feat(wanglanmei-ref): 参考件轮1交付——经营深化玩法(双权衡两头能输+失败态+救单宽限)49测全绿,mmx资产16件(图14+乐2)风格统一,五要素自评≈4.1
- 逻辑/宿主两层:balance/shop-core/lines/loop-proof 纯逻辑零DOM受控时间随机;render/hostfx/audio-director/palette/assets/main 宿主层 - 手感链:同帧涟漪+弹簧按压/quadOut弧线飞行/连击升调/大额成交hit-stop+屏震+闪白/冷却期InputBuffer补打 - 美术:16件同风格词(主会话目检4张:水彩绘本暖色族高度统一)+P5派生压力色板+vignette联动 - 收编亲验:49/49+全仓155/155绿/隔离零外溢/IP gate源码资产零命中/逻辑层禁API零真调/manifest抽查5/5哈希一致 - mmx对账:quota面零增量(image/music不走Token Plan),权威用量=台账16次,扣费留月度并账 - 计数笔误15→16已纠;状态③收编提交完成,构建+e2e四件套接续 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
57
game-runtime/games/wanglanmei-ref/BUILD.md
Normal file
@ -0,0 +1,57 @@
|
||||
# wanglanmei-ref · 构建与本地 serve(集成段 mini-desktop 用)
|
||||
|
||||
> 本机(lane)零工具链纪律:本目录只跑 `node --test`;esbuild 构建与浏览器证据一律在集成段。
|
||||
|
||||
## 0. 单测(本机/任意机,零依赖)
|
||||
|
||||
```bash
|
||||
cd game-runtime && node --test "games/wanglanmei-ref/test/*.test.mjs"
|
||||
# 注意:node24 对「裸目录」参数不做测试扫描(与 scripts/test.sh 显式列文件同因),须用上面引号 glob 形式。
|
||||
# 原始输出已存 games/wanglanmei-ref/test/RESULT.txt(49/49)。
|
||||
```
|
||||
|
||||
## 1. 构建(esbuild 锁参,沿 scripts/build.mjs 同参数:bundle+minify+iife+es2019+sourcemap=false)
|
||||
|
||||
在 `game-runtime/` 目录下(需 `npm i -D esbuild`,mini-desktop 已具备):
|
||||
|
||||
```bash
|
||||
node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
|
||||
```
|
||||
|
||||
产物:`games/wanglanmei-ref/dist/ref-bundle.js`(iife 全局 `WanglanmeiRef`,含 core 协议 + 所用插件 + 游戏全部代码;资产不打包,运行时相对路径取 `./assets/`)。
|
||||
|
||||
## 2. 本地静态 serve(沿 test/harness/static-serve.cjs;轮 1 约定端口 4320)
|
||||
|
||||
```bash
|
||||
# 注意 serve 根 = 本游戏目录(index.html 以相对路径取 dist/ 与 assets/)
|
||||
systemd-run --unit=t1ba-ref-r1 node test/harness/static-serve.cjs games/wanglanmei-ref 4320
|
||||
# 探活
|
||||
curl -sf http://localhost:4320/ | head -3
|
||||
```
|
||||
|
||||
入口 URL:
|
||||
|
||||
| 用途 | URL |
|
||||
|---|---|
|
||||
| 真人试玩(RAF 交互) | `http://localhost:4320/?seed=305441741` |
|
||||
| 取证模式(确定性驱动,无 RAF) | `http://localhost:4320/?mode=evidence&seed=305441741` |
|
||||
|
||||
- `?seed=` 注入主随机种子(缺省 0x1234abcd=305441741,不传也确定);
|
||||
- `?mode=evidence`:画面完全由 `window.__refHost.stepFrames(n)` / `window.__refHost.tap(x,y)` 驱动,同参数重跑一致;
|
||||
- 取证导出:`window.__refForensics` / `window.__refChecklist`(字段含义见 FORENSICS.md);
|
||||
- 探活标记:`window.__refBooted === true`(false + `window.__refBootError` = 引导失败)。
|
||||
|
||||
## 3. 构建产物 IP gate 复核(轮终必跑)
|
||||
|
||||
```bash
|
||||
# 受保护字样以码位构造(U+738B U+84DD U+8393),防本文件自身触发字面量扫描;期望输出 banned: false
|
||||
node -e "const s=require('fs').readFileSync('games/wanglanmei-ref/dist/ref-bundle.js','utf8');console.log('banned:',s.includes(String.fromCharCode(0x738b,0x84dd,0x8393)))"
|
||||
# 对外页面标题/manifest/资产 prompt 同口径复核:
|
||||
node -e "const b=String.fromCharCode(0x738b,0x84dd,0x8393);for(const f of ['games/wanglanmei-ref/index.html','games/wanglanmei-ref/assets/manifest.json'])console.log(f,require('fs').readFileSync(f,'utf8').includes(b)?'VIOLATION':'clean')"
|
||||
```
|
||||
|
||||
## 4. 轮终收尾
|
||||
|
||||
```bash
|
||||
systemctl stop t1ba-ref-r1 # 不留孤儿 unit/端口;日志归档 evidence/round-1/
|
||||
```
|
||||
58
game-runtime/games/wanglanmei-ref/FORENSICS.md
Normal file
@ -0,0 +1,58 @@
|
||||
# wanglanmei-ref · 取证导出说明(给主会话 e2e 读)
|
||||
|
||||
> 运行时挂两个全局:`window.__refForensics`(取证清单)与 `window.__refChecklist`(五要素自动子项)。
|
||||
> 另有 `window.__refHost`(确定性驱动钩子)与 `window.__refBooted`(探活)。全部在 boot 完成后可用。
|
||||
|
||||
## 1. `window.__refForensics`
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|---|---|---|
|
||||
| `protocol` | string | core 协议版本('core-protocol-v0') |
|
||||
| `seed` / `mode` | number / string | 本次运行的主种子与模式('play'/'evidence') |
|
||||
| `geometry()` | () => Zone[] | **可交互几何**:当前帧全部命中区,`{id, kind, x, y, hw, hh, z}`(中心+半宽高,collision 插件 broadphase 同口径;与视觉绘制同源——computeZones 与绘制共用同一布局函数) |
|
||||
| `probe()` | () => object | **锚点接线实记**:`{jsonl, verify, anchors, uncaught, initOk, registeredPlugins}`。`jsonl`=P10 六锚链逐行 JSONL;`verify.ok`=FNV-1a 哈希链+偏序校验;`anchors` 期望前 6 项=`t_boot→t_plugins_ready→t_game_init→t_first_paint→t_input_bound→t_game_start`;`uncaught` 期望 `[]` |
|
||||
| `loopProof` | object | **闭环可达断言数据**(boot 期对纯逻辑跑出):`{ok, opCount(≤64), ops[], day, upgradeId, served, cashJiao, reputation}`——开局→进货定价→营业接待→首日结算→首次升级的完整 op 流水 |
|
||||
| `failureProof` | object | **失败态可达**:摆烂策略到停业的证明 `{ok, reason('reputation'/'bankrupt'), days, finedJiao, angry, events[]}`(含 fined 与 gameover 事件留痕) |
|
||||
| `priceProof` | object | **定价两头都能输**:`{loProfit(<0), midProfit(>0), hiProfit(<0), twoSidedLoss}`(单位角,解析模型) |
|
||||
| `state()` | () => object | 游戏状态快照(JSON 安全):phase/day/cash/reputation/priceMul/stock/unlocked/upgrades/customers 数/combo/today/lifetime/gameoverReason |
|
||||
| `recentEvents()` | () => object[] | 最近 ≤240 条逻辑事件(含 tMs 仿真时戳),断言行为序列用 |
|
||||
| `measures()` | () => object | 实时度量:frames/taps/tapFeedbackMaxFrames/comboUiShown/hitStopCount/rushPaletteSeen/bgmCrossfadeSeen/linesShown/bufferedServeReplays + `particle`(三层粒子数)+ `audio`(sfxKinds/bgm 增益)+ `assetsMissing` |
|
||||
|
||||
## 2. `window.__refChecklist`(LOOP-LOG 五要素表自动子项,逐帧刷新)
|
||||
|
||||
| 组 | 子项 | 判读 |
|
||||
|---|---|---|
|
||||
| `shougan`(手感) | `tapFeedbackMaxFrames` | 点击→可见反馈的最大帧延迟,目标 ≤3(涟漪+按压弹簧在 tap 同帧注册,下一渲染帧可见,构造上 ≤1) |
|
||||
| | `tapFeedbackWithin3` / `everyTapScalePulse` | 布尔(后者构造保证:onTap 必加涟漪+弹簧脉冲) |
|
||||
| | `comboUiShown` | 连击 UI 出现过(live 计数或当日 maxCombo≥2) |
|
||||
| | `flightEasingUsed` / `hitStopTriggered` / `bufferedServeReplays` | 商品飞行走非线性缓动(构造保证)/ hit-stop 实际触发过 / 输入缓冲补打次数 |
|
||||
| `meishu`(美术统一性) | `allDrawsViaPalette` | 构造保证:render.js 一切取色经 palette 解析器(P5 lerp/apply/shiftHsl 驱动) |
|
||||
| | `vignetteEnabled` / `rushPaletteSeen` | 暗角开启(强度随高峰 0.22→0.46)/ 高峰压力色板实际出现过 |
|
||||
| | `assetsLoaded` | 'n/13' 图像装载数(缺失走程序化回退,亦无原色矩形) |
|
||||
| `yinyue`(音乐) | `audioUnlocked` / `bgmReady` / `bgmCrossfadeSeen` | 手势解锁 / 双 BGM 解码就绪 / 双轨增益同时 >0.02 出现过(crossfade 实证) |
|
||||
| | `sfxKindCount` / `sfxKinds` | 已触发音效种类(目标 ≥5;全集=arrive/shelve/upgrade/coin/bad/rescue/bigsale/tick/empty) |
|
||||
| `shendu`(结构深度) | `loopReachable` / `loopOps` / `firstUpgradeDay` | 闭环可达 / 操作数 / 首次升级落在第几天(应 =2,首段落内) |
|
||||
| | `failureReachable` / `failureReason` / `priceTwoSidedLoss` / `upgradeTreeSize` | 失败态可达 / 原因 / 定价两头能输 / 升级树 8 节点 |
|
||||
| `juese`(角色壳) | `keeperSpriteResident` | 店主立绘每帧常驻(构造保证) |
|
||||
| | `lineGroups` / `lineTotal` / `linesShownLive` | 台词场景组数(≥6) / 总条数(≥20) / 本次运行实际展示条数 |
|
||||
|
||||
> 「构造保证」类布尔=代码结构使然(如取色必经调色器),运行时恒 true;
|
||||
> 「live」类=真实发生才置位(如 rushPaletteSeen/bgmCrossfadeSeen/hitStopTriggered),e2e 需驱动到对应场景再读。
|
||||
|
||||
## 3. `window.__refHost`(评估驱动钩子,样板=host-dev 的 `__hostdev`)
|
||||
|
||||
| 方法 | 含义 |
|
||||
|---|---|
|
||||
| `stepFrames(n)` | 确定性推进 n 帧(固定 dt=1/60 + 步进时钟;evidence 模式唯一推进方式) |
|
||||
| `tap(x, y)` | 注入一次点击(pointerdown+up,经受控输入桥,与真 CDP 输入同路) |
|
||||
| `do(action)` | 直接执行按钮动作('newGame'/'continue'/'openShop'/'confirmSettle'/'confirmSummary'/'restart'),e2e 兜底用 |
|
||||
| `state()` / `frameCount()` / `startRaf()` / `stopRaf()` | 快照 / 帧数 / RAF 开关 |
|
||||
|
||||
## 4. e2e 建议断言流(输入轨迹/截图/probe/checklist 四件套)
|
||||
|
||||
1. 开 `?mode=evidence&seed=305441741` → 等 `__refBooted===true`;
|
||||
2. `__refForensics.probe().verify.ok === true` 且 `uncaught.length === 0`;
|
||||
3. `geometry()` 找 `newGame` 中心 → CDP 真输入点击 → `state().phase === 'prep'`;
|
||||
4. 依 `geometry()` 点 `inc-*` 备货、`openShop` 开门 → stepFrames 推进 → 对 `serve-*` 真输入连点 → `state().today.served > 0`、`recentEvents()` 出现 `serve_ok`;
|
||||
5. 推到 `phase==='settle'` → 截图(结算面板)→ 点 `confirmSettle` → 第 2 天点 `up-speed1` → `state().upgrades.speed1===true`(闭环真 UI 复证);
|
||||
6. 全程读 `__refChecklist` 落 JSON;canvas 哈希在固定 seed + stepFrames 定帧下取。
|
||||
@ -46,13 +46,71 @@ serve=`systemd-run --unit=t1ba-ref-r1` 端口 4320,轮终 stop+日志归档 `evi
|
||||
|
||||
### 状态
|
||||
|
||||
- [ ] ②写码(Fable agent,待集成门 3/3 过后开火)
|
||||
- [x] ②写码(Fable agent,2026-06-12 交付:玩法逻辑+宿主层+15 资产+49 测试全绿+取证导出;详见下方「轮 1 交付」三节)
|
||||
- [ ] ③收编提交+构建
|
||||
- [ ] ④评估四件套(输入轨迹/截图+canvas 哈希/probe JSONL/checklist JSON)
|
||||
- [ ] ⑤判词
|
||||
|
||||
---
|
||||
|
||||
## 轮 1 交付 · 设计说明(关键裁量+理由)
|
||||
|
||||
> 写码 agent 留痕(2026-06-12)。交付物清单:`src/`×10(逻辑 4+宿主 6)+`entry.js`+`index.html`+`test/`×8(49 测试)+`assets/`×15+manifest+`BUILD.md`+`FORENSICS.md`。
|
||||
|
||||
1. **逻辑/宿主两层切分**:`balance.js`(数值单一事实源)/`shop-core.js`(营业日状态机)/`shopkeeper-lines.js`(台词库)/`loop-proof.js`(闭环+失败态脚本化证明)为**纯逻辑层**——零 DOM、时间=逐帧 dt 累计、随机=注入受控源、金额全程「角」整数防浮点漂移;渲染(`render.js`)/表现导演(`hostfx.js`)/音频(`audio-director.js`)/资产(`assets.js`)/引导(`main.js`)为宿主层。逻辑层 49 项 node 直测,不开浏览器即可钉死玩法正确性。
|
||||
2. **二阶决策做成「数值上两头都能输」**:定价→客流曲线(0.8→×1.3,1.5→×0.25)+毛利曲线+日租金叠加,解析模型实测 0.8 亏 ¥6.7/1.5 亏 ¥7.8/最优 ×1.15 赚 ¥25.7(内部最优,非送分单调);库存=资金占用 vs 缺货流失(-2.5 声誉/人)。两者都有测试与运行时 priceProof 双保险。
|
||||
3. **失败态真实可达**:耐心耗尽→0.55s 救单宽限(coyote 语义,救回有额外声誉奖励——把惩罚窗变成高光操作窗)→愤怒离店 -4 声誉;日结声誉<30 罚没(基数 ¥24+每差评 ¥2);声誉≤0 或现金<0→停业终态。`proveFailure` 脚本化证明 1 天即可摆烂致死,罚没与 gameover 事件双留痕。
|
||||
4. **连击=ComboWindow(P4 公开 API)喂受控仿真时钟**:2.6s 窗,小费 +4%/级封顶 +32%,收银音效每级 +2 半音(音调递升),≥2 出徽章(弹簧弹跳)。
|
||||
5. **接待手感链**:tap 同帧出涟漪+按压弹簧(反馈帧延迟构造上 ≤1)→商品 quadOut+抛物弧飞行 0.28s→到达爆金币喷泉(粒子)+浮字+叮;大额成交(≥¥9)hit-stop(0.09s)+屏震+闪白三件套;冷却期点击经 gamefeel InputBuffer(260ms)缓冲补打不吞输入。
|
||||
6. **P3 多实例分层供色**:粒子插件不内置颜色(集成段 REPORT §4.3 教训)且 v1 环境曲线全局共享——故建 3 个独立实例(金币金/奶油亮/暖棕烟,各派生独立随机流+独立环境),宿主逐层设 fillStyle;juice 套件只走注册进 registry 的权威实例(全局唯一 timeScale)。
|
||||
7. **美术统一性双保险**:①15 件 mmx 资产共用同一组风格词(手绘水彩绘本+暖色族+奶油底,商品/头像全部居中构图);②程序化绘制一切取色必经 `palette.js` 解析器——基础色名表唯一,高峰压力色板由 P5 shiftHsl 程序化派生(色相-10°/饱和×1.18/明度×0.9)+lerpPaletteMap 过渡+vignette 0.22→0.46 收紧,情绪随玩法起伏。资产无 alpha 通道的限制用「圆形头像/圆角卡片裱框」构图语言化解(全部 prompt 按此构图生成,统一且回避抠图)。
|
||||
8. **音频双路裁量**:BGM=mmx 两首(92/132bpm 同族配器)宿主双 source 增益逐帧 crossfade(≈1.2s 收敛);音效 P6 预设(来客 blip/上架 thud/升级 chime)+宿主合成 6 种(收银连击升调/差评降鸣/救单琶音/大额低鸣/点按嗒/缺货噗)——P6 预设面无音高参数,合成补位为刻意裁量(记 v2 边界)。
|
||||
9. **probe 六锚编排沿 host-dev 样板**:宿主自管 probe,t_boot→t_plugins_ready→(资产装载+游戏构建)t_game_init→首帧 t_first_paint→probe.init 绑输入 t_input_bound→t_game_start,verifyChain 全绿(headless 测试断言)。
|
||||
10. **取证清单随码导出**:`__refForensics`(几何与视觉同源 computeZones/probe 链/闭环+失败+定价三证明/状态/事件环/度量)+`__refChecklist`(五要素表自动子项,「构造保证」与「live 实测」分类标注)——字段语义全录 FORENSICS.md。
|
||||
11. **本机最强验证=headless 全链路**:Proxy 伪 2D 上下文真跑 bootRef,真 tap 路由走完 开店→进货→营业接待→日结→次日升级 整圈+同 seed 确定性复跑一致(不开浏览器,红线 4 合规);像素/发声证据留集成段四件套。
|
||||
|
||||
## 轮 1 交付 · 生成清单(mmx,逐次当场记录;另存 assets/gen-ledger.md 原始件)
|
||||
|
||||
- **调用合计:16 次(图 14+乐 2)≤ 预算 20;全部 exit0,零配额/鉴权失败;补绘 1 次(#16 店主招手像服装与待机像对齐,替换 #2 产物)。**
|
||||
- 图统一风格词 STYLE=「手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁」;全部 768²(1:1)默认,storefront 9:16,ui-panel 4:3;**prompt 全程零受保护字样**(占位店主=通用市井老板娘)。
|
||||
- 完整 sha256 见 `assets/manifest.json`(file/bytes/sha256/source 四字段全录)。
|
||||
|
||||
| # | 类型 | 文件 | 字节 | sha256(前16) | prompt(+STYLE) |
|
||||
|---|---|---|---:|---|---|
|
||||
| 1 | image | shopkeeper-idle.jpg | 250074 | d880ac0010bc545c | 一位亲切的市井小卖部老板娘半身像,圆脸盘发系米色围裙,温和微笑,双手轻搭在木柜台上,人物居中,纯浅奶油色背景 |
|
||||
| 2 | image | (弃用,被 #16 替换) | 239519 | bea80c16a0de273d | 同一位市井小卖部老板娘半身像,…,开心地挥手打招呼,热情笑容,人物居中,纯浅奶油色背景 |
|
||||
| 3 | image | customer-kid.jpg | 272573 | a7923ef9d6ae4e10 | 小学生男孩圆形头像构图,胸像,馋嘴期待的表情,背红色书包,居中,纯浅奶油色背景 |
|
||||
| 4 | image | customer-lady.jpg | 144021 | 0d3123c7b65bd186 | 年轻上班族女性圆形头像构图,胸像,温和微笑,低马尾辫白衬衫,居中,纯浅奶油色背景 |
|
||||
| 5 | image | customer-grandpa.jpg | 327951 | 193dbdda36be380f | 慈祥老爷爷圆形头像构图,胸像,眯眼笑,戴灰色布帽白胡子,居中,纯浅奶油色背景 |
|
||||
| 6 | image | customer-auntie.jpg | 259982 | 233d14e41ffd41f7 | 时髦中年阿姨圆形头像构图,胸像,爽朗大笑,棕色卷发戴耳环,居中,纯浅奶油色背景 |
|
||||
| 7 | image | storefront.jpg | 509293 | ab453b8b35b32ac8 | 温暖的小卖部店内背景,木质货架陈列杂货,暖黄吊灯,玻璃柜台,窗外是傍晚市井街道,无人物,画面中下部干净留白(9:16) |
|
||||
| 8 | image | item-soda.jpg | 113934 | 1fee829fe5d39d0f | 一罐橙子味汽水易拉罐,简洁商品图标,居中特写,纯浅奶油色背景 |
|
||||
| 9 | image | item-snack.jpg | 176052 | 485629ceee8bc69b | 一包红色包装的辣条零食,简洁商品图标,居中特写,纯浅奶油色背景 |
|
||||
| 10 | image | item-bread.jpg | 120166 | 063a0aea058515fb | 一只金黄色菠萝包面包,简洁商品图标,居中特写,纯浅奶油色背景 |
|
||||
| 11 | image | ui-panel.jpg | 162064 | 3d4ddf5f9508ec7e | 米色水彩纸纹理面板,四边轻微水彩晕染描边,中心干净留白,无文字无图案(4:3) |
|
||||
| 12 | image | item-ice.jpg | 73755 | 2143ba67d643edc5 | 一支双球奶油香草冰淇淋甜筒,简洁商品图标,居中特写,纯浅奶油色背景 |
|
||||
| 13 | image | item-toy.jpg | 99391 | 497143628f79e8ad | 一辆红色小木头玩具汽车,简洁商品图标,居中特写,纯浅奶油色背景 |
|
||||
| 14 | music | bgm-idle.mp3 | 4419803 | 37a7e4faeabd9eca | 温暖悠闲的市井小店白天背景音乐,木吉他指弹与口琴主奏,轻刷子鼓点与软贝斯,大调,治愈放松,适合经营游戏闲时循环播放,旋律简单上口(--instrumental --bpm 92) |
|
||||
| 15 | music | bgm-rush.mp3 | 4610393 | 690aa103725c2358 | 忙碌欢快的市井小店高峰时段背景音乐,木吉他快速扫弦与口琴主奏,手拍与沙锤节奏,大调,与同族闲时曲同一配器风格,紧凑明快但不焦躁,适合经营游戏高峰循环播放(--instrumental --bpm 132) |
|
||||
| 16 | image | shopkeeper-wave.jpg(替换#2) | 205213 | a1ff8036ab7a729a | 同一位亲切的市井小卖部老板娘半身像,圆脸深棕色盘发配红色发圈,粉色衬衫外系米黄色围裙,开心地挥手打招呼,热情笑容露齿笑,人物居中,纯浅奶油色背景——补绘原因:服装色与待机像对齐(待机/招手在场内同位切换,撞色会闪) |
|
||||
|
||||
(quota 对账「前/后」读数由主会话按轮前基线补;本 agent 按纪律未跑 quota show,**调用计数=16**(表 #1~#16,原文"15"系笔误,主会话收编时按表行数纠正)。)
|
||||
|
||||
## 轮 1 交付 · v2 边界(刻意没做的)
|
||||
|
||||
1. **P6 情绪分层/ZzFXM 曲未用**:BGM 走 mmx 双轨 crossfade(计划内首选);P6 的 loadSong/intensity 留给「程序化配乐」场景。**P6 预设面无音高/音量参数**→连击升调收银音由宿主合成补位;建议 v2 给 P6 扩 `playSfx(name, {pitch, volume})`。
|
||||
2. **拖拽交互未做**:接待=点按(移动端最脆路径);拖拽上架/拖拽递货留 v2(P1 raycast 面already 可支撑)。滑条已用 pointermove 拖拽。
|
||||
3. **多发射器异构环境规避而非解决**:P3 v1 环境曲线全局共享→以 3 实例分层绕开;若 v2 插件支持粒子级环境可并回单实例。
|
||||
4. **存档只存 prep 间持久态**:当日过程态(顾客队列/日内时刻)不存——中途关页=当日重开(口径已写注释);云存档/多档位不做。
|
||||
5. **经济纵深 3 段落后未扩**:房租每段落 +¥12 的单一递增压力;新品类/事件卡/天气等内容扩展留品类层。
|
||||
6. **图像无 alpha**:image-01 产 JPEG,以裱框构图语言化解;若后续需要真抠图 sprite,v2 评估 +抠图管线(或带 alpha 的生成器)。
|
||||
7. **save 跨 iframe 契约不声称**:P9 走 localStorage adapter(浏览器)/内存(降级),SDK storage 通道按执行版口径留 β。
|
||||
8. **店面背景右下角自带生成水印样纹理**:9:16 大图角部有细小杂笔触,被底部面板遮挡,不影响成片;如亲玩轮在意可用余量 5 次补绘一张。
|
||||
|
||||
---
|
||||
|
||||
## mmx 对账与生成清单(轮 1)
|
||||
|
||||
(开火前补「前」基线,生成后补清单与「后」读数)
|
||||
生成清单见上「轮 1 交付 · 生成清单」(**16 次**调用全记录+sha256;原始当场记录件=assets/gen-ledger.md)。
|
||||
|
||||
**对账结论(主会话 2026-06-12 收编时核)**:`quota show` 前后读数零增量(`evidence/round-1/quota-{before,after}.json`:general 0→0,video 21→21 为本轮之前的他用)——**image-01/music-2.6-free 调用不走 Token Plan 配额面**(music-2.6-free 对 API key 不限量;image 计费面待月度账单核对)。权威用量=生成清单 16 次+manifest sha256 抽查 5/5 一致;真实扣费入成本台账留月度对账(per [[newapi-billing-plane-integration]] 外平面计量并账纪律)。
|
||||
|
||||
BIN
game-runtime/games/wanglanmei-ref/assets/bgm-idle.mp3
Normal file
BIN
game-runtime/games/wanglanmei-ref/assets/bgm-rush.mp3
Normal file
BIN
game-runtime/games/wanglanmei-ref/assets/customer-auntie.jpg
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/customer-grandpa.jpg
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/customer-kid.jpg
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/customer-lady.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
17
game-runtime/games/wanglanmei-ref/assets/gen-ledger.md
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
| 1 | image | shopkeeper-idle.jpg | 250074 | d880ac0010bc545c | 一位亲切的市井小卖部老板娘半身像,圆脸盘发系米色围裙,温和微笑,双手轻搭在木柜台上,人物居中,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 2 | image | shopkeeper-wave.jpg | 239519 | bea80c16a0de273d | 同一位市井小卖部老板娘半身像,圆脸盘发系米色围裙,开心地挥手打招呼,热情笑容,人物居中,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 3 | image | customer-kid.jpg | 272573 | a7923ef9d6ae4e10 | 小学生男孩圆形头像构图,胸像,馋嘴期待的表情,背红色书包,居中,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 4 | image | customer-lady.jpg | 144021 | 0d3123c7b65bd186 | 年轻上班族女性圆形头像构图,胸像,温和微笑,低马尾辫白衬衫,居中,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 5 | image | customer-grandpa.jpg | 327951 | 193dbdda36be380f | 慈祥老爷爷圆形头像构图,胸像,眯眼笑,戴灰色布帽白胡子,居中,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 6 | image | customer-auntie.jpg | 259982 | 233d14e41ffd41f7 | 时髦中年阿姨圆形头像构图,胸像,爽朗大笑,棕色卷发戴耳环,居中,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 7 | image | storefront.jpg | 509293 | ab453b8b35b32ac8 | 温暖的小卖部店内背景,木质货架陈列杂货,暖黄吊灯,玻璃柜台,窗外是傍晚市井街道,无人物,画面中下部干净留白,STYLE | exit0 |
|
||||
| 8 | image | item-soda.jpg | 113934 | 1fee829fe5d39d0f | 一罐橙子味汽水易拉罐,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 9 | image | item-snack.jpg | 176052 | 485629ceee8bc69b | 一包红色包装的辣条零食,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 10 | image | item-bread.jpg | 120166 | 063a0aea058515fb | 一只金黄色菠萝包面包,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 11 | image | ui-panel.jpg | 162064 | 3d4ddf5f9508ec7e | 米色水彩纸纹理面板,四边轻微水彩晕染描边,中心干净留白,无文字无图案,STYLE | exit0 |
|
||||
| 12 | image | item-ice.jpg | 73755 | 2143ba67d643edc5 | 一支双球奶油香草冰淇淋甜筒,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 13 | image | item-toy.jpg | 99391 | 497143628f79e8ad | 一辆红色小木头玩具汽车,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
|
||||
| 14 | music | bgm-idle.mp3 | 4419803 | 37a7e4faeabd9eca | 温暖悠闲的市井小店白天背景音乐,木吉他指弹与口琴主奏,轻刷子鼓点与软贝斯,大调,治愈放松,适合经营游戏闲时循环播放,旋律简单上口 (--instrumental --bpm 92) | exit0 |
|
||||
| 15 | music | bgm-rush.mp3 | 4610393 | 690aa103725c2358 | 忙碌欢快的市井小店高峰时段背景音乐,木吉他快速扫弦与口琴主奏,手拍与沙锤节奏,大调,与同族闲时曲同一配器风格,紧凑明快但不焦躁,适合经营游戏高峰循环播放 (--instrumental --bpm 132) | exit0 |
|
||||
| 16 | image | shopkeeper-wave.jpg(替换#2) | 205213 | a1ff8036ab7a729a | 同一位亲切的市井小卖部老板娘半身像,圆脸深棕色盘发配红色发圈,粉色衬衫外系米黄色围裙,开心地挥手打招呼,热情笑容露齿笑,人物居中,纯浅奶油色背景,STYLE | exit0 服装与待机像对齐,采用;#2 产物弃用 |
|
||||
BIN
game-runtime/games/wanglanmei-ref/assets/item-bread.jpg
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/item-ice.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/item-snack.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/item-soda.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/item-toy.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
176
game-runtime/games/wanglanmei-ref/assets/manifest.json
Normal file
@ -0,0 +1,176 @@
|
||||
{
|
||||
"game": "wanglanmei-ref",
|
||||
"round": 1,
|
||||
"note": "全部图片共用同一组风格词(美术统一性之根);占位店主=通用市井店主,IP 签署后换正式形象;程序化绘制(粒子/UI描边/图形)不在此表,manifest 只录外采资产。",
|
||||
"styleWords": "手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁",
|
||||
"assets": [
|
||||
{
|
||||
"file": "shopkeeper-idle.jpg",
|
||||
"role": "店主立绘·待机",
|
||||
"bytes": 250074,
|
||||
"sha256": "d880ac0010bc545c06f1f6c5424021e9a55b0831c653befefd11a666abb1cda2",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "一位亲切的市井小卖部老板娘半身像,圆脸盘发系米色围裙,温和微笑,双手轻搭在木柜台上,人物居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "shopkeeper-wave.jpg",
|
||||
"role": "店主立绘·招手",
|
||||
"bytes": 205213,
|
||||
"sha256": "a1ff8036ab7a729a1dc640da852d7abd3e21c73469e2cf5e9bb897f5f9e5011a",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "同一位亲切的市井小卖部老板娘半身像,圆脸深棕色盘发配红色发圈,粉色衬衫外系米黄色围裙,开心地挥手打招呼,热情笑容露齿笑,人物居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
},
|
||||
"note": "第16次调用产物(服装与待机像对齐);第2次调用产物弃用"
|
||||
},
|
||||
{
|
||||
"file": "customer-kid.jpg",
|
||||
"role": "顾客头像·小学生",
|
||||
"bytes": 272573,
|
||||
"sha256": "a7923ef9d6ae4e10d54e6794c8f409c6a3409e9f2572f97cbcd387f0878ae267",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "小学生男孩圆形头像构图,胸像,馋嘴期待的表情,背红色书包,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "customer-lady.jpg",
|
||||
"role": "顾客头像·上班族",
|
||||
"bytes": 144021,
|
||||
"sha256": "0d3123c7b65bd186922752ca151a2ad95846f95da53bbce614f5403dc923f36e",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "年轻上班族女性圆形头像构图,胸像,温和微笑,低马尾辫白衬衫,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "customer-grandpa.jpg",
|
||||
"role": "顾客头像·老爷爷",
|
||||
"bytes": 327951,
|
||||
"sha256": "193dbdda36be380ff091e4856a465567d59809d35928336a5e384d929f6aa91d",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "慈祥老爷爷圆形头像构图,胸像,眯眼笑,戴灰色布帽白胡子,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "customer-auntie.jpg",
|
||||
"role": "顾客头像·阿姨",
|
||||
"bytes": 259982,
|
||||
"sha256": "233d14e41ffd41f7c54771c3ce3a8560f5ebde5c947a2bef9c2ba210d8716cc8",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "时髦中年阿姨圆形头像构图,胸像,爽朗大笑,棕色卷发戴耳环,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "storefront.jpg",
|
||||
"role": "店面背景(9:16)",
|
||||
"bytes": 509293,
|
||||
"sha256": "ab453b8b35b32ac8d02e646d6dc913e4b0c375bf44fe24b55cdebfbccb61c5f2",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "温暖的小卖部店内背景,木质货架陈列杂货,暖黄吊灯,玻璃柜台,窗外是傍晚市井街道,无人物,画面中下部干净留白,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "item-soda.jpg",
|
||||
"role": "商品图·汽水",
|
||||
"bytes": 113934,
|
||||
"sha256": "1fee829fe5d39d0fe24fc58bf577004cf5b0a2f206ac0466d89df982a95bed48",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "一罐橙子味汽水易拉罐,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "item-snack.jpg",
|
||||
"role": "商品图·辣条",
|
||||
"bytes": 176052,
|
||||
"sha256": "485629ceee8bc69bf9dbaa39b59cf72bac143230653352a2f7f74ba76293d995",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "一包红色包装的辣条零食,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "item-bread.jpg",
|
||||
"role": "商品图·面包",
|
||||
"bytes": 120166,
|
||||
"sha256": "063a0aea058515fb0594c88c6a15c751d90ad8c8d0459ff9c50aeaf253e06545",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "一只金黄色菠萝包面包,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "item-ice.jpg",
|
||||
"role": "商品图·冰淇淋(解锁)",
|
||||
"bytes": 73755,
|
||||
"sha256": "2143ba67d643edc5a012e101fdb5158680a8e5d80ddcda38fcaa057e0ecaebee",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "一支双球奶油香草冰淇淋甜筒,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "item-toy.jpg",
|
||||
"role": "商品图·小玩具(解锁)",
|
||||
"bytes": 99391,
|
||||
"sha256": "497143628f79e8adaad4f7344719004a64dfadbd0744e23701164e12ec2d34e2",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "一辆红色小木头玩具汽车,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "ui-panel.jpg",
|
||||
"role": "UI 面板底(4:3)",
|
||||
"bytes": 162064,
|
||||
"sha256": "3d4ddf5f9508ec7eb02ef88c3f033249553c3924824a2ad082fd3ee4e5721230",
|
||||
"source": {
|
||||
"tool": "mmx image generate",
|
||||
"model": "image-01",
|
||||
"prompt": "米色水彩纸纹理面板,四边轻微水彩晕染描边,中心干净留白,无文字无图案,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "bgm-idle.mp3",
|
||||
"role": "BGM·闲时",
|
||||
"bytes": 4419803,
|
||||
"sha256": "37a7e4faeabd9ecab156a35bb57a309cc1bd841b9d5e2c07470f9cfd229ac1f4",
|
||||
"source": {
|
||||
"tool": "mmx music generate",
|
||||
"model": "music-2.6-free",
|
||||
"flags": "--instrumental --bpm 92",
|
||||
"prompt": "温暖悠闲的市井小店白天背景音乐,木吉他指弹与口琴主奏,轻刷子鼓点与软贝斯,大调,治愈放松,适合经营游戏闲时循环播放,旋律简单上口"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "bgm-rush.mp3",
|
||||
"role": "BGM·高峰",
|
||||
"bytes": 4610393,
|
||||
"sha256": "690aa103725c2358d6f9fe15c8e16d2fc36e507e20fb397a0198da0682ac08c2",
|
||||
"source": {
|
||||
"tool": "mmx music generate",
|
||||
"model": "music-2.6-free",
|
||||
"flags": "--instrumental --bpm 132",
|
||||
"prompt": "忙碌欢快的市井小店高峰时段背景音乐,木吉他快速扫弦与口琴主奏,手拍与沙锤节奏,大调,与同族闲时曲同一配器风格,紧凑明快但不焦躁,适合经营游戏高峰循环播放"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
game-runtime/games/wanglanmei-ref/assets/shopkeeper-idle.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/shopkeeper-wave.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/storefront.jpg
Normal file
|
After Width: | Height: | Size: 497 KiB |
BIN
game-runtime/games/wanglanmei-ref/assets/ui-panel.jpg
Normal file
|
After Width: | Height: | Size: 158 KiB |
17
game-runtime/games/wanglanmei-ref/entry.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* entry.js — wanglanmei-ref 的 esbuild 打包入口(集成段锁参打成 iife → index.html 加载)
|
||||
* owner:wanglanmei-ref 轮 1
|
||||
*
|
||||
* 【构建】沿 scripts/build.mjs 锁参(bundle+minify+iife+es2019+sourcemap=false),见 BUILD.md:
|
||||
* node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
|
||||
*
|
||||
* 【定位】仿 host-dev/entry.js:把「core 协议 + 所用插件 + 游戏(逻辑/渲染/宿主)」汇成一个
|
||||
* iife 全局(WanglanmeiRef),index.html 调 bootRef 启动。α 不接 littlejsengine(裁定同 host-dev)。
|
||||
* 本文件 import-safe(node 可 import 不执行任何浏览器调用),boot 由页面显式触发。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { bootRef } from './src/main.js';
|
||||
|
||||
export { bootRef };
|
||||
@ -0,0 +1,42 @@
|
||||
{
|
||||
"model_remains": [
|
||||
{
|
||||
"start_time": 1781265600000,
|
||||
"end_time": 1781280000000,
|
||||
"remains_time": 7678241,
|
||||
"current_interval_total_count": 0,
|
||||
"current_interval_usage_count": 0,
|
||||
"model_name": "general",
|
||||
"current_weekly_total_count": 0,
|
||||
"current_weekly_usage_count": 0,
|
||||
"weekly_start_time": 1780848000000,
|
||||
"weekly_end_time": 1781452800000,
|
||||
"weekly_remains_time": 180478241,
|
||||
"current_interval_status": 1,
|
||||
"current_interval_remaining_percent": 96,
|
||||
"current_weekly_status": 3,
|
||||
"current_weekly_remaining_percent": 100
|
||||
},
|
||||
{
|
||||
"start_time": 1781193600000,
|
||||
"end_time": 1781280000000,
|
||||
"remains_time": 7678241,
|
||||
"current_interval_total_count": 3,
|
||||
"current_interval_usage_count": 3,
|
||||
"model_name": "video",
|
||||
"current_weekly_total_count": 21,
|
||||
"current_weekly_usage_count": 21,
|
||||
"weekly_start_time": 1780848000000,
|
||||
"weekly_end_time": 1781452800000,
|
||||
"weekly_remains_time": 180478241,
|
||||
"current_interval_status": 1,
|
||||
"current_interval_remaining_percent": 100,
|
||||
"current_weekly_status": 1,
|
||||
"current_weekly_remaining_percent": 100
|
||||
}
|
||||
],
|
||||
"base_resp": {
|
||||
"status_code": 0,
|
||||
"status_msg": "success"
|
||||
}
|
||||
}
|
||||
92
game-runtime/games/wanglanmei-ref/index.html
Normal file
@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.html — 暖阳小卖部(wanglanmei-ref 轮 1)宿主页
|
||||
用法:
|
||||
· 集成段(mini-desktop)锁参构建:见 BUILD.md(产物 dist/ref-bundle.js,iife 全局 WanglanmeiRef);
|
||||
· serve 本目录(test/harness/static-serve.cjs),浏览器打开;
|
||||
· ?seed=12345 注入主随机种子(缺省固定种子,确定可复现);
|
||||
· ?mode=evidence 取证模式:不起 RAF,由 window.__refHost.stepFrames/tap 确定性驱动;
|
||||
· 取证导出:window.__refForensics / window.__refChecklist(字段说明见 FORENSICS.md)。
|
||||
注意:页面对外文本走 IP gate(零受保护字样);本页标题即对外名「暖阳小卖部」。
|
||||
-->
|
||||
<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 内,页面壳只负责承载。 */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #2e231b;
|
||||
color: #d8c6a8;
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
#game {
|
||||
width: 390px;
|
||||
height: 844px;
|
||||
background: #f6e7c9;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
touch-action: none; /* 触摸全交 JS(pointer 桥接),禁浏览器默认手势 */
|
||||
}
|
||||
#status {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
min-height: 15px;
|
||||
max-width: 390px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 构建产物(集成段生成):iife 全局 WanglanmeiRef(含 bootRef)。 -->
|
||||
<script src="./dist/ref-bundle.js"></script>
|
||||
<script>
|
||||
// 引导:异步 boot(含资产装载);失败把错误打到 status + 重抛(评估门要看 console 零未捕获错误)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
try {
|
||||
var G = window.WanglanmeiRef;
|
||||
if (!G || typeof G.bootRef !== 'function') {
|
||||
throw new Error('WanglanmeiRef.bootRef 未找到(ref-bundle.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootRef({ canvas: canvas, statusEl: statusEl })
|
||||
.then(function () {
|
||||
window.__refBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__refBooted = false;
|
||||
window.__refBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__refBootError;
|
||||
throw e;
|
||||
});
|
||||
} catch (e) {
|
||||
window.__refBooted = false;
|
||||
window.__refBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__refBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
84
game-runtime/games/wanglanmei-ref/src/assets.js
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层不得 import 本文件)
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:main.js(boot 时 await)/ render.js(取图)
|
||||
*
|
||||
* 【容错铁律】
|
||||
* 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(渲染层有程序化回退),
|
||||
* 绝不因资产把游戏带崩——评估环境可能只 serve 代码不带资产。
|
||||
* 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** 图像资产键 → 文件名(与 assets/manifest.json 对应)。 */
|
||||
export const IMAGE_FILES = {
|
||||
shopkeeperIdle: 'shopkeeper-idle.jpg',
|
||||
shopkeeperWave: 'shopkeeper-wave.jpg',
|
||||
customer0: 'customer-kid.jpg',
|
||||
customer1: 'customer-lady.jpg',
|
||||
customer2: 'customer-grandpa.jpg',
|
||||
customer3: 'customer-auntie.jpg',
|
||||
storefront: 'storefront.jpg',
|
||||
itemSoda: 'item-soda.jpg',
|
||||
itemSnack: 'item-snack.jpg',
|
||||
itemBread: 'item-bread.jpg',
|
||||
itemIce: 'item-ice.jpg',
|
||||
itemToy: 'item-toy.jpg',
|
||||
uiPanel: 'ui-panel.jpg',
|
||||
};
|
||||
|
||||
/** 商品 id → 图像键(渲染层查表)。 */
|
||||
export const PRODUCT_IMAGE_KEY = {
|
||||
soda: 'itemSoda',
|
||||
snack: 'itemSnack',
|
||||
bread: 'itemBread',
|
||||
ice: 'itemIce',
|
||||
toy: 'itemToy',
|
||||
};
|
||||
|
||||
/**
|
||||
* 装载全部图像资产(并行;单个失败不连坐)。
|
||||
* @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('[ref-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]);
|
||||
images[key] = null;
|
||||
missing.push(key);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = root + IMAGE_FILES[key];
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all(keys.map(loadOne)).then(() => ({
|
||||
images,
|
||||
missing,
|
||||
loaded: keys.length - missing.length,
|
||||
total: keys.length,
|
||||
}));
|
||||
}
|
||||
204
game-runtime/games/wanglanmei-ref/src/audio-director.js
Normal file
@ -0,0 +1,204 @@
|
||||
/**
|
||||
* audio-director.js — 音频导演(宿主层):双 BGM crossfade + 合成音效 + P6 预设接线
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:main.js(事件→声音 / 每帧推 crossfade)
|
||||
*
|
||||
* 【职责边界】
|
||||
* - 宿主层是受控面的「提供方」:AudioContext 实例由 main 的 audioFactory 创建(用户手势解锁后),
|
||||
* 本模块经 getAudio() 取同一实例——P6 插件经受控面 getAudioContext() 拿到的也是它(同源不双开)。
|
||||
* - BGM:mmx 两首(闲时 92bpm / 高峰 132bpm 同族配器)fetch+decode 后双 source 同启循环,
|
||||
* 增益按 rushFactor 每帧插值 crossfade(评审基线「闲/忙双 BGM crossfade」的正面实现)。
|
||||
* - 音效分两路(设计裁量,详见 LOOP-LOG 设计说明):
|
||||
* ① P6 playSfx 预设:来客=blip / 落货上架=thud / 升级=chime(插件公开 API 直用);
|
||||
* ② 宿主合成(osc+包络,受控面提供方特权):收银叮(连击逐级升调——P6 预设面无音高参数,
|
||||
* 此为补位,已记 v2 边界)/ 差评降调蜂鸣 / 救单上行琶音 / 大额成交低鸣+亮叮 / 点按轻嗒。
|
||||
* - 一切发声在「未解锁/无 AudioContext」时静默 no-op(容错降级,与 P6 同则)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 创建音频导演。
|
||||
* @param {Object} deps
|
||||
* @param {() => (AudioContext|null)} deps.getAudio 取宿主 AudioContext(未解锁返回 null)
|
||||
* @param {any} deps.p6 已注册的 audio-music 插件实例(playSfx 用)
|
||||
* @param {string} [deps.baseUrl] 资产根(缺省 './assets/')
|
||||
* @returns {AudioDirector}
|
||||
*/
|
||||
export function createAudioDirector(deps) {
|
||||
const getAudio = deps.getAudio;
|
||||
const p6 = deps.p6;
|
||||
const baseUrl = deps.baseUrl == null ? './assets/' : deps.baseUrl;
|
||||
|
||||
/** @type {Set<string>} 已触发过的音效种类(checklist:≥5 种)。 */
|
||||
const sfxKinds = new Set();
|
||||
/** BGM 状态。 */
|
||||
const bgm = {
|
||||
loading: false,
|
||||
ready: false,
|
||||
failed: false,
|
||||
/** @type {GainNode|null} */ gainIdle: null,
|
||||
/** @type {GainNode|null} */ gainRush: null,
|
||||
idleLevel: 0, // 当前增益(取证可读)
|
||||
rushLevel: 0,
|
||||
targetRush: 0, // 目标 crossfade 比例 0..1
|
||||
};
|
||||
/** @type {GainNode|null} 合成音效总线(统一限幅)。 */
|
||||
let sfxBus = null;
|
||||
|
||||
/** 取(并懒建)音效总线。@param {AudioContext} ac */
|
||||
function bus(ac) {
|
||||
if (!sfxBus) {
|
||||
sfxBus = ac.createGain();
|
||||
sfxBus.gain.value = 0.5;
|
||||
sfxBus.connect(ac.destination);
|
||||
}
|
||||
return sfxBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁后启动 BGM 装载(fetch+decode 两首 → 双循环 source 同启)。幂等。
|
||||
* 失败(资产缺位/解码失败)→ 标记 failed 静默继续(音效仍可用)。
|
||||
*/
|
||||
function startBgm() {
|
||||
const ac = getAudio();
|
||||
if (!ac || bgm.loading || bgm.ready || bgm.failed) return;
|
||||
bgm.loading = true;
|
||||
/** 装一首并接好循环 source。@param {string} file @returns {Promise<GainNode>} */
|
||||
function loadTrack(file) {
|
||||
return fetch(baseUrl + file)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.arrayBuffer();
|
||||
})
|
||||
.then((buf) => ac.decodeAudioData(buf))
|
||||
.then((audioBuf) => {
|
||||
const src = ac.createBufferSource();
|
||||
src.buffer = audioBuf;
|
||||
src.loop = true;
|
||||
const gain = ac.createGain();
|
||||
gain.gain.value = 0;
|
||||
src.connect(gain);
|
||||
gain.connect(ac.destination);
|
||||
src.start();
|
||||
return gain;
|
||||
});
|
||||
}
|
||||
Promise.all([loadTrack('bgm-idle.mp3'), loadTrack('bgm-rush.mp3')])
|
||||
.then(([gi, gr]) => {
|
||||
bgm.gainIdle = gi;
|
||||
bgm.gainRush = gr;
|
||||
bgm.ready = true;
|
||||
bgm.loading = false;
|
||||
})
|
||||
.catch((e) => {
|
||||
// BGM 失败不连坐游戏:留痕降级(checklist bgm=false,亲玩轮会暴露)。
|
||||
console.warn('[ref-audio] BGM 装载失败(已降级无 BGM):' + (e && e.message ? e.message : e));
|
||||
bgm.failed = true;
|
||||
bgm.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 每帧推进:BGM 增益向目标 crossfade 比例平滑插值(约 1.2s 收敛)。
|
||||
* @param {number} dt 秒 @param {number} rushT 高峰系数 0..1 @param {boolean} musicOn 当前阶段是否要 BGM
|
||||
*/
|
||||
function step(dt, rushT, musicOn) {
|
||||
bgm.targetRush = rushT;
|
||||
if (!bgm.ready) return;
|
||||
const overall = musicOn ? 1 : 0.35; // 面板/结算阶段整体收小声,不戛然而止
|
||||
const targetIdle = 0.5 * (1 - rushT) * overall;
|
||||
const targetRush = 0.55 * rushT * overall;
|
||||
const k = Math.min(dt / 1.2, 1); // 线性追踪:约 1.2s 收敛
|
||||
bgm.idleLevel += (targetIdle - bgm.idleLevel) * k;
|
||||
bgm.rushLevel += (targetRush - bgm.rushLevel) * k;
|
||||
bgm.gainIdle.gain.value = bgm.idleLevel;
|
||||
bgm.gainRush.gain.value = bgm.rushLevel;
|
||||
}
|
||||
|
||||
/* ── 合成音效原语(受控面提供方特权;全部短促、暖色系音色)── */
|
||||
|
||||
/**
|
||||
* 一只包络化振荡器。
|
||||
* @param {AudioContext} ac @param {OscillatorType} type @param {number} f0 起始频率
|
||||
* @param {number} f1 结束频率 @param {number} dur 时长 @param {number} vol 音量 @param {number} [delay]
|
||||
*/
|
||||
function blip(ac, type, f0, f1, dur, vol, delay) {
|
||||
const t0 = ac.currentTime + (delay || 0);
|
||||
const osc = ac.createOscillator();
|
||||
const g = ac.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(f0, t0);
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(f1, 1), t0 + dur);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(vol, t0 + 0.012);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
osc.connect(g);
|
||||
g.connect(bus(ac));
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.02);
|
||||
}
|
||||
|
||||
/** 触发一种音效(按 kind 分路:P6 预设 or 宿主合成)。@param {string} kind @param {Object} [opt] */
|
||||
function sfx(kind, opt) {
|
||||
const ac = getAudio();
|
||||
if (!ac) return; // 未解锁:静默(与 P6 降级同则)
|
||||
const o = opt || {};
|
||||
switch (kind) {
|
||||
case 'arrive': // 来客门铃 —— P6 预设 blip
|
||||
p6.playSfx('blip');
|
||||
break;
|
||||
case 'shelve': // 落货上架闷响 —— P6 预设 thud
|
||||
p6.playSfx('thud');
|
||||
break;
|
||||
case 'upgrade': // 升级风铃 —— P6 预设 chime
|
||||
p6.playSfx('chime');
|
||||
break;
|
||||
case 'coin': {
|
||||
// 收银叮:连击逐级升调(每连击 +2 半音,封顶 +20)——「音调递升」手感锚点。
|
||||
const combo = Math.min(o.combo || 1, 11);
|
||||
const f = 660 * Math.pow(2, ((combo - 1) * 2) / 12);
|
||||
blip(ac, 'sine', f, f * 1.5, 0.16, 0.5);
|
||||
blip(ac, 'sine', f * 2, f * 2.6, 0.1, 0.22, 0.03);
|
||||
break;
|
||||
}
|
||||
case 'bad': // 差评降调蜂鸣(暖色系的「闷」,不刺耳)
|
||||
blip(ac, 'sawtooth', 220, 90, 0.32, 0.3);
|
||||
break;
|
||||
case 'rescue': // 救单上行琶音(惊险→释然)
|
||||
blip(ac, 'triangle', 520, 540, 0.07, 0.32);
|
||||
blip(ac, 'triangle', 660, 690, 0.07, 0.32, 0.07);
|
||||
blip(ac, 'triangle', 880, 920, 0.1, 0.34, 0.14);
|
||||
break;
|
||||
case 'bigsale': // 大额成交:低鸣 + 高亮叮(配 hit-stop)
|
||||
blip(ac, 'sine', 130, 80, 0.22, 0.5);
|
||||
blip(ac, 'sine', 1320, 1760, 0.18, 0.3, 0.05);
|
||||
break;
|
||||
case 'tick': // 通用点按轻嗒(每次 tap 的听觉回执)
|
||||
blip(ac, 'triangle', 900, 700, 0.045, 0.16);
|
||||
break;
|
||||
case 'empty': // 空货架/无效点按(提示性短噗)
|
||||
blip(ac, 'square', 180, 140, 0.08, 0.14);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
sfxKinds.add(kind);
|
||||
}
|
||||
|
||||
return {
|
||||
startBgm,
|
||||
step,
|
||||
sfx,
|
||||
/** 取证:音效种类清单 / BGM 状态。 */
|
||||
probe() {
|
||||
return {
|
||||
sfxKinds: Array.from(sfxKinds),
|
||||
bgm: { ready: bgm.ready, failed: bgm.failed, idleLevel: bgm.idleLevel, rushLevel: bgm.rushLevel },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {ReturnType<typeof createAudioDirector>} AudioDirector
|
||||
*/
|
||||
296
game-runtime/games/wanglanmei-ref/src/balance.js
Normal file
@ -0,0 +1,296 @@
|
||||
/**
|
||||
* balance.js — 小卖部经营 · 数值与公式单一事实源(纯数据 + 纯函数,零依赖)
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:shop-core.js(状态机)/ test(数值断言)/ 渲染层(展示)
|
||||
*
|
||||
* 【设计原则】
|
||||
* 1. 金额一律用「角」整数(1 元 = 10 角),杜绝浮点漂移;展示层再 ÷10 格式化。
|
||||
* 2. 二阶决策必须「数值上两头都能输」:定价过低 → 毛利薄到盖不住房租;定价过高 →
|
||||
* 客流萎缩到营收盖不住房租。estimateDayOutcome 给出解析模型,测试钉死该性质。
|
||||
* 3. 所有曲线/公式集中在本文件,shop-core 只消费——调参不碰状态机。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 商品表(wholesale/basePrice 单位=角;popularity=顾客点单权重)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProductDef
|
||||
* @property {string} id 商品 id(存档/事件键)。
|
||||
* @property {string} name 展示名(简体中文)。
|
||||
* @property {number} wholesale 进货价(角)。
|
||||
* @property {number} basePrice 基准售价(角,priceMul=1.0 时)。
|
||||
* @property {number} popularity 点单权重(顾客随机点单按权重抽样)。
|
||||
* @property {boolean} initial 开局是否解锁。
|
||||
*/
|
||||
|
||||
/** @type {ProductDef[]} 商品全表(前 3 件开局解锁,后 2 件经升级解锁)。 */
|
||||
export const PRODUCTS = [
|
||||
{ id: 'soda', name: '汽水', wholesale: 20, basePrice: 40, popularity: 1.0, initial: true },
|
||||
{ id: 'snack', name: '辣条', wholesale: 10, basePrice: 25, popularity: 1.2, initial: true },
|
||||
{ id: 'bread', name: '面包', wholesale: 30, basePrice: 60, popularity: 0.8, initial: true },
|
||||
{ id: 'ice', name: '冰淇淋', wholesale: 40, basePrice: 90, popularity: 0.7, initial: false },
|
||||
{ id: 'toy', name: '小玩具', wholesale: 80, basePrice: 180, popularity: 0.35, initial: false },
|
||||
];
|
||||
|
||||
/** 按 id 取商品定义(不存在返回 undefined)。@param {string} id */
|
||||
export function productOf(id) {
|
||||
return PRODUCTS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 营业日节奏(单位:秒)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const DAY = {
|
||||
/** 单个营业日时长(秒)≈ 96s,落在 brief 的 90~120s 区间。 */
|
||||
lengthSec: 96,
|
||||
/** 高峰期起点(秒)。 */
|
||||
rushStartSec: 30,
|
||||
/** 高峰期终点(秒)。 */
|
||||
rushEndSec: 66,
|
||||
/** 高峰进出场的平滑斜坡(秒)——rushFactor 在边缘线性过渡,供色板/BGM crossfade。 */
|
||||
rushRampSec: 3,
|
||||
/** 一个段落包含的营业日数(3 日一段落,首段落内必出首次升级)。 */
|
||||
daysPerSegment: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算某时刻的高峰系数 rushFactor ∈ [0,1](0=闲时,1=高峰;边缘 3s 线性渐变)。
|
||||
* @param {number} tSec 营业日内时刻(秒)
|
||||
* @returns {number}
|
||||
*/
|
||||
export function rushFactorAt(tSec) {
|
||||
const { rushStartSec: a, rushEndSec: b, rushRampSec: r } = DAY;
|
||||
if (tSec <= a - r || tSec >= b + r) return 0;
|
||||
if (tSec >= a && tSec <= b) return 1;
|
||||
if (tSec < a) return (tSec - (a - r)) / r; // 进高峰斜坡
|
||||
return 1 - (tSec - b) / r; // 出高峰斜坡
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 客流模型(定价 × 声誉 × 装修 → 到店间隔)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const FLOW = {
|
||||
/** 闲时基础到店间隔(秒)。 */
|
||||
idleIntervalSec: 4.6,
|
||||
/** 高峰基础到店间隔(秒)。 */
|
||||
rushIntervalSec: 2.3,
|
||||
/** 间隔随机抖动区间(乘数,[min,max))。 */
|
||||
jitterMin: 0.85,
|
||||
jitterMax: 1.2,
|
||||
/** 排队槽位数(满了新客进不来=流失,不掉声誉)。 */
|
||||
queueSlots: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* 定价→客流乘数曲线(核心二阶决策之一)。
|
||||
* 低价端斜率 1.5(薄利多销),高价端斜率 1.5(贵了没人来),下限 0.25。
|
||||
* 锚点:0.8→1.30 / 1.0→1.00 / 1.25→0.625 / 1.5→0.25。
|
||||
* @param {number} priceMul 定价乘数 ∈ [0.8, 1.5]
|
||||
* @returns {number} 客流乘数
|
||||
*/
|
||||
export function flowMulOfPrice(priceMul) {
|
||||
if (priceMul <= 1) return 1 + (1 - priceMul) * 1.5;
|
||||
return Math.max(0.25, 1 - (priceMul - 1) * 1.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 声誉→客流乘数(口碑带客)。锚点:rep50→1.0 / rep100→1.45 / rep0→0.55。
|
||||
* @param {number} reputation 声誉 ∈ [0,100]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function flowMulOfReputation(reputation) {
|
||||
return 0.55 + 0.9 * (clamp(reputation, 0, 100) / 100);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 耐心与声誉
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const PATIENCE = {
|
||||
/** 闲时耐心总量(秒)。 */
|
||||
idleSec: 13,
|
||||
/** 高峰耐心总量(秒,更短=压力态)。 */
|
||||
rushSec: 8.5,
|
||||
/** 耐心耗尽后的「救单宽限」(秒):宽限内仍可接待(coyote 语义,逻辑层内实现)。 */
|
||||
graceSec: 0.55,
|
||||
/** 缺货顾客「看了一眼货架」后离店的停留时长(秒)。 */
|
||||
stockoutLingerSec: 2.5,
|
||||
/** 耐心低于该比例时触发一次「不耐烦」事件(气泡/音效挂点)。 */
|
||||
impatientRatio: 0.32,
|
||||
};
|
||||
|
||||
export const REP = {
|
||||
/** 开局声誉。 */
|
||||
start: 62,
|
||||
max: 100,
|
||||
/** 每次成功接待 +(×10 存为整数避免浮点:实际 +0.55,内部用 repTenth)。 */
|
||||
serveGainTenth: 6, // +0.6
|
||||
/** 救单(宽限期内接住)额外 +1.0。 */
|
||||
rescueBonusTenth: 10,
|
||||
/** 耐心耗尽愤怒离店 −4.0。 */
|
||||
angryLossTenth: 40,
|
||||
/** 缺货失望离店 −2.5。 */
|
||||
stockoutLossTenth: 25,
|
||||
/** 日结「全天零差评」口碑回暖 +1.5。 */
|
||||
noAngryBonusTenth: 15,
|
||||
/** 罚没阈值:日结时声誉 < 30 → 触发罚款。 */
|
||||
fineThreshold: 30,
|
||||
};
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 经济(单位:角)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const ECON = {
|
||||
/** 开局资金(角)= ¥120。 */
|
||||
startCashJiao: 1200,
|
||||
/** 基础日租金(角)= ¥40;每进一个段落 +¥12(递增压力)。 */
|
||||
rentBaseJiao: 400,
|
||||
rentPerSegmentJiao: 120,
|
||||
/** 罚款基数(角)= ¥24 + 每个差评 ¥2(声誉 < fineThreshold 的日结触发)。 */
|
||||
fineBaseJiao: 240,
|
||||
finePerAngryJiao: 20,
|
||||
/** 大额成交阈值(角):单笔 ≥ ¥9 触发 hit-stop/screen-shake 级反馈。 */
|
||||
bigSaleJiao: 90,
|
||||
/** 连击小费:每级连击 +4% 售价,封顶 +32%(连击 9)。 */
|
||||
comboTipStep: 0.04,
|
||||
comboTipCap: 0.32,
|
||||
/** 定价乘数边界与步进。 */
|
||||
priceMin: 0.8,
|
||||
priceMax: 1.5,
|
||||
priceStep: 0.05,
|
||||
};
|
||||
|
||||
/**
|
||||
* 商品当前售价(角):基准价 × 定价乘数,取整到角。
|
||||
* @param {string} productId
|
||||
* @param {number} priceMul
|
||||
* @returns {number} 售价(角)
|
||||
*/
|
||||
export function priceJiaoOf(productId, priceMul) {
|
||||
const p = productOf(productId);
|
||||
return p ? Math.round(p.basePrice * priceMul) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连击小费乘数:连击 n(≥1)时售价乘 1 + min((n-1)×step, cap)。
|
||||
* @param {number} combo 当前连击数
|
||||
* @returns {number}
|
||||
*/
|
||||
export function comboTipMul(combo) {
|
||||
const bonus = Math.min(Math.max(combo - 1, 0) * ECON.comboTipStep, ECON.comboTipCap);
|
||||
return 1 + bonus;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 升级树(货架容量 / 收银速度 / 店面装修 / 新品解锁)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* @typedef {Object} UpgradeDef
|
||||
* @property {string} id 升级 id。
|
||||
* @property {string} name 展示名。
|
||||
* @property {string} desc 一句话效果描述(展示用)。
|
||||
* @property {number} costJiao 价格(角)。
|
||||
* @property {string|null} requires 前置升级 id(无=null)。
|
||||
* @property {string|null} unlocksProduct 解锁的商品 id(非解锁类=null)。
|
||||
*/
|
||||
|
||||
/** @type {UpgradeDef[]} 升级树(首段落内最低 ¥55 可达首次升级)。 */
|
||||
export const UPGRADES = [
|
||||
{ id: 'speed1', name: '收银加速 I', desc: '收银间隔 0.55s→0.42s', costJiao: 550, requires: null, unlocksProduct: null },
|
||||
{ id: 'shelf1', name: '货架扩容 I', desc: '库存上限 24→34', costJiao: 650, requires: null, unlocksProduct: null },
|
||||
{ id: 'unlock_ice', name: '新品·冰淇淋', desc: '解锁高毛利冰淇淋', costJiao: 750, requires: null, unlocksProduct: 'ice' },
|
||||
{ id: 'decor1', name: '店面装修 I', desc: '顾客耐心 +15%,日结声誉 +3', costJiao: 850, requires: null, unlocksProduct: null },
|
||||
{ id: 'unlock_toy', name: '新品·小玩具', desc: '解锁高单价小玩具', costJiao: 1100, requires: null, unlocksProduct: 'toy' },
|
||||
{ id: 'speed2', name: '收银加速 II', desc: '收银间隔 0.42s→0.32s', costJiao: 1300, requires: 'speed1', unlocksProduct: null },
|
||||
{ id: 'shelf2', name: '货架扩容 II', desc: '库存上限 34→48', costJiao: 1500, requires: 'shelf1', unlocksProduct: null },
|
||||
{ id: 'decor2', name: '店面装修 II', desc: '耐心 +30%,日结声誉 +6,客流 +12%', costJiao: 1900, requires: 'decor1', unlocksProduct: null },
|
||||
];
|
||||
|
||||
/** 按 id 取升级定义。@param {string} id */
|
||||
export function upgradeOf(id) {
|
||||
return UPGRADES.find((u) => u.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「已购升级 id 集合」推导生效参数(货架容量/收银冷却/装修加成)。
|
||||
* @param {Record<string, boolean>} owned 已购集合(id→true)
|
||||
* @returns {{ shelfCap:number, serveCooldownSec:number, patienceMul:number, settleRepBonusTenth:number, flowMul:number }}
|
||||
*/
|
||||
export function derivedOfUpgrades(owned) {
|
||||
const has = (id) => !!owned[id];
|
||||
return {
|
||||
// 货架容量:24 → 34 → 48。
|
||||
shelfCap: has('shelf2') ? 48 : has('shelf1') ? 34 : 24,
|
||||
// 收银冷却:0.55 → 0.42 → 0.32 秒。
|
||||
serveCooldownSec: has('speed2') ? 0.32 : has('speed1') ? 0.42 : 0.55,
|
||||
// 装修:耐心乘数 / 日结声誉回复(×10 整数)/ 客流乘数。
|
||||
patienceMul: has('decor2') ? 1.3 : has('decor1') ? 1.15 : 1,
|
||||
settleRepBonusTenth: has('decor2') ? 60 : has('decor1') ? 30 : 0,
|
||||
flowMul: has('decor2') ? 1.12 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 解析日收益模型(测试钉死「两头都能输」+ 内部预判用,不进游戏 UI)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 估算一个营业日的期望结果(解析近似,不含随机)。
|
||||
* 模型:到客数 = 基础到客 × 定价客流 × 声誉客流;成交数 = min(到客, 接待力上限, 备货);
|
||||
* 毛利 = Σ(售价 − 进货价) 按 popularity 加权均值 × 成交数;净利 = 毛利 − 房租。
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.priceMul 定价乘数
|
||||
* @param {number} [opts.reputation] 声誉(缺省 REP.start)
|
||||
* @param {number} [opts.serveCapacity] 全天接待力上限(缺省 26,对应人手一台收银)
|
||||
* @param {number} [opts.stockUnits] 备货量上限(缺省 Infinity,仅算定价效应)
|
||||
* @param {number} [opts.segment] 段落(决定房租,缺省 1)
|
||||
* @param {string[]} [opts.unlocked] 解锁商品(缺省开局 3 件)
|
||||
* @returns {{ arrivals:number, served:number, grossJiao:number, rentJiao:number, profitJiao:number }}
|
||||
*/
|
||||
export function estimateDayOutcome(opts) {
|
||||
const priceMul = opts.priceMul;
|
||||
const reputation = opts.reputation == null ? REP.start : opts.reputation;
|
||||
const serveCapacity = opts.serveCapacity == null ? 26 : opts.serveCapacity;
|
||||
const stockUnits = opts.stockUnits == null ? Infinity : opts.stockUnits;
|
||||
const segment = opts.segment == null ? 1 : opts.segment;
|
||||
const unlocked = opts.unlocked || PRODUCTS.filter((p) => p.initial).map((p) => p.id);
|
||||
|
||||
// 基础到客数:闲时段 + 高峰段按各自间隔积分。
|
||||
const idleSec = DAY.lengthSec - (DAY.rushEndSec - DAY.rushStartSec);
|
||||
const baseArrivals = idleSec / FLOW.idleIntervalSec + (DAY.rushEndSec - DAY.rushStartSec) / FLOW.rushIntervalSec;
|
||||
const arrivals = baseArrivals * flowMulOfPrice(priceMul) * flowMulOfReputation(reputation);
|
||||
const served = Math.min(arrivals, serveCapacity, stockUnits);
|
||||
|
||||
// popularity 加权平均毛利(角/单)。
|
||||
const defs = unlocked.map((id) => productOf(id)).filter(Boolean);
|
||||
const wSum = defs.reduce((s, p) => s + p.popularity, 0);
|
||||
const avgMarginJiao = defs.reduce(
|
||||
(s, p) => s + (Math.round(p.basePrice * priceMul) - p.wholesale) * (p.popularity / wSum),
|
||||
0
|
||||
);
|
||||
|
||||
const grossJiao = Math.round(served * avgMarginJiao);
|
||||
const rentJiao = ECON.rentBaseJiao + (segment - 1) * ECON.rentPerSegmentJiao;
|
||||
return { arrivals, served, grossJiao, rentJiao, profitJiao: grossJiao - rentJiao };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 通用小工具
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 钳制。@param {number} v @param {number} lo @param {number} hi */
|
||||
export function clamp(v, lo, hi) {
|
||||
return v < lo ? lo : v > hi ? hi : v;
|
||||
}
|
||||
|
||||
/** 角 → 元字符串(如 1234 → "123.4";整元省略小数)。@param {number} jiao */
|
||||
export function fmtYuan(jiao) {
|
||||
const yuan = jiao / 10;
|
||||
return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(1);
|
||||
}
|
||||
309
game-runtime/games/wanglanmei-ref/src/hostfx.js
Normal file
@ -0,0 +1,309 @@
|
||||
/**
|
||||
* hostfx.js — 表现层导演(宿主层):缓动补间 / 弹簧 / 弹道抛掷 / 粒子分层 / juice / 浮字
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:main.js(事件→表现)/ render.js(读取动画值并绘制)
|
||||
*
|
||||
* 【插件用法(全部走公开 API)】
|
||||
* - P4 gamefeel.easing:一切补间曲线(面板滑入/商品飞行/气泡弹出);
|
||||
* - P2 physics-lite.springStep:按钮按压脉冲/落货压扁/连击徽章弹跳(弹簧阻尼手感);
|
||||
* - P2 physics-lite.Projectile:进货抛掷上架弹道(解析初速 → 半隐式欧拉积分,真弹道);
|
||||
* - P3 particles-juice:多实例分层取色——插件不内置颜色(集成段 REPORT §4.3 教训),
|
||||
* 宿主为每层提供色族(金币金/奶油亮/暖棕烟),render 前逐层设 fillStyle;
|
||||
* juice 套件(hit-stop/屏震/闪白/脉冲)只用「注册进 registry 的权威实例」(全局唯一时间缩放)。
|
||||
*
|
||||
* 【双时间轴】step(dtReal, dtWorld):世界系动画(飞行/弹道/浮字)吃 dtWorld(受 hit-stop 冻结,
|
||||
* 打击骤停时商品也悬停——这正是手感);UI 系动画(按钮弹簧/面板补间)吃 dtReal(界面不卡死)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { easing } from '../../../src/plugins/gamefeel/impl.js';
|
||||
import { springStep, Projectile } from '../../../src/plugins/physics-lite/impl.js';
|
||||
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
|
||||
|
||||
/**
|
||||
* 创建表现层导演。
|
||||
* @param {Object} deps
|
||||
* @param {import('../../../src/core/api.d.ts').HostDevContextBundle} deps.bundle 宿主受控面 bundle(派生粒子层上下文用)
|
||||
* @param {any} deps.juice 已注册的 particles-juice 权威实例(juice 套件 + 闪白)
|
||||
* @returns {HostFx}
|
||||
*/
|
||||
export function createHostFx(deps) {
|
||||
const bundle = deps.bundle;
|
||||
const juice = deps.juice;
|
||||
|
||||
/* ── 粒子分层(多实例 = 多色族 + 各自独立环境曲线,规避 v1「全局最近发射器环境」串扰)── */
|
||||
/**
|
||||
* 建一层粒子:独立插件实例 + 派生受控上下文(host 拥有 bundle,合法派生;autoStep 随 tick 驱动)。
|
||||
* @param {string} name 层名(参与随机子种子派生)
|
||||
* @returns {any} particles-juice 实例
|
||||
*/
|
||||
function makeLayer(name) {
|
||||
const inst = createParticlesJuicePlugin({ maxParticles: 400 });
|
||||
inst.init(bundle.deriveContextFor(name, name));
|
||||
return inst;
|
||||
}
|
||||
const layerGold = makeLayer('fx-gold'); // 金币金(收钱)
|
||||
const layerCream = makeLayer('fx-cream'); // 奶油亮(满意/救单/升级)
|
||||
const layerSmoke = makeLayer('fx-smoke'); // 暖棕烟(差评/缺货)
|
||||
|
||||
/* ── 补间(UI 时间轴)── */
|
||||
/** @type {Array<{t:number,dur:number,ease:(x:number)=>number,onUpdate:(v:number)=>void,onDone:(()=>void)|null}>} */
|
||||
let tweens = [];
|
||||
/**
|
||||
* 加一条补间:dur 秒内 onUpdate(eased t)。
|
||||
* @param {number} dur @param {(v:number)=>void} onUpdate
|
||||
* @param {{ease?:(x:number)=>number, onDone?:()=>void}} [opts]
|
||||
*/
|
||||
function addTween(dur, onUpdate, opts) {
|
||||
tweens.push({
|
||||
t: 0,
|
||||
dur: Math.max(dur, 0.001),
|
||||
ease: (opts && opts.ease) || easing.cubicOut,
|
||||
onUpdate,
|
||||
onDone: (opts && opts.onDone) || null,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 弹簧表(UI 时间轴;springStep 半隐式积分)── */
|
||||
/** @type {Map<string, {state:{x:number,v:number}, target:number, stiffness:number, damping:number}>} */
|
||||
const springs = new Map();
|
||||
/**
|
||||
* 读弹簧当前值(不存在则建于 rest 值)。
|
||||
* @param {string} id @param {number} [rest] 静止值(缺省 1)
|
||||
*/
|
||||
function springVal(id, rest) {
|
||||
let s = springs.get(id);
|
||||
if (!s) {
|
||||
s = { state: { x: rest == null ? 1 : rest, v: 0 }, target: rest == null ? 1 : rest, stiffness: 320, damping: 14 };
|
||||
springs.set(id, s);
|
||||
}
|
||||
return s.state.x;
|
||||
}
|
||||
/**
|
||||
* 弹簧脉冲:瞬时速度踢一脚(按压/弹跳手感),目标值回弹。
|
||||
* @param {string} id @param {number} kick 速度冲量(如 -3=先压扁再弹回)
|
||||
* @param {number} [target] 新目标(缺省保持)
|
||||
*/
|
||||
function springKick(id, kick, target) {
|
||||
springVal(id); // 确保存在
|
||||
const s = springs.get(id);
|
||||
s.state.v += kick;
|
||||
if (target != null) s.target = target;
|
||||
}
|
||||
|
||||
/* ── 商品飞行(世界时间轴;easing 弧线)── */
|
||||
/** @type {Array<{t:number,dur:number,fx:number,fy:number,tx:number,ty:number,arc:number,imageKey:string|null,label:string,onArrive:(()=>void)|null}>} */
|
||||
let flights = [];
|
||||
/**
|
||||
* 货架→顾客的商品飞行(非线性缓动 + 抛物弧)。
|
||||
* @param {{fx:number,fy:number,tx:number,ty:number,dur?:number,arc?:number,imageKey?:string,label?:string,onArrive?:()=>void}} o
|
||||
*/
|
||||
function addFlight(o) {
|
||||
flights.push({
|
||||
t: 0,
|
||||
dur: o.dur || 0.3,
|
||||
fx: o.fx,
|
||||
fy: o.fy,
|
||||
tx: o.tx,
|
||||
ty: o.ty,
|
||||
arc: o.arc == null ? 46 : o.arc,
|
||||
imageKey: o.imageKey || null,
|
||||
label: o.label || '',
|
||||
onArrive: o.onArrive || null,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 进货抛掷(世界时间轴;P2 Projectile 真弹道)── */
|
||||
/** @type {Array<{proj:Projectile, age:number, dur:number, tx:number, ty:number, imageKey:string|null, onLand:(()=>void)|null}>} */
|
||||
let tosses = [];
|
||||
/** 弹道重力(单位/秒²,画布坐标系 y 向下)。 */
|
||||
const TOSS_G = 980;
|
||||
/**
|
||||
* 抛一件货上架:按目标点与飞行时长解析初速(vx=dx/T, vy=(dy−½gT²)/T),逐帧积分。
|
||||
* @param {{fx:number,fy:number,tx:number,ty:number,dur?:number,imageKey?:string,onLand?:()=>void}} o
|
||||
*/
|
||||
function addToss(o) {
|
||||
const T = o.dur || 0.45;
|
||||
const vx = (o.tx - o.fx) / T;
|
||||
const vy = (o.ty - o.fy - 0.5 * TOSS_G * T * T) / T;
|
||||
tosses.push({
|
||||
proj: new Projectile({ px: o.fx, py: o.fy, vx, vy }, { gravity: { x: 0, y: TOSS_G } }),
|
||||
age: 0,
|
||||
dur: T,
|
||||
tx: o.tx,
|
||||
ty: o.ty,
|
||||
imageKey: o.imageKey || null,
|
||||
onLand: o.onLand || null,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 浮字 / 点按涟漪(世界时间轴)── */
|
||||
/** @type {Array<{text:string,x:number,y:number,t:number,dur:number,colorName:string,size:number}>} */
|
||||
let floaters = [];
|
||||
/** 浮字(+¥4.5 / 差评! / 连击×N)。 */
|
||||
function addFloater(text, x, y, colorName, size) {
|
||||
floaters.push({ text, x, y, t: 0, dur: 0.9, colorName: colorName || 'gold', size: size || 16 });
|
||||
}
|
||||
/** @type {Array<{x:number,y:number,t:number}>} 点按涟漪(每次 tap 当帧出现=反馈零延迟)。 */
|
||||
let tapRings = [];
|
||||
function addTapRing(x, y) {
|
||||
tapRings.push({ x, y, t: 0 });
|
||||
}
|
||||
|
||||
/* ── 粒子触发配方(中性预设 + 局部覆盖;语义/颜色=游戏层)── */
|
||||
/** 收钱金光:向上喷泉(重力回落)。 */
|
||||
function burstCoin(x, y, big) {
|
||||
layerGold.spawnEmitter('burst', x, y, {
|
||||
count: big ? 26 : 13,
|
||||
angle: -Math.PI / 2,
|
||||
spread: 0.85,
|
||||
speedMin: 130,
|
||||
speedMax: big ? 320 : 240,
|
||||
gravity: 420,
|
||||
lifeMin: 0.35,
|
||||
lifeMax: 0.7,
|
||||
sizeCurve: { kind: 'decay', from: big ? 5.5 : 4, to: 0, power: 1.3 },
|
||||
});
|
||||
}
|
||||
/** 满意亮尘:轻飘上扬。 */
|
||||
function burstSparkle(x, y, n) {
|
||||
layerCream.spawnEmitter('burst', x, y, {
|
||||
count: n || 10,
|
||||
angle: -Math.PI / 2,
|
||||
spread: 1.1,
|
||||
speedMin: 40,
|
||||
speedMax: 110,
|
||||
gravity: -30,
|
||||
lifeMin: 0.4,
|
||||
lifeMax: 0.8,
|
||||
sizeCurve: { kind: 'decay', from: 3, to: 0, power: 1 },
|
||||
});
|
||||
}
|
||||
/** 差评烟尘:闷闷地散开。 */
|
||||
function burstSmoke(x, y) {
|
||||
layerSmoke.spawnEmitter('burst', x, y, {
|
||||
count: 12,
|
||||
angle: -Math.PI / 2,
|
||||
spread: Math.PI,
|
||||
speedMin: 25,
|
||||
speedMax: 70,
|
||||
gravity: -18,
|
||||
drag: 1.2,
|
||||
lifeMin: 0.5,
|
||||
lifeMax: 1.0,
|
||||
sizeCurve: { kind: 'easeInOut', from: 5, to: 1 },
|
||||
alphaCurve: { kind: 'decay', from: 0.7, to: 0, power: 1.5 },
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 主步进 ── */
|
||||
/**
|
||||
* 推进表现层一帧。
|
||||
* @param {number} dtReal UI 时间步(秒)
|
||||
* @param {number} dtWorld 世界时间步(已乘 hit-stop 缩放)
|
||||
*/
|
||||
function step(dtReal, dtWorld) {
|
||||
// 补间(UI)。
|
||||
for (let i = tweens.length - 1; i >= 0; i--) {
|
||||
const tw = tweens[i];
|
||||
tw.t += dtReal;
|
||||
const k = Math.min(tw.t / tw.dur, 1);
|
||||
tw.onUpdate(tw.ease(k));
|
||||
if (k >= 1) {
|
||||
if (tw.onDone) tw.onDone();
|
||||
tweens.splice(i, 1);
|
||||
}
|
||||
}
|
||||
// 弹簧(UI;springStep 内部自适应子步,安全稳定)。
|
||||
for (const s of springs.values()) {
|
||||
s.state = springStep(s.state, s.target, { stiffness: s.stiffness, damping: s.damping }, dtReal);
|
||||
}
|
||||
// 飞行(世界)。
|
||||
for (let i = flights.length - 1; i >= 0; i--) {
|
||||
const f = flights[i];
|
||||
f.t += dtWorld;
|
||||
if (f.t >= f.dur) {
|
||||
if (f.onArrive) f.onArrive();
|
||||
flights.splice(i, 1);
|
||||
}
|
||||
}
|
||||
// 弹道(世界;Projectile 固定步长积分)。
|
||||
for (let i = tosses.length - 1; i >= 0; i--) {
|
||||
const t = tosses[i];
|
||||
t.age += dtWorld;
|
||||
t.proj.step(dtWorld);
|
||||
if (t.age >= t.dur) {
|
||||
if (t.onLand) t.onLand();
|
||||
tosses.splice(i, 1);
|
||||
}
|
||||
}
|
||||
// 浮字 / 涟漪(世界)。
|
||||
for (let i = floaters.length - 1; i >= 0; i--) {
|
||||
const f = floaters[i];
|
||||
f.t += dtWorld;
|
||||
if (f.t >= f.dur) floaters.splice(i, 1);
|
||||
}
|
||||
for (let i = tapRings.length - 1; i >= 0; i--) {
|
||||
tapRings[i].t += dtReal;
|
||||
if (tapRings[i].t >= 0.32) tapRings.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制粒子分层(每层宿主供色——插件无内置颜色)+ 权威实例(其 render 含闪白叠加)。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {{c:(n:string,a?:number)=>string}} pal
|
||||
*/
|
||||
function renderParticles(g, pal) {
|
||||
g.save();
|
||||
g.fillStyle = pal.c('coin');
|
||||
layerGold.render(g);
|
||||
g.fillStyle = pal.c('cream');
|
||||
layerCream.render(g);
|
||||
g.fillStyle = pal.c('smoke');
|
||||
layerSmoke.render(g);
|
||||
g.restore();
|
||||
// 权威实例:无粒子,render 仅绘 hit 闪白叠加层。
|
||||
juice.render(g);
|
||||
}
|
||||
|
||||
/** @type {HostFx} */
|
||||
const fx = {
|
||||
easing,
|
||||
addTween,
|
||||
springVal,
|
||||
springKick,
|
||||
addFlight,
|
||||
addToss,
|
||||
addFloater,
|
||||
addTapRing,
|
||||
burstCoin,
|
||||
burstSparkle,
|
||||
burstSmoke,
|
||||
step,
|
||||
renderParticles,
|
||||
/** 世界系动画的只读视图(render.js 取用绘制)。 */
|
||||
view: { flights, tosses, floaters, tapRings },
|
||||
/** juice 套件直通(hit-stop/屏震/闪白/脉冲,全局唯一实例)。 */
|
||||
juice,
|
||||
/** 粒子层探针(取证:各层粒子数)。 */
|
||||
particleProbe() {
|
||||
return {
|
||||
gold: layerGold.particleCount(),
|
||||
cream: layerCream.particleCount(),
|
||||
smoke: layerSmoke.particleCount(),
|
||||
};
|
||||
},
|
||||
/** 释放手动管理的粒子层(页面卸载/重启用;幂等)。 */
|
||||
dispose() {
|
||||
layerGold.dispose();
|
||||
layerCream.dispose();
|
||||
layerSmoke.dispose();
|
||||
},
|
||||
};
|
||||
return fx;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {ReturnType<typeof createHostFx>} HostFx
|
||||
*/
|
||||
154
game-runtime/games/wanglanmei-ref/src/loop-proof.js
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* loop-proof.js — 闭环可达 / 失败态可达的脚本化证明(纯逻辑,零 DOM)
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:test(断言)+ main.js(boot 时跑一遍,结果挂 __refForensics)
|
||||
*
|
||||
* 【定位】
|
||||
* 用固定策略驱动 shop-core,产出两份「可机读证明」:
|
||||
* 1. proveCoreLoop:开局 → 进货定价 → 营业接待 → 首日结算 → 首次升级 —— 闭环可达,
|
||||
* 并记录全部操作(op 序列)供断言「操作数 ≤ 上限」(结构深度的客观证据)。
|
||||
* 2. proveFailure:摆烂策略(不接待)→ 差评滚雪球 → 罚没 → 失败终态 —— 失败真实可达
|
||||
* (spike 判词「只胜不败」的反面证据)。
|
||||
* 两份证明都确定性(注入 SeededRandom),同种子重跑逐字段一致。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { SeededRandom } from '../../../src/core/plugin.js';
|
||||
import { createShopCore } from './shop-core.js';
|
||||
import { PRODUCTS, UPGRADES } from './balance.js';
|
||||
|
||||
/** 仿真步长(秒):30Hz 足够覆盖逻辑分支,跑得快。 */
|
||||
const STEP = 1 / 30;
|
||||
|
||||
/**
|
||||
* 闭环证明:开局→首日结算→首次升级。
|
||||
* 策略:均衡备货(每件 7/8/5)→ 定价 1.15(解析模型内的较优区)→ 营业时「有货且有客就接待」
|
||||
* → 日结确认 → 买「买得起的最便宜升级」。
|
||||
* @param {number} [seed] 随机种子(缺省 20260612)
|
||||
* @param {number} [maxOps] 操作数上限(缺省 64;超限即判失败)
|
||||
* @returns {{ ok:boolean, opCount:number, ops:string[], day:number, upgradeId:string|null,
|
||||
* served:number, cashJiao:number, reputation:number, failReason:string|null }}
|
||||
*/
|
||||
export function proveCoreLoop(seed, maxOps) {
|
||||
const limit = maxOps == null ? 64 : maxOps;
|
||||
const core = createShopCore({ random: new SeededRandom(seed == null ? 20260612 : seed) });
|
||||
/** @type {string[]} 操作流水(人读得懂的闭环路径证据)。 */
|
||||
const ops = [];
|
||||
/** 记一次玩家操作。@param {string} desc */
|
||||
const op = (desc) => ops.push(desc);
|
||||
|
||||
// ① 开局。
|
||||
core.startGame(null);
|
||||
op('startGame');
|
||||
|
||||
// ② 进货:按销量预期均衡备货(汽水 7 / 辣条 8 / 面包 5 = 20 件 ≤ 容量 24)。
|
||||
const plan = { soda: 7, snack: 8, bread: 5 };
|
||||
for (const id of Object.keys(plan)) {
|
||||
const r = core.buyStock(id, plan[id]);
|
||||
op(`buyStock(${id},${plan[id]})`);
|
||||
if (!r.ok) return fail(`进货失败:${id}:${r.reason}`);
|
||||
}
|
||||
|
||||
// ③ 定价:1.15(利润与客流的平衡点附近)。
|
||||
core.setPrice(1.15);
|
||||
op('setPrice(1.15)');
|
||||
|
||||
// ④ 开门营业。
|
||||
if (!core.startDay().ok) return fail('startDay失败');
|
||||
op('startDay');
|
||||
|
||||
// ⑤ 营业:固定策略逐帧推进——出现「有货可接」的顾客且冷却就接待(每次接待计 1 op)。
|
||||
let guard = 0;
|
||||
while (core.state.phase === 'open' && guard++ < 30 * 200) {
|
||||
core.update(STEP);
|
||||
if (core.state.serveCooldown > 0) continue;
|
||||
const c = core.state.customers.find(
|
||||
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
|
||||
);
|
||||
if (c) {
|
||||
const r = core.serve(c.productId);
|
||||
if (r.ok) op(`serve(${c.productId})`);
|
||||
}
|
||||
}
|
||||
if (core.state.phase !== 'settle') return fail(`未到达日结(phase=${core.state.phase})`);
|
||||
|
||||
// ⑥ 日结确认。
|
||||
const served = core.state.settle.served;
|
||||
core.confirmSettle();
|
||||
op('confirmSettle');
|
||||
if (core.state.phase !== 'prep') return fail(`日结后未回 prep(phase=${core.state.phase})`);
|
||||
|
||||
// ⑦ 首次升级:买「买得起的最便宜」(升级树最低 ¥55,首段落内必可达)。
|
||||
const affordable = UPGRADES.filter((u) => !u.requires && core.state.cash >= u.costJiao).sort(
|
||||
(a, b) => a.costJiao - b.costJiao
|
||||
)[0];
|
||||
if (!affordable) return fail(`第2天开市前无可负担升级(cash=${core.state.cash})`);
|
||||
const r = core.buyUpgrade(affordable.id);
|
||||
op(`buyUpgrade(${affordable.id})`);
|
||||
if (!r.ok) return fail(`升级购买失败:${r.reason}`);
|
||||
|
||||
core.drainEvents(); // 清空事件(证明只看状态终点)
|
||||
return {
|
||||
ok: ops.length <= limit,
|
||||
opCount: ops.length,
|
||||
ops,
|
||||
day: core.state.day,
|
||||
upgradeId: affordable.id,
|
||||
served,
|
||||
cashJiao: core.state.cash,
|
||||
reputation: core.reputation(),
|
||||
failReason: ops.length <= limit ? null : `操作数超限:${ops.length}>${limit}`,
|
||||
};
|
||||
|
||||
/** 失败收口。@param {string} why */
|
||||
function fail(why) {
|
||||
return { ok: false, opCount: ops.length, ops, day: core.state.day, upgradeId: null, served: 0, cashJiao: core.state.cash, reputation: core.reputation(), failReason: why };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败态证明:摆烂策略(备货极少 + 全天不接待)→ 差评滚雪球 → 罚没 → 失败终态。
|
||||
* @param {number} [seed] 随机种子(缺省 4422)
|
||||
* @param {number} [maxDays] 最多模拟天数(缺省 4;超出仍未失败则证明不成立)
|
||||
* @returns {{ ok:boolean, reason:string|null, days:number, finedJiao:number, angry:number, events:string[] }}
|
||||
*/
|
||||
export function proveFailure(seed, maxDays) {
|
||||
const days = maxDays == null ? 4 : maxDays;
|
||||
const core = createShopCore({ random: new SeededRandom(seed == null ? 4422 : seed) });
|
||||
core.startGame(null);
|
||||
let finedJiao = 0;
|
||||
let angry = 0;
|
||||
/** @type {string[]} 关键事件流水(fined/gameover 证据)。 */
|
||||
const trail = [];
|
||||
|
||||
for (let d = 0; d < days; d++) {
|
||||
// 故意只买 1 件货(缺货流失双杀)然后开门完全不接待。
|
||||
core.buyStock('snack', 1);
|
||||
core.setPrice(1.0);
|
||||
if (!core.startDay().ok) break;
|
||||
let guard = 0;
|
||||
while (core.state.phase === 'open' && guard++ < 30 * 200) {
|
||||
core.update(STEP);
|
||||
}
|
||||
// 收集本日事件证据。
|
||||
for (const e of core.drainEvents()) {
|
||||
if (e.type === 'fined') {
|
||||
finedJiao += e.fineJiao;
|
||||
trail.push(`day${core.state.day}:fined:${e.fineJiao}`);
|
||||
} else if (e.type === 'angry_leave') {
|
||||
angry += 1;
|
||||
} else if (e.type === 'gameover') {
|
||||
trail.push(`day${core.state.day}:gameover:${e.reason}`);
|
||||
}
|
||||
}
|
||||
if (core.state.phase === 'gameover') {
|
||||
return { ok: true, reason: core.state.gameoverReason, days: core.state.day, finedJiao, angry, events: trail };
|
||||
}
|
||||
if (core.state.phase === 'settle') core.confirmSettle();
|
||||
if (core.state.phase === 'summary') core.confirmSummary();
|
||||
}
|
||||
return { ok: false, reason: null, days, finedJiao, angry, events: trail };
|
||||
}
|
||||
|
||||
/** 商品/升级表再导出(证明消费方免双 import)。 */
|
||||
export { PRODUCTS, UPGRADES };
|
||||
823
game-runtime/games/wanglanmei-ref/src/main.js
Normal file
@ -0,0 +1,823 @@
|
||||
/**
|
||||
* main.js — wanglanmei-ref 宿主引导(浏览器层;受控面提供方)
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:entry.js(esbuild 入口)→ index.html
|
||||
*
|
||||
* 【职责】仿 host-dev/host.js 样板:
|
||||
* - Canvas 390×844 DPR2(取证固定环境)+ 步进时钟(?mode=evidence 完全确定性);
|
||||
* - createHostDevContext 受控面 → PluginRegistry 注册 7 件业务插件 + probe 宿主自管六锚链;
|
||||
* - DOM 输入 → 归一化 → HostDevInputBridge._emit(真实交互灌进受控输入面);
|
||||
* - 游戏逻辑(shop-core 纯模块)只吃受控 dt/随机;渲染/资产/BGM 在宿主层;
|
||||
* - 取证导出:window.__refForensics / window.__refChecklist / window.__refHost(驱动钩子)。
|
||||
*
|
||||
* 【红线遵守】
|
||||
* - 游戏逻辑零裸 Date.now/Math.random/AudioContext —— 全部经受控面/注入;
|
||||
* - 宿主层是受控面提供方:RAF/Image/fetch/AudioContext 工厂只在本层出现;
|
||||
* - 不 import littlejsengine(α 裁定:发行版 v0 = core+插件,引擎桩=RAF+tick)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
|
||||
import { createCollisionPlugin, aabbVsAabb } from '../../../src/plugins/collision/impl.js';
|
||||
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/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';
|
||||
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
|
||||
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
|
||||
import { createRuntimeProbePlugin } from '../../../src/plugins/runtime-probe/impl.js';
|
||||
|
||||
import { createShopCore } from './shop-core.js';
|
||||
import { createLinePicker, LINES } from './shopkeeper-lines.js';
|
||||
import { createPalette } from './palette.js';
|
||||
import { loadAssets, PRODUCT_IMAGE_KEY } from './assets.js';
|
||||
import { createHostFx } from './hostfx.js';
|
||||
import { createAudioDirector } from './audio-director.js';
|
||||
import { drawFrame, computeZones, shelfSlotRect, W as VIEW_W, H as VIEW_H, LAYOUT } from './render.js';
|
||||
import { proveCoreLoop, proveFailure } from './loop-proof.js';
|
||||
import { estimateDayOutcome, ECON, fmtYuan, productOf } from './balance.js';
|
||||
|
||||
/** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐)。 */
|
||||
const DPR = 2;
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
/** 读 URL 参数。 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Canvas 设为逻辑 390×844 / 物理 ×DPR,并把 2D 上下文缩放到逻辑坐标。 */
|
||||
function prepareCanvas(canvas) {
|
||||
canvas.width = VIEW_W * DPR;
|
||||
canvas.height = VIEW_H * DPR;
|
||||
canvas.style.width = VIEW_W + 'px';
|
||||
canvas.style.height = VIEW_H + 'px';
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动参考件。
|
||||
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement}} opts
|
||||
* @returns {Promise<object>} refHost 句柄(同时挂 window.__refHost)
|
||||
*/
|
||||
export async function bootRef(opts) {
|
||||
const canvas = opts.canvas;
|
||||
const statusEl = opts.statusEl || null;
|
||||
const seed = resolveSeed();
|
||||
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
|
||||
const g = prepareCanvas(canvas);
|
||||
|
||||
/** 状态行(仅人看)。 */
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
/* ── 未捕获错误归集(评估门:console 零未捕获错误)── */
|
||||
/** @type {Array<{type:string,message:string}>} */
|
||||
const uncaught = [];
|
||||
window.addEventListener('error', (e) => uncaught.push({ type: 'error', message: String((e && e.message) || e) }));
|
||||
window.addEventListener('unhandledrejection', (e) =>
|
||||
uncaught.push({ type: 'unhandledrejection', message: String((e && e.reason && e.reason.message) || (e && e.reason) || e) })
|
||||
);
|
||||
|
||||
/* ── 受控音频工厂(用户手势解锁后才 new;宿主=受控面提供方)── */
|
||||
let realAudioCtx = null;
|
||||
let audioUnlocked = false;
|
||||
function audioFactory() {
|
||||
if (!audioUnlocked) return null;
|
||||
if (realAudioCtx) return realAudioCtx;
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
realAudioCtx = new AC();
|
||||
return realAudioCtx;
|
||||
}
|
||||
|
||||
/* ── 受控面 bundle:步进时钟 + 主种子 + 真 2D 上下文 ── */
|
||||
let mockNowMs = 0;
|
||||
const bundle = createHostDevContext({ context2d: g, seed, audioFactory, clock: () => mockNowMs });
|
||||
|
||||
/* ── 注册 7 件业务插件(probe 宿主自管,保证六锚偏序——host-dev 同款编排)── */
|
||||
const registry = new PluginRegistry();
|
||||
const collision = createCollisionPlugin({ cellSize: 96 });
|
||||
const physics = createPhysicsLitePlugin();
|
||||
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 });
|
||||
// P9 后端:浏览器 localStorage(不可用环境回退内存 adapter,错误路径留痕)。
|
||||
let saveAdapter;
|
||||
try {
|
||||
saveAdapter = createLocalStorageAdapter();
|
||||
} catch (e) {
|
||||
console.warn('[ref] localStorage 不可用,存档退内存 adapter:' + (e && e.message));
|
||||
saveAdapter = createMemoryAdapter();
|
||||
}
|
||||
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: saveAdapter });
|
||||
registry
|
||||
.register(collision)
|
||||
.register(physics)
|
||||
.register(gamefeel)
|
||||
.register(juice)
|
||||
.register(palettePost)
|
||||
.register(save)
|
||||
.register(audioMusic);
|
||||
registry.useContext(bundle.context);
|
||||
const initRes = registry.initAll();
|
||||
|
||||
/* ── runtime-probe:宿主自管(六锚 canonical 顺序,t_input_bound 恰在 first_paint 后)── */
|
||||
const pluginSet = registry.list().concat(['runtime-probe']);
|
||||
const probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true });
|
||||
const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe');
|
||||
/** 安全打点(probe 异常不中断启动链)。 */
|
||||
function mark(anchor, extra) {
|
||||
try {
|
||||
probe.mark(anchor, extra);
|
||||
} catch (e) {
|
||||
uncaught.push({ type: 'mark', message: String((e && e.message) || e) });
|
||||
}
|
||||
}
|
||||
mark('t_boot', { by: 'ref-host', stage: 'pre-init' });
|
||||
mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok });
|
||||
|
||||
/* ── 游戏对象(逻辑随机=派生流;台词随机=另一派生流——互不推演)── */
|
||||
const core = createShopCore({ random: bundle.deriveContextFor('shop-core', 'shop-core').random });
|
||||
const pickLine = createLinePicker(bundle.deriveContextFor('shop-lines', 'shop-lines').random);
|
||||
const pal = createPalette();
|
||||
const fx = createHostFx({ bundle, juice });
|
||||
const audio = createAudioDirector({ getAudio: () => realAudioCtx, p6: audioMusic });
|
||||
|
||||
/* ── 资产装载(容错:缺失走程序化回退,照常启动)── */
|
||||
setStatus('装载资产…');
|
||||
const assets = await loadAssets('./assets/');
|
||||
|
||||
/* ── boot 期纯逻辑证明(闭环/失败态/定价两头输——挂 forensics 供 e2e 读)── */
|
||||
const loopProof = proveCoreLoop();
|
||||
const failureProof = proveFailure();
|
||||
const priceProof = (() => {
|
||||
const lo = estimateDayOutcome({ priceMul: ECON.priceMin });
|
||||
const mid = estimateDayOutcome({ priceMul: 1.0 });
|
||||
const hi = estimateDayOutcome({ priceMul: ECON.priceMax });
|
||||
return { loProfit: lo.profitJiao, midProfit: mid.profitJiao, hiProfit: hi.profitJiao, twoSidedLoss: lo.profitJiao < 0 && hi.profitJiao < 0 && mid.profitJiao > 0 };
|
||||
})();
|
||||
|
||||
mark('t_game_init', { assetsLoaded: assets.loaded, assetsTotal: assets.total, loopProofOk: loopProof.ok });
|
||||
|
||||
/* ── 宿主 UI 态 ── */
|
||||
const ui = {
|
||||
bubbleText: '',
|
||||
bubbleUntil: 0,
|
||||
bubbleShownAt: -9999,
|
||||
keeperWaveUntil: 0,
|
||||
tickerText: '小本生意,用心经营。',
|
||||
banner: null, // {text,color,until,dur}
|
||||
panelSlide: 1, // prep 面板滑入 0→1
|
||||
settleAnim: 0, // 日结计数 0→1
|
||||
/** @type {Array<{slot:number, avatar:number, emote:string, until:number}>} 离场幽灵(视觉余韵)。 */
|
||||
ghosts: [],
|
||||
};
|
||||
/** @type {Record<number, number>} 顾客出生时刻(走位动画)。 */
|
||||
const customerBornMs = {};
|
||||
|
||||
/* ── 度量(checklist 自动子项的数据源)── */
|
||||
const measures = {
|
||||
frames: 0,
|
||||
taps: 0,
|
||||
tapFeedbackMaxFrames: 0,
|
||||
comboUiShown: 0,
|
||||
hitStopCount: 0,
|
||||
rushPaletteSeen: false,
|
||||
bgmCrossfadeSeen: false,
|
||||
linesShown: 0,
|
||||
bufferedServeReplays: 0,
|
||||
};
|
||||
/** @type {Array<object>} 最近逻辑事件环形缓冲(取证)。 */
|
||||
const recentEvents = [];
|
||||
function pushRecent(e) {
|
||||
recentEvents.push(e);
|
||||
if (recentEvents.length > 240) recentEvents.shift();
|
||||
}
|
||||
|
||||
/* ── 台词气泡 ── */
|
||||
/**
|
||||
* 店主说一句(场景取词;普通优先级 700ms 内不顶替)。
|
||||
* @param {string} sceneKey @param {boolean} [force] 高优先级强插
|
||||
*/
|
||||
function say(sceneKey, force) {
|
||||
if (!force && mockNowMs - ui.bubbleShownAt < 700 && ui.bubbleUntil > mockNowMs) return;
|
||||
const text = pickLine(sceneKey);
|
||||
if (!text) return;
|
||||
ui.bubbleText = text;
|
||||
ui.bubbleUntil = mockNowMs + 2600;
|
||||
ui.bubbleShownAt = mockNowMs;
|
||||
ui.tickerText = text;
|
||||
measures.linesShown += 1;
|
||||
fx.springKick('bubble', 2.2);
|
||||
}
|
||||
|
||||
/** 横幅。 */
|
||||
function banner(text, color, durMs) {
|
||||
ui.banner = { text, color, dur: durMs || 1500, until: mockNowMs + (durMs || 1500) };
|
||||
}
|
||||
|
||||
/* ── 音频解锁(首个手势)── */
|
||||
function unlockAudioOnce() {
|
||||
if (audioUnlocked) return;
|
||||
audioUnlocked = true;
|
||||
const ac = audioFactory();
|
||||
if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {});
|
||||
audio.startBgm();
|
||||
}
|
||||
|
||||
/* ── 命中区(视觉同源几何;collision broadphase + 窄相点测)── */
|
||||
/** @type {Array<any>} 当前帧命中区。 */
|
||||
let zones = [];
|
||||
function rebuildZones() {
|
||||
zones = computeZones(core, { hasSave: hasSave() });
|
||||
collision.spatial.clear();
|
||||
for (const z of zones) collision.spatial.insert(z.id, { x: z.x, y: z.y, hw: z.hw, hh: z.hh });
|
||||
}
|
||||
/** 点测:broadphase 粗筛 → aabbVsAabb 窄相 → 取 z 最高者。 */
|
||||
function hitZone(x, y) {
|
||||
const candidates = collision.spatial.queryPoint(x, y);
|
||||
let best = null;
|
||||
for (const c of candidates) {
|
||||
const z = zones.find((zz) => zz.id === c.id);
|
||||
if (!z) continue;
|
||||
if (!aabbVsAabb({ x, y, hw: 0, hh: 0 }, { x: z.x, y: z.y, hw: z.hw, hh: z.hh }).hit) continue;
|
||||
if (!best || z.z > best.z) best = z;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function hasSave() {
|
||||
return !!save.get('profile');
|
||||
}
|
||||
|
||||
/* ── 接待缓冲重放(P4 输入缓冲:冷却期点击不丢,窗口内自动补打)── */
|
||||
/** @type {{productId:string, tMs:number}|null} */
|
||||
let pendingServe = null;
|
||||
function maybeReplayBufferedServe() {
|
||||
if (!pendingServe || core.state.phase !== 'open' || core.state.serveCooldown > 0) return;
|
||||
const within = mockNowMs - pendingServe.tMs <= 260;
|
||||
if (within && gamefeel.inputBuffer && gamefeel.inputBuffer.consume('pointerdown', mockNowMs)) {
|
||||
const pid = pendingServe.productId;
|
||||
pendingServe = null;
|
||||
const r = core.serve(pid);
|
||||
if (r.ok) measures.bufferedServeReplays += 1;
|
||||
} else if (!within) {
|
||||
pendingServe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 点击路由 ── */
|
||||
let sliderDrag = null; // {track}
|
||||
/** 滑条 x → 定价。 */
|
||||
function priceFromX(x, track) {
|
||||
const v = Math.max(0, Math.min(1, (x - track.x) / track.w));
|
||||
core.setPrice(ECON.priceMin + v * (ECON.priceMax - ECON.priceMin));
|
||||
}
|
||||
|
||||
function onTap(e) {
|
||||
measures.taps += 1;
|
||||
const tapFrame = bundle.frameCount();
|
||||
fx.addTapRing(e.x, e.y); // 当帧反馈(≤1 渲染帧可见)
|
||||
const z = hitZone(e.x, e.y);
|
||||
if (!z) {
|
||||
// 空点:极轻 tick(仍有听觉回执),不打扰。
|
||||
if (audioUnlocked) audio.sfx('tick');
|
||||
noteFeedback(tapFrame);
|
||||
return;
|
||||
}
|
||||
fx.springKick('z:' + z.id, -2.6); // 按压脉冲(render 侧按钮读同名弹簧 'z:<zoneId>')
|
||||
switch (z.kind) {
|
||||
case 'serve': {
|
||||
const pid = z.payload.productId;
|
||||
fx.springKick('slot-' + pid, -3.2);
|
||||
const r = core.serve(pid);
|
||||
if (!r.ok) {
|
||||
if (r.reason === 'cooldown') {
|
||||
pendingServe = { productId: pid, tMs: mockNowMs }; // 输入缓冲补打
|
||||
} else if (r.reason === 'no_stock') {
|
||||
audio.sfx('empty');
|
||||
const rect = shelfSlotRect(core.state.unlocked.indexOf(pid));
|
||||
fx.addFloater('缺货…', rect.x + rect.w / 2, rect.y, 'danger', 13);
|
||||
} else if (r.reason === 'no_customer') {
|
||||
audio.sfx('tick');
|
||||
const rect = shelfSlotRect(core.state.unlocked.indexOf(pid));
|
||||
fx.addFloater('没人要这个', rect.x + rect.w / 2, rect.y, 'inkSoft', 12);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stockInc': {
|
||||
const r = core.buyStock(z.payload.productId, 1);
|
||||
if (r.ok) audio.sfx('tick');
|
||||
else {
|
||||
audio.sfx('empty');
|
||||
fx.addFloater(r.reason === 'no_cash' ? '钱不够了' : '货架满了', z.x, z.y - 22, 'danger', 13);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stockDec': {
|
||||
const r = core.refundStock(z.payload.productId, 1);
|
||||
audio.sfx(r.ok ? 'tick' : 'empty');
|
||||
break;
|
||||
}
|
||||
case 'slider':
|
||||
sliderDrag = { track: z.payload.track };
|
||||
priceFromX(e.x, z.payload.track);
|
||||
audio.sfx('tick');
|
||||
break;
|
||||
case 'upgrade': {
|
||||
const r = core.buyUpgrade(z.payload.upgradeId);
|
||||
if (!r.ok && r.reason === 'no_cash') {
|
||||
audio.sfx('empty');
|
||||
fx.addFloater('钱不够了', z.x, z.y - 24, 'danger', 13);
|
||||
} else if (!r.ok && r.reason === 'requires') {
|
||||
audio.sfx('empty');
|
||||
fx.addFloater('要先买前置', z.x, z.y - 24, 'inkSoft', 12);
|
||||
} else if (!r.ok && r.reason === 'owned') {
|
||||
audio.sfx('tick');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'btn':
|
||||
audio.sfx('tick');
|
||||
handleAction(z.payload.action);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
noteFeedback(tapFrame);
|
||||
}
|
||||
|
||||
/** 反馈延迟度量:反馈在 tap 同帧注册,下一渲染帧可见 ⇒ 帧延迟 ≤1。 */
|
||||
function noteFeedback(tapFrame) {
|
||||
const delay = Math.max(bundle.frameCount() - tapFrame, 0) + 1; // +1=下一渲染帧可见的保守口径
|
||||
measures.tapFeedbackMaxFrames = Math.max(measures.tapFeedbackMaxFrames, delay);
|
||||
}
|
||||
|
||||
/** 按钮动作。 */
|
||||
function handleAction(action) {
|
||||
switch (action) {
|
||||
case 'newGame':
|
||||
core.startGame(null);
|
||||
break;
|
||||
case 'continue':
|
||||
core.startGame(save.get('profile'));
|
||||
break;
|
||||
case 'openShop':
|
||||
core.startDay();
|
||||
break;
|
||||
case 'confirmSettle':
|
||||
core.confirmSettle();
|
||||
break;
|
||||
case 'confirmSummary':
|
||||
core.confirmSummary();
|
||||
break;
|
||||
case 'restart':
|
||||
core.startGame(null);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 逻辑事件 → 表现(粒子/音效/台词/横幅/存档)── */
|
||||
/** 槽位中心坐标。 */
|
||||
function slotPos(slot) {
|
||||
const s = LAYOUT.queue[slot] || LAYOUT.queue[0];
|
||||
return { x: s.x, y: s.y };
|
||||
}
|
||||
|
||||
function applyEvents(events) {
|
||||
for (const e of events) {
|
||||
pushRecent(e);
|
||||
switch (e.type) {
|
||||
case 'customer_arrive': {
|
||||
customerBornMs[e.id] = mockNowMs;
|
||||
audio.sfx('arrive');
|
||||
break;
|
||||
}
|
||||
case 'serve_ok': {
|
||||
const from = shelfSlotRect(core.state.unlocked.indexOf(e.productId));
|
||||
const to = slotPos(e.slot);
|
||||
delete customerBornMs[e.id];
|
||||
// 满意幽灵(顾客拿到货的余韵)。
|
||||
ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: e.rescued ? '!' : '♪', until: mockNowMs + 460 });
|
||||
// 商品飞行:货架 → 顾客(非线性缓动+弧线),到达再收钱爆金光。
|
||||
fx.addFlight({
|
||||
fx: from.x + 28,
|
||||
fy: from.y + 28,
|
||||
tx: to.x,
|
||||
ty: to.y,
|
||||
dur: 0.28,
|
||||
imageKey: PRODUCT_IMAGE_KEY[e.productId],
|
||||
label: (productOf(e.productId) || { name: '?' }).name[0],
|
||||
onArrive: () => {
|
||||
fx.burstCoin(to.x, to.y, e.big);
|
||||
fx.addFloater(`+¥${fmtYuan(e.revenueJiao)}`, to.x, to.y - 30, 'gold', e.big ? 19 : 15);
|
||||
audio.sfx('coin', { combo: e.combo });
|
||||
fx.springKick('hud-cash', 1.8);
|
||||
if (e.rescued) {
|
||||
fx.burstSparkle(to.x, to.y - 10, 14);
|
||||
audio.sfx('rescue');
|
||||
}
|
||||
},
|
||||
});
|
||||
if (e.combo >= 2) {
|
||||
fx.springKick('combo', 2.4);
|
||||
if (e.combo === 3) measures.comboUiShown += 1;
|
||||
}
|
||||
if (e.big) {
|
||||
// 大额成交:hit-stop + 屏震 + 闪白(脆的打击感三件套)。
|
||||
juice.hitStop(0.09, 0.12);
|
||||
juice.shakeScreen(0.22, 5, 26);
|
||||
juice.flashScreen(0.12, 0.18);
|
||||
measures.hitStopCount += 1;
|
||||
audio.sfx('bigsale');
|
||||
ui.keeperWaveUntil = mockNowMs + 1300;
|
||||
say('big_sale', true);
|
||||
} else if (e.rescued) {
|
||||
say('rescue', true);
|
||||
} else if (e.combo >= 3 && e.combo % 3 === 0) {
|
||||
say('combo_hot');
|
||||
} else if (e.combo === 1 && core.state.today.served % 4 === 1) {
|
||||
say('serve_ok');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'angry_leave': {
|
||||
const p = slotPos(e.slot);
|
||||
delete customerBornMs[e.id];
|
||||
ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '怒', until: mockNowMs + 520 });
|
||||
fx.burstSmoke(p.x, p.y);
|
||||
fx.addFloater('差评!', p.x, p.y - 36, 'danger', 15);
|
||||
juice.shakeScreen(0.16, 3, 24);
|
||||
audio.sfx('bad');
|
||||
say('angry', true);
|
||||
break;
|
||||
}
|
||||
case 'stockout_leave': {
|
||||
const p = slotPos(e.slot);
|
||||
delete customerBornMs[e.id];
|
||||
ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '…', until: mockNowMs + 480 });
|
||||
fx.burstSmoke(p.x, p.y);
|
||||
fx.addFloater('白跑一趟…', p.x, p.y - 36, 'danger', 13);
|
||||
audio.sfx('empty');
|
||||
if (core.state.today.stockout % 3 === 1) say('stockout');
|
||||
break;
|
||||
}
|
||||
case 'customer_grace':
|
||||
audio.sfx('tick');
|
||||
break;
|
||||
case 'walkaway_full':
|
||||
fx.addFloater('店里挤不下…', 372, 420, 'inkSoft', 12);
|
||||
break;
|
||||
case 'rush_start':
|
||||
banner('高峰来了!', 'brick', 1600);
|
||||
say('rush_start', true);
|
||||
break;
|
||||
case 'rush_end':
|
||||
banner('缓过来了~', 'leaf', 1300);
|
||||
break;
|
||||
case 'closing':
|
||||
banner('打烊咯~', 'wood', 1500);
|
||||
break;
|
||||
case 'day_start': {
|
||||
say('day_start', true);
|
||||
ui.keeperWaveUntil = mockNowMs + 1200;
|
||||
// 进货上架弹道(P2 Projectile):开门瞬间把备货抛上架,落架压扁+闷响。
|
||||
let delay = 0;
|
||||
for (let i = 0; i < core.state.unlocked.length; i++) {
|
||||
const pid = core.state.unlocked[i];
|
||||
const n = Math.min(core.state.stock[pid] || 0, 3);
|
||||
const rect = shelfSlotRect(i);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const d = delay;
|
||||
fx.addTween(Math.max(d, 0.001), () => {}, {
|
||||
onDone: () =>
|
||||
fx.addToss({
|
||||
fx: -24,
|
||||
fy: 600,
|
||||
tx: rect.x + 28,
|
||||
ty: rect.y + 28,
|
||||
dur: 0.42,
|
||||
imageKey: PRODUCT_IMAGE_KEY[pid],
|
||||
onLand: () => {
|
||||
fx.springKick('slot-' + pid, -3.4);
|
||||
audio.sfx('shelve');
|
||||
},
|
||||
}),
|
||||
});
|
||||
delay += 0.07;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'day_end': {
|
||||
ui.settleAnim = 0;
|
||||
fx.addTween(1.1, (v) => {
|
||||
ui.settleAnim = v;
|
||||
});
|
||||
say(e.settle.netJiao >= 0 ? 'settle_win' : 'settle_lose', true);
|
||||
break;
|
||||
}
|
||||
case 'fined':
|
||||
audio.sfx('bad');
|
||||
break;
|
||||
case 'upgrade_bought': {
|
||||
fx.burstSparkle(VIEW_W / 2, 430, e.first ? 22 : 14);
|
||||
fx.addFloater('升级!', VIEW_W / 2, 410, 'gold', 18);
|
||||
audio.sfx('upgrade');
|
||||
ui.keeperWaveUntil = mockNowMs + 1300;
|
||||
say('upgrade', true);
|
||||
break;
|
||||
}
|
||||
case 'price_changed':
|
||||
break;
|
||||
case 'phase_changed': {
|
||||
if (e.to === 'prep') {
|
||||
ui.panelSlide = 0;
|
||||
fx.addTween(0.45, (v) => {
|
||||
ui.panelSlide = v;
|
||||
});
|
||||
if (e.from === 'title' || e.from === 'gameover') say('open_shop', true);
|
||||
}
|
||||
ui.ghosts.length = 0;
|
||||
break;
|
||||
}
|
||||
case 'gameover':
|
||||
save.remove('profile'); // 失败清档(重开新局)
|
||||
audio.sfx('bad');
|
||||
say('gameover', true);
|
||||
break;
|
||||
case 'save_point':
|
||||
save.set('profile', core.snapshotForSave());
|
||||
break;
|
||||
case 'segment_end':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 输入接线:DOM → 归一化 → 受控桥;游戏经受控面订阅 ── */
|
||||
const inputBridge = bundle.inputBridge;
|
||||
function toCanvasXY(clientX, clientY) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * VIEW_W,
|
||||
y: ((clientY - rect.top) / rect.height) * VIEW_H,
|
||||
};
|
||||
}
|
||||
canvas.addEventListener('pointerdown', (ev) => {
|
||||
unlockAudioOnce();
|
||||
const p = toCanvasXY(ev.clientX, ev.clientY);
|
||||
inputBridge._emit('pointerdown', p);
|
||||
});
|
||||
canvas.addEventListener('pointermove', (ev) => {
|
||||
const p = toCanvasXY(ev.clientX, ev.clientY);
|
||||
inputBridge._emit('pointermove', p);
|
||||
});
|
||||
canvas.addEventListener('pointerup', (ev) => {
|
||||
const p = toCanvasXY(ev.clientX, ev.clientY);
|
||||
inputBridge._emit('pointerup', p);
|
||||
});
|
||||
// 游戏侧:经受控输入面订阅(与插件同一面)。
|
||||
const input = bundle.context.getInput();
|
||||
input.on('pointerdown', (e) => onTap(e));
|
||||
input.on('pointermove', (e) => {
|
||||
if (sliderDrag) priceFromX(e.x, sliderDrag.track);
|
||||
});
|
||||
input.on('pointerup', () => {
|
||||
sliderDrag = null;
|
||||
});
|
||||
|
||||
/* ── 闲时台词节拍器 ── */
|
||||
let idleChatTimer = 7;
|
||||
|
||||
/* ── 主帧 ── */
|
||||
function frame(dtReal) {
|
||||
mockNowMs += dtReal * 1000;
|
||||
const ts = juice.getTimeScale();
|
||||
const dtWorld = dtReal * ts; // hit-stop 冻结世界(手感锚点)
|
||||
core.update(dtWorld);
|
||||
bundle.tick(dtReal); // 驱动插件 onFrame(粒子 autoStep 等,按 PLUGIN.md 约定吃真实 dt)
|
||||
applyEvents(core.drainEvents());
|
||||
maybeReplayBufferedServe();
|
||||
fx.step(dtReal, dtWorld);
|
||||
|
||||
// 高峰 → 色板/暗角/BGM 三联动。
|
||||
const rush = core.state.phase === 'open' ? core.state.rushFactor : 0;
|
||||
pal.setRush(rush);
|
||||
if (rush > 0.9) measures.rushPaletteSeen = true;
|
||||
palettePost.setPost({ vignette: { enabled: true, strength: 0.22 + rush * 0.24 } });
|
||||
audio.step(dtReal, rush, core.state.phase === 'open');
|
||||
const ap = audio.probe();
|
||||
if (ap.bgm.ready && ap.bgm.idleLevel > 0.02 && ap.bgm.rushLevel > 0.02) measures.bgmCrossfadeSeen = true;
|
||||
|
||||
// 闲时随口台词(营业中、不忙的时候偶尔来一句)。
|
||||
if (core.state.phase === 'open') {
|
||||
idleChatTimer -= dtReal;
|
||||
if (idleChatTimer <= 0) {
|
||||
idleChatTimer = 8 + (rush > 0.5 ? 4 : 0);
|
||||
if (ui.bubbleUntil < mockNowMs) say(rush > 0.5 ? 'rush_start' : 'idle_chat');
|
||||
}
|
||||
}
|
||||
// 幽灵过期清理。
|
||||
for (let i = ui.ghosts.length - 1; i >= 0; i--) if (ui.ghosts[i].until <= mockNowMs) ui.ghosts.splice(i, 1);
|
||||
|
||||
rebuildZones();
|
||||
drawFrame(g, {
|
||||
core,
|
||||
pal,
|
||||
assets,
|
||||
fx,
|
||||
ui,
|
||||
nowMs: mockNowMs,
|
||||
customerBornMs,
|
||||
hasSave: hasSave(),
|
||||
});
|
||||
palettePost.renderPost(g); // 暗角收口(绘制末尾叠加)
|
||||
measures.frames += 1;
|
||||
refreshForensics();
|
||||
}
|
||||
|
||||
/** 确定性推进 n 帧(evidence 驱动 / 启动首帧)。 */
|
||||
function stepFrames(n) {
|
||||
for (let i = 0; i < n; i++) frame(FIXED_DT);
|
||||
}
|
||||
|
||||
/* ── RAF(play 模式)── */
|
||||
let rafId = 0;
|
||||
let rafRunning = false;
|
||||
let lastTs = 0;
|
||||
function rafLoop(ts) {
|
||||
if (!rafRunning) return;
|
||||
if (lastTs === 0) lastTs = ts;
|
||||
let dt = (ts - lastTs) / 1000;
|
||||
lastTs = ts;
|
||||
if (dt > 0.05) dt = 0.05; // 钳大跳帧
|
||||
frame(dt);
|
||||
rafId = window.requestAnimationFrame(rafLoop);
|
||||
}
|
||||
function startRaf() {
|
||||
if (rafRunning) return;
|
||||
rafRunning = true;
|
||||
lastTs = 0;
|
||||
rafId = window.requestAnimationFrame(rafLoop);
|
||||
}
|
||||
function stopRaf() {
|
||||
rafRunning = false;
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
|
||||
/* ── 取证导出 ── */
|
||||
const forensics = {
|
||||
protocol: CORE_PROTOCOL_VERSION,
|
||||
seed,
|
||||
mode,
|
||||
/** 可交互几何(当前帧;与视觉同源)。 */
|
||||
geometry: () => zones.map((z) => ({ id: z.id, kind: z.kind, x: z.x, y: z.y, hw: z.hw, hh: z.hh, z: z.z })),
|
||||
/** 探针六锚链导出。 */
|
||||
probe: () => ({
|
||||
jsonl: probe.toJSONL(),
|
||||
verify: probe.verify(),
|
||||
anchors: probe.getRecords().map((r) => r.anchor),
|
||||
uncaught: uncaught.slice(),
|
||||
initOk: initRes.ok,
|
||||
registeredPlugins: pluginSet,
|
||||
}),
|
||||
/** 闭环/失败态/定价证明(boot 期纯逻辑跑出)。 */
|
||||
loopProof,
|
||||
failureProof,
|
||||
priceProof,
|
||||
/** 游戏状态快照(JSON 安全)。 */
|
||||
state: () => JSON.parse(JSON.stringify({
|
||||
phase: core.state.phase,
|
||||
day: core.state.day,
|
||||
cash: core.state.cash,
|
||||
reputation: core.reputation(),
|
||||
priceMul: core.state.priceMul,
|
||||
stock: core.state.stock,
|
||||
unlocked: core.state.unlocked,
|
||||
upgrades: core.state.upgrades,
|
||||
customers: core.state.customers.length,
|
||||
combo: core.state.comboCount,
|
||||
today: core.state.today,
|
||||
lifetime: core.state.lifetime,
|
||||
gameoverReason: core.state.gameoverReason,
|
||||
})),
|
||||
/** 最近逻辑事件(环形 240 条)。 */
|
||||
recentEvents: () => recentEvents.slice(),
|
||||
/** 实时度量。 */
|
||||
measures: () => Object.assign({ particle: fx.particleProbe(), audio: audio.probe(), assetsMissing: assets.missing }, measures),
|
||||
};
|
||||
|
||||
/** checklist(LOOP-LOG 五要素表自动子项 → 布尔+数值,逐帧刷新)。 */
|
||||
const checklist = {};
|
||||
function refreshForensics() {
|
||||
const ap = audio.probe();
|
||||
checklist.shougan = {
|
||||
tapFeedbackMaxFrames: measures.tapFeedbackMaxFrames, // 目标 ≤3
|
||||
tapFeedbackWithin3: measures.taps === 0 || measures.tapFeedbackMaxFrames <= 3,
|
||||
everyTapScalePulse: true, // 构造保证:onTap 必加涟漪+弹簧(见 onTap)
|
||||
comboUiShown: measures.comboUiShown > 0 || core.state.today.maxCombo >= 2,
|
||||
flightEasingUsed: true, // serve_ok 飞行走 easing.quadOut+弧线(构造保证)
|
||||
hitStopTriggered: measures.hitStopCount > 0,
|
||||
bufferedServeReplays: measures.bufferedServeReplays,
|
||||
};
|
||||
checklist.meishu = {
|
||||
allDrawsViaPalette: true, // 构造保证:render.js 一切取色经 pal.c()
|
||||
vignetteEnabled: palettePost.getPost().vignette.enabled,
|
||||
rushPaletteSeen: measures.rushPaletteSeen,
|
||||
assetsLoaded: `${assets.loaded}/${assets.total}`,
|
||||
noPrimaryPlaceholderRects: true, // 构造保证:回退块也走调色(amber/sky 族)
|
||||
};
|
||||
checklist.yinyue = {
|
||||
audioUnlocked,
|
||||
bgmReady: ap.bgm.ready,
|
||||
bgmCrossfadeSeen: measures.bgmCrossfadeSeen,
|
||||
sfxKindCount: ap.sfxKinds.length, // 目标 ≥5
|
||||
sfxKinds: ap.sfxKinds,
|
||||
};
|
||||
checklist.shendu = {
|
||||
loopReachable: loopProof.ok,
|
||||
loopOps: loopProof.opCount,
|
||||
failureReachable: failureProof.ok,
|
||||
failureReason: failureProof.reason,
|
||||
priceTwoSidedLoss: priceProof.twoSidedLoss,
|
||||
upgradeTreeSize: 8,
|
||||
firstUpgradeDay: loopProof.day,
|
||||
};
|
||||
checklist.juese = {
|
||||
keeperSpriteResident: true, // 构造保证:drawKeeper 每帧绘制
|
||||
lineGroups: Object.keys(LINES).length,
|
||||
lineTotal: Object.values(LINES).reduce((s, a) => s + a.length, 0),
|
||||
linesShownLive: measures.linesShown,
|
||||
};
|
||||
}
|
||||
refreshForensics();
|
||||
window.__refForensics = forensics;
|
||||
window.__refChecklist = checklist;
|
||||
|
||||
/* ── 首帧 + 输入锚 + 启动 ── */
|
||||
stepFrames(1);
|
||||
mark('t_first_paint', { frame: bundle.frameCount() });
|
||||
try {
|
||||
probe.init(probeCtx); // 绑输入 → t_input_bound(恰在 first_paint 之后,偏序成立)
|
||||
} catch (e) {
|
||||
uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) });
|
||||
}
|
||||
mark('t_game_start', { mode });
|
||||
|
||||
const refHost = {
|
||||
seed,
|
||||
mode,
|
||||
protocol: CORE_PROTOCOL_VERSION,
|
||||
frameCount: () => bundle.frameCount(),
|
||||
stepFrames,
|
||||
startRaf,
|
||||
stopRaf,
|
||||
/** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */
|
||||
tap(x, y) {
|
||||
unlockAudioOnce();
|
||||
inputBridge._emit('pointerdown', { x, y });
|
||||
inputBridge._emit('pointerup', { x, y });
|
||||
},
|
||||
/** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API)。 */
|
||||
do(action) {
|
||||
handleAction(action);
|
||||
},
|
||||
state: forensics.state,
|
||||
forensics,
|
||||
};
|
||||
window.__refHost = refHost;
|
||||
|
||||
if (mode === 'evidence') {
|
||||
// evidence:不起 RAF,画面完全由 __refHost.stepFrames/tap 接管(同参数重跑一致)。
|
||||
setStatus(`evidence 就绪 seed=${seed} · 用 window.__refHost 驱动`);
|
||||
} else {
|
||||
setStatus(`seed=${seed} · 点「开店!」开始(首次点击开声)`);
|
||||
startRaf();
|
||||
}
|
||||
return refHost;
|
||||
}
|
||||
106
game-runtime/games/wanglanmei-ref/src/palette.js
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* palette.js — 全局暖调色板 + P5 调色引擎接线(宿主层;色板数据=游戏生成域,引擎=P5 插件)
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:render.js(一切绘制取色必经本模块)/ main.js(vignette 强度)
|
||||
*
|
||||
* 【美术统一性之根】
|
||||
* 1. 全部 mmx 资产共用同一组风格词(见 assets/manifest.json styleWords);
|
||||
* 2. 全部程序化绘制(UI/粒子/文字)只准经本模块取色——色名表唯一,杜绝原色矩形;
|
||||
* 3. 高峰压力态 = 同一色名表经 P5 lerpPaletteMap 在「平静→压力」两份目标色板间过渡
|
||||
* (压力色板由 shiftHsl 从基色程序化派生:色相偏暖红、饱和升、明度降),
|
||||
* 配 vignette 收紧(main 调 setPost)——画面情绪随玩法起伏而非换贴图。
|
||||
*
|
||||
* 【P5 红线遵守】P5 只供引擎(映射/插值/HSL),成品色板(下面的 BASE)是本游戏的数据。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { lerpPaletteMap, applyPaletteMap, shiftHsl } from '../../../src/plugins/palette-post/impl.js';
|
||||
|
||||
/**
|
||||
* 基础色名表(唯一事实源)。一切绘制色都从这里出(warm 市井暖调家族)。
|
||||
* @type {Record<string, {r:number,g:number,b:number}>}
|
||||
*/
|
||||
export const BASE = {
|
||||
paper: { r: 246, g: 231, b: 201 }, // 米纸底
|
||||
paperHi: { r: 255, g: 246, b: 227 }, // 高亮纸
|
||||
cream: { r: 255, g: 243, b: 220 }, // 奶油(粒子亮色)
|
||||
ink: { r: 91, g: 70, b: 54 }, // 主墨色(文字/描边)
|
||||
inkSoft: { r: 138, g: 108, b: 84 }, // 次级墨色
|
||||
wood: { r: 169, g: 118, b: 79 }, // 木质(柜台/货架)
|
||||
woodDark: { r: 124, g: 84, b: 55 }, // 深木
|
||||
gold: { r: 232, g: 169, b: 66 }, // 金(钱/强调)
|
||||
coin: { r: 246, g: 196, b: 83 }, // 金币粒子
|
||||
amber: { r: 240, g: 196, b: 117 }, // 琥珀(进度条)
|
||||
brick: { r: 201, g: 111, b: 74 }, // 砖橙(警示暖红)
|
||||
danger: { r: 178, g: 72, b: 54 }, // 差评红(仍在暖族)
|
||||
leaf: { r: 133, g: 158, b: 94 }, // 暖叶绿(成功/盈利)
|
||||
sky: { r: 243, g: 217, b: 168 }, // 暖天光
|
||||
shadow: { r: 62, g: 49, b: 40 }, // 阴影
|
||||
scrim: { r: 46, g: 35, b: 27 }, // 蒙层
|
||||
smoke: { r: 140, g: 106, b: 85 }, // 烟尘(差评粒子,暖棕)
|
||||
};
|
||||
|
||||
/** 色名列表(固定顺序,构造映射表用)。 */
|
||||
const NAMES = Object.keys(BASE);
|
||||
|
||||
/**
|
||||
* 创建调色器:维护「平静→压力」过渡态,输出 css 颜色字符串(带量化缓存,避免逐帧逐色重算)。
|
||||
* @returns {{ setRush:(t:number)=>void, rushT:()=>number, c:(name:string, alpha?:number)=>string, rgb:(name:string)=>{r:number,g:number,b:number} }}
|
||||
*/
|
||||
export function createPalette() {
|
||||
const from = NAMES.map((n) => BASE[n]);
|
||||
// 平静色板:恒等映射(from→from)。
|
||||
const calmMap = { from, to: from.slice() };
|
||||
// 压力色板:每个基色经 HSL 偏移派生(暖红化 + 微降明度 + 升饱和)——P5 引擎做派生,数据归游戏。
|
||||
const rushMap = {
|
||||
from,
|
||||
to: from.map((c) => shiftHsl(c, { hue: -10, sat: 1.18, light: 0.9 })),
|
||||
};
|
||||
|
||||
/** 当前压力系数(量化到 1/25 步,缓存键)。 */
|
||||
let quantT = 0;
|
||||
/** @type {Record<string, {r:number,g:number,b:number}>} 当前色名→RGB 解析缓存。 */
|
||||
let resolved = buildResolved(0);
|
||||
|
||||
/** 重建解析表。@param {number} t */
|
||||
function buildResolved(t) {
|
||||
const map = lerpPaletteMap(calmMap, rushMap, t);
|
||||
/** @type {Record<string, {r:number,g:number,b:number}>} */
|
||||
const out = {};
|
||||
for (const n of NAMES) out[n] = applyPaletteMap(map, BASE[n]);
|
||||
return out;
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 设定高峰压力系数(0=平静 1=高峰)。量化到 0.04 步进,变化才重建缓存。
|
||||
* @param {number} t
|
||||
*/
|
||||
setRush(t) {
|
||||
const q = Math.round(Math.max(0, Math.min(1, t)) * 25) / 25;
|
||||
if (q !== quantT) {
|
||||
quantT = q;
|
||||
resolved = buildResolved(q);
|
||||
}
|
||||
},
|
||||
/** 当前压力系数。 */
|
||||
rushT() {
|
||||
return quantT;
|
||||
},
|
||||
/**
|
||||
* 取色(css 字符串)。一切绘制必经此函数(美术统一性 checklist 的实现锚点)。
|
||||
* @param {string} name 色名(BASE 键)
|
||||
* @param {number} [alpha] 透明度(缺省 1)
|
||||
* @returns {string}
|
||||
*/
|
||||
c(name, alpha) {
|
||||
const v = resolved[name] || BASE.ink;
|
||||
const a = alpha == null ? 1 : alpha;
|
||||
return a >= 1 ? `rgb(${v.r},${v.g},${v.b})` : `rgba(${v.r},${v.g},${v.b},${a})`;
|
||||
},
|
||||
/** 取当前 RGB 对象(粒子层 fillStyle 等需要原始分量时用)。@param {string} name */
|
||||
rgb(name) {
|
||||
return resolved[name] || BASE.ink;
|
||||
},
|
||||
};
|
||||
}
|
||||
1008
game-runtime/games/wanglanmei-ref/src/render.js
Normal file
673
game-runtime/games/wanglanmei-ref/src/shop-core.js
Normal file
@ -0,0 +1,673 @@
|
||||
/**
|
||||
* shop-core.js — 小卖部经营 · 营业日状态机(纯逻辑,零 DOM,时间/随机全走注入受控源)
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:main.js(宿主接线)/ loop-proof.js / test
|
||||
*
|
||||
* 【纯逻辑铁律(红线 2)】
|
||||
* - 本模块不触 DOM / canvas / AudioContext / Date.now / performance.now / Math.random;
|
||||
* - 时间 = 调用方逐帧喂 update(dtSeconds),内部积累 simClockMs(受控仿真时钟);
|
||||
* - 随机 = 注入的 RandomSource(core-protocol-v0 形状:next/range/reseed);
|
||||
* - 连击 = gamefeel 插件公开 API 的 ComboWindow(公开 API 面,喂受控 TimeSource)。
|
||||
*
|
||||
* 【状态机】
|
||||
* title → prep ⇄ open → settle → (prep | summary → prep | gameover)
|
||||
* - prep:进货(资金占用 vs 缺货流失)+ 定价(高价↑利润↓客流)+ 买升级 —— 二阶决策都在这里;
|
||||
* - open:顾客到店排队(限时耐心)→ 点击货架商品接待 → 收钱(连击小费);
|
||||
* 耐心耗尽→愤怒离店掉声誉(含 0.55s 救单宽限);缺货→失望离店掉声誉;
|
||||
* - settle:房租扣除;声誉 < 30 → 罚没;声誉 ≤ 0 或资金 < 0 → 失败终态(真实可达)。
|
||||
*
|
||||
* 【与宿主的接口】
|
||||
* 命令式 API(serve/buyStock/...,全部返回 {ok, reason?})+ 事件队列 drainEvents()
|
||||
* (宿主据事件驱动粒子/音效/台词/存档),渲染层只读 state(直接引用,单线程安全)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { ComboWindow } from '../../../src/plugins/gamefeel/impl.js';
|
||||
import {
|
||||
PRODUCTS,
|
||||
productOf,
|
||||
DAY,
|
||||
rushFactorAt,
|
||||
FLOW,
|
||||
flowMulOfPrice,
|
||||
flowMulOfReputation,
|
||||
PATIENCE,
|
||||
REP,
|
||||
ECON,
|
||||
priceJiaoOf,
|
||||
comboTipMul,
|
||||
UPGRADES,
|
||||
upgradeOf,
|
||||
derivedOfUpgrades,
|
||||
clamp,
|
||||
} from './balance.js';
|
||||
|
||||
/** 存档结构版本号(restoreFromSave 校验,不认旧版即拒绝并回退新开局)。 */
|
||||
export const SAVE_VERSION = 1;
|
||||
|
||||
/** 连击窗口(毫秒):相邻两次接待 ≤2600ms 算连击。 */
|
||||
const COMBO_WINDOW_MS = 2600;
|
||||
|
||||
/**
|
||||
* 创建一局小卖部经营状态机。
|
||||
* @param {Object} deps
|
||||
* @param {{ next:()=>number, range:(a:number,b:number)=>number, reseed:(s:number)=>void }} deps.random
|
||||
* 受控随机源(core-protocol-v0 RandomSource 形状;宿主传派生流,测试传 SeededRandom)。
|
||||
* @returns {ShopCore}
|
||||
*/
|
||||
export function createShopCore(deps) {
|
||||
const rng = deps.random;
|
||||
if (!rng || typeof rng.next !== 'function') {
|
||||
throw new Error('[shop-core] 必须注入受控随机源 random(next/range/reseed)');
|
||||
}
|
||||
|
||||
/* ── 受控仿真时钟:update 累计;ComboWindow 经 TimeSource 视图消费同一时钟 ── */
|
||||
let simClockMs = 0;
|
||||
/** @type {{ nowMs:()=>number, elapsedMs:()=>number }} 受控 TimeSource 视图(喂给 gamefeel)。 */
|
||||
const timeSource = {
|
||||
nowMs: () => simClockMs,
|
||||
elapsedMs: () => simClockMs,
|
||||
};
|
||||
// 连击计时窗(gamefeel 公开 API):封顶 99。
|
||||
const combo = new ComboWindow(timeSource, { windowMs: COMBO_WINDOW_MS, maxCount: 99 });
|
||||
|
||||
/* ── 事件队列(宿主逐帧 drain)── */
|
||||
/** @type {Array<{type:string}&Record<string,any>>} */
|
||||
let events = [];
|
||||
/** 入队一条事件(附仿真时刻,便于取证)。@param {string} type @param {Object} [payload] */
|
||||
function emit(type, payload) {
|
||||
events.push(Object.assign({ type, tMs: Math.round(simClockMs) }, payload || {}));
|
||||
}
|
||||
|
||||
/* ── 顶层状态(渲染层只读引用)── */
|
||||
const state = {
|
||||
phase: 'title', // 'title'|'prep'|'open'|'settle'|'summary'|'gameover'
|
||||
day: 1, // 当前营业日(1 起)
|
||||
cash: ECON.startCashJiao, // 资金(角)
|
||||
repTenth: REP.start * 10, // 声誉 ×10 整数(避免浮点漂移;展示 ÷10)
|
||||
priceMul: 1.0, // 定价乘数
|
||||
/** @type {Record<string, number>} 各商品库存(件)。 */
|
||||
stock: {},
|
||||
/** @type {string[]} 已解锁商品 id(顺序即货架顺序)。 */
|
||||
unlocked: PRODUCTS.filter((p) => p.initial).map((p) => p.id),
|
||||
/** @type {Record<string, boolean>} 已购升级集合。 */
|
||||
upgrades: {},
|
||||
/** @type {Customer[]} 在场顾客。 */
|
||||
customers: [],
|
||||
dayTimeSec: 0, // 营业日内时刻(秒)
|
||||
rushFactor: 0, // 高峰系数 0..1(色板/BGM 挂点)
|
||||
serveCooldown: 0, // 收银冷却(秒)
|
||||
comboCount: 0, // 当前连击(渲染缓存,每帧从 ComboWindow 读)
|
||||
/** @type {Record<string, number>} 本次 prep 已购件数(可退)。 */
|
||||
prepBought: {},
|
||||
/** 当日统计(settle 报表与罚款依据)。 */
|
||||
today: freshToday(),
|
||||
/** 生涯统计。 */
|
||||
lifetime: { days: 0, served: 0, angry: 0, netJiao: 0, upgradesBought: 0 },
|
||||
/** @type {object|null} 日结报表(phase=settle 时有效)。 */
|
||||
settle: null,
|
||||
/** @type {object|null} 段落小结(phase=summary 时有效)。 */
|
||||
summary: null,
|
||||
/** @type {string|null} 失败原因:'reputation'|'bankrupt'。 */
|
||||
gameoverReason: null,
|
||||
};
|
||||
|
||||
/** @returns {object} 全新当日统计。 */
|
||||
function freshToday() {
|
||||
return { revenueJiao: 0, soldCostJiao: 0, served: 0, angry: 0, stockout: 0, walkaway: 0, rescued: 0, bigSales: 0, maxCombo: 0 };
|
||||
}
|
||||
|
||||
/* ── 顾客 ──
|
||||
* @typedef {Object} Customer
|
||||
* @property {number} id 自增 id
|
||||
* @property {string} productId 想要的商品
|
||||
* @property {number} slot 队列槽位(0..queueSlots-1)
|
||||
* @property {'queue'|'leaving'|'stockout'} mood
|
||||
* queue=正常排队;leaving=耐心耗尽进入救单宽限;stockout=发现缺货停留片刻后离店
|
||||
* @property {number} patienceSec 剩余耐心(秒)
|
||||
* @property {number} patienceMaxSec 总耐心
|
||||
* @property {number} timerSec mood 内倒计时(leaving 宽限 / stockout 停留)
|
||||
* @property {number} avatar 头像编号(0..3,渲染用)
|
||||
* @property {boolean} impatientFired 是否已发过「不耐烦」事件
|
||||
*/
|
||||
let customerIdSeq = 1;
|
||||
/** 下一位顾客的到店倒计时(秒)。 */
|
||||
let spawnTimer = 0;
|
||||
/** 上一帧的高峰布尔(rush_start/rush_end 边沿触发用)。 */
|
||||
let wasRush = false;
|
||||
|
||||
/** 升级派生参数缓存(升级变化时重算)。 */
|
||||
let derived = derivedOfUpgrades(state.upgrades);
|
||||
function refreshDerived() {
|
||||
derived = derivedOfUpgrades(state.upgrades);
|
||||
}
|
||||
|
||||
/** 当前库存总件数。 */
|
||||
function totalStock() {
|
||||
let n = 0;
|
||||
for (const id of Object.keys(state.stock)) n += state.stock[id];
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 声誉读数(0..100,1 位小数语义)。 */
|
||||
function reputation() {
|
||||
return state.repTenth / 10;
|
||||
}
|
||||
|
||||
/** 调声誉(×10 整数,钳制 [0, max])。@param {number} deltaTenth */
|
||||
function bumpRep(deltaTenth) {
|
||||
state.repTenth = clamp(state.repTenth + deltaTenth, 0, REP.max * 10);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* 阶段切换
|
||||
* ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 切阶段并广播事件。@param {string} to */
|
||||
function setPhase(to) {
|
||||
const from = state.phase;
|
||||
state.phase = to;
|
||||
emit('phase_changed', { from, to });
|
||||
}
|
||||
|
||||
/** 进入 prep(开市准备)。@param {number} day */
|
||||
function enterPrep(day) {
|
||||
state.day = day;
|
||||
state.prepBought = {};
|
||||
state.today = freshToday();
|
||||
state.customers = [];
|
||||
state.dayTimeSec = 0;
|
||||
state.rushFactor = 0;
|
||||
state.settle = null;
|
||||
state.summary = null;
|
||||
setPhase('prep');
|
||||
// 存档点:每个 prep 开头(进度=「第 day 天开市前」)。
|
||||
emit('save_point', {});
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* open 阶段:到店 / 耐心 / 接待
|
||||
* ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 重置到店倒计时(按当前定价/声誉/装修/时段计算 + 抖动)。 */
|
||||
function rollSpawnTimer() {
|
||||
const base = state.rushFactor > 0.5 ? FLOW.rushIntervalSec : FLOW.idleIntervalSec;
|
||||
const mul = flowMulOfPrice(state.priceMul) * flowMulOfReputation(reputation()) * derived.flowMul;
|
||||
spawnTimer = (base / Math.max(mul, 0.05)) * rng.range(FLOW.jitterMin, FLOW.jitterMax);
|
||||
}
|
||||
|
||||
/** 顾客点单:按 popularity 权重在已解锁商品中抽样(不看库存——顾客进店前不知道)。 */
|
||||
function pickProduct() {
|
||||
const defs = state.unlocked.map((id) => productOf(id));
|
||||
const wSum = defs.reduce((s, p) => s + p.popularity, 0);
|
||||
let roll = rng.next() * wSum;
|
||||
for (const p of defs) {
|
||||
roll -= p.popularity;
|
||||
if (roll <= 0) return p.id;
|
||||
}
|
||||
return defs[defs.length - 1].id;
|
||||
}
|
||||
|
||||
/** 找一个空队列槽(0..n-1 的最小空位;无空位返回 -1)。 */
|
||||
function freeSlot() {
|
||||
for (let s = 0; s < FLOW.queueSlots; s++) {
|
||||
if (!state.customers.some((c) => c.slot === s)) return s;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 生成一位顾客(队满→流失事件)。 */
|
||||
function spawnCustomer() {
|
||||
const productId = pickProduct();
|
||||
const slot = freeSlot();
|
||||
if (slot < 0) {
|
||||
// 队满进不来:只记流失,不掉声誉(高峰压力的温和面)。
|
||||
state.today.walkaway += 1;
|
||||
emit('walkaway_full', { productId });
|
||||
return;
|
||||
}
|
||||
const baseSec = state.rushFactor > 0.5 ? PATIENCE.rushSec : PATIENCE.idleSec;
|
||||
const patienceMaxSec = baseSec * derived.patienceMul * rng.range(0.9, 1.12);
|
||||
/** @type {Customer} */
|
||||
const c = {
|
||||
id: customerIdSeq++,
|
||||
productId,
|
||||
slot,
|
||||
mood: 'queue',
|
||||
patienceSec: patienceMaxSec,
|
||||
patienceMaxSec,
|
||||
timerSec: 0,
|
||||
avatar: Math.floor(rng.next() * 4) % 4,
|
||||
impatientFired: false,
|
||||
};
|
||||
// 进店即发现缺货 → 看一眼就失望(缺货流失的「快反馈」路径)。
|
||||
if ((state.stock[productId] || 0) <= 0) {
|
||||
c.mood = 'stockout';
|
||||
c.timerSec = PATIENCE.stockoutLingerSec;
|
||||
}
|
||||
state.customers.push(c);
|
||||
emit('customer_arrive', { id: c.id, slot, productId, mood: c.mood });
|
||||
}
|
||||
|
||||
/** 移除顾客。@param {Customer} c */
|
||||
function removeCustomer(c) {
|
||||
const i = state.customers.indexOf(c);
|
||||
if (i >= 0) state.customers.splice(i, 1);
|
||||
}
|
||||
|
||||
/** 推进 open 阶段一帧。@param {number} dt 秒 */
|
||||
function updateOpen(dt) {
|
||||
state.dayTimeSec += dt;
|
||||
state.rushFactor = rushFactorAt(state.dayTimeSec);
|
||||
|
||||
// 高峰进出边沿事件(BGM crossfade / 压力色板挂点)。
|
||||
const isRush = state.rushFactor > 0.5;
|
||||
if (isRush !== wasRush) {
|
||||
emit(isRush ? 'rush_start' : 'rush_end', {});
|
||||
wasRush = isRush;
|
||||
}
|
||||
|
||||
// 收银冷却。
|
||||
if (state.serveCooldown > 0) state.serveCooldown = Math.max(0, state.serveCooldown - dt);
|
||||
|
||||
// 打烊:到时不再进客,场内顾客温和散场(不掉声誉),转日结。
|
||||
if (state.dayTimeSec >= DAY.lengthSec) {
|
||||
emit('closing', {});
|
||||
state.customers = [];
|
||||
doSettle();
|
||||
return;
|
||||
}
|
||||
|
||||
// 到店节拍。
|
||||
spawnTimer -= dt;
|
||||
if (spawnTimer <= 0) {
|
||||
spawnCustomer();
|
||||
rollSpawnTimer();
|
||||
}
|
||||
|
||||
// 顾客耐心 / 状态推进(倒序遍历,便于移除)。
|
||||
for (let i = state.customers.length - 1; i >= 0; i--) {
|
||||
const c = state.customers[i];
|
||||
if (c.mood === 'queue') {
|
||||
// 排队中商品被买空 → 转缺货停留(看了一眼货架)。
|
||||
if ((state.stock[c.productId] || 0) <= 0) {
|
||||
c.mood = 'stockout';
|
||||
c.timerSec = PATIENCE.stockoutLingerSec;
|
||||
continue;
|
||||
}
|
||||
c.patienceSec -= dt;
|
||||
// 「不耐烦」一次性事件(气泡/滴答音效挂点)。
|
||||
if (!c.impatientFired && c.patienceSec / c.patienceMaxSec <= PATIENCE.impatientRatio) {
|
||||
c.impatientFired = true;
|
||||
emit('customer_impatient', { id: c.id, slot: c.slot });
|
||||
}
|
||||
// 耐心耗尽 → 救单宽限(coyote 语义:失效后仍留一小窗可接住)。
|
||||
if (c.patienceSec <= 0) {
|
||||
c.mood = 'leaving';
|
||||
c.timerSec = PATIENCE.graceSec;
|
||||
emit('customer_grace', { id: c.id, slot: c.slot });
|
||||
}
|
||||
} else if (c.mood === 'leaving') {
|
||||
c.timerSec -= dt;
|
||||
if (c.timerSec <= 0) {
|
||||
// 愤怒离店:掉声誉(失败态的日常来源)。
|
||||
removeCustomer(c);
|
||||
state.today.angry += 1;
|
||||
bumpRep(-REP.angryLossTenth);
|
||||
emit('angry_leave', { id: c.id, slot: c.slot, avatar: c.avatar, productId: c.productId, repLossTenth: REP.angryLossTenth });
|
||||
}
|
||||
} else if (c.mood === 'stockout') {
|
||||
c.timerSec -= dt;
|
||||
if (c.timerSec <= 0) {
|
||||
// 缺货失望离店:掉声誉(少于愤怒,但备货不足会持续放血)。
|
||||
removeCustomer(c);
|
||||
state.today.stockout += 1;
|
||||
bumpRep(-REP.stockoutLossTenth);
|
||||
emit('stockout_leave', { id: c.id, slot: c.slot, avatar: c.avatar, productId: c.productId, repLossTenth: REP.stockoutLossTenth });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 连击展示缓存(断连归 0 由 ComboWindow 判定)。
|
||||
state.comboCount = combo.getCount(simClockMs);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* 日结 / 段落 / 失败
|
||||
* ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 执行日结:算账(房租/罚款)→ 声誉回暖 → 失败判定 → settle 报表。 */
|
||||
function doSettle() {
|
||||
const t = state.today;
|
||||
const segment = Math.ceil(state.day / DAY.daysPerSegment);
|
||||
const rentJiao = ECON.rentBaseJiao + (segment - 1) * ECON.rentPerSegmentJiao;
|
||||
|
||||
// 罚没:声誉 < 阈值 → 罚款 = 基数 + 每差评加罚(差评赔偿)。
|
||||
let fineJiao = 0;
|
||||
if (reputation() < REP.fineThreshold) {
|
||||
fineJiao = ECON.fineBaseJiao + t.angry * ECON.finePerAngryJiao;
|
||||
emit('fined', { fineJiao });
|
||||
}
|
||||
|
||||
// 声誉日结回暖:全天零差评 +1.5;装修加成 +3/+6。
|
||||
let repBackTenth = derived.settleRepBonusTenth;
|
||||
if (t.angry === 0) repBackTenth += REP.noAngryBonusTenth;
|
||||
bumpRep(repBackTenth);
|
||||
|
||||
state.cash -= rentJiao + fineJiao;
|
||||
const netJiao = t.revenueJiao - t.soldCostJiao - rentJiao - fineJiao;
|
||||
state.lifetime.days += 1;
|
||||
state.lifetime.netJiao += netJiao;
|
||||
|
||||
state.settle = {
|
||||
day: state.day,
|
||||
revenueJiao: t.revenueJiao,
|
||||
soldCostJiao: t.soldCostJiao,
|
||||
rentJiao,
|
||||
fineJiao,
|
||||
netJiao,
|
||||
served: t.served,
|
||||
angry: t.angry,
|
||||
stockout: t.stockout,
|
||||
walkaway: t.walkaway,
|
||||
rescued: t.rescued,
|
||||
maxCombo: t.maxCombo,
|
||||
repBackTenth,
|
||||
reputation: reputation(),
|
||||
};
|
||||
emit('day_end', { settle: state.settle });
|
||||
|
||||
// 失败终态判定(真实可达,非只胜不败)。
|
||||
if (reputation() <= 0) {
|
||||
state.gameoverReason = 'reputation';
|
||||
setPhase('gameover');
|
||||
emit('gameover', { reason: 'reputation' });
|
||||
return;
|
||||
}
|
||||
if (state.cash < 0) {
|
||||
state.gameoverReason = 'bankrupt';
|
||||
setPhase('gameover');
|
||||
emit('gameover', { reason: 'bankrupt' });
|
||||
return;
|
||||
}
|
||||
setPhase('settle');
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* 公开命令 API(全部 {ok, reason?})
|
||||
* ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const core = {
|
||||
/** 渲染层只读状态(直接引用;单线程逐帧消费,不做防御拷贝换性能)。 */
|
||||
state,
|
||||
/** 受控 TimeSource 视图(宿主可复用,如喂浮动文字时间轴)。 */
|
||||
timeSource,
|
||||
|
||||
/**
|
||||
* 推进仿真一帧。宿主喂「已应用 hit-stop 缩放的世界 dt」。
|
||||
* @param {number} dtSeconds 帧时间步(秒,≥0)
|
||||
*/
|
||||
update(dtSeconds) {
|
||||
const dt = Math.max(0, Math.min(dtSeconds, 0.1)); // 钳制大跳帧,防卡顿后逻辑雪崩
|
||||
simClockMs += dt * 1000;
|
||||
if (state.phase === 'open') updateOpen(dt);
|
||||
},
|
||||
|
||||
/** 取走积压事件(宿主逐帧调;返回后内部清空)。 */
|
||||
drainEvents() {
|
||||
const out = events;
|
||||
events = [];
|
||||
return out;
|
||||
},
|
||||
|
||||
/**
|
||||
* 开始新游戏(title/gameover → prep 第 1 天)。
|
||||
* @param {object|null} [save] 传入合法存档则续档(恢复到存档日的 prep)。
|
||||
*/
|
||||
startGame(save) {
|
||||
if (save && core.restoreFromSave(save)) return { ok: true, continued: true };
|
||||
// 全新开局:恢复默认。
|
||||
state.cash = ECON.startCashJiao;
|
||||
state.repTenth = REP.start * 10;
|
||||
state.priceMul = 1.0;
|
||||
state.stock = {};
|
||||
state.unlocked = PRODUCTS.filter((p) => p.initial).map((p) => p.id);
|
||||
state.upgrades = {};
|
||||
state.lifetime = { days: 0, served: 0, angry: 0, netJiao: 0, upgradesBought: 0 };
|
||||
state.gameoverReason = null;
|
||||
refreshDerived();
|
||||
combo.reset();
|
||||
emit('game_start', { continued: false });
|
||||
enterPrep(1);
|
||||
return { ok: true, continued: false };
|
||||
},
|
||||
|
||||
/**
|
||||
* 进货(prep 限定)。资金占用是真实代价:买多压资金,买少缺货流失。
|
||||
* @param {string} productId @param {number} [n] 件数(缺省 1)
|
||||
*/
|
||||
buyStock(productId, n) {
|
||||
const count = Math.max(1, Math.floor(n || 1));
|
||||
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
|
||||
const p = productOf(productId);
|
||||
if (!p || !state.unlocked.includes(productId)) return { ok: false, reason: 'unknown_product' };
|
||||
if (totalStock() + count > derived.shelfCap) return { ok: false, reason: 'shelf_full' };
|
||||
const costJiao = p.wholesale * count;
|
||||
if (state.cash < costJiao) return { ok: false, reason: 'no_cash' };
|
||||
state.cash -= costJiao;
|
||||
state.stock[productId] = (state.stock[productId] || 0) + count;
|
||||
state.prepBought[productId] = (state.prepBought[productId] || 0) + count;
|
||||
emit('stock_bought', { productId, n: count, costJiao });
|
||||
return { ok: true, costJiao };
|
||||
},
|
||||
|
||||
/**
|
||||
* 退货(仅限本次 prep 买的,原价退——允许悔棋,让备货决策可探索)。
|
||||
* @param {string} productId @param {number} [n]
|
||||
*/
|
||||
refundStock(productId, n) {
|
||||
const count = Math.max(1, Math.floor(n || 1));
|
||||
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
|
||||
const bought = state.prepBought[productId] || 0;
|
||||
if (bought < count || (state.stock[productId] || 0) < count) return { ok: false, reason: 'not_bought_now' };
|
||||
const p = productOf(productId);
|
||||
state.cash += p.wholesale * count;
|
||||
state.stock[productId] -= count;
|
||||
state.prepBought[productId] = bought - count;
|
||||
emit('stock_refunded', { productId, n: count });
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* 定价(prep 限定)。钳制 [0.8,1.5] 并吸附 0.05 步进。
|
||||
* @param {number} mul
|
||||
*/
|
||||
setPrice(mul) {
|
||||
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
|
||||
const snapped = clamp(Math.round(mul / ECON.priceStep) * ECON.priceStep, ECON.priceMin, ECON.priceMax);
|
||||
state.priceMul = Math.round(snapped * 100) / 100;
|
||||
emit('price_changed', { priceMul: state.priceMul });
|
||||
return { ok: true, priceMul: state.priceMul };
|
||||
},
|
||||
|
||||
/**
|
||||
* 购买升级(prep 限定;校验前置与资金)。
|
||||
* @param {string} upgradeId
|
||||
*/
|
||||
buyUpgrade(upgradeId) {
|
||||
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
|
||||
const u = upgradeOf(upgradeId);
|
||||
if (!u) return { ok: false, reason: 'unknown_upgrade' };
|
||||
if (state.upgrades[upgradeId]) return { ok: false, reason: 'owned' };
|
||||
if (u.requires && !state.upgrades[u.requires]) return { ok: false, reason: 'requires' };
|
||||
if (state.cash < u.costJiao) return { ok: false, reason: 'no_cash' };
|
||||
state.cash -= u.costJiao;
|
||||
state.upgrades[upgradeId] = true;
|
||||
state.lifetime.upgradesBought += 1;
|
||||
if (u.unlocksProduct && !state.unlocked.includes(u.unlocksProduct)) {
|
||||
state.unlocked.push(u.unlocksProduct);
|
||||
}
|
||||
refreshDerived();
|
||||
emit('upgrade_bought', { id: upgradeId, costJiao: u.costJiao, first: state.lifetime.upgradesBought === 1 });
|
||||
emit('save_point', {});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/** 开门营业(prep → open)。 */
|
||||
startDay() {
|
||||
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
|
||||
state.dayTimeSec = 0;
|
||||
state.rushFactor = 0;
|
||||
wasRush = false;
|
||||
state.serveCooldown = 0;
|
||||
combo.reset();
|
||||
state.comboCount = 0;
|
||||
setPhase('open');
|
||||
emit('day_start', { day: state.day });
|
||||
rollSpawnTimer();
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* 接待:把货架上的 productId 递给「最早到的、想要它的」顾客。
|
||||
* 命中救单宽限(mood='leaving')也算成功并奖励声誉(惊险救单)。
|
||||
* @param {string} productId
|
||||
* @returns {{ok:boolean, reason?:string, revenueJiao?:number, combo?:number, big?:boolean, rescued?:boolean, customerId?:number}}
|
||||
*/
|
||||
serve(productId) {
|
||||
if (state.phase !== 'open') return { ok: false, reason: 'phase' };
|
||||
if (state.serveCooldown > 0) return { ok: false, reason: 'cooldown' };
|
||||
if (!state.unlocked.includes(productId)) return { ok: false, reason: 'unknown_product' };
|
||||
if ((state.stock[productId] || 0) <= 0) {
|
||||
emit('tap_empty', { productId });
|
||||
return { ok: false, reason: 'no_stock' };
|
||||
}
|
||||
// 取最早到店(id 最小)的、想要该商品的、可被接待的顾客。
|
||||
const target = state.customers
|
||||
.filter((c) => c.productId === productId && (c.mood === 'queue' || c.mood === 'leaving'))
|
||||
.sort((a, b) => a.id - b.id)[0];
|
||||
if (!target) {
|
||||
emit('tap_nocustomer', { productId });
|
||||
return { ok: false, reason: 'no_customer' };
|
||||
}
|
||||
const rescued = target.mood === 'leaving';
|
||||
// 出货 + 收钱(连击小费)。
|
||||
state.stock[productId] -= 1;
|
||||
const n = combo.hit(simClockMs);
|
||||
state.comboCount = n;
|
||||
state.today.maxCombo = Math.max(state.today.maxCombo, n);
|
||||
const revenueJiao = Math.round(priceJiaoOf(productId, state.priceMul) * comboTipMul(n));
|
||||
state.cash += revenueJiao;
|
||||
state.today.revenueJiao += revenueJiao;
|
||||
state.today.soldCostJiao += productOf(productId).wholesale;
|
||||
state.today.served += 1;
|
||||
state.lifetime.served += 1;
|
||||
if (rescued) state.today.rescued += 1;
|
||||
// 声誉:成功接待小幅回血,救单额外奖励。
|
||||
bumpRep(REP.serveGainTenth + (rescued ? REP.rescueBonusTenth : 0));
|
||||
// 收银冷却(升级可缩短)。
|
||||
state.serveCooldown = derived.serveCooldownSec;
|
||||
const big = revenueJiao >= ECON.bigSaleJiao;
|
||||
if (big) state.today.bigSales += 1;
|
||||
removeCustomer(target);
|
||||
emit('serve_ok', {
|
||||
id: target.id,
|
||||
slot: target.slot,
|
||||
avatar: target.avatar,
|
||||
productId,
|
||||
revenueJiao,
|
||||
combo: n,
|
||||
big,
|
||||
rescued,
|
||||
});
|
||||
return { ok: true, revenueJiao, combo: n, big, rescued, customerId: target.id };
|
||||
},
|
||||
|
||||
/** 日结确认(settle → prep 下一天;逢段落末 → summary)。 */
|
||||
confirmSettle() {
|
||||
if (state.phase !== 'settle') return { ok: false, reason: 'phase' };
|
||||
const finished = state.day;
|
||||
emit('save_point', {});
|
||||
if (finished % DAY.daysPerSegment === 0) {
|
||||
const segment = finished / DAY.daysPerSegment;
|
||||
state.summary = {
|
||||
segment,
|
||||
days: finished,
|
||||
netJiao: state.lifetime.netJiao,
|
||||
served: state.lifetime.served,
|
||||
upgradesBought: state.lifetime.upgradesBought,
|
||||
reputation: reputation(),
|
||||
};
|
||||
setPhase('summary');
|
||||
emit('segment_end', { summary: state.summary });
|
||||
} else {
|
||||
enterPrep(finished + 1);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/** 段落小结确认(summary → prep 下一天)。 */
|
||||
confirmSummary() {
|
||||
if (state.phase !== 'summary') return { ok: false, reason: 'phase' };
|
||||
enterPrep(state.day + 1);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/** 当前升级派生参数(渲染层展示货架容量等)。 */
|
||||
derived() {
|
||||
return derived;
|
||||
},
|
||||
|
||||
/** 当前声誉(0..100,浮点一位)。 */
|
||||
reputation,
|
||||
|
||||
/** 当前库存总件数(货架容量展示)。 */
|
||||
totalStock,
|
||||
|
||||
/* ── 存档(P9 由宿主接线;逻辑层只产/收纯数据)── */
|
||||
|
||||
/** 导出存档快照(仅 prep 间持久字段;当日过程态不存)。 */
|
||||
snapshotForSave() {
|
||||
return {
|
||||
v: SAVE_VERSION,
|
||||
day: state.day,
|
||||
cash: state.cash,
|
||||
repTenth: state.repTenth,
|
||||
priceMul: state.priceMul,
|
||||
stock: Object.assign({}, state.stock),
|
||||
unlocked: state.unlocked.slice(),
|
||||
upgrades: Object.assign({}, state.upgrades),
|
||||
lifetime: Object.assign({}, state.lifetime),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 从存档恢复(恢复到存档日的 prep)。结构非法/版本不符 → 返回 false(调用方回退新开局)。
|
||||
* @param {object} save
|
||||
* @returns {boolean}
|
||||
*/
|
||||
restoreFromSave(save) {
|
||||
if (!save || save.v !== SAVE_VERSION) return false;
|
||||
if (typeof save.day !== 'number' || typeof save.cash !== 'number' || typeof save.repTenth !== 'number') return false;
|
||||
if (!save.unlocked || !Array.isArray(save.unlocked) || save.unlocked.length === 0) return false;
|
||||
state.cash = Math.floor(save.cash);
|
||||
state.repTenth = clamp(Math.floor(save.repTenth), 0, REP.max * 10);
|
||||
state.priceMul = clamp(save.priceMul || 1, ECON.priceMin, ECON.priceMax);
|
||||
state.stock = Object.assign({}, save.stock || {});
|
||||
state.unlocked = save.unlocked.filter((id) => !!productOf(id));
|
||||
state.upgrades = Object.assign({}, save.upgrades || {});
|
||||
state.lifetime = Object.assign({ days: 0, served: 0, angry: 0, netJiao: 0, upgradesBought: 0 }, save.lifetime || {});
|
||||
state.gameoverReason = null;
|
||||
refreshDerived();
|
||||
combo.reset();
|
||||
emit('game_start', { continued: true });
|
||||
enterPrep(Math.max(1, Math.floor(save.day)));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
/** 升级全表再导出(渲染层/测试便利)。 */
|
||||
export { UPGRADES, PRODUCTS };
|
||||
114
game-runtime/games/wanglanmei-ref/src/shopkeeper-lines.js
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* shopkeeper-lines.js — 店主台词库(纯数据 + 注入随机的取词器,零依赖)
|
||||
* owner:wanglanmei-ref 轮 1 | 消费方:宿主层(事件→气泡台词)/ test(条数与去重断言)
|
||||
*
|
||||
* 【角色壳】
|
||||
* 店主人设 = 通用市井小卖部老板娘(占位形象,IP 签署后换正式形象):嘴快心热、
|
||||
* 精打细算、爱跟街坊唠两句。台词全部口语化、带性格,按场景分组 ≥6 组、合计 ≥20 条。
|
||||
* ⚠️ 红线(IP gate):台词与一切对外可见文本零受保护 IP 原名(仅用 wanglanmei 代号指代)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 台词库:场景 key → 候选句子数组。
|
||||
* 场景与逻辑事件的映射由宿主层决定(fx-director),本文件只管「说什么」。
|
||||
* @type {Record<string, string[]>}
|
||||
*/
|
||||
export const LINES = {
|
||||
/** 开店/进入准备 */
|
||||
open_shop: [
|
||||
'开张喽——街坊们都等着呢!',
|
||||
'今儿个进点啥好货?我心里有数。',
|
||||
'小本生意,账可得算明白咯。',
|
||||
],
|
||||
/** 开门营业瞬间 */
|
||||
day_start: [
|
||||
'开门迎客!都麻利点儿~',
|
||||
'哟,今天太阳不错,准是个好日子。',
|
||||
],
|
||||
/** 闲时随口 */
|
||||
idle_chat: [
|
||||
'慢悠悠的下午,最适合盘账啦。',
|
||||
'隔壁老李又来赊账?想得美。',
|
||||
'货架擦得锃亮,看着就舒坦。',
|
||||
],
|
||||
/** 高峰来临 */
|
||||
rush_start: [
|
||||
'放学啦放学啦,打起精神来!',
|
||||
'人都涌过来了,手上别停!',
|
||||
],
|
||||
/** 成功接待(普通) */
|
||||
serve_ok: [
|
||||
'好嘞,拿稳当!',
|
||||
'您慢走,常来啊~',
|
||||
'这就给您拿,快得很!',
|
||||
],
|
||||
/** 连击起势(combo ≥3) */
|
||||
combo_hot: [
|
||||
'手感来了,谁都别拦我!',
|
||||
'一个接一个,越干越顺溜!',
|
||||
],
|
||||
/** 大额成交 */
|
||||
big_sale: [
|
||||
'哎哟,大主顾!今晚加个鸡腿!',
|
||||
'这单顶半天买卖,美滋滋~',
|
||||
],
|
||||
/** 惊险救单 */
|
||||
rescue: [
|
||||
'别走别走,这就给您拿上!',
|
||||
'好险好险,差点儿黄了一单。',
|
||||
],
|
||||
/** 顾客愤怒离店 */
|
||||
angry: [
|
||||
'哎呀,把人晾跑了……心疼。',
|
||||
'砸招牌了砸招牌了,得上点心!',
|
||||
],
|
||||
/** 缺货被看穿 */
|
||||
stockout: [
|
||||
'空货架最戳心窝子,明儿多备点。',
|
||||
'又卖断了……是好事也是坏事。',
|
||||
],
|
||||
/** 日结盈利 */
|
||||
settle_win: [
|
||||
'今儿账面红火,记上记上!',
|
||||
'辛苦一天,值当!',
|
||||
],
|
||||
/** 日结亏损/被罚 */
|
||||
settle_lose: [
|
||||
'这账……越算越心凉。',
|
||||
'罚单都贴脸上了,明天得换个活法。',
|
||||
],
|
||||
/** 买升级 */
|
||||
upgrade: [
|
||||
'舍不得孩子套不着狼,升!',
|
||||
'鸟枪换炮,咱也讲究一回!',
|
||||
],
|
||||
/** 失败终态 */
|
||||
gameover: [
|
||||
'唉……卷帘门一拉,从头再来吧。',
|
||||
],
|
||||
};
|
||||
|
||||
/** 台词场景总数(断言用)。 */
|
||||
export const LINE_GROUP_COUNT = Object.keys(LINES).length;
|
||||
|
||||
/**
|
||||
* 创建取词器:同场景轮转不复读(记住上一条下标,注入随机挑「非上一条」)。
|
||||
* @param {{ next:()=>number }} random 受控随机源
|
||||
* @returns {(key:string)=>string} 取词函数(未知场景返回空串)
|
||||
*/
|
||||
export function createLinePicker(random) {
|
||||
/** @type {Record<string, number>} 各场景上次用的下标。 */
|
||||
const lastIdx = {};
|
||||
return function pick(key) {
|
||||
const arr = LINES[key];
|
||||
if (!arr || arr.length === 0) return '';
|
||||
if (arr.length === 1) return arr[0];
|
||||
// 在「非上一条」里随机挑,保证连续两次不同句。
|
||||
let idx = Math.floor(random.next() * arr.length) % arr.length;
|
||||
if (idx === lastIdx[key]) idx = (idx + 1) % arr.length;
|
||||
lastIdx[key] = idx;
|
||||
return arr[idx];
|
||||
};
|
||||
}
|
||||
60
game-runtime/games/wanglanmei-ref/test/RESULT.txt
Normal file
@ -0,0 +1,60 @@
|
||||
✔ 商品表:开局 3 件解锁,全部毛利为正(priceMul=1 不卖一件亏一件) (3.120321ms)
|
||||
✔ 定价→客流曲线:单调不增,低价多客高价少客,有下限 (4.153737ms)
|
||||
✔ 声誉→客流:50 分=基准 1.0,越高越旺 (0.555963ms)
|
||||
✔ 二阶决策核心性质:定价两头都能输、中间能赢(解析模型钉死) (2.414891ms)
|
||||
✔ 库存侧同样有真代价:备货不足封顶成交(缺货流失的经济面) (0.724014ms)
|
||||
✔ 高峰系数曲线:进出有 3s 斜坡、峰区恒 1、外区恒 0 (1.863329ms)
|
||||
✔ 连击小费:1 连无加成,逐级 +4%,封顶 +32% (0.530341ms)
|
||||
✔ 升级派生参数:容量/冷却/装修逐级生效 (0.756734ms)
|
||||
✔ 售价取整与金额格式化(角整数口径) (0.918999ms)
|
||||
[ref] localStorage 不可用,存档退内存 adapter:[save-progress] createLocalStorageAdapter 需要全局 localStorage(仅浏览器集成段可用)。node/host-dev 请用缺省内存 adapter 或 createMemoryAdapter()。
|
||||
[ref] localStorage 不可用,存档退内存 adapter:[save-progress] createLocalStorageAdapter 需要全局 localStorage(仅浏览器集成段可用)。node/host-dev 请用缺省内存 adapter 或 createMemoryAdapter()。
|
||||
[ref] localStorage 不可用,存档退内存 adapter:[save-progress] createLocalStorageAdapter 需要全局 localStorage(仅浏览器集成段可用)。node/host-dev 请用缺省内存 adapter 或 createMemoryAdapter()。
|
||||
✔ 无浏览器全链路:boot→开店→进货定价→营业接待→日结→次日升级(真 tap 路由) (1776.945792ms)
|
||||
✔ 无浏览器确定性:同 seed 同驱动脚本 → 状态终点一致 (151.34042ms)
|
||||
✔ import-safe:../src/balance.js 可在 node 导入且导出齐备 (17.517917ms)
|
||||
✔ import-safe:../src/shop-core.js 可在 node 导入且导出齐备 (15.054321ms)
|
||||
✔ import-safe:../src/shopkeeper-lines.js 可在 node 导入且导出齐备 (3.169908ms)
|
||||
✔ import-safe:../src/loop-proof.js 可在 node 导入且导出齐备 (19.166177ms)
|
||||
✔ import-safe:../src/palette.js 可在 node 导入且导出齐备 (3.083209ms)
|
||||
✔ import-safe:../src/assets.js 可在 node 导入且导出齐备 (6.154749ms)
|
||||
✔ import-safe:../src/hostfx.js 可在 node 导入且导出齐备 (11.534494ms)
|
||||
✔ import-safe:../src/audio-director.js 可在 node 导入且导出齐备 (5.879451ms)
|
||||
✔ import-safe:../src/render.js 可在 node 导入且导出齐备 (13.195904ms)
|
||||
✔ import-safe:../src/main.js 可在 node 导入且导出齐备 (41.435327ms)
|
||||
✔ import-safe:../entry.js 可在 node 导入且导出齐备 (20.014131ms)
|
||||
✔ palette:取色器在 node 侧即可运转(纯计算,无 canvas 依赖) (9.702205ms)
|
||||
✔ computeZones:各阶段命中区几何自洽(中心+半宽高,无 NaN,id 唯一) (10.651098ms)
|
||||
✔ 角色壳:台词场景 ≥6 组、总条数 ≥20、全部非空中文短句 (2.585166ms)
|
||||
✔ 取词器:同场景连续两次不复读;同种子序列确定 (1.297027ms)
|
||||
✔ IP gate:本参考件全部源码/文档/资产清单零受保护字样 (11.865686ms)
|
||||
✔ 资产清单:manifest 存在则逐条校验文件在位且字段齐备 (1.504569ms)
|
||||
✔ 闭环可达:开局→进货定价→营业接待→首日结算→首次升级,操作数 ≤ 64 (51.377226ms)
|
||||
✔ 闭环证明确定性:同种子重跑逐字段一致 (29.534474ms)
|
||||
✔ 失败态可达:摆烂经营 → 罚没 + 停业(非只胜不败) (11.483678ms)
|
||||
✔ 五阶段面板绘制零运行时错误(含罚款行/小结/失败态/存档标题) (30.242431ms)
|
||||
✔ 存档往返:玩出进度 → snapshot → P9 set/get → restore → 快照逐字段一致 (35.38545ms)
|
||||
✔ 坏档容错:版本不符 / 结构残缺 → 拒绝并回退新开局(不崩) (3.026769ms)
|
||||
✔ 命名空间隔离 + 清档:clear 只清本命名空间 (0.812016ms)
|
||||
✔ 阶段流转:title→(startGame)prep→(startDay)open;非法阶段命令被拒 (8.730899ms)
|
||||
✔ 进货:资金占用 + 货架容量上限 + 本次 prep 可退 (1.07444ms)
|
||||
✔ 定价:钳制 [0.8,1.5] 且吸附 0.05 步进 (0.495487ms)
|
||||
✔ 接待闭环:到客→接待→收钱(价随定价)→冷却→连击小费 (3.88231ms)
|
||||
✔ 耐心耗尽:先进救单宽限(grace 事件)再愤怒离店掉声誉 −4 (3.856226ms)
|
||||
✔ 救单宽限:宽限期内接待成功,rescued 标记 + 额外声誉奖励 (2.124579ms)
|
||||
✔ 缺货:进店即失望停留 → 离店掉声誉 −2.5;空货架点击返回 no_stock (1.108312ms)
|
||||
✔ 日结账目:勤快经营一天 → settle 报表自洽(净利=营收−货成本−房租−罚款),房租入账 (6.031153ms)
|
||||
✔ 罚没:日结时声誉 < 30 → 罚款 = 基数 + 差评加罚(fined 事件) (7.747264ms)
|
||||
✔ 失败终态①:声誉跌到 0 → 日结判停业(reputation) (7.533603ms)
|
||||
✔ 失败终态②:日结付不起房租 → 停业(bankrupt) (10.444134ms)
|
||||
✔ 升级:资金扣减 + 前置校验 + 商品解锁 + 收银冷却缩短生效 (1.215569ms)
|
||||
✔ 三日一段落:第 3 天日结确认 → 段落小结 → 确认 → 第 4 天 prep (7.064072ms)
|
||||
✔ 确定性:同种子同操作 → 事件流逐字段一致;异种子 → 到店节拍不同 (9.005889ms)
|
||||
ℹ tests 49
|
||||
ℹ suites 0
|
||||
ℹ pass 49
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 2207.457411
|
||||
108
game-runtime/games/wanglanmei-ref/test/balance.test.mjs
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* balance.test.mjs — 数值模型测试:二阶决策「两头都能输」是结构深度的根基
|
||||
* 运行:node --test games/wanglanmei-ref/test/(零依赖)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
PRODUCTS,
|
||||
productOf,
|
||||
DAY,
|
||||
rushFactorAt,
|
||||
flowMulOfPrice,
|
||||
flowMulOfReputation,
|
||||
estimateDayOutcome,
|
||||
comboTipMul,
|
||||
derivedOfUpgrades,
|
||||
priceJiaoOf,
|
||||
ECON,
|
||||
fmtYuan,
|
||||
} from '../src/balance.js';
|
||||
|
||||
test('商品表:开局 3 件解锁,全部毛利为正(priceMul=1 不卖一件亏一件)', () => {
|
||||
const initial = PRODUCTS.filter((p) => p.initial);
|
||||
assert.equal(initial.length, 3);
|
||||
for (const p of PRODUCTS) {
|
||||
assert.ok(p.basePrice > p.wholesale, `${p.id} 基准价应高于进货价`);
|
||||
assert.ok(p.popularity > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('定价→客流曲线:单调不增,低价多客高价少客,有下限', () => {
|
||||
assert.ok(flowMulOfPrice(0.8) > flowMulOfPrice(1.0));
|
||||
assert.ok(flowMulOfPrice(1.0) > flowMulOfPrice(1.25));
|
||||
assert.ok(flowMulOfPrice(1.25) > flowMulOfPrice(1.5));
|
||||
assert.equal(flowMulOfPrice(1.0), 1.0);
|
||||
assert.ok(flowMulOfPrice(1.5) >= 0.2, '高价端客流有下限(不会归零导致除零)');
|
||||
});
|
||||
|
||||
test('声誉→客流:50 分=基准 1.0,越高越旺', () => {
|
||||
assert.ok(Math.abs(flowMulOfReputation(50) - 1.0) < 1e-9);
|
||||
assert.ok(flowMulOfReputation(100) > flowMulOfReputation(50));
|
||||
assert.ok(flowMulOfReputation(0) < flowMulOfReputation(50));
|
||||
});
|
||||
|
||||
test('二阶决策核心性质:定价两头都能输、中间能赢(解析模型钉死)', () => {
|
||||
// 默认参数(开局声誉/接待力/段落 1 房租)下:
|
||||
const lo = estimateDayOutcome({ priceMul: ECON.priceMin }); // 0.8 贱卖
|
||||
const mid = estimateDayOutcome({ priceMul: 1.0 });
|
||||
const hi = estimateDayOutcome({ priceMul: ECON.priceMax }); // 1.5 贵卖
|
||||
assert.ok(mid.profitJiao > 0, `中间定价应盈利(实际 ${mid.profitJiao} 角)`);
|
||||
assert.ok(lo.profitJiao < 0, `贱卖应亏损(毛利盖不住房租,实际 ${lo.profitJiao} 角)`);
|
||||
assert.ok(hi.profitJiao < 0, `贵卖应亏损(客流盖不住房租,实际 ${hi.profitJiao} 角)`);
|
||||
// 内部最优存在:扫格点,最优点既非最低也非最高(真权衡,不是单调送分题)。
|
||||
let best = null;
|
||||
for (let m = ECON.priceMin; m <= ECON.priceMax + 1e-9; m += ECON.priceStep) {
|
||||
const r = estimateDayOutcome({ priceMul: m });
|
||||
if (!best || r.profitJiao > best.profit) best = { m, profit: r.profitJiao };
|
||||
}
|
||||
assert.ok(best.m > ECON.priceMin + 1e-9 && best.m < ECON.priceMax - 1e-9, `最优定价应在区间内部(实际 ${best.m})`);
|
||||
assert.ok(best.profit > mid.profitJiao * 0.99, '内部最优不劣于基准定价');
|
||||
});
|
||||
|
||||
test('库存侧同样有真代价:备货不足封顶成交(缺货流失的经济面)', () => {
|
||||
const full = estimateDayOutcome({ priceMul: 1.1 });
|
||||
const starved = estimateDayOutcome({ priceMul: 1.1, stockUnits: 8 });
|
||||
assert.ok(starved.served < full.served);
|
||||
assert.ok(starved.profitJiao < full.profitJiao, '少备货 → 少赚(资金占用 vs 缺货流失是真权衡)');
|
||||
});
|
||||
|
||||
test('高峰系数曲线:进出有 3s 斜坡、峰区恒 1、外区恒 0', () => {
|
||||
assert.equal(rushFactorAt(0), 0);
|
||||
assert.equal(rushFactorAt(DAY.rushStartSec + 1), 1);
|
||||
assert.equal(rushFactorAt(DAY.rushEndSec + DAY.rushRampSec + 1), 0);
|
||||
const ramp = rushFactorAt(DAY.rushStartSec - DAY.rushRampSec / 2);
|
||||
assert.ok(ramp > 0 && ramp < 1, '斜坡区应在 (0,1) 内');
|
||||
});
|
||||
|
||||
test('连击小费:1 连无加成,逐级 +4%,封顶 +32%', () => {
|
||||
assert.equal(comboTipMul(1), 1);
|
||||
assert.ok(Math.abs(comboTipMul(2) - 1.04) < 1e-9);
|
||||
assert.ok(Math.abs(comboTipMul(9) - 1.32) < 1e-9);
|
||||
assert.ok(Math.abs(comboTipMul(50) - 1.32) < 1e-9, '超过 9 连不再增长');
|
||||
});
|
||||
|
||||
test('升级派生参数:容量/冷却/装修逐级生效', () => {
|
||||
const base = derivedOfUpgrades({});
|
||||
assert.equal(base.shelfCap, 24);
|
||||
assert.equal(base.serveCooldownSec, 0.55);
|
||||
const s1 = derivedOfUpgrades({ shelf1: true, speed1: true, decor1: true });
|
||||
assert.equal(s1.shelfCap, 34);
|
||||
assert.equal(s1.serveCooldownSec, 0.42);
|
||||
assert.ok(s1.patienceMul > 1 && s1.settleRepBonusTenth > 0);
|
||||
const s2 = derivedOfUpgrades({ shelf1: true, shelf2: true, speed1: true, speed2: true, decor1: true, decor2: true });
|
||||
assert.equal(s2.shelfCap, 48);
|
||||
assert.equal(s2.serveCooldownSec, 0.32);
|
||||
assert.ok(s2.flowMul > 1);
|
||||
});
|
||||
|
||||
test('售价取整与金额格式化(角整数口径)', () => {
|
||||
assert.equal(priceJiaoOf('soda', 1.0), 40);
|
||||
assert.equal(priceJiaoOf('snack', 1.15), Math.round(25 * 1.15));
|
||||
assert.equal(fmtYuan(1200), '120');
|
||||
assert.equal(fmtYuan(45), '4.5');
|
||||
assert.equal(productOf('nope'), undefined);
|
||||
});
|
||||
167
game-runtime/games/wanglanmei-ref/test/headless-boot.test.mjs
Normal file
@ -0,0 +1,167 @@
|
||||
/**
|
||||
* headless-boot.test.mjs — 无浏览器全链路冒烟(本机最强可跑验证)
|
||||
*
|
||||
* 用「Proxy 伪 2D 上下文 + window 桩」真跑 bootRef:
|
||||
* boot(evidence) → 标题点「开店」→ prep 点「+」进货/点「开门营业」→ 营业期连点货架接待
|
||||
* → 跑满一天到日结 → 点「继续」→ 第 2 天 prep 点升级卡 → 断言升级到手。
|
||||
* 整条链路走的就是真实 onTap 命中路由 + 真实渲染调用面(每帧 drawFrame 全画),
|
||||
* 任何宿主层运行时错误都会在此抛出。像素正确性留集成段浏览器证据(两层测试纪律)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
/** 建一个「接受一切调用」的伪 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];
|
||||
// 其余一律给可调用空函数(fillRect/arc/fillText/setTransform/clip/…)。
|
||||
if (!fnCache.has(prop)) fnCache.set(prop, () => {});
|
||||
return fnCache.get(prop);
|
||||
},
|
||||
set(target, prop, v) {
|
||||
target[prop] = v; // fillStyle/font/globalAlpha… 任意赋值放行
|
||||
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 { bootRef } = await import('../src/main.js');
|
||||
const host = await bootRef({ canvas });
|
||||
return host;
|
||||
}
|
||||
|
||||
test('无浏览器全链路:boot→开店→进货定价→营业接待→日结→次日升级(真 tap 路由)', async () => {
|
||||
const host = await bootHeadless('?mode=evidence&seed=99');
|
||||
const F = globalThis.window.__refForensics;
|
||||
const C = globalThis.window.__refChecklist;
|
||||
assert.equal(host.mode, 'evidence');
|
||||
assert.equal(F.probe().verify.ok, true, '六锚链 verifyChain 应通过');
|
||||
assert.deepEqual(
|
||||
F.probe().anchors.slice(0, 6),
|
||||
['t_boot', 't_plugins_ready', 't_game_init', 't_first_paint', 't_input_bound', 't_game_start'],
|
||||
'六锚 canonical 顺序'
|
||||
);
|
||||
assert.equal(F.state().phase, 'title');
|
||||
|
||||
/** 按 zone id 点一下(取证几何=命中真源)。@param {string} id */
|
||||
function tapZone(id) {
|
||||
const z = F.geometry().find((zz) => zz.id === id);
|
||||
assert.ok(z, `命中区缺失:${id}(当前=${F.geometry().map((x) => x.id).join(',')})`);
|
||||
host.tap(z.x, z.y);
|
||||
host.stepFrames(2); // 让事件/渲染落帧
|
||||
}
|
||||
|
||||
// ① 标题 → 开店。
|
||||
tapZone('newGame');
|
||||
assert.equal(F.state().phase, 'prep');
|
||||
|
||||
// ② 进货:每件商品点「+」若干次(走真实按钮路由)。
|
||||
for (const pid of ['soda', 'snack', 'bread']) {
|
||||
for (let i = 0; i < 7; i++) tapZone('inc-' + pid);
|
||||
}
|
||||
assert.ok(F.state().stock.soda >= 6, '进货应到账');
|
||||
|
||||
// ③ 开门营业。
|
||||
tapZone('openShop');
|
||||
assert.equal(F.state().phase, 'open');
|
||||
|
||||
// ④ 营业:每 30 帧把三个货架槽各点一遍(匹配则接待;冷却点击走输入缓冲)。
|
||||
let guard = 0;
|
||||
while (F.state().phase === 'open' && guard++ < 240) {
|
||||
for (const pid of F.state().unlocked) {
|
||||
const z = F.geometry().find((zz) => zz.id === 'serve-' + pid);
|
||||
if (z) host.tap(z.x, z.y);
|
||||
host.stepFrames(6);
|
||||
}
|
||||
host.stepFrames(12);
|
||||
}
|
||||
assert.equal(F.state().phase, 'settle', `应到日结(guard=${guard})`);
|
||||
const settled = F.state();
|
||||
assert.ok(settled.today.served > 0, '当天应有成交');
|
||||
|
||||
// ⑤ 日结 → 第 2 天 prep。
|
||||
tapZone('confirmSettle');
|
||||
assert.equal(F.state().phase, 'prep');
|
||||
assert.equal(F.state().day, 2);
|
||||
|
||||
// ⑥ 升级:点「收银加速 I」卡(¥55,首日利润后必买得起;不够就先验余额再断言)。
|
||||
const cashBefore = F.state().cash;
|
||||
assert.ok(cashBefore >= 550, `第 2 天开市前应买得起最便宜升级(cash=${cashBefore})`);
|
||||
tapZone('up-speed1');
|
||||
assert.equal(F.state().upgrades.speed1, true, '升级应到手');
|
||||
|
||||
// ⑦ checklist 自动子项抽查(结构深度/角色壳/手感子项在 node 侧即可断言)。
|
||||
assert.equal(C.shendu.loopReachable, true);
|
||||
assert.equal(C.shendu.failureReachable, true);
|
||||
assert.equal(C.shendu.priceTwoSidedLoss, true);
|
||||
assert.ok(C.shougan.tapFeedbackMaxFrames <= 3, `点击反馈帧延迟 ${C.shougan.tapFeedbackMaxFrames} 应 ≤3`);
|
||||
assert.ok(C.juese.lineTotal >= 20);
|
||||
assert.ok(C.juese.linesShownLive > 0, '过程中应展示过台词');
|
||||
// 评估环境无音频:音效种类为 0 属预期(发声留集成段真浏览器)。
|
||||
assert.equal(C.yinyue.audioUnlocked, true, 'tap 应触发解锁流程(无 AudioContext 时降级静默)');
|
||||
|
||||
// ⑧ 取证几何与状态导出形状健全。
|
||||
for (const z of F.geometry()) {
|
||||
assert.ok(Number.isFinite(z.x) && Number.isFinite(z.hw));
|
||||
}
|
||||
assert.ok(F.recentEvents().length > 0);
|
||||
assert.ok(F.measures().frames > 100);
|
||||
});
|
||||
|
||||
test('无浏览器确定性:同 seed 同驱动脚本 → 状态终点一致', async () => {
|
||||
/** 跑固定脚本到营业 300 帧,返回状态快照。 */
|
||||
async function run() {
|
||||
// 每次重置模块图无必要:bootRef 每次新建实例,互不共享状态(window 桩重建)。
|
||||
const host = await bootHeadless('?mode=evidence&seed=777');
|
||||
const F = globalThis.window.__refForensics;
|
||||
const tap = (id) => {
|
||||
const z = F.geometry().find((zz) => zz.id === id);
|
||||
if (z) {
|
||||
host.tap(z.x, z.y);
|
||||
host.stepFrames(2);
|
||||
}
|
||||
};
|
||||
tap('newGame');
|
||||
for (const pid of ['soda', 'snack']) for (let i = 0; i < 4; i++) tap('inc-' + pid);
|
||||
tap('openShop');
|
||||
host.stepFrames(300);
|
||||
const s = F.state();
|
||||
return JSON.stringify({ cash: s.cash, customers: s.customers, stock: s.stock, rep: s.reputation });
|
||||
}
|
||||
const a = await run();
|
||||
const b = await run();
|
||||
assert.equal(a, b, '同 seed 同脚本应逐字段一致(受控时间/随机生效)');
|
||||
});
|
||||
72
game-runtime/games/wanglanmei-ref/test/import-graph.test.mjs
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* import-graph.test.mjs — 模块图可解析 + import-safe 守门
|
||||
* 宿主层文件(main/render/hostfx/audio/assets/entry)必须「import 即安全」:
|
||||
* 顶层零浏览器 API 调用(boot 由页面显式触发),node 侧 import 不抛错——
|
||||
* 这是本机无浏览器条件下对宿主层语法/依赖图的最强可跑验证(像素级证据留集成段)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const MODULES = [
|
||||
['../src/balance.js', ['PRODUCTS', 'estimateDayOutcome', 'flowMulOfPrice']],
|
||||
['../src/shop-core.js', ['createShopCore', 'SAVE_VERSION']],
|
||||
['../src/shopkeeper-lines.js', ['LINES', 'createLinePicker']],
|
||||
['../src/loop-proof.js', ['proveCoreLoop', 'proveFailure']],
|
||||
['../src/palette.js', ['createPalette', 'BASE']],
|
||||
['../src/assets.js', ['loadAssets', 'PRODUCT_IMAGE_KEY']],
|
||||
['../src/hostfx.js', ['createHostFx']],
|
||||
['../src/audio-director.js', ['createAudioDirector']],
|
||||
['../src/render.js', ['drawFrame', 'computeZones', 'shelfSlotRect', 'LAYOUT']],
|
||||
['../src/main.js', ['bootRef']],
|
||||
['../entry.js', ['bootRef']],
|
||||
];
|
||||
|
||||
for (const [path, expectedExports] of MODULES) {
|
||||
test(`import-safe:${path} 可在 node 导入且导出齐备`, async () => {
|
||||
const mod = await import(path);
|
||||
for (const name of expectedExports) {
|
||||
assert.ok(name in mod, `${path} 缺导出 ${name}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('palette:取色器在 node 侧即可运转(纯计算,无 canvas 依赖)', async () => {
|
||||
const { createPalette } = await import('../src/palette.js');
|
||||
const pal = createPalette();
|
||||
const calm = pal.c('gold');
|
||||
pal.setRush(1);
|
||||
const rush = pal.c('gold');
|
||||
assert.ok(/^rgb\(/.test(calm) && /^rgb\(/.test(rush));
|
||||
assert.notEqual(calm, rush, '高峰色板应与平静色板不同(压力换色真实生效)');
|
||||
assert.equal(pal.c('ink', 0.5).startsWith('rgba('), true);
|
||||
});
|
||||
|
||||
test('computeZones:各阶段命中区几何自洽(中心+半宽高,无 NaN,id 唯一)', async () => {
|
||||
const { computeZones } = await import('../src/render.js');
|
||||
const { createShopCore } = await import('../src/shop-core.js');
|
||||
const { SeededRandom } = await import('../../../src/core/plugin.js');
|
||||
const core = createShopCore({ random: new SeededRandom(3) });
|
||||
// title 阶段。
|
||||
let zones = computeZones(core, { hasSave: true });
|
||||
assert.ok(zones.some((z) => z.id === 'newGame') && zones.some((z) => z.id === 'continue'));
|
||||
// prep 阶段。
|
||||
core.startGame(null);
|
||||
zones = computeZones(core, { hasSave: false });
|
||||
assert.ok(zones.some((z) => z.kind === 'slider'));
|
||||
assert.ok(zones.filter((z) => z.kind === 'upgrade').length === 8, '8 个升级卡都可点');
|
||||
// open 阶段:货架槽=接待区。
|
||||
core.buyStock('soda', 2);
|
||||
core.startDay();
|
||||
zones = computeZones(core, { hasSave: false });
|
||||
assert.equal(zones.filter((z) => z.kind === 'serve').length, core.state.unlocked.length);
|
||||
// 几何健康:唯一 id + 数值有限。
|
||||
const ids = new Set();
|
||||
for (const z of zones) {
|
||||
assert.ok(!ids.has(z.id), `命中区 id 重复:${z.id}`);
|
||||
ids.add(z.id);
|
||||
for (const k of ['x', 'y', 'hw', 'hh']) assert.ok(Number.isFinite(z[k]) && z[k] >= 0, `${z.id}.${k} 非法`);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* lines-and-ipgate.test.mjs — 店主台词库(角色壳)+ IP gate 静态扫描
|
||||
* 覆盖:场景组数 ≥6 / 总条数 ≥20 / 取词器不复读且确定性 / 源码与台词零受保护 IP 字样。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SeededRandom } from '../../../src/core/plugin.js';
|
||||
import { LINES, LINE_GROUP_COUNT, createLinePicker } from '../src/shopkeeper-lines.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const GAME_ROOT = join(HERE, '..');
|
||||
|
||||
// 受保护 IP 字样(IP gate 红线):对外文本/源码字符串一律不得出现。
|
||||
// 用码位拼接构造,避免本测试文件自身含该字面量。
|
||||
const BANNED = String.fromCharCode(0x738b, 0x84dd, 0x8393); // 「王」「蓝」「莓」
|
||||
|
||||
test('角色壳:台词场景 ≥6 组、总条数 ≥20、全部非空中文短句', () => {
|
||||
assert.ok(LINE_GROUP_COUNT >= 6, `场景组数 ${LINE_GROUP_COUNT} 应 ≥6`);
|
||||
let total = 0;
|
||||
for (const key of Object.keys(LINES)) {
|
||||
for (const s of LINES[key]) {
|
||||
assert.ok(typeof s === 'string' && s.length >= 2, `${key} 存在空台词`);
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(total >= 20, `台词总数 ${total} 应 ≥20`);
|
||||
});
|
||||
|
||||
test('取词器:同场景连续两次不复读;同种子序列确定', () => {
|
||||
const pick1 = createLinePicker(new SeededRandom(8));
|
||||
const pick2 = createLinePicker(new SeededRandom(8));
|
||||
let prev = '';
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const a = pick1('serve_ok');
|
||||
const b = pick2('serve_ok');
|
||||
assert.equal(a, b, '同种子取词序列应一致');
|
||||
assert.notEqual(a, prev, '连续两次不应复读同句');
|
||||
assert.ok(a.length > 0);
|
||||
prev = a;
|
||||
}
|
||||
assert.equal(pick1('unknown_scene'), '', '未知场景返回空串不抛错');
|
||||
});
|
||||
|
||||
test('IP gate:本参考件全部源码/文档/资产清单零受保护字样', () => {
|
||||
/** 递归收集待扫描文本文件。@param {string} dir */
|
||||
function collect(dir) {
|
||||
/** @type {string[]} */
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, name.name);
|
||||
if (name.isDirectory()) {
|
||||
// dist=构建产物(由集成段 grep 门另测);evidence=主会话证据归档,不属本件产出。
|
||||
if (name.name === 'dist' || name.name === 'node_modules' || name.name === 'evidence') continue;
|
||||
out.push(...collect(p));
|
||||
} else if (/\.(js|mjs|html|json|md|txt)$/.test(name.name)) {
|
||||
// LOOP-LOG.md 是内部循环留痕(执行版 §4:内部文档允许提及原名),不进对外 IP gate。
|
||||
if (name.name === 'LOOP-LOG.md') continue;
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (const file of collect(GAME_ROOT)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
assert.ok(!text.includes(BANNED), `IP gate 违例:${file} 含受保护字样`);
|
||||
}
|
||||
});
|
||||
|
||||
test('资产清单:manifest 存在则逐条校验文件在位且字段齐备', () => {
|
||||
const manifestPath = join(GAME_ROOT, 'assets', 'manifest.json');
|
||||
if (!existsSync(manifestPath)) {
|
||||
// 资产生成前允许缺位(生成后本测试自动转严格)。
|
||||
assert.ok(true);
|
||||
return;
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
assert.ok(Array.isArray(manifest.assets) && manifest.assets.length > 0);
|
||||
for (const a of manifest.assets) {
|
||||
assert.ok(a.file && typeof a.bytes === 'number' && a.sha256 && a.source, `资产记录字段残缺:${JSON.stringify(a)}`);
|
||||
assert.ok(existsSync(join(GAME_ROOT, 'assets', a.file)), `资产文件缺位:${a.file}`);
|
||||
const src = JSON.stringify(a.source);
|
||||
assert.ok(!src.includes(BANNED), `资产 prompt 含受保护字样:${a.file}`);
|
||||
}
|
||||
});
|
||||
37
game-runtime/games/wanglanmei-ref/test/loop-proof.test.mjs
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* loop-proof.test.mjs — 闭环可达 / 失败态可达(结构深度的两份客观证明)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { proveCoreLoop, proveFailure } from '../src/loop-proof.js';
|
||||
|
||||
test('闭环可达:开局→进货定价→营业接待→首日结算→首次升级,操作数 ≤ 64', () => {
|
||||
const proof = proveCoreLoop();
|
||||
assert.ok(proof.ok, `闭环证明失败:${proof.failReason}(ops=${proof.ops.join('|')})`);
|
||||
assert.ok(proof.opCount <= 64, `操作数超限:${proof.opCount}`);
|
||||
assert.ok(proof.served > 0, '首日应有成交');
|
||||
assert.ok(proof.upgradeId, '应完成首次升级');
|
||||
assert.equal(proof.day, 2, '首次升级落在第 2 天开市前(首段落内)');
|
||||
// 关键路径节点齐备(人读得懂的 op 流水)。
|
||||
assert.ok(proof.ops[0] === 'startGame');
|
||||
assert.ok(proof.ops.includes('startDay'));
|
||||
assert.ok(proof.ops.includes('confirmSettle'));
|
||||
assert.ok(proof.ops.some((o) => o.startsWith('buyUpgrade(')));
|
||||
});
|
||||
|
||||
test('闭环证明确定性:同种子重跑逐字段一致', () => {
|
||||
const a = proveCoreLoop(20260612);
|
||||
const b = proveCoreLoop(20260612);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
test('失败态可达:摆烂经营 → 罚没 + 停业(非只胜不败)', () => {
|
||||
const proof = proveFailure();
|
||||
assert.ok(proof.ok, `失败态证明未达成(${proof.days} 天内未停业)`);
|
||||
assert.ok(proof.reason === 'reputation' || proof.reason === 'bankrupt', `失败原因应为声誉/破产,实际 ${proof.reason}`);
|
||||
assert.ok(proof.events.some((s) => s.includes('fined')), '失败路径上应出现罚没');
|
||||
assert.ok(proof.events.some((s) => s.includes('gameover')), '应出现失败终态事件');
|
||||
});
|
||||
100
game-runtime/games/wanglanmei-ref/test/panel-draw.test.mjs
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* panel-draw.test.mjs — 各阶段面板绘制路径冒烟(白盒造状态 + 伪 2D 上下文)
|
||||
* 覆盖 headless 全链路未触达的绘制分支:带罚款的日结 / 段落小结 / 失败终态 / 带存档的标题 / prep。
|
||||
* 任何绘制路径运行时错误(空引用/NaN 入参等)在此抛出;像素正确性留集成段。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHostDevContext, SeededRandom } from '../../../src/core/plugin.js';
|
||||
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
|
||||
import { createShopCore } from '../src/shop-core.js';
|
||||
import { createPalette } from '../src/palette.js';
|
||||
import { createHostFx } from '../src/hostfx.js';
|
||||
import { drawFrame } from '../src/render.js';
|
||||
|
||||
/** 伪 2D 上下文(与 headless-boot 同构)。 */
|
||||
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 fns = new Map();
|
||||
return new Proxy(base, {
|
||||
get(t, p) {
|
||||
if (p in t) return t[p];
|
||||
if (!fns.has(p)) fns.set(p, () => {});
|
||||
return fns.get(p);
|
||||
},
|
||||
set(t, p, v) {
|
||||
t[p] = v;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 组装最小 view。 */
|
||||
function makeView(core) {
|
||||
const bundle = createHostDevContext({ context2d: null, seed: 5 });
|
||||
const juice = createParticlesJuicePlugin({ seed: 5 });
|
||||
juice.init(bundle.deriveContextFor('particles-juice', 'particles-juice'));
|
||||
const fx = createHostFx({ bundle, juice });
|
||||
return {
|
||||
core,
|
||||
pal: createPalette(),
|
||||
assets: { images: {}, missing: [], loaded: 0, total: 0 }, // 全程序化回退分支
|
||||
fx,
|
||||
ui: { bubbleText: '试营业~', bubbleUntil: 9999, keeperWaveUntil: 0, tickerText: 't', banner: { text: '高峰来了!', color: 'brick', dur: 1000, until: 500 }, panelSlide: 1, settleAnim: 1, ghosts: [{ slot: 0, avatar: 1, emote: '怒', until: 400 }] },
|
||||
nowMs: 100,
|
||||
customerBornMs: {},
|
||||
hasSave: true,
|
||||
};
|
||||
}
|
||||
|
||||
test('五阶段面板绘制零运行时错误(含罚款行/小结/失败态/存档标题)', () => {
|
||||
const g = makeFake2d();
|
||||
const core = createShopCore({ random: new SeededRandom(5) });
|
||||
const view = makeView(core);
|
||||
|
||||
// title(带存档按钮)。
|
||||
assert.equal(core.state.phase, 'title');
|
||||
drawFrame(g, view);
|
||||
|
||||
// prep(含升级网格/滑条/进货行)。
|
||||
core.startGame(null);
|
||||
core.buyStock('soda', 3);
|
||||
drawFrame(g, view);
|
||||
|
||||
// open(造几位顾客 + 各 mood 分支)。
|
||||
core.startDay();
|
||||
for (let i = 0; i < 240; i++) core.update(1 / 30);
|
||||
assert.equal(core.state.phase, 'open');
|
||||
drawFrame(g, view);
|
||||
|
||||
// settle(白盒注入带罚款报表)。
|
||||
core.state.phase = 'settle';
|
||||
core.state.settle = {
|
||||
day: 1, revenueJiao: 800, soldCostJiao: 300, rentJiao: 400, fineJiao: 260, netJiao: -160,
|
||||
served: 12, angry: 3, stockout: 2, walkaway: 1, rescued: 1, maxCombo: 4, repBackTenth: 15, reputation: 28,
|
||||
};
|
||||
drawFrame(g, view);
|
||||
|
||||
// summary。
|
||||
core.state.phase = 'summary';
|
||||
core.state.summary = { segment: 1, days: 3, netJiao: 520, served: 60, upgradesBought: 2, reputation: 66 };
|
||||
drawFrame(g, view);
|
||||
|
||||
// gameover(两种失败原因)。
|
||||
core.state.phase = 'gameover';
|
||||
core.state.gameoverReason = 'reputation';
|
||||
drawFrame(g, view);
|
||||
core.state.gameoverReason = 'bankrupt';
|
||||
drawFrame(g, view);
|
||||
});
|
||||
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* save-roundtrip.test.mjs — 存档读写往返(P9 save-progress 插件公开 API,内存 adapter)
|
||||
* 证明:游戏进度经 P9 set/get 往返后逐字段一致,且坏档/缺档安全回退新开局。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PluginRegistry, createHostDevContext, SeededRandom } from '../../../src/core/plugin.js';
|
||||
import { createSaveProgressPlugin, createMemoryAdapter } from '../../../src/plugins/save-progress/impl.js';
|
||||
import { createShopCore } from '../src/shop-core.js';
|
||||
|
||||
/** 经注册器把 P9 接上受控面(与浏览器集成同形,仅 adapter 不同)。 */
|
||||
function makeSave() {
|
||||
const { context } = createHostDevContext({ context2d: null });
|
||||
const reg = new PluginRegistry();
|
||||
reg.useContext(context);
|
||||
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: createMemoryAdapter() });
|
||||
reg.register(save);
|
||||
const r = reg.initAll();
|
||||
assert.ok(r.ok, 'P9 init 应成功');
|
||||
return { save, reg };
|
||||
}
|
||||
|
||||
test('存档往返:玩出进度 → snapshot → P9 set/get → restore → 快照逐字段一致', () => {
|
||||
const { save } = makeSave();
|
||||
|
||||
// ① 玩出一点真实进度(升级 + 进货 + 调价)。
|
||||
const core1 = createShopCore({ random: new SeededRandom(99) });
|
||||
core1.startGame(null);
|
||||
core1.buyUpgrade('speed1');
|
||||
core1.buyStock('soda', 4);
|
||||
core1.buyStock('bread', 2);
|
||||
core1.setPrice(1.25);
|
||||
const snap1 = core1.snapshotForSave();
|
||||
|
||||
// ② 写入 P9(内存 adapter)→ 读回。
|
||||
assert.ok(save.set('profile', snap1), 'P9 set 应成功');
|
||||
const loaded = save.get('profile');
|
||||
assert.deepEqual(loaded, snap1, 'P9 往返后逐字段一致');
|
||||
|
||||
// ③ 用读回的存档恢复新核 → 再快照应与原快照一致(续档语义完整)。
|
||||
const core2 = createShopCore({ random: new SeededRandom(99) });
|
||||
const r = core2.startGame(loaded);
|
||||
assert.ok(r.ok && r.continued, '应走续档路径');
|
||||
assert.equal(core2.state.phase, 'prep', '续档恢复到存档日 prep');
|
||||
assert.deepEqual(core2.snapshotForSave(), snap1);
|
||||
assert.equal(core2.derived().serveCooldownSec, 0.42, '升级派生参数随档恢复');
|
||||
});
|
||||
|
||||
test('坏档容错:版本不符 / 结构残缺 → 拒绝并回退新开局(不崩)', () => {
|
||||
const { save } = makeSave();
|
||||
save.set('profile', { v: 999, day: 3 }); // 版本不符
|
||||
const core = createShopCore({ random: new SeededRandom(5) });
|
||||
const r = core.startGame(save.get('profile'));
|
||||
assert.ok(r.ok);
|
||||
assert.equal(r.continued, false, '坏档应回退新开局');
|
||||
assert.equal(core.state.day, 1);
|
||||
// 缺档(undefined)同样回退。
|
||||
const core2 = createShopCore({ random: new SeededRandom(5) });
|
||||
const r2 = core2.startGame(save.get('missing_key'));
|
||||
assert.equal(r2.continued, false);
|
||||
});
|
||||
|
||||
test('命名空间隔离 + 清档:clear 只清本命名空间', () => {
|
||||
const { context } = (() => ({ context: createHostDevContext({ context2d: null }).context }))();
|
||||
const reg = new PluginRegistry();
|
||||
reg.useContext(context);
|
||||
const adapter = createMemoryAdapter();
|
||||
const mine = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter });
|
||||
const other = createSaveProgressPlugin({ namespace: 'other-game', adapter });
|
||||
reg.register(mine); // 同名插件只能注册一个;other 直接 init(P9 init 不读 ctx)
|
||||
reg.initAll();
|
||||
other.init(context);
|
||||
mine.set('profile', { a: 1 });
|
||||
other.set('profile', { b: 2 });
|
||||
assert.equal(mine.clear(), 1, '只清掉本命名空间 1 条');
|
||||
assert.deepEqual(other.get('profile'), { b: 2 }, '他人命名空间不受影响');
|
||||
});
|
||||
336
game-runtime/games/wanglanmei-ref/test/shop-core.test.mjs
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* shop-core.test.mjs — 营业日状态机测试(确定性驱动:SeededRandom + 固定步长)
|
||||
* 覆盖:阶段流转 / 接待收钱连击 / 耐心耗尽掉声誉 / 缺货流失 / 救单宽限 /
|
||||
* 日结账目(房租/罚没)/ 失败终态两条路径 / 升级效果 / 定价钳制。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SeededRandom } from '../../../src/core/plugin.js';
|
||||
import { createShopCore } from '../src/shop-core.js';
|
||||
import { REP, ECON, DAY, PATIENCE, priceJiaoOf } from '../src/balance.js';
|
||||
|
||||
const STEP = 1 / 30;
|
||||
|
||||
/** 造一局并跳到 prep。@param {number} seed */
|
||||
function newGame(seed) {
|
||||
const core = createShopCore({ random: new SeededRandom(seed) });
|
||||
core.startGame(null);
|
||||
core.drainEvents();
|
||||
return core;
|
||||
}
|
||||
|
||||
/** 推进直到谓词成立或超时(秒)。@returns {boolean} 是否在时限内成立 */
|
||||
function runUntil(core, predicate, maxSec) {
|
||||
const steps = Math.ceil((maxSec || 60) / STEP);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
core.update(STEP);
|
||||
if (predicate()) return true;
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
/** 收集事件直到谓词命中(事件保留返回)。 */
|
||||
function collectUntil(core, typeWanted, maxSec) {
|
||||
/** @type {any[]} */
|
||||
const all = [];
|
||||
const ok = runUntil(
|
||||
core,
|
||||
() => {
|
||||
all.push(...core.drainEvents());
|
||||
return all.some((e) => e.type === typeWanted);
|
||||
},
|
||||
maxSec
|
||||
);
|
||||
return { ok, events: all };
|
||||
}
|
||||
|
||||
test('阶段流转:title→(startGame)prep→(startDay)open;非法阶段命令被拒', () => {
|
||||
const core = createShopCore({ random: new SeededRandom(7) });
|
||||
assert.equal(core.state.phase, 'title');
|
||||
assert.equal(core.serve('soda').ok, false); // title 不能接待
|
||||
core.startGame(null);
|
||||
assert.equal(core.state.phase, 'prep');
|
||||
assert.equal(core.confirmSettle().ok, false); // prep 不能确认日结
|
||||
assert.ok(core.buyStock('soda', 2).ok);
|
||||
assert.ok(core.startDay().ok);
|
||||
assert.equal(core.state.phase, 'open');
|
||||
assert.equal(core.buyStock('soda', 1).ok, false, '营业中禁止进货');
|
||||
assert.equal(core.setPrice(1.2).ok, false, '营业中禁止调价');
|
||||
});
|
||||
|
||||
test('进货:资金占用 + 货架容量上限 + 本次 prep 可退', () => {
|
||||
const core = newGame(11);
|
||||
const cash0 = core.state.cash;
|
||||
assert.ok(core.buyStock('soda', 5).ok);
|
||||
assert.equal(core.state.cash, cash0 - 5 * 20, '进货立即占用资金(汽水进价 2 元/件)');
|
||||
assert.equal(core.state.stock.soda, 5);
|
||||
// 容量上限(基础 24)。
|
||||
assert.ok(core.buyStock('snack', 19).ok);
|
||||
assert.equal(core.buyStock('bread', 1).ok, false, '24 件满架后再买应被拒');
|
||||
assert.equal(core.buyStock('bread', 1).reason, 'shelf_full');
|
||||
// 退货(仅退本次 prep 买的,原价)。
|
||||
const cashBefore = core.state.cash;
|
||||
assert.ok(core.refundStock('soda', 2).ok);
|
||||
assert.equal(core.state.cash, cashBefore + 2 * 20);
|
||||
assert.equal(core.state.stock.soda, 3);
|
||||
// 未解锁商品不可进货。
|
||||
assert.equal(core.buyStock('toy', 1).ok, false);
|
||||
});
|
||||
|
||||
test('定价:钳制 [0.8,1.5] 且吸附 0.05 步进', () => {
|
||||
const core = newGame(13);
|
||||
core.setPrice(0.3);
|
||||
assert.equal(core.state.priceMul, 0.8);
|
||||
core.setPrice(9);
|
||||
assert.equal(core.state.priceMul, 1.5);
|
||||
core.setPrice(1.13);
|
||||
assert.equal(core.state.priceMul, 1.15);
|
||||
});
|
||||
|
||||
test('接待闭环:到客→接待→收钱(价随定价)→冷却→连击小费', () => {
|
||||
const core = newGame(21);
|
||||
core.buyStock('soda', 8);
|
||||
core.buyStock('snack', 8);
|
||||
core.buyStock('bread', 8);
|
||||
core.setPrice(1.0);
|
||||
core.startDay();
|
||||
core.drainEvents();
|
||||
|
||||
// 等到至少 2 位顾客在场(闲时间隔 ~4s,两位 ~9s 内必到)。
|
||||
assert.ok(runUntil(core, () => core.state.customers.length >= 2, 30), '30s 内应有两位顾客');
|
||||
const c1 = core.state.customers[0];
|
||||
const cashBefore = core.state.cash;
|
||||
const r1 = core.serve(c1.productId);
|
||||
assert.ok(r1.ok);
|
||||
assert.equal(r1.combo, 1);
|
||||
assert.equal(r1.revenueJiao, priceJiaoOf(c1.productId, 1.0), '首单无小费=标价');
|
||||
assert.equal(core.state.cash, cashBefore + r1.revenueJiao);
|
||||
// 冷却期内再接待被拒。
|
||||
const c2 = core.state.customers[0];
|
||||
assert.equal(core.serve(c2 ? c2.productId : 'soda').reason, 'cooldown');
|
||||
// 等冷却结束(0.55s),第二单落在连击窗(2.6s)内 → combo=2,小费 +4%。
|
||||
assert.ok(runUntil(core, () => core.state.serveCooldown === 0, 2));
|
||||
const c2b = core.state.customers.find((c) => c.mood !== 'stockout');
|
||||
assert.ok(c2b, '应仍有可接待顾客');
|
||||
const r2 = core.serve(c2b.productId);
|
||||
assert.ok(r2.ok);
|
||||
assert.equal(r2.combo, 2);
|
||||
assert.equal(r2.revenueJiao, Math.round(priceJiaoOf(c2b.productId, 1.0) * 1.04), '2 连击小费 +4%');
|
||||
assert.equal(core.state.today.maxCombo, 2);
|
||||
});
|
||||
|
||||
test('耐心耗尽:先进救单宽限(grace 事件)再愤怒离店掉声誉 −4', () => {
|
||||
const core = newGame(31);
|
||||
core.buyStock('soda', 8);
|
||||
core.buyStock('snack', 8);
|
||||
core.buyStock('bread', 8); // 备足货 → 排除缺货路径
|
||||
core.startDay();
|
||||
const repBefore = core.state.repTenth;
|
||||
const got = collectUntil(core, 'angry_leave', 40); // 闲时耐心 ~13s,留足余量
|
||||
assert.ok(got.ok, '不接待应出现愤怒离店');
|
||||
const graceIdx = got.events.findIndex((e) => e.type === 'customer_grace');
|
||||
const angryIdx = got.events.findIndex((e) => e.type === 'angry_leave');
|
||||
assert.ok(graceIdx >= 0 && graceIdx < angryIdx, '愤怒离店前必先进入救单宽限');
|
||||
const angryCount = got.events.filter((e) => e.type === 'angry_leave').length;
|
||||
assert.equal(core.state.repTenth, repBefore - angryCount * REP.angryLossTenth, '每个差评 −4.0 声誉');
|
||||
assert.ok(got.events.some((e) => e.type === 'customer_impatient'), '低耐心应触发不耐烦事件');
|
||||
});
|
||||
|
||||
test('救单宽限:宽限期内接待成功,rescued 标记 + 额外声誉奖励', () => {
|
||||
const core = newGame(33);
|
||||
core.buyStock('soda', 8);
|
||||
core.buyStock('snack', 8);
|
||||
core.buyStock('bread', 8);
|
||||
core.startDay();
|
||||
// 等第一位顾客进入宽限态。
|
||||
assert.ok(
|
||||
runUntil(core, () => core.state.customers.some((c) => c.mood === 'leaving'), 40),
|
||||
'应出现进入宽限态的顾客'
|
||||
);
|
||||
const leaving = core.state.customers.find((c) => c.mood === 'leaving');
|
||||
const repBefore = core.state.repTenth;
|
||||
const r = core.serve(leaving.productId);
|
||||
assert.ok(r.ok && r.rescued, '宽限期内接待应成功且标记 rescued');
|
||||
assert.equal(core.state.repTenth, repBefore + REP.serveGainTenth + REP.rescueBonusTenth);
|
||||
assert.equal(core.state.today.rescued, 1);
|
||||
});
|
||||
|
||||
test('缺货:进店即失望停留 → 离店掉声誉 −2.5;空货架点击返回 no_stock', () => {
|
||||
const core = newGame(41);
|
||||
// 全程不进货 → 所有顾客都是 stockout 路径。
|
||||
core.startDay();
|
||||
const repBefore = core.state.repTenth;
|
||||
const got = collectUntil(core, 'stockout_leave', 30);
|
||||
assert.ok(got.ok, '缺货顾客应失望离店');
|
||||
const n = got.events.filter((e) => e.type === 'stockout_leave').length;
|
||||
assert.equal(core.state.repTenth, repBefore - n * REP.stockoutLossTenth);
|
||||
// 空货架点击。
|
||||
const r = core.serve('soda');
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'no_stock');
|
||||
});
|
||||
|
||||
test('日结账目:勤快经营一天 → settle 报表自洽(净利=营收−货成本−房租−罚款),房租入账', () => {
|
||||
const core = newGame(51);
|
||||
core.buyStock('soda', 8);
|
||||
core.buyStock('snack', 9);
|
||||
core.buyStock('bread', 6);
|
||||
core.setPrice(1.15);
|
||||
core.startDay();
|
||||
// 勤快策略:能接就接。
|
||||
runUntil(
|
||||
core,
|
||||
() => {
|
||||
if (core.state.phase !== 'open') return true;
|
||||
if (core.state.serveCooldown === 0) {
|
||||
const c = core.state.customers.find(
|
||||
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
|
||||
);
|
||||
if (c) core.serve(c.productId);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
DAY.lengthSec + 10
|
||||
);
|
||||
assert.equal(core.state.phase, 'settle', '营业日走完应进日结');
|
||||
const s = core.state.settle;
|
||||
assert.ok(s.served > 0, '应有成交');
|
||||
assert.equal(s.netJiao, s.revenueJiao - s.soldCostJiao - s.rentJiao - s.fineJiao, '净利公式自洽');
|
||||
assert.equal(s.rentJiao, ECON.rentBaseJiao, '第 1 段落房租=基础房租');
|
||||
assert.equal(s.fineJiao, 0, '声誉健康不应被罚');
|
||||
// 日结确认 → 第 2 天 prep。
|
||||
assert.ok(core.confirmSettle().ok);
|
||||
assert.equal(core.state.phase, 'prep');
|
||||
assert.equal(core.state.day, 2);
|
||||
});
|
||||
|
||||
test('罚没:日结时声誉 < 30 → 罚款 = 基数 + 差评加罚(fined 事件)', () => {
|
||||
const core = newGame(61);
|
||||
core.buyStock('soda', 8);
|
||||
core.buyStock('snack', 8);
|
||||
core.buyStock('bread', 8);
|
||||
core.startDay();
|
||||
// 推到临近打烊,然后白盒制造「低声誉但现金充裕」局面(隔离罚没路径,不连带破产)。
|
||||
runUntil(core, () => core.state.dayTimeSec >= DAY.lengthSec - 2, DAY.lengthSec + 5);
|
||||
core.state.repTenth = 200; // 20 分 < 罚没阈值 30,> 0 不触发声誉失败
|
||||
core.state.cash = 8000; // 现金充裕 → 罚得起,不触发破产失败
|
||||
const got = collectUntil(core, 'day_end', 5);
|
||||
assert.ok(got.ok);
|
||||
assert.ok(got.events.some((e) => e.type === 'fined'), '应触发罚没事件');
|
||||
const s = core.state.settle;
|
||||
assert.equal(s.fineJiao, ECON.fineBaseJiao + s.angry * ECON.finePerAngryJiao, '罚款=基数+每差评加罚');
|
||||
assert.equal(core.state.phase, 'settle', '被罚但未死 → 正常进日结');
|
||||
});
|
||||
|
||||
test('失败终态①:声誉跌到 0 → 日结判停业(reputation)', () => {
|
||||
const core = newGame(71);
|
||||
core.buyStock('soda', 8);
|
||||
core.buyStock('snack', 8);
|
||||
core.buyStock('bread', 8);
|
||||
core.startDay();
|
||||
runUntil(core, () => core.state.dayTimeSec >= DAY.lengthSec - 2, DAY.lengthSec + 5);
|
||||
core.state.repTenth = 0; // 白盒:声誉归零
|
||||
runUntil(core, () => core.state.phase !== 'open', 5);
|
||||
assert.equal(core.state.phase, 'gameover');
|
||||
assert.equal(core.state.gameoverReason, 'reputation');
|
||||
});
|
||||
|
||||
test('失败终态②:日结付不起房租 → 停业(bankrupt)', () => {
|
||||
const core = newGame(73);
|
||||
core.buyStock('soda', 8);
|
||||
core.buyStock('snack', 8);
|
||||
core.buyStock('bread', 8);
|
||||
core.startDay();
|
||||
runUntil(core, () => core.state.dayTimeSec >= DAY.lengthSec - 2, DAY.lengthSec + 5);
|
||||
core.state.cash = 100; // 白盒:只剩 ¥10,房租 ¥40 付不起
|
||||
core.state.repTenth = 800; // 声誉健康 → 隔离破产路径(不被声誉失败抢跑)
|
||||
runUntil(core, () => core.state.phase !== 'open', 5);
|
||||
assert.equal(core.state.phase, 'gameover');
|
||||
assert.equal(core.state.gameoverReason, 'bankrupt');
|
||||
});
|
||||
|
||||
test('升级:资金扣减 + 前置校验 + 商品解锁 + 收银冷却缩短生效', () => {
|
||||
const core = newGame(81);
|
||||
core.state.cash = 3000; // 白盒:给足资金,专测升级语义(开局资金能否买齐由闭环证明管)
|
||||
assert.equal(core.buyUpgrade('speed2').reason, 'requires', '未买 I 不能买 II');
|
||||
const cash0 = core.state.cash;
|
||||
assert.ok(core.buyUpgrade('speed1').ok);
|
||||
assert.equal(core.state.cash, cash0 - 550);
|
||||
assert.equal(core.buyUpgrade('speed1').reason, 'owned', '不可重复购买');
|
||||
assert.equal(core.derived().serveCooldownSec, 0.42, '收银冷却生效');
|
||||
// 解锁新品后可进货。
|
||||
assert.ok(core.buyUpgrade('unlock_ice').ok);
|
||||
assert.ok(core.state.unlocked.includes('ice'));
|
||||
assert.ok(core.buyStock('ice', 2).ok);
|
||||
// 资金不足被拒。
|
||||
core.state.cash = 10;
|
||||
assert.equal(core.buyUpgrade('decor1').reason, 'no_cash');
|
||||
});
|
||||
|
||||
test('三日一段落:第 3 天日结确认 → 段落小结 → 确认 → 第 4 天 prep', () => {
|
||||
const core = newGame(91);
|
||||
for (let day = 1; day <= 3; day++) {
|
||||
// 每日按「补到 8 件」备货(防缺货流失把声誉拖死,能完整跑满 3 天)。
|
||||
for (const id of ['soda', 'snack', 'bread']) {
|
||||
const need = 8 - (core.state.stock[id] || 0);
|
||||
if (need > 0) core.buyStock(id, need);
|
||||
}
|
||||
core.startDay();
|
||||
runUntil(
|
||||
core,
|
||||
() => {
|
||||
if (core.state.phase !== 'open') return true;
|
||||
if (core.state.serveCooldown === 0) {
|
||||
const c = core.state.customers.find(
|
||||
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
|
||||
);
|
||||
if (c) core.serve(c.productId);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
DAY.lengthSec + 10
|
||||
);
|
||||
assert.equal(core.state.phase, 'settle', `第 ${day} 天应到日结`);
|
||||
core.confirmSettle();
|
||||
if (day < 3) {
|
||||
assert.equal(core.state.phase, 'prep');
|
||||
}
|
||||
}
|
||||
assert.equal(core.state.phase, 'summary', '第 3 天后应出段落小结');
|
||||
assert.equal(core.state.summary.segment, 1);
|
||||
assert.ok(core.confirmSummary().ok);
|
||||
assert.equal(core.state.phase, 'prep');
|
||||
assert.equal(core.state.day, 4);
|
||||
});
|
||||
|
||||
test('确定性:同种子同操作 → 事件流逐字段一致;异种子 → 到店节拍不同', () => {
|
||||
/** 跑固定脚本收事件。@param {number} seed */
|
||||
function run(seed) {
|
||||
const core = createShopCore({ random: new SeededRandom(seed) });
|
||||
core.startGame(null);
|
||||
core.buyStock('soda', 6);
|
||||
core.buyStock('snack', 6);
|
||||
core.setPrice(1.1);
|
||||
core.startDay();
|
||||
const events = [];
|
||||
for (let i = 0; i < 30 * 30; i++) {
|
||||
core.update(STEP);
|
||||
if (core.state.serveCooldown === 0) {
|
||||
const c = core.state.customers.find(
|
||||
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
|
||||
);
|
||||
if (c) core.serve(c.productId);
|
||||
}
|
||||
events.push(...core.drainEvents());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
const a = JSON.stringify(run(123));
|
||||
const b = JSON.stringify(run(123));
|
||||
const c = JSON.stringify(run(456));
|
||||
assert.equal(a, b, '同种子完全可复现');
|
||||
assert.notEqual(a, c, '异种子应产生不同节拍');
|
||||
});
|
||||