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