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:
parent
73d3391696
commit
db14845d12
11
game-runtime/src/core/game-host.d.ts
vendored
11
game-runtime/src/core/game-host.d.ts
vendored
@ -21,16 +21,19 @@ import type { PluginContext } from './api.d.ts';
|
||||
* 游戏美术画在此面 → 与引擎粒子(引擎自渲落同一 mainContext)同屏。**此字段是装载契约的核心**
|
||||
* (游戏取引擎绘制面的唯一约定,不借 PluginContext.getContext2d——那是插件面)。
|
||||
* - seed:主随机种子(确定性);canvas:可见画布引用(输入 rect 映射用,real=引擎 mainCanvas)。
|
||||
* - assets(U1 additive·可选):宿主 init 预载的资产图源(assetId → {image}),落实本接口
|
||||
* init 注释「装资产(ref loadAssets 异步)」的意图。gd-runtime 的 shape:'sprite' 渲染经此取图 drawImage;
|
||||
* 缺省(宿主预载未 wire / 无美术游戏)→ 运行时降级类目色占位,零破坏(既有 boot 不带此字段照常)。
|
||||
* - 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:宿主预载资产图源(assetId→{image});缺=sprite 降级占位
|
||||
assets?: Record<string, { image?: CanvasImageSource }>; // U1 additive:宿主预载资产图源(**按 asset.ref 键**→{image});缺=sprite 降级占位;HOST TODO 见上
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
// assets(U1 引擎资产·可选):present 时逐条校 id/category(六类)/ref(malformed 转 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)');
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@ -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 } }, // 对象 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.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 语法过 ────────────────────────────── */
|
||||
|
||||
@ -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 = {
|
||||
@ -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(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 : 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); // 引用资产 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 时):类目色 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) {
|
||||
|
||||
@ -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) 悬空 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 { 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 命中图源');
|
||||
});
|
||||
|
||||
22
game-runtime/src/host/runtime-api-2d.d.ts
vendored
22
game-runtime/src/host/runtime-api-2d.d.ts
vendored
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user