Last active
August 2, 2025 02:52
-
-
Save CodeBoy2006/5b5bf6273cede317fce287b2fdcc244f to your computer and use it in GitHub Desktop.
Revisions
-
CodeBoy2006 revised this gist
Jul 21, 2025 . 2 changed files with 188 additions and 145 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -1,7 +1,7 @@ // ==UserScript== // @name 全局脚本 - 3D视野组件 (单 Canvas v10.8 Bbox Fixed) // @version 10.8 // @description 实现遮挡物半透明效果,当模型包裹另一个时,外层模型自动透视,支持玩家显示开关。已优化移动端触摸支持和性能。新增并修复了bbox格式兼容。 // @author Codeboy, 优化 by AI // @match */* // @grant none @@ -21,13 +21,13 @@ }; 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) { @@ -102,6 +102,79 @@ 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(); @@ -117,7 +190,6 @@ } cancelAnimationFrame(st.raf); st.container.removeEventListener('mousemove', st.onMouseMove); st.container.removeEventListener('touchstart', st.onTouchStart); st.container.removeEventListener('click', st.onClick); @@ -142,16 +214,7 @@ 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]; @@ -190,7 +253,10 @@ 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)); @@ -212,33 +278,25 @@ 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; @@ -247,24 +305,19 @@ 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 { @@ -273,7 +326,6 @@ } } function onClick(e) { updatePointer(e); raycaster.setFromCamera(pointer, camera); @@ -284,9 +336,8 @@ } container.addEventListener('mousemove', onMouseMove); container.addEventListener('touchstart', onTouchStart, { passive: false }); container.addEventListener('click', onClick); function updateLabelScale(sprite) { if (!sprite) return; @@ -300,7 +351,6 @@ frameCount++; controls.update(); if (frameCount % 3 === 0) { prevOccluders.forEach(mesh => { if (mesh !== activeMesh) { @@ -312,7 +362,7 @@ 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(); @@ -356,71 +406,60 @@ 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); @@ -435,36 +474,27 @@ /* --------------- 批量渲染函数 --------------- */ 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'); @@ -473,7 +503,6 @@ messageSentResetTimer = setTimeout(() => { messageSentRecently = false; }, 1000); }); } if (eventTypes.GENERATION_STARTED) { eventSource.on(eventTypes.GENERATION_STARTED, () => { logDebug(`Event: GENERATION_STARTED (messageSentRecently: ${messageSentRecently})`); @@ -487,7 +516,6 @@ } }); } if (eventTypes.GENERATION_ENDED) { eventSource.on(eventTypes.GENERATION_ENDED, () => { logDebug('Event: GENERATION_ENDED'); @@ -502,7 +530,6 @@ } }); } if (eventTypes.MESSAGE_RECEIVED) { eventSource.on(eventTypes.MESSAGE_RECEIVED, (messageId) => { logDebug(`Event: MESSAGE_RECEIVED, messageId: ${messageId}`); @@ -512,48 +539,43 @@ } }); } 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视野组件'); @@ -563,7 +585,6 @@ }); } function waitReady() { if (typeof SillyTavern !== 'undefined' && SillyTavern.chat && SillyTavern.eventSource && SillyTavern.eventTypes) { logDebug('SillyTavern 环境就绪,初始化3D视野组件'); @@ -575,7 +596,7 @@ 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); } 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 charactersOriginal file line number Diff line number Diff line change @@ -9,8 +9,8 @@ 1. **`<visionThink>` 块:** * 目的: 提供一个**强制性的、详尽的**思考过程,解释为何需要生成视觉布局,以及如何根据**当前回合的事件**来构建它。 * 内容要求: * **精确识别**:列出所有需要被渲染的对象。 * **严谨定位**:根据当前回合的叙述,为每个对象确定其精确的 `bbox`(包围盒)。所有坐标均以**`<user>`的位置为原点(0,0,0)**。 * **合理分类**:为每个对象分配合适的类型 (`type`)。 * **数据来源**: 所有渲染信息必须**严格源于**本回合的叙述。**禁止凭空捏造**场景细节。 @@ -19,23 +19,18 @@ * 操作限制: **只能**使用在 `<vision-syntax>` 中定义的标签和格式。 * 严格格式化规则: * **完全遵守**指定的标签和参数语法。 * 所有字符串值**必须**使用**双引号**包围。 * 所有参数名和类型名**必须**使用**英文**。 **关键放置:** `<visionThink>` 和 `<vision3d>` 块**必须总是成对出现**,并且**只能出现在回复的绝对末尾**。 </rule> <vision-syntax> <!-- 定义三维视觉渲染的精确语法 --> <!-- 核心方法: 使用 bbox (包围盒) 来精确定义空间体积 --> <vision3d size="数字"> <item bbox:"minX,minY,minZ,maxX,maxY,maxZ"; content:"文本"; type:"分类";/> <!-- ... more items ... --> </vision3d> @@ -45,82 +40,109 @@ <!-- 定义坐标系、对象类型和渲染心智模型 --> <!-- 原点: <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> -
CodeBoy2006 revised this gist
Jul 20, 2025 . 1 changed file with 144 additions and 223 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -1,8 +1,8 @@ // ==UserScript== // @name 全局脚本 - 3D视野组件 (单 Canvas v10.6 Mobile Friendly) // @version 10.6 // @description 实现遮挡物半透明效果,当模型包裹另一个时,外层模型自动透视,支持玩家显示开关。已优化移动端触摸支持和性能。 // @author Codeboy, 优化 by AI // @match */* // @grant none // ==/UserScript== @@ -61,7 +61,7 @@ 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} `; @@ -110,15 +110,18 @@ 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 => { @@ -139,7 +142,6 @@ async function render3D(id, $placeholder, worldSize, xml) { if (!await ensureThree()) return; const items = [...xml.matchAll(/<item>(.*?)<\/item>/gis)].map(m => { const obj = {}; m[1].split(';').forEach(p => { @@ -148,48 +150,31 @@ }); 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 || '中立' }; }); $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); @@ -198,45 +183,22 @@ 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); 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; @@ -250,125 +212,152 @@ resizeObserver.observe(container); syncSize(); /* ------------ 交互优化 (开始) ------------ */ const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let activeMesh = null; // 用于替代 hoverMesh,同时服务于桌面和移动端 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 }); // 使用 passive: false 允许 preventDefault 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(); // 【性能优化】将高成本的遮挡计算限制为每3帧执行一次 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); // 使用 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}`); @@ -411,15 +400,11 @@ 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>`); @@ -433,11 +418,7 @@ if ($mesText && $mesText.length) { $mesText.html(SillyTavern.messageFormatting( html, msgObj.name, msgObj.is_system, msgObj.is_user, messageId )); const renderDelay = isImmediate ? 0 : 50; @@ -464,6 +445,10 @@ } }); } // 省略后面的事件监听设置和启动函数,它们与原版相同,无需修改... // ... (setupOptimizedEventListeners, setupCleanupOnUnload, waitReady) ... // 为保持代码完整性,将剩余部分粘贴在此处 /* --------------- 优化的事件监听设置 --------------- */ function setupOptimizedEventListeners() { @@ -477,51 +462,37 @@ 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) { @@ -532,7 +503,6 @@ }); } if (eventTypes.MESSAGE_RECEIVED) { eventSource.on(eventTypes.MESSAGE_RECEIVED, (messageId) => { logDebug(`Event: MESSAGE_RECEIVED, messageId: ${messageId}`); @@ -543,37 +513,27 @@ }); } 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'); @@ -582,34 +542,14 @@ }); } 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('优化的事件监听器设置完成'); } @@ -625,36 +565,17 @@ /* ------------------ 启动脚本 ------------------ */ 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})`); } else { setTimeout(waitReady, 300); } -
CodeBoy2006 revised this gist
Jul 19, 2025 . 2 changed files with 257 additions and 131 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -1,8 +1,8 @@ // ==UserScript== // @name 全局脚本 - 3D视野组件 (单 Canvas v10.5) // @version 10.5 // @description 实现遮挡物半透明效果,当模型包裹另一个时,外层模型自动透视,支持玩家显示开关 // @author Codeboy // @match */* // @grant none // ==/UserScript== @@ -22,8 +22,14 @@ 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); @@ -66,41 +72,43 @@ 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; } /* ------------ 运行时存储 & 清理 -------------- */ const active = new Map(); const renderTimers = new Map(); function cleanup(id) { const st = active.get(id); if (!st) return; // 清理防抖计时器 const timer = renderTimers.get(id); @@ -133,53 +141,98 @@ /* ---------- 解析 <vision3d> 数据 ---------- */ const items = [...xml.matchAll(/<item>(.*?)<\/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(`<div class="vision3d-container"><canvas class="vision3d-canvas"></canvas></div>`); 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, 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); } logDebug(`玩家模型已添加 (标签: ${PLAYER_CONFIG.showLabel})`); } else { logDebug('玩家模型已隐藏'); } /* items mesh & label */ 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 ); 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); }); @@ -188,76 +241,144 @@ 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 mouse = new THREE.Vector2(); let hoverMesh = null; let prevOccluders = []; // 存储上一帧的遮挡物 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) { 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() { controls.update(); // 1. 恢复上一帧的遮挡物 prevOccluders.forEach(mesh => { if (mesh !== hoverMesh) { mesh.material.opacity = mesh.userData.baseOpacity; } mesh.userData.isOccluder = false; }); prevOccluders = []; // 2. 处理悬停效果 raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(meshes); const newHoverMesh = intersects[0]?.object || null; if (newHoverMesh !== hoverMesh) { if (hoverMesh) { hoverMesh.material.opacity = hoverMesh.userData.baseOpacity; hoverMesh.userData.labelSprite.scale.copy( hoverMesh.userData.labelSprite.userData.baseScale ); } if (newHoverMesh) { newHoverMesh.material.opacity = 0.5; newHoverMesh.userData.labelSprite.scale.multiplyScalar(1.25); } hoverMesh = newHoverMesh; } // 3. 处理遮挡效果 (对玩家和悬停物体) const targets = []; if (playerMesh && PLAYER_CONFIG.visible) targets.push(playerMesh); if (hoverMesh) targets.push(hoverMesh); 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 === hoverMesh) continue; // 设置为半透明 occluder.material.opacity = 0.2; occluder.userData.isOccluder = true; 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; prevOccluders.push(closest); } } }); // 4. 更新标签大小(仅当存在时) if (playerLabel && PLAYER_CONFIG.visible && PLAYER_CONFIG.showLabel) { updateLabelScale(playerLabel); } meshes.forEach(m => updateLabelScale(m.userData.labelSprite)); // 5. 渲染场景 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}, 玩家可见: ${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); @@ -269,7 +390,6 @@ /* --------------- 立即渲染函数 --------------- */ function renderVisionMessageImmediate(messageId, $mesText) { const existingTimer = renderTimers.get(messageId); if (existingTimer) { clearTimeout(existingTimer); @@ -291,20 +411,19 @@ const reg = /<vision3d size="([\d.]+)">(.*?)<\/vision3d>/s; const match = msgObj.mes.match(reg); // 清理旧实例 if (!match && active.has(messageId)) { cleanup(messageId); } // 创建新实例 if (match) { cleanup(messageId); 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) { @@ -313,9 +432,15 @@ } 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) { @@ -362,11 +487,8 @@ logDebug('Event: MESSAGE_SENT'); messageSentRecently = true; if (messageSentResetTimer) clearTimeout(messageSentResetTimer); messageSentResetTimer = setTimeout(() => { messageSentRecently = false; messageSentResetTimer = null; @@ -381,19 +503,13 @@ 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); } }); } @@ -403,14 +519,9 @@ 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) { @@ -466,7 +577,6 @@ if (eventTypes.MESSAGES_DELETED) { eventSource.on(eventTypes.MESSAGES_DELETED, () => { logDebug('Event: MESSAGES_DELETED'); active.forEach((_, id) => cleanup(id)); setTimeout(renderAllVisionMessages, 300); }); @@ -476,14 +586,12 @@ 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, @@ -496,7 +604,7 @@ otherDebouncedEvents.forEach(eventType => { if (eventType) { eventSource.on(eventType, () => { logDebug(`Event (Other): ${eventType}`); setTimeout(renderAllVisionMessages, 300); }); } @@ -542,7 +650,10 @@ }; window.vision3dRenderAll = renderAllVisionMessages; // 暴露玩家配置到全局(便于动态调整) window.vision3dPlayerConfig = PLAYER_CONFIG; logDebug(`3D视野组件初始化完成 (玩家可见: ${PLAYER_CONFIG.visible})`); } else { setTimeout(waitReady, 300); 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 charactersOriginal file line number Diff line number Diff line change @@ -42,19 +42,36 @@ </vision-syntax> <coordinate-system-and-types> <!-- 定义坐标系、对象类型和渲染心智模型 --> <!-- 原点: <user>的位置永远是坐标系的原点 (0,0,0) --> **3D坐标系 (空间):** - `pos:X,Y,Z`: 对象的**底部中心点**坐标。 - `X`: 左右。正数为右,负数为左。 - `Y`: 前后。正数为前,负数为后。 - `Z`: 上下。**这是对象底部所在的垂直高度**。 - `dim:W,D,H`: 对象的尺寸。 - `W (Width)`: 沿X轴的宽度 (左右)。 - `D (Depth)`: 沿Y轴的深度 (前后)。 - `H (Height)`: 沿Z轴的高度 (上下)。 --- **核心渲染心智模型 (非常重要):** **把 `pos.Z` 想象成“地基高度”**。渲染器的工作方式如下: 1. 它首先找到你指定的 `(pos.X, pos.Y)` 水平位置。 2. 然后,它上升到你指定的 `pos.Z` 高度。 3. **最后,它将尺寸为 `dim` 的物体放置在这个“地基”上,使其底部与 `pos.Z` 高度对齐。** 这意味着物体的实际垂直中心位置是由渲染器**自动计算**的,公式为: `实际垂直中心 = pos.Z + (dim.H / 2)` **实践指南:** * 要让一个物体(如一个人)**站在地面上**,你应该设置 `pos.Z:0`。渲染器会自动处理他的身高。 * 要让一个**2米高的箱子**顶部与一个**4米高的平台**齐平,你应该将箱子的 `pos.Z` 设置为 `2` (因为 `2 + (2/2) = 3` 是其中心, 但我们关心的是顶部,即`2 + 2 = 4`)。不,这个例子不对。正确的是:如果平台顶部是4米,那么箱子的 `pos.Z` 就应该是 `2` (因为箱子底部在2米高,高度为2米,顶部就正好在4米)。 * 要让一个物体**漂浮在离地面3米**高的空中,你应该设置 `pos.Z:3`。 --- **对象类型 (type):** - `友方 (Friendly)`: 盟友、友好的NPC等。 - `敌方 (Hostile)`: 敌人、具有威胁的实体。 @@ -65,47 +82,45 @@ </coordinate-system-and-types> <example> <!-- 示例:演示了基于新心智模型的思考过程和对应的渲染标签 --> <!-- 场景叙述: 你(代号“凤凰”)正蹲伏在一张翻倒的重金属桌后,这里是“奇点实验室”被毁坏的服务器机房。在你正前方8米处,一名敌对士兵正以一个高大的服务器机柜为掩护。你的右侧,一条金属 catwalk(猫道)部分坍塌,形成了一个斜坡,另一名狙击手正占据着一个4米高的平台残余部分。你的盟友,阿里斯博士,正蜷缩在左前方远处的一组服务器后方,他身旁的墙壁上,一个破损的动力导管正迸射出危险的电火花。一个至关重要的“数据核心”掉落在你左手边的地板上。 --> <visionThink> 基于当前场景分析,遵循“地基高度”心智模型: 1. **场景设置**: 这是一个复杂的多层次战斗场景,必须使用 `<vision3d>` 渲染。设定视野范围 `size` 为25米。 2. **识别并定位对象 (以<user>为原点 (0,0,0))**: - **观察者角色 (“凤凰”)**: 位于原点(0,0,0)。 - **翻倒的金属桌 (地形)**: 你正前方的掩体,直接放在地面上。`pos.Z` 为0。`pos:0,1.5,0; dim:2,1,1;` (渲染器会自动将其垂直中心置于 0 + 1/2 = 0.5) - **地面士兵 (敌方)**: 正前方8米,站在地面上。`pos.Z` 为0。`pos:-1,8,0; dim:0.8,0.8,1.8;` (其垂直中心将被渲染在 0 + 1.8/2 = 0.9) - **士兵掩体-服务器机柜 (地形)**: 在地面士兵前方,也放在地面上。`pos.Z` 为0。`pos:-1,9,0; dim:1,2,3;` - **猫道平台 (地形)**: 右侧,本身有4米高。这意味着它的**底部在地面上**,`pos.Z` 为0。`pos:10,6,0; dim:6,8,4;` (平台本身高4米,顶部自然在4米高度) - **坍塌的斜坡 (地形)**: 连接地面和猫道平台。可以看作一个底部在地面,逐渐升高的物体。为简化,我们将其底部设为0。`pos:6,3,0; dim:4,6,3.5;` - **狙击手 (敌方)**: 站在猫道平台上。猫道平台的顶部高度为4米,所以狙击手的**脚踩在4米高处**。因此他的 `pos.Z` 为4。`pos:10,7,4; dim:0.8,0.8,1.8;` (其垂直中心将被渲染在 4 + 1.8/2 = 4.9) - **阿里斯博士 (友方)**: 左前方远处,蜷缩在地面上。`pos.Z` 为0。`pos:-10,14,0; dim:0.8,0.8,1;` - **博士掩体-服务器组 (地形)**: 在博士前方,放在地面上。`pos.Z` 为0。`pos:-10,15,0; dim:4,2,3;` - **电火花导管 (地形)**: 在博士旁边的墙上,离地2米高。因此它的底部高度 `pos.Z` 为2。`pos:-12,14,2; dim:0.2,1,1;` - **数据核心 (物品)**: 在你左手边的地板上。`pos.Z` 为0。`pos:-2,1,0; dim:0.2,0.2,0.1;` </visionThink> <vision3d size="25"> <!-- Your Cover --> <item>pos:0,1.5,0; dim:2,1,1; content:"翻倒的桌子"; type:"地形";</item> <!-- Hostile Soldier on the ground floor --> <item>pos:-1,8,0; dim:0.8,0.8,1.8; content:"士兵"; type:"敌方";</item> <!-- Server Rack cover for the ground soldier --> <item>pos:-1,9,0; dim:1,2,3; content:"服务器机柜"; type:"地形";</item> <!-- Elevated Catwalk Platform --> <item>pos:10,6,0; dim:6,8,4; content:"猫道平台"; type:"地形";</item> <!-- Collapsed Ramp leading to the catwalk --> <item>pos:6,3,0; dim:4,6,3.5; content:"坍塌的斜坡"; type:"地形";</item> <!-- Hostile Sniper on the catwalk --> <item>pos:10,7,4; dim:0.8,0.8,1.8; content:"狙击手"; type:"敌方";</item> <!-- Dr. Aris, your ally --> <item>pos:-10,14,0; dim:0.8,0.8,1; content:"阿里斯博士"; type:"友方";</item> <!-- Server Racks providing cover for the doctor --> <item>pos:-10,15,0; dim:4,2,3; content:"服务器组"; type:"地形";</item> <!-- Damaged Power Conduit --> <item>pos:-12,14,2; dim:0.2,1,1; content:"电火花"; type:"地形";</item> <!-- The objective item --> <item>pos:-2,1,0; dim:0.2,0.2,0.1; content:"数据核心"; type:"物品";</item> </vision3d> </example> </vision-renderer-guide>{{setvar::vison3d::<vison3d>}} -
CodeBoy2006 created this gist
Jul 19, 2025 .There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,553 @@ // ==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/[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} .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; /* ---------- 解析 <vision3d> 数据 ---------- */ const items = [...xml.matchAll(/<item>(.*?)<\/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(`<div class="vision3d-container"><canvas class="vision3d-canvas"></canvas></div>`); 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 size="([\d.]+)">(.*?)<\/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, `<div id="${placeholderId}"></div>`); // 如果没有传入$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(); })(); 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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,111 @@ <vision-renderer-guide> <rule> **启用附属模块[视觉投影仪] - 强制执行** 视觉投影仪 (Scene Projector) 被激活为一个强制性子模块,用于处理所有相关的三维空间布局和视野渲染。其功能至关重要,**绝不能被跳过**。 **核心职责:** 在任何需要以三维空间布局来展示角色视野的回合末尾,视觉投影仪 **必须** 按顺序执行以下两个动作: 1. **`<visionThink>` 块:** * 目的: 提供一个**强制性的、详尽的**思考过程,解释为何需要生成视觉布局,以及如何根据**当前回合的事件**来构建它。 * 内容要求: * **精确识别**:列出所有需要被渲染的对象(角色、物品、地形等)。 * **严谨定位**:根据当前回合的叙述,为每个对象确定其精确的坐标 (`pos`) 和尺寸 (`dim`)。所有坐标均以**`<user>`的位置为原点(0,0,0)**。 * **合理分类**:为每个对象分配合适的类型 (`type`)。 * **数据来源**: 所有渲染信息必须**严格源于**本回合的叙述。**禁止凭空捏造**场景细节。 2. **`<vision3d>` 块:** * 目的: 提供**精确的、可执行的**标签块,用于实际渲染在 `<visionThink>` 中规划好的场景。如果 `<visionThink>` 确定需要渲染,则此块**不得省略**。 * 操作限制: **只能**使用在 `<vision-syntax>` 中定义的标签和格式。 * 严格格式化规则: * **完全遵守**指定的标签和参数语法。 * 所有字符串值**必须**使用**双引号**包围 (例如, `content:"哥布林"`). * 所有参数名和类型名**必须**使用**英文** (例如, `pos`, `dim`, `type`). * 坐标和尺寸的数值之间用逗号 `,` 分隔。 * 参数之间用分号 `;` 分隔。 **关键放置:** `<visionThink>` 和 `<vision3d>` 块**必须总是成对出现**,并且**只能出现在回复的绝对末尾**,在所有叙事或情节内容之后。它们**绝不能**出现在初步思考或预计算阶段。 **失败条款:** 在回合事件明显需要进行视觉布局更新时,省略 `<visionThink>` 或 `<vision3d>` 块,是对视觉投影仪模块的**一次严重失败**。勤勉和完整性至关重要。 </rule> <vision-syntax> <!-- 定义三维视觉渲染的精确语法 --> <!-- 3D 空间视角 --> <vision3d size="数字"> <item>pos:X,Y,Z; dim:W,D,H; content:"文本"; type:"分类";</item> <!-- ... more items ... --> </vision3d> </vision-syntax> <coordinate-system-and-types> <!-- 定义坐标系和对象类型 --> <!-- 原点: <user>的位置永远是坐标系的原点 (0,0,0) --> **3D坐标系 (空间):** - `pos:X,Y,Z`: 对象的中心点坐标。 - `X`: 左右。正右负左。 - `Y`: 前后。正前负后。 - `Z`: 上下。正上负下。 - `dim:W,D,H`: 对象的尺寸。 - `W (Width)`: 沿X轴的宽度。 - `D (Depth)`: 沿Y轴的深度。 - `H (Height)`: 沿Z轴的高度。 **对象类型 (type):** - `友方 (Friendly)`: 盟友、友好的NPC等。 - `敌方 (Hostile)`: 敌人、具有威胁的实体。 - `中立 (Neutral)`: 不具有明显立场的NPC、动物等。 - `物品 (Item)`: 可交互的物品、道具、宝箱等。 - `地形 (Terrain)`: 静态的环境元素,如墙壁、柱子、障碍物等。 </coordinate-system-and-types> <example> <!-- 示例:演示了思考过程和对应的渲染标签 --> <!-- 场景叙述: 你(代号“凤凰”)正蹲伏在一张翻倒的重金属桌后,这里是“奇点实验室”被毁坏的服务器机房。在你正前方8米处,一名敌对士兵正以一个高大的服务器机柜为掩护。你的右侧,一条金属 catwalk(猫道)部分坍塌,形成了一个斜坡,另一名狙击手正占据着4米高的平台残余部分。你的盟友,阿里斯博士,正蜷缩在左前方远处的一组服务器后方,他身旁的墙壁上,一个破损的动力导管正迸射出危险的电火花。一个至关重要的“数据核心”掉落在你左手边的地板上。 --> <visionThink> <!-- 基于当前场景分析: 1. **场景设置**: 这是一个复杂的多层次战斗场景,包含高低差(地面、坍塌的猫道)、多种类型的实体(友方、敌方、物品)和掩体。必须使用 `<vision3d>` 进行渲染。设定视野范围 `size` 为25米,以清晰包含所有关键元素。 2. **识别并定位对象**: - **观察者角色 (在这个场景是“凤凰”)**: 位于原点(0,0,0),作为所有坐标的参考。 - **翻倒的金属桌 (地形)**: 你正前方的掩体,假设它高1米,宽2米。`pos:0,1.5,0.5; dim:2,1,1;` - **地面士兵 (敌方)**: 正前方8米(Y=8)。假设他在桌子左侧探头,所以X为负。身高1.8米。`pos:-1,8,0.9; dim:0.8,0.8,1.8;` - **士兵掩体-服务器机柜 (地形)**: 在地面士兵前方,提供掩护。`pos:-1,9,1.5; dim:1,2,3;` - **猫道平台 (地形)**: 右侧,4米高。假设平台中心点在右侧10米(X=10),前方6米(Y=6)处。`pos:10,6,3.75; dim:6,8,0.5;` - **坍塌的斜坡 (地形)**: 连接地面和猫道平台,用一个倾斜的长方体来近似表示。`pos:6,3,1.75; dim:4,6,3.5;` - **狙击手 (敌方)**: 在猫道平台上。`pos:10,7,4.9; dim:0.8,0.8,1.8;` (4米平台+0.9米身高中心) - **阿里斯博士 (友方)**: 左前方远处,蜷缩着。`pos:-10,14,0.5; dim:0.8,0.8,1;` (蜷缩高度1米) - **博士掩体-服务器组 (地形)**: 在博士前方。`pos:-10,15,1.5; dim:4,2,3;` - **电火花导管 (地形)**: 博士旁边的墙上,设定墙在X=-12处。`pos:-12,14,2; dim:0.2,1,1;` - **数据核心 (物品)**: 在你左手边地板上。`pos:-2,1,0.05; dim:0.2,0.2,0.1;` --> </visionThink> <vision3d size="25"> <!-- Your Cover --> <item>pos:0,1.5,0.5; dim:2,1,1; content:"翻倒的桌子"; type:"地形";</item> <!-- Hostile Soldier on the ground floor --> <item>pos:-1,8,0.9; dim:0.8,0.8,1.8; content:"士兵"; type:"敌方";</item> <!-- Server Rack cover for the ground soldier --> <item>pos:-1,9,1.5; dim:1,2,3; content:"服务器机柜"; type:"地形";</item> <!-- Elevated Catwalk Platform --> <item>pos:10,6,3.75; dim:6,8,0.5; content:"猫道平台"; type:"地形";</item> <!-- Collapsed Ramp leading to the catwalk --> <item>pos:6,3,1.75; dim:4,6,3.5; content:"坍塌的斜坡"; type:"地形";</item> <!-- Hostile Sniper on the catwalk --> <item>pos:10,7,4.9; dim:0.8,0.8,1.8; content:"狙击手"; type:"敌方";</item> <!-- Dr. Aris, your ally --> <item>pos:-10,14,0.5; dim:0.8,0.8,1; content:"阿里斯博士"; type:"友方";</item> <!-- Server Racks providing cover for the doctor --> <item>pos:-10,15,1.5; dim:4,2,3; content:"服务器组"; type:"地形";</item> <!-- Damaged Power Conduit --> <item>pos:-12,14,2; dim:0.2,1,1; content:"电火花"; type:"地形";</item> <!-- The objective item --> <item>pos:-2,1,0.05; dim:0.2,0.2,0.1; content:"数据核心"; type:"物品";</item> </vision3d> </example> </dataTable-operatior-guide>