mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(pet): match Codex's per-frame pacing and pointer interactions for imported pets (#8730)
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyCodexSpriteTimingDefaults } from '../../shared/codex-pet-sprite-defaults'
|
||||
import {
|
||||
CODEX_PET_ANIMATIONS,
|
||||
CODEX_PET_FRAME,
|
||||
@@ -63,6 +64,40 @@ describe('applyCodexPetDefaults', () => {
|
||||
expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS)
|
||||
})
|
||||
|
||||
it('bakes uniform per-frame durations when a Codex-layout bundle pins an explicit fps', () => {
|
||||
// The requested fps is baked as durations so it is honored rather than
|
||||
// overridden by the timed table, and so the sprite never enters the
|
||||
// no-durations legacy retiming path.
|
||||
const manifest = applyCodexPetDefaults({ id: 'zippy', displayName: 'Zippy', fps: 4 })
|
||||
|
||||
expect(manifest.fps).toBe(4)
|
||||
// 4 fps → 250ms per frame; idle keeps its six frames.
|
||||
expect(manifest.animations?.idle).toEqual({
|
||||
row: 0,
|
||||
frames: 6,
|
||||
frameDurationsMs: [250, 250, 250, 250, 250, 250]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicit fps=8 bundle out of the legacy retiming path', () => {
|
||||
// fps=8 matches the Codex default and the legacy geometry, but baking
|
||||
// durations makes it a non-match so the uniform pacing is preserved.
|
||||
const manifest = applyCodexPetDefaults({ id: 'octo', displayName: 'Octo', fps: 8 })
|
||||
const sprite = {
|
||||
frameWidth: 192,
|
||||
frameHeight: 208,
|
||||
columns: 8,
|
||||
rows: 9,
|
||||
sheetWidth: 1536,
|
||||
sheetHeight: 1872,
|
||||
fps: 8,
|
||||
defaultAnimation: 'idle',
|
||||
animations: manifest.animations
|
||||
}
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
expect(sprite.animations?.idle.frameDurationsMs).toEqual([125, 125, 125, 125, 125, 125])
|
||||
})
|
||||
|
||||
it('does not override explicit Orca bundle sprite metadata', () => {
|
||||
const manifest = applyCodexPetDefaults({
|
||||
spritesheetPath: 'custom.png',
|
||||
|
||||
+26
-18
@@ -1,4 +1,22 @@
|
||||
import type { SpriteAnimation } from '../../shared/types'
|
||||
import {
|
||||
CODEX_PET_ANIMATIONS,
|
||||
CODEX_PET_DEFAULT_ANIMATION,
|
||||
CODEX_PET_DEFAULT_FPS,
|
||||
CODEX_PET_FRAME,
|
||||
CODEX_PET_SPRITESHEET_PATH,
|
||||
codexAnimationsAtUniformFps
|
||||
} from '../../shared/codex-pet-sprite-defaults'
|
||||
|
||||
// Re-exported so main-side consumers keep their import path. The tables live
|
||||
// in shared/ so the renderer can reuse the same fingerprint for upgrades.
|
||||
export {
|
||||
CODEX_PET_ANIMATIONS,
|
||||
CODEX_PET_DEFAULT_ANIMATION,
|
||||
CODEX_PET_DEFAULT_FPS,
|
||||
CODEX_PET_FRAME,
|
||||
CODEX_PET_SPRITESHEET_PATH
|
||||
}
|
||||
|
||||
export type PetManifestLike = {
|
||||
id?: string
|
||||
@@ -19,23 +37,6 @@ export type ResolvedPetManifest<T extends PetManifestLike = PetManifestLike> = T
|
||||
spritesheetPath: string
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
export const CODEX_PET_ANIMATIONS: Record<string, SpriteAnimation> = {
|
||||
idle: { row: 0, frames: 6 },
|
||||
'running-right': { row: 1, frames: 8 },
|
||||
'running-left': { row: 2, frames: 8 },
|
||||
waving: { row: 3, frames: 4 },
|
||||
jumping: { row: 4, frames: 5 },
|
||||
failed: { row: 5, frames: 8 },
|
||||
waiting: { row: 6, frames: 6 },
|
||||
running: { row: 7, frames: 6 },
|
||||
review: { row: 8, frames: 6 }
|
||||
}
|
||||
|
||||
function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean {
|
||||
return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath)
|
||||
}
|
||||
@@ -61,7 +62,14 @@ export function applyCodexPetDefaults<T extends PetManifestLike>(
|
||||
frame: manifest.frame ?? CODEX_PET_FRAME,
|
||||
fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS,
|
||||
defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION,
|
||||
animations: manifest.animations ?? CODEX_PET_ANIMATIONS
|
||||
// Why: with no fps, bake Codex's intended uneven pacing; with an explicit
|
||||
// fps, bake that as uniform durations so it is honored instead of being
|
||||
// overridden by the timed table (and stays out of the legacy retiming path).
|
||||
animations:
|
||||
manifest.animations ??
|
||||
(manifest.fps === undefined
|
||||
? CODEX_PET_ANIMATIONS
|
||||
: codexAnimationsAtUniformFps(manifest.fps))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,4 +109,67 @@ describe('registerPetHandlers', () => {
|
||||
readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png'))
|
||||
).resolves.toEqual(sheetBytes)
|
||||
})
|
||||
|
||||
function webpVp8x(width: number, height: number): Buffer {
|
||||
const u24 = (value: number): Buffer =>
|
||||
Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff])
|
||||
const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)])
|
||||
const size = Buffer.alloc(4)
|
||||
size.writeUInt32LE(payload.byteLength, 0)
|
||||
const riffSize = Buffer.alloc(4)
|
||||
riffSize.writeUInt32LE(4 + 8 + payload.byteLength, 0)
|
||||
return Buffer.concat([
|
||||
Buffer.from('RIFF'),
|
||||
riffSize,
|
||||
Buffer.from('WEBP'),
|
||||
Buffer.from('VP8X'),
|
||||
size,
|
||||
payload
|
||||
])
|
||||
}
|
||||
|
||||
async function writeSpriteBundle(
|
||||
animations: Record<string, { row: number; frames: number; frameDurationsMs?: number[] }>
|
||||
): Promise<string> {
|
||||
const bundleDir = join(tempDir, 'durations.codex-pet')
|
||||
await mkdir(bundleDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(bundleDir, 'pet.json'),
|
||||
JSON.stringify({
|
||||
id: 'durations',
|
||||
displayName: 'Durations',
|
||||
spritesheetPath: 'sheet.webp',
|
||||
frame: { width: 2, height: 2 },
|
||||
animations
|
||||
})
|
||||
)
|
||||
await writeFile(join(bundleDir, 'sheet.webp'), webpVp8x(4, 2))
|
||||
return bundleDir
|
||||
}
|
||||
|
||||
it('imports a bundle whose animations declare per-frame durations', async () => {
|
||||
const bundleDir = await writeSpriteBundle({
|
||||
idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] }
|
||||
})
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
|
||||
|
||||
const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet
|
||||
|
||||
expect(result.sprite?.animations?.idle).toEqual({
|
||||
row: 0,
|
||||
frames: 2,
|
||||
frameDurationsMs: [1680, 1920]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a bundle whose frame durations do not match the frame count', async () => {
|
||||
const bundleDir = await writeSpriteBundle({
|
||||
idle: { row: 0, frames: 2, frameDurationsMs: [1680] }
|
||||
})
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
|
||||
|
||||
await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow(
|
||||
'declares 1 frame durations but 2 frames'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+8
-1
@@ -112,7 +112,9 @@ const PetManifestSchema = z
|
||||
z.string().min(1).max(64),
|
||||
z.object({
|
||||
row: z.number().int().min(0).max(256),
|
||||
frames: z.number().int().positive().max(512)
|
||||
frames: z.number().int().positive().max(512),
|
||||
// Why: cap each hold at 60s so a bad manifest can't freeze the overlay.
|
||||
frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
@@ -394,6 +396,11 @@ export function registerPetHandlers(): void {
|
||||
`Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.`
|
||||
)
|
||||
}
|
||||
if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) {
|
||||
throw new Error(
|
||||
`Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) {
|
||||
throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
retainedAgentsByPaneKey: {},
|
||||
petSize: 180
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
<T,>(selector: (state: typeof storeState) => T): T => {
|
||||
return selector(storeState)
|
||||
},
|
||||
{
|
||||
getState: () => storeState
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./usePetUrl', () => ({
|
||||
usePetUrl: () => ({
|
||||
url: 'blob:custom-pet',
|
||||
ready: true,
|
||||
sprite: {
|
||||
frameWidth: 192,
|
||||
frameHeight: 208,
|
||||
columns: 8,
|
||||
rows: 9,
|
||||
sheetWidth: 1536,
|
||||
sheetHeight: 1872,
|
||||
fps: 8,
|
||||
defaultAnimation: 'idle',
|
||||
animations: {
|
||||
// Codex ambient idle pacing: 6.6s cycle with long bookend holds.
|
||||
idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] }
|
||||
}
|
||||
},
|
||||
detected: null
|
||||
})
|
||||
}))
|
||||
|
||||
import { PetOverlay } from './PetOverlay'
|
||||
|
||||
function renderPetOverlay(): { container: HTMLDivElement; root: Root } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<PetOverlay />)
|
||||
})
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
function installLocalStorage(): void {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
setItem: (key: string, value: string) => values.set(key, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('PetOverlay per-frame sprite durations', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
installLocalStorage()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('emits one held keyframe stop per frame instead of uniform steps()', () => {
|
||||
;({ container, root } = renderPetOverlay())
|
||||
|
||||
const css = Array.from(container.querySelectorAll('style'))
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n')
|
||||
|
||||
// Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms.
|
||||
expect(css).toContain('0% { background-position: 0px 0px; }')
|
||||
expect(css).toContain('25.4545%')
|
||||
expect(css).toContain('35.4545%')
|
||||
expect(css).toContain('45.4545%')
|
||||
expect(css).toContain('58.1818%')
|
||||
expect(css).toContain('70.9091%')
|
||||
expect(css).not.toContain('to { background-position:')
|
||||
|
||||
const spriteDiv = Array.from(container.querySelectorAll('div')).find(
|
||||
(div) => div.style.backgroundImage !== ''
|
||||
)
|
||||
expect(spriteDiv?.style.animation).toContain('6.6s')
|
||||
expect(spriteDiv?.style.animation).toContain('step-end')
|
||||
expect(spriteDiv?.style.animation).toContain('infinite')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
retainedAgentsByPaneKey: {},
|
||||
petSize: 180
|
||||
}))
|
||||
|
||||
// Why: a mutable pet so a re-render can simulate switching between two cached
|
||||
// custom pets that both resolve the idle state to different rows.
|
||||
const petUrlState = vi.hoisted(() => ({
|
||||
url: 'blob:pet-a',
|
||||
idleRow: 0
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
<T,>(selector: (state: typeof storeState) => T): T => selector(storeState),
|
||||
{ getState: () => storeState }
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./usePetUrl', () => ({
|
||||
usePetUrl: () => ({
|
||||
url: petUrlState.url,
|
||||
ready: true,
|
||||
sprite: {
|
||||
frameWidth: 192,
|
||||
frameHeight: 208,
|
||||
columns: 8,
|
||||
rows: 9,
|
||||
sheetWidth: 1536,
|
||||
sheetHeight: 1872,
|
||||
fps: 8,
|
||||
defaultAnimation: 'idle',
|
||||
animations: { idle: { row: petUrlState.idleRow, frames: 6 } }
|
||||
},
|
||||
detected: null
|
||||
})
|
||||
}))
|
||||
|
||||
import { PetOverlay } from './PetOverlay'
|
||||
|
||||
function installLocalStorage(): void {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
setItem: (key: string, value: string) => values.set(key, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function spriteDiv(container: HTMLElement): HTMLDivElement | undefined {
|
||||
return Array.from(container.querySelectorAll('div')).find(
|
||||
(div) => div.style.backgroundImage !== ''
|
||||
)
|
||||
}
|
||||
|
||||
describe('PetOverlay pet switching', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
installLocalStorage()
|
||||
petUrlState.url = 'blob:pet-a'
|
||||
petUrlState.idleRow = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('remounts the sprite on a pet change so it cannot inherit the prior timeline', () => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => root?.render(<PetOverlay />))
|
||||
const before = spriteDiv(container)
|
||||
expect(before).toBeDefined()
|
||||
|
||||
// Switch to a different cached pet whose idle maps to another row.
|
||||
petUrlState.url = 'blob:pet-b'
|
||||
petUrlState.idleRow = 2
|
||||
act(() => root?.render(<PetOverlay />))
|
||||
|
||||
// key={url} makes React replace the element rather than update it in place;
|
||||
// a fresh element starts its CSS animation at frame 0 instead of carrying
|
||||
// the prior pet's currentTime. Without the key it would be the same node.
|
||||
const after = spriteDiv(container)
|
||||
expect(after).toBeDefined()
|
||||
expect(after).not.toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
retainedAgentsByPaneKey: {},
|
||||
petSize: 180
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
<T,>(selector: (state: typeof storeState) => T): T => selector(storeState),
|
||||
{ getState: () => storeState }
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./usePetUrl', () => ({
|
||||
usePetUrl: () => ({
|
||||
url: 'blob:custom-pet',
|
||||
ready: true,
|
||||
sprite: {
|
||||
frameWidth: 192,
|
||||
frameHeight: 208,
|
||||
columns: 8,
|
||||
rows: 9,
|
||||
sheetWidth: 1536,
|
||||
sheetHeight: 1872,
|
||||
fps: 8,
|
||||
defaultAnimation: 'idle',
|
||||
animations: {
|
||||
idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] },
|
||||
'running-right': { row: 1, frames: 8 }
|
||||
}
|
||||
},
|
||||
detected: null
|
||||
})
|
||||
}))
|
||||
|
||||
import { PetOverlay } from './PetOverlay'
|
||||
|
||||
function renderPetOverlay(): { container: HTMLDivElement; root: Root } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<PetOverlay />)
|
||||
})
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
function installLocalStorage(): void {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
setItem: (key: string, value: string) => values.set(key, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function spriteDiv(container: HTMLElement): HTMLDivElement {
|
||||
const div = Array.from(container.querySelectorAll('div')).find(
|
||||
(candidate) => candidate.style.backgroundImage !== ''
|
||||
)
|
||||
if (!div) {
|
||||
throw new Error('sprite div not found')
|
||||
}
|
||||
return div
|
||||
}
|
||||
|
||||
// The @keyframes name is `pet-<useId>-<animationName>-<dragGeneration>`: the
|
||||
// animation name keeps a switched-to row from reusing the prior timeline, and
|
||||
// the generation suffix restarts a same-row grab from frame 0.
|
||||
function animationName(container: HTMLElement): string {
|
||||
return spriteDiv(container).style.animation.split(' ')[0]
|
||||
}
|
||||
|
||||
function firePointer(target: Element, type: string, clientX: number, clientY: number): void {
|
||||
act(() => {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(type, { clientX, clientY, button: 0, pointerId: 1, bubbles: true })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe('PetOverlay grab-and-hold pointer interaction', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
installLocalStorage()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('freezes on a stationary grab, then animates once dragged past the deadzone', () => {
|
||||
;({ container, root } = renderPetOverlay())
|
||||
const wrapper = container.querySelector('.pointer-events-auto')
|
||||
if (!wrapper) {
|
||||
throw new Error('draggable wrapper not found')
|
||||
}
|
||||
|
||||
// Baseline: the live idle row animates (idle carries per-frame durations, so
|
||||
// it renders as step-end rather than steps()).
|
||||
expect(spriteDiv(container).style.animationPlayState).toBe('running')
|
||||
expect(spriteDiv(container).style.animation).toContain('step-end')
|
||||
expect(animationName(container)).toContain('idle')
|
||||
const idleName = animationName(container)
|
||||
|
||||
// Grab and hold still: mint a fresh restart (frame 0) and freeze there while
|
||||
// staying on the live idle row.
|
||||
firePointer(wrapper, 'pointerdown', 50, 50)
|
||||
expect(spriteDiv(container).style.animationPlayState).toBe('paused')
|
||||
expect(spriteDiv(container).style.animation).toContain('step-end')
|
||||
expect(animationName(container)).not.toBe(idleName)
|
||||
const heldName = animationName(container)
|
||||
|
||||
// A sub-4px twitch stays frozen (deadzone).
|
||||
firePointer(wrapper, 'pointermove', 52, 51)
|
||||
expect(spriteDiv(container).style.animationPlayState).toBe('paused')
|
||||
|
||||
// A large vertical-only move keeps the hold (no horizontal direction yet).
|
||||
firePointer(wrapper, 'pointermove', 52, 80)
|
||||
expect(spriteDiv(container).style.animationPlayState).toBe('paused')
|
||||
|
||||
// Drag right past the 4px deadzone: switch to the running-right row (row 1,
|
||||
// 8 frames, no durations → steps(8)) and resume animating. The keyframes
|
||||
// name changes with the row, so it starts from frame 0 rather than reusing
|
||||
// the idle timeline.
|
||||
firePointer(wrapper, 'pointermove', 70, 80)
|
||||
expect(spriteDiv(container).style.animationPlayState).toBe('running')
|
||||
expect(spriteDiv(container).style.animation).toContain('steps(8)')
|
||||
expect(animationName(container)).toContain('running-right')
|
||||
expect(animationName(container)).not.toBe(heldName)
|
||||
|
||||
// Release restores the live agent state (idle) and resumes animating.
|
||||
firePointer(wrapper, 'pointerup', 70, 80)
|
||||
expect(spriteDiv(container).style.animationPlayState).toBe('running')
|
||||
expect(spriteDiv(container).style.animation).toContain('step-end')
|
||||
expect(animationName(container)).toContain('idle')
|
||||
})
|
||||
})
|
||||
@@ -5,11 +5,21 @@ import type { DetectedSpriteCacheEntry } from './pet-blob-cache'
|
||||
import type { CustomPet } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
|
||||
import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state'
|
||||
import {
|
||||
selectPetAnimationName,
|
||||
type PetAnimationName,
|
||||
type PetDragAnimation
|
||||
} from './pet-agent-state'
|
||||
import { usePetPointerInteraction } from './usePetPointerInteraction'
|
||||
import { buildSpriteAnimationCss } from './sprite-animation-css'
|
||||
|
||||
type Sprite = NonNullable<CustomPet['sprite']>
|
||||
|
||||
function usePetAnimationName(dragging: boolean): PetAnimationName {
|
||||
function usePetAnimationName(
|
||||
dragging: boolean,
|
||||
dragAnimation: PetDragAnimation,
|
||||
hovering: boolean
|
||||
): PetAnimationName {
|
||||
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
|
||||
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
|
||||
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
|
||||
@@ -22,6 +32,8 @@ function usePetAnimationName(dragging: boolean): PetAnimationName {
|
||||
entries: Object.values(agentStatusByPaneKey),
|
||||
retainedCount: Object.keys(retainedAgentsByPaneKey).length,
|
||||
dragging,
|
||||
dragAnimation,
|
||||
hovering,
|
||||
now: Date.now(),
|
||||
staleAfterMs: AGENT_STATUS_STALE_AFTER_MS
|
||||
})
|
||||
@@ -37,15 +49,21 @@ function SpriteFrame({
|
||||
sprite,
|
||||
animate,
|
||||
maxSize,
|
||||
animationName
|
||||
animationName,
|
||||
restartKey
|
||||
}: {
|
||||
url: string
|
||||
sprite: Sprite
|
||||
animate: boolean
|
||||
maxSize: number
|
||||
animationName: PetAnimationName
|
||||
// Why: folded into the keyframes name, so bumping it mints a fresh animation
|
||||
// that restarts from frame 0 even when the state row is unchanged.
|
||||
restartKey: number
|
||||
}): React.JSX.Element {
|
||||
const animKeyframesId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
// Why: name the @keyframes per animation (+restartKey for same-row grabs) so a
|
||||
// switched-to row starts at frame 0 instead of inheriting the prior timeline.
|
||||
const animKeyframesId = `${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-${animationName}-${restartKey}`
|
||||
const anim =
|
||||
sprite.animations?.[animationName] ||
|
||||
(sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) ||
|
||||
@@ -63,11 +81,15 @@ function SpriteFrame({
|
||||
const bgH = sprite.sheetHeight * scale
|
||||
const startX = 0
|
||||
const startY = -(row * sprite.frameHeight * scale)
|
||||
const endX = -(frames * sprite.frameWidth * scale)
|
||||
const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps))
|
||||
// Why: sprite keyframes are runtime CSS, not user-visible copy; translated
|
||||
// CSS keywords make the browser discard the animation.
|
||||
const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }`
|
||||
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
|
||||
keyframesId: animKeyframesId,
|
||||
frames,
|
||||
fps: sprite.fps,
|
||||
frameWidth: sprite.frameWidth,
|
||||
scale,
|
||||
rowOffsetY: startY,
|
||||
frameDurationsMs: anim?.frameDurationsMs
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<style>{keyframesCss}</style>
|
||||
@@ -80,7 +102,7 @@ function SpriteFrame({
|
||||
backgroundSize: `${bgW}px ${bgH}px`,
|
||||
backgroundPosition: `${startX}px ${startY}px`,
|
||||
imageRendering: 'pixelated',
|
||||
animation: `pet-${animKeyframesId} ${duration}s steps(${frames}) infinite`,
|
||||
animation: animationCss,
|
||||
animationPlayState: animate ? 'running' : 'paused'
|
||||
}}
|
||||
/>
|
||||
@@ -324,8 +346,10 @@ export function PetOverlay(): React.JSX.Element {
|
||||
},
|
||||
[size]
|
||||
)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dragOffsetRef = useRef<Position>({ x: 0, y: 0 })
|
||||
const { dragging, dragAnimation, hovering, dragGeneration, handlers } = usePetPointerInteraction(
|
||||
position,
|
||||
(next) => setPosition(clampToViewport(next, size))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = (): void => setPosition((prev) => clampToViewport(prev, size))
|
||||
@@ -344,46 +368,12 @@ export function PetOverlay(): React.JSX.Element {
|
||||
}
|
||||
}, [dragging, position])
|
||||
|
||||
const animate = documentVisible && !reducedMotion && !dragging
|
||||
const animationName = usePetAnimationName(dragging)
|
||||
|
||||
// Why: setPointerCapture routes subsequent pointer events to this element
|
||||
// even when the cursor leaves the OS window, so dragging can't get stuck in
|
||||
// the "true" state if the user releases outside the app.
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
dragOffsetRef.current = {
|
||||
x: event.clientX - position.x,
|
||||
y: event.clientY - position.y
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setDragging(true)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (!dragging) {
|
||||
return
|
||||
}
|
||||
setPosition(
|
||||
clampToViewport(
|
||||
{
|
||||
x: event.clientX - dragOffsetRef.current.x,
|
||||
y: event.clientY - dragOffsetRef.current.y
|
||||
},
|
||||
size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const endDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setDragging(false)
|
||||
}
|
||||
const motionAllowed = documentVisible && !reducedMotion
|
||||
// Why: a still/vertical grab freezes on frame 0 (Codex grab-and-hold); a
|
||||
// horizontal drag keeps animating so the running rows show. Bob always pauses.
|
||||
const spriteAnimate = motionAllowed && (!dragging || dragAnimation !== null)
|
||||
const bobAnimate = motionAllowed && !dragging
|
||||
const animationName = usePetAnimationName(dragging, dragAnimation, hovering)
|
||||
|
||||
return (
|
||||
// Why: the outer box and middle layer stay pointer-events-none so app chrome
|
||||
@@ -401,15 +391,12 @@ export function PetOverlay(): React.JSX.Element {
|
||||
>
|
||||
<div className="pointer-events-none flex size-full items-center justify-end">
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
{...handlers}
|
||||
className="pointer-events-auto flex h-fit w-fit select-none"
|
||||
style={{
|
||||
cursor: dragging ? 'grabbing' : 'grab',
|
||||
animation: 'pet-bob 1.2s ease-in-out infinite',
|
||||
animationPlayState: animate ? 'running' : 'paused',
|
||||
animationPlayState: bobAnimate ? 'running' : 'paused',
|
||||
touchAction: 'none',
|
||||
// Why: floor so the wrapper stays grabbable while w-fit/h-fit would
|
||||
// otherwise collapse to 0×0 during the image-load window.
|
||||
@@ -419,15 +406,20 @@ export function PetOverlay(): React.JSX.Element {
|
||||
>
|
||||
<style>{PET_BOB_KEYFRAMES_CSS}</style>
|
||||
{sprite ? (
|
||||
// Why: remount per pet so switching cached sprites can't inherit the
|
||||
// previous pet's animation timeline (same @keyframes name → carried
|
||||
// currentTime); each pet starts clean.
|
||||
<SpriteFrame
|
||||
key={url}
|
||||
url={url}
|
||||
sprite={sprite}
|
||||
animate={animate}
|
||||
animate={spriteAnimate}
|
||||
maxSize={size}
|
||||
animationName={animationName}
|
||||
restartKey={dragGeneration}
|
||||
/>
|
||||
) : detected ? (
|
||||
<DetectedSpriteFrame detected={detected} animate={animate} maxSize={size} />
|
||||
<DetectedSpriteFrame detected={detected} animate={spriteAnimate} maxSize={size} />
|
||||
) : (
|
||||
// Why: cap explicitly at the pet size — the w-fit/h-fit wrapper is
|
||||
// fit-content, so max-w/h-full has no fixed box to resolve against
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import { selectPetAnimationName } from './pet-agent-state'
|
||||
import { nextPetDragAnimation, selectPetAnimationName } from './pet-agent-state'
|
||||
|
||||
const NOW = 1_000
|
||||
const STALE_AFTER_MS = 500
|
||||
@@ -28,6 +28,8 @@ function select(
|
||||
entries,
|
||||
retainedCount: 0,
|
||||
dragging: false,
|
||||
dragAnimation: null,
|
||||
hovering: false,
|
||||
now: NOW,
|
||||
staleAfterMs: STALE_AFTER_MS,
|
||||
...options
|
||||
@@ -63,7 +65,57 @@ describe('selectPetAnimationName', () => {
|
||||
expect(select([entry('working'), entry('done', { interrupted: true })])).toBe('running')
|
||||
})
|
||||
|
||||
it('uses jumping while the pet is being dragged', () => {
|
||||
expect(select([entry('blocked')], { dragging: true })).toBe('jumping')
|
||||
it('keeps the live agent state while grabbed and held still (no drag direction)', () => {
|
||||
expect(select([entry('blocked')], { dragging: true })).toBe('waiting')
|
||||
expect(select([entry('working')], { dragging: true })).toBe('running')
|
||||
expect(select([], { dragging: true })).toBe('idle')
|
||||
})
|
||||
|
||||
it('runs toward the drag direction while the pet is dragged horizontally', () => {
|
||||
expect(select([entry('blocked')], { dragging: true, dragAnimation: 'running-right' })).toBe(
|
||||
'running-right'
|
||||
)
|
||||
expect(select([], { dragging: true, dragAnimation: 'running-left' })).toBe('running-left')
|
||||
})
|
||||
|
||||
it('uses jumping while hovered but not dragging', () => {
|
||||
expect(select([], { hovering: true })).toBe('jumping')
|
||||
expect(select([entry('working')], { hovering: true })).toBe('jumping')
|
||||
})
|
||||
|
||||
it('prefers the live state over hover while grabbed (drag suppresses the hover jump)', () => {
|
||||
expect(select([entry('working')], { dragging: true, hovering: true })).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextPetDragAnimation', () => {
|
||||
it('keeps the direction and baseline for sub-threshold horizontal travel', () => {
|
||||
expect(nextPetDragAnimation(null, 3)).toEqual({ animation: null, accepted: false })
|
||||
expect(nextPetDragAnimation('running-right', -3)).toEqual({
|
||||
animation: 'running-right',
|
||||
accepted: false
|
||||
})
|
||||
})
|
||||
|
||||
it('picks the horizontal direction at the 4px threshold and advances the baseline', () => {
|
||||
expect(nextPetDragAnimation(null, 4)).toEqual({
|
||||
animation: 'running-right',
|
||||
accepted: true
|
||||
})
|
||||
expect(nextPetDragAnimation(null, -4)).toEqual({
|
||||
animation: 'running-left',
|
||||
accepted: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last direction without advancing when horizontal travel stays under 4px', () => {
|
||||
// A vertical/near-vertical drag has ~0 horizontal delta: keep the direction
|
||||
// but do not reset the baseline, so accumulated horizontal travel still adds
|
||||
// up toward the threshold on a slow diagonal drag.
|
||||
expect(nextPetDragAnimation('running-left', 0)).toEqual({
|
||||
animation: 'running-left',
|
||||
accepted: false
|
||||
})
|
||||
expect(nextPetDragAnimation(null, 0)).toEqual({ animation: null, accepted: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,27 +1,50 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
|
||||
export type PetAnimationName = 'idle' | 'running' | 'waiting' | 'review' | 'jumping'
|
||||
export type PetAnimationName =
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'waiting'
|
||||
| 'review'
|
||||
| 'jumping'
|
||||
| 'running-right'
|
||||
| 'running-left'
|
||||
|
||||
export type PetDragAnimation = 'running-right' | 'running-left' | null
|
||||
|
||||
// Why: direction tracks horizontal travel only. `accepted` (advance the
|
||||
// baseline) fires solely on a >=4px horizontal move, so sub-threshold jitter
|
||||
// and vertical drags keep the last direction without resetting the baseline —
|
||||
// otherwise a slow diagonal drag would reset before ever crossing 4px.
|
||||
export function nextPetDragAnimation(
|
||||
current: PetDragAnimation,
|
||||
deltaX: number
|
||||
): { animation: PetDragAnimation; accepted: boolean } {
|
||||
if (deltaX >= 4) {
|
||||
return { animation: 'running-right', accepted: true }
|
||||
}
|
||||
if (deltaX <= -4) {
|
||||
return { animation: 'running-left', accepted: true }
|
||||
}
|
||||
return { animation: current, accepted: false }
|
||||
}
|
||||
|
||||
export type PetAnimationInput = {
|
||||
entries: AgentStatusEntry[]
|
||||
retainedCount: number
|
||||
dragging: boolean
|
||||
dragAnimation: PetDragAnimation
|
||||
hovering: boolean
|
||||
now: number
|
||||
staleAfterMs: number
|
||||
}
|
||||
|
||||
export function selectPetAnimationName({
|
||||
entries,
|
||||
retainedCount,
|
||||
dragging,
|
||||
now,
|
||||
staleAfterMs
|
||||
}: PetAnimationInput): PetAnimationName {
|
||||
if (dragging) {
|
||||
return 'jumping'
|
||||
}
|
||||
|
||||
function agentStateAnimation(
|
||||
entries: AgentStatusEntry[],
|
||||
retainedCount: number,
|
||||
now: number,
|
||||
staleAfterMs: number
|
||||
): PetAnimationName {
|
||||
let hasWorking = false
|
||||
let hasDone = false
|
||||
|
||||
@@ -47,3 +70,24 @@ export function selectPetAnimationName({
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function selectPetAnimationName({
|
||||
entries,
|
||||
retainedCount,
|
||||
dragging,
|
||||
dragAnimation,
|
||||
hovering,
|
||||
now,
|
||||
staleAfterMs
|
||||
}: PetAnimationInput): PetAnimationName {
|
||||
const base = agentStateAnimation(entries, retainedCount, now, staleAfterMs)
|
||||
// Why: aligned with Codex. A horizontal drag runs toward the pointer,
|
||||
// grab-and-hold keeps the live agent state, and only a plain hover jumps.
|
||||
if (dragging) {
|
||||
return dragAnimation ?? base
|
||||
}
|
||||
if (hovering) {
|
||||
return 'jumping'
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSpriteAnimationCss } from './sprite-animation-css'
|
||||
|
||||
const BASE = {
|
||||
keyframesId: 'kf',
|
||||
frameWidth: 100,
|
||||
scale: 1,
|
||||
rowOffsetY: -200
|
||||
}
|
||||
|
||||
describe('buildSpriteAnimationCss', () => {
|
||||
it('emits one step-end stop per frame for uneven Codex-style pacing', () => {
|
||||
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
|
||||
...BASE,
|
||||
frames: 6,
|
||||
fps: 8,
|
||||
frameDurationsMs: [1680, 660, 660, 840, 840, 1920]
|
||||
})
|
||||
|
||||
// Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms.
|
||||
expect(keyframesCss).toContain('0% { background-position: 0px -200px; }')
|
||||
expect(keyframesCss).toContain('25.4545% { background-position: -100px -200px; }')
|
||||
expect(keyframesCss).toContain('70.9091% { background-position: -500px -200px; }')
|
||||
expect(keyframesCss).not.toContain(' to {')
|
||||
expect(animationCss).toBe('pet-kf 6.6s step-end infinite')
|
||||
})
|
||||
|
||||
it('falls back to uniform steps() when durations are absent, invalid, or corrupt', () => {
|
||||
for (const frameDurationsMs of [
|
||||
undefined,
|
||||
[100, 100],
|
||||
[100, -1, 100],
|
||||
[100, Number.NaN, 100],
|
||||
// Untrusted persisted data: non-arrays with a matching `length` must not
|
||||
// reach .every and throw.
|
||||
{ length: 3 } as unknown as number[],
|
||||
'aaa' as unknown as number[]
|
||||
]) {
|
||||
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
|
||||
...BASE,
|
||||
frames: 3,
|
||||
fps: 6,
|
||||
frameDurationsMs
|
||||
})
|
||||
expect(keyframesCss).toBe(
|
||||
'@keyframes pet-kf { from { background-position: 0px -200px; } to { background-position: -300px -200px; } }'
|
||||
)
|
||||
expect(animationCss).toBe('pet-kf 0.5s steps(3) infinite')
|
||||
}
|
||||
})
|
||||
|
||||
it('degrades to uniform pacing rather than dropping a frame that rounds away', () => {
|
||||
// A sub-precision frame among a ~minute total loses its own stop: a leading
|
||||
// one collapses two stops to 0%, a trailing one rounds the final stop to
|
||||
// 100% (no interval before the loop). Either way, degrade to steps() so
|
||||
// every frame still renders instead of letting one vanish.
|
||||
for (const frameDurationsMs of [
|
||||
[0.001, 60_000],
|
||||
[60_000, 0.001]
|
||||
]) {
|
||||
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
|
||||
...BASE,
|
||||
frames: 2,
|
||||
fps: 8,
|
||||
frameDurationsMs
|
||||
})
|
||||
expect(keyframesCss).not.toContain('step')
|
||||
expect(keyframesCss).toContain(' to {')
|
||||
expect(animationCss).toContain('steps(2)')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a genuinely short-but-representable frame as its own stop', () => {
|
||||
const { keyframesCss } = buildSpriteAnimationCss({
|
||||
...BASE,
|
||||
frames: 2,
|
||||
fps: 8,
|
||||
frameDurationsMs: [10, 990]
|
||||
})
|
||||
expect(keyframesCss).toContain('0% { background-position: 0px -200px; }')
|
||||
expect(keyframesCss).toContain('1% { background-position: -100px -200px; }')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
// Sprite-sheet keyframe CSS for the pet overlay. Kept as a pure module so the
|
||||
// pacing math is unit-testable without mounting the overlay or a DOM.
|
||||
|
||||
export type SpriteAnimationCss = {
|
||||
keyframesCss: string
|
||||
animationCss: string
|
||||
}
|
||||
|
||||
export type SpriteAnimationCssInput = {
|
||||
// Sanitized `@keyframes` identifier; folded with the restart key by the caller.
|
||||
keyframesId: string
|
||||
frames: number
|
||||
fps: number
|
||||
frameWidth: number
|
||||
scale: number
|
||||
// Vertical offset selecting the animation's row on the sheet.
|
||||
rowOffsetY: number
|
||||
// Per-frame holds in ms; uneven pacing renders when they pass validation.
|
||||
frameDurationsMs: number[] | undefined
|
||||
}
|
||||
|
||||
// Why: sprite keyframes are runtime CSS, not user-visible copy; translated CSS
|
||||
// keywords make the browser discard the animation, so keep them out of i18n.
|
||||
export function buildSpriteAnimationCss({
|
||||
keyframesId,
|
||||
frames,
|
||||
fps,
|
||||
frameWidth,
|
||||
scale,
|
||||
rowOffsetY,
|
||||
frameDurationsMs
|
||||
}: SpriteAnimationCssInput): SpriteAnimationCss {
|
||||
const name = `pet-${keyframesId}`
|
||||
const durations = validFrameDurations(frameDurationsMs, frames)
|
||||
if (durations) {
|
||||
const totalMs = durations.reduce((sum, ms) => sum + ms, 0)
|
||||
// Why: Codex pets hold frames unevenly (idle rests ~1.9s on its last frame).
|
||||
// steps() can't express that, so emit one step-end stop per frame.
|
||||
const stops = stepEndStops(durations, totalMs, frameWidth, scale, rowOffsetY)
|
||||
if (stops) {
|
||||
return {
|
||||
keyframesCss: `@keyframes ${name} { ${stops.join(' ')} }`,
|
||||
animationCss: `${name} ${totalMs / 1000}s step-end infinite`
|
||||
}
|
||||
}
|
||||
}
|
||||
// Uniform sheet fps: one steps() run across the row.
|
||||
const duration = Math.max(0.1, frames / Math.max(0.1, fps))
|
||||
const endX = -(frames * frameWidth * scale)
|
||||
return {
|
||||
keyframesCss: `@keyframes ${name} { from { background-position: 0px ${rowOffsetY}px; } to { background-position: ${endX}px ${rowOffsetY}px; } }`,
|
||||
animationCss: `${name} ${duration}s steps(${frames}) infinite`
|
||||
}
|
||||
}
|
||||
|
||||
function validFrameDurations(
|
||||
frameDurationsMs: number[] | undefined,
|
||||
frames: number
|
||||
): number[] | null {
|
||||
// Why: Array.isArray, not a truthiness check — persisted/RPC-synced sprites
|
||||
// are untrusted, so a corrupt non-array value (e.g. { length: 6 }) must fail
|
||||
// here rather than throw on .length/.every during render.
|
||||
if (
|
||||
Array.isArray(frameDurationsMs) &&
|
||||
frameDurationsMs.length === frames &&
|
||||
frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0)
|
||||
) {
|
||||
return frameDurationsMs
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Cumulative step-end stops, one per frame. Returns null (→ uniform fallback)
|
||||
// when a frame is too short to survive our 4-decimal precision: either two
|
||||
// stops collapse to the same percentage, or the final stop rounds to 100% and
|
||||
// so has no interval before the loop. Either way the frame would vanish, so we
|
||||
// degrade to uniform pacing rather than silently drop it.
|
||||
function stepEndStops(
|
||||
durations: number[],
|
||||
totalMs: number,
|
||||
frameWidth: number,
|
||||
scale: number,
|
||||
rowOffsetY: number
|
||||
): string[] | null {
|
||||
const stops: string[] = []
|
||||
let elapsedMs = 0
|
||||
let previousPct = -1
|
||||
for (let index = 0; index < durations.length; index++) {
|
||||
const pct = +((elapsedMs / totalMs) * 100).toFixed(4)
|
||||
if (pct <= previousPct || pct >= 100) {
|
||||
return null
|
||||
}
|
||||
previousPct = pct
|
||||
const x = -(index * frameWidth * scale)
|
||||
stops.push(`${pct}% { background-position: ${x}px ${rowOffsetY}px; }`)
|
||||
elapsedMs += durations[index]
|
||||
}
|
||||
return stops
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { usePetPointerInteraction } from './usePetPointerInteraction'
|
||||
|
||||
type HandlerName = keyof ReturnType<typeof usePetPointerInteraction>['handlers']
|
||||
|
||||
function fakeTarget(): {
|
||||
setPointerCapture: (id: number) => void
|
||||
hasPointerCapture: (id: number) => boolean
|
||||
releasePointerCapture: (id: number) => void
|
||||
} {
|
||||
const captured = new Set<number>()
|
||||
return {
|
||||
setPointerCapture: (id) => void captured.add(id),
|
||||
hasPointerCapture: (id) => captured.has(id),
|
||||
releasePointerCapture: (id) => void captured.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const moveTo = vi.fn()
|
||||
const target = fakeTarget()
|
||||
const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, moveTo))
|
||||
|
||||
const event = (props: {
|
||||
clientX?: number
|
||||
clientY?: number
|
||||
pointerId?: number
|
||||
button?: number
|
||||
}) =>
|
||||
({
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
currentTarget: target,
|
||||
preventDefault: () => {},
|
||||
...props
|
||||
}) as unknown as Parameters<
|
||||
ReturnType<typeof usePetPointerInteraction>['handlers']['onPointerDown']
|
||||
>[0]
|
||||
|
||||
const fire = (name: HandlerName, props: Parameters<typeof event>[0] = {}): void => {
|
||||
act(() => result.current.handlers[name](event(props)))
|
||||
}
|
||||
|
||||
return { result, moveTo, fire, event }
|
||||
}
|
||||
|
||||
describe('usePetPointerInteraction', () => {
|
||||
it('starts a drag and bumps the restart generation on a primary grab', () => {
|
||||
const { result, fire } = setup()
|
||||
fire('onPointerDown', { clientX: 50, clientY: 50 })
|
||||
expect(result.current.dragging).toBe(true)
|
||||
expect(result.current.dragAnimation).toBe(null)
|
||||
expect(result.current.dragGeneration).toBe(1)
|
||||
})
|
||||
|
||||
it('runs toward the horizontal drag direction past the 4px deadzone', () => {
|
||||
const { result, fire, moveTo } = setup()
|
||||
fire('onPointerDown', { clientX: 50, clientY: 50 })
|
||||
fire('onPointerMove', { clientX: 58, clientY: 50 })
|
||||
expect(result.current.dragAnimation).toBe('running-right')
|
||||
expect(moveTo).toHaveBeenLastCalledWith({ x: 8, y: 0 })
|
||||
fire('onPointerMove', { clientX: 40, clientY: 50 })
|
||||
expect(result.current.dragAnimation).toBe('running-left')
|
||||
})
|
||||
|
||||
it('ignores sub-deadzone jitter and keeps the last direction on vertical moves', () => {
|
||||
const { result, fire } = setup()
|
||||
fire('onPointerDown', { clientX: 50, clientY: 50 })
|
||||
fire('onPointerMove', { clientX: 52, clientY: 52 })
|
||||
expect(result.current.dragAnimation).toBe(null)
|
||||
fire('onPointerMove', { clientX: 58, clientY: 52 })
|
||||
expect(result.current.dragAnimation).toBe('running-right')
|
||||
fire('onPointerMove', { clientX: 58, clientY: 100 })
|
||||
expect(result.current.dragAnimation).toBe('running-right')
|
||||
})
|
||||
|
||||
it('accumulates horizontal travel across a slow diagonal drag', () => {
|
||||
// Regression: the baseline advances only on an accepted direction, so a
|
||||
// sequence of sub-4px diagonal moves still crosses the threshold instead of
|
||||
// resetting each time and never triggering.
|
||||
const { result, fire } = setup()
|
||||
fire('onPointerDown', { clientX: 50, clientY: 50 })
|
||||
fire('onPointerMove', { clientX: 53, clientY: 55 })
|
||||
expect(result.current.dragAnimation).toBe(null)
|
||||
fire('onPointerMove', { clientX: 56, clientY: 60 })
|
||||
expect(result.current.dragAnimation).toBe('running-right')
|
||||
})
|
||||
|
||||
it('keeps the latest horizontal direction when two moves land in one commit', () => {
|
||||
// Regression: the direction lives in a ref, not the state closure, so a
|
||||
// vertical move batched right after a horizontal one cannot resurrect the
|
||||
// stale prior direction.
|
||||
const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, vi.fn()))
|
||||
const target = fakeTarget()
|
||||
const ev = (clientX: number, clientY: number) =>
|
||||
({
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget: target,
|
||||
preventDefault: () => {}
|
||||
}) as never
|
||||
|
||||
act(() => result.current.handlers.onPointerDown(ev(50, 50)))
|
||||
act(() => {
|
||||
result.current.handlers.onPointerMove(ev(40, 50)) // left
|
||||
result.current.handlers.onPointerMove(ev(40, 90)) // vertical from the new sample
|
||||
})
|
||||
expect(result.current.dragAnimation).toBe('running-left')
|
||||
})
|
||||
|
||||
it('scopes the drag to the owning pointer and ignores a second touch', () => {
|
||||
const { result, fire, moveTo } = setup()
|
||||
fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 1 })
|
||||
// A second pointer must not hijack the drag or mint a new restart.
|
||||
fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 2 })
|
||||
expect(result.current.dragGeneration).toBe(1)
|
||||
fire('onPointerMove', { clientX: 90, clientY: 50, pointerId: 2 })
|
||||
expect(moveTo).not.toHaveBeenCalled()
|
||||
// Releasing the non-owning pointer leaves the drag alive.
|
||||
fire('onPointerUp', { pointerId: 2 })
|
||||
expect(result.current.dragging).toBe(true)
|
||||
// The owning pointer ends it.
|
||||
fire('onPointerUp', { pointerId: 1 })
|
||||
expect(result.current.dragging).toBe(false)
|
||||
expect(result.current.dragAnimation).toBe(null)
|
||||
})
|
||||
|
||||
it('ends the drag when pointer capture is lost', () => {
|
||||
const { result, fire } = setup()
|
||||
fire('onPointerDown', { clientX: 50, clientY: 50 })
|
||||
fire('onLostPointerCapture', { pointerId: 1 })
|
||||
expect(result.current.dragging).toBe(false)
|
||||
expect(result.current.dragAnimation).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { nextPetDragAnimation, type PetDragAnimation } from './pet-agent-state'
|
||||
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
export type PetPointerInteraction = {
|
||||
dragging: boolean
|
||||
dragAnimation: PetDragAnimation
|
||||
hovering: boolean
|
||||
// Why: bumped on grab so the sprite restarts from frame 0, aligned with the
|
||||
// Codex mascot.
|
||||
dragGeneration: number
|
||||
handlers: {
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
onLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
onPointerEnter: () => void
|
||||
onPointerLeave: () => void
|
||||
}
|
||||
}
|
||||
|
||||
// Drag/hover state for the pet overlay. `position` is the overlay's current
|
||||
// top-left corner and `moveTo` receives the unclamped position the drag wants.
|
||||
export function usePetPointerInteraction(
|
||||
position: Point,
|
||||
moveTo: (next: Point) => void
|
||||
): PetPointerInteraction {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dragAnimation, setDragAnimation] = useState<PetDragAnimation>(null)
|
||||
const [hovering, setHovering] = useState(false)
|
||||
const [dragGeneration, setDragGeneration] = useState(0)
|
||||
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 })
|
||||
// Why: horizontal baseline for the drag-direction hysteresis, advanced only on
|
||||
// an accepted direction. Kept separate from dragOffsetRef (position math).
|
||||
const dragBaselineXRef = useRef(0)
|
||||
// Why: direction and the owning pointer are read+written inside pointer
|
||||
// handlers, so keep them in refs immune to React's render batching. Reading
|
||||
// the direction from state would let two coalesced moves in one commit
|
||||
// resurrect a stale direction; `dragAnimation` state exists only to render.
|
||||
const dragDirectionRef = useRef<PetDragAnimation>(null)
|
||||
const activePointerRef = useRef<number | null>(null)
|
||||
|
||||
// Why: setPointerCapture routes subsequent pointer events to this element
|
||||
// even when the cursor leaves the OS window, so dragging can't get stuck in
|
||||
// the "true" state if the user releases outside the app.
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
// Why: primary button only, and one pointer owns the drag — a second touch
|
||||
// must not hijack the anchors mid-drag.
|
||||
if (event.button !== 0 || activePointerRef.current !== null) {
|
||||
return
|
||||
}
|
||||
activePointerRef.current = event.pointerId
|
||||
dragOffsetRef.current = {
|
||||
x: event.clientX - position.x,
|
||||
y: event.clientY - position.y
|
||||
}
|
||||
dragBaselineXRef.current = event.clientX
|
||||
dragDirectionRef.current = null
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setDragging(true)
|
||||
setDragAnimation(null)
|
||||
setDragGeneration((generation) => generation + 1)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (event.pointerId !== activePointerRef.current) {
|
||||
return
|
||||
}
|
||||
const next = nextPetDragAnimation(
|
||||
dragDirectionRef.current,
|
||||
event.clientX - dragBaselineXRef.current
|
||||
)
|
||||
if (next.accepted) {
|
||||
dragBaselineXRef.current = event.clientX
|
||||
if (next.animation !== dragDirectionRef.current) {
|
||||
dragDirectionRef.current = next.animation
|
||||
setDragAnimation(next.animation)
|
||||
}
|
||||
}
|
||||
moveTo({
|
||||
x: event.clientX - dragOffsetRef.current.x,
|
||||
y: event.clientY - dragOffsetRef.current.y
|
||||
})
|
||||
}
|
||||
|
||||
const endDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (event.pointerId !== activePointerRef.current) {
|
||||
return
|
||||
}
|
||||
activePointerRef.current = null
|
||||
dragDirectionRef.current = null
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setDragging(false)
|
||||
setDragAnimation(null)
|
||||
}
|
||||
|
||||
return {
|
||||
dragging,
|
||||
dragAnimation,
|
||||
hovering,
|
||||
dragGeneration,
|
||||
handlers: {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp: endDrag,
|
||||
onPointerCancel: endDrag,
|
||||
// Why: capture can be revoked without a pointerup (e.g. the element loses
|
||||
// it); treat that as the end of the drag so it can't wedge on.
|
||||
onLostPointerCapture: endDrag,
|
||||
onPointerEnter: () => setHovering(true),
|
||||
onPointerLeave: () => setHovering(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CustomPet } from '../../../../shared/types'
|
||||
import { applyCodexSpriteTimingDefaults } from '../../../../shared/codex-pet-sprite-defaults'
|
||||
import { useAppStore } from '../../store'
|
||||
import { BUNDLED_PET, findBundledPet, isBundledPetId } from './pet-models'
|
||||
import {
|
||||
@@ -114,7 +115,14 @@ export function usePetUrl(): ResolvedPet {
|
||||
customMeta.sprite.frameHeight > 0 &&
|
||||
customMeta.sprite.fps > 0
|
||||
) {
|
||||
return { url: customUrl, ready: true, sprite: customMeta.sprite, detected: null }
|
||||
// Why: sprites persisted before per-frame durations existed pace ~9x too
|
||||
// fast. Upgrade the legacy Codex fingerprint without forcing a re-import.
|
||||
return {
|
||||
url: customUrl,
|
||||
ready: true,
|
||||
sprite: applyCodexSpriteTimingDefaults(customMeta.sprite),
|
||||
detected: null
|
||||
}
|
||||
}
|
||||
const detected = detectedSpriteCache.get(customMeta.id)
|
||||
if (detected) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CODEX_PET_ANIMATIONS,
|
||||
CODEX_PET_DEFAULT_COLUMNS,
|
||||
CODEX_PET_DEFAULT_FPS,
|
||||
applyCodexSpriteTimingDefaults,
|
||||
type CustomPetSprite
|
||||
} from './codex-pet-sprite-defaults'
|
||||
|
||||
function legacyCodexSprite(): CustomPetSprite {
|
||||
return {
|
||||
frameWidth: 192,
|
||||
frameHeight: 208,
|
||||
columns: 8,
|
||||
rows: 9,
|
||||
sheetWidth: 1536,
|
||||
sheetHeight: 1872,
|
||||
fps: CODEX_PET_DEFAULT_FPS,
|
||||
defaultAnimation: 'idle',
|
||||
// Pre-durations builds baked exactly this shape from the old defaults.
|
||||
animations: Object.fromEntries(
|
||||
Object.entries(CODEX_PET_ANIMATIONS).map(([name, anim]) => [
|
||||
name,
|
||||
{ row: anim.row, frames: anim.frames }
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('CODEX_PET_ANIMATIONS', () => {
|
||||
it('ships per-frame durations matching each frame count', () => {
|
||||
for (const [name, anim] of Object.entries(CODEX_PET_ANIMATIONS)) {
|
||||
expect(anim.frameDurationsMs, name).toBeDefined()
|
||||
expect(anim.frameDurationsMs, name).toHaveLength(anim.frames)
|
||||
for (const ms of anim.frameDurationsMs ?? []) {
|
||||
expect(ms, name).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the Codex ambient idle cycle at 6.6 seconds', () => {
|
||||
const total = (CODEX_PET_ANIMATIONS.idle.frameDurationsMs ?? []).reduce(
|
||||
(sum, ms) => sum + ms,
|
||||
0
|
||||
)
|
||||
expect(total).toBe(6600)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCodexSpriteTimingDefaults', () => {
|
||||
it('upgrades the legacy baked-default fingerprint with durations', () => {
|
||||
const upgraded = applyCodexSpriteTimingDefaults(legacyCodexSprite())
|
||||
expect(upgraded.animations).toEqual(CODEX_PET_ANIMATIONS)
|
||||
expect(upgraded.animations?.idle.frameDurationsMs).toEqual([1680, 660, 660, 840, 840, 1920])
|
||||
})
|
||||
|
||||
it('leaves hand-authored animation layouts untouched', () => {
|
||||
const sprite = legacyCodexSprite()
|
||||
sprite.animations = { blink: { row: 0, frames: 2 } }
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
|
||||
it('leaves sprites with a non-default fps untouched', () => {
|
||||
const sprite = { ...legacyCodexSprite(), fps: 4 }
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
|
||||
it('leaves sprites that already declare durations untouched', () => {
|
||||
const sprite = legacyCodexSprite()
|
||||
sprite.animations = {
|
||||
...sprite.animations,
|
||||
idle: { row: 0, frames: 6, frameDurationsMs: [100, 100, 100, 100, 100, 100] }
|
||||
}
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
|
||||
it('leaves sprites without animations untouched', () => {
|
||||
const sprite = { ...legacyCodexSprite(), animations: undefined }
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
|
||||
it('leaves a hand-authored 8 fps sheet that only reuses the Codex row map untouched', () => {
|
||||
// Same fps + nine Codex rows, but non-Codex frame geometry — must not retime.
|
||||
const sprite: CustomPetSprite = {
|
||||
...legacyCodexSprite(),
|
||||
frameWidth: 32,
|
||||
frameHeight: 32,
|
||||
sheetWidth: CODEX_PET_DEFAULT_COLUMNS * 32,
|
||||
sheetHeight: 9 * 32
|
||||
}
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
|
||||
it('leaves a sheet with a non-idle default animation untouched', () => {
|
||||
const sprite = { ...legacyCodexSprite(), defaultAnimation: 'running' }
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
|
||||
it('leaves a sheet with a non-Codex column count untouched', () => {
|
||||
const sprite = { ...legacyCodexSprite(), columns: CODEX_PET_DEFAULT_COLUMNS + 2 }
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
|
||||
it('does not throw on a corrupted animation entry that matches the outer fingerprint', () => {
|
||||
const sprite = legacyCodexSprite()
|
||||
// Persisted data is untrusted: a null value under a matching key must be a
|
||||
// non-match, not a crash.
|
||||
sprite.animations = { ...sprite.animations, idle: null as never }
|
||||
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
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 } }
|
||||
}
|
||||
@@ -3602,6 +3602,8 @@ export type CustomPet = {
|
||||
export type SpriteAnimation = {
|
||||
row: number
|
||||
frames: number
|
||||
/** Per-frame holds in ms (length === frames). Absent means uniform sheet fps. */
|
||||
frameDurationsMs?: number[]
|
||||
}
|
||||
|
||||
export type PersistedTrustedOrcaHookEntry = {
|
||||
|
||||
Reference in New Issue
Block a user