Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user