测试链接: https://elizaaaaa.github.io/RoadTripHTMLGame/
本目录是 design-doc.md 第 7 节清单对应的落地结果:一套可复用的 engine/,配一份非正式的 content/ 示例数据,验证"加油站出发 → 探索 → 复盘 → 发布 → 下一天 → 结局"这条主循环能跑通。没有正式故事文案——content/ 里的文字全部标了"占位"或在 _comment 字段里写明是示例,你写真实故事时替换这些 JSON 即可,原则上不用改 engine/ 里的代码。
全流程固定 3 天,每天时间窗口不同(配置在 content/days.json 每天数据的 startMin/endMin 字段,见 engine/time.js):第 1 天只有晚上(20:00-24:00),第 2、3 天是全天(08:00-24:00)。
index.html 用 fetch() 加载 content/*.json 和 assets/maps/hotspots.json,浏览器出于安全策略会拦截 file:// 协议下的本地 JSON 请求,所以不能直接双击 index.html 打开,需要起一个本地静态服务器,例如任选一种:
# Python(自带,最省事)
python -m http.server 8080
# Node
npx serve .然后访问它给出的地址(如 http://localhost:8080)。如果忘了起服务器直接打开文件,页面会自己弹出提示,不会是空白页或看不懂的报错。
design-doc.md 设计基准文档(唯一真源,改设计先改这里)
TODO.md 欠账清单:还缺哪些美术素材 / 界面 / 内容,做完就勾掉
index.html 页面骨架 + 状态栏/地图容器/弹窗容器,不含具体文案
engine/ 引擎代码,通用规则,不写死任何故事内容
state.js 全局状态结构定义 + 存档/读档(localStorage)+ 每天存档点快照/回退("重新度过今日"/"回到上一天")
time.js 时间系统:分钟计时钟、每天时间窗口(按天配置起止分钟)、超时判定
map.js 图片地图:百分比坐标热点渲染、缩放/平移、点击交互
signal.js 信号闪现:被动随机弹留言,池子来自当天 content
quake.js 地震演出:抖动 + 落灰 + 一拍死寂,按震级预设参数,零美术素材(用在 timedEvents 和结局上)
reveal.js 逐字/逐句浮现:把文本包成带序号的 span,动画交给 ui.css 的 .rv-*(只用在结局和地震这类要有分量的地方)
basecamp.js 加油站专属准入规则(翻旧报纸每日一次)
sanity.js 理智值:数值、三档阈值、UI 反馈 class
dice.js 1d20 掷骰:按理智档位决定取骰次数/取值规则
keyword-parser.js 解析 [[显示文字|key]] 语法为可点击 span
archive.js 档案库:解锁、分类、跨天主案进度计算
review.js 复盘:前置条件判定、选项提交、choiceLog 记录
publish.js 发布 vlog:播放量结算(超时未归判"被黑暗吞噬"坏结局,见 main.js handleDayOver(),不走这个模块)
vlogstats.js 发布之后弹出的"手机数据页":把播放量派生成点赞/评论/关注/完播率等互动数据并渲染
ending.js 结局判定:进度 × 理智值 2x2 矩阵 + 文案变体 + 后日谈(epilogue)分段
main.js 启动与流程编排,把上面这些模块接起来
ui.css 引擎侧通用界面样式(含理智值氛围反馈动效)
phone.css "手机数据页"专用样式(竖屏手机外壳 + 暖色玻璃拟态,跟 ui.css 的 macOS 灰调分开)
content/ 示例数据,非正式剧本,改这里不用碰 engine/
worldbuilding.md 已确认的世界观命名 canon(镇名/地名/公司名),写正式文案前先看这个
days.json 3 天示例:地点开放表、事件、掷骰事件、定时全局事件(两次地震)、信号池、复盘、旧报纸、每天时间窗口
archive.json 示例档案库词条(含嵌套 [[]] 互跳、unlockedBy、linkedMainCase)
endings.json 4 个结局的占位文案 + 后日谈 + choiceLog 文案变体示例
assets/maps/
white-lake-map-now.png 底图:白湖镇「现在」的卫星照片(1536x1024),一开局看到的就是它
white-lake-map.png 手绘旧地图(同尺寸同取景),地点解锁后在该点周围墨迹洇开露出来
placeholder-town-map.svg 已弃用的占位地图底图,留作对照,没有代码在引用它了
hotspots.json 热点坐标表(1 个 basecamp + 10 个 investigation)
tools/
coord-picker.html 独立的地图坐标拾取小工具,见下方说明
phone-preview.html "手机数据页"预览工具:不用真玩到发布就能看样式,见下方说明
quake-preview.html 地震演出 / 结局浮现预览工具:不用真玩到第 2 天上午就能调参,见下方说明
_legacy-reference/ 重构前参照的旧原型(另一个故事,仅供工程参考,不是本项目内容)
tools/coord-picker.html 是纯前端单文件小工具,不属于游戏本体,双击直接用浏览器打开就行(不用起本地服务器):拖一张地图图片进去(或用按钮选 / Ctrl+V 粘贴)→ 在图上点击想放标记的位置 → 右侧列表里改 id/name/type → 点「复制 JSON」或「下载 hotspots.json」,把结果贴进 assets/maps/hotspots.json。支持滚轮缩放、拖动平移、拖动已有标记微调位置,坐标定义和 hotspots.json 里的 x/y 完全一致(像素位置 ÷ 图片原始宽高 × 100,跟图片在页面里显示多大无关)。
每天在剪辑台点「确认发布」之后(当天配了复盘的话,是看完对错反馈点「完成」之后),会弹出一个竖屏手机界面:封面用地图底图压成暖色调当视频封面,下面是本期播放/点赞/评论三宫格、最热片段、完播率、新增关注和评论区,收起手机才接旅馆的「进入下一天」。播放量仍然由 engine/publish.js 结算,其余指标由 engine/vlogstats.js 按"播放量 + 当期素材重要度 + 理智档位"派生——用以(天数 + 播放量 + 素材 id)为种子的伪随机数,所以同一期数据反复打开都是同一组数字,算完还会缓存进 state.publishLog 那条记录的 stats 字段。理智濒崩时点赞/评论上浮、完播率下降,素材出错(glitched)时整体打折,都会在面板上给一句提示。发布过之后想再看一眼,可以从加油站「发布 vlog」tab 或顶部「开始剪辑」里的「查看本期数据」进去。
手机屏幕内容一屏放不下:滚轮之外还能按住鼠标上下拖(触屏走原生滑动,不接管;拖动超过 4px 才算拖拽,松手那一下的 click 会被吞掉,不会误触按钮)。手机左边空地上有一列「向下滑动查看更多」的提示,点它往下翻一屏,玩家自己滚过之后就淡出不再出现。
改这套界面的样式时用 tools/phone-preview.html(要起本地静态服务器,访问 /tools/phone-preview.html):左上角能直接切第几天、理智档位、素材是否出错,不用真的从第 1 天玩到发布。
tools/quake-preview.html(同样要起本地静态服务器,访问 /tools/quake-preview.html):不用真的玩到第 2 天上午 10 点半,点一下按钮就能看地震演出——可以切震级预设、直接改振幅和时长,也能单独预览"地震 + 事件窗口"和结局窗口的逐字/逐句浮现节奏。改 engine/quake.js、engine/reveal.js 或 ui.css 里「地震演出」「逐字 / 逐句浮现」两节时用它对着调。
演出本身零美术素材:抖动是 CSS 关键帧(#app 和弹窗两层振幅不同、相位错开、一路衰减到停),落灰是几层互质间距的 radial-gradient 点阵以不同速度下落叠出来的视差,全程只动 transform/opacity,不碰 filter(原因见 ui.css 理智闪烁那一节的注释)。系统开了"减少动态效果"时自动只保留落灰和那一拍死寂,不抖。
assets/maps/hotspots.json:x/y是百分比坐标(0-100),type只能是"basecamp"(唯一,加油站)或"investigation"。想换地图就把mapImage(卫星底图)和detailImage(手绘旧地图)指到你自己的图,热点用tools/coord-picker.html(见上一节)重新量一遍。- 墨迹揭图:
mapImage是一开局就看得见的底图,detailImage只在已解锁地点周围像墨水洇开一样显示出来(engine/map.js的「墨迹揭图」一节)。两张图必须同尺寸同取景,同一套x/y才对得上。可选字段inkRadius单独指定某个点洇开范围的半径(占图宽的百分比,不写默认 8.5),占地大的地点(白湖、废弃工厂)调大一点更自然。揭开过的区域不会随换天收回去。所有热点都揭开过之后,墨会从镇中心漫过整张纸,直接显示完整的detailImage(连图廓、罗盘、比例尺一起),不再是一块块的。
- 墨迹揭图:
content/days.json:按天数字符串做 key("1"、"2"、"3"),meta.totalDays决定跑几天后进入结局判定(当前固定 3 天)。每天包含:startMin/endMin:当天的时间窗口(分钟数,0:00=0),决定clock-display起始值和"超时未归"的判定点;不写则各自兜底 480(08:00)/1200(20:00),见engine/time.jsunlockedLocations:当天地图上哪些 investigation 热点是解锁的(加油站永远解锁,不用列)events:普通文本事件(type:"text")或掷骰事件(type:"diceCheck",需要diceThreshold+outcomes.{critFail,fail,success,critSuccess}),loc对应热点 id,mainline标记是否主线,clue/sanityCost/unlocksArchive都是可选字段timedEvents:定时全局事件,到点就发生、跟玩家当时在哪无关(对应 design-doc.md 1.6 节的两次地震)。字段at是当天第几分钟(10:33 = 633),其余字段跟普通text事件一样(text支持换行分页、clue/sanityCost/unlocksArchive照常),另可配quake: { "magnitude": "6.4" | "7.1" }在弹文案之前先播一次地震演出(engine/quake.js)。判定是"这次交互推进的这一小时有没有跨过at",引擎每次交互固定推进 60 分钟,所以文案要容得下误差(写"大约十点半"而不是"10:33 整");也因为时间只在"去调查地点"时推进,at配得晚就要求当天有足够多的点位,否则走不到那个时刻。dialogues一样能绑在它上面(afterEvent填 timedEvent 的 id)newspaper:加油站"翻旧报纸"能看到的内容,每天一次signalPool:信号闪现的候选池,被动小概率触发reviews:复盘,req是需要先采集到的clueid 数组,options[].tag会写进choiceLog,供结局文案变体匹配vlogTitle:这期 vlog 的标题,发布后的手机数据页显示在频道名下面;不写兜底"第 N 天的素材"vlogComments:手机数据页评论区里的评论,{ user, text, likes?, alien? },alien:true显示成灰色斜体的"未知来源"评论(呼应"置顶那条不是我们发的");当天真的弹出过的信号闪现会自动接在后面,不用重复写- 频道名写在
meta.channel({ name, handle }),不写兜底QQ & BB
content/archive.json:category随便定义(地点/人物/事件/物品……),linkedMainCase:true的词条才计入探索进度(5.3 节 progress),unlockedBy目前只是给你自己看的注释,引擎侧任何途径调用archive.unlock()都算数,不校验来源。content/endings.json:当前代码里还是旧模型的 4 个结局 key(truth_escape/costly_escape/blind_escape/trapped,对应旧的"探索进度×San值 2x2 矩阵");variants[].when对应choiceLog里的tag,命中就把text追加在结局正文后面。可选的epilogue(后日谈)是单独一页,玩家在结局正文里点过"继续"之后才揭晓——design-doc.md 1.4 节要求结局②的翻转必须放在这里、正文那一段不许留破绽,所以两段在数据上就是分开的;epilogue自身也能用换行继续分页。可选的quake会在结局窗口弹出之前先播一次地震演出。结局窗口的演出是标题逐字上浮 + 正文逐句淡入(engine/reveal.js)。待更新:设计已确认改成 3 个结局的新模型(San 值熔断/进度不够 → 结局①,进度达标后由高潮关键抉择二选一给出结局②/③),见 design-doc.md 5.3 节;engine/ending.js和这份 JSON 都还没跟着改,等正式故事大纲到位后一起重构。
-
手动过了一遍 DOM id 对照(
index.html静态 id vsmain.js动态生成 id),没有对不上的。 -
用 Node 直接跑了一遍
engine/的纯逻辑模块(跳过map.js/main.js里依赖浏览器 DOM 的部分),拿真实的content/*.json数据模拟了两天完整流程:文本事件、掷骰事件三档取骰规则、信号闪现命中率、关键词解锁(含嵌套跳转)、复盘前置条件、加油站翻报纸/发布、超时惩罚、结局矩阵四个象限——68 项断言全部通过。 -
用本地静态服务器起了一遍,确认
index.html/engine/*.js/content/*.json/assets/maps/*全部能被正常请求到(200)。 -
3 天时间窗口改版(第 1 天 20:00-24:00、第 2/3 天 08:00-24:00):所有
content/*.json过了一遍JSON.parse校验;单独跑了time.js/state.js的边界断言——第 1 天初始时钟 20:00、走 2 次(180 分钟)未到 24:00、走第 3 次即判定超时,第 2/3 天重置到 08:00 且能连续走 10 次才到超时点——全部通过。新增的第 3 天示例内容(theSink/cemetery两个地点、1 个掷骰事件、2 个文本事件、1 个复盘)尚未跑完整的浏览器端手动通关,改动故事文案或调整这部分时建议自己再点一遍。 -
定时全局事件 + 地震演出 + 结局后日谈(本轮新增):用 Node 直接跑了
engine/time.js、engine/reveal.js、engine/ending.js加真实content/*.json的 117 项断言——第 2 天按 60 分钟步长走完全天,M6.4 恰好在第 3 次交互(600→660)弹一次且只弹一次,第 3 天 M7.1 落在第 13 次交互(1200→1260);左开右闭区间验过不会在相邻两次推进里重复命中;逐句切分不会把[[显示文字|key]]切开;每个结局都有后日谈且都写了 M6.4/M7.1 两条记录;所有unlocksArchive的 key 在正文里都有对应的[[..|key]]链接、所有线索 id 在materials.json里都有登记、所有对话绑定的事件都存在且afterSegment不越界。 -
浏览器端实测(Edge headless):
quake.play({magnitude:'7.1'})的 promise 在 2800ms 兑现(1900 抖 + 900 死寂),演出期间body上是quaking quake-blocking、落灰层 z-index 250 且接管点击、#app/#modal-box各自挂上对应关键帧,结束后 class 全部摘干净、#app的 animation 回到none;截图对比确认地图确实位移、顶部确实压上了一层灰。结局窗口的.rv-char/.rv-clause计算样式也核过(逐字 0.05s、逐句 0.18s 递增)。
框架阶段没有覆盖到、故意先放着的点:
- 复盘只做了"选一个选项"这一种形式,原型参考里的"判断对错/排序"题型没有照搬——design-doc.md 对复盘的要求本身就是"记录关键选择供结局文案分支",没有要求特定题型,所以先按最简单的够用实现来,你要更复杂的题型可以在
review.js上加。