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]