/** * @file PetSprite.tsx * @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is * a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per * animation state (idle, run right, run left, waving, jumping, failed, * waiting, running, review) — played one row at a time by Sagi's mood; an * uploaded image is shown still and bobs with the same `data-mood` CSS as * Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right * or waving row over the mood's, like Codex's own pet. Each row steps at its * own pace and holds its last frame longer, so a loop breathes instead of * spinning. Reduced motion holds the row's first frame. A sprite that fails to * load (deleted from another browser) calls `onBroken`, so the mascot falls * back to Sagi instead of an empty box. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; import { api } from "../../lib/api"; import type { Mood } from "./brain"; const CELL = { w: 192, h: 208 }; const SHEET = { cols: 8, rows: 9 }; /** Frames in each row of the Codex atlas. */ const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6]; /** Which row plays for each mood. */ const ROW: Record = { idle: 0, sleeping: 0, thinking: 7, // running — an agent is working watching: 8, // review happy: 4, // jumping stuck: 6, // waiting — for the user worried: 5, // failed disconnected: 5, }; /** What the user is doing to the pet, which outranks its mood. */ export type PetAction = "run-left" | "run-right" | "wave"; const ACTION_ROW: Record = { "run-right": 1, "run-left": 2, wave: 3 }; /** Per row: ms per frame, and ms the last frame is held before the loop restarts. */ const PACE: [number, number][] = [ [220, 1400], // idle: a breath, then a pause [120, 120], // run right — keeps up with the drag [120, 120], // run left [180, 400], // waving [160, 700], // jumping [200, 1200], // failed [220, 900], // waiting [170, 400], // running (working) [220, 900], // review ]; const SLEEP_PACE: [number, number] = [450, 2000]; export function PetSprite({ id, kind, mood, action, size, reducedMotion, onBroken, }: { id: string; kind: "codex" | "image"; mood: Mood; action?: PetAction | null; size: number; reducedMotion: boolean; onBroken: () => void; }) { const url = api.pets.spriteUrl(id); const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0); const sleeping = !action && mood === "sleeping"; const [frame, setFrame] = useState(0); useEffect(() => { setFrame(0); if (kind !== "codex" || reducedMotion) return; const count = FRAMES[row] ?? 1; const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]); let f = 0; let timer = 0; const tick = () => { timer = window.setTimeout( () => { f = (f + 1) % count; setFrame(f); tick(); }, f === count - 1 ? hold : step ); }; tick(); return () => window.clearTimeout(timer); }, [kind, row, sleeping, reducedMotion]); // A background image reports no error of its own: probe it once. useEffect(() => { if (kind !== "codex") return; const probe = new Image(); probe.onerror = onBroken; probe.src = url; return () => { probe.onerror = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, kind]); if (kind === "image") { return ( ); } const scale = size / CELL.h; return (
); }