From 80aaa116abde4046cd58c43b911716a032478500 Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 18 Jun 2026 06:13:18 -0700 Subject: [PATCH] =?UTF-8?q?feat(game-runtime):=20clickable=20=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E5=BC=8F=E7=82=B9=E5=87=BB=E5=9F=BA=E5=85=83(harness?= =?UTF-8?q?=20=E6=8E=A2=E9=92=88=E8=AF=81=209/9)+=20tap-targets=20prompt?= =?UTF-8?q?=20=E8=BF=AD=E4=BB=A3=E2=80=94=E2=80=94=E6=B2=BB=E4=BE=BF?= =?UTF-8?q?=E5=AE=9C=E6=A8=A1=E5=9E=8B=20tap-handler=20=E9=A1=BD=E7=96=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan 2026-06-18-001 U3 迭代3-4轮(据快走查根因)。 根因(全budget+stage2 都不解,3轮prompt不解):便宜/强模型均**顽固把 tap 命中门控在分离 state 实体/计时器/turn 上** → 盲点驱动点不中 → tap-targets 类系统性 E_live/F_wiring/G_input/H 挂。即「让模型写 tap-handler」这条路便宜模型走不通。 引擎线根治(gd-runtime.js):新增 **clickable 声明式基元** `{kind:'clickable',score?,mark?:{shape,color,r},hitW?,hitH?}`—— 运行时内置:本帧点中带此组件且 !occupied 的实体 → 自动翻 occupied+spawn 标记(可见)+计分+fx。**离散点击类声明它即可、免写 handler**。 验证:gd-runtime 单测 16(含 clickable 派发) + 全套 60/60;**手写纯声明式(behaviors:[])clickable tap-targets gamedef 过 harness 9/9**(E_live distinct=8/F_wiring/G_input/H 全过)——证基元+管线正确。 GAMEDEF_SYSTEM:教 clickable(替「写 tap-handler」)+ 参照2 改纯声明式 clickable few-shot(经探针证 9/9)。 runtime-api-2d.md:补 clickable 组件文档。 **关键测量定论(终结本机迭代)**:便宜模型 tap-targets 单管线生成**高方差**(同 brief 一轮 8/9 一轮 5/9)—— N=1~2 小样 lili-mac 早读**信噪比不足**,无法可靠优化「模型产出过门率」(我前几轮在追噪声)。 **管线/运行时/约定已证正确(手写金样两品类均 9/9)+ 引擎补了 clickable 基元**;**模型产出 cutover 率是统计量**, 需大样+全 repair 预算 → **authoritative=mini-desktop SaaFullGraphE2eTest -Dsaa.e2e=1**(多 brief+5+3 repair+stage2)。 快走查工具:加 stage2 救场升档(QC_STAGE*)+ gamedef assert 字段 coerce 到 score(治 design 造 moves/totalRound 等取证读不到字段)。 设计决策待 6c6g:design↔gamedef 的 assertAfterPlay 字段词汇契约(design 应约束到 gamedef 暴露集 score/remaining, 或运行时暴露更多)——触共享 DESIGN_SYSTEM。 Co-Authored-By: Claude Opus 4.8 --- .../game/module/aigc/saa/SaaPrompts.java | 27 ++++++++------- game-runtime/src/host/gd-runtime.js | 29 ++++++++++++++++ game-runtime/src/host/gd-runtime.test.mjs | 25 ++++++++++++++ game-runtime/src/host/runtime-api-2d.md | 1 + wg1/gen-worker/gamedef_quickcheck.py | 34 ++++++++++++++----- 5 files changed, 95 insertions(+), 21 deletions(-) diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java index 5407e9a2..a1f55a48 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java @@ -181,7 +181,11 @@ final class SaaPrompts { - **命名逐字对齐设计稿 gatespec**: 设计稿末尾 ```gatespec 块的 driver.ballPath / controlCheck.paddlePath / assertAfterPlay.path 引用的名字,你的实体 id 与进展字段必须**逐字一致**: · 挡板接球类: 球实体 id **就叫 "ball"**、挡板 **就叫 "paddle"**(别用 paddlePlayer/ball1 等变体——九门读 ball.x/paddle.x,变体=读到 undefined=挂)。 - · 离散点击类(井字棋/打地鼠/翻牌/扫雷): 每个可点目标实体打 tags:["target"], 设 idx(序号)/occupied(是否已占)(九门据 targets[] 逐个点; 不打 target 标=点不到=挂)。 + · 离散点击类(井字棋/打地鼠/翻牌/扫雷): **声明式·免写 tap-handler**——每个可点目标实体打 tags:["target"]+idx, + 并加一个 **clickable 组件** `{kind:"clickable", score:1, mark:{shape:"circle", color:"#ffcc00", r:30}}`;运行时**内置**: + 点中该目标 → 自动翻 occupied + spawn mark(可见变化) + 计分 + fx(粒子/音效)。**绝不要自己写 justTapped/overlap 的 tap-handler** + (实测便宜/强模型都常把命中门控在分离 state 实体/计时器/turn 上→盲点驱动永远点不中→E_live/G_input/F/H 全挂)。 + 九门盲点 occupied!==true 的目标;打地鼠等计时类:让当前激活目标 occupied=false(driver 才点)、其余 occupied=true(或 spawn/destroy 控可点性)。 - **可控体必须响应指针**(九门 driver 靠点击指针位驱动你的控制体): 挡板/玩家等可控实体务必随指针移动,如 `const p=rt.getEntity('paddle'); if(p && rt.input.pointer.down){ p.x = rt.input.pointer.x; }`(只接键盘=driver 推不动=I_control 挂)。 - **进展落在可取证字段**: 取证只暴露 score / remaining(=存活实体数,destroy 目标即降)/ 实体位置——进展务必体现在 rt.addScore 或 @@ -208,24 +212,23 @@ final class SaaPrompts { "rules":[{"id":"survive","condition":"rt.score >= 20","outcome":"win"}] } - 【参照2(离散点击/tap-targets 类:井字棋/打地鼠/翻牌/三消点选——每个可点目标打 tags:["target"]+idx+occupied,点中→翻态+spawn 标记可见+计分)】 + 【参照2(离散点击/tap-targets 类:井字棋/打地鼠/翻牌/三消——**声明式·无 behaviors**;每个目标加 clickable 组件,运行时自动处理点击翻态+计分+特效)】 { "components":[ {"id":"bg","kind":"render","shape":"fill","color":"#10101a"}, - {"id":"tile","kind":"render","shape":"rect","color":"#33384a","w":100,"h":100} + {"id":"tile","kind":"render","shape":"rect","color":"#33384a","w":100,"h":100}, + {"id":"clk","kind":"clickable","score":1,"mark":{"shape":"circle","color":"#ffcc00","r":30}} ], "entities":[ {"id":"world","transform":{"position":{"x":0,"y":0}},"components":["bg"]}, - {"id":"t0","transform":{"position":{"x":75,"y":350}},"tags":["target"],"idx":0,"components":["tile"]}, - {"id":"t1","transform":{"position":{"x":195,"y":350}},"tags":["target"],"idx":1,"components":["tile"]}, - {"id":"t2","transform":{"position":{"x":315,"y":350}},"tags":["target"],"idx":2,"components":["tile"]}, - {"id":"t3","transform":{"position":{"x":75,"y":520}},"tags":["target"],"idx":3,"components":["tile"]}, - {"id":"t4","transform":{"position":{"x":195,"y":520}},"tags":["target"],"idx":4,"components":["tile"]}, - {"id":"t5","transform":{"position":{"x":315,"y":520}},"tags":["target"],"idx":5,"components":["tile"]} - ], - "behaviors":[ - {"id":"tap","trigger":"update","code":"if(!rt.input.justTapped()) return; const p=rt.input.pointer; for(const t of rt.query('target')){ if(!t.occupied && rt.overlap({x:p.x,y:p.y,w:8,h:8},{x:t.x,y:t.y,w:100,h:100})){ t.occupied=true; rt.spawn({x:t.x,y:t.y,components:[{kind:'render',shape:'circle',color:'#ffcc00',r:30}]}); rt.addScore(1); rt.fx.burst(t.x,t.y,'#ffcc00'); rt.fx.beep('score'); break; } }"} + {"id":"t0","transform":{"position":{"x":75,"y":350}},"tags":["target"],"idx":0,"components":["tile","clk"]}, + {"id":"t1","transform":{"position":{"x":195,"y":350}},"tags":["target"],"idx":1,"components":["tile","clk"]}, + {"id":"t2","transform":{"position":{"x":315,"y":350}},"tags":["target"],"idx":2,"components":["tile","clk"]}, + {"id":"t3","transform":{"position":{"x":75,"y":520}},"tags":["target"],"idx":3,"components":["tile","clk"]}, + {"id":"t4","transform":{"position":{"x":195,"y":520}},"tags":["target"],"idx":4,"components":["tile","clk"]}, + {"id":"t5","transform":{"position":{"x":315,"y":520}},"tags":["target"],"idx":5,"components":["tile","clk"]} ], + "behaviors":[], "scenes":[{"id":"main","entityRefs":["world","t0","t1","t2","t3","t4","t5"]}], "rules":[{"id":"win","condition":"rt.score >= 6","outcome":"win"}] } diff --git a/game-runtime/src/host/gd-runtime.js b/game-runtime/src/host/gd-runtime.js index 0164d072..e4379ae7 100644 --- a/game-runtime/src/host/gd-runtime.js +++ b/game-runtime/src/host/gd-runtime.js @@ -268,6 +268,34 @@ export function createRuntime(boot, gameDefinition) { e.y += e.vy * dt; } } + /** + * 内置点击派发(plan U3 tap-targets 救场基元):本帧有 tap 时,点中**带 clickable 组件且未 occupied** 的实体 → + * 翻 occupied + spawn 标记(可见变化) + 计分 + fx。让离散点击类「声明 clickable 即可」,免模型写易错 tap-handler + * (实测便宜/强模型均常把 tap 门控在分离 state/计时器上→点不中)。clickable 组件:{kind:'clickable',score?,mark?:{shape,color,r/w/h},hitW?,hitH?}。 + */ + function dispatchClicks() { + if (!tappedThisFrame) return; + for (const e of entities) { + if (!e.alive || e.occupied) continue; + const clk = componentOf(e, 'clickable'); + if (!clk) continue; + const rc = componentOf(e, 'render'); + const hw = num(clk.hitW != null ? clk.hitW : (rc && rc.w != null ? rc.w : (rc && rc.r != null ? rc.r * 2 : 80))) / 2; + const hh = num(clk.hitH != null ? clk.hitH : (rc && rc.h != null ? rc.h : (rc && rc.r != null ? rc.r * 2 : 80))) / 2; + if (Math.abs(pointer.x - e.x) <= hw && Math.abs(pointer.y - e.y) <= hh) { + e.occupied = true; + const mk = clk.mark; + if (mk && typeof mk === 'object') { + rt.spawn({ x: e.x, y: e.y, components: [{ kind: 'render', shape: mk.shape || 'circle', color: mk.color || '#ffcc00', r: num(mk.r != null ? mk.r : 28), w: mk.w, h: mk.h }] }); + } + score = num(score) + num(clk.score != null ? clk.score : 1); + fxBurst(e.x, e.y, (mk && mk.color) || '#ffcc00'); + fxBeep('score'); + break; // 一次 tap 只触一个目标 + } + } + } + /** 回收死实体(帧末统一清,避免迭代中改数组)。 */ function reap() { let dirty = false; @@ -302,6 +330,7 @@ export function createRuntime(boot, gameDefinition) { curDt = num(dt); if (phase === 'playing') { // latch 终态后停摆(H 门:终态驻留);但输入沿仍每帧清(见下) for (const slot of tickBehaviors) { if (phase !== 'playing') break; runBehavior(slot, curDt); } + dispatchClicks(); // 内置 clickable 点击派发(tap-targets 声明式基元,免模型写 tap-handler) integratePhysics(curDt); evalRules(); reap(); diff --git a/game-runtime/src/host/gd-runtime.test.mjs b/game-runtime/src/host/gd-runtime.test.mjs index c4dca66c..ae3342d5 100644 --- a/game-runtime/src/host/gd-runtime.test.mjs +++ b/game-runtime/src/host/gd-runtime.test.mjs @@ -249,3 +249,28 @@ test('U1-修 rt.time 不暴露 nowMs', () => { assert.equal(typeof r.rt.time.now, 'function'); assert.equal(r.rt.time.nowMs, undefined, 'nowMs 已移除(防非确定性逃逸)'); }); + +/* U3 救场基元:clickable 内置点击派发(声明式 tap-targets,免模型写 tap-handler)。 */ +test('U3 clickable 内置点击派发', () => { + const { boot, bundle, mock } = makeBoot(1, true); + const gdef = { + components: [ + { id: 'cell', kind: 'render', shape: 'rect', color: '#333', w: 100, h: 100 }, + { id: 'clk', kind: 'clickable', score: 1, mark: { shape: 'circle', color: '#ff0', r: 30 } }, + ], + entities: [{ id: 'c0', transform: { position: { x: 100, y: 300 } }, tags: ['target'], components: ['cell', 'clk'] }], + }; + const r = createRuntime(boot, gdef); r.init(); + bundle.inputBridge._emit('pointerdown', { x: 100, y: 300 }); // 点中 c0 + r.update(0.016); + assert.equal(r.state().score, 1, '点中 clickable +1 分(无需 tap-handler)'); + assert.equal(r.state().targets[0].occupied, true, '点中后 occupied 翻 true(driver 会跳过它)'); + assert.ok(r.state().entities.length >= 2, '应 spawn 标记实体(可见变化→E_live)'); + assert.ok(mock.emitterCalls.length >= 1, 'fx.burst 真触发引擎(F_wiring)'); + bundle.inputBridge._emit('pointerdown', { x: 100, y: 300 }); // 再点同一(已 occupied) + r.update(0.016); + assert.equal(r.state().score, 1, 'occupied 后再点不重复计分'); + bundle.inputBridge._emit('pointerdown', { x: 5, y: 5 }); // 点空白 + r.update(0.016); + assert.equal(r.state().score, 1, '点空白不计分'); +}); diff --git a/game-runtime/src/host/runtime-api-2d.md b/game-runtime/src/host/runtime-api-2d.md index ce037ba8..055eeeb5 100644 --- a/game-runtime/src/host/runtime-api-2d.md +++ b/game-runtime/src/host/runtime-api-2d.md @@ -103,6 +103,7 @@ - `shape:'circle'` + `r` - `shape:'fill'` + `color`(铺满视口,作背景) - **physics**:`{id, kind:'physics', gravity?}`——挂此组件的实体每帧自动 `x+=vx*dt; y+=vy*dt`(有 `gravity` 则先 `vy+=gravity*dt`)。不挂则位置全由 behavior 控制。 +- **clickable**(离散点击声明式基元):`{id, kind:'clickable', score?:1, mark?:{shape,color,r/w/h}, hitW?, hitH?}`——运行时**内置**:本帧点中带此组件且 `occupied!==true` 的实体(命中区取 hitW/hitH,缺省取 render 尺寸/80)→ 自动翻 `occupied=true` + spawn `mark` 标记实体(可见变化) + `+score` + fx 粒子/音效。**离散点击类(井字棋/打地鼠/翻牌)用它即可,无需写 tap-handler**(免模型把命中误门控在分离 state/计时器上)。配 `tags:["target"]` 让九门 driver 能逐个点。 - **collision / custom**:声明式标记,由 behavior 自行 `rt.overlap` 判定 / 读取。 --- diff --git a/wg1/gen-worker/gamedef_quickcheck.py b/wg1/gen-worker/gamedef_quickcheck.py index 55ed3d5c..727b3eca 100644 --- a/wg1/gen-worker/gamedef_quickcheck.py +++ b/wg1/gen-worker/gamedef_quickcheck.py @@ -28,7 +28,11 @@ GAME_RUNTIME = REPO_ROOT / "game-runtime" GEN_DIR = GAME_RUNTIME / "games" / "_wg1-gen" BRIEFS = Path(__file__).resolve().parent / "briefs" import os -MAX_RETRIES = int(os.environ.get("QC_MAX_RETRIES", "3")) # repair 回环上限(可环境覆盖;真图 maxRepairs=5,本机早读取 3 平衡时延) +# 救场阶梯(mirror 真图 SaaStudioGraph):stage1 连续 STAGE1_REPAIRS 次失败 → 升档 stage2 再 STAGE2_EXTRA 次。 +STAGE1_MODEL = os.environ.get("QC_STAGE1", "deepseek-v4-flash") +STAGE2_MODEL = os.environ.get("QC_STAGE2", "deepseek-v4-pro") +STAGE1_REPAIRS = int(os.environ.get("QC_STAGE1_REPAIRS", "5")) # 真图 maxRepairs 默认 5 +STAGE2_EXTRA = int(os.environ.get("QC_STAGE2_EXTRA", "3")) # 真图 stage2ExtraRepairs 默认 3 def extract_block(name): @@ -52,6 +56,16 @@ def extract_gatespec(design_text): if "assertAfterPlay" in gs and not isinstance(gs["assertAfterPlay"], list): gs["assertAfterPlay"] = [] gs.setdefault("expectLatch", True) + # gamedef 取证只暴露 score/remaining/progress/实体位置——把 assertAfterPlay 的顶层自定义标量字段(moves/totalRound 等)强制到 score + # (设计 agent 常造取证读不到的字段→H_progress 必挂;不动含 '.' 的实体位置断言如 ball.x)。 + EXPOSED = ("score", "remaining", "progress") + for a in (gs.get("assertAfterPlay") or []): + if isinstance(a, dict) and isinstance(a.get("path"), str): + p = a["path"].lstrip("/") + top = p.split(".")[0].split("[")[0] + if "." not in p and top not in EXPOSED: + a["path"] = "score" + a["op"] = a.get("op", "increased") drv = gs.get("driver") if isinstance(drv, dict) and drv.get("type") == "paddle-intercept": bp = drv.get("ballPath") @@ -87,14 +101,14 @@ def assemble_via_cli(game_id, gamedef_obj): return r.returncode == 0, (r.stdout + r.stderr).strip() -def run_one(game_id, gamedef_system, design_system, model): +def run_one(game_id, gamedef_system, design_system): brief_text, play_spec = run._load_brief(game_id) gid = "gd-" + game_id - out = {"game_id": gid, "stage": None, "pass": False, "guards": {}, "err": "", "driver": None} + out = {"game_id": gid, "stage": None, "pass": False, "guards": {}, "err": "", "driver": None, "model": STAGE1_MODEL} # ① design 步(mirror 真图 design 节点):产 gatespec(driver/controlCheck/assert) 覆写 play_spec + enriched。 enriched = brief_text try: - dresp = _client.chat(model, design_system, brief_text, max_tokens=16000) + dresp = _client.chat(STAGE1_MODEL, design_system, brief_text, max_tokens=16000) enriched = brief_text + "\n\n## 设计稿\n" + dresp["content"] gs = extract_gatespec(dresp["content"]) if gs: @@ -108,13 +122,16 @@ def run_one(game_id, gamedef_system, design_system, model): # 这是 ≥60% cutover 门的真机制(单发首攻 ≠ 终率);design 只跑一次,repair 只重 generate。 gdir = GEN_DIR / gid feedback = None - for attempt in range(MAX_RETRIES + 1): + total = STAGE1_REPAIRS + STAGE2_EXTRA + for attempt in range(total): + gen_model = STAGE2_MODEL if attempt >= STAGE1_REPAIRS else STAGE1_MODEL # 救场升档:stage1 耗尽→stage2 强档 out["attempts"] = attempt + 1 + out["model"] = gen_model user = "请为下面这款游戏产出一份 gameDefinition(只输出一个 JSON 对象,不要 ```代码块、不要解释):\n\n" + enriched if feedback: user += "\n\n———\n上一次生成【未通过】,原因如下,请针对性修正后重新产出完整 gameDefinition JSON:\n" + feedback try: - resp = _client.chat(model, gamedef_system, user, max_tokens=16000) + resp = _client.chat(gen_model, gamedef_system, user, max_tokens=16000) except Exception as e: out["stage"] = "generate"; feedback = "模型调用失败:" + str(e)[:160]; continue gd = extract_json_obj(resp["content"]) @@ -142,13 +159,12 @@ def run_one(game_id, gamedef_system, design_system, model): def main(): games = sys.argv[1:] or ["runner", "flappy", "whack", "pong"] - model = _client.DEFAULT_MODEL gds = extract_block("GAMEDEF_SYSTEM") dsys = extract_block("DESIGN_SYSTEM") - print(f"[gamedef-qc] model={model} games={games} (design+gamedef 全图镜像; prompts 抽自 SaaPrompts.java)\n") + print(f"[gamedef-qc] stage1={STAGE1_MODEL}({STAGE1_REPAIRS})→stage2={STAGE2_MODEL}({STAGE2_EXTRA}) games={games} (全图镜像+救场升档)\n") results = [] for g in games: - r = run_one(g, gds, dsys, model) + r = run_one(g, gds, dsys) results.append(r) mark = "✅ PASS" if r["pass"] else f"❌ {r['stage']}" gpass = sum(1 for v in r["guards"].values() if v)