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)