Spaces:
Sleeping
Sleeping
| """ | |
| Module for camera-related functionality in the ARCEngine. | |
| """ | |
| from typing import List, Tuple | |
| import numpy as np | |
| from .interfaces import RenderableUserDisplay | |
| from .sprites import Sprite | |
| class Camera: | |
| """A camera that defines the viewport into the game world.""" | |
| # Maximum allowed dimensions | |
| MAX_DIMENSION = 64 | |
| _x: int | |
| _y: int | |
| _width: int | |
| _height: int | |
| _background: int | |
| _letter_box: int | |
| _interfaces: list[RenderableUserDisplay] | |
| def __init__( | |
| self, | |
| x: int = 0, | |
| y: int = 0, | |
| width: int = 64, | |
| height: int = 64, | |
| background: int = 5, | |
| letter_box: int = 5, | |
| interfaces: list[RenderableUserDisplay] = [], | |
| ): | |
| """Initialize a new Camera. | |
| Args: | |
| x: X coordinate in pixels (default: 0) | |
| y: Y coordinate in pixels (default: 0) | |
| width: Viewport width in pixels (default: 64, max: 64) | |
| height: Viewport height in pixels (default: 64, max: 64) | |
| background: Background color index (default: 5 - Black) | |
| letter_box: Letter box color index (default: 5 - Black) | |
| interfaces: Optional list of renderable interfaces to initialize with | |
| Raises: | |
| ValueError: If width or height exceed 64 pixels | |
| """ | |
| if width > 64 or height > 64 or width < 0 or height < 0: | |
| raise ValueError("Camera dimensions cannot exceed 64x64 pixels and must be positive") | |
| self._x = x | |
| self._y = y | |
| self.width = width | |
| self.height = height | |
| self._background = background | |
| self._letter_box = letter_box | |
| self._interfaces: List[RenderableUserDisplay] = [] | |
| if interfaces: | |
| for interface in interfaces: | |
| self._interfaces.append(interface) | |
| def x(self) -> int: | |
| """Get the camera's x position. | |
| Returns: | |
| int: The camera's x position | |
| """ | |
| return self._x | |
| def x(self, value: int) -> None: | |
| """Set the camera's x position. | |
| Args: | |
| value: The new x position | |
| """ | |
| self._x = int(value) | |
| def y(self) -> int: | |
| """Get the camera's y position. | |
| Returns: | |
| int: The camera's y position | |
| """ | |
| return self._y | |
| def y(self, value: int) -> None: | |
| """Set the camera's y position. | |
| Args: | |
| value: The new y position | |
| """ | |
| self._y = int(value) | |
| def width(self) -> int: | |
| """Get the camera's width. | |
| Returns: | |
| int: The camera's width | |
| """ | |
| return self._width | |
| def width(self, value: int) -> None: | |
| """Set the camera's width. | |
| Args: | |
| value: The new width | |
| Raises: | |
| ValueError: If width exceeds MAX_DIMENSION | |
| """ | |
| width_int = int(value) | |
| if width_int > self.MAX_DIMENSION: | |
| raise ValueError(f"Width cannot exceed {self.MAX_DIMENSION} pixels") | |
| self._width = width_int | |
| def height(self) -> int: | |
| """Get the camera's height. | |
| Returns: | |
| int: The camera's height | |
| """ | |
| return self._height | |
| def height(self, value: int) -> None: | |
| """Set the camera's height. | |
| Args: | |
| value: The new height | |
| Raises: | |
| ValueError: If height exceeds MAX_DIMENSION | |
| """ | |
| height_int = int(value) | |
| if height_int > self.MAX_DIMENSION: | |
| raise ValueError(f"Height cannot exceed {self.MAX_DIMENSION} pixels") | |
| self._height = height_int | |
| def background(self) -> int: | |
| """Get the camera's background color.""" | |
| return self._background | |
| def background(self, value: int) -> None: | |
| """Set the camera's background color.""" | |
| self._background = value | |
| def letter_box(self) -> int: | |
| """Get the camera's letter box color.""" | |
| return self._letter_box | |
| def letter_box(self, value: int) -> None: | |
| """Set the camera's letter box color.""" | |
| self._letter_box = value | |
| def resize(self, width: int, height: int) -> None: | |
| """Resize the camera. | |
| Args: | |
| width: The new width | |
| height: The new height | |
| """ | |
| self.width = width | |
| self.height = height | |
| def move(self, dx: int, dy: int) -> None: | |
| """Move the camera by the specified delta. | |
| Args: | |
| dx: The change in x position | |
| dy: The change in y position | |
| """ | |
| self._x += int(dx) | |
| self._y += int(dy) | |
| def _calculate_scale_and_offset(self) -> Tuple[int, int, int]: | |
| """Calculate the scale factor and offsets for letterboxing. | |
| Returns: | |
| Tuple[int, int, int]: (scale, x_offset, y_offset) | |
| - scale: The uniform scale factor to fit viewport in 64x64 | |
| - x_offset: Horizontal offset for centering | |
| - y_offset: Vertical offset for centering | |
| """ | |
| # Calculate maximum possible scale that fits in MAX_DIMENSION | |
| scale_x = self.MAX_DIMENSION // self._width | |
| scale_y = self.MAX_DIMENSION // self._height | |
| scale = min(scale_x, scale_y) | |
| # Calculate scaled dimensions | |
| scaled_width = self._width * scale | |
| scaled_height = self._height * scale | |
| # Only use offsets if we can't scale up to fill the screen | |
| x_offset = (self.MAX_DIMENSION - scaled_width) // 2 | |
| y_offset = (self.MAX_DIMENSION - scaled_height) // 2 | |
| return scale, x_offset, y_offset | |
| def _raw_render(self, sprites: List[Sprite]) -> np.ndarray: | |
| """Internal method to render the camera view. | |
| Args: | |
| sprites: List of sprites to render. Sprites are rendered in order of their layer | |
| value (lower layers first). Negative pixel values are treated as transparent. | |
| Only visible sprites (based on their interaction mode) will be rendered. | |
| Returns: | |
| np.ndarray: The rendered view as a 2D numpy array | |
| """ | |
| # Create background array filled with background color | |
| output = np.full((self._height, self._width), self._background, dtype=np.int8) | |
| if not sprites: | |
| return output | |
| # Sort sprites by layer (lower layers first) and filter out non-visible sprites | |
| sorted_sprites = sorted((s for s in sprites if s.is_visible), key=lambda s: s.layer) | |
| for sprite in sorted_sprites: | |
| # Get the sprite's rendered pixels (handles rotation and scaling) | |
| sprite_pixels = sprite.render() | |
| sprite_height, sprite_width = sprite_pixels.shape | |
| # Calculate sprite position relative to camera | |
| rel_x = sprite.x - self._x | |
| rel_y = sprite.y - self._y | |
| # Calculate the intersection with viewport | |
| dest_x_start = max(0, rel_x) | |
| dest_x_end = min(self._width, rel_x + sprite_width) | |
| dest_y_start = max(0, rel_y) | |
| dest_y_end = min(self._height, rel_y + sprite_height) | |
| # Skip if sprite is completely outside viewport | |
| if dest_x_end <= dest_x_start or dest_y_end <= dest_y_start: | |
| continue | |
| # Calculate source region from sprite | |
| sprite_x_start = max(0, -rel_x) | |
| sprite_x_end = sprite_width - max(0, (rel_x + sprite_width) - self._width) | |
| sprite_y_start = max(0, -rel_y) | |
| sprite_y_end = sprite_height - max(0, (rel_y + sprite_height) - self._height) | |
| # Get the sprite region we're going to copy | |
| sprite_region = sprite_pixels[sprite_y_start:sprite_y_end, sprite_x_start:sprite_x_end] | |
| # Create a mask for non-negative (visible) pixels | |
| visible_mask = sprite_region >= 0 | |
| # Update only the non-transparent pixels | |
| output[dest_y_start:dest_y_end, dest_x_start:dest_x_end][visible_mask] = sprite_region[visible_mask] | |
| return output | |
| def render(self, sprites: List[Sprite]) -> np.ndarray: | |
| """Render the camera view. | |
| The rendered output is always 64x64 pixels. If the camera's viewport is smaller, | |
| the view will be scaled up uniformly (maintaining aspect ratio) to fit within | |
| 64x64, and the remaining space will be filled with the letter_box color. | |
| Args: | |
| sprites: List of sprites to render (currently unused) | |
| Returns: | |
| np.ndarray: The rendered view as a 64x64 numpy array | |
| """ | |
| # Start with a letter-boxed canvas | |
| output = np.full((self.MAX_DIMENSION, self.MAX_DIMENSION), self._letter_box, dtype=np.int8) | |
| # Get the raw camera view | |
| view = self._raw_render(sprites) | |
| # Calculate scaling and offsets | |
| scale, x_offset, y_offset = self._calculate_scale_and_offset() | |
| # Scale up the view using numpy's repeat | |
| if scale > 1: | |
| view = np.repeat(np.repeat(view, scale, axis=0), scale, axis=1) | |
| # Insert the scaled view into the letter-boxed output | |
| output[y_offset : y_offset + view.shape[0], x_offset : x_offset + view.shape[1]] = view | |
| for interface in self._interfaces: | |
| output = interface.render_interface(output) | |
| return output | |
| def replace_interface(self, new_interfaces: list[RenderableUserDisplay]) -> None: | |
| """Replace the current interfaces with new ones. | |
| This method replaces all current interfaces with the provided ones. Each interface | |
| in the new list will be cloned to prevent external modification. | |
| Args: | |
| new_interfaces: List of new interfaces to use. These should be cloned before passing them in. | |
| """ | |
| self._interfaces = [] | |
| if new_interfaces: | |
| for interface in new_interfaces: | |
| self._interfaces.append(interface) | |
| def display_to_grid(self, display_x: int, display_y: int) -> tuple[int, int] | None: | |
| """Convert display coordinates (64x64) to camera grid coordinates. | |
| This takes into account: | |
| - Camera scaling max(64/camera_width, 64/camera_height) | |
| - Letterbox padding | |
| - Grid boundaries | |
| Args: | |
| display_x: X coordinate in display space (0-63) | |
| display_y: Y coordinate in display space (0-63) | |
| Returns: | |
| tuple[int, int] | None: The corresponding grid coordinates (x, y) or None if the display coordinates are within the letterbox | |
| """ | |
| # Calculate scaling factor | |
| scale_x = int(64 / self.width) | |
| scale_y = int(64 / self.height) | |
| scale = min(scale_x, scale_y) | |
| # Calculate letterbox padding | |
| x_padding = int((64 - (self.width * scale)) / 2) | |
| y_padding = int((64 - (self.height * scale)) / 2) | |
| # Remove padding and scale down | |
| grid_x = int((display_x - x_padding) / scale) if display_x - x_padding >= 0 else -1 | |
| grid_y = int((display_y - y_padding) / scale) if display_y - y_padding >= 0 else -1 | |
| if grid_x < 0 or grid_y < 0 or grid_x >= self.width or grid_y >= self.height: | |
| # Given X, Y is off the camera screen | |
| return None | |
| return grid_x + self.x, grid_y + self.y | |