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

View File

@@ -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,
}