動畫時序照 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>
This commit is contained in:
2026-08-21 14:41:42 +08:00
co-authored by Claude Fable 5
parent e591475d4c
commit c96dcccb80
3 changed files with 170 additions and 35 deletions
+164 -33
View File
@@ -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;