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

摘要

本文详细介绍了 WildHunt 项目中浏览器端多人 3D 对局的实时同步方案。该方案采用服务端权威模型,前端通过 WebSocket 上报玩家输入并附带序号防重,后端维护对局运行时状态并定期广播快照。关键设计包括:

  • 架构选择:服务端维护核心对局状态(位置、角色、技能、胜负),前端负责本地表现和输入采集
  • 协议设计:简洁的消息类型(JOIN_ROOM、PLAYER_INPUT、GAME_SNAPSHOT、MATCH_END)确保基本对局闭环
  • 输入处理:移动输入持续上报,技能动作用序号防丢,空闲时减少网络包发送
  • 同步机制:服务端用序号丢弃重复输入,按 matchId 锁保护运行时状态,生成完整快照广播
  • 工程实践:首帧快照作为加载闸门,WebSocket 连接自动适应环境,后端用装饰器控制并发发送

该方案在 Demo 阶段实现了稳定可理解的实时同步,为从 Web 应用过渡到 Web 实时游戏提供了实用参考。文章同时指出了后续优化方向:客户端插值平滑、快照差分传输、固定帧率调度等。

在这里插入图片描述

项目地址:

  • GitHub:https://github.com/typsusan-zzz/wildhunt-fullstack
  • Gitee:https://gitee.com/susantyp/wildhunt-fullstack
  • 在线体验:https://wildhunt-backend-production.up.railway.app

这篇文章讲 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 反馈;但正式对局中,核心状态由服务端维护:玩家位置、角色类型、死亡状态、剩余时间、找到的目标数、误伤数、技能确认、分身状态、比赛是否结束。

这种结构可以理解成:

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

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

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

前端基础 WebSocket 客户端在 frontend/src/net/ws-client.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。客户端消息主要有:

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 是输入序号,后端用它丢弃重复或乱序的输入。服务端消息里,对局相关的有:

| { 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 里上报。移动输入是持续状态:前、后、左、右、冲刺。技能输入是一次性动作:狼扑咬、狼气味追踪、鹿进食、鹿环顾、鹿烟雾分身。

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

wolfPounceSeq
wolfScentSeq
deerEatSeq
deerLookSeq
deerCamouflageSeq

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

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

逻辑大概是:

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

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 的最后序号:

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

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 和倒计时。到那一步,它就会更接近真正的实时游戏服务器。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐