Compare commits
4 Commits
d30b491ba7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b7b005823 | ||
|
|
a17a68a07e | ||
|
|
f29d8c953c | ||
|
|
b0ad9610be |
57
CLAUDE.md
Normal file
57
CLAUDE.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for working in this repo. **`SPEC.md` is the source of truth for engine
|
||||||
|
behavior and the level format** — read it before changing mechanics, and update
|
||||||
|
it (and `README.md`) when behavior changes. This file only covers workflow and
|
||||||
|
conventions not found there.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
The working virtualenv is `.venv` (Python 3.13). Prefix commands with
|
||||||
|
`.venv/bin/` or activate it first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python main.py # play every level in ./levels
|
||||||
|
.venv/bin/python main.py levels/foo.yaml # play specific level(s), in order
|
||||||
|
.venv/bin/python main.py --debug # force the debug view (see SPEC §14)
|
||||||
|
.venv/bin/python -m pytest # run the test suite
|
||||||
|
.venv/bin/black main.py game tests tools # format (do this before committing)
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite runs headless — `conftest.py` sets the SDL dummy video/audio
|
||||||
|
drivers, so no window opens and nothing needs a display.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Formatting: `black`** (default 88-col line length, no config file). Run it on
|
||||||
|
any Python you touch. It's in `requirements-dev.txt`.
|
||||||
|
- The game must stay **fully playable with zero art assets**: every sprite falls
|
||||||
|
back to a labeled colored rectangle (see `game/assets.py`). Don't add a hard
|
||||||
|
dependency on any PNG.
|
||||||
|
- **Physics/collision live in true coordinates.** `Level.render_offset` is a
|
||||||
|
draw-only translation for the debug overscan — never fold it into gameplay
|
||||||
|
math.
|
||||||
|
- All tunables (physics, timing, colors, the placeholder table) belong in
|
||||||
|
`game/settings.py`, not scattered as literals.
|
||||||
|
|
||||||
|
## Adding a trap type
|
||||||
|
|
||||||
|
Subclass `Trap` in `game/traps.py`, implement only the hooks it needs
|
||||||
|
(`solid_rects`, `hazard_rects`, `carriers`, `update`, `draw`, `reset`, …), and
|
||||||
|
register the class in the `TRAP_TYPES` factory map. Nothing else in the engine
|
||||||
|
needs to change. See SPEC §11–13 for the hook contract and the trap catalog.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/` mirrors the behavior described in `SPEC.md` (physics, every trap and
|
||||||
|
trigger, mounting, crush/shove, game-flow states). When you change behavior, add
|
||||||
|
or update the matching test — several existing tests are regression guards for
|
||||||
|
specific bugs (corner-warp, fits-under-no-crush, ride-into-wall-no-crush), so
|
||||||
|
read the test's comment before altering its expectations.
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
See SPEC §15 for the full layout. In short: `game/game.py` (loop/states/HUD),
|
||||||
|
`game/level.py` (YAML → geometry + traps), `game/player.py` (physics &
|
||||||
|
collision), `game/traps.py` (triggers + all trap types), `game/settings.py`
|
||||||
|
(tunables), `game/assets.py` (sprites + placeholders).
|
||||||
41
README.md
41
README.md
@@ -104,29 +104,30 @@ Any trap can also take:
|
|||||||
| `type` | Key parameters | Behavior |
|
| `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. |
|
| `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. |
|
| `block` | see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, crumbling, and/or phasing. |
|
||||||
| `arrow_shooter` | `direction`, `speed`, `interval`, `trigger`, `delay` | A wall turret firing deadly arrows every `interval` seconds while its `trigger` holds. |
|
| `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. |
|
| `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
|
### The `block` trap
|
||||||
|
|
||||||
One trap covers stationary blocks, sliders, patrolling platforms, spike blocks,
|
One trap covers stationary blocks, sliders, patrolling platforms, spike blocks,
|
||||||
fake blocks, and crumbling blocks — compose the behaviour from options (which
|
fake blocks, crumbling blocks, and phase blocks — compose the behaviour from
|
||||||
combine, e.g. a moving platform that crumbles):
|
options (which combine, e.g. a moving platform that crumbles, or a phase block
|
||||||
|
that's always moving):
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `path` [[col,row],…] | waypoints it travels between (default just `[at]` = stationary) |
|
| `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) |
|
| `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). |
|
| `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. |
|
| `trigger` | when it activates (default `always`). A patrol is just a path + the default `always` trigger. A block that both moves and phases can give each its own trigger via a per-target map (see Triggers). |
|
||||||
| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid |
|
| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid. On a phase block the hazard is live only once materialised. |
|
||||||
| `fake` (bool) | looks solid but you fall straight through it |
|
| `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) |
|
| `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) |
|
||||||
|
| `phase` (bool) + `fade` | invisible/intangible until triggered, then fades into a solid (or a hazard, if `deadly`) over `fade` s (default 0.3) and back out when the trigger releases. Forming into you is lethal, but a non-deadly one first shoves you clear if you're only clipping an edge; if you die while one is mid-fade it snaps fully visible for the death freeze. |
|
||||||
| `speed` | px/s |
|
| `speed` | px/s |
|
||||||
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) |
|
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, `phase_block` if phase, 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 |
|
| `delay` / `release` | hold `delay` s to activate (may be a per-target map); in `once` motion, 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 |
|
| `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
|
```yaml
|
||||||
@@ -145,6 +146,15 @@ combine, e.g. a moving platform that crumbles):
|
|||||||
path: [[9, 10], [14, 10], [14, 6]]
|
path: [[9, 10], [14, 10], [14, 6]]
|
||||||
mode: loop
|
mode: loop
|
||||||
sprite: patrol_block
|
sprite: patrol_block
|
||||||
|
|
||||||
|
- type: block # deadly block that patrols AND phases in near you
|
||||||
|
at: [9, 3]
|
||||||
|
phase: true
|
||||||
|
deadly: true
|
||||||
|
move: [4, 0]
|
||||||
|
trigger:
|
||||||
|
motion: always # always sliding back and forth
|
||||||
|
phase: { within: 3 } # but only solid/deadly when you're close
|
||||||
```
|
```
|
||||||
|
|
||||||
A `once` block runs a *committed stroke*: once it starts moving it runs all the
|
A `once` block runs a *committed stroke*: once it starts moving it runs all the
|
||||||
@@ -183,6 +193,21 @@ trigger:
|
|||||||
*continuously* this long before the trap arms; leaving the condition resets the
|
*continuously* this long before the trap arms; leaving the condition resets the
|
||||||
countdown. Handy for "linger and it strikes" spikes.
|
countdown. Handy for "linger and it strikes" spikes.
|
||||||
|
|
||||||
|
**Per-target triggers.** A `block` can both move and phase, and each behaviour
|
||||||
|
can take its own trigger (and `delay`). Give a single condition to drive both,
|
||||||
|
or a map keyed by target (`motion` / `phase`) with `default` covering the rest:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
trigger:
|
||||||
|
default: always # phase (and anything else)
|
||||||
|
motion: { within: 5 } # motion only
|
||||||
|
delay: { motion: 0.1, phase: 0.3 }
|
||||||
|
```
|
||||||
|
|
||||||
|
A map that names some targets but omits `default` leaves the unnamed ones with
|
||||||
|
no trigger (off) — the debug view warns if that silently disables a capability.
|
||||||
|
An unknown target name is an error.
|
||||||
|
|
||||||
### Getting crushed
|
### Getting crushed
|
||||||
|
|
||||||
You also die if a moving (non-deadly) `block` **pinches** you: presses you
|
You also die if a moving (non-deadly) `block` **pinches** you: presses you
|
||||||
|
|||||||
79
SPEC.md
79
SPEC.md
@@ -341,6 +341,37 @@ Composites evaluate *all* children each frame (so nested timers keep ticking).
|
|||||||
long before the trap arms; leaving the condition resets the countdown. This is
|
long before the trap arms; leaving the condition resets the countdown. This is
|
||||||
arm hysteresis (e.g. "linger and it strikes").
|
arm hysteresis (e.g. "linger and it strikes").
|
||||||
|
|
||||||
|
### 12.1 Per-target triggers and delays
|
||||||
|
|
||||||
|
A trap with more than one triggerable behaviour — a `block` can both **move**
|
||||||
|
and **phase** — lets each behaviour take its own trigger and delay. The value is
|
||||||
|
either:
|
||||||
|
|
||||||
|
- a **single** condition/scalar (the common case), applied to every target; or
|
||||||
|
- a **per-target map** keyed by target name, with `default` covering the rest.
|
||||||
|
|
||||||
|
Target names are disjoint from the condition keywords above, so a bare condition
|
||||||
|
like `{ within: 2 }` is never mistaken for a map. The block's targets are
|
||||||
|
`motion` and `phase`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
trigger: { within: 2 } # both motion and phase
|
||||||
|
trigger:
|
||||||
|
default: always # phase (and any other target)
|
||||||
|
motion: { within: 5 } # motion only
|
||||||
|
delay: { motion: 0.1, phase: 0.3 } # per-target arm delays
|
||||||
|
```
|
||||||
|
|
||||||
|
- If a map names some targets but omits `default`, the **unnamed targets get no
|
||||||
|
trigger and never fire** — a `phase` block with `trigger: { motion: … }` and
|
||||||
|
no `default` never materialises. (In the debug view this prints a warning,
|
||||||
|
since it's usually a mistake.)
|
||||||
|
- An unknown target name raises an error at load time.
|
||||||
|
- Each target keeps its own condition instance and arm countdown, so their
|
||||||
|
timers never interfere.
|
||||||
|
- A scalar `delay` applies to all targets; an unnamed target without a `default`
|
||||||
|
delay falls back to `0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Trap catalog
|
## 13. Trap catalog
|
||||||
@@ -354,20 +385,21 @@ holds.
|
|||||||
|
|
||||||
### 13.2 `block` — the all-in-one block
|
### 13.2 `block` — the all-in-one block
|
||||||
One trap covering stationary blocks, sliders, patrolling platforms, spike
|
One trap covering stationary blocks, sliders, patrolling platforms, spike
|
||||||
blocks, fake blocks, and crumbling blocks. Options combine.
|
blocks, fake blocks, crumbling blocks, and phase blocks. Options combine.
|
||||||
|
|
||||||
| Option | Meaning |
|
| Option | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `path: [[col,row],…]` | waypoints it travels between (default `[at]` = stationary) |
|
| `path: [[col,row],…]` | waypoints it travels between (default `[at]` = stationary) |
|
||||||
| `move: [dcol,drow]` | shorthand for a 2-point path `[at, at+move]` (a slider) |
|
| `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. |
|
| `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` |
|
| `trigger` | when it activates (default `always`); a patrol is a path + `always`. May be a per-target map (`motion` / `phase`) — see §12.1 |
|
||||||
| `speed` | px/s (default 140) |
|
| `speed` | px/s (default 140) |
|
||||||
| `deadly` (bool) | hazard (spikes) instead of a solid |
|
| `deadly` (bool) | hazard (spikes) instead of a solid. On a `phase` block the hazard is live only while it's materialised |
|
||||||
| `fake` (bool) | drawn like a solid block but non-collidable (you fall through) |
|
| `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) |
|
| `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`) |
|
| `phase` (bool) + `fade` | invisible/intangible until triggered, then fades into a solid (or a hazard, if `deadly`) over `fade` s (default 0.3) and back out when the trigger releases — see the phasing note below |
|
||||||
| `delay` / `release` | (`once`) hysteresis: hold `delay` s to start extending, be clear `release` s (default 0.1) to start retracting |
|
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, `phase_block` if phase, else `moving_block`) |
|
||||||
|
| `delay` / `release` | hold `delay` s to activate (may be a per-target map, §12.1); in `once` motion, 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) |
|
| `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:
|
Behavior notes:
|
||||||
@@ -375,11 +407,24 @@ Behavior notes:
|
|||||||
endpoint without reversing, and only reconsiders its trigger while parked at
|
endpoint without reversing, and only reconsiders its trigger while parked at
|
||||||
an endpoint. Combined with `home` sensing, this eliminates the jitter a
|
an endpoint. Combined with `home` sensing, this eliminates the jitter a
|
||||||
slider would otherwise get from moving out of its own sensor range.
|
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
|
- A non-deadly, non-fake, non-phasing (or materialised) block is a **solid**
|
||||||
**carrier** so the player rides it; a moving block can **shove** or **crush**
|
that reports itself as a **carrier** so the player rides it; a moving block
|
||||||
the player (§5.2, §5.6).
|
can **shove** or **crush** the player (§5.2, §5.6).
|
||||||
- Movement runs first each frame, then the crumble state machine, so a moving
|
- Movement, crumble, and phasing run in that order each frame, so a block can
|
||||||
platform can also crumble.
|
combine them — e.g. a block that's always moving (`motion: always`) but only
|
||||||
|
**phases** in when the player is near (`phase: { within: N }`). Motion is
|
||||||
|
trigger-driven; crumble is contact-driven; phasing is trigger-driven.
|
||||||
|
- **Phasing**: while the `phase` trigger holds the block fades in and becomes
|
||||||
|
collidable (a solid, or — with `deadly` — a hazard that's live only once
|
||||||
|
materialised); when it releases, it fades back out and goes intangible. If the
|
||||||
|
player overlaps the cell the instant it *starts* forming, a non-deadly block
|
||||||
|
is forgiving about edge clips: when they're only clipping an edge (overlap ≤
|
||||||
|
half a tile on the shallowest axis) it shoves them out and solidifies behind
|
||||||
|
them, staying lethal only when it forms through their middle (a deep overlap)
|
||||||
|
or the shove would press them into another solid (a crush). A `deadly` phase
|
||||||
|
block skips the shove and simply kills. If the player dies while a phase block
|
||||||
|
is mid-fade, it snaps fully visible before the death freeze (via the
|
||||||
|
`finalize_on_death` hook — see §7/§11).
|
||||||
|
|
||||||
### 13.3 `arrow_shooter`
|
### 13.3 `arrow_shooter`
|
||||||
A wall turret that fires a deadly projectile every `interval` seconds while its
|
A wall turret that fires a deadly projectile every `interval` seconds while its
|
||||||
@@ -399,18 +444,8 @@ and destination tiles and fades out over ~0.35 s (expanding/thinning rings drawn
|
|||||||
procedurally, no sprite), so the teleport reads on screen even though the tile
|
procedurally, no sprite), so the teleport reads on screen even though the tile
|
||||||
itself is invisible.
|
itself is invisible.
|
||||||
|
|
||||||
### 13.5 `phase_block`
|
(A *phase block* — invisible/intangible until triggered, then fades into a solid
|
||||||
Invisible and intangible until its `trigger` fires, then it fades into a solid
|
or hazard — is not a separate type: it's the `block` option `phase: true`, §13.2.)
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
61
game/game.py
61
game/game.py
@@ -257,7 +257,9 @@ class Game:
|
|||||||
if self.state == "dying" and self.death_rect is not None:
|
if self.state == "dying" and self.death_rect is not None:
|
||||||
# The traps stay drawn in their moment-of-death state; the player is
|
# The traps stay drawn in their moment-of-death state; the player is
|
||||||
# replaced by a corpse sprite where they fell.
|
# replaced by a corpse sprite where they fell.
|
||||||
sprite = self.assets.get("player_dead", self.death_rect.w, self.death_rect.h)
|
sprite = self.assets.get(
|
||||||
|
"player_dead", self.death_rect.w, self.death_rect.h
|
||||||
|
)
|
||||||
self.world.blit(sprite, self.death_rect.move(self.level.render_offset))
|
self.world.blit(sprite, self.death_rect.move(self.level.render_offset))
|
||||||
else:
|
else:
|
||||||
self.player.draw(self.world, self.assets)
|
self.player.draw(self.world, self.assets)
|
||||||
@@ -278,11 +280,15 @@ class Game:
|
|||||||
|
|
||||||
if self.state == "won_level":
|
if self.state == "won_level":
|
||||||
plural = "death" if self.level_deaths == 1 else "deaths"
|
plural = "death" if self.level_deaths == 1 else "deaths"
|
||||||
self._draw_win_splash("LEVEL COMPLETE — phone charged!",
|
self._draw_win_splash(
|
||||||
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level")
|
"LEVEL COMPLETE — phone charged!",
|
||||||
|
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level",
|
||||||
|
)
|
||||||
elif self.state == "won_all":
|
elif self.state == "won_all":
|
||||||
self._draw_win_splash("YOU MADE IT! Phone fully charged.",
|
self._draw_win_splash(
|
||||||
f"All levels cleared with {self.deaths} deaths. ENTER to replay")
|
"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
|
if self.state == "fading": # fade-in only; fade-out returned early above
|
||||||
p = min(1.0, self.fade_timer / S.FADE_TIME)
|
p = min(1.0, self.fade_timer / S.FADE_TIME)
|
||||||
@@ -300,16 +306,22 @@ class Game:
|
|||||||
r = max(2, rect.h // 6)
|
r = max(2, rect.h // 6)
|
||||||
inset = max(2, rect.h // 10)
|
inset = max(2, rect.h // 10)
|
||||||
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
|
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
|
||||||
pygame.draw.rect(surf, (28, 30, 40),
|
pygame.draw.rect(
|
||||||
rect.inflate(-inset, -inset), border_radius=r)
|
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)))
|
fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac)))
|
||||||
if fill_frac > 0:
|
if fill_frac > 0:
|
||||||
fw = max(inset, fw)
|
fw = max(inset, fw)
|
||||||
pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
|
pygame.draw.rect(
|
||||||
border_radius=r)
|
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
|
nub_w, nub_h = max(3, rect.h // 4), rect.h // 2
|
||||||
pygame.draw.rect(surf, (60, 60, 72),
|
pygame.draw.rect(
|
||||||
(rect.right, rect.centery - nub_h // 2, nub_w, nub_h))
|
surf, (60, 60, 72), (rect.right, rect.centery - nub_h // 2, nub_w, nub_h)
|
||||||
|
)
|
||||||
|
|
||||||
def _draw_hud(self, hide_battery=False):
|
def _draw_hud(self, hide_battery=False):
|
||||||
w = self.screen.get_width()
|
w = self.screen.get_width()
|
||||||
@@ -322,17 +334,24 @@ class Game:
|
|||||||
bar_w, bar_h = 180, 20
|
bar_w, bar_h = 180, 20
|
||||||
bx, by = 12, (HUD_H - bar_h) // 2
|
bx, by = 12, (HUD_H - bar_h) // 2
|
||||||
if not hide_battery:
|
if not hide_battery:
|
||||||
time_frac = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
|
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)
|
visual = time_frac * (self.level.battery_pct / 100.0)
|
||||||
col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80)
|
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)
|
self._draw_battery(
|
||||||
|
self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col
|
||||||
|
)
|
||||||
|
|
||||||
secs = max(0.0, self.battery)
|
secs = max(0.0, self.battery)
|
||||||
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
|
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
|
||||||
self.screen.blit(label, (bx + bar_w + 16, by - 1))
|
self.screen.blit(label, (bx + bar_w + 16, by - 1))
|
||||||
|
|
||||||
deaths = self.hud_font.render(
|
deaths = self.hud_font.render(
|
||||||
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD)
|
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD
|
||||||
|
)
|
||||||
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
|
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
|
||||||
|
|
||||||
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
|
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
|
||||||
@@ -367,9 +386,12 @@ class Game:
|
|||||||
home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2)
|
home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2)
|
||||||
target = self._hero_battery()
|
target = self._hero_battery()
|
||||||
pos = home.lerp(pygame.Vector2(target.center), move_p)
|
pos = home.lerp(pygame.Vector2(target.center), move_p)
|
||||||
rect = pygame.Rect(0, 0,
|
rect = pygame.Rect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
round(bar_w + (target.w - bar_w) * move_p),
|
round(bar_w + (target.w - bar_w) * move_p),
|
||||||
round(bar_h + (target.h - bar_h) * move_p))
|
round(bar_h + (target.h - bar_h) * move_p),
|
||||||
|
)
|
||||||
rect.center = (round(pos.x), round(pos.y))
|
rect.center = (round(pos.x), round(pos.y))
|
||||||
self._draw_battery(self.screen, rect, fill, (90, 220, 120))
|
self._draw_battery(self.screen, rect, fill, (90, 220, 120))
|
||||||
|
|
||||||
@@ -391,7 +413,10 @@ class Game:
|
|||||||
s = self.hud_font.render(subtitle, True, S.COLOR_HUD)
|
s = self.hud_font.render(subtitle, True, S.COLOR_HUD)
|
||||||
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
|
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
|
||||||
|
|
||||||
|
|
||||||
def discover_levels(levels_dir):
|
def discover_levels(levels_dir):
|
||||||
paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) +
|
paths = sorted(
|
||||||
glob.glob(os.path.join(levels_dir, "*.yml")))
|
glob.glob(os.path.join(levels_dir, "*.yaml"))
|
||||||
|
+ glob.glob(os.path.join(levels_dir, "*.yml"))
|
||||||
|
)
|
||||||
return paths
|
return paths
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ class Level:
|
|||||||
|
|
||||||
rows = data.get("map", "").splitlines()
|
rows = data.get("map", "").splitlines()
|
||||||
# Strip a leading blank line from block-scalar formatting, keep shape.
|
# 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] == "":
|
if rows and rows[0] == "":
|
||||||
rows = rows[1:]
|
rows = rows[1:]
|
||||||
|
|
||||||
@@ -144,8 +142,10 @@ class Level:
|
|||||||
for rect in self.oneways:
|
for rect in self.oneways:
|
||||||
surface.blit(oneway_img, rect.move(ox, oy))
|
surface.blit(oneway_img, rect.move(ox, oy))
|
||||||
|
|
||||||
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
surface.blit(
|
||||||
self.goal_rect.move(ox, oy))
|
assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
||||||
|
self.goal_rect.move(ox, oy),
|
||||||
|
)
|
||||||
for tr in self.traps:
|
for tr in self.traps:
|
||||||
tr.render(surface, assets)
|
tr.render(surface, assets)
|
||||||
|
|
||||||
@@ -173,10 +173,12 @@ class Level:
|
|||||||
# Shade the off-screen overscan so the real play area reads clearly.
|
# Shade the off-screen overscan so the real play area reads clearly.
|
||||||
view = pygame.Rect(ox, oy, self.width, self.height)
|
view = pygame.Rect(ox, oy, self.width, self.height)
|
||||||
shade = (10, 12, 20, 130)
|
shade = (10, 12, 20, 130)
|
||||||
for band in (pygame.Rect(0, 0, sw, oy), # above
|
for band in (
|
||||||
|
pygame.Rect(0, 0, sw, oy), # above
|
||||||
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
|
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
|
||||||
pygame.Rect(0, oy, ox, self.height), # left
|
pygame.Rect(0, oy, ox, self.height), # left
|
||||||
pygame.Rect(view.right, oy, sw - view.right, self.height)): # right
|
pygame.Rect(view.right, oy, sw - view.right, self.height),
|
||||||
|
): # right
|
||||||
if band.w > 0 and band.h > 0:
|
if band.w > 0 and band.h > 0:
|
||||||
overlay.fill(shade, band)
|
overlay.fill(shade, band)
|
||||||
|
|
||||||
@@ -190,14 +192,16 @@ class Level:
|
|||||||
pygame.draw.line(overlay, line, (0, k * t), (sw, k * t))
|
pygame.draw.line(overlay, line, (0, k * t), (sw, k * t))
|
||||||
label = (150, 162, 190)
|
label = (150, 162, 190)
|
||||||
for k in range(sw // t):
|
for k in range(sw // t):
|
||||||
overlay.blit(Level._grid_font.render(str(k - mx), True, label),
|
overlay.blit(
|
||||||
(k * t + 2, 1))
|
Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1)
|
||||||
|
)
|
||||||
for k in range(sh // t):
|
for k in range(sh // t):
|
||||||
overlay.blit(Level._grid_font.render(str(k - my), True, label),
|
overlay.blit(
|
||||||
(1, k * t + 1))
|
Level._grid_font.render(str(k - my), True, label), (1, k * t + 1)
|
||||||
|
)
|
||||||
|
|
||||||
# Outline the actual runtime viewport (the level's true bounds).
|
# Outline the actual runtime viewport (the level's true bounds).
|
||||||
if (mx or my):
|
if mx or my:
|
||||||
pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2)
|
pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2)
|
||||||
|
|
||||||
surface.blit(overlay, (0, 0))
|
surface.blit(overlay, (0, 0))
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class Player:
|
|||||||
self.drop_through_timer = 0.0
|
self.drop_through_timer = 0.0
|
||||||
self.was_jump_held = False
|
self.was_jump_held = False
|
||||||
self.crushed = False
|
self.crushed = False
|
||||||
self.carry = (0.0, 0.0)
|
|
||||||
self._sync_rect()
|
self._sync_rect()
|
||||||
|
|
||||||
def _sync_rect(self):
|
def _sync_rect(self):
|
||||||
@@ -118,9 +117,13 @@ class Player:
|
|||||||
pinned = any(p.colliderect(s) for s in solids)
|
pinned = any(p.colliderect(s) for s in solids)
|
||||||
|
|
||||||
for r, dx, dy in movers:
|
for r, dx, dy in movers:
|
||||||
if dy > 0 and bd and pinned and r.colliderect(up): # squished down onto floor
|
if (
|
||||||
|
dy > 0 and bd and pinned and r.colliderect(up)
|
||||||
|
): # squished down onto floor
|
||||||
return True
|
return True
|
||||||
if dy < 0 and bu and pinned and r.colliderect(down): # squished up into ceiling
|
if (
|
||||||
|
dy < 0 and bu and pinned and r.colliderect(down)
|
||||||
|
): # squished up into ceiling
|
||||||
return True
|
return True
|
||||||
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
|
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
|
||||||
return True
|
return True
|
||||||
@@ -137,15 +140,15 @@ class Player:
|
|||||||
|
|
||||||
def _ride_platforms(self):
|
def _ride_platforms(self):
|
||||||
# If standing on a moving platform, inherit its motion this frame.
|
# 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():
|
for rect, dx, dy in self.level.carriers():
|
||||||
if (abs(self.rect.bottom - rect.top) <= 3
|
if (
|
||||||
|
abs(self.rect.bottom - rect.top) <= 3
|
||||||
and self.rect.right > rect.left + 1
|
and self.rect.right > rect.left + 1
|
||||||
and self.rect.left < rect.right - 1):
|
and self.rect.left < rect.right - 1
|
||||||
|
):
|
||||||
self.fx += dx
|
self.fx += dx
|
||||||
self.fy += dy
|
self.fy += dy
|
||||||
self._sync_rect()
|
self._sync_rect()
|
||||||
self.carry = (dx, dy)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
def _horizontal(self, dt, inp):
|
def _horizontal(self, dt, inp):
|
||||||
@@ -188,8 +191,11 @@ class Player:
|
|||||||
self.drop_through_timer = 0.12
|
self.drop_through_timer = 0.12
|
||||||
|
|
||||||
# jump (buffered + coyote)
|
# jump (buffered + coyote)
|
||||||
if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \
|
if (
|
||||||
and self.drop_through_timer <= 0:
|
self.jump_buffer > 0
|
||||||
|
and (self.on_ground or self.coyote > 0)
|
||||||
|
and self.drop_through_timer <= 0
|
||||||
|
):
|
||||||
self.vy = -S.JUMP_SPEED
|
self.vy = -S.JUMP_SPEED
|
||||||
self.on_ground = False
|
self.on_ground = False
|
||||||
self.coyote = 0.0
|
self.coyote = 0.0
|
||||||
@@ -266,5 +272,6 @@ class Player:
|
|||||||
# --- rendering -----------------------------------------------------------
|
# --- rendering -----------------------------------------------------------
|
||||||
def draw(self, surface, assets):
|
def draw(self, surface, assets):
|
||||||
ox, oy = self.level.render_offset
|
ox, oy = self.level.render_offset
|
||||||
surface.blit(assets.get("player", self.rect.w, self.rect.h),
|
surface.blit(
|
||||||
self.rect.move(ox, oy))
|
assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
|
||||||
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
|
|||||||
# --- Gameplay ----------------------------------------------------------------
|
# --- Gameplay ----------------------------------------------------------------
|
||||||
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
|
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;
|
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)
|
# the phone is dying, so the bar reads near-empty)
|
||||||
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
|
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
|
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
|
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
|
||||||
@@ -32,7 +32,6 @@ FADE_TIME = 0.3 # seconds for each half of the fade-to-black transitio
|
|||||||
# --- Colors (placeholder rendering) ------------------------------------------
|
# --- Colors (placeholder rendering) ------------------------------------------
|
||||||
COLOR_BG = (24, 26, 38)
|
COLOR_BG = (24, 26, 38)
|
||||||
COLOR_HUD = (235, 235, 245)
|
COLOR_HUD = (235, 235, 245)
|
||||||
COLOR_HUD_WARN = (240, 90, 90)
|
|
||||||
|
|
||||||
# Fallback colors + labels for placeholder rectangles, keyed by sprite name.
|
# Fallback colors + labels for placeholder rectangles, keyed by sprite name.
|
||||||
PLACEHOLDERS = {
|
PLACEHOLDERS = {
|
||||||
|
|||||||
477
game/traps.py
477
game/traps.py
@@ -15,12 +15,12 @@ Nothing else in the engine needs to change.
|
|||||||
|
|
||||||
import pygame
|
import pygame
|
||||||
|
|
||||||
from . import settings
|
|
||||||
|
|
||||||
|
|
||||||
# --- helpers -----------------------------------------------------------------
|
# --- helpers -----------------------------------------------------------------
|
||||||
_DIRS = {
|
_DIRS = {
|
||||||
"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0),
|
"up": (0, -1),
|
||||||
|
"down": (0, 1),
|
||||||
|
"left": (-1, 0),
|
||||||
|
"right": (1, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ _DIRS = {
|
|||||||
# trigger: { timer: { interval: 1.2, up_time: 0.7 } }
|
# trigger: { timer: { interval: 1.2, up_time: 0.7 } }
|
||||||
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
|
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
|
||||||
|
|
||||||
|
|
||||||
class _Always:
|
class _Always:
|
||||||
def evaluate(self, trap, game, dt):
|
def evaluate(self, trap, game, dt):
|
||||||
return True
|
return True
|
||||||
@@ -44,8 +45,20 @@ class _Always:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Never:
|
||||||
|
"""A target with no condition — never fires. Used when a per-target trigger
|
||||||
|
map names some targets but omits ``default``, so the unnamed ones are off."""
|
||||||
|
|
||||||
|
def evaluate(self, trap, game, dt):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class _Within:
|
class _Within:
|
||||||
"""Player within N tiles of the trap centre (Euclidean radius)."""
|
"""Player within N tiles of the trap centre (Euclidean radius)."""
|
||||||
|
|
||||||
def __init__(self, n):
|
def __init__(self, n):
|
||||||
self.n = float(n)
|
self.n = float(n)
|
||||||
|
|
||||||
@@ -69,6 +82,7 @@ class _Directional:
|
|||||||
left/right (same rows) or *directly* above/below (same columns).
|
left/right (same rows) or *directly* above/below (same columns).
|
||||||
inclusive: specify if the trap tile itself counts in the given direction.
|
inclusive: specify if the trap tile itself counts in the given direction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, direction, rng, aligned, inclusive):
|
def __init__(self, direction, rng, aligned, inclusive):
|
||||||
self.dir = direction
|
self.dir = direction
|
||||||
self.rng = None if rng is None else float(rng)
|
self.rng = None if rng is None else float(rng)
|
||||||
@@ -113,6 +127,7 @@ class _Directional:
|
|||||||
|
|
||||||
class _Timer:
|
class _Timer:
|
||||||
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
|
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
|
||||||
|
|
||||||
def __init__(self, interval, up_time):
|
def __init__(self, interval, up_time):
|
||||||
self.interval = float(interval)
|
self.interval = float(interval)
|
||||||
self.up_time = float(up_time)
|
self.up_time = float(up_time)
|
||||||
@@ -155,7 +170,9 @@ def make_condition(spec):
|
|||||||
if spec == "always":
|
if spec == "always":
|
||||||
return _Always()
|
return _Always()
|
||||||
if not isinstance(spec, dict):
|
if not isinstance(spec, dict):
|
||||||
raise ValueError(f"trigger must be 'always' or a condition object, got {spec!r}")
|
raise ValueError(
|
||||||
|
f"trigger must be 'always' or a condition object, got {spec!r}"
|
||||||
|
)
|
||||||
if "all" in spec:
|
if "all" in spec:
|
||||||
return _All([make_condition(s) for s in spec["all"]])
|
return _All([make_condition(s) for s in spec["all"]])
|
||||||
if "any" in spec:
|
if "any" in spec:
|
||||||
@@ -166,10 +183,57 @@ def make_condition(spec):
|
|||||||
if "within" in spec:
|
if "within" in spec:
|
||||||
return _Within(spec["within"])
|
return _Within(spec["within"])
|
||||||
if "dir" in spec:
|
if "dir" in spec:
|
||||||
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False), spec.get("inclusive", False))
|
return _Directional(
|
||||||
|
spec["dir"],
|
||||||
|
spec.get("range"),
|
||||||
|
spec.get("aligned", False),
|
||||||
|
spec.get("inclusive", False),
|
||||||
|
)
|
||||||
raise ValueError(f"unrecognized trigger condition: {spec!r}")
|
raise ValueError(f"unrecognized trigger condition: {spec!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-target trigger / delay maps -----------------------------------------
|
||||||
|
# A trap with more than one triggerable behaviour (a block can move *and* phase)
|
||||||
|
# lets each behaviour take its own trigger and delay. The value is either a
|
||||||
|
# single condition/scalar applied to every target (the common case) or a map
|
||||||
|
# keyed by target name (``motion``, ``phase``, …) with ``default`` covering the
|
||||||
|
# rest. Target names are disjoint from the condition keywords above, so a bare
|
||||||
|
# condition like ``{within: 2}`` is never mistaken for a target map.
|
||||||
|
#
|
||||||
|
# trigger: { within: 2 } # both motion and phase
|
||||||
|
# trigger: { default: always, motion: { within: 5 } }
|
||||||
|
# delay: { motion: 0.1, phase: 0.3 }
|
||||||
|
|
||||||
|
_NEVER = object() # sentinel: an unnamed target with no ``default`` -> off
|
||||||
|
|
||||||
|
|
||||||
|
def _is_target_map(value, targets):
|
||||||
|
"""True if ``value`` is a per-target map (a dict keyed by target names)
|
||||||
|
rather than a single condition/scalar applied to every target."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
names = set(targets) | {"default"}
|
||||||
|
return any(k in names for k in value)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_targets(value, targets, kind, missing):
|
||||||
|
"""Resolve a scalar-or-map ``trigger``/``delay`` value into
|
||||||
|
``{target: spec}``. A scalar (or single-condition dict) applies to every
|
||||||
|
target; a per-target map assigns each named target its own spec, with
|
||||||
|
``default`` covering the rest and unnamed-without-default falling back to
|
||||||
|
``missing``."""
|
||||||
|
if not _is_target_map(value, targets):
|
||||||
|
return {t: value for t in targets}
|
||||||
|
valid = set(targets) | {"default"}
|
||||||
|
unknown = [k for k in value if k not in valid]
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"{kind}: unknown target(s) {unknown}; valid targets are {sorted(valid)}"
|
||||||
|
)
|
||||||
|
fallback = value.get("default", missing)
|
||||||
|
return {t: value.get(t, fallback) for t in targets}
|
||||||
|
|
||||||
|
|
||||||
class Trap:
|
class Trap:
|
||||||
def __init__(self, spec, level):
|
def __init__(self, spec, level):
|
||||||
self.spec = spec
|
self.spec = spec
|
||||||
@@ -274,8 +338,9 @@ class Trap:
|
|||||||
def _follow(self, parent):
|
def _follow(self, parent):
|
||||||
ox, oy = self._mount_off
|
ox, oy = self._mount_off
|
||||||
pr = parent.current_rect()
|
pr = parent.current_rect()
|
||||||
self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy,
|
self.base_rect = pygame.Rect(
|
||||||
self.base_rect.w, self.base_rect.h)
|
pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h
|
||||||
|
)
|
||||||
|
|
||||||
def tick(self, dt, game):
|
def tick(self, dt, game):
|
||||||
self.update(dt, game)
|
self.update(dt, game)
|
||||||
@@ -328,23 +393,44 @@ class Trap:
|
|||||||
|
|
||||||
# --- triggers ------------------------------------------------------------
|
# --- triggers ------------------------------------------------------------
|
||||||
# Traps with an activation condition call _init_trigger() in __init__,
|
# Traps with an activation condition call _init_trigger() in __init__,
|
||||||
# _reset_trigger() in reset(), and triggered() each frame.
|
# _reset_trigger() in reset(), and triggered(target) each frame. A trap with
|
||||||
def _init_trigger(self, spec):
|
# a single behaviour uses the lone default target; a block that both moves
|
||||||
self.trigger = make_condition(spec.get("trigger", "always"))
|
# and phases names two ("motion", "phase") so each can take its own trigger
|
||||||
self.trig_delay = float(spec.get("delay", 0.0)) # arm delay (seconds)
|
# and arm delay (see the per-target docs above make_condition's helpers).
|
||||||
self._trig_timer = 0.0
|
def _init_trigger(self, spec, targets=("main",)):
|
||||||
|
trig_specs = _split_targets(
|
||||||
|
spec.get("trigger", "always"), targets, "trigger", _NEVER
|
||||||
|
)
|
||||||
|
delay_specs = _split_targets(spec.get("delay", 0.0), targets, "delay", 0.0)
|
||||||
|
self._trig = {}
|
||||||
|
self._trig_never = set() # targets that resolved to a never-firing condition
|
||||||
|
for t in targets:
|
||||||
|
cspec = trig_specs[t]
|
||||||
|
if cspec is _NEVER:
|
||||||
|
cond = _Never()
|
||||||
|
self._trig_never.add(t)
|
||||||
|
else:
|
||||||
|
cond = make_condition(cspec)
|
||||||
|
self._trig[t] = {
|
||||||
|
"cond": cond,
|
||||||
|
"delay": float(delay_specs[t] or 0.0), # arm delay (seconds)
|
||||||
|
"timer": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
def _reset_trigger(self):
|
def _reset_trigger(self):
|
||||||
self._trig_timer = 0.0
|
for slot in self._trig.values():
|
||||||
self.trigger.reset()
|
slot["timer"] = 0.0
|
||||||
|
slot["cond"].reset()
|
||||||
|
|
||||||
def triggered(self, game, dt):
|
def triggered(self, game, dt, target="main"):
|
||||||
"""True while the trigger condition holds. If ``delay`` is set, the
|
"""True while ``target``'s trigger condition holds. If a ``delay`` is
|
||||||
condition must hold *continuously* for that long first; leaving the
|
set, the condition must hold *continuously* for that long first; leaving
|
||||||
condition resets the countdown."""
|
the condition resets the countdown. Each target keeps its own condition
|
||||||
raw = self.trigger.evaluate(self, game, dt)
|
instance and countdown, so their timers never interfere."""
|
||||||
self._trig_timer = self._trig_timer + dt if raw else 0.0
|
slot = self._trig[target]
|
||||||
return raw and self._trig_timer >= self.trig_delay
|
raw = slot["cond"].evaluate(self, game, dt)
|
||||||
|
slot["timer"] = slot["timer"] + dt if raw else 0.0
|
||||||
|
return raw and slot["timer"] >= slot["delay"]
|
||||||
|
|
||||||
|
|
||||||
# --- spike: emerges to kill -------------------------------------------------
|
# --- spike: emerges to kill -------------------------------------------------
|
||||||
@@ -354,6 +440,7 @@ class Spike(Trap):
|
|||||||
direction: which edge of the cell the spike sits on (up/down/left/right).
|
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.
|
See the trigger-condition docs at the top of this module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, spec, level):
|
def __init__(self, spec, level):
|
||||||
super().__init__(spec, level)
|
super().__init__(spec, level)
|
||||||
self.direction = spec.get("direction", "up")
|
self.direction = spec.get("direction", "up")
|
||||||
@@ -398,23 +485,36 @@ class Spike(Trap):
|
|||||||
|
|
||||||
# --- 3. block: the unified stationary / sliding / patrolling / spike block ---
|
# --- 3. block: the unified stationary / sliding / patrolling / spike block ---
|
||||||
class Block(Trap):
|
class Block(Trap):
|
||||||
"""A block that may move and/or be deadly — one trap covering stationary
|
"""A block that may move, phase in/out, and/or be deadly — one trap covering
|
||||||
blocks, proximity sliders, patrolling platforms, and spike blocks.
|
stationary blocks, proximity sliders, patrolling platforms, spike blocks,
|
||||||
|
fake blocks, crumbling blocks, and phase blocks.
|
||||||
|
|
||||||
path: list of [col,row] waypoints (default just ``[at]`` = stationary).
|
path: list of [col,row] waypoints (default just ``[at]`` = stationary).
|
||||||
move: [dcol,drow] shorthand for a 2-point path [at, at+move] (a slider).
|
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
|
trigger: when it activates (default ``always``). Sensors track the block's
|
||||||
position, so a condition like ``{dir: above}`` keeps it going while
|
live position, so a condition like ``{dir: above}`` keeps it going
|
||||||
the player rides it.
|
while the player rides it. A block that both moves and phases can
|
||||||
|
give each its own trigger via a per-target map keyed ``motion`` /
|
||||||
|
``phase`` (see the per-target docs near make_condition).
|
||||||
mode: ``once`` (default) extends to the last point while triggered and
|
mode: ``once`` (default) extends to the last point while triggered and
|
||||||
retreats to the first when not — the slider/dropper behaviour;
|
retreats to the first when not — the slider/dropper behaviour;
|
||||||
``loop`` / ``pingpong`` cycle the whole path continuously (patrol).
|
``loop`` / ``pingpong`` cycle the whole path continuously (patrol).
|
||||||
deadly: true -> a hazard (spikes) instead of a solid.
|
deadly: true -> a hazard (spikes) instead of a solid. On a phase block the
|
||||||
speed: px/s. sprite: override (default spike_block if deadly, else moving_block).
|
hazard is only live while it's materialised.
|
||||||
delay/release: (``once`` mode) the trigger must hold for ``delay`` seconds to
|
phase: true -> invisible/intangible until triggered, then fades into a
|
||||||
start extending and be clear for ``release`` seconds to start
|
solid (or, if ``deadly``, a hazard) over ``fade`` seconds and back
|
||||||
retracting — hysteresis that stops boundary jitter.
|
out when the trigger releases. Forming into the player is lethal,
|
||||||
|
though a non-deadly one first tries to shove a player merely
|
||||||
|
clipping an edge clear.
|
||||||
|
speed: px/s. sprite: override (default: spike_block if deadly, fake_block
|
||||||
|
if fake, crumble_block if crumble, phase_block if phase, else
|
||||||
|
moving_block).
|
||||||
|
delay/release: the trigger must hold for ``delay`` seconds to activate; in
|
||||||
|
``once`` motion it must also be clear for ``release`` seconds to
|
||||||
|
start retracting — hysteresis that stops boundary jitter. ``delay``
|
||||||
|
may be a per-target map too.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, spec, level):
|
def __init__(self, spec, level):
|
||||||
super().__init__(spec, level)
|
super().__init__(spec, level)
|
||||||
t = self.tile
|
t = self.tile
|
||||||
@@ -433,19 +533,44 @@ class Block(Trap):
|
|||||||
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
|
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
|
||||||
self.crumble_delay = float(spec.get("crumble_delay", 0.4))
|
self.crumble_delay = float(spec.get("crumble_delay", 0.4))
|
||||||
self.respawn = float(spec.get("respawn", 2.5))
|
self.respawn = float(spec.get("respawn", 2.5))
|
||||||
self.sprite = spec.get("sprite",
|
self.phase = bool(spec.get("phase", False)) # fades in/out on trigger
|
||||||
"spike_block" if self.deadly else
|
self.fade = float(spec.get("fade", 0.3)) # phase fade-in/out seconds
|
||||||
"fake_block" if self.fake else
|
self.sprite = spec.get("sprite", self._default_sprite())
|
||||||
"crumble_block" if self.crumble else "moving_block")
|
|
||||||
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
||||||
# `home` (default): sense the trigger from the resting cell, so the block
|
# `home` (default): sense the trigger from the resting cell, so the block
|
||||||
# moving away can't toggle its own trigger (no jitter). `current`: sense
|
# 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),
|
# from the live position — for blocks the player rides (e.g. a dropper),
|
||||||
# so it stays put while ridden instead of pulling back.
|
# so it stays put while ridden instead of pulling back.
|
||||||
self.sense = spec.get("sense", "home")
|
self.sense = spec.get("sense", "home")
|
||||||
self._init_trigger(spec)
|
# Motion and phasing each take their own trigger/delay (a single trigger
|
||||||
|
# applies to both). Crumble is contact-driven, not trigger-driven.
|
||||||
|
self._init_trigger(spec, targets=("motion", "phase"))
|
||||||
|
if self.level.debug:
|
||||||
|
self._warn_dead_trigger("phase", self.phase)
|
||||||
|
self._warn_dead_trigger("motion", len(self.points) >= 2)
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
|
def _default_sprite(self):
|
||||||
|
if self.deadly:
|
||||||
|
return "spike_block"
|
||||||
|
if self.fake:
|
||||||
|
return "fake_block"
|
||||||
|
if self.crumble:
|
||||||
|
return "crumble_block"
|
||||||
|
if self.phase:
|
||||||
|
return "phase_block"
|
||||||
|
return "moving_block"
|
||||||
|
|
||||||
|
def _warn_dead_trigger(self, target, enabled):
|
||||||
|
# Design aid: a capability that's on but whose trigger never fires (a
|
||||||
|
# per-target map that named other targets but omitted this one and
|
||||||
|
# `default`) is almost always a mistake — flag it in the debug view.
|
||||||
|
if enabled and target in self._trig_never:
|
||||||
|
print(
|
||||||
|
f"[level] block at [{self.col},{self.row}]: {target} is enabled "
|
||||||
|
f"but its trigger never fires (add a '{target}' or 'default' target)"
|
||||||
|
)
|
||||||
|
|
||||||
def sensor_rect(self):
|
def sensor_rect(self):
|
||||||
return self.base_rect if self.sense == "home" else self._rect()
|
return self.base_rect if self.sense == "home" else self._rect()
|
||||||
|
|
||||||
@@ -463,8 +588,9 @@ class Block(Trap):
|
|||||||
# `home` sensing tracks the resting cell as it rides along the parent.
|
# `home` sensing tracks the resting cell as it rides along the parent.
|
||||||
ox, oy = self._origin
|
ox, oy = self._origin
|
||||||
offx, offy = self._mount_off
|
offx, offy = self._mount_off
|
||||||
self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy),
|
self.base_rect = pygame.Rect(
|
||||||
self.tile, self.tile)
|
round(ox + offx), round(oy + offy), self.tile, self.tile
|
||||||
|
)
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self._reset_trigger()
|
self._reset_trigger()
|
||||||
@@ -475,13 +601,19 @@ class Block(Trap):
|
|||||||
self._origin = (0.0, 0.0)
|
self._origin = (0.0, 0.0)
|
||||||
self.prev = (self.x, self.y)
|
self.prev = (self.x, self.y)
|
||||||
self.dir = 1 # pingpong direction
|
self.dir = 1 # pingpong direction
|
||||||
self.phase = "rest" # once mode: rest|extending|extended|retracting
|
self.motion_phase = "rest" # once mode: rest|extending|extended|retracting
|
||||||
self._release_t = 0.0
|
self._release_t = 0.0
|
||||||
# index of the waypoint we're AT (once) / heading toward (patrol)
|
# 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.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0)
|
||||||
self.cstate = "solid" # crumble: solid|crumbling|gone
|
self.cstate = "solid" # crumble: solid|crumbling|gone
|
||||||
self.ctimer = 0.0
|
self.ctimer = 0.0
|
||||||
self.shake = 0.0
|
self.shake = 0.0
|
||||||
|
# phase: fade-in progress (0..1) and whether it's currently collidable.
|
||||||
|
self.alpha = 0.0
|
||||||
|
self.materialized = False
|
||||||
|
# Set the frame a block (crumble re-forming or phase forming) materialises
|
||||||
|
# into the player — lethal this frame. Crumble and phase are mutually
|
||||||
|
# exclusive in practice, so they share the flag.
|
||||||
self.emerge_kill = False
|
self.emerge_kill = False
|
||||||
|
|
||||||
def update(self, dt, game):
|
def update(self, dt, game):
|
||||||
@@ -491,8 +623,8 @@ class Block(Trap):
|
|||||||
# block executes its path/move relative to the parent it rides.
|
# block executes its path/move relative to the parent it rides.
|
||||||
if not self._mounted:
|
if not self._mounted:
|
||||||
self.prev = (self.x, self.y)
|
self.prev = (self.x, self.y)
|
||||||
active = self.triggered(game, dt) # call every frame to keep the timer live
|
|
||||||
if len(self.points) >= 2:
|
if len(self.points) >= 2:
|
||||||
|
active = self.triggered(game, dt, "motion")
|
||||||
step = self.speed * dt
|
step = self.speed * dt
|
||||||
if self.mode == "once":
|
if self.mode == "once":
|
||||||
self._update_once(active, dt, step)
|
self._update_once(active, dt, step)
|
||||||
@@ -500,13 +632,16 @@ class Block(Trap):
|
|||||||
self._update_patrol(active, step)
|
self._update_patrol(active, step)
|
||||||
if self.crumble:
|
if self.crumble:
|
||||||
self._update_crumble(dt, game)
|
self._update_crumble(dt, game)
|
||||||
|
if self.phase:
|
||||||
|
self._update_phase(dt, game)
|
||||||
|
|
||||||
def _update_crumble(self, dt, game):
|
def _update_crumble(self, dt, game):
|
||||||
self.emerge_kill = False
|
self.emerge_kill = False
|
||||||
r = self._rect()
|
r = self._rect()
|
||||||
p = game.player.rect
|
p = game.player.rect
|
||||||
on_top = (abs(p.bottom - r.top) <= 4
|
on_top = (
|
||||||
and p.right > r.left + 2 and p.left < r.right - 2)
|
abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2
|
||||||
|
)
|
||||||
if self.cstate == "solid":
|
if self.cstate == "solid":
|
||||||
if on_top:
|
if on_top:
|
||||||
self.cstate = "crumbling"
|
self.cstate = "crumbling"
|
||||||
@@ -528,6 +663,78 @@ class Block(Trap):
|
|||||||
self.ctimer = 0.0
|
self.ctimer = 0.0
|
||||||
self.shake = 0.0
|
self.shake = 0.0
|
||||||
|
|
||||||
|
# How deep the player may be into a forming phase block 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_phase(self, dt, game):
|
||||||
|
# Materialise while the `phase` trigger holds (fade alpha 0->1), fade
|
||||||
|
# back out when it releases. A block forming into the player is lethal;
|
||||||
|
# a non-deadly (solid) one first tries to shove a player who's only
|
||||||
|
# clipping an edge clear, while a deadly one simply kills.
|
||||||
|
self.emerge_kill = False
|
||||||
|
active = self.triggered(game, dt, "phase")
|
||||||
|
if active:
|
||||||
|
forming = self.alpha == 0.0 and not self.materialized
|
||||||
|
if (
|
||||||
|
forming
|
||||||
|
and not self.deadly
|
||||||
|
and game.player.rect.colliderect(self._rect())
|
||||||
|
and not self._eject_player(game)
|
||||||
|
):
|
||||||
|
self.emerge_kill = True
|
||||||
|
return
|
||||||
|
self.alpha = min(1.0, self.alpha + dt / self.fade)
|
||||||
|
self.materialized = True
|
||||||
|
else:
|
||||||
|
self.alpha = max(0.0, self.alpha - dt / self.fade)
|
||||||
|
if self.alpha == 0.0:
|
||||||
|
self.materialized = 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)."""
|
||||||
|
p = game.player.rect
|
||||||
|
b = self._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 materialised yet, so it's absent from solid_rects();
|
||||||
|
# any hit here is a *different* solid backing them — no room to dodge.
|
||||||
|
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):
|
||||||
|
# A phase block caught mid-fade (or forming into the player) snaps fully
|
||||||
|
# visible so the frozen death tableau shows the block that got them.
|
||||||
|
if self.phase and (self.emerge_kill or self.alpha > 0.0):
|
||||||
|
self.alpha = 1.0
|
||||||
|
self.materialized = True
|
||||||
|
|
||||||
def _step_to(self, tgt, step):
|
def _step_to(self, tgt, step):
|
||||||
"""Move toward tgt by step; snap and return True on arrival."""
|
"""Move toward tgt by step; snap and return True on arrival."""
|
||||||
tx, ty = tgt
|
tx, ty = tgt
|
||||||
@@ -546,27 +753,27 @@ class Block(Trap):
|
|||||||
# mid-stroke reversal, so a block that moves out of its own sensor range
|
# mid-stroke reversal, so a block that moves out of its own sensor range
|
||||||
# can't buzz. Hysteresis (delay/release) smooths the parked decisions.
|
# can't buzz. Hysteresis (delay/release) smooths the parked decisions.
|
||||||
n = len(self.points)
|
n = len(self.points)
|
||||||
if self.phase == "rest":
|
if self.motion_phase == "rest":
|
||||||
if active:
|
if active:
|
||||||
self.phase = "extending"
|
self.motion_phase = "extending"
|
||||||
elif self.phase == "extending":
|
elif self.motion_phase == "extending":
|
||||||
if self._step_to(self.points[self.idx + 1], step):
|
if self._step_to(self.points[self.idx + 1], step):
|
||||||
self.idx += 1
|
self.idx += 1
|
||||||
if self.idx >= n - 1:
|
if self.idx >= n - 1:
|
||||||
self.phase = "extended"
|
self.motion_phase = "extended"
|
||||||
self._release_t = 0.0
|
self._release_t = 0.0
|
||||||
elif self.phase == "extended":
|
elif self.motion_phase == "extended":
|
||||||
if active:
|
if active:
|
||||||
self._release_t = 0.0
|
self._release_t = 0.0
|
||||||
else:
|
else:
|
||||||
self._release_t += dt
|
self._release_t += dt
|
||||||
if self._release_t >= self.release:
|
if self._release_t >= self.release:
|
||||||
self.phase = "retracting"
|
self.motion_phase = "retracting"
|
||||||
elif self.phase == "retracting":
|
elif self.motion_phase == "retracting":
|
||||||
if self._step_to(self.points[self.idx - 1], step):
|
if self._step_to(self.points[self.idx - 1], step):
|
||||||
self.idx -= 1
|
self.idx -= 1
|
||||||
if self.idx <= 0:
|
if self.idx <= 0:
|
||||||
self.phase = "rest"
|
self.motion_phase = "rest"
|
||||||
|
|
||||||
def _update_patrol(self, active, step):
|
def _update_patrol(self, active, step):
|
||||||
tgt = self.points[self.idx] if active else self.points[0]
|
tgt = self.points[self.idx] if active else self.points[0]
|
||||||
@@ -590,23 +797,30 @@ class Block(Trap):
|
|||||||
|
|
||||||
def _rect(self):
|
def _rect(self):
|
||||||
ox, oy = self._origin
|
ox, oy = self._origin
|
||||||
return pygame.Rect(round(ox + self.x), round(oy + self.y),
|
return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile)
|
||||||
self.tile, self.tile)
|
|
||||||
|
|
||||||
def current_rect(self):
|
def current_rect(self):
|
||||||
return self._rect()
|
return self._rect()
|
||||||
|
|
||||||
def _intangible(self):
|
def _intangible(self):
|
||||||
return self.deadly or self.fake or (self.crumble and self.cstate == "gone")
|
if self.deadly or self.fake:
|
||||||
|
return True
|
||||||
|
if self.crumble and self.cstate == "gone":
|
||||||
|
return True
|
||||||
|
if self.phase and not self.materialized:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def solid_rects(self):
|
def solid_rects(self):
|
||||||
return [] if self._intangible() else [self._rect()]
|
return [] if self._intangible() else [self._rect()]
|
||||||
|
|
||||||
def hazard_rects(self):
|
def hazard_rects(self):
|
||||||
rects = []
|
rects = []
|
||||||
if self.deadly:
|
# A deadly block is a hazard whenever it's present — for a phase block
|
||||||
|
# that means only once it has materialised.
|
||||||
|
if self.deadly and (not self.phase or self.materialized):
|
||||||
rects.append(self._rect().inflate(-4, -4))
|
rects.append(self._rect().inflate(-4, -4))
|
||||||
if self.crumble and self.emerge_kill:
|
if self.emerge_kill: # crumble re-forming / phase forming into the player
|
||||||
rects.append(self._rect())
|
rects.append(self._rect())
|
||||||
return rects
|
return rects
|
||||||
|
|
||||||
@@ -630,10 +844,22 @@ class Block(Trap):
|
|||||||
if self.level.debug:
|
if self.level.debug:
|
||||||
self._debug_ghost(surface, assets, self.sprite)
|
self._debug_ghost(surface, assets, self.sprite)
|
||||||
return
|
return
|
||||||
|
# phase block still dormant: invisible (debug tints the cell so it shows).
|
||||||
|
if self.phase and self.alpha <= 0.0:
|
||||||
|
if self.level.debug:
|
||||||
|
self._debug_tint(surface, (120, 210, 240), alpha=45)
|
||||||
|
return
|
||||||
rect = self._rect()
|
rect = self._rect()
|
||||||
if self.crumble and self.cstate == "crumbling":
|
if self.crumble and self.cstate == "crumbling":
|
||||||
rect = rect.move(int(self.shake), 0)
|
rect = rect.move(int(self.shake), 0)
|
||||||
surface.blit(assets.get(self.sprite, self.tile, self.tile), self._rt(rect))
|
img = assets.get(self.sprite, self.tile, self.tile)
|
||||||
|
if self.phase and self.alpha < 1.0: # fade in/out
|
||||||
|
img = img.copy()
|
||||||
|
img.fill(
|
||||||
|
(255, 255, 255, int(255 * self.alpha)),
|
||||||
|
special_flags=pygame.BLEND_RGBA_MULT,
|
||||||
|
)
|
||||||
|
surface.blit(img, self._rt(rect))
|
||||||
if self.fake and self.level.debug:
|
if self.fake and self.level.debug:
|
||||||
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
|
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
|
||||||
|
|
||||||
@@ -654,6 +880,7 @@ class ArrowShooter(Trap):
|
|||||||
direction: up/down/left/right. speed: px/s. interval: seconds between shots.
|
direction: up/down/left/right. speed: px/s. interval: seconds between shots.
|
||||||
trigger: only fires while the condition holds (default ``always``).
|
trigger: only fires while the condition holds (default ``always``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, spec, level):
|
def __init__(self, spec, level):
|
||||||
super().__init__(spec, level)
|
super().__init__(spec, level)
|
||||||
self.direction = spec.get("direction", "left")
|
self.direction = spec.get("direction", "left")
|
||||||
@@ -676,10 +903,14 @@ class ArrowShooter(Trap):
|
|||||||
rect = pygame.Rect(0, 0, w, h)
|
rect = pygame.Rect(0, 0, w, h)
|
||||||
rect.center = r.center
|
rect.center = r.center
|
||||||
# nudge the arrow to the emitting edge
|
# nudge the arrow to the emitting edge
|
||||||
if dx == -1: rect.right = r.left
|
if dx == -1:
|
||||||
elif dx == 1: rect.left = r.right
|
rect.right = r.left
|
||||||
elif dy == -1: rect.bottom = r.top
|
elif dx == 1:
|
||||||
elif dy == 1: rect.top = r.bottom
|
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))
|
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
|
||||||
|
|
||||||
def update(self, dt, game):
|
def update(self, dt, game):
|
||||||
@@ -704,8 +935,9 @@ class ArrowShooter(Trap):
|
|||||||
return [a.rect for a in self.arrows]
|
return [a.rect for a in self.arrows]
|
||||||
|
|
||||||
def draw(self, surface, assets):
|
def draw(self, surface, assets):
|
||||||
surface.blit(assets.get("arrow_shooter", self.tile, self.tile),
|
surface.blit(
|
||||||
self._rt(self.base_rect))
|
assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect)
|
||||||
|
)
|
||||||
for a in self.arrows:
|
for a in self.arrows:
|
||||||
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
|
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
|
||||||
|
|
||||||
@@ -717,6 +949,7 @@ class Warp(Trap):
|
|||||||
destination and fades away, so the (otherwise invisible) teleport reads on
|
destination and fades away, so the (otherwise invisible) teleport reads on
|
||||||
screen. The level's ``debug`` flag tints it (and draws a line to its
|
screen. The level's ``debug`` flag tints it (and draws a line to its
|
||||||
destination) while designing."""
|
destination) while designing."""
|
||||||
|
|
||||||
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
|
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
|
||||||
|
|
||||||
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
|
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
|
||||||
@@ -763,8 +996,9 @@ class Warp(Trap):
|
|||||||
c.render(surface, assets)
|
c.render(surface, assets)
|
||||||
|
|
||||||
def _dest_rect(self):
|
def _dest_rect(self):
|
||||||
return pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
|
return pygame.Rect(
|
||||||
self.tile, self.tile)
|
self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile
|
||||||
|
)
|
||||||
|
|
||||||
def _draw_aura(self, surface, rect):
|
def _draw_aura(self, surface, rect):
|
||||||
# An expanding, fading glow centred on the cell. As the pulse decays the
|
# An expanding, fading glow centred on the cell. As the pulse decays the
|
||||||
@@ -788,121 +1022,22 @@ class Warp(Trap):
|
|||||||
if self.level.debug:
|
if self.level.debug:
|
||||||
self._debug_tint(surface, (210, 80, 235), alpha=90)
|
self._debug_tint(surface, (210, 80, 235), alpha=90)
|
||||||
dest = self._dest_rect()
|
dest = self._dest_rect()
|
||||||
pygame.draw.line(surface, (210, 80, 235),
|
pygame.draw.line(
|
||||||
self._rt(self.base_rect).center, self._rt(dest).center, 1)
|
surface,
|
||||||
|
(210, 80, 235),
|
||||||
|
self._rt(self.base_rect).center,
|
||||||
|
self._rt(dest).center,
|
||||||
|
1,
|
||||||
|
)
|
||||||
self._debug_tint(surface, (210, 80, 235), dest, 45)
|
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._rt(self.base_rect))
|
|
||||||
|
|
||||||
|
|
||||||
# --- registry + factory ------------------------------------------------------
|
# --- registry + factory ------------------------------------------------------
|
||||||
TRAP_TYPES = {
|
TRAP_TYPES = {
|
||||||
"spike": Spike,
|
"spike": Spike,
|
||||||
"block": Block,
|
"block": Block,
|
||||||
"arrow_shooter": ArrowShooter,
|
"arrow_shooter": ArrowShooter,
|
||||||
"warp": Warp,
|
"warp": Warp,
|
||||||
"phase_block": PhaseBlock,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest>=7.0
|
pytest>=7.0
|
||||||
|
black
|
||||||
|
|||||||
@@ -113,8 +113,13 @@ def test_slider_once_no_jitter_and_retracts():
|
|||||||
|
|
||||||
def test_sense_current_rides_down():
|
def test_sense_current_rides_down():
|
||||||
# A dropper (sense=current) commits to the bottom and holds while ridden.
|
# A dropper (sense=current) commits to the bottom and holds while ridden.
|
||||||
b = block(at=[21, 9], move=[0, 4], speed=220, sense="current",
|
b = block(
|
||||||
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]})
|
at=[21, 9],
|
||||||
|
move=[0, 4],
|
||||||
|
speed=220,
|
||||||
|
sense="current",
|
||||||
|
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]},
|
||||||
|
)
|
||||||
# player standing on top of it
|
# player standing on top of it
|
||||||
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
|
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
|
||||||
g = FakeGame(p)
|
g = FakeGame(p)
|
||||||
@@ -131,7 +136,7 @@ def test_carriers_reports_motion():
|
|||||||
b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120)
|
b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120)
|
||||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||||
b.update(1 / 60, g)
|
b.update(1 / 60, g)
|
||||||
(rect, dx, dy), = b.carriers()
|
((rect, dx, dy),) = b.carriers()
|
||||||
assert dx != 0 and dy == 0 # moving horizontally
|
assert dx != 0 and dy == 0 # moving horizontally
|
||||||
|
|
||||||
|
|
||||||
@@ -147,8 +152,12 @@ def test_expand_line():
|
|||||||
|
|
||||||
|
|
||||||
def test_expand_grid_with_spacing():
|
def test_expand_grid_with_spacing():
|
||||||
ats = [s["at"] for s in expand_spec(
|
ats = [
|
||||||
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})]
|
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]]
|
assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]]
|
||||||
# count/spacing are stripped from each expanded spec
|
# count/spacing are stripped from each expanded spec
|
||||||
for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}):
|
for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}):
|
||||||
@@ -173,4 +182,9 @@ traps:
|
|||||||
blocks = [t for t in lvl.traps if isinstance(t, Block)]
|
blocks = [t for t in lvl.traps if isinstance(t, Block)]
|
||||||
assert len(blocks) == 4
|
assert len(blocks) == 4
|
||||||
assert all(b.deadly for b in blocks)
|
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]
|
assert sorted(b.current_rect().x for b in blocks) == [
|
||||||
|
2 * 32,
|
||||||
|
3 * 32,
|
||||||
|
4 * 32,
|
||||||
|
5 * 32,
|
||||||
|
]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from conftest import step, run, place, hold, DT
|
|||||||
from game import settings as S
|
from game import settings as S
|
||||||
from game.player import InputState
|
from game.player import InputState
|
||||||
|
|
||||||
|
|
||||||
SIMPLE = """
|
SIMPLE = """
|
||||||
name: My Level
|
name: My Level
|
||||||
tile_size: 32
|
tile_size: 32
|
||||||
@@ -62,6 +61,7 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
|
|||||||
a.write_text(SIMPLE)
|
a.write_text(SIMPLE)
|
||||||
b.write_text(SIMPLE.replace("My Level", "Two"))
|
b.write_text(SIMPLE.replace("My Level", "Two"))
|
||||||
from game.game import Game
|
from game.game import Game
|
||||||
|
|
||||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||||
|
|
||||||
def die():
|
def die():
|
||||||
@@ -69,7 +69,9 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
|
|||||||
while g.state == "dying":
|
while g.state == "dying":
|
||||||
step(g)
|
step(g)
|
||||||
|
|
||||||
die(); die(); die()
|
die()
|
||||||
|
die()
|
||||||
|
die()
|
||||||
assert g.level_deaths == 3 and g.deaths == 3
|
assert g.level_deaths == 3 and g.deaths == 3
|
||||||
g._advance()
|
g._advance()
|
||||||
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
|
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
|
||||||
@@ -95,6 +97,7 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
|
|||||||
a.write_text(SIMPLE)
|
a.write_text(SIMPLE)
|
||||||
b.write_text(SIMPLE.replace("My Level", "Two"))
|
b.write_text(SIMPLE.replace("My Level", "Two"))
|
||||||
from game.game import Game
|
from game.game import Game
|
||||||
|
|
||||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||||
g.state = "won_level"
|
g.state = "won_level"
|
||||||
g._start_fade(g._advance)
|
g._start_fade(g._advance)
|
||||||
@@ -116,26 +119,39 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
|
|||||||
def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch):
|
def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch):
|
||||||
a = tmp_path / "a.yaml"
|
a = tmp_path / "a.yaml"
|
||||||
b = tmp_path / "b.yaml"
|
b = tmp_path / "b.yaml"
|
||||||
a.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") # 3 rows
|
a.write_text(
|
||||||
b.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n") # 4 rows
|
"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
|
from game.game import Game
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
orig = pygame.display.set_mode
|
orig = pygame.display.set_mode
|
||||||
monkeypatch.setattr(pygame.display, "set_mode",
|
monkeypatch.setattr(
|
||||||
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k))
|
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 = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||||
g._advance(); g._replay()
|
g._advance()
|
||||||
|
g._replay()
|
||||||
assert len(calls) == 1 # never recreated the window
|
assert len(calls) == 1 # never recreated the window
|
||||||
# sized to the tallest level (4 rows + HUD)
|
# sized to the tallest level (4 rows + HUD)
|
||||||
from game.game import HUD_H
|
from game.game import HUD_H
|
||||||
|
|
||||||
assert g.win_h == 4 * 32 + HUD_H
|
assert g.win_h == 4 * 32 + HUD_H
|
||||||
|
|
||||||
|
|
||||||
def test_debug_view_adds_overscan_margin(tmp_path):
|
def test_debug_view_adds_overscan_margin(tmp_path):
|
||||||
from game.game import Game, HUD_H
|
from game.game import Game, HUD_H
|
||||||
from game import settings as S
|
from game import settings as S
|
||||||
|
|
||||||
p = tmp_path / "d.yaml"
|
p = tmp_path / "d.yaml"
|
||||||
p.write_text("name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n")
|
p.write_text(
|
||||||
|
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n"
|
||||||
|
)
|
||||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||||
m = S.DEBUG_VIEW_MARGIN * 32
|
m = S.DEBUG_VIEW_MARGIN * 32
|
||||||
assert m > 0
|
assert m > 0
|
||||||
@@ -148,6 +164,7 @@ def test_debug_view_adds_overscan_margin(tmp_path):
|
|||||||
|
|
||||||
def test_no_overscan_without_debug(tmp_path):
|
def test_no_overscan_without_debug(tmp_path):
|
||||||
from game.game import Game
|
from game.game import Game
|
||||||
|
|
||||||
p = tmp_path / "n.yaml"
|
p = tmp_path / "n.yaml"
|
||||||
p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||||
@@ -169,14 +186,17 @@ def test_cli_debug_forces_all_levels(make_game):
|
|||||||
|
|
||||||
def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
|
def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
|
||||||
from game.game import Game
|
from game.game import Game
|
||||||
|
|
||||||
p = tmp_path / "hot.yaml"
|
p = tmp_path / "hot.yaml"
|
||||||
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||||
assert g.level.name == "A" and len(g.level.traps) == 0
|
assert g.level.name == "A" and len(g.level.traps) == 0
|
||||||
d0 = g.deaths
|
d0 = g.deaths
|
||||||
# edit the file on disk, then F5
|
# edit the file on disk, then F5
|
||||||
p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
|
p.write_text(
|
||||||
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n")
|
"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()
|
g._reload_level()
|
||||||
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
|
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
|
||||||
while g.state == "dying":
|
while g.state == "dying":
|
||||||
@@ -187,6 +207,7 @@ def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
|
|||||||
|
|
||||||
def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
|
def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
|
||||||
from game.game import Game
|
from game.game import Game
|
||||||
|
|
||||||
p = tmp_path / "bad.yaml"
|
p = tmp_path / "bad.yaml"
|
||||||
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||||
@@ -201,7 +222,10 @@ def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
|
|||||||
def test_debug_grid_adds_pixels(make_level):
|
def test_debug_grid_adds_pixels(make_level):
|
||||||
import pygame
|
import pygame
|
||||||
from game.assets import AssetStore
|
from game.assets import AssetStore
|
||||||
lvl = make_level("name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n")
|
|
||||||
|
lvl = make_level(
|
||||||
|
"name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n"
|
||||||
|
)
|
||||||
a = AssetStore("noassets")
|
a = AssetStore("noassets")
|
||||||
|
|
||||||
def painted(dbg):
|
def painted(dbg):
|
||||||
@@ -209,6 +233,11 @@ def test_debug_grid_adds_pixels(make_level):
|
|||||||
w = pygame.Surface((lvl.width, lvl.height))
|
w = pygame.Surface((lvl.width, lvl.height))
|
||||||
w.fill((0, 0, 0))
|
w.fill((0, 0, 0))
|
||||||
lvl.draw(w, a)
|
lvl.draw(w, a)
|
||||||
return sum(1 for y in range(lvl.height) for x in range(lvl.width)
|
return sum(
|
||||||
if w.get_at((x, y))[:3] != (0, 0, 0))
|
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
|
assert painted(True) > painted(False) # grid + labels add ink
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from conftest import step, DT
|
from conftest import step, DT
|
||||||
from game.traps import Block, Spike
|
from game.traps import Block, Spike
|
||||||
|
|
||||||
|
|
||||||
PLATFORM_WITH_SPIKE = """
|
PLATFORM_WITH_SPIKE = """
|
||||||
name: t
|
name: t
|
||||||
tile_size: 32
|
tile_size: 32
|
||||||
@@ -74,8 +73,10 @@ traps:
|
|||||||
for m in plat.mounts:
|
for m in plat.mounts:
|
||||||
assert m._mounted and m.deadly
|
assert m._mounted and m.deadly
|
||||||
# each deadly mount tracks the platform at its offset and is lethal
|
# each deadly mount tracks the platform at its offset and is lethal
|
||||||
exp = (plat.current_rect().x + m._mount_off[0],
|
exp = (
|
||||||
plat.current_rect().y + m._mount_off[1])
|
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.current_rect().x, m.current_rect().y) == exp
|
||||||
assert m.hazard_rects()
|
assert m.hazard_rects()
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def test_variable_jump_height(make_game):
|
|||||||
step(g, hold(jump_pressed=(i == 0), jump_held=held))
|
step(g, hold(jump_pressed=(i == 0), jump_held=held))
|
||||||
hi = min(hi, g.player.rect.bottom)
|
hi = min(hi, g.player.rect.bottom)
|
||||||
return ground - hi
|
return ground - hi
|
||||||
|
|
||||||
assert peak(60) > peak(1) + 8
|
assert peak(60) > peak(1) + 8
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Spike, arrow_shooter, warp, phase_block + the invisible flag & block arrays."""
|
"""Spike, arrow_shooter, warp, phase blocks + the invisible flag & block arrays."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import pygame
|
import pygame
|
||||||
from conftest import (FakeGame, step, run, place, hold, DT)
|
from conftest import FakeGame, step, run, place, hold, DT
|
||||||
from game.assets import AssetStore
|
from game.assets import AssetStore
|
||||||
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
|
from game.traps import Spike, ArrowShooter, Warp, Block
|
||||||
|
|
||||||
|
|
||||||
# --- spike -------------------------------------------------------------------
|
# --- spike -------------------------------------------------------------------
|
||||||
@@ -62,6 +62,7 @@ def test_spike_sprite_rotates_per_direction(tmp_path):
|
|||||||
|
|
||||||
def h(s):
|
def h(s):
|
||||||
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
|
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
|
||||||
|
|
||||||
assert h(up) != h(down) # rotation actually happened
|
assert h(up) != h(down) # rotation actually happened
|
||||||
assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
|
assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
|
||||||
|
|
||||||
@@ -210,17 +211,18 @@ map: |
|
|||||||
#P......G#
|
#P......G#
|
||||||
##########
|
##########
|
||||||
traps:
|
traps:
|
||||||
- type: phase_block
|
- type: block
|
||||||
at: [4, 3]
|
at: [4, 3]
|
||||||
|
phase: true
|
||||||
fade: 0.2
|
fade: 0.2
|
||||||
trigger: { within: 2 }
|
trigger: { within: 2 }
|
||||||
""")
|
""")
|
||||||
pb = g.level.traps[0]
|
pb = g.level.traps[0]
|
||||||
place(g, 1, 3)
|
place(g, 1, 3)
|
||||||
step(g)
|
step(g)
|
||||||
assert not pb.solid and pb.alpha == 0 # dormant far away
|
assert not pb.materialized and pb.alpha == 0 # dormant far away
|
||||||
run(g, 40, lambda i: hold(right=True))
|
run(g, 40, lambda i: hold(right=True))
|
||||||
assert pb.solid and pb.alpha > 0 # phased in solid on approach
|
assert pb.materialized and pb.alpha > 0 # phased in solid on approach
|
||||||
|
|
||||||
|
|
||||||
def test_phase_block_kills_if_inside_when_it_forms(make_game):
|
def test_phase_block_kills_if_inside_when_it_forms(make_game):
|
||||||
@@ -233,8 +235,9 @@ map: |
|
|||||||
#.....G#
|
#.....G#
|
||||||
########
|
########
|
||||||
traps:
|
traps:
|
||||||
- type: phase_block
|
- type: block
|
||||||
at: [3, 2]
|
at: [3, 2]
|
||||||
|
phase: true
|
||||||
fade: 0.2
|
fade: 0.2
|
||||||
trigger: { within: 3 }
|
trigger: { within: 3 }
|
||||||
""")
|
""")
|
||||||
@@ -260,8 +263,9 @@ map: |
|
|||||||
#......G#
|
#......G#
|
||||||
#########
|
#########
|
||||||
traps:
|
traps:
|
||||||
- type: phase_block
|
- type: block
|
||||||
at: [4, 1]
|
at: [4, 1]
|
||||||
|
phase: true
|
||||||
fade: 0.2
|
fade: 0.2
|
||||||
trigger: { within: 5 }
|
trigger: { within: 5 }
|
||||||
""")
|
""")
|
||||||
@@ -277,7 +281,7 @@ traps:
|
|||||||
# survived, and pushed out to the left so it no longer overlaps the cell
|
# survived, and pushed out to the left so it no longer overlaps the cell
|
||||||
assert g.deaths == d0
|
assert g.deaths == d0
|
||||||
assert g.player.rect.right <= b.left
|
assert g.player.rect.right <= b.left
|
||||||
assert pb.solid and not pb.emerge_kill
|
assert pb.materialized and not pb.emerge_kill
|
||||||
|
|
||||||
|
|
||||||
def test_phase_block_kills_when_shove_would_squish(make_game):
|
def test_phase_block_kills_when_shove_would_squish(make_game):
|
||||||
@@ -291,8 +295,9 @@ map: |
|
|||||||
#.....G#
|
#.....G#
|
||||||
########
|
########
|
||||||
traps:
|
traps:
|
||||||
- type: phase_block
|
- type: block
|
||||||
at: [1, 1]
|
at: [1, 1]
|
||||||
|
phase: true
|
||||||
fade: 0.2
|
fade: 0.2
|
||||||
trigger: { within: 5 }
|
trigger: { within: 5 }
|
||||||
""")
|
""")
|
||||||
@@ -325,8 +330,9 @@ map: |
|
|||||||
#.....G#
|
#.....G#
|
||||||
########
|
########
|
||||||
traps:
|
traps:
|
||||||
- type: phase_block
|
- type: block
|
||||||
at: [3, 2]
|
at: [3, 2]
|
||||||
|
phase: true
|
||||||
fade: 0.5
|
fade: 0.5
|
||||||
trigger: { within: 3 }
|
trigger: { within: 3 }
|
||||||
""")
|
""")
|
||||||
@@ -336,16 +342,55 @@ traps:
|
|||||||
step(g)
|
step(g)
|
||||||
assert 0 < pb.alpha < 1
|
assert 0 < pb.alpha < 1
|
||||||
g._start_death() # die from something
|
g._start_death() # die from something
|
||||||
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
|
assert pb.alpha == 1.0 and pb.materialized # snapped fully visible for the freeze
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadly_phase_block_harmless_until_materialized(make_game):
|
||||||
|
# A deadly phase block is neither solid nor a hazard while dormant, then
|
||||||
|
# becomes a (never-solid) hazard once it materialises.
|
||||||
|
g = make_game("""
|
||||||
|
name: t
|
||||||
|
tile_size: 32
|
||||||
|
map: |
|
||||||
|
##########
|
||||||
|
#........#
|
||||||
|
#........#
|
||||||
|
#P......G#
|
||||||
|
##########
|
||||||
|
traps:
|
||||||
|
- type: block
|
||||||
|
at: [4, 2]
|
||||||
|
phase: true
|
||||||
|
deadly: true
|
||||||
|
fade: 0.15
|
||||||
|
trigger: { within: 3 }
|
||||||
|
""")
|
||||||
|
b = g.level.traps[0]
|
||||||
|
place(g, 1, 3) # far away -> dormant
|
||||||
|
step(g)
|
||||||
|
assert not b.solid_rects() and not b.hazard_rects()
|
||||||
|
# Materialise it by standing on the cell; a deadly block never becomes solid.
|
||||||
|
place(g, 4, 2)
|
||||||
|
d0 = g.deaths
|
||||||
|
killed = False
|
||||||
|
for _ in range(12):
|
||||||
|
step(g)
|
||||||
|
if g.deaths > d0:
|
||||||
|
killed = True
|
||||||
|
break
|
||||||
|
assert killed # the materialised hazard kills
|
||||||
|
assert not b.solid_rects() # deadly -> never solid, even materialised
|
||||||
|
|
||||||
|
|
||||||
def test_debug_overscan_reveals_offmap_trap(make_level):
|
def test_debug_overscan_reveals_offmap_trap(make_level):
|
||||||
# In debug the play surface is enlarged and shifted (render_offset) so a trap
|
# In debug the play surface is enlarged and shifted (render_offset) so a trap
|
||||||
# placed just off the map is drawn into the overscan instead of being clipped.
|
# placed just off the map is drawn into the overscan instead of being clipped.
|
||||||
from game import settings as S
|
from game import settings as S
|
||||||
|
|
||||||
lvl = make_level(
|
lvl = make_level(
|
||||||
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n"
|
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n"
|
||||||
"traps:\n - type: block\n at: [5, 1]\n deadly: true\n") # col 5 = off-map
|
"traps:\n - type: block\n at: [5, 1]\n deadly: true\n"
|
||||||
|
) # col 5 = off-map
|
||||||
a = AssetStore("noassets")
|
a = AssetStore("noassets")
|
||||||
m = S.DEBUG_VIEW_MARGIN * lvl.tile
|
m = S.DEBUG_VIEW_MARGIN * lvl.tile
|
||||||
lvl.render_offset = (m, m)
|
lvl.render_offset = (m, m)
|
||||||
@@ -384,8 +429,11 @@ traps:
|
|||||||
w = pygame.Surface((lvl.width, lvl.height))
|
w = pygame.Surface((lvl.width, lvl.height))
|
||||||
w.fill((0, 0, 0))
|
w.fill((0, 0, 0))
|
||||||
sp.render(w, a)
|
sp.render(w, a)
|
||||||
return any(w.get_at((x, y))[:3] != (0, 0, 0)
|
return any(
|
||||||
for y in range(32, 96) for x in range(32, 96))
|
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(False) is False # invisible in play
|
||||||
assert painted(True) is True # revealed in debug
|
assert painted(True) is True # revealed in debug
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ from game.traps import make_condition
|
|||||||
|
|
||||||
|
|
||||||
def ev(spec, px, py, w=20, h=28, dt=0.0):
|
def ev(spec, px, py, w=20, h=28, dt=0.0):
|
||||||
trap = type("T", (), {"tile": 32,
|
trap = type(
|
||||||
"sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)})()
|
"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)
|
return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt)
|
||||||
|
|
||||||
|
|
||||||
# trap centre = (116, 116); left100 right132 top100 bottom132
|
# trap centre = (116, 116); left100 right132 top100 bottom132
|
||||||
|
|
||||||
|
|
||||||
@@ -67,8 +70,9 @@ def test_all_and_any():
|
|||||||
|
|
||||||
def test_timer_cycles():
|
def test_timer_cycles():
|
||||||
c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}})
|
c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}})
|
||||||
trap = type("T", (), {"tile": 32,
|
trap = type(
|
||||||
"sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)})()
|
"T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)}
|
||||||
|
)()
|
||||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||||
states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)]
|
states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)]
|
||||||
assert states[0] is False # starts in the "off" interval
|
assert states[0] is False # starts in the "off" interval
|
||||||
@@ -77,6 +81,7 @@ def test_timer_cycles():
|
|||||||
|
|
||||||
def test_bad_condition_raises():
|
def test_bad_condition_raises():
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
make_condition({"nope": 1})
|
make_condition({"nope": 1})
|
||||||
|
|
||||||
@@ -104,3 +109,121 @@ traps:
|
|||||||
armed = i
|
armed = i
|
||||||
break
|
break
|
||||||
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
|
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-target triggers/delays (a block can move AND phase) -----------------
|
||||||
|
def _block(make_level, body):
|
||||||
|
lvl = make_level(
|
||||||
|
"name: t\ntile_size: 32\nmap: |\n ######\n #P..G#\n ######\ntraps:\n" + body
|
||||||
|
)
|
||||||
|
return lvl.traps[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _cond(block, target):
|
||||||
|
return type(block._trig[target]["cond"]).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_single_condition_applies_to_all_targets(make_level):
|
||||||
|
# A bare condition (not a target map) drives both motion and phase.
|
||||||
|
b = _block(
|
||||||
|
make_level, " - type: block\n at: [2, 1]\n trigger: { within: 2 }\n"
|
||||||
|
)
|
||||||
|
assert _cond(b, "motion") == "_Within"
|
||||||
|
assert _cond(b, "phase") == "_Within"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_all_any_is_a_single_condition(make_level):
|
||||||
|
# `all`/`any` are condition keywords, not target names -> one condition.
|
||||||
|
b = _block(
|
||||||
|
make_level,
|
||||||
|
" - type: block\n at: [2, 1]\n"
|
||||||
|
" trigger: { any: [ { within: 2 }, { dir: left } ] }\n",
|
||||||
|
)
|
||||||
|
assert _cond(b, "motion") == "_Any" and _cond(b, "phase") == "_Any"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_per_target_map_with_default(make_level):
|
||||||
|
# `default` covers unnamed targets; a named target overrides it.
|
||||||
|
b = _block(
|
||||||
|
make_level,
|
||||||
|
" - type: block\n at: [2, 1]\n phase: true\n"
|
||||||
|
" trigger:\n default: always\n motion: { within: 5 }\n",
|
||||||
|
)
|
||||||
|
assert _cond(b, "motion") == "_Within" # named
|
||||||
|
assert _cond(b, "phase") == "_Always" # from default
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_unnamed_target_off_without_default(make_level):
|
||||||
|
# A map naming only motion (no default) leaves phase with no trigger -> off.
|
||||||
|
b = _block(
|
||||||
|
make_level,
|
||||||
|
" - type: block\n at: [2, 1]\n phase: true\n"
|
||||||
|
" trigger: { motion: always }\n",
|
||||||
|
)
|
||||||
|
assert "phase" in b._trig_never
|
||||||
|
assert _cond(b, "phase") == "_Never" and _cond(b, "motion") == "_Always"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_unknown_target_raises(make_level):
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_block(
|
||||||
|
make_level,
|
||||||
|
" - type: block\n at: [2, 1]\n"
|
||||||
|
" trigger: { motion: always, bogus: always }\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delay_scalar_applies_to_all_targets(make_level):
|
||||||
|
b = _block(make_level, " - type: block\n at: [2, 1]\n delay: 0.2\n")
|
||||||
|
assert b._trig["motion"]["delay"] == 0.2 and b._trig["phase"]["delay"] == 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_delay_per_target_map(make_level):
|
||||||
|
b = _block(
|
||||||
|
make_level,
|
||||||
|
" - type: block\n at: [2, 1]\n delay: { motion: 0.1, phase: 0.3 }\n",
|
||||||
|
)
|
||||||
|
assert b._trig["motion"]["delay"] == 0.1 and b._trig["phase"]["delay"] == 0.3
|
||||||
|
# unnamed-without-default delay falls back to 0 (no arm delay)
|
||||||
|
b2 = _block(
|
||||||
|
make_level, " - type: block\n at: [2, 1]\n delay: { motion: 0.1 }\n"
|
||||||
|
)
|
||||||
|
assert b2._trig["phase"]["delay"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_motion_and_phase_independent_triggers(make_level):
|
||||||
|
# The user's case: a phase block that's *always* moving but only materialises
|
||||||
|
# when the player is near. Motion and phase read their own triggers.
|
||||||
|
lvl = make_level("""
|
||||||
|
name: t
|
||||||
|
tile_size: 32
|
||||||
|
map: |
|
||||||
|
############
|
||||||
|
#..........#
|
||||||
|
#P........G#
|
||||||
|
############
|
||||||
|
traps:
|
||||||
|
- type: block
|
||||||
|
at: [4, 1]
|
||||||
|
phase: true
|
||||||
|
move: [3, 0]
|
||||||
|
speed: 200
|
||||||
|
fade: 0.2
|
||||||
|
trigger:
|
||||||
|
motion: always
|
||||||
|
phase: { within: 2 }
|
||||||
|
""")
|
||||||
|
b = lvl.traps[0]
|
||||||
|
b.reset()
|
||||||
|
far = FakeGame(pygame.Rect(1 * 32, 1 * 32, 20, 28)) # player at the far end
|
||||||
|
x0 = b._rect().x
|
||||||
|
for _ in range(20):
|
||||||
|
b.tick(1 / 60, far)
|
||||||
|
assert b._rect().x != x0 # moved (motion: always) ...
|
||||||
|
assert b.alpha == 0.0 # ... but hasn't materialised (player far)
|
||||||
|
near = FakeGame(pygame.Rect(b._rect().x + 2, 1 * 32, 20, 28))
|
||||||
|
for _ in range(10):
|
||||||
|
b.tick(1 / 60, near)
|
||||||
|
assert b.alpha > 0.0 # phases in once the player is close
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ def draw_player_dead(s):
|
|||||||
for ex in (11, 17): # X eyes
|
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, 9), (ex + 4, 14), 2)
|
||||||
pygame.draw.line(s, (225, 85, 85), (ex + 4, 9), (ex, 14), 2)
|
pygame.draw.line(s, (225, 85, 85), (ex + 4, 9), (ex, 14), 2)
|
||||||
pygame.draw.lines(s, (200, 205, 215), False,
|
pygame.draw.lines(
|
||||||
[(9, 8), (14, 13), (12, 18), (21, 21)], 1) # crack
|
s, (200, 205, 215), False, [(9, 8), (14, 13), (12, 18), (21, 21)], 1
|
||||||
|
) # crack
|
||||||
|
|
||||||
|
|
||||||
def draw_goal(s):
|
def draw_goal(s):
|
||||||
@@ -95,15 +96,18 @@ def draw_patrol_block(s):
|
|||||||
pygame.draw.rect(s, (80, 65, 125), (0, 0, TILE, TILE), 2)
|
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
|
pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight
|
||||||
for off in (0, 9): # motion chevrons
|
for off in (0, 9): # motion chevrons
|
||||||
pygame.draw.lines(s, (92, 76, 142), False,
|
pygame.draw.lines(
|
||||||
[(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2)
|
s, (92, 76, 142), False, [(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def draw_crumble_block(s):
|
def draw_crumble_block(s):
|
||||||
s.fill((166, 136, 96))
|
s.fill((166, 136, 96))
|
||||||
pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2)
|
pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2)
|
||||||
cr = (112, 86, 56)
|
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, [(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.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, (2, 14), (9, 16), 1)
|
||||||
pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1)
|
pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1)
|
||||||
@@ -129,7 +133,11 @@ def draw_spike_block(s):
|
|||||||
[(2, 2), (13, 6), (6, 13)], # up-left
|
[(2, 2), (13, 6), (6, 13)], # up-left
|
||||||
[(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right
|
[(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right
|
||||||
[(2, TILE - 2), (13, TILE - 6), (6, TILE - 13)], # down-left
|
[(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
|
[
|
||||||
|
(TILE - 2, TILE - 2),
|
||||||
|
(TILE - 13, TILE - 6),
|
||||||
|
(TILE - 6, TILE - 13),
|
||||||
|
], # down-right
|
||||||
]
|
]
|
||||||
for t in tris:
|
for t in tris:
|
||||||
pygame.draw.polygon(s, base, t)
|
pygame.draw.polygon(s, base, t)
|
||||||
@@ -144,8 +152,7 @@ def draw_phase_block(s):
|
|||||||
pygame.draw.rect(s, (120, 210, 240), (0, 0, TILE, TILE), 2)
|
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), (4, 4), (TILE - 5, TILE - 5), 1)
|
||||||
pygame.draw.line(s, (150, 230, 255), (TILE - 5, 4), (4, TILE - 5), 1)
|
pygame.draw.line(s, (150, 230, 255), (TILE - 5, 4), (4, TILE - 5), 1)
|
||||||
pygame.draw.polygon(s, (150, 230, 255),
|
pygame.draw.polygon(s, (150, 230, 255), [(16, 4), (28, 16), (16, 28), (4, 16)], 1)
|
||||||
[(16, 4), (28, 16), (16, 28), (4, 16)], 1)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_arrow(s):
|
def draw_arrow(s):
|
||||||
|
|||||||
Reference in New Issue
Block a user