From db14845d128079e31026afc95bb79e1366660bf5 Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 19 Jun 2026 01:32:07 -0700 Subject: [PATCH] =?UTF-8?q?fix(review):=20U1=20=E8=B5=84=E4=BA=A7=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E2=80=94=E2=80=94ce-code-review=20=E6=95=B4=E6=94=B9?= =?UTF-8?q?=EF=BC=88P0=20assets=20=E5=B1=82=E7=BA=A7=20+=20P1=20=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=90=8C=E6=AD=A5=20+=20=E6=A0=A1=E9=AA=8C=E6=B4=9E?= =?UTF-8?q?=20+=20P2=20=E7=BE=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- game-runtime/src/core/game-host.d.ts | 11 +- game-runtime/src/host/boot-game-host.js | 3 + game-runtime/src/host/build-from-source.mjs | 41 ++++--- .../src/host/build-from-source.test.mjs | 50 ++++++--- game-runtime/src/host/gd-runtime.js | 29 +++-- game-runtime/src/host/gd-runtime.test.mjs | 103 +++++++++++++----- game-runtime/src/host/runtime-api-2d.d.ts | 22 +++- 7 files changed, 180 insertions(+), 79 deletions(-) diff --git a/game-runtime/src/core/game-host.d.ts b/game-runtime/src/core/game-host.d.ts index 3f82cb00..3722df25 100644 --- a/game-runtime/src/core/game-host.d.ts +++ b/game-runtime/src/core/game-host.d.ts @@ -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; // U1 additive:宿主预载资产图源(assetId→{image});缺=sprite 降级占位 + assets?: Record; // U1 additive:宿主预载资产图源(**按 asset.ref 键**→{image});缺=sprite 降级占位;HOST TODO 见上 } /** diff --git a/game-runtime/src/host/boot-game-host.js b/game-runtime/src/host/boot-game-host.js index 9339dc2a..63394f6c 100644 --- a/game-runtime/src/host/boot-game-host.js +++ b/game-runtime/src/host/boot-game-host.js @@ -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, diff --git a/game-runtime/src/host/build-from-source.mjs b/game-runtime/src/host/build-from-source.mjs index c9be8c0c..808d4dc3 100644 --- a/game-runtime/src/host/build-from-source.mjs +++ b/game-runtime/src/host/build-from-source.mjs @@ -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// 下三级回 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 }; } diff --git a/game-runtime/src/host/build-from-source.test.mjs b/game-runtime/src/host/build-from-source.test.mjs index 14df70d4..6cef6c08 100644 --- a/game-runtime/src/host/build-from-source.test.mjs +++ b/game-runtime/src/host/build-from-source.test.mjs @@ -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 语法过 ────────────────────────────── */ diff --git a/game-runtime/src/host/gd-runtime.js b/game-runtime/src/host/gd-runtime.js index 42c1982a..cd50b176 100644 --- a/game-runtime/src/host/gd-runtime.js +++ b/game-runtime/src/host/gd-runtime.js @@ -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) { diff --git a/game-runtime/src/host/gd-runtime.test.mjs b/game-runtime/src/host/gd-runtime.test.mjs index 4a0b6ce6..d8eb81d4 100644 --- a/game-runtime/src/host/gd-runtime.test.mjs +++ b/game-runtime/src/host/gd-runtime.test.mjs @@ -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 命中图源'); }); diff --git a/game-runtime/src/host/runtime-api-2d.d.ts b/game-runtime/src/host/runtime-api-2d.d.ts index e959844d..540149af 100644 --- a/game-runtime/src/host/runtime-api-2d.d.ts +++ b/game-runtime/src/host/runtime-api-2d.d.ts @@ -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 }, gameDefinition: GameDefinition): Runtime; export default createRuntime;