Files
open-design/apps/web/src/components/pet/PetSpriteFace.tsx
marco 5dd70b5016
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Initial import: open-design source for helix-mind.ai distribution
This repository contains the open-design daemon CLI source code, built
and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use
by the HelixMind /design slash command.

Licenses: Apache-2.0 (root) + MIT (skills/*)
2026-05-06 20:50:24 +02:00

159 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<span className={className} aria-hidden style={style}>
{active.glyph}
</span>
);
}
if (active.atlas && active.atlas.rowsDef.length > 0) {
return (
<AtlasSprite
imageUrl={active.imageUrl}
cols={Math.max(1, active.atlas.cols)}
rows={Math.max(1, active.atlas.rows)}
rowsDef={active.atlas.rowsDef}
rowId={rowId}
className={className}
size={size}
/>
);
}
const frames = Math.max(1, active.frames ?? 1);
const fps = Math.max(1, active.fps ?? 6);
if (frames === 1) {
return (
<span
className={`${className ?? ''} pet-image static`.trim()}
aria-hidden
style={{
backgroundImage: `url(${active.imageUrl})`,
width: size,
height: size,
}}
/>
);
}
// 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 (
<span
className={`${className ?? ''} pet-image frames`.trim()}
aria-hidden
style={{
backgroundImage: `url(${active.imageUrl})`,
backgroundSize: `${frames * 100}% 100%`,
animation: `pet-frames ${durationMs}ms steps(${frames}, jump-none) infinite`,
width: size,
height: size,
}}
/>
);
}
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 (
<span
className={`${className ?? ''} pet-image atlas`.trim()}
aria-hidden
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: `${cols * 100}% ${rows * 100}%`,
backgroundPosition: `${xPct}% ${yPct}%`,
width: size,
height: size,
}}
/>
);
}