Skip to content

Instantly share code, notes, and snippets.

@CodeBoy2006
Last active August 2, 2025 02:52
Show Gist options
  • Save CodeBoy2006/5b5bf6273cede317fce287b2fdcc244f to your computer and use it in GitHub Desktop.
Save CodeBoy2006/5b5bf6273cede317fce287b2fdcc244f to your computer and use it in GitHub Desktop.

Revisions

  1. CodeBoy2006 revised this gist Jul 21, 2025. 2 changed files with 188 additions and 145 deletions.
    165 changes: 93 additions & 72 deletions Vision3D.js
    Original file line number Diff line number Diff line change
    @@ -1,7 +1,7 @@
    // ==UserScript==
    // @name 全局脚本 - 3D视野组件 (单 Canvas v10.6 Mobile Friendly)
    // @version 10.6
    // @description 实现遮挡物半透明效果,当模型包裹另一个时,外层模型自动透视,支持玩家显示开关。已优化移动端触摸支持和性能。
    // @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 = [...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 || '中立' };
    });
    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; // 用于替代 hoverMesh,同时服务于桌面和移动端
    let activeMesh = null;
    let prevOccluders = [];
    let frameCount = 0; // 【性能优化】帧计数器
    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 }); // 使用 passive: false 允许 preventDefault
    container.addEventListener('click', onClick); // 用于处理桌面端点击空白取消
    /* ------------ 交互优化 (结束) ------------ */
    container.addEventListener('touchstart', onTouchStart, { passive: false });
    container.addEventListener('click', onClick);

    function updateLabelScale(sprite) {
    if (!sprite) return;
    @@ -300,7 +351,6 @@
    frameCount++;
    controls.update();

    // 【性能优化】将高成本的遮挡计算限制为每3帧执行一次
    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); // 使用 activeMesh
    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);
    }
    });
    }

    // 省略后面的事件监听设置和启动函数,它们与原版相同,无需修改...
    // ... (setupOptimizedEventListeners, setupCleanupOnUnload, waitReady) ...
    // 为保持代码完整性,将剩余部分粘贴在此处

    /* --------------- 优化的事件监听设置 --------------- */
    // ... 这部分代码没有变化,故折叠 ...
    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})`);
    logDebug(`3D视野组件初始化完成 (玩家可见: ${PLAYER_CONFIG.visible}), 支持bbox格式`);
    } else {
    setTimeout(waitReady, 300);
    }
    168 changes: 95 additions & 73 deletions prompt.xml
    Original file line number Diff line number Diff line change
    @@ -9,8 +9,8 @@
    1. **`<visionThink>` 块:**
    * 目的: 提供一个**强制性的、详尽的**思考过程,解释为何需要生成视觉布局,以及如何根据**当前回合的事件**来构建它。
    * 内容要求:
    * **精确识别**:列出所有需要被渲染的对象(角色、物品、地形等)
    * **严谨定位**:根据当前回合的叙述,为每个对象确定其精确的坐标 (`pos`) 和尺寸 (`dim`)。所有坐标均以**`<user>`的位置为原点(0,0,0)**。
    * **精确识别**:列出所有需要被渲染的对象。
    * **严谨定位**:根据当前回合的叙述,为每个对象确定其精确的 `bbox`(包围盒)。所有坐标均以**`<user>`的位置为原点(0,0,0)**。
    * **合理分类**:为每个对象分配合适的类型 (`type`)。
    * **数据来源**: 所有渲染信息必须**严格源于**本回合的叙述。**禁止凭空捏造**场景细节。

    @@ -19,23 +19,18 @@
    * 操作限制: **只能**使用在 `<vision-syntax>` 中定义的标签和格式。
    * 严格格式化规则:
    * **完全遵守**指定的标签和参数语法。
    * 所有字符串值**必须**使用**双引号**包围 (例如, `content:"哥布林"`).
    * 所有参数名和类型名**必须**使用**英文** (例如, `pos`, `dim`, `type`).
    * 坐标和尺寸的数值之间用逗号 `,` 分隔。
    * 参数之间用分号 `;` 分隔。
    * 所有字符串值**必须**使用**双引号**包围。
    * 所有参数名和类型名**必须**使用**英文**。

    **关键放置:** `<visionThink>` 和 `<vision3d>` 块**必须总是成对出现**,并且**只能出现在回复的绝对末尾**,在所有叙事或情节内容之后。它们**绝不能**出现在初步思考或预计算阶段。

    **失败条款:** 在回合事件明显需要进行视觉布局更新时,省略 `<visionThink>` 或 `<vision3d>` 块,是对视觉投影仪模块的**一次严重失败**。勤勉和完整性至关重要。
    **关键放置:** `<visionThink>` 和 `<vision3d>` 块**必须总是成对出现**,并且**只能出现在回复的绝对末尾**。

    </rule>

    <vision-syntax>
    <!-- 定义三维视觉渲染的精确语法 -->

    <!-- 3D 空间视角 -->
    <!-- 核心方法: 使用 bbox (包围盒) 来精确定义空间体积 -->
    <vision3d size="数字">
    <item>pos:X,Y,Z; dim:W,D,H; content:"文本"; type:"分类";</item>
    <item bbox:"minX,minY,minZ,maxX,maxY,maxZ"; content:"文本"; type:"分类";/>
    <!-- ... more items ... -->
    </vision3d>

    @@ -45,82 +40,109 @@
    <!-- 定义坐标系、对象类型和渲染心智模型 -->
    <!-- 原点: <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轴的高度 (上下)。
    **3D坐标系 (包围盒定义法):**
    - **`bbox:"minX,minY,minZ,maxX,maxY,maxZ"`**: 使用6个值定义一个物体的三维长方体包围盒。
    - `minX`, `maxX`: 物体在 **X轴 (左右)** 上的最小和最大坐标。
    - `minY`, `maxY`: 物体在 **Y轴 (前后)** 上的最小和最大坐标。
    - `minZ`, `maxZ`: 物体在 **Z轴 (上下)** 上的最小和最大坐标。

    ---
    **核心渲染心智模型 (非常重要):**
    **核心渲染心智模型 (边界定义法):**

    你的任务是**直接定义一个物体所占据的精确三维空间**。你需要通过指定其长方体包围盒(`bbox`)的六个边界坐标来实现。

    **把 `pos.Z` 想象成“地基高度”**。渲染器的工作方式如下:
    1. 它首先找到你指定的 `(pos.X, pos.Y)` 水平位置。
    2. 然后,它上升到你指定的 `pos.Z` 高度。
    3. **最后,它将尺寸为 `dim` 的物体放置在这个“地基”上,使其底部与 `pos.Z` 高度对齐。**
    **思考边界的流程:**
    1. **确定垂直边界 (Z轴):**
    * `minZ`: 物体的**底部**在哪个高度?(例如,地面是 `0`,桌面上是 `1`)
    * `maxZ`: 物体的**顶部**在哪个高度?(通常是 `minZ + 物体高度`)

    这意味着物体的实际垂直中心位置是由渲染器**自动计算**的,公式为:
    `实际垂直中心 = pos.Z + (dim.H / 2)`
    2. **确定水平边界 (X轴和Y轴):**
    * 根据叙述中物体的位置(例如“在你左前方5米处”)和它的尺寸(宽度和深度),直接推断出它的空间范围。
    * `minX`, `maxX`: 物体最左和最右的边界。
    * `minY`, `maxY`: 物体最后和最前的边界。

    **实践指南:**
    * 要让一个物体(如一个人)**站在地面上**,你应该设置 `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`。
    * **示例**: 一个身高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)`: 盟友、友好的NPC等。
    - `敌方 (Hostile)`: 敌人、具有威胁的实体。
    - `中立 (Neutral)`: 不具有明显立场的NPC、动物等。
    - `物品 (Item)`: 可交互的物品、道具、宝箱等。
    - `地形 (Terrain)`: 静态的环境元素,如墙壁、柱子、障碍物等。
    (无变化)
    - `友方 (Friendly)`, `敌方 (Hostile)`, `中立 (Neutral)`, `物品 (Item)`, `地形 (Terrain)`

    </coordinate-system-and-types>

    <example>
    <!-- 示例:演示了基于新心智模型的思考过程和对应的渲染标签 -->
    <!-- 场景叙述: 你(代号“凤凰”)正蹲伏在一张翻倒的重金属桌后,这里是“奇点实验室”被毁坏的服务器机房。在你正前方8米处,一名敌对士兵正以一个高大的服务器机柜为掩护。你的右侧,一条金属 catwalk(猫道)部分坍塌,形成了一个斜坡,另一名狙击手正占据着一个4米高的平台残余部分。你的盟友,阿里斯博士,正蜷缩在左前方远处的一组服务器后方,他身旁的墙壁上,一个破损的动力导管正迸射出危险的电火花。一个至关重要的“数据核心”掉落在你左手边的地板上-->
    <!-- 示例 1: 标准战术场景 (边界定义法) -->
    <!-- 场景叙述: 你正蹲伏在一张翻倒的金属桌后。一名敌方士兵在你前方8米、偏左1米的位置。在你右侧10米、前方7米处,有一个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;`
    基于当前场景分析,这是一个标准的战术环境,使用边界定义法。
    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">
    <!-- 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>
    <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>{{setvar::vison3d::<vison3d>}}
    </vision-renderer-guide>
  2. CodeBoy2006 revised this gist Jul 20, 2025. 1 changed file with 144 additions and 223 deletions.
    367 changes: 144 additions & 223 deletions Vision3D.js
    Original 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
    // @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}
    .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;

    /* ---------- 解析 <vision3d> 数据 ---------- */
    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 || '中立'
    };
    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 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 = 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);
    }

    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
    );
    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
    };
    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 mouse = new THREE.Vector2();
    let hoverMesh = null;
    let prevOccluders = []; // 存储上一帧的遮挡物

    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) {
    const r = container.getBoundingClientRect();
    mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1;
    mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1;
    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; // 防止空引用
    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();

    // 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帧执行一次
    if (frameCount % 3 === 0) {
    prevOccluders.forEach(mesh => {
    if (mesh !== activeMesh) {
    mesh.material.opacity = mesh.userData.baseOpacity;
    }
    mesh.userData.isOccluder = false;
    });
    prevOccluders = [];

    // 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();
    const targets = [];
    if (playerMesh && PLAYER_CONFIG.visible) targets.push(playerMesh);
    if (activeMesh) targets.push(activeMesh); // 使用 activeMesh

    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;
    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 (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);
    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);
    }
    }
    }
    });
    });
    }

    // 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)
    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 (!match && active.has(messageId)) {
    if (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>`);
    @@ -433,11 +418,7 @@

    if ($mesText && $mesText.length) {
    $mesText.html(SillyTavern.messageFormatting(
    html,
    msgObj.name,
    msgObj.is_system,
    msgObj.is_user,
    messageId
    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;
    messageSentResetTimer = null;
    logDebug('自动重置 messageSentRecently 标志');
    }, 1000);
    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}`);
    const msg = SillyTavern.chat[messageId];
    if (msg && !msg.is_user && !msg.is_system) {
    renderVisionMessageDebounced(messageId, null, 200);
    }
    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);
    }
    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);
    });
    }

    // 其他需要重新渲染的事件
    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): ${eventType}`);
    setTimeout(renderAllVisionMessages, 300);
    });
    }
    });


    logDebug('优化的事件监听器设置完成');
    }

    @@ -625,36 +565,17 @@

    /* ------------------ 启动脚本 ------------------ */
    function waitReady() {
    if (typeof SillyTavern !== 'undefined' &&
    SillyTavern.chat &&
    SillyTavern.eventSource &&
    SillyTavern.eventTypes) {

    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.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);
    }
  3. CodeBoy2006 revised this gist Jul 19, 2025. 2 changed files with 257 additions and 131 deletions.
    301 changes: 206 additions & 95 deletions Vision3D.js
    Original file line number Diff line number Diff line change
    @@ -1,8 +1,8 @@
    // ==UserScript==
    // @name 全局脚本 - 3D视野组件 (单 Canvas v10.1)
    // @version 10.1
    // @description 将标签改为 3D Sprite,仅用一张 Canvas;解决多次 scale 累积问题;优化 resize、hover 效果;完善事件触发逻辑
    // @author codeboy
    // @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; // 可通过控制台设置 window.vision3dDebug = 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}`; // 先用大字号测宽
    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.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 });
    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; // 1px ≈ 0.01 world unit
    const pxRatio = 0.01;
    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(); // 防抖计时器
    const renderTimers = new Map();

    function cleanup(id) {
    const st = active.get(id); if (!st) return;
    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 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 || '中立' };
    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 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);
    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);

    /* 玩家模型和标签(根据配置决定是否添加) */
    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),
    new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 })
    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 };

    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();
    camera.aspect = 1;
    camera.updateProjectionMatrix();
    renderer.setSize(s, s, false);
    }
    const resizeObserver = new ResizeObserver(syncSize); resizeObserver.observe(container);

    const resizeObserver = new ResizeObserver(syncSize);
    resizeObserver.observe(container);
    syncSize();

    /* Hover 光线检测 */
    /* 交互设置 */
    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;
    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 = camDist * 0.04; // 调节粗细可见度;经验参数
    const scaleFactor = Math.max(0.1, camDist * 0.04);
    sprite.scale.copy(sprite.userData.baseScale).multiplyScalar(scaleFactor);
    }

    /* 渲染循环 */
    /* --------------- 渲染循环 (核心逻辑) --------------- */
    function loop() {
    controls.update();

    // hover
    // 1. 恢复上一帧的遮挡物
    prevOccluders.forEach(mesh => {
    if (mesh !== hoverMesh) {
    mesh.material.opacity = mesh.userData.baseOpacity;
    }
    mesh.userData.isOccluder = false;
    });
    prevOccluders = [];

    // 2. 处理悬停效果
    raycaster.setFromCamera(mouse, camera);
    const intersect = raycaster.intersectObjects(meshes)[0]?.object || null;
    if (intersect !== hoverMesh) {
    const intersects = raycaster.intersectObjects(meshes);
    const newHoverMesh = intersects[0]?.object || null;

    if (newHoverMesh !== 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.material.opacity = hoverMesh.userData.baseOpacity;
    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);

    if (newHoverMesh) {
    newHoverMesh.material.opacity = 0.5;
    newHoverMesh.userData.labelSprite.scale.multiplyScalar(1.25);
    }

    hoverMesh = newHoverMesh;
    }

    // 每帧动态调整文字大小
    updateLabelScale(playerLabel);
    // 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)
    scene,
    renderer,
    container,
    onMouseMove,
    resizeObserver,
    raf: requestAnimationFrame(loop)
    });

    logDebug(`创建 3D 视野组件: ${id}, items: ${items.length}, worldSize: ${worldSize}`);
    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);
    }
    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);

    // 如果消息已有组件但不再包含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) {
    @@ -313,9 +432,15 @@
    }

    if ($mesText && $mesText.length) {
    $mesText.html(SillyTavern.messageFormatting(html, msgObj.name, msgObj.is_system, msgObj.is_user, messageId));
    $mesText.html(SillyTavern.messageFormatting(
    html,
    msgObj.name,
    msgObj.is_system,
    msgObj.is_user,
    messageId
    ));

    const renderDelay = isImmediate ? 0 : 50; // 立即渲染时不延迟
    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);
    }
    if (messageSentResetTimer) clearTimeout(messageSentResetTimer);

    // 1秒后自动重置标志
    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);
    messageSentResetTimer = null;
    }
    } else {
    logDebug('跳过 GENERATION_STARTED 事件的渲染,因为不是由消息发送触发的');
    if (messageSentResetTimer) clearTimeout(messageSentResetTimer);
    }
    });
    }
    @@ -403,14 +519,9 @@
    eventSource.on(eventTypes.GENERATION_ENDED, () => {
    logDebug('Event: GENERATION_ENDED');

    // 重置消息发送标志
    messageSentRecently = false;
    if (messageSentResetTimer) {
    clearTimeout(messageSentResetTimer);
    messageSentResetTimer = null;
    }
    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 Debounced): ${eventType}`);
    logDebug(`Event (Other): ${eventType}`);
    setTimeout(renderAllVisionMessages, 300);
    });
    }
    @@ -542,7 +650,10 @@
    };
    window.vision3dRenderAll = renderAllVisionMessages;

    logDebug('3D视野组件初始化完成');
    // 暴露玩家配置到全局(便于动态调整)
    window.vision3dPlayerConfig = PLAYER_CONFIG;

    logDebug(`3D视野组件初始化完成 (玩家可见: ${PLAYER_CONFIG.visible})`);

    } else {
    setTimeout(waitReady, 300);
    87 changes: 51 additions & 36 deletions prompt.xml
    Original 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`: 上下。正上负下
    - `pos:X,Y,Z`: 对象的**底部中心点**坐标
    - `X`: 左右。正数为右,负数为左
    - `Y`: 前后。正数为前,负数为后
    - `Z`: 上下。**这是对象底部所在的垂直高度**
    - `dim:W,D,H`: 对象的尺寸。
    - `W (Width)`: 沿X轴的宽度。
    - `D (Depth)`: 沿Y轴的深度。
    - `H (Height)`: 沿Z轴的高度。
    - `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米高的平台残余部分。你的盟友,阿里斯博士,正蜷缩在左前方远处的一组服务器后方,他身旁的墙壁上,一个破损的动力导管正迸射出危险的电火花。一个至关重要的“数据核心”掉落在你左手边的地板上。 -->
    <!-- 示例:演示了基于新心智模型的思考过程和对应的渲染标签 -->
    <!-- 场景叙述: 你(代号“凤凰”)正蹲伏在一张翻倒的重金属桌后,这里是“奇点实验室”被毁坏的服务器机房。在你正前方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;`
    -->
    基于当前场景分析,遵循“地基高度”心智模型:
    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.5; dim:2,1,1; content:"翻倒的桌子"; type:"地形";</item>
    <item>pos:0,1.5,0; 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>
    <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,1.5; dim:1,2,3; content:"服务器机柜"; type:"地形";</item>
    <item>pos:-1,9,0; dim:1,2,3; content:"服务器机柜"; type:"地形";</item>
    <!-- Elevated Catwalk Platform -->
    <item>pos:10,6,3.75; dim:6,8,0.5; content:"猫道平台"; type:"地形";</item>
    <item>pos:10,6,0; dim:6,8,4; content:"猫道平台"; type:"地形";</item>
    <!-- Collapsed Ramp leading to the catwalk -->
    <item>pos:6,3,1.75; dim:4,6,3.5; content:"坍塌的斜坡"; type:"地形";</item>
    <item>pos:6,3,0; 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>
    <item>pos:10,7,4; 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>
    <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,1.5; dim:4,2,3; content:"服务器组"; type:"地形";</item>
    <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.05; dim:0.2,0.2,0.1; content:"数据核心"; type:"物品";</item>
    <item>pos:-2,1,0; dim:0.2,0.2,0.1; content:"数据核心"; type:"物品";</item>
    </vision3d>
    </example>
    </dataTable-operatior-guide>
    </vision-renderer-guide>{{setvar::vison3d::<vison3d>}}
  4. CodeBoy2006 created this gist Jul 19, 2025.
    553 changes: 553 additions & 0 deletions Vision3D.js
    Original 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();
    })();
    111 changes: 111 additions & 0 deletions prompt.xml
    Original 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>