545 lines
24 KiB
TypeScript
545 lines
24 KiB
TypeScript
import { app, BrowserWindow, ipcMain, Menu, type IpcMainEvent } from "electron";
|
|
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-state.js";
|
|
import { clampToPrimaryWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
|
|
import { builtInPet } from "./built-in-pet.js";
|
|
import { getInstalledPetDir } from "./pet-paths.js";
|
|
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
|
|
import { pickReactionMessage } from "./reaction-messages.js";
|
|
|
|
export interface DefaultPetWindowOptions {
|
|
readonly position: Point;
|
|
readonly paused: boolean;
|
|
readonly display: PetTransientDisplay | null;
|
|
readonly onPositionChanged: (position: Point) => void;
|
|
readonly onHideRequested: () => void;
|
|
}
|
|
|
|
export interface AgentPetWindowOptions {
|
|
readonly petId: string;
|
|
readonly displayName: string;
|
|
readonly position: Point;
|
|
readonly display: PetTransientDisplay | null;
|
|
readonly onCloseRequested: () => void;
|
|
}
|
|
|
|
export interface PetTransientDisplay {
|
|
readonly reaction?: OpenPetsReaction;
|
|
readonly message?: string;
|
|
readonly reactionMessage?: string;
|
|
}
|
|
|
|
type PetMotionState = "idle" | "run-left" | "run-right";
|
|
type UniversalSpriteState = "idle" | "running-right" | "running-left" | "waving" | "jumping" | "failed" | "waiting" | "running" | "review";
|
|
|
|
const motionToSpriteState = {
|
|
idle: "idle",
|
|
"run-right": "running-right",
|
|
"run-left": "running-left",
|
|
} as const satisfies Record<PetMotionState, UniversalSpriteState>;
|
|
|
|
const reactionToSpriteState = {
|
|
idle: "idle",
|
|
thinking: "review",
|
|
working: "running",
|
|
editing: "running",
|
|
running: "running",
|
|
testing: "waiting",
|
|
waiting: "waiting",
|
|
waving: "waving",
|
|
success: "jumping",
|
|
error: "failed",
|
|
celebrating: "jumping",
|
|
} as const satisfies Record<OpenPetsReaction, UniversalSpriteState>;
|
|
|
|
const defaultPetSprite = {
|
|
fileName: "default-pet-spritesheet.webp",
|
|
frameWidth: 192,
|
|
frameHeight: 208,
|
|
columns: 8,
|
|
rows: 9,
|
|
states: {
|
|
idle: { row: 0, frames: 6, durationMs: 5500, iterations: "infinite" },
|
|
"running-right": { row: 1, frames: 8, durationMs: 1060 },
|
|
"running-left": { row: 2, frames: 8, durationMs: 1060 },
|
|
waving: { row: 3, frames: 4, durationMs: 700, iterations: 2 },
|
|
jumping: { row: 4, frames: 5, durationMs: 840, iterations: 2 },
|
|
failed: { row: 5, frames: 8, durationMs: 1220, iterations: 2 },
|
|
waiting: { row: 6, frames: 6, durationMs: 1010 },
|
|
running: { row: 7, frames: 6, durationMs: 820 },
|
|
review: { row: 8, frames: 6, durationMs: 1030 },
|
|
} satisfies Record<UniversalSpriteState, { readonly row: number; readonly frames: number; readonly durationMs: number; readonly iterations?: number | "infinite" }>,
|
|
} as const;
|
|
|
|
export function createDefaultPetWindow(options: DefaultPetWindowOptions): BrowserWindow {
|
|
const window = createBasePetWindow("OpenPets — Default Pet", options.position);
|
|
installMousePassthroughAndDrag(window);
|
|
installMotionStatePublisher(window);
|
|
installPetContextMenu(window, { label: "Hide pet", click: options.onHideRequested });
|
|
|
|
const savePosition = debounce(() => {
|
|
if (window.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
options.onPositionChanged(readWindowPosition(window));
|
|
}, 150);
|
|
|
|
window.on("move", savePosition);
|
|
window.on("moved", savePosition);
|
|
window.on("close", () => {
|
|
options.onPositionChanged(readWindowPosition(window));
|
|
});
|
|
|
|
void loadDefaultPetContent(window, options.paused, options.display);
|
|
|
|
return window;
|
|
}
|
|
|
|
export function createAgentPetWindow(options: AgentPetWindowOptions): BrowserWindow {
|
|
const window = createBasePetWindow(`OpenPets — ${options.displayName}`, options.position);
|
|
installMousePassthroughAndDrag(window);
|
|
installMotionStatePublisher(window);
|
|
installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested });
|
|
void loadExplicitPetContent(window, options.petId, options.display);
|
|
return window;
|
|
}
|
|
|
|
function installPetContextMenu(window: BrowserWindow, action: { readonly label: string; readonly click: () => void }): void {
|
|
window.webContents.on("context-menu", (event) => {
|
|
event.preventDefault();
|
|
if (window.isDestroyed()) return;
|
|
Menu.buildFromTemplate([{ label: action.label, click: action.click }]).popup({ window });
|
|
});
|
|
}
|
|
|
|
function installMousePassthroughAndDrag(window: BrowserWindow): void {
|
|
let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number } | null = null;
|
|
|
|
const isFromWindow = (event: IpcMainEvent): boolean => event.sender === window.webContents;
|
|
const setPassthrough = (passthrough: boolean): void => {
|
|
if (window.isDestroyed()) return;
|
|
if (passthrough) window.setIgnoreMouseEvents(true, { forward: true });
|
|
else window.setIgnoreMouseEvents(false);
|
|
};
|
|
|
|
const handleHitTest = (event: IpcMainEvent, interactive: unknown): void => {
|
|
if (!isFromWindow(event)) return;
|
|
setPassthrough(!interactive && !dragging);
|
|
};
|
|
|
|
const handleDragStart = (event: IpcMainEvent, point: unknown): void => {
|
|
if (!isFromWindow(event) || !isScreenPoint(point)) return;
|
|
const [startWindowX, startWindowY] = window.getPosition();
|
|
dragging = { startScreenX: point.screenX, startScreenY: point.screenY, startWindowX, startWindowY };
|
|
setPassthrough(false);
|
|
};
|
|
|
|
const handleDragMove = (event: IpcMainEvent, point: unknown): void => {
|
|
if (!isFromWindow(event) || !dragging || !isScreenPoint(point)) return;
|
|
window.setPosition(dragging.startWindowX + Math.round(point.screenX - dragging.startScreenX), dragging.startWindowY + Math.round(point.screenY - dragging.startScreenY), false);
|
|
};
|
|
|
|
const handleDragEnd = (event: IpcMainEvent): void => {
|
|
if (!isFromWindow(event)) return;
|
|
dragging = null;
|
|
};
|
|
|
|
ipcMain.on("openpets:pet-hit-test", handleHitTest);
|
|
ipcMain.on("openpets:pet-drag-start", handleDragStart);
|
|
ipcMain.on("openpets:pet-drag-move", handleDragMove);
|
|
ipcMain.on("openpets:pet-drag-end", handleDragEnd);
|
|
window.webContents.once("did-finish-load", () => setPassthrough(true));
|
|
window.on("closed", () => {
|
|
ipcMain.off("openpets:pet-hit-test", handleHitTest);
|
|
ipcMain.off("openpets:pet-drag-start", handleDragStart);
|
|
ipcMain.off("openpets:pet-drag-move", handleDragMove);
|
|
ipcMain.off("openpets:pet-drag-end", handleDragEnd);
|
|
});
|
|
}
|
|
|
|
function isScreenPoint(value: unknown): value is { readonly screenX: number; readonly screenY: number } {
|
|
return typeof value === "object" && value !== null && typeof (value as { readonly screenX?: unknown }).screenX === "number" && typeof (value as { readonly screenY?: unknown }).screenY === "number";
|
|
}
|
|
|
|
function createBasePetWindow(title: string, position: Point): BrowserWindow {
|
|
const window = new BrowserWindow({
|
|
title,
|
|
width: defaultPetWindowSize.width,
|
|
height: defaultPetWindowSize.height,
|
|
x: position.x,
|
|
y: position.y,
|
|
frame: false,
|
|
transparent: true,
|
|
resizable: false,
|
|
maximizable: false,
|
|
minimizable: false,
|
|
fullscreenable: false,
|
|
skipTaskbar: true,
|
|
alwaysOnTop: true,
|
|
show: false,
|
|
hasShadow: false,
|
|
backgroundColor: "#00000000",
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
preload: join(app.getAppPath(), "pet-preload.cjs"),
|
|
},
|
|
});
|
|
|
|
window.setMenu(null);
|
|
window.setAlwaysOnTop(true, "floating");
|
|
|
|
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
window.webContents.on("will-navigate", (event, url) => {
|
|
if (isAllowedPetDocumentUrl(url)) return;
|
|
event.preventDefault();
|
|
});
|
|
window.webContents.on("will-redirect", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => {
|
|
console.error("Failed to load default pet window.", { errorCode, errorDescription });
|
|
});
|
|
window.webContents.on("render-process-gone", (_event, details) => {
|
|
console.error("Default pet renderer process gone.", details);
|
|
});
|
|
|
|
return window;
|
|
}
|
|
|
|
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null): Promise<void> {
|
|
const html = await createDefaultPetHtml(paused, display);
|
|
await loadPetHtmlFile(window, html, "default").catch((error: unknown) => {
|
|
console.error("Failed to load default pet URL.", error);
|
|
});
|
|
}
|
|
|
|
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null): Promise<void> {
|
|
try {
|
|
const state = getAppStateSnapshot();
|
|
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
|
|
if (!pet || pet.broken || pet.id === builtInPet.id) {
|
|
throw new Error(`Cannot render explicit pet: ${petId}`);
|
|
}
|
|
const html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue);
|
|
await loadPetHtmlFile(window, html, `explicit-${pet.id}`);
|
|
} catch (error: unknown) {
|
|
console.error(`Failed to load explicit pet ${petId} URL.`, error);
|
|
}
|
|
}
|
|
|
|
export function preparePetTransientDisplay(display: PetTransientDisplay): PetTransientDisplay {
|
|
if (!display.reaction || display.message || display.reactionMessage) return display;
|
|
return { ...display, reactionMessage: pickReactionMessage(display.reaction) };
|
|
}
|
|
|
|
export function mergePetTransientDisplay(current: PetTransientDisplay | null, next: PetTransientDisplay): PetTransientDisplay {
|
|
if (next.message || !next.reaction || !current?.message) return preparePetTransientDisplay(next);
|
|
return { ...current, reaction: next.reaction };
|
|
}
|
|
|
|
export function getTransientReactionAnimationMs(display: PetTransientDisplay): number | null {
|
|
if (!display.reaction) return null;
|
|
const state = reactionToSpriteState[display.reaction];
|
|
const row = defaultPetSprite.states[state];
|
|
const iterations = "iterations" in row ? row.iterations : "infinite";
|
|
return typeof iterations === "number" ? row.durationMs * iterations : null;
|
|
}
|
|
|
|
export function clearTransientReaction(display: PetTransientDisplay): PetTransientDisplay {
|
|
if (!display.reaction) return display;
|
|
return { ...display, reaction: undefined };
|
|
}
|
|
|
|
export function setPetReactionState(window: BrowserWindow, state: UniversalSpriteState): void {
|
|
if (window.isDestroyed()) return;
|
|
window.webContents.send("openpets:pet-reaction-state", state);
|
|
}
|
|
|
|
export function getSafeDefaultPetPosition(position: Point | undefined): Point {
|
|
return clampToPrimaryWorkArea(position ?? getDefaultPetInitialPosition(), defaultPetWindowSize);
|
|
}
|
|
|
|
export function readWindowPosition(window: BrowserWindow): Point {
|
|
const [x, y] = window.getPosition();
|
|
return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize);
|
|
}
|
|
|
|
async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string> {
|
|
const installedPetHtml = await tryCreateInstalledPetHtml(paused, display);
|
|
if (installedPetHtml) {
|
|
return installedPetHtml;
|
|
}
|
|
|
|
const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString();
|
|
const bubble = createBubbleMarkup(display, paused);
|
|
const stateRows = defaultPetSprite.states;
|
|
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
|
|
|
|
return `<!doctype html>
|
|
<html lang="en" data-reaction-state="${getReactionSpriteState(display?.reaction)}" data-motion-state="idle">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>OpenPets Default Pet</title>
|
|
<style>
|
|
${createPetWindowCss(paused, scale)}
|
|
.sprite {
|
|
width: ${defaultPetSprite.frameWidth}px;
|
|
height: ${defaultPetSprite.frameHeight}px;
|
|
background-image: url("${escapeCssUrl(spriteUrl)}");
|
|
background-size: ${defaultPetSprite.frameWidth * defaultPetSprite.columns}px ${defaultPetSprite.frameHeight * defaultPetSprite.rows}px;
|
|
background-repeat: no-repeat;
|
|
--sprite-row-y: 0px;
|
|
--sprite-frames: ${stateRows.idle.frames};
|
|
--sprite-duration: ${stateRows.idle.durationMs}ms;
|
|
--sprite-iterations: ${stateRows.idle.iterations};
|
|
background-position: 0 var(--sprite-row-y);
|
|
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
|
|
animation-play-state: var(--play-state);
|
|
transform: scale(${scale});
|
|
transform-origin: top left;
|
|
}
|
|
${createSpriteStateCss(".sprite")}
|
|
@keyframes pet-frames {
|
|
from { background-position: 0 var(--sprite-row-y); }
|
|
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="stage" aria-label="OpenPets default pet">
|
|
${bubble}
|
|
<div class="pet-shell">
|
|
<div class="sprite" role="img" aria-label="Claude animated default pet"></div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string | null> {
|
|
const state = getAppStateSnapshot();
|
|
const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
|
|
|
|
if (!selected || selected.id === builtInPet.id || selected.broken) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue);
|
|
} catch (error) {
|
|
console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error);
|
|
try {
|
|
markPetBroken(selected.id, error instanceof Error ? error.message : "Installed pet rendering failed.");
|
|
} catch (markError) {
|
|
console.error(`Failed to mark installed pet ${selected.id} broken.`, markError);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue): Promise<string> {
|
|
const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp");
|
|
const spritesheet = await stat(spritesheetPath);
|
|
if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) {
|
|
throw new Error("Installed pet spritesheet is missing or too large.");
|
|
}
|
|
|
|
const imageUrl = pathToFileURL(spritesheetPath).toString();
|
|
const bubble = createBubbleMarkup(display, paused);
|
|
const stateRows = defaultPetSprite.states;
|
|
|
|
return `<!doctype html>
|
|
<html lang="en" data-reaction-state="${getReactionSpriteState(display?.reaction)}" data-motion-state="idle">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>OpenPets Default Pet</title>
|
|
<style>
|
|
${createPetWindowCss(paused, scale)}
|
|
.installed-card { width: ${Math.ceil(defaultPetSprite.frameWidth * scale)}px; height: ${Math.ceil(defaultPetSprite.frameHeight * scale)}px; overflow: visible; position: relative; }
|
|
.installed-sprite {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: ${defaultPetSprite.frameWidth}px;
|
|
height: ${defaultPetSprite.frameHeight}px;
|
|
background-image: url("${escapeCssUrl(imageUrl)}");
|
|
background-size: ${defaultPetSprite.frameWidth * defaultPetSprite.columns}px ${defaultPetSprite.frameHeight * defaultPetSprite.rows}px;
|
|
background-repeat: no-repeat;
|
|
--sprite-row-y: 0px;
|
|
--sprite-frames: ${stateRows.idle.frames};
|
|
--sprite-duration: ${stateRows.idle.durationMs}ms;
|
|
--sprite-iterations: ${stateRows.idle.iterations};
|
|
background-position: 0 var(--sprite-row-y);
|
|
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
|
|
animation-play-state: var(--play-state);
|
|
transform: scale(${scale});
|
|
transform-origin: top left;
|
|
}
|
|
${createSpriteStateCss(".installed-sprite")}
|
|
@keyframes pet-frames {
|
|
from { background-position: 0 var(--sprite-row-y); }
|
|
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="stage" aria-label="${escapeHtml(displayName)}">
|
|
${bubble}
|
|
<div class="pet-shell">
|
|
<div class="installed-card" role="img" aria-label="${escapeHtml(displayName)}">
|
|
<div class="installed-sprite"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
|
|
const opacity = paused ? "0.62" : "1";
|
|
const playState = paused ? "paused" : "running";
|
|
const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale);
|
|
const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale);
|
|
const petBottom = 22;
|
|
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 3);
|
|
return `
|
|
:root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; }
|
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; }
|
|
html { color: #172033; }
|
|
body { -webkit-app-region: no-drag; pointer-events: none; }
|
|
.stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; }
|
|
.pet-shell { position: absolute; left: 50%; bottom: ${petBottom}px; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18)); transform: translateX(-50%); transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
|
|
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 2; box-sizing: border-box; max-width: min(200px, calc(100vw - 14px)); max-height: 92px; padding: 8px 10px; background: #fff; color: #000; font: 700 11px/14px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: left; border: 2px solid #000; border-radius: 4px; box-shadow: 2px 2px 0 #000; white-space: normal; overflow-wrap: anywhere; word-break: normal; overflow: hidden; pointer-events: auto; -webkit-app-region: no-drag; transform: translateX(-50%); opacity: 1; animation: bubble-in 160ms steps(2, end); }
|
|
.bubble-text { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; }
|
|
.bubble::after, .bubble::before { content: none; }
|
|
.bubble.is-reaction { max-width: min(142px, calc(100vw - 10px)); max-height: 64px; padding: 6px 8px; font: 750 11px/13px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: center; overflow-wrap: break-word; }
|
|
.bubble.is-reaction .bubble-text { -webkit-line-clamp: 3; }
|
|
.bubble.is-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 106px; font-size: 10.5px; line-height: 13px; }
|
|
.bubble.is-long-message .bubble-text { -webkit-line-clamp: 7; }
|
|
.bubble.is-very-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 126px; font-size: 10px; line-height: 13px; }
|
|
.bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 9; }
|
|
@keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(3px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
|
@media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble { animation: none !important; } }
|
|
`;
|
|
}
|
|
|
|
function createSpriteStateCss(selector: ".sprite" | ".installed-sprite"): string {
|
|
const reactionRules = Object.keys(defaultPetSprite.states).map((state) => createSpriteRule(`html[data-reaction-state="${state}"] ${selector}`, state as UniversalSpriteState));
|
|
const motionRules = (Object.entries(motionToSpriteState) as Array<[PetMotionState, UniversalSpriteState]>)
|
|
.filter(([motion]) => motion !== "idle")
|
|
.map(([motion, state]) => createSpriteRule(`html[data-motion-state="${motion}"] ${selector}`, state));
|
|
return [...reactionRules, ...motionRules].join("\n");
|
|
}
|
|
|
|
function createSpriteRule(selector: string, state: UniversalSpriteState): string {
|
|
const row = defaultPetSprite.states[state];
|
|
const iterations = "iterations" in row ? row.iterations : "infinite";
|
|
return `${selector} { --sprite-row-y: -${row.row * defaultPetSprite.frameHeight}px; --sprite-frames: ${row.frames}; --sprite-duration: ${row.durationMs}ms; --sprite-iterations: ${iterations}; }`;
|
|
}
|
|
|
|
function getReactionSpriteState(reaction: OpenPetsReaction | undefined): UniversalSpriteState {
|
|
return reaction ? reactionToSpriteState[reaction] : "idle";
|
|
}
|
|
|
|
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean): string {
|
|
const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction) : undefined) ?? (paused ? "Paused" : "");
|
|
if (!text) return "";
|
|
const className = getBubbleClassName(text, Boolean(display?.message && !display?.reactionMessage));
|
|
return `<div class="${className}" role="status" aria-live="polite"><span class="bubble-text">${escapeHtml(text)}</span></div>`;
|
|
}
|
|
|
|
function getBubbleClassName(text: string, isExplicitMessage: boolean): string {
|
|
if (!isExplicitMessage) return "bubble is-reaction";
|
|
const lengthClass = text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
|
|
return `bubble is-message${lengthClass}`;
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function escapeCssUrl(value: string): string {
|
|
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "");
|
|
}
|
|
|
|
function installMotionStatePublisher(window: BrowserWindow): void {
|
|
let lastX = window.getPosition()[0];
|
|
let lastSent: PetMotionState = "idle";
|
|
let idleTimer: NodeJS.Timeout | null = null;
|
|
|
|
const sendMotionState = (state: PetMotionState): void => {
|
|
if (window.isDestroyed() || lastSent === state) return;
|
|
lastSent = state;
|
|
window.webContents.send("openpets:pet-motion", state);
|
|
};
|
|
|
|
const scheduleIdle = (): void => {
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
idleTimer = setTimeout(() => {
|
|
idleTimer = null;
|
|
sendMotionState("idle");
|
|
}, 180);
|
|
};
|
|
|
|
const handleMove = (): void => {
|
|
if (window.isDestroyed()) return;
|
|
const [x] = window.getPosition();
|
|
const deltaX = x - lastX;
|
|
lastX = x;
|
|
|
|
if (Math.abs(deltaX) >= 3) {
|
|
sendMotionState(deltaX > 0 ? "run-right" : "run-left");
|
|
}
|
|
scheduleIdle();
|
|
};
|
|
|
|
window.on("move", handleMove);
|
|
window.on("moved", handleMove);
|
|
window.webContents.on("did-finish-load", () => {
|
|
lastSent = "idle";
|
|
window.webContents.send("openpets:pet-motion", "idle");
|
|
});
|
|
window.on("closed", () => {
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
});
|
|
}
|
|
|
|
function isAllowedPetDocumentUrl(url: string): boolean {
|
|
return url.startsWith("data:text/html") || url.startsWith("file://");
|
|
}
|
|
|
|
async function loadPetHtmlFile(window: BrowserWindow, html: string, name: string): Promise<void> {
|
|
const dir = join(app.getPath("userData"), "rendered-pets");
|
|
await mkdir(dir, { recursive: true });
|
|
const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "pet";
|
|
const filePath = join(dir, `${safeName}.html`);
|
|
await writeFile(filePath, html, "utf8");
|
|
await window.loadFile(filePath);
|
|
}
|
|
|
|
function debounce(callback: () => void, delayMs: number): () => void {
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
|
|
return () => {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
timeout = setTimeout(callback, delayMs);
|
|
};
|
|
}
|