// ==UserScript== // @name 🫧404小站 — 🐟石蒜桌面宠物 // @namespace https://scriptcat.org/users/yyy404 // @version 1.0.0 // @description 基于 sakana-widget 的石蒜桌面宠物,支持自定义角色(链接 / 本地上传)与可视化配置 // @author yyy404 // @icon https://cdn.jsdmirror.com/gh/whyyy-404/icon@main/Collection/Half/tf22.png // @match *://*/* // @noframes // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/sakana-widget@3.0.1/lib/index.umd.min.js // @resource AstrSeio https://book.astrbot.app/AstrSeio.png // @resource Kobe https://book.astrbot.app/Kobe.png // @resource Wululu https://book.astrbot.app/Wululu.png // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_getResourceURL // @grant GM_registerMenuCommand // @grant GM_notification // @connect book.astrbot.app // @license MIT // @homepageURL https://github.com/dsrkafuu/sakana-widget // @supportURL https://github.com/dsrkafuu/sakana-widget/issues // ==/UserScript== /* * 🐟「石蒜桌面宠物」 * ---------------------------------------- * 哲学问题:如果一页网页没有宠物,它还算打开过吗? * 冷知识:点关闭不会删库跑路,只是变成一条鱼。 * 致谢:物理引擎来自 sakana-widget,甩起来的锅归牛顿。 * 作者:yyy404 —— 有趣是第一生产力(大概)。 */ (function () { 'use strict'; // 小窗、广告框里就别再塞一只了,只在主页面露面 try { if (window.top !== window.self) return; } catch { return; // 跨域了,看都看不见,溜了 } const BOOT_KEY = '__sakanaDesktopPetBooted__'; if (window[BOOT_KEY]) return; // 已经起来过了,别重复刷屏 window[BOOT_KEY] = true; // 你的设置、角色、位置,都存在这儿 const STORAGE = { settings: 'sakana.settings', customCharacters: 'sakana.customCharacters', currentCharacter: 'sakana.currentCharacter', petPosition: 'sakana.petPosition', }; const CORNER_OFFSET = 24; // 🐟 占多大地方;EDGE_PAD 是贴边时留的一口气,别贴到出血 const FISH_SIZE = 36; const FISH_EDGE_PAD = 10; const DEFAULT_SETTINGS = { enabled: true, size: 200, defaultCharacter: 'chisato', showReviveButton: true, }; // 库自带两位;下面三位安装时由脚本猫 @resource 自动拉图 const CORE_CHARACTERS = [ { id: 'chisato', name: '千束 (Chisato)' }, { id: 'takina', name: '泷奈 (Takina)' }, ]; const PACK_CHARACTERS = [ { id: 'astrseio', name: 'AstrSeio', resource: 'AstrSeio', url: 'https://book.astrbot.app/AstrSeio.png' }, { id: 'kobe', name: 'Kobe', resource: 'Kobe', url: 'https://book.astrbot.app/Kobe.png' }, { id: 'wululu', name: 'Wululu', resource: 'Wululu', url: 'https://book.astrbot.app/Wululu.png' }, ]; function loadSettings() { try { const raw = GM_getValue(STORAGE.settings, null); if (raw) { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; const merged = { ...DEFAULT_SETTINGS, ...parsed }; delete merged.cdnSource; delete merged.position; return merged; } } catch { /* 读不出来就算了,当第一次用 */ } return { enabled: GM_getValue('基础设置.enabled', DEFAULT_SETTINGS.enabled), size: Number(GM_getValue('基础设置.size', DEFAULT_SETTINGS.size)) || DEFAULT_SETTINGS.size, defaultCharacter: GM_getValue('基础设置.defaultCharacter', DEFAULT_SETTINGS.defaultCharacter) || DEFAULT_SETTINGS.defaultCharacter, showReviveButton: GM_getValue('基础设置.showReviveButton', DEFAULT_SETTINGS.showReviveButton), }; } function saveSettings(next) { GM_setValue(STORAGE.settings, JSON.stringify(next)); } const cfg = loadSettings(); /** @type {typeof SakanaWidget | null} */ let SakanaWidgetClass = null; /** @type {InstanceType | null} */ let widgetInstance = null; let currentCharId = cfg.defaultCharacter; /** @type {HTMLButtonElement | null} */ let reviveBtn = null; /** @type {HTMLElement | null} */ let mountRoot = null; let widgetHidden = false; /** @type {{ left: number, top: number } | null} */ let sharedPos = null; GM_addStyle(` #sakana-desktop-root { position: fixed; z-index: 2147483646; pointer-events: none; } #sakana-desktop-root .sakana-widget-wrapper { width: 100%; height: 100%; } #sakana-desktop-root .sakana-widget-ctrl { cursor: grab !important; touch-action: none; } #sakana-desktop-root .sakana-widget-ctrl:active { cursor: grabbing !important; } #sakana-desktop-root.sakana-dragging .sakana-widget-ctrl-item { pointer-events: none !important; } #sakana-revive-btn { position: fixed; z-index: 2147483645; border: none; background: transparent; box-shadow: none; padding: 0; margin: 0; width: 36px; height: 36px; box-sizing: border-box; display: none; align-items: center; justify-content: center; cursor: grab; font-size: 28px; line-height: 1; overflow: hidden; user-select: none; -webkit-user-select: none; pointer-events: auto; filter: drop-shadow(0 1px 2px rgba(0,0,0,.25)); transition: transform .15s ease; } #sakana-revive-btn:hover { transform: scale(1.12); } #sakana-revive-btn:active { cursor: grabbing; } #sakana-revive-btn.visible { display: flex; } #sakana-panel-overlay { position: fixed; inset: 0; z-index: 2147483647; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; backdrop-filter: blur(2px); } #sakana-panel-overlay.open { display: flex; pointer-events: auto; } #sakana-panel { width: min(460px, calc(100vw - 32px)); max-height: min(78vh, 560px); overflow: hidden; display: flex; flex-direction: column; background: #fff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,.25); font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: #333; } #sakana-panel header { padding: 14px 18px 0; flex-shrink: 0; } #sakana-panel header h2 { margin: 0 0 2px; font-size: 16px; font-weight: 600; } #sakana-panel header p { margin: 0 0 8px; font-size: 12px; color: #888; } #sakana-panel .tab-bar { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid #eee; flex-shrink: 0; } #sakana-panel .tab-btn { border: none; background: none; padding: 8px 12px; font-size: 13px; color: #888; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; } #sakana-panel .tab-btn.active { color: #1565c0; font-weight: 600; border-bottom-color: #1565c0; } #sakana-panel .tab-btn:hover { color: #555; } #sakana-panel .panel-body { padding: 12px 18px 16px; overflow: hidden; flex: 1; min-height: 0; display: flex; flex-direction: column; } #sakana-panel .tab-pane { display: none; flex: 1; min-height: 0; } #sakana-panel .tab-pane.active[data-tab="characters"] { display: flex; flex-direction: column; overflow: hidden; } #sakana-panel .tab-pane.active[data-tab="settings"] { display: block; overflow: auto; } #sakana-panel .char-pane { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: 4px; } #sakana-panel .char-pane-list { flex: 1; min-height: 0; display: flex; flex-direction: column; } #sakana-panel .char-pane-add { flex-shrink: 0; border-top: 1px solid #eee; padding-top: 8px; margin-top: 4px; } #sakana-panel .section-title { font-size: 13px; font-weight: 600; margin: 10px 0 6px; color: #555; flex-shrink: 0; } #sakana-panel .section-title:first-child { margin-top: 0; } #sakana-settings-form { display: flex; flex-direction: column; gap: 12px; } #sakana-settings-form label { font-size: 13px; color: #555; display: flex; flex-direction: column; gap: 4px; } #sakana-settings-form label.row-inline { flex-direction: row; align-items: center; gap: 8px; } #sakana-settings-form input[type="text"], #sakana-settings-form input[type="number"], #sakana-settings-form select { padding: 7px 10px; border: 1px solid #ddd; border-radius: 8px; font-size: 13px; } #sakana-settings-form input:focus, #sakana-settings-form select:focus { outline: none; border-color: #1565c0; } #sakana-settings-form .hint { font-size: 12px; color: #999; margin: 0; } #sakana-char-list { list-style: none; margin: 0; padding: 0; flex: 1; min-height: 0; overflow: auto; } #sakana-char-list li { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; margin-bottom: 6px; background: #f7f7f8; border: 1px solid #eee; } #sakana-char-list li.active { border-color: #90caf9; background: #e3f2fd; } #sakana-char-list .char-thumb { width: 56px; height: 56px; border-radius: 10px; background: #ddd center/cover no-repeat; flex-shrink: 0; } #sakana-char-list .char-info { flex: 1; min-width: 0; } #sakana-char-list .char-name { font-size: 14px; font-weight: 500; } #sakana-char-list .char-id { font-size: 11px; color: #999; word-break: break-all; } #sakana-char-list .char-badge { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #e8e8e8; color: #666; } #sakana-char-list .char-badge.builtin { background: #e3f2fd; color: #1565c0; } #sakana-char-list button { border: none; background: transparent; cursor: pointer; padding: 3px 6px; border-radius: 6px; font-size: 12px; } #sakana-char-list button.use-btn { color: #1565c0; } #sakana-char-list button.use-btn:hover { background: #e3f2fd; } #sakana-char-list button.del-btn { color: #c62828; } #sakana-char-list button.del-btn:hover { background: #ffebee; } #sakana-add-form { display: flex; flex-direction: column; gap: 6px; } #sakana-add-form label { font-size: 12px; color: #555; display: flex; flex-direction: column; gap: 2px; } #sakana-add-form input[type="text"] { padding: 6px 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 13px; } #sakana-add-form input[type="text"]:focus { outline: none; border-color: #1565c0; } #sakana-add-form .hint { font-size: 11px; color: #999; margin: 0; } #sakana-add-form .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } #sakana-add-form .add-media-row { display: grid; grid-template-columns: 1fr 52px; gap: 8px; align-items: start; } #sakana-panel .btn { padding: 7px 14px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; } #sakana-panel .btn-save { background: #1565c0; color: #fff; border: 1px solid #1565c0; } #sakana-panel .btn-save:hover { background: #0d47a1; border-color: #0d47a1; } #sakana-panel .btn-primary { background: #1565c0; color: #fff; } #sakana-panel .btn-primary:hover { background: #0d47a1; } #sakana-panel .btn-secondary { background: #fff; color: #1565c0; border: 1px solid #90caf9; } #sakana-panel .btn-secondary:hover { background: #e3f2fd; } #sakana-panel .preview-box { width: 52px; height: 52px; border-radius: 8px; background: #f0f0f0 center/contain no-repeat; border: 1px dashed #ddd; margin: 0; } #sakana-panel .preview-box.has-image { border-style: solid; border-color: #90caf9; } `); function resolveSakanaWidget() { if (typeof SakanaWidget !== 'undefined' && SakanaWidget) return SakanaWidget; if (typeof globalThis !== 'undefined' && globalThis.SakanaWidget) return globalThis.SakanaWidget; if (typeof window !== 'undefined' && window.SakanaWidget) return window.SakanaWidget; return null; } function getCustomCharacters() { try { const raw = GM_getValue(STORAGE.customCharacters, '[]'); const list = typeof raw === 'string' ? JSON.parse(raw) : raw; return Array.isArray(list) ? list : []; } catch { return []; } } function saveCustomCharacters(list) { GM_setValue(STORAGE.customCharacters, JSON.stringify(list)); } function slugify(name) { const base = name .trim() .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9\u4e00-\u9fff-]/g, ''); return base || `custom-${Date.now()}`; } function resolvePackImage(pack) { try { if (typeof GM_getResourceURL === 'function') { const local = GM_getResourceURL(pack.resource); if (local) return local; } } catch { /* 资源没装上就走在线链接 */ } return pack.url; } // 扩展包 + 加的角色;晃动参数先跟千束借一套 function registerAllExtraCharacters(SW) { const template = SW.getCharacter('chisato'); if (!template) return; for (const pack of PACK_CHARACTERS) { try { SW.registerCharacter(pack.id, { image: resolvePackImage(pack), initialState: { ...template.initialState }, }); } catch (e) { console.warn('[石蒜桌面宠物] 扩展角色加不上:', pack.id, e); } } for (const item of getCustomCharacters()) { if (!item.id || !item.image) continue; try { SW.registerCharacter(item.id, { image: item.image, initialState: { ...template.initialState, ...(item.initialState || {}) }, }); } catch (e) { console.warn('[石蒜桌面宠物] 这个角色加不上:', item.id, e); } } } function listCatalogCharacters(SW) { const all = SW.getCharacters(); const items = []; for (const c of CORE_CHARACTERS) { if (!all[c.id]) continue; items.push({ id: c.id, name: c.name, image: all[c.id].image, builtin: true }); } for (const c of PACK_CHARACTERS) { if (!all[c.id]) continue; items.push({ id: c.id, name: c.name, image: all[c.id].image, builtin: true }); } for (const c of getCustomCharacters()) { items.push({ id: c.id, name: c.name || c.id, image: c.image, builtin: false }); } return items; } function getPetSize() { return Math.max(120, Math.min(400, cfg.size)); } function getCornerPosition(corner) { const size = getPetSize(); const w = window.innerWidth; const h = window.innerHeight; const map = { 'bottom-right': { left: w - size - CORNER_OFFSET, top: h - size - CORNER_OFFSET }, 'bottom-left': { left: CORNER_OFFSET, top: h - size - CORNER_OFFSET }, 'top-right': { left: w - size - CORNER_OFFSET, top: CORNER_OFFSET }, 'top-left': { left: CORNER_OFFSET, top: CORNER_OFFSET }, }; return clampPosition(map[corner] || map['bottom-right']); } function clampPosition(pos) { const size = getPetSize(); return { left: Math.max(0, Math.min(window.innerWidth - size, Number(pos.left) || 0)), top: Math.max(0, Math.min(window.innerHeight - size, Number(pos.top) || 0)), }; } function loadSavedPosition() { if (sharedPos) return clampPosition(sharedPos); const saved = GM_getValue(STORAGE.petPosition, null) || GM_getValue('sakana.revivePosition', null); if (saved && typeof saved.left === 'number' && typeof saved.top === 'number') { sharedPos = clampPosition(saved); return sharedPos; } sharedPos = getCornerPosition('bottom-right'); return sharedPos; } function saveSharedPosition(pos) { sharedPos = clampPosition(pos); GM_setValue(STORAGE.petPosition, sharedPos); return sharedPos; } function applyFixedPosition(el, pos) { if (!el) return; el.style.position = 'fixed'; el.style.left = `${pos.left}px`; el.style.top = `${pos.top}px`; el.style.right = 'auto'; el.style.bottom = 'auto'; } function getFishBoxSize() { if (reviveBtn) { const w = reviveBtn.offsetWidth || FISH_SIZE; const h = reviveBtn.offsetHeight || FISH_SIZE; return { w, h }; } return { w: FISH_SIZE, h: FISH_SIZE }; } function clampFishPosition(pos) { const { w, h } = getFishBoxSize(); const pad = FISH_EDGE_PAD; const maxLeft = Math.max(pad, window.innerWidth - w - pad); const maxTop = Math.max(pad, window.innerHeight - h - pad); return { left: Math.max(pad, Math.min(maxLeft, Number(pos.left) || 0)), top: Math.max(pad, Math.min(maxTop, Number(pos.top) || 0)), }; } // 藏起来之后,鱼出现在她刚才站的正中间 function getFishCenterFromPetPos(petPos) { const size = getPetSize(); return { left: petPos.left + size / 2 - FISH_SIZE / 2, top: petPos.top + size / 2 - FISH_SIZE / 2, }; } // 点鱼喊她回来:尽量对上鱼的位置,但别让她半截跑出屏幕 function getPetPosFromFishCenter(fishPos) { const size = getPetSize(); return clampPosition({ left: fishPos.left + FISH_SIZE / 2 - size / 2, top: fishPos.top + FISH_SIZE / 2 - size / 2, }); } function getLiveMountRoot() { const el = document.getElementById('sakana-desktop-root'); if (el) mountRoot = el; return mountRoot; } function applyPetPosition(pos) { const p = saveSharedPosition(pos); const root = getLiveMountRoot(); if (root) applyFixedPosition(root, p); return p; } function applyFishPosition(pos) { if (!reviveBtn) return; applyFixedPosition(reviveBtn, clampFishPosition(pos)); } function readFishPosition() { if (!reviveBtn) return getFishCenterFromPetPos(loadSavedPosition()); return clampFishPosition({ left: parseFloat(reviveBtn.style.left), top: parseFloat(reviveBtn.style.top), }); } // 拖底下那条白杠是挪位置;拖人是晃,别拖混了 function setupControlBarDrag() { const root = getLiveMountRoot(); if (!root) return; const ctrl = root.querySelector('.sakana-widget-ctrl'); if (!ctrl || ctrl.dataset.sakanaDragBound === '1') return; ctrl.dataset.sakanaDragBound = '1'; // 绑过就别再绑,不然拖起来会抽风 let dragging = false; let didDrag = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; const onMove = (e) => { if (!dragging) return; const point = e.touches ? e.touches[0] : e; if (!point) return; const dx = point.clientX - startX; const dy = point.clientY - startY; if (!didDrag && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) { didDrag = true; root.classList.add('sakana-dragging'); } if (!didDrag) return; if (e.cancelable) e.preventDefault(); const size = getPetSize(); let left = startLeft + dx; let top = startTop + dy; left = Math.max(0, Math.min(window.innerWidth - size, left)); top = Math.max(0, Math.min(window.innerHeight - size, top)); applyFixedPosition(root, { left, top }); }; const onEnd = () => { if (!dragging) return; dragging = false; document.removeEventListener('mousemove', onMove, true); document.removeEventListener('mouseup', onEnd, true); document.removeEventListener('touchmove', onMove, true); document.removeEventListener('touchend', onEnd, true); root.classList.remove('sakana-dragging'); if (didDrag) { applyPetPosition({ left: parseFloat(root.style.left) || 0, top: parseFloat(root.style.top) || 0, }); setTimeout(() => { didDrag = false; }, 80); } }; const onStart = (e) => { if (e.type === 'mousedown' && e.button !== 0) return; const point = e.touches ? e.touches[0] : e; if (!point) return; const live = getLiveMountRoot(); if (!live) return; dragging = true; didDrag = false; startX = point.clientX; startY = point.clientY; const rect = live.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; document.addEventListener('mousemove', onMove, true); document.addEventListener('mouseup', onEnd, true); document.addEventListener('touchmove', onMove, { capture: true, passive: false }); document.addEventListener('touchend', onEnd, true); }; ctrl.addEventListener('mousedown', onStart, true); ctrl.addEventListener('touchstart', onStart, { capture: true, passive: true }); ctrl.addEventListener( 'click', (e) => { if (didDrag) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } }, true, ); } function setupReviveDrag(btn) { let dragging = false; let didDrag = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; const onMove = (e) => { if (!dragging) return; const point = e.touches ? e.touches[0] : e; if (!point) return; const dx = point.clientX - startX; const dy = point.clientY - startY; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) didDrag = true; let left = startLeft + dx; let top = startTop + dy; const clamped = clampFishPosition({ left, top }); applyFixedPosition(btn, clamped); if (e.cancelable) e.preventDefault(); }; const onEnd = () => { if (!dragging) return; dragging = false; document.removeEventListener('mousemove', onMove, true); document.removeEventListener('mouseup', onEnd, true); document.removeEventListener('touchmove', onMove, true); document.removeEventListener('touchend', onEnd, true); // 松手就停那儿,别自己弹回去 if (didDrag) { setTimeout(() => { didDrag = false; }, 80); } }; const onStart = (e) => { if (e.type === 'mousedown' && e.button !== 0) return; const point = e.touches ? e.touches[0] : e; if (!point) return; dragging = true; didDrag = false; startX = point.clientX; startY = point.clientY; const rect = btn.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; document.addEventListener('mousemove', onMove, true); document.addEventListener('mouseup', onEnd, true); document.addEventListener('touchmove', onMove, { capture: true, passive: false }); document.addEventListener('touchend', onEnd, true); e.preventDefault(); }; btn.addEventListener('mousedown', onStart); btn.addEventListener('touchstart', onStart, { passive: false }); btn.addEventListener('click', (e) => { if (didDrag) { e.preventDefault(); e.stopImmediatePropagation(); return; } revivePet(); }); } function removeAllReviveButtons() { document.querySelectorAll('#sakana-revive-btn').forEach((el) => el.remove()); reviveBtn = null; } function destroyReviveButton() { removeAllReviveButtons(); } function ensureReviveButton() { if (!cfg.showReviveButton) { destroyReviveButton(); return null; } // 以前判断写错,页面上能冒出两条鱼……这里只留一条 const existing = document.querySelectorAll('#sakana-revive-btn'); if (existing.length > 1) { for (let i = 1; i < existing.length; i++) existing[i].remove(); } if (existing[0] && document.contains(existing[0])) { reviveBtn = existing[0]; return reviveBtn; } if (reviveBtn && document.contains(reviveBtn)) return reviveBtn; removeAllReviveButtons(); const btn = document.createElement('button'); btn.id = 'sakana-revive-btn'; btn.type = 'button'; btn.title = '拖动 · 点击唤回宠物'; btn.textContent = '🐟'; reviveBtn = btn; applyFishPosition(getFishCenterFromPetPos(loadSavedPosition())); setupReviveDrag(btn); document.documentElement.appendChild(btn); return btn; } function showFishAtPetCenter() { const btn = ensureReviveButton(); if (!btn) return; applyFishPosition(getFishCenterFromPetPos(loadSavedPosition())); btn.classList.add('visible'); } function hideFish() { document.querySelectorAll('#sakana-revive-btn').forEach((el) => { el.classList.remove('visible'); }); } function revivePet() { if (!widgetInstance) return; // 鱼在角落也没事,人会完整贴回来,不会飞出屏幕 const petPos = getPetPosFromFishCenter(readFishPosition()); applyPetPosition(petPos); widgetInstance.show(); hideFish(); } function hidePet() { if (!widgetInstance) return; const root = getLiveMountRoot(); if (root) { const rect = root.getBoundingClientRect(); applyPetPosition({ left: rect.left, top: rect.top }); } widgetInstance.hide(); if (cfg.showReviveButton) showFishAtPetCenter(); } function remountWidget(SW) { const wasHidden = widgetHidden; const pos = loadSavedPosition(); if (widgetInstance) { try { widgetInstance.unmount(); } catch { /* ignore */ } widgetInstance = null; } const old = document.getElementById('sakana-desktop-root'); if (old) old.remove(); mountRoot = null; saveSharedPosition(pos); mountWidget(SW); if (wasHidden) { widgetInstance?.hide(); if (cfg.showReviveButton) showFishAtPetCenter(); } } function applySettingsLive(SW, prev, next) { Object.assign(cfg, next); saveSettings(next); if (!next.enabled) { if (widgetInstance) { try { widgetInstance.unmount(); } catch { /* ignore */ } widgetInstance = null; } const old = document.getElementById('sakana-desktop-root'); if (old) old.remove(); mountRoot = null; destroyReviveButton(); widgetHidden = false; return; } if (!widgetInstance) { remountWidget(SW); return; } if (prev.size !== next.size) { remountWidget(SW); } if (prev.showReviveButton !== next.showReviveButton) { if (next.showReviveButton) { if (widgetHidden) showFishAtPetCenter(); } else { destroyReviveButton(); } } if (prev.defaultCharacter !== next.defaultCharacter && widgetInstance) { const all = SW.getCharacters(); if (all[next.defaultCharacter]) { try { widgetInstance.setCharacter(next.defaultCharacter); currentCharId = next.defaultCharacter; GM_setValue(STORAGE.currentCharacter, next.defaultCharacter); } catch { /* ignore */ } } } } function createMountRoot() { const existing = document.getElementById('sakana-desktop-root'); if (existing) existing.remove(); const root = document.createElement('div'); root.id = 'sakana-desktop-root'; const size = getPetSize(); root.style.width = `${size}px`; root.style.height = `${size}px`; applyFixedPosition(root, loadSavedPosition()); document.documentElement.appendChild(root); mountRoot = root; return root; } function mountWidget(SW) { registerAllExtraCharacters(SW); const savedChar = GM_getValue(STORAGE.currentCharacter, cfg.defaultCharacter); const allChars = SW.getCharacters(); currentCharId = allChars[savedChar] ? savedChar : cfg.defaultCharacter; const root = createMountRoot(); widgetInstance = new SW({ size: getPetSize(), character: currentCharId, controls: true, saveState: true, stateKey: 'sakana-desktop-pet-status', }); widgetInstance .addStateListener((state) => { widgetHidden = state === 'hide'; if (widgetHidden) { const live = getLiveMountRoot(); if (live) { const rect = live.getBoundingClientRect(); // 藏起来后宽高可能读成 0,还是看 style 里记的位置靠谱 const fromStyle = { left: parseFloat(live.style.left), top: parseFloat(live.style.top), }; if (!Number.isNaN(fromStyle.left) && !Number.isNaN(fromStyle.top)) { applyPetPosition(fromStyle); } else if (rect.width > 0 || rect.height > 0) { applyPetPosition({ left: rect.left, top: rect.top }); } else { applyPetPosition(loadSavedPosition()); } } if (cfg.showReviveButton) showFishAtPetCenter(); } else { applyPetPosition(loadSavedPosition()); hideFish(); } }) .mount(root); // 库会把挂载节点换掉,得重新抓一下再绑拖动 getLiveMountRoot(); applyPetPosition(loadSavedPosition()); setupControlBarDrag(); ensureReviveButton(); return widgetInstance; } function closePanel() { document.getElementById('sakana-panel-overlay')?.classList.remove('open'); } function switchTab(overlay, tabId) { overlay.querySelectorAll('.tab-btn').forEach((b) => { b.classList.toggle('active', b.dataset.tab === tabId); }); overlay.querySelectorAll('.tab-pane').forEach((p) => { p.classList.toggle('active', p.dataset.tab === tabId); }); } function fillSettingsForm(overlay, SW) { const form = overlay.querySelector('#sakana-settings-form'); if (!form) return; form.querySelector('#sakana-set-enabled').checked = cfg.enabled; form.querySelector('#sakana-set-size').value = cfg.size; form.querySelector('#sakana-set-revive').checked = cfg.showReviveButton; const charSelect = form.querySelector('#sakana-set-default-char'); const options = listCatalogCharacters(SW); charSelect.innerHTML = options .map((o) => ``) .join(''); charSelect.value = cfg.defaultCharacter; } function openPanel(SW, initialTab = 'characters') { let overlay = document.getElementById('sakana-panel-overlay'); if (overlay) { switchTab(overlay, initialTab); fillSettingsForm(overlay, SW); renderCharList(SW); overlay.classList.add('open'); return; } overlay = document.createElement('div'); overlay.id = 'sakana-panel-overlay'; overlay.innerHTML = ` `; document.documentElement.appendChild(overlay); overlay.querySelectorAll('.tab-btn').forEach((btn) => { btn.addEventListener('click', () => switchTab(overlay, btn.dataset.tab)); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); }); overlay.querySelector('#sakana-settings-form').addEventListener('submit', (e) => { e.preventDefault(); const prev = { ...cfg }; const next = { enabled: overlay.querySelector('#sakana-set-enabled').checked, size: Math.max(120, Math.min(400, Number(overlay.querySelector('#sakana-set-size').value) || 200)), defaultCharacter: overlay.querySelector('#sakana-set-default-char').value, showReviveButton: overlay.querySelector('#sakana-set-revive').checked, }; applySettingsLive(SW, prev, next); GM_notification({ title: '石蒜桌面宠物', text: '设置已保存并生效', timeout: 2500 }); closePanel(); }); const preview = overlay.querySelector('#sakana-preview'); const urlInput = overlay.querySelector('#sakana-char-url'); const fileInput = overlay.querySelector('#sakana-char-file'); urlInput.addEventListener('input', () => { const url = urlInput.value.trim(); if (url) { preview.style.backgroundImage = `url("${url.replace(/"/g, '%22')}")`; preview.classList.add('has-image'); fileInput.value = ''; delete urlInput.dataset.base64; } else if (!urlInput.dataset.base64) { preview.style.backgroundImage = ''; preview.classList.remove('has-image'); } }); fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) return; if (file.size > 2 * 1024 * 1024) { GM_notification({ title: '石蒜桌面宠物', text: '图片请小于 2MB', timeout: 3000 }); fileInput.value = ''; return; } const reader = new FileReader(); reader.onload = () => { urlInput.dataset.base64 = reader.result; preview.style.backgroundImage = `url("${reader.result}")`; preview.classList.add('has-image'); urlInput.value = ''; }; reader.readAsDataURL(file); }); overlay.querySelector('#sakana-clear-form').addEventListener('click', () => { overlay.querySelector('#sakana-char-name').value = ''; urlInput.value = ''; delete urlInput.dataset.base64; fileInput.value = ''; preview.style.backgroundImage = ''; preview.classList.remove('has-image'); }); overlay.querySelector('#sakana-add-form').addEventListener('submit', (e) => { e.preventDefault(); const name = overlay.querySelector('#sakana-char-name').value.trim(); const imageUrl = urlInput.value.trim(); const imageBase64 = urlInput.dataset.base64; const image = imageUrl || imageBase64; if (!name) { GM_notification({ title: '石蒜桌面宠物', text: '请填写角色名称', timeout: 2500 }); return; } if (!image) { GM_notification({ title: '石蒜桌面宠物', text: '请提供图片链接或上传图片', timeout: 2500 }); return; } let id = slugify(name); const existing = getCustomCharacters(); if (existing.some((c) => c.id === id)) { id = `${id}-${Date.now().toString(36)}`; } const template = SW.getCharacter('chisato'); const entry = { id, name, image, initialState: template?.initialState }; existing.push(entry); saveCustomCharacters(existing); try { SW.registerCharacter(id, { image, initialState: template.initialState, }); widgetInstance?.setCharacter(id); currentCharId = id; GM_setValue(STORAGE.currentCharacter, id); GM_notification({ title: '石蒜桌面宠物', text: `已添加并切换到「${name}」`, timeout: 2500 }); } catch (err) { GM_notification({ title: '石蒜桌面宠物', text: `添加失败: ${err.message}`, timeout: 4000 }); } overlay.querySelector('#sakana-clear-form').click(); renderCharList(SW); fillSettingsForm(overlay, SW); }); fillSettingsForm(overlay, SW); renderCharList(SW); switchTab(overlay, initialTab); overlay.classList.add('open'); } function renderCharList(SW) { const listEl = document.getElementById('sakana-char-list'); if (!listEl) return; const current = currentCharId || GM_getValue(STORAGE.currentCharacter, cfg.defaultCharacter); const items = listCatalogCharacters(SW); listEl.innerHTML = items .map( (item) => `
  • ${escapeHtml(item.name)}
    ${escapeHtml(item.id)}
    ${item.builtin ? '内置' : '自定义'} ${item.builtin ? '' : ''}
  • `, ) .join(''); listEl.querySelectorAll('button').forEach((btn) => { btn.addEventListener('click', () => { const li = btn.closest('li'); const id = li?.dataset.id; if (!id) return; if (btn.dataset.action === 'use') { try { widgetInstance?.setCharacter(id); currentCharId = id; GM_setValue(STORAGE.currentCharacter, id); renderCharList(SW); } catch (err) { GM_notification({ title: '石蒜桌面宠物', text: err.message, timeout: 3000 }); } } else if (btn.dataset.action === 'delete') { if (!confirm(`确定删除自定义角色「${id}」?删除后需刷新页面完全生效。`)) return; const next = getCustomCharacters().filter((c) => c.id !== id); saveCustomCharacters(next); if (currentCharId === id) { GM_setValue(STORAGE.currentCharacter, 'chisato'); } GM_notification({ title: '石蒜桌面宠物', text: '已删除,正在刷新页面…', timeout: 2000, }); setTimeout(() => location.reload(), 400); } }); }); } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function registerMenus(SW) { GM_registerMenuCommand('⚙️ 设置和角色管理', () => openPanel(SW, 'characters')); GM_registerMenuCommand('唤回宠物', () => { if (!widgetInstance) { GM_notification({ title: '石蒜桌面宠物', text: '宠物未启用或未加载', timeout: 2500 }); return; } revivePet(); }); GM_registerMenuCommand('隐藏宠物', () => { if (!widgetInstance) { GM_notification({ title: '石蒜桌面宠物', text: '宠物未启用或未加载', timeout: 2500 }); return; } hidePet(); }); } SakanaWidgetClass = resolveSakanaWidget(); if (!SakanaWidgetClass) { console.error('[石蒜桌面宠物] @require 未提供 SakanaWidget'); GM_notification({ title: '石蒜桌面宠物', text: '库未加载,请检查网络后重新安装/更新脚本', timeout: 5000, }); return; } if (cfg.enabled) { mountWidget(SakanaWidgetClass); } registerMenus(SakanaWidgetClass); const scriptVersion = (typeof GM_info !== 'undefined' && GM_info?.script?.version) || '?.?.?'; console.info(`[石蒜桌面宠物] 好了好了,人来了 v${scriptVersion}`); })();