Files
battery/README.md

243 lines
11 KiB
Markdown
Raw Normal View History

2026-07-21 19:36:02 -04:00
# 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) |
| `{ dir: …, inclusive: true }` | …also count the trap's own tile as being on that side (default `false`: the player must be strictly past the near edge) |
2026-07-21 19:36:02 -04:00
| `{ 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 driven sideways into a wall by a block hitting you from the side.
Riding a platform horizontally into a wall is *not* a crush, though: you simply
stop at the wall while the platform slides on under your feet. During the death
pause the whole scene freezes, so the offending block stops on the spot until
you respawn.
2026-07-21 19:36:02 -04:00
### 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
```