Files
claude-pet/renderer/renderer.js
T
JianMiauandClaude Fable 5 93f635dd01 氣泡改成兩行,第一行顯示對話名稱或專案名
摘要:
氣泡上方加一行標題(/rename 設的對話名稱,沒設就用專案資料夾名),訊息移到第二行。

根本原因:
原本把專案名以「(專案名)」的形式接在訊息尾巴,訊息本身就短,
加了括號後更容易被氣泡的單行省略號截掉,而且同時開多個 session 時
要看到最後才知道是哪一個在叫你。

影響:
多 session 並行時不容易分辨氣泡是哪個專案發出的;訊息也被括號擠掉。

修法:
- index.html 氣泡內加 #bubble-title;style.css 把單行限制從 #bubble 移到
  #bubble-title 與 #bubble-text 各自身上,兩行各自省略,
  並以 :empty 讓沒有標題時整行收起來,不留空白。
- say() 增加第四個參數 title,事件處理處的八個呼叫點改傳標題、移除 suffix。
- main.js 的 latestNarration 改名為 readTranscript,同一次反向掃描同時取出
  助理說明與 custom-title(/rename 會把它寫進 transcript),兩者都拿到就提早結束;
  收到事件時把 sessionTitle 一併併進 payload。
- 標題優先序:對話名稱 > 專案資料夾名 > 不顯示。

驗證:
- 單元測試以真實 transcript 執行 readTranscript:取到對話名稱 "claude-pet"、
  同時取到助理說明、快取一致、路徑不存在或為 null 時回 null,6 項全過。
- 實機截圖確認兩行版面正確。截圖當下剛好收到另一個 session 的真實事件,
  標題顯示 "telegram-codex-bot"、第二行是該 session 的助理說明,
  等於用實際流量驗證了多 session 下的標題辨識。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.5 與十項改動都在出貨檔案裡,
  包含舊的 suffix 已完全移除。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:09:01 +08:00

743 lines
26 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; // 游標靜止多久後不再盯著看(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 閒置多久後從清單移除(純清理,不影響顯示)
const GREETING_MS = 8000; // first-awake 問候通知的存活時間(Pi = 8 s)
const ACTIVITY_MS = 6000; // 工作中氣泡(工具動作/助理說明)的存活時間
// 工具對照表;沒列到的就直接顯示工具名稱
const TOOL_LABELS = {
Bash: "⚙️ 執行",
Read: "📖 讀",
Write: "✏️ 寫",
Edit: "✏️ 改",
NotebookEdit: "✏️ 改",
Glob: "🔍 找檔案",
Grep: "🔍 搜尋",
WebFetch: "🌐 擷取",
WebSearch: "🔎 搜尋",
Task: "🤖 子代理",
TodoWrite: "📝 待辦",
Artifact: "📄 產出",
};
const canvas = document.getElementById("sprite");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const bubble = document.getElementById("bubble");
const bubbleTitle = document.getElementById("bubble-title");
const bubbleText = document.getElementById("bubble-text");
let sheet = null;
let petInfo = null;
let scale = 1;
let followCursor = false; // Codex 的追視來源是 quick chat 文字游標/computer-use 游標,不是滑鼠;預設關
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 };
// 播放中的序列:frames = [{row, col, ms}]loopStart 為 null 表示播完就結束
let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() };
let prevBase = "idle";
let lastNarrationId = null; // 同一段說明只播報一次
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;
}
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
function startAnim(anim, now) {
// 追視是單一靜態擺姿,沒有幀序列(TIMINGS 裡沒有 look)。
// 少了這道判斷會走進 cycleOf("look") 而丟 TypeErrorrequestAnimationFrame 迴圈整個死掉。
if (anim === "look") {
player = { anim, frames: [], loopStart: null, step: 0, stepStart: now };
return;
}
let seq;
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));
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 hovered = false; // 游標是否壓在人物的不透明像素上
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}`);
// 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;
}
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 };
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();
}
// 對應 Codex:每個 thread 產生一則帶狀態的通知,依優先序排序後取最上面那則的狀態給寵物
function baseState() {
const now = Date.now();
let best = "idle";
for (const [id, s] of sessions) {
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;
}
function play(anim, loops = 1, onDone = null) {
overlay = { anim, loops, onDone };
startAnim(anim, performance.now());
}
function resolveAnim() {
// 拖曳: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();
// Codex 的 lookFrame 會覆蓋所有狀態,但它的追視來源是 quick chat 的文字游標,
// 只有你真的在那裡打字時才存在。滑鼠則是一直都在,照搬會讓工作中的動畫永遠被靜態擺姿蓋掉,
// 寵物就失去反映 Claude Code 狀態的作用,所以只在 idle 時追視。
if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
return base;
}
// 助理的說明是 markdown,直接塞進氣泡會看到 ** 和反引號
function stripMarkdown(text) {
return String(text)
.replace(/```[\s\S]*?```/g, " ") // 整段程式碼
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // 連結與圖片只留文字
.replace(/`([^`]*)`/g, "$1")
.replace(/\*\*([^*]*)\*\*/g, "$1")
.replace(/(^|\s)[*_]([^*_\s][^*_]*)[*_](?=\s|$)/g, "$1$2")
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // 標題
.replace(/^\s{0,3}[-*+>|]\s+/gm, "") // 清單與引言
.replace(/[ \t]+/g, " ");
}
// 只取第一句,氣泡是單行,塞整段沒有意義
function firstSentence(text) {
const line = stripMarkdown(text).split(/\r?\n/).find((l) => l.trim()) || "";
const m = line.trim().match(/^[^。!?!?]{1,60}[。!?!?]?/);
return (m ? m[0] : line.trim()).slice(0, 60);
}
// 指令取前半段、路徑只取檔名,其餘截斷
function shortTarget(target, toolName) {
const t = String(target).trim();
if (toolName === "Bash") return t.slice(0, 44);
if (/[\\/]/.test(t) && !/\s/.test(t)) return t.split(/[\\/]/).pop().slice(0, 40);
return t.slice(0, 44);
}
// 氣泡第一行:對話名稱(/rename 設的)優先,其次專案資料夾名
function bubbleTitleOf(p, cwd) {
return p.sessionTitle || projectName(cwd) || "";
}
// 有什麼就顯示什麼:優先顯示新的助理說明,否則顯示正在用的工具
function sayActivity(p, title) {
if (baseState() === "waiting") return; // 別蓋掉在等授權的固定氣泡
if (p.narration && p.narrationId && p.narrationId !== lastNarrationId) {
lastNarrationId = p.narrationId;
say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS, false, title);
return;
}
if (!p.toolName) return;
const label = TOOL_LABELS[p.toolName] || `🔧 ${p.toolName}`;
const detail = p.toolName === "Task" && p.agentType ? p.agentType : p.toolTarget;
say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS, false, title);
}
function projectName(cwd) {
if (!cwd) return "";
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
}
// ---------- 氣泡 ----------
// title 是第一行(對話名稱或專案名),text 是第二行
function say(text, ms, sticky = false, title = "") {
clearTimeout(bubbleTimer);
bubbleTitle.textContent = title || "";
bubbleText.textContent = text;
bubble.title = title ? `${title}\n${text}` : 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 title = bubbleTitleOf(p, s.cwd);
switch (p.event) {
case "SessionStart":
setState(s, "idle");
// Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡
if (!p.source || p.source === "startup") say("👋 嗨!", 3000, false, title);
break;
case "UserPromptSubmit":
setState(s, "running");
hideBubble();
break;
case "PreToolUse":
if (p.toolName === "AskUserQuestion") {
setState(s, "waiting");
say("❓ 有問題想問你", 0, true, title);
} else if (p.toolName === "ExitPlanMode") {
setState(s, "waiting");
say("📋 計畫等你確認", 0, true, title);
} else {
setState(s, "running");
sayActivity(p, title);
}
break;
case "PostToolUse":
setState(s, "running");
break;
case "PostToolUseFailure":
// Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡
setState(s, "running");
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("🔐 需要你授權", 0, true, title);
} else if (t === "idle_prompt") {
// Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變
say("💤 在等你回覆", 4000, false, title);
} else if (t === "elicitation_dialog") {
setState(s, "waiting");
say("❓ 有問題想問你", 0, true, title);
} else if (p.message) {
say(`💬 ${p.message}`, 4000);
}
break;
}
case "Stop":
if (p.stopHookActive) break;
// Codexturn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
setState(s, "review");
say("✅ 完成!", 8000, false, title);
break;
case "StopFailure":
// Codexturn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
setState(s, "failed");
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();
}
function handleTest(p) {
const anim = p.anim;
if (anim === "clear") {
sessions.delete("test");
overlay = null;
hideBubble();
} else if (anim === "waving" || anim === "jumping") {
play(anim, STATE_REPEATS);
} else if (anim in PRIORITY) {
const s = session("test");
setState(s, anim);
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 overBubble(x, y) {
if (bubble.classList.contains("hidden")) return false;
const r = bubble.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
// 壓在人物的不透明像素上(透明處要讓滑鼠穿過去)
function overSprite(x, y) {
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;
}
}
// hover 判定用人物方框,不用逐像素 alpha:sprite 每一格的輪廓不同,逐像素會讓游標停著不動時
// 也在 true/false 之間高速抖動。Codex 的 pet-area-hover 同樣是用矩形算的。
function overPetArea(x, y) {
const r = canvas.getBoundingClientRect();
return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
}
// Codex 的寵物元件在 hover 時會把狀態換成 jumpingcodex-pet-assets 的
// `respondToHover && hovered ? "jumping" : state`),走 f() 的規則播 3 次後沉澱回慢速 idle。
// 那個 prop 在 Codex 桌面寵物那層沒有開啟,這裡選擇打開它。
function setHovered(on) {
if (on === hovered) return;
hovered = on;
if (on) {
// 只在「進入」時觸發一次;一次性動作或拖曳進行中不打斷
if (!overlay && !drag) {
play("jumping", STATE_REPEATS);
overlay.fromHover = true;
}
return;
}
// 游標離開就立刻收掉,不等三次跳完。Codex 也是這樣:它的顯示狀態是
// `hovered ? "jumping" : state`,一離開就翻回原狀態、動畫重跑,不會播完。
if (overlay?.fromHover) overlay = null;
}
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: null };
canvas.classList.add("grabbing");
window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y });
}
return;
}
setHovered(overPetArea(e.clientX, e.clientY));
// 點穿判定仍看實際像素,透明處要能點到底下的視窗
setIgnore(!(overSprite(e.clientX, e.clientY) || overBubble(e.clientX, e.clientY)));
});
document.addEventListener("mouseout", (e) => {
if (e.relatedTarget || pressed) return;
setHovered(false);
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 }) => {
// Codex:單一取樣的水平位移 >= 4 才切 running-right<= -4 才切 running-left
if (drag && Math.abs(dx) >= 4) 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 === true;
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);
// 系統的「減少動態效果」切換時,讓目前的動畫重新開始(Codex 的 effect 也把 reducedMotion 列為相依)
reducedMotionQuery.addEventListener("change", () => { if (player.anim) startAnim(player.anim, performance.now()); });
window.pet.send("pet:ready");
requestAnimationFrame(tick);