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()