Skip to content

这次从零到一的开发过程,尤其是经历的数次排错和架构升级,是非常有价值的经验。这份文档将清晰地列出我们最终使用的API接口,并详细剖析排查过程中的每一个易错点,希望能为未来的开发提供宝贵的参考。

概述

本文档旨在归档"3D视野组件"全局脚本所使用的SillyTavern原生API,并总结在开发过程中遇到的关键问题与解决方案。本脚本的最终实现完全不依赖任何第三方助手(如Tavern-Helper),仅使用SillyTavern在全局脚本环境中直接暴露的稳定接口(为了方便,仍然使用了酒馆助手加载脚本),并演进到了高性能的单Canvas架构。

最新版传送门

Gist

效果展示

插件效果图

使用的SillyTavern原生API接口

以下是在最终脚本中使用的全部原生API,它们均可通过在全局脚本作用域内直接访问SillyTavern对象获得。

SillyTavern.chat

  • 类型: Array<Object>

  • 描述: 一个数组,包含了当前聊天中的所有消息对象。最关键的特性是,该数组的索引直接对应于消息的 message_id。例如,第 x 条消息的数据就存储在 SillyTavern.chat[x] 中。

  • 使用方式:

    javascript
    // 获取 message_id 为 10 的消息的完整数据对象
    const messageData = SillyTavern.chat[10];
    
    // 从数据对象中获取原始消息文本
    const rawMessage = messageData.mes;
  • 数据对象结构 (我们用到的部分):

    • mes: string - 原始的、未经处理的消息文本。
    • name: string - 发送该消息的角色名。
    • is_user: boolean - 是否为用户发送的消息。
    • is_system: boolean - 是否为系统消息。

SillyTavern.messageFormatting()

  • 类型: Function

  • 签名: (message: string, ch_name: string, is_system: boolean, is_user: boolean, message_id: number) => string

  • 描述: SillyTavern的核心函数之一。它接收一条原始消息文本和相关元数据,将其转换为最终在聊天窗口中显示的、经过格式化的HTML字符串。这包括处理Markdown、引用、换行等。在我们脚本中,当消息不包含<vision3d>标签时,我们用它来回退到SillyTavern的默认显示方式。

  • 使用方式:

    javascript
    // 假设 messageData 是从 SillyTavern.chat[message_id] 获取的对象
    const formattedHtml = SillyTavern.messageFormatting(
        messageData.mes,
        messageData.name,
        messageData.is_system,
        messageData.is_user,
        message_id
    );
    
    // 将格式化后的HTML设置到DOM元素中
    mes_text.html(formattedHtml);

SillyTavern.eventSource

  • 类型: Object

  • 描述: SillyTavern的中央事件总线,用于监听系统内发生的各种事件。我们通过它的 .on() 方法来注册事件回调函数。

  • 使用方式:

    javascript
    // 定义一个事件处理函数
    const handleMessageUpdate = (message_id) => {
        // ...处理逻辑...
    };
    
    // 监听消息更新事件
    SillyTavern.eventSource.on(SillyTavern.eventTypes.MESSAGE_UPDATED, handleMessageUpdate);

SillyTavern.eventTypes

  • 类型: Object

  • 描述: 一个包含了所有可用事件名称常量(字符串)的对象。强烈推荐使用这个对象来引用事件,而不是直接使用事件的字符串名称(如'message_updated')。这可以确保即使未来SillyTavern更新修改了事件的字符串值,我们的脚本依然能正常工作。

  • 使用方式:

    javascript
    // 正确方式
    SillyTavern.eventSource.on(SillyTavern.eventTypes.MESSAGE_SWIPED, swipeHandler);
    
    // 不推荐的方式
    // SillyTavern.eventSource.on('message_swiped', swipeHandler);

开发过程中的易错点与关键修正

这部分是本次开发最有价值的沉淀,记录了我们如何从错误的假设一步步修正到最终的正确实现。

易错点 1:API入口的误判 (TavernHelper vs 原生 SillyTavern)

  • 最初假设: window.TavernHelper 是主要的、功能齐全的API入口。
  • 现实情况: Tavern-Helper 是一个可选的第三方扩展,不能保证在所有用户的环境中都存在。而全局脚本的执行上下文中,SillyTavern会直接暴露一个名为 SillyTavern 的全局对象,这才是官方、稳定且唯一的原生API入口。
  • 关键修正: 彻底放弃对 Tavern-Helper 的任何依赖。所有功能调用全部切换到直接使用 SillyTavern 对象。

易错点 2:SillyTavern.chat 数据结构的误解

  • 最初假设: SillyTavern.chat 是一个标准的对象数组,每个对象都有一个 message_id 字段,需要通过 Array.prototype.find(m => m.message_id === id) 来查找。
  • 现实情况: SillyTavern.chat 的设计非常直接和高效,它是一个稀疏数组,其数组索引本身就是 message_id
  • 关键修正: 废弃了低效且不正确的 .find() 遍历。所有获取消息数据的操作都改为直接通过索引访问:const messageData = SillyTavern.chat[message_id]

易错点 3:消息内容的破坏性替换

  • 现象: 当消息中包含<vision3d>标签时,消息中的其他文本内容会完全消失,只显示3D场景。
  • 深层原因: 最初的实现使用了mes_text.html()来直接设置整个消息容器的内容,这是一种"覆盖式"的处理方式。
  • 关键修正: 从"覆盖"改为"无损替换"。采用两步渲染法:
    1. 字符串处理: 在原始消息字符串中,将<vision3d>标签替换为带有唯一ID的HTML占位符<div>
    2. 统一格式化: 将包含占位符的完整字符串交给SillyTavern.messageFormatting()进行标准格式化。
    3. 精确渲染: 通过占位符的唯一ID定位到具体的DOM元素,只在该占位符内部创建3D场景。

易错点 4:从双Canvas到单Canvas的架构升级

  • 现象: 最初版本使用一个<canvas>渲染3D模型,另一个<canvas>通过2D Context绘制HTML标签。这种方法虽然可行,但存在几个问题:标签不是真正的3D对象,无法被模型遮挡;需要维护两个渲染循环和尺寸同步逻辑,代码复杂且容易出错。
  • 深层原因: 2D标签只是一个UI覆盖层,与3D世界是分离的。
  • 关键修正: 统一到单WebGL Canvas渲染。我们采用了THREE.Sprite技术:
    1. 动态纹理: 在内存中创建一个临时的2D canvas,使用fillText()将文字绘制上去。
    2. 材质转换: 将这个内存中的canvas转换为THREE.CanvasTexture
    3. 精灵实现: 将纹理应用到THREE.SpriteMaterial,并创建一个THREE.Sprite对象。
  • 最终效果: Sprite是Three.js中一种特殊的3D对象,它始终面向相机,完美模拟了2D标签的效果,但它真实存在于3D空间中。这不仅统一了渲染流程,简化了代码,还带来了额外的好处:可以根据与相机的距离动态缩放标签大小,使其在远近都清晰可见。

易错点 5:事件风暴与渲染时机

  • 现象: 在AI生成回复(Streaming)时,MESSAGE_UPDATED事件会高频触发,导致3D场景不断重绘,造成性能瓶颈和闪烁。同样,快速切换消息或删除消息也会引发不必要的重复渲染。
  • 深层原因: 对所有事件都采取了简单直接的重渲染处理,没有区分事件的性质和频率。
  • 关键修正: 实现了一套智能的、区分场景的事件处理机制
    1. 立即渲染: 对于需要即时反馈的事件,如MESSAGE_RECEIVEDGENERATION_ENDED,我们立即执行渲染。
    2. 防抖(Debounce)渲染: 对于MESSAGE_UPDATEDMESSAGE_SWIPED等高频事件,我们引入防抖机制。在事件触发后等待一小段时间(如150ms),如果期间没有新事件,才执行渲染。这能有效合并连续的更新为一次渲染。
    3. 条件渲染: 引入messageSentRecently等状态标志,使得某些事件(如GENERATION_STARTED)只在特定上下文(用户刚发送完消息)中才触发渲染,避免了不相关的操作(如重新生成)也触发渲染。
  • 最终效果: 大幅降低了CPU和GPU的负载,UI响应更流畅,同时确保了所有状态更新都能被准确、高效地捕获。

最终实现代码

以下是经过多次迭代和修正后的最终版本,它融合了单Canvas架构和智能事件处理:

javascript
// ==UserScript==
// @name         全局脚本 - 3D视野组件 (单 Canvas v10.8 Bbox Fixed)
// @version      10.8
// @description  实现遮挡物半透明效果,当模型包裹另一个时,外层模型自动透视,支持玩家显示开关。已优化移动端触摸支持和性能。新增并修复了bbox格式兼容。
// @author       Codeboy, 优化 by AI
// @match        */*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    /* -------------------- 配置 -------------------- */
    const WIDGET_NAME = 'GlobalScript_Vision3D_SingleCanvas';
    const VISION_TYPES = {
        '友方': { color: 0x28a745 },
        '敌方': { color: 0xdc3545 },
        '中立': { color: 0x6c757d },
        '物品': { color: 0x007bff },
        '地形': { color: 0x8B4513 }
    };
    const USER_COLOR = 0xffd700;
    const FONT_FAMILY = 'Arial, "PingFang SC", "Microsoft YaHei", sans-serif';

    // 玩家显示配置
    const PLAYER_CONFIG = {
        visible: false, // 设为 false 即可隐藏玩家模型和标签
        showLabel: true // 是否显示玩家标签(仅在 visible 为 true 时有效)
    };

    /* -------------------- 调试日志 -------------------- */
    let debugMode = false;
    function logDebug(...args) {
        if (debugMode || window.vision3dDebug) {
            console.log(`[${WIDGET_NAME}]`, ...args);
        }
    }

    /* ------------------ 依赖加载 ------------------ */
    let libsReady = false;
    async function ensureThree() {
        if (libsReady) return true;
        const load = src => new Promise((r, j) => {
            if (document.querySelector(`script[src="${src}"]`)) return r();
            const s = Object.assign(document.createElement('script'), { src });
            s.onload = r; s.onerror = () => j(new Error(`load fail: ${src}`));
            document.head.appendChild(s);
        });
        try {
            await load('https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js');
            await load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js');
            libsReady = true; return true;
        } catch (e) {
            console.error(WIDGET_NAME, e); return false;
        }
    }

    /* -------------------- 样式 -------------------- */
    function injectCss() {
        if (document.getElementById(`${WIDGET_NAME}-style`)) return;
        const style = document.createElement('style');
        style.id = `${WIDGET_NAME}-style`;
        style.textContent = `
            .vision3d-container{position:relative;width:100%;max-width:600px;border:1px solid #444;border-radius:8px;margin:10px auto;aspect-ratio:1/1;overflow:hidden;cursor:grab;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;}
            .vision3d-container:active{cursor:grabbing}
            .vision3d-canvas{position:absolute;top:0;left:0;width:100%!important;height:100%!important;display:block}
        `;
        document.head.appendChild(style);
    }

    /* ------------ 工具:文字转 Sprite ------------ */
    function makeTextSprite(text, { baseScale = 0.8, color = '#f0f0f0', bg = 'rgba(0,0,0,.75)', padding = 6 } = {}) {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        ctx.font = `bold 48px ${FONT_FAMILY}`;
        const textW = ctx.measureText(text).width;
        const textH = 48;
        canvas.width = textW + padding * 2;
        canvas.height = textH + padding * 2;
        ctx.font = `bold 48px ${FONT_FAMILY}`;
        ctx.textAlign = 'center'; 
        ctx.textBaseline = 'middle';
        ctx.fillStyle = bg;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.fillStyle = color;
        ctx.fillText(text, canvas.width / 2, canvas.height / 2);
        
        const texture = new THREE.CanvasTexture(canvas);
        texture.minFilter = THREE.LinearFilter;
        
        const material = new THREE.SpriteMaterial({
            map: texture,
            transparent: true,
            depthTest: true,
            depthWrite: false
        });
        
        const sprite = new THREE.Sprite(material);
        const pxRatio = 0.01;
        sprite.scale.set(canvas.width * pxRatio, canvas.height * pxRatio, 1).multiplyScalar(baseScale);
        sprite.userData = { baseScale: sprite.scale.clone(), canvas };
        return sprite;
    }

    /* ------------ 解析函数:兼容新旧格式 -------------- */
    function parseVision3DItems(xml) {
        const items = [];
        
        const itemMatches = [
            ...xml.matchAll(/<item>(.*?)<\/item>/gis),
            ...xml.matchAll(/<item\s+([^>]+)\s*\/>/gis)
        ];
        
        itemMatches.forEach(match => {
            const obj = {};
            let contentStr = '';
            
            if (match[0].includes('</item>')) {
                // 旧格式:<item>pos:x,y,z; dim:w,d,h; ...</item>
                contentStr = match[1];
                contentStr.split(';').forEach(part => {
                    const [k, v] = part.split(':').map(s => s.trim());
                    if (k && v) obj[k] = v.replace(/"/g, '');
                });
                
                if (obj.pos && obj.dim) {
                    const [x, y, z] = obj.pos.split(',').map(Number);
                    const [w, d, h] = obj.dim.split(',').map(Number);
                    items.push({
                        pos: { x, y, z },
                        dim: { w, d, h },
                        label: obj.content || 'Unknown',
                        type: obj.type || '中立'
                    });
                }
            } else {
                // 新格式:<item bbox:"..." ... />
                contentStr = match[1];
                const attrRegex = /(\w+):\s*"([^"]*)"/g;
                let attrMatch;
                while ((attrMatch = attrRegex.exec(contentStr)) !== null) {
                    obj[attrMatch[1]] = attrMatch[2];
                }
                
                if (obj.bbox) {
                    const coords = obj.bbox.split(',').map(Number);
                    if (coords.length === 6) {
                        const [minX, minY, minZ, maxX, maxY, maxZ] = coords;
                        
                        // **【修复】** 转换为与旧系统兼容的中心点和尺寸
                        // pos.x/y 是水平中心点
                        const centerX = (minX + maxX) / 2;
                        const centerY = (minY + maxY) / 2;
                        
                        // pos.z 在旧系统中是物体的**底部**高度, 对应 bbox 的 minZ
                        const baseZ = minZ; 
                        
                        const width = maxX - minX;
                        const depth = maxY - minY;
                        const height = maxZ - minZ;
                        
                        items.push({
                            pos: { x: centerX, y: centerY, z: baseZ },
                            dim: { w: width, d: depth, h: height },
                            label: obj.content || 'Unknown',
                            type: obj.type || '中立'
                        });
                        
                        logDebug(`解析bbox项目: bbox=${obj.bbox} -> pos=(${centerX},${centerY},${baseZ}), dim=(${width},${depth},${height})`);
                    }
                }
            }
        });
        
        return items;
    }

    /* ------------ 运行时存储 & 清理 -------------- */
    const active = new Map();
    const renderTimers = new Map();
    
    function cleanup(id) {
        const st = active.get(id); 
        if (!st) return;
        
        const timer = renderTimers.get(id);
        if (timer) {
            clearTimeout(timer);
            renderTimers.delete(id);
        }
        
        cancelAnimationFrame(st.raf);
        st.container.removeEventListener('mousemove', st.onMouseMove);
        st.container.removeEventListener('touchstart', st.onTouchStart);
        st.container.removeEventListener('click', st.onClick);

        st.resizeObserver.disconnect();
        st.renderer.dispose();
        st.scene.traverse(o => {
            if (o.isMesh) {
                o.geometry?.dispose();
                (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m?.dispose?.());
            }
            if (o.isSprite) {
                o.material?.map?.dispose();
                o.material?.dispose();
            }
        });
        active.delete(id);
        logDebug(`清理 3D 视野组件: ${id}`);
    }

    /* --------------- 渲染主逻辑 ------------------ */
    async function render3D(id, $placeholder, worldSize, xml) {
        if (!await ensureThree()) return;

        const items = parseVision3DItems(xml);
        
        $placeholder.html(`<div class="vision3d-container"><canvas class="vision3d-canvas"></canvas></div>`);
        const container = $placeholder.find('.vision3d-container')[0];
        const canvas3D  = container.querySelector('canvas');
        
        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
        camera.position.set(worldSize * .8, worldSize * .8, worldSize * .8);
        
        const renderer = new THREE.WebGLRenderer({ canvas: canvas3D, antialias: true, alpha: true, logarithmicDepthBuffer: true });
        
        const controls = new THREE.OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true; 
        controls.target.set(0, 0, 0);

        scene.add(new THREE.GridHelper(worldSize, worldSize, 0x555555, 0x555555));
        
        let playerMesh = null;
        let playerLabel = null;
        
        if (PLAYER_CONFIG.visible) {
            const playerGeo = new THREE.ConeGeometry(.5, 2, 8).translate(0, 1, 0);
            playerMesh = new THREE.Mesh(playerGeo, new THREE.MeshBasicMaterial({ color: USER_COLOR }));
            playerMesh.userData.isPlayer = true;
            scene.add(playerMesh);
            
            if (PLAYER_CONFIG.showLabel) {
                playerLabel = makeTextSprite('玩家', { baseScale: 1 });
                playerLabel.position.set(0, 2.5, 0);
                scene.add(playerLabel);
            }
        }

        const meshes = [];
        items.forEach(it => {
            const color = VISION_TYPES[it.type]?.color || VISION_TYPES['中立'].color;
            const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.9, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
            const mesh = new THREE.Mesh(new THREE.BoxGeometry(it.dim.w, it.dim.h, it.dim.d), material);
            
            // 渲染逻辑保持不变。它期望 it.pos.z 是底部高度。
            mesh.position.set(it.pos.x, it.pos.z + it.dim.h / 2, -it.pos.y);
            
            scene.add(mesh);
            const labelSprite = makeTextSprite(it.label, { baseScale: 0.9 });
            labelSprite.position.copy(mesh.position).add(new THREE.Vector3(0, it.dim.h / 2 + .5, 0));
            scene.add(labelSprite);
            mesh.userData = { labelSprite, baseOpacity: material.opacity, isOccluder: false };
            meshes.push(mesh);
        });

        function syncSize() {
            const { width, height } = container.getBoundingClientRect();
            if (!width || !height) return;
            const s = Math.min(width, height);
            camera.aspect = 1; 
            camera.updateProjectionMatrix();
            renderer.setSize(s, s, false);
        }
        
        const resizeObserver = new ResizeObserver(syncSize); 
        resizeObserver.observe(container);
        syncSize();
        
        const raycaster = new THREE.Raycaster();
        const pointer = new THREE.Vector2();
        let activeMesh = null;
        let prevOccluders = [];
        let frameCount = 0;

        function updateActiveObject(newActiveMesh) {
            if (newActiveMesh === activeMesh) return;
            if (activeMesh) {
                activeMesh.material.opacity = activeMesh.userData.baseOpacity;
                activeMesh.userData.labelSprite.scale.copy(activeMesh.userData.labelSprite.userData.baseScale);
            }
            if (newActiveMesh) {
                newActiveMesh.material.opacity = 0.5;
                newActiveMesh.userData.labelSprite.scale.multiplyScalar(1.25);
            }
            activeMesh = newActiveMesh;
        }

        function updatePointer(event) {
            const rect = container.getBoundingClientRect();
            const x = (event.clientX - rect.left) / rect.width;
            const y = (event.clientY - rect.top) / rect.height;
            pointer.x = x * 2 - 1;
            pointer.y = -y * 2 + 1;
        }

        function onMouseMove(e) {
            updatePointer(e);
            raycaster.setFromCamera(pointer, camera);
            const intersects = raycaster.intersectObjects(meshes);
            updateActiveObject(intersects[0]?.object || null);
        }

        function onTouchStart(e) {
            if (e.touches.length === 1) {
                updatePointer(e.touches[0]);
                raycaster.setFromCamera(pointer, camera);
                const intersects = raycaster.intersectObjects(meshes);
                const tappedObject = intersects[0]?.object || null;
                if (tappedObject && tappedObject === activeMesh) {
                    updateActiveObject(null);
                } else {
                    updateActiveObject(tappedObject);
                }
            }
        }
        
        function onClick(e) {
            updatePointer(e);
            raycaster.setFromCamera(pointer, camera);
            const intersects = raycaster.intersectObjects(meshes);
            if (intersects.length === 0) {
                 updateActiveObject(null);
            }
        }

        container.addEventListener('mousemove', onMouseMove);
        container.addEventListener('touchstart', onTouchStart, { passive: false });
        container.addEventListener('click', onClick);
        
        function updateLabelScale(sprite) {
            if (!sprite) return;
            const camDist = camera.position.distanceTo(sprite.position);
            const scaleFactor = Math.max(0.1, camDist * 0.04);
            sprite.scale.copy(sprite.userData.baseScale).multiplyScalar(scaleFactor);
        }

        function loop() {
            active.get(id).raf = requestAnimationFrame(loop);
            frameCount++;
            controls.update();

            if (frameCount % 3 === 0) {
                prevOccluders.forEach(mesh => {
                    if (mesh !== activeMesh) {
                        mesh.material.opacity = mesh.userData.baseOpacity;
                    }
                    mesh.userData.isOccluder = false;
                });
                prevOccluders = [];

                const targets = [];
                if (playerMesh && PLAYER_CONFIG.visible) targets.push(playerMesh);
                if (activeMesh) targets.push(activeMesh);
                
                targets.forEach(target => {
                    const direction = new THREE.Vector3().subVectors(target.position, camera.position).normalize();
                    raycaster.set(camera.position, direction);
                    const allIntersects = raycaster.intersectObjects(scene.children, true);
                    let targetFound = false;

                    for (const intersect of allIntersects) {
                        if (intersect.object === target) {
                            targetFound = true;
                            break;
                        }
                        if (meshes.includes(intersect.object) && intersect.object !== target) {
                            const occluder = intersect.object;
                            if (occluder === activeMesh) continue;
                            occluder.material.opacity = 0.2;
                            occluder.userData.isOccluder = true;
                            if (!prevOccluders.includes(occluder)) prevOccluders.push(occluder);
                        }
                    }
                    
                    if (!targetFound && allIntersects.length > 0) {
                        const closest = allIntersects[0].object;
                        if (meshes.includes(closest)) {
                            closest.material.opacity = 0.2;
                            closest.userData.isOccluder = true;
                            if (!prevOccluders.includes(closest)) prevOccluders.push(closest);
                        }
                    }
                });
            }

            if (playerLabel && PLAYER_CONFIG.visible && PLAYER_CONFIG.showLabel) {
                updateLabelScale(playerLabel);
            }
            meshes.forEach(m => updateLabelScale(m.userData.labelSprite));

            renderer.render(scene, camera);
        }

        active.set(id, {
            scene, renderer, container, resizeObserver, 
            raf: requestAnimationFrame(loop),
            onMouseMove, onTouchStart, onClick 
        });
        
        logDebug(`创建 3D 视野组件: ${id}, items: ${items.length}, worldSize: ${worldSize}, 玩家可见: ${PLAYER_CONFIG.visible}`);
    }

    /* --------------- 防抖/立即/核心 渲染函数 --------------- */
    // ... 这部分代码没有变化,故折叠 ...
    function renderVisionMessageDebounced(messageId, $mesText, delay = 150) {
        const existingTimer = renderTimers.get(messageId);
        if (existingTimer) clearTimeout(existingTimer);
        const timer = setTimeout(() => {
            renderVisionMessage(messageId, $mesText, false);
            renderTimers.delete(messageId);
        }, delay);
        renderTimers.set(messageId, timer);
        logDebug(`防抖渲染消息: ${messageId}, 延迟: ${delay}ms`);
    }

    function renderVisionMessageImmediate(messageId, $mesText) {
        const existingTimer = renderTimers.get(messageId);
        if (existingTimer) {
            clearTimeout(existingTimer);
            renderTimers.delete(messageId);
        }
        renderVisionMessage(messageId, $mesText, true);
        logDebug(`立即渲染消息: ${messageId}`);
    }

    function renderVisionMessage(messageId, $mesText, isImmediate = false) {
        const msgObj = SillyTavern.chat[messageId]; 
        if (!msgObj) {
            logDebug(`消息不存在: ${messageId}`);
            return;
        }
        const reg = /<vision3d size="([\d.]+)">(.*?)<\/vision3d>/s;
        const match = msgObj.mes.match(reg);
        if (active.has(messageId)) {
            cleanup(messageId);
        }
        if (match) {
            let html = msgObj.mes;
            const placeholderId = `v3d-${messageId}-${Date.now()}`;
            html = html.replace(reg, `<div id="${placeholderId}"></div>`);
            if (!$mesText) {
                const mesElement = $(`#chat .mes[mesid="${messageId}"]`);
                if (mesElement.length) {
                    $mesText = mesElement.find('.mes_text');
                }
            }
            if ($mesText && $mesText.length) {
                $mesText.html(SillyTavern.messageFormatting(
                    html, msgObj.name, msgObj.is_system, msgObj.is_user, messageId
                ));
                const renderDelay = isImmediate ? 0 : 50;
                setTimeout(() => {
                    const $placeholder = $(`#${placeholderId}`, $mesText);
                    if ($placeholder.length) {
                        render3D(messageId, $placeholder, +match[1], match[2]);
                    }
                }, renderDelay);
            }
        }
    }

    /* --------------- 批量渲染函数 --------------- */
    function renderAllVisionMessages() {
        logDebug('开始批量渲染所有vision3d消息');
        $('#chat .mes[is_user="false"][is_system="false"]').each((_, el) => {
            const messageId = +el.getAttribute('mesid');
            const $mesText = $('.mes_text', el);
            if (messageId && $mesText.length) {
                renderVisionMessageDebounced(messageId, $mesText, 100);
            }
        });
    }

    /* --------------- 优化的事件监听设置 --------------- */
    // ... 这部分代码没有变化,故折叠 ...
    function setupOptimizedEventListeners() {
        if (!SillyTavern.eventTypes || !SillyTavern.eventSource) {
            logDebug('SillyTavern 事件系统未就绪');
            return;
        }
        const eventTypes = SillyTavern.eventTypes;
        const eventSource = SillyTavern.eventSource;
        logDebug('设置优化的事件监听器');
        let messageSentRecently = false;
        let messageSentResetTimer = null;
        if (eventTypes.MESSAGE_SENT) {
            eventSource.on(eventTypes.MESSAGE_SENT, () => {
                logDebug('Event: MESSAGE_SENT');
                messageSentRecently = true;
                if (messageSentResetTimer) clearTimeout(messageSentResetTimer);
                messageSentResetTimer = setTimeout(() => { messageSentRecently = false; }, 1000);
            });
        }
        if (eventTypes.GENERATION_STARTED) {
            eventSource.on(eventTypes.GENERATION_STARTED, () => {
                logDebug(`Event: GENERATION_STARTED (messageSentRecently: ${messageSentRecently})`);
                if (messageSentRecently) {
                    const latestMessage = SillyTavern.chat[SillyTavern.chat.length - 1];
                    if (latestMessage && !latestMessage.is_user) {
                        renderVisionMessageDebounced(SillyTavern.chat.length - 1);
                    }
                    messageSentRecently = false;
                    if (messageSentResetTimer) clearTimeout(messageSentResetTimer);
                }
            });
        }
        if (eventTypes.GENERATION_ENDED) {
            eventSource.on(eventTypes.GENERATION_ENDED, () => {
                logDebug('Event: GENERATION_ENDED');
                messageSentRecently = false;
                if (messageSentResetTimer) clearTimeout(messageSentResetTimer);
                for (let i = SillyTavern.chat.length - 1; i >= 0; i--) {
                    const msg = SillyTavern.chat[i];
                    if (msg && !msg.is_user && !msg.is_system) {
                        renderVisionMessageImmediate(i);
                        break;
                    }
                }
            });
        }
        if (eventTypes.MESSAGE_RECEIVED) {
            eventSource.on(eventTypes.MESSAGE_RECEIVED, (messageId) => {
                logDebug(`Event: MESSAGE_RECEIVED, messageId: ${messageId}`);
                const msg = SillyTavern.chat[messageId];
                if (msg && !msg.is_user && !msg.is_system) {
                    renderVisionMessageImmediate(messageId);
                }
            });
        }
        if (eventTypes.MESSAGE_SWIPED) {
            eventSource.on(eventTypes.MESSAGE_SWIPED, (messageId) => {
                logDebug(`Event: MESSAGE_SWIPED, messageId: ${messageId}`);
                renderVisionMessageDebounced(messageId, null, 200);
            });
        }
        if (eventTypes.MESSAGE_UPDATED) {
            eventSource.on(eventTypes.MESSAGE_UPDATED, (messageId) => {
                logDebug(`Event: MESSAGE_UPDATED, messageId: ${messageId}`);
                renderVisionMessageDebounced(messageId, null, 150);
            });
        }
        if (eventTypes.MESSAGE_DELETED) {
            eventSource.on(eventTypes.MESSAGE_DELETED, (messageId) => {
                logDebug(`Event: MESSAGE_DELETED, messageId: ${messageId}`);
                cleanup(messageId);
            });
        }
        if (eventTypes.MESSAGES_DELETED) {
            eventSource.on(eventTypes.MESSAGES_DELETED, () => {
                logDebug('Event: MESSAGES_DELETED');
                active.forEach((_, id) => cleanup(id));
                setTimeout(renderAllVisionMessages, 300);
            });
        }
        if (eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED) {
            eventSource.on(eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED, () => {
                logDebug('Event: CHARACTER_FIRST_MESSAGE_SELECTED');
                active.forEach((_, id) => cleanup(id));
                setTimeout(renderAllVisionMessages, 200);
            });
        }
        logDebug('优化的事件监听器设置完成');
    }

    /* --------------- 页面卸载清理与启动 --------------- */
    // ... 这部分代码没有变化,故折叠 ...
    function setupCleanupOnUnload() {
        window.addEventListener('beforeunload', () => {
            logDebug('页面卸载,清理所有3D视野组件');
            active.forEach((_, id) => cleanup(id));
            renderTimers.forEach(timer => clearTimeout(timer));
            renderTimers.clear();
        });
    }

    function waitReady() {
        if (typeof SillyTavern !== 'undefined' && SillyTavern.chat && SillyTavern.eventSource && SillyTavern.eventTypes) {
            logDebug('SillyTavern 环境就绪,初始化3D视野组件');
            injectCss();
            setupOptimizedEventListeners();
            setupCleanupOnUnload();
            setTimeout(renderAllVisionMessages, 500);
            window.vision3dDebug = false;
            window.vision3dCleanup = (id) => { id !== undefined ? cleanup(id) : active.forEach((_, id) => cleanup(id)); };
            window.vision3dRenderAll = renderAllVisionMessages;
            window.vision3dPlayerConfig = PLAYER_CONFIG;
            logDebug(`3D视野组件初始化完成 (玩家可见: ${PLAYER_CONFIG.visible}), 支持bbox格式`);
        } else {
            setTimeout(waitReady, 300);
        }
    }
    
    waitReady();
})();

功能特性

支持的XML标签格式

脚本能够解析如下格式的XML标签(size可选):

pos/dim 格式

xml
<vision3d size="10">
  <item>pos:2,3,0; dim:1,1,2; content:石柱; type:地形</item>
  <item>pos:-1,2,0; dim:1,1,1; content:宝箱; type:物品</item>
  <item>pos:0,5,0; dim:1,2,1; content:守卫; type:敌方</item>
</vision3d>

bbox 格式

xml
<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>

视觉效果

  • 单Canvas 3D渲染: 使用Three.js在单个<canvas>中创建交互式3D场景。
  • 3D精灵标签: 标签使用THREE.Sprite实现,始终面向相机,并根据距离动态缩放,确保可读性。
  • 颜色区分: 不同类型的物品有明确的颜色标识。
  • 用户标记: 金色圆锥体标识用户位置。
  • 交互功能: 鼠标悬停高亮物体及其标签、轨道控制器支持缩放旋转。

现代CSS响应式实现

我们采用了现代CSS属性aspect-ratio来轻松实现响应式正方形容器,代码简洁且高效。

css
.vision3d-container {
    position: relative;
    width: 100%;
    max-width: 600px;
    border: 1px solid #444;
    border-radius: 8px;
    margin: 10px auto;
    /* 直接定义宽高比为1:1 */
    aspect-ratio: 1/1; 
    overflow: hidden;
    cursor: grab;
}

这种方法语义清晰,是目前实现固定比例响应式布局的最佳实践,得到了所有现代浏览器的良好支持。

结语

这个项目从最初的简单需求到最终的稳定实现,经历了多次技术选型的调整和问题的深度排查。最重要的收获是:

  1. 深入理解目标平台的API设计,而不是依赖第三方封装。
  2. 无损替换比覆盖式处理更符合模块化设计原则
  3. 架构演进的重要性:从双Canvas到单Canvas Sprite 渲染,是稳定性和性能的关键飞跃。
  4. 智能事件处理是性能的保障:面对高频事件,必须采用防抖、节流或更复杂的逻辑,而不是粗暴地响应每一次事件。
  5. 完善的错误处理和资源管理是长期稳定运行的基础

希望这份文档能为后续的SillyTavern插件开发提供有价值的参考。