diff --git a/.agents/skills/heritage-game-design.md b/.agents/skills/heritage-game-design.md new file mode 100644 index 00000000..b16d5e2c --- /dev/null +++ b/.agents/skills/heritage-game-design.md @@ -0,0 +1,169 @@ +# skill: heritage-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/插件。**这份 skill 负责让设计本身好玩**;丰富度的「校验」走纯 LLM 验证 agent(非阻塞),绝不写成代码校验。 +> +> **可达性边界**:产出必须落得到 A-model 轻量运行时——**LittleJS 2D 手机竖屏(390×844)、单/少场景、便宜模型可生成的多文件代码、11 注入插件能力域**。设计再好,落不到这个运行时就是空想——见 §9 红线。 +> +> **证据基**:范式蒸馏自「传统技艺/民俗」题材在轻量小游戏里的成熟玩法化模式(工序模拟、图样拼合、节律配合属训练知识内的通识范式);品类骨架 = [`game-runtime/games/_template-feiyi/`](../../game-runtime/games/_template-feiyi/)(工序节拍循环,已独立过九门)。 + +--- + +## 0. 为什么这一品类能成(底层动机 —— 设计要击中这些) + +非遗主题小游戏的吸引力来自 5 个心理引擎,**好玩 = 同时点燃尽量多个**;一个都没点燃 = 贴皮换装: + +1. **手作治愈**:亲手把一件东西从毛坯做到成品的「完成感」——工序一道道点亮、作品一件件出炉,过程本身解压。 +2. **文化好奇**:传统工艺"原来是这么做出来的"的新鲜感——工序名、器物名、纹样名自带内容密度,天然不同质化。 +3. **节律爽感**:火候、节拍、时机——"在对的时刻出手"的技巧回报,是这一品类替代"打怪爽"的张力源。 +4. **收集图鉴**:作品册/纹样册凑齐的强迫感(每件成品都是一个文化物件,收集动机比抽象分数强)。 +5. **成就展示**:「我烧出来的这窑」可截图、可分享——成品天然是炫耀物。 + +> **反"贴皮"总纲**:非遗品类最常见的失败不是不好玩,是**玩法与文化无关**——标题叫传统工艺、玩起来是换色打地鼠。判据:把主题词全部替换成现代词,玩法若毫发无损,就是贴皮(见 §10 自检与品类 rubric H5)。 + +--- + +## 1. 核心玩法化范式(可达子集 —— design 优先从这里选) + +| 范式 | 一句话 | 我们运行时怎么落(插件) | +|---|---|---| +| **工序节拍**(品类骨架默认) | 一件作品 = 一条 ≥3 道的工序链,工序依次「就绪」,在火候窗口内点掉 → 出成品;错过窗口回炉 | `_template-feiyi` 已接好整条循环:session-score 计工钱 + timer-scheduler 限时 + hud-ui 工位/窗口条 + particles-juice 出彩 + audio-music 音效 | +| **图样拼合** | 按纹样图谱点亮格子/部件,拼出一幅完整图样(剪纸/织纹/年画式的构图感) | core 网格状态 + hud-ui 绘格 + 点亮判定走 targets occupied | +| **节律配合** | 跟着节拍点(舞龙鼓点/号子),命中节拍窗口得分、连中叠倍 | timer-scheduler 节拍源 + gamefeel ComboWindow + audio-music | +| **工坊经营** | 接订单→按工序制作→交付收钱(非遗 × 经营的复合;经营范式见 sim-business §1) | 工序节拍循环 + 订单表;别超 2 主机制 | +| **收集图鉴** | 每件成品入册,凑齐一套解锁下一套 | save-progress 存收集 + hud-ui 图鉴格 | +| **技艺成长** | 攒工钱解锁新作品/新纹样/新工序(更难更值钱) | session-score 阈值 + core 解锁表 | + +**组合配方(经验)**:一款轻量非遗 = **1 条工序/节律主循环 + 1 个成长轴(解锁新作品/纹样)+ 收集点缀**。别堆超过 2 个主机制(便宜模型 + 单屏扛不住,也稀释爽点)。 + +**成长轴申报(对齐质量模型 SoT §7)**:本品类以**工艺步骤解锁/章节推进**作成长轴(替代经营类的数值滚雪球)——解锁的是"会做的东西变多、做的东西变难变值钱",任意时刻都露出下一个锁。这是档位底线四件里「成长轴」的品类映射,不是裸降。 + +--- + +## 2. 关卡 / 进度 / 留存设计 + +- **首 3 分钟(决定生死)**:① 0–10 秒:零阅读上手(第一道工序高亮闪、点它就有反馈);② 10–60 秒:**做成第一件作品**(完整走完一条工序链的完成感 = 本品类的首个爽点);③ 1–3 分钟:露出 2–3 个后续锁(下一件更难的作品/新纹样/新工序)。 +- **留存钩子**:作品图鉴缺格(「还差 2 件凑齐这一套」)· 每日首件双倍工钱 · 限时"赶工订单"(可选)。 +- **长线**:套系图鉴(做齐一套解锁下一套)· 工坊升级(案台变大、工序变多)· 最佳纪录(单日最多成品)。 +- **节奏**:一局内先松后紧——开局工序就绪慢、窗口宽(学会),后段就绪快、窗口收紧(赶工感);爽点 = 出成品的瞬间(全屏小庆祝),平台期 = 攒工钱等解锁。 + +--- + +## 3. 数值与节奏(节律张力是本品类的手感命脉) + +- **双层奖励(与自动验收解耦,工程现实)**:**基础分**——每完成一道工序都加分(盲点也能推进,保九门驱动器可过门);**技巧分**——火候窗口内的「正窗」时刻(窗口前 40%)出手给暴击倍率、连续正窗叠 combo。真人靠技巧分玩出高分,驱动器拿基础分过门,两不耽误。 +- **工序节奏目标值(design 必给具体区间,别让 code agent 拍脑袋)**:工序就绪间隔 **~0.6–1.0s**(太慢 = 干等,太快 = 手忙);火候窗口 **~5–8s**(过窗回炉,不判死);回炉冷却 **~0.8–1.2s**;一局 **60s**,期望成品 **2–4 件**(工序链 4–6 道);后 20s 可把窗口收紧到 **~3.5–5s** 做赶工段。这些目标值交 core.js 参数区。 +- **工钱曲线**:后道工序分值高于前道(越接近成品越值钱,弃单代价自然变大);成品奖励 ≈ 单道工序分的 3–5 倍;解锁阈值取「2–3 件成品的工钱」,制造"差一件就解锁"的钩子。 +- **代价设计**:错过窗口 = 回炉重做该道(损时间不清进度),**不做「一错全毁」**——挫败 = 流失;紧张感来自时间损失与 combo 断裂,不来自惩罚清零。 + +--- + +## 4. 美术风格范式(可达 + mmx 可产 + 程序化兜底) + +| 风格 | 调性 | 配色 | 适合 | +|---|---|---|---| +| **国潮扁平**(主流首选) | 雅致轻快 | 低饱和底 + 朱砂/黛青/藤黄点缀 | 绝大多数非遗题材 | +| **暖木工坊** | 手作温度 | 陶土棕/米白/窑火橙 | 制陶/木作/织染类工坊 | +| **纸艺高对比** | 利落喜庆 | 宣纸白/朱红剪影 | 剪纸/皮影/年画类图样玩法 | +| **夜灯暖光** | 热闹民俗 | 深夜蓝底 + 灯笼暖黄 | 灯彩/庙会/节庆类 | + +- **物件即主角**:非遗品类的"萌角色"往往是**器物本身**(一只越做越亮的陶碗、一盏点起来的花灯)——给成品高光时刻,拥有感落在作品上。 +- **美术一致性之根**:全程共用一组"风格词"(主体 + 风格 + 配色 + 构图),mmx 每次生成都带它;程序化兜底——hud-ui + palette-post 画几何工位 + 统一色板即可成立,**别因美术阻塞可玩性**。 + +--- + +## 5. UI 范式(hud-ui 全可达) + +- **顶部 HUD**:工钱 / 成品数 / 倒计时(固定布局); +- **工位排布**:工序台按链条排(横排/弧形),当前就绪工位**高亮 + 窗口条**(所剩火候一眼可见); +- **飘字**:每道工序 "+N 工钱" 上浮,正窗暴击换色加大; +- **成品时刻**:出成品全屏小庆祝(闪光 + 粒子 + 音效)+ 成品图入册动画; +- **图鉴格**:已做出的作品点亮、未解锁的置灰挂锁(「下一个锁」可视化); +- **结算屏**:摆出本局做成的作品(名字 + 数量)+ 工钱 + 最佳纪录——这是可截图的炫耀时刻,别只弹一行分数。 + +--- + +## 6. 音乐 / 音效范式(audio-music 可达) + +- **BGM**:民乐感轻循环(笛/筝/鼓的轻快律动);mmx 生成:`mmx music generate --prompt "轻快民乐,竹笛与古筝,手工坊的温暖节奏" --instrumental --bpm 96`。 +- **音效(节律品类的手感一半在声音)**:工序完成「嗒」(click)· 正窗暴击「叮」(chime/coin)· 出成品欢庆(win)· 回炉闷响(thud/fail)。经 plugins.audioMusic 语义音名;**宁可程序化音效,也别没有反馈音**。 +- 节律配合范式里,音效就是玩法(节拍提示音),优先级最高。 + +--- + +## 7. 首屏 / 传播钩子(通用,MVP 也用) + +- 首屏 3 秒:直接站在工坊案台前,第一道工序已在闪——点它就开始,别放文化说明墙(文化经玩法体感,不靠开屏读文); +- 成品结算屏 = 天然分享图(本局烧成的三件套摆开); +- 「同款」钩子:主题(哪门手艺)+ 玩法原型(工序节拍/图样拼合)是清晰的品类元数据,remix 换一门手艺即一款新游戏。 + +--- + +## 8. 设计阶段输出配方(生成 agent 据 brief 产出这些 → 喂代码生成) + +给定一句话 brief,设计阶段应产出一份**结构化玩法设计**,**逐项落到运行时**: + +1. **核心循环**(一句话 + 30 秒脚本:玩家做什么→得什么反馈→怎么变强)+ **玩法化路径**(从 §1 选:工序节拍/图样拼合/节律配合…); +2. **工序链/图谱定义**(道数、每道的名字与分值——名字用该门手艺的通识工序词); +3. **双层奖励**(基础分 + 技巧分/正窗暴击,§3); +4. **节奏目标值**(就绪间隔/窗口/回炉/一局时长/期望成品数 —— 按 §3 区间给具体数,交 core.js); +5. **成长轴**(3–4 级解锁:新作品/新纹样/新工序 + 阈值); +6. **美术风格词**(一组,全资产共用)+ 资产清单(标 mmx 或程序化); +7. **音效清单**(完成/暴击/成品/回炉 + BGM 情绪); +8. **UI 布局**(HUD/工位/窗口条/图鉴/结算,§5); +9. **首 3 分钟脚本**(上手→首件成品→露出后续锁)。 + +> **MVP-first 铁律(关乎 code agent 能否收敛)**:首版**只做可玩核心**——1 条工序链(4–6 道)+ 双层奖励 + 3–4 级解锁 + 基础数值/音效,**作品种类 ≤3 种起步**。**多套图鉴 / 订单系统 / 离线收益 / 广告位 / 多档 BGM 一律标「后续·MVP 不做」**,别一稿堆满(设计太满 → code agent read-thrash 不收敛)。**先出能玩的核心,再谈丰富。** + +> 这份设计 = 代码生成阶段的"图纸";生成 agent 照 littlejs-game-dev 把它实现为多文件代码。需要完整过门范例时 read_file `game-runtime/games/_fewshot-feiyi/src/game-logic.js`(本品类 few-shot 正例:过九门 + 丰富度参照)。 + +--- + +## 9. 可达性红线(落不到运行时的设计 = 废稿,必剔除/简化) + +- ❌ 联网协作 / 排行榜社交 → 单机 + 本地最佳(session-score best)。 +- ❌ 大地图寻访 / 多村落流程 → 单/少场景竖屏(scene-fsm 切 3–5 态)。 +- ❌ 3D 拉坯 / 自由手势雕刻(连续轨迹判定)→ 2D 点击 + 程序化动效;手感靠窗口时机与 juice,不靠手势精度。 +- ❌ 知识问答墙 / 长文化说明打断玩法 → 文化经工序名/器物名/氛围体感传达,玩法优先。 +- ❌ 超过 2 个主机制 → 聚焦 1 条主循环 + 1 成长轴。 +- ❌ **核心动作做成「先选工具再选工位」两步**:零-LLM 自动验收 driver 只会单击 `occupied:true` 目标、跟不动两步 → 九门 H 验不到进展 = 好游戏被误判 → ✅ **核心动作单击即完成当前工序**(就绪工位 occupied:true,点它 = 该道完成);确需多步,则 `_forensicsView().state().targets` 必须随状态把"下一步该点的"标 occupied:true,让 driver 跟着推进。 +- ❌ **文化表述红线**:工序、器物、民俗意象用**通识**表述(制陶有塑形上釉、织造有经纬提花这一层常识粒度);**不编造具体非遗项目的失实细节**——不虚构某项目的历史年代/传承人/名录地位/地域归属,不使用"官方认证""名录在册"这类可能失实的措辞;brief 点名了具体项目时,只用其公知常识、拿不准的细节一律泛化成通识写法。 +- ✅ 留下的:工序节拍、图样拼合、节律配合、收集图鉴、技艺解锁、轻工坊经营 —— 全在 11 注入插件 + 单屏 + 便宜模型可生成范围内。 + +--- + +## 10. 反"贴皮/无趣"落地清单(design 自检,逐条必过) + +通用 8 条好玩自检(即时反馈/可见成长/下一个解锁/30 秒爽点/数值滚雪球/情感锚/放置回归/音反馈)见 [`sim-business-game-design.md`](sim-business-game-design.md) §10 —— **对所有品类通用,本品类照过**(成长轴按 §1 的申报映射:工艺步骤解锁;情感锚落在器物上,§4)。此外本品类另过 5 条品类扩展(与品类 rubric 同源,见 §11): + +1. **工序链成立**:玩法是不是围绕一条有名字、有先后的工序链推进?(乱点无序 = 没有"手艺"体感) +2. **节律张力**:有没有"在对的时刻出手"的窗口与回报?(永远可点、何时点都一样 = 无手感) +3. **技艺成长可见**:有没有"会做的东西变多/变难/变值钱"的解锁线? +4. **作品可展示**:成品有没有被"摆出来"的时刻(图鉴/结算展示)? +5. **文化氛围一致**:把主题词全换成现代词,玩法画面是否就散了?(散了 = 氛围真在承载;毫发无损 = 贴皮) + +--- + +## 11. 品类 rubric(丰富度品类扩展 · 喂 LLM 验证 agent · 非阻塞) + +对齐质量模型 SoT([`游戏质量与爆火能力`](../../docs/architecture/架构/生成引擎/游戏质量与爆火能力.md))§4 计分制:每条 0/1 + 层标,与通用底座 v2(11 条)**分母分开、独立小计**;纯 LLM 评分、不进九门、不进脚手架、不拦发布。机器可读同源文件(评分尺数据,含锚定值)= [`cheap-worker/fixtures/genre-rubrics/heritage.rubric.json`](../../cheap-worker/fixtures/genre-rubrics/heritage.rubric.json);金标锚定纪律(≥1 正例 + ≥1 薄反例、评分尺变更须金标复验、单款分组小计漂移 >±1 回退)照 SoT §4 规范三执行。 + +| # | 层 | 判据(0/1) | 正例 | 反例 | +|---|---|---|---|---| +| H1 | L2 | **工序链成立**:玩法围绕一条 ≥3 道、有名字、有先后依赖的工序链推进,依序完成才出成品 | 选料→塑形→上彩→出窑依序点亮,漏一道不出成品 | 屏上随机冒目标,点哪个都一样加分 | +| H2 | L2 | **节律张力**:工序有时机窗口(火候/节拍),窗口内操作有更高回报、错过有代价(回炉/断连击),且不判死 | 正窗出手暴击 ×2、过窗回炉重做该道 | 目标永远可点,任何时刻点效果完全一样 | +| H3 | L3 | **技艺成长可见**:随进度解锁新作品/新纹样/新工序(本品类成长轴),任意时刻可见下一个锁 | 攒够 60 工钱解锁「刻花盘」,图鉴里下一格挂锁预告 | 从头到尾只做同一件作品,无任何解锁预告 | +| H4 | L4 | **作品可展示**:成品进图鉴/结算屏有成品展示画面(可截图的文化炫耀时刻) | 结算屏摆出本局做成的三件器物 + 名字 + 工钱 | 到点只弹一行「得分 120」文字即黑屏 | +| H5 | L2 | **文化氛围一致**:主题、工序命名、配色、音效构成一致的传统氛围(通识表述),主题词换成现代词玩法画面即散 | 陶坊主题 + 陶土色系 + 塑形/上釉工序名 + 民乐感音效 | 标题带"非遗"但画面霓虹方块、工序叫 step1/step2 | + +分组小计:品类扩展 L2 ×3(H1/H2/H5)/ L3 ×1(H3)/ L4 ×1(H4),分母 5,与通用底座 11 条分开计。逐条长期不成立 = 本 skill 或品类骨架的改进信号(SoT §4 规范六)。 + +--- + +## 相关 + +- 代码层实现 → [`littlejs-game-dev.md`](littlejs-game-dev.md)(11 插件 API / 结构 / 资产-mmx / 工厂契约) +- 经营品类对应手册(体例同源;工坊经营复合玩法时参照其资源环范式)→ [`sim-business-game-design.md`](sim-business-game-design.md) +- 品类骨架(工序节拍循环,轻起点)→ `game-runtime/games/_template-feiyi/` · few-shot 过门正例 → `game-runtime/games/_fewshot-feiyi/` +- 便宜模型生成 worker / 九门 → [`cheap-model-game-generation.md`](cheap-model-game-generation.md) diff --git a/cheap-worker/cheap_roles.py b/cheap-worker/cheap_roles.py index 4c7effe9..d99d2626 100644 --- a/cheap-worker/cheap_roles.py +++ b/cheap-worker/cheap_roles.py @@ -49,6 +49,7 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款** 3. 用到某插件就 read_file 它的 api.d.ts(如 game-runtime/src/plugins/scene-fsm/api.d.ts、.../session-score/api.d.ts)看精确签名。 4. 你的起点 ⟦G⟧/src/game-logic.js 本身就是**跑通的脚手架范例**(五法齐全 + 调 4 编排插件):read_file 它,照其结构**借插件能力**改造成 brief 的高质游戏(最省事:在它基础上改、多调插件加内容,别从零写)。 5. **若 brief 属经营 / 养成 / 放置 / 点客这类「攒资源→成长→解锁」品类**:read_file('.agents/skills/sim-business-game-design.md') 取「设计什么才好玩」的范式(6 心理引擎 / §1 玩法范式含进货补货环 / §8 设计配方 / §10 反无趣 8 条自检)。littlejs-game-dev 教你「代码怎么写」、sim-business 教你「设计什么才好玩」——配对用:先想清好玩、再写正确。 +6. **若 brief 属非遗 / 传统技艺 / 民俗这类「传统文化玩法化」品类**:read_file('.agents/skills/heritage-game-design.md') 取「设计什么才好玩」的范式(§1 玩法化范式含工序节拍 / §3 双层奖励与工序节奏目标值 / §9 文化表述红线 / §10 反贴皮自检);需要完整过门范例再 read_file('game-runtime/games/_fewshot-feiyi/src/game-logic.js')。 【先设计后写码:决定这游戏好不好玩(关键,别跳过)】 能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现: diff --git a/cheap-worker/fixtures/genre-rubrics/heritage.rubric.json b/cheap-worker/fixtures/genre-rubrics/heritage.rubric.json new file mode 100644 index 00000000..65e4281a --- /dev/null +++ b/cheap-worker/fixtures/genre-rubrics/heritage.rubric.json @@ -0,0 +1,52 @@ +{ + "_genre": "heritage", + "_note": "非遗品类丰富度 rubric(品类扩展,W-GENRE 四件套之④)。喂 LLM 验证 agent 的评分尺数据、非代码校验(质量模型 SoT §4 规范四红线);每条 0/1 + 层标,与通用底座 v2(11 条,cheap_verify.RICHNESS_CHECKLIST)分母分开、独立小计(SoT §4 规范二)。entries 字段形状 = (name, layer, meaning) 对齐 RICHNESS_CHECKLIST 元组 + 正反例(SoT §4 规范一最小形状)。散文同源 = .agents/skills/heritage-game-design.md §11(条目一字对齐,改一处必同改另一处并金标复验)。", + "entries": [ + { + "id": "H1", + "name": "工序链成立", + "layer": "L2", + "meaning": "玩法围绕一条 ≥3 道、有名字、有先后依赖的工序链推进,依序完成才出成品", + "positive": "选料→塑形→上彩→出窑依序点亮,漏一道不出成品", + "negative": "屏上随机冒目标,点哪个都一样加分" + }, + { + "id": "H2", + "name": "节律张力", + "layer": "L2", + "meaning": "工序有时机窗口(火候/节拍),窗口内操作有更高回报、错过有代价(回炉/断连击),且不判死", + "positive": "正窗出手暴击 ×2、过窗回炉重做该道", + "negative": "目标永远可点,任何时刻点效果完全一样" + }, + { + "id": "H3", + "name": "技艺成长可见", + "layer": "L3", + "meaning": "随进度解锁新作品/新纹样/新工序(本品类成长轴),任意时刻可见下一个锁", + "positive": "攒够 60 工钱解锁「刻花盘」,图鉴里下一格挂锁预告", + "negative": "从头到尾只做同一件作品,无任何解锁预告" + }, + { + "id": "H4", + "name": "作品可展示", + "layer": "L4", + "meaning": "成品进图鉴/结算屏有成品展示画面(可截图的文化炫耀时刻)", + "positive": "结算屏摆出本局做成的三件器物 + 名字 + 工钱", + "negative": "到点只弹一行「得分 120」文字即黑屏" + }, + { + "id": "H5", + "name": "文化氛围一致", + "layer": "L2", + "meaning": "主题、工序命名、配色、音效构成一致的传统氛围(通识表述),主题词换成现代词玩法画面即散", + "positive": "陶坊主题 + 陶土色系 + 塑形/上釉工序名 + 民乐感音效", + "negative": "标题带「非遗」但画面霓虹方块、工序叫 step1/step2" + } + ], + "groupMaxes": { "L2": 3, "L3": 1, "L4": 1 }, + "anchors": { + "_note": "金标锚定(SoT §4 规范三:≥1 正例 + ≥1 薄反例;rubric 文本/评分 prompt 变更须金标复验,单款分组小计漂移 >±1 回退)。待首次金标复验回填。", + "positive": null, + "thinNegative": null + } +} diff --git a/cheap-worker/fixtures/golden-specs/heritage-craft.play-spec.json b/cheap-worker/fixtures/golden-specs/heritage-craft.play-spec.json new file mode 100644 index 00000000..3d24a1ab --- /dev/null +++ b/cheap-worker/fixtures/golden-specs/heritage-craft.play-spec.json @@ -0,0 +1,17 @@ +{ + "_genre": "heritage-craft", + "_baseSample": "_template-feiyi", + "_note": "金标 play-spec(对照 fixtures)。非遗工序节拍:当前就绪工序的工位 occupied=true,点击完成该道加工钱(score 递增),整条链走完出成品。formalize _template-feiyi 品类骨架独立过九门时验证的 occupied spec。同属 tap-targets occupied 反应族 + score 递增,不用货币递减门(骨架无消费扣减;生成款若含进货/耗材再由 per-game gatespec 加 currency decreased 断言)。只依赖 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": "窗口内点就绪工位完成工序,工钱(score)应上升;工序不就绪/命中坏/计分坏则不升" } + ], + "expectLatch": true +} diff --git a/cheap-worker/tests/test_golden_specs.py b/cheap-worker/tests/test_golden_specs.py index f326076e..07e8923a 100644 --- a/cheap-worker/tests/test_golden_specs.py +++ b/cheap-worker/tests/test_golden_specs.py @@ -13,8 +13,9 @@ from pathlib import Path _FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "golden-specs" -# 三个 tap-targets 代表品类(plan KTD2):点击得分/打地鼠/经营点客,各 formalize base1/base2/base4 的 occupied spec。 -_EXPECTED_GENRES = {"click-score", "whack-mole", "shop-serve"} +# tap-targets 代表品类(plan KTD2 三类 + W-GENRE 品类件随四件套登记):点击得分/打地鼠/经营点客 formalize +# base1/base2/base4 的 occupied spec;非遗工序节拍(heritage-craft)formalize _template-feiyi 骨架独立过九门的 spec。 +_EXPECTED_GENRES = {"click-score", "whack-mole", "shop-serve", "heritage-craft"} # driver 只许这些键 —— 全是与具体游戏无关的契约级参数;出现坐标类键(x/y/coords/points)即"绑定具体游戏"违规。 _ALLOWED_DRIVER_KEYS = {"type", "targetMode", "targetsPath", "steps", "stepMs"} _FORBIDDEN_COORD_KEYS = {"x", "y", "coords", "points", "positions", "targets"} @@ -30,7 +31,7 @@ def _load_specs() -> dict: def test_three_genres_present(): - """恰好三个 tap-targets 代表品类(无遗漏、无多余)。""" + """登记的 tap-targets 代表品类恰好齐(无遗漏、无多余;新品类随 W-GENRE 四件套登记入 _EXPECTED_GENRES)。""" specs = _load_specs() assert set(specs) == _EXPECTED_GENRES, f"品类集不符:{set(specs)} != {_EXPECTED_GENRES}" diff --git a/contracts/prompts/04-config/cheap-system.md b/contracts/prompts/04-config/cheap-system.md index fad75b0b..1d414c3f 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.0.0 +version: 1.1.0 --- 你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩、有内容、不易同质化的高质小游戏**(多文件 LittleJS),落在 ⟦G⟧/。 @@ -19,6 +19,7 @@ version: 1.0.0 3. 用到某插件就 read_file 它的 api.d.ts(如 game-runtime/src/plugins/scene-fsm/api.d.ts、.../session-score/api.d.ts)看精确签名。 4. 你的起点 ⟦G⟧/src/game-logic.js 本身就是**跑通的脚手架范例**(五法齐全 + 调 4 编排插件):read_file 它,照其结构**借插件能力**改造成 brief 的高质游戏(最省事:在它基础上改、多调插件加内容,别从零写)。 5. **若 brief 属经营 / 养成 / 放置 / 点客这类「攒资源→成长→解锁」品类**:read_file('.agents/skills/sim-business-game-design.md') 取「设计什么才好玩」的范式(6 心理引擎 / §1 玩法范式含进货补货环 / §8 设计配方 / §10 反无趣 8 条自检)。littlejs-game-dev 教你「代码怎么写」、sim-business 教你「设计什么才好玩」——配对用:先想清好玩、再写正确。 +6. **若 brief 属非遗 / 传统技艺 / 民俗这类「传统文化玩法化」品类**:read_file('.agents/skills/heritage-game-design.md') 取「设计什么才好玩」的范式(§1 玩法化范式含工序节拍 / §3 双层奖励与工序节奏目标值 / §9 文化表述红线 / §10 反贴皮自检);需要完整过门范例再 read_file('game-runtime/games/_fewshot-feiyi/src/game-logic.js')。 【先设计后写码:决定这游戏好不好玩(关键,别跳过)】 能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现: diff --git a/contracts/prompts/registry.yaml b/contracts/prompts/registry.yaml index 73161798..aa86d223 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.0.0 + version: 1.1.0 # 1.1.0:W-GENRE 非遗品类件——先读手册加第 6 步品类路由(heritage-game-design skill + _fewshot-feiyi 正例指针) stage: "04-config" owner: WS2 file: 04-config/cheap-system.md diff --git a/game-runtime/games/_template-feiyi/README.md b/game-runtime/games/_template-feiyi/README.md new file mode 100644 index 00000000..c213ee3e --- /dev/null +++ b/game-runtime/games/_template-feiyi/README.md @@ -0,0 +1,53 @@ +# _template-feiyi — A-model「非遗工序节拍」品类骨架(3 层架构) + +一个**可玩的完整工序游戏**:一件作品 = 一条有先后的工序链,工序依次「就绪」→ 在火候窗口内点击就绪工位完成该道 → 整条链走完出一件成品 → 错过窗口回炉重做 → 60 秒限时开工结算(menu→play→over)。这份骨架**已经把整套工序循环接好了**(含让自动验收能跑通的取证契约)——你的活是按 brief **换手艺**,不是重写循环。 + +**怎么改 = 给这间工坊换一门手艺,循环结构别动:** +- 换手艺/工序/作品/难度 → 改 `src/core.js` 顶部的「多样性参数空间」(`CRAFT_CHAIN` 工序链、`WORKS` 作品表、就绪间隔/窗口/回炉常量)。制陶写选料/塑形/上彩,织造写纺线/上机/织纹,灯彩写扎骨/糊面/点灯——**同骨架做出不雷同的手艺**就靠这里。工序名用该门手艺的**通识**工序词,别编造具体非遗项目的失实细节(设计手册 §9 红线)。 +- 换观感(案台布景/工位长相/配色)→ 改 `src/render.js`。 +- 想加机制(正窗暴击 combo/解锁新作品/图鉴)→ 在 `src/game-logic.js` 循环里加,但**别破坏 `_forensicsView` 的 `targets`/`score` 契约**(改坏=九门 H 进展门直接挂)。 +- **别推倒重写整个循环**:工序就绪/火候窗口/回炉/出成品/限时结算都接好了,从它起步只补身份,远比从零写省。 +- 玩法设计先读 [.agents/skills/heritage-game-design.md](../../../.agents/skills/heritage-game-design.md)(这个品类什么好玩:玩法化范式/节奏目标值/双层奖励/文化表述红线)。 + +## 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/gamefeel 等 onFrame 不推进、timer 永不到期、一局不结束。**`dt` 是秒**:游戏内毫秒计时要 `elapsedMs += dt*1000`;`timerScheduler.after(ms)` 收毫秒(60s 写 60000)。 +- **零引擎 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`(自动验收只点这些工位驱动工序);`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 速查 + ❌→✅ 易犯错)。 diff --git a/game-runtime/games/_template-feiyi/assets/gen-ledger.md b/game-runtime/games/_template-feiyi/assets/gen-ledger.md new file mode 100644 index 00000000..5e906546 --- /dev/null +++ b/game-runtime/games/_template-feiyi/assets/gen-ledger.md @@ -0,0 +1,24 @@ +# 资产生成台账(gen-ledger) — _template-feiyi「工序节拍」 + +> 本文件登记 **mmx 生成的素材生成历史**(prompt / 字节 / hash / 替换记录)——改素材时的索引。 +> 本模板无外部美术/音频(纯程序化图形),故台账为空(stub)。 + +## 用法(加美术时) + +1. 用 mmx 生成素材,产物存 `assets/`(`.jpg` 美术、`.mp3` 音乐音效): + ```bash + # 美术(模型 image-01):<主体> + 统一风格词 + 纯色背景,居中,简洁 + mmx image generate --prompt "..." --out kiln-glow.jpg + # 音乐(模型 music-2.6-free):<情绪/配器/场景> + mmx music generate --prompt "轻快民乐,竹笛与古筝,手工坊的温暖节奏" --instrumental --bpm 96 --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-feiyi/assets/manifest.json b/game-runtime/games/_template-feiyi/assets/manifest.json new file mode 100644 index 00000000..c0fefd57 --- /dev/null +++ b/game-runtime/games/_template-feiyi/assets/manifest.json @@ -0,0 +1,6 @@ +{ + "game": "_template", + "note": "资产清单(最小空清单)。本模板「点圆得分」无外部美术/音频,全用程序化图形,故 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-feiyi/entry.js b/game-runtime/games/_template-feiyi/entry.js new file mode 100644 index 00000000..30eddce4 --- /dev/null +++ b/game-runtime/games/_template-feiyi/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-feiyi/index.html b/game-runtime/games/_template-feiyi/index.html new file mode 100644 index 00000000..9534878e --- /dev/null +++ b/game-runtime/games/_template-feiyi/index.html @@ -0,0 +1,100 @@ + + + + + + + + 点圆得分 + + + +
+
booting…
+ +
+ + + + + + diff --git a/game-runtime/games/_template-feiyi/src/assets.js b/game-runtime/games/_template-feiyi/src/assets.js new file mode 100644 index 00000000..26e483c5 --- /dev/null +++ b/game-runtime/games/_template-feiyi/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-feiyi/src/core.js b/game-runtime/games/_template-feiyi/src/core.js new file mode 100644 index 00000000..9cda8b5a --- /dev/null +++ b/game-runtime/games/_template-feiyi/src/core.js @@ -0,0 +1,153 @@ +/** + * core.js — 【L3 纯逻辑】非遗工序节拍品类 · 品类骨架的玩法数值与判定 + * + * 这是「非遗工序节拍」品类脚手架:一件作品 = 一条有先后的工序链,工序依次「就绪」, + * 玩家在火候窗口内点击就绪工位完成该道工序,整条链走完出一件成品;错过窗口回炉重做。 + * 本文件是它的**纯逻辑层**——工位几何、工序就绪节奏、火候窗口、命中判定,全是无副作用 + * 纯函数(随机经入参 rng、零 Math.random/Date.now)。 + * + * ┌─ 这一档怎么用这份骨架 ────────────────────────────────────────────────────┐ + * │ 工序循环的**结构**(就绪→窗口内点击→下一道→成品→新作品)已由 game-logic.js 接好, │ + * │ 你(AI)一般不动循环骨架;要把它做成 brief 要的那门手艺,改的是**下面的工序链与常量**—— │ + * │ 这就是「同品类多款不雷同」的参数空间:换手艺(制陶/织造/灯彩/木作/纸艺…)就换 │ + * │ CRAFT_CHAIN 的工序名/颜色/分值 + WORKS 的作品名,调难度就改就绪间隔/窗口/回炉。 │ + * │ 结构不变、身份全变。工序名用该门手艺的**通识**工序词,别编造具体非遗项目的失实细节。 │ + * └──────────────────────────────────────────────────────────────────────────┘ + * + * 作品字段(公开契约,render / game-logic / 取证共用): + * work = { id, name, bonus, stepIndex, stepState:'crafting'|'ready', stateSinceMs, windowMs, craftMs } + */ + +'use strict'; + +/* ═══════════════════════════════════════════════════════════════════════════ + * 【多样性参数空间 · AI 按 brief 改这些做出不雷同的手艺】 + * 下面整段都是「玩法身份」:换手艺/调难度就改这里,工序循环结构不受影响。 + * ═══════════════════════════════════════════════════════════════════════════ */ + +/** 一局开工时长(ms)。限时类,到点收工结算。改它调节奏(45s 紧张 / 90s 悠闲)。 */ +export const ROUND_MS = 60000; + +/** + * 工序链(玩法身份的核心多样性源):每道 = 名字 + 颜色 + 完成分。**至少 3 道**、有先后。 + * 换手艺就换这张表——制陶写「选料/塑形/晾坯/上彩/出窑」,织造写「纺线/上机/织纹/收边」, + * 灯彩写「扎骨/糊面/绘彩/点灯」。名字用通识工序词;颜色用于程序化绘制(render 读 step.color)。 + * 分值建议后道高于前道(越接近成品越值钱,§3 工钱曲线)。 + */ +export const CRAFT_CHAIN = [ + { name: '选料', color: '#a8794f', points: 4 }, + { name: '塑形', color: '#c98860', points: 6 }, + { name: '晾坯', color: '#d9b38c', points: 8 }, + { name: '上彩', color: '#4f7fa8', points: 10 }, + { name: '出窑', color: '#e07840', points: 12 }, +]; + +/** + * 作品表(第二多样性源):做成一件从中轮换,bonus = 成品奖励分(≈单道分的 3–5 倍)。 + * 换手艺就换作品名(织造=「云纹帕/如意结」、灯彩=「莲花灯/走马灯」…)。至少留 3 件,收集有差异。 + */ +export const WORKS = [ + { name: '陶碗', bonus: 24 }, + { name: '花瓶', bonus: 30 }, + { name: '刻花盘', bonus: 36 }, +]; + +/** 工序就绪间隔区间(ms):上一道完成后,下一道多久「就绪」。越小越忙(§3 目标值 0.6–1.0s)。 */ +export const CRAFT_MIN_MS = 600; +export const CRAFT_MAX_MS = 1000; +/** 开局首道工序就绪延迟(ms):留一拍让玩家看清案台。 */ +export const FIRST_READY_DELAY_MS = 700; + +/** 火候窗口区间(ms):工序就绪后须在窗口内点掉,过窗回炉(§3 目标值 5–8s,不判死)。 */ +export const WINDOW_MIN_MS = 5000; +export const WINDOW_MAX_MS = 8000; + +/** 回炉冷却(ms):过窗后该道重新加工的耗时(损时间不清进度)。 */ +export const REDO_MS = 1000; + +/** 工位点按半径(手指友好,别小于 ~44)。 */ +export const STATION_TAP_RADIUS = 56; + +/** 工位布局比例(读 viewport 算具体像素,别硬编码 390×844):案台两行排开。 */ +export const STATION_ROW_Y_RATIO = [0.56, 0.74]; +export const STATION_MARGIN_X_RATIO = 0.18; + +/* ═══════════════════════════════════════════════════════════════════════════ + * 【工序循环判定 · 结构层 · 一般不动】 + * 下面是「工序何时就绪、何时过窗、点哪算命中」的纯算式。换手艺不必改这里。 + * ═══════════════════════════════════════════════════════════════════════════ */ + +/** 受控随机区间 [lo,hi):入参 rng 走 ctx.random,纯确定性。 */ +function rngRange(rng, lo, hi) { return lo + rng() * (hi - lo); } + +/** + * 算工位中心点(每道工序一个工位;读 viewport,不硬编码)。 + * 按 STATION_ROW_Y_RATIO 分两行、每行均匀铺开。 + * @returns {{x:number,y:number,station:number}[]} 与 CRAFT_CHAIN 等长 + */ +export function computeStations(viewW, viewH, count) { + const n = count == null ? CRAFT_CHAIN.length : count; + const rows = STATION_ROW_Y_RATIO.length; + const perRow = Math.ceil(n / rows); + const left = viewW * STATION_MARGIN_X_RATIO; + const right = viewW * (1 - STATION_MARGIN_X_RATIO); + const out = []; + for (let i = 0; i < n; i++) { + const row = Math.floor(i / perRow); + const col = i % perRow; + const inRow = Math.min(perRow, n - row * perRow); + const x = inRow === 1 ? (left + right) / 2 : left + (col * (right - left)) / (inRow - 1); + out.push({ station: i, x: Math.round(x), y: Math.round(viewH * STATION_ROW_Y_RATIO[Math.min(row, rows - 1)]) }); + } + return out; +} + +/** 开一件新作品(从 WORKS 轮换取,首道进入加工态)。 */ +export function makeWork(rng, nowMs, idSeq, firstDelayMs) { + const w = WORKS[idSeq % WORKS.length]; + return { + id: idSeq, + name: w.name, + bonus: w.bonus, + stepIndex: 0, + stepState: 'crafting', // crafting=加工中(未就绪) | ready=就绪(火候窗口内可点) + stateSinceMs: nowMs, + craftMs: firstDelayMs != null ? firstDelayMs : Math.round(rngRange(rng, CRAFT_MIN_MS, CRAFT_MAX_MS)), + windowMs: Math.round(rngRange(rng, WINDOW_MIN_MS, WINDOW_MAX_MS)), + }; +} + +/** 加工态是否已到就绪时刻(crafting 满 craftMs → 该转 ready)。 */ +export function shouldBecomeReady(work, nowMs) { + return !!work && work.stepState === 'crafting' && (nowMs - work.stateSinceMs) >= work.craftMs; +} + +/** 就绪态是否已过火候窗口(ready 超 windowMs → 该回炉)。 */ +export function isWindowExpired(work, nowMs) { + return !!work && work.stepState === 'ready' && (nowMs - work.stateSinceMs) > work.windowMs; +} + +/** 火候窗口剩余比例 [0,1](1=刚就绪,0=已过窗;非 ready 返 0)。nowMs 必传,实时算。 */ +export function windowRatio(work, nowMs) { + if (!work || work.stepState !== 'ready' || !work.windowMs) return 0; + const waited = nowMs - work.stateSinceMs; + return Math.max(0, Math.min(1, (work.windowMs - waited) / work.windowMs)); +} + +/** 当前工序此刻是否可点(= ready 且窗口未过)。 */ +export function isStepTappable(work, nowMs) { + return !!work && work.stepState === 'ready' && (nowMs - work.stateSinceMs) <= work.windowMs; +} + +/** 点 (px,py) 是否命中工位(圆心 + 半径)。 */ +export function hitStation(px, py, st) { + if (!st) return false; + const dx = px - st.x, dy = py - st.y; + return dx * dx + dy * dy <= STATION_TAP_RADIUS * STATION_TAP_RADIUS; +} + +/** 本道工序完成分(读 CRAFT_CHAIN;越界容错返 0)。 */ +export function stepPoints(stepIndex) { + const s = CRAFT_CHAIN[stepIndex]; + return s ? s.points : 0; +} diff --git a/game-runtime/games/_template-feiyi/src/game-logic.js b/game-runtime/games/_template-feiyi/src/game-logic.js new file mode 100644 index 00000000..ceba5889 --- /dev/null +++ b/game-runtime/games/_template-feiyi/src/game-logic.js @@ -0,0 +1,301 @@ +/** + * game-logic.js — 【L3 游戏本体】非遗工序节拍品类 · 品类骨架 + * owner:生成 agent(从本「工序节拍」骨架起步改造)| 消费方:game.js wrapper(L1)调本文件的 createGame。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【这份骨架已经替你接好了一整套「工序节拍」循环——你的活是给它换手艺、不是重写】 + * + * 已接好(结构层 · 一般别动): + * · 一件作品 = 一条工序链(core.CRAFT_CHAIN),工序依次「就绪」(加工满 craftMs 转 ready) + * · 点击就绪工位 = 完成该道工序:加工钱(sessionScore.add)+ 粒子(juice.burst)+ 音效 + 飘字 + * · 火候窗口(ready 超 windowMs)过窗回炉:该道重做、损时间不清进度 + 轻屏震 + * · 整条链走完 = 出一件成品:成品奖励 + 庆祝,自动开下一件(WORKS 轮换) + * · 60 秒限时开工 → 到点收工结算(sceneFsm 切 over,latch 终态) + * · _forensicsView 的 targets/score 契约(自动验收靠它驱动你的游戏,**改坏它=九门 H 直接挂**) + * + * 你按 brief 改(身份层 · 多样性源,做出不雷同的手艺): + * · 换手艺/工序/作品/难度 → 改 core.js 顶部的多样性参数空间(CRAFT_CHAIN、WORKS、节奏常量) + * · 画面观感(案台长啥样、工位长啥样、配色)→ 改 render.js + * · 想加机制(正窗暴击 combo、解锁新作品、图鉴)→ 在循环里加,但**别破坏 targets/score 契约** + * + * 入参(L1 已摊平):createGame({ plugins, bundle, viewport });受控面 ctx 在 init(boot) 的 boot.ctx。 + * 返回 GameInstance:init/update/render/destroy + handleTap + _forensicsView。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// 纯逻辑(工位几何 / 工序就绪 / 火候窗口 / 命中):零副作用,可 node 单测。换手艺改 core.js 顶部即可。 +import { + ROUND_MS, CRAFT_CHAIN, FIRST_READY_DELAY_MS, REDO_MS, + computeStations, makeWork, shouldBecomeReady, isWindowExpired, + isStepTappable, hitStation, windowRatio, stepPoints, +} 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'; + +/** 飘字(完成工序 "+N")生命与上飘速度——纯表现,随意调。 */ +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 stations = computeStations(VW, VH); // 工位中心(与 CRAFT_CHAIN 等长;读 viewport,定一次) + let work = null; // 当前在做的作品(makeWork 产) + let floats = []; // 完成工序飘字 + let elapsedMs = 0; // play 内累计毫秒(进 play 时重置) + let nextWorkId = 0; // 作品轮换序号 + let wage = 0; // 当日工钱(= 累计加分,随完成工序上升) + let worksDone = []; // 本局做成的作品名(结算展示 + 取证) + let redos = 0; // 回炉次数(过窗) + let roundTimer = null; // 60s 开工倒计时句柄 + + /* ── 命中区(菜单/结算按钮):render 回调实时算并写回(所见即所点)── */ + let menuStartBtn = null; + let overRestartBtn = null; + + /* ── 取证度量 ── */ + const measures = { frames: 0, taps: 0, steps: 0, works: 0, redos: 0 }; + + /** 受控随机 [0,1):经 boot.ctx.random,绝不 Math.random。 */ + function rng() { return ctx ? ctx.random.next() : 0; } + + /** 进 play:重置开工态、起 60s 倒计时(到点切 over)。 */ + function enterPlay() { + sessionScore.reset(); + floats = []; + elapsedMs = 0; + wage = 0; worksDone = []; redos = 0; + nextWorkId = 0; + work = makeWork(rng, 0, nextWorkId++, FIRST_READY_DELAY_MS); // 首道留一拍让玩家看清案台 + if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } + roundTimer = timerScheduler.after(ROUND_MS, () => { // timerScheduler 收毫秒:60s 写 ROUND_MS=60000 + sessionScore.lose(); + sceneFsm.transition(SCENE_OVER); + if (ctx) ctx.log('scene', 'over 收工结算', { score: sessionScore.getScore(), wage, works: worksDone.length, redos }); + }); + sceneFsm.transition(SCENE_PLAY); + if (ctx) ctx.log('scene', 'play 开工', { roundMs: ROUND_MS, chain: CRAFT_CHAIN.map((s) => s.name).join('→') }); + } + + /** 【输入入口·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) { + // 工序核心:点中「就绪工位」= 完成当前道(1 步成序,自动验收据此涨分)。 + if (!work || !isStepTappable(work, elapsedMs)) return; + const st = stations[work.stepIndex]; + if (st && hitStation(x, y, st)) completeStep(st); + } + } + + /** 完成当前工序:加工钱 + 飘字 + 粒子 + 音效;链走完则出成品并开下一件。 */ + function completeStep(st) { + measures.steps += 1; + const amount = stepPoints(work.stepIndex); + wage += amount; + sessionScore.add(amount); // 计分即工钱(随完成工序单调上升 → 满足 H 进展门) + floats.push({ x: st.x, y: st.y - 70, age: 0, life: FLOAT_LIFE_S, amount }); + if (juice) { try { juice.burst(st.x, st.y - 30); } catch (_) { /* 容错 */ } } // 完成爆点(#10 母语 burst) + if (audioMusic) { try { audioMusic.playSfx('click'); } catch (_) { /* 容错 */ } } // 工序「嗒」(SFX 语义名) + if (ctx) ctx.log('step', '工序完成', { step: CRAFT_CHAIN[work.stepIndex].name, amount, total: wage }); + + if (work.stepIndex + 1 >= CRAFT_CHAIN.length) { + finishWork(); // 整条链走完 → 出成品 + } else { + work.stepIndex += 1; // 下一道进入加工态(craftMs 后就绪) + work.stepState = 'crafting'; + work.stateSinceMs = elapsedMs; + const fresh = makeWork(rng, elapsedMs, work.id); // 只借它抽新的 craft/window 随机值 + work.craftMs = fresh.craftMs; + work.windowMs = fresh.windowMs; + } + } + + /** 出成品:成品奖励 + 庆祝 + 记入当日作品,轮换开下一件。 */ + function finishWork() { + measures.works += 1; + wage += work.bonus; + sessionScore.add(work.bonus); // 成品奖励也走计分(score 契约保持单调上升) + worksDone.push(work.name); + floats.push({ x: VW / 2, y: VH * 0.36, age: 0, life: FLOAT_LIFE_S * 1.4, amount: work.bonus, big: work.name }); + if (juice) { try { juice.burst(VW / 2, VH * 0.4, { count: 24 }); } catch (_) { /* 容错 */ } } + if (audioMusic) { try { audioMusic.playSfx('win'); } catch (_) { /* 容错 */ } } // 出成品欢庆 + if (ctx) ctx.log('work', '成品出炉', { name: work.name, bonus: work.bonus, works: worksDone.length }); + work = makeWork(rng, elapsedMs, nextWorkId++); // 开下一件(WORKS 轮换) + } + + /** 过窗回炉:该道重做(损时间不清进度)+ 轻屏震 + 闷响。 */ + function redoStep() { + redos += 1; measures.redos += 1; + work.stepState = 'crafting'; + work.stateSinceMs = elapsedMs; + work.craftMs = REDO_MS; // 回炉冷却后重新就绪 + if (juice) { try { juice.shakeScreen(0.18, 4); } catch (_) { /* 容错 */ } } + if (audioMusic) { try { audioMusic.playSfx('thud'); } catch (_) { /* 容错 */ } } // 回炉闷响 + if (ctx) ctx.log('redo', '火候过了回炉', { step: CRAFT_CHAIN[work.stepIndex].name, redos }); + } + + /** 工序状态推进:加工满时长 → 就绪;就绪过窗 → 回炉。 */ + function updateWork() { + if (!work) return; + if (shouldBecomeReady(work, elapsedMs)) { + work.stepState = 'ready'; + work.stateSinceMs = elapsedMs; + if (audioMusic) { try { audioMusic.playSfx('blip'); } catch (_) { /* 容错 */ } } // 就绪提示(节拍音) + } else if (isWindowExpired(work, elapsedMs)) { + redoStep(); + } + } + + /** 飘字上飘 + 淡出。 */ + 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); + } + } + + /** 注册三场景到 scene-fsm(每场景各调一次 define;回调 render 只收 g、update 只收 dt)。 */ + function defineScenes() { + sceneFsm.define(SCENE_MENU, { + onEnter() { floats = []; menuStartBtn = null; }, + render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH }).startBtn; }, + }); + sceneFsm.define(SCENE_PLAY, { + render(g) { + renderPlay(g, hudUi, { + vw: VW, vh: VH, stations, work, floats, + wage, works: worksDone.length, nowMs: elapsedMs, + remainMs: Math.max(0, ROUND_MS - elapsedMs), + }); + }, + }); + 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, wage, worksDone, redos, best: sessionScore.getBest(), + }).restartBtn; + }, + }); + } + + /* ════════════ GameInstance 五法 ════════════ */ + const instance = { + async init(b) { + boot = b; + ctx = b.ctx; + assets = await loadAssets('./assets/'); // 缺图不崩(容错返空集) + stations = computeStations(VW, VH); + defineScenes(); + sceneFsm.start(SCENE_MENU); + inited = true; + ctx.log('init', 'feiyi 工坊就绪 menu', { vw: VW, vh: VH, steps: CRAFT_CHAIN.length }); + }, + + 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(漏了=工序永不就绪) + updateWork(); + 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 表示「当前就绪、窗口内可点」的工位——自动验收只点 occupied:true 的工位。 + * · score:= 工钱,随完成工序单调上升——自动验收据它判「真有进展」。 + * 加机制时这两个键的语义务必保持(targets 列可点目标、score 随进展升)。 + */ + _forensicsView() { + return { + state: () => ({ + phase: sceneFsm.current(), + score: sessionScore.getScore(), + best: sessionScore.getBest(), + outcome: sessionScore.getOutcome(), + remainMs: Math.max(0, ROUND_MS - elapsedMs), + wage, + works: worksDone.length, + currentStep: work ? work.stepIndex : -1, + redos, + targets: stations.map((st) => { + const active = !!work && work.stepIndex === st.station; + const tappable = active && isStepTappable(work, elapsedMs); + return { + x: st.x, y: st.y, station: st.station, + occupied: tappable, + windowRatio: active ? windowRatio(work, elapsedMs) : 0, + }; + }), + }), + 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 === 'craftFirst' && cur === SCENE_PLAY) { + // 确定性完成「当前就绪的工序」(等价于点中该工位;未就绪则无操作)。 + if (work && isStepTappable(work, elapsedMs)) { + const st = stations[work.stepIndex]; + if (st) completeStep(st); + } + } + }, + }; + + return instance; +} diff --git a/game-runtime/games/_template-feiyi/src/game.js b/game-runtime/games/_template-feiyi/src/game.js new file mode 100644 index 00000000..28fcd225 --- /dev/null +++ b/game-runtime/games/_template-feiyi/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-feiyi/src/host-config.js b/game-runtime/games/_template-feiyi/src/host-config.js new file mode 100644 index 00000000..ee1683c4 --- /dev/null +++ b/game-runtime/games/_template-feiyi/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-feiyi/src/main.js b/game-runtime/games/_template-feiyi/src/main.js new file mode 100644 index 00000000..ba505a73 --- /dev/null +++ b/game-runtime/games/_template-feiyi/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-feiyi/src/render.js b/game-runtime/games/_template-feiyi/src/render.js new file mode 100644 index 00000000..6062f9a9 --- /dev/null +++ b/game-runtime/games/_template-feiyi/src/render.js @@ -0,0 +1,208 @@ +/** + * render.js — 【L3 表现层】非遗工序节拍品类 · 品类骨架的绘制 + * 一律画在入参 g(= boot.mainContext)。本骨架全程序化绘制(无外部美术)。 + * + * ┌─ 这是「观感层」:做不雷同的手艺,这里随便改 ──────────────────────────────┐ + * │ 换配色(下面 C)、换案台布景(drawBackdrop)、换工位长相(drawStation)都只影响卖相、 │ + * │ 不碰工序循环。想加美术:把图放 assets/manifest.json,init 里读 boot.assets,缺图回退程序化。│ + * │ HUD 与按钮经 hud-ui 插件;**drawButton 返回它绘制的命中矩形,直接接住当命中区**(别再自己另算)。│ + * └────────────────────────────────────────────────────────────────────────────┘ + */ + +'use strict'; + +import { CRAFT_CHAIN } from './core.js'; + +/* ── 配色(玩法身份:换手艺就换这套色;缺省 = 暖木工坊 陶土棕/米白/窑火橙)── */ +const C = { + bgWall: '#4a3a2c', + bgBench: '#33261a', + benchTop: '#6e4f30', + lattice: '#5c4632', + panel: 'rgba(255,255,255,0.08)', + stroke: 'rgba(255,255,255,0.22)', + text: '#f5ead6', + textDim: '#b7a58c', + accent: '#e07840', + accentDark: '#7a3d18', + ready: '#7ed957', + warn: '#e8b23f', + danger: '#e85a4f', + wage: '#ffd34a', + done: '#9adf8f', + pending: 'rgba(255,255,255,0.28)', +}; + +/* ────────── 公共绘制零件(观感层,随意改)────────── */ + +/** 铺底:土墙 + 木格窗 + 案台。 */ +function drawBackdrop(g, VW, VH) { + g.fillStyle = C.bgWall; g.fillRect(0, 0, VW, VH * 0.46); + g.fillStyle = C.bgBench; g.fillRect(0, VH * 0.46, VW, VH * 0.54); + g.fillStyle = '#241a10'; g.fillRect(0, 0, VW, 6); + // 两扇木格窗(纯几何,给"坊"的体感) + const winY = 64, winH = 110, winW = 88; + for (let i = 0; i < 2; i++) { + const wx = VW * (0.26 + i * 0.48) - winW / 2; + g.fillStyle = '#20160d'; g.fillRect(wx, winY, winW, winH); + g.strokeStyle = C.lattice; g.lineWidth = 3; g.strokeRect(wx, winY, winW, winH); + g.beginPath(); + for (let k = 1; k <= 2; k++) { + g.moveTo(wx + (winW / 3) * k, winY); g.lineTo(wx + (winW / 3) * k, winY + winH); + g.moveTo(wx, winY + (winH / 3) * k); g.lineTo(wx + winW, winY + (winH / 3) * k); + } + g.stroke(); + } + // 案台面(两条横案,工位坐其上) + g.fillStyle = C.benchTop; + g.fillRect(VW * 0.08, VH * 0.585, VW * 0.84, 14); + g.fillRect(VW * 0.08, VH * 0.765, VW * 0.84, 14); + // 匾额(店招底,menu 标题叠其上) + g.fillStyle = C.accent; g.fillRect(VW * 0.5 - 90, 26, 180, 36); + g.strokeStyle = C.accentDark; g.lineWidth = 3; g.strokeRect(VW * 0.5 - 90, 26, 180, 36); +} + +/** + * 一个工位(台座 + 工序物件 + 状态圈): + * state: 'done'(已完成·打勾色)| 'ready'(就绪·高亮 + 窗口条)| 'crafting'(加工中·呼吸圈)| 'pending'(未到·置灰) + */ +function drawStation(g, st, step, state, ratio) { + // 台座 + g.beginPath(); g.ellipse(st.x, st.y + 34, 40, 10, 0, 0, Math.PI * 2); + g.fillStyle = 'rgba(0,0,0,0.35)'; g.fill(); + // 工序物件(圆件,颜色 = 该道工序色;pending 置灰) + const bodyColor = state === 'pending' ? '#5a4a3a' : step.color; + g.beginPath(); g.arc(st.x, st.y, 26, 0, Math.PI * 2); + g.fillStyle = bodyColor; g.fill(); + g.strokeStyle = '#241a10'; g.lineWidth = 2.5; g.stroke(); + // 状态圈 + if (state === 'ready') { + g.beginPath(); g.arc(st.x, st.y, 36, 0, Math.PI * 2); + g.strokeStyle = C.ready; g.lineWidth = 4; g.stroke(); + // 火候窗口条(所剩窗口一眼可见) + const barW = 62, barH = 8, bx = st.x - barW / 2, by = st.y - 56; + g.fillStyle = 'rgba(0,0,0,0.5)'; g.fillRect(bx, by, barW, barH); + g.fillStyle = ratio > 0.5 ? C.ready : (ratio > 0.25 ? C.warn : C.danger); + g.fillRect(bx, by, barW * ratio, barH); + g.strokeStyle = '#241a10'; g.lineWidth = 1; g.strokeRect(bx, by, barW, barH); + } else if (state === 'crafting') { + g.setLineDash([5, 5]); g.beginPath(); g.arc(st.x, st.y, 34, 0, Math.PI * 2); + g.strokeStyle = C.warn; g.lineWidth = 3; g.stroke(); g.setLineDash([]); + } else if (state === 'done') { + g.beginPath(); g.arc(st.x, st.y, 32, 0, Math.PI * 2); + g.strokeStyle = C.done; g.lineWidth = 3; g.stroke(); + } else { + g.setLineDash([3, 5]); g.beginPath(); g.arc(st.x, st.y, 32, 0, Math.PI * 2); + g.strokeStyle = C.pending; g.lineWidth = 2; g.stroke(); g.setLineDash([]); + } + // 工序名 + g.font = '600 14px sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; + g.fillStyle = state === 'pending' ? C.textDim : C.text; + g.fillText(step.name, st.x, st.y + 52); +} + +/** 顶部 HUD:工钱 + 成品数 + 倒计时(经 hud-ui)。 */ +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.wage, 22, 40, { font: '700 22px sans-serif', color: C.wage, align: 'left', baseline: 'middle' }); + hud.drawText(g, '成品 ' + info.works, info.vw / 2, 40, { font: '600 18px sans-serif', color: C.text, align: 'center', baseline: 'middle' }); + const sec = Math.max(0, Math.ceil(info.remainMs / 1000)); + hud.drawText(g, '剩余 ' + sec + 's', info.vw - 22, 40, { font: '600 20px sans-serif', color: info.remainMs < 10000 ? C.danger : C.textDim, align: 'right', baseline: 'middle' }); +} + +/** 当前作品条:正在做哪件 + 第几道(工序进度的文字锚)。 */ +function drawWorkLine(g, hud, view) { + if (!view.work) return; + const idx = view.work.stepIndex; + const total = CRAFT_CHAIN.length; + const stepName = CRAFT_CHAIN[idx] ? CRAFT_CHAIN[idx].name : ''; + hud.drawText(g, '在做:' + view.work.name + ' · ' + stepName + '(' + (idx + 1) + '/' + total + ')', + view.vw / 2, view.vh * 0.50, { font: '600 16px sans-serif', color: C.text, align: 'center', baseline: 'middle' }); +} + +/** 完成飘字("+N",出成品时带作品名)。 */ +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.textAlign = 'center'; g.textBaseline = 'middle'; + if (f.big) { + g.font = '800 24px sans-serif'; + g.fillStyle = C.done; g.strokeStyle = '#241a10'; g.lineWidth = 4; + g.strokeText(f.big + ' 出炉 +' + f.amount, f.x, f.y); + g.fillText(f.big + ' 出炉 +' + f.amount, f.x, f.y); + } else { + g.font = '700 24px sans-serif'; + g.fillStyle = C.wage; g.strokeStyle = '#241a10'; g.lineWidth = 4; + g.strokeText('+' + f.amount, f.x, f.y); + g.fillText('+' + f.amount, f.x, f.y); + } + g.restore(); + } +} + +/* ────────── 场景绘制入口(导出给 game-logic 的 defineScenes 用)────────── */ + +/** + * menu:匾额 + 标题 + 开工按钮。换坊名/副标在这里改。 + * @returns {{startBtn:{x,y,w,h}}} 命中区(drawButton 返回的归一化矩形)。 + */ +export function renderMenu(g, hud, view) { + drawBackdrop(g, view.vw, view.vh); + g.fillStyle = 'rgba(18,12,6,0.55)'; g.fillRect(0, 0, view.vw, view.vh); + hud.drawText(g, '老陶坊', view.vw / 2, view.vh * 0.30, { font: '800 46px sans-serif', color: C.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '按工序点亮就绪的工位,火候别过——60 秒出最多成品!', view.vw / 2, view.vh * 0.30 + 60, { font: '15px sans-serif', color: C.textDim, align: 'center', baseline: 'middle' }); + const w = 240, h = 60; + // drawButton 返回它绘制的命中矩形(#5),直接接住当命中区——别再自己另算坐标。 + const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.58 - h / 2, w, h }, + { label: '开工', font: '700 22px sans-serif', fill: C.accent, textColor: '#241a10', radius: 30 }); + return { startBtn }; +} + +/** play:背景 + 工位链 + 当前作品条 + 飘字 + HUD。工位点击命中由 game-logic 管(按工位,不是按钮)。 */ +export function renderPlay(g, hud, view) { + const { vw, vh, stations, work, floats, wage, works, remainMs, nowMs } = view; + drawBackdrop(g, vw, vh); + for (const st of stations) { + const step = CRAFT_CHAIN[st.station] || CRAFT_CHAIN[0]; + let state = 'pending'; + let ratio = 0; + if (work) { + if (st.station < work.stepIndex) state = 'done'; + else if (st.station === work.stepIndex) { + if (work.stepState === 'ready') { + const waited = nowMs - work.stateSinceMs; + ratio = Math.max(0, Math.min(1, (work.windowMs - waited) / work.windowMs)); + state = 'ready'; + } else { + state = 'crafting'; + } + } + } + drawStation(g, st, step, state, ratio); + } + drawWorkLine(g, hud, view); + drawFloats(g, floats); + drawHud(g, hud, { vw, vh, wage, works, remainMs }); +} + +/** + * over:今日工钱 + 成品摆件(结算展示,可截图的时刻)+ 回炉数 + 最佳 + 再开按钮。 + * @returns {{restartBtn:{x,y,w,h}}} + */ +export function renderOver(g, hud, view) { + drawBackdrop(g, view.vw, view.vh); + g.fillStyle = 'rgba(18,12,6,0.55)'; g.fillRect(0, 0, view.vw, view.vh); + hud.drawText(g, '今日收工', view.vw / 2, view.vh * 0.20, { font: '800 32px sans-serif', color: C.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '工钱 ' + view.wage, view.vw / 2, view.vh * 0.29, { font: '800 52px sans-serif', color: C.wage, align: 'center', baseline: 'middle' }); + // 成品摆出来(结算展示:名字逐件列出;骨架用文字件,加美术后可换图) + const names = (view.worksDone && view.worksDone.length) ? view.worksDone.join(' · ') : '(今日没出成品)'; + hud.drawText(g, '出炉:' + names, view.vw / 2, view.vh * 0.40, { font: '600 18px sans-serif', color: C.done, align: 'center', baseline: 'middle' }); + hud.drawText(g, '成品 ' + (view.worksDone ? view.worksDone.length : 0) + ' 回炉 ' + view.redos, view.vw / 2, view.vh * 0.46, { font: '600 18px sans-serif', color: C.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '最佳工钱 ' + view.best, view.vw / 2, view.vh * 0.46 + 30, { font: '600 16px sans-serif', color: C.accent, align: 'center', baseline: 'middle' }); + const w = 240, h = 60; + const restartBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.66 - h / 2, w, h }, + { label: '再开一天', font: '700 22px sans-serif', fill: C.accent, textColor: '#241a10', radius: 30 }); + return { restartBtn }; +} diff --git a/game-runtime/games/_template-feiyi/test/core.test.mjs b/game-runtime/games/_template-feiyi/test/core.test.mjs new file mode 100644 index 00000000..f796c8ae --- /dev/null +++ b/game-runtime/games/_template-feiyi/test/core.test.mjs @@ -0,0 +1,91 @@ +/** + * 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 { + ROUND_MS, CRAFT_CHAIN, WORKS, CRAFT_MIN_MS, WINDOW_MIN_MS, WINDOW_MAX_MS, + STATION_TAP_RADIUS, FIRST_READY_DELAY_MS, + computeStations, makeWork, shouldBecomeReady, isWindowExpired, + isStepTappable, hitStation, windowRatio, stepPoints, +} from '../src/core.js'; + +/** 确定性 rng(从序列依次取,耗尽循环)。 */ +function seqRng(values) { let i = 0; return () => values[(i++) % values.length]; } + +test('computeStations:与工序链等长,都在视口内,station 索引 0..n-1', () => { + const sts = computeStations(390, 844); + assert.equal(sts.length, CRAFT_CHAIN.length, '应与 CRAFT_CHAIN 等长(每道一个工位)'); + for (const s of sts) { + assert.ok(s.x >= 0 && s.x <= 390, `工位 x 越界:${s.x}`); + assert.ok(s.y >= 0 && s.y <= 844, `工位 y 越界:${s.y}`); + } + assert.deepEqual(sts.map((s) => s.station), CRAFT_CHAIN.map((_, i) => i), 'station 索引应 0..n-1'); +}); + +test('makeWork:确定性 + 作品从 WORKS 轮换 + 首道 crafting', () => { + const a = makeWork(seqRng([0.3, 0.6]), 0, 0); + const b = makeWork(seqRng([0.3, 0.6]), 0, 0); + assert.deepEqual(a, b, '同 rng 序列应产逐字段一致作品'); + assert.equal(a.stepIndex, 0); + assert.equal(a.stepState, 'crafting'); + assert.equal(a.name, WORKS[0].name); + assert.equal(makeWork(seqRng([0]), 0, 1).name, WORKS[1].name, 'idSeq 轮换作品表'); + assert.ok(a.windowMs >= WINDOW_MIN_MS && a.windowMs <= WINDOW_MAX_MS, '窗口取自区间'); +}); + +test('makeWork:firstDelayMs 覆盖首道加工时长(开局留一拍)', () => { + const w = makeWork(seqRng([0.5]), 0, 0, FIRST_READY_DELAY_MS); + assert.equal(w.craftMs, FIRST_READY_DELAY_MS, '首道 craftMs 应为指定延迟'); +}); + +test('shouldBecomeReady:加工满 craftMs 才就绪', () => { + const w = makeWork(seqRng([0]), 1000, 0); // craftMs = CRAFT_MIN_MS + assert.equal(shouldBecomeReady(w, 1000 + CRAFT_MIN_MS - 1), false, '未满不就绪'); + assert.equal(shouldBecomeReady(w, 1000 + CRAFT_MIN_MS), true, '满时长应就绪'); + w.stepState = 'ready'; + assert.equal(shouldBecomeReady(w, 999999), false, '已 ready 不重复转'); +}); + +test('火候窗口:ready 内可点,过窗判回炉,ratio 单调走到 0', () => { + const w = makeWork(seqRng([0]), 0, 0); + w.stepState = 'ready'; w.stateSinceMs = 2000; w.windowMs = 6000; + assert.equal(isStepTappable(w, 2000), true, '刚就绪可点'); + assert.equal(windowRatio(w, 2000), 1, '刚就绪 ratio=1'); + assert.equal(windowRatio(w, 5000), 0.5, '半窗 ratio=0.5'); + assert.equal(isStepTappable(w, 8000), true, '窗口边界仍可点'); + assert.equal(isWindowExpired(w, 8001), true, '过窗应回炉'); + assert.equal(isStepTappable(w, 8001), false, '过窗不可点'); + assert.equal(windowRatio(w, 9000), 0, '过窗 ratio 夹到 0、不为负'); + w.stepState = 'crafting'; + assert.equal(isStepTappable(w, 2000), false, '非 ready 不可点'); + assert.equal(windowRatio(null, 0), 0, 'null 容错'); +}); + +test('hitStation:命中半径内,半径外不命中', () => { + const st = { x: 200, y: 600 }; + assert.equal(hitStation(200, 600, st), true, '圆心命中'); + assert.equal(hitStation(200 + STATION_TAP_RADIUS, 600, st), true, '边界(距=r)命中'); + assert.equal(hitStation(200 + STATION_TAP_RADIUS + 2, 600, st), false, '半径外不命中'); + assert.equal(hitStation(0, 0, null), false, 'null 容错'); +}); + +test('stepPoints:后道分高于前道(工钱曲线)+ 越界容错', () => { + for (let i = 1; i < CRAFT_CHAIN.length; i++) { + assert.ok(stepPoints(i) > stepPoints(i - 1), `第 ${i} 道分值应高于前道`); + } + assert.equal(stepPoints(999), 0, '越界返 0'); +}); + +test('常量自洽:60 秒一局 · 工序链 ≥3 道 · 作品 ≥3 件', () => { + assert.equal(ROUND_MS, 60000, '一局应 60 秒'); + assert.ok(CRAFT_CHAIN.length >= 3, '工序链至少 3 道(品类 rubric H1 结构前提)'); + assert.ok(WORKS.length >= 3, '作品至少 3 件(收集有差异)'); +}); diff --git a/game-runtime/games/_template-feiyi/test/headless-boot.test.mjs b/game-runtime/games/_template-feiyi/test/headless-boot.test.mjs new file mode 100644 index 00000000..9fa196da --- /dev/null +++ b/game-runtime/games/_template-feiyi/test/headless-boot.test.mjs @@ -0,0 +1,192 @@ +/** + * headless-boot.test.mjs — 无浏览器全链路冒烟(本机最强可跑验证;范式照 wanglanmei-ref headless-boot) + * + * 用「Proxy 伪 2D 上下文 + window 桩」真跑 bootGame: + * boot(evidence) → menu 开工 → play 等首道工序就绪 → 逐道完成整条工序链 → 断言工钱递增 + 出成品 + * → 驱动满一局(timer-scheduler.after 经 bundle.tick 受控时钟到期)→ 断言切到 over 场景。 + * 整条链路走真实 scene-fsm 派发 / 真实 timer-scheduler 受控到期 + 每帧 render(g) 全画, + * 任何宿主层运行时错误都会在此抛出。像素正确性留集成段浏览器证据(两层测试纪律)。 + * + * 【真用到的插件方法(本测覆盖)】 + * scene-fsm: start/define/transition/current/update/render(经 game 五法间接驱动) + * session-score: reset/add/getScore/getBest/lose/getOutcome(工序加分 + 到点锁负闸) + * hud-ui: pointInRect(命中开工/再开按钮) / drawText/drawButton/drawPanel(每帧 render) + * timer-scheduler:after(一局倒计时) / cancel(切场景清理) —— 经 bundle.tick 受控到期 + */ + +'use strict'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { CRAFT_CHAIN, WORKS } 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; +} + +/** 驱动到「当前工序就绪(targets 有 occupied)」;超 guard 抛。 */ +function stepUntilReady(host, F, guardFrames = 400) { + let guard = 0; + while (guard < guardFrames) { + const t = F.state().targets || []; + if (t.some((x) => x.occupied === true)) return; + host.stepFrames(6); + guard += 6; + } + assert.fail(`等了 ${guardFrames} 帧工序仍未就绪(craftMs 推进坏了?)`); +} + +test('无浏览器全链路:boot→开工→整条工序链→出成品→满局到点切 over(真 tap 路由 + 受控定时)', async () => { + const host = await bootHeadless('?mode=evidence&seed=99'); + const F = globalThis.window.__gameForensics; + assert.ok(F, '取证全局 __gameForensics 应已挂载'); + assert.equal(host.mode, 'evidence'); + + // ① 初始态:menu。 + assert.equal(F.state().phase, 'menu', 'boot 后应在 menu 场景'); + + // ② 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().targets.length, CRAFT_CHAIN.length, 'targets 应与工序链等长(每工位一项)'); + assert.equal(F.state().targets.some((t) => t.occupied), false, '首道尚在加工,无 occupied'); + + // ③ 逐道完成整条工序链:等就绪 → craftFirst(以就绪工位走真实 completeStep),断言工钱按工序分递增。 + let expect = 0; + for (let i = 0; i < CRAFT_CHAIN.length; i++) { + stepUntilReady(host, F); + const st = F.state(); + assert.equal(st.currentStep, i, `第 ${i + 1} 道应为当前工序`); + const occ = st.targets.filter((t) => t.occupied); + assert.equal(occ.length, 1, '同一时刻只有当前道就绪(occupied 恰一个)'); + assert.equal(occ[0].station, i, 'occupied 工位应是当前道'); + host.do({ type: 'craftFirst' }); + host.stepFrames(2); + expect += CRAFT_CHAIN[i].points; + if (i === CRAFT_CHAIN.length - 1) expect += WORKS[0].bonus; // 末道出成品 + 首件作品奖励 + assert.equal(F.state().score, expect, `完成第 ${i + 1} 道后工钱应为 ${expect}`); + } + assert.equal(F.state().works, 1, '整条链走完应出 1 件成品'); + assert.equal(F.state().currentStep, 0, '出成品后应开下一件、回到第 1 道'); + + // ④ 未就绪时点击/命令不加分(节律张力:非窗口期无效)。 + const scoreBefore = F.state().score; + host.do({ type: 'craftFirst' }); // 下一件首道还在加工 → 应无操作 + host.tap(0, 0); // 空点(不在任何工位) + host.stepFrames(2); + assert.equal(F.state().score, scoreBefore, '未就绪/空点不应加分(窗口判定生效)'); + + // ⑤ 驱动满一局:timer-scheduler.after(60000) 经 bundle.tick 受控到期 → 切 over + 锁负闸。 + let guard = 0; + while (F.state().phase === 'play' && guard++ < 2000) { + host.stepFrames(30); + } + assert.equal(F.state().phase, 'over', `满一局应自动切 over(guard=${guard}×30 帧)`); + assert.equal(F.state().outcome, 'lost', '到点应锁定 outcome=lost(限时结算,负闸 latch)'); + assert.ok(F.state().score >= scoreBefore, 'over 应保留累计工钱'); + assert.ok(F.state().best >= F.state().score, `最佳工钱应 ≥ 本局(best=${F.state().best})`); + + // ⑥ over → 再开一天:do('restart') 回 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().works, 0, '再开后成品数应归零'); + + // ⑦ 取证度量形状健全(frames/taps/steps/works 都被驱动过)。 + const m = F.measures(); + assert.ok(m.frames > 100, `应驱动过足量帧(frames=${m.frames})`); + assert.ok(m.taps > 0, `应记录过点击(taps=${m.taps})`); + assert.ok(m.steps >= CRAFT_CHAIN.length, `应记录过 ≥${CRAFT_CHAIN.length} 道工序(steps=${m.steps})`); + assert.equal(m.works, 1, '应记录过 1 件成品'); +}); + +test('无浏览器确定性:同 seed 同驱动脚本 → 工钱/工序序列一致(受控随机生效)', async () => { + /** 跑固定脚本:开工后完成 6 道工序,收集 [score,currentStep,works] 序列。 */ + async function run() { + const host = await bootHeadless('?mode=evidence&seed=777'); + const F = globalThis.window.__gameForensics; + host.do({ type: 'start' }); + host.stepFrames(2); + const seq = []; + for (let i = 0; i < 6; i++) { + stepUntilReady(host, F); + host.do({ type: 'craftFirst' }); + host.stepFrames(2); + const s = F.state(); + seq.push([s.score, s.currentStep, s.works]); + } + return JSON.stringify(seq); + } + const a = await run(); + const b = await run(); + assert.equal(a, b, '同 seed 同脚本应产逐字段一致的工序序列'); +}); + +test('火候窗口回炉:就绪后一直不点 → 回炉重做(redos 计数)且分数不涨', async () => { + const host = await bootHeadless('?mode=evidence&seed=42'); + const F = globalThis.window.__gameForensics; + host.do({ type: 'start' }); + host.stepFrames(2); + stepUntilReady(host, F); + assert.equal(F.state().redos, 0, '就绪时还没回炉'); + // 不点,硬等过窗(窗口 ≤8000ms ≈ 480 帧;给足余量)。 + let guard = 0; + while (F.state().redos === 0 && guard++ < 200) { + host.stepFrames(6); + } + assert.equal(F.state().redos, 1, '过窗应回炉一次'); + assert.equal(F.state().score, 0, '回炉不加分(损时间不清进度)'); + assert.equal(F.state().currentStep, 0, '回炉后仍在原道(不清进度)'); + // 回炉冷却后应再次就绪、仍可完成(不判死)。 + stepUntilReady(host, F); + host.do({ type: 'craftFirst' }); + host.stepFrames(2); + assert.ok(F.state().score > 0, '回炉后再就绪仍可完成该道'); +}); diff --git a/game-runtime/tools/amodel-gen/prompt.mjs b/game-runtime/tools/amodel-gen/prompt.mjs index c1218838..4d75bb3c 100644 --- a/game-runtime/tools/amodel-gen/prompt.mjs +++ b/game-runtime/tools/amodel-gen/prompt.mjs @@ -79,6 +79,7 @@ export function buildSystemPrompt(id) { 3. 用到某插件就 read_file 它的 api.d.ts(如 game-runtime/src/plugins/scene-fsm/api.d.ts、.../session-score/api.d.ts)看精确签名。 4. 你的起点 ${G}/src/game-logic.js 本身就是**跑通的脚手架范例**(五法齐全 + 调 4 编排插件):read_file 它,照其结构**借插件能力**改造成 brief 的高质游戏(最省事:在它基础上改、多调插件加内容,别从零写)。 5. **若 brief 属经营 / 养成 / 放置 / 点客这类「攒资源→成长→解锁」品类**:read_file('.agents/skills/sim-business-game-design.md') 取「设计什么才好玩」的范式(6 心理引擎 / §1 玩法范式含进货补货环 / §8 设计配方 / §10 反无趣 8 条自检)。littlejs-game-dev 教你「代码怎么写」、sim-business 教你「设计什么才好玩」——配对用:先想清好玩、再写正确。 +6. **若 brief 属非遗 / 传统技艺 / 民俗这类「传统文化玩法化」品类**:read_file('.agents/skills/heritage-game-design.md') 取「设计什么才好玩」的范式(§1 玩法化范式含工序节拍 / §3 双层奖励与工序节奏目标值 / §9 文化表述红线 / §10 反贴皮自检);需要完整过门范例再 read_file('game-runtime/games/_fewshot-feiyi/src/game-logic.js')。 【先设计后写码:决定这游戏好不好玩(关键,别跳过)】 能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现: