merge: W-GENRE 解谜品类四件套(skill puzzle-game-design + _template-puzzle 9/9 + _fewshot-puzzle 正例 + rubric fixture/金标 spec + 范例签名机器门)——品类路由并集 v1.4.0(剧情6/TRPG7/非遗8/解谜9)
Some checks failed
docs-gate / docs-gate (push) Has been cancelled

合并裁定(rubric 挂点两套实现收敛,fable 亲审):评分尺主干 = TRPG 机制(单 checks 数组契约+嵌套
richness.genre,19 单测钉住);从分支嫁接 fixtures/genre-rubrics/<genre>.json 加载器为第二来源
(内联 GENRE_CHECKLISTS 优先、fixture 兜底,puzzle.json 即刻生效);分支平铺 genreHits/genreChecks
双数组契约弃用,其接线测试适配主干形状。run_studio 增显式 genre= 参数(None→按模板推断,原行为不变)。
GENRE_BY_TEMPLATE += _template-puzzle。顺手补账:.agents/README 索引四个品类 skill(前三次 merge 欠账)。

验收(本机):金标 7 品类 6/6 + cheap_verify 19/19 + 解谜资产门 6/6(含 node scaffold/check 真 shell-out)
+ 模板 node 13/13 + check_registry 17 条 + node --check/py_compile 全过;test_roles 2 红=主干既有基线
(回落分叉,归一单已排)。

诚实记录:分支验收唯「n=3-5 小批全过门」BLOCKED(4/7;三失败=AI 大面重写引入运行时 bug,九门正确
拦截,根因已回写 skill §9 品类红线;spike 路无续修环是结构缺口,生产路 RepairMiddleware 覆盖)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-02 23:48:41 -07:00
commit db4aadd785
38 changed files with 3569 additions and 19 deletions

View File

@ -46,6 +46,10 @@
| `add-business-module.md` | 新增一个 game-module 业务模块的标准步骤 |
| `littlejs-game-dev.md` | AI 写 LittleJS 游戏代码作业手册code 层12 插件 API + 代码结构 + 工厂五法 + 资产/mmx 流 + 受控面铁律,终态产物 = `src/` 多文件工程 |
| `sim-business-game-design.md` | 经营模拟小游戏玩法设计手册(给生成 agent 的设计阶段):爆款范式/数值/美术/UI/音 → 映射 12 插件 + 反"无趣"8 条自检;与 littlejs-game-dev 配对 |
| `narrative-game-design.md` | 剧情/互动叙事品类设计手册W-GENRE 件①):分支选择+多结局图鉴范式 / 文本红线 / 可达性红线含取证契约 |
| `trpg-game-design.md` | TRPG/掷骰冒险品类设计手册W-GENRE 件①):掷骰可见 / 风险回报取舍 / 难度爬坡登顶 / 反无趣自检 |
| `heritage-game-design.md` | 非遗/传统技艺品类设计手册W-GENRE 件①):工序链玩法化 / 双层奖励 / 文化表述红线 / 反贴皮自检 |
| `puzzle-game-design.md` | 解谜品类设计手册W-GENRE 件①):顿悟距离 / 规则递进关卡阶梯 / 卡壳兜底 / 品类可达红线(解映射 targets occupied |
| `runtime-and-multichannel.md` | 运行时打包、iframe 沙箱、SDK 注入与多渠道(微信/抖音/快手)导出手册 |
| `contract-first-development.md` | 契约先行8 类契约对齐与各工位 mock 并行解耦 |
| `prompt-governance.md` | Prompt 即第 8 契约Registry/加载注入/eval 门禁/HITL 治理手册 |

View File

@ -0,0 +1,185 @@
# skill: puzzle-game-design —— 解谜小游戏「玩法设计」作业手册(给生成 agent 的设计阶段)
> 定位:这是 **生成 agent 设计阶段造"好玩的解谜玩法设计"的唯一作业手册**。它回答 **"设计什么才好玩"**(谜题机制/关卡进度/数值/美术/音/UI 范式),与 [`littlejs-game-dev.md`](littlejs-game-dev.md)(回答"代码怎么写")配对:**设计阶段产玩法概念 → 代码生成阶段照 littlejs-game-dev 产代码**。经营/养成类的对应手册是 [`sim-business-game-design.md`](sim-business-game-design.md);两份体例相同、品类不同。
>
> **铁律(创始人 2026-06-21)**:"好玩 = 上游设计层职责",不在 runtime/插件。插件只让设计**能被 juice 出手感**;**这份 skill 负责让设计本身好玩**。
>
> **可达性边界**:产出必须落得到 A-model 轻量运行时——**LittleJS 2D 手机竖屏(390×844)、单/少场景、便宜模型可生成的多文件代码、11 注入插件能力域**。解谜品类还有一条**品类特有的可达红线:谜题的"解"必须能被零-LLM 自动验收 driver 点出来**(§9,设计再妙、driver 解不动就会被九门误判)。
>
> **上位标准**:分层质量与验收口径 = [`docs/architecture/架构/生成引擎/游戏质量与爆火能力.md`](../../docs/architecture/架构/生成引擎/游戏质量与爆火能力.md)(L1 机制可玩/L2 内容丰富/L3 留存结构/L4 传播钩子;品类 rubric 计分制见其 §4)。本品类 rubric 机器版(喂 LLM 验证 agent)= `cheap-worker/fixtures/genre-rubrics/puzzle.json`,与本文 §10 品类条目**同源**——改任一处必须同改另一处并做金标复验。
>
> **证据基**:范式蒸馏自近年微信/抖音「找茬/脑洞益智/消除/记忆翻牌」品类的成熟模式(训练知识,非实时榜单);范式稳定可用,具体举例供参照。
---
## 0. 为什么这一品类爆(底层动机 —— 设计要击中这些)
解谜小游戏的"爽"来自 6 个心理引擎,**好玩 = 同时点燃尽量多个**;无趣 = 一个都没点燃:
1. **顿悟时刻(啊哈!)**:看一眼没头绪 → 观察两秒 → "我懂了!"——线索与解之间必须有一段**想一下的距离**,这是解谜区别于反应点击的命脉。
2. **胜任感(我变聪明了)**:解开=智力被确认;连续解开=滚雪球的自信。反面:靠乱点也能过 = 顿悟感全无。
3. **清屏快感**:把盘面上的目标一个个点掉、清空、整理归位的强迫症满足(消除/配对/归类共用这条)。
4. **递进挑战与"差一点"**:关卡一关比一关难一点点;卡住时"就差一个没找到"的张力,引向提示/重试,**别卡死**。
5. **炫耀智商**:成绩(用时/步数/星级/关数)天然可比较、可截图——解谜是传播钩子最顺手的品类。
6. **低门槛零阅读**:规则靠第一关"点一下就懂"示范,不靠文字说明书;3 秒上手,谜题深度靠后续关卡展开。
> **反"无趣"总纲**:解谜无趣几乎总是因为——谜面一眼看穿(无顿悟)/ 关卡重复同一规则(无递进)/ 乱点也能过(无胜任感)/ 卡死无提示(挫败流失)/ 无音效无庆祝(解开了没反馈)。§10 是落地清单。
---
## 1. 核心玩法范式(可达子集 —— design 优先从这里选)
| 范式 | 一句话 | 我们运行时怎么落(插件) |
|---|---|---|
| **规则匹配谜盘**(主流首选) | 每关给一条线索(如"找出所有【红宝石】"),盘面若干格,点中所有符合的即过关;规则随关卡换维度(颜色→形状→数量→组合) | core 纯逻辑产盘面+判定 + hud-ui 线索横幅/格子 + session-score 计分 + particles-juice 点中爆点 |
| **找不同 / 找目标** | 一堆干扰项里藏 N 个目标(不同色/缺一角/多一笔),全找到过关 | core 产干扰+目标 + render 细微差异绘制 + juice 找到闪光 |
| **顺序推理** | 按推理出的次序点(从小到大/按亮灯顺序/按字序),点错断连击 | core 产序列+当前该点项 + hud-ui 高亮 + timer-scheduler 节奏 |
| **记忆翻牌配对** | 短暂展示后盖住,凭记忆点出成对目标 | core 记牌面 + timer-scheduler 展示倒计时;**两步操作须守 §9 的 occupied 跟随纪律** |
| **归类整理** | 把混在一起的东西按规则归拢(点出所有"属于厨房的") | 同规则匹配谜盘,规则换成类别归属 |
| **消除变体(点选式)** | 点走相邻同色/凑三消除,清空过关 | core 盘面+消除判定;**只用点选、不做拖拽滑动**(§9) |
| **星级 / 收集图鉴** | 每关按用时/步数评 13 星,攒星解锁新章节/新主题 | session-score(best 接 save)+ save-progress 存星 + hud-ui 图鉴格 |
| **数值成长曲线** | 每关基分 + 连击倍率 + 剩时奖励,制造"这把更高分"的重开欲 | balance 数值表 + session-score |
**组合配方(经验)**:一款轻量解谜 = **1 个谜题主范式 + 关卡阶梯(规则随关递进)+ 连击/星级一个成长点缀 + 收集/称号一个第二动机**。别堆超过 2 个主机制(便宜模型 + 单屏扛不住,也稀释顿悟感)。
---
## 2. 关卡 / 进度 / 留存设计(解谜的进度结构 = 关卡阶梯本身)
解谜品类里,「解锁阶梯」的品类形态 = **关卡链:每过一关解锁下一关,且新关引入新规则/新维度/更大盘面**——这就是质量模型底线四件里"解锁阶梯 ≥3 级"的解谜映射(纯换汤不换药的重复关不算阶梯)。
- **首 3 分钟(决定生死)**:① 010 秒:第一关零阅读、一眼能解(示范规则,点一下就有反馈);② 1060 秒:连过 23 关,第一次出现"要想一下"的关 + 第一次连击/星级奖励(给到"我变聪明了"的爽);③ 13 分钟:露出后续内容预告(下一章图鉴 / "第 8 关解锁新玩法"的锁),建立"还有很多"预期。
- **难度曲线**:难度 = 盘面大小 × 规则维度 × 干扰强度,三个旋钮**一次只拧一个**。节奏 = 23 关舒适 → 1 关"要想一下" → 过关大庆祝 → 回舒适;顿悟关是爽点、不是墙。
- **卡点设计**:玩家 ≥812 秒无正确操作 = 卡壳信号 → 给**软提示**(候选高亮/线索重播/排除一个干扰项),**卡壳不出局**;提示是"诱惑"(将来可接看广告解锁),不是"惩罚"。
- **留存钩子**:每日谜题(当日专属一关)· 章节图鉴(攒星点亮)· 连胜纪录 · 称号阶梯("见习侦探→推理大师")。便宜档至少给一种。
- **长线**:章节主题轮换(水果→宝石→古文字)· 图鉴凑齐 · 无尽模式冲纪录。
---
## 3. 数值与节奏(给具体区间锚 —— 别让 code agent 拍脑袋)
- **盘面与解数**:起步 **3×3 格、每关 35 个正确目标**;上限 4×4(竖屏手指友好,格子 ≥72px);目标太少无清屏感、太多变体力活。
- **观察窗口**:出题后留 **0.81.5s** 让玩家扫一眼盘面再判定(别一出题就算超时);记忆类展示 **1.52.5s** 再盖住。
- **一关时长**:**820s/关**为舒适区;整局限时 6090s 或 812 关到终局,两种收口都可(限时更适合自动验收,见 §9)。
- **计分骨架**:每个正确目标基分(如 +10)+ **连击倍率**(2s 内连续正确 ×1.5/×2,断连归 1)+ 过关奖励(+30 起、随关数上浮 ×1.15/关)+ 剩时奖励。让"聪明又快"肉眼变成大分差。
- **错点惩罚(轻)**:点错 = 抖屏 + 断连击 + 短锁定 **0.30.5s**;**绝不倒扣到看不见涨分**(分数总体must上升,挫败=流失;真玩验收也断言 score 递增)。
- **双货币(变现预留)**:星星(过关挣,解锁章节)+ 提示券(稀缺,卡壳消耗,**经看广告或攒**)。看广告点位:卡壳要提示 · 失败续 10 秒 · 结算翻倍。包装成"福利",不打断顿悟爽点。
---
## 4. 美术风格范式(可达 + mmx 可产 + 程序化兜底)
| 风格 | 调性 | 配色 | 适合 |
|---|---|---|---|
| **明快益智扁平**(主流首选) | 干净聪明 | 高对比糖果色 + 深底 | 绝大多数谜盘/找茬 |
| **夜空霓虹** | 专注冷静 | 深蓝底 + 霓虹高亮 | 逻辑/序列/记忆 |
| **纸面手绘** | 轻松幽默 | 米纸底 + 铅笔线 | 脑洞/找不同 |
| 国风雅致 | 沉静 | 宣纸 + 黛青/朱砂 | 古文字/七巧板主题 |
- **谜面可读性 > 一切装饰**:格子间距 ≥12px、目标属性(颜色/形状/数量)一眼可辨——看不清的谜面 = 假难度,是解谜品类头号美术错误。
- **高亮语言要统一**:可点/选中/正确/错误四态各一种视觉语言(描边/发光/抖动/变灰),全程一致。
- **美术一致性之根**:全程共用一组"风格词",mmx 每次生成都带它;**程序化兜底**——纯几何图形(圆/方/星/菱)+ 统一色板就能做出体面的谜盘,**别因美术阻塞可玩性**。
---
## 5. UI 范式(hud-ui 全可达)
- **线索横幅**:盘面上方一条醒目横幅写本关线索("找出所有【红色宝石】");换关时横幅刷新 + 轻动画;
- **顶部 HUD**:关卡数(第 3/8 关)/ 分数 / 剩余时间条(drawBar);
- **连击提示**:连续正确时"连击 ×2!"飘字放大;
- **过关庆祝**:整盘清空瞬间全屏小庆祝(闪白 + 粒子 + 音效 + "第 N 关完成!");
- **卡壳软提示**:候选格轻微脉动/发光(hud-ui 变色重绘即可);
- **结算面板**:大分数 + 通过关数 + 最佳纪录 + 星级/称号 + 再来一局按钮——**结算就是炫耀时刻,别打完黑屏**。
---
## 6. 音乐 / 音效范式(audio-music 可达)
- **BGM**:轻快专注循环(思考不烦躁);可随关卡后期 setIntensity 提张力。
- **音效(顿悟感的一半,务必有)**:点中正确"叮"(click/chime)· 连击升调 · 点错闷响(thud)· 过关号角(win)· 星级揭晓(coin)。**即时音反馈是"解开了"的确认信号**——audioMusic `playSfx`,无声卡静默降级不崩。
- 红线:**宁可程序化音效,也别没有反馈音**(解开无声 = 顿悟感直接减半)。
---
## 7. 首屏 / 留存视觉钩子(通用,MVP 也用)
- 首屏 3 秒抓人:**直接给第一题**(菜单一点就进第一关,别先放教程墙);
- "关卡地图/章节图鉴"的进度预告(点亮了 3 格、后面还有一长串);
- 成绩对比预期(最佳纪录摆在结算正中,"下把能更高");
- 称号/星级当门面(情感锚 + 炫耀点)。
---
## 8. 设计阶段输出配方(生成 agent 据 brief 产出这些 → 喂代码生成)
给定一句话 brief,设计阶段应产出一份**结构化玩法设计**,**逐项落到运行时**:
1. **谜题主范式**(从 §1 选一个;一句话:玩家看什么线索 → 怎么想 → 点什么);
2. **关卡阶梯**(36 关起步:每关的盘面大小 / 规则维度 / 新引入什么——逐关写清,这是解谜的解锁阶梯);
3. **数值骨架**(基分 / 连击倍率 / 过关奖励 / 错点惩罚 / 观察窗口与一关时长 —— 按 §3 区间给具体值,交 core/balance);
4. **卡壳兜底**(几秒算卡壳 / 提示形式);
5. **美术风格词**(一组,全资产共用)+ 谜面元素清单(格子/目标/干扰项,标 mmx 或程序化);
6. **音效清单**(正确/错误/连击/过关/星级)+ BGM 情绪;
7. **UI 布局**(线索横幅 / HUD / 连击飘字 / 结算面板,§5);
8. **插件选择清单**(常用:sceneFsm + sessionScore + hudUi + timerScheduler + audioMusic + juice,记忆类加 save 存最佳);
9. **首 3 分钟脚本**(逐关:示范关→舒适关→首个顿悟关→内容预告);
10. **取证契约声明**(§9:targets 怎么列、occupied 怎么标、score 怎么涨——设计阶段就写清,别等实现时才想)。
> **MVP-first 铁律(关乎 code agent 能否收敛)**:首版**只做可玩核心**——1 个谜题主范式 + 35 关阶梯 + 基分/连击 + 基础音效,谜面元素 ≤3 种属性维度。**每日谜题 / 图鉴 / 称号系统 / 无尽模式 / 提示券商店 一律标「后续·MVP 不做」**,别一稿堆满 —— 设计太满 → code agent 实现负担过重 → read-thrash 不收敛。**先出能玩的核心,再谈丰富。**
> 这份设计 = 代码生成阶段的"图纸";生成 agent 照 littlejs-game-dev 把它实现为多文件代码。**设计阶段把"好玩"想清楚,代码阶段只管正确实现。**
---
## 9. 可达性红线(落不到运行时的设计 = 废稿,必剔除/简化)
- ❌ 拖拽 / 滑动 / 连线 / 画线为核心操作(华容道滑块、连连看划线、一笔画)→ 零-LLM 自动验收 driver **只会单击**,拖不动 → 改成**点选等价形态**(点两格交换、点格子步进、点序列下一项)。
- ❌ 大段文字谜面 / 灯谜 / 阅读理解 → 竖屏小字读不动、机器也验不了;线索用**视觉属性**(颜色/形状/数量/位置)表达,横幅一行字封顶。
- ❌ 多屏关卡地图 / 大世界章节 → 单/少场景(menu→play→over),章节感用主题轮换与图鉴表达。
- ❌ 复杂谜题编辑器 / 用户出题 → 超出便宜档;盘面一律 core 纯逻辑程序化生成(受控随机,可复现)。
- ❌ 超过 2 个主机制(谜盘+消除+记忆混一款)→ 聚焦 1 主范式 + 1 成长点缀。
- ❌ **「解」不在取证面上**——这是解谜品类的头号验收翻车点:**`_forensicsView().state().targets` 必须兼职"求解器"——当前**该点的正确目标**标 `occupied:true`、其余(干扰项/已清/锁定中)标 `false`**;自动验收只点 `occupied:true` 的格子,解谜逻辑再对、occupied 标错 = 九门 H 验不到进展 = 好游戏被误判。多步范式(翻牌配对:先翻 A 再翻 B)必须**随状态把"下一步该点的"跟标 occupied**(翻开 A 后,把 A 的配对牌标 occupied);做不到就改单步范式。
- ❌ **重写盘面生成器而丢「可解性质」**——解谜品类第二号翻车点(批产实测:自写符文生成器产出 0 解盘面 → 第一关就无解 → 永无 occupied 目标 → 九门 H 挂):脚手架的 `generateBoard` 保证**每关恰好 N 个解、至少 1 个解、干扰项绝不撞规则**;换主题/换规则维度**优先只改 THEME/LEVELS 表**、别动生成器;确要自写规则系统,必须保住同一性质(生成后自检一遍「符合规则的格数 == 预期解数、≥1」,0 解=不可解关=废稿)。
- ❌ **在场景 render 回调里引用未定义变量**——解谜档的按钮命中区(开始/再来)靠 render 返回值回写(`menuStartBtn = renderMenu(...)`),render 回调抛错会被 scene-fsm **隔离成告警不崩**,后果是命中区永远拿不到 → 点不进游戏、卡菜单(批产实测:renderMenu 里一个未定义的 `cy` 让整局 E/G/H 三门连锁挂)。改完 render 先顺一遍所有变量来源。
- ❌ 点错即死 / 三错清零重开 → 错点轻惩罚(§3),乱点不该赢、但点错不该死。
- ✅ 留下的:规则匹配谜盘、找目标、顺序推理、点选消除、记忆配对(守 occupied 纪律)、星级收集、关卡阶梯、连击计分 —— 全在 11 注入插件 + 单屏 + 便宜模型可生成范围内。
---
## 10. 反"无趣"落地清单(design 自检,逐条必过)
**通用 8 条**(全品类同一张表,与 sim-business §10 同源;解谜视角的落点写在括号里):
1. **即时反馈**:每次正确点击有没有"飘字 + 音效 + 粒子"三件套?(无 → 必加)
2. **可见成长**:分数/关数有没有肉眼可见地涨?(连击倍率让它涨得有戏剧性)
3. **下一个解锁**:任意时刻有没有"下一关引入什么/差几颗星解锁下一章"的钩子?
4. **30 秒爽点**:开局 30 秒内有没有连过两关 + 第一次连击奖励?
5. **数值滚雪球**:连击×剩时奖励有没有让"聪明又快"变成大分差(而非匀速加分)?
6. **情感锚**:有没有一个可积攒的拥有物(星星/图鉴/称号)让玩家"想凑齐"?
7. **放置回归**:回来有没有惊喜(每日谜题/连胜保留)?(便宜档至少一种,标「后续」也要在设计里留位)
8. **音反馈**:正确/错误/过关有没有声音?(哪怕程序化)
**解谜品类扩展 5 条**(机器版同源 = `cheap-worker/fixtures/genre-rubrics/puzzle.json`,LLM 验证 agent 按它评分;改条目两处必须同改 + 金标复验):
| # | 层 | 条目 | 一句判据 | 正例 | 反例 |
|---|---|---|---|---|---|
| P1 | L2 | 谜题规则递进 | 关卡引入 ≥2 种谜题规则/维度,后关比前关多一层复杂度,不是同一规则换数字重复 | 12 关按颜色找、34 关按形状找、5 关颜色+形状组合 | 8 关全是"找红色",只是格子变多 |
| P2 | L2 | 顿悟距离 | 谜面需观察/比对/推理才能定位解,线索与答案之间有"想一下"的距离,不是看见就点的纯反应 | 线索"数量为 3 的宝石",盘面要数一数 | 目标自己发光闪烁,无脑点亮的 |
| P3 | L3 | 关卡阶梯可见 | 玩家任意时刻能看到关卡进度(第 N/共 M 关或下一关预告),过关有明确庆祝节点 | HUD 常驻"第 3/8 关"+ 过关全屏庆祝 | 只有分数在涨,玩家不知道自己走到哪 |
| P4 | L2 | 卡壳兜底 | 卡住 ≥812s 有软提示(高亮/排除/线索重播),卡壳不出局不清零 | 10s 无操作候选格开始脉动 | 卡住只能干等到超时判负 |
| P5 | L4 | 成绩可炫耀 | 结算展示可比较成绩(用时/连击/星级/称号,配最佳纪录),不是打完就黑屏 | 结算面板:分数+最高连击+3 星+历史最佳 | 结算只有一行"游戏结束" |
> 通用 8 条命中越多越好玩;品类 5 条对齐质量模型的档位观测线(便宜档批次:L2 组均分 ≥60%、L3 ≥2/3、L4 ≥1/2,消费于品类小批验收、不拦单款)。**设计阶段就过这两张表,别等做出来才发现无趣。**
>
> **底线四件的解谜映射**(质量模型 §4 结构底线,设计必须给全):成长轴 = 分数+关卡数;解锁阶梯 = 关卡链逐关引入新规则(≥3 关,任意时刻可见下一关);第二动机 = 星级/图鉴/称号可积攒;音反馈 = 正确/过关必有声。四件全可映射,无需替代轴申报。
---
## 相关
- 代码层实现 → [`littlejs-game-dev.md`](littlejs-game-dev.md)(11 注入插件 API / 结构 / 资产-mmx / 工厂契约)
- 品类轻脚手架(克隆起点)→ `game-runtime/games/_template-puzzle/`(规则匹配谜盘骨架:盘面生成/线索/判定/关卡流转已接好,取证契约齐)
- 过门正例(few-shot,读它看"丰富成什么样")→ `game-runtime/games/_fewshot-puzzle/src/`(在骨架上做出规则递进+连击+星级+结算炫耀的完整款)
- 品类 rubric 机器版(喂 LLM 验证 agent)→ `cheap-worker/fixtures/genre-rubrics/puzzle.json`;金标 play-spec → `cheap-worker/fixtures/golden-specs/puzzle-board.play-spec.json`
- 便宜模型生成 worker / 九门 → [`cheap-model-game-generation.md`](cheap-model-game-generation.md)
- 质量分层与档位观测线(上位标准)→ [`docs/architecture/架构/生成引擎/游戏质量与爆火能力.md`](../../docs/architecture/架构/生成引擎/游戏质量与爆火能力.md)

View File

@ -52,6 +52,7 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
6. ** brief 属剧情 / 互动叙事 / 文字冒险这类读文本做选择推进剧情品类**read_file('.agents/skills/narrative-game-design.md') 设计什么才好玩的范式6 心理引擎 / §1 范式含分支选择+多结局图鉴 / §3 文本红线 / §8 设计配方 / §9 可达性红线含取证契约 read_file('game-runtime/games/_template-story/src/game-logic.js') 看剧情品类过门范例分支推进 + 结局 latch + 结局图鉴的骨架写法其故事数据在同目录 core.js 顶部参数空间
7. ** brief TRPG / 掷骰 / 冒险 / 地牢爬塔这类掷骰对抗抉择成长品类**read_file('.agents/skills/trpg-game-design.md') 设计什么才好玩的范式6 心理引擎 / §1 玩法范式含掷骰爬塔 / §3 掷骰概率数值 / §9 红线核心动作单击即结算·掷骰过程可见 / §10 反无趣自检
8. ** brief 属非遗 / 传统技艺 / 民俗这类传统文化玩法化品类**read_file('.agents/skills/heritage-game-design.md') 设计什么才好玩的范式§1 玩法化范式含工序节拍 / §3 双层奖励与工序节奏目标值 / §9 文化表述红线 / §10 反贴皮自检需要完整过门范例再 read_file('game-runtime/games/_fewshot-feiyi/src/game-logic.js')
9. ** brief 属解谜找规律 / 找不同 / 翻牌配对 / 顺序推理 / 点选消除这类观察推理点解品类**read_file('.agents/skills/puzzle-game-design.md') 取解谜设计范式顿悟距离 / 规则递进关卡阶梯 / 卡壳兜底 / 成绩炫耀 / §9 品类可达红线解谜的必须映射到 targets occupied想看丰富成什么样的完整过门正例 read_file('game-runtime/games/_fewshot-puzzle/src/game-logic.js') 参照
先设计后写码决定这游戏好不好玩关键别跳过
能跑 好玩动手写码前先据 brief 在心里 game-logic.js 顶部一小段注释里定一份**轻量玩法设计**再实现
@ -61,13 +62,13 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
- **数值成长**产出/成本随级上升略带滚雪球感别平淡线性
- **音效清单**收钱/ 升级欢呼 / 解锁号角 plugins.audioMusic宁可程序化也别没有反馈音
定完过一遍 sim-business §10 **8 条好玩自检**即时反馈 可见成长 下一个解锁 30 秒内首次升级/解锁 数值滚雪球 情感锚萌角色/拥有物 放置回归惊喜 音反馈命中越多越好玩命中 3 能玩但无趣
**8 条自检对所有品类通用**动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式选项单击即推进结局 latch 驻留选择有重量选哪个通向不同走向绝不做选啥都一样的假分支**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式
**8 条自检对所有品类通用**动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式选项单击即推进结局 latch 驻留选择有重量选哪个通向不同走向绝不做选啥都一样的假分支**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式
MVP-first 铁律钉死·关乎你能不能收敛别一稿堆满
设计太满 你实现负担过重 read/write 反复跳跑不收敛实测满配设计循环截停精简设计 9 步收敛首版**只做可玩核心**核心循环 + 1 个主机制 + 1 个资源环进货+ 34 级解锁 + 基础数值/音效**商品 3 种起步****离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标后续·MVP 不做**别塞进首版**先出能玩的核心再谈丰富**
步骤
A 读手册1 + 按品类 5/6/7/8+ 读你的起点 game-logic.js4 B brief **轻量玩法设计**核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效 §10 八条自检 + MVP-first C 读要用的插件 api.d.ts D write_file 改写 **game-logic.js**按需拆 core/render实现玩法 E check有错改到 PASS F build有错改到 PASS G check+build 都绿后调 finish
A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4 B brief **轻量玩法设计**核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效 §10 八条自检 + MVP-first C 读要用的插件 api.d.ts D write_file 改写 **game-logic.js**按需拆 core/render实现玩法 E check有错改到 PASS F build有错改到 PASS G check+build 都绿后调 finish
红线check 会拦违反则 finish 被拒
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**命名导出别改名别默认导出**零引擎 import**引擎经 boot.ctx.getEngine()

View File

@ -121,9 +121,14 @@ def build_trace_source(verdict, driver_type, attempts, stage, model) -> dict:
async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000,
port=4320, cdp_port=9222, run_gates=True,
system_prompt=None, initial_kick=None, write_whitelist=None, prepare=None,
scaffold_template=None, scaffold_desc=None, verify_richness_enabled=True):
scaffold_template=None, scaffold_desc=None, verify_richness_enabled=True,
genre=None):
"""跑便宜档一局生成scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。
genreW-GENRE 件④缺省 None=原行为品类键 'puzzle'显式透传给丰富度 LLM 评分同一次
judge 调用 additive 追加品类扩展条目分母独立小计落 richness.genreNone scaffold_template
GENRE_BY_TEMPLATE 推断两路都没有/条目缺 自动退化为纯通用底座评分
run_gates=True默认spike 单局收口跑全套 stagesmokeensure_play_spec九门 play
run_gates=Falsegeneration-only compare_node 对照用收口只到 stage+smoke 为止****自动产 play-spec
****跑内部九门 play 由调用方在生成与 play 之间注入金标 spec 再单独 play把驱动器从对照变量里摘掉
@ -285,9 +290,10 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
# 只在 run_gates完整 spike 收口)且 finished有真产物时跑对照路run_gates=False零改动、不触发 LLM。
if verify_richness_enabled and run_gates and finished:
try:
# 品类扩展 rubric(W-GENRE 件④):scaffold_template 登记在 GENRE_BY_TEMPLATE 时同一次评分
# additive 追加品类条目(分母独立小计落 richness.genre);未登记/None → 行为与既有完全一致。
_genre = cheap_verify.GENRE_BY_TEMPLATE.get(scaffold_template) if scaffold_template else None
# 品类扩展 rubric(W-GENRE 件④):显式 genre 参数优先,否则按 scaffold_template 查
# GENRE_BY_TEMPLATE 推断;命中时同一次评分 additive 追加品类条目(分母独立小计落
# richness.genre);两路都没有/未登记 → 行为与既有完全一致。
_genre = genre or (cheap_verify.GENRE_BY_TEMPLATE.get(scaffold_template) if scaffold_template else None)
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=_genre)
rv = summary["richness"]
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} groups={rv.get('groups')} "

View File

@ -17,6 +17,8 @@ cheap_verify.py — 便宜档「丰富度」LLM 验证 agent非阻塞·只报
"""
import asyncio
import json
from pathlib import Path
import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap故纯函数可在无 agentscope 环境单测。
@ -66,8 +68,38 @@ GENRE_CHECKLISTS = {
# 品类脚手架目录名 → 品类 keyrun_studio 据 scaffold_template 透传 genreNone/未登记 → 不评品类扩展)。
GENRE_BY_TEMPLATE = {
"_template-trpg": "trpg",
"_template-puzzle": "puzzle",
}
# 品类条目第二来源 = fixtures/genre-rubrics/<genre>.json数据件解谜首用。查找顺序 = 内联
# GENRE_CHECKLISTS 优先、fixture 兜底(两源归一是 rubric 挂点收敛工单的事,本处只保证两路都能生效)。
_GENRE_RUBRICS_DIR = Path(__file__).resolve().parent / "fixtures" / "genre-rubrics"
def load_genre_checklist(genre) -> list:
"""读品类 rubric fixture → [(标准名, 层标, 判据)*]genre 空/文件缺/坏条目 → [](绝不抛)。
条目形状随 fixturename/layer/meaning正反例 positive/negative 只作金标锚材料不进 judge prompt
prompt 里逐条只给一句可判定判据与通用底座条目同形
"""
if not genre or not isinstance(genre, str):
return []
try:
p = _GENRE_RUBRICS_DIR / (genre + ".json")
if not p.is_file():
return []
data = json.loads(p.read_text(encoding="utf-8"))
out = []
for it in (data.get("items") or []):
if not isinstance(it, dict):
continue
name, layer, meaning = it.get("name"), it.get("layer"), it.get("meaning")
if isinstance(name, str) and name and layer in _LAYERS and isinstance(meaning, str) and meaning:
out.append((name, layer, meaning))
return out
except Exception: # noqa: BLE001 fixture 坏 → 扩展为空,评分退化为纯通用底座,绝不阻断
return []
def _layer_maxes(checklist=RICHNESS_CHECKLIST) -> dict:
"""各层条目数(分母)。通用底座 v2 = L2:6 / L3:3 / L4:2。"""
@ -310,8 +342,9 @@ async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=
sources: 可选直接注入的源码文本单测用绕过文件 I/ONone game_id 收集
timeout: judge LLM 调用超时秒数超时 degraded绝不卡死收口
max_src_bytes: judge 的源码上限
genre: 品类 keyW-GENRE 件④命中 GENRE_CHECKLISTS 同一次评分 additive 追加品类扩展条目
分母独立小计落 result["genre"]None/未登记品类 行为与既有版本完全一致prompt 逐字节不变
genre: 品类 keyW-GENRE 件④命中 GENRE_CHECKLISTS fixtures/genre-rubrics/<genre>.json
同一次评分 additive 追加品类扩展条目分母独立小计落 result["genre"]
None/两处均未登记 行为与既有版本完全一致prompt 逐字节不变
Returns:
成功 parse_judge_output 的结构化评分 judgeTokens 观测best-effort
@ -329,8 +362,9 @@ async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=
import _bootstrap # 代理旁路 + key 注入由 _bootstrap.build_cheap_model 内部处理
m = _bootstrap.build_cheap_model(max_tokens=_JUDGE_MAX_TOKENS)
# 品类扩展(未登记的 genre 静默按 None——评分尺没有它的条目别让评分半路造尺
genre_checklist = GENRE_CHECKLISTS.get(genre) if genre else None
# 品类扩展(内联 GENRE_CHECKLISTS 优先、fixtures/genre-rubrics/<genre>.json 兜底;
# 两处都没有 → 静默按 None——评分尺没有它的条目别让评分半路造尺
genre_checklist = (GENRE_CHECKLISTS.get(genre) or load_genre_checklist(genre) or None) if genre else None
genre_key = genre if genre_checklist else None
sys_msg = SystemMsg(name="system", content=_JUDGE_SYSTEM_GENRE if genre_checklist else _JUDGE_SYSTEM)

View File

@ -0,0 +1,41 @@
{
"genre": "puzzle",
"_note": "解谜品类丰富度 rubric 扩展(W-GENRE 品类件④·机器版)。喂 cheap_verify 的 LLM 验证 agent:与通用底座 11 条同一次评分调用、同一 0/1+层标形状,但分母独立小计(质量模型 SoT §3.3 规范二,不与底座混合平均);纯 LLM 非阻塞,绝不写成代码校验/不进九门/不进脚手架。与 .agents/skills/puzzle-game-design.md §10 品类扩展表同源——改任一处必须同改另一处,且评分尺变更须金标复验(单款分组小计漂移 >±1 即回退,规范三)。金标锚:正例=_fewshot-puzzle(丰富成品),薄反例=_template-puzzle 原样产物(轻起点)。",
"items": [
{
"name": "谜题规则递进",
"layer": "L2",
"meaning": "关卡引入 ≥2 种谜题规则/维度,后关比前关多一层复杂度,不是同一规则换数字重复",
"positive": "12 关按颜色找、34 关按形状找、5 关颜色+形状组合",
"negative": "8 关全是「找红色」,只是格子变多"
},
{
"name": "顿悟距离",
"layer": "L2",
"meaning": "谜面需观察/比对/推理才能定位解,线索与答案之间有「想一下」的距离,不是看见就点的纯反应",
"positive": "线索「数量为 3 的宝石」,盘面要数一数才敢点",
"negative": "目标自己发光闪烁,无脑点亮的"
},
{
"name": "关卡阶梯可见",
"layer": "L3",
"meaning": "玩家任意时刻能看到关卡进度(第 N/共 M 关或下一关预告),过关有明确庆祝节点",
"positive": "HUD 常驻「第 3/8 关」+ 过关全屏庆祝",
"negative": "只有分数在涨,玩家不知道自己走到哪"
},
{
"name": "卡壳兜底",
"layer": "L2",
"meaning": "卡住 ≥812s 有软提示(高亮/排除/线索重播),卡壳不出局不清零",
"positive": "10s 无正确操作,候选格开始脉动提示",
"negative": "卡住只能干等到超时判负"
},
{
"name": "成绩可炫耀",
"layer": "L4",
"meaning": "结算展示可比较成绩(用时/连击/星级/称号,配最佳纪录),不是打完就黑屏",
"positive": "结算面板:分数+最高连击+3 星+历史最佳+称号",
"negative": "结算只有一行「游戏结束」"
}
]
}

View File

@ -0,0 +1,17 @@
{
"_genre": "puzzle",
"_baseSample": "_template-puzzle/p11b-tmpl",
"_note": "金标 play-spec(对照 fixtures·W-GENRE 解谜品类件④)。规则谜盘:当前正确解的格子 occupied=true(取证面兼职求解器),点中所有符合线索的格子过关加分(score 递增),通全关提前 win/限时到点 lose(latch)。解谜在范围内同属 occupied 反应族 + score 递增,不用货币递减门。只依赖 forensics 契约(targets occupied / state.score),不绑定具体游戏坐标。",
"driver": {
"type": "tap-targets",
"targetMode": "occupied",
"targetsPath": "targets",
"steps": 50,
"stepMs": 240
},
"expectedEngineCallPrefixes": ["sessionScore.", "audioMusic.", "juice."],
"assertAfterPlay": [
{ "path": "score", "op": "increased", "why": "点对正确解应加分;occupied 契约标错/解不可点则不升" }
],
"expectLatch": true
}

View File

@ -0,0 +1,153 @@
"""
test_genre_puzzle_assets.py 解谜品类范例资产的范例签名 == 现契约机器校验门(W-GENRE 品类件③④配套)
防什么(设计档 §3 决策⑥/§6新漂移面):per-genre 脚手架与 few-shot 正例是给生成 agent 学的范例,
插件 API 改签名后若范例没同步,它们就成了**下一代漂移源**( AI 写已不存在的调用)本门把
范例调的每个 API 在现契约(api.d.ts)里真实存在 + 结构契约/红线/形状全过编译成机器检查:
scaffold 克隆范例 tools.mjs check(语法/结构契约/红线/API-存在静态门/形状静态门) 必须 PASS
纯机械确定(shell-out node + 断言退出码), LLM零网络;改坏范例签名 本测试红 CI
:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_genre_puzzle_assets.py
( node PATH cheap_run shell-out 前提一致)
"""
import json
import shutil
import subprocess
from pathlib import Path
_REPO = Path(__file__).resolve().parents[2]
_GAME_RUNTIME = _REPO / "game-runtime"
_TOOLS = _GAME_RUNTIME / "tools" / "amodel-gen" / "tools.mjs"
# 解谜品类的两件范例资产:轻脚手架(件②)+ few-shot 过门正例(件③)。
_PUZZLE_EXEMPLARS = ("_template-puzzle", "_fewshot-puzzle")
# 校验用临时 gameId 前缀(scaffold 落 games/amgen-<id>/,测后清理;与生成产物 p11b-*/cheap-* 不串)。
_CHECK_ID_PREFIX = "gate-puzzle-sig-"
def _scaffold_and_check(template_name: str) -> tuple:
"""scaffold 克隆范例 → tools.mjs check;返回 (scaffold_ok, check_ok, check_output)。测后清理产物目录。"""
gid = _CHECK_ID_PREFIX + template_name.lstrip("_")
game_dir = _GAME_RUNTIME / "games" / f"amgen-{gid}"
try:
r1 = subprocess.run(
["node", str(_TOOLS), "scaffold-saa", gid, template_name],
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=60, check=False,
)
# 模板缺失时 scaffoldSaa 会回落 _template(带 warn)——那也是失败态:范例目录没了/改名了,必须红。
fell_back = "回落 _template" in (r1.stdout + r1.stderr)
scaffold_ok = r1.returncode == 0 and not fell_back
if not scaffold_ok:
return False, False, (r1.stdout + r1.stderr).strip()
r2 = subprocess.run(
["node", str(_TOOLS), "check", gid],
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=60, check=False,
)
return True, r2.returncode == 0, (r2.stdout + r2.stderr).strip()
finally:
shutil.rmtree(game_dir, ignore_errors=True) # 清理临时产物,不留脏目录
def test_puzzle_exemplars_pass_contract_check():
"""两件解谜范例(轻脚手架 + few-shot 正例)scaffold 后都必须过 check(范例签名 == 现契约)。"""
for tmpl in _PUZZLE_EXEMPLARS:
scaffold_ok, check_ok, out = _scaffold_and_check(tmpl)
assert scaffold_ok, f"{tmpl}: scaffold 失败/回落(范例目录缺失或克隆坏):{out[:500]}"
assert check_ok, f"{tmpl}: tools.mjs check 未过(范例签名漂移出现契约):{out[:800]}"
def test_puzzle_exemplars_forensics_contract_present():
"""静态兜底:两件范例的 game-logic.js 必须带解谜取证契约关键面(createGame/_forensicsView/targets/occupied)。"""
for tmpl in _PUZZLE_EXEMPLARS:
src = (_GAME_RUNTIME / "games" / tmpl / "src" / "game-logic.js").read_text(encoding="utf-8")
for needle in ("export function createGame({ plugins, bundle, viewport })",
"_forensicsView", "targets", "occupied", "bundle.tick"):
assert needle in src, f"{tmpl}: game-logic.js 缺契约面「{needle}"
def test_puzzle_fewshot_has_remix_meta():
"""few-shot 正例必须带同款钩子元数据 GENRE_META(品类原型 + 主题标签,L4 传播结构范例)。"""
core = (_GAME_RUNTIME / "games" / "_fewshot-puzzle" / "src" / "core.js").read_text(encoding="utf-8")
assert "GENRE_META" in core and "archetype" in core and "themeTags" in core, \
"few-shot 正例缺 GENRE_META(genre/archetype/themeTags)——同款钩子范例面"
def test_puzzle_genre_rubric_fixture_shape():
"""品类 rubric fixture(件④)形状校验:≥4 条、逐条 0/1 可判、标层 L2/L3/L4、带正反例(质量模型 §3.3 最小形状)。"""
p = Path(__file__).resolve().parents[1] / "fixtures" / "genre-rubrics" / "puzzle.json"
assert p.is_file(), "缺 fixtures/genre-rubrics/puzzle.json(品类 rubric 机器版)"
data = json.loads(p.read_text(encoding="utf-8"))
assert data.get("genre") == "puzzle"
items = data.get("items")
assert isinstance(items, list) and len(items) >= 4, "品类扩展须 ≥4 条(§3.3 规范二)"
for it in items:
assert isinstance(it.get("name"), str) and it["name"], "条目缺 name"
assert it.get("layer") in ("L2", "L3", "L4"), f"条目 {it.get('name')} 层标非法:{it.get('layer')}"
assert isinstance(it.get("meaning"), str) and it["meaning"], f"条目 {it.get('name')} 缺一句可判定判据"
assert isinstance(it.get("positive"), str) and it["positive"], f"条目 {it.get('name')} 缺正例"
assert isinstance(it.get("negative"), str) and it["negative"], f"条目 {it.get('name')} 缺反例"
def _import_cheap_verify():
"""import cheap_verify(tests/ 直跑时补 sys.path;纯函数路不依赖 agentscope)。"""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import cheap_verify
return cheap_verify
def test_genre_loader_and_none_path_identity():
"""④接线不变量:genre=None/缺 fixture → loader 返 [];judge prompt 与无品类版**逐字节一致**(评分尺零漂移)。"""
cv = _import_cheap_verify()
assert cv.load_genre_checklist(None) == []
assert cv.load_genre_checklist("") == []
assert cv.load_genre_checklist("no-such-genre-xyz") == [], "缺 fixture 必须退化为空扩展"
items = cv.load_genre_checklist("puzzle")
assert len(items) >= 4 and all(len(t) == 3 and t[1] in ("L2", "L3", "L4") for t in items)
src = "// demo src"
assert cv._build_judge_user(src, "b") == cv._build_judge_user(src, "b", genre_key="puzzle", genre_checklist=None), \
"无品类条目时 judge prompt 必须与既有版本逐字节一致(锚定纪律)"
# 品类段真的进 prompt(有品类时;主干契约=单 checks 数组、通用 11 条在前、品类条目紧随)。
p = cv._build_judge_user(src, "b", genre_key="puzzle", genre_checklist=tuple(items))
assert "品类扩展清单" in p and items[0][0] in p and f"{11 + len(items)}" in p
def test_genre_parse_separate_subtotals():
"""④解析:品类段独立解析出 result["genre"](分母独立小计);无 genre_checklist 时无新增键。
主干契约 = ** checks 数组**(通用 11 条在前品类条目紧随其后),输出嵌套 richness.genre
{key,hits,score,max,groups} TRPG 品类件同一机制(merge 收敛:分支原 genreChecks 双数组契约弃用)
"""
cv = _import_cheap_verify()
items = tuple(cv.load_genre_checklist("puzzle"))
base_checks = [{"name": n, "hit": True, "why": "ok"} for (n, _l, _m) in cv.RICHNESS_CHECKLIST]
genre_checks = [{"name": n, "hit": (i % 2 == 0), "why": "ok"} for i, (n, _l, _m) in enumerate(items)]
text = json.dumps({"checks": base_checks + genre_checks, "notes": "n"}, ensure_ascii=False)
r = cv.parse_judge_output(text, genre_checklist=items, genre_key="puzzle")
assert r["degraded"] is False and r["score"] == 11
g = r["genre"]
assert g["key"] == "puzzle" and g["max"] == len(items) and len(g["hits"]) == len(items)
gg = g["groups"]
layer_max = {ly: sum(1 for (_n, l, _m) in items if l == ly) for ly in ("L2", "L3", "L4")}
assert {ly: gg[ly]["max"] for ly in gg} == layer_max, "品类 groups 分母应来自品类清单(独立小计)"
assert g["score"] == sum(1 for i in range(len(items)) if i % 2 == 0)
# 模型漏答品类段(checks 只有通用 11 条)→ 扩展全 0、底座不受影响。
r2 = cv.parse_judge_output(json.dumps({"checks": base_checks, "notes": "n"}),
genre_checklist=items, genre_key="puzzle")
assert r2["score"] == 11 and all(not h["hit"] for h in r2["genre"]["hits"])
# 无品类路:输出键集与既有版本一致(不带 genre 键)。
r3 = cv.parse_judge_output(json.dumps({"checks": base_checks, "notes": "n"}))
assert "genre" not in r3
if __name__ == "__main__":
# 裸跑模式(与本目录其它测试同款:无 pytest 也可直接 python 执行)。
test_puzzle_exemplars_pass_contract_check()
test_puzzle_exemplars_forensics_contract_present()
test_puzzle_fewshot_has_remix_meta()
test_puzzle_genre_rubric_fixture_shape()
test_genre_loader_and_none_path_identity()
test_genre_parse_separate_subtotals()
print("test_genre_puzzle_assets: 全部通过")

View File

@ -14,9 +14,9 @@ from pathlib import Path
_FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "golden-specs"
# tap-targets 代表品类:三个基线品类(plan KTD2,各 formalize base1/base2/base4 的 occupied spec)
# + W-GENRE 品类件④ 追加(story-branch=_template-story / trpg=_template-trpg / heritage-craft=_template-feiyi;
# 新品类进金标须同步本期望集)。
_EXPECTED_GENRES = {"click-score", "whack-mole", "shop-serve", "story-branch", "trpg", "heritage-craft"}
# + W-GENRE 品类件④ 追加(story-branch=_template-story / trpg=_template-trpg / heritage-craft=_template-feiyi /
# puzzle=_template-puzzle;新品类进金标须同步本期望集)。
_EXPECTED_GENRES = {"click-score", "whack-mole", "shop-serve", "story-branch", "trpg", "heritage-craft", "puzzle"}
# driver 只许这些键 —— 全是与具体游戏无关的契约级参数;出现坐标类键(x/y/coords/points)即"绑定具体游戏"违规。
_ALLOWED_DRIVER_KEYS = {"type", "targetMode", "targetsPath", "steps", "stepMs"}
_FORBIDDEN_COORD_KEYS = {"x", "y", "coords", "points", "positions", "targets"}

View File

@ -1,6 +1,6 @@
---
id: config.cheap-system
version: 1.3.0
version: 1.4.0
---
你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩、有内容、不易同质化的高质小游戏**(多文件 LittleJS落在 ⟦G⟧/。
@ -22,6 +22,7 @@ version: 1.3.0
6. **若 brief 属剧情 / 互动叙事 / 文字冒险这类「读文本→做选择→推进剧情」品类**read_file('.agents/skills/narrative-game-design.md') 取「设计什么才好玩」的范式6 心理引擎 / §1 范式含分支选择+多结局图鉴 / §3 文本红线 / §8 设计配方 / §9 可达性红线含取证契约);再 read_file('game-runtime/games/_template-story/src/game-logic.js') 看剧情品类过门范例(分支推进 + 结局 latch + 结局图鉴的骨架写法,其故事数据在同目录 core.js 顶部参数空间)。
7. **若 brief 属 TRPG / 掷骰 / 冒险 / 地牢爬塔这类「掷骰对抗→抉择→成长」品类**read_file('.agents/skills/trpg-game-design.md') 取「设计什么才好玩」的范式6 心理引擎 / §1 玩法范式含掷骰爬塔 / §3 掷骰概率数值 / §9 红线〔核心动作单击即结算·掷骰过程可见〕 / §10 反无趣自检)。
8. **若 brief 属非遗 / 传统技艺 / 民俗这类「传统文化玩法化」品类**read_file('.agents/skills/heritage-game-design.md') 取「设计什么才好玩」的范式§1 玩法化范式含工序节拍 / §3 双层奖励与工序节奏目标值 / §9 文化表述红线 / §10 反贴皮自检);需要完整过门范例再 read_file('game-runtime/games/_fewshot-feiyi/src/game-logic.js')。
9. **若 brief 属解谜(找规律 / 找不同 / 翻牌配对 / 顺序推理 / 点选消除这类「观察→推理→点解」品类)**read_file('.agents/skills/puzzle-game-design.md') 取解谜设计范式(顿悟距离 / 规则递进关卡阶梯 / 卡壳兜底 / 成绩炫耀 / §9 品类可达红线——解谜的「解」必须映射到 targets occupied想看「丰富成什么样」的完整过门正例可 read_file('game-runtime/games/_fewshot-puzzle/src/game-logic.js') 参照。
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
@ -32,13 +33,13 @@ version: 1.3.0
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:收钱「叮」/ 升级欢呼 / 解锁号角(经 plugins.audioMusic宁可程序化也别没有反馈音
定完过一遍 **9 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚〔萌角色/拥有物〕 ⑦放置回归惊喜 ⑧音反馈 ⑨**核心操作非无脑**〔每次主操作有真实的决策/技巧含量,不是点了自动结算〕)——**⑨ 是否决项:⑨ 不命中,①–⑧ 全中也只是「有元素的无趣游戏」**(实测一款 6/8 命中却不好玩,正死在 ⑨);⑨ 命中的前提下,其余命中越多越好玩。
**8 条自检对所有品类通用**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式;**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式。
**8 条自检对所有品类通用**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式;**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式(解谜的技巧分=看懂线索少捞错,同守上面两层奖励解耦)
【MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)】
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 34 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 MVP 的心脏,砍了就只剩无趣骨架。**
【步骤】
A 读手册1 + 按品类 5/6/7/8+ 读你的起点 game-logic.js4→ B 据 brief 定**轻量玩法设计**(核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效,过 §10 八条自检 + 守 MVP-first→ C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js**(按需拆 core/render实现玩法 → E 调 check有错改到 PASS → F 调 build有错改到 PASS → G check+build 都绿后调 finish。
A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4→ B 据 brief 定**轻量玩法设计**(核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效,过 §10 八条自检 + 守 MVP-first→ C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js**(按需拆 core/render实现玩法 → E 调 check有错改到 PASS → F 调 build有错改到 PASS → G check+build 都绿后调 finish。
【红线check 会拦,违反则 finish 被拒)】
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。

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.3.0 # v1.3.0W-GENRE 品类路由并集——剧情(+6/ TRPG+7/ 非遗(+8 heritage-game-design + _fewshot-feiyi 正例指针merge 时版本归一cheap_roles.py/prompt.mjs 双源同改
version: 1.4.0 # v1.4.0W-GENRE 品类路由并集——剧情(+6/ TRPG+7/ 非遗(+8/ 解谜(+9 puzzle-game-design + _fewshot-puzzle 正例指针merge 时版本归一cheap_roles.py/prompt.mjs 双源同改
stage: "04-config"
owner: WS2
file: 04-config/cheap-system.md

View File

@ -0,0 +1,19 @@
# _fewshot-puzzle — 解谜品类 few-shot 过门正例「深海寻宝谜航」
这是**给生成 agent 读的完整成品范例**(few-shot 正例):从 `_template-puzzle` 骨架起步、按「深海寻宝」brief 换身份加深度后交出来的样子。它演示的是**增量**——骨架已给的循环别重写,agent 该补的是这些:
| 丰富维度 | 本范例的落点 | 对应 rubric |
|---|---|---|
| 主题全换 | 宝石→深海宝物(THEME/文案/底景/贝壳·海星·珍珠画法) | 多样性(D9 不雷同) |
| 规则递进 | 6 关,颜色→形状→数量→混合→组合逐关引入,每关 intro 教学副标 | P1 谜题规则递进 / P3 关卡阶梯可见 |
| 星级评价 | 每关按错点数评 13 星(聪明比手快值钱),过关亮星 | P5 成绩可炫耀 / 可见成长 |
| 称号 + 收集 | 总星数换称号;累计星星 save 持久跨局积攒(回归可见) | 情感锚 / 第二动机 |
| 结算炫耀 | 分数+星级+称号+通关数+最高连击+累计收集+最佳纪录 | P5 / 炫耀时刻 |
| 同款钩子 | `GENRE_META`(品类原型 rule-match-board + 主题标签) | 同款钩子(L4) |
| 卡壳兜底 | 8s 无进展剩余解脉动(卡壳不出局) | P4 卡壳兜底 |
**取证契约与骨架完全一致**(丰富不碰契约):`targets` 只把当前正确解标 `occupied:true`;`score` 随点对/过关单调升;menu/over 期 `targets:[]` 键在场。
范例签名 == 现契约:本目录随 `cheap-worker/tests/test_genre_puzzle_assets.py` 走机器校验门(scaffold + tools.mjs check),API 漂移会在 CI 挂——**改插件签名时必须同步改本范例**(防它成下一代漂移源)。
配套:设计手册 `.agents/skills/puzzle-game-design.md` · 代码手册 `.agents/skills/littlejs-game-dev.md` · 轻脚手架 `game-runtime/games/_template-puzzle/`

View File

@ -0,0 +1,24 @@
# 资产生成台账(gen-ledger) — _template-puzzle「规则谜盘」
> 本文件登记 **mmx 生成的素材生成历史**(prompt / 字节 / hash / 替换记录)——改素材时的索引。
> 本模板无外部美术/音频(纯程序化图形),故台账为空(stub)。
## 用法(加美术时)
1. 用 mmx 生成素材,产物存 `assets/`(`.jpg` 美术、`.mp3` 音乐音效):
```bash
# 美术(模型 image-01):<主体> + 统一风格词 + 纯色背景,居中,简洁
mmx image generate --prompt "..." --out gem-ruby.jpg
# 音乐(模型 music-2.6-free):<情绪/配器/场景>
mmx music generate --prompt "轻快专注...益智" --instrumental --bpm 100 --out bgm-loop.mp3
```
2. 在下表追加一行(序号 / 类型 / 文件 / 字节 / hash 前 16 位 / prompt(风格词部分用 STYLE 缩写)/ 退出码)。
3. 同步更新 `assets/manifest.json` 对应行的 `sha256/bytes/prompt`
4. 在 `src/assets.js``IMAGE_FILES` 添键(键名→文件名);render 里用 `assets.images.<键>` 取图。
5. 铁律:**全套素材共用一组风格词**(美术统一性之根);每次调用都把 prompt 写进本台账与 manifest,便于复现替换。
## 台账
| # | 类型 | 文件 | 字节 | hash(前16) | prompt | 结果 |
|---|---|---|---|---|---|---|
| — | — | (本模板无外采素材) | — | — | — | — |

View File

@ -0,0 +1,6 @@
{
"game": "_fewshot-puzzle",
"note": "资产清单(最小空清单)。本范例「深海寻宝谜航」无外部美术/音频,全用程序化图形(drawGlyph 画贝壳/海星/珍珠),故 assets 为空数组。加美术时:每件外采资产在此登记一行 {file, role, bytes, sha256, source:{tool, model, prompt}};并把键名→文件名加进 src/assets.js 的 IMAGE_FILES。",
"styleWords": "(加美术时填:全套素材共用同一组风格词——美术统一性之根)",
"assets": []
}

View File

@ -0,0 +1,33 @@
/**
* entry.js 点圆得分 esbuild 打包入口(引擎 import 只落本文件;打成 iife index.html 加载)
* owner_template 克隆起点 消费方scripts/build.mjs(打包) index.html(运行)
*
* 定位 · 精简自 wanglanmei entry.js
* core 协议 + 所用插件 + 游戏(逻辑/渲染/宿主)+ 引擎汇成一个 iife 全局(TemplateGame),
* index.html bootGame 启动**引擎 import 只活本文件**(Q4 铁律),且经动态 import 延后求值:
* · node import entry.js ****触发引擎装载(bootGame 未被调用) entry.js node import-safe;
* · esbuild(bundle+iife, splitting)把动态 import 的引擎内联进单文件产物;
* · 浏览器运行期调 bootGame 时才求值引擎(window/AudioContext 已存在),拿名字空间注入 main.js real 通道
* Q4 铁律不破:main.js(host )经注入用引擎不直接 import;game.js 游戏工厂零引擎 import
*
* 构建(沿 scripts/build.mjs 锁参)
* node scripts/build.mjs games/_template/entry.js games/_template/dist/template-bundle.js --global-name=TemplateGame
* (本模板未预置 dist 产物;克隆后按上行构建即可在浏览器跑 ?engine=real;node headless src/main.js stub,免构建)
*/
'use strict';
import { bootGame as bootGameCore } from './src/main.js';
/**
* 对外 bootGame:动态 import 引擎名字空间后注入委托 main.js 真实现
* index.html 调的是 iife 全局 TemplateGame.bootGame(即本包装)
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} [opts]
* @returns {Promise<object>} gameHost 句柄
*/
export async function bootGame(opts) {
// 动态 import:求值时机延后到 bootGame 被调用(浏览器运行期)。esbuild 内联,无外部请求。
// 调用方已显式给 engine 则尊重其值,不重复装载(便于复用)。
const engine = (opts && opts.engine) || (await import('littlejsengine'));
return bootGameCore(Object.assign({}, opts, { engine }));
}

View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<!--
index.html — 「点圆得分」(_template 克隆起点)宿主页
用法:
· 构建产物(iife 全局 TemplateGame):见 entry.js 头注释的 build.mjs 锁参命令(产物 dist/template-bundle.js);
· serve 本目录,浏览器打开;
· ?seed=12345 注入主随机种子(缺省固定种子,确定可复现);
· ?engine=stub(缺省·回滚锚)/ ?engine=real(引擎掌帧);
· ?mode=evidence 取证模式:stub 下不起 RAF,由 window.__gameHost.stepFrames/tap 确定性驱动;
· 取证导出:window.__gameForensics / window.__gameHost;就绪标记:__gameBooted。
注意:页面壳只负责承载;游戏全部绘制在 canvas 内。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- 固定取证视口:390×844 移动竖屏基准;禁缩放防像素哈希扰动。 -->
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
<title>点圆得分</title>
<style>
/* 自适应居中画布:游戏全部绘制在 canvas 内,页面壳只承载。
画布按 390:844 比例缩放 fit 窗口——桌面短窗口不裁底(否则底部内容如按钮/角色看不见)。
host 输入映射按 getBoundingClientRect 归一化(boot-game-host toCanvasXY),CSS 缩放下点击坐标仍准。 */
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #0a1622;
color: #9fb3c8;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
#wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
#game {
/* 取「宽受限」与「高受限」的较小者,保 390:844 比例完整可见。
!important 覆盖 host 在 stub 通道写的内联 display(390×844 硬编码);buffer 不动→evidence 像素哈希不受影响;
输入经 boot-game-host toCanvasXY 按 getBoundingClientRect 归一,CSS 缩放下点击坐标仍准。 */
width: min(100vw, calc(100vh * 390 / 844)) !important;
height: min(100vh, calc(100vw * 844 / 390)) !important;
background: #10243a;
display: block;
touch-action: none; /* 触摸全交 JS(pointer 桥接),禁浏览器默认手势 */
}
#status {
position: absolute; /* 浮层叠于 canvas 顶部,不占布局流 */
top: 0; left: 0; right: 0;
z-index: 2;
pointer-events: none; /* 绝不截触摸 */
font-size: 12px;
line-height: 1.4;
min-height: 15px;
text-align: center;
word-break: break-all;
opacity: 0.7;
}
</style>
</head>
<body>
<div id="wrap">
<div id="status">booting…</div>
<canvas id="game"></canvas>
</div>
<!-- 构建产物(iife 全局 TemplateGame,含 bootGame)。克隆后按 entry.js 头注释构建生成。 -->
<script src="./dist/template-bundle.js"></script>
<script>
// 引导:异步 boot;失败把错误打到 status + 重抛(评估门要看 console 零未捕获错误)。
(function () {
'use strict';
var statusEl = document.getElementById('status');
var canvas = document.getElementById('game');
try {
var G = window.TemplateGame;
if (!G || typeof G.bootGame !== 'function') {
throw new Error('TemplateGame.bootGame 未找到(template-bundle.js 未正确加载或 globalName 不符)');
}
G.bootGame({ canvas: canvas, statusEl: statusEl })
.then(function () { window.__gameBooted = true; })
.catch(function (e) {
window.__gameBooted = false;
window.__gameBootError = String((e && e.message) || e);
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
throw e;
});
} catch (e) {
window.__gameBooted = false;
window.__gameBootError = String((e && e.message) || e);
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
throw e;
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,73 @@
/**
* assets.js 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件)
* owner_template 克隆起点 消费方game.js(init await loadAssets)/ render.js(若用图则取)
*
* 本款现状:无外部资产(纯程序化图形)
* 点圆得分全用 g 原生绘制(/矩形/文字),不需任何 .jpg/.mp3, IMAGE_FILES 为空
* **声明模式的结构必须留全**这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键,
* render 里用 assets.images.<> 取图,装载/容错链条已就位,不必重搭
*
* 容错铁律(发行版红线 §7-5)
* 任一资产缺失/加载失败 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退),
* 绝不因资产把游戏带崩本款无资产 loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)
* node 环境无 Image 同样安全返回空集(import-safe)
* 音频文件不在此装载(需用户手势后解码, audio-director 懒加载;本款无音频)
*/
'use strict';
/**
* 图像资产键 文件名( assets/manifest.json 对应)
* 在此声明图片资产键文件名本款无美术故为空对象 {}
* 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)
* @type {Record<string, string>}
*/
export const IMAGE_FILES = {};
/**
* 装载全部图像资产(并行;单个失败不连坐)本款 IMAGE_FILES 为空 返回空集
* @param {string} [baseUrl] 资产根(缺省 './assets/')
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
const images = {};
/** @type {string[]} */
const missing = [];
/** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */
function loadOne(key) {
return new Promise((resolve) => {
// 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。
if (typeof Image === 'undefined') {
images[key] = null;
missing.push(key);
resolve(null);
return;
}
const img = new Image();
img.onload = () => {
images[key] = img;
resolve(img);
};
img.onerror = () => {
// 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。
console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]);
images[key] = null;
missing.push(key);
resolve(null);
};
img.src = root + IMAGE_FILES[key];
});
}
// 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。
return Promise.all(keys.map(loadOne)).then(() => ({
images,
missing,
loaded: keys.length - missing.length,
total: keys.length,
}));
}

View File

@ -0,0 +1,234 @@
/**
* core.js L3 纯逻辑解谜品类 few-shot 过门正例深海寻宝谜航的玩法数值与判定
*
* 这份是** _template-puzzle 骨架起步换身份加深度后的完整成品范例**(few-shot 正例):
* 展示生成 agent 该交出的样子主题全换(宝石深海寻宝)关卡阶梯拉长且逐关引入新维度
* 星级评价 + 称号 + 跨局星星收集(save 持久)过关语义元数据(remix 同款钩子)
* 结构仍是规则谜盘:每关一条线索,点中所有符合的格子过关;全部纯函数随机经入参 rng
*/
'use strict';
/* ═══════════════ 【身份层:主题(相对骨架全换)】 ═══════════════ */
/** 深海寻宝主题:元素=海底宝物(贝壳/海星/珍珠),颜色=珊瑚红/深海蓝/荧光绿。 */
export const THEME = {
title: '深海寻宝谜航',
tagline: '读懂线索,潜入海底点亮宝物!',
shapes: [
{ id: 'shell', name: '贝壳' },
{ id: 'starfish', name: '海星' },
{ id: 'pearl', name: '珍珠' },
],
colors: [
{ id: 'coral', name: '珊瑚红', fill: '#ff7b6b' },
{ id: 'abyss', name: '深海蓝', fill: '#4f9dde' },
{ id: 'glow', name: '荧光绿', fill: '#5fe0a0' },
],
countNames: ['一件', '两件', '三件'],
};
/**
* 同款钩子元数据(L4 传播结构):品类原型 + 主题标签, feed做同款remix 预填
* 生成 agent 每款都该给原型定结构标签定皮肤,别人一键同款换主题
*/
export const GENRE_META = {
genre: 'puzzle',
archetype: 'rule-match-board', // 品类原型:规则匹配谜盘
themeTags: ['深海', '寻宝', '治愈'],
};
/**
* 关卡曲线(比骨架长且陡:6 ,维度逐关引入 颜色形状数量组合,盘面 3×3 4×4)
* 每关自带 intro(出题横幅副标,教学感) 3 分钟脚本的落点
*/
export const LEVELS = [
{ cols: 3, rows: 3, matches: 3, kinds: ['color'], intro: '找颜色:热热身' },
{ cols: 3, rows: 3, matches: 4, kinds: ['color'], intro: '同样的规则,再快一点' },
{ cols: 4, rows: 3, matches: 4, kinds: ['shape'], intro: '新玩法:看形状' },
{ cols: 4, rows: 3, matches: 4, kinds: ['count'], intro: '要数一数了' },
{ cols: 4, rows: 4, matches: 5, kinds: ['shape', 'count'], intro: '两种规则随机出' },
{ cols: 4, rows: 4, matches: 5, kinds: ['combo'], intro: '终极:双重线索' },
];
/* ═══════════════ 【节奏与计分(在骨架锚点上按主题微调)】 ═══════════════ */
export const ROUND_MS = 90000; // 6 关更长,一局 90s
export const OBSERVE_MS = 900; // 观察窗(§3 锚 0.81.5s)
export const CLEAR_PAUSE_MS = 800; // 过关庆祝(星级揭晓要一拍)
export const LOCK_MS = 400; // 错点短锁定
export const HINT_AFTER_MS = 8000; // 卡壳 8s 给软提示
export const SCORE_PER_HIT = 10;
export const COMBO_WINDOW_MS = 2000;
export const COMBO_MAX_MULT = 3;
export const LEVEL_BONUS_BASE = 30;
export const LEVEL_BONUS_GROWTH = 1.15;
/** 星级判定:按本关错点数评 13 星(0 错=3 星 / 1 错=2 星 / 更多=1 星)——聪明比手快值钱。 */
export const STAR_WRONGS_3 = 0;
export const STAR_WRONGS_2 = 1;
/** 称号阶梯(按总星数,结算炫耀点):写死文案也是身份层,换主题就换词。 */
export const TITLES = [
{ minStars: 16, name: '深海寻宝大师' },
{ minStars: 12, name: '资深潜航员' },
{ minStars: 7, name: '见习潜水员' },
{ minStars: 0, name: '岸边观望者' },
];
/** 跨局收集(第二动机):累计星星存档键(save 插件持久,回归可见积攒)。 */
export const SAVE_KEY_STARS = 'seaPuzzleStarsTotal';
/** 盘面几何。 */
export const BOARD_TOP_RATIO = 0.26;
export const BOARD_SIDE_MARGIN = 24;
export const TILE_GAP = 12;
/* ═══════════════ 【结构层:谜盘循环判定(与骨架同范式)】 ═══════════════ */
export function levelsTotal() { return LEVELS.length; }
export function levelConfig(levelIdx) {
const i = Math.max(0, Math.min(LEVELS.length - 1, levelIdx));
return LEVELS[i];
}
export function levelBonus(levelIdx) {
return Math.round(LEVEL_BONUS_BASE * Math.pow(LEVEL_BONUS_GROWTH, Math.max(0, levelIdx)));
}
/** 本关星级(按错点数;聪明玩法的可炫耀量化)。 */
export function starsForLevel(wrongs) {
if (wrongs <= STAR_WRONGS_3) return 3;
if (wrongs <= STAR_WRONGS_2) return 2;
return 1;
}
/** 总星数 → 称号(结算面板用)。 */
export function titleForStars(totalStars) {
for (const t of TITLES) if (totalStars >= t.minStars) return t.name;
return TITLES[TITLES.length - 1].name;
}
function rngIndex(rng, n) { return Math.floor(rng() * n) % n; }
function rngPick(rng, arr) { return arr[rngIndex(rng, arr.length)]; }
/** 挑本关规则(维度从该关 kinds 抽,值随主题表抽)。 */
export function pickRule(levelIdx, rng, theme) {
const t = theme || THEME;
const kind = rngPick(rng, levelConfig(levelIdx).kinds);
if (kind === 'shape') return { kind, shape: rngPick(rng, t.shapes).id };
if (kind === 'count') return { kind, count: 1 + rngIndex(rng, t.countNames.length) };
if (kind === 'combo') return { kind, color: rngPick(rng, t.colors).id, shape: rngPick(rng, t.shapes).id };
return { kind: 'color', color: rngPick(rng, t.colors).id };
}
export function matchesRule(tile, rule) {
if (!tile || !rule) return false;
if (rule.color != null && tile.color !== rule.color) return false;
if (rule.shape != null && tile.shape !== rule.shape) return false;
if (rule.count != null && tile.count !== rule.count) return false;
return true;
}
/** 线索文案(主题化用词:宝物/打捞)。 */
export function clueText(rule, theme) {
const t = theme || THEME;
const colorName = (id) => (t.colors.find((c) => c.id === id) || { name: '?' }).name;
const shapeName = (id) => (t.shapes.find((s) => s.id === id) || { name: '?' }).name;
if (rule.kind === 'shape') return '打捞所有【' + shapeName(rule.shape) + '】';
if (rule.kind === 'count') return '打捞有【' + (t.countNames[rule.count - 1] || rule.count) + '】宝物的格子';
if (rule.kind === 'combo') return '打捞【' + colorName(rule.color) + '·' + shapeName(rule.shape) + '】';
return '打捞所有【' + colorName(rule.color) + '】宝物';
}
export function computeTileGeometry(cols, rows, viewW, viewH) {
const innerW = viewW - BOARD_SIDE_MARGIN * 2 - TILE_GAP * (cols - 1);
const size = Math.floor(innerW / cols);
const top = Math.round(viewH * BOARD_TOP_RATIO);
const out = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
out.push({
row: r, col: c, size,
x: BOARD_SIDE_MARGIN + c * (size + TILE_GAP) + size / 2,
y: top + r * (size + TILE_GAP) + size / 2,
});
}
}
return out;
}
function randomAttrs(rng, theme) {
return {
shape: rngPick(rng, theme.shapes).id,
color: rngPick(rng, theme.colors).id,
count: 1 + rngIndex(rng, theme.countNames.length),
};
}
/** 撞上规则的干扰格 → 确定性改开一个约束维度(绝不无界重试)。 */
function forceMismatch(attrs, rule, theme) {
const next = (arr, id) => {
const i = arr.findIndex((e) => e.id === id);
return arr[(i + 1) % arr.length].id;
};
const out = { shape: attrs.shape, color: attrs.color, count: attrs.count };
if (rule.color != null) out.color = next(theme.colors, rule.color);
else if (rule.shape != null) out.shape = next(theme.shapes, rule.shape);
else if (rule.count != null) out.count = (rule.count % theme.countNames.length) + 1;
return out;
}
/** 生成一关盘面:恰好 matchTotal 个符合、其余全不符合(全找到=过关可判)。 */
export function generateBoard(levelIdx, rng, viewW, viewH, theme) {
const t = theme || THEME;
const cfg = levelConfig(levelIdx);
const rule = pickRule(levelIdx, rng, t);
const geo = computeTileGeometry(cfg.cols, cfg.rows, viewW, viewH);
const total = geo.length;
const matchTotal = Math.max(1, Math.min(cfg.matches, total - 1));
const order = geo.map((_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = rngIndex(rng, i + 1);
const tmp = order[i]; order[i] = order[j]; order[j] = tmp;
}
const matchSet = new Set(order.slice(0, matchTotal));
const tiles = geo.map((pos, i) => {
let attrs = randomAttrs(rng, t);
if (matchSet.has(i)) {
if (rule.color != null) attrs.color = rule.color;
if (rule.shape != null) attrs.shape = rule.shape;
if (rule.count != null) attrs.count = rule.count;
} else if (matchesRule(attrs, rule)) {
attrs = forceMismatch(attrs, rule, t);
}
return {
id: i, row: pos.row, col: pos.col, x: pos.x, y: pos.y, size: pos.size,
shape: attrs.shape, color: attrs.color, count: attrs.count, cleared: false,
};
});
return { rule, clue: clueText(rule, t), tiles, matchTotal, intro: cfg.intro || '' };
}
export function remainingMatches(tiles, rule) {
let n = 0;
for (const tile of tiles) if (!tile.cleared && matchesRule(tile, rule)) n += 1;
return n;
}
export function hitTile(px, py, tiles) {
for (const tile of tiles) {
const half = tile.size / 2;
if (px >= tile.x - half && px <= tile.x + half && py >= tile.y - half && py <= tile.y + half) return tile;
}
return null;
}
export function hitScore(streak) {
return SCORE_PER_HIT * Math.max(1, Math.min(COMBO_MAX_MULT, streak));
}

View File

@ -0,0 +1,333 @@
/**
* game-logic.js L3 游戏本体解谜品类 few-shot 过门正例深海寻宝谜航
* owner:few-shot 范例资产(生成 agent read 它看丰富成什么样) 消费方:game.js wrapper(L1)
*
*
* 这份 = _template-puzzle 骨架换身份加深度后的完整成品(few-shot 正例)
* 相对骨架多出来的丰富(生成 agent 该学的就是这些增量):
* · 主题全换:深海寻宝(THEME/文案/底景/宝物画法, core.js/render.js)
* · 关卡阶梯拉长:6 维度逐关引入每关带 intro 教学副标( 3 分钟脚本落地)
* · 星级评价:每关按错点数评 13 (聪明比手快值钱),过关庆祝亮星
* · 称号 + 跨局收集:总星数换称号;累计星星经 save 插件持久(回归可见积攒,第二动机)
* · 结算炫耀:分数/星级/称号/通关数/最高连击/累计收集/最佳纪录(不是打完黑屏)
* · 同款钩子:GENRE_META 品类原型 + 主题标签(remix 预填元数据)
* 取证契约与骨架一致(targets 只把当前正确解标 occupiedscore 单调升)丰富不碰契约
*
*/
'use strict';
import {
ROUND_MS, OBSERVE_MS, CLEAR_PAUSE_MS, LOCK_MS, HINT_AFTER_MS, COMBO_WINDOW_MS,
SAVE_KEY_STARS, GENRE_META,
generateBoard, remainingMatches, hitTile, matchesRule, hitScore, levelsTotal, levelBonus,
starsForLevel, titleForStars,
} from './core.js';
import { renderMenu, renderPlay, renderOver } from './render.js';
import { loadAssets } from './assets.js';
const SCENE_MENU = 'menu';
const SCENE_PLAY = 'play';
const SCENE_OVER = 'over';
const FLOAT_LIFE_S = 0.9;
const FLOAT_RISE_PXPS = 70;
export function createGame({ plugins, bundle, viewport }) {
// 深海谜航用 7 件:场景机 / 计分 / UI / 计时 / 音效 / 粒子 / 存档(跨局星星收集)。
const { sceneFsm, sessionScore, hudUi, timerScheduler, audioMusic, juice, save } = plugins;
const VW = (viewport && viewport.w) || 390;
const VH = (viewport && viewport.h) || 844;
let boot = null;
let ctx = null;
let assets = null;
let inited = false;
/* ── 运行态 ── */
let board = null;
let levelIdx = 0;
let levelsCleared = 0;
let elapsedMs = 0;
let stepState = 'observe'; // observe | solve | clearing
let stepUntilMs = 0;
let lockedUntilMs = 0;
let lockedTileId = null;
let streak = 0;
let bestStreak = 0;
let lastHitAtMs = -1;
let lastProgressAtMs = 0;
let floats = [];
let roundTimer = null;
let won = false;
/* ── 丰富层运行态(星级/收集) ── */
let levelWrongs = 0; // 本关错点数(星级判定)
let starsThisRun = 0; // 本局星数
let lastLevelStars = 0; // 刚过那关的星数(庆祝亮星)
let totalStars = 0; // 跨局累计(save 持久)
let menuStartBtn = null;
let overRestartBtn = null;
const measures = { frames: 0, taps: 0, hits: 0, wrongs: 0, levelsClearedTotal: 0 };
function rng() { return ctx ? ctx.random.next() : 0; }
function canSolveNow() {
return sceneFsm.current() === SCENE_PLAY && stepState === 'solve' && elapsedMs >= lockedUntilMs;
}
/** 读/写跨局星星(save 容错:异常时收集降级为本局,绝不崩)。 */
function loadTotalStars() {
try { return Number(save.get(SAVE_KEY_STARS, 0)) || 0; } catch (_) { return 0; }
}
function persistTotalStars(v) {
try { save.set(SAVE_KEY_STARS, v); } catch (_) { /* 容错:存档不可用只影响收集持久 */ }
}
function dealLevel(idx) {
board = generateBoard(idx, rng, VW, VH);
stepState = 'observe';
stepUntilMs = elapsedMs + OBSERVE_MS;
lockedUntilMs = 0; lockedTileId = null;
levelWrongs = 0;
lastProgressAtMs = elapsedMs;
if (ctx) ctx.log('level', '出题', { level: idx + 1, clue: board.clue, matches: board.matchTotal });
}
function enterPlay() {
sessionScore.reset();
levelIdx = 0; levelsCleared = 0; elapsedMs = 0;
streak = 0; bestStreak = 0; lastHitAtMs = -1;
floats = []; won = false;
starsThisRun = 0; lastLevelStars = 0;
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
roundTimer = timerScheduler.after(ROUND_MS, () => {
sessionScore.lose();
won = false;
sceneFsm.transition(SCENE_OVER);
if (ctx) ctx.log('scene', 'over 氧气耗尽', { score: sessionScore.getScore(), levelsCleared, starsThisRun });
});
sceneFsm.transition(SCENE_PLAY);
dealLevel(0);
if (audioMusic) { try { audioMusic.playSfx('click'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('scene', 'play 开始潜航', { roundMs: ROUND_MS, levels: levelsTotal(), meta: GENRE_META });
}
function finishWin() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
sessionScore.win();
won = true;
sceneFsm.transition(SCENE_OVER);
if (audioMusic) { try { audioMusic.playSfx('win'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('scene', 'over 满载而归', { score: sessionScore.getScore(), levelsCleared, starsThisRun });
}
/** 过关:星级揭晓 + 奖励 + 收集入账(save 持久)。 */
function clearLevel() {
levelsCleared += 1;
measures.levelsClearedTotal += 1;
const bonus = levelBonus(levelIdx);
sessionScore.add(bonus);
lastLevelStars = starsForLevel(levelWrongs);
starsThisRun += lastLevelStars;
totalStars += lastLevelStars;
persistTotalStars(totalStars); // 收集立即入账(中途退出也不丢)
floats.push({ x: VW / 2, y: VH * 0.5, age: 0, life: FLOAT_LIFE_S, text: '过关 +' + bonus, color: '#5fe0a0' });
stepState = 'clearing';
stepUntilMs = elapsedMs + CLEAR_PAUSE_MS;
if (juice) { try { juice.flashScreen(0.25, 0.5); } catch (_) { /* 容错 */ } }
if (audioMusic) { try { audioMusic.playSfx('win'); } catch (_) { /* 容错 */ } }
if (audioMusic && lastLevelStars >= 3) { try { audioMusic.playSfx('coin'); } catch (_) { /* 容错 */ } } // 满星加一声
if (ctx) ctx.log('level', '过关', { level: levelIdx + 1, bonus, stars: lastLevelStars, score: sessionScore.getScore() });
}
function solveTile(tile) {
tile.cleared = true;
measures.hits += 1;
streak = (lastHitAtMs >= 0 && elapsedMs - lastHitAtMs <= COMBO_WINDOW_MS) ? streak + 1 : 1;
lastHitAtMs = elapsedMs;
lastProgressAtMs = elapsedMs;
if (streak > bestStreak) bestStreak = streak;
const gained = hitScore(streak);
sessionScore.add(gained);
const mult = gained / hitScore(1);
floats.push({ x: tile.x, y: tile.y - tile.size * 0.5, age: 0, life: FLOAT_LIFE_S,
text: '+' + gained + (mult > 1 ? ' ×' + mult : ''), color: mult > 1 ? '#ffd34a' : '#ffd35e' });
if (juice) { try { juice.burst(tile.x, tile.y); } catch (_) { /* 容错 */ } }
if (audioMusic) { try { audioMusic.playSfx('chime'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('solve', '打捞', { level: levelIdx + 1, tile: tile.id, gained, streak, score: sessionScore.getScore() });
if (remainingMatches(board.tiles, board.rule) === 0) clearLevel();
}
function wrongTile(tile) {
measures.wrongs += 1;
levelWrongs += 1;
streak = 0;
lockedUntilMs = elapsedMs + LOCK_MS;
lockedTileId = tile ? tile.id : null;
if (juice) { try { juice.shakeScreen(0.18, 4); } catch (_) { /* 容错 */ } }
if (audioMusic) { try { audioMusic.playSfx('thud'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('wrong', '捞错', { level: levelIdx + 1, tile: tile ? tile.id : null, levelWrongs });
}
function handleTap(x, y) {
measures.taps += 1;
const cur = sceneFsm.current();
if (cur === SCENE_MENU) {
if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();
return;
}
if (cur === SCENE_OVER) {
if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay();
return;
}
if (cur === SCENE_PLAY) {
if (!canSolveNow() || !board) return;
const tile = hitTile(x, y, board.tiles);
if (!tile || tile.cleared) return;
if (matchesRule(tile, board.rule)) solveTile(tile);
else wrongTile(tile);
}
}
function updateFloats(dt) {
for (let i = floats.length - 1; i >= 0; i--) {
const f = floats[i];
f.age += dt; f.y -= FLOAT_RISE_PXPS * dt;
if (f.age >= f.life) floats.splice(i, 1);
}
}
/** 卡壳软提示(P4 卡壳兜底):8s 无正确操作 → 剩余解脉动。 */
function hintIdsNow() {
if (stepState !== 'solve' || !board) return null;
if (elapsedMs - lastProgressAtMs < HINT_AFTER_MS) return null;
const ids = new Set();
for (const tile of board.tiles) if (!tile.cleared && matchesRule(tile, board.rule)) ids.add(tile.id);
return ids;
}
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { board = null; floats = []; menuStartBtn = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH, totalStars }).startBtn; },
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
if (!board) return;
renderPlay(g, hudUi, {
vw: VW, vh: VH,
tiles: board.tiles, clue: board.clue, intro: board.intro, floats,
score: sessionScore.getScore(), levelIdx, total: levelsTotal(),
remainMs: Math.max(0, ROUND_MS - elapsedMs), roundMs: ROUND_MS,
starsThisRun,
phaseNote: stepState === 'observe' ? { text: '观察一下…' }
: (stepState === 'clearing' ? { text: '第 ' + (levelIdx + 1) + ' 关完成!', stars: lastLevelStars } : null),
lockedTileId: elapsedMs < lockedUntilMs ? lockedTileId : null,
hintIds: hintIdsNow(),
});
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } },
render(g) {
overRestartBtn = renderOver(g, hudUi, {
vw: VW, vh: VH, won,
score: sessionScore.getScore(), levelsCleared, bestStreak,
starsThisRun, totalStars,
levelsTried: Math.max(1, levelsCleared + (won ? 0 : 1)),
title: titleForStars(starsThisRun),
best: sessionScore.getBest(),
}).restartBtn;
},
});
}
const instance = {
async init(b) {
boot = b;
ctx = b.ctx;
assets = await loadAssets('./assets/');
totalStars = loadTotalStars(); // 跨局收集回读(回归可见积攒)
defineScenes();
sceneFsm.start(SCENE_MENU);
inited = true;
ctx.log('init', 'sea-puzzle 就绪 menu', { vw: VW, vh: VH, levels: levelsTotal(), totalStars });
},
update(dt) {
if (!inited) return;
measures.frames += 1;
if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); // ★ 必调,驱动受控时钟
sceneFsm.update(dt);
if (sceneFsm.current() === SCENE_PLAY) {
elapsedMs += dt * 1000; // dt 秒 → 毫秒计时
if (stepState === 'observe' && elapsedMs >= stepUntilMs) {
stepState = 'solve';
lastProgressAtMs = elapsedMs;
} else if (stepState === 'clearing' && elapsedMs >= stepUntilMs) {
const next = levelIdx + 1;
if (next >= levelsTotal()) finishWin();
else { levelIdx = next; dealLevel(next); }
}
updateFloats(dt);
}
},
render(g) {
if (!inited) return;
sceneFsm.render(g);
if (juice) { try { juice.render(g); } catch (_) { /* 容错 */ } } // 粒子每帧手调
},
handleTap,
destroy() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
try { sceneFsm.reset(); } catch (_) { /* 幂等 */ }
inited = false;
},
/** 取证契约同骨架:targets 只把当前正确解标 occupied、score 单调升;丰富层键 additive。 */
_forensicsView() {
return {
state: () => ({
phase: sceneFsm.current(),
score: sessionScore.getScore(),
best: sessionScore.getBest(),
outcome: sessionScore.getOutcome(),
remainMs: Math.max(0, ROUND_MS - elapsedMs),
level: levelIdx + 1,
levelsTotal: levelsTotal(),
levelsCleared,
starsThisRun,
totalStars,
clue: board ? board.clue : null,
matchesLeft: board ? remainingMatches(board.tiles, board.rule) : 0,
targets: board ? board.tiles.map((tile) => ({
id: tile.id,
x: tile.x,
y: tile.y,
occupied: canSolveNow() && !tile.cleared && matchesRule(tile, board.rule),
})) : [],
}),
measures: () => ({ ...measures }),
};
},
_handleAction(action) {
if (!action || !inited) return;
const cur = sceneFsm.current();
if (action.type === 'start' && cur === SCENE_MENU) enterPlay();
else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay();
else if (action.type === 'solveFirst' && cur === SCENE_PLAY && board && canSolveNow()) {
const tile = board.tiles.find((t) => !t.cleared && matchesRule(t, board.rule));
if (tile) solveTile(tile);
}
},
};
return instance;
}

View File

@ -0,0 +1,73 @@
/**
* game.js L1 固定 plumbing · LLM 不写(writeFileTool )装载契约 GameHostFactory wrapper
* owner_template L1 固定骨架 消费方host-config.js(templateFactory)
*
*
* 职责 · 为何固定
* host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 game-logic.js
* "易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**,
* 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类
* 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool )
*
*/
'use strict';
// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。
import { createGame as createGameLogic } from './game-logic.js';
/**
* 装载契约 GameHostFactoryhost boot 调本工厂建 GameInstance
* 摊平 opts.runtime game-logic createGame({plugins,bundle,viewport});
* 接管输入handleTap 派发这一固定 plumbing(根治 B):init 后订阅受控输入,
* pointerdown 一律直达实例方法 handleTap(x,y)keydown 直达 handleKey(key)
* L3 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
* (不写 ctx.getInput不自管 pendingClicks 队列不在 update 里挑时机消费点击)
* 结构性根除点击在错的 phase 分支被消费 / 永不消费 游戏启动不了这类 M3 易犯错
* @param {Object} [opts] 工厂选项(host wiring)
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
*/
export default function gameFactory(opts) {
const runtime = (opts && opts.runtime) || {};
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
const game = createGameLogic({
plugins: runtime.plugins || {},
bundle: runtime.bundle,
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
});
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
let inputSubs = [];
game.init = async function init(boot) {
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
const ctx = boot && boot.ctx;
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
if (typeof game.handleTap === 'function') {
inputSubs.push(input.on('pointerdown', (e) => {
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
}));
}
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
if (typeof game.handleKey === 'function') {
inputSubs.push(input.on('keydown', (e) => {
try { game.handleKey((e && e.key) || ''); }
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
}));
}
};
game.destroy = function destroy() {
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
inputSubs = [];
if (innerDestroy) innerDestroy();
};
return game;
}

View File

@ -0,0 +1,110 @@
/**
* host-config.js L1 固定 plumbing · LLM 不写(writeFileTool )宿主装配:实例化全标准能力插件集 + 视口 + 工厂 wiring
* owner_template L1 固定骨架 消费方main.js(spike)/ entry-bundle.js(SAA) buildTemplateHostConfig 装配 bootGameHost
*
*
* 本文件做什么 · 为何固定
* 通用宿主 bootGameHost(opts) 需要四组装配:插件实例集 / 注册偏序 / 视口 / 工厂 wiring
* 本文件把"这游戏怎么配 bootGameHost"固化为**全游戏逐字相同**的单一来源
* 3 层架构(2026-06-22 边界设计),host-config **L1 工程骨架**:LLM 不碰(writeFileTool 拒写),
* 根除 M3 反复搞坏插件 wiring 的错类(viewport 漏键=reading 'w' / registerOrder / 取插件嵌套)
*
* 全标准能力插件集(D2 全注入,创始人裁 D9=A)
* 编排 4:sceneFsm/sessionScore/hudUi/timerScheduler · 持久:save · 手感/特效:gamefeel/juice/palettePost
* · 音频:audioMusic · 物理:collision/physics 11 件全摆上桌,L3 按需取用(用到才解构)不用的不解构即可
* 默认 config generic-host-config 同口径(能力库默认面,非玩法); node-import-safe(碰受控资源只在 init/runtime )
*
* Q4 铁律本文件零引擎 import(仅装配插件实例 + 视口 + wiring);引擎由 bootGameHost opts.engine 注入node import-safe
*
*/
'use strict';
// 编排 4 件(menu→play→over + 计分 + UI 原语 + 受控定时)。
import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js';
import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js';
import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js';
import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js';
// 持久(最佳分等):优先 localStorage,不可用退内存。
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
// 手感/特效/后处理(打击感 / 粒子 / 暗角)。
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
// 音频(audio ctx 内部经 ctx.getAudioContext 懒接,L3 只调 play;无声卡 no-op)。
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
// 物理/碰撞(网格碰撞 + 轻物理)。
import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js';
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
// 装载契约 game 工厂(game.js 默认导出 = 薄 wrapper,L1 固定;实际玩法在 game-logic.js,L3 写)。
import createGame from './game.js';
/** 本款游戏工厂(GameHostFactory)。 */
export const templateFactory = createGame;
/** 逻辑视口(390×844 移动竖屏基准)。**L1 固定硬编码**:不从 render.js 导入,以免 LLM 改 render 时漏 W/H 致 viewport 失值 → boot 崩 reading 'w'。 */
export const templateViewport = { w: 390, h: 844 };
/**
* 装配调 bootGameHost 所需的全部入参( 11 插件实例集 / 注册偏序 / 视口 / 工厂 wiring)
* main.js / entry-bundle.js canvas/statusEl/engine/seed/mode/engineMode/onReady 后传 bootGameHost
* @param {{seed:number}} args
* @returns {{factory:Function, plugins:object, registerOrder:Array<object>, viewport:{w:number,h:number}, buildFactoryOpts:Function}}
*/
export function buildTemplateHostConfig(args) {
const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0;
/* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */
let saveAdapter;
try {
saveAdapter = createLocalStorageAdapter();
} catch (e) {
console.warn('[_template] localStorage 不可用,存档退内存 adapter:' + (e && e.message));
saveAdapter = createMemoryAdapter();
}
const save = createSaveProgressPlugin({ namespace: 'zaomeng-template', adapter: saveAdapter });
/* ── 编排 4(host 拥有,L3 经 plugins.<键> 用)── */
const sceneFsm = createSceneFsmPlugin();
// session-score 的 best 接 save:跨局保留最佳分(load/save 经 KV 面,失败容错降级)。
const sessionScore = createSessionScorePlugin({
best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) },
});
const hudUi = createHudUiPlugin();
const timerScheduler = createTimerSchedulerPlugin();
/* ── 能力库 6(默认 config 同 generic-host-config 口径;seed 给粒子确定性)── */
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
const collision = createCollisionPlugin({ cellSize: 96 });
const physics = createPhysicsLitePlugin();
// 工厂注入键名(canonical;与 L3 game-logic.js 内 plugins.<键> 解构一致;skill 逐字列)。
const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics };
// 注册偏序(M1:全 11 合集显式定义):持久+编排先(save 先于 sessionScore 的 best 钩子)→ 基元(collision/physics/gamefeel/juice/palettePost)→ audioMusic 末(同 generic 偏序惯例,audio 依赖最少、放末)。
const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic];
return {
factory: templateFactory,
plugins,
registerOrder,
viewport: templateViewport,
/**
* 构造工厂 opts(host wiring): runtime 把全插件集 + bundle + 真实 AudioContext 取法暴露给游戏工厂
* game.js wrapper(L1)吃掉 opts.runtime.{plugins,bundle} 两层解构,把扁平件 + viewport L3 createGame
* @param {import('../../../src/core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用)
* @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄
*/
buildFactoryOpts: (_bootCtx, hostInternals) => ({
runtime: {
plugins: hostInternals.plugins,
bundle: hostInternals.bundle,
getRealAudioCtx: hostInternals.getRealAudioCtx,
viewport: templateViewport, // D3:viewport 下传,L3 读 viewport.w/h 不硬编码
},
}),
};
}

View File

@ -0,0 +1,132 @@
/**
* main.js 点圆得分宿主引导(薄包装层:把通用宿主 bootGameHost 配成本款 bootGame)
* owner_template 克隆起点 消费方entry.js(打包入口) index.html
*
* 职责(精简自 wanglanmei main.js)
* 解析 URL 参数(seed/mode/engine) 装配本款专属项(取自 host-config) 调通用 bootGameHost
* onReady 钩子里挂取证全局(window.__gameHost / __gameForensics), headless-boot e2e CDP 驱动
*
* 双通道(沿用通用宿主)
* - ?engine=stub(**缺省**,回滚锚):浏览器 RAF + 单一墙钟,node headless 走此(无引擎注入);
* - ?engine=real(显式开):引擎掌帧(浏览器 + entry 包装层注入引擎名字空间)
*
* 红线遵守
* - 引擎 import 只活 entry.js;main.js(host ) opts.engine 注入用引擎不直接 import( node import-safe);
* - game.js 游戏工厂零引擎 import
*/
'use strict';
// 通用宿主:引擎五回调骨架 + 受控面背书 + 输入接线全在此(参数化游戏工厂/插件/视口)。
import { bootGameHost } from '../../../src/host/boot-game-host.js';
// 本款宿主装配配置(工厂/4 插件/视口/工厂 wiring)——bootGame 与打包入口共用同一份(防第二套)。
import { buildTemplateHostConfig } from './host-config.js';
// 协议号(取证导出用)。
import { CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
/** 读 URL 参数(node 桩 window.location.search 亦可读)。 */
function getParam(name, def) {
try {
const v = new URLSearchParams(window.location.search).get(name);
return v == null ? def : v;
} catch {
return def;
}
}
/** 解析种子(缺省固定值,保证「不传也确定」)。 */
function resolveSeed() {
const raw = getParam('seed', null);
if (raw == null) return 0x1234abcd >>> 0;
const n = Number(raw);
return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0;
}
/**
* 启动本款游戏(host)退化为 bootGameHost 的薄包装:装配本款专属参数 + 组装取证全局
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} opts
* - engine:引擎名字空间(littlejsengine), entry.js 包装层经 opts 注入(main.js 不顶层 import 引擎, node-safe)
* ?engine=real 通道用;stub/node 通道不触达(缺省 undefined)
* @returns {Promise<object>} gameHost 句柄(同时挂 window.__gameHost)
*/
export async function bootGame(opts) {
const canvas = opts.canvas;
const statusEl = opts.statusEl || null;
const seed = resolveSeed();
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
// 引擎通道:缺省 stub(回滚锚 + node headless 走此);真玩取证显式带 &engine=real。
const engineMode = getParam('engine', 'stub');
// 本款宿主装配(工厂/4 插件/注册偏序/视口/工厂 wiring):取自共用配置(与打包入口同源)。
const cfg = buildTemplateHostConfig({ seed });
/** 取证内部件(onReady 注入:游戏取证视图等)。 */
let gameForensics = null;
/** @type {object|null} bootGameHost 返回的通用 host 句柄。 */
let coreHost = null;
/** 本款取证导出(读 onReady 注入的内部件)。 */
const forensics = {
protocol: CORE_PROTOCOL_VERSION,
seed,
mode,
/** 游戏状态快照(JSON 安全):phase/score/best/remainMs/target/...。 */
state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }),
/** 运行度量:frames/taps/hits。 */
measures: () => (gameForensics ? gameForensics.measures() : {}),
};
/** 驱动句柄(evidence 驱动 / e2e):在通用 host 句柄上扩展本款取证面。延迟读 coreHost(onReady 时已赋值)。 */
const gameHost = {
seed,
mode,
engineMode,
protocol: CORE_PROTOCOL_VERSION,
frameCount: () => (coreHost ? coreHost.frameCount() : 0),
stepFrames: (n) => coreHost && coreHost.stepFrames(n),
startRaf: () => coreHost && coreHost.startRaf(),
stopRaf: () => coreHost && coreHost.stopRaf(),
/** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */
tap: (x, y) => coreHost && coreHost.tap(x, y),
/** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API):'start'/'hitTarget'/'restart'。 */
do: (action) => coreHost && coreHost.do(action),
state: () => forensics.state(),
forensics,
};
/** 状态行(仅人看;通用宿主已设过一轮,这里仅错误兜底用)。 */
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
try {
// 调通用宿主:把本款专属项经 opts 注入(取自共用配置 cfg),引擎骨架复用 bootGameHost。
coreHost = await bootGameHost({
canvas,
statusEl,
engine: opts.engine, // entry 包装层注入的引擎名字空间(real 用;stub/node 为 undefined)
seed,
mode,
engineMode,
viewport: cfg.viewport, // 本款视口(390×844,共用配置)
factory: cfg.factory, // 游戏工厂来源
plugins: cfg.plugins, // 本款 4 件插件实例集(+save)
registerOrder: cfg.registerOrder, // 注册偏序
buildFactoryOpts: cfg.buildFactoryOpts, // host wiring(runtime:{plugins,bundle})
/** 就绪钩子:受控面/游戏全就绪后,接进取证对象 + 挂取证全局。 */
onReady: (host, internals) => {
gameForensics = internals.getGameForensics();
// 挂取证全局(headless-boot 与 e2e CDP 驱动据此读状态 / 驱动)。
window.__gameForensics = forensics;
window.__gameHost = gameHost;
},
});
} catch (e) {
// 引擎接管/装载失败:镜像错因,重抛供上层捕获。
window.__gameBootError = window.__gameHostBootError || String((e && e.message) || e);
setStatus('BOOT ERROR: ' + window.__gameBootError + '(可加 ?engine=stub 回滚桩)');
throw e;
}
return gameHost;
}

View File

@ -0,0 +1,231 @@
/**
* render.js L3 表现层few-shot 正例深海寻宝谜航的绘制
* 一律画在入参 g(= boot.mainContext)全程序化绘制:深海渐变 + 气泡点缀 + 宝物图形
* 相对骨架的观感升级:主题底景全换元素画法全换(贝壳/海星/珍珠)结算面板加星级与称号
*/
'use strict';
import { THEME } from './core.js';
/* ── 深海配色(身份层) ── */
const C = {
seaTop: '#06263f',
seaBottom: '#0b4a66',
banner: '#ffd35e',
bannerText: '#3a2a05',
intro: '#9fd8ef',
tile: '#123a55',
tileEdge: 'rgba(159,216,239,0.35)',
tileCleared: 'rgba(18,58,85,0.35)',
panel: 'rgba(255,255,255,0.08)',
stroke: 'rgba(159,216,239,0.3)',
text: '#eaf7ff',
textDim: '#8fb8cc',
accent: '#ffd35e',
good: '#5fe0a0',
bad: '#ff7b6b',
star: '#ffd34a',
starOff: 'rgba(255,211,74,0.25)',
};
/** 深海底景:渐变 + 上浮气泡(确定性摆位)+ 海床。 */
function drawBackdrop(g, VW, VH) {
const grad = g.createLinearGradient(0, 0, 0, VH);
grad.addColorStop(0, C.seaTop);
grad.addColorStop(1, C.seaBottom);
g.fillStyle = grad;
g.fillRect(0, 0, VW, VH);
g.fillStyle = 'rgba(159,216,239,0.14)';
for (let i = 0; i < 14; i++) {
const bx = (i * 97) % VW;
const by = (i * 173) % VH;
g.beginPath(); g.arc(bx, by, 3 + (i % 3) * 2, 0, Math.PI * 2); g.fill();
}
g.fillStyle = 'rgba(6,30,45,0.8)';
g.beginPath(); g.ellipse(VW / 2, VH + 24, VW * 0.8, 64, 0, 0, Math.PI * 2); g.fill();
}
/** 画一件宝物(shape id → 贝壳/海星/珍珠;换主题改这里)。 */
function drawGlyph(g, shapeId, cx, cy, r, fill) {
g.fillStyle = fill;
g.strokeStyle = 'rgba(0,0,0,0.35)';
g.lineWidth = 2;
if (shapeId === 'starfish') { // 海星:五角
g.beginPath();
for (let i = 0; i < 10; i++) {
const ang = -Math.PI / 2 + (i * Math.PI) / 5;
const rr = i % 2 === 0 ? r : r * 0.45;
const px = cx + Math.cos(ang) * rr;
const py = cy + Math.sin(ang) * rr;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.closePath(); g.fill(); g.stroke();
} else if (shapeId === 'shell') { // 贝壳:扇形 + 纹路
g.beginPath();
g.moveTo(cx, cy + r * 0.8);
g.arc(cx, cy - r * 0.1, r, Math.PI * 0.15, Math.PI * 0.85, true);
g.closePath(); g.fill(); g.stroke();
g.beginPath();
g.moveTo(cx, cy + r * 0.75); g.lineTo(cx - r * 0.5, cy - r * 0.55);
g.moveTo(cx, cy + r * 0.75); g.lineTo(cx + r * 0.5, cy - r * 0.55);
g.strokeStyle = 'rgba(0,0,0,0.25)'; g.lineWidth = 1.5; g.stroke();
} else { // 珍珠:圆 + 高光
g.beginPath(); g.arc(cx, cy, r * 0.85, 0, Math.PI * 2); g.fill(); g.stroke();
g.fillStyle = 'rgba(255,255,255,0.55)';
g.beginPath(); g.arc(cx - r * 0.3, cy - r * 0.3, r * 0.22, 0, Math.PI * 2); g.fill();
}
}
/** count 件宝物在格内的摆位。 */
function glyphLayout(count, size) {
const off = size * 0.22;
if (count === 2) return [{ dx: -off, dy: 0 }, { dx: off, dy: 0 }];
if (count === 3) return [{ dx: 0, dy: -off }, { dx: -off, dy: off }, { dx: off, dy: off }];
return [{ dx: 0, dy: 0 }];
}
/** 一个谜面格(底板 + 宝物 + 状态视觉:cleared 打勾 / locked 红罩 / hinting 金描边)。 */
function drawTile(g, tile, theme, opts) {
const half = tile.size / 2;
const x = tile.x - half, y = tile.y - half;
const colorDef = theme.colors.find((c) => c.id === tile.color) || { fill: '#999' };
g.fillStyle = tile.cleared ? C.tileCleared : C.tile;
g.beginPath();
if (g.roundRect) g.roundRect(x, y, tile.size, tile.size, 12); else g.rect(x, y, tile.size, tile.size);
g.fill();
g.strokeStyle = opts && opts.hinting ? C.accent : C.tileEdge;
g.lineWidth = opts && opts.hinting ? 3 : 2;
g.stroke();
if (tile.cleared) {
g.strokeStyle = C.good; g.lineWidth = 5;
g.beginPath();
g.moveTo(tile.x - half * 0.4, tile.y);
g.lineTo(tile.x - half * 0.1, tile.y + half * 0.32);
g.lineTo(tile.x + half * 0.45, tile.y - half * 0.3);
g.stroke();
return;
}
const r = tile.size * 0.16;
for (const p of glyphLayout(tile.count, tile.size)) {
drawGlyph(g, tile.shape, tile.x + p.dx, tile.y + p.dy, r, colorDef.fill);
}
if (opts && opts.locked) {
g.fillStyle = 'rgba(255,123,107,0.25)';
g.beginPath();
if (g.roundRect) g.roundRect(x, y, tile.size, tile.size, 12); else g.rect(x, y, tile.size, tile.size);
g.fill();
}
}
/** 线索横幅 + 关卡 intro 副标(教学感)。 */
function drawClueBanner(g, hud, clue, intro, VW, VH) {
const bh = 52;
const by = Math.round(VH * 0.26) - bh - 34;
hud.drawPanel(g, { x: 16, y: by, w: VW - 32, h: bh }, { fill: C.banner, stroke: 'rgba(0,0,0,0.35)', lineWidth: 2, radius: 14 });
hud.drawText(g, clue, VW / 2, by + bh / 2, { font: '800 21px sans-serif', color: C.bannerText, align: 'center', baseline: 'middle' });
if (intro) {
hud.drawText(g, intro, VW / 2, by + bh + 16, { font: '600 13px sans-serif', color: C.intro, align: 'center', baseline: 'middle' });
}
}
/** 顶部 HUD:关卡 + 分数 + 本局星星 + 剩时条。 */
function drawHud(g, hud, info) {
hud.drawPanel(g, { x: 8, y: 12, w: info.vw - 16, h: 56 }, { fill: C.panel, stroke: C.stroke, lineWidth: 2, radius: 12 });
hud.drawText(g, '第 ' + (info.levelIdx + 1) + '/' + info.levelsTotal + ' 关', 22, 30, { font: '700 16px sans-serif', color: C.text, align: 'left', baseline: 'middle' });
hud.drawText(g, '★ ' + info.starsThisRun, 22, 52, { font: '700 14px sans-serif', color: C.star, align: 'left', baseline: 'middle' });
hud.drawText(g, String(info.score), info.vw / 2, 40, { font: '800 24px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
const ratio = Math.max(0, Math.min(1, info.remainMs / info.roundMs));
hud.drawBar(g, { x: info.vw - 118, y: 32, w: 96, h: 14 }, ratio, { fill: ratio < 0.25 ? C.bad : C.good, bg: 'rgba(0,0,0,0.5)', radius: 6 });
}
/** 得分/连击飘字。 */
function drawFloats(g, floats) {
for (const f of floats) {
const alpha = Math.max(0, 1 - f.age / f.life);
if (alpha <= 0) continue;
g.save(); g.globalAlpha = alpha;
g.font = '700 24px sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = f.color || C.accent; g.strokeStyle = 'rgba(0,0,0,0.6)'; g.lineWidth = 4;
g.strokeText(f.text, f.x, f.y); g.fillText(f.text, f.x, f.y);
g.restore();
}
}
/** 画一排星(结算/过关用;on=实心金,off=浅描)。 */
function drawStars(g, cx, cy, n, size) {
for (let i = 0; i < 3; i++) {
const sx = cx + (i - 1) * size * 2.4;
g.fillStyle = i < n ? C.star : C.starOff;
g.beginPath();
for (let k = 0; k < 10; k++) {
const ang = -Math.PI / 2 + (k * Math.PI) / 5;
const rr = k % 2 === 0 ? size : size * 0.45;
const px = sx + Math.cos(ang) * rr;
const py = cy + Math.sin(ang) * rr;
if (k === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.closePath(); g.fill();
}
}
/* ────────── 场景绘制入口 ────────── */
/**
* menu:标题 + 示例宝物 + 累计星星(跨局收集,回归可见)+ 开始按钮
* @returns {{startBtn:{x,y,w,h}}}
*/
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
hud.drawText(g, THEME.title, view.vw / 2, view.vh * 0.26, { font: '800 42px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, THEME.tagline, view.vw / 2, view.vh * 0.26 + 52, { font: '15px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
const cy = view.vh * 0.4;
drawGlyph(g, 'shell', view.vw / 2 - 70, cy, 22, THEME.colors[0].fill);
drawGlyph(g, 'starfish', view.vw / 2, cy, 22, THEME.colors[2].fill);
drawGlyph(g, 'pearl', view.vw / 2 + 70, cy, 22, THEME.colors[1].fill);
hud.drawText(g, '累计打捞 ★ ' + view.totalStars, view.vw / 2, view.vh * 0.48, { font: '700 16px sans-serif', color: C.star, align: 'center', baseline: 'middle' });
const w = 240, h = 60;
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h },
{ label: '开始潜航', font: '700 22px sans-serif', fill: C.accent, textColor: C.bannerText, radius: 30 });
return { startBtn };
}
/** play:底景 + 线索横幅(带 intro)+ 谜盘 + 飘字 + HUD + 阶段提示。 */
export function renderPlay(g, hud, view) {
const { vw, vh, tiles, clue, intro, floats, score, levelIdx, total, remainMs, roundMs,
phaseNote, lockedTileId, hintIds, starsThisRun } = view;
drawBackdrop(g, vw, vh);
drawClueBanner(g, hud, clue, intro, vw, vh);
for (const tile of tiles) {
drawTile(g, tile, THEME, {
locked: lockedTileId === tile.id,
hinting: !!(hintIds && hintIds.has && hintIds.has(tile.id)),
});
}
drawFloats(g, floats);
drawHud(g, hud, { vw, vh, score, levelIdx, levelsTotal: total, remainMs, roundMs, starsThisRun });
if (phaseNote) {
hud.drawText(g, phaseNote.text, vw / 2, vh * 0.84, { font: '700 20px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
if (phaseNote.stars != null) drawStars(g, vw / 2, vh * 0.84 + 40, phaseNote.stars, 14);
}
}
/**
* over:结算即炫耀时刻大分数 + 星级 + 称号 + 通关数/最高连击 + 累计收集 + 最佳纪录
* @returns {{restartBtn:{x,y,w,h}}}
*/
export function renderOver(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
g.fillStyle = 'rgba(4,18,30,0.55)'; g.fillRect(0, 0, view.vw, view.vh);
hud.drawText(g, view.won ? '满载而归!' : '氧气耗尽', view.vw / 2, view.vh * 0.17, { font: '800 34px sans-serif', color: view.won ? C.good : C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, String(view.score), view.vw / 2, view.vh * 0.27, { font: '800 56px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
hud.drawText(g, '本局得分', view.vw / 2, view.vh * 0.27 + 42, { font: '600 14px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
drawStars(g, view.vw / 2, view.vh * 0.38, Math.min(3, Math.round(view.starsThisRun / Math.max(1, view.levelsTried))), 16);
hud.drawText(g, '本局 ★ ' + view.starsThisRun + ' · 称号「' + view.title + '」', view.vw / 2, view.vh * 0.43, { font: '700 18px sans-serif', color: C.star, align: 'center', baseline: 'middle' });
hud.drawText(g, '通过 ' + view.levelsCleared + ' 关 最高连击 ×' + view.bestStreak, view.vw / 2, view.vh * 0.5, { font: '600 18px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '累计打捞 ★ ' + view.totalStars + ' 最佳纪录 ' + view.best, view.vw / 2, view.vh * 0.5 + 30, { font: '600 15px sans-serif', color: C.textDim, 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 },
{ label: '再潜一次', font: '700 22px sans-serif', fill: C.accent, textColor: C.bannerText, radius: 30 });
return { restartBtn };
}

View File

@ -0,0 +1,54 @@
# _template-puzzle — A-model「规则谜盘」解谜品类轻脚手架(3 层架构)
一个**可玩的完整解谜游戏**:每关一条线索(找出所有【红色】/【星形】/【两颗】…)→ 观察盘面 → 点中所有符合的格子过关 → 关卡逐关引入新规则维度 → 通全关提前胜利 / 限时到点结算(menu→play→over)。这份骨架**已经把整套谜盘循环接好了**(含让自动验收能跑通的取证契约)——你的活是按 brief **换主题、加深度**,不是重写循环。
**怎么改 = 给这款解谜换身份,循环结构别动:**
- 换主题/元素/文案 → 改 `src/core.js``THEME`(shapes/colors/countNames/标题)。宝石写圆/菱/星,水果写苹果/香蕉/葡萄,海洋写鱼/贝/星——**同骨架做出不雷同的解谜**就靠这里。
- 调难度/关卡阶梯 → 改 `src/core.js``LEVELS`(盘面大小/解数/规则维度逐关递进)与节奏常量(观察窗/锁定/连击窗)。
- 换观感(底景/元素画法/配色)→ 改 `src/render.js`(`drawGlyph` 把圆/菱/星换成你的元素)。
- 想加机制(星级/图鉴/新规则维度/每日谜题)→ 在 `src/game-logic.js` 循环里加,但**别破坏 `_forensicsView``targets`/`score` 契约**(改坏=九门 H 进展门直接挂)。
- **别推倒重写整个循环**:出题/观察窗/点解判定/连击/过关递进/限时结算/卡壳提示都接好了,从它起步只补身份与深度,远比从零写省。
- 「设计什么才好玩」读 `.agents/skills/puzzle-game-design.md`(顿悟距离/规则递进/卡壳兜底/成绩炫耀);完整过门正例参照 `game-runtime/games/_fewshot-puzzle/src/`
## 3 层 + 你只碰 L3
| 层 | 文件 | 你 |
|---|---|---|
| **L1 固定 plumbing**(write 会被拒) | `index.html` · `entry-bundle.js` · `src/host-config.js`(已注入**全 11 插件** + viewport 390×844 + wiring) · `src/game.js`(薄 wrapper:摊平 opts.runtime → 交 game-logic) | **不碰** |
| **L3 游戏本体(已是完整解谜游戏)** | **`src/game-logic.js`(谜盘循环 + 五法 + 取证契约)** · `src/core.js`(纯逻辑 + 多样性参数空间) · `src/render.js`(观感) · `src/assets.js`(资产装载) | **在这上面换身份加深度** |
## 入口契约(钉死)
```js
// src/game-logic.js —— 谜盘循环已写好,你换身份
export function createGame({ plugins, bundle, viewport }) {
// 插件已扁平注入(全 11 件,用到才解构);本骨架解谜档用 6 件:
const { sceneFsm, sessionScore, hudUi, timerScheduler, audioMusic, juice } = plugins;
// 全部可用键:sceneFsm sessionScore hudUi timerScheduler save gamefeel juice palettePost audioMusic collision physics
return {
async init(boot) { const ctx = boot.ctx; /* 装资产→定义场景→start;输入不订阅(L1 派发到 handleTap) */ },
update(dt) { bundle.tick(dt); /* ★必调;再推进玩法 */ },
render(g) { /* 画在 g;读 viewport.w/h */ },
handleTap(x, y) { /* ★输入入口:各 phase 点击逻辑;L1 在 pointerdown 时直达调用(别自己订阅输入) */ },
destroy() {},
_forensicsView() { return { state(){ /* phase/score/targets 实时算 */ }, measures(){} }; }, // ★解谜档自动验收契约
};
}
```
## 铁律(check 会拦)
- **入参扁平 `{plugins, bundle, viewport}`** —— 没有 opts、没有 opts.runtime、没有 ctx(L1 已摊平)。受控面 ctx 只在 `init(boot).boot.ctx`(时间/随机/输入/引擎)。
- **插件直接 `const {…} = plugins`** —— 别写 `opts.runtime.plugins`(L1 内部的,你见不到)。游戏只用、不 new、不 register。
- **`update(dt)` 必调 `bundle.tick(dt)`** —— 否则 timer-scheduler 等 onFrame 不推进、限时永不到点、一局不结束。**`dt` 是秒**:游戏内毫秒计时要 `elapsedMs += dt*1000`;`timerScheduler.after(ms)` 收毫秒(75s 写 75000)。
- **零引擎 import**(引擎经 `boot.ctx.getEngine()`);游戏源**零裸** Math.random/Date.now/setTimeout/new AudioContext/addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,音效经 plugins.audioMusic;**输入不调 ctx.getInput**——写实例方法 `handleTap(x,y)`(点击)/`handleKey(key)`(键盘),L1 在 pointerdown/keydown 时直达调用。
- **插件方法名一律以 `src/plugins/<name>/api.d.ts` 为准**,别臆测(scene-fsm 是 `define` 不是 defineScenes)。本骨架已用对的母语写法:`sessionScore.add(n)` 计分、`juice.burst(x,y)` 爆点、`hudUi.drawButton(g,rect,opts)` 返回命中矩形可直接接住判命中。
- **画面读 `viewport.w/h`**,别硬编码 390×844。
- **`_forensicsView().state()` 的解谜契约**:`targets` 每格一项、**只把「当前该点的正确解」标 `occupied:true`**(solve 阶段、未清、符合本关规则;干扰项/已清格/观察窗与锁定中一律 false)——解谜的取证面兼职"求解器",自动验收只点 occupied:true 的格驱动解谜;`score` 随点对/过关单调上升(自动验收据它判真有进展)。**换皮加机制都别动这两个键的语义。**
## 你写什么 vs 调什么
- **你写(玩法身份)**:主题/元素/文案与关卡曲线(core 顶部参数空间)、观感与元素画法(render)、把身份接到已写好的谜盘循环(game-logic 一般只微调/加机制)。
- **你调(通用机制)**:计分 `sessionScore` · 场景机 `sceneFsm` · UI 原语 `hudUi` · 受控定时 `timerScheduler` · 持久 `save` · 手感 `gamefeel` · 粒子 `juice` · 后处理 `palettePost` · 音频 `audioMusic` · 碰撞/物理 `collision`/`physics`(全已注入,用到才取)。
详见 `.agents/skills/littlejs-game-dev.md`(11 插件 API 速查 + ❌→✅ 易犯错)与 `.agents/skills/puzzle-game-design.md`(解谜设计手册)。

View File

@ -0,0 +1,24 @@
# 资产生成台账(gen-ledger) — _template-puzzle「规则谜盘」
> 本文件登记 **mmx 生成的素材生成历史**(prompt / 字节 / hash / 替换记录)——改素材时的索引。
> 本模板无外部美术/音频(纯程序化图形),故台账为空(stub)。
## 用法(加美术时)
1. 用 mmx 生成素材,产物存 `assets/`(`.jpg` 美术、`.mp3` 音乐音效):
```bash
# 美术(模型 image-01):<主体> + 统一风格词 + 纯色背景,居中,简洁
mmx image generate --prompt "..." --out gem-ruby.jpg
# 音乐(模型 music-2.6-free):<情绪/配器/场景>
mmx music generate --prompt "轻快专注...益智" --instrumental --bpm 100 --out bgm-loop.mp3
```
2. 在下表追加一行(序号 / 类型 / 文件 / 字节 / hash 前 16 位 / prompt(风格词部分用 STYLE 缩写)/ 退出码)。
3. 同步更新 `assets/manifest.json` 对应行的 `sha256/bytes/prompt`
4. 在 `src/assets.js``IMAGE_FILES` 添键(键名→文件名);render 里用 `assets.images.<键>` 取图。
5. 铁律:**全套素材共用一组风格词**(美术统一性之根);每次调用都把 prompt 写进本台账与 manifest,便于复现替换。
## 台账
| # | 类型 | 文件 | 字节 | hash(前16) | prompt | 结果 |
|---|---|---|---|---|---|---|
| — | — | (本模板无外采素材) | — | — | — | — |

View File

@ -0,0 +1,6 @@
{
"game": "_template-puzzle",
"note": "资产清单(最小空清单)。本模板「规则谜盘」无外部美术/音频,全用程序化图形(drawGlyph 画圆/菱/星),故 assets 为空数组。加美术时:每件外采资产在此登记一行 {file, role, bytes, sha256, source:{tool, model, prompt}};并把键名→文件名加进 src/assets.js 的 IMAGE_FILES。程序化绘制(格子/元素/UI/文字)不录本表,manifest 只录外采资产。",
"styleWords": "(加美术时填:全套素材共用同一组风格词——美术统一性之根;参考 wanglanmei-ref manifest 的 styleWords)",
"assets": []
}

View File

@ -0,0 +1,33 @@
/**
* entry.js 点圆得分 esbuild 打包入口(引擎 import 只落本文件;打成 iife index.html 加载)
* owner_template 克隆起点 消费方scripts/build.mjs(打包) index.html(运行)
*
* 定位 · 精简自 wanglanmei entry.js
* core 协议 + 所用插件 + 游戏(逻辑/渲染/宿主)+ 引擎汇成一个 iife 全局(TemplateGame),
* index.html bootGame 启动**引擎 import 只活本文件**(Q4 铁律),且经动态 import 延后求值:
* · node import entry.js ****触发引擎装载(bootGame 未被调用) entry.js node import-safe;
* · esbuild(bundle+iife, splitting)把动态 import 的引擎内联进单文件产物;
* · 浏览器运行期调 bootGame 时才求值引擎(window/AudioContext 已存在),拿名字空间注入 main.js real 通道
* Q4 铁律不破:main.js(host )经注入用引擎不直接 import;game.js 游戏工厂零引擎 import
*
* 构建(沿 scripts/build.mjs 锁参)
* node scripts/build.mjs games/_template/entry.js games/_template/dist/template-bundle.js --global-name=TemplateGame
* (本模板未预置 dist 产物;克隆后按上行构建即可在浏览器跑 ?engine=real;node headless src/main.js stub,免构建)
*/
'use strict';
import { bootGame as bootGameCore } from './src/main.js';
/**
* 对外 bootGame:动态 import 引擎名字空间后注入委托 main.js 真实现
* index.html 调的是 iife 全局 TemplateGame.bootGame(即本包装)
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} [opts]
* @returns {Promise<object>} gameHost 句柄
*/
export async function bootGame(opts) {
// 动态 import:求值时机延后到 bootGame 被调用(浏览器运行期)。esbuild 内联,无外部请求。
// 调用方已显式给 engine 则尊重其值,不重复装载(便于复用)。
const engine = (opts && opts.engine) || (await import('littlejsengine'));
return bootGameCore(Object.assign({}, opts, { engine }));
}

View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<!--
index.html — 「点圆得分」(_template 克隆起点)宿主页
用法:
· 构建产物(iife 全局 TemplateGame):见 entry.js 头注释的 build.mjs 锁参命令(产物 dist/template-bundle.js);
· serve 本目录,浏览器打开;
· ?seed=12345 注入主随机种子(缺省固定种子,确定可复现);
· ?engine=stub(缺省·回滚锚)/ ?engine=real(引擎掌帧);
· ?mode=evidence 取证模式:stub 下不起 RAF,由 window.__gameHost.stepFrames/tap 确定性驱动;
· 取证导出:window.__gameForensics / window.__gameHost;就绪标记:__gameBooted。
注意:页面壳只负责承载;游戏全部绘制在 canvas 内。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- 固定取证视口:390×844 移动竖屏基准;禁缩放防像素哈希扰动。 -->
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
<title>点圆得分</title>
<style>
/* 自适应居中画布:游戏全部绘制在 canvas 内,页面壳只承载。
画布按 390:844 比例缩放 fit 窗口——桌面短窗口不裁底(否则底部内容如按钮/角色看不见)。
host 输入映射按 getBoundingClientRect 归一化(boot-game-host toCanvasXY),CSS 缩放下点击坐标仍准。 */
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #0a1622;
color: #9fb3c8;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
#wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
#game {
/* 取「宽受限」与「高受限」的较小者,保 390:844 比例完整可见。
!important 覆盖 host 在 stub 通道写的内联 display(390×844 硬编码);buffer 不动→evidence 像素哈希不受影响;
输入经 boot-game-host toCanvasXY 按 getBoundingClientRect 归一,CSS 缩放下点击坐标仍准。 */
width: min(100vw, calc(100vh * 390 / 844)) !important;
height: min(100vh, calc(100vw * 844 / 390)) !important;
background: #10243a;
display: block;
touch-action: none; /* 触摸全交 JS(pointer 桥接),禁浏览器默认手势 */
}
#status {
position: absolute; /* 浮层叠于 canvas 顶部,不占布局流 */
top: 0; left: 0; right: 0;
z-index: 2;
pointer-events: none; /* 绝不截触摸 */
font-size: 12px;
line-height: 1.4;
min-height: 15px;
text-align: center;
word-break: break-all;
opacity: 0.7;
}
</style>
</head>
<body>
<div id="wrap">
<div id="status">booting…</div>
<canvas id="game"></canvas>
</div>
<!-- 构建产物(iife 全局 TemplateGame,含 bootGame)。克隆后按 entry.js 头注释构建生成。 -->
<script src="./dist/template-bundle.js"></script>
<script>
// 引导:异步 boot;失败把错误打到 status + 重抛(评估门要看 console 零未捕获错误)。
(function () {
'use strict';
var statusEl = document.getElementById('status');
var canvas = document.getElementById('game');
try {
var G = window.TemplateGame;
if (!G || typeof G.bootGame !== 'function') {
throw new Error('TemplateGame.bootGame 未找到(template-bundle.js 未正确加载或 globalName 不符)');
}
G.bootGame({ canvas: canvas, statusEl: statusEl })
.then(function () { window.__gameBooted = true; })
.catch(function (e) {
window.__gameBooted = false;
window.__gameBootError = String((e && e.message) || e);
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
throw e;
});
} catch (e) {
window.__gameBooted = false;
window.__gameBootError = String((e && e.message) || e);
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
throw e;
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,73 @@
/**
* assets.js 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件)
* owner_template 克隆起点 消费方game.js(init await loadAssets)/ render.js(若用图则取)
*
* 本款现状:无外部资产(纯程序化图形)
* 点圆得分全用 g 原生绘制(/矩形/文字),不需任何 .jpg/.mp3, IMAGE_FILES 为空
* **声明模式的结构必须留全**这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键,
* render 里用 assets.images.<> 取图,装载/容错链条已就位,不必重搭
*
* 容错铁律(发行版红线 §7-5)
* 任一资产缺失/加载失败 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退),
* 绝不因资产把游戏带崩本款无资产 loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)
* node 环境无 Image 同样安全返回空集(import-safe)
* 音频文件不在此装载(需用户手势后解码, audio-director 懒加载;本款无音频)
*/
'use strict';
/**
* 图像资产键 文件名( assets/manifest.json 对应)
* 在此声明图片资产键文件名本款无美术故为空对象 {}
* 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)
* @type {Record<string, string>}
*/
export const IMAGE_FILES = {};
/**
* 装载全部图像资产(并行;单个失败不连坐)本款 IMAGE_FILES 为空 返回空集
* @param {string} [baseUrl] 资产根(缺省 './assets/')
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
const images = {};
/** @type {string[]} */
const missing = [];
/** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */
function loadOne(key) {
return new Promise((resolve) => {
// 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。
if (typeof Image === 'undefined') {
images[key] = null;
missing.push(key);
resolve(null);
return;
}
const img = new Image();
img.onload = () => {
images[key] = img;
resolve(img);
};
img.onerror = () => {
// 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。
console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]);
images[key] = null;
missing.push(key);
resolve(null);
};
img.src = root + IMAGE_FILES[key];
});
}
// 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。
return Promise.all(keys.map(loadOne)).then(() => ({
images,
missing,
loaded: keys.length - missing.length,
total: keys.length,
}));
}

View File

@ -0,0 +1,265 @@
/**
* core.js L3 纯逻辑解谜品类 · 轻脚手架的谜题生成与判定
*
* 这是规则匹配谜盘品类脚手架:每关一条线索(找出所有红色/星形/两颗),
* 盘面若干格子,点中所有符合线索的格子即过关,关卡逐关引入新规则维度,限时结算
* 本文件是它的**纯逻辑层**盘面生成规则挑选线索文案命中判定关卡曲线,
* 全是无副作用纯函数(随机经入参 rng Math.random/Date.now)
*
* 这一档怎么用这份骨架
* 谜盘循环的**结构**(出题观察点解过关下一关限时结算)已由 game-logic.js 接好,
* (AI)一般不动循环骨架;要把它做成 brief 要的那款解谜,改的是**下面的主题与参数**
* 这就是同品类多款不雷同的参数空间:换主题(宝石/水果/古文字/海洋)就换 THEME
* 元素/配色/文案,调难度就改 LEVELS 关卡曲线(盘面大小/解数/规则维度)与节奏常量
* 结构不变身份全变
*
*
* 格子字段(公开契约,render / game-logic / 取证共用):
* { id, row, col, x, y, size, shape, color, count, cleared }
* shape/color THEME 元素的 id;count = 格内元素个数(1..3);cleared = 已被点掉
*/
'use strict';
/*
* 多样性参数空间 · AI brief 改这些做出不雷同的解谜
* 下面整段都是玩法身份:换主题/调难度/换规则维度就改这里,谜盘循环结构不受影响
* */
/**
* 主题(玩法身份的核心多样性源):谜面元素长什么样叫什么
* 换主题就换这张表水果谜盘写苹果/香蕉/葡萄,古文字写篆//,海洋写鱼//
* shapes = 形状维度(id render 分支,name 进线索文案);colors 同理(fill 供程序化绘制)
* **shapes/colors 各至少留 3 **,否则找出所有 X的干扰项不够谜面一眼看穿
*/
export const THEME = {
title: '宝石谜坊',
tagline: '看线索,点中所有符合的宝石!',
shapes: [
{ id: 'circle', name: '圆形' },
{ id: 'diamond', name: '菱形' },
{ id: 'star', name: '星形' },
],
colors: [
{ id: 'ruby', name: '红色', fill: '#e85a6a' },
{ id: 'sapphire', name: '蓝色', fill: '#5aa7e8' },
{ id: 'emerald', name: '绿色', fill: '#66c46a' },
],
/** count 维度的线索用词(下标 = count-1):「找出有【两颗】的格子」。 */
countNames: ['一颗', '两颗', '三颗'],
};
/**
* 关卡曲线(解谜的解锁阶梯本体):每关 = 盘面大小 + 解数 + 本关可抽的规则维度
* 逐关引入新维度(颜色形状数量组合)= 品类要求的规则递进;调难度就改这张表
* kinds 可选值:'color' | 'shape' | 'count' | 'combo'(颜色+形状组合,最难)
*/
export const LEVELS = [
{ cols: 3, rows: 3, matches: 3, kinds: ['color'] }, // 示范关:一眼能解,教会规则
{ cols: 3, rows: 3, matches: 4, kinds: ['color', 'shape'] }, // 舒适关:同维度略加量
{ cols: 4, rows: 3, matches: 4, kinds: ['shape'] }, // 新维度:按形状找
{ cols: 4, rows: 3, matches: 4, kinds: ['count'] }, // 顿悟关:要数一数
{ cols: 4, rows: 4, matches: 5, kinds: ['combo'] }, // 组合关:两维同时满足
];
/** 一局总时长(ms)。限时解谜,到点结算;通全关即提前胜利。 */
export const ROUND_MS = 75000;
/** 每关出题后的观察窗口(ms):让玩家扫一眼盘面,窗口内点击不判(§3 节奏锚 0.81.5s)。 */
export const OBSERVE_MS = 800;
/** 过关庆祝停顿(ms):清盘 → 庆祝 → 下一关。 */
export const CLEAR_PAUSE_MS = 700;
/** 点错锁定(ms):轻惩罚——抖屏 + 断连击 + 短锁定,绝不倒扣大分/清零。 */
export const LOCK_MS = 350;
/** 卡壳软提示阈值(ms):这么久没正确操作 → 剩余解开始脉动提示(卡壳不出局,§2 节奏锚 812s)。 */
export const HINT_AFTER_MS = 9000;
/** 计分骨架:基分 × 连击倍率(2s 内连续正确,封顶 ×3)+ 过关奖励(随关数 ×1.15 上浮)。 */
export const SCORE_PER_HIT = 10;
export const COMBO_WINDOW_MS = 2000;
export const COMBO_MAX_MULT = 3;
export const LEVEL_BONUS_BASE = 30;
export const LEVEL_BONUS_GROWTH = 1.15;
/** 盘面几何(读 viewport 算像素,别硬编码 390×844):顶部留给线索横幅与 HUD。 */
export const BOARD_TOP_RATIO = 0.24;
export const BOARD_SIDE_MARGIN = 24;
export const TILE_GAP = 12;
/*
* 谜盘循环判定 · 结构层 · 一般不动
* 下面是盘面怎么生成线索怎么出点哪算对的纯算式换主题不必改这里
* */
/** 关卡总数(通关判定用)。 */
export function levelsTotal() { return LEVELS.length; }
/** 取某关配置(越界夹到最后一关——防 AI 改 LEVELS 后索引越界崩)。 */
export function levelConfig(levelIdx) {
const i = Math.max(0, Math.min(LEVELS.length - 1, levelIdx));
return LEVELS[i];
}
/** 过关奖励(随关数滚雪球:30 × 1.15^关序)。 */
export function levelBonus(levelIdx) {
return Math.round(LEVEL_BONUS_BASE * Math.pow(LEVEL_BONUS_GROWTH, Math.max(0, levelIdx)));
}
/** 受控随机取整 [0,n)。 */
function rngIndex(rng, n) { return Math.floor(rng() * n) % n; }
/** 从数组随机取一项(rng 经 boot.ctx,确定性可复现)。 */
function rngPick(rng, arr) { return arr[rngIndex(rng, arr.length)]; }
/**
* 挑本关规则:从该关 kinds 抽一个维度,再抽该维度的值
* @returns {{kind:string, color?:string, shape?:string, count?:number}}
* kind='color' {color:<id>};'shape' {shape:<id>};'count' {count:1..3};
* 'combo' {color,shape} 双约束
*/
export function pickRule(levelIdx, rng, theme) {
const t = theme || THEME;
const kind = rngPick(rng, levelConfig(levelIdx).kinds);
if (kind === 'shape') return { kind, shape: rngPick(rng, t.shapes).id };
if (kind === 'count') return { kind, count: 1 + rngIndex(rng, t.countNames.length) };
if (kind === 'combo') return { kind, color: rngPick(rng, t.colors).id, shape: rngPick(rng, t.shapes).id };
return { kind: 'color', color: rngPick(rng, t.colors).id }; // 缺省/未知维度回落颜色(最易读)
}
/** 格子是否符合规则(规则带哪个约束就比哪个;combo = 双约束全中)。 */
export function matchesRule(tile, rule) {
if (!tile || !rule) return false;
if (rule.color != null && tile.color !== rule.color) return false;
if (rule.shape != null && tile.shape !== rule.shape) return false;
if (rule.count != null && tile.count !== rule.count) return false;
return true;
}
/** 线索文案(横幅一行字;换主题时随 THEME 的 name/countNames 自动换)。 */
export function clueText(rule, theme) {
const t = theme || THEME;
const colorName = (id) => (t.colors.find((c) => c.id === id) || { name: '?' }).name;
const shapeName = (id) => (t.shapes.find((s) => s.id === id) || { name: '?' }).name;
if (rule.kind === 'shape') return '找出所有【' + shapeName(rule.shape) + '】';
if (rule.kind === 'count') return '找出有【' + (t.countNames[rule.count - 1] || rule.count) + '】的格子';
if (rule.kind === 'combo') return '找出【' + colorName(rule.color) + '·' + shapeName(rule.shape) + '】';
return '找出所有【' + colorName(rule.color) + '】';
}
/** 算盘面格子几何(读 viewport;格子取方形,含间距均分宽度,顶部从 BOARD_TOP_RATIO 起)。 */
export function computeTileGeometry(cols, rows, viewW, viewH) {
const innerW = viewW - BOARD_SIDE_MARGIN * 2 - TILE_GAP * (cols - 1);
const size = Math.floor(innerW / cols);
const top = Math.round(viewH * BOARD_TOP_RATIO);
const out = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
out.push({
row: r,
col: c,
size,
x: BOARD_SIDE_MARGIN + c * (size + TILE_GAP) + size / 2, // 中心点(取证 targets 用它)
y: top + r * (size + TILE_GAP) + size / 2,
});
}
}
return out;
}
/** 给一个格子随机三维属性(可能撞上规则,由 generateBoard 修正)。 */
function randomAttrs(rng, theme) {
return {
shape: rngPick(rng, theme.shapes).id,
color: rngPick(rng, theme.colors).id,
count: 1 + rngIndex(rng, theme.countNames.length),
};
}
/** 把「撞上规则」的属性组确定性地改开一个约束维度(取该维度列表的下一项,绝不无界重试)。 */
function forceMismatch(attrs, rule, theme) {
const next = (arr, id) => {
const i = arr.findIndex((e) => e.id === id);
return arr[(i + 1) % arr.length].id;
};
const out = { shape: attrs.shape, color: attrs.color, count: attrs.count };
if (rule.color != null) out.color = next(theme.colors, rule.color); // 改开颜色即必不中
else if (rule.shape != null) out.shape = next(theme.shapes, rule.shape); // 纯形状规则改开形状
else if (rule.count != null) out.count = (rule.count % theme.countNames.length) + 1; // 改开数量
return out;
}
/**
* 生成一关盘面(纯函数):恰好 matches 个格子符合规则其余全不符合保证全找到=过关可判
* 可解性质(改我之前先读):任何改动必须保住每关恰好 N 个解至少 1 个解干扰项绝不撞规则
* 0 解盘面 = 不可解关 = 永无 occupied 目标 = 九门 H (实测翻车点)换主题/维度优先只改 THEME/LEVELS
* @param {number} levelIdx 关序(0 )
* @param {()=>number} rng 受控随机源(boot.ctx.random)
* @param {number} viewW / viewH 视口
* @returns {{rule:object, clue:string, tiles:object[], matchTotal:number}}
*/
export function generateBoard(levelIdx, rng, viewW, viewH, theme) {
const t = theme || THEME;
const cfg = levelConfig(levelIdx);
const rule = pickRule(levelIdx, rng, t);
const geo = computeTileGeometry(cfg.cols, cfg.rows, viewW, viewH);
const total = geo.length;
const matchTotal = Math.max(1, Math.min(cfg.matches, total - 1)); // 至少 1 个解、至少留 1 个干扰
// 受控洗牌选出「符合规则」的格位(Fisher-Yates,随机全经 rng)。
const order = geo.map((_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = rngIndex(rng, i + 1);
const tmp = order[i]; order[i] = order[j]; order[j] = tmp;
}
const matchSet = new Set(order.slice(0, matchTotal));
const tiles = geo.map((pos, i) => {
let attrs = randomAttrs(rng, t);
if (matchSet.has(i)) {
// 符合格:约束维度定死为规则值,自由维度保留随机(同为解的格子看起来也不雷同)。
if (rule.color != null) attrs.color = rule.color;
if (rule.shape != null) attrs.shape = rule.shape;
if (rule.count != null) attrs.count = rule.count;
} else if (matchesRule(attrs, rule)) {
attrs = forceMismatch(attrs, rule, t); // 干扰格撞上规则 → 确定性改开一维
}
return {
id: i,
row: pos.row,
col: pos.col,
x: pos.x,
y: pos.y,
size: pos.size,
shape: attrs.shape,
color: attrs.color,
count: attrs.count,
cleared: false,
};
});
return { rule, clue: clueText(rule, t), tiles, matchTotal };
}
/** 盘面上还没点掉的解数(=0 即本关清盘)。 */
export function remainingMatches(tiles, rule) {
let n = 0;
for (const tile of tiles) if (!tile.cleared && matchesRule(tile, rule)) n += 1;
return n;
}
/** 点 (px,py) 命中哪个格子(方形命中区;没中返回 null)。 */
export function hitTile(px, py, tiles) {
for (const tile of tiles) {
const half = tile.size / 2;
if (px >= tile.x - half && px <= tile.x + half && py >= tile.y - half && py <= tile.y + half) return tile;
}
return null;
}
/** 本次正确命中的得分(基分 × 连击倍率,倍率 = min(连击数, 封顶))。 */
export function hitScore(streak) {
return SCORE_PER_HIT * Math.max(1, Math.min(COMBO_MAX_MULT, streak));
}

View File

@ -0,0 +1,336 @@
/**
* game-logic.js L3 游戏本体解谜品类 · 轻脚手架(规则匹配谜盘)
* owner:生成 agent(从本规则谜盘骨架起步改造) 消费方:game.js wrapper(L1)调本文件的 createGame
*
*
* 这份骨架已经替你接好了一整套规则谜盘循环你的活是换主题加深度不是重写
*
* 已接好(结构层 · 一般别动):
* · 每关出一条线索(找所有红色/星形/两颗)+ 程序化盘面(恰好 N 个解)
* · 观察窗口 点解:点对 = 加分(连击倍率)+ 粒子 + 音效 + 飘字;点错 = 抖屏 + 断连击 + 短锁定
* · 全部解找齐 = 过关庆祝 下一关(规则维度逐关递进);通全关提前胜利,限时到点结算
* · 卡壳 9s 剩余解脉动软提示(卡壳不出局)
* · _forensicsView targets/score 契约(自动验收靠它驱动你的游戏,**改坏它=九门 H 直接挂**)
*
* 你按 brief (身份层 · 多样性源,做出不雷同的解谜):
* · 换主题/元素/文案 core.js THEME(宝石水果/古文字/海洋)
* · 调难度/关卡阶梯 core.js LEVELS(盘面大小/解数/规则维度)与节奏常量
* · 换观感(底景/元素画法/配色) render.js
* · 想加机制(星级/图鉴/每日谜题/新规则维度) 在循环里加,**别破坏 targets/score 契约**
*
* 入参(L1 已摊平):createGame({ plugins, bundle, viewport });受控面 ctx init(boot) boot.ctx
* 返回 GameInstance:init/update/render/destroy + handleTap + _forensicsView
*
*/
'use strict';
// 纯逻辑(盘面生成/规则/命中/计分):零副作用,可 node 单测。换主题改 core.js 的 THEME/LEVELS 即可。
import {
ROUND_MS, OBSERVE_MS, CLEAR_PAUSE_MS, LOCK_MS, HINT_AFTER_MS, COMBO_WINDOW_MS,
generateBoard, remainingMatches, hitTile, matchesRule, hitScore, levelsTotal, levelBonus,
} from './core.js';
// 场景级绘制(画在 boot.mainContext);换观感改 render.js。
import { renderMenu, renderPlay, renderOver } from './render.js';
// 资产装载(本骨架无外部美术,容错返空集;要加美术放 assets/manifest.json 再读 boot.assets)。
import { loadAssets } from './assets.js';
/* ── 场景名(解谜三幕:出题 → 解谜 → 结算)── */
const SCENE_MENU = 'menu';
const SCENE_PLAY = 'play';
const SCENE_OVER = 'over';
/** 飘字("+20 ×2!")生命与上飘速度——纯表现,随意调。 */
const FLOAT_LIFE_S = 0.9;
const FLOAT_RISE_PXPS = 70;
export function createGame({ plugins, bundle, viewport }) {
// 解谜档用 6 件:场景机 / 计分 / UI / 计时(限时+节奏)/ 音效 / 粒子。要更多手感再加 gamefeel 等。
const { sceneFsm, sessionScore, hudUi, timerScheduler, audioMusic, juice } = plugins;
const VW = (viewport && viewport.w) || 390;
const VH = (viewport && viewport.h) || 844;
/* ── init(boot) 内填充 ── */
let boot = null;
let ctx = null;
let assets = null;
let inited = false;
/* ── 实例私有运行态 ── */
let board = null; // 当前关盘面 {rule, clue, tiles, matchTotal}(generateBoard 产)
let levelIdx = 0; // 当前关序(0 起)
let levelsCleared = 0; // 已通关数
let elapsedMs = 0; // play 内累计毫秒(进 play 时重置)
let stepState = 'observe'; // 关内小阶段:observe(观察窗)| solve(可点解)| clearing(过关庆祝)
let stepUntilMs = 0; // 当前小阶段的截止时刻(与 elapsedMs 同轴)
let lockedUntilMs = 0; // 错点锁定截止时刻(0=未锁)
let lockedTileId = null; // 被错点的格子 id(render 画红罩)
let streak = 0; // 当前连击数
let bestStreak = 0; // 本局最高连击
let lastHitAtMs = -1; // 上次正确命中时刻(连击窗判定)
let lastProgressAtMs = 0; // 上次正确操作时刻(卡壳提示判定)
let floats = []; // 得分飘字
let roundTimer = null; // 一局限时句柄
let won = false; // 终局是否通关胜利
/* ── 命中区(菜单/结算按钮):render 回调实时算并写回(所见即所点)── */
let menuStartBtn = null;
let overRestartBtn = null;
/* ── 取证度量 ── */
const measures = { frames: 0, taps: 0, hits: 0, wrongs: 0, levelsClearedTotal: 0 };
/** 受控随机 [0,1):经 boot.ctx.random,绝不 Math.random。 */
function rng() { return ctx ? ctx.random.next() : 0; }
/** 此刻是否可判点解(在 play、solve 小阶段、且不在错点锁定中)。 */
function canSolveNow() {
return sceneFsm.current() === SCENE_PLAY && stepState === 'solve' && elapsedMs >= lockedUntilMs;
}
/** 出下一关(或首关):生成盘面 + 进观察窗。 */
function dealLevel(idx) {
board = generateBoard(idx, rng, VW, VH);
stepState = 'observe';
stepUntilMs = elapsedMs + OBSERVE_MS;
lockedUntilMs = 0; lockedTileId = null;
lastProgressAtMs = elapsedMs;
if (ctx) ctx.log('level', '出题', { level: idx + 1, clue: board.clue, matches: board.matchTotal });
}
/** 进 play:重置整局 → 首关。 */
function enterPlay() {
sessionScore.reset();
levelIdx = 0; levelsCleared = 0; elapsedMs = 0;
streak = 0; bestStreak = 0; lastHitAtMs = -1;
floats = []; won = false;
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
roundTimer = timerScheduler.after(ROUND_MS, () => { // timerScheduler 收毫秒:75s 写 ROUND_MS=75000
sessionScore.lose(); // 限时到点没通全关 = 负,latch 终态
won = false;
sceneFsm.transition(SCENE_OVER);
if (ctx) ctx.log('scene', 'over 时间到', { score: sessionScore.getScore(), levelsCleared });
});
sceneFsm.transition(SCENE_PLAY);
dealLevel(0);
if (audioMusic) { try { audioMusic.playSfx('click'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('scene', 'play 开始解谜', { roundMs: ROUND_MS, levels: levelsTotal() });
}
/** 通全关:提前胜利结算(win latch)。 */
function finishWin() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
sessionScore.win();
won = true;
sceneFsm.transition(SCENE_OVER);
if (audioMusic) { try { audioMusic.playSfx('win'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('scene', 'over 通全关', { score: sessionScore.getScore(), levelsCleared });
}
/** 点对一格:加分(连击倍率)+ 反馈;清盘则过关。 */
function solveTile(tile) {
tile.cleared = true;
measures.hits += 1;
// 连击:窗口内连续正确 → streak+1,断窗归 1(纯用 elapsedMs 同轴判,受控确定性)。
streak = (lastHitAtMs >= 0 && elapsedMs - lastHitAtMs <= COMBO_WINDOW_MS) ? streak + 1 : 1;
lastHitAtMs = elapsedMs;
lastProgressAtMs = elapsedMs;
if (streak > bestStreak) bestStreak = streak;
const gained = hitScore(streak);
sessionScore.add(gained); // 计分随点解单调上升 → 满足 H 进展门
const mult = gained / hitScore(1);
floats.push({ x: tile.x, y: tile.y - tile.size * 0.5, age: 0, life: FLOAT_LIFE_S,
text: '+' + gained + (mult > 1 ? ' ×' + mult : ''), color: mult > 1 ? '#ffd34a' : '#f0c04b' });
if (juice) { try { juice.burst(tile.x, tile.y); } catch (_) { /* 容错 */ } } // 点对爆点
if (audioMusic) { try { audioMusic.playSfx('chime'); } catch (_) { /* 容错 */ } } // 点对「叮」
if (ctx) ctx.log('solve', '点对', { level: levelIdx + 1, tile: tile.id, gained, streak, score: sessionScore.getScore() });
if (remainingMatches(board.tiles, board.rule) === 0) { // 清盘 → 过关
levelsCleared += 1;
measures.levelsClearedTotal += 1;
const bonus = levelBonus(levelIdx);
sessionScore.add(bonus);
floats.push({ x: VW / 2, y: VH * 0.5, age: 0, life: FLOAT_LIFE_S, text: '过关 +' + bonus, color: '#7ed957' });
stepState = 'clearing';
stepUntilMs = elapsedMs + CLEAR_PAUSE_MS;
if (juice) { try { juice.flashScreen(0.25, 0.5); } catch (_) { /* 容错 */ } } // 过关闪白(dur 秒, peak alpha)
if (audioMusic) { try { audioMusic.playSfx('win'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('level', '过关', { level: levelIdx + 1, bonus, score: sessionScore.getScore() });
}
}
/** 点错一格:轻惩罚(断连击 + 短锁定 + 抖屏),绝不倒扣大分。 */
function wrongTile(tile) {
measures.wrongs += 1;
streak = 0;
lockedUntilMs = elapsedMs + LOCK_MS;
lockedTileId = tile ? tile.id : null;
if (juice) { try { juice.shakeScreen(0.18, 4); } catch (_) { /* 容错 */ } }
if (audioMusic) { try { audioMusic.playSfx('thud'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('wrong', '点错', { level: levelIdx + 1, tile: tile ? tile.id : null });
}
/** 【输入入口·L3 只写这个】L1 在每次 pointerdown 直达调用;各 phase 自行判定。 */
function handleTap(x, y) {
measures.taps += 1;
const cur = sceneFsm.current();
if (cur === SCENE_MENU) {
if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();
return;
}
if (cur === SCENE_OVER) {
if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay();
return;
}
if (cur === SCENE_PLAY) {
if (!canSolveNow() || !board) return; // 观察窗/庆祝/锁定中:点击不判(轻节奏约束)
const tile = hitTile(x, y, board.tiles);
if (!tile || tile.cleared) return; // 点空/点已清格:不奖不罚
if (matchesRule(tile, board.rule)) solveTile(tile);
else wrongTile(tile);
}
}
/** 飘字上飘 + 淡出。 */
function updateFloats(dt) {
for (let i = floats.length - 1; i >= 0; i--) {
const f = floats[i];
f.age += dt; f.y -= FLOAT_RISE_PXPS * dt;
if (f.age >= f.life) floats.splice(i, 1);
}
}
/** 卡壳软提示:太久没正确操作 → 剩余解的 id 集(render 画脉动描边);否则 null。 */
function hintIdsNow() {
if (stepState !== 'solve' || !board) return null;
if (elapsedMs - lastProgressAtMs < HINT_AFTER_MS) return null;
const ids = new Set();
for (const tile of board.tiles) if (!tile.cleared && matchesRule(tile, board.rule)) ids.add(tile.id);
return ids;
}
/** 注册三场景到 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; },
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
if (!board) return;
renderPlay(g, hudUi, {
vw: VW, vh: VH,
tiles: board.tiles, clue: board.clue, floats,
score: sessionScore.getScore(), levelIdx, total: levelsTotal(),
remainMs: Math.max(0, ROUND_MS - elapsedMs), roundMs: ROUND_MS,
phaseNote: stepState === 'observe' ? '观察一下…'
: (stepState === 'clearing' ? '第 ' + (levelIdx + 1) + ' 关完成!' : null),
lockedTileId: elapsedMs < lockedUntilMs ? lockedTileId : null,
hintIds: hintIdsNow(),
});
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } },
render(g) {
overRestartBtn = renderOver(g, hudUi, {
vw: VW, vh: VH, won,
score: sessionScore.getScore(), levelsCleared, bestStreak,
best: sessionScore.getBest(),
}).restartBtn;
},
});
}
/* ════════════ GameInstance 五法 ════════════ */
const instance = {
async init(b) {
boot = b;
ctx = b.ctx;
assets = await loadAssets('./assets/'); // 缺图不崩(容错返空集)
defineScenes();
sceneFsm.start(SCENE_MENU);
inited = true;
ctx.log('init', 'puzzle 就绪 menu', { vw: VW, vh: VH, levels: levelsTotal() });
},
update(dt) {
if (!inited) return;
measures.frames += 1;
if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); // ★ 没这步 timer 永不到期、一局不结束
sceneFsm.update(dt);
if (sceneFsm.current() === SCENE_PLAY) {
elapsedMs += dt * 1000; // dt 是秒,游戏内计时用毫秒 → ×1000(漏了=永不出观察窗)
if (stepState === 'observe' && elapsedMs >= stepUntilMs) {
stepState = 'solve'; // 观察窗结束 → 可点解
lastProgressAtMs = elapsedMs;
} else if (stepState === 'clearing' && elapsedMs >= stepUntilMs) {
const next = levelIdx + 1; // 庆祝结束 → 下一关或通关胜利
if (next >= levelsTotal()) finishWin();
else { levelIdx = next; dealLevel(next); }
}
updateFloats(dt);
}
},
render(g) {
if (!inited) return;
sceneFsm.render(g);
if (juice) { try { juice.render(g); } catch (_) { /* 容错 */ } } // 粒子每帧手调(juice 不自动上屏)
},
handleTap,
destroy() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
try { sceneFsm.reset(); } catch (_) { /* 幂等 */ }
inited = false;
},
/**
* 取证视图自动验收契约·改坏=九门挂:state() **实时**(绝不外层先算好闭包返回)
* · targets:每格一项,**occupied=true 只标当前该点的正确解**(solve 阶段未清符合本关规则非锁定)
* 解谜品类的取证面兼职"求解器",自动验收只点 occupied:true 的格驱动解谜推进
* · score:随点对/过关单调上升自动验收据它判真有进展
* 加机制换主题都别动这两个键的语义(干扰项/已清格/观察窗内一律 occupied:false)
*/
_forensicsView() {
return {
state: () => ({
phase: sceneFsm.current(),
score: sessionScore.getScore(),
best: sessionScore.getBest(),
outcome: sessionScore.getOutcome(),
remainMs: Math.max(0, ROUND_MS - elapsedMs),
level: levelIdx + 1,
levelsTotal: levelsTotal(),
levelsCleared,
clue: board ? board.clue : null,
matchesLeft: board ? remainingMatches(board.tiles, board.rule) : 0,
targets: board ? board.tiles.map((tile) => ({
id: tile.id,
x: tile.x,
y: tile.y,
occupied: canSolveNow() && !tile.cleared && matchesRule(tile, board.rule),
})) : [],
}),
measures: () => ({ ...measures }),
};
},
/** 直接命令入口(host.do(action);e2e 辅助:绕坐标确定性触发)。 */
_handleAction(action) {
if (!action || !inited) return;
const cur = sceneFsm.current();
if (action.type === 'start' && cur === SCENE_MENU) enterPlay();
else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay();
else if (action.type === 'solveFirst' && cur === SCENE_PLAY && board && canSolveNow()) {
const tile = board.tiles.find((t) => !t.cleared && matchesRule(t, board.rule));
if (tile) solveTile(tile);
}
},
};
return instance;
}

View File

@ -0,0 +1,73 @@
/**
* game.js L1 固定 plumbing · LLM 不写(writeFileTool )装载契约 GameHostFactory wrapper
* owner_template L1 固定骨架 消费方host-config.js(templateFactory)
*
*
* 职责 · 为何固定
* host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 game-logic.js
* "易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**,
* 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类
* 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool )
*
*/
'use strict';
// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。
import { createGame as createGameLogic } from './game-logic.js';
/**
* 装载契约 GameHostFactoryhost boot 调本工厂建 GameInstance
* 摊平 opts.runtime game-logic createGame({plugins,bundle,viewport});
* 接管输入handleTap 派发这一固定 plumbing(根治 B):init 后订阅受控输入,
* pointerdown 一律直达实例方法 handleTap(x,y)keydown 直达 handleKey(key)
* L3 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
* (不写 ctx.getInput不自管 pendingClicks 队列不在 update 里挑时机消费点击)
* 结构性根除点击在错的 phase 分支被消费 / 永不消费 游戏启动不了这类 M3 易犯错
* @param {Object} [opts] 工厂选项(host wiring)
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
*/
export default function gameFactory(opts) {
const runtime = (opts && opts.runtime) || {};
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
const game = createGameLogic({
plugins: runtime.plugins || {},
bundle: runtime.bundle,
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
});
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
let inputSubs = [];
game.init = async function init(boot) {
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
const ctx = boot && boot.ctx;
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
if (typeof game.handleTap === 'function') {
inputSubs.push(input.on('pointerdown', (e) => {
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
}));
}
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
if (typeof game.handleKey === 'function') {
inputSubs.push(input.on('keydown', (e) => {
try { game.handleKey((e && e.key) || ''); }
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
}));
}
};
game.destroy = function destroy() {
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
inputSubs = [];
if (innerDestroy) innerDestroy();
};
return game;
}

View File

@ -0,0 +1,110 @@
/**
* host-config.js L1 固定 plumbing · LLM 不写(writeFileTool )宿主装配:实例化全标准能力插件集 + 视口 + 工厂 wiring
* owner_template L1 固定骨架 消费方main.js(spike)/ entry-bundle.js(SAA) buildTemplateHostConfig 装配 bootGameHost
*
*
* 本文件做什么 · 为何固定
* 通用宿主 bootGameHost(opts) 需要四组装配:插件实例集 / 注册偏序 / 视口 / 工厂 wiring
* 本文件把"这游戏怎么配 bootGameHost"固化为**全游戏逐字相同**的单一来源
* 3 层架构(2026-06-22 边界设计),host-config **L1 工程骨架**:LLM 不碰(writeFileTool 拒写),
* 根除 M3 反复搞坏插件 wiring 的错类(viewport 漏键=reading 'w' / registerOrder / 取插件嵌套)
*
* 全标准能力插件集(D2 全注入,创始人裁 D9=A)
* 编排 4:sceneFsm/sessionScore/hudUi/timerScheduler · 持久:save · 手感/特效:gamefeel/juice/palettePost
* · 音频:audioMusic · 物理:collision/physics 11 件全摆上桌,L3 按需取用(用到才解构)不用的不解构即可
* 默认 config generic-host-config 同口径(能力库默认面,非玩法); node-import-safe(碰受控资源只在 init/runtime )
*
* Q4 铁律本文件零引擎 import(仅装配插件实例 + 视口 + wiring);引擎由 bootGameHost opts.engine 注入node import-safe
*
*/
'use strict';
// 编排 4 件(menu→play→over + 计分 + UI 原语 + 受控定时)。
import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js';
import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js';
import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js';
import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js';
// 持久(最佳分等):优先 localStorage,不可用退内存。
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
// 手感/特效/后处理(打击感 / 粒子 / 暗角)。
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
// 音频(audio ctx 内部经 ctx.getAudioContext 懒接,L3 只调 play;无声卡 no-op)。
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
// 物理/碰撞(网格碰撞 + 轻物理)。
import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js';
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
// 装载契约 game 工厂(game.js 默认导出 = 薄 wrapper,L1 固定;实际玩法在 game-logic.js,L3 写)。
import createGame from './game.js';
/** 本款游戏工厂(GameHostFactory)。 */
export const templateFactory = createGame;
/** 逻辑视口(390×844 移动竖屏基准)。**L1 固定硬编码**:不从 render.js 导入,以免 LLM 改 render 时漏 W/H 致 viewport 失值 → boot 崩 reading 'w'。 */
export const templateViewport = { w: 390, h: 844 };
/**
* 装配调 bootGameHost 所需的全部入参( 11 插件实例集 / 注册偏序 / 视口 / 工厂 wiring)
* main.js / entry-bundle.js canvas/statusEl/engine/seed/mode/engineMode/onReady 后传 bootGameHost
* @param {{seed:number}} args
* @returns {{factory:Function, plugins:object, registerOrder:Array<object>, viewport:{w:number,h:number}, buildFactoryOpts:Function}}
*/
export function buildTemplateHostConfig(args) {
const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0;
/* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */
let saveAdapter;
try {
saveAdapter = createLocalStorageAdapter();
} catch (e) {
console.warn('[_template] localStorage 不可用,存档退内存 adapter:' + (e && e.message));
saveAdapter = createMemoryAdapter();
}
const save = createSaveProgressPlugin({ namespace: 'zaomeng-template', adapter: saveAdapter });
/* ── 编排 4(host 拥有,L3 经 plugins.<键> 用)── */
const sceneFsm = createSceneFsmPlugin();
// session-score 的 best 接 save:跨局保留最佳分(load/save 经 KV 面,失败容错降级)。
const sessionScore = createSessionScorePlugin({
best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) },
});
const hudUi = createHudUiPlugin();
const timerScheduler = createTimerSchedulerPlugin();
/* ── 能力库 6(默认 config 同 generic-host-config 口径;seed 给粒子确定性)── */
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
const collision = createCollisionPlugin({ cellSize: 96 });
const physics = createPhysicsLitePlugin();
// 工厂注入键名(canonical;与 L3 game-logic.js 内 plugins.<键> 解构一致;skill 逐字列)。
const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics };
// 注册偏序(M1:全 11 合集显式定义):持久+编排先(save 先于 sessionScore 的 best 钩子)→ 基元(collision/physics/gamefeel/juice/palettePost)→ audioMusic 末(同 generic 偏序惯例,audio 依赖最少、放末)。
const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic];
return {
factory: templateFactory,
plugins,
registerOrder,
viewport: templateViewport,
/**
* 构造工厂 opts(host wiring): runtime 把全插件集 + bundle + 真实 AudioContext 取法暴露给游戏工厂
* game.js wrapper(L1)吃掉 opts.runtime.{plugins,bundle} 两层解构,把扁平件 + viewport L3 createGame
* @param {import('../../../src/core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用)
* @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄
*/
buildFactoryOpts: (_bootCtx, hostInternals) => ({
runtime: {
plugins: hostInternals.plugins,
bundle: hostInternals.bundle,
getRealAudioCtx: hostInternals.getRealAudioCtx,
viewport: templateViewport, // D3:viewport 下传,L3 读 viewport.w/h 不硬编码
},
}),
};
}

View File

@ -0,0 +1,132 @@
/**
* main.js 点圆得分宿主引导(薄包装层:把通用宿主 bootGameHost 配成本款 bootGame)
* owner_template 克隆起点 消费方entry.js(打包入口) index.html
*
* 职责(精简自 wanglanmei main.js)
* 解析 URL 参数(seed/mode/engine) 装配本款专属项(取自 host-config) 调通用 bootGameHost
* onReady 钩子里挂取证全局(window.__gameHost / __gameForensics), headless-boot e2e CDP 驱动
*
* 双通道(沿用通用宿主)
* - ?engine=stub(**缺省**,回滚锚):浏览器 RAF + 单一墙钟,node headless 走此(无引擎注入);
* - ?engine=real(显式开):引擎掌帧(浏览器 + entry 包装层注入引擎名字空间)
*
* 红线遵守
* - 引擎 import 只活 entry.js;main.js(host ) opts.engine 注入用引擎不直接 import( node import-safe);
* - game.js 游戏工厂零引擎 import
*/
'use strict';
// 通用宿主:引擎五回调骨架 + 受控面背书 + 输入接线全在此(参数化游戏工厂/插件/视口)。
import { bootGameHost } from '../../../src/host/boot-game-host.js';
// 本款宿主装配配置(工厂/4 插件/视口/工厂 wiring)——bootGame 与打包入口共用同一份(防第二套)。
import { buildTemplateHostConfig } from './host-config.js';
// 协议号(取证导出用)。
import { CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
/** 读 URL 参数(node 桩 window.location.search 亦可读)。 */
function getParam(name, def) {
try {
const v = new URLSearchParams(window.location.search).get(name);
return v == null ? def : v;
} catch {
return def;
}
}
/** 解析种子(缺省固定值,保证「不传也确定」)。 */
function resolveSeed() {
const raw = getParam('seed', null);
if (raw == null) return 0x1234abcd >>> 0;
const n = Number(raw);
return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0;
}
/**
* 启动本款游戏(host)退化为 bootGameHost 的薄包装:装配本款专属参数 + 组装取证全局
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} opts
* - engine:引擎名字空间(littlejsengine), entry.js 包装层经 opts 注入(main.js 不顶层 import 引擎, node-safe)
* ?engine=real 通道用;stub/node 通道不触达(缺省 undefined)
* @returns {Promise<object>} gameHost 句柄(同时挂 window.__gameHost)
*/
export async function bootGame(opts) {
const canvas = opts.canvas;
const statusEl = opts.statusEl || null;
const seed = resolveSeed();
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
// 引擎通道:缺省 stub(回滚锚 + node headless 走此);真玩取证显式带 &engine=real。
const engineMode = getParam('engine', 'stub');
// 本款宿主装配(工厂/4 插件/注册偏序/视口/工厂 wiring):取自共用配置(与打包入口同源)。
const cfg = buildTemplateHostConfig({ seed });
/** 取证内部件(onReady 注入:游戏取证视图等)。 */
let gameForensics = null;
/** @type {object|null} bootGameHost 返回的通用 host 句柄。 */
let coreHost = null;
/** 本款取证导出(读 onReady 注入的内部件)。 */
const forensics = {
protocol: CORE_PROTOCOL_VERSION,
seed,
mode,
/** 游戏状态快照(JSON 安全):phase/score/best/remainMs/target/...。 */
state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }),
/** 运行度量:frames/taps/hits。 */
measures: () => (gameForensics ? gameForensics.measures() : {}),
};
/** 驱动句柄(evidence 驱动 / e2e):在通用 host 句柄上扩展本款取证面。延迟读 coreHost(onReady 时已赋值)。 */
const gameHost = {
seed,
mode,
engineMode,
protocol: CORE_PROTOCOL_VERSION,
frameCount: () => (coreHost ? coreHost.frameCount() : 0),
stepFrames: (n) => coreHost && coreHost.stepFrames(n),
startRaf: () => coreHost && coreHost.startRaf(),
stopRaf: () => coreHost && coreHost.stopRaf(),
/** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */
tap: (x, y) => coreHost && coreHost.tap(x, y),
/** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API):'start'/'hitTarget'/'restart'。 */
do: (action) => coreHost && coreHost.do(action),
state: () => forensics.state(),
forensics,
};
/** 状态行(仅人看;通用宿主已设过一轮,这里仅错误兜底用)。 */
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
try {
// 调通用宿主:把本款专属项经 opts 注入(取自共用配置 cfg),引擎骨架复用 bootGameHost。
coreHost = await bootGameHost({
canvas,
statusEl,
engine: opts.engine, // entry 包装层注入的引擎名字空间(real 用;stub/node 为 undefined)
seed,
mode,
engineMode,
viewport: cfg.viewport, // 本款视口(390×844,共用配置)
factory: cfg.factory, // 游戏工厂来源
plugins: cfg.plugins, // 本款 4 件插件实例集(+save)
registerOrder: cfg.registerOrder, // 注册偏序
buildFactoryOpts: cfg.buildFactoryOpts, // host wiring(runtime:{plugins,bundle})
/** 就绪钩子:受控面/游戏全就绪后,接进取证对象 + 挂取证全局。 */
onReady: (host, internals) => {
gameForensics = internals.getGameForensics();
// 挂取证全局(headless-boot 与 e2e CDP 驱动据此读状态 / 驱动)。
window.__gameForensics = forensics;
window.__gameHost = gameHost;
},
});
} catch (e) {
// 引擎接管/装载失败:镜像错因,重抛供上层捕获。
window.__gameBootError = window.__gameHostBootError || String((e && e.message) || e);
setStatus('BOOT ERROR: ' + window.__gameBootError + '(可加 ?engine=stub 回滚桩)');
throw e;
}
return gameHost;
}

View File

@ -0,0 +1,209 @@
/**
* render.js L3 表现层解谜品类 · 轻脚手架的绘制
* 一律画在入参 g(= boot.mainContext)本骨架全程序化绘制(无外部美术)
*
* 这是观感层:做不雷同的解谜,这里随便改
* 换配色(下面 C)换谜面元素长相(drawGlyph:// 苹果/贝壳/古字)换底景
* (drawBackdrop)都只影响卖相不碰谜盘循环想加美术:图放 assets/manifest.json,
* init 里读 boot.assets,缺图回退程序化
* 谜面可读性 > 一切装饰:格子四态(可点/锁定/点对/干扰)的视觉语言保持统一
* HUD 与按钮经 hud-ui;**drawButton 返回命中矩形,直接接住当命中区**(别再自己另算)
*
*/
'use strict';
import { THEME } from './core.js';
/* ── 配色(玩法身份:换主题就换这套色)── */
const C = {
bgTop: '#1d2340',
bgBottom: '#2a3358',
banner: '#f0c04b',
bannerText: '#241a05',
tile: '#39426b',
tileEdge: 'rgba(255,255,255,0.25)',
tileCleared: 'rgba(57,66,107,0.35)',
panel: 'rgba(255,255,255,0.08)',
stroke: 'rgba(255,255,255,0.22)',
text: '#f4f0ff',
textDim: '#a9a7c9',
accent: '#f0c04b',
good: '#7ed957',
bad: '#e85a4f',
};
/* ────────── 公共绘制零件(观感层,随意改)────────── */
/** 铺底:夜空渐变 + 点缀星光(解谜品类默认「夜空霓虹」调性)。 */
function drawBackdrop(g, VW, VH) {
const grad = g.createLinearGradient(0, 0, 0, VH);
grad.addColorStop(0, C.bgTop);
grad.addColorStop(1, C.bgBottom);
g.fillStyle = grad;
g.fillRect(0, 0, VW, VH);
// 固定点缀(确定性,不用随机:观感层别引 rng)
g.fillStyle = 'rgba(255,255,255,0.18)';
for (let i = 0; i < 18; i++) {
const sx = (i * 73) % VW;
const sy = (i * 131) % Math.round(VH * 0.5);
g.fillRect(sx, sy, 2, 2);
}
}
/** 画一个谜面元素(shape id → 图形;换主题把这里换成苹果/贝壳/古字…)。 */
function drawGlyph(g, shapeId, cx, cy, r, fill) {
g.fillStyle = fill;
g.strokeStyle = 'rgba(0,0,0,0.35)';
g.lineWidth = 2;
if (shapeId === 'diamond') {
g.beginPath();
g.moveTo(cx, cy - r); g.lineTo(cx + r, cy); g.lineTo(cx, cy + r); g.lineTo(cx - r, cy);
g.closePath(); g.fill(); g.stroke();
} else if (shapeId === 'star') {
g.beginPath();
for (let i = 0; i < 10; i++) {
const ang = -Math.PI / 2 + (i * Math.PI) / 5;
const rr = i % 2 === 0 ? r : r * 0.45;
const px = cx + Math.cos(ang) * rr;
const py = cy + Math.sin(ang) * rr;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.closePath(); g.fill(); g.stroke();
} else { // circle(缺省)
g.beginPath(); g.arc(cx, cy, r, 0, Math.PI * 2); g.fill(); g.stroke();
}
}
/** count 个元素在格内的摆位(1=居中 / 2=并排 / 3=品字)。 */
function glyphLayout(count, size) {
const off = size * 0.22;
if (count === 2) return [{ dx: -off, dy: 0 }, { dx: off, dy: 0 }];
if (count === 3) return [{ dx: 0, dy: -off }, { dx: -off, dy: off }, { dx: off, dy: off }];
return [{ dx: 0, dy: 0 }];
}
/**
* 一个谜面格子(底板 + count 个元素 + 状态视觉):
* cleared=已点对(变暗打勾)· locked=错点锁定中(红闪)· hinting=卡壳软提示(脉动描边)
*/
function drawTile(g, tile, theme, opts) {
const half = tile.size / 2;
const x = tile.x - half, y = tile.y - half;
const colorDef = theme.colors.find((c) => c.id === tile.color) || { fill: '#999' };
g.fillStyle = tile.cleared ? C.tileCleared : C.tile;
g.beginPath();
if (g.roundRect) g.roundRect(x, y, tile.size, tile.size, 12); else g.rect(x, y, tile.size, tile.size);
g.fill();
g.strokeStyle = opts && opts.hinting ? C.accent : C.tileEdge;
g.lineWidth = opts && opts.hinting ? 3 : 2;
g.stroke();
if (tile.cleared) {
// 已点对:打勾,格子退场
g.strokeStyle = C.good; g.lineWidth = 5;
g.beginPath();
g.moveTo(tile.x - half * 0.4, tile.y);
g.lineTo(tile.x - half * 0.1, tile.y + half * 0.32);
g.lineTo(tile.x + half * 0.45, tile.y - half * 0.3);
g.stroke();
return;
}
const r = tile.size * 0.16;
for (const p of glyphLayout(tile.count, tile.size)) {
drawGlyph(g, tile.shape, tile.x + p.dx, tile.y + p.dy, r, colorDef.fill);
}
if (opts && opts.locked) { // 错点锁定的红罩(轻惩罚可视化)
g.fillStyle = 'rgba(232,90,79,0.25)';
g.beginPath();
if (g.roundRect) g.roundRect(x, y, tile.size, tile.size, 12); else g.rect(x, y, tile.size, tile.size);
g.fill();
}
}
/** 线索横幅(谜面核心 UI:一行大字告诉玩家找什么)。 */
function drawClueBanner(g, hud, clue, VW, VH) {
const bh = 52;
const by = Math.round(VH * 0.24) - bh - 18;
hud.drawPanel(g, { x: 16, y: by, w: VW - 32, h: bh }, { fill: C.banner, stroke: 'rgba(0,0,0,0.35)', lineWidth: 2, radius: 14 });
hud.drawText(g, clue, VW / 2, by + bh / 2, { font: '800 22px sans-serif', color: C.bannerText, align: 'center', baseline: 'middle' });
}
/** 顶部 HUD:关卡进度 + 分数 + 剩时条(经 hud-ui;drawBar 是通用比例条)。 */
function drawHud(g, hud, info) {
hud.drawPanel(g, { x: 8, y: 12, w: info.vw - 16, h: 56 }, { fill: C.panel, stroke: C.stroke, lineWidth: 2, radius: 12 });
hud.drawText(g, '第 ' + (info.levelIdx + 1) + '/' + info.levelsTotal + ' 关', 22, 40, { font: '700 18px sans-serif', color: C.text, align: 'left', baseline: 'middle' });
hud.drawText(g, String(info.score), info.vw / 2, 40, { font: '800 24px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
const ratio = Math.max(0, Math.min(1, info.remainMs / info.roundMs));
hud.drawBar(g, { x: info.vw - 118, y: 32, w: 96, h: 14 }, ratio, { fill: ratio < 0.25 ? C.bad : C.good, bg: 'rgba(0,0,0,0.5)', radius: 6 });
}
/** 连击/得分飘字("+20 ×2!" 上飘淡出)。 */
function drawFloats(g, floats) {
for (const f of floats) {
const alpha = Math.max(0, 1 - f.age / f.life);
if (alpha <= 0) continue;
g.save(); g.globalAlpha = alpha;
g.font = '700 24px sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = f.color || C.accent; g.strokeStyle = 'rgba(0,0,0,0.6)'; g.lineWidth = 4;
g.strokeText(f.text, f.x, f.y); g.fillText(f.text, f.x, f.y);
g.restore();
}
}
/* ────────── 场景绘制入口(导出给 game-logic 的 defineScenes 用)────────── */
/**
* menu:标题 + 玩法一句话 + 示例元素 + 开始按钮换主题在 THEME/这里改文案
* @returns {{startBtn:{x,y,w,h}}} 命中区(drawButton 返回的归一化矩形)
*/
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
hud.drawText(g, THEME.title, view.vw / 2, view.vh * 0.28, { font: '800 46px sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, THEME.tagline, view.vw / 2, view.vh * 0.28 + 56, { font: '15px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
// 三个示例元素当门面(也是零阅读教学:告诉玩家盘面长这样)
const cy = view.vh * 0.42;
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 },
{ label: '开始解谜', font: '700 22px sans-serif', fill: C.accent, textColor: C.bannerText, radius: 30 });
return { startBtn };
}
/** play:背景 + 线索横幅 + 谜盘 + 飘字 + HUD + 状态提示(观察/过关瞬间)。 */
export function renderPlay(g, hud, view) {
const { vw, vh, tiles, clue, floats, score, levelIdx, total, remainMs, roundMs, phaseNote, lockedTileId, hintIds } = view;
drawBackdrop(g, vw, vh);
drawClueBanner(g, hud, clue, vw, vh);
for (const tile of tiles) {
drawTile(g, tile, THEME, {
locked: lockedTileId === tile.id,
hinting: !!(hintIds && hintIds.has && hintIds.has(tile.id)),
});
}
drawFloats(g, floats);
drawHud(g, hud, { vw, vh, score, levelIdx, levelsTotal: total, remainMs, roundMs });
if (phaseNote) { // 观察窗口/过关庆祝的一行提示
hud.drawText(g, phaseNote, vw / 2, vh * 0.86, { font: '700 20px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
}
}
/**
* over:结算即炫耀时刻大分数 + 通过关数 + 最高连击 + 最佳纪录 + 再来按钮
* @returns {{restartBtn:{x,y,w,h}}}
*/
export function renderOver(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
g.fillStyle = 'rgba(10,12,28,0.55)'; g.fillRect(0, 0, view.vw, view.vh);
hud.drawText(g, view.won ? '全部解开!' : '时间到', view.vw / 2, view.vh * 0.2, { font: '800 34px sans-serif', color: view.won ? C.good : C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, String(view.score), view.vw / 2, view.vh * 0.31, { font: '800 58px sans-serif', color: C.accent, align: 'center', baseline: 'middle' });
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 },
{ label: '再解一局', font: '700 22px sans-serif', fill: C.accent, textColor: C.bannerText, radius: 30 });
return { restartBtn };
}

View File

@ -0,0 +1,126 @@
/**
* core.test.mjs 解谜品类骨架 · 纯逻辑单测
*
* core.js 脱离引擎/插件/I-O:盘面生成规则挑选线索文案命中判定关卡曲线全是纯函数
* (随机经入参 rng,确定性)故可 node 直接断言(发行版core 保持纯净 node 单测红线)
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
THEME, LEVELS, SCORE_PER_HIT, COMBO_MAX_MULT, LEVEL_BONUS_BASE,
levelsTotal, levelConfig, levelBonus, pickRule, matchesRule, clueText,
computeTileGeometry, generateBoard, remainingMatches, hitTile, hitScore,
} from '../src/core.js';
/** 确定性 rng(从序列依次取,耗尽循环)。 */
function seqRng(values) { let i = 0; return () => values[(i++) % values.length]; }
/** 线性同余伪随机(种子确定性,遍历性好——盘面生成的属性组合覆盖用)。 */
function lcgRng(seed) {
let s = seed >>> 0;
return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
test('computeTileGeometry:格数 = cols×rows,中心点都在视口内,尺寸为正', () => {
for (const { cols, rows } of LEVELS) {
const geo = computeTileGeometry(cols, rows, 390, 844);
assert.equal(geo.length, cols * rows, `应产 ${cols}×${rows} 个格`);
for (const t of geo) {
assert.ok(t.size > 0, `格子尺寸应为正:${t.size}`);
assert.ok(t.x - t.size / 2 >= 0 && t.x + t.size / 2 <= 390, `格心 x 越界:${t.x}(size=${t.size})`);
assert.ok(t.y - t.size / 2 >= 0 && t.y + t.size / 2 <= 844, `格心 y 越界:${t.y}(size=${t.size})`);
}
}
});
test('pickRule:维度取自该关 kinds,值合法', () => {
const rng = lcgRng(7);
for (let lv = 0; lv < levelsTotal(); lv++) {
for (let i = 0; i < 20; i++) {
const rule = pickRule(lv, rng);
assert.ok(levelConfig(lv).kinds.includes(rule.kind), `${lv + 1} 关抽出的维度 ${rule.kind} 应在该关 kinds 内`);
if (rule.color != null) assert.ok(THEME.colors.some((c) => c.id === rule.color), '颜色值应在 THEME.colors');
if (rule.shape != null) assert.ok(THEME.shapes.some((s) => s.id === rule.shape), '形状值应在 THEME.shapes');
if (rule.count != null) assert.ok(rule.count >= 1 && rule.count <= THEME.countNames.length, 'count 应在 1..countNames');
}
}
});
test('generateBoard:恰好 matchTotal 个格符合规则、其余全不符合(全找到=过关可判)', () => {
// 多种子 × 全关卡的性质测试:解数精确、干扰项零撞规则——这是「点完所有解=清盘」判定的根基。
for (const seed of [1, 42, 777, 20260702]) {
const rng = lcgRng(seed);
for (let lv = 0; lv < levelsTotal(); lv++) {
const b = generateBoard(lv, rng, 390, 844);
const cfg = levelConfig(lv);
const matching = b.tiles.filter((t) => matchesRule(t, b.rule)).length;
assert.equal(matching, b.matchTotal, `seed=${seed}${lv + 1} 关:符合格数 ${matching} 应恰为 matchTotal=${b.matchTotal}`);
assert.ok(b.matchTotal >= 1, '至少 1 个解');
assert.ok(b.matchTotal <= cfg.cols * cfg.rows - 1, '至少留 1 个干扰');
assert.equal(remainingMatches(b.tiles, b.rule), b.matchTotal, '初始剩余解 = matchTotal');
assert.ok(typeof b.clue === 'string' && b.clue.includes('找出'), '线索文案应生成');
}
}
});
test('clueText:文案含主题元素名(换主题自动跟随)', () => {
assert.ok(clueText({ kind: 'color', color: THEME.colors[0].id }).includes(THEME.colors[0].name));
assert.ok(clueText({ kind: 'shape', shape: THEME.shapes[2].id }).includes(THEME.shapes[2].name));
assert.ok(clueText({ kind: 'count', count: 2 }).includes(THEME.countNames[1]));
const combo = clueText({ kind: 'combo', color: THEME.colors[1].id, shape: THEME.shapes[0].id });
assert.ok(combo.includes(THEME.colors[1].name) && combo.includes(THEME.shapes[0].name), 'combo 线索应含两维名');
});
test('remainingMatches:点掉解才递减,点掉干扰不动', () => {
const rng = lcgRng(99);
const b = generateBoard(0, rng, 390, 844);
const before = remainingMatches(b.tiles, b.rule);
const distractor = b.tiles.find((t) => !matchesRule(t, b.rule));
distractor.cleared = true; // (纯数据操作,模拟误点不清解——游戏里干扰格点错并不 cleared,这里只验计数语义)
assert.equal(remainingMatches(b.tiles, b.rule), before, '清掉干扰格不影响剩余解数');
const match = b.tiles.find((t) => matchesRule(t, b.rule));
match.cleared = true;
assert.equal(remainingMatches(b.tiles, b.rule), before - 1, '清掉解格剩余解数 -1');
});
test('hitTile:方形命中区(中心命中、边界外不中)', () => {
const tiles = [{ id: 0, x: 100, y: 200, size: 80, cleared: false }];
assert.equal(hitTile(100, 200, tiles), tiles[0], '中心应命中');
assert.equal(hitTile(139, 239, tiles), tiles[0], '半径内角落应命中');
assert.equal(hitTile(141, 200, tiles), null, '出右界不命中');
assert.equal(hitTile(100, 241, tiles), null, '出下界不命中');
});
test('hitScore:连击倍率 ×1/×2/×3 封顶', () => {
assert.equal(hitScore(1), SCORE_PER_HIT);
assert.equal(hitScore(2), SCORE_PER_HIT * 2);
assert.equal(hitScore(3), SCORE_PER_HIT * COMBO_MAX_MULT);
assert.equal(hitScore(9), SCORE_PER_HIT * COMBO_MAX_MULT, '超封顶仍按封顶算');
});
test('levelBonus:过关奖励随关数滚雪球(单调不减,首关=基数)', () => {
assert.equal(levelBonus(0), LEVEL_BONUS_BASE);
for (let i = 1; i < levelsTotal(); i++) {
assert.ok(levelBonus(i) >= levelBonus(i - 1), `${i + 1} 关奖励应 ≥ 上一关`);
}
});
test('levelConfig:索引越界夹到末关(防 AI 改 LEVELS 后崩)', () => {
assert.equal(levelConfig(-3), LEVELS[0]);
assert.equal(levelConfig(999), LEVELS[LEVELS.length - 1]);
});
test('确定性:同 rng 序列 → 同盘面(受控随机可复现)', () => {
const a = generateBoard(2, seqRng([0.1, 0.5, 0.9, 0.3, 0.7]), 390, 844);
const b = generateBoard(2, seqRng([0.1, 0.5, 0.9, 0.3, 0.7]), 390, 844);
assert.deepEqual(
a.tiles.map((t) => [t.shape, t.color, t.count]),
b.tiles.map((t) => [t.shape, t.color, t.count]),
'同随机序列应产逐格一致的盘面',
);
assert.deepEqual(a.rule, b.rule, '规则也应一致');
});

View File

@ -0,0 +1,198 @@
/**
* headless-boot.test.mjs 解谜骨架 · 无浏览器全链路冒烟(范式照 _template-shop/headless-boot)
*
* Proxy 2D 上下文 + window 真跑 bootGame:
* boot(evidence) menu 起局 观察窗(点解被挡) 逐格点解(分数递增) 清盘过关 逐关通到 win
* 断言 outcome=won;另测限时到点 lose + seed 确定性 + targets 取证契约(occupied 只标解)
* 整条链路走真实 _handleAction/handleTap 路由 / 真实 scene-fsm 派发 / 真实 timer-scheduler 受控到期
* + 每帧 render(g) 全画,任何宿主层运行时错误都会在此抛出像素正确性留集成段浏览器证据(两层测试纪律)
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { OBSERVE_MS, CLEAR_PAUSE_MS, ROUND_MS, levelsTotal } from '../src/core.js';
/** 建一个「接受一切调用」的伪 2D 上下文(特定方法给出形状正确的返回值)。 */
function makeFake2d() {
const grad = { addColorStop() {} };
const base = {
canvas: { width: 780, height: 1688 },
createLinearGradient: () => grad,
createRadialGradient: () => grad,
getImageData: () => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 }),
putImageData() {},
measureText: () => ({ width: 0 }),
};
const fnCache = new Map();
return new Proxy(base, {
get(target, prop) {
if (prop in target) return target[prop];
if (!fnCache.has(prop)) fnCache.set(prop, () => {});
return fnCache.get(prop);
},
set(target, prop, v) { target[prop] = v; return true; },
});
}
/** 装好 window/canvas 桩并 boot。@param {string} search URL 参数 */
async function bootHeadless(search) {
globalThis.window = {
location: { search },
addEventListener() {},
requestAnimationFrame() { return 0; },
cancelAnimationFrame() {},
};
const fake2d = makeFake2d();
const canvas = {
width: 0,
height: 0,
style: {},
getContext: () => fake2d,
addEventListener() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 390, height: 844 }),
};
const { bootGame } = await import('../src/main.js');
const host = await bootGame({ canvas });
return host;
}
/** 按毫秒换算需驱动的帧数(FIXED_DT≈16.67ms),多给 25% 余量。 */
function framesFor(ms) { return Math.ceil((ms / 16.67) * 1.25) + 4; }
/** 逐格点解当前关(用 do('solveFirst') 走真实 solveTile 路),清盘即停;带步数护栏。 */
function clearCurrentLevel(host, F) {
let guard = 0;
while (F.state().matchesLeft > 0 && guard++ < 30) {
host.do({ type: 'solveFirst' });
host.stepFrames(2);
}
assert.ok(guard < 30, `清盘不应超护栏(guard=${guard})`);
}
test('无浏览器全链路:boot→起局→观察窗挡点→点解加分→逐关通到 win(真路由 + 受控定时)', async () => {
const host = await bootHeadless('?mode=evidence&seed=99');
const F = globalThis.window.__gameForensics;
assert.ok(F, '取证全局 __gameForensics 应已挂载');
assert.equal(host.mode, 'evidence');
// ① 初始态:menu;targets 键在场(自动验收据键存在性判 tap-targets 族)且为空数组。
assert.equal(F.state().phase, 'menu', 'boot 后应在 menu 场景');
assert.deepEqual(F.state().targets, [], 'menu 期 targets 应为空数组(键必须在)');
// ② menu → play:do('start') 与点开始按钮同走 enterPlay。
host.do({ type: 'start' });
host.stepFrames(2);
assert.equal(F.state().phase, 'play', '起局后应进 play');
assert.equal(F.state().score, 0, '进 play 时分数应归零');
assert.equal(F.state().level, 1, '应从第 1 关开始');
assert.ok(F.state().clue, '应有本关线索');
assert.ok(F.state().matchesLeft >= 1, '本关应有解');
// ③ 观察窗内:targets 全 occupied:false(点解被挡——取证契约「窗口内不可点」)。
const inObserve = F.state().targets;
assert.ok(inObserve.length >= 9, 'play 期 targets 应列全盘格子');
assert.ok(inObserve.every((t) => t.occupied === false), '观察窗内 occupied 应全 false');
host.do({ type: 'solveFirst' });
host.stepFrames(2);
assert.equal(F.state().score, 0, '观察窗内点解不应加分');
// ④ 出观察窗:恰有 matchesLeft 个 occupied:true(取证面兼职求解器,只标正确解)。
host.stepFrames(framesFor(OBSERVE_MS));
const solving = F.state();
const occupiedN = solving.targets.filter((t) => t.occupied === true).length;
assert.equal(occupiedN, solving.matchesLeft, `occupied 数(${occupiedN})应恰为剩余解数(${solving.matchesLeft})`);
// ⑤ 点解:每次分数严格递增;点空白/点干扰不加分。
let prevScore = F.state().score;
host.do({ type: 'solveFirst' });
host.stepFrames(2);
assert.ok(F.state().score > prevScore, '点对一格分数应上升');
prevScore = F.state().score;
host.tap(2, 2); // 盘面外空白
host.stepFrames(2);
assert.equal(F.state().score, prevScore, '点空白不应加分');
const wrong = F.state().targets.find((t) => t.occupied === false);
if (wrong) { // 点干扰格:不加分(轻惩罚断连击,不倒扣)
host.tap(wrong.x, wrong.y);
host.stepFrames(2);
assert.equal(F.state().score, prevScore, '点干扰格不应加分');
}
// ⑥ 清盘过关 → 庆祝停顿 → 第 2 关(新线索,重进观察窗)。
host.stepFrames(framesFor(400)); // 让错点锁定期过去
clearCurrentLevel(host, F);
assert.equal(F.state().levelsCleared, 1, '清盘后已通关数应为 1');
host.stepFrames(framesFor(CLEAR_PAUSE_MS));
assert.equal(F.state().level, 2, '庆祝后应进第 2 关');
assert.ok(F.state().matchesLeft >= 1, '第 2 关应有解');
// ⑦ 逐关通到底:全 levelsTotal 关 → win latch(outcome=won,phase=over)。
let guardLv = 0;
while (F.state().phase === 'play' && guardLv++ < levelsTotal() + 2) {
host.stepFrames(framesFor(OBSERVE_MS)); // 出观察窗
clearCurrentLevel(host, F);
host.stepFrames(framesFor(CLEAR_PAUSE_MS)); // 过关停顿 → 下一关或 over
}
assert.equal(F.state().phase, 'over', `通全关应切 over(guard=${guardLv})`);
assert.equal(F.state().outcome, 'won', '通全关应锁 outcome=won(胜负闸)');
assert.equal(F.state().levelsCleared, levelsTotal(), '应通满全部关卡');
assert.ok(F.state().score > 0, 'over 应保留本局得分');
assert.ok(F.state().best >= F.state().score, `最佳分应 ≥ 本局分(best=${F.state().best})`);
// ⑧ over → 再来一局:回 play 且清零重开。
host.do({ type: 'restart' });
host.stepFrames(2);
assert.equal(F.state().phase, 'play', '重开后应回 play');
assert.equal(F.state().score, 0, '重开后分数应归零');
assert.equal(F.state().level, 1, '重开后应回第 1 关');
// ⑨ 取证度量形状健全(frames/taps/hits 都被驱动过)。
const m = F.measures();
assert.ok(m.frames > 100, `应驱动过足量帧(frames=${m.frames})`);
assert.ok(m.taps > 0, `应记录过点击(taps=${m.taps})`);
assert.ok(m.hits >= levelsTotal(), `应记录过足量命中(hits=${m.hits})`);
});
test('限时到点:不解题拖满一局 → over + outcome=lost(lose latch)', async () => {
const host = await bootHeadless('?mode=evidence&seed=42');
const F = globalThis.window.__gameForensics;
host.do({ type: 'start' });
host.stepFrames(2);
assert.equal(F.state().phase, 'play');
// 不点解,纯驱动帧到限时到点(ROUND_MS 经 bundle.tick 受控时钟到期)。
let guard = 0;
while (F.state().phase === 'play' && guard++ < Math.ceil(ROUND_MS / 16.67 / 60) + 20) {
host.stepFrames(60);
}
assert.equal(F.state().phase, 'over', `限时到点应切 over(guard=${guard}×60 帧)`);
assert.equal(F.state().outcome, 'lost', '没通全关到点应锁 outcome=lost');
});
test('无浏览器确定性:同 seed → 同线索与同盘面序列(受控随机生效)', async () => {
/** 跑固定脚本:通前两关,收集(线索,首格属性)序列。 */
async function run() {
const host = await bootHeadless('?mode=evidence&seed=777');
const F = globalThis.window.__gameForensics;
host.do({ type: 'start' });
host.stepFrames(2);
const seen = [];
for (let lv = 0; lv < 2; lv++) {
seen.push(F.state().clue);
host.stepFrames(framesFor(OBSERVE_MS));
let guard = 0;
while (F.state().matchesLeft > 0 && guard++ < 30) {
host.do({ type: 'solveFirst' });
host.stepFrames(2);
}
seen.push(F.state().score);
host.stepFrames(framesFor(CLEAR_PAUSE_MS));
}
return JSON.stringify(seen);
}
const a = await run();
const b = await run();
assert.equal(a, b, '同 seed 同脚本应产逐项一致的线索/得分序列');
});

View File

@ -82,6 +82,7 @@ export function buildSystemPrompt(id) {
6. ** brief 属剧情 / 互动叙事 / 文字冒险这类读文本做选择推进剧情品类**:read_file('.agents/skills/narrative-game-design.md') 设计什么才好玩的范式(6 心理引擎 / §1 范式含分支选择+多结局图鉴 / §3 文本红线 / §8 设计配方 / §9 可达性红线含取证契约); read_file('game-runtime/games/_template-story/src/game-logic.js') 看剧情品类过门范例(分支推进 + 结局 latch + 结局图鉴的骨架写法,其故事数据在同目录 core.js 顶部参数空间)
7. ** brief TRPG / 掷骰 / 冒险 / 地牢爬塔这类掷骰对抗抉择成长品类**:read_file('.agents/skills/trpg-game-design.md') 设计什么才好玩的范式(6 心理引擎 / §1 玩法范式含掷骰爬塔 / §3 掷骰概率数值 / §9 红线核心动作单击即结算·掷骰过程可见 / §10 反无趣自检)
8. ** brief 属非遗 / 传统技艺 / 民俗这类传统文化玩法化品类**:read_file('.agents/skills/heritage-game-design.md') 设计什么才好玩的范式(§1 玩法化范式含工序节拍 / §3 双层奖励与工序节奏目标值 / §9 文化表述红线 / §10 反贴皮自检);需要完整过门范例再 read_file('game-runtime/games/_fewshot-feiyi/src/game-logic.js')
9. ** brief 属解谜(找规律 / 找不同 / 翻牌配对 / 顺序推理 / 点选消除这类观察推理点解品类)**:read_file('.agents/skills/puzzle-game-design.md') 取解谜设计范式(顿悟距离 / 规则递进关卡阶梯 / 卡壳兜底 / 成绩炫耀 / §9 品类可达红线解谜的必须映射到 targets occupied);想看丰富成什么样的完整过门正例, read_file('game-runtime/games/_fewshot-puzzle/src/game-logic.js') 参照
先设计后写码:决定这游戏好不好玩(关键,别跳过)
能跑 好玩动手写码前,先据 brief 在心里( game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
@ -91,13 +92,13 @@ export function buildSystemPrompt(id) {
- **数值成长**:产出/成本随级上升略带滚雪球感,别平淡线性;
- **音效清单**:收钱/ 升级欢呼 / 解锁号角( plugins.audioMusic;宁可程序化也别没有反馈音)
定完过一遍 sim-business §10 **8 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚萌角色/拥有物 ⑦放置回归惊喜 ⑧音反馈)命中越多越好玩,命中 3 能玩但无趣
**8 条自检对所有品类通用**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式;**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进结局 latch 驻留,选择有重量选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式
**8 条自检对所有品类通用**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式;**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进结局 latch 驻留,选择有重量选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式;**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式
MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)
设计太满 你实现负担过重 read/write 反复跳跑不收敛(实测:满配设计循环截停精简设计 9 步收敛)首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 34 级解锁 + 基础数值/音效,**商品 3 种起步****离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标后续·MVP 不做**别塞进首版**先出能玩的核心,再谈丰富**
步骤
A 读手册(1 + 按品类 5/6/7/8)+ 读你的起点 game-logic.js(4) B brief **轻量玩法设计**(核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效, §10 八条自检 + MVP-first) C 读要用的插件 api.d.ts D write_file 改写 **game-logic.js**(按需拆 core/render)实现玩法 E check,有错改到 PASS F build,有错改到 PASS G check+build 都绿后调 done
A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4) B brief **轻量玩法设计**(核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效, §10 八条自检 + MVP-first) C 读要用的插件 api.d.ts D write_file 改写 **game-logic.js**(按需拆 core/render)实现玩法 E check,有错改到 PASS F build,有错改到 PASS G check+build 都绿后调 done
红线(check 会拦,违反则 done 被拒)
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出别改名别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())