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:
parent
c24b7fb4d2
commit
80aaa116ab
@ -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"}]
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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, '点空白不计分');
|
||||
});
|
||||
|
||||
@ -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` 判定 / 读取。
|
||||
|
||||
---
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user