feat(desktop): improve pet motion and interactions

This commit is contained in:
程序员阿江(Relakkes)
2026-07-23 03:03:05 +08:00
parent ca0342d252
commit 2240a07088
18 changed files with 418 additions and 131 deletions
@@ -115,6 +115,8 @@ describe('Electron IPC capabilities', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, [
{ x: -1, y: 0, width: 20, height: 20 },
])).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusMainWindow, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusMainWindow, {})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, 'session-123')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, '../escape')).toBe(false)
})
@@ -138,6 +140,9 @@ describe('Electron IPC capabilities', () => {
ELECTRON_IPC_CHANNELS.petsDragWindow,
)).toBe(true)
expect(isElectronIpcChannelAllowedForPetWindow(ELECTRON_IPC_CHANNELS.petsFocusSession)).toBe(true)
expect(isElectronIpcChannelAllowedForPetWindow(
ELECTRON_IPC_CHANNELS.petsFocusMainWindow,
)).toBe(true)
for (const forbidden of [
ELECTRON_IPC_CHANNELS.commandInvoke,
+2
View File
@@ -151,6 +151,7 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.petsDragWindow]: petWindowDrag,
[ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents]: booleanPayload,
[ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions]: petInteractiveRegions,
[ELECTRON_IPC_CHANNELS.petsFocusMainWindow]: noPayload,
[ELECTRON_IPC_CHANNELS.petsFocusSession]: sessionIdPayload,
[ELECTRON_IPC_CHANNELS.dialogOpen]: optionalRecord,
[ELECTRON_IPC_CHANNELS.dialogSave]: optionalRecord,
@@ -205,6 +206,7 @@ const petWindowChannels = new Set<ElectronIpcChannel>([
ELECTRON_IPC_CHANNELS.petsDragWindow,
ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents,
ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions,
ELECTRON_IPC_CHANNELS.petsFocusMainWindow,
ELECTRON_IPC_CHANNELS.petsFocusSession,
])
+1
View File
@@ -19,6 +19,7 @@ export const ELECTRON_IPC_CHANNELS = {
petsDragWindow: 'desktop:pets:drag-window',
petsSetIgnoreMouseEvents: 'desktop:pets:set-ignore-mouse-events',
petsSetInteractiveRegions: 'desktop:pets:set-interactive-regions',
petsFocusMainWindow: 'desktop:pets:focus-main-window',
petsFocusSession: 'desktop:pets:focus-session',
dialogOpen: 'desktop:dialog:open',
dialogSave: 'desktop:dialog:save',
+6
View File
@@ -461,6 +461,12 @@ function registerIpcHandlers() {
payload as Electron.Rectangle[],
)
})
registerHandler(ELECTRON_IPC_CHANNELS.petsFocusMainWindow, (event) => {
if (!getPetWindowController().owns(currentWindow(event))) {
throw new Error('Pet window IPC sender does not own the companion window')
}
showMainWindow(mainWindow, app)
})
registerHandler(ELECTRON_IPC_CHANNELS.petsFocusSession, (_event, payload) =>
focusPetSession(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.dialogOpen, (event, payload) =>
+1
View File
@@ -48,6 +48,7 @@ const petHost = {
invoke<void>(ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents, ignore),
setInteractiveRegions: (regions: Array<{ x: number, y: number, width: number, height: number }>) =>
invoke<void>(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, regions),
focusMainWindow: () => invoke<void>(ELECTRON_IPC_CHANNELS.petsFocusMainWindow),
focusSession: (sessionId: string) =>
invoke<void>(ELECTRON_IPC_CHANNELS.petsFocusSession, sessionId),
},
@@ -161,6 +161,16 @@ describe('Electron pet window service', () => {
)
})
it('lets only the owned pet window focus the main desktop window', () => {
expect(mainSource).toContain(
'registerHandler(ELECTRON_IPC_CHANNELS.petsFocusMainWindow, (event)',
)
expect(mainSource).toContain(
'if (!getPetWindowController().owns(currentWindow(event)))',
)
expect(mainSource).toContain('showMainWindow(mainWindow, app)')
})
it('routes the native context menu through the sender-owned pet controller', () => {
expect(mainSource).toContain(
'registerHandler(ELECTRON_IPC_CHANNELS.petsShowContextMenu, (event, payload)',
@@ -289,6 +299,72 @@ describe('Electron pet window service', () => {
})
})
it.each([
['left', { x: -100, y: 220 }, { x: -136, y: 160 }],
['right', { x: 1_000, y: 220 }, { x: 552, y: 160 }],
] as const)(
'lets the mascot reach the %s display edge through transparent window padding',
async (_edge, pointerEnd, expectedPosition) => {
const petWindow = createFakeWindow({
x: 100,
y: 120,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [
{ x: 136, y: 240, width: 112, height: 128 },
])
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
controller.dragWindow(petWindow as never, { phase: 'end', ...pointerEnd })
expect(petWindow.setPosition).toHaveBeenLastCalledWith(
expectedPosition.x,
expectedPosition.y,
false,
)
},
)
it('restores an edge position after the renderer reports the visible mascot region', async () => {
let petWindow: ReturnType<typeof createFakeWindow> | undefined
const createWindow = vi.fn((bounds) => {
petWindow = createFakeWindow(bounds as {
x: number
y: number
width: number
height: number
})
return petWindow
})
const controller = new PetWindowController({
createWindow: createWindow as never,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
readPosition: () => ({ x: -136, y: 160 }),
})
await controller.show()
expect(createWindow).toHaveBeenCalledWith(expect.objectContaining({ x: 0, y: 160 }))
controller.setInteractiveRegions(petWindow as never, [
{ x: 136, y: 240, width: 112, height: 128 },
])
expect(petWindow?.setPosition).toHaveBeenLastCalledWith(-136, 160, false)
})
it('tracks the native cursor at 60 Hz without renderer move payloads', async () => {
vi.useFakeTimers()
try {
+65 -8
View File
@@ -118,15 +118,31 @@ export function writePetWindowPosition(
export function clampPetWindowPosition(
position: PetWindowPosition,
workArea: Rectangle,
visibleRegion: Rectangle = {
x: 0,
y: 0,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
},
): PetWindowPosition {
const maxX = workArea.x + Math.max(0, workArea.width - PET_WINDOW_WIDTH)
const maxY = workArea.y + Math.max(0, workArea.height - PET_WINDOW_HEIGHT)
const minX = workArea.x - visibleRegion.x
const minY = workArea.y - visibleRegion.y
const maxX = minX + Math.max(0, workArea.width - visibleRegion.width)
const maxY = minY + Math.max(0, workArea.height - visibleRegion.height)
return {
x: Math.min(Math.max(Math.round(position.x), workArea.x), maxX),
y: Math.min(Math.max(Math.round(position.y), workArea.y), maxY),
x: Math.min(Math.max(Math.round(position.x), minX), maxX),
y: Math.min(Math.max(Math.round(position.y), minY), maxY),
}
}
function normalizePetWindowRegion(region: Rectangle): Rectangle {
const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, Math.round(region.x)))
const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, Math.round(region.y)))
const right = Math.max(x + 1, Math.min(PET_WINDOW_WIDTH, Math.round(region.x + region.width)))
const bottom = Math.max(y + 1, Math.min(PET_WINDOW_HEIGHT, Math.round(region.y + region.height)))
return { x, y, width: right - x, height: bottom - y }
}
export function getPetWindowBounds(
workArea: Rectangle,
restoredPosition?: PetWindowPosition | null,
@@ -221,6 +237,8 @@ export class PetWindowController {
lastPosition: PetWindowPosition
} | null = null
private dragTimer: ReturnType<typeof setInterval> | null = null
private visibleDragRegion: Rectangle | null = null
private pendingRestoredPosition: PetWindowPosition | null = null
private readonly options: PetWindowControllerOptions
constructor(options: PetWindowControllerOptions) {
@@ -229,6 +247,8 @@ export class PetWindowController {
private async create(): Promise<PetWindow> {
const restoredPosition = this.options.readPosition?.() ?? null
this.visibleDragRegion = null
this.pendingRestoredPosition = restoredPosition
const currentWorkArea = restoredPosition && this.options.getWorkAreaForPoint
? this.options.getWorkAreaForPoint({
x: restoredPosition.x + Math.floor(PET_WINDOW_WIDTH / 2),
@@ -243,7 +263,11 @@ export class PetWindowController {
this.window = window
window.on('closed', () => {
this.finishDrag(window)
if (this.window === window) this.window = null
if (this.window === window) {
this.window = null
this.visibleDragRegion = null
this.pendingRestoredPosition = null
}
})
try {
@@ -253,7 +277,11 @@ export class PetWindowController {
return window
} catch (error) {
if (!window.isDestroyed()) window.destroy()
if (this.window === window) this.window = null
if (this.window === window) {
this.window = null
this.visibleDragRegion = null
this.pendingRestoredPosition = null
}
throw error
}
}
@@ -296,6 +324,8 @@ export class PetWindowController {
}
window.destroy()
this.window = null
this.visibleDragRegion = null
this.pendingRestoredPosition = null
}
owns(window: PetWindow | null): boolean {
@@ -314,7 +344,30 @@ export class PetWindowController {
if (!this.owns(window)) {
throw new Error('Pet window IPC sender does not own the companion window')
}
if ((this.options.platform ?? process.platform) === 'darwin') return
const platform = this.options.platform ?? process.platform
const primaryRegion = regions[0]
if (platform === 'darwin' && primaryRegion) {
this.visibleDragRegion = normalizePetWindowRegion(primaryRegion)
const requestedPosition = this.pendingRestoredPosition ?? window.getBounds()
this.pendingRestoredPosition = null
const anchor = {
x: requestedPosition.x + this.visibleDragRegion.x + Math.floor(this.visibleDragRegion.width / 2),
y: requestedPosition.y + this.visibleDragRegion.y + Math.floor(this.visibleDragRegion.height / 2),
}
const workArea = this.options.getWorkAreaForPoint?.(anchor)
?? this.options.getCurrentWorkArea()
const nextPosition = clampPetWindowPosition(
requestedPosition,
workArea,
this.visibleDragRegion,
)
const bounds = window.getBounds()
if (nextPosition.x !== bounds.x || nextPosition.y !== bounds.y) {
window.setPosition(nextPosition.x, nextPosition.y, false)
}
}
if (platform === 'darwin') return
const shape = regions.flatMap((region) => {
const requestedLeft = Math.round(region.x) - PET_WINDOW_SHAPE_PADDING
@@ -400,7 +453,11 @@ export class PetWindowController {
}
const workArea = this.options.getWorkAreaForPoint?.(pointer)
?? this.options.getCurrentWorkArea()
const nextPosition = clampPetWindowPosition(requestedPosition, workArea)
const nextPosition = clampPetWindowPosition(
requestedPosition,
workArea,
this.visibleDragRegion ?? undefined,
)
if (
nextPosition.x === drag.lastPosition.x
&& nextPosition.y === drag.lastPosition.y
+6 -1
View File
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
updatePetPreferences: vi.fn(),
initializeDesktopServerUrl: vi.fn(),
focusSession: vi.fn(),
focusMainWindow: vi.fn(),
hidePet: vi.fn(),
showContextMenu: vi.fn(),
dragWindow: vi.fn(),
@@ -49,6 +50,7 @@ vi.mock('../../lib/desktopHost', () => ({
pets: {
list: vi.fn(async () => ({ pets: [], errors: [] })),
focusSession: mocks.focusSession,
focusMainWindow: mocks.focusMainWindow,
hide: mocks.hidePet,
showContextMenu: mocks.showContextMenu,
dragWindow: mocks.dragWindow,
@@ -143,6 +145,7 @@ describe('PetApp', () => {
mocks.fetchSessions.mockResolvedValue(undefined)
mocks.showContextMenu.mockResolvedValue(true)
mocks.dragWindow.mockResolvedValue(undefined)
mocks.focusMainWindow.mockResolvedValue(undefined)
mocks.getChatStatus.mockImplementation(async (sessionId: string) => ({
state: sessionId === 'session-running' ? 'thinking' : 'idle',
activityState: sessionId === 'session-running' ? 'running' : 'idle',
@@ -304,7 +307,7 @@ describe('PetApp', () => {
})
})
it('keeps a short mascot pointer gesture as a normal pet interaction', async () => {
it('focuses the main desktop window after a short mascot pointer gesture', async () => {
render(<PetApp />)
const mascot = await screen.findByRole('button', { name: 'pet.window.interact' })
mascot.setPointerCapture = vi.fn()
@@ -335,6 +338,7 @@ describe('PetApp', () => {
fireEvent.click(mascot)
expect(mocks.dragWindow).not.toHaveBeenCalled()
expect(mocks.focusMainWindow).toHaveBeenCalledTimes(1)
expect(mocks.updatePetPreferences).not.toHaveBeenCalledWith({ collapsed: true })
expect(mascot.querySelector('[data-pet-state="waving"]')).toBeInTheDocument()
expect(mascot.setPointerCapture).toHaveBeenCalledWith(7)
@@ -388,6 +392,7 @@ describe('PetApp', () => {
[{ phase: 'end', x: 530, y: 440 }],
]))
expect(mascot).toHaveAttribute('data-dragging', 'false')
expect(mocks.focusMainWindow).not.toHaveBeenCalled()
expect(mocks.updatePetPreferences).not.toHaveBeenCalledWith({ collapsed: true })
expect(mascot.setPointerCapture).toHaveBeenCalledWith(9)
expect(mascot.releasePointerCapture).toHaveBeenCalledWith(9)
+1
View File
@@ -494,6 +494,7 @@ export function PetApp() {
}
return
}
void getDesktopHost().pets.focusMainWindow().catch(() => undefined)
playTransient('waving')
}}
onContextMenu={(event) => {
+38 -3
View File
@@ -1,3 +1,4 @@
import { Profiler } from 'react'
import { act, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -130,6 +131,23 @@ describe('PetRenderer', () => {
expect(document.querySelector('[data-pet-frame-transition="previous"]')).not.toBeInTheDocument()
})
it('updates atlas frames without committing a React render for every frame', () => {
vi.useFakeTimers()
const commits: string[] = []
render(
<Profiler id="pet" onRender={(_id, phase) => commits.push(phase)}>
<PetRenderer pet={customPet} state="waving" size={96} motionEnabled />
</Profiler>,
)
const pet = screen.getByRole('img', { name: 'Mochi' })
expect(commits).toHaveLength(1)
act(() => vi.advanceTimersByTime(140))
expect(pet).toHaveAttribute('data-pet-column', '1')
expect(commits).toHaveLength(1)
})
it('changes rows immediately when the requested state changes', () => {
vi.useFakeTimers()
const { rerender } = render(
@@ -144,7 +162,7 @@ describe('PetRenderer', () => {
expect(pet).toHaveAttribute('data-pet-column', '0')
})
it('plays an active row three times, then remains in the slow idle loop', () => {
it('plays an active row three times, rests, then starts another work burst', () => {
vi.useFakeTimers()
render(<PetRenderer pet={builtinPet} state="running" size={96} motionEnabled />)
@@ -159,8 +177,25 @@ describe('PetRenderer', () => {
expect(pet).toHaveAttribute('data-pet-column', '0')
advanceTimersByDurations([1680, 660, 660, 840, 840, 1920])
expect(pet).toHaveAttribute('data-pet-motion-state', 'idle')
expect(pet).toHaveAttribute('data-pet-row', '0')
expect(pet).toHaveAttribute('data-pet-motion-state', 'running')
expect(pet).toHaveAttribute('data-pet-row', '7')
expect(pet).toHaveAttribute('data-pet-column', '0')
})
it('adds a calm gesture after two slow idle loops', () => {
vi.useFakeTimers()
render(<PetRenderer pet={builtinPet} state="idle" size={96} motionEnabled />)
const pet = screen.getByRole('img', { name: 'Dada' })
advanceTimersByDurations([
1680, 660, 660, 840, 840, 1920,
1680, 660, 660, 840, 840, 1920,
])
expect(pet).toHaveAttribute('data-pet-state', 'idle')
expect(pet).toHaveAttribute('data-pet-motion-state', 'waving')
expect(pet).toHaveAttribute('data-pet-motion-phase', 'action')
expect(pet).toHaveAttribute('data-pet-row', '3')
expect(pet).toHaveAttribute('data-pet-column', '0')
})
+130 -78
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useState, type CSSProperties } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import {
PET_ATLAS_V2,
getPetAnimationPlaybackStep,
@@ -67,79 +67,6 @@ function usePrefersReducedMotion() {
return prefersReducedMotion
}
type PetPlayback = Readonly<{
renderedState: PetAnimationState
playbackIndex: number
}>
function usePetPlayback(
requestedState: PetAnimationState,
motionEnabled: boolean,
lookDirection: PetLookDirection | null | undefined,
) {
const [playback, setPlayback] = useState<PetPlayback>(() => ({
renderedState: requestedState,
playbackIndex: 0,
}))
useLayoutEffect(() => {
if (!motionEnabled) {
setPlayback((current) => current.renderedState === requestedState && current.playbackIndex === 0
? current
: { renderedState: requestedState, playbackIndex: 0 })
return
}
if (requestedState === 'idle' && lookDirection !== undefined) {
setPlayback((current) => current.renderedState === requestedState && current.playbackIndex === 0
? current
: { renderedState: requestedState, playbackIndex: 0 })
return
}
const startedAt = performance.now()
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const updateFrame = () => {
if (cancelled) return
const tick = getPetAnimationPlaybackTickAtElapsedMs(
requestedState,
Math.max(0, performance.now() - startedAt),
)
setPlayback((current) => (
current.renderedState === requestedState
&& current.playbackIndex === tick.playbackIndex
) ? current : {
renderedState: requestedState,
playbackIndex: tick.playbackIndex,
})
timer = setTimeout(updateFrame, Math.max(1, Math.ceil(tick.remainingDurationMs)))
}
updateFrame()
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [lookDirection, motionEnabled, requestedState])
const renderedState = motionEnabled ? playback.renderedState : requestedState
const playbackIndex = motionEnabled ? playback.playbackIndex : 0
const step = getPetAnimationPlaybackStep(renderedState, playbackIndex)
const showingCursorPose = motionEnabled
&& renderedState === 'idle'
&& requestedState === 'idle'
&& lookDirection !== undefined
const frame = showingCursorPose ? getPetLookFrame(lookDirection) : step.frame
return {
frame,
renderedState,
motionState: step.phase === 'action' ? renderedState : 'idle',
phase: showingCursorPose ? 'gaze' : step.phase,
}
}
type AtlasVisual = Readonly<{
atlasUrl: string
frame: PetAtlasFrame
@@ -202,11 +129,121 @@ function getAtlasBackgroundStyle({
backgroundImage: `url(${JSON.stringify(atlasUrl)})`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${size * PET_ATLAS_V2.columns}px ${height * PET_ATLAS_V2.rows}px`,
backgroundPosition: `${-frame.columnIndex * size + offsetX}px ${-frame.rowIndex * height + offsetY}px`,
backgroundPosition: getAtlasBackgroundPosition(frame, offsetX, offsetY, size, height),
imageRendering: pixelated ? 'pixelated' : 'auto',
}
}
function getAtlasBackgroundPosition(
frame: PetAtlasFrame,
offsetX: number,
offsetY: number,
size: number,
height: number,
) {
return `${-frame.columnIndex * size + offsetX}px ${-frame.rowIndex * height + offsetY}px`
}
type PetPlaybackVisual = Readonly<{
frame: PetAtlasFrame
motionState: PetAnimationState
phase: 'action' | 'idle' | 'gaze'
}>
function getInitialPetPlaybackVisual(
requestedState: PetAnimationState,
motionEnabled: boolean,
lookDirection: PetLookDirection | null | undefined,
): PetPlaybackVisual {
if (motionEnabled && requestedState === 'idle' && lookDirection !== undefined) {
return {
frame: getPetLookFrame(lookDirection),
motionState: 'idle',
phase: 'gaze',
}
}
const step = getPetAnimationPlaybackStep(requestedState, 0)
return {
frame: step.frame,
motionState: step.motionState,
phase: step.phase,
}
}
function usePetPlayback({
requestedState,
motionEnabled,
lookDirection,
usesAtlas,
petId,
size,
height,
spriteRef,
stageRef,
}: {
requestedState: PetAnimationState
motionEnabled: boolean
lookDirection: PetLookDirection | null | undefined
usesAtlas: boolean
petId: string
size: number
height: number
spriteRef: React.RefObject<HTMLDivElement>
stageRef: React.RefObject<HTMLDivElement>
}) {
useLayoutEffect(() => {
const sprite = spriteRef.current
const stage = stageRef.current
if (!sprite || !stage) return
const applyVisual = ({ frame, motionState, phase }: PetPlaybackVisual) => {
sprite.dataset.petMotionState = motionState
sprite.dataset.petMotionPhase = phase
stage.dataset.petMotionState = motionState
if (!usesAtlas) return
const { offsetX, offsetY } = getPetFrameOffset(petId, frame, size, height)
sprite.dataset.petRow = String(frame.rowIndex)
sprite.dataset.petColumn = String(frame.columnIndex)
sprite.style.backgroundPosition = getAtlasBackgroundPosition(
frame,
offsetX,
offsetY,
size,
height,
)
}
const initialVisual = getInitialPetPlaybackVisual(requestedState, motionEnabled, lookDirection)
applyVisual(initialVisual)
if (!motionEnabled || (requestedState === 'idle' && lookDirection !== undefined)) return
const startedAt = performance.now()
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const updateFrame = () => {
if (cancelled) return
const tick = getPetAnimationPlaybackTickAtElapsedMs(
requestedState,
Math.max(0, performance.now() - startedAt),
)
applyVisual({
frame: tick.frame,
motionState: tick.motionState,
phase: tick.phase,
})
timer = setTimeout(updateFrame, Math.max(1, Math.ceil(tick.remainingDurationMs)))
}
updateFrame()
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [height, lookDirection, motionEnabled, petId, requestedState, size, spriteRef, stageRef, usesAtlas])
}
export function PetRenderer({
pet,
state,
@@ -219,12 +256,25 @@ export function PetRenderer({
const effectiveMotionEnabled = motionEnabled && !prefersReducedMotion
const atlasUrl = pet.source === 'custom' ? pet.dataUrl : pet.spritesheetUrl
const usesAtlas = Number(pet.spriteVersionNumber) >= PET_ATLAS_V2.spriteVersionNumber
const playback = usePetPlayback(
const height = size * PET_ATLAS_V2.cellHeight / PET_ATLAS_V2.cellWidth
const spriteRef = useRef<HTMLDivElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const playback = getInitialPetPlaybackVisual(
state,
effectiveMotionEnabled,
lookDirection,
)
const height = size * PET_ATLAS_V2.cellHeight / PET_ATLAS_V2.cellWidth
usePetPlayback({
requestedState: state,
motionEnabled: effectiveMotionEnabled,
lookDirection,
usesAtlas,
petId: pet.id,
size,
height,
spriteRef,
stageRef,
})
const pixelated = pet.source === 'custom' && usesAtlas
const frameOffset = getPetFrameOffset(pet.id, playback.frame, size, height)
const currentVisual = {
@@ -248,18 +298,20 @@ export function PetRenderer({
return (
<div
ref={stageRef}
className={`pet-sprite-stage shrink-0 ${usesAtlas ? 'pet-sprite-stage--atlas' : 'pet-sprite-stage--single'} ${className}`}
data-pet-motion={effectiveMotionEnabled ? 'enabled' : 'disabled'}
data-pet-motion-state={playback.motionState}
style={{ width: size, height }}
>
<div
ref={spriteRef}
role="img"
aria-label={pet.displayName}
className="pet-sprite"
data-pet-source={pet.source}
data-pet-state={state}
data-pet-rendered-state={playback.renderedState}
data-pet-rendered-state={state}
data-pet-motion-state={playback.motionState}
data-pet-motion-phase={playback.phase}
data-pet-motion={effectiveMotionEnabled ? 'enabled' : 'disabled'}
+21 -11
View File
@@ -98,18 +98,28 @@ describe('pet animation atlas contract', () => {
expect(() => getPetAnimationFrameAtElapsedMs('idle', -1)).toThrow(RangeError)
})
it('plays the recorded Codex idle row as one continuous six-times-slower loop', () => {
it('keeps the recorded Codex idle timing and inserts low-frequency ambient gestures', () => {
const playback = getPetAnimationPlaybackFrames('idle')
expect(PET_IDLE_DURATION_MULTIPLIER).toBe(6)
expect(playback).toHaveLength(6)
expect(playback.map((frame) => frame.columnIndex)).toEqual([0, 1, 2, 3, 4, 5])
expect(playback.map((frame) => frame.durationMs)).toEqual([1680, 660, 660, 840, 840, 1920])
expect(playback.slice(0, 12).map((frame) => frame.columnIndex)).toEqual([
0, 1, 2, 3, 4, 5,
0, 1, 2, 3, 4, 5,
])
expect(playback.slice(0, 6).map((frame) => frame.durationMs)).toEqual([
1680, 660, 660, 840, 840, 1920,
])
expect(playback[12]).toMatchObject({
rowIndex: 3,
columnIndex: 0,
durationMs: 140,
})
expect(getPetAnimationPlaybackFrames('idle')).toBe(playback)
expect(getPetAnimationPlaybackLoopStartIndex('idle')).toBe(0)
expect(getNextPetAnimationPlaybackIndex('idle', 5)).toBe(0)
expect(getNextPetAnimationPlaybackIndex('idle', playback.length - 1)).toBe(0)
})
it('plays three active cycles once, then loops only the slow idle tail', () => {
it('repeats three active cycles after each slow idle recovery loop', () => {
const runningFrames = getPetAnimationFrames('running')
const playback = getPetAnimationPlaybackFrames('running')
const loopStartIndex = runningFrames.length * 3
@@ -122,8 +132,8 @@ describe('pet animation atlas contract', () => {
columnIndex: 0,
durationMs: 1680,
})
expect(getPetAnimationPlaybackLoopStartIndex('running')).toBe(loopStartIndex)
expect(getNextPetAnimationPlaybackIndex('running', playback.length - 1)).toBe(loopStartIndex)
expect(getPetAnimationPlaybackLoopStartIndex('running')).toBe(0)
expect(getNextPetAnimationPlaybackIndex('running', playback.length - 1)).toBe(0)
expect(getPetAnimationPlaybackStep('running', runningFrames.length - 1)).toMatchObject({
phase: 'action',
cycleBoundaryAfter: true,
@@ -154,9 +164,9 @@ describe('pet animation atlas contract', () => {
remainingDurationMs: 1680,
})
expect(getPetAnimationPlaybackTickAtElapsedMs('running', idleTailStartMs + 6_600)).toMatchObject({
playbackIndex: 18,
phase: 'idle',
remainingDurationMs: 1680,
playbackIndex: 0,
phase: 'action',
remainingDurationMs: 120,
})
expect(() => getPetAnimationPlaybackTickAtElapsedMs('idle', -1)).toThrow(RangeError)
})
+57 -29
View File
@@ -57,12 +57,21 @@ export type PetAnimationFrame = PetAtlasFrame & Readonly<{
export const PET_ACTIVE_BURST_LOOPS = 3
export const PET_IDLE_DURATION_MULTIPLIER = 6
export const PET_AMBIENT_IDLE_LOOPS = 2
export const PET_AMBIENT_GESTURE_LOOPS = 2
export type PetAnimationPlaybackPhase = 'action' | 'idle'
export type PetAnimationPlaybackStep = Readonly<{
frame: PetAnimationFrame
phase: PetAnimationPlaybackPhase
motionState: PetAnimationState
cycleBoundaryAfter: boolean
}>
type PetAnimationPlaybackFrame = PetAnimationFrame & Readonly<{
phase: PetAnimationPlaybackPhase
motionState: PetAnimationState
cycleBoundaryAfter: boolean
}>
@@ -99,24 +108,53 @@ export function getPetAnimationFrames(state: PetAnimationState): readonly PetAni
}))
}
export function getPetAnimationPlaybackFrames(state: PetAnimationState): readonly PetAnimationFrame[] {
function getRepeatedPetAnimationFrames(
state: PetAnimationState,
loops: number,
phase: PetAnimationPlaybackPhase,
durationMultiplier = 1,
): readonly PetAnimationPlaybackFrame[] {
const frames = getPetAnimationFrames(state)
const idleSequence = getPetAnimationFrames('idle').map((frame) => ({
return Array.from({ length: loops }, () => frames.map((frame) => ({
...frame,
durationMs: frame.durationMs * PET_IDLE_DURATION_MULTIPLIER,
}))
if (state === 'idle') return idleSequence
return [
...Array.from({ length: PET_ACTIVE_BURST_LOOPS }, () => frames).flat(),
...idleSequence,
]
durationMs: frame.durationMs * durationMultiplier,
phase,
motionState: state,
cycleBoundaryAfter: frame.frameIndex === frames.length - 1,
}))).flat()
}
export function getPetAnimationPlaybackLoopStartIndex(state: PetAnimationState): number {
return state === 'idle'
? 0
: getPetAnimationFrames(state).length * PET_ACTIVE_BURST_LOOPS
const petAnimationPlaybackCache = new Map<PetAnimationState, readonly PetAnimationPlaybackFrame[]>()
export function getPetAnimationPlaybackFrames(state: PetAnimationState): readonly PetAnimationPlaybackFrame[] {
const cached = petAnimationPlaybackCache.get(state)
if (cached) return cached
const slowIdle = () => getRepeatedPetAnimationFrames(
'idle',
1,
'idle',
PET_IDLE_DURATION_MULTIPLIER,
)
const playback = state === 'idle'
? [
...Array.from({ length: PET_AMBIENT_IDLE_LOOPS }, slowIdle).flat(),
...getRepeatedPetAnimationFrames('waving', PET_AMBIENT_GESTURE_LOOPS, 'action'),
...Array.from({ length: PET_AMBIENT_IDLE_LOOPS }, slowIdle).flat(),
...getRepeatedPetAnimationFrames('jumping', PET_AMBIENT_GESTURE_LOOPS, 'action'),
]
: [
...getRepeatedPetAnimationFrames(state, PET_ACTIVE_BURST_LOOPS, 'action'),
...slowIdle(),
]
petAnimationPlaybackCache.set(state, playback)
return playback
}
export function getPetAnimationPlaybackLoopStartIndex(_state: PetAnimationState): number {
return 0
}
export function getNextPetAnimationPlaybackIndex(
@@ -144,22 +182,12 @@ export function getPetAnimationPlaybackStep(
const playback = getPetAnimationPlaybackFrames(state)
const normalizedIndex = playbackIndex % playback.length
const actionFrameCount = getPetAnimationPlaybackLoopStartIndex(state)
if (normalizedIndex < actionFrameCount) {
const actionCycleLength = getPetAnimationFrames(state).length
return {
frame: playback[normalizedIndex]!,
phase: 'action',
cycleBoundaryAfter: (normalizedIndex + 1) % actionCycleLength === 0,
}
}
const idleIndex = normalizedIndex - actionFrameCount
const frame = playback[normalizedIndex]!
return {
frame: playback[normalizedIndex]!,
phase: 'idle',
cycleBoundaryAfter: idleIndex === getPetAnimationFrames('idle').length - 1,
frame,
phase: frame.phase,
motionState: frame.motionState,
cycleBoundaryAfter: frame.cycleBoundaryAfter,
}
}
@@ -134,6 +134,9 @@ export const browserHost: DesktopHost = {
async setInteractiveRegions() {
unsupported('Changing companion pet interaction regions')
},
async focusMainWindow() {
unsupported('Focusing the main desktop window')
},
async focusSession() {
unsupported('Focusing a pet session')
},
@@ -48,6 +48,7 @@ describe('desktop host contract', () => {
await expect(browserHost.pets.dragWindow({ phase: 'start', x: 100, y: 100 })).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.setIgnoreMouseEvents(true)).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.setInteractiveRegions([{ x: 0, y: 0, width: 10, height: 10 }])).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.focusMainWindow()).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.focusSession('session-1')).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.onNavigateSession(vi.fn())).resolves.toEqual(expect.any(Function))
})
@@ -147,6 +147,7 @@ describe('electron desktop host', () => {
await host.pets.dragWindow({ phase: 'move', x: 640, y: 480 })
await host.pets.setIgnoreMouseEvents(true)
await host.pets.setInteractiveRegions([{ x: 100, y: 200, width: 120, height: 140 }])
await host.pets.focusMainWindow()
await host.pets.focusSession('session-123')
await host.pets.onNavigateSession(handler)
await host.pets.onVisibilityChanged(handler)
@@ -177,7 +178,8 @@ describe('electron desktop host', () => {
expect(invoke).toHaveBeenNthCalledWith(10, ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, [
{ x: 100, y: 200, width: 120, height: 140 },
])
expect(invoke).toHaveBeenNthCalledWith(11, ELECTRON_IPC_CHANNELS.petsFocusSession, 'session-123')
expect(invoke).toHaveBeenNthCalledWith(11, ELECTRON_IPC_CHANNELS.petsFocusMainWindow, undefined)
expect(invoke).toHaveBeenNthCalledWith(12, ELECTRON_IPC_CHANNELS.petsFocusSession, 'session-123')
expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.petNavigateSession, handler)
expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.petVisibilityChanged, handler)
})
@@ -114,6 +114,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
dragWindow: payload => invoke(ELECTRON_IPC_CHANNELS.petsDragWindow, payload),
setIgnoreMouseEvents: ignore => invoke(ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents, ignore),
setInteractiveRegions: regions => invoke(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, regions),
focusMainWindow: () => invoke(ELECTRON_IPC_CHANNELS.petsFocusMainWindow),
focusSession: sessionId => invoke(ELECTRON_IPC_CHANNELS.petsFocusSession, sessionId),
onNavigateSession: handler => subscribe(ELECTRON_EVENT_CHANNELS.petNavigateSession, handler),
onVisibilityChanged: handler => subscribe(ELECTRON_EVENT_CHANNELS.petVisibilityChanged, handler),
+1
View File
@@ -236,6 +236,7 @@ export type DesktopHost = {
dragWindow(payload: DesktopPetWindowDrag): Promise<void>
setIgnoreMouseEvents(ignore: boolean): Promise<void>
setInteractiveRegions(regions: DesktopPetInteractiveRegion[]): Promise<void>
focusMainWindow(): Promise<void>
focusSession(sessionId: string): Promise<void>
onNavigateSession(handler: (sessionId: string) => void): Promise<DesktopHostUnlisten>
onVisibilityChanged(handler: (visible: boolean) => void): Promise<DesktopHostUnlisten>