Make phase a block property rather than a distinct type
This commit is contained in:
41
README.md
41
README.md
@@ -104,29 +104,30 @@ Any trap can also take:
|
||||
| `type` | Key parameters | Behavior |
|
||||
|-----------------|----------------|----------|
|
||||
| `spike` | `direction` (`up`/`down`/`left`/`right`), `trigger`, `delay` | Deadly spike; active while its `trigger` holds. The sprite auto-rotates to point the way `direction` faces. |
|
||||
| `block` | see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, and/or crumbling. |
|
||||
| `block` | see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, crumbling, and/or phasing. |
|
||||
| `arrow_shooter` | `direction`, `speed`, `interval`, `trigger`, `delay` | A wall turret firing deadly arrows every `interval` seconds while its `trigger` holds. |
|
||||
| `warp` | `to` [col,row] | Invisible tile that teleports the player to `to` on contact. Re-arms once you leave it. |
|
||||
| `phase_block` | `trigger`, `fade` | Invisible and intangible until its `trigger` fires, then fades into a solid over `fade` seconds (and fades back out when the trigger releases). If you're standing in the cell the instant it *starts* appearing, you die — and if you die while one is mid-fade it snaps fully visible for the death freeze. |
|
||||
|
||||
### The `block` trap
|
||||
|
||||
One trap covers stationary blocks, sliders, patrolling platforms, spike blocks,
|
||||
fake blocks, and crumbling blocks — compose the behaviour from options (which
|
||||
combine, e.g. a moving platform that crumbles):
|
||||
fake blocks, crumbling blocks, and phase blocks — compose the behaviour from
|
||||
options (which combine, e.g. a moving platform that crumbles, or a phase block
|
||||
that's always moving):
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `path` [[col,row],…] | waypoints it travels between (default just `[at]` = stationary) |
|
||||
| `move` [dcol,drow] | shorthand for a 2-point path `[at, at+move]` (a slider) |
|
||||
| `mode` | `once` (default): advance to the last point while triggered, retreat to the first when not — the slider/dropper. `loop` / `pingpong`: cycle the whole path continuously (a patrol). |
|
||||
| `trigger` | when it moves (default `always`). A patrol is just a path + the default `always` trigger. |
|
||||
| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid |
|
||||
| `trigger` | when it activates (default `always`). A patrol is just a path + the default `always` trigger. A block that both moves and phases can give each its own trigger via a per-target map (see Triggers). |
|
||||
| `deadly` (bool) | `true` → a hazard (spikes) instead of a solid. On a phase block the hazard is live only once materialised. |
|
||||
| `fake` (bool) | looks solid but you fall straight through it |
|
||||
| `crumble` (bool) + `crumble_delay`, `respawn` | gives way `crumble_delay` s after you stand on it, vanishes, then reappears after `respawn` s (killing you if you're standing where it re-forms) |
|
||||
| `phase` (bool) + `fade` | invisible/intangible until triggered, then fades into a solid (or a hazard, if `deadly`) over `fade` s (default 0.3) and back out when the trigger releases. Forming into you is lethal, but a non-deadly one first shoves you clear if you're only clipping an edge; if you die while one is mid-fade it snaps fully visible for the death freeze. |
|
||||
| `speed` | px/s |
|
||||
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) |
|
||||
| `delay` / `release` | (`once`) hysteresis: the trigger must hold `delay` s to start extending and be clear `release` s (default 0.1) to start retracting — stops boundary jitter |
|
||||
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, `phase_block` if phase, else `moving_block`) |
|
||||
| `delay` / `release` | hold `delay` s to activate (may be a per-target map); in `once` motion, be clear `release` s (default 0.1) to start retracting — stops boundary jitter |
|
||||
| `sense` | `home` (default): a slider senses its trigger from its resting cell, so moving away can't toggle its own trigger. `current`: senses from the live position — for a block you *ride* (e.g. a dropper) so it stays put while ridden instead of pulling back |
|
||||
|
||||
```yaml
|
||||
@@ -145,6 +146,15 @@ combine, e.g. a moving platform that crumbles):
|
||||
path: [[9, 10], [14, 10], [14, 6]]
|
||||
mode: loop
|
||||
sprite: patrol_block
|
||||
|
||||
- type: block # deadly block that patrols AND phases in near you
|
||||
at: [9, 3]
|
||||
phase: true
|
||||
deadly: true
|
||||
move: [4, 0]
|
||||
trigger:
|
||||
motion: always # always sliding back and forth
|
||||
phase: { within: 3 } # but only solid/deadly when you're close
|
||||
```
|
||||
|
||||
A `once` block runs a *committed stroke*: once it starts moving it runs all the
|
||||
@@ -183,6 +193,21 @@ trigger:
|
||||
*continuously* this long before the trap arms; leaving the condition resets the
|
||||
countdown. Handy for "linger and it strikes" spikes.
|
||||
|
||||
**Per-target triggers.** A `block` can both move and phase, and each behaviour
|
||||
can take its own trigger (and `delay`). Give a single condition to drive both,
|
||||
or a map keyed by target (`motion` / `phase`) with `default` covering the rest:
|
||||
|
||||
```yaml
|
||||
trigger:
|
||||
default: always # phase (and anything else)
|
||||
motion: { within: 5 } # motion only
|
||||
delay: { motion: 0.1, phase: 0.3 }
|
||||
```
|
||||
|
||||
A map that names some targets but omits `default` leaves the unnamed ones with
|
||||
no trigger (off) — the debug view warns if that silently disables a capability.
|
||||
An unknown target name is an error.
|
||||
|
||||
### Getting crushed
|
||||
|
||||
You also die if a moving (non-deadly) `block` **pinches** you: presses you
|
||||
|
||||
79
SPEC.md
79
SPEC.md
@@ -341,6 +341,37 @@ Composites evaluate *all* children each frame (so nested timers keep ticking).
|
||||
long before the trap arms; leaving the condition resets the countdown. This is
|
||||
arm hysteresis (e.g. "linger and it strikes").
|
||||
|
||||
### 12.1 Per-target triggers and delays
|
||||
|
||||
A trap with more than one triggerable behaviour — a `block` can both **move**
|
||||
and **phase** — lets each behaviour take its own trigger and delay. The value is
|
||||
either:
|
||||
|
||||
- a **single** condition/scalar (the common case), applied to every target; or
|
||||
- a **per-target map** keyed by target name, 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 map. The block's targets are
|
||||
`motion` and `phase`.
|
||||
|
||||
```yaml
|
||||
trigger: { within: 2 } # both motion and phase
|
||||
trigger:
|
||||
default: always # phase (and any other target)
|
||||
motion: { within: 5 } # motion only
|
||||
delay: { motion: 0.1, phase: 0.3 } # per-target arm delays
|
||||
```
|
||||
|
||||
- If a map names some targets but omits `default`, the **unnamed targets get no
|
||||
trigger and never fire** — a `phase` block with `trigger: { motion: … }` and
|
||||
no `default` never materialises. (In the debug view this prints a warning,
|
||||
since it's usually a mistake.)
|
||||
- An unknown target name raises an error at load time.
|
||||
- Each target keeps its own condition instance and arm countdown, so their
|
||||
timers never interfere.
|
||||
- A scalar `delay` applies to all targets; an unnamed target without a `default`
|
||||
delay falls back to `0`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Trap catalog
|
||||
@@ -354,20 +385,21 @@ holds.
|
||||
|
||||
### 13.2 `block` — the all-in-one block
|
||||
One trap covering stationary blocks, sliders, patrolling platforms, spike
|
||||
blocks, fake blocks, and crumbling blocks. Options combine.
|
||||
blocks, fake blocks, crumbling blocks, and phase blocks. Options combine.
|
||||
|
||||
| Option | Meaning |
|
||||
|---|---|
|
||||
| `path: [[col,row],…]` | waypoints it travels between (default `[at]` = stationary) |
|
||||
| `move: [dcol,drow]` | shorthand for a 2-point path `[at, at+move]` (a slider) |
|
||||
| `mode` | `once` (default): extend to the last waypoint while triggered, retreat to the first when not — slider/dropper. `loop` / `pingpong`: cycle the whole path continuously — a patrol. |
|
||||
| `trigger` | when it moves (default `always`); a patrol is a path + `always` |
|
||||
| `trigger` | when it activates (default `always`); a patrol is a path + `always`. May be a per-target map (`motion` / `phase`) — see §12.1 |
|
||||
| `speed` | px/s (default 140) |
|
||||
| `deadly` (bool) | hazard (spikes) instead of a solid |
|
||||
| `deadly` (bool) | hazard (spikes) instead of a solid. On a `phase` block the hazard is live only while it's materialised |
|
||||
| `fake` (bool) | drawn like a solid block but non-collidable (you fall through) |
|
||||
| `crumble` (bool) + `crumble_delay`, `respawn` | gives way `crumble_delay` s after you stand on it, vanishes, then re-forms after `respawn` s (killing you if you're standing where it re-forms) |
|
||||
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) |
|
||||
| `delay` / `release` | (`once`) hysteresis: hold `delay` s to start extending, be clear `release` s (default 0.1) to start retracting |
|
||||
| `phase` (bool) + `fade` | invisible/intangible until triggered, then fades into a solid (or a hazard, if `deadly`) over `fade` s (default 0.3) and back out when the trigger releases — see the phasing note below |
|
||||
| `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, `phase_block` if phase, else `moving_block`) |
|
||||
| `delay` / `release` | hold `delay` s to activate (may be a per-target map, §12.1); in `once` motion, be clear `release` s (default 0.1) to start retracting |
|
||||
| `sense` | `home` (default): sense the trigger from the resting cell; `current`: sense from the live position (for blocks you ride, so they stay put while ridden) |
|
||||
|
||||
Behavior notes:
|
||||
@@ -375,11 +407,24 @@ Behavior notes:
|
||||
endpoint without reversing, and only reconsiders its trigger while parked at
|
||||
an endpoint. Combined with `home` sensing, this eliminates the jitter a
|
||||
slider would otherwise get from moving out of its own sensor range.
|
||||
- A non-deadly, non-fake block is a **solid** that reports itself as a
|
||||
**carrier** so the player rides it; a moving block can **shove** or **crush**
|
||||
the player (§5.2, §5.6).
|
||||
- Movement runs first each frame, then the crumble state machine, so a moving
|
||||
platform can also crumble.
|
||||
- A non-deadly, non-fake, non-phasing (or materialised) block is a **solid**
|
||||
that reports itself as a **carrier** so the player rides it; a moving block
|
||||
can **shove** or **crush** the player (§5.2, §5.6).
|
||||
- Movement, crumble, and phasing run in that order each frame, so a block can
|
||||
combine them — e.g. a block that's always moving (`motion: always`) but only
|
||||
**phases** in when the player is near (`phase: { within: N }`). Motion is
|
||||
trigger-driven; crumble is contact-driven; phasing is trigger-driven.
|
||||
- **Phasing**: while the `phase` trigger holds the block fades in and becomes
|
||||
collidable (a solid, or — with `deadly` — a hazard that's live only once
|
||||
materialised); when it releases, it fades back out and goes intangible. If the
|
||||
player overlaps the cell the instant it *starts* forming, a non-deadly block
|
||||
is forgiving about edge clips: when they're only clipping an edge (overlap ≤
|
||||
half a tile on the shallowest axis) it shoves them out and solidifies behind
|
||||
them, staying lethal only when it forms through their middle (a deep overlap)
|
||||
or the shove would press them into another solid (a crush). A `deadly` phase
|
||||
block skips the shove and simply kills. If the player dies while a phase block
|
||||
is mid-fade, it snaps fully visible before the death freeze (via the
|
||||
`finalize_on_death` hook — see §7/§11).
|
||||
|
||||
### 13.3 `arrow_shooter`
|
||||
A wall turret that fires a deadly projectile every `interval` seconds while its
|
||||
@@ -399,18 +444,8 @@ and destination tiles and fades out over ~0.35 s (expanding/thinning rings drawn
|
||||
procedurally, no sprite), so the teleport reads on screen even though the tile
|
||||
itself is invisible.
|
||||
|
||||
### 13.5 `phase_block`
|
||||
Invisible and intangible until its `trigger` fires, then it fades into a solid
|
||||
over `fade` seconds (alpha 0→1) and fades back out when the trigger releases. If
|
||||
the player overlaps the cell the instant it *starts* forming, it's forgiving
|
||||
about edge clips: when they're only clipping an edge (overlap ≤ half a tile on
|
||||
the shallowest axis) it shoves them out of the cell and solidifies behind them.
|
||||
It stays lethal only when the block forms through the player's middle (a deep
|
||||
overlap) or the shove would press them into another solid (squished against
|
||||
something — a crush, as usual). In the lethal case it stays intangible that frame
|
||||
so they're killed rather than displaced. If the player dies while it is mid-fade,
|
||||
it snaps fully visible before the death freeze (via the `finalize_on_death`
|
||||
hook — see §7/§11).
|
||||
(A *phase block* — invisible/intangible until triggered, then fades into a solid
|
||||
or hazard — is not a separate type: it's the `block` option `phase: true`, §13.2.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
414
game/traps.py
414
game/traps.py
@@ -45,6 +45,17 @@ class _Always:
|
||||
pass
|
||||
|
||||
|
||||
class _Never:
|
||||
"""A target with no condition — never fires. Used when a per-target trigger
|
||||
map names some targets but omits ``default``, so the unnamed ones are off."""
|
||||
|
||||
def evaluate(self, trap, game, dt):
|
||||
return False
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Within:
|
||||
"""Player within N tiles of the trap centre (Euclidean radius)."""
|
||||
|
||||
@@ -181,6 +192,48 @@ def make_condition(spec):
|
||||
raise ValueError(f"unrecognized trigger condition: {spec!r}")
|
||||
|
||||
|
||||
# --- per-target trigger / delay maps -----------------------------------------
|
||||
# A trap with more than one triggerable behaviour (a block can move *and* phase)
|
||||
# lets each behaviour take its own trigger and delay. The value is either a
|
||||
# single condition/scalar applied to every target (the common case) or a map
|
||||
# keyed by target name (``motion``, ``phase``, …) with ``default`` covering the
|
||||
# rest. Target names are disjoint from the condition keywords above, so a bare
|
||||
# condition like ``{within: 2}`` is never mistaken for a target map.
|
||||
#
|
||||
# trigger: { within: 2 } # both motion and phase
|
||||
# trigger: { default: always, motion: { within: 5 } }
|
||||
# delay: { motion: 0.1, phase: 0.3 }
|
||||
|
||||
_NEVER = object() # sentinel: an unnamed target with no ``default`` -> off
|
||||
|
||||
|
||||
def _is_target_map(value, targets):
|
||||
"""True if ``value`` is a per-target map (a dict keyed by target names)
|
||||
rather than a single condition/scalar applied to every target."""
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
names = set(targets) | {"default"}
|
||||
return any(k in names for k in value)
|
||||
|
||||
|
||||
def _split_targets(value, targets, kind, missing):
|
||||
"""Resolve a scalar-or-map ``trigger``/``delay`` value into
|
||||
``{target: spec}``. A scalar (or single-condition dict) applies to every
|
||||
target; a per-target map assigns each named target its own spec, with
|
||||
``default`` covering the rest and unnamed-without-default falling back to
|
||||
``missing``."""
|
||||
if not _is_target_map(value, targets):
|
||||
return {t: value for t in targets}
|
||||
valid = set(targets) | {"default"}
|
||||
unknown = [k for k in value if k not in valid]
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"{kind}: unknown target(s) {unknown}; valid targets are {sorted(valid)}"
|
||||
)
|
||||
fallback = value.get("default", missing)
|
||||
return {t: value.get(t, fallback) for t in targets}
|
||||
|
||||
|
||||
class Trap:
|
||||
def __init__(self, spec, level):
|
||||
self.spec = spec
|
||||
@@ -340,23 +393,44 @@ class Trap:
|
||||
|
||||
# --- triggers ------------------------------------------------------------
|
||||
# Traps with an activation condition call _init_trigger() in __init__,
|
||||
# _reset_trigger() in reset(), and triggered() each frame.
|
||||
def _init_trigger(self, spec):
|
||||
self.trigger = make_condition(spec.get("trigger", "always"))
|
||||
self.trig_delay = float(spec.get("delay", 0.0)) # arm delay (seconds)
|
||||
self._trig_timer = 0.0
|
||||
# _reset_trigger() in reset(), and triggered(target) each frame. A trap with
|
||||
# a single behaviour uses the lone default target; a block that both moves
|
||||
# and phases names two ("motion", "phase") so each can take its own trigger
|
||||
# and arm delay (see the per-target docs above make_condition's helpers).
|
||||
def _init_trigger(self, spec, targets=("main",)):
|
||||
trig_specs = _split_targets(
|
||||
spec.get("trigger", "always"), targets, "trigger", _NEVER
|
||||
)
|
||||
delay_specs = _split_targets(spec.get("delay", 0.0), targets, "delay", 0.0)
|
||||
self._trig = {}
|
||||
self._trig_never = set() # targets that resolved to a never-firing condition
|
||||
for t in targets:
|
||||
cspec = trig_specs[t]
|
||||
if cspec is _NEVER:
|
||||
cond = _Never()
|
||||
self._trig_never.add(t)
|
||||
else:
|
||||
cond = make_condition(cspec)
|
||||
self._trig[t] = {
|
||||
"cond": cond,
|
||||
"delay": float(delay_specs[t] or 0.0), # arm delay (seconds)
|
||||
"timer": 0.0,
|
||||
}
|
||||
|
||||
def _reset_trigger(self):
|
||||
self._trig_timer = 0.0
|
||||
self.trigger.reset()
|
||||
for slot in self._trig.values():
|
||||
slot["timer"] = 0.0
|
||||
slot["cond"].reset()
|
||||
|
||||
def triggered(self, game, dt):
|
||||
"""True while the trigger condition holds. If ``delay`` is set, the
|
||||
condition must hold *continuously* for that long first; leaving the
|
||||
condition resets the countdown."""
|
||||
raw = self.trigger.evaluate(self, game, dt)
|
||||
self._trig_timer = self._trig_timer + dt if raw else 0.0
|
||||
return raw and self._trig_timer >= self.trig_delay
|
||||
def triggered(self, game, dt, target="main"):
|
||||
"""True while ``target``'s trigger condition holds. If a ``delay`` is
|
||||
set, the condition must hold *continuously* for that long first; leaving
|
||||
the condition resets the countdown. Each target keeps its own condition
|
||||
instance and countdown, so their timers never interfere."""
|
||||
slot = self._trig[target]
|
||||
raw = slot["cond"].evaluate(self, game, dt)
|
||||
slot["timer"] = slot["timer"] + dt if raw else 0.0
|
||||
return raw and slot["timer"] >= slot["delay"]
|
||||
|
||||
|
||||
# --- spike: emerges to kill -------------------------------------------------
|
||||
@@ -411,22 +485,34 @@ class Spike(Trap):
|
||||
|
||||
# --- 3. block: the unified stationary / sliding / patrolling / spike block ---
|
||||
class Block(Trap):
|
||||
"""A block that may move and/or be deadly — one trap covering stationary
|
||||
blocks, proximity sliders, patrolling platforms, and spike blocks.
|
||||
"""A block that may move, phase in/out, and/or be deadly — one trap covering
|
||||
stationary blocks, proximity sliders, patrolling platforms, spike blocks,
|
||||
fake blocks, crumbling blocks, and phase blocks.
|
||||
|
||||
path: list of [col,row] waypoints (default just ``[at]`` = stationary).
|
||||
move: [dcol,drow] shorthand for a 2-point path [at, at+move] (a slider).
|
||||
trigger: when it moves (default ``always``). Sensors track the block's live
|
||||
position, so a condition like ``{dir: above}`` keeps it going while
|
||||
the player rides it.
|
||||
trigger: when it activates (default ``always``). Sensors track the block's
|
||||
live position, so a condition like ``{dir: above}`` keeps it going
|
||||
while the player rides it. A block that both moves and phases can
|
||||
give each its own trigger via a per-target map keyed ``motion`` /
|
||||
``phase`` (see the per-target docs near make_condition).
|
||||
mode: ``once`` (default) extends to the last point while triggered and
|
||||
retreats to the first when not — the slider/dropper behaviour;
|
||||
``loop`` / ``pingpong`` cycle the whole path continuously (patrol).
|
||||
deadly: true -> a hazard (spikes) instead of a solid.
|
||||
speed: px/s. sprite: override (default spike_block if deadly, else moving_block).
|
||||
delay/release: (``once`` mode) the trigger must hold for ``delay`` seconds to
|
||||
start extending and be clear for ``release`` seconds to start
|
||||
retracting — hysteresis that stops boundary jitter.
|
||||
deadly: true -> a hazard (spikes) instead of a solid. On a phase block the
|
||||
hazard is only live while it's materialised.
|
||||
phase: true -> invisible/intangible until triggered, then fades into a
|
||||
solid (or, if ``deadly``, a hazard) over ``fade`` seconds and back
|
||||
out when the trigger releases. Forming into the player is lethal,
|
||||
though a non-deadly one first tries to shove a player merely
|
||||
clipping an edge clear.
|
||||
speed: px/s. sprite: override (default: spike_block if deadly, fake_block
|
||||
if fake, crumble_block if crumble, phase_block if phase, else
|
||||
moving_block).
|
||||
delay/release: the trigger must hold for ``delay`` seconds to activate; in
|
||||
``once`` motion it must also be clear for ``release`` seconds to
|
||||
start retracting — hysteresis that stops boundary jitter. ``delay``
|
||||
may be a per-target map too.
|
||||
"""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
@@ -447,27 +533,44 @@ class Block(Trap):
|
||||
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
|
||||
self.crumble_delay = float(spec.get("crumble_delay", 0.4))
|
||||
self.respawn = float(spec.get("respawn", 2.5))
|
||||
self.sprite = spec.get(
|
||||
"sprite",
|
||||
(
|
||||
"spike_block"
|
||||
if self.deadly
|
||||
else (
|
||||
"fake_block"
|
||||
if self.fake
|
||||
else "crumble_block" if self.crumble else "moving_block"
|
||||
)
|
||||
),
|
||||
)
|
||||
self.phase = bool(spec.get("phase", False)) # fades in/out on trigger
|
||||
self.fade = float(spec.get("fade", 0.3)) # phase fade-in/out seconds
|
||||
self.sprite = spec.get("sprite", self._default_sprite())
|
||||
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
||||
# `home` (default): sense the trigger from the resting cell, so the block
|
||||
# moving away can't toggle its own trigger (no jitter). `current`: sense
|
||||
# from the live position — for blocks the player rides (e.g. a dropper),
|
||||
# so it stays put while ridden instead of pulling back.
|
||||
self.sense = spec.get("sense", "home")
|
||||
self._init_trigger(spec)
|
||||
# Motion and phasing each take their own trigger/delay (a single trigger
|
||||
# applies to both). Crumble is contact-driven, not trigger-driven.
|
||||
self._init_trigger(spec, targets=("motion", "phase"))
|
||||
if self.level.debug:
|
||||
self._warn_dead_trigger("phase", self.phase)
|
||||
self._warn_dead_trigger("motion", len(self.points) >= 2)
|
||||
self.reset()
|
||||
|
||||
def _default_sprite(self):
|
||||
if self.deadly:
|
||||
return "spike_block"
|
||||
if self.fake:
|
||||
return "fake_block"
|
||||
if self.crumble:
|
||||
return "crumble_block"
|
||||
if self.phase:
|
||||
return "phase_block"
|
||||
return "moving_block"
|
||||
|
||||
def _warn_dead_trigger(self, target, enabled):
|
||||
# Design aid: a capability that's on but whose trigger never fires (a
|
||||
# per-target map that named other targets but omitted this one and
|
||||
# `default`) is almost always a mistake — flag it in the debug view.
|
||||
if enabled and target in self._trig_never:
|
||||
print(
|
||||
f"[level] block at [{self.col},{self.row}]: {target} is enabled "
|
||||
f"but its trigger never fires (add a '{target}' or 'default' target)"
|
||||
)
|
||||
|
||||
def sensor_rect(self):
|
||||
return self.base_rect if self.sense == "home" else self._rect()
|
||||
|
||||
@@ -498,13 +601,19 @@ class Block(Trap):
|
||||
self._origin = (0.0, 0.0)
|
||||
self.prev = (self.x, self.y)
|
||||
self.dir = 1 # pingpong direction
|
||||
self.phase = "rest" # once mode: rest|extending|extended|retracting
|
||||
self.motion_phase = "rest" # once mode: rest|extending|extended|retracting
|
||||
self._release_t = 0.0
|
||||
# index of the waypoint we're AT (once) / heading toward (patrol)
|
||||
self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0)
|
||||
self.cstate = "solid" # crumble: solid|crumbling|gone
|
||||
self.ctimer = 0.0
|
||||
self.shake = 0.0
|
||||
# phase: fade-in progress (0..1) and whether it's currently collidable.
|
||||
self.alpha = 0.0
|
||||
self.materialized = False
|
||||
# Set the frame a block (crumble re-forming or phase forming) materialises
|
||||
# into the player — lethal this frame. Crumble and phase are mutually
|
||||
# exclusive in practice, so they share the flag.
|
||||
self.emerge_kill = False
|
||||
|
||||
def update(self, dt, game):
|
||||
@@ -514,8 +623,8 @@ class Block(Trap):
|
||||
# block executes its path/move relative to the parent it rides.
|
||||
if not self._mounted:
|
||||
self.prev = (self.x, self.y)
|
||||
active = self.triggered(game, dt) # call every frame to keep the timer live
|
||||
if len(self.points) >= 2:
|
||||
active = self.triggered(game, dt, "motion")
|
||||
step = self.speed * dt
|
||||
if self.mode == "once":
|
||||
self._update_once(active, dt, step)
|
||||
@@ -523,6 +632,8 @@ class Block(Trap):
|
||||
self._update_patrol(active, step)
|
||||
if self.crumble:
|
||||
self._update_crumble(dt, game)
|
||||
if self.phase:
|
||||
self._update_phase(dt, game)
|
||||
|
||||
def _update_crumble(self, dt, game):
|
||||
self.emerge_kill = False
|
||||
@@ -552,6 +663,78 @@ class Block(Trap):
|
||||
self.ctimer = 0.0
|
||||
self.shake = 0.0
|
||||
|
||||
# How deep the player may be into a forming phase block and still be nudged
|
||||
# clear rather than killed. A shallow clip (feet/shoulder in the cell) gets
|
||||
# shoved out; forming through their middle stays lethal.
|
||||
_EDGE_GRACE = 0.5 # fraction of a tile
|
||||
|
||||
def _update_phase(self, dt, game):
|
||||
# Materialise while the `phase` trigger holds (fade alpha 0->1), fade
|
||||
# back out when it releases. A block forming into the player is lethal;
|
||||
# a non-deadly (solid) one first tries to shove a player who's only
|
||||
# clipping an edge clear, while a deadly one simply kills.
|
||||
self.emerge_kill = False
|
||||
active = self.triggered(game, dt, "phase")
|
||||
if active:
|
||||
forming = self.alpha == 0.0 and not self.materialized
|
||||
if (
|
||||
forming
|
||||
and not self.deadly
|
||||
and game.player.rect.colliderect(self._rect())
|
||||
and not self._eject_player(game)
|
||||
):
|
||||
self.emerge_kill = True
|
||||
return
|
||||
self.alpha = min(1.0, self.alpha + dt / self.fade)
|
||||
self.materialized = True
|
||||
else:
|
||||
self.alpha = max(0.0, self.alpha - dt / self.fade)
|
||||
if self.alpha == 0.0:
|
||||
self.materialized = False
|
||||
|
||||
def _eject_player(self, game):
|
||||
"""Nudge a player who's only clipping the forming block out of its cell.
|
||||
|
||||
Returns True if they were pushed clear (forgiving). Returns False — leave
|
||||
it lethal — when the block is forming through the player's middle (too
|
||||
deep to fairly eject) or the shove would press them into another solid
|
||||
(squished against something)."""
|
||||
p = game.player.rect
|
||||
b = self._rect()
|
||||
# Distance to move the player to clear the block on each side.
|
||||
outs = {
|
||||
"up": p.bottom - b.top,
|
||||
"down": b.bottom - p.top,
|
||||
"left": p.right - b.left,
|
||||
"right": b.right - p.left,
|
||||
}
|
||||
side = min(outs, key=outs.get)
|
||||
dist = outs[side]
|
||||
if dist > self.tile * self._EDGE_GRACE:
|
||||
return False # deep overlap — forming through them
|
||||
dx, dy = _DIRS[side]
|
||||
moved = p.move(dx * dist, dy * dist)
|
||||
# The block isn't materialised yet, so it's absent from solid_rects();
|
||||
# any hit here is a *different* solid backing them — no room to dodge.
|
||||
if any(moved.colliderect(s) for s in self.level.solid_rects()):
|
||||
return False
|
||||
player = game.player
|
||||
player.fx += dx * dist
|
||||
player.fy += dy * dist
|
||||
if dx:
|
||||
player.vx = 0.0
|
||||
if dy:
|
||||
player.vy = 0.0
|
||||
player._sync_rect()
|
||||
return True
|
||||
|
||||
def finalize_on_death(self):
|
||||
# A phase block caught mid-fade (or forming into the player) snaps fully
|
||||
# visible so the frozen death tableau shows the block that got them.
|
||||
if self.phase and (self.emerge_kill or self.alpha > 0.0):
|
||||
self.alpha = 1.0
|
||||
self.materialized = True
|
||||
|
||||
def _step_to(self, tgt, step):
|
||||
"""Move toward tgt by step; snap and return True on arrival."""
|
||||
tx, ty = tgt
|
||||
@@ -570,27 +753,27 @@ class Block(Trap):
|
||||
# mid-stroke reversal, so a block that moves out of its own sensor range
|
||||
# can't buzz. Hysteresis (delay/release) smooths the parked decisions.
|
||||
n = len(self.points)
|
||||
if self.phase == "rest":
|
||||
if self.motion_phase == "rest":
|
||||
if active:
|
||||
self.phase = "extending"
|
||||
elif self.phase == "extending":
|
||||
self.motion_phase = "extending"
|
||||
elif self.motion_phase == "extending":
|
||||
if self._step_to(self.points[self.idx + 1], step):
|
||||
self.idx += 1
|
||||
if self.idx >= n - 1:
|
||||
self.phase = "extended"
|
||||
self.motion_phase = "extended"
|
||||
self._release_t = 0.0
|
||||
elif self.phase == "extended":
|
||||
elif self.motion_phase == "extended":
|
||||
if active:
|
||||
self._release_t = 0.0
|
||||
else:
|
||||
self._release_t += dt
|
||||
if self._release_t >= self.release:
|
||||
self.phase = "retracting"
|
||||
elif self.phase == "retracting":
|
||||
self.motion_phase = "retracting"
|
||||
elif self.motion_phase == "retracting":
|
||||
if self._step_to(self.points[self.idx - 1], step):
|
||||
self.idx -= 1
|
||||
if self.idx <= 0:
|
||||
self.phase = "rest"
|
||||
self.motion_phase = "rest"
|
||||
|
||||
def _update_patrol(self, active, step):
|
||||
tgt = self.points[self.idx] if active else self.points[0]
|
||||
@@ -620,16 +803,24 @@ class Block(Trap):
|
||||
return self._rect()
|
||||
|
||||
def _intangible(self):
|
||||
return self.deadly or self.fake or (self.crumble and self.cstate == "gone")
|
||||
if self.deadly or self.fake:
|
||||
return True
|
||||
if self.crumble and self.cstate == "gone":
|
||||
return True
|
||||
if self.phase and not self.materialized:
|
||||
return True
|
||||
return False
|
||||
|
||||
def solid_rects(self):
|
||||
return [] if self._intangible() else [self._rect()]
|
||||
|
||||
def hazard_rects(self):
|
||||
rects = []
|
||||
if self.deadly:
|
||||
# A deadly block is a hazard whenever it's present — for a phase block
|
||||
# that means only once it has materialised.
|
||||
if self.deadly and (not self.phase or self.materialized):
|
||||
rects.append(self._rect().inflate(-4, -4))
|
||||
if self.crumble and self.emerge_kill:
|
||||
if self.emerge_kill: # crumble re-forming / phase forming into the player
|
||||
rects.append(self._rect())
|
||||
return rects
|
||||
|
||||
@@ -653,10 +844,22 @@ class Block(Trap):
|
||||
if self.level.debug:
|
||||
self._debug_ghost(surface, assets, self.sprite)
|
||||
return
|
||||
# phase block still dormant: invisible (debug tints the cell so it shows).
|
||||
if self.phase and self.alpha <= 0.0:
|
||||
if self.level.debug:
|
||||
self._debug_tint(surface, (120, 210, 240), alpha=45)
|
||||
return
|
||||
rect = self._rect()
|
||||
if self.crumble and self.cstate == "crumbling":
|
||||
rect = rect.move(int(self.shake), 0)
|
||||
surface.blit(assets.get(self.sprite, self.tile, self.tile), self._rt(rect))
|
||||
img = assets.get(self.sprite, self.tile, self.tile)
|
||||
if self.phase and self.alpha < 1.0: # fade in/out
|
||||
img = img.copy()
|
||||
img.fill(
|
||||
(255, 255, 255, int(255 * self.alpha)),
|
||||
special_flags=pygame.BLEND_RGBA_MULT,
|
||||
)
|
||||
surface.blit(img, self._rt(rect))
|
||||
if self.fake and self.level.debug:
|
||||
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
|
||||
|
||||
@@ -829,121 +1032,12 @@ class Warp(Trap):
|
||||
self._debug_tint(surface, (210, 80, 235), dest, 45)
|
||||
|
||||
|
||||
# --- 10. phase block: fades into a solid on trigger -------------------------
|
||||
class PhaseBlock(Trap):
|
||||
"""Invisible and intangible until its ``trigger`` fires, then it fades into
|
||||
a solid obstacle over ``fade`` seconds (and fades back out when the trigger
|
||||
releases). If the player is standing in the cell the instant it *starts*
|
||||
appearing, they're killed."""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.fade = float(spec.get("fade", 0.3))
|
||||
self._init_trigger(spec)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self._reset_trigger()
|
||||
self.alpha = 0.0
|
||||
self.solid = False
|
||||
self.emerge_kill = False
|
||||
|
||||
# How deep the player may be into the cell and still be nudged clear rather
|
||||
# than killed. A shallow clip (feet/shoulder in the cell) gets shoved out;
|
||||
# forming through their middle stays lethal.
|
||||
_EDGE_GRACE = 0.5 # fraction of a tile
|
||||
|
||||
def update(self, dt, game):
|
||||
self.emerge_kill = False
|
||||
active = self.triggered(game, dt)
|
||||
if active:
|
||||
if (
|
||||
self.alpha == 0.0
|
||||
and not self.solid
|
||||
and game.player.rect.colliderect(self.base_rect)
|
||||
):
|
||||
# Forming into the player. If they're only clipping an edge, shove
|
||||
# them clear and let the block solidify behind them; only if it's
|
||||
# forming through their middle — or the shove would squish them
|
||||
# into a solid — is it lethal.
|
||||
if not self._eject_player(game):
|
||||
self.emerge_kill = True
|
||||
return
|
||||
self.alpha = min(1.0, self.alpha + dt / self.fade)
|
||||
self.solid = True
|
||||
else:
|
||||
self.alpha = max(0.0, self.alpha - dt / self.fade)
|
||||
if self.alpha == 0.0:
|
||||
self.solid = False
|
||||
|
||||
def _eject_player(self, game):
|
||||
"""Nudge a player who's only clipping the forming block out of its cell.
|
||||
|
||||
Returns True if they were pushed clear (forgiving). Returns False — leave
|
||||
it lethal — when the block is forming through the player's middle (too
|
||||
deep to fairly eject) or the shove would press them into another solid
|
||||
(squished against something, a crush as usual)."""
|
||||
p = game.player.rect
|
||||
b = self.base_rect
|
||||
# Distance to move the player to clear the block on each side.
|
||||
outs = {
|
||||
"up": p.bottom - b.top,
|
||||
"down": b.bottom - p.top,
|
||||
"left": p.right - b.left,
|
||||
"right": b.right - p.left,
|
||||
}
|
||||
side = min(outs, key=outs.get)
|
||||
dist = outs[side]
|
||||
if dist > self.tile * self._EDGE_GRACE:
|
||||
return False # deep overlap — forming through them
|
||||
dx, dy = _DIRS[side]
|
||||
moved = p.move(dx * dist, dy * dist)
|
||||
# The block isn't solid yet, so it's absent from solid_rects(); any hit
|
||||
# here is a *different* solid backing them — no room to dodge = squished.
|
||||
if any(moved.colliderect(s) for s in self.level.solid_rects()):
|
||||
return False
|
||||
player = game.player
|
||||
player.fx += dx * dist
|
||||
player.fy += dy * dist
|
||||
if dx:
|
||||
player.vx = 0.0
|
||||
if dy:
|
||||
player.vy = 0.0
|
||||
player._sync_rect()
|
||||
return True
|
||||
|
||||
def finalize_on_death(self):
|
||||
# If we were forming when the player died, snap to fully visible so the
|
||||
# frozen death tableau shows the block that got them.
|
||||
if self.emerge_kill or self.alpha > 0.0:
|
||||
self.alpha = 1.0
|
||||
self.solid = True
|
||||
|
||||
def solid_rects(self):
|
||||
return [self.base_rect] if self.solid else []
|
||||
|
||||
def hazard_rects(self):
|
||||
return [self.base_rect] if self.emerge_kill else []
|
||||
|
||||
def draw(self, surface, assets):
|
||||
if self.alpha <= 0.0:
|
||||
if self.level.debug:
|
||||
self._debug_tint(surface, (120, 210, 240), alpha=45)
|
||||
return
|
||||
img = assets.get("phase_block", self.tile, self.tile).copy()
|
||||
img.fill(
|
||||
(255, 255, 255, int(255 * self.alpha)), special_flags=pygame.BLEND_RGBA_MULT
|
||||
)
|
||||
surface.blit(img, self._rt(self.base_rect))
|
||||
|
||||
|
||||
# --- registry + factory ------------------------------------------------------
|
||||
TRAP_TYPES = {
|
||||
"spike": Spike,
|
||||
"block": Block,
|
||||
"arrow_shooter": ArrowShooter,
|
||||
"warp": Warp,
|
||||
"phase_block": PhaseBlock,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Spike, arrow_shooter, warp, phase_block + the invisible flag & block arrays."""
|
||||
"""Spike, arrow_shooter, warp, phase blocks + the invisible flag & block arrays."""
|
||||
|
||||
import hashlib
|
||||
import pygame
|
||||
from conftest import FakeGame, step, run, place, hold, DT
|
||||
from game.assets import AssetStore
|
||||
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
|
||||
from game.traps import Spike, ArrowShooter, Warp, Block
|
||||
|
||||
|
||||
# --- spike -------------------------------------------------------------------
|
||||
@@ -211,17 +211,18 @@ map: |
|
||||
#P......G#
|
||||
##########
|
||||
traps:
|
||||
- type: phase_block
|
||||
- type: block
|
||||
at: [4, 3]
|
||||
phase: true
|
||||
fade: 0.2
|
||||
trigger: { within: 2 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
place(g, 1, 3)
|
||||
step(g)
|
||||
assert not pb.solid and pb.alpha == 0 # dormant far away
|
||||
assert not pb.materialized and pb.alpha == 0 # dormant far away
|
||||
run(g, 40, lambda i: hold(right=True))
|
||||
assert pb.solid and pb.alpha > 0 # phased in solid on approach
|
||||
assert pb.materialized and pb.alpha > 0 # phased in solid on approach
|
||||
|
||||
|
||||
def test_phase_block_kills_if_inside_when_it_forms(make_game):
|
||||
@@ -234,8 +235,9 @@ map: |
|
||||
#.....G#
|
||||
########
|
||||
traps:
|
||||
- type: phase_block
|
||||
- type: block
|
||||
at: [3, 2]
|
||||
phase: true
|
||||
fade: 0.2
|
||||
trigger: { within: 3 }
|
||||
""")
|
||||
@@ -261,8 +263,9 @@ map: |
|
||||
#......G#
|
||||
#########
|
||||
traps:
|
||||
- type: phase_block
|
||||
- type: block
|
||||
at: [4, 1]
|
||||
phase: true
|
||||
fade: 0.2
|
||||
trigger: { within: 5 }
|
||||
""")
|
||||
@@ -278,7 +281,7 @@ traps:
|
||||
# survived, and pushed out to the left so it no longer overlaps the cell
|
||||
assert g.deaths == d0
|
||||
assert g.player.rect.right <= b.left
|
||||
assert pb.solid and not pb.emerge_kill
|
||||
assert pb.materialized and not pb.emerge_kill
|
||||
|
||||
|
||||
def test_phase_block_kills_when_shove_would_squish(make_game):
|
||||
@@ -292,8 +295,9 @@ map: |
|
||||
#.....G#
|
||||
########
|
||||
traps:
|
||||
- type: phase_block
|
||||
- type: block
|
||||
at: [1, 1]
|
||||
phase: true
|
||||
fade: 0.2
|
||||
trigger: { within: 5 }
|
||||
""")
|
||||
@@ -326,8 +330,9 @@ map: |
|
||||
#.....G#
|
||||
########
|
||||
traps:
|
||||
- type: phase_block
|
||||
- type: block
|
||||
at: [3, 2]
|
||||
phase: true
|
||||
fade: 0.5
|
||||
trigger: { within: 3 }
|
||||
""")
|
||||
@@ -337,7 +342,44 @@ traps:
|
||||
step(g)
|
||||
assert 0 < pb.alpha < 1
|
||||
g._start_death() # die from something
|
||||
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
|
||||
assert pb.alpha == 1.0 and pb.materialized # snapped fully visible for the freeze
|
||||
|
||||
|
||||
def test_deadly_phase_block_harmless_until_materialized(make_game):
|
||||
# A deadly phase block is neither solid nor a hazard while dormant, then
|
||||
# becomes a (never-solid) hazard once it materialises.
|
||||
g = make_game("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
##########
|
||||
#........#
|
||||
#........#
|
||||
#P......G#
|
||||
##########
|
||||
traps:
|
||||
- type: block
|
||||
at: [4, 2]
|
||||
phase: true
|
||||
deadly: true
|
||||
fade: 0.15
|
||||
trigger: { within: 3 }
|
||||
""")
|
||||
b = g.level.traps[0]
|
||||
place(g, 1, 3) # far away -> dormant
|
||||
step(g)
|
||||
assert not b.solid_rects() and not b.hazard_rects()
|
||||
# Materialise it by standing on the cell; a deadly block never becomes solid.
|
||||
place(g, 4, 2)
|
||||
d0 = g.deaths
|
||||
killed = False
|
||||
for _ in range(12):
|
||||
step(g)
|
||||
if g.deaths > d0:
|
||||
killed = True
|
||||
break
|
||||
assert killed # the materialised hazard kills
|
||||
assert not b.solid_rects() # deadly -> never solid, even materialised
|
||||
|
||||
|
||||
def test_debug_overscan_reveals_offmap_trap(make_level):
|
||||
|
||||
@@ -109,3 +109,121 @@ traps:
|
||||
armed = i
|
||||
break
|
||||
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
|
||||
|
||||
|
||||
# --- per-target triggers/delays (a block can move AND phase) -----------------
|
||||
def _block(make_level, body):
|
||||
lvl = make_level(
|
||||
"name: t\ntile_size: 32\nmap: |\n ######\n #P..G#\n ######\ntraps:\n" + body
|
||||
)
|
||||
return lvl.traps[0]
|
||||
|
||||
|
||||
def _cond(block, target):
|
||||
return type(block._trig[target]["cond"]).__name__
|
||||
|
||||
|
||||
def test_trigger_single_condition_applies_to_all_targets(make_level):
|
||||
# A bare condition (not a target map) drives both motion and phase.
|
||||
b = _block(
|
||||
make_level, " - type: block\n at: [2, 1]\n trigger: { within: 2 }\n"
|
||||
)
|
||||
assert _cond(b, "motion") == "_Within"
|
||||
assert _cond(b, "phase") == "_Within"
|
||||
|
||||
|
||||
def test_trigger_all_any_is_a_single_condition(make_level):
|
||||
# `all`/`any` are condition keywords, not target names -> one condition.
|
||||
b = _block(
|
||||
make_level,
|
||||
" - type: block\n at: [2, 1]\n"
|
||||
" trigger: { any: [ { within: 2 }, { dir: left } ] }\n",
|
||||
)
|
||||
assert _cond(b, "motion") == "_Any" and _cond(b, "phase") == "_Any"
|
||||
|
||||
|
||||
def test_trigger_per_target_map_with_default(make_level):
|
||||
# `default` covers unnamed targets; a named target overrides it.
|
||||
b = _block(
|
||||
make_level,
|
||||
" - type: block\n at: [2, 1]\n phase: true\n"
|
||||
" trigger:\n default: always\n motion: { within: 5 }\n",
|
||||
)
|
||||
assert _cond(b, "motion") == "_Within" # named
|
||||
assert _cond(b, "phase") == "_Always" # from default
|
||||
|
||||
|
||||
def test_trigger_unnamed_target_off_without_default(make_level):
|
||||
# A map naming only motion (no default) leaves phase with no trigger -> off.
|
||||
b = _block(
|
||||
make_level,
|
||||
" - type: block\n at: [2, 1]\n phase: true\n"
|
||||
" trigger: { motion: always }\n",
|
||||
)
|
||||
assert "phase" in b._trig_never
|
||||
assert _cond(b, "phase") == "_Never" and _cond(b, "motion") == "_Always"
|
||||
|
||||
|
||||
def test_trigger_unknown_target_raises(make_level):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_block(
|
||||
make_level,
|
||||
" - type: block\n at: [2, 1]\n"
|
||||
" trigger: { motion: always, bogus: always }\n",
|
||||
)
|
||||
|
||||
|
||||
def test_delay_scalar_applies_to_all_targets(make_level):
|
||||
b = _block(make_level, " - type: block\n at: [2, 1]\n delay: 0.2\n")
|
||||
assert b._trig["motion"]["delay"] == 0.2 and b._trig["phase"]["delay"] == 0.2
|
||||
|
||||
|
||||
def test_delay_per_target_map(make_level):
|
||||
b = _block(
|
||||
make_level,
|
||||
" - type: block\n at: [2, 1]\n delay: { motion: 0.1, phase: 0.3 }\n",
|
||||
)
|
||||
assert b._trig["motion"]["delay"] == 0.1 and b._trig["phase"]["delay"] == 0.3
|
||||
# unnamed-without-default delay falls back to 0 (no arm delay)
|
||||
b2 = _block(
|
||||
make_level, " - type: block\n at: [2, 1]\n delay: { motion: 0.1 }\n"
|
||||
)
|
||||
assert b2._trig["phase"]["delay"] == 0.0
|
||||
|
||||
|
||||
def test_motion_and_phase_independent_triggers(make_level):
|
||||
# The user's case: a phase block that's *always* moving but only materialises
|
||||
# when the player is near. Motion and phase read their own triggers.
|
||||
lvl = make_level("""
|
||||
name: t
|
||||
tile_size: 32
|
||||
map: |
|
||||
############
|
||||
#..........#
|
||||
#P........G#
|
||||
############
|
||||
traps:
|
||||
- type: block
|
||||
at: [4, 1]
|
||||
phase: true
|
||||
move: [3, 0]
|
||||
speed: 200
|
||||
fade: 0.2
|
||||
trigger:
|
||||
motion: always
|
||||
phase: { within: 2 }
|
||||
""")
|
||||
b = lvl.traps[0]
|
||||
b.reset()
|
||||
far = FakeGame(pygame.Rect(1 * 32, 1 * 32, 20, 28)) # player at the far end
|
||||
x0 = b._rect().x
|
||||
for _ in range(20):
|
||||
b.tick(1 / 60, far)
|
||||
assert b._rect().x != x0 # moved (motion: always) ...
|
||||
assert b.alpha == 0.0 # ... but hasn't materialised (player far)
|
||||
near = FakeGame(pygame.Rect(b._rect().x + 2, 1 * 32, 20, 28))
|
||||
for _ in range(10):
|
||||
b.tick(1 / 60, near)
|
||||
assert b.alpha > 0.0 # phases in once the player is close
|
||||
|
||||
Reference in New Issue
Block a user