From 13c690b05aed1b4527ede3398d09ca7d697ebde7 Mon Sep 17 00:00:00 2001 From: Jinho Choi <83327025+nasagong@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:59:44 +0900 Subject: [PATCH] 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 --- src/main/ipc/pet-bundle.test.ts | 35 ++++ src/main/ipc/pet-bundle.ts | 44 +++-- src/main/ipc/pet.test.ts | 63 +++++++ src/main/ipc/pet.ts | 9 +- .../pet/PetOverlay.frame-durations.test.tsx | 112 +++++++++++++ .../pet/PetOverlay.pet-switch.test.tsx | 107 ++++++++++++ .../PetOverlay.pointer-interaction.test.tsx | 156 ++++++++++++++++++ .../src/components/pet/PetOverlay.tsx | 110 ++++++------ .../components/pet/pet-agent-state.test.ts | 58 ++++++- .../src/components/pet/pet-agent-state.ts | 68 ++++++-- .../pet/sprite-animation-css.test.ts | 83 ++++++++++ .../components/pet/sprite-animation-css.ts | 99 +++++++++++ .../pet/usePetPointerInteraction.test.ts | 142 ++++++++++++++++ .../pet/usePetPointerInteraction.ts | 119 +++++++++++++ src/renderer/src/components/pet/usePetUrl.ts | 10 +- src/shared/codex-pet-sprite-defaults.test.ts | 111 +++++++++++++ src/shared/codex-pet-sprite-defaults.ts | 100 +++++++++++ src/shared/types.ts | 2 + 18 files changed, 1334 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx create mode 100644 src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx create mode 100644 src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx create mode 100644 src/renderer/src/components/pet/sprite-animation-css.test.ts create mode 100644 src/renderer/src/components/pet/sprite-animation-css.ts create mode 100644 src/renderer/src/components/pet/usePetPointerInteraction.test.ts create mode 100644 src/renderer/src/components/pet/usePetPointerInteraction.ts create mode 100644 src/shared/codex-pet-sprite-defaults.test.ts create mode 100644 src/shared/codex-pet-sprite-defaults.ts diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts index f8d4f880f4b..096dd37e853 100644 --- a/src/main/ipc/pet-bundle.test.ts +++ b/src/main/ipc/pet-bundle.test.ts @@ -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', diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 1d231ebaa21..1b701a7a105 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -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 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 = { - 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( 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)) } } diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index 59311df5613..5e857bb1244 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -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 + ): Promise { + 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' + ) + }) }) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 2a6c0e13c73..8e4b0d60ea0 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -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.`) diff --git a/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx new file mode 100644 index 00000000000..1fb5819119a --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx @@ -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( + (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() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + 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') + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx new file mode 100644 index 00000000000..3467119ee18 --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx @@ -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( + (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() + 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()) + 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()) + + // 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) + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx new file mode 100644 index 00000000000..17fcdeb2cdc --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx @@ -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( + (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() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + 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---`: 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') + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 89fc81dc6da..a4b13c1d69b 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -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 -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 ( <> @@ -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({ 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): 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): void => { - if (!dragging) { - return - } - setPosition( - clampToViewport( - { - x: event.clientX - dragOffsetRef.current.x, - y: event.clientY - dragOffsetRef.current.y - }, - size - ) - ) - } - - const endDrag = (event: React.PointerEvent): 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 { >
{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. ) : detected ? ( - + ) : ( // 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 diff --git a/src/renderer/src/components/pet/pet-agent-state.test.ts b/src/renderer/src/components/pet/pet-agent-state.test.ts index 4c6d28b7135..09dc0813b5b 100644 --- a/src/renderer/src/components/pet/pet-agent-state.test.ts +++ b/src/renderer/src/components/pet/pet-agent-state.test.ts @@ -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 }) }) }) diff --git a/src/renderer/src/components/pet/pet-agent-state.ts b/src/renderer/src/components/pet/pet-agent-state.ts index f45d54bad35..26be938dd8d 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -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 +} diff --git a/src/renderer/src/components/pet/sprite-animation-css.test.ts b/src/renderer/src/components/pet/sprite-animation-css.test.ts new file mode 100644 index 00000000000..43f72c4afd2 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.test.ts @@ -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; }') + }) +}) diff --git a/src/renderer/src/components/pet/sprite-animation-css.ts b/src/renderer/src/components/pet/sprite-animation-css.ts new file mode 100644 index 00000000000..f04a8ecadf3 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.ts @@ -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 +} diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.test.ts b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts new file mode 100644 index 00000000000..e234c32986d --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts @@ -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['handlers'] + +function fakeTarget(): { + setPointerCapture: (id: number) => void + hasPointerCapture: (id: number) => boolean + releasePointerCapture: (id: number) => void +} { + const captured = new Set() + 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['handlers']['onPointerDown'] + >[0] + + const fire = (name: HandlerName, props: Parameters[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) + }) +}) diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.ts b/src/renderer/src/components/pet/usePetPointerInteraction.ts new file mode 100644 index 00000000000..9a3afd29d39 --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.ts @@ -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) => void + onPointerMove: (event: ReactPointerEvent) => void + onPointerUp: (event: ReactPointerEvent) => void + onPointerCancel: (event: ReactPointerEvent) => void + onLostPointerCapture: (event: ReactPointerEvent) => 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(null) + const [hovering, setHovering] = useState(false) + const [dragGeneration, setDragGeneration] = useState(0) + const dragOffsetRef = useRef({ 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(null) + const activePointerRef = useRef(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): 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): 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): 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) + } + } +} diff --git a/src/renderer/src/components/pet/usePetUrl.ts b/src/renderer/src/components/pet/usePetUrl.ts index 60c5dbef9d7..efc9fc05568 100644 --- a/src/renderer/src/components/pet/usePetUrl.ts +++ b/src/renderer/src/components/pet/usePetUrl.ts @@ -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) { diff --git a/src/shared/codex-pet-sprite-defaults.test.ts b/src/shared/codex-pet-sprite-defaults.test.ts new file mode 100644 index 00000000000..efe6521acd4 --- /dev/null +++ b/src/shared/codex-pet-sprite-defaults.test.ts @@ -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) + }) +}) diff --git a/src/shared/codex-pet-sprite-defaults.ts b/src/shared/codex-pet-sprite-defaults.ts new file mode 100644 index 00000000000..b33625d6202 --- /dev/null +++ b/src/shared/codex-pet-sprite-defaults.ts @@ -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 = { + // 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 { + 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 + +/** 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 } } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 36297481741..b54496da29e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = {