"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], }; const LOOK_DEADZONE = 38; // 游標離寵物中心太近 → 回到 idle(約寵物高度的 0.27) const LOOK_IDLE_AFTER = 6000; // 游標靜止多久後不再盯著看 const REVIEW_DURATION = 6000; // Stop 後 review 多久回 idle const SESSION_STALE = 30 * 60 * 1000; const PRIORITY = { waiting: 3, running: 2, review: 1, idle: 0 }; 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 = true; const sessions = new Map(); // sessionId → { state, updatedAt, cwd, reviewUntil } let overlay = null; // 一次性動作:{ anim, loopsLeft, onDone } let drag = null; // 拖曳中:{ dir: "left" | "right" } const look = { idx: null, lastMoveAt: 0 }; let player = { anim: "idle", frame: 0, frameStart: performance.now() }; let prevBase = "idle"; 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; makeTrayIcon(); log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`); }; 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 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, reviewUntil: 0 }; 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(); } function baseState() { const now = Date.now(); let best = "idle"; for (const [id, s] of sessions) { if (now - s.updatedAt > SESSION_STALE) { sessions.delete(id); continue; } if (s.state === "review" && now > s.reviewUntil) s.state = "idle"; if (PRIORITY[s.state] > PRIORITY[best]) best = s.state; } return best; } function play(anim, loops = 1, onDone = null) { overlay = { anim, loopsLeft: loops, onDone }; player = { anim, frame: 0, frameStart: performance.now() }; } function resolveAnim() { if (drag) return drag.dir === "left" ? "running-left" : "running-right"; if (overlay) return overlay.anim; const base = baseState(); if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look"; return base; } 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"); if (!p.source || p.source === "startup") { play("waving", 1); 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": setState(s, "running"); play("failed", 1); 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") { setState(s, "waiting"); say(`💤 在等你回覆${suffix}`, 0, true); } 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; setState(s, "review"); s.reviewUntil = Date.now() + REVIEW_DURATION; play("jumping", 1, () => say(`✅ 完成!${suffix}`, 8000)); break; case "StopFailure": setState(s, "idle"); play("failed", 2); 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" || anim === "failed") { play(anim, anim === "failed" ? 2 : 1); } else if (anim in PRIORITY) { const s = session("test"); setState(s, anim); if (anim === "review") s.reviewUntil = Date.now() + 60000; 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) { player = { anim, frame: 0, frameStart: now }; report(); } if (anim !== "look") { const t = TIMINGS[anim]; if (now - player.frameStart > 5000) player.frameStart = now; // 從休眠醒來,避免狂追幀 let guard = 0; while (now - player.frameStart >= t[player.frame] && guard++ < 64) { player.frameStart += t[player.frame]; player.frame += 1; if (player.frame >= t.length) { player.frame = 0; if (overlay && overlay.anim === anim) { overlay.loopsLeft -= 1; if (overlay.loopsLeft <= 0) { const done = overlay.onDone; overlay = null; if (done) done(); break; } } } } } 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; let row; let col; if (anim === "look") { row = 9 + (look.idx >> 3); col = look.idx & 7; } else { row = ROW[anim]; col = player.frame; } ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(sheet, col * CELL_W, 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 * Math.max(scale, 0.75)) { 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 overOpaque(x, y) { if (!bubble.classList.contains("hidden")) { const r = bubble.getBoundingClientRect(); if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true; } 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: "right" }; canvas.classList.add("grabbing"); window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y }); } return; } setIgnore(!overOpaque(e.clientX, e.clientY)); }); document.addEventListener("mouseout", (e) => { if (!e.relatedTarget && !pressed) 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 }) => { if (drag && dx) 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 !== false; 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); window.pet.send("pet:ready"); requestAnimationFrame(tick);