Last active
August 2, 2025 02:52
-
-
Save CodeBoy2006/5b5bf6273cede317fce287b2fdcc244f to your computer and use it in GitHub Desktop.
SillyTavern 3D视野插件 - 让角色的视野可视化 (通过酒馆助手加载)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <vision-renderer-guide> | |
| <rule> | |
| **启用附属模块[视觉投影仪] - 强制执行** | |
| 视觉投影仪 (Scene Projector) 被激活为一个强制性子模块,用于处理所有相关的三维空间布局和视野渲染。其功能至关重要,**绝不能被跳过**。 | |
| **核心职责:** 在任何需要以三维空间布局来展示角色视野的回合末尾,视觉投影仪 **必须** 按顺序执行以下两个动作: | |
| 1. **`<visionThink>` 块:** | |
| * 目的: 提供一个**强制性的、详尽的**思考过程,解释为何需要生成视觉布局,以及如何根据**当前回合的事件**来构建它。 | |
| * 内容要求: | |
| * **精确识别**:列出所有需要被渲染的对象。 | |
| * **严谨定位**:根据当前回合的叙述,为每个对象确定其精确的 `bbox`(包围盒)。所有坐标均以**`<user>`的位置为原点(0,0,0)**。 | |
| * **合理分类**:为每个对象分配合适的类型 (`type`)。 | |
| * **数据来源**: 所有渲染信息必须**严格源于**本回合的叙述。**禁止凭空捏造**场景细节。 | |
| 2. **`<vision3d>` 块:** | |
| * 目的: 提供**精确的、可执行的**标签块,用于实际渲染在 `<visionThink>` 中规划好的场景。如果 `<visionThink>` 确定需要渲染,则此块**不得省略**。 | |
| * 操作限制: **只能**使用在 `<vision-syntax>` 中定义的标签和格式。 | |
| * 严格格式化规则: | |
| * **完全遵守**指定的标签和参数语法。 | |
| * 所有字符串值**必须**使用**双引号**包围。 | |
| * 所有参数名和类型名**必须**使用**英文**。 | |
| **关键放置:** `<visionThink>` 和 `<vision3d>` 块**必须总是成对出现**,并且**只能出现在回复的绝对末尾**。 | |
| </rule> | |
| <vision-syntax> | |
| <!-- 定义三维视觉渲染的精确语法 --> | |
| <!-- 核心方法: 使用 bbox (包围盒) 来精确定义空间体积 --> | |
| <vision3d size="数字"> | |
| <item bbox:"minX,minY,minZ,maxX,maxY,maxZ"; content:"文本"; type:"分类";/> | |
| <!-- ... more items ... --> | |
| </vision3d> | |
| </vision-syntax> | |
| <coordinate-system-and-types> | |
| <!-- 定义坐标系、对象类型和渲染心智模型 --> | |
| <!-- 原点: <user>的位置永远是坐标系的原点 (0,0,0) --> | |
| **3D坐标系 (包围盒定义法):** | |
| - **`bbox:"minX,minY,minZ,maxX,maxY,maxZ"`**: 使用6个值定义一个物体的三维长方体包围盒。 | |
| - `minX`, `maxX`: 物体在 **X轴 (左右)** 上的最小和最大坐标。 | |
| - `minY`, `maxY`: 物体在 **Y轴 (前后)** 上的最小和最大坐标。 | |
| - `minZ`, `maxZ`: 物体在 **Z轴 (上下)** 上的最小和最大坐标。 | |
| --- | |
| **核心渲染心智模型 (边界定义法):** | |
| 你的任务是**直接定义一个物体所占据的精确三维空间**。你需要通过指定其长方体包围盒(`bbox`)的六个边界坐标来实现。 | |
| **思考边界的流程:** | |
| 1. **确定垂直边界 (Z轴):** | |
| * `minZ`: 物体的**底部**在哪个高度?(例如,地面是 `0`,桌面上是 `1`) | |
| * `maxZ`: 物体的**顶部**在哪个高度?(通常是 `minZ + 物体高度`) | |
| 2. **确定水平边界 (X轴和Y轴):** | |
| * 根据叙述中物体的位置(例如“在你左前方5米处”)和它的尺寸(宽度和深度),直接推断出它的空间范围。 | |
| * `minX`, `maxX`: 物体最左和最右的边界。 | |
| * `minY`, `maxY`: 物体最后和最前的边界。 | |
| **实践指南:** | |
| * **示例**: 一个身高1.8米、宽/深0.8米的士兵,其中心在 `(-1, 8)`,并**站立在地面上**。 | |
| * **垂直边界 (Z):** | |
| * 他站在地面上,所以 `minZ = 0`。 | |
| * 他身高1.8米,所以 `maxZ = 0 + 1.8 = 1.8`。 | |
| * **水平边界 (X, Y):** | |
| * 他的中心在 `X=-1`,宽度为`0.8`,所以他的X轴范围是 `[-1.4, -0.6]`。 | |
| * 他的中心在 `Y=8`,深度为`0.8`,所以他的Y轴范围是 `[7.6, 8.4]`。 | |
| * **最终 `bbox`**: `bbox:"-1.4,7.6,0,-0.6,8.4,1.8"` | |
| * **垂直位移**: 如果该士兵站在一个**4米高的平台**上: | |
| * **垂直边界 (Z):** | |
| * 他的脚踩在4米高度,所以 `minZ = 4`。 | |
| * 他的顶部在 `maxZ = 4 + 1.8 = 5.8`。 | |
| * **水平边界 (X, Y):** 不变。 | |
| * **最终 `bbox`**: `bbox:"-1.4,7.6,4,-0.6,8.4,5.8"` | |
| --- | |
| **渲染尺度与符号化抽象:** | |
| (此部分原则不变,仅在示例中应用新语法) | |
| 当叙事涉及宏大尺度时,系统**必须**采用艺术化的符号处理。在 `<visionThink>` 中,**必须**说明场景已进行符号化抽象,布局仅代表相对关系。 | |
| --- | |
| **对象类型 (type):** | |
| (无变化) | |
| - `友方 (Friendly)`, `敌方 (Hostile)`, `中立 (Neutral)`, `物品 (Item)`, `地形 (Terrain)` | |
| </coordinate-system-and-types> | |
| <example> | |
| <!-- 示例 1: 标准战术场景 (边界定义法) --> | |
| <!-- 场景叙述: 你正蹲伏在一张翻倒的金属桌后。一名敌方士兵在你前方8米、偏左1米的位置。在你右侧10米、前方7米处,有一个4米高的猫道平台,一名狙击手正站在平台边缘瞄准你。 --> | |
| <visionThink> | |
| 基于当前场景分析,这是一个标准的战术环境,使用边界定义法。 | |
| 1. **场景设置**: 视野范围 `size` 设为25米。 | |
| 2. **识别并定义对象边界 (以<user>为原点)**: | |
| - **翻倒的金属桌 (地形)**: 2米宽x1米深x1米高,中心在(0, 1.5),放在地面上。 | |
| * 边界: X轴[-1, 1], Y轴[1, 2], Z轴[0, 1]。 | |
| * `bbox:"-1,1,0,1,2,1"` | |
| - **地面士兵 (敌方)**: 0.8米宽x0.8米深x1.8米高,中心在(-1, 8),站在地面上。 | |
| * 边界: X轴[-1.4, -0.6], Y轴[7.6, 8.4], Z轴[0, 1.8]。 | |
| * `bbox:"-1.4,7.6,0,-0.6,8.4,1.8"` | |
| - **猫道平台 (地形)**: 6米宽x8米深x4米高,中心在(10, 7),从地面建起。 | |
| * 边界: X轴[7, 13], Y轴[3, 11], Z轴[0, 4]。 | |
| * `bbox:"7,3,0,13,11,4"` | |
| - **狙击手 (敌方)**: 0.8米宽x0.8米深x1.8米高,中心在(10, 7),站在4米高的平台上。 | |
| * 边界: X轴[9.6, 10.4], Y轴[6.6, 7.4], Z轴[4, 5.8]。 | |
| * `bbox:"9.6,6.6,4,10.4,7.4,5.8"` | |
| </visionThink> | |
| <vision3d size="25"> | |
| <item bbox:"-1,1,0,1,2,1"; content:"翻倒的桌子"; type:"地形";/> | |
| <item bbox:"-1.4,7.6,0,-0.6,8.4,1.8"; content:"士兵"; type:"敌方";/> | |
| <item bbox:"7,3,0,13,11,4"; content:"猫道平台"; type:"地形";/> | |
| <item bbox:"9.6,6.6,4,10.4,7.4,5.8"; content:"狙击手"; type:"敌方";/> | |
| </vision3d> | |
| </example> | |
| <example> | |
| <!-- 示例 2: 宏大尺度场景的符号化抽象 (边界定义法) --> | |
| <!-- 场景叙述: 你的飞船悬停在太阳的北极上方。星图显示:水星在右前方,地球在左方。一支敌对舰队从后方偏右的火星方向跃迁而来。 --> | |
| <visionThink> | |
| 当前场景是天文尺度,必须执行符号化抽象,布局仅代表相对位置。 | |
| 1. **处理方式**: 使用BBox定义法在一个 `size="100"` 的概念棋盘上布置符号。 | |
| 2. **识别并定义对象边界 (关系优先)**: | |
| - **太阳 (地形)**: 符号中心(0,0),尺寸(10,10,10),为作参考其顶部与棋盘平面对齐。 | |
| * 边界: X轴[-5, 5], Y轴[-5, 5], Z轴[-10, 0]。 | |
| * `bbox:"-5,-5,-10,5,5,0"` | |
| - **水星 (中立)**: 符号中心(20, 30),尺寸(2,2,2),在棋盘平面上。 | |
| * 边界: X轴[19, 21], Y轴[29, 31], Z轴[0, 2]。 | |
| * `bbox:"19,29,0,21,31,2"` | |
| - **地球 (中立)**: 符号中心(-40, 0),尺寸(2,2,2),在棋盘平面上。 | |
| * 边界: X轴[-41, -39], Y轴[-1, 1], Z轴[0, 2]。 | |
| * `bbox:"-41,-1,0,-39,1,2"` | |
| - **敌对舰队 (敌方)**: 符号中心(23, -42),尺寸(5,5,2)以示威胁,在棋盘平面上。 | |
| * 边界: X轴[20.5, 25.5], Y轴[-44.5, -39.5], Z轴[0, 2]。 | |
| * `bbox:"20.5,-44.5,0,25.5,-39.5,2"` | |
| </visionThink> | |
| <vision3d size="100"> | |
| <item bbox:"-5,-5,-10,5,5,0"; content:"太阳 (符号)"; type:"地形";/> | |
| <item bbox:"19,29,0,21,31,2"; content:"水星 (符号)"; type:"中立";/> | |
| <item bbox:"-41,-1,0,-39,1,2"; content:"地球 (符号)"; type:"中立";/> | |
| <item bbox:"20.5,-44.5,0,25.5,-39.5,2"; content:"敌对舰队 (符号)"; type:"敌方";/> | |
| </vision3d> | |
| </example> | |
| </vision-renderer-guide> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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/[email protected]/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>(.*?)<\/item>/gis), | |
| ...xml.matchAll(/<item\s+([^>]+)\s*\/>/gis) | |
| ]; | |
| itemMatches.forEach(match => { | |
| const obj = {}; | |
| let contentStr = ''; | |
| if (match[0].includes('</item>')) { | |
| // 旧格式:<item>pos:x,y,z; dim:w,d,h; ...</item> | |
| 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 { | |
| // 新格式:<item bbox:"..." ... /> | |
| 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(`<div class="vision3d-container"><canvas class="vision3d-canvas"></canvas></div>`); | |
| 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 size="([\d.]+)">(.*?)<\/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, `<div id="${placeholderId}"></div>`); | |
| 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(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment