440 lines
21 KiB
Markdown
440 lines
21 KiB
Markdown
|
|
# Dying Phone — Engine & Level-Format Specification
|
|||
|
|
|
|||
|
|
This document describes the game engine and the level configuration format in
|
|||
|
|
enough detail to reimplement equivalent functionality from scratch. It does
|
|||
|
|
**not** describe the specific shipped levels — only the mechanics and the data
|
|||
|
|
format levels are written in.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Concept
|
|||
|
|
|
|||
|
|
A single-screen 2D platformer. Your phone is dying; each level's goal is to
|
|||
|
|
reach the charger before the battery runs out. Levels are dense with obvious
|
|||
|
|
and (mostly) hidden traps. Touching a hazard, falling out of the world, or
|
|||
|
|
running the battery to zero kills you and instantly respawns you at the start
|
|||
|
|
with every trap reset. There is no scrolling — each level fits one screen.
|
|||
|
|
|
|||
|
|
Built with Python + pygame. Art is optional: any missing sprite is drawn as a
|
|||
|
|
labeled colored rectangle, so the game is fully playable with zero assets.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Runtime, entry point, CLI
|
|||
|
|
|
|||
|
|
- Dependencies: `pygame` (>= 2.5), `PyYAML`.
|
|||
|
|
- Entry point `main.py`:
|
|||
|
|
- `python main.py` — play every `*.yaml`/`*.yml` in `./levels`, sorted.
|
|||
|
|
- `python main.py a.yaml b.yaml …` — play the given level files in order.
|
|||
|
|
- `--debug` flag (anywhere in args) — force the debug view on for all levels.
|
|||
|
|
- The game plays levels in sequence; finishing the last shows a win screen, and
|
|||
|
|
ENTER restarts from the first level.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Coordinates, tiles, window
|
|||
|
|
|
|||
|
|
- The world is a grid of square tiles. Tile size defaults to **32 px** and can
|
|||
|
|
be overridden per level (`tile_size`).
|
|||
|
|
- Grid coordinates are `[col, row]`, 0-based from the top-left of the map. A
|
|||
|
|
cell's pixel rect is `(col*tile, row*tile, tile, tile)`.
|
|||
|
|
- Y increases downward (screen convention).
|
|||
|
|
- The window is sized **once** at startup to fit the tallest and widest level
|
|||
|
|
(plus a HUD band of 46 px on top; minimum window width 480 px). It is never
|
|||
|
|
resized afterward — recreating the display mid-session makes the OS window
|
|||
|
|
flicker/close. Each level's play area is a surface of its own pixel size,
|
|||
|
|
centered within the fixed window below the HUD; smaller levels are
|
|||
|
|
letterboxed with a dark background.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Game loop & states
|
|||
|
|
|
|||
|
|
Fixed logical timestep driven by `pygame.time.Clock` at 60 FPS. Each frame `dt`
|
|||
|
|
is clamped to a max of `1/30` s to avoid tunneling on lag spikes.
|
|||
|
|
|
|||
|
|
Per frame: poll input → run the current state's update → draw.
|
|||
|
|
|
|||
|
|
States:
|
|||
|
|
|
|||
|
|
- **playing** — normal simulation (battery drains, level & player update, death
|
|||
|
|
and goal checks run).
|
|||
|
|
- **dying** — death pause: the whole scene is frozen (no level/player update);
|
|||
|
|
a timer counts down `DEATH_PAUSE` (0.5 s) showing the corpse, then respawns.
|
|||
|
|
- **charging** — level-clear beat: everything frozen while the battery-fill
|
|||
|
|
animation plays for `CHARGE_TIME` (1.0 s); then transitions to a win splash.
|
|||
|
|
- **won_level** / **won_all** — win splash overlays (per-level vs final).
|
|||
|
|
- **fading** — a fade-to-black transition between levels (see §8).
|
|||
|
|
|
|||
|
|
Input is edge- and level-sensitive:
|
|||
|
|
- `A`/`D` or arrows → move; `S`/down → down (used for dropping through
|
|||
|
|
one-ways); `Space`/`W`/`Up` → jump (both an edge `jump_pressed` and a held
|
|||
|
|
`jump_held`).
|
|||
|
|
- `R` → give up (triggers a death). `Esc` → quit. `Enter` → advance/replay on a
|
|||
|
|
win splash.
|
|||
|
|
- `F5` → hot-reload the current level file from disk. It is treated as a death:
|
|||
|
|
it plays the death beat and increments both counters (so it also escapes
|
|||
|
|
soft-locks), and on the following respawn the level is re-read from its file
|
|||
|
|
(picking up any edits) instead of merely reset. A malformed edit is caught and
|
|||
|
|
logged rather than crashing (the old level is kept). The per-level death count
|
|||
|
|
carries across the reload.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. Player physics
|
|||
|
|
|
|||
|
|
The player is an axis-aligned rectangle sized `0.72*tile` wide × `0.92*tile`
|
|||
|
|
tall. Position is tracked as floats (`fx, fy`); the integer collision rect is
|
|||
|
|
`round`ed from them each substep.
|
|||
|
|
|
|||
|
|
### 5.1 Tunables (pixels, seconds)
|
|||
|
|
| Constant | Value | Meaning |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `GRAVITY` | 2200 | downward acceleration (px/s²) |
|
|||
|
|
| `MAX_FALL` | 1400 | terminal downward velocity |
|
|||
|
|
| `MOVE_SPEED` | 320 | horizontal run speed |
|
|||
|
|
| `ACCEL` | 3200 | ground acceleration toward target speed |
|
|||
|
|
| `AIR_ACCEL` | 2200 | weaker air control |
|
|||
|
|
| `FRICTION` | 3600 | ground deceleration when no input |
|
|||
|
|
| `JUMP_SPEED` | 760 | initial upward velocity of a jump |
|
|||
|
|
| `JUMP_CUT` | 0.45 | fraction of upward velocity kept if jump released early |
|
|||
|
|
| `COYOTE_TIME` | 0.10 | grace window to jump after leaving a ledge |
|
|||
|
|
| `JUMP_BUFFER` | 0.10 | how long a jump press is remembered before landing |
|
|||
|
|
|
|||
|
|
### 5.2 Update order (per frame)
|
|||
|
|
1. **Ride platforms** — if standing on a moving platform (feet within 3 px of a
|
|||
|
|
carrier's top, with horizontal overlap), add that carrier's per-frame motion
|
|||
|
|
`(dx, dy)` to the player and record it as `carry`.
|
|||
|
|
2. **Push by movers** — if a block moving horizontally overlaps the player's
|
|||
|
|
side (with vertical overlap ≥ half the player height), displace the player
|
|||
|
|
out the *nearer* horizontal edge so the block shoves them along that axis
|
|||
|
|
(rather than the vertical pass burying them).
|
|||
|
|
3. **Horizontal** — accelerate `vx` toward the input target (or apply friction),
|
|||
|
|
integrate `fx`, then resolve X collisions.
|
|||
|
|
4. **Vertical** — update jump timers, apply jump/gravity, integrate `fy`, then
|
|||
|
|
resolve Y collisions.
|
|||
|
|
5. **Crush check** — set `crushed` (see §5.6).
|
|||
|
|
|
|||
|
|
### 5.3 Jumping
|
|||
|
|
- **Buffered**: a jump press within `JUMP_BUFFER` before landing still fires on
|
|||
|
|
landing.
|
|||
|
|
- **Coyote**: a jump within `COYOTE_TIME` after walking off a ledge still fires.
|
|||
|
|
- **Variable height**: releasing jump mid-rise multiplies remaining upward
|
|||
|
|
velocity by `JUMP_CUT` once (tap = short hop, hold = full jump).
|
|||
|
|
|
|||
|
|
### 5.4 Collision resolution (minimum-translation, per axis)
|
|||
|
|
Movement and resolution are done one axis at a time (move X → resolve X → move
|
|||
|
|
Y → resolve Y). Resolution chooses the side by **least penetration**, not by
|
|||
|
|
velocity sign, so clipping a block's corner never warps the player across it.
|
|||
|
|
|
|||
|
|
- **X pass**: for each overlapping solid, compute horizontal penetration from
|
|||
|
|
each side and the vertical penetration. Only resolve horizontally if the
|
|||
|
|
horizontal overlap is the *smaller* one (otherwise it's really a vertical
|
|||
|
|
collision — leave it for the Y pass, so a block on top can't be dodged by
|
|||
|
|
squirting out sideways). Push out the nearer horizontal edge; zero `vx`.
|
|||
|
|
- **Y pass**: for each overlapping solid, resolve toward the nearer of top/
|
|||
|
|
bottom edge. Landing on a top sets `on_ground`; zero `vy`.
|
|||
|
|
|
|||
|
|
### 5.5 One-way platforms
|
|||
|
|
Separate from solids. During the Y pass, when falling (`vy >= 0`) and not in a
|
|||
|
|
drop-through, the player lands on a one-way **only if its feet just crossed the
|
|||
|
|
platform's top edge this frame** (penetration between 0 and roughly the
|
|||
|
|
per-frame fall distance). Jumping up through them is unobstructed. Holding down
|
|||
|
|
+ pressing jump sets a short drop-through timer (~0.12 s) that suppresses
|
|||
|
|
one-way landing so the player falls through.
|
|||
|
|
|
|||
|
|
### 5.6 Crush death
|
|||
|
|
A crush is a moving block pinning the player against a solid on the opposite
|
|||
|
|
side. Detection probes a thin strip on each side of the player for a backing
|
|||
|
|
solid, checks whether the player is *pinned* (still overlapping a solid after
|
|||
|
|
resolution), and whether a moving carrier is closing in on the appropriate side:
|
|||
|
|
- Moving down onto a floored player (backed below + pinned) → crush.
|
|||
|
|
- Moving up into a player under a ceiling (backed above + pinned) → crush.
|
|||
|
|
- Moving horizontally into a player backed by a wall → crush.
|
|||
|
|
- Being carried sideways into a wall (via the `carry` direction) → crush.
|
|||
|
|
A block that stops with the player fitting underneath does **not** crush
|
|||
|
|
(no residual overlap → not pinned).
|
|||
|
|
|
|||
|
|
### 5.7 Death conditions (in `playing`)
|
|||
|
|
- Battery ≤ 0.
|
|||
|
|
- Player fell out of the world (top below the level height + 160 px).
|
|||
|
|
- `crushed` is true.
|
|||
|
|
- Player rect overlaps any hazard rect.
|
|||
|
|
Reaching the goal rect wins the level.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. Battery / timer
|
|||
|
|
|
|||
|
|
- Each level has a time limit `battery_seconds` (default 45). It counts down
|
|||
|
|
only while `playing`; hitting zero is a death.
|
|||
|
|
- The battery **bar** is visual-only and reads near-empty from the start to
|
|||
|
|
match the dying-phone theme: it fills to `battery_pct` percent (default 12)
|
|||
|
|
at full time and drains proportionally to zero. The exact seconds remaining
|
|||
|
|
are always shown as text next to the bar, so the bar can be a thin "almost
|
|||
|
|
dead" sliver without losing information.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. Death, respawn, counters
|
|||
|
|
|
|||
|
|
- On death: increment a **per-level** counter and a **session-total** counter,
|
|||
|
|
snapshot the player position as a corpse, call each trap's
|
|||
|
|
`finalize_on_death()`, and enter `dying` (scene frozen, traps *not yet* reset —
|
|||
|
|
they linger for the pause). A red tint fades over the pause.
|
|||
|
|
- After `DEATH_PAUSE` seconds: respawn the player at the level spawn, reset all
|
|||
|
|
traps, refill the battery, return to `playing`.
|
|||
|
|
- The HUD shows `deaths <level> / <total> total`. The per-level count resets
|
|||
|
|
when a level loads; the session total persists across levels and resets only
|
|||
|
|
on replay after clearing everything.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. Level-complete flow
|
|||
|
|
|
|||
|
|
On reaching the goal:
|
|||
|
|
1. **charging** state (scene frozen). The battery widget detaches from the HUD,
|
|||
|
|
flies to screen-center while growing (~2.7×, ease-out over the first ~40% of
|
|||
|
|
the beat), the background dims, and the battery fills from its near-empty
|
|||
|
|
finish level up to 100% (a live percentage is shown). Lasts `CHARGE_TIME`.
|
|||
|
|
2. **won_level** / **won_all** splash: the enlarged, full battery stays centered
|
|||
|
|
with a "phone charged!" title above and death counts below.
|
|||
|
|
3. On ENTER: a **fade** transition — snapshot the splash frame, dip it to black
|
|||
|
|
over `FADE_TIME` (0.3 s), swap the level at full black, then fade the new
|
|||
|
|
level in over `FADE_TIME`. `won_all` → ENTER replays from level 1 with a
|
|||
|
|
fresh session total.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 9. Assets
|
|||
|
|
|
|||
|
|
- `AssetStore.get(name, w, h, angle=0)` returns a surface of exactly `(w, h)`
|
|||
|
|
for a sprite name.
|
|||
|
|
- If `assets/<name>.png` exists, it is loaded, optionally **rotated**
|
|||
|
|
counter-clockwise by `angle` degrees, then **nearest-neighbour scaled** to
|
|||
|
|
`(w, h)` (keeps pixel art crisp). Results are cached by `(name, w, h, angle)`.
|
|||
|
|
- Otherwise a placeholder is drawn: a colored rect (per-sprite color + optional
|
|||
|
|
label) with a dark border. Placeholders are not rotated.
|
|||
|
|
- Sprite names used by the engine: `player`, `player_dead`, `block`, `goal`,
|
|||
|
|
`fake_block`, `spike`, `moving_block`, `patrol_block`, `crumble_block`,
|
|||
|
|
`arrow_shooter`, `arrow`, `spike_block`, `phase_block`.
|
|||
|
|
- Directional sprites (spikes) are rotated by direction: up=0°, left=90°,
|
|||
|
|
down=180°, right=−90°.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 10. Level file format (YAML)
|
|||
|
|
|
|||
|
|
A level is one YAML document.
|
|||
|
|
|
|||
|
|
### 10.1 Top-level keys
|
|||
|
|
| Key | Default | Meaning |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `name` | filename | display name |
|
|||
|
|
| `tile_size` | 32 | pixels per tile |
|
|||
|
|
| `battery_seconds` | 45 | time limit |
|
|||
|
|
| `battery_pct` | 12 | how full the battery bar *looks* at start (visual only) |
|
|||
|
|
| `debug` | false | reveal everything normally hidden (see §14) |
|
|||
|
|
| `map` | "" | ASCII map (a YAML block scalar) |
|
|||
|
|
| `traps` | [] | list of trap specs (see §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.
|