b441f32949
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # client/src/components/Sagi/PetSprite.tsx # client/src/components/Sagi/useSagiBrain.ts
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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<Mood, number> = {
|
||
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<PetAction, number> = { "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<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
|
||
/** Loops of a row before the sprite rests on its first frame. */
|
||
const SPRITE_LOOPS = 4;
|
||
|
||
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 loops = 0;
|
||
let timer = 0;
|
||
// `i` always indexes `list`: it wraps at its end. A few loops, then the
|
||
// first frame rests — a sprite that never stops re-paints the page several
|
||
// times a second for as long as it is open; a new mood or action plays again.
|
||
const tick = () => {
|
||
timer = window.setTimeout(() => {
|
||
if (++i === list.length) {
|
||
i = 0;
|
||
if (MOMENTS.has(row)) list = play(0);
|
||
if (++loops >= SPRITE_LOOPS) return setCell(list[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 (
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
data-mood={mood}
|
||
data-moment={moment ?? undefined}
|
||
className="sagi-pet-image"
|
||
width={size}
|
||
height={size}
|
||
draggable={false}
|
||
onError={onBroken}
|
||
style={{ width: size, height: size, objectFit: "contain" }}
|
||
/>
|
||
);
|
||
}
|
||
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 (
|
||
<div
|
||
data-testid="pet-sprite"
|
||
data-row={shown.row}
|
||
aria-hidden
|
||
style={{
|
||
width: Math.round(CELL.w * scale),
|
||
height: size,
|
||
margin: "0 auto",
|
||
backgroundImage: `url("${url}")`,
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|