"""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