ed1bf20e6b
The mascot can wear a pet. Right-click it for Hide pet, Size (48-250 px), Choose pet (Sagi, featured, your own - deletable), Upload your own..., Import .codex-pet bundle..., Find more on codex-pets.net and Pet settings.... The worn pet and its size are viewer prefs (agenthub.pet, agenthub.pet.size); the pets live on the server under <data dir>/pets/<id>/ (server/lib/pets.js, /api/pets). A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872 spritesheet.webp, 8x9 frames of 192x208, one row per state. PetSprite.tsx plays the row of the mascot's mood; an uploaded image bobs. Only the two bundle files are read, sizes are checked from the zip directory before inflating, the sheet's WebP header must match, uploads are raster only (no SVG), ids are made by the server, every write is same-origin. The four featured pets (Goku, Bac Ho, Border Collie, 11) are codex-pets.net uploads with no redistribution licence, so none is bundled: a pick downloads it from codex-pets.net by a fixed id.
108 lines
3.0 KiB
TypeScript
108 lines
3.0 KiB
TypeScript
/**
|
||
* @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. 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 } 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<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,
|
||
};
|
||
|
||
export function PetSprite({
|
||
id,
|
||
kind,
|
||
mood,
|
||
size,
|
||
reducedMotion,
|
||
onBroken,
|
||
}: {
|
||
id: string;
|
||
kind: "codex" | "image";
|
||
mood: Mood;
|
||
size: number;
|
||
reducedMotion: boolean;
|
||
onBroken: () => void;
|
||
}) {
|
||
const url = api.pets.spriteUrl(id);
|
||
const row = ROW[mood] ?? 0;
|
||
const [frame, setFrame] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setFrame(0);
|
||
if (kind !== "codex" || reducedMotion) return;
|
||
const timer = window.setInterval(
|
||
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
|
||
mood === "sleeping" ? 400 : 125
|
||
);
|
||
return () => window.clearInterval(timer);
|
||
}, [kind, row, mood, 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 (
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
data-mood={mood}
|
||
className="sagi-pet-image"
|
||
width={size}
|
||
height={size}
|
||
draggable={false}
|
||
onError={onBroken}
|
||
style={{ width: size, height: size, objectFit: "contain" }}
|
||
/>
|
||
);
|
||
}
|
||
const scale = size / CELL.h;
|
||
return (
|
||
<div
|
||
data-testid="pet-sprite"
|
||
data-row={row}
|
||
aria-hidden
|
||
style={{
|
||
width: Math.round(CELL.w * scale),
|
||
height: size,
|
||
margin: "0 auto",
|
||
backgroundImage: `url("${url}")`,
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|