"""Camera2D -- 2D camera with smoothing, zoom, bounds, and shake."""
from __future__ import annotations
import random
from ..descriptors import Property
from ..math.types import Vec2, clamp
from .node2d import Node2D
[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 = Property(
0xFFFFFFFF, range=(0, 0xFFFFFFFF), hint="Cull mask bitmask (32 layers, all visible by default)",
group="Camera",
)
[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._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.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)