Files
transcience/src/actors/boss/boss_phase.gd
T
claude e0c1e0d5c6
ci / verify (push) Successful in 49s
Stage 5: bosses that move, attacks that warn, and a second boss
Boss movement is a property of the PHASE, not of the boss -- a fight that
stands still and then starts hunting you is one boss with two phases. Four
modes (STATIC, ORBIT, CHASE, WAYPOINTS) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.

CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.

The room clamp moved to after movement, where it is finally load-bearing. It
was a no-op while every boss stood still, which is exactly when an invariant
is cheapest to establish: boss rooms deliberately do not lock, so walking out
is always an escape, and that only holds if the boss cannot follow.

TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.

The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.

Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.

Two things found while finishing:

  - tools/export_content.gd had a hand-maintained boss list and had already
    gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
    ALL_BOSSES now feed the export tool, the renderer and five tests that each
    kept their own copy.
  - diag_loot failed intermittently after another diagnostic. Taking over from
    the bot cleared its input queue but not its HELD input, so a starved server
    coasted on the bot's last movement vector for half a second and walked the
    player off the item it had been placed on. The press arrived correctly,
    which is why "the press reached the simulation" passed while everything it
    should have caused failed.

check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:32:12 +02:00

54 lines
2.3 KiB
GDScript

class_name BossPhase
extends Resource
## One stage of a boss fight: a looping timeline of emitters, entered when the
## boss drops below [member enter_at_hp_fraction].
@export var name: String = "Phase"
## The phase becomes active once hp/max_hp is at or below this. Phases are
## evaluated in array order, so list them from 1.0 downwards.
@export_range(0.0, 1.0, 0.01) var enter_at_hp_fraction: float = 1.0
## The phase timeline wraps at this many ticks. Emitter [member start_tick] and
## [member end_tick] are relative to the start of each loop.
@export var loop_ticks: int = 600
## Ticks of no fire after entering the phase, so the transition is readable.
@export var telegraph_ticks: int = 45
## Damage taken while in this phase is scaled by this. Use < 1.0 for armoured
## phases rather than adding hit points, so the fight length stays predictable.
@export var damage_taken_mult: float = 1.0
@export var emitters: Array[BulletEmitter] = []
@export_group("Movement")
## How the boss moves during this phase. Movement is a property of the PHASE,
## not of the boss: a fight that stands still and then starts hunting you is
## one boss with two phases, and expressing it any other way would put a
## per-boss branch in the simulation.
@export var move: Move = Move.STATIC
## Units per second. Zero is equivalent to STATIC.
@export var move_speed: float = 0.0
## ORBIT: radius around the arena's centre. CHASE: the distance it tries to
## hold from you -- closing all the way would mean a boss you cannot see past.
@export var move_param: float = 120.0
## WAYPOINTS: points in the arena as fractions of it, so one phase works in any
## room. (0,0) is the top-left corner of the fightable area, (1,1) the bottom
## right.
@export var waypoints: Array[Vector2] = []
## Ticks spent standing at each waypoint before moving on.
@export var waypoint_dwell: int = 60
enum Move {
## Never moves. Every phase written before bosses could move.
STATIC,
## Circles the centre of its arena at [member move_param] radius.
ORBIT,
## Closes on the nearest player, holding [member move_param] distance.
CHASE,
## Walks a fixed circuit of [member waypoints], pausing at each.
WAYPOINTS,
}
## Whether this phase actually moves the boss. Derived rather than stored, so a
## phase cannot claim to move and then sit still.
func moves() -> bool:
return move != Move.STATIC and move_speed > 0.0