Source code for simvx.core.animation.sprite

"""Sprite nodes with frame-based animation support."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass

from ..descriptors import Property, Signal
from ..nodes_2d.node2d import Node2D
from ..math.types import Vec2

# ============================================================================
# Sprite2D
# ============================================================================


[docs] class Sprite2D(Node2D): """2D sprite node -- renders a texture via Draw2D.draw_texture(). The ``texture`` property holds a file path; the graphics backend loads it via TextureManager and stores the GPU index in ``_texture_id``. The ``draw()`` callback emits a textured quad through the renderer (Draw2D). Attributes: texture: Path to the image file (PNG/JPG). colour: RGBA tint (0.0-1.0 floats). width: Display width in pixels (0 = use texture native size). height: Display height in pixels (0 = use texture native size). """ texture = Property("", hint="Path to sprite texture") colour = Property((1.0, 1.0, 1.0, 1.0), hint="RGBA tint (0-1)") width = Property(0, hint="Display width (0 = native)") height = Property(0, hint="Display height (0 = native)") def __init__( self, texture: str | None = None, position=None, rotation: float = 0.0, scale=None, colour: tuple = (1.0, 1.0, 1.0, 1.0), width: int = 0, height: int = 0, **kwargs, ): super().__init__(position=position, rotation=rotation, scale=scale, **kwargs) if texture: self.texture = texture self.colour = colour self.width = width self.height = height # Set by SceneAdapter when the texture is loaded on GPU self._texture_id: int = -1 # Legacy alias @property def texture_path(self) -> str: return self.texture @texture_path.setter def texture_path(self, v: str): self.texture = v or ""
[docs] def draw(self, renderer) -> None: """Emit a textured quad via the renderer (Draw2D).""" if self._texture_id < 0 or not self.visible: return pos = self.world_position w = self.width if self.width > 0 else 64 h = self.height if self.height > 0 else 64 s = self.world_scale renderer.draw_texture( self._texture_id, pos.x - w * s.x * 0.5, pos.y - h * s.y * 0.5, w * s.x, h * s.y, colour=self.colour, rotation=self.world_rotation, )
[docs] def to_dict(self) -> dict: """Serialize sprite state.""" return { "texture_path": self.texture, "position": [self.position.x, self.position.y], "scale": [self.scale.x, self.scale.y], "rotation": self.rotation, "colour": list(self.colour), "visible": self.visible, "width": self.width, "height": self.height, }
[docs] @classmethod def from_dict(cls, data: dict): """Deserialize sprite state.""" sprite = cls( texture=data.get("texture_path"), position=Vec2(*data.get("position", [0, 0])), scale=Vec2(*data.get("scale", [1, 1])), rotation=data.get("rotation", 0.0), colour=tuple(data.get("colour", [1, 1, 1, 1])), width=data.get("width", 0), height=data.get("height", 0), ) sprite.visible = data.get("visible", True) return sprite
# ============================================================================ # SpriteAnimation / AnimatedSprite2D # ============================================================================
[docs] @dataclass class SpriteAnimation: """Named sprite animation with frame range.""" name: str frames: list[int] # Frame indices fps: float = 10.0 loop: bool = True
[docs] class AnimatedSprite2D(Sprite2D): """Sprite with frame-based animation from sprite sheets. Inherits from Sprite2D (Node2D), so it participates in the scene tree and gets ``process(dt)`` and ``draw(renderer)`` called automatically. Example: sprite = AnimatedSprite2D( texture="player.png", frames_horizontal=4, frames_vertical=4 ) sprite.add_animation("walk", frames=[0, 1, 2, 3], fps=10, loop=True) sprite.add_animation("jump", frames=[4, 5, 6], fps=15, loop=False) sprite.play("walk") """ def __init__( self, texture: str = None, frames_horizontal: int = 1, frames_vertical: int = 1, frame_width: int | None = None, frame_height: int | None = None, **kwargs, ): super().__init__(texture=texture, **kwargs) self.frames_h = frames_horizontal self.frames_v = frames_vertical self.frame_width = frame_width # Manual frame size (optional) self.frame_height = frame_height # Animation state self.animations: dict[str, SpriteAnimation] = {} self.current_animation: str | None = None self.frame = 0 # Current frame index self.frame_time = 0.0 # Accumulated time for current frame self.playing = False self.animation_finished = False # Signals self.animation_finished_signal = Signal() # Callbacks (legacy compatibility) self.on_animation_finished: Callable | None = None self.on_frame_changed: Callable[[int], None] | None = None
[docs] def add_animation(self, name: str, frames: list[int], fps: float = 10.0, loop: bool = True): """Register a named animation.""" self.animations[name] = SpriteAnimation(name, frames, fps, loop)
[docs] def play(self, animation_name: str = "default"): """Play named animation.""" if animation_name not in self.animations: # Fallback: play all frames total_frames = self.frames_h * self.frames_v self.add_animation(animation_name, list(range(total_frames))) self.current_animation = animation_name self.frame = 0 self.frame_time = 0.0 self.playing = True self.animation_finished = False
[docs] def stop(self): """Stop animation at current frame.""" self.playing = False
[docs] def pause(self): """Pause animation (alias for stop).""" self.stop()
[docs] def resume(self): """Resume animation.""" self.playing = True
[docs] def process(self, dt: float): """Advance sprite animation each frame.""" if not self.playing or not self.current_animation: return anim = self.animations[self.current_animation] self.frame_time += dt frame_duration = 1.0 / anim.fps if anim.fps > 0 else 0.0 while self.frame_time >= frame_duration and frame_duration > 0: self.frame_time -= frame_duration old_frame = self.frame self.frame += 1 # Loop or finish if self.frame >= len(anim.frames): if anim.loop: self.frame = 0 else: self.frame = len(anim.frames) - 1 self.playing = False self.animation_finished = True self.animation_finished_signal() if self.on_animation_finished: self.on_animation_finished() if self.frame != old_frame and self.on_frame_changed: self.on_frame_changed(self.frame)
[docs] def draw(self, renderer) -> None: """Draw the current animation frame as a textured quad with proper UVs.""" if self._texture_id < 0 or not self.visible: return pos = self.world_position w = self.width if self.width > 0 else 64 h = self.height if self.height > 0 else 64 s = self.world_scale uv0, uv1 = self.get_frame_uv() renderer.draw_texture_region( self._texture_id, pos.x - w * s.x * 0.5, pos.y - h * s.y * 0.5, w * s.x, h * s.y, uv0.x, uv0.y, uv1.x, uv1.y, colour=self.colour, rotation=self.world_rotation, )
[docs] def get_current_frame_index(self) -> int: """Get absolute frame index in sprite sheet.""" if not self.current_animation or self.current_animation not in self.animations: return 0 anim = self.animations[self.current_animation] return anim.frames[self.frame] if self.frame < len(anim.frames) else 0
[docs] def get_frame_uv(self) -> tuple[Vec2, Vec2]: """Get UV coordinates for current frame (top-left, bottom-right).""" idx = self.get_current_frame_index() row = idx // self.frames_h col = idx % self.frames_h u0 = col / self.frames_h v0 = row / self.frames_v u1 = (col + 1) / self.frames_h v1 = (row + 1) / self.frames_v return (Vec2(u0, v0), Vec2(u1, v1))
[docs] def to_dict(self) -> dict: """Serialize animated sprite.""" data = super().to_dict() data.update( { "frames_h": self.frames_h, "frames_v": self.frames_v, "frame_width": self.frame_width, "frame_height": self.frame_height, "animations": { name: { "frames": anim.frames, "fps": anim.fps, "loop": anim.loop, } for name, anim in self.animations.items() }, "current_animation": self.current_animation, "frame": self.frame, } ) return data
[docs] @classmethod def from_dict(cls, data: dict): """Deserialize animated sprite.""" sprite = cls( texture=data.get("texture_path"), frames_horizontal=data.get("frames_h", 1), frames_vertical=data.get("frames_v", 1), frame_width=data.get("frame_width"), frame_height=data.get("frame_height"), position=Vec2(*data.get("position", [0, 0])), scale=Vec2(*data.get("scale", [1, 1])), rotation=data.get("rotation", 0.0), colour=tuple(data.get("colour", [1, 1, 1, 1])), ) sprite.visible = data.get("visible", True) # Restore animations for name, anim_data in data.get("animations", {}).items(): sprite.add_animation(name, anim_data["frames"], anim_data.get("fps", 10.0), anim_data.get("loop", True)) sprite.current_animation = data.get("current_animation") sprite.frame = data.get("frame", 0) return sprite