openpetswithchatandmcp/apps/desktop/src/pet-motion-engine.ts
2026-06-10 08:16:38 +02:00

128 lines
5.7 KiB
TypeScript

import { BrowserWindow, screen } from "electron";
import { clampToVisibleWorkArea, defaultPetWindowSize, type Point } from "./display.js";
import { isPetWindowDragging } from "./pet-window.js";
/**
* Liveness motion primitives (§13.6): animated absolute moves, continuous
* cursor following with lag, and lightweight gravity/bounce physics. Operates
* on any pet window through an accessor (windows can be recreated), one loop
* per surface, paused while hidden or being dragged.
*/
export type WindowAccessor = () => BrowserWindow | null;
type MotionState = {
follow: { lag: number } | null;
physics: { gravity: boolean; bounce: number; vy: number } | null;
loop: NodeJS.Timeout | null;
moveGeneration: number;
};
const motionStates = new Map<string, { accessor: WindowAccessor; state: MotionState }>();
const loopIntervalMs = 50;
function stateFor(petHandleId: string, accessor: WindowAccessor): MotionState {
let entry = motionStates.get(petHandleId);
if (!entry) {
entry = { accessor, state: { follow: null, physics: null, loop: null, moveGeneration: 0 } };
motionStates.set(petHandleId, entry);
}
entry.accessor = accessor;
return entry.state;
}
function easeProgress(t: number, easing: string): number {
if (easing === "linear") return t;
if (easing === "ease-in") return t * t;
if (easing === "ease-out") return 1 - (1 - t) * (1 - t);
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; // ease-in-out
}
export async function motionMoveTo(petHandleId: string, accessor: WindowAccessor, target: Point, opts: { durationMs?: number; easing?: string } = {}): Promise<void> {
const state = stateFor(petHandleId, accessor);
const window = accessor();
if (!window || window.isDestroyed()) return;
const generation = ++state.moveGeneration;
const durationMs = Math.min(Math.max(opts.durationMs ?? 700, 100), 10_000);
const easing = opts.easing ?? "ease-in-out";
const [startX, startY] = window.getPosition();
const clamped = clampToVisibleWorkArea(target, defaultPetWindowSize);
const steps = Math.max(4, Math.round(durationMs / 33));
for (let step = 1; step <= steps; step += 1) {
const live = accessor();
if (!live || live.isDestroyed() || state.moveGeneration !== generation || isPetWindowDragging(live)) return;
const t = easeProgress(step / steps, easing);
live.setPosition(Math.round(startX + (clamped.x - startX) * t), Math.round(startY + (clamped.y - startY) * t), false);
await delay(durationMs / steps);
}
}
export function motionSetFollowCursor(petHandleId: string, accessor: WindowAccessor, opts: { enabled: boolean; lag?: number }): void {
const state = stateFor(petHandleId, accessor);
state.follow = opts.enabled ? { lag: Math.min(Math.max(opts.lag ?? 0.85, 0), 1) } : null;
syncLoop(petHandleId, accessor, state);
}
export function motionSetPhysics(petHandleId: string, accessor: WindowAccessor, opts: { gravity?: boolean; bounce?: number }): void {
const state = stateFor(petHandleId, accessor);
state.physics = opts.gravity ? { gravity: true, bounce: Math.min(Math.max(opts.bounce ?? 0.4, 0), 1), vy: 0 } : null;
syncLoop(petHandleId, accessor, state);
}
export function motionStop(petHandleId: string): void {
const entry = motionStates.get(petHandleId);
if (!entry) return;
entry.state.follow = null;
entry.state.physics = null;
entry.state.moveGeneration += 1;
if (entry.state.loop) { clearInterval(entry.state.loop); entry.state.loop = null; }
}
export function motionStopAll(): void {
for (const petHandleId of motionStates.keys()) motionStop(petHandleId);
}
function syncLoop(petHandleId: string, accessor: WindowAccessor, state: MotionState): void {
const wantsLoop = state.follow !== null || state.physics !== null;
if (!wantsLoop && state.loop) { clearInterval(state.loop); state.loop = null; return; }
if (!wantsLoop || state.loop) return;
state.loop = setInterval(() => {
const window = accessor();
if (!window || window.isDestroyed()) { motionStop(petHandleId); return; }
if (!window.isVisible() || isPetWindowDragging(window)) return;
const [x, y] = window.getPosition();
let nextX = x;
let nextY = y;
if (state.follow) {
const cursor = screen.getCursorScreenPoint();
// Aim the pet's bottom-center near the cursor; lag controls smoothing.
const targetX = cursor.x - Math.round(defaultPetWindowSize.width / 2);
const targetY = cursor.y - Math.round(defaultPetWindowSize.height * 0.7);
const smoothing = 1 - state.follow.lag;
nextX = Math.round(x + (targetX - x) * Math.max(0.02, smoothing * 0.35));
nextY = Math.round(y + (targetY - y) * Math.max(0.02, smoothing * 0.35));
}
if (state.physics?.gravity) {
const display = screen.getDisplayNearestPoint({ x: x + Math.round(defaultPetWindowSize.width / 2), y: y + Math.round(defaultPetWindowSize.height / 2) });
const floor = display.workArea.y + display.workArea.height - defaultPetWindowSize.height;
state.physics.vy = Math.min(state.physics.vy + 2.2, 48);
nextY = y + Math.round(state.physics.vy);
if (nextY >= floor) {
nextY = floor;
if (Math.abs(state.physics.vy) > 6 && state.physics.bounce > 0) state.physics.vy = -state.physics.vy * state.physics.bounce;
else state.physics.vy = 0;
}
}
if (nextX !== x || nextY !== y) {
// Under gravity the y motion is bounded by the floor above; only clamp x.
const clamped = clampToVisibleWorkArea({ x: nextX, y: nextY }, defaultPetWindowSize);
window.setPosition(clamped.x, state.physics?.gravity ? nextY : clamped.y, false);
}
}, loopIntervalMs);
state.loop.unref?.();
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}