拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenFrontIO 回放系统(Replays)实战解析:浏览器端逐帧解码、哈希校验与 IndexedDB 缓存

OpenFrontIO 回放系统(Replays)实战解析:浏览器端逐帧解码、哈希校验与 IndexedDB 缓存
  • 游戏开发
  • 后端

【免费下载链接】OpenFrontIO

Online browser-based RTS game

项目地址:https://gitcode.com/gh_mirrors/op/OpenFrontIO
点击查看免费下载

导读

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.tsIndexedDB 缓存
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 内部的帧编码需要关心。若修改了编码:

  1. 必须 bumpREPLAY_VERSION(位于 codec/ReplayTypes.ts,当前为 1);
  2. 版本号是存储键的一部分,且所有开发构建共享构建名DEV——如果不 bump,一个 dev 构建就会去读旧编码存储的回放(而读不出/读错);
  3. 同步解码的约束决定了读取侧要与异步 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逐帧对比。

九、局限与边界

基于文档与源码,使用这套系统时需注意几个前提:

  1. 构建确定性:核心只在一个构建内确定,跨构建的记录要么被送去版本化外壳(replay.<domain>/<gameID>),要么在开发客户端上于首个哈希不匹配处停止——这是"能放的就是真实打过的"这一保证的代价;
  2. 哈希不是签名:它们来自同一条记录,作用是捕获"构建漂移",而不是防篡改证明;
  3. 结尾不校验:记录最后一个哈希之后的帧原样放行,通常只有最后几秒(多人每 10 turn、单人每 100 turn 一个哈希);
  4. 存储是尽力而为:无存储、存储满、私有窗口等场景下回放每次都重新处理,功能不受影响,只是失去秒开体验;
  5. 格式变更需同步REPLAY_VERSION:否则DEV构建会误读旧编码数据。

这套设计把"重放整个对局"的服务端负担完全卸载到观看者的浏览器,用"边算边放 + 哈希闸门 + 本地 LRU 缓存"三个机制在实时性、正确性与体验之间取得了平衡,是研究浏览器端确定性回放系统的一份高质量参考实现。

  • 游戏开发
  • 后端

【免费下载链接】OpenFrontIO

Online browser-based RTS game

项目地址:https://gitcode.com/gh_mirrors/op/OpenFrontIO
点击查看免费下载

相关推荐

上一篇:终极黑苹果配置方案:OpCore Simplify一键EFI生成完全指南
下一篇:从源码到发布:adobe-discord-rpc的webpack构建、zxp签名打包与Releases发布完整流程指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

返回列表