diff --git a/game-runtime/src/adapter/README.md b/game-runtime/src/adapter/README.md index 9002b5ca..9120ccc1 100644 --- a/game-runtime/src/adapter/README.md +++ b/game-runtime/src/adapter/README.md @@ -24,10 +24,21 @@ - **input op**(behavior.trigger='input'):`hit-nearest-target`(点最近目标计分)· `control-x`(实体 x 跟手=连续控制)· `flap`(竖直上冲)· `move-lr`(点左/右半 → 实体左/右移一步=方向式 steer)· `tap-safe`(点 safe 目标计分、点 `.hazards` 列出的 hazard 即记 miss=辨别/规避)。 - **update op**(trigger='update'):`drift-targets`(目标慢漂移)· `fall-and-catch`(下落物+接住=控制类)· `flappy-gap`(重力+穿缝=flappy)· `reach-goal`(实体 x 抵达目标 x 容差内 → 计分+目标重生=导航/收集)。 - **rule**:`{ condition: " <数字|config.键>", outcome: win|lose }`(序前优先);胜负 → `phase=gameover` + **latch 驻留**。 -- **assets[]**(六类 sprite/character/effect/scene/ui/music):render 组件经 `.asset` 引用;有真 url → 浏览器 `new Image`+drawImage,否则几何兜底(§5.5 MVP spec-only)。 -- **命中/接住/穿缝/胜负事件**经 `ctx.getEngine()` 发粒子+音效(满足九门 F 真接线 + game-feel)。 +- **命中/接住/穿缝/抵达/胜负事件**经 `ctx.getEngine()` 发粒子+音效(满足九门 F 真接线 + game-feel)。 -> **边界(KTD1)**:适配器只解释**声明式**部分;复杂 behavior 真逻辑由核心 code agent 产、经 build.mjs 编进 `__GameBundle`(非适配器解释)。适配器覆盖**简单声明式游戏的主要机制族**。 +## 六类资产消费(E3,对齐 `contracts/agent-loop/source-project.schema.json` 冻结 assetSpec) + +assetSpec = `{ id, category∈[sprite,character,effect,scene,ui,music], ref, url?, provider? }`——**引用/url,无内联数据**。消费侧(引擎线)按类: + +| 类 | 消费路径 | 兜底(spec-only 无 url) | +|---|---|---| +| `sprite` / `character` | 实体 render 组件经 `.asset` 引用 → 有 url 浏览器 `new Image`+drawImage | 几何矩形(命中变暗) | +| `ui` / `effect` | 同上(经 render 组件)/ effect 亦由命中粒子反馈体现 | 几何 / 粒子 | +| `scene` | **顶层背景**(非实体引用):取首个 `category==='scene'`,有 url → drawImage 满幅 | 默认底色 `#0d1117` | +| `music` | **经 url 不可消费**——冻结受控面 `getEngine().audio` 仅 zzfx `synthSfx/synthSong`(合成,非文件播放),无播 url 音频能力 | 静默(跨线契约缺口:需 engine `audio.playUrl` 或 music assetSpec 携 zzfx 歌数据;见 `src/core/api.d.ts`) | + +> **边界(KTD1)**:适配器只解释**声明式**部分;复杂 behavior 真逻辑由核心 code agent 产、经 build.mjs 编进 `__GameBundle`(非适配器解释)。适配器覆盖**简单声明式游戏的主要机制族**(已 6 族,见下)。 +> **更复杂族(aim-fire 抛射瞄准 / drag-aiming 拖拽弹弓 / match-3 消除)**:虽受控面有 keydown/keyup/pointermove/pointerup 足以接其输入,但其抛射物理/旋转瞄准/读盘消除是**复杂 behavior 逻辑**——按 KTD1 归 code-agent `__GameBundle`,**不入声明式适配器**(强行内联=越界 + 过拟合)。 ## 已验证六族(均过真九门 A–I + 首局门) diff --git a/game-runtime/src/adapter/adapter.test.mjs b/game-runtime/src/adapter/adapter.test.mjs index 13bacb7d..6ee4892d 100644 --- a/game-runtime/src/adapter/adapter.test.mjs +++ b/game-runtime/src/adapter/adapter.test.mjs @@ -193,6 +193,50 @@ test('E3:assets[] 入 forensics(通道可观测)+ spec-only 几何兜底不崩 assert.ok(g._rec.fillRects.length >= 5, '几何兜底仍画目标'); }); +test('E3:scene 类资产 → 背景消费(node/无 url 默认底兜底不崩)+ assets[] 可观测 scene 类', () => { + const src = { + gameDefinition: { + entities: [{ id: 't', transform: { position: { x: 100, y: 200 } }, components: ['r'] }], + components: [{ id: 'r', kind: 'render', shape: 'rect', w: 50, h: 50, color: '#3cf' }], + behaviors: [{ id: 'b', trigger: 'input', op: 'hit-nearest-target' }], + scenes: [{ id: 's', entityRefs: ['t'] }], rules: [], + }, + assets: [{ id: 'bg1', category: 'scene', ref: 'asset://bg1', url: 'https://example.com/bg.png' }], + config: {}, + }; + const game = adaptGameDefinition(src)(); + const { boot, g } = makeBoot(); + game.init(boot); + // node 无 Image → bgImg null → 默认底色兜底:render 不崩、仍画满幅底(D 门)。 + assert.doesNotThrow(() => game.render(g)); + assert.ok(g._rec.fillRects.length >= 1, '默认底色兜底仍画满幅'); + assert.ok(game._forensicsView().state().assets.some((a) => a.category === 'scene'), 'forensics assets[] 应含 scene 类'); +}); + +test('E3:scene 背景有 url + 已加载 → render 走 drawImage 满幅(浏览器路;Image stub 验分支)', () => { + // node 无 Image → 以最小 Image stub 模拟浏览器侧已加载图,验 render 走 drawImage(0,0,满幅) 分支(非兜底)。 + const prevImage = globalThis.Image; + globalThis.Image = class { constructor() { this.complete = true; this.naturalWidth = 64; this.naturalHeight = 64; } set src(_v) {} }; + try { + const src = { + gameDefinition: { entities: [], components: [], behaviors: [], scenes: [], rules: [] }, + assets: [{ id: 'bg1', category: 'scene', ref: 'asset://bg1', url: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }], + config: {}, + }; + const game = adaptGameDefinition(src)(); + const { boot } = makeBoot(); + const g = recordingCtx2d(); + const drawn = []; + g.drawImage = (_img, x, y, w, h) => drawn.push({ x, y, w, h }); + game.init({ ctx: boot.ctx, mainContext: g, canvas: {}, seed: 1 }); + game.render(g); + assert.equal(drawn.length, 1, '背景应 drawImage 一次'); + assert.deepEqual(drawn[0], { x: 0, y: 0, w: 390, h: 844 }, '背景应画满幅 390×844'); + } finally { + globalThis.Image = prevImage; // 还原全局,勿污染其它用例 + } +}); + // ── 控制类 archetype(paddle-intercept 族:连续控制 + 下落接住,exercises I 门) ── test('控制类:forensics 暴露 paddle/ball(供 paddle-intercept driver)', () => { const game = adaptGameDefinition(CATCH_SOURCE)(); diff --git a/game-runtime/src/adapter/create-game-from-definition.js b/game-runtime/src/adapter/create-game-from-definition.js index 569c656a..0de9c18c 100644 --- a/game-runtime/src/adapter/create-game-from-definition.js +++ b/game-runtime/src/adapter/create-game-from-definition.js @@ -70,6 +70,10 @@ export function adaptGameDefinition(source) { // E3:SourceProject.assets[](§5.1 顶层,六类 assetSpec);render 组件经 .asset 引用其 id。 const assets = Array.isArray(src.assets) ? src.assets : (Array.isArray(gd.assets) ? gd.assets : []); const assetById = new Map(assets.map((a) => [a.id, a])); + // E3:scene 类资产 = 关卡背景(非实体引用,取首个 category==='scene')。有 url → 浏览器 new Image 画满幅; + // 否则默认底色兜底(§5.5 MVP spec-only)。这是 sprite/character/ui/effect(经 render 组件 .asset 消费)之外 + // 唯一的「顶层资产消费」缺口——补齐后六类资产消费侧对齐冻结契约(music 经 url 不可消费,见 README 受控面说明)。 + const sceneAssetSpec = assets.find((a) => a && a.category === 'scene') || null; const compById = new Map(components.map((c) => [c.id, c])); // 激活场景 = 首个 scene(无则全实体);激活实体按 entityRefs 过滤。 @@ -93,6 +97,7 @@ export function adaptGameDefinition(source) { let ctx = null; let sub = null; let nowMs = 0; + let bgImg = null; // E3:scene 类背景图(有 url 时浏览器侧 new Image;node/无 url → null 走默认底色) // 运行态:目标(可点击实体)+ 计分 + phase(booting|playing|gameover,对齐 P0 latch 终态)。 /** @type {{id:string,x:number,y:number,w:number,h:number,color:string,hit:boolean}[]} */ let targets = []; @@ -102,6 +107,10 @@ export function adaptGameDefinition(source) { /** init:建目标、订阅受控输入、置 playing。 */ function init(boot) { ctx = boot.ctx; + // E3:预载 scene 背景图(有 url 才在浏览器侧 new Image;node 无 Image / 无 url → bgImg 保持 null,render 走默认底色)。 + if (sceneAssetSpec && sceneAssetSpec.url && typeof Image !== 'undefined') { + try { bgImg = new Image(); bgImg.src = sceneAssetSpec.url; } catch (_) { bgImg = null; } + } targets = activeEntities.map((e, i) => { const rc = renderCompOf(e) || {}; const pos = (e.transform && e.transform.position) || { x: 0, y: 0 }; @@ -300,8 +309,13 @@ export function adaptGameDefinition(source) { /** render:画到入参 g(=mainContext)。满幅底(D 门)+ 目标(命中变暗)+ 计分 HUD + 时间脉冲(E 门活性)+ 终态字。 */ function render(g) { - g.fillStyle = '#0d1117'; - g.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H); + // E3:scene 背景图已加载 → 画满幅;否则默认底色兜底(满幅底=D 门;spec-only 无 url 时走此路)。 + if (bgImg && bgImg.complete && bgImg.naturalWidth > 0) { + try { g.drawImage(bgImg, 0, 0, VIEWPORT_W, VIEWPORT_H); } catch (_) { g.fillStyle = '#0d1117'; g.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H); } + } else { + g.fillStyle = '#0d1117'; + g.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H); + } // 时间脉冲条:保证无输入时帧间也有变化(E 门活性,走 ctx.time 非 Date.now)。 const pulse = 0.5 + 0.5 * Math.sin(state.t * 2); g.fillStyle = 'rgba(80,160,255,' + (0.25 + 0.5 * pulse).toFixed(3) + ')';