From 33d9cd62ad55bfff05445c7ca67e384759659f43 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 15:10:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(adapter):=20=E6=97=B6=E9=97=B4=E5=8E=8B?= =?UTF-8?q?=E5=8A=9B=E7=BB=B4=E5=BA=A6=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20elapsed=20=E7=9B=B8=E5=AF=B9=E7=A7=92,=E6=94=AF=E6=92=91=20t?= =?UTF-8?q?ime-based=20win/lose=20=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修一处潜在语义错 + 补休闲玩法时间压力维度(限时/倒计时): - 旧 state.t = nowMs/1000 是【绝对单调时】(受控面明禁跨设备比绝对值),用作 `t >= timeLimit` 规则会因时钟原点(performance.now/Date.now)立判或漂移——错 - 新增 state.elapsed = (now - startMs)/1000(init 记 startMs,相对秒),forensics 暴露 elapsed;规则 `elapsed >= config.timeLimit → win/lose` 正确(零新增 op,正交叠加任一族) - 单测经注入时钟(createHostDevContext({clock}))验:init@1s 起点,clockMs=3000 时 elapsed=2(相对,非绝对 3)仍 playing,clockMs=4500 时 elapsed=3.5 触发 lose——证相对非绝对 验证:src 全量 197/197 绿(+1 时间压力);additive 改动经回归九门(tap-targets 重建探针 A–I 全绿,H score:0→3+latch)确认不破坏既有 6 族,探针已清场。 Co-Authored-By: Claude Opus 4.8 --- game-runtime/src/adapter/README.md | 3 ++- game-runtime/src/adapter/adapter.test.mjs | 26 +++++++++++++++++++ .../adapter/create-game-from-definition.js | 7 +++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/game-runtime/src/adapter/README.md b/game-runtime/src/adapter/README.md index 9120ccc1..21d85523 100644 --- a/game-runtime/src/adapter/README.md +++ b/game-runtime/src/adapter/README.md @@ -23,7 +23,7 @@ - **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 驻留**。 +- **rule**:`{ condition: " <数字|config.键>", outcome: win|lose }`(序前优先);胜负 → `phase=gameover` + **latch 驻留**。可引用 state 字段:`score`/`misses`/**`elapsed`**(相对秒,init 起算)。**时间压力维度**(限时/倒计时)= `elapsed >= config.timeLimit → lose`(零新增 op,复用 rule 机制);⚠ 勿用 `t`(绝对单调时,禁作时限判)。 - **命中/接住/穿缝/抵达/胜负事件**经 `ctx.getEngine()` 发粒子+音效(满足九门 F 真接线 + game-feel)。 ## 六类资产消费(E3,对齐 `contracts/agent-loop/source-project.schema.json` 冻结 assetSpec) @@ -43,6 +43,7 @@ assetSpec = `{ id, category∈[sprite,character,effect,scene,ui,music], ref, url ## 已验证六族(均过真九门 A–I + 首局门) > 覆盖休闲玩法的主要机制维度:点击 / 连续控制 / 离散方向 steer / 物理 / 导航 / 辨别(负反馈)。 +> **时间压力维度**(限时/倒计时)经 `elapsed >= config.timeLimit` 规则正交叠加于任一族(复用现有 driver,无新族),单测已验。 | fixture | 族 | driver | 关键门 | |---|---|---|---| diff --git a/game-runtime/src/adapter/adapter.test.mjs b/game-runtime/src/adapter/adapter.test.mjs index 6ee4892d..2598bfad 100644 --- a/game-runtime/src/adapter/adapter.test.mjs +++ b/game-runtime/src/adapter/adapter.test.mjs @@ -181,6 +181,32 @@ test('win 优先于 lose:先达 winScore 即 win(规则序)', () => { assert.equal(game._forensicsView().state().result, 'win'); }); +test('时间压力族:elapsed >= config.timeLimit 规则 + 注入时钟 → 到时 lose(零新增 op,复用 rule 机制)', () => { + // 注入可控时钟,验 time-based 规则:elapsed 是【相对秒】(init 起点起算),非 state.t 的绝对单调值。 + let clockMs = 0; + const { context } = createHostDevContext({ clock: () => clockMs }); + 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: [{ id: 'timeout', condition: 'elapsed >= config.timeLimit', outcome: 'lose' }], + }, + config: { timeLimit: 3 }, + }; + const game = adaptGameDefinition(src)(); + clockMs = 1000; // init 在绝对 t=1s 记起点 → 验 elapsed 用相对差(否则绝对 1>... 立判,本例必假阴/假阳) + game.init({ ctx: context, mainContext: recordingCtx2d(), canvas: {}, seed: 1 }); + clockMs = 3000; game.update(0.1); // elapsed=(3000-1000)/1000=2 < 3 → 未到时(若误用绝对 t=3 则会错误触发) + assert.equal(game._forensicsView().state().phase, 'playing', '未到时限应仍 playing'); + assert.ok(Math.abs(game._forensicsView().state().elapsed - 2) < 1e-6, 'elapsed 应=相对 2 秒(非绝对 3)'); + clockMs = 4500; game.update(0.1); // elapsed=3.5 >= 3 → 到时 + const v = game._forensicsView().state(); + assert.equal(v.phase, 'gameover'); + assert.equal(v.result, 'lose'); +}); + test('E3:assets[] 入 forensics(通道可观测)+ spec-only 几何兜底不崩', () => { const game = adaptGameDefinition(SOURCE)(); const { boot, g } = makeBoot(); diff --git a/game-runtime/src/adapter/create-game-from-definition.js b/game-runtime/src/adapter/create-game-from-definition.js index 0de9c18c..139feb4c 100644 --- a/game-runtime/src/adapter/create-game-from-definition.js +++ b/game-runtime/src/adapter/create-game-from-definition.js @@ -97,16 +97,18 @@ export function adaptGameDefinition(source) { let ctx = null; let sub = null; let nowMs = 0; + let startMs = null; // 时间压力族:init 记游戏起点;state.t 是绝对单调时(禁作时限判),elapsed 才是相对秒 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 = []; - const state = { phase: 'booting', score: 0, misses: 0, result: null, t: 0 }; + const state = { phase: 'booting', score: 0, misses: 0, result: null, t: 0, elapsed: 0 }; const entById = (id) => targets.find((t) => t && t.id === id); // 控制类按实体 id 取(paddle/faller) /** init:建目标、订阅受控输入、置 playing。 */ function init(boot) { ctx = boot.ctx; + startMs = ctx.time.nowMs(); // 时间压力族:记游戏起点 → elapsed=(now-start)/1000(相对秒),正确支撑 `elapsed >= config.timeLimit` 规则 // 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; } @@ -221,7 +223,7 @@ export function adaptGameDefinition(source) { /** update:刷钟 + 评估 rules(胜负 → phase=gameover + 终态驻留)。 */ function update(dt) { - if (ctx) { nowMs = ctx.time.nowMs(); state.t = nowMs / 1000; } + if (ctx) { nowMs = ctx.time.nowMs(); state.t = nowMs / 1000; state.elapsed = startMs != null ? (nowMs - startMs) / 1000 : 0; } if (state.phase !== 'playing') return; // 已终态:latch 驻留,不再评估 // trigger=update 行为(本切片:drift-targets 目标慢漂移,证 realtime/update 路)。 for (const b of behaviors) { @@ -372,6 +374,7 @@ export function adaptGameDefinition(source) { result: state.result, progress: ws ? state.score / ws : 0, remaining: Math.max(0, ws - state.score), // 剩余到通关(对齐 prompt.py HUD 硬规则 + harness 读) + elapsed: state.elapsed || 0, // 时间压力族:相对秒(供 `elapsed >= config.timeLimit` 规则 + 倒计时 HUD;绝对 state.t 禁作时限) tickModel: profile.tickModel || null, assets: assets.map((a) => ({ id: a.id, category: a.category })), // E3 通道可观测(§10.2-3) // tap-targets driver 读 targets[{idx,x,y,occupied,safe}] 自适应出招(逻辑坐标=中心点)。