逐項對照 Codex 校正所有行為邏輯

摘要:
從 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:11:56 +08:00
co-authored by Claude Fable 5
parent 7d4c5d849c
commit f886df554e
6 changed files with 216 additions and 77 deletions
+49 -17
View File
@@ -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 的 spritesheet9 個標準動作列 + 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 ──hookstdin 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 × 137Codex 的 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 | 相同 |
| 尺寸 | 寬 80224 px 可調 | 65%175%82221 px |
刻意不同的地方都是因為 Claude Code 沒有對應的功能(quick chat、computer-use 游標)或對應的訊號(thread 是否已讀)。
## HTTP API
只監聽 `127.0.0.1`
+16 -8
View File
@@ -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 寬度可調範圍是 80224 pxike() 的 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 ----------
+1
View File
@@ -10,6 +10,7 @@ const SEND = new Set([
"pet:tray-icon",
"pet:state",
"pet:log",
"pet:greeted",
]);
const ON = new Set([
+1
View File
@@ -9,6 +9,7 @@
<body>
<div id="stage">
<div id="bubble" class="hidden"><span id="bubble-text"></span></div>
<div id="controls"><button id="menu-btn" type="button" title="選單" aria-label="選單"></button></div>
<canvas id="sprite" width="126" height="137"></canvas>
</div>
<script src="renderer.js"></script>
+102 -52
View File
@@ -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 pxhoverControlGap)。
// 人物以中心縮到 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;
// CodexlookFrame 一旦有值就覆蓋所有狀態,靜態擺姿
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;
// Codexturn 完成且未讀 → 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);
// Codexturn 失敗 → 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 時會把狀態換成 jumpingcodex-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);
+47
View File
@@ -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 pxhoverControlSize),靠近時才出現 */
#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 {