"""Sprite loading + frame animation. Convention: assets/sprites//*.png, frames played in filename-sorted order (e.g. frame_00.png, frame_01.png, ...), looping. matches bolt_pet.state.PetState values: idle, listening, thinking, talking. If a state's directory has no frames (real art not dropped in yet), falls back to a small procedurally-drawn placeholder blob so the app still runs end-to-end. Swap in real sprite sheets by pointing SPRITE_DIR at your own folder (see assets/sprites/README.md) — no code changes needed as long as the same per-state-subfolder-of-PNGs convention is followed. If your sheets use a different layout (single grid image, etc.), tell me the format and this loader can be adapted. """ from __future__ import annotations from pathlib import Path from typing import Optional from PySide6.QtCore import QSize, Qt from PySide6.QtGui import QColor, QPainter, QPixmap from ..state import PetState DEFAULT_SPRITE_DIR = Path(__file__).resolve().parent.parent / "assets" / "sprites" # Placeholder palette per state, used only when no frames are found. _PLACEHOLDER_COLORS = { PetState.IDLE: QColor(120, 170, 240), PetState.LISTENING: QColor(120, 220, 160), PetState.THINKING: QColor(230, 190, 90), PetState.TALKING: QColor(240, 130, 150), PetState.ERROR: QColor(220, 90, 90), } def _placeholder_frames(state: PetState, size: int) -> list[QPixmap]: """A tiny 2-frame "breathing" blob so idle/listening/etc. are visually distinguishable even before real art exists.""" color = _PLACEHOLDER_COLORS.get(state, QColor(150, 150, 150)) frames = [] for scale in (1.0, 0.92): pixmap = QPixmap(size, size) pixmap.fill(Qt.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(color) painter.setPen(Qt.NoPen) margin = size * (1 - scale) / 2 painter.drawEllipse(int(margin), int(margin), int(size * scale), int(size * scale)) # simple eyes so it reads as a face, not just a circle eye_r = max(2, size // 16) eye_y = int(size * 0.42) painter.setBrush(QColor(30, 30, 40)) painter.drawEllipse(int(size * 0.36) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2) painter.drawEllipse(int(size * 0.64) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2) painter.end() frames.append(pixmap) return frames class SpriteAnimation: """One state's frame sequence + current playback position.""" def __init__(self, frames: list[QPixmap]): self.frames = frames or [] self._index = 0 def advance(self) -> None: if self.frames: self._index = (self._index + 1) % len(self.frames) def current(self) -> Optional[QPixmap]: if not self.frames: return None return self.frames[self._index] def reset(self) -> None: self._index = 0 def _load_frames_from_dir(directory: Path, size: int) -> list[QPixmap]: if not directory.is_dir(): return [] paths = sorted(directory.glob("*.png")) + sorted(directory.glob("*.PNG")) frames = [] for path in paths: pixmap = QPixmap(str(path)) if pixmap.isNull(): continue if pixmap.size() != QSize(size, size): pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) frames.append(pixmap) return frames WALK = "walk" # Animations that aren't pipeline states. Walking is a property of *movement*, # orthogonal to whether the pet is idle/listening/talking, so it deliberately # isn't a PetState — state.py stays a description of the conversation, not of # the body. Loaded the same way, keyed by name. EXTRA_ANIMATIONS = (WALK,) class SpriteSet: """All animations for every PetState, plus the extras, from *sprite_dir*.""" def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160): self.size = size self._animations: dict[str, SpriteAnimation] = {} self._loaded: set[str] = set() # keys backed by real art, not placeholders for state in PetState: frames = _load_frames_from_dir(sprite_dir / state.value, size) if frames: self._loaded.add(state.value) else: frames = _placeholder_frames(state, size) self._animations[state.value] = SpriteAnimation(frames) for name in EXTRA_ANIMATIONS: frames = _load_frames_from_dir(sprite_dir / name, size) if frames: self._loaded.add(name) self._animations[name] = SpriteAnimation(frames) @staticmethod def _key(key) -> str: return key.value if isinstance(key, PetState) else str(key) def get(self, key) -> SpriteAnimation: """Animation for a PetState or an extra name. Unknown/absent extras fall back to idle, so a sprite folder with no walk/ still runs.""" return self._animations.get(self._key(key)) or self._animations[PetState.IDLE.value] def has(self, key) -> bool: """True only when real frames were found — the caller uses this to decide whether to use an extra animation at all, rather than being handed a placeholder blob that looks nothing like walking.""" return self._key(key) in self._loaded