From d84e4627c37ded606c4dac240e9c79ab7ae999e7 Mon Sep 17 00:00:00 2001 From: James Campbell Date: Tue, 21 Jul 2026 19:36:02 -0400 Subject: [PATCH] Initial commit --- .gitignore | 1 + README.md | 238 +++++++++++ SPEC.md | 439 +++++++++++++++++++ assets/README.md | 29 ++ assets/arrow.png | Bin 0 -> 245 bytes assets/arrow_shooter.png | Bin 0 -> 293 bytes assets/block.png | Bin 0 -> 174 bytes assets/crumble_block.png | Bin 0 -> 303 bytes assets/fake_block.png | Bin 0 -> 174 bytes assets/goal.png | Bin 0 -> 351 bytes assets/moving_block.png | Bin 0 -> 227 bytes assets/patrol_block.png | Bin 0 -> 239 bytes assets/phase_block.png | Bin 0 -> 319 bytes assets/player.png | Bin 0 -> 256 bytes assets/player_dead.png | Bin 0 -> 345 bytes assets/spike.png | Bin 0 -> 197 bytes assets/spike_block.png | Bin 0 -> 534 bytes game/__init__.py | 1 + game/assets.py | 72 ++++ game/game.py | 377 +++++++++++++++++ game/level.py | 170 ++++++++ game/player.py | 270 ++++++++++++ game/settings.py | 48 +++ game/traps.py | 883 +++++++++++++++++++++++++++++++++++++++ levels/level1.yaml | 88 ++++ levels/level2.yaml | 104 +++++ main.py | 52 +++ pytest.ini | 6 + requirements-dev.txt | 2 + requirements.txt | 2 + tests/conftest.py | 136 ++++++ tests/test_block.py | 176 ++++++++ tests/test_crush.py | 176 ++++++++ tests/test_flow.py | 190 +++++++++ tests/test_mounting.py | 153 +++++++ tests/test_physics.py | 151 +++++++ tests/test_traps.py | 339 +++++++++++++++ tests/test_triggers.py | 91 ++++ tools/gen_sprites.py | 192 +++++++++ 39 files changed, 4386 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 assets/README.md create mode 100644 assets/arrow.png create mode 100644 assets/arrow_shooter.png create mode 100644 assets/block.png create mode 100644 assets/crumble_block.png create mode 100644 assets/fake_block.png create mode 100644 assets/goal.png create mode 100644 assets/moving_block.png create mode 100644 assets/patrol_block.png create mode 100644 assets/phase_block.png create mode 100644 assets/player.png create mode 100644 assets/player_dead.png create mode 100644 assets/spike.png create mode 100644 assets/spike_block.png create mode 100644 game/__init__.py create mode 100644 game/assets.py create mode 100644 game/game.py create mode 100644 game/level.py create mode 100644 game/player.py create mode 100644 game/settings.py create mode 100644 game/traps.py create mode 100644 levels/level1.yaml create mode 100644 levels/level2.yaml create mode 100644 main.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_block.py create mode 100644 tests/test_crush.py create mode 100644 tests/test_flow.py create mode 100644 tests/test_mounting.py create mode 100644 tests/test_physics.py create mode 100644 tests/test_traps.py create mode 100644 tests/test_triggers.py create mode 100644 tools/gen_sprites.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c7b1c1 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..28b383d --- /dev/null +++ b/SPEC.md @@ -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 / 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/.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 §12–13) | + +### 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 left–right +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. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..b2884e6 --- /dev/null +++ b/assets/README.md @@ -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. diff --git a/assets/arrow.png b/assets/arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..1d74c1e39c1cd9d964a0b63d4d39b034f265479f GIT binary patch literal 245 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJJ)SO(ArY-_r)}hIF%V#xK7B=_ zSclyLj-(Y+`uB4%DMc+{W9sm8;&{B3QL-s*$pOcQ}!7y=pC?|u&?}=Rtn7$P?b{ep^JjrR%R8UJ-y29xGX-@_N!{;xHl|vZQiyC`7 z*fO4I3d*m()78V98+L_-a3XISOCKC-m+}mA~M2XP59XB{@rO qC&B)CZNE7hck-)Du2L?NNoU=j-nF}i_i#AS9}J$ZelF{r5}E+|%2_S| literal 0 HcmV?d00001 diff --git a/assets/arrow_shooter.png b/assets/arrow_shooter.png new file mode 100644 index 0000000000000000000000000000000000000000..ed99403c8903749884427878fc1cd7cd20c4e23b GIT binary patch literal 293 zcmV+=0owkFP)k1z+i*jeyj?9Jq-X59WYvf(W)rSSF^`2QgJTaSF`yH%nXb|l5vhgH3yt11&*z` zt|Mrnzaxm%<9?expUziS9aaZ0+?89zfDgnmkz@b^CMjT&BFjXy0SZpVsU}!6fPt>u zVg^cz*8!!Yazzc`@B9~t;VE}QXIHY=g(Ng!v2f=?5|R(VxeZw}K!LbdG5cS@1!Ar5 rGu)Qa+S-=V=11`DfV~=Pt9b`LJhWwO&X|b^00000NkvXXu0mjf%Gh{0 literal 0 HcmV?d00001 diff --git a/assets/block.png b/assets/block.png new file mode 100644 index 0000000000000000000000000000000000000000..a846a8a3978434b8df7a638f8643b045f2d7a758 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJQcoAhkcigglFyw#&l@x}WGd*U zCjddC;TxGbFS0%^Wd0o>Tk!mx>?WS7Z%+i7fuMOxk4)q*b{j4?S%X&%f$Tk230D}I z-!W9AvaAj{!TRKtyp3YRYOAGNb{$5Jguf1>C~|8w(05@8UH)BL#(x6y5_0X0%$#Aeg^#cQWx;DK^Q^OV~f`u9x$Z z5^A4vb0Q+ggVXV_TF`bqHFc%F@$LQ~B0~ag*F$wd15I7Qi^a%Fr8<3h18L;SzCVc>n41w3KvgwoTq34hMgR8hLw02eNc zenGw@;jhJEaim}NmhOdw>!ERIo`rlG%y)>S0` zFUZq?W-RAPKvVBD2QEz&K1)lpiS`trvQCZ!1JYZ&&~T002ovPDHLkV1kYD Be1rf1 literal 0 HcmV?d00001 diff --git a/assets/fake_block.png b/assets/fake_block.png new file mode 100644 index 0000000000000000000000000000000000000000..a846a8a3978434b8df7a638f8643b045f2d7a758 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJQcoAhkcigglFyw#&l@x}WGd*U zCjddC;TxGbFS0%^Wd0o>Tk!mx>?WS7Z%+i7fuMOxk4)q*b{j4?S%X&%f$Tk230D}I z-!W9AvaAj{!TRKtyp3YRYOAGNb{$xx@T$RQ7ET8c3of5K$DnIuM7Dm4 z8~_V?N&wjofCas-krBh?bLX(eKTRD#Y0$#}3ptvW1n^;44$Fq;P=39$QEd z8!aS90Wm>ON}3?a0oa0`Qb4o=h|O}O<_MzG04(0|#XmXOk!S~?8;qV9$t?@0QVtAA zK}0P;mIIIzBqhEl+X2+50|+_*xg~?k5yV=K&nyfFAUB(^X(Y7@Ce$Ru*;>Wbkc2fn xCu!J_FVdQ&MBb@00IkCBLDyZ literal 0 HcmV?d00001 diff --git a/assets/phase_block.png b/assets/phase_block.png new file mode 100644 index 0000000000000000000000000000000000000000..33c8685f4b9c47ba9cf1e6bdcb2093971a91f8e1 GIT binary patch literal 319 zcmV-F0l@x=P)~4Kz;1@&;*AyT~NjAgL4xwn#oH%8Du%@8`jI=*{W=dVgr(xr`i6 zM0Cv$+jBd0@O+zz=nuZY0U)KtWMI+PvE9 zX}Ac$nM#Ixy*6A0*qb7TS&wKTfYKB(v>ph+lXg_E0<@$Zfe-;A(yl?U0Fh~zAgTa8 z(#}CtyP!wfIS6+bSkqp#ArY;~2@)?GCQ7(EDHZ)c zY53>vYMI&zPIs2F#CfKiKC1G>W7ChT-R>@yOv_nRr%isuA?DQ9;d#PvhQ5u@ zG-khJw-;G3Nhc_sPSI)#WEb#Zevy#9KyJ?4z5j$93!M@UI44|TR9Vg#^U>RDk8(33 zeKJafzUt6S6ud#27qKGCzcSW;rJ2cMOw?8F^S(SQmUSUvt}G znk9>KL%{65puNr8Bm2$@K4IvxQk!DcG-KUO4F-lvH|Ii$260uOml!-<{an^LB{Ts5 DYiV1G literal 0 HcmV?d00001 diff --git a/assets/player_dead.png b/assets/player_dead.png new file mode 100644 index 0000000000000000000000000000000000000000..b4c98f4f9d8680b6eda86c7d938df6c8f285ce6f GIT binary patch literal 345 zcmV-f0jB~{Qj(s@r?^y&8ZNRlYR3n*;yIi>IVrzJKysr+_nt>XLT`(&XGk1LOX4 zimYm&4>2~;je`QHAbe#FP{u9N;)?2$4{AU+4ho>k?^HcdL^=ghGB_6koH6Krq_fsQ zXxSHGxBCwPNQh9fssRAq?)wlvU&I!T;z2z@r9sV&Hj1;0}h4GAxW+&00000NkvXXu0mjf!|{1C&RoU@&(nhri~U}ontw6M7Gu*W^>1phh#iJN>99XcJ48XB3Jk27x2 zIdtGagQ@mKueuK{2Tqh7a7cCB!OSMqW7W{w$Y|Psh+)eA`jdO)+@enLS8(v`VP-Sp uHZtI86OhPYHxPQ|aY2fwf}_WZf#KNFdWMNNe>MZ%#o+1c=d#Wzp$Pz_F+>{x literal 0 HcmV?d00001 diff --git a/assets/spike_block.png b/assets/spike_block.png new file mode 100644 index 0000000000000000000000000000000000000000..32350a975b9fd83dd49e460db238df97570c650d GIT binary patch literal 534 zcmV+x0_pvUP)Q&qi3x@aA}I8I2QmHH$$`S`uqiEW53 z;_;l^mHls5Hk*07L>>ruJSSIsn%!C&#H&t#7wRRHUfHcZbqQq!WKvxBKVJd1TI*t( zbuo=*@Q9rE=GLm^SkwZ?hDs6=CLp43PfA<3KJtKUG_mJnPiWrI#s#^GqGE(1*dP<~ z$K#lOOI}kH0<9DSvE$Y)XFN48TenESQKu`r<{I)8f)J!muR#i}Y31;V7d53yx1$!8 zGg;LO(A=NOUTl~>CR;7BK3i+N05p~`bP5@-xOFj25CW}fOhNmRDHIZjxdbzB)jzZn YUwQX~4H@Hlp#T5?07*qoM6N<$f@L`6%K!iX literal 0 HcmV?d00001 diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..30eaf47 --- /dev/null +++ b/game/__init__.py @@ -0,0 +1 @@ +"""Dying Phone — a single-screen 2D platformer engine built on pygame.""" diff --git a/game/assets.py b/game/assets.py new file mode 100644 index 0000000..e1aebfe --- /dev/null +++ b/game/assets.py @@ -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 ``.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 diff --git a/game/game.py b/game/game.py new file mode 100644 index 0000000..7360438 --- /dev/null +++ b/game/game.py @@ -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 diff --git a/game/level.py b/game/level.py new file mode 100644 index 0000000..c36606f --- /dev/null +++ b/game/level.py @@ -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)) diff --git a/game/player.py b/game/player.py new file mode 100644 index 0000000..a8897a2 --- /dev/null +++ b/game/player.py @@ -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) diff --git a/game/settings.py b/game/settings.py new file mode 100644 index 0000000..89ef42b --- /dev/null +++ b/game/settings.py @@ -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), ""), +} diff --git a/game/traps.py b/game/traps.py new file mode 100644 index 0000000..3601668 --- /dev/null +++ b/game/traps.py @@ -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 diff --git a/levels/level1.yaml b/levels/level1.yaml new file mode 100644 index 0000000..752f2be --- /dev/null +++ b/levels/level1.yaml @@ -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] diff --git a/levels/level2.yaml b/levels/level2.yaml new file mode 100644 index 0000000..71df83a --- /dev/null +++ b/levels/level2.yaml @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..0e15215 --- /dev/null +++ b/main.py @@ -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() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b98430d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +pythonpath = . +testpaths = tests +addopts = -q +filterwarnings = + ignore:'fc-list' is missing:UserWarning diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9573d48 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=7.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9dc013d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pygame>=2.5.0 +PyYAML>=6.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..801bca1 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_block.py b/tests/test_block.py new file mode 100644 index 0000000..efb215d --- /dev/null +++ b/tests/test_block.py @@ -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] diff --git a/tests/test_crush.py b/tests/test_crush.py new file mode 100644 index 0000000..6e11d99 --- /dev/null +++ b/tests/test_crush.py @@ -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 diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 0000000..75c42b8 --- /dev/null +++ b/tests/test_flow.py @@ -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 diff --git a/tests/test_mounting.py b/tests/test_mounting.py new file mode 100644 index 0000000..8826f82 --- /dev/null +++ b/tests/test_mounting.py @@ -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 diff --git a/tests/test_physics.py b/tests/test_physics.py new file mode 100644 index 0000000..4d8cb66 --- /dev/null +++ b/tests/test_physics.py @@ -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 diff --git a/tests/test_traps.py b/tests/test_traps.py new file mode 100644 index 0000000..a6ac92b --- /dev/null +++ b/tests/test_traps.py @@ -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 diff --git a/tests/test_triggers.py b/tests/test_triggers.py new file mode 100644 index 0000000..c1868e9 --- /dev/null +++ b/tests/test_triggers.py @@ -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 diff --git a/tools/gen_sprites.py b/tools/gen_sprites.py new file mode 100644 index 0000000..449ab29 --- /dev/null +++ b/tools/gen_sprites.py @@ -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()