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.
SillyTavern 3D视野插件 - 让角色的视野可视化 (通过酒馆助手加载)
<vision-renderer-guide>
<rule>
**启用附属模块[视觉投影仪] - 强制执行**
视觉投影仪 (Scene Projector) 被激活为一个强制性子模块,用于处理所有相关的三维空间布局和视野渲染。其功能至关重要,**绝不能被跳过**。
**核心职责:** 在任何需要以三维空间布局来展示角色视野的回合末尾,视觉投影仪 **必须** 按顺序执行以下两个动作:
1. **`<visionThink>` 块:**
* 目的: 提供一个**强制性的、详尽的**思考过程,解释为何需要生成视觉布局,以及如何根据**当前回合的事件**来构建它。
* 内容要求:
* **精确识别**:列出所有需要被渲染的对象。
* **严谨定位**:根据当前回合的叙述,为每个对象确定其精确的 `bbox`(包围盒)。所有坐标均以**`<user>`的位置为原点(0,0,0)**。
* **合理分类**:为每个对象分配合适的类型 (`type`)。
* **数据来源**: 所有渲染信息必须**严格源于**本回合的叙述。**禁止凭空捏造**场景细节。
2. **`<vision3d>` 块:**
* 目的: 提供**精确的、可执行的**标签块,用于实际渲染在 `<visionThink>` 中规划好的场景。如果 `<visionThink>` 确定需要渲染,则此块**不得省略**。
* 操作限制: **只能**使用在 `<vision-syntax>` 中定义的标签和格式。
* 严格格式化规则:
* **完全遵守**指定的标签和参数语法。
* 所有字符串值**必须**使用**双引号**包围。
* 所有参数名和类型名**必须**使用**英文**。
**关键放置:** `<visionThink>` 和 `<vision3d>` 块**必须总是成对出现**,并且**只能出现在回复的绝对末尾**。
</rule>
<vision-syntax>
<!-- 定义三维视觉渲染的精确语法 -->
<!-- 核心方法: 使用 bbox (包围盒) 来精确定义空间体积 -->
<vision3d size="数字">
<item bbox:"minX,minY,minZ,maxX,maxY,maxZ"; content:"文本"; type:"分类";/>
<!-- ... more items ... -->
</vision3d>
</vision-syntax>
<coordinate-system-and-types>
<!-- 定义坐标系、对象类型和渲染心智模型 -->
<!-- 原点: <user>的位置永远是坐标系的原点 (0,0,0) -->
**3D坐标系 (包围盒定义法):**
- **`bbox:"minX,minY,minZ,maxX,maxY,maxZ"`**: 使用6个值定义一个物体的三维长方体包围盒。
- `minX`, `maxX`: 物体在 **X轴 (左右)** 上的最小和最大坐标。
- `minY`, `maxY`: 物体在 **Y轴 (前后)** 上的最小和最大坐标。
- `minZ`, `maxZ`: 物体在 **Z轴 (上下)** 上的最小和最大坐标。
---
**核心渲染心智模型 (边界定义法):**
你的任务是**直接定义一个物体所占据的精确三维空间**。你需要通过指定其长方体包围盒(`bbox`)的六个边界坐标来实现。
**思考边界的流程:**
1. **确定垂直边界 (Z轴):**
* `minZ`: 物体的**底部**在哪个高度?(例如,地面是 `0`,桌面上是 `1`)
* `maxZ`: 物体的**顶部**在哪个高度?(通常是 `minZ + 物体高度`)
2. **确定水平边界 (X轴和Y轴):**
* 根据叙述中物体的位置(例如“在你左前方5米处”)和它的尺寸(宽度和深度),直接推断出它的空间范围。
* `minX`, `maxX`: 物体最左和最右的边界。
* `minY`, `maxY`: 物体最后和最前的边界。
**实践指南:**
* **示例**: 一个身高1.8米、宽/深0.8米的士兵,其中心在 `(-1, 8)`,并**站立在地面上**。
* **垂直边界 (Z):**
* 他站在地面上,所以 `minZ = 0`。
* 他身高1.8米,所以 `maxZ = 0 + 1.8 = 1.8`。
* **水平边界 (X, Y):**
* 他的中心在 `X=-1`,宽度为`0.8`,所以他的X轴范围是 `[-1.4, -0.6]`。
* 他的中心在 `Y=8`,深度为`0.8`,所以他的Y轴范围是 `[7.6, 8.4]`。
* **最终 `bbox`**: `bbox:"-1.4,7.6,0,-0.6,8.4,1.8"`
* **垂直位移**: 如果该士兵站在一个**4米高的平台**上:
* **垂直边界 (Z):**
* 他的脚踩在4米高度,所以 `minZ = 4`。
* 他的顶部在 `maxZ = 4 + 1.8 = 5.8`。
* **水平边界 (X, Y):** 不变。
* **最终 `bbox`**: `bbox:"-1.4,7.6,4,-0.6,8.4,5.8"`
---
**渲染尺度与符号化抽象:**
(此部分原则不变,仅在示例中应用新语法)
当叙事涉及宏大尺度时,系统**必须**采用艺术化的符号处理。在 `<visionThink>` 中,**必须**说明场景已进行符号化抽象,布局仅代表相对关系。
---
**对象类型 (type):**
(无变化)
- `友方 (Friendly)`, `敌方 (Hostile)`, `中立 (Neutral)`, `物品 (Item)`, `地形 (Terrain)`
</coordinate-system-and-types>
<example>
<!-- 示例 1: 标准战术场景 (边界定义法) -->
<!-- 场景叙述: 你正蹲伏在一张翻倒的金属桌后。一名敌方士兵在你前方8米、偏左1米的位置。在你右侧10米、前方7米处,有一个4米高的猫道平台,一名狙击手正站在平台边缘瞄准你。 -->
<visionThink>
基于当前场景分析,这是一个标准的战术环境,使用边界定义法。
1. **场景设置**: 视野范围 `size` 设为25米。
2. **识别并定义对象边界 (以<user>为原点)**:
- **翻倒的金属桌 (地形)**: 2米宽x1米深x1米高,中心在(0, 1.5),放在地面上。
* 边界: X轴[-1, 1], Y轴[1, 2], Z轴[0, 1]。
* `bbox:"-1,1,0,1,2,1"`
- **地面士兵 (敌方)**: 0.8米宽x0.8米深x1.8米高,中心在(-1, 8),站在地面上。
* 边界: X轴[-1.4, -0.6], Y轴[7.6, 8.4], Z轴[0, 1.8]。
* `bbox:"-1.4,7.6,0,-0.6,8.4,1.8"`
- **猫道平台 (地形)**: 6米宽x8米深x4米高,中心在(10, 7),从地面建起。
* 边界: X轴[7, 13], Y轴[3, 11], Z轴[0, 4]。
* `bbox:"7,3,0,13,11,4"`
- **狙击手 (敌方)**: 0.8米宽x0.8米深x1.8米高,中心在(10, 7),站在4米高的平台上。
* 边界: X轴[9.6, 10.4], Y轴[6.6, 7.4], Z轴[4, 5.8]。
* `bbox:"9.6,6.6,4,10.4,7.4,5.8"`
</visionThink>
<vision3d size="25">
<item bbox:"-1,1,0,1,2,1"; content:"翻倒的桌子"; type:"地形";/>
<item bbox:"-1.4,7.6,0,-0.6,8.4,1.8"; content:"士兵"; type:"敌方";/>
<item bbox:"7,3,0,13,11,4"; content:"猫道平台"; type:"地形";/>
<item bbox:"9.6,6.6,4,10.4,7.4,5.8"; content:"狙击手"; type:"敌方";/>
</vision3d>
</example>
<example>
<!-- 示例 2: 宏大尺度场景的符号化抽象 (边界定义法) -->
<!-- 场景叙述: 你的飞船悬停在太阳的北极上方。星图显示:水星在右前方,地球在左方。一支敌对舰队从后方偏右的火星方向跃迁而来。 -->
<visionThink>
当前场景是天文尺度,必须执行符号化抽象,布局仅代表相对位置。
1. **处理方式**: 使用BBox定义法在一个 `size="100"` 的概念棋盘上布置符号。
2. **识别并定义对象边界 (关系优先)**:
- **太阳 (地形)**: 符号中心(0,0),尺寸(10,10,10),为作参考其顶部与棋盘平面对齐。
* 边界: X轴[-5, 5], Y轴[-5, 5], Z轴[-10, 0]。
* `bbox:"-5,-5,-10,5,5,0"`
- **水星 (中立)**: 符号中心(20, 30),尺寸(2,2,2),在棋盘平面上。
* 边界: X轴[19, 21], Y轴[29, 31], Z轴[0, 2]。
* `bbox:"19,29,0,21,31,2"`
- **地球 (中立)**: 符号中心(-40, 0),尺寸(2,2,2),在棋盘平面上。
* 边界: X轴[-41, -39], Y轴[-1, 1], Z轴[0, 2]。
* `bbox:"-41,-1,0,-39,1,2"`
- **敌对舰队 (敌方)**: 符号中心(23, -42),尺寸(5,5,2)以示威胁,在棋盘平面上。
* 边界: X轴[20.5, 25.5], Y轴[-44.5, -39.5], Z轴[0, 2]。
* `bbox:"20.5,-44.5,0,25.5,-39.5,2"`
</visionThink>
<vision3d size="100">
<item bbox:"-5,-5,-10,5,5,0"; content:"太阳 (符号)"; type:"地形";/>
<item bbox:"19,29,0,21,31,2"; content:"水星 (符号)"; type:"中立";/>
<item bbox:"-41,-1,0,-39,1,2"; content:"地球 (符号)"; type:"中立";/>
<item bbox:"20.5,-44.5,0,25.5,-39.5,2"; content:"敌对舰队 (符号)"; type:"敌方";/>
</vision3d>
</example>
</vision-renderer-guide>
// ==UserScript==
// @name 全局脚本 - 3D视野组件 (单 Canvas v10.8 Bbox Fixed)
// @version 10.8
// @description 实现遮挡物半透明效果,当模型包裹另一个时,外层模型自动透视,支持玩家显示开关。已优化移动端触摸支持和性能。新增并修复了bbox格式兼容。
// @author Codeboy, 优化 by AI
// @match */*
// @grant none
// ==/UserScript==
(function () {
'use strict';
/* -------------------- 配置 -------------------- */
const WIDGET_NAME = 'GlobalScript_Vision3D_SingleCanvas';
const VISION_TYPES = {
'友方': { color: 0x28a745 },
'敌方': { color: 0xdc3545 },
'中立': { color: 0x6c757d },
'物品': { color: 0x007bff },
'地形': { color: 0x8B4513 }
};
const USER_COLOR = 0xffd700;
const FONT_FAMILY = 'Arial, "PingFang SC", "Microsoft YaHei", sans-serif';
// 玩家显示配置
const PLAYER_CONFIG = {
visible: false, // 设为 false 即可隐藏玩家模型和标签
showLabel: true // 是否显示玩家标签(仅在 visible 为 true 时有效)
};
/* -------------------- 调试日志 -------------------- */
let debugMode = false;
function logDebug(...args) {
if (debugMode || window.vision3dDebug) {
console.log(`[${WIDGET_NAME}]`, ...args);
}
}
/* ------------------ 依赖加载 ------------------ */
let libsReady = false;
async function ensureThree() {
if (libsReady) return true;
const load = src => new Promise((r, j) => {
if (document.querySelector(`script[src="${src}"]`)) return r();
const s = Object.assign(document.createElement('script'), { src });
s.onload = r; s.onerror = () => j(new Error(`load fail: ${src}`));
document.head.appendChild(s);
});
try {
await load('https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js');
await load('https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js');
libsReady = true; return true;
} catch (e) {
console.error(WIDGET_NAME, e); return false;
}
}
/* -------------------- 样式 -------------------- */
function injectCss() {
if (document.getElementById(`${WIDGET_NAME}-style`)) return;
const style = document.createElement('style');
style.id = `${WIDGET_NAME}-style`;
style.textContent = `
.vision3d-container{position:relative;width:100%;max-width:600px;border:1px solid #444;border-radius:8px;margin:10px auto;aspect-ratio:1/1;overflow:hidden;cursor:grab;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;}
.vision3d-container:active{cursor:grabbing}
.vision3d-canvas{position:absolute;top:0;left:0;width:100%!important;height:100%!important;display:block}
`;
document.head.appendChild(style);
}
/* ------------ 工具:文字转 Sprite ------------ */
function makeTextSprite(text, { baseScale = 0.8, color = '#f0f0f0', bg = 'rgba(0,0,0,.75)', padding = 6 } = {}) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = `bold 48px ${FONT_FAMILY}`;
const textW = ctx.measureText(text).width;
const textH = 48;
canvas.width = textW + padding * 2;
canvas.height = textH + padding * 2;
ctx.font = `bold 48px ${FONT_FAMILY}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = color;
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: true,
depthWrite: false
});
const sprite = new THREE.Sprite(material);
const pxRatio = 0.01;
sprite.scale.set(canvas.width * pxRatio, canvas.height * pxRatio, 1).multiplyScalar(baseScale);
sprite.userData = { baseScale: sprite.scale.clone(), canvas };
return sprite;
}
/* ------------ 解析函数:兼容新旧格式 -------------- */
function parseVision3DItems(xml) {
const items = [];
const itemMatches = [
...xml.matchAll(/<item>(.*?)<\/item>/gis),
...xml.matchAll(/<item\s+([^>]+)\s*\/>/gis)
];
itemMatches.forEach(match => {
const obj = {};
let contentStr = '';
if (match[0].includes('</item>')) {
// 旧格式:<item>pos:x,y,z; dim:w,d,h; ...</item>
contentStr = match[1];
contentStr.split(';').forEach(part => {
const [k, v] = part.split(':').map(s => s.trim());
if (k && v) obj[k] = v.replace(/"/g, '');
});
if (obj.pos && obj.dim) {
const [x, y, z] = obj.pos.split(',').map(Number);
const [w, d, h] = obj.dim.split(',').map(Number);
items.push({
pos: { x, y, z },
dim: { w, d, h },
label: obj.content || 'Unknown',
type: obj.type || '中立'
});
}
} else {
// 新格式:<item bbox:"..." ... />
contentStr = match[1];
const attrRegex = /(\w+):\s*"([^"]*)"/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(contentStr)) !== null) {
obj[attrMatch[1]] = attrMatch[2];
}
if (obj.bbox) {
const coords = obj.bbox.split(',').map(Number);
if (coords.length === 6) {
const [minX, minY, minZ, maxX, maxY, maxZ] = coords;
// **【修复】** 转换为与旧系统兼容的中心点和尺寸
// pos.x/y 是水平中心点
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
// pos.z 在旧系统中是物体的**底部**高度, 对应 bbox 的 minZ
const baseZ = minZ;
const width = maxX - minX;
const depth = maxY - minY;
const height = maxZ - minZ;
items.push({
pos: { x: centerX, y: centerY, z: baseZ },
dim: { w: width, d: depth, h: height },
label: obj.content || 'Unknown',
type: obj.type || '中立'
});
logDebug(`解析bbox项目: bbox=${obj.bbox} -> pos=(${centerX},${centerY},${baseZ}), dim=(${width},${depth},${height})`);
}
}
}
});
return items;
}
/* ------------ 运行时存储 & 清理 -------------- */
const active = new Map();
const renderTimers = new Map();
function cleanup(id) {
const st = active.get(id);
if (!st) return;
const timer = renderTimers.get(id);
if (timer) {
clearTimeout(timer);
renderTimers.delete(id);
}
cancelAnimationFrame(st.raf);
st.container.removeEventListener('mousemove', st.onMouseMove);
st.container.removeEventListener('touchstart', st.onTouchStart);
st.container.removeEventListener('click', st.onClick);
st.resizeObserver.disconnect();
st.renderer.dispose();
st.scene.traverse(o => {
if (o.isMesh) {
o.geometry?.dispose();
(Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m?.dispose?.());
}
if (o.isSprite) {
o.material?.map?.dispose();
o.material?.dispose();
}
});
active.delete(id);
logDebug(`清理 3D 视野组件: ${id}`);
}
/* --------------- 渲染主逻辑 ------------------ */
async function render3D(id, $placeholder, worldSize, xml) {
if (!await ensureThree()) return;
const items = parseVision3DItems(xml);
$placeholder.html(`<div class="vision3d-container"><canvas class="vision3d-canvas"></canvas></div>`);
const container = $placeholder.find('.vision3d-container')[0];
const canvas3D = container.querySelector('canvas');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
camera.position.set(worldSize * .8, worldSize * .8, worldSize * .8);
const renderer = new THREE.WebGLRenderer({ canvas: canvas3D, antialias: true, alpha: true, logarithmicDepthBuffer: true });
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 0, 0);
scene.add(new THREE.GridHelper(worldSize, worldSize, 0x555555, 0x555555));
let playerMesh = null;
let playerLabel = null;
if (PLAYER_CONFIG.visible) {
const playerGeo = new THREE.ConeGeometry(.5, 2, 8).translate(0, 1, 0);
playerMesh = new THREE.Mesh(playerGeo, new THREE.MeshBasicMaterial({ color: USER_COLOR }));
playerMesh.userData.isPlayer = true;
scene.add(playerMesh);
if (PLAYER_CONFIG.showLabel) {
playerLabel = makeTextSprite('玩家', { baseScale: 1 });
playerLabel.position.set(0, 2.5, 0);
scene.add(playerLabel);
}
}
const meshes = [];
items.forEach(it => {
const color = VISION_TYPES[it.type]?.color || VISION_TYPES['中立'].color;
const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.9, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
const mesh = new THREE.Mesh(new THREE.BoxGeometry(it.dim.w, it.dim.h, it.dim.d), material);
// 渲染逻辑保持不变。它期望 it.pos.z 是底部高度。
mesh.position.set(it.pos.x, it.pos.z + it.dim.h / 2, -it.pos.y);
scene.add(mesh);
const labelSprite = makeTextSprite(it.label, { baseScale: 0.9 });
labelSprite.position.copy(mesh.position).add(new THREE.Vector3(0, it.dim.h / 2 + .5, 0));
scene.add(labelSprite);
mesh.userData = { labelSprite, baseOpacity: material.opacity, isOccluder: false };
meshes.push(mesh);
});
function syncSize() {
const { width, height } = container.getBoundingClientRect();
if (!width || !height) return;
const s = Math.min(width, height);
camera.aspect = 1;
camera.updateProjectionMatrix();
renderer.setSize(s, s, false);
}
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
syncSize();
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let activeMesh = null;
let prevOccluders = [];
let frameCount = 0;
function updateActiveObject(newActiveMesh) {
if (newActiveMesh === activeMesh) return;
if (activeMesh) {
activeMesh.material.opacity = activeMesh.userData.baseOpacity;
activeMesh.userData.labelSprite.scale.copy(activeMesh.userData.labelSprite.userData.baseScale);
}
if (newActiveMesh) {
newActiveMesh.material.opacity = 0.5;
newActiveMesh.userData.labelSprite.scale.multiplyScalar(1.25);
}
activeMesh = newActiveMesh;
}
function updatePointer(event) {
const rect = container.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
pointer.x = x * 2 - 1;
pointer.y = -y * 2 + 1;
}
function onMouseMove(e) {
updatePointer(e);
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(meshes);
updateActiveObject(intersects[0]?.object || null);
}
function onTouchStart(e) {
if (e.touches.length === 1) {
updatePointer(e.touches[0]);
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(meshes);
const tappedObject = intersects[0]?.object || null;
if (tappedObject && tappedObject === activeMesh) {
updateActiveObject(null);
} else {
updateActiveObject(tappedObject);
}
}
}
function onClick(e) {
updatePointer(e);
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(meshes);
if (intersects.length === 0) {
updateActiveObject(null);
}
}
container.addEventListener('mousemove', onMouseMove);
container.addEventListener('touchstart', onTouchStart, { passive: false });
container.addEventListener('click', onClick);
function updateLabelScale(sprite) {
if (!sprite) return;
const camDist = camera.position.distanceTo(sprite.position);
const scaleFactor = Math.max(0.1, camDist * 0.04);
sprite.scale.copy(sprite.userData.baseScale).multiplyScalar(scaleFactor);
}
function loop() {
active.get(id).raf = requestAnimationFrame(loop);
frameCount++;
controls.update();
if (frameCount % 3 === 0) {
prevOccluders.forEach(mesh => {
if (mesh !== activeMesh) {
mesh.material.opacity = mesh.userData.baseOpacity;
}
mesh.userData.isOccluder = false;
});
prevOccluders = [];
const targets = [];
if (playerMesh && PLAYER_CONFIG.visible) targets.push(playerMesh);
if (activeMesh) targets.push(activeMesh);
targets.forEach(target => {
const direction = new THREE.Vector3().subVectors(target.position, camera.position).normalize();
raycaster.set(camera.position, direction);
const allIntersects = raycaster.intersectObjects(scene.children, true);
let targetFound = false;
for (const intersect of allIntersects) {
if (intersect.object === target) {
targetFound = true;
break;
}
if (meshes.includes(intersect.object) && intersect.object !== target) {
const occluder = intersect.object;
if (occluder === activeMesh) continue;
occluder.material.opacity = 0.2;
occluder.userData.isOccluder = true;
if (!prevOccluders.includes(occluder)) prevOccluders.push(occluder);
}
}
if (!targetFound && allIntersects.length > 0) {
const closest = allIntersects[0].object;
if (meshes.includes(closest)) {
closest.material.opacity = 0.2;
closest.userData.isOccluder = true;
if (!prevOccluders.includes(closest)) prevOccluders.push(closest);
}
}
});
}
if (playerLabel && PLAYER_CONFIG.visible && PLAYER_CONFIG.showLabel) {
updateLabelScale(playerLabel);
}
meshes.forEach(m => updateLabelScale(m.userData.labelSprite));
renderer.render(scene, camera);
}
active.set(id, {
scene, renderer, container, resizeObserver,
raf: requestAnimationFrame(loop),
onMouseMove, onTouchStart, onClick
});
logDebug(`创建 3D 视野组件: ${id}, items: ${items.length}, worldSize: ${worldSize}, 玩家可见: ${PLAYER_CONFIG.visible}`);
}
/* --------------- 防抖/立即/核心 渲染函数 --------------- */
// ... 这部分代码没有变化,故折叠 ...
function renderVisionMessageDebounced(messageId, $mesText, delay = 150) {
const existingTimer = renderTimers.get(messageId);
if (existingTimer) clearTimeout(existingTimer);
const timer = setTimeout(() => {
renderVisionMessage(messageId, $mesText, false);
renderTimers.delete(messageId);
}, delay);
renderTimers.set(messageId, timer);
logDebug(`防抖渲染消息: ${messageId}, 延迟: ${delay}ms`);
}
function renderVisionMessageImmediate(messageId, $mesText) {
const existingTimer = renderTimers.get(messageId);
if (existingTimer) {
clearTimeout(existingTimer);
renderTimers.delete(messageId);
}
renderVisionMessage(messageId, $mesText, true);
logDebug(`立即渲染消息: ${messageId}`);
}
function renderVisionMessage(messageId, $mesText, isImmediate = false) {
const msgObj = SillyTavern.chat[messageId];
if (!msgObj) {
logDebug(`消息不存在: ${messageId}`);
return;
}
const reg = /<vision3d size="([\d.]+)">(.*?)<\/vision3d>/s;
const match = msgObj.mes.match(reg);
if (active.has(messageId)) {
cleanup(messageId);
}
if (match) {
let html = msgObj.mes;
const placeholderId = `v3d-${messageId}-${Date.now()}`;
html = html.replace(reg, `<div id="${placeholderId}"></div>`);
if (!$mesText) {
const mesElement = $(`#chat .mes[mesid="${messageId}"]`);
if (mesElement.length) {
$mesText = mesElement.find('.mes_text');
}
}
if ($mesText && $mesText.length) {
$mesText.html(SillyTavern.messageFormatting(
html, msgObj.name, msgObj.is_system, msgObj.is_user, messageId
));
const renderDelay = isImmediate ? 0 : 50;
setTimeout(() => {
const $placeholder = $(`#${placeholderId}`, $mesText);
if ($placeholder.length) {
render3D(messageId, $placeholder, +match[1], match[2]);
}
}, renderDelay);
}
}
}
/* --------------- 批量渲染函数 --------------- */
function renderAllVisionMessages() {
logDebug('开始批量渲染所有vision3d消息');
$('#chat .mes[is_user="false"][is_system="false"]').each((_, el) => {
const messageId = +el.getAttribute('mesid');
const $mesText = $('.mes_text', el);
if (messageId && $mesText.length) {
renderVisionMessageDebounced(messageId, $mesText, 100);
}
});
}
/* --------------- 优化的事件监听设置 --------------- */
// ... 这部分代码没有变化,故折叠 ...
function setupOptimizedEventListeners() {
if (!SillyTavern.eventTypes || !SillyTavern.eventSource) {
logDebug('SillyTavern 事件系统未就绪');
return;
}
const eventTypes = SillyTavern.eventTypes;
const eventSource = SillyTavern.eventSource;
logDebug('设置优化的事件监听器');
let messageSentRecently = false;
let messageSentResetTimer = null;
if (eventTypes.MESSAGE_SENT) {
eventSource.on(eventTypes.MESSAGE_SENT, () => {
logDebug('Event: MESSAGE_SENT');
messageSentRecently = true;
if (messageSentResetTimer) clearTimeout(messageSentResetTimer);
messageSentResetTimer = setTimeout(() => { messageSentRecently = false; }, 1000);
});
}
if (eventTypes.GENERATION_STARTED) {
eventSource.on(eventTypes.GENERATION_STARTED, () => {
logDebug(`Event: GENERATION_STARTED (messageSentRecently: ${messageSentRecently})`);
if (messageSentRecently) {
const latestMessage = SillyTavern.chat[SillyTavern.chat.length - 1];
if (latestMessage && !latestMessage.is_user) {
renderVisionMessageDebounced(SillyTavern.chat.length - 1);
}
messageSentRecently = false;
if (messageSentResetTimer) clearTimeout(messageSentResetTimer);
}
});
}
if (eventTypes.GENERATION_ENDED) {
eventSource.on(eventTypes.GENERATION_ENDED, () => {
logDebug('Event: GENERATION_ENDED');
messageSentRecently = false;
if (messageSentResetTimer) clearTimeout(messageSentResetTimer);
for (let i = SillyTavern.chat.length - 1; i >= 0; i--) {
const msg = SillyTavern.chat[i];
if (msg && !msg.is_user && !msg.is_system) {
renderVisionMessageImmediate(i);
break;
}
}
});
}
if (eventTypes.MESSAGE_RECEIVED) {
eventSource.on(eventTypes.MESSAGE_RECEIVED, (messageId) => {
logDebug(`Event: MESSAGE_RECEIVED, messageId: ${messageId}`);
const msg = SillyTavern.chat[messageId];
if (msg && !msg.is_user && !msg.is_system) {
renderVisionMessageImmediate(messageId);
}
});
}
if (eventTypes.MESSAGE_SWIPED) {
eventSource.on(eventTypes.MESSAGE_SWIPED, (messageId) => {
logDebug(`Event: MESSAGE_SWIPED, messageId: ${messageId}`);
renderVisionMessageDebounced(messageId, null, 200);
});
}
if (eventTypes.MESSAGE_UPDATED) {
eventSource.on(eventTypes.MESSAGE_UPDATED, (messageId) => {
logDebug(`Event: MESSAGE_UPDATED, messageId: ${messageId}`);
renderVisionMessageDebounced(messageId, null, 150);
});
}
if (eventTypes.MESSAGE_DELETED) {
eventSource.on(eventTypes.MESSAGE_DELETED, (messageId) => {
logDebug(`Event: MESSAGE_DELETED, messageId: ${messageId}`);
cleanup(messageId);
});
}
if (eventTypes.MESSAGES_DELETED) {
eventSource.on(eventTypes.MESSAGES_DELETED, () => {
logDebug('Event: MESSAGES_DELETED');
active.forEach((_, id) => cleanup(id));
setTimeout(renderAllVisionMessages, 300);
});
}
if (eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED) {
eventSource.on(eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED, () => {
logDebug('Event: CHARACTER_FIRST_MESSAGE_SELECTED');
active.forEach((_, id) => cleanup(id));
setTimeout(renderAllVisionMessages, 200);
});
}
logDebug('优化的事件监听器设置完成');
}
/* --------------- 页面卸载清理与启动 --------------- */
// ... 这部分代码没有变化,故折叠 ...
function setupCleanupOnUnload() {
window.addEventListener('beforeunload', () => {
logDebug('页面卸载,清理所有3D视野组件');
active.forEach((_, id) => cleanup(id));
renderTimers.forEach(timer => clearTimeout(timer));
renderTimers.clear();
});
}
function waitReady() {
if (typeof SillyTavern !== 'undefined' && SillyTavern.chat && SillyTavern.eventSource && SillyTavern.eventTypes) {
logDebug('SillyTavern 环境就绪,初始化3D视野组件');
injectCss();
setupOptimizedEventListeners();
setupCleanupOnUnload();
setTimeout(renderAllVisionMessages, 500);
window.vision3dDebug = false;
window.vision3dCleanup = (id) => { id !== undefined ? cleanup(id) : active.forEach((_, id) => cleanup(id)); };
window.vision3dRenderAll = renderAllVisionMessages;
window.vision3dPlayerConfig = PLAYER_CONFIG;
logDebug(`3D视野组件初始化完成 (玩家可见: ${PLAYER_CONFIG.visible}), 支持bbox格式`);
} else {
setTimeout(waitReady, 300);
}
}
waitReady();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment