feat(scene3d): Scene3D 接入脚本引擎 + 按需注入 three.js + 自写环绕相机
- 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+属性变化)、相机旋转全链路
This commit is contained in:
parent
d945e414d7
commit
406ed6f17a
@ -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
|
||||
|
||||
545
bricks/engine.js
Normal file
545
bricks/engine.js
Normal file
@ -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: '<reload>' };
|
||||
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 };
|
||||
}));
|
||||
134
bricks/inherit.js
Normal file
134
bricks/inherit.js
Normal file
@ -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,
|
||||
};
|
||||
}));
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user