lili db14845d12 fix(review): U1 资产渲染——ce-code-review 整改(P0 assets 层级 + P1 契约同步 + 校验洞 + P2 群)
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>
2026-06-19 01:32:07 -07:00

482 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;