Initial commit

This commit is contained in:
James Campbell
2026-07-21 19:36:02 -04:00
commit d84e4627c3
39 changed files with 4386 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__

238
README.md Normal file
View File

@@ -0,0 +1,238 @@
# Dying Phone
A single-screen 2D platformer in the spirit of the original *Super Mario Bros.*,
built with Python + pygame. Your phone is dying; sprint to the charger at the
end of each level before the battery hits zero. The catch: the level is packed
with obvious and (mostly) hidden traps. Touch one and you respawn instantly at
the start with every trap reset. The battery is just the story's excuse for a
timer — running it down kills you the same as any spike.
## Install & run
```bash
pip install -r requirements.txt
python main.py # play every level in ./levels
python main.py levels/level1.yaml # play a specific level (or several)
python main.py --debug # force the debug view on for all levels
```
No art required — everything renders as labeled colored rectangles until you
drop PNGs into `assets/` (see `assets/README.md`).
### Tests
```bash
pip install -r requirements-dev.txt
pytest
```
The suite runs headless (SDL dummy driver) and covers the physics, collision
resolution, every trap and trigger, mounting, crush/shove interactions, and the
game-flow states. See `SPEC.md` for a full description of the engine and level
format.
## Controls
| Key | Action |
|--------------------|-------------------------------------------|
| `A` / `D` (arrows) | move left / right |
| `Space` / `W` | jump (hold = higher, tap = short hop) |
| `S` + `Space` | drop through one-way platforms |
| `R` | give up and respawn |
| `F5` | hot-reload the level from disk (counts as a death; also escapes soft-locks) |
| `Esc` | quit |
| `Enter` | advance on the level-complete screen |
## Writing levels
Levels are plain YAML. A level has an ASCII `map` for static geometry and a
`traps` list for the nasty interactive bits.
```yaml
name: "My Level"
tile_size: 32 # pixels per tile (optional; default 32)
battery_seconds: 40 # time limit (optional)
map: |
.........
...###...
.P.....G.
#########
traps:
- type: spike
at: [4, 2] # [col, row], 0-based from top-left
direction: up
trigger: { within: 2.0 }
```
### Map legend
| Char | Meaning |
|------|-------------------------------------------|
| `#` | solid block |
| `-` | one-way platform — stand on top, jump/drop through. Renders **identically to a solid block**, so it's a hidden mechanic. |
| `P` | player spawn |
| `G` | goal / phone charger |
| `.` or space | empty |
The window is sized to the map automatically — one screen, no scrolling.
Level options (all optional, alongside `name` / `map` / `traps`):
| Key | Default | Meaning |
|---|---|---|
| `tile_size` | 32 | pixels per tile |
| `battery_seconds` | 45 | time limit |
| `battery_pct` | 12 | how full the battery bar *looks* at the start (visual only) |
| `debug` | false | reveal everything normally hidden — a tile grid with col/row labels, faded `-` one-way platforms, tinted fake blocks / invisible walls / warps (with a line to their destination), dormant phase blocks, not-yet-sprung spikes, crumbled-block ghosts, and moving block paths. The `--debug` CLI flag forces this on for every level. Combine with `F5` to iterate on a level without relaunching. |
## Trap reference
All traps take `at: [col, row]`. Where a trap draws its own block (fake, moving,
patrol, crumble, shooter), leave that map cell **empty** — otherwise a real
solid sits underneath it.
Any trap can also take:
- **`invisible: true`** — stays fully functional but isn't drawn (revealed under
the level's `debug` view). An invisible solid `block` is a wall you can't see.
- **`count: [nx, ny]`** (or a single int for a horizontal line) — place a
line/grid of copies, offset by **`spacing: [sx, sy]`** tiles (default 1). Only
`at` is shifted per copy, so `move` is relative and works; an absolute `path`
is shared. Handy for a row of spikes or a rectangle of (invisible) blocks.
| `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. |
| `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):
| 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 |
| `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) |
| `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 |
| `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
- type: block # stationary spike block
at: [13, 4]
deadly: true
- type: block # dropper: extends down while you're above it, rides with you
at: [21, 9]
move: [0, 4]
sense: current
trigger: { all: [ {within: 5}, {dir: above, aligned: true} ] }
- type: block # patrolling platform
at: [9, 10]
path: [[9, 10], [14, 10], [14, 6]]
mode: loop
sprite: patrol_block
```
A `once` block runs a *committed stroke*: once it starts moving it runs all the
way to the endpoint (never reversing mid-stroke) and only reconsiders its
trigger while parked — so a slider that moves out of its own sensor range can't
buzz. Blocks track their live position, so a moving block can shove or crush
you, and you can ride a non-deadly one.
### Triggers
`spike`, `block`, and `arrow_shooter` are active while a **trigger
condition** holds. A trigger is `always` (the default) or a condition object
measured against the player each frame, relative to the trap's *current*
position (so a moving trap's sensors follow it):
| condition | meaning |
|---|---|
| `{ within: N }` | player within N tiles (radius) |
| `{ dir: left\|right\|above\|below }` | player is on that side |
| `{ dir: …, range: N }` | …and within N tiles that way |
| `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (i.e. *directly* left / *directly* above) |
| `{ timer: { interval: A, up_time: B } }` | cyclic: off A seconds, on B seconds |
| `{ all: [ … ] }` | every listed condition (AND) |
| `{ any: [ … ] }` | any listed condition (OR) |
```yaml
# drops only when you're close AND standing over it; keeps dropping as you ride it
trigger:
all:
- { within: 5 }
- { dir: above, aligned: true }
```
**`delay`** (seconds, default `0`): the trigger condition must hold
*continuously* this long before the trap arms; leaving the condition resets the
countdown. Handy for "linger and it strikes" spikes.
### Getting crushed
You also die if a moving (non-deadly) `block` **pinches** you: presses you
against a solid surface — squished into the ground from above, carried up into a
ceiling, or ridden sideways into a wall. During the death pause the whole scene
freezes, so the offending block stops on the spot until you respawn.
### Mounting traps on other traps
Any trap can carry a `mounts:` list of child traps that ride along with it. A
mounted trap's `at` is read as a **relative** offset (in tiles) from its parent,
and it tracks the parent's live position every frame — so you can bolt a spike
onto a patrolling platform, or a turret onto a moving block:
```yaml
- type: block
path: [[6, 10], [9, 10]]
mode: pingpong
sprite: patrol_block
mounts:
- type: spike
at: [0, -1] # one tile above the block; moves with it
trigger: always
direction: up
```
Everything the mounted trap does — hazards, solids, trigger sensing, drawing —
follows the parent automatically; no special-casing per trap type. You can mount
a `block` too (e.g. deadly blocks at the ends of a moving platform for spikes on
a moving hazard) — a mounted trap **rides rigidly** at its offset and doesn't
run its own motion, so mount stationary pieces (spikes, blocks, turrets) rather
than something you expect to move on its own.
### Adding a new trap type
Subclass `Trap` in `game/traps.py`, implement any of the hooks
(`solid_rects`, `oneway_rects`, `hazard_rects`, `carriers`, `update`, `draw`,
`reset`), and register the class in `TRAP_TYPES`. The engine polls those hooks
every frame — nothing else needs to change.
## Project layout
```
main.py entry point
game/
game.py loop, states, HUD, death/respawn/win flow
level.py YAML -> geometry + traps
player.py movement & AABB collision
traps.py trap base class + all trap types + factory
assets.py PNG loading with colored-rect placeholders
settings.py physics/gameplay tunables
levels/*.yaml level definitions
assets/*.png optional sprites
```

439
SPEC.md Normal file
View File

@@ -0,0 +1,439 @@
# Dying Phone — Engine & Level-Format Specification
This document describes the game engine and the level configuration format in
enough detail to reimplement equivalent functionality from scratch. It does
**not** describe the specific shipped levels — only the mechanics and the data
format levels are written in.
---
## 1. Concept
A single-screen 2D platformer. Your phone is dying; each level's goal is to
reach the charger before the battery runs out. Levels are dense with obvious
and (mostly) hidden traps. Touching a hazard, falling out of the world, or
running the battery to zero kills you and instantly respawns you at the start
with every trap reset. There is no scrolling — each level fits one screen.
Built with Python + pygame. Art is optional: any missing sprite is drawn as a
labeled colored rectangle, so the game is fully playable with zero assets.
---
## 2. Runtime, entry point, CLI
- Dependencies: `pygame` (>= 2.5), `PyYAML`.
- Entry point `main.py`:
- `python main.py` — play every `*.yaml`/`*.yml` in `./levels`, sorted.
- `python main.py a.yaml b.yaml …` — play the given level files in order.
- `--debug` flag (anywhere in args) — force the debug view on for all levels.
- The game plays levels in sequence; finishing the last shows a win screen, and
ENTER restarts from the first level.
---
## 3. Coordinates, tiles, window
- The world is a grid of square tiles. Tile size defaults to **32 px** and can
be overridden per level (`tile_size`).
- Grid coordinates are `[col, row]`, 0-based from the top-left of the map. A
cell's pixel rect is `(col*tile, row*tile, tile, tile)`.
- Y increases downward (screen convention).
- The window is sized **once** at startup to fit the tallest and widest level
(plus a HUD band of 46 px on top; minimum window width 480 px). It is never
resized afterward — recreating the display mid-session makes the OS window
flicker/close. Each level's play area is a surface of its own pixel size,
centered within the fixed window below the HUD; smaller levels are
letterboxed with a dark background.
---
## 4. Game loop & states
Fixed logical timestep driven by `pygame.time.Clock` at 60 FPS. Each frame `dt`
is clamped to a max of `1/30` s to avoid tunneling on lag spikes.
Per frame: poll input → run the current state's update → draw.
States:
- **playing** — normal simulation (battery drains, level & player update, death
and goal checks run).
- **dying** — death pause: the whole scene is frozen (no level/player update);
a timer counts down `DEATH_PAUSE` (0.5 s) showing the corpse, then respawns.
- **charging** — level-clear beat: everything frozen while the battery-fill
animation plays for `CHARGE_TIME` (1.0 s); then transitions to a win splash.
- **won_level** / **won_all** — win splash overlays (per-level vs final).
- **fading** — a fade-to-black transition between levels (see §8).
Input is edge- and level-sensitive:
- `A`/`D` or arrows → move; `S`/down → down (used for dropping through
one-ways); `Space`/`W`/`Up` → jump (both an edge `jump_pressed` and a held
`jump_held`).
- `R` → give up (triggers a death). `Esc` → quit. `Enter` → advance/replay on a
win splash.
- `F5` → hot-reload the current level file from disk. It is treated as a death:
it plays the death beat and increments both counters (so it also escapes
soft-locks), and on the following respawn the level is re-read from its file
(picking up any edits) instead of merely reset. A malformed edit is caught and
logged rather than crashing (the old level is kept). The per-level death count
carries across the reload.
---
## 5. Player physics
The player is an axis-aligned rectangle sized `0.72*tile` wide × `0.92*tile`
tall. Position is tracked as floats (`fx, fy`); the integer collision rect is
`round`ed from them each substep.
### 5.1 Tunables (pixels, seconds)
| Constant | Value | Meaning |
|---|---|---|
| `GRAVITY` | 2200 | downward acceleration (px/s²) |
| `MAX_FALL` | 1400 | terminal downward velocity |
| `MOVE_SPEED` | 320 | horizontal run speed |
| `ACCEL` | 3200 | ground acceleration toward target speed |
| `AIR_ACCEL` | 2200 | weaker air control |
| `FRICTION` | 3600 | ground deceleration when no input |
| `JUMP_SPEED` | 760 | initial upward velocity of a jump |
| `JUMP_CUT` | 0.45 | fraction of upward velocity kept if jump released early |
| `COYOTE_TIME` | 0.10 | grace window to jump after leaving a ledge |
| `JUMP_BUFFER` | 0.10 | how long a jump press is remembered before landing |
### 5.2 Update order (per frame)
1. **Ride platforms** — if standing on a moving platform (feet within 3 px of a
carrier's top, with horizontal overlap), add that carrier's per-frame motion
`(dx, dy)` to the player and record it as `carry`.
2. **Push by movers** — if a block moving horizontally overlaps the player's
side (with vertical overlap ≥ half the player height), displace the player
out the *nearer* horizontal edge so the block shoves them along that axis
(rather than the vertical pass burying them).
3. **Horizontal** — accelerate `vx` toward the input target (or apply friction),
integrate `fx`, then resolve X collisions.
4. **Vertical** — update jump timers, apply jump/gravity, integrate `fy`, then
resolve Y collisions.
5. **Crush check** — set `crushed` (see §5.6).
### 5.3 Jumping
- **Buffered**: a jump press within `JUMP_BUFFER` before landing still fires on
landing.
- **Coyote**: a jump within `COYOTE_TIME` after walking off a ledge still fires.
- **Variable height**: releasing jump mid-rise multiplies remaining upward
velocity by `JUMP_CUT` once (tap = short hop, hold = full jump).
### 5.4 Collision resolution (minimum-translation, per axis)
Movement and resolution are done one axis at a time (move X → resolve X → move
Y → resolve Y). Resolution chooses the side by **least penetration**, not by
velocity sign, so clipping a block's corner never warps the player across it.
- **X pass**: for each overlapping solid, compute horizontal penetration from
each side and the vertical penetration. Only resolve horizontally if the
horizontal overlap is the *smaller* one (otherwise it's really a vertical
collision — leave it for the Y pass, so a block on top can't be dodged by
squirting out sideways). Push out the nearer horizontal edge; zero `vx`.
- **Y pass**: for each overlapping solid, resolve toward the nearer of top/
bottom edge. Landing on a top sets `on_ground`; zero `vy`.
### 5.5 One-way platforms
Separate from solids. During the Y pass, when falling (`vy >= 0`) and not in a
drop-through, the player lands on a one-way **only if its feet just crossed the
platform's top edge this frame** (penetration between 0 and roughly the
per-frame fall distance). Jumping up through them is unobstructed. Holding down
+ pressing jump sets a short drop-through timer (~0.12 s) that suppresses
one-way landing so the player falls through.
### 5.6 Crush death
A crush is a moving block pinning the player against a solid on the opposite
side. Detection probes a thin strip on each side of the player for a backing
solid, checks whether the player is *pinned* (still overlapping a solid after
resolution), and whether a moving carrier is closing in on the appropriate side:
- Moving down onto a floored player (backed below + pinned) → crush.
- Moving up into a player under a ceiling (backed above + pinned) → crush.
- Moving horizontally into a player backed by a wall → crush.
- Being carried sideways into a wall (via the `carry` direction) → crush.
A block that stops with the player fitting underneath does **not** crush
(no residual overlap → not pinned).
### 5.7 Death conditions (in `playing`)
- Battery ≤ 0.
- Player fell out of the world (top below the level height + 160 px).
- `crushed` is true.
- Player rect overlaps any hazard rect.
Reaching the goal rect wins the level.
---
## 6. Battery / timer
- Each level has a time limit `battery_seconds` (default 45). It counts down
only while `playing`; hitting zero is a death.
- The battery **bar** is visual-only and reads near-empty from the start to
match the dying-phone theme: it fills to `battery_pct` percent (default 12)
at full time and drains proportionally to zero. The exact seconds remaining
are always shown as text next to the bar, so the bar can be a thin "almost
dead" sliver without losing information.
---
## 7. Death, respawn, counters
- On death: increment a **per-level** counter and a **session-total** counter,
snapshot the player position as a corpse, call each trap's
`finalize_on_death()`, and enter `dying` (scene frozen, traps *not yet* reset —
they linger for the pause). A red tint fades over the pause.
- After `DEATH_PAUSE` seconds: respawn the player at the level spawn, reset all
traps, refill the battery, return to `playing`.
- The HUD shows `deaths <level> / <total> total`. The per-level count resets
when a level loads; the session total persists across levels and resets only
on replay after clearing everything.
---
## 8. Level-complete flow
On reaching the goal:
1. **charging** state (scene frozen). The battery widget detaches from the HUD,
flies to screen-center while growing (~2.7×, ease-out over the first ~40% of
the beat), the background dims, and the battery fills from its near-empty
finish level up to 100% (a live percentage is shown). Lasts `CHARGE_TIME`.
2. **won_level** / **won_all** splash: the enlarged, full battery stays centered
with a "phone charged!" title above and death counts below.
3. On ENTER: a **fade** transition — snapshot the splash frame, dip it to black
over `FADE_TIME` (0.3 s), swap the level at full black, then fade the new
level in over `FADE_TIME`. `won_all` → ENTER replays from level 1 with a
fresh session total.
---
## 9. Assets
- `AssetStore.get(name, w, h, angle=0)` returns a surface of exactly `(w, h)`
for a sprite name.
- If `assets/<name>.png` exists, it is loaded, optionally **rotated**
counter-clockwise by `angle` degrees, then **nearest-neighbour scaled** to
`(w, h)` (keeps pixel art crisp). Results are cached by `(name, w, h, angle)`.
- Otherwise a placeholder is drawn: a colored rect (per-sprite color + optional
label) with a dark border. Placeholders are not rotated.
- Sprite names used by the engine: `player`, `player_dead`, `block`, `goal`,
`fake_block`, `spike`, `moving_block`, `patrol_block`, `crumble_block`,
`arrow_shooter`, `arrow`, `spike_block`, `phase_block`.
- Directional sprites (spikes) are rotated by direction: up=0°, left=90°,
down=180°, right=90°.
---
## 10. Level file format (YAML)
A level is one YAML document.
### 10.1 Top-level keys
| Key | Default | Meaning |
|---|---|---|
| `name` | filename | display name |
| `tile_size` | 32 | pixels per tile |
| `battery_seconds` | 45 | time limit |
| `battery_pct` | 12 | how full the battery bar *looks* at start (visual only) |
| `debug` | false | reveal everything normally hidden (see §14) |
| `map` | "" | ASCII map (a YAML block scalar) |
| `traps` | [] | list of trap specs (see §1213) |
### 10.2 Map legend
The `map` is rows of characters. A leading blank line is ignored; rows may be
ragged (width = longest row).
| Char | Meaning |
|---|---|
| `#` | solid block |
| `-` | one-way platform (stand on top; jump/drop through) — rendered **identically** to a solid block, so it's a hidden mechanic |
| `P` | player spawn (feet at the cell's bottom, centered) |
| `G` | goal / charger |
| `.` or space | empty |
Static solids and one-ways come only from the map. Everything dynamic or
hidden is a trap.
---
## 11. Trap framework
Every trap subclasses a base `Trap`. The engine polls a small set of hooks each
frame; a trap implements only the ones it needs.
### 11.1 Geometry hooks (default: contribute nothing)
- `solid_rects()` → rects that fully block movement.
- `oneway_rects()` → rects that block only from above.
- `hazard_rects()` → rects that kill the player on contact.
- `carriers()``(rect, dx, dy)` for moving platforms the player can ride
(`dx, dy` = movement this frame).
- `current_rect()` → the trap's live footprint (defaults to its cell; movers
override so mounts and sensors can follow it).
- `sensor_rect()` → where its trigger senses the player from (defaults to
`current_rect()`).
### 11.2 Lifecycle
- `update(dt, game)` — advance state (has access to `game.player`).
- `draw(surface, assets)` — visible appearance.
- `reset()` — return to initial state (called on player death / level load).
- `finalize_on_death()` — settle to a final look the instant the player dies,
before the scene freezes (e.g. a phase block mid-fade snaps fully visible).
### 11.3 Universal properties
- Every trap takes `at: [col, row]`. Where a trap draws its own block, leave
that map cell empty (otherwise a real solid sits under it).
- **`invisible: true`** — any trap can be invisible: it stays fully functional
but is not drawn (only shown under the debug view). `warp` hard-codes this.
- **`count: [nx, ny]`** (or a single int → a horizontal line) — expand the spec
into an nx-by-ny line/grid of copies at level load, each offset by
**`spacing: [sx, sy]`** tiles (default 1). Only `at` is shifted per copy (so a
block's `move` is relative and works; an absolute `path` is shared, so arrays
suit stationary/simple traps). A rectangle of invisible solid blocks is how
you make an invisible wall.
### 11.4 Mounting (traps riding traps)
A trap may carry a `mounts:` list of child trap specs. A mount's `at` is read as
a **relative** offset (in tiles) from its parent. Each frame the parent
repositions each mount to track its own live position; mounts may nest
arbitrarily.
Most mounts **ride rigidly** — they hold their offset and run no motion of their
own (spikes, turrets, stationary deadly blocks). A **`block` mount that has its
own `path`/`move` is mobile**: its motion runs in a coordinate frame *relative
to the parent*, so it both rides along with the parent **and** performs its own
stroke/patrol. For example, a block mounted on a platform that patrols leftright
can itself lunge upward (a triggered `once` slider) to catch a player leaping
over, while continuing to drift sideways with its carrier; spikes mounted on that
block ride it in turn. A mobile mount's `carriers()` reports its **total** motion
(parent drift + its own move) so a player standing on it is carried correctly,
and its `home` trigger sensor tracks the resting cell as it rides along.
The engine aggregates a trap and everything mounted on it via
`all_solid_rects()`, `all_oneway_rects()`, `all_hazard_rects()`,
`all_carriers()`, plus `tick()`/`render()`/`reset_all()` wrappers.
---
## 12. Triggers
`spike`, `block`, and `arrow_shooter` are "active" while a **trigger condition**
holds. A trigger is the string `always` (default) or a condition object,
evaluated against the player each frame relative to the trap's `sensor_rect()`.
| Condition | Meaning |
|---|---|
| `{ within: N }` | player within N tiles of the sensor center (radius) |
| `{ dir: left\|right\|above\|below }` | player is on that side |
| `{ dir: …, range: N }` | …and within N tiles in that direction |
| `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (directly left / directly above, etc.) |
| `{ timer: { interval: A, up_time: B } }` | cyclic: off for A s, then on for B s, repeating |
| `{ all: [ … ] }` | AND of sub-conditions |
| `{ any: [ … ] }` | OR of sub-conditions |
Composites evaluate *all* children each frame (so nested timers keep ticking).
**`delay`** (seconds, default 0): the condition must hold *continuously* this
long before the trap arms; leaving the condition resets the countdown. This is
arm hysteresis (e.g. "linger and it strikes").
---
## 13. Trap catalog
### 13.1 `spike`
A deadly half-tile spike on one edge of its cell, active while its `trigger`
holds.
- `direction`: `up`/`down`/`left`/`right` — which edge it sits on; the hazard is
the half-cell along that edge, and the sprite auto-rotates to match.
- `trigger`, `delay` as in §12.
### 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.
| 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` |
| `speed` | px/s (default 140) |
| `deadly` (bool) | hazard (spikes) instead of a solid |
| `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 |
| `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:
- **Committed stroke** (`once`): once a block starts moving it runs to the
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.
### 13.3 `arrow_shooter`
A wall turret that fires a deadly projectile every `interval` seconds while its
`trigger` holds.
- `direction` (up/down/left/right), `speed`, `interval`, `trigger`, `delay`.
- Arrows are hazards that travel until off the (inflated) level bounds or until
they hit a static solid.
(An invisible wall is not a separate type — declare an array of invisible solid
blocks: `type: block, invisible: true, count: [w, h]`.)
### 13.4 `warp`
An invisible tile that teleports the player to `to: [col, row]` on contact
(zeroing velocity). It re-arms only once the player has left the tile, so it
fires once per entry.
### 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).
---
## 14. Debug view
`debug: true` on a level (or the `--debug` CLI flag, which forces it on for all
levels) reveals everything normally hidden:
- a faint tile grid with column/row labels (so `at: [col,row]` is eyeballable),
- one-way platforms are faded (distinguishable from solids),
- fake blocks tinted, invisible walls tinted, warps tinted with a line to their
destination, dormant phase blocks ghosted, not-yet-sprung spikes marked,
- crumbled-away blocks shown as ghosts,
- moving blocks draw their travel path (waypoints + connecting lines).
---
## 15. Module layout
```
main.py entry point + CLI arg parsing
game/
settings.py all tunables (physics, timing, colors, placeholder table)
assets.py sprite loading with rotation + placeholder fallback
level.py YAML -> geometry (solids/one-ways/spawn/goal) + trap list
player.py player physics, collision resolution, crush/carry/push
traps.py trigger conditions, base Trap + mounting, all trap classes
game.py window, game loop, states, HUD, death/charge/fade flow
tools/gen_sprites.py regenerates the default pixel-art PNGs into assets/
levels/*.yaml level definitions (data, not part of this spec)
tests/ pytest suite mirroring the behavior described here
```
A trap type is added by subclassing `Trap`, implementing the hooks it needs,
and registering it in the `TRAP_TYPES` factory map in `traps.py` — nothing else
in the engine needs to change.

29
assets/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Sprites
Drop PNG files here to replace the colored-rectangle placeholders. Files are
matched by name and scaled to the tile/entity size automatically — no code
changes needed. Transparency (RGBA) is respected.
Recognized filenames:
| File | Used for |
|----------------------|--------------------------------------------|
| `player.png` | the player character |
| `block.png` | solid blocks and one-way platform ledges |
| `goal.png` | the phone charger (level exit) |
| `fake_block.png` | fall-through blocks (make it match block!) |
| `spike.png` | spikes / deadly patrol blocks |
| `moving_block.png` | proximity-triggered sliding blocks |
| `patrol_block.png` | perpetual moving platforms |
| `crumble_block.png` | blocks that crumble when stood on |
| `arrow_shooter.png` | wall turrets |
| `arrow.png` | fired arrows |
Any missing file falls back to a labeled colored rectangle, so the game is
fully playable with no art at all.
The PNGs currently here are simple defaults generated by `tools/gen_sprites.py`
(pixel art drawn with pygame). Re-run `python tools/gen_sprites.py` to
regenerate them, tweak the `draw_*` functions in that script, or just overwrite
any PNG with your own art. Sprites are scaled nearest-neighbour, so pixel art
stays crisp at any size.

BIN
assets/arrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

BIN
assets/arrow_shooter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

BIN
assets/block.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

BIN
assets/crumble_block.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

BIN
assets/fake_block.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

BIN
assets/goal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

BIN
assets/moving_block.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

BIN
assets/patrol_block.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

BIN
assets/phase_block.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

BIN
assets/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

BIN
assets/player_dead.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

BIN
assets/spike.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

BIN
assets/spike_block.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

1
game/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Dying Phone — a single-screen 2D platformer engine built on pygame."""

72
game/assets.py Normal file
View File

@@ -0,0 +1,72 @@
"""Sprite loading with graceful placeholders.
Every drawable in the game asks the AssetStore for a surface by name. If a PNG
named ``<name>.png`` exists in the assets directory it is loaded and scaled to
the requested size; otherwise a labeled colored rectangle is drawn instead, so
the game is fully playable before any art exists.
"""
import os
import pygame
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._font = None
def _font_for(self, h):
# Lazily build a font sized to the tile so placeholder labels fit.
size = max(10, int(h * 0.5))
return pygame.font.SysFont("consolas,menlo,monospace", size, bold=True)
def _load_raw(self, name):
if name in self._raw:
return self._raw[name]
path = os.path.join(self.assets_dir, name + ".png")
surf = None
if os.path.isfile(path):
try:
surf = pygame.image.load(path).convert_alpha()
except pygame.error:
surf = None
self._raw[name] = surf
return surf
def get(self, name, w, h, angle=0):
"""Return a Surface of exactly (w, h) for the given sprite name,
optionally rotated counter-clockwise by ``angle`` degrees first (used to
point directional sprites like spikes the right way)."""
w, h = int(w), int(h)
angle %= 360
key = (name, w, h, angle)
if key in self._cache:
return self._cache[key]
raw = self._load_raw(name)
if raw is not None:
if angle:
raw = pygame.transform.rotate(raw, angle)
# Nearest-neighbour keeps the pixel-art sprites crisp at any size.
surf = pygame.transform.scale(raw, (w, h))
else:
surf = self._make_placeholder(name, w, h)
self._cache[key] = surf
return surf
def _make_placeholder(self, name, w, h):
color, label = settings.PLACEHOLDERS.get(name, ((200, 60, 200), "?"))
surf = pygame.Surface((w, h), pygame.SRCALPHA)
surf.fill(color)
# A subtle border helps distinguish adjacent tiles of the same color.
pygame.draw.rect(surf, (0, 0, 0, 90), surf.get_rect(), max(1, w // 16))
if label:
font = self._font_for(h)
text = font.render(label, True, (15, 15, 20))
surf.blit(text, text.get_rect(center=(w // 2, h // 2)))
return surf

377
game/game.py Normal file
View File

@@ -0,0 +1,377 @@
"""Top-level game loop, states, HUD, and the death/respawn/win flow."""
import os
import glob
import pygame
from . import settings as S
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
class Game:
def __init__(self, level_paths, assets_dir, debug=False):
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.assets = AssetStore(assets_dir)
self.clock = pygame.time.Clock()
self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True)
self.big_font = pygame.font.SysFont("consolas,menlo,monospace", 40, bold=True)
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
# Size the window ONCE to fit the largest level, then never resize it —
# calling set_mode again mid-session recreates the window (it flickers /
# looks like it closes). Smaller levels are centred within it.
dims = [(lv.width, lv.height) for lv in map(Level, self.level_paths)]
self.win_w = max(max(w for w, _ in dims), MIN_W)
self.win_h = max(h for _, h in dims) + HUD_H
self.screen = pygame.display.set_mode((self.win_w, self.win_h))
self._load_current()
# --- level management ----------------------------------------------------
def _load_current(self):
self.level = Level(self.level_paths[self.index])
if self.force_debug:
self.level.debug = True # CLI flag overrides the per-level setting
self.player = Player(self.level)
self.battery = self.level.battery
self.world = pygame.Surface((self.level.width, self.level.height))
# Centre the play area in the fixed window, below the HUD.
ox = (self.win_w - self.level.width) // 2
oy = HUD_H + (self.win_h - HUD_H - self.level.height) // 2
self.world_pos = (ox, oy)
self.state = "playing"
self.death_timer = 0.0
self.death_rect = None
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.
# Traps are deliberately NOT reset yet — that happens on respawn.
if self.state != "playing":
return
self.deaths += 1
self.level_deaths += 1
self.state = "dying"
self.death_timer = S.DEATH_PAUSE
self.death_rect = self.player.rect.copy()
# Let traps settle into a final look before the scene freezes — e.g. a
# phase block that was mid-materialise snaps to fully visible.
for t in self.level.traps:
t.finalize_all()
def _respawn(self):
if self._pending_reload:
# F5 hot-reload: re-read the level file from disk (picks up edits),
# keeping the death the reload just counted.
self._pending_reload = False
kept = self.level_deaths
try:
self._load_current()
self.level_deaths = kept
except Exception as ex: # bad edit -> don't crash
print(f"[reload] {self.level_paths[self.index]}: {ex}")
self.player.respawn()
self.level.reset()
self.battery = self.level.battery
else:
self.player.respawn()
self.level.reset()
self.battery = self.level.battery # phone gets plugged back in at start
self.state = "playing"
def _reload_level(self):
# Hot-reload the current level from disk. Counts as a death — plays the
# death beat and bumps the counters — so it also escapes soft-locks.
if self.state != "playing":
return
self._pending_reload = True
self._start_death()
def _reach_goal(self):
# Freeze everything and play the battery-charging animation; the win
# splash follows once it finishes.
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._final = self.index + 1 >= len(self.level_paths)
def _advance(self):
self.index += 1
self._load_current()
def _replay(self):
# Fresh session after clearing everything.
self.index = 0
self.deaths = 0
self._load_current()
def _start_fade(self, action):
# Fade to black, run `action` (load a level) at full black, then fade in.
# Snapshot the current frame (the splash) so the fade-out dips *it* to
# black instead of flashing the underlying world.
self.fade_snapshot = self.screen.copy()
self.state = "fading"
self.fade_timer = 0.0
self.fade_phase = "out"
self.fade_action = action
def _update_fade(self, dt):
self.fade_timer += dt
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_phase = "in"
self.fade_timer = 0.0
else:
self.state = "playing"
# --- input ---------------------------------------------------------------
def _poll_events(self):
inp = InputState()
for e in pygame.event.get():
if e.type == pygame.QUIT:
self.running = False
elif e.type == pygame.KEYDOWN:
if e.key == pygame.K_ESCAPE:
self.running = False
elif e.key in (pygame.K_SPACE, pygame.K_w, pygame.K_UP):
inp.jump_pressed = True
elif e.key == pygame.K_r:
self._start_death() # manual give-up / restart
elif e.key == pygame.K_F5:
self._reload_level() # hot-reload from disk (counts as a death)
elif self.state == "won_level" and e.key == pygame.K_RETURN:
self._start_fade(self._advance)
elif self.state == "won_all" and e.key == pygame.K_RETURN:
self._start_fade(self._replay)
keys = pygame.key.get_pressed()
inp.left = keys[pygame.K_a] or keys[pygame.K_LEFT]
inp.right = keys[pygame.K_d] or keys[pygame.K_RIGHT]
inp.down = keys[pygame.K_s] or keys[pygame.K_DOWN]
inp.jump_held = keys[pygame.K_SPACE] or keys[pygame.K_w] or keys[pygame.K_UP]
return inp
# --- main loop -----------------------------------------------------------
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
inp = self._poll_events()
if self.state == "playing":
self._update_play(dt, inp)
elif self.state == "dying":
# Scene is frozen; just count down, then reset and respawn.
self.death_timer -= dt
if self.death_timer <= 0:
self._respawn()
elif self.state == "charging":
# Everything stops while the battery animates up to full.
self.charge_timer += dt
if self.charge_timer >= S.CHARGE_TIME:
self.state = "won_all" if self._final else "won_level"
elif self.state == "fading":
self._update_fade(dt)
self._draw()
pygame.quit()
def _update_play(self, dt, inp):
self.battery -= dt
self.level.update(dt, self)
self.player.update(dt, inp)
# 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
if not died:
for hz in self.level.hazard_rects():
if pr.colliderect(hz):
died = True
break
if died:
self._start_death()
return
# reached the charger?
if pr.colliderect(self.level.goal_rect):
self._reach_goal()
# --- rendering -----------------------------------------------------------
def _draw(self):
# Fade-out: dip the frozen splash frame to black (level not swapped yet).
if self.state == "fading" and self.fade_phase == "out":
self.screen.blit(self.fade_snapshot, (0, 0))
p = min(1.0, self.fade_timer / S.FADE_TIME)
overlay = pygame.Surface(self.screen.get_size())
overlay.fill((0, 0, 0))
overlay.set_alpha(int(255 * p))
self.screen.blit(overlay, (0, 0))
pygame.display.flip()
return
self.world.fill(S.COLOR_BG)
self.level.draw(self.world, self.assets)
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)
self.world.blit(sprite, self.death_rect)
else:
self.player.draw(self.world, self.assets)
self.screen.fill((12, 12, 18))
self.screen.blit(self.world, self.world_pos)
self._draw_hud(hide_battery=(self.state == "charging"))
if self.state == "dying":
# A red tint that fades as the corpse lingers.
alpha = int(140 * (self.death_timer / S.DEATH_PAUSE))
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((200, 40, 40, alpha))
self.screen.blit(overlay, (0, 0))
if self.state == "charging":
self._draw_charge_anim()
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")
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")
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))
overlay.set_alpha(int(255 * (1 - p)))
self.screen.blit(overlay, (0, 0))
pygame.display.flip()
@staticmethod
def _draw_battery(surf, rect, fill_frac, col):
"""Draw a battery (frame + fill + nub) into an arbitrary rect, so the
same widget serves the HUD and the blown-up charging animation."""
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)
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)
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))
def _draw_hud(self, hide_battery=False):
w = self.screen.get_width()
pygame.draw.rect(self.screen, (18, 20, 30), (0, 0, w, HUD_H))
# battery bar. The phone is dying, so the bar *looks* near-empty from the
# start (scaled by the level's battery_pct); the seconds text below is the
# real timer. Fill drains proportionally to the time left. Hidden while
# the charge animation flies the battery to centre stage.
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
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)
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)
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
self.screen.blit(name, (w - deaths.get_width() - name.get_width() - 32, by - 1))
def _hero_battery(self):
"""The enlarged battery's resting rect — centre of screen, ~60% wide.
Shared by the charge animation (its target) and the win splash (so it
stays put, full, while the splash is up)."""
W, H = self.screen.get_size()
bar_w, bar_h = 180, 20
scale = min(W * 0.6, 520) / bar_w
rect = pygame.Rect(0, 0, round(bar_w * scale), round(bar_h * scale))
rect.center = (W // 2, H // 2)
return rect
def _draw_charge_anim(self):
"""The charging beat: dim the frozen scene, then fly the battery out of
the HUD toward the centre and grow it (brought toward the viewer) while
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%
fill = self.charge_from + (1.0 - self.charge_from) * fill_p
dim = pygame.Surface((W, H), pygame.SRCALPHA)
dim.fill((6, 8, 14, int(195 * move_p)))
self.screen.blit(dim, (0, 0))
bar_w, bar_h = 180, 20
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.center = (round(pos.x), round(pos.y))
self._draw_battery(self.screen, rect, fill, (90, 220, 120))
pct = int(round(fill * 100))
num = self.big_font.render(f"{pct}%", True, (150, 245, 180))
self.screen.blit(num, num.get_rect(center=(W // 2, rect.top - 36)))
def _draw_win_splash(self, title, subtitle):
"""Level-complete overlay: keep the charged hero battery centred, with
the message above and below it."""
W, H = self.screen.get_size()
dim = pygame.Surface((W, H), pygame.SRCALPHA)
dim.fill((10, 12, 20, 205))
self.screen.blit(dim, (0, 0))
rect = self._hero_battery()
self._draw_battery(self.screen, rect, 1.0, (90, 220, 120))
t = self.big_font.render(title, True, (120, 240, 160))
self.screen.blit(t, t.get_rect(center=(W // 2, rect.top - 40)))
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")))
return paths

170
game/level.py Normal file
View File

@@ -0,0 +1,170 @@
"""Level loading: YAML -> geometry + traps.
A level file has an ASCII ``map`` for static geometry and a ``traps`` list for
the interactive/nasty bits. Grid coordinates are ``[col, row]`` (0-based, from
the top-left of the map).
Map legend:
# solid block
- one-way platform (stand on top, jump up through it)
P player spawn
G goal (the phone charger)
. or space empty
"""
import os
import pygame
import yaml
from . import settings
from . import traps as traps_mod
class Level:
def __init__(self, path):
with open(path, "r") as fh:
data = yaml.safe_load(fh) or {}
self.path = path
self.name = data.get("name", os.path.splitext(os.path.basename(path))[0])
self.tile = int(data.get("tile_size", settings.TILE))
self.battery = float(data.get("battery_seconds", settings.DEFAULT_BATTERY))
# How full the battery bar *looks* at the start (percent). Visual only —
# the actual time limit is battery_seconds.
self.battery_pct = float(data.get("battery_pct", settings.DEFAULT_BATTERY_PCT))
# Debug view: reveal everything normally hidden — one-way platforms,
# invisible walls, warps, dormant phase blocks, fake blocks, and
# not-yet-sprung spikes. Traps read this via ``self.level.debug``.
self.debug = bool(data.get("debug", False))
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:]
self.cols = max((len(r) for r in rows), default=0)
self.grid_rows = len(rows)
self.width = self.cols * self.tile
self.height = self.grid_rows * self.tile
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)
for r, line in enumerate(rows):
for c, ch in enumerate(line):
rect = self.cell_rect(c, r)
if ch == "#":
self.solids.append(rect)
elif ch == "-":
self.oneways.append(rect)
elif ch == "P":
self.spawn = (rect.x, rect.y)
elif ch == "G":
self.goal_rect = rect
# Build traps from the config via the factory. `count`/`spacing` on a
# spec expands into a line/grid of copies first.
self.traps = []
for spec in data.get("traps", []) or []:
for one in traps_mod.expand_spec(spec):
trap = traps_mod.make_trap(one, self)
if trap is not None:
self.traps.append(trap)
# Snap any mounted traps onto their parent's starting position so their
# geometry is correct even before the first update tick.
self.reset()
# --- helpers -------------------------------------------------------------
def cell_rect(self, col, row):
return pygame.Rect(col * self.tile, row * self.tile, self.tile, self.tile)
def reset(self):
"""Reset every trap to its initial state (called on player death)."""
for t in self.traps:
t.reset_all()
def update(self, dt, game):
for t in self.traps:
t.tick(dt, game)
# Rects that block movement this frame: static solids + any trap-provided
# solids (moving/patrol blocks). One-way platforms are handled separately.
# The all_* wrappers fold in anything mounted on a trap.
def solid_rects(self):
rects = list(self.solids)
for t in self.traps:
rects.extend(t.all_solid_rects())
return rects
def oneway_rects(self):
rects = list(self.oneways)
for t in self.traps:
rects.extend(t.all_oneway_rects())
return rects
# Moving platforms the player can ride: (rect, dx, dy) moved this frame.
def carriers(self):
out = []
for t in self.traps:
out.extend(t.all_carriers())
return out
# Rects that kill the player on contact.
def hazard_rects(self):
rects = []
for t in self.traps:
rects.extend(t.all_hazard_rects())
return rects
def draw(self, surface, assets):
t = self.tile
block = assets.get("block", t, t)
for rect in self.solids:
surface.blit(block, rect)
# One-way platforms look identical to solid blocks — you only discover
# them by falling through. `debug` fades them so a level designer can
# see which is which.
oneway_img = block
if self.debug:
oneway_img = block.copy()
oneway_img.fill((255, 255, 255, 110), special_flags=pygame.BLEND_RGBA_MULT)
for rect in self.oneways:
surface.blit(oneway_img, rect)
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h),
self.goal_rect)
for tr in self.traps:
tr.render(surface, assets)
if self.debug:
self._draw_grid(surface)
_grid_font = None
def _draw_grid(self, surface):
"""A faint tile grid with col/row labels, so `at: [col,row]` placement
is eyeballable while designing."""
if Level._grid_font is None:
Level._grid_font = pygame.font.SysFont("consolas,menlo,monospace", 10)
overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
line = (255, 255, 255, 26)
for c in range(self.cols + 1):
x = c * self.tile
pygame.draw.line(overlay, line, (x, 0), (x, self.height))
for r in range(self.grid_rows + 1):
y = r * self.tile
pygame.draw.line(overlay, line, (0, y), (self.width, y))
label = (150, 162, 190)
for c in range(self.cols):
overlay.blit(Level._grid_font.render(str(c), True, label),
(c * self.tile + 2, 1))
for r in range(self.grid_rows):
overlay.blit(Level._grid_font.render(str(r), True, label),
(1, r * self.tile + 1))
surface.blit(overlay, (0, 0))

270
game/player.py Normal file
View File

@@ -0,0 +1,270 @@
"""The player character and all of its physics.
Movement: A / D to run, Space (or W) to jump with variable height, S to drop
through one-way platforms. Collision is swept-axis AABB against the level's
solid rects, with separate handling for one-way platforms and moving platforms
the player can ride.
"""
import pygame
from . import settings as S
class InputState:
__slots__ = ("left", "right", "down", "jump_pressed", "jump_held")
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
class Player:
def __init__(self, level):
self.level = level
self.w = int(level.tile * 0.72)
self.h = int(level.tile * 0.92)
self.rect = pygame.Rect(0, 0, self.w, self.h)
self.respawn()
def respawn(self):
sx, sy = self.level.spawn
# center the player horizontally in its spawn tile, feet at tile bottom
self.fx = float(sx + (self.level.tile - self.w) / 2)
self.fy = float(sy + (self.level.tile - self.h))
self.vx = 0.0
self.vy = 0.0
self.on_ground = False
self.coyote = 0.0
self.jump_buffer = 0.0
self.facing = 1
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):
self.rect.x = round(self.fx)
self.rect.y = round(self.fy)
# --- main update ---------------------------------------------------------
def update(self, dt, inp):
self._ride_platforms()
self._push_by_movers()
self._horizontal(dt, inp)
self._vertical(dt, inp)
self.crushed = self._check_crush()
def _push_by_movers(self):
"""A solid moving horizontally into our side shoves us along that axis.
Without this the player never resolves against a block that walks into
them (they aren't moving), and the vertical pass then mis-reads the side
overlap as a downward collision — burying them in the floor.
"""
p = self.rect
for rect, dx, dy in self.level.carriers():
if dx == 0 or not p.colliderect(rect):
continue
# Only treat it as a side hit when we share a real chunk of height —
# a shallow overlap just means we're standing on top of the block.
v_overlap = min(p.bottom, rect.bottom) - max(p.top, rect.top)
if v_overlap < self.h * 0.5:
continue
# Push out the *nearer* horizontal side, not blindly the way the
# block is travelling — otherwise hitting its trailing face (e.g.
# 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
if pen_right <= pen_left:
p.left = rect.right
else:
p.right = rect.left
self.fx = float(p.x)
def _check_crush(self):
"""A crush = a moving block pressing us against a solid on the opposite
side (squished into the floor/ceiling/wall, or pinned while riding).
We probe a thin strip on each side of the player: if a solid backs us on
one side and a *moving* block is closing in from the other side on the
same axis, we're pinched and die.
"""
movers = [(r, dx, dy) for (r, dx, dy) in self.level.carriers() if dx or dy]
if not movers:
return False
solids = self.level.solid_rects()
p = self.rect
e = 4 # probe depth (a touch larger than a fast block's per-frame step)
up = pygame.Rect(p.left + 2, p.top - e, max(1, p.width - 4), e)
down = pygame.Rect(p.left + 2, p.bottom, max(1, p.width - 4), e)
left = pygame.Rect(p.left - e, p.top + 2, e, max(1, p.height - 4))
right = pygame.Rect(p.right, p.top + 2, e, max(1, p.height - 4))
def backed(probe):
return any(probe.colliderect(s) for s in solids)
bu, bd = backed(up), backed(down)
bl, br = backed(left), backed(right)
# Are we actually compressed? After this frame's resolution we still
# overlap a solid because we couldn't be separated. If a descending
# block stops with us fitting underneath, there's no overlap — no crush.
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
return True
if dy < 0 and bu and pinned and r.colliderect(down): # squished up into ceiling
return True
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
return True
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
return True
# Riding a platform that carries us *sideways* into a wall: the pushing
# block is under our feet, so the side-probes above miss it — use the
# carry direction instead. (Vertical carry crushes are caught above.)
cdx, _ = self.carry
if cdx > 0 and br:
return True
if cdx < 0 and bl:
return True
return False
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):
self.fx += dx
self.fy += dy
self._sync_rect()
self.carry = (dx, dy)
break
def _horizontal(self, dt, inp):
target = 0.0
if inp.left:
target -= S.MOVE_SPEED
self.facing = -1
if inp.right:
target += S.MOVE_SPEED
self.facing = 1
if target != 0.0:
accel = S.ACCEL if self.on_ground else S.AIR_ACCEL
if self.vx < target:
self.vx = min(self.vx + accel * dt, target)
else:
self.vx = max(self.vx - accel * dt, target)
else:
# friction toward zero (only meaningful decel on the ground)
fr = S.FRICTION if self.on_ground else S.AIR_ACCEL * 0.5
if self.vx > 0:
self.vx = max(0.0, self.vx - fr * dt)
elif self.vx < 0:
self.vx = min(0.0, self.vx + fr * dt)
self.fx += self.vx * dt
self._sync_rect()
self._resolve_axis(axis="x")
def _vertical(self, dt, inp):
# timers
self.coyote = self.coyote - dt if self.coyote > 0 else 0.0
if inp.jump_pressed:
self.jump_buffer = S.JUMP_BUFFER
else:
self.jump_buffer = max(0.0, self.jump_buffer - dt)
self.drop_through_timer = max(0.0, self.drop_through_timer - dt)
if inp.down and inp.jump_pressed:
# Space + S: drop through one-way platforms.
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:
self.vy = -S.JUMP_SPEED
self.on_ground = False
self.coyote = 0.0
self.jump_buffer = 0.0
# variable jump height: the frame Space is released mid-rise, cut the
# remaining upward velocity once (a quick tap = a short hop).
if self.was_jump_held and not inp.jump_held and self.vy < 0:
self.vy *= S.JUMP_CUT
self.was_jump_held = inp.jump_held
# gravity
self.vy = min(self.vy + S.GRAVITY * dt, S.MAX_FALL)
was_on_ground = self.on_ground
self.on_ground = False
self.fy += self.vy * dt
self._sync_rect()
self._resolve_axis(axis="y")
# start coyote window the frame we walk off a ledge
if was_on_ground and not self.on_ground and self.vy >= 0:
self.coyote = S.COYOTE_TIME
# --- collision -----------------------------------------------------------
def _resolve_axis(self, axis):
solids = self.level.solid_rects()
if axis == "x":
for s in solids:
if self.rect.colliderect(s):
# Resolve toward the nearer edge (not by velocity sign) so
# clipping a block's corner can't warp us across it. But only
# 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_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
if pen_right <= pen_left:
self.rect.left = s.right
else:
self.rect.right = s.left
self.fx = float(self.rect.x)
self.vx = 0.0
else: # y
for s in solids:
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
if overlap_top <= overlap_bottom:
self.rect.bottom = s.top
self.on_ground = True
else:
self.rect.top = s.bottom
self.fy = float(self.rect.y)
self.vy = 0.0
# one-way platforms: land only when falling and the feet just
# crossed the platform's top edge this frame.
if self.vy >= 0 and self.drop_through_timer <= 0:
max_pen = self.vy / S.FPS + 8 # how far feet could have sunk this frame
for o in self.level.oneway_rects():
if self.rect.colliderect(o):
penetration = self.rect.bottom - o.top
if 0 <= penetration <= max_pen:
self.rect.bottom = o.top
self.fy = float(self.rect.y)
self.vy = 0.0
self.on_ground = True
# --- rendering -----------------------------------------------------------
def draw(self, surface, assets):
surface.blit(assets.get("player", self.rect.w, self.rect.h), self.rect)

48
game/settings.py Normal file
View File

@@ -0,0 +1,48 @@
"""Global tunables. Anything a level does not override falls back to these."""
# --- Display -----------------------------------------------------------------
TILE = 32 # default tile size in pixels (levels may override)
FPS = 60
CAPTION = "Dying Phone"
# --- 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
# --- Gameplay ----------------------------------------------------------------
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
# --- 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), ""),
"crumble_block": ((150, 120, 100), ""),
"arrow_shooter": ((80, 80, 95), ""),
"arrow": ((250, 220, 90), ">"),
"spike_block": ((150, 156, 172), "*"),
"phase_block": ((90, 180, 210), ""),
}

883
game/traps.py Normal file
View File

@@ -0,0 +1,883 @@
"""Traps: data-driven hazards and moving geometry.
Every trap subclasses :class:`Trap` and implements a few optional hooks that the
engine polls each frame:
solid_rects() -> rects that fully block movement (moving/patrol blocks)
oneway_rects() -> rects that block only from above
hazard_rects() -> rects that kill the player on contact
carriers() -> (rect, dx, dy) moved-this-frame platforms to ride
update(dt, game), draw(surface, assets), reset()
Add a new trap by writing a subclass and registering it in ``TRAP_TYPES``.
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),
}
# --- trigger conditions ------------------------------------------------------
# A trigger is a condition (or tree of conditions) evaluated against the player
# each frame. Leaves are spatial (within / dir) or temporal (timer); composites
# are all / any. Everything is measured against the trap's *current* rect, so a
# moving trap's sensors follow it automatically.
#
# trigger: always
# trigger: { within: 2 }
# trigger: { dir: left, range: 2, aligned: true }
# 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
def reset(self):
pass
class _Within:
"""Player within N tiles of the trap centre (Euclidean radius)."""
def __init__(self, n):
self.n = float(n)
def evaluate(self, trap, game, dt):
r = trap.sensor_rect()
p = game.player.rect
dx = p.centerx - r.centerx
dy = p.centery - r.centery
reach = self.n * trap.tile
return dx * dx + dy * dy <= reach * reach
def reset(self):
pass
class _Directional:
"""Player is to a given side of the trap.
range: cap the distance in that direction (tiles); omit = anywhere.
aligned: also require overlap on the perpendicular axis, i.e. *directly*
left/right (same rows) or *directly* above/below (same columns).
"""
def __init__(self, direction, rng, aligned):
self.dir = direction
self.rng = None if rng is None else float(rng)
self.aligned = bool(aligned)
def evaluate(self, trap, game, dt):
r = trap.sensor_rect()
p = game.player.rect
d = self.dir
if d in ("left", "right"):
if self.aligned and not (p.bottom > r.top and p.top < r.bottom):
return False
if d == "left":
if p.centerx >= r.left:
return False
dist = r.left - p.centerx
else:
if p.centerx <= r.right:
return False
dist = p.centerx - r.right
else: # above / below
if self.aligned and not (p.right > r.left and p.left < r.right):
return False
if d == "above":
if p.centery >= r.top:
return False
dist = r.top - p.centery
else:
if p.centery <= r.bottom:
return False
dist = p.centery - r.bottom
return self.rng is None or dist <= self.rng * trap.tile
def reset(self):
pass
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)
self.t = 0.0
def evaluate(self, trap, game, dt):
self.t += dt
return (self.t % (self.interval + self.up_time)) >= self.interval
def reset(self):
self.t = 0.0
class _All:
def __init__(self, subs):
self.subs = subs
def evaluate(self, trap, game, dt):
# Evaluate every child (so timers keep ticking), then combine.
return all([c.evaluate(trap, game, dt) for c in self.subs])
def reset(self):
for c in self.subs:
c.reset()
class _Any:
def __init__(self, subs):
self.subs = subs
def evaluate(self, trap, game, dt):
return any([c.evaluate(trap, game, dt) for c in self.subs])
def reset(self):
for c in self.subs:
c.reset()
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}")
if "all" in spec:
return _All([make_condition(s) for s in spec["all"]])
if "any" in spec:
return _Any([make_condition(s) for s in spec["any"]])
if "timer" in spec:
tm = spec["timer"]
return _Timer(tm.get("interval", 1.5), tm.get("up_time", 0.8))
if "within" in spec:
return _Within(spec["within"])
if "dir" in spec:
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False))
raise ValueError(f"unrecognized trigger condition: {spec!r}")
class Trap:
def __init__(self, spec, level):
self.spec = spec
self.level = level
self.tile = level.tile
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)
# Any trap can be made invisible (still functional; revealed in debug).
self.invisible = bool(spec.get("invisible", False))
# Mounted traps ride on this one. Their `at` is read as a *relative*
# offset (in tiles) from this trap's cell; each frame they are moved to
# track this trap's current position (see tick/_follow). A mounted trap
# rides rigidly — it doesn't move independently.
self._mount_off = (0, 0)
self.mounts = []
for mspec in spec.get("mounts", []) or []:
child = make_trap(mspec, level)
if child is not None:
child._mount_off = (child.base_rect.x, child.base_rect.y)
child._mounted = True
self.mounts.append(child)
# --- geometry hooks (default: contribute nothing) ------------------------
def solid_rects(self):
return []
def oneway_rects(self):
return []
def hazard_rects(self):
return []
def carriers(self):
return []
# Where this trap currently is. Static traps stay at their cell; movers
# override to return their live position so mounted traps can follow.
def current_rect(self):
return self.base_rect
# Where this trap's trigger senses the player from. Defaults to the live
# position; a slider overrides it to sense from home so moving away can't
# toggle its own trigger.
def sensor_rect(self):
return self.current_rect()
# Debug helper: tint a rect so a normally-hidden trap is visible when the
# level's `debug` flag is on.
def _debug_tint(self, surface, rgb, rect=None, alpha=80):
r = rect if rect is not None else self.base_rect
overlay = pygame.Surface(r.size, pygame.SRCALPHA)
overlay.fill((*rgb, alpha))
surface.blit(overlay, r)
# Debug helper: a faded sprite + outline showing where something absent
# (e.g. a crumbled-away block) belongs.
def _debug_ghost(self, surface, assets, sprite_name, rect=None):
r = rect if rect is not None else self.base_rect
img = assets.get(sprite_name, r.w, r.h).copy()
img.fill((255, 255, 255, 70), special_flags=pygame.BLEND_RGBA_MULT)
surface.blit(img, r)
pygame.draw.rect(surface, (130, 140, 160), r, 1)
# Debug helper: outline a path through a list of tile cells (top-left px),
# connecting their centres. `closed` joins the last cell back to the first.
def _debug_path(self, surface, cells, closed=False, color=(214, 200, 96)):
rects = [pygame.Rect(x, y, self.tile, self.tile) for (x, y) in cells]
if len(rects) >= 2:
pygame.draw.lines(surface, color, closed, [r.center for r in rects], 1)
for r in rects:
pygame.draw.rect(surface, color, r, 1)
# --- lifecycle hooks -----------------------------------------------------
def update(self, dt, game):
pass
def draw(self, surface, assets):
pass
def reset(self):
pass
def finalize_on_death(self):
"""Hook: snap to a final appearance the instant the player dies, before
the scene freezes for the death animation. Default: nothing."""
pass
# --- mounting: aggregate self + mounted children -------------------------
# Levels call these wrappers so a trap and everything riding on it are
# treated as one unit. Subclasses keep overriding the plain hooks above.
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)
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.tick(dt, game)
def render(self, surface, assets):
# `invisible` traps skip their visible drawing, but still show up under
# the level's debug view (their draw() reveals them there).
if not (self.invisible and not self.level.debug):
self.draw(surface, assets)
for c in self.mounts:
c.render(surface, assets)
def reset_all(self):
self.reset()
for c in self.mounts:
c.reset_all()
c._follow(self)
def finalize_all(self):
self.finalize_on_death()
for c in self.mounts:
c.finalize_all()
def all_solid_rects(self):
out = list(self.solid_rects())
for c in self.mounts:
out.extend(c.all_solid_rects())
return out
def all_oneway_rects(self):
out = list(self.oneway_rects())
for c in self.mounts:
out.extend(c.all_oneway_rects())
return out
def all_hazard_rects(self):
out = list(self.hazard_rects())
for c in self.mounts:
out.extend(c.all_hazard_rects())
return out
def all_carriers(self):
out = list(self.carriers())
for c in self.mounts:
out.extend(c.all_carriers())
return out
# --- 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
def _reset_trigger(self):
self._trig_timer = 0.0
self.trigger.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
# --- spike: emerges to kill -------------------------------------------------
class Spike(Trap):
"""A spike that becomes deadly while its ``trigger`` condition holds.
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")
self._init_trigger(spec)
self.reset()
def reset(self):
self._reset_trigger()
self.active = False
def _hazard_rect(self):
# Spike occupies half the cell along its emerging edge.
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
return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
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)
return pygame.Rect(r.x, r.y, t // 2, t)
return pygame.Rect(r.x + t // 2, 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}
def update(self, dt, game):
self.active = self.triggered(game, dt)
def hazard_rects(self):
return [self._hazard_rect()] if self.active else []
def draw(self, surface, assets):
if self.active:
hr = self._hazard_rect()
angle = self._ANGLE.get(self.direction, 0)
surface.blit(assets.get("spike", hr.w, hr.h, angle), hr)
elif self.level.debug:
# A dormant spike — show where it will strike.
self._debug_tint(surface, (230, 80, 80), self._hazard_rect(), 60)
# --- 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.
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.
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.
"""
def __init__(self, spec, level):
super().__init__(spec, level)
t = self.tile
if spec.get("path"):
pts = spec["path"]
elif spec.get("move"):
mv = spec["move"]
pts = [[self.col, self.row], [self.col + mv[0], self.row + mv[1]]]
else:
pts = [[self.col, self.row]]
self.points = [(p[0] * t, p[1] * t) for p in pts]
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.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
# `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)
self.reset()
def sensor_rect(self):
return self.base_rect if self.sense == "home" else self._rect()
def _follow(self, parent):
# Mounted on another trap. Our motion coordinates (self.x, self.y) live in
# a LOCAL frame relative to the parent; we track the parent's live
# position as our `_origin` and still run our own motion in update(). So a
# mount can slide/patrol while riding along with its carrier — e.g. a
# block that lunges up to catch a player leaping over, yet keeps drifting
# sideways with the platform it sits on. Capture `prev` (absolute) BEFORE
# shifting the origin so carriers() reports our *total* motion this frame
# (parent drift + our own move).
self.prev = (self._origin[0] + self.x, self._origin[1] + self.y)
self._origin = parent.current_rect().topleft
# `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)
def reset(self):
self._reset_trigger()
self.x, self.y = self.points[0]
# Parent top-left when mounted (set each frame by _follow); (0, 0) for a
# free block, so its x/y double as absolute coords. A mount's x/y are
# 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._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.ctimer = 0.0
self.shake = 0.0
self.emerge_kill = False
def update(self, dt, game):
# A mount's `prev` (absolute) was captured in _follow before its origin
# shifted; a free block records it here. Either way the motion below runs
# in our own frame (local for a mount, absolute otherwise), so a mounted
# 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:
step = self.speed * dt
if self.mode == "once":
self._update_once(active, dt, step)
else:
self._update_patrol(active, step)
if self.crumble:
self._update_crumble(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)
if self.cstate == "solid":
if on_top:
self.cstate = "crumbling"
self.ctimer = 0.0
elif self.cstate == "crumbling":
self.ctimer += dt
self.shake = (self.ctimer * 40) % 4 - 2
if self.ctimer >= self.crumble_delay:
self.cstate = "gone"
self.ctimer = 0.0
elif self.cstate == "gone":
self.ctimer += dt
if self.ctimer >= self.respawn:
# Re-forming into the player kills them (like the old crumble).
if p.colliderect(r):
self.emerge_kill = True
else:
self.cstate = "solid"
self.ctimer = 0.0
self.shake = 0.0
def _step_to(self, tgt, step):
"""Move toward tgt by step; snap and return True on arrival."""
tx, ty = tgt
dx, dy = tx - self.x, ty - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist <= step or dist == 0:
self.x, self.y = tx, ty
return True
self.x += dx / dist * step
self.y += dy / dist * step
return False
def _update_once(self, active, dt, step):
# A committed stroke: once moving we run to the endpoint regardless of
# the trigger flickering, and only reconsider it while parked — no
# 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 active:
self.phase = "extending"
elif self.phase == "extending":
if self._step_to(self.points[self.idx + 1], step):
self.idx += 1
if self.idx >= n - 1:
self.phase = "extended"
self._release_t = 0.0
elif self.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":
if self._step_to(self.points[self.idx - 1], step):
self.idx -= 1
if self.idx <= 0:
self.phase = "rest"
def _update_patrol(self, active, step):
tgt = self.points[self.idx] if active else self.points[0]
if self._step_to(tgt, step):
if active:
self._advance_patrol()
else:
self.idx = 1 if len(self.points) > 1 else 0
self.dir = 1
def _advance_patrol(self):
n = len(self.points)
if self.mode == "loop":
self.idx = (self.idx + 1) % n
else: # pingpong
nxt = self.idx + self.dir
if nxt >= n or nxt < 0:
self.dir *= -1
nxt = self.idx + self.dir
self.idx = nxt
def _rect(self):
ox, oy = self._origin
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")
def solid_rects(self):
return [] if self._intangible() else [self._rect()]
def hazard_rects(self):
rects = []
if self.deadly:
rects.append(self._rect().inflate(-4, -4))
if self.crumble and self.emerge_kill:
rects.append(self._rect())
return rects
def carriers(self):
if self._intangible():
return []
ax = self._origin[0] + self.x
ay = self._origin[1] + self.y
return [(self._rect(), ax - self.prev[0], ay - self.prev[1])]
def draw(self, surface, assets):
if self.level.debug and len(self.points) > 1:
self._debug_path(surface, self.points, closed=(self.mode == "loop"))
# crumbled away: hidden (ghost in debug), unless re-forming into the player
if self.crumble and self.cstate == "gone" and not self.emerge_kill:
if self.level.debug:
self._debug_ghost(surface, assets, self.sprite)
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), rect)
if self.fake and self.level.debug:
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
# --- 4. arrow shooter --------------------------------------------------------
class Arrow:
__slots__ = ("rect", "vx", "vy")
def __init__(self, rect, vx, vy):
self.rect = rect
self.vx = vx
self.vy = vy
class ArrowShooter(Trap):
"""A block that fires deadly arrows on an interval while triggered.
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")
self.speed = float(spec.get("speed", 260.0))
self.interval = float(spec.get("interval", 1.6))
self._init_trigger(spec)
self.reset()
def reset(self):
self.timer = 0.0
self.arrows = []
self._reset_trigger()
def _spawn(self):
dx, dy = _DIRS.get(self.direction, (-1, 0))
t = self.tile
w = t // 2 if dx else t // 3
h = t // 3 if dx else t // 2
r = self.base_rect
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
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
def update(self, dt, game):
can_fire = self.triggered(game, dt)
self.timer += dt
if can_fire and self.timer >= self.interval:
self.timer = 0.0
self._spawn()
bounds = pygame.Rect(0, 0, self.level.width, self.level.height).inflate(80, 80)
alive = []
for a in self.arrows:
a.rect.x += round(a.vx * dt)
a.rect.y += round(a.vy * dt)
if bounds.contains(a.rect) or bounds.colliderect(a.rect):
# stop at solid walls
if not any(a.rect.colliderect(s) for s in self.level.solids):
alive.append(a)
self.arrows = alive
def hazard_rects(self):
return [a.rect for a in self.arrows]
def draw(self, surface, assets):
surface.blit(assets.get("arrow_shooter", self.tile, self.tile), self.base_rect)
for a in self.arrows:
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), a.rect)
# --- warp: invisible teleporter ---------------------------------------------
class Warp(Trap):
"""An invisible tile that teleports the player to ``to: [col, row]`` on
contact. The level's ``debug`` flag tints it (and draws a line to its
destination) while designing."""
def __init__(self, spec, level):
super().__init__(spec, level)
self.invisible = True
to = spec.get("to", [self.col, self.row])
self.dest = (int(to[0]), int(to[1]))
self.reset()
def reset(self):
self._armed = True # re-arms once the player has left the tile
def update(self, dt, game):
inside = game.player.rect.colliderect(self.base_rect)
if inside and self._armed:
p = game.player
p.fx = float(self.dest[0] * self.tile + (self.tile - p.w) / 2)
p.fy = float(self.dest[1] * self.tile + (self.tile - p.h))
p.vx = p.vy = 0.0
p._sync_rect()
self._armed = False
elif not inside:
self._armed = True
def draw(self, surface, assets):
if self.level.debug:
self._debug_tint(surface, (210, 80, 235), alpha=90)
dest = pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
self.tile, self.tile)
pygame.draw.line(surface, (210, 80, 235),
self.base_rect.center, 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.base_rect)
# --- registry + factory ------------------------------------------------------
TRAP_TYPES = {
"spike": Spike,
"block": Block,
"arrow_shooter": ArrowShooter,
"warp": Warp,
"phase_block": PhaseBlock,
}
def make_trap(spec, level):
ttype = spec.get("type")
cls = TRAP_TYPES.get(ttype)
if cls is None:
print(f"[level] unknown trap type: {ttype!r} — skipping")
return None
return cls(spec, level)
def expand_spec(spec):
"""Expand a trap spec's ``count`` into a line/grid of copies.
``count: [nx, ny]`` (or a single int for a horizontal line) places
nx-by-ny copies, each offset by ``spacing: [sx, sy]`` tiles (default 1).
Only ``at`` is shifted per copy (so ``move`` is relative and works;
absolute ``path`` is shared, so arrays suit stationary/simple traps).
A rectangle of ``invisible`` blocks replaces the old invisible wall.
"""
count = spec.get("count")
if count is None:
yield spec
return
if isinstance(count, (list, tuple)):
nx = int(count[0])
ny = int(count[1]) if len(count) > 1 else 1
else:
nx, ny = int(count), 1
spacing = spec.get("spacing", 1)
if isinstance(spacing, (list, tuple)):
sx = spacing[0]
sy = spacing[1] if len(spacing) > 1 else spacing[0]
else:
sx = sy = spacing
bc, br = spec.get("at", [0, 0])
for j in range(ny):
for i in range(nx):
s = dict(spec)
s.pop("count", None)
s.pop("spacing", None)
s["at"] = [bc + i * sx, br + j * sy]
yield s

88
levels/level1.yaml Normal file
View File

@@ -0,0 +1,88 @@
# Level 1 — "Low Battery"
# A gentle-ish introduction to the fact that nothing here can be trusted.
#
# Map legend: #=solid -=one-way platform P=spawn G=goal(charger) .=empty
# Trap coords are [col, row], 0-based from the top-left of the map.
name: "1 — Low Battery"
tile_size: 32
battery_seconds: 40
map: |
.........................
.........................
.......#####.............
.........................
.........................
..............#####......
.........................
....###..................
.........................
.................####....
.........................
.P.....................G.
##########.###.##########
traps:
# The 4th tile of this ledge isn't really there — a classic pit-trap step.
- type: block
at: [7, 7]
fake: true
# A rhythmic floor spike near the start. Timed, so you can learn to read it.
- type: spike
at: [5, 11]
direction: up
trigger: { timer: { interval: 1.2, up_time: 0.7 } }
# The little island between the pits looks safe. It is — for a quarter
# second. Keep moving and you pass; hesitate and it strikes.
- type: spike
at: [12, 11]
direction: up
trigger: { within: 1.6 }
delay: 0.25
# A wall turret raking the upper platform with arrows (always firing).
- type: arrow_shooter
at: [22, 5]
direction: left
speed: 240
interval: 1.4
# This tile extends the right-hand ledge... until you step over it, then it
# drops (mode: once — extends while triggered, retracts when not). Two
# conditions: you're close AND directly above it. Because the sensors track
# the block, it keeps dropping as you ride it down.
- type: block
at: [21, 9]
move: [0, 4]
speed: 220
sense: current # rides down with you (senses from its live position)
trigger:
all:
- { within: 5 }
- { dir: above, aligned: true }
# A ferry platform between the low ledges — ride it, don't rush it. A patrol
# is just a block with a path and (the default) `always` trigger.
- type: block
at: [9, 10]
path: [[9, 10], [14, 10], [14, 6]]
mode: loop
speed: 110
sprite: patrol_block
# A stepping block that gives way a beat after you land on it.
- type: block
at: [16, 11]
crumble: true
crumble_delay: 0.4
respawn: 2.5
# An invisible catch-wall below the dropper — a vertical array of invisible
# blocks (this is what used to be an invisible_wall).
- type: block
at: [22, 10]
invisible: true
count: [1, 3]

104
levels/level2.yaml Normal file
View File

@@ -0,0 +1,104 @@
# Level 2 — "3% Remaining"
# Shorter fuse, meaner traps, more things that move when you least want them to.
name: "2 — 3% Remaining"
tile_size: 32
battery_seconds: 32
battery_pct: 4 # bar reads ~empty to match the theme (real time is 32s)
map: |
.........................
....#####................
.........................
.........................
.......----........###...
.........................
...###...........###.....
.........................
..........#..#...........
.........................
.P..................G....
#####.####.#####...######
traps:
# Ceiling spikes that stab down when you pass beneath — proximity triggered.
- type: spike
at: [8, 2]
direction: down
trigger: { within: 2.0 }
# A static spike hazard in the middle lane (a deadly block = spike block).
- type: block
at: [13, 4]
deadly: true
- type: block
at: [13, 5]
deadly: true
- type: block
at: [13, 6]
deadly: true
# A spiked platform patrolling the low run — jump it, never land on it.
# (Demonstrates a *mobile* mount: a block rides the patrolling block AND runs
# its own motion, lunging up to catch a player leaping over while it keeps
# sliding left/right with its carrier. Spikes ride on top of that block.)
- type: block
at: [6, 10]
path: [[6, 10], [9, 10]]
mode: pingpong
speed: 130
sprite: patrol_block
mounts:
# Rests one tile above the patrol block; springs three tiles up when the
# player is above it, then settles back — all while riding along.
- type: block
at: [0, -1]
move: [0, -3]
speed: 320
mode: once
trigger: { dir: above, range: 5, aligned: true }
mounts:
- type: spike
at: [0, -1] # spikes on top of the lunging block, riding with it
trigger: always
direction: up
# Two of the mid platform's tiles are fake — the middle looks fully floored.
- type: block
at: [11, 8]
fake: true
- type: block
at: [12, 8]
fake: true
# A block that slides sideways into you as you approach the gap.
- type: block
at: [16, 6]
move: [-3, 0]
speed: 260
trigger: { within: 3.0 }
# Crossfire: turrets from both walls at the goal approach.
- type: arrow_shooter
at: [23, 8]
direction: left
speed: 300
interval: 1.1
- type: arrow_shooter
at: [0, 4]
direction: right
speed: 220
interval: 1.7
trigger: { within: 8 }
# The single pillar before the goal (col 17 on the floor row) is a lie.
- type: block
at: [17, 11]
fake: true
# Crumbling stairs up to the exit.
- type: block
at: [19, 9]
crumble: true
crumble_delay: 0.3
respawn: 2.0

52
main.py Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Dying Phone — a single-screen 2D platformer of ridiculous traps.
Story: your phone is dying. Reach the charger at the end of each level before
the battery runs out. The level is littered with obvious and (mostly) hidden
traps. Touch one and you're instantly respawned at the start — traps reset.
Controls:
A / D (or arrows) move
Space / W / Up jump (hold for higher, tap for a hop)
S + Space drop through one-way platforms
R give up and respawn
Esc quit
Usage:
python main.py # play every level in ./levels
python main.py levels/foo.yaml [more.yaml ...] # play specific levels
python main.py --debug # force debug view on for all levels
"""
import os
import sys
from game.game import Game, discover_levels
ROOT = os.path.dirname(os.path.abspath(__file__))
def main():
args = sys.argv[1:]
debug = "--debug" in args
args = [a for a in args if a != "--debug"]
if args:
level_paths = [os.path.abspath(p) for p in args]
else:
level_paths = discover_levels(os.path.join(ROOT, "levels"))
if not level_paths:
print("No levels found. Add a .yaml level file to the levels/ directory.")
sys.exit(1)
missing = [p for p in level_paths if not os.path.isfile(p)]
if missing:
print("Level file(s) not found:", *missing, sep="\n ")
sys.exit(1)
assets_dir = os.path.join(ROOT, "assets")
Game(level_paths, assets_dir, debug=debug).run()
if __name__ == "__main__":
main()

6
pytest.ini Normal file
View File

@@ -0,0 +1,6 @@
[pytest]
pythonpath = .
testpaths = tests
addopts = -q
filterwarnings =
ignore:'fc-list' is missing:UserWarning

2
requirements-dev.txt Normal file
View File

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

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pygame>=2.5.0
PyYAML>=6.0

136
tests/conftest.py Normal file
View File

@@ -0,0 +1,136 @@
"""Shared pytest fixtures + helpers for the Dying Phone engine tests.
Everything runs headless via SDL's dummy drivers, so no window is needed.
"""
import os
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
import pygame
import pytest
from game import settings as S
from game.level import Level
from game.game import Game
from game.player import InputState
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
yield
pygame.quit()
# --- level/game factories ----------------------------------------------------
@pytest.fixture
def make_level(tmp_path):
n = [0]
def _make(yaml_text):
n[0] += 1
path = tmp_path / f"level_{n[0]}.yaml"
path.write_text(yaml_text)
return Level(str(path))
return _make
@pytest.fixture
def make_game(tmp_path):
n = [0]
def _make(yaml_text, **kw):
n[0] += 1
path = tmp_path / f"game_{n[0]}.yaml"
path.write_text(yaml_text)
# nonexistent assets dir -> placeholder rectangles (deterministic)
return Game([str(path)], str(tmp_path / "no_assets"), **kw)
return _make
# --- run-loop helpers ---------------------------------------------------------
def step(game, inp=None):
"""Advance one frame, mirroring Game.run()'s per-state dispatch."""
inp = inp or InputState()
if game.state == "playing":
game._update_play(DT, inp)
elif game.state == "dying":
game.death_timer -= DT
if game.death_timer <= 0:
game._respawn()
elif game.state == "charging":
game.charge_timer += DT
if game.charge_timer >= S.CHARGE_TIME:
game.state = "won_all" if game._final else "won_level"
elif game.state == "fading":
game._update_fade(DT)
def run(game, frames, inp_fn=None):
for i in range(frames):
step(game, inp_fn(i) if inp_fn else None)
def place(game, col, row):
"""Put the player's feet at the bottom of cell (col,row), centred."""
t = game.level.tile
game.player.fx = float(col * t + (t - game.player.w) / 2)
game.player.fy = float(row * t + (t - game.player.h))
game.player.vx = game.player.vy = 0.0
game.player._sync_rect()
def hold(**flags):
inp = InputState()
for k, v in flags.items():
setattr(inp, k, v)
return inp
# --- fakes for isolated trap unit tests --------------------------------------
class FakeLevel:
tile = 32
debug = False
width = 800
height = 480
def cell_rect(self, c, r):
return pygame.Rect(c * 32, r * 32, 32, 32)
class FakePlayer:
def __init__(self, rect):
self.rect = rect
self.w = rect.w
self.h = rect.h
self.fx = float(rect.x)
self.fy = float(rect.y)
self.vx = self.vy = 0.0
def _sync_rect(self):
self.rect.x = round(self.fx)
self.rect.y = round(self.fy)
class FakeGame:
def __init__(self, rect):
self.player = FakePlayer(rect)
def reversals(values):
"""Count direction reversals in a numeric sequence (for jitter tests)."""
rev, prev = 0, 0
for i in range(1, len(values)):
d = (values[i] > values[i - 1]) - (values[i] < values[i - 1])
if d and prev and d != prev:
rev += 1
if d:
prev = d
return rev

176
tests/test_block.py Normal file
View File

@@ -0,0 +1,176 @@
"""The unified `block` trap: modes, deadly/fake/crumble, jitter, sensing."""
import pygame
from conftest import FakeGame, FakeLevel, reversals, step, run, place, hold
from game.traps import Block, expand_spec
def block(**spec):
spec.setdefault("type", "block")
spec.setdefault("at", [16, 6])
return Block(spec, FakeLevel())
def test_stationary_is_solid():
b = block(at=[3, 3])
assert b.solid_rects() == [b._rect()]
assert b.hazard_rects() == []
def test_deadly_is_hazard_not_solid():
b = block(at=[3, 3], deadly=True)
assert b.solid_rects() == []
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.sprite == "fake_block" # looks like a real block
def test_crumble_cycle_and_emerge_kill(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
######
#.P..#
#...G#
######
traps:
- type: block
at: [2, 2]
crumble: true
crumble_delay: 0.3
respawn: 0.8
""")
cb = g.level.traps[0]
states, killed, d0 = set(), False, g.deaths
for _ in range(300):
step(g)
states.add(cb.cstate)
if g.deaths > d0:
killed = True
break
assert "gone" in states # it crumbled away
assert killed # re-formed onto the standing player
def test_moving_block_that_crumbles(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
##########
#........#
#........#
#.......G#
##########
traps:
- type: block
at: [2, 2]
path: [[2, 2], [7, 2]]
mode: pingpong
speed: 90
crumble: true
crumble_delay: 0.3
respawn: 1.0
""")
b = g.level.traps[0]
place(g, 2, 1) # ride the platform
moved = crumbled = False
start = b.x
for _ in range(200):
step(g)
moved |= abs(b.x - start) > 20
crumbled |= b.cstate != "solid"
if g.state != "playing":
break
assert moved and crumbled
def test_patrol_loop_visits_all_waypoints():
b = block(at=[0, 0], path=[[0, 0], [4, 0], [4, 3]], mode="loop", speed=400)
g = FakeGame(pygame.Rect(0, 0, 4, 4))
seen = set()
for _ in range(600):
b.update(1 / 60, g)
seen.add((round(b.x / 32), round(b.y / 32)))
assert {(0, 0), (4, 0), (4, 3)} <= seen
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
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
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
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}]})
# player standing on top of it
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
g = FakeGame(p)
ys = []
for _ in range(90):
b.update(1 / 60, g)
# 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
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
# --- array expansion (count / spacing) --------------------------------------
def test_expand_no_count_yields_one():
specs = list(expand_spec({"type": "block", "at": [2, 3]}))
assert specs == [{"type": "block", "at": [2, 3]}]
def test_expand_line():
ats = [s["at"] for s in expand_spec({"type": "spike", "at": [1, 1], "count": 4})]
assert ats == [[1, 1], [2, 1], [3, 1], [4, 1]]
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]})]
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]}):
assert "count" not in s and "spacing" not in s
def test_array_expands_in_level(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
########
#......#
#P....G#
########
traps:
- type: block
at: [2, 1]
deadly: true
count: [4, 1]
""")
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]

176
tests/test_crush.py Normal file
View File

@@ -0,0 +1,176 @@
"""Crush death, shove/carry interactions, and the collision-resolution
regressions (corner warp, fits-under, cliff-shove)."""
from conftest import step, run, place, hold
def until_crushed(g, frames=200):
for _ in range(frames):
if g.state != "playing":
break
step(g)
if g.player.crushed:
return True
return False
def test_descend_crush(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
#####
#...#
#P.G#
#####
traps:
- type: block
at: [1, 1]
move: [0, 2]
speed: 200
trigger: { within: 20 }
""")
# player stands on the floor under the descending block
assert until_crushed(g, 120)
def test_fits_under_no_false_crush(make_game):
# A block that stops with clearance must NOT crush a grounded player.
g = make_game("""
name: t
tile_size: 32
map: |
#####
#...#
#...#
#P.G#
#####
traps:
- type: block
at: [1, 1]
move: [0, 1]
speed: 160
trigger: { within: 20 }
""")
place(g, 1, 2)
for _ in range(120):
step(g)
assert not g.player.crushed
def test_shove_into_wall_crushes(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
#######
#....G#
#.....#
#######
traps:
- type: block
at: [2, 2]
path: [[2, 2], [6, 2]]
mode: pingpong
speed: 200
""")
# player pinned against the right wall
place(g, 5, 2)
g.player.fx = float(6 * 32 - g.player.w)
g.player._sync_rect()
assert until_crushed(g)
def test_shove_along_not_into_floor(make_game):
# A block moving horizontally into a grounded player shoves them sideways,
# never buries them in the floor.
g = make_game("""
name: t
tile_size: 32
map: |
############
#..........#
#..........#
#.........G#
############
traps:
- type: block
at: [8, 3]
path: [[8, 3], [2, 3]]
mode: pingpong
speed: 160
""")
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
for _ in range(120):
step(g)
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
def test_corner_clip_does_not_warp(make_game):
# Walking into a moving block near its corner must not fling the player
# across it (the old velocity-sign resolver bug).
g = make_game("""
name: t
tile_size: 32
map: |
#########
#.......#
#.......#
#.......#
#P.....G#
#########
traps:
- type: block
at: [5, 2]
path: [[5, 2], [2, 2]]
mode: pingpong
speed: 150
""")
place(g, 4, 3)
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
prev = g.player.rect.x
def test_cliff_shove_falls_on_first_pass(make_game):
# A block sweeping the player toward a ledge should push them off on the
# first pass (before it turns around).
g = make_game("""
name: t
tile_size: 32
map: |
###########
#.........#
#.........#
######....#
traps:
- type: block
at: [1, 2]
path: [[1, 2], [8, 2]]
mode: pingpong
speed: 150
""")
b = g.level.traps[0]
place(g, 4, 2)
reversalsN, pd, left_at = 0, 1, None
for i in range(240):
step(g)
if g.state != "playing":
break
d = 1 if b.x > b.prev[0] else (-1 if b.x < b.prev[0] else pd)
if d != pd:
reversalsN += 1
pd = d
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

190
tests/test_flow.py Normal file
View File

@@ -0,0 +1,190 @@
"""Level loading, death/respawn, counters, level-clear flow, window sizing."""
import pygame
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
battery_seconds: 30
map: |
#####
#P.G#
#####
"""
def test_level_parse(make_level):
lvl = make_level(SIMPLE)
assert lvl.name == "My 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
def test_unknown_trap_type_is_skipped(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
###
#P#
###
traps:
- type: not_a_real_trap
at: [1, 1]
""")
assert lvl.traps == [] # skipped, no crash
def test_death_pause_then_respawn(make_game):
g = make_game(SIMPLE)
run(g, 5)
d0 = g.deaths
g._start_death()
assert g.state == "dying" and g.deaths == d0 + 1
frames = 0
while g.state == "dying" and frames < 100:
step(g)
frames += 1
assert g.state == "playing"
assert abs(frames * DT - S.DEATH_PAUSE) < DT * 2
def test_death_counters_per_level_and_total(make_game, tmp_path):
# two-level game to test per-level reset vs session total
a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml"
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():
g._start_death()
while g.state == "dying":
step(g)
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
die()
assert g.level_deaths == 1 and g.deaths == 4
g._replay()
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
for _ in range(70):
step(g)
assert g.state == "won_all" # single level -> won_all
def test_fade_swaps_level_at_black(make_game, tmp_path):
a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml"
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)
out = swapped = done = 0
for i in range(80):
prev = g.index
step(g)
if g.fade_phase == "out" and g.state == "fading":
out += 1
if g.index != prev:
swapped = i
if g.state == "playing":
done = i
break
assert swapped and done and g.index == 1
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
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))
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
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_battery_visual_scales_by_pct(make_level):
lvl = make_level(SIMPLE + "battery_pct: 4\n")
assert lvl.battery_pct == 4
def test_cli_debug_forces_all_levels(make_game):
g = make_game(SIMPLE, debug=True)
assert g.level.debug is True
g2 = make_game(SIMPLE)
assert g2.level.debug is False
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")
g._reload_level()
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
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
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
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")
a = AssetStore("noassets")
def painted(dbg):
lvl.debug = dbg
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

153
tests/test_mounting.py Normal file
View File

@@ -0,0 +1,153 @@
"""Mounting traps on other traps."""
from conftest import step, DT
from game.traps import Block, Spike
PLATFORM_WITH_SPIKE = """
name: t
tile_size: 32
map: |
############
#..........#
#..........#
#.........G#
#P.........#
############
traps:
- type: block
at: [3, 2]
path: [[3, 2], [8, 2]]
mode: pingpong
speed: 120
sprite: patrol_block
mounts:
- type: spike
at: [0, -1]
trigger: always
direction: up
"""
def test_spike_mount_follows_platform(make_game):
g = make_game(PLATFORM_WITH_SPIKE)
plat = g.level.traps[0]
sp = plat.mounts[0]
assert isinstance(sp, Spike)
for _ in range(30):
g.level.update(DT, g)
p = plat.current_rect()
hz = sp.hazard_rects()[0]
# spike sits one tile above the platform and moves with it
assert hz.centerx == p.centerx
assert hz.bottom <= p.top + 1
def test_block_mounted_on_block(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
############
#..........#
#..........#
#.........G#
#P.........#
############
traps:
- type: block
at: [3, 2]
path: [[3, 2], [8, 2]]
mode: pingpong
speed: 120
sprite: patrol_block
mounts:
- type: block
at: [-1, 0]
deadly: true
- type: block
at: [1, 0]
deadly: true
""")
plat = g.level.traps[0]
g.level.update(DT, g)
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])
assert (m.current_rect().x, m.current_rect().y) == exp
assert m.hazard_rects()
MOBILE_MOUNT = """
name: t
tile_size: 32
map: |
################
#..............#
#..............#
#..............#
#..............#
#P............G#
################
traps:
- type: block
at: [3, 4]
path: [[3, 4], [8, 4]]
mode: pingpong
speed: 120
sprite: patrol_block
mounts:
# Rides the patrol AND runs its own upward stroke when armed.
- type: block
at: [0, -1]
move: [0, -3]
speed: 240
mode: once
trigger: { within: 40 } # player is always in range -> stays extended
mounts:
- type: spike
at: [0, -1]
trigger: always
direction: up
"""
def test_block_mount_runs_own_motion_while_riding(make_game):
g = make_game(MOBILE_MOUNT)
plat = g.level.traps[0]
mnt = plat.mounts[0]
assert isinstance(mnt, Block) and mnt._mounted
tile = g.level.tile
# At rest the mount sits one tile above the patrol, tracking its column.
assert mnt.current_rect().x == plat.current_rect().x
assert mnt.current_rect().top == plat.current_rect().top - tile
for _ in range(120):
g.level.update(DT, g)
pr, mr = plat.current_rect(), mnt.current_rect()
# Still rides along horizontally (same column offset as home)...
assert mr.x == pr.x
# ...but has run its own three-tile upward stroke above the resting spot
# (resting = 1 tile up; extended = 1 + 3 tiles up).
assert mr.top == pr.top - 4 * tile
# It reports as a solid carrier so a rider would be carried.
assert mnt.solid_rects() and mnt.carriers()
# The spike rides the lunging block, one tile above it.
spike = mnt.mounts[0]
hz = spike.hazard_rects()[0]
assert hz.centerx == mr.centerx and hz.bottom <= mr.top + 1
def test_reset_repositions_mounts(make_game):
g = make_game(PLATFORM_WITH_SPIKE)
plat = g.level.traps[0]
sp = plat.mounts[0]
for _ in range(40):
g.level.update(DT, g)
g.level.reset()
# after reset the platform is home and the mount snapped back onto it
assert sp.base_rect.centerx == plat.current_rect().centerx

151
tests/test_physics.py Normal file
View File

@@ -0,0 +1,151 @@
"""Player movement & collision."""
from conftest import step, run, place, hold
from game import settings as S
FLAT = """
name: t
tile_size: 32
map: |
##########
#........#
#........#
#........#
#P......G#
##########
"""
def test_falls_and_lands_on_floor(make_game):
g = make_game(FLAT)
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)
def test_terminal_velocity(make_game):
g = make_game(FLAT)
place(g, 4, 1)
for _ in range(200):
step(g)
assert g.player.vy <= S.MAX_FALL + 1
def test_jump_gains_height_then_returns(make_game):
g = make_game(FLAT)
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
def test_variable_jump_height(make_game):
# Full-hold jump should out-climb a 1-frame tap.
def peak(hold_frames):
g = make_game(FLAT)
run(g, 30)
ground = g.player.rect.bottom
hi = ground
for i in range(60):
held = i < hold_frames
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
def test_coyote_time_allows_jump_after_leaving_ledge(make_game):
# Walk off a ledge, then jump within the coyote window -> should rise.
g = make_game("""
name: t
tile_size: 32
map: |
#######
#.....#
#.....#
##....#
#P#...#
#######
""")
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
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)
run(g, 120, lambda i: hold(left=True))
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):
g = make_game("""
name: t
tile_size: 32
map: |
#######
#.....#
#.....#
#--...#
#.....#
#P...G#
#######
""")
# Fall onto the one-way from above -> lands on it.
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
# From below, jumping up passes through it (doesn't block the head).
g2 = make_game("""
name: t
tile_size: 32
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
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
passed = True
assert passed
def test_drop_through_oneway(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
#######
#.....#
#.....#
#--...#
#.....#
#....G#
#######
""")
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

339
tests/test_traps.py Normal file
View File

@@ -0,0 +1,339 @@
"""Spike, arrow_shooter, warp, phase_block + the invisible flag & block arrays."""
import hashlib
import pygame
from conftest import (FakeGame, step, run, place, hold, DT)
from game.assets import AssetStore
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
# --- spike -------------------------------------------------------------------
def test_spike_active_by_trigger(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
#####
#P.G#
#####
traps:
- type: spike
at: [1, 1]
trigger: always
direction: up
""")
sp = lvl.traps[0]
sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4)))
assert sp.active and sp.hazard_rects()
def test_spike_hazard_is_half_tile_on_the_direction_edge(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
#####
#P.G#
#####
traps:
- type: spike
at: [1, 1]
trigger: always
direction: down
""")
sp = lvl.traps[0]
sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4)))
hz = sp.hazard_rects()[0]
# down spike occupies the TOP half of its cell
assert hz.height == 16 and hz.top == sp.base_rect.top
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
adir = tmp_path / "assets"
adir.mkdir()
pygame.image.save(surf, str(adir / "spike.png"))
a = AssetStore(str(adir))
up = a.get("spike", 32, 16, 0)
down = a.get("spike", 32, 16, 180)
left = a.get("spike", 16, 32, 90)
def h(s):
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
assert h(up) != h(down) # rotation actually happened
assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
# --- arrow shooter -----------------------------------------------------------
def test_arrow_shooter_fires_on_interval(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
##########
#.......G#
#P.......#
##########
traps:
- type: arrow_shooter
at: [7, 1]
direction: left
speed: 200
interval: 0.5
""")
sh = lvl.traps[0]
g = FakeGame(pygame.Rect(0, 0, 4, 4))
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
def test_arrow_shooter_trigger_gates_firing(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
##########
#.......G#
#P.......#
##########
traps:
- type: arrow_shooter
at: [7, 1]
direction: left
interval: 0.2
trigger: { within: 1 }
""")
sh = lvl.traps[0]
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
# --- invisible wall, now an array of invisible blocks ------------------------
def test_invisible_block_array_is_solid_and_invisible(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
######
#....#
#....#
#P..G#
######
traps:
- type: block
at: [3, 1]
invisible: true
count: [1, 2]
""")
blocks = [t for t in lvl.traps if isinstance(t, Block)]
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
# --- warp --------------------------------------------------------------------
def test_warp_teleports_and_rearms(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
##########
#.......G#
#P.......#
##########
traps:
- type: warp
at: [4, 2]
to: [7, 1]
""")
place(g, 2, 2)
warped = False
for _ in range(120):
step(g, hold(right=True))
if g.player.rect.x >= 7 * 32 - 4:
warped = True
break
assert warped
# --- phase block -------------------------------------------------------------
def test_phase_block_intangible_until_triggered(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
##########
#........#
#........#
#P......G#
##########
traps:
- type: phase_block
at: [4, 3]
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
run(g, 40, lambda i: hold(right=True))
assert pb.solid and pb.alpha > 0 # phased in solid on approach
def test_phase_block_kills_if_inside_when_it_forms(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
########
#......#
#.....G#
########
traps:
- type: phase_block
at: [3, 2]
fade: 0.2
trigger: { within: 3 }
""")
pb = g.level.traps[0]
place(g, 3, 2) # standing right where it will form
killed, d0 = False, g.deaths
for _ in range(10):
step(g)
if g.deaths > d0:
killed = True
break
assert killed
def test_phase_block_nudges_player_clipping_edge(make_game):
# Only clipping the edge of a forming phase block -> shoved clear, not killed.
g = make_game("""
name: t
tile_size: 32
map: |
#########
#.......#
#......G#
#########
traps:
- type: phase_block
at: [4, 1]
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.fy = float(b.top + 2)
g.player.vx = g.player.vy = 0.0
g.player._sync_rect()
d0 = g.deaths
step(g)
# 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
def test_phase_block_kills_when_shove_would_squish(make_game):
# Clipping the edge but backed by a wall on the escape side -> lethal.
g = make_game("""
name: t
tile_size: 32
map: |
########
#......#
#.....G#
########
traps:
- type: phase_block
at: [1, 1]
fade: 0.2
trigger: { within: 5 }
""")
pb = g.level.traps[0]
b = pb.base_rect
# Clip the block's left edge, but the level wall (col 0) is right there, so
# the leftward shove has nowhere to go — squished.
g.player.fx = float(b.left - g.player.w + 5)
g.player.fy = float(b.top + 2)
g.player.vx = g.player.vy = 0.0
g.player._sync_rect()
killed, d0 = False, g.deaths
for _ in range(6):
step(g)
if g.deaths > d0:
killed = True
break
assert killed
def test_phase_block_snaps_visible_on_death(make_game):
# Dying while a phase block is forming should snap it fully visible for the
# frozen death tableau.
g = make_game("""
name: t
tile_size: 32
map: |
########
#......#
#.....G#
########
traps:
- type: phase_block
at: [3, 2]
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
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
# --- generic invisible flag --------------------------------------------------
def test_invisible_flag_on_any_trap(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
#####
#...#
#P.G#
#####
traps:
- type: spike
at: [2, 1]
trigger: always
direction: up
invisible: true
""")
a = AssetStore("no_assets_dir")
sp = lvl.traps[0]
g = FakeGame(pygame.Rect(999, 999, 4, 4))
def painted(debug):
lvl.debug = debug
sp.tick(DT, g)
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))
assert painted(False) is False # invisible in play
assert painted(True) is True # revealed in debug
assert sp.hazard_rects() # still deadly either way

91
tests/test_triggers.py Normal file
View File

@@ -0,0 +1,91 @@
"""Trigger conditions and the arm-delay hysteresis."""
import pygame
from conftest import FakeGame, FakeLevel
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)})()
return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt)
# trap centre = (116, 116); left100 right132 top100 bottom132
def test_always():
assert ev("always", 999, 999) is True
def test_within_radius():
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": "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
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
# without aligned, any column counts
assert ev({"dir": "above"}, 400, 60) is True
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
c2 = {"any": [{"dir": "left"}, {"dir": "right"}]}
assert ev(c2, 40, 105) is True
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)})()
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
def test_bad_condition_raises():
import pytest
with pytest.raises(ValueError):
make_condition({"nope": 1})
def test_delay_hysteresis(make_level):
# A spike within-1.6 with delay 0.25 arms only after staying in range.
lvl = make_level("""
name: t
tile_size: 32
map: |
#####
#P.G#
#####
traps:
- type: spike
at: [1, 1]
trigger: { within: 5 }
delay: 0.25
""")
sp = lvl.traps[0]
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

192
tools/gen_sprites.py Normal file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Generate simple default sprite PNGs into ../assets/.
Everything here is plain pygame drawing at 32x32 (the tile size). Re-run any
time to regenerate; tweak the draw_* functions, or just replace the PNGs with
your own art (the game loads them by filename either way).
python tools/gen_sprites.py
"""
import os
import pygame
TILE = 32
ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")
def _s():
return pygame.Surface((TILE, TILE), pygame.SRCALPHA)
# --- individual sprites ------------------------------------------------------
def draw_block(s):
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.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
def draw_fake_block(s):
# Intentionally identical to a real block — that's the whole trap.
draw_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, (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
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.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
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, (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, (200, 190, 80), bolt, 1)
def draw_spike(s):
base, edge = (156, 162, 178), (92, 98, 116)
n = 4
w = TILE / n
for i in range(n):
x = i * w
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
def draw_moving_block(s):
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
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
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)
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, [(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
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, (22, 24, 30), (TILE // 2, TILE // 2), 6) # barrel
pygame.draw.circle(s, (150, 64, 64), (TILE // 2, TILE // 2), 3)
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
]
for t in tris:
pygame.draw.polygon(s, base, t)
pygame.draw.polygon(s, edge, t, 1)
pygame.draw.circle(s, (112, 118, 136), (m, m), 8)
pygame.draw.circle(s, edge, (m, m), 8, 1)
def draw_phase_block(s):
# Cyan "energy" block; the game fades its alpha in/out as it phases.
s.fill((70, 150, 190))
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)
def draw_arrow(s):
# Orientation-agnostic energy bolt (arrows fly in several directions and the
# engine doesn't rotate the sprite).
cx = cy = TILE // 2
diamond = [(cx, cy - 9), (cx + 9, cy), (cx, cy + 9), (cx - 9, cy)]
pygame.draw.polygon(s, (250, 225, 110), diamond)
pygame.draw.polygon(s, (210, 175, 70), diamond, 2)
pygame.draw.circle(s, (255, 248, 190), (cx, cy), 3)
SPRITES = {
"player": draw_player,
"player_dead": draw_player_dead,
"block": draw_block,
"fake_block": draw_fake_block,
"goal": draw_goal,
"spike": draw_spike,
"moving_block": draw_moving_block,
"patrol_block": draw_patrol_block,
"crumble_block": draw_crumble_block,
"arrow_shooter": draw_arrow_shooter,
"arrow": draw_arrow,
"spike_block": draw_spike_block,
"phase_block": draw_phase_block,
}
def main():
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
pygame.init()
os.makedirs(ASSETS, exist_ok=True)
for name, fn in SPRITES.items():
surf = _s()
fn(surf)
pygame.image.save(surf, os.path.join(ASSETS, name + ".png"))
pygame.quit()
print(f"wrote {len(SPRITES)} sprites to {os.path.normpath(ASSETS)}")
if __name__ == "__main__":
main()