"""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 class SpriteSet: """All animations for every PetState, loaded from *sprite_dir*.""" def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160): self.size = size self._animations: dict[PetState, SpriteAnimation] = {} for state in PetState: frames = _load_frames_from_dir(sprite_dir / state.value, size) if not frames: frames = _placeholder_frames(state, size) self._animations[state] = SpriteAnimation(frames) def get(self, state: PetState) -> SpriteAnimation: return self._animations[state]