From c96dcccb80e4469c3cf2f54394bdf56b6dfd7f09 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Fri, 21 Aug 2026 14:41:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8B=95=E7=95=AB=E6=99=82=E5=BA=8F=E7=85=A7?= =?UTF-8?q?=20Codex=20=E6=A0=A1=E6=AD=A3=EF=BC=8C=E4=B8=A6=E4=BF=AE?= =?UTF-8?q?=E6=8E=89=20look=20=E5=88=97=E8=A2=AB=E7=95=AB=E5=B0=8F?= =?UTF-8?q?=E9=80=A0=E6=88=90=E7=9A=84=E7=B8=AE=E6=B0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: 待機動畫放慢成 Codex 的速度、非 idle 狀態播 3 次後沉澱,並自動補正 spritesheet 裡整列 look 格子被畫小的缺陷。 根本原因: 1. idle 直接用合約上的幀時長無限循環,一輪只有 1.1 秒,看起來一直在動。Codex 的 codex-pet-assets 裡有一個放慢倍率 6,idle 實際是一輪 6.6 秒,其他狀態則是播 3 次後接上慢速 idle 並從那裡循環。 2. 內附的小念 spritesheet 第 10 列(看向 180 度到 337.5 度)整列被畫成 idle 的 78%,寬高同時縮小。游標移到人物身上或左邊時會切到那一列,人物就突然矮一截。第 9 列尾端只有高度變矮、寬度不變,那是低頭姿勢,不能一起改。 3. 追視參考點取在頭部高度(0.3),與 Codex 的方框中心不同,使得更多游標位置被判成「在下方」,更容易踩到第 10 列。 影響: 待機時一直晃;游標一靠近或移到左側,人物就明顯縮水又彈回來。 修法: - 動畫改成序列模型(frames = [{row, col, ms}] + loopStart),對應 Codex 的 f(state): IDLE_SLOWDOWN = 6、STATE_REPEATS = 3、播完 3 次後循環慢速 idle。 - 每個 hook 事件都重播一次動畫(pulse),對應 Codex 每次狀態更新都會重跑動畫效果; 否則狀態播完 3 次後就再也看不出 Claude Code 在忙。 - 載入寵物時量測 16 個 look 格子,以整列中位數判斷是否整列被縮小(門檻 0.9), 是的話以腳底為支點放大回 idle 身高;單格差異不動,避免把低頭姿勢拉直。 - 追視參考點改為人物方框中心、死區改為 1 px,與 Codex 相同。 - 拖曳用的 running-left / running-right 維持持續循環,不套用沉澱規則。 刻意不同於 Codex 的地方: Codex 的追視會覆蓋所有狀態,本程式只在 idle 時追視。否則工作中的動畫會被靜態擺姿蓋掉, 寵物就失去反映 Claude Code 狀態的作用。 驗證: - 抽出序列邏輯單獨執行:idle 一輪 6600 ms(幀長 1680/660/660/840/840/1920); running / waiting / review 皆為 24 幀、第 18 幀起循環列 0;拖曳維持循環;一次性動作播完即止。 - 以 PIL 重現量測與繪製:小念第 9 列中位數比 0.995 不補正,第 10 列 0.783 補正倍率 1.2774, 補正後畫面身高 128.7-132.1 px(idle 為 130.4),且無任何一格超出畫布; xiao-nian-realistic 兩列都是 0.99,判定不需補正而未受影響。 - 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar 內的 renderer.js 與 main.js,確認六項改動都在出貨的檔案裡。 Co-Authored-By: Claude Fable 5 --- README.md | 6 +- main.js | 2 +- renderer/renderer.js | 197 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 170 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index de2a340..279f617 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ ## 特色 - **完整實作 Codex V2 pet contract** — 8 欄 × 11 列、192×208 cell 的 spritesheet,9 個標準動作列 + 16 個看向方向,**每一幀的時長都照 Codex 規格**,動起來跟在 Codex app 裡一模一樣。 -- **16 方向追視** — 游標在哪,她就看哪(000 = 正上方、順時針 22.5° 一格)。游標靠太近或靜止 6 秒就回到 idle 呼吸動畫。 +- **16 方向追視** — 游標在哪,她就看哪(000 = 正上方、順時針 22.5° 一格)。追視時是靜態擺姿、完全不動,與 Codex 相同;游標靜止 6 秒才改播慢速 idle。 +- **自動修正圖檔缺陷** — Codex 的 hatch 產生器有時會把整列 look 格子畫得比 idle 小(內附的小念第 10 列就小了 22%,游標一往下移人物就縮水)。以整列中位數偵測、用腳底當支點放大回正確身高;單格的姿勢差異不動。Codex 本身沒有做這件事。 - **與 Codex 同尺寸** — 100% 就是 Codex 桌面寵物的大小(126 × 137),高度用 Codex 的 `ceil(寬 × 208/192)` 算,兩邊並排看起來一樣大。 - **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。 - **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。 @@ -123,6 +124,8 @@ npm run autostart:install # (可選)開機自動啟動 規則: +- **待機時幾乎不動** — 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` 的終端機卡住狀態)。 @@ -228,6 +231,7 @@ 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 在忙。 - **顯示尺寸與 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` 辨識自己的項目,重裝只替換自己的、不動別人的。 diff --git a/main.js b/main.js index faea5e1..3595224 100644 --- a/main.js +++ b/main.js @@ -277,7 +277,7 @@ function startCursorPolling() { lastCursor = p; const b = win.getBounds(); const cx = b.x + b.width / 2; - const cy = b.y + BUBBLE_H + PET_H * config.scale * 0.3; // 大約頭部的高度 + const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心,與 Codex 相同 send("pet:cursor", { dx: p.x - cx, dy: p.y - cy }); }, 50); } diff --git a/renderer/renderer.js b/renderer/renderer.js index 316778b..794e3cd 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -33,8 +33,16 @@ const TIMINGS = { review: [150, 150, 150, 150, 150, 280], }; -const LOOK_DEADZONE = 38; // 游標離寵物中心太近 → 回到 idle(約寵物高度的 0.27) -const LOOK_IDLE_AFTER = 6000; // 游標靜止多久後不再盯著看 +// Codex 的 idle 每幀時長是合約值的 6 倍(codex-pet-assets 的 `_ = 6`), +// 一輪 6.6 秒而不是 1.1 秒,這就是為什麼它待機時看起來幾乎不動。 +const IDLE_SLOWDOWN = 6; +// Codex 的非 idle 狀態只播 3 次就沉澱回慢速 idle,不會一直循環下去。 +const STATE_REPEATS = 3; +// 每列 look 格子的整體縮放若比 idle 小超過這個比例就視為圖檔缺陷並補正 +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 }; @@ -50,11 +58,54 @@ let scale = 1; let followCursor = true; const sessions = new Map(); // sessionId → { state, updatedAt, cwd, reviewUntil } -let overlay = null; // 一次性動作:{ anim, loopsLeft, onDone } +let overlay = null; // 一次性動作:{ anim, loops, onDone } let drag = null; // 拖曳中:{ dir: "left" | "right" } const look = { idx: null, lastMoveAt: 0 }; -let player = { anim: "idle", frame: 0, frameStart: performance.now() }; +// 播放中的序列:frames = [{row, col, ms}],loopStart 為 null 表示播完就結束 +let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() }; let prevBase = "idle"; +let lookCells = null; // 16 個 look 格子的量測結果(含缺陷補正倍率) + +// 一輪原始動作 +function cycleOf(anim) { + return TIMINGS[anim].map((ms, col) => ({ row: ROW[anim], col, ms })); +} + +// Codex 的慢速 idle +const IDLE_SLOW = TIMINGS.idle.map((ms, col) => ({ row: ROW.idle, col, ms: ms * IDLE_SLOWDOWN })); + +const baseSeqCache = new Map(); + +// 對應 Codex 的 f(state):idle 直接循環慢速 idle,其他狀態播 STATE_REPEATS 次後沉澱成慢速 idle +function baseSequence(anim) { + let seq = baseSeqCache.get(anim); + if (seq) return seq; + if (anim === "idle") { + seq = { frames: IDLE_SLOW, loopStart: 0 }; + } else { + const repeated = []; + for (let i = 0; i < STATE_REPEATS; i++) repeated.push(...cycleOf(anim)); + seq = { frames: [...repeated, ...IDLE_SLOW], loopStart: repeated.length }; + } + baseSeqCache.set(anim, seq); + return seq; +} + +function startAnim(anim, now) { + let seq; + if (overlay && overlay.anim === anim) { + // 一次性動作:播指定次數後結束,交還給基礎狀態 + const frames = []; + for (let i = 0; i < overlay.loops; i++) frames.push(...cycleOf(anim)); + seq = { frames, loopStart: null }; + } else if (anim === "running-left" || anim === "running-right") { + // 拖曳中要持續跑,不能沉澱成 idle + seq = { frames: cycleOf(anim), loopStart: 0 }; + } else { + seq = baseSequence(anim); + } + player = { anim, frames: seq.frames, loopStart: seq.loopStart, step: 0, stepStart: now }; +} let bubbleTimer = null; let bubbleSticky = false; @@ -74,6 +125,7 @@ function loadPet(pet) { const img = new Image(); img.onload = () => { sheet = img; + measureLookRows(); makeTrayIcon(); log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`); }; @@ -89,6 +141,59 @@ function applyScale(s) { canvas.style.height = `${canvas.height}px`; } +// 量一格的人物輪廓(用來偵測圖檔裡整列被畫小的缺陷) +function measureCell(g, row, col) { + g.clearRect(0, 0, CELL_W, CELL_H); + g.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, CELL_W, CELL_H); + const d = g.getImageData(0, 0, CELL_W, CELL_H).data; + let minX = CELL_W, maxX = -1, minY = CELL_H, maxY = -1; + for (let y = 0; y < CELL_H; y++) { + for (let x = 0; x < CELL_W; x++) { + if (d[(y * CELL_W + x) * 4 + 3] <= 20) continue; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + if (maxY < 0) return null; + return { w: maxX - minX + 1, h: maxY - minY + 1, cx: (minX + maxX) / 2, bottom: maxY }; +} + +// Codex 的 hatch 產生器有時會把整列 look 格子畫得比 idle 小(小念的第 10 列就小了 22%), +// 直接照畫會讓游標一往下移人物就縮水。以「整列中位數」判斷,單格差異(低頭之類的姿勢)不動。 +function measureLookRows() { + lookCells = null; + if (!sheet || petInfo?.version !== 2) return; + try { + const c = document.createElement("canvas"); + c.width = CELL_W; + c.height = CELL_H; + const g = c.getContext("2d", { willReadFrequently: true }); + const base = measureCell(g, ROW.idle, 0); + if (!base) return; + const cells = []; + let fixed = 0; + for (const row of [9, 10]) { + const measured = []; + for (let col = 0; col < 8; col++) measured.push(measureCell(g, row, col)); + const heights = measured.filter(Boolean).map((m) => m.h).sort((a, b) => a - b); + if (!heights.length) { + cells.push(...measured); + continue; + } + const median = heights[heights.length >> 1]; + const scale = median / base.h < LOOK_SCALE_TOLERANCE ? base.h / median : 1; + if (scale !== 1) fixed += measured.filter(Boolean).length; + for (const m of measured) cells.push(m ? { ...m, scale } : null); + } + lookCells = cells; + if (fixed) log(`look rows rescaled: ${fixed} cells (idle h=${base.h})`); + } catch (err) { + log(`measureLookRows failed: ${err.message}`); + } +} + function cellHasPixels(row, col) { const c = document.createElement("canvas"); c.width = 8; c.height = 8; @@ -148,8 +253,17 @@ function baseState() { } function play(anim, loops = 1, onDone = null) { - overlay = { anim, loopsLeft: loops, onDone }; - player = { anim, frame: 0, frameStart: performance.now() }; + overlay = { anim, loops, onDone }; + 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() { @@ -273,6 +387,7 @@ function handleEvent(p) { break; } report(); + pulse(); } function handleTest(p) { @@ -307,28 +422,28 @@ function report() { function tick(now) { const anim = resolveAnim(); if (anim !== player.anim) { - player = { anim, frame: 0, frameStart: now }; + startAnim(anim, now); report(); } if (anim !== "look") { - const t = TIMINGS[anim]; - if (now - player.frameStart > 5000) player.frameStart = now; // 從休眠醒來,避免狂追幀 + if (now - player.stepStart > 5000) player.stepStart = now; // 從休眠醒來,避免狂追幀 let guard = 0; - while (now - player.frameStart >= t[player.frame] && guard++ < 64) { - player.frameStart += t[player.frame]; - player.frame += 1; - if (player.frame >= t.length) { - player.frame = 0; - if (overlay && overlay.anim === anim) { - overlay.loopsLeft -= 1; - if (overlay.loopsLeft <= 0) { - const done = overlay.onDone; - overlay = null; - if (done) done(); - break; - } + while (guard++ < 64) { + const cur = player.frames[player.step]; + if (!cur || now - player.stepStart < cur.ms) break; + player.stepStart += cur.ms; + player.step += 1; + if (player.step >= player.frames.length) { + if (player.loopStart == null) { + // 一次性動作播完 → 停在最後一格,交還給基礎狀態 + player.step = player.frames.length - 1; + const done = overlay?.onDone; + overlay = null; + if (done) done(); + break; } + player.step = player.loopStart; } } } @@ -344,18 +459,34 @@ function tick(now) { function draw(anim) { ctx.clearRect(0, 0, canvas.width, canvas.height); if (!sheet) return; - let row; - let col; - if (anim === "look") { - row = 9 + (look.idx >> 3); - col = look.idx & 7; - } else { - row = ROW[anim]; - col = player.frame; - } ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; - ctx.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height); + + if (anim === "look") { + const idx = look.idx; + const row = 9 + (idx >> 3); + const col = idx & 7; + const fix = lookCells && lookCells[idx]; + if (fix && fix.scale !== 1) { + // 這一格在圖檔裡被整個畫小了(Codex 的 hatch 產生器有時會這樣), + // 以腳底為支點放大回 idle 的身高,否則游標一往下移人物就會突然縮水。 + const k = fix.scale; + const sx = canvas.width / CELL_W; + const sy = canvas.height / CELL_H; + ctx.drawImage( + sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, + fix.cx * (1 - k) * sx, fix.bottom * (1 - k) * sy, + canvas.width * k, canvas.height * k, + ); + return; + } + ctx.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height); + return; + } + + const frame = player.frames[player.step] || player.frames[0]; + if (!frame) return; + ctx.drawImage(sheet, frame.col * CELL_W, frame.row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height); } // ---------- 看向游標 ---------- @@ -363,7 +494,7 @@ function draw(anim) { window.pet.on("pet:cursor", ({ dx, dy }) => { const now = Date.now(); const dist = Math.hypot(dx, dy); - if (dist < LOOK_DEADZONE * Math.max(scale, 0.75)) { + if (dist < LOOK_DEADZONE) { look.idx = null; look.lastMoveAt = now; return;