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>
This commit is contained in:
lili 2026-06-19 01:32:07 -07:00
parent 73d3391696
commit db14845d12
7 changed files with 180 additions and 79 deletions

View File

@ -21,16 +21,19 @@ import type { PluginContext } from './api.d.ts';
* mainContext****
* PluginContext.getContext2d
* - seedcanvas rect real= mainCanvas
* - assetsU1 additive·宿 init assetId {image}
* init ref loadAssets gd-runtime shape:'sprite' drawImage
* 宿 wire / boot
* - assetsU1 additive·宿 init ** asset.ref **= id/URL
* asset.idgd-runtime drawSprite renderasset.iddef.refboot.assets[ref]
* init ref loadAssets 宿 wire /
* boot
* **HOST TODOplan 2026-06-19-001 U1 wiring**boot-game-host setupCommon loadAssets
* asset.ref wire sprite drawImage
*/
export interface GameHostBootContext {
ctx: PluginContext; // 下层已冻结受控面(复用)
mainContext: CanvasRenderingContext2D; // 引擎 mainContextboot 注入render 绘制面)
canvas: HTMLCanvasElement; // 可见画布real=引擎 mainCanvas输入 rect 映射用)
seed: number; // 主随机种子
assets?: Record<string, { image?: CanvasImageSource }>; // U1 additive宿主预载资产图源assetId→{image});缺=sprite 降级占位
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 TODOplan 2026-06-19-001 U1 剩余 wiringU1 已建运行时 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

@ -29,9 +29,10 @@
'use strict';
/** gameDefinition schema#/properties/gameDefinition
* assets = U1 引擎资产渲染纳入原缺 line 101 strip = 六类资产产物永不被消费的死代码,评审 P0 */
const GAMEDEF_KEYS = ['entities', 'components', 'behaviors', 'scenes', 'rules', 'assets'];
/** 六类资产类目(冻结,对齐 source-project.schema.json#/assets/category + game-material V24 + studio.yaml。 */
* 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';
@ -104,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', 'assets']) {
// 复用 GAMEDEF_KEYS成员皆数组类免「白名单/数组化」两处字面量漂移 = 本次 P0(漏加一处)同类隐患(评审 P2)。
for (const k of GAMEDEF_KEYS) {
if (cleaned[k] != null && !Array.isArray(cleaned[k])) {
errors.push(k + ' 非数组(应为数组,已忽略)');
cleaned[k] = [];
@ -151,17 +153,28 @@ export function validateSourceProject(sourceProject) {
errors.push(...scanLogic(cond, 'rule[' + ((r && r.id) || '?') + '].condition', true));
}
}
// assetsU1 引擎资产·可选present 时逐条校 id/category(六类)/refmalformed 转 repair 信号;
// 缺 assets 整体跳过 = 无美术游戏合法)。引用完整性(render→asset)由运行时降级兜底,此处不强校。
for (const a of (Array.isArray(cleaned.assets) ? cleaned.assets : [])) {
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);
}
if (a.ref == null || String(a.ref).trim() === '') {
errors.push('asset[' + id + '] 缺 ref资产引用,如素材 id/URL');
// assetsU1 引擎资产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,28 +57,46 @@ test('U2-① validate 揪结构/引用/缺逻辑错', () => {
assert.equal(validateSourceProject(null).ok, false);
});
/* U1 引擎资产assets 进白名单不被 strip死代码修+ 六类/ref 校验转 repair 信号。 */
test('U1 assets 存活白名单 + 六类/ref 校验', () => {
const withAssets = validateSourceProject({ gameDefinition: {
...goldenSource().gameDefinition,
/* 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(withAssets.ok, true, '带合法 assets 应过: ' + withAssets.errors.join('; '));
assert.ok(Array.isArray(withAssets.gameDefinition.assets), 'assets 应存活进 gameDefinition非被 strip');
assert.equal(withAssets.gameDefinition.assets[0].id, 'hero-img');
// 非六类 category + 缺 ref + 缺 id 各转 repair 信号
const bad = validateSourceProject({ gameDefinition: {
...goldenSource().gameDefinition,
});
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
{ category: 'music', ref: 'm' }, // 缺 id
{ id: 'a1', category: 'weapon', ref: 'x' }, // 非六类
{ id: 'a2', category: 'sprite' }, // 缺 ref
{ id: 'a3', category: 'sprite', ref: { mat: 1 } }, // 对象 refString({}) 非空,旧逻辑会绕过)
{ category: 'music', ref: 'm' }, // 缺 id
],
} });
});
assert.equal(bad.ok, false);
assert.ok(bad.errors.some((e) => e.includes('非六类')), '应揪非六类 category');
assert.ok(bad.errors.some((e) => e.includes('ref')), '应揪缺 ref');
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 语法过 ────────────────────────────── */

View File

@ -26,6 +26,8 @@
/** 视口常量(与 generic-host-config 的 390×844 移动竖屏基准同口径behaviors 边界判断 / 渲染兜底用)。 */
const VIEW_W = 390;
const VIEW_H = 844;
/** sprite 缺省尺寸U1·略大于 rect 缺省 20sprite 通常占位更大;评审 P3 具名化免魔法数)。 */
const DEFAULT_SPRITE_SIZE = 32;
/** rt.fx.beep 音效参数包ZzFX 参数;按 kind 取,缺省走 default。仅为「真调引擎合成核」满 F 门,不强求好听。 */
const BEEP_PARAMS = {
@ -376,33 +378,38 @@ export function createRuntime(boot, gameDefinition) {
}
}
/**
* U1 sprite 渲染render 组件 shape:'sprite' asset/ref/sprite 解资产 宿主预载图源 drawImage居中,w/h 缺省 32
* 受控面铁律图源**只取 boot.assets**宿主 init 预载,game-host.d.ts additive运行时不触 new Image/DOM
* 无图源宿主预载未 wire / 资产未载 降级类目色占位 rect保实体可见不崩不空屏前向兼容,宿主预载落地后自动转真图
* U1 sprite 渲染render 组件 shape:'sprite' 引用资产 asset.ref 取宿主预载图源 drawImage居中
* 解析链评审 id/ref key 空间歧义c.asset(canonicalc.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 : 32));
const h = num(c.h != null ? c.h : (c.height != null ? c.height : 32));
const ref = c.asset != null ? c.asset : (c.ref != null ? c.ref : c.sprite);
const slot = (bootAssets && ref != null) ? bootAssets[String(ref)] : null;
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); // 引用资产 idcanonical=assetref 为容差别名)
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 时):类目色 rect保可见。
const def = (ref != null) ? assetDefs.get(String(ref)) : null;
// 降级占位(宿主预载未 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';
default: return '#cc66cc'; // sprite / 未知类目
case 'music': return '#aa66aa'; // music 本不应入 render 组件;给独立色便于辨误用(评审 P2
default: return '#cc66cc'; // sprite / 未知类目
}
}
function drawHud(g) {

View File

@ -275,41 +275,84 @@ test('U3 clickable 内置点击派发', () => {
assert.equal(r.state().score, 1, '点空白不计分');
});
/* U1 sprite 资产渲染boot.assets 有图源 → drawImage 居中;无图源 → 降级类目色占位 rect、不抛、不 drawImage。 */
test('U1 sprite 渲染(有图源 drawImage / 无图源占位)', () => {
/* 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) 有宿主预载图源 → g.drawImage 居中调用一次
const fakeImg = { __img: true };
const aCalls = { drawImage: [] };
const gA = {
fillStyle: '', font: '', textAlign: '',
drawImage: (img, x, y, w, h) => aCalls.drawImage.push({ img, x, y, w, h }),
fillRect: () => {}, beginPath: () => {}, arc: () => {}, fill: () => {}, fillText: () => {},
};
const { boot: bootA } = makeBoot(1, false);
bootA.assets = { 'hero-img': { image: fakeImg } }; // additive boot.assets宿主预载
const rA = createRuntime(bootA, gdef); rA.init();
assert.doesNotThrow(() => rA.render(gA));
assert.equal(aCalls.drawImage.length, 1, '有图源 sprite 应 drawImage 一次');
assert.equal(aCalls.drawImage[0].img, fakeImg, '应画宿主预载图源');
assert.deepEqual(
{ x: aCalls.drawImage[0].x, y: aCalls.drawImage[0].y, w: aCalls.drawImage[0].w, h: aCalls.drawImage[0].h },
{ x: 80, y: 180, w: 40, h: 40 }, '居中 drawImage(x-w/2, y-h/2, w, h)');
// (b) 无图源(宿主预载未 wire→ 降级占位 fillRect、零 drawImage、不抛
const bCalls = { drawImage: 0, fillRect: 0 };
const gB = {
fillStyle: '', font: '', textAlign: '',
drawImage: () => { bCalls.drawImage++; }, fillRect: () => { bCalls.fillRect++; },
beginPath: () => {}, arc: () => {}, fill: () => {}, fillText: () => {},
// (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) 悬空 refrender.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 类目占位色独立(评审 P2sprite 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 { boot: bootB } = makeBoot(1, false); // 无 boot.assets
const rB = createRuntime(bootB, gdef); rB.init();
assert.doesNotThrow(() => rB.render(gB));
assert.equal(bCalls.drawImage, 0, '无图源不应 drawImage');
assert.ok(bCalls.fillRect >= 1, '无图源应降级占位 fillRect保实体可见');
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 视口作背景)/ spriteU1引 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[].idcanonical=assetref 为容差别名)。运行时经 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;