509 lines
18 KiB
JavaScript
509 lines
18 KiB
JavaScript
/*!
|
||
* runtime.js — 元景 W-10 运行时执行引擎(前端核心,v1)
|
||
*
|
||
* 能力覆盖(W-10a ~ W-10i):
|
||
* W-10a 世界运行时初始化:构建运行态实体树(父子树 + 扁平索引)
|
||
* W-10b 实体运行时状态管理:state 机(idle/active/done/failed…)+ 位置/速度
|
||
* W-10c 脚本运行时执行调度:与 script_engine 编译产物(actions 数组)对接
|
||
* W-10d 事件分发:事件队列 -> 路由到对应实体绑定的脚本
|
||
* W-10e 定时器管理:addTimer / 按间隔触发(不依赖 setInterval,tick 驱动)
|
||
* W-10f 碰撞检测:AABB 两两检测 -> 触发 onCollide 脚本/事件
|
||
* W-10g 运行时暂停/恢复:pause() / resume(),事件循环挂起
|
||
* W-10h 错误处理与降级:try/catch 捕获,单脚本失败不整页崩溃
|
||
* W-10i 独立世界本地引擎:后端不可用时本地构建世界继续运行
|
||
*
|
||
* 对接:script_engine 编译产物格式
|
||
* { id, name, actions: [ { op:'move'|'set_state'|'emit'|'delay'|'log'|'fail', ... } ] }
|
||
*
|
||
* 用法:
|
||
* var rt = new RuntimeEngine({ worldId: 'w1', canvas: canvasEl });
|
||
* rt.initWorld(serverData); // serverData 可来自 /scense_runtime/api/load_world.dspy
|
||
* rt.start();
|
||
* rt.on('log', function(m){ ... }); // UI 订阅日志
|
||
*/
|
||
(function (global) {
|
||
'use strict';
|
||
|
||
var ENGINE_VERSION = '1.0.0';
|
||
|
||
// ================= 工具 =================
|
||
function now() { return Date.now(); }
|
||
|
||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||
|
||
// ================= 实体(W-10b) =================
|
||
function makeEntity(node) {
|
||
var props = node.props || {};
|
||
return {
|
||
id: String(node.id),
|
||
name: String(node.name || node.id),
|
||
type: String(node.type || 'object'),
|
||
state: String(node.state || 'idle'),
|
||
x: Number(node.x) || 0,
|
||
y: Number(node.y) || 0,
|
||
vx: 0,
|
||
vy: 0,
|
||
width: Number(props.width) || 40,
|
||
height: Number(props.height) || 40,
|
||
props: Object.assign({}, props),
|
||
children: [],
|
||
scripts: Array.isArray(props.scripts) ? props.scripts.slice() : [],
|
||
onCollide: props.onCollide || null,
|
||
timers: []
|
||
};
|
||
}
|
||
|
||
function flattenTree(roots, out) {
|
||
out = out || [];
|
||
for (var i = 0; i < roots.length; i++) {
|
||
out.push(roots[i]);
|
||
if (roots[i].children && roots[i].children.length) {
|
||
flattenTree(roots[i].children, out);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ================= 脚本执行(W-10c,与 script_engine 编译产物对接) =================
|
||
function executeScript(script, ctx, rt) {
|
||
if (!script || typeof script !== 'object') {
|
||
throw new Error('runtime: invalid script object (compile artifact missing)');
|
||
}
|
||
if (!Array.isArray(script.actions) || script.actions.length === 0) {
|
||
throw new Error('runtime: script has no actions: ' + (script.id || '?'));
|
||
}
|
||
var results = [];
|
||
for (var i = 0; i < script.actions.length; i++) {
|
||
var act = script.actions[i];
|
||
if (!act || typeof act.op !== 'string') {
|
||
throw new Error('runtime: action missing op at index ' + i);
|
||
}
|
||
results.push(executeAction(act, ctx, rt, script));
|
||
}
|
||
return { ok: true, script: script.id || '?', results: results };
|
||
}
|
||
|
||
function executeAction(act, ctx, rt, script) {
|
||
var e = ctx.entity;
|
||
switch (act.op) {
|
||
case 'move': {
|
||
var dx = Number(act.dx) || 0;
|
||
var dy = Number(act.dy) || 0;
|
||
e.vx = dx;
|
||
e.vy = dy;
|
||
e.x += dx;
|
||
e.y += dy;
|
||
return { op: 'move', dx: dx, dy: dy, x: e.x, y: e.y };
|
||
}
|
||
case 'set_state': {
|
||
e.state = String(act.state || 'idle');
|
||
return { op: 'set_state', state: e.state };
|
||
}
|
||
case 'emit': {
|
||
rt.dispatchEvent(String(act.event || 'custom'), act.entity_id || e.id, act.payload || {});
|
||
return { op: 'emit', event: act.event || 'custom' };
|
||
}
|
||
case 'delay': {
|
||
var ms = Number(act.ms) || 0;
|
||
rt.addTimer(ms, function () {
|
||
rt.dispatchEvent('timer', e.id, { script: script ? script.id : '' });
|
||
});
|
||
return { op: 'delay', ms: ms };
|
||
}
|
||
case 'log': {
|
||
rt.log('[' + e.id + '] ' + (act.message || ''));
|
||
return { op: 'log', message: act.message || '' };
|
||
}
|
||
case 'fail': {
|
||
// 用于验证 W-10h 错误降级:主动抛错,由 handleError 捕获,不整页崩溃
|
||
throw new Error('runtime: script failure: ' + (act.message || 'boom'));
|
||
}
|
||
default:
|
||
throw new Error('runtime: unknown action op: ' + act.op);
|
||
}
|
||
}
|
||
|
||
// ================= 运行时引擎 =================
|
||
function RuntimeEngine(opts) {
|
||
opts = opts || {};
|
||
this.worldId = opts.worldId || 'local';
|
||
this.canvas = opts.canvas || null;
|
||
this.tickMs = Number(opts.tickMs) || 16;
|
||
|
||
this.entities = {}; // id -> entity(扁平索引)
|
||
this.roots = []; // 实体树根
|
||
this.entityList = []; // 扁平列表(碰撞/渲染遍历)
|
||
this.eventQueue = []; // 事件队列(W-10d)
|
||
this.timers = []; // 定时器列表(W-10e)
|
||
this.scriptStore = {}; // scriptId -> script(脚本注册表)
|
||
|
||
this.running = false;
|
||
this.paused = false;
|
||
this.mode = 'independent'; // 'server' | 'independent'(W-10i)
|
||
this.degraded = false;
|
||
this.errorCount = 0;
|
||
this.lastErrors = [];
|
||
this.frame = 0;
|
||
this._loopId = 0;
|
||
this._last = 0;
|
||
this._listeners = {};
|
||
this.world = null;
|
||
}
|
||
|
||
// ---------- W-10a 世界运行时初始化 ----------
|
||
RuntimeEngine.prototype.initWorld = function (serverData) {
|
||
serverData = serverData || {};
|
||
this.reset();
|
||
if (serverData.ok && serverData.degraded !== true && Array.isArray(serverData.entities)) {
|
||
// 服务端辅助可用:用服务端世界 + 实体
|
||
this.mode = 'server';
|
||
this.world = serverData.world || { id: this.worldId, name: this.worldId };
|
||
this.buildTree(serverData.entities);
|
||
this.log('世界初始化完成(server 模式):' + (this.world.name || this.worldId)
|
||
+ ',实体 ' + this.entityList.length + ' 个');
|
||
} else {
|
||
// W-10i 独立模式:后端不可用/查无数据 -> 本地构建
|
||
this.mode = 'independent';
|
||
this.degraded = true;
|
||
this.world = { id: this.worldId, name: '独立世界', mode: 'independent' };
|
||
this.buildTree(this._localWorldEntities());
|
||
this.log('后端不可用,已降级独立模式(W-10i):本地构建 '
|
||
+ this.entityList.length + ' 个实体');
|
||
}
|
||
this._loadSampleScripts();
|
||
this.emit('world', { mode: this.mode, world: this.world, entities: this.entityList });
|
||
return this.getStatus();
|
||
};
|
||
|
||
RuntimeEngine.prototype._localWorldEntities = function () {
|
||
// 独立模式内置演示世界(W-10i)
|
||
return [
|
||
{ id: 'hero', name: '主角', type: 'character', x: 60, y: 120,
|
||
props: { width: 44, height: 44, scripts: ['script_click', 'script_move'] } },
|
||
{ id: 'box1', name: '箱子A', type: 'prop', x: 260, y: 120,
|
||
props: { width: 40, height: 40, scripts: ['script_timer'] } },
|
||
{ id: 'box2', name: '箱子B', type: 'prop', x: 460, y: 120,
|
||
props: { width: 40, height: 40, scripts: ['script_fail'] } },
|
||
{ id: 'npc1', name: 'NPC-1', type: 'npc', x: 160, y: 300,
|
||
props: { width: 40, height: 40, scripts: ['script_click'] } },
|
||
{ id: 'npc2', name: 'NPC-2', type: 'npc', x: 360, y: 300,
|
||
props: { width: 40, height: 40, scripts: ['script_move'] } }
|
||
];
|
||
};
|
||
|
||
RuntimeEngine.prototype.buildTree = function (entities) {
|
||
// 扁平实体列表 -> 父子树 + 扁平索引(服务端 build_entity_tree 的 JS 镜像)
|
||
var byId = {};
|
||
var parentMap = {};
|
||
var i, eid;
|
||
for (i = 0; i < entities.length; i++) {
|
||
eid = String(entities[i].id || entities[i].entity_id || '');
|
||
if (!eid) { continue; }
|
||
var ent = makeEntity(entities[i]);
|
||
byId[eid] = ent;
|
||
parentMap[eid] = entities[i].parent_id || entities[i].parent || null;
|
||
}
|
||
var roots = [];
|
||
for (eid in byId) {
|
||
if (!Object.prototype.hasOwnProperty.call(byId, eid)) { continue; }
|
||
var pid = parentMap[eid];
|
||
if (pid && byId[pid]) {
|
||
byId[pid].children.push(byId[eid]);
|
||
} else {
|
||
roots.push(byId[eid]);
|
||
}
|
||
}
|
||
this.roots = roots;
|
||
this.entityList = flattenTree(roots);
|
||
var self = this;
|
||
this.entityList.forEach(function (e) { self.entities[e.id] = e; });
|
||
};
|
||
|
||
RuntimeEngine.prototype._loadSampleScripts = function () {
|
||
// 示例脚本(与 script_engine 编译产物 actions 格式一致)
|
||
this.scriptStore['script_move'] = {
|
||
id: 'script_move', name: '移动',
|
||
actions: [{ op: 'log', message: '执行 move 脚本' }, { op: 'move', dx: 12, dy: 0 }]
|
||
};
|
||
this.scriptStore['script_click'] = {
|
||
id: 'script_click', name: '点击响应',
|
||
actions: [
|
||
{ op: 'log', message: '收到 click 事件,执行点击脚本' },
|
||
{ op: 'set_state', state: 'active' },
|
||
{ op: 'emit', event: 'active_changed', payload: { source: 'click' } }
|
||
]
|
||
};
|
||
this.scriptStore['script_timer'] = {
|
||
id: 'script_timer', name: '定时器',
|
||
actions: [{ op: 'log', message: '定时器触发(每 3s)' }, { op: 'move', dx: 0, dy: 10 }]
|
||
};
|
||
this.scriptStore['script_fail'] = {
|
||
id: 'script_fail', name: '错误演示',
|
||
actions: [{ op: 'log', message: '即将故意抛错以验证降级' }, { op: 'fail', message: '演示脚本异常' }]
|
||
};
|
||
};
|
||
|
||
RuntimeEngine.prototype.bindScript = function (entityId, script) {
|
||
if (script && script.id) { this.scriptStore[script.id] = script; }
|
||
var e = this.entities[entityId];
|
||
if (!e) { throw new Error('runtime: entity not found: ' + entityId); }
|
||
e.scripts.push(script && script.id ? script.id : '');
|
||
return true;
|
||
};
|
||
|
||
// ---------- W-10g 启动 / 暂停 / 恢复 ----------
|
||
RuntimeEngine.prototype.start = function () {
|
||
if (this.running && !this.paused) { return this.getStatus(); }
|
||
this.running = true;
|
||
this.paused = false;
|
||
var self = this;
|
||
this._last = now();
|
||
this._loopId = global.setTimeout(function loop() {
|
||
if (!self.running) { return; }
|
||
var ts = now();
|
||
self.tick(ts);
|
||
self._loopId = global.setTimeout(loop, self.tickMs);
|
||
}, this.tickMs);
|
||
this.log('运行时已启动(tick=' + this.tickMs + 'ms)');
|
||
this.emit('status', this.getStatus());
|
||
return this.getStatus();
|
||
};
|
||
|
||
RuntimeEngine.prototype.pause = function () {
|
||
this.paused = true;
|
||
this.log('运行时已暂停(W-10g)');
|
||
this.emit('status', this.getStatus());
|
||
return this.getStatus();
|
||
};
|
||
|
||
RuntimeEngine.prototype.resume = function () {
|
||
if (!this.running) { return this.start(); }
|
||
this.paused = false;
|
||
this._last = now();
|
||
this.log('运行时已恢复(W-10g)');
|
||
this.emit('status', this.getStatus());
|
||
return this.getStatus();
|
||
};
|
||
|
||
RuntimeEngine.prototype.stop = function () {
|
||
this.running = false;
|
||
if (this._loopId) { global.clearTimeout(this._loopId); this._loopId = 0; }
|
||
this.log('运行时已停止');
|
||
};
|
||
|
||
RuntimeEngine.prototype.reset = function () {
|
||
this.stop();
|
||
this.entities = {};
|
||
this.roots = [];
|
||
this.entityList = [];
|
||
this.eventQueue = [];
|
||
this.timers = [];
|
||
this.errorCount = 0;
|
||
this.lastErrors = [];
|
||
this.frame = 0;
|
||
};
|
||
|
||
// ---------- 事件循环主 tick ----------
|
||
RuntimeEngine.prototype.tick = function (ts) {
|
||
if (this.paused || !this.running) { return; }
|
||
var dt = Math.min(ts - this._last, 100) / 1000;
|
||
this._last = ts;
|
||
this.frame++;
|
||
try {
|
||
this.update(dt); // 实体状态更新(W-10b)
|
||
this.processEvents(); // 事件分发(W-10d)
|
||
this.processTimers(); // 定时器(W-10e)
|
||
this.checkCollisions(); // 碰撞检测(W-10f)
|
||
} catch (err) {
|
||
this.handleError(err); // W-10h 捕获,不整页崩溃
|
||
}
|
||
this.emit('frame', { frame: this.frame, dt: dt });
|
||
};
|
||
|
||
RuntimeEngine.prototype.update = function (dt) {
|
||
var i;
|
||
for (i = 0; i < this.entityList.length; i++) {
|
||
var e = this.entityList[i];
|
||
e.x += e.vx * dt * 60;
|
||
e.y += e.vy * dt * 60;
|
||
// 边界约束
|
||
e.x = clamp(e.x, 10, 720);
|
||
e.y = clamp(e.y, 10, 460);
|
||
// vx/vy 衰减(移动脚本一次性位移后归零)
|
||
if (Math.abs(e.vx) < 0.1) { e.vx = 0; }
|
||
if (Math.abs(e.vy) < 0.1) { e.vy = 0; }
|
||
}
|
||
};
|
||
|
||
// ---------- W-10d 事件分发 ----------
|
||
RuntimeEngine.prototype.dispatchEvent = function (type, entityId, payload) {
|
||
this.eventQueue.push({
|
||
type: String(type || 'custom'),
|
||
entityId: entityId ? String(entityId) : '',
|
||
payload: payload || {},
|
||
ts: now()
|
||
});
|
||
if (this.eventQueue.length > 500) { this.eventQueue.shift(); }
|
||
return this.eventQueue.length;
|
||
};
|
||
|
||
RuntimeEngine.prototype.processEvents = function () {
|
||
while (this.eventQueue.length) {
|
||
var ev = this.eventQueue.shift();
|
||
this._routeEvent(ev);
|
||
}
|
||
};
|
||
|
||
RuntimeEngine.prototype._routeEvent = function (ev) {
|
||
// 路由到对应实体绑定的脚本(ev.entityId -> entity.scripts -> scriptStore)
|
||
var e = ev.entityId ? this.entities[ev.entityId] : null;
|
||
if (!e) {
|
||
this.log('事件 ' + ev.type + ' 无目标实体,忽略');
|
||
return;
|
||
}
|
||
if (!e.scripts || e.scripts.length === 0) {
|
||
this.log('实体 ' + e.id + ' 未绑定脚本,事件 ' + ev.type + ' 忽略');
|
||
return;
|
||
}
|
||
var self = this;
|
||
var matched = false;
|
||
e.scripts.forEach(function (sid) {
|
||
var script = self.scriptStore[sid];
|
||
if (!script) { return; }
|
||
matched = true;
|
||
self.log('路由事件 ' + ev.type + ' -> 实体 ' + e.id + ' 脚本 ' + sid);
|
||
try {
|
||
executeScript(script, { entity: e, event: ev }, self);
|
||
if (e.state !== 'failed') { e.state = 'done'; }
|
||
} catch (err) {
|
||
e.state = 'failed';
|
||
self.handleError(err);
|
||
}
|
||
});
|
||
if (!matched) {
|
||
this.log('实体 ' + e.id + ' 的脚本未注册,事件 ' + ev.type + ' 忽略');
|
||
}
|
||
};
|
||
|
||
// ---------- W-10e 定时器管理 ----------
|
||
RuntimeEngine.prototype.addTimer = function (ms, fn) {
|
||
if (typeof fn !== 'function') { return; }
|
||
this.timers.push({ due: now() + Math.max(ms, 0), fn: fn, fired: false });
|
||
return this.timers.length;
|
||
};
|
||
|
||
RuntimeEngine.prototype.processTimers = function () {
|
||
var t = now();
|
||
var pending = [];
|
||
var i;
|
||
for (i = 0; i < this.timers.length; i++) {
|
||
var tm = this.timers[i];
|
||
if (tm.due <= t) {
|
||
try {
|
||
tm.fn();
|
||
} catch (err) {
|
||
this.handleError(err);
|
||
}
|
||
} else {
|
||
pending.push(tm);
|
||
}
|
||
}
|
||
this.timers = pending;
|
||
};
|
||
|
||
// ---------- W-10f 碰撞检测(AABB) ----------
|
||
RuntimeEngine.prototype.checkCollisions = function () {
|
||
var list = this.entityList;
|
||
var i, j;
|
||
for (i = 0; i < list.length; i++) {
|
||
for (j = i + 1; j < list.length; j++) {
|
||
var a = list[i], b = list[j];
|
||
if (aabbHit(a, b)) {
|
||
this.log('碰撞:' + a.id + ' <-> ' + b.id);
|
||
this._fireCollide(a, b);
|
||
this._fireCollide(b, a);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
function aabbHit(a, b) {
|
||
return a.x < b.x + b.width && a.x + a.width > b.x &&
|
||
a.y < b.y + b.height && a.y + a.height > b.y;
|
||
}
|
||
|
||
RuntimeEngine.prototype._fireCollide = function (self, other) {
|
||
if (typeof self.onCollide === 'string' && this.scriptStore[self.onCollide]) {
|
||
try {
|
||
executeScript(this.scriptStore[self.onCollide],
|
||
{ entity: self, event: { type: 'collide', other: other.id } }, this);
|
||
} catch (err) {
|
||
this.handleError(err);
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---------- W-10h 错误处理与降级 ----------
|
||
RuntimeEngine.prototype.handleError = function (err) {
|
||
this.errorCount++;
|
||
var msg = (err && err.message) ? err.message : String(err);
|
||
this.lastErrors.push(msg);
|
||
if (this.lastErrors.length > 50) { this.lastErrors.shift(); }
|
||
this.log('[错误] ' + msg + '(已降级,不影响引擎运行,累计 ' + this.errorCount + ' 次)');
|
||
this.emit('error', { message: msg, count: this.errorCount });
|
||
};
|
||
|
||
RuntimeEngine.prototype.log = function (msg) {
|
||
this.emit('log', { ts: new Date().toISOString(), message: msg });
|
||
};
|
||
|
||
// ---------- UI 事件订阅 ----------
|
||
RuntimeEngine.prototype.on = function (name, fn) {
|
||
if (!this._listeners[name]) { this._listeners[name] = []; }
|
||
this._listeners[name].push(fn);
|
||
return this;
|
||
};
|
||
|
||
RuntimeEngine.prototype.emit = function (name, data) {
|
||
var fns = this._listeners[name];
|
||
if (!fns) { return; }
|
||
for (var i = 0; i < fns.length; i++) {
|
||
try { fns[i](data); } catch (err) { /* 订阅者异常不影响引擎 */ }
|
||
}
|
||
};
|
||
|
||
// ---------- 状态查询 ----------
|
||
RuntimeEngine.prototype.getStatus = function () {
|
||
return {
|
||
engine: 'runtime-js-v' + ENGINE_VERSION,
|
||
worldId: this.worldId,
|
||
mode: this.mode,
|
||
degraded: this.degraded,
|
||
running: this.running,
|
||
paused: this.paused,
|
||
frame: this.frame,
|
||
entities: this.entityList.length,
|
||
eventQueue: this.eventQueue.length,
|
||
timers: this.timers.length,
|
||
errorCount: this.errorCount
|
||
};
|
||
};
|
||
|
||
RuntimeEngine.prototype.getEntity = function (id) {
|
||
return this.entities[id] || null;
|
||
};
|
||
|
||
RuntimeEngine.prototype.getEntities = function () {
|
||
return this.entityList;
|
||
};
|
||
|
||
// 点击实体 -> 触发事件(W-10d 入口:UI 点击 -> dispatchEvent('click'))
|
||
RuntimeEngine.prototype.trigger = function (entityId, eventType) {
|
||
return this.dispatchEvent(eventType || 'click', entityId, { source: 'ui' });
|
||
};
|
||
|
||
// ================= 导出 =================
|
||
global.RuntimeEngine = RuntimeEngine;
|
||
global.RUNTIME_ENGINE_VERSION = ENGINE_VERSION;
|
||
})(window);
|