浏览器里的实时对局同步:WildHunt 的 WebSocket、输入序号与服务端快照

浏览器里的实时对局同步:WildHunt 的 WebSocket、输入序号与服务端快照

项目地址:

这篇文章讲 WildHunt 里最核心的工程问题:浏览器里的多人 3D 对局如何同步。这个项目不是大型商业游戏,也没有接入专业游戏服务器框架,而是用一个相对朴素的方案完成闭环:前端用 WebSocket 上报输入,后端维护对局运行时,服务端返回权威快照,前端根据快照更新狼、鹿、分身和结算状态。

从技术栈看,前端是 Vite + TypeScript + Three.js,后端是 Spring Boot 3 + WebSocket + MySQL。实时同步涉及的关键文件有:

  • frontend/src/net/ws-client.ts
  • frontend/src/net/protocol.ts
  • frontend/src/net/game-channel.ts
  • frontend/src/game/game-network.ts
  • frontend/src/game/game-input-controller.ts
  • backend/wildhunt-web/src/main/java/com/wildhunt/web/ws/GameWebSocketHandler.java
  • backend/wildhunt-service/src/main/java/com/wildhunt/service/GameMatchService.java

为什么不用"前端自己判定"

这个 Demo 的玩法决定了它不能完全靠前端本地判定。狼扑中的是不是真人鹿?鹿的烟雾分身是否已经用过?倒计时结束后谁赢?这些结果如果全部由客户端决定,就会出现不同玩家看到不同结果的问题。更现实一点,即使不考虑作弊,只要网络延迟和设备性能不同,各端本地模拟也会逐渐分叉。

所以项目采用了"服务端快照为准"的模型。前端仍然会做本地表现,比如移动手感、动画、粒子、镜头、HUD 反馈;但正式对局中,核心状态由服务端维护:玩家位置、角色类型、死亡状态、剩余时间、找到的目标数、误伤数、技能确认、分身状态、比赛是否结束。

这种结构可以理解成:

text 复制代码
玩家输入 -> WebSocket -> 后端 RuntimeMatch -> 生成 GAME_SNAPSHOT -> 广播给同一对局玩家

前端收到 GAME_SNAPSHOT 后,把服务端状态映射到场景里的 wolf/deer/decoy 对象。如果收到 MATCH_END,则弹出结算面板。

WebSocket 连接:根据环境自动选择地址

前端基础 WebSocket 客户端在 frontend/src/net/ws-client.ts。它做了一个很实用的地址推导:

ts 复制代码
const WS_BASE = import.meta.env.VITE_WS_BASE_URL
  ?? (import.meta.env.DEV
    ? 'ws://localhost:8080'
    : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}`);

开发环境默认连本地 ws://localhost:8080,生产环境默认同域名连 WebSocket。如果前端单独部署,也可以通过 VITE_WS_BASE_URL 指定地址。这个设计让本地开发、后端同域托管和前端独立托管都能兼容。

WsClient 本身很薄,只负责连接、发送 JSON、断线重连。断线重连是通过 close 事件做的:如果不是客户端主动关闭,并且还保留了 path 和 onMessage,就在 1200ms 后重新 connect。它不做复杂的消息队列,因为正式对局的权威状态会通过快照不断覆盖,短暂断线后只要重连并拿到新快照即可。

协议设计:消息类型少,但边界清楚

前端协议类型定义在 frontend/src/net/protocol.ts。客户端消息主要有:

ts 复制代码
type ClientMessage =
  | { type: 'JOIN_ROOM'; roomId: string; token: string }
  | { type: 'ROOM_CHAT_SEND'; roomId: string; content: string }
  | { type: 'PING'; roomId?: string; sentAt: number }
  | { type: 'PLAYER_INPUT'; matchId: string; seq: number; input: Record<string, unknown> };

对局同步最关键的是 PLAYER_INPUT。它包含 matchIdseqinputseq 是输入序号,后端用它丢弃重复或乱序的输入。服务端消息里,对局相关的有:

ts 复制代码
| { type: 'GAME_START'; ... }
| { type: 'GAME_SNAPSHOT'; tick: number; players: unknown[]; snapshot?: Record<string, unknown> }
| { type: 'MATCH_END'; result: { title?: string; detail?: string; expDelta?: number; trophyDelta?: number; wolfWin?: boolean } }

这里没有把协议设计得特别复杂。它没有客户端预测确认、回滚、状态差分、帧编号插值这些高级功能。原因是 Demo 阶段更需要稳定闭环,而不是过早模拟商业游戏网络层。当前设计已经能表达:开局、输入、快照、结算。

输入上报:持续输入和一次性动作分开处理

对局输入在 frontend/src/game/game-input-controller.ts 里采集,在 frontend/src/game/game-network.ts 里上报。移动输入是持续状态:前、后、左、右、冲刺。技能输入是一次性动作:狼扑咬、狼气味追踪、鹿进食、鹿环顾、鹿烟雾分身。

一次性动作最容易丢,所以项目给每个动作维护了一个序号:

ts 复制代码
wolfPounceSeq
wolfScentSeq
deerEatSeq
deerLookSeq
deerCamouflageSeq

每次玩家按下技能键,前端不只是把 deerCamouflage 设成 true,还会让 deerCamouflageSeq += 1。网络层保存上次已发送的动作序号,只要发现当前序号更大,就把这个动作放入下一次输入包。发送成功后,再更新 lastSentActionSeq。

game-network.ts 里还有一个小优化:如果没有移动输入、没有一次性动作,并且距离上次空闲包不足 250ms,就不发送。正式发送频率是 50ms 一个 interval。这样既能保证移动响应,又不会在玩家完全不动时疯狂刷空包。

逻辑大概是:

ts 复制代码
const movementPayload = { forward, back, left, right, sprint };
const actionPayload = {
  wolfPounce: wolfPounceSeq > lastSentActionSeq.wolfPounce,
  deerCamouflage: deerCamouflageSeq > lastSentActionSeq.deerCamouflage,
};

if (!hasMovementInput && !hasOneShotAction && now - lastSentInputAt < 250) return;
send({ type: 'PLAYER_INPUT', matchId, seq: ++inputSeq, input: { ...movementPayload, ...actionPayload } });

这个设计非常适合 WebSocket Demo。它避免了短按动作因为发送周期错过而丢失,也避免了空闲状态下浪费太多网络包。

首帧快照:正式开局的加载闸门

正式对局不能连接上 WebSocket 就立刻开跑。前端有一个 LoadingGate,其中 firstSnapshot 是关键条件之一。connectAuthoritativeGameChannel 会创建一个 ready Promise,只有第一次收到 GAME_SNAPSHOT 才 resolve;如果 8 秒内没收到,则 reject。

这部分解决的是线上环境很常见的问题:后端冷启动、网络慢、WebSocket 建连成功但业务态还没准备好。如果前端先按本地随机状态进入游戏,之后服务端首帧一来,位置、角色和倒计时全部跳变,体验会很差。用首帧快照作为开局条件后,用户看到的是"加载中",而不是一个半同步的错误对局。

后端入口:GameWebSocketHandler

后端 WebSocket 注册在 WebSocketConfig

java 复制代码
registry.addHandler(roomHandler, "/ws/room").setAllowedOriginPatterns("*");
registry.addHandler(gameHandler, "/ws/game").setAllowedOriginPatterns("*");
registry.addHandler(lobbyHandler, "/ws/lobby").setAllowedOriginPatterns("*");

游戏对局使用 /ws/gameGameWebSocketHandler 在连接建立时做几件事:

  1. 从 query 里拿 token,通过 JwtService 解析 userId。
  2. 根据 userId 查询当前对局 gameMatchService.current(userId)
  3. 如果用户未登录或没有对局,关闭连接。
  4. userIdmatchId 放进 session attributes。
  5. ConcurrentWebSocketSessionDecorator 包装 session,限制发送时间和缓冲区大小。
  6. 立即发送一条 GAME_SNAPSHOT,作为客户端首帧快照。

这里的 ConcurrentWebSocketSessionDecorator 很重要。Spring WebSocket 的 session 直接并发发送可能出问题,装饰器可以给发送加限制,避免单个慢连接无限堆积消息。

输入去重:后端用 seq 丢弃旧包

handleTextMessage 只处理 PLAYER_INPUT。它会读取 seq,然后用 lastSeq 记录每个 userId:matchId 的最后序号:

java 复制代码
String key = userId + ":" + matchId;
long seq = json.path("seq").asLong(-1);
long old = lastSeq.getOrDefault(key, -1L);
if (seq <= old) return;
lastSeq.put(key, seq);

这是一个简单但非常有效的防抖和去重机制。WebSocket 本身保证同一连接内消息有序,但重连、客户端重发、异常情况下仍然可能出现重复输入。用 seq 可以让后端明确"只接受更新的输入"。它不是完整的反作弊系统,但足够保证 Demo 的输入处理不会被重复动作污染。

RuntimeMatch:服务端运行时怎么推进对局

GameMatchService 里有一个内部类 RuntimeMatch,它是对局运行时的核心。每个 matchId 对应一个 runtime,保存在 runtimeMatches 这个 ConcurrentHashMap 里。applyInput 根据 matchId 找到 runtime,然后在 synchronized (runtime) 里调用 runtime.apply(...)

为什么要 synchronized?因为同一局可能有多个 WebSocket session 同时输入。对局状态包括玩家位置、死亡状态、分身、误伤数、胜负等,这些都必须串行修改。用 per-match runtime 锁,比全局锁更细,至少不同对局之间不会互相阻塞。

RuntimePlayer 保存每个玩家的状态:userIdnicknameroleaixzyawdeadfoodEatencamouflageUntilMsdeerDecoyUseddecoySmokeUntilMs。鹿的分身不是普通玩家,而是 RuntimeDecoy,它有 ownerUserId、位置、方向、速度、过期时间。快照生成时,会把活着且未过期的 decoy 追加到 players 列表里,并标记 decoy: true

这样前端收到快照后,可以统一用玩家列表渲染鹿,但又能识别哪些是真玩家、哪些是 AI、哪些是分身。狼扑中分身时,服务端返回 skillConfirm,前端就能播放烟雾和提示,而不会把它计入胜负。

快照内容:把 UI 和场景都喂饱

服务端 snapshot 里包含:

  • serverTimeLeft:服务端剩余时间
  • foundReal:已找到真人鹿数量
  • realTotal:目标真人鹿总数
  • mistakes:误伤数
  • players:狼、鹿、AI、分身的状态列表
  • skillConfirm:技能确认信息
  • matchEndedwolfWin:结算标记

这些字段既给 3D 场景用,也给 HUD 用。比如前端 updateHud() 里显示倒计时、阵营、目标鹿数量、误伤数、体力、饥饿、可疑度。正式对局中,如果服务端下发 serverTimeLeft,前端会用它修正本地时间,避免长时间运行后倒计时漂移。

广播与结算

每次后端应用输入后,会构造 GAME_SNAPSHOT payload,并广播给同一 matchId 的所有 session。如果 update.matchEnded() 为 true,还会广播 MATCH_END

java 复制代码
broadcast(matchId, Map.of(
  "type", "MATCH_END",
  "matchId", matchId,
  "result", Map.of(
    "title", update.wolfWin() ? "狼方胜利" : "鹿群胜利",
    "detail", update.wolfWin() ? "服务端判定全部目标鹿已被找出。" : "服务端倒计时结束,仍有鹿存活。",
    "wolfWin", update.wolfWin()
  )
));

结算本身由 GameMatchService.settle 处理。它会根据胜负给真人玩家记录经验和奖杯变化,推进赛季通行证经验,清理当前对局映射和在线状态,并把 wh_game_matchwh_match_player 的状态更新到数据库。这里同样有 settleLocks,保证一局只结算一次,避免多个输入同时触发结束时重复发奖励。

这个同步方案的边界

当前方案适合 Demo 和轻量实时对局,但它也有边界:

  1. 没有完整客户端预测和服务器回滚。移动延迟明显时,玩家会感到被快照拉扯。
  2. 快照是整包广播,不是状态差分。玩家规模扩大后要考虑压缩和只广播变化。
  3. AI 和物理仍然比较轻量,没有复杂导航和碰撞求解。
  4. WebSocket 断线恢复依赖重新获取当前对局,尚未做断线期间输入重放。

不过从技术分享角度看,这个版本的优点也正是"可理解"。它用很少的概念把实时对局闭环跑通了:输入、序号、运行时、快照、广播、结算。对于想从 Web 应用过渡到 Web 实时游戏的人来说,这是一个很好的中间形态。

小结

WildHunt 的实时同步没有追求一步到位的工业级网络架构,而是选择了一条比较务实的路线:服务端权威、客户端表现、输入序号防丢、防重复,首帧快照作为加载闸门,按 matchId 广播同步状态。这个架构能支撑一个 Web 3D 多人 Demo,也方便继续迭代。

后续如果要升级,我会优先做三个方向:第一,前端对服务端位置做插值和平滑,而不是直接跳到快照位置;第二,快照从全量改成关键字段差分;第三,把运行时 tick 从"输入驱动"升级成固定帧率调度器,服务端即使没有输入也能稳定推进 AI 和倒计时。到那一步,它就会更接近真正的实时游戏服务器。

相关推荐
云水一下1 小时前
HTML5 从入门到精通:不止于标签——HTML5 高级特性,小交互无需 JavaScript
前端·html5
来自上海的这位朋友1 小时前
Spring Boot + MySQL 搭一个多人游戏后端:登录、房间、匹配、对局和成长系统
前端·后端·three.js
遇事不決洛必達1 小时前
【爬虫随笔】常见js混淆原理和特征
javascript·爬虫·逆向·js加密
chasdream1 小时前
Doris批量导入慢?Spring Boot整合Doris Routine Load是如何提升数据导入性能
后端·数据分析
用户2181697049301 小时前
golang 并发 goroutine sync.Lock atomic WaitGroup 协程通信(共享数据,channqel消息)channel
后端
徐安安ye1 小时前
FlashAttention前端优化:Token合并、MergeNet与冗余计算消除
前端
Reart1 小时前
从0解构tinyweb项目(十三)--剩余Handler自读验证(未完成版)
后端
吃炸鸡的前端1 小时前
react-hook-from从入门到精通
前端·javascript·react.js
Gopher_HBo1 小时前
接入层Nginx
后端