From 406ed6f17af9e6e04ad6a896d90eb4afd0d6a191 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 27 Aug 2026 15:41:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(scene3d):=20Scene3D=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=BC=95=E6=93=8E=20+=20=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=20three.js=20+=20=E8=87=AA=E5=86=99=E7=8E=AF?= =?UTF-8?q?=E7=BB=95=E7=9B=B8=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine.js/inherit.js 以 UMD 双分支引入(node 测试照跑 + 浏览器拼进 bricks.js) - Scene3D 新增本地逻辑引擎(独立世界):描述符 properties/events 喂 SceneRuntime, 点击→tap 注入、每帧 step 推进、transform/visible 回填 three.js - three.min.js 按需注入(非 3D 页面不背 608KB) - 自写环绕相机:单指旋转/双指缩放/双指平移(three.js r148+ 移除 UMD OrbitControls) - 新增 API:set_entity_transform / set_paused / reset_runtime - 已验证:门铃示例(定时+条件+点击+moveTo+属性变化)、相机旋转全链路 --- bricks/build.sh | 2 +- bricks/engine.js | 545 ++++++++++++++++++++++++++++++++++++++++++++++ bricks/inherit.js | 134 ++++++++++++ bricks/scene3d.js | 285 ++++++++++++++++++++---- 4 files changed, 924 insertions(+), 42 deletions(-) create mode 100644 bricks/engine.js create mode 100644 bricks/inherit.js diff --git a/bricks/build.sh b/bricks/build.sh index 1f62f41..2638fc4 100755 --- a/bricks/build.sh +++ b/bricks/build.sh @@ -13,7 +13,7 @@ SOURCES=" page_data_loader.js factory.js uitypesdef.js utils.js uitype.js \ line.js pie.js bar.js gobang.js period.js iconbarpage.js \ keypress.js asr.js webspeech.js countdown.js progressbar.js \ qaframe.js svg.js videoplayer.js scatter.js radar.js kline.js \ - heatmap.js map.js qr.js textfiles.js agent_input.js agent.js api_doc.js flipcard.js carousel.js draggable.js resourcebrowser.js scene3d.js " + heatmap.js map.js qr.js textfiles.js agent_input.js agent.js api_doc.js flipcard.js carousel.js draggable.js resourcebrowser.js inherit.js engine.js scene3d.js " echo ${SOURCES} cat ${SOURCES} > ../dist/bricks.js # uglifyjs --compress --mangle -- ../dist/bricks.js > ../dist/bricks.min.js diff --git a/bricks/engine.js b/bricks/engine.js new file mode 100644 index 0000000..8ae66dd --- /dev/null +++ b/bricks/engine.js @@ -0,0 +1,545 @@ +/* + * engine.js — 脚本引擎解释器原型 + * 实现《脚本引擎设计规范》v1.0 的类型系统 / 表达式求值 / 事件 / 动作 / 执行模型 / 错误语义。 + * 纯同步、确定性的离散事件仿真,无任何外部依赖,可用 node 直接运行。 + * + * 渲染 / 音频 / 变换动画在原型中为桩(记录日志),但【逻辑语义】——事件顺序、属性变化检测、 + * wait 挂起、控制流、类型检查、终止性、错误码——全部真实实现。 + * + * 实体继承:加载时按《实体继承设计规范》§3 求解有效定义(inherit.js),扁平化注入, + * 脚本引擎运行时不感知继承。 + */ + +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(require('./inherit')); + } else { + root.bricks = root.bricks || {}; + root.bricks.ScriptEngine = factory(root.bricks.ScriptInherit); + } +}(typeof self !== 'undefined' ? self : this, function (inherit) { +const resolveAll = inherit.resolveAll; + +// ────────────────────────────── 类型系统(§1)────────────────────────────── + +function typeOf(v) { + if (v === null) return 'null'; + if (typeof v === 'number') return 'number'; + if (typeof v === 'boolean') return 'boolean'; + if (typeof v === 'string') return 'string'; + if (v && typeof v === 'object' && '$e' in v) return 'entity'; + return 'unknown'; +} + +// ────────────────────────────── 错误(§8)─────────────────────────────── + +class ScriptError extends Error { + constructor(code, message, loc) { + super(`[${code}] ${message}` + (loc ? ` (${loc})` : '')); + this.code = code; + this.loc = loc; + } +} + +const E_TYPE = 'E-TYPE', E_REF = 'E-REF', E_LOOP = 'E-LOOP', + E_DEPTH = 'E-DEPTH', E_BUDGET = 'E-BUDGET', E_DIV0 = 'E-DIV0'; + +// ────────────────────────────── 表达式求值(§3)─────────────────────────── + +// 运行时上下文:求值表达式所需的作用域与运行时句柄 +function makeCtx(rt, entity, message) { + return { rt, entity, scene: rt.scene, global: rt.global, message }; +} + +function resolveVar(ctx, node) { + const scope = node.scope; + if (scope === 'global') return ctx.global.vars; + if (scope === 'scene') return ctx.scene.vars; + if (scope === 'entity') { + if (!ctx.entity) throw new ScriptError(E_REF, '实体作用域变量在场景级脚本不可用', node.name); + return ctx.entity.vars; + } + // auto:实体 → 场景 → 全局(遮蔽规则 §2.3) + if (ctx.entity && node.name in ctx.entity.vars) return ctx.entity.vars; + if (node.name in ctx.scene.vars) return ctx.scene.vars; + return ctx.global.vars; +} + +function getProp(ctx, target, name) { + const holder = target === 'self' ? ctx.entity + : target === 'scene' ? ctx.scene + : ctx.rt.entities[target]; + if (!holder) throw new ScriptError(E_REF, `引用的实体不存在: ${target}`, name); + const p = holder.properties[name]; + if (p === undefined) throw new ScriptError(E_REF, `属性不存在: ${name}`, name); + return p.value; +} + +function evalExpr(node, ctx, depth = 0) { + if (depth > 64) throw new ScriptError(E_DEPTH, '表达式嵌套超限'); + const k = node.k; + if (k === 'lit') return node.v; + if (k === 'var') { + const vars = resolveVar(ctx, node); + if (!(node.name in vars)) throw new ScriptError(E_REF, `变量未定义: ${node.name}`, node.name); + return vars[node.name]; + } + if (k === 'prop') return getProp(ctx, node.target, node.name); + if (k === 'unary') { + const x = evalExpr(node.x, ctx, depth + 1); + if (node.op === '-') { if (typeOf(x) !== 'number') throw new ScriptError(E_TYPE, '一元 - 要求 number'); return -x; } + if (node.op === '!') { if (typeOf(x) !== 'boolean') throw new ScriptError(E_TYPE, '一元 ! 要求 boolean'); return !x; } + } + if (k === 'bin') return evalBin(node, ctx, depth); + if (k === 'call') { + const fn = BUILTINS[node.fn]; + if (!fn) throw new ScriptError(E_REF, `未知函数: ${node.fn}`); + const args = (node.args || []).map(a => evalExpr(a, ctx, depth + 1)); + return fn(args, ctx); + } + throw new ScriptError(E_TYPE, `未知表达式节点: ${k}`); +} + +function evalBin(node, ctx, depth) { + const op = node.op; + if (op === '&&' || op === '||') { + const l = evalExpr(node.l, ctx, depth + 1); + if (typeOf(l) !== 'boolean') throw new ScriptError(E_TYPE, `&&/|| 操作数须为 boolean,实为 ${typeOf(l)}`); + if (op === '&&') return l === false ? false : (evalExpr(node.r, ctx, depth + 1) === true); + return l === true ? true : (evalExpr(node.r, ctx, depth + 1) === true); + } + const l = evalExpr(node.l, ctx, depth + 1); + const r = evalExpr(node.r, ctx, depth + 1); + const tl = typeOf(l), tr = typeOf(r); + if (op === '+') { + if (tl === 'number' && tr === 'number') return l + r; + if (tl === 'string' && tr === 'string') return l + r; + throw new ScriptError(E_TYPE, `+ 要求两侧同为 number 或同为 string,实为 ${tl}/${tr}`); + } + if (['-', '*', '/', '%'].includes(op)) { + if (tl !== 'number' || tr !== 'number') throw new ScriptError(E_TYPE, `${op} 要求两侧为 number`); + if (op === '-' ) return l - r; + if (op === '*' ) return l * r; + if (op === '/' ) return l / r; + if (r === 0) throw new ScriptError(E_DIV0, '取模除数为 0'); + return l % r; + } + if (['>', '<', '>=', '<='].includes(op)) { + if (tl !== 'number' || tr !== 'number') throw new ScriptError(E_TYPE, `${op} 要求两侧为 number`); + if (op === '>') return l > r; + if (op === '<') return l < r; + if (op === '>=') return l >= r; + return l <= r; + } + if (op === '==' || op === '!=') { + if (tl !== tr) throw new ScriptError(E_TYPE, `==/!= 要求两侧类型相同,实为 ${tl}/${tr}`); + const eq = tl === 'entity' ? (l.$e === r.$e) : (l === r); + return op === '==' ? eq : !eq; + } + throw new ScriptError(E_TYPE, `未知运算符: ${op}`); +} + +// ────────────────────────────── 真值表(§1.3)───────────────────────────── + +function truthy(v) { + const t = typeOf(v); + if (t === 'boolean') return v; + if (t === 'number') return v !== 0; + if (t === 'string') return v.length > 0; + if (t === 'entity') return true; // 非 null 实体恒真 + if (t === 'null') return false; + return false; +} + +// ────────────────────────────── 内建函数(§3.5 封闭集合)─────────────────── + +const BUILTINS = { + abs: ([x]) => { needNum(x); return Math.abs(x); }, + min: ([a, b]) => { needNum(a); needNum(b); return Math.min(a, b); }, + max: ([a, b]) => { needNum(a); needNum(b); return Math.max(a, b); }, + floor: ([x]) => { needNum(x); return Math.floor(x); }, + ceil: ([x]) => { needNum(x); return Math.ceil(x); }, + round: ([x]) => { needNum(x); return Math.round(x); }, + random: ([a, b]) => { needNum(a); needNum(b); return a + Math.random() * (b - a); }, + randomInt: ([a, b]) => { needNum(a); needNum(b); return Math.floor(a + Math.random() * (b - a + 1)); }, + toNumber: ([s]) => { if (typeOf(s) !== 'string') throw new ScriptError(E_TYPE, 'toNumber 要求 string'); const n = Number(s); if (Number.isNaN(n)) throw new ScriptError(E_TYPE, `无法解析为数字: ${s}`); return n; }, + toString: ([x]) => { const t = typeOf(x); return t === 'entity' ? x.$e : t === 'null' ? '' : String(x); }, + len: ([s]) => { if (typeOf(s) !== 'string') throw new ScriptError(E_TYPE, 'len 要求 string'); return [...s].length; }, + contains: ([s, sub]) => { if (typeOf(s) !== 'string' || typeOf(sub) !== 'string') throw new ScriptError(E_TYPE, 'contains 要求两个 string'); return s.includes(sub); }, + compare: ([a, b]) => { if (typeOf(a) !== 'string' || typeOf(b) !== 'string') throw new ScriptError(E_TYPE, 'compare 要求两个 string'); return a < b ? -1 : a > b ? 1 : 0; }, + now: (_args, ctx) => ctx.rt.now(), + exists: ([e]) => { if (typeOf(e) !== 'entity') throw new ScriptError(E_TYPE, 'exists 要求 entity'); return !!e.$e; }, + getProp: ([e, name]) => { if (typeOf(e) !== 'entity') throw new ScriptError(E_TYPE, 'getProp 第一个参数须为 entity'); return getProp({ entity: null, rt: null }, e.$e, name); }, +}; +function needNum(x) { if (typeOf(x) !== 'number') throw new ScriptError(E_TYPE, `要求 number,实为 ${typeOf(x)}`); } + +// ────────────────────────────── 运行时(§7)─────────────────────────────── + +class SceneRuntime { + constructor(sceneDef, opts = {}) { + this.opts = opts; + this.T = 0; // 单调时钟(秒) + this.log = []; // 日志(音频/消息/变换桩 + 事件触发) + this.eventCount = 0; + this.budget = opts.budget || 100000; + this.frameDT = opts.frameDT || 0.1; // 仿真帧步长(秒) + this.halted = false; + + // 作用域容器 + this.global = { vars: {} }; + this.scene = { id: sceneDef.id, properties: buildProps(sceneDef.properties), vars: {}, events: sceneDef.events || [] }; + this.entities = {}; // id -> entity + this.entityOrder = []; // 创建顺序(§7.2 确定性) + + // 事件索引 + this.watchers = new Map(); // "target::prop" -> [eventRef] + this.intervals = []; // {event, nextFire, count, ref} + this.pendingPC = new Set(); // onPropertyChange 合并入队(§7.5.4) + + // 队列与唤醒 + this.readyQueue = []; // FIFO 任务 + this.wakeups = []; // {time, task, ref} + + // 继承求解(《实体继承设计规范》§5.1):场景加载时对所有实体求解一次, + // 模板实体过滤(不注册运行时),其余以扁平化定义注入 + const raw = sceneDef.entities || []; + const resolved = resolveAll(raw); // 非法继承在此抛 InheritError(保存期校验) + const resolvedList = raw + .filter(e => !e.template) // §5.4 模板实体不注册 + .map(e => resolved[e.id]); + this._buildEntities(resolvedList); + this._indexEvents(); + this._fireInitEvents(); + } + + now() { return this.T; } + + _log(kind, msg) { this.log.push({ t: this.T, kind, msg }); } + + _buildEntities(list) { + for (const e of list) { + this.entities[e.id] = { + id: e.id, name: e.name, type: e.type || 'model', + asset_ref: e.asset_ref || null, // 继承求解后的有效外观(§3.6) + properties: buildProps(e.properties), + vars: {}, + transform: e.transform ? { + pos: (e.transform.pos || [0,0,0]).slice(), + rot: (e.transform.rot || [0,0,0]).slice(), + scale: (e.transform.scale || [1,1,1]).slice(), + } : { pos: [0,0,0], rot: [0,0,0], scale: [1,1,1] }, + visible: true, events: e.events || [], + }; + this.entityOrder.push(e.id); + } + } + + _indexEvents() { + const reg = (holder, ev) => { + const ref = { holder, ev }; + if (ev.type === 'onInterval') { + this.intervals.push({ event: ev, holder, nextFire: (ev.params.startDelay || 0), count: 0, ref }); + } + if (ev.type === 'onPropertyChange') { + const p = ev.params; + // 归一化 target:"self" → 持有者实体 id / "scene";否则用字面量实体 id + const t = p.target === 'self' ? (holder === this.scene ? 'scene' : holder.id) : p.target; + const key = `${t}::${p.property}`; + if (!this.watchers.has(key)) this.watchers.set(key, []); + this.watchers.get(key).push(ref); + } + }; + // 场景事件先注册(§7.2 顺序:scene 优先) + for (const ev of this.scene.events) reg(this.scene, ev); + for (const eid of this.entityOrder) for (const ev of this.entities[eid].events) reg(this.entities[eid], ev); + } + + _fireInitEvents() { + // onStart:scene 先,实体按创建顺序 + const starts = []; + for (const ev of this.scene.events) if (ev.type === 'onStart') starts.push({ holder: this.scene, ev }); + for (const eid of this.entityOrder) for (const ev of this.entities[eid].events) if (ev.type === 'onStart') starts.push({ holder: this.entities[eid], ev }); + for (const s of starts) this._enqueue(s.holder, s.ev, null); + + for (const ev of this.scene.events) if (ev.type === 'onSceneLoad') this._enqueue(this.scene, ev, null); + } + + // 目标解析:'self' | 'scene' | 实体ID + _holder(target, curEntity) { + if (target === 'self') { if (!curEntity) throw new ScriptError(E_REF, 'self 在场景级脚本不可用'); return curEntity; } + if (target === 'scene') return this.scene; + const h = this.entities[target]; + if (!h) throw new ScriptError(E_REF, `实体不存在: ${target}`); + return h; + } + + _enqueue(holder, ev, message) { + const task = { holder, ev, message, gen: null, eventId: ev.id }; + this.readyQueue.push(task); + if (ev.type === 'onPropertyChange') this.pendingPC.add(ev.id); + this._log('enqueue', `event=${ev.type}#${ev.id} 入队 (holder=${holder === this.scene ? 'scene' : holder.id})`); + } + + // ── 主循环 ── + run(maxSeconds) { + const end = maxSeconds; + while (this.T < end - 1e-9 && !this.halted) { + this.T += this.frameDT; + this._tick(); + } + return { log: this.log, scene: this.scene, entities: this.entities, global: this.global, eventCount: this.eventCount }; + } + + // 逐帧推进(浏览器实时模式):推进 dt 秒并执行一次 tick。 + // 与 run 的区别:dt 为真实墙钟增量(非固定 frameDT),供 requestAnimationFrame 每帧调用。 + step(dt) { + if (this.halted) return; + this.T += dt; + this._tick(); + } + + _tick() { + // ① 到期定时器(确定性顺序已在 _indexEvents 保证:scene 先、实体按创建顺序、声明顺序) + for (const it of this.intervals) { + const p = it.event.params; + if (this.T + 1e-9 >= it.nextFire) { + const max = p.maxCount; + if (max != null && it.count >= max) continue; + let guardPass = true; + if (p.guard) { + const ctx = makeCtx(this, it.holder === this.scene ? null : it.holder, null); + guardPass = truthy(evalExpr(p.guard, ctx)); + } + if (guardPass) { this._enqueue(it.holder, it.event, null); it.count++; } + if (p.loop) it.nextFire += p.interval; else it.nextFire = Infinity; + } + } + // ② 到期 wait 唤醒 + const due = this.wakeups.filter(w => this.T + 1e-9 >= w.time); + this.wakeups = this.wakeups.filter(w => this.T + 1e-9 < w.time); + for (const w of due) this.readyQueue.push(w.task); + // ③ 处理就绪队列 + this._processQueue(); + } + + _processQueue() { + while (this.readyQueue.length && !this.halted) { + const task = this.readyQueue.shift(); + if (task.ev.type === 'onPropertyChange') this.pendingPC.delete(task.eventId); + if (!task.gen) { + const holder = task.holder; + const ctx = makeCtx(this, holder === this.scene ? null : holder, task.message); + task.gen = runActions(task.ev.actions || [], ctx, this, task); + } + this.eventCount++; + if (this.eventCount > this.budget) { + this._log('error', `E-BUDGET 事件预算超限(${this.budget})`); + this.halted = true; + break; + } + // 驱动该任务直到挂起(wait)或结束 + let r; + while (true) { + try { + r = task.gen.next(); + } catch (e) { + if (e && e.$sceneSwitch) { this._log('scene', `场景切换: ${e.$sceneSwitch}`); this.halted = true; break; } + if (e instanceof ScriptError) { this._log('error', `${e.code} ${e.message}`); } + else this._log('error', `未捕获异常: ${e && e.message}`); + break; // 任务终止(§8.3:仅终止当前任务) + } + if (r.done) break; + if (r.value && r.value.wait !== undefined) { + this.wakeups.push({ time: this.T + r.value.wait, task }); + this._log('wait', `任务挂起 ${r.value.wait}s,将在 T=${(this.T + r.value.wait).toFixed(3)} 恢复`); + break; // 让出,处理下一就绪任务 + } + } + } + } + + // ── 属性写入(含变化检测 §7.5)── + _writeProp(holder, name, value, srcEventId) { + const p = holder.properties[name]; + if (p === undefined) throw new ScriptError(E_REF, `属性不存在: ${name}`); + if (typeOf(value) !== p.type) throw new ScriptError(E_TYPE, `属性 ${name} 类型应为 ${p.type},实为 ${typeOf(value)}`); + const v1 = p.value; + if (p.type === 'entity' ? (v1 && v1.$e) === (value && value.$e) : v1 === value) return; // 值未变 + p.value = value; + this._log('prop', `${holder.id}.${name} = ${fmtVal(value)}`); + this._onPropChange(holder, name); + } + + _onPropChange(holder, name) { + const target = holder === this.scene ? 'scene' : holder.id; + const key = `${target}::${name}`; + const refs = this.watchers.get(key) || []; + for (const ref of refs) { + if (this.pendingPC.has(ref.ev.id)) continue; // 合并:同一事件已有待处理任务(§7.5.4) + let pass = true; + if (ref.ev.params.condition) { + const ctx = makeCtx(this, ref.holder === this.scene ? null : ref.holder, null); + pass = truthy(evalExpr(ref.ev.params.condition, ctx)); + } + if (pass) this._enqueue(ref.holder, ref.ev, null); + } + } + + // ── 对外触发接口(供测试 onTap/onMessage/onAudioEnd)── + tap(entityId) { + const h = this.entities[entityId]; + if (!h) throw new ScriptError(E_REF, `实体不存在: ${entityId}`); + for (const ev of h.events) if (ev.type === 'onTap') this._enqueue(h, ev, null); + } + + sendMessage(name, payload, source) { + const deliver = (holder) => { + for (const ev of holder.events) { + if (ev.type !== 'onMessage') continue; + if (ev.params.name !== name) continue; + const src = ev.params.source || 'any'; + if (src !== 'any' && src !== source) continue; + this._enqueue(holder, ev, { name, source, payload: payload || null }); + } + }; + deliver(this.scene); + for (const eid of this.entityOrder) deliver(this.entities[eid]); + } +} + +function buildProps(def) { + const out = {}; + for (const [name, spec] of Object.entries(def || {})) { + const t = spec.type || 'string'; + const raw = spec.default !== undefined ? spec.default : spec.value; // 兼容 {default} 与 {value} + out[name] = { type: t, value: raw != null ? (t === 'entity' ? { $e: raw } : raw) : null }; + } + return out; +} + +function fmtVal(v) { + const t = typeOf(v); + if (t === 'entity') return `@${v.$e || 'null'}`; + if (t === 'string') return `"${v}"`; + if (t === 'null') return 'null'; + return String(v); +} + +// ────────────────────────────── 动作解释(generator,§6)────────────────── + +function* runActions(actions, ctx, rt, task) { + for (const a of actions) yield* runAction(a, ctx, rt, task); +} + +function* runAction(a, ctx, rt, task) { + const p = a.params || {}; + switch (a.type) { + case 'if': { + if (truthy(evalExpr(p.condition, ctx))) yield* runActions(p.then || [], ctx, rt, task); + else { + let matched = false; + for (const ei of (p.elseif || [])) { + if (truthy(evalExpr(ei.condition, ctx))) { yield* runActions(ei.actions || [], ctx, rt, task); matched = true; break; } + } + if (!matched && p.else) yield* runActions(p.else, ctx, rt, task); + } + return; + } + case 'while': { + let it = 0; + while (truthy(evalExpr(p.condition, ctx))) { + if (++it > p.maxIterations) throw new ScriptError(E_LOOP, `while 迭代超过 maxIterations=${p.maxIterations}`); + yield* runActions(p.body || [], ctx, rt, task); + } + return; + } + case 'for': { + const from = num(evalExpr(p.from, ctx)); + const to = num(evalExpr(p.to, ctx)); + const step = num(evalExpr(p.step, ctx)); + if (step === 0) throw new ScriptError(E_TYPE, 'for 的 step 不能为 0'); + const staticCap = Math.floor(Math.abs((to - from) / step)) + 1; + const cap = p.maxIterations != null ? Math.min(staticCap, p.maxIterations) : staticCap; + const vars = ctx.entity ? ctx.entity.vars : ctx.scene.vars; // 循环变量:实体上下则实体作用域,否则场景作用域 + let v = from, it = 0; + while ((step > 0 ? v <= to : v >= to) && it < cap) { + vars[p.var] = v; + yield* runActions(p.body || [], ctx, rt, task); + v += step; it++; + } + return; + } + case 'wait': { + const d = num(evalExpr(p.duration, ctx)); + yield { wait: d }; + return; + } + case 'setVariable': { + const holder = p.scope === 'global' ? ctx.global : p.scope === 'scene' ? ctx.scene : ctx.entity; + if (!holder) throw new ScriptError(E_REF, 'setVariable 目标作用域不可用'); + holder.vars[p.name] = evalExpr(p.value, ctx); + rt._log('var', `${p.scope}.${p.name} = ${fmtVal(holder.vars[p.name])}`); + return; + } + case 'setProperty': { + const h = rt._holder(p.target, ctx.entity); + rt._writeProp(h, p.name, evalExpr(p.value, ctx)); + return; + } + case 'changeProperty': { + const h = rt._holder(p.target, ctx.entity); + const cur = h.properties[p.name]; + if (!cur) throw new ScriptError(E_REF, `属性不存在: ${p.name}`); + if (cur.type !== 'number') throw new ScriptError(E_TYPE, `changeProperty 要求 number 属性: ${p.name}`); + rt._writeProp(h, p.name, cur.value + num(evalExpr(p.delta, ctx))); + return; + } + case 'playAudio': { + rt._log('audio', `▶ play ${p.audio} vol=${p.volume ?? 1} loop=${p.loop ?? false}`); + const dur = (rt.opts.audioDurations || {})[p.audio]; + if (dur && !p.loop) { + // 桩:自然播完触发 onAudioEnd + const fire = () => { for (const [eid, h] of Object.entries(rt.entities)) for (const ev of h.events) if (ev.type === 'onAudioEnd' && ev.params.audio === p.audio) rt._enqueue(h, ev, null); }; + rt.wakeups.push({ time: rt.T + dur, task: { holder: ctx.entity || ctx.scene, ev: { type: '__audioEnd', id: 'audio_end_' + p.audio, actions: [] }, message: null, gen: null, _post: fire } }); + } + return; + } + case 'stopAudio': rt._log('audio', `■ stop ${p.audio}`); return; + case 'pauseAudio': rt._log('audio', `⏸ pause ${p.audio}`); return; + case 'resumeAudio': rt._log('audio', `▶ resume ${p.audio}`); return; + case 'moveTo': case 'rotateTo': case 'scaleTo': { + const h = rt._holder(p.target, ctx.entity); + if (a.type === 'moveTo') h.transform.pos = p.position && p.position.entity ? rt.entities[p.position.entity].transform.pos.slice() : [p.position.x, p.position.y, p.position.z]; + if (a.type === 'rotateTo') h.transform.rot = [p.rotation.x, p.rotation.y, p.rotation.z]; + if (a.type === 'scaleTo') h.transform.scale = Array.isArray(p.scale) ? p.scale : [p.scale, p.scale, p.scale]; + rt._log('transform', `${a.type} ${h.id} → ${JSON.stringify(h.transform.pos)} dur=${p.duration ?? 0} easing=${p.easing ?? 'linear'}`); + return; + } + case 'lookAt': { + const h = rt._holder(p.target, ctx.entity); + rt._log('transform', `lookAt ${h.id} → ${JSON.stringify(p.position)}`); + return; + } + case 'show': case 'hide': { + const h = rt._holder(p.target, ctx.entity); + h.visible = (a.type === 'show'); + rt._log('prop', `${h.id}.visible = ${h.visible}`); + return; + } + case 'switchScene': throw { $sceneSwitch: p.scene }; + case 'reloadScene': throw { $sceneSwitch: '' }; + case 'sendMessage': { + const src = ctx.entity ? ctx.entity.id : 'scene'; + rt.sendMessage(p.name, p.payload, src); + rt._log('msg', `sendMessage "${p.name}" from ${src}`); + return; + } + default: + throw new ScriptError(E_TYPE, `未知动作类型: ${a.type}`); + } +} + +function num(x) { if (typeOf(x) !== 'number') throw new ScriptError(E_TYPE, `要求 number,实为 ${typeOf(x)}`); return x; } + +return { SceneRuntime, evalExpr, truthy, typeOf, ScriptError, makeCtx, E_TYPE, E_REF, E_LOOP, E_DEPTH, E_BUDGET, E_DIV0 }; +})); diff --git a/bricks/inherit.js b/bricks/inherit.js new file mode 100644 index 0000000..b1fc7f5 --- /dev/null +++ b/bricks/inherit.js @@ -0,0 +1,134 @@ +/* + * inherit.js — 《实体继承设计规范》§3「有效定义求解」的实现 + * + * 原型式单继承:沿 parent 链自根向叶合并属性定义与事件集。 + * 求解产出扁平化定义,脚本引擎不感知继承(规范 P1)。 + * 所有非法结构抛 InheritError(错误码见规范 §6.1),对应保存期校验。 + */ + +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + } else { + root.bricks = root.bricks || {}; + root.bricks.ScriptInherit = factory(); + } +}(typeof self !== 'undefined' ? self : this, function () { +const E_INH_CYCLE = 'E-INH-CYCLE'; +const E_INH_DEPTH = 'E-INH-DEPTH'; +const E_INH_NOSUCHPARENT = 'E-INH-NOSUCHPARENT'; +const E_INH_TYPE = 'E-INH-TYPE'; +const E_INH_DUPID = 'E-INH-DUPID'; +const E_INH_NOSUCHEVENT = 'E-INH-NOSUCHEVENT'; +const E_INH_CONFLICT = 'E-INH-CONFLICT'; + +class InheritError extends Error { + constructor(code, message) { + super(`[${code}] ${message}`); + this.code = code; + } +} + +// 属性定义的默认值:规范用 {type, default},兼容旧格式 {type, value} +function defDefault(def) { + return def.default !== undefined ? def.default : def.value; +} + +// §3.4 继承链校验:环 / 深度(≤32,含自身) / 父存在性 +function validateChain(e, all) { + const seen = new Set(); + let cur = e, depth = 0; + for (;;) { + if (seen.has(cur.id)) { + throw new InheritError(E_INH_CYCLE, `继承环: ${[...seen, cur.id].join(' → ')}`); + } + seen.add(cur.id); + depth++; + if (depth > 32) throw new InheritError(E_INH_DEPTH, `继承链深度超过 32(实体 ${e.id})`); + if (!cur.parent) break; + const p = all[cur.parent]; + if (!p) throw new InheritError(E_INH_NOSUCHPARENT, `父实体不存在: ${cur.parent}(引用自 ${cur.id})`); + cur = p; + } +} + +// §3.2 属性求解(自根向叶;覆盖只改默认值,类型必须一致) +function resolveProperties(e, all) { + if (!e.parent) { + const out = {}; + for (const [k, v] of Object.entries(e.properties || {})) out[k] = JSON.parse(JSON.stringify(v)); + return out; + } + const base = resolveProperties(all[e.parent], all); + for (const [name, def] of Object.entries(e.properties || {})) { + if (name in base) { + const bt = base[name].type || 'string'; + const dt = def.type || 'string'; + if (bt !== dt) { + throw new InheritError(E_INH_TYPE, `实体 ${e.id} 覆盖属性 "${name}" 类型不匹配:继承类型 ${bt},覆盖类型 ${dt}`); + } + base[name] = { type: bt, default: defDefault(def) }; // 类型不变,只覆盖默认值 + } else { + base[name] = JSON.parse(JSON.stringify(def)); // 新增 + } + } + return base; +} + +// §3.3 事件求解(自根向叶;同 id 原位覆盖;禁用移除) +// 依赖 JS Map 语义:set 已存在的 key 保持原插入位置 → 精确实现 §5.3「覆盖原位替换,不挪到末尾」 +function resolveEvents(e, all) { + const own = e.events || []; + const ownIds = new Set(); + for (const ev of own) { + if (ownIds.has(ev.id)) throw new InheritError(E_INH_DUPID, `实体 ${e.id} 自身事件 id 重复: ${ev.id}`); + ownIds.add(ev.id); + } + let map; + if (!e.parent) { + map = new Map(own.map(ev => [ev.id, ev])); + } else { + map = resolveEvents(all[e.parent], all); + for (const ev of own) map.set(ev.id, ev); // 覆盖(原位)或新增 + for (const id of (e.disabled_events || [])) { + if (ownIds.has(id)) throw new InheritError(E_INH_CONFLICT, `实体 ${e.id}: 事件 ${id} 既覆盖又禁用`); + if (!map.has(id)) throw new InheritError(E_INH_NOSUCHEVENT, `实体 ${e.id} 禁用了不存在的继承事件: ${id}`); + map.delete(id); + } + } + return map; +} + +// §3.6 外观求解:沿链取第一个非 null 的 asset_ref +function resolveAsset(e, all) { + let cur = e; + while (cur) { + if (cur.asset_ref) return cur.asset_ref; + cur = cur.parent ? all[cur.parent] : null; + } + return null; +} + +// §3.1 求解入口:对全部实体先校验后求解,返回 id → 扁平化定义 +function resolveAll(list) { + const all = {}; + for (const e of list) all[e.id] = e; + const out = {}; + for (const e of list) { + validateChain(e, all); + out[e.id] = { + ...e, + asset_ref: resolveAsset(e, all), + properties: resolveProperties(e, all), + events: [...resolveEvents(e, all).values()], + }; + } + return out; +} + + return { + resolveAll, validateChain, resolveProperties, resolveEvents, resolveAsset, + InheritError, + E_INH_CYCLE, E_INH_DEPTH, E_INH_NOSUCHPARENT, E_INH_TYPE, E_INH_DUPID, E_INH_NOSUCHEVENT, E_INH_CONFLICT, + }; +})); diff --git a/bricks/scene3d.js b/bricks/scene3d.js index adebc43..7164c65 100644 --- a/bricks/scene3d.js +++ b/bricks/scene3d.js @@ -2,25 +2,36 @@ var bricks = window.bricks || {}; /* * scene3d.js — bricks3d 核心 widget:Scene3D * - * 后端声明式驱动:后端用 JSON 描述符声明场景(实体/变换/颜色/相机), - * Scene3D 用 three.js 本地渲染;点击实体 → dispatch('entity_tapped', {entity_id, entity_name}) - * → 走标准 binds 链(urlwidget/script)回调后端。 + * 三层职责: + * 1. 渲染:用 three.js 把后端下发的场景描述符(实体/变换/颜色/相机)渲染出来 + * 2. 输入上报:点击实体 → dispatch('entity_tapped', {entity_id, entity_name}) → 走 binds 回传后端 + * 3. 本地逻辑(独立世界):描述符若带 properties/events,则用 bricks.ScriptEngine.SceneRuntime + * 在浏览器里本地跑脚本引擎,每帧 step() 推进,把 transform/visible 状态回填 three.js + * + * 架构(独立世界 / 共享世界): + * - 独立世界:数据全下前端,本地跑引擎(本文件第 3 层职责) + * - 共享世界:后端权威世界副本,前端只渲染 + 上报输入,后端推送状态更新(本文件第 1/2 层职责) * * options(全部 get_ 前缀,避开 opts_set_style 的 option→实例拷贝冲突): * scene : 内联场景描述符对象 * scene_url : 后端数据端点(优先于 scene),返回场景描述符 * get_bgcolor : 背景色(默认 0x141420) * get_grid : 是否显示地面网格(默认 true) + * get_run_local : 是否本地跑脚本引擎(独立世界=true;共享世界/纯渲染=false,默认 false) + * get_three_url : three.js 的 URL(默认 /bricks/3parties/three.min.js,按需注入用) * - * 场景描述符格式(与《脚本引擎设计规范》实体子集对齐): + * 场景描述符格式(渲染子集 + 逻辑子集统一): * { * "camera": {"position": [x,y,z], "fov": 50}, * "entities": [ * {"id": "door", "name": "门", "type": "box", * "transform": {"pos": [x,y,z], "rot": [deg,deg,deg], "scale": [x,y,z]}, - * "appearance": {"color": "#3b82f6"}}, - * {"id": "ball", "type": "sphere", ...} - * ] + * "appearance": {"color": "#3b82f6"}, + * "properties": {"hasVisitor": {"type":"boolean","value":true}}, // 逻辑子集(可选) + * "events": [ ... ] // 逻辑子集(可选) + * } + * ], + * "events": [ ... ] // 场景级事件(可选) * } * * dispatch 的事件: @@ -28,11 +39,14 @@ var bricks = window.bricks || {}; * scene_ready {entity_count} * scene_error {message} * - * three.js 依赖:dist/3parties/three.min.js(r149 UMD 全局版)。 + * three.js 依赖:/bricks/3parties/three.min.js(r149 UMD 全局版),构造时按需注入。 * 活动对象(scene/camera/renderer/mesh)只存实例私有字段,绝不进 opts/描述符 * (防 JSON.stringify 循环引用 —— bricks 已知坑)。 */ +// 全局 three.js 加载 Promise(多实例共享,避免重复注入 script) +bricks._s3_three_promise = bricks._s3_three_promise || null; + bricks.Scene3D = class extends bricks.VBox { constructor(opts) { opts.width = opts.width || '100%'; @@ -41,6 +55,7 @@ bricks.Scene3D = class extends bricks.VBox { this.dom_element.style.display = 'flex'; this.dom_element.style.flexDirection = 'column'; this.dom_element.style.overflow = 'hidden'; + this.dom_element.style.touchAction = 'none'; // 移动端手势:禁用浏览器默认缩放/滚动 // three.js 活动对象 —— 私有字段,不进 opts this._s3_scene = null; this._s3_camera = null; @@ -50,15 +65,35 @@ bricks.Scene3D = class extends bricks.VBox { this._s3_raycaster = null; this._s3_pointer = null; this._s3_ready = false; + // 引擎(独立世界本地逻辑) + this._s3_runtime = null; // SceneRuntime 实例 + this._s3_last_t = 0; // 上一帧时间戳(step 用) + this._s3_paused = false; + // 相机控制状态 + this._s3_ctrl = { rotX: 0, rotY: 0, dist: 12, targetX: 0, targetY: 1, targetZ: 0, lastPX: 0, lastPY: 0, pinchDist: 0, mode: 'rotate' }; + this._s3_cam_pos = [8, 6, 10]; // 当前相机位置(环绕球坐标计算) schedule_once(this.s3_build.bind(this), 0.1); } + // ── three.js 按需注入 ── + s3_load_three() { + if (typeof THREE !== 'undefined') return Promise.resolve(THREE); + if (bricks._s3_three_promise) return bricks._s3_three_promise; + var url = this.opts.get_three_url || '/bricks/3parties/three.min.js'; + bricks._s3_three_promise = new Promise(function (resolve, reject) { + var s = document.createElement('script'); + s.src = url; + s.onload = function () { if (typeof THREE !== 'undefined') resolve(THREE); else reject(new Error('THREE 未定义')); }; + s.onerror = function () { bricks._s3_three_promise = null; reject(new Error('three.js 加载失败: ' + url)); }; + document.head.appendChild(s); + }); + return bricks._s3_three_promise; + } + async s3_build() { - if (typeof THREE === 'undefined') { - this.s3_show_error('three.js 未加载(需引入 /bricks/3parties/three.min.js)'); - this.dispatch('scene_error', {message: 'THREE undefined'}); - return; - } + try { await this.s3_load_three(); } + catch (e) { this.s3_show_error(e.message); this.dispatch('scene_error', { message: String(e) }); return; } + var desc = null; if (this.opts.scene_url) { try { @@ -66,7 +101,7 @@ bricks.Scene3D = class extends bricks.VBox { desc = await jc.get(this.opts.scene_url); } catch (e) { this.s3_show_error('场景数据加载失败: ' + this.opts.scene_url); - this.dispatch('scene_error', {message: String(e)}); + this.dispatch('scene_error', { message: String(e) }); return; } } else { @@ -79,14 +114,15 @@ bricks.Scene3D = class extends bricks.VBox { try { this.s3_init_gl(desc); this.s3_build_entities(desc.entities || []); - this.s3_bind_events(); + this.s3_bind_input(desc); + this.s3_start_runtime(desc); this.s3_loop(); this._s3_ready = true; - this.dispatch('scene_ready', {entity_count: (desc.entities || []).length}); + this.dispatch('scene_ready', { entity_count: (desc.entities || []).length }); } catch (e) { console.error('Scene3D build error', e); this.s3_show_error('场景构建失败: ' + e.message); - this.dispatch('scene_error', {message: String(e)}); + this.dispatch('scene_error', { message: String(e) }); } } @@ -101,11 +137,18 @@ bricks.Scene3D = class extends bricks.VBox { var cam = desc.camera || {}; var camera = new THREE.PerspectiveCamera(cam.fov || 50, w / h, 0.1, 1000); + this._s3_camera = camera; // 先赋值,s3_apply_camera 依赖它 var p = cam.position || [8, 6, 10]; - camera.position.set(p[0], p[1], p[2]); - camera.lookAt(0, 1, 0); + this._s3_cam_pos = p.slice(); + // 初始化环绕球参数(由初始相机位置反推) + var dx = p[0], dy = p[1] - 1, dz = p[2]; // 目标点默认 [0,1,0] + var dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + this._s3_ctrl.dist = dist || 12; + this._s3_ctrl.rotY = Math.atan2(dx, dz); // 方位角(绕 Y) + this._s3_ctrl.rotX = Math.asin(Math.max(-1, Math.min(1, dy / (dist || 1)))); // 俯仰角 + this.s3_apply_camera(); - var renderer = new THREE.WebGLRenderer({antialias: true}); + var renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(w, h); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); el.appendChild(renderer.domElement); @@ -130,10 +173,121 @@ bricks.Scene3D = class extends bricks.VBox { this._s3_raycaster = new THREE.Raycaster(); this._s3_pointer = new THREE.Vector2(); - // 容器尺寸变化 → 跟随(bricks.resize_observer 会向本元素派发 resize 事件) this.bind('resize', this.s3_on_resize.bind(this)); } + // ── 环绕相机:单指旋转 / 双指缩放 / 双指平移 ── + s3_apply_camera() { + var c = this._s3_ctrl; + var cy = Math.cos(c.rotX), sy = Math.sin(c.rotX); + var cx = Math.cos(c.rotY), sx = Math.sin(c.rotY); + var x = c.targetX + c.dist * cy * sx; + var y = c.targetY + c.dist * sy; + var z = c.targetZ + c.dist * cy * cx; + this._s3_camera.position.set(x, y, z); + this._s3_camera.lookAt(c.targetX, c.targetY, c.targetZ); + this._s3_cam_pos = [x, y, z]; + } + + s3_bind_input(desc) { + var self = this; + var cv = this._s3_renderer.domElement; + this._s3_pointers = {}; // pointerId → {x, y} + var downX = 0, downY = 0; // 单指按下位置(点击判定用) + var downCount = 0; + + cv.addEventListener('pointerdown', function (ev) { + cv.setPointerCapture && cv.setPointerCapture(ev.pointerId); + self._s3_pointers[ev.pointerId] = { x: ev.clientX, y: ev.clientY }; + var n = Object.keys(self._s3_pointers).length; + if (n === 1) { + downX = ev.clientX; downY = ev.clientY; downCount = 1; + self._s3_ctrl.pinchDist = 0; + self._s3_ctrl.pinchMid = null; + } else if (n === 2) { + // 进入双指:记录初始距离与中点 + var ids = Object.keys(self._s3_pointers); + var p1 = self._s3_pointers[ids[0]], p2 = self._s3_pointers[ids[1]]; + self._s3_ctrl.pinchDist = Math.hypot(p1.x - p2.x, p1.y - p2.y); + self._s3_ctrl.pinchMid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; + } + }); + + cv.addEventListener('pointermove', function (ev) { + if (!(ev.pointerId in self._s3_pointers)) return; + self._s3_pointers[ev.pointerId] = { x: ev.clientX, y: ev.clientY }; + var ids = Object.keys(self._s3_pointers); + if (ids.length === 2) { + var p1 = self._s3_pointers[ids[0]], p2 = self._s3_pointers[ids[1]]; + var d = Math.hypot(p1.x - p2.x, p1.y - p2.y); + if (self._s3_ctrl.pinchDist > 0) { + var ratio = self._s3_ctrl.pinchDist / d; + self._s3_ctrl.dist = Math.max(1.5, Math.min(80, self._s3_ctrl.dist * ratio)); + } + self._s3_ctrl.pinchDist = d; + var mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + if (self._s3_ctrl.pinchMid) { + self._s3_pan(mx - self._s3_ctrl.pinchMid.x, my - self._s3_ctrl.pinchMid.y); + } + self._s3_ctrl.pinchMid = { x: mx, y: my }; + } else if (ids.length === 1) { + var dx = ev.clientX - self._s3_ctrl.lastPX; + var dy = ev.clientY - self._s3_ctrl.lastPY; + self._s3_ctrl.rotY -= dx * 0.005; + self._s3_ctrl.rotX += dy * 0.005; + self._s3_ctrl.rotX = Math.max(-1.4, Math.min(1.4, self._s3_ctrl.rotX)); + self.s3_apply_camera(); + } + self._s3_ctrl.lastPX = ev.clientX; + self._s3_ctrl.lastPY = ev.clientY; + }); + + cv.addEventListener('pointerup', function (ev) { + delete self._s3_pointers[ev.pointerId]; + self._s3_ctrl.lastPX = ev.clientX; + self._s3_ctrl.lastPY = ev.clientY; + self._s3_ctrl.pinchDist = 0; + self._s3_ctrl.pinchMid = null; + downCount = 0; + }); + cv.addEventListener('pointercancel', function (ev) { + delete self._s3_pointers[ev.pointerId]; + self._s3_ctrl.pinchDist = 0; + self._s3_ctrl.pinchMid = null; + }); + + // 点击拾取(与拖拽区分:位移小于阈值才算点击) + cv.addEventListener('click', function (ev) { + if (Math.abs(ev.clientX - downX) > 6 || Math.abs(ev.clientY - downY) > 6) return; // 拖拽不算点击 + if (!self._s3_ready) return; + var rect = cv.getBoundingClientRect(); + self._s3_pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; + self._s3_pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; + self._s3_raycaster.setFromCamera(self._s3_pointer, self._s3_camera); + var hits = self._s3_raycaster.intersectObjects(Object.values(self._s3_meshes), false); + if (hits.length > 0) { + var ud = hits[0].object.userData; + if (self._s3_runtime) self._s3_runtime.tap(ud.entity_id); // 独立世界:注入引擎 + self.dispatch('entity_tapped', { entity_id: ud.entity_id, entity_name: ud.entity_name }); + } + }); + } + + s3_pan(dxpx, dypx) { + var c = this._s3_ctrl; + var scale = c.dist * 0.0012; // 平移速度随距离 + var sy = Math.sin(c.rotX), cy = Math.cos(c.rotX); + var sx = Math.sin(c.rotY), cx = Math.cos(c.rotY); + // 相机右向量(水平,绕 Y) + var rx = cx, rz = -sx; + // 相机上向量(简化) + var upx = -sy * sx, upy = cy, upz = -sy * cx; + c.targetX += (-rx * dxpx - upx * dypx) * scale; + c.targetY += (-upy * dypx) * scale; + c.targetZ += (-rz * dxpx - upz * dypx) * scale; + this.s3_apply_camera(); + } + s3_build_entities(list) { for (var i = 0; i < list.length; i++) { var e = list[i]; @@ -164,14 +318,14 @@ bricks.Scene3D = class extends bricks.VBox { case 'cylinder': geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 24); break; - case 'model': // glTF 占位:初版用盒子代替,GLTFLoader 为下一步 + case 'model': // glTF 占位:初版用盒子代替,GLTFLoader 为后续 geo = new THREE.BoxGeometry(1, 1, 1); break; default: console.warn('Scene3D: 未知实体类型', e.type, e.id); return null; } - var mat = new THREE.MeshStandardMaterial({color: new THREE.Color(color)}); + var mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(color) }); var mesh = new THREE.Mesh(geo, mat); mesh.position.set(pos[0], pos[1], pos[2]); mesh.rotation.set( @@ -183,28 +337,55 @@ bricks.Scene3D = class extends bricks.VBox { return mesh; } - s3_bind_events() { - var self = this; - var cv = this._s3_renderer.domElement; - cv.style.cursor = 'pointer'; - cv.addEventListener('click', function(ev) { - if (!self._s3_ready) return; - var rect = cv.getBoundingClientRect(); - self._s3_pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; - self._s3_pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; - self._s3_raycaster.setFromCamera(self._s3_pointer, self._s3_camera); - var hits = self._s3_raycaster.intersectObjects(Object.values(self._s3_meshes), false); - if (hits.length > 0) { - var ud = hits[0].object.userData; - self.dispatch('entity_tapped', {entity_id: ud.entity_id, entity_name: ud.entity_name}); - } - }); + // ── 本地逻辑引擎(独立世界)── + s3_start_runtime(desc) { + if (!this.opts.get_run_local) return; + if (!bricks.ScriptEngine || !bricks.ScriptEngine.SceneRuntime) { + this.s3_show_error('脚本引擎未加载(bricks.ScriptEngine 缺失)'); + return; + } + var hasLogic = (desc.events && desc.events.length) || + (desc.entities || []).some(function (e) { return (e.events && e.events.length) || (e.properties && Object.keys(e.properties).length); }); + if (!hasLogic) return; // 纯渲染,不启动引擎 + try { + this._s3_runtime = new bricks.ScriptEngine.SceneRuntime(desc, { frameDT: 0.05, budget: 100000 }); + this._s3_last_t = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + } catch (e) { + console.error('Scene3D 引擎启动失败', e); + this.dispatch('scene_error', { message: '引擎启动失败: ' + e.message }); + } + } + + s3_sync_from_runtime() { + if (!this._s3_runtime) return; + var ents = this._s3_runtime.entities; + for (var id in this._s3_meshes) { + var mesh = this._s3_meshes[id]; + var re = ents[id]; + if (!re) continue; + var tr = re.transform; + mesh.position.set(tr.pos[0], tr.pos[1], tr.pos[2]); + mesh.rotation.set( + THREE.MathUtils.degToRad(tr.rot[0]), + THREE.MathUtils.degToRad(tr.rot[1]), + THREE.MathUtils.degToRad(tr.rot[2]) + ); + mesh.scale.set(tr.scale[0], tr.scale[1], tr.scale[2]); + mesh.visible = re.visible; + } } s3_loop() { var self = this; function tick() { self._s3_raf = requestAnimationFrame(tick); + if (self._s3_runtime && !self._s3_paused) { + var now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + var dt = (now - self._s3_last_t) / 1000; + self._s3_last_t = now; + if (dt > 0 && dt < 0.5) self._s3_runtime.step(dt); + self.s3_sync_from_runtime(); + } if (self._s3_renderer && self._s3_scene && self._s3_camera) { self._s3_renderer.render(self._s3_scene, self._s3_camera); } @@ -221,7 +402,7 @@ bricks.Scene3D = class extends bricks.VBox { this._s3_renderer.setSize(w, h); } - // 公共 API:设置实体颜色(后端描述符更新或前端联动均可调用) + // 公共 API:设置实体颜色(后端状态更新或前端联动均可调用) set_entity_color(entity_id, color) { var m = this._s3_meshes[entity_id]; if (m && m.material) m.material.color.set(color); @@ -233,8 +414,29 @@ bricks.Scene3D = class extends bricks.VBox { if (m) m.visible = !!visible; } + // 公共 API:设置实体变换(共享世界后端推送状态用) + set_entity_transform(entity_id, transform) { + var m = this._s3_meshes[entity_id]; + if (!m || !transform) return; + var pos = transform.pos, rot = transform.rot, scl = transform.scale; + if (pos) m.position.set(pos[0], pos[1], pos[2]); + if (rot) m.rotation.set(THREE.MathUtils.degToRad(rot[0]), THREE.MathUtils.degToRad(rot[1]), THREE.MathUtils.degToRad(rot[2])); + if (scl) m.scale.set(scl[0], scl[1], scl[2]); + } + + // 公共 API:暂停/继续本地引擎 + set_paused(paused) { this._s3_paused = !!paused; } + + // 公共 API:重置本地引擎(重新加载描述符逻辑) + reset_runtime(desc) { + if (this._s3_runtime && desc) { + this._s3_runtime = new bricks.ScriptEngine.SceneRuntime(desc, { frameDT: 0.05, budget: 100000 }); + this._s3_last_t = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + } + } + s3_show_error(msg) { - var t = new bricks.Text({text: '⚠ ' + msg, color: '#f87171', padding: '12px'}); + var t = new bricks.Text({ text: '⚠ ' + msg, color: '#f87171', padding: '12px' }); this.add_widget(t); } @@ -249,6 +451,7 @@ bricks.Scene3D = class extends bricks.VBox { if (m.material) m.material.dispose(); } this._s3_meshes = {}; + this._s3_runtime = null; if (this._s3_renderer) { this._s3_renderer.dispose(); if (this._s3_renderer.domElement && this._s3_renderer.domElement.parentNode) {