shrike/ship.py¶

Part of SHRIKE.

   1"""The player ship: drift flight, the shield arc, hull breaches and the warp spool.
   2
   3The ship is the hub every other gameplay module talks to. It owns four things
   4that nothing else may own:
   5
   6* **Flight.** Dampened drift on the flight plane, with the nose aimed
   7  independently of the thrust vector, so strafing runs and reverse-firing
   8  retreats are the native vocabulary rather than an advanced trick.
   9* **Hull integrity.** Damage, the breach ladder (a breach opens each time the
  10  hull crosses another quarter of its maximum) and the rooted patch channel.
  11  Breaches are the O2 leak: life support consumes ``BREACH_OPENED`` and
  12  ``BREACH_PATCHED`` and bleeds accordingly.
  13* **The shield arc.** A steerable 120-degree facing that blocks fully inside
  14  its cone and is bare hull everywhere else. Q and E rotate it continuously on
  15  keyboard; on a pad it mirrors the aim vector and the bumpers nudge it away in
  16  60-degree steps, so aiming twice at once survives on both devices.
  17* **Energy arbitration.** Every consumer that spends from the shared capacitor
  18  goes through :meth:`PlayerShip.request_energy` / :meth:`PlayerShip.drain_energy`,
  19  which forward to the run's :class:`~shrike.power.PowerSystem` when one is
  20  registered and to a local :class:`Capacitor` when the ship is flying on its
  21  own. Both arbitrate the same way: weapons fire down to
  22  ``balance.WEAPONS_ENERGY_FLOOR`` and every other consumer spends atomically
  23  or is told no, rather than half-firing.
  24
  25* **The warp spool's outcome contract.** A channel that starts ends on exactly
  26  one of :data:`SPOOL_JUMP`, :data:`SPOOL_ABORTED` or :data:`SPOOL_FAILED`, and
  27  every one of them is announced. It never resets in silence, it cannot be
  28  pushed out of reach forever by fire (:data:`WARP_SPOOL_MAX_INTERRUPTIONS`),
  29  and it refuses to declare a jump the tank cannot pay for.
  30
  31Everything the HUD and the audio director need is a signal: hull, breaches,
  32afterburner state, shield absorption and breakage, and the warp spool ladder.
  33"""
  34
  35import importlib.util
  36import math
  37
  38from simvx.core import (
  39    Area3D,
  40    Input,
  41    Material,
  42    Mesh,
  43    MeshInstance3D,
  44    Node,
  45    Node3D,
  46    Property,
  47    Quat,
  48    Signal,
  49    SphereShape3D,
  50    Vec2,
  51    Vec3,
  52)
  53
  54from . import balance
  55from .runtime import (
  56    PLANE_Y,
  57    CameraRig,
  58    Groups,
  59    Layers,
  60    Services,
  61    gamepad_aim_input,
  62    heading_to_direction,
  63    move_input,
  64)
  65
  66# ============================================================================
  67# Numbers the design fixes by feel rather than by table, so balance.py has no
  68# name for them yet. They are derived from the constants that balance.py does
  69# carry wherever a derivation exists, and are flagged for the balance pass.
  70# ============================================================================
  71
  72#: Hull length in world units, the unit the drift budget is quoted in.
  73SHIP_LENGTH_UNITS = balance.SHIP_LENGTH_UNITS
  74#: Cruise top speed, world units per second. Chosen so that releasing all keys
  75#: coasts ``balance.RELEASE_DRIFT_SHIP_LENGTHS`` hull lengths under
  76#: ``balance.INERTIAL_DAMPENING``: the coast is ``v / (60 * (1 - d))`` units.
  77#: The derivation lives in balance.py so the enemy speed table shares it.
  78CRUISE_SPEED = balance.PLAYER_CRUISE_SPEED
  79#: Thrust acceleration, world units per second squared: the value at which the
  80#: dampened velocity settles on ``CRUISE_SPEED`` at the 60 Hz fixed step.
  81THRUST_ACCELERATION = CRUISE_SPEED * 60.0 * (1.0 - balance.INERTIAL_DAMPENING) / balance.INERTIAL_DAMPENING
  82#: Afterburner multiplies both the acceleration and the speed ceiling.
  83AFTERBURNER_SPEED_MULT = balance.PLAYER_AFTERBURNER_SPEED_MULT
  84
  85#: Keyboard shield rotation rate, radians per second (about 170 degrees/s).
  86SHIELD_ROTATE_RADIANS_PER_S = 3.0
  87#: Shield recovery, absorption points per second while it is drawing energy.
  88SHIELD_REGEN_POINTS_PER_S = 12.0
  89
  90# -- Drawing the shield ------------------------------------------------------
  91#
  92# The arc was a number and a coverage test with nothing on screen, so Q and E
  93# were two keys that did nothing observable and a hit taken on the bare quarter
  94# of the hull was indistinguishable from one the shield had failed to stop. It
  95# is drawn now, at all times, and it turns where the pilot turns it.
  96
  97#: The band's radius from the hull centre, and the bore of the band itself.
  98#: Outside the hull, inside the HUD's own foveal arcs, so the three rings
  99#: (capacitor, hull, shield) stay told apart.
 100SHIELD_BAND_RADIUS = 2.35
 101SHIELD_BAND_BORE = 0.11
 102#: Points along the band. A 120-degree arc needs enough of them to read as a
 103#: curve rather than as a bent stick.
 104SHIELD_BAND_POINTS = 17
 105#: The arc's own colour: cold blue, which the readability contract leaves free
 106#: (player fire is warm gold, enemy fire magenta, telegraphs white).
 107SHIELD_BAND_COLOUR = (0.48, 0.82, 1.0)
 108#: What a broken arc is drawn as while its lockout runs: still there, so the
 109#: pilot sees which quarter is not being covered, and unmistakably dead.
 110SHIELD_BROKEN_COLOUR = (0.95, 0.36, 0.30)
 111#: Opacity of the band at full charge and at none, plus what a fresh absorb
 112#: adds on top and how fast that flash decays.
 113SHIELD_ALPHA_FULL = 0.34
 114SHIELD_ALPHA_EMPTY = 0.10
 115SHIELD_FLASH_ALPHA = 0.85
 116SHIELD_FLASH_DECAY_PER_S = 3.2
 117#: Emissive strength at rest and at the peak of an absorb flash. Both stay
 118#: under the bloom threshold at rest: the arc marks a facing, it does not light
 119#: the sector.
 120SHIELD_GLOW_REST = 0.8
 121SHIELD_GLOW_FLASH = 3.4
 122
 123#: Right-stick deflection past which the pad owns the aim.
 124PAD_AIM_DEADZONE = 0.25
 125#: Mouse travel in pixels that hands the aim back to the mouse.
 126MOUSE_TAKEOVER_PIXELS = 2.0
 127#: How far ahead of the ship the pad's aim point sits, world units.
 128PAD_AIM_REACH_UNITS = 22.0
 129#: Reach of the fallback aim mapping used before a camera exists.
 130FALLBACK_AIM_REACH_UNITS = 30.0
 131
 132#: Where the exhaust nacelles sit in the ship's local frame. The nose runs down
 133#: local -Z, so the tail is +Z and the pair straddle the keel. These are the
 134#: mouths of the art kit's own engine bells: the Vagrant is 2.6 units long with
 135#: a 1.7 beam, and its two bells sit at 94 per cent of the half length behind
 136#: the waist, a third of the beam apart. A plume that starts anywhere else
 137#: reads as fire coming out of the hull rather than out of the engines.
 138ENGINE_NACELLE_OFFSETS = ((-0.53, 0.0, 1.22), (0.53, 0.0, 1.22))
 139#: Ribbon throttle reached on full thrust with the burner cold. The rest of the
 140#: range belongs to the afterburner, so lighting it is a visible change in the
 141#: plume and not just a number in the HUD.
 142RIBBON_CRUISE_THROTTLE = 0.65
 143#: How fast the plume spools up and down, in throttle fraction per second. A
 144#: key is either down or it is not, so without a ramp the jet appears and
 145#: vanishes between two frames, which reads as a flicker rather than a drive.
 146#: It relights faster than it dies, the way a real throttle answers.
 147RIBBON_SPOOL_UP_PER_S = 8.0
 148RIBBON_SPOOL_DOWN_PER_S = 3.4
 149#: Throttle a lit burner holds on its own, with no thrust key down. The burner
 150#: drains the capacitor whether or not the ship is being pushed, so the plume
 151#: has to say it is lit rather than only saying the ship is accelerating.
 152RIBBON_BURNER_FLOOR = 0.45
 153
 154#: Hold time on the interact action that starts a breach patch, per section 10.
 155INTERACT_PATCH_HOLD_S = 1.0
 156
 157# ---------------------------------------------------------------------------
 158# The warp spool's outcome contract
 159#
 160# A started channel ends on exactly one of these, and every ending is
 161# announced. A ring that closes and reopens under fire forever, or one that is
 162# reset without a word when the pilot dies, is a five-second animation that
 163# taught the player nothing about what their drive just did.
 164# ---------------------------------------------------------------------------
 165
 166#: The jump happened: ``warp_completed`` fired.
 167SPOOL_JUMP = "jump"
 168#: The channel was stood down deliberately (the abort key, a tear-in, death).
 169SPOOL_ABORTED = "aborted"
 170#: The channel could not finish, and :attr:`PlayerShip.spool_failure` says why.
 171SPOOL_FAILED = "failed"
 172
 173#: Interruptions one channel survives. Each adds
 174#: ``balance.WARP_SPOOL_PENALTY_PER_HIT_S``, so fleeing under fire degrades as
 175#: the design asks; past this it breaks, because a channel that can be pushed
 176#: out of reach indefinitely is not a decision the pilot can read.
 177WARP_SPOOL_MAX_INTERRUPTIONS = balance.WARP_SPOOL_MAX_INTERRUPTIONS
 178#: The two reasons a channel fails, in the words the HUD puts on screen.
 179SPOOL_FAILURE_UNDER_FIRE = "SPOOL BROKEN: taking fire"
 180SPOOL_FAILURE_NO_FUEL = "NO FUEL TO COMPLETE JUMP"
 181
 182#: Radius of the ship's hitbox, world units.
 183HITBOX_RADIUS = 1.0
 184
 185# ---------------------------------------------------------------------------
 186# Hulls
 187#
 188# The design says hardpoints grow "two to four by hull", and for a long time
 189# nothing anywhere read that sentence: ``WeaponRack.add_hardpoint`` had no
 190# callers at all, every rack was built at ``balance.HARDPOINTS_STARTER``, and
 191# the Barge, the Dart and the Hive were three unlockable trophies that flew the
 192# starter's two guns and wore the starter's silhouette. The per-hull count now
 193# lives in ``balance.HULL_HARDPOINTS``, beside ``balance.HULL_SOCKETS``.
 194# ---------------------------------------------------------------------------
 195
 196#: The hull a run flies unless the run configuration names another.
 197DEFAULT_HULL = "vagrant"
 198
 199
 200def hardpoints_for_hull(hull_id: str) -> int:
 201    """How many gun mounts *hull_id* flies with, raising on an unknown hull."""
 202    try:
 203        return balance.HULL_HARDPOINTS[hull_id]
 204    except KeyError:
 205        raise ValueError(
 206            f"unknown hull {hull_id!r}; expected one of {', '.join(sorted(balance.HULL_HARDPOINTS))}"
 207        ) from None
 208
 209
 210#: Validation ceiling on the hull Property, not a balance number: hulls and
 211#: plating raise the working maximum, and this only stops a nonsense write.
 212HULL_PROPERTY_CEILING = 10_000.0
 213
 214
 215def wrap_angle(radians: float) -> float:
 216    """Fold an angle into ``[-pi, pi]``."""
 217    return math.remainder(float(radians), math.tau)
 218
 219
 220def bearing_of(direction: Vec3) -> float | None:
 221    """Heading of a world direction on the flight plane, or None if it names none."""
 222    dx, dz = float(direction[0]), float(direction[2])
 223    if math.hypot(dx, dz) < 1e-6:
 224        return None
 225    return math.atan2(-dz, dx)
 226
 227
 228class Capacitor(Node):
 229    """The shared energy budget, and the arbitration rule for spending it.
 230
 231    Every consumer, weapons, shield recovery, the afterburner, the tractor
 232    scoop, auto-turrets and the Refinery, asks this object for energy instead
 233    of subtracting from a number of its own. The rule is all-or-nothing: a
 234    request larger than the stored charge spends nothing and reports the denial
 235    on :attr:`energy_denied`, so a beam that cannot afford a full tick stutters
 236    audibly rather than draining the capacitor other consumers were counting on.
 237
 238    This is the standalone stand-in for the run's ``PowerSystem``, which owns
 239    generation as well as spending, and it keeps the two spending rules the
 240    real system arbitrates by. Weapons spend against
 241    ``balance.WEAPONS_ENERGY_FLOOR`` rather than the exact cost: a shot is
 242    refused only below the floor, and above it goes off for whatever charge is
 243    left, so a fight never ends because the tank was a fraction short. And the
 244    hull's emergency bus (``balance.POWER_EMERGENCY_TRICKLE_PER_S``) keeps a
 245    trickle flowing, so a ship flying without a power system is dim rather
 246    than bricked. Socketed generation still needs the real system: the ship
 247    builds this only when no ``Services.POWER`` singleton is registered.
 248    """
 249
 250    capacitor_changed = Signal(float, float)
 251    energy_denied = Signal(str)
 252
 253    def __init__(self, capacity: float = balance.CAPACITOR_MAX, **kwargs):
 254        super().__init__(**kwargs)
 255        self.capacitor_max = float(capacity)
 256        self.capacitor = float(capacity)
 257        #: Name of the consumer refused most recently, for the HUD's denial flash.
 258        self.last_denied: str | None = None
 259
 260    def on_update(self, dt: float):
 261        self.add_energy(balance.POWER_EMERGENCY_TRICKLE_PER_S * dt)
 262
 263    def request(self, amount: float, consumer: str) -> bool:
 264        """Spend *amount*. False (and a denial) if the charge is short.
 265
 266        Same contract as ``PowerSystem.request``: weapons are refused only
 267        below ``balance.WEAPONS_ENERGY_FLOOR`` and spend whatever is left;
 268        every other consumer spends atomically or not at all.
 269        """
 270        amount = float(amount)
 271        if amount <= 0.0:
 272            return True
 273        floor = balance.WEAPONS_ENERGY_FLOOR if consumer in balance.WEAPONS else amount - 1e-9
 274        if self.capacitor < floor:
 275            self.last_denied = consumer
 276            self.energy_denied(consumer)
 277            return False
 278        self.capacitor = max(0.0, self.capacitor - amount)
 279        self.capacitor_changed(self.capacitor, self.capacitor_max)
 280        return True
 281
 282    def drain(self, per_second: float, dt: float, consumer: str) -> bool:
 283        """Spend a per-second rate over *dt*, on the same all-or-nothing rule."""
 284        return self.request(float(per_second) * float(dt), consumer)
 285
 286    def add_energy(self, amount: float) -> None:
 287        """Add charge, clamped to the capacitor's ceiling."""
 288        amount = float(amount)
 289        if amount <= 0.0:
 290            return
 291        self.capacitor = min(self.capacitor_max, self.capacitor + amount)
 292        self.capacitor_changed(self.capacitor, self.capacitor_max)
 293
 294
 295class ShieldArc(Node3D):
 296    """The steerable 120-degree shield facing, a child of :class:`PlayerShip`.
 297
 298    The arc blocks fully inside its cone and not at all outside it, so defence
 299    is a second aiming problem laid over the first. It absorbs
 300    ``balance.SHIELD_ABSORB_MAX`` points before it breaks, stays down for
 301    ``balance.SHIELD_BREAK_LOCKOUT_S``, and then recovers while it can pay
 302    ``balance.ENERGY_SHIELD_REGEN_PER_S`` out of the capacitor. Recovery stalls,
 303    it does not fail, when the capacitor is empty.
 304
 305    :attr:`arc_centre` is measured from the ship's nose, so it costs nothing to
 306    hold the shield off-axis while the nose tracks a target: a pad player nudges
 307    the offset in 60-degree steps, a keyboard player sweeps it with Q and E, and
 308    both leave the aim untouched.
 309    """
 310
 311    def __init__(self, **kwargs):
 312        super().__init__(**kwargs)
 313        #: Arc centre in radians, measured anticlockwise from the ship's nose.
 314        self.arc_centre: float = 0.0
 315        self.charge: float = balance.SHIELD_ABSORB_MAX
 316        self.broken: bool = False
 317        self.lockout_remaining: float = 0.0
 318        self.half_width: float = math.radians(balance.SHIELD_ARC_DEGREES) * 0.5
 319        #: The drawn band, mounted on ready. The node's own local -Z is the
 320        #: arc's centre, so its ``forward`` reads back as the covered bearing.
 321        self.band: MeshInstance3D | None = None
 322        self._ship: PlayerShip | None = None
 323        self._flash = 0.0
 324
 325    @property
 326    def charge_max(self) -> float:
 327        return balance.SHIELD_ABSORB_MAX
 328
 329    @property
 330    def charge_fraction(self) -> float:
 331        """How much of the arc's absorption is left, 0 to 1."""
 332        return self.charge / self.charge_max if self.charge_max > 0.0 else 0.0
 333
 334    # -- the drawn arc ----------------------------------------------------
 335
 336    def on_ready(self):
 337        self.band = self.add_child(
 338            MeshInstance3D(
 339                name="Band",
 340                mesh=Mesh.extrude_path(self._band_centreline(), sides=6, radius=SHIELD_BAND_BORE),
 341                material=Material(
 342                    colour=(*SHIELD_BAND_COLOUR, SHIELD_ALPHA_FULL),
 343                    emissive_colour=SHIELD_BAND_COLOUR,
 344                    emissive_strength=SHIELD_GLOW_REST,
 345                ),
 346            )
 347        )
 348        self._refresh_band()
 349
 350    def _band_centreline(self) -> list[tuple[float, float, float]]:
 351        """The arc's points, swept about the node's own local -Z.
 352
 353        Local -Z is what :meth:`Node3D.face_along` aims and what the parent
 354        hull's nose already runs down, so laying the band around it and turning
 355        the node by :attr:`arc_centre` puts the drawn arc exactly where
 356        :meth:`covers` says the cover is.
 357        """
 358        points = []
 359        for index in range(SHIELD_BAND_POINTS):
 360            offset = -self.half_width + 2.0 * self.half_width * index / (SHIELD_BAND_POINTS - 1)
 361            points.append(
 362                (
 363                    -SHIELD_BAND_RADIUS * math.sin(offset),
 364                    0.0,
 365                    -SHIELD_BAND_RADIUS * math.cos(offset),
 366                )
 367            )
 368        return points
 369
 370    def _refresh_band(self) -> None:
 371        """Turn the band onto the covered bearing and colour it for its state."""
 372        band = self.band
 373        if band is None:
 374            return
 375        self.rotation = Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), self.arc_centre)
 376        colour = SHIELD_BROKEN_COLOUR if self.broken else SHIELD_BAND_COLOUR
 377        rest = SHIELD_ALPHA_EMPTY + (SHIELD_ALPHA_FULL - SHIELD_ALPHA_EMPTY) * self.charge_fraction
 378        alpha = rest + (SHIELD_FLASH_ALPHA - rest) * self._flash
 379        material = band.material
 380        material.colour = (*colour, alpha)
 381        material.emissive_colour = colour
 382        material.emissive_strength = SHIELD_GLOW_REST + (SHIELD_GLOW_FLASH - SHIELD_GLOW_REST) * self._flash
 383
 384    def absolute_centre(self) -> float:
 385        """The arc's centre as a world heading, folding in the ship's nose."""
 386        nose = self._ship.heading if self._ship is not None else 0.0
 387        return wrap_angle(nose + self.arc_centre)
 388
 389    def covers(self, incoming_direction: Vec3) -> bool:
 390        """True if the arc faces *incoming_direction*.
 391
 392        *incoming_direction* is the world-space direction from the ship toward
 393        the source of the damage, the bearing under attack. A broken arc covers
 394        nothing, and a zero-length direction names no bearing, so it is not
 395        covered either.
 396        """
 397        if self.broken:
 398            return False
 399        bearing = bearing_of(Vec3(incoming_direction))
 400        if bearing is None:
 401            return False
 402        return abs(wrap_angle(bearing - self.absolute_centre())) <= self.half_width + 1e-9
 403
 404    def absorb(self, amount: float) -> float:
 405        """Soak up to the remaining charge and return the damage that got through.
 406
 407        Coverage is the caller's question: :meth:`covers` answers it, and
 408        :meth:`PlayerShip.apply_damage` asks it. A hit that empties the charge
 409        breaks the arc and starts the lockout.
 410        """
 411        amount = float(amount)
 412        if self.broken or amount <= 0.0:
 413            return max(0.0, amount)
 414        soaked = min(amount, self.charge)
 415        self.charge -= soaked
 416        if soaked > 0.0:
 417            self._flash = 1.0
 418        if self.charge <= 1e-9:
 419            self.charge = 0.0
 420            self.broken = True
 421            self.lockout_remaining = balance.SHIELD_BREAK_LOCKOUT_S
 422        return amount - soaked
 423
 424    def nudge(self, delta: float) -> None:
 425        """Rotate the arc by *delta* radians relative to the nose."""
 426        self.arc_centre = wrap_angle(self.arc_centre + float(delta))
 427
 428    def recentre(self) -> None:
 429        """Snap the arc back onto the nose, which on a pad is the aim vector."""
 430        self.arc_centre = 0.0
 431
 432    def on_update(self, dt: float):
 433        self._flash = max(0.0, self._flash - SHIELD_FLASH_DECAY_PER_S * dt)
 434        self._refresh_band()
 435        if self.lockout_remaining > 0.0:
 436            self.lockout_remaining = max(0.0, self.lockout_remaining - dt)
 437            if self.lockout_remaining > 0.0:
 438                return
 439            self.broken = False
 440        if self.charge >= self.charge_max or self._ship is None:
 441            return
 442        if not self._ship.drain_energy(balance.ENERGY_SHIELD_REGEN_PER_S, dt, "shield_regen"):
 443            return
 444        self.charge = min(self.charge_max, self.charge + SHIELD_REGEN_POINTS_PER_S * dt)
 445
 446
 447class PlayerShip(Node3D):
 448    """The player's hull: drift flight, aim, breaches, shields and the warp spool.
 449
 450    Flight is the design's 30-second loop. Thrust accelerates the plane velocity
 451    and ``balance.INERTIAL_DAMPENING`` bleeds it, so releasing everything coasts
 452    about ``balance.RELEASE_DRIFT_SHIP_LENGTHS`` hull lengths; the nose points at
 453    the aim point regardless of where the thrust is pushing. The afterburner
 454    lifts both the acceleration and the ceiling over
 455    ``balance.AFTERBURNER_IGNITION_RAMP_S`` while it can pay
 456    ``balance.ENERGY_AFTERBURNER_PER_S``, and cuts out the moment it cannot.
 457
 458    Damage lands through :meth:`apply_damage`, which is the only path into the
 459    hull. It resolves the shield arc first (combat's router must not resolve it
 460    a second time), lengthens a running warp spool, and opens a breach each time
 461    the hull crosses another ``balance.BREACH_HULL_FRACTION_STEP`` of its
 462    maximum. Breaches never close on their own: :meth:`patch_breach` roots the
 463    ship for ``balance.BREACH_PATCH_CHANNEL_S`` and costs
 464    ``balance.BREACH_PATCH_SCRAP``.
 465    """
 466
 467    hull = Property(
 468        balance.HULL_MAX_STARTER,
 469        range=(0.0, HULL_PROPERTY_CEILING),
 470        on_change="_on_hull_changed",
 471        hint="Current hull integrity",
 472    )
 473    hull_max = Property(
 474        balance.HULL_MAX_STARTER,
 475        range=(1.0, HULL_PROPERTY_CEILING),
 476        hint="Hull integrity ceiling; the hull choice may raise it",
 477    )
 478
 479    # Signals, named exactly as runtime.SignalNames registers them.
 480    hull_changed = Signal(float, float)
 481    breach_opened = Signal(int)
 482    breach_patched = Signal(int)
 483    ship_destroyed = Signal()
 484    afterburner_changed = Signal(bool)
 485    shield_absorbed = Signal(float)
 486    shield_broken = Signal()
 487    warp_spool_started = Signal(bool)
 488    warp_spool_interrupted = Signal(float)
 489    warp_spool_cancelled = Signal()
 490    warp_completed = Signal(bool)
 491
 492    # Class-level defaults: the hull Property's change hook can fire before
 493    # __init__ has finished assigning instance state.
 494    open_breaches: int = 0
 495    _breaches_seen: int = 0
 496    _destroyed: bool = False
 497
 498    def __init__(self, *, hull_id: str = DEFAULT_HULL, **kwargs):
 499        super().__init__(**kwargs)
 500        #: Which of the four flyable hulls this is. It decides the silhouette
 501        #: the art kit builds and how many gun mounts the rack should open.
 502        self.hull_id = str(hull_id)
 503        hardpoints_for_hull(self.hull_id)  # Fail on the constructor, not on the art.
 504        #: Plane velocity, ``Vec2(x, z)`` in world units per second.
 505        self.velocity = Vec2(0.0, 0.0)
 506        #: Nose heading in radians, anticlockwise from +X seen from above.
 507        self.heading: float = math.pi * 0.5
 508        self.open_breaches = 0
 509        self._breaches_seen = 0
 510        self._destroyed = False
 511
 512        self.shield: ShieldArc | None = None
 513        self.assists = balance.AssistSettings()
 514        #: Set by the hunter for the arrival lockout: the drive will not spool.
 515        self.warp_scrambled = False
 516
 517        self._capacitor: Capacitor | None = None
 518        self._rig: CameraRig | None = None
 519        self._ribbons: list[Node3D] = []
 520        self._ribbon_throttle = 0.0
 521        #: The drive state the plumes were last given, so a coasting ship does
 522        #: not rewrite two materials and four emitter properties per ribbon per
 523        #: frame to arrive at the plume already on the tail.
 524        self._ribbon_drive: tuple | None = None
 525        self._thrust_input = Vec2(0.0, 0.0)
 526        self._afterburner_ramp = 0.0
 527        self._afterburner_active = False
 528        self._patch_remaining = 0.0
 529        self._spooling = False
 530        self._spool_remaining = 0.0
 531        self._spool_emergency = False
 532        self._spool_outcome = ""
 533        self._spool_failure = ""
 534        self._spool_interruptions = 0
 535        self._interact_hold = 0.0
 536        self._interact_consumed = False
 537        self._pad_aim = False
 538        self._aim_direction = heading_to_direction(self.heading)
 539        self._last_mouse = Vec2(Input.mouse_position)
 540
 541    # ------------------------------------------------------------------ setup
 542
 543    def on_enter_tree(self):
 544        super().on_enter_tree()
 545        self.add_to_group(Groups.SHIP)
 546
 547    def on_ready(self):
 548        self.position = Vec3(float(self.position.x), PLANE_Y, float(self.position.z))
 549        self.add_child(self._build_hull_visual())
 550        self.add_child(
 551            Area3D(
 552                name="Hitbox",
 553                shape=SphereShape3D(radius=HITBOX_RADIUS),
 554                collision_layer=Layers.SHIP,
 555                collision_mask=Layers.MASK_SHIP,
 556            )
 557        )
 558        self.shield = self.add_child(ShieldArc(name="ShieldArc"))
 559        self.shield._ship = self
 560        self._mount_ribbons()
 561        self._face_nose()
 562
 563    def _mount_ribbons(self) -> None:
 564        """Hang an exhaust ribbon on each nacelle, where the vfx layer exists.
 565
 566        The plume is the ship's own answer to a screen with nothing else on it:
 567        it says the drive is lit, which way it is pushing and, through the
 568        ribbon's notoriety hook, how loud the pilot's reputation has become.
 569        """
 570        if importlib.util.find_spec("shrike.vfx") is None:
 571            return
 572        from .vfx import EngineRibbon
 573
 574        for index, offset in enumerate(ENGINE_NACELLE_OFFSETS):
 575            self._ribbons.append(self.add_child(EngineRibbon(name=f"EngineRibbon{index}", position=Vec3(*offset))))
 576
 577    def _build_hull_visual(self) -> Node3D:
 578        """The hull geometry, mounted so its nose runs down the node's local -Z.
 579
 580        That is the axis :meth:`Node3D.face_along` aims, so the mount is the one
 581        place the art's own convention is reconciled with the scene's. The art
 582        kit builds the ship pointing along +X, and a quarter turn about Y brings
 583        it onto -Z. Until ``shrike.artkit`` is importable the ship flies as a
 584        plain cone, which is enough to read a heading on screen.
 585        """
 586        mount = Node3D(name="Hull")
 587        if importlib.util.find_spec("shrike.artkit") is not None:
 588            from .artkit import build_player_ship
 589
 590            art = build_player_ship(hull=self.hull_id, hardpoints=self.hardpoint_count)
 591            art.rotation = Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), math.pi * 0.5)
 592            mount.add_child(art)
 593        else:
 594            mount.add_child(
 595                MeshInstance3D(
 596                    name="HullMesh",
 597                    mesh=Mesh.cone(radius=0.6, height=SHIP_LENGTH_UNITS, segments=12),
 598                    material=Material(colour=(0.75, 0.78, 0.82, 1.0), metallic=0.8, roughness=0.35),
 599                    rotation=Quat.from_euler(math.radians(90), 0.0, 0.0),
 600                )
 601            )
 602        return mount
 603
 604    @property
 605    def hardpoint_count(self) -> int:
 606        """Gun mounts this hull opens, the size the run's ``WeaponRack`` is built at.
 607
 608        The rack is assembled by the run scene, so this is where it asks; the
 609        hull answers once and both the rack and the pylons on the hull mesh come
 610        from the same number.
 611        """
 612        return hardpoints_for_hull(self.hull_id)
 613
 614    # ------------------------------------------------------------------ energy
 615
 616    @property
 617    def power(self):
 618        """The energy source this ship spends from.
 619
 620        The run's ``PowerSystem`` when one is registered, otherwise a local
 621        :class:`Capacitor` built on first use. Both expose the same
 622        ``request`` / ``drain`` / ``add_energy`` arbitration surface, so no
 623        consumer needs to know which it is talking to.
 624        """
 625        tree = self.tree
 626        if tree is not None:
 627            system = tree.singletons.get(Services.POWER)
 628            if system is not None:
 629                return system
 630        if self._capacitor is None:
 631            self._capacitor = self.add_child(Capacitor(name="Capacitor"))
 632        return self._capacitor
 633
 634    def request_energy(self, amount: float, consumer: str) -> bool:
 635        """Spend *amount* from the capacitor atomically, naming the consumer."""
 636        return bool(self.power.request(amount, consumer))
 637
 638    def drain_energy(self, per_second: float, dt: float, consumer: str) -> bool:
 639        """Spend a per-second rate over *dt*, atomically, naming the consumer."""
 640        return bool(self.power.drain(per_second, dt, consumer))
 641
 642    # -------------------------------------------------------------------- aim
 643
 644    def aim_point(self) -> Vec3:
 645        """The point on the flight plane the player is aiming at.
 646
 647        The right stick owns the aim whenever it is deflected, and keeps it
 648        while it rests at centre so a released stick does not snap the nose onto
 649        a stale mouse position. Any real mouse movement takes it back.
 650        """
 651        pad = gamepad_aim_input()
 652        px, pz = float(pad.x), float(pad.y)
 653        if math.hypot(px, pz) >= PAD_AIM_DEADZONE:
 654            self._pad_aim = True
 655            length = math.hypot(px, pz)
 656            self._aim_direction = Vec3(px / length, 0.0, pz / length)
 657        else:
 658            mouse = Vec2(Input.mouse_position)
 659            moved = math.hypot(float(mouse.x) - float(self._last_mouse.x), float(mouse.y) - float(self._last_mouse.y))
 660            if moved > MOUSE_TAKEOVER_PIXELS:
 661                self._pad_aim = False
 662            self._last_mouse = mouse
 663            if not self._pad_aim:
 664                return self._mouse_aim_point()
 665        return Vec3(
 666            float(self.position.x) + float(self._aim_direction.x) * PAD_AIM_REACH_UNITS,
 667            PLANE_Y,
 668            float(self.position.z) + float(self._aim_direction.z) * PAD_AIM_REACH_UNITS,
 669        )
 670
 671    def _mouse_aim_point(self) -> Vec3:
 672        """Cast the mouse through the camera onto the flight plane."""
 673        camera = self._rig.camera if self._rig is not None else None
 674        width, height = self.tree.screen_size if self.tree is not None else (1.0, 1.0)
 675        width, height = max(float(width), 1.0), max(float(height), 1.0)
 676        mouse = Input.mouse_position
 677        if camera is None:
 678            # No camera yet: a screen-centre-relative mapping still aims.
 679            dx = (float(mouse.x) - width * 0.5) / width
 680            dz = (float(mouse.y) - height * 0.5) / height
 681            return Vec3(
 682                float(self.position.x) + dx * FALLBACK_AIM_REACH_UNITS,
 683                PLANE_Y,
 684                float(self.position.z) + dz * FALLBACK_AIM_REACH_UNITS,
 685            )
 686        ndc_x = 2.0 * float(mouse.x) / width - 1.0
 687        ndc_y = 1.0 - 2.0 * float(mouse.y) / height
 688        half = math.tan(math.radians(float(camera.fov)) * 0.5)
 689        forward, right, up = camera.forward, camera.right, camera.up
 690        direction = Vec3(
 691            float(forward.x) + float(right.x) * ndc_x * half * (width / height) + float(up.x) * ndc_y * half,
 692            float(forward.y) + float(right.y) * ndc_x * half * (width / height) + float(up.y) * ndc_y * half,
 693            float(forward.z) + float(right.z) * ndc_x * half * (width / height) + float(up.z) * ndc_y * half,
 694        )
 695        origin = camera.world_position
 696        if float(direction.y) > -1e-6:
 697            # Aiming at or above the horizon: hold the last aim rather than
 698            # throwing the reticle to infinity behind the camera.
 699            return Vec3(
 700                float(self.position.x) + float(self._aim_direction.x) * PAD_AIM_REACH_UNITS,
 701                PLANE_Y,
 702                float(self.position.z) + float(self._aim_direction.z) * PAD_AIM_REACH_UNITS,
 703            )
 704        distance = (PLANE_Y - float(origin.y)) / float(direction.y)
 705        return Vec3(
 706            float(origin.x) + float(direction.x) * distance,
 707            PLANE_Y,
 708            float(origin.z) + float(direction.z) * distance,
 709        )
 710
 711    def _face_nose(self) -> None:
 712        self.face_along(heading_to_direction(self.heading))
 713
 714    # ------------------------------------------------------------------ damage
 715
 716    def apply_damage(self, amount: float, direction: Vec3, kind: str = "impact") -> None:
 717        """Take a hit from *direction*, the bearing from the ship to its source.
 718
 719        The shield arc resolves here and nowhere else. Any hit landing during a
 720        warp spool lengthens the channel by
 721        ``balance.WARP_SPOOL_PENALTY_PER_HIT_S``, blocked or not: fleeing under
 722        fire is meant to degrade.
 723        """
 724        amount = float(amount) * float(self.assists.damage_taken_mult)
 725        if self._destroyed or amount <= 0.0:
 726            return
 727
 728        leak = amount
 729        if self.shield is not None and self.shield.covers(direction):
 730            was_broken = self.shield.broken
 731            leak = self.shield.absorb(amount)
 732            soaked = amount - leak
 733            if soaked > 0.0:
 734                self.shield_absorbed(soaked)
 735            if self.shield.broken and not was_broken:
 736                self.shield_broken()
 737
 738        if self._spooling and not self.assists.spool_immunity:
 739            self._spool_interruptions += 1
 740            self._spool_remaining += balance.WARP_SPOOL_PENALTY_PER_HIT_S
 741            self.warp_spool_interrupted(balance.WARP_SPOOL_PENALTY_PER_HIT_S)
 742            if self._spool_interruptions > WARP_SPOOL_MAX_INTERRUPTIONS:
 743                self._end_spool(SPOOL_FAILED, SPOOL_FAILURE_UNDER_FIRE)
 744
 745        if leak > 0.0:
 746            self.hull = max(0.0, float(self.hull) - leak)
 747
 748    def _on_hull_changed(self) -> None:
 749        self.hull_changed(float(self.hull), float(self.hull_max))
 750        self._update_breaches()
 751        if float(self.hull) <= 0.0 and not self._destroyed:
 752            self._destroyed = True
 753            self._patch_remaining = 0.0
 754            self.velocity = Vec2(0.0, 0.0)
 755            if self._spooling:
 756                # Dropping the flag here and saying nothing left the ring
 757                # closing over a dead ship and the chart holding a route it
 758                # would never be told about.
 759                self._end_spool(SPOOL_ABORTED)
 760            self.ship_destroyed()
 761
 762    def _update_breaches(self) -> None:
 763        """Open a breach for every fresh quarter of the hull that has gone."""
 764        maximum = max(float(self.hull_max), 1e-6)
 765        fraction = max(0.0, float(self.hull)) / maximum
 766        steps = int(1.0 / balance.BREACH_HULL_FRACTION_STEP) - 1
 767        crossed = min(steps, int((1.0 - fraction) / balance.BREACH_HULL_FRACTION_STEP + 1e-9))
 768        while crossed > self._breaches_seen:
 769            self._breaches_seen += 1
 770            self.open_breaches += 1
 771            self.breach_opened(self.open_breaches)
 772
 773    def patch_breach(self) -> bool:
 774        """Start the rooted patch channel. False if there is nothing to patch.
 775
 776        Costs ``balance.BREACH_PATCH_SCRAP`` where an economy is running, and
 777        roots the ship for ``balance.BREACH_PATCH_CHANNEL_S``: the whole point
 778        of the breach is that closing it costs seconds you wanted to spend
 779        mining.
 780        """
 781        if self._destroyed or self.open_breaches <= 0 or self._patch_remaining > 0.0:
 782            return False
 783        tree = self.tree
 784        economy = tree.singletons.get(Services.ECONOMY) if tree is not None else None
 785        if economy is not None and not economy.spend_scrap(balance.BREACH_PATCH_SCRAP):
 786            return False
 787        self._patch_remaining = balance.BREACH_PATCH_CHANNEL_S
 788        self.velocity = Vec2(0.0, 0.0)
 789        return True
 790
 791    def seal_breach(self) -> bool:
 792        """Close one open breach at once. False when none is open.
 793
 794        The rooted, scrap-priced channel is :meth:`patch_breach`; this is the
 795        depot's breach foam, which was paid for at the counter and works the
 796        moment it is aboard.
 797        """
 798        if self._destroyed or self.open_breaches <= 0:
 799            return False
 800        self.open_breaches -= 1
 801        self.breach_patched(self.open_breaches)
 802        return True
 803
 804    @property
 805    def patching(self) -> bool:
 806        """True while the patch channel roots the ship."""
 807        return self._patch_remaining > 0.0
 808
 809    def patch_fraction(self) -> float:
 810        """How far the patch channel has run, 0 to 1, for the HUD's radial fill."""
 811        if self._patch_remaining <= 0.0:
 812            return 0.0
 813        return 1.0 - self._patch_remaining / balance.BREACH_PATCH_CHANNEL_S
 814
 815    # -------------------------------------------------------------------- warp
 816
 817    def begin_warp_spool(self, *, emergency: bool = False) -> None:
 818        """Start the warp channel. A scrambled drive refuses to spool at all."""
 819        if self._destroyed or self._spooling or self.warp_scrambled:
 820            return
 821        self._spooling = True
 822        self._spool_emergency = emergency
 823        self._spool_remaining = balance.WARP_SPOOL_S
 824        self._spool_interruptions = 0
 825        self._spool_outcome = ""
 826        self._spool_failure = ""
 827        self.warp_spool_started(emergency)
 828
 829    def cancel_warp_spool(self) -> None:
 830        """Abandon a running channel without completing it.
 831
 832        Emits ``warp_spool_cancelled`` so the ring, the HUD label and any route
 833        the chart had already bought all stand down; a drive that was not
 834        spooling has nothing to cancel and stays silent.
 835        """
 836        if not self._spooling:
 837            return
 838        self._end_spool(SPOOL_ABORTED)
 839
 840    def _end_spool(self, outcome: str, reason: str = "") -> None:
 841        """Close a running channel on exactly one outcome, and say which.
 842
 843        This is the only way out of :attr:`spooling`. A jump fires
 844        ``warp_completed``; an abort and a failure both fire
 845        ``warp_spool_cancelled``, because to every consumer holding a route
 846        they are the same instruction, and a failure additionally hands the HUD
 847        the reason so the pilot is told what the drive could not do.
 848        """
 849        self._spooling = False
 850        self._spool_remaining = 0.0
 851        self._spool_interruptions = 0
 852        self._spool_outcome = outcome
 853        self._spool_failure = reason if outcome == SPOOL_FAILED else ""
 854        if outcome == SPOOL_JUMP:
 855            self.warp_completed(self._spool_emergency)
 856            return
 857        self.warp_spool_cancelled()
 858        if outcome != SPOOL_FAILED:
 859            return
 860        hud = self.tree.singletons.get(Services.HUD) if self.tree is not None else None
 861        if hud is not None and hasattr(hud, "fail_warp_spool"):
 862            hud.fail_warp_spool(reason)
 863
 864    def _fuel_is_dry(self) -> bool:
 865        """Whether the tank ran out while the channel was running.
 866
 867        The price of a given jump belongs to the chart, which refuses its own
 868        route when the tank cannot cover it. What the ship owns is the floor:
 869        an empty tank cannot buy any jump at all, so the channel fails here
 870        rather than announcing a jump nothing can pay for.
 871        """
 872        power = self.tree.singletons.get(Services.POWER) if self.tree is not None else None
 873        fuel = getattr(power, "fuel", None)
 874        return fuel is not None and float(fuel) <= 0.0
 875
 876    @property
 877    def spooling(self) -> bool:
 878        return self._spooling
 879
 880    @property
 881    def spool_outcome(self) -> str:
 882        """How the last channel ended: one of the ``SPOOL_*`` ids, or empty."""
 883        return self._spool_outcome
 884
 885    @property
 886    def spool_failure(self) -> str:
 887        """Why the last channel failed, empty unless it did."""
 888        return self._spool_failure
 889
 890    def spool_fraction(self) -> float:
 891        """How far the spool has closed, 0 to 1, for the diegetic warp ring."""
 892        if not self._spooling:
 893            return 0.0
 894        total = max(balance.WARP_SPOOL_S, self._spool_remaining)
 895        return 1.0 - self._spool_remaining / total
 896
 897    @property
 898    def afterburner_active(self) -> bool:
 899        return self._afterburner_active
 900
 901    @property
 902    def engine_ribbons(self) -> list[Node3D]:
 903        """The mounted exhaust plumes, empty when the vfx layer is absent."""
 904        return list(self._ribbons)
 905
 906    # ------------------------------------------------------------------ frames
 907
 908    def on_fixed_update(self, dt: float):
 909        if self._destroyed:
 910            self.velocity = Vec2(0.0, 0.0)
 911            self._thrust_input = Vec2(0.0, 0.0)
 912            return
 913        if self.patching:
 914            # Rooted: the patch is worth exactly as much as the seconds it costs.
 915            self.velocity = Vec2(0.0, 0.0)
 916            self._thrust_input = Vec2(0.0, 0.0)
 917            return
 918
 919        thrust = move_input()
 920        self._thrust_input = thrust
 921        burn = self._tick_afterburner(dt)
 922        acceleration = THRUST_ACCELERATION * burn
 923        ceiling = CRUISE_SPEED * burn
 924        damping = balance.INERTIAL_DAMPENING ** (dt * 60.0)
 925        vx = (float(self.velocity.x) + float(thrust.x) * acceleration * dt) * damping
 926        vz = (float(self.velocity.y) + float(thrust.y) * acceleration * dt) * damping
 927        speed = math.hypot(vx, vz)
 928        if speed > ceiling:
 929            vx, vz = vx / speed * ceiling, vz / speed * ceiling
 930        self.velocity = Vec2(vx, vz)
 931        self.position = Vec3(
 932            float(self.position.x) + vx * dt,
 933            PLANE_Y,
 934            float(self.position.z) + vz * dt,
 935        )
 936
 937    def _tick_afterburner(self, dt: float) -> float:
 938        """Ramp the burn in or out and return the current thrust multiplier."""
 939        wants = Input.is_action_pressed("afterburner")
 940        burning = wants and self.drain_energy(balance.ENERGY_AFTERBURNER_PER_S, dt, "afterburner")
 941        ramp_step = dt / max(balance.AFTERBURNER_IGNITION_RAMP_S, 1e-6)
 942        if burning:
 943            self._afterburner_ramp = min(1.0, self._afterburner_ramp + ramp_step)
 944        else:
 945            self._afterburner_ramp = max(0.0, self._afterburner_ramp - ramp_step)
 946        if burning != self._afterburner_active:
 947            self._afterburner_active = burning
 948            self.afterburner_changed(burning)
 949        return 1.0 + (AFTERBURNER_SPEED_MULT - 1.0) * self._afterburner_ramp
 950
 951    def on_update(self, dt: float):
 952        self._ensure_camera_rig()
 953        aim = self.aim_point()
 954        bearing = bearing_of(
 955            Vec3(
 956                float(aim.x) - float(self.position.x),
 957                0.0,
 958                float(aim.z) - float(self.position.z),
 959            )
 960        )
 961        if bearing is not None and not self._destroyed:
 962            self.heading = bearing
 963            self._aim_direction = heading_to_direction(self.heading)
 964            self._face_nose()
 965        if self._rig is not None:
 966            self._rig.set_aim(aim)
 967        self._tick_ribbons(dt)
 968        if self._destroyed:
 969            return
 970
 971        self._tick_shield_input(dt)
 972        self._tick_interact(dt)
 973        self._tick_channels(dt)
 974
 975    def _ensure_camera_rig(self) -> None:
 976        if self._rig is not None or self.tree is None or self.tree.root is None:
 977            return
 978        self._rig = _find_camera_rig(self.tree.root)
 979        if self._rig is not None:
 980            self._rig.set_target(self)
 981
 982    def _tick_ribbons(self, dt: float) -> None:
 983        """Drive the exhaust plumes from the thrust vector and the burner ramp.
 984
 985        The plume streams opposite the thrust rather than off the tail, because
 986        the thrust vector and the nose are independent here: a pilot strafing
 987        sideways with the nose on a target should see which way the ship is
 988        actually being pushed.
 989
 990        The throttle the plumes are given is spooled toward the commanded one
 991        rather than snapped to it, so a tapped key is a jet lighting and dying
 992        instead of one frame of fire.
 993
 994        A lit burner floors the throttle at :data:`RIBBON_BURNER_FLOOR` whether
 995        or not a thrust key is down. It is spending the capacitor either way,
 996        and a posture the pilot is paying for that shows nothing on the hull is
 997        a posture they cannot tell they are in.
 998        """
 999        if not self._ribbons:
1000            return
1001        stalled = self._destroyed or self.patching
1002        thrust = Vec2(0.0, 0.0) if stalled else self._thrust_input
1003        burn = 0.0 if stalled else self._afterburner_ramp
1004        tx, tz = float(thrust.x), float(thrust.y)
1005        magnitude = min(1.0, math.hypot(tx, tz))
1006        commanded = magnitude * (RIBBON_CRUISE_THROTTLE + (1.0 - RIBBON_CRUISE_THROTTLE) * burn)
1007        commanded = max(commanded, RIBBON_BURNER_FLOOR * burn)
1008        rate = RIBBON_SPOOL_UP_PER_S if commanded > self._ribbon_throttle else RIBBON_SPOOL_DOWN_PER_S
1009        step = rate * max(dt, 0.0)
1010        if abs(commanded - self._ribbon_throttle) <= step:
1011            self._ribbon_throttle = commanded
1012        else:
1013            self._ribbon_throttle += math.copysign(step, commanded - self._ribbon_throttle)
1014        # The plumes are told the drive state only when it moves. Every setter
1015        # on the ribbon rebuilds the whole plume, and a ship holding a throttle
1016        # or coasting at zero is the ordinary case, not the exception.
1017        drive = (self._ribbon_throttle, burn, -tx, -tz)
1018        if drive == self._ribbon_drive:
1019            return
1020        self._ribbon_drive = drive
1021        exhaust = Vec3(-tx, 0.0, -tz)
1022        for ribbon in self._ribbons:
1023            ribbon.set_throttle(self._ribbon_throttle)
1024            ribbon.set_burn(burn)
1025            ribbon.set_direction(exhaust)
1026
1027    def _tick_shield_input(self, dt: float) -> None:
1028        if self.shield is None:
1029            return
1030        left_edge = Input.is_action_just_pressed("shield_left")
1031        right_edge = Input.is_action_just_pressed("shield_right")
1032        if self._pad_aim:
1033            # Bumpers nudge in fixed steps; both together re-centre on the aim.
1034            step = math.radians(balance.SHIELD_PAD_NUDGE_DEGREES)
1035            if left_edge and right_edge:
1036                self.shield.recentre()
1037            elif left_edge:
1038                if Input.is_action_pressed("shield_right"):
1039                    self.shield.recentre()
1040                else:
1041                    self.shield.nudge(step)
1042            elif right_edge:
1043                if Input.is_action_pressed("shield_left"):
1044                    self.shield.recentre()
1045                else:
1046                    self.shield.nudge(-step)
1047            return
1048        rate = SHIELD_ROTATE_RADIANS_PER_S * dt
1049        if Input.is_action_pressed("shield_left"):
1050            self.shield.nudge(rate)
1051        if Input.is_action_pressed("shield_right"):
1052            self.shield.nudge(-rate)
1053
1054    def _tick_interact(self, dt: float) -> None:
1055        """Hold-to-patch, the keyboard and pad route into :meth:`patch_breach`.
1056
1057        Only the patch rung of the interact ladder lives here: docking, grabbing
1058        and the Refinery hold belong to the modules that own those verbs.
1059        """
1060        if not Input.is_action_pressed("interact"):
1061            self._interact_hold = 0.0
1062            self._interact_consumed = False
1063            return
1064        self._interact_hold += dt
1065        if self._interact_consumed or self._interact_hold < INTERACT_PATCH_HOLD_S:
1066            return
1067        if self.open_breaches > 0 and not self.patching and self.patch_breach():
1068            self._interact_consumed = True
1069
1070    def interact_hold_fraction(self) -> float:
1071        """Progress of the interact hold toward the patch, 0 to 1."""
1072        return min(1.0, self._interact_hold / INTERACT_PATCH_HOLD_S)
1073
1074    def _tick_channels(self, dt: float) -> None:
1075        if self._patch_remaining > 0.0:
1076            self._patch_remaining = max(0.0, self._patch_remaining - dt)
1077            if self._patch_remaining == 0.0 and self.open_breaches > 0:
1078                self.open_breaches -= 1
1079                self.breach_patched(self.open_breaches)
1080
1081        if Input.is_action_just_pressed("warp_spool"):
1082            if self._spooling:
1083                self.cancel_warp_spool()
1084            else:
1085                self.begin_warp_spool()
1086
1087        if self._spooling:
1088            self._spool_remaining -= dt
1089            if self._spool_remaining <= 0.0:
1090                if self._fuel_is_dry():
1091                    self._end_spool(SPOOL_FAILED, SPOOL_FAILURE_NO_FUEL)
1092                else:
1093                    self._end_spool(SPOOL_JUMP)
1094
1095
1096def _find_camera_rig(node: Node) -> CameraRig | None:
1097    """Depth-first search for the run's camera rig, by type rather than by name."""
1098    if isinstance(node, CameraRig):
1099        return node
1100    for child in node.children:
1101        found = _find_camera_rig(child)
1102        if found is not None:
1103            return found
1104    return None