diff --git a/.agents/README.md b/.agents/README.md index 7eaf6c86..ac5ab6ec 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -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 治理手册 | diff --git a/.agents/skills/puzzle-game-design.md b/.agents/skills/puzzle-game-design.md new file mode 100644 index 00000000..87368828 --- /dev/null +++ b/.agents/skills/puzzle-game-design.md @@ -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) diff --git a/cheap-worker/cheap_roles.py b/cheap-worker/cheap_roles.py index 773c31e7..6b942700 100644 --- a/cheap-worker/cheap_roles.py +++ b/cheap-worker/cheap_roles.py @@ -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())。 diff --git a/cheap-worker/cheap_studio.py b/cheap-worker/cheap_studio.py index 339a306e..034b1647 100644 --- a/cheap-worker/cheap_studio.py +++ b/cheap-worker/cheap_studio.py @@ -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')} " diff --git a/cheap-worker/cheap_verify.py b/cheap-worker/cheap_verify.py index 3e79c5c3..13226ea9 100644 --- a/cheap-worker/cheap_verify.py +++ b/cheap-worker/cheap_verify.py @@ -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/.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/.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/.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) diff --git a/cheap-worker/fixtures/genre-rubrics/puzzle.json b/cheap-worker/fixtures/genre-rubrics/puzzle.json new file mode 100644 index 00000000..5ff7d7af --- /dev/null +++ b/cheap-worker/fixtures/genre-rubrics/puzzle.json @@ -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": "结算只有一行「游戏结束」" + } + ] +} diff --git a/cheap-worker/fixtures/golden-specs/puzzle.play-spec.json b/cheap-worker/fixtures/golden-specs/puzzle.play-spec.json new file mode 100644 index 00000000..a5ce7ddd --- /dev/null +++ b/cheap-worker/fixtures/golden-specs/puzzle.play-spec.json @@ -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 +} diff --git a/cheap-worker/tests/test_genre_puzzle_assets.py b/cheap-worker/tests/test_genre_puzzle_assets.py new file mode 100644 index 00000000..7da96427 --- /dev/null +++ b/cheap-worker/tests/test_genre_puzzle_assets.py @@ -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-/,测后清理;与生成产物 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: 全部通过") diff --git a/cheap-worker/tests/test_golden_specs.py b/cheap-worker/tests/test_golden_specs.py index 5e6b8b8a..8ef2efe4 100644 --- a/cheap-worker/tests/test_golden_specs.py +++ b/cheap-worker/tests/test_golden_specs.py @@ -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"} diff --git a/contracts/prompts/04-config/cheap-system.md b/contracts/prompts/04-config/cheap-system.md index 0af51247..901dc1e7 100644 --- a/contracts/prompts/04-config/cheap-system.md +++ b/contracts/prompts/04-config/cheap-system.md @@ -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())。 diff --git a/contracts/prompts/registry.yaml b/contracts/prompts/registry.yaml index 7e711624..4fc55529 100644 --- a/contracts/prompts/registry.yaml +++ b/contracts/prompts/registry.yaml @@ -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 diff --git a/game-runtime/games/_fewshot-puzzle/README.md b/game-runtime/games/_fewshot-puzzle/README.md new file mode 100644 index 00000000..a06c5b61 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/README.md @@ -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/`。 diff --git a/game-runtime/games/_fewshot-puzzle/assets/gen-ledger.md b/game-runtime/games/_fewshot-puzzle/assets/gen-ledger.md new file mode 100644 index 00000000..efdd2976 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/assets/gen-ledger.md @@ -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 | 结果 | +|---|---|---|---|---|---|---| +| — | — | (本模板无外采素材) | — | — | — | — | diff --git a/game-runtime/games/_fewshot-puzzle/assets/manifest.json b/game-runtime/games/_fewshot-puzzle/assets/manifest.json new file mode 100644 index 00000000..6699a85b --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/assets/manifest.json @@ -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": [] +} diff --git a/game-runtime/games/_fewshot-puzzle/entry.js b/game-runtime/games/_fewshot-puzzle/entry.js new file mode 100644 index 00000000..30eddce4 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/entry.js @@ -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} gameHost 句柄 + */ +export async function bootGame(opts) { + // 动态 import:求值时机延后到 bootGame 被调用(浏览器运行期)。esbuild 内联,无外部请求。 + // 调用方已显式给 engine 则尊重其值,不重复装载(便于复用)。 + const engine = (opts && opts.engine) || (await import('littlejsengine')); + return bootGameCore(Object.assign({}, opts, { engine })); +} diff --git a/game-runtime/games/_fewshot-puzzle/index.html b/game-runtime/games/_fewshot-puzzle/index.html new file mode 100644 index 00000000..9534878e --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/index.html @@ -0,0 +1,100 @@ + + + + + + + + 点圆得分 + + + +
+
booting…
+ +
+ + + + + + diff --git a/game-runtime/games/_fewshot-puzzle/src/assets.js b/game-runtime/games/_fewshot-puzzle/src/assets.js new file mode 100644 index 00000000..26e483c5 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/src/assets.js @@ -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} + */ +export const IMAGE_FILES = {}; + +/** + * 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。 + * @param {string} [baseUrl] 资产根(缺省 './assets/') + * @returns {Promise<{ images: Record, missing: string[], loaded: number, total: number }>} + */ +export function loadAssets(baseUrl) { + const root = baseUrl == null ? './assets/' : baseUrl; + const keys = Object.keys(IMAGE_FILES); + /** @type {Record} */ + 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, + })); +} diff --git a/game-runtime/games/_fewshot-puzzle/src/core.js b/game-runtime/games/_fewshot-puzzle/src/core.js new file mode 100644 index 00000000..91ece388 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/src/core.js @@ -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)); +} diff --git a/game-runtime/games/_fewshot-puzzle/src/game-logic.js b/game-runtime/games/_fewshot-puzzle/src/game-logic.js new file mode 100644 index 00000000..43f950ec --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/src/game-logic.js @@ -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; +} diff --git a/game-runtime/games/_fewshot-puzzle/src/game.js b/game-runtime/games/_fewshot-puzzle/src/game.js new file mode 100644 index 00000000..28fcd225 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/src/game.js @@ -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; +} diff --git a/game-runtime/games/_fewshot-puzzle/src/host-config.js b/game-runtime/games/_fewshot-puzzle/src/host-config.js new file mode 100644 index 00000000..ee1683c4 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/src/host-config.js @@ -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, 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 不硬编码 + }, + }), + }; +} diff --git a/game-runtime/games/_fewshot-puzzle/src/main.js b/game-runtime/games/_fewshot-puzzle/src/main.js new file mode 100644 index 00000000..ba505a73 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/src/main.js @@ -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} 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; +} diff --git a/game-runtime/games/_fewshot-puzzle/src/render.js b/game-runtime/games/_fewshot-puzzle/src/render.js new file mode 100644 index 00000000..771553f2 --- /dev/null +++ b/game-runtime/games/_fewshot-puzzle/src/render.js @@ -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 }; +} diff --git a/game-runtime/games/_template-puzzle/README.md b/game-runtime/games/_template-puzzle/README.md new file mode 100644 index 00000000..cc1874e5 --- /dev/null +++ b/game-runtime/games/_template-puzzle/README.md @@ -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//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`(解谜设计手册)。 diff --git a/game-runtime/games/_template-puzzle/assets/gen-ledger.md b/game-runtime/games/_template-puzzle/assets/gen-ledger.md new file mode 100644 index 00000000..efdd2976 --- /dev/null +++ b/game-runtime/games/_template-puzzle/assets/gen-ledger.md @@ -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 | 结果 | +|---|---|---|---|---|---|---| +| — | — | (本模板无外采素材) | — | — | — | — | diff --git a/game-runtime/games/_template-puzzle/assets/manifest.json b/game-runtime/games/_template-puzzle/assets/manifest.json new file mode 100644 index 00000000..1cefcde5 --- /dev/null +++ b/game-runtime/games/_template-puzzle/assets/manifest.json @@ -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": [] +} diff --git a/game-runtime/games/_template-puzzle/entry.js b/game-runtime/games/_template-puzzle/entry.js new file mode 100644 index 00000000..30eddce4 --- /dev/null +++ b/game-runtime/games/_template-puzzle/entry.js @@ -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} gameHost 句柄 + */ +export async function bootGame(opts) { + // 动态 import:求值时机延后到 bootGame 被调用(浏览器运行期)。esbuild 内联,无外部请求。 + // 调用方已显式给 engine 则尊重其值,不重复装载(便于复用)。 + const engine = (opts && opts.engine) || (await import('littlejsengine')); + return bootGameCore(Object.assign({}, opts, { engine })); +} diff --git a/game-runtime/games/_template-puzzle/index.html b/game-runtime/games/_template-puzzle/index.html new file mode 100644 index 00000000..9534878e --- /dev/null +++ b/game-runtime/games/_template-puzzle/index.html @@ -0,0 +1,100 @@ + + + + + + + + 点圆得分 + + + +
+
booting…
+ +
+ + + + + + diff --git a/game-runtime/games/_template-puzzle/src/assets.js b/game-runtime/games/_template-puzzle/src/assets.js new file mode 100644 index 00000000..26e483c5 --- /dev/null +++ b/game-runtime/games/_template-puzzle/src/assets.js @@ -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} + */ +export const IMAGE_FILES = {}; + +/** + * 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。 + * @param {string} [baseUrl] 资产根(缺省 './assets/') + * @returns {Promise<{ images: Record, missing: string[], loaded: number, total: number }>} + */ +export function loadAssets(baseUrl) { + const root = baseUrl == null ? './assets/' : baseUrl; + const keys = Object.keys(IMAGE_FILES); + /** @type {Record} */ + 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, + })); +} diff --git a/game-runtime/games/_template-puzzle/src/core.js b/game-runtime/games/_template-puzzle/src/core.js new file mode 100644 index 00000000..3200d1a9 --- /dev/null +++ b/game-runtime/games/_template-puzzle/src/core.js @@ -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:};'shape' → {shape:};'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)); +} diff --git a/game-runtime/games/_template-puzzle/src/game-logic.js b/game-runtime/games/_template-puzzle/src/game-logic.js new file mode 100644 index 00000000..daae190f --- /dev/null +++ b/game-runtime/games/_template-puzzle/src/game-logic.js @@ -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; +} diff --git a/game-runtime/games/_template-puzzle/src/game.js b/game-runtime/games/_template-puzzle/src/game.js new file mode 100644 index 00000000..28fcd225 --- /dev/null +++ b/game-runtime/games/_template-puzzle/src/game.js @@ -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; +} diff --git a/game-runtime/games/_template-puzzle/src/host-config.js b/game-runtime/games/_template-puzzle/src/host-config.js new file mode 100644 index 00000000..ee1683c4 --- /dev/null +++ b/game-runtime/games/_template-puzzle/src/host-config.js @@ -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, 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 不硬编码 + }, + }), + }; +} diff --git a/game-runtime/games/_template-puzzle/src/main.js b/game-runtime/games/_template-puzzle/src/main.js new file mode 100644 index 00000000..ba505a73 --- /dev/null +++ b/game-runtime/games/_template-puzzle/src/main.js @@ -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} 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; +} diff --git a/game-runtime/games/_template-puzzle/src/render.js b/game-runtime/games/_template-puzzle/src/render.js new file mode 100644 index 00000000..698a67a9 --- /dev/null +++ b/game-runtime/games/_template-puzzle/src/render.js @@ -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 }; +} diff --git a/game-runtime/games/_template-puzzle/test/core.test.mjs b/game-runtime/games/_template-puzzle/test/core.test.mjs new file mode 100644 index 00000000..eedf8e60 --- /dev/null +++ b/game-runtime/games/_template-puzzle/test/core.test.mjs @@ -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, '规则也应一致'); +}); diff --git a/game-runtime/games/_template-puzzle/test/headless-boot.test.mjs b/game-runtime/games/_template-puzzle/test/headless-boot.test.mjs new file mode 100644 index 00000000..cb201472 --- /dev/null +++ b/game-runtime/games/_template-puzzle/test/headless-boot.test.mjs @@ -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 同脚本应产逐项一致的线索/得分序列'); +}); diff --git a/game-runtime/tools/amodel-gen/prompt.mjs b/game-runtime/tools/amodel-gen/prompt.mjs index 3bef8011..9a728353 100644 --- a/game-runtime/tools/amodel-gen/prompt.mjs +++ b/game-runtime/tools/amodel-gen/prompt.mjs @@ -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())。