"""Sprite3D, SpriteAnimation3D, AnimatedSprite3D -- billboarded sprites in 3D."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from ..descriptors import Property, Signal
from ..math.types import Vec2, Vec3
from .node3d import Node3D
[docs]
class Sprite3D(Node3D):
"""Billboarded textured quad in 3D space.
Like Text3D but for images. Renders a textured quad positioned in the 3D
world, optionally billboarded to always face the camera. Used for health
bars, retro-3D sprites, vegetation cards, and particle-like effects.
The ``texture`` property holds a file path; the graphics backend loads it
and stores the GPU index in ``_texture_id``. The ``pixel_size`` property
controls how many world units each pixel occupies.
Example::
sprite = Sprite3D(texture="tree.png", position=(0, 1, -5))
sprite.pixel_size = 0.01
sprite.billboard = True
"""
texture = Property(None, hint="Texture path or image")
pixel_size = Property(0.01, range=(0.001, 1.0), hint="World units per pixel")
billboard = Property(True, hint="Always face camera")
flip_h = Property(False, hint="Flip texture horizontally")
flip_v = Property(False, hint="Flip texture vertically")
modulate = Property((1.0, 1.0, 1.0, 1.0), hint="Colour tint RGBA")
centered = Property(True, hint="Centre the sprite on its position")
offset = Property(None, hint="Pixel offset from position (Vec2)")
alpha_cut = Property("disabled", enum=["disabled", "discard", "opaque_prepass"], hint="Alpha mode")
render_priority = Property(0, range=(-128, 127), hint="Draw order priority")
region_enabled = Property(False, hint="Use a sub-region of the texture")
region_rect = Property(None, hint="Source rectangle (x, y, w, h) in pixels")
def __init__(self, texture=None, **kwargs):
super().__init__(**kwargs)
if texture is not None:
self.texture = texture
# Set by graphics backend when texture is loaded on GPU
self._texture_id: int = -1
self._texture_width: int = 0
self._texture_height: int = 0
@property
def texture_size(self) -> tuple[int, int]:
"""Native texture size in pixels (width, height). Set by the graphics backend."""
return (self._texture_width, self._texture_height)
[docs]
def get_quad_size(self) -> Vec2:
"""Return the quad's world-space size based on pixel_size and texture/region dimensions.
Returns:
Vec2 with (width, height) in world units.
"""
ps = float(self.pixel_size)
if self.region_enabled and self.region_rect is not None:
pw = float(self.region_rect[2])
ph = float(self.region_rect[3])
elif self._texture_width > 0 and self._texture_height > 0:
pw = float(self._texture_width)
ph = float(self._texture_height)
else:
pw, ph = 64.0, 64.0 # fallback when texture not yet loaded
return Vec2(pw * ps, ph * ps)
[docs]
def get_uv_rect(self) -> tuple[float, float, float, float]:
"""Return (u0, v0, u1, v1) UV coordinates, accounting for region and flip.
Returns:
Tuple of (u0, v0, u1, v1) in 0-1 normalised texture space.
"""
if self.region_enabled and self.region_rect is not None and self._texture_width > 0:
rx, ry, rw, rh = self.region_rect
tw, th = float(self._texture_width), float(self._texture_height)
u0, v0 = rx / tw, ry / th
u1, v1 = (rx + rw) / tw, (ry + rh) / th
else:
u0, v0, u1, v1 = 0.0, 0.0, 1.0, 1.0
if self.flip_h:
u0, u1 = u1, u0
if self.flip_v:
v0, v1 = v1, v0
return (u0, v0, u1, v1)
[docs]
def get_aabb(self):
"""Return axis-aligned bounding box for frustum culling.
Returns:
AABB centred on the node's global position, sized by the quad dimensions.
"""
from ..math_types import AABB
qs = self.get_quad_size()
s = self.world_scale
hw = qs.x * float(s.x) * 0.5
hh = qs.y * float(s.y) * 0.5
# Billboard sprites can face any direction, so use max extent for depth
extent = max(hw, hh)
p = self.world_position
return AABB(
float(p.x) - hw, float(p.y) - hh, float(p.z) - extent,
hw * 2, hh * 2, extent * 2,
)
[docs]
def draw(self, renderer) -> None:
"""Emit a billboarded textured quad for the renderer."""
if self._texture_id < 0 or not self.visible:
return
qs = self.get_quad_size()
s = self.world_scale
w = qs.x * float(s.x)
h = qs.y * float(s.y)
pos = self.world_position
u0, v0, u1, v1 = self.get_uv_rect()
# Pixel offset in world units
ox, oy = 0.0, 0.0
if self.offset is not None:
ps = float(self.pixel_size)
ox = float(self.offset[0]) * ps
oy = float(self.offset[1]) * ps
renderer.draw_sprite_3d(
texture_id=self._texture_id,
position=pos,
width=w,
height=h,
uv=(u0, v0, u1, v1),
colour=self.modulate,
billboard=self.billboard,
centered=self.centered,
offset=(ox, oy),
alpha_cut=self.alpha_cut,
render_priority=self.render_priority,
)
[docs]
@dataclass
class SpriteAnimation3D:
"""Named sprite animation with frame indices and playback settings."""
name: str
frames: list[int]
fps: float = 10.0
loop: bool = True
[docs]
class AnimatedSprite3D(Sprite3D):
"""Animated billboarded sprite in 3D space.
Plays frame-based animations from a sprite sheet, mirroring
``AnimatedSprite2D`` but positioned in 3D world space with billboard
support.
Example::
sprite = AnimatedSprite3D(
texture="enemy_sheet.png",
frames_horizontal=4,
frames_vertical=2,
position=(0, 1, -5),
)
sprite.add_animation("walk", frames=[0, 1, 2, 3], fps=10, loop=True)
sprite.add_animation("die", frames=[4, 5, 6, 7], fps=8, loop=False)
sprite.play("walk")
"""
speed_scale = Property(1.0, range=(0.0, 10.0), hint="Animation speed multiplier")
frame_changed = Signal()
animation_finished = Signal()
def __init__(
self,
texture=None,
frames_horizontal: int = 1,
frames_vertical: int = 1,
**kwargs,
):
super().__init__(texture=texture, **kwargs)
self.frames_h = frames_horizontal
self.frames_v = frames_vertical
# Animation state
self.animations: dict[str, SpriteAnimation3D] = {}
self.current_animation: str | None = None
self.frame: int = 0
self._frame_time: float = 0.0
self.playing: bool = False
self._animation_finished: bool = False
# Legacy callbacks
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.
Args:
name: Animation identifier.
frames: List of frame indices into the sprite sheet.
fps: Playback speed in frames per second.
loop: Whether the animation loops.
"""
self.animations[name] = SpriteAnimation3D(name, frames, fps, loop)
[docs]
def play(self, animation_name: str = "default"):
"""Start playing a named animation.
If the animation name is not registered, a default animation covering
all frames in the sheet is created automatically.
"""
if animation_name not in self.animations:
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 from current frame."""
self.playing = True
[docs]
def process(self, dt: float):
"""Advance animation by *dt* seconds, respecting speed_scale."""
if not self.playing or not self.current_animation:
return
anim = self.animations[self.current_animation]
if not anim.frames:
return
self._frame_time += dt * float(self.speed_scale)
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
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()
if self.on_animation_finished:
self.on_animation_finished()
if self.frame != old_frame:
self.frame_changed()
if self.on_frame_changed:
self.on_frame_changed(self.frame)
[docs]
def get_current_frame_index(self) -> int:
"""Get the absolute frame index in the 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 the current frame (top-left, bottom-right).
Returns:
Tuple of (uv0, uv1) as Vec2, with flip applied.
"""
idx = self.get_current_frame_index()
col = idx % self.frames_h
row = 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
if self.flip_h:
u0, u1 = u1, u0
if self.flip_v:
v0, v1 = v1, v0
return (Vec2(u0, v0), Vec2(u1, v1))
[docs]
def get_uv_rect(self) -> tuple[float, float, float, float]:
"""Override to use sprite-sheet frame UVs instead of region_rect."""
uv0, uv1 = self.get_frame_uv()
return (float(uv0.x), float(uv0.y), float(uv1.x), float(uv1.y))
[docs]
def get_quad_size(self) -> Vec2:
"""Override: frame size is texture size divided by sheet grid dimensions."""
ps = float(self.pixel_size)
if self._texture_width > 0 and self._texture_height > 0:
pw = float(self._texture_width) / self.frames_h
ph = float(self._texture_height) / self.frames_v
else:
pw, ph = 64.0, 64.0
return Vec2(pw * ps, ph * ps)