merge(planB U1): 引擎资产渲染 + asset 孤儿闭合(死代码修) → dev/2.0.0

Plan B(core-done) U1:六类资产 ref→assets→drawImage 渲染链路 + GAMEDEF_KEYS 孤儿闭合(assets 在 sourceProject 顶层正确抽取并入运行时 GameDefinition,消死代码)+ drawEntity shape:sprite(受控面只取宿主预载 boot.assets/运行时不碰 DOM/缺图源降级类目色占位)。已过 ce-code-review(P0 assets 层级/P1 契约同步/校验洞)+ 6c6g SHIP review。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-19 09:59:40 +00:00
commit 81c1b2cfa9
7 changed files with 227 additions and 6 deletions

View File

@ -21,12 +21,19 @@ import type { PluginContext } from './api.d.ts';
* 游戏美术画在此面 → 与引擎粒子(引擎自渲落同一 mainContext)同屏。**此字段是装载契约的核心**
* (游戏取引擎绘制面的唯一约定,不借 PluginContext.getContext2d——那是插件面)。
* - seed:主随机种子(确定性);canvas:可见画布引用(输入 rect 映射用,real=引擎 mainCanvas)。
* - assets(U1 additive·可选):宿主 init 预载的资产图源,**按 asset.ref 键**(装载坐标=素材 id/URL,
* 非 asset.id;gd-runtime drawSprite 经 render→asset.id→def.ref→boot.assets[ref] 解析),落实本接口
* init 注释「装资产(ref loadAssets 异步)」的意图。缺省(宿主预载未 wire / 无美术游戏)→ 运行时降级类目色占位,
* 零破坏(既有 boot 不带此字段照常)。
* **HOST TODO(plan 2026-06-19-001 U1 剩余 wiring)**:boot-game-host setupCommon 须在 loadAssets 完成后
* 按 asset.ref 填充本字段 → 真图源;未 wire 前 sprite 在生产恒走占位(drawImage 路仅测试注入可达)。
*/
export interface GameHostBootContext {
ctx: PluginContext; // 下层已冻结受控面(复用)
mainContext: CanvasRenderingContext2D; // 引擎 mainContext(boot 注入,render 绘制面)
canvas: HTMLCanvasElement; // 可见画布(real=引擎 mainCanvas,输入 rect 映射用)
seed: number; // 主随机种子
assets?: Record<string, { image?: CanvasImageSource }>; // U1 additive:宿主预载资产图源(**按 asset.ref 键**→{image});缺=sprite 降级占位;HOST TODO 见上
}
/**

View File

@ -192,6 +192,9 @@ export async function bootGameHost(opts) {
const factoryOpts = buildFactoryOpts(bundle.context, { bundle, plugins: pluginInstances, getRealAudioCtx: () => realAudioCtx });
game = factory(factoryOpts);
// 5) game.init:建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱(ctx/mainContext/canvas/seed)。
// HOST TODO(plan 2026-06-19-001 U1 剩余 wiring):U1 已建运行时 sprite 渲染 + boot.assets 契约(按 asset.ref 键),
// 但此处尚未注入 assets → 生产中 sprite 恒走类目色占位。补法:loadAssets(从 sourceProject 顶层 assets 按 ref
// 异步载图)完成后,传 `assets: { [asset.ref]: { image } }` 入下方 boot。未 wire 前真图源路仅测试注入可达。
await game.init({
ctx: bundle.context,
mainContext: renderCtx,

View File

@ -28,8 +28,12 @@
'use strict';
/** gameDefinition 顶层白名单(对齐 schema#/properties/gameDefinition)。 */
/** gameDefinition 顶层白名单(对齐 schema#/properties/gameDefinition)。
* ⚠️ assets **不在此**——schema 中 assets 位于 sourceProject 顶层(与 gameDefinition 平级,additionalProperties:false),
* 非 gameDefinition 属性;单独从顶层 sp.assets 抽取(见 validateSourceProject 末),误入本白名单会丢合规顶层 assets(评审 P0)。 */
const GAMEDEF_KEYS = ['entities', 'components', 'behaviors', 'scenes', 'rules'];
/** 六类资产类目(冻结,对齐 source-project.schema.json #/$defs/assetSpec + game-material V24 + studio.yaml)。 */
const ASSET_CATEGORIES = new Set(['sprite', 'character', 'effect', 'scene', 'ui', 'music']);
/** 生成 game 目录中,generated-factory.js → src/host/gd-runtime.js 的相对 import 路径(games/_wg1-gen/<name>/ 下三级回 game-runtime)。 */
const DEFAULT_RUNTIME_IMPORT = '../../../src/host/gd-runtime.js';
@ -101,7 +105,8 @@ export function validateSourceProject(sourceProject) {
for (const k of GAMEDEF_KEYS) { if (gd[k] != null) cleaned[k] = gd[k]; }
// 数组类字段强制数组化(present-but-not-array=模型漂移:记错并降级为 [],杜绝后续 .map 裸 TypeError)。
for (const k of ['entities', 'components', 'behaviors', 'scenes', 'rules']) {
// 复用 GAMEDEF_KEYS(成员皆数组类),免「白名单/数组化」两处字面量漂移 = 本次 P0(漏加一处)同类隐患(评审 P2)。
for (const k of GAMEDEF_KEYS) {
if (cleaned[k] != null && !Array.isArray(cleaned[k])) {
errors.push(k + ' 非数组(应为数组,已忽略)');
cleaned[k] = [];
@ -148,6 +153,29 @@ export function validateSourceProject(sourceProject) {
errors.push(...scanLogic(cond, 'rule[' + ((r && r.id) || '?') + '].condition', true));
}
}
// assets(U1 引擎资产):schema 中 assets 在 **sourceProject 顶层**(line 37,与 gameDefinition 平级),
// 非 gameDefinition 属性(gameDefinition additionalProperties:false)——评审 P0 修:从顶层 sp.assets 抽,
// 非误入 GAMEDEF_KEYS(那会丢合规顶层 assets)。容差:sp 直传 gameDefinition 时回退 gd.assets。
// 校验后并入 cleaned.assets(=运行时 GameDefinition,供 gdef.assets→drawSprite 消费;非源 gameDefinition,无 schema 冲突)。
let assetsSrc = Array.isArray(sp.assets) ? sp.assets : (Array.isArray(gd.assets) ? gd.assets : null);
if (sp.assets != null && !Array.isArray(sp.assets)) {
errors.push('assets 非数组(应为数组,已忽略)');
assetsSrc = null;
}
if (assetsSrc) {
for (const a of assetsSrc) {
if (!a || a.id == null) { errors.push('asset 缺 id'); continue; }
const id = String(a.id);
if (!ASSET_CATEGORIES.has(String(a.category))) {
errors.push('asset[' + id + '] category 非六类(sprite/character/effect/scene/ui/music): ' + a.category);
}
// ref 须**非空字符串**:String({}) 非空会让对象 ref 绕过校验 → 运行时永远解析不到图源(占位)且零 repair 信号、模型死循环(评审)。
if (typeof a.ref !== 'string' || a.ref.trim() === '') {
errors.push('asset[' + id + '] ref 须非空字符串(资产引用,如素材 id/URL)');
}
}
cleaned.assets = assetsSrc; // 并入运行时 GameDefinition(供 gdef.assets → drawSprite)
}
return { ok: errors.length === 0, errors, gameDefinition: cleaned };
}

View File

@ -57,6 +57,48 @@ test('U2-① validate 揪结构/引用/缺逻辑错', () => {
assert.equal(validateSourceProject(null).ok, false);
});
/* U1 引擎资产(评审 P0 修):assets 在 sourceProject **顶层**(schema 真位)被抽取并入运行时 gameDefinition;六类/非空串 ref/非数组校验。 */
test('U1 assets 顶层抽取 + 六类/非串ref/非数组 校验', () => {
// schema 真位:assets 在 sourceProject 顶层(与 gameDefinition 平级,非内嵌)
const top = validateSourceProject({
gameDefinition: goldenSource().gameDefinition,
assets: [{ id: 'hero-img', category: 'sprite', ref: 'mat-001' }],
});
assert.equal(top.ok, true, '顶层合法 assets 应过: ' + top.errors.join('; '));
assert.ok(Array.isArray(top.gameDefinition.assets), '顶层 assets 应抽入运行时 gameDefinition.assets(供 gdef.assets 消费)');
assert.equal(top.gameDefinition.assets[0].ref, 'mat-001', 'ref 透传保真');
assert.equal(top.gameDefinition.assets[0].category, 'sprite', 'category 透传保真');
// 容差:sp 直传时 assets 内嵌 gameDefinition 也回退抽取(模型漂移兜底)
const nested = validateSourceProject({ gameDefinition: { ...goldenSource().gameDefinition, assets: [{ id: 'x', category: 'ui', ref: 'r' }] } });
assert.equal(nested.ok, true, '内嵌 assets 容差应过');
assert.equal(nested.gameDefinition.assets[0].id, 'x');
// 错误转 repair 信号:非六类 / 非空串 ref(含对象 ref 绕过) / 缺 id
const bad = validateSourceProject({
gameDefinition: goldenSource().gameDefinition,
assets: [
{ id: 'a1', category: 'weapon', ref: 'x' }, // 非六类
{ id: 'a2', category: 'sprite' }, // 缺 ref
{ id: 'a3', category: 'sprite', ref: { mat: 1 } }, // 对象 ref(String({}) 非空,旧逻辑会绕过)
{ category: 'music', ref: 'm' }, // 缺 id
],
});
assert.equal(bad.ok, false);
assert.ok(bad.errors.some((e) => e.includes('非六类')), '应揪非六类 category');
assert.ok(bad.errors.filter((e) => e.includes('ref 须非空字符串')).length >= 2, '应揪缺 ref + 对象 ref 两条(非串防绕过)');
assert.ok(bad.errors.some((e) => e.includes('asset 缺 id')), '应揪缺 id');
// assets 非数组 → repair 信号
const notArr = validateSourceProject({ gameDefinition: goldenSource().gameDefinition, assets: { id: 'x' } });
assert.equal(notArr.ok, false);
assert.ok(notArr.errors.some((e) => e.includes('assets 非数组')), '应揪 assets 非数组');
// 端到端:合法顶层 assets 经 assemble 序列化进 GAME_DEFINITION(证 gd-runtime 可消费)
const factory = assembleFactoryFromSource({ gameDefinition: goldenSource().gameDefinition, assets: [{ id: 'hero-img', category: 'sprite', ref: 'mat-001' }] });
assert.ok(factory.includes('mat-001'), 'assemble 应把 assets.ref 序列化进 GAME_DEFINITION');
});
/* ② 装配产物对齐装载契约 + node --check 语法过 ────────────────────────────── */
test('U2-② assemble 产物含装载契约要件 + node --check 过', () => {
const src = assembleFactoryFromSource(goldenSource());

View File

@ -26,6 +26,8 @@
/** 视口常量(与 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 = {
@ -71,6 +73,13 @@ export function createRuntime(boot, 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)
@ -356,6 +365,7 @@ export function createRuntime(boot, gameDefinition) {
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));
@ -367,6 +377,41 @@ export function createRuntime(boot, gameDefinition) {
}
}
}
/**
* 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 {

View File

@ -274,3 +274,85 @@ test('U3 clickable 内置点击派发', () => {
r.update(0.016);
assert.equal(r.state().score, 1, '点空白不计分');
});
/* U1 sprite 资产渲染:经 asset.ref 取 boot.assets 图源 drawImage;无图源/中间态/抛错/悬空ref → 降级类目色占位 rect。 */
test('U1 sprite 渲染(ref 键图源 drawImage / 多路降级占位)', () => {
const fakeImg = { __img: true };
// mock g:记 drawImage 入参 + fillRect 次数 + 每次 fillRect 时的 fillStyle(验占位类目色)。
const mkG = (rec) => {
let fill = '';
return {
font: '', textAlign: '',
get fillStyle() { return fill; }, set fillStyle(v) { fill = v; },
drawImage: (img, x, y, w, h) => { rec.drawImage.push({ img, x, y, w, h }); },
fillRect: () => { rec.fillRect++; rec.fillAt.push(fill); },
beginPath() {}, arc() {}, fill() {}, fillText() {},
};
};
const rec = () => ({ drawImage: [], fillRect: 0, fillAt: [] });
const gdef = {
components: [{ id: 'spr', kind: 'render', shape: 'sprite', asset: 'hero-img', w: 40, h: 40 }],
entities: [{ id: 'h', transform: { position: { x: 100, y: 200 } }, components: ['spr'] }],
assets: [{ id: 'hero-img', category: 'sprite', ref: 'mat-001' }],
};
// (a) 有图源(**按 asset.ref='mat-001' 键**,非 id)→ drawImage 居中一次
const recHit = rec();
const bootA = makeBoot(1, false).boot; bootA.assets = { 'mat-001': { image: fakeImg } };
const rA = createRuntime(bootA, gdef); rA.init();
assert.doesNotThrow(() => rA.render(mkG(recHit)));
assert.equal(recHit.drawImage.length, 1, '有图源 sprite 应 drawImage 一次');
assert.equal(recHit.drawImage[0].img, fakeImg);
assert.deepEqual({ x: recHit.drawImage[0].x, y: recHit.drawImage[0].y, w: recHit.drawImage[0].w, h: recHit.drawImage[0].h }, { x: 80, y: 180, w: 40, h: 40 }, '居中 drawImage');
// (b) 无 boot.assets → 占位、零 drawImage
const recNone = rec();
const rB = createRuntime(makeBoot(1, false).boot, gdef); rB.init();
assert.doesNotThrow(() => rB.render(mkG(recNone)));
assert.equal(recNone.drawImage.length, 0); assert.ok(recNone.fillRect >= 1, '无图源降级占位');
// (c) slot 存在但 image=null(中间态)→ 占位、零 drawImage
const recNull = rec();
const bootC = makeBoot(1, false).boot; bootC.assets = { 'mat-001': { image: null } };
const rC = createRuntime(bootC, gdef); rC.init();
assert.doesNotThrow(() => rC.render(mkG(recNull)));
assert.equal(recNull.drawImage.length, 0, 'image=null 不 drawImage'); assert.ok(recNull.fillRect >= 1, 'image=null 降级占位');
// (d) drawImage 抛错 → catch 兜底落占位、不连坐帧
const recThrow = rec();
const gD = mkG(recThrow); gD.drawImage = () => { throw new Error('decode fail'); };
const bootD = makeBoot(1, false).boot; bootD.assets = { 'mat-001': { image: fakeImg } };
const rD = createRuntime(bootD, gdef); rD.init();
assert.doesNotThrow(() => rD.render(gD), 'drawImage 抛错不应连坐渲染帧');
assert.ok(recThrow.fillRect >= 1, 'drawImage 抛错后落占位 fillRect');
// (e) 悬空 ref(render.asset 引用不存在的 asset id)→ 默认占位色 #cc66cc、不崩
const recDangling = rec();
const gdefDangling = { ...gdef, components: [{ id: 'spr', kind: 'render', shape: 'sprite', asset: 'nope', w: 40, h: 40 }] };
const rE = createRuntime(makeBoot(1, false).boot, gdefDangling); rE.init();
assert.doesNotThrow(() => rE.render(mkG(recDangling)));
assert.equal(recDangling.drawImage.length, 0); assert.ok(recDangling.fillAt.includes('#cc66cc'), '悬空 ref 占位用默认色 #cc66cc');
// (f) music 类目占位色独立(评审 P2):sprite render 引 music 资产、无图源 → 占位 #aa66aa
const recMusic = rec();
const gdefMusic = {
components: [{ id: 'sm', kind: 'render', shape: 'sprite', asset: 'bgm', w: 20, h: 20 }],
entities: [{ id: 'hm', transform: { position: { x: 50, y: 50 } }, components: ['sm'] }],
assets: [{ id: 'bgm', category: 'music', ref: 'mat-music' }],
};
const rF = createRuntime(makeBoot(1, false).boot, gdefMusic); rF.init();
assert.doesNotThrow(() => rF.render(mkG(recMusic)));
assert.ok(recMusic.fillAt.includes('#aa66aa'), 'music 类目占位色 #aa66aa(与 sprite default 区分)');
// (g) asset:0 falsy 边界(!= null 应解析,非当缺省)→ 经 ref 命中图源 drawImage
const recFalsy = rec();
const gdef0 = {
components: [{ id: 's0', kind: 'render', shape: 'sprite', asset: 0, w: 20, h: 20 }],
entities: [{ id: 'h0', transform: { position: { x: 50, y: 50 } }, components: ['s0'] }],
assets: [{ id: 0, category: 'sprite', ref: 'r0' }],
};
const boot0 = makeBoot(1, false).boot; boot0.assets = { r0: { image: fakeImg } };
const r0 = createRuntime(boot0, gdef0); r0.init();
assert.doesNotThrow(() => r0.render(mkG(recFalsy)));
assert.equal(recFalsy.drawImage.length, 1, 'asset:0 应解析(!= null)并经 ref 命中图源');
});

View File

@ -23,11 +23,14 @@ export type Color = string | { r: number; g: number; b: number; a?: number };
export interface RenderComponent {
id?: string;
kind: 'render';
/** rect(缺省,以 transform 为中心)/ circle / fill(铺满 390×844 视口作背景)。 */
shape?: 'rect' | 'circle' | 'fill';
/** rect(缺省,以 transform 为中心)/ circle / fill(铺满 390×844 视口作背景)/ sprite(U1:引 asset 经 boot.assets 图源 drawImage,缺图降级占位)。 */
shape?: 'rect' | 'circle' | 'fill' | 'sprite';
color?: Color;
w?: number; h?: number; // rect
w?: number; h?: number; // rect / sprite(缺省 32)
r?: number; // circle
/** U1 sprite:引用 GameDefinition.assets[].id(canonical=asset;ref 为容差别名)。运行时经 asset.ref 查 boot.assets 图源。 */
asset?: string;
ref?: string;
}
/** 物理组件(opt-in):挂此组件的实体每帧 x+=vx*dt; y+=vy*dt(有 gravity 则先 vy+=gravity*dt)。 */
@ -95,12 +98,23 @@ export interface SceneDef {
entityRefs: string[];
}
/** 资产规格(U1·对齐 source-project.schema.json #/$defs/assetSpec)。schema 中 assets 位于 **sourceProject 顶层**(与 gameDefinition 平级);assembler 校验后抽入运行时 GameDefinition.assets 供渲染。 */
export interface AssetSpec {
id: string;
category: 'sprite' | 'character' | 'effect' | 'scene' | 'ui' | 'music';
ref: string; // 资产引用(素材 id / URL)= 宿主 boot.assets 装载键
url?: string;
provider?: string;
}
export interface GameDefinition {
entities: EntityDef[];
components?: Component[];
behaviors?: BehaviorDef[];
scenes?: SceneDef[];
rules?: RuleDef[];
/** U1 引擎资产:assembler 从 sourceProject 顶层 assets 抽入此处供运行时 gdef.assets 消费(非 schema gameDefinition 属性)。 */
assets?: AssetSpec[];
}
/* ──────────────────────────────────────────────────────────────────────────
@ -213,5 +227,5 @@ export interface Runtime {
* @param boot 宿主注入的受控启动上下文(含 ctx:PluginContext 受控面)。
* @param gameDefinition 声明式领域模型。
*/
export declare function createRuntime(boot: GameHostBootContext | { ctx: PluginContext; seed?: number; mainContext?: CanvasRenderingContext2D | null; canvas?: HTMLCanvasElement | null }, gameDefinition: GameDefinition): Runtime;
export declare function createRuntime(boot: GameHostBootContext | { ctx: PluginContext; seed?: number; mainContext?: CanvasRenderingContext2D | null; canvas?: HTMLCanvasElement | null; assets?: Record<string, { image?: CanvasImageSource }> }, gameDefinition: GameDefinition): Runtime;
export default createRuntime;