feat(adapter): E3 补齐顶层资产消费——scene 类 → 背景(url drawImage 满幅 / 默认底兜底)
对齐 contracts/agent-loop/source-project.schema.json 冻结 assetSpec(id/category/ref/url?/provider?),补齐 sprite 之外唯一的「顶层资产消费」缺口: - scene 类资产 = 关卡背景(非实体引用):取首个 category==='scene',有 url → 浏览器 new Image + render drawImage(0,0,满幅);node/无 url → 默认底色 #0d1117 兜底(spec-only) - 单测:① node 兜底不崩 + forensics assets[] 可观测 scene 类;② Image stub 验 url→drawImage 满幅 390×844 分支(非兜底) - README 增「六类资产消费」表:sprite/character/ui/effect 经 render 组件 .asset;scene 顶层背景;music 经 url 不可消费(受控面 getEngine().audio 仅 zzfx synth,无播文件音频能力——跨线契约缺口,已记) - README 记 KTD1 族边界:aim-fire/drag-aiming/match-3 的抛射物理/旋转瞄准/读盘消除属复杂 behavior 逻辑,归 code-agent __GameBundle,不入声明式适配器(避越界+过拟合) 验证:src 全量 196/196 绿(+2 scene 用例)。music 消费缺口与复杂族边界为跨线/架构事实,已在 README 落账供后端线/创始人定夺,未臆造消费路。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d45945af5b
commit
46aef4e11c
@ -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=辨别/规避)。
|
- **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 容差内 → 计分+目标重生=导航/收集)。
|
- **update op**(trigger='update'):`drift-targets`(目标慢漂移)· `fall-and-catch`(下落物+接住=控制类)· `flappy-gap`(重力+穿缝=flappy)· `reach-goal`(实体 x 抵达目标 x 容差内 → 计分+目标重生=导航/收集)。
|
||||||
- **rule**:`{ condition: "<state字段> <op> <数字|config.键>", outcome: win|lose }`(序前优先);胜负 → `phase=gameover` + **latch 驻留**。
|
- **rule**:`{ condition: "<state字段> <op> <数字|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 + 首局门)
|
## 已验证六族(均过真九门 A–I + 首局门)
|
||||||
|
|
||||||
|
|||||||
@ -193,6 +193,50 @@ test('E3:assets[] 入 forensics(通道可观测)+ spec-only 几何兜底不崩
|
|||||||
assert.ok(g._rec.fillRects.length >= 5, '几何兜底仍画目标');
|
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 门) ──
|
// ── 控制类 archetype(paddle-intercept 族:连续控制 + 下落接住,exercises I 门) ──
|
||||||
test('控制类:forensics 暴露 paddle/ball(供 paddle-intercept driver)', () => {
|
test('控制类:forensics 暴露 paddle/ball(供 paddle-intercept driver)', () => {
|
||||||
const game = adaptGameDefinition(CATCH_SOURCE)();
|
const game = adaptGameDefinition(CATCH_SOURCE)();
|
||||||
|
|||||||
@ -70,6 +70,10 @@ export function adaptGameDefinition(source) {
|
|||||||
// E3:SourceProject.assets[](§5.1 顶层,六类 assetSpec);render 组件经 .asset 引用其 id。
|
// E3:SourceProject.assets[](§5.1 顶层,六类 assetSpec);render 组件经 .asset 引用其 id。
|
||||||
const assets = Array.isArray(src.assets) ? src.assets : (Array.isArray(gd.assets) ? gd.assets : []);
|
const assets = Array.isArray(src.assets) ? src.assets : (Array.isArray(gd.assets) ? gd.assets : []);
|
||||||
const assetById = new Map(assets.map((a) => [a.id, a]));
|
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]));
|
const compById = new Map(components.map((c) => [c.id, c]));
|
||||||
// 激活场景 = 首个 scene(无则全实体);激活实体按 entityRefs 过滤。
|
// 激活场景 = 首个 scene(无则全实体);激活实体按 entityRefs 过滤。
|
||||||
@ -93,6 +97,7 @@ export function adaptGameDefinition(source) {
|
|||||||
let ctx = null;
|
let ctx = null;
|
||||||
let sub = null;
|
let sub = null;
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
|
let bgImg = null; // E3:scene 类背景图(有 url 时浏览器侧 new Image;node/无 url → null 走默认底色)
|
||||||
// 运行态:目标(可点击实体)+ 计分 + phase(booting|playing|gameover,对齐 P0 latch 终态)。
|
// 运行态:目标(可点击实体)+ 计分 + phase(booting|playing|gameover,对齐 P0 latch 终态)。
|
||||||
/** @type {{id:string,x:number,y:number,w:number,h:number,color:string,hit:boolean}[]} */
|
/** @type {{id:string,x:number,y:number,w:number,h:number,color:string,hit:boolean}[]} */
|
||||||
let targets = [];
|
let targets = [];
|
||||||
@ -102,6 +107,10 @@ export function adaptGameDefinition(source) {
|
|||||||
/** init:建目标、订阅受控输入、置 playing。 */
|
/** init:建目标、订阅受控输入、置 playing。 */
|
||||||
function init(boot) {
|
function init(boot) {
|
||||||
ctx = boot.ctx;
|
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) => {
|
targets = activeEntities.map((e, i) => {
|
||||||
const rc = renderCompOf(e) || {};
|
const rc = renderCompOf(e) || {};
|
||||||
const pos = (e.transform && e.transform.position) || { x: 0, y: 0 };
|
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 门活性)+ 终态字。 */
|
/** render:画到入参 g(=mainContext)。满幅底(D 门)+ 目标(命中变暗)+ 计分 HUD + 时间脉冲(E 门活性)+ 终态字。 */
|
||||||
function render(g) {
|
function render(g) {
|
||||||
g.fillStyle = '#0d1117';
|
// E3:scene 背景图已加载 → 画满幅;否则默认底色兜底(满幅底=D 门;spec-only 无 url 时走此路)。
|
||||||
g.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H);
|
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)。
|
// 时间脉冲条:保证无输入时帧间也有变化(E 门活性,走 ctx.time 非 Date.now)。
|
||||||
const pulse = 0.5 + 0.5 * Math.sin(state.t * 2);
|
const pulse = 0.5 + 0.5 * Math.sin(state.t * 2);
|
||||||
g.fillStyle = 'rgba(80,160,255,' + (0.25 + 0.5 * pulse).toFixed(3) + ')';
|
g.fillStyle = 'rgba(80,160,255,' + (0.25 + 0.5 * pulse).toFixed(3) + ')';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user