""" Camera system for viewport scrolling. Provides abstraction for camera motion in different modes: - Vertical: traditional upward scroll - Horizontal: left/right movement - Omni: combination of both - Floating: sinusoidal/bobbing motion The camera defines a visible viewport into a larger Canvas. """ import math from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum, auto class CameraMode(Enum): VERTICAL = auto() HORIZONTAL = auto() OMNI = auto() FLOATING = auto() @dataclass class CameraViewport: """Represents the visible viewport.""" x: int y: int width: int height: int @dataclass class Camera: """Camera for viewport scrolling. The camera defines a visible viewport into a Canvas. It can be smaller than the canvas to allow scrolling, and supports zoom to scale the view. Attributes: x: Current horizontal offset (positive = scroll left) y: Current vertical offset (positive = scroll up) mode: Current camera mode speed: Base scroll speed zoom: Zoom factor (1.0 = 100%, 2.0 = 200% zoom out) canvas_width: Width of the canvas being viewed canvas_height: Height of the canvas being viewed custom_update: Optional custom update function """ x: int = 0 y: int = 0 mode: CameraMode = CameraMode.VERTICAL speed: float = 1.0 zoom: float = 1.0 canvas_width: int = 200 # Larger than viewport for scrolling canvas_height: int = 200 custom_update: Callable[["Camera", float], None] | None = None _time: float = field(default=0.0, repr=False) @property def w(self) -> int: """Shorthand for viewport_width.""" return self.viewport_width @property def h(self) -> int: """Shorthand for viewport_height.""" return self.viewport_height @property def viewport_width(self) -> int: """Get the visible viewport width. This is the canvas width divided by zoom. """ return max(1, int(self.canvas_width / self.zoom)) @property def viewport_height(self) -> int: """Get the visible viewport height. This is the canvas height divided by zoom. """ return max(1, int(self.canvas_height / self.zoom)) def get_viewport(self) -> CameraViewport: """Get the current viewport bounds. Returns: CameraViewport with position and size (clamped to canvas bounds) """ vw = self.viewport_width vh = self.viewport_height clamped_x = max(0, min(self.x, self.canvas_width - vw)) clamped_y = max(0, min(self.y, self.canvas_height - vh)) return CameraViewport( x=clamped_x, y=clamped_y, width=vw, height=vh, ) def set_zoom(self, zoom: float) -> None: """Set the zoom factor. Args: zoom: Zoom factor (1.0 = 100%, 2.0 = zoomed out 2x, 0.5 = zoomed in 2x) """ self.zoom = max(0.1, min(10.0, zoom)) def update(self, dt: float) -> None: """Update camera position based on mode. Args: dt: Delta time in seconds """ self._time += dt if self.custom_update: self.custom_update(self, dt) return if self.mode == CameraMode.VERTICAL: self._update_vertical(dt) elif self.mode == CameraMode.HORIZONTAL: self._update_horizontal(dt) elif self.mode == CameraMode.OMNI: self._update_omni(dt) elif self.mode == CameraMode.FLOATING: self._update_floating(dt) self._clamp_to_bounds() def _clamp_to_bounds(self) -> None: """Clamp camera position to stay within canvas bounds. Only clamps if the viewport is smaller than the canvas. If viewport equals canvas (no scrolling needed), allows any position for backwards compatibility with original behavior. """ vw = self.viewport_width vh = self.viewport_height # Only clamp if there's room to scroll if vw < self.canvas_width: self.x = max(0, min(self.x, self.canvas_width - vw)) if vh < self.canvas_height: self.y = max(0, min(self.y, self.canvas_height - vh)) def _update_vertical(self, dt: float) -> None: self.y += int(self.speed * dt * 60) def _update_horizontal(self, dt: float) -> None: self.x += int(self.speed * dt * 60) def _update_omni(self, dt: float) -> None: speed = self.speed * dt * 60 self.y += int(speed) self.x += int(speed * 0.5) def _update_floating(self, dt: float) -> None: base = self.speed * 30 self.y = int(math.sin(self._time * 2) * base) self.x = int(math.cos(self._time * 1.5) * base * 0.5) def reset(self) -> None: """Reset camera position.""" self.x = 0 self.y = 0 self._time = 0.0 self.zoom = 1.0 def set_canvas_size(self, width: int, height: int) -> None: """Set the canvas size and clamp position if needed. Args: width: New canvas width height: New canvas height """ self.canvas_width = width self.canvas_height = height self._clamp_to_bounds() @classmethod def vertical(cls, speed: float = 1.0) -> "Camera": """Create a vertical scrolling camera.""" return cls(mode=CameraMode.VERTICAL, speed=speed, canvas_height=200) @classmethod def horizontal(cls, speed: float = 1.0) -> "Camera": """Create a horizontal scrolling camera.""" return cls(mode=CameraMode.HORIZONTAL, speed=speed, canvas_width=200) @classmethod def omni(cls, speed: float = 1.0) -> "Camera": """Create an omnidirectional scrolling camera.""" return cls( mode=CameraMode.OMNI, speed=speed, canvas_width=200, canvas_height=200 ) @classmethod def floating(cls, speed: float = 1.0) -> "Camera": """Create a floating/bobbing camera.""" return cls( mode=CameraMode.FLOATING, speed=speed, canvas_width=200, canvas_height=200 ) @classmethod def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera": """Create a camera with custom update function.""" return cls(custom_update=update_fn)