下载 App
【聚光灯21天】DAY 13–16 · H5 版四天:从六个页面到能上线
修改于10/204 浏览 包含 AI 合成内容

DAY 12 封存了 Lua 版、开了 H5 的壳。这四天把整个游戏在浏览器里重建,并推到接近可上线。
四天的主线:
| 天 | 做了什么 |
|---|---|
| DAY 13 | 六个页面 + 罗盘 + 双音频系统 + 四条资产管线 + 七套浏览器验收 |
| DAY 14 | 全量审查 → 修 5 条高危 + 15 条中危;存档语义换代(v2.2.0 轮次存档) |
| DAY 15 | 存档页重做(自动档 6 条 + 暂存)、设置页改滑杆、剧本内容缩减 |
| DAY 16 | TapTap 云存档对接 + 青史图鉴 + 朝代预告页 + 封面/分享卡素材 |


一、为什么能一天做完六个页面
因为规则早就写好了,今天只做"渲染层"。
这个项目的规则真源一直是 JS(prot/engine.js),Lua 是它的逐位移植。所以 H5 版:
ts复制
ts// h5/vite.config.ts alias: { '@engine': resolve(repoRoot, 'prot/engine.js'), // 规则:原样复用 '@data': resolve(repoRoot, 'prot/lua-data'), // 数据:原样 import }
不拷贝、不转译。 prot/ 的测试因此继续守护同一份代码。
于是活就变成了纯粹的**“把状态渲染成 DOM”**。game/session.ts 933 行,控制流严格对齐已封存的 scripts/qingshi/ui/game.lua。
六个页面 + 罗盘
| 模块 | 行数 | 说明 |
|---|---|---|
| ui/menu.ts | 263 | 主菜单 |
| ui/select.ts | 286 | 选择页(剧本卡 + 难度 + 入局漩涡) |
| ui/wheel.ts | 299 | 朝代罗盘 |
| ui/settings.ts | 115 | 设置页 |
| ui/savepage.ts | 107 | 存档页 |
| ui/cast.ts | 77 | 立绘列 |
| game/session.ts | 933 | 会话与流程 |
罗盘是最难的一块。 wheel.ts 头注写明:模型与手感参数与 Lua 完全一致 ——
- 朝代名固定在自己的方位(第 1 项在正上方),不随盘转
- 盘面(环带 + 烘焙在顶部的指针)旋转,由指针去指当前选中的那一格
- 拖拽跟手 → 松手惯性 → 欠阻尼弹簧吸附(冲过再回摆)→ 整体归位
与 Lua 的差异只在渲染层:NanoVG 即时绘制换成 DOM + CSS transform,每帧只写 transform/位置,不重排布局。
这块的手感在 Lua 版调了很多轮(DAY 8 的"罗盘四版"),今天照搬结论,不重新调。


二、DAY 14:一次审查,修掉 5 高 15 中
DAY 13 搭得快,DAY 14 就做了全量审查(三路并行深审 + 主审查人逐条核实)。
基线:tsc --noEmit 通过、8/8 套浏览器测试全绿、数据门禁通过。
报告里最有价值的一句话
最值得警惕的模式是「注释里正确诊断了问题,代码却没跟上自己的诊断」。
两个实例:
- 懒加载被自检抵消 —— scenarios.ts 精心做了按需加载,但 main.ts 的启动自检串行拉全部五份 JSON(280KB),首屏被迫下载全部数据。注释还自承"这是刻意取舍"。
- 版本号第三副本 —— vite.config.ts 注入 __APP_VERSION__ 并专门注释警告"版本号漂移事故",而 settings.ts 又手抄了一份 APP_VERSION,注入值全仓零引用。下次升版本必然静默漂移 —— 正是注释警告的那件事。
五条高危
| # | 问题 | 后果 |
|---|---|---|
| H1 | 「回溯」按钮每开一局累积一个,旧按钮闭包绑定旧对局 | 第二局起顶栏叠出 N 个按钮;点旧的会污染正在进行的对局 |
| H2 | 顶栏返回钮每次开局 addEventListener 且从不移除 | 第 N 局点返回会触发 N 次 goSelect() |
| H3 | 存档页「存 入」是永远走不通的死功能 | 所有入口都先 session = null;且返回路径硬编码 'normal' 重开,难度被悄悄改写 |
| U-M1 | 罗盘缺 pointercancel 处理 | 移动端切后台 → 相位永久卡在 drag |
| M1 | 启动自检抵消懒加载 | 首屏多下 280KB |
H1 和 H2 是同一个根因:shell.bar 是常驻元素,而每局的 Session 往上面挂东西却从不摘除。
修复:两个"出口"侧的洞
ts复制
ts// Session.dispose():对局离场时必须调 // bar/main 都是常驻壳节点,挂上去的回溯按钮与 pointerdown 监听 // 不摘掉的话,每开一局就叠一层 —— 旧按钮的闭包还绑着旧 Game, // 点了会污染正在进行的对局。
wireBack 改成幂等:用 Symbol 属性记住旧回调,先 removeEventListener 再挂新。
修完后测试从 8 套扩到 11 套 —— 新增 ending / settings / hardcore 三套,专补审查指出的"测试盲测面"。


三、DAY 14 重头:存档从「回到章首」改成「回到那一刻」
旧语义的问题
原来的存档是章首档:读档 = 回到本章开头。
问题是:一章里有 3–5 轮经营,每轮 3 张牌。 玩家玩到第 4 轮想存档去吃饭 —— 回来发现前 3 轮白打了。
新语义:两件套(v2.2.0)
snapshot 章首快照(供章内回溯用)
live 当前时刻的全量状态(发牌/相位/随机轨迹)
最硬的一块:随机轨迹也要存
原设计有条原则写得明明白白:
回溯恢复历史状态,不恢复随机命运(不存 rng 状态)
理由是 SL 问题:随机数能存能读,玩家就能"穷举读穿骰子"。
但跨会话存档是另一回事 —— 你存了档关掉游戏,明天打开,总不能让世界重新掷骰子。
所以取舍是分开对待:
| 场景 | 随机数 | 理由 |
|---|---|---|
| 章内回溯 | 不存,重新掷 | 防 SL:可以试错,不可重播 |
| 跨会话读档 | 存,原样续接 | 存档是"暂停",不是"重开" |
实现上让 rng 可序列化:
js复制
jsf._getState = function () { return a | 0; }; // 取出内部状态 f._setState = function (s) { a = s | 0; }; // 写回
主 rng 与已派生的流(risk / event / corr)都随档存取。
四类相位的 round-trip 测试
新语义最容易错的是"相位"—— 存档时可能停在四种不同相位:
| 测试 | 存/读的位置 |
|---|---|
| A | round —— 第 2 轮已发牌时存;读档后相位/手牌/倒计时/属性/计次一致 |
| B | 随机轨迹 —— 同一存档读进两个新对局,后续同操作逐位一致 |
| C | choice —— 抉择中存档;读档仍在抉择页 |
| D | summary —— 章末结算中存档;读档可推进下一章 |
B 是最关键的一条:它证明随机轨迹真的随档恢复了。
顺带补的两道校验
① 剧本身份绑定(scenKey) —— 五剧本属性维度同名,原校验只看难度,于是跨剧本的档能导入他本对局(带着别人的 flags 进不可达内容)。
② 脏档拒收 —— 存档存在 localStorage,是不可信输入:
js复制
jsif (!this.d.ATTR_NAMES[ak2]) return false; // 未知属性键 = 跨剧本脏档 if (!numRange(s.attrs[ak2], 0, this.attrMax(ak2))) return false; // 范围须落在本剧本上限内


四、DAY 15:存档页重做 + 设置页改滑杆
存档结构(用户二次拍板)
| 类型 | 键 | 数量 | 写入时机 |
|---|---|---|---|
| 自动档 | qingshi.autos.v1 | 每剧本最多 6 条 | 只有一个:对局页点「返回」时玩家确认 |
| 暂存 | qingshi.stash.v1 | 每剧本只留最近 1 条 | 关页/刷新(pagehide)自动落,防丢兜底 |
原来的 6 个手动槽位,改成展示这 6 条自动档 —— 不再有手动存。从"玩家自己管理存档槽"变成"系统自动管,玩家只管要不要留"。
槽位还会显示轮次标识(经营过才显示)—— 因为存档现在能精确到轮次了。
设置页:开关+三档 → 横向滑杆
音频三项(音乐/音效/朗读)各一条横向滑杆:拖到最左即静音,
没有独立开关键(2026-10-15 用户拍板,参照常见音量滑杆的交互)。
滑杆左侧的喇叭图标随音量档位变化:0 打叉 / 低无波 / 中一波 / 高两波。
一个实现细节值得记:
拖动期间不重画整行(重画会销毁滑杆、打断拖拽),只就地更新图标与填充。
老存档的三档值(0.3/0.6/0.9)直接映射为滑杆位置 30/60/90,无需迁移。
剧本内容缩减
建了 备份_缩减前_2026-10-15,对五本剧本做了一轮压缩:
| 剧本 | 行数变化 |
|---|---|
| 李斯 | 5,896 → 5,793 |
| 荆轲 | 4,838 → 4,803 |
| 韩信 | 5,135 → 5,086 |
| 项羽 | 4,899 → 4,875 |
| 陈胜 | 4,668 → 4,629 |


五、DAY 16:TapTap 云存档 + 图鉴 + 朝代预告
云存档(game/tapcloud.ts)
这是第一个"平台相关"的模块。运行环境是 TapTap H5 容器注入的全局 tap 对象;普通浏览器没有 tap,本模块全部降级为 no-op。
三个设计决策写在头注里:
① 整包单存档,不打散
本地是「自动档按剧本分桶 + 暂存每剧本一条」的多槽结构,云端不打散成一存档一槽(存档数量有上限,且逐槽上传会撞上「一分钟一次」的频控),而是把两个 store 打包成一个 JSON,对应云端唯一一份名为 qingshi_saves_v1 的存档。
② 冲突取舍:整包新者胜
云端 modifiedTime 比本地最近变动新 → 云端整包覆写本地(覆写前留本地备份);反之上传本地。官方 QA 建议的「逐档对比引导玩家选」对整包方案不适用,多设备同时玩的冲突场景接受这个粒度。
③ 频控:一分钟一次
本地每次写档只标脏(lastChange),由调度器在距上次上传满 60s 后(debounce 1.5s)合并上传;关页 pagehide 时若已满 60s 补一次末班车。
还留了测试挂钩:window.__QS_TAP_UPLOAD_INTERVAL__ 可覆写上传间隔,测试把它缩到 1.5s 级别,不用真等一分钟。
跨局收藏(game/collection.ts)
审查时发现的缺口:成就/图鉴只活在当局内存里,关页面就全丢。
新增跨局收藏,键 qingshi.col.v1.<scenId>,与封存 Lua 版的 col_v1_ 同代不同名(两个平台各自的收藏各自累计)。
脏档收窄不拒收 —— 结局达成记录(endings)是 H5 新增字段,旧档没有它,读取时按空表收窄,不因为缺一个字段就拒收整份。
青史图鉴(ui/gallery.ts)
版式:左栏三 panel(收集总览环 / 印章收集条 / 筛选),右栏按剧本分节的结局卡片(已达成实线卡 / 未达成虚线卡,同名同尺寸不跳动)。
一个取舍:
五个剧本 JSON 在这里会全部按需下载(与首屏瘦身是 trade-off):图鉴页本来就是「我要看全部剧本」的场景,且 loadScenario 有缓存,入过局的剧本不再重复下载。
未达成的结局只留谜面,不剧透名字与赞文。
朝代预告页(ui/teaser.ts)
未开卷朝代(汉/唐/宋/明)在选择页右列展示的内容,替代原先的竖排「准备中」:
① 题头(朝代大字 + 年代 + 英文 + 金线)
② 一句话定位(lede,可带朱色高亮段)
③ 时代关键词(胶囊样式)
④ 开卷进度(考据/编纂/校验/开卷 四节点三态图)
⑤ 即将登场(只显示四字剧名+年代,不显示人物名、无人立绘 —— 用户拍板)
⑥ 底注(回秦提示 + 罗盘提示)
测试扩到 16 套
四天里测试从 7 套扩到 16 套:
chain / viewports / backtrack / save / audio / voice / reveal
ending / settings / hardcore / stash / gallery / tapcloud ← 新增
scenarios(纯 Node)/ dev-check(专盯 dev 模式)
其中 hardcore.cjs 补的是一个从未被触发过的硬约束:硬核难度不可存档。
tapcloud.cjs 用 Page.addScriptToEvaluateOnNewDocument 在页面脚本前注入一个内存版 tap mock —— 真容器只在 TapTap 客户端里存在。


六、四天的数据
[图片:暂不支持黏贴,请手动导入]
| 项 | 值 |
|---|---|
| H5 源码 | 6,192 → 约 9,000 行(16 个 .ts 模块) |
| 浏览器验收 | 7 套 → 16 套 |
| engine.js | +298 / −64(轮次存档 + 两道校验) |
| h5 四天改动 | +3,686 / −1,363 |
| 新增引擎方法 | makeStateRng / makeLiveState / _importLive / _validStateFields |
| 审查 | 三路并行深审 → 5 高危 + 15 中危,当日全部修完 |
| 评级 | 5 级 → 10 级(甲乙丙上下中 + 丁) |
| 资产管线 | 4 条(美术 131MB→WebP / BGM 41MB / 配音 249MB / 截图) |


七、四天最值钱的三条
① 换载体能一天做完六个页面,不是因为写得快
是因为规则不在渲染层里。 DAY 6 把规则抽成 JS 真源 + Lua 移植,当时只是为了做对拍网。但 DAY 12 换载体、DAY 13 一天重建六个页面 —— 这份"规则与渲染分离"的架构,决定了项目的迁移成本。
② “存/读"不是"保存数据”,是"保存状态机"
做轮次存档时才发现难点不在"把更多字段塞进 JSON",而在:
- 相位 —— 四种不同相位都要能恢复
- 随机轨迹 —— 不只是"存几个数",是"让随机流能在另一个时间点续接"
- 可信边界 —— 存档来自 localStorage,是不可信输入
一个"存档功能"的复杂度,几乎等于它要保存的那个状态机的复杂度。
③ 最危险的 bug,是"注释诊断对了,代码没跟上"
审查报告里最值得记的一条:
最值得警惕的模式是「注释里正确诊断了问题,代码却没跟上自己的诊断」。
懒加载被自检抵消、版本号第三副本 —— 两个都是同一个模式:注释里写着"要注意 X",代码却做了非 X。
这类问题测试抓不到(代码本身没坏,只是没做它声称要做的事),review 才抓得到。


明天
- 真机验收 —— 云存档需在 TapTap 容器内实测(普通浏览器只能验降级路径)
- 配音全量转码 —— 试点已验证机制,--all 跑全量(约 100MB,谨慎)
- 首屏体积 —— 美术已瘦身,再核一遍实际下载量
- 成就图标 64 缺失 —— P3 美术批次待补
- 审查 backlog 的打磨项(低危清单)


载体:H5(Vite + TypeScript),规则引擎复用 prot/engine.js · 状态:六页面 + 罗盘 + 双音频 + 云存档 + 图鉴 + 朝代预告;16 套浏览器验收 · 存档语义换代(v2.2.0 轮次存档)· 验证:prot 全绿、tsc 通过、浏览器 11/11
#聚光灯gamejam开发者日志
#聚光灯gamejam开发者日志










