Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e4563e861 | ||
|
|
7060571df1 | ||
|
|
cd4b618be0 | ||
|
|
42083acec5 | ||
|
|
93f635dd01 | ||
|
|
95c9b97eb7 |
@@ -20,6 +20,7 @@
|
|||||||
- [新增 / 切換寵物](#新增--切換寵物)
|
- [新增 / 切換寵物](#新增--切換寵物)
|
||||||
- [設定檔](#設定檔)
|
- [設定檔](#設定檔)
|
||||||
- [架構與檔案](#架構與檔案)
|
- [架構與檔案](#架構與檔案)
|
||||||
|
- [能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼)
|
||||||
- [與 Codex 的對照](#與-codex-的對照)
|
- [與 Codex 的對照](#與-codex-的對照)
|
||||||
- [HTTP API](#http-api)
|
- [HTTP API](#http-api)
|
||||||
- [打包成 exe](#打包成-exe)
|
- [打包成 exe](#打包成-exe)
|
||||||
@@ -39,7 +40,8 @@
|
|||||||
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。
|
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。
|
||||||
- **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。
|
- **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。
|
||||||
- **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。
|
- **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。
|
||||||
- **對話氣泡** — 完成、需要授權、有問題要問你、工具失敗、整理記憶中……一眼看出 Claude Code 在等什麼。
|
- **對話氣泡(兩行)** — 第一行是**對話名稱**(`/rename` 設的)或專案資料夾名,第二行才是訊息。同時開多個 session 時一眼就知道是哪一個在叫你。
|
||||||
|
- **工作中即時播報** — 氣泡會顯示正在用的工具與對象(`⚙️ 執行 npm run dist`、`📖 讀 renderer.js`),以及助理剛說的那句話(`💬 …`)。**注意:這不是思考過程**,原因見[能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼)。
|
||||||
- **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。
|
- **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。
|
||||||
- **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`,Codex hatch 出來的寵物直接在選單裡切換。
|
- **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`,Codex hatch 出來的寵物直接在選單裡切換。
|
||||||
- **Hook 零負擔** — hook 腳本約 100 ms 完成、寵物沒開也靜默 exit 0,不會拖慢或卡住 Claude Code。
|
- **Hook 零負擔** — hook 腳本約 100 ms 完成、寵物沒開也靜默 exit 0,不會拖慢或卡住 Claude Code。
|
||||||
@@ -109,7 +111,7 @@ npm run autostart:install # (可選)開機自動啟動
|
|||||||
| (程式啟動) | 該寵物第一次出現 | `waving` × 3 → 慢速 `idle` | 👋 嗨,我是〈寵物名〉(8 秒) |
|
| (程式啟動) | 該寵物第一次出現 | `waving` × 3 → 慢速 `idle` | 👋 嗨,我是〈寵物名〉(8 秒) |
|
||||||
| `SessionStart` | `source = startup` | `idle` | 👋 嗨!(專案) |
|
| `SessionStart` | `source = startup` | `idle` | 👋 嗨!(專案) |
|
||||||
| `UserPromptSubmit` | | `running` | (關閉舊氣泡) |
|
| `UserPromptSubmit` | | `running` | (關閉舊氣泡) |
|
||||||
| `PreToolUse` | 一般工具 | `running` | |
|
| `PreToolUse` | 一般工具 | `running` | 有新的助理說明就顯示 `💬 …`,否則顯示 `⚙️ 執行 …` 之類的工具動作 |
|
||||||
| `PreToolUse` | `AskUserQuestion` | `waiting` | ❓ 有問題想問你 |
|
| `PreToolUse` | `AskUserQuestion` | `waiting` | ❓ 有問題想問你 |
|
||||||
| `PreToolUse` | `ExitPlanMode` | `waiting` | 📋 計畫等你確認 |
|
| `PreToolUse` | `ExitPlanMode` | `waiting` | 📋 計畫等你確認 |
|
||||||
| `PostToolUse` | | `running` | |
|
| `PostToolUse` | | `running` | |
|
||||||
@@ -209,6 +211,8 @@ Spritesheet:**1536 × 2288**,8 欄 × 11 列,每格 **192 × 208**,透
|
|||||||
|
|
||||||
事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。
|
事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。
|
||||||
|
|
||||||
|
氣泡的第一行是對話名稱或專案名,第二行是訊息;標題為空時整行會收起來,不會留空白。
|
||||||
|
|
||||||
## 架構與檔案
|
## 架構與檔案
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -237,13 +241,45 @@ Claude Code ──hook(stdin 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 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。
|
- **拖曳**:由主程序用 `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-的對照)。
|
- **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。
|
||||||
- **顯示尺寸與 sprite 來源尺寸分開**:`CELL_W/CELL_H`(192 × 208)只用來從 spritesheet 取格子,畫到畫面上的是 `PET_W/PET_H`(126 × 137,Codex 的 mascot 尺寸)。視窗寬度另有 `MIN_WIN_W = 240` 的下限,免得寵物變小後氣泡被截掉。
|
- **顯示尺寸與 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` 失敗。
|
- **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。
|
||||||
- **hook 合併規則**:以指令中是否含 `claude-pet-hook` 辨識自己的項目,重裝只替換自己的、不動別人的。
|
- **hook 合併規則**:以指令中是否含 `claude-pet-hook` 辨識自己的項目,重裝只替換自己的、不動別人的。
|
||||||
|
|
||||||
|
## 能顯示什麼、不能顯示什麼
|
||||||
|
|
||||||
|
氣泡**顯示不了 Claude 的思考過程**。Claude Code 從 **2.1.238** 起就不再把 thinking 的文字寫進 transcript,只留加密簽章:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "thinking", "thinking": "", "signature": "CAQSwgQKEAgRGAI4AUII…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
(2.1.237 以前有文字。這是 Claude Code 版本的差異,跟模型無關——同樣是 `claude-opus-5`,
|
||||||
|
2.1.237 的 session 存得到、2.1.241 的存不到。)Claude Code 也沒有對外的事件串流,
|
||||||
|
所以 hook 拿不到思考內容。這點跟 Codex 不同:Codex CLI 的 `--json` 會直接送出 `reasoning` 事件。
|
||||||
|
|
||||||
|
拿得到的是這些,氣泡就顯示這些:
|
||||||
|
|
||||||
|
| 來源 | 內容 | 從哪來 |
|
||||||
|
|---|---|---|
|
||||||
|
| 工具動作 | `⚙️ 執行 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 並依檔案大小快取,
|
||||||
|
不會因為 transcript 長到幾 MB 就變慢。
|
||||||
|
|
||||||
## 與 Codex 的對照
|
## 與 Codex 的對照
|
||||||
|
|
||||||
所有數值都是從 Codex app(`WindowsApps\OpenAI.Codex_*\app\resources\app.asar`)的 `codex-pet-assets`、`avatar-overlay-native-frame` 與主程序讀出來的。
|
所有數值都是從 Codex app(`WindowsApps\OpenAI.Codex_*\app\resources\app.asar`)的 `codex-pet-assets`、`avatar-overlay-native-frame` 與主程序讀出來的。
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ function parse(text) {
|
|||||||
try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; }
|
try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 從 tool_input 挑一個最有資訊量的欄位當成「正在對什麼做事」。
|
||||||
|
// 整包 tool_input 可能很大(例如 Write 帶整份檔案內容),只取一小段。
|
||||||
|
const TARGET_KEYS = ["command", "file_path", "pattern", "path", "url", "query", "notebook_path", "description", "prompt"];
|
||||||
|
|
||||||
|
function toolTarget(toolInput) {
|
||||||
|
if (!toolInput || typeof toolInput !== "object") return undefined;
|
||||||
|
for (const key of TARGET_KEYS) {
|
||||||
|
const v = toolInput[key];
|
||||||
|
if (typeof v === "string" && v.trim()) return trim(v.trim().replace(/\s+/g, " "), 160);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPayload(input) {
|
function buildPayload(input) {
|
||||||
const error = typeof input.error === "string" ? input.error : input.error?.message;
|
const error = typeof input.error === "string" ? input.error : input.error?.message;
|
||||||
return {
|
return {
|
||||||
@@ -37,6 +50,10 @@ function buildPayload(input) {
|
|||||||
cwd: input.cwd ?? null,
|
cwd: input.cwd ?? null,
|
||||||
permissionMode: input.permission_mode ?? null,
|
permissionMode: input.permission_mode ?? null,
|
||||||
toolName: input.tool_name ?? null,
|
toolName: input.tool_name ?? null,
|
||||||
|
toolTarget: toolTarget(input.tool_input),
|
||||||
|
agentType: input.agent_type ?? null,
|
||||||
|
// 助理的說明文字要從 transcript 撈;寵物那邊才讀,hook 只負責把路徑帶過去
|
||||||
|
transcriptPath: input.transcript_path ?? null,
|
||||||
notificationType: input.notification_type ?? null,
|
notificationType: input.notification_type ?? null,
|
||||||
title: trim(input.title, 160),
|
title: trim(input.title, 160),
|
||||||
message: trim(input.message, 240),
|
message: trim(input.message, 240),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const PAD_BOTTOM = 8;
|
|||||||
const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身)
|
const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身)
|
||||||
// Codex 的 mascot 寬度可調範圍是 80–224 px(ike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內
|
// Codex 的 mascot 寬度可調範圍是 80–224 px(ike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內
|
||||||
const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75];
|
const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75];
|
||||||
|
const TOPMOST_REASSERT_MS = 2000; // 多久重新宣告一次置頂(見 reassertTopmost)
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
activePetId: "xiao-nian",
|
activePetId: "xiao-nian",
|
||||||
@@ -55,6 +56,7 @@ let tray = null;
|
|||||||
let pets = [];
|
let pets = [];
|
||||||
let dragTimer = null;
|
let dragTimer = null;
|
||||||
let cursorTimer = null;
|
let cursorTimer = null;
|
||||||
|
let topmostTimer = null;
|
||||||
let lastCursor = { x: NaN, y: NaN };
|
let lastCursor = { x: NaN, y: NaN };
|
||||||
let currentState = { anim: "idle", base: "idle", sessions: 0 };
|
let currentState = { anim: "idle", base: "idle", sessions: 0 };
|
||||||
|
|
||||||
@@ -94,6 +96,60 @@ function appendLog(line) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- transcript:取最新的助理說明文字 ----------
|
||||||
|
|
||||||
|
// Claude Code 從 2.1.238 起不再把 thinking 的文字寫進 transcript(只剩加密簽章),
|
||||||
|
// 但助理的可見說明(text 區塊)還在。讀檔尾就夠了,transcript 會長到好幾 MB。
|
||||||
|
const TRANSCRIPT_TAIL = 192 * 1024;
|
||||||
|
const narrationCache = new Map(); // path → { size, value }
|
||||||
|
|
||||||
|
// 回傳 { 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 narration = null;
|
||||||
|
let title = null;
|
||||||
|
try {
|
||||||
|
const start = Math.max(0, stat.size - TRANSCRIPT_TAIL);
|
||||||
|
const fd = fs.openSync(file, "r");
|
||||||
|
const buf = Buffer.alloc(stat.size - start);
|
||||||
|
fs.readSync(fd, buf, 0, buf.length, start);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
const lines = buf.toString("utf8").split("\n");
|
||||||
|
if (start > 0) lines.shift(); // 第一行多半被切一半
|
||||||
|
// 由後往前找,先遇到的就是最新的;兩樣都拿到就停
|
||||||
|
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 (!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()) {
|
||||||
|
narration = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) };
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- pets ----------
|
// ---------- pets ----------
|
||||||
|
|
||||||
function dirKey(p) {
|
function dirKey(p) {
|
||||||
@@ -225,7 +281,10 @@ function createWindow() {
|
|||||||
if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
|
if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
|
||||||
win.setIgnoreMouseEvents(true, { forward: true });
|
win.setIgnoreMouseEvents(true, { forward: true });
|
||||||
win.loadFile(path.join(APP_DIR, "renderer", "index.html"));
|
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; });
|
win.on("closed", () => { win = null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,13 +331,42 @@ function endDrag(save) {
|
|||||||
|
|
||||||
// ---------- cursor look ----------
|
// ---------- 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() {
|
||||||
|
if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer) 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 startCursorPolling() {
|
function startCursorPolling() {
|
||||||
cursorTimer = setInterval(() => {
|
cursorTimer = setInterval(() => {
|
||||||
if (!win || !config.followCursor || dragTimer) return;
|
if (!win || dragTimer) return;
|
||||||
const p = screen.getCursorScreenPoint();
|
const p = screen.getCursorScreenPoint();
|
||||||
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
|
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
|
||||||
lastCursor = p;
|
lastCursor = p;
|
||||||
const b = win.getBounds();
|
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 cx = b.x + b.width / 2;
|
||||||
const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心,與 Codex 相同
|
const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心,與 Codex 相同
|
||||||
send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
|
send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
|
||||||
@@ -300,6 +388,14 @@ function startServer() {
|
|||||||
try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ }
|
try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ }
|
||||||
if (payload && typeof payload === "object") {
|
if (payload && typeof payload === "object") {
|
||||||
appendLog(JSON.stringify(payload));
|
appendLog(JSON.stringify(payload));
|
||||||
|
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);
|
send("pet:event", payload);
|
||||||
res.writeHead(204);
|
res.writeHead(204);
|
||||||
} else {
|
} else {
|
||||||
@@ -418,6 +514,7 @@ function buildMenu() {
|
|||||||
config.alwaysOnTop = item.checked;
|
config.alwaysOnTop = item.checked;
|
||||||
saveConfig();
|
saveConfig();
|
||||||
win?.setAlwaysOnTop(item.checked, "screen-saver");
|
win?.setAlwaysOnTop(item.checked, "screen-saver");
|
||||||
|
if (item.checked) reassertTopmost();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -535,6 +632,7 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
createTray();
|
createTray();
|
||||||
startServer();
|
startServer();
|
||||||
startCursorPolling();
|
startCursorPolling();
|
||||||
|
startTopmostGuard();
|
||||||
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
|
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -544,6 +642,7 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
if (cursorTimer) clearInterval(cursorTimer);
|
if (cursorTimer) clearInterval(cursorTimer);
|
||||||
|
if (topmostTimer) clearInterval(topmostTimer);
|
||||||
endDrag(false);
|
endDrag(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-pet",
|
"name": "claude-pet",
|
||||||
"version": "2.0.3",
|
"version": "2.0.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-pet",
|
"name": "claude-pet",
|
||||||
"version": "2.0.3",
|
"version": "2.0.9",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^43.4.1",
|
"electron": "^43.4.1",
|
||||||
"electron-builder": "^26.0.12"
|
"electron-builder": "^26.0.12"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-pet",
|
"name": "claude-pet",
|
||||||
"version": "2.0.3",
|
"version": "2.0.9",
|
||||||
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)",
|
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const ON = new Set([
|
|||||||
"pet:settings",
|
"pet:settings",
|
||||||
"pet:event",
|
"pet:event",
|
||||||
"pet:cursor",
|
"pet:cursor",
|
||||||
|
"pet:cursor-pos",
|
||||||
"pet:drag-move",
|
"pet:drag-move",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="stage">
|
<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>
|
<canvas id="sprite" width="126" height="137"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<script src="renderer.js"></script>
|
<script src="renderer.js"></script>
|
||||||
|
|||||||
+117
-21
@@ -55,10 +55,28 @@ const RUNNING_SILENCE = 30 * 60e3;
|
|||||||
const IDLE_PRUNE = 30 * 60e3; // idle 的 session 閒置多久後從清單移除(純清理,不影響顯示)
|
const IDLE_PRUNE = 30 * 60e3; // idle 的 session 閒置多久後從清單移除(純清理,不影響顯示)
|
||||||
|
|
||||||
const GREETING_MS = 8000; // first-awake 問候通知的存活時間(Pi = 8 s)
|
const GREETING_MS = 8000; // first-awake 問候通知的存活時間(Pi = 8 s)
|
||||||
|
const ACTIVITY_MS = 6000; // 工作中氣泡(工具動作/助理說明)的存活時間
|
||||||
|
|
||||||
|
// 工具對照表;沒列到的就直接顯示工具名稱
|
||||||
|
const TOOL_LABELS = {
|
||||||
|
Bash: "⚙️ 執行",
|
||||||
|
Read: "📖 讀",
|
||||||
|
Write: "✏️ 寫",
|
||||||
|
Edit: "✏️ 改",
|
||||||
|
NotebookEdit: "✏️ 改",
|
||||||
|
Glob: "🔍 找檔案",
|
||||||
|
Grep: "🔍 搜尋",
|
||||||
|
WebFetch: "🌐 擷取",
|
||||||
|
WebSearch: "🔎 搜尋",
|
||||||
|
Task: "🤖 子代理",
|
||||||
|
TodoWrite: "📝 待辦",
|
||||||
|
Artifact: "📄 產出",
|
||||||
|
};
|
||||||
|
|
||||||
const canvas = document.getElementById("sprite");
|
const canvas = document.getElementById("sprite");
|
||||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
const bubble = document.getElementById("bubble");
|
const bubble = document.getElementById("bubble");
|
||||||
|
const bubbleTitle = document.getElementById("bubble-title");
|
||||||
const bubbleText = document.getElementById("bubble-text");
|
const bubbleText = document.getElementById("bubble-text");
|
||||||
|
|
||||||
let sheet = null;
|
let sheet = null;
|
||||||
@@ -73,6 +91,7 @@ const look = { idx: null, lastMoveAt: 0 };
|
|||||||
// 播放中的序列:frames = [{row, col, ms}],loopStart 為 null 表示播完就結束
|
// 播放中的序列:frames = [{row, col, ms}],loopStart 為 null 表示播完就結束
|
||||||
let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() };
|
let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() };
|
||||||
let prevBase = "idle";
|
let prevBase = "idle";
|
||||||
|
let lastNarrationId = null; // 同一段說明只播報一次
|
||||||
let lookCells = null; // 16 個 look 格子的量測結果(含缺陷補正倍率)
|
let lookCells = null; // 16 個 look 格子的量測結果(含缺陷補正倍率)
|
||||||
|
|
||||||
// 一輪原始動作
|
// 一輪原始動作
|
||||||
@@ -131,6 +150,7 @@ let bubbleTimer = null;
|
|||||||
let bubbleSticky = false;
|
let bubbleSticky = false;
|
||||||
let ignoringMouse = true;
|
let ignoringMouse = true;
|
||||||
let hovered = false; // 游標是否壓在人物的不透明像素上
|
let hovered = false; // 游標是否壓在人物的不透明像素上
|
||||||
|
let lastPoint = null; // 最後已知的游標位置(視窗相對),主程序輪詢或 mousemove 都會更新
|
||||||
let pressed = null;
|
let pressed = null;
|
||||||
let lastReported = "";
|
let lastReported = "";
|
||||||
|
|
||||||
@@ -301,6 +321,53 @@ function resolveAnim() {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 助理的說明是 markdown,直接塞進氣泡會看到 ** 和反引號
|
||||||
|
function stripMarkdown(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/```[\s\S]*?```/g, " ") // 整段程式碼
|
||||||
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // 連結與圖片只留文字
|
||||||
|
.replace(/`([^`]*)`/g, "$1")
|
||||||
|
.replace(/\*\*([^*]*)\*\*/g, "$1")
|
||||||
|
.replace(/(^|\s)[*_]([^*_\s][^*_]*)[*_](?=\s|$)/g, "$1$2")
|
||||||
|
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // 標題
|
||||||
|
.replace(/^\s{0,3}[-*+>|]\s+/gm, "") // 清單與引言
|
||||||
|
.replace(/[ \t]+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只取第一句,氣泡是單行,塞整段沒有意義
|
||||||
|
function firstSentence(text) {
|
||||||
|
const line = stripMarkdown(text).split(/\r?\n/).find((l) => l.trim()) || "";
|
||||||
|
const m = line.trim().match(/^[^。!?!?]{1,60}[。!?!?]?/);
|
||||||
|
return (m ? m[0] : line.trim()).slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指令取前半段、路徑只取檔名,其餘截斷
|
||||||
|
function shortTarget(target, toolName) {
|
||||||
|
const t = String(target).trim();
|
||||||
|
if (toolName === "Bash") return t.slice(0, 44);
|
||||||
|
if (/[\\/]/.test(t) && !/\s/.test(t)) return t.split(/[\\/]/).pop().slice(0, 40);
|
||||||
|
return t.slice(0, 44);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 氣泡第一行:對話名稱(/rename 設的)優先,其次專案資料夾名
|
||||||
|
function bubbleTitleOf(p, cwd) {
|
||||||
|
return p.sessionTitle || projectName(cwd) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有什麼就顯示什麼:優先顯示新的助理說明,否則顯示正在用的工具
|
||||||
|
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, 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, false, title);
|
||||||
|
}
|
||||||
|
|
||||||
function projectName(cwd) {
|
function projectName(cwd) {
|
||||||
if (!cwd) return "";
|
if (!cwd) return "";
|
||||||
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
|
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
|
||||||
@@ -308,10 +375,12 @@ function projectName(cwd) {
|
|||||||
|
|
||||||
// ---------- 氣泡 ----------
|
// ---------- 氣泡 ----------
|
||||||
|
|
||||||
function say(text, ms, sticky = false) {
|
// title 是第一行(對話名稱或專案名),text 是第二行
|
||||||
|
function say(text, ms, sticky = false, title = "") {
|
||||||
clearTimeout(bubbleTimer);
|
clearTimeout(bubbleTimer);
|
||||||
|
bubbleTitle.textContent = title || "";
|
||||||
bubbleText.textContent = text;
|
bubbleText.textContent = text;
|
||||||
bubble.title = text;
|
bubble.title = title ? `${title}\n${text}` : text;
|
||||||
bubble.classList.remove("hidden");
|
bubble.classList.remove("hidden");
|
||||||
bubbleSticky = sticky;
|
bubbleSticky = sticky;
|
||||||
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
|
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
|
||||||
@@ -330,28 +399,31 @@ function handleEvent(p) {
|
|||||||
if (p.event === "__test") return handleTest(p);
|
if (p.event === "__test") return handleTest(p);
|
||||||
|
|
||||||
const s = session(p.sessionId, p.cwd);
|
const s = session(p.sessionId, p.cwd);
|
||||||
const proj = projectName(s.cwd);
|
const title = bubbleTitleOf(p, s.cwd);
|
||||||
const suffix = proj ? `(${proj})` : "";
|
|
||||||
|
|
||||||
switch (p.event) {
|
switch (p.event) {
|
||||||
case "SessionStart":
|
case "SessionStart":
|
||||||
setState(s, "idle");
|
setState(s, "idle");
|
||||||
// Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡
|
// 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;
|
break;
|
||||||
case "UserPromptSubmit":
|
case "UserPromptSubmit":
|
||||||
setState(s, "running");
|
setState(s, "running");
|
||||||
|
// 這時 transcript 裡最新的助理訊息還是上一輪的結尾,
|
||||||
|
// 先標記成已顯示,免得接下來的工具事件把它當成新話講出來
|
||||||
|
if (p.narrationId) lastNarrationId = p.narrationId;
|
||||||
hideBubble();
|
hideBubble();
|
||||||
break;
|
break;
|
||||||
case "PreToolUse":
|
case "PreToolUse":
|
||||||
if (p.toolName === "AskUserQuestion") {
|
if (p.toolName === "AskUserQuestion") {
|
||||||
setState(s, "waiting");
|
setState(s, "waiting");
|
||||||
say(`❓ 有問題想問你${suffix}`, 0, true);
|
say("❓ 有問題想問你", 0, true, title);
|
||||||
} else if (p.toolName === "ExitPlanMode") {
|
} else if (p.toolName === "ExitPlanMode") {
|
||||||
setState(s, "waiting");
|
setState(s, "waiting");
|
||||||
say(`📋 計畫等你確認${suffix}`, 0, true);
|
say("📋 計畫等你確認", 0, true, title);
|
||||||
} else {
|
} else {
|
||||||
setState(s, "running");
|
setState(s, "running");
|
||||||
|
sayActivity(p, title);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "PostToolUse":
|
case "PostToolUse":
|
||||||
@@ -360,25 +432,25 @@ function handleEvent(p) {
|
|||||||
case "PostToolUseFailure":
|
case "PostToolUseFailure":
|
||||||
// Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡
|
// Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡
|
||||||
setState(s, "running");
|
setState(s, "running");
|
||||||
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
|
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000, false, title);
|
||||||
break;
|
break;
|
||||||
case "PermissionRequest":
|
case "PermissionRequest":
|
||||||
setState(s, "waiting");
|
setState(s, "waiting");
|
||||||
say(`🔐 需要你授權${p.toolName ? `:${p.toolName}` : ""}`, 0, true);
|
say(`🔐 需要你授權${p.toolName ? `:${p.toolName}` : ""}`, 0, true, title);
|
||||||
break;
|
break;
|
||||||
case "Notification": {
|
case "Notification": {
|
||||||
const t = p.notificationType;
|
const t = p.notificationType;
|
||||||
if (t === "permission_prompt") {
|
if (t === "permission_prompt") {
|
||||||
setState(s, "waiting");
|
setState(s, "waiting");
|
||||||
say(`🔐 需要你授權${suffix}`, 0, true);
|
say("🔐 需要你授權", 0, true, title);
|
||||||
} else if (t === "idle_prompt") {
|
} else if (t === "idle_prompt") {
|
||||||
// Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變
|
// Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變
|
||||||
say(`💤 在等你回覆${suffix}`, 4000);
|
say("💤 在等你回覆", 4000, false, title);
|
||||||
} else if (t === "elicitation_dialog") {
|
} else if (t === "elicitation_dialog") {
|
||||||
setState(s, "waiting");
|
setState(s, "waiting");
|
||||||
say(`❓ 有問題想問你${suffix}`, 0, true);
|
say("❓ 有問題想問你", 0, true, title);
|
||||||
} else if (p.message) {
|
} else if (p.message) {
|
||||||
say(`💬 ${p.message}`, 4000);
|
say(`💬 ${p.message}`, 4000, false, title);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -387,12 +459,18 @@ function handleEvent(p) {
|
|||||||
// Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
|
// Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
|
||||||
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
|
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
|
||||||
setState(s, "review");
|
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;
|
break;
|
||||||
case "StopFailure":
|
case "StopFailure":
|
||||||
// Codex:turn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
|
// Codex:turn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
|
||||||
setState(s, "failed");
|
setState(s, "failed");
|
||||||
say(`❌ ${p.error || "出錯了"}`, 8000);
|
say(`❌ ${p.error || "出錯了"}`, 8000, false, title);
|
||||||
break;
|
break;
|
||||||
case "SubagentStart":
|
case "SubagentStart":
|
||||||
case "SubagentStop":
|
case "SubagentStop":
|
||||||
@@ -400,13 +478,13 @@ function handleEvent(p) {
|
|||||||
break;
|
break;
|
||||||
case "PreCompact":
|
case "PreCompact":
|
||||||
setState(s, "running");
|
setState(s, "running");
|
||||||
say("🗜️ 整理記憶中…", 3000);
|
say("🗜️ 整理記憶中…", 3000, false, title);
|
||||||
break;
|
break;
|
||||||
case "SessionEnd":
|
case "SessionEnd":
|
||||||
sessions.delete(p.sessionId || "default");
|
sessions.delete(p.sessionId || "default");
|
||||||
if (sessions.size === 0) {
|
if (sessions.size === 0) {
|
||||||
hideBubble();
|
hideBubble();
|
||||||
say("👋 掰掰", 2000);
|
say("👋 掰掰", 2000, false, title);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -426,7 +504,7 @@ function handleTest(p) {
|
|||||||
} else if (anim in PRIORITY) {
|
} else if (anim in PRIORITY) {
|
||||||
const s = session("test");
|
const s = session("test");
|
||||||
setState(s, anim);
|
setState(s, anim);
|
||||||
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true);
|
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true, "動作測試");
|
||||||
else if (anim === "idle") hideBubble();
|
else if (anim === "idle") hideBubble();
|
||||||
}
|
}
|
||||||
report();
|
report();
|
||||||
@@ -477,6 +555,9 @@ function tick(now) {
|
|||||||
prevBase = base;
|
prevBase = base;
|
||||||
|
|
||||||
draw(anim);
|
draw(anim);
|
||||||
|
// 換格會改變游標底下的像素。游標沒移動時主程序不會再送座標,
|
||||||
|
// 所以這裡自己重驗一次,否則會卡在點穿狀態動不了。
|
||||||
|
if (lastPoint && overPetArea(lastPoint.x, lastPoint.y)) refreshHitTest();
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +669,15 @@ function setHovered(on) {
|
|||||||
if (overlay?.fromHover) overlay = null;
|
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) {
|
function setIgnore(ignore) {
|
||||||
if (ignore === ignoringMouse) return;
|
if (ignore === ignoringMouse) return;
|
||||||
ignoringMouse = ignore;
|
ignoringMouse = ignore;
|
||||||
@@ -605,13 +695,13 @@ window.addEventListener("mousemove", (e) => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setHovered(overPetArea(e.clientX, e.clientY));
|
lastPoint = { x: e.clientX, y: e.clientY };
|
||||||
// 點穿判定仍看實際像素,透明處要能點到底下的視窗
|
refreshHitTest();
|
||||||
setIgnore(!(overSprite(e.clientX, e.clientY) || overBubble(e.clientX, e.clientY)));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("mouseout", (e) => {
|
document.addEventListener("mouseout", (e) => {
|
||||||
if (e.relatedTarget || pressed) return;
|
if (e.relatedTarget || pressed) return;
|
||||||
|
lastPoint = null;
|
||||||
setHovered(false);
|
setHovered(false);
|
||||||
setIgnore(true);
|
setIgnore(true);
|
||||||
});
|
});
|
||||||
@@ -639,6 +729,12 @@ window.addEventListener("mouseup", (e) => {
|
|||||||
|
|
||||||
window.addEventListener("contextmenu", (e) => e.preventDefault());
|
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 }) => {
|
window.pet.on("pet:drag-move", ({ dx }) => {
|
||||||
// Codex:單一取樣的水平位移 >= 4 才切 running-right,<= -4 才切 running-left
|
// Codex:單一取樣的水平位移 >= 4 才切 running-right,<= -4 才切 running-left
|
||||||
if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right";
|
if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right";
|
||||||
|
|||||||
+19
-2
@@ -30,11 +30,28 @@ html, body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
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;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
#bubble::after {
|
||||||
|
|||||||
+2
-1
@@ -144,8 +144,9 @@ async function main() {
|
|||||||
console.log(`Tag : ${TAG}`);
|
console.log(`Tag : ${TAG}`);
|
||||||
|
|
||||||
if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`);
|
if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`);
|
||||||
|
// dist/ 可能留著前幾版的產物,只挑檔名含當前版本的,免得舊檔混進新 release
|
||||||
const files = fs.readdirSync(DIST_DIR)
|
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))
|
.map((f) => path.join(DIST_DIR, f))
|
||||||
.filter((f) => fs.statSync(f).isFile());
|
.filter((f) => fs.statSync(f).isFile());
|
||||||
if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`);
|
if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`);
|
||||||
|
|||||||
Reference in New Issue
Block a user