// ==UserScript== // @name 全局脚本 - 3D视野组件 (单 Canvas v10.1) // @version 10.1 // @description 将标签改为 3D Sprite,仅用一张 Canvas;解决多次 scale 累积问题;优化 resize、hover 效果;完善事件触发逻辑 // @author codeboy // @match */* // @grant none // ==/UserScript== (function () { 'use strict'; /* -------------------- 配置 -------------------- */ const WIDGET_NAME = 'GlobalScript_Vision3D_SingleCanvas'; const VISION_TYPES = { '友方': { color: 0x28a745 }, '敌方': { color: 0xdc3545 }, '中立': { color: 0x6c757d }, '物品': { color: 0x007bff }, '地形': { color: 0x8B4513 } }; const USER_COLOR = 0xffd700; const FONT_FAMILY = 'Arial, "PingFang SC", "Microsoft YaHei", sans-serif'; /* -------------------- 调试日志 -------------------- */ let debugMode = false; // 可通过控制台设置 window.vision3dDebug = true function logDebug(...args) { if (debugMode || window.vision3dDebug) { console.log(`[${WIDGET_NAME}]`, ...args); } } /* ------------------ 依赖加载 ------------------ */ let libsReady = false; async function ensureThree() { if (libsReady) return true; const load = src => new Promise((r, j) => { if (document.querySelector(`script[src="${src}"]`)) return r(); const s = Object.assign(document.createElement('script'), { src }); s.onload = r; s.onerror = () => j(new Error(`load fail: ${src}`)); document.head.appendChild(s); }); try { await load('https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'); await load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js'); libsReady = true; return true; } catch (e) { console.error(WIDGET_NAME, e); return false; } } /* -------------------- 样式 -------------------- */ function injectCss() { if (document.getElementById(`${WIDGET_NAME}-style`)) return; const style = document.createElement('style'); style.id = `${WIDGET_NAME}-style`; style.textContent = ` .vision3d-container{position:relative;width:100%;max-width:600px;border:1px solid #444;border-radius:8px;margin:10px auto;aspect-ratio:1/1;overflow:hidden;cursor:grab} .vision3d-container:active{cursor:grabbing} .vision3d-canvas{position:absolute;top:0;left:0;width:100%!important;height:100%!important;display:block} `; document.head.appendChild(style); } /* ------------ 工具:文字转 Sprite ------------ */ function makeTextSprite(text, { baseScale = 0.8, color = '#f0f0f0', bg = 'rgba(0,0,0,.75)', padding = 6 } = {}) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = `bold 48px ${FONT_FAMILY}`; // 先用大字号测宽 const textW = ctx.measureText(text).width; const textH = 48; canvas.width = textW + padding * 2; canvas.height = textH + padding * 2; // 重新以正确尺寸绘制 ctx.font = `bold 48px ${FONT_FAMILY}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = bg; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = color; ctx.fillText(text, canvas.width / 2, canvas.height / 2); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; // 防止离屏抽样模糊 const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }); const sprite = new THREE.Sprite(material); // 基础缩放:用像素转化为世界单位;此比例可按需调整 const pxRatio = 0.01; // 1px ≈ 0.01 world unit sprite.scale.set(canvas.width * pxRatio, canvas.height * pxRatio, 1).multiplyScalar(baseScale); // 附加自定义字段以便 hover 动态调整 sprite.userData = { baseScale: sprite.scale.clone(), canvas }; return sprite; } /* ------------ 运行时存储 & 清理 -------------- */ const active = new Map(); const renderTimers = new Map(); // 防抖计时器 function cleanup(id) { const st = active.get(id); if (!st) return; // 清理防抖计时器 const timer = renderTimers.get(id); if (timer) { clearTimeout(timer); renderTimers.delete(id); } cancelAnimationFrame(st.raf); st.container.removeEventListener('mousemove', st.onMouseMove); st.resizeObserver.disconnect(); st.renderer.dispose(); st.scene.traverse(o => { if (o.isMesh) { o.geometry?.dispose(); (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m?.dispose?.()); } if (o.isSprite) { o.material?.map?.dispose(); o.material?.dispose(); } }); active.delete(id); logDebug(`清理 3D 视野组件: ${id}`); } /* --------------- 渲染主逻辑 ------------------ */ async function render3D(id, $placeholder, worldSize, xml) { if (!await ensureThree()) return; /* ---------- 解析 数据 ---------- */ const items = [...xml.matchAll(/(.*?)<\/item>/gis)].map(m => { const obj = {}; m[1].split(';').forEach(p => { const [k, v] = p.split(':').map(s => s.trim()); if (k && v) obj[k] = v.replace(/"/g, ''); }); const [x, y, z] = (obj.pos || '0,0,0').split(',').map(Number); const [w, d, h] = (obj.dim || '1,1,1').split(',').map(Number); return { pos: { x, y, z }, dim: { w, d, h }, label: obj.content, type: obj.type || '中立' }; }); /* ------------------- DOM ------------------ */ $placeholder.html(`
`); const container = $placeholder.find('.vision3d-container')[0]; const canvas3D = container.querySelector('canvas'); /* ------------------- Three ---------------- */ const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); camera.position.set(worldSize * .8, worldSize * .8, worldSize * .8); const renderer = new THREE.WebGLRenderer({ canvas: canvas3D, antialias: true, alpha: true }); const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.target.set(0, 0, 0); /* 网格与玩家 */ scene.add(new THREE.GridHelper(worldSize, worldSize, 0x555555, 0x555555)); const playerGeo = new THREE.ConeGeometry(.5, 2, 8).translate(0, 1, 0); const playerMesh = new THREE.Mesh(playerGeo, new THREE.MeshBasicMaterial({ color: USER_COLOR })); scene.add(playerMesh); const playerLabel = makeTextSprite('玩家', { baseScale: 1 }); playerLabel.position.set(0, 2.5, 0); scene.add(playerLabel); /* items mesh & label */ const meshes = []; items.forEach(it => { const color = VISION_TYPES[it.type]?.color || VISION_TYPES['中立'].color; const mesh = new THREE.Mesh( new THREE.BoxGeometry(it.dim.w, it.dim.h, it.dim.d), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }) ); mesh.position.set(it.pos.x, it.pos.z + it.dim.h / 2, -it.pos.y); scene.add(mesh); const labelSprite = makeTextSprite(it.label, { baseScale: 0.9 }); labelSprite.position.copy(mesh.position).add(new THREE.Vector3(0, it.dim.h / 2 + .5, 0)); scene.add(labelSprite); mesh.userData = { labelSprite }; meshes.push(mesh); }); /* 自适应尺寸 */ function syncSize() { const { width, height } = container.getBoundingClientRect(); if (!width || !height) return; const s = Math.min(width, height); camera.aspect = 1; camera.updateProjectionMatrix(); renderer.setSize(s, s, false); } const resizeObserver = new ResizeObserver(syncSize); resizeObserver.observe(container); syncSize(); /* Hover 光线检测 */ const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); let hoverMesh = null; function onMouseMove(e) { const r = container.getBoundingClientRect(); mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1; mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1; } container.addEventListener('mousemove', onMouseMove); /* 视距缩放函数 */ function updateLabelScale(sprite) { const camDist = camera.position.distanceTo(sprite.position); const scaleFactor = camDist * 0.04; // 调节粗细可见度;经验参数 sprite.scale.copy(sprite.userData.baseScale).multiplyScalar(scaleFactor); } /* 渲染循环 */ function loop() { controls.update(); // hover raycaster.setFromCamera(mouse, camera); const intersect = raycaster.intersectObjects(meshes)[0]?.object || null; if (intersect !== hoverMesh) { if (hoverMesh) { hoverMesh.material.opacity = 0.9; hoverMesh.userData.labelSprite.material.opacity = 1.0; hoverMesh.userData.labelSprite.scale.copy(hoverMesh.userData.labelSprite.userData.baseScale); } hoverMesh = intersect; if (hoverMesh) { hoverMesh.material.opacity = 0.5; hoverMesh.userData.labelSprite.material.opacity = 1.2; hoverMesh.userData.labelSprite.scale.multiplyScalar(1.25); } } // 每帧动态调整文字大小 updateLabelScale(playerLabel); meshes.forEach(m => updateLabelScale(m.userData.labelSprite)); renderer.render(scene, camera); active.get(id).raf = requestAnimationFrame(loop); } /* 注册清理对象 */ active.set(id, { scene, renderer, container, onMouseMove, resizeObserver, raf: requestAnimationFrame(loop) }); logDebug(`创建 3D 视野组件: ${id}, items: ${items.length}, worldSize: ${worldSize}`); } /* --------------- 防抖渲染函数 --------------- */ function renderVisionMessageDebounced(messageId, $mesText, delay = 150) { // 清除之前的计时器 const existingTimer = renderTimers.get(messageId); if (existingTimer) { clearTimeout(existingTimer); } // 设置新的防抖计时器 const timer = setTimeout(() => { renderVisionMessage(messageId, $mesText, false); renderTimers.delete(messageId); }, delay); renderTimers.set(messageId, timer); logDebug(`防抖渲染消息: ${messageId}, 延迟: ${delay}ms`); } /* --------------- 立即渲染函数 --------------- */ function renderVisionMessageImmediate(messageId, $mesText) { // 清除防抖计时器(如果存在) const existingTimer = renderTimers.get(messageId); if (existingTimer) { clearTimeout(existingTimer); renderTimers.delete(messageId); } renderVisionMessage(messageId, $mesText, true); logDebug(`立即渲染消息: ${messageId}`); } /* --------------- 核心渲染函数 --------------- */ function renderVisionMessage(messageId, $mesText, isImmediate = false) { const msgObj = SillyTavern.chat[messageId]; if (!msgObj) { logDebug(`消息不存在: ${messageId}`); return; } const reg = /(.*?)<\/vision3d>/s; const match = msgObj.mes.match(reg); // 如果消息已有组件但不再包含vision3d标签,清理旧实例 if (!match && active.has(messageId)) { cleanup(messageId); } // 如果找到vision3d标签,先清理旧实例再创建新实例 if (match) { cleanup(messageId); let html = msgObj.mes; const placeholderId = `v3d-${messageId}-${Date.now()}`; html = html.replace(reg, `
`); // 如果没有传入$mesText,尝试获取 if (!$mesText) { const mesElement = $(`#chat .mes[mesid="${messageId}"]`); if (mesElement.length) { $mesText = mesElement.find('.mes_text'); } } if ($mesText && $mesText.length) { $mesText.html(SillyTavern.messageFormatting(html, msgObj.name, msgObj.is_system, msgObj.is_user, messageId)); const renderDelay = isImmediate ? 0 : 50; // 立即渲染时不延迟 setTimeout(() => { const $placeholder = $(`#${placeholderId}`, $mesText); if ($placeholder.length) { render3D(messageId, $placeholder, +match[1], match[2]); } }, renderDelay); } } } /* --------------- 批量渲染函数 --------------- */ function renderAllVisionMessages() { logDebug('开始批量渲染所有vision3d消息'); $('#chat .mes[is_user="false"][is_system="false"]').each((_, el) => { const messageId = +el.getAttribute('mesid'); const $mesText = $('.mes_text', el); if (messageId && $mesText.length) { renderVisionMessageDebounced(messageId, $mesText, 100); } }); } /* --------------- 优化的事件监听设置 --------------- */ function setupOptimizedEventListeners() { if (!SillyTavern.eventTypes || !SillyTavern.eventSource) { logDebug('SillyTavern 事件系统未就绪'); return; } const eventTypes = SillyTavern.eventTypes; const eventSource = SillyTavern.eventSource; logDebug('设置优化的事件监听器'); // 消息生成状态追踪 let messageSentRecently = false; let messageSentResetTimer = null; // 监听消息发送事件 if (eventTypes.MESSAGE_SENT) { eventSource.on(eventTypes.MESSAGE_SENT, () => { logDebug('Event: MESSAGE_SENT'); messageSentRecently = true; if (messageSentResetTimer) { clearTimeout(messageSentResetTimer); } // 1秒后自动重置标志 messageSentResetTimer = setTimeout(() => { messageSentRecently = false; messageSentResetTimer = null; logDebug('自动重置 messageSentRecently 标志'); }, 1000); }); } // 生成开始时的条件渲染 if (eventTypes.GENERATION_STARTED) { eventSource.on(eventTypes.GENERATION_STARTED, () => { logDebug(`Event: GENERATION_STARTED (messageSentRecently: ${messageSentRecently})`); if (messageSentRecently) { // 获取最新消息进行防抖渲染 const latestMessage = SillyTavern.chat[SillyTavern.chat.length - 1]; if (latestMessage && !latestMessage.is_user) { renderVisionMessageDebounced(SillyTavern.chat.length - 1); } messageSentRecently = false; if (messageSentResetTimer) { clearTimeout(messageSentResetTimer); messageSentResetTimer = null; } } else { logDebug('跳过 GENERATION_STARTED 事件的渲染,因为不是由消息发送触发的'); } }); } // 生成结束时的立即渲染 if (eventTypes.GENERATION_ENDED) { eventSource.on(eventTypes.GENERATION_ENDED, () => { logDebug('Event: GENERATION_ENDED'); // 重置消息发送标志 messageSentRecently = false; if (messageSentResetTimer) { clearTimeout(messageSentResetTimer); messageSentResetTimer = null; } // 立即渲染最新的非用户消息 for (let i = SillyTavern.chat.length - 1; i >= 0; i--) { const msg = SillyTavern.chat[i]; if (msg && !msg.is_user && !msg.is_system) { renderVisionMessageImmediate(i); break; } } }); } // 消息接收时的立即渲染 if (eventTypes.MESSAGE_RECEIVED) { eventSource.on(eventTypes.MESSAGE_RECEIVED, (messageId) => { logDebug(`Event: MESSAGE_RECEIVED, messageId: ${messageId}`); const msg = SillyTavern.chat[messageId]; if (msg && !msg.is_user && !msg.is_system) { renderVisionMessageImmediate(messageId); } }); } // 消息切换事件 - 防抖渲染 if (eventTypes.MESSAGE_SWIPED) { eventSource.on(eventTypes.MESSAGE_SWIPED, (messageId) => { logDebug(`Event: MESSAGE_SWIPED, messageId: ${messageId}`); const msg = SillyTavern.chat[messageId]; if (msg && !msg.is_user && !msg.is_system) { renderVisionMessageDebounced(messageId, null, 200); } }); } // 消息更新事件 - 防抖渲染 if (eventTypes.MESSAGE_UPDATED) { eventSource.on(eventTypes.MESSAGE_UPDATED, (messageId) => { logDebug(`Event: MESSAGE_UPDATED, messageId: ${messageId}`); const msg = SillyTavern.chat[messageId]; if (msg && !msg.is_user && !msg.is_system) { renderVisionMessageDebounced(messageId, null, 150); } }); } // 消息删除事件 - 立即清理 if (eventTypes.MESSAGE_DELETED) { eventSource.on(eventTypes.MESSAGE_DELETED, (messageId) => { logDebug(`Event: MESSAGE_DELETED, messageId: ${messageId}`); cleanup(messageId); }); } // 批量消息删除事件 if (eventTypes.MESSAGES_DELETED) { eventSource.on(eventTypes.MESSAGES_DELETED, () => { logDebug('Event: MESSAGES_DELETED'); // 清理所有实例,稍后重新渲染 active.forEach((_, id) => cleanup(id)); setTimeout(renderAllVisionMessages, 300); }); } // 角色切换事件 if (eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED) { eventSource.on(eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED, () => { logDebug('Event: CHARACTER_FIRST_MESSAGE_SELECTED'); // 清理所有旧实例 active.forEach((_, id) => cleanup(id)); // 重新渲染所有消息 setTimeout(renderAllVisionMessages, 200); }); } // 其他需要重新渲染的事件 - 防抖处理 const otherDebouncedEvents = [ eventTypes.IMAGE_SWIPED, eventTypes.MESSAGE_FILE_EMBEDDED, eventTypes.MESSAGE_REASONING_EDITED, eventTypes.MESSAGE_REASONING_DELETED, eventTypes.FILE_ATTACHMENT_DELETED, eventTypes.GROUP_UPDATED ].filter(Boolean); otherDebouncedEvents.forEach(eventType => { if (eventType) { eventSource.on(eventType, () => { logDebug(`Event (Other Debounced): ${eventType}`); setTimeout(renderAllVisionMessages, 300); }); } }); logDebug('优化的事件监听器设置完成'); } /* --------------- 页面卸载清理 --------------- */ function setupCleanupOnUnload() { window.addEventListener('beforeunload', () => { logDebug('页面卸载,清理所有3D视野组件'); active.forEach((_, id) => cleanup(id)); renderTimers.forEach(timer => clearTimeout(timer)); renderTimers.clear(); }); } /* ------------------ 启动脚本 ------------------ */ function waitReady() { if (typeof SillyTavern !== 'undefined' && SillyTavern.chat && SillyTavern.eventSource && SillyTavern.eventTypes) { logDebug('SillyTavern 环境就绪,初始化3D视野组件'); injectCss(); setupOptimizedEventListeners(); setupCleanupOnUnload(); // 初始渲染现有消息 setTimeout(renderAllVisionMessages, 500); // 暴露调试接口到全局 window.vision3dDebug = false; window.vision3dCleanup = (id) => { if (id !== undefined) { cleanup(id); } else { active.forEach((_, id) => cleanup(id)); } }; window.vision3dRenderAll = renderAllVisionMessages; logDebug('3D视野组件初始化完成'); } else { setTimeout(waitReady, 300); } } waitReady(); })();