import { useEffect, useState, type CSSProperties } from 'react'; import type { PetAtlasRowDef } from '../../types'; import type { ResolvedPet } from './pets'; interface Props { active: ResolvedPet; className?: string; // Optional explicit pixel size; the overlay leaves it unset and // inherits container metrics, while the rail / settings preview // pin a concrete size to keep the cell shape consistent. size?: number; // Atlas-mode only — which row id (e.g. `idle`, `waving`, `running-right`) // to play right now. Defaults to `idle` (or the first row, when the // atlas does not declare an idle row). Ignored for emoji / strip pets. rowId?: string; } // Renders the pet's face. Four cases: // // 1. No imageUrl — just the emoji glyph (legacy / built-ins). // 2. imageUrl + atlas — the full Codex 8x9 sprite atlas. We pick the // requested row by index and step through that row's frames at // the row's per-second fps. Mirrors the `codex-pets-react` // `SpriteAnimator` behaviour so different interactions (idle, // waving, running-*) play the right row of the atlas. // 3. imageUrl + frames > 1 — legacy horizontal spritesheet (one row // cropped out). Walked through with a CSS `steps()` animation. // 4. imageUrl + frames === 1 — single static image. export function PetSpriteFace({ active, className, size, rowId }: Props) { if (!active.imageUrl) { const style: CSSProperties | undefined = size ? { fontSize: Math.round(size * 0.85), width: size, height: size, lineHeight: 1 } : undefined; return ( {active.glyph} ); } if (active.atlas && active.atlas.rowsDef.length > 0) { return ( ); } const frames = Math.max(1, active.frames ?? 1); const fps = Math.max(1, active.fps ?? 6); if (frames === 1) { return ( ); } // Strip mode — N frames laid out horizontally. The image is // (N × container_width) wide, so the visible frame is selected by // sliding background-position-x from 0% to 100% in (N-1) steps. // `steps(N, jump-none)` is required because the default jump-end // would land on 0/N, 1/N, …, (N-1)/N, which slices each frame mid-cell; // jump-none lands on the actual cell boundaries 0/(N-1) … 1. const durationMs = Math.round((frames / fps) * 1000); return ( ); } interface AtlasSpriteProps { imageUrl: string; cols: number; rows: number; rowsDef: PetAtlasRowDef[]; rowId?: string; className?: string; size?: number; } // Atlas renderer. Drives the frame index from JS instead of a CSS // `steps()` animation — sidesteps the jump-end vs jump-none footgun // and makes per-row fps trivial to swap when the parent flips the // `rowId` prop (idle ↔ waving ↔ running-*). function AtlasSprite({ imageUrl, cols, rows, rowsDef, rowId, className, size, }: AtlasSpriteProps) { const def = rowsDef.find((r) => r.id === rowId) ?? rowsDef.find((r) => r.id === 'idle') ?? rowsDef[0]!; const rowFrames = Math.max(1, def.frames); const fps = Math.max(1, def.fps); const [frame, setFrame] = useState(0); // Reset to frame 0 on row change so a freshly-triggered animation // (e.g. tap → waving) starts cleanly instead of mid-cycle. useEffect(() => { setFrame(0); if (rowFrames <= 1) return; const intervalMs = Math.max(16, Math.round(1000 / fps)); const id = window.setInterval(() => { setFrame((f) => (f + 1) % rowFrames); }, intervalMs); return () => window.clearInterval(id); }, [def.id, def.index, rowFrames, fps]); // Background math: // - background-size = (cols × 100%) × (rows × 100%) // → each grid cell renders at exactly the container size. // - background-position-x = frame / (cols - 1) × 100% // → 0% slides to the leftmost cell, 100% to the rightmost, // intermediate cells land at frame/(cols-1) of the offset range. // - background-position-y = rowIndex / (rows - 1) × 100% const xPct = cols > 1 ? (frame / (cols - 1)) * 100 : 0; const yPct = rows > 1 ? (def.index / (rows - 1)) * 100 : 0; return ( ); }