Make phase a block property rather than a distinct type

This commit is contained in:
James Campbell
2026-08-02 00:03:58 -04:00
parent a17a68a07e
commit 7b7b005823
5 changed files with 515 additions and 201 deletions

79
SPEC.md
View File

@@ -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
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
@@ -354,20 +385,21 @@ holds.
### 13.2 `block` — the all-in-one block
One trap covering stationary blocks, sliders, patrolling platforms, spike
blocks, fake blocks, and crumbling blocks. Options combine.
blocks, fake blocks, crumbling blocks, and phase blocks. Options combine.
| Option | Meaning |
|---|---|
| `path: [[col,row],…]` | waypoints it travels between (default `[at]` = stationary) |
| `move: [dcol,drow]` | shorthand for a 2-point path `[at, at+move]` (a slider) |
| `mode` | `once` (default): extend to the last waypoint while triggered, retreat to the first when not — slider/dropper. `loop` / `pingpong`: cycle the whole path continuously — a patrol. |
| `trigger` | when it moves (default `always`); a patrol is a path + `always` |
| `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) |
| `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) |
| `crumble` (bool) + `crumble_delay`, `respawn` | gives way `crumble_delay` s after you stand on it, vanishes, then re-forms after `respawn` s (killing you if you're standing where it re-forms) |
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) |
| `delay` / `release` | (`once`) hysteresis: hold `delay` s to start extending, be clear `release` s (default 0.1) to start retracting |
| `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 |
| `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) |
Behavior notes:
@@ -375,11 +407,24 @@ Behavior notes:
endpoint without reversing, and only reconsiders its trigger while parked at
an endpoint. Combined with `home` sensing, this eliminates the jitter a
slider would otherwise get from moving out of its own sensor range.
- A non-deadly, non-fake block is a **solid** that reports itself as a
**carrier** so the player rides it; a moving block can **shove** or **crush**
the player (§5.2, §5.6).
- Movement runs first each frame, then the crumble state machine, so a moving
platform can also crumble.
- A non-deadly, non-fake, non-phasing (or materialised) block is a **solid**
that reports itself as a **carrier** so the player rides it; a moving block
can **shove** or **crush** the player (§5.2, §5.6).
- Movement, crumble, and phasing run in that order each frame, so a block can
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`
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
itself is invisible.
### 13.5 `phase_block`
Invisible and intangible until its `trigger` fires, then it fades into a solid
over `fade` seconds (alpha 0→1) and fades back out when the trigger releases. If
the player overlaps the cell the instant it *starts* forming, it's forgiving
about edge clips: when they're only clipping an edge (overlap ≤ half a tile on
the shallowest axis) it shoves them out of the cell and solidifies behind them.
It stays lethal only when the block forms through the player's middle (a deep
overlap) or the shove would press them into another solid (squished against
something — a crush, as usual). In the lethal case it stays intangible that frame
so they're killed rather than displaced. If the player dies while it is mid-fade,
it snaps fully visible before the death freeze (via the `finalize_on_death`
hook — see §7/§11).
(A *phase block* — invisible/intangible until triggered, then fades into a solid
or hazard — is not a separate type: it's the `block` option `phase: true`, §13.2.)
---