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