shrike/runtime.py¶

Part of SHRIKE.

  1"""The shared runtime spine of SHRIKE.
  2
  3Everything that more than one gameplay module must agree on lives here: the
  4flight-plane convention, the signal-name registry, node groups, collision
  5layers, the camera rig, and the input map. Gameplay modules import these
  6names; none of them redefines a group string, a layer bit or a signal name of
  7its own.
  8
  9Flight-plane convention
 10=======================
 11
 12Gameplay happens on a single plane in 3D space:
 13
 14* **Y is up.** The flight plane is the XZ plane at ``y = PLANE_Y`` (0.0).
 15* Ship, enemies, salvage and projectiles keep ``position.y == PLANE_Y`` at all
 16  times; anything visual may float above or below, gameplay logic never does.
 17* 2D quantities on the plane map as ``Vec2(x, z)``; :func:`to_plane` and
 18  :func:`from_plane` convert. Headings are radians anticlockwise from +X when
 19  viewed from above (from +Y looking down).
 20* The camera looks down at the plane from a pitch of about 60 degrees from the
 21  horizontal, offset toward +Z, so on screen +X is right and -Z is up.
 22"""
 23
 24import math
 25
 26from simvx.core import (
 27    Camera3D,
 28    Input,
 29    InputBinding,
 30    InputMap,
 31    JoyAxis,
 32    JoyButton,
 33    Key,
 34    MouseButton,
 35    Node3D,
 36    Vec2,
 37    Vec3,
 38)
 39
 40from . import balance
 41
 42# ============================================================================
 43# Flight plane
 44# ============================================================================
 45
 46PLANE_Y = 0.0
 47
 48#: Window size for the interactive app; tests use SceneRunner's own size.
 49WINDOW_WIDTH = 1280
 50WINDOW_HEIGHT = 720
 51
 52
 53def to_plane(v: Vec3) -> Vec2:
 54    """Project a world position onto the flight plane as ``Vec2(x, z)``."""
 55    return Vec2(float(v.x), float(v.z))
 56
 57
 58def from_plane(p: Vec2, y: float = PLANE_Y) -> Vec3:
 59    """Lift a flight-plane position back into world space."""
 60    return Vec3(float(p.x), y, float(p.y))
 61
 62
 63def heading_to_direction(heading: float) -> Vec3:
 64    """Unit world vector on the plane for a heading in radians (0 is +X)."""
 65    return Vec3(math.cos(heading), 0.0, -math.sin(heading))
 66
 67
 68# ============================================================================
 69# Node groups
 70# ============================================================================
 71
 72
 73class Groups:
 74    """Scene-tree group names. Query with ``self.tree.group(Groups.X)``."""
 75
 76    SHIP = "ship"
 77    ENEMIES = "enemies"
 78    HUNTER = "hunter"
 79    TURRETS = "turrets"
 80    SALVAGE = "salvage"
 81    DEPOSITS = "deposits"
 82    WRECKS = "wrecks"
 83    VAULTS = "vaults"
 84    DEPOTS = "depots"
 85    PLAYER_PROJECTILES = "player_projectiles"
 86    ENEMY_PROJECTILES = "enemy_projectiles"
 87    HAZARDS = "hazards"
 88
 89
 90class Services:
 91    """Singleton names for the run-scoped system nodes.
 92
 93    Registered with ``tree.add_singleton(name, node)`` by the run scene and
 94    looked up with ``tree.singletons[name]``. One name per system module.
 95    """
 96
 97    POWER = "power_system"
 98    SIGNATURE = "signature_meter"
 99    ECONOMY = "economy"
100    NOTORIETY = "notoriety"
101    WAVES = "wave_composer"
102    DAMAGE = "damage_router"
103    JUICE = "juice_director"
104    AUDIO = "audio_director"
105    SAVE = "save_system"
106    META = "meta_profile"
107    HUD = "hud"
108
109
110# ============================================================================
111# Collision layers
112# ============================================================================
113
114
115class Layers:
116    """Collision layer bits, shared by every body and area in the game.
117
118    A body's ``collision_layer`` says what it IS; its ``collision_mask`` says
119    what it SEES. The canonical masks below are the default matrix; combat.py
120    owns any exception and documents it where it applies.
121    """
122
123    SHIP = 1 << 0
124    ENEMY = 1 << 1
125    PLAYER_FIRE = 1 << 2
126    ENEMY_FIRE = 1 << 3
127    SALVAGE = 1 << 4
128    TERRAIN = 1 << 5
129    HUNTER = 1 << 6
130    INTERACT = 1 << 7  # depots, vaults, deposits, event triggers (sensor-only)
131
132    MASK_SHIP = ENEMY | ENEMY_FIRE | TERRAIN | HUNTER
133    MASK_ENEMY = SHIP | PLAYER_FIRE | TERRAIN
134    MASK_PLAYER_FIRE = ENEMY | TERRAIN | HUNTER
135    MASK_ENEMY_FIRE = SHIP | TERRAIN
136    MASK_SCOOP = SALVAGE
137    MASK_INTERACT_SENSOR = SHIP
138
139
140# ============================================================================
141# Signal-name registry
142# ============================================================================
143
144
145class SignalNames:
146    """Canonical names for every cross-module signal.
147
148    A module that owns one of these defines a ``Signal`` attribute with
149    exactly this name on the emitting node; consumers connect by attribute.
150    Payloads are documented beside each name below. Signals used only within
151    one module need not be registered here.
152    """
153
154    # ship.py
155    HULL_CHANGED = "hull_changed"  # (current: float, maximum: float)
156    BREACH_OPENED = "breach_opened"  # (open_breaches: int)
157    BREACH_PATCHED = "breach_patched"  # (open_breaches: int)
158    SHIP_DESTROYED = "ship_destroyed"  # ()
159    AFTERBURNER_CHANGED = "afterburner_changed"  # (active: bool)
160
161    # power.py
162    CAPACITOR_CHANGED = "capacitor_changed"  # (current: float, maximum: float)
163    ENERGY_DENIED = "energy_denied"  # (consumer: str)
164    SOLAR_STATE_CHANGED = "solar_state_changed"  # (state: str)
165    WING_DESTROYED = "wing_destroyed"  # (wing_index: int)
166    GENERATOR_CHANGED = "generator_changed"  # (running: bool)
167    SILENT_RUNNING_CHANGED = "silent_running_changed"  # (active: bool)
168    O2_CHANGED = "o2_changed"  # (current: float, maximum: float)
169    FUEL_CHANGED = "fuel_changed"  # (current: float, maximum: float)
170
171    # signature.py
172    SIGNATURE_CHANGED = "signature_changed"  # (value: float)
173    SIGNATURE_LOCKED = "signature_locked"  # ()
174
175    # weapons.py
176    WEAPON_FIRED = "weapon_fired"  # (weapon_id: str)
177    AMMO_CHANGED = "ammo_changed"  # (weapon_id: str, rounds: int)
178    WEAPON_EQUIPPED = "weapon_equipped"  # (weapon_id: str, hardpoint: int)
179
180    # combat.py
181    DAMAGE_DEALT = "damage_dealt"  # (target: Node, amount: float, kind: str)
182    ENEMY_KILLED = "enemy_killed"  # (archetype: str, position: Vec3, elite: bool)
183    PLAYER_DAMAGED = "player_damaged"  # (amount: float, direction: Vec3)
184    SHIELD_ABSORBED = "shield_absorbed"  # (amount: float)
185    SHIELD_BROKEN = "shield_broken"  # ()
186
187    # enemies
188    SCREAMER_SCREAMED = "screamer_screamed"  # ()
189    UMBILICAL_BROKEN = "umbilical_broken"  # ()
190    SHELL_LAUNCHED = "shell_launched"  # (origin: Vec3, flight_seconds: float)
191
192    # waves.py
193    WAVE_COMPOSED = "wave_composed"  # (recipe_id: str, threat_spent: float)
194    WAVE_SPAWNED = "wave_spawned"  # (count: int)
195
196    # hunter.py
197    HUNTER_TELEGRAPH = "hunter_telegraph"  # (stage: str: "t60" | "t30" | "t0")
198    HUNTER_ARRIVED = "hunter_arrived"  # (arrival_index: int)
199    HUNTER_LOCKOUT_ENDED = "hunter_lockout_ended"  # ()
200    HUNTER_FED = "hunter_fed"  # (scrap: float, seconds_bought: float)
201    HUNTER_DEPARTED = "hunter_departed"  # ()
202    QUILL_SHEARED = "quill_sheared"  # (quills_this_run: int)
203    SECTOR_CRACKED = "sector_cracked"  # ()
204
205    # sector.py / events.py
206    SECTOR_ENTERED = "sector_entered"  # (sector_index: int, biome_id: str)
207    RESOURCE_COLLECTED = "resource_collected"  # (kind: str, amount: float)
208    RICH_NODE_TAPPED = "rich_node_tapped"  # ()
209    VAULT_HACKED = "vault_hacked"  # (scrap: float)
210    SIGNAL_EVENT = "signal_event"  # (event_id: str)
211
212    # trading.py / economy
213    SCRAP_CHANGED = "scrap_changed"  # (total: float)
214    CORES_BANKED = "cores_banked"  # (cores: float, rate: float)
215    DOCKED = "docked"  # (depot_id: str)
216    UNDOCKED = "undocked"  # ()
217    REFINERY_BATCH_STARTED = "refinery_batch_started"  # ()
218    REFINERY_BATCH_COMPLETED = "refinery_batch_completed"  # (cores: float)
219
220    # audio.py
221    AUDIO_CUE = "audio_cue"  # (cue_id: str, caption: str): every played cue; the HUD's caption track
222
223    # bounty.py
224    NOTORIETY_CHANGED = "notoriety_changed"  # (value: int, reason: str)
225
226    # chart.py
227    CHART_OPENED = "chart_opened"  # ()
228    JUMP_STARTED = "jump_started"  # (node_id: str, fuel_cost: float)
229    JUMP_COMPLETED = "jump_completed"  # (node_id: str)
230    WAKE_ADVANCED = "wake_advanced"  # (columns_consumed: int)
231
232    # warp (ship.py owns the spool; flow.py consumes the outcome)
233    WARP_SPOOL_STARTED = "warp_spool_started"  # (emergency: bool)
234    WARP_SPOOL_INTERRUPTED = "warp_spool_interrupted"  # (added_seconds: float)
235    WARP_SPOOL_CANCELLED = "warp_spool_cancelled"  # ()
236    WARP_COMPLETED = "warp_completed"  # (emergency: bool)
237
238    # flow.py
239    RUN_STARTED = "run_started"  # (config: dict)
240    RUN_ENDED = "run_ended"  # (outcome: str, ledger: dict)
241    LAST_STAND_TRIGGERED = "last_stand_triggered"  # ()
242    EXTRACTION_COMPLETED = "extraction_completed"  # (ledger: dict)
243
244    # save.py / meta.py
245    PROFILE_LOADED = "profile_loaded"  # (profile: dict)
246    PROFILE_SAVED = "profile_saved"  # ()
247    DOCTRINE_CHANGED = "doctrine_changed"  # (node_id: str, owned: bool)
248
249
250# ============================================================================
251# Input map
252# ============================================================================
253
254#: Every gameplay action, bound for keyboard and mouse AND gamepad. The run
255#: root declares ``input_actions = INPUT_ACTIONS`` so the scene tree registers
256#: the map at mount and re-registers it on every ``change_scene``.
257INPUT_ACTIONS: dict[str, list] = {
258    # Thrust vector (left stick on pad; the nose aims independently)
259    "thrust_up": [Key.W, InputBinding(joy_axis=JoyAxis.LEFT_Y, joy_axis_positive=False)],
260    "thrust_down": [Key.S, InputBinding(joy_axis=JoyAxis.LEFT_Y, joy_axis_positive=True)],
261    "thrust_left": [Key.A, InputBinding(joy_axis=JoyAxis.LEFT_X, joy_axis_positive=False)],
262    "thrust_right": [Key.D, InputBinding(joy_axis=JoyAxis.LEFT_X, joy_axis_positive=True)],
263    # Aim nose (mouse position on keyboard; right stick on pad, read as an axis pair)
264    "aim_up": [InputBinding(joy_axis=JoyAxis.RIGHT_Y, joy_axis_positive=False)],
265    "aim_down": [InputBinding(joy_axis=JoyAxis.RIGHT_Y, joy_axis_positive=True)],
266    "aim_left": [InputBinding(joy_axis=JoyAxis.RIGHT_X, joy_axis_positive=False)],
267    "aim_right": [InputBinding(joy_axis=JoyAxis.RIGHT_X, joy_axis_positive=True)],
268    # Weapons
269    "fire_primary": [MouseButton.LEFT, InputBinding(joy_axis=JoyAxis.RIGHT_TRIGGER, joy_axis_positive=True)],
270    "mining_beam": [MouseButton.RIGHT, InputBinding(joy_axis=JoyAxis.LEFT_TRIGGER, joy_axis_positive=True)],
271    # Shield arc: Q/E rotate on keyboard; on pad the arc mirrors the aim vector
272    # and the bumpers nudge it 60 degrees; tapping both re-centres it.
273    "shield_left": [Key.Q, JoyButton.LEFT_BUMPER],
274    "shield_right": [Key.E, JoyButton.RIGHT_BUMPER],
275    # Movement and posture
276    "afterburner": [Key.LEFT_SHIFT, JoyButton.A],
277    "silent_running": [Key.C, JoyButton.Y],
278    "solar_wings": [Key.X, JoyButton.DPAD_UP],
279    "generator": [Key.Z, JoyButton.DPAD_DOWN],
280    # Interaction: tap docks or grabs, hold 1 s patches the nearest breach,
281    # hold 3 s starts a Refinery batch (or sells scrap while docked).
282    "interact": [Key.F, JoyButton.X],
283    "jettison_scrap": [Key.G, JoyButton.B],
284    "warp_spool": [Key.R, JoyButton.LEFT_THUMB],
285    "tractor_scoop": [Key.TAB, JoyButton.RIGHT_THUMB],
286    # Screens. Escape is deliberately not on the chart: backing out of things
287    # is what Escape means everywhere else, so it raises the pause overlay and
288    # the map lives on its own key.
289    "star_chart": [Key.M, JoyButton.BACK],
290    "pause_menu": [Key.ESCAPE, JoyButton.START],
291    # Display: hides and shows the HUD's persistent controls bar. A display
292    # control rather than a gameplay verb, but it lives on the declarative map
293    # like everything else so it remaps and reaches the pad.
294    "toggle_controls": [Key.H, JoyButton.DPAD_LEFT],
295    # Menu navigation. No gameplay code reads these; every screen's cursor
296    # answers the arrows and Enter alongside the thrust keys and interact.
297    # They share pad buttons with gameplay verbs and the controls-bar toggle
298    # (D-pad, A) safely because the two vocabularies are never live at the same
299    # time. All four arrows are declared because the screens advertise ARROWS as
300    # one block, and half a block that answers is worse than none.
301    "menu_up": [Key.UP, JoyButton.DPAD_UP],
302    "menu_down": [Key.DOWN, JoyButton.DPAD_DOWN],
303    "menu_left": [Key.LEFT, JoyButton.DPAD_LEFT],
304    "menu_right": [Key.RIGHT, JoyButton.DPAD_RIGHT],
305    "menu_confirm": [Key.ENTER, Key.KP_ENTER, JoyButton.A],
306    # Developer readout: wall-clock frame time and, where the driver has
307    # timestamp pools, the per-pass GPU times. Keyboard only and deliberately
308    # unbound on the pad, because it is a measurement tool rather than a verb
309    # and nothing in the game reads it. "It feels hotter than last build" is
310    # not a number until somebody on real hardware presses this.
311    "debug_frame_stats": [Key.F3],
312}
313
314#: Actions that are instruments rather than verbs. They are exempt from the
315#: keyboard-and-pad rule every other action keeps: a pad button spent on a
316#: developer readout is a pad button a pilot cannot use, and nothing in the game
317#: reads these. Declared here rather than spelled out in the test, so the
318#: exemption is the module's statement about itself.
319DEBUG_ACTIONS: frozenset[str] = frozenset({"debug_frame_stats"})
320
321
322def register_input_actions() -> None:
323    """Register the full action map with the active :class:`InputMap`.
324
325    The canonical path is declarative (``input_actions`` on the run root);
326    this helper exists for tests and tools that drive input without mounting
327    the real root scene.
328    """
329    for name, bindings in INPUT_ACTIONS.items():
330        InputMap.add_action(name, list(bindings), _quiet=True)
331
332
333def move_input() -> Vec2:
334    """The thrust vector from the action map, on the flight plane, unnormalised.
335
336    Returns ``Vec2(x, z)`` in plane coordinates: +x is screen right, +y (that
337    is, world +Z) is screen down. Magnitude is clamped to 1.
338    """
339    x = Input.get_axis("thrust_left", "thrust_right")
340    z = Input.get_axis("thrust_up", "thrust_down")
341    v = Vec2(x, z)
342    length = math.hypot(float(v.x), float(v.y))
343    if length > 1.0:
344        return Vec2(float(v.x) / length, float(v.y) / length)
345    return v
346
347
348def gamepad_aim_input() -> Vec2:
349    """The right-stick aim vector in plane coordinates, zero when centred."""
350    return Vec2(Input.get_axis("aim_left", "aim_right"), Input.get_axis("aim_up", "aim_down"))
351
352
353# ============================================================================
354# Camera rig
355# ============================================================================
356
357CAMERA_PITCH_RADIANS = math.radians(60.0)
358CAMERA_DISTANCE = 42.0
359CAMERA_FOV_DEGREES = 50.0
360#: The aspect the framing is authored at. A window wider than this keeps the
361#: same horizontal extent and gains nothing; a narrower one keeps the vertical
362#: field and simply sees less to the sides.
363CAMERA_REFERENCE_ASPECT = 16.0 / 9.0
364#: Exponential follow smoothing rate, higher is stiffer.
365CAMERA_FOLLOW_RATE = 8.0
366#: Seconds of plane velocity the focus runs ahead of the ship by. The aim lead
367#: alone points the view where the guns are; this points it where the hull is
368#: going, which is the half that decides whether an obstacle arrives as a
369#: surprise.
370CAMERA_VELOCITY_LEAD_S = 0.45
371#: Ceiling on the combined aim-plus-velocity lead, world units. Set above the
372#: worst case honest play can produce (a full-speed burn straight at the far
373#: corner of the screen) so it only ever catches an aim point that is off the
374#: screen entirely, and the hull never leaves the middle of the frame.
375CAMERA_LEAD_MAX_UNITS = 14.0
376#: Below this pitch above the top screen edge the ground plane is no longer in
377#: front of the camera; the framing maths clamps rather than diverging.
378_CAMERA_MIN_EDGE_PITCH = math.radians(2.0)
379
380
381def camera_fov_degrees(aspect: float) -> float:
382    """The vertical field of view to use at *aspect*, fitting by width.
383
384    A perspective camera holds its vertical extent fixed, so on an ultrawide
385    window every extra pixel of width buys the player more warning to the
386    sides than the game is balanced for. Past
387    :data:`CAMERA_REFERENCE_ASPECT` this narrows the vertical field just
388    enough to hold the horizontal extent at its authored value; at or below
389    the reference aspect it is exactly :data:`CAMERA_FOV_DEGREES`.
390    """
391    aspect = max(float(aspect), 1e-3)
392    half_height = math.tan(math.radians(CAMERA_FOV_DEGREES) * 0.5)
393    half_width = half_height * CAMERA_REFERENCE_ASPECT
394    return math.degrees(2.0 * math.atan(min(half_height, half_width / aspect)))
395
396
397def camera_plane_extents(aspect: float = CAMERA_REFERENCE_ASPECT) -> tuple[float, float]:
398    """Flight-plane distances from the focus to the near and far screen edges.
399
400    Returns ``(near, far)`` in world units: how far toward +Z (screen down) the
401    bottom edge of the screen reaches, and how far toward -Z (screen up) the
402    top edge does. A pitched camera makes those two wildly different, which is
403    why :class:`CameraRig` biases its focus rather than centring the ship.
404    """
405    half = math.radians(camera_fov_degrees(aspect)) * 0.5
406    height = CAMERA_DISTANCE * math.sin(CAMERA_PITCH_RADIANS)
407    base = CAMERA_DISTANCE * math.cos(CAMERA_PITCH_RADIANS)
408    near = base - height / math.tan(CAMERA_PITCH_RADIANS + half)
409    top_pitch = max(CAMERA_PITCH_RADIANS - half, _CAMERA_MIN_EDGE_PITCH)
410    far = height / math.tan(top_pitch) - base
411    return near, far
412
413
414def camera_depth_bias(aspect: float = CAMERA_REFERENCE_ASPECT) -> float:
415    """How far toward +Z the focus sits so the ship is centred in *depth*.
416
417    Pointing the focus straight at the ship puts it well below the middle of
418    the visible band: the top edge of the screen reaches much further across
419    the plane than the bottom edge does. Offsetting the focus by half the
420    difference gives the same warning above and below the hull.
421    """
422    near, far = camera_plane_extents(aspect)
423    return (far - near) * 0.5
424
425
426class CameraRig(Node3D):
427    """The near top-down chase camera for the flight plane.
428
429    Owns a :class:`Camera3D` child pitched ``CAMERA_PITCH_RADIANS`` from the
430    horizontal, offset toward +Z, looking at a focus point on the plane. Three
431    things decide where that focus goes:
432
433    * the aim, by ``balance.CAMERA_AIM_LEAD_FRACTION`` of the ship-to-aim
434      offset;
435    * the ship's plane velocity, by :data:`CAMERA_VELOCITY_LEAD_S`, so flying
436      somewhere shows you what you are flying into (the two leads together are
437      clamped to :data:`CAMERA_LEAD_MAX_UNITS`);
438    * :func:`camera_depth_bias`, a fixed push toward +Z that makes the warning
439      above and below the hull equal.
440
441    The vertical field of view is refreshed from :func:`camera_fov_degrees`
442    every frame, so a window that is wider than 16:9 sees the same slice of
443    the plane rather than a wider one.
444
445    Usage: add as a sibling of the ship, call :meth:`set_target` once and
446    :meth:`set_aim` whenever the aim point moves (the ship module does both).
447    """
448
449    def __init__(self, **kwargs):
450        super().__init__(**kwargs)
451        self._target: Node3D | None = None
452        self._aim_point: Vec3 = Vec3(0.0, PLANE_Y, 0.0)
453        self._focus: Vec2 = Vec2(0.0, 0.0)
454        self.camera: Camera3D | None = None
455
456    def on_ready(self):
457        self.camera = self.add_child(Camera3D(name="Camera", fov=CAMERA_FOV_DEGREES))
458        self.snap()
459
460    @property
461    def focus(self) -> Vec2:
462        """The flight-plane point the camera is currently looking at."""
463        return self._focus
464
465    @property
466    def aspect(self) -> float:
467        """The viewport's width-to-height ratio, falling back to the reference."""
468        size = self.tree.screen_size if self.tree is not None else None
469        if size is None:
470            return CAMERA_REFERENCE_ASPECT
471        width, height = float(size[0]), float(size[1])
472        if width <= 0.0 or height <= 0.0:
473            return CAMERA_REFERENCE_ASPECT
474        return width / height
475
476    def set_target(self, node: Node3D) -> None:
477        """Follow *node*; the rig snaps to it immediately."""
478        self._target = node
479        self.snap()
480
481    def snap(self) -> None:
482        """Cut the camera to where the target is now, with no follow lag.
483
484        For teleports. A warp arrival moves the hull the width of a sector in
485        one frame, and a rig left to ease after it would fly the whole of that
486        distance with the player watching.
487        """
488        self._snap()
489
490    def set_aim(self, world_point: Vec3) -> None:
491        """The world-space point the player is aiming at, on the flight plane."""
492        self._aim_point = world_point
493
494    def _lead(self, ship: Vec2) -> Vec2:
495        """The combined aim and velocity lead, clamped to its ceiling."""
496        aim = to_plane(self._aim_point)
497        fraction = balance.CAMERA_AIM_LEAD_FRACTION
498        lead_x = (float(aim.x) - float(ship.x)) * fraction
499        lead_z = (float(aim.y) - float(ship.y)) * fraction
500        velocity = getattr(self._target, "velocity", None)
501        if velocity is not None and len(velocity) == 2:
502            lead_x += float(velocity[0]) * CAMERA_VELOCITY_LEAD_S
503            lead_z += float(velocity[1]) * CAMERA_VELOCITY_LEAD_S
504        reach = math.hypot(lead_x, lead_z)
505        if reach > CAMERA_LEAD_MAX_UNITS:
506            scale = CAMERA_LEAD_MAX_UNITS / reach
507            lead_x, lead_z = lead_x * scale, lead_z * scale
508        return Vec2(lead_x, lead_z)
509
510    def _desired_focus(self) -> Vec2:
511        if self._target is None:
512            return self._focus
513        ship = to_plane(self._target.position)
514        lead = self._lead(ship)
515        return Vec2(
516            float(ship.x) + float(lead.x),
517            float(ship.y) + float(lead.y) + camera_depth_bias(self.aspect),
518        )
519
520    def _snap(self) -> None:
521        self._focus = self._desired_focus()
522        self._place()
523
524    def _place(self) -> None:
525        if self.camera is None:
526            return
527        self.camera.fov = camera_fov_degrees(self.aspect)
528        fx, fz = float(self._focus.x), float(self._focus.y)
529        cam_y = CAMERA_DISTANCE * math.sin(CAMERA_PITCH_RADIANS)
530        cam_z = fz + CAMERA_DISTANCE * math.cos(CAMERA_PITCH_RADIANS)
531        self.camera.position = Vec3(fx, cam_y, cam_z)
532        self.camera.look_at(Vec3(fx, PLANE_Y, fz))
533
534    def on_update(self, dt: float):
535        desired = self._desired_focus()
536        blend = 1.0 - math.exp(-CAMERA_FOLLOW_RATE * dt)
537        self._focus = Vec2(
538            float(self._focus.x) + (float(desired.x) - float(self._focus.x)) * blend,
539            float(self._focus.y) + (float(desired.y) - float(self._focus.y)) * blend,
540        )
541        self._place()