- 游戏开发
- 后端
【免费下载链接】OpenFrontIO
Online browser-based RTS game
导读
OpenFrontIO 是一款开源的浏览器 RTS 游戏,其新一代回放查看器(Replay Viewer)用一个全新的思路实现了"可任意拖拽进度条"的比赛回放:回放完全在观看者的浏览器本地生成——用游戏自身的渲染器与 HUD 播放已结束的对局,处理工作交给一个 Web Worker,服务端全程不参与回放计算。本文基于仓库中的 docs/Replays.md,结合src/client/replay/目录下的完整源码实现,讲解回放从"存档记录(game record)"到"可播放的逐帧编码"的完整流水线:构建版本匹配与哈希校验、边处理边播放的流式机制、IndexedDB 存储与容量淘汰策略,以及本地调试回放查看器的完整命令行流程。读完你将掌握这套回放系统的架构原理,并能在本地跑通record-demo→stub-game-api→dev的端到端验证链路。
一、回放查看器是什么:零服务端依赖的浏览器内回放
回放查看器在浏览器中播放一场已结束的对局,并且可以在时间轴的任意位置拖动(seek)。它的关键在于:
- 使用游戏自己的渲染器和 HUD,因此回放画面与真实对局几乎无差别;
- 回放数据在查看器的浏览器本地制作,数据源是这场对局的服务端存档记录(archived record),"Nothing runs on the server"(没有任何回放逻辑运行在服务端,见 docs/Replays.md)。
由于核心模拟(core)只在同一个构建(build)内具备确定性,回放也受此约束:一条存档记录只能在其对应的构建上回放。构建以 git commit 标识,开发构建统一使用DEV。
如何进入回放查看器
新查看器目前是可选开启的(opt-in):
- 普通入口:"watch replay" 在玩家未开启新查看器时仍打开经典回放;
- 玩家可在设置中打开New Replay Viewer,其底层设置项为
UserSettings.replayViewer(对应布尔键settings.replayViewer,默认false,见 src/core/game/UserSettings.ts); - 无论设置如何,直接访问带 hash 的链接
#replay-viewer=<gameID>都会打开新查看器。
从源码看,入口分发逻辑在 src/client/replay/ReplayEntry.ts:openReplayViewer()先检查replayViewer()设置,再检查sessionStorage中openfront.replay.classic标记(用户主动退回经典回放的 game 列表),随后通过设置window.location.hash触发hashchange事件由 Main 打开查看器。
二、一条回放是如何制作出来的:五步流水线
原文档将回放制作描述为五个步骤,这里结合源码逐一展开。
1. 打开(Open)
从大厅弹窗(lobby modal)观看已结束的对局,或直接访问#replay-viewer=<gameID>,都会打开查看器,查看器随后从 API 获取该对局的存档记录。
取记录的实现位于 src/client/replay/ReplayRecord.ts 的fetchReplayRecord(),它返回四种结果:record(拿到了记录)、other_build(记录来自其他构建)、not_found、unreachable。
2. 检查构建版本(Check the build)
核心模拟只在同一个构建内是确定性的,因此:
- 本构建能回放的记录,直接处理;
- 来自其他构建的记录,会被送到那个构建的版本化外壳(versioned shell),地址形如
replay.<domain>/<gameID>(对应 issue #4934)。
在 src/client/replay/ReplayViewer.ts 中,当fetchReplayRecord返回other_build时,调用 src/client/VersionedReplay.ts 的findVersionedShell()探测对应外壳是否可用,可用则window.location.assign跳转过去。版本化外壳通过isReplayShellHost(hostname)识别,这也是后面存储策略区分主站与外壳的关键。
3. 处理(Process)
处理由一个Web Worker完成(ReplayProcessor.worker.ts):它用本构建的核心(core)重跑整条记录,并把每一个 tick 编码进回放文件,同时检查直播客户端们当初一致认同的每一个哈希值,在第一个不匹配处停止。
核心实现是 src/client/replay/processor/ReplayProcessor.ts 的processGameRecord(),关键细节:
decompressGameRecord({ ...record })解压记录中的 turns(注意传入副本,因为解压会替换 turns);wireGameStartInfo():对记录的GameStartInfo再次套用服务端下线时做的"线缆空置"(wire blanking,toWireGameStartInfo),并丢弃仅记录才有的字段(player stats、persistentID),确保与直播客户端拿到的 GameStartInfo 完全一致——clan 标签、好友关系都会影响队伍分配,跳过这一步会导致组队对局失步;- 通过
createGameRunner以无本地 clientID的方式构建游戏(与 Main.ts、LocalServer、Worker 观看记录时的建局方式相同),然后逐 turn 执行; - 每个 tick 的回调里比对
GameUpdateType.Hash:记录的 hash 与重算 hash 不一致时记下HashMismatch(turn、recorded、computed),并在后续抛出ReplayDesyncError; - 记录的最后一个哈希之后没有任何校验依据,这些帧直接放行(见下文"边处理边播放")。
处理进度每PROGRESS_EVERY = 100个 tick 上报一次。
4. 边处理边播放(Play while processing)
这是该实现最巧妙的部分:查看器不需要等全部处理完,游戏在"长出来"的过程中就可以播放。
- Worker 先把固定不变的头部字段(
onStart,对应ReplayBase:keyframeInterval、地图宽高、陆地瓦片数、gameStartInfo)发给主线程; - 然后每隔几秒发送一批新帧(
onAppend,默认间隔appendEveryMs = 5000;第一批约在 1 秒后发出,因为首个 chunk 一完成就发送); - 帧只有在某个更晚的哈希已经匹配之后才会被送出——这样即使处理中途发现失步,查看器手里已有的内容依然是真实打过的对局;
- 例外是结尾:记录最后一个哈希之后的帧没有任何东西可以校验,因此"按原样"送出。哈希在多人对局中每 10 个 turn 出现一次、单人模式每 100 个 turn 一次,所以未经校验的尾巴最多只有最后几秒;经典回放同样不校验这些帧。
关于哈希的作用,文档强调:哈希捕获的是"构建与原始对局发生了漂移",而不是签名(signature),因为它们来自同一条记录本身。
播放期间的时间轴覆盖整场对局的长度(长度在处理开始前就已知,record.info.num_turns):尚未处理的部分显示为灰色,拖动到那里会被"弹回"(snap back)到已处理边界。
从代码看,流式交付的实现由 src/client/replay/codec/encode/StreamingEncoder.ts 的takeAppend()完成:它返回自上次调用以来新增的闭包 chunk(closed chunks)、新出现的玩家/单位类型字典、以及事件列表;由于内容在第一个await之前就被选中,调用方的游戏循环可以在 gzip 完成期间继续推进。主线程侧 src/client/replay/LocalProcessing.ts 通过onStart/onAppend/onDone/onError回调把增量交给查看器,查看器在 ReplayViewer.ts 的receive()中串行地(一个接一个 promise 链)应用这些增量:第一个 append 打开回放,后续 append 追加帧。
性能参考(来自原文档):一场26 分钟、25 人的对局在开发构建下处理耗时约70 秒,存储体积约24 MB。
5. 存储(Store)
处理完成后,回放被写入 IndexedDB,下次观看可以秒开(详见下一节)。
三、存储与缓存:IndexedDB 上的 LRU 回放库
原文档用一张表总结了存储策略,这里逐行展开并给出源码依据(核心实现 src/client/replay/ReplayStore.ts):
| 项目 | 策略 |
|---|---|
| 存储位置 | IndexedDB,数据库名openfront-replays(DB_NAME常量,含replays与meta两个 object store,meta以key为主键) |
| 键 | Game ID + build +REPLAY_VERSION,形如${gameID}.${build}.v${REPLAY_VERSION}(replayKey()与keySuffix()) |
| 容量上限 | 256 MB(MAX_STORED_BYTES = 256 * 1024 * 1024),超过时最久未观看的优先删除(LRU) |
| Chunk | 保持原样存储——它们本来就是 gzip 压缩过的 |
| 核爆、死亡单位 | 压缩为二进制并 gzip(PackedEvents.ts,见下) |
| 损坏的副本 | 删除,然后重新处理对局 |
| 其他构建的回放 | 主站上删除;replay.<domain>外壳上保留 |
| 完全无存储 | 完全可以:每次重新处理即可 |
几个值得展开的实现细节:
- LRU 淘汰:
evictionsFor()把元数据按usedAt升序排序(最久未看在前),配合meta表的touch()更新观看时间,逐步淘汰直到总量低于上限。ReplayStore.get()每次命中都会touch刷新时间戳。 - 尺寸估算:
replaySize()= gzipped chunks 的总字节数 +packedEvents字节数 + 其余部分序列化为 JSON 的长度。单条回放超过容量上限时直接不存(put()中if (size > this.maxBytes) return)。 - 损坏自愈:
get()读取失败(fromStored抛错)时打印警告并删除该键,返回undefined,让查看器走"重新处理"路径;ReplayViewer中存储副本UnreadableReplayError也会触发replayStore.remove。 - 主站与外壳的差异:
ReplayStore构造参数sharedAcrossBuilds由isReplayShellHost(window.location.hostname)决定。主站(不共享)在put()时会把键后缀不匹配本构建的其他构建回放全部删除——因为那些对局的观看发生在各自版本化外壳上;而所有构建的外壳共享同一个replay.<domain>origin,所以那里保留其他构建的回放,直到容量上限需要腾地方。 - 健壮性:存储层是"尽力而为"——数据库打开有 3 秒超时(
OPEN_TIMEOUT_MS,避免等待其他标签页关闭旧版本导致的版本升级阻塞),私有窗口、清空站点数据、存储不可用等情况下,查看器只是每次重新处理对局而已。 - PackedEvents:核爆冲击(nuke impacts,一个爆炸要列出一整片瓦片)和死亡单位(一场长对局有成千上万个)原本约占存储的四分之一,src/client/replay/codec/PackedEvents.ts 将它们编码为二进制:核爆冲击用 varuint 记录 tick + 陆地/水域瓦片列表(瓦片按升序记录差值,爆炸是圆盘所以差值大多为 1),死亡单位用 varuint tick/unitId + short string 单位类型 + ownerSmallID + 位置 + reachedTarget 标志,最后整体 gzip。
四、代码结构:从编码器到页面
回放功能全部位于 src/client/replay/,原文档将其分为三部分:
| 目录 | 内容 |
|---|---|
codec/ | 回放格式:编码器、读取器、字段表 |
processor/ | 把对局记录变成回放,并校验哈希 |
| 顶层 | 查看器、播放、渲染器与 HUD 的粘合、缓存 |
建议从这些文件开始阅读(对应原文档的文件表,均已确认存在):
| 文件 | 职责 |
|---|---|
| codec/encode/StreamingEncoder.ts | 逐 tick 编码,输出 gzip 后的 chunk |
| codec/decode/ReplayReader.ts | 在任意帧重建游戏状态 |
| codec/EntitySchema.ts | 玩家与单位的字段表,编码/解码共用 |
| processor/ReplayProcessor.ts | 重跑记录、校验哈希、喂给编码器 |
| LocalProcessing.ts | 在 Worker 中运行处理器 |
| ReplayPlayback.ts | 播放、暂停、调速、seek |
| ReplayFrameBuilder.ts | 解码帧转换为渲染器的FrameData |
| ReplayGameAdapter.ts | 让一帧看起来像GameView,供 HUD 读取 |
| ReplayStore.ts | IndexedDB 缓存 |
| ReplayViewer.ts | <replay-viewer>页面 |
五、回放格式:流式 chunk、键帧与同步解码
帧编码与 chunk 布局
StreamingEncoder每收到一个 tick(GameUpdateViewData)就立刻编码一帧,因为帧所指向的"归一化状态"只在那一 tick 有效。每keyframeInterval帧关闭一个 chunk 并 gzip,从而保证峰值内存 = 一个原始 chunk + 已压缩的 chunk 们。
每个 chunk 的布局(见closeChunk()):
u16 frameCount u32 × frameCount 帧偏移(相对于偏移表末尾) 帧数据(第 1 帧是键帧 keyframe,其余是增量帧 delta)每个 chunk 以**键帧(keyframe)**开头——它携带整幅地图的瓦片状态(按 run-length 编码的瓦片值 + 长度)、全部玩家/单位/名字的完整状态;之后的帧只编码变化的量(delta),由FrameEncoder用 change-mask 分节(瓦片、玩家、单位、名字、杂项更新、移除单位、地形变化)记录差异。
键帧间隔的取舍在 codec/ReplayTypes.ts 的注释中给出了量化结论:DEFAULT_KEYFRAME_INTERVAL = 100时,26 分钟 25 人对局的中位 seek 延迟约29 ms,而间隔 200 时为 33 ms,代价是文件大约大19%。并且每个文件自存自己的间隔,改变该值不会破坏已存储的回放。
读取器与"同步解码"约束
ReplayReader是解码端的核心,行为特征:
seek(frame)应用目标帧所在 chunk 开头的键帧,再依次应用增量到目标帧;在同一 chunk 内向前移动时只应用当前帧之后的增量;next()前进一帧;- 返回的
ReplayFrame中tileState等 Map 会被复用、下次调用会变化,但其中的PlayerState/UnitState对象从不被原地修改(增量通过复制产生新对象),因此持有它们是安全的; - 解码是同步的,而浏览器里的 gunzip 是异步的。因此
seek()/next()要求目标 chunk 先被load()加载(inflate),除非 inflate 函数本身同步(测试和 Node 中就是如此); - 内部缓存最近 inflate 过的3 个 chunk(
MAX_CACHED_CHUNKS = 3),按 LRU 淘汰。
播放器 ReplayPlayback.ts 因此采用"先 load 再读、提前 inflate 下一 chunk"的策略(drain()每次解码前await this.reader.load(...),随后prefetchNextChunk()预取下一块)。其他值得注意的播放细节:
- 前进不超过
STEP_LIMIT = 30帧时逐帧播放(保证拖尾与特效正确),超过则视为 seek; - 播放请求不会排队,只保留最新目标(
settle()循环直到当前帧 == 目标帧),所以拖动时间轴不会越拖越落后; tick()把时间间隔上限截断为 1000 ms,防止隐藏标签页恢复后产生巨大跳跃;- 处理中的对局以
live模式播放:到达最后一帧时像视频缓冲一样等待更多帧,而不是停住。
字段表与事件
EntitySchema用一张字段表同时驱动编码与解码:每个字段在 u32 change-mask 中占一位;一个字段可以覆盖总是同时变化的多个键(如 trainType 与 loaded);变化频繁的数字(瓦片、金币、兵力)用COUNTER编码——整数以 varint 写差值,其余写完整 f64,保证每个值精确还原。
事件(ReplayEvents)与帧并行存储:nukeImpacts、railroadEvents(铁路的 Destruction/Construction/Snap 三种)、motionPlans(运动计划送达 tick 与开始移动 tick,核弹预瞄线需要两者)、constructionStarts、deadUnitEvents、spawnPhaseEnd。ReplayFrameBuilder利用这些事件在帧之外重建"帧不携带"的派生状态:拖尾(按帧从单位位置重放)、铁路网(逐 tick 重放铁路事件,seek 后也能落在与直播对局相同的网络上)、死亡单位特效、出生阶段(由 spawn-phase-end tick 决定)、核弹预瞄线(由 motion plan 送达 tick 决定),其逐帧输出与GameView.populateFrame的一致性由 tests/client/replay/ReplayFrameBuilder.test.ts 对照验证。
修改格式必须做的事
回放从不离开生成它的浏览器,所以不存在跨浏览器/跨端的文件布局问题,只有每个 chunk 内部的帧编码需要关心。若修改了编码:
- 必须 bump
REPLAY_VERSION(位于 codec/ReplayTypes.ts,当前为 1); - 版本号是存储键的一部分,且所有开发构建共享构建名
DEV——如果不 bump,一个 dev 构建就会去读旧编码存储的回放(而读不出/读错); - 同步解码的约束决定了读取侧要与异步 gunzip 配合:
ReplayPlayback必须先ReplayReader.load目标 chunk,再读取,并提前 inflate 下一块。
六、本地试运行:端到端验证回放链路
原文档给出了完整的本地实验流程。你需要先有一份对局记录(game record),两种来源:
方式一:本地生成(推荐)。用 scripts/replay/record-demo.mts 在本地打一局并写出存档记录:
npx tsx scripts/replay/record-demo.mts <out.json> [ticks] [gameID]参数含义(默认值来自脚本实现):out.json输出文件;ticks默认 1500;gameID默认demoGame1。脚本会用约 30 个 bot(config({ bots: 30 }))跑一局,并把记录打上当前 checkout 的 git commit 戳(可用环境变量$GIT_COMMIT覆盖)。因为开发客户端(commit 为DEV)可以处理任何记录,这条记录总能通过构建匹配检查。
方式二:从真实对局保存。在浏览器打开https://api.openfront.io/game/<id>并保存响应(Cloudflare 会拦截脚本,需手动保存)。开发客户端会尝试任何记录,但如果记录来自更早的构建、且此后核心逻辑有变,会在第一个哈希不匹配处停止。
然后按原文档的流程启动(注意示例中游戏 ID 为dqKzit4cWu):
GAME=dqKzit4cWu # 对局的 ID mkdir -p /tmp/records && cp "$GAME.json" /tmp/records/ # 开发客户端期望它的 API 在 8787 端口,其他 API 调用会 404(无害) npx tsx scripts/replay/stub-game-api.mts /tmp/records 8787 npm run dev最后打开:http://localhost:9000/#replay-viewer=<gameID>
scripts/replay/stub-game-api.mts 是游戏 API 的替身:它把<dir>/<gameID>.json以GET /game/<gameID>提供(这正是查看器ReplayRecord.ts拉取记录的端点),并带有 CORS 响应头与 OPTIONS 预检处理。它的完整签名是npx tsx scripts/replay/stub-game-api.mts <dir> [port] [host],默认端口 8788、默认绑定127.0.0.1;文档示例显式指定8787是因为开发客户端的ApiBase把 API 期望在 8787,其余 API 调用 404 是预期且无害的。
七、Worker 与浏览器环境的工程细节
LocalProcessing把处理器放进 Worker 有明确的工程考量(见 LocalProcessing.ts 注释):
- 由于 bundle 从 CDN 提供、跨源的
new Worker(url)会被拒绝,Worker 采用same-origin Blob 内联方式创建(?worker&inline动态 import,与游戏自身的 Worker 一致),动态 import 让它不进入主 bundle; - Worker 与主线程通过
ProcessorMessages通信:progress/start/append/done/error五种消息;error附带desync标志,查看器据此显示"该记录无法在本构建上回放"(desync)或"处理失败"(failed); append消息的 chunk 压缩缓冲通过Transferable 转移(move 而非 copy),因为 Worker 送出后不再需要这些 chunk(ReplayProcessor.worker.ts);- Worker 内没有
window,因此通过主线程传来的cdnBase设置__CDN_BASE__,地图用FetchGameMapLoader从 CDN 拉取。
查看器本身(<replay-viewer>自定义元素)的打开逻辑优先级为:先查本地存储 → 命中则秒开;未命中则取记录 → 其他构建则跳转版本化外壳 → 否则在本地 Worker 中处理并边处理边播放。它还提供了完整的交互:空格播放/暂停、左右方向键 ±1 帧(Shift 组合 ±100 帧)、指针拖拽/滚轮缩放/双击适配、设置菜单(打开时暂停播放)、游戏同款排行榜(player-stats)、事件流(events-display,默认跟随人类玩家视角,点击排行榜可切换)与悬停卡片(player-info-overlay)。渲染器在每次动画帧里先更新相机再绘制,与ClientGameRunner保持同步。
八、测试与验证
回放管线在仓库中有完整的测试覆盖,可作为行为规格来阅读:
- tests/client/replay/ReplayStore.test.ts:键由 gameID + build + 版本号组成、LRU 淘汰顺序、容量上限、nuke/死亡单位的压缩存储、损坏副本被删除并触发重处理、存储不可用时永不抛错;
- tests/client/replay/codec/ReplayFormat.test.ts:编码/解码往返一致性(golden 级别);
- tests/client/replay/processor/Processor.test.ts:处理器重跑记录、哈希比对与失步(desync)行为;
- tests/client/replay/LocalProcessing.test.ts:Worker 内联与消息协议;
- tests/client/replay/ReplayViewerErrors.test.ts:查看器的错误路径(not_found / unreachable / other_build / desync / 经典回放回退);
- tests/client/replay/ReplayFrameBuilder.test.ts:解码帧构建的
FrameData与直播对局GameView逐帧对比。
九、局限与边界
基于文档与源码,使用这套系统时需注意几个前提:
- 构建确定性:核心只在一个构建内确定,跨构建的记录要么被送去版本化外壳(
replay.<domain>/<gameID>),要么在开发客户端上于首个哈希不匹配处停止——这是"能放的就是真实打过的"这一保证的代价; - 哈希不是签名:它们来自同一条记录,作用是捕获"构建漂移",而不是防篡改证明;
- 结尾不校验:记录最后一个哈希之后的帧原样放行,通常只有最后几秒(多人每 10 turn、单人每 100 turn 一个哈希);
- 存储是尽力而为:无存储、存储满、私有窗口等场景下回放每次都重新处理,功能不受影响,只是失去秒开体验;
- 格式变更需同步
REPLAY_VERSION:否则DEV构建会误读旧编码数据。
这套设计把"重放整个对局"的服务端负担完全卸载到观看者的浏览器,用"边算边放 + 哈希闸门 + 本地 LRU 缓存"三个机制在实时性、正确性与体验之间取得了平衡,是研究浏览器端确定性回放系统的一份高质量参考实现。
- 游戏开发
- 后端
【免费下载链接】OpenFrontIO
Online browser-based RTS game
相关推荐
CANN opbase OpCacheContainer 内部哈希缓存容器源码解析与实战指南
CANN opbase OpCacheContainer 内部哈希缓存容器源码解析与实战指南 op_cache_container 是 CANN opbase(
人工智能算子库CANNAscendViMax 角色提取全解析:快速读懂剧本里的人物
ViMax 角色提取全解析:快速读懂剧本里的人物 拿一段剧本丢给 AI,它怎么知道里面有几个角色、各自长什么样?ViMax 的角色提取就是为此设计的:读一遍文本
人工智能AI Agent多智能体媒体生成视频抖音无水印批量下载指南:5 步把一整个作者主页存进本地(免费开源)
抖音无水印批量下载指南:5 步把一整个作者主页存进本地(免费开源) 关注了半年的博主,昨天还能点的视频今天已经 404;一条一条手动点保存,录下来的还自带水印,
网页爬虫CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考