mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
feat(desktop): improve pet motion and interactions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -494,6 +494,7 @@ export function PetApp() {
|
||||
}
|
||||
return
|
||||
}
|
||||
void getDesktopHost().pets.focusMainWindow().catch(() => undefined)
|
||||
playTransient('waving')
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user