From f886df554e02305a225e56de3ee8cf44046800c9 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Fri, 21 Aug 2026 15:11:56 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=90=E9=A0=85=E5=B0=8D=E7=85=A7=20Codex=20?= =?UTF-8?q?=E6=A0=A1=E6=AD=A3=E6=89=80=E6=9C=89=E8=A1=8C=E7=82=BA=E9=82=8F?= =?UTF-8?q?=E8=BC=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: 從 Codex app 的 app.asar 讀出寵物的完整行為邏輯(狀態決策、優先序、到期、hover 版面、 初次問候、拖曳、reduced motion),逐項對照後校正本程式。 根本原因: 先前只對齊了動畫時序,狀態機本身仍是自訂的,與 Codex 有多處不一致: 1. 跨 session 優先序是 waiting>running>review>idle,但 Codex 的 Ai() 是 waiting>failed>review>running,review 其實高於 running。 2. Stop 用 jumping 一次 + 6 秒 review,Codex 是 review 持續狀態直到該 thread 被讀取。 3. StopFailure 用 failed 播兩次就回 idle,Codex 的 failed 是持續狀態。 4. 工具失敗(PostToolUseFailure)會播 failed,Codex 中單一工具失敗不改變 turn 狀態。 5. 每個 hook 事件都重播動畫,Codex 只在狀態改變時重播(effect 相依只有 state)。 6. hover 是「跳躍」,Codex 實際的 respondToHover 全 app 沒人開啟;真正的 hover 行為是 proximity 版面:40px 內人物縮 0.9、下移、露出控制鈕,56px 外退出。 7. 追視綁滑鼠且預設開,但 Codex 的追視來源是 quick chat 文字游標/computer-use 游標, 不是滑鼠。 8. 沒有 first-awake 問候;狀態到期時間、拖曳方向閾值、reduced motion、預設位置也都沒對齊。 影響: 多 session 時顯示的狀態可能不對(running 蓋掉 review);完成與失敗的表現和 Codex 差很多; 待機期間仍可能因連續事件一直重播;hover 行為與 Codex 完全不同。 修法(數值皆取自 codex-pet-assets / avatar-overlay-native-frame / 主程序): - 優先序改為 waiting(4)>failed(3)>review(2)>running(1)>idle(0)(對應 Ai())。 - 到期改為 failed 1h、waiting 24h、review 7d、running/idle 不到期(對應 Ei()), 另保留一個 Codex 沒有的安全網:running 30 分鐘無事件視為終端機關閉。 - Stop→review 持續狀態;StopFailure→failed 持續狀態;PostToolUseFailure 只給氣泡不改狀態; idle_prompt 不再算 waiting。 - 動畫只在狀態改變時重播(移除 pulse)。 - hover 改為 proximity 版面:enter 40px / exit 56px,人物縮 0.9、下移 14px、 200ms 過渡,上方露出 24px 選單鈕(對應 hoverControlSize/hoverOffsetY/hoverControlGap)。 - 追視改為選配、預設關;first-awake 問候每隻寵物一次(記在設定檔 greetedPetIds); 拖曳方向改為單次位移 >= 4px 才決定(對應 Codex 的取樣邏輯); reduced motion 只顯示第一格;預設位置改為工作區右下各 24px;尺寸檔位對齊 80–224px 範圍。 - README 新增「與 Codex 的對照」整節,逐項列出相同與刻意不同之處。 驗證: - baseState 單元測試:優先序(review>running、waiting>failed、failed>review)與各狀態 到期(failed 1h、waiting 24h、review 7d、running 安全網、idle 清理)全數通過。 - 啟動實機以 HTTP /event 灌事件、/state 讀回:Stop→review、review 蓋過 running、 授權→waiting、waiting>failed、failed>running、工具失敗不改狀態,皆正確。 - 桌面截圖:寵物正常顯示;滑鼠移到人物 40px 內時人物縮小下移、上方浮出圓形選單鈕, 與 Codex 的 hover 版面一致。 Co-Authored-By: Claude Fable 5 --- README.md | 66 ++++++++++++++----- main.js | 24 ++++--- preload.js | 1 + renderer/index.html | 1 + renderer/renderer.js | 154 ++++++++++++++++++++++++++++--------------- renderer/style.css | 47 +++++++++++++ 6 files changed, 216 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 92ce24a..6c5c6e2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [新增 / 切換寵物](#新增--切換寵物) - [設定檔](#設定檔) - [架構與檔案](#架構與檔案) +- [與 Codex 的對照](#與-codex-的對照) - [HTTP API](#http-api) - [打包成 exe](#打包成-exe) - [疑難排解](#疑難排解) @@ -30,7 +31,8 @@ ## 特色 - **完整實作 Codex V2 pet contract** — 8 欄 × 11 列、192×208 cell 的 spritesheet,9 個標準動作列 + 16 個看向方向,**每一幀的時長都照 Codex 規格**,動起來跟在 Codex app 裡一模一樣。 -- **16 方向追視** — 游標在哪,她就看哪(000 = 正上方、順時針 22.5° 一格)。追視時是靜態擺姿、完全不動,與 Codex 相同;游標靜止 6 秒才改播慢速 idle。 +- **行為邏輯逐項對照 Codex** — 狀態優先序、各狀態到期時間、動畫時序、hover 版面、初次問候、拖曳方向判定……都是從 Codex app 的 `app.asar` 讀出來的數值(見[與 Codex 的對照](#與-codex-的對照))。 +- **16 方向追視(選配,預設關)** — 開啟後游標在哪她就看哪(000 = 正上方、順時針 22.5° 一格),追視時是靜態擺姿。Codex 的寵物**不看滑鼠**——它看的是 quick chat 的文字游標與 computer-use 的虛擬游標,所以預設關閉以求一致;喜歡的話在選單打開。 - **自動修正圖檔缺陷** — Codex 的 hatch 產生器有時會把整列 look 格子畫得比 idle 小(內附的小念第 10 列就小了 22%,游標一往下移人物就縮水)。以整列中位數偵測、用腳底當支點放大回正確身高;單格的姿勢差異不動。Codex 本身沒有做這件事。 - **與 Codex 同尺寸** — 100% 就是 Codex 桌面寵物的大小(126 × 137),高度用 Codex 的 `ceil(寬 × 208/192)` 算,兩邊並排看起來一樣大。 - **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。 @@ -85,8 +87,8 @@ npm run autostart:install # (可選)開機自動啟動 | 左鍵點一下 | 揮手(若有非固定的氣泡,先關閉氣泡) | | 左鍵拖曳 | 搬家;往左播 running-left、往右播 running-right;放開後位置寫入設定檔 | | 右鍵 / 系統匣左鍵 | 開啟選單 | -| 滑鼠移到她身上 | 跳躍 3 次(Codex 的 hover 行為),離開後回到原本狀態 | -| 滑鼠靠近 | 盯著游標看(可在選單關閉) | +| 滑鼠靠近(40 px 內) | 她縮到 0.9、往下讓出位置,上方出現選單鈕;離開 56 px 外才復原(Codex 的 hover 版面) | +| 滑鼠靠近 | 盯著游標看(選單開啟「跟著游標看」後才有) | **選單功能** @@ -103,34 +105,36 @@ npm run autostart:install # (可選)開機自動啟動 | Claude Code hook | 條件 | 動作 | 氣泡 | |---|---|---|---| -| `SessionStart` | `source = startup` | `waving` 一次 → `idle` | 👋 嗨!(專案) | +| (程式啟動) | 該寵物第一次出現 | `waving` × 3 → 慢速 `idle` | 👋 嗨,我是〈寵物名〉(8 秒) | +| `SessionStart` | `source = startup` | `idle` | 👋 嗨!(專案) | | `UserPromptSubmit` | | `running` | (關閉舊氣泡) | | `PreToolUse` | 一般工具 | `running` | | | `PreToolUse` | `AskUserQuestion` | `waiting` | ❓ 有問題想問你 | | `PreToolUse` | `ExitPlanMode` | `waiting` | 📋 計畫等你確認 | | `PostToolUse` | | `running` | | -| `PostToolUseFailure` | | `failed` 一次 → `running` | 😵 〈工具〉失敗了 | +| `PostToolUseFailure` | | `running`(不變) | 😵 〈工具〉失敗了 | | `PermissionRequest` | | `waiting` | 🔐 需要你授權:〈工具〉 | | `Notification` | `permission_prompt` | `waiting` | 🔐 需要你授權 | -| `Notification` | `idle_prompt` | `waiting` | 💤 在等你回覆 | +| `Notification` | `idle_prompt` | (不變) | 💤 在等你回覆 | | `Notification` | `elicitation_dialog` | `waiting` | ❓ 有問題想問你 | | `Notification` | 其他 | (不變) | 💬 訊息內容 | -| `Stop` | `stop_hook_active = false` | `jumping` 一次 → `review` 6 秒 → `idle` | ✅ 完成!(專案) | -| `StopFailure` | | `failed` 兩次 → `idle` | ❌ 錯誤訊息 | +| `Stop` | `stop_hook_active = false` | `review`,直到該 session 下一個動作/結束/7 天 | ✅ 完成!(專案) | +| `StopFailure` | | `failed`,直到該 session 下一個動作/結束/1 小時 | ❌ 錯誤訊息 | | `SubagentStart` / `SubagentStop` | | `running` | | | `PreCompact` | | `running` | 🗜️ 整理記憶中… | | `SessionEnd` | | 移除該 session | 最後一個結束時 👋 掰掰 | -| (無事件) | 游標移動 | 16 方向 `look` | | -| (無事件) | 拖曳 | `running-left` / `running-right` | | -| (無事件) | 滑鼠移到人物像素上 | `jumping` × 3 | | +| (無事件) | 游標移動(追視開啟時) | 16 方向 `look`,覆蓋所有狀態 | | +| (無事件) | 拖曳 | 單次位移 ≥ 4 px 才決定 `running-left` / `running-right` | | +| (無事件) | 游標靠近 40 px 內 | 動畫不變;人物縮 0.9、顯示選單鈕 | | 規則: - **待機時幾乎不動** — idle 每幀時長是合約值的 **6 倍**(一輪 6.6 秒,不是 1.1 秒),與 Codex 的 `codex-pet-assets` 一致。 -- **非 idle 狀態播 3 次就沉澱** 成慢速 idle,也和 Codex 相同;但每收到一個 hook 事件就重播一次,所以工作中她會隨著每次工具呼叫動一下,安靜下來才靜下來。 -- **一次性動作**(waving / jumping / failed)播完自動回到該 session 的基礎狀態。 -- **基礎狀態**依 session 彙整:`waiting(3) > running(2) > review(1) > idle(0)`,取最高者顯示。 -- 30 分鐘沒有任何事件的 session 會被視為結束而忽略(避免沒送 `SessionEnd` 的終端機卡住狀態)。 +- **非 idle 狀態播 3 次就沉澱** 成慢速 idle。動畫只在**狀態改變**時重新開始(Codex 的 effect 相依只有 state),工作中連續的工具呼叫不會讓她一直動。 +- **一次性動作**(點擊揮手、初次問候)播完自動回到基礎狀態。 +- **基礎狀態**依 session 彙整:`waiting(4) > failed(3) > review(2) > running(1) > idle(0)`,取最高者顯示——與 Codex 通知排序 `Ai()` 相同,注意 **review 高於 running**。 +- **到期**與 Codex 的 `Ei()` 相同:`failed` 1 小時、`waiting` 24 小時、`review` 7 天後退回 idle;`running` 與 `idle` 不到期。另有一個 Codex 沒有的安全網:`running` 30 分鐘沒任何事件視為終端機已被關掉。 +- **系統「減少動態效果」開啟時**只顯示每個狀態的第一格,與 Codex 相同。 - `waiting` 的氣泡是固定的,狀態離開 `waiting` 時自動消失。 ## 寵物包格式(Codex V2) @@ -198,7 +202,8 @@ Spritesheet:**1536 × 2288**,8 欄 × 11 列,每格 **192 × 208**,透 | `scale` | `1` | 縮放:0.5 / 0.75 / 1 / 1.25 / 1.5。`1` 表示 126 × 137,與 Codex 桌面寵物同大小 | | `position` | `null` | 視窗左上角座標;`null` 為主螢幕右下角。若座標落在所有螢幕之外會自動拉回 | | `alwaysOnTop` | `true` | 永遠置頂 | -| `followCursor` | `true` | 追視游標 | +| `followCursor` | `false` | 追視游標(Codex 的寵物不看滑鼠,預設關) | +| `greetedPetIds` | `[]` | 已打過招呼的寵物 id;刪掉可讓該寵物下次啟動再揮手一次 | | `port` | `17333` | 接收 hook 事件的本機埠(改了之後 hook 端要設環境變數 `CLAUDE_PET_PORT`) | 事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。 @@ -233,11 +238,38 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO - **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`,renderer 在 mousemove 時讀 canvas 該點 alpha(或是否在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。 - **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。 -- **動畫時序完全照 Codex**:`IDLE_SLOWDOWN = 6`、`STATE_REPEATS = 3`、追視死區 1 px、追視參考點為人物方框中心——這幾個值都是從 Codex app 的 `resources/app.asar` 裡的 `codex-pet-assets` 讀出來的(`_ = 6`、`[...n,...n,...n]`、`N = 1`)。唯一刻意不同的是:Codex 的追視會覆蓋所有狀態,本程式只在 idle 時追視,否則工作中的動畫會被蓋掉、看不出 Claude Code 在忙。 +- **行為邏輯以 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` 失敗。 - **hook 合併規則**:以指令中是否含 `claude-pet-hook` 辨識自己的項目,重裝只替換自己的、不動別人的。 +## 與 Codex 的對照 + +所有數值都是從 Codex app(`WindowsApps\OpenAI.Codex_*\app\resources\app.asar`)的 `codex-pet-assets`、`avatar-overlay-native-frame` 與主程序讀出來的。 + +| 項目 | Codex | 本程式 | +|---|---|---| +| idle 速度 | 合約幀長 × 6(`_ = 6`) | 相同 | +| 非 idle 狀態 | 播 3 次 → 慢速 idle(`[...n,...n,...n]`) | 相同 | +| 動畫何時重播 | 只在狀態改變時(effect 相依 `[state, lookFrame, reducedMotion]`) | 相同 | +| 跨 thread 優先序 | `waiting 0 > failed 1 > review 2 > running 3 > idle 4`(`Ai()`) | 相同 | +| 狀態到期 | failed 1 h、waiting 24 h、review 7 d、running/idle 不到期(`Ei()`) | 相同,另加 running 30 分鐘無事件的安全網 | +| `failed` | 持續狀態(turn 失敗/取消) | 相同(`StopFailure`);工具失敗不算 | +| `waiting` | 授權、`requestUserInput`、計畫待確認 | 相同;`idle_prompt` 不算 | +| `review` | turn 完成且未讀,直到使用者回到該 thread | 直到該 session 下一個動作 | +| `waving` | 只在 first-awake(每隻寵物一次,8 秒) | 相同(記在設定檔 `greetedPetIds`) | +| `jumping` | 只在 `respondToHover`,而整個 app 沒人開啟它 | 只在選單「動作測試」 | +| hover | 40 px 內進入、56 px 外退出;人物縮 0.9、下移 14 px、上方 24 px 控制鈕、200 ms | 相同;控制鈕是選單 | +| 追視 | 看 quick chat 文字游標/computer-use 游標,死區 1 px,覆蓋所有狀態 | 沒有對應來源;改為**選配**看滑鼠,預設關 | +| 拖曳方向 | 單次取樣水平位移 ≥ 4 px | 相同 | +| 拖曳放開 | 彈簧回彈(spring 220 / damping 26) | 無(直接停在放開處) | +| 點擊 | 開 quick chat | 揮手(沒有對應功能) | +| reduced motion | 只顯示第一格 | 相同 | +| 預設位置 | 工作區右下各留 24 px | 相同 | +| 尺寸 | 寬 80–224 px 可調 | 65%–175%(82–221 px) | + +刻意不同的地方都是因為 Claude Code 沒有對應的功能(quick chat、computer-use 游標)或對應的訊號(thread 是否已讀)。 + ## HTTP API 只監聽 `127.0.0.1`。 diff --git a/main.js b/main.js index 3595224..461397a 100644 --- a/main.js +++ b/main.js @@ -35,7 +35,8 @@ const BUBBLE_H = 72; // 氣泡保留高度 const PAD_X = 24; // 左右最小透明邊 const PAD_BOTTOM = 8; const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身) -const SCALES = [0.5, 0.75, 1, 1.25, 1.5]; +// Codex 的 mascot 寬度可調範圍是 80–224 px(ike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內 +const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75]; const DEFAULT_CONFIG = { activePetId: "xiao-nian", @@ -43,7 +44,8 @@ const DEFAULT_CONFIG = { scale: 1, position: null, alwaysOnTop: true, - followCursor: true, + followCursor: false, // Codex 的寵物不看滑鼠(它看的是 quick chat 文字游標/computer-use 游標);預設關 + greetedPetIds: [], // 已經打過招呼的寵物(Codex 的 first-awake 每隻只做一次) port: 17333, }; @@ -71,6 +73,7 @@ function loadConfig() { .filter((p) => fs.existsSync(p)); if (!SCALES.includes(Number(merged.scale))) merged.scale = 1; merged.scale = Number(merged.scale); + if (!Array.isArray(merged.greetedPetIds)) merged.greetedPetIds = []; return merged; } @@ -157,6 +160,7 @@ function petPayload(pet) { displayName: pet.displayName, description: pet.description, version: pet.version, + greeted: config.greetedPetIds.includes(pet.id), dataUrl: `data:${mime};base64,${data}`, }; } @@ -173,8 +177,9 @@ function windowSizeFor(scale) { function defaultPosition(size) { const { workArea } = screen.getPrimaryDisplay(); return { + // Codex 的 anchor:離工作區右邊與底邊各 24 px(z5 = 24) x: workArea.x + workArea.width - size.width - 24, - y: workArea.y + workArea.height - size.height - 4, + y: workArea.y + workArea.height - size.height - 24, }; } @@ -242,16 +247,14 @@ function startDrag(offsetX, offsetY) { if (!win) return; endDrag(false); let last = screen.getCursorScreenPoint(); - let lastDx = 0; const startedAt = Date.now(); dragTimer = setInterval(() => { if (!win || Date.now() - startedAt > 60000) return endDrag(true); const p = screen.getCursorScreenPoint(); const dx = p.x - last.x; last = p; - if (Math.abs(dx) >= 1) lastDx = dx; win.setPosition(Math.round(p.x - offsetX), Math.round(p.y - offsetY), false); - send("pet:drag-move", { dx: lastDx }); + send("pet:drag-move", { dx }); }, 16); } @@ -368,8 +371,8 @@ function info(message) { function buildMenu() { const pet = activePet(); const TESTS = [ - ["idle", "待機"], ["running", "工作中"], ["waiting", "等待授權"], ["review", "檢視成果"], - ["failed", "失敗"], ["jumping", "跳躍(完成)"], ["waving", "揮手"], + ["idle", "待機"], ["running", "工作中"], ["waiting", "等待授權"], ["review", "檢視成果(完成)"], + ["failed", "失敗"], ["waving", "揮手"], ["jumping", "跳躍"], ]; return Menu.buildFromTemplate([ { label: pet ? `${pet.displayName}(${pet.id})` : "沒有可用的寵物", enabled: false }, @@ -505,6 +508,11 @@ ipcMain.on("pet:state", (_e, state) => { refreshTray(); }); ipcMain.on("pet:log", (_e, line) => appendLog(String(line))); +ipcMain.on("pet:greeted", (_e, id) => { + if (typeof id !== "string" || config.greetedPetIds.includes(id)) return; + config.greetedPetIds.push(id); + saveConfig(); +}); // ---------- app ---------- diff --git a/preload.js b/preload.js index cd5195b..f6085a5 100644 --- a/preload.js +++ b/preload.js @@ -10,6 +10,7 @@ const SEND = new Set([ "pet:tray-icon", "pet:state", "pet:log", + "pet:greeted", ]); const ON = new Set([ diff --git a/renderer/index.html b/renderer/index.html index f9a858d..e076041 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -9,6 +9,7 @@
+
diff --git a/renderer/renderer.js b/renderer/renderer.js index 82420e5..761fc32 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -42,22 +42,37 @@ const STATE_REPEATS = 3; const LOOK_SCALE_TOLERANCE = 0.9; const LOOK_DEADZONE = 1; // 與 Codex 相同(它的死區只有 1 px) -const LOOK_IDLE_AFTER = 6000; // 游標靜止多久後不再盯著看,改播慢速 idle -const REVIEW_DURATION = 6000; // Stop 後 review 多久回 idle -const SESSION_STALE = 30 * 60 * 1000; -const PRIORITY = { waiting: 3, running: 2, review: 1, idle: 0 }; +const LOOK_IDLE_AFTER = 6000; // 游標靜止多久後不再盯著看(Codex 的追視來源不是滑鼠,沒有這個問題) + +// 跨 session 的優先序,與 Codex 通知排序 Ai() 相同:waiting 0 > failed 1 > review 2 > running 3 > idle 4 +const PRIORITY = { waiting: 4, failed: 3, review: 2, running: 1, idle: 0 }; +// 各狀態多久沒更新就退回 idle,與 Codex 通知到期 Ei() 相同:failed 1 小時、waiting 24 小時、review 7 天。 +// running 與 idle 在 Codex 裡不會到期。 +const EXPIRY = { failed: 3600e3, waiting: 1440 * 60e3, review: 10080 * 60e3 }; +// 安全網(Codex 沒有,它的 thread 是常駐連線):終端機被直接關掉時不會送 SessionEnd, +// running 太久沒任何事件就視為已結束。 +const RUNNING_SILENCE = 30 * 60e3; +const IDLE_PRUNE = 30 * 60e3; // idle 的 session 閒置多久後從清單移除(純清理,不影響顯示) + +// hover 版面,數值與 Codex 的 petControlsAppearance 相同 +const HOVER_SCALE = 0.9; // H_:游標靠近時人物縮到 0.9 +const HOVER_OFFSET_Y = 10 + 8 / 2; // hoverOffsetY + hoverControlGap / 2:人物往下移,讓出上方放控制鈕 +const PROXIMITY_ENTER = 40; // proximityEnterDistance:游標離人物多近算「靠近」 +const PROXIMITY_EXIT = 56; // proximityExitDistance:離多遠才算離開(遲滯,避免邊界抖動) +const GREETING_MS = 8000; // first-awake 問候通知的存活時間(Pi = 8 s) const canvas = document.getElementById("sprite"); const ctx = canvas.getContext("2d", { willReadFrequently: true }); const bubble = document.getElementById("bubble"); const bubbleText = document.getElementById("bubble-text"); +const controls = document.getElementById("controls"); let sheet = null; let petInfo = null; let scale = 1; -let followCursor = true; +let followCursor = false; // Codex 的追視來源是 quick chat 文字游標/computer-use 游標,不是滑鼠;預設關 -const sessions = new Map(); // sessionId → { state, updatedAt, cwd, reviewUntil } +const sessions = new Map(); // sessionId → { state, updatedAt, cwd } let overlay = null; // 一次性動作:{ anim, loops, onDone } let drag = null; // 拖曳中:{ dir: "left" | "right" } const look = { idx: null, lastMoveAt: 0 }; @@ -91,9 +106,14 @@ function baseSequence(anim) { return seq; } +const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + function startAnim(anim, now) { let seq; - if (overlay && overlay.anim === anim) { + if (reducedMotionQuery.matches) { + // 與 Codex 相同:系統關閉動畫時只顯示該狀態的第一格 + seq = { frames: [cycleOf(anim)[0]], loopStart: null }; + } else if (overlay && overlay.anim === anim) { // 一次性動作:播指定次數後結束,交還給基礎狀態 const frames = []; for (let i = 0; i < overlay.loops; i++) frames.push(...cycleOf(anim)); @@ -110,7 +130,7 @@ function startAnim(anim, now) { let bubbleTimer = null; let bubbleSticky = false; let ignoringMouse = true; -let hovered = false; +let near = false; // 游標是否在人物附近(Codex 的 proximity) let pressed = null; let lastReported = ""; @@ -129,6 +149,12 @@ function loadPet(pet) { measureLookRows(); makeTrayIcon(); log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`); + // Codex 的 first-awake:某隻寵物第一次被叫出來時揮手(播 3 次)並打招呼 8 秒,之後不再 + if (!pet.greeted) { + play("waving", STATE_REPEATS); + say(`👋 嗨,我是${pet.displayName}`, GREETING_MS); + window.pet.send("pet:greeted", pet.id); + } }; img.onerror = () => log(`spritesheet load failed for ${pet.id}`); img.src = pet.dataUrl; @@ -140,6 +166,10 @@ function applyScale(s) { canvas.height = Math.round(PET_H * s); canvas.style.width = `${canvas.width}px`; canvas.style.height = `${canvas.height}px`; + // 控制鈕底邊 = 縮小後人物的頂端再往上 8 px(hoverControlGap)。 + // 人物以中心縮到 0.9 再往下移 HOVER_OFFSET_Y,頂端會下移 h*(1-0.9)/2 + HOVER_OFFSET_Y。 + const shiftedTop = canvas.height * (1 - HOVER_SCALE) / 2 + HOVER_OFFSET_Y; + controls.style.bottom = `${8 + canvas.height - shiftedTop + 8}px`; } // 量一格的人物輪廓(用來偵測圖檔裡整列被畫小的缺陷) @@ -229,7 +259,7 @@ function session(id, cwd) { const key = id || "default"; let s = sessions.get(key); if (!s) { - s = { state: "idle", updatedAt: Date.now(), cwd: cwd || null, reviewUntil: 0 }; + s = { state: "idle", updatedAt: Date.now(), cwd: cwd || null }; sessions.set(key, s); } if (cwd) s.cwd = cwd; @@ -242,12 +272,15 @@ function setState(s, state) { s.updatedAt = Date.now(); } +// 對應 Codex:每個 thread 產生一則帶狀態的通知,依優先序排序後取最上面那則的狀態給寵物 function baseState() { const now = Date.now(); let best = "idle"; for (const [id, s] of sessions) { - if (now - s.updatedAt > SESSION_STALE) { sessions.delete(id); continue; } - if (s.state === "review" && now > s.reviewUntil) s.state = "idle"; + const age = now - s.updatedAt; + if (EXPIRY[s.state] && age > EXPIRY[s.state]) s.state = "idle"; + else if (s.state === "running" && age > RUNNING_SILENCE) s.state = "idle"; + if (s.state === "idle" && age > IDLE_PRUNE) { sessions.delete(id); continue; } if (PRIORITY[s.state] > PRIORITY[best]) best = s.state; } return best; @@ -258,21 +291,15 @@ function play(anim, loops = 1, onDone = null) { startAnim(anim, performance.now()); } -// Codex 每次狀態更新都會重跑動畫效果、從頭播 3 次。這裡對應成:每個 hook 事件都讓她動一下, -// 沒有事件進來時就自然沉澱成慢速 idle。否則工作中的狀態播完 3 次後就再也看不出她在忙。 -function pulse() { - if (overlay || drag) return; - const anim = resolveAnim(); - if (anim === "look") return; - startAnim(anim, performance.now()); -} - function resolveAnim() { - if (drag) return drag.dir === "left" ? "running-left" : "running-right"; + // 拖曳:Codex 在 pointerdown 時先清掉 transient state,要等到單次位移 ≥ 4 px 才決定方向 + if (drag) return drag.dir ? `running-${drag.dir}` : baseState(); + // 一次性動作(點擊揮手、初次問候)。Codex 的 lookFrame 會蓋過這些,但它的追視來源不是滑鼠; + // 滑鼠永遠在畫面上,若照搬會讓點擊揮手永遠看不到,所以這裡讓手勢優先。 if (overlay) return overlay.anim; - const base = baseState(); - if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look"; - return base; + // Codex:lookFrame 一旦有值就覆蓋所有狀態,靜態擺姿 + if (followCursor && petInfo?.version === 2 && look.idx !== null) return "look"; + return baseState(); } function projectName(cwd) { @@ -310,10 +337,8 @@ function handleEvent(p) { switch (p.event) { case "SessionStart": setState(s, "idle"); - if (!p.source || p.source === "startup") { - play("waving", 1); - say(`👋 嗨!${suffix}`, 3000); - } + // Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡 + if (!p.source || p.source === "startup") say(`👋 嗨!${suffix}`, 3000); break; case "UserPromptSubmit": setState(s, "running"); @@ -334,8 +359,8 @@ function handleEvent(p) { setState(s, "running"); break; case "PostToolUseFailure": + // Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡 setState(s, "running"); - play("failed", 1); say(`😵 ${p.toolName || "工具"} 失敗了`, 4000); break; case "PermissionRequest": @@ -348,8 +373,8 @@ function handleEvent(p) { setState(s, "waiting"); say(`🔐 需要你授權${suffix}`, 0, true); } else if (t === "idle_prompt") { - setState(s, "waiting"); - say(`💤 在等你回覆${suffix}`, 0, true); + // Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變 + say(`💤 在等你回覆${suffix}`, 4000); } else if (t === "elicitation_dialog") { setState(s, "waiting"); say(`❓ 有問題想問你${suffix}`, 0, true); @@ -360,13 +385,14 @@ function handleEvent(p) { } case "Stop": if (p.stopHookActive) break; + // Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。 + // 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。 setState(s, "review"); - s.reviewUntil = Date.now() + REVIEW_DURATION; - play("jumping", 1, () => say(`✅ 完成!${suffix}`, 8000)); + say(`✅ 完成!${suffix}`, 8000); break; case "StopFailure": - setState(s, "idle"); - play("failed", 2); + // Codex:turn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期 + setState(s, "failed"); say(`❌ ${p.error || "出錯了"}`, 8000); break; case "SubagentStart": @@ -388,7 +414,6 @@ function handleEvent(p) { break; } report(); - pulse(); } function handleTest(p) { @@ -397,12 +422,11 @@ function handleTest(p) { sessions.delete("test"); overlay = null; hideBubble(); - } else if (anim === "waving" || anim === "jumping" || anim === "failed") { - play(anim, anim === "failed" ? 2 : 1); + } else if (anim === "waving" || anim === "jumping") { + play(anim, STATE_REPEATS); } else if (anim in PRIORITY) { const s = session("test"); setState(s, anim); - if (anim === "review") s.reviewUntil = Date.now() + 60000; if (anim === "waiting") say("🔐(測試)需要你授權", 0, true); else if (anim === "idle") hideBubble(); } @@ -539,12 +563,29 @@ function overSprite(x, y) { } } -// Codex 的寵物 hover 時會把狀態換成 jumping(codex-pet-assets 的 respondToHover ? "jumping" : state), -// 跳 STATE_REPEATS 次後沉澱回慢速 idle,滑鼠離開再回到原本的狀態。 -function setHovered(on) { - if (on === hovered) return; - hovered = on; - if (on && !overlay && !drag) play("jumping", STATE_REPEATS); +function overControls(x, y) { + if (!near) return false; + const r = controls.getBoundingClientRect(); + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; +} + +function distanceToRect(x, y, r) { + const dx = Math.max(r.left - x, 0, x - r.right); + const dy = Math.max(r.top - y, 0, y - r.bottom); + return Math.hypot(dx, dy); +} + +// Codex 的 hover:游標進到離人物 40 px 內就切到 hover 版面——人物縮到 0.9、往下移 14 px、 +// 上方出現 24 px 的控制鈕(200 ms 過渡);離開超過 56 px 才退出。動畫狀態本身不變。 +function updateProximity(x, y) { + const d = distanceToRect(x, y, canvas.getBoundingClientRect()); + setNear(near ? d <= PROXIMITY_EXIT : d <= PROXIMITY_ENTER); +} + +function setNear(on) { + if (on === near) return; + near = on; + document.body.classList.toggle("near", on); } function setIgnore(ignore) { @@ -558,24 +599,24 @@ window.addEventListener("mousemove", (e) => { if (pressed) { if (!pressed.moved && Math.hypot(e.screenX - pressed.sx, e.screenY - pressed.sy) > 4) { pressed.moved = true; - drag = { dir: "right" }; + drag = { dir: null }; canvas.classList.add("grabbing"); window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y }); } return; } - const onSprite = overSprite(e.clientX, e.clientY); - setHovered(onSprite); - setIgnore(!(onSprite || overBubble(e.clientX, e.clientY))); + updateProximity(e.clientX, e.clientY); + setIgnore(!(overSprite(e.clientX, e.clientY) || overBubble(e.clientX, e.clientY) || overControls(e.clientX, e.clientY))); }); document.addEventListener("mouseout", (e) => { if (e.relatedTarget || pressed) return; - setHovered(false); + setNear(false); setIgnore(true); }); window.addEventListener("mousedown", (e) => { + if (e.target instanceof Element && e.target.closest("#controls")) return; if (e.button === 0) { pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false }; } else if (e.button === 2) { @@ -599,7 +640,8 @@ window.addEventListener("mouseup", (e) => { window.addEventListener("contextmenu", (e) => e.preventDefault()); window.pet.on("pet:drag-move", ({ dx }) => { - if (drag && dx) drag.dir = dx < 0 ? "left" : "right"; + // Codex:單一取樣的水平位移 >= 4 才切 running-right,<= -4 才切 running-left + if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right"; }); function onClick() { @@ -613,7 +655,7 @@ function onClick() { // ---------- 初始化 ---------- window.pet.on("pet:init", ({ pet, scale: s, followCursor: f }) => { - followCursor = f !== false; + followCursor = f === true; applyScale(s || 1); if (pet) loadPet(pet); else log("no pet available"); @@ -625,5 +667,13 @@ window.pet.on("pet:settings", (st) => { }); window.pet.on("pet:event", handleEvent); +document.getElementById("menu-btn").addEventListener("click", (e) => { + e.stopPropagation(); + window.pet.send("pet:context-menu"); +}); + +// 系統的「減少動態效果」切換時,讓目前的動畫重新開始(Codex 的 effect 也把 reducedMotion 列為相依) +reducedMotionQuery.addEventListener("change", () => { if (player.anim) startAnim(player.anim, performance.now()); }); + window.pet.send("pet:ready"); requestAnimationFrame(tick); diff --git a/renderer/style.css b/renderer/style.css index 55a8cc3..783f556 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -56,6 +56,53 @@ html, body { #sprite { display: block; + /* Codex 的 hover 版面:以中心縮到 0.9 並往下移,200 ms 過渡(transitionDurationMs) */ + transform-origin: 50% 50%; + transition: transform 200ms ease; +} + +body.near #sprite { + transform: translateY(14px) scale(0.9); +} + +/* 靠近時氣泡讓開控制鈕 */ +body.near #bubble:not(.hidden) { + transform: translateY(-22px); +} + +/* 控制鈕:24 px(hoverControlSize),靠近時才出現 */ +#controls { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; +} + +body.near #controls { + opacity: 1; + pointer-events: auto; +} + +#controls button { + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.97); + color: #222; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); + font-size: 13px; + line-height: 24px; + cursor: pointer; +} + +#controls button:hover { + background: #fff; } #sprite.grab {