Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
OpenPets Dev
8648cadc55 fix(desktop): OP2 flip speech bubble below pet when near top of screen 2026-06-13 22:59:26 +00:00
2 changed files with 88 additions and 18 deletions

View file

@ -302,6 +302,17 @@ ipcRenderer.on("openpets:pet-scale-override", (_event, scale) => {
if (sprite) sprite.style.transform = `scale(${value})`;
});
ipcRenderer.on("openpets:pet-bubble-layout", (_event, layout) => {
if (!layout || typeof layout !== "object") return;
const root = document.documentElement;
if (typeof layout.bubbleMaxHeight === "number") root.style.setProperty("--bubble-max-height", `${layout.bubbleMaxHeight}px`);
if (typeof layout.bubbleMaxHeightLong === "number") root.style.setProperty("--bubble-max-height-long", `${layout.bubbleMaxHeightLong}px`);
if (typeof layout.bubbleMaxHeightVeryLong === "number") root.style.setProperty("--bubble-max-height-very-long", `${layout.bubbleMaxHeightVeryLong}px`);
document.querySelectorAll(".bubble").forEach((bubble) => {
bubble.classList.toggle("is-below", layout.bubbleBelow === true);
});
});
// --- Plugin audio (named WebAudio recipes + bundled data URLs) ---------------
let audioContext = null;

View file

@ -17,6 +17,8 @@ import type { ActiveBubble } from "./plugin-bubble-arbiter.js";
import type { PluginBubbleIndicator, PluginCommandForm, PluginBubbleHud, PluginBubbleHudItem } from "./plugin-sdk-bridge.js";
import { defaultPetSprite, motionToSpriteState, resolveReactionSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js";
const petWindowLastDisplay = new WeakMap<BrowserWindow, PetTransientDisplay | null>();
export interface PetWindowInteractionHooks {
readonly onBubbleDismissed?: (dismissToken: string) => void;
readonly onBubbleAction?: (dismissToken: string, actionId: string) => void;
@ -431,6 +433,10 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
dragging = null;
petWindowDragging.set(window, false);
debug("pet.window", "drag end", { windowId, position: window.isDestroyed() ? null : readWindowPosition(window) });
if (!window.isDestroyed()) {
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
resizePetWindowForDisplay(window, scale, petWindowLastDisplay.get(window) ?? null);
}
if (wasDragging) onPetEvent?.("pet:dragEnd", {});
};
@ -662,6 +668,7 @@ function applyPetAlwaysOnTop(window: BrowserWindow): void {
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null, dismissToken?: string, pluginBubbles: PetPluginBubbles | null = null): Promise<void> {
const sequence = allocateWindowLoadSequence(window);
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
petWindowLastDisplay.set(window, display);
resizePetWindowForDisplay(window, scale, display);
debug("pet.window", "default content render begin", { windowId: window.id, sequence, paused, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), fullMessage: display?.fullMessage === true, badge, hasPluginBubble: Boolean(pluginBubbles?.transient), hasPinned: Boolean(pluginBubbles?.pinned), defaultPetId: getAppStateSnapshot().preferences.defaultPetId });
const render = await createDefaultPetRender(paused, display, badge, dismissToken, pluginBubbles);
@ -669,6 +676,7 @@ export async function loadDefaultPetContent(window: BrowserWindow, paused: boole
if (tryUpdateLoadedPetContent(window, render, "default", sequence)) return;
await loadPetHtmlFile(window, render.html, "default", sequence).then(() => {
petWindowRenderCache.set(window, render.cacheKey);
sendBubbleLayout(window, getPetWindowSize(window, scale, display));
}).catch((error: unknown) => {
logError("pet.window", "default content load failed", error instanceof Error ? error : { error });
console.error("Failed to load default pet URL.", error);
@ -677,6 +685,7 @@ export async function loadDefaultPetContent(window: BrowserWindow, paused: boole
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null, dismissToken?: string, scaleOverride?: PetScaleValue, pluginBubbles: PetPluginBubbles | null = null): Promise<void> {
const sequence = allocateWindowLoadSequence(window);
petWindowLastDisplay.set(window, display);
try {
const state = getAppStateSnapshot();
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
@ -691,6 +700,7 @@ export async function loadExplicitPetContent(window: BrowserWindow, petId: strin
if (tryUpdateLoadedPetContent(window, render, `explicit-${pet.id}`, sequence)) return;
await loadPetHtmlFile(window, render.html, `explicit-${pet.id}`, sequence);
petWindowRenderCache.set(window, render.cacheKey);
sendBubbleLayout(window, getPetWindowSize(window, scale, display));
} catch (error: unknown) {
logError("pet.window", "explicit content load failed", error instanceof Error ? error : { petId, error });
console.error(`Failed to load explicit pet ${petId} URL.`, error);
@ -984,7 +994,7 @@ function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: s
</div>`;
}
function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
function createPetWindowCss(paused: boolean, scale: PetScaleValue, layout?: Partial<PetWindowBubbleLayout>): string {
const opacity = paused ? "0.62" : "1";
const playState = paused ? "paused" : "running";
const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale);
@ -992,9 +1002,18 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
const petBottom = 22;
const hitPadding = 18;
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8);
const bubbleTop = Math.ceil(petBottom + scaledHeight + 8);
const bubbleMaxHeight = layout?.bubbleMaxHeight ?? 200;
const bubbleMaxHeightLong = layout?.bubbleMaxHeightLong ?? 240;
const bubbleMaxHeightVeryLong = layout?.bubbleMaxHeightVeryLong ?? 300;
const petShellFilter = process.platform === "win32" ? "none" : "drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18))";
const bubbleBackdropFilter = process.platform === "win32" ? "none" : "blur(10px)";
return `
:root {
--bubble-max-height: ${bubbleMaxHeight}px;
--bubble-max-height-long: ${bubbleMaxHeightLong}px;
--bubble-max-height-very-long: ${bubbleMaxHeightVeryLong}px;
}
: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; }
@ -1002,7 +1021,9 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
.stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; }
.pet-hitbox { position: absolute; left: 50%; bottom: ${Math.max(0, petBottom - hitPadding)}px; z-index: 1; width: ${scaledWidth + hitPadding * 2}px; height: ${scaledHeight + hitPadding * 2}px; display: grid; place-items: center; transform: translateX(-50%); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
.pet-shell { position: relative; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: ${petShellFilter}; 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: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(340px, calc(100vw - 24px)); max-height: 200px; padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); }
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(340px, calc(100vw - 24px)); max-height: var(--bubble-max-height); padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); }
.bubble.is-below { bottom: auto; top: ${bubbleTop}px; transform-origin: 64% 0; }
.bubble.is-below::after { top: -7px; bottom: auto; border: none; border-top: 1px solid rgba(255, 255, 255, 0.56); border-left: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 0; border-top-left-radius: 3px; transform: translateX(-50%) rotate(45deg); }
.bubble[data-dismiss-token] { cursor: pointer; }
.bubble::after { content: ""; position: absolute; left: 64%; bottom: -7px; width: 12px; height: 12px; background: inherit; border-right: 1px solid rgba(255, 255, 255, 0.56); border-bottom: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 3px; transform: translateX(-50%) rotate(45deg); box-shadow: 3px 3px 7px rgba(15, 23, 42, 0.08); }
.bubble-header { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: currentColor; font: 780 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.01em; }
@ -1019,9 +1040,9 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
.bubble.is-status-only .bubble-header { display: grid; grid-template-columns: 18px minmax(0, auto); align-items: center; justify-content: center; }
.bubble.is-message-only { border-radius: 14px 14px 3px 14px; }
.bubble.has-actions { min-width: min(176px, calc(100vw - 18px)); }
.bubble.is-long-message { max-width: min(380px, calc(100vw - 24px)); max-height: 240px; }
.bubble.is-long-message { max-width: min(380px, calc(100vw - 24px)); max-height: var(--bubble-max-height-long); }
.bubble.is-long-message .bubble-text { font-size: 10px; line-height: 13px; }
.bubble.is-very-long-message { max-width: min(440px, calc(100vw - 24px)); max-height: 300px; }
.bubble.is-very-long-message { max-width: min(440px, calc(100vw - 24px)); max-height: var(--bubble-max-height-very-long); }
.bubble.is-very-long-message .bubble-text { font-size: 9.5px; line-height: 12.5px; }
.bubble.is-busy .bubble-status-icon { background: #3b82f6; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.34); }
.bubble.is-waiting .bubble-status-icon { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); }
@ -1438,19 +1459,27 @@ function allocateWindowLoadSequence(window: BrowserWindow): number {
return sequence;
}
function sendBubbleLayout(window: BrowserWindow, layout: PetWindowBubbleLayout): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:pet-bubble-layout", {
bubbleMaxHeight: layout.bubbleMaxHeight,
bubbleMaxHeightLong: layout.bubbleMaxHeightLong,
bubbleMaxHeightVeryLong: layout.bubbleMaxHeightVeryLong,
bubbleBelow: layout.bubbleBelow,
});
}
function resizePetWindowForDisplay(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): void {
if (window.isDestroyed()) return;
const nextSize = getPetWindowSize(window, scale, display);
const currentBounds = window.getBounds();
if (currentBounds.width === nextSize.width && currentBounds.height === nextSize.height) {
return;
}
const anchor = readWindowPosition(window);
const nextY = nextSize.bubbleBelow
? anchor.y
: anchor.y - Math.max(0, nextSize.height - defaultPetWindowSize.height);
const nextBounds = {
x: anchor.x - Math.round((nextSize.width - defaultPetWindowSize.width) / 2),
y: anchor.y - Math.max(0, nextSize.height - defaultPetWindowSize.height),
y: nextY,
width: nextSize.width,
height: nextSize.height,
};
@ -1467,6 +1496,7 @@ function resizePetWindowForDisplay(window: BrowserWindow, scale: PetScaleValue,
width: nextSize.width,
height: nextSize.height,
}, false);
sendBubbleLayout(window, nextSize);
}
function resizePetWindowForScale(window: BrowserWindow, scale: PetScaleValue): void {
@ -1498,13 +1528,26 @@ function resizePetWindowForScale(window: BrowserWindow, scale: PetScaleValue): v
}, false);
}
function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): { readonly width: number; readonly height: number } {
interface PetWindowBubbleLayout {
readonly width: number;
readonly height: number;
readonly bubbleBelow: boolean;
readonly bubbleMaxHeight: number;
readonly bubbleMaxHeightLong: number;
readonly bubbleMaxHeightVeryLong: number;
}
function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): PetWindowBubbleLayout {
const base = getBasePetWindowSize(scale);
const defaultMaxHeight = 200;
const defaultMaxHeightLong = 240;
const defaultMaxHeightVeryLong = 300;
const message = display?.message ?? display?.reactionMessage ?? "";
if (!message) {
return base;
return { width: base.width, height: base.height, bubbleBelow: false, bubbleMaxHeight: defaultMaxHeight, bubbleMaxHeightLong: defaultMaxHeightLong, bubbleMaxHeightVeryLong: defaultMaxHeightVeryLong };
}
const anchor = readWindowPosition(window);
const displayForBounds = screen.getDisplayMatching(window.getBounds()) ?? screen.getPrimaryDisplay();
const workArea = displayForBounds.workArea;
const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale);
@ -1512,6 +1555,16 @@ function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display:
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8);
const petFootprintExtra = Math.max(0, Math.ceil((scale - 1) * 44));
const chooseDirection = (neededExtra: number): { readonly bubbleBelow: boolean; readonly available: number } => {
const spaceAbove = Math.max(0, anchor.y - workArea.y);
const spaceBelow = Math.max(0, workArea.y + workArea.height - (anchor.y + base.height));
if (neededExtra <= spaceAbove) return { bubbleBelow: false, available: spaceAbove };
if (neededExtra <= spaceBelow) return { bubbleBelow: true, available: spaceBelow };
return spaceBelow >= spaceAbove
? { bubbleBelow: true, available: spaceBelow }
: { bubbleBelow: false, available: spaceAbove };
};
if (display?.fullMessage) {
const width = clampNumber(
Math.round(340 + Math.min(220, Math.max(0, message.length - 80)) * 1.05),
@ -1521,23 +1574,29 @@ function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display:
const charsPerLine = Math.max(28, Math.floor((width - 56) / 7.2));
const estimatedLines = estimateWrappedLineCount(message, charsPerLine);
const bubbleHeight = 74 + estimatedLines * 16 + petFootprintExtra;
const neededExtra = Math.max(0, Math.round(bubbleHeight - 132));
const { bubbleBelow, available } = chooseDirection(neededExtra);
const height = clampNumber(
Math.round(base.height + bubbleHeight - 132),
Math.max(base.height, base.height + Math.min(neededExtra, available)),
base.height,
Math.max(base.height, workArea.height - 28),
);
return { width, height };
const maxHeight = Math.max(120, height - bubbleBottom - 8);
return { width, height, bubbleBelow, bubbleMaxHeight: maxHeight, bubbleMaxHeightLong: maxHeight, bubbleMaxHeightVeryLong: maxHeight };
}
// Normal message: base width, ensure window is tall enough for the scrollable bubble
const bubbleMaxHeight = 168;
const minHeightForBubble = bubbleBottom + bubbleMaxHeight + 8;
const neededExtra = Math.max(0, bubbleBottom + defaultMaxHeight + 8 - base.height);
const { bubbleBelow, available } = chooseDirection(neededExtra);
const maxHeight = Math.max(80, Math.min(defaultMaxHeight, available - bubbleBottom - 8));
const height = clampNumber(
Math.max(base.height, minHeightForBubble),
bubbleBelow ? base.height + available : bubbleBottom + maxHeight + 8,
base.height,
Math.max(base.height, workArea.height - 28),
);
return { width: base.width, height };
const longMax = Math.max(80, Math.min(defaultMaxHeightLong, maxHeight + 40));
const veryLongMax = Math.max(80, Math.min(defaultMaxHeightVeryLong, maxHeight + 100));
return { width: base.width, height, bubbleBelow, bubbleMaxHeight: maxHeight, bubbleMaxHeightLong: longMax, bubbleMaxHeightVeryLong: veryLongMax };
}
function estimateWrappedLineCount(message: string, charsPerLine: number): number {