"""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 = { "up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), } # --- 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 } ] } 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 class _Within: """Player within N tiles of the trap centre (Euclidean radius).""" 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). inclusive: specify if the trap tile itself counts in the given direction. """ def __init__(self, direction, rng, aligned, inclusive): self.dir = direction self.rng = None if rng is None else float(rng) self.aligned = bool(aligned) self.inclusive = bool(inclusive) 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 if p.centerx >= ref: return False dist = ref - p.centerx else: ref = r.left if self.inclusive else r.right if p.centerx <= ref: return False dist = p.centerx - ref else: # above / below if self.aligned and not (p.right > r.left and p.left < r.right): return False if d == "above": ref = r.bottom if self.inclusive else r.top if p.centery >= ref: return False dist = r.top - p.centery else: ref = r.top if self.inclusive else r.bottom if p.centery <= ref: return False dist = p.centery - ref 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``.""" 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): raise ValueError( f"trigger must be 'always' or a condition object, got {spec!r}" ) 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: return _Directional( spec["dir"], spec.get("range"), spec.get("aligned", False), spec.get("inclusive", False), ) 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 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) self._mounted = False # set True on traps that ride another (a mount) # 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) # 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) 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) 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] 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() self.base_rect = pygame.Rect( pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h ) def tick(self, dt, game): self.update(dt, game) for c in self.mounts: c._follow(self) # reposition after we've moved this frame 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, } def _reset_trigger(self): for slot in self._trig.values(): slot["timer"] = 0.0 slot["cond"].reset() 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 ------------------------------------------------- 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. """ 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)) if dy == -1: # up: bottom half is ground, tip points up return pygame.Rect(r.x, r.y + t // 2, t, t // 2) if dy == 1: # down (ceiling spike) return pygame.Rect(r.x, r.y, t, t // 2) if dx == -1: # left (from right wall pointing left) return pygame.Rect(r.x + t // 2, r.y, t // 2, t) return pygame.Rect(r.x, r.y, t // 2, t) # right # 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)) 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. 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). 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. """ 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)) self.fake = bool(spec.get("fake", False)) # looks solid, isn't 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()) 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") # 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() 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 self.base_rect = pygame.Rect( round(ox + offx), round(oy + offy), self.tile, self.tile ) 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) self.dir = 1 # pingpong direction 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): # 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") 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) def _update_crumble(self, dt, game): self.emerge_kill = False r = self._rect() p = game.player.rect on_top = ( abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2 ) 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 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": if active: 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.motion_phase = "extended" self._release_t = 0.0 elif self.motion_phase == "extended": 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": if self._step_to(self.points[self.idx - 1], step): self.idx -= 1 if self.idx <= 0: self.motion_phase = "rest" 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 return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile) 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 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): rects.append(self._rect().inflate(-4, -4)) if self.emerge_kill: # crumble re-forming / phase forming into the player 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")) # 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 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)) 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``). """ 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 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 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): surface.blit( assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect) ) for a in self.arrows: surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect)) # --- 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 destination) while designing.""" _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)), ) 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): 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 def update(self, dt, game): if self._pulse > 0.0: self._pulse = max(0.0, self._pulse - dt / self._PULSE_TIME) 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 self._pulse = 1.0 # flash at both ends this frame, then fade 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): 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)) def draw(self, surface, assets): if self._pulse > 0.0: self._draw_aura(surface, self.base_rect) self._draw_aura(surface, self._dest_rect()) if self.level.debug: self._debug_tint(surface, (210, 80, 235), alpha=90) dest = self._dest_rect() pygame.draw.line( surface, (210, 80, 235), self._rt(self.base_rect).center, self._rt(dest).center, 1, ) 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