Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf059b19cf | ||
|
|
a9497cc123 | ||
|
|
4e4563e861 | ||
|
|
7060571df1 | ||
|
|
cd4b618be0 | ||
|
|
42083acec5 | ||
|
|
93f635dd01 |
@@ -37,10 +37,10 @@
|
||||
- **16 方向追視(選配,預設關)** — 開啟後、且目前是待機狀態時,游標在哪她就看哪(000 = 正上方、順時針 22.5° 一格),追視時是靜態擺姿;Claude Code 在工作時以狀態動畫優先。Codex 的寵物**不看滑鼠**——它看的是 quick chat 的文字游標與 computer-use 的虛擬游標,所以預設關閉以求一致;喜歡的話在選單打開。
|
||||
- **自動修正圖檔缺陷** — Codex 的 hatch 產生器有時會把整列 look 格子畫得比 idle 小(內附的小念第 10 列就小了 22%,游標一往下移人物就縮水)。以整列中位數偵測、用腳底當支點放大回正確身高;單格的姿勢差異不動。Codex 本身沒有做這件事。
|
||||
- **與 Codex 同尺寸** — 100% 就是 Codex 桌面寵物的大小(126 × 137),高度用 Codex 的 `ceil(寬 × 208/192)` 算,兩邊並排看起來一樣大。
|
||||
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。
|
||||
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;人物方框以外的區域**點穿**,只有壓在人物或氣泡上滑鼠才會被吃掉,不擋你操作底下的視窗。
|
||||
- **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。
|
||||
- **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。
|
||||
- **對話氣泡** — 完成、需要授權、有問題要問你、工具失敗、整理記憶中……一眼看出 Claude Code 在等什麼。
|
||||
- **對話氣泡(兩行)** — 第一行是**對話名稱**(`/rename` 設的)或專案資料夾名,第二行才是訊息。同時開多個 session 時一眼就知道是哪一個在叫你。
|
||||
- **工作中即時播報** — 氣泡會顯示正在用的工具與對象(`⚙️ 執行 npm run dist`、`📖 讀 renderer.js`),以及助理剛說的那句話(`💬 …`)。**注意:這不是思考過程**,原因見[能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼)。
|
||||
- **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。
|
||||
- **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`,Codex hatch 出來的寵物直接在選單裡切換。
|
||||
@@ -211,6 +211,8 @@ Spritesheet:**1536 × 2288**,8 欄 × 11 列,每格 **192 × 208**,透
|
||||
|
||||
事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。
|
||||
|
||||
氣泡的第一行是對話名稱或專案名,第二行是訊息;標題為空時整行會收起來,不會留空白。
|
||||
|
||||
## 架構與檔案
|
||||
|
||||
```
|
||||
@@ -219,14 +221,14 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO
|
||||
│ 視窗 / 系統匣 / 游標輪詢 / 拖曳 / 設定
|
||||
▼ IPC
|
||||
renderer/renderer.js
|
||||
狀態機 + 幀動畫 + 追視 + 氣泡 + alpha 點穿判定
|
||||
狀態機 + 幀動畫 + 追視 + 氣泡 + 矩形回報
|
||||
```
|
||||
|
||||
| 檔案 | 職責 |
|
||||
|---|---|
|
||||
| `main.js` | Electron 主程序:透明置頂視窗、系統匣選單、HTTP 伺服器、游標輪詢(50 ms)、拖曳(60 fps)、設定讀寫、寵物掃描 |
|
||||
| `preload.js` | 以 contextBridge 暴露白名單 IPC 頻道給 renderer(sandbox 模式) |
|
||||
| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、滑鼠 alpha 判定 |
|
||||
| `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,先備份) |
|
||||
@@ -239,8 +241,12 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO
|
||||
|
||||
設計重點:
|
||||
|
||||
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`,renderer 在 mousemove 時讀 canvas 該點 alpha(或是否在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。
|
||||
- **點穿由主程序決定**:視窗預設 `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-的對照)。
|
||||
- **顯示尺寸與 sprite 來源尺寸分開**:`CELL_W/CELL_H`(192 × 208)只用來從 spritesheet 取格子,畫到畫面上的是 `PET_W/PET_H`(126 × 137,Codex 的 mascot 尺寸)。視窗寬度另有 `MIN_WIN_W = 240` 的下限,免得寵物變小後氣泡被截掉。
|
||||
- **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。
|
||||
@@ -264,6 +270,14 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO
|
||||
|---|---|---|
|
||||
| 工具動作 | `⚙️ 執行 npm run dist`、`📖 讀 renderer.js` | hook 的 `tool_name` 與 `tool_input` |
|
||||
| 助理說明 | 助理剛講那段話的第一句 | 由 `transcript_path` 讀 transcript 尾端的 `text` 區塊 |
|
||||
| 氣泡標題 | 對話名稱,沒設就用專案資料夾名 | transcript 的 `custom-title`(`/rename` 寫進去的),退回 `cwd` 的最後一段 |
|
||||
|
||||
氣泡長這樣:
|
||||
|
||||
```
|
||||
claude-pet
|
||||
💬 兩行氣泡正確運作,這是真實事件。
|
||||
```
|
||||
|
||||
同一段說明只播報一次(以 transcript 的 uuid 判斷),markdown 標記會先清掉,
|
||||
等待授權時不播報以免蓋掉固定氣泡。讀 transcript 只讀最後 192 KB 並依檔案大小快取,
|
||||
@@ -287,7 +301,7 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO
|
||||
| `jumping` | 定義在 `respondToHover ? "jumping" : state`,但整個 app 沒人開啟那個 prop | **選擇打開**:游標進入人物方框就播,最多 3 次 |
|
||||
| hover 離開 | 顯示狀態立刻翻回 `state`,動畫重跑,不會把跳躍播完 | 相同(游標離開就中斷跳躍) |
|
||||
| hover 版面 | 40 px 內進入、56 px 外退出;人物縮 0.9、下移 14 px、上方 24 px 控制鈕、200 ms | 未採用——控制鈕的功能就是選單,右鍵已經有了 |
|
||||
| hover 判定 | 用矩形(`pet-area-hover-changed` 由 rect 算) | 相同(逐像素會因每格輪廓不同而抖動) |
|
||||
| hover/點穿判定 | 用矩形(`pet-area-hover-changed` 由 rect 算) | 相同(逐像素會因每格輪廓不同而抖動;點穿若跟著抖,按下去的瞬間剛好透明就穿到底下去) |
|
||||
| 追視來源 | quick chat 的文字游標/computer-use 游標 | 沒有對應來源;改為**選配**看滑鼠,預設關 |
|
||||
| 追視優先序 | `lookFrame` 覆蓋所有狀態 | 只在 idle 時追視。Codex 的文字游標只有你在打字時才存在,滑鼠卻是一直都在,照搬會讓工作中的動畫永遠被靜態擺姿蓋掉 |
|
||||
| 拖曳方向 | 單次取樣水平位移 ≥ 4 px | 相同 |
|
||||
|
||||
@@ -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 };
|
||||
@@ -37,6 +37,9 @@ const PAD_BOTTOM = 8;
|
||||
const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身)
|
||||
// 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",
|
||||
@@ -55,7 +58,18 @@ let tray = null;
|
||||
let pets = [];
|
||||
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 ----------
|
||||
@@ -99,16 +113,19 @@ function appendLog(line) {
|
||||
// Claude Code 從 2.1.238 起不再把 thinking 的文字寫進 transcript(只剩加密簽章),
|
||||
// 但助理的可見說明(text 區塊)還在。讀檔尾就夠了,transcript 會長到好幾 MB。
|
||||
const TRANSCRIPT_TAIL = 192 * 1024;
|
||||
const narrationCache = new Map(); // path → { size, id, text }
|
||||
const narrationCache = new Map(); // path → { size, value }
|
||||
|
||||
function latestNarration(file) {
|
||||
// 回傳 { narration: { id, text } | null, title: string | null }
|
||||
// title 取自 /rename 寫進 transcript 的 custom-title,沒有就讓呼叫端退回專案名
|
||||
function readTranscript(file) {
|
||||
if (typeof file !== "string" || !file) return null;
|
||||
let stat;
|
||||
try { stat = fs.statSync(file); } catch { return null; }
|
||||
const cached = narrationCache.get(file);
|
||||
if (cached && cached.size === stat.size) return cached.value;
|
||||
|
||||
let value = null;
|
||||
let narration = null;
|
||||
let title = null;
|
||||
try {
|
||||
const start = Math.max(0, stat.size - TRANSCRIPT_TAIL);
|
||||
const fd = fs.openSync(file, "r");
|
||||
@@ -117,24 +134,29 @@ function latestNarration(file) {
|
||||
fs.closeSync(fd);
|
||||
const lines = buf.toString("utf8").split("\n");
|
||||
if (start > 0) lines.shift(); // 第一行多半被切一半
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
// 由後往前找,先遇到的就是最新的;兩樣都拿到就停
|
||||
for (let i = lines.length - 1; i >= 0 && !(narration && title); i--) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
let entry;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (entry.type !== "assistant") continue;
|
||||
if (!title && entry.type === "custom-title" && typeof entry.customTitle === "string" && entry.customTitle.trim()) {
|
||||
title = entry.customTitle.trim().slice(0, 60);
|
||||
continue;
|
||||
}
|
||||
if (narration || entry.type !== "assistant") continue;
|
||||
const content = entry.message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
||||
value = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) };
|
||||
narration = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (value) break;
|
||||
}
|
||||
} catch { /* 讀不到就算了,寵物不該因此出錯 */ }
|
||||
|
||||
const value = { narration, title };
|
||||
narrationCache.set(file, { size: stat.size, value });
|
||||
if (narrationCache.size > 32) narrationCache.delete(narrationCache.keys().next().value);
|
||||
return value;
|
||||
@@ -271,8 +293,19 @@ function createWindow() {
|
||||
if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
|
||||
win.setIgnoreMouseEvents(true, { forward: true });
|
||||
win.loadFile(path.join(APP_DIR, "renderer", "index.html"));
|
||||
win.once("ready-to-show", () => win.showInactive());
|
||||
win.once("ready-to-show", () => {
|
||||
win.showInactive();
|
||||
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) {
|
||||
@@ -290,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;
|
||||
@@ -304,27 +340,98 @@ 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 ----------
|
||||
|
||||
// Windows 有時候會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元:
|
||||
// 從外部檢查會看到 TOPMOST=true,但 z-order 排在一般視窗(例如 VS Code)之下,
|
||||
// 於是寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。
|
||||
// setAlwaysOnTop 在狀態沒變時可能不會真的呼叫 SetWindowPos,所以再補一個 moveTop()
|
||||
// 強制重新插回 topmost band 的最上面(視窗是 WS_EX_NOACTIVATE + focusable:false,不會搶焦點)。
|
||||
function reassertTopmost() {
|
||||
// 選單開著時不做:moveTop 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到了
|
||||
if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer || menuIsOpen()) return;
|
||||
try {
|
||||
win.setAlwaysOnTop(true, "screen-saver");
|
||||
win.moveTop();
|
||||
} catch { /* 視窗正在關閉就算了 */ }
|
||||
}
|
||||
|
||||
function startTopmostGuard() {
|
||||
topmostTimer = setInterval(reassertTopmost, TOPMOST_REASSERT_MS);
|
||||
// 螢幕配置變動最容易觸發降級,變動後立刻補一次
|
||||
screen.on("display-metrics-changed", reassertTopmost);
|
||||
screen.on("display-added", reassertTopmost);
|
||||
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 || !config.followCursor || 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();
|
||||
// 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 相同
|
||||
send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
|
||||
@@ -346,10 +453,13 @@ function startServer() {
|
||||
try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ }
|
||||
if (payload && typeof payload === "object") {
|
||||
appendLog(JSON.stringify(payload));
|
||||
const narration = latestNarration(payload.transcriptPath);
|
||||
if (narration) {
|
||||
payload.narration = narration.text;
|
||||
payload.narrationId = narration.id;
|
||||
const info = readTranscript(payload.transcriptPath);
|
||||
if (info) {
|
||||
if (info.narration) {
|
||||
payload.narration = info.narration.text;
|
||||
payload.narrationId = info.narration.id;
|
||||
}
|
||||
if (info.title) payload.sessionTitle = info.title;
|
||||
}
|
||||
send("pet:event", payload);
|
||||
res.writeHead(204);
|
||||
@@ -362,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") {
|
||||
@@ -469,6 +591,7 @@ function buildMenu() {
|
||||
config.alwaysOnTop = item.checked;
|
||||
saveConfig();
|
||||
win?.setAlwaysOnTop(item.checked, "screen-saver");
|
||||
if (item.checked) reassertTopmost();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -547,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); }
|
||||
});
|
||||
@@ -558,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);
|
||||
@@ -586,6 +734,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
createTray();
|
||||
startServer();
|
||||
startCursorPolling();
|
||||
startTopmostGuard();
|
||||
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
|
||||
});
|
||||
|
||||
@@ -595,6 +744,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
app.on("before-quit", () => {
|
||||
if (cursorTimer) clearInterval(cursorTimer);
|
||||
if (topmostTimer) clearInterval(topmostTimer);
|
||||
endDrag(false);
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-pet",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-pet",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.11",
|
||||
"devDependencies": {
|
||||
"electron": "^43.4.1",
|
||||
"electron-builder": "^26.0.12"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-pet",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.11",
|
||||
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
||||
+4
-1
@@ -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",
|
||||
@@ -20,7 +20,10 @@ const ON = new Set([
|
||||
"pet:settings",
|
||||
"pet:event",
|
||||
"pet:cursor",
|
||||
"pet:cursor-pos",
|
||||
"pet:drag-move",
|
||||
"pet:drag-ended",
|
||||
"pet:ignore-state",
|
||||
]);
|
||||
|
||||
contextBridge.exposeInMainWorld("pet", {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="bubble" class="hidden"><span id="bubble-text"></span></div>
|
||||
<div id="bubble" class="hidden"><span id="bubble-title"></span><span id="bubble-text"></span></div>
|
||||
<canvas id="sprite" width="126" height="137"></canvas>
|
||||
</div>
|
||||
<script src="renderer.js"></script>
|
||||
|
||||
+80
-52
@@ -76,6 +76,7 @@ const TOOL_LABELS = {
|
||||
const canvas = document.getElementById("sprite");
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
const bubble = document.getElementById("bubble");
|
||||
const bubbleTitle = document.getElementById("bubble-title");
|
||||
const bubbleText = document.getElementById("bubble-text");
|
||||
|
||||
let sheet = null;
|
||||
@@ -147,8 +148,8 @@ function startAnim(anim, now) {
|
||||
|
||||
let bubbleTimer = null;
|
||||
let bubbleSticky = false;
|
||||
let ignoringMouse = true;
|
||||
let hovered = false; // 游標是否壓在人物的不透明像素上
|
||||
let hovered = false; // 游標是否在人物方框內
|
||||
let lastPoint = null; // 最後已知的游標位置(視窗相對),主程序輪詢或 mousemove 都會更新
|
||||
let pressed = null;
|
||||
let lastReported = "";
|
||||
|
||||
@@ -156,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) {
|
||||
@@ -184,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();
|
||||
}
|
||||
|
||||
// 量一格的人物輪廓(用來偵測圖檔裡整列被畫小的缺陷)
|
||||
@@ -347,18 +353,23 @@ function shortTarget(target, toolName) {
|
||||
return t.slice(0, 44);
|
||||
}
|
||||
|
||||
// 氣泡第一行:對話名稱(/rename 設的)優先,其次專案資料夾名
|
||||
function bubbleTitleOf(p, cwd) {
|
||||
return p.sessionTitle || projectName(cwd) || "";
|
||||
}
|
||||
|
||||
// 有什麼就顯示什麼:優先顯示新的助理說明,否則顯示正在用的工具
|
||||
function sayActivity(p) {
|
||||
function sayActivity(p, title) {
|
||||
if (baseState() === "waiting") return; // 別蓋掉在等授權的固定氣泡
|
||||
if (p.narration && p.narrationId && p.narrationId !== lastNarrationId) {
|
||||
lastNarrationId = p.narrationId;
|
||||
say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS);
|
||||
say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS, false, title);
|
||||
return;
|
||||
}
|
||||
if (!p.toolName) return;
|
||||
const label = TOOL_LABELS[p.toolName] || `🔧 ${p.toolName}`;
|
||||
const detail = p.toolName === "Task" && p.agentType ? p.agentType : p.toolTarget;
|
||||
say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS);
|
||||
say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS, false, title);
|
||||
}
|
||||
|
||||
function projectName(cwd) {
|
||||
@@ -368,12 +379,15 @@ function projectName(cwd) {
|
||||
|
||||
// ---------- 氣泡 ----------
|
||||
|
||||
function say(text, ms, sticky = false) {
|
||||
// title 是第一行(對話名稱或專案名),text 是第二行
|
||||
function say(text, ms, sticky = false, title = "") {
|
||||
clearTimeout(bubbleTimer);
|
||||
bubbleTitle.textContent = title || "";
|
||||
bubbleText.textContent = text;
|
||||
bubble.title = text;
|
||||
bubble.title = title ? `${title}\n${text}` : text;
|
||||
bubble.classList.remove("hidden");
|
||||
bubbleSticky = sticky;
|
||||
reportHitRects();
|
||||
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
|
||||
}
|
||||
|
||||
@@ -381,6 +395,7 @@ function hideBubble() {
|
||||
clearTimeout(bubbleTimer);
|
||||
bubble.classList.add("hidden");
|
||||
bubbleSticky = false;
|
||||
reportHitRects();
|
||||
}
|
||||
|
||||
// ---------- 事件 ----------
|
||||
@@ -390,29 +405,31 @@ function handleEvent(p) {
|
||||
if (p.event === "__test") return handleTest(p);
|
||||
|
||||
const s = session(p.sessionId, p.cwd);
|
||||
const proj = projectName(s.cwd);
|
||||
const suffix = proj ? `(${proj})` : "";
|
||||
const title = bubbleTitleOf(p, s.cwd);
|
||||
|
||||
switch (p.event) {
|
||||
case "SessionStart":
|
||||
setState(s, "idle");
|
||||
// Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡
|
||||
if (!p.source || p.source === "startup") say(`👋 嗨!${suffix}`, 3000);
|
||||
if (!p.source || p.source === "startup") say("👋 嗨!", 3000, false, title);
|
||||
break;
|
||||
case "UserPromptSubmit":
|
||||
setState(s, "running");
|
||||
// 這時 transcript 裡最新的助理訊息還是上一輪的結尾,
|
||||
// 先標記成已顯示,免得接下來的工具事件把它當成新話講出來
|
||||
if (p.narrationId) lastNarrationId = p.narrationId;
|
||||
hideBubble();
|
||||
break;
|
||||
case "PreToolUse":
|
||||
if (p.toolName === "AskUserQuestion") {
|
||||
setState(s, "waiting");
|
||||
say(`❓ 有問題想問你${suffix}`, 0, true);
|
||||
say("❓ 有問題想問你", 0, true, title);
|
||||
} else if (p.toolName === "ExitPlanMode") {
|
||||
setState(s, "waiting");
|
||||
say(`📋 計畫等你確認${suffix}`, 0, true);
|
||||
say("📋 計畫等你確認", 0, true, title);
|
||||
} else {
|
||||
setState(s, "running");
|
||||
sayActivity(p);
|
||||
sayActivity(p, title);
|
||||
}
|
||||
break;
|
||||
case "PostToolUse":
|
||||
@@ -421,25 +438,25 @@ 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;
|
||||
if (t === "permission_prompt") {
|
||||
setState(s, "waiting");
|
||||
say(`🔐 需要你授權${suffix}`, 0, true);
|
||||
say("🔐 需要你授權", 0, true, title);
|
||||
} else if (t === "idle_prompt") {
|
||||
// Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變
|
||||
say(`💤 在等你回覆${suffix}`, 4000);
|
||||
say("💤 在等你回覆", 4000, false, title);
|
||||
} else if (t === "elicitation_dialog") {
|
||||
setState(s, "waiting");
|
||||
say(`❓ 有問題想問你${suffix}`, 0, true);
|
||||
say("❓ 有問題想問你", 0, true, title);
|
||||
} else if (p.message) {
|
||||
say(`💬 ${p.message}`, 4000);
|
||||
say(`💬 ${p.message}`, 4000, false, title);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -448,12 +465,18 @@ function handleEvent(p) {
|
||||
// Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
|
||||
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
|
||||
setState(s, "review");
|
||||
say(`✅ 完成!${suffix}`, 8000);
|
||||
// 收尾時把最後一句回覆講出來,而不是只說「完成」
|
||||
if (p.narration) {
|
||||
lastNarrationId = p.narrationId || lastNarrationId;
|
||||
say(`✅ ${firstSentence(p.narration)}`, 8000, false, title);
|
||||
} else {
|
||||
say("✅ 完成!", 8000, false, title);
|
||||
}
|
||||
break;
|
||||
case "StopFailure":
|
||||
// Codex:turn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
|
||||
setState(s, "failed");
|
||||
say(`❌ ${p.error || "出錯了"}`, 8000);
|
||||
say(`❌ ${p.error || "出錯了"}`, 8000, false, title);
|
||||
break;
|
||||
case "SubagentStart":
|
||||
case "SubagentStop":
|
||||
@@ -461,13 +484,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:
|
||||
@@ -487,7 +510,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();
|
||||
@@ -604,27 +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);
|
||||
|
||||
// 壓在人物的不透明像素上(透明處要讓滑鼠穿過去)
|
||||
function overSprite(x, y) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (x < r.left || x >= r.right || y < r.top || y >= r.bottom) return false;
|
||||
const px = Math.floor((x - r.left) * (canvas.width / r.width));
|
||||
const py = Math.floor((y - r.top) * (canvas.height / r.height));
|
||||
try {
|
||||
return ctx.getImageData(px, py, 1, 1).data[3] > 20;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -649,11 +663,10 @@ function setHovered(on) {
|
||||
if (overlay?.fromHover) overlay = null;
|
||||
}
|
||||
|
||||
function setIgnore(ignore) {
|
||||
if (ignore === ignoringMouse) return;
|
||||
ignoringMouse = ignore;
|
||||
window.pet.send("pet:ignore-mouse", ignore);
|
||||
canvas.classList.toggle("grab", !ignore);
|
||||
// 命中判定只剩 hover(跳三下):mousemove 與主程序輪詢都走同一條路
|
||||
function refreshHitTest() {
|
||||
if (!lastPoint || pressed || drag) return;
|
||||
setHovered(overPetArea(lastPoint.x, lastPoint.y));
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
@@ -666,15 +679,14 @@ 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);
|
||||
});
|
||||
|
||||
window.addEventListener("mousedown", (e) => {
|
||||
@@ -700,11 +712,27 @@ 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";
|
||||
});
|
||||
|
||||
// 主程序結束了拖曳(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();
|
||||
|
||||
+19
-2
@@ -30,11 +30,28 @@ html, body {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 標題與內文各自佔一行,各自省略;沒有標題時整行收起來 */
|
||||
#bubble-title,
|
||||
#bubble-text {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#bubble-title {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
#bubble-title:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#bubble::after {
|
||||
|
||||
+2
-1
@@ -144,8 +144,9 @@ async function main() {
|
||||
console.log(`Tag : ${TAG}`);
|
||||
|
||||
if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`);
|
||||
// dist/ 可能留著前幾版的產物,只挑檔名含當前版本的,免得舊檔混進新 release
|
||||
const files = fs.readdirSync(DIST_DIR)
|
||||
.filter((f) => ASSET_PATTERNS.some((re) => re.test(f)))
|
||||
.filter((f) => ASSET_PATTERNS.some((re) => re.test(f)) && f.includes(pkg.version))
|
||||
.map((f) => path.join(DIST_DIR, f))
|
||||
.filter((f) => fs.statSync(f).isFile());
|
||||
if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`);
|
||||
|
||||
Reference in New Issue
Block a user