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.
This commit is contained in:
James Campbell
2026-07-22 11:56:21 -04:00
parent d84e4627c3
commit 59e4bcce1c
8 changed files with 249 additions and 41 deletions

20
SPEC.md
View File

@@ -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.
---

View File

@@ -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)

View File

@@ -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))

View File

@@ -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))

View File

@@ -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

View File

@@ -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 ------------------------------------------------------

View File

@@ -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

View File

@@ -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("""