From bf059b19cf39cdf777fd301aadd4de0ed8a07df9 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Tue, 25 Aug 2026 14:15:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BB=9E=E7=A9=BF=E6=94=B9=E7=94=B1=E4=B8=BB?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E6=B1=BA=E5=AE=9A=E4=B8=A6=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E6=BB=91=E9=BC=A0=E8=A8=BA=E6=96=B7=EF=BC=9A=E4=BF=AE=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E6=8B=96=E6=9B=B3=E3=80=81=E4=B8=8D=E8=83=BD=E5=8F=B3?= =?UTF-8?q?=E9=8D=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根本原因: 點穿狀態一直是 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 --- README.md | 9 ++- lib/hit-test.js | 23 +++++++ main.js | 150 ++++++++++++++++++++++++++++++++++++------- package-lock.json | 4 +- package.json | 2 +- preload.js | 4 +- renderer/renderer.js | 54 +++++++++------- 7 files changed, 191 insertions(+), 55 deletions(-) create mode 100644 lib/hit-test.js diff --git a/README.md b/README.md index 4e5531b..a330fa3 100644 --- a/README.md +++ b/README.md @@ -221,14 +221,14 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO │ 視窗 / 系統匣 / 游標輪詢 / 拖曳 / 設定 ▼ IPC renderer/renderer.js - 狀態機 + 幀動畫 + 追視 + 氣泡 + 方框點穿判定 + 狀態機 + 幀動畫 + 追視 + 氣泡 + 矩形回報 ``` | 檔案 | 職責 | |---|---| | `main.js` | Electron 主程序:透明置頂視窗、系統匣選單、HTTP 伺服器、游標輪詢(50 ms)、拖曳(60 fps)、設定讀寫、寵物掃描 | | `preload.js` | 以 contextBridge 暴露白名單 IPC 頻道給 renderer(sandbox 模式) | -| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、滑鼠方框判定 | +| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、hover 判定與人物/氣泡矩形回報 | | `renderer/index.html`、`style.css` | 版面與氣泡樣式(CSP 僅允許 self / data:) | | `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST;800 ms 逾時、永遠 exit 0 | | `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) | @@ -241,7 +241,10 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO 設計重點: -- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`,renderer 依游標是否在**人物方框**或氣泡上即時切換,方框以外永遠點得到底下的視窗。判定用方框而不是讀 canvas 該點的 alpha:人物的手腳與邊緣會隨動畫在透明/不透明之間切換(以小念為例,hover 時只有 44% 的人物像素每一幀都不透明),逐像素判定會讓點穿狀態跟著動畫高速抖動,按下去那一瞬間剛好是透明格,滑鼠事件就穿到底下的視窗去了。判定的座標有兩個來源:renderer 自己的 mousemove,以及**主程序每 50 ms 的游標輪詢**。後者是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到),只靠 mousemove 會在「游標還停在寵物身上時進入點穿」之後再也收不到事件,變成永遠卡在點穿、按不下去也拖不動。 +- **點穿由主程序決定**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`;主程序每 50 ms 自己輪詢游標(`screen.getCursorScreenPoint()`),對照 renderer 回報的**人物方框**與(顯示中的)氣泡矩形,游標在上面就接滑鼠、否則穿透(`lib/hit-test.js`,純邏輯可單元測試)。決策不經過 renderer:以前是 renderer 判定再用 IPC 叫主程序切,renderer 一旦例外、卡住或狀態錯亂(例如放開事件沒送到、`pressed` 一直是 true),點穿就永遠不會再更新,寵物就點不到了。renderer 沒回報矩形或掛掉時,主程序用版面公式算方框當備援;renderer 掛掉會記錄並自動重載。判定用方框而不是讀 canvas 該點的 alpha:人物的手腳與邊緣會隨動畫在透明/不透明之間切換(以小念為例,hover 時只有 44% 的人物像素每一幀都不透明),逐像素判定會讓點穿狀態跟著動畫高速抖動,按下去那一瞬間剛好是透明格,滑鼠事件就穿到底下的視窗去了。主程序輪詢也是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到)。 +- **拖曳結束一定通知 renderer**:視窗永遠不取得焦點,Windows 對背景視窗的滑鼠 capture 有限制,放開的那一下若落在視窗外 renderer 收不到 mouseup。主程序不管因為 mouseup、逾時(20 秒)或其他原因結束拖曳,都送 `pet:drag-ended` 讓 renderer 清掉按住/拖曳狀態。 +- **右鍵選單開著時暫停置頂重宣告**:否則 `moveTop()` 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到。 +- **診斷**:`GET /state` 除了動畫狀態,還會回 `bounds` 與 `mouse`(目前是否點穿、游標的視窗相對座標、是否在方框/氣泡內、矩形來源是 renderer 還是備援、切換次數、輪詢次數、renderer 錯誤數、最近幾次拖曳結束的原因);renderer 的例外、拖曳開始/結束、選單開關都會寫進事件紀錄。 - **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。 - **置頂要定期重新宣告**:Windows 有時候會把視窗踢出 topmost band,卻留著 `WS_EX_TOPMOST` 樣式位元——從外部檢查看到 `TOPMOST=true`,但 z-order 排在一般視窗(例如 VS Code)之下,寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。所以每 2 秒重新宣告一次,並在螢幕配置變動時立刻補一次。`setAlwaysOnTop` 在狀態沒變時可能不會真的呼叫 `SetWindowPos`,因此再補一個 `moveTop()` 強制插回 topmost band 最上面;視窗是 `WS_EX_NOACTIVATE` + `focusable: false`,不會搶焦點。 - **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。 diff --git a/lib/hit-test.js b/lib/hit-test.js new file mode 100644 index 0000000..07bcc5a --- /dev/null +++ b/lib/hit-test.js @@ -0,0 +1,23 @@ +"use strict"; +// 點穿決策的純邏輯,由主程序使用;抽出來是為了能在 node 直接單元測試(main.js 一載入就 require electron)。 + +function inside(r, p) { + return !!r && !!p && p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h; +} + +// 人物方框在視窗內的位置,與 renderer 的版面一致(#stage 水平置中、上方保留氣泡高度、底部留白)。 +// renderer 活著時會回報實際量到的矩形,這只是它還沒回報或掛掉時的備援。 +function petBoxRect(bounds, scale, { petW, petH, bubbleH }) { + const w = Math.round(petW * scale); + const h = Math.round(petH * scale); + return { x: Math.round((bounds.width - w) / 2), y: bubbleH, w, h }; +} + +// 這一刻視窗該不該點穿:拖曳中一律接滑鼠;否則游標在人物方框或氣泡上才接,其餘穿透。 +function wantIgnore({ rel, box, bubble, dragging }) { + if (dragging) return false; + if (!rel) return true; + return !(inside(box, rel) || inside(bubble, rel)); +} + +module.exports = { inside, petBoxRect, wantIgnore }; diff --git a/main.js b/main.js index 094ffa6..f9bdbba 100644 --- a/main.js +++ b/main.js @@ -38,6 +38,8 @@ const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Cod // Codex 的 mascot 寬度可調範圍是 80–224 px(ike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內 const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75]; const TOPMOST_REASSERT_MS = 2000; // 多久重新宣告一次置頂(見 reassertTopmost) +const DRAG_MAX_MS = 20000; // 拖曳最長時間:放開事件若沒送到,別讓寵物一直跟著游標(見 endDrag) +const { inside, petBoxRect, wantIgnore } = require("./lib/hit-test"); const DEFAULT_CONFIG = { activePetId: "xiao-nian", @@ -58,6 +60,16 @@ let dragTimer = null; let cursorTimer = null; let topmostTimer = null; let lastCursor = { x: NaN, y: NaN }; +// 點穿狀態由主程序自己決定與追蹤(見 updateIgnore);renderer 只回報它量到的矩形 +let ignoring = true; +let hitRects = null; // renderer 回報的 { box, bubble }(視窗相對座標),null 表示還沒回報 +let menuOpenedAt = 0; // 右鍵選單打開的時間,0 表示沒開 +// 給 /state 看的診斷資料:下次「點不到寵物」時不用猜,直接看這裡 +const mouseDiag = { + toggles: 0, lastToggleAt: null, polls: 0, lastPollAt: null, + rel: null, inBox: false, inBubble: false, + rendererErrors: 0, lastRendererError: null, dragEnds: [], +}; let currentState = { anim: "idle", base: "idle", sessions: 0 }; // ---------- config ---------- @@ -286,6 +298,14 @@ function createWindow() { reassertTopmost(); }); win.on("closed", () => { win = null; }); + // renderer 掛了就記下來並重載;點穿判定在主程序,寵物不會因此卡成點不到 + win.webContents.on("render-process-gone", (_e, details) => { + appendLog(`renderer gone: ${details?.reason || "unknown"}`); + hitRects = null; + setTimeout(() => { if (win && !win.isDestroyed()) win.webContents.reload(); }, 500); + }); + win.webContents.on("unresponsive", () => appendLog("renderer unresponsive")); + win.webContents.on("responsive", () => appendLog("renderer responsive again")); } function send(channel, data) { @@ -303,12 +323,15 @@ function sendInit() { // ---------- drag ---------- function startDrag(offsetX, offsetY) { - if (!win) return; - endDrag(false); + if (!win || win.isDestroyed()) return; + endDrag(false, "restart"); let last = screen.getCursorScreenPoint(); const startedAt = Date.now(); + appendLog(`drag start offset=${offsetX},${offsetY}`); + applyIgnore(false); dragTimer = setInterval(() => { - if (!win || Date.now() - startedAt > 60000) return endDrag(true); + if (!win || win.isDestroyed()) return endDrag(false, "no-window"); + if (Date.now() - startedAt > DRAG_MAX_MS) return endDrag(true, "timeout"); const p = screen.getCursorScreenPoint(); const dx = p.x - last.x; last = p; @@ -317,16 +340,22 @@ function startDrag(offsetX, offsetY) { }, 16); } -function endDrag(save) { - if (dragTimer) { - clearInterval(dragTimer); - dragTimer = null; - } - if (save && win) { +// 視窗永遠不取得焦點(focusable:false),Windows 對背景視窗的滑鼠 capture 有限制: +// 放開的那一下若剛好落在視窗外,renderer 收不到 mouseup。所以結束拖曳的原因不只 mouseup, +// 而且不管誰結束的都要通知 renderer 清掉按住/拖曳狀態,免得它一直以為還在拖。 +function endDrag(save, reason = "mouseup") { + if (!dragTimer) return; + clearInterval(dragTimer); + dragTimer = null; + if (save && win && !win.isDestroyed()) { const [x, y] = win.getPosition(); config.position = { x, y }; saveConfig(); } + appendLog(`drag end reason=${reason} pos=${config.position ? `${config.position.x},${config.position.y}` : "?"}`); + mouseDiag.dragEnds.push({ at: Date.now(), reason }); + if (mouseDiag.dragEnds.length > 10) mouseDiag.dragEnds.shift(); + send("pet:drag-ended"); } // ---------- cursor look ---------- @@ -337,7 +366,8 @@ function endDrag(save) { // setAlwaysOnTop 在狀態沒變時可能不會真的呼叫 SetWindowPos,所以再補一個 moveTop() // 強制重新插回 topmost band 的最上面(視窗是 WS_EX_NOACTIVATE + focusable:false,不會搶焦點)。 function reassertTopmost() { - if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer) return; + // 選單開著時不做:moveTop 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到了 + if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer || menuIsOpen()) return; try { win.setAlwaysOnTop(true, "screen-saver"); win.moveTop(); @@ -352,20 +382,55 @@ function startTopmostGuard() { screen.on("display-removed", reassertTopmost); } +function menuIsOpen() { + // popup 的 callback 關閉時一定會來;保險起見超過 60 秒就當它已經關了 + return menuOpenedAt > 0 && Date.now() - menuOpenedAt < 60000; +} + +function currentHitRects() { + if (hitRects) return hitRects; + if (!win || win.isDestroyed()) return { box: null, bubble: null }; + // renderer 還沒回報(或掛了):用版面公式算人物方框,氣泡就當沒有 + return { box: petBoxRect(win.getBounds(), config.scale, { petW: PET_W, petH: PET_H, bubbleH: BUBBLE_H }), bubble: null }; +} + +// 點穿只在這裡切換。以前是 renderer 判定再用 IPC 叫主程序切:renderer 一旦例外、卡住或狀態錯亂 +// (例如放開事件沒送到、pressed 一直是 true),點穿就永遠不會再更新——這是「不能拖曳也不能右鍵」 +// 最難查的一種成因。現在主程序拿自己輪詢到的游標、renderer 回報的矩形、是否拖曳中,自己決定。 +function applyIgnore(ignore) { + if (!win || win.isDestroyed() || ignore === ignoring) return; + ignoring = ignore; + mouseDiag.toggles++; + mouseDiag.lastToggleAt = Date.now(); + try { win.setIgnoreMouseEvents(ignore, { forward: true }); } catch { /* 視窗正在關閉 */ } + send("pet:ignore-state", ignore); +} + +function updateIgnore(rel) { + const { box, bubble } = currentHitRects(); + mouseDiag.rel = rel; + mouseDiag.inBox = inside(box, rel); + mouseDiag.inBubble = inside(bubble, rel); + applyIgnore(wantIgnore({ rel, box, bubble, dragging: !!dragTimer })); +} + function startCursorPolling() { cursorTimer = setInterval(() => { - if (!win || dragTimer) return; - const p = screen.getCursorScreenPoint(); + if (!win || win.isDestroyed()) return; + let p; + try { p = screen.getCursorScreenPoint(); } catch { return; } + mouseDiag.polls++; + mouseDiag.lastPollAt = Date.now(); + const b = win.getBounds(); + const rel = { x: p.x - b.x, y: p.y - b.y }; + updateIgnore(rel); + if (dragTimer) return; // 拖曳中視窗跟著游標走,座標交給 drag loop if (p.x === lastCursor.x && p.y === lastCursor.y) return; lastCursor = p; - const b = win.getBounds(); - // 點穿判定不能只靠 renderer 的 mousemove。視窗處於點穿狀態時,Electron 的 - // setIgnoreMouseEvents(true, { forward: true }) 並不保證一定會把 mousemove 轉發進來 - // (游標直接跳到寵物上常常收不到,慢慢移過去才會)。一旦在游標還停在寵物身上時 - // 進入點穿,就再也收不到事件、ignoringMouse 永遠是 true,滑鼠按下也傳不進視窗 - // → 就是「有時候卡住無法拖曳」。主程序自己輪詢絕對不會漏,所以改由這裡把 - // 視窗相對座標送過去,讓 renderer 重新做一次判定。 - send("pet:cursor-pos", { x: p.x - b.x, y: p.y - b.y }); + // hover(跳三下)與拖曳起點仍由 renderer 處理,所以座標照送。視窗處於點穿狀態時 + // Electron 的 setIgnoreMouseEvents(true, { forward: true }) 並不保證會把 mousemove 轉發進來 + // (游標直接跳到寵物上常常收不到),主程序自己輪詢絕對不會漏。 + send("pet:cursor-pos", rel); if (!config.followCursor) return; const cx = b.x + b.width / 2; const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心,與 Codex 相同 @@ -407,7 +472,19 @@ function startServer() { } if (req.method === "GET" && req.url === "/state") { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ ...currentState, pet: activePet()?.id || null, scale: config.scale })); + res.end(JSON.stringify({ + ...currentState, + pet: activePet()?.id || null, + scale: config.scale, + bounds: win && !win.isDestroyed() ? win.getBounds() : null, + mouse: { + ignoring, + dragging: !!dragTimer, + menuOpen: menuIsOpen(), + rects: { ...currentHitRects(), source: hitRects ? "renderer" : "fallback" }, + ...mouseDiag, + }, + })); return; } if (req.method === "GET" && req.url === "/health") { @@ -593,10 +670,28 @@ function resetPosition() { // ---------- IPC ---------- ipcMain.on("pet:ready", () => sendInit()); -ipcMain.on("pet:ignore-mouse", (_e, ignore) => win?.setIgnoreMouseEvents(!!ignore, { forward: true })); +function sanitizeRect(r) { + if (!r || typeof r !== "object") return null; + const v = ["x", "y", "w", "h"].map((k) => Number(r[k])); + return v.every(Number.isFinite) ? { x: v[0], y: v[1], w: v[2], h: v[3] } : null; +} +ipcMain.on("pet:hit-rects", (_e, rects) => { + const box = sanitizeRect(rects?.box); + hitRects = box ? { box, bubble: sanitizeRect(rects?.bubble) } : null; + // 氣泡剛出現在靜止的游標底下這種情況,不等下一次游標移動就重算 + if (win && !win.isDestroyed() && Number.isFinite(lastCursor.x)) { + const b = win.getBounds(); + updateIgnore({ x: lastCursor.x - b.x, y: lastCursor.y - b.y }); + } +}); ipcMain.on("pet:drag-start", (_e, { offsetX, offsetY }) => startDrag(Number(offsetX) || 0, Number(offsetY) || 0)); ipcMain.on("pet:drag-end", () => endDrag(true)); -ipcMain.on("pet:context-menu", () => buildMenu().popup({ window: win })); +ipcMain.on("pet:context-menu", () => { + if (!win || win.isDestroyed()) return; + menuOpenedAt = Date.now(); // 開著的期間暫停置頂重宣告,見 reassertTopmost + appendLog("menu open"); + buildMenu().popup({ window: win, callback: () => { menuOpenedAt = 0; appendLog("menu closed"); } }); +}); ipcMain.on("pet:tray-icon", (_e, dataUrl) => { try { tray?.setImage(nativeImage.createFromDataURL(dataUrl)); } catch (err) { console.error(err); } }); @@ -604,7 +699,14 @@ ipcMain.on("pet:state", (_e, state) => { currentState = { ...currentState, ...state }; refreshTray(); }); -ipcMain.on("pet:log", (_e, line) => appendLog(String(line))); +ipcMain.on("pet:log", (_e, line) => { + const s = String(line); + if (s.includes("[renderer] error")) { + mouseDiag.rendererErrors++; + mouseDiag.lastRendererError = s.slice(0, 200); + } + appendLog(s); +}); ipcMain.on("pet:greeted", (_e, id) => { if (typeof id !== "string" || config.greetedPetIds.includes(id)) return; config.greetedPetIds.push(id); diff --git a/package-lock.json b/package-lock.json index 818efab..645446d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-pet", - "version": "2.0.10", + "version": "2.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-pet", - "version": "2.0.10", + "version": "2.0.11", "devDependencies": { "electron": "^43.4.1", "electron-builder": "^26.0.12" diff --git a/package.json b/package.json index 634121c..f162348 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-pet", - "version": "2.0.10", + "version": "2.0.11", "description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)", "main": "main.js", "private": true, diff --git a/preload.js b/preload.js index 23c9674..6f35ef4 100644 --- a/preload.js +++ b/preload.js @@ -3,7 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron"); const SEND = new Set([ "pet:ready", - "pet:ignore-mouse", + "pet:hit-rects", "pet:drag-start", "pet:drag-end", "pet:context-menu", @@ -22,6 +22,8 @@ const ON = new Set([ "pet:cursor", "pet:cursor-pos", "pet:drag-move", + "pet:drag-ended", + "pet:ignore-state", ]); contextBridge.exposeInMainWorld("pet", { diff --git a/renderer/renderer.js b/renderer/renderer.js index 2ee7dd7..b069a39 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -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();