这次从零到一的开发过程,尤其是经历的数次排错和架构升级,是非常有价值的经验。这份文档将清晰地列出我们最终使用的API接口,并详细剖析排查过程中的每一个易错点,希望能为未来的开发提供宝贵的参考。
概述
本文档旨在归档"3D视野组件"全局脚本所使用的SillyTavern原生API,并总结在开发过程中遇到的关键问题与解决方案。本脚本的最终实现完全不依赖任何第三方助手(如Tavern-Helper),仅使用SillyTavern在全局脚本环境中直接暴露的稳定接口(为了方便,仍然使用了酒馆助手加载脚本),并演进到了高性能的单Canvas架构。
最新版传送门
效果展示
使用的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()
来直接设置整个消息容器的内容,这是一种"覆盖式"的处理方式。 - 关键修正: 从"覆盖"改为"无损替换"。采用两步渲染法:
- 字符串处理: 在原始消息字符串中,将
<vision3d>
标签替换为带有唯一ID的HTML占位符<div>
。 - 统一格式化: 将包含占位符的完整字符串交给
SillyTavern.messageFormatting()
进行标准格式化。 - 精确渲染: 通过占位符的唯一ID定位到具体的DOM元素,只在该占位符内部创建3D场景。
- 字符串处理: 在原始消息字符串中,将
易错点 4:从双Canvas到单Canvas的架构升级
- 现象: 最初版本使用一个
<canvas>
渲染3D模型,另一个<canvas>
通过2D Context绘制HTML标签。这种方法虽然可行,但存在几个问题:标签不是真正的3D对象,无法被模型遮挡;需要维护两个渲染循环和尺寸同步逻辑,代码复杂且容易出错。 - 深层原因: 2D标签只是一个UI覆盖层,与3D世界是分离的。
- 关键修正: 统一到单WebGL Canvas渲染。我们采用了
THREE.Sprite
技术:- 动态纹理: 在内存中创建一个临时的2D
canvas
,使用fillText()
将文字绘制上去。 - 材质转换: 将这个内存中的
canvas
转换为THREE.CanvasTexture
。 - 精灵实现: 将纹理应用到
THREE.SpriteMaterial
,并创建一个THREE.Sprite
对象。
- 动态纹理: 在内存中创建一个临时的2D
- 最终效果:
Sprite
是Three.js中一种特殊的3D对象,它始终面向相机,完美模拟了2D标签的效果,但它真实存在于3D空间中。这不仅统一了渲染流程,简化了代码,还带来了额外的好处:可以根据与相机的距离动态缩放标签大小,使其在远近都清晰可见。
易错点 5:事件风暴与渲染时机
- 现象: 在AI生成回复(Streaming)时,
MESSAGE_UPDATED
事件会高频触发,导致3D场景不断重绘,造成性能瓶颈和闪烁。同样,快速切换消息或删除消息也会引发不必要的重复渲染。 - 深层原因: 对所有事件都采取了简单直接的重渲染处理,没有区分事件的性质和频率。
- 关键修正: 实现了一套智能的、区分场景的事件处理机制:
- 立即渲染: 对于需要即时反馈的事件,如
MESSAGE_RECEIVED
和GENERATION_ENDED
,我们立即执行渲染。 - 防抖(Debounce)渲染: 对于
MESSAGE_UPDATED
和MESSAGE_SWIPED
等高频事件,我们引入防抖机制。在事件触发后等待一小段时间(如150ms),如果期间没有新事件,才执行渲染。这能有效合并连续的更新为一次渲染。 - 条件渲染: 引入
messageSentRecently
等状态标志,使得某些事件(如GENERATION_STARTED
)只在特定上下文(用户刚发送完消息)中才触发渲染,避免了不相关的操作(如重新生成)也触发渲染。
- 立即渲染: 对于需要即时反馈的事件,如
- 最终效果: 大幅降低了CPU和GPU的负载,UI响应更流畅,同时确保了所有状态更新都能被准确、高效地捕获。
最终实现代码
以下是经过多次迭代和修正后的最终版本,它融合了单Canvas架构和智能事件处理:
// ==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 格式
<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 格式
<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
来轻松实现响应式正方形容器,代码简洁且高效。
.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;
}
这种方法语义清晰,是目前实现固定比例响应式布局的最佳实践,得到了所有现代浏览器的良好支持。
结语
这个项目从最初的简单需求到最终的稳定实现,经历了多次技术选型的调整和问题的深度排查。最重要的收获是:
- 深入理解目标平台的API设计,而不是依赖第三方封装。
- 无损替换比覆盖式处理更符合模块化设计原则。
- 架构演进的重要性:从双Canvas到单Canvas
Sprite
渲染,是稳定性和性能的关键飞跃。 - 智能事件处理是性能的保障:面对高频事件,必须采用防抖、节流或更复杂的逻辑,而不是粗暴地响应每一次事件。
- 完善的错误处理和资源管理是长期稳定运行的基础。
希望这份文档能为后续的SillyTavern插件开发提供有价值的参考。