Initial commit
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__
|
||||
238
README.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Dying Phone
|
||||
|
||||
A single-screen 2D platformer in the spirit of the original *Super Mario Bros.*,
|
||||
built with Python + pygame. Your phone is dying; sprint to the charger at the
|
||||
end of each level before the battery hits zero. The catch: the level is packed
|
||||
with obvious and (mostly) hidden traps. Touch one and you respawn instantly at
|
||||
the start with every trap reset. The battery is just the story's excuse for a
|
||||
timer — running it down kills you the same as any spike.
|
||||
|
||||
## Install & run
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python main.py # play every level in ./levels
|
||||
python main.py levels/level1.yaml # play a specific level (or several)
|
||||
python main.py --debug # force the debug view on for all levels
|
||||
```
|
||||
|
||||
No art required — everything renders as labeled colored rectangles until you
|
||||
drop PNGs into `assets/` (see `assets/README.md`).
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
The suite runs headless (SDL dummy driver) and covers the physics, collision
|
||||
resolution, every trap and trigger, mounting, crush/shove interactions, and the
|
||||
game-flow states. See `SPEC.md` for a full description of the engine and level
|
||||
format.
|
||||
|
||||
## Controls
|
||||
|
||||
| Key | Action |
|
||||
|--------------------|-------------------------------------------|
|
||||
| `A` / `D` (arrows) | move left / right |
|
||||
| `Space` / `W` | jump (hold = higher, tap = short hop) |
|
||||
| `S` + `Space` | drop through one-way platforms |
|
||||
| `R` | give up and respawn |
|
||||
| `F5` | hot-reload the level from disk (counts as a death; also escapes soft-locks) |
|
||||
| `Esc` | quit |
|
||||
| `Enter` | advance on the level-complete screen |
|
||||
|
||||
## Writing levels
|
||||
|
||||
Levels are plain YAML. A level has an ASCII `map` for static geometry and a
|
||||
`traps` list for the nasty interactive bits.
|
||||
|
||||
```yaml
|
||||
name: "My Level"
|
||||
tile_size: 32 # pixels per tile (optional; default 32)
|
||||
battery_seconds: 40 # time limit (optional)
|
||||
|
||||
map: |
|
||||
.........
|
||||
...###...
|
||||
.P.....G.
|
||||
#########
|
||||
|
||||
traps:
|
||||
- type: spike
|
||||
at: [4, 2] # [col, row], 0-based from top-left
|
||||
direction: up
|
||||
trigger: { within: 2.0 }
|
||||
```
|
||||
|
||||
### Map legend
|
||||
|
||||
| Char | Meaning |
|
||||
|------|-------------------------------------------|
|
||||
| `#` | solid block |
|
||||
| `-` | one-way platform — stand on top, jump/drop through. Renders **identically to a solid block**, so it's a hidden mechanic. |
|
||||
| `P` | player spawn |
|
||||
| `G` | goal / phone charger |
|
||||
| `.` or space | empty |
|
||||
|
||||
The window is sized to the map automatically — one screen, no scrolling.
|
||||
|
||||
Level options (all optional, alongside `name` / `map` / `traps`):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `tile_size` | 32 | pixels per tile |
|
||||
| `battery_seconds` | 45 | time limit |
|
||||
| `battery_pct` | 12 | how full the battery bar *looks* at the start (visual only) |
|
||||
| `debug` | false | reveal everything normally hidden — a tile grid with col/row labels, faded `-` one-way platforms, tinted fake blocks / invisible walls / warps (with a line to their destination), dormant phase blocks, not-yet-sprung spikes, crumbled-block ghosts, and moving block paths. The `--debug` CLI flag forces this on for every level. Combine with `F5` to iterate on a level without relaunching. |
|
||||
|
||||
## Trap reference
|
||||
|
||||
All traps take `at: [col, row]`. Where a trap draws its own block (fake, moving,
|
||||
patrol, crumble, shooter), leave that map cell **empty** — otherwise a real
|
||||
solid sits underneath it.
|
||||
|
||||
Any trap can also take:
|
||||
- **`invisible: true`** — stays fully functional but isn't drawn (revealed under
|
||||
the level's `debug` view). An invisible solid `block` is a wall you can't see.
|
||||
- **`count: [nx, ny]`** (or a single int for a horizontal line) — place a
|
||||
line/grid of copies, offset by **`spacing: [sx, sy]`** tiles (default 1). Only
|
||||
`at` is shifted per copy, so `move` is relative and works; an absolute `path`
|
||||
is shared. Handy for a row of spikes or a rectangle of (invisible) blocks.
|
||||
|
||||
| `type` | Key parameters | Behavior |
|
||||
|-----------------|----------------|----------|
|
||||
| `spike` | `direction` (`up`/`down`/`left`/`right`), `trigger`, `delay` | Deadly spike; active while its `trigger` holds. The sprite auto-rotates to point the way `direction` faces. |
|
||||
| `block` | see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, and/or crumbling. |
|
||||
| `arrow_shooter` | `direction`, `speed`, `interval`, `trigger`, `delay` | A wall turret firing deadly arrows every `interval` seconds while its `trigger` holds. |
|
||||
| `warp` | `to` [col,row] | Invisible tile that teleports the player to `to` on contact. Re-arms once you leave it. |
|
||||
| `phase_block` | `trigger`, `fade` | Invisible and intangible until its `trigger` fires, then fades into a solid over `fade` seconds (and fades back out when the trigger releases). If you're standing in the cell the instant it *starts* appearing, you die — and if you die while one is mid-fade it snaps fully visible for the death freeze. |
|
||||
|
||||
### The `block` trap
|
||||
|
||||
One trap covers stationary blocks, sliders, patrolling platforms, spike blocks,
|
||||
fake blocks, and crumbling blocks — compose the behaviour from options (which
|
||||
combine, e.g. a moving platform that crumbles):
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `path` [[col,row],…] | waypoints it travels between (default just `[at]` = stationary) |
|
||||
| `move` [dcol,drow] | shorthand for a 2-point path `[at, at+move]` (a slider) |
|
||||
| `mode` | `once` (default): advance to the last point while triggered, retreat to the first when not — the slider/dropper. `loop` / `pingpong`: cycle the whole path continuously (a patrol). |
|
||||
| `trigger` | when it moves (default `always`). A patrol is just a path + the default `always` trigger. |
|
||||
| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid |
|
||||
| `fake` (bool) | looks solid but you fall straight through it |
|
||||
| `crumble` (bool) + `crumble_delay`, `respawn` | gives way `crumble_delay` s after you stand on it, vanishes, then reappears after `respawn` s (killing you if you're standing where it re-forms) |
|
||||
| `speed` | px/s |
|
||||
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) |
|
||||
| `delay` / `release` | (`once`) hysteresis: the trigger must hold `delay` s to start extending and be clear `release` s (default 0.1) to start retracting — stops boundary jitter |
|
||||
| `sense` | `home` (default): a slider senses its trigger from its resting cell, so moving away can't toggle its own trigger. `current`: senses from the live position — for a block you *ride* (e.g. a dropper) so it stays put while ridden instead of pulling back |
|
||||
|
||||
```yaml
|
||||
- type: block # stationary spike block
|
||||
at: [13, 4]
|
||||
deadly: true
|
||||
|
||||
- type: block # dropper: extends down while you're above it, rides with you
|
||||
at: [21, 9]
|
||||
move: [0, 4]
|
||||
sense: current
|
||||
trigger: { all: [ {within: 5}, {dir: above, aligned: true} ] }
|
||||
|
||||
- type: block # patrolling platform
|
||||
at: [9, 10]
|
||||
path: [[9, 10], [14, 10], [14, 6]]
|
||||
mode: loop
|
||||
sprite: patrol_block
|
||||
```
|
||||
|
||||
A `once` block runs a *committed stroke*: once it starts moving it runs all the
|
||||
way to the endpoint (never reversing mid-stroke) and only reconsiders its
|
||||
trigger while parked — so a slider that moves out of its own sensor range can't
|
||||
buzz. Blocks track their live position, so a moving block can shove or crush
|
||||
you, and you can ride a non-deadly one.
|
||||
|
||||
### Triggers
|
||||
|
||||
`spike`, `block`, and `arrow_shooter` are active while a **trigger
|
||||
condition** holds. A trigger is `always` (the default) or a condition object
|
||||
measured against the player each frame, relative to the trap's *current*
|
||||
position (so a moving trap's sensors follow it):
|
||||
|
||||
| condition | meaning |
|
||||
|---|---|
|
||||
| `{ within: N }` | player within N tiles (radius) |
|
||||
| `{ dir: left\|right\|above\|below }` | player is on that side |
|
||||
| `{ dir: …, range: N }` | …and within N tiles that way |
|
||||
| `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (i.e. *directly* left / *directly* above) |
|
||||
| `{ timer: { interval: A, up_time: B } }` | cyclic: off A seconds, on B seconds |
|
||||
| `{ all: [ … ] }` | every listed condition (AND) |
|
||||
| `{ any: [ … ] }` | any listed condition (OR) |
|
||||
|
||||
```yaml
|
||||
# drops only when you're close AND standing over it; keeps dropping as you ride it
|
||||
trigger:
|
||||
all:
|
||||
- { within: 5 }
|
||||
- { dir: above, aligned: true }
|
||||
```
|
||||
|
||||
**`delay`** (seconds, default `0`): the trigger condition must hold
|
||||
*continuously* this long before the trap arms; leaving the condition resets the
|
||||
countdown. Handy for "linger and it strikes" spikes.
|
||||
|
||||
### Getting crushed
|
||||
|
||||
You also die if a moving (non-deadly) `block` **pinches** you: presses you
|
||||
against a solid surface — squished into the ground from above, carried up into a
|
||||
ceiling, or ridden sideways into a wall. During the death pause the whole scene
|
||||
freezes, so the offending block stops on the spot until you respawn.
|
||||
|
||||
### Mounting traps on other traps
|
||||
|
||||
Any trap can carry a `mounts:` list of child traps that ride along with it. A
|
||||
mounted trap's `at` is read as a **relative** offset (in tiles) from its parent,
|
||||
and it tracks the parent's live position every frame — so you can bolt a spike
|
||||
onto a patrolling platform, or a turret onto a moving block:
|
||||
|
||||
```yaml
|
||||
- type: block
|
||||
path: [[6, 10], [9, 10]]
|
||||
mode: pingpong
|
||||
sprite: patrol_block
|
||||
mounts:
|
||||
- type: spike
|
||||
at: [0, -1] # one tile above the block; moves with it
|
||||
trigger: always
|
||||
direction: up
|
||||
```
|
||||
|
||||
Everything the mounted trap does — hazards, solids, trigger sensing, drawing —
|
||||
follows the parent automatically; no special-casing per trap type. You can mount
|
||||
a `block` too (e.g. deadly blocks at the ends of a moving platform for spikes on
|
||||
a moving hazard) — a mounted trap **rides rigidly** at its offset and doesn't
|
||||
run its own motion, so mount stationary pieces (spikes, blocks, turrets) rather
|
||||
than something you expect to move on its own.
|
||||
|
||||
### Adding a new trap type
|
||||
|
||||
Subclass `Trap` in `game/traps.py`, implement any of the hooks
|
||||
(`solid_rects`, `oneway_rects`, `hazard_rects`, `carriers`, `update`, `draw`,
|
||||
`reset`), and register the class in `TRAP_TYPES`. The engine polls those hooks
|
||||
every frame — nothing else needs to change.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
main.py entry point
|
||||
game/
|
||||
game.py loop, states, HUD, death/respawn/win flow
|
||||
level.py YAML -> geometry + traps
|
||||
player.py movement & AABB collision
|
||||
traps.py trap base class + all trap types + factory
|
||||
assets.py PNG loading with colored-rect placeholders
|
||||
settings.py physics/gameplay tunables
|
||||
levels/*.yaml level definitions
|
||||
assets/*.png optional sprites
|
||||
```
|
||||
439
SPEC.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Dying Phone — Engine & Level-Format Specification
|
||||
|
||||
This document describes the game engine and the level configuration format in
|
||||
enough detail to reimplement equivalent functionality from scratch. It does
|
||||
**not** describe the specific shipped levels — only the mechanics and the data
|
||||
format levels are written in.
|
||||
|
||||
---
|
||||
|
||||
## 1. Concept
|
||||
|
||||
A single-screen 2D platformer. Your phone is dying; each level's goal is to
|
||||
reach the charger before the battery runs out. Levels are dense with obvious
|
||||
and (mostly) hidden traps. Touching a hazard, falling out of the world, or
|
||||
running the battery to zero kills you and instantly respawns you at the start
|
||||
with every trap reset. There is no scrolling — each level fits one screen.
|
||||
|
||||
Built with Python + pygame. Art is optional: any missing sprite is drawn as a
|
||||
labeled colored rectangle, so the game is fully playable with zero assets.
|
||||
|
||||
---
|
||||
|
||||
## 2. Runtime, entry point, CLI
|
||||
|
||||
- Dependencies: `pygame` (>= 2.5), `PyYAML`.
|
||||
- Entry point `main.py`:
|
||||
- `python main.py` — play every `*.yaml`/`*.yml` in `./levels`, sorted.
|
||||
- `python main.py a.yaml b.yaml …` — play the given level files in order.
|
||||
- `--debug` flag (anywhere in args) — force the debug view on for all levels.
|
||||
- The game plays levels in sequence; finishing the last shows a win screen, and
|
||||
ENTER restarts from the first level.
|
||||
|
||||
---
|
||||
|
||||
## 3. Coordinates, tiles, window
|
||||
|
||||
- The world is a grid of square tiles. Tile size defaults to **32 px** and can
|
||||
be overridden per level (`tile_size`).
|
||||
- Grid coordinates are `[col, row]`, 0-based from the top-left of the map. A
|
||||
cell's pixel rect is `(col*tile, row*tile, tile, tile)`.
|
||||
- Y increases downward (screen convention).
|
||||
- The window is sized **once** at startup to fit the tallest and widest level
|
||||
(plus a HUD band of 46 px on top; minimum window width 480 px). It is never
|
||||
resized afterward — recreating the display mid-session makes the OS window
|
||||
flicker/close. Each level's play area is a surface of its own pixel size,
|
||||
centered within the fixed window below the HUD; smaller levels are
|
||||
letterboxed with a dark background.
|
||||
|
||||
---
|
||||
|
||||
## 4. Game loop & states
|
||||
|
||||
Fixed logical timestep driven by `pygame.time.Clock` at 60 FPS. Each frame `dt`
|
||||
is clamped to a max of `1/30` s to avoid tunneling on lag spikes.
|
||||
|
||||
Per frame: poll input → run the current state's update → draw.
|
||||
|
||||
States:
|
||||
|
||||
- **playing** — normal simulation (battery drains, level & player update, death
|
||||
and goal checks run).
|
||||
- **dying** — death pause: the whole scene is frozen (no level/player update);
|
||||
a timer counts down `DEATH_PAUSE` (0.5 s) showing the corpse, then respawns.
|
||||
- **charging** — level-clear beat: everything frozen while the battery-fill
|
||||
animation plays for `CHARGE_TIME` (1.0 s); then transitions to a win splash.
|
||||
- **won_level** / **won_all** — win splash overlays (per-level vs final).
|
||||
- **fading** — a fade-to-black transition between levels (see §8).
|
||||
|
||||
Input is edge- and level-sensitive:
|
||||
- `A`/`D` or arrows → move; `S`/down → down (used for dropping through
|
||||
one-ways); `Space`/`W`/`Up` → jump (both an edge `jump_pressed` and a held
|
||||
`jump_held`).
|
||||
- `R` → give up (triggers a death). `Esc` → quit. `Enter` → advance/replay on a
|
||||
win splash.
|
||||
- `F5` → hot-reload the current level file from disk. It is treated as a death:
|
||||
it plays the death beat and increments both counters (so it also escapes
|
||||
soft-locks), and on the following respawn the level is re-read from its file
|
||||
(picking up any edits) instead of merely reset. A malformed edit is caught and
|
||||
logged rather than crashing (the old level is kept). The per-level death count
|
||||
carries across the reload.
|
||||
|
||||
---
|
||||
|
||||
## 5. Player physics
|
||||
|
||||
The player is an axis-aligned rectangle sized `0.72*tile` wide × `0.92*tile`
|
||||
tall. Position is tracked as floats (`fx, fy`); the integer collision rect is
|
||||
`round`ed from them each substep.
|
||||
|
||||
### 5.1 Tunables (pixels, seconds)
|
||||
| Constant | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `GRAVITY` | 2200 | downward acceleration (px/s²) |
|
||||
| `MAX_FALL` | 1400 | terminal downward velocity |
|
||||
| `MOVE_SPEED` | 320 | horizontal run speed |
|
||||
| `ACCEL` | 3200 | ground acceleration toward target speed |
|
||||
| `AIR_ACCEL` | 2200 | weaker air control |
|
||||
| `FRICTION` | 3600 | ground deceleration when no input |
|
||||
| `JUMP_SPEED` | 760 | initial upward velocity of a jump |
|
||||
| `JUMP_CUT` | 0.45 | fraction of upward velocity kept if jump released early |
|
||||
| `COYOTE_TIME` | 0.10 | grace window to jump after leaving a ledge |
|
||||
| `JUMP_BUFFER` | 0.10 | how long a jump press is remembered before landing |
|
||||
|
||||
### 5.2 Update order (per frame)
|
||||
1. **Ride platforms** — if standing on a moving platform (feet within 3 px of a
|
||||
carrier's top, with horizontal overlap), add that carrier's per-frame motion
|
||||
`(dx, dy)` to the player and record it as `carry`.
|
||||
2. **Push by movers** — if a block moving horizontally overlaps the player's
|
||||
side (with vertical overlap ≥ half the player height), displace the player
|
||||
out the *nearer* horizontal edge so the block shoves them along that axis
|
||||
(rather than the vertical pass burying them).
|
||||
3. **Horizontal** — accelerate `vx` toward the input target (or apply friction),
|
||||
integrate `fx`, then resolve X collisions.
|
||||
4. **Vertical** — update jump timers, apply jump/gravity, integrate `fy`, then
|
||||
resolve Y collisions.
|
||||
5. **Crush check** — set `crushed` (see §5.6).
|
||||
|
||||
### 5.3 Jumping
|
||||
- **Buffered**: a jump press within `JUMP_BUFFER` before landing still fires on
|
||||
landing.
|
||||
- **Coyote**: a jump within `COYOTE_TIME` after walking off a ledge still fires.
|
||||
- **Variable height**: releasing jump mid-rise multiplies remaining upward
|
||||
velocity by `JUMP_CUT` once (tap = short hop, hold = full jump).
|
||||
|
||||
### 5.4 Collision resolution (minimum-translation, per axis)
|
||||
Movement and resolution are done one axis at a time (move X → resolve X → move
|
||||
Y → resolve Y). Resolution chooses the side by **least penetration**, not by
|
||||
velocity sign, so clipping a block's corner never warps the player across it.
|
||||
|
||||
- **X pass**: for each overlapping solid, compute horizontal penetration from
|
||||
each side and the vertical penetration. Only resolve horizontally if the
|
||||
horizontal overlap is the *smaller* one (otherwise it's really a vertical
|
||||
collision — leave it for the Y pass, so a block on top can't be dodged by
|
||||
squirting out sideways). Push out the nearer horizontal edge; zero `vx`.
|
||||
- **Y pass**: for each overlapping solid, resolve toward the nearer of top/
|
||||
bottom edge. Landing on a top sets `on_ground`; zero `vy`.
|
||||
|
||||
### 5.5 One-way platforms
|
||||
Separate from solids. During the Y pass, when falling (`vy >= 0`) and not in a
|
||||
drop-through, the player lands on a one-way **only if its feet just crossed the
|
||||
platform's top edge this frame** (penetration between 0 and roughly the
|
||||
per-frame fall distance). Jumping up through them is unobstructed. Holding down
|
||||
+ pressing jump sets a short drop-through timer (~0.12 s) that suppresses
|
||||
one-way landing so the player falls through.
|
||||
|
||||
### 5.6 Crush death
|
||||
A crush is a moving block pinning the player against a solid on the opposite
|
||||
side. Detection probes a thin strip on each side of the player for a backing
|
||||
solid, checks whether the player is *pinned* (still overlapping a solid after
|
||||
resolution), and whether a moving carrier is closing in on the appropriate side:
|
||||
- Moving down onto a floored player (backed below + pinned) → crush.
|
||||
- Moving up into a player under a ceiling (backed above + pinned) → crush.
|
||||
- Moving horizontally into a player backed by a wall → crush.
|
||||
- Being carried sideways into a wall (via the `carry` direction) → crush.
|
||||
A block that stops with the player fitting underneath does **not** crush
|
||||
(no residual overlap → not pinned).
|
||||
|
||||
### 5.7 Death conditions (in `playing`)
|
||||
- Battery ≤ 0.
|
||||
- Player fell out of the world (top below the level height + 160 px).
|
||||
- `crushed` is true.
|
||||
- Player rect overlaps any hazard rect.
|
||||
Reaching the goal rect wins the level.
|
||||
|
||||
---
|
||||
|
||||
## 6. Battery / timer
|
||||
|
||||
- Each level has a time limit `battery_seconds` (default 45). It counts down
|
||||
only while `playing`; hitting zero is a death.
|
||||
- The battery **bar** is visual-only and reads near-empty from the start to
|
||||
match the dying-phone theme: it fills to `battery_pct` percent (default 12)
|
||||
at full time and drains proportionally to zero. The exact seconds remaining
|
||||
are always shown as text next to the bar, so the bar can be a thin "almost
|
||||
dead" sliver without losing information.
|
||||
|
||||
---
|
||||
|
||||
## 7. Death, respawn, counters
|
||||
|
||||
- On death: increment a **per-level** counter and a **session-total** counter,
|
||||
snapshot the player position as a corpse, call each trap's
|
||||
`finalize_on_death()`, and enter `dying` (scene frozen, traps *not yet* reset —
|
||||
they linger for the pause). A red tint fades over the pause.
|
||||
- After `DEATH_PAUSE` seconds: respawn the player at the level spawn, reset all
|
||||
traps, refill the battery, return to `playing`.
|
||||
- The HUD shows `deaths <level> / <total> total`. The per-level count resets
|
||||
when a level loads; the session total persists across levels and resets only
|
||||
on replay after clearing everything.
|
||||
|
||||
---
|
||||
|
||||
## 8. Level-complete flow
|
||||
|
||||
On reaching the goal:
|
||||
1. **charging** state (scene frozen). The battery widget detaches from the HUD,
|
||||
flies to screen-center while growing (~2.7×, ease-out over the first ~40% of
|
||||
the beat), the background dims, and the battery fills from its near-empty
|
||||
finish level up to 100% (a live percentage is shown). Lasts `CHARGE_TIME`.
|
||||
2. **won_level** / **won_all** splash: the enlarged, full battery stays centered
|
||||
with a "phone charged!" title above and death counts below.
|
||||
3. On ENTER: a **fade** transition — snapshot the splash frame, dip it to black
|
||||
over `FADE_TIME` (0.3 s), swap the level at full black, then fade the new
|
||||
level in over `FADE_TIME`. `won_all` → ENTER replays from level 1 with a
|
||||
fresh session total.
|
||||
|
||||
---
|
||||
|
||||
## 9. Assets
|
||||
|
||||
- `AssetStore.get(name, w, h, angle=0)` returns a surface of exactly `(w, h)`
|
||||
for a sprite name.
|
||||
- If `assets/<name>.png` exists, it is loaded, optionally **rotated**
|
||||
counter-clockwise by `angle` degrees, then **nearest-neighbour scaled** to
|
||||
`(w, h)` (keeps pixel art crisp). Results are cached by `(name, w, h, angle)`.
|
||||
- Otherwise a placeholder is drawn: a colored rect (per-sprite color + optional
|
||||
label) with a dark border. Placeholders are not rotated.
|
||||
- Sprite names used by the engine: `player`, `player_dead`, `block`, `goal`,
|
||||
`fake_block`, `spike`, `moving_block`, `patrol_block`, `crumble_block`,
|
||||
`arrow_shooter`, `arrow`, `spike_block`, `phase_block`.
|
||||
- Directional sprites (spikes) are rotated by direction: up=0°, left=90°,
|
||||
down=180°, right=−90°.
|
||||
|
||||
---
|
||||
|
||||
## 10. Level file format (YAML)
|
||||
|
||||
A level is one YAML document.
|
||||
|
||||
### 10.1 Top-level keys
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `name` | filename | display name |
|
||||
| `tile_size` | 32 | pixels per tile |
|
||||
| `battery_seconds` | 45 | time limit |
|
||||
| `battery_pct` | 12 | how full the battery bar *looks* at start (visual only) |
|
||||
| `debug` | false | reveal everything normally hidden (see §14) |
|
||||
| `map` | "" | ASCII map (a YAML block scalar) |
|
||||
| `traps` | [] | list of trap specs (see §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.
|
||||
29
assets/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Sprites
|
||||
|
||||
Drop PNG files here to replace the colored-rectangle placeholders. Files are
|
||||
matched by name and scaled to the tile/entity size automatically — no code
|
||||
changes needed. Transparency (RGBA) is respected.
|
||||
|
||||
Recognized filenames:
|
||||
|
||||
| File | Used for |
|
||||
|----------------------|--------------------------------------------|
|
||||
| `player.png` | the player character |
|
||||
| `block.png` | solid blocks and one-way platform ledges |
|
||||
| `goal.png` | the phone charger (level exit) |
|
||||
| `fake_block.png` | fall-through blocks (make it match block!) |
|
||||
| `spike.png` | spikes / deadly patrol blocks |
|
||||
| `moving_block.png` | proximity-triggered sliding blocks |
|
||||
| `patrol_block.png` | perpetual moving platforms |
|
||||
| `crumble_block.png` | blocks that crumble when stood on |
|
||||
| `arrow_shooter.png` | wall turrets |
|
||||
| `arrow.png` | fired arrows |
|
||||
|
||||
Any missing file falls back to a labeled colored rectangle, so the game is
|
||||
fully playable with no art at all.
|
||||
|
||||
The PNGs currently here are simple defaults generated by `tools/gen_sprites.py`
|
||||
(pixel art drawn with pygame). Re-run `python tools/gen_sprites.py` to
|
||||
regenerate them, tweak the `draw_*` functions in that script, or just overwrite
|
||||
any PNG with your own art. Sprites are scaled nearest-neighbour, so pixel art
|
||||
stays crisp at any size.
|
||||
BIN
assets/arrow.png
Normal file
|
After Width: | Height: | Size: 245 B |
BIN
assets/arrow_shooter.png
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
assets/block.png
Normal file
|
After Width: | Height: | Size: 174 B |
BIN
assets/crumble_block.png
Normal file
|
After Width: | Height: | Size: 303 B |
BIN
assets/fake_block.png
Normal file
|
After Width: | Height: | Size: 174 B |
BIN
assets/goal.png
Normal file
|
After Width: | Height: | Size: 351 B |
BIN
assets/moving_block.png
Normal file
|
After Width: | Height: | Size: 227 B |
BIN
assets/patrol_block.png
Normal file
|
After Width: | Height: | Size: 239 B |
BIN
assets/phase_block.png
Normal file
|
After Width: | Height: | Size: 319 B |
BIN
assets/player.png
Normal file
|
After Width: | Height: | Size: 256 B |
BIN
assets/player_dead.png
Normal file
|
After Width: | Height: | Size: 345 B |
BIN
assets/spike.png
Normal file
|
After Width: | Height: | Size: 197 B |
BIN
assets/spike_block.png
Normal file
|
After Width: | Height: | Size: 534 B |
1
game/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Dying Phone — a single-screen 2D platformer engine built on pygame."""
|
||||
72
game/assets.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Sprite loading with graceful placeholders.
|
||||
|
||||
Every drawable in the game asks the AssetStore for a surface by name. If a PNG
|
||||
named ``<name>.png`` exists in the assets directory it is loaded and scaled to
|
||||
the requested size; otherwise a labeled colored rectangle is drawn instead, so
|
||||
the game is fully playable before any art exists.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pygame
|
||||
|
||||
from . import settings
|
||||
|
||||
|
||||
class AssetStore:
|
||||
def __init__(self, assets_dir):
|
||||
self.assets_dir = assets_dir
|
||||
self._raw = {} # name -> original loaded Surface (or None if missing)
|
||||
self._cache = {} # (name, w, h) -> scaled Surface
|
||||
self._font = None
|
||||
|
||||
def _font_for(self, h):
|
||||
# Lazily build a font sized to the tile so placeholder labels fit.
|
||||
size = max(10, int(h * 0.5))
|
||||
return pygame.font.SysFont("consolas,menlo,monospace", size, bold=True)
|
||||
|
||||
def _load_raw(self, name):
|
||||
if name in self._raw:
|
||||
return self._raw[name]
|
||||
path = os.path.join(self.assets_dir, name + ".png")
|
||||
surf = None
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
surf = pygame.image.load(path).convert_alpha()
|
||||
except pygame.error:
|
||||
surf = None
|
||||
self._raw[name] = surf
|
||||
return surf
|
||||
|
||||
def get(self, name, w, h, angle=0):
|
||||
"""Return a Surface of exactly (w, h) for the given sprite name,
|
||||
optionally rotated counter-clockwise by ``angle`` degrees first (used to
|
||||
point directional sprites like spikes the right way)."""
|
||||
w, h = int(w), int(h)
|
||||
angle %= 360
|
||||
key = (name, w, h, angle)
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
raw = self._load_raw(name)
|
||||
if raw is not None:
|
||||
if angle:
|
||||
raw = pygame.transform.rotate(raw, angle)
|
||||
# Nearest-neighbour keeps the pixel-art sprites crisp at any size.
|
||||
surf = pygame.transform.scale(raw, (w, h))
|
||||
else:
|
||||
surf = self._make_placeholder(name, w, h)
|
||||
|
||||
self._cache[key] = surf
|
||||
return surf
|
||||
|
||||
def _make_placeholder(self, name, w, h):
|
||||
color, label = settings.PLACEHOLDERS.get(name, ((200, 60, 200), "?"))
|
||||
surf = pygame.Surface((w, h), pygame.SRCALPHA)
|
||||
surf.fill(color)
|
||||
# A subtle border helps distinguish adjacent tiles of the same color.
|
||||
pygame.draw.rect(surf, (0, 0, 0, 90), surf.get_rect(), max(1, w // 16))
|
||||
if label:
|
||||
font = self._font_for(h)
|
||||
text = font.render(label, True, (15, 15, 20))
|
||||
surf.blit(text, text.get_rect(center=(w // 2, h // 2)))
|
||||
return surf
|
||||
377
game/game.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""Top-level game loop, states, HUD, and the death/respawn/win flow."""
|
||||
|
||||
import os
|
||||
import glob
|
||||
import pygame
|
||||
|
||||
from . import settings as S
|
||||
from .assets import AssetStore
|
||||
from .level import Level
|
||||
from .player import Player, InputState
|
||||
|
||||
HUD_H = 46 # height of the status bar above the play area
|
||||
MIN_W = 480 # keep the window wide enough for the HUD text
|
||||
|
||||
|
||||
class Game:
|
||||
def __init__(self, level_paths, assets_dir, debug=False):
|
||||
pygame.init()
|
||||
pygame.display.set_caption(S.CAPTION)
|
||||
self.level_paths = level_paths
|
||||
self.force_debug = debug # --debug: turn debug view on for every level
|
||||
self.assets = AssetStore(assets_dir)
|
||||
self.clock = pygame.time.Clock()
|
||||
self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True)
|
||||
self.big_font = pygame.font.SysFont("consolas,menlo,monospace", 40, bold=True)
|
||||
|
||||
self.index = 0
|
||||
self.running = True
|
||||
self.state = "playing" # playing | dying | won_level | won_all
|
||||
self.death_timer = 0.0 # counts down during the "dying" pause
|
||||
self.death_rect = None # where to draw the corpse
|
||||
self.deaths = 0 # total deaths this session
|
||||
self.level_deaths = 0 # deaths on the current level (resets per level)
|
||||
self._pending_reload = False # F5: reload the level file on next respawn
|
||||
|
||||
# Size the window ONCE to fit the largest level, then never resize it —
|
||||
# calling set_mode again mid-session recreates the window (it flickers /
|
||||
# looks like it closes). Smaller levels are centred within it.
|
||||
dims = [(lv.width, lv.height) for lv in map(Level, self.level_paths)]
|
||||
self.win_w = max(max(w for w, _ in dims), MIN_W)
|
||||
self.win_h = max(h for _, h in dims) + HUD_H
|
||||
self.screen = pygame.display.set_mode((self.win_w, self.win_h))
|
||||
|
||||
self._load_current()
|
||||
|
||||
# --- level management ----------------------------------------------------
|
||||
def _load_current(self):
|
||||
self.level = Level(self.level_paths[self.index])
|
||||
if self.force_debug:
|
||||
self.level.debug = True # CLI flag overrides the per-level setting
|
||||
self.player = Player(self.level)
|
||||
self.battery = self.level.battery
|
||||
self.world = pygame.Surface((self.level.width, self.level.height))
|
||||
# Centre the play area in the fixed window, below the HUD.
|
||||
ox = (self.win_w - self.level.width) // 2
|
||||
oy = HUD_H + (self.win_h - HUD_H - self.level.height) // 2
|
||||
self.world_pos = (ox, oy)
|
||||
self.state = "playing"
|
||||
self.death_timer = 0.0
|
||||
self.death_rect = None
|
||||
self.level_deaths = 0 # fresh count for the level we just loaded
|
||||
|
||||
def _start_death(self):
|
||||
# Begin the death pause: freeze everything, leave the corpse on screen.
|
||||
# Traps are deliberately NOT reset yet — that happens on respawn.
|
||||
if self.state != "playing":
|
||||
return
|
||||
self.deaths += 1
|
||||
self.level_deaths += 1
|
||||
self.state = "dying"
|
||||
self.death_timer = S.DEATH_PAUSE
|
||||
self.death_rect = self.player.rect.copy()
|
||||
# Let traps settle into a final look before the scene freezes — e.g. a
|
||||
# phase block that was mid-materialise snaps to fully visible.
|
||||
for t in self.level.traps:
|
||||
t.finalize_all()
|
||||
|
||||
def _respawn(self):
|
||||
if self._pending_reload:
|
||||
# F5 hot-reload: re-read the level file from disk (picks up edits),
|
||||
# keeping the death the reload just counted.
|
||||
self._pending_reload = False
|
||||
kept = self.level_deaths
|
||||
try:
|
||||
self._load_current()
|
||||
self.level_deaths = kept
|
||||
except Exception as ex: # bad edit -> don't crash
|
||||
print(f"[reload] {self.level_paths[self.index]}: {ex}")
|
||||
self.player.respawn()
|
||||
self.level.reset()
|
||||
self.battery = self.level.battery
|
||||
else:
|
||||
self.player.respawn()
|
||||
self.level.reset()
|
||||
self.battery = self.level.battery # phone gets plugged back in at start
|
||||
self.state = "playing"
|
||||
|
||||
def _reload_level(self):
|
||||
# Hot-reload the current level from disk. Counts as a death — plays the
|
||||
# death beat and bumps the counters — so it also escapes soft-locks.
|
||||
if self.state != "playing":
|
||||
return
|
||||
self._pending_reload = True
|
||||
self._start_death()
|
||||
|
||||
def _reach_goal(self):
|
||||
# Freeze everything and play the battery-charging animation; the win
|
||||
# splash follows once it finishes.
|
||||
self.state = "charging"
|
||||
self.charge_timer = 0.0
|
||||
tf = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
|
||||
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
|
||||
self._final = self.index + 1 >= len(self.level_paths)
|
||||
|
||||
def _advance(self):
|
||||
self.index += 1
|
||||
self._load_current()
|
||||
|
||||
def _replay(self):
|
||||
# Fresh session after clearing everything.
|
||||
self.index = 0
|
||||
self.deaths = 0
|
||||
self._load_current()
|
||||
|
||||
def _start_fade(self, action):
|
||||
# Fade to black, run `action` (load a level) at full black, then fade in.
|
||||
# Snapshot the current frame (the splash) so the fade-out dips *it* to
|
||||
# black instead of flashing the underlying world.
|
||||
self.fade_snapshot = self.screen.copy()
|
||||
self.state = "fading"
|
||||
self.fade_timer = 0.0
|
||||
self.fade_phase = "out"
|
||||
self.fade_action = action
|
||||
|
||||
def _update_fade(self, dt):
|
||||
self.fade_timer += dt
|
||||
if self.fade_timer < S.FADE_TIME:
|
||||
return
|
||||
if self.fade_phase == "out":
|
||||
self.fade_action() # swap levels while the screen is black
|
||||
self.state = "fading" # _load_current() flips to "playing"; undo it
|
||||
self.fade_phase = "in"
|
||||
self.fade_timer = 0.0
|
||||
else:
|
||||
self.state = "playing"
|
||||
|
||||
# --- input ---------------------------------------------------------------
|
||||
def _poll_events(self):
|
||||
inp = InputState()
|
||||
for e in pygame.event.get():
|
||||
if e.type == pygame.QUIT:
|
||||
self.running = False
|
||||
elif e.type == pygame.KEYDOWN:
|
||||
if e.key == pygame.K_ESCAPE:
|
||||
self.running = False
|
||||
elif e.key in (pygame.K_SPACE, pygame.K_w, pygame.K_UP):
|
||||
inp.jump_pressed = True
|
||||
elif e.key == pygame.K_r:
|
||||
self._start_death() # manual give-up / restart
|
||||
elif e.key == pygame.K_F5:
|
||||
self._reload_level() # hot-reload from disk (counts as a death)
|
||||
elif self.state == "won_level" and e.key == pygame.K_RETURN:
|
||||
self._start_fade(self._advance)
|
||||
elif self.state == "won_all" and e.key == pygame.K_RETURN:
|
||||
self._start_fade(self._replay)
|
||||
|
||||
keys = pygame.key.get_pressed()
|
||||
inp.left = keys[pygame.K_a] or keys[pygame.K_LEFT]
|
||||
inp.right = keys[pygame.K_d] or keys[pygame.K_RIGHT]
|
||||
inp.down = keys[pygame.K_s] or keys[pygame.K_DOWN]
|
||||
inp.jump_held = keys[pygame.K_SPACE] or keys[pygame.K_w] or keys[pygame.K_UP]
|
||||
return inp
|
||||
|
||||
# --- main loop -----------------------------------------------------------
|
||||
def run(self):
|
||||
while self.running:
|
||||
dt = self.clock.tick(S.FPS) / 1000.0
|
||||
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
|
||||
inp = self._poll_events()
|
||||
|
||||
if self.state == "playing":
|
||||
self._update_play(dt, inp)
|
||||
elif self.state == "dying":
|
||||
# Scene is frozen; just count down, then reset and respawn.
|
||||
self.death_timer -= dt
|
||||
if self.death_timer <= 0:
|
||||
self._respawn()
|
||||
elif self.state == "charging":
|
||||
# Everything stops while the battery animates up to full.
|
||||
self.charge_timer += dt
|
||||
if self.charge_timer >= S.CHARGE_TIME:
|
||||
self.state = "won_all" if self._final else "won_level"
|
||||
elif self.state == "fading":
|
||||
self._update_fade(dt)
|
||||
|
||||
self._draw()
|
||||
pygame.quit()
|
||||
|
||||
def _update_play(self, dt, inp):
|
||||
self.battery -= dt
|
||||
self.level.update(dt, self)
|
||||
self.player.update(dt, inp)
|
||||
|
||||
# death conditions
|
||||
pr = self.player.rect
|
||||
died = self.battery <= 0
|
||||
died = died or pr.top > self.level.height + 160 # fell out of the world
|
||||
died = died or self.player.crushed # pinched by a moving block
|
||||
if not died:
|
||||
for hz in self.level.hazard_rects():
|
||||
if pr.colliderect(hz):
|
||||
died = True
|
||||
break
|
||||
if died:
|
||||
self._start_death()
|
||||
return
|
||||
|
||||
# reached the charger?
|
||||
if pr.colliderect(self.level.goal_rect):
|
||||
self._reach_goal()
|
||||
|
||||
# --- rendering -----------------------------------------------------------
|
||||
def _draw(self):
|
||||
# Fade-out: dip the frozen splash frame to black (level not swapped yet).
|
||||
if self.state == "fading" and self.fade_phase == "out":
|
||||
self.screen.blit(self.fade_snapshot, (0, 0))
|
||||
p = min(1.0, self.fade_timer / S.FADE_TIME)
|
||||
overlay = pygame.Surface(self.screen.get_size())
|
||||
overlay.fill((0, 0, 0))
|
||||
overlay.set_alpha(int(255 * p))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
pygame.display.flip()
|
||||
return
|
||||
|
||||
self.world.fill(S.COLOR_BG)
|
||||
self.level.draw(self.world, self.assets)
|
||||
if self.state == "dying" and self.death_rect is not None:
|
||||
# The traps stay drawn in their moment-of-death state; the player is
|
||||
# replaced by a corpse sprite where they fell.
|
||||
sprite = self.assets.get("player_dead", self.death_rect.w, self.death_rect.h)
|
||||
self.world.blit(sprite, self.death_rect)
|
||||
else:
|
||||
self.player.draw(self.world, self.assets)
|
||||
|
||||
self.screen.fill((12, 12, 18))
|
||||
self.screen.blit(self.world, self.world_pos)
|
||||
self._draw_hud(hide_battery=(self.state == "charging"))
|
||||
|
||||
if self.state == "dying":
|
||||
# A red tint that fades as the corpse lingers.
|
||||
alpha = int(140 * (self.death_timer / S.DEATH_PAUSE))
|
||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
||||
overlay.fill((200, 40, 40, alpha))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
|
||||
if self.state == "charging":
|
||||
self._draw_charge_anim()
|
||||
|
||||
if self.state == "won_level":
|
||||
plural = "death" if self.level_deaths == 1 else "deaths"
|
||||
self._draw_win_splash("LEVEL COMPLETE — phone charged!",
|
||||
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level")
|
||||
elif self.state == "won_all":
|
||||
self._draw_win_splash("YOU MADE IT! Phone fully charged.",
|
||||
f"All levels cleared with {self.deaths} deaths. ENTER to replay")
|
||||
|
||||
if self.state == "fading": # fade-in only; fade-out returned early above
|
||||
p = min(1.0, self.fade_timer / S.FADE_TIME)
|
||||
overlay = pygame.Surface(self.screen.get_size())
|
||||
overlay.fill((0, 0, 0))
|
||||
overlay.set_alpha(int(255 * (1 - p)))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
|
||||
pygame.display.flip()
|
||||
|
||||
@staticmethod
|
||||
def _draw_battery(surf, rect, fill_frac, col):
|
||||
"""Draw a battery (frame + fill + nub) into an arbitrary rect, so the
|
||||
same widget serves the HUD and the blown-up charging animation."""
|
||||
r = max(2, rect.h // 6)
|
||||
inset = max(2, rect.h // 10)
|
||||
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
|
||||
pygame.draw.rect(surf, (28, 30, 40),
|
||||
rect.inflate(-inset, -inset), border_radius=r)
|
||||
fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac)))
|
||||
if fill_frac > 0:
|
||||
fw = max(inset, fw)
|
||||
pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
|
||||
border_radius=r)
|
||||
nub_w, nub_h = max(3, rect.h // 4), rect.h // 2
|
||||
pygame.draw.rect(surf, (60, 60, 72),
|
||||
(rect.right, rect.centery - nub_h // 2, nub_w, nub_h))
|
||||
|
||||
def _draw_hud(self, hide_battery=False):
|
||||
w = self.screen.get_width()
|
||||
pygame.draw.rect(self.screen, (18, 20, 30), (0, 0, w, HUD_H))
|
||||
|
||||
# battery bar. The phone is dying, so the bar *looks* near-empty from the
|
||||
# start (scaled by the level's battery_pct); the seconds text below is the
|
||||
# real timer. Fill drains proportionally to the time left. Hidden while
|
||||
# the charge animation flies the battery to centre stage.
|
||||
bar_w, bar_h = 180, 20
|
||||
bx, by = 12, (HUD_H - bar_h) // 2
|
||||
if not hide_battery:
|
||||
time_frac = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
|
||||
visual = time_frac * (self.level.battery_pct / 100.0)
|
||||
col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80)
|
||||
self._draw_battery(self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col)
|
||||
|
||||
secs = max(0.0, self.battery)
|
||||
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
|
||||
self.screen.blit(label, (bx + bar_w + 16, by - 1))
|
||||
|
||||
deaths = self.hud_font.render(
|
||||
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD)
|
||||
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
|
||||
|
||||
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
|
||||
self.screen.blit(name, (w - deaths.get_width() - name.get_width() - 32, by - 1))
|
||||
|
||||
def _hero_battery(self):
|
||||
"""The enlarged battery's resting rect — centre of screen, ~60% wide.
|
||||
Shared by the charge animation (its target) and the win splash (so it
|
||||
stays put, full, while the splash is up)."""
|
||||
W, H = self.screen.get_size()
|
||||
bar_w, bar_h = 180, 20
|
||||
scale = min(W * 0.6, 520) / bar_w
|
||||
rect = pygame.Rect(0, 0, round(bar_w * scale), round(bar_h * scale))
|
||||
rect.center = (W // 2, H // 2)
|
||||
return rect
|
||||
|
||||
def _draw_charge_anim(self):
|
||||
"""The charging beat: dim the frozen scene, then fly the battery out of
|
||||
the HUD toward the centre and grow it (brought toward the viewer) while
|
||||
it fills to 100%."""
|
||||
W, H = self.screen.get_size()
|
||||
t = min(1.0, self.charge_timer / S.CHARGE_TIME)
|
||||
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
|
||||
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
|
||||
fill = self.charge_from + (1.0 - self.charge_from) * fill_p
|
||||
|
||||
dim = pygame.Surface((W, H), pygame.SRCALPHA)
|
||||
dim.fill((6, 8, 14, int(195 * move_p)))
|
||||
self.screen.blit(dim, (0, 0))
|
||||
|
||||
bar_w, bar_h = 180, 20
|
||||
home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2)
|
||||
target = self._hero_battery()
|
||||
pos = home.lerp(pygame.Vector2(target.center), move_p)
|
||||
rect = pygame.Rect(0, 0,
|
||||
round(bar_w + (target.w - bar_w) * move_p),
|
||||
round(bar_h + (target.h - bar_h) * move_p))
|
||||
rect.center = (round(pos.x), round(pos.y))
|
||||
self._draw_battery(self.screen, rect, fill, (90, 220, 120))
|
||||
|
||||
pct = int(round(fill * 100))
|
||||
num = self.big_font.render(f"{pct}%", True, (150, 245, 180))
|
||||
self.screen.blit(num, num.get_rect(center=(W // 2, rect.top - 36)))
|
||||
|
||||
def _draw_win_splash(self, title, subtitle):
|
||||
"""Level-complete overlay: keep the charged hero battery centred, with
|
||||
the message above and below it."""
|
||||
W, H = self.screen.get_size()
|
||||
dim = pygame.Surface((W, H), pygame.SRCALPHA)
|
||||
dim.fill((10, 12, 20, 205))
|
||||
self.screen.blit(dim, (0, 0))
|
||||
rect = self._hero_battery()
|
||||
self._draw_battery(self.screen, rect, 1.0, (90, 220, 120))
|
||||
t = self.big_font.render(title, True, (120, 240, 160))
|
||||
self.screen.blit(t, t.get_rect(center=(W // 2, rect.top - 40)))
|
||||
s = self.hud_font.render(subtitle, True, S.COLOR_HUD)
|
||||
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
|
||||
|
||||
def discover_levels(levels_dir):
|
||||
paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) +
|
||||
glob.glob(os.path.join(levels_dir, "*.yml")))
|
||||
return paths
|
||||
170
game/level.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Level loading: YAML -> geometry + traps.
|
||||
|
||||
A level file has an ASCII ``map`` for static geometry and a ``traps`` list for
|
||||
the interactive/nasty bits. Grid coordinates are ``[col, row]`` (0-based, from
|
||||
the top-left of the map).
|
||||
|
||||
Map legend:
|
||||
# solid block
|
||||
- one-way platform (stand on top, jump up through it)
|
||||
P player spawn
|
||||
G goal (the phone charger)
|
||||
. or space empty
|
||||
"""
|
||||
|
||||
import os
|
||||
import pygame
|
||||
import yaml
|
||||
|
||||
from . import settings
|
||||
from . import traps as traps_mod
|
||||
|
||||
|
||||
class Level:
|
||||
def __init__(self, path):
|
||||
with open(path, "r") as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
|
||||
self.path = path
|
||||
self.name = data.get("name", os.path.splitext(os.path.basename(path))[0])
|
||||
self.tile = int(data.get("tile_size", settings.TILE))
|
||||
self.battery = float(data.get("battery_seconds", settings.DEFAULT_BATTERY))
|
||||
# How full the battery bar *looks* at the start (percent). Visual only —
|
||||
# the actual time limit is battery_seconds.
|
||||
self.battery_pct = float(data.get("battery_pct", settings.DEFAULT_BATTERY_PCT))
|
||||
# Debug view: reveal everything normally hidden — one-way platforms,
|
||||
# invisible walls, warps, dormant phase blocks, fake blocks, and
|
||||
# not-yet-sprung spikes. Traps read this via ``self.level.debug``.
|
||||
self.debug = bool(data.get("debug", False))
|
||||
|
||||
rows = data.get("map", "").splitlines()
|
||||
# Strip a leading blank line from block-scalar formatting, keep shape.
|
||||
rows = [r for r in rows if r.strip("\n") != "" or True]
|
||||
rows = [r.rstrip("\n") for r in rows]
|
||||
if rows and rows[0] == "":
|
||||
rows = rows[1:]
|
||||
|
||||
self.cols = max((len(r) for r in rows), default=0)
|
||||
self.grid_rows = len(rows)
|
||||
self.width = self.cols * self.tile
|
||||
self.height = self.grid_rows * self.tile
|
||||
|
||||
self.solids = [] # list[pygame.Rect] — full blocking
|
||||
self.oneways = [] # list[pygame.Rect] — blocking only from above
|
||||
self.spawn = (self.tile, self.tile)
|
||||
self.goal_rect = pygame.Rect(self.width - self.tile, 0, self.tile, self.tile)
|
||||
|
||||
for r, line in enumerate(rows):
|
||||
for c, ch in enumerate(line):
|
||||
rect = self.cell_rect(c, r)
|
||||
if ch == "#":
|
||||
self.solids.append(rect)
|
||||
elif ch == "-":
|
||||
self.oneways.append(rect)
|
||||
elif ch == "P":
|
||||
self.spawn = (rect.x, rect.y)
|
||||
elif ch == "G":
|
||||
self.goal_rect = rect
|
||||
|
||||
# Build traps from the config via the factory. `count`/`spacing` on a
|
||||
# spec expands into a line/grid of copies first.
|
||||
self.traps = []
|
||||
for spec in data.get("traps", []) or []:
|
||||
for one in traps_mod.expand_spec(spec):
|
||||
trap = traps_mod.make_trap(one, self)
|
||||
if trap is not None:
|
||||
self.traps.append(trap)
|
||||
|
||||
# Snap any mounted traps onto their parent's starting position so their
|
||||
# geometry is correct even before the first update tick.
|
||||
self.reset()
|
||||
|
||||
# --- helpers -------------------------------------------------------------
|
||||
def cell_rect(self, col, row):
|
||||
return pygame.Rect(col * self.tile, row * self.tile, self.tile, self.tile)
|
||||
|
||||
def reset(self):
|
||||
"""Reset every trap to its initial state (called on player death)."""
|
||||
for t in self.traps:
|
||||
t.reset_all()
|
||||
|
||||
def update(self, dt, game):
|
||||
for t in self.traps:
|
||||
t.tick(dt, game)
|
||||
|
||||
# Rects that block movement this frame: static solids + any trap-provided
|
||||
# solids (moving/patrol blocks). One-way platforms are handled separately.
|
||||
# The all_* wrappers fold in anything mounted on a trap.
|
||||
def solid_rects(self):
|
||||
rects = list(self.solids)
|
||||
for t in self.traps:
|
||||
rects.extend(t.all_solid_rects())
|
||||
return rects
|
||||
|
||||
def oneway_rects(self):
|
||||
rects = list(self.oneways)
|
||||
for t in self.traps:
|
||||
rects.extend(t.all_oneway_rects())
|
||||
return rects
|
||||
|
||||
# Moving platforms the player can ride: (rect, dx, dy) moved this frame.
|
||||
def carriers(self):
|
||||
out = []
|
||||
for t in self.traps:
|
||||
out.extend(t.all_carriers())
|
||||
return out
|
||||
|
||||
# Rects that kill the player on contact.
|
||||
def hazard_rects(self):
|
||||
rects = []
|
||||
for t in self.traps:
|
||||
rects.extend(t.all_hazard_rects())
|
||||
return rects
|
||||
|
||||
def draw(self, surface, assets):
|
||||
t = self.tile
|
||||
block = assets.get("block", t, t)
|
||||
for rect in self.solids:
|
||||
surface.blit(block, rect)
|
||||
|
||||
# One-way platforms look identical to solid blocks — you only discover
|
||||
# them by falling through. `debug` fades them so a level designer can
|
||||
# see which is which.
|
||||
oneway_img = block
|
||||
if self.debug:
|
||||
oneway_img = block.copy()
|
||||
oneway_img.fill((255, 255, 255, 110), special_flags=pygame.BLEND_RGBA_MULT)
|
||||
for rect in self.oneways:
|
||||
surface.blit(oneway_img, rect)
|
||||
|
||||
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
||||
self.goal_rect)
|
||||
for tr in self.traps:
|
||||
tr.render(surface, assets)
|
||||
|
||||
if self.debug:
|
||||
self._draw_grid(surface)
|
||||
|
||||
_grid_font = None
|
||||
|
||||
def _draw_grid(self, surface):
|
||||
"""A faint tile grid with col/row labels, so `at: [col,row]` placement
|
||||
is eyeballable while designing."""
|
||||
if Level._grid_font is None:
|
||||
Level._grid_font = pygame.font.SysFont("consolas,menlo,monospace", 10)
|
||||
overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
|
||||
line = (255, 255, 255, 26)
|
||||
for c in range(self.cols + 1):
|
||||
x = c * self.tile
|
||||
pygame.draw.line(overlay, line, (x, 0), (x, self.height))
|
||||
for r in range(self.grid_rows + 1):
|
||||
y = r * self.tile
|
||||
pygame.draw.line(overlay, line, (0, y), (self.width, y))
|
||||
label = (150, 162, 190)
|
||||
for c in range(self.cols):
|
||||
overlay.blit(Level._grid_font.render(str(c), True, label),
|
||||
(c * self.tile + 2, 1))
|
||||
for r in range(self.grid_rows):
|
||||
overlay.blit(Level._grid_font.render(str(r), True, label),
|
||||
(1, r * self.tile + 1))
|
||||
surface.blit(overlay, (0, 0))
|
||||
270
game/player.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""The player character and all of its physics.
|
||||
|
||||
Movement: A / D to run, Space (or W) to jump with variable height, S to drop
|
||||
through one-way platforms. Collision is swept-axis AABB against the level's
|
||||
solid rects, with separate handling for one-way platforms and moving platforms
|
||||
the player can ride.
|
||||
"""
|
||||
|
||||
import pygame
|
||||
|
||||
from . import settings as S
|
||||
|
||||
|
||||
class InputState:
|
||||
__slots__ = ("left", "right", "down", "jump_pressed", "jump_held")
|
||||
|
||||
def __init__(self):
|
||||
self.left = self.right = self.down = False
|
||||
self.jump_pressed = False # edge: pressed this frame
|
||||
self.jump_held = False # level: currently down
|
||||
|
||||
|
||||
class Player:
|
||||
def __init__(self, level):
|
||||
self.level = level
|
||||
self.w = int(level.tile * 0.72)
|
||||
self.h = int(level.tile * 0.92)
|
||||
self.rect = pygame.Rect(0, 0, self.w, self.h)
|
||||
self.respawn()
|
||||
|
||||
def respawn(self):
|
||||
sx, sy = self.level.spawn
|
||||
# center the player horizontally in its spawn tile, feet at tile bottom
|
||||
self.fx = float(sx + (self.level.tile - self.w) / 2)
|
||||
self.fy = float(sy + (self.level.tile - self.h))
|
||||
self.vx = 0.0
|
||||
self.vy = 0.0
|
||||
self.on_ground = False
|
||||
self.coyote = 0.0
|
||||
self.jump_buffer = 0.0
|
||||
self.facing = 1
|
||||
self.drop_through_timer = 0.0
|
||||
self.was_jump_held = False
|
||||
self.crushed = False
|
||||
self.carry = (0.0, 0.0)
|
||||
self._sync_rect()
|
||||
|
||||
def _sync_rect(self):
|
||||
self.rect.x = round(self.fx)
|
||||
self.rect.y = round(self.fy)
|
||||
|
||||
# --- main update ---------------------------------------------------------
|
||||
def update(self, dt, inp):
|
||||
self._ride_platforms()
|
||||
self._push_by_movers()
|
||||
self._horizontal(dt, inp)
|
||||
self._vertical(dt, inp)
|
||||
self.crushed = self._check_crush()
|
||||
|
||||
def _push_by_movers(self):
|
||||
"""A solid moving horizontally into our side shoves us along that axis.
|
||||
|
||||
Without this the player never resolves against a block that walks into
|
||||
them (they aren't moving), and the vertical pass then mis-reads the side
|
||||
overlap as a downward collision — burying them in the floor.
|
||||
"""
|
||||
p = self.rect
|
||||
for rect, dx, dy in self.level.carriers():
|
||||
if dx == 0 or not p.colliderect(rect):
|
||||
continue
|
||||
# Only treat it as a side hit when we share a real chunk of height —
|
||||
# a shallow overlap just means we're standing on top of the block.
|
||||
v_overlap = min(p.bottom, rect.bottom) - max(p.top, rect.top)
|
||||
if v_overlap < self.h * 0.5:
|
||||
continue
|
||||
# Push out the *nearer* horizontal side, not blindly the way the
|
||||
# block is travelling — otherwise hitting its trailing face (e.g.
|
||||
# jumping into the left side of a right-moving block) teleports us
|
||||
# clear across it. Minimal displacement keeps the shove-along and
|
||||
# shove-into-wall behaviours intact.
|
||||
pen_right = rect.right - p.left # displacement to exit rightward
|
||||
pen_left = p.right - rect.left # displacement to exit leftward
|
||||
if pen_right <= pen_left:
|
||||
p.left = rect.right
|
||||
else:
|
||||
p.right = rect.left
|
||||
self.fx = float(p.x)
|
||||
|
||||
def _check_crush(self):
|
||||
"""A crush = a moving block pressing us against a solid on the opposite
|
||||
side (squished into the floor/ceiling/wall, or pinned while riding).
|
||||
|
||||
We probe a thin strip on each side of the player: if a solid backs us on
|
||||
one side and a *moving* block is closing in from the other side on the
|
||||
same axis, we're pinched and die.
|
||||
"""
|
||||
movers = [(r, dx, dy) for (r, dx, dy) in self.level.carriers() if dx or dy]
|
||||
if not movers:
|
||||
return False
|
||||
|
||||
solids = self.level.solid_rects()
|
||||
p = self.rect
|
||||
e = 4 # probe depth (a touch larger than a fast block's per-frame step)
|
||||
up = pygame.Rect(p.left + 2, p.top - e, max(1, p.width - 4), e)
|
||||
down = pygame.Rect(p.left + 2, p.bottom, max(1, p.width - 4), e)
|
||||
left = pygame.Rect(p.left - e, p.top + 2, e, max(1, p.height - 4))
|
||||
right = pygame.Rect(p.right, p.top + 2, e, max(1, p.height - 4))
|
||||
|
||||
def backed(probe):
|
||||
return any(probe.colliderect(s) for s in solids)
|
||||
|
||||
bu, bd = backed(up), backed(down)
|
||||
bl, br = backed(left), backed(right)
|
||||
|
||||
# Are we actually compressed? After this frame's resolution we still
|
||||
# overlap a solid because we couldn't be separated. If a descending
|
||||
# block stops with us fitting underneath, there's no overlap — no crush.
|
||||
pinned = any(p.colliderect(s) for s in solids)
|
||||
|
||||
for r, dx, dy in movers:
|
||||
if dy > 0 and bd and pinned and r.colliderect(up): # squished down onto floor
|
||||
return True
|
||||
if dy < 0 and bu and pinned and r.colliderect(down): # squished up into ceiling
|
||||
return True
|
||||
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
|
||||
return True
|
||||
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
|
||||
return True
|
||||
|
||||
# Riding a platform that carries us *sideways* into a wall: the pushing
|
||||
# block is under our feet, so the side-probes above miss it — use the
|
||||
# carry direction instead. (Vertical carry crushes are caught above.)
|
||||
cdx, _ = self.carry
|
||||
if cdx > 0 and br:
|
||||
return True
|
||||
if cdx < 0 and bl:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ride_platforms(self):
|
||||
# If standing on a moving platform, inherit its motion this frame.
|
||||
self.carry = (0.0, 0.0)
|
||||
for rect, dx, dy in self.level.carriers():
|
||||
if (abs(self.rect.bottom - rect.top) <= 3
|
||||
and self.rect.right > rect.left + 1
|
||||
and self.rect.left < rect.right - 1):
|
||||
self.fx += dx
|
||||
self.fy += dy
|
||||
self._sync_rect()
|
||||
self.carry = (dx, dy)
|
||||
break
|
||||
|
||||
def _horizontal(self, dt, inp):
|
||||
target = 0.0
|
||||
if inp.left:
|
||||
target -= S.MOVE_SPEED
|
||||
self.facing = -1
|
||||
if inp.right:
|
||||
target += S.MOVE_SPEED
|
||||
self.facing = 1
|
||||
|
||||
if target != 0.0:
|
||||
accel = S.ACCEL if self.on_ground else S.AIR_ACCEL
|
||||
if self.vx < target:
|
||||
self.vx = min(self.vx + accel * dt, target)
|
||||
else:
|
||||
self.vx = max(self.vx - accel * dt, target)
|
||||
else:
|
||||
# friction toward zero (only meaningful decel on the ground)
|
||||
fr = S.FRICTION if self.on_ground else S.AIR_ACCEL * 0.5
|
||||
if self.vx > 0:
|
||||
self.vx = max(0.0, self.vx - fr * dt)
|
||||
elif self.vx < 0:
|
||||
self.vx = min(0.0, self.vx + fr * dt)
|
||||
|
||||
self.fx += self.vx * dt
|
||||
self._sync_rect()
|
||||
self._resolve_axis(axis="x")
|
||||
|
||||
def _vertical(self, dt, inp):
|
||||
# timers
|
||||
self.coyote = self.coyote - dt if self.coyote > 0 else 0.0
|
||||
if inp.jump_pressed:
|
||||
self.jump_buffer = S.JUMP_BUFFER
|
||||
else:
|
||||
self.jump_buffer = max(0.0, self.jump_buffer - dt)
|
||||
self.drop_through_timer = max(0.0, self.drop_through_timer - dt)
|
||||
if inp.down and inp.jump_pressed:
|
||||
# Space + S: drop through one-way platforms.
|
||||
self.drop_through_timer = 0.12
|
||||
|
||||
# jump (buffered + coyote)
|
||||
if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \
|
||||
and self.drop_through_timer <= 0:
|
||||
self.vy = -S.JUMP_SPEED
|
||||
self.on_ground = False
|
||||
self.coyote = 0.0
|
||||
self.jump_buffer = 0.0
|
||||
|
||||
# variable jump height: the frame Space is released mid-rise, cut the
|
||||
# remaining upward velocity once (a quick tap = a short hop).
|
||||
if self.was_jump_held and not inp.jump_held and self.vy < 0:
|
||||
self.vy *= S.JUMP_CUT
|
||||
self.was_jump_held = inp.jump_held
|
||||
|
||||
# gravity
|
||||
self.vy = min(self.vy + S.GRAVITY * dt, S.MAX_FALL)
|
||||
|
||||
was_on_ground = self.on_ground
|
||||
self.on_ground = False
|
||||
self.fy += self.vy * dt
|
||||
self._sync_rect()
|
||||
self._resolve_axis(axis="y")
|
||||
|
||||
# start coyote window the frame we walk off a ledge
|
||||
if was_on_ground and not self.on_ground and self.vy >= 0:
|
||||
self.coyote = S.COYOTE_TIME
|
||||
|
||||
# --- collision -----------------------------------------------------------
|
||||
def _resolve_axis(self, axis):
|
||||
solids = self.level.solid_rects()
|
||||
if axis == "x":
|
||||
for s in solids:
|
||||
if self.rect.colliderect(s):
|
||||
# Resolve toward the nearer edge (not by velocity sign) so
|
||||
# clipping a block's corner can't warp us across it. But only
|
||||
# if the overlap is *more horizontal than vertical* — a block
|
||||
# sitting on top of us is a vertical collision; pushing us
|
||||
# sideways out from under it would dodge a crush.
|
||||
pen_left = self.rect.right - s.left # sank in from the left
|
||||
pen_right = s.right - self.rect.left # sank in from the right
|
||||
pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top)
|
||||
if min(pen_left, pen_right) > pen_y:
|
||||
continue # let the Y pass handle it
|
||||
if pen_right <= pen_left:
|
||||
self.rect.left = s.right
|
||||
else:
|
||||
self.rect.right = s.left
|
||||
self.fx = float(self.rect.x)
|
||||
self.vx = 0.0
|
||||
else: # y
|
||||
for s in solids:
|
||||
if self.rect.colliderect(s):
|
||||
# Resolve toward the nearer edge, not by velocity sign — so a
|
||||
# block descending onto us can't pop us out its top.
|
||||
overlap_top = self.rect.bottom - s.top # sank onto its top
|
||||
overlap_bottom = s.bottom - self.rect.top # rose into its underside
|
||||
if overlap_top <= overlap_bottom:
|
||||
self.rect.bottom = s.top
|
||||
self.on_ground = True
|
||||
else:
|
||||
self.rect.top = s.bottom
|
||||
self.fy = float(self.rect.y)
|
||||
self.vy = 0.0
|
||||
# one-way platforms: land only when falling and the feet just
|
||||
# crossed the platform's top edge this frame.
|
||||
if self.vy >= 0 and self.drop_through_timer <= 0:
|
||||
max_pen = self.vy / S.FPS + 8 # how far feet could have sunk this frame
|
||||
for o in self.level.oneway_rects():
|
||||
if self.rect.colliderect(o):
|
||||
penetration = self.rect.bottom - o.top
|
||||
if 0 <= penetration <= max_pen:
|
||||
self.rect.bottom = o.top
|
||||
self.fy = float(self.rect.y)
|
||||
self.vy = 0.0
|
||||
self.on_ground = True
|
||||
|
||||
# --- rendering -----------------------------------------------------------
|
||||
def draw(self, surface, assets):
|
||||
surface.blit(assets.get("player", self.rect.w, self.rect.h), self.rect)
|
||||
48
game/settings.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Global tunables. Anything a level does not override falls back to these."""
|
||||
|
||||
# --- Display -----------------------------------------------------------------
|
||||
TILE = 32 # default tile size in pixels (levels may override)
|
||||
FPS = 60
|
||||
CAPTION = "Dying Phone"
|
||||
|
||||
# --- Physics (pixels / second, unless noted) ---------------------------------
|
||||
GRAVITY = 2200.0 # downward acceleration
|
||||
MAX_FALL = 1400.0 # terminal velocity
|
||||
MOVE_SPEED = 320.0 # horizontal run speed
|
||||
ACCEL = 3200.0 # ground acceleration toward target speed
|
||||
AIR_ACCEL = 2200.0 # weaker control in the air
|
||||
FRICTION = 3600.0 # deceleration when no input on ground
|
||||
JUMP_SPEED = 760.0 # initial upward velocity of a jump
|
||||
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
|
||||
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
|
||||
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
|
||||
|
||||
# --- Gameplay ----------------------------------------------------------------
|
||||
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
|
||||
DEFAULT_BATTERY_PCT = 12.0 # how full the battery *looks* at the start (visual only;
|
||||
# the phone is dying, so the bar reads near-empty)
|
||||
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
|
||||
CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear
|
||||
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
|
||||
|
||||
# --- Colors (placeholder rendering) ------------------------------------------
|
||||
COLOR_BG = (24, 26, 38)
|
||||
COLOR_HUD = (235, 235, 245)
|
||||
COLOR_HUD_WARN = (240, 90, 90)
|
||||
|
||||
# Fallback colors + labels for placeholder rectangles, keyed by sprite name.
|
||||
PLACEHOLDERS = {
|
||||
"player": ((90, 200, 255), "P"),
|
||||
"player_dead": ((120, 40, 40), "X"),
|
||||
"block": ((110, 120, 140), ""),
|
||||
"goal": ((90, 230, 130), "GOAL"),
|
||||
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
|
||||
"spike": ((230, 80, 80), "^"),
|
||||
"moving_block": ((150, 130, 90), ""),
|
||||
"patrol_block": ((130, 100, 170), ""),
|
||||
"crumble_block": ((150, 120, 100), ""),
|
||||
"arrow_shooter": ((80, 80, 95), ""),
|
||||
"arrow": ((250, 220, 90), ">"),
|
||||
"spike_block": ((150, 156, 172), "*"),
|
||||
"phase_block": ((90, 180, 210), ""),
|
||||
}
|
||||
883
game/traps.py
Normal file
@@ -0,0 +1,883 @@
|
||||
"""Traps: data-driven hazards and moving geometry.
|
||||
|
||||
Every trap subclasses :class:`Trap` and implements a few optional hooks that the
|
||||
engine polls each frame:
|
||||
|
||||
solid_rects() -> rects that fully block movement (moving/patrol blocks)
|
||||
oneway_rects() -> rects that block only from above
|
||||
hazard_rects() -> rects that kill the player on contact
|
||||
carriers() -> (rect, dx, dy) moved-this-frame platforms to ride
|
||||
update(dt, game), draw(surface, assets), reset()
|
||||
|
||||
Add a new trap by writing a subclass and registering it in ``TRAP_TYPES``.
|
||||
Nothing else in the engine needs to change.
|
||||
"""
|
||||
|
||||
import pygame
|
||||
|
||||
from . import settings
|
||||
|
||||
|
||||
# --- helpers -----------------------------------------------------------------
|
||||
_DIRS = {
|
||||
"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0),
|
||||
}
|
||||
|
||||
|
||||
# --- trigger conditions ------------------------------------------------------
|
||||
# A trigger is a condition (or tree of conditions) evaluated against the player
|
||||
# each frame. Leaves are spatial (within / dir) or temporal (timer); composites
|
||||
# are all / any. Everything is measured against the trap's *current* rect, so a
|
||||
# moving trap's sensors follow it automatically.
|
||||
#
|
||||
# trigger: always
|
||||
# trigger: { within: 2 }
|
||||
# trigger: { dir: left, range: 2, aligned: true }
|
||||
# trigger: { timer: { interval: 1.2, up_time: 0.7 } }
|
||||
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
|
||||
|
||||
class _Always:
|
||||
def evaluate(self, trap, game, dt):
|
||||
return True
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Within:
|
||||
"""Player within N tiles of the trap centre (Euclidean radius)."""
|
||||
def __init__(self, n):
|
||||
self.n = float(n)
|
||||
|
||||
def evaluate(self, trap, game, dt):
|
||||
r = trap.sensor_rect()
|
||||
p = game.player.rect
|
||||
dx = p.centerx - r.centerx
|
||||
dy = p.centery - r.centery
|
||||
reach = self.n * trap.tile
|
||||
return dx * dx + dy * dy <= reach * reach
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Directional:
|
||||
"""Player is to a given side of the trap.
|
||||
|
||||
range: cap the distance in that direction (tiles); omit = anywhere.
|
||||
aligned: also require overlap on the perpendicular axis, i.e. *directly*
|
||||
left/right (same rows) or *directly* above/below (same columns).
|
||||
"""
|
||||
def __init__(self, direction, rng, aligned):
|
||||
self.dir = direction
|
||||
self.rng = None if rng is None else float(rng)
|
||||
self.aligned = bool(aligned)
|
||||
|
||||
def evaluate(self, trap, game, dt):
|
||||
r = trap.sensor_rect()
|
||||
p = game.player.rect
|
||||
d = self.dir
|
||||
if d in ("left", "right"):
|
||||
if self.aligned and not (p.bottom > r.top and p.top < r.bottom):
|
||||
return False
|
||||
if d == "left":
|
||||
if p.centerx >= r.left:
|
||||
return False
|
||||
dist = r.left - p.centerx
|
||||
else:
|
||||
if p.centerx <= r.right:
|
||||
return False
|
||||
dist = p.centerx - r.right
|
||||
else: # above / below
|
||||
if self.aligned and not (p.right > r.left and p.left < r.right):
|
||||
return False
|
||||
if d == "above":
|
||||
if p.centery >= r.top:
|
||||
return False
|
||||
dist = r.top - p.centery
|
||||
else:
|
||||
if p.centery <= r.bottom:
|
||||
return False
|
||||
dist = p.centery - r.bottom
|
||||
return self.rng is None or dist <= self.rng * trap.tile
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Timer:
|
||||
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
|
||||
def __init__(self, interval, up_time):
|
||||
self.interval = float(interval)
|
||||
self.up_time = float(up_time)
|
||||
self.t = 0.0
|
||||
|
||||
def evaluate(self, trap, game, dt):
|
||||
self.t += dt
|
||||
return (self.t % (self.interval + self.up_time)) >= self.interval
|
||||
|
||||
def reset(self):
|
||||
self.t = 0.0
|
||||
|
||||
|
||||
class _All:
|
||||
def __init__(self, subs):
|
||||
self.subs = subs
|
||||
|
||||
def evaluate(self, trap, game, dt):
|
||||
# Evaluate every child (so timers keep ticking), then combine.
|
||||
return all([c.evaluate(trap, game, dt) for c in self.subs])
|
||||
|
||||
def reset(self):
|
||||
for c in self.subs:
|
||||
c.reset()
|
||||
|
||||
|
||||
class _Any:
|
||||
def __init__(self, subs):
|
||||
self.subs = subs
|
||||
|
||||
def evaluate(self, trap, game, dt):
|
||||
return any([c.evaluate(trap, game, dt) for c in self.subs])
|
||||
|
||||
def reset(self):
|
||||
for c in self.subs:
|
||||
c.reset()
|
||||
|
||||
|
||||
def make_condition(spec):
|
||||
if spec == "always":
|
||||
return _Always()
|
||||
if not isinstance(spec, dict):
|
||||
raise ValueError(f"trigger must be 'always' or a condition object, got {spec!r}")
|
||||
if "all" in spec:
|
||||
return _All([make_condition(s) for s in spec["all"]])
|
||||
if "any" in spec:
|
||||
return _Any([make_condition(s) for s in spec["any"]])
|
||||
if "timer" in spec:
|
||||
tm = spec["timer"]
|
||||
return _Timer(tm.get("interval", 1.5), tm.get("up_time", 0.8))
|
||||
if "within" in spec:
|
||||
return _Within(spec["within"])
|
||||
if "dir" in spec:
|
||||
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False))
|
||||
raise ValueError(f"unrecognized trigger condition: {spec!r}")
|
||||
|
||||
|
||||
class Trap:
|
||||
def __init__(self, spec, level):
|
||||
self.spec = spec
|
||||
self.level = level
|
||||
self.tile = level.tile
|
||||
at = spec.get("at", [0, 0])
|
||||
self.col, self.row = int(at[0]), int(at[1])
|
||||
self.base_rect = level.cell_rect(self.col, self.row)
|
||||
self._mounted = False # set True on traps that ride another (a mount)
|
||||
# Any trap can be made invisible (still functional; revealed in debug).
|
||||
self.invisible = bool(spec.get("invisible", False))
|
||||
|
||||
# Mounted traps ride on this one. Their `at` is read as a *relative*
|
||||
# offset (in tiles) from this trap's cell; each frame they are moved to
|
||||
# track this trap's current position (see tick/_follow). A mounted trap
|
||||
# rides rigidly — it doesn't move independently.
|
||||
self._mount_off = (0, 0)
|
||||
self.mounts = []
|
||||
for mspec in spec.get("mounts", []) or []:
|
||||
child = make_trap(mspec, level)
|
||||
if child is not None:
|
||||
child._mount_off = (child.base_rect.x, child.base_rect.y)
|
||||
child._mounted = True
|
||||
self.mounts.append(child)
|
||||
|
||||
# --- geometry hooks (default: contribute nothing) ------------------------
|
||||
def solid_rects(self):
|
||||
return []
|
||||
|
||||
def oneway_rects(self):
|
||||
return []
|
||||
|
||||
def hazard_rects(self):
|
||||
return []
|
||||
|
||||
def carriers(self):
|
||||
return []
|
||||
|
||||
# Where this trap currently is. Static traps stay at their cell; movers
|
||||
# override to return their live position so mounted traps can follow.
|
||||
def current_rect(self):
|
||||
return self.base_rect
|
||||
|
||||
# Where this trap's trigger senses the player from. Defaults to the live
|
||||
# position; a slider overrides it to sense from home so moving away can't
|
||||
# toggle its own trigger.
|
||||
def sensor_rect(self):
|
||||
return self.current_rect()
|
||||
|
||||
# Debug helper: tint a rect so a normally-hidden trap is visible when the
|
||||
# level's `debug` flag is on.
|
||||
def _debug_tint(self, surface, rgb, rect=None, alpha=80):
|
||||
r = rect if rect is not None else self.base_rect
|
||||
overlay = pygame.Surface(r.size, pygame.SRCALPHA)
|
||||
overlay.fill((*rgb, alpha))
|
||||
surface.blit(overlay, r)
|
||||
|
||||
# Debug helper: a faded sprite + outline showing where something absent
|
||||
# (e.g. a crumbled-away block) belongs.
|
||||
def _debug_ghost(self, surface, assets, sprite_name, rect=None):
|
||||
r = rect if rect is not None else self.base_rect
|
||||
img = assets.get(sprite_name, r.w, r.h).copy()
|
||||
img.fill((255, 255, 255, 70), special_flags=pygame.BLEND_RGBA_MULT)
|
||||
surface.blit(img, r)
|
||||
pygame.draw.rect(surface, (130, 140, 160), r, 1)
|
||||
|
||||
# Debug helper: outline a path through a list of tile cells (top-left px),
|
||||
# connecting their centres. `closed` joins the last cell back to the first.
|
||||
def _debug_path(self, surface, cells, closed=False, color=(214, 200, 96)):
|
||||
rects = [pygame.Rect(x, y, self.tile, self.tile) for (x, y) in cells]
|
||||
if len(rects) >= 2:
|
||||
pygame.draw.lines(surface, color, closed, [r.center for r in rects], 1)
|
||||
for r in rects:
|
||||
pygame.draw.rect(surface, color, r, 1)
|
||||
|
||||
# --- lifecycle hooks -----------------------------------------------------
|
||||
def update(self, dt, game):
|
||||
pass
|
||||
|
||||
def draw(self, surface, assets):
|
||||
pass
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
def finalize_on_death(self):
|
||||
"""Hook: snap to a final appearance the instant the player dies, before
|
||||
the scene freezes for the death animation. Default: nothing."""
|
||||
pass
|
||||
|
||||
# --- mounting: aggregate self + mounted children -------------------------
|
||||
# Levels call these wrappers so a trap and everything riding on it are
|
||||
# treated as one unit. Subclasses keep overriding the plain hooks above.
|
||||
def _follow(self, parent):
|
||||
ox, oy = self._mount_off
|
||||
pr = parent.current_rect()
|
||||
self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy,
|
||||
self.base_rect.w, self.base_rect.h)
|
||||
|
||||
def tick(self, dt, game):
|
||||
self.update(dt, game)
|
||||
for c in self.mounts:
|
||||
c._follow(self) # reposition after we've moved this frame
|
||||
c.tick(dt, game)
|
||||
|
||||
def render(self, surface, assets):
|
||||
# `invisible` traps skip their visible drawing, but still show up under
|
||||
# the level's debug view (their draw() reveals them there).
|
||||
if not (self.invisible and not self.level.debug):
|
||||
self.draw(surface, assets)
|
||||
for c in self.mounts:
|
||||
c.render(surface, assets)
|
||||
|
||||
def reset_all(self):
|
||||
self.reset()
|
||||
for c in self.mounts:
|
||||
c.reset_all()
|
||||
c._follow(self)
|
||||
|
||||
def finalize_all(self):
|
||||
self.finalize_on_death()
|
||||
for c in self.mounts:
|
||||
c.finalize_all()
|
||||
|
||||
def all_solid_rects(self):
|
||||
out = list(self.solid_rects())
|
||||
for c in self.mounts:
|
||||
out.extend(c.all_solid_rects())
|
||||
return out
|
||||
|
||||
def all_oneway_rects(self):
|
||||
out = list(self.oneway_rects())
|
||||
for c in self.mounts:
|
||||
out.extend(c.all_oneway_rects())
|
||||
return out
|
||||
|
||||
def all_hazard_rects(self):
|
||||
out = list(self.hazard_rects())
|
||||
for c in self.mounts:
|
||||
out.extend(c.all_hazard_rects())
|
||||
return out
|
||||
|
||||
def all_carriers(self):
|
||||
out = list(self.carriers())
|
||||
for c in self.mounts:
|
||||
out.extend(c.all_carriers())
|
||||
return out
|
||||
|
||||
# --- triggers ------------------------------------------------------------
|
||||
# Traps with an activation condition call _init_trigger() in __init__,
|
||||
# _reset_trigger() in reset(), and triggered() each frame.
|
||||
def _init_trigger(self, spec):
|
||||
self.trigger = make_condition(spec.get("trigger", "always"))
|
||||
self.trig_delay = float(spec.get("delay", 0.0)) # arm delay (seconds)
|
||||
self._trig_timer = 0.0
|
||||
|
||||
def _reset_trigger(self):
|
||||
self._trig_timer = 0.0
|
||||
self.trigger.reset()
|
||||
|
||||
def triggered(self, game, dt):
|
||||
"""True while the trigger condition holds. If ``delay`` is set, the
|
||||
condition must hold *continuously* for that long first; leaving the
|
||||
condition resets the countdown."""
|
||||
raw = self.trigger.evaluate(self, game, dt)
|
||||
self._trig_timer = self._trig_timer + dt if raw else 0.0
|
||||
return raw and self._trig_timer >= self.trig_delay
|
||||
|
||||
|
||||
# --- spike: emerges to kill -------------------------------------------------
|
||||
class Spike(Trap):
|
||||
"""A spike that becomes deadly while its ``trigger`` condition holds.
|
||||
|
||||
direction: which edge of the cell the spike sits on (up/down/left/right).
|
||||
See the trigger-condition docs at the top of this module.
|
||||
"""
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.direction = spec.get("direction", "up")
|
||||
self._init_trigger(spec)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self._reset_trigger()
|
||||
self.active = False
|
||||
|
||||
def _hazard_rect(self):
|
||||
# Spike occupies half the cell along its emerging edge.
|
||||
t = self.tile
|
||||
r = self.base_rect
|
||||
dx, dy = _DIRS.get(self.direction, (0, -1))
|
||||
if dy == -1: # up: bottom half is ground, tip points up
|
||||
return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
|
||||
if dy == 1: # down (ceiling spike)
|
||||
return pygame.Rect(r.x, r.y, t, t // 2)
|
||||
if dx == -1: # left (from right wall pointing left)
|
||||
return pygame.Rect(r.x, r.y, t // 2, t)
|
||||
return pygame.Rect(r.x + t // 2, r.y, t // 2, t) # right
|
||||
|
||||
# CCW rotation to point the (up-facing) sprite the right way.
|
||||
_ANGLE = {"up": 0, "left": 90, "down": 180, "right": -90}
|
||||
|
||||
def update(self, dt, game):
|
||||
self.active = self.triggered(game, dt)
|
||||
|
||||
def hazard_rects(self):
|
||||
return [self._hazard_rect()] if self.active else []
|
||||
|
||||
def draw(self, surface, assets):
|
||||
if self.active:
|
||||
hr = self._hazard_rect()
|
||||
angle = self._ANGLE.get(self.direction, 0)
|
||||
surface.blit(assets.get("spike", hr.w, hr.h, angle), hr)
|
||||
elif self.level.debug:
|
||||
# A dormant spike — show where it will strike.
|
||||
self._debug_tint(surface, (230, 80, 80), self._hazard_rect(), 60)
|
||||
|
||||
|
||||
# --- 3. block: the unified stationary / sliding / patrolling / spike block ---
|
||||
class Block(Trap):
|
||||
"""A block that may move and/or be deadly — one trap covering stationary
|
||||
blocks, proximity sliders, patrolling platforms, and spike blocks.
|
||||
|
||||
path: list of [col,row] waypoints (default just ``[at]`` = stationary).
|
||||
move: [dcol,drow] shorthand for a 2-point path [at, at+move] (a slider).
|
||||
trigger: when it moves (default ``always``). Sensors track the block's live
|
||||
position, so a condition like ``{dir: above}`` keeps it going while
|
||||
the player rides it.
|
||||
mode: ``once`` (default) extends to the last point while triggered and
|
||||
retreats to the first when not — the slider/dropper behaviour;
|
||||
``loop`` / ``pingpong`` cycle the whole path continuously (patrol).
|
||||
deadly: true -> a hazard (spikes) instead of a solid.
|
||||
speed: px/s. sprite: override (default spike_block if deadly, else moving_block).
|
||||
delay/release: (``once`` mode) the trigger must hold for ``delay`` seconds to
|
||||
start extending and be clear for ``release`` seconds to start
|
||||
retracting — hysteresis that stops boundary jitter.
|
||||
"""
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
t = self.tile
|
||||
if spec.get("path"):
|
||||
pts = spec["path"]
|
||||
elif spec.get("move"):
|
||||
mv = spec["move"]
|
||||
pts = [[self.col, self.row], [self.col + mv[0], self.row + mv[1]]]
|
||||
else:
|
||||
pts = [[self.col, self.row]]
|
||||
self.points = [(p[0] * t, p[1] * t) for p in pts]
|
||||
self.speed = float(spec.get("speed", 140.0))
|
||||
self.mode = spec.get("mode", "once")
|
||||
self.deadly = bool(spec.get("deadly", False))
|
||||
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
|
||||
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
|
||||
self.crumble_delay = float(spec.get("crumble_delay", 0.4))
|
||||
self.respawn = float(spec.get("respawn", 2.5))
|
||||
self.sprite = spec.get("sprite",
|
||||
"spike_block" if self.deadly else
|
||||
"fake_block" if self.fake else
|
||||
"crumble_block" if self.crumble else "moving_block")
|
||||
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
||||
# `home` (default): sense the trigger from the resting cell, so the block
|
||||
# moving away can't toggle its own trigger (no jitter). `current`: sense
|
||||
# from the live position — for blocks the player rides (e.g. a dropper),
|
||||
# so it stays put while ridden instead of pulling back.
|
||||
self.sense = spec.get("sense", "home")
|
||||
self._init_trigger(spec)
|
||||
self.reset()
|
||||
|
||||
def sensor_rect(self):
|
||||
return self.base_rect if self.sense == "home" else self._rect()
|
||||
|
||||
def _follow(self, parent):
|
||||
# Mounted on another trap. Our motion coordinates (self.x, self.y) live in
|
||||
# a LOCAL frame relative to the parent; we track the parent's live
|
||||
# position as our `_origin` and still run our own motion in update(). So a
|
||||
# mount can slide/patrol while riding along with its carrier — e.g. a
|
||||
# block that lunges up to catch a player leaping over, yet keeps drifting
|
||||
# sideways with the platform it sits on. Capture `prev` (absolute) BEFORE
|
||||
# shifting the origin so carriers() reports our *total* motion this frame
|
||||
# (parent drift + our own move).
|
||||
self.prev = (self._origin[0] + self.x, self._origin[1] + self.y)
|
||||
self._origin = parent.current_rect().topleft
|
||||
# `home` sensing tracks the resting cell as it rides along the parent.
|
||||
ox, oy = self._origin
|
||||
offx, offy = self._mount_off
|
||||
self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy),
|
||||
self.tile, self.tile)
|
||||
|
||||
def reset(self):
|
||||
self._reset_trigger()
|
||||
self.x, self.y = self.points[0]
|
||||
# Parent top-left when mounted (set each frame by _follow); (0, 0) for a
|
||||
# free block, so its x/y double as absolute coords. A mount's x/y are
|
||||
# LOCAL — the live rect is always _origin + (x, y).
|
||||
self._origin = (0.0, 0.0)
|
||||
self.prev = (self.x, self.y)
|
||||
self.dir = 1 # pingpong direction
|
||||
self.phase = "rest" # once mode: rest|extending|extended|retracting
|
||||
self._release_t = 0.0
|
||||
# index of the waypoint we're AT (once) / heading toward (patrol)
|
||||
self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0)
|
||||
self.cstate = "solid" # crumble: solid|crumbling|gone
|
||||
self.ctimer = 0.0
|
||||
self.shake = 0.0
|
||||
self.emerge_kill = False
|
||||
|
||||
def update(self, dt, game):
|
||||
# A mount's `prev` (absolute) was captured in _follow before its origin
|
||||
# shifted; a free block records it here. Either way the motion below runs
|
||||
# in our own frame (local for a mount, absolute otherwise), so a mounted
|
||||
# block executes its path/move relative to the parent it rides.
|
||||
if not self._mounted:
|
||||
self.prev = (self.x, self.y)
|
||||
active = self.triggered(game, dt) # call every frame to keep the timer live
|
||||
if len(self.points) >= 2:
|
||||
step = self.speed * dt
|
||||
if self.mode == "once":
|
||||
self._update_once(active, dt, step)
|
||||
else:
|
||||
self._update_patrol(active, step)
|
||||
if self.crumble:
|
||||
self._update_crumble(dt, game)
|
||||
|
||||
def _update_crumble(self, dt, game):
|
||||
self.emerge_kill = False
|
||||
r = self._rect()
|
||||
p = game.player.rect
|
||||
on_top = (abs(p.bottom - r.top) <= 4
|
||||
and p.right > r.left + 2 and p.left < r.right - 2)
|
||||
if self.cstate == "solid":
|
||||
if on_top:
|
||||
self.cstate = "crumbling"
|
||||
self.ctimer = 0.0
|
||||
elif self.cstate == "crumbling":
|
||||
self.ctimer += dt
|
||||
self.shake = (self.ctimer * 40) % 4 - 2
|
||||
if self.ctimer >= self.crumble_delay:
|
||||
self.cstate = "gone"
|
||||
self.ctimer = 0.0
|
||||
elif self.cstate == "gone":
|
||||
self.ctimer += dt
|
||||
if self.ctimer >= self.respawn:
|
||||
# Re-forming into the player kills them (like the old crumble).
|
||||
if p.colliderect(r):
|
||||
self.emerge_kill = True
|
||||
else:
|
||||
self.cstate = "solid"
|
||||
self.ctimer = 0.0
|
||||
self.shake = 0.0
|
||||
|
||||
def _step_to(self, tgt, step):
|
||||
"""Move toward tgt by step; snap and return True on arrival."""
|
||||
tx, ty = tgt
|
||||
dx, dy = tx - self.x, ty - self.y
|
||||
dist = (dx * dx + dy * dy) ** 0.5
|
||||
if dist <= step or dist == 0:
|
||||
self.x, self.y = tx, ty
|
||||
return True
|
||||
self.x += dx / dist * step
|
||||
self.y += dy / dist * step
|
||||
return False
|
||||
|
||||
def _update_once(self, active, dt, step):
|
||||
# A committed stroke: once moving we run to the endpoint regardless of
|
||||
# the trigger flickering, and only reconsider it while parked — no
|
||||
# mid-stroke reversal, so a block that moves out of its own sensor range
|
||||
# can't buzz. Hysteresis (delay/release) smooths the parked decisions.
|
||||
n = len(self.points)
|
||||
if self.phase == "rest":
|
||||
if active:
|
||||
self.phase = "extending"
|
||||
elif self.phase == "extending":
|
||||
if self._step_to(self.points[self.idx + 1], step):
|
||||
self.idx += 1
|
||||
if self.idx >= n - 1:
|
||||
self.phase = "extended"
|
||||
self._release_t = 0.0
|
||||
elif self.phase == "extended":
|
||||
if active:
|
||||
self._release_t = 0.0
|
||||
else:
|
||||
self._release_t += dt
|
||||
if self._release_t >= self.release:
|
||||
self.phase = "retracting"
|
||||
elif self.phase == "retracting":
|
||||
if self._step_to(self.points[self.idx - 1], step):
|
||||
self.idx -= 1
|
||||
if self.idx <= 0:
|
||||
self.phase = "rest"
|
||||
|
||||
def _update_patrol(self, active, step):
|
||||
tgt = self.points[self.idx] if active else self.points[0]
|
||||
if self._step_to(tgt, step):
|
||||
if active:
|
||||
self._advance_patrol()
|
||||
else:
|
||||
self.idx = 1 if len(self.points) > 1 else 0
|
||||
self.dir = 1
|
||||
|
||||
def _advance_patrol(self):
|
||||
n = len(self.points)
|
||||
if self.mode == "loop":
|
||||
self.idx = (self.idx + 1) % n
|
||||
else: # pingpong
|
||||
nxt = self.idx + self.dir
|
||||
if nxt >= n or nxt < 0:
|
||||
self.dir *= -1
|
||||
nxt = self.idx + self.dir
|
||||
self.idx = nxt
|
||||
|
||||
def _rect(self):
|
||||
ox, oy = self._origin
|
||||
return pygame.Rect(round(ox + self.x), round(oy + self.y),
|
||||
self.tile, self.tile)
|
||||
|
||||
def current_rect(self):
|
||||
return self._rect()
|
||||
|
||||
def _intangible(self):
|
||||
return self.deadly or self.fake or (self.crumble and self.cstate == "gone")
|
||||
|
||||
def solid_rects(self):
|
||||
return [] if self._intangible() else [self._rect()]
|
||||
|
||||
def hazard_rects(self):
|
||||
rects = []
|
||||
if self.deadly:
|
||||
rects.append(self._rect().inflate(-4, -4))
|
||||
if self.crumble and self.emerge_kill:
|
||||
rects.append(self._rect())
|
||||
return rects
|
||||
|
||||
def carriers(self):
|
||||
if self._intangible():
|
||||
return []
|
||||
ax = self._origin[0] + self.x
|
||||
ay = self._origin[1] + self.y
|
||||
return [(self._rect(), ax - self.prev[0], ay - self.prev[1])]
|
||||
|
||||
def draw(self, surface, assets):
|
||||
if self.level.debug and len(self.points) > 1:
|
||||
self._debug_path(surface, self.points, closed=(self.mode == "loop"))
|
||||
# crumbled away: hidden (ghost in debug), unless re-forming into the player
|
||||
if self.crumble and self.cstate == "gone" and not self.emerge_kill:
|
||||
if self.level.debug:
|
||||
self._debug_ghost(surface, assets, self.sprite)
|
||||
return
|
||||
rect = self._rect()
|
||||
if self.crumble and self.cstate == "crumbling":
|
||||
rect = rect.move(int(self.shake), 0)
|
||||
surface.blit(assets.get(self.sprite, self.tile, self.tile), rect)
|
||||
if self.fake and self.level.debug:
|
||||
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
|
||||
|
||||
|
||||
# --- 4. arrow shooter --------------------------------------------------------
|
||||
class Arrow:
|
||||
__slots__ = ("rect", "vx", "vy")
|
||||
|
||||
def __init__(self, rect, vx, vy):
|
||||
self.rect = rect
|
||||
self.vx = vx
|
||||
self.vy = vy
|
||||
|
||||
|
||||
class ArrowShooter(Trap):
|
||||
"""A block that fires deadly arrows on an interval while triggered.
|
||||
|
||||
direction: up/down/left/right. speed: px/s. interval: seconds between shots.
|
||||
trigger: only fires while the condition holds (default ``always``).
|
||||
"""
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.direction = spec.get("direction", "left")
|
||||
self.speed = float(spec.get("speed", 260.0))
|
||||
self.interval = float(spec.get("interval", 1.6))
|
||||
self._init_trigger(spec)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.timer = 0.0
|
||||
self.arrows = []
|
||||
self._reset_trigger()
|
||||
|
||||
def _spawn(self):
|
||||
dx, dy = _DIRS.get(self.direction, (-1, 0))
|
||||
t = self.tile
|
||||
w = t // 2 if dx else t // 3
|
||||
h = t // 3 if dx else t // 2
|
||||
r = self.base_rect
|
||||
rect = pygame.Rect(0, 0, w, h)
|
||||
rect.center = r.center
|
||||
# nudge the arrow to the emitting edge
|
||||
if dx == -1: rect.right = r.left
|
||||
elif dx == 1: rect.left = r.right
|
||||
elif dy == -1: rect.bottom = r.top
|
||||
elif dy == 1: rect.top = r.bottom
|
||||
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
|
||||
|
||||
def update(self, dt, game):
|
||||
can_fire = self.triggered(game, dt)
|
||||
self.timer += dt
|
||||
if can_fire and self.timer >= self.interval:
|
||||
self.timer = 0.0
|
||||
self._spawn()
|
||||
|
||||
bounds = pygame.Rect(0, 0, self.level.width, self.level.height).inflate(80, 80)
|
||||
alive = []
|
||||
for a in self.arrows:
|
||||
a.rect.x += round(a.vx * dt)
|
||||
a.rect.y += round(a.vy * dt)
|
||||
if bounds.contains(a.rect) or bounds.colliderect(a.rect):
|
||||
# stop at solid walls
|
||||
if not any(a.rect.colliderect(s) for s in self.level.solids):
|
||||
alive.append(a)
|
||||
self.arrows = alive
|
||||
|
||||
def hazard_rects(self):
|
||||
return [a.rect for a in self.arrows]
|
||||
|
||||
def draw(self, surface, assets):
|
||||
surface.blit(assets.get("arrow_shooter", self.tile, self.tile), self.base_rect)
|
||||
for a in self.arrows:
|
||||
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), a.rect)
|
||||
|
||||
|
||||
# --- warp: invisible teleporter ---------------------------------------------
|
||||
class Warp(Trap):
|
||||
"""An invisible tile that teleports the player to ``to: [col, row]`` on
|
||||
contact. The level's ``debug`` flag tints it (and draws a line to its
|
||||
destination) while designing."""
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.invisible = True
|
||||
to = spec.get("to", [self.col, self.row])
|
||||
self.dest = (int(to[0]), int(to[1]))
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self._armed = True # re-arms once the player has left the tile
|
||||
|
||||
def update(self, dt, game):
|
||||
inside = game.player.rect.colliderect(self.base_rect)
|
||||
if inside and self._armed:
|
||||
p = game.player
|
||||
p.fx = float(self.dest[0] * self.tile + (self.tile - p.w) / 2)
|
||||
p.fy = float(self.dest[1] * self.tile + (self.tile - p.h))
|
||||
p.vx = p.vy = 0.0
|
||||
p._sync_rect()
|
||||
self._armed = False
|
||||
elif not inside:
|
||||
self._armed = True
|
||||
|
||||
def draw(self, surface, assets):
|
||||
if self.level.debug:
|
||||
self._debug_tint(surface, (210, 80, 235), alpha=90)
|
||||
dest = pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
|
||||
self.tile, self.tile)
|
||||
pygame.draw.line(surface, (210, 80, 235),
|
||||
self.base_rect.center, dest.center, 1)
|
||||
self._debug_tint(surface, (210, 80, 235), dest, 45)
|
||||
|
||||
|
||||
# --- 10. phase block: fades into a solid on trigger -------------------------
|
||||
class PhaseBlock(Trap):
|
||||
"""Invisible and intangible until its ``trigger`` fires, then it fades into
|
||||
a solid obstacle over ``fade`` seconds (and fades back out when the trigger
|
||||
releases). If the player is standing in the cell the instant it *starts*
|
||||
appearing, they're killed."""
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.fade = float(spec.get("fade", 0.3))
|
||||
self._init_trigger(spec)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self._reset_trigger()
|
||||
self.alpha = 0.0
|
||||
self.solid = False
|
||||
self.emerge_kill = False
|
||||
|
||||
# How deep the player may be into the cell and still be nudged clear rather
|
||||
# than killed. A shallow clip (feet/shoulder in the cell) gets shoved out;
|
||||
# forming through their middle stays lethal.
|
||||
_EDGE_GRACE = 0.5 # fraction of a tile
|
||||
|
||||
def update(self, dt, game):
|
||||
self.emerge_kill = False
|
||||
active = self.triggered(game, dt)
|
||||
if active:
|
||||
if self.alpha == 0.0 and not self.solid \
|
||||
and game.player.rect.colliderect(self.base_rect):
|
||||
# Forming into the player. If they're only clipping an edge, shove
|
||||
# them clear and let the block solidify behind them; only if it's
|
||||
# forming through their middle — or the shove would squish them
|
||||
# into a solid — is it lethal.
|
||||
if not self._eject_player(game):
|
||||
self.emerge_kill = True
|
||||
return
|
||||
self.alpha = min(1.0, self.alpha + dt / self.fade)
|
||||
self.solid = True
|
||||
else:
|
||||
self.alpha = max(0.0, self.alpha - dt / self.fade)
|
||||
if self.alpha == 0.0:
|
||||
self.solid = False
|
||||
|
||||
def _eject_player(self, game):
|
||||
"""Nudge a player who's only clipping the forming block out of its cell.
|
||||
|
||||
Returns True if they were pushed clear (forgiving). Returns False — leave
|
||||
it lethal — when the block is forming through the player's middle (too
|
||||
deep to fairly eject) or the shove would press them into another solid
|
||||
(squished against something, a crush as usual)."""
|
||||
p = game.player.rect
|
||||
b = self.base_rect
|
||||
# Distance to move the player to clear the block on each side.
|
||||
outs = {
|
||||
"up": p.bottom - b.top,
|
||||
"down": b.bottom - p.top,
|
||||
"left": p.right - b.left,
|
||||
"right": b.right - p.left,
|
||||
}
|
||||
side = min(outs, key=outs.get)
|
||||
dist = outs[side]
|
||||
if dist > self.tile * self._EDGE_GRACE:
|
||||
return False # deep overlap — forming through them
|
||||
dx, dy = _DIRS[side]
|
||||
moved = p.move(dx * dist, dy * dist)
|
||||
# The block isn't solid yet, so it's absent from solid_rects(); any hit
|
||||
# here is a *different* solid backing them — no room to dodge = squished.
|
||||
if any(moved.colliderect(s) for s in self.level.solid_rects()):
|
||||
return False
|
||||
player = game.player
|
||||
player.fx += dx * dist
|
||||
player.fy += dy * dist
|
||||
if dx:
|
||||
player.vx = 0.0
|
||||
if dy:
|
||||
player.vy = 0.0
|
||||
player._sync_rect()
|
||||
return True
|
||||
|
||||
def finalize_on_death(self):
|
||||
# If we were forming when the player died, snap to fully visible so the
|
||||
# frozen death tableau shows the block that got them.
|
||||
if self.emerge_kill or self.alpha > 0.0:
|
||||
self.alpha = 1.0
|
||||
self.solid = True
|
||||
|
||||
def solid_rects(self):
|
||||
return [self.base_rect] if self.solid else []
|
||||
|
||||
def hazard_rects(self):
|
||||
return [self.base_rect] if self.emerge_kill else []
|
||||
|
||||
def draw(self, surface, assets):
|
||||
if self.alpha <= 0.0:
|
||||
if self.level.debug:
|
||||
self._debug_tint(surface, (120, 210, 240), alpha=45)
|
||||
return
|
||||
img = assets.get("phase_block", self.tile, self.tile).copy()
|
||||
img.fill((255, 255, 255, int(255 * self.alpha)),
|
||||
special_flags=pygame.BLEND_RGBA_MULT)
|
||||
surface.blit(img, self.base_rect)
|
||||
|
||||
|
||||
# --- registry + factory ------------------------------------------------------
|
||||
TRAP_TYPES = {
|
||||
"spike": Spike,
|
||||
"block": Block,
|
||||
"arrow_shooter": ArrowShooter,
|
||||
"warp": Warp,
|
||||
"phase_block": PhaseBlock,
|
||||
}
|
||||
|
||||
|
||||
def make_trap(spec, level):
|
||||
ttype = spec.get("type")
|
||||
cls = TRAP_TYPES.get(ttype)
|
||||
if cls is None:
|
||||
print(f"[level] unknown trap type: {ttype!r} — skipping")
|
||||
return None
|
||||
return cls(spec, level)
|
||||
|
||||
|
||||
def expand_spec(spec):
|
||||
"""Expand a trap spec's ``count`` into a line/grid of copies.
|
||||
|
||||
``count: [nx, ny]`` (or a single int for a horizontal line) places
|
||||
nx-by-ny copies, each offset by ``spacing: [sx, sy]`` tiles (default 1).
|
||||
Only ``at`` is shifted per copy (so ``move`` is relative and works;
|
||||
absolute ``path`` is shared, so arrays suit stationary/simple traps).
|
||||
A rectangle of ``invisible`` blocks replaces the old invisible wall.
|
||||
"""
|
||||
count = spec.get("count")
|
||||
if count is None:
|
||||
yield spec
|
||||
return
|
||||
if isinstance(count, (list, tuple)):
|
||||
nx = int(count[0])
|
||||
ny = int(count[1]) if len(count) > 1 else 1
|
||||
else:
|
||||
nx, ny = int(count), 1
|
||||
spacing = spec.get("spacing", 1)
|
||||
if isinstance(spacing, (list, tuple)):
|
||||
sx = spacing[0]
|
||||
sy = spacing[1] if len(spacing) > 1 else spacing[0]
|
||||
else:
|
||||
sx = sy = spacing
|
||||
bc, br = spec.get("at", [0, 0])
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
s = dict(spec)
|
||||
s.pop("count", None)
|
||||
s.pop("spacing", None)
|
||||
s["at"] = [bc + i * sx, br + j * sy]
|
||||
yield s
|
||||
88
levels/level1.yaml
Normal file
@@ -0,0 +1,88 @@
|
||||
# Level 1 — "Low Battery"
|
||||
# A gentle-ish introduction to the fact that nothing here can be trusted.
|
||||
#
|
||||
# Map legend: #=solid -=one-way platform P=spawn G=goal(charger) .=empty
|
||||
# Trap coords are [col, row], 0-based from the top-left of the map.
|
||||
|
||||
name: "1 — Low Battery"
|
||||
tile_size: 32
|
||||
battery_seconds: 40
|
||||
|
||||
map: |
|
||||
.........................
|
||||
.........................
|
||||
.......#####.............
|
||||
.........................
|
||||
.........................
|
||||
..............#####......
|
||||
.........................
|
||||
....###..................
|
||||
.........................
|
||||
.................####....
|
||||
.........................
|
||||
.P.....................G.
|
||||
##########.###.##########
|
||||
|
||||
traps:
|
||||
# The 4th tile of this ledge isn't really there — a classic pit-trap step.
|
||||
- type: block
|
||||
at: [7, 7]
|
||||
fake: true
|
||||
|
||||
# A rhythmic floor spike near the start. Timed, so you can learn to read it.
|
||||
- type: spike
|
||||
at: [5, 11]
|
||||
direction: up
|
||||
trigger: { timer: { interval: 1.2, up_time: 0.7 } }
|
||||
|
||||
# The little island between the pits looks safe. It is — for a quarter
|
||||
# second. Keep moving and you pass; hesitate and it strikes.
|
||||
- type: spike
|
||||
at: [12, 11]
|
||||
direction: up
|
||||
trigger: { within: 1.6 }
|
||||
delay: 0.25
|
||||
|
||||
# A wall turret raking the upper platform with arrows (always firing).
|
||||
- type: arrow_shooter
|
||||
at: [22, 5]
|
||||
direction: left
|
||||
speed: 240
|
||||
interval: 1.4
|
||||
|
||||
# This tile extends the right-hand ledge... until you step over it, then it
|
||||
# drops (mode: once — extends while triggered, retracts when not). Two
|
||||
# conditions: you're close AND directly above it. Because the sensors track
|
||||
# the block, it keeps dropping as you ride it down.
|
||||
- type: block
|
||||
at: [21, 9]
|
||||
move: [0, 4]
|
||||
speed: 220
|
||||
sense: current # rides down with you (senses from its live position)
|
||||
trigger:
|
||||
all:
|
||||
- { within: 5 }
|
||||
- { dir: above, aligned: true }
|
||||
|
||||
# A ferry platform between the low ledges — ride it, don't rush it. A patrol
|
||||
# is just a block with a path and (the default) `always` trigger.
|
||||
- type: block
|
||||
at: [9, 10]
|
||||
path: [[9, 10], [14, 10], [14, 6]]
|
||||
mode: loop
|
||||
speed: 110
|
||||
sprite: patrol_block
|
||||
|
||||
# A stepping block that gives way a beat after you land on it.
|
||||
- type: block
|
||||
at: [16, 11]
|
||||
crumble: true
|
||||
crumble_delay: 0.4
|
||||
respawn: 2.5
|
||||
|
||||
# An invisible catch-wall below the dropper — a vertical array of invisible
|
||||
# blocks (this is what used to be an invisible_wall).
|
||||
- type: block
|
||||
at: [22, 10]
|
||||
invisible: true
|
||||
count: [1, 3]
|
||||
104
levels/level2.yaml
Normal file
@@ -0,0 +1,104 @@
|
||||
# Level 2 — "3% Remaining"
|
||||
# Shorter fuse, meaner traps, more things that move when you least want them to.
|
||||
|
||||
name: "2 — 3% Remaining"
|
||||
tile_size: 32
|
||||
battery_seconds: 32
|
||||
battery_pct: 4 # bar reads ~empty to match the theme (real time is 32s)
|
||||
|
||||
map: |
|
||||
.........................
|
||||
....#####................
|
||||
.........................
|
||||
.........................
|
||||
.......----........###...
|
||||
.........................
|
||||
...###...........###.....
|
||||
.........................
|
||||
..........#..#...........
|
||||
.........................
|
||||
.P..................G....
|
||||
#####.####.#####...######
|
||||
|
||||
traps:
|
||||
# Ceiling spikes that stab down when you pass beneath — proximity triggered.
|
||||
- type: spike
|
||||
at: [8, 2]
|
||||
direction: down
|
||||
trigger: { within: 2.0 }
|
||||
|
||||
# A static spike hazard in the middle lane (a deadly block = spike block).
|
||||
- type: block
|
||||
at: [13, 4]
|
||||
deadly: true
|
||||
- type: block
|
||||
at: [13, 5]
|
||||
deadly: true
|
||||
- type: block
|
||||
at: [13, 6]
|
||||
deadly: true
|
||||
|
||||
# A spiked platform patrolling the low run — jump it, never land on it.
|
||||
# (Demonstrates a *mobile* mount: a block rides the patrolling block AND runs
|
||||
# its own motion, lunging up to catch a player leaping over while it keeps
|
||||
# sliding left/right with its carrier. Spikes ride on top of that block.)
|
||||
- type: block
|
||||
at: [6, 10]
|
||||
path: [[6, 10], [9, 10]]
|
||||
mode: pingpong
|
||||
speed: 130
|
||||
sprite: patrol_block
|
||||
mounts:
|
||||
# Rests one tile above the patrol block; springs three tiles up when the
|
||||
# player is above it, then settles back — all while riding along.
|
||||
- type: block
|
||||
at: [0, -1]
|
||||
move: [0, -3]
|
||||
speed: 320
|
||||
mode: once
|
||||
trigger: { dir: above, range: 5, aligned: true }
|
||||
mounts:
|
||||
- type: spike
|
||||
at: [0, -1] # spikes on top of the lunging block, riding with it
|
||||
trigger: always
|
||||
direction: up
|
||||
|
||||
# Two of the mid platform's tiles are fake — the middle looks fully floored.
|
||||
- type: block
|
||||
at: [11, 8]
|
||||
fake: true
|
||||
- type: block
|
||||
at: [12, 8]
|
||||
fake: true
|
||||
|
||||
# A block that slides sideways into you as you approach the gap.
|
||||
- type: block
|
||||
at: [16, 6]
|
||||
move: [-3, 0]
|
||||
speed: 260
|
||||
trigger: { within: 3.0 }
|
||||
|
||||
# Crossfire: turrets from both walls at the goal approach.
|
||||
- type: arrow_shooter
|
||||
at: [23, 8]
|
||||
direction: left
|
||||
speed: 300
|
||||
interval: 1.1
|
||||
- type: arrow_shooter
|
||||
at: [0, 4]
|
||||
direction: right
|
||||
speed: 220
|
||||
interval: 1.7
|
||||
trigger: { within: 8 }
|
||||
|
||||
# The single pillar before the goal (col 17 on the floor row) is a lie.
|
||||
- type: block
|
||||
at: [17, 11]
|
||||
fake: true
|
||||
|
||||
# Crumbling stairs up to the exit.
|
||||
- type: block
|
||||
at: [19, 9]
|
||||
crumble: true
|
||||
crumble_delay: 0.3
|
||||
respawn: 2.0
|
||||
52
main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dying Phone — a single-screen 2D platformer of ridiculous traps.
|
||||
|
||||
Story: your phone is dying. Reach the charger at the end of each level before
|
||||
the battery runs out. The level is littered with obvious and (mostly) hidden
|
||||
traps. Touch one and you're instantly respawned at the start — traps reset.
|
||||
|
||||
Controls:
|
||||
A / D (or arrows) move
|
||||
Space / W / Up jump (hold for higher, tap for a hop)
|
||||
S + Space drop through one-way platforms
|
||||
R give up and respawn
|
||||
Esc quit
|
||||
|
||||
Usage:
|
||||
python main.py # play every level in ./levels
|
||||
python main.py levels/foo.yaml [more.yaml ...] # play specific levels
|
||||
python main.py --debug # force debug view on for all levels
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from game.game import Game, discover_levels
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
debug = "--debug" in args
|
||||
args = [a for a in args if a != "--debug"]
|
||||
if args:
|
||||
level_paths = [os.path.abspath(p) for p in args]
|
||||
else:
|
||||
level_paths = discover_levels(os.path.join(ROOT, "levels"))
|
||||
|
||||
if not level_paths:
|
||||
print("No levels found. Add a .yaml level file to the levels/ directory.")
|
||||
sys.exit(1)
|
||||
|
||||
missing = [p for p in level_paths if not os.path.isfile(p)]
|
||||
if missing:
|
||||
print("Level file(s) not found:", *missing, sep="\n ")
|
||||
sys.exit(1)
|
||||
|
||||
assets_dir = os.path.join(ROOT, "assets")
|
||||
Game(level_paths, assets_dir, debug=debug).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
pytest.ini
Normal file
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
addopts = -q
|
||||
filterwarnings =
|
||||
ignore:'fc-list' is missing:UserWarning
|
||||
2
requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=7.0
|
||||
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pygame>=2.5.0
|
||||
PyYAML>=6.0
|
||||
136
tests/conftest.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Shared pytest fixtures + helpers for the Dying Phone engine tests.
|
||||
|
||||
Everything runs headless via SDL's dummy drivers, so no window is needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
||||
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
||||
|
||||
import pygame
|
||||
import pytest
|
||||
|
||||
from game import settings as S
|
||||
from game.level import Level
|
||||
from game.game import Game
|
||||
from game.player import InputState
|
||||
|
||||
DT = 1 / 60.0
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _pygame():
|
||||
pygame.init()
|
||||
pygame.display.set_mode((64, 64)) # a display so convert_alpha() works
|
||||
yield
|
||||
pygame.quit()
|
||||
|
||||
|
||||
# --- level/game factories ----------------------------------------------------
|
||||
@pytest.fixture
|
||||
def make_level(tmp_path):
|
||||
n = [0]
|
||||
|
||||
def _make(yaml_text):
|
||||
n[0] += 1
|
||||
path = tmp_path / f"level_{n[0]}.yaml"
|
||||
path.write_text(yaml_text)
|
||||
return Level(str(path))
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_game(tmp_path):
|
||||
n = [0]
|
||||
|
||||
def _make(yaml_text, **kw):
|
||||
n[0] += 1
|
||||
path = tmp_path / f"game_{n[0]}.yaml"
|
||||
path.write_text(yaml_text)
|
||||
# nonexistent assets dir -> placeholder rectangles (deterministic)
|
||||
return Game([str(path)], str(tmp_path / "no_assets"), **kw)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
# --- run-loop helpers ---------------------------------------------------------
|
||||
def step(game, inp=None):
|
||||
"""Advance one frame, mirroring Game.run()'s per-state dispatch."""
|
||||
inp = inp or InputState()
|
||||
if game.state == "playing":
|
||||
game._update_play(DT, inp)
|
||||
elif game.state == "dying":
|
||||
game.death_timer -= DT
|
||||
if game.death_timer <= 0:
|
||||
game._respawn()
|
||||
elif game.state == "charging":
|
||||
game.charge_timer += DT
|
||||
if game.charge_timer >= S.CHARGE_TIME:
|
||||
game.state = "won_all" if game._final else "won_level"
|
||||
elif game.state == "fading":
|
||||
game._update_fade(DT)
|
||||
|
||||
|
||||
def run(game, frames, inp_fn=None):
|
||||
for i in range(frames):
|
||||
step(game, inp_fn(i) if inp_fn else None)
|
||||
|
||||
|
||||
def place(game, col, row):
|
||||
"""Put the player's feet at the bottom of cell (col,row), centred."""
|
||||
t = game.level.tile
|
||||
game.player.fx = float(col * t + (t - game.player.w) / 2)
|
||||
game.player.fy = float(row * t + (t - game.player.h))
|
||||
game.player.vx = game.player.vy = 0.0
|
||||
game.player._sync_rect()
|
||||
|
||||
|
||||
def hold(**flags):
|
||||
inp = InputState()
|
||||
for k, v in flags.items():
|
||||
setattr(inp, k, v)
|
||||
return inp
|
||||
|
||||
|
||||
# --- fakes for isolated trap unit tests --------------------------------------
|
||||
class FakeLevel:
|
||||
tile = 32
|
||||
debug = False
|
||||
width = 800
|
||||
height = 480
|
||||
|
||||
def cell_rect(self, c, r):
|
||||
return pygame.Rect(c * 32, r * 32, 32, 32)
|
||||
|
||||
|
||||
class FakePlayer:
|
||||
def __init__(self, rect):
|
||||
self.rect = rect
|
||||
self.w = rect.w
|
||||
self.h = rect.h
|
||||
self.fx = float(rect.x)
|
||||
self.fy = float(rect.y)
|
||||
self.vx = self.vy = 0.0
|
||||
|
||||
def _sync_rect(self):
|
||||
self.rect.x = round(self.fx)
|
||||
self.rect.y = round(self.fy)
|
||||
|
||||
|
||||
class FakeGame:
|
||||
def __init__(self, rect):
|
||||
self.player = FakePlayer(rect)
|
||||
|
||||
|
||||
def reversals(values):
|
||||
"""Count direction reversals in a numeric sequence (for jitter tests)."""
|
||||
rev, prev = 0, 0
|
||||
for i in range(1, len(values)):
|
||||
d = (values[i] > values[i - 1]) - (values[i] < values[i - 1])
|
||||
if d and prev and d != prev:
|
||||
rev += 1
|
||||
if d:
|
||||
prev = d
|
||||
return rev
|
||||
176
tests/test_block.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""The unified `block` trap: modes, deadly/fake/crumble, jitter, sensing."""
|
||||
|
||||
import pygame
|
||||
from conftest import FakeGame, FakeLevel, reversals, step, run, place, hold
|
||||
from game.traps import Block, expand_spec
|
||||
|
||||
|
||||
def block(**spec):
|
||||
spec.setdefault("type", "block")
|
||||
spec.setdefault("at", [16, 6])
|
||||
return Block(spec, FakeLevel())
|
||||
|
||||
|
||||
def test_stationary_is_solid():
|
||||
b = block(at=[3, 3])
|
||||
assert b.solid_rects() == [b._rect()]
|
||||
assert b.hazard_rects() == []
|
||||
|
||||
|
||||
def test_deadly_is_hazard_not_solid():
|
||||
b = block(at=[3, 3], deadly=True)
|
||||
assert b.solid_rects() == []
|
||||
assert b.hazard_rects() # non-empty
|
||||
|
||||
|
||||
def test_fake_is_drawn_but_not_solid():
|
||||
b = block(at=[3, 3], fake=True)
|
||||
assert b.solid_rects() == [] # you fall through
|
||||
assert b.sprite == "fake_block" # looks like a real block
|
||||
|
||||
|
||||
def test_crumble_cycle_and_emerge_kill(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
######
|
||||
#.P..#
|
||||
#...G#
|
||||
######
|
||||
traps:
|
||||
- type: block
|
||||
at: [2, 2]
|
||||
crumble: true
|
||||
crumble_delay: 0.3
|
||||
respawn: 0.8
|
||||
""")
|
||||
cb = g.level.traps[0]
|
||||
states, killed, d0 = set(), False, g.deaths
|
||||
for _ in range(300):
|
||||
step(g)
|
||||
states.add(cb.cstate)
|
||||
if g.deaths > d0:
|
||||
killed = True
|
||||
break
|
||||
assert "gone" in states # it crumbled away
|
||||
assert killed # re-formed onto the standing player
|
||||
|
||||
|
||||
def test_moving_block_that_crumbles(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
##########
|
||||
#........#
|
||||
#........#
|
||||
#.......G#
|
||||
##########
|
||||
traps:
|
||||
- type: block
|
||||
at: [2, 2]
|
||||
path: [[2, 2], [7, 2]]
|
||||
mode: pingpong
|
||||
speed: 90
|
||||
crumble: true
|
||||
crumble_delay: 0.3
|
||||
respawn: 1.0
|
||||
""")
|
||||
b = g.level.traps[0]
|
||||
place(g, 2, 1) # ride the platform
|
||||
moved = crumbled = False
|
||||
start = b.x
|
||||
for _ in range(200):
|
||||
step(g)
|
||||
moved |= abs(b.x - start) > 20
|
||||
crumbled |= b.cstate != "solid"
|
||||
if g.state != "playing":
|
||||
break
|
||||
assert moved and crumbled
|
||||
|
||||
|
||||
def test_patrol_loop_visits_all_waypoints():
|
||||
b = block(at=[0, 0], path=[[0, 0], [4, 0], [4, 3]], mode="loop", speed=400)
|
||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||
seen = set()
|
||||
for _ in range(600):
|
||||
b.update(1 / 60, g)
|
||||
seen.add((round(b.x / 32), round(b.y / 32)))
|
||||
assert {(0, 0), (4, 0), (4, 3)} <= seen
|
||||
|
||||
|
||||
def test_slider_once_no_jitter_and_retracts():
|
||||
# sense=home (default): a slider moving away can't toggle its own trigger.
|
||||
b = block(at=[16, 6], move=[-3, 0], speed=260, trigger={"within": 3})
|
||||
g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right
|
||||
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
|
||||
assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced
|
||||
g.player.rect.x = 30 * 32 # leave
|
||||
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
|
||||
assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base
|
||||
|
||||
|
||||
def test_sense_current_rides_down():
|
||||
# A dropper (sense=current) commits to the bottom and holds while ridden.
|
||||
b = block(at=[21, 9], move=[0, 4], speed=220, sense="current",
|
||||
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]})
|
||||
# player standing on top of it
|
||||
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
|
||||
g = FakeGame(p)
|
||||
ys = []
|
||||
for _ in range(90):
|
||||
b.update(1 / 60, g)
|
||||
# keep the player riding the block top
|
||||
p.bottom = b._rect().top
|
||||
ys.append(b.y)
|
||||
assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob
|
||||
|
||||
|
||||
def test_carriers_reports_motion():
|
||||
b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120)
|
||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||
b.update(1 / 60, g)
|
||||
(rect, dx, dy), = b.carriers()
|
||||
assert dx != 0 and dy == 0 # moving horizontally
|
||||
|
||||
|
||||
# --- array expansion (count / spacing) --------------------------------------
|
||||
def test_expand_no_count_yields_one():
|
||||
specs = list(expand_spec({"type": "block", "at": [2, 3]}))
|
||||
assert specs == [{"type": "block", "at": [2, 3]}]
|
||||
|
||||
|
||||
def test_expand_line():
|
||||
ats = [s["at"] for s in expand_spec({"type": "spike", "at": [1, 1], "count": 4})]
|
||||
assert ats == [[1, 1], [2, 1], [3, 1], [4, 1]]
|
||||
|
||||
|
||||
def test_expand_grid_with_spacing():
|
||||
ats = [s["at"] for s in expand_spec(
|
||||
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})]
|
||||
assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]]
|
||||
# count/spacing are stripped from each expanded spec
|
||||
for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}):
|
||||
assert "count" not in s and "spacing" not in s
|
||||
|
||||
|
||||
def test_array_expands_in_level(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
########
|
||||
#......#
|
||||
#P....G#
|
||||
########
|
||||
traps:
|
||||
- type: block
|
||||
at: [2, 1]
|
||||
deadly: true
|
||||
count: [4, 1]
|
||||
""")
|
||||
blocks = [t for t in lvl.traps if isinstance(t, Block)]
|
||||
assert len(blocks) == 4
|
||||
assert all(b.deadly for b in blocks)
|
||||
assert sorted(b.current_rect().x for b in blocks) == [2 * 32, 3 * 32, 4 * 32, 5 * 32]
|
||||
176
tests/test_crush.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Crush death, shove/carry interactions, and the collision-resolution
|
||||
regressions (corner warp, fits-under, cliff-shove)."""
|
||||
|
||||
from conftest import step, run, place, hold
|
||||
|
||||
|
||||
def until_crushed(g, frames=200):
|
||||
for _ in range(frames):
|
||||
if g.state != "playing":
|
||||
break
|
||||
step(g)
|
||||
if g.player.crushed:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_descend_crush(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#####
|
||||
#...#
|
||||
#P.G#
|
||||
#####
|
||||
traps:
|
||||
- type: block
|
||||
at: [1, 1]
|
||||
move: [0, 2]
|
||||
speed: 200
|
||||
trigger: { within: 20 }
|
||||
""")
|
||||
# player stands on the floor under the descending block
|
||||
assert until_crushed(g, 120)
|
||||
|
||||
|
||||
def test_fits_under_no_false_crush(make_game):
|
||||
# A block that stops with clearance must NOT crush a grounded player.
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#####
|
||||
#...#
|
||||
#...#
|
||||
#P.G#
|
||||
#####
|
||||
traps:
|
||||
- type: block
|
||||
at: [1, 1]
|
||||
move: [0, 1]
|
||||
speed: 160
|
||||
trigger: { within: 20 }
|
||||
""")
|
||||
place(g, 1, 2)
|
||||
for _ in range(120):
|
||||
step(g)
|
||||
assert not g.player.crushed
|
||||
|
||||
|
||||
def test_shove_into_wall_crushes(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#######
|
||||
#....G#
|
||||
#.....#
|
||||
#######
|
||||
traps:
|
||||
- type: block
|
||||
at: [2, 2]
|
||||
path: [[2, 2], [6, 2]]
|
||||
mode: pingpong
|
||||
speed: 200
|
||||
""")
|
||||
# player pinned against the right wall
|
||||
place(g, 5, 2)
|
||||
g.player.fx = float(6 * 32 - g.player.w)
|
||||
g.player._sync_rect()
|
||||
assert until_crushed(g)
|
||||
|
||||
|
||||
def test_shove_along_not_into_floor(make_game):
|
||||
# A block moving horizontally into a grounded player shoves them sideways,
|
||||
# never buries them in the floor.
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
############
|
||||
#..........#
|
||||
#..........#
|
||||
#.........G#
|
||||
############
|
||||
traps:
|
||||
- type: block
|
||||
at: [8, 3]
|
||||
path: [[8, 3], [2, 3]]
|
||||
mode: pingpong
|
||||
speed: 160
|
||||
""")
|
||||
place(g, 4, 3) # on the floor, at the block's body level
|
||||
floor_top = 4 * 32
|
||||
start_x = g.player.rect.x
|
||||
max_bottom = g.player.rect.bottom
|
||||
for _ in range(120):
|
||||
step(g)
|
||||
max_bottom = max(max_bottom, g.player.rect.bottom)
|
||||
if g.state != "playing":
|
||||
break
|
||||
assert max_bottom <= floor_top + 1 # never pushed into the floor
|
||||
assert g.player.rect.x < start_x # got shoved along
|
||||
|
||||
|
||||
def test_corner_clip_does_not_warp(make_game):
|
||||
# Walking into a moving block near its corner must not fling the player
|
||||
# across it (the old velocity-sign resolver bug).
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#########
|
||||
#.......#
|
||||
#.......#
|
||||
#.......#
|
||||
#P.....G#
|
||||
#########
|
||||
traps:
|
||||
- type: block
|
||||
at: [5, 2]
|
||||
path: [[5, 2], [2, 2]]
|
||||
mode: pingpong
|
||||
speed: 150
|
||||
""")
|
||||
place(g, 4, 3)
|
||||
prev = g.player.rect.x
|
||||
for _ in range(120):
|
||||
step(g, hold(left=True))
|
||||
assert g.player.rect.x - prev <= 28 # no sudden rightward warp
|
||||
prev = g.player.rect.x
|
||||
|
||||
|
||||
def test_cliff_shove_falls_on_first_pass(make_game):
|
||||
# A block sweeping the player toward a ledge should push them off on the
|
||||
# first pass (before it turns around).
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
###########
|
||||
#.........#
|
||||
#.........#
|
||||
######....#
|
||||
traps:
|
||||
- type: block
|
||||
at: [1, 2]
|
||||
path: [[1, 2], [8, 2]]
|
||||
mode: pingpong
|
||||
speed: 150
|
||||
""")
|
||||
b = g.level.traps[0]
|
||||
place(g, 4, 2)
|
||||
reversalsN, pd, left_at = 0, 1, None
|
||||
for i in range(240):
|
||||
step(g)
|
||||
if g.state != "playing":
|
||||
break
|
||||
d = 1 if b.x > b.prev[0] else (-1 if b.x < b.prev[0] else pd)
|
||||
if d != pd:
|
||||
reversalsN += 1
|
||||
pd = d
|
||||
if not g.player.on_ground and g.player.rect.bottom > 3 * 32:
|
||||
left_at = reversalsN
|
||||
break
|
||||
assert left_at == 0 # fell before any block reversal
|
||||
190
tests/test_flow.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Level loading, death/respawn, counters, level-clear flow, window sizing."""
|
||||
|
||||
import pygame
|
||||
from conftest import step, run, place, hold, DT
|
||||
from game import settings as S
|
||||
from game.player import InputState
|
||||
|
||||
|
||||
SIMPLE = """
|
||||
name: My Level
|
||||
tile_size: 32
|
||||
battery_seconds: 30
|
||||
map: |
|
||||
#####
|
||||
#P.G#
|
||||
#####
|
||||
"""
|
||||
|
||||
|
||||
def test_level_parse(make_level):
|
||||
lvl = make_level(SIMPLE)
|
||||
assert lvl.name == "My Level"
|
||||
assert lvl.width == 5 * 32 and lvl.height == 3 * 32
|
||||
assert lvl.spawn == (1 * 32, 1 * 32)
|
||||
assert lvl.goal_rect.topleft == (3 * 32, 1 * 32)
|
||||
assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls
|
||||
|
||||
|
||||
def test_unknown_trap_type_is_skipped(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
###
|
||||
#P#
|
||||
###
|
||||
traps:
|
||||
- type: not_a_real_trap
|
||||
at: [1, 1]
|
||||
""")
|
||||
assert lvl.traps == [] # skipped, no crash
|
||||
|
||||
|
||||
def test_death_pause_then_respawn(make_game):
|
||||
g = make_game(SIMPLE)
|
||||
run(g, 5)
|
||||
d0 = g.deaths
|
||||
g._start_death()
|
||||
assert g.state == "dying" and g.deaths == d0 + 1
|
||||
frames = 0
|
||||
while g.state == "dying" and frames < 100:
|
||||
step(g)
|
||||
frames += 1
|
||||
assert g.state == "playing"
|
||||
assert abs(frames * DT - S.DEATH_PAUSE) < DT * 2
|
||||
|
||||
|
||||
def test_death_counters_per_level_and_total(make_game, tmp_path):
|
||||
# two-level game to test per-level reset vs session total
|
||||
a = tmp_path / "a.yaml"
|
||||
b = tmp_path / "b.yaml"
|
||||
a.write_text(SIMPLE)
|
||||
b.write_text(SIMPLE.replace("My Level", "Two"))
|
||||
from game.game import Game
|
||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||
|
||||
def die():
|
||||
g._start_death()
|
||||
while g.state == "dying":
|
||||
step(g)
|
||||
|
||||
die(); die(); die()
|
||||
assert g.level_deaths == 3 and g.deaths == 3
|
||||
g._advance()
|
||||
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
|
||||
die()
|
||||
assert g.level_deaths == 1 and g.deaths == 4
|
||||
g._replay()
|
||||
assert g.index == 0 and g.deaths == 0 # replay resets the total
|
||||
|
||||
|
||||
def test_reach_goal_charges_then_wins(make_game):
|
||||
g = make_game(SIMPLE)
|
||||
g._reach_goal()
|
||||
assert g.state == "charging"
|
||||
assert 0 <= g.charge_from < 0.2 # near-empty start
|
||||
for _ in range(70):
|
||||
step(g)
|
||||
assert g.state == "won_all" # single level -> won_all
|
||||
|
||||
|
||||
def test_fade_swaps_level_at_black(make_game, tmp_path):
|
||||
a = tmp_path / "a.yaml"
|
||||
b = tmp_path / "b.yaml"
|
||||
a.write_text(SIMPLE)
|
||||
b.write_text(SIMPLE.replace("My Level", "Two"))
|
||||
from game.game import Game
|
||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||
g.state = "won_level"
|
||||
g._start_fade(g._advance)
|
||||
out = swapped = done = 0
|
||||
for i in range(80):
|
||||
prev = g.index
|
||||
step(g)
|
||||
if g.fade_phase == "out" and g.state == "fading":
|
||||
out += 1
|
||||
if g.index != prev:
|
||||
swapped = i
|
||||
if g.state == "playing":
|
||||
done = i
|
||||
break
|
||||
assert swapped and done and g.index == 1
|
||||
assert done - swapped >= 15 # fade-in ~0.3s not skipped
|
||||
|
||||
|
||||
def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch):
|
||||
a = tmp_path / "a.yaml"
|
||||
b = tmp_path / "b.yaml"
|
||||
a.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") # 3 rows
|
||||
b.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n") # 4 rows
|
||||
from game.game import Game
|
||||
calls = []
|
||||
orig = pygame.display.set_mode
|
||||
monkeypatch.setattr(pygame.display, "set_mode",
|
||||
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k))
|
||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||
g._advance(); g._replay()
|
||||
assert len(calls) == 1 # never recreated the window
|
||||
# sized to the tallest level (4 rows + HUD)
|
||||
from game.game import HUD_H
|
||||
assert g.win_h == 4 * 32 + HUD_H
|
||||
|
||||
|
||||
def test_battery_visual_scales_by_pct(make_level):
|
||||
lvl = make_level(SIMPLE + "battery_pct: 4\n")
|
||||
assert lvl.battery_pct == 4
|
||||
|
||||
|
||||
def test_cli_debug_forces_all_levels(make_game):
|
||||
g = make_game(SIMPLE, debug=True)
|
||||
assert g.level.debug is True
|
||||
g2 = make_game(SIMPLE)
|
||||
assert g2.level.debug is False
|
||||
|
||||
|
||||
def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
|
||||
from game.game import Game
|
||||
p = tmp_path / "hot.yaml"
|
||||
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||
assert g.level.name == "A" and len(g.level.traps) == 0
|
||||
d0 = g.deaths
|
||||
# edit the file on disk, then F5
|
||||
p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
|
||||
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n")
|
||||
g._reload_level()
|
||||
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
|
||||
while g.state == "dying":
|
||||
step(g)
|
||||
assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit
|
||||
assert g.level_deaths == 1 # counted, not reset to 0
|
||||
|
||||
|
||||
def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
|
||||
from game.game import Game
|
||||
p = tmp_path / "bad.yaml"
|
||||
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||
p.write_text("name: A\n bad: [unclosed\n") # invalid YAML
|
||||
g._reload_level()
|
||||
while g.state == "dying":
|
||||
step(g)
|
||||
assert g.state == "playing" # fell back, no crash
|
||||
assert g.level.name == "A" # kept the old level
|
||||
|
||||
|
||||
def test_debug_grid_adds_pixels(make_level):
|
||||
import pygame
|
||||
from game.assets import AssetStore
|
||||
lvl = make_level("name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n")
|
||||
a = AssetStore("noassets")
|
||||
|
||||
def painted(dbg):
|
||||
lvl.debug = dbg
|
||||
w = pygame.Surface((lvl.width, lvl.height))
|
||||
w.fill((0, 0, 0))
|
||||
lvl.draw(w, a)
|
||||
return sum(1 for y in range(lvl.height) for x in range(lvl.width)
|
||||
if w.get_at((x, y))[:3] != (0, 0, 0))
|
||||
assert painted(True) > painted(False) # grid + labels add ink
|
||||
153
tests/test_mounting.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Mounting traps on other traps."""
|
||||
|
||||
from conftest import step, DT
|
||||
from game.traps import Block, Spike
|
||||
|
||||
|
||||
PLATFORM_WITH_SPIKE = """
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
############
|
||||
#..........#
|
||||
#..........#
|
||||
#.........G#
|
||||
#P.........#
|
||||
############
|
||||
traps:
|
||||
- type: block
|
||||
at: [3, 2]
|
||||
path: [[3, 2], [8, 2]]
|
||||
mode: pingpong
|
||||
speed: 120
|
||||
sprite: patrol_block
|
||||
mounts:
|
||||
- type: spike
|
||||
at: [0, -1]
|
||||
trigger: always
|
||||
direction: up
|
||||
"""
|
||||
|
||||
|
||||
def test_spike_mount_follows_platform(make_game):
|
||||
g = make_game(PLATFORM_WITH_SPIKE)
|
||||
plat = g.level.traps[0]
|
||||
sp = plat.mounts[0]
|
||||
assert isinstance(sp, Spike)
|
||||
for _ in range(30):
|
||||
g.level.update(DT, g)
|
||||
p = plat.current_rect()
|
||||
hz = sp.hazard_rects()[0]
|
||||
# spike sits one tile above the platform and moves with it
|
||||
assert hz.centerx == p.centerx
|
||||
assert hz.bottom <= p.top + 1
|
||||
|
||||
|
||||
def test_block_mounted_on_block(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
############
|
||||
#..........#
|
||||
#..........#
|
||||
#.........G#
|
||||
#P.........#
|
||||
############
|
||||
traps:
|
||||
- type: block
|
||||
at: [3, 2]
|
||||
path: [[3, 2], [8, 2]]
|
||||
mode: pingpong
|
||||
speed: 120
|
||||
sprite: patrol_block
|
||||
mounts:
|
||||
- type: block
|
||||
at: [-1, 0]
|
||||
deadly: true
|
||||
- type: block
|
||||
at: [1, 0]
|
||||
deadly: true
|
||||
""")
|
||||
plat = g.level.traps[0]
|
||||
g.level.update(DT, g)
|
||||
for m in plat.mounts:
|
||||
assert m._mounted and m.deadly
|
||||
# each deadly mount tracks the platform at its offset and is lethal
|
||||
exp = (plat.current_rect().x + m._mount_off[0],
|
||||
plat.current_rect().y + m._mount_off[1])
|
||||
assert (m.current_rect().x, m.current_rect().y) == exp
|
||||
assert m.hazard_rects()
|
||||
|
||||
|
||||
MOBILE_MOUNT = """
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
################
|
||||
#..............#
|
||||
#..............#
|
||||
#..............#
|
||||
#..............#
|
||||
#P............G#
|
||||
################
|
||||
traps:
|
||||
- type: block
|
||||
at: [3, 4]
|
||||
path: [[3, 4], [8, 4]]
|
||||
mode: pingpong
|
||||
speed: 120
|
||||
sprite: patrol_block
|
||||
mounts:
|
||||
# Rides the patrol AND runs its own upward stroke when armed.
|
||||
- type: block
|
||||
at: [0, -1]
|
||||
move: [0, -3]
|
||||
speed: 240
|
||||
mode: once
|
||||
trigger: { within: 40 } # player is always in range -> stays extended
|
||||
mounts:
|
||||
- type: spike
|
||||
at: [0, -1]
|
||||
trigger: always
|
||||
direction: up
|
||||
"""
|
||||
|
||||
|
||||
def test_block_mount_runs_own_motion_while_riding(make_game):
|
||||
g = make_game(MOBILE_MOUNT)
|
||||
plat = g.level.traps[0]
|
||||
mnt = plat.mounts[0]
|
||||
assert isinstance(mnt, Block) and mnt._mounted
|
||||
tile = g.level.tile
|
||||
|
||||
# At rest the mount sits one tile above the patrol, tracking its column.
|
||||
assert mnt.current_rect().x == plat.current_rect().x
|
||||
assert mnt.current_rect().top == plat.current_rect().top - tile
|
||||
|
||||
for _ in range(120):
|
||||
g.level.update(DT, g)
|
||||
|
||||
pr, mr = plat.current_rect(), mnt.current_rect()
|
||||
# Still rides along horizontally (same column offset as home)...
|
||||
assert mr.x == pr.x
|
||||
# ...but has run its own three-tile upward stroke above the resting spot
|
||||
# (resting = 1 tile up; extended = 1 + 3 tiles up).
|
||||
assert mr.top == pr.top - 4 * tile
|
||||
# It reports as a solid carrier so a rider would be carried.
|
||||
assert mnt.solid_rects() and mnt.carriers()
|
||||
# The spike rides the lunging block, one tile above it.
|
||||
spike = mnt.mounts[0]
|
||||
hz = spike.hazard_rects()[0]
|
||||
assert hz.centerx == mr.centerx and hz.bottom <= mr.top + 1
|
||||
|
||||
|
||||
def test_reset_repositions_mounts(make_game):
|
||||
g = make_game(PLATFORM_WITH_SPIKE)
|
||||
plat = g.level.traps[0]
|
||||
sp = plat.mounts[0]
|
||||
for _ in range(40):
|
||||
g.level.update(DT, g)
|
||||
g.level.reset()
|
||||
# after reset the platform is home and the mount snapped back onto it
|
||||
assert sp.base_rect.centerx == plat.current_rect().centerx
|
||||
151
tests/test_physics.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Player movement & collision."""
|
||||
|
||||
from conftest import step, run, place, hold
|
||||
from game import settings as S
|
||||
|
||||
FLAT = """
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
##########
|
||||
#........#
|
||||
#........#
|
||||
#........#
|
||||
#P......G#
|
||||
##########
|
||||
"""
|
||||
|
||||
|
||||
def test_falls_and_lands_on_floor(make_game):
|
||||
g = make_game(FLAT)
|
||||
place(g, 4, 1) # up in the air
|
||||
run(g, 60)
|
||||
assert g.player.on_ground
|
||||
assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160)
|
||||
|
||||
|
||||
def test_terminal_velocity(make_game):
|
||||
g = make_game(FLAT)
|
||||
place(g, 4, 1)
|
||||
for _ in range(200):
|
||||
step(g)
|
||||
assert g.player.vy <= S.MAX_FALL + 1
|
||||
|
||||
|
||||
def test_jump_gains_height_then_returns(make_game):
|
||||
g = make_game(FLAT)
|
||||
run(g, 30) # settle on floor
|
||||
ground = g.player.rect.bottom
|
||||
peak = ground
|
||||
for i in range(60):
|
||||
step(g, hold(jump_pressed=(i == 0), jump_held=True))
|
||||
peak = min(peak, g.player.rect.bottom)
|
||||
assert peak < ground - 32 # rose at least a tile
|
||||
|
||||
|
||||
def test_variable_jump_height(make_game):
|
||||
# Full-hold jump should out-climb a 1-frame tap.
|
||||
def peak(hold_frames):
|
||||
g = make_game(FLAT)
|
||||
run(g, 30)
|
||||
ground = g.player.rect.bottom
|
||||
hi = ground
|
||||
for i in range(60):
|
||||
held = i < hold_frames
|
||||
step(g, hold(jump_pressed=(i == 0), jump_held=held))
|
||||
hi = min(hi, g.player.rect.bottom)
|
||||
return ground - hi
|
||||
assert peak(60) > peak(1) + 8
|
||||
|
||||
|
||||
def test_coyote_time_allows_jump_after_leaving_ledge(make_game):
|
||||
# Walk off a ledge, then jump within the coyote window -> should rise.
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#######
|
||||
#.....#
|
||||
#.....#
|
||||
##....#
|
||||
#P#...#
|
||||
#######
|
||||
""")
|
||||
place(g, 1, 3) # standing on the little step at [1,3] top
|
||||
# walk right off the step for a couple frames, then jump
|
||||
run(g, 4, lambda i: hold(right=True))
|
||||
y_before = g.player.rect.bottom
|
||||
step(g, hold(right=True, jump_pressed=True, jump_held=True))
|
||||
step(g, hold(right=True, jump_held=True))
|
||||
assert g.player.vy < 0 # a jump actually started
|
||||
|
||||
|
||||
def test_walls_stop_horizontal_movement(make_game):
|
||||
g = make_game(FLAT)
|
||||
place(g, 1, 4)
|
||||
run(g, 120, lambda i: hold(right=True))
|
||||
assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288)
|
||||
run(g, 120, lambda i: hold(left=True))
|
||||
assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32)
|
||||
|
||||
|
||||
def test_oneway_platform_land_from_above_pass_from_below(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#######
|
||||
#.....#
|
||||
#.....#
|
||||
#--...#
|
||||
#.....#
|
||||
#P...G#
|
||||
#######
|
||||
""")
|
||||
# Fall onto the one-way from above -> lands on it.
|
||||
place(g, 1, 1) # open air above the one-way (row 3)
|
||||
run(g, 60)
|
||||
assert g.player.on_ground and g.player.rect.bottom == 3 * 32
|
||||
|
||||
# From below, jumping up passes through it (doesn't block the head).
|
||||
g2 = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#######
|
||||
#.....#
|
||||
#--...#
|
||||
#.....#
|
||||
#P...G#
|
||||
#######
|
||||
""")
|
||||
place(g2, 1, 4) # on the floor, below the one-way (row 2)
|
||||
run(g2, 5) # settle so on_ground is set before jumping
|
||||
passed = False
|
||||
for i in range(40):
|
||||
step(g2, hold(jump_pressed=(i == 0), jump_held=(i < 12)))
|
||||
if g2.player.rect.top < 2 * 32: # rose above the one-way row
|
||||
passed = True
|
||||
assert passed
|
||||
|
||||
|
||||
def test_drop_through_oneway(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#######
|
||||
#.....#
|
||||
#.....#
|
||||
#--...#
|
||||
#.....#
|
||||
#....G#
|
||||
#######
|
||||
""")
|
||||
place(g, 1, 2) # standing on the one-way at row 3
|
||||
run(g, 20)
|
||||
assert g.player.on_ground
|
||||
# press down + jump to drop through
|
||||
for i in range(30):
|
||||
step(g, hold(down=True, jump_pressed=(i == 0)))
|
||||
assert g.player.rect.top > 3 * 32 # fell below the one-way
|
||||
339
tests/test_traps.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Spike, arrow_shooter, warp, phase_block + the invisible flag & block arrays."""
|
||||
|
||||
import hashlib
|
||||
import pygame
|
||||
from conftest import (FakeGame, step, run, place, hold, DT)
|
||||
from game.assets import AssetStore
|
||||
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
|
||||
|
||||
|
||||
# --- spike -------------------------------------------------------------------
|
||||
def test_spike_active_by_trigger(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#####
|
||||
#P.G#
|
||||
#####
|
||||
traps:
|
||||
- type: spike
|
||||
at: [1, 1]
|
||||
trigger: always
|
||||
direction: up
|
||||
""")
|
||||
sp = lvl.traps[0]
|
||||
sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4)))
|
||||
assert sp.active and sp.hazard_rects()
|
||||
|
||||
|
||||
def test_spike_hazard_is_half_tile_on_the_direction_edge(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#####
|
||||
#P.G#
|
||||
#####
|
||||
traps:
|
||||
- type: spike
|
||||
at: [1, 1]
|
||||
trigger: always
|
||||
direction: down
|
||||
""")
|
||||
sp = lvl.traps[0]
|
||||
sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4)))
|
||||
hz = sp.hazard_rects()[0]
|
||||
# down spike occupies the TOP half of its cell
|
||||
assert hz.height == 16 and hz.top == sp.base_rect.top
|
||||
|
||||
|
||||
def test_spike_sprite_rotates_per_direction(tmp_path):
|
||||
# A deliberately asymmetric sprite so each rotation is distinct.
|
||||
surf = pygame.Surface((8, 8), pygame.SRCALPHA)
|
||||
surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only
|
||||
adir = tmp_path / "assets"
|
||||
adir.mkdir()
|
||||
pygame.image.save(surf, str(adir / "spike.png"))
|
||||
a = AssetStore(str(adir))
|
||||
up = a.get("spike", 32, 16, 0)
|
||||
down = a.get("spike", 32, 16, 180)
|
||||
left = a.get("spike", 16, 32, 90)
|
||||
|
||||
def h(s):
|
||||
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
|
||||
assert h(up) != h(down) # rotation actually happened
|
||||
assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
|
||||
|
||||
|
||||
# --- arrow shooter -----------------------------------------------------------
|
||||
def test_arrow_shooter_fires_on_interval(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
##########
|
||||
#.......G#
|
||||
#P.......#
|
||||
##########
|
||||
traps:
|
||||
- type: arrow_shooter
|
||||
at: [7, 1]
|
||||
direction: left
|
||||
speed: 200
|
||||
interval: 0.5
|
||||
""")
|
||||
sh = lvl.traps[0]
|
||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||
for _ in range(40): # ~0.66s -> at least one shot
|
||||
sh.update(DT, g)
|
||||
assert sh.arrows # spawned arrows
|
||||
assert sh.hazard_rects() # arrows are hazards
|
||||
|
||||
|
||||
def test_arrow_shooter_trigger_gates_firing(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
##########
|
||||
#.......G#
|
||||
#P.......#
|
||||
##########
|
||||
traps:
|
||||
- type: arrow_shooter
|
||||
at: [7, 1]
|
||||
direction: left
|
||||
interval: 0.2
|
||||
trigger: { within: 1 }
|
||||
""")
|
||||
sh = lvl.traps[0]
|
||||
far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near
|
||||
for _ in range(60):
|
||||
sh.update(DT, far)
|
||||
assert not sh.arrows # never fired while player out of range
|
||||
|
||||
|
||||
# --- invisible wall, now an array of invisible blocks ------------------------
|
||||
def test_invisible_block_array_is_solid_and_invisible(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
######
|
||||
#....#
|
||||
#....#
|
||||
#P..G#
|
||||
######
|
||||
traps:
|
||||
- type: block
|
||||
at: [3, 1]
|
||||
invisible: true
|
||||
count: [1, 2]
|
||||
""")
|
||||
blocks = [t for t in lvl.traps if isinstance(t, Block)]
|
||||
assert len(blocks) == 2 # one per cell
|
||||
assert all(b.invisible and b.solid_rects() for b in blocks)
|
||||
ys = sorted(b.current_rect().top for b in blocks)
|
||||
assert ys == [1 * 32, 2 * 32] # stacked vertically
|
||||
|
||||
|
||||
# --- warp --------------------------------------------------------------------
|
||||
def test_warp_teleports_and_rearms(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
##########
|
||||
#.......G#
|
||||
#P.......#
|
||||
##########
|
||||
traps:
|
||||
- type: warp
|
||||
at: [4, 2]
|
||||
to: [7, 1]
|
||||
""")
|
||||
place(g, 2, 2)
|
||||
warped = False
|
||||
for _ in range(120):
|
||||
step(g, hold(right=True))
|
||||
if g.player.rect.x >= 7 * 32 - 4:
|
||||
warped = True
|
||||
break
|
||||
assert warped
|
||||
|
||||
|
||||
# --- phase block -------------------------------------------------------------
|
||||
def test_phase_block_intangible_until_triggered(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
##########
|
||||
#........#
|
||||
#........#
|
||||
#P......G#
|
||||
##########
|
||||
traps:
|
||||
- type: phase_block
|
||||
at: [4, 3]
|
||||
fade: 0.2
|
||||
trigger: { within: 2 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
place(g, 1, 3)
|
||||
step(g)
|
||||
assert not pb.solid and pb.alpha == 0 # dormant far away
|
||||
run(g, 40, lambda i: hold(right=True))
|
||||
assert pb.solid and pb.alpha > 0 # phased in solid on approach
|
||||
|
||||
|
||||
def test_phase_block_kills_if_inside_when_it_forms(make_game):
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
########
|
||||
#......#
|
||||
#.....G#
|
||||
########
|
||||
traps:
|
||||
- type: phase_block
|
||||
at: [3, 2]
|
||||
fade: 0.2
|
||||
trigger: { within: 3 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
place(g, 3, 2) # standing right where it will form
|
||||
killed, d0 = False, g.deaths
|
||||
for _ in range(10):
|
||||
step(g)
|
||||
if g.deaths > d0:
|
||||
killed = True
|
||||
break
|
||||
assert killed
|
||||
|
||||
|
||||
def test_phase_block_nudges_player_clipping_edge(make_game):
|
||||
# Only clipping the edge of a forming phase block -> shoved clear, not killed.
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#########
|
||||
#.......#
|
||||
#......G#
|
||||
#########
|
||||
traps:
|
||||
- type: phase_block
|
||||
at: [4, 1]
|
||||
fade: 0.2
|
||||
trigger: { within: 5 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
b = pb.base_rect
|
||||
# Straddle the block's left edge: mostly outside, just clipping into it.
|
||||
g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap
|
||||
g.player.fy = float(b.top + 2)
|
||||
g.player.vx = g.player.vy = 0.0
|
||||
g.player._sync_rect()
|
||||
d0 = g.deaths
|
||||
step(g)
|
||||
# survived, and pushed out to the left so it no longer overlaps the cell
|
||||
assert g.deaths == d0
|
||||
assert g.player.rect.right <= b.left
|
||||
assert pb.solid and not pb.emerge_kill
|
||||
|
||||
|
||||
def test_phase_block_kills_when_shove_would_squish(make_game):
|
||||
# Clipping the edge but backed by a wall on the escape side -> lethal.
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
########
|
||||
#......#
|
||||
#.....G#
|
||||
########
|
||||
traps:
|
||||
- type: phase_block
|
||||
at: [1, 1]
|
||||
fade: 0.2
|
||||
trigger: { within: 5 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
b = pb.base_rect
|
||||
# Clip the block's left edge, but the level wall (col 0) is right there, so
|
||||
# the leftward shove has nowhere to go — squished.
|
||||
g.player.fx = float(b.left - g.player.w + 5)
|
||||
g.player.fy = float(b.top + 2)
|
||||
g.player.vx = g.player.vy = 0.0
|
||||
g.player._sync_rect()
|
||||
killed, d0 = False, g.deaths
|
||||
for _ in range(6):
|
||||
step(g)
|
||||
if g.deaths > d0:
|
||||
killed = True
|
||||
break
|
||||
assert killed
|
||||
|
||||
|
||||
def test_phase_block_snaps_visible_on_death(make_game):
|
||||
# Dying while a phase block is forming should snap it fully visible for the
|
||||
# frozen death tableau.
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
########
|
||||
#......#
|
||||
#.....G#
|
||||
########
|
||||
traps:
|
||||
- type: phase_block
|
||||
at: [3, 2]
|
||||
fade: 0.5
|
||||
trigger: { within: 3 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
place(g, 5, 2) # near enough to trigger, not on the cell
|
||||
for _ in range(6): # let it partially fade in
|
||||
step(g)
|
||||
assert 0 < pb.alpha < 1
|
||||
g._start_death() # die from something
|
||||
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
|
||||
|
||||
|
||||
# --- generic invisible flag --------------------------------------------------
|
||||
def test_invisible_flag_on_any_trap(make_level):
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#####
|
||||
#...#
|
||||
#P.G#
|
||||
#####
|
||||
traps:
|
||||
- type: spike
|
||||
at: [2, 1]
|
||||
trigger: always
|
||||
direction: up
|
||||
invisible: true
|
||||
""")
|
||||
a = AssetStore("no_assets_dir")
|
||||
sp = lvl.traps[0]
|
||||
g = FakeGame(pygame.Rect(999, 999, 4, 4))
|
||||
|
||||
def painted(debug):
|
||||
lvl.debug = debug
|
||||
sp.tick(DT, g)
|
||||
w = pygame.Surface((lvl.width, lvl.height))
|
||||
w.fill((0, 0, 0))
|
||||
sp.render(w, a)
|
||||
return any(w.get_at((x, y))[:3] != (0, 0, 0)
|
||||
for y in range(32, 96) for x in range(32, 96))
|
||||
|
||||
assert painted(False) is False # invisible in play
|
||||
assert painted(True) is True # revealed in debug
|
||||
assert sp.hazard_rects() # still deadly either way
|
||||
91
tests/test_triggers.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Trigger conditions and the arm-delay hysteresis."""
|
||||
|
||||
import pygame
|
||||
from conftest import FakeGame, FakeLevel
|
||||
from game.traps import make_condition
|
||||
|
||||
|
||||
def ev(spec, px, py, w=20, h=28, dt=0.0):
|
||||
trap = type("T", (), {"tile": 32,
|
||||
"sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)})()
|
||||
return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt)
|
||||
# trap centre = (116, 116); left100 right132 top100 bottom132
|
||||
|
||||
|
||||
def test_always():
|
||||
assert ev("always", 999, 999) is True
|
||||
|
||||
|
||||
def test_within_radius():
|
||||
assert ev({"within": 2}, 106, 104) is True # ~10px away
|
||||
assert ev({"within": 2}, 400, 116) is False # far
|
||||
|
||||
|
||||
def test_dir_left_right():
|
||||
assert ev({"dir": "left"}, 40, 105) is True # to the left
|
||||
assert ev({"dir": "left"}, 200, 105) is False # to the right
|
||||
assert ev({"dir": "right"}, 200, 105) is True
|
||||
|
||||
|
||||
def test_dir_range():
|
||||
assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left
|
||||
assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left
|
||||
|
||||
|
||||
def test_dir_aligned():
|
||||
# above + aligned requires horizontal overlap with the trap column
|
||||
assert ev({"dir": "above", "aligned": True}, 108, 60) is True
|
||||
assert ev({"dir": "above", "aligned": True}, 108, 110) is False # not above
|
||||
assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned
|
||||
# without aligned, any column counts
|
||||
assert ev({"dir": "above"}, 400, 60) is True
|
||||
|
||||
|
||||
def test_all_and_any():
|
||||
c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]}
|
||||
assert ev(c, 108, 80) is True
|
||||
assert ev(c, 108, 110) is False # close but not above
|
||||
c2 = {"any": [{"dir": "left"}, {"dir": "right"}]}
|
||||
assert ev(c2, 40, 105) is True
|
||||
assert ev(c2, 110, 40) is False # above-only satisfies neither
|
||||
|
||||
|
||||
def test_timer_cycles():
|
||||
c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}})
|
||||
trap = type("T", (), {"tile": 32,
|
||||
"sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)})()
|
||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||
states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)]
|
||||
assert states[0] is False # starts in the "off" interval
|
||||
assert any(states) and not all(states) # cycles on and off
|
||||
|
||||
|
||||
def test_bad_condition_raises():
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
make_condition({"nope": 1})
|
||||
|
||||
|
||||
def test_delay_hysteresis(make_level):
|
||||
# A spike within-1.6 with delay 0.25 arms only after staying in range.
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
#####
|
||||
#P.G#
|
||||
#####
|
||||
traps:
|
||||
- type: spike
|
||||
at: [1, 1]
|
||||
trigger: { within: 5 }
|
||||
delay: 0.25
|
||||
""")
|
||||
sp = lvl.traps[0]
|
||||
g = FakeGame(sp.base_rect.copy()) # player right on it -> in range
|
||||
armed = None
|
||||
for i in range(30):
|
||||
if sp.triggered(g, 1 / 60):
|
||||
armed = i
|
||||
break
|
||||
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
|
||||
192
tools/gen_sprites.py
Normal file
@@ -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()
|
||||