Files
claude-pet/renderer/renderer.js
T
JianMiauandClaude Fable 5 c96dcccb80 動畫時序照 Codex 校正,並修掉 look 列被畫小造成的縮水
摘要:
待機動畫放慢成 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 <noreply@anthropic.com>
2026-08-21 14:41:42 +08:00

614 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Claude Pet rendererCodex V2 pet contract 的動畫引擎 + 狀態機
// 規格來源:8 欄 x 11 列、192x208 cell,列 0-8 標準動作、列 9-10 為 16 個順時針看向方向(000 = 正上方)
const CELL_W = 192; // spritesheet 單格來源尺寸
const CELL_H = 208;
// 100% 時的顯示尺寸,對齊 Codex 桌面寵物(與 main.js 的 PET_W / PET_H 一致)
const PET_W = 126;
const PET_H = Math.ceil((PET_W * CELL_H) / CELL_W); // 137
const ROW = {
idle: 0,
"running-right": 1,
"running-left": 2,
waving: 3,
jumping: 4,
failed: 5,
waiting: 6,
running: 7,
review: 8,
};
// 每一幀的毫秒數(Codex V2 animation-rows 合約)
const TIMINGS = {
idle: [280, 110, 110, 140, 140, 320],
"running-right": [120, 120, 120, 120, 120, 120, 120, 220],
"running-left": [120, 120, 120, 120, 120, 120, 120, 220],
waving: [140, 140, 140, 280],
jumping: [140, 140, 140, 140, 280],
failed: [140, 140, 140, 140, 140, 140, 140, 240],
waiting: [150, 150, 150, 150, 150, 260],
running: [120, 120, 120, 120, 120, 220],
review: [150, 150, 150, 150, 150, 280],
};
// 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 };
const canvas = document.getElementById("sprite");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const bubble = document.getElementById("bubble");
const bubbleText = document.getElementById("bubble-text");
let sheet = null;
let petInfo = null;
let scale = 1;
let followCursor = true;
const sessions = new Map(); // sessionId → { state, updatedAt, cwd, reviewUntil }
let overlay = null; // 一次性動作:{ anim, loops, onDone }
let drag = null; // 拖曳中:{ dir: "left" | "right" }
const look = { idx: null, lastMoveAt: 0 };
// 播放中的序列: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;
let ignoringMouse = true;
let pressed = null;
let lastReported = "";
function log(msg) {
try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
}
// ---------- 載入 ----------
function loadPet(pet) {
petInfo = pet;
sheet = null;
const img = new Image();
img.onload = () => {
sheet = img;
measureLookRows();
makeTrayIcon();
log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`);
};
img.onerror = () => log(`spritesheet load failed for ${pet.id}`);
img.src = pet.dataUrl;
}
function applyScale(s) {
scale = s;
canvas.width = Math.round(PET_W * s);
canvas.height = Math.round(PET_H * s);
canvas.style.width = `${canvas.width}px`;
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;
const g = c.getContext("2d");
g.drawImage(sheet, col * CELL_W + CELL_W / 2 - 24, row * CELL_H + CELL_H / 2 - 24, 48, 48, 0, 0, 8, 8);
const d = g.getImageData(0, 0, 8, 8).data;
for (let i = 3; i < d.length; i += 4) if (d[i] > 30) return true;
return false;
}
function makeTrayIcon() {
if (!sheet) return;
try {
const col = petInfo.version === 2 && cellHasPixels(0, 6) ? 6 : 0;
const c = document.createElement("canvas");
c.width = 32; c.height = 32;
const g = c.getContext("2d");
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = "high";
// 取上半身(頭部)做成方形 icon
const sw = 128, sh = 128;
g.drawImage(sheet, col * CELL_W + (CELL_W - sw) / 2, 4, sw, sh, 0, 0, 32, 32);
window.pet.send("pet:tray-icon", c.toDataURL("image/png"));
} catch (err) {
log(`tray icon failed: ${err.message}`);
}
}
// ---------- 狀態 ----------
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 };
sessions.set(key, s);
}
if (cwd) s.cwd = cwd;
s.updatedAt = Date.now();
return s;
}
function setState(s, state) {
s.state = state;
s.updatedAt = Date.now();
}
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";
if (PRIORITY[s.state] > PRIORITY[best]) best = s.state;
}
return best;
}
function play(anim, loops = 1, onDone = null) {
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() {
if (drag) return drag.dir === "left" ? "running-left" : "running-right";
if (overlay) return overlay.anim;
const base = baseState();
if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
return base;
}
function projectName(cwd) {
if (!cwd) return "";
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
}
// ---------- 氣泡 ----------
function say(text, ms, sticky = false) {
clearTimeout(bubbleTimer);
bubbleText.textContent = text;
bubble.title = text;
bubble.classList.remove("hidden");
bubbleSticky = sticky;
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
}
function hideBubble() {
clearTimeout(bubbleTimer);
bubble.classList.add("hidden");
bubbleSticky = false;
}
// ---------- 事件 ----------
function handleEvent(p) {
if (!p || typeof p !== "object") return;
if (p.event === "__test") return handleTest(p);
const s = session(p.sessionId, p.cwd);
const proj = projectName(s.cwd);
const suffix = proj ? `${proj}` : "";
switch (p.event) {
case "SessionStart":
setState(s, "idle");
if (!p.source || p.source === "startup") {
play("waving", 1);
say(`👋 嗨!${suffix}`, 3000);
}
break;
case "UserPromptSubmit":
setState(s, "running");
hideBubble();
break;
case "PreToolUse":
if (p.toolName === "AskUserQuestion") {
setState(s, "waiting");
say(`❓ 有問題想問你${suffix}`, 0, true);
} else if (p.toolName === "ExitPlanMode") {
setState(s, "waiting");
say(`📋 計畫等你確認${suffix}`, 0, true);
} else {
setState(s, "running");
}
break;
case "PostToolUse":
setState(s, "running");
break;
case "PostToolUseFailure":
setState(s, "running");
play("failed", 1);
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
break;
case "PermissionRequest":
setState(s, "waiting");
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true);
break;
case "Notification": {
const t = p.notificationType;
if (t === "permission_prompt") {
setState(s, "waiting");
say(`🔐 需要你授權${suffix}`, 0, true);
} else if (t === "idle_prompt") {
setState(s, "waiting");
say(`💤 在等你回覆${suffix}`, 0, true);
} else if (t === "elicitation_dialog") {
setState(s, "waiting");
say(`❓ 有問題想問你${suffix}`, 0, true);
} else if (p.message) {
say(`💬 ${p.message}`, 4000);
}
break;
}
case "Stop":
if (p.stopHookActive) break;
setState(s, "review");
s.reviewUntil = Date.now() + REVIEW_DURATION;
play("jumping", 1, () => say(`✅ 完成!${suffix}`, 8000));
break;
case "StopFailure":
setState(s, "idle");
play("failed", 2);
say(`❌ ${p.error || "出錯了"}`, 8000);
break;
case "SubagentStart":
case "SubagentStop":
setState(s, "running");
break;
case "PreCompact":
setState(s, "running");
say("🗜️ 整理記憶中…", 3000);
break;
case "SessionEnd":
sessions.delete(p.sessionId || "default");
if (sessions.size === 0) {
hideBubble();
say("👋 掰掰", 2000);
}
break;
default:
break;
}
report();
pulse();
}
function handleTest(p) {
const anim = p.anim;
if (anim === "clear") {
sessions.delete("test");
overlay = null;
hideBubble();
} else if (anim === "waving" || anim === "jumping" || anim === "failed") {
play(anim, anim === "failed" ? 2 : 1);
} 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();
}
report();
}
function report() {
const base = baseState();
const snapshot = JSON.stringify({ anim: player.anim, base, sessions: sessions.size, pet: petInfo?.id || null });
if (snapshot !== lastReported) {
lastReported = snapshot;
window.pet.send("pet:state", JSON.parse(snapshot));
}
}
// ---------- 動畫主迴圈 ----------
function tick(now) {
const anim = resolveAnim();
if (anim !== player.anim) {
startAnim(anim, now);
report();
}
if (anim !== "look") {
if (now - player.stepStart > 5000) player.stepStart = now; // 從休眠醒來,避免狂追幀
let guard = 0;
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;
}
}
}
const base = baseState();
if (prevBase === "waiting" && base !== "waiting" && bubbleSticky) hideBubble();
prevBase = base;
draw(anim);
requestAnimationFrame(tick);
}
function draw(anim) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!sheet) return;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
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);
}
// ---------- 看向游標 ----------
window.pet.on("pet:cursor", ({ dx, dy }) => {
const now = Date.now();
const dist = Math.hypot(dx, dy);
if (dist < LOOK_DEADZONE) {
look.idx = null;
look.lastMoveAt = now;
return;
}
// 000 = 正上方,順時針
const angle = (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
const candidate = Math.round(angle / 22.5) % 16;
if (look.idx === null) {
look.idx = candidate;
} else if (candidate !== look.idx) {
// 加一點遲滯,避免在邊界抖動
let diff = Math.abs(angle - look.idx * 22.5);
diff = Math.min(diff, 360 - diff);
if (diff > 11.25 + 3) look.idx = candidate;
}
look.lastMoveAt = now;
});
setInterval(() => {
if (look.idx !== null && Date.now() - look.lastMoveAt > LOOK_IDLE_AFTER) look.idx = null;
}, 500);
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
function overOpaque(x, y) {
if (!bubble.classList.contains("hidden")) {
const r = bubble.getBoundingClientRect();
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true;
}
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;
}
}
function setIgnore(ignore) {
if (ignore === ignoringMouse) return;
ignoringMouse = ignore;
window.pet.send("pet:ignore-mouse", ignore);
canvas.classList.toggle("grab", !ignore);
}
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" };
canvas.classList.add("grabbing");
window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y });
}
return;
}
setIgnore(!overOpaque(e.clientX, e.clientY));
});
document.addEventListener("mouseout", (e) => {
if (!e.relatedTarget && !pressed) setIgnore(true);
});
window.addEventListener("mousedown", (e) => {
if (e.button === 0) {
pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false };
} else if (e.button === 2) {
window.pet.send("pet:context-menu");
}
});
window.addEventListener("mouseup", (e) => {
if (e.button !== 0 || !pressed) return;
const wasDrag = pressed.moved;
pressed = null;
if (wasDrag) {
drag = null;
canvas.classList.remove("grabbing");
window.pet.send("pet:drag-end");
} else {
onClick();
}
});
window.addEventListener("contextmenu", (e) => e.preventDefault());
window.pet.on("pet:drag-move", ({ dx }) => {
if (drag && dx) drag.dir = dx < 0 ? "left" : "right";
});
function onClick() {
if (!bubble.classList.contains("hidden") && !bubbleSticky) {
hideBubble();
return;
}
if (!overlay) play("waving", 1);
}
// ---------- 初始化 ----------
window.pet.on("pet:init", ({ pet, scale: s, followCursor: f }) => {
followCursor = f !== false;
applyScale(s || 1);
if (pet) loadPet(pet);
else log("no pet available");
});
window.pet.on("pet:load", ({ pet }) => { if (pet) loadPet(pet); });
window.pet.on("pet:scale", ({ scale: s }) => applyScale(s));
window.pet.on("pet:settings", (st) => {
if (st && "followCursor" in st) followCursor = !!st.followCursor;
});
window.pet.on("pet:event", handleEvent);
window.pet.send("pet:ready");
requestAnimationFrame(tick);