884 lines
33 KiB
Python
884 lines
33 KiB
Python
|
|
"""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
|
||
|
|
|
||
|
|
from . import settings
|
||
|
|
|
||
|
|
|
||
|
|
# --- 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).
|
||
|
|
"""
|
||
|
|
def __init__(self, direction, rng, aligned):
|
||
|
|
self.dir = direction
|
||
|
|
self.rng = None if rng is None else float(rng)
|
||
|
|
self.aligned = bool(aligned)
|
||
|
|
|
||
|
|
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":
|
||
|
|
if p.centerx >= r.left:
|
||
|
|
return False
|
||
|
|
dist = r.left - p.centerx
|
||
|
|
else:
|
||
|
|
if p.centerx <= r.right:
|
||
|
|
return False
|
||
|
|
dist = p.centerx - r.right
|
||
|
|
else: # above / below
|
||
|
|
if self.aligned and not (p.right > r.left and p.left < r.right):
|
||
|
|
return False
|
||
|
|
if d == "above":
|
||
|
|
if p.centery >= r.top:
|
||
|
|
return False
|
||
|
|
dist = r.top - p.centery
|
||
|
|
else:
|
||
|
|
if p.centery <= r.bottom:
|
||
|
|
return False
|
||
|
|
dist = p.centery - r.bottom
|
||
|
|
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))
|
||
|
|
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()
|
||
|
|
|
||
|
|
# 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 = 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 = 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)):
|
||
|
|
rects = [pygame.Rect(x, y, 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, r.y, t // 2, t)
|
||
|
|
return pygame.Rect(r.x + t // 2, 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), 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:
|
||
|
|
self._debug_path(surface, self.points, 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), 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.base_rect)
|
||
|
|
for a in self.arrows:
|
||
|
|
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), a.rect)
|
||
|
|
|
||
|
|
|
||
|
|
# --- warp: invisible teleporter ---------------------------------------------
|
||
|
|
class Warp(Trap):
|
||
|
|
"""An invisible tile that teleports the player to ``to: [col, row]`` on
|
||
|
|
contact. The level's ``debug`` flag tints it (and draws a line to its
|
||
|
|
destination) while designing."""
|
||
|
|
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
|
||
|
|
|
||
|
|
def update(self, dt, game):
|
||
|
|
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
|
||
|
|
elif not inside:
|
||
|
|
self._armed = True
|
||
|
|
|
||
|
|
def draw(self, surface, assets):
|
||
|
|
if self.level.debug:
|
||
|
|
self._debug_tint(surface, (210, 80, 235), alpha=90)
|
||
|
|
dest = pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
|
||
|
|
self.tile, self.tile)
|
||
|
|
pygame.draw.line(surface, (210, 80, 235),
|
||
|
|
self.base_rect.center, 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.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
|