diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index 7154701b..3a89753e 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -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, diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index 98e07cbb..ccd542fc 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -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([ ELECTRON_IPC_CHANNELS.petsDragWindow, ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents, ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, + ELECTRON_IPC_CHANNELS.petsFocusMainWindow, ELECTRON_IPC_CHANNELS.petsFocusSession, ]) diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 2928c356..f9c9d7c6 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -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', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index fb91e387..6422c625 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -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) => diff --git a/desktop/electron/pet-preload.ts b/desktop/electron/pet-preload.ts index ffea38f6..4e4a1a0b 100644 --- a/desktop/electron/pet-preload.ts +++ b/desktop/electron/pet-preload.ts @@ -48,6 +48,7 @@ const petHost = { invoke(ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents, ignore), setInteractiveRegions: (regions: Array<{ x: number, y: number, width: number, height: number }>) => invoke(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, regions), + focusMainWindow: () => invoke(ELECTRON_IPC_CHANNELS.petsFocusMainWindow), focusSession: (sessionId: string) => invoke(ELECTRON_IPC_CHANNELS.petsFocusSession, sessionId), }, diff --git a/desktop/electron/services/petWindow.test.ts b/desktop/electron/services/petWindow.test.ts index c2ba26b8..25e7e659 100644 --- a/desktop/electron/services/petWindow.test.ts +++ b/desktop/electron/services/petWindow.test.ts @@ -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 | 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 { diff --git a/desktop/electron/services/petWindow.ts b/desktop/electron/services/petWindow.ts index 7a7fc20e..fe96aae4 100644 --- a/desktop/electron/services/petWindow.ts +++ b/desktop/electron/services/petWindow.ts @@ -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 | 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 { 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 diff --git a/desktop/src/features/pets/PetApp.test.tsx b/desktop/src/features/pets/PetApp.test.tsx index 56b64915..9c3ae68b 100644 --- a/desktop/src/features/pets/PetApp.test.tsx +++ b/desktop/src/features/pets/PetApp.test.tsx @@ -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() 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) diff --git a/desktop/src/features/pets/PetApp.tsx b/desktop/src/features/pets/PetApp.tsx index ee0c5872..d52854bc 100644 --- a/desktop/src/features/pets/PetApp.tsx +++ b/desktop/src/features/pets/PetApp.tsx @@ -494,6 +494,7 @@ export function PetApp() { } return } + void getDesktopHost().pets.focusMainWindow().catch(() => undefined) playTransient('waving') }} onContextMenu={(event) => { diff --git a/desktop/src/features/pets/PetRenderer.test.tsx b/desktop/src/features/pets/PetRenderer.test.tsx index 48c3464a..6c1f518c 100644 --- a/desktop/src/features/pets/PetRenderer.test.tsx +++ b/desktop/src/features/pets/PetRenderer.test.tsx @@ -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( + commits.push(phase)}> + + , + ) + + 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() @@ -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() + + 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') }) diff --git a/desktop/src/features/pets/PetRenderer.tsx b/desktop/src/features/pets/PetRenderer.tsx index d9a8dcff..e9ed583f 100644 --- a/desktop/src/features/pets/PetRenderer.tsx +++ b/desktop/src/features/pets/PetRenderer.tsx @@ -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(() => ({ - 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 | 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 + stageRef: React.RefObject +}) { + 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 | 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(null) + const stageRef = useRef(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 (
{ 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) }) diff --git a/desktop/src/features/pets/petAnimation.ts b/desktop/src/features/pets/petAnimation.ts index 087268f7..14717cd9 100644 --- a/desktop/src/features/pets/petAnimation.ts +++ b/desktop/src/features/pets/petAnimation.ts @@ -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() + +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, } } diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index 305cacd7..d348ca3e 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -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') }, diff --git a/desktop/src/lib/desktopHost/contract.test.ts b/desktop/src/lib/desktopHost/contract.test.ts index 02c5b9b0..c517c59a 100644 --- a/desktop/src/lib/desktopHost/contract.test.ts +++ b/desktop/src/lib/desktopHost/contract.test.ts @@ -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)) }) diff --git a/desktop/src/lib/desktopHost/electronHost.test.ts b/desktop/src/lib/desktopHost/electronHost.test.ts index d8972a70..5be436c5 100644 --- a/desktop/src/lib/desktopHost/electronHost.test.ts +++ b/desktop/src/lib/desktopHost/electronHost.test.ts @@ -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) }) diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index fa8ecea2..a9a28d94 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -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), diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index 4c236e5a..cc50641e 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -236,6 +236,7 @@ export type DesktopHost = { dragWindow(payload: DesktopPetWindowDrag): Promise setIgnoreMouseEvents(ignore: boolean): Promise setInteractiveRegions(regions: DesktopPetInteractiveRegion[]): Promise + focusMainWindow(): Promise focusSession(sessionId: string): Promise onNavigateSession(handler: (sessionId: string) => void): Promise onVisibilityChanged(handler: (visible: boolean) => void): Promise