"use strict"; // Claude Pet renderer:Codex 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") 而丟 TypeError,requestAnimationFrame 迴圈整個死掉。 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; // Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。 // 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。 setState(s, "review"); say("✅ 完成!", 8000, false, title); break; case "StopFailure": // Codex:turn 失敗 → 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 時會把狀態換成 jumping(codex-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);