點穿改由主程序決定並加入滑鼠診斷:修不能拖曳、不能右鍵

根本原因:
點穿狀態一直是 renderer 判定後再用 IPC 叫主程序切換。renderer 只要例外、
卡住或狀態錯亂(例如視窗永遠不取得焦點、Windows 對背景視窗的 capture 有限制,
放開的那一下落在視窗外就收不到 mouseup,pressed 從此卡在 true),主程序就
再也收不到切換指令,寵物永遠點不到。另外 2.0.8 起每 2 秒的置頂重宣告會在
右鍵選單開著時把寵物視窗抬到選單上面,2.0.10 改成整個方框接滑鼠後,被蓋住
的選單項目就點不到了。

影響:
偶發「不能拖曳、不能右鍵」,而且發生時沒有任何可查的狀態或紀錄。

修法:
1. 主程序每 50 ms 自己輪詢游標,對照 renderer 回報的人物方框與氣泡矩形
   (pet:hit-rects)直接呼叫 setIgnoreMouseEvents;決策邏輯抽成 lib/hit-test.js。
   renderer 沒回報或掛掉時用版面公式算方框當備援,並自動重載。
2. 拖曳不管因 mouseup、逾時(20 秒)或其他原因結束,都送 pet:drag-ended
   讓 renderer 清掉按住/拖曳狀態。
3. 右鍵選單開著時暫停置頂重宣告。
4. /state 回傳 bounds 與 mouse 診斷(是否點穿、游標相對座標、是否在方框/
   氣泡、矩形來源、切換與輪詢次數、renderer 錯誤、拖曳結束原因);renderer
   例外、拖曳開始/結束、選單開關寫入事件紀錄。
版號 2.0.11。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 14:15:29 +08:00
co-authored by Claude Fable 5
parent a9497cc123
commit bf059b19cf
7 changed files with 191 additions and 55 deletions
+30 -24
View File
@@ -148,7 +148,6 @@ function startAnim(anim, now) {
let bubbleTimer = null;
let bubbleSticky = false;
let ignoringMouse = true;
let hovered = false; // 游標是否在人物方框內
let lastPoint = null; // 最後已知的游標位置(視窗相對),主程序輪詢或 mousemove 都會更新
let pressed = null;
@@ -158,6 +157,10 @@ function log(msg) {
try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
}
// renderer 的例外以前只會出現在 DevTools,包成 exe 後根本看不到;寫進事件紀錄才查得到
window.addEventListener("error", (e) => log(`error: ${e.message} (${e.filename}:${e.lineno})`));
window.addEventListener("unhandledrejection", (e) => log(`error: unhandled rejection: ${e.reason?.message || e.reason}`));
// ---------- 載入 ----------
function loadPet(pet) {
@@ -186,6 +189,7 @@ function applyScale(s) {
canvas.height = Math.round(PET_H * s);
canvas.style.width = `${canvas.width}px`;
canvas.style.height = `${canvas.height}px`;
reportHitRects();
}
// 量一格的人物輪廓(用來偵測圖檔裡整列被畫小的缺陷)
@@ -383,6 +387,7 @@ function say(text, ms, sticky = false, title = "") {
bubble.title = title ? `${title}\n${text}` : text;
bubble.classList.remove("hidden");
bubbleSticky = sticky;
reportHitRects();
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
}
@@ -390,6 +395,7 @@ function hideBubble() {
clearTimeout(bubbleTimer);
bubble.classList.add("hidden");
bubbleSticky = false;
reportHitRects();
}
// ---------- 事件 ----------
@@ -621,16 +627,18 @@ setInterval(() => {
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
function overBubble(x, y) {
if (bubble.classList.contains("hidden")) return false;
const r = bubble.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
// 主程序自己輪詢游標並決定點穿(見 main.js 的 updateIgnore),這裡只要回報「哪裡算是寵物」:
// 人物方框與(顯示中的)氣泡的視窗相對矩形。版面一變(縮放、氣泡出現/消失/改字)就重報一次。
// 用方框不用逐像素 alpha:sprite 每一格的輪廓不同,人物的手腳與邊緣會隨動畫在透明/不透明之間
// 切換,逐像素會讓點穿跟著動畫抖,按下去那一瞬間剛好是透明格就穿到底下去。Codex 也是用矩形。
function reportHitRects() {
const rect = (r) => ({ x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });
const b = bubble.classList.contains("hidden") ? null : bubble.getBoundingClientRect();
window.pet.send("pet:hit-rects", { box: rect(canvas.getBoundingClientRect()), bubble: b ? rect(b) : null });
}
window.addEventListener("resize", reportHitRects);
// hover 與點穿都用人物方框,不用逐像素 alpha:sprite 每一格的輪廓不同,人物的手腳與邊緣
// 會隨動畫在透明/不透明之間切換,逐像素會讓游標停著不動時也在 true/false 之間高速抖動;
// 點穿跟著抖,按下去那一瞬間剛好是透明格,滑鼠事件就穿到底下的視窗去了(看起來就是
// 「又被穿透了、不能拖曳也不能右鍵」)。Codex 的 pet-area-hover 同樣是用矩形算的。
// hover 判定同樣用人物方框Codex 的 pet-area-hover 也是 rect
function overPetArea(x, y) {
const r = canvas.getBoundingClientRect();
return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
@@ -655,21 +663,10 @@ function setHovered(on) {
if (overlay?.fromHover) overlay = null;
}
// 命中判定集中在這裡:mousemove 與主程序輪詢都走同一條路
// 主程序每 50 ms 一定會送座標過來,所以就算 mousemove 沒被轉發進來也能復原。
// 命中判定只剩 hover(跳三下):mousemove 與主程序輪詢都走同一條路
function refreshHitTest() {
if (!lastPoint || pressed || drag) return; // 拖曳中不要動點穿狀態
const { x, y } = lastPoint;
const inArea = overPetArea(x, y);
setHovered(inArea);
setIgnore(!(inArea || overBubble(x, y)));
}
function setIgnore(ignore) {
if (ignore === ignoringMouse) return;
ignoringMouse = ignore;
window.pet.send("pet:ignore-mouse", ignore);
canvas.classList.toggle("grab", !ignore);
if (!lastPoint || pressed || drag) return;
setHovered(overPetArea(lastPoint.x, lastPoint.y));
}
window.addEventListener("mousemove", (e) => {
@@ -690,7 +687,6 @@ document.addEventListener("mouseout", (e) => {
if (e.relatedTarget || pressed) return;
lastPoint = null;
setHovered(false);
setIgnore(true);
});
window.addEventListener("mousedown", (e) => {
@@ -727,6 +723,16 @@ window.pet.on("pet:drag-move", ({ dx }) => {
if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right";
});
// 主程序結束了拖曳(mouseup、逾時或其他原因):不管這邊有沒有收到 mouseup,一律把狀態清掉
window.pet.on("pet:drag-ended", () => {
pressed = null;
drag = null;
canvas.classList.remove("grabbing");
});
// 主程序切了點穿狀態:只負責換游標樣式
window.pet.on("pet:ignore-state", (ignore) => canvas.classList.toggle("grab", !ignore));
function onClick() {
if (!bubble.classList.contains("hidden") && !bubbleSticky) {
hideBubble();