80bef6f524
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.0 KiB
Python
112 lines
4.0 KiB
Python
"""Sprite loading + frame animation.
|
|
|
|
Convention: assets/sprites/<state>/*.png, frames played in filename-sorted
|
|
order (e.g. frame_00.png, frame_01.png, ...), looping. <state> 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]
|