// ==UserScript== // @name 全局脚本 - 3D视野组件 (单 Canvas v10.8 Bbox Fixed) // @version 10.8 // @description 实现遮挡物半透明效果,当模型包裹另一个时,外层模型自动透视,支持玩家显示开关。已优化移动端触摸支持和性能。新增并修复了bbox格式兼容。 // @author Codeboy, 优化 by AI // @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'; // 玩家显示配置 const PLAYER_CONFIG = { visible: false, // 设为 false 即可隐藏玩家模型和标签 showLabel: true // 是否显示玩家标签(仅在 visible 为 true 时有效) }; /* -------------------- 调试日志 -------------------- */ let debugMode = false; 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;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;} .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, depthTest: true, depthWrite: false }); const sprite = new THREE.Sprite(material); const pxRatio = 0.01; sprite.scale.set(canvas.width * pxRatio, canvas.height * pxRatio, 1).multiplyScalar(baseScale); sprite.userData = { baseScale: sprite.scale.clone(), canvas }; return sprite; } /* ------------ 解析函数:兼容新旧格式 -------------- */ function parseVision3DItems(xml) { const items = []; const itemMatches = [ ...xml.matchAll(/(.*?)<\/item>/gis), ...xml.matchAll(/]+)\s*\/>/gis) ]; itemMatches.forEach(match => { const obj = {}; let contentStr = ''; if (match[0].includes('')) { // 旧格式:pos:x,y,z; dim:w,d,h; ... contentStr = match[1]; contentStr.split(';').forEach(part => { const [k, v] = part.split(':').map(s => s.trim()); if (k && v) obj[k] = v.replace(/"/g, ''); }); if (obj.pos && obj.dim) { const [x, y, z] = obj.pos.split(',').map(Number); const [w, d, h] = obj.dim.split(',').map(Number); items.push({ pos: { x, y, z }, dim: { w, d, h }, label: obj.content || 'Unknown', type: obj.type || '中立' }); } } else { // 新格式: contentStr = match[1]; const attrRegex = /(\w+):\s*"([^"]*)"/g; let attrMatch; while ((attrMatch = attrRegex.exec(contentStr)) !== null) { obj[attrMatch[1]] = attrMatch[2]; } if (obj.bbox) { const coords = obj.bbox.split(',').map(Number); if (coords.length === 6) { const [minX, minY, minZ, maxX, maxY, maxZ] = coords; // **【修复】** 转换为与旧系统兼容的中心点和尺寸 // pos.x/y 是水平中心点 const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; // pos.z 在旧系统中是物体的**底部**高度, 对应 bbox 的 minZ const baseZ = minZ; const width = maxX - minX; const depth = maxY - minY; const height = maxZ - minZ; items.push({ pos: { x: centerX, y: centerY, z: baseZ }, dim: { w: width, d: depth, h: height }, label: obj.content || 'Unknown', type: obj.type || '中立' }); logDebug(`解析bbox项目: bbox=${obj.bbox} -> pos=(${centerX},${centerY},${baseZ}), dim=(${width},${depth},${height})`); } } } }); return items; } /* ------------ 运行时存储 & 清理 -------------- */ 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.container.removeEventListener('touchstart', st.onTouchStart); st.container.removeEventListener('click', st.onClick); 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 = parseVision3DItems(xml); $placeholder.html(`
`); const container = $placeholder.find('.vision3d-container')[0]; const canvas3D = container.querySelector('canvas'); 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, logarithmicDepthBuffer: 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)); let playerMesh = null; let playerLabel = null; if (PLAYER_CONFIG.visible) { const playerGeo = new THREE.ConeGeometry(.5, 2, 8).translate(0, 1, 0); playerMesh = new THREE.Mesh(playerGeo, new THREE.MeshBasicMaterial({ color: USER_COLOR })); playerMesh.userData.isPlayer = true; scene.add(playerMesh); if (PLAYER_CONFIG.showLabel) { playerLabel = makeTextSprite('玩家', { baseScale: 1 }); playerLabel.position.set(0, 2.5, 0); scene.add(playerLabel); } } const meshes = []; items.forEach(it => { const color = VISION_TYPES[it.type]?.color || VISION_TYPES['中立'].color; const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.9, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }); const mesh = new THREE.Mesh(new THREE.BoxGeometry(it.dim.w, it.dim.h, it.dim.d), material); // 渲染逻辑保持不变。它期望 it.pos.z 是底部高度。 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, baseOpacity: material.opacity, isOccluder: false }; 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(); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let activeMesh = null; let prevOccluders = []; let frameCount = 0; function updateActiveObject(newActiveMesh) { if (newActiveMesh === activeMesh) return; if (activeMesh) { activeMesh.material.opacity = activeMesh.userData.baseOpacity; activeMesh.userData.labelSprite.scale.copy(activeMesh.userData.labelSprite.userData.baseScale); } if (newActiveMesh) { newActiveMesh.material.opacity = 0.5; newActiveMesh.userData.labelSprite.scale.multiplyScalar(1.25); } activeMesh = newActiveMesh; } function updatePointer(event) { const rect = container.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width; const y = (event.clientY - rect.top) / rect.height; pointer.x = x * 2 - 1; pointer.y = -y * 2 + 1; } function onMouseMove(e) { updatePointer(e); raycaster.setFromCamera(pointer, camera); const intersects = raycaster.intersectObjects(meshes); updateActiveObject(intersects[0]?.object || null); } function onTouchStart(e) { if (e.touches.length === 1) { updatePointer(e.touches[0]); raycaster.setFromCamera(pointer, camera); const intersects = raycaster.intersectObjects(meshes); const tappedObject = intersects[0]?.object || null; if (tappedObject && tappedObject === activeMesh) { updateActiveObject(null); } else { updateActiveObject(tappedObject); } } } function onClick(e) { updatePointer(e); raycaster.setFromCamera(pointer, camera); const intersects = raycaster.intersectObjects(meshes); if (intersects.length === 0) { updateActiveObject(null); } } container.addEventListener('mousemove', onMouseMove); container.addEventListener('touchstart', onTouchStart, { passive: false }); container.addEventListener('click', onClick); function updateLabelScale(sprite) { if (!sprite) return; const camDist = camera.position.distanceTo(sprite.position); const scaleFactor = Math.max(0.1, camDist * 0.04); sprite.scale.copy(sprite.userData.baseScale).multiplyScalar(scaleFactor); } function loop() { active.get(id).raf = requestAnimationFrame(loop); frameCount++; controls.update(); if (frameCount % 3 === 0) { prevOccluders.forEach(mesh => { if (mesh !== activeMesh) { mesh.material.opacity = mesh.userData.baseOpacity; } mesh.userData.isOccluder = false; }); prevOccluders = []; const targets = []; if (playerMesh && PLAYER_CONFIG.visible) targets.push(playerMesh); if (activeMesh) targets.push(activeMesh); targets.forEach(target => { const direction = new THREE.Vector3().subVectors(target.position, camera.position).normalize(); raycaster.set(camera.position, direction); const allIntersects = raycaster.intersectObjects(scene.children, true); let targetFound = false; for (const intersect of allIntersects) { if (intersect.object === target) { targetFound = true; break; } if (meshes.includes(intersect.object) && intersect.object !== target) { const occluder = intersect.object; if (occluder === activeMesh) continue; occluder.material.opacity = 0.2; occluder.userData.isOccluder = true; if (!prevOccluders.includes(occluder)) prevOccluders.push(occluder); } } if (!targetFound && allIntersects.length > 0) { const closest = allIntersects[0].object; if (meshes.includes(closest)) { closest.material.opacity = 0.2; closest.userData.isOccluder = true; if (!prevOccluders.includes(closest)) prevOccluders.push(closest); } } }); } if (playerLabel && PLAYER_CONFIG.visible && PLAYER_CONFIG.showLabel) { updateLabelScale(playerLabel); } meshes.forEach(m => updateLabelScale(m.userData.labelSprite)); renderer.render(scene, camera); } active.set(id, { scene, renderer, container, resizeObserver, raf: requestAnimationFrame(loop), onMouseMove, onTouchStart, onClick }); logDebug(`创建 3D 视野组件: ${id}, items: ${items.length}, worldSize: ${worldSize}, 玩家可见: ${PLAYER_CONFIG.visible}`); } /* --------------- 防抖/立即/核心 渲染函数 --------------- */ // ... 这部分代码没有变化,故折叠 ... 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); if (active.has(messageId)) { cleanup(messageId); } if (match) { let html = msgObj.mes; const placeholderId = `v3d-${messageId}-${Date.now()}`; html = html.replace(reg, `
`); 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); messageSentResetTimer = setTimeout(() => { messageSentRecently = false; }, 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); } }); } if (eventTypes.GENERATION_ENDED) { eventSource.on(eventTypes.GENERATION_ENDED, () => { logDebug('Event: GENERATION_ENDED'); messageSentRecently = false; if (messageSentResetTimer) clearTimeout(messageSentResetTimer); 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}`); renderVisionMessageDebounced(messageId, null, 200); }); } if (eventTypes.MESSAGE_UPDATED) { eventSource.on(eventTypes.MESSAGE_UPDATED, (messageId) => { logDebug(`Event: MESSAGE_UPDATED, messageId: ${messageId}`); 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); }); } 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) => { id !== undefined ? cleanup(id) : active.forEach((_, id) => cleanup(id)); }; window.vision3dRenderAll = renderAllVisionMessages; window.vision3dPlayerConfig = PLAYER_CONFIG; logDebug(`3D视野组件初始化完成 (玩家可见: ${PLAYER_CONFIG.visible}), 支持bbox格式`); } else { setTimeout(waitReady, 300); } } waitReady(); })();