/* ============================================================================ * dsh-mornye-quota-pet —— 莫宁额度桌宠(页面客户端,原生 JS 零依赖) * * 数据:GET /dsh-mornye-pet/usage.json → {windows:{rolling|weekly|monthly:{used,resetsAt}}} * 请求失败/加载中时不播随机台词;断线时沿用最近数据并标记 stale。 * 演示:URL 带 ?mock=1 时使用本地 mock 数据(供 demo.html 双击预览)。 * ==========================================================================*/ (function () { if (window.__dshMornyePet) return window.__dshMornyePet = true var DEMO = (window.MORN_RE_DEMO === true) || /(?:[?&]|#)mock=1/.test(location.search) || (function () { // 也支持把 ?mock=1 挂在 script 标签上(如 demo.html 的 src="lib/widget.js?mock=1") try { var s = document.currentScript && document.currentScript.src return !!s && /\bmock=1\b/.test(s) } catch (e) { return false } })() var MIN_SCALE = 0.6 var MAX_SCALE = 2.5 var CLICK_SQ = 9 var REFRESH_MS = 60000 var BUBBLE_MS = 5000 var RANDOM_MS = 6500 var FETCH_TIMEOUT_MS = 20000 var TICK_MS = 1000 var CHANGE_MS = 900 var ANIM_MS = 700 var API = { usage: DEMO ? null : '/dsh-mornye-pet/usage.json', config: DEMO ? null : '/dsh-mornye-pet/config.json', sources: DEMO ? null : '/dsh-mornye-pet/sources.json', pet: DEMO ? (window.MORN_RE_PET_DATA_URL || 'pet.png') : '/dsh-mornye-pet/pet.png', press: DEMO ? null : '/dsh-mornye-pet/sound/press-', release: DEMO ? null : '/dsh-mornye-pet/sound/release-' } var WINDOW_KEYS = [] // 档位键(由 /sources.json 驱动) var WINDOW_LABEL = {} var SHORT = {} var DEMO_SOURCES = [ { key: 'opencode:monthly', label: '每月额度', short: '月' }, { key: 'opencode:weekly', label: '每周额度', short: '周' }, { key: 'opencode:rolling', label: '滚动额度', short: '滚动' }, { key: 'demo', label: '示例源 · 额度', short: '示例' }, ] function applySources(list) { WINDOW_KEYS = [] WINDOW_LABEL = {} SHORT = {} var src = (Array.isArray(list) && list.length) ? list : DEMO_SOURCES src.forEach(function (d) { if (!d || !d.key) return WINDOW_KEYS.push(d.key) WINDOW_LABEL[d.key] = d.label || d.key SHORT[d.key] = d.short || String(d.label || d.key).slice(0, 2) }) buildWinRows() if (!winOrder.length) winOrder = WINDOW_KEYS.slice() sanitizeCur() if (typeof rebuildSrcList === 'function') rebuildSrcList() } /* ---------------- 莫宁视觉:淡蓝白 + 灿金细线 + 冷静几何 ---------------- */ var css = [ '.dshmv-root{position:fixed;right:0;bottom:0;--dshmv-scale:1;--dshmv-base:clamp(150px,calc(min(280px,min(100vw,100vh) * 0.30) * var(--dshmv-scale)),680px);width:var(--dshmv-base);height:var(--dshmv-base);pointer-events:none;user-select:none;-webkit-user-select:none;z-index:9999;font-family:inherit;transition:left .16s ease,top .16s ease,transform .3s ease}', '.dshmv-root.dshmv-left{transform:scaleX(-1)}', '.dshmv-root.dshmv-dragging{cursor:grabbing;transition:none}', '.dshmv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}', '.dshmv-img{position:absolute;right:0;bottom:0;width:62%;height:auto;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none;filter:drop-shadow(0 6px 14px rgba(61,80,143,.18))}', '.dshmv-bubble{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1}', '.dshmv-bubble svg{display:block;width:100%;height:100%;pointer-events:none}', '.dshmv-bubble svg path,.dshmv-bubble svg ellipse{pointer-events:none;cursor:pointer}', '.dshmv-bubble.dshmv-bubble-open svg .dshmv-shape,.dshmv-bubble.dshmv-bubble-open svg .dshmv-b1,.dshmv-bubble.dshmv-bubble-open svg .dshmv-b2{pointer-events:visiblePainted}', '.dshmv-bubble .dshmv-shape,.dshmv-bubble .dshmv-b1,.dshmv-bubble .dshmv-b2,.dshmv-bubble .dshmv-ringtrack,.dshmv-bubble .dshmv-ringarc,.dshmv-bubble .dshmv-dot{opacity:0;transform:scale(.7);transform-box:fill-box;transform-origin:50% 50%;transition:opacity .2s ease,transform .2s ease}', '.dshmv-bubble.dshmv-bubble-open .dshmv-shape,.dshmv-bubble.dshmv-bubble-open .dshmv-b1,.dshmv-bubble.dshmv-bubble-open .dshmv-b2,.dshmv-bubble.dshmv-bubble-open .dshmv-ringtrack,.dshmv-bubble.dshmv-bubble-open .dshmv-ringarc,.dshmv-bubble.dshmv-bubble-open .dshmv-dot{opacity:1;transform:none}', '.dshmv-bubble.dshmv-bubble-open .dshmv-b2{transition-delay:0s}', '.dshmv-bubble.dshmv-bubble-open .dshmv-b1{transition-delay:.13s}', '.dshmv-bubble.dshmv-bubble-open .dshmv-shape{transition-delay:.26s}', '.dshmv-bubble.dshmv-bubble-open .dshmv-ringarc{transition-delay:.10s}', '.dshmv-bubble.dshmv-bubble-open .dshmv-ringtrack{transition-delay:.10s}', '.dshmv-bubble.dshmv-bubble-open .dshmv-dot{transition-delay:.16s}', '.dshmv-bubble .dshmv-shape{transition-delay:.1s}', '.dshmv-bubble .dshmv-b1{transition-delay:.2s}', '.dshmv-bubble .dshmv-b2{transition-delay:.3s}', '.dshmv-text{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);text-align:center;color:#3d5a8f;line-height:1.15;white-space:nowrap;--dshmv-u:calc(var(--dshmv-base) / 1026);pointer-events:none;opacity:0;transition:opacity .16s ease,transform .3s ease}', '.dshmv-bubble.dshmv-bubble-open .dshmv-text{opacity:1;transition:opacity .16s ease .36s,transform .3s ease}', '.dshmv-root.dshmv-left .dshmv-text{transform:translate(-50%,-50%) scaleX(-1)}', '.dshmv-label{font-size:calc(var(--dshmv-u) * 58);font-weight:600;letter-spacing:.08em;color:#5b7299;max-width:calc(var(--dshmv-u) * 560);overflow:hidden;text-overflow:ellipsis}', '.dshmv-amount{font-size:calc(var(--dshmv-u) * 116);font-weight:800;line-height:1.05;color:#3d5a8f}', '.dshmv-amount.dshmv-wrap{font-size:calc(var(--dshmv-u) * 62);font-weight:700;line-height:1.3}', '.dshmv-bubble.dshmv-hide-ring .dshmv-ringtrack,.dshmv-bubble.dshmv-hide-ring .dshmv-ringarc{opacity:0 !important;transform:scale(.7) !important}', '.dshmv-hint{display:block;font-size:calc(var(--dshmv-u) * 50);color:#8aa0c4;letter-spacing:.02em;margin-top:calc(var(--dshmv-u) * 8);max-width:calc(var(--dshmv-u) * 600);white-space:normal;line-height:1.3;overflow-wrap:anywhere}', '.dshmv-wrap{white-space:normal;max-width:calc(var(--dshmv-u) * 620);line-height:1.25}', '.dshmv-menu-btn{position:absolute;top:40px;right:4px;width:26px;height:26px;border:none;border-radius:6px;background:rgba(61,90,143,.82);cursor:pointer;pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:0;z-index:2;opacity:0;transition:opacity .15s ease;color-scheme:light}', '.dshmv-menu-btn.dshmv-menu-btn-visible{opacity:1;pointer-events:auto}', '.dshmv-menu-btn span{display:block;width:14px;height:2px;background:#fff;border-radius:1px}', '.dshmv-menu-btn:hover{background:#3d5a8f}', '.dshmv-menu{position:fixed;min-width:286px;background:rgba(253,255,255,.96);border:1px solid rgba(201,166,90,.6);border-radius:10px;padding:10px 12px;opacity:0;transform:scale(.92) translateY(-4px);transform-origin:top right;transition:opacity .18s ease,transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:none;z-index:10000;box-shadow:0 6px 18px rgba(61,80,143,.16);color-scheme:light}', '.dshmv-menu.dshmv-menu-open{opacity:1;transform:scale(1) translateY(0);pointer-events:auto}', '.dshmv-menu-row{display:flex;align-items:center;gap:8px;margin:5px 0;color:#3d5a8f;font-size:12px;white-space:nowrap}', '.dshmv-range{flex:1;min-width:0;accent-color:#c9a65a}', '.dshmv-number{width:46px;border:1px solid rgba(61,90,143,.4);border-radius:6px;padding:2px 4px;font-size:12px;color:#3d5a8f;background:#fff}', '.dshmv-sound{flex:1;border:1px solid rgba(61,90,143,.4);border-radius:6px;background:rgba(61,90,143,.06);color:#3d5a8f;font-size:12px;padding:3px 0;cursor:pointer}', '.dshmv-sound:hover{background:rgba(61,90,143,.12)}', '.dshmv-volpct{width:36px;text-align:right;color:#3d5a8f;font-size:12px}', /* 菜单内直填自定义源 */ '.dshmv-srcbtn{flex:1;border:1px solid rgba(201,166,90,.6);border-radius:6px;background:#fff;color:#3d5a8f;font-size:12px;padding:4px 6px;cursor:pointer}', '.dshmv-srcbtn:hover{background:rgba(201,166,90,.12)}', '.dshmv-srcbtn-ghost{background:rgba(61,90,143,.06);border-color:rgba(61,90,143,.4)}', '.dshmv-src-toggle{width:100%;text-align:left}', '.dshmv-srcpanel{margin-top:6px;padding:6px 8px;border:1px dashed rgba(61,90,143,.35);border-radius:8px;background:rgba(61,90,143,.03)}', '.dshmv-srcin{flex:1;min-width:0;border:1px solid rgba(61,90,143,.4);border-radius:6px;padding:3px 5px;font-size:12px;color:#3d5a8f;background:#fff}', '.dshmv-srcstatus{font-size:11px;line-height:1.5;color:#3d5a8f;margin-top:4px;white-space:pre-wrap}', '.dshmv-srclist{margin-top:2px}', '.dshmv-srci{display:flex;align-items:center;gap:6px;font-size:12px;color:#3d5a8f;padding:2px 4px;border-radius:5px}', '.dshmv-srci:hover{background:rgba(61,90,143,.06)}', '.dshmv-src-del{margin-left:auto;border:none;background:none;color:#d04a4a;cursor:pointer;font-size:12px;padding:2px 4px}', /* 额度勾选/拖拽排序 */ '.dshmv-winlist{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px;width:100%}', '.dshmv-winrow{display:flex;align-items:center;gap:6px;font-size:12px;color:#3d5a8f;padding:3px 5px;border-radius:5px;cursor:grab;background:rgba(61,90,143,.06)}', '.dshmv-winrow.dshmv-winrow-off{opacity:.42;cursor:default;background:transparent}', '.dshmv-winrow input{accent-color:#c9a65a;margin:0}', '.dshmv-winrow .dshmv-drag{color:#c9a65a;font-size:10px;margin-left:auto}', '.dshmv-wingroup{font-size:11px;font-weight:600;color:#8aa0c4;letter-spacing:.06em;margin:5px 2px 2px}', /* 弹幕观察:悬浮在宠物上方的弹幕轮播(内容 DOM 构建,绝不用 innerHTML 拼用户文本) */ '.dshmv-danmaku{position:fixed;background:rgba(253,255,255,.93);border:1px solid rgba(201,166,90,.55);border-radius:10px;padding:6px 9px;box-shadow:0 4px 14px rgba(61,80,143,.16);z-index:9998;color:#3d5a8f;display:none;overflow:hidden}', '.dshmv-danmaku-head{font-size:12px;font-weight:700;color:#5b7299;letter-spacing:.14em;margin-bottom:4px;border-bottom:1px dashed rgba(201,166,90,.4);padding-bottom:3px}', '.dshmv-danmaku-row{font-size:13px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.dshmv-danmaku-row b{color:#3d5a8f;font-weight:700}', '.dshmv-danmaku-row.fresh{color:#d09a3f;animation:dshmvFlash 1.4s ease}', '@keyframes dshmvFlash{0%{background:rgba(201,166,90,.4)}100%{background:transparent}}', '.dshmv-danmaku-empty{font-size:12px;color:#8aa0c4}', /* 三联仪表 */ '.dshmv-triplebox{position:absolute;left:44%;top:36%;width:62%;transform:translate(-50%,-50%);display:none;color:#3d5a8f;--dshmv-u:calc(var(--dshmv-base) / 1026);pointer-events:none}', '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox{display:block}', '.dshmv-bubble.dshmv-mode-triple .dshmv-text{display:none}', '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open.dshmv-random .dshmv-triplebox{display:none}', '.dshmv-bubble.dshmv-mode-triple.dshmv-random .dshmv-text{display:block}', '.dshmv-root.dshmv-left .dshmv-triplebox{transform:translate(-50%,-50%) scaleX(-1)}', '.dshmv-trow{display:flex;align-items:center;margin:calc(var(--dshmv-u) * 14) 0;white-space:nowrap}', '.dshmv-tlabel{font-size:calc(var(--dshmv-u) * 46);color:#5b7299;min-width:56px;max-width:120px;text-align:left;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.dshmv-tbar{flex:1;height:calc(var(--dshmv-u) * 14);background:#e7edf9;border-radius:calc(var(--dshmv-u) * 7);overflow:hidden;margin:0 calc(var(--dshmv-u) * 10)}', '.dshmv-tfill{display:block;height:100%;border-radius:inherit;transition:width .4s ease}', '.dshmv-tpct{font-size:calc(var(--dshmv-u) * 46);font-weight:800;color:#3d5a8f;width:52px;text-align:right;flex-shrink:0}', '.dshmv-treset{font-size:calc(var(--dshmv-u) * 32);color:#8aa0c4;width:82px;text-align:right;flex-shrink:0}', /* 方案A:行数多 → 双列(4+)/紧凑(7+),永不缩字;用短标签一行式行,双列收起重置列 */ '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid{display:grid;width:76%;grid-template-columns:1fr 1fr;column-gap:calc(var(--dshmv-u) * 14);row-gap:calc(var(--dshmv-u) * 2)}', '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid .dshmv-trow{margin:calc(var(--dshmv-u) * 4) 0}', '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid .dshmv-tlabel{min-width:0;max-width:none;padding-right:calc(var(--dshmv-u) * 8)}', '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid .dshmv-treset{display:none}', '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid.dshmv-tcompact .dshmv-trow{margin:calc(var(--dshmv-u) * 2) 0}', '.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid.dshmv-tcompact .dshmv-tbar{height:calc(var(--dshmv-u) * 12)}', /* 紧急告警角标 */ '.dshmv-badge{position:absolute;top:32%;right:30%;width:13px;height:13px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(61,80,143,.35);pointer-events:auto;cursor:pointer;z-index:3;display:none}', '.dshmv-badge-warn{background:#d9b45b}', '.dshmv-badge-danger{background:#d04a4a}' ].join('\n') var styleEl = document.createElement('style') styleEl.textContent = css document.head.appendChild(styleEl) var root = document.createElement('div') root.className = 'dshmv-root' var img = document.createElement('img') img.className = 'dshmv-img' img.src = API.pet img.alt = '莫宁额度桌宠' img.draggable = false var menuBtn = document.createElement('button') menuBtn.type = 'button' menuBtn.className = 'dshmv-menu-btn' menuBtn.title = '菜单' menuBtn.innerHTML = '' menuBtn.addEventListener('contextmenu', function (e) { e.stopPropagation(); e.preventDefault() }) menuBtn.addEventListener('click', function (e) { e.stopPropagation() var r = menuBtn.getBoundingClientRect() toggleMenuAt(r.left + 6, r.bottom + 4) }) var menuBox = document.createElement('div') menuBox.className = 'dshmv-menu' function menuLabel(text) { var s = document.createElement('span'); s.textContent = text; return s } function menuRow() { var r = document.createElement('div'); r.className = 'dshmv-menu-row'; return r } var scaleInput = document.createElement('input') scaleInput.type = 'range' scaleInput.min = String(MIN_SCALE) scaleInput.max = String(MAX_SCALE) scaleInput.step = '0.1' scaleInput.className = 'dshmv-range' scaleInput.value = '1.5' var scaleNumber = document.createElement('input') scaleNumber.type = 'number' scaleNumber.min = '1' scaleNumber.max = '20' scaleNumber.step = '1' scaleNumber.className = 'dshmv-number' scaleNumber.value = '10' scaleInput.addEventListener('pointerdown', function () { root.style.transition = 'none' }) scaleInput.addEventListener('input', function () { setScale(scaleInput.value) }) scaleInput.addEventListener('change', function () { root.style.transition = '' }) scaleNumber.addEventListener('change', function () { var v = Math.round(Number(scaleNumber.value)) var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19 setScale(s) root.style.transition = '' }) var soundSelect = document.createElement('select') soundSelect.className = 'dshmv-sound' function soundOpt(value, label) { var o = document.createElement('option'); o.value = value; o.textContent = label; return o } soundSelect.appendChild(soundOpt('tone', '仪器音')) soundSelect.appendChild(soundOpt('chime', '鸣响音')) soundSelect.appendChild(soundOpt('click', '按键音')) soundSelect.appendChild(soundOpt('pop', '气泡音')) soundSelect.appendChild(soundOpt('blip', '脉冲音')) soundSelect.appendChild(soundOpt('thock', '叩击音')) soundSelect.appendChild(soundOpt('off', '无声')) soundSelect.addEventListener('change', function () { setSoundToggle(soundSelect.value) }) var volInput = document.createElement('input') volInput.type = 'range' volInput.min = '0' volInput.max = '1' volInput.step = '0.05' volInput.className = 'dshmv-range' volInput.value = '0.9' var volPct = document.createElement('span') volPct.className = 'dshmv-volpct' volPct.textContent = '90%' volInput.addEventListener('input', function () { setVol(volInput.value) }) var row1 = menuRow(); row1.appendChild(menuLabel('大小')); row1.appendChild(scaleInput); row1.appendChild(scaleNumber) var row2 = menuRow(); row2.appendChild(menuLabel('音效')); row2.appendChild(soundSelect) var row3 = menuRow(); row3.appendChild(menuLabel('音量')); row3.appendChild(volInput); row3.appendChild(volPct) /* 显示样式:单档 / 三联仪表 */ var viewSelect = document.createElement('select') viewSelect.className = 'dshmv-sound' viewSelect.appendChild(soundOpt('single', '单档')) viewSelect.appendChild(soundOpt('triple', '三联')) viewSelect.addEventListener('change', function () { viewMode = viewSelect.value === 'triple' ? 'triple' : 'single' saveConfig() render() }) var rowSt = menuRow(); rowSt.appendChild(menuLabel('样式')); rowSt.appendChild(viewSelect) /* 额度:勾选=显示/参与轮换;拖 ☰ 排序(第一位=默认显示) */ var winListWrap = document.createElement('div') winListWrap.className = 'dshmv-winlist' var winRowEls = {} var dragSrc = null function winRow(wk) { var r = document.createElement('div') r.className = 'dshmv-winrow' r.setAttribute('data-win', wk) r.draggable = true var cb = document.createElement('input') cb.type = 'checkbox'; cb.className = 'dshmv-wincheck' cb.addEventListener('change', function () { toggleWin(wk) }) var lb = document.createElement('span'); lb.textContent = WINDOW_LABEL[wk] var hd = document.createElement('span'); hd.className = 'dshmv-drag'; hd.textContent = '☰' r.appendChild(cb); r.appendChild(lb); r.appendChild(hd) r.addEventListener('dragstart', function (e) { dragSrc = wk; try { e.dataTransfer.setData('text/plain', wk); e.dataTransfer.effectAllowed = 'move' } catch (err) {} }) r.addEventListener('dragover', function (e) { if (dragSrc && dragSrc !== wk && winOrder.indexOf(wk) !== -1) { try { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } catch (err) {} } }) r.addEventListener('drop', function (e) { if (dragSrc && dragSrc !== wk) { moveWinOrder(dragSrc, wk); try { e.preventDefault() } catch (err) {} } }) r.addEventListener('dragend', function () { dragSrc = null; refreshWinRows() }) winRowEls[wk] = r return r } function buildWinRows() { winListWrap.innerHTML = '' var lastGroup = null WINDOW_KEYS.forEach(function (k) { var group = /^opencode:/.test(k) ? 'OpenCode Go' : null if (group && group !== lastGroup) { var h = document.createElement('div'); h.className = 'dshmv-wingroup'; h.textContent = group winListWrap.appendChild(h); lastGroup = group } winListWrap.appendChild(winRow(k)) }) refreshWinRows() } function refreshWinRows() { var order = winOrder.slice() WINDOW_KEYS.forEach(function (k) { if (order.indexOf(k) === -1) order.push(k) }) order.forEach(function (k) { winListWrap.appendChild(winRowEls[k]) }) WINDOW_KEYS.forEach(function (k) { var on = winOrder.indexOf(k) !== -1 var rowEl = winRowEls[k] rowEl.classList.toggle('dshmv-winrow-off', !on) rowEl.draggable = on rowEl.querySelector('input').checked = on }) } function toggleWin(wk) { var i = winOrder.indexOf(wk) if (i !== -1) { if (winOrder.length <= 1) { refreshWinRows(); return } // 至少保留 1 个 winOrder.splice(i, 1) } else { winOrder.push(wk) } sanitizeCur() refreshWinRows() saveConfig() render() } function moveWinOrder(src, target) { var s = winOrder.indexOf(src); var t = winOrder.indexOf(target) if (s === -1 || t === -1 || s === t) { dragSrc = null; refreshWinRows(); return } winOrder.splice(s, 1) t = winOrder.indexOf(target) winOrder.splice(t, 0, src) refreshWinRows() saveConfig() render() } var rowQ = menuRow(); rowQ.appendChild(menuLabel('额度')) menuBox.appendChild(row1); menuBox.appendChild(row2); menuBox.appendChild(row3) menuBox.appendChild(rowSt) menuBox.appendChild(rowQ) menuBox.appendChild(winListWrap) /* ---- 弹幕观察:开关在菜单;连本地桥接(127.0.0.1:3081)轮询最新弹幕,悬浮展示 ---- */ var danmakuOn = false var danmakuTimer = null var danmakuPanel = null var danmakuList = [] var danmakuSeq = 0 var DMP_URL = DEMO ? null : 'http://127.0.0.1:3081/danmaku.json' var rowD = menuRow(); rowD.appendChild(menuLabel('弹幕观察')) var danmakuSwitch = document.createElement('input') danmakuSwitch.type = 'checkbox'; danmakuSwitch.className = 'dshmv-wincheck' danmakuSwitch.addEventListener('change', function () { setDanmaku(danmakuSwitch.checked) }) rowD.appendChild(danmakuSwitch) menuBox.appendChild(rowD) function setDanmaku(on) { danmakuOn = !!on danmakuSwitch.checked = danmakuOn saveConfig() if (danmakuOn) startDanmaku(); else stopDanmaku() } function ensureDanmakuPanel() { if (danmakuPanel) return danmakuPanel = document.createElement('div') danmakuPanel.className = 'dshmv-danmaku' var head = document.createElement('div'); head.className = 'dshmv-danmaku-head'; head.textContent = '弹幕观察' var body = document.createElement('div'); body.className = 'dshmv-danmaku-body'; body.textContent = '更新中…' danmakuPanel.appendChild(head); danmakuPanel.appendChild(body) document.body.appendChild(danmakuPanel) } function startDanmaku() { if (DEMO || !DMP_URL) return ensureDanmakuPanel() danmakuPanel.style.display = 'block' danmakuSeq = 0 danmakuLoop() positionDanmaku() } function stopDanmaku() { if (danmakuTimer) { clearInterval(danmakuTimer); danmakuTimer = null } if (danmakuPanel) danmakuPanel.style.display = 'none' } function danmakuLoop() { if (danmakuTimer) clearInterval(danmakuTimer) pollDanmaku() danmakuTimer = setInterval(pollDanmaku, 1000) } function pollDanmaku() { if (!danmakuOn || DEMO || !DMP_URL) return fetch(DMP_URL + '?since=' + danmakuSeq, { cache: 'no-store' }) .then(function (r) { return r.json() }) .then(function (d) { if (!d || !d.ok || !d.messages || !d.messages.length) { positionDanmaku(); return } d.messages.forEach(function (m) { if (!m) return danmakuList.unshift({ user: String(m.user || '?'), text: String(m.text || ''), ts: Number(m.ts) || 0 }) if (Number(m.ts) > danmakuSeq) danmakuSeq = Number(m.ts) }) if (danmakuList.length > 8) danmakuList.length = 8 renderDanmaku() positionDanmaku() }) .catch(function (e) { window.__lastErr = 'danmaku:' + String((e && e.message) || e); positionDanmaku() }) } function renderDanmaku() { if (!danmakuPanel) return var body = danmakuPanel.querySelector('.dshmv-danmaku-body') body.className = 'dshmv-danmaku-body' body.textContent = '' if (!danmakuList.length) { body.className = 'dshmv-danmaku-empty'; body.textContent = '更新中…'; return } var n = Math.min(4, danmakuList.length) for (var i = 0; i < n; i++) { var m = danmakuList[i] var row = document.createElement('div') row.className = 'dshmv-danmaku-row' + (i === 0 ? ' fresh' : '') var b = document.createElement('b'); b.textContent = m.user row.appendChild(b); row.appendChild(document.createTextNode(':' + m.text)) body.appendChild(row) } } function positionDanmaku() { if (!danmakuPanel) return try { var r = root.getBoundingClientRect() var vp = viewport() var pw = Math.round(Math.max(160, Math.min(300, r.width * 0.72))) danmakuPanel.style.width = pw + 'px' var x = state.h === 'left' ? r.left : r.right - pw x = Math.min(Math.max(4, x), Math.max(4, vp.w - pw - 4)) var y = Math.max(4, r.top - danmakuPanel.offsetHeight - 8) danmakuPanel.style.left = x + 'px' danmakuPanel.style.top = y + 'px' } catch (err) {} } /* ---- 自定义源:菜单内直填(POST /sources.json 热重载;仅生产模式) ---- */ var rebuildSrcList = null if (!DEMO) { var SRC_AUTH = [['none', '无密钥'], ['bearer', 'Bearer 头'], ['header', 'X-API-Key 头']] var srcToggle = document.createElement('button') srcToggle.type = 'button'; srcToggle.className = 'dshmv-srcbtn dshmv-src-toggle'; srcToggle.textContent = '+ 自定义源' var srcPanel = document.createElement('div'); srcPanel.className = 'dshmv-srcpanel'; srcPanel.style.display = 'none' function srcIn(ph) { var i = document.createElement('input'); i.className = 'dshmv-srcin'; i.placeholder = ph; return i } function srcRow(label, el) { var r = menuRow(); r.appendChild(menuLabel(label)); r.appendChild(el); return r } var srcNameI = srcIn('名称,如 Kimi 开放平台') var srcUrlI = srcIn('接口 URL,https://…') var srcTypeS = document.createElement('select'); srcTypeS.className = 'dshmv-sound' srcTypeS.appendChild(soundOpt('used_percent', '给「已用百分比」')) srcTypeS.appendChild(soundOpt('used_value', '给「已用量 + 上限」')) srcTypeS.appendChild(soundOpt('remain_money', '给「剩余金额 + 总额」')) var srcPathI = srcIn('数字路径,如 data.used') var srcLimitI = srcIn('上限/总额路径(后两种要填)') var srcLimitRow = srcRow('上限', srcLimitI) var srcResetI = srcIn('重置时间路径(选填)') var srcAuthS = document.createElement('select'); srcAuthS.className = 'dshmv-sound' SRC_AUTH.forEach(function (a) { srcAuthS.appendChild(soundOpt(a[0], a[1])) }) var srcKeyI = srcIn('密钥引用名(填 key 的环境变量名)') var srcStatus = document.createElement('div'); srcStatus.className = 'dshmv-srcstatus' var srcTestB = document.createElement('button'); srcTestB.type = 'button'; srcTestB.className = 'dshmv-srcbtn dshmv-srcbtn-ghost'; srcTestB.textContent = '测试' var srcAddB = document.createElement('button'); srcAddB.type = 'button'; srcAddB.className = 'dshmv-srcbtn'; srcAddB.textContent = '添加' var srcBtnRow = menuRow(); srcBtnRow.appendChild(srcTestB); srcBtnRow.appendChild(srcAddB) srcPanel.appendChild(srcRow('名称', srcNameI)) srcPanel.appendChild(srcRow('接口', srcUrlI)) srcPanel.appendChild(srcRow('类型', srcTypeS)) srcPanel.appendChild(srcRow('数字', srcPathI)) srcPanel.appendChild(srcLimitRow) srcPanel.appendChild(srcRow('重置', srcResetI)) srcPanel.appendChild(srcRow('密钥方式', srcAuthS)) srcPanel.appendChild(srcRow('密钥引用', srcKeyI)) srcPanel.appendChild(srcBtnRow) srcPanel.appendChild(srcStatus) var srcListWrap = document.createElement('div'); srcListWrap.className = 'dshmv-srclist' var srcHead = menuRow(); srcHead.appendChild(srcToggle) menuBox.appendChild(srcHead) menuBox.appendChild(srcListWrap) menuBox.appendChild(srcPanel) srcToggle.addEventListener('click', function () { srcPanel.style.display = srcPanel.style.display === 'none' ? '' : 'none'; syncLimitRow() }) srcTypeS.addEventListener('change', syncLimitRow) function syncLimitRow() { srcLimitRow.style.display = (srcTypeS.value === 'used_percent') ? 'none' : '' } syncLimitRow() function srcPayload() { return { name: srcNameI.value.trim(), url: srcUrlI.value.trim(), mtype: srcTypeS.value, path: srcPathI.value.trim(), limit_path: srcLimitI.value.trim(), reset_path: srcResetI.value.trim(), key_ref: srcKeyI.value.trim(), authType: srcAuthS.value, } } function srcPost(action) { return fetch(API.sources, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, source: srcPayload() }) }) .then(function (r) { return r.json() }).catch(function () { return { ok: false, error: '网络请求失败' } }) } function srcFail(d) { srcStatus.textContent = '✗ ' + (d && d.error ? d.error : '失败') } srcTestB.addEventListener('click', function () { srcStatus.textContent = '测试中…' srcPost('test').then(function (d) { if (d && d.ok) srcStatus.textContent = '✓ 成功,已用 ' + d.used + '%' + (d.resetsAt ? ',重置 ' + String(d.resetsAt).slice(0, 10) : '') else srcFail(d) }) }) srcAddB.addEventListener('click', function () { srcStatus.textContent = '添加中…' srcPost('add').then(function (d) { if (d && d.ok) { srcStatus.textContent = '已添加 ✓ 见上方档位列表' applySources(d.sources) WINDOW_KEYS.forEach(function (k) { if (winOrder.indexOf(k) === -1) winOrder.push(k) }) sanitizeCur(); refreshWinRows(); saveConfig(); render(); rebuildSrcList(); refresh(true) } else srcFail(d) }) }) rebuildSrcList = function () { srcListWrap.innerHTML = '' WINDOW_KEYS.forEach(function (k) { if (k.indexOf('opencode:') === 0) return var r = document.createElement('div'); r.className = 'dshmv-srci' var lb = document.createElement('span'); lb.textContent = WINDOW_LABEL[k] || k var del = document.createElement('button'); del.type = 'button'; del.className = 'dshmv-src-del'; del.textContent = '✕' r.appendChild(lb); r.appendChild(del) srcListWrap.appendChild(r) del.addEventListener('click', function () { srcStatus.textContent = '删除中…' fetch(API.sources, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'remove', id: k }) }) .then(function (r) { return r.json() }).catch(function () { return { ok: false, error: '网络请求失败' } }) .then(function (d) { if (d && d.ok) { // 先剔除 winOrder(否则 applySources 重建列表时会把刚删的键又贴回去) winOrder = winOrder.filter(function (x) { return x !== k }) applySources(d.sources) if (!winOrder.length) winOrder = WINDOW_KEYS.slice() sanitizeCur(); refreshWinRows(); saveConfig(); render(); rebuildSrcList(); refresh(true) srcPanel.style.display = 'none' } else srcFail(d) }) }) }) } } document.body.appendChild(menuBox) /* ---- 气泡 SVG:淡蓝白卡片 + 灿金细线 + 剩余额度环 ---- */ var RING_CX = 454, RING_CY = 266, RING_R = 118 var RING_C = 2 * Math.PI * RING_R function ringArc(wants) { var arc = document.createElementNS('http://www.w3.org/2000/svg', 'circle') arc.setAttribute('class', 'dshmv-ringarc') arc.setAttribute('cx', RING_CX); arc.setAttribute('cy', RING_CY); arc.setAttribute('r', RING_R) arc.setAttribute('fill', 'none'); arc.setAttribute('stroke', '#c9a65a') arc.setAttribute('stroke-width', '12'); arc.setAttribute('stroke-linecap', 'round') arc.setAttribute('transform', 'rotate(-90 ' + RING_CX + ' ' + RING_CY + ')') arc.setAttribute('stroke-dasharray', '0 ' + RING_C.toFixed(1)) arc.setAttribute('stroke-dashoffset', '0') return arc } var ringEl = ringArc() var textBox = document.createElement('div') textBox.className = 'dshmv-text' var labelEl = document.createElement('div') labelEl.className = 'dshmv-label' var amountEl = document.createElement('div') amountEl.className = 'dshmv-amount' var hintEl = document.createElement('div') hintEl.className = 'dshmv-hint' textBox.appendChild(labelEl); textBox.appendChild(amountEl); textBox.appendChild(hintEl) var bubbleBox = document.createElement('div') bubbleBox.className = 'dshmv-bubble' /* 注:innerHTML 为完全静态的 SVG 模板(仅拼接数字常量 RING_CX/CY/R), 不含任何外部/用户数据,无注入面;动态变化的圆环用 createElementNS 独立追加。 */ bubbleBox.innerHTML = '' + '' + '' + '' + '' + '' + '' bubbleBox.querySelector('svg').appendChild(ringEl) bubbleBox.appendChild(textBox) bubbleBox.addEventListener('click', function (e) { e.stopPropagation() if (!bubbleShown) return if (randomActive) { // 已在随机台词态:再点关闭 hideBubble() return } bubbleClickCount++ if (bubbleClickCount === 1) { // 第一次点击:切换窗口(月→周→滚动) cycleWindow() } else if (bubbleClickCount === 2) { // 第二次点击:随机台词(若门控/加载态导致无可用台词,保持数据气泡并重试) var lines = pickRandomLines() if (!lines) { bubbleClickCount-- refresh(true) return } randomActive = true randomLines = lines swapBubbleContent(function () { render() }) } else { hideBubble() } }) /* 三联仪表容器(A)+ 紧急告警角标(B) */ var tripleBox = document.createElement('div') tripleBox.className = 'dshmv-triplebox' bubbleBox.appendChild(tripleBox) function shortReset(resetsAt) { var s = secsToReset(resetsAt) if (s === null) return '--' if (s <= 0) return '已重置' var d = Math.floor(s / 86400) var h = Math.floor((s % 86400) / 3600) if (d >= 1) return d + '天' + h + '时' var m = Math.floor((s % 3600) / 60) return h + '时' + m + '分' } function renderTriple() { if (viewMode !== 'triple' || state.windows === null) return /* 注:innerHTML 仅由数字/固定模板拼装(同气泡 SVG);标签取自自家 sources.json,无外部数据注入面 */ var rows = [] for (var i = 0; i < winOrder.length; i++) { var k = winOrder[i] var win = state.windows[k] if (!win) continue var used = typeof win.used === 'number' ? Math.round(win.used) : null var remain = used === null ? null : Math.max(0, 100 - used) var hcol = used === null ? '#c9a65a' : healthOf(remain).c var barW = used === null ? 0 : Math.min(100, used) // 用量条 = 已用%(>100% 满条红示警) rows.push({ k: k, used: used, hcol: hcol, barW: barW }) } /* 布局:统一单列(短标签+大进度条+百分比,条最宽);仅 ≥7 档转双列紧凑 */ var grid = rows.length >= 7 tripleBox.classList.toggle('dshmv-tgrid', grid) var s = '' for (var j = 0; j < rows.length; j++) { var r = rows[j] s += '
' + '' + (SHORT[r.k] || WINDOW_LABEL[r.k] || r.k) + '' + '' + '' + (r.used === null ? '--' : r.used + '%') + '' + '
' } tripleBox.innerHTML = s } var badgeEl = document.createElement('div') badgeEl.className = 'dshmv-badge' var badgeAlarm = null badgeEl.addEventListener('click', function (e) { e.stopPropagation() if (badgeAlarm) { state.cur = badgeAlarm.k shownWin = null; shownVal = null sanitizeCur() showBubble() refresh(true) } }) root.appendChild(badgeEl) function alarmState() { var high = null, near = null for (var i = 0; i < winOrder.length; i++) { var k = winOrder[i] var w = state.windows && state.windows[k] if (!w) continue if (/rolling/.test(k)) continue // 滚动档 5h 周期,禁用角标(否则恒亮金点) if (high === null && typeof w.used === 'number' && w.used >= ALARM_USED) high = k if (near === null) { var s = secsToReset(w.resetsAt); if (s !== null && s > 0 && s <= ALARM_RESET_SEC) near = k } } return high ? { k: high, badge: 'dshmv-badge-danger' } : (near ? { k: near, badge: 'dshmv-badge-warn' } : null) } function updateBadge() { if (!badgeEl) return var usable = state.status === 'ok' && state.windows !== null var a = usable ? alarmState() : null badgeAlarm = a if (!a) { badgeEl.style.display = 'none'; return } badgeEl.style.display = 'block' badgeEl.className = 'dshmv-badge ' + a.badge } var body = document.createElement('div') body.className = 'dshmv-body' body.appendChild(img) body.appendChild(bubbleBox) root.appendChild(body) root.appendChild(menuBtn) document.body.appendChild(root) /* ---------------- 状态 ---------------- */ var state = { scale: 1.5, h: 'right', hOff: 0, v: 'bottom', vOff: 0, left: 0, top: 0, windows: null, cur: null, stale: false, status: 'loading', message: '' } /* 额度选项:启用档的有序列表(第一位=默认显示);viewMode: single 单档 / triple 三联 */ var winOrder = [] // 默认空,applySources 后 = 全部可用档;config 加载后可能被覆盖 var viewMode = 'single' var ALARM_USED = 85 // 已用 ≥85% → 红点告警 var ALARM_RESET_SEC = 6 * 3600 // 距重置 <6h → 金点告警 // SHORT 已在顶部随 applySources 动态填充 function sanitizeCur() { if (winOrder.indexOf(state.cur) === -1) state.cur = (winOrder[0] || null) } var busy = false var settleTimer = null var animDelayTimer = null var drag = null var shownWin = null var shownVal = null var animId = null var bubbleShown = false var bubbleTimer = null var randomActive = false var randomLines = null var tickTimer = null var talkTimer = null var bubbleClickCount = 0 /* ---------------- 台词(定稿版,含时间门控) ---------------- */ var BUBBLE_STYLE_CLASS = { A: 'dshmv-label', B: 'dshmv-amount', C: 'dshmv-hint' } function pickOne(arr) { return arr[Math.floor(Math.random() * arr.length)] } function singleCenter(style, text, wrap) { // 台词长句默认启用自动换行 + 适配字号,防止横向溢出气泡 return [null, { t: text, s: style, w: wrap === undefined ? true : !!wrap }, null] } function secsToReset(resetsAt) { if (!resetsAt) return null var t = new Date(resetsAt).getTime() - Date.now() return Math.floor(t / 1000) } function anyNearReset(h) { var win = state.windows && state.windows[h] var s = win ? secsToReset(win.resetsAt) : null return s !== null && s <= 24 * 3600 } function maxUsed() { var m = 0 if (state.windows) { for (var k in state.windows) { var u = state.windows[k].used if (typeof u === 'number' && u > m) m = u } } return m } function greetNow() { var h = new Date().getHours() // 北京时间(本机时区);DSH 运行在本机 if (h >= 5 && h < 11) return '早上好' if (h >= 11 && h < 13) return '中午好' if (h >= 13 && h < 18) return '下午好' if (h >= 18 && h < 23) return '晚上好' return '夜深了' } function resetText(resetsAt) { var s = secsToReset(resetsAt) if (s === null) return '重置时间未知' if (s <= 0) return '刚刚重置' var d = Math.floor(s / 86400) var h = Math.floor((s % 86400) / 3600) var m = Math.floor((s % 3600) / 60) var sec = s % 60 if (d >= 1) return '重置 ' + d + '天' + h + '小时后' if (h >= 1) return '重置 ' + h + '小时' + String(m).padStart(2, '0') + '分后' if (m >= 1) return '重置 ' + m + '分' + String(sec).padStart(2, '0') + '秒后' return '即将重置' } /* 每组返回可选的台词条目(带 when 门控);返回 null 表示该条当前不成立 */ function group2() { return [ { w: 1, lines: function () { return singleCenter('B', '频率稳定,额度余量一切正常。') } }, { w: 1, lines: function () { return singleCenter('B', '观测窗口已同步,数据实时更新中。') } }, { w: 1, lines: function () { return singleCenter('B', '远程工位待命中——你的额度,我来盯着。') } }, { w: 1, lines: function () { return singleCenter('B', '仪器读数正常,未发现异常波动。') } }, { w: 1, lines: function () { return singleCenter('B', '观测报告:今日额度余额符合预期。') } }, { w: 1, when: function () { return anyNearReset('monthly') || anyNearReset('weekly') || anyNearReset('rolling') || maxUsed() >= 60 }, lines: function () { return singleCenter('B', '放心,重置前我会一直守着这个窗口。') } }, ] } function group3() { return [ { w: 1, lines: function () { return singleCenter('B', '连轴转可不行的……这次正好,把自己放个假。') } }, { w: 1, lines: function () { return singleCenter('B', '频率流失那种检修,交给我就行。你只管好好写代码。') } }, { w: 1, lines: function () { return singleCenter('B', '别担心,这种程度我自己就可以处理。唔……') } }, { w: 1, lines: function () { return singleCenter('B', '想看星空的话,等你忙完,我陪你去做观星活动。') } }, { w: 1, lines: function () { return singleCenter('B', '可以的话,记得给我带一袋多加糖的咖啡——') } }, { w: 1, lines: function () { return singleCenter('B', '进度不急着赶,十五分钟的闲聊时间,我帮你看着。') } }, { w: 1, lines: function () { return singleCenter('B', '好了,工作处理完了。' + greetNow() + ',前辈。') } }, { w: 1, lines: function () { return [null, { t: '前辈……', s: 'B' }, null] } }, ] } function group4() { return [ { w: 1, lines: function () { return singleCenter('B', '这么贵都不打折,前辈你说两句好听的呗。') } }, { w: 1, lines: function () { return singleCenter('B', 'token 烧太快,月末要跟胖海豚一起吃土。') } }, { w: 1, lines: function () { return singleCenter('B', '要不要我顺手给你画张趋势图?反正也是观测。') } }, { w: 1, lines: function () { return singleCenter('B', '额度这东西,跟虚质磁暴一样,说不准哪天就爆了。') } }, { w: 1, lines: function () { return singleCenter('B', '我看不懂人类的用量,但我大受震撼。') } }, ] } function group5() { return [ { w: 1, lines: function () { return singleCenter('B', '蓝色星空之下,我们终会抵达天空海之外——') } }, { w: 1, lines: function () { return singleCenter('B', '密码只能输入一次,强行破解的话……风险未知。') } }, { w: 1, when: function () { return maxUsed() >= 90 }, lines: function () { return singleCenter('B', '根据教职工守则,超限复盘报告我是可以不写的——但会盯着前辈写完。') } }, { w: 1, lines: function () { return singleCenter('B', '所以,糖加够的咖啡,永远是深夜最合适的思维补充剂。') } }, ] } var GROUP_POOLS = [ { w: 12, g: group2 }, { w: 10, g: group3 }, { w: 6, g: group4 }, { w: 1, g: group5 } ] function pickRandomLines() { // 加载中 / 断线 / 失败态:禁播随机台词 if (state.status === 'loading' || state.status === 'error') return null // 展平全部条目并做门控过滤 var entries = [] for (var i = 0; i < GROUP_POOLS.length; i++) { var items = GROUP_POOLS[i].g() for (var j = 0; j < items.length; j++) { var it = items[j] if (it.when && !it.when()) continue entries.push({ w: GROUP_POOLS[i].w * it.w, lines: it.lines }) } } if (entries.length === 0) return null var total = 0 for (var k = 0; k < entries.length; k++) total += entries[k].w var r = Math.random() * total for (var k2 = 0; k2 < entries.length; k2++) { r -= entries[k2].w if (r < 0) return entries[k2].lines() } return entries[entries.length - 1].lines() } /* ---------------- 渲染 ---------------- */ function healthOf(remain) { if (remain >= 50) return { c: '#c9a65a', t: '#3d5a8f' } if (remain >= 25) return { c: '#d09a3f', t: '#b57f2a' } return { c: '#d04a4a', t: '#d04a4a' } } function fmtPct(n) { return Math.round(n) + '%' } function currentWin() { return state.windows && state.windows[state.cur] ? state.windows[state.cur] : null } function setRing(remain, color) { if (!bubbleBox.classList.contains('dshmv-hide-ring')) { var len = Math.max(0, Math.min(1, remain / 100)) * RING_C ringEl.setAttribute('stroke', color) ringEl.setAttribute('stroke-dasharray', len.toFixed(1) + ' ' + RING_C.toFixed(1)) } } /* C:主档 + 其余档小字速览 */ function glanceText() { if (winOrder.length < 2 || viewMode === 'triple') return '' var parts = [] for (var i = 0; i < winOrder.length; i++) { var k = winOrder[i] if (k === state.cur) continue var w = state.windows && state.windows[k] var u = w ? w.used : null parts.push(SHORT[k] + ' ' + (typeof u === 'number' ? Math.round(u) + '%' : '--')) } return parts.length ? ' ' + parts.join(' · ') : '' } function fmtNum(n) { var x = Number(n) if (!Number.isFinite(x)) return '--' return Number.isInteger(x) ? String(x) : String(Math.round(x * 100) / 100) } function buildHint(win) { if (!win) return '' var u = win.used if (typeof u !== 'number') return '重置时间未知' + glanceText() var h = '剩余 ' + fmtPct(100 - u) + ' · ' + resetText(win.resetsAt) var ex = win.extra if (ex && (ex.type === 'remain_money' || ex.type === 'used_value') && typeof ex.raw === 'number') { var unit = typeof ex.unit === 'string' ? ex.unit : '' h += ' · ' + (ex.type === 'remain_money' ? '剩' : '已用') + ' ' + unit + fmtNum(ex.raw) if (typeof ex.limit === 'number') h += ' / ' + unit + fmtNum(ex.limit) } if (state.stale) h += ' · 旧数据' return h + glanceText() } function render() { sanitizeCur() var randomShowing = !!(randomActive && randomLines) if (randomShowing) { bubbleBox.classList.remove('dshmv-mode-triple') bubbleBox.classList.add('dshmv-random') bubbleBox.classList.add('dshmv-hide-ring') applyBubbleLines(randomLines) updateBadge() return } bubbleBox.classList.remove('dshmv-random') if (viewMode === 'triple') { renderTriple() bubbleBox.classList.add('dshmv-mode-triple') bubbleBox.classList.add('dshmv-hide-ring') updateBadge() return } bubbleBox.classList.remove('dshmv-mode-triple') var win = currentWin() var amount, hint, label = (WINDOW_LABEL[state.cur] || '额度') + ' · 已用', ringPct = null, health = { c: '#c9a65a', t: '#3d5a8f' } if (state.status === 'error') { amount = shownVal !== null && shownWin === state.cur ? fmtPct(shownVal) : '--' hint = state.message ? String(state.message).slice(0, 12) : '获取失败 · 点击重试' label = (WINDOW_LABEL[state.cur] || '额度') + ' · 已用' } else if (!win || state.windows === null) { amount = shownVal !== null && shownWin === state.cur ? fmtPct(shownVal) : '…' hint = state.status === 'loading' ? '额度数据同步中…' : '等待数据…' } else { var used = win.used var remain = typeof used === 'number' ? 100 - used : null amount = remain !== null ? fmtPct(used) : '--' hint = buildHint(win) if (remain !== null) { ringPct = remain; health = healthOf(remain) } } labelEl.textContent = label amountEl.textContent = amount amountEl.style.color = health.t setHint(hint) if (ringPct === null) bubbleBox.classList.add('dshmv-hide-ring') else { bubbleBox.classList.remove('dshmv-hide-ring'); setRing(ringPct, health.c) } updateBadge() } var hintFadeTimer = null var lastHintText = null function setHint(text) { if (text === lastHintText) return lastHintText = text if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null } if (!bubbleShown) { hintEl.textContent = text; return } hintEl.style.transition = 'opacity .18s ease' hintEl.style.opacity = '0' hintFadeTimer = setTimeout(function () { hintFadeTimer = null hintEl.textContent = text hintEl.style.opacity = '1' setTimeout(function () { hintEl.style.transition = ''; hintEl.style.opacity = '' }, 220) }, 190) } var bubbleSwapTimer = null function swapBubbleContent(applyFn) { if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null } textBox.style.transition = 'opacity .18s ease' textBox.style.opacity = '0' bubbleSwapTimer = setTimeout(function () { bubbleSwapTimer = null applyFn() textBox.style.opacity = '1' setTimeout(function () { textBox.style.transition = ''; textBox.style.opacity = '' }, 220) }, 190) } function applyBubbleLines(lines) { var els = [labelEl, amountEl, hintEl] for (var i = 0; i < 3; i++) { var el = els[i] var ln = lines && lines[i] if (ln) { el.style.display = '' el.className = (BUBBLE_STYLE_CLASS[ln.s] || 'dshmv-label') + (ln.w ? ' dshmv-wrap' : '') el.textContent = ln.t el.style.color = ln.s === 'B' ? '#3d5a8f' : '' } else { el.style.display = 'none' el.textContent = '' el.style.color = '' } } } function restoreLines() { if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null } if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null } lastHintText = null textBox.style.transition = ''; textBox.style.opacity = '' labelEl.style.display = '' labelEl.className = 'dshmv-label' labelEl.style.color = '' amountEl.className = 'dshmv-amount' amountEl.style.color = '' hintEl.className = 'dshmv-hint' hintEl.style.color = '' render() } function showBubble() { if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null } bubbleShown = true randomActive = false randomLines = null bubbleClickCount = 0 restoreLines() bubbleBox.classList.add('dshmv-bubble-open') startTick() bubbleTimer = setTimeout(hideBubble, BUBBLE_MS) } function hideBubble() { if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null } if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null } if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null } stopTick() textBox.style.transition = ''; textBox.style.opacity = '' hintEl.style.transition = ''; hintEl.style.opacity = '' randomActive = false randomLines = null bubbleShown = false bubbleClickCount = 0 bubbleBox.classList.remove('dshmv-bubble-open') } /* 自言自语:随机台词自动弹出,不依赖用户点击;看完自己收起 */ function openRandomSpeak(lines) { if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null } bubbleShown = true bubbleClickCount = 0 randomActive = true randomLines = lines bubbleBox.classList.add('dshmv-bubble-open') render() stopTick() lastHintText = null bubbleTimer = setTimeout(hideBubble, RANDOM_MS) } function scheduleTalk(first) { if (talkTimer) { clearTimeout(talkTimer); talkTimer = null } var delay = first ? (18000 + Math.random() * 22000) : (60000 + Math.random() * 90000) talkTimer = setTimeout(function () { talkTimer = null // 空闲才开口:气泡未开、菜单未开、未在拖拽、数据正常 var can = !bubbleShown && !menuOpen && !(drag && drag.active) && state.status === 'ok' if (can) { var lines = pickRandomLines() if (lines) openRandomSpeak(lines) } scheduleTalk(false) }, delay) } function cycleWindow() { if (winOrder.length < 2) return var i = winOrder.indexOf(state.cur) state.cur = winOrder[(i + 1) % winOrder.length] shownWin = null shownVal = null swapBubbleContent(function () { lastHintText = null render() }) refresh(false) } /* 倒计时秒级刷新(气泡开着且未进随机台词态时) */ function startTick() { stopTick() tickTimer = setInterval(function () { if (!bubbleShown) { stopTick(); return } if (randomActive) return lastHintText = null if (viewMode === 'triple') { renderTriple(); updateBadge(); return } var win = currentWin() if (win && typeof win.used === 'number') hintEl.textContent = buildHint(win) updateBadge() }, TICK_MS) } function stopTick() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null } } /* ---------------- 位置 / 吸附 / 缩放(沿用小鲸鱼成熟机制) ---------------- */ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) } function viewport() { return { w: window.innerWidth || document.documentElement.clientWidth || 1280, h: window.innerHeight || document.documentElement.clientHeight || 800 } } function animateAmount(to, duration) { var from = (shownWin === state.cur && shownVal !== null) ? shownVal : to if (animId) cancelAnimationFrame(animId) if (!isFinite(from)) from = to if (from === to) { shownVal = to; shownWin = state.cur; amountEl.textContent = fmtPct(to); return } var startTime = null function step(ts) { if (startTime === null) startTime = ts var t = Math.min(1, (ts - startTime) / duration) var eased = 1 - Math.pow(1 - t, 3) var val = from + (to - from) * eased amountEl.textContent = fmtPct(val) if (t < 1) { animId = requestAnimationFrame(step) } else { animId = null; shownVal = to; shownWin = state.cur; amountEl.textContent = fmtPct(to) } } animId = requestAnimationFrame(step) } function express() { root.style.right = 'auto' root.style.bottom = 'auto' root.style.left = state.left + 'px' root.style.top = state.top + 'px' root.classList.toggle('dshmv-left', state.h === 'left') } function settle() { var vp = viewport() var w = root.offsetWidth || root.getBoundingClientRect().width || 0 var h = root.offsetHeight || root.getBoundingClientRect().height || 0 if (drag && drag.active) { state.left = clamp(state.left, 0, Math.max(0, vp.w - w)) state.top = clamp(state.top, 0, Math.max(0, vp.h - h)) express(); return } if (state.h === 'right') state.left = Math.max(0, vp.w - w - state.hOff) else if (state.h === 'left') state.left = state.hOff else state.left = clamp(state.left, 0, Math.max(0, vp.w - w)) if (state.v === 'bottom') state.top = Math.max(0, vp.h - h - state.vOff) else if (state.v === 'top') state.top = state.vOff else state.top = clamp(state.top, 0, Math.max(0, vp.h - h)) express() } /* ---------------- 数据刷新 ---------------- */ var mockBase = null function mockFeed() { // 演示模式:贴合真实值的固定数值(月度 29% / 每周 42% / 滚动 2% / 示例源 37.5%), // 只在首次载入生成一次、之后稳定——仅重置倒计时随时间走,数字不跳(区分真实变化)。 if (!mockBase) { var once = Date.now() mockBase = { 'opencode:monthly': { used: 29, resetInMs: 19 * 86400000 + 5 * 3600000 }, 'opencode:weekly': { used: 42, resetInMs: 3 * 86400000 + 22 * 3600000 }, 'opencode:rolling': { used: 2, resetInMs: 5 * 3600000 }, demo: { used: 37.5, resetInMs: 18 * 3600000 }, once: once } } function win(k) { var b = mockBase[k] return { used: b.used, resetsAt: new Date(mockBase.once + b.resetInMs).toISOString() } } var windows = {} WINDOW_KEYS.forEach(function (k) { if (mockBase[k]) windows[k] = win(k) }) return { ok: true, fetchedAt: Date.now(), windows: windows } } function refresh(manual) { if (busy) return busy = true if (animDelayTimer) { clearTimeout(animDelayTimer); animDelayTimer = null } if (manual || state.windows === null) { state.status = 'loading'; render() } function done(data) { var ok = data && data.ok if (ok) { state.windows = data.windows || state.windows state.stale = !!data.stale state.message = '' var win = currentWin() var newVal = win && typeof win.used === 'number' ? win.used : null var changed = shownVal !== null && shownWin === state.cur && newVal !== null && newVal !== shownVal if (changed) { if (!manual) { showBubble() state.status = 'ok' if (animDelayTimer) clearTimeout(animDelayTimer) animDelayTimer = setTimeout(function () { animDelayTimer = null animateAmount(newVal, ANIM_MS) render() }, 300) if (settleTimer) clearTimeout(settleTimer) settleTimer = setTimeout(function () { settleTimer = null render() }, CHANGE_MS + 300) } else { animateAmount(newVal, ANIM_MS) state.status = 'ok' render() } } else { if (animId === null) { shownVal = newVal; shownWin = state.cur } if (newVal !== null) { shownVal = newVal; shownWin = state.cur } state.status = 'ok' render() } } else { // 瞬时失败且之前有数据 → 沿用旧值并标记 stale(不闪错误) if (data && data.stale && state.windows) { state.stale = true state.message = '' state.status = 'ok' render() } else { state.status = 'error' state.message = (data && data.error) ? String(data.error) : '获取失败' render() } } armRefreshLoop(); checkReset() } function fail() { if (state.windows) { state.stale = true state.status = 'ok' } else { state.status = 'error' state.message = '获取失败' } render() armRefreshLoop() } if (DEMO) { setTimeout(function () { try { busy = false; done(mockFeed()) } catch (e) { window.__lastErr = 'demo-tick:' + String((e && e.stack) || e) } }, 250) return } var ctrl = null var timer = null try { ctrl = new AbortController(); timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS) } catch (err) {} fetch(API.usage, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined }) .then(function (r) { return r.json() }) .then(done) .catch(fail) .finally(function () { busy = false; if (timer) clearTimeout(timer) }) } /* ---------------- 重置仪式感 + 临近加速刷新 ---------------- */ var FAST_REFRESH_MS = 20000 var resetWatch = null var refreshTimer = null function nearReset() { if (!state.windows) return false for (var k in state.windows) { var w = state.windows[k]; if (!w) continue var s = secsToReset(w.resetsAt) if (s !== null && s > 0 && s <= 3600) return true } return false } function armRefreshLoop() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null } refreshTimer = setInterval(function () { refresh(false) }, nearReset() ? FAST_REFRESH_MS : REFRESH_MS) } function checkReset() { var now = Date.now() if (!resetWatch) resetWatch = {} for (var k in state.windows) { var w = state.windows[k]; if (!w) continue var r = w.resetsAt ? new Date(w.resetsAt).getTime() : null var prev = resetWatch[k] if (typeof prev === 'number' && prev > 0 && prev <= now && r !== null && r !== prev) { resetWatch[k] = r ceremonyReset(k) return } resetWatch[k] = r } } function ceremonyReset(key) { if (DEMO) { resetWatch = {}; return } stopTick() showBubble() lastHintText = null var lines = ['额度刷新啦!', '重置完成,重新出发~', '新额度已到账 🎉'] var line = lines[Math.floor(Math.random() * lines.length)] randomLines = singleCenter('B', line, true) randomActive = true render() if (talkTimer) clearTimeout(talkTimer) talkTimer = setTimeout(function () { randomLines = null randomActive = false render() scheduleTalk(true) }, RANDOM_MS + 800) refresh(true) } /* ---------------- 配置 / 音效 / 菜单 ---------------- */ var soundOn = true var soundVol = 0.9 var soundSet = 'tone' function saveConfig() { try { var payload = { scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, windows: winOrder.slice(), viewMode: viewMode, h: state.h, hOff: state.hOff, v: state.v, vOff: state.vOff, danmaku: danmakuOn } if (DEMO) { try { localStorage.setItem('dshmp-demo-config', JSON.stringify(payload)) } catch (err) {} return } fetch(API.config, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) } catch (err) {} } function scaleToDisplay(s) { return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1 } function setScale(v) { var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number(v))) * 10) / 10 var rect = root.getBoundingClientRect() var fx = state.h === 'left' ? rect.left : rect.right var fy = rect.bottom state.scale = next root.style.setProperty('--dshmv-scale', String(next)) scaleInput.value = String(next) scaleNumber.value = String(scaleToDisplay(next)) saveConfig() var r2 = root.getBoundingClientRect() var vp = viewport() if (state.h === 'left') state.left = Math.min(Math.max(fx, 0), Math.max(0, vp.w - r2.width)) else state.left = Math.min(Math.max(fx - r2.width, 0), Math.max(0, vp.w - r2.width)) state.top = Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height)) express() } function setVol(v) { var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100 soundVol = next soundOn = next > 0 volInput.value = String(next) volPct.textContent = Math.round(next * 100) + '%' try { if (pressAudio) pressAudio.volume = next; if (releaseAudio) releaseAudio.volume = next } catch (err) {} saveConfig() } function setSoundToggle(v) { soundOn = v !== 'off' if (soundOn) { soundSet = v applySoundSet() } soundSelect.value = soundOn ? soundSet : 'off' if (!soundOn) { volInput.value = '0'; volPct.textContent = '0%' } saveConfig() } var SQUISH = 'scaleY(0.88) scaleX(1.05)' var pressAudio = null var releaseAudio = null var pressing = false var pressEnded = false var releasePlayed = false var releaseTimer = null function applySoundSet() { if (DEMO || !API.press) return try { pressAudio = new Audio(API.press + soundSet + '.wav') pressAudio.preload = 'auto' pressAudio.volume = soundVol releaseAudio = new Audio(API.release + soundSet + '.wav') releaseAudio.preload = 'auto' releaseAudio.volume = soundVol } catch (err) {} } function playPress() { if (!pressAudio || !soundOn) return try { if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null } if (releaseAudio) { releaseAudio.pause(); releaseAudio.currentTime = 0 } pressEnded = false releasePlayed = false pressAudio.onended = function () { pressEnded = true if (!pressing && !releasePlayed) playRelease() } pressAudio.currentTime = 0 var p = pressAudio.play() if (p && typeof p.catch === 'function') p.catch(function () {}) } catch (err) {} } function playRelease() { if (releasePlayed || !releaseAudio || !soundOn) return releasePlayed = true try { releaseAudio.currentTime = 0 var p = releaseAudio.play() if (p && typeof p.catch === 'function') p.catch(function () {}) } catch (err) {} } function pressDown() { body.style.transform = SQUISH pressing = true playPress() } function pressUp() { body.style.transform = 'scaleY(1) scaleX(1)' pressing = false if (pressEnded) { playRelease(); return } var durKnown = false var remainMs = 0 try { var dur = pressAudio ? pressAudio.duration : 0 if (isFinite(dur) && dur > 0) { durKnown = true; remainMs = (dur - pressAudio.currentTime) * 1000 } } catch (err) {} if (durKnown) { releaseTimer = setTimeout(function () { releaseTimer = null; playRelease() }, Math.max(0, remainMs - 100)) } } var menuOpen = false function toggleMenuAt(x, y) { menuOpen = !menuOpen if (menuOpen) positionMenuAt(x, y) menuBox.classList.toggle('dshmv-menu-open', menuOpen) menuBtn.classList.toggle('dshmv-menu-btn-visible', menuOpen) } function closeMenu() { menuOpen = false menuBox.classList.remove('dshmv-menu-open') menuBtn.classList.remove('dshmv-menu-btn-visible') root.style.transition = '' snapCheck() } function snapCheck() { var rect = root.getBoundingClientRect() var vp = viewport() var w = rect.width, h = rect.height var left = rect.left, top = rect.top var centerX = left + w / 2, centerY = top + h / 2 var moved = false if (centerX < vp.w / 4) { state.h = 'left'; state.hOff = 0; left = 0; moved = true } else if (centerX > vp.w * 3 / 4) { state.h = 'right'; state.hOff = 0; left = vp.w - w; moved = true } else { state.h = null; state.hOff = left } if (centerY < vp.h / 4) { state.v = 'top'; state.vOff = 0; top = 0; moved = true } else { state.v = 'bottom'; state.vOff = Math.max(0, vp.h - top - h) } if (moved) { state.left = left; state.top = top; settle() } } function positionMenuAt(x, y) { try { var wOff = menuBox.offsetWidth || 176 var hOff = menuBox.offsetHeight || 120 var vp = viewport() var left = Math.min(Math.max(0, x), Math.max(0, vp.w - wOff - 6)) var top = y + 10 if (top + hOff > vp.h - 6) top = Math.max(6, y - hOff - 10) menuBox.style.left = left + 'px' menuBox.style.right = 'auto' menuBox.style.bottom = 'auto' menuBox.style.top = top + 'px' menuBox.style.transformOrigin = 'top left' } catch (err) {} } /* ---------------- 点击判定(alpha 命中,精确到像素) ---------------- */ var hitCanvas = null var hitReady = false var hitW = 100, hitH = 100 function setupHitTest() { try { var probe = new Image() probe.onload = function () { try { hitW = probe.naturalWidth || probe.width || 100 hitH = probe.naturalHeight || probe.height || 100 hitCanvas = document.createElement('canvas') hitCanvas.width = hitW; hitCanvas.height = hitH hitCanvas.getContext('2d').drawImage(probe, 0, 0) hitReady = true } catch (err) {} } probe.onerror = function () {} probe.src = API.pet } catch (err) {} } function isPetHit(e) { if (!hitCanvas || !hitReady) return true try { var r = img.getBoundingClientRect() if (!r || r.width <= 0 || r.height <= 0) return false var lx = (e.clientX - r.left) / r.width * hitW var ly = (e.clientY - r.top) / r.height * hitH if (lx < 0 || ly < 0 || lx >= hitW || ly >= hitH) return false if (state.h === 'left') lx = hitW - lx var data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data return data[3] > 10 } catch (err) { return true } } function onDocPointerDown(e) { if (e.target && e.target.closest) { if (e.target.closest('.dshmv-bubble') || e.target.closest('.dshmv-menu') || e.target.closest('.dshmv-menu-btn')) return } if (menuOpen) { closeMenu(); return } if (e.button !== 0 && e.pointerType === 'mouse') return if (!isPetHit(e)) return try { e.preventDefault(); e.stopPropagation() } catch (err) {} var vp = viewport() var rect = root.getBoundingClientRect() drag = { active: true, startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, w: rect.width, h: rect.height, moved: false, vp: vp } root.classList.add('dshmv-dragging') pressDown() setWidgetCursor('grabbing') document.addEventListener('pointermove', onDocPointerMove, true) document.addEventListener('pointerup', onDocPointerUp, true) document.addEventListener('pointercancel', onDocPointerCancel, true) document.addEventListener('click', onDocClickStopper, true) } function onDocPointerMove(e) { if (!drag || !drag.active) return var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY if (dx * dx + dy * dy >= CLICK_SQ) drag.moved = true state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w)) state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h)) express() } function onDocPointerUp(e) { endDrag(e, true) } function onDocPointerCancel(e) { endDrag(e, false) } function onDocClickStopper(e) { try { e.preventDefault(); e.stopPropagation() } catch (err) {} } document.addEventListener('pointerdown', onDocPointerDown, true) var widgetCursor = '' function setWidgetCursor(v) { if (v !== widgetCursor) { widgetCursor = v; try { document.body.style.cursor = v } catch (err) {} } } function onDocPointerMoveCursor(e) { if (drag && drag.active) { setWidgetCursor('grabbing'); return } var over = isPetHit(e) setWidgetCursor(over ? 'grab' : '') } document.addEventListener('pointermove', onDocPointerMoveCursor, true) /* 右键在莫宁/气泡上出菜单;Esc 关闭 */ function onDocContext(e) { var el = null try { el = e.target } catch (err) {} if (el && el.closest && el.closest('.dshmv-menu')) { try { e.preventDefault() } catch (err) {} return } var onTarget = el && el.closest && (el.closest('.dshmv-bubble') || el.closest('.dshmv-menu-btn')) if (!onTarget && !isPetHit(e)) return try { e.preventDefault(); e.stopPropagation() } catch (err) {} toggleMenuAt(e.clientX || 0, e.clientY || 0) } document.addEventListener('contextmenu', onDocContext, true) document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && menuOpen) closeMenu() }) function endDrag(e, clickAllowed) { if (!drag || !drag.active) return drag.active = false document.removeEventListener('pointermove', onDocPointerMove, true) document.removeEventListener('pointerup', onDocPointerUp, true) document.removeEventListener('pointercancel', onDocPointerCancel, true) document.removeEventListener('click', onDocClickStopper, true) pressUp() root.classList.remove('dshmv-dragging') setWidgetCursor(isPetHit(e) ? 'grab' : '') if (clickAllowed && !drag.moved) { showBubble(); refresh(true); return } var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w)) var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h)) var centerX = left + drag.w / 2, centerY = top + drag.h / 2 if (centerX < drag.vp.w / 4) { state.h = 'left'; state.hOff = 0 } else if (centerX > drag.vp.w * 3 / 4) { state.h = 'right'; state.hOff = 0 } else { state.h = null; state.hOff = left } if (centerY < drag.vp.h / 4) { state.v = 'top'; state.vOff = 0 } else if (centerY > drag.vp.h * 3 / 4) { state.v = 'bottom'; state.vOff = 0 } else { state.v = null; state.vOff = top } state.left = left; state.top = top settle() } window.addEventListener('resize', function () { settle() }) /* ---------------- 初始化 ---------------- */ window.__lastErr = null var rect0 = root.getBoundingClientRect() state.left = rect0.left state.top = rect0.top try { express(); render(); applySoundSet(); setupHitTest() } catch (e) { window.__lastErr = 'init-blk:' + String((e && e.stack) || e) } function loadConfig(d) { if (d && typeof d.scale === 'number' && d.scale >= MIN_SCALE - 0.1 && d.scale <= MAX_SCALE + 0.1) { state.scale = d.scale root.style.setProperty('--dshmv-scale', String(d.scale)) scaleInput.value = String(d.scale) scaleNumber.value = String(scaleToDisplay(d.scale)) settle() } if (d && typeof d.vol === 'number') { soundVol = d.vol soundOn = soundVol > 0 volInput.value = String(soundVol) volPct.textContent = Math.round(soundVol * 100) + '%' try { if (pressAudio) pressAudio.volume = soundVol; if (releaseAudio) releaseAudio.volume = soundVol } catch (err) {} } if (d && d.sound === false) soundOn = false if (d && d.soundSet && ['tone', 'chime', 'click', 'pop', 'blip', 'thock'].indexOf(d.soundSet) !== -1) { soundSet = d.soundSet if (soundOn) applySoundSet() } /* 额度档位:勾选集合 + 显示顺序(第一位=默认);非法项剔除,至少保留 1 个 */ if (d && Array.isArray(d.windows) && d.windows.length) { var next = [] d.windows.forEach(function (k) { if (WINDOW_KEYS.indexOf(k) !== -1 && next.indexOf(k) === -1) next.push(k) }) if (next.length) { WINDOW_KEYS.forEach(function (k) { if (next.indexOf(k) === -1) next.push(k) }) winOrder = next } } if (d && (d.viewMode === 'triple' || d.viewMode === 'single')) viewMode = d.viewMode /* 位置记忆:恢复吸附方位/偏移(settle 换算回像素并落位) */ if (d && (d.h === 'left' || d.h === 'right' || d.h === null)) state.h = d.h if (d && typeof d.hOff === 'number' && isFinite(d.hOff) && d.hOff >= 0) state.hOff = d.hOff if (d && (d.v === 'top' || d.v === 'bottom' || d.v === null)) state.v = d.v if (d && typeof d.vOff === 'number' && isFinite(d.vOff) && d.vOff >= 0) state.vOff = d.vOff settle() if (d && typeof d.danmaku === 'boolean') danmakuOn = d.danmaku danmakuSwitch.checked = danmakuOn if (danmakuOn && !DEMO) startDanmaku(); else stopDanmaku() viewSelect.value = viewMode refreshWinRows() sanitizeCur() soundSelect.value = soundOn ? soundSet : 'off' } function initConfig() { try { if (DEMO) { applySources(DEMO_SOURCES) try { var raw = localStorage.getItem('dshmp-demo-config') if (raw) loadConfig(JSON.parse(raw)) } catch (err) {} refresh(false) return } // 先拉源描述(决定档位/标签),再拉配置,再拉数据 fetch(API.sources, { cache: 'no-store' }) .then(function (r) { return r.json() }) .then(function (d) { applySources(d && d.sources) }) .catch(function () { applySources(null) }) .then(function () { return fetch(API.config, { cache: 'no-store' }).then(function (r) { return r.json() }) }) .then(loadConfig) .catch(function () {}) .then(function () { refresh(false) }) } catch (e) { window.__lastErr = 'initConfig:' + String((e && e.stack) || e) } } initConfig() scheduleTalk(true) armRefreshLoop() /* 调试句柄(生产无害) */ window.__dshmv = { state: state, DEMO: DEMO, mockFeed: mockFeed, render: render, refresh: refresh, API: API, ceremonyReset: ceremonyReset, armRefreshLoop: armRefreshLoop, checkReset: checkReset, nearReset: nearReset, danmakuList: danmakuList, setDanmaku: setDanmaku, pollDanmaku: pollDanmaku } })()