Stage 5: bosses that move, attacks that warn, and a second boss
ci / verify (push) Successful in 49s
ci / verify (push) Successful in 49s
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>
This commit is contained in:
1 parent
cb2c1e7840
commit
e0c1e0d5c6
34 files changed
+1498
-49
No files matched your search
@@ -0,0 +1,84 @@
|
||||
class_name TelegraphedStrikeEmitter
|
||||
extends BulletEmitter
|
||||
## Marks spots in the arena, then fills them with bullets a moment later.
|
||||
##
|
||||
## The whole point is the moment in between. A burst that appears at your feet
|
||||
## with no warning is not a pattern, it is a coin flip; the same burst with a
|
||||
## second of notice is a question about where you would rather be standing.
|
||||
## [member warn_ticks] is that second, and the client draws the marker from the
|
||||
## TELEGRAPH event this emits.
|
||||
##
|
||||
## Stateless, like every other emitter -- emitters are shared resources and two
|
||||
## bosses of the same kind must not stomp each other's timers. The strike
|
||||
## positions are therefore *derived* from the volley number rather than rolled
|
||||
## and remembered, so the announcement and the strike compute the same points
|
||||
## without anything being stored between them.
|
||||
|
||||
## Spots marked per volley.
|
||||
@export var strikes: int = 3
|
||||
## Ticks between the marker appearing and the burst landing. Must be shorter
|
||||
## than [member interval], or a volley would land after the next was announced.
|
||||
@export var warn_ticks: int = 75
|
||||
## Bullets in each burst, fired outwards from the marked spot.
|
||||
@export var burst_count: int = 12
|
||||
## Radius of the marked area, and the ring the burst is born on.
|
||||
@export var blast_radius: float = 64.0
|
||||
## Keeps strikes off the arena's walls, where half the burst would be wasted.
|
||||
@export var margin: float = 72.0
|
||||
## Distinguishes two strike emitters in one phase, which would otherwise derive
|
||||
## the same points and stack every burst on top of itself.
|
||||
@export var pattern_seed: int = 0
|
||||
|
||||
|
||||
## Armed on two ticks per volley: the announcement, and the strike itself.
|
||||
func should_fire(local_tick: int) -> bool:
|
||||
if not is_armed(local_tick) or interval <= 0:
|
||||
return false
|
||||
var into := posmod(local_tick - start_tick, interval)
|
||||
return into == 0 or into == posmod(warn_ticks, interval)
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if strikes <= 0 or interval <= 0:
|
||||
return
|
||||
var offset := ctx.local_tick - start_tick
|
||||
var volley := int(floor(float(offset) / float(interval)))
|
||||
var announcing := posmod(offset, interval) == 0
|
||||
for i in strikes:
|
||||
var at := strike_point(volley, i, ctx.bounds)
|
||||
if announcing:
|
||||
ctx.telegraph(at, blast_radius, warn_ticks)
|
||||
else:
|
||||
_burst(ctx, at)
|
||||
|
||||
|
||||
## The burst leaves the marked spot outwards, so standing on the marker is the
|
||||
## one place that is certainly wrong and the edge is survivable.
|
||||
func _burst(ctx: EmitContext, at: Vector2) -> void:
|
||||
if burst_count <= 0:
|
||||
return
|
||||
for i in burst_count:
|
||||
var a := TAU * (float(i) / float(burst_count))
|
||||
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25))
|
||||
|
||||
|
||||
## Where volley [param volley]'s strike [param index] lands, inside
|
||||
## [param bounds]. A pure function of its arguments: called once when the
|
||||
## warning goes up and again when the burst lands, and it has to agree with
|
||||
## itself both times without anything being remembered in between.
|
||||
func strike_point(volley: int, index: int, bounds: Rect2) -> Vector2:
|
||||
var inner := bounds.grow(-margin)
|
||||
if inner.size.x <= 0.0 or inner.size.y <= 0.0:
|
||||
inner = bounds
|
||||
return inner.position + Vector2(
|
||||
_unit(volley, index * 2) * inner.size.x,
|
||||
_unit(volley, index * 2 + 1) * inner.size.y)
|
||||
|
||||
|
||||
## A small integer hash in [0, 1). Not a good random number generator and does
|
||||
## not need to be -- it needs to be scattered, and it needs to give the same
|
||||
## answer twice.
|
||||
func _unit(a: int, b: int) -> float:
|
||||
var h := (a * 374761393 + b * 668265263 + pattern_seed * 2246822519) & 0x7FFFFFFF
|
||||
h = ((h ^ (h >> 13)) * 1274126177) & 0x7FFFFFFF
|
||||
return float((h >> 7) & 0xFFFF) / 65536.0
|
||||
Reference in new issue
Block a user