diff --git a/README.md b/README.md index bdd4e7e..8bb6a85 100644 --- a/README.md +++ b/README.md @@ -104,29 +104,30 @@ Any trap can also take: | `type` | Key parameters | Behavior | |-----------------|----------------|----------| | `spike` | `direction` (`up`/`down`/`left`/`right`), `trigger`, `delay` | Deadly spike; active while its `trigger` holds. The sprite auto-rotates to point the way `direction` faces. | -| `block` | see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, and/or crumbling. | +| `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. | | `warp` | `to` [col,row] | Invisible tile that teleports the player to `to` on contact. Re-arms once you leave it. | -| `phase_block` | `trigger`, `fade` | Invisible and intangible until its `trigger` fires, then fades into a solid over `fade` seconds (and fades back out when the trigger releases). If you're standing in the cell the instant it *starts* appearing, you die — and if you die while one is mid-fade it snaps fully visible for the death freeze. | ### The `block` trap One trap covers stationary blocks, sliders, patrolling platforms, spike blocks, -fake blocks, and crumbling blocks — compose the behaviour from options (which -combine, e.g. a moving platform that crumbles): +fake blocks, crumbling blocks, and phase blocks — compose the behaviour from +options (which combine, e.g. a moving platform that crumbles, or a phase block +that's always moving): | option | meaning | |---|---| | `path` [[col,row],…] | waypoints it travels between (default just `[at]` = stationary) | | `move` [dcol,drow] | shorthand for a 2-point path `[at, at+move]` (a slider) | | `mode` | `once` (default): advance to the last point while triggered, retreat to the first when not — the slider/dropper. `loop` / `pingpong`: cycle the whole path continuously (a patrol). | -| `trigger` | when it moves (default `always`). A patrol is just a path + the default `always` trigger. | -| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid | +| `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. On a phase block the hazard is live only once materialised. | | `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) | +| `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 | -| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) | -| `delay` / `release` | (`once`) hysteresis: the trigger must hold `delay` s to start extending and be clear `release` s (default 0.1) to start retracting — stops boundary jitter | +| `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); 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 | ```yaml @@ -145,6 +146,15 @@ combine, e.g. a moving platform that crumbles): path: [[9, 10], [14, 10], [14, 6]] mode: loop 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 @@ -183,6 +193,21 @@ trigger: *continuously* this long before the trap arms; leaving the condition resets the 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 You also die if a moving (non-deadly) `block` **pinches** you: presses you diff --git a/SPEC.md b/SPEC.md index af5310f..32bacbd 100644 --- a/SPEC.md +++ b/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 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.) --- diff --git a/game/traps.py b/game/traps.py index b9d851b..5864975 100644 --- a/game/traps.py +++ b/game/traps.py @@ -45,6 +45,17 @@ class _Always: 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: """Player within N tiles of the trap centre (Euclidean radius).""" @@ -181,6 +192,48 @@ def make_condition(spec): 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: def __init__(self, spec, level): self.spec = spec @@ -340,23 +393,44 @@ class Trap: # --- triggers ------------------------------------------------------------ # Traps with an activation condition call _init_trigger() in __init__, - # _reset_trigger() in reset(), and triggered() each frame. - def _init_trigger(self, spec): - self.trigger = make_condition(spec.get("trigger", "always")) - self.trig_delay = float(spec.get("delay", 0.0)) # arm delay (seconds) - self._trig_timer = 0.0 + # _reset_trigger() in reset(), and triggered(target) each frame. A trap with + # a single behaviour uses the lone default target; a block that both moves + # and phases names two ("motion", "phase") so each can take its own trigger + # and arm delay (see the per-target docs above make_condition's helpers). + 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): - self._trig_timer = 0.0 - self.trigger.reset() + for slot in self._trig.values(): + slot["timer"] = 0.0 + slot["cond"].reset() - def triggered(self, game, dt): - """True while the trigger condition holds. If ``delay`` is set, the - condition must hold *continuously* for that long first; leaving the - condition resets the countdown.""" - raw = self.trigger.evaluate(self, game, dt) - self._trig_timer = self._trig_timer + dt if raw else 0.0 - return raw and self._trig_timer >= self.trig_delay + def triggered(self, game, dt, target="main"): + """True while ``target``'s trigger condition holds. If a ``delay`` is + set, the condition must hold *continuously* for that long first; leaving + the condition resets the countdown. Each target keeps its own condition + instance and countdown, so their timers never interfere.""" + slot = self._trig[target] + 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 ------------------------------------------------- @@ -411,22 +485,34 @@ class Spike(Trap): # --- 3. block: the unified stationary / sliding / patrolling / spike block --- class Block(Trap): - """A block that may move and/or be deadly — one trap covering stationary - blocks, proximity sliders, patrolling platforms, and spike blocks. + """A block that may move, phase in/out, and/or be deadly — one trap covering + 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). move: [dcol,drow] shorthand for a 2-point path [at, at+move] (a slider). - trigger: when it moves (default ``always``). Sensors track the block's live - position, so a condition like ``{dir: above}`` keeps it going while - the player rides it. + trigger: when it activates (default ``always``). Sensors track the block's + live position, so a condition like ``{dir: above}`` keeps it going + 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 retreats to the first when not — the slider/dropper behaviour; ``loop`` / ``pingpong`` cycle the whole path continuously (patrol). - deadly: true -> a hazard (spikes) instead of a solid. - speed: px/s. sprite: override (default spike_block if deadly, else moving_block). - delay/release: (``once`` mode) the trigger must hold for ``delay`` seconds to - start extending and be clear for ``release`` seconds to start - retracting — hysteresis that stops boundary jitter. + deadly: true -> a hazard (spikes) instead of a solid. On a phase block the + hazard is only live while it's materialised. + phase: true -> invisible/intangible until triggered, then fades into a + solid (or, if ``deadly``, a hazard) over ``fade`` seconds and back + 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): @@ -447,27 +533,44 @@ class Block(Trap): self.crumble = bool(spec.get("crumble", False)) # gives way when stood on self.crumble_delay = float(spec.get("crumble_delay", 0.4)) self.respawn = float(spec.get("respawn", 2.5)) - self.sprite = spec.get( - "sprite", - ( - "spike_block" - if self.deadly - else ( - "fake_block" - if self.fake - else "crumble_block" if self.crumble else "moving_block" - ) - ), - ) + self.phase = bool(spec.get("phase", False)) # fades in/out on trigger + self.fade = float(spec.get("fade", 0.3)) # phase fade-in/out seconds + self.sprite = spec.get("sprite", self._default_sprite()) self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis # `home` (default): sense the trigger from the resting cell, so the block # moving away can't toggle its own trigger (no jitter). `current`: sense # from the live position — for blocks the player rides (e.g. a dropper), # so it stays put while ridden instead of pulling back. self.sense = spec.get("sense", "home") - self._init_trigger(spec) + # 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() + 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): return self.base_rect if self.sense == "home" else self._rect() @@ -498,13 +601,19 @@ class Block(Trap): self._origin = (0.0, 0.0) self.prev = (self.x, self.y) self.dir = 1 # pingpong direction - self.phase = "rest" # once mode: rest|extending|extended|retracting + self.motion_phase = "rest" # once mode: rest|extending|extended|retracting self._release_t = 0.0 # index of the waypoint we're AT (once) / heading toward (patrol) self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0) self.cstate = "solid" # crumble: solid|crumbling|gone self.ctimer = 0.0 self.shake = 0.0 + # 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 def update(self, dt, game): @@ -514,8 +623,8 @@ class Block(Trap): # block executes its path/move relative to the parent it rides. if not self._mounted: self.prev = (self.x, self.y) - active = self.triggered(game, dt) # call every frame to keep the timer live if len(self.points) >= 2: + active = self.triggered(game, dt, "motion") step = self.speed * dt if self.mode == "once": self._update_once(active, dt, step) @@ -523,6 +632,8 @@ class Block(Trap): self._update_patrol(active, step) if self.crumble: self._update_crumble(dt, game) + if self.phase: + self._update_phase(dt, game) def _update_crumble(self, dt, game): self.emerge_kill = False @@ -552,6 +663,78 @@ class Block(Trap): self.ctimer = 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): """Move toward tgt by step; snap and return True on arrival.""" tx, ty = tgt @@ -570,27 +753,27 @@ class Block(Trap): # mid-stroke reversal, so a block that moves out of its own sensor range # can't buzz. Hysteresis (delay/release) smooths the parked decisions. n = len(self.points) - if self.phase == "rest": + if self.motion_phase == "rest": if active: - self.phase = "extending" - elif self.phase == "extending": + self.motion_phase = "extending" + elif self.motion_phase == "extending": if self._step_to(self.points[self.idx + 1], step): self.idx += 1 if self.idx >= n - 1: - self.phase = "extended" + self.motion_phase = "extended" self._release_t = 0.0 - elif self.phase == "extended": + elif self.motion_phase == "extended": if active: self._release_t = 0.0 else: self._release_t += dt if self._release_t >= self.release: - self.phase = "retracting" - elif self.phase == "retracting": + self.motion_phase = "retracting" + elif self.motion_phase == "retracting": if self._step_to(self.points[self.idx - 1], step): self.idx -= 1 if self.idx <= 0: - self.phase = "rest" + self.motion_phase = "rest" def _update_patrol(self, active, step): tgt = self.points[self.idx] if active else self.points[0] @@ -620,16 +803,24 @@ class Block(Trap): return self._rect() 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): return [] if self._intangible() else [self._rect()] def hazard_rects(self): 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)) - if self.crumble and self.emerge_kill: + if self.emerge_kill: # crumble re-forming / phase forming into the player rects.append(self._rect()) return rects @@ -653,10 +844,22 @@ class Block(Trap): if self.level.debug: self._debug_ghost(surface, assets, self.sprite) 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() if self.crumble and self.cstate == "crumbling": 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: self._debug_tint(surface, (255, 40, 40), self._rect(), 90) @@ -829,121 +1032,12 @@ class Warp(Trap): 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 ------------------------------------------------------ TRAP_TYPES = { "spike": Spike, "block": Block, "arrow_shooter": ArrowShooter, "warp": Warp, - "phase_block": PhaseBlock, } diff --git a/tests/test_traps.py b/tests/test_traps.py index affea4d..67a04c3 100644 --- a/tests/test_traps.py +++ b/tests/test_traps.py @@ -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 pygame from conftest import FakeGame, step, run, place, hold, DT from game.assets import AssetStore -from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block +from game.traps import Spike, ArrowShooter, Warp, Block # --- spike ------------------------------------------------------------------- @@ -211,17 +211,18 @@ map: | #P......G# ########## traps: - - type: phase_block + - type: block at: [4, 3] + phase: true fade: 0.2 trigger: { within: 2 } """) pb = g.level.traps[0] place(g, 1, 3) step(g) - assert not pb.solid and pb.alpha == 0 # dormant far away + assert not pb.materialized and pb.alpha == 0 # dormant far away run(g, 40, lambda i: hold(right=True)) - assert pb.solid and pb.alpha > 0 # phased in solid on approach + assert pb.materialized and pb.alpha > 0 # phased in solid on approach def test_phase_block_kills_if_inside_when_it_forms(make_game): @@ -234,8 +235,9 @@ map: | #.....G# ######## traps: - - type: phase_block + - type: block at: [3, 2] + phase: true fade: 0.2 trigger: { within: 3 } """) @@ -261,8 +263,9 @@ map: | #......G# ######### traps: - - type: phase_block + - type: block at: [4, 1] + phase: true fade: 0.2 trigger: { within: 5 } """) @@ -278,7 +281,7 @@ traps: # survived, and pushed out to the left so it no longer overlaps the cell assert g.deaths == d0 assert g.player.rect.right <= b.left - assert pb.solid and not pb.emerge_kill + assert pb.materialized and not pb.emerge_kill def test_phase_block_kills_when_shove_would_squish(make_game): @@ -292,8 +295,9 @@ map: | #.....G# ######## traps: - - type: phase_block + - type: block at: [1, 1] + phase: true fade: 0.2 trigger: { within: 5 } """) @@ -326,8 +330,9 @@ map: | #.....G# ######## traps: - - type: phase_block + - type: block at: [3, 2] + phase: true fade: 0.5 trigger: { within: 3 } """) @@ -337,7 +342,44 @@ traps: step(g) assert 0 < pb.alpha < 1 g._start_death() # die from something - assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze + 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): diff --git a/tests/test_triggers.py b/tests/test_triggers.py index 78444fa..15cb393 100644 --- a/tests/test_triggers.py +++ b/tests/test_triggers.py @@ -109,3 +109,121 @@ traps: armed = i break 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