feat(game-runtime): U1 运行时约定基座 createRuntime/rt(2D 适配器 behavior 侧 API)
plan 2026-06-18-001 U1 · 引擎线 · 真结构化 gameDefinition 产线化地基。
新增 game-runtime/src/host/gd-runtime.js:
- createRuntime(boot, gameDefinition) → 建可变世界(entities 实例)+ 产 rt 对象;
rt 即 U2 装配器编译每 behavior 时 new Function('rt','self','dt',code) 注入的那个 rt。
- rt 面(落 plan KTD2):实体 getEntity/entities/query/spawn/destroy(.x.y.vx.vy.alive
.components.tags+.get/set/destroy);输入轮询 input.isDown/justPressed/justTapped/pointer
(包 ctx.getInput 受控事件);时间随机 time.now(相对 elapsed,非绝对钟)/dt/random/randRange
/randInt(走 boot.ctx 受控种子,禁 Math.random/Date.now);分数胜负 score/addScore/setScore
/win()/lose()(置 latch 终态 gameover,不可逆=H门);工具特效 clamp/dist/overlap(AABB)+
fx.burst/beep(经 ctx.getEngine 真调引擎粒子/合成核=满F门,无引擎优雅 no-op)。
- behaviors 各带逻辑 JS 经 new Function 编译(init 跑一次/其余每帧),抛错隔离+回灌质量信号;
rules condition 编 JS 布尔(win/lose latch、score/advance 上升沿);物理组件 opt-in 积分;
声明式渲染器逐实体按 render 组件画 rect/circle/fill(behaviors 只逻辑不画)+极简 HUD。
受控面铁律:经 boot.ctx(PluginContext)注入,不触裸引擎/DOM;缺 ctx fail-loud。
迭代状态 v0(plan「别冻早」):契约 runtime-api-2d.d.ts 待收 3-5 类真实模型输出补 idiom 再正式化。
验收(lili-mac 快走查):node --test src/host/gd-runtime.test.mjs 10/10 过——
三条必验(rt.random 同种子可复现/win latch 驻留不可逆/fx.burst 真触发 ctx.getEngine)
+ 集成(behavior 编译跑通/rule latch/spawn-destroy-回收/输入轮询/物理积分/渲染出图)+缺 ctx 守卫。
全 runtime 套件 41/41 无回归。测试文件就近 src/host/(贴 npm test 的 src/**/*.test.mjs 约定,
非 plan 所写 test/unit/——遵仓内既有 co-located 约定)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5bff11dfaf
commit
000f87d98b
352
game-runtime/src/host/gd-runtime.js
Normal file
352
game-runtime/src/host/gd-runtime.js
Normal file
@ -0,0 +1,352 @@
|
||||
/**
|
||||
* gd-runtime.js —— 运行时访问约定基座(2D 适配器的 behavior 侧 API · core-runtime-v0)
|
||||
* owner:本 session(引擎线 · plan 2026-06-18-001 U1) | 消费方:U2 build-from-source 装配器 + U3 generate 真产 gameDefinition
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把已冻结的受控面 `boot.ctx`(PluginContext,api.d.ts)包装成「声明式 gameDefinition」
|
||||
* 友好的 2D 运行时:createRuntime(boot, gameDefinition) → 建可变世界(entities 实例)+ 产 `rt` 对象。
|
||||
* `rt` 即 U2 装配器编译每个 behavior 时 `new Function('rt','self','dt', code)` 注入的那个 rt——
|
||||
* behaviors 经 rt 操作世界(不写画、不触裸引擎/DOM),声明式渲染器逐实体按 render 组件出图。
|
||||
*
|
||||
* 【两阶段生命周期定位】gameDefinition = 开发态权威源(声明式 + behaviors 各带逻辑 JS);
|
||||
* 本运行时是「源 → 可玩」的运行期装配核(改源不改打包产物,见 source-project.schema.json)。
|
||||
*
|
||||
* 【受控面铁律(对齐 api.d.ts / game-host.d.ts)】
|
||||
* - 时间/随机一律走 boot.ctx(确定性可复现);rt 内部**禁 Math.random / Date.now**。
|
||||
* - 引擎能力(粒子/音频)一律经 boot.ctx.getEngine()——满 F 门「真接线」;无引擎时优雅 no-op。
|
||||
* - 输入一律经 boot.ctx.getInput() 订阅,rt 侧转成 behaviors 友好的**轮询面**(isDown/justTapped/pointer)。
|
||||
*
|
||||
* 【迭代状态·别冻早(plan U1)】本文件是运行时约定 **v0**;契约(runtime-api-2d.d.ts)待收 3-5 类真实
|
||||
* 模型输出补 idiom、1-2 轮稳定后再正式化。当前以「能跑能测」为准,接口可能随真实输出微调。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** 视口常量(与 generic-host-config 的 390×844 移动竖屏基准同口径;behaviors 边界判断 / 渲染兜底用)。 */
|
||||
const VIEW_W = 390;
|
||||
const VIEW_H = 844;
|
||||
|
||||
/** rt.fx.beep 音效参数包(ZzFX 参数;按 kind 取,缺省走 default)。仅为「真调引擎合成核」满 F 门,不强求好听。 */
|
||||
const BEEP_PARAMS = {
|
||||
default: [1, 0.05, 400, , , 0.1, , 1.2],
|
||||
score: [1, 0.05, 800, , , 0.15, , 0.5],
|
||||
hit: [1, 0.05, 300, , , 0.1, , 1.0],
|
||||
lose: [1, 0.05, 150, 0.5, 0.3, 0.1, , 1.3],
|
||||
win: [1, 0.05, 600, , 0.2, 0.2, , 0.6],
|
||||
};
|
||||
|
||||
/* ── 小工具(纯函数,确定性) ───────────────────────────────────────────── */
|
||||
function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; }
|
||||
function toArray(v) { return Array.isArray(v) ? v : []; }
|
||||
/** 归一化颜色:接受 '#rrggbb' / {r,g,b,a}(0..1);产出引擎归一化 {r,g,b,a}。缺省不透明白。 */
|
||||
function toColor(c) {
|
||||
if (c && typeof c === 'object') {
|
||||
return { r: num(c.r), g: num(c.g), b: num(c.b), a: c.a == null ? 1 : num(c.a) };
|
||||
}
|
||||
if (typeof c === 'string' && c[0] === '#') {
|
||||
const h = c.length === 4
|
||||
? c.slice(1).split('').map(x => x + x).join('') // #abc → aabbcc
|
||||
: c.slice(1);
|
||||
const n = parseInt(h, 16);
|
||||
if (isFinite(n)) return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, a: 1 };
|
||||
}
|
||||
return { r: 1, g: 1, b: 1, a: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 建运行时:实例化 entities、编译 behaviors/rules、产 rt 对象。
|
||||
* @param {{ctx:object, mainContext?:object, canvas?:object, seed?:number}} boot 宿主注入的受控启动上下文(game-host.d.ts GameHostBootContext)。
|
||||
* @param {{entities?:Array, components?:Array, behaviors?:Array, scenes?:Array, rules?:Array}} gameDefinition 声明式领域模型(source-project.schema.json#/properties/gameDefinition)。
|
||||
* @returns {{rt:object, init:Function, update:Function, render:Function, state:Function, destroy:Function, errors:Function}}
|
||||
* 运行时句柄:U2 装配器产出的工厂经它实现 GameInstance 的 init/update/render/destroy/_forensicsView。
|
||||
*/
|
||||
export function createRuntime(boot, gameDefinition) {
|
||||
const ctx = boot && boot.ctx;
|
||||
// 错误路径必硬失败:绕过受控面即破「引擎可换」铁律,宁可 fail-loud 不静默降级。
|
||||
if (!ctx || typeof ctx.getInput !== 'function') {
|
||||
throw new Error('[gd-runtime] boot.ctx 缺失或非法:运行时必须经受控面(PluginContext)注入,不得绕过 ctx 触裸引擎/DOM');
|
||||
}
|
||||
const gdef = gameDefinition || {};
|
||||
// 组件定义按 id 索引(entity.components 是 id 引用数组,schema#/$defs/entity)。
|
||||
const componentDefs = new Map();
|
||||
for (const c of toArray(gdef.components)) { if (c && c.id != null) componentDefs.set(String(c.id), c); }
|
||||
|
||||
/* ── 世界状态 ───────────────────────────────────────────────────────── */
|
||||
let entities = []; // 当前世界实体(含已 spawn;reap 清 alive=false)
|
||||
const byId = new Map(); // id → entity
|
||||
let spawnSeq = 0; // 匿名 spawn 实体的自增序号
|
||||
let score = 0;
|
||||
let phase = 'booting'; // booting → playing → gameover(gameover 为 latch 终态,不可逆)
|
||||
let result = null; // 'win' | 'lose' | null
|
||||
let elapsed = 0; // 相对游戏时间(秒,累加 dt)——禁用绝对钟比较(取证可复现)
|
||||
let curDt = 0; // 本帧 dt(rt.dt 经 getter 读它)
|
||||
const subs = []; // 输入订阅句柄(destroy 时注销,防泄漏)
|
||||
const errors = []; // behavior/rule 运行错误(回灌为生成质量信号,供 repair 读)
|
||||
|
||||
/* ── 输入轮询态(订阅受控事件 → 维护,behaviors 经 rt.input 轮询) ──────── */
|
||||
const downKeys = new Set(); // 当前按住的键
|
||||
const justPressedKeys = new Set(); // 本帧刚按下的键(帧末清)
|
||||
let pointer = { x: 0, y: 0, down: false };
|
||||
let tappedThisFrame = false; // 本帧是否发生 pointerdown(帧末清)
|
||||
subs.push(ctx.getInput().on('keydown', (e) => { downKeys.add(e.key); justPressedKeys.add(e.key); }));
|
||||
subs.push(ctx.getInput().on('keyup', (e) => { downKeys.delete(e.key); }));
|
||||
subs.push(ctx.getInput().on('pointerdown', (e) => { pointer = { x: e.x, y: e.y, down: true }; tappedThisFrame = true; }));
|
||||
subs.push(ctx.getInput().on('pointermove', (e) => { pointer.x = e.x; pointer.y = e.y; }));
|
||||
subs.push(ctx.getInput().on('pointerup', () => { pointer.down = false; }));
|
||||
|
||||
/* ── 实体工厂 ───────────────────────────────────────────────────────── */
|
||||
/** 把组件引用/内联组件解析为组件对象数组(字符串 → 查 componentDefs;对象 → 内联用,spawn 走此路)。 */
|
||||
function resolveComponents(refs) {
|
||||
const out = [];
|
||||
for (const r of toArray(refs)) {
|
||||
if (typeof r === 'string') { const def = componentDefs.get(r); if (def) out.push(def); }
|
||||
else if (r && typeof r === 'object') { out.push(r); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/** 取实体首个指定 kind 的组件(无则 null)。 */
|
||||
function componentOf(e, kind) {
|
||||
for (const c of e.components) { if (c && c.kind === kind) return c; }
|
||||
return null;
|
||||
}
|
||||
/** 建实体实例:transform.position → x/y;vx/vy/tags/components 归一化;带 get/set/destroy。 */
|
||||
function makeEntity(spec) {
|
||||
const pos = (spec.transform && spec.transform.position) || { x: spec.x, y: spec.y };
|
||||
const e = {
|
||||
id: spec.id != null ? String(spec.id) : ('_e' + (spawnSeq++)),
|
||||
x: num(pos && pos.x), y: num(pos && pos.y),
|
||||
vx: num(spec.vx), vy: num(spec.vy),
|
||||
alive: true,
|
||||
tags: new Set(toArray(spec.tags).map(String)),
|
||||
components: resolveComponents(spec.components),
|
||||
get(k) { return this[k]; },
|
||||
set(k, v) { this[k] = v; return v; },
|
||||
destroy() { this.alive = false; },
|
||||
};
|
||||
return e;
|
||||
}
|
||||
function addEntity(e) { entities.push(e); byId.set(e.id, e); return e; }
|
||||
|
||||
/* ── rt 对象(behavior 侧 API · 注入 new Function('rt','self','dt',code)) ── */
|
||||
/** latch 终态(win/lose 一旦置定不可逆——H 门「终态驻留」硬约束)。 */
|
||||
function latch(r) { if (phase === 'gameover') return; phase = 'gameover'; result = r; }
|
||||
/** 经 ctx.getEngine() 真调引擎粒子(满 F 门「真接线」;无引擎优雅 no-op,不留 sim 降级)。 */
|
||||
function fxBurst(x, y, color) {
|
||||
const eng = ctx.getEngine && ctx.getEngine();
|
||||
if (!eng || !eng.particles) return;
|
||||
const c = toColor(color);
|
||||
try {
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: num(x), y: num(y) }, count: 20, speed: 120, particleTime: 0.4,
|
||||
colorStart: c, colorEnd: { r: c.r, g: c.g, b: c.b, a: 0 },
|
||||
});
|
||||
} catch (_) { /* 引擎调用失败不连坐游戏帧 */ }
|
||||
}
|
||||
/** 经 ctx.getEngine() 真调引擎合成核(满 F 门;无引擎 no-op)。 */
|
||||
function fxBeep(kind) {
|
||||
const eng = ctx.getEngine && ctx.getEngine();
|
||||
if (!eng || !eng.audio || !eng.audio.synth) return;
|
||||
try { eng.audio.synth.synthSfx(BEEP_PARAMS[kind] || BEEP_PARAMS.default); } catch (_) { /* no-op */ }
|
||||
}
|
||||
/** AABB 重叠(中心点 + 半宽半高;缺省半尺寸 8px)。a/b = {x,y,w?,h?}。 */
|
||||
function overlap(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const ahw = num(a.w || 16) / 2, ahh = num(a.h || 16) / 2, bhw = num(b.w || 16) / 2, bhh = num(b.h || 16) / 2;
|
||||
return Math.abs(num(a.x) - num(b.x)) <= ahw + bhw && Math.abs(num(a.y) - num(b.y)) <= ahh + bhh;
|
||||
}
|
||||
|
||||
const rt = {
|
||||
/* 实体面 */
|
||||
getEntity(id) { const e = byId.get(String(id)); return e && e.alive ? e : null; },
|
||||
entities() { return entities.filter((e) => e.alive); },
|
||||
/** 按 tag 或组件 id/kind 查询活实体(gameplay 主用 tag)。 */
|
||||
query(name) {
|
||||
return entities.filter((e) => e.alive && (e.tags.has(name) || e.components.some((c) => c && (c.id === name || c.kind === name))));
|
||||
},
|
||||
/** 生成实体:{x,y,vx,vy,tags,components}(components 可内联对象)。返回实体引用。 */
|
||||
spawn(spec) { return addEntity(makeEntity(spec || {})); },
|
||||
destroy(e) { if (e) e.alive = false; },
|
||||
/* 输入面(轮询) */
|
||||
input: {
|
||||
isDown: (k) => downKeys.has(k),
|
||||
justPressed: (k) => justPressedKeys.has(k),
|
||||
justTapped: () => tappedThisFrame,
|
||||
get pointer() { return pointer; },
|
||||
},
|
||||
/* 时间/随机面(走 boot.ctx 受控种子,确定性;禁 Math.random/Date.now) */
|
||||
time: { now: () => elapsed, nowMs: () => ctx.time.nowMs() },
|
||||
get dt() { return curDt; },
|
||||
random: () => ctx.random.next(),
|
||||
randRange: (a, b) => ctx.random.range(a, b),
|
||||
randInt: (a, b) => Math.floor(ctx.random.range(a, b + 1)),
|
||||
/* 分数胜负面(win/lose 置 latch 终态) */
|
||||
get score() { return score; },
|
||||
addScore: (n) => { score += (n == null ? 1 : n); return score; },
|
||||
setScore: (n) => { score = num(n); return score; },
|
||||
win: () => latch('win'),
|
||||
lose: () => latch('lose'),
|
||||
/* 工具/特效面 */
|
||||
clamp: (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v),
|
||||
dist: (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by),
|
||||
overlap,
|
||||
fx: { burst: (x, y, color) => fxBurst(x, y, color), beep: (kind) => fxBeep(kind) },
|
||||
/* 视口(behaviors 边界判断用) */
|
||||
view: { w: VIEW_W, h: VIEW_H },
|
||||
};
|
||||
|
||||
/* ── 编译 behaviors(各带逻辑 JS:behavior.code 规范名 / behavior.js 兼容别名) ── */
|
||||
// self = 本 behavior 的持久局部态(跨帧保留,存计时器/累加器等);dt = 本帧秒步。
|
||||
const initBehaviors = []; // trigger==='init':世界建好后跑一次
|
||||
const tickBehaviors = []; // 其余(update/input/collision/timer):每帧跑(behaviors 经 rt.input 轮询)
|
||||
for (const b of toArray(gdef.behaviors)) {
|
||||
const code = b && (b.code != null ? b.code : (b.js != null ? b.js : ''));
|
||||
if (!code || typeof code !== 'string') continue;
|
||||
let fn;
|
||||
try { fn = new Function('rt', 'self', 'dt', code); }
|
||||
catch (err) { errors.push('behavior[' + (b.id || '?') + '] 编译失败: ' + err.message); continue; }
|
||||
const slot = { id: b.id, trigger: b.trigger, fn, self: {} };
|
||||
(b.trigger === 'init' ? initBehaviors : tickBehaviors).push(slot);
|
||||
}
|
||||
function runBehavior(slot, dt) {
|
||||
// behavior 抛错隔离 + 回灌质量信号(plan U2 step6:喂 repair feedback);单 behavior 错不连坐整帧。
|
||||
try { slot.fn(rt, slot.self, dt); }
|
||||
catch (err) { errors.push('behavior[' + (slot.id || '?') + '] 运行抛错: ' + err.message); }
|
||||
}
|
||||
|
||||
/* ── 编译 rules(condition = JS 布尔表达式串,rt/self 在作用域内) ──────── */
|
||||
const compiledRules = [];
|
||||
for (const r of toArray(gdef.rules)) {
|
||||
let cond;
|
||||
try { cond = new Function('rt', 'self', 'return (' + (r.condition || 'false') + ');'); }
|
||||
catch (err) { errors.push('rule[' + (r.id || '?') + '] 编译失败: ' + err.message); continue; }
|
||||
compiledRules.push({ id: r.id, cond, outcome: r.outcome, self: {}, lastTrue: false });
|
||||
}
|
||||
function evalRules() {
|
||||
for (const cr of compiledRules) {
|
||||
let t = false;
|
||||
try { t = !!cr.cond(rt, cr.self); } catch (err) { errors.push('rule[' + (cr.id || '?') + '] 运行抛错: ' + err.message); }
|
||||
const rising = t && !cr.lastTrue; // 上升沿(score/advance 用,防每帧重复触发)
|
||||
cr.lastTrue = t;
|
||||
if (!t) continue;
|
||||
if (cr.outcome === 'win') latch('win');
|
||||
else if (cr.outcome === 'lose') latch('lose');
|
||||
else if (cr.outcome === 'score') { if (rising) score += 1; }
|
||||
// 'advance':多场景推进,v0 占位(单场景为主,后续迭代)。
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 物理积分(opt-in:含 kind=physics 组件的实体每帧按 vx/vy(+gravity) 推进) ── */
|
||||
function integratePhysics(dt) {
|
||||
for (const e of entities) {
|
||||
if (!e.alive) continue;
|
||||
const phys = componentOf(e, 'physics');
|
||||
if (!phys) continue;
|
||||
if (phys.gravity) e.vy += num(phys.gravity) * dt;
|
||||
e.x += e.vx * dt;
|
||||
e.y += e.vy * dt;
|
||||
}
|
||||
}
|
||||
/** 回收死实体(帧末统一清,避免迭代中改数组)。 */
|
||||
function reap() {
|
||||
let dirty = false;
|
||||
for (const e of entities) { if (!e.alive) { byId.delete(e.id); dirty = true; } }
|
||||
if (dirty) entities = entities.filter((e) => e.alive);
|
||||
}
|
||||
|
||||
/* ── 场景:v0 取 scenes[0].entityRefs 过滤初始实体(缺省全量实例化) ──────── */
|
||||
function sceneRefs() {
|
||||
const scenes = toArray(gdef.scenes);
|
||||
if (!scenes.length || !Array.isArray(scenes[0].entityRefs)) return null;
|
||||
return new Set(scenes[0].entityRefs.map(String));
|
||||
}
|
||||
|
||||
/* ── 生命周期:init / update / render / state / destroy ─────────────────── */
|
||||
function init() {
|
||||
const refs = sceneRefs();
|
||||
for (const espec of toArray(gdef.entities)) {
|
||||
if (refs && espec.id != null && !refs.has(String(espec.id))) continue;
|
||||
addEntity(makeEntity(espec));
|
||||
}
|
||||
for (const slot of initBehaviors) runBehavior(slot, 0);
|
||||
if (phase === 'booting') phase = 'playing';
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (phase !== 'playing') return; // latch 终态后停摆(H 门:终态驻留不可逆)
|
||||
curDt = num(dt);
|
||||
elapsed += curDt;
|
||||
for (const slot of tickBehaviors) { if (phase !== 'playing') break; runBehavior(slot, curDt); }
|
||||
integratePhysics(curDt);
|
||||
evalRules();
|
||||
reap();
|
||||
justPressedKeys.clear(); // 帧末清每帧输入沿
|
||||
tappedThisFrame = false;
|
||||
}
|
||||
|
||||
/** 声明式渲染器:逐实体按 render 组件画 rect/circle/fill(behaviors 只逻辑不画)+ 极简 HUD。 */
|
||||
function render(g) {
|
||||
if (!g) return;
|
||||
// 背景 fill 实体先画(kind=render 且 shape=fill 铺满视口)。
|
||||
for (const e of entities) {
|
||||
if (!e.alive) continue;
|
||||
for (const c of e.components) {
|
||||
if (c && c.kind === 'render' && c.shape === 'fill') { g.fillStyle = c.color || '#000'; g.fillRect(0, 0, VIEW_W, VIEW_H); }
|
||||
}
|
||||
}
|
||||
for (const e of entities) { if (e.alive) drawEntity(g, e); }
|
||||
drawHud(g);
|
||||
}
|
||||
function drawEntity(g, e) {
|
||||
for (const c of e.components) {
|
||||
if (!c || c.kind !== 'render' || c.shape === 'fill') continue;
|
||||
g.fillStyle = c.color || '#ffffff';
|
||||
if (c.shape === 'circle') {
|
||||
const r = num(c.r != null ? c.r : (c.radius != null ? c.radius : 10));
|
||||
g.beginPath(); g.arc(e.x, e.y, r, 0, Math.PI * 2); g.fill();
|
||||
} else { // rect(缺省):以 transform 为中心
|
||||
const w = num(c.w != null ? c.w : (c.width != null ? c.width : 20));
|
||||
const h = num(c.h != null ? c.h : (c.height != null ? c.height : 20));
|
||||
g.fillRect(e.x - w / 2, e.y - h / 2, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
function drawHud(g) {
|
||||
if (typeof g.fillText !== 'function') return; // mock ctx 容错
|
||||
try {
|
||||
g.fillStyle = '#ffffff';
|
||||
g.font = '24px monospace';
|
||||
g.textAlign = 'center';
|
||||
g.fillText('Score: ' + score, VIEW_W / 2, 36);
|
||||
if (phase === 'gameover') {
|
||||
g.font = '36px monospace';
|
||||
g.fillStyle = result === 'win' ? '#33dd66' : '#ff4444';
|
||||
g.fillText(result === 'win' ? 'YOU WIN' : 'GAME OVER', VIEW_W / 2, VIEW_H / 2);
|
||||
}
|
||||
} catch (_) { /* HUD 失败不连坐 */ }
|
||||
}
|
||||
|
||||
/** 取证快照(喂 _forensicsView().state();九门读 phase/score/result/remaining)。 */
|
||||
function state() {
|
||||
const alive = entities.filter((e) => e.alive);
|
||||
return {
|
||||
phase, result, score,
|
||||
elapsed,
|
||||
remaining: alive.length,
|
||||
progress: null,
|
||||
entities: alive.map((e) => ({ id: e.id, x: e.x, y: e.y, tags: Array.from(e.tags) })),
|
||||
errors: errors.slice(), // 生成质量信号(behavior/rule 抛错)
|
||||
};
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const s of subs) { try { s && s.cancel && s.cancel(); } catch (_) { /* no-op */ } }
|
||||
subs.length = 0;
|
||||
entities = [];
|
||||
byId.clear();
|
||||
}
|
||||
|
||||
return { rt, init, update, render, state, destroy, errors: () => errors.slice() };
|
||||
}
|
||||
|
||||
export default createRuntime;
|
||||
189
game-runtime/src/host/gd-runtime.test.mjs
Normal file
189
game-runtime/src/host/gd-runtime.test.mjs
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* gd-runtime.test.mjs —— U1 运行时约定基座单测(plan 2026-06-18-001 U1 验收)
|
||||
* 跑:cd game-runtime && node --test src/host/gd-runtime.test.mjs(亦被 `npm test` 的 src/**\/*.test.mjs 收)
|
||||
*
|
||||
* 钉死 plan U1 三条必验 + 集成验证(证 behaviors/rules/spawn 真跑通):
|
||||
* ① 确定性:rt.random 同种子可复现;② win latch 驻留不可逆;③ rt.fx.burst 真触发 ctx.getEngine。
|
||||
* 受控 ctx 经 src/core/plugin.js 的 createHostDevContext(带 SeededRandom + 可注 engineFactory)建。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHostDevContext } from '../core/plugin.js';
|
||||
import { createRuntime } from './gd-runtime.js';
|
||||
|
||||
/** 建一个记账 mock 引擎(记 particles/audio 调用,验「真接线」),交 engineFactory。 */
|
||||
function makeMockEngine() {
|
||||
const emitterCalls = [];
|
||||
const synthCalls = [];
|
||||
const engine = {
|
||||
particles: { spawnEmitter: (spec) => { emitterCalls.push(spec); return { isActive: () => false, stop() {} }; } },
|
||||
audio: { synth: { synthSfx: (p) => { synthCalls.push(p); return [0]; }, synthSong: () => null } },
|
||||
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
|
||||
};
|
||||
return { engine, emitterCalls, synthCalls };
|
||||
}
|
||||
|
||||
/** 起一个受控 boot(注 seed + 可选 mock 引擎)。返回 {boot, bundle, mock}。 */
|
||||
function makeBoot(seed, withEngine) {
|
||||
const mock = withEngine ? makeMockEngine() : null;
|
||||
const bundle = createHostDevContext({ seed, engineFactory: mock ? () => mock.engine : null });
|
||||
const boot = { ctx: bundle.context, mainContext: null, canvas: null, seed };
|
||||
return { boot, bundle, mock };
|
||||
}
|
||||
|
||||
/* ① 确定性:同种子两运行时 rt.random 序列逐位相等(禁 Math.random 的硬证)。 */
|
||||
test('U1-① rt.random 同种子可复现(确定性)', () => {
|
||||
const a = createRuntime(makeBoot(20260618, false).boot, {});
|
||||
const b = createRuntime(makeBoot(20260618, false).boot, {});
|
||||
const seqA = Array.from({ length: 6 }, () => a.rt.random());
|
||||
const seqB = Array.from({ length: 6 }, () => b.rt.random());
|
||||
assert.deepEqual(seqA, seqB, 'rt.random 同种子序列应逐位相等');
|
||||
// randRange/randInt 同样确定
|
||||
assert.equal(a.rt.randInt(0, 100), b.rt.randInt(0, 100));
|
||||
// 不同种子应大概率不同(弱证非常数)
|
||||
const c = createRuntime(makeBoot(999, false).boot, {});
|
||||
assert.notDeepEqual(Array.from({ length: 6 }, () => c.rt.random()), seqA);
|
||||
});
|
||||
|
||||
/* ② win/lose latch:置定终态后不可逆(H 门「终态驻留」硬约束)。 */
|
||||
test('U1-② rt.win() 后 phase latch 驻留不可逆', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {});
|
||||
r.init();
|
||||
assert.equal(r.state().phase, 'playing');
|
||||
r.rt.win();
|
||||
assert.equal(r.state().phase, 'gameover');
|
||||
assert.equal(r.state().result, 'win');
|
||||
// latch 不可逆:再调 lose 不应翻盘
|
||||
r.rt.lose();
|
||||
assert.equal(r.state().result, 'win', 'latch 后 result 不可被 lose 覆盖');
|
||||
// latch 后 update 停摆(不再推进 elapsed)
|
||||
const before = r.state().elapsed;
|
||||
r.update(0.5);
|
||||
assert.equal(r.state().elapsed, before, 'latch 后 update 应停摆');
|
||||
});
|
||||
|
||||
/* ③ rt.fx.burst / beep 真触发 ctx.getEngine() 调用(满 F 门「真接线」)。 */
|
||||
test('U1-③ rt.fx.burst/beep 真调引擎(满 F 门)', () => {
|
||||
const { boot, mock } = makeBoot(1, true);
|
||||
const r = createRuntime(boot, {});
|
||||
r.rt.fx.burst(100, 200, '#ff0000');
|
||||
assert.equal(mock.emitterCalls.length, 1, 'fx.burst 应触发一次 particles.spawnEmitter');
|
||||
assert.deepEqual(mock.emitterCalls[0].pos, { x: 100, y: 200 }, '发射原点应为受控面像素 {x,y}');
|
||||
assert.ok(Math.abs(mock.emitterCalls[0].colorStart.r - 1) < 1e-6, '#ff0000 → colorStart.r≈1');
|
||||
r.rt.fx.beep('score');
|
||||
assert.equal(mock.synthCalls.length, 1, 'fx.beep 应触发一次 audio.synth.synthSfx');
|
||||
// 无引擎时优雅 no-op(不抛)
|
||||
const noEng = createRuntime(makeBoot(1, false).boot, {});
|
||||
assert.doesNotThrow(() => { noEng.rt.fx.burst(0, 0, '#fff'); noEng.rt.fx.beep('hit'); });
|
||||
});
|
||||
|
||||
/* 集成①:behavior 带逻辑 JS 经 new Function 编译 + 每帧跑 + 操作世界(实体移动)。 */
|
||||
test('U1-集成 behavior 编译并每帧操作世界', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const gdef = {
|
||||
entities: [{ id: 'p', transform: { position: { x: 10, y: 10 } } }],
|
||||
behaviors: [{ id: 'mv', trigger: 'update', code: "const p=rt.getEntity('p'); if(p){ p.x += 100*dt; }" }],
|
||||
};
|
||||
const r = createRuntime(boot, gdef);
|
||||
r.init();
|
||||
assert.equal(r.rt.getEntity('p').x, 10);
|
||||
r.update(0.5);
|
||||
assert.ok(Math.abs(r.rt.getEntity('p').x - 60) < 1e-6, '0.5s @100px/s → x≈60');
|
||||
assert.deepEqual(r.errors(), [], '正常 behavior 不应产错误信号');
|
||||
});
|
||||
|
||||
/* 集成②:rule 布尔表达式编译 + 上升沿/latch(score≥3 → win)。 */
|
||||
test('U1-集成 rule 评估并 latch', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, { rules: [{ id: 'w', condition: 'rt.score >= 3', outcome: 'win' }] });
|
||||
r.init();
|
||||
r.rt.setScore(2);
|
||||
r.update(0.016);
|
||||
assert.equal(r.state().phase, 'playing', 'score<3 不应胜');
|
||||
r.rt.setScore(3);
|
||||
r.update(0.016);
|
||||
assert.equal(r.state().phase, 'gameover');
|
||||
assert.equal(r.state().result, 'win');
|
||||
});
|
||||
|
||||
/* 集成③:rt.spawn/destroy/query + 死实体回收。 */
|
||||
test('U1-集成 spawn/destroy/query 与回收', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {});
|
||||
r.init();
|
||||
const b = r.rt.spawn({ x: 5, y: 5, tags: ['bullet'] });
|
||||
assert.equal(r.rt.query('bullet').length, 1);
|
||||
r.rt.destroy(b);
|
||||
r.update(0.016); // reap 在帧末
|
||||
assert.equal(r.rt.query('bullet').length, 0, 'destroy 后下一帧应回收');
|
||||
assert.equal(r.rt.getEntity(b.id), null);
|
||||
});
|
||||
|
||||
/* 集成④:输入轮询面(受控事件桥 _emit → rt.input 轮询)。 */
|
||||
test('U1-集成 输入轮询(isDown/justTapped)', () => {
|
||||
const { boot, bundle } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {});
|
||||
r.init();
|
||||
bundle.inputBridge._emit('keydown', { key: 'ArrowLeft' });
|
||||
assert.equal(r.rt.input.isDown('ArrowLeft'), true);
|
||||
assert.equal(r.rt.input.justPressed('ArrowLeft'), true);
|
||||
bundle.inputBridge._emit('pointerdown', { x: 42, y: 99 });
|
||||
assert.equal(r.rt.input.justTapped(), true);
|
||||
assert.deepEqual({ x: r.rt.input.pointer.x, y: r.rt.input.pointer.y, down: r.rt.input.pointer.down }, { x: 42, y: 99, down: true });
|
||||
r.update(0.016); // 帧末清沿
|
||||
assert.equal(r.rt.input.justPressed('ArrowLeft'), false, '帧末应清 justPressed');
|
||||
assert.equal(r.rt.input.justTapped(), false, '帧末应清 justTapped');
|
||||
assert.equal(r.rt.input.isDown('ArrowLeft'), true, 'isDown 不随帧清(仍按住)');
|
||||
bundle.inputBridge._emit('keyup', { key: 'ArrowLeft' });
|
||||
assert.equal(r.rt.input.isDown('ArrowLeft'), false);
|
||||
});
|
||||
|
||||
/* 集成⑤:物理组件 opt-in 积分(gravity + vx/vy)。 */
|
||||
test('U1-集成 物理组件积分', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const gdef = {
|
||||
components: [{ id: 'phys', kind: 'physics', gravity: 100 }],
|
||||
entities: [{ id: 'ball', transform: { position: { x: 0, y: 0 } }, vx: 10, vy: 0, components: ['phys'] }],
|
||||
};
|
||||
const r = createRuntime(boot, gdef);
|
||||
r.init();
|
||||
r.update(1.0);
|
||||
const ball = r.rt.getEntity('ball');
|
||||
assert.ok(Math.abs(ball.vy - 100) < 1e-6, 'gravity 100 @1s → vy≈100');
|
||||
assert.ok(Math.abs(ball.x - 10) < 1e-6, 'vx 10 @1s → x≈10');
|
||||
});
|
||||
|
||||
/* 集成⑥:声明式渲染器不抛 + 真出绘制调用(mock g 记账)。 */
|
||||
test('U1-集成 声明式渲染器出图', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const calls = { fillRect: 0, arc: 0 };
|
||||
const g = {
|
||||
fillStyle: '', font: '', textAlign: '',
|
||||
fillRect: () => { calls.fillRect++; }, beginPath: () => {}, arc: () => { calls.arc++; }, fill: () => {},
|
||||
fillText: () => {},
|
||||
};
|
||||
const gdef = {
|
||||
components: [
|
||||
{ id: 'bg', kind: 'render', shape: 'fill', color: '#101020' },
|
||||
{ id: 'box', kind: 'render', shape: 'rect', color: '#fff', w: 20, h: 20 },
|
||||
{ id: 'dot', kind: 'render', shape: 'circle', color: '#0f0', r: 8 },
|
||||
],
|
||||
entities: [
|
||||
{ id: 'b', transform: { position: { x: 0, y: 0 } }, components: ['bg'] },
|
||||
{ id: 'box1', transform: { position: { x: 100, y: 100 } }, components: ['box'] },
|
||||
{ id: 'd1', transform: { position: { x: 50, y: 50 } }, components: ['dot'] },
|
||||
],
|
||||
};
|
||||
const r = createRuntime(boot, gdef);
|
||||
r.init();
|
||||
assert.doesNotThrow(() => r.render(g));
|
||||
assert.ok(calls.fillRect >= 2, 'fill 背景 + rect 实体应出 fillRect');
|
||||
assert.equal(calls.arc, 1, 'circle 实体应出 arc');
|
||||
});
|
||||
|
||||
/* 守卫:缺 boot.ctx 必 fail-loud(错误路径不静默)。 */
|
||||
test('U1-守卫 缺 ctx 硬失败', () => {
|
||||
assert.throws(() => createRuntime({}, {}), /boot\.ctx 缺失/);
|
||||
assert.throws(() => createRuntime(null, {}), /boot\.ctx 缺失/);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user