""" 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 @property def name(self) -> str: """Get the sprite's name.""" return self._name @property def x(self) -> int: """Get the current X coordinate.""" return self._x @property def y(self) -> int: """Get the current Y coordinate.""" return self._y @property def scale(self) -> int: """Get the current scale factor.""" return self._scale @property def blocking(self) -> BlockingMode: """Get the current blocking behavior.""" return self._blocking @property def layer(self) -> int: """Get the current rendering layer.""" return self._layer @property def tags(self) -> list[str]: """Get the current tags.""" return self._tags @property def mirror_ud(self) -> bool: """Get the current mirror up/down state.""" return self._mirror_ud @property 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 @property 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 @property 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 @property def width(self) -> int: """Get the sprite's width.""" return int(self.render().shape[1]) @property def height(self) -> int: """Get the sprite's height.""" return int(self.render().shape[0]) @property 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 )