239 lines
11 KiB
Markdown
239 lines
11 KiB
Markdown
# 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
|
|
```
|