Compare commits

...

3 Commits

Author SHA1 Message Date
James Campbell
a17a68a07e Code cleanup 2026-08-01 12:34:46 -04:00
James Campbell
f29d8c953c Format code with black 2026-08-01 12:19:46 -04:00
James Campbell
b0ad9610be Add a CLAUDE.md file 2026-08-01 12:19:30 -04:00
17 changed files with 516 additions and 319 deletions

57
CLAUDE.md Normal file
View File

@@ -0,0 +1,57 @@
# CLAUDE.md
Guidance for working in this repo. **`SPEC.md` is the source of truth for engine
behavior and the level format** — read it before changing mechanics, and update
it (and `README.md`) when behavior changes. This file only covers workflow and
conventions not found there.
## Commands
The working virtualenv is `.venv` (Python 3.13). Prefix commands with
`.venv/bin/` or activate it first.
```bash
.venv/bin/python main.py # play every level in ./levels
.venv/bin/python main.py levels/foo.yaml # play specific level(s), in order
.venv/bin/python main.py --debug # force the debug view (see SPEC §14)
.venv/bin/python -m pytest # run the test suite
.venv/bin/black main.py game tests tools # format (do this before committing)
```
The test suite runs headless — `conftest.py` sets the SDL dummy video/audio
drivers, so no window opens and nothing needs a display.
## Conventions
- **Formatting: `black`** (default 88-col line length, no config file). Run it on
any Python you touch. It's in `requirements-dev.txt`.
- The game must stay **fully playable with zero art assets**: every sprite falls
back to a labeled colored rectangle (see `game/assets.py`). Don't add a hard
dependency on any PNG.
- **Physics/collision live in true coordinates.** `Level.render_offset` is a
draw-only translation for the debug overscan — never fold it into gameplay
math.
- All tunables (physics, timing, colors, the placeholder table) belong in
`game/settings.py`, not scattered as literals.
## Adding a trap type
Subclass `Trap` in `game/traps.py`, implement only the hooks it needs
(`solid_rects`, `hazard_rects`, `carriers`, `update`, `draw`, `reset`, …), and
register the class in the `TRAP_TYPES` factory map. Nothing else in the engine
needs to change. See SPEC §1113 for the hook contract and the trap catalog.
## Tests
`tests/` mirrors the behavior described in `SPEC.md` (physics, every trap and
trigger, mounting, crush/shove, game-flow states). When you change behavior, add
or update the matching test — several existing tests are regression guards for
specific bugs (corner-warp, fits-under-no-crush, ride-into-wall-no-crush), so
read the test's comment before altering its expectations.
## Module map
See SPEC §15 for the full layout. In short: `game/game.py` (loop/states/HUD),
`game/level.py` (YAML → geometry + traps), `game/player.py` (physics &
collision), `game/traps.py` (triggers + all trap types), `game/settings.py`
(tunables), `game/assets.py` (sprites + placeholders).

View File

@@ -257,7 +257,9 @@ class Game:
if self.state == "dying" and self.death_rect is not None: if self.state == "dying" and self.death_rect is not None:
# The traps stay drawn in their moment-of-death state; the player is # The traps stay drawn in their moment-of-death state; the player is
# replaced by a corpse sprite where they fell. # replaced by a corpse sprite where they fell.
sprite = self.assets.get("player_dead", self.death_rect.w, self.death_rect.h) sprite = self.assets.get(
"player_dead", self.death_rect.w, self.death_rect.h
)
self.world.blit(sprite, self.death_rect.move(self.level.render_offset)) self.world.blit(sprite, self.death_rect.move(self.level.render_offset))
else: else:
self.player.draw(self.world, self.assets) self.player.draw(self.world, self.assets)
@@ -278,11 +280,15 @@ class Game:
if self.state == "won_level": if self.state == "won_level":
plural = "death" if self.level_deaths == 1 else "deaths" plural = "death" if self.level_deaths == 1 else "deaths"
self._draw_win_splash("LEVEL COMPLETE — phone charged!", self._draw_win_splash(
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level") "LEVEL COMPLETE — phone charged!",
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level",
)
elif self.state == "won_all": elif self.state == "won_all":
self._draw_win_splash("YOU MADE IT! Phone fully charged.", self._draw_win_splash(
f"All levels cleared with {self.deaths} deaths. ENTER to replay") "YOU MADE IT! Phone fully charged.",
f"All levels cleared with {self.deaths} deaths. ENTER to replay",
)
if self.state == "fading": # fade-in only; fade-out returned early above if self.state == "fading": # fade-in only; fade-out returned early above
p = min(1.0, self.fade_timer / S.FADE_TIME) p = min(1.0, self.fade_timer / S.FADE_TIME)
@@ -300,16 +306,22 @@ class Game:
r = max(2, rect.h // 6) r = max(2, rect.h // 6)
inset = max(2, rect.h // 10) inset = max(2, rect.h // 10)
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r) pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
pygame.draw.rect(surf, (28, 30, 40), pygame.draw.rect(
rect.inflate(-inset, -inset), border_radius=r) surf, (28, 30, 40), rect.inflate(-inset, -inset), border_radius=r
)
fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac))) fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac)))
if fill_frac > 0: if fill_frac > 0:
fw = max(inset, fw) fw = max(inset, fw)
pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2), pygame.draw.rect(
border_radius=r) surf,
col,
(rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
border_radius=r,
)
nub_w, nub_h = max(3, rect.h // 4), rect.h // 2 nub_w, nub_h = max(3, rect.h // 4), rect.h // 2
pygame.draw.rect(surf, (60, 60, 72), pygame.draw.rect(
(rect.right, rect.centery - nub_h // 2, nub_w, nub_h)) surf, (60, 60, 72), (rect.right, rect.centery - nub_h // 2, nub_w, nub_h)
)
def _draw_hud(self, hide_battery=False): def _draw_hud(self, hide_battery=False):
w = self.screen.get_width() w = self.screen.get_width()
@@ -322,17 +334,24 @@ class Game:
bar_w, bar_h = 180, 20 bar_w, bar_h = 180, 20
bx, by = 12, (HUD_H - bar_h) // 2 bx, by = 12, (HUD_H - bar_h) // 2
if not hide_battery: if not hide_battery:
time_frac = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0 time_frac = (
max(0.0, self.battery / self.level.battery)
if self.level.battery
else 0.0
)
visual = time_frac * (self.level.battery_pct / 100.0) visual = time_frac * (self.level.battery_pct / 100.0)
col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80) col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80)
self._draw_battery(self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col) self._draw_battery(
self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col
)
secs = max(0.0, self.battery) secs = max(0.0, self.battery)
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD) label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
self.screen.blit(label, (bx + bar_w + 16, by - 1)) self.screen.blit(label, (bx + bar_w + 16, by - 1))
deaths = self.hud_font.render( deaths = self.hud_font.render(
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD) f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD
)
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1)) self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD) name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
@@ -367,9 +386,12 @@ class Game:
home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2) home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2)
target = self._hero_battery() target = self._hero_battery()
pos = home.lerp(pygame.Vector2(target.center), move_p) pos = home.lerp(pygame.Vector2(target.center), move_p)
rect = pygame.Rect(0, 0, rect = pygame.Rect(
0,
0,
round(bar_w + (target.w - bar_w) * move_p), round(bar_w + (target.w - bar_w) * move_p),
round(bar_h + (target.h - bar_h) * move_p)) round(bar_h + (target.h - bar_h) * move_p),
)
rect.center = (round(pos.x), round(pos.y)) rect.center = (round(pos.x), round(pos.y))
self._draw_battery(self.screen, rect, fill, (90, 220, 120)) self._draw_battery(self.screen, rect, fill, (90, 220, 120))
@@ -391,7 +413,10 @@ class Game:
s = self.hud_font.render(subtitle, True, S.COLOR_HUD) s = self.hud_font.render(subtitle, True, S.COLOR_HUD)
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34))) self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
def discover_levels(levels_dir): def discover_levels(levels_dir):
paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) + paths = sorted(
glob.glob(os.path.join(levels_dir, "*.yml"))) glob.glob(os.path.join(levels_dir, "*.yaml"))
+ glob.glob(os.path.join(levels_dir, "*.yml"))
)
return paths return paths

View File

@@ -39,8 +39,6 @@ class Level:
rows = data.get("map", "").splitlines() rows = data.get("map", "").splitlines()
# Strip a leading blank line from block-scalar formatting, keep shape. # Strip a leading blank line from block-scalar formatting, keep shape.
rows = [r for r in rows if r.strip("\n") != "" or True]
rows = [r.rstrip("\n") for r in rows]
if rows and rows[0] == "": if rows and rows[0] == "":
rows = rows[1:] rows = rows[1:]
@@ -144,8 +142,10 @@ class Level:
for rect in self.oneways: for rect in self.oneways:
surface.blit(oneway_img, rect.move(ox, oy)) surface.blit(oneway_img, rect.move(ox, oy))
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h), surface.blit(
self.goal_rect.move(ox, oy)) assets.get("goal", self.goal_rect.w, self.goal_rect.h),
self.goal_rect.move(ox, oy),
)
for tr in self.traps: for tr in self.traps:
tr.render(surface, assets) tr.render(surface, assets)
@@ -173,10 +173,12 @@ class Level:
# Shade the off-screen overscan so the real play area reads clearly. # Shade the off-screen overscan so the real play area reads clearly.
view = pygame.Rect(ox, oy, self.width, self.height) view = pygame.Rect(ox, oy, self.width, self.height)
shade = (10, 12, 20, 130) shade = (10, 12, 20, 130)
for band in (pygame.Rect(0, 0, sw, oy), # above for band in (
pygame.Rect(0, 0, sw, oy), # above
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
pygame.Rect(0, oy, ox, self.height), # left pygame.Rect(0, oy, ox, self.height), # left
pygame.Rect(view.right, oy, sw - view.right, self.height)): # right pygame.Rect(view.right, oy, sw - view.right, self.height),
): # right
if band.w > 0 and band.h > 0: if band.w > 0 and band.h > 0:
overlay.fill(shade, band) overlay.fill(shade, band)
@@ -190,14 +192,16 @@ class Level:
pygame.draw.line(overlay, line, (0, k * t), (sw, k * t)) pygame.draw.line(overlay, line, (0, k * t), (sw, k * t))
label = (150, 162, 190) label = (150, 162, 190)
for k in range(sw // t): for k in range(sw // t):
overlay.blit(Level._grid_font.render(str(k - mx), True, label), overlay.blit(
(k * t + 2, 1)) Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1)
)
for k in range(sh // t): for k in range(sh // t):
overlay.blit(Level._grid_font.render(str(k - my), True, label), overlay.blit(
(1, k * t + 1)) Level._grid_font.render(str(k - my), True, label), (1, k * t + 1)
)
# Outline the actual runtime viewport (the level's true bounds). # Outline the actual runtime viewport (the level's true bounds).
if (mx or my): if mx or my:
pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2) pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2)
surface.blit(overlay, (0, 0)) surface.blit(overlay, (0, 0))

View File

@@ -42,7 +42,6 @@ class Player:
self.drop_through_timer = 0.0 self.drop_through_timer = 0.0
self.was_jump_held = False self.was_jump_held = False
self.crushed = False self.crushed = False
self.carry = (0.0, 0.0)
self._sync_rect() self._sync_rect()
def _sync_rect(self): def _sync_rect(self):
@@ -118,9 +117,13 @@ class Player:
pinned = any(p.colliderect(s) for s in solids) pinned = any(p.colliderect(s) for s in solids)
for r, dx, dy in movers: for r, dx, dy in movers:
if dy > 0 and bd and pinned and r.colliderect(up): # squished down onto floor if (
dy > 0 and bd and pinned and r.colliderect(up)
): # squished down onto floor
return True return True
if dy < 0 and bu and pinned and r.colliderect(down): # squished up into ceiling if (
dy < 0 and bu and pinned and r.colliderect(down)
): # squished up into ceiling
return True return True
if dx > 0 and br and r.colliderect(left): # pushed right into a wall if dx > 0 and br and r.colliderect(left): # pushed right into a wall
return True return True
@@ -137,15 +140,15 @@ class Player:
def _ride_platforms(self): def _ride_platforms(self):
# If standing on a moving platform, inherit its motion this frame. # If standing on a moving platform, inherit its motion this frame.
self.carry = (0.0, 0.0)
for rect, dx, dy in self.level.carriers(): for rect, dx, dy in self.level.carriers():
if (abs(self.rect.bottom - rect.top) <= 3 if (
abs(self.rect.bottom - rect.top) <= 3
and self.rect.right > rect.left + 1 and self.rect.right > rect.left + 1
and self.rect.left < rect.right - 1): and self.rect.left < rect.right - 1
):
self.fx += dx self.fx += dx
self.fy += dy self.fy += dy
self._sync_rect() self._sync_rect()
self.carry = (dx, dy)
break break
def _horizontal(self, dt, inp): def _horizontal(self, dt, inp):
@@ -188,8 +191,11 @@ class Player:
self.drop_through_timer = 0.12 self.drop_through_timer = 0.12
# jump (buffered + coyote) # jump (buffered + coyote)
if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \ if (
and self.drop_through_timer <= 0: self.jump_buffer > 0
and (self.on_ground or self.coyote > 0)
and self.drop_through_timer <= 0
):
self.vy = -S.JUMP_SPEED self.vy = -S.JUMP_SPEED
self.on_ground = False self.on_ground = False
self.coyote = 0.0 self.coyote = 0.0
@@ -266,5 +272,6 @@ class Player:
# --- rendering ----------------------------------------------------------- # --- rendering -----------------------------------------------------------
def draw(self, surface, assets): def draw(self, surface, assets):
ox, oy = self.level.render_offset ox, oy = self.level.render_offset
surface.blit(assets.get("player", self.rect.w, self.rect.h), surface.blit(
self.rect.move(ox, oy)) assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
)

View File

@@ -24,7 +24,7 @@ JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
# --- Gameplay ---------------------------------------------------------------- # --- Gameplay ----------------------------------------------------------------
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
DEFAULT_BATTERY_PCT = 12.0 # how full the battery *looks* at the start (visual only; DEFAULT_BATTERY_PCT = 12.0 # how full the battery *looks* at the start (visual only;
# the phone is dying, so the bar reads near-empty) # the phone is dying, so the bar reads near-empty)
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
@@ -32,7 +32,6 @@ FADE_TIME = 0.3 # seconds for each half of the fade-to-black transitio
# --- Colors (placeholder rendering) ------------------------------------------ # --- Colors (placeholder rendering) ------------------------------------------
COLOR_BG = (24, 26, 38) COLOR_BG = (24, 26, 38)
COLOR_HUD = (235, 235, 245) COLOR_HUD = (235, 235, 245)
COLOR_HUD_WARN = (240, 90, 90)
# Fallback colors + labels for placeholder rectangles, keyed by sprite name. # Fallback colors + labels for placeholder rectangles, keyed by sprite name.
PLACEHOLDERS = { PLACEHOLDERS = {

View File

@@ -15,12 +15,12 @@ Nothing else in the engine needs to change.
import pygame import pygame
from . import settings
# --- helpers ----------------------------------------------------------------- # --- helpers -----------------------------------------------------------------
_DIRS = { _DIRS = {
"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "up": (0, -1),
"down": (0, 1),
"left": (-1, 0),
"right": (1, 0),
} }
@@ -36,6 +36,7 @@ _DIRS = {
# trigger: { timer: { interval: 1.2, up_time: 0.7 } } # trigger: { timer: { interval: 1.2, up_time: 0.7 } }
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] } # trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
class _Always: class _Always:
def evaluate(self, trap, game, dt): def evaluate(self, trap, game, dt):
return True return True
@@ -46,6 +47,7 @@ class _Always:
class _Within: class _Within:
"""Player within N tiles of the trap centre (Euclidean radius).""" """Player within N tiles of the trap centre (Euclidean radius)."""
def __init__(self, n): def __init__(self, n):
self.n = float(n) self.n = float(n)
@@ -69,6 +71,7 @@ class _Directional:
left/right (same rows) or *directly* above/below (same columns). left/right (same rows) or *directly* above/below (same columns).
inclusive: specify if the trap tile itself counts in the given direction. inclusive: specify if the trap tile itself counts in the given direction.
""" """
def __init__(self, direction, rng, aligned, inclusive): def __init__(self, direction, rng, aligned, inclusive):
self.dir = direction self.dir = direction
self.rng = None if rng is None else float(rng) self.rng = None if rng is None else float(rng)
@@ -113,6 +116,7 @@ class _Directional:
class _Timer: class _Timer:
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``.""" """Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
def __init__(self, interval, up_time): def __init__(self, interval, up_time):
self.interval = float(interval) self.interval = float(interval)
self.up_time = float(up_time) self.up_time = float(up_time)
@@ -155,7 +159,9 @@ def make_condition(spec):
if spec == "always": if spec == "always":
return _Always() return _Always()
if not isinstance(spec, dict): if not isinstance(spec, dict):
raise ValueError(f"trigger must be 'always' or a condition object, got {spec!r}") raise ValueError(
f"trigger must be 'always' or a condition object, got {spec!r}"
)
if "all" in spec: if "all" in spec:
return _All([make_condition(s) for s in spec["all"]]) return _All([make_condition(s) for s in spec["all"]])
if "any" in spec: if "any" in spec:
@@ -166,7 +172,12 @@ def make_condition(spec):
if "within" in spec: if "within" in spec:
return _Within(spec["within"]) return _Within(spec["within"])
if "dir" in spec: if "dir" in spec:
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False), spec.get("inclusive", False)) return _Directional(
spec["dir"],
spec.get("range"),
spec.get("aligned", False),
spec.get("inclusive", False),
)
raise ValueError(f"unrecognized trigger condition: {spec!r}") raise ValueError(f"unrecognized trigger condition: {spec!r}")
@@ -274,8 +285,9 @@ class Trap:
def _follow(self, parent): def _follow(self, parent):
ox, oy = self._mount_off ox, oy = self._mount_off
pr = parent.current_rect() pr = parent.current_rect()
self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy, self.base_rect = pygame.Rect(
self.base_rect.w, self.base_rect.h) pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h
)
def tick(self, dt, game): def tick(self, dt, game):
self.update(dt, game) self.update(dt, game)
@@ -354,6 +366,7 @@ class Spike(Trap):
direction: which edge of the cell the spike sits on (up/down/left/right). direction: which edge of the cell the spike sits on (up/down/left/right).
See the trigger-condition docs at the top of this module. See the trigger-condition docs at the top of this module.
""" """
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
self.direction = spec.get("direction", "up") self.direction = spec.get("direction", "up")
@@ -415,6 +428,7 @@ class Block(Trap):
start extending and be clear for ``release`` seconds to start start extending and be clear for ``release`` seconds to start
retracting — hysteresis that stops boundary jitter. retracting — hysteresis that stops boundary jitter.
""" """
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
t = self.tile t = self.tile
@@ -433,10 +447,18 @@ class Block(Trap):
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
self.crumble_delay = float(spec.get("crumble_delay", 0.4)) self.crumble_delay = float(spec.get("crumble_delay", 0.4))
self.respawn = float(spec.get("respawn", 2.5)) self.respawn = float(spec.get("respawn", 2.5))
self.sprite = spec.get("sprite", self.sprite = spec.get(
"spike_block" if self.deadly else "sprite",
"fake_block" if self.fake else (
"crumble_block" if self.crumble else "moving_block") "spike_block"
if self.deadly
else (
"fake_block"
if self.fake
else "crumble_block" if self.crumble else "moving_block"
)
),
)
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
# `home` (default): sense the trigger from the resting cell, so the block # `home` (default): sense the trigger from the resting cell, so the block
# moving away can't toggle its own trigger (no jitter). `current`: sense # moving away can't toggle its own trigger (no jitter). `current`: sense
@@ -463,8 +485,9 @@ class Block(Trap):
# `home` sensing tracks the resting cell as it rides along the parent. # `home` sensing tracks the resting cell as it rides along the parent.
ox, oy = self._origin ox, oy = self._origin
offx, offy = self._mount_off offx, offy = self._mount_off
self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy), self.base_rect = pygame.Rect(
self.tile, self.tile) round(ox + offx), round(oy + offy), self.tile, self.tile
)
def reset(self): def reset(self):
self._reset_trigger() self._reset_trigger()
@@ -505,8 +528,9 @@ class Block(Trap):
self.emerge_kill = False self.emerge_kill = False
r = self._rect() r = self._rect()
p = game.player.rect p = game.player.rect
on_top = (abs(p.bottom - r.top) <= 4 on_top = (
and p.right > r.left + 2 and p.left < r.right - 2) abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2
)
if self.cstate == "solid": if self.cstate == "solid":
if on_top: if on_top:
self.cstate = "crumbling" self.cstate = "crumbling"
@@ -590,8 +614,7 @@ class Block(Trap):
def _rect(self): def _rect(self):
ox, oy = self._origin ox, oy = self._origin
return pygame.Rect(round(ox + self.x), round(oy + self.y), return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile)
self.tile, self.tile)
def current_rect(self): def current_rect(self):
return self._rect() return self._rect()
@@ -654,6 +677,7 @@ class ArrowShooter(Trap):
direction: up/down/left/right. speed: px/s. interval: seconds between shots. direction: up/down/left/right. speed: px/s. interval: seconds between shots.
trigger: only fires while the condition holds (default ``always``). trigger: only fires while the condition holds (default ``always``).
""" """
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
self.direction = spec.get("direction", "left") self.direction = spec.get("direction", "left")
@@ -676,10 +700,14 @@ class ArrowShooter(Trap):
rect = pygame.Rect(0, 0, w, h) rect = pygame.Rect(0, 0, w, h)
rect.center = r.center rect.center = r.center
# nudge the arrow to the emitting edge # nudge the arrow to the emitting edge
if dx == -1: rect.right = r.left if dx == -1:
elif dx == 1: rect.left = r.right rect.right = r.left
elif dy == -1: rect.bottom = r.top elif dx == 1:
elif dy == 1: rect.top = r.bottom rect.left = r.right
elif dy == -1:
rect.bottom = r.top
elif dy == 1:
rect.top = r.bottom
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed)) self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
def update(self, dt, game): def update(self, dt, game):
@@ -704,8 +732,9 @@ class ArrowShooter(Trap):
return [a.rect for a in self.arrows] return [a.rect for a in self.arrows]
def draw(self, surface, assets): def draw(self, surface, assets):
surface.blit(assets.get("arrow_shooter", self.tile, self.tile), surface.blit(
self._rt(self.base_rect)) assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect)
)
for a in self.arrows: for a in self.arrows:
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect)) surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
@@ -717,6 +746,7 @@ class Warp(Trap):
destination and fades away, so the (otherwise invisible) teleport reads on destination and fades away, so the (otherwise invisible) teleport reads on
screen. The level's ``debug`` flag tints it (and draws a line to its screen. The level's ``debug`` flag tints it (and draws a line to its
destination) while designing.""" destination) while designing."""
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out _PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn # Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
@@ -763,8 +793,9 @@ class Warp(Trap):
c.render(surface, assets) c.render(surface, assets)
def _dest_rect(self): def _dest_rect(self):
return pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile, return pygame.Rect(
self.tile, self.tile) self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile
)
def _draw_aura(self, surface, rect): def _draw_aura(self, surface, rect):
# An expanding, fading glow centred on the cell. As the pulse decays the # An expanding, fading glow centred on the cell. As the pulse decays the
@@ -788,8 +819,13 @@ class Warp(Trap):
if self.level.debug: if self.level.debug:
self._debug_tint(surface, (210, 80, 235), alpha=90) self._debug_tint(surface, (210, 80, 235), alpha=90)
dest = self._dest_rect() dest = self._dest_rect()
pygame.draw.line(surface, (210, 80, 235), pygame.draw.line(
self._rt(self.base_rect).center, self._rt(dest).center, 1) surface,
(210, 80, 235),
self._rt(self.base_rect).center,
self._rt(dest).center,
1,
)
self._debug_tint(surface, (210, 80, 235), dest, 45) self._debug_tint(surface, (210, 80, 235), dest, 45)
@@ -799,6 +835,7 @@ class PhaseBlock(Trap):
a solid obstacle over ``fade`` seconds (and fades back out when the trigger a solid obstacle over ``fade`` seconds (and fades back out when the trigger
releases). If the player is standing in the cell the instant it *starts* releases). If the player is standing in the cell the instant it *starts*
appearing, they're killed.""" appearing, they're killed."""
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
self.fade = float(spec.get("fade", 0.3)) self.fade = float(spec.get("fade", 0.3))
@@ -820,8 +857,11 @@ class PhaseBlock(Trap):
self.emerge_kill = False self.emerge_kill = False
active = self.triggered(game, dt) active = self.triggered(game, dt)
if active: if active:
if self.alpha == 0.0 and not self.solid \ if (
and game.player.rect.colliderect(self.base_rect): self.alpha == 0.0
and not self.solid
and game.player.rect.colliderect(self.base_rect)
):
# Forming into the player. If they're only clipping an edge, shove # Forming into the player. If they're only clipping an edge, shove
# them clear and let the block solidify behind them; only if it's # them clear and let the block solidify behind them; only if it's
# forming through their middle — or the shove would squish them # forming through their middle — or the shove would squish them
@@ -891,8 +931,9 @@ class PhaseBlock(Trap):
self._debug_tint(surface, (120, 210, 240), alpha=45) self._debug_tint(surface, (120, 210, 240), alpha=45)
return return
img = assets.get("phase_block", self.tile, self.tile).copy() img = assets.get("phase_block", self.tile, self.tile).copy()
img.fill((255, 255, 255, int(255 * self.alpha)), img.fill(
special_flags=pygame.BLEND_RGBA_MULT) (255, 255, 255, int(255 * self.alpha)), special_flags=pygame.BLEND_RGBA_MULT
)
surface.blit(img, self._rt(self.base_rect)) surface.blit(img, self._rt(self.base_rect))

View File

@@ -1,2 +1,3 @@
-r requirements.txt -r requirements.txt
pytest>=7.0 pytest>=7.0
black

View File

@@ -113,8 +113,13 @@ def test_slider_once_no_jitter_and_retracts():
def test_sense_current_rides_down(): def test_sense_current_rides_down():
# A dropper (sense=current) commits to the bottom and holds while ridden. # A dropper (sense=current) commits to the bottom and holds while ridden.
b = block(at=[21, 9], move=[0, 4], speed=220, sense="current", b = block(
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]}) at=[21, 9],
move=[0, 4],
speed=220,
sense="current",
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]},
)
# player standing on top of it # player standing on top of it
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29) p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
g = FakeGame(p) g = FakeGame(p)
@@ -131,7 +136,7 @@ def test_carriers_reports_motion():
b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120) b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120)
g = FakeGame(pygame.Rect(0, 0, 4, 4)) g = FakeGame(pygame.Rect(0, 0, 4, 4))
b.update(1 / 60, g) b.update(1 / 60, g)
(rect, dx, dy), = b.carriers() ((rect, dx, dy),) = b.carriers()
assert dx != 0 and dy == 0 # moving horizontally assert dx != 0 and dy == 0 # moving horizontally
@@ -147,8 +152,12 @@ def test_expand_line():
def test_expand_grid_with_spacing(): def test_expand_grid_with_spacing():
ats = [s["at"] for s in expand_spec( ats = [
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})] s["at"]
for s in expand_spec(
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]}
)
]
assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]] assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]]
# count/spacing are stripped from each expanded spec # count/spacing are stripped from each expanded spec
for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}): for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}):
@@ -173,4 +182,9 @@ traps:
blocks = [t for t in lvl.traps if isinstance(t, Block)] blocks = [t for t in lvl.traps if isinstance(t, Block)]
assert len(blocks) == 4 assert len(blocks) == 4
assert all(b.deadly for b in blocks) assert all(b.deadly for b in blocks)
assert sorted(b.current_rect().x for b in blocks) == [2 * 32, 3 * 32, 4 * 32, 5 * 32] assert sorted(b.current_rect().x for b in blocks) == [
2 * 32,
3 * 32,
4 * 32,
5 * 32,
]

View File

@@ -5,7 +5,6 @@ from conftest import step, run, place, hold, DT
from game import settings as S from game import settings as S
from game.player import InputState from game.player import InputState
SIMPLE = """ SIMPLE = """
name: My Level name: My Level
tile_size: 32 tile_size: 32
@@ -62,6 +61,7 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
a.write_text(SIMPLE) a.write_text(SIMPLE)
b.write_text(SIMPLE.replace("My Level", "Two")) b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets")) g = Game([str(a), str(b)], str(tmp_path / "noassets"))
def die(): def die():
@@ -69,7 +69,9 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
while g.state == "dying": while g.state == "dying":
step(g) step(g)
die(); die(); die() die()
die()
die()
assert g.level_deaths == 3 and g.deaths == 3 assert g.level_deaths == 3 and g.deaths == 3
g._advance() g._advance()
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
@@ -95,6 +97,7 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
a.write_text(SIMPLE) a.write_text(SIMPLE)
b.write_text(SIMPLE.replace("My Level", "Two")) b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets")) g = Game([str(a), str(b)], str(tmp_path / "noassets"))
g.state = "won_level" g.state = "won_level"
g._start_fade(g._advance) g._start_fade(g._advance)
@@ -116,26 +119,39 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch): def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch):
a = tmp_path / "a.yaml" a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml" b = tmp_path / "b.yaml"
a.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") # 3 rows a.write_text(
b.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n") # 4 rows "name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
) # 3 rows
b.write_text(
"name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n"
) # 4 rows
from game.game import Game from game.game import Game
calls = [] calls = []
orig = pygame.display.set_mode orig = pygame.display.set_mode
monkeypatch.setattr(pygame.display, "set_mode", monkeypatch.setattr(
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k)) pygame.display,
"set_mode",
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k),
)
g = Game([str(a), str(b)], str(tmp_path / "noassets")) g = Game([str(a), str(b)], str(tmp_path / "noassets"))
g._advance(); g._replay() g._advance()
g._replay()
assert len(calls) == 1 # never recreated the window assert len(calls) == 1 # never recreated the window
# sized to the tallest level (4 rows + HUD) # sized to the tallest level (4 rows + HUD)
from game.game import HUD_H from game.game import HUD_H
assert g.win_h == 4 * 32 + HUD_H assert g.win_h == 4 * 32 + HUD_H
def test_debug_view_adds_overscan_margin(tmp_path): def test_debug_view_adds_overscan_margin(tmp_path):
from game.game import Game, HUD_H from game.game import Game, HUD_H
from game import settings as S from game import settings as S
p = tmp_path / "d.yaml" p = tmp_path / "d.yaml"
p.write_text("name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n") p.write_text(
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n"
)
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
m = S.DEBUG_VIEW_MARGIN * 32 m = S.DEBUG_VIEW_MARGIN * 32
assert m > 0 assert m > 0
@@ -148,6 +164,7 @@ def test_debug_view_adds_overscan_margin(tmp_path):
def test_no_overscan_without_debug(tmp_path): def test_no_overscan_without_debug(tmp_path):
from game.game import Game from game.game import Game
p = tmp_path / "n.yaml" p = tmp_path / "n.yaml"
p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
@@ -169,14 +186,17 @@ def test_cli_debug_forces_all_levels(make_game):
def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path): def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
from game.game import Game from game.game import Game
p = tmp_path / "hot.yaml" p = tmp_path / "hot.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
assert g.level.name == "A" and len(g.level.traps) == 0 assert g.level.name == "A" and len(g.level.traps) == 0
d0 = g.deaths d0 = g.deaths
# edit the file on disk, then F5 # edit the file on disk, then F5
p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n" p.write_text(
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n") "name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n"
)
g._reload_level() g._reload_level()
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
while g.state == "dying": while g.state == "dying":
@@ -187,6 +207,7 @@ def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
def test_f5_bad_yaml_does_not_crash(tmp_path, capsys): def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
from game.game import Game from game.game import Game
p = tmp_path / "bad.yaml" p = tmp_path / "bad.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
@@ -201,7 +222,10 @@ def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
def test_debug_grid_adds_pixels(make_level): def test_debug_grid_adds_pixels(make_level):
import pygame import pygame
from game.assets import AssetStore from game.assets import AssetStore
lvl = make_level("name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n")
lvl = make_level(
"name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n"
)
a = AssetStore("noassets") a = AssetStore("noassets")
def painted(dbg): def painted(dbg):
@@ -209,6 +233,11 @@ def test_debug_grid_adds_pixels(make_level):
w = pygame.Surface((lvl.width, lvl.height)) w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0)) w.fill((0, 0, 0))
lvl.draw(w, a) lvl.draw(w, a)
return sum(1 for y in range(lvl.height) for x in range(lvl.width) return sum(
if w.get_at((x, y))[:3] != (0, 0, 0)) 1
for y in range(lvl.height)
for x in range(lvl.width)
if w.get_at((x, y))[:3] != (0, 0, 0)
)
assert painted(True) > painted(False) # grid + labels add ink assert painted(True) > painted(False) # grid + labels add ink

View File

@@ -3,7 +3,6 @@
from conftest import step, DT from conftest import step, DT
from game.traps import Block, Spike from game.traps import Block, Spike
PLATFORM_WITH_SPIKE = """ PLATFORM_WITH_SPIKE = """
name: t name: t
tile_size: 32 tile_size: 32
@@ -74,8 +73,10 @@ traps:
for m in plat.mounts: for m in plat.mounts:
assert m._mounted and m.deadly assert m._mounted and m.deadly
# each deadly mount tracks the platform at its offset and is lethal # each deadly mount tracks the platform at its offset and is lethal
exp = (plat.current_rect().x + m._mount_off[0], exp = (
plat.current_rect().y + m._mount_off[1]) plat.current_rect().x + m._mount_off[0],
plat.current_rect().y + m._mount_off[1],
)
assert (m.current_rect().x, m.current_rect().y) == exp assert (m.current_rect().x, m.current_rect().y) == exp
assert m.hazard_rects() assert m.hazard_rects()

View File

@@ -55,6 +55,7 @@ def test_variable_jump_height(make_game):
step(g, hold(jump_pressed=(i == 0), jump_held=held)) step(g, hold(jump_pressed=(i == 0), jump_held=held))
hi = min(hi, g.player.rect.bottom) hi = min(hi, g.player.rect.bottom)
return ground - hi return ground - hi
assert peak(60) > peak(1) + 8 assert peak(60) > peak(1) + 8

View File

@@ -2,7 +2,7 @@
import hashlib import hashlib
import pygame import pygame
from conftest import (FakeGame, step, run, place, hold, DT) from conftest import FakeGame, step, run, place, hold, DT
from game.assets import AssetStore from game.assets import AssetStore
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
@@ -62,6 +62,7 @@ def test_spike_sprite_rotates_per_direction(tmp_path):
def h(s): def h(s):
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest() return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
assert h(up) != h(down) # rotation actually happened assert h(up) != h(down) # rotation actually happened
assert left.get_size() == (16, 32) and up.get_size() == (32, 16) assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
@@ -343,9 +344,11 @@ def test_debug_overscan_reveals_offmap_trap(make_level):
# In debug the play surface is enlarged and shifted (render_offset) so a trap # In debug the play surface is enlarged and shifted (render_offset) so a trap
# placed just off the map is drawn into the overscan instead of being clipped. # placed just off the map is drawn into the overscan instead of being clipped.
from game import settings as S from game import settings as S
lvl = make_level( lvl = make_level(
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n" "name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n"
"traps:\n - type: block\n at: [5, 1]\n deadly: true\n") # col 5 = off-map "traps:\n - type: block\n at: [5, 1]\n deadly: true\n"
) # col 5 = off-map
a = AssetStore("noassets") a = AssetStore("noassets")
m = S.DEBUG_VIEW_MARGIN * lvl.tile m = S.DEBUG_VIEW_MARGIN * lvl.tile
lvl.render_offset = (m, m) lvl.render_offset = (m, m)
@@ -384,8 +387,11 @@ traps:
w = pygame.Surface((lvl.width, lvl.height)) w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0)) w.fill((0, 0, 0))
sp.render(w, a) sp.render(w, a)
return any(w.get_at((x, y))[:3] != (0, 0, 0) return any(
for y in range(32, 96) for x in range(32, 96)) w.get_at((x, y))[:3] != (0, 0, 0)
for y in range(32, 96)
for x in range(32, 96)
)
assert painted(False) is False # invisible in play assert painted(False) is False # invisible in play
assert painted(True) is True # revealed in debug assert painted(True) is True # revealed in debug

View File

@@ -6,9 +6,12 @@ from game.traps import make_condition
def ev(spec, px, py, w=20, h=28, dt=0.0): def ev(spec, px, py, w=20, h=28, dt=0.0):
trap = type("T", (), {"tile": 32, trap = type(
"sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)})() "T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)}
)()
return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt) return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt)
# trap centre = (116, 116); left100 right132 top100 bottom132 # trap centre = (116, 116); left100 right132 top100 bottom132
@@ -67,8 +70,9 @@ def test_all_and_any():
def test_timer_cycles(): def test_timer_cycles():
c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}}) c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}})
trap = type("T", (), {"tile": 32, trap = type(
"sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)})() "T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)}
)()
g = FakeGame(pygame.Rect(0, 0, 4, 4)) g = FakeGame(pygame.Rect(0, 0, 4, 4))
states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)] states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)]
assert states[0] is False # starts in the "off" interval assert states[0] is False # starts in the "off" interval
@@ -77,6 +81,7 @@ def test_timer_cycles():
def test_bad_condition_raises(): def test_bad_condition_raises():
import pytest import pytest
with pytest.raises(ValueError): with pytest.raises(ValueError):
make_condition({"nope": 1}) make_condition({"nope": 1})

View File

@@ -55,8 +55,9 @@ def draw_player_dead(s):
for ex in (11, 17): # X eyes for ex in (11, 17): # X eyes
pygame.draw.line(s, (225, 85, 85), (ex, 9), (ex + 4, 14), 2) pygame.draw.line(s, (225, 85, 85), (ex, 9), (ex + 4, 14), 2)
pygame.draw.line(s, (225, 85, 85), (ex + 4, 9), (ex, 14), 2) pygame.draw.line(s, (225, 85, 85), (ex + 4, 9), (ex, 14), 2)
pygame.draw.lines(s, (200, 205, 215), False, pygame.draw.lines(
[(9, 8), (14, 13), (12, 18), (21, 21)], 1) # crack s, (200, 205, 215), False, [(9, 8), (14, 13), (12, 18), (21, 21)], 1
) # crack
def draw_goal(s): def draw_goal(s):
@@ -95,15 +96,18 @@ def draw_patrol_block(s):
pygame.draw.rect(s, (80, 65, 125), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (80, 65, 125), (0, 0, TILE, TILE), 2)
pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight
for off in (0, 9): # motion chevrons for off in (0, 9): # motion chevrons
pygame.draw.lines(s, (92, 76, 142), False, pygame.draw.lines(
[(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2) s, (92, 76, 142), False, [(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2
)
def draw_crumble_block(s): def draw_crumble_block(s):
s.fill((166, 136, 96)) s.fill((166, 136, 96))
pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2)
cr = (112, 86, 56) cr = (112, 86, 56)
pygame.draw.lines(s, cr, False, [(8, 2), (12, 10), (9, 16), (14, 24), (12, TILE)], 1) pygame.draw.lines(
s, cr, False, [(8, 2), (12, 10), (9, 16), (14, 24), (12, TILE)], 1
)
pygame.draw.lines(s, cr, False, [(22, 3), (19, 9), (24, 15), (20, 22)], 1) pygame.draw.lines(s, cr, False, [(22, 3), (19, 9), (24, 15), (20, 22)], 1)
pygame.draw.line(s, cr, (2, 14), (9, 16), 1) pygame.draw.line(s, cr, (2, 14), (9, 16), 1)
pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1) pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1)
@@ -129,7 +133,11 @@ def draw_spike_block(s):
[(2, 2), (13, 6), (6, 13)], # up-left [(2, 2), (13, 6), (6, 13)], # up-left
[(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right [(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right
[(2, TILE - 2), (13, TILE - 6), (6, TILE - 13)], # down-left [(2, TILE - 2), (13, TILE - 6), (6, TILE - 13)], # down-left
[(TILE - 2, TILE - 2), (TILE - 13, TILE - 6), (TILE - 6, TILE - 13)], # down-right [
(TILE - 2, TILE - 2),
(TILE - 13, TILE - 6),
(TILE - 6, TILE - 13),
], # down-right
] ]
for t in tris: for t in tris:
pygame.draw.polygon(s, base, t) pygame.draw.polygon(s, base, t)
@@ -144,8 +152,7 @@ def draw_phase_block(s):
pygame.draw.rect(s, (120, 210, 240), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (120, 210, 240), (0, 0, TILE, TILE), 2)
pygame.draw.line(s, (150, 230, 255), (4, 4), (TILE - 5, TILE - 5), 1) pygame.draw.line(s, (150, 230, 255), (4, 4), (TILE - 5, TILE - 5), 1)
pygame.draw.line(s, (150, 230, 255), (TILE - 5, 4), (4, TILE - 5), 1) pygame.draw.line(s, (150, 230, 255), (TILE - 5, 4), (4, TILE - 5), 1)
pygame.draw.polygon(s, (150, 230, 255), pygame.draw.polygon(s, (150, 230, 255), [(16, 4), (28, 16), (16, 28), (4, 16)], 1)
[(16, 4), (28, 16), (16, 28), (4, 16)], 1)
def draw_arrow(s): def draw_arrow(s):