Files
battery/game/traps.py

1085 lines
42 KiB
Python
Raw Normal View History

2026-07-21 19:36:02 -04:00
"""Traps: data-driven hazards and moving geometry.
Every trap subclasses :class:`Trap` and implements a few optional hooks that the
engine polls each frame:
solid_rects() -> rects that fully block movement (moving/patrol blocks)
oneway_rects() -> rects that block only from above
hazard_rects() -> rects that kill the player on contact
carriers() -> (rect, dx, dy) moved-this-frame platforms to ride
update(dt, game), draw(surface, assets), reset()
Add a new trap by writing a subclass and registering it in ``TRAP_TYPES``.
Nothing else in the engine needs to change.
"""
import pygame
# --- helpers -----------------------------------------------------------------
_DIRS = {
2026-08-01 12:19:46 -04:00
"up": (0, -1),
"down": (0, 1),
"left": (-1, 0),
"right": (1, 0),
2026-07-21 19:36:02 -04:00
}
# --- trigger conditions ------------------------------------------------------
# A trigger is a condition (or tree of conditions) evaluated against the player
# each frame. Leaves are spatial (within / dir) or temporal (timer); composites
# are all / any. Everything is measured against the trap's *current* rect, so a
# moving trap's sensors follow it automatically.
#
# trigger: always
# trigger: { within: 2 }
# trigger: { dir: left, range: 2, aligned: true }
# trigger: { timer: { interval: 1.2, up_time: 0.7 } }
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
2026-08-01 12:19:46 -04:00
2026-07-21 19:36:02 -04:00
class _Always:
def evaluate(self, trap, game, dt):
return True
def reset(self):
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
2026-07-21 19:36:02 -04:00
class _Within:
"""Player within N tiles of the trap centre (Euclidean radius)."""
2026-08-01 12:19:46 -04:00
2026-07-21 19:36:02 -04:00
def __init__(self, n):
self.n = float(n)
def evaluate(self, trap, game, dt):
r = trap.sensor_rect()
p = game.player.rect
dx = p.centerx - r.centerx
dy = p.centery - r.centery
reach = self.n * trap.tile
return dx * dx + dy * dy <= reach * reach
def reset(self):
pass
class _Directional:
"""Player is to a given side of the trap.
range: cap the distance in that direction (tiles); omit = anywhere.
aligned: also require overlap on the perpendicular axis, i.e. *directly*
left/right (same rows) or *directly* above/below (same columns).
2026-07-25 12:59:33 -04:00
inclusive: specify if the trap tile itself counts in the given direction.
2026-07-21 19:36:02 -04:00
"""
2026-08-01 12:19:46 -04:00
2026-07-25 12:59:33 -04:00
def __init__(self, direction, rng, aligned, inclusive):
2026-07-21 19:36:02 -04:00
self.dir = direction
self.rng = None if rng is None else float(rng)
self.aligned = bool(aligned)
2026-07-25 12:59:33 -04:00
self.inclusive = bool(inclusive)
2026-07-21 19:36:02 -04:00
def evaluate(self, trap, game, dt):
r = trap.sensor_rect()
p = game.player.rect
d = self.dir
if d in ("left", "right"):
if self.aligned and not (p.bottom > r.top and p.top < r.bottom):
return False
if d == "left":
ref = r.right if self.inclusive else r.left
2026-07-25 12:59:33 -04:00
if p.centerx >= ref:
2026-07-21 19:36:02 -04:00
return False
2026-07-25 12:59:33 -04:00
dist = ref - p.centerx
2026-07-21 19:36:02 -04:00
else:
2026-07-25 12:59:33 -04:00
ref = r.left if self.inclusive else r.right
if p.centerx <= ref:
2026-07-21 19:36:02 -04:00
return False
2026-07-25 12:59:33 -04:00
dist = p.centerx - ref
2026-07-21 19:36:02 -04:00
else: # above / below
if self.aligned and not (p.right > r.left and p.left < r.right):
return False
if d == "above":
2026-07-25 12:59:33 -04:00
ref = r.bottom if self.inclusive else r.top
if p.centery >= ref:
2026-07-21 19:36:02 -04:00
return False
dist = r.top - p.centery
else:
2026-07-25 12:59:33 -04:00
ref = r.top if self.inclusive else r.bottom
if p.centery <= ref:
2026-07-21 19:36:02 -04:00
return False
2026-07-25 12:59:33 -04:00
dist = p.centery - ref
2026-07-21 19:36:02 -04:00
return self.rng is None or dist <= self.rng * trap.tile
def reset(self):
pass
class _Timer:
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
2026-08-01 12:19:46 -04:00
2026-07-21 19:36:02 -04:00
def __init__(self, interval, up_time):
self.interval = float(interval)
self.up_time = float(up_time)
self.t = 0.0
def evaluate(self, trap, game, dt):
self.t += dt
return (self.t % (self.interval + self.up_time)) >= self.interval
def reset(self):
self.t = 0.0
class _All:
def __init__(self, subs):
self.subs = subs
def evaluate(self, trap, game, dt):
# Evaluate every child (so timers keep ticking), then combine.
return all([c.evaluate(trap, game, dt) for c in self.subs])
def reset(self):
for c in self.subs:
c.reset()
class _Any:
def __init__(self, subs):
self.subs = subs
def evaluate(self, trap, game, dt):
return any([c.evaluate(trap, game, dt) for c in self.subs])
def reset(self):
for c in self.subs:
c.reset()
def make_condition(spec):
if spec == "always":
return _Always()
if not isinstance(spec, dict):
2026-08-01 12:19:46 -04:00
raise ValueError(
f"trigger must be 'always' or a condition object, got {spec!r}"
)
2026-07-21 19:36:02 -04:00
if "all" in spec:
return _All([make_condition(s) for s in spec["all"]])
if "any" in spec:
return _Any([make_condition(s) for s in spec["any"]])
if "timer" in spec:
tm = spec["timer"]
return _Timer(tm.get("interval", 1.5), tm.get("up_time", 0.8))
if "within" in spec:
return _Within(spec["within"])
if "dir" in spec:
2026-08-01 12:19:46 -04:00
return _Directional(
spec["dir"],
spec.get("range"),
spec.get("aligned", False),
spec.get("inclusive", False),
)
2026-07-21 19:36:02 -04:00
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}
2026-07-21 19:36:02 -04:00
class Trap:
def __init__(self, spec, level):
self.spec = spec
self.level = level
self.tile = level.tile
at = spec.get("at", [0, 0])
self.col, self.row = int(at[0]), int(at[1])
self.base_rect = level.cell_rect(self.col, self.row)
2026-08-01 12:19:46 -04:00
self._mounted = False # set True on traps that ride another (a mount)
2026-07-21 19:36:02 -04:00
# Any trap can be made invisible (still functional; revealed in debug).
self.invisible = bool(spec.get("invisible", False))
# Mounted traps ride on this one. Their `at` is read as a *relative*
# offset (in tiles) from this trap's cell; each frame they are moved to
# track this trap's current position (see tick/_follow). A mounted trap
# rides rigidly — it doesn't move independently.
self._mount_off = (0, 0)
self.mounts = []
for mspec in spec.get("mounts", []) or []:
child = make_trap(mspec, level)
if child is not None:
child._mount_off = (child.base_rect.x, child.base_rect.y)
child._mounted = True
self.mounts.append(child)
# --- geometry hooks (default: contribute nothing) ------------------------
def solid_rects(self):
return []
def oneway_rects(self):
return []
def hazard_rects(self):
return []
def carriers(self):
return []
# Where this trap currently is. Static traps stay at their cell; movers
# override to return their live position so mounted traps can follow.
def current_rect(self):
return self.base_rect
# Where this trap's trigger senses the player from. Defaults to the live
# position; a slider overrides it to sense from home so moving away can't
# toggle its own trigger.
def sensor_rect(self):
return self.current_rect()
# Translate a level-space rect into render space. Everything a trap draws
# goes through this so the debug view's overscan margin shifts it correctly;
# it's a no-op (offset (0, 0)) in normal play.
def _rt(self, rect):
ox, oy = self.level.render_offset
return rect.move(ox, oy)
2026-07-21 19:36:02 -04:00
# Debug helper: tint a rect so a normally-hidden trap is visible when the
# level's `debug` flag is on.
def _debug_tint(self, surface, rgb, rect=None, alpha=80):
r = self._rt(rect if rect is not None else self.base_rect)
2026-07-21 19:36:02 -04:00
overlay = pygame.Surface(r.size, pygame.SRCALPHA)
overlay.fill((*rgb, alpha))
surface.blit(overlay, r)
# Debug helper: a faded sprite + outline showing where something absent
# (e.g. a crumbled-away block) belongs.
def _debug_ghost(self, surface, assets, sprite_name, rect=None):
r = self._rt(rect if rect is not None else self.base_rect)
2026-07-21 19:36:02 -04:00
img = assets.get(sprite_name, r.w, r.h).copy()
img.fill((255, 255, 255, 70), special_flags=pygame.BLEND_RGBA_MULT)
surface.blit(img, r)
pygame.draw.rect(surface, (130, 140, 160), r, 1)
# Debug helper: outline a path through a list of tile cells (top-left px),
# connecting their centres. `closed` joins the last cell back to the first.
def _debug_path(self, surface, cells, closed=False, color=(214, 200, 96)):
ox, oy = self.level.render_offset
rects = [pygame.Rect(x + ox, y + oy, self.tile, self.tile) for (x, y) in cells]
2026-07-21 19:36:02 -04:00
if len(rects) >= 2:
pygame.draw.lines(surface, color, closed, [r.center for r in rects], 1)
for r in rects:
pygame.draw.rect(surface, color, r, 1)
# --- lifecycle hooks -----------------------------------------------------
def update(self, dt, game):
pass
def draw(self, surface, assets):
pass
def reset(self):
pass
def finalize_on_death(self):
"""Hook: snap to a final appearance the instant the player dies, before
the scene freezes for the death animation. Default: nothing."""
pass
# --- mounting: aggregate self + mounted children -------------------------
# Levels call these wrappers so a trap and everything riding on it are
# treated as one unit. Subclasses keep overriding the plain hooks above.
def _follow(self, parent):
ox, oy = self._mount_off
pr = parent.current_rect()
2026-08-01 12:19:46 -04:00
self.base_rect = pygame.Rect(
pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h
)
2026-07-21 19:36:02 -04:00
def tick(self, dt, game):
self.update(dt, game)
for c in self.mounts:
2026-08-01 12:19:46 -04:00
c._follow(self) # reposition after we've moved this frame
2026-07-21 19:36:02 -04:00
c.tick(dt, game)
def render(self, surface, assets):
# `invisible` traps skip their visible drawing, but still show up under
# the level's debug view (their draw() reveals them there).
if not (self.invisible and not self.level.debug):
self.draw(surface, assets)
for c in self.mounts:
c.render(surface, assets)
def reset_all(self):
self.reset()
for c in self.mounts:
c.reset_all()
c._follow(self)
def finalize_all(self):
self.finalize_on_death()
for c in self.mounts:
c.finalize_all()
def all_solid_rects(self):
out = list(self.solid_rects())
for c in self.mounts:
out.extend(c.all_solid_rects())
return out
def all_oneway_rects(self):
out = list(self.oneway_rects())
for c in self.mounts:
out.extend(c.all_oneway_rects())
return out
def all_hazard_rects(self):
out = list(self.hazard_rects())
for c in self.mounts:
out.extend(c.all_hazard_rects())
return out
def all_carriers(self):
out = list(self.carriers())
for c in self.mounts:
out.extend(c.all_carriers())
return out
# --- triggers ------------------------------------------------------------
# Traps with an activation condition call _init_trigger() in __init__,
# _reset_trigger() in reset(), and triggered(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,
}
2026-07-21 19:36:02 -04:00
def _reset_trigger(self):
for slot in self._trig.values():
slot["timer"] = 0.0
slot["cond"].reset()
2026-07-21 19:36:02 -04:00
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"]
2026-07-21 19:36:02 -04:00
# --- spike: emerges to kill -------------------------------------------------
class Spike(Trap):
"""A spike that becomes deadly while its ``trigger`` condition holds.
direction: which edge of the cell the spike sits on (up/down/left/right).
See the trigger-condition docs at the top of this module.
"""
2026-08-01 12:19:46 -04:00
2026-07-21 19:36:02 -04:00
def __init__(self, spec, level):
super().__init__(spec, level)
self.direction = spec.get("direction", "up")
self._init_trigger(spec)
self.reset()
def reset(self):
self._reset_trigger()
self.active = False
def _hazard_rect(self):
# Spike occupies half the cell along its emerging edge.
t = self.tile
r = self.base_rect
dx, dy = _DIRS.get(self.direction, (0, -1))
2026-08-01 12:19:46 -04:00
if dy == -1: # up: bottom half is ground, tip points up
2026-07-21 19:36:02 -04:00
return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
2026-08-01 12:19:46 -04:00
if dy == 1: # down (ceiling spike)
2026-07-21 19:36:02 -04:00
return pygame.Rect(r.x, r.y, t, t // 2)
2026-08-01 12:19:46 -04:00
if dx == -1: # left (from right wall pointing left)
2026-07-26 19:04:54 -04:00
return pygame.Rect(r.x + t // 2, r.y, t // 2, t)
2026-08-01 12:19:46 -04:00
return pygame.Rect(r.x, r.y, t // 2, t) # right
2026-07-21 19:36:02 -04:00
# CCW rotation to point the (up-facing) sprite the right way.
_ANGLE = {"up": 0, "left": 90, "down": 180, "right": -90}
def update(self, dt, game):
self.active = self.triggered(game, dt)
def hazard_rects(self):
return [self._hazard_rect()] if self.active else []
def draw(self, surface, assets):
if self.active:
hr = self._hazard_rect()
angle = self._ANGLE.get(self.direction, 0)
surface.blit(assets.get("spike", hr.w, hr.h, angle), self._rt(hr))
2026-07-21 19:36:02 -04:00
elif self.level.debug:
# A dormant spike — show where it will strike.
self._debug_tint(surface, (230, 80, 80), self._hazard_rect(), 60)
# --- 3. block: the unified stationary / sliding / patrolling / spike block ---
class Block(Trap):
"""A block that may move, 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.
2026-07-21 19:36:02 -04:00
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 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).
2026-07-21 19:36:02 -04:00
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. 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.
2026-07-21 19:36:02 -04:00
"""
2026-08-01 12:19:46 -04:00
2026-07-21 19:36:02 -04:00
def __init__(self, spec, level):
super().__init__(spec, level)
t = self.tile
if spec.get("path"):
pts = spec["path"]
elif spec.get("move"):
mv = spec["move"]
pts = [[self.col, self.row], [self.col + mv[0], self.row + mv[1]]]
else:
pts = [[self.col, self.row]]
self.points = [(p[0] * t, p[1] * t) for p in pts]
self.speed = float(spec.get("speed", 140.0))
self.mode = spec.get("mode", "once")
self.deadly = bool(spec.get("deadly", False))
2026-08-01 12:19:46 -04:00
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
2026-07-21 19:36:02 -04:00
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.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())
2026-08-01 12:19:46 -04:00
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
2026-07-21 19:36:02 -04:00
# `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")
# 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)
2026-07-21 19:36:02 -04:00
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)"
)
2026-07-21 19:36:02 -04:00
def sensor_rect(self):
return self.base_rect if self.sense == "home" else self._rect()
def _follow(self, parent):
# Mounted on another trap. Our motion coordinates (self.x, self.y) live in
# a LOCAL frame relative to the parent; we track the parent's live
# position as our `_origin` and still run our own motion in update(). So a
# mount can slide/patrol while riding along with its carrier — e.g. a
# block that lunges up to catch a player leaping over, yet keeps drifting
# sideways with the platform it sits on. Capture `prev` (absolute) BEFORE
# shifting the origin so carriers() reports our *total* motion this frame
# (parent drift + our own move).
self.prev = (self._origin[0] + self.x, self._origin[1] + self.y)
self._origin = parent.current_rect().topleft
# `home` sensing tracks the resting cell as it rides along the parent.
ox, oy = self._origin
offx, offy = self._mount_off
2026-08-01 12:19:46 -04:00
self.base_rect = pygame.Rect(
round(ox + offx), round(oy + offy), self.tile, self.tile
)
2026-07-21 19:36:02 -04:00
def reset(self):
self._reset_trigger()
self.x, self.y = self.points[0]
# Parent top-left when mounted (set each frame by _follow); (0, 0) for a
# free block, so its x/y double as absolute coords. A mount's x/y are
# LOCAL — the live rect is always _origin + (x, y).
self._origin = (0.0, 0.0)
self.prev = (self.x, self.y)
2026-08-01 12:19:46 -04:00
self.dir = 1 # pingpong direction
self.motion_phase = "rest" # once mode: rest|extending|extended|retracting
2026-07-21 19:36:02 -04:00
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)
2026-08-01 12:19:46 -04:00
self.cstate = "solid" # crumble: solid|crumbling|gone
2026-07-21 19:36:02 -04:00
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.
2026-07-21 19:36:02 -04:00
self.emerge_kill = False
def update(self, dt, game):
# A mount's `prev` (absolute) was captured in _follow before its origin
# shifted; a free block records it here. Either way the motion below runs
# in our own frame (local for a mount, absolute otherwise), so a mounted
# block executes its path/move relative to the parent it rides.
if not self._mounted:
self.prev = (self.x, self.y)
if len(self.points) >= 2:
active = self.triggered(game, dt, "motion")
2026-07-21 19:36:02 -04:00
step = self.speed * dt
if self.mode == "once":
self._update_once(active, dt, step)
else:
self._update_patrol(active, step)
if self.crumble:
self._update_crumble(dt, game)
if self.phase:
self._update_phase(dt, game)
2026-07-21 19:36:02 -04:00
def _update_crumble(self, dt, game):
self.emerge_kill = False
r = self._rect()
p = game.player.rect
2026-08-01 12:19:46 -04:00
on_top = (
abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2
)
2026-07-21 19:36:02 -04:00
if self.cstate == "solid":
if on_top:
self.cstate = "crumbling"
self.ctimer = 0.0
elif self.cstate == "crumbling":
self.ctimer += dt
self.shake = (self.ctimer * 40) % 4 - 2
if self.ctimer >= self.crumble_delay:
self.cstate = "gone"
self.ctimer = 0.0
elif self.cstate == "gone":
self.ctimer += dt
if self.ctimer >= self.respawn:
# Re-forming into the player kills them (like the old crumble).
if p.colliderect(r):
self.emerge_kill = True
else:
self.cstate = "solid"
self.ctimer = 0.0
self.shake = 0.0
# 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
2026-07-21 19:36:02 -04:00
def _step_to(self, tgt, step):
"""Move toward tgt by step; snap and return True on arrival."""
tx, ty = tgt
dx, dy = tx - self.x, ty - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist <= step or dist == 0:
self.x, self.y = tx, ty
return True
self.x += dx / dist * step
self.y += dy / dist * step
return False
def _update_once(self, active, dt, step):
# A committed stroke: once moving we run to the endpoint regardless of
# the trigger flickering, and only reconsider it while parked — no
# mid-stroke reversal, so a block that moves out of its own sensor range
# can't buzz. Hysteresis (delay/release) smooths the parked decisions.
n = len(self.points)
if self.motion_phase == "rest":
2026-07-21 19:36:02 -04:00
if active:
self.motion_phase = "extending"
elif self.motion_phase == "extending":
2026-07-21 19:36:02 -04:00
if self._step_to(self.points[self.idx + 1], step):
self.idx += 1
if self.idx >= n - 1:
self.motion_phase = "extended"
2026-07-21 19:36:02 -04:00
self._release_t = 0.0
elif self.motion_phase == "extended":
2026-07-21 19:36:02 -04:00
if active:
self._release_t = 0.0
else:
self._release_t += dt
if self._release_t >= self.release:
self.motion_phase = "retracting"
elif self.motion_phase == "retracting":
2026-07-21 19:36:02 -04:00
if self._step_to(self.points[self.idx - 1], step):
self.idx -= 1
if self.idx <= 0:
self.motion_phase = "rest"
2026-07-21 19:36:02 -04:00
def _update_patrol(self, active, step):
tgt = self.points[self.idx] if active else self.points[0]
if self._step_to(tgt, step):
if active:
self._advance_patrol()
else:
self.idx = 1 if len(self.points) > 1 else 0
self.dir = 1
def _advance_patrol(self):
n = len(self.points)
if self.mode == "loop":
self.idx = (self.idx + 1) % n
else: # pingpong
nxt = self.idx + self.dir
if nxt >= n or nxt < 0:
self.dir *= -1
nxt = self.idx + self.dir
self.idx = nxt
def _rect(self):
ox, oy = self._origin
2026-08-01 12:19:46 -04:00
return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile)
2026-07-21 19:36:02 -04:00
def current_rect(self):
return self._rect()
def _intangible(self):
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
2026-07-21 19:36:02 -04:00
def solid_rects(self):
return [] if self._intangible() else [self._rect()]
def hazard_rects(self):
rects = []
# 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):
2026-07-21 19:36:02 -04:00
rects.append(self._rect().inflate(-4, -4))
if self.emerge_kill: # crumble re-forming / phase forming into the player
2026-07-21 19:36:02 -04:00
rects.append(self._rect())
return rects
def carriers(self):
if self._intangible():
return []
ax = self._origin[0] + self.x
ay = self._origin[1] + self.y
return [(self._rect(), ax - self.prev[0], ay - self.prev[1])]
def draw(self, surface, assets):
if self.level.debug and len(self.points) > 1:
# `points` live in our own frame — local (relative to the parent) for
# a mount — so shift them by the parent origin to draw where the block
# actually travels.
ox, oy = self._origin
cells = [(px + ox, py + oy) for (px, py) in self.points]
self._debug_path(surface, cells, closed=(self.mode == "loop"))
2026-07-21 19:36:02 -04:00
# crumbled away: hidden (ghost in debug), unless re-forming into the player
if self.crumble and self.cstate == "gone" and not self.emerge_kill:
if self.level.debug:
self._debug_ghost(surface, assets, self.sprite)
return
# 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
2026-07-21 19:36:02 -04:00
rect = self._rect()
if self.crumble and self.cstate == "crumbling":
rect = rect.move(int(self.shake), 0)
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))
2026-07-21 19:36:02 -04:00
if self.fake and self.level.debug:
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
# --- 4. arrow shooter --------------------------------------------------------
class Arrow:
__slots__ = ("rect", "vx", "vy")
def __init__(self, rect, vx, vy):
self.rect = rect
self.vx = vx
self.vy = vy
class ArrowShooter(Trap):
"""A block that fires deadly arrows on an interval while triggered.
direction: up/down/left/right. speed: px/s. interval: seconds between shots.
trigger: only fires while the condition holds (default ``always``).
"""
2026-08-01 12:19:46 -04:00
2026-07-21 19:36:02 -04:00
def __init__(self, spec, level):
super().__init__(spec, level)
self.direction = spec.get("direction", "left")
self.speed = float(spec.get("speed", 260.0))
self.interval = float(spec.get("interval", 1.6))
self._init_trigger(spec)
self.reset()
def reset(self):
self.timer = 0.0
self.arrows = []
self._reset_trigger()
def _spawn(self):
dx, dy = _DIRS.get(self.direction, (-1, 0))
t = self.tile
w = t // 2 if dx else t // 3
h = t // 3 if dx else t // 2
r = self.base_rect
rect = pygame.Rect(0, 0, w, h)
rect.center = r.center
# nudge the arrow to the emitting edge
2026-08-01 12:19:46 -04:00
if dx == -1:
rect.right = r.left
elif dx == 1:
rect.left = r.right
elif dy == -1:
rect.bottom = r.top
elif dy == 1:
rect.top = r.bottom
2026-07-21 19:36:02 -04:00
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
def update(self, dt, game):
can_fire = self.triggered(game, dt)
self.timer += dt
if can_fire and self.timer >= self.interval:
self.timer = 0.0
self._spawn()
bounds = pygame.Rect(0, 0, self.level.width, self.level.height).inflate(80, 80)
alive = []
for a in self.arrows:
a.rect.x += round(a.vx * dt)
a.rect.y += round(a.vy * dt)
if bounds.contains(a.rect) or bounds.colliderect(a.rect):
# stop at solid walls
if not any(a.rect.colliderect(s) for s in self.level.solids):
alive.append(a)
self.arrows = alive
def hazard_rects(self):
return [a.rect for a in self.arrows]
def draw(self, surface, assets):
2026-08-01 12:19:46 -04:00
surface.blit(
assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect)
)
2026-07-21 19:36:02 -04:00
for a in self.arrows:
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
2026-07-21 19:36:02 -04:00
# --- warp: invisible teleporter ---------------------------------------------
class Warp(Trap):
"""An invisible tile that teleports the player to ``to: [col, row]`` on
contact. On activation a short aura flashes at both the source and the
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
2026-07-21 19:36:02 -04:00
destination) while designing."""
2026-08-01 12:19:46 -04:00
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
# largest-first so the bright core sits on top.
_AURA = (
(1.15, 0.30, (188, 116, 246)),
(0.80, 0.55, (222, 158, 252)),
(0.45, 0.95, (245, 224, 255)),
)
2026-07-21 19:36:02 -04:00
def __init__(self, spec, level):
super().__init__(spec, level)
self.invisible = True
to = spec.get("to", [self.col, self.row])
self.dest = (int(to[0]), int(to[1]))
self.reset()
def reset(self):
2026-08-01 12:19:46 -04:00
self._armed = True # re-arms once the player has left the tile
self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME
2026-07-21 19:36:02 -04:00
def update(self, dt, game):
if self._pulse > 0.0:
self._pulse = max(0.0, self._pulse - dt / self._PULSE_TIME)
2026-07-21 19:36:02 -04:00
inside = game.player.rect.colliderect(self.base_rect)
if inside and self._armed:
p = game.player
p.fx = float(self.dest[0] * self.tile + (self.tile - p.w) / 2)
p.fy = float(self.dest[1] * self.tile + (self.tile - p.h))
p.vx = p.vy = 0.0
p._sync_rect()
self._armed = False
2026-08-01 12:19:46 -04:00
self._pulse = 1.0 # flash at both ends this frame, then fade
2026-07-21 19:36:02 -04:00
elif not inside:
self._armed = True
def render(self, surface, assets):
# Warps are invisible, so the base render() would skip draw() in normal
# play — but the activation aura should show then too. Draw whenever a
# pulse is live (or in debug, for the design tint/line).
if self.level.debug or self._pulse > 0.0:
self.draw(surface, assets)
for c in self.mounts:
c.render(surface, assets)
def _dest_rect(self):
2026-08-01 12:19:46 -04:00
return pygame.Rect(
self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile
)
def _draw_aura(self, surface, rect):
# An expanding, fading glow centred on the cell. As the pulse decays the
# rings grow a little and thin out, so it reads as a quick flash-and-fade.
p = self._pulse
grow = 0.55 + (1.0 - p) * 0.85
size = self.tile * 3
aura = pygame.Surface((size, size), pygame.SRCALPHA)
c = (size // 2, size // 2)
for rmul, amul, col in self._AURA:
radius = int(self.tile * rmul * grow)
alpha = int(255 * amul * p)
if radius >= 1 and alpha > 0:
pygame.draw.circle(aura, (*col, alpha), c, radius)
surface.blit(aura, aura.get_rect(center=self._rt(rect).center))
2026-07-21 19:36:02 -04:00
def draw(self, surface, assets):
if self._pulse > 0.0:
self._draw_aura(surface, self.base_rect)
self._draw_aura(surface, self._dest_rect())
2026-07-21 19:36:02 -04:00
if self.level.debug:
self._debug_tint(surface, (210, 80, 235), alpha=90)
dest = self._dest_rect()
2026-08-01 12:19:46 -04:00
pygame.draw.line(
surface,
(210, 80, 235),
self._rt(self.base_rect).center,
self._rt(dest).center,
1,
)
2026-07-21 19:36:02 -04:00
self._debug_tint(surface, (210, 80, 235), dest, 45)
# --- registry + factory ------------------------------------------------------
TRAP_TYPES = {
"spike": Spike,
"block": Block,
"arrow_shooter": ArrowShooter,
"warp": Warp,
}
def make_trap(spec, level):
ttype = spec.get("type")
cls = TRAP_TYPES.get(ttype)
if cls is None:
print(f"[level] unknown trap type: {ttype!r} — skipping")
return None
return cls(spec, level)
def expand_spec(spec):
"""Expand a trap spec's ``count`` into a line/grid of copies.
``count: [nx, ny]`` (or a single int for a horizontal line) places
nx-by-ny copies, each offset by ``spacing: [sx, sy]`` tiles (default 1).
Only ``at`` is shifted per copy (so ``move`` is relative and works;
absolute ``path`` is shared, so arrays suit stationary/simple traps).
A rectangle of ``invisible`` blocks replaces the old invisible wall.
"""
count = spec.get("count")
if count is None:
yield spec
return
if isinstance(count, (list, tuple)):
nx = int(count[0])
ny = int(count[1]) if len(count) > 1 else 1
else:
nx, ny = int(count), 1
spacing = spec.get("spacing", 1)
if isinstance(spacing, (list, tuple)):
sx = spacing[0]
sy = spacing[1] if len(spacing) > 1 else spacing[0]
else:
sx = sy = spacing
bc, br = spec.get("at", [0, 0])
for j in range(ny):
for i in range(nx):
s = dict(spec)
s.pop("count", None)
s.pop("spacing", None)
s["at"] = [bc + i * sx, br + j * sy]
yield s