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 {