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