Files
claude-pet/renderer/renderer.js
T
JianMiauandClaude Fable 5 6da4775867 移除 hover 浮出的選單鈕,版本升到 2.0.0
摘要:
拿掉游標靠近時人物縮小讓位、上方浮出圓形選單鈕的整套 hover 版面,選單只留右鍵與系統匣。

根本原因:
上一版照 Codex 的 petControlsAppearance 做了 proximity 版面,但那顆控制鈕的作用就是開選單,
而本程式的右鍵與系統匣已經有同樣的選單,多一顆按鈕沒有增加任何功能,
反而讓游標經過時人物會縮小位移。

影響:
滑鼠靠近時人物無謂地縮小、下移,並蓋出一顆重複功能的按鈕。

修法:
- renderer:移除 HOVER_SCALE / HOVER_OFFSET_Y / PROXIMITY_ENTER / PROXIMITY_EXIT 常數,
  以及 updateProximity / setNear / overControls / distanceToRect 與 near 狀態、
  applyScale 裡的控制鈕定位、menu-btn 的事件繫結;mousemove 的點穿判定回到人物與氣泡兩者。
- index.html 移除 #controls;style.css 移除 body.near 的縮放位移與 #controls 樣式。
- package.json 版本 0.1.0 → 2.0.0。
- README 的日常使用、行為對照表、與 Codex 的對照三處同步更新,
  對照表標明此項未採用及原因。

保留不變:
右鍵與系統匣選單、idle 放慢 6 倍、狀態優先序與到期、look 列縮放補正、
first-awake 問候、拖曳 4 px 方向閾值、reduced motion。

驗證:
- 三個 js 檔語法檢查通過,全專案 grep 確認 controls / menu-btn / HOVER_ / PROXIMITY_ /
  body.near / setNear 等皆無殘留。
- 實機啟動:/health 正常,events.log 顯示 look rows rescaled 8 cells 仍正常觸發;
  把游標移到人物上截圖,人物維持原尺寸原位置、無按鈕浮出(測後游標已復位)。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar:版本 2.0.0、html 無 menu-btn、
  css 無 body.near、js 無 proximity 邏輯,且右鍵選單、idle 放慢、look 補正、
  狀態優先序都還在;exe 版本資訊為 2.0.0。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:18:30 +08:00

636 lines
22 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 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 = 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 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) {
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 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;
// CodexlookFrame 一旦有值就覆蓋所有狀態,靜態擺姿
if (followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
return baseState();
}
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");
// Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡
if (!p.source || p.source === "startup") 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":
// 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(`🔐 需要你授權${suffix}`, 0, true);
} else if (t === "idle_prompt") {
// Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變
say(`💤 在等你回覆${suffix}`, 4000);
} 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;
// Codexturn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
setState(s, "review");
say(`✅ 完成!${suffix}`, 8000);
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;
}
}
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;
}
setIgnore(!(overSprite(e.clientX, e.clientY) || overBubble(e.clientX, e.clientY)));
});
document.addEventListener("mouseout", (e) => {
if (e.relatedTarget || pressed) return;
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);