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