merge: W-GENRE 非遗品类四件套(skill heritage-game-design + _template-feiyi 9/9 + _fewshot-feiyi 正例 + rubric fixture/金标 spec;n=5 过 4/5·首败根因=import 断链已加护栏后 3 连过·幻觉 0)——品类路由并集 v1.3.0(剧情6/TRPG7/非遗8)
Some checks failed
docs-gate / docs-gate (push) Has been cancelled
Some checks failed
docs-gate / docs-gate (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
496f8b6de7
169
.agents/skills/heritage-game-design.md
Normal file
169
.agents/skills/heritage-game-design.md
Normal file
@ -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)
|
||||
@ -51,6 +51,7 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
|
||||
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/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')。
|
||||
|
||||
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
|
||||
能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
|
||||
@ -66,7 +67,7 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
|
||||
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版。**先出能玩的核心,再谈丰富。**
|
||||
|
||||
【步骤】
|
||||
A 读手册(1 + 按品类 5/6/7)+ 读你的起点 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)+ 读你的起点 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())。
|
||||
|
||||
61
cheap-worker/fixtures/genre-rubrics/heritage.rubric.json
Normal file
61
cheap-worker/fixtures/genre-rubrics/heritage.rubric.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"_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 回退)。口径 = 通用底座 11 条 + 本品类扩展 5 条一次合评、两分母分开(MiniMax-M3 judge,单次锚定、容差 ±1/组);2026-07-02 首评回填。",
|
||||
"positive": {
|
||||
"sample": "games/_fewshot-feiyi(源=生成局 p11d-print,木版年画作坊)",
|
||||
"base": { "score": 10, "max": 11, "groups": { "L2": { "score": 6, "max": 6 }, "L3": { "score": 2, "max": 3 }, "L4": { "score": 2, "max": 2 } } },
|
||||
"genre": { "score": 5, "max": 5, "groups": { "L2": { "score": 3, "max": 3 }, "L3": { "score": 1, "max": 1 }, "L4": { "score": 1, "max": 1 } } }
|
||||
},
|
||||
"thinNegative": {
|
||||
"sample": "games/_template-feiyi 骨架原样实例(轻起点,天然薄)",
|
||||
"base": { "score": 6, "max": 11, "groups": { "L2": { "score": 4, "max": 6 }, "L3": { "score": 0, "max": 3 }, "L4": { "score": 2, "max": 2 } } },
|
||||
"genre": { "score": 3, "max": 5, "groups": { "L2": { "score": 2, "max": 3 }, "L3": { "score": 0, "max": 1 }, "L4": { "score": 1, "max": 1 } },
|
||||
"perItem": { "工序链成立": true, "节律张力": false, "技艺成长可见": false, "作品可展示": true, "文化氛围一致": true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -14,8 +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 骨架;新品类进金标须同步本期望集)。
|
||||
_EXPECTED_GENRES = {"click-score", "whack-mole", "shop-serve", "story-branch", "trpg"}
|
||||
# + 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"}
|
||||
# driver 只许这些键 —— 全是与具体游戏无关的契约级参数;出现坐标类键(x/y/coords/points)即"绑定具体游戏"违规。
|
||||
_ALLOWED_DRIVER_KEYS = {"type", "targetMode", "targetsPath", "steps", "stepMs"}
|
||||
_FORBIDDEN_COORD_KEYS = {"x", "y", "coords", "points", "positions", "targets"}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
id: config.cheap-system
|
||||
version: 1.2.0
|
||||
version: 1.3.0
|
||||
---
|
||||
|
||||
你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩、有内容、不易同质化的高质小游戏**(多文件 LittleJS),落在 ⟦G⟧/。
|
||||
@ -21,6 +21,7 @@ version: 1.2.0
|
||||
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/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')。
|
||||
|
||||
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
|
||||
能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
|
||||
@ -37,7 +38,7 @@ version: 1.2.0
|
||||
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 MVP 的心脏,砍了就只剩无趣骨架。**
|
||||
|
||||
【步骤】
|
||||
A 读手册(1 + 按品类 5/6/7)+ 读你的起点 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)+ 读你的起点 game-logic.js(4)→ B 据 brief 定**轻量玩法设计**(核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效,过 §10 八条自检 + 守 MVP-first)→ C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js**(按需拆 core/render)实现玩法 → E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check+build 都绿后调 finish。
|
||||
|
||||
【红线(check 会拦,违反则 finish 被拒)】
|
||||
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。
|
||||
|
||||
@ -121,7 +121,7 @@ prompts:
|
||||
# 首版正文 = _SYSTEM_PROMPT 内置原文逐字节对齐(含 ⟦G⟧/⟦SCAFFOLD_DESC⟧ 占位符),默认行为字节不变;
|
||||
# 调一条 prompt = 改 04-config/cheap-system.md 正文 + 升 version,下次生成自动生效。
|
||||
- id: config.cheap-system
|
||||
version: 1.2.0 # v1.2.0:W-GENRE 品类路由并集——v1.1.0 剧情条目(+6 narrative-game-design + _template-story 范例)+ TRPG 条目(+7 trpg-game-design);merge 时版本归一;cheap_roles.py/prompt.mjs 双源同改
|
||||
version: 1.3.0 # v1.3.0:W-GENRE 品类路由并集——剧情(+6)/ TRPG(+7)/ 非遗(+8 heritage-game-design + _fewshot-feiyi 正例指针);merge 时版本归一;cheap_roles.py/prompt.mjs 双源同改
|
||||
stage: "04-config"
|
||||
owner: WS2
|
||||
file: 04-config/cheap-system.md
|
||||
|
||||
33
game-runtime/games/_fewshot-feiyi/README.md
Normal file
33
game-runtime/games/_fewshot-feiyi/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# _fewshot-feiyi — 非遗品类 few-shot 过门正例(木版年画作坊)
|
||||
|
||||
这是非遗品类的 **few-shot 完整正例**(W-GENRE 四件套之③):一款由生成 agent 在 `_template-feiyi` 工序节拍骨架上做出来、**真过九门**的完整游戏,示范"轻骨架之上把 brief 做丰富"长什么样。生成 agent 需要完整范例时 read_file 这里的 `src/game-logic.js`(prompt 先读手册第 6 步与 `.agents/skills/heritage-game-design.md` §8 都指向此处)。
|
||||
|
||||
## 来历(可复核)
|
||||
|
||||
- **brief**:「木版年画作坊:刻版、上墨、铺纸、拓印,赶在年前印出最多年画;印得多解锁新的吉祥图样」。
|
||||
- **生成局**:`p11d-print`(便宜档进程内路,MiniMax-M3,attempts=1 一次成型,¥2.65,335s;W-GENRE 非遗品类件 n=5 小批第 3 款)。
|
||||
- **九门**:A–I 全绿(verdict = `games/_wg1-gen/p11d-print/evidence/verdict.json`,生成机器留档;批产记录见品类件工单回执)。
|
||||
- **丰富度**(纯 LLM 验证 agent,非阻塞):9/11——L2 5/6 / L3 2/3 / L4 2/2;未命中仅「情感锚 / 放置回归」。
|
||||
- **范例签名机器校验门**:`tools.mjs check` PASS(API 静态门 / 形状门 / 红线全绿;蒸馏时复跑过)。
|
||||
|
||||
## 它示范了什么(读 game-logic.js 时看这些)
|
||||
|
||||
- **换手艺不重写循环**:工序链(刻版→上墨→铺纸→拓印)与作品表(福字/门神/财神/麒麟/童子抱鱼)全落在 core.js 参数空间;工序节拍结构(就绪→窗口内点击→出成品→回炉)原样保留。
|
||||
- **在循环上加机制**:正窗暴击 + 连击、攒工钱解锁新图样(本品类成长轴 = 工艺步骤/图样解锁)、结算屏摆出本局印成的年画(炫耀时刻)。
|
||||
- **取证契约不破**:`_forensicsView().state()` 的 `targets`(occupied=当前就绪工位)与 `score`(单调上升)语义未动——这是过九门的命脉。
|
||||
|
||||
## 复验配方(怀疑漂移时跑)
|
||||
|
||||
```bash
|
||||
# 在 game-runtime/ 下:骨架 clone 一个临时局,覆盖本正例 L3,过机器门 + 九门
|
||||
node tools/amodel-gen/tools.mjs scaffold-saa p11d-fewshot-recheck _template-feiyi
|
||||
cp games/_fewshot-feiyi/src/*.js games/amgen-p11d-fewshot-recheck/src/
|
||||
node -e "import('./tools/amodel-gen/tools.mjs').then(m=>process.exit(m.check('p11d-fewshot-recheck').ok?0:1))"
|
||||
node tools/amodel-gen/tools.mjs build-saa p11d-fewshot-recheck && node tools/amodel-gen/tools.mjs stage p11d-fewshot-recheck
|
||||
cp ../cheap-worker/fixtures/golden-specs/heritage-craft.play-spec.json games/_wg1-gen/p11d-fewshot-recheck/play-spec.json
|
||||
bash games/_wg1-gen/_shared/serve-and-play.sh p11d-fewshot-recheck
|
||||
```
|
||||
|
||||
## 漂移纪律(设计档 §3⑥/§6:per-genre 正例是「必须同改」清单成员)
|
||||
|
||||
L2 插件 API 改签名时,本正例随 `_template-feiyi`、prompt、skill **同批同改**并复跑上面的复验配方——正例签名与现契约不一致 = 下一代脏数据源,宁可删除不可留漂。
|
||||
73
game-runtime/games/_fewshot-feiyi/src/assets.js
Normal file
73
game-runtime/games/_fewshot-feiyi/src/assets.js
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件)
|
||||
* owner:_template 克隆起点 | 消费方:game.js(init 时 await loadAssets)/ render.js(若用图则取)
|
||||
*
|
||||
* 【本款现状:无外部资产(纯程序化图形)】
|
||||
* 「点圆得分」全用 g 原生绘制(圆/矩形/文字),不需任何 .jpg/.mp3,故 IMAGE_FILES 为空。
|
||||
* 但**声明模式的结构必须留全**——这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键,
|
||||
* render 里用 assets.images.<键> 取图,装载/容错链条已就位,不必重搭。
|
||||
*
|
||||
* 【容错铁律(发行版红线 §7-5)】
|
||||
* 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退),
|
||||
* 绝不因资产把游戏带崩。本款无资产 → loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)。
|
||||
* node 环境无 Image → 同样安全返回空集(import-safe)。
|
||||
* 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载;本款无音频)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 图像资产键 → 文件名(与 assets/manifest.json 对应)。
|
||||
* 【在此声明图片资产键→文件名】本款无美术故为空对象 {}。
|
||||
* 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)。
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
export const IMAGE_FILES = {};
|
||||
|
||||
/**
|
||||
* 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。
|
||||
* @param {string} [baseUrl] 资产根(缺省 './assets/')
|
||||
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
|
||||
*/
|
||||
export function loadAssets(baseUrl) {
|
||||
const root = baseUrl == null ? './assets/' : baseUrl;
|
||||
const keys = Object.keys(IMAGE_FILES);
|
||||
/** @type {Record<string, HTMLImageElement|null>} */
|
||||
const images = {};
|
||||
/** @type {string[]} */
|
||||
const missing = [];
|
||||
|
||||
/** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */
|
||||
function loadOne(key) {
|
||||
return new Promise((resolve) => {
|
||||
// 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。
|
||||
if (typeof Image === 'undefined') {
|
||||
images[key] = null;
|
||||
missing.push(key);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
images[key] = img;
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
// 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。
|
||||
console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]);
|
||||
images[key] = null;
|
||||
missing.push(key);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = root + IMAGE_FILES[key];
|
||||
});
|
||||
}
|
||||
|
||||
// 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。
|
||||
return Promise.all(keys.map(loadOne)).then(() => ({
|
||||
images,
|
||||
missing,
|
||||
loaded: keys.length - missing.length,
|
||||
total: keys.length,
|
||||
}));
|
||||
}
|
||||
179
game-runtime/games/_fewshot-feiyi/src/core.js
Normal file
179
game-runtime/games/_fewshot-feiyi/src/core.js
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* core.js — 【L3 纯逻辑】木版年画作坊 · 工序节拍玩法数值与判定
|
||||
*
|
||||
* 本款 = 非遗「工序节拍」品类(template-feiyi)+ 传统技艺「木版年画」。
|
||||
* 一件年画 = 一条 4 道工序链:刻版 → 上墨 → 铺纸 → 拓印;在火候窗口内点工位
|
||||
* 完成该道工序;过窗回炉;整条链走完 = 印出一张年画;60s 限时赶工结算。
|
||||
* 印满阈值解锁新图样(财神/麒麟/童子)——「印得多解锁新的吉祥图样」的核心钩子。
|
||||
*
|
||||
* 工序名取自木版年画通识工序词;**不编造具体非遗项目的失实细节**。
|
||||
*
|
||||
* 作品字段(公开契约,render / game-logic / 取证共用):
|
||||
* work = { id, name, bonus, stepIndex, stepState:'crafting'|'ready', stateSinceMs,
|
||||
* craftMs, windowMs, glyph: '福'|'门神'|... }
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* 【多样性参数空间 · 木版年画作坊】
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** 一局开工时长(ms):年前赶工 60s。 */
|
||||
export const ROUND_MS = 60000;
|
||||
|
||||
/**
|
||||
* 工序链(木版年画 4 道):名字 + 颜色 + 完成分。
|
||||
* 颜色用于程序化绘制工位物件(刻刀/墨辊/宣纸/棕刷的视觉锚)。
|
||||
* 顺序:刻版→上墨→铺纸→拓印,越接近成品越值钱(后道高于前道)。
|
||||
*/
|
||||
export const CRAFT_CHAIN = [
|
||||
{ name: '刻版', color: '#5a3a24', points: 5, tool: '刻刀', desc: '雕梨木板' },
|
||||
{ name: '上墨', color: '#c8261a', points: 7, tool: '墨辊', desc: '朱砂上墨' },
|
||||
{ name: '铺纸', color: '#f3e8c2', points: 9, tool: '宣纸', desc: '覆纸上版' },
|
||||
{ name: '拓印', color: '#1f4e7a', points: 12, tool: '棕刷', desc: '按压转印' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 作品表(吉祥图样):印出一张年画;bonus = 成品奖励分(≈单道工序分的 3–4 倍)。
|
||||
* 收集向 = 凑齐一套解锁「图鉴」成就感。
|
||||
*
|
||||
* `unlockAtWage` 字段:达成该累计工钱后该图样解锁(传 -1 = 默认解锁)。
|
||||
* 默认解锁 3 件,工钱达 80 解锁第 4 件「麒麟」,达 160 解锁第 5 件「童子」。
|
||||
* ——「印得多解锁新的吉祥图样」的核心钩子落在这里。
|
||||
*/
|
||||
export const WORKS = [
|
||||
{ name: '福字', glyph: '福', bgColor: '#c8261a', bonus: 26, unlockAtWage: 0 },
|
||||
{ name: '门神', glyph: '门', bgColor: '#1f4e7a', bonus: 32, unlockAtWage: 0 },
|
||||
{ name: '财神', glyph: '财', bgColor: '#caa84a', bonus: 38, unlockAtWage: 0 },
|
||||
{ name: '麒麟', glyph: '麒', bgColor: '#5a3a24', bonus: 44, unlockAtWage: 80 },
|
||||
{ name: '童子抱鱼', glyph: '童', bgColor: '#7a9b3e', bonus: 50, unlockAtWage: 160 },
|
||||
];
|
||||
|
||||
/** 解锁的初始默认工资阈值(在本局内)——所有工资 < 阈值的图样在图鉴里置灰挂锁。 */
|
||||
export const UNLOCK_PROGRESS_TOTAL = 200; // 「印得多解锁新的吉祥图样」封顶参考
|
||||
|
||||
/** 工序就绪间隔区间(ms):上一道完成后,下一道多久「就绪」。 */
|
||||
export const CRAFT_MIN_MS = 650;
|
||||
export const CRAFT_MAX_MS = 1050;
|
||||
/** 开局首道工序就绪延迟(ms):留一拍让玩家看清案台。 */
|
||||
export const FIRST_READY_DELAY_MS = 700;
|
||||
|
||||
/** 火候窗口区间(ms):工序就绪后须在窗口内点掉,过窗回炉(不判死)。 */
|
||||
export const WINDOW_MIN_MS = 5000;
|
||||
export const WINDOW_MAX_MS = 8000;
|
||||
|
||||
/** 正窗暴击(基础分之上加成):窗口前 40% 出手 → 加成倍率(双层奖励之技巧分)。 */
|
||||
export const CRIT_RATIO = 0.4;
|
||||
export const CRIT_MULT = 1.6;
|
||||
|
||||
/** 回炉冷却(ms):过窗后该道重新加工的耗时(损时间不清进度)。 */
|
||||
export const REDO_MS = 1100;
|
||||
|
||||
/** 工位点按半径(手指友好,别小于 ~44)。 */
|
||||
export const STATION_TAP_RADIUS = 58;
|
||||
|
||||
/** 工位布局比例(读 viewport 算具体像素,别硬编码 390×844):案台一横排。 */
|
||||
export const STATION_ROW_Y_RATIO = [0.62];
|
||||
export const STATION_MARGIN_X_RATIO = 0.14;
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* 【工序循环判定 · 结构层 · 一般不动】
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** 受控随机区间 [lo,hi):入参 rng 走 ctx.random,纯确定性。 */
|
||||
function rngRange(rng, lo, hi) { return lo + rng() * (hi - lo); }
|
||||
|
||||
/**
|
||||
* 算工位中心点(每道工序一个工位;读 viewport,不硬编码)。
|
||||
* @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;
|
||||
}
|
||||
|
||||
/** 取当前工钱下可印的作品表(过滤未解锁的)。 */
|
||||
export function availableWorks(wage, works) {
|
||||
const list = works || WORKS;
|
||||
return list.filter((w) => wage >= (w.unlockAtWage != null ? w.unlockAtWage : 0));
|
||||
}
|
||||
|
||||
/** 开一件新作品(从已解锁图样中轮换)。 */
|
||||
export function makeWork(rng, nowMs, idSeq, firstDelayMs, wage, works) {
|
||||
const pool = availableWorks(wage, works);
|
||||
const list = pool.length > 0 ? pool : (works || WORKS);
|
||||
const w = list[idSeq % list.length];
|
||||
return {
|
||||
id: idSeq,
|
||||
name: w.name,
|
||||
glyph: w.glyph,
|
||||
bgColor: w.bgColor,
|
||||
bonus: w.bonus,
|
||||
stepIndex: 0,
|
||||
stepState: 'crafting',
|
||||
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)。 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 是否为正窗暴击(在窗口前 CRIT_RATIO 比例内出手)。 */
|
||||
export function isCritWindow(work, nowMs) {
|
||||
if (!work || work.stepState !== 'ready' || !work.windowMs) return false;
|
||||
const waited = nowMs - work.stateSinceMs;
|
||||
return waited <= work.windowMs * CRIT_RATIO;
|
||||
}
|
||||
|
||||
/** 解锁进度(0..1)——本局累计工资 / UNLOCK_PROGRESS_TOTAL,供图鉴用。 */
|
||||
export function unlockProgress(wage) {
|
||||
if (UNLOCK_PROGRESS_TOTAL <= 0) return 1;
|
||||
return Math.max(0, Math.min(1, wage / UNLOCK_PROGRESS_TOTAL));
|
||||
}
|
||||
331
game-runtime/games/_fewshot-feiyi/src/game-logic.js
Normal file
331
game-runtime/games/_fewshot-feiyi/src/game-logic.js
Normal file
@ -0,0 +1,331 @@
|
||||
/**
|
||||
* game-logic.js — 【L3 游戏本体】木版年画作坊 · 工序节拍 + 暴击 combo + 吉祥图样解锁
|
||||
*
|
||||
* 玩法核心(60s 赶工):
|
||||
* · 一件年画 = 4 道工序链:刻版 → 上墨 → 铺纸 → 拓印
|
||||
* · 工序依次就绪(craftMs 加工后转 ready),火候窗口(windowMs)内点掉=完成
|
||||
* · 过窗回炉(REDO_MS 冷却后该道重做,损时间不清进度)
|
||||
* · 整条链走完 = 印出一张年画(bonus 加分 + 庆祝)
|
||||
* · **正窗暴击**:窗口前 40% 出手 → 工钱 ×CRIT_MULT,连续正窗叠 combo
|
||||
* · **吉祥图样解锁**:累计工钱达标(80/160)→ 麒麟/童子抱鱼入图鉴可印
|
||||
* · 60s 到点切 over 结算,再开一印
|
||||
*
|
||||
* 已在 _forensicsView.state 实时暴露工资 + combo + 暴击 + 解锁图样列表,
|
||||
* 满足九门 H 进展门(score 单调上升)与 E_live(targets 含就绪工位)。
|
||||
*
|
||||
* 入参(L1 已摊平):createGame({ plugins, bundle, viewport })
|
||||
* 受控面 ctx 在 init(boot) 的 boot.ctx。
|
||||
* 返回:init/update/render/destroy + handleTap + _forensicsView。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
ROUND_MS, CRAFT_CHAIN, FIRST_READY_DELAY_MS, REDO_MS,
|
||||
WORKS, UNLOCK_PROGRESS_TOTAL, CRIT_MULT,
|
||||
computeStations, makeWork, shouldBecomeReady, isWindowExpired,
|
||||
isStepTappable, hitStation, stepPoints, isCritWindow,
|
||||
} 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.95;
|
||||
const FLOAT_RISE_PXPS = 72;
|
||||
/** combo 高亮飘字延长生命。 */
|
||||
const COMBO_FLOAT_LIFE_S = 1.3;
|
||||
/** 呼吸周期 220ms(就绪工位的脉冲)——用内部累计毫秒算 0..1,避免依赖裸 performance.now。 */
|
||||
const PULSE_PERIOD_MS = 220;
|
||||
|
||||
export function createGame({ plugins, bundle, viewport }) {
|
||||
// 用 6 件:场景机 / 计分 / UI / 计时 / 音效 / 粒子(节拍 + 出彩)。
|
||||
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); // 工位中心(读 viewport,定一次)
|
||||
let work = null; // 当前作品
|
||||
let floats = []; // 飘字
|
||||
let elapsedMs = 0; // play 内累计毫秒
|
||||
let nextWorkId = 0; // 作品序号
|
||||
let wage = 0; // 当日工钱(随完成工序上升)
|
||||
let worksDone = []; // 本局印出的作品名(结算 + 取证)
|
||||
let redos = 0; // 回炉次数
|
||||
let roundTimer = null; // 60s 倒计时句柄
|
||||
|
||||
/* ── 双层奖励 / 正窗暴击 / combo ── */
|
||||
let combo = 0; // 连续正窗次数
|
||||
let bestCombo = 0; // 本局最高 combo
|
||||
let totalCrits = 0; // 本局暴击总次数
|
||||
let lastCrit = false; // 上一次出手是否暴击(取证可见)
|
||||
|
||||
/* ── 命中区(菜单/结算按钮):render 回调实时算并写回 ── */
|
||||
let menuStartBtn = null;
|
||||
let overRestartBtn = null;
|
||||
|
||||
/* ── 取证度量 ── */
|
||||
const measures = { frames: 0, taps: 0, steps: 0, works: 0, redos: 0, crits: 0 };
|
||||
|
||||
/** 受控随机 [0,1):经 boot.ctx.random。 */
|
||||
function rng() { return ctx ? ctx.random.next() : 0; }
|
||||
|
||||
/** 进 play:重置开工态、起 60s 倒计时。 */
|
||||
function enterPlay() {
|
||||
sessionScore.reset();
|
||||
floats = [];
|
||||
elapsedMs = 0;
|
||||
wage = 0; worksDone = []; redos = 0;
|
||||
combo = 0; bestCombo = 0; totalCrits = 0; lastCrit = false;
|
||||
measures.steps = 0; measures.works = 0; measures.redos = 0; measures.crits = 0;
|
||||
nextWorkId = 0;
|
||||
work = makeWork(rng, 0, nextWorkId++, FIRST_READY_DELAY_MS, wage, WORKS);
|
||||
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
|
||||
roundTimer = timerScheduler.after(ROUND_MS, () => {
|
||||
sessionScore.lose();
|
||||
sceneFsm.transition(SCENE_OVER);
|
||||
if (ctx) ctx.log('scene', 'over 收工', { score: sessionScore.getScore(), wage, works: worksDone.length, redos, bestCombo });
|
||||
});
|
||||
sceneFsm.transition(SCENE_PLAY);
|
||||
if (ctx) ctx.log('scene', 'play 开工', { roundMs: ROUND_MS, chain: CRAFT_CHAIN.map((s) => s.name).join('→') });
|
||||
}
|
||||
|
||||
/** L3 输入入口:L1 在 pointerdown 时直达调用。 */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成当前工序:基础分 + 暴击判定 + combo + 飘字 + 粒子 + 音效。 */
|
||||
function completeStep(st) {
|
||||
measures.steps += 1;
|
||||
const base = stepPoints(work.stepIndex);
|
||||
const crit = isCritWindow(work, elapsedMs);
|
||||
lastCrit = crit;
|
||||
let amount = base;
|
||||
if (crit) {
|
||||
amount = Math.round(base * CRIT_MULT);
|
||||
totalCrits += 1; measures.crits += 1;
|
||||
combo += 1; if (combo > bestCombo) bestCombo = combo;
|
||||
} else {
|
||||
combo = 0;
|
||||
}
|
||||
// combo 额外加成(2 连起每连 +10%,叠到原数上,封顶 1.5x)
|
||||
if (combo >= 2) {
|
||||
const bonus = Math.min(0.5, (combo - 1) * 0.1);
|
||||
amount = Math.round(amount * (1 + bonus));
|
||||
}
|
||||
wage += amount;
|
||||
sessionScore.add(amount); // 计分即工钱(score 单调上升 → H 进展门)
|
||||
// 飘字
|
||||
const life = crit ? COMBO_FLOAT_LIFE_S : FLOAT_LIFE_S;
|
||||
if (crit) {
|
||||
floats.push({ x: st.x, y: st.y - 70, age: 0, life, amount, crit: true });
|
||||
if (combo >= 2) {
|
||||
floats.push({ x: st.x, y: st.y - 110, age: 0, life: COMBO_FLOAT_LIFE_S, amount: combo, combo: true });
|
||||
}
|
||||
} else {
|
||||
floats.push({ x: st.x, y: st.y - 70, age: 0, life, amount });
|
||||
}
|
||||
if (juice) { try { juice.burst(st.x, st.y - 30, crit ? { count: 18 } : undefined); } catch (_) { /* 容错 */ } }
|
||||
if (audioMusic) { try { audioMusic.playSfx(crit ? 'chime' : 'click'); } catch (_) { /* 容错 */ } }
|
||||
if (ctx) ctx.log('step', '工序完成', { step: CRAFT_CHAIN[work.stepIndex].name, base, crit, combo, amount, total: wage });
|
||||
|
||||
if (work.stepIndex + 1 >= CRAFT_CHAIN.length) {
|
||||
finishWork();
|
||||
} else {
|
||||
work.stepIndex += 1;
|
||||
work.stepState = 'crafting';
|
||||
work.stateSinceMs = elapsedMs;
|
||||
const fresh = makeWork(rng, elapsedMs, work.id, undefined, wage, WORKS);
|
||||
work.craftMs = fresh.craftMs;
|
||||
work.windowMs = fresh.windowMs;
|
||||
work.name = fresh.name; work.glyph = fresh.glyph; work.bgColor = fresh.bgColor; work.bonus = fresh.bonus;
|
||||
}
|
||||
}
|
||||
|
||||
/** 出成品:成品奖励 + 庆祝 + 记入当日作品,开下一件。 */
|
||||
function finishWork() {
|
||||
measures.works += 1;
|
||||
wage += work.bonus;
|
||||
sessionScore.add(work.bonus);
|
||||
worksDone.push(work.name);
|
||||
floats.push({ x: VW / 2, y: VH * 0.36, age: 0, life: FLOAT_LIFE_S * 1.5, amount: work.bonus, big: work.name });
|
||||
if (juice) { try { juice.burst(VW / 2, VH * 0.4, { count: 26 }); } catch (_) { /* 容错 */ } }
|
||||
if (audioMusic) { try { audioMusic.playSfx('win'); } catch (_) { /* 容错 */ } }
|
||||
if (ctx) ctx.log('work', '印出年画', { name: work.name, glyph: work.glyph, bonus: work.bonus, works: worksDone.length });
|
||||
work = makeWork(rng, elapsedMs, nextWorkId++, undefined, wage, WORKS);
|
||||
}
|
||||
|
||||
/** 过窗回炉:该道重做 + 轻屏震 + 闷响 + combo 断。 */
|
||||
function redoStep() {
|
||||
redos += 1; measures.redos += 1;
|
||||
work.stepState = 'crafting';
|
||||
work.stateSinceMs = elapsedMs;
|
||||
work.craftMs = REDO_MS;
|
||||
combo = 0; // 回炉断 combo
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 呼吸相位 0..1(基于 elapsedMs 算 sin 输入,纯确定性)——给 renderPlay 让就绪工位脉冲。 */
|
||||
function pulse01() {
|
||||
return 0.5 + 0.5 * Math.sin((elapsedMs / PULSE_PERIOD_MS) * Math.PI * 2);
|
||||
}
|
||||
|
||||
/** 三场景注册(每场景各调一次 define)。 */
|
||||
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),
|
||||
pulse01: pulse01(),
|
||||
});
|
||||
},
|
||||
});
|
||||
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', '年画作坊就绪 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);
|
||||
sceneFsm.update(dt);
|
||||
if (sceneFsm.current() === SCENE_PLAY) {
|
||||
elapsedMs += dt * 1000;
|
||||
updateWork();
|
||||
updateFloats(dt);
|
||||
}
|
||||
},
|
||||
|
||||
render(g) {
|
||||
if (!inited) return;
|
||||
sceneFsm.render(g);
|
||||
if (juice && typeof juice.render === 'function') {
|
||||
try { juice.render(g); } catch (_) { /* 容错 */ }
|
||||
}
|
||||
},
|
||||
|
||||
handleTap,
|
||||
destroy() {
|
||||
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
|
||||
},
|
||||
|
||||
/**
|
||||
* 取证契约:实时算 phase/score/targets/combo/解锁等。
|
||||
* ★ 必须在函数体内实时算,不能闭包冻结(否则 host 读 boot 快照=永 menu)。
|
||||
* targets = 每工位一项;当前工序在窗口内可点 → occupied:true,
|
||||
* 其余 false(自动验收 driver 据此驱动游戏)。
|
||||
*/
|
||||
_forensicsView() {
|
||||
return {
|
||||
state() {
|
||||
const cur = sceneFsm ? sceneFsm.current() : null;
|
||||
// 实时算 targets(每工位)
|
||||
const targets = [];
|
||||
for (let i = 0; i < stations.length; i++) {
|
||||
const st = stations[i];
|
||||
let occupied = false;
|
||||
if (cur === SCENE_PLAY && work && i === work.stepIndex && isStepTappable(work, elapsedMs)) {
|
||||
occupied = true;
|
||||
}
|
||||
targets.push({ x: st.x, y: st.y, occupied });
|
||||
}
|
||||
return {
|
||||
phase: cur,
|
||||
score: sessionScore ? sessionScore.getScore() : wage,
|
||||
targets,
|
||||
wage,
|
||||
works: worksDone.length,
|
||||
redos,
|
||||
combo,
|
||||
bestCombo,
|
||||
crits: totalCrits,
|
||||
lastCrit,
|
||||
nextStep: work ? (CRAFT_CHAIN[work.stepIndex] && CRAFT_CHAIN[work.stepIndex].name) : null,
|
||||
unlocked: (WORKS || []).filter((w) => wage >= (w.unlockAtWage || 0)).map((w) => w.name),
|
||||
remainMs: Math.max(0, ROUND_MS - elapsedMs),
|
||||
};
|
||||
},
|
||||
measures() {
|
||||
return Object.assign({}, measures, { score: sessionScore ? sessionScore.getScore() : wage, wage, works: worksDone.length, redos, combo, bestCombo, crits: totalCrits });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
379
game-runtime/games/_fewshot-feiyi/src/render.js
Normal file
379
game-runtime/games/_fewshot-feiyi/src/render.js
Normal file
@ -0,0 +1,379 @@
|
||||
/**
|
||||
* render.js — 【L3 表现层】木版年画作坊 · 绘制
|
||||
* 一律画在入参 g(= boot.mainContext)。本款全程序化绘制(无外部美术)。
|
||||
*
|
||||
* 美术风格:「纸艺高对比」—— 宣纸白底 + 朱红/靛蓝/藤黄(年画调性);
|
||||
* 案台面 = 一张长宣纸;工位 = 各色墨印/工具;成品出炉展示朱红方框大字。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { CRAFT_CHAIN, WORKS, UNLOCK_PROGRESS_TOTAL } from './core.js';
|
||||
|
||||
/* ── 配色(年画调性:朱砂红 + 靛蓝 + 藤黄 + 宣纸米白)── */
|
||||
const C = {
|
||||
// 背景
|
||||
bgWall: '#2b1810', // 深木墙
|
||||
bgWallAccent: '#3d261a',
|
||||
lattice: '#5c3a26',
|
||||
// 案台(宣纸)
|
||||
paper: '#f3e8c2', // 宣纸米白
|
||||
paperEdge: '#d8c896',
|
||||
paperShadow: 'rgba(0,0,0,0.35)',
|
||||
// 文本
|
||||
text: '#241208', // 在宣纸上的字
|
||||
textLight: '#fbeec2', // 在深底上的字
|
||||
textDim: '#8c6a4a',
|
||||
// 状态
|
||||
ready: '#1f7a3a', // 就绪:墨绿
|
||||
readyGlow: '#7ed957',
|
||||
warn: '#caa84a',
|
||||
danger: '#c8261a',
|
||||
done: '#7a9b3e', // 完成:藤黄绿
|
||||
pending: 'rgba(60,40,20,0.45)',
|
||||
// 朱砂红(年画主色)
|
||||
vermilion: '#c8261a',
|
||||
vermilionDark: '#7a1810',
|
||||
// 靛蓝
|
||||
indigo: '#1f4e7a',
|
||||
// 装饰
|
||||
accentGold: '#caa84a',
|
||||
panel: 'rgba(0,0,0,0.55)',
|
||||
panelLight: 'rgba(255,236,194,0.92)',
|
||||
stroke: 'rgba(255,236,194,0.35)',
|
||||
strokeDark: 'rgba(36,18,8,0.55)',
|
||||
};
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** 铺底:深木墙 + 木格窗 + 牌匾(年画坊名)。 */
|
||||
function drawBackdrop(g, VW, VH) {
|
||||
// 深木墙
|
||||
g.fillStyle = C.bgWall; g.fillRect(0, 0, VW, VH * 0.50);
|
||||
g.fillStyle = C.bgWallAccent; g.fillRect(0, VH * 0.50, VW, VH * 0.50);
|
||||
// 横梁
|
||||
g.fillStyle = '#1a0e08'; g.fillRect(0, 0, VW, 8);
|
||||
g.fillStyle = '#1a0e08'; g.fillRect(0, VH * 0.50 - 4, VW, 8);
|
||||
// 两扇木格窗
|
||||
const winY = 70, winH = 90, winW = 72;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const wx = VW * (0.22 + i * 0.56) - winW / 2;
|
||||
g.fillStyle = '#160c06'; g.fillRect(wx, winY, winW, winH);
|
||||
g.strokeStyle = C.lattice; g.lineWidth = 2.5; g.strokeRect(wx, winY, winW, winH);
|
||||
g.beginPath();
|
||||
g.moveTo(wx + winW / 2, winY); g.lineTo(wx + winW / 2, winY + winH);
|
||||
g.moveTo(wx, winY + winH / 2); g.lineTo(wx + winW, winY + winH / 2);
|
||||
g.stroke();
|
||||
}
|
||||
// 牌匾(年画坊)—— 标题叠在 renderMenu 里画
|
||||
g.fillStyle = C.vermilion;
|
||||
g.fillRect(VW / 2 - 110, 22, 220, 38);
|
||||
g.strokeStyle = C.vermilionDark; g.lineWidth = 3;
|
||||
g.strokeRect(VW / 2 - 110, 22, 220, 38);
|
||||
}
|
||||
|
||||
/** 一张长宣纸案台。 */
|
||||
function drawBench(g, VW, VH) {
|
||||
// 案台阴影
|
||||
g.fillStyle = C.paperShadow;
|
||||
g.fillRect(VW * 0.04, VH * 0.565, VW * 0.92, VH * 0.18);
|
||||
// 宣纸
|
||||
g.fillStyle = C.paper;
|
||||
g.fillRect(VW * 0.06, VH * 0.555, VW * 0.88, VH * 0.16);
|
||||
// 纸边毛笔感(浅描边)
|
||||
g.strokeStyle = C.paperEdge; g.lineWidth = 2;
|
||||
g.strokeRect(VW * 0.06, VH * 0.555, VW * 0.88, VH * 0.16);
|
||||
// 案台桌脚暗影
|
||||
g.fillStyle = '#1a0e08';
|
||||
g.fillRect(0, VH * 0.715, VW, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个工位:工具/物件图 + 状态圈 + 工序名。
|
||||
* 状态: 'pending' | 'crafting' | 'ready' | 'done'
|
||||
* 呼吸脉冲用入参 pulse01(0..1)驱动,避免依赖裸 performance.now。
|
||||
*/
|
||||
function drawStation(g, st, step, state, ratio, pulse01) {
|
||||
// 工位阴影
|
||||
g.beginPath(); g.ellipse(st.x, st.y + 38, 42, 10, 0, 0, Math.PI * 2);
|
||||
g.fillStyle = C.paperShadow; g.fill();
|
||||
// 工位物件(按工序绘制不同工具)
|
||||
drawTool(g, st.x, st.y, step, state);
|
||||
// 状态圈
|
||||
if (state === 'ready') {
|
||||
// 就绪:高亮绿圈 + 呼吸
|
||||
const pulse = 0.85 + 0.15 * (pulse01 != null ? pulse01 : 0.5);
|
||||
g.beginPath(); g.arc(st.x, st.y, 40 * pulse, 0, Math.PI * 2);
|
||||
g.strokeStyle = C.readyGlow; g.lineWidth = 4; g.stroke();
|
||||
g.beginPath(); g.arc(st.x, st.y, 46, 0, Math.PI * 2);
|
||||
g.strokeStyle = C.ready; g.lineWidth = 2; g.stroke();
|
||||
// 火候窗口条
|
||||
const barW = 70, barH = 8, bx = st.x - barW / 2, by = st.y - 62;
|
||||
g.fillStyle = 'rgba(36,18,8,0.55)'; g.fillRect(bx, by, barW, barH);
|
||||
g.fillStyle = ratio > 0.5 ? C.readyGlow : (ratio > 0.25 ? C.warn : C.danger);
|
||||
g.fillRect(bx, by, barW * ratio, barH);
|
||||
g.strokeStyle = C.strokeDark; 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, 38, 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, 36, 0, Math.PI * 2);
|
||||
g.strokeStyle = C.done; g.lineWidth = 3; g.stroke();
|
||||
// 打勾
|
||||
g.beginPath();
|
||||
g.moveTo(st.x - 12, st.y);
|
||||
g.lineTo(st.x - 4, st.y + 9);
|
||||
g.lineTo(st.x + 14, st.y - 10);
|
||||
g.strokeStyle = C.done; g.lineWidth = 4; g.stroke();
|
||||
} else {
|
||||
// pending:虚线灰圈
|
||||
g.setLineDash([3, 5]); g.beginPath(); g.arc(st.x, st.y, 36, 0, Math.PI * 2);
|
||||
g.strokeStyle = C.pending; g.lineWidth = 2; g.stroke(); g.setLineDash([]);
|
||||
}
|
||||
// 工序名
|
||||
g.font = '700 14px "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillStyle = state === 'pending' ? C.textDim : C.text;
|
||||
g.fillText(step.name, st.x, st.y + 56);
|
||||
}
|
||||
|
||||
/** 工位上的工具/物件(每道工序不同,程序化绘制)。 */
|
||||
function drawTool(g, x, y, step, state) {
|
||||
const col = state === 'pending' ? C.pending : step.color;
|
||||
g.save();
|
||||
g.translate(x, y);
|
||||
const idx = step.name;
|
||||
if (idx === '刻版') {
|
||||
// 木版 + 刻线
|
||||
g.fillStyle = '#7a4a26';
|
||||
g.fillRect(-22, -10, 44, 22);
|
||||
g.fillStyle = col;
|
||||
g.fillRect(-22, -10, 44, 4);
|
||||
g.strokeStyle = col; g.lineWidth = 2;
|
||||
g.beginPath(); g.moveTo(-14, -2); g.lineTo(-6, 6); g.stroke();
|
||||
g.beginPath(); g.moveTo(0, -4); g.lineTo(8, 4); g.stroke();
|
||||
g.beginPath(); g.moveTo(-10, 6); g.lineTo(10, -2); g.stroke();
|
||||
// 刻刀(右上)
|
||||
g.fillStyle = '#444';
|
||||
g.fillRect(14, -22, 4, 14);
|
||||
g.fillStyle = '#888';
|
||||
g.beginPath();
|
||||
g.moveTo(16, -8); g.lineTo(13, -2); g.lineTo(19, -2); g.closePath();
|
||||
g.fill();
|
||||
} else if (idx === '上墨') {
|
||||
// 墨辊
|
||||
g.save();
|
||||
g.rotate(-0.3);
|
||||
g.fillStyle = '#5a3a24';
|
||||
g.fillRect(-24, -4, 48, 8);
|
||||
g.fillStyle = col;
|
||||
g.fillRect(-24, -4, 48, 4);
|
||||
g.fillStyle = '#7a4a26';
|
||||
g.fillRect(-30, -3, 6, 6);
|
||||
g.fillRect(24, -3, 6, 6);
|
||||
g.restore();
|
||||
} else if (idx === '铺纸') {
|
||||
// 宣纸方块
|
||||
g.fillStyle = col;
|
||||
g.fillRect(-20, -16, 40, 32);
|
||||
g.strokeStyle = C.strokeDark; g.lineWidth = 1.5;
|
||||
g.strokeRect(-20, -16, 40, 32);
|
||||
g.beginPath();
|
||||
g.moveTo(-20, 0); g.lineTo(20, 0);
|
||||
g.strokeStyle = C.paperEdge; g.lineWidth = 1; g.stroke();
|
||||
} else if (idx === '拓印') {
|
||||
// 棕刷 + 版
|
||||
g.fillStyle = '#7a4a26';
|
||||
g.fillRect(-22, -4, 44, 16);
|
||||
g.fillStyle = col;
|
||||
g.fillRect(-22, -4, 44, 4);
|
||||
g.strokeStyle = col; g.lineWidth = 1.5;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const px = -18 + i * 9;
|
||||
g.beginPath(); g.moveTo(px, 12); g.lineTo(px + 2, 18); g.stroke();
|
||||
}
|
||||
} else {
|
||||
g.beginPath(); g.arc(0, 0, 22, 0, Math.PI * 2);
|
||||
g.fillStyle = col; g.fill();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/** 顶部 HUD:工钱 + 成品数 + 倒计时。 */
|
||||
function drawHud(g, hud, info) {
|
||||
hud.drawPanel(g, { x: 8, y: 76, w: info.vw - 16, h: 52 }, { fill: C.panel, stroke: C.stroke, lineWidth: 2, radius: 12 });
|
||||
hud.drawText(g, '工钱 ' + info.wage, 22, 102, { font: '700 22px "PingFang SC",sans-serif', color: C.accentGold, align: 'left', baseline: 'middle' });
|
||||
hud.drawText(g, '年画 ' + info.works, info.vw / 2, 102, { font: '600 18px "PingFang SC",sans-serif', color: C.textLight, align: 'center', baseline: 'middle' });
|
||||
const sec = Math.max(0, Math.ceil(info.remainMs / 1000));
|
||||
const tcol = info.remainMs < 10000 ? C.danger : C.textLight;
|
||||
hud.drawText(g, '剩余 ' + sec + 's', info.vw - 22, 102, { font: '700 20px "PingFang SC",sans-serif', color: tcol, 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.drawPanel(g, { x: view.vw * 0.10, y: view.vh * 0.42, w: view.vw * 0.80, h: 36 },
|
||||
{ fill: C.panelLight, stroke: C.strokeDark, lineWidth: 1.5, radius: 8 });
|
||||
hud.drawText(g, '在印:' + view.work.name + ' · ' + stepName + '(' + (idx + 1) + '/' + total + ')',
|
||||
view.vw / 2, view.vh * 0.42 + 18, { font: '700 16px "PingFang SC",sans-serif', color: C.text, align: 'center', baseline: 'middle' });
|
||||
}
|
||||
|
||||
/** 图鉴条(底部):已解锁/未解锁的图样预览 —— 「印得多解锁新吉祥图样」的视觉钩子。 */
|
||||
function drawCodex(g, hud, view) {
|
||||
const yTop = view.vh * 0.78;
|
||||
// 浅纸面
|
||||
hud.drawPanel(g, { x: view.vw * 0.06, y: yTop, w: view.vw * 0.88, h: view.vh * 0.16 },
|
||||
{ fill: C.paper, stroke: C.strokeDark, lineWidth: 1.5, radius: 10 });
|
||||
// "图鉴"小标
|
||||
hud.drawText(g, '图鉴', view.vw * 0.10, yTop + 14, { font: '700 12px "PingFang SC",sans-serif', color: C.text, align: 'left', baseline: 'middle' });
|
||||
// 解锁进度条
|
||||
const pbW = view.vw * 0.55;
|
||||
hud.drawBar(g, { x: view.vw * 0.18, y: yTop + 8, w: pbW, h: 8 }, Math.min(1, view.wage / UNLOCK_PROGRESS_TOTAL),
|
||||
{ bg: 'rgba(0,0,0,0.25)', fill: C.vermilion, stroke: C.strokeDark, radius: 4 });
|
||||
hud.drawText(g, view.wage + ' / ' + UNLOCK_PROGRESS_TOTAL, view.vw * 0.18 + pbW + 6, yTop + 12, { font: '600 11px sans-serif', color: C.textDim, align: 'left', baseline: 'middle' });
|
||||
// 图样格(每件一格:大字 + 解锁/未解锁底色)
|
||||
const cellW = 44, cellH = 56;
|
||||
const cellY = yTop + 28;
|
||||
const startX = view.vw * 0.18;
|
||||
for (let i = 0; i < WORKS.length; i++) {
|
||||
const w = WORKS[i];
|
||||
const cx = startX + i * (cellW + 4);
|
||||
const unlocked = view.wage >= (w.unlockAtWage || 0);
|
||||
// 格背景
|
||||
hud.drawPanel(g, { x: cx, y: cellY, w: cellW, h: cellH },
|
||||
{ fill: unlocked ? w.bgColor : 'rgba(60,40,20,0.18)', stroke: unlocked ? w.bgColor : C.textDim, lineWidth: 1.5, radius: 6 });
|
||||
if (unlocked) {
|
||||
// 大字
|
||||
g.fillStyle = '#fbeec2';
|
||||
g.font = '900 26px "STKaiti", "KaiTi", "PingFang SC", serif';
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText(w.glyph, cx + cellW / 2, cellY + 24);
|
||||
hud.drawText(g, w.name, cx + cellW / 2, cellY + cellH - 6, { font: '600 10px "PingFang SC",sans-serif', color: '#fbeec2', align: 'center', baseline: 'middle' });
|
||||
} else {
|
||||
// 锁图标(用文字符号)
|
||||
g.fillStyle = C.textDim;
|
||||
g.font = '700 18px sans-serif';
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText('LOCK', cx + cellW / 2, cellY + 22);
|
||||
hud.drawText(g, w.unlockAtWage + '钱', cx + cellW / 2, cellY + cellH - 6, { font: '600 10px "PingFang SC",sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成飘字。 */
|
||||
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.combo) {
|
||||
g.font = '900 26px "PingFang SC",sans-serif';
|
||||
g.fillStyle = C.accentGold; g.strokeStyle = '#241208'; g.lineWidth = 4;
|
||||
g.strokeText('连击 ×' + f.amount, f.x, f.y);
|
||||
g.fillText('连击 ×' + f.amount, f.x, f.y);
|
||||
} else if (f.crit) {
|
||||
g.font = '900 28px "PingFang SC",sans-serif';
|
||||
g.fillStyle = C.vermilion; g.strokeStyle = '#241208'; g.lineWidth = 4;
|
||||
g.strokeText('正窗! +' + f.amount, f.x, f.y);
|
||||
g.fillText('正窗! +' + f.amount, f.x, f.y);
|
||||
} else if (f.big) {
|
||||
g.font = '900 30px "STKaiti","KaiTi",serif';
|
||||
g.fillStyle = C.danger; g.strokeStyle = '#241208'; g.lineWidth = 5;
|
||||
g.strokeText(f.big + ' 印成! +' + f.amount, f.x, f.y);
|
||||
g.fillText(f.big + ' 印成! +' + f.amount, f.x, f.y);
|
||||
} else {
|
||||
g.font = '800 24px "PingFang SC",sans-serif';
|
||||
g.fillStyle = C.accentGold; g.strokeStyle = '#241208'; g.lineWidth = 4;
|
||||
g.strokeText('+' + f.amount, f.x, f.y);
|
||||
g.fillText('+' + f.amount, f.x, f.y);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────── 场景绘制入口 ────────── */
|
||||
|
||||
/** menu:牌匾标题 + 开工按钮。 */
|
||||
export function renderMenu(g, hud, view) {
|
||||
drawBackdrop(g, view.vw, view.vh);
|
||||
// 暗化
|
||||
g.fillStyle = 'rgba(15,8,4,0.55)'; g.fillRect(0, 0, view.vw, view.vh);
|
||||
// 牌匾标题(画在牌匾上)
|
||||
hud.drawText(g, '年画作坊', view.vw / 2, 41, { font: '900 24px "STKaiti","KaiTi",serif', color: C.textLight, align: 'center', baseline: 'middle' });
|
||||
// 大标题
|
||||
hud.drawText(g, '赶在年前', view.vw / 2, view.vh * 0.26, { font: '900 42px "STKaiti","KaiTi",serif', color: C.vermilion, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '印出最多年画', view.vw / 2, view.vh * 0.26 + 56, { font: '900 32px "STKaiti","KaiTi",serif', color: C.accentGold, align: 'center', baseline: 'middle' });
|
||||
// 副标
|
||||
hud.drawText(g, '60s 内刻版→上墨→铺纸→拓印,工钱越多解锁新图样',
|
||||
view.vw / 2, view.vh * 0.45, { font: '14px "PingFang SC",sans-serif', color: C.textLight, align: 'center', baseline: 'middle' });
|
||||
// 开工按钮
|
||||
const w = 240, h = 60;
|
||||
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.62 - h / 2, w, h },
|
||||
{ label: '开 印', font: '800 24px "STKaiti","KaiTi",serif', fill: C.vermilion, textColor: C.textLight, radius: 30 });
|
||||
return { startBtn };
|
||||
}
|
||||
|
||||
/**
|
||||
* play:背景 + 工位链 + 作品条 + 图鉴 + 飘字 + HUD。
|
||||
* pulse01 = 0..1 的呼吸相位(0..1 循环),由 game-logic 经受控时钟算后传入。
|
||||
*/
|
||||
export function renderPlay(g, hud, view) {
|
||||
const { vw, vh, stations, work, floats, wage, works, remainMs, nowMs, pulse01 } = view;
|
||||
drawBackdrop(g, vw, vh);
|
||||
drawBench(g, vw, vh);
|
||||
drawWorkLine(g, hud, view);
|
||||
// 工位
|
||||
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, pulse01);
|
||||
}
|
||||
drawFloats(g, floats);
|
||||
drawHud(g, hud, { vw, vh, wage, works, remainMs });
|
||||
drawCodex(g, hud, { vw, vh, wage });
|
||||
}
|
||||
|
||||
/** over:今日工钱 + 出炉作品展示 + 图鉴进度 + 最佳纪录 + 再开按钮。 */
|
||||
export function renderOver(g, hud, view) {
|
||||
drawBackdrop(g, view.vw, view.vh);
|
||||
g.fillStyle = 'rgba(15,8,4,0.60)'; g.fillRect(0, 0, view.vw, view.vh);
|
||||
// 标题
|
||||
hud.drawText(g, '收 工 咯', view.vw / 2, view.vh * 0.16, { font: '900 36px "STKaiti","KaiTi",serif', color: C.vermilion, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '工钱 ' + view.wage, view.vw / 2, view.vh * 0.26, { font: '900 50px "STKaiti","KaiTi",serif', color: C.accentGold, align: 'center', baseline: 'middle' });
|
||||
// 成品展示条
|
||||
const itemY = view.vh * 0.36;
|
||||
hud.drawPanel(g, { x: view.vw * 0.08, y: itemY, w: view.vw * 0.84, h: 64 },
|
||||
{ fill: C.paper, stroke: C.strokeDark, lineWidth: 2, radius: 10 });
|
||||
const names = (view.worksDone && view.worksDone.length) ? view.worksDone.join(' · ') : '今日没印出年画';
|
||||
hud.drawText(g, '印出:' + names, view.vw / 2, itemY + 22, { font: '700 16px "PingFang SC",sans-serif', color: C.text, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '成品 ' + (view.worksDone ? view.worksDone.length : 0) + ' 回炉 ' + view.redos,
|
||||
view.vw / 2, itemY + 46, { font: '600 15px "PingFang SC",sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
|
||||
// 最佳纪录
|
||||
hud.drawText(g, '最佳工钱 ' + view.best, view.vw / 2, view.vh * 0.50, { font: '700 18px "PingFang SC",sans-serif', color: C.accentGold, align: 'center', baseline: 'middle' });
|
||||
// 提示
|
||||
hud.drawText(g, '印得越多,「麒麟」「童子抱鱼」会陆续解锁哦~',
|
||||
view.vw / 2, view.vh * 0.56, { font: '13px "PingFang SC",sans-serif', color: C.textLight, align: 'center', baseline: 'middle' });
|
||||
// 再开按钮
|
||||
const w = 240, h = 60;
|
||||
const restartBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.70 - h / 2, w, h },
|
||||
{ label: '再开一印', font: '800 24px "STKaiti","KaiTi",serif', fill: C.vermilion, textColor: C.textLight, radius: 30 });
|
||||
return { restartBtn };
|
||||
}
|
||||
53
game-runtime/games/_template-feiyi/README.md
Normal file
53
game-runtime/games/_template-feiyi/README.md
Normal file
@ -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/<name>/api.d.ts` 为准**,别臆测(scene-fsm 是 `define` 不是 defineScenes)。本骨架已用对的母语写法:`sessionScore.add(n)` 计分、`juice.burst(x,y)` 爆点、`hudUi.drawButton(g,rect,opts)` 返回命中矩形可直接接住判命中。
|
||||
- **画面读 `viewport.w/h`**,别硬编码 390×844。
|
||||
- **`_forensicsView().state()` 的工序契约**:`targets` 列每工位一项、「当前就绪且窗口内可点」的工位 `occupied:true`(自动验收只点这些工位驱动工序);`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 速查 + ❌→✅ 易犯错)。
|
||||
24
game-runtime/games/_template-feiyi/assets/gen-ledger.md
Normal file
24
game-runtime/games/_template-feiyi/assets/gen-ledger.md
Normal file
@ -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 | 结果 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| — | — | (本模板无外采素材) | — | — | — | — |
|
||||
6
game-runtime/games/_template-feiyi/assets/manifest.json
Normal file
6
game-runtime/games/_template-feiyi/assets/manifest.json
Normal file
@ -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": []
|
||||
}
|
||||
33
game-runtime/games/_template-feiyi/entry.js
Normal file
33
game-runtime/games/_template-feiyi/entry.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* entry.js — 「点圆得分」的 esbuild 打包入口(引擎 import 只落本文件;打成 iife → index.html 加载)
|
||||
* owner:_template 克隆起点 | 消费方:scripts/build.mjs(打包)→ index.html(运行)
|
||||
*
|
||||
* 【定位 · 精简自 wanglanmei entry.js】
|
||||
* 把「core 协议 + 所用插件 + 游戏(逻辑/渲染/宿主)+ 引擎」汇成一个 iife 全局(TemplateGame),
|
||||
* index.html 调 bootGame 启动。**引擎 import 只活本文件**(Q4 铁律),且经动态 import 延后求值:
|
||||
* · node import entry.js 时**不**触发引擎装载(bootGame 未被调用)→ entry.js node import-safe;
|
||||
* · esbuild(bundle+iife,无 splitting)把动态 import 的引擎内联进单文件产物;
|
||||
* · 浏览器运行期调 bootGame 时才求值引擎(window/AudioContext 已存在),拿名字空间注入 main.js real 通道。
|
||||
* Q4 铁律不破:main.js(host 层)经注入用引擎、不直接 import;game.js 游戏工厂零引擎 import。
|
||||
*
|
||||
* 【构建(沿 scripts/build.mjs 锁参)】
|
||||
* node scripts/build.mjs games/_template/entry.js games/_template/dist/template-bundle.js --global-name=TemplateGame
|
||||
* (本模板未预置 dist 产物;克隆后按上行构建即可在浏览器跑 ?engine=real;node headless 走 src/main.js stub,免构建。)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { bootGame as bootGameCore } from './src/main.js';
|
||||
|
||||
/**
|
||||
* 对外 bootGame:动态 import 引擎名字空间后注入、委托 main.js 真实现。
|
||||
* index.html 调的是 iife 全局 TemplateGame.bootGame(即本包装)。
|
||||
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} [opts]
|
||||
* @returns {Promise<object>} gameHost 句柄
|
||||
*/
|
||||
export async function bootGame(opts) {
|
||||
// 动态 import:求值时机延后到 bootGame 被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
// 调用方已显式给 engine 则尊重其值,不重复装载(便于复用)。
|
||||
const engine = (opts && opts.engine) || (await import('littlejsengine'));
|
||||
return bootGameCore(Object.assign({}, opts, { engine }));
|
||||
}
|
||||
100
game-runtime/games/_template-feiyi/index.html
Normal file
100
game-runtime/games/_template-feiyi/index.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.html — 「点圆得分」(_template 克隆起点)宿主页
|
||||
用法:
|
||||
· 构建产物(iife 全局 TemplateGame):见 entry.js 头注释的 build.mjs 锁参命令(产物 dist/template-bundle.js);
|
||||
· serve 本目录,浏览器打开;
|
||||
· ?seed=12345 注入主随机种子(缺省固定种子,确定可复现);
|
||||
· ?engine=stub(缺省·回滚锚)/ ?engine=real(引擎掌帧);
|
||||
· ?mode=evidence 取证模式:stub 下不起 RAF,由 window.__gameHost.stepFrames/tap 确定性驱动;
|
||||
· 取证导出:window.__gameForensics / window.__gameHost;就绪标记:__gameBooted。
|
||||
注意:页面壳只负责承载;游戏全部绘制在 canvas 内。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;禁缩放防像素哈希扰动。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>点圆得分</title>
|
||||
<style>
|
||||
/* 自适应居中画布:游戏全部绘制在 canvas 内,页面壳只承载。
|
||||
画布按 390:844 比例缩放 fit 窗口——桌面短窗口不裁底(否则底部内容如按钮/角色看不见)。
|
||||
host 输入映射按 getBoundingClientRect 归一化(boot-game-host toCanvasXY),CSS 缩放下点击坐标仍准。 */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a1622;
|
||||
color: #9fb3c8;
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#game {
|
||||
/* 取「宽受限」与「高受限」的较小者,保 390:844 比例完整可见。
|
||||
!important 覆盖 host 在 stub 通道写的内联 display(390×844 硬编码);buffer 不动→evidence 像素哈希不受影响;
|
||||
输入经 boot-game-host toCanvasXY 按 getBoundingClientRect 归一,CSS 缩放下点击坐标仍准。 */
|
||||
width: min(100vw, calc(100vh * 390 / 844)) !important;
|
||||
height: min(100vh, calc(100vw * 844 / 390)) !important;
|
||||
background: #10243a;
|
||||
display: block;
|
||||
touch-action: none; /* 触摸全交 JS(pointer 桥接),禁浏览器默认手势 */
|
||||
}
|
||||
#status {
|
||||
position: absolute; /* 浮层叠于 canvas 顶部,不占布局流 */
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none; /* 绝不截触摸 */
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
min-height: 15px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 构建产物(iife 全局 TemplateGame,含 bootGame)。克隆后按 entry.js 头注释构建生成。 -->
|
||||
<script src="./dist/template-bundle.js"></script>
|
||||
<script>
|
||||
// 引导:异步 boot;失败把错误打到 status + 重抛(评估门要看 console 零未捕获错误)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
try {
|
||||
var G = window.TemplateGame;
|
||||
if (!G || typeof G.bootGame !== 'function') {
|
||||
throw new Error('TemplateGame.bootGame 未找到(template-bundle.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGame({ canvas: canvas, statusEl: statusEl })
|
||||
.then(function () { window.__gameBooted = true; })
|
||||
.catch(function (e) {
|
||||
window.__gameBooted = false;
|
||||
window.__gameBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
|
||||
throw e;
|
||||
});
|
||||
} catch (e) {
|
||||
window.__gameBooted = false;
|
||||
window.__gameBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
73
game-runtime/games/_template-feiyi/src/assets.js
Normal file
73
game-runtime/games/_template-feiyi/src/assets.js
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件)
|
||||
* owner:_template 克隆起点 | 消费方:game.js(init 时 await loadAssets)/ render.js(若用图则取)
|
||||
*
|
||||
* 【本款现状:无外部资产(纯程序化图形)】
|
||||
* 「点圆得分」全用 g 原生绘制(圆/矩形/文字),不需任何 .jpg/.mp3,故 IMAGE_FILES 为空。
|
||||
* 但**声明模式的结构必须留全**——这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键,
|
||||
* render 里用 assets.images.<键> 取图,装载/容错链条已就位,不必重搭。
|
||||
*
|
||||
* 【容错铁律(发行版红线 §7-5)】
|
||||
* 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退),
|
||||
* 绝不因资产把游戏带崩。本款无资产 → loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)。
|
||||
* node 环境无 Image → 同样安全返回空集(import-safe)。
|
||||
* 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载;本款无音频)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 图像资产键 → 文件名(与 assets/manifest.json 对应)。
|
||||
* 【在此声明图片资产键→文件名】本款无美术故为空对象 {}。
|
||||
* 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)。
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
export const IMAGE_FILES = {};
|
||||
|
||||
/**
|
||||
* 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。
|
||||
* @param {string} [baseUrl] 资产根(缺省 './assets/')
|
||||
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
|
||||
*/
|
||||
export function loadAssets(baseUrl) {
|
||||
const root = baseUrl == null ? './assets/' : baseUrl;
|
||||
const keys = Object.keys(IMAGE_FILES);
|
||||
/** @type {Record<string, HTMLImageElement|null>} */
|
||||
const images = {};
|
||||
/** @type {string[]} */
|
||||
const missing = [];
|
||||
|
||||
/** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */
|
||||
function loadOne(key) {
|
||||
return new Promise((resolve) => {
|
||||
// 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。
|
||||
if (typeof Image === 'undefined') {
|
||||
images[key] = null;
|
||||
missing.push(key);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
images[key] = img;
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
// 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。
|
||||
console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]);
|
||||
images[key] = null;
|
||||
missing.push(key);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = root + IMAGE_FILES[key];
|
||||
});
|
||||
}
|
||||
|
||||
// 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。
|
||||
return Promise.all(keys.map(loadOne)).then(() => ({
|
||||
images,
|
||||
missing,
|
||||
loaded: keys.length - missing.length,
|
||||
total: keys.length,
|
||||
}));
|
||||
}
|
||||
153
game-runtime/games/_template-feiyi/src/core.js
Normal file
153
game-runtime/games/_template-feiyi/src/core.js
Normal file
@ -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;
|
||||
}
|
||||
303
game-runtime/games/_template-feiyi/src/game-logic.js
Normal file
303
game-runtime/games/_template-feiyi/src/game-logic.js
Normal file
@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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 契约**
|
||||
* · ⚠ 改写本文件时 **import 列表必须与你用到的 core.js 导出一一对齐**:漏 import 的裸标识符
|
||||
* check/build 都拦不住(esbuild 当全局放行),运行时点一下就 ReferenceError 被隔离 → 永卡菜单、九门全挂。
|
||||
*
|
||||
* 入参(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;
|
||||
}
|
||||
73
game-runtime/games/_template-feiyi/src/game.js
Normal file
73
game-runtime/games/_template-feiyi/src/game.js
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* game.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】装载契约 GameHostFactory 薄 wrapper。
|
||||
* owner:_template L1 固定骨架 | 消费方:host-config.js(templateFactory)。
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责 · 为何固定】
|
||||
* 把 host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 的 game-logic.js。
|
||||
* —— 把"易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**,
|
||||
* 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` → 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类。
|
||||
* 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool 拒)。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。
|
||||
import { createGame as createGameLogic } from './game-logic.js';
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。
|
||||
* ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport});
|
||||
* ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入,
|
||||
* pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。
|
||||
* —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
|
||||
* (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。
|
||||
* 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。
|
||||
* @param {Object} [opts] 工厂选项(host wiring)。
|
||||
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
|
||||
*/
|
||||
export default function gameFactory(opts) {
|
||||
const runtime = (opts && opts.runtime) || {};
|
||||
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
|
||||
const game = createGameLogic({
|
||||
plugins: runtime.plugins || {},
|
||||
bundle: runtime.bundle,
|
||||
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
|
||||
});
|
||||
|
||||
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
|
||||
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
|
||||
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
|
||||
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
|
||||
let inputSubs = [];
|
||||
|
||||
game.init = async function init(boot) {
|
||||
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
|
||||
const ctx = boot && boot.ctx;
|
||||
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
|
||||
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
|
||||
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
|
||||
if (typeof game.handleTap === 'function') {
|
||||
inputSubs.push(input.on('pointerdown', (e) => {
|
||||
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
|
||||
}));
|
||||
}
|
||||
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
|
||||
if (typeof game.handleKey === 'function') {
|
||||
inputSubs.push(input.on('keydown', (e) => {
|
||||
try { game.handleKey((e && e.key) || ''); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
game.destroy = function destroy() {
|
||||
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
|
||||
inputSubs = [];
|
||||
if (innerDestroy) innerDestroy();
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
110
game-runtime/games/_template-feiyi/src/host-config.js
Normal file
110
game-runtime/games/_template-feiyi/src/host-config.js
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* host-config.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】宿主装配:实例化全标准能力插件集 + 视口 + 工厂 wiring。
|
||||
* owner:_template L1 固定骨架 | 消费方:main.js(spike)/ entry-bundle.js(SAA)经 buildTemplateHostConfig 装配 bootGameHost。
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【本文件做什么 · 为何固定】
|
||||
* 通用宿主 bootGameHost(opts) 需要四组装配:插件实例集 / 注册偏序 / 视口 / 工厂 wiring。
|
||||
* 本文件把"这游戏怎么配 bootGameHost"固化为**全游戏逐字相同**的单一来源。
|
||||
* 3 层架构(2026-06-22 边界设计)下,host-config 是 **L1 工程骨架**:LLM 不碰(writeFileTool 拒写),
|
||||
* 根除 M3 反复搞坏插件 wiring 的错类(viewport 漏键=reading 'w' / registerOrder / 取插件嵌套)。
|
||||
*
|
||||
* 【全标准能力插件集(D2 全注入,创始人裁 D9=A)】
|
||||
* 编排 4:sceneFsm/sessionScore/hudUi/timerScheduler · 持久:save · 手感/特效:gamefeel/juice/palettePost
|
||||
* · 音频:audioMusic · 物理:collision/physics —— 共 11 件全摆上桌,L3 按需取用(用到才解构)、不用的不解构即可。
|
||||
* 默认 config 与 generic-host-config 同口径(能力库默认面,非玩法);均 node-import-safe(碰受控资源只在 init/runtime 期)。
|
||||
*
|
||||
* 【Q4 铁律】本文件零引擎 import(仅装配插件实例 + 视口 + wiring);引擎由 bootGameHost 经 opts.engine 注入。node import-safe。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 编排 4 件(menu→play→over + 计分 + UI 原语 + 受控定时)。
|
||||
import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js';
|
||||
import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js';
|
||||
import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js';
|
||||
import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js';
|
||||
// 持久(最佳分等):优先 localStorage,不可用退内存。
|
||||
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
|
||||
// 手感/特效/后处理(打击感 / 粒子 / 暗角)。
|
||||
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
|
||||
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
|
||||
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
|
||||
// 音频(audio ctx 内部经 ctx.getAudioContext 懒接,L3 只调 play;无声卡 no-op)。
|
||||
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
|
||||
// 物理/碰撞(网格碰撞 + 轻物理)。
|
||||
import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js';
|
||||
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
|
||||
|
||||
// 装载契约 game 工厂(game.js 默认导出 = 薄 wrapper,L1 固定;实际玩法在 game-logic.js,L3 写)。
|
||||
import createGame from './game.js';
|
||||
|
||||
/** 本款游戏工厂(GameHostFactory)。 */
|
||||
export const templateFactory = createGame;
|
||||
|
||||
/** 逻辑视口(390×844 移动竖屏基准)。**L1 固定硬编码**:不从 render.js 导入,以免 LLM 改 render 时漏 W/H 致 viewport 失值 → boot 崩 reading 'w'。 */
|
||||
export const templateViewport = { w: 390, h: 844 };
|
||||
|
||||
/**
|
||||
* 装配调 bootGameHost 所需的全部入参(全 11 插件实例集 / 注册偏序 / 视口 / 工厂 wiring)。
|
||||
* main.js / entry-bundle.js 补 canvas/statusEl/engine/seed/mode/engineMode/onReady 后传 bootGameHost。
|
||||
* @param {{seed:number}} args
|
||||
* @returns {{factory:Function, plugins:object, registerOrder:Array<object>, viewport:{w:number,h:number}, buildFactoryOpts:Function}}
|
||||
*/
|
||||
export function buildTemplateHostConfig(args) {
|
||||
const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0;
|
||||
|
||||
/* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */
|
||||
let saveAdapter;
|
||||
try {
|
||||
saveAdapter = createLocalStorageAdapter();
|
||||
} catch (e) {
|
||||
console.warn('[_template] localStorage 不可用,存档退内存 adapter:' + (e && e.message));
|
||||
saveAdapter = createMemoryAdapter();
|
||||
}
|
||||
const save = createSaveProgressPlugin({ namespace: 'zaomeng-template', adapter: saveAdapter });
|
||||
|
||||
/* ── 编排 4(host 拥有,L3 经 plugins.<键> 用)── */
|
||||
const sceneFsm = createSceneFsmPlugin();
|
||||
// session-score 的 best 接 save:跨局保留最佳分(load/save 经 KV 面,失败容错降级)。
|
||||
const sessionScore = createSessionScorePlugin({
|
||||
best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) },
|
||||
});
|
||||
const hudUi = createHudUiPlugin();
|
||||
const timerScheduler = createTimerSchedulerPlugin();
|
||||
|
||||
/* ── 能力库 6(默认 config 同 generic-host-config 口径;seed 给粒子确定性)── */
|
||||
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
|
||||
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
|
||||
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
|
||||
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
|
||||
const collision = createCollisionPlugin({ cellSize: 96 });
|
||||
const physics = createPhysicsLitePlugin();
|
||||
|
||||
// 工厂注入键名(canonical;与 L3 game-logic.js 内 plugins.<键> 解构一致;skill 逐字列)。
|
||||
const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics };
|
||||
// 注册偏序(M1:全 11 合集显式定义):持久+编排先(save 先于 sessionScore 的 best 钩子)→ 基元(collision/physics/gamefeel/juice/palettePost)→ audioMusic 末(同 generic 偏序惯例,audio 依赖最少、放末)。
|
||||
const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic];
|
||||
|
||||
return {
|
||||
factory: templateFactory,
|
||||
plugins,
|
||||
registerOrder,
|
||||
viewport: templateViewport,
|
||||
/**
|
||||
* 构造工厂 opts(host wiring):经 runtime 把全插件集 + bundle + 真实 AudioContext 取法暴露给游戏工厂。
|
||||
* game.js wrapper(L1)吃掉 opts.runtime.{plugins,bundle} 两层解构,把扁平件 + viewport 交 L3 的 createGame。
|
||||
* @param {import('../../../src/core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用)。
|
||||
* @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄。
|
||||
*/
|
||||
buildFactoryOpts: (_bootCtx, hostInternals) => ({
|
||||
runtime: {
|
||||
plugins: hostInternals.plugins,
|
||||
bundle: hostInternals.bundle,
|
||||
getRealAudioCtx: hostInternals.getRealAudioCtx,
|
||||
viewport: templateViewport, // D3:viewport 下传,L3 读 viewport.w/h 不硬编码
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
132
game-runtime/games/_template-feiyi/src/main.js
Normal file
132
game-runtime/games/_template-feiyi/src/main.js
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* main.js — 「点圆得分」宿主引导(薄包装层:把通用宿主 bootGameHost 配成本款 bootGame)
|
||||
* owner:_template 克隆起点 | 消费方:entry.js(打包入口)→ index.html
|
||||
*
|
||||
* 【职责(精简自 wanglanmei main.js)】
|
||||
* 解析 URL 参数(seed/mode/engine)→ 装配本款专属项(取自 host-config)→ 调通用 bootGameHost
|
||||
* → 在 onReady 钩子里挂取证全局(window.__gameHost / __gameForensics),供 headless-boot 与 e2e CDP 驱动。
|
||||
*
|
||||
* 【双通道(沿用通用宿主)】
|
||||
* - ?engine=stub(**缺省**,回滚锚):浏览器 RAF + 单一墙钟,node headless 走此(无引擎注入);
|
||||
* - ?engine=real(显式开):引擎掌帧(浏览器 + entry 包装层注入引擎名字空间)。
|
||||
*
|
||||
* 【红线遵守】
|
||||
* - 引擎 import 只活 entry.js;main.js(host 层)经 opts.engine 注入用引擎、不直接 import(保 node import-safe);
|
||||
* - game.js 游戏工厂零引擎 import。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主:引擎五回调骨架 + 受控面背书 + 输入接线全在此(参数化游戏工厂/插件/视口)。
|
||||
import { bootGameHost } from '../../../src/host/boot-game-host.js';
|
||||
// 本款宿主装配配置(工厂/4 插件/视口/工厂 wiring)——bootGame 与打包入口共用同一份(防第二套)。
|
||||
import { buildTemplateHostConfig } from './host-config.js';
|
||||
// 协议号(取证导出用)。
|
||||
import { CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
|
||||
|
||||
/** 读 URL 参数(node 桩 window.location.search 亦可读)。 */
|
||||
function getParam(name, def) {
|
||||
try {
|
||||
const v = new URLSearchParams(window.location.search).get(name);
|
||||
return v == null ? def : v;
|
||||
} catch {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析种子(缺省固定值,保证「不传也确定」)。 */
|
||||
function resolveSeed() {
|
||||
const raw = getParam('seed', null);
|
||||
if (raw == null) return 0x1234abcd >>> 0;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动本款游戏(host)。退化为 bootGameHost 的薄包装:装配本款专属参数 + 组装取证全局。
|
||||
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} opts
|
||||
* - engine:引擎名字空间(littlejsengine),由 entry.js 包装层经 opts 注入(main.js 不顶层 import 引擎,保 node-safe)。
|
||||
* 仅 ?engine=real 通道用;stub/node 通道不触达(缺省 undefined)。
|
||||
* @returns {Promise<object>} gameHost 句柄(同时挂 window.__gameHost)
|
||||
*/
|
||||
export async function bootGame(opts) {
|
||||
const canvas = opts.canvas;
|
||||
const statusEl = opts.statusEl || null;
|
||||
const seed = resolveSeed();
|
||||
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
|
||||
// 引擎通道:缺省 stub(回滚锚 + node headless 走此);真玩取证显式带 &engine=real。
|
||||
const engineMode = getParam('engine', 'stub');
|
||||
|
||||
// 本款宿主装配(工厂/4 插件/注册偏序/视口/工厂 wiring):取自共用配置(与打包入口同源)。
|
||||
const cfg = buildTemplateHostConfig({ seed });
|
||||
|
||||
/** 取证内部件(onReady 注入:游戏取证视图等)。 */
|
||||
let gameForensics = null;
|
||||
/** @type {object|null} bootGameHost 返回的通用 host 句柄。 */
|
||||
let coreHost = null;
|
||||
|
||||
/** 本款取证导出(读 onReady 注入的内部件)。 */
|
||||
const forensics = {
|
||||
protocol: CORE_PROTOCOL_VERSION,
|
||||
seed,
|
||||
mode,
|
||||
/** 游戏状态快照(JSON 安全):phase/score/best/remainMs/target/...。 */
|
||||
state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }),
|
||||
/** 运行度量:frames/taps/hits。 */
|
||||
measures: () => (gameForensics ? gameForensics.measures() : {}),
|
||||
};
|
||||
|
||||
/** 驱动句柄(evidence 驱动 / e2e):在通用 host 句柄上扩展本款取证面。延迟读 coreHost(onReady 时已赋值)。 */
|
||||
const gameHost = {
|
||||
seed,
|
||||
mode,
|
||||
engineMode,
|
||||
protocol: CORE_PROTOCOL_VERSION,
|
||||
frameCount: () => (coreHost ? coreHost.frameCount() : 0),
|
||||
stepFrames: (n) => coreHost && coreHost.stepFrames(n),
|
||||
startRaf: () => coreHost && coreHost.startRaf(),
|
||||
stopRaf: () => coreHost && coreHost.stopRaf(),
|
||||
/** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */
|
||||
tap: (x, y) => coreHost && coreHost.tap(x, y),
|
||||
/** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API):'start'/'hitTarget'/'restart'。 */
|
||||
do: (action) => coreHost && coreHost.do(action),
|
||||
state: () => forensics.state(),
|
||||
forensics,
|
||||
};
|
||||
|
||||
/** 状态行(仅人看;通用宿主已设过一轮,这里仅错误兜底用)。 */
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调通用宿主:把本款专属项经 opts 注入(取自共用配置 cfg),引擎骨架复用 bootGameHost。
|
||||
coreHost = await bootGameHost({
|
||||
canvas,
|
||||
statusEl,
|
||||
engine: opts.engine, // entry 包装层注入的引擎名字空间(real 用;stub/node 为 undefined)
|
||||
seed,
|
||||
mode,
|
||||
engineMode,
|
||||
viewport: cfg.viewport, // 本款视口(390×844,共用配置)
|
||||
factory: cfg.factory, // 游戏工厂来源
|
||||
plugins: cfg.plugins, // 本款 4 件插件实例集(+save)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // host wiring(runtime:{plugins,bundle})
|
||||
/** 就绪钩子:受控面/游戏全就绪后,接进取证对象 + 挂取证全局。 */
|
||||
onReady: (host, internals) => {
|
||||
gameForensics = internals.getGameForensics();
|
||||
// 挂取证全局(headless-boot 与 e2e CDP 驱动据此读状态 / 驱动)。
|
||||
window.__gameForensics = forensics;
|
||||
window.__gameHost = gameHost;
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// 引擎接管/装载失败:镜像错因,重抛供上层捕获。
|
||||
window.__gameBootError = window.__gameHostBootError || String((e && e.message) || e);
|
||||
setStatus('BOOT ERROR: ' + window.__gameBootError + '(可加 ?engine=stub 回滚桩)');
|
||||
throw e;
|
||||
}
|
||||
|
||||
return gameHost;
|
||||
}
|
||||
208
game-runtime/games/_template-feiyi/src/render.js
Normal file
208
game-runtime/games/_template-feiyi/src/render.js
Normal file
@ -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 };
|
||||
}
|
||||
91
game-runtime/games/_template-feiyi/test/core.test.mjs
Normal file
91
game-runtime/games/_template-feiyi/test/core.test.mjs
Normal file
@ -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 件(收集有差异)');
|
||||
});
|
||||
192
game-runtime/games/_template-feiyi/test/headless-boot.test.mjs
Normal file
192
game-runtime/games/_template-feiyi/test/headless-boot.test.mjs
Normal file
@ -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, '回炉后再就绪仍可完成该道');
|
||||
});
|
||||
@ -81,6 +81,7 @@ export function buildSystemPrompt(id) {
|
||||
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/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')。
|
||||
|
||||
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
|
||||
能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
|
||||
@ -96,7 +97,7 @@ export function buildSystemPrompt(id) {
|
||||
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版。**先出能玩的核心,再谈丰富。**
|
||||
|
||||
【步骤】
|
||||
A 读手册(1 + 按品类 5/6/7)+ 读你的起点 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)+ 读你的起点 game-logic.js(4)→ B 据 brief 定**轻量玩法设计**(核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效,过 §10 八条自检 + 守 MVP-first)→ C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js**(按需拆 core/render)实现玩法 → E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check+build 都绿后调 done。
|
||||
|
||||
【红线(check 会拦,违反则 done 被拒)】
|
||||
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user