6-reviewer ce-code-review 揪出并整改: - P0(api-contract,读 schema 实证): assets 在 source-project.schema sourceProject 顶层(非 gameDefinition, 后者 additionalProperties:false)→ 原加进 GAMEDEF_KEYS 丢合规顶层 assets。改:validateSourceProject 从顶层 sp.assets 抽取(容差回退 gd.assets)并入 cleaned.assets;GAMEDEF_KEYS 移除 assets;测试改顶层。 - P1(api-contract+project-standards 双证): runtime-api-2d.d.ts 同步—GameDefinition.assets / RenderComponent shape:'sprite'+asset?/ref? / AssetSpec / createRuntime boot.assets(contract-first)。 - P1(adversarial conf100 复现): 非字符串 asset.ref(对象)绕过校验→零 repair 信号死循环 → ref 须非空字符串。 - P2(adversarial+maintainability): id/ref key 空间歧义 → drawSprite 经 asset.ref 查 boot.assets(宿主按 ref 键)。 - P2(maintainability): 数组化循环复用 GAMEDEF_KEYS 去重 / placeholderColor 补 music 色 / 弃 c.sprite 别名 / 32px 具名 DEFAULT_SPRITE_SIZE。 - P2(project-standards+api-contract): 宿主预载半-wired → game-host.d.ts + boot-game-host.js 加 HOST TODO 指针。 测试: 重写 assets(顶层/非串ref/非数组/端到端) + sprite 7 路(ref键/无图/image=null/抛错/悬空/music/asset:0)。node --test src/host 31/31 绿。 残留(已 TODO 指针 + plan U1 剩余 + task 记录): 宿主 loadAssets→boot.assets 真 wiring(浏览器耦合、node 难测)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
482 lines
26 KiB
JavaScript
482 lines
26 KiB
JavaScript
/**
|
||
* 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;
|
||
/** sprite 缺省尺寸(U1·略大于 rect 缺省 20,sprite 通常占位更大;评审 P3 具名化免魔法数)。 */
|
||
const DEFAULT_SPRITE_SIZE = 32;
|
||
|
||
/** 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); }
|
||
// 资产定义按 id 索引(U1:render 组件 shape:'sprite' 经 asset/ref 引用六类资产;缺省空=无美术游戏)。
|
||
const assetDefs = new Map();
|
||
for (const a of toArray(gdef.assets)) { if (a && a.id != null) assetDefs.set(String(a.id), a); }
|
||
// 宿主预载图源(additive·game-host.d.ts boot.assets,可空):assetId → {image} 受控面图源。
|
||
// 受控面铁律:sprite 图源**只取宿主 init 预载**,运行时绝不触 new Image()/DOM(保「引擎可换」+ node 单测可跑)。
|
||
// 缺图源(宿主预载未 wire / 资产未载)→ drawSprite 降级类目色占位(前向兼容,不崩不空屏)。
|
||
const bootAssets = (boot && boot.assets && typeof boot.assets === 'object') ? boot.assets : null;
|
||
|
||
/* ── 世界状态 ───────────────────────────────────────────────────────── */
|
||
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;
|
||
}
|
||
/** 实体上限(防失控 spawn 静默膨胀;超限记错误信号并拒新增,转成 repair 可读信号)。 */
|
||
const ENTITY_CAP = 5000;
|
||
function addEntity(e) {
|
||
if (entities.length >= ENTITY_CAP) {
|
||
if (errors.length < 50) errors.push('实体数超上限 ' + ENTITY_CAP + '(疑失控 spawn),拒新增: ' + e.id);
|
||
return e;
|
||
}
|
||
// 重复 id:保留先到者可寻址(byId 仅在无映射时写),记质量信号;杜绝「后者覆盖 byId、reap 误删致先到者失联」。
|
||
if (byId.has(e.id)) { if (errors.length < 50) errors.push('实体 id 重复: ' + e.id + '(先到者保持可寻址)'); }
|
||
else { byId.set(e.id, e); }
|
||
entities.push(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 重叠(中心点 + 半宽半高;缺省全尺寸 16=半 8)。a/b = {x,y,w?,h?}。显式 0 须被尊重(不被 truthy 误当默认)。 */
|
||
function overlap(a, b) {
|
||
if (!a || !b) return false;
|
||
const sz = (v) => (v == null ? 16 : num(v));
|
||
const ahw = sz(a.w) / 2, ahh = sz(a.h) / 2, bhw = sz(b.w) / 2, bhh = sz(b.h) / 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 { x: pointer.x, y: pointer.y, down: pointer.down }; }, // 返副本:防 behavior 改内部 pointer 态
|
||
},
|
||
/* 时间/随机面(走 boot.ctx 受控种子,确定性;禁 Math.random/Date.now)。
|
||
不暴露 nowMs(墙钟=非确定性)——behaviors 一律 now()(相对 elapsed)/dt,保取证可复现。 */
|
||
time: { now: () => elapsed },
|
||
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 终态;addScore 守 num 防 NaN/字符串污染 score 契约——score 是 behaviors/rules/取证共享面)。 */
|
||
get score() { return score; },
|
||
addScore: (n) => { score = num(score) + (n == null ? 1 : num(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 },
|
||
};
|
||
// 冻结 rt 纯 API 面(input/fx/time/view + rt 本体)——防某 behavior 改写共享 API(如 rt.input.isDown)串味全局;
|
||
// 实体仍可变(behaviors 合法写 e.x),只冻 API 命名空间。getter(dt/score)/方法仍可调;写入静默失效或抛,不腐蚀内部态。
|
||
Object.freeze(rt.input);
|
||
Object.freeze(rt.fx);
|
||
Object.freeze(rt.time);
|
||
Object.freeze(rt.view);
|
||
Object.freeze(rt);
|
||
|
||
/* ── 编译 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;
|
||
}
|
||
}
|
||
/**
|
||
* 内置点击派发(plan U3 tap-targets 救场基元):本帧有 tap 时,点中**带 clickable 组件且未 occupied** 的实体 →
|
||
* 翻 occupied + spawn 标记(可见变化) + 计分 + fx。让离散点击类「声明 clickable 即可」,免模型写易错 tap-handler
|
||
* (实测便宜/强模型均常把 tap 门控在分离 state/计时器上→点不中)。clickable 组件:{kind:'clickable',score?,mark?:{shape,color,r/w/h},hitW?,hitH?}。
|
||
*/
|
||
function dispatchClicks() {
|
||
if (!tappedThisFrame) return;
|
||
for (const e of entities) {
|
||
if (!e.alive || e.occupied) continue;
|
||
const clk = componentOf(e, 'clickable');
|
||
if (!clk) continue;
|
||
const rc = componentOf(e, 'render');
|
||
const hw = num(clk.hitW != null ? clk.hitW : (rc && rc.w != null ? rc.w : (rc && rc.r != null ? rc.r * 2 : 80))) / 2;
|
||
const hh = num(clk.hitH != null ? clk.hitH : (rc && rc.h != null ? rc.h : (rc && rc.r != null ? rc.r * 2 : 80))) / 2;
|
||
if (Math.abs(pointer.x - e.x) <= hw && Math.abs(pointer.y - e.y) <= hh) {
|
||
e.occupied = true;
|
||
const mk = clk.mark;
|
||
if (mk && typeof mk === 'object') {
|
||
rt.spawn({ x: e.x, y: e.y, components: [{ kind: 'render', shape: mk.shape || 'circle', color: mk.color || '#ffcc00', r: num(mk.r != null ? mk.r : 28), w: mk.w, h: mk.h }] });
|
||
}
|
||
score = num(score) + num(clk.score != null ? clk.score : 1);
|
||
fxBurst(e.x, e.y, (mk && mk.color) || '#ffcc00');
|
||
fxBeep('score');
|
||
break; // 一次 tap 只触一个目标
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 回收死实体(帧末统一清,避免迭代中改数组)。 */
|
||
function reap() {
|
||
let dirty = false;
|
||
for (const e of entities) {
|
||
if (!e.alive) {
|
||
if (byId.get(e.id) === e) byId.delete(e.id); // 仅当 byId 指向该死亡实例才删(保同 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) {
|
||
curDt = num(dt);
|
||
if (phase === 'playing') { // latch 终态后停摆(H 门:终态驻留);但输入沿仍每帧清(见下)
|
||
for (const slot of tickBehaviors) { if (phase !== 'playing') break; runBehavior(slot, curDt); }
|
||
dispatchClicks(); // 内置 clickable 点击派发(tap-targets 声明式基元,免模型写 tap-handler)
|
||
integratePhysics(curDt);
|
||
evalRules();
|
||
reap();
|
||
elapsed += curDt; // 帧末推进(首帧 behaviors 见 now()=0;latch 后不推进=时间冻结)
|
||
}
|
||
justPressedKeys.clear(); // 输入沿恒帧末清(含 latch 后,杜绝事件滞留——防未来 resume 路读到幻触)
|
||
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;
|
||
if (c.shape === 'sprite') { drawSprite(g, e, c); continue; } // U1 资产渲染(图源/降级见 drawSprite)
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* U1 sprite 渲染:render 组件 shape:'sprite' 引用资产 → 经 asset.ref 取宿主预载图源 drawImage(居中)。
|
||
* 解析链(评审:消 id/ref key 空间歧义):c.asset(canonical;c.ref 容差)= 资产 id → assetDefs.get(id) → def.ref(装载坐标=素材id/URL)
|
||
* → boot.assets[def.ref].image。**boot.assets 按 asset.ref 键**(宿主装载坐标),非 asset.id。
|
||
* 受控面铁律:图源只取 boot.assets(宿主 init 预载,game-host.d.ts additive);运行时不触 new Image/DOM。
|
||
* 无图源(宿主预载未 wire / 资产未载 / 悬空 ref)→ 降级类目色占位 rect(保可见、不崩、不空屏;宿主预载落地后自动转真图)。
|
||
*/
|
||
function drawSprite(g, e, c) {
|
||
const w = num(c.w != null ? c.w : (c.width != null ? c.width : DEFAULT_SPRITE_SIZE));
|
||
const h = num(c.h != null ? c.h : (c.height != null ? c.height : DEFAULT_SPRITE_SIZE));
|
||
const assetId = c.asset != null ? c.asset : (c.ref != null ? c.ref : null); // 引用资产 id(canonical=asset,ref 为容差别名)
|
||
const def = assetId != null ? assetDefs.get(String(assetId)) : null;
|
||
// 图源键 = asset.ref(宿主按装载坐标键 boot.assets);无 def(悬空 ref)时退回 assetId 容差。
|
||
const loadKey = (def && def.ref != null) ? String(def.ref) : (assetId != null ? String(assetId) : null);
|
||
const slot = (bootAssets && loadKey != null) ? bootAssets[loadKey] : null;
|
||
const img = slot && slot.image;
|
||
if (img && typeof g.drawImage === 'function') {
|
||
try { g.drawImage(img, e.x - w / 2, e.y - h / 2, w, h); return; }
|
||
catch (_) { /* drawImage 失败不连坐游戏帧,落占位 */ }
|
||
}
|
||
// 降级占位(宿主预载未 wire / 悬空 ref 时):类目色 rect,保可见。
|
||
g.fillStyle = c.color || placeholderColor(def && def.category);
|
||
g.fillRect(e.x - w / 2, e.y - h / 2, w, h);
|
||
}
|
||
/** 占位类目色(宿主图源缺时用;六类各一色,便于人眼辨「待载资产/误用」)。 */
|
||
function placeholderColor(cat) {
|
||
switch (cat) {
|
||
case 'character': return '#88ccff';
|
||
case 'effect': return '#ffcc44';
|
||
case 'scene': return '#446644';
|
||
case 'ui': return '#cccccc';
|
||
case 'music': return '#aa66aa'; // music 本不应入 render 组件;给独立色便于辨误用(评审 P2)
|
||
default: return '#cc66cc'; // sprite / 未知类目
|
||
}
|
||
}
|
||
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 外,
|
||
* 还**派生 harness driver 形状**(plan U1 step5,对齐 SaaPrompts gatespec driver 契约):
|
||
* ① 逐实体按 id 投影为顶层命名位(ball.x/paddle.x/bird.vy… 让 driver 命名路径解析);
|
||
* ② tag='target' 的实体 → targets[{x,y,idx,occupied,safe}](tap-targets driver 读)。
|
||
* 故 gatespec 的 driver.ballPath='ball.x' 等须与 gameDefinition 实体 id 对齐(U3 prompt 喂此约定)。
|
||
*/
|
||
function state() {
|
||
const alive = entities.filter((e) => e.alive);
|
||
const snap = {
|
||
phase, result, score,
|
||
elapsed,
|
||
remaining: alive.length,
|
||
progress: null,
|
||
errors: errors.slice(), // 生成质量信号(behavior/rule 抛错)
|
||
};
|
||
// ① 逐实体按 id 投影顶层命名位(不覆盖上面的保留键)。
|
||
for (const e of alive) {
|
||
if (e.id && !(e.id in snap)) snap[e.id] = entityView(e);
|
||
}
|
||
// ② tap-targets driver:tag='target' 的实体导出 targets 数组(idx/occupied/safe 供确定性逐格驱动)。
|
||
const tnodes = alive.filter((e) => e.tags.has('target'));
|
||
if (tnodes.length) {
|
||
snap.targets = tnodes.map((e, i) => ({
|
||
x: e.x, y: e.y, idx: (e.idx != null ? e.idx : i),
|
||
occupied: !!e.occupied, safe: e.safe !== false,
|
||
}));
|
||
}
|
||
snap.entities = alive.map((e) => ({ id: e.id, x: e.x, y: e.y, tags: Array.from(e.tags) }));
|
||
return snap;
|
||
}
|
||
|
||
/** 单实体取证投影:位置/速度 + 常见数值字段(w/h/r/angle/radius)供 gatespec 命名路径解析。 */
|
||
function entityView(e) {
|
||
const v = { x: e.x, y: e.y, vx: e.vx, vy: e.vy };
|
||
for (const k of ['w', 'h', 'r', 'angle', 'radius']) {
|
||
if (typeof e[k] === 'number') v[k] = e[k];
|
||
}
|
||
return v;
|
||
}
|
||
|
||
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;
|