diff --git a/game-runtime/src/adapter/adapter.test.mjs b/game-runtime/src/adapter/adapter.test.mjs index 20d19a19..5b8cc7a4 100644 --- a/game-runtime/src/adapter/adapter.test.mjs +++ b/game-runtime/src/adapter/adapter.test.mjs @@ -23,6 +23,7 @@ import { createHostDevContext } from '../core/plugin.js'; 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')); /** 录制版 2D 上下文:记 fillRect/fillText 调用,setter 容纳 fillStyle/font/strokeStyle 赋值。 */ function recordingCtx2d() { @@ -120,3 +121,24 @@ test('destroy 幂等', () => { game.destroy(); assert.equal(game._forensicsView().state().targets.length, 0); }); + +test('drift-targets:update 后目标漂移(realtime/update 行为泛化)', () => { + const game = adaptGameDefinition(DRIFT_SOURCE)(); + const { boot } = makeBoot(); + game.init(boot); + const before = game._forensicsView().state().targets.map((t) => t.x); + game.update(0.1); + game.update(0.1); + const after = game._forensicsView().state().targets.map((t) => t.x); + assert.ok(after.some((x, i) => x !== before[i]), '至少一个目标 x 应漂移(update 行为生效)'); +}); + +test('drift 下 tap 当前(漂移后)位仍命中计分', () => { + const game = adaptGameDefinition(DRIFT_SOURCE)(); + const { boot, inputBridge } = makeBoot(); + game.init(boot); + game.update(0.1); + const t0 = game._forensicsView().state().targets[0]; // 当前漂移后中心 + inputBridge._emit('pointerdown', { x: t0.x, y: t0.y }); + assert.equal(game._forensicsView().state().score, 1); +}); diff --git a/game-runtime/src/adapter/create-game-from-definition.js b/game-runtime/src/adapter/create-game-from-definition.js index 7c54d392..9b8ca24d 100644 --- a/game-runtime/src/adapter/create-game-from-definition.js +++ b/game-runtime/src/adapter/create-game-from-definition.js @@ -20,6 +20,8 @@ const VIEWPORT_H = 844; /** 最小 behavior 词汇表(声明式路支持的 op;其余 op = 走 code-agent 逻辑码,本切片不解释)。 */ const SUPPORTED_INPUT_OPS = new Set(['hit-nearest-target']); +/** trigger=update 的 op(本切片支持 drift-targets:目标随时间慢漂移,证 realtime/update behavior 路泛化)。 */ +const SUPPORTED_UPDATE_OPS = new Set(['drift-targets']); /** * 极小安全条件求值(无 eval):解析 " "。 @@ -92,12 +94,13 @@ export function adaptGameDefinition(source) { /** init:建目标、订阅受控输入、置 playing。 */ function init(boot) { ctx = boot.ctx; - targets = activeEntities.map((e) => { + targets = activeEntities.map((e, i) => { const rc = renderCompOf(e) || {}; const pos = (e.transform && e.transform.position) || { x: 0, y: 0 }; return { id: e.id, x: pos.x, y: pos.y, + baseX: pos.x, baseY: pos.y, phase: i * 1.7, // drift-targets 用:漂移基准 + 错相 w: typeof rc.w === 'number' ? rc.w : 48, h: typeof rc.h === 'number' ? rc.h : 48, color: typeof rc.color === 'string' ? rc.color : '#3cf', @@ -148,6 +151,17 @@ export function adaptGameDefinition(source) { function update(dt) { if (ctx) { nowMs = ctx.time.nowMs(); state.t = nowMs / 1000; } if (state.phase !== 'playing') return; // 已终态:latch 驻留,不再评估 + // trigger=update 行为(本切片:drift-targets 目标慢漂移,证 realtime/update 路)。 + for (const b of behaviors) { + if (b.trigger !== 'update' || !SUPPORTED_UPDATE_OPS.has(b.op)) continue; + if (b.op === 'drift-targets') { + const amp = typeof b.amplitude === 'number' ? b.amplitude : 24; + const spd = typeof b.speed === 'number' ? b.speed : 0.8; + for (const tg of targets) { + tg.x = tg.baseX + amp * Math.sin(state.t * spd + tg.phase); // 慢漂移:driver 逐步读 _forensicsView 仍可命中 + } + } + } for (const r of rules) { if (evalCondition(r.condition, state, config)) { if (r.outcome === 'win' || r.outcome === 'lose') { diff --git a/game-runtime/src/adapter/fixtures/moving-targets.source.json b/game-runtime/src/adapter/fixtures/moving-targets.source.json new file mode 100644 index 00000000..c7acea30 --- /dev/null +++ b/game-runtime/src/adapter/fixtures/moving-targets.source.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "1.0", + "sourceHash": "1111111111111111111111111111111111111111111111111111111111111111", + "profile": { "tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric" }, + "gameDefinition": { + "entities": [ + { "id": "m1", "transform": { "position": { "x": 100, "y": 240 } }, "components": ["r-mov"] }, + { "id": "m2", "transform": { "position": { "x": 200, "y": 440 } }, "components": ["r-mov"] }, + { "id": "m3", "transform": { "position": { "x": 150, "y": 640 } }, "components": ["r-mov"] } + ], + "components": [ + { "id": "r-mov", "kind": "render", "shape": "rect", "w": 60, "h": 60, "color": "#7cf" } + ], + "behaviors": [ + { "id": "b-drift", "trigger": "update", "op": "drift-targets", "amplitude": 24, "speed": 0.8 }, + { "id": "b-tap", "trigger": "input", "op": "hit-nearest-target" } + ], + "scenes": [ + { "id": "s1", "entityRefs": ["m1", "m2", "m3"] } + ], + "rules": [ + { "id": "win", "condition": "score >= config.winScore", "outcome": "win" } + ] + }, + "config": { "winScore": 3, "hitRadius": 60 } +}