329 lines
15 KiB
TypeScript
329 lines
15 KiB
TypeScript
import { BrowserWindow } from "electron";
|
|
|
|
import { getAppStateSnapshot, type PetScaleValue } from "./app-state.js";
|
|
import { applyExternalPetReaction, applyExternalPetStatusReaction, getDefaultPetPaused, getDefaultPetWindowForPlugins, defaultPetBubbleArbiter } from "./default-pet-controller.js";
|
|
import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
|
|
import { builtInPet } from "./built-in-pet.js";
|
|
import { debug, info } from "./logger.js";
|
|
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
|
|
import { motionMoveTo, motionSetFollowCursor, motionSetPhysics, motionStop, type WindowAccessor } from "./pet-motion-engine.js";
|
|
import { createAgentPetWindow, isPetWindowDragging, loadExplicitPetContent, setPetReactionState, setPetSpriteOverride, setPetWindowScale, type PetPluginBubbles, type PetStatusBadgeReaction } from "./pet-window.js";
|
|
import { PetBubbleArbiter, type PetBubbleSink } from "./plugin-bubble-arbiter.js";
|
|
import { publishPluginPetEvent } from "./plugin-events-source.js";
|
|
import { resolveReactionSpriteState } from "./reaction-animation-mapping.js";
|
|
import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugin-sdk-bridge.js";
|
|
|
|
/**
|
|
* Multi-pet registry (§4): addressable pet handles for plugins. "default" is
|
|
* the user's default pet; plugin-spawned pets get their own windows, bubble
|
|
* arbiters, and liveness loops, and are torn down with their owning plugin.
|
|
*/
|
|
|
|
type SpawnedPet = {
|
|
readonly handleId: string;
|
|
readonly ownerPluginId: string;
|
|
readonly petId: string;
|
|
readonly name: string;
|
|
window: BrowserWindow | null;
|
|
readonly arbiter: PetBubbleArbiter;
|
|
bubbles: PetPluginBubbles;
|
|
statusReaction: PetStatusBadgeReaction | null;
|
|
currentAnimation: string;
|
|
spriteOverride: { filePath: string; fps: number; loop: boolean } | null;
|
|
scale: PetScaleValue;
|
|
reactionRevert: NodeJS.Timeout | null;
|
|
};
|
|
|
|
const spawnedPets = new Map<string, SpawnedPet>();
|
|
const changeListeners = new Set<(pets: PluginPetInfo[]) => void>();
|
|
const tickSubscribers = new Map<string, Set<(dtMs: number) => void>>();
|
|
const tickLoops = new Map<string, { timer: NodeJS.Timeout; lastAt: number }>();
|
|
const tickIntervalMs = 100;
|
|
let nextSpawnId = 0;
|
|
let defaultAnimation = "idle";
|
|
|
|
function windowAccessor(petHandleId: string): WindowAccessor {
|
|
if (petHandleId === "default") return getDefaultPetWindowForPlugins;
|
|
return () => {
|
|
const pet = spawnedPets.get(petHandleId);
|
|
return pet?.window && !pet.window.isDestroyed() ? pet.window : null;
|
|
};
|
|
}
|
|
|
|
function requireWindow(petHandleId: string): BrowserWindow {
|
|
const window = windowAccessor(petHandleId)();
|
|
if (!window) throw new Error(`Pet is not available: ${petHandleId}`);
|
|
return window;
|
|
}
|
|
|
|
function notifyChange(): void {
|
|
const pets = listPluginPets();
|
|
for (const listener of changeListeners) { try { listener(pets); } catch { /* listener errors are isolated */ } }
|
|
}
|
|
|
|
export function listPluginPets(): PluginPetInfo[] {
|
|
const state = getAppStateSnapshot();
|
|
const defaultName = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId)?.displayName ?? "Default pet";
|
|
const out: PluginPetInfo[] = [{ id: "default", name: defaultName, kind: "default", visible: getDefaultPetWindowForPlugins()?.isVisible() ?? false }];
|
|
for (const pet of spawnedPets.values()) {
|
|
out.push({ id: pet.handleId, name: pet.name, kind: "plugin", visible: pet.window !== null && !pet.window.isDestroyed() && pet.window.isVisible() });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function onPluginPetsChange(listener: (pets: PluginPetInfo[]) => void): () => void {
|
|
changeListeners.add(listener);
|
|
return () => changeListeners.delete(listener);
|
|
}
|
|
|
|
function refreshSpawnedPet(pet: SpawnedPet): void {
|
|
if (!pet.window || pet.window.isDestroyed()) return;
|
|
void loadExplicitPetContent(pet.window, pet.petId, null, pet.statusReaction, undefined, pet.scale, pet.bubbles.transient || pet.bubbles.pinned ? pet.bubbles : null).then(() => {
|
|
if (pet.window && !pet.window.isDestroyed() && pet.spriteOverride) setPetSpriteOverride(pet.window, pet.spriteOverride);
|
|
});
|
|
}
|
|
|
|
export async function spawnPluginPet(opts: { pluginId: string; petId: string; name?: string; position?: Point }): Promise<string> {
|
|
const state = getAppStateSnapshot();
|
|
const installed = state.pets.installed.find((pet) => pet.id === opts.petId);
|
|
if (!installed || installed.broken || installed.id === builtInPet.id) throw new Error(`Pet is not installed or cannot be spawned: ${opts.petId}`);
|
|
const handleId = `plugin-pet-${++nextSpawnId}`;
|
|
const base = getDefaultPetInitialPosition(defaultPetWindowSize);
|
|
const offset = (spawnedPets.size + 1) * 60;
|
|
const position = clampToVisibleWorkArea(opts.position ?? { x: base.x - offset, y: base.y }, defaultPetWindowSize);
|
|
|
|
const pet: SpawnedPet = {
|
|
handleId,
|
|
ownerPluginId: opts.pluginId,
|
|
petId: opts.petId,
|
|
name: opts.name ?? installed.displayName,
|
|
window: null,
|
|
arbiter: null as unknown as PetBubbleArbiter,
|
|
bubbles: { transient: null, pinned: null },
|
|
statusReaction: null,
|
|
currentAnimation: "idle",
|
|
spriteOverride: null,
|
|
scale: state.preferences.petScale as PetScaleValue,
|
|
reactionRevert: null,
|
|
};
|
|
const sink: PetBubbleSink = {
|
|
present(slot, content) {
|
|
pet.bubbles = slot === "pinned" ? { ...pet.bubbles, pinned: content } : { ...pet.bubbles, transient: content };
|
|
refreshSpawnedPet(pet);
|
|
},
|
|
};
|
|
(pet as { arbiter: PetBubbleArbiter }).arbiter = new PetBubbleArbiter(sink);
|
|
|
|
const window = createAgentPetWindow({
|
|
petId: opts.petId,
|
|
displayName: pet.name,
|
|
scale: pet.scale,
|
|
position,
|
|
display: null,
|
|
badge: null,
|
|
onCloseRequested: () => closePluginPet(opts.pluginId, handleId, true),
|
|
onBubbleDismissed: (token) => { if (PetBubbleArbiter.isArbiterToken(token)) pet.arbiter.handleDismissed(token); },
|
|
onBubbleAction: (token, actionId) => pet.arbiter.handleAction(token, actionId),
|
|
onBubbleSubmit: (token, values) => pet.arbiter.handleSubmit(token, values),
|
|
onPetEvent: (name, payload) => publishPluginPetEvent(handleId, name, payload),
|
|
});
|
|
pet.window = window;
|
|
window.once("closed", () => { pet.window = null; });
|
|
window.showInactive();
|
|
spawnedPets.set(handleId, pet);
|
|
info("plugin", "plugin pet spawned", { handleId, petId: opts.petId, pluginId: opts.pluginId });
|
|
notifyChange();
|
|
return handleId;
|
|
}
|
|
|
|
export async function closePluginPet(pluginId: string, petHandleId: string, force = false): Promise<void> {
|
|
const pet = spawnedPets.get(petHandleId);
|
|
if (!pet) return;
|
|
if (!force && pet.ownerPluginId !== pluginId) throw new Error("Plugins may only close pets they spawned.");
|
|
spawnedPets.delete(petHandleId);
|
|
motionStop(petHandleId);
|
|
stopTicker(petHandleId);
|
|
if (pet.reactionRevert) clearTimeout(pet.reactionRevert);
|
|
pet.arbiter.clearPlugin(pet.ownerPluginId);
|
|
if (pet.window && !pet.window.isDestroyed()) pet.window.destroy();
|
|
info("plugin", "plugin pet closed", { petHandleId, pluginId });
|
|
notifyChange();
|
|
}
|
|
|
|
/** Tear down everything a plugin spawned or animated (plugin stop/reload). */
|
|
export function clearPluginPetsForPlugin(pluginId: string): void {
|
|
for (const pet of [...spawnedPets.values()]) {
|
|
if (pet.ownerPluginId === pluginId) void closePluginPet(pluginId, pet.handleId, true);
|
|
}
|
|
defaultPetBubbleArbiter.clearPlugin(pluginId);
|
|
}
|
|
|
|
export function showPluginPet(petHandleId: string): void {
|
|
if (petHandleId === "default") { import("./default-pet-controller.js").then(({ showDefaultPet }) => showDefaultPet()).catch(() => undefined); notifyChange(); return; }
|
|
requireWindow(petHandleId).showInactive();
|
|
notifyChange();
|
|
}
|
|
|
|
export function hidePluginPet(petHandleId: string): void {
|
|
if (petHandleId === "default") { import("./default-pet-controller.js").then(({ hideDefaultPet }) => hideDefaultPet()).catch(() => undefined); notifyChange(); return; }
|
|
requireWindow(petHandleId).hide();
|
|
notifyChange();
|
|
}
|
|
|
|
export function reactPluginPet(petHandleId: string, reaction: OpenPetsReaction, options: { readonly showMessage?: boolean } = {}): void {
|
|
if (petHandleId === "default") { applyExternalPetReaction(reaction, options); defaultAnimation = String(reaction); return; }
|
|
const pet = spawnedPets.get(petHandleId);
|
|
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
|
|
applySpawnedPetAnimation(pet, { kind: "reaction", reaction });
|
|
}
|
|
|
|
export function setPluginPetAnimation(petHandleId: string, spec: PluginAnimationSpec): void {
|
|
if (petHandleId === "default") {
|
|
const window = requireWindow("default");
|
|
if (spec.kind === "reaction") {
|
|
applyExternalPetReaction(spec.reaction);
|
|
setPetSpriteOverride(window, null);
|
|
defaultAnimation = String(spec.reaction);
|
|
} else {
|
|
setPetSpriteOverride(window, { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop });
|
|
defaultAnimation = "sprite";
|
|
}
|
|
return;
|
|
}
|
|
const pet = spawnedPets.get(petHandleId);
|
|
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
|
|
applySpawnedPetAnimation(pet, spec);
|
|
}
|
|
|
|
function applySpawnedPetAnimation(pet: SpawnedPet, spec: PluginAnimationSpec): void {
|
|
if (!pet.window || pet.window.isDestroyed()) throw new Error(`Pet is not available: ${pet.handleId}`);
|
|
if (pet.reactionRevert) { clearTimeout(pet.reactionRevert); pet.reactionRevert = null; }
|
|
if (spec.kind === "reaction") {
|
|
pet.spriteOverride = null;
|
|
setPetSpriteOverride(pet.window, null);
|
|
const spriteState = resolveReactionSpriteState(spec.reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides);
|
|
setPetReactionState(pet.window, spriteState);
|
|
pet.currentAnimation = String(spec.reaction);
|
|
pet.reactionRevert = setTimeout(() => {
|
|
pet.reactionRevert = null;
|
|
if (pet.window && !pet.window.isDestroyed()) setPetReactionState(pet.window, "idle");
|
|
pet.currentAnimation = "idle";
|
|
}, 4_000);
|
|
pet.reactionRevert.unref?.();
|
|
} else {
|
|
pet.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop };
|
|
setPetSpriteOverride(pet.window, pet.spriteOverride);
|
|
pet.currentAnimation = "sprite";
|
|
}
|
|
}
|
|
|
|
export function setPluginPetScale(petHandleId: string, scale: number): void {
|
|
setPetWindowScale(requireWindow(petHandleId), scale);
|
|
const pet = spawnedPets.get(petHandleId);
|
|
if (pet) pet.scale = scale as PetScaleValue;
|
|
}
|
|
|
|
export function setPluginPetStatusReaction(petHandleId: string, reaction: OpenPetsReaction | null): void {
|
|
if (petHandleId === "default") {
|
|
applyExternalPetStatusReaction(reaction);
|
|
return;
|
|
}
|
|
const pet = spawnedPets.get(petHandleId);
|
|
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
|
|
pet.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
|
|
refreshSpawnedPet(pet);
|
|
}
|
|
|
|
export async function movePluginPetBy(petHandleId: string, opts: { x: number; y: number; durationMs?: number }): Promise<void> {
|
|
const window = requireWindow(petHandleId);
|
|
const [x, y] = window.getPosition();
|
|
const distance = Math.min(Math.hypot(opts.x, opts.y), 160);
|
|
const scale = distance > 0 ? distance / Math.hypot(opts.x, opts.y) : 0;
|
|
await motionMoveTo(petHandleId, windowAccessor(petHandleId), { x: x + opts.x * scale, y: y + opts.y * scale }, { durationMs: opts.durationMs ?? 700 });
|
|
}
|
|
|
|
export async function wanderPluginPet(petHandleId: string, opts: { distance?: number; durationMs?: number }): Promise<void> {
|
|
const distance = Math.min(Math.max(opts.distance ?? 80, 0), 160);
|
|
const angle = Math.random() * Math.PI * 2;
|
|
await movePluginPetBy(petHandleId, { x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, durationMs: opts.durationMs });
|
|
}
|
|
|
|
export async function movePluginPetToHome(petHandleId: string): Promise<void> {
|
|
const home = getDefaultPetInitialPosition(defaultPetWindowSize);
|
|
await motionMoveTo(petHandleId, windowAccessor(petHandleId), home, { durationMs: 1_200 });
|
|
}
|
|
|
|
export async function movePluginPetTo(petHandleId: string, point: Point, opts: { durationMs?: number; easing?: string } = {}): Promise<void> {
|
|
requireWindow(petHandleId);
|
|
await motionMoveTo(petHandleId, windowAccessor(petHandleId), point, opts);
|
|
}
|
|
|
|
export function setPluginPetFollowCursor(petHandleId: string, opts: { enabled: boolean; lag?: number }): void {
|
|
requireWindow(petHandleId);
|
|
motionSetFollowCursor(petHandleId, windowAccessor(petHandleId), opts);
|
|
}
|
|
|
|
export function setPluginPetPhysics(petHandleId: string, opts: { gravity?: boolean; bounce?: number }): void {
|
|
requireWindow(petHandleId);
|
|
motionSetPhysics(petHandleId, windowAccessor(petHandleId), opts);
|
|
}
|
|
|
|
export function getPluginPetState(petHandleId: string): PluginPetState {
|
|
const window = requireWindow(petHandleId);
|
|
const [x, y] = window.getPosition();
|
|
const [width, height] = window.getSize();
|
|
const pet = spawnedPets.get(petHandleId);
|
|
return {
|
|
position: { x, y },
|
|
bounds: { x, y, width, height },
|
|
currentAnimation: petHandleId === "default" ? (getDefaultPetPaused() ? "paused" : defaultAnimation) : pet?.currentAnimation ?? "idle",
|
|
visible: window.isVisible(),
|
|
dragging: isPetWindowDragging(window),
|
|
};
|
|
}
|
|
|
|
/** Host-driven behavior loop: throttled, auto-paused while hidden/dragging. */
|
|
export function onPluginPetTick(petHandleId: string, handler: (dtMs: number) => void): () => void {
|
|
let subscribers = tickSubscribers.get(petHandleId);
|
|
if (!subscribers) { subscribers = new Set(); tickSubscribers.set(petHandleId, subscribers); }
|
|
subscribers.add(handler);
|
|
if (!tickLoops.has(petHandleId)) {
|
|
const loop = { timer: null as unknown as NodeJS.Timeout, lastAt: Date.now() };
|
|
loop.timer = setInterval(() => {
|
|
const window = windowAccessor(petHandleId)();
|
|
const now = Date.now();
|
|
if (!window || window.isDestroyed() || !window.isVisible() || isPetWindowDragging(window)) { loop.lastAt = now; return; }
|
|
const dt = now - loop.lastAt;
|
|
loop.lastAt = now;
|
|
const live = tickSubscribers.get(petHandleId);
|
|
if (!live || live.size === 0) return;
|
|
for (const subscriber of live) { try { subscriber(dt); } catch { /* isolated upstream */ } }
|
|
}, tickIntervalMs);
|
|
loop.timer.unref?.();
|
|
tickLoops.set(petHandleId, loop);
|
|
}
|
|
return () => {
|
|
const live = tickSubscribers.get(petHandleId);
|
|
live?.delete(handler);
|
|
if (live && live.size === 0) stopTicker(petHandleId);
|
|
};
|
|
}
|
|
|
|
function stopTicker(petHandleId: string): void {
|
|
const loop = tickLoops.get(petHandleId);
|
|
if (loop) { clearInterval(loop.timer); tickLoops.delete(petHandleId); }
|
|
tickSubscribers.delete(petHandleId);
|
|
}
|
|
|
|
/** Show a plugin bubble on a pet surface (the bubbles capability entry point). */
|
|
export function getPluginPetArbiter(petHandleId: string): PetBubbleArbiter {
|
|
if (petHandleId === "default") return defaultPetBubbleArbiter;
|
|
const pet = spawnedPets.get(petHandleId);
|
|
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
|
|
return pet.arbiter;
|
|
}
|
|
|
|
export function closeAllPluginPets(): void {
|
|
for (const pet of [...spawnedPets.values()]) void closePluginPet(pet.ownerPluginId, pet.handleId, true);
|
|
debug("plugin", "all plugin pets closed");
|
|
}
|