/** * demo_viewer.js —— W-07 实时演示前端控制 * 职责:three.js 渲染世界 + 500ms 轮询状态(<1s 实时反映) + 调用 /scense_demo/api/*.dspy * 状态隔离:所有修改走覆盖层接口,结束演示后正式数据不变(服务端保证)。 */ (function () { 'use strict'; var API = { start: '/scense_demo/api/start.dspy', stop: '/scense_demo/api/stop.dspy', camera: '/scense_demo/api/camera.dspy', pause: '/scense_demo/api/pause.dspy', edit: '/scense_demo/api/entity_edit.dspy', attrs: '/scense_demo/api/entity_attrs.dspy', hotload: '/scense_demo/api/hotload.dspy', presence: '/scense_demo/api/presence.dspy', fullscreen: '/scense_demo/api/fullscreen.dspy', state: '/scense_demo/api/state.dspy' }; var state = { sessionId: null, worldId: null, status: null, cameraMode: 'orbit', timer: null, entities: {}, three: null, scene: null, camera: null, renderer: null, meshes: {}, raf: null, clock: 0, attrsMode: false }; function getParam(name) { var m = new RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search); return m ? decodeURIComponent(m[1]) : null; } function post(url, body) { return fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}) }).then(function (r) { return r.json(); }); } function setLabel(text, color) { var el = document.getElementById('demo_session_label'); if (el) { el.textContent = text; if (color) el.style.color = color; } } function ensureThree() { if (window.THREE) return Promise.resolve(window.THREE); return new Promise(function (resolve) { var s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'; s.onload = function () { resolve(window.THREE); }; s.onerror = function () { resolve(null); }; document.head.appendChild(s); }); } function getContainer() { var c = document.getElementById('demo_canvas_container'); if (!c) return null; c.style.display = 'block'; return c; } function initThree() { var container = getContainer(); if (!container) return; container.innerHTML = ''; var w = container.clientWidth || 600; var h = container.clientHeight || 400; if (!state.three) return; var T = state.three; state.scene = new T.Scene(); state.scene.background = new T.Color(0x1a1d26); state.camera = new T.PerspectiveCamera(60, w / h, 0.1, 1000); state.camera.position.set(8, 6, 10); state.camera.lookAt(0, 0, 0); var grid = new T.GridHelper(20, 20, 0x3a4152, 0x2a2f3d); state.scene.add(grid); var amb = new T.AmbientLight(0xffffff, 0.5); state.scene.add(amb); var dir = new T.DirectionalLight(0xffffff, 0.8); dir.position.set(10, 20, 10); state.scene.add(dir); state.renderer = new T.WebGLRenderer({antialias: true}); state.renderer.setSize(w, h); container.appendChild(state.renderer.domElement); animate(); } function animate() { state.raf = requestAnimationFrame(animate); if (!state.renderer || !state.scene) return; state.clock += 0.01; Object.keys(state.meshes).forEach(function (id) { var m = state.meshes[id]; if (m && m.userData && m.userData.rot) { m.rotation.y = state.clock * (m.userData.rotSpeed || 0.5); } }); applyCamera(); state.renderer.render(state.scene, state.camera); } function applyCamera() { if (!state.camera) return; if (state.cameraMode === 'topdown') { state.camera.position.lerp(new state.three.Vector3(0, 20, 0.01), 0.08); state.camera.lookAt(0, 0, 0); } else if (state.cameraMode === 'firstperson') { state.camera.position.lerp(new state.three.Vector3(2, 2, 6), 0.08); state.camera.lookAt(0, 1.5, 0); } else { state.camera.position.lerp(new state.three.Vector3(8, 6, 10), 0.08); state.camera.lookAt(0, 0, 0); } } function renderState(data) { if (!state.scene || !state.three) return; var seen = {}; (data.entities || []).forEach(function (ent) { seen[ent.entity_id] = true; var cfg = ent.state || {}; var color = cfg.color || '#4A90D9'; var size = cfg.size || 1; if (!state.meshes[ent.entity_id]) { var geo = new state.three.BoxGeometry(size, size, size); var mat = new state.three.MeshStandardMaterial({color: new state.three.Color(color)}); var mesh = new state.three.Mesh(geo, mat); mesh.position.set((Object.keys(state.meshes).length % 6) * 2.5 - 5, 0.5, 0); mesh.userData = {rot: true, rotSpeed: cfg.rotSpeed || 0.5}; state.scene.add(mesh); state.meshes[ent.entity_id] = mesh; } else { var m = state.meshes[ent.entity_id]; if (cfg.color) m.material.color.set(cfg.color); } }); Object.keys(state.meshes).forEach(function (id) { if (!seen[id]) { state.scene.remove(state.meshes[id]); delete state.meshes[id]; } }); } function setAttrsPanel(data) { var el = getContainer(); if (!el) return; var lines = ['【实体属性】 ' + data.entity_name + ' (' + data.entity_id + ')']; lines.push('--- 基线(base, 演示开始快照) ---'); lines.push(JSON.stringify(data.base, null, 2)); lines.push('--- 当前(demo, 演示中值) ---'); lines.push(JSON.stringify(data.demo, null, 2)); lines.push('--- 合并(merged, 渲染值) ---'); lines.push(JSON.stringify(data.merged, null, 2)); var div = document.createElement('pre'); div.style.color = '#8A93A6'; div.style.whiteSpace = 'pre-wrap'; div.textContent = lines.join('\n'); el.innerHTML = ''; el.appendChild(div); } function poll() { if (!state.sessionId) return; post(API.state, {session_id: state.sessionId}).then(function (r) { if (r.status === 200 && r.data) { state.status = r.data.status; state.cameraMode = r.data.camera_mode || state.cameraMode; setLabel('会话 ' + state.sessionId.slice(0, 12) + ' · ' + state.status + ' · 视角 ' + state.cameraMode + ' · 在线 ' + (r.data.viewer_count || 0), '#22C55E'); if (state.attrsMode) { state.attrsMode = false; } if (state.scene) renderState(r.data); } else { setLabel('轮询失败: ' + (r.message || '未知'), '#EF4444'); } }).catch(function (e) { setLabel('轮询异常: ' + e.message, '#EF4444'); }); } function startPolling() { if (state.timer) clearInterval(state.timer); state.timer = setInterval(poll, 500); // 500ms 轮询 → 编辑实体 <1s 反映 poll(); } function stopPolling() { if (state.timer) { clearInterval(state.timer); state.timer = null; } } var demoViewer = { startDemo: function () { var worldId = state.worldId || getParam('world_id'); if (!worldId) { worldId = window.prompt('请输入世界 ID (W-01 world.id):'); if (!worldId) { setLabel('已取消:缺少 world_id', '#FF8800'); return; } } state.worldId = worldId; ensureThree().then(function (T) { state.three = T; return post(API.start, {world_id: worldId, session_name: '演示-' + worldId}); }).then(function (r) { if (r.status === 200 && r.data) { state.sessionId = r.data.session_id; initThree(); startPolling(); setLabel('演示已开始: ' + r.data.session_id.slice(0, 12), '#22C55E'); } else { setLabel('开始失败: ' + (r.message || '未知'), '#EF4444'); } }).catch(function (e) { setLabel('开始异常: ' + e.message, '#EF4444'); }); }, stopDemo: function () { if (!state.sessionId) { setLabel('未开始演示', '#FF8800'); return; } post(API.stop, {session_id: state.sessionId}).then(function (r) { stopPolling(); if (r.status === 200) { setLabel('演示已结束,正式数据未被修改', '#22C55E'); } else { setLabel('结束失败: ' + (r.message || '未知'), '#EF4444'); } state.sessionId = null; state.meshes = {}; var c = getContainer(); if (c) { c.innerHTML = ''; c.textContent = '3D 预览区(已结束)'; } }); }, switchCamera: function (mode) { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } post(API.camera, {session_id: state.sessionId, camera_mode: mode}).then(function (r) { if (r.status === 200) { state.cameraMode = r.data.camera_mode; setLabel('视角已切换: ' + r.data.camera_mode, '#22C55E'); } else setLabel('视角切换失败: ' + (r.message || '未知'), '#EF4444'); }); }, pauseDemo: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } post(API.pause, {session_id: state.sessionId, paused: true}).then(function (r) { if (r.status === 200) setLabel('已暂停', '#FF8800'); else setLabel('暂停失败: ' + (r.message || '未知'), '#EF4444'); }); }, resumeDemo: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } post(API.pause, {session_id: state.sessionId, paused: false}).then(function (r) { if (r.status === 200) setLabel('已继续', '#22C55E'); else setLabel('继续失败: ' + (r.message || '未知'), '#EF4444'); }); }, editEntity: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } var entityId = window.prompt('输入要编辑的实体 ID:'); if (!entityId) return; var fieldsRaw = window.prompt('输入字段 JSON(如 {"color":"#22C55E","size":2,"rotSpeed":1}):'); if (!fieldsRaw) return; var fields; try { fields = JSON.parse(fieldsRaw); } catch (e) { setLabel('字段 JSON 非法: ' + e.message, '#EF4444'); return; } post(API.edit, {session_id: state.sessionId, entity_id: entityId, fields: fields}).then(function (r) { if (r.status === 200) setLabel('实体已更新(<1s 前端反映)', '#22C55E'); else setLabel('编辑失败: ' + (r.message || '未知'), '#EF4444'); }); }, showAttrs: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } var entityId = window.prompt('输入要查看的实体 ID:'); if (!entityId) return; post(API.attrs, {session_id: state.sessionId, entity_id: entityId}).then(function (r) { if (r.status === 200 && r.data) { state.attrsMode = true; setAttrsPanel(r.data); } else setLabel('查询失败: ' + (r.message || '未知'), '#EF4444'); }); }, hotload: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } var scriptId = window.prompt('输入脚本 ID (script_engine 联动):'); if (!scriptId) return; var content = window.prompt('输入脚本内容(保存即语法校验+热加载):'); if (!content) return; post(API.hotload, {session_id: state.sessionId, script_id: scriptId, script_content: content}).then(function (r) { if (r.status === 200) setLabel('脚本已热加载: ' + r.data.status, '#22C55E'); else setLabel('热加载失败: ' + (r.message || '未知'), '#EF4444'); }); }, joinPresence: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } post(API.presence, {session_id: state.sessionId, action: 'join'}).then(function (r) { if (r.status === 200) setLabel('已加入多人预览, 当前在线 ' + r.data.viewer_count, '#22C55E'); else setLabel('加入失败: ' + (r.message || '未知'), '#EF4444'); }); }, leavePresence: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } post(API.presence, {session_id: state.sessionId, action: 'leave', user_id: ''}).then(function (r) { if (r.status === 200) setLabel('已离开预览', '#22C55E'); else setLabel('离开失败: ' + (r.message || '未知'), '#EF4444'); }); }, toggleFullscreen: function () { if (!state.sessionId) { setLabel('请先开始演示', '#FF8800'); return; } var el = getContainer(); var enabled = false; if (document.fullscreenElement) { document.exitFullscreen(); enabled = false; } else if (el && el.requestFullscreen) { el.requestFullscreen(); enabled = true; } post(API.fullscreen, {session_id: state.sessionId, enabled: enabled}).then(function (r) { if (r.status === 200) setLabel('全屏: ' + (enabled ? '开' : '关'), '#22C55E'); }); } }; window.demoViewer = demoViewer; })(); /* —— 按钮事件委托(demo_viewer.ui 按钮无 script 绑定,按文本路由)—— */ (function () { var ACT = { '开始演示': function () { window.demoViewer.startDemo(); }, '结束演示': function () { window.demoViewer.stopDemo(); }, '暂停': function () { window.demoViewer.pauseDemo(); }, '继续': function () { window.demoViewer.resumeDemo(); }, '编辑实体': function () { window.demoViewer.editEntity(); }, '查看属性': function () { window.demoViewer.showAttrs(); }, '保存脚本(热加载)': function () { window.demoViewer.hotload(); }, '全屏': function () { window.demoViewer.toggleFullscreen(); }, '视角:环绕': function () { window.demoViewer.switchCamera('orbit'); }, '视角:俯视': function () { window.demoViewer.switchCamera('topdown'); }, '视角:第一人称': function () { window.demoViewer.switchCamera('firstperson'); }, '加入多人预览': function () { window.demoViewer.joinPresence(); }, '离开预览': function () { window.demoViewer.leavePresence(); } }; document.addEventListener('click', function (e) { var b = e.target && e.target.closest ? e.target.closest('button') : null; if (!b || !window.demoViewer) return; var txt = (b.textContent || '').trim(); if (ACT[txt]) { e.preventDefault(); ACT[txt](); } }); })();