2026-08-21 10:32:39 +08:00
|
|
|
|
"use strict";
|
|
|
|
|
|
// Claude Pet renderer:Codex V2 pet contract 的動畫引擎 + 狀態機
|
|
|
|
|
|
// 規格來源:8 欄 x 11 列、192x208 cell,列 0-8 標準動作、列 9-10 為 16 個順時針看向方向(000 = 正上方)
|
|
|
|
|
|
|
2026-08-21 10:53:08 +08:00
|
|
|
|
const CELL_W = 192; // spritesheet 單格來源尺寸
|
2026-08-21 10:32:39 +08:00
|
|
|
|
const CELL_H = 208;
|
2026-08-21 10:53:08 +08:00
|
|
|
|
// 100% 時的顯示尺寸,對齊 Codex 桌面寵物(與 main.js 的 PET_W / PET_H 一致)
|
|
|
|
|
|
const PET_W = 126;
|
|
|
|
|
|
const PET_H = Math.ceil((PET_W * CELL_H) / CELL_W); // 137
|
2026-08-21 10:32:39 +08:00
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-21 14:41:42 +08:00
|
|
|
|
// 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)
|
2026-08-21 15:11:56 +08:00
|
|
|
|
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)
|
2026-08-24 10:16:43 +08:00
|
|
|
|
const ACTIVITY_MS = 6000; // 工作中氣泡(工具動作/助理說明)的存活時間
|
|
|
|
|
|
|
|
|
|
|
|
// 工具對照表;沒列到的就直接顯示工具名稱
|
|
|
|
|
|
const TOOL_LABELS = {
|
|
|
|
|
|
Bash: "⚙️ 執行",
|
|
|
|
|
|
Read: "📖 讀",
|
|
|
|
|
|
Write: "✏️ 寫",
|
|
|
|
|
|
Edit: "✏️ 改",
|
|
|
|
|
|
NotebookEdit: "✏️ 改",
|
|
|
|
|
|
Glob: "🔍 找檔案",
|
|
|
|
|
|
Grep: "🔍 搜尋",
|
|
|
|
|
|
WebFetch: "🌐 擷取",
|
|
|
|
|
|
WebSearch: "🔎 搜尋",
|
|
|
|
|
|
Task: "🤖 子代理",
|
|
|
|
|
|
TodoWrite: "📝 待辦",
|
|
|
|
|
|
Artifact: "📄 產出",
|
|
|
|
|
|
};
|
2026-08-21 10:32:39 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-21 15:11:56 +08:00
|
|
|
|
let followCursor = false; // Codex 的追視來源是 quick chat 文字游標/computer-use 游標,不是滑鼠;預設關
|
2026-08-21 10:32:39 +08:00
|
|
|
|
|
2026-08-21 15:11:56 +08:00
|
|
|
|
const sessions = new Map(); // sessionId → { state, updatedAt, cwd }
|
2026-08-21 14:41:42 +08:00
|
|
|
|
let overlay = null; // 一次性動作:{ anim, loops, onDone }
|
2026-08-21 10:32:39 +08:00
|
|
|
|
let drag = null; // 拖曳中:{ dir: "left" | "right" }
|
|
|
|
|
|
const look = { idx: null, lastMoveAt: 0 };
|
2026-08-21 14:41:42 +08:00
|
|
|
|
// 播放中的序列:frames = [{row, col, ms}],loopStart 為 null 表示播完就結束
|
|
|
|
|
|
let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() };
|
2026-08-21 10:32:39 +08:00
|
|
|
|
let prevBase = "idle";
|
2026-08-24 10:16:43 +08:00
|
|
|
|
let lastNarrationId = null; // 同一段說明只播報一次
|
2026-08-21 14:41:42 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 15:11:56 +08:00
|
|
|
|
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
|
|
|
|
|
2026-08-21 14:41:42 +08:00
|
|
|
|
function startAnim(anim, now) {
|
2026-08-21 16:48:43 +08:00
|
|
|
|
// 追視是單一靜態擺姿,沒有幀序列(TIMINGS 裡沒有 look)。
|
|
|
|
|
|
// 少了這道判斷會走進 cycleOf("look") 而丟 TypeError,requestAnimationFrame 迴圈整個死掉。
|
|
|
|
|
|
if (anim === "look") {
|
|
|
|
|
|
player = { anim, frames: [], loopStart: null, step: 0, stepStart: now };
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-21 14:41:42 +08:00
|
|
|
|
let seq;
|
2026-08-21 15:11:56 +08:00
|
|
|
|
if (reducedMotionQuery.matches) {
|
|
|
|
|
|
// 與 Codex 相同:系統關閉動畫時只顯示該狀態的第一格
|
|
|
|
|
|
seq = { frames: [cycleOf(anim)[0]], loopStart: null };
|
|
|
|
|
|
} else if (overlay && overlay.anim === anim) {
|
2026-08-21 14:41:42 +08:00
|
|
|
|
// 一次性動作:播指定次數後結束,交還給基礎狀態
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
2026-08-21 10:32:39 +08:00
|
|
|
|
|
|
|
|
|
|
let bubbleTimer = null;
|
|
|
|
|
|
let bubbleSticky = false;
|
|
|
|
|
|
let ignoringMouse = true;
|
2026-08-21 16:24:24 +08:00
|
|
|
|
let hovered = false; // 游標是否壓在人物的不透明像素上
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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;
|
2026-08-21 14:41:42 +08:00
|
|
|
|
measureLookRows();
|
2026-08-21 10:32:39 +08:00
|
|
|
|
makeTrayIcon();
|
|
|
|
|
|
log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`);
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// Codex 的 first-awake:某隻寵物第一次被叫出來時揮手(播 3 次)並打招呼 8 秒,之後不再
|
|
|
|
|
|
if (!pet.greeted) {
|
|
|
|
|
|
play("waving", STATE_REPEATS);
|
|
|
|
|
|
say(`👋 嗨,我是${pet.displayName}`, GREETING_MS);
|
|
|
|
|
|
window.pet.send("pet:greeted", pet.id);
|
|
|
|
|
|
}
|
2026-08-21 10:32:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
img.onerror = () => log(`spritesheet load failed for ${pet.id}`);
|
|
|
|
|
|
img.src = pet.dataUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyScale(s) {
|
|
|
|
|
|
scale = s;
|
2026-08-21 10:53:08 +08:00
|
|
|
|
canvas.width = Math.round(PET_W * s);
|
|
|
|
|
|
canvas.height = Math.round(PET_H * s);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
canvas.style.width = `${canvas.width}px`;
|
|
|
|
|
|
canvas.style.height = `${canvas.height}px`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 14:41:42 +08:00
|
|
|
|
// 量一格的人物輪廓(用來偵測圖檔裡整列被畫小的缺陷)
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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) {
|
2026-08-21 15:11:56 +08:00
|
|
|
|
s = { state: "idle", updatedAt: Date.now(), cwd: cwd || null };
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// 對應 Codex:每個 thread 產生一則帶狀態的通知,依優先序排序後取最上面那則的狀態給寵物
|
2026-08-21 10:32:39 +08:00
|
|
|
|
function baseState() {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
let best = "idle";
|
|
|
|
|
|
for (const [id, s] of sessions) {
|
2026-08-21 15:11:56 +08:00
|
|
|
|
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; }
|
2026-08-21 10:32:39 +08:00
|
|
|
|
if (PRIORITY[s.state] > PRIORITY[best]) best = s.state;
|
|
|
|
|
|
}
|
|
|
|
|
|
return best;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function play(anim, loops = 1, onDone = null) {
|
2026-08-21 14:41:42 +08:00
|
|
|
|
overlay = { anim, loops, onDone };
|
|
|
|
|
|
startAnim(anim, performance.now());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 10:32:39 +08:00
|
|
|
|
function resolveAnim() {
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// 拖曳:Codex 在 pointerdown 時先清掉 transient state,要等到單次位移 ≥ 4 px 才決定方向
|
|
|
|
|
|
if (drag) return drag.dir ? `running-${drag.dir}` : baseState();
|
|
|
|
|
|
// 一次性動作(點擊揮手、初次問候)。Codex 的 lookFrame 會蓋過這些,但它的追視來源不是滑鼠;
|
|
|
|
|
|
// 滑鼠永遠在畫面上,若照搬會讓點擊揮手永遠看不到,所以這裡讓手勢優先。
|
2026-08-21 10:32:39 +08:00
|
|
|
|
if (overlay) return overlay.anim;
|
2026-08-21 16:48:43 +08:00
|
|
|
|
const base = baseState();
|
|
|
|
|
|
// Codex 的 lookFrame 會覆蓋所有狀態,但它的追視來源是 quick chat 的文字游標,
|
|
|
|
|
|
// 只有你真的在那裡打字時才存在。滑鼠則是一直都在,照搬會讓工作中的動畫永遠被靜態擺姿蓋掉,
|
|
|
|
|
|
// 寵物就失去反映 Claude Code 狀態的作用,所以只在 idle 時追視。
|
|
|
|
|
|
if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
|
|
|
|
|
|
return base;
|
2026-08-21 10:32:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 10:16:43 +08:00
|
|
|
|
// 助理的說明是 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 有什麼就顯示什麼:優先顯示新的助理說明,否則顯示正在用的工具
|
|
|
|
|
|
function sayActivity(p) {
|
|
|
|
|
|
if (baseState() === "waiting") return; // 別蓋掉在等授權的固定氣泡
|
|
|
|
|
|
if (p.narration && p.narrationId && p.narrationId !== lastNarrationId) {
|
|
|
|
|
|
lastNarrationId = p.narrationId;
|
|
|
|
|
|
say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS);
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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");
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡
|
|
|
|
|
|
if (!p.source || p.source === "startup") say(`👋 嗨!${suffix}`, 3000);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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");
|
2026-08-24 10:16:43 +08:00
|
|
|
|
sayActivity(p);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "PostToolUse":
|
|
|
|
|
|
setState(s, "running");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "PostToolUseFailure":
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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") {
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變
|
|
|
|
|
|
say(`💤 在等你回覆${suffix}`, 4000);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
} 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;
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
|
|
|
|
|
|
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
|
2026-08-21 10:32:39 +08:00
|
|
|
|
setState(s, "review");
|
2026-08-21 15:11:56 +08:00
|
|
|
|
say(`✅ 完成!${suffix}`, 8000);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case "StopFailure":
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// Codex:turn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
|
|
|
|
|
|
setState(s, "failed");
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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();
|
2026-08-21 15:11:56 +08:00
|
|
|
|
} else if (anim === "waving" || anim === "jumping") {
|
|
|
|
|
|
play(anim, STATE_REPEATS);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
} 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) {
|
2026-08-21 14:41:42 +08:00
|
|
|
|
startAnim(anim, now);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
report();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (anim !== "look") {
|
2026-08-21 14:41:42 +08:00
|
|
|
|
if (now - player.stepStart > 5000) player.stepStart = now; // 從休眠醒來,避免狂追幀
|
2026-08-21 10:32:39 +08:00
|
|
|
|
let guard = 0;
|
2026-08-21 14:41:42 +08:00
|
|
|
|
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;
|
2026-08-21 10:32:39 +08:00
|
|
|
|
}
|
2026-08-21 14:41:42 +08:00
|
|
|
|
player.step = player.loopStart;
|
2026-08-21 10:32:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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";
|
2026-08-21 14:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 看向游標 ----------
|
|
|
|
|
|
|
|
|
|
|
|
window.pet.on("pet:cursor", ({ dx, dy }) => {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const dist = Math.hypot(dx, dy);
|
2026-08-21 14:41:42 +08:00
|
|
|
|
if (dist < LOOK_DEADZONE) {
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
|
|
|
|
|
|
|
2026-08-21 14:49:34 +08:00
|
|
|
|
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) {
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 16:24:24 +08:00
|
|
|
|
// 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 時會把狀態換成 jumping(codex-pet-assets 的
|
|
|
|
|
|
// `respondToHover && hovered ? "jumping" : state`),走 f() 的規則播 3 次後沉澱回慢速 idle。
|
|
|
|
|
|
// 那個 prop 在 Codex 桌面寵物那層沒有開啟,這裡選擇打開它。
|
|
|
|
|
|
function setHovered(on) {
|
|
|
|
|
|
if (on === hovered) return;
|
|
|
|
|
|
hovered = on;
|
2026-08-21 16:58:57 +08:00
|
|
|
|
if (on) {
|
|
|
|
|
|
// 只在「進入」時觸發一次;一次性動作或拖曳進行中不打斷
|
|
|
|
|
|
if (!overlay && !drag) {
|
|
|
|
|
|
play("jumping", STATE_REPEATS);
|
|
|
|
|
|
overlay.fromHover = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 游標離開就立刻收掉,不等三次跳完。Codex 也是這樣:它的顯示狀態是
|
|
|
|
|
|
// `hovered ? "jumping" : state`,一離開就翻回原狀態、動畫重跑,不會播完。
|
|
|
|
|
|
if (overlay?.fromHover) overlay = null;
|
2026-08-21 16:24:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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;
|
2026-08-21 15:11:56 +08:00
|
|
|
|
drag = { dir: null };
|
2026-08-21 10:32:39 +08:00
|
|
|
|
canvas.classList.add("grabbing");
|
|
|
|
|
|
window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y });
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-21 16:24:24 +08:00
|
|
|
|
setHovered(overPetArea(e.clientX, e.clientY));
|
|
|
|
|
|
// 點穿判定仍看實際像素,透明處要能點到底下的視窗
|
2026-08-21 15:18:30 +08:00
|
|
|
|
setIgnore(!(overSprite(e.clientX, e.clientY) || overBubble(e.clientX, e.clientY)));
|
2026-08-21 10:32:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mouseout", (e) => {
|
2026-08-21 14:49:34 +08:00
|
|
|
|
if (e.relatedTarget || pressed) return;
|
2026-08-21 16:24:24 +08:00
|
|
|
|
setHovered(false);
|
2026-08-21 14:49:34 +08:00
|
|
|
|
setIgnore(true);
|
2026-08-21 10:32:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 }) => {
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// Codex:單一取樣的水平位移 >= 4 才切 running-right,<= -4 才切 running-left
|
|
|
|
|
|
if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right";
|
2026-08-21 10:32:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 }) => {
|
2026-08-21 15:11:56 +08:00
|
|
|
|
followCursor = f === true;
|
2026-08-21 10:32:39 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-21 15:11:56 +08:00
|
|
|
|
// 系統的「減少動態效果」切換時,讓目前的動畫重新開始(Codex 的 effect 也把 reducedMotion 列為相依)
|
|
|
|
|
|
reducedMotionQuery.addEventListener("change", () => { if (player.anim) startAnim(player.anim, performance.now()); });
|
|
|
|
|
|
|
2026-08-21 10:32:39 +08:00
|
|
|
|
window.pet.send("pet:ready");
|
|
|
|
|
|
requestAnimationFrame(tick);
|