/** * 趴趴鲸鱼娘 dsh-whale-lap —— 浏览器表现层(纯 DOM 注入,零依赖)。 * * 做的事情: * 1. 经 BASE 取 whale-girl.svg 并内联进页面(内联才能让 CSS 动画作用于 SVG); * 2. 找到 DSH 的对话框(composer),让她「趴」在对话框上沿; * 3. 轮询 DSH 状态(工作中/思考/成功/报错)联动表情与台词; * 4. 拖拽、单击、三连击、右键菜单、主动闲聊、自动打盹。 * * 全部状态保存在 localStorage(键名 whale-lap:*),无外部请求、无遥测。 */ (function () { "use strict"; if (window.__dshWhaleLapBooted) return; window.__dshWhaleLapBooted = true; var BASE = window.DSH_WHALE_LAP_BASE || "/api/dsh-whale-lap/assets?f="; var doc = document; var win = window; /* ================= 配置 ================= */ var CFG = { sizeW: 196, // 趴姿基准宽度(px) aspect: 320 / 170, // SVG 宽高比,高度 = 宽度 / aspect overlap: 15, // 身体与对话框上沿的重叠像素(「趴在上面」) gapRight: 10, // 距对话框右缘 pollMs: 700, // 状态轮询间隔 layoutMs: 1500, // 兜底重排间隔 minDrag: 5, // 拖拽判定阈值 idleSleepMs: 180000, // 安静多久自动打盹 sleepMs: 40000, // 打盹时长 nightStart: 23, // 深夜安静时段起点(时) nightEnd: 7, // 深夜安静时段终点(时) nightNapMs: 60000, // 深夜安静时段:安静多久就自动打盹 }; /* ================= 偏好存储 ================= */ var KEY = "whale-lap:"; var pref = { get: function (k, d) { try { var v = localStorage.getItem(KEY + k); return v === null ? d : v; } catch (e) { return d; } }, set: function (k, v) { try { localStorage.setItem(KEY + k, String(v)); } catch (e) { /* storage 不可用 */ } }, }; /* ================= DSH 状态信号 ================= */ // 与 dsh-whale-musume 同源的口径: // data-running / data-state="ongoing" 是 DSH 真正的工作指示;历史卡片会永久带 // data-state="running",会把人钉死在「工作中」,所以绝不拿它当信号。 var STATE_BANKS = { work: ['[data-role="tool"]', '[data-tool="true"]', '[data-tool-card="true"]', '[data-running]', '[data-state="ongoing"]', '[data-status="running"]'], think: ['[aria-busy="true"]', '[data-state="loading"]', '[data-status="pending"]'], error: ['[data-state="error"]:not([class*="turnErrorDot"])', '[data-status="error"]'], success: ['[data-state="success"]', '[data-status="success"]'], }; /* ================= 台词 ================= */ var LINES = { greet: ["呜哇——!我来啦!", "鲸鱼娘报到!今天也趴在你对话框上~", "扑通!从海里爬上来陪你写代码啦!"], idle: [ "呜~趴在对话框上好舒服~", "今天也要一起加油哦!", "写累了就摸摸我的头~", "你看我像不像一只趴在键盘上的鲸鱼?", "深呼吸——吸气——呼气——", "有人在认真工作呢,我安静陪着~", "尾巴有点痒,蹭一蹭~", "我头顶的喷泉可爱吗?嘿嘿~", ], listen: ["收到收到!", "嗯嗯,我在听~", "这个想法不错!", "打字声好有节奏感♪"], work: ["收到!开始干活啦!", "工具在跑,我帮你盯梢!", "开工!冲鸭!", "代码交给它,安心放心~"], success: ["成功啦!太棒了!", "🎉 完美收工!", "顺利通过!夸夸!", "好耶!这波稳了!"], error: ["呜…出错了…", "别灰心,我再陪你试试!", "报错不怕,重来一次!", "呜哇——!这个 bug 好凶!"], tap: ["呜喵~", "摸头好舒服…", "嘿嘿~", "再摸一下嘛~", "尾巴摇起来啦!", "唔…头发乱了啦!"], celebrate: ["三连击!星星眼★", "好耶!庆祝庆祝!", "哇!被摸得好开心!"], hover: ["唔…一直盯着人家看,会害羞的啦~"], }; function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } /* ================= DOM 工具 ================= */ function firstVisible(sel) { try { var nodes = doc.querySelectorAll(sel); for (var i = 0; i < nodes.length; i++) { var n = nodes[i]; if (!(n instanceof Element)) continue; var r = n.getBoundingClientRect(); if (r.width <= 1 && r.height <= 1) continue; // checkVisibility 比 getComputedStyle 便宜得多(不构造整份样式声明); // 不支持时退回老路径。 if (typeof n.checkVisibility === "function") { if (!n.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue; } else { var s = win.getComputedStyle(n); if (s.display === "none" || s.visibility === "hidden" || Number(s.opacity) === 0) continue; } return n; } } catch (e) { /* 选择器异常忽略 */ } return null; } function hasBank(key) { var sels = STATE_BANKS[key]; for (var i = 0; i < sels.length; i++) { if (firstVisible(sels[i])) return true; } return false; } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } /** 找到 DSH 的对话框(composer)。优先官方 selector,兜底找视口下半部的输入框。 */ function findComposer() { var card = firstVisible('[data-composer-card="true"]'); if (card) return card; var ta = firstVisible('[data-slot="conversation.composer.bar"] textarea'); if (ta) return ta.closest("form") || ta.parentElement; var any = firstVisible("textarea, [contenteditable='true']"); if (any) { var r = any.getBoundingClientRect(); if (r.top > win.innerHeight * 0.45) return any.closest("form") || any.parentElement; } return null; } function findTextarea() { var ta = firstVisible('[data-slot="conversation.composer.bar"] textarea'); if (ta) return ta; var any = firstVisible("textarea, [contenteditable='true']"); if (any) { var r = any.getBoundingClientRect(); if (r.top > win.innerHeight * 0.45) return any; } return null; } /* ================= 状态 ================= */ var state = { name: "idle", since: 0, typingTs: 0, lastCelebrate: 0, lastChatter: 0, lastActivity: Date.now(), }; var sleepUntil = 0; var nextChatterIn = 35000; // 首次主动闲聊延迟 function isSleeping() { return sleepUntil > Date.now(); } /** 深夜安静时段(23:00–07:00):不闲聊、不主动说话、更快打盹。 */ function isNight() { if (pref.get("nightDemo", "0") === "1") return true; // demo 开关(也可当测试钩子) var h = new Date().getHours(); return h >= CFG.nightStart || h < CFG.nightEnd; } /** 切换心情(data-wl-state 驱动 CSS 表情/动效) */ function setMood(name) { var prev = state.name; state.name = name; state.since = Date.now(); root.setAttribute("data-wl-state", name); state.lastActivity = Date.now(); if (name === "work" && prev !== "work") say(pick(LINES.work), 2200); if (name === "error") say(pick(LINES.error), 3000); if (name === "listen" && prev !== "listen" && Math.random() < 0.3) say(pick(LINES.listen), 1500); if (name === "idle" && prev === "work" && Math.random() < 0.4) say(pick(LINES.success), 2400); } /* ================= 定位:趴到对话框上 ================= */ function currentScale() { return clamp(Number(pref.get("scale", 1)) || 1, 0.6, 1.6); } function layout() { if (!root || root.hidden) return; var w = CFG.sizeW * currentScale(); var h = w / CFG.aspect; var mode = pref.get("mode", "lap"); var x, y; var composer = mode === "lap" ? findComposer() : null; if (composer) { var r = composer.getBoundingClientRect(); x = r.right - w - CFG.gapRight; y = r.top - h + CFG.overlap; // 身体下沿压进对话框上沿 → 趴在上面 } else { var fx = Number(pref.get("freeX", 0)) || 0; var fy = Number(pref.get("freeY", 0)) || 0; if (fx || fy) { x = fx; y = fy; } else { x = win.innerWidth - w - 16; y = win.innerHeight - h - 14; } } root.style.width = Math.round(w) + "px"; root.style.left = Math.round(clamp(x, 4, win.innerWidth - w - 4)) + "px"; root.style.top = Math.round(clamp(y, 4, win.innerHeight - h - 4)) + "px"; } /* ================= 气泡 / 粒子 ================= */ var sayTimer = null; function say(text, ms) { if (!bubbleText) return; bubbleText.textContent = text; bubble.hidden = false; bubble.classList.remove("wl-in"); void bubble.offsetWidth; // 重新触发 pop 动画 bubble.classList.add("wl-in"); if (sayTimer) clearTimeout(sayTimer); sayTimer = setTimeout(function () { bubble.hidden = true; }, ms || 3000); } function spawnFx(emojis, n) { for (var i = 0; i < n; i++) { var s = doc.createElement("span"); s.textContent = emojis[Math.floor(Math.random() * emojis.length)]; s.style.left = (8 + Math.random() * 70) + "%"; s.style.bottom = (25 + Math.random() * 45) + "px"; s.style.animationDelay = (Math.random() * 0.15) + "s"; fxLayer.appendChild(s); (function (el) { el.addEventListener("animationend", function () { el.remove(); }); })(s); } } /* ================= 互动 ================= */ var drag = null; var taps = { n: 0, t: 0 }; function handleTap() { var now = Date.now(); if (now - taps.t > 600) taps.n = 0; taps.t = now; taps.n += 1; if (taps.n >= 3) { taps.n = 0; celebrate(); return; } setTimeout(function () { taps.n = 0; }, 700); if (isSleeping()) sleepUntil = 0; // 戳醒睡着的她 say(pick(LINES.tap), 1600); spawnFx(["❤", "✦", "💙", "🫧"], 5); if (state.name !== "work" && state.name !== "sleep" && state.name !== "error") setMood("happy"); } function celebrate() { if (isSleeping()) return; setMood("happy"); say(pick(LINES.celebrate), 2400); spawnFx(["★", "✨", "💙", "❤", "🎉"], 8); root.classList.add("wl-dbl"); setTimeout(function () { root.classList.remove("wl-dbl"); }, 1250); } /* ================= 菜单 ================= */ function menuItem(label, fn) { var b = doc.createElement("button"); b.type = "button"; b.textContent = label; b.addEventListener("click", function () { closeMenu(); fn(); }); return b; } function buildMenu() { menu.innerHTML = ""; var t = doc.createElement("div"); t.className = "wl-menu-title"; t.textContent = "🐋 趴趴鲸鱼娘"; menu.appendChild(t); var mode = pref.get("mode", "lap"); if (mode === "lap") { menu.appendChild(menuItem("🆓 自由摆放", function () { pref.set("mode", "free"); layout(); })); } else { menu.appendChild(menuItem("🖇️ 贴回对话框", function () { pref.set("mode", "lap"); layout(); })); } menu.appendChild(menuItem("➕ 放大一点", function () { pref.set("scale", clamp(currentScale() + 0.15, 0.6, 1.6)); layout(); })); menu.appendChild(menuItem("➖ 缩小一点", function () { pref.set("scale", clamp(currentScale() - 0.15, 0.6, 1.6)); layout(); })); menu.appendChild(menuItem(isSleeping() ? "🌞 快醒醒" : "😴 睡一会儿", function () { sleepUntil = isSleeping() ? 0 : Date.now() + CFG.sleepMs; })); menu.appendChild(menuItem("💬 说句话", function () { say(pick(LINES.tap), 2000); })); menu.appendChild(menuItem("🙈 先躲起来", hide)); menu.appendChild(menuItem("ℹ️ 关于", function () { say("趴趴鲸鱼娘 v1.1.0 🐋 手绘 SVG · 趴在对话框上 · 零外部依赖", 3400); })); } function openMenu(x, y) { buildMenu(); menu.hidden = false; menu.setAttribute("open", ""); if (x !== undefined && y !== undefined) { var rr = root.getBoundingClientRect(); menu.style.left = clamp(x - rr.left, 4, rr.width - 140) + "px"; menu.style.top = clamp(y - rr.top, 4, rr.height - 40) + "px"; } else { menu.style.left = ""; menu.style.top = ""; } } function closeMenu() { menu.hidden = true; menu.removeAttribute("open"); } /* ================= 隐藏 / 找回 ================= */ function hide() { closeMenu(); pref.set("hidden", 1); root.hidden = true; var btn = doc.createElement("button"); btn.id = "dsh-whale-lap-findme"; btn.type = "button"; btn.title = "把鲸鱼娘叫回来"; btn.setAttribute("aria-label", "把鲸鱼娘叫回来"); btn.textContent = "🐋"; btn.addEventListener("click", function () { pref.set("hidden", 0); root.hidden = false; btn.remove(); layout(); say("嘿嘿,我回来啦~", 1800); }); doc.body.appendChild(btn); } /* ================= 轮询 ================= */ function poll() { var now = Date.now(); var night = isNight(); if (isSleeping()) { if (state.name !== "sleep") setMood("sleep"); return; } if (state.name === "sleep") { setMood("idle"); if (!night) say(pick(LINES.greet), 1800); // 深夜睡醒不吵你 } // 瞬态(开心/难过)倒计时结束回 idle if ((state.name === "happy" || state.name === "sad") && now - state.since > 3000) { setMood("idle"); } var next = "idle"; if (hasBank("error")) next = "error"; else if (hasBank("work")) next = "work"; else if (hasBank("think")) next = "think"; else if (now - state.typingTs < 2400) next = "listen"; if (next !== state.name && state.name !== "happy" && state.name !== "sad") { setMood(next); } // 成功 → 庆祝(限频) if (hasBank("success") && now - state.lastCelebrate > 9000 && !isSleeping()) { state.lastCelebrate = now; celebrate(); } // 主动闲聊(深夜安静时段不开口) if (!night && (state.name === "idle" || state.name === "listen") && now > state.lastChatter + nextChatterIn) { state.lastChatter = now; nextChatterIn = 50000 + Math.random() * 60000; say(pick(LINES.idle), 2600); } // 长时间无人互动 → 自动打盹(深夜更快睡着) if (state.name === "idle" && now - state.lastActivity > (night ? CFG.nightNapMs : CFG.idleSleepMs)) { sleepUntil = now + CFG.sleepMs; } } /* ================= 监听输入(她在听你打字) ================= */ var lastTa = null; function watchTyping() { var ta = findTextarea(); if (ta && ta !== lastTa) { lastTa = ta; ta.addEventListener("input", function () { state.typingTs = Date.now(); state.lastActivity = Date.now(); }); } } /* ================= 事件绑定 ================= */ function attachEvents() { // 拖拽 stage.addEventListener("pointerdown", function (e) { if (e.button !== 0 && e.pointerType === "mouse") return; drag = { sx: e.clientX, sy: e.clientY, ox: parseFloat(root.style.left) || 0, oy: parseFloat(root.style.top) || 0, px: 0, moved: false, }; stage.setPointerCapture(e.pointerId); state.lastActivity = Date.now(); }); stage.addEventListener("pointermove", function (e) { if (!drag) return; var dx = e.clientX - drag.sx; var dy = e.clientY - drag.sy; if (!drag.moved && Math.hypot(dx, dy) < CFG.minDrag) return; if (!drag.moved) { drag.moved = true; root.setAttribute("data-wl-dragging", "1"); closeMenu(); } var w = root.offsetWidth || CFG.sizeW; var h = root.offsetHeight || (CFG.sizeW / CFG.aspect); root.style.left = Math.round(clamp(drag.ox + dx, 0, win.innerWidth - w)) + "px"; root.style.top = Math.round(clamp(drag.oy + dy, 0, win.innerHeight - h)) + "px"; // 身体随移动方向倾斜(被拎起来的感觉) var vx = dx - drag.px; drag.px = dx; stage.style.transform = "rotate(" + clamp(vx * 0.7, -16, 16) + "deg)"; }); function pointerUp() { if (!drag) return; var wasDrag = drag.moved; var ox = parseFloat(root.style.left) || 0; var oy = parseFloat(root.style.top) || 0; drag = null; root.removeAttribute("data-wl-dragging"); stage.style.transform = ""; if (wasDrag) { pref.set("freeX", ox); pref.set("freeY", oy); if (pref.get("mode", "lap") === "lap") layout(); // 松手弹回对话框(CSS transition 生效) return; } handleTap(); // 没拖拽 → 算单击 } stage.addEventListener("pointerup", pointerUp); stage.addEventListener("pointercancel", pointerUp); // 悬停太久 → 害羞 var hoverT = 0; stage.addEventListener("pointerenter", function () { hoverT = Date.now(); }); stage.addEventListener("pointerleave", function () { if (Date.now() - hoverT > 2500 && Math.random() < 0.5 && state.name === "idle") { say(pick(LINES.hover), 2000); } }); // 右键菜单 stage.addEventListener("contextmenu", function (e) { e.preventDefault(); openMenu(e.clientX, e.clientY); }); // 齿轮按钮 gear.addEventListener("click", function (e) { e.stopPropagation(); if (menu.hasAttribute("open")) closeMenu(); else openMenu(undefined, undefined); }); // 点击页面其他处关闭菜单 doc.addEventListener("pointerdown", function (e) { if (menu.hasAttribute("open") && !menu.contains(e.target) && e.target !== gear) closeMenu(); }); // Esc 关闭菜单 doc.addEventListener("keydown", function (e) { if (e.key === "Escape" && menu.hasAttribute("open")) closeMenu(); }); // 视口变化重排(rAF 节流:滚动事件高频触发时只跑最后一帧) var layoutRaf = 0; function requestLayout() { if (layoutRaf) return; layoutRaf = (win.requestAnimationFrame || function (fn) { return setTimeout(fn, 100); })(function () { layoutRaf = 0; layout(); }); } win.addEventListener("resize", requestLayout); win.addEventListener("scroll", requestLayout, { passive: true }); doc.addEventListener("scroll", requestLayout, { passive: true, capture: true }); } /* ================= 启动 ================= */ var root, stage, bubble, bubbleText, gear, menu, fxLayer; function buildDom(svgText) { root = doc.createElement("div"); root.id = "dsh-whale-lap-root"; root.setAttribute("data-wl-state", "idle"); stage = doc.createElement("div"); stage.className = "wl-stage"; stage.innerHTML = svgText; root.appendChild(stage); bubble = doc.createElement("div"); bubble.className = "wl-bubble"; bubble.hidden = true; bubbleText = doc.createElement("span"); bubble.appendChild(bubbleText); root.appendChild(bubble); gear = doc.createElement("button"); gear.type = "button"; gear.className = "wl-gear"; gear.title = "菜单"; gear.setAttribute("aria-label", "鲸鱼娘菜单"); gear.textContent = "⚙"; root.appendChild(gear); menu = doc.createElement("div"); menu.className = "wl-menu"; menu.hidden = true; root.appendChild(menu); fxLayer = doc.createElement("div"); fxLayer.className = "wl-fx"; root.appendChild(fxLayer); doc.body.appendChild(root); if (pref.get("hidden", 0) === "1") hide(); } async function boot() { var svgText = null; try { var res = await fetch(BASE + "whale-girl.svg"); if (res.ok) svgText = await res.text(); } catch (e) { /* 网络失败 */ } if (!svgText || !doc.body) { console.warn("[dsh-whale-lap] 资源加载失败,看板娘未能唤醒"); return; } buildDom(svgText); attachEvents(); layout(); setTimeout(function () { if (!isNight()) say(pick(LINES.greet), 2400); // 深夜启动不吵你 state.lastChatter = Date.now(); }, 900); // 单 tick:轮询 / 兜底重排 / 打字监听 共用一条定时器,按各自节奏执行 var lastLayoutRun = 0; var lastTypingRun = 0; setInterval(function () { var now = Date.now(); poll(); if (now - lastLayoutRun >= CFG.layoutMs) { lastLayoutRun = now; layout(); } if (now - lastTypingRun >= CFG.pollMs * 2) { lastTypingRun = now; watchTyping(); } }, CFG.pollMs); window.__dshWhaleLap = { version: "1.1.0", say: say, setMood: setMood, layout: layout, isNight: isNight, state: state, }; } if (doc.readyState === "loading") { doc.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();