Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions games/ld60/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ All five share a design:
`bytearray` shared with the tilemap, sprites are fixed lists, and the walls get
their outline from an autotile pass at build time. (Spiral Tunnel's edges are
slanted tiles instead, like the prototype's, since the tunnel moves sideways as
it goes down.)
it goes down, and rocks are scattered through its walls at random so you can
see them move.)
- Catch the Robots draws its maze as two tilemaps over the same cells, moved
together: the dark fill, then the bright outlines over it. A `TUNNEL` layer
can land several rows of a tile on one LED, and the last row drawn wins, so in
a single tilemap the fill could hide a one-pixel outline.
- You are scored on the time you take to finish. Faster than your best saves a
new best (`vs2.saves`, under the name `best`).

Expand All @@ -70,7 +75,8 @@ All five share a design:
## Art and sound

Art is generated, not drawn by hand: run `python3 tools/ld60_assets/make_art.py`
to rewrite every `images/tiles.png` and `images/sprites.png`, and
to rewrite every `images/tiles.png` and `images/sprites.png` (and Catch the
Robots' `images/outlines.png`), and
`python3 tools/ld60_assets/make_sounds.py` (needs ffmpeg) for the MP3s in
`sounds/`. In-game text is in the tutorial's `steel8x8.png`. The title and end
screens put their heading in the system's `rainbow8x8.png` and the lines below
Expand All @@ -79,4 +85,6 @@ it in the 4x6 `tinyfont_white.png`, with PRESS A in `tinyfont_red.png`.
Each `menu.png` is a moment of the game itself:
`python3 tools/ld60_assets/capture_menu_icons.py` plays every game headless from
a fixed seed and saves the 64 x 30 window round the player, drawn with the
game's own tiles and sprites. Run it again after changing the art.
game's own tiles and sprites. Spiral Tunnel's walls are further apart than the
icon is wide, so rows of empty tunnel are cut from between them. Run it again
after changing the art.
12 changes: 10 additions & 2 deletions games/ld60/catch_the_robots/code/catch_the_robots.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,16 @@ def build(self):

self.solid = bytearray(COLS * ROWS)
self.cells = bytearray([NO_TILE] * (COLS * ROWS))
# The walls are two tilemaps over the same cells, moved together: the
# dark fill, then the bright outlines over it. On a TUNNEL layer several
# rows of a tile can land on one LED, and the last one drawn wins, so in
# one tilemap the fill could hide an outline drawn in the same tile.
self.maze = self.world.tilemap(
"tiles.png", columns=COLS, rows=ROWS, cells=self.cells,
view_width=W, view_height=VIEW_H)
self.outlines = self.world.tilemap(
"outlines.png", columns=COLS, rows=ROWS, cells=self.cells,
view_width=W, view_height=VIEW_H)
open_cells = self.dig_maze()
self.autotile()

Expand Down Expand Up @@ -352,8 +359,9 @@ def place(self):
player."""
cam_x = int(self.cam_turn + 0.5) % W
cam_y = int(self.cam_depth + 0.5)
self.maze.x = ANCHOR - cam_x
self.maze.view_y = cam_y
for walls in (self.maze, self.outlines):
walls.x = ANCHOR - cam_x
walls.view_y = cam_y
self.show(self.player.sprite, self.player.x, self.player.y, cam_x, cam_y)
for robot in self.robots:
if robot.alive:
Expand Down
2 changes: 2 additions & 0 deletions games/ld60/catch_the_robots/images/__images__.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ palettegroups:
main:
- strip: tiles.png
frames: 16
- strip: outlines.png
frames: 16
- strip: sprites.png
frames: 7
text:
Expand Down
Binary file added games/ld60/catch_the_robots/images/outlines.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified games/ld60/catch_the_robots/images/tiles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified games/ld60/defender/menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions games/ld60/spiral_tunnel/code/spiral_tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
# on the right edge.
LEFT_AT = (0, 0, 1, 3, 6, 10)
RIGHT_AT = (0, 0, 15, 17, 20, 24)
# Then ROCKS frames of solid wall with rocks in it, scattered through the wall at
# random so that you can see the walls move as they scroll by.
ROCKS_AT = 29
ROCKS = 6

CRUISE = 1.2 # columns per tick the player flies round at
STEER_X = 0.75 # extra or less with right and left
Expand Down Expand Up @@ -205,7 +209,8 @@ def carve_tunnel(self):

def draw_tunnel(self):
"""Fill the map with wall and cut the tunnel out of it, with a slanted
tile where each edge crosses from one row to the next."""
tile where each edge crosses from one row to the next. Half the solid
wall gets a rock in it."""
for row in range(ROWS):
left = self.left[row]
width = self.span[row]
Expand All @@ -220,7 +225,8 @@ def draw_tunnel(self):
elif k - width < right_shift:
tile = RIGHT_AT[right_shift] + k - width
else:
tile = 0
rock = randrange(2 * ROCKS)
tile = ROCKS_AT + rock if rock < ROCKS else 0
self.cells[row * COLS + col] = tile

def wall_at(self, px, py):
Expand Down
2 changes: 1 addition & 1 deletion games/ld60/spiral_tunnel/images/__images__.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
palettegroups:
main:
- strip: tiles.png
frames: 29
frames: 35
- strip: sprites.png
frames: 8
text:
Expand Down
Binary file modified games/ld60/spiral_tunnel/images/tiles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified games/ld60/spiral_tunnel/menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 28 additions & 2 deletions tests/test_ld60_games.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ def setUp_again(self):
self.setUp()

def test_what_is_drawn_as_wall_is_wall_and_what_is_drawn_open_is_open(self):
from games.ld60.spiral_tunnel.code.spiral_tunnel import COLS, NO_TILE, ROWS
from games.ld60.spiral_tunnel.code.spiral_tunnel import (
COLS, NO_TILE, ROCKS, ROCKS_AT, ROWS)

game = self.start_game()
for row in range(ROWS - 1):
Expand All @@ -327,9 +328,19 @@ def test_what_is_drawn_as_wall_is_wall_and_what_is_drawn_open_is_open(self):
centre = (col * 8 + 4, row * 8 + 4)
if tile == NO_TILE:
self.assertFalse(game.wall_at(*centre), (row, col))
elif tile == 0:
elif tile == 0 or ROCKS_AT <= tile < ROCKS_AT + ROCKS:
self.assertTrue(game.wall_at(*centre), (row, col))

def test_rocks_are_scattered_through_the_solid_wall(self):
from games.ld60.spiral_tunnel.code.spiral_tunnel import ROCKS, ROCKS_AT

game = self.start_game()
rocks = [tile for tile in game.cells if ROCKS_AT <= tile < ROCKS_AT + ROCKS]
solid = len(rocks) + sum(1 for tile in game.cells if tile == 0)
# About half the solid wall, with every kind of rock in it.
self.assertTrue(0.35 * solid < len(rocks) < 0.65 * solid, (len(rocks), solid))
self.assertEqual(set(rocks), set(range(ROCKS_AT, ROCKS_AT + ROCKS)))

def test_the_slanted_edge_is_a_straight_line_across_the_tiles_it_spans(self):
game = self.start_game()
row = 5
Expand Down Expand Up @@ -677,6 +688,21 @@ def make_room(self, game):
robot.alive = False
robot.sprite.hide()

def test_the_wall_outlines_are_drawn_over_the_fill_from_the_same_cells(self):
game = self.start_game()
self.assertIs(game.outlines.cells, game.maze.cells)
# Fill, then outlines, then the robots and the player over both.
drawables = game.world._drawables
self.assertLess(drawables.index(game.maze), drawables.index(game.outlines))
self.assertLess(drawables.index(game.outlines), drawables.index(game.player.sprite))
# They scroll together.
game.player.y = 20 * 8
game.player.row = 20
self.run_ticks(20, director.JOY_RIGHT)
self.assertGreater(game.maze.view_y, 0)
self.assertEqual((game.outlines.x, game.outlines.view_y),
(game.maze.x, game.maze.view_y))

def test_the_camera_scrolls_down_the_maze(self):
game = self.start_game()
self.assertEqual(game.maze.view_y, 0)
Expand Down
93 changes: 67 additions & 26 deletions tools/ld60_assets/capture_menu_icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@

Each ``menu.png`` is a real moment of play. The game runs headless, the way
tests/test_ld60_games.py drives it, from a fixed seed with scripted input, and
its world layer (the tilemap and every visible sprite, in the game's own
``images/tiles.png`` and ``images/sprites.png``) is painted flat in game
coordinates: x round the disc, y in from the rim. The 64 x 30 window round the
player is the icon, turned the way the player sees it. The four games played at
the top of the disc store their pictures turned through 180 degrees (see
make_art.py), so each picture is turned back; Rim Runner is played at the bottom,
where x and y run the other way, so its whole window is turned instead.
its world layer (its tilemaps and every visible sprite, in the game's own
images) is painted flat in game coordinates: x round the disc, y in from the
rim. The 64 x 30 window round the player is the icon, turned the way the player
sees it. The four games played at the top of the disc store their pictures
turned through 180 degrees (see make_art.py), so each picture is turned back;
Rim Runner is played at the bottom, where x and y run the other way, so its
whole window is turned instead.

Run make_art.py first when the art changes. Needs Pillow.
"""
Expand Down Expand Up @@ -64,6 +64,7 @@ def sleep_ms(ms):
DEPTH = 256 # rows in from the rim
TILE = 8
ICON_W, ICON_H = 64, 30
ABOVE = 64 # rows of the map a whole_map painting shows above the view
EMPTY_TILE = 255


Expand All @@ -90,8 +91,9 @@ def run_left_now_and_then(game, tick):
return director.JOY_LEFT if (tick // 40) % 2 else 0


def fly_down_to_the_planet(game, tick):
return director.JOY_DOWN if tick <= 25 else 0
def fly_down_and_into_the_aliens(game, tick):
"""Defender: down toward the planet, then left, at a crowd of aliens."""
return director.JOY_DOWN if tick <= 25 else director.JOY_LEFT


def stand_still(game, tick):
Expand All @@ -101,17 +103,21 @@ def stand_still(game, tick):
# Which moment each icon shows: the seed, how many ticks into the game, the input,
# the player's sprite, and how far below the player-centred window it is taken
# (positive moves it toward the centre of the disc). Picked by eye for a frame
# with the player, an enemy and the level all in view.
# with the player, enemies and the level all in view. Spiral Tunnel's walls are
# further apart than the icon is wide, so its window paints the whole map (not
# only what the camera shows) and is ``squeeze``d: rows of empty tunnel are cut
# from between the walls, so they come closer together and the icon looks full.
MOMENTS = {
"spiral_tunnel": dict(seed="s2", ticks=84, play=dive_down_the_tunnel,
player=lambda game: game.ship, shift=-6, top=True),
"spiral_tunnel": dict(seed="s4", ticks=86, play=dive_down_the_tunnel,
player=lambda game: game.ship, shift=-11, top=True,
whole_map=True, squeeze=14),
"catch_the_robots": dict(seed="s0", ticks=200, play=stand_still,
player=lambda game: game.player.sprite, shift=0, top=True),
"rim_runner": dict(seed="s2", ticks=64, play=run_left_now_and_then,
player=lambda game: game.runner, shift=8, top=False),
"dodge_em": dict(seed="s0", ticks=120, play=stand_still,
player=lambda game: game.player.sprite, shift=0, top=True),
"defender": dict(seed="s3", ticks=136, play=fly_down_to_the_planet,
"defender": dict(seed="s4", ticks=76, play=fly_down_and_into_the_aliens,
player=lambda game: game.ship, shift=3, top=True),
}

Expand Down Expand Up @@ -163,33 +169,44 @@ def frames_of(path):
for index in range(strip.width // TILE)]


def paint_world(name, game):
def paint_world(name, game, whole_map=False):
"""The world layer, flat, in game coordinates, each picture turned back
upright from how a top-of-the-disc game stores it."""
images = {strip: frames_of(GAMES / name / "images" / strip)
for strip in ("tiles.png", "sprites.png")}
canvas = Image.new("RGBA", (W, DEPTH), (0, 0, 0, 255))
upright from how a top-of-the-disc game stores it. It is ABOVE rows taller
than the view, at the top: with ``whole_map`` the tilemaps are painted there
too, beyond the edge of the view the game draws, and anywhere below it."""
images = {}

def frames_for(image):
if image.name not in images:
images[image.name] = frames_of(GAMES / name / "images" / image.name)
return images[image.name]

canvas = Image.new("RGBA", (W, ABOVE + DEPTH), (0, 0, 0, 255))

def put(picture, x, y):
x = int(x) % W
for wrap in (0, -W):
canvas.paste(picture, (x + wrap, int(y)), picture)
canvas.paste(picture, (x + wrap, ABOVE + int(y)), picture)

for tilemap in game.world.tilemaps:
if not tilemap.visible:
continue
frames = images[tilemap.image.name]
frames = frames_for(tilemap.image)
view_x, view_y = tilemap.view_x, tilemap.view_y
view_right = view_x + tilemap._view_width
view_bottom = view_y + tilemap._view_height
if whole_map:
view_top, view_bottom = view_y - ABOVE, DEPTH
else:
view_top = view_y
for row in range(tilemap.rows):
for col in range(tilemap.columns):
frame = tilemap.cells[row * tilemap.columns + col]
if frame == EMPTY_TILE:
continue
# Only the part of the tile inside the view is drawn.
sx, sy = col * TILE, row * TILE
left, top = max(sx, view_x), max(sy, view_y)
left, top = max(sx, view_x), max(sy, view_top)
right, bottom = min(sx + TILE, view_right), min(sy + TILE, view_bottom)
if left >= right or top >= bottom:
continue
Expand All @@ -200,7 +217,7 @@ def put(picture, x, y):
for sprite in game.world.sprites:
if not sprite.visible:
continue
picture = images[sprite.image.name][sprite.frame].rotate(180)
picture = frames_for(sprite.image)[sprite.frame].rotate(180)
if sprite.flip_x:
picture = picture.transpose(Image.FLIP_LEFT_RIGHT)
if sprite.flip_y:
Expand All @@ -209,17 +226,41 @@ def put(picture, x, y):
return canvas


def squeeze(window, rows):
"""Cut ``rows`` rows that are empty all the way across out of the window,
the ones nearest its middle, then trim it to the icon's height. Nothing
drawn is cut: the walls on either side just come closer together."""
width, height = window.size
pixels = window.load()
middle = height // 2
empty = [y for y in range(height)
if all(pixels[x, y][:3] == (0, 0, 0) for x in range(width))]
cut = set(sorted(empty, key=lambda y: abs(y - middle))[:rows])
kept = [y for y in range(height) if y not in cut]
out = Image.new("RGBA", (width, len(kept)))
for new_y, y in enumerate(kept):
out.paste(window.crop((0, y, width, y + 1)), (0, new_y))
extra = len(kept) - ICON_H
return out.crop((0, extra // 2, width, extra // 2 + ICON_H))


def icon(name, moment):
game = play(name, moment)
world = paint_world(name, game)
whole_map = moment.get("whole_map", False)
world = paint_world(name, game, whole_map)
player = moment["player"](game)
rows = moment.get("squeeze", 0)
height = ICON_H + rows
left = int(player.x) + TILE // 2 - ICON_W // 2
top = max(int(player.y) + TILE // 2 - ICON_H // 2 + moment["shift"], 0)
top = ABOVE + int(player.y) + TILE // 2 - height // 2 + moment["shift"]
top = max(top, 0 if whole_map else ABOVE)
# Three copies side by side, so a window across x = 0 needs no special case.
round_the_disc = Image.new("RGBA", (W * 3, DEPTH))
round_the_disc = Image.new("RGBA", (W * 3, ABOVE + DEPTH))
for copy in range(3):
round_the_disc.paste(world, (copy * W, 0))
window = round_the_disc.crop((W + left, top, W + left + ICON_W, top + ICON_H))
window = round_the_disc.crop((W + left, top, W + left + ICON_W, top + height))
if rows:
window = squeeze(window, rows)
if not moment["top"]:
window = window.rotate(180)
return window.convert("RGB")
Expand Down
Loading
Loading