Format code with black

This commit is contained in:
James Campbell
2026-08-01 12:19:46 -04:00
parent b0ad9610be
commit f29d8c953c
16 changed files with 459 additions and 311 deletions

View File

@@ -257,7 +257,9 @@ class Game:
if self.state == "dying" and self.death_rect is not None:
# The traps stay drawn in their moment-of-death state; the player is
# 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))
else:
self.player.draw(self.world, self.assets)
@@ -278,11 +280,15 @@ class Game:
if self.state == "won_level":
plural = "death" if self.level_deaths == 1 else "deaths"
self._draw_win_splash("LEVEL COMPLETE — phone charged!",
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level")
self._draw_win_splash(
"LEVEL COMPLETE — phone charged!",
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level",
)
elif self.state == "won_all":
self._draw_win_splash("YOU MADE IT! Phone fully charged.",
f"All levels cleared with {self.deaths} deaths. ENTER to replay")
self._draw_win_splash(
"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
p = min(1.0, self.fade_timer / S.FADE_TIME)
@@ -300,16 +306,22 @@ class Game:
r = max(2, rect.h // 6)
inset = max(2, rect.h // 10)
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
pygame.draw.rect(surf, (28, 30, 40),
rect.inflate(-inset, -inset), border_radius=r)
pygame.draw.rect(
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)))
if fill_frac > 0:
fw = max(inset, fw)
pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
border_radius=r)
pygame.draw.rect(
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
pygame.draw.rect(surf, (60, 60, 72),
(rect.right, rect.centery - nub_h // 2, nub_w, nub_h))
pygame.draw.rect(
surf, (60, 60, 72), (rect.right, rect.centery - nub_h // 2, nub_w, nub_h)
)
def _draw_hud(self, hide_battery=False):
w = self.screen.get_width()
@@ -322,17 +334,24 @@ class Game:
bar_w, bar_h = 180, 20
bx, by = 12, (HUD_H - bar_h) // 2
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)
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)
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
self.screen.blit(label, (bx + bar_w + 16, by - 1))
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))
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)
target = self._hero_battery()
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_h + (target.h - bar_h) * move_p))
round(bar_h + (target.h - bar_h) * move_p),
)
rect.center = (round(pos.x), round(pos.y))
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)
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
def discover_levels(levels_dir):
paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) +
glob.glob(os.path.join(levels_dir, "*.yml")))
paths = sorted(
glob.glob(os.path.join(levels_dir, "*.yaml"))
+ glob.glob(os.path.join(levels_dir, "*.yml"))
)
return paths

View File

@@ -144,8 +144,10 @@ class Level:
for rect in self.oneways:
surface.blit(oneway_img, rect.move(ox, oy))
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h),
self.goal_rect.move(ox, oy))
surface.blit(
assets.get("goal", self.goal_rect.w, self.goal_rect.h),
self.goal_rect.move(ox, oy),
)
for tr in self.traps:
tr.render(surface, assets)
@@ -173,10 +175,12 @@ class Level:
# Shade the off-screen overscan so the real play area reads clearly.
view = pygame.Rect(ox, oy, self.width, self.height)
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, 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:
overlay.fill(shade, band)
@@ -190,14 +194,16 @@ class Level:
pygame.draw.line(overlay, line, (0, k * t), (sw, k * t))
label = (150, 162, 190)
for k in range(sw // t):
overlay.blit(Level._grid_font.render(str(k - mx), True, label),
(k * t + 2, 1))
overlay.blit(
Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1)
)
for k in range(sh // t):
overlay.blit(Level._grid_font.render(str(k - my), True, label),
(1, k * t + 1))
overlay.blit(
Level._grid_font.render(str(k - my), True, label), (1, k * t + 1)
)
# 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)
surface.blit(overlay, (0, 0))

View File

@@ -118,9 +118,13 @@ class Player:
pinned = any(p.colliderect(s) for s in solids)
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
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
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
return True
@@ -139,9 +143,11 @@ class Player:
# 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():
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.left < rect.right - 1):
and self.rect.left < rect.right - 1
):
self.fx += dx
self.fy += dy
self._sync_rect()
@@ -188,8 +194,11 @@ class Player:
self.drop_through_timer = 0.12
# jump (buffered + coyote)
if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \
and self.drop_through_timer <= 0:
if (
self.jump_buffer > 0
and (self.on_ground or self.coyote > 0)
and self.drop_through_timer <= 0
):
self.vy = -S.JUMP_SPEED
self.on_ground = False
self.coyote = 0.0
@@ -266,5 +275,6 @@ class Player:
# --- rendering -----------------------------------------------------------
def draw(self, surface, assets):
ox, oy = self.level.render_offset
surface.blit(assets.get("player", self.rect.w, self.rect.h),
self.rect.move(ox, oy))
surface.blit(
assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
)

View File

@@ -17,10 +17,12 @@ import pygame
from . import settings
# --- helpers -----------------------------------------------------------------
_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 +38,7 @@ _DIRS = {
# trigger: { timer: { interval: 1.2, up_time: 0.7 } }
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
class _Always:
def evaluate(self, trap, game, dt):
return True
@@ -46,6 +49,7 @@ class _Always:
class _Within:
"""Player within N tiles of the trap centre (Euclidean radius)."""
def __init__(self, n):
self.n = float(n)
@@ -69,6 +73,7 @@ class _Directional:
left/right (same rows) or *directly* above/below (same columns).
inclusive: specify if the trap tile itself counts in the given direction.
"""
def __init__(self, direction, rng, aligned, inclusive):
self.dir = direction
self.rng = None if rng is None else float(rng)
@@ -113,6 +118,7 @@ class _Directional:
class _Timer:
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
def __init__(self, interval, up_time):
self.interval = float(interval)
self.up_time = float(up_time)
@@ -155,7 +161,9 @@ def make_condition(spec):
if spec == "always":
return _Always()
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:
return _All([make_condition(s) for s in spec["all"]])
if "any" in spec:
@@ -166,7 +174,12 @@ def make_condition(spec):
if "within" in spec:
return _Within(spec["within"])
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}")
@@ -274,8 +287,9 @@ class Trap:
def _follow(self, parent):
ox, oy = self._mount_off
pr = parent.current_rect()
self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy,
self.base_rect.w, self.base_rect.h)
self.base_rect = pygame.Rect(
pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h
)
def tick(self, dt, game):
self.update(dt, game)
@@ -354,6 +368,7 @@ class Spike(Trap):
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.
"""
def __init__(self, spec, level):
super().__init__(spec, level)
self.direction = spec.get("direction", "up")
@@ -415,6 +430,7 @@ class Block(Trap):
start extending and be clear for ``release`` seconds to start
retracting — hysteresis that stops boundary jitter.
"""
def __init__(self, spec, level):
super().__init__(spec, level)
t = self.tile
@@ -433,10 +449,18 @@ class Block(Trap):
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
self.crumble_delay = float(spec.get("crumble_delay", 0.4))
self.respawn = float(spec.get("respawn", 2.5))
self.sprite = spec.get("sprite",
"spike_block" if self.deadly else
"fake_block" if self.fake else
"crumble_block" if self.crumble else "moving_block")
self.sprite = spec.get(
"sprite",
(
"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
# `home` (default): sense the trigger from the resting cell, so the block
# moving away can't toggle its own trigger (no jitter). `current`: sense
@@ -463,8 +487,9 @@ class Block(Trap):
# `home` sensing tracks the resting cell as it rides along the parent.
ox, oy = self._origin
offx, offy = self._mount_off
self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy),
self.tile, self.tile)
self.base_rect = pygame.Rect(
round(ox + offx), round(oy + offy), self.tile, self.tile
)
def reset(self):
self._reset_trigger()
@@ -505,8 +530,9 @@ class Block(Trap):
self.emerge_kill = False
r = self._rect()
p = game.player.rect
on_top = (abs(p.bottom - r.top) <= 4
and p.right > r.left + 2 and p.left < r.right - 2)
on_top = (
abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2
)
if self.cstate == "solid":
if on_top:
self.cstate = "crumbling"
@@ -590,8 +616,7 @@ class Block(Trap):
def _rect(self):
ox, oy = self._origin
return pygame.Rect(round(ox + self.x), round(oy + self.y),
self.tile, self.tile)
return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile)
def current_rect(self):
return self._rect()
@@ -654,6 +679,7 @@ class ArrowShooter(Trap):
direction: up/down/left/right. speed: px/s. interval: seconds between shots.
trigger: only fires while the condition holds (default ``always``).
"""
def __init__(self, spec, level):
super().__init__(spec, level)
self.direction = spec.get("direction", "left")
@@ -676,10 +702,14 @@ class ArrowShooter(Trap):
rect = pygame.Rect(0, 0, w, h)
rect.center = r.center
# nudge the arrow to the emitting edge
if dx == -1: rect.right = r.left
elif dx == 1: rect.left = r.right
elif dy == -1: rect.bottom = r.top
elif dy == 1: rect.top = r.bottom
if dx == -1:
rect.right = r.left
elif dx == 1:
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))
def update(self, dt, game):
@@ -704,8 +734,9 @@ class ArrowShooter(Trap):
return [a.rect for a in self.arrows]
def draw(self, surface, assets):
surface.blit(assets.get("arrow_shooter", self.tile, self.tile),
self._rt(self.base_rect))
surface.blit(
assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect)
)
for a in self.arrows:
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
@@ -717,6 +748,7 @@ class Warp(Trap):
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
destination) while designing."""
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
@@ -763,8 +795,9 @@ class Warp(Trap):
c.render(surface, assets)
def _dest_rect(self):
return pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
self.tile, self.tile)
return pygame.Rect(
self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile
)
def _draw_aura(self, surface, rect):
# An expanding, fading glow centred on the cell. As the pulse decays the
@@ -788,8 +821,13 @@ class Warp(Trap):
if self.level.debug:
self._debug_tint(surface, (210, 80, 235), alpha=90)
dest = self._dest_rect()
pygame.draw.line(surface, (210, 80, 235),
self._rt(self.base_rect).center, self._rt(dest).center, 1)
pygame.draw.line(
surface,
(210, 80, 235),
self._rt(self.base_rect).center,
self._rt(dest).center,
1,
)
self._debug_tint(surface, (210, 80, 235), dest, 45)
@@ -799,6 +837,7 @@ class PhaseBlock(Trap):
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*
appearing, they're killed."""
def __init__(self, spec, level):
super().__init__(spec, level)
self.fade = float(spec.get("fade", 0.3))
@@ -820,8 +859,11 @@ class PhaseBlock(Trap):
self.emerge_kill = False
active = self.triggered(game, dt)
if active:
if self.alpha == 0.0 and not self.solid \
and game.player.rect.colliderect(self.base_rect):
if (
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
# them clear and let the block solidify behind them; only if it's
# forming through their middle — or the shove would squish them
@@ -891,8 +933,9 @@ class PhaseBlock(Trap):
self._debug_tint(surface, (120, 210, 240), alpha=45)
return
img = assets.get("phase_block", self.tile, self.tile).copy()
img.fill((255, 255, 255, int(255 * self.alpha)),
special_flags=pygame.BLEND_RGBA_MULT)
img.fill(
(255, 255, 255, int(255 * self.alpha)), special_flags=pygame.BLEND_RGBA_MULT
)
surface.blit(img, self._rt(self.base_rect))

View File

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

View File

@@ -113,8 +113,13 @@ def test_slider_once_no_jitter_and_retracts():
def test_sense_current_rides_down():
# A dropper (sense=current) commits to the bottom and holds while ridden.
b = block(at=[21, 9], move=[0, 4], speed=220, sense="current",
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]})
b = block(
at=[21, 9],
move=[0, 4],
speed=220,
sense="current",
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]},
)
# player standing on top of it
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
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)
g = FakeGame(pygame.Rect(0, 0, 4, 4))
b.update(1 / 60, g)
(rect, dx, dy), = b.carriers()
((rect, dx, dy),) = b.carriers()
assert dx != 0 and dy == 0 # moving horizontally
@@ -147,8 +152,12 @@ def test_expand_line():
def test_expand_grid_with_spacing():
ats = [s["at"] for s in expand_spec(
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})]
ats = [
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]]
# count/spacing are stripped from each expanded spec
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)]
assert len(blocks) == 4
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.player import InputState
SIMPLE = """
name: My Level
tile_size: 32
@@ -62,6 +61,7 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
a.write_text(SIMPLE)
b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
def die():
@@ -69,7 +69,9 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
while g.state == "dying":
step(g)
die(); die(); die()
die()
die()
die()
assert g.level_deaths == 3 and g.deaths == 3
g._advance()
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)
b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
g.state = "won_level"
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):
a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml"
a.write_text("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
a.write_text(
"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
calls = []
orig = pygame.display.set_mode
monkeypatch.setattr(pygame.display, "set_mode",
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k))
monkeypatch.setattr(
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._advance(); g._replay()
g._advance()
g._replay()
assert len(calls) == 1 # never recreated the window
# sized to the tallest level (4 rows + HUD)
from game.game import HUD_H
assert g.win_h == 4 * 32 + HUD_H
def test_debug_view_adds_overscan_margin(tmp_path):
from game.game import Game, HUD_H
from game import settings as S
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"))
m = S.DEBUG_VIEW_MARGIN * 32
assert m > 0
@@ -148,6 +164,7 @@ def test_debug_view_adds_overscan_margin(tmp_path):
def test_no_overscan_without_debug(tmp_path):
from game.game import Game
p = tmp_path / "n.yaml"
p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
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):
from game.game import Game
p = tmp_path / "hot.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets"))
assert g.level.name == "A" and len(g.level.traps) == 0
d0 = g.deaths
# edit the file on disk, then F5
p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n")
p.write_text(
"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()
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
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):
from game.game import Game
p = tmp_path / "bad.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
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):
import pygame
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")
def painted(dbg):
@@ -209,6 +233,11 @@ def test_debug_grid_adds_pixels(make_level):
w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0))
lvl.draw(w, a)
return sum(1 for y in range(lvl.height) for x in range(lvl.width)
if w.get_at((x, y))[:3] != (0, 0, 0))
return sum(
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

View File

@@ -3,7 +3,6 @@
from conftest import step, DT
from game.traps import Block, Spike
PLATFORM_WITH_SPIKE = """
name: t
tile_size: 32
@@ -74,8 +73,10 @@ traps:
for m in plat.mounts:
assert m._mounted and m.deadly
# each deadly mount tracks the platform at its offset and is lethal
exp = (plat.current_rect().x + m._mount_off[0],
plat.current_rect().y + m._mount_off[1])
exp = (
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.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))
hi = min(hi, g.player.rect.bottom)
return ground - hi
assert peak(60) > peak(1) + 8

View File

@@ -2,7 +2,7 @@
import hashlib
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.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
@@ -62,6 +62,7 @@ def test_spike_sprite_rotates_per_direction(tmp_path):
def h(s):
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
assert h(up) != h(down) # rotation actually happened
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
# placed just off the map is drawn into the overscan instead of being clipped.
from game import settings as S
lvl = make_level(
"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")
m = S.DEBUG_VIEW_MARGIN * lvl.tile
lvl.render_offset = (m, m)
@@ -384,8 +387,11 @@ traps:
w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0))
sp.render(w, a)
return any(w.get_at((x, y))[:3] != (0, 0, 0)
for y in range(32, 96) for x in range(32, 96))
return any(
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(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):
trap = type("T", (), {"tile": 32,
"sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)})()
trap = type(
"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)
# trap centre = (116, 116); left100 right132 top100 bottom132
@@ -67,8 +70,9 @@ def test_all_and_any():
def test_timer_cycles():
c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}})
trap = type("T", (), {"tile": 32,
"sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)})()
trap = type(
"T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)}
)()
g = FakeGame(pygame.Rect(0, 0, 4, 4))
states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)]
assert states[0] is False # starts in the "off" interval
@@ -77,6 +81,7 @@ def test_timer_cycles():
def test_bad_condition_raises():
import pytest
with pytest.raises(ValueError):
make_condition({"nope": 1})

View File

@@ -55,8 +55,9 @@ def draw_player_dead(s):
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 + 4, 9), (ex, 14), 2)
pygame.draw.lines(s, (200, 205, 215), False,
[(9, 8), (14, 13), (12, 18), (21, 21)], 1) # crack
pygame.draw.lines(
s, (200, 205, 215), False, [(9, 8), (14, 13), (12, 18), (21, 21)], 1
) # crack
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, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight
for off in (0, 9): # motion chevrons
pygame.draw.lines(s, (92, 76, 142), False,
[(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2)
pygame.draw.lines(
s, (92, 76, 142), False, [(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2
)
def draw_crumble_block(s):
s.fill((166, 136, 96))
pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2)
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.line(s, cr, (2, 14), (9, 16), 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
[(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right
[(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:
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.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.polygon(s, (150, 230, 255),
[(16, 4), (28, 16), (16, 28), (4, 16)], 1)
pygame.draw.polygon(s, (150, 230, 255), [(16, 4), (28, 16), (16, 28), (4, 16)], 1)
def draw_arrow(s):