Spaces:
Sleeping
Sleeping
| """ | |
| Module for sprite-related classes and functionality in the ARCEngine. | |
| """ | |
| import uuid | |
| from typing import List, Optional | |
| import numpy as np | |
| from numpy import ndarray | |
| from .enums import BlockingMode, InteractionMode | |
| def _downscale_mode(arr: np.ndarray, factor: int) -> np.ndarray: | |
| """ | |
| Nearest-neighbor style down-scaling for palette images. | |
| For each non-overlapping block it keeps the dominant color | |
| (mode), breaking ties by the highest palette index. | |
| Transparent pixels (-1) are ignored, but if all pixels are | |
| transparent, the block remains transparent. | |
| Parameters | |
| ---------- | |
| arr : 2-D np.ndarray | |
| Input image of dtype int8 / uint8 holding palette indices. | |
| Transparency is denoted by -1. | |
| factor : int | |
| The integer scale factor (e.g. 2 turns 64×64 → 32×32). | |
| Returns | |
| ------- | |
| np.ndarray | |
| The down-scaled image, same dtype as the input. | |
| """ | |
| H, W = arr.shape | |
| if H % factor != 0 or W % factor != 0: | |
| raise ValueError(f"Array dimensions ({H}, {W}) must be divisible by scale factor {factor}") | |
| # Step 1: split into blocks → shape (out_h, out_w, factor, factor) | |
| blocks = arr.reshape(H // factor, factor, -1, factor).swapaxes(1, 2) | |
| blocks = blocks.reshape(-1, factor * factor) | |
| # Step 2: find dominant color for each block | |
| out = np.empty(len(blocks), dtype=arr.dtype) | |
| for i, blk in enumerate(blocks): | |
| non_transparent = blk[blk != -1] | |
| transparent = blk[blk < 0] | |
| # Check to see if this is majority transparent | |
| if transparent.size > non_transparent.size: | |
| out[i] = -1 # if all values are -1, keep block transparent | |
| else: | |
| cnts = np.bincount(non_transparent.astype(np.int16)) | |
| max_count = cnts.max() | |
| max_indices = np.where(cnts == max_count)[0] | |
| out[i] = max_indices[-1] # break ties by highest index | |
| # Step 3: reshape to 2-D image | |
| return out.reshape(H // factor, W // factor) | |
| def _interaction_mode_from(visible: bool, collidable: bool) -> InteractionMode: | |
| if visible and collidable: | |
| return InteractionMode.TANGIBLE | |
| elif visible and not collidable: | |
| return InteractionMode.INTANGIBLE | |
| elif not visible and collidable: | |
| return InteractionMode.INVISIBLE | |
| else: | |
| return InteractionMode.REMOVED | |
| class Sprite: | |
| """A 2D sprite that can be positioned and scaled in the game world.""" | |
| # Valid rotation values in degrees (clockwise) | |
| VALID_ROTATIONS = {0, 90, 180, 270} | |
| pixels: ndarray | |
| _name: str | |
| _x: int | |
| _y: int | |
| _layer: int | |
| _rotation: int | |
| _mirror_ud: bool | |
| _mirror_lr: bool | |
| _scale: int # Use set_scale to validate scale factor | |
| _blocking: BlockingMode | |
| _interaction: InteractionMode | |
| _tags: list[str] | |
| def __init__( | |
| self, | |
| pixels: List[List[int]] | np.ndarray, | |
| name: Optional[str] = None, | |
| x: int = 0, | |
| y: int = 0, | |
| layer: int = 0, | |
| scale: int = 1, | |
| rotation: int = 0, | |
| mirror_ud: bool = False, | |
| mirror_lr: bool = False, | |
| blocking: BlockingMode = BlockingMode.PIXEL_PERFECT, | |
| interaction: InteractionMode | None = None, | |
| visible: bool = True, | |
| collidable: bool = True, | |
| tags: list[str] = [], | |
| ): | |
| """Initialize a new Sprite. | |
| Args: | |
| pixels: 2D list representing the sprite's pixels | |
| name: Sprite name (default: None, will generate UUID) | |
| x: X coordinate in pixels (default: 0) | |
| y: Y coordinate in pixels (default: 0) | |
| layer: Z-order layer for rendering (default: 0, higher values render on top) | |
| scale: Scale factor (default: 1) | |
| rotation: Rotation in degrees (default: 0) | |
| blocking: Collision detection method (default: NOT_BLOCKED) | |
| interaction: How the sprite interacts with the game world (default: TANGIBLE) | |
| Raises: | |
| ValueError: If scale is 0, pixels is not a 2D list, rotation is invalid, | |
| or if downscaling factor doesn't evenly divide sprite dimensions | |
| """ | |
| if isinstance(pixels, np.ndarray): | |
| if pixels.ndim != 2: | |
| raise ValueError("Pixels must be a 2D array 111") | |
| if pixels.dtype != np.int8: | |
| base = pixels.astype(np.int8, copy=False) | |
| else: | |
| base = pixels | |
| else: | |
| if not isinstance(pixels, list) or not all(isinstance(row, list) for row in pixels): | |
| raise ValueError("Pixels must be a 2D list or a 2D numpy array") | |
| base = np.array(pixels, dtype=np.int8) | |
| self.pixels = base | |
| if self.pixels.ndim != 2: | |
| raise ValueError("Pixels must be a 2D array 222") | |
| self._name = name if name is not None else str(uuid.uuid4()) | |
| self._x = int(x) | |
| self._y = int(y) | |
| self._layer = int(layer) | |
| self._set_rotation(rotation) | |
| self._mirror_ud = mirror_ud | |
| self._mirror_lr = mirror_lr | |
| self._blocking = blocking | |
| self.set_scale(scale) # Use set_scale to validate scale factor | |
| if interaction is None: | |
| self._interaction = _interaction_mode_from(visible, collidable) | |
| else: | |
| self._interaction = interaction | |
| self._tags = tags | |
| def clone(self, new_name: Optional[str] = None) -> "Sprite": | |
| """Create an independent copy of this sprite. | |
| Args: | |
| new_name: Optional name for the cloned sprite. If None, reuses current name. | |
| Returns: | |
| A new Sprite instance with the same properties but independent state. | |
| """ | |
| # Create a deep copy of the pixels array | |
| pixels_copy = self.pixels.copy() | |
| # Create a new sprite with copied properties | |
| return Sprite( | |
| pixels=pixels_copy.tolist(), # Convert back to list for constructor | |
| name=new_name if new_name is not None else self._name, # Use new name or generate new UUID | |
| x=self._x, | |
| y=self._y, | |
| scale=self._scale, | |
| rotation=self.rotation, # Use the public property to get normalized value | |
| mirror_ud=self._mirror_ud, | |
| mirror_lr=self._mirror_lr, | |
| blocking=self._blocking, | |
| layer=self._layer, | |
| interaction=self._interaction, | |
| tags=self._tags.copy(), # Copy the tags list | |
| ) | |
| def _set_rotation(self, rotation: int) -> None: | |
| """Internal method to set rotation with validation. | |
| Args: | |
| rotation: The rotation value in degrees | |
| Raises: | |
| ValueError: If rotation is not a valid 90-degree increment | |
| """ | |
| normalized = rotation % 360 | |
| if normalized not in self.VALID_ROTATIONS: | |
| raise ValueError(f"Rotation must be one of {self.VALID_ROTATIONS}, got {rotation}") | |
| self.rotation = normalized | |
| def set_rotation(self, rotation: int) -> "Sprite": | |
| """Set the sprite's rotation to a specific value. | |
| Args: | |
| rotation: The new rotation in degrees (must be 0, 90, 180, or 270) | |
| Raises: | |
| ValueError: If rotation is not a valid 90-degree increment | |
| """ | |
| self._set_rotation(int(rotation)) | |
| return self | |
| def rotate(self, delta: int) -> "Sprite": | |
| """Rotate the sprite by a given amount. | |
| Args: | |
| delta: The change in rotation in degrees (must result in a valid rotation) | |
| Raises: | |
| ValueError: If resulting rotation is not a valid 90-degree increment | |
| """ | |
| if delta < 0: | |
| delta = 360 + (delta % 360) | |
| new_rotation = (self.rotation + delta) % 360 | |
| self._set_rotation(new_rotation) | |
| return self | |
| def set_position(self, x: int, y: int) -> "Sprite": | |
| """Set the sprite's position. | |
| Args: | |
| x: New X coordinate in pixels | |
| y: New Y coordinate in pixels | |
| """ | |
| self._x = int(x) | |
| self._y = int(y) | |
| return self | |
| def set_scale(self, scale: int) -> "Sprite": | |
| """Set the sprite's scale factor. | |
| Args: | |
| scale: The new scale factor. Positive values scale up, negative values scale down. | |
| Negative values indicate divisor: -1 means half size (divide by 2), -2 means one-third size, etc. | |
| Raises: | |
| ValueError: If scale is 0 or if downscaling factor doesn't evenly divide sprite dimensions | |
| """ | |
| scale_int = int(scale) | |
| if scale_int == 0: | |
| raise ValueError("Scale cannot be zero") | |
| # For downscaling, validate dimensions are divisible by scale factor | |
| if scale_int < 0: | |
| H, W = self.pixels.shape | |
| factor = -scale_int + 1 # -1 -> 2, -2 -> 3, -3 -> 4, etc. | |
| if H % factor != 0 or W % factor != 0: | |
| raise ValueError(f"Array dimensions ({H}, {W}) must be divisible by scale factor {factor}") | |
| self._scale = scale_int | |
| return self | |
| def adjust_scale(self, delta: int) -> None: | |
| """Adjust the sprite's scale by a delta value, moving one step at a time. | |
| The method will adjust the scale by incrementing or decrementing by 1 | |
| repeatedly until reaching the target scale. This ensures smooth transitions | |
| and validates each step. | |
| Negative scales indicate downscaling factors: | |
| -1 = half size (1/2) | |
| -2 = one-third size (1/3) | |
| -3 = one-fourth size (1/4) | |
| etc. | |
| For example: | |
| - Current scale 1, delta +2 -> Steps through: 1 -> 2 -> 3 | |
| - Current scale 1, delta -2 -> Steps through: 1 -> 0 -> -1 (half size) | |
| - Current scale -2, delta +3 -> Steps through: -2 -> -1 -> 0 -> 1 | |
| Args: | |
| delta: The total change in scale to apply. Positive values increase scale, | |
| negative values decrease it. | |
| Raises: | |
| ValueError: If any intermediate scale would be 0 or if a downscaling factor | |
| doesn't evenly divide sprite dimensions | |
| """ | |
| if delta == 0: | |
| return | |
| # Determine direction of change | |
| step = 1 if delta > 0 else -1 | |
| target_scale = self._scale + delta | |
| # Take steps one at a time | |
| while self._scale != target_scale: | |
| next_scale = self._scale + step | |
| # Skip over zero since it's invalid | |
| if next_scale == 0: | |
| next_scale = step | |
| # Let ValueError propagate up | |
| self.set_scale(next_scale) | |
| def set_blocking(self, blocking: BlockingMode) -> "Sprite": | |
| """Set the sprite's blocking behavior. | |
| Args: | |
| blocking: The new blocking behavior | |
| """ | |
| if not isinstance(blocking, BlockingMode): | |
| raise ValueError("blocking must be a BlockingMode enum value") | |
| self._blocking = blocking | |
| return self | |
| def set_name(self, name: str) -> "Sprite": | |
| """Set the sprite's name. | |
| Args: | |
| name: New name for the sprite | |
| """ | |
| if not name: | |
| raise ValueError("Name cannot be empty") | |
| self._name = name | |
| return self | |
| def name(self) -> str: | |
| """Get the sprite's name.""" | |
| return self._name | |
| def x(self) -> int: | |
| """Get the current X coordinate.""" | |
| return self._x | |
| def y(self) -> int: | |
| """Get the current Y coordinate.""" | |
| return self._y | |
| def scale(self) -> int: | |
| """Get the current scale factor.""" | |
| return self._scale | |
| def blocking(self) -> BlockingMode: | |
| """Get the current blocking behavior.""" | |
| return self._blocking | |
| def layer(self) -> int: | |
| """Get the current rendering layer.""" | |
| return self._layer | |
| def tags(self) -> list[str]: | |
| """Get the current tags.""" | |
| return self._tags | |
| def mirror_ud(self) -> bool: | |
| """Get the current mirror up/down state.""" | |
| return self._mirror_ud | |
| def mirror_lr(self) -> bool: | |
| """Get the current mirror left/right state.""" | |
| return self._mirror_lr | |
| def set_mirror_ud(self, mirror_ud: bool) -> "Sprite": | |
| """Set the sprite's mirror up/down state.""" | |
| self._mirror_ud = mirror_ud | |
| return self | |
| def set_mirror_lr(self, mirror_lr: bool) -> "Sprite": | |
| """Set the sprite's mirror left/right state.""" | |
| self._mirror_lr = mirror_lr | |
| return self | |
| def set_layer(self, layer: int) -> "Sprite": | |
| """Set the sprite's rendering layer. | |
| Args: | |
| layer: New layer value. Higher values render on top. | |
| """ | |
| self._layer = int(layer) | |
| return self | |
| def interaction(self) -> InteractionMode: | |
| """Get the current interaction mode.""" | |
| return self._interaction | |
| def set_interaction(self, interaction: InteractionMode) -> "Sprite": | |
| """Set the sprite's interaction mode. | |
| Args: | |
| interaction: The new interaction mode | |
| Raises: | |
| ValueError: If interaction is not an InteractionMode enum value | |
| """ | |
| if not isinstance(interaction, InteractionMode): | |
| raise ValueError("interaction must be an InteractionMode enum value") | |
| self._interaction = interaction | |
| return self | |
| def is_visible(self) -> bool: | |
| """Check if a sprite with this interaction mode should be rendered. | |
| Returns: | |
| bool: True if the sprite should be visible, False otherwise | |
| """ | |
| return self._interaction == InteractionMode.TANGIBLE or self._interaction == InteractionMode.INTANGIBLE | |
| def set_visible(self, visible: bool) -> "Sprite": | |
| """Set the sprite's visibility. | |
| Args: | |
| visible: The new visibility state | |
| """ | |
| self._interaction = _interaction_mode_from(visible, self.is_collidable) | |
| return self | |
| def width(self) -> int: | |
| """Get the sprite's width.""" | |
| return int(self.render().shape[1]) | |
| def height(self) -> int: | |
| """Get the sprite's height.""" | |
| return int(self.render().shape[0]) | |
| def is_collidable(self) -> bool: | |
| """Check if a sprite with this interaction mode should participate in collisions. | |
| Returns: | |
| bool: True if the sprite should be checked for collisions, False otherwise | |
| """ | |
| return self._interaction == InteractionMode.TANGIBLE or self._interaction == InteractionMode.INVISIBLE | |
| def set_collidable(self, collidable: bool) -> "Sprite": | |
| """Set the sprite's collidable state. | |
| Args: | |
| collidable: The new collidable state | |
| """ | |
| self._interaction = _interaction_mode_from(self.is_visible, collidable) | |
| return self | |
| def render(self) -> np.ndarray: | |
| """Render the sprite with current scale and rotation. | |
| Returns: | |
| np.ndarray: The rendered sprite as a 2D numpy array | |
| """ | |
| # Start with the base pixels | |
| result = self.pixels.copy() | |
| # Handle rotation first (if any) | |
| if self.rotation != 0: | |
| # Convert degrees to number of 90-degree rotations (clockwise) | |
| k = int((-self.rotation % 360) / 90) # Negative for clockwise rotation | |
| if k != 0: | |
| result = np.rot90(result, k=k) | |
| if self._mirror_ud: | |
| result = np.flipud(result) | |
| if self._mirror_lr: | |
| result = np.fliplr(result) | |
| # Handle scaling | |
| if self._scale != 1: | |
| if self._scale > 1: | |
| # For upscaling, repeat the array in both dimensions | |
| result = np.repeat(np.repeat(result, self._scale, axis=0), self._scale, axis=1) | |
| else: # self._scale < 0 | |
| # For downscaling, use mode-based approach | |
| # Convert negative scale to actual divisor (e.g. -1 -> 2, -2 -> 3) | |
| factor = -self._scale + 1 # -1 -> 2, -2 -> 3, -3 -> 4, etc. | |
| result = _downscale_mode(result, factor) | |
| return result | |
| def collides_with(self, other: "Sprite", ignoreMode: bool = False) -> bool: | |
| """Check if this sprite collides with another sprite. | |
| The collision check follows these rules: | |
| 1. A sprite cannot collide with itself | |
| 2. Non-collidable sprites (based on interaction mode) never collide | |
| 3. For collidable sprites, the collision detection method is based on their blocking mode: | |
| - NOT_BLOCKED: Always returns False | |
| - BOUNDING_BOX: Simple rectangular collision check | |
| - PIXEL_PERFECT: Precise pixel-level collision detection | |
| Args: | |
| other: The other sprite to check collision with | |
| Returns: | |
| bool: True if the sprites collide, False otherwise | |
| """ | |
| # Rule 1: A sprite cannot collide with itself | |
| if self is other: | |
| return False | |
| if not ignoreMode: | |
| # Rule 2: Both sprites must be collidable | |
| if not (self.is_collidable and other.is_collidable): | |
| return False | |
| # Rule 3: Handle different blocking modes | |
| if self._blocking == BlockingMode.NOT_BLOCKED or other._blocking == BlockingMode.NOT_BLOCKED: | |
| return False | |
| # Get sprite dimensions after rendering (accounts for rotation and scaling) | |
| self_pixels = self.render() | |
| other_pixels = other.render() | |
| self_height, self_width = self_pixels.shape | |
| other_height, other_width = other_pixels.shape | |
| # First check bounding box collision | |
| # If there's no bounding box collision, there can't be pixel collision | |
| if self._x >= other._x + other_width or self._x + self_width <= other._x or self._y >= other._y + other_height or self._y + self_height <= other._y: | |
| return False | |
| # If either sprite uses PIXEL_PERFECT, do pixel-level collision detection | |
| if self._blocking == BlockingMode.PIXEL_PERFECT or other._blocking == BlockingMode.PIXEL_PERFECT: | |
| # Calculate intersection region | |
| x_min = max(self._x, other._x) | |
| x_max = min(self._x + self_width, other._x + other_width) | |
| y_min = max(self._y, other._y) | |
| y_max = min(self._y + self_height, other._y + other_height) | |
| # Get the overlapping regions from both sprites | |
| self_x_start = x_min - self._x | |
| self_x_end = x_max - self._x | |
| self_y_start = y_min - self._y | |
| self_y_end = y_max - self._y | |
| other_x_start = x_min - other._x | |
| other_x_end = x_max - other._x | |
| other_y_start = y_min - other._y | |
| other_y_end = y_max - other._y | |
| # Extract overlapping regions | |
| self_region = self_pixels[self_y_start:self_y_end, self_x_start:self_x_end] | |
| other_region = other_pixels[other_y_start:other_y_end, other_x_start:other_x_end] | |
| # Check if any non-transparent pixels overlap | |
| self_mask = self_region != -1 | |
| other_mask = other_region != -1 | |
| return bool(np.any(self_mask & other_mask)) | |
| # Otherwise, we already know there's a bounding box collision | |
| return True | |
| def move(self, dx: int, dy: int) -> None: | |
| """Move the sprite by the given deltas. | |
| Args: | |
| dx: Change in x position (positive = right, negative = left) | |
| dy: Change in y position (positive = down, negative = up) | |
| """ | |
| self._x += int(dx) | |
| self._y += int(dy) | |
| def color_remap(self, old_color: int | None, new_color: int) -> "Sprite": | |
| """Remap the sprite's color. | |
| Args: | |
| old_color: The old color to remap, or None to remap all colors | |
| new_color: The new color to remap to | |
| """ | |
| if old_color is None: | |
| # Replace all non-negative pixels with new_color | |
| self.pixels = np.where(self.pixels >= 0, new_color, self.pixels) | |
| else: | |
| # Replace only pixels matching old_color | |
| self.pixels = np.where(self.pixels == old_color, new_color, self.pixels) | |
| return self | |
| def merge(self, other: "Sprite") -> "Sprite": | |
| """Merge two sprites together. | |
| This method creates a new sprite that combines the pixels of both sprites. | |
| When pixels overlap, non-negative pixels take precedence over negative ones. | |
| Args: | |
| other: The other sprite to merge with | |
| Returns: | |
| Sprite: A new sprite containing the merged pixels | |
| """ | |
| # Get rendered versions of both sprites to handle scaling/rotation | |
| self_pixels = self.render() | |
| other_pixels = other.render() | |
| # Calculate the bounds of the merged sprite | |
| min_x = min(self._x, other._x) | |
| min_y = min(self._y, other._y) | |
| max_x = max(self._x + self_pixels.shape[1], other._x + other_pixels.shape[1]) | |
| max_y = max(self._y + self_pixels.shape[0], other._y + other_pixels.shape[0]) | |
| # Create a new array for the merged sprite | |
| merged_height = max_y - min_y | |
| merged_width = max_x - min_x | |
| merged_pixels = np.full((merged_height, merged_width), -1, dtype=np.int8) | |
| # Copy other's pixels, keeping non-negative pixels | |
| other_y_start = other._y - min_y | |
| other_x_start = other._x - min_x | |
| other_region = merged_pixels[other_y_start : other_y_start + other_pixels.shape[0], other_x_start : other_x_start + other_pixels.shape[1]] | |
| merged_pixels[other_y_start : other_y_start + other_pixels.shape[0], other_x_start : other_x_start + other_pixels.shape[1]] = np.where(other_pixels != -1, other_pixels, other_region) | |
| # Copy self's pixels | |
| self_y_start = self._y - min_y | |
| self_x_start = self._x - min_x | |
| merged_pixels[self_y_start : self_y_start + self_pixels.shape[0], self_x_start : self_x_start + self_pixels.shape[1]] = np.where( | |
| self_pixels != -1, self_pixels, merged_pixels[self_y_start : self_y_start + self_pixels.shape[0], self_x_start : self_x_start + self_pixels.shape[1]] | |
| ) | |
| blocking = self._blocking | |
| if blocking == BlockingMode.NOT_BLOCKED: | |
| blocking = other._blocking | |
| elif blocking == BlockingMode.BOUNDING_BOX and other._blocking == BlockingMode.PIXEL_PERFECT: | |
| blocking = BlockingMode.PIXEL_PERFECT | |
| interaction = self._interaction | |
| if interaction == InteractionMode.REMOVED: | |
| interaction = other._interaction | |
| elif interaction == InteractionMode.INVISIBLE and other._interaction == InteractionMode.TANGIBLE: | |
| interaction = InteractionMode.TANGIBLE | |
| elif interaction == InteractionMode.INTANGIBLE and other._interaction == InteractionMode.TANGIBLE: | |
| interaction = InteractionMode.TANGIBLE | |
| # Create and return new sprite | |
| return Sprite( | |
| name=self._name, | |
| pixels=merged_pixels, | |
| x=min_x, | |
| y=min_y, | |
| layer=max(self._layer, other._layer), # Use higher layer | |
| blocking=blocking, | |
| interaction=interaction, | |
| tags=list(set(self._tags + other._tags)), # Combine unique tags | |
| ) | |