nodes/camera_controller.pyΒΆ

Part of Dungeon Explorer.

 1"""Camera2D controller: smooth follow with room bounds clamping and directional shake."""
 2
 3import random
 4
 5from scripts.dungeon_generator import TILE_SIZE
 6
 7from simvx.core import Camera2D, Vec2
 8
 9
10class CameraController(Camera2D):
11    """Camera that smoothly follows a target node, clamped to dungeon bounds.
12
13    Uses Camera2D's built-in smoothing, limits, and omnidirectional shake, and
14    layers a directional "kick" on top for hit feedback (damage-scaled and
15    dodge shakes reuse the base shake unchanged).
16    """
17
18    # Transient view node: recreated on every dungeon/town load. Excluded from
19    # save snapshots so SaveManager never records a path that is absent when a
20    # save is applied before the camera is rebuilt (see _continue_game).
21    __save_persist__ = False
22
23    def __init__(self, **kwargs):
24        super().__init__(**kwargs)
25        # Directional kick layered on top of Camera2D's base shake.
26        self._kick_dir = Vec2()
27        self._kick_intensity = 0.0
28        self._kick_timer = 0.0
29        self._kick_duration = 0.0
30
31    def set_target(self, node) -> None:
32        """Set the node to follow (uses Camera2D.target)."""
33        self.target = node
34
35    def set_bounds(self, min_pos: Vec2, max_pos: Vec2) -> None:
36        """Set the camera movement bounds (world coordinates)."""
37        self.limit_left = float(min_pos.x)
38        self.limit_top = float(min_pos.y)
39        self.limit_right = float(max_pos.x)
40        self.limit_bottom = float(max_pos.y)
41
42    def set_bounds_from_dungeon(self, width: int, height: int) -> None:
43        """Set bounds from dungeon grid dimensions."""
44        self.limit_left = 0.0
45        self.limit_top = 0.0
46        self.limit_right = float(width * TILE_SIZE)
47        self.limit_bottom = float(height * TILE_SIZE)
48
49    @property
50    def is_shaking(self) -> bool:
51        return self._shake_timer > 0 or self._kick_timer > 0
52
53    def directional_shake(self, direction: Vec2, intensity: float = 5.0, duration: float = 0.12):
54        """Shake biased along ``direction`` (e.g. the hit direction).
55
56        A subtle omnidirectional base shake sells the impact, while a kick
57        oscillating along ``direction`` biases the motion that way. Both fade
58        out over ``duration``.
59        """
60        self.shake(intensity=intensity * 0.5, duration=duration)
61        length = float((direction.x**2 + direction.y**2) ** 0.5)
62        self._kick_dir = Vec2(direction) / length if length > 1e-6 else Vec2()
63        self._kick_intensity = max(intensity, 1.0)
64        self._kick_timer = self._kick_duration = duration
65
66    def on_update(self, dt: float):
67        super().on_update(dt)
68        if self._kick_timer > 0.0:
69            self._kick_timer -= dt
70            fade = self._kick_timer / self._kick_duration if self._kick_duration > 0 else 0.0
71            magnitude = self._kick_intensity * fade * random.uniform(-1.0, 1.0)
72            self.offset = self.offset + self._kick_dir * magnitude * self.zoom
73
74    def damage_shake(self, damage: int, max_hp: int, duration: float = 0.15):
75        """Scale shake intensity by damage relative to max HP."""
76        ratio = min(1.0, damage / max(1, max_hp))
77        intensity = 3.0 + ratio * 12.0  # 3-15 range
78        self.shake(intensity=intensity, duration=duration)
79
80    def boss_shake(self, intensity: float = 8.0, duration: float = 0.25):
81        """Stronger shake for boss attacks."""
82        self.shake(intensity=intensity, duration=duration)
83
84    def dodge_shake(self):
85        """Subtle shake on dodge roll start."""
86        self.shake(intensity=1.5, duration=0.06)