From 5164f28f6933bc2bd2823404edaa02e8f2ce8ba6 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 14:40:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(adapter):=20E1=20=E7=AC=AC=E5=9B=9B?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E6=97=8F=20flappy=E2=80=94=E2=80=94flap=20+?= =?UTF-8?q?=20flappy-gap,=E7=9C=9F=E4=B9=9D=E9=97=A8=E7=BB=BF(flap-to-gap?= =?UTF-8?q?=20driver)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flappy/runner 族:重力下落 + 点击振翅(flap input op,vy 上冲)+ 单缝隙左滚穿缝判定(flappy-gap update op: 重力推进 bird、缝隙越过 bird.x → 在缝内计分+穿缝反馈(粒子/音效)否则漏接、回收缝隙、出界=漏+复位)。 - forensics additive 暴露 bird{x,y,vy}/nextGap{centerY}(供 flap-to-gap driver 速度前瞻穿缝)。 - flappy.source.json fixture;单测 +3(forensics/flap 上冲/重力+穿缝机制),共 27 adapter。 - e2e(本机):scaffoldFromSource 落 flappy → build → 真九门 A–I 全过(score 0→2 latch、flap-to-gap 导航穿缝、F 门反馈);瞬时件已清。 - 全 game-runtime 套件 188/188 绿。 证成:E1 适配器覆盖四大游戏族(静态点击/realtime 漂移/控制类 paddle-intercept/flappy 物理)均过真九门 (含 I 门),声明式适配器跨游戏族泛化稳固。 Co-Authored-By: Claude Opus 4.8 --- game-runtime/src/adapter/adapter.test.mjs | 29 ++++++++++++ .../adapter/create-game-from-definition.js | 46 +++++++++++++++++-- .../src/adapter/fixtures/flappy.source.json | 27 +++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 game-runtime/src/adapter/fixtures/flappy.source.json diff --git a/game-runtime/src/adapter/adapter.test.mjs b/game-runtime/src/adapter/adapter.test.mjs index 7b61d5df..e25abab6 100644 --- a/game-runtime/src/adapter/adapter.test.mjs +++ b/game-runtime/src/adapter/adapter.test.mjs @@ -25,6 +25,7 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'tap-target.source.json'), 'utf8')); const DRIFT_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'moving-targets.source.json'), 'utf8')); const CATCH_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'catch.source.json'), 'utf8')); +const FLAPPY_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'flappy.source.json'), 'utf8')); /** 录制版 2D 上下文:记 fillRect/fillText 调用,setter 容纳 fillStyle/font/strokeStyle 赋值。 */ function recordingCtx2d() { @@ -227,6 +228,34 @@ test('控制类:paddle 对齐下落球 → 接住计分', () => { assert.ok(game._forensicsView().state().score >= 1, '对齐后应接住至少 1 次'); }); +// ── flappy(flap-to-gap 族:重力 + 振翅 + 穿缝,第 4 游戏族) ── +test('flappy:forensics 暴露 bird{y,vy} + nextGap{centerY}(供 flap-to-gap driver)', () => { + const game = adaptGameDefinition(FLAPPY_SOURCE)(); + const { boot } = makeBoot(); + game.init(boot); + const v = game._forensicsView().state(); + assert.ok(v.bird && typeof v.bird.y === 'number' && typeof v.bird.vy === 'number', '应暴露 bird.y/vy'); + assert.ok(v.nextGap && typeof v.nextGap.centerY === 'number', '应暴露 nextGap.centerY'); +}); + +test('flappy:flap 给 bird 向上冲量(vy 变负)', () => { + const game = adaptGameDefinition(FLAPPY_SOURCE)(); + const { boot, inputBridge } = makeBoot(); + game.init(boot); + game.update(0.05); // 先因重力 vy>0 + inputBridge._emit('pointerdown', { x: 195, y: 400 }); + assert.ok(game._forensicsView().state().bird.vy < 0, 'flap 后 vy 应为负(上冲)'); +}); + +test('flappy:重力下落 + 缝隙滚过 → 穿缝计分或撞漏(机制生效)', () => { + const game = adaptGameDefinition(FLAPPY_SOURCE)(); + const { boot } = makeBoot(); + game.init(boot); + for (let i = 0; i < 60; i++) game.update(0.05); // 推进:bird 落 + 缝隙滚过 bird.x + const v = game._forensicsView().state(); + assert.ok(v.score + v.misses >= 1, '缝隙至少越过一次(穿缝计分或撞=漏接)'); +}); + // ── 鲁棒性:便宜模型产的 gameDefinition 常残缺/畸形,生产路适配器须优雅降级不崩 ── test('鲁棒:空 gameDefinition → 不崩、phase playing、可渲染', () => { const game = adaptGameDefinition({})(); diff --git a/game-runtime/src/adapter/create-game-from-definition.js b/game-runtime/src/adapter/create-game-from-definition.js index df36696b..4ddfc000 100644 --- a/game-runtime/src/adapter/create-game-from-definition.js +++ b/game-runtime/src/adapter/create-game-from-definition.js @@ -19,9 +19,9 @@ const VIEWPORT_W = 390; const VIEWPORT_H = 844; /** 最小 behavior 词汇表(声明式路支持的 op;其余 op = 走 code-agent 逻辑码,本切片不解释)。 */ -const SUPPORTED_INPUT_OPS = new Set(['hit-nearest-target', 'control-x']); -/** trigger=update 的 op(drift-targets:目标慢漂移;fall-and-catch:下落物 + 接住判定 = 控制类 archetype)。 */ -const SUPPORTED_UPDATE_OPS = new Set(['drift-targets', 'fall-and-catch']); +const SUPPORTED_INPUT_OPS = new Set(['hit-nearest-target', 'control-x', 'flap']); +/** trigger=update 的 op(drift-targets:漂移;fall-and-catch:接住=控制类;flappy-gap:重力+穿缝=flappy 类)。 */ +const SUPPORTED_UPDATE_OPS = new Set(['drift-targets', 'fall-and-catch', 'flappy-gap']); /** * 极小安全条件求值(无 eval):解析 " "。 @@ -111,6 +111,7 @@ export function adaptGameDefinition(source) { id: e.id, x: pos.x, y: pos.y, baseX: pos.x, baseY: pos.y, phase: i * 1.7, // drift-targets 用:漂移基准 + 错相 + vy: 0, // flappy/重力类用:竖直速度 w: typeof rc.w === 'number' ? rc.w : 48, h: typeof rc.h === 'number' ? rc.h : 48, color: typeof rc.color === 'string' ? rc.color : '#3cf', @@ -163,6 +164,11 @@ export function adaptGameDefinition(source) { const ent = entById(b.entity); if (ent) ent.x = Math.max(0, Math.min(VIEWPORT_W - ent.w, px - ent.w / 2)); } + if (b.op === 'flap') { + // flappy 类:点击给控制体向上竖直冲量(y 向下为正 → 上冲为负)。 + const ent = entById(b.entity); + if (ent) ent.vy = -(typeof b.impulse === 'number' ? b.impulse : 360); + } } } @@ -203,6 +209,31 @@ export function adaptGameDefinition(source) { } } } + if (b.op === 'flappy-gap') { + // flappy:bird 重力下落 + 缝隙左滚;缝隙越过 bird.x → 在缝内计分否则漏接,回收缝隙;出界=漏接+复位。 + const bird = entById(b.bird), gap = entById(b.gap); + if (bird && gap) { + bird.vy = (bird.vy || 0) + (typeof b.gravity === 'number' ? b.gravity : 1100) * dt; + bird.y += bird.vy * dt; + const gapH = typeof b.gapHeight === 'number' ? b.gapHeight : 200; + const birdCx = bird.x + bird.w / 2, prevCx = gap.x + gap.w / 2; + gap.x -= (typeof b.scrollSpeed === 'number' ? b.scrollSpeed : 150) * dt; + if (prevCx >= birdCx && gap.x + gap.w / 2 < birdCx) { // 缝隙越过 bird + if (Math.abs((bird.y + bird.h / 2) - (gap.y + gap.h / 2)) <= gapH / 2) { + state.score += 1; // 穿缝 + const eng = ctx && ctx.getEngine && ctx.getEngine(); // 穿缝反馈(F 门) + if (eng) { + if (eng.particles) { try { eng.particles.spawnEmitter({ pos: { x: birdCx, y: bird.y }, count: 16, speed: 100, particleTime: 0.45, colorStart: { r: 0.3, g: 1, b: 0.5, a: 1 }, colorEnd: { r: 0.1, g: 0.4, b: 0.2, a: 0 } }); } catch (_) { /* 不连坐 */ } } + if (eng.audio && eng.audio.synth) { try { eng.audio.synth.synthSfx([1, 0.05, 340, , , 0.13, , 1.1]); } catch (_) { /* 同 */ } } + } + } else { + state.misses += 1; // 撞 + } + } + if (gap.x + gap.w < 0) { gap.x = VIEWPORT_W; gap.y = (ctx && ctx.random) ? Math.floor(ctx.random.range(180, VIEWPORT_H - 220)) : VIEWPORT_H / 2; } // 回收 + if (bird.y < 0 || bird.y > VIEWPORT_H) { state.misses += 1; bird.y = VIEWPORT_H / 2; bird.vy = 0; } // 出界=漏+复位 + } + } } for (const r of rules) { if (evalCondition(r.condition, state, config)) { @@ -253,10 +284,15 @@ export function adaptGameDefinition(source) { state: () => { const ws = typeof config.winScore === 'number' ? config.winScore : targets.length; // 控制类暴露 paddle/ball(中心逻辑坐标)供 paddle-intercept driver 的 controlCheck.paddlePath/ballPath 读。 - let paddle = null, ball = null; + let paddle = null, ball = null, bird = null, nextGap = null; for (const b of behaviors) { if (b.trigger === 'input' && b.op === 'control-x') { const e = entById(b.entity); if (e) paddle = { x: e.x + e.w / 2, y: e.y, w: e.w }; } if (b.trigger === 'update' && b.op === 'fall-and-catch') { const e = entById(b.faller); if (e) ball = { x: e.x + e.w / 2, y: e.y + e.h / 2 }; } + if (b.trigger === 'update' && b.op === 'flappy-gap') { + const bd = entById(b.bird), gp = entById(b.gap); + if (bd) bird = { x: bd.x + bd.w / 2, y: bd.y + bd.h / 2, vy: bd.vy || 0 }; + if (gp) nextGap = { centerY: gp.y + gp.h / 2, x: gp.x }; + } } return { phase: state.phase, @@ -273,6 +309,8 @@ export function adaptGameDefinition(source) { })), ...(paddle ? { paddle } : {}), ...(ball ? { ball } : {}), + ...(bird ? { bird } : {}), + ...(nextGap ? { nextGap } : {}), }; }, }; diff --git a/game-runtime/src/adapter/fixtures/flappy.source.json b/game-runtime/src/adapter/fixtures/flappy.source.json new file mode 100644 index 00000000..db724a0c --- /dev/null +++ b/game-runtime/src/adapter/fixtures/flappy.source.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "1.0", + "sourceHash": "3333333333333333333333333333333333333333333333333333333333333333", + "profile": { "tickModel": "realtime", "inputModel": "continuous", "progressModel": "metric" }, + "gameDefinition": { + "entities": [ + { "id": "bird", "transform": { "position": { "x": 90, "y": 400 } }, "components": ["r-bird"] }, + { "id": "gap", "transform": { "position": { "x": 390, "y": 420 } }, "components": ["r-gap"] } + ], + "components": [ + { "id": "r-bird", "kind": "render", "shape": "rect", "w": 28, "h": 28, "color": "#fc4" }, + { "id": "r-gap", "kind": "render", "shape": "rect", "w": 40, "h": 40, "color": "#4f8" } + ], + "behaviors": [ + { "id": "b-flap", "trigger": "input", "op": "flap", "entity": "bird", "impulse": 360 }, + { "id": "b-flappy", "trigger": "update", "op": "flappy-gap", "bird": "bird", "gap": "gap", "gravity": 1100, "scrollSpeed": 150, "gapHeight": 200 } + ], + "scenes": [ + { "id": "s1", "entityRefs": ["bird", "gap"] } + ], + "rules": [ + { "id": "win", "condition": "score >= config.winScore", "outcome": "win" }, + { "id": "lose", "condition": "misses >= config.maxMisses", "outcome": "lose" } + ] + }, + "config": { "winScore": 3, "maxMisses": 8 } +}