From 59e4bcce1c1f8496253c3ad250cacf756a45442a Mon Sep 17 00:00:00 2001 From: James Campbell Date: Wed, 22 Jul 2026 11:56:21 -0400 Subject: [PATCH] Debug view improvements, warp animation * Add a visible frame one tile beyond the normal screen in debug mode. * Fix the display of motion paths for mounted traps in debug mode. * Add an animation for the warp trap. --- SPEC.md | 20 ++++++++--- game/game.py | 32 +++++++++++++---- game/level.py | 67 ++++++++++++++++++++++++++--------- game/player.py | 4 ++- game/settings.py | 4 +++ game/traps.py | 86 ++++++++++++++++++++++++++++++++++++++------- tests/test_flow.py | 24 +++++++++++++ tests/test_traps.py | 53 ++++++++++++++++++++++++++++ 8 files changed, 249 insertions(+), 41 deletions(-) diff --git a/SPEC.md b/SPEC.md index 28b383d..b3fcd09 100644 --- a/SPEC.md +++ b/SPEC.md @@ -40,8 +40,9 @@ labeled colored rectangle, so the game is fully playable with zero assets. cell's pixel rect is `(col*tile, row*tile, tile, tile)`. - Y increases downward (screen convention). - The window is sized **once** at startup to fit the tallest and widest level - (plus a HUD band of 46 px on top; minimum window width 480 px). It is never - resized afterward — recreating the display mid-session makes the OS window + (plus a HUD band of 46 px on top; minimum window width 480 px; plus the debug + overscan margin of §14 for any level shown in debug). It is never resized + afterward — recreating the display mid-session makes the OS window flicker/close. Each level's play area is a surface of its own pixel size, centered within the fixed window below the HUD; smaller levels are letterboxed with a dark background. @@ -388,7 +389,10 @@ blocks: `type: block, invisible: true, count: [w, h]`.) ### 13.4 `warp` An invisible tile that teleports the player to `to: [col, row]` on contact (zeroing velocity). It re-arms only once the player has left the tile, so it -fires once per entry. +fires once per entry. On activation a brief **aura** flashes at both the source +and destination tiles and fades out over ~0.35 s (expanding/thinning rings drawn +procedurally, no sprite), so the teleport reads on screen even though the tile +itself is invisible. ### 13.5 `phase_block` Invisible and intangible until its `trigger` fires, then it fades into a solid @@ -414,7 +418,15 @@ levels) reveals everything normally hidden: - fake blocks tinted, invisible walls tinted, warps tinted with a line to their destination, dormant phase blocks ghosted, not-yet-sprung spikes marked, - crumbled-away blocks shown as ghosts, -- moving blocks draw their travel path (waypoints + connecting lines). +- moving blocks draw their travel path (waypoints + connecting lines), +- an **overscan margin** of `DEBUG_VIEW_MARGIN` tiles (default 1) is revealed on + every side, so geometry just off the map — e.g. an invisible catch-wall a step + off-screen — is visible instead of clipped. The play surface is enlarged and + all drawing is shifted into it (`Level.render_offset`, a draw-only translation; + physics stays in true coordinates). The off-map region is shaded, its grid + labels run negative / past the map, and the **true runtime viewport is + outlined** so it's clear what's actually on screen during play. The window is + sized at startup to include this margin for any level shown in debug. --- diff --git a/game/game.py b/game/game.py index 7360438..afd6f42 100644 --- a/game/game.py +++ b/game/game.py @@ -35,8 +35,10 @@ class Game: # Size the window ONCE to fit the largest level, then never resize it — # calling set_mode again mid-session recreates the window (it flickers / - # looks like it closes). Smaller levels are centred within it. - dims = [(lv.width, lv.height) for lv in map(Level, self.level_paths)] + # looks like it closes). Smaller levels are centred within it. The debug + # view reveals a margin of overscan around a level, so budget for it here + # (for any level that will be shown in debug) or it'd be clipped. + dims = [self._draw_size(lv) for lv in map(Level, self.level_paths)] self.win_w = max(max(w for w, _ in dims), MIN_W) self.win_h = max(h for _, h in dims) + HUD_H self.screen = pygame.display.set_mode((self.win_w, self.win_h)) @@ -44,16 +46,34 @@ class Game: self._load_current() # --- level management ---------------------------------------------------- + def _debug_margin(self, level): + """Pixels of overscan drawn around a level in the debug view (0 if it + won't be shown in debug).""" + if not (self.force_debug or level.debug): + return 0 + return S.DEBUG_VIEW_MARGIN * level.tile + + def _draw_size(self, level): + """The pixel size the level's play surface occupies, including any debug + overscan margin on both sides.""" + m = self._debug_margin(level) + return level.width + 2 * m, level.height + 2 * m + def _load_current(self): self.level = Level(self.level_paths[self.index]) if self.force_debug: self.level.debug = True # CLI flag overrides the per-level setting self.player = Player(self.level) self.battery = self.level.battery - self.world = pygame.Surface((self.level.width, self.level.height)) + # In debug, grow the play surface by a margin and shift all drawing into + # it (render_offset) so geometry just off the map is visible. + m = self._debug_margin(self.level) + self.level.render_offset = (m, m) + world_w, world_h = self._draw_size(self.level) + self.world = pygame.Surface((world_w, world_h)) # Centre the play area in the fixed window, below the HUD. - ox = (self.win_w - self.level.width) // 2 - oy = HUD_H + (self.win_h - HUD_H - self.level.height) // 2 + ox = (self.win_w - world_w) // 2 + oy = HUD_H + (self.win_h - HUD_H - world_h) // 2 self.world_pos = (ox, oy) self.state = "playing" self.death_timer = 0.0 @@ -238,7 +258,7 @@ class Game: # 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) - self.world.blit(sprite, self.death_rect) + self.world.blit(sprite, self.death_rect.move(self.level.render_offset)) else: self.player.draw(self.world, self.assets) diff --git a/game/level.py b/game/level.py index c36606f..af76730 100644 --- a/game/level.py +++ b/game/level.py @@ -49,6 +49,12 @@ class Level: self.width = self.cols * self.tile self.height = self.grid_rows * self.tile + # Draw-time translation (pixels): everything visible is shifted by this + # when blitted. Physics stays in true level coordinates — this is purely + # so the debug view can reveal a margin of overscan around the level (set + # by the Game). (0, 0) in normal play, so nothing moves. + self.render_offset = (0, 0) + self.solids = [] # list[pygame.Rect] — full blocking self.oneways = [] # list[pygame.Rect] — blocking only from above self.spawn = (self.tile, self.tile) @@ -123,9 +129,10 @@ class Level: def draw(self, surface, assets): t = self.tile + ox, oy = self.render_offset block = assets.get("block", t, t) for rect in self.solids: - surface.blit(block, rect) + surface.blit(block, rect.move(ox, oy)) # One-way platforms look identical to solid blocks — you only discover # them by falling through. `debug` fades them so a level designer can @@ -135,10 +142,10 @@ class Level: oneway_img = block.copy() oneway_img.fill((255, 255, 255, 110), special_flags=pygame.BLEND_RGBA_MULT) for rect in self.oneways: - surface.blit(oneway_img, rect) + surface.blit(oneway_img, rect.move(ox, oy)) surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h), - self.goal_rect) + self.goal_rect.move(ox, oy)) for tr in self.traps: tr.render(surface, assets) @@ -149,22 +156,48 @@ class Level: def _draw_grid(self, surface): """A faint tile grid with col/row labels, so `at: [col,row]` placement - is eyeballable while designing.""" + is eyeballable while designing. + + In the debug view the drawing surface is larger than the level (a margin + of overscan; see ``render_offset``). The grid extends across the whole + surface — labels go negative / past the map in the margin — the off-map + region is shaded, and the true play area is outlined so it's clear what's + actually on screen at runtime.""" if Level._grid_font is None: Level._grid_font = pygame.font.SysFont("consolas,menlo,monospace", 10) - overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA) + t = self.tile + ox, oy = self.render_offset + sw, sh = surface.get_size() + overlay = pygame.Surface((sw, sh), pygame.SRCALPHA) + + # 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 + 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 + if band.w > 0 and band.h > 0: + overlay.fill(shade, band) + + # Tile grid across the whole surface; offsets are whole tiles so lines + # stay aligned to the level grid. mx/my = margin width in tiles. + mx, my = ox // t, oy // t line = (255, 255, 255, 26) - for c in range(self.cols + 1): - x = c * self.tile - pygame.draw.line(overlay, line, (x, 0), (x, self.height)) - for r in range(self.grid_rows + 1): - y = r * self.tile - pygame.draw.line(overlay, line, (0, y), (self.width, y)) + for k in range(sw // t + 1): + pygame.draw.line(overlay, line, (k * t, 0), (k * t, sh)) + for k in range(sh // t + 1): + pygame.draw.line(overlay, line, (0, k * t), (sw, k * t)) label = (150, 162, 190) - for c in range(self.cols): - overlay.blit(Level._grid_font.render(str(c), True, label), - (c * self.tile + 2, 1)) - for r in range(self.grid_rows): - overlay.blit(Level._grid_font.render(str(r), True, label), - (1, r * self.tile + 1)) + for k in range(sw // t): + 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)) + + # Outline the actual runtime viewport (the level's true bounds). + if (mx or my): + pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2) + surface.blit(overlay, (0, 0)) diff --git a/game/player.py b/game/player.py index a8897a2..6a9e748 100644 --- a/game/player.py +++ b/game/player.py @@ -267,4 +267,6 @@ class Player: # --- rendering ----------------------------------------------------------- def draw(self, surface, assets): - surface.blit(assets.get("player", self.rect.w, self.rect.h), self.rect) + ox, oy = self.level.render_offset + surface.blit(assets.get("player", self.rect.w, self.rect.h), + self.rect.move(ox, oy)) diff --git a/game/settings.py b/game/settings.py index 89ef42b..1504565 100644 --- a/game/settings.py +++ b/game/settings.py @@ -4,6 +4,10 @@ TILE = 32 # default tile size in pixels (levels may override) FPS = 60 CAPTION = "Dying Phone" +# Debug view only: how many extra tiles to reveal beyond the level on every side, +# so off-screen geometry (e.g. an invisible catch-wall a step off the map) is +# visible while designing. The true play area is outlined within this margin. +DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug # --- Physics (pixels / second, unless noted) --------------------------------- GRAVITY = 2200.0 # downward acceleration diff --git a/game/traps.py b/game/traps.py index 3601668..badbf06 100644 --- a/game/traps.py +++ b/game/traps.py @@ -213,10 +213,17 @@ class Trap: def sensor_rect(self): return self.current_rect() + # Translate a level-space rect into render space. Everything a trap draws + # goes through this so the debug view's overscan margin shifts it correctly; + # it's a no-op (offset (0, 0)) in normal play. + def _rt(self, rect): + ox, oy = self.level.render_offset + return rect.move(ox, oy) + # Debug helper: tint a rect so a normally-hidden trap is visible when the # level's `debug` flag is on. def _debug_tint(self, surface, rgb, rect=None, alpha=80): - r = rect if rect is not None else self.base_rect + r = self._rt(rect if rect is not None else self.base_rect) overlay = pygame.Surface(r.size, pygame.SRCALPHA) overlay.fill((*rgb, alpha)) surface.blit(overlay, r) @@ -224,7 +231,7 @@ class Trap: # Debug helper: a faded sprite + outline showing where something absent # (e.g. a crumbled-away block) belongs. def _debug_ghost(self, surface, assets, sprite_name, rect=None): - r = rect if rect is not None else self.base_rect + r = self._rt(rect if rect is not None else self.base_rect) img = assets.get(sprite_name, r.w, r.h).copy() img.fill((255, 255, 255, 70), special_flags=pygame.BLEND_RGBA_MULT) surface.blit(img, r) @@ -233,7 +240,8 @@ class Trap: # Debug helper: outline a path through a list of tile cells (top-left px), # connecting their centres. `closed` joins the last cell back to the first. def _debug_path(self, surface, cells, closed=False, color=(214, 200, 96)): - rects = [pygame.Rect(x, y, self.tile, self.tile) for (x, y) in cells] + ox, oy = self.level.render_offset + rects = [pygame.Rect(x + ox, y + oy, self.tile, self.tile) for (x, y) in cells] if len(rects) >= 2: pygame.draw.lines(surface, color, closed, [r.center for r in rects], 1) for r in rects: @@ -376,7 +384,7 @@ class Spike(Trap): if self.active: hr = self._hazard_rect() angle = self._ANGLE.get(self.direction, 0) - surface.blit(assets.get("spike", hr.w, hr.h, angle), hr) + surface.blit(assets.get("spike", hr.w, hr.h, angle), self._rt(hr)) elif self.level.debug: # A dormant spike — show where it will strike. self._debug_tint(surface, (230, 80, 80), self._hazard_rect(), 60) @@ -605,7 +613,12 @@ class Block(Trap): def draw(self, surface, assets): if self.level.debug and len(self.points) > 1: - self._debug_path(surface, self.points, closed=(self.mode == "loop")) + # `points` live in our own frame — local (relative to the parent) for + # a mount — so shift them by the parent origin to draw where the block + # actually travels. + ox, oy = self._origin + cells = [(px + ox, py + oy) for (px, py) in self.points] + self._debug_path(surface, cells, closed=(self.mode == "loop")) # crumbled away: hidden (ghost in debug), unless re-forming into the player if self.crumble and self.cstate == "gone" and not self.emerge_kill: if self.level.debug: @@ -614,7 +627,7 @@ class Block(Trap): rect = self._rect() if self.crumble and self.cstate == "crumbling": rect = rect.move(int(self.shake), 0) - surface.blit(assets.get(self.sprite, self.tile, self.tile), rect) + surface.blit(assets.get(self.sprite, self.tile, self.tile), self._rt(rect)) if self.fake and self.level.debug: self._debug_tint(surface, (255, 40, 40), self._rect(), 90) @@ -685,16 +698,29 @@ 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.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), a.rect) + surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect)) # --- warp: invisible teleporter --------------------------------------------- class Warp(Trap): """An invisible tile that teleports the player to ``to: [col, row]`` on - contact. The level's ``debug`` flag tints it (and draws a line to its + contact. On activation a short aura flashes at both the source and the + 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 + # largest-first so the bright core sits on top. + _AURA = ( + (1.15, 0.30, (188, 116, 246)), + (0.80, 0.55, (222, 158, 252)), + (0.45, 0.95, (245, 224, 255)), + ) + def __init__(self, spec, level): super().__init__(spec, level) self.invisible = True @@ -704,8 +730,11 @@ class Warp(Trap): def reset(self): self._armed = True # re-arms once the player has left the tile + self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME def update(self, dt, game): + if self._pulse > 0.0: + self._pulse = max(0.0, self._pulse - dt / self._PULSE_TIME) inside = game.player.rect.colliderect(self.base_rect) if inside and self._armed: p = game.player @@ -714,16 +743,47 @@ class Warp(Trap): p.vx = p.vy = 0.0 p._sync_rect() self._armed = False + self._pulse = 1.0 # flash at both ends this frame, then fade elif not inside: self._armed = True + def render(self, surface, assets): + # Warps are invisible, so the base render() would skip draw() in normal + # play — but the activation aura should show then too. Draw whenever a + # pulse is live (or in debug, for the design tint/line). + if self.level.debug or self._pulse > 0.0: + self.draw(surface, assets) + for c in self.mounts: + 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) + + def _draw_aura(self, surface, rect): + # An expanding, fading glow centred on the cell. As the pulse decays the + # rings grow a little and thin out, so it reads as a quick flash-and-fade. + p = self._pulse + grow = 0.55 + (1.0 - p) * 0.85 + size = self.tile * 3 + aura = pygame.Surface((size, size), pygame.SRCALPHA) + c = (size // 2, size // 2) + for rmul, amul, col in self._AURA: + radius = int(self.tile * rmul * grow) + alpha = int(255 * amul * p) + if radius >= 1 and alpha > 0: + pygame.draw.circle(aura, (*col, alpha), c, radius) + surface.blit(aura, aura.get_rect(center=self._rt(rect).center)) + def draw(self, surface, assets): + if self._pulse > 0.0: + self._draw_aura(surface, self.base_rect) + self._draw_aura(surface, self._dest_rect()) if self.level.debug: self._debug_tint(surface, (210, 80, 235), alpha=90) - dest = pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile, - self.tile, self.tile) + dest = self._dest_rect() pygame.draw.line(surface, (210, 80, 235), - self.base_rect.center, dest.center, 1) + self._rt(self.base_rect).center, self._rt(dest).center, 1) self._debug_tint(surface, (210, 80, 235), dest, 45) @@ -827,7 +887,7 @@ class PhaseBlock(Trap): 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) - surface.blit(img, self.base_rect) + surface.blit(img, self._rt(self.base_rect)) # --- registry + factory ------------------------------------------------------ diff --git a/tests/test_flow.py b/tests/test_flow.py index 75c42b8..806bfec 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -131,6 +131,30 @@ def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch): 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") + g = Game([str(p)], str(tmp_path / "noassets")) + m = S.DEBUG_VIEW_MARGIN * 32 + assert m > 0 + # the level draws shifted into an enlarged play surface... + assert g.level.render_offset == (m, m) + assert g.world.get_size() == (g.level.width + 2 * m, g.level.height + 2 * m) + # ...and the fixed window budgeted the margin so it isn't clipped. + assert g.win_h == g.level.height + 2 * m + HUD_H + + +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")) + assert g.level.render_offset == (0, 0) + assert g.world.get_size() == (g.level.width, g.level.height) + + def test_battery_visual_scales_by_pct(make_level): lvl = make_level(SIMPLE + "battery_pct: 4\n") assert lvl.battery_pct == 4 diff --git a/tests/test_traps.py b/tests/test_traps.py index a6ac92b..d26dbc5 100644 --- a/tests/test_traps.py +++ b/tests/test_traps.py @@ -163,6 +163,41 @@ traps: assert warped +def test_warp_flash_pulses_and_fades(make_game): + g = make_game(""" +name: t +tile_size: 32 +map: | + ########## + #.......G# + #P.......# + ########## +traps: + - type: warp + at: [4, 2] + to: [7, 1] +""") + warp = g.level.traps[0] + assert warp._pulse == 0.0 # dormant: no aura + place(g, 4, 2) # stand on the warp tile + step(g) # -> teleports, aura flashes on + assert warp._pulse > 0.9 + + # The aura paints at BOTH the source and the destination — even though the + # warp is invisible and we're not in debug. + a = AssetStore("noassets") + surf = pygame.Surface((g.level.width, g.level.height)) + surf.fill((0, 0, 0)) + warp.render(surf, a) + assert surf.get_at(warp.base_rect.center)[:3] != (0, 0, 0) + assert surf.get_at(warp._dest_rect().center)[:3] != (0, 0, 0) + + # It quickly fades back to nothing within the pulse window. + for _ in range(int(warp._PULSE_TIME / DT) + 2): + step(g) + assert warp._pulse == 0.0 + + # --- phase block ------------------------------------------------------------- def test_phase_block_intangible_until_triggered(make_game): g = make_game(""" @@ -304,6 +339,24 @@ traps: assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze +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 + a = AssetStore("noassets") + m = S.DEBUG_VIEW_MARGIN * lvl.tile + lvl.render_offset = (m, m) + surf = pygame.Surface((lvl.width + 2 * m, lvl.height + 2 * m)) + surf.fill((0, 0, 0)) + lvl.draw(surf, a) + cx = 5 * lvl.tile + m + lvl.tile // 2 + cy = 1 * lvl.tile + m + lvl.tile // 2 + assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan + + # --- generic invisible flag -------------------------------------------------- def test_invisible_flag_on_any_trap(make_level): lvl = make_level("""