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
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:
commit
db4aadd785
@ -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 治理手册 |
|
||||
|
||||
185
.agents/skills/puzzle-game-design.md
Normal file
185
.agents/skills/puzzle-game-design.md
Normal 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) |
|
||||
| **星级 / 收集图鉴** | 每关按用时/步数评 1–3 星,攒星解锁新章节/新主题 | session-score(best 接 save)+ save-progress 存星 + hud-ui 图鉴格 |
|
||||
| **数值成长曲线** | 每关基分 + 连击倍率 + 剩时奖励,制造"这把更高分"的重开欲 | balance 数值表 + session-score |
|
||||
|
||||
**组合配方(经验)**:一款轻量解谜 = **1 个谜题主范式 + 关卡阶梯(规则随关递进)+ 连击/星级一个成长点缀 + 收集/称号一个第二动机**。别堆超过 2 个主机制(便宜模型 + 单屏扛不住,也稀释顿悟感)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关卡 / 进度 / 留存设计(解谜的进度结构 = 关卡阶梯本身)
|
||||
|
||||
解谜品类里,「解锁阶梯」的品类形态 = **关卡链:每过一关解锁下一关,且新关引入新规则/新维度/更大盘面**——这就是质量模型底线四件里"解锁阶梯 ≥3 级"的解谜映射(纯换汤不换药的重复关不算阶梯)。
|
||||
|
||||
- **首 3 分钟(决定生死)**:① 0–10 秒:第一关零阅读、一眼能解(示范规则,点一下就有反馈);② 10–60 秒:连过 2–3 关,第一次出现"要想一下"的关 + 第一次连击/星级奖励(给到"我变聪明了"的爽);③ 1–3 分钟:露出后续内容预告(下一章图鉴 / "第 8 关解锁新玩法"的锁),建立"还有很多"预期。
|
||||
- **难度曲线**:难度 = 盘面大小 × 规则维度 × 干扰强度,三个旋钮**一次只拧一个**。节奏 = 2–3 关舒适 → 1 关"要想一下" → 过关大庆祝 → 回舒适;顿悟关是爽点、不是墙。
|
||||
- **卡点设计**:玩家 ≥8–12 秒无正确操作 = 卡壳信号 → 给**软提示**(候选高亮/线索重播/排除一个干扰项),**卡壳不出局**;提示是"诱惑"(将来可接看广告解锁),不是"惩罚"。
|
||||
- **留存钩子**:每日谜题(当日专属一关)· 章节图鉴(攒星点亮)· 连胜纪录 · 称号阶梯("见习侦探→推理大师")。便宜档至少给一种。
|
||||
- **长线**:章节主题轮换(水果→宝石→古文字)· 图鉴凑齐 · 无尽模式冲纪录。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数值与节奏(给具体区间锚 —— 别让 code agent 拍脑袋)
|
||||
|
||||
- **盘面与解数**:起步 **3×3 格、每关 3–5 个正确目标**;上限 4×4(竖屏手指友好,格子 ≥72px);目标太少无清屏感、太多变体力活。
|
||||
- **观察窗口**:出题后留 **0.8–1.5s** 让玩家扫一眼盘面再判定(别一出题就算超时);记忆类展示 **1.5–2.5s** 再盖住。
|
||||
- **一关时长**:**8–20s/关**为舒适区;整局限时 60–90s 或 8–12 关到终局,两种收口都可(限时更适合自动验收,见 §9)。
|
||||
- **计分骨架**:每个正确目标基分(如 +10)+ **连击倍率**(2s 内连续正确 ×1.5/×2,断连归 1)+ 过关奖励(+30 起、随关数上浮 ×1.15/关)+ 剩时奖励。让"聪明又快"肉眼变成大分差。
|
||||
- **错点惩罚(轻)**:点错 = 抖屏 + 断连击 + 短锁定 **0.3–0.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. **关卡阶梯**(3–6 关起步:每关的盘面大小 / 规则维度 / 新引入什么——逐关写清,这是解谜的解锁阶梯);
|
||||
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 个谜题主范式 + 3–5 关阶梯 + 基分/连击 + 基础音效,谜面元素 ≤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 种谜题规则/维度,后关比前关多一层复杂度,不是同一规则换数字重复 | 1–2 关按颜色找、3–4 关按形状找、5 关颜色+形状组合 | 8 关全是"找红色",只是格子变多 |
|
||||
| P2 | L2 | 顿悟距离 | 谜面需观察/比对/推理才能定位解,线索与答案之间有"想一下"的距离,不是看见就点的纯反应 | 线索"数量为 3 的宝石",盘面要数一数 | 目标自己发光闪烁,无脑点亮的 |
|
||||
| P3 | L3 | 关卡阶梯可见 | 玩家任意时刻能看到关卡进度(第 N/共 M 关或下一关预告),过关有明确庆祝节点 | HUD 常驻"第 3/8 关"+ 过关全屏庆祝 | 只有分数在涨,玩家不知道自己走到哪 |
|
||||
| P4 | L2 | 卡壳兜底 | 卡住 ≥8–12s 有软提示(高亮/排除/线索重播),卡壳不出局不清零 | 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)
|
||||
@ -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 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤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 都绿后调 finish。
|
||||
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 都绿后调 finish。
|
||||
|
||||
【红线(check 会拦,违反则 finish 被拒)】
|
||||
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。
|
||||
|
||||
@ -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。
|
||||
|
||||
genre(W-GENRE 件④,缺省 None=原行为):品类键(如 'puzzle')显式透传给丰富度 LLM 评分——同一次
|
||||
judge 调用 additive 追加品类扩展条目(分母独立小计落 richness.genre);None → 按 scaffold_template
|
||||
查 GENRE_BY_TEMPLATE 推断;两路都没有/条目缺 → 自动退化为纯通用底座评分。
|
||||
|
||||
run_gates=True(默认,spike 单局):收口跑全套 stage→smoke→ensure_play_spec→九门 play。
|
||||
run_gates=False(generation-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')} "
|
||||
|
||||
@ -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 = {
|
||||
# 品类脚手架目录名 → 品类 key(run_studio 据 scaffold_template 透传 genre;None/未登记 → 不评品类扩展)。
|
||||
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 空/文件缺/坏条目 → [](绝不抛)。
|
||||
|
||||
条目形状随 fixture(name/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/O);None → 从 game_id 收集。
|
||||
timeout: judge LLM 调用超时秒数;超时 → degraded(绝不卡死收口)。
|
||||
max_src_bytes: 喂 judge 的源码上限。
|
||||
genre: 品类 key(W-GENRE 件④);命中 GENRE_CHECKLISTS → 同一次评分 additive 追加品类扩展条目
|
||||
(分母独立小计落 result["genre"]);None/未登记品类 → 行为与既有版本完全一致(prompt 逐字节不变)。
|
||||
genre: 品类 key(W-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)
|
||||
|
||||
41
cheap-worker/fixtures/genre-rubrics/puzzle.json
Normal file
41
cheap-worker/fixtures/genre-rubrics/puzzle.json
Normal 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": "1–2 关按颜色找、3–4 关按形状找、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": "卡住 ≥8–12s 有软提示(高亮/排除/线索重播),卡壳不出局不清零",
|
||||
"positive": "10s 无正确操作,候选格开始脉动提示",
|
||||
"negative": "卡住只能干等到超时判负"
|
||||
},
|
||||
{
|
||||
"name": "成绩可炫耀",
|
||||
"layer": "L4",
|
||||
"meaning": "结算展示可比较成绩(用时/连击/星级/称号,配最佳纪录),不是打完就黑屏",
|
||||
"positive": "结算面板:分数+最高连击+3 星+历史最佳+称号",
|
||||
"negative": "结算只有一行「游戏结束」"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
cheap-worker/fixtures/golden-specs/puzzle.play-spec.json
Normal file
17
cheap-worker/fixtures/golden-specs/puzzle.play-spec.json
Normal 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
|
||||
}
|
||||
153
cheap-worker/tests/test_genre_puzzle_assets.py
Normal file
153
cheap-worker/tests/test_genre_puzzle_assets.py
Normal 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: 全部通过")
|
||||
@ -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"}
|
||||
|
||||
@ -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 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 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 都绿后调 finish。
|
||||
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 都绿后调 finish。
|
||||
|
||||
【红线(check 会拦,违反则 finish 被拒)】
|
||||
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。
|
||||
|
||||
@ -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.0:W-GENRE 品类路由并集——剧情(+6)/ TRPG(+7)/ 非遗(+8 heritage-game-design + _fewshot-feiyi 正例指针);merge 时版本归一;cheap_roles.py/prompt.mjs 双源同改
|
||||
version: 1.4.0 # v1.4.0:W-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
|
||||
|
||||
19
game-runtime/games/_fewshot-puzzle/README.md
Normal file
19
game-runtime/games/_fewshot-puzzle/README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# _fewshot-puzzle — 解谜品类 few-shot 过门正例「深海寻宝谜航」
|
||||
|
||||
这是**给生成 agent 读的完整成品范例**(few-shot 正例):从 `_template-puzzle` 骨架起步、按「深海寻宝」brief 换身份加深度后交出来的样子。它演示的是**增量**——骨架已给的循环别重写,agent 该补的是这些:
|
||||
|
||||
| 丰富维度 | 本范例的落点 | 对应 rubric |
|
||||
|---|---|---|
|
||||
| 主题全换 | 宝石→深海宝物(THEME/文案/底景/贝壳·海星·珍珠画法) | 多样性(D9 不雷同) |
|
||||
| 规则递进 | 6 关,颜色→形状→数量→混合→组合逐关引入,每关 intro 教学副标 | P1 谜题规则递进 / P3 关卡阶梯可见 |
|
||||
| 星级评价 | 每关按错点数评 1–3 星(聪明比手快值钱),过关亮星 | 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/`。
|
||||
24
game-runtime/games/_fewshot-puzzle/assets/gen-ledger.md
Normal file
24
game-runtime/games/_fewshot-puzzle/assets/gen-ledger.md
Normal 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 | 结果 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| — | — | (本模板无外采素材) | — | — | — | — |
|
||||
6
game-runtime/games/_fewshot-puzzle/assets/manifest.json
Normal file
6
game-runtime/games/_fewshot-puzzle/assets/manifest.json
Normal 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": []
|
||||
}
|
||||
33
game-runtime/games/_fewshot-puzzle/entry.js
Normal file
33
game-runtime/games/_fewshot-puzzle/entry.js
Normal 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 }));
|
||||
}
|
||||
100
game-runtime/games/_fewshot-puzzle/index.html
Normal file
100
game-runtime/games/_fewshot-puzzle/index.html
Normal 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>
|
||||
73
game-runtime/games/_fewshot-puzzle/src/assets.js
Normal file
73
game-runtime/games/_fewshot-puzzle/src/assets.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
234
game-runtime/games/_fewshot-puzzle/src/core.js
Normal file
234
game-runtime/games/_fewshot-puzzle/src/core.js
Normal 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.8–1.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;
|
||||
|
||||
/** 星级判定:按本关错点数评 1–3 星(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));
|
||||
}
|
||||
333
game-runtime/games/_fewshot-puzzle/src/game-logic.js
Normal file
333
game-runtime/games/_fewshot-puzzle/src/game-logic.js
Normal 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 分钟脚本落地)
|
||||
* · 星级评价:每关按错点数评 1–3 星(聪明比手快值钱),过关庆祝亮星
|
||||
* · 称号 + 跨局收集:总星数换称号;累计星星经 save 插件持久(回归可见积攒,第二动机)
|
||||
* · 结算炫耀:分数/星级/称号/通关数/最高连击/累计收集/最佳纪录(不是打完黑屏)
|
||||
* · 同款钩子:GENRE_META 品类原型 + 主题标签(remix 预填元数据)
|
||||
* 取证契约与骨架一致(targets 只把当前正确解标 occupied、score 单调升)——丰富不碰契约。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'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;
|
||||
}
|
||||
73
game-runtime/games/_fewshot-puzzle/src/game.js
Normal file
73
game-runtime/games/_fewshot-puzzle/src/game.js
Normal 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';
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】host 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;
|
||||
}
|
||||
110
game-runtime/games/_fewshot-puzzle/src/host-config.js
Normal file
110
game-runtime/games/_fewshot-puzzle/src/host-config.js
Normal 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 不硬编码
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
132
game-runtime/games/_fewshot-puzzle/src/main.js
Normal file
132
game-runtime/games/_fewshot-puzzle/src/main.js
Normal 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;
|
||||
}
|
||||
231
game-runtime/games/_fewshot-puzzle/src/render.js
Normal file
231
game-runtime/games/_fewshot-puzzle/src/render.js
Normal 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 };
|
||||
}
|
||||
54
game-runtime/games/_template-puzzle/README.md
Normal file
54
game-runtime/games/_template-puzzle/README.md
Normal 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`(解谜设计手册)。
|
||||
24
game-runtime/games/_template-puzzle/assets/gen-ledger.md
Normal file
24
game-runtime/games/_template-puzzle/assets/gen-ledger.md
Normal 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 | 结果 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| — | — | (本模板无外采素材) | — | — | — | — |
|
||||
6
game-runtime/games/_template-puzzle/assets/manifest.json
Normal file
6
game-runtime/games/_template-puzzle/assets/manifest.json
Normal 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": []
|
||||
}
|
||||
33
game-runtime/games/_template-puzzle/entry.js
Normal file
33
game-runtime/games/_template-puzzle/entry.js
Normal 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 }));
|
||||
}
|
||||
100
game-runtime/games/_template-puzzle/index.html
Normal file
100
game-runtime/games/_template-puzzle/index.html
Normal 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>
|
||||
73
game-runtime/games/_template-puzzle/src/assets.js
Normal file
73
game-runtime/games/_template-puzzle/src/assets.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
265
game-runtime/games/_template-puzzle/src/core.js
Normal file
265
game-runtime/games/_template-puzzle/src/core.js
Normal 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.8–1.5s)。 */
|
||||
export const OBSERVE_MS = 800;
|
||||
|
||||
/** 过关庆祝停顿(ms):清盘 → 庆祝 → 下一关。 */
|
||||
export const CLEAR_PAUSE_MS = 700;
|
||||
|
||||
/** 点错锁定(ms):轻惩罚——抖屏 + 断连击 + 短锁定,绝不倒扣大分/清零。 */
|
||||
export const LOCK_MS = 350;
|
||||
|
||||
/** 卡壳软提示阈值(ms):这么久没正确操作 → 剩余解开始脉动提示(卡壳不出局,§2 节奏锚 8–12s)。 */
|
||||
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));
|
||||
}
|
||||
336
game-runtime/games/_template-puzzle/src/game-logic.js
Normal file
336
game-runtime/games/_template-puzzle/src/game-logic.js
Normal 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;
|
||||
}
|
||||
73
game-runtime/games/_template-puzzle/src/game.js
Normal file
73
game-runtime/games/_template-puzzle/src/game.js
Normal 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';
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】host 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;
|
||||
}
|
||||
110
game-runtime/games/_template-puzzle/src/host-config.js
Normal file
110
game-runtime/games/_template-puzzle/src/host-config.js
Normal 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 不硬编码
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
132
game-runtime/games/_template-puzzle/src/main.js
Normal file
132
game-runtime/games/_template-puzzle/src/main.js
Normal 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;
|
||||
}
|
||||
209
game-runtime/games/_template-puzzle/src/render.js
Normal file
209
game-runtime/games/_template-puzzle/src/render.js
Normal 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 };
|
||||
}
|
||||
126
game-runtime/games/_template-puzzle/test/core.test.mjs
Normal file
126
game-runtime/games/_template-puzzle/test/core.test.mjs
Normal 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, '规则也应一致');
|
||||
});
|
||||
198
game-runtime/games/_template-puzzle/test/headless-boot.test.mjs
Normal file
198
game-runtime/games/_template-puzzle/test/headless-boot.test.mjs
Normal 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 同脚本应产逐项一致的线索/得分序列');
|
||||
});
|
||||
@ -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 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤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())。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user