afterglow/view/camera.pyΒΆ
Part of Afterglow.
1"""GameCamera: a Camera3D that smoothly follows the Wisp and frames the room.
2
3Each room is single-screen, so the camera's resting framing fits the whole room
4into view (chosen so the larger of width/height fills the frame with a margin).
5On top of that resting pose the camera adds:
6 * smoothed follow of the player centre (exponential ease toward the target),
7 * dash look-ahead (lead the camera in the player's travel direction),
8 * a gentle downward tilt + slight pull-back so the diorama relief reads in 3D.
9
10Perspective-only engine: we fake an "ortho-ish" flat look with a low fov and a
11long pull-back distance, then bias the framing distance to fit the room.
12
13Read-only over the sim. ``update(dt, room)`` is called once per frame after the
14room has stepped.
15"""
16
17from __future__ import annotations
18
19import math
20
21import numpy as np
22
23from simvx.core import Camera3D, Vec3
24
25TILE_SIZE = 8
26WORLD_SCALE = 1.0 / TILE_SIZE
27
28# Framing / feel tuning (world units, seconds).
29FOV = 42.0 # low-ish fov -> long focal length -> flat, diorama-friendly framing
30FIT_MARGIN = 1.04 # a thin safety border so the room FILLS the frame
31FIT_ASPECT = 16.0 / 9.0 # real window aspect (Camera3D projects 16:9) so width never over-pulls
32DOWN_TILT = 0.06 # radians the camera tilts down for a touch of parallax depth
33FOLLOW_SMOOTH = 7.0 # higher = snappier follow
34LOOKAHEAD = 0.9 # world units of dash/velocity lead
35LOOKAHEAD_SMOOTH = 5.0
36
37
38def _exp_smooth(current, target, rate: float, dt: float):
39 """Frame-rate independent exponential approach (alpha = 1 - e^-rate*dt)."""
40 a = 1.0 - math.exp(-rate * max(0.0, dt))
41 return current + (np.asarray(target) - np.asarray(current)) * a
42
43
44class GameCamera(Camera3D):
45 """Smoothed follow camera framed to fit a single-screen room.
46
47 Usage::
48
49 cam = GameCamera()
50 parent.add_child(cam)
51 cam.frame_room(room) # set resting framing on room change
52 # each frame after room.step(...):
53 cam.update(dt, room)
54 """
55
56 def __init__(self, **kwargs):
57 kwargs.setdefault("fov", FOV)
58 super().__init__(**kwargs)
59 self._room_centre = Vec3(0, 0, 0)
60 self._fit_distance = 12.0
61 self._follow = np.zeros(2, dtype=np.float32) # smoothed x,y of focus
62 self._lookahead = np.zeros(2, dtype=np.float32)
63 self._initialised = False
64 # Screen-shake: a transient X/Y offset added on top of the framed eye in
65 # _apply, decaying over its own duration. Driven via shake(); NEVER via
66 # punch_position on .position (that truncates the 3D eye to Vec2, zeroing
67 # the camera's pull-back Z and blanking the whole view for the shake).
68 self._shake_amp = 0.0
69 self._shake_t = 0.0
70 self._shake_dur = 0.0
71 self._shake_freq = 22.0
72 self._shake_decay = 9.0
73 self._shake_offset = np.zeros(2, dtype=np.float32)
74
75 # -- framing -----------------------------------------------------------
76
77 def frame_room(self, room) -> None:
78 """Compute the resting framing (centre + distance) for a room.
79
80 The diorama places one tile per world unit (tile coord == world coord),
81 so the room spans ``room.w`` x ``room.h`` world units. The view's
82 ``WORLD_SCALE`` only converts entity *pixel* coords into that same space.
83 """
84 w = float(room.w)
85 h = float(room.h)
86 cx = w * 0.5
87 cy = -h * 0.5
88 self._room_centre = Vec3(cx, cy, 0.0)
89
90 # Distance so the WHOLE room fits: vertical fov bounds height directly,
91 # and a conservative window aspect bounds width (narrower windows need a
92 # larger pull-back). The diorama relief extrudes toward the camera, so
93 # add its half-depth so the front faces never clip the near plane.
94 half_fov = math.radians(FOV) * 0.5
95 tan_v = math.tan(half_fov)
96 fit_h = (h * 0.5 * FIT_MARGIN) / tan_v
97 fit_w = (w * 0.5 * FIT_MARGIN) / (tan_v * FIT_ASPECT)
98 self._fit_distance = max(fit_h, fit_w, 4.0) + 1.0
99
100 self._follow = np.array([cx, cy], dtype=np.float32)
101 self._lookahead[:] = 0.0
102 self._initialised = True
103 self._apply(self._follow)
104
105 # -- per-frame ---------------------------------------------------------
106
107 def shake(self, amplitude: float, duration: float, *, frequency: float = 22.0, decay: float = 9.0) -> None:
108 """Start a damped-sine screen shake added as an offset in ``_apply``.
109
110 Safe over the framed 3D eye: it perturbs only the X/Y of the eye and the
111 look target, so the camera keeps its pull-back Z and never blanks the
112 view. A new shake replaces any in-flight one (the louder feel wins).
113 """
114 amplitude = float(amplitude)
115 duration = float(duration)
116 if amplitude <= 0.0 or duration <= 0.0:
117 return
118 self._shake_amp = amplitude
119 self._shake_dur = duration
120 self._shake_t = 0.0
121 self._shake_freq = float(frequency)
122 self._shake_decay = float(decay)
123
124 def _advance_shake(self, dt: float) -> None:
125 if self._shake_t >= self._shake_dur:
126 self._shake_offset[:] = 0.0
127 return
128 self._shake_t += max(0.0, dt)
129 if self._shake_t >= self._shake_dur:
130 self._shake_offset[:] = 0.0
131 self._shake_amp = 0.0
132 return
133 env = math.exp(-self._shake_decay * self._shake_t)
134 wave = math.sin(2.0 * math.pi * self._shake_freq * self._shake_t)
135 disp = self._shake_amp * env * wave
136 # Bias the two axes slightly so the shake is not a pure diagonal line.
137 self._shake_offset[0] = disp
138 self._shake_offset[1] = disp * 0.85
139
140 def update(self, dt: float, room) -> None:
141 """Smoothly track the player; lead the dash; keep the room framed."""
142 if not self._initialised:
143 self.frame_room(room)
144 self._advance_shake(dt)
145
146 p = room.player
147 target = np.array([p.cx * WORLD_SCALE, -p.cy * WORLD_SCALE], dtype=np.float32)
148
149 # Lead in the direction of travel (strongest during a dash).
150 speed = math.hypot(p.vx, p.vy)
151 if speed > 1e-3:
152 lead_scale = LOOKAHEAD * (1.6 if p.state == "dash" else 1.0)
153 lead = np.array([p.vx, -p.vy], dtype=np.float32) / speed * lead_scale
154 else:
155 lead = np.zeros(2, dtype=np.float32)
156 self._lookahead = _exp_smooth(self._lookahead, lead, LOOKAHEAD_SMOOTH, dt)
157
158 focus = target + self._lookahead
159 # Keep the focus inside the room so single-screen rooms never drift off.
160 focus = self._clamp_focus(room, focus)
161 self._follow = _exp_smooth(self._follow, focus, FOLLOW_SMOOTH, dt)
162 self._apply(self._follow)
163
164 # -- internals ---------------------------------------------------------
165
166 def _clamp_focus(self, room, focus: np.ndarray) -> np.ndarray:
167 """Blend the player focus toward room centre so the level stays framed."""
168 # Limit how far the focus may stray from the room centre (a fraction of
169 # the room half-extent) so a single-screen room is always mostly visible.
170 c = np.array([self._room_centre.x, self._room_centre.y], dtype=np.float32)
171 half = np.array([room.w * 0.20, room.h * 0.20], dtype=np.float32)
172 return c + np.clip(focus - c, -half, half)
173
174 def _apply(self, focus_xy: np.ndarray) -> None:
175 """Position the camera behind/above the focus with a downward tilt."""
176 fx, fy = float(focus_xy[0]), float(focus_xy[1])
177 dist = self._fit_distance
178 # Transient screen-shake offset (X/Y only) preserves the pull-back Z.
179 sx, sy = float(self._shake_offset[0]), float(self._shake_offset[1])
180 # Pull back along +Z, raise slightly so the tilt looks down onto the relief.
181 height = math.tan(DOWN_TILT) * dist
182 eye = Vec3(fx + sx, fy + sy + height, dist)
183 self.position = eye
184 self.look_at((fx + sx, fy + sy, 0.0))