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