2 Commits
Author SHA1 Message Date
JianMiauandClaude Fable 5 cd4b618be0 補上遺漏的氣泡標題列
摘要:
整理記憶中、需要你授權、工具失敗、錯誤訊息、一般通知、掰掰這六個氣泡補上第一行標題。

根本原因:
上一版把氣泡改成兩行時,是以「原本帶(專案名)尾綴」為線索去改呼叫點,
但這六個訊息本來就沒有尾綴,所以整批漏掉,顯示出來只有單行、沒有專案或對話名稱。

影響:
壓縮記憶、等授權、出錯這些最需要知道是哪個 session 的時候,反而看不到來源。

修法:
- handleEvent 內六個 say() 補上 title 參數。
- 選單「動作測試」的氣泡給標題「動作測試」,才分得出是測試不是真的事件。
- first-awake 的「嗨,我是〈寵物名〉」維持沒有標題:它不屬於任何 session,
  訊息本身已含寵物名,CSS 的 :empty 會把空標題整行收起來,不會留空白。

驗證:
以 DOM stub 載入完整 renderer.js 驅動 handleEvent,11 項全過:
PreCompact 的標題與訊息、其餘五個補上的事件都帶標題、原本就有的「完成」仍正常、
對話名稱優先於專案名、無 cwd 也無對話名時標題為空字串、滑鼠停留提示為兩行。
另以靜態掃描確認 handleEvent 內全部 13 個 say() 都帶 title,
並在打包後的 app.asar 上重跑同一項掃描。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:06:50 +08:00
JianMiauandClaude Fable 5 42083acec5 修正有時候卡住無法拖曳:點穿狀態改由主程序輪詢驅動
摘要:
點穿的命中判定不再只依賴 renderer 的 mousemove,改由主程序每 50 ms 的游標輪詢一起驅動,
並在每格畫完後用最後已知座標重驗一次。

根本原因:
視窗預設 setIgnoreMouseEvents(true, { forward: true }),而唯一會把它切回可互動的地方
是 renderer 的 mousemove。問題是視窗處於點穿狀態時,Electron 的 forward 不保證會把
mousemove 轉發進來——本次開發過程中已實測到:游標直接跳到寵物上收不到事件,
慢慢掃過去才收得到。
於是只要在「游標還停在寵物身上」時進入點穿(例如游標停在某一點,動畫換格後
該點的像素變成透明),就再也收不到 mousemove,ignoringMouse 永遠是 true,
滑鼠按下也傳不進視窗,就變成按不動也拖不動,而且不會自己好。
另外 startCursorPolling 的內容原本被 `!config.followCursor` 整段擋掉,
而追視預設為關,所以主程序根本沒有在追游標,沒有任何備援路徑。

影響:
寵物有時候完全無法拖曳或點擊,只能重啟;游標停在人物邊緣或半透明處時特別容易發生。

修法:
- startCursorPolling 的守衛從 `!win || !config.followCursor || dragTimer` 改為
  `!win || dragTimer`,輪詢一律執行;只有送 pet:cursor(追視用)那一行仍看 followCursor。
- 主程序新增 pet:cursor-pos,送出視窗相對座標;preload 開放該頻道。
- renderer 把命中判定集中成 refreshHitTest(),mousemove 與 pet:cursor-pos 走同一條路,
  並在 pressed / drag 期間不動點穿狀態,避免拖曳中途鬆手。
- tick 畫完後若最後已知座標落在人物方框內就重驗一次,處理「游標沒動但換格導致
  該點像素改變」的情況。
- mouseout 時清掉 lastPoint。
- README 的設計重點補上為何不能只靠 mousemove。

驗證:
- 實機加暫時診斷確認 pet:cursor-pos 確實送達 renderer,且回報的 canvasRect
  (57,72,183,209)與依視窗尺寸手算的結果一致;確認後移除診斷碼。
- 以 DOM stub 載入完整 renderer.js 並讓 getImageData 的 alpha 可切換,14 項全過:
  壓在人物上會解除點穿並觸發 hover 跳躍、有通知主程序、方框內但透明維持點穿、
  方框外點穿且非 hover、拖曳中與按住中都不改點穿、pet:cursor-pos 有註冊且
  直接跳到人物上也能解除點穿、以及最關鍵的一組——先進入點穿狀態,
  再由 pet:cursor-pos 在同一點換格後把它救回來,這正是原本卡死的情境。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.6 與九項改動都在出貨檔案裡。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:51:28 +08:00
6 changed files with 43 additions and 15 deletions
+1 -1
View File
@@ -241,7 +241,7 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
設計重點:
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`renderer 在 mousemove 時讀 canvas 該點 alpha(或是否在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`renderer 讀 canvas 該點 alpha(或是否在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。判定的座標有兩個來源:renderer 自己的 mousemove,以及**主程序每 50 ms 的游標輪詢**。後者是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到),只靠 mousemove 會在「游標還停在寵物身上時進入點穿」之後再也收不到事件,變成永遠卡在點穿、按不下去也拖不動。另外每一格畫完也會用最後已知座標重驗一次,因為換格會改變游標底下的像素。
- **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。
- **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。
- **顯示尺寸與 sprite 來源尺寸分開**:`CELL_W/CELL_H`192 × 208)只用來從 spritesheet 取格子,畫到畫面上的是 `PET_W/PET_H`126 × 137Codex 的 mascot 尺寸)。視窗寬度另有 `MIN_WIN_W = 240` 的下限,免得寵物變小後氣泡被截掉。
+9 -1
View File
@@ -328,11 +328,19 @@ function endDrag(save) {
function startCursorPolling() {
cursorTimer = setInterval(() => {
if (!win || !config.followCursor || dragTimer) return;
if (!win || dragTimer) return;
const p = screen.getCursorScreenPoint();
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 });
if (!config.followCursor) return;
const cx = b.x + b.width / 2;
const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心,與 Codex 相同
send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-pet",
"version": "2.0.5",
"version": "2.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-pet",
"version": "2.0.5",
"version": "2.0.7",
"devDependencies": {
"electron": "^43.4.1",
"electron-builder": "^26.0.12"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-pet",
"version": "2.0.5",
"version": "2.0.7",
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 29 個動作列 + 16 方向追視)",
"main": "main.js",
"private": true,
+1
View File
@@ -20,6 +20,7 @@ const ON = new Set([
"pet:settings",
"pet:event",
"pet:cursor",
"pet:cursor-pos",
"pet:drag-move",
]);
+29 -10
View File
@@ -150,6 +150,7 @@ let bubbleTimer = null;
let bubbleSticky = false;
let ignoringMouse = true;
let hovered = false; // 游標是否壓在人物的不透明像素上
let lastPoint = null; // 最後已知的游標位置(視窗相對),主程序輪詢或 mousemove 都會更新
let pressed = null;
let lastReported = "";
@@ -428,11 +429,11 @@ function handleEvent(p) {
case "PostToolUseFailure":
// Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡
setState(s, "running");
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000, false, title);
break;
case "PermissionRequest":
setState(s, "waiting");
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true);
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true, title);
break;
case "Notification": {
const t = p.notificationType;
@@ -446,7 +447,7 @@ function handleEvent(p) {
setState(s, "waiting");
say("❓ 有問題想問你", 0, true, title);
} else if (p.message) {
say(`💬 ${p.message}`, 4000);
say(`💬 ${p.message}`, 4000, false, title);
}
break;
}
@@ -460,7 +461,7 @@ function handleEvent(p) {
case "StopFailure":
// Codexturn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
setState(s, "failed");
say(`${p.error || "出錯了"}`, 8000);
say(`${p.error || "出錯了"}`, 8000, false, title);
break;
case "SubagentStart":
case "SubagentStop":
@@ -468,13 +469,13 @@ function handleEvent(p) {
break;
case "PreCompact":
setState(s, "running");
say("🗜️ 整理記憶中…", 3000);
say("🗜️ 整理記憶中…", 3000, false, title);
break;
case "SessionEnd":
sessions.delete(p.sessionId || "default");
if (sessions.size === 0) {
hideBubble();
say("👋 掰掰", 2000);
say("👋 掰掰", 2000, false, title);
}
break;
default:
@@ -494,7 +495,7 @@ function handleTest(p) {
} else if (anim in PRIORITY) {
const s = session("test");
setState(s, anim);
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true);
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true, "動作測試");
else if (anim === "idle") hideBubble();
}
report();
@@ -545,6 +546,9 @@ function tick(now) {
prevBase = base;
draw(anim);
// 換格會改變游標底下的像素。游標沒移動時主程序不會再送座標,
// 所以這裡自己重驗一次,否則會卡在點穿狀態動不了。
if (lastPoint && overPetArea(lastPoint.x, lastPoint.y)) refreshHitTest();
requestAnimationFrame(tick);
}
@@ -656,6 +660,15 @@ function setHovered(on) {
if (overlay?.fromHover) overlay = null;
}
// 命中判定集中在這裡:mousemove 與主程序輪詢都走同一條路。
// 主程序每 50 ms 一定會送座標過來,所以就算 mousemove 沒被轉發進來也能復原。
function refreshHitTest() {
if (!lastPoint || pressed || drag) return; // 拖曳中不要動點穿狀態
const { x, y } = lastPoint;
setHovered(overPetArea(x, y));
setIgnore(!(overSprite(x, y) || overBubble(x, y)));
}
function setIgnore(ignore) {
if (ignore === ignoringMouse) return;
ignoringMouse = ignore;
@@ -673,13 +686,13 @@ window.addEventListener("mousemove", (e) => {
}
return;
}
setHovered(overPetArea(e.clientX, e.clientY));
// 點穿判定仍看實際像素,透明處要能點到底下的視窗
setIgnore(!(overSprite(e.clientX, e.clientY) || overBubble(e.clientX, e.clientY)));
lastPoint = { x: e.clientX, y: e.clientY };
refreshHitTest();
});
document.addEventListener("mouseout", (e) => {
if (e.relatedTarget || pressed) return;
lastPoint = null;
setHovered(false);
setIgnore(true);
});
@@ -707,6 +720,12 @@ window.addEventListener("mouseup", (e) => {
window.addEventListener("contextmenu", (e) => e.preventDefault());
// 主程序的游標輪詢:不管 mousemove 有沒有被轉發進來都會到
window.pet.on("pet:cursor-pos", ({ x, y }) => {
lastPoint = { x, y };
refreshHitTest();
});
window.pet.on("pet:drag-move", ({ dx }) => {
// Codex:單一取樣的水平位移 >= 4 才切 running-right<= -4 才切 running-left
if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right";