feat(game-runtime): clickable 声明式点击基元(harness 探针证 9/9)+ tap-targets prompt 迭代——治便宜模型 tap-handler 顽疾

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 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-18 06:13:18 -07:00
parent c24b7fb4d2
commit 80aaa116ab
5 changed files with 95 additions and 21 deletions

View File

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

View File

@ -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();

View File

@ -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, '点空白不计分');
});

View File

@ -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` 判定 / 读取。
---

View File

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