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