shrike/juice.py¶

Part of SHRIKE.

  1"""The feel layer: hit-stop, trauma-budgeted shake, punches and the scoop magnet.
  2
  3Everything here is reaction. :class:`JuiceDirector` owns no gameplay state and
  4decides nothing: it listens to the signals the rest of the game already emits
  5and spends them on the player's hands and eyes. Kills buy hit-stop scaled by
  6how big the thing was, hits buy trauma, heavy shots buy recoil, and scrap
  7confetti leans toward the scoop so that income is a physical sensation rather
  8than a number ticking up in a corner.
  9
 10Two rules shape the whole module.
 11
 12**The clamp is load-bearing.** Shake is a trauma budget with a hard ceiling, a
 13squared response and an accessibility slider, and the ceiling drops further
 14while the Shrike's telegraph ladder is running. A mite shoal dying in a corner
 15must never blur the one warning the player's life depends on.
 16
 17**A punch gives every unit back.** The engine's ``punch_position`` and
 18``punch_rotation`` coroutines drive a plain carrier, and the director mirrors
 19the carrier onto the node as a frame-to-frame delta. The node's own movement
 20code stays authoritative, and when the impulse ends the node is exactly where
 21its own logic put it.
 22"""
 23
 24import math
 25
 26from simvx.core import Node, Node3D, Quat, Signal, Vec2, Vec3, punch_position, punch_rotation
 27
 28from . import balance
 29from .power import SignalWiring
 30from .runtime import (
 31    CAMERA_DISTANCE,
 32    CAMERA_FOV_DEGREES,
 33    PLANE_Y,
 34    WINDOW_HEIGHT,
 35    CameraRig,
 36    Groups,
 37    SignalNames,
 38    heading_to_direction,
 39    to_plane,
 40)
 41from .vfx import Vfx
 42
 43# ============================================================================
 44# Numbers the design fixes by feel rather than by table, so balance.py has no
 45# name for them yet. Anything with a derivation is derived; the rest is flagged
 46# for the balance pass.
 47# ============================================================================
 48
 49#: World units per screen pixel on the flight plane, from the camera rig's own
 50#: geometry. Section 10 quotes recoil and shake in pixels, and the game is 3D.
 51WORLD_UNITS_PER_PIXEL = (2.0 * CAMERA_DISTANCE * math.tan(math.radians(CAMERA_FOV_DEGREES) * 0.5)) / WINDOW_HEIGHT
 52
 53#: Time constant of the ship's drift, seconds: ``INERTIAL_DAMPENING`` is applied
 54#: as ``d ** (dt * 60)``, so velocity decays as ``exp(-t / DRIFT_TIME_S)``.
 55DRIFT_TIME_S = 1.0 / (60.0 * math.log(1.0 / balance.INERTIAL_DAMPENING))
 56
 57#: Rail lance recoil as a velocity impulse, sized so the shove coasts exactly
 58#: ``balance.RAILGUN_RECOIL_PX`` before the dampening eats it: a free dodge for
 59#: a player who fires across their own line of travel.
 60RAILGUN_RECOIL_UNITS = balance.RAILGUN_RECOIL_PX * WORLD_UNITS_PER_PIXEL
 61RAILGUN_RECOIL_IMPULSE = RAILGUN_RECOIL_UNITS / DRIFT_TIME_S
 62
 63#: Scene time scale during a hit-stop. Not zero: the director counts its own
 64#: freeze in unscaled time, and a live scale keeps coroutines and audio sane.
 65HIT_STOP_TIME_SCALE = 0.05
 66
 67#: Screen shake, in the pixels section 10 speaks and the units the camera uses.
 68SHAKE_MAX_OFFSET_PX = 26.0
 69SHAKE_MAX_OFFSET_UNITS = SHAKE_MAX_OFFSET_PX * WORLD_UNITS_PER_PIXEL
 70#: Trauma decays to nothing in about two thirds of a second.
 71TRAUMA_DECAY_PER_S = 1.6
 72#: The hard clamp. Trauma saturates here however much arrives in one frame.
 73TRAUMA_CEILING = 1.0
 74#: The clamp while a Shrike telegraph is running. The ladder must stay legible.
 75TELEGRAPH_TRAUMA_CEILING = 0.30
 76#: Wobble rates: the bearing sweeps at one rate and the swing beats at another,
 77#: deliberately incommensurate. Keeping them polar rather than per-axis is what
 78#: makes the clamp a real bound on the offset's length rather than on each axis.
 79SHAKE_BEARING_RATE_HZ = 21.0
 80SHAKE_SWING_RATE_HZ = 28.7
 81
 82#: Trauma each event buys, before the ceiling and the slider.
 83TRAUMA_PER_KILL = 0.32
 84#: Trauma a hit costing the whole hull would buy, before the ceiling.
 85#:
 86#: Well over the ceiling on purpose. Shake amplitude is trauma *squared*, so the
 87#: old linear one-for-one meant the Shrike's 35-hull lantern burn, the single
 88#: heaviest blow in the game, bought 0.35 trauma and therefore twelve percent of
 89#: the shake budget: three pixels. The owner reported the burn as having no
 90#: feedback at all while cue after cue was added to it, and this is why.
 91#: At 2.6 a burn lands at 0.91, near the top of the budget, a mite's chip still
 92#: barely registers, and anything that takes half the hull saturates.
 93TRAUMA_PLAYER_HIT_FULL_HULL = 2.6
 94TRAUMA_SHIELD_FULL_ABSORB = 0.35
 95TRAUMA_RAILGUN_SHOT = 0.22
 96TRAUMA_ARRIVAL = 0.8
 97
 98#: Fraction of maximum hull a single hit has to cost before it also stops the
 99#: clock. Chip damage must not stutter the game; a blow worth a third of the
100#: hull has to land like one.
101HIT_STOP_PLAYER_HIT_THRESHOLD = 0.15
102#: Milliseconds of hit-stop such a blow buys, at the threshold and at a hit that
103#: would take the whole hull. Both sit inside the kill band, so a wound never
104#: freezes the game for longer than killing something does.
105#:
106#: The floor is the middle of that band rather than its bottom. The threshold
107#: above has already thrown out every graze, so anything reaching this line is
108#: a blow; starting it at the same 30 ms the smallest kill in the game buys
109#: meant the Shrike's lantern, which takes a third of the hull in one frame,
110#: stopped the clock for 37 ms. That is two frames, and it is the same weight
111#: as swatting a mite.
112HIT_STOP_PLAYER_HIT_MIN_MS = (balance.HIT_STOP_MIN_MS + balance.HIT_STOP_MAX_MS) * 0.5
113HIT_STOP_PLAYER_HIT_MAX_MS = balance.HIT_STOP_MAX_MS
114#: Shape of the ramp between them. Below 1 it rises fastest just past the
115#: threshold, so the difference between a heavy blow and a lethal one is felt
116#: rather than reserved for hits that would end the run outright.
117HIT_STOP_PLAYER_HIT_CURVE = 0.5
118
119#: Punch impulses, in world units of peak displacement, and their duration.
120PUNCH_DURATION_S = 0.18
121PUNCH_PLAYER_HIT_UNITS = 0.55
122PUNCH_SHIELD_UNITS = 0.30
123PUNCH_RECOIL_UNITS = 0.22
124PUNCH_RAILGUN_UNITS = 0.60
125PUNCH_DOCK_UNITS = 0.25
126PUNCH_WARP_UNITS = 0.70
127#: Rotational punches, radians of peak yaw on the camera rig.
128TWIST_DURATION_S = 0.35
129TWIST_DOCK_RADIANS = math.radians(1.4)
130TWIST_WARP_RADIANS = math.radians(4.0)
131
132#: The scoop magnet. Confetti inside this radius leans toward the ship; the
133#: sector still owns the collection, this only makes the income visible.
134SCOOP_ATTRACT_RADIUS = 12.0
135SCOOP_PULL_MIN_PER_S = 3.0
136SCOOP_PULL_MAX_PER_S = 16.0
137
138#: Hit-stop for a kill scales with the archetype's threat cost against the
139#: dearest ordinary archetype; a set-piece with no threat price is full size.
140KILL_SIZE_REFERENCE_THREAT = max(spec.threat for spec in balance.ENEMIES.values())
141#: An elite of any archetype reads one size larger than its base.
142ELITE_KILL_SIZE_BONUS = 0.25
143
144#: Safety net on the telegraph clamp, so a hunter that never arrives cannot
145#: leave the shake permanently muted.
146TELEGRAPH_GUARD_S = balance.SHRIKE_TELEGRAPH_FIRST_S
147
148
149def kill_size(archetype: str, elite: bool = False) -> float:
150    """How big a kill of *archetype* reads, from 0 to 1.
151
152    Drives both the hit-stop length and the trauma a kill buys, so a mite pops
153    and a Bombardier lands.
154    """
155    spec = balance.ENEMIES.get(archetype)
156    size = 1.0 if spec is None or spec.threat <= 0 else spec.threat / KILL_SIZE_REFERENCE_THREAT
157    if elite:
158        size += ELITE_KILL_SIZE_BONUS
159    return min(1.0, max(0.0, size))
160
161
162def hit_stop_ms_for_kill(archetype: str, elite: bool = False) -> float:
163    """Milliseconds of hit-stop a kill of *archetype* is worth."""
164    span = balance.HIT_STOP_MAX_MS - balance.HIT_STOP_MIN_MS
165    return balance.HIT_STOP_MIN_MS + span * kill_size(archetype, elite)
166
167
168def hit_stop_ms_for_wound(hull_fraction: float) -> float:
169    """Milliseconds of hit-stop a hit costing *hull_fraction* of the hull buys.
170
171    Zero below :data:`HIT_STOP_PLAYER_HIT_THRESHOLD`, because a game that
172    stutters every time a mite grazes the hull is a game with a frame-rate
173    problem, not a game with weight.
174    """
175    hull_fraction = _clamp01(hull_fraction)
176    if hull_fraction < HIT_STOP_PLAYER_HIT_THRESHOLD:
177        return 0.0
178    over = (hull_fraction - HIT_STOP_PLAYER_HIT_THRESHOLD) / max(1.0 - HIT_STOP_PLAYER_HIT_THRESHOLD, 1e-6)
179    ramp = _clamp01(over) ** HIT_STOP_PLAYER_HIT_CURVE
180    return HIT_STOP_PLAYER_HIT_MIN_MS + (HIT_STOP_PLAYER_HIT_MAX_MS - HIT_STOP_PLAYER_HIT_MIN_MS) * ramp
181
182
183def _clamp01(value: float) -> float:
184    return min(1.0, max(0.0, float(value)))
185
186
187def _plane_direction(direction: Vec3) -> Vec2:
188    """*direction* flattened onto the plane and scaled to unit length."""
189    x, z = float(direction.x), float(direction.z)
190    length = math.hypot(x, z)
191    if length < 1e-9:
192        return Vec2(0.0, 0.0)
193    return Vec2(x / length, z / length)
194
195
196class _PunchCarrier:
197    """A plain target for the engine's punch coroutines.
198
199    ``punch_position`` writes a ``Vec2`` and ``punch_rotation`` writes a float;
200    a ``Node3D`` carries a ``Vec3`` and a ``Quat``. The coroutines drive this
201    instead, and the director mirrors the result onto the node.
202    """
203
204    def __init__(self):
205        self.position = Vec2(0.0, 0.0)
206        self.rotation = 0.0
207
208
209class _PlaneOffset:
210    """Mirrors a carrier's plane offset onto a node as a per-frame delta."""
211
212    def __init__(self, node: Node3D, carrier: _PunchCarrier):
213        self._node = node
214        self._carrier = carrier
215        self._applied = Vec2(0.0, 0.0)
216
217    def apply(self) -> None:
218        node = self._node
219        if node.destroying or node.tree is None:
220            return
221        current = Vec2(self._carrier.position)
222        dx = float(current.x) - float(self._applied.x)
223        dz = float(current.y) - float(self._applied.y)
224        self._applied = current
225        node.position = Vec3(float(node.position.x) + dx, float(node.position.y), float(node.position.z) + dz)
226
227
228class _Twist:
229    """Mirrors a carrier's angle onto a node as a yaw about the captured pose."""
230
231    def __init__(self, node: Node3D, carrier: _PunchCarrier):
232        self._node = node
233        self._carrier = carrier
234        self._base = node.rotation
235
236    def apply(self) -> None:
237        node = self._node
238        if node.destroying or node.tree is None:
239            return
240        node.rotation = Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), float(self._carrier.rotation)) * self._base
241
242
243def _drive(punch, mirror) -> object:
244    """Step an engine punch coroutine and mirror each step onto its node.
245
246    The engine coroutine restores the carrier to its base in its own ``finally``
247    clause, so the trailing mirror is what hands the node its offset back.
248    """
249    step = None
250    while True:
251        try:
252            punch.send(step)
253        except StopIteration:
254            break
255        mirror.apply()
256        step = yield
257    mirror.apply()
258
259
260class JuiceDirector(Node):
261    """The run's feel layer, registered as ``Services.JUICE``.
262
263    Consumes ``ENEMY_KILLED`` (hit-stop and trauma scaled by kill size, plus
264    the combo rung the audio ladder pitches on), ``PLAYER_DAMAGED`` and
265    ``SHIELD_ABSORBED`` (directional sparks and a punch), ``WEAPON_FIRED``
266    (recoil, and the rail lance's shove), ``DOCKED`` and the warp signals
267    (punch and twist), and ``HUNTER_TELEGRAPH`` (which tightens the shake clamp
268    until the thing actually arrives).
269
270    Accessibility: :attr:`shake_scale` and :attr:`hit_stop_scale` are the two
271    sliders section 10 promises, both 0 to 1, and both able to switch their
272    effect off entirely without changing anything else.
273    """
274
275    #: (rung: int) the kill-combo rung, inside ``balance.KILL_COMBO_WINDOW_S``.
276    kill_ladder = Signal(int)
277
278    def __init__(self, *, shake_scale: float = 1.0, hit_stop_scale: float = 1.0, **kwargs):
279        super().__init__(**kwargs)
280        #: Live trauma, 0 to 1. Shake is its square.
281        self.trauma = 0.0
282        #: Seconds of hit-stop still to run, counted in unscaled time.
283        self.hit_stop_remaining = 0.0
284        #: The scene time scale the director is asking for.
285        self.time_scale = 1.0
286        #: Kills inside the current combo window.
287        self.kill_combo = 0
288
289        self._shake_scale = _clamp01(shake_scale)
290        self._hit_stop_scale = _clamp01(hit_stop_scale)
291        self._applied_scale = 1.0
292        self._scaled_app = None
293        self._combo_remaining = 0.0
294        self._telegraph_guard = 0.0
295        self._shake_time = 0.0
296        self._shake_offset = Vec2(0.0, 0.0)
297        self._shake_applied = Vec2(0.0, 0.0)
298        self._rig: CameraRig | None = None
299        self._wiring = SignalWiring(self)
300
301    # ------------------------------------------------------------------ setup
302
303    def on_ready(self):
304        for signal_name, handler in (
305            (SignalNames.ENEMY_KILLED, self._on_enemy_killed),
306            (SignalNames.PLAYER_DAMAGED, self._on_player_damaged),
307            (SignalNames.SHIELD_ABSORBED, self._on_shield_absorbed),
308            (SignalNames.WEAPON_FIRED, self._on_weapon_fired),
309            (SignalNames.DOCKED, self._on_docked),
310            (SignalNames.WARP_SPOOL_STARTED, self._on_warp_spool_started),
311            (SignalNames.WARP_COMPLETED, self._on_warp_completed),
312            (SignalNames.HUNTER_TELEGRAPH, self._on_hunter_telegraph),
313            (SignalNames.HUNTER_ARRIVED, self._on_hunter_arrived),
314        ):
315            self._wiring.want(signal_name, handler)
316        # No sweep here on purpose: the director is a singleton, so its
317        # on_ready runs while the run scene is still assembling itself. The
318        # first poll of on_update sweeps a fully mounted tree instead.
319
320    def on_exit_tree(self):
321        """Hand the scene clock back however the director was holding it."""
322        if self._applied_scale != 1.0:
323            self._set_time_scale(1.0)
324        self.hit_stop_remaining = 0.0
325
326    # --------------------------------------------------------------- settings
327
328    @property
329    def shake_scale(self) -> float:
330        """Screen-shake slider, 0 (off) to 1 (full)."""
331        return self._shake_scale
332
333    @shake_scale.setter
334    def shake_scale(self, value: float) -> None:
335        self._shake_scale = _clamp01(value)
336
337    @property
338    def hit_stop_scale(self) -> float:
339        """Hit-stop slider, 0 (off) to 1 (full)."""
340        return self._hit_stop_scale
341
342    @hit_stop_scale.setter
343    def hit_stop_scale(self, value: float) -> None:
344        self._hit_stop_scale = _clamp01(value)
345
346    @property
347    def trauma_ceiling(self) -> float:
348        """The live hard clamp: tighter while a telegraph is on screen."""
349        return TELEGRAPH_TRAUMA_CEILING if self._telegraph_guard > 0.0 else TRAUMA_CEILING
350
351    @property
352    def shake_offset(self) -> Vec2:
353        """The plane offset the camera rig is currently carrying."""
354        return Vec2(self._shake_offset)
355
356    # ---------------------------------------------------------- public surface
357
358    def hit_stop(self, ms: float) -> None:
359        """Freeze the scene clock for *ms* milliseconds of unscaled time.
360
361        Overlapping calls take the longer freeze rather than stacking, so a
362        shoal wiping out in one flak burst reads as one beat, not eight.
363        """
364        ms = min(max(float(ms), 0.0), balance.HIT_STOP_MAX_MS) * self._hit_stop_scale
365        if ms <= 0.0:
366            return
367        self.hit_stop_remaining = max(self.hit_stop_remaining, ms / 1000.0)
368        self._set_time_scale(HIT_STOP_TIME_SCALE)
369
370    def shake(self, trauma: float) -> None:
371        """Add *trauma* to the budget, saturating at the live ceiling.
372
373        The slider scales what the camera does with the budget, not the budget
374        itself, so it reads as a linear "how much shake" control; at zero the
375        trauma is not even banked.
376        """
377        trauma = max(0.0, float(trauma))
378        if trauma <= 0.0 or self._shake_scale <= 0.0:
379            return
380        self.trauma = min(self.trauma_ceiling, self.trauma + trauma)
381
382    def punch(self, node: Node3D, direction: Vec3, strength: float) -> None:
383        """Kick *node* along *direction* by *strength* world units, and back.
384
385        The impulse is a damped sine on the node's plane position; it is purely
386        additive, so the node's own movement keeps running underneath it and the
387        offset is returned in full when the impulse ends.
388        """
389        if node is None or node.destroying or strength <= 0.0:
390            return
391        aim = _plane_direction(Vec3(direction))
392        if float(aim.x) == 0.0 and float(aim.y) == 0.0:
393            return
394        carrier = _PunchCarrier()
395        amplitude = Vec2(float(aim.x) * float(strength), float(aim.y) * float(strength))
396        coroutine = punch_position(carrier, amplitude, PUNCH_DURATION_S)
397        self.start_coroutine(_drive(coroutine, _PlaneOffset(node, carrier)))
398
399    def punch_twist(self, node: Node3D, radians: float) -> None:
400        """Yaw *node* about its current pose and settle it back.
401
402        For nodes that do not drive their own rotation: the camera rig, a dock
403        ring. The ship's nose is written every frame by its own aim code, so it
404        takes :meth:`punch` instead.
405        """
406        if node is None or node.destroying or radians == 0.0:
407            return
408        carrier = _PunchCarrier()
409        coroutine = punch_rotation(carrier, float(radians), TWIST_DURATION_S)
410        self.start_coroutine(_drive(coroutine, _Twist(node, carrier)))
411
412    def kill_juice(self, archetype: str, position: Vec3, elite: bool = False) -> None:
413        """The full kill reaction: stop, shake, debris, and the ladder rung."""
414        size = kill_size(archetype, elite)
415        self.hit_stop(hit_stop_ms_for_kill(archetype, elite))
416        self.shake(TRAUMA_PER_KILL * size)
417        self._advance_combo()
418        tree = self.tree
419        if tree is not None and tree.root is not None:
420            Vfx.spawn(tree, "explosion", position, scale=0.6 + size)
421            Vfx.spawn(tree, "scrap_confetti", position, scale=0.6 + size)
422
423    # ------------------------------------------------------------------- tick
424
425    def on_update(self, dt: float):
426        unscaled = dt / self._applied_scale if self._applied_scale > 0.0 else dt
427        self._wiring.poll(unscaled)
428        self._tick_hit_stop(unscaled)
429        self._tick_combo(unscaled)
430        self._tick_telegraph_guard(unscaled)
431        self._tick_shake(unscaled)
432        self._tick_scoop_magnet(dt)
433
434    def _tick_hit_stop(self, dt: float) -> None:
435        if self.hit_stop_remaining <= 0.0:
436            return
437        self.hit_stop_remaining -= dt
438        if self.hit_stop_remaining <= 0.0:
439            self.hit_stop_remaining = 0.0
440            self._set_time_scale(1.0)
441
442    def _tick_combo(self, dt: float) -> None:
443        if self._combo_remaining <= 0.0:
444            return
445        self._combo_remaining -= dt
446        if self._combo_remaining <= 0.0:
447            self._combo_remaining = 0.0
448            self.kill_combo = 0
449
450    def _tick_telegraph_guard(self, dt: float) -> None:
451        if self._telegraph_guard > 0.0:
452            self._telegraph_guard = max(0.0, self._telegraph_guard - dt)
453            self.trauma = min(self.trauma, self.trauma_ceiling)
454
455    def _tick_shake(self, dt: float) -> None:
456        self._shake_time += dt
457        if self.trauma > 0.0:
458            self.trauma = max(0.0, self.trauma - TRAUMA_DECAY_PER_S * dt)
459        amplitude = SHAKE_MAX_OFFSET_UNITS * self.trauma * self.trauma * self._shake_scale
460        if amplitude <= 0.0:
461            self._shake_offset = Vec2(0.0, 0.0)
462        else:
463            bearing = self._shake_time * math.tau * SHAKE_BEARING_RATE_HZ
464            swing = amplitude * math.sin(self._shake_time * math.tau * SHAKE_SWING_RATE_HZ + 1.7)
465            self._shake_offset = Vec2(swing * math.cos(bearing), swing * math.sin(bearing))
466        self._apply_shake()
467
468    def _apply_shake(self) -> None:
469        """Carry the shake on the rig itself, leaving its follow code alone."""
470        rig = self._camera_rig()
471        if rig is None:
472            self._shake_applied = Vec2(0.0, 0.0)
473            return
474        dx = float(self._shake_offset.x) - float(self._shake_applied.x)
475        dz = float(self._shake_offset.y) - float(self._shake_applied.y)
476        self._shake_applied = Vec2(self._shake_offset)
477        if dx == 0.0 and dz == 0.0:
478            return
479        rig.position = Vec3(float(rig.position.x) + dx, float(rig.position.y), float(rig.position.z) + dz)
480
481    def _tick_scoop_magnet(self, dt: float) -> None:
482        """Lean loose salvage toward the ship so income is a physical thing.
483
484        Only the lean lives here. The sector still owns collection, so a mote
485        the magnet drags in is credited exactly once, by the code that always
486        credited it.
487        """
488        tree = self.tree
489        if tree is None or dt <= 0.0:
490            return
491        ship = tree.get_first_in_group(Groups.SHIP)
492        if ship is None or ship.destroying:
493            return
494        here = to_plane(ship.position)
495        for mote in tree.group(Groups.SALVAGE):
496            if mote.destroying or getattr(mote, "collected", False):
497                continue
498            there = to_plane(mote.position)
499            dx = float(here.x) - float(there.x)
500            dz = float(here.y) - float(there.y)
501            distance = math.hypot(dx, dz)
502            if distance <= 1e-6 or distance > SCOOP_ATTRACT_RADIUS:
503                continue
504            closeness = 1.0 - distance / SCOOP_ATTRACT_RADIUS
505            speed = SCOOP_PULL_MIN_PER_S + (SCOOP_PULL_MAX_PER_S - SCOOP_PULL_MIN_PER_S) * closeness
506            step = min(distance, speed * dt)
507            mote.position = Vec3(
508                float(mote.position.x) + dx / distance * step,
509                PLANE_Y,
510                float(mote.position.z) + dz / distance * step,
511            )
512
513    # --------------------------------------------------------------- handlers
514
515    def _on_enemy_killed(self, archetype: str, position: Vec3, elite: bool) -> None:
516        self.kill_juice(str(archetype), Vec3(position), bool(elite))
517
518    def _on_player_damaged(self, amount: float, direction: Vec3) -> None:
519        hull_max = max(float(balance.HULL_MAX_STARTER), 1e-6)
520        cost = _clamp01(float(amount) / hull_max)
521        self.shake(TRAUMA_PLAYER_HIT_FULL_HULL * cost)
522        self.hit_stop(hit_stop_ms_for_wound(cost))
523        toward_attacker = Vec3(direction)
524        away = Vec3(-float(toward_attacker.x), 0.0, -float(toward_attacker.z))
525        ship = self._ship()
526        if ship is not None:
527            self.punch(ship, away, PUNCH_PLAYER_HIT_UNITS)
528            self._spawn(ship, "impact", toward_attacker)
529        rig = self._camera_rig()
530        if rig is not None:
531            self.punch(rig, away, PUNCH_PLAYER_HIT_UNITS)
532
533    def _on_shield_absorbed(self, amount: float) -> None:
534        """Sparks fly off the facing that took the hit, not off the hull."""
535        self.shake(TRAUMA_SHIELD_FULL_ABSORB * _clamp01(float(amount) / balance.SHIELD_ABSORB_MAX))
536        ship = self._ship()
537        if ship is None:
538            return
539        facing = self._shield_facing(ship)
540        self.punch(ship, -facing, PUNCH_SHIELD_UNITS)
541        self._spawn(ship, "shield_spark", facing)
542
543    def _on_weapon_fired(self, weapon_id: str) -> None:
544        """Recoil, and for the rail lance a shove worth steering with."""
545        ship = self._ship()
546        if ship is None:
547            return
548        back = -self._nose_direction(ship)
549        if str(weapon_id) == "rail_lance":
550            self.punch(ship, back, PUNCH_RAILGUN_UNITS)
551            self.shake(TRAUMA_RAILGUN_SHOT)
552            self._shove(ship, back, RAILGUN_RECOIL_IMPULSE)
553            return
554        self.punch(ship, back, PUNCH_RECOIL_UNITS)
555
556    def _on_docked(self, depot_id: str) -> None:
557        del depot_id
558        rig = self._camera_rig()
559        ship = self._ship()
560        if ship is not None:
561            self.punch(ship, self._nose_direction(ship), PUNCH_DOCK_UNITS)
562        if rig is not None:
563            self.punch_twist(rig, TWIST_DOCK_RADIANS)
564
565    def _on_warp_spool_started(self, emergency: bool) -> None:
566        del emergency
567        rig = self._camera_rig()
568        if rig is not None:
569            self.punch_twist(rig, -TWIST_DOCK_RADIANS)
570
571    def _on_warp_completed(self, emergency: bool) -> None:
572        del emergency
573        # The jump's own punch is the only feel that crosses it. Trauma and
574        # hit stop are answers to blows landed in the sector being left, and a
575        # camera still swinging, or a clock still stopped, from a fight several
576        # light-years back reads as the new sector doing it to you.
577        self.trauma = 0.0
578        self._shake_offset = Vec2(0.0, 0.0)
579        self._apply_shake()
580        if self.hit_stop_remaining > 0.0:
581            self.hit_stop_remaining = 0.0
582            self._set_time_scale(1.0)
583        ship = self._ship()
584        rig = self._camera_rig()
585        if ship is not None:
586            self.punch(ship, self._nose_direction(ship), PUNCH_WARP_UNITS)
587            self._spawn(ship, "warp_implosion", self._nose_direction(ship))
588        if rig is not None:
589            self.punch_twist(rig, TWIST_WARP_RADIANS)
590
591    def _on_hunter_telegraph(self, stage: str) -> None:
592        del stage
593        self._telegraph_guard = TELEGRAPH_GUARD_S
594        self.trauma = min(self.trauma, self.trauma_ceiling)
595
596    def _on_hunter_arrived(self, arrival_index: int = 0) -> None:
597        del arrival_index
598        self._telegraph_guard = 0.0
599        self.shake(TRAUMA_ARRIVAL)
600
601    # ---------------------------------------------------------------- helpers
602
603    def _advance_combo(self) -> None:
604        self.kill_combo = self.kill_combo + 1 if self._combo_remaining > 0.0 else 1
605        self._combo_remaining = balance.KILL_COMBO_WINDOW_S
606        self.kill_ladder(self.kill_combo)
607
608    def _set_time_scale(self, scale: float) -> None:
609        """Ask the app for *scale*, and remember what it actually granted.
610
611        The app scales the dt the director itself is ticked with, so the granted
612        factor is what turns that dt back into the unscaled seconds a hit-stop
613        is counted in. Headless there is no app, nothing is scaled, and the
614        director's own clock is already unscaled.
615        """
616        self.time_scale = float(scale)
617        app = self.app or self._scaled_app
618        if app is None or not hasattr(app, "time_scale"):
619            self._applied_scale = 1.0
620            return
621        app.time_scale = float(scale)
622        self._applied_scale = float(scale)
623        self._scaled_app = app if scale != 1.0 else None
624
625    def _ship(self) -> Node3D | None:
626        tree = self.tree
627        if tree is None:
628            return None
629        ship = tree.get_first_in_group(Groups.SHIP)
630        return None if ship is None or ship.destroying else ship
631
632    def _camera_rig(self) -> CameraRig | None:
633        rig = self._rig
634        if rig is not None and not rig.destroying and rig.tree is not None:
635            return rig
636        self._rig = None
637        tree = self.tree
638        if tree is None or tree.root is None:
639            return None
640        self._rig = tree.root if isinstance(tree.root, CameraRig) else tree.root.find(CameraRig)
641        return self._rig
642
643    def _nose_direction(self, ship: Node3D) -> Vec3:
644        """Unit world vector along the ship's nose."""
645        return heading_to_direction(float(getattr(ship, "heading", 0.0)))
646
647    def _shield_facing(self, ship: Node3D) -> Vec3:
648        """Unit world vector along the shield arc's centre, or the nose."""
649        shield = getattr(ship, "shield", None)
650        if shield is None:
651            return self._nose_direction(ship)
652        return heading_to_direction(float(shield.absolute_centre()))
653
654    def _shove(self, ship: Node3D, direction: Vec3, impulse: float) -> None:
655        """Add a velocity impulse to the ship, the recoil's mobility half."""
656        velocity = getattr(ship, "velocity", None)
657        if velocity is None:
658            return
659        aim = _plane_direction(Vec3(direction))
660        ship.velocity = Vec2(
661            float(velocity.x) + float(aim.x) * impulse,
662            float(velocity.y) + float(aim.y) * impulse,
663        )
664
665    def _spawn(self, ship: Node3D, effect: str, facing: Vec3) -> None:
666        """Play a one-shot on the hull's surface along *facing*."""
667        tree = self.tree
668        if tree is None or tree.root is None:
669            return
670        at = Vec3(
671            float(ship.position.x) + float(facing.x),
672            PLANE_Y,
673            float(ship.position.z) + float(facing.z),
674        )
675        Vfx.spawn(tree, effect, at, direction=Vec3(facing))
676
677
678__all__ = [
679    "HIT_STOP_TIME_SCALE",
680    "RAILGUN_RECOIL_IMPULSE",
681    "RAILGUN_RECOIL_UNITS",
682    "SCOOP_ATTRACT_RADIUS",
683    "SHAKE_MAX_OFFSET_UNITS",
684    "TELEGRAPH_TRAUMA_CEILING",
685    "TRAUMA_CEILING",
686    "JuiceDirector",
687    "hit_stop_ms_for_kill",
688    "hit_stop_ms_for_wound",
689    "kill_size",
690]