"""Camera2D -- 2D camera with smoothing, zoom, bounds, and shake."""
import random
from ..descriptors import Property
from ..math.types import Vec2, clamp
from ..properties import Bitmask, Colour
from .node2d import Node2D
# Default viewport size used for gizmo when no tree/window is available
_DEFAULT_VIEWPORT_W = 1280.0
_DEFAULT_VIEWPORT_H = 720.0
[docs]
class Camera2D(Node2D):
"""2D camera for scrolling games.
Provides target following with smoothing, zoom, viewport bounds/limits,
and screen shake effects. The active Camera2D's offset is applied by the
renderer to all 2D draw calls.
"""
zoom = Property(1.0, range=(0.1, 10.0), hint="Camera zoom level", group="Camera")
smoothing = Property(0.0, range=(0.0, 50.0), hint="Lerp speed (0 = instant)", group="Camera")
limit_left = Property(-1e9, hint="Left edge limit", group="Camera")
limit_right = Property(1e9, hint="Right edge limit", group="Camera")
limit_top = Property(-1e9, hint="Top edge limit", group="Camera")
limit_bottom = Property(1e9, hint="Bottom edge limit", group="Camera")
cull_mask = Bitmask(0xFFFFFFFF, hint="32 layers, all visible by default", group="Camera")
gizmo_colour = Colour((0.6, 0.4, 0.9, 0.7))
[docs]
def set_cull_mask_layer(self, index: int, enabled: bool = True) -> None:
"""Enable or disable a specific cull mask layer (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Cull mask layer index must be 0-31, got {index}")
if enabled:
self.cull_mask = self.cull_mask | (1 << index)
else:
self.cull_mask = self.cull_mask & ~(1 << index)
[docs]
def is_cull_mask_layer_enabled(self, index: int) -> bool:
"""Check if a specific cull mask layer is enabled (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Cull mask layer index must be 0-31, got {index}")
return bool(self.cull_mask & (1 << index))
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.target: Node2D | None = None
self.offset = Vec2()
self._shake_intensity = 0.0
self._shake_duration = 0.0
self._shake_timer = 0.0
self._shake_offset = Vec2() # current-frame jitter, consumed by scene_tree.draw
self._current = Vec2()
[docs]
def ready(self):
if self._tree:
self._tree._current_camera_2d = self
if self.target:
self._current = Vec2(self.target.world_position)
[docs]
def shake(self, intensity: float = 5.0, duration: float = 0.3):
"""Start a screen shake effect."""
self._shake_intensity = intensity
self._shake_duration = duration
self._shake_timer = duration
[docs]
def process(self, dt: float):
target_pos = self.target.world_position if self.target else self.world_position
if self.smoothing > 0 and dt > 0:
t = min(1.0, self.smoothing * dt)
self._current = self._current + (target_pos - self._current) * t
else:
self._current = Vec2(target_pos)
cam_x = clamp(self._current.x, self.limit_left, self.limit_right)
cam_y = clamp(self._current.y, self.limit_top, self.limit_bottom)
self._current = Vec2(cam_x, cam_y)
shake_offset = Vec2()
if self._shake_timer > 0:
self._shake_timer -= dt
fade = self._shake_timer / self._shake_duration if self._shake_duration > 0 else 0
shake_offset = Vec2(
random.uniform(-1, 1) * self._shake_intensity * fade,
random.uniform(-1, 1) * self._shake_intensity * fade,
)
self._shake_offset = shake_offset
self.offset = (self._current * -1 + shake_offset) * self.zoom
[docs]
def world_to_screen(self, world_pos: Vec2, screen_size: Vec2) -> Vec2:
"""Convert a world position to screen coordinates."""
return (world_pos + self.offset) + screen_size * 0.5
[docs]
def screen_to_world(self, screen_pos: Vec2, screen_size: Vec2) -> Vec2:
"""Convert screen coordinates to world position."""
return (screen_pos - screen_size * 0.5 - self.offset) * (1.0 / self.zoom)
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return line segments for viewport bounds rectangle and crosshair at camera position."""
p = self.world_position
z = float(self.zoom) if self.zoom > 0 else 1.0
# Determine viewport size from tree/window or use defaults
vw, vh = _DEFAULT_VIEWPORT_W, _DEFAULT_VIEWPORT_H
if self._tree and hasattr(self._tree, "_window_size"):
ws = self._tree._window_size
if ws:
vw, vh = float(ws[0]), float(ws[1])
hw, hh = (vw / z) * 0.5, (vh / z) * 0.5
# Viewport bounds rectangle
tl, tr = Vec2(p.x - hw, p.y - hh), Vec2(p.x + hw, p.y - hh)
br, bl = Vec2(p.x + hw, p.y + hh), Vec2(p.x - hw, p.y + hh)
lines: list[tuple[Vec2, Vec2]] = [(tl, tr), (tr, br), (br, bl), (bl, tl)]
# Crosshair at camera position
ch = min(hw, hh) * 0.08
lines.append((Vec2(p.x - ch, p.y), Vec2(p.x + ch, p.y)))
lines.append((Vec2(p.x, p.y - ch), Vec2(p.x, p.y + ch)))
return lines