Source code for simvx.core.nodes_2d.camera

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