Files
claude-pet/renderer/renderer.js
T
JianMiauandClaude Fable 5 eeba11e643 修正解鎖螢幕後不能拖曳、不能右鍵:互動不再依賴 mousedown
根本原因:
2.0.13 的事件紀錄證實:鎖定螢幕再解鎖後,renderer 只收得到 mouseup 與
mousemove,左右鍵的 mousedown 全部被吃掉,直到重啟才恢復(符合 Windows 對
永不啟用視窗的 WM_MOUSEACTIVATE 回傳 MA_NOACTIVATEANDEAT 的行為,只丟掉按下
那一則訊息)。而左鍵靠 mousedown 設 pressed、右鍵靠 mousedown 開選單,兩者
因此同時失效。

影響:
每次鎖定/解鎖後寵物就不能拖曳、不能右鍵,只能重啟。

修法:
右鍵選單改在 mouseup 開(也是 Windows 慣例);mousemove 的 buttons 位元帶著
「左鍵按著」而沒有 pressed 時補一個 synthetic pressed,拖曳照常;沒有
mousedown 的左鍵 mouseup 當成單擊。補上對應單元測試與 README 說明。
版號 2.0.14。

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

793 lines
29 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 hovered = false; // 游標是否在人物方框內
let lastPoint = null; // 最後已知的游標位置(視窗相對),主程序輪詢或 mousemove 都會更新
let pressed = null;
let lastReported = "";
function log(msg) {
try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
}
// renderer 的例外以前只會出現在 DevTools,包成 exe 後根本看不到;寫進事件紀錄才查得到
window.addEventListener("error", (e) => log(`error: ${e.message} (${e.filename}:${e.lineno})`));
window.addEventListener("unhandledrejection", (e) => log(`error: unhandled rejection: ${e.reason?.message || e.reason}`));
// ---------- 載入 ----------
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`;
reportHitRects();
}
// 量一格的人物輪廓(用來偵測圖檔裡整列被畫小的缺陷)
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;
reportHitRects();
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
}
function hideBubble() {
clearTimeout(bubbleTimer);
bubble.classList.add("hidden");
bubbleSticky = false;
reportHitRects();
}
// ---------- 事件 ----------
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");
// 這時 transcript 裡最新的助理訊息還是上一輪的結尾,
// 先標記成已顯示,免得接下來的工具事件把它當成新話講出來
if (p.narrationId) lastNarrationId = p.narrationId;
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, false, title);
break;
case "PermissionRequest":
setState(s, "waiting");
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true, title);
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, false, title);
}
break;
}
case "Stop":
if (p.stopHookActive) break;
// Codexturn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
setState(s, "review");
// 收尾時把最後一句回覆講出來,而不是只說「完成」
if (p.narration) {
lastNarrationId = p.narrationId || lastNarrationId;
say(`✅ ${firstSentence(p.narration)}`, 8000, false, title);
} else {
say("✅ 完成!", 8000, false, title);
}
break;
case "StopFailure":
// Codexturn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
setState(s, "failed");
say(`❌ ${p.error || "出錯了"}`, 8000, false, title);
break;
case "SubagentStart":
case "SubagentStop":
setState(s, "running");
break;
case "PreCompact":
setState(s, "running");
say("🗜️ 整理記憶中…", 3000, false, title);
break;
case "SessionEnd":
sessions.delete(p.sessionId || "default");
if (sessions.size === 0) {
hideBubble();
say("👋 掰掰", 2000, false, title);
}
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,
// renderer 這邊的滑鼠狀態也露到 /state,查「點不到寵物」時才看得出是不是卡在這裡
rendererMouse: { pressed: !!pressed, drag: !!drag, hovered, overlay: overlay?.anim || 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);
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
// 主程序自己輪詢游標並決定點穿(見 main.js 的 updateIgnore),這裡只要回報「哪裡算是寵物」:
// 人物方框與(顯示中的)氣泡的視窗相對矩形。版面一變(縮放、氣泡出現/消失/改字)就重報一次。
// 用方框不用逐像素 alpha:sprite 每一格的輪廓不同,人物的手腳與邊緣會隨動畫在透明/不透明之間
// 切換,逐像素會讓點穿跟著動畫抖,按下去那一瞬間剛好是透明格就穿到底下去。Codex 也是用矩形。
function reportHitRects() {
const rect = (r) => ({ x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });
const b = bubble.classList.contains("hidden") ? null : bubble.getBoundingClientRect();
window.pet.send("pet:hit-rects", { box: rect(canvas.getBoundingClientRect()), bubble: b ? rect(b) : null });
}
window.addEventListener("resize", reportHitRects);
// hover 判定同樣用人物方框(Codex 的 pet-area-hover 也是 rect
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;
log(`hover ${on ? "enter" : "leave"} pressed=${!!pressed} drag=${!!drag} overlay=${overlay?.anim || "-"}`);
report();
if (on) {
// 只在「進入」時觸發一次;一次性動作或拖曳進行中不打斷
if (!overlay && !drag) {
play("jumping", STATE_REPEATS);
overlay.fromHover = true;
}
return;
}
// 游標離開就立刻收掉,不等三次跳完。Codex 也是這樣:它的顯示狀態是
// `hovered ? "jumping" : state`,一離開就翻回原狀態、動畫重跑,不會播完。
if (overlay?.fromHover) overlay = null;
}
// 命中判定只剩 hover(跳三下):mousemove 與主程序輪詢都走同一條路
function refreshHitTest() {
if (!lastPoint || pressed || drag) return;
setHovered(overPetArea(lastPoint.x, lastPoint.y));
}
window.addEventListener("mousemove", (e) => {
// 鎖定螢幕再解鎖後,Windows/Chromium 會把送給這個視窗的每一個 mousedown 吃掉,
// mouseup 與 mousemove 卻照送(實測見事件紀錄)。mousemove 的 buttons 位元仍然帶著
// 「左鍵按著」,用它補一個 pressed,拖曳才不會因為少了 down 就失效。
if (!pressed && !drag && (e.buttons & 1)) {
pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false, synthetic: true };
log(`mousedown missing; synthesized from buttons at ${e.clientX},${e.clientY}`);
}
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;
}
lastPoint = { x: e.clientX, y: e.clientY };
refreshHitTest();
});
document.addEventListener("mouseout", (e) => {
if (e.relatedTarget || pressed) return;
lastPoint = null;
setHovered(false);
});
// 滑鼠按下/放開都寫進事件紀錄:這是「有沒有收到滑鼠事件」唯一的直接證據
window.addEventListener("mousedown", (e) => {
log(`mousedown b=${e.button} at ${e.clientX},${e.clientY} pressed=${!!pressed} drag=${!!drag}`);
if (e.button === 0) {
pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false };
}
report();
});
// 點擊與右鍵都以 mouseup 為準:解鎖後 mousedown 會被吃掉(見上),mouseup 不會;
// 右鍵選單在放開時開也是 Windows 的慣例。
window.addEventListener("mouseup", (e) => {
log(`mouseup b=${e.button} at ${e.clientX},${e.clientY} pressed=${!!pressed} moved=${!!pressed?.moved}`);
if (e.button === 2) {
window.pet.send("pet:context-menu");
return;
}
if (e.button !== 0) return;
if (!pressed) {
log("mouseup without mousedown; treating as click");
onClick();
report();
return;
}
const wasDrag = pressed.moved;
pressed = null;
if (wasDrag) {
drag = null;
canvas.classList.remove("grabbing");
window.pet.send("pet:drag-end");
} else {
onClick();
}
report();
});
window.addEventListener("contextmenu", (e) => e.preventDefault());
// 主程序的游標輪詢:不管 mousemove 有沒有被轉發進來都會到
window.pet.on("pet:cursor-pos", ({ x, y }) => {
lastPoint = { x, y };
refreshHitTest();
});
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";
});
// 主程序結束了拖曳(mouseup、逾時或其他原因):不管這邊有沒有收到 mouseup,一律把狀態清掉
window.pet.on("pet:drag-ended", () => {
pressed = null;
drag = null;
canvas.classList.remove("grabbing");
report();
});
// 主程序切了點穿狀態:只負責換游標樣式
window.pet.on("pet:ignore-state", (ignore) => canvas.classList.toggle("grab", !ignore));
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);