Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9497cc123 | ||
|
|
4e4563e861 |
@@ -37,7 +37,7 @@
|
||||
- **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 並記住位置;點一下揮手;右鍵或系統匣開選單。
|
||||
- **對話氣泡(兩行)** — 第一行是**對話名稱**(`/rename` 設的)或專案資料夾名,第二行才是訊息。同時開多個 session 時一眼就知道是哪一個在叫你。
|
||||
@@ -221,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 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、滑鼠方框判定 |
|
||||
| `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,7 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO
|
||||
|
||||
設計重點:
|
||||
|
||||
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`,renderer 讀 canvas 該點的 alpha(或是否壓在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。判定的座標有兩個來源:renderer 自己的 mousemove,以及**主程序每 50 ms 的游標輪詢**。後者是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到),只靠 mousemove 會在「游標還停在寵物身上時進入點穿」之後再也收不到事件,變成永遠卡在點穿、按不下去也拖不動。另外每一格畫完也會用最後已知座標重驗一次,因為換格會改變游標底下的像素。
|
||||
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`,renderer 依游標是否在**人物方框**或氣泡上即時切換,方框以外永遠點得到底下的視窗。判定用方框而不是讀 canvas 該點的 alpha:人物的手腳與邊緣會隨動畫在透明/不透明之間切換(以小念為例,hover 時只有 44% 的人物像素每一幀都不透明),逐像素判定會讓點穿狀態跟著動畫高速抖動,按下去那一瞬間剛好是透明格,滑鼠事件就穿到底下的視窗去了。判定的座標有兩個來源:renderer 自己的 mousemove,以及**主程序每 50 ms 的游標輪詢**。後者是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到),只靠 mousemove 會在「游標還停在寵物身上時進入點穿」之後再也收不到事件,變成永遠卡在點穿、按不下去也拖不動。
|
||||
- **拖曳**:由主程序用 `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-的對照)。
|
||||
@@ -298,7 +298,7 @@ claude-pet
|
||||
| `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 | 相同 |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-pet",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-pet",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"devDependencies": {
|
||||
"electron": "^43.4.1",
|
||||
"electron-builder": "^26.0.12"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-pet",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
||||
+18
-22
@@ -149,7 +149,7 @@ 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 = "";
|
||||
@@ -409,6 +409,9 @@ function handleEvent(p) {
|
||||
break;
|
||||
case "UserPromptSubmit":
|
||||
setState(s, "running");
|
||||
// 這時 transcript 裡最新的助理訊息還是上一輪的結尾,
|
||||
// 先標記成已顯示,免得接下來的工具事件把它當成新話講出來
|
||||
if (p.narrationId) lastNarrationId = p.narrationId;
|
||||
hideBubble();
|
||||
break;
|
||||
case "PreToolUse":
|
||||
@@ -456,7 +459,13 @@ function handleEvent(p) {
|
||||
// Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
|
||||
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
|
||||
setState(s, "review");
|
||||
say("✅ 完成!", 8000, false, title);
|
||||
// 收尾時把最後一句回覆講出來,而不是只說「完成」
|
||||
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 小時到期
|
||||
@@ -546,9 +555,6 @@ function tick(now) {
|
||||
prevBase = base;
|
||||
|
||||
draw(anim);
|
||||
// 換格會改變游標底下的像素。游標沒移動時主程序不會再送座標,
|
||||
// 所以這裡自己重驗一次,否則會卡在點穿狀態動不了。
|
||||
if (lastPoint && overPetArea(lastPoint.x, lastPoint.y)) refreshHitTest();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
@@ -621,21 +627,10 @@ function overBubble(x, y) {
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
}
|
||||
|
||||
// 壓在人物的不透明像素上(透明處要讓滑鼠穿過去)
|
||||
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 與點穿都用人物方框,不用逐像素 alpha:sprite 每一格的輪廓不同,人物的手腳與邊緣
|
||||
// 會隨動畫在透明/不透明之間切換,逐像素會讓游標停著不動時也在 true/false 之間高速抖動;
|
||||
// 點穿跟著抖,按下去那一瞬間剛好是透明格,滑鼠事件就穿到底下的視窗去了(看起來就是
|
||||
// 「又被穿透了、不能拖曳也不能右鍵」)。Codex 的 pet-area-hover 同樣是用矩形算的。
|
||||
function overPetArea(x, y) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
|
||||
@@ -665,8 +660,9 @@ function setHovered(on) {
|
||||
function refreshHitTest() {
|
||||
if (!lastPoint || pressed || drag) return; // 拖曳中不要動點穿狀態
|
||||
const { x, y } = lastPoint;
|
||||
setHovered(overPetArea(x, y));
|
||||
setIgnore(!(overSprite(x, y) || overBubble(x, y)));
|
||||
const inArea = overPetArea(x, y);
|
||||
setHovered(inArea);
|
||||
setIgnore(!(inArea || overBubble(x, y)));
|
||||
}
|
||||
|
||||
function setIgnore(ignore) {
|
||||
|
||||
+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