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