"""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 _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}") 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() 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 def _reset_trigger(self): self._trig_timer = 0.0 self.trigger.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 # --- 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 and/or be deadly — one trap covering stationary blocks, proximity sliders, patrolling platforms, and spike 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. 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. """ 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.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.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) self.reset() 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.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 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) active = self.triggered(game, dt) # call every frame to keep the timer live if len(self.points) >= 2: 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) 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 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.phase == "rest": if active: self.phase = "extending" elif self.phase == "extending": if self._step_to(self.points[self.idx + 1], step): self.idx += 1 if self.idx >= n - 1: self.phase = "extended" self._release_t = 0.0 elif self.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": if self._step_to(self.points[self.idx - 1], step): self.idx -= 1 if self.idx <= 0: self.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): return self.deadly or self.fake or (self.crumble and self.cstate == "gone") def solid_rects(self): return [] if self._intangible() else [self._rect()] def hazard_rects(self): rects = [] if self.deadly: rects.append(self._rect().inflate(-4, -4)) if self.crumble and self.emerge_kill: 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 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)) 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) # --- 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, } 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