fix(gen): 模板起局鲁棒性 menuStartBtn 解耦 render + targets 键/起局约定进 prompt v1.6.1 + gen.mjs 入口守卫 (W-S1 修复单②)

T1 六款模板(_template + feiyi/puzzle/shop/story/trpg)起局/重开按钮命中矩形:
从 render 副作用回写(menuStartBtn = renderMenu(...).startBtn)改为抽纯几何
menuStartRect/overRestartRect + 在场景进入点 onEnter 确定性赋值、render 只画;
治并发 rAF 饥饿下 render 未跑过=命中矩形恒 null=卡 menu(归因案4)。
no-render 探针坐实:修复前六款均卡 menu、修复后均按坐标起局进 play。

T2 cheap-system.md 升 v1.6.1,红线段增两条硬约束:①点 target 玩法 state 键名
固定为 targets(不得改成 items 等,C5 驱动器族选择依赖)②起局命中矩形须在状态
进入点建立、不依赖渲染帧;各模板 _forensicsView 加同义契约注释。cheap_roles.py
_SYSTEM_PROMPT 与 md 正文逐字节 parity 同步,registry.yaml 版本 bump 对齐。

T3 gen.mjs 顶层裸调 main() 加入口守卫(import.meta.url === pathToFileURL(argv[1])),
被 import 时不再触发真生成(footgun,曾 ~116s)、直接执行行为不变。

T4 puzzle-game-design.md §9 死链经核实在本 commit 不存在(唯一 play-spec 引用已
正确指向存在的 puzzle.play-spec.json),未伪造改动;littlejs-game-dev.md 补一条
起局 onEnter 指导闭合 doc-code。

验证:test_roles 12/12 + check_registry 17 对齐 + md↔_SYSTEM_PROMPT parity True
+ 6 模板 esbuild build 全绿 + amodel-gen tests 15+11 + 模板 node --test 除
_template-shop/headless-boot(既存红,与基线同)外全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-03 14:17:53 -07:00
parent 96de98b345
commit 34923812c3
17 changed files with 191 additions and 81 deletions

View File

@ -139,6 +139,7 @@ export function createGame({ plugins, bundle, viewport }) {
- ❌ `sceneFsm.defineScenes({...})` → ✅ `sceneFsm.define(name, {onEnter,onExit,update,render})` **逐个**定义(**没有** `defineScenes`)。
- ❌ `sceneFsm.define({ menu:{...}, play:{...}, over:{...} })` 单对象批量注册 → ✅ **每个场景各调一次** `sceneFsm.define('menu', {...}); sceneFsm.define('play', {...}); sceneFsm.define('over', {...})`(`define(name, handlers)` 第一参恒为场景名**字符串**;传对象 → 第一参变成 `[object Object]`、menu/play/over 全没注册 → `transition('play')` 找不到目标态被忽略 → 卡菜单进不去 play。实测合成/2048 类翻车点)。可链式 `.define(...).define(...)`。**回调表里 `update(dt)` 只收 dt、`render(g)` 只收 g、与五法同形——别臆造场景名/state 首参(#15),绝不写 `update(scene, dt)`。**
- ✅ **`hudUi.drawButton(g, rect, opts)` 回吐它绘制的命中矩形 `{x,y,w,h}`**(阶段一B #5 改签名):直觉写法就对——`const btn = hudUi.drawButton(g, {x,y,w,h}, {label}); if (hudUi.pointInRect(px,py,btn)) {...}`,drawButton 返回命中矩形、直接接住判命中即可(也可先单独定义 rect 再画再 pointInRect,两种都对)。命中判定 `hudUi.pointInRect(px, py, rect)`(签名 `(px, py, rect)`;**只在 hudUi,collision 插件没有 pointInRect 方法**)。另:**`drawButton` 第二参是 rect 对象 `{x,y,w,h}`、不是 label/坐标位置参**——别写 `drawButton(g, '开始', x, y, w, h)`
- ✅ **起局/交互按钮的命中矩形在场景 `onEnter`(状态进入点)确定性建立、`render` 只画不写**——绝不让它只在 `render` 副作用里生成(`menuStartBtn = renderMenu(...)` 那种回写):并发渲染帧饥饿时 `render` 可能一次都没跑过 → 命中矩形恒 `null` → 菜单点不动、一局起不来(卡 menu 头号成因)。把按钮几何抽成纯函数,`onEnter` 调它设命中区、`render` 调它画,命中与绘制同源不漂移(`_template*` 六款范例的 `menuStartRect/overRestartRect` 即此写法)。
- ❌ scene 的 update 回调臆造首参写成 `update(running, dt)` → ✅ **scene 的 `update` 回调只收一个参数 `(dt)`**(`sceneFsm.define(name, { update(dt){…} })`;`sceneFsm.update(dt)` 只透传 dt,没有 running 之类首参)。写错首参 → 真正的 dt 落到第二参=undefined → `state.elapsedMs += undefined = NaN` → 不再 spawn/计时、计时显示「NaNs」、游戏空转看着像没 bug 实则永不推进(实测 wanglanmei-v2:菜单能进 play 但无顾客、营业额恒 0)。**任何回调签名都以 `src/plugins/<name>/api.d.ts` 为准,别臆造参数。**
- ❌ `update(dt)` 把 dt 当毫秒直接累加 `elapsedMs += dt` → ✅ **dt 单位是「秒」(≈1/60)**;游戏内毫秒计时(倒计时/spawn 间隔/耐心,常量如 60000/1300/4800)累加要 **`elapsedMs += dt * 1000`**。漏 ×1000 → 计时慢 1000 倍 → 永不到 spawn 阈值=零顾客、一局永不结束(实测 wanglanmei-v2 翻车点;`_template` 范例里 `remainMs - dt*1000` 就是这个换算)。
- ❌ `_forensicsView()` 在**外层**先算好 phase/targets 再闭包返回(`const phase=...; return { state(){ return { phase } } }`)→ ✅ **phase/targets/score 必须在 `state()` 函数体内部实时计算**(host 只在 boot 调一次 `_forensicsView()`,外层算的值定格成 boot 快照=永远 menu、targets 永远空 → 自动验收/驱动看不到目标、永判死菜单。实测 wanglanmei-v2:游戏真在 play 但门读到的 phase 恒 menu)。照 `_template/src/game-logic.js``state: () => ({ phase: sceneFsm.current(), ... })` 写。

View File

@ -79,11 +79,11 @@ A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4
- **插件扁平注入直接 const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins用到才解构****绝不写 opts.runtime.plugins**那是 L1 内部的你见不到 写了就 undefined boot reading 'define'游戏只用插件 new register
- **可用插件键 11 已注入用到才取**sceneFsm / sessionScore / hudUi / timerScheduler / save / gamefeel / juice / palettePost / audioMusic / collision / physics
- **插件方法名一律以 api.d.ts 为准别臆测**实测幻觉scene-fsm defineScenes define用到某插件先 read_file 它的 api.d.tsskill 实测易犯的幻觉 API逐条列
- **hudUi.drawButton(g, rect, opts) 回吐它绘制的命中矩形 { x, y, w, h }** 直觉写法就对const btn = hudUi.drawButton(g, { x, y, w, h }, { label }); if (hudUi.pointInRect(px, py, btn)) 命中处理drawButton 返回命中矩形直接接住判命中即可命中判定走 hudUi.pointInRect(px, py, rect)**只在 hudUicollision 插件没有 pointInRect 方法****第二参是 rect 对象 { x, y, w, h }不是 label/坐标位置参**别写 drawButton(g, '开始', x, y, w, h) 这种位置参形态
- **hudUi.drawButton(g, rect, opts) 回吐它绘制的命中矩形 { x, y, w, h }** 直觉写法就对const btn = hudUi.drawButton(g, { x, y, w, h }, { label }); if (hudUi.pointInRect(px, py, btn)) 命中处理drawButton 返回命中矩形直接接住判命中即可命中判定走 hudUi.pointInRect(px, py, rect)**只在 hudUicollision 插件没有 pointInRect 方法****第二参是 rect 对象 { x, y, w, h }不是 label/坐标位置参**别写 drawButton(g, '开始', x, y, w, h) 这种位置参形态**起局/交互按钮的命中矩形必须在状态进入点scene onEnter确定性建立render 只画不赋值**绝不让命中矩形只在 render 副作用里生成并发渲染帧饥饿下 render 可能没跑过一次=矩形恒 null=菜单点不动一局起不来把按钮几何抽成纯函数onEnter 调它设命中区render 调它画同一来源不漂移
- **sceneFsm.define(name, handlers) 逐场景调一次绝不单对象批量注册** sceneFsm.define({ menu: {...}, play: {...}, over: {...} }) 第一参当成了场景名[object Object]menu/play/over 全没真注册 后续 transition('play') 找不到目标态被忽略 卡菜单实测合成/2048 类翻车点 **每个场景各调一次**sceneFsm.define('menu', { onEnter(){...} }); sceneFsm.define('play', { onEnter(){...}, update(dt){...} }); sceneFsm.define('over', { onEnter(){...} });define 第一参恒为场景名字符串第二参才是回调表 { onEnter, onExit, update, render }**回调表里 update 只收 (dt)render 只收 (g)与五法同形别臆造场景名/state 首参绝不写 update(scene, dt)**可链式 .define(...).define(...)
- **画面读 viewport.w/h**viewport 是入参别硬编码 390×844
- **美术资产 assets/manifest.json 非空** read_file('assets/manifest.json') 看有哪些资产init(boot) const assets = boot.assets || {}render const img = assets['ref名']?.image; if (img) g.drawImage(img, x, y, w, h); else 程序化回退**ref = manifest 条目 file 去扩展名**shopkeeper-idle.jpg 'shopkeeper-idle'缺图/加载失败时 assets[ref].image nullhost 已容错 务必走程序化回退别崩BGM/音效经 plugins.audioMusic不在 boot.assets manifest/无美术 全程序化绘制即可
- **必须实现 _forensicsView()**可测性红线 + 自动驱动契约返回 { state(){}, measures(){} }state() 至少 { phase, score }**若游戏靠点击推进经营点客/打地鼠/点离散目标等state().targets:[{x,y,occupied}] 列当前屏上目标可点的等待的客/冒头的鼠 occupied:true不可点置 false** 自动验收据此点 occupied:true 的目标真驱动你的游戏**score 用累计进展营收/得分随推进上升** 自动验收据 score 上升判"真有进展"不写则验证读不到状态check 会拦**钉死phase/targets/score 必须在 state() 函数体内部实时读取/计算绝不在 _forensicsView() 外层先算好再闭包返回**host 只在 boot 调一次 _forensicsView外层算的值会被定格成 boot 快照=永远 menutargets 永远空 自动验收看不到顾客永判死菜单
- **必须实现 _forensicsView()**可测性红线 + 自动驱动契约返回 { state(){}, measures(){} }state() 至少 { phase, score }**若游戏靠点击推进经营点客/打地鼠/点离散目标等state().targets:[{x,y,occupied}] 列当前屏上目标可点的等待的客/冒头的鼠 occupied:true不可点置 false** 自动验收据此点 occupied:true 的目标真驱动你的游戏**键名固定为 `targets`不得改成 items/objects **判卷驱动器靠 state 有无 `targets` 键选驱动器族点目标族 vs 循环按键族改名=选错族=盲驱动器驱动不了你的游戏=H 进展门挂**score 用累计进展营收/得分随推进上升** 自动验收据 score 上升判"真有进展"不写则验证读不到状态check 会拦**钉死phase/targets/score 必须在 state() 函数体内部实时读取/计算绝不在 _forensicsView() 外层先算好再闭包返回**host 只在 boot 调一次 _forensicsView外层算的值会被定格成 boot 快照=永远 menutargets 永远空 自动验收看不到顾客永判死菜单
- **关键事件用 ctx.log 记日志** init/场景切换/得分/出错处 ctx.log('tag', 信息)**ctx 来自 init 的入参 bootconst ctx = boot.ctx绝不是 boot.boot.ctx**host 传入的 boot 已是 {ctx, mainContext, canvas, seed, assets}多套一层 .boot=undefined ctx=null 无输入游戏点不动check 会拦 .boot.ctx插件调用已自动记你只补游戏语义事件
- **输入契约钉死·根治"启动不了"**输入订阅已由 L1game.js wrapper接管**你绝不自己订阅输入**不写 ctx.getInput不自建 pendingClicks 队列不在 update(dt) 里挑时机消费点击你只写实例方法 **handleTap(x, y)**点击主输入经营点客 / 打地鼠 / 点按钮等**menu/玩中/结算各 phase 的判定全写在它内部**L1 会在每次 pointerdown **直达**调用它键盘玩法方向键 / 空格 **handleKey(key)**L1 keydown 时调用check 会拦调了 getInput一个输入方法都没暴露

View File

@ -1,6 +1,6 @@
---
id: config.cheap-system
version: 1.6.0
version: 1.6.1
---
你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩、有内容、不易同质化的高质小游戏**(多文件 LittleJS落在 ⟦G⟧/。
@ -49,11 +49,11 @@ A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4
- **插件扁平注入:直接 const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins用到才解构****绝不写 opts.runtime.plugins**(那是 L1 内部的,你见不到 → 写了就 undefined → boot 崩 reading 'define')。游戏只用插件、不 new、不 register。
- **可用插件键(全 11 已注入,用到才取)**sceneFsm / sessionScore / hudUi / timerScheduler / save / gamefeel / juice / palettePost / audioMusic / collision / physics。
- **插件方法名一律以 api.d.ts 为准、别臆测**实测幻觉scene-fsm 写 defineScenes应 define。用到某插件先 read_file 它的 api.d.tsskill「⚠ 实测易犯的幻觉 API」逐条列 ❌→✅。
- **hudUi.drawButton(g, rect, opts) 回吐它绘制的命中矩形 { x, y, w, h }**:✅ 直觉写法就对——const btn = hudUi.drawButton(g, { x, y, w, h }, { label }); if (hudUi.pointInRect(px, py, btn)) 命中处理drawButton 返回命中矩形,直接接住判命中即可)。命中判定走 hudUi.pointInRect(px, py, rect)**只在 hudUicollision 插件没有 pointInRect 方法**)。**第二参是 rect 对象 { x, y, w, h }、不是 label/坐标位置参**——别写 drawButton(g, '开始', x, y, w, h) 这种位置参形态。
- **hudUi.drawButton(g, rect, opts) 回吐它绘制的命中矩形 { x, y, w, h }**:✅ 直觉写法就对——const btn = hudUi.drawButton(g, { x, y, w, h }, { label }); if (hudUi.pointInRect(px, py, btn)) 命中处理drawButton 返回命中矩形,直接接住判命中即可)。命中判定走 hudUi.pointInRect(px, py, rect)**只在 hudUicollision 插件没有 pointInRect 方法**)。**第二参是 rect 对象 { x, y, w, h }、不是 label/坐标位置参**——别写 drawButton(g, '开始', x, y, w, h) 这种位置参形态。**起局/交互按钮的命中矩形必须在状态进入点scene onEnter确定性建立、render 只画不赋值**:绝不让命中矩形只在 render 副作用里生成——并发渲染帧饥饿下 render 可能没跑过一次=矩形恒 null=菜单点不动、一局起不来把按钮几何抽成纯函数onEnter 调它设命中区、render 调它画,同一来源不漂移。
- **sceneFsm.define(name, handlers) 逐场景调一次——绝不单对象批量注册**:❌ sceneFsm.define({ menu: {...}, play: {...}, over: {...} }) → 第一参当成了场景名「[object Object]」、menu/play/over 全没真注册 → 后续 transition('play') 找不到目标态被忽略 → 卡菜单(实测合成/2048 类翻车点)。✅ **每个场景各调一次**sceneFsm.define('menu', { onEnter(){...} }); sceneFsm.define('play', { onEnter(){...}, update(dt){...} }); sceneFsm.define('over', { onEnter(){...} });define 第一参恒为场景名字符串、第二参才是回调表 { onEnter, onExit, update, render }**回调表里 update 只收 (dt)、render 只收 (g)、与五法同形——别臆造场景名/state 首参,绝不写 update(scene, dt)**;可链式 .define(...).define(...))。
- **画面读 viewport.w/h**viewport 是入参;别硬编码 390×844
- **美术资产(若 assets/manifest.json 非空)**:先 read_file('assets/manifest.json') 看有哪些资产init(boot) 里 const assets = boot.assets || {}render 里 const img = assets['ref名']?.image; if (img) g.drawImage(img, x, y, w, h); else 程序化回退。**ref = manifest 条目 file 去扩展名**shopkeeper-idle.jpg → 'shopkeeper-idle')。缺图/加载失败时 assets[ref].image 为 nullhost 已容错)→ 务必走程序化回退、别崩。BGM/音效经 plugins.audioMusic不在 boot.assets。无 manifest/无美术 → 全程序化绘制即可。
- **必须实现 _forensicsView()**(可测性红线 + 自动驱动契约):返回 { state(){…}, measures(){…} }state() 至少 { phase, score }。**若游戏靠点击推进(经营点客/打地鼠/点离散目标等state().targets:[{x,y,occupied}] 列当前屏上目标,可点的(等待的客/冒头的鼠)置 occupied:true、不可点置 false** —— 自动验收据此点 occupied:true 的目标真驱动你的游戏。**score 用累计进展(营收/得分),随推进上升** —— 自动验收据 score 上升判"真有进展"。不写则验证读不到状态check 会拦)。**钉死phase/targets/score 必须在 state() 函数体内部实时读取/计算——绝不在 _forensicsView() 外层先算好再闭包返回**host 只在 boot 调一次 _forensicsView外层算的值会被定格成 boot 快照=永远 menu、targets 永远空 → 自动验收看不到顾客、永判死菜单)。
- **必须实现 _forensicsView()**(可测性红线 + 自动驱动契约):返回 { state(){…}, measures(){…} }state() 至少 { phase, score }。**若游戏靠点击推进(经营点客/打地鼠/点离散目标等state().targets:[{x,y,occupied}] 列当前屏上目标,可点的(等待的客/冒头的鼠)置 occupied:true、不可点置 false** —— 自动验收据此点 occupied:true 的目标真驱动你的游戏。**键名固定为 `targets`,不得改成 items/objects 等**:判卷驱动器靠 state 有无 `targets` 键选驱动器族(点目标族 vs 循环按键族),改名=选错族=盲驱动器驱动不了你的游戏=H 进展门挂。**score 用累计进展(营收/得分),随推进上升** —— 自动验收据 score 上升判"真有进展"。不写则验证读不到状态check 会拦)。**钉死phase/targets/score 必须在 state() 函数体内部实时读取/计算——绝不在 _forensicsView() 外层先算好再闭包返回**host 只在 boot 调一次 _forensicsView外层算的值会被定格成 boot 快照=永远 menu、targets 永远空 → 自动验收看不到顾客、永判死菜单)。
- **关键事件用 ctx.log 记日志**:在 init/场景切换/得分/出错处 调 ctx.log('tag', 信息)。**ctx 来自 init 的入参 boot即「const ctx = boot.ctx」——绝不是 boot.boot.ctx**host 传入的 boot 已是 {ctx, mainContext, canvas, seed, assets},多套一层 .boot=undefined → ctx=null → 无输入、游戏点不动check 会拦 .boot.ctx。插件调用已自动记你只补游戏语义事件。
- **输入契约(钉死·根治"启动不了"**:输入订阅已由 L1game.js wrapper接管**你绝不自己订阅输入**——不写 ctx.getInput、不自建 pendingClicks 队列、不在 update(dt) 里挑时机消费点击。你只写实例方法 **handleTap(x, y)**(点击主输入:经营点客 / 打地鼠 / 点按钮等;**menu/玩中/结算各 phase 的判定全写在它内部**L1 会在每次 pointerdown 时**直达**调用它;键盘玩法(方向键 / 空格)写 **handleKey(key)**L1 在 keydown 时调用。check 会拦「调了 getInput」与「一个输入方法都没暴露」。

View File

@ -121,7 +121,7 @@ prompts:
# 首版正文 = _SYSTEM_PROMPT 内置原文逐字节对齐(含 ⟦G⟧/⟦SCAFFOLD_DESC⟧ 占位符),默认行为字节不变;
# 调一条 prompt = 改 04-config/cheap-system.md 正文 + 升 version下次生成自动生效。
- id: config.cheap-system
version: 1.6.0 # v1.6.0:打磨不降档(创始人 07-04 定调「档位切 scope 不切 polish」——删 spike「能跑能玩即可」残留、hitstop 出禁项改结算标配、设计块强制落盘+finish 前收尾自查、新增 ⑩ 难度曲线、⑨ 经营决策模式指针。v1.5.0:策划知识包 v2——⑨ 否决项加判定句式+内嵌正反例8/9 条计数口径统一prompt.mjs 冻结为 A/B 史料不再双源同改。v1.4.0W-GENRE 品类路由并集(剧情+6/TRPG+7/非遗+8/解谜+9
version: 1.6.1 # v1.6.1:起局鲁棒性 + targets 键契约进红线——①起局/交互按钮命中矩形必须在状态进入点 onEnter 确定性建立、render 只画(治并发 rAF 饥饿下卡 menu②点目标玩法 state 键名固定 `targets`(驱动器契约 C5 依赖,改名=盲驱动器选错族=H 门挂)。v1.6.0:打磨不降档(创始人 07-04 定调「档位切 scope 不切 polish」——删 spike「能跑能玩即可」残留、hitstop 出禁项改结算标配、设计块强制落盘+finish 前收尾自查、新增 ⑩ 难度曲线、⑨ 经营决策模式指针。v1.5.0:策划知识包 v2——⑨ 否决项加判定句式+内嵌正反例8/9 条计数口径统一prompt.mjs 冻结为 A/B 史料不再双源同改。v1.4.0W-GENRE 品类路由并集(剧情+6/TRPG+7/非遗+8/解谜+9
stage: "04-config"
owner: WS2
file: 04-config/cheap-system.md

View File

@ -34,7 +34,7 @@ import {
isStepTappable, hitStation, windowRatio, stepPoints,
} from './core.js';
// 场景级绘制(画在 boot.mainContext);换观感改 render.js。
import { renderMenu, renderPlay, renderOver } from './render.js';
import { renderMenu, renderPlay, renderOver, menuStartRect, overRestartRect } from './render.js';
// 资产装载(本骨架无外部美术,容错返空集;要加美术放 assets/manifest.json 再读 boot.assets)。
import { loadAssets } from './assets.js';
@ -71,7 +71,8 @@ export function createGame({ plugins, bundle, viewport }) {
let redos = 0; // 回炉次数(过窗)
let roundTimer = null; // 60s 开工倒计时句柄
/* ── 命中区(菜单/结算按钮):render 回调实时算并写回(所见即所点)── */
/* (/):** onEnter **( defineScenes),render
* 绝不只在 render 副作用里生成并发 rAF 饥饿下 render 没跑过=矩形恒 null=菜单点不动一局起不来 */
let menuStartBtn = null;
let overRestartBtn = null;
@ -190,8 +191,8 @@ export function createGame({ plugins, bundle, viewport }) {
/** 注册三场景到 scene-fsm(每场景各调一次 define;回调 render 只收 g、update 只收 dt)。 */
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { floats = []; menuStartBtn = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH }).startBtn; },
onEnter() { floats = []; menuStartBtn = menuStartRect({ vw: VW, vh: VH }); }, // 进入 menu 即定命中区(不等 render)
render(g) { renderMenu(g, hudUi, { vw: VW, vh: VH }); }, // render 只画,不写命中矩形
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
@ -203,11 +204,14 @@ export function createGame({ plugins, bundle, viewport }) {
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } },
onEnter() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
overRestartBtn = overRestartRect({ vw: VW, vh: VH }); // 进入 over 即定命中区
},
render(g) {
overRestartBtn = renderOver(g, hudUi, {
renderOver(g, hudUi, { // render 只画,不写命中矩形
vw: VW, vh: VH, wage, worksDone, redos, best: sessionScore.getBest(),
}).restartBtn;
});
},
});
}
@ -254,6 +258,7 @@ export function createGame({ plugins, bundle, viewport }) {
/**
* 取证视图自动验收契约·改坏=九门挂:state() **实时**(绝不外层先算好闭包返回)
* · targets:每工位一项,occupied=true 表示当前就绪窗口内可点的工位自动验收只点 occupied:true 的工位
* **键名固定为 `targets`,不得改成 items/objects **:判卷驱动器(C5) state 有无 `targets` 键选驱动器族,改名=选错族=盲驱动点不到=H 门挂
* · score:= 工钱,随完成工序单调上升自动验收据它判真有进展
* 加机制时这两个键的语义务必保持(targets 列可点目标score 随进展升)
*/

View File

@ -144,18 +144,31 @@ function drawFloats(g, floats) {
/* ────────── 场景绘制入口(导出给 game-logic 的 defineScenes 用)────────── */
/**
* 起局/重开按钮的命中矩形(纯几何,零副作用):game-logic **状态进入点 onEnter** 据此确定性建命中区,
* renderMenu/renderOver 据此画同一来源,命中与绘制永不漂移**绝不把命中矩形只在 render 副作用里
* 生成**:并发渲染帧饥饿下 render 可能尚未跑过一次,命中矩形就恒为 null 菜单点不动一局起不来( menu 案4)
*/
export function menuStartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h };
}
export function overRestartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h };
}
/**
* menu:匾额 + 标题 + 开工按钮换坊名/副标在这里改
* @returns {{startBtn:{x,y,w,h}}} 命中区(drawButton 返回的归一化矩形)
* @returns {{startBtn:{x,y,w,h}}} 命中区(menuStartRect 同源)
*/
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
g.fillStyle = 'rgba(18,12,6,0.55)'; g.fillRect(0, 0, view.vw, view.vh);
hud.drawText(g, '老陶坊', view.vw / 2, view.vh * 0.30, { font: '800 46px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '按工序点亮就绪的工位,火候别过——60 秒出最多成品!', view.vw / 2, view.vh * 0.30 + 60, { font: '15px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
// drawButton 返回它绘制的命中矩形(#5),直接接住当命中区——别再自己另算坐标。
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h },
// 命中矩形由纯几何 menuStartRect 给出(onEnter 已据它建命中区),这里画在同一矩形上——所见即所点。
const startBtn = hud.drawButton(g, menuStartRect(view),
{ label: '开工', font: '700 22px sans-serif', fill: C.accent, textColor: '#241a10', radius: 30 });
return { startBtn };
}
@ -201,8 +214,7 @@ export function renderOver(g, hud, view) {
hud.drawText(g, '出炉:' + names, view.vw / 2, view.vh * 0.40, { font: '600 18px sans-serif', color: C.done, align: 'center', baseline: 'middle' });
hud.drawText(g, '成品 ' + (view.worksDone ? view.worksDone.length : 0) + ' 回炉 ' + view.redos, view.vw / 2, view.vh * 0.46, { font: '600 18px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '最佳工钱 ' + view.best, view.vw / 2, view.vh * 0.46 + 30, { font: '600 16px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
const restartBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h },
const restartBtn = hud.drawButton(g, overRestartRect(view),
{ label: '再开一天', font: '700 22px sans-serif', fill: C.accent, textColor: '#241a10', radius: 30 });
return { restartBtn };
}

View File

@ -31,7 +31,7 @@ import {
generateBoard, remainingMatches, hitTile, matchesRule, hitScore, levelsTotal, levelBonus,
} from './core.js';
// 场景级绘制(画在 boot.mainContext);换观感改 render.js。
import { renderMenu, renderPlay, renderOver } from './render.js';
import { renderMenu, renderPlay, renderOver, menuStartRect, overRestartRect } from './render.js';
// 资产装载(本骨架无外部美术,容错返空集;要加美术放 assets/manifest.json 再读 boot.assets)。
import { loadAssets } from './assets.js';
@ -74,7 +74,8 @@ export function createGame({ plugins, bundle, viewport }) {
let roundTimer = null; // 一局限时句柄
let won = false; // 终局是否通关胜利
/* ── 命中区(菜单/结算按钮):render 回调实时算并写回(所见即所点)── */
/* (/):** onEnter **( defineScenes),render
* 绝不只在 render 副作用里生成并发 rAF 饥饿下 render 没跑过=矩形恒 null=菜单点不动一局起不来 */
let menuStartBtn = null;
let overRestartBtn = null;
@ -213,8 +214,8 @@ export function createGame({ plugins, bundle, viewport }) {
/** 注册三场景到 scene-fsm(每场景各调一次 define;回调 render 只收 g、update 只收 dt)。 */
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { board = null; floats = []; menuStartBtn = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH }).startBtn; },
onEnter() { board = null; floats = []; menuStartBtn = menuStartRect({ vw: VW, vh: VH }); }, // 进入 menu 即定命中区(不等 render)
render(g) { renderMenu(g, hudUi, { vw: VW, vh: VH }); }, // render 只画,不写命中矩形
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
@ -232,13 +233,16 @@ export function createGame({ plugins, bundle, viewport }) {
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } },
onEnter() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
overRestartBtn = overRestartRect({ vw: VW, vh: VH }); // 进入 over 即定命中区
},
render(g) {
overRestartBtn = renderOver(g, hudUi, {
renderOver(g, hudUi, { // render 只画,不写命中矩形
vw: VW, vh: VH, won,
score: sessionScore.getScore(), levelsCleared, bestStreak,
best: sessionScore.getBest(),
}).restartBtn;
});
},
});
}
@ -292,6 +296,7 @@ export function createGame({ plugins, bundle, viewport }) {
* 取证视图自动验收契约·改坏=九门挂:state() **实时**(绝不外层先算好闭包返回)
* · targets:每格一项,**occupied=true 只标当前该点的正确解**(solve 阶段未清符合本关规则非锁定)
* 解谜品类的取证面兼职"求解器",自动验收只点 occupied:true 的格驱动解谜推进
* **键名固定为 `targets`,不得改成 items/objects **:判卷驱动器(C5) state 有无 `targets` 键选驱动器族,改名=选错族=盲驱动点不到=H 门挂
* · score:随点对/过关单调上升自动验收据它判真有进展
* 加机制换主题都别动这两个键的语义(干扰项/已清格/观察窗内一律 occupied:false)
*/

View File

@ -152,9 +152,23 @@ function drawFloats(g, floats) {
/* ────────── 场景绘制入口(导出给 game-logic 的 defineScenes 用)────────── */
/**
* 起局/重开按钮的命中矩形(纯几何,零副作用):game-logic **状态进入点 onEnter** 据此确定性建命中区,
* renderMenu/renderOver 据此画同一来源,命中与绘制永不漂移**绝不把命中矩形只在 render 副作用里
* 生成**:并发渲染帧饥饿下 render 可能尚未跑过一次,命中矩形就恒为 null 菜单点不动一局起不来( menu 案4)
*/
export function menuStartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h };
}
export function overRestartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h };
}
/**
* menu:标题 + 玩法一句话 + 示例元素 + 开始按钮换主题在 THEME/这里改文案
* @returns {{startBtn:{x,y,w,h}}} 命中区(drawButton 返回的归一化矩形)
* @returns {{startBtn:{x,y,w,h}}} 命中区(menuStartRect 同源)
*/
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
@ -165,9 +179,8 @@ export function renderMenu(g, hud, view) {
drawGlyph(g, 'circle', view.vw / 2 - 70, cy, 22, THEME.colors[0].fill);
drawGlyph(g, 'diamond', view.vw / 2, cy, 22, THEME.colors[1].fill);
drawGlyph(g, 'star', view.vw / 2 + 70, cy, 22, THEME.colors[2].fill);
const w = 240, h = 60;
// drawButton 返回它绘制的命中矩形,直接接住当命中区——别再自己另算坐标。
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h },
// 命中矩形由纯几何 menuStartRect 给出(onEnter 已据它建命中区),这里画在同一矩形上——所见即所点。
const startBtn = hud.drawButton(g, menuStartRect(view),
{ label: '开始解谜', font: '700 22px sans-serif', fill: C.accent, textColor: C.bannerText, radius: 30 });
return { startBtn };
}
@ -202,8 +215,7 @@ export function renderOver(g, hud, view) {
hud.drawText(g, '本局得分', view.vw / 2, view.vh * 0.31 + 42, { font: '600 14px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
hud.drawText(g, '通过 ' + view.levelsCleared + ' 关 最高连击 ×' + view.bestStreak, view.vw / 2, view.vh * 0.45, { font: '600 20px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '最佳纪录 ' + view.best, view.vw / 2, view.vh * 0.45 + 32, { font: '600 16px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
const restartBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h },
const restartBtn = hud.drawButton(g, overRestartRect(view),
{ label: '再解一局', font: '700 22px sans-serif', fill: C.accent, textColor: C.bannerText, radius: 30 });
return { restartBtn };
}

View File

@ -29,7 +29,7 @@ import {
isTappable, hitCustomer, patienceRatio,
} from './core.js';
// 场景级绘制(画在 boot.mainContext);换观感改 render.js。
import { renderMenu, renderPlay, renderOver } from './render.js';
import { renderMenu, renderPlay, renderOver, menuStartRect, overRestartRect } from './render.js';
// 资产装载(本骨架无外部美术,容错返空集;要加美术放 assets/manifest.json 再读 boot.assets)。
import { loadAssets } from './assets.js';
@ -67,7 +67,8 @@ export function createGame({ plugins, bundle, viewport }) {
let lost = 0; // 流失顾客数
let roundTimer = null; // 60s 营业倒计时句柄
/* ── 命中区(菜单/结算按钮):render 回调实时算并写回(所见即所点)── */
/* (/):** onEnter **( defineScenes),render
* 绝不只在 render 副作用里生成并发 rAF 饥饿下 render 没跑过=矩形恒 null=菜单点不动一局起不来 */
let menuStartBtn = null;
let overRestartBtn = null;
@ -171,8 +172,8 @@ export function createGame({ plugins, bundle, viewport }) {
/** 注册三场景到 scene-fsm(每场景各调一次 define;回调 render 只收 g、update 只收 dt)。 */
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { customers = []; floats = []; menuStartBtn = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH }).startBtn; },
onEnter() { customers = []; floats = []; menuStartBtn = menuStartRect({ vw: VW, vh: VH }); }, // 进入 menu 即定命中区(不等 render)
render(g) { renderMenu(g, hudUi, { vw: VW, vh: VH }); }, // render 只画,不写命中矩形
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
@ -184,11 +185,14 @@ export function createGame({ plugins, bundle, viewport }) {
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } },
onEnter() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
overRestartBtn = overRestartRect({ vw: VW, vh: VH }); // 进入 over 即定命中区
},
render(g) {
overRestartBtn = renderOver(g, hudUi, {
renderOver(g, hudUi, { // render 只画,不写命中矩形
vw: VW, vh: VH, currency, served, lost, best: sessionScore.getBest(),
}).restartBtn;
});
},
});
}
@ -236,6 +240,7 @@ export function createGame({ plugins, bundle, viewport }) {
/**
* 取证视图自动验收契约·改坏=九门挂:state() **实时**(绝不外层先算好闭包返回)
* · targets:每桌一项,occupied=true 表示有可点的等待顾客自动验收只点 occupied:true 的桌驱动上菜
* **键名固定为 `targets`,不得改成 items/objects **:判卷驱动器(C5) state 有无 `targets` 键选驱动器族,改名=选错族=盲驱动点不到=H 门挂
* · score:= 营收,随上菜单调上升自动验收据它判真有进展
* 加机制时这两个键的语义务必保持(targets 列可点目标score 随进展升)
*/

View File

@ -115,18 +115,31 @@ function drawFloats(g, floats) {
/* ────────── 场景绘制入口(导出给 game-logic 的 defineScenes 用)────────── */
/**
* 起局/重开按钮的命中矩形(纯几何,零副作用):game-logic **状态进入点 onEnter** 据此确定性建命中区,
* renderMenu/renderOver 据此画同一来源,命中与绘制永不漂移**绝不把命中矩形只在 render 副作用里
* 生成**:并发渲染帧饥饿下 render 可能尚未跑过一次,命中矩形就恒为 null 菜单点不动一局起不来( menu 案4)
*/
export function menuStartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h };
}
export function overRestartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h };
}
/**
* menu:招牌 + 标题 + 开始按钮换店名/副标在这里改
* @returns {{startBtn:{x,y,w,h}}} 命中区(drawButton 返回的归一化矩形)
* @returns {{startBtn:{x,y,w,h}}} 命中区(menuStartRect 同源)
*/
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
g.fillStyle = 'rgba(20,12,6,0.55)'; g.fillRect(0, 0, view.vw, view.vh);
hud.drawText(g, '烘焙小馆', view.vw / 2, view.vh * 0.30, { font: '800 46px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '点击等待的顾客上菜,60 秒内赚最多营收!', view.vw / 2, view.vh * 0.30 + 60, { font: '15px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
// drawButton 返回它绘制的命中矩形(#5),直接接住当命中区——别再自己另算坐标。
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h },
// 命中矩形由纯几何 menuStartRect 给出(onEnter 已据它建命中区),这里画在同一矩形上——所见即所点。
const startBtn = hud.drawButton(g, menuStartRect(view),
{ label: '开始营业', font: '700 22px sans-serif', fill: C.accent, textColor: '#1d1208', radius: 30 });
return { startBtn };
}
@ -164,8 +177,7 @@ export function renderOver(g, hud, view) {
hud.drawText(g, '当日营收', view.vw / 2, view.vh * 0.32 + 38, { font: '600 14px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
hud.drawText(g, '订单 ' + view.served + ' 流失 ' + view.lost, view.vw / 2, view.vh * 0.46, { font: '600 20px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '最佳营收 $ ' + view.best, view.vw / 2, view.vh * 0.46 + 32, { font: '600 16px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
const restartBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h },
const restartBtn = hud.drawButton(g, overRestartRect(view),
{ label: '再来一日', font: '700 22px sans-serif', fill: C.accent, textColor: '#1d1208', radius: 30 });
return { restartBtn };
}

View File

@ -30,7 +30,7 @@ import {
initialStats, mergeEndings,
} from './core.js';
// 场景级绘制(画在 boot.mainContext);换观感改 render.js。
import { renderMenu, renderPlay, renderOver } from './render.js';
import { renderMenu, renderPlay, renderOver, menuStartRect, overRestartRect } from './render.js';
// 资产装载(本骨架无外部美术,容错返空集;要加美术放 assets/manifest.json 再读)。
import { loadAssets } from './assets.js';
@ -67,7 +67,9 @@ export function createGame({ plugins, bundle, viewport }) {
let elapsedMs = 0; // play 内累计毫秒(呼吸提示相位用)
const endingIds = allEndingIds(STORY_NODES);
/* ── 命中区:选项矩形由 core.layoutChoices 统一算(命中=绘制=取证三者同源);按钮由 render 回写 ── */
/* : core.layoutChoices (==);
* 起局/重开按钮**在场景进入点 onEnter 确定性赋值**( defineScenes),render 只读不写
* 绝不只在 render 副作用里生成(并发 rAF 饥饿下 render 没跑过=矩形恒 null=菜单点不动一局起不来) */
let choiceRects = [];
let menuStartBtn = null;
let overRestartBtn = null;
@ -160,8 +162,8 @@ export function createGame({ plugins, bundle, viewport }) {
/** 注册三场景到 scene-fsm(每场景各调一次 define;回调 render 只收 g、update 只收 dt)。 */
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { menuStartBtn = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH, title: STORY_TITLE, subtitle: STORY_SUBTITLE }).startBtn; },
onEnter() { menuStartBtn = menuStartRect({ vw: VW, vh: VH }); }, // 进入 menu 即定命中区(不等 render)
render(g) { renderMenu(g, hudUi, { vw: VW, vh: VH, title: STORY_TITLE, subtitle: STORY_SUBTITLE }); }, // render 只画
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
@ -174,12 +176,12 @@ export function createGame({ plugins, bundle, viewport }) {
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { overRestartBtn = null; },
onEnter() { overRestartBtn = overRestartRect({ vw: VW, vh: VH }); }, // 进入 over 即定命中区
render(g) {
overRestartBtn = renderOver(g, hudUi, {
renderOver(g, hudUi, { // render 只画,不写命中矩形
vw: VW, vh: VH, endingNode, stats, statNames: STAT_NAMES,
unlocked: unlockedEndings, endingIds, nodes: STORY_NODES,
}).restartBtn;
});
},
});
}
@ -225,6 +227,7 @@ export function createGame({ plugins, bundle, viewport }) {
* 取证视图自动验收契约·改坏=九门挂:state() **实时**(绝不外层先算好闭包返回)
* · targets:play = 当前每个选项一项(中心坐标,occupied=true 可点)自动验收只点
* occupied:true 的选项驱动剧情推进;menu/over = 空数组(键保留,值为空 driver 不误点)
* **键名固定为 `targets`,不得改成 items/choices **:判卷驱动器(C5) state 有无 `targets` 键选驱动器族,改名=选错族=盲驱动点不到=H 门挂
* · score:= 已做选择数,随推进单调上升自动验收据它判真有进展
* 加机制时这两个键的语义务必保持(targets 列可点选项score 随推进升)
*/

View File

@ -103,10 +103,25 @@ function drawStats(g, hud, statNames, stats, vw, y) {
/* ────────── 场景绘制入口(导出给 game-logic 的 defineScenes 用)────────── */
/**
* 起局/重开按钮的命中矩形(纯几何,零副作用):game-logic **状态进入点 onEnter** 据此确定性建命中区,
* renderMenu/renderOver 据此画同一来源,命中与绘制永不漂移**绝不把命中矩形只在 render 副作用里
* 生成**:并发渲染帧饥饿下 render 可能尚未跑过一次,命中矩形就恒为 null 菜单点不动一局起不来( menu 案4)
* 起局按钮居 0.58H(-LLM 验收 driver 起局扫点的可达区,别挪出 0.46H0.90H)
*/
export function menuStartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h };
}
export function overRestartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h };
}
/**
* menu:标题 + 引子卡 + 开始按钮
* 按钮位于视口中线偏下(0.58H)这是零-LLM 验收 driver 起局扫点的可达区,别挪出 0.46H0.90H
* @returns {{startBtn:{x,y,w,h}}} 命中区(drawButton 返回的命中矩形)
* @returns {{startBtn:{x,y,w,h}}} 命中区(menuStartRect 同源)
*/
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
@ -118,9 +133,8 @@ export function renderMenu(g, hud, view) {
wrapText(g, view.subtitle, cardX + 18, view.vh * 0.32 + 20, cardW - 36, 26, 4);
hud.drawText(g, '每个选择,都通向不同的结局', view.vw / 2, view.vh * 0.50,
{ font: '14px sans-serif', color: C.dim, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
// drawButton 返回它绘制的命中矩形,直接接住当命中区——别再自己另算坐标。
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h },
// 命中矩形由纯几何 menuStartRect 给出(onEnter 已据它建命中区),这里画在同一矩形上——所见即所点。
const startBtn = hud.drawButton(g, menuStartRect(view),
{ label: '开始阅读', font: '700 22px sans-serif', fill: C.accent, textColor: '#241708', radius: 30 });
return { startBtn };
}
@ -187,8 +201,7 @@ export function renderOver(g, hud, view) {
const line = endingIds.map((id) => (got.has(id) ? '「' + nodes[id].title + '」' : '???')).join(' ');
hud.drawText(g, line, vw / 2, vh * 0.53 + 26,
{ font: '13px sans-serif', color: C.dim, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
const restartBtn = hud.drawButton(g, { x: vw / 2 - w / 2, y: vh * 0.66 - h / 2, w, h },
const restartBtn = hud.drawButton(g, overRestartRect(view),
{ label: '再读一次', font: '700 22px sans-serif', fill: C.accent, textColor: '#241708', radius: 30 });
return { restartBtn };
}

View File

@ -32,7 +32,7 @@ import {
cardTappable, hitCard,
} from './core.js';
// 场景级绘制(画在 boot.mainContext);换观感改 render.js。
import { renderMenu, renderPlay, renderOver } from './render.js';
import { renderMenu, renderPlay, renderOver, menuStartRect, overRestartRect } from './render.js';
// 资产装载(本骨架无外部美术,容错返空集;要加美术放 assets/manifest.json 再读 boot.assets)。
import { loadAssets } from './assets.js';
@ -78,7 +78,8 @@ export function createGame({ plugins, bundle, viewport }) {
let nextCardId = 1; // 卡自增 id
let roundTimer = null; // 60s 试炼倒计时句柄
/* ── 命中区(菜单/结算按钮):render 回调实时算并写回(所见即所点)── */
/* (/):** onEnter **( defineScenes),render
* 绝不只在 render 副作用里生成并发 rAF 饥饿下 render 没跑过=矩形恒 null=菜单点不动一局起不来 */
let menuStartBtn = null;
let overRestartBtn = null;
@ -220,8 +221,8 @@ export function createGame({ plugins, bundle, viewport }) {
/** 注册三场景到 scene-fsm(每场景各调一次 define;回调 render 只收 g、update 只收 dt)。 */
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { cards = []; floats = []; menuStartBtn = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH }).startBtn; },
onEnter() { cards = []; floats = []; menuStartBtn = menuStartRect({ vw: VW, vh: VH }); }, // 进入 menu 即定命中区(不等 render)
render(g) { renderMenu(g, hudUi, { vw: VW, vh: VH }); }, // render 只画,不写命中矩形
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
@ -236,12 +237,15 @@ export function createGame({ plugins, bundle, viewport }) {
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } },
onEnter() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
overRestartBtn = overRestartRect({ vw: VW, vh: VH }); // 进入 over 即定命中区
},
render(g) {
overRestartBtn = renderOver(g, hudUi, {
renderOver(g, hudUi, { // render 只画,不写命中矩形
vw: VW, vh: VH, gold, floor, kills, opens, crits, heroDown,
level: hero.level, best: sessionScore.getBest(), outcome: sessionScore.getOutcome(),
}).restartBtn;
});
},
});
}
@ -287,6 +291,7 @@ export function createGame({ plugins, bundle, viewport }) {
/**
* 取证视图自动验收契约·改坏=九门挂:state() **实时**(绝不外层先算好闭包返回)
* · targets:本层每张卡一项,occupied=true 表示可点的遭遇卡自动验收只点 occupied:true 的卡驱动掷骰
* **键名固定为 `targets`,不得改成 items/cards **:判卷驱动器(C5) state 有无 `targets` 键选驱动器族,改名=选错族=盲驱动点不到=H 门挂
* · score:= 金币战利品,随结算单调上升(失败也有安慰分)自动验收据它判真有进展
* 加机制时这两个键的语义务必保持(targets 列可点目标score 随进展升)
*/

View File

@ -128,9 +128,23 @@ function drawFloorBanner(g, hud, view) {
/* ────────── 场景绘制入口(导出给 game-logic 的 defineScenes 用)────────── */
/**
* 起局/重开按钮的命中矩形(纯几何,零副作用):game-logic **状态进入点 onEnter** 据此确定性建命中区,
* renderMenu/renderOver 据此画同一来源,命中与绘制永不漂移**绝不把命中矩形只在 render 副作用里
* 生成**:并发渲染帧饥饿下 render 可能尚未跑过一次,命中矩形就恒为 null 菜单点不动一局起不来( menu 案4)
*/
export function menuStartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.60 - h / 2, w, h };
}
export function overRestartRect(view) {
const w = 240, h = 60;
return { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h };
}
/**
* menu:塔门 + 标题 + 玩法一句话 + 开始按钮换冒险名/副标在这里改
* @returns {{startBtn:{x,y,w,h}}} 命中区(drawButton 返回的矩形)
* @returns {{startBtn:{x,y,w,h}}} 命中区(menuStartRect 同源)
*/
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
@ -140,9 +154,8 @@ export function renderMenu(g, hud, view) {
// 玩法核心一屏读懂(零阅读上手:三行图例)
hud.drawText(g, '⚔ 怪物:高奖励·失败扣血', view.vw / 2, view.vh * 0.42, { font: '600 15px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
hud.drawText(g, '◆ 宝箱:稳收益 ✦ 奇遇:险中带甜', view.vw / 2, view.vh * 0.42 + 26, { font: '600 15px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
// drawButton 返回它绘制的命中矩形,直接接住当命中区——别再自己另算坐标。
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.60 - h / 2, w, h },
// 命中矩形由纯几何 menuStartRect 给出(onEnter 已据它建命中区),这里画在同一矩形上——所见即所点。
const startBtn = hud.drawButton(g, menuStartRect(view),
{ label: '进塔试炼', font: '700 22px sans-serif', fill: C.accent, textColor: '#160c2a', radius: 30 });
return { startBtn };
}
@ -175,8 +188,7 @@ export function renderOver(g, hud, view) {
hud.drawText(g, '爬到第 ' + view.floor + ' 层 · Lv.' + view.level, view.vw / 2, view.vh * 0.44, { font: '700 20px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '击败 ' + view.kills + ' · 开箱 ' + view.opens + ' · 大成功 ' + view.crits, view.vw / 2, view.vh * 0.44 + 30, { font: '600 16px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
hud.drawText(g, '最高纪录 金 ' + view.best, view.vw / 2, view.vh * 0.44 + 60, { font: '600 16px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
const restartBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h },
const restartBtn = hud.drawButton(g, overRestartRect(view),
{ label: '再闯一次', font: '700 22px sans-serif', fill: C.accent, textColor: '#160c2a', radius: 30 });
return { restartBtn };
}

View File

@ -22,8 +22,9 @@
import { spawnTarget, ROUND_MS, TARGET_RADIUS } from './core.js';
// 资产装载(本款无外部资产,loadAssets 容错返回空集;结构留全便于加美术)。
import { loadAssets } from './assets.js';
// 场景级绘制(画在 boot.mainContext;命中区由 render 的按钮矩形给出,所见即所点)。
import { renderMenu, renderPlay, renderOver } from './render.js';
// 场景级绘制(画在 boot.mainContext);起局/重开按钮命中矩形由纯几何 menuStartRect/overRestartRect 给出,
// 在场景进入点 onEnter 确定性建立(不依赖渲染帧),render 只负责画。
import { renderMenu, renderPlay, renderOver, menuStartRect, overRestartRect } from './render.js';
/** 固定逻辑步(与 host FIXED_DT 对齐)。 */
const FIXED_DT = 1 / 60;
@ -54,6 +55,8 @@ export function createGame({ plugins, bundle, viewport }) {
let target = null;
let roundTimer = null;
let remainMs = 0;
// 【起局契约·钉死】起局/重开按钮命中矩形在**场景进入点 onEnter** 确定性赋值(见 defineScenes),
// render 只读不写——绝不只在 render 副作用里生成(并发 rAF 饥饿下 render 没跑过=矩形恒 null=菜单点不动)。
let menuStartBtn = null;
let overRestartBtn = null;
@ -112,15 +115,15 @@ export function createGame({ plugins, bundle, viewport }) {
/** 注册 3 个场景到 scene-fsm(状态名你定;render 回调把命中区暴露给 onTap)。 */
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { target = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi).startBtn; },
onEnter() { target = null; menuStartBtn = menuStartRect(); }, // 进入 menu 即定命中区(不等 render)
render(g) { renderMenu(g, hudUi); }, // render 只画,不写命中矩形
});
sceneFsm.define(SCENE_PLAY, {
render(g) { renderPlay(g, hudUi, { score: sessionScore.getScore(), remainMs, target }); },
});
sceneFsm.define(SCENE_OVER, {
onEnter() { target = null; if (roundTimer != null) { timerScheduler.cancel(roundTimer); roundTimer = null; } },
render(g) { overRestartBtn = renderOver(g, hudUi, { score: sessionScore.getScore(), best: sessionScore.getBest() }).restartBtn; },
onEnter() { target = null; if (roundTimer != null) { timerScheduler.cancel(roundTimer); roundTimer = null; } overRestartBtn = overRestartRect(); }, // 进入 over 即定命中区
render(g) { renderOver(g, hudUi, { score: sessionScore.getScore(), best: sessionScore.getBest() }); }, // render 只画
});
}
@ -166,7 +169,13 @@ export function createGame({ plugins, bundle, viewport }) {
inited = false;
},
/** 取证视图(可选·建议):host.state()/onReady/九门据此读状态 + 度量(JSON 安全)。 */
/**
* 取证视图(可选·建议):host.state()/onReady/九门据此读状态 + 度量(JSON 安全)
* targets 键契约·C5点目标玩法(点客/打地鼠/点离散目标)必须在 state() 里暴露 `targets` 数组
* [{x,y,occupied}]**键名固定为 `targets`,不得改成 items/objects **:判卷驱动器靠 state 有无
* `targets` 键选驱动器族(点目标族 vs 循环按键族),改名=选错族=盲驱动器点不到你的目标=H 进展门挂
* 本款是单目标点击,暴露 `target`(单数,同被驱动器识别);多目标一律用 `targets` 数组
*/
_forensicsView() {
return {
state: () => ({

View File

@ -89,6 +89,15 @@ export function centerButtonRect(cy, label) {
return { x: W / 2 - w / 2, y: cy - h / 2, w, h };
}
/**
* 起局/重开按钮的命中矩形(纯几何,零副作用):game-logic **状态进入点 onEnter** 据此确定性
* 建命中区,renderMenu/renderOver 据此画同一来源,命中与绘制永不漂移
* **绝不把命中矩形只在 render 副作用里生成**:并发渲染帧饥饿下 render 可能尚未跑过一次,
* 命中矩形就恒为 null 菜单点不动一局起不来( menu 归因案4)
*/
export function menuStartRect() { return centerButtonRect(H * 0.56, '开始'); }
export function overRestartRect() { return centerButtonRect(H * 0.6, '再来一局'); }
/** 画按钮(委托 hud-ui,本款视觉色经 opts 注入)。 */
function drawButton(g, hud, rect, label) {
hud.drawButton(g, rect, { label, font: '600 22px sans-serif', fill: COLOR.accent, textColor: '#06201d', radius: 30 });
@ -106,7 +115,7 @@ export function renderMenu(g, hud) {
drawBackdrop(g);
hud.drawText(g, '点圆得分', W / 2, H * 0.32, { font: '700 44px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '限时 20 秒,点中圆圈得分', W / 2, H * 0.32 + 48, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
const startBtn = centerButtonRect(H * 0.56, '开始');
const startBtn = menuStartRect();
drawButton(g, hud, startBtn, '开始');
return { startBtn };
}
@ -131,7 +140,7 @@ export function renderOver(g, hud, view) {
hud.drawText(g, '时间到!', W / 2, H * 0.28, { font: '700 40px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '本局得分 ' + view.score, W / 2, H * 0.28 + 60, { font: '600 26px sans-serif', color: COLOR.target, align: 'center', baseline: 'middle' });
hud.drawText(g, '最佳分 ' + view.best, W / 2, H * 0.28 + 100, { font: '18px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
const restartBtn = centerButtonRect(H * 0.6, '再来一局');
const restartBtn = overRestartRect();
drawButton(g, hud, restartBtn, '再来一局');
return { restartBtn };
}

View File

@ -16,6 +16,7 @@
import { writeFileSync, readFileSync, mkdirSync, cpSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url'; // 入口守卫用:判定本模块是否被「直接执行」(见文件末尾)
import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir, ensurePlaySpec } from './tools.mjs';
import { chat, MODEL } from './m3.mjs';
import { TOOLS, buildSystemPrompt, DESIGN_TOOLS, buildDesignSystemPrompt } from './prompt.mjs';
@ -326,4 +327,10 @@ async function main() {
}
}
main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); });
// ── 入口守卫(footgun 兜底)────────────────────────────────────────────────
// 缘由:gen.mjs 曾因顶层裸调 main() 被「import 一次」就跑真生成(~116s、烧钱烧时)。
// 仅当作为脚本「直接执行」(node …/gen.mjs …)时跑 main;被 import(测试/工具引用)时不触发。
// idiom 同 scripts/build.mjs:比对 import.meta.url 与 process.argv[1] 的规范 file:// URL。
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); });
}