feat(adapter): E1 第四游戏族 flappy——flap + flappy-gap,真九门绿(flap-to-gap driver)

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 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-17 14:40:29 -07:00
parent 413f8edce4
commit 5164f28f69
3 changed files with 98 additions and 4 deletions

View File

@ -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('flappyforensics 暴露 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('flappyflap 给 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({})();

View File

@ -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 的 opdrift-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 的 opdrift-targets漂移fall-and-catch接住=控制类flappy-gap重力+穿缝=flappy 类)。 */
const SUPPORTED_UPDATE_OPS = new Set(['drift-targets', 'fall-and-catch', 'flappy-gap']);
/**
* 极小安全条件求值 eval解析 "<field> <op> <rhs>"
@ -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') {
// flappybird 重力下落 + 缝隙左滚;缝隙越过 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 } : {}),
};
},
};

View File

@ -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 }
}