mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
Imported .codex-pet bundles played every animation at a flat 8 fps (~9x too fast). Mirror Codex's exact per-frame duration tables, render uneven holds as step-end keyframes, upgrade legacy-persisted pets at render, and add the Codex mascot's pointer interactions (hover, grab-and-hold on frame 0, horizontal-drag running). Hardened over several adversarial review rounds: honor explicit fps, start each row/pet from frame 0, scope drag to its pointer, and guard untrusted persisted data. Fixes #8729 Co-authored-by: nasagong <zinho2000@gachon.ac.kr>
101 lines
4.7 KiB
TypeScript
101 lines
4.7 KiB
TypeScript
import type { CustomPet, SpriteAnimation } from './types'
|
|
|
|
/** Codex pet sprite layout and pacing, mirroring the tables in Codex CLI's
|
|
* `tui/src/pets`. Shared because main bakes these into imported bundles and
|
|
* the renderer needs the same fingerprint to upgrade legacy persisted pets. */
|
|
|
|
export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp'
|
|
export const CODEX_PET_FRAME = { width: 192, height: 208 } as const
|
|
export const CODEX_PET_DEFAULT_ANIMATION = 'idle'
|
|
export const CODEX_PET_DEFAULT_FPS = 8
|
|
// Codex sheets are 8 columns wide (its widest rows run 8 frames).
|
|
export const CODEX_PET_DEFAULT_COLUMNS = 8
|
|
|
|
// Codex's `app_state_animation`: every non-final frame holds `frameMs`, the last
|
|
// holds `finalMs`. Values below mirror `codex-rs/tui/src/pets/model.rs` exactly.
|
|
function appStateDurations(frames: number, frameMs: number, finalMs: number): number[] {
|
|
return Array.from({ length: frames }, (_, i) => (i === frames - 1 ? finalMs : frameMs))
|
|
}
|
|
|
|
export const CODEX_PET_ANIMATIONS: Record<string, SpriteAnimation> = {
|
|
// Idle is the ambient loop: long holds on the bookend frames, 6.6s cycle.
|
|
idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] },
|
|
'running-right': { row: 1, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) },
|
|
'running-left': { row: 2, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) },
|
|
waving: { row: 3, frames: 4, frameDurationsMs: appStateDurations(4, 140, 280) },
|
|
jumping: { row: 4, frames: 5, frameDurationsMs: appStateDurations(5, 140, 280) },
|
|
failed: { row: 5, frames: 8, frameDurationsMs: appStateDurations(8, 140, 240) },
|
|
waiting: { row: 6, frames: 6, frameDurationsMs: appStateDurations(6, 150, 260) },
|
|
running: { row: 7, frames: 6, frameDurationsMs: appStateDurations(6, 120, 220) },
|
|
review: { row: 8, frames: 6, frameDurationsMs: appStateDurations(6, 150, 280) }
|
|
}
|
|
|
|
/** The Codex row layout paced uniformly at `fps` (each frame held 1000/fps ms).
|
|
* A bundle that pins an explicit fps gets its pacing baked as durations, so the
|
|
* fps is honored (SpriteFrame prefers durations over the sheet fps) and the
|
|
* sprite carries durations — which keeps it distinct from the no-durations
|
|
* legacy fingerprint below, so the render-time upgrade never retimes it. */
|
|
export function codexAnimationsAtUniformFps(fps: number): Record<string, SpriteAnimation> {
|
|
const frameMs = 1000 / fps
|
|
return Object.fromEntries(
|
|
Object.entries(CODEX_PET_ANIMATIONS).map(([name, { row, frames }]) => [
|
|
name,
|
|
{ row, frames, frameDurationsMs: Array.from({ length: frames }, () => frameMs) }
|
|
])
|
|
)
|
|
}
|
|
|
|
export type CustomPetSprite = NonNullable<CustomPet['sprite']>
|
|
|
|
/** The exact sprite an old Orca build baked for an imported Codex bundle:
|
|
* 192x208 frames on an 8-wide sheet at the flat 8 fps rate, idle default, and
|
|
* the nine Codex rows carrying no per-frame durations. Matching the full
|
|
* geometry (not just the row map) keeps a hand-authored 8 fps sheet that merely
|
|
* reuses those rows from being silently retimed. A pet deliberately built on
|
|
* the Codex layout that wants to keep uniform pacing opts out by declaring any
|
|
* frameDurationsMs of its own.
|
|
*
|
|
* Rows/sheet height are intentionally excluded so v2 (11-row) Codex sheets,
|
|
* which still bake these nine animations, upgrade too. */
|
|
function isLegacyCodexSprite(sprite: CustomPetSprite): boolean {
|
|
const animations = sprite.animations
|
|
if (
|
|
!animations ||
|
|
sprite.fps !== CODEX_PET_DEFAULT_FPS ||
|
|
sprite.frameWidth !== CODEX_PET_FRAME.width ||
|
|
sprite.frameHeight !== CODEX_PET_FRAME.height ||
|
|
sprite.columns !== CODEX_PET_DEFAULT_COLUMNS ||
|
|
sprite.defaultAnimation !== CODEX_PET_DEFAULT_ANIMATION
|
|
) {
|
|
return false
|
|
}
|
|
const names = Object.keys(animations)
|
|
if (names.length !== Object.keys(CODEX_PET_ANIMATIONS).length) {
|
|
return false
|
|
}
|
|
return names.every((name) => {
|
|
const anim = animations[name]
|
|
const preset = CODEX_PET_ANIMATIONS[name]
|
|
// Why: `anim` is untrusted persisted data — guard it so a corrupted entry
|
|
// (e.g. a null value) is a non-match rather than a render-time throw.
|
|
return (
|
|
!!preset &&
|
|
!!anim &&
|
|
anim.row === preset.row &&
|
|
anim.frames === preset.frames &&
|
|
anim.frameDurationsMs === undefined
|
|
)
|
|
})
|
|
}
|
|
|
|
/** Pets imported before per-frame durations existed persist the legacy Codex
|
|
* fingerprint at the flat 8 fps sheet rate, so swap in the current defaults for
|
|
* those. Anything else passes through untouched. Render-time only — persisted
|
|
* data is never rewritten, so the upgrade is reversible on downgrade. */
|
|
export function applyCodexSpriteTimingDefaults(sprite: CustomPetSprite): CustomPetSprite {
|
|
if (!isLegacyCodexSprite(sprite)) {
|
|
return sprite
|
|
}
|
|
return { ...sprite, animations: { ...CODEX_PET_ANIMATIONS } }
|
|
}
|