/** * @file PetSprite.tsx * @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is * a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872), * one per animation state (idle, run right, run left, waving, jumping, failed, * waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16 * gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame * timings; a moment (waving, jumping, failed, review) plays three times and * settles into idle, a condition loops. An `action` (dragged left/right, * hovered) plays the run-left/run-right or waving row over the mood's, like * Codex's own pet; a `moment` (a new session waves, a finish jumps, an error * fails, a wait waits, a new subagent reviews — `useSagiBrain`) plays its row * over the mood's, under the action; a v2 pet at idle holds the gaze pose `gaze` names. An * uploaded image is shown still and bobs with the same `data-mood` CSS as * Sagi. 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, PetMoment } from "./brain"; const CELL = { w: 192, h: 208 }; const COLS = 8; /** `n` frames of `step` ms, the last one `last` ms. */ const frames = (n: number, step: number, last: number) => Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step)); /** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs). * Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is * how a sleeping pet breathes here. */ const DURATIONS = [ [280, 110, 110, 140, 140, 320], // idle frames(8, 120, 220), // run right frames(8, 120, 220), // run left frames(4, 140, 280), // waving frames(5, 140, 280), // jumping frames(8, 140, 240), // failed frames(6, 150, 260), // waiting frames(6, 120, 220), // running (working) frames(6, 150, 280), // review ]; const SLEEP_SLOWDOWN = 6; /** Rows that play three times and settle into idle; the others loop. */ const MOMENTS = new Set([3, 4, 5, 8]); /** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */ const GAZE_ROW = 9; /** 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 }; /** A moment's row: wave, jump, failed, waiting, review. It outranks the mood, * and the user's own action outranks it. */ const MOMENT_ROW: Record = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 }; export function PetSprite({ id, kind, mood, action, moment, gaze, size, reducedMotion, onBroken, }: { id: string; kind: "codex" | "image"; mood: Mood; action?: PetAction | null; moment?: PetMoment | null; /** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */ gaze?: number | null; size: number; reducedMotion: boolean; onBroken: () => void; }) { const url = api.pets.spriteUrl(id); const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0); const sleeping = !action && !moment && mood === "sleeping"; // The cell on show, tagged with the row it was played for: a cell left over // from the previous row (say column 7 of a run) is never drawn on the next. const [cell, setCell] = useState({ of: row, row, col: 0 }); const [rows, setRows] = useState(9); useEffect(() => { setCell({ of: row, row, col: 0 }); if (kind !== "codex" || reducedMotion) return; const play = (r: number, slow = 1) => (DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow })); const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1); let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own; let i = 0; let timer = 0; // `i` always indexes `list`: it wraps at its end. const tick = () => { timer = window.setTimeout(() => { if (++i === list.length) { i = 0; if (MOMENTS.has(row)) list = play(0); } setCell(list[i]!); tick(); }, list[i]!.ms); }; 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; // It also tells a v2 sheet (11 rows) from a v1 (9). const probe = new Image(); probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9); probe.onerror = onBroken; probe.src = url; return () => { probe.onload = null; probe.onerror = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, kind]); if (kind === "image") { return ( ); } const pose = gaze != null && rows > GAZE_ROW && !action && !moment && mood === "idle" ? gaze : null; const shown = pose != null ? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS } : cell.of === row ? cell : { row, col: 0 }; const scale = size / CELL.h; return (
); }