Compare commits

..

4 Commits

Author SHA1 Message Date
James Campbell
7b7b005823 Make phase a block property rather than a distinct type 2026-08-02 00:03:58 -04:00
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
19 changed files with 1000 additions and 489 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

@@ -104,29 +104,30 @@ Any trap can also take:
| `type` | Key parameters | Behavior |
|-----------------|----------------|----------|
| `spike` | `direction` (`up`/`down`/`left`/`right`), `trigger`, `delay` | Deadly spike; active while its `trigger` holds. The sprite auto-rotates to point the way `direction` faces. |
| `block` | see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, and/or crumbling. |
| `block` | see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, crumbling, and/or phasing. |
| `arrow_shooter` | `direction`, `speed`, `interval`, `trigger`, `delay` | A wall turret firing deadly arrows every `interval` seconds while its `trigger` holds. |
| `warp` | `to` [col,row] | Invisible tile that teleports the player to `to` on contact. Re-arms once you leave it. |
| `phase_block` | `trigger`, `fade` | Invisible and intangible until its `trigger` fires, then fades into a solid over `fade` seconds (and fades back out when the trigger releases). If you're standing in the cell the instant it *starts* appearing, you die — and if you die while one is mid-fade it snaps fully visible for the death freeze. |
### The `block` trap
One trap covers stationary blocks, sliders, patrolling platforms, spike blocks,
fake blocks, and crumbling blocks — compose the behaviour from options (which
combine, e.g. a moving platform that crumbles):
fake blocks, crumbling blocks, and phase blocks — compose the behaviour from
options (which combine, e.g. a moving platform that crumbles, or a phase block
that's always moving):
| option | meaning |
|---|---|
| `path` [[col,row],…] | waypoints it travels between (default just `[at]` = stationary) |
| `move` [dcol,drow] | shorthand for a 2-point path `[at, at+move]` (a slider) |
| `mode` | `once` (default): advance to the last point while triggered, retreat to the first when not — the slider/dropper. `loop` / `pingpong`: cycle the whole path continuously (a patrol). |
| `trigger` | when it moves (default `always`). A patrol is just a path + the default `always` trigger. |
| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid |
| `trigger` | when it activates (default `always`). A patrol is just a path + the default `always` trigger. A block that both moves and phases can give each its own trigger via a per-target map (see Triggers). |
| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid. On a phase block the hazard is live only once materialised. |
| `fake` (bool) | looks solid but you fall straight through it |
| `crumble` (bool) + `crumble_delay`, `respawn` | gives way `crumble_delay` s after you stand on it, vanishes, then reappears after `respawn` s (killing you if you're standing where it re-forms) |
| `phase` (bool) + `fade` | invisible/intangible until triggered, then fades into a solid (or a hazard, if `deadly`) over `fade` s (default 0.3) and back out when the trigger releases. Forming into you is lethal, but a non-deadly one first shoves you clear if you're only clipping an edge; if you die while one is mid-fade it snaps fully visible for the death freeze. |
| `speed` | px/s |
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) |
| `delay` / `release` | (`once`) hysteresis: the trigger must hold `delay` s to start extending and be clear `release` s (default 0.1) to start retracting — stops boundary jitter |
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, `phase_block` if phase, else `moving_block`) |
| `delay` / `release` | hold `delay` s to activate (may be a per-target map); in `once` motion, be clear `release` s (default 0.1) to start retracting — stops boundary jitter |
| `sense` | `home` (default): a slider senses its trigger from its resting cell, so moving away can't toggle its own trigger. `current`: senses from the live position — for a block you *ride* (e.g. a dropper) so it stays put while ridden instead of pulling back |
```yaml
@@ -145,6 +146,15 @@ combine, e.g. a moving platform that crumbles):
path: [[9, 10], [14, 10], [14, 6]]
mode: loop
sprite: patrol_block
- type: block # deadly block that patrols AND phases in near you
at: [9, 3]
phase: true
deadly: true
move: [4, 0]
trigger:
motion: always # always sliding back and forth
phase: { within: 3 } # but only solid/deadly when you're close
```
A `once` block runs a *committed stroke*: once it starts moving it runs all the
@@ -183,6 +193,21 @@ trigger:
*continuously* this long before the trap arms; leaving the condition resets the
countdown. Handy for "linger and it strikes" spikes.
**Per-target triggers.** A `block` can both move and phase, and each behaviour
can take its own trigger (and `delay`). Give a single condition to drive both,
or a map keyed by target (`motion` / `phase`) with `default` covering the rest:
```yaml
trigger:
default: always # phase (and anything else)
motion: { within: 5 } # motion only
delay: { motion: 0.1, phase: 0.3 }
```
A map that names some targets but omits `default` leaves the unnamed ones with
no trigger (off) — the debug view warns if that silently disables a capability.
An unknown target name is an error.
### Getting crushed
You also die if a moving (non-deadly) `block` **pinches** you: presses you

79
SPEC.md
View File

@@ -341,6 +341,37 @@ Composites evaluate *all* children each frame (so nested timers keep ticking).
long before the trap arms; leaving the condition resets the countdown. This is
arm hysteresis (e.g. "linger and it strikes").
### 12.1 Per-target triggers and delays
A trap with more than one triggerable behaviour — a `block` can both **move**
and **phase** — lets each behaviour take its own trigger and delay. The value is
either:
- a **single** condition/scalar (the common case), applied to every target; or
- a **per-target map** keyed by target name, with `default` covering the rest.
Target names are disjoint from the condition keywords above, so a bare condition
like `{ within: 2 }` is never mistaken for a map. The block's targets are
`motion` and `phase`.
```yaml
trigger: { within: 2 } # both motion and phase
trigger:
default: always # phase (and any other target)
motion: { within: 5 } # motion only
delay: { motion: 0.1, phase: 0.3 } # per-target arm delays
```
- If a map names some targets but omits `default`, the **unnamed targets get no
trigger and never fire** — a `phase` block with `trigger: { motion: … }` and
no `default` never materialises. (In the debug view this prints a warning,
since it's usually a mistake.)
- An unknown target name raises an error at load time.
- Each target keeps its own condition instance and arm countdown, so their
timers never interfere.
- A scalar `delay` applies to all targets; an unnamed target without a `default`
delay falls back to `0`.
---
## 13. Trap catalog
@@ -354,20 +385,21 @@ holds.
### 13.2 `block` — the all-in-one block
One trap covering stationary blocks, sliders, patrolling platforms, spike
blocks, fake blocks, and crumbling blocks. Options combine.
blocks, fake blocks, crumbling blocks, and phase blocks. Options combine.
| Option | Meaning |
|---|---|
| `path: [[col,row],…]` | waypoints it travels between (default `[at]` = stationary) |
| `move: [dcol,drow]` | shorthand for a 2-point path `[at, at+move]` (a slider) |
| `mode` | `once` (default): extend to the last waypoint while triggered, retreat to the first when not — slider/dropper. `loop` / `pingpong`: cycle the whole path continuously — a patrol. |
| `trigger` | when it moves (default `always`); a patrol is a path + `always` |
| `trigger` | when it activates (default `always`); a patrol is a path + `always`. May be a per-target map (`motion` / `phase`) — see §12.1 |
| `speed` | px/s (default 140) |
| `deadly` (bool) | hazard (spikes) instead of a solid |
| `deadly` (bool) | hazard (spikes) instead of a solid. On a `phase` block the hazard is live only while it's materialised |
| `fake` (bool) | drawn like a solid block but non-collidable (you fall through) |
| `crumble` (bool) + `crumble_delay`, `respawn` | gives way `crumble_delay` s after you stand on it, vanishes, then re-forms after `respawn` s (killing you if you're standing where it re-forms) |
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) |
| `delay` / `release` | (`once`) hysteresis: hold `delay` s to start extending, be clear `release` s (default 0.1) to start retracting |
| `phase` (bool) + `fade` | invisible/intangible until triggered, then fades into a solid (or a hazard, if `deadly`) over `fade` s (default 0.3) and back out when the trigger releases — see the phasing note below |
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, `phase_block` if phase, else `moving_block`) |
| `delay` / `release` | hold `delay` s to activate (may be a per-target map, §12.1); in `once` motion, be clear `release` s (default 0.1) to start retracting |
| `sense` | `home` (default): sense the trigger from the resting cell; `current`: sense from the live position (for blocks you ride, so they stay put while ridden) |
Behavior notes:
@@ -375,11 +407,24 @@ Behavior notes:
endpoint without reversing, and only reconsiders its trigger while parked at
an endpoint. Combined with `home` sensing, this eliminates the jitter a
slider would otherwise get from moving out of its own sensor range.
- A non-deadly, non-fake block is a **solid** that reports itself as a
**carrier** so the player rides it; a moving block can **shove** or **crush**
the player (§5.2, §5.6).
- Movement runs first each frame, then the crumble state machine, so a moving
platform can also crumble.
- A non-deadly, non-fake, non-phasing (or materialised) block is a **solid**
that reports itself as a **carrier** so the player rides it; a moving block
can **shove** or **crush** the player (§5.2, §5.6).
- Movement, crumble, and phasing run in that order each frame, so a block can
combine them — e.g. a block that's always moving (`motion: always`) but only
**phases** in when the player is near (`phase: { within: N }`). Motion is
trigger-driven; crumble is contact-driven; phasing is trigger-driven.
- **Phasing**: while the `phase` trigger holds the block fades in and becomes
collidable (a solid, or — with `deadly` — a hazard that's live only once
materialised); when it releases, it fades back out and goes intangible. If the
player overlaps the cell the instant it *starts* forming, a non-deadly block
is forgiving about edge clips: when they're only clipping an edge (overlap ≤
half a tile on the shallowest axis) it shoves them out and solidifies behind
them, staying lethal only when it forms through their middle (a deep overlap)
or the shove would press them into another solid (a crush). A `deadly` phase
block skips the shove and simply kills. If the player dies while a phase block
is mid-fade, it snaps fully visible before the death freeze (via the
`finalize_on_death` hook — see §7/§11).
### 13.3 `arrow_shooter`
A wall turret that fires a deadly projectile every `interval` seconds while its
@@ -399,18 +444,8 @@ 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
over `fade` seconds (alpha 0→1) and fades back out when the trigger releases. If
the player overlaps the cell the instant it *starts* forming, it's forgiving
about edge clips: when they're only clipping an edge (overlap ≤ half a tile on
the shallowest axis) it shoves them out of the cell and solidifies behind them.
It stays lethal only when the block forms through the player's middle (a deep
overlap) or the shove would press them into another solid (squished against
something — a crush, as usual). In the lethal case it stays intangible that frame
so they're killed rather than displaced. If the player dies while it is mid-fade,
it snaps fully visible before the death freeze (via the `finalize_on_death`
hook — see §7/§11).
(A *phase block* — invisible/intangible until triggered, then fades into a solid
or hazard — is not a separate type: it's the `block` option `phase: true`, §13.2.)
---

View File

@@ -15,8 +15,8 @@ from . import settings
class AssetStore:
def __init__(self, assets_dir):
self.assets_dir = assets_dir
self._raw = {} # name -> original loaded Surface (or None if missing)
self._cache = {} # (name, w, h) -> scaled Surface
self._raw = {} # name -> original loaded Surface (or None if missing)
self._cache = {} # (name, w, h) -> scaled Surface
self._font = None
def _font_for(self, h):

View File

@@ -9,8 +9,8 @@ from .assets import AssetStore
from .level import Level
from .player import Player, InputState
HUD_H = 46 # height of the status bar above the play area
MIN_W = 480 # keep the window wide enough for the HUD text
HUD_H = 46 # height of the status bar above the play area
MIN_W = 480 # keep the window wide enough for the HUD text
class Game:
@@ -18,7 +18,7 @@ class Game:
pygame.init()
pygame.display.set_caption(S.CAPTION)
self.level_paths = level_paths
self.force_debug = debug # --debug: turn debug view on for every level
self.force_debug = debug # --debug: turn debug view on for every level
self.assets = AssetStore(assets_dir)
self.clock = pygame.time.Clock()
self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True)
@@ -26,12 +26,12 @@ class Game:
self.index = 0
self.running = True
self.state = "playing" # playing | dying | won_level | won_all
self.death_timer = 0.0 # counts down during the "dying" pause
self.death_rect = None # where to draw the corpse
self.deaths = 0 # total deaths this session
self.level_deaths = 0 # deaths on the current level (resets per level)
self._pending_reload = False # F5: reload the level file on next respawn
self.state = "playing" # playing | dying | won_level | won_all
self.death_timer = 0.0 # counts down during the "dying" pause
self.death_rect = None # where to draw the corpse
self.deaths = 0 # total deaths this session
self.level_deaths = 0 # deaths on the current level (resets per level)
self._pending_reload = False # F5: reload the level file on next respawn
# Size the window ONCE to fit the largest level, then never resize it —
# calling set_mode again mid-session recreates the window (it flickers /
@@ -62,7 +62,7 @@ class Game:
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.level.debug = True # CLI flag overrides the per-level setting
self.player = Player(self.level)
self.battery = self.level.battery
# In debug, grow the play surface by a margin and shift all drawing into
@@ -78,7 +78,7 @@ class Game:
self.state = "playing"
self.death_timer = 0.0
self.death_rect = None
self.level_deaths = 0 # fresh count for the level we just loaded
self.level_deaths = 0 # fresh count for the level we just loaded
def _start_death(self):
# Begin the death pause: freeze everything, leave the corpse on screen.
@@ -104,7 +104,7 @@ class Game:
try:
self._load_current()
self.level_deaths = kept
except Exception as ex: # bad edit -> don't crash
except Exception as ex: # bad edit -> don't crash
print(f"[reload] {self.level_paths[self.index]}: {ex}")
self.player.respawn()
self.level.reset()
@@ -112,7 +112,7 @@ class Game:
else:
self.player.respawn()
self.level.reset()
self.battery = self.level.battery # phone gets plugged back in at start
self.battery = self.level.battery # phone gets plugged back in at start
self.state = "playing"
def _reload_level(self):
@@ -129,7 +129,7 @@ class Game:
self.state = "charging"
self.charge_timer = 0.0
tf = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
self._final = self.index + 1 >= len(self.level_paths)
def _advance(self):
@@ -157,8 +157,8 @@ class Game:
if self.fade_timer < S.FADE_TIME:
return
if self.fade_phase == "out":
self.fade_action() # swap levels while the screen is black
self.state = "fading" # _load_current() flips to "playing"; undo it
self.fade_action() # swap levels while the screen is black
self.state = "fading" # _load_current() flips to "playing"; undo it
self.fade_phase = "in"
self.fade_timer = 0.0
else:
@@ -195,7 +195,7 @@ class Game:
def run(self):
while self.running:
dt = self.clock.tick(S.FPS) / 1000.0
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
inp = self._poll_events()
if self.state == "playing":
@@ -224,8 +224,8 @@ class Game:
# death conditions
pr = self.player.rect
died = self.battery <= 0
died = died or pr.top > self.level.height + 160 # fell out of the world
died = died or self.player.crushed # pinched by a moving block
died = died or pr.top > self.level.height + 160 # fell out of the world
died = died or self.player.crushed # pinched by a moving block
if not died:
for hz in self.level.hazard_rects():
if pr.colliderect(hz):
@@ -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,13 +280,17 @@ 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
if self.state == "fading": # fade-in only; fade-out returned early above
p = min(1.0, self.fade_timer / S.FADE_TIME)
overlay = pygame.Surface(self.screen.get_size())
overlay.fill((0, 0, 0))
@@ -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)
@@ -355,8 +374,8 @@ class Game:
it fills to 100%."""
W, H = self.screen.get_size()
t = min(1.0, self.charge_timer / S.CHARGE_TIME)
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
fill = self.charge_from + (1.0 - self.charge_from) * fill_p
dim = pygame.Surface((W, H), pygame.SRCALPHA)
@@ -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,
round(bar_w + (target.w - bar_w) * move_p),
round(bar_h + (target.h - bar_h) * move_p))
rect = pygame.Rect(
0,
0,
round(bar_w + (target.w - bar_w) * 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

@@ -39,8 +39,6 @@ class Level:
rows = data.get("map", "").splitlines()
# 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] == "":
rows = rows[1:]
@@ -55,8 +53,8 @@ class Level:
# 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.solids = [] # list[pygame.Rect] — full blocking
self.oneways = [] # list[pygame.Rect] — blocking only from above
self.spawn = (self.tile, self.tile)
self.goal_rect = pygame.Rect(self.width - self.tile, 0, self.tile, self.tile)
@@ -144,8 +142,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 +173,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
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
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)
@@ -190,14 +192,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

@@ -16,8 +16,8 @@ class InputState:
def __init__(self):
self.left = self.right = self.down = False
self.jump_pressed = False # edge: pressed this frame
self.jump_held = False # level: currently down
self.jump_pressed = False # edge: pressed this frame
self.jump_held = False # level: currently down
class Player:
@@ -42,7 +42,6 @@ class Player:
self.drop_through_timer = 0.0
self.was_jump_held = False
self.crushed = False
self.carry = (0.0, 0.0)
self._sync_rect()
def _sync_rect(self):
@@ -78,8 +77,8 @@ class Player:
# jumping into the left side of a right-moving block) teleports us
# clear across it. Minimal displacement keeps the shove-along and
# shove-into-wall behaviours intact.
pen_right = rect.right - p.left # displacement to exit rightward
pen_left = p.right - rect.left # displacement to exit leftward
pen_right = rect.right - p.left # displacement to exit rightward
pen_left = p.right - rect.left # displacement to exit leftward
if pen_right <= pen_left:
p.left = rect.right
else:
@@ -118,11 +117,15 @@ 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
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
return True
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
return True
@@ -137,15 +140,15 @@ class Player:
def _ride_platforms(self):
# 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
and self.rect.right > rect.left + 1
and self.rect.left < rect.right - 1):
if (
abs(self.rect.bottom - rect.top) <= 3
and self.rect.right > rect.left + 1
and self.rect.left < rect.right - 1
):
self.fx += dx
self.fy += dy
self._sync_rect()
self.carry = (dx, dy)
break
def _horizontal(self, dt, inp):
@@ -188,8 +191,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
@@ -225,11 +231,11 @@ class Player:
# if the overlap is *more horizontal than vertical* — a block
# sitting on top of us is a vertical collision; pushing us
# sideways out from under it would dodge a crush.
pen_left = self.rect.right - s.left # sank in from the left
pen_right = s.right - self.rect.left # sank in from the right
pen_left = self.rect.right - s.left # sank in from the left
pen_right = s.right - self.rect.left # sank in from the right
pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top)
if min(pen_left, pen_right) > pen_y:
continue # let the Y pass handle it
continue # let the Y pass handle it
if pen_right <= pen_left:
self.rect.left = s.right
else:
@@ -241,8 +247,8 @@ class Player:
if self.rect.colliderect(s):
# Resolve toward the nearer edge, not by velocity sign — so a
# block descending onto us can't pop us out its top.
overlap_top = self.rect.bottom - s.top # sank onto its top
overlap_bottom = s.bottom - self.rect.top # rose into its underside
overlap_top = self.rect.bottom - s.top # sank onto its top
overlap_bottom = s.bottom - self.rect.top # rose into its underside
if overlap_top <= overlap_bottom:
self.rect.bottom = s.top
self.on_ground = True
@@ -266,5 +272,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

@@ -1,52 +1,51 @@
"""Global tunables. Anything a level does not override falls back to these."""
# --- Display -----------------------------------------------------------------
TILE = 32 # default tile size in pixels (levels may override)
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
DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug
# --- Physics (pixels / second, unless noted) ---------------------------------
GRAVITY = 2200.0 # downward acceleration
MAX_FALL = 1400.0 # terminal velocity
MOVE_SPEED = 320.0 # horizontal run speed
ACCEL = 3200.0 # ground acceleration toward target speed
AIR_ACCEL = 2200.0 # weaker control in the air
FRICTION = 3600.0 # deceleration when no input on ground
JUMP_SPEED = 760.0 # initial upward velocity of a jump
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
GRAVITY = 2200.0 # downward acceleration
MAX_FALL = 1400.0 # terminal velocity
MOVE_SPEED = 320.0 # horizontal run speed
ACCEL = 3200.0 # ground acceleration toward target speed
AIR_ACCEL = 2200.0 # weaker control in the air
FRICTION = 3600.0 # deceleration when no input on ground
JUMP_SPEED = 760.0 # initial upward velocity of a jump
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
# --- 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;
# the phone is dying, so the bar reads near-empty)
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
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
# the phone is dying, so the bar reads near-empty)
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
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
# --- Colors (placeholder rendering) ------------------------------------------
COLOR_BG = (24, 26, 38)
COLOR_HUD = (235, 235, 245)
COLOR_HUD_WARN = (240, 90, 90)
# Fallback colors + labels for placeholder rectangles, keyed by sprite name.
PLACEHOLDERS = {
"player": ((90, 200, 255), "P"),
"player_dead": ((120, 40, 40), "X"),
"block": ((110, 120, 140), ""),
"goal": ((90, 230, 130), "GOAL"),
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
"spike": ((230, 80, 80), "^"),
"moving_block": ((150, 130, 90), ""),
"patrol_block": ((130, 100, 170), ""),
"player": ((90, 200, 255), "P"),
"player_dead": ((120, 40, 40), "X"),
"block": ((110, 120, 140), ""),
"goal": ((90, 230, 130), "GOAL"),
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
"spike": ((230, 80, 80), "^"),
"moving_block": ((150, 130, 90), ""),
"patrol_block": ((130, 100, 170), ""),
"crumble_block": ((150, 120, 100), ""),
"arrow_shooter": ((80, 80, 95), ""),
"arrow": ((250, 220, 90), ">"),
"spike_block": ((150, 156, 172), "*"),
"phase_block": ((90, 180, 210), ""),
"arrow": ((250, 220, 90), ">"),
"spike_block": ((150, 156, 172), "*"),
"phase_block": ((90, 180, 210), ""),
}

View File

@@ -15,12 +15,12 @@ Nothing else in the engine needs to change.
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 +36,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
@@ -44,8 +45,20 @@ class _Always:
pass
class _Never:
"""A target with no condition — never fires. Used when a per-target trigger
map names some targets but omits ``default``, so the unnamed ones are off."""
def evaluate(self, trap, game, dt):
return False
def reset(self):
pass
class _Within:
"""Player within N tiles of the trap centre (Euclidean radius)."""
def __init__(self, n):
self.n = float(n)
@@ -69,6 +82,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 +127,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 +170,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,10 +183,57 @@ 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}")
# --- per-target trigger / delay maps -----------------------------------------
# A trap with more than one triggerable behaviour (a block can move *and* phase)
# lets each behaviour take its own trigger and delay. The value is either a
# single condition/scalar applied to every target (the common case) or a map
# keyed by target name (``motion``, ``phase``, …) with ``default`` covering the
# rest. Target names are disjoint from the condition keywords above, so a bare
# condition like ``{within: 2}`` is never mistaken for a target map.
#
# trigger: { within: 2 } # both motion and phase
# trigger: { default: always, motion: { within: 5 } }
# delay: { motion: 0.1, phase: 0.3 }
_NEVER = object() # sentinel: an unnamed target with no ``default`` -> off
def _is_target_map(value, targets):
"""True if ``value`` is a per-target map (a dict keyed by target names)
rather than a single condition/scalar applied to every target."""
if not isinstance(value, dict):
return False
names = set(targets) | {"default"}
return any(k in names for k in value)
def _split_targets(value, targets, kind, missing):
"""Resolve a scalar-or-map ``trigger``/``delay`` value into
``{target: spec}``. A scalar (or single-condition dict) applies to every
target; a per-target map assigns each named target its own spec, with
``default`` covering the rest and unnamed-without-default falling back to
``missing``."""
if not _is_target_map(value, targets):
return {t: value for t in targets}
valid = set(targets) | {"default"}
unknown = [k for k in value if k not in valid]
if unknown:
raise ValueError(
f"{kind}: unknown target(s) {unknown}; valid targets are {sorted(valid)}"
)
fallback = value.get("default", missing)
return {t: value.get(t, fallback) for t in targets}
class Trap:
def __init__(self, spec, level):
self.spec = spec
@@ -178,7 +242,7 @@ class Trap:
at = spec.get("at", [0, 0])
self.col, self.row = int(at[0]), int(at[1])
self.base_rect = level.cell_rect(self.col, self.row)
self._mounted = False # set True on traps that ride another (a mount)
self._mounted = False # set True on traps that ride another (a mount)
# Any trap can be made invisible (still functional; revealed in debug).
self.invisible = bool(spec.get("invisible", False))
@@ -274,13 +338,14 @@ 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)
for c in self.mounts:
c._follow(self) # reposition after we've moved this frame
c._follow(self) # reposition after we've moved this frame
c.tick(dt, game)
def render(self, surface, assets):
@@ -328,23 +393,44 @@ class Trap:
# --- triggers ------------------------------------------------------------
# Traps with an activation condition call _init_trigger() in __init__,
# _reset_trigger() in reset(), and triggered() each frame.
def _init_trigger(self, spec):
self.trigger = make_condition(spec.get("trigger", "always"))
self.trig_delay = float(spec.get("delay", 0.0)) # arm delay (seconds)
self._trig_timer = 0.0
# _reset_trigger() in reset(), and triggered(target) each frame. A trap with
# a single behaviour uses the lone default target; a block that both moves
# and phases names two ("motion", "phase") so each can take its own trigger
# and arm delay (see the per-target docs above make_condition's helpers).
def _init_trigger(self, spec, targets=("main",)):
trig_specs = _split_targets(
spec.get("trigger", "always"), targets, "trigger", _NEVER
)
delay_specs = _split_targets(spec.get("delay", 0.0), targets, "delay", 0.0)
self._trig = {}
self._trig_never = set() # targets that resolved to a never-firing condition
for t in targets:
cspec = trig_specs[t]
if cspec is _NEVER:
cond = _Never()
self._trig_never.add(t)
else:
cond = make_condition(cspec)
self._trig[t] = {
"cond": cond,
"delay": float(delay_specs[t] or 0.0), # arm delay (seconds)
"timer": 0.0,
}
def _reset_trigger(self):
self._trig_timer = 0.0
self.trigger.reset()
for slot in self._trig.values():
slot["timer"] = 0.0
slot["cond"].reset()
def triggered(self, game, dt):
"""True while the trigger condition holds. If ``delay`` is set, the
condition must hold *continuously* for that long first; leaving the
condition resets the countdown."""
raw = self.trigger.evaluate(self, game, dt)
self._trig_timer = self._trig_timer + dt if raw else 0.0
return raw and self._trig_timer >= self.trig_delay
def triggered(self, game, dt, target="main"):
"""True while ``target``'s trigger condition holds. If a ``delay`` is
set, the condition must hold *continuously* for that long first; leaving
the condition resets the countdown. Each target keeps its own condition
instance and countdown, so their timers never interfere."""
slot = self._trig[target]
raw = slot["cond"].evaluate(self, game, dt)
slot["timer"] = slot["timer"] + dt if raw else 0.0
return raw and slot["timer"] >= slot["delay"]
# --- spike: emerges to kill -------------------------------------------------
@@ -354,6 +440,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")
@@ -369,13 +456,13 @@ class Spike(Trap):
t = self.tile
r = self.base_rect
dx, dy = _DIRS.get(self.direction, (0, -1))
if dy == -1: # up: bottom half is ground, tip points up
if dy == -1: # up: bottom half is ground, tip points up
return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
if dy == 1: # down (ceiling spike)
if dy == 1: # down (ceiling spike)
return pygame.Rect(r.x, r.y, t, t // 2)
if dx == -1: # left (from right wall pointing left)
if dx == -1: # left (from right wall pointing left)
return pygame.Rect(r.x + t // 2, r.y, t // 2, t)
return pygame.Rect(r.x, r.y, t // 2, t) # right
return pygame.Rect(r.x, r.y, t // 2, t) # right
# CCW rotation to point the (up-facing) sprite the right way.
_ANGLE = {"up": 0, "left": 90, "down": 180, "right": -90}
@@ -398,23 +485,36 @@ class Spike(Trap):
# --- 3. block: the unified stationary / sliding / patrolling / spike block ---
class Block(Trap):
"""A block that may move and/or be deadly — one trap covering stationary
blocks, proximity sliders, patrolling platforms, and spike blocks.
"""A block that may move, phase in/out, and/or be deadly — one trap covering
stationary blocks, proximity sliders, patrolling platforms, spike blocks,
fake blocks, crumbling blocks, and phase blocks.
path: list of [col,row] waypoints (default just ``[at]`` = stationary).
move: [dcol,drow] shorthand for a 2-point path [at, at+move] (a slider).
trigger: when it moves (default ``always``). Sensors track the block's live
position, so a condition like ``{dir: above}`` keeps it going while
the player rides it.
trigger: when it activates (default ``always``). Sensors track the block's
live position, so a condition like ``{dir: above}`` keeps it going
while the player rides it. A block that both moves and phases can
give each its own trigger via a per-target map keyed ``motion`` /
``phase`` (see the per-target docs near make_condition).
mode: ``once`` (default) extends to the last point while triggered and
retreats to the first when not — the slider/dropper behaviour;
``loop`` / ``pingpong`` cycle the whole path continuously (patrol).
deadly: true -> a hazard (spikes) instead of a solid.
speed: px/s. sprite: override (default spike_block if deadly, else moving_block).
delay/release: (``once`` mode) the trigger must hold for ``delay`` seconds to
start extending and be clear for ``release`` seconds to start
retracting — hysteresis that stops boundary jitter.
deadly: true -> a hazard (spikes) instead of a solid. On a phase block the
hazard is only live while it's materialised.
phase: true -> invisible/intangible until triggered, then fades into a
solid (or, if ``deadly``, a hazard) over ``fade`` seconds and back
out when the trigger releases. Forming into the player is lethal,
though a non-deadly one first tries to shove a player merely
clipping an edge clear.
speed: px/s. sprite: override (default: spike_block if deadly, fake_block
if fake, crumble_block if crumble, phase_block if phase, else
moving_block).
delay/release: the trigger must hold for ``delay`` seconds to activate; in
``once`` motion it must also be clear for ``release`` seconds to
start retracting — hysteresis that stops boundary jitter. ``delay``
may be a per-target map too.
"""
def __init__(self, spec, level):
super().__init__(spec, level)
t = self.tile
@@ -429,23 +529,48 @@ class Block(Trap):
self.speed = float(spec.get("speed", 140.0))
self.mode = spec.get("mode", "once")
self.deadly = bool(spec.get("deadly", False))
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
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.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
self.phase = bool(spec.get("phase", False)) # fades in/out on trigger
self.fade = float(spec.get("fade", 0.3)) # phase fade-in/out seconds
self.sprite = spec.get("sprite", self._default_sprite())
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
# from the live position — for blocks the player rides (e.g. a dropper),
# so it stays put while ridden instead of pulling back.
self.sense = spec.get("sense", "home")
self._init_trigger(spec)
# Motion and phasing each take their own trigger/delay (a single trigger
# applies to both). Crumble is contact-driven, not trigger-driven.
self._init_trigger(spec, targets=("motion", "phase"))
if self.level.debug:
self._warn_dead_trigger("phase", self.phase)
self._warn_dead_trigger("motion", len(self.points) >= 2)
self.reset()
def _default_sprite(self):
if self.deadly:
return "spike_block"
if self.fake:
return "fake_block"
if self.crumble:
return "crumble_block"
if self.phase:
return "phase_block"
return "moving_block"
def _warn_dead_trigger(self, target, enabled):
# Design aid: a capability that's on but whose trigger never fires (a
# per-target map that named other targets but omitted this one and
# `default`) is almost always a mistake — flag it in the debug view.
if enabled and target in self._trig_never:
print(
f"[level] block at [{self.col},{self.row}]: {target} is enabled "
f"but its trigger never fires (add a '{target}' or 'default' target)"
)
def sensor_rect(self):
return self.base_rect if self.sense == "home" else self._rect()
@@ -463,8 +588,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()
@@ -474,14 +600,20 @@ class Block(Trap):
# LOCAL — the live rect is always _origin + (x, y).
self._origin = (0.0, 0.0)
self.prev = (self.x, self.y)
self.dir = 1 # pingpong direction
self.phase = "rest" # once mode: rest|extending|extended|retracting
self.dir = 1 # pingpong direction
self.motion_phase = "rest" # once mode: rest|extending|extended|retracting
self._release_t = 0.0
# index of the waypoint we're AT (once) / heading toward (patrol)
self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0)
self.cstate = "solid" # crumble: solid|crumbling|gone
self.cstate = "solid" # crumble: solid|crumbling|gone
self.ctimer = 0.0
self.shake = 0.0
# phase: fade-in progress (0..1) and whether it's currently collidable.
self.alpha = 0.0
self.materialized = False
# Set the frame a block (crumble re-forming or phase forming) materialises
# into the player — lethal this frame. Crumble and phase are mutually
# exclusive in practice, so they share the flag.
self.emerge_kill = False
def update(self, dt, game):
@@ -491,8 +623,8 @@ class Block(Trap):
# block executes its path/move relative to the parent it rides.
if not self._mounted:
self.prev = (self.x, self.y)
active = self.triggered(game, dt) # call every frame to keep the timer live
if len(self.points) >= 2:
active = self.triggered(game, dt, "motion")
step = self.speed * dt
if self.mode == "once":
self._update_once(active, dt, step)
@@ -500,13 +632,16 @@ class Block(Trap):
self._update_patrol(active, step)
if self.crumble:
self._update_crumble(dt, game)
if self.phase:
self._update_phase(dt, game)
def _update_crumble(self, dt, game):
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"
@@ -528,6 +663,78 @@ class Block(Trap):
self.ctimer = 0.0
self.shake = 0.0
# How deep the player may be into a forming phase block and still be nudged
# clear rather than killed. A shallow clip (feet/shoulder in the cell) gets
# shoved out; forming through their middle stays lethal.
_EDGE_GRACE = 0.5 # fraction of a tile
def _update_phase(self, dt, game):
# Materialise while the `phase` trigger holds (fade alpha 0->1), fade
# back out when it releases. A block forming into the player is lethal;
# a non-deadly (solid) one first tries to shove a player who's only
# clipping an edge clear, while a deadly one simply kills.
self.emerge_kill = False
active = self.triggered(game, dt, "phase")
if active:
forming = self.alpha == 0.0 and not self.materialized
if (
forming
and not self.deadly
and game.player.rect.colliderect(self._rect())
and not self._eject_player(game)
):
self.emerge_kill = True
return
self.alpha = min(1.0, self.alpha + dt / self.fade)
self.materialized = True
else:
self.alpha = max(0.0, self.alpha - dt / self.fade)
if self.alpha == 0.0:
self.materialized = False
def _eject_player(self, game):
"""Nudge a player who's only clipping the forming block out of its cell.
Returns True if they were pushed clear (forgiving). Returns False — leave
it lethal — when the block is forming through the player's middle (too
deep to fairly eject) or the shove would press them into another solid
(squished against something)."""
p = game.player.rect
b = self._rect()
# Distance to move the player to clear the block on each side.
outs = {
"up": p.bottom - b.top,
"down": b.bottom - p.top,
"left": p.right - b.left,
"right": b.right - p.left,
}
side = min(outs, key=outs.get)
dist = outs[side]
if dist > self.tile * self._EDGE_GRACE:
return False # deep overlap — forming through them
dx, dy = _DIRS[side]
moved = p.move(dx * dist, dy * dist)
# The block isn't materialised yet, so it's absent from solid_rects();
# any hit here is a *different* solid backing them — no room to dodge.
if any(moved.colliderect(s) for s in self.level.solid_rects()):
return False
player = game.player
player.fx += dx * dist
player.fy += dy * dist
if dx:
player.vx = 0.0
if dy:
player.vy = 0.0
player._sync_rect()
return True
def finalize_on_death(self):
# A phase block caught mid-fade (or forming into the player) snaps fully
# visible so the frozen death tableau shows the block that got them.
if self.phase and (self.emerge_kill or self.alpha > 0.0):
self.alpha = 1.0
self.materialized = True
def _step_to(self, tgt, step):
"""Move toward tgt by step; snap and return True on arrival."""
tx, ty = tgt
@@ -546,27 +753,27 @@ class Block(Trap):
# mid-stroke reversal, so a block that moves out of its own sensor range
# can't buzz. Hysteresis (delay/release) smooths the parked decisions.
n = len(self.points)
if self.phase == "rest":
if self.motion_phase == "rest":
if active:
self.phase = "extending"
elif self.phase == "extending":
self.motion_phase = "extending"
elif self.motion_phase == "extending":
if self._step_to(self.points[self.idx + 1], step):
self.idx += 1
if self.idx >= n - 1:
self.phase = "extended"
self.motion_phase = "extended"
self._release_t = 0.0
elif self.phase == "extended":
elif self.motion_phase == "extended":
if active:
self._release_t = 0.0
else:
self._release_t += dt
if self._release_t >= self.release:
self.phase = "retracting"
elif self.phase == "retracting":
self.motion_phase = "retracting"
elif self.motion_phase == "retracting":
if self._step_to(self.points[self.idx - 1], step):
self.idx -= 1
if self.idx <= 0:
self.phase = "rest"
self.motion_phase = "rest"
def _update_patrol(self, active, step):
tgt = self.points[self.idx] if active else self.points[0]
@@ -590,23 +797,30 @@ 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()
def _intangible(self):
return self.deadly or self.fake or (self.crumble and self.cstate == "gone")
if self.deadly or self.fake:
return True
if self.crumble and self.cstate == "gone":
return True
if self.phase and not self.materialized:
return True
return False
def solid_rects(self):
return [] if self._intangible() else [self._rect()]
def hazard_rects(self):
rects = []
if self.deadly:
# A deadly block is a hazard whenever it's present — for a phase block
# that means only once it has materialised.
if self.deadly and (not self.phase or self.materialized):
rects.append(self._rect().inflate(-4, -4))
if self.crumble and self.emerge_kill:
if self.emerge_kill: # crumble re-forming / phase forming into the player
rects.append(self._rect())
return rects
@@ -630,10 +844,22 @@ class Block(Trap):
if self.level.debug:
self._debug_ghost(surface, assets, self.sprite)
return
# phase block still dormant: invisible (debug tints the cell so it shows).
if self.phase and self.alpha <= 0.0:
if self.level.debug:
self._debug_tint(surface, (120, 210, 240), alpha=45)
return
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), self._rt(rect))
img = assets.get(self.sprite, self.tile, self.tile)
if self.phase and self.alpha < 1.0: # fade in/out
img = img.copy()
img.fill(
(255, 255, 255, int(255 * self.alpha)),
special_flags=pygame.BLEND_RGBA_MULT,
)
surface.blit(img, self._rt(rect))
if self.fake and self.level.debug:
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
@@ -654,6 +880,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 +903,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 +935,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,7 +949,8 @@ 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
_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.
@@ -735,8 +968,8 @@ class Warp(Trap):
self.reset()
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
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:
@@ -749,7 +982,7 @@ 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
self._pulse = 1.0 # flash at both ends this frame, then fade
elif not inside:
self._armed = True
@@ -763,8 +996,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,121 +1022,22 @@ 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)
# --- 10. phase block: fades into a solid on trigger -------------------------
class PhaseBlock(Trap):
"""Invisible and intangible until its ``trigger`` fires, then it fades into
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))
self._init_trigger(spec)
self.reset()
def reset(self):
self._reset_trigger()
self.alpha = 0.0
self.solid = False
self.emerge_kill = False
# How deep the player may be into the cell and still be nudged clear rather
# than killed. A shallow clip (feet/shoulder in the cell) gets shoved out;
# forming through their middle stays lethal.
_EDGE_GRACE = 0.5 # fraction of a tile
def update(self, dt, game):
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):
# 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
# into a solid — is it lethal.
if not self._eject_player(game):
self.emerge_kill = True
return
self.alpha = min(1.0, self.alpha + dt / self.fade)
self.solid = True
else:
self.alpha = max(0.0, self.alpha - dt / self.fade)
if self.alpha == 0.0:
self.solid = False
def _eject_player(self, game):
"""Nudge a player who's only clipping the forming block out of its cell.
Returns True if they were pushed clear (forgiving). Returns False — leave
it lethal — when the block is forming through the player's middle (too
deep to fairly eject) or the shove would press them into another solid
(squished against something, a crush as usual)."""
p = game.player.rect
b = self.base_rect
# Distance to move the player to clear the block on each side.
outs = {
"up": p.bottom - b.top,
"down": b.bottom - p.top,
"left": p.right - b.left,
"right": b.right - p.left,
}
side = min(outs, key=outs.get)
dist = outs[side]
if dist > self.tile * self._EDGE_GRACE:
return False # deep overlap — forming through them
dx, dy = _DIRS[side]
moved = p.move(dx * dist, dy * dist)
# The block isn't solid yet, so it's absent from solid_rects(); any hit
# here is a *different* solid backing them — no room to dodge = squished.
if any(moved.colliderect(s) for s in self.level.solid_rects()):
return False
player = game.player
player.fx += dx * dist
player.fy += dy * dist
if dx:
player.vx = 0.0
if dy:
player.vy = 0.0
player._sync_rect()
return True
def finalize_on_death(self):
# If we were forming when the player died, snap to fully visible so the
# frozen death tableau shows the block that got them.
if self.emerge_kill or self.alpha > 0.0:
self.alpha = 1.0
self.solid = True
def solid_rects(self):
return [self.base_rect] if self.solid else []
def hazard_rects(self):
return [self.base_rect] if self.emerge_kill else []
def draw(self, surface, assets):
if self.alpha <= 0.0:
if self.level.debug:
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)
surface.blit(img, self._rt(self.base_rect))
# --- registry + factory ------------------------------------------------------
TRAP_TYPES = {
"spike": Spike,
"block": Block,
"arrow_shooter": ArrowShooter,
"warp": Warp,
"phase_block": PhaseBlock,
}

View File

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

View File

@@ -22,7 +22,7 @@ DT = 1 / 60.0
@pytest.fixture(scope="session", autouse=True)
def _pygame():
pygame.init()
pygame.display.set_mode((64, 64)) # a display so convert_alpha() works
pygame.display.set_mode((64, 64)) # a display so convert_alpha() works
yield
pygame.quit()

View File

@@ -20,12 +20,12 @@ def test_stationary_is_solid():
def test_deadly_is_hazard_not_solid():
b = block(at=[3, 3], deadly=True)
assert b.solid_rects() == []
assert b.hazard_rects() # non-empty
assert b.hazard_rects() # non-empty
def test_fake_is_drawn_but_not_solid():
b = block(at=[3, 3], fake=True)
assert b.solid_rects() == [] # you fall through
assert b.solid_rects() == [] # you fall through
assert b.sprite == "fake_block" # looks like a real block
@@ -53,8 +53,8 @@ traps:
if g.deaths > d0:
killed = True
break
assert "gone" in states # it crumbled away
assert killed # re-formed onto the standing player
assert "gone" in states # it crumbled away
assert killed # re-formed onto the standing player
def test_moving_block_that_crumbles(make_game):
@@ -78,7 +78,7 @@ traps:
respawn: 1.0
""")
b = g.level.traps[0]
place(g, 2, 1) # ride the platform
place(g, 2, 1) # ride the platform
moved = crumbled = False
start = b.x
for _ in range(200):
@@ -103,18 +103,23 @@ def test_patrol_loop_visits_all_waypoints():
def test_slider_once_no_jitter_and_retracts():
# sense=home (default): a slider moving away can't toggle its own trigger.
b = block(at=[16, 6], move=[-3, 0], speed=260, trigger={"within": 3})
g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right
g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced
g.player.rect.x = 30 * 32 # leave
assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced
g.player.rect.x = 30 * 32 # leave
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base
assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base
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)
@@ -124,15 +129,15 @@ def test_sense_current_rides_down():
# keep the player riding the block top
p.bottom = b._rect().top
ys.append(b.y)
assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob
assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob
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()
assert dx != 0 and dy == 0 # moving horizontally
((rect, dx, dy),) = b.carriers()
assert dx != 0 and dy == 0 # moving horizontally
# --- array expansion (count / spacing) --------------------------------------
@@ -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

@@ -100,7 +100,7 @@ traps:
mode: pingpong
speed: 160
""")
place(g, 4, 3) # on the floor, at the block's body level
place(g, 4, 3) # on the floor, at the block's body level
floor_top = 4 * 32
start_x = g.player.rect.x
max_bottom = g.player.rect.bottom
@@ -109,8 +109,8 @@ traps:
max_bottom = max(max_bottom, g.player.rect.bottom)
if g.state != "playing":
break
assert max_bottom <= floor_top + 1 # never pushed into the floor
assert g.player.rect.x < start_x # got shoved along
assert max_bottom <= floor_top + 1 # never pushed into the floor
assert g.player.rect.x < start_x # got shoved along
def test_ride_into_wall_stops_no_crush(make_game):
@@ -137,7 +137,7 @@ traps:
""")
# stand the player on top of the platform at its left end (body in row 2)
place(g, 1, 2)
g.player.fy = float(3 * 32 - g.player.h) # feet on the block's top
g.player.fy = float(3 * 32 - g.player.h) # feet on the block's top
g.player._sync_rect()
wall_left = 8 * 32
hit_wall = False
@@ -145,12 +145,12 @@ traps:
step(g)
if g.state != "playing":
break
assert not g.player.crushed # never crushed
assert g.player.rect.right <= wall_left + 1 # stopped at the wall
assert not g.player.crushed # never crushed
assert g.player.rect.right <= wall_left + 1 # stopped at the wall
if g.player.rect.right >= wall_left - 1:
hit_wall = True
assert g.state == "playing" # survived the whole time
assert hit_wall # actually reached the wall
assert g.state == "playing" # survived the whole time
assert hit_wall # actually reached the wall
def test_corner_clip_does_not_warp(make_game):
@@ -177,7 +177,7 @@ traps:
prev = g.player.rect.x
for _ in range(120):
step(g, hold(left=True))
assert g.player.rect.x - prev <= 28 # no sudden rightward warp
assert g.player.rect.x - prev <= 28 # no sudden rightward warp
prev = g.player.rect.x
@@ -213,4 +213,4 @@ traps:
if not g.player.on_ground and g.player.rect.bottom > 3 * 32:
left_at = reversalsN
break
assert left_at == 0 # fell before any block reversal
assert left_at == 0 # fell before any block reversal

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
@@ -23,7 +22,7 @@ def test_level_parse(make_level):
assert lvl.width == 5 * 32 and lvl.height == 3 * 32
assert lvl.spawn == (1 * 32, 1 * 32)
assert lvl.goal_rect.topleft == (3 * 32, 1 * 32)
assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls
assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls
def test_unknown_trap_type_is_skipped(make_level):
@@ -38,7 +37,7 @@ traps:
- type: not_a_real_trap
at: [1, 1]
""")
assert lvl.traps == [] # skipped, no crash
assert lvl.traps == [] # skipped, no crash
def test_death_pause_then_respawn(make_game):
@@ -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,24 +69,26 @@ 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
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
die()
assert g.level_deaths == 1 and g.deaths == 4
g._replay()
assert g.index == 0 and g.deaths == 0 # replay resets the total
assert g.index == 0 and g.deaths == 0 # replay resets the total
def test_reach_goal_charges_then_wins(make_game):
g = make_game(SIMPLE)
g._reach_goal()
assert g.state == "charging"
assert 0 <= g.charge_from < 0.2 # near-empty start
assert 0 <= g.charge_from < 0.2 # near-empty start
for _ in range(70):
step(g)
assert g.state == "won_all" # single level -> won_all
assert g.state == "won_all" # single level -> won_all
def test_fade_swaps_level_at_black(make_game, tmp_path):
@@ -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)
@@ -110,32 +113,45 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
done = i
break
assert swapped and done and g.index == 1
assert done - swapped >= 15 # fade-in ~0.3s not skipped
assert done - swapped >= 15 # fade-in ~0.3s not skipped
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()
assert len(calls) == 1 # never recreated the window
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,39 +186,46 @@ 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
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
while g.state == "dying":
step(g)
assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit
assert g.level_deaths == 1 # counted, not reset to 0
assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit
assert g.level_deaths == 1 # counted, not reset to 0
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"))
p.write_text("name: A\n bad: [unclosed\n") # invalid YAML
p.write_text("name: A\n bad: [unclosed\n") # invalid YAML
g._reload_level()
while g.state == "dying":
step(g)
assert g.state == "playing" # fell back, no crash
assert g.level.name == "A" # kept the old level
assert g.state == "playing" # fell back, no crash
assert g.level.name == "A" # kept the old level
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))
assert painted(True) > painted(False) # grid + labels add ink
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

@@ -18,10 +18,10 @@ map: |
def test_falls_and_lands_on_floor(make_game):
g = make_game(FLAT)
place(g, 4, 1) # up in the air
place(g, 4, 1) # up in the air
run(g, 60)
assert g.player.on_ground
assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160)
assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160)
def test_terminal_velocity(make_game):
@@ -34,13 +34,13 @@ def test_terminal_velocity(make_game):
def test_jump_gains_height_then_returns(make_game):
g = make_game(FLAT)
run(g, 30) # settle on floor
run(g, 30) # settle on floor
ground = g.player.rect.bottom
peak = ground
for i in range(60):
step(g, hold(jump_pressed=(i == 0), jump_held=True))
peak = min(peak, g.player.rect.bottom)
assert peak < ground - 32 # rose at least a tile
assert peak < ground - 32 # rose at least a tile
def test_variable_jump_height(make_game):
@@ -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
@@ -71,22 +72,22 @@ map: |
#P#...#
#######
""")
place(g, 1, 3) # standing on the little step at [1,3] top
place(g, 1, 3) # standing on the little step at [1,3] top
# walk right off the step for a couple frames, then jump
run(g, 4, lambda i: hold(right=True))
y_before = g.player.rect.bottom
step(g, hold(right=True, jump_pressed=True, jump_held=True))
step(g, hold(right=True, jump_held=True))
assert g.player.vy < 0 # a jump actually started
assert g.player.vy < 0 # a jump actually started
def test_walls_stop_horizontal_movement(make_game):
g = make_game(FLAT)
place(g, 1, 4)
run(g, 120, lambda i: hold(right=True))
assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288)
assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288)
run(g, 120, lambda i: hold(left=True))
assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32)
assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32)
def test_oneway_platform_land_from_above_pass_from_below(make_game):
@@ -103,7 +104,7 @@ map: |
#######
""")
# Fall onto the one-way from above -> lands on it.
place(g, 1, 1) # open air above the one-way (row 3)
place(g, 1, 1) # open air above the one-way (row 3)
run(g, 60)
assert g.player.on_ground and g.player.rect.bottom == 3 * 32
@@ -119,12 +120,12 @@ map: |
#P...G#
#######
""")
place(g2, 1, 4) # on the floor, below the one-way (row 2)
run(g2, 5) # settle so on_ground is set before jumping
place(g2, 1, 4) # on the floor, below the one-way (row 2)
run(g2, 5) # settle so on_ground is set before jumping
passed = False
for i in range(40):
step(g2, hold(jump_pressed=(i == 0), jump_held=(i < 12)))
if g2.player.rect.top < 2 * 32: # rose above the one-way row
if g2.player.rect.top < 2 * 32: # rose above the one-way row
passed = True
assert passed
@@ -142,10 +143,10 @@ map: |
#....G#
#######
""")
place(g, 1, 2) # standing on the one-way at row 3
place(g, 1, 2) # standing on the one-way at row 3
run(g, 20)
assert g.player.on_ground
# press down + jump to drop through
for i in range(30):
step(g, hold(down=True, jump_pressed=(i == 0)))
assert g.player.rect.top > 3 * 32 # fell below the one-way
assert g.player.rect.top > 3 * 32 # fell below the one-way

View File

@@ -1,10 +1,10 @@
"""Spike, arrow_shooter, warp, phase_block + the invisible flag & block arrays."""
"""Spike, arrow_shooter, warp, phase blocks + the invisible flag & block arrays."""
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
from game.traps import Spike, ArrowShooter, Warp, Block
# --- spike -------------------------------------------------------------------
@@ -51,7 +51,7 @@ traps:
def test_spike_sprite_rotates_per_direction(tmp_path):
# A deliberately asymmetric sprite so each rotation is distinct.
surf = pygame.Surface((8, 8), pygame.SRCALPHA)
surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only
surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only
adir = tmp_path / "assets"
adir.mkdir()
pygame.image.save(surf, str(adir / "spike.png"))
@@ -62,7 +62,8 @@ 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 h(up) != h(down) # rotation actually happened
assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
@@ -85,10 +86,10 @@ traps:
""")
sh = lvl.traps[0]
g = FakeGame(pygame.Rect(0, 0, 4, 4))
for _ in range(40): # ~0.66s -> at least one shot
for _ in range(40): # ~0.66s -> at least one shot
sh.update(DT, g)
assert sh.arrows # spawned arrows
assert sh.hazard_rects() # arrows are hazards
assert sh.arrows # spawned arrows
assert sh.hazard_rects() # arrows are hazards
def test_arrow_shooter_trigger_gates_firing(make_level):
@@ -108,10 +109,10 @@ traps:
trigger: { within: 1 }
""")
sh = lvl.traps[0]
far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near
far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near
for _ in range(60):
sh.update(DT, far)
assert not sh.arrows # never fired while player out of range
assert not sh.arrows # never fired while player out of range
# --- invisible wall, now an array of invisible blocks ------------------------
@@ -132,10 +133,10 @@ traps:
count: [1, 2]
""")
blocks = [t for t in lvl.traps if isinstance(t, Block)]
assert len(blocks) == 2 # one per cell
assert len(blocks) == 2 # one per cell
assert all(b.invisible and b.solid_rects() for b in blocks)
ys = sorted(b.current_rect().top for b in blocks)
assert ys == [1 * 32, 2 * 32] # stacked vertically
assert ys == [1 * 32, 2 * 32] # stacked vertically
# --- warp --------------------------------------------------------------------
@@ -178,9 +179,9 @@ traps:
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.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
@@ -210,17 +211,18 @@ map: |
#P......G#
##########
traps:
- type: phase_block
- type: block
at: [4, 3]
phase: true
fade: 0.2
trigger: { within: 2 }
""")
pb = g.level.traps[0]
place(g, 1, 3)
step(g)
assert not pb.solid and pb.alpha == 0 # dormant far away
assert not pb.materialized and pb.alpha == 0 # dormant far away
run(g, 40, lambda i: hold(right=True))
assert pb.solid and pb.alpha > 0 # phased in solid on approach
assert pb.materialized and pb.alpha > 0 # phased in solid on approach
def test_phase_block_kills_if_inside_when_it_forms(make_game):
@@ -233,13 +235,14 @@ map: |
#.....G#
########
traps:
- type: phase_block
- type: block
at: [3, 2]
phase: true
fade: 0.2
trigger: { within: 3 }
""")
pb = g.level.traps[0]
place(g, 3, 2) # standing right where it will form
place(g, 3, 2) # standing right where it will form
killed, d0 = False, g.deaths
for _ in range(10):
step(g)
@@ -260,15 +263,16 @@ map: |
#......G#
#########
traps:
- type: phase_block
- type: block
at: [4, 1]
phase: true
fade: 0.2
trigger: { within: 5 }
""")
pb = g.level.traps[0]
b = pb.base_rect
# Straddle the block's left edge: mostly outside, just clipping into it.
g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap
g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap
g.player.fy = float(b.top + 2)
g.player.vx = g.player.vy = 0.0
g.player._sync_rect()
@@ -277,7 +281,7 @@ traps:
# survived, and pushed out to the left so it no longer overlaps the cell
assert g.deaths == d0
assert g.player.rect.right <= b.left
assert pb.solid and not pb.emerge_kill
assert pb.materialized and not pb.emerge_kill
def test_phase_block_kills_when_shove_would_squish(make_game):
@@ -291,8 +295,9 @@ map: |
#.....G#
########
traps:
- type: phase_block
- type: block
at: [1, 1]
phase: true
fade: 0.2
trigger: { within: 5 }
""")
@@ -325,27 +330,67 @@ map: |
#.....G#
########
traps:
- type: phase_block
- type: block
at: [3, 2]
phase: true
fade: 0.5
trigger: { within: 3 }
""")
pb = g.level.traps[0]
place(g, 5, 2) # near enough to trigger, not on the cell
for _ in range(6): # let it partially fade in
place(g, 5, 2) # near enough to trigger, not on the cell
for _ in range(6): # let it partially fade in
step(g)
assert 0 < pb.alpha < 1
g._start_death() # die from something
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
g._start_death() # die from something
assert pb.alpha == 1.0 and pb.materialized # snapped fully visible for the freeze
def test_deadly_phase_block_harmless_until_materialized(make_game):
# A deadly phase block is neither solid nor a hazard while dormant, then
# becomes a (never-solid) hazard once it materialises.
g = make_game("""
name: t
tile_size: 32
map: |
##########
#........#
#........#
#P......G#
##########
traps:
- type: block
at: [4, 2]
phase: true
deadly: true
fade: 0.15
trigger: { within: 3 }
""")
b = g.level.traps[0]
place(g, 1, 3) # far away -> dormant
step(g)
assert not b.solid_rects() and not b.hazard_rects()
# Materialise it by standing on the cell; a deadly block never becomes solid.
place(g, 4, 2)
d0 = g.deaths
killed = False
for _ in range(12):
step(g)
if g.deaths > d0:
killed = True
break
assert killed # the materialised hazard kills
assert not b.solid_rects() # deadly -> never solid, even materialised
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)
@@ -354,7 +399,7 @@ def test_debug_overscan_reveals_offmap_trap(make_level):
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
assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan
# --- generic invisible flag --------------------------------------------------
@@ -384,9 +429,12 @@ 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
assert sp.hazard_rects() # still deadly either way
assert painted(False) is False # invisible in play
assert painted(True) is True # revealed in debug
assert sp.hazard_rects() # still deadly either way

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
@@ -17,26 +20,26 @@ def test_always():
def test_within_radius():
assert ev({"within": 2}, 106, 104) is True # ~10px away
assert ev({"within": 2}, 400, 116) is False # far
assert ev({"within": 2}, 106, 104) is True # ~10px away
assert ev({"within": 2}, 400, 116) is False # far
def test_dir_left_right():
assert ev({"dir": "left"}, 40, 105) is True # to the left
assert ev({"dir": "left"}, 200, 105) is False # to the right
assert ev({"dir": "left"}, 40, 105) is True # to the left
assert ev({"dir": "left"}, 200, 105) is False # to the right
assert ev({"dir": "right"}, 200, 105) is True
def test_dir_range():
assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left
assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left
assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left
assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left
def test_dir_aligned():
# above + aligned requires horizontal overlap with the trap column
assert ev({"dir": "above", "aligned": True}, 108, 60) is True
assert ev({"dir": "above", "aligned": True}, 108, 110) is False # not above
assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned
assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned
# without aligned, any column counts
assert ev({"dir": "above"}, 400, 60) is True
@@ -44,8 +47,8 @@ def test_dir_aligned():
def test_dir_inclusive():
# trap column spans x 100..132 (centre 116). A player standing *inside* the
# column (centre 116) is neither strictly left nor strictly right of it.
assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left
assert ev({"dir": "right"}, 106, 105) is False # inside -> not right
assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left
assert ev({"dir": "right"}, 106, 105) is False # inside -> not right
# inclusive counts the trap's own tile as being on that side
assert ev({"dir": "left", "inclusive": True}, 106, 105) is True
assert ev({"dir": "right", "inclusive": True}, 106, 105) is True
@@ -59,24 +62,26 @@ def test_dir_inclusive():
def test_all_and_any():
c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]}
assert ev(c, 108, 80) is True
assert ev(c, 108, 110) is False # close but not above
assert ev(c, 108, 110) is False # close but not above
c2 = {"any": [{"dir": "left"}, {"dir": "right"}]}
assert ev(c2, 40, 105) is True
assert ev(c2, 110, 40) is False # above-only satisfies neither
assert ev(c2, 110, 40) is False # above-only satisfies neither
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
assert any(states) and not all(states) # cycles on and off
assert states[0] is False # starts in the "off" interval
assert any(states) and not all(states) # cycles on and off
def test_bad_condition_raises():
import pytest
with pytest.raises(ValueError):
make_condition({"nope": 1})
@@ -97,10 +102,128 @@ traps:
delay: 0.25
""")
sp = lvl.traps[0]
g = FakeGame(sp.base_rect.copy()) # player right on it -> in range
g = FakeGame(sp.base_rect.copy()) # player right on it -> in range
armed = None
for i in range(30):
if sp.triggered(g, 1 / 60):
armed = i
break
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
# --- per-target triggers/delays (a block can move AND phase) -----------------
def _block(make_level, body):
lvl = make_level(
"name: t\ntile_size: 32\nmap: |\n ######\n #P..G#\n ######\ntraps:\n" + body
)
return lvl.traps[0]
def _cond(block, target):
return type(block._trig[target]["cond"]).__name__
def test_trigger_single_condition_applies_to_all_targets(make_level):
# A bare condition (not a target map) drives both motion and phase.
b = _block(
make_level, " - type: block\n at: [2, 1]\n trigger: { within: 2 }\n"
)
assert _cond(b, "motion") == "_Within"
assert _cond(b, "phase") == "_Within"
def test_trigger_all_any_is_a_single_condition(make_level):
# `all`/`any` are condition keywords, not target names -> one condition.
b = _block(
make_level,
" - type: block\n at: [2, 1]\n"
" trigger: { any: [ { within: 2 }, { dir: left } ] }\n",
)
assert _cond(b, "motion") == "_Any" and _cond(b, "phase") == "_Any"
def test_trigger_per_target_map_with_default(make_level):
# `default` covers unnamed targets; a named target overrides it.
b = _block(
make_level,
" - type: block\n at: [2, 1]\n phase: true\n"
" trigger:\n default: always\n motion: { within: 5 }\n",
)
assert _cond(b, "motion") == "_Within" # named
assert _cond(b, "phase") == "_Always" # from default
def test_trigger_unnamed_target_off_without_default(make_level):
# A map naming only motion (no default) leaves phase with no trigger -> off.
b = _block(
make_level,
" - type: block\n at: [2, 1]\n phase: true\n"
" trigger: { motion: always }\n",
)
assert "phase" in b._trig_never
assert _cond(b, "phase") == "_Never" and _cond(b, "motion") == "_Always"
def test_trigger_unknown_target_raises(make_level):
import pytest
with pytest.raises(ValueError):
_block(
make_level,
" - type: block\n at: [2, 1]\n"
" trigger: { motion: always, bogus: always }\n",
)
def test_delay_scalar_applies_to_all_targets(make_level):
b = _block(make_level, " - type: block\n at: [2, 1]\n delay: 0.2\n")
assert b._trig["motion"]["delay"] == 0.2 and b._trig["phase"]["delay"] == 0.2
def test_delay_per_target_map(make_level):
b = _block(
make_level,
" - type: block\n at: [2, 1]\n delay: { motion: 0.1, phase: 0.3 }\n",
)
assert b._trig["motion"]["delay"] == 0.1 and b._trig["phase"]["delay"] == 0.3
# unnamed-without-default delay falls back to 0 (no arm delay)
b2 = _block(
make_level, " - type: block\n at: [2, 1]\n delay: { motion: 0.1 }\n"
)
assert b2._trig["phase"]["delay"] == 0.0
def test_motion_and_phase_independent_triggers(make_level):
# The user's case: a phase block that's *always* moving but only materialises
# when the player is near. Motion and phase read their own triggers.
lvl = make_level("""
name: t
tile_size: 32
map: |
############
#..........#
#P........G#
############
traps:
- type: block
at: [4, 1]
phase: true
move: [3, 0]
speed: 200
fade: 0.2
trigger:
motion: always
phase: { within: 2 }
""")
b = lvl.traps[0]
b.reset()
far = FakeGame(pygame.Rect(1 * 32, 1 * 32, 20, 28)) # player at the far end
x0 = b._rect().x
for _ in range(20):
b.tick(1 / 60, far)
assert b._rect().x != x0 # moved (motion: always) ...
assert b.alpha == 0.0 # ... but hasn't materialised (player far)
near = FakeGame(pygame.Rect(b._rect().x + 2, 1 * 32, 20, 28))
for _ in range(10):
b.tick(1 / 60, near)
assert b.alpha > 0.0 # phases in once the player is close

View File

@@ -21,11 +21,11 @@ def _s():
# --- individual sprites ------------------------------------------------------
def draw_block(s):
s.fill((96, 104, 122)) # stone
s.fill((96, 104, 122)) # stone
seam = (70, 76, 92)
pygame.draw.rect(s, seam, (0, 0, TILE, TILE), 2) # border
pygame.draw.line(s, seam, (0, 16), (TILE, 16), 2) # course seam
pygame.draw.line(s, seam, (16, 2), (16, 16), 2) # offset bricks
pygame.draw.rect(s, seam, (0, 0, TILE, TILE), 2) # border
pygame.draw.line(s, seam, (0, 16), (TILE, 16), 2) # course seam
pygame.draw.line(s, seam, (16, 2), (16, 16), 2) # offset bricks
pygame.draw.line(s, seam, (8, 16), (8, TILE - 2), 2)
pygame.draw.line(s, seam, (24, 16), (24, TILE - 2), 2)
pygame.draw.line(s, (132, 140, 158), (2, 2), (TILE - 3, 2), 1) # top highlight
@@ -38,33 +38,34 @@ def draw_fake_block(s):
def draw_player(s):
body = pygame.Rect(6, 2, 20, 28)
pygame.draw.rect(s, (40, 44, 60), body, border_radius=5) # phone body
pygame.draw.rect(s, (18, 20, 30), body, 2, border_radius=5) # outline
pygame.draw.rect(s, (95, 205, 255), (9, 6, 14, 16)) # screen
pygame.draw.rect(s, (20, 30, 45), (12, 10, 3, 4)) # eyes
pygame.draw.rect(s, (40, 44, 60), body, border_radius=5) # phone body
pygame.draw.rect(s, (18, 20, 30), body, 2, border_radius=5) # outline
pygame.draw.rect(s, (95, 205, 255), (9, 6, 14, 16)) # screen
pygame.draw.rect(s, (20, 30, 45), (12, 10, 3, 4)) # eyes
pygame.draw.rect(s, (20, 30, 45), (18, 10, 3, 4))
pygame.draw.line(s, (20, 30, 45), (12, 17), (20, 17), 2) # smile
pygame.draw.rect(s, (120, 126, 140), (14, 25, 4, 2)) # home button
pygame.draw.line(s, (20, 30, 45), (12, 17), (20, 17), 2) # smile
pygame.draw.rect(s, (120, 126, 140), (14, 25, 4, 2)) # home button
def draw_player_dead(s):
body = pygame.Rect(6, 2, 20, 28)
pygame.draw.rect(s, (62, 42, 46), body, border_radius=5)
pygame.draw.rect(s, (30, 20, 22), body, 2, border_radius=5)
pygame.draw.rect(s, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen
for ex in (11, 17): # X eyes
pygame.draw.rect(s, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen
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):
pad = pygame.Rect(4, 4, 24, 24)
pygame.draw.rect(s, (36, 110, 66), pad, border_radius=5) # charger pad
pygame.draw.rect(s, (36, 110, 66), pad, border_radius=5) # charger pad
pygame.draw.rect(s, (90, 230, 140), pad, 2, border_radius=5)
bolt = [(19, 5), (10, 18), (15, 18), (12, 27), (23, 13), (17, 13)]
pygame.draw.polygon(s, (245, 240, 130), bolt) # lightning bolt
pygame.draw.polygon(s, (245, 240, 130), bolt) # lightning bolt
pygame.draw.polygon(s, (200, 190, 80), bolt, 1)
@@ -77,43 +78,46 @@ def draw_spike(s):
pts = [(x, TILE), (x + w / 2, 3), (x + w, TILE)]
pygame.draw.polygon(s, base, pts)
pygame.draw.polygon(s, edge, pts, 1)
pygame.draw.rect(s, edge, (0, TILE - 4, TILE, 4)) # base strip
pygame.draw.rect(s, edge, (0, TILE - 4, TILE, 4)) # base strip
def draw_moving_block(s):
s.fill((172, 122, 72)) # crate
s.fill((172, 122, 72)) # crate
pygame.draw.rect(s, (120, 80, 45), (0, 0, TILE, TILE), 2)
for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]:
pygame.draw.circle(s, (92, 60, 35), (bx, by), 2) # bolts
pygame.draw.circle(s, (92, 60, 35), (bx, by), 2) # bolts
plate = pygame.Rect(10, 10, 12, 12)
pygame.draw.rect(s, (152, 106, 60), plate)
pygame.draw.rect(s, (120, 80, 45), plate, 1)
def draw_patrol_block(s):
s.fill((122, 102, 178)) # platform
s.fill((122, 102, 178)) # platform
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.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
)
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)
def draw_arrow_shooter(s):
s.fill((72, 76, 92)) # turret block
s.fill((72, 76, 92)) # turret block
pygame.draw.rect(s, (45, 48, 60), (0, 0, TILE, TILE), 2)
for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]:
pygame.draw.circle(s, (40, 42, 52), (bx, by), 2) # rivets
pygame.draw.circle(s, (40, 42, 52), (bx, by), 2) # rivets
pygame.draw.circle(s, (22, 24, 30), (TILE // 2, TILE // 2), 6) # barrel
pygame.draw.circle(s, (150, 64, 64), (TILE // 2, TILE // 2), 3)
@@ -122,14 +126,18 @@ def draw_spike_block(s):
base, edge = (152, 158, 174), (84, 88, 104)
m = TILE // 2
tris = [
[(m, 0), (m - 4, 11), (m + 4, 11)], # up
[(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down
[(0, m), (11, m - 4), (11, m + 4)], # left
[(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right
[(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
[(m, 0), (m - 4, 11), (m + 4, 11)], # up
[(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down
[(0, m), (11, m - 4), (11, m + 4)], # left
[(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right
[(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
]
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):