shrike/hunter.py¶

Part of SHRIKE.

  1"""The Shrike: the segmented hunter the whole economy is priced against.
  2
  3Two nodes share the work. :class:`HunterDirector` sits in the run scene for the
  4whole run: it consumes ``SIGNATURE_LOCKED``, counts arrivals, compresses the
  5warning per arrival and per biome, and spawns a :class:`Hunter` per visit. The
  6:class:`Hunter` is the animal itself: it runs the strict telegraph ladder,
  7tears in, scrambles the warp drive for the ten-second lockout, sweeps its
  8lantern, ramps its damage the longer it is tolerated, breaks off to devour
  9jettisoned scrap, cracks the sector open fifteen seconds in, and leaves when
 10the player does.
 11
 12The ladder never skips a stage and never depends on hearing: every stage is a
 13signal (``HUNTER_TELEGRAPH`` with ``"t60"``, ``"t30"``, ``"t0"``) that the
 14audio director voices and the HUD subtitles. Escalation compresses the warning
 15only; the lockout choreography, the quill window and the fight itself are the
 16same on the tenth arrival as on the first.
 17"""
 18
 19import math
 20import random
 21
 22from simvx.core import Area3D, Input, Material, Mesh, MeshInstance3D, Node, Node3D, Quat, Signal, SphereShape3D, Vec3
 23
 24from . import balance
 25from .artkit import build_shrike_segment, palette
 26from .hud import COLOUR_WARNING
 27from .power import SignalWiring
 28from .runtime import PLANE_Y, Groups, Layers, Services, SignalNames, heading_to_direction
 29from .sector import SECTOR_RADIUS
 30from .ship import AFTERBURNER_SPEED_MULT, CRUISE_SPEED, SHIP_LENGTH_UNITS, wrap_angle
 31from .vfx import LANTERN_OUTER_CONE_DEGREES, LANTERN_RANGE, VISIBLE_REACH_UNITS, LanternCone, Vfx
 32
 33# ============================================================================
 34# Numbers tuned by feel rather than by table; balance.py carries no name for
 35# them, so they live here.
 36# ============================================================================
 37
 38#: Cruise speed: 115 percent of the player's afterburner ceiling.
 39SHRIKE_SPEED = balance.SHRIKE_CRUISE_SPEED_MULT * CRUISE_SPEED * AFTERBURNER_SPEED_MULT
 40#: The wide turn arc, radians per second. Escape is angular, never linear.
 41SHRIKE_TURN_RATE_RADIANS_PER_S = 0.9
 42#: Body segments including the lantern head; total length is eight
 43#: player-ship lengths, so spacing falls out of the count.
 44SHRIKE_SEGMENT_COUNT = 8
 45SEGMENT_SPACING = balance.SHRIKE_LENGTH_MULT * SHIP_LENGTH_UNITS / (SHRIKE_SEGMENT_COUNT - 1)
 46#: Which body segments carry a shearable fin (and therefore a Quill).
 47FIN_SEGMENT_INDICES = (2, 3, 4, 5)
 48FIN_HIT_RADIUS = 1.6
 49HEAD_HIT_RADIUS = 2.2
 50#: Inside this range the eel banks into an orbit instead of ramming.
 51ORBIT_STANDOFF_UNITS = 22.0
 52#: How long the lantern burns after the one-second dim, and the gap between
 53#: sweeps while it re-tracks the ship.
 54LANTERN_BURN_S = 0.75
 55LANTERN_COOLDOWN_S = 1.75
 56#: Delay before the first sweep of an arrival, so the tear-in reads first.
 57LANTERN_FIRST_TRACK_S = 0.5
 58#: How fast the doused cone re-tracks the ship between sweeps, radians per
 59#: second. The pre-burn dim locks the aim, which is what makes it dodgeable.
 60LANTERN_TRACK_RATE_RADIANS_PER_S = 1.8
 61#: One tap of jettison dumps this much carried scrap to the devourer.
 62JETTISON_CHUNK_SCRAP = 40.0
 63
 64#: Where the tear-in lands, as a fraction of :data:`~shrike.vfx.VISIBLE_REACH_UNITS`,
 65#: the radius of what the pilot can see: just inside the edge of it.
 66#:
 67#: A fixed distance would be wrong. The visible field is a little under 25
 68#: units across from the hull, so a beast tearing in at 55 units would arrive
 69#: two screens away, close to within its 46-unit lantern reach while still off
 70#: screen, and burn the ship to death from somewhere the player could not look,
 71#: never once seen. The Shrike is eight ship-lengths of animal and the arrival
 72#: is meant to be the wave: it arrives on screen.
 73ARRIVAL_SCREEN_FRACTION = 0.88
 74ARRIVAL_DISTANCE_UNITS = VISIBLE_REACH_UNITS * ARRIVAL_SCREEN_FRACTION
 75#: Close enough to a scrap dump to stop and devour.
 76FEED_REACH_UNITS = 6.0
 77#: Seconds a departing hunter keeps swimming before the node is freed. The
 78#: grace is the beast leaving somewhere the pilot can still watch it leave, so
 79#: it only means anything inside the sector where it was seen; see
 80#: :meth:`Hunter.depart`.
 81DEPART_FREE_S = 3.0
 82#: Visual bob amplitude of the trailing segments, world units off the plane.
 83SEGMENT_BOB_UNITS = 0.35
 84
 85#: What the death ledger calls a hull the lantern finished, and what it calls
 86#: one the Shrike took by anything else. A death with the beast in the sector
 87#: is its death either way; which of the two is on the headline is the
 88#: difference between "it got me" and "the beam got me, and I could have moved".
 89LANTERN_BLOW_ID = "shrike_lantern"
 90HUNTER_BLOW_ID = "hunter"
 91#: The label a burn floats off the hull, so the price is read once rather than
 92#: inferred from a gauge that dropped while the pilot was looking elsewhere.
 93LANTERN_BURN_LABEL = "LANTERN BURN"
 94#: How long the burn's label stays up. Much longer than an ordinary float,
 95#: because this one has to outlive the flare that delivered it: the cone is
 96#: white for :data:`LANTERN_BURN_S` and a label the same length as the flash is
 97#: a label nobody ever read a whole line of.
 98LANTERN_BURN_LABEL_HOLD_S = 3.0
 99#: Where the label is planted, as a sideways step off the burning cone's axis.
100#: The ship is no place for it: it is the cone's target and the brightest
101#: patch on the screen for the whole of the burn; stepping along the
102#: axis is no better, because the cone widens as it goes. So the step is
103#: across: past the cone's own radius at the ship's range, plus clearance, and
104#: bounded so the label stays inside the pilot's foveal zone at any range.
105LANTERN_BURN_LABEL_CLEARANCE_UNITS = 2.5
106LANTERN_BURN_LABEL_MIN_UNITS = 4.0
107LANTERN_BURN_LABEL_MAX_UNITS = 9.0
108#: The cue a landed burn sounds. It is not the klaxon, which is the hull's own
109#: alarm and says nothing about what did the burning: a caption track reading
110#: "KLAXON" and then "HULL BREACHED" never names the animal. :data:`audio.CUES`
111#: carries a burn of its own, captioned LANTERN BURN, so the heaviest single
112#: blow in the game is a sound a muted mix can still read.
113LANTERN_BURN_CUE = "lantern_burn"
114#: How long a burn stays the blow worth naming. A hull that dies half a minute
115#: after the last burn died of something else, and saying otherwise is how a
116#: ledger stops being read.
117LANTERN_BLOW_WINDOW_S = 3.0
118
119
120class FinSegment(Node3D):
121    """One shearable fin: 250 HP behind plating, and a Quill when it goes.
122
123    Fins are the only part of the Shrike that yields to damage; the body is
124    armour. Plating (``balance.SHRIKE_FIN_DAMAGE_TAKEN_MULT``) belongs to the
125    damage router, which trims every hunter-side number before forwarding it,
126    so what arrives here is already plated and lands on the pool untouched.
127    Applying the multiplier a second time here would put a fin nine times
128    behind its 250-point pool and blow the 15-second shear band.
129    """
130
131    HITBOX_RADIUS = FIN_HIT_RADIUS
132
133    def __init__(self, hunter: Hunter, segment_index: int, **kwargs):
134        super().__init__(**kwargs)
135        self._hunter = hunter
136        self.segment_index = int(segment_index)
137        self.hp: float = balance.SHRIKE_FIN_HP
138        self.sheared = False
139
140    def on_enter_tree(self):
141        super().on_enter_tree()
142        self.add_to_group(Groups.HUNTER)
143
144    def on_ready(self):
145        accent = palette("hunter").accent
146        self.add_child(
147            MeshInstance3D(
148                name="FinBlade",
149                mesh=Mesh.cone(radius=0.5, height=2.2, segments=6),
150                material=Material(colour=(*accent[:3], 1.0), emissive_colour=accent[:3], emissive_strength=1.6),
151                rotation=Quat.from_euler(0.0, 0.0, math.radians(90.0)),
152            )
153        )
154        self.add_child(
155            Area3D(
156                name="FinHitbox",
157                shape=SphereShape3D(radius=FIN_HIT_RADIUS),
158                collision_layer=Layers.HUNTER,
159                collision_mask=Layers.SHIP | Layers.TERRAIN,
160            )
161        )
162
163    def take_damage(self, amount: float, kind: str = "impact") -> None:
164        """Take a hit the router has already trimmed through the fin's plating."""
165        if self.sheared:
166            return
167        self.hp -= float(amount)
168        if self.hp <= 0.0:
169            self.hp = 0.0
170            self.sheared = True
171            self._hunter._on_fin_sheared(self)
172            self.destroy()
173
174
175class Hunter(Node3D):
176    """The Shrike itself: ladder, lockout, lantern, fins, feeding and leash.
177
178    Spawned by :class:`HunterDirector` when the sector's signature locks. The
179    node's position is the lantern head; the trailing segments are children
180    chained behind it, free to bob off the flight plane while every gameplay
181    test stays on it.
182    """
183
184    hunter_telegraph = Signal(str)
185    hunter_arrived = Signal(int)
186    hunter_lockout_ended = Signal()
187    hunter_fed = Signal(float, float)
188    hunter_departed = Signal()
189    quill_sheared = Signal(int)
190    sector_cracked = Signal()
191    #: (amount: float, direction: Vec3) toward the lantern that burned the ship.
192    #:
193    #: The same ``PLAYER_DAMAGED`` every other source of hull damage announces,
194    #: and the reason it is emitted here rather than by the damage router is in
195    #: :meth:`_burn_ship`: the burn's hull maths is deliberately directionless
196    #: so the shield arc covers no bearing for it, and a directionless hit is
197    #: the one value every consumer of the signal drops. The hull keeps its
198    #: bearing-free resolution; the pilot gets a bearing to look down.
199    player_damaged = Signal(float, Vec3)
200
201    HITBOX_RADIUS = HEAD_HIT_RADIUS
202
203    def __init__(self, *, seed: int = 0, **kwargs):
204        super().__init__(**kwargs)
205        self.seed = int(seed)
206        self.state = "idle"  # "idle" | "approaching" | "present" | "departing"
207        self.arrival_index = 0
208        self.quills_this_run = 0
209        #: Bearing from the ship toward where the tear-in will happen, for the
210        #: compass edge glow. Set when the ladder starts.
211        self.approach_bearing = 0.0
212        #: Last ladder stage emitted, for the HUD's countdown framing.
213        self.telegraph_stage: str | None = None
214        self.telegraph_remaining = 0.0
215
216        self.lantern: LanternCone | None = None
217        self.fins: list[FinSegment] = []
218
219        self._heading = 0.0
220        self._lantern_heading = 0.0
221        self._lantern_phase = "track"  # "track" | "preburn" | "burn"
222        self._lantern_timer = LANTERN_FIRST_TRACK_S
223        self._burn_landed = False
224        self._blackout_remaining = 0.0
225        self._blow_remaining = 0.0
226
227        self._ramp_start = 0.0
228        self._present_s = 0.0
229        self._lockout_remaining = 0.0
230        self._lockout_ended_sent = False
231        self._crack_remaining = 0.0
232        self._crack_sent = False
233        self._t30_sent = False
234        self._feed_remaining = 0.0
235        self._feed_point = (0.0, 0.0)
236        self._depart_remaining = 0.0
237        self._orbit_sign = 1.0
238        self._time = 0.0
239        self._chain: list[tuple[float, float]] = []
240        self._segments: list[Node3D] = []
241
242    # ------------------------------------------------------------------ setup
243
244    def on_enter_tree(self):
245        super().on_enter_tree()
246        self.add_to_group(Groups.HUNTER)
247
248    def on_ready(self):
249        self.add_child(
250            Area3D(
251                name="HeadHitbox",
252                shape=SphereShape3D(radius=HEAD_HIT_RADIUS),
253                collision_layer=Layers.HUNTER,
254                collision_mask=Layers.SHIP | Layers.TERRAIN,
255            )
256        )
257        self.lantern = self.add_child(LanternCone(name="Lantern"))
258        for index in range(SHRIKE_SEGMENT_COUNT):
259            segment = self.add_child(Node3D(name=f"Segment{index}"))
260            art = build_shrike_segment(index, seed=self.seed)
261            # The art kit builds along +X; a quarter turn brings it onto the
262            # local -Z that face_along aims.
263            art.rotation = Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), math.pi * 0.5)
264            segment.add_child(art)
265            if index in FIN_SEGMENT_INDICES:
266                side = 1.0 if (index % 2) else -1.0
267                fin = segment.add_child(
268                    FinSegment(self, index, name=f"Fin{index}", position=Vec3(side * 1.1, 0.0, 0.0))
269                )
270                self.fins.append(fin)
271            self._segments.append(segment)
272        self._reset_chain()
273
274    # ------------------------------------------------------------- public API
275
276    def begin_approach(self, arrival_index: int, telegraph_s: float) -> None:
277        """Start the telegraph ladder: *telegraph_s* seconds until the tear-in.
278
279        The first stage fires immediately; the klaxon stage fires at T-30, or
280        together with the first stage when the whole warning has been
281        compressed or muffled inside it. Stage identities never change, only
282        their timing.
283        """
284        self.arrival_index = int(arrival_index)
285        self._ramp_start = balance.SHRIKE_RAMP_START_LATER_ARRIVALS if self.arrival_index >= 3 else 0.0
286        self.state = "approaching"
287        self.telegraph_remaining = float(telegraph_s)
288        self._t30_sent = False
289        self._stage_ship()
290        self._emit_stage("t60")
291        if self.telegraph_remaining <= balance.SHRIKE_TELEGRAPH_KLAXON_S:
292            self._t30_sent = True
293            self._emit_stage("t30")
294
295    def depart(self, *, immediate: bool = False) -> None:
296        """Break off and leave. Emits ``HUNTER_DEPARTED`` once, immediately.
297
298        The body then swims off for :data:`DEPART_FREE_S` before the node is
299        freed, which is the beast leaving rather than the beast blinking out.
300        *immediate* frees it in place instead, for the caller whose reason for
301        departing is that the world underneath it has been replaced: after a
302        jump the swim happens in the new sector, at the old sector's
303        coordinates, trailing fins and a lit lantern cone, and whether it
304        crosses the arrival screen is a matter of where the two anchors
305        happened to fall.
306        """
307        if self.state == "departing" or self.destroying:
308            return
309        self.state = "departing"
310        self._depart_remaining = DEPART_FREE_S
311        self._set_lantern_idle()
312        ship = self._ship()
313        if ship is not None:
314            ship.warp_scrambled = False
315        self.hunter_departed.emit()
316        if immediate:
317            self.destroy()
318
319    def feed(self, scrap: float) -> None:
320        """Devour *scrap* jettisoned at the ship's position; buys escape time.
321
322        Rate is ``balance.SHRIKE_FEED_SECONDS_PER_SCRAP`` (about eight seconds
323        per forty scrap). Feeding stacks: two dumps buy two windows.
324        """
325        scrap = float(scrap)
326        if scrap <= 0.0 or self.state != "present":
327            return
328        seconds = scrap * balance.SHRIKE_FEED_SECONDS_PER_SCRAP
329        ship = self._ship()
330        if ship is not None:
331            self._feed_point = (float(ship.position.x), float(ship.position.z))
332        else:
333            self._feed_point = (float(self.position.x), float(self.position.z))
334        self._feed_remaining += seconds
335        self._set_lantern_idle()
336        self.hunter_fed.emit(scrap, seconds)
337
338    def take_damage(self, amount: float, kind: str = "impact") -> None:
339        """Body shots ping off the armoured hull; only the fins yield."""
340
341    def damage_multiplier(self) -> float:
342        """The soft enrage: +20 percent per ten seconds present, no cap.
343
344        Third and later arrivals start already ramped, so re-ringing the bell
345        is what gets dearer, never standing your ground.
346        """
347        intervals = int(self._present_s / balance.SHRIKE_RAMP_INTERVAL_S)
348        return 1.0 + self._ramp_start + balance.SHRIKE_RAMP_FRACTION * intervals
349
350    @property
351    def approach(self) -> tuple[float, float] | None:
352        """``(seconds, bearing)`` while inbound, or None when it is not.
353
354        The HUD's countdown and its approach marker read this: the telegraph
355        signal carries a stage name and nothing else, and a stage name is not a
356        clock. Bearing is the one the tear-in will land on, so the marker
357        points at the place rather than in a general direction.
358        """
359        if self.state != "approaching":
360            return None
361        return (max(0.0, self.telegraph_remaining), self.approach_bearing)
362
363    @property
364    def last_blow(self) -> str:
365        """The blow the death ledger should name if the hull dies right now.
366
367        Valid mid-death: the burn writes it before the hull resolves the hit,
368        exactly as ``DamageRouter.last_ship_hit_by`` is written before the
369        router forwards one, so it is already true inside the
370        ``SHIP_DESTROYED`` emitted from within that resolution. It falls back
371        to the animal itself once the burn is :data:`LANTERN_BLOW_WINDOW_S`
372        old, because a hull that dies long after the last beam died of
373        something else.
374        """
375        return LANTERN_BLOW_ID if self._blow_remaining > 0.0 else HUNTER_BLOW_ID
376
377    @property
378    def lockout_active(self) -> bool:
379        """True during the ten-second scramble after the tear-in."""
380        return self.state == "present" and self._lockout_remaining > 0.0
381
382    @property
383    def feeding(self) -> bool:
384        """True while it is breaking off to devour jettisoned scrap."""
385        return self.state == "present" and self._feed_remaining > 0.0
386
387    @property
388    def fins_remaining(self) -> int:
389        return sum(1 for fin in self.fins if not fin.sheared)
390
391    # ------------------------------------------------------------------ frames
392
393    def on_update(self, dt: float):
394        self._time += dt
395        if self.state == "approaching":
396            self._tick_telegraph(dt)
397        elif self.state == "present":
398            self._tick_present(dt)
399        elif self.state == "departing":
400            self._depart_remaining -= dt
401            if self._depart_remaining <= 0.0:
402                self.destroy()
403
404    def on_fixed_update(self, dt: float):
405        if self.state == "present":
406            self._swim(dt)
407            self._clamp_to_leash()
408        elif self.state == "departing":
409            self._advance(self._heading, dt)
410        else:
411            return
412        self._update_segments()
413
414    # ---------------------------------------------------------------- telegraph
415
416    def _tick_telegraph(self, dt: float) -> None:
417        self.telegraph_remaining -= dt
418        if not self._t30_sent and self.telegraph_remaining <= balance.SHRIKE_TELEGRAPH_KLAXON_S:
419            self._t30_sent = True
420            self._emit_stage("t30")
421        if self.telegraph_remaining <= 0.0:
422            self.telegraph_remaining = 0.0
423            self._arrive()
424
425    def _emit_stage(self, stage: str) -> None:
426        self.telegraph_stage = stage
427        self.hunter_telegraph.emit(stage)
428
429    def _stage_ship(self) -> None:
430        """Pick the approach vector and hold off-sector until the tear-in."""
431        rng = random.Random(self.seed * 1_000_003 + self.arrival_index)
432        self.approach_bearing = rng.uniform(0.0, math.tau)
433        anchor = self._ship_plane() or (0.0, 0.0)
434        direction = heading_to_direction(self.approach_bearing)
435        distance = SECTOR_RADIUS * 2.0
436        self.position = Vec3(
437            anchor[0] + float(direction.x) * distance,
438            PLANE_Y,
439            anchor[1] + float(direction.z) * distance,
440        )
441        self._heading = wrap_angle(self.approach_bearing + math.pi)
442        self._reset_chain()
443
444    def _arrive(self) -> None:
445        """T-0: the lens tears, on screen, and the arrival is the wave.
446
447        The tear-in is placed inside the pilot's own visible field
448        (:data:`ARRIVAL_DISTANCE_UNITS`) so the thing that has been counted
449        down to is the thing they are looking at when it lands. The sector
450        clamp still applies and is applied first, because the leash is the
451        arena's rule; the field clamp then runs over the top of it, so a pilot
452        who has flown out past the boundary still watches the arrival happen
453        rather than hearing about it.
454        """
455        anchor = self._ship_plane() or (0.0, 0.0)
456        direction = heading_to_direction(self.approach_bearing)
457        x = anchor[0] + float(direction.x) * ARRIVAL_DISTANCE_UNITS
458        z = anchor[1] + float(direction.z) * ARRIVAL_DISTANCE_UNITS
459        reach = math.hypot(x, z)
460        if reach > SECTOR_RADIUS:
461            x, z = x / reach * SECTOR_RADIUS, z / reach * SECTOR_RADIUS
462        offset = math.hypot(x - anchor[0], z - anchor[1])
463        if offset > ARRIVAL_DISTANCE_UNITS:
464            pull = ARRIVAL_DISTANCE_UNITS / offset
465            x = anchor[0] + (x - anchor[0]) * pull
466            z = anchor[1] + (z - anchor[1]) * pull
467        self.position = Vec3(x, PLANE_Y, z)
468        self._heading = wrap_angle(self.approach_bearing + math.pi)
469        self._lantern_heading = self._heading
470        self._reset_chain()
471
472        self.state = "present"
473        self._present_s = 0.0
474        self._lockout_remaining = balance.SHRIKE_LOCKOUT_S
475        self._lockout_ended_sent = False
476        self._crack_remaining = balance.SHRIKE_CRACK_OPEN_S
477        self._crack_sent = False
478        self._lantern_phase = "track"
479        self._lantern_timer = LANTERN_FIRST_TRACK_S
480        self._blow_remaining = 0.0
481        self._set_lantern("sweep")
482
483        if self.tree is not None:
484            Vfx.spawn(self.tree, "warp_implosion", Vec3(x, PLANE_Y, z))
485        ship = self._ship()
486        if ship is not None:
487            ship.cancel_warp_spool()
488            ship.warp_scrambled = True
489
490        self._emit_stage("t0")
491        self.hunter_arrived.emit(self.arrival_index)
492
493    # ------------------------------------------------------------------ present
494
495    def _tick_present(self, dt: float) -> None:
496        self._present_s += dt
497
498        if self._lockout_remaining > 0.0:
499            self._lockout_remaining -= dt
500            if self._lockout_remaining <= 0.0 and not self._lockout_ended_sent:
501                self._lockout_ended_sent = True
502                ship = self._ship()
503                if ship is not None:
504                    ship.warp_scrambled = False
505                self.hunter_lockout_ended.emit()
506
507        if not self._crack_sent:
508            self._crack_remaining -= dt
509            if self._crack_remaining <= 0.0:
510                self._crack_sent = True
511                self.sector_cracked.emit()
512
513        if self._feed_remaining > 0.0:
514            self._feed_remaining = max(0.0, self._feed_remaining - dt)
515            if self._feed_remaining <= 0.0:
516                self._set_lantern("sweep")  # done eating, and hunting again visibly
517        else:
518            self._tick_lantern(dt)
519
520        self._blow_remaining = max(0.0, self._blow_remaining - dt)
521        self._tick_blackout(dt)
522        self._tick_jettison()
523
524    def _tick_jettison(self) -> None:
525        """A tap of jettison dumps a chunk of carried scrap as bait."""
526        if not Input.just_pressed("jettison_scrap"):
527            return
528        tree = self.tree
529        economy = tree.singletons.get(Services.ECONOMY) if tree is not None else None
530        if economy is None:
531            return
532        amount = min(JETTISON_CHUNK_SCRAP, float(getattr(economy, "scrap", 0.0)))
533        if amount > 0.0 and economy.spend_scrap(amount):
534            self.feed(amount)
535
536    # ------------------------------------------------------------------ lantern
537
538    def _tick_lantern(self, dt: float) -> None:
539        ship = self._ship()
540        sweep_mult = 1.0
541        if ship is not None and getattr(ship.assists, "slow_lantern", False):
542            sweep_mult = balance.ASSIST_LANTERN_SWEEP_MULT
543
544        if self._lantern_phase == "track":
545            self._track_ship(dt * sweep_mult)
546            self._lantern_timer -= dt * sweep_mult
547            if self._lantern_timer <= 0.0:
548                self._lantern_phase = "preburn"
549                self._lantern_timer = balance.SHRIKE_LANTERN_PREBURN_WARNING_S
550                self._set_lantern("preburn")
551        elif self._lantern_phase == "preburn":
552            self._lantern_timer -= dt
553            if self._lantern_timer <= 0.0:
554                self._lantern_phase = "burn"
555                self._lantern_timer = LANTERN_BURN_S
556                self._burn_landed = False
557                self._set_lantern("burn")
558        elif self._lantern_phase == "burn":
559            if not self._burn_landed and self._ship_in_cone():
560                self._burn_landed = True
561                self._burn_ship()
562            self._lantern_timer -= dt
563            if self._lantern_timer <= 0.0:
564                self._lantern_phase = "track"
565                self._lantern_timer = LANTERN_COOLDOWN_S
566                self._set_lantern("sweep")
567
568    def _track_ship(self, dt: float) -> None:
569        target = self._ship_plane()
570        if target is None:
571            return
572        bearing = self._bearing_to(target)
573        if bearing is None:
574            return
575        delta = wrap_angle(bearing - self._lantern_heading)
576        step = max(-LANTERN_TRACK_RATE_RADIANS_PER_S * dt, min(LANTERN_TRACK_RATE_RADIANS_PER_S * dt, delta))
577        self._lantern_heading = wrap_angle(self._lantern_heading + step)
578        if self.lantern is not None:
579            self.lantern.aim_at(heading_to_direction(self._lantern_heading))
580
581    def _ship_in_cone(self) -> bool:
582        """Whether the burn would land, which needs the head to be on screen.
583
584        The cone is drawn to :data:`~shrike.vfx.LANTERN_RANGE` and burns to the
585        same number, and that number is the pilot's own visible reach: nothing
586        the player cannot see is allowed to hurt them. Without the second half
587        of that rule the beast could sit off screen and burn.
588        """
589        target = self._ship_plane()
590        if target is None:
591            return False
592        dx = target[0] - float(self.position.x)
593        dz = target[1] - float(self.position.z)
594        distance = math.hypot(dx, dz)
595        if distance > min(LANTERN_RANGE, VISIBLE_REACH_UNITS) or distance < 1e-6:
596            return False
597        bearing = math.atan2(-dz, dx)
598        return abs(wrap_angle(bearing - self._lantern_heading)) <= math.radians(LANTERN_OUTER_CONE_DEGREES)
599
600    def _burn_ship(self) -> None:
601        """A clean hit: 35 hull scaled by the ramp, plus the capacitor blackout.
602
603        The burn is an enveloping wash of light, not a bearing-aligned shot, so
604        it lands as a directionless hit: the shield arc covers no bearing for
605        it and the only defence is not being in the cone. Spool lengthening and
606        breach bookkeeping still run through the ship's one damage path.
607
608        Everything after the hull is the hit becoming legible. The blow is
609        named before the hull resolves it, because a fatal burn emits
610        ``SHIP_DESTROYED`` from inside ``apply_damage`` and the ledger reads
611        the name during that emission. Then the pilot is told twice:
612        ``PLAYER_DAMAGED`` on the bearing of the lantern, which is what the
613        screen-edge desaturation and the feel layer's trauma are spent on, and
614        a label floated off the hull carrying the number. Neither existed, and
615        the burn was therefore silent: 35 hull left the gauge with no cue of
616        any kind, which is how a run could end to an animal in plain sight
617        that appeared never to attack.
618        """
619        ship = self._ship()
620        if ship is None:
621            return
622        self._blow_remaining = LANTERN_BLOW_WINDOW_S
623        before = float(getattr(ship, "hull", 0.0))
624        ship.apply_damage(
625            balance.SHRIKE_LANTERN_HULL_DAMAGE * self.damage_multiplier(), Vec3(0.0, 0.0, 0.0), kind="lantern"
626        )
627        self._black_out_capacitor(ship)
628        landed = max(0.0, before - float(getattr(ship, "hull", 0.0)))
629        if landed <= 0.0:
630            return
631        self.player_damaged.emit(landed, self._bearing_from_ship(ship))
632        self._float_burn_cost(ship, landed)
633        self._sound_burn(ship)
634
635    def _black_out_capacitor(self, ship) -> None:
636        """Drop the bus for the blackout second, as one state rather than a drain.
637
638        The hold belongs to the power system: it is the only code that knows
639        what the sources are putting back in, and holding the tank down from
640        out here would spend the charge one frame and watch it refill the next.
641        A system without the hold has its capacitor drained from here each frame.
642        """
643        power = getattr(ship, "power", None)
644        seconds = balance.SHRIKE_LANTERN_CAPACITOR_BLACKOUT_S
645        if power is not None and hasattr(power, "begin_blackout"):
646            power.begin_blackout(seconds)
647            self._blackout_remaining = 0.0
648            return
649        self._blackout_remaining = seconds
650
651    def _sound_burn(self, ship) -> None:
652        """Put the burn on the audio track, where a pilot looking away hears it.
653
654        The burn has a cue of its own and it captions on the HUD's plate, so a
655        muted mix reads the hit too. It is deliberately the loudest thing the
656        game says: a quieter cue reads as no cue at all.
657        """
658        tree = self.tree
659        director = tree.singletons.get(Services.AUDIO) if tree is not None else None
660        if director is None or not hasattr(director, "play"):
661            return
662        director.play(LANTERN_BURN_CUE, position=Vec3(ship.position))
663
664    def _bearing_from_ship(self, ship) -> Vec3:
665        """Unit vector from the ship toward this head, the cue's convention.
666
667        Every consumer of ``PLAYER_DAMAGED`` reads the direction as "where the
668        threat is", so the arrow, the desaturated edge and the punch all point
669        at the animal rather than away from it.
670        """
671        dx = float(self.position.x) - float(ship.position.x)
672        dz = float(self.position.z) - float(ship.position.z)
673        reach = math.hypot(dx, dz)
674        if reach < 1e-6:
675            return heading_to_direction(self._lantern_heading)
676        return Vec3(dx / reach, 0.0, dz / reach)
677
678    def _burn_label_position(self, ship) -> Vec3:
679        """Where the burn's label is planted: beside the cone, not inside it.
680
681        Anchoring it to the ship put the one line that names the blow at the
682        cone's aim point, so it spent its whole life being erased by the flare
683        that had just delivered it. The label steps across the cone's axis by
684        more than the cone's own radius at this range, and takes whichever of
685        the two perpendiculars reads higher up the screen (-Z), so it lands on
686        dark space above the hull rather than under it.
687        """
688        sx, sz = float(ship.position.x), float(ship.position.z)
689        dx, dz = sx - float(self.position.x), sz - float(self.position.z)
690        reach = math.hypot(dx, dz)
691        if reach < 1e-6:
692            return Vec3(sx, PLANE_Y, sz - LANTERN_BURN_LABEL_MIN_UNITS)
693        radius = reach * math.tan(math.radians(LANTERN_OUTER_CONE_DEGREES))
694        clear = radius + LANTERN_BURN_LABEL_CLEARANCE_UNITS
695        step = min(LANTERN_BURN_LABEL_MAX_UNITS, max(LANTERN_BURN_LABEL_MIN_UNITS, clear))
696        px, pz = -dz / reach, dx / reach
697        if pz > 0.0:
698            px, pz = -px, -pz
699        return Vec3(sx + px * step, PLANE_Y, sz + pz * step)
700
701    def _float_burn_cost(self, ship, landed: float) -> None:
702        """Put the price on the hull in words, once, where the pilot is looking.
703
704        On a plate and for :data:`LANTERN_BURN_LABEL_HOLD_S`, because this
705        label is written into a flood of white light and an ordinary float is
706        neither backed nor long enough to survive one. Planted in the world
707        rather than pinned to the hull, so it marks where the burn landed and
708        stays put while the pilot flies out of the cone.
709
710        The caption is not raised here: every cue :meth:`_sound_burn` plays is
711        announced on the audio director's ``audio_cue`` signal and the HUD
712        captions that announcement itself, wherever the play came from.
713        """
714        tree = self.tree
715        hud = tree.singletons.get(Services.HUD) if tree is not None else None
716        if hud is None or not hasattr(hud, "float_text"):
717            return
718        hud.float_text(
719            f"{LANTERN_BURN_LABEL}: {landed:.0f} HULL",
720            position=self._burn_label_position(ship),
721            hold_s=LANTERN_BURN_LABEL_HOLD_S,
722            colour=COLOUR_WARNING,
723            toward="",
724            plate=True,
725        )
726
727    def _tick_blackout(self, dt: float) -> None:
728        """Hold the capacitor at zero for the blackout second after a burn."""
729        if self._blackout_remaining <= 0.0:
730            return
731        self._blackout_remaining = max(0.0, self._blackout_remaining - dt)
732        ship = self._ship()
733        if ship is None:
734            return
735        power = ship.power
736        charge = float(getattr(power, "capacitor", 0.0))
737        if charge > 0.0:
738            power.request(charge, "lantern_blackout")
739
740    def _set_lantern(self, phase: str) -> None:
741        """Show *phase*: ``"off"``, ``"sweep"``, ``"preburn"`` or ``"burn"``.
742
743        The sweep is lit. A dark sweep would make the whole telegraph a lie:
744        the pilot would see nothing at all, then a dim shell for one second,
745        then take 35 hull. The dim only warns of a burn if there is something
746        brighter for it to be dimmer than.
747        """
748        cone = self.lantern
749        if cone is None:
750            return
751        cone.set_active(phase != "off")
752        cone.set_preburn(phase == "preburn")
753        cone.set_burn(phase == "burn")
754
755    def _set_lantern_idle(self) -> None:
756        """Douse it: feeding and departing are the two times it stops hunting."""
757        self._lantern_phase = "track"
758        self._lantern_timer = LANTERN_COOLDOWN_S
759        self._set_lantern("off")
760
761    # ---------------------------------------------------------------- movement
762
763    def _swim(self, dt: float) -> None:
764        if self._lantern_phase != "track" and not self.feeding:
765            return  # coiled to burn: the locked cone stays where the dim promised
766        if self.feeding:
767            fx, fz = self._feed_point
768            dx = fx - float(self.position.x)
769            dz = fz - float(self.position.z)
770            if math.hypot(dx, dz) <= FEED_REACH_UNITS:
771                return  # devouring on the spot
772            desired = math.atan2(-dz, dx)
773        else:
774            target = self._ship_plane()
775            if target is None:
776                return
777            bearing = self._bearing_to(target)
778            if bearing is None:
779                return
780            dx = target[0] - float(self.position.x)
781            dz = target[1] - float(self.position.z)
782            desired = bearing
783            if math.hypot(dx, dz) < ORBIT_STANDOFF_UNITS:
784                desired = wrap_angle(bearing + self._orbit_sign * (math.pi * 0.5 + 0.35))
785        delta = wrap_angle(desired - self._heading)
786        step = max(-SHRIKE_TURN_RATE_RADIANS_PER_S * dt, min(SHRIKE_TURN_RATE_RADIANS_PER_S * dt, delta))
787        self._heading = wrap_angle(self._heading + step)
788        self._advance(self._heading, dt)
789
790    def _advance(self, heading: float, dt: float) -> None:
791        direction = heading_to_direction(heading)
792        self.position = Vec3(
793            float(self.position.x) + float(direction.x) * SHRIKE_SPEED * dt,
794            PLANE_Y,
795            float(self.position.z) + float(direction.z) * SHRIKE_SPEED * dt,
796        )
797
798    def _clamp_to_leash(self) -> None:
799        """It leashes to the sector: the arena's edge is also its cage.
800
801        The clamp lands a hair inside the radius because ``position`` stores
802        float32: rescaling to exactly SECTOR_RADIUS in float64 can round up on
803        the store and leave the beast a few micro-units past its own leash.
804        """
805        x, z = float(self.position.x), float(self.position.z)
806        reach = math.hypot(x, z)
807        if reach > SECTOR_RADIUS:
808            leash = SECTOR_RADIUS * (1.0 - 1e-6)
809            self.position = Vec3(x / reach * leash, PLANE_Y, z / reach * leash)
810
811    # ------------------------------------------------------------------- body
812
813    def _reset_chain(self) -> None:
814        head = (float(self.position.x), float(self.position.z))
815        tail = heading_to_direction(wrap_angle(self._heading + math.pi))
816        self._chain = [
817            (head[0] + float(tail.x) * SEGMENT_SPACING * i, head[1] + float(tail.z) * SEGMENT_SPACING * i)
818            for i in range(SHRIKE_SEGMENT_COUNT)
819        ]
820        self._update_segments()
821
822    def _update_segments(self) -> None:
823        """Follow-the-leader: each segment trails the one ahead at fixed spacing."""
824        if not self._chain:
825            return
826        head = (float(self.position.x), float(self.position.z))
827        self._chain[0] = head
828        for i in range(1, SHRIKE_SEGMENT_COUNT):
829            px, pz = self._chain[i - 1]
830            cx, cz = self._chain[i]
831            dx, dz = cx - px, cz - pz
832            length = math.hypot(dx, dz)
833            if length < 1e-6:
834                dx, dz, length = SEGMENT_SPACING, 0.0, SEGMENT_SPACING
835            self._chain[i] = (px + dx / length * SEGMENT_SPACING, pz + dz / length * SEGMENT_SPACING)
836        for i, segment in enumerate(self._segments):
837            sx, sz = self._chain[i]
838            bob = 0.0 if i == 0 else math.sin(self._time * 2.0 + i * 0.9) * SEGMENT_BOB_UNITS
839            segment.position = Vec3(sx - head[0], bob, sz - head[1])
840            ax, az = self._chain[max(0, i - 1)]
841            direction = Vec3(ax - sx, 0.0, az - sz)
842            if i == 0:
843                direction = heading_to_direction(self._heading)
844            if math.hypot(float(direction.x), float(direction.z)) > 1e-6:
845                segment.face_along(direction)
846
847    # ------------------------------------------------------------------ lookup
848
849    def _ship(self):
850        return self.tree.first(Groups.SHIP) if self.tree is not None else None
851
852    def _ship_plane(self) -> tuple[float, float] | None:
853        ship = self._ship()
854        if ship is None:
855            return None
856        return float(ship.position.x), float(ship.position.z)
857
858    def _bearing_to(self, target: tuple[float, float]) -> float | None:
859        dx = target[0] - float(self.position.x)
860        dz = target[1] - float(self.position.z)
861        if math.hypot(dx, dz) < 1e-6:
862            return None
863        return math.atan2(-dz, dx)
864
865    # ------------------------------------------------------------------- fins
866
867    def _on_fin_sheared(self, fin: FinSegment) -> None:
868        self.quills_this_run += 1
869        if self.tree is not None:
870            Vfx.spawn(self.tree, "explosion", fin.world_position, faction="hunter")
871        self.quill_sheared.emit(self.quills_this_run)
872
873
874class HunterDirector(Node):
875    """Owns the ladder: arrival counting, compression, muffling and spawning.
876
877    A child of the run scene, alive for the whole run. It consumes
878    ``SIGNATURE_LOCKED`` to start an arrival, ``SECTOR_ENTERED`` to know the
879    biome's muffling, and ``WARP_COMPLETED`` to send the hunter home when the
880    player escapes. Escalation compresses the warning, never the fight: the
881    second arrival warns at T-45, the third and later at T-30, and a nebula
882    muffles the whole warning down to T-20 whatever the arrival count.
883    """
884
885    arrivals_this_run: int = 0
886
887    def __init__(self, *, seed: int = 0, **kwargs):
888        super().__init__(**kwargs)
889        self.seed = int(seed)
890        self.arrivals_this_run = 0
891        self.biome_id = "debris_field"
892        self.hunter: Hunter | None = None
893        self._wiring = SignalWiring(self)
894
895    def on_ready(self):
896        self._wiring.want(SignalNames.SIGNATURE_LOCKED, self.on_signature_locked)
897        self._wiring.want(SignalNames.SECTOR_ENTERED, self._on_sector_entered)
898        self._wiring.want(SignalNames.WARP_COMPLETED, self._on_warp_completed)
899        self._wiring.sweep()
900
901    def on_update(self, dt: float):
902        self._wiring.poll(dt)
903
904    # ------------------------------------------------------------------ ladder
905
906    def telegraph_seconds(self, arrival_index: int) -> float:
907        """Warning length for the given arrival, after compression and muffling."""
908        if arrival_index <= 1:
909            seconds = balance.SHRIKE_TELEGRAPH_FIRST_S
910        elif arrival_index == 2:
911            seconds = balance.SHRIKE_TELEGRAPH_SECOND_ARRIVAL_S
912        else:
913            seconds = balance.SHRIKE_TELEGRAPH_LATER_ARRIVALS_S
914        override = balance.BIOMES[self.biome_id].telegraph_override_s
915        if override is not None:
916            seconds = min(seconds, override)
917        return seconds
918
919    def on_signature_locked(self) -> None:
920        """The meter hit 100: one arrival, exactly, is now inbound."""
921        if self.hunter is not None and not self.hunter.destroying:
922            return
923        self.arrivals_this_run += 1
924        parent = self.parent if self.parent is not None else self
925        hunter = parent.add_child(Hunter(name="Hunter", seed=self.seed))
926        hunter.hunter_departed.connect(self._on_hunter_departed)
927        self.hunter = hunter
928        hunter.begin_approach(self.arrivals_this_run, self.telegraph_seconds(self.arrivals_this_run))
929
930    def set_biome(self, biome_id: str) -> None:
931        """Adopt the current sector's biome for its telegraph muffling."""
932        if biome_id not in balance.BIOMES:
933            raise ValueError(f"Unknown biome {biome_id!r}; expected one of {', '.join(balance.BIOMES)}")
934        self.biome_id = biome_id
935
936    # ---------------------------------------------------------------- handlers
937
938    def _on_sector_entered(self, sector_index: int, biome_id: str) -> None:
939        self.set_biome(biome_id)
940        # A sector is dealt once, so a beast still alive here belongs to the
941        # last one. Immediate for the same reason as the jump above, and so
942        # the departure does not depend on which of the two signals lands first.
943        if self.hunter is not None and not self.hunter.destroying:
944            self.hunter.depart(immediate=True)
945
946    def _on_warp_completed(self, emergency: bool) -> None:
947        # The hull is somewhere else now, so there is nowhere for the beast to
948        # swim away to that the pilot could watch. Freed in place instead.
949        if self.hunter is not None and not self.hunter.destroying:
950            self.hunter.depart(immediate=True)
951
952    def _on_hunter_departed(self) -> None:
953        self.hunter = None
954
955
956def killing_blow(tree) -> str:
957    """Which Shrike blow the death ledger should name, for a death in its sector.
958
959    A run that ends with the beast on screen ended because of the beast, and
960    the ledger has always said so. What it could not say was *how*, so a pilot
961    burned twice by a beam they never learned to read left with "the Shrike
962    took you" and no lesson in it. :data:`LANTERN_BLOW_ID` is that lesson, and
963    :attr:`Hunter.last_blow` is written before the hull resolves the burn, so
964    it is already true inside the ``SHIP_DESTROYED`` emission the ledger reads
965    it from.
966
967    Falls back to :data:`HUNTER_BLOW_ID` when the burn is stale or the group
968    holds no live hunter (a sheared fin outlives its animal for a frame, and a
969    fin is not a blow).
970    """
971    if tree is None:
972        return HUNTER_BLOW_ID
973    hunter = tree.first(Groups.HUNTER, Hunter)
974    return (hunter.last_blow or HUNTER_BLOW_ID) if hunter is not None else HUNTER_BLOW_ID