浏览器里的实时对局同步:WildHunt 的 WebSocket、输入序号与服务端快照
浏览器里的实时对局同步: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.tsfrontend/src/net/protocol.tsfrontend/src/net/game-channel.tsfrontend/src/game/game-network.tsfrontend/src/game/game-input-controller.tsbackend/wildhunt-web/src/main/java/com/wildhunt/web/ws/GameWebSocketHandler.javabackend/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。它包含 matchId、seq 和 input。seq 是输入序号,后端用它丢弃重复或乱序的输入。服务端消息里,对局相关的有:
| { 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/game。GameWebSocketHandler 在连接建立时做几件事:
- 从 query 里拿
token,通过JwtService解析 userId。 - 根据 userId 查询当前对局
gameMatchService.current(userId)。 - 如果用户未登录或没有对局,关闭连接。
- 把
userId和matchId放进 session attributes。 - 用
ConcurrentWebSocketSessionDecorator包装 session,限制发送时间和缓冲区大小。 - 立即发送一条
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 保存每个玩家的状态:userId、nickname、role、ai、x、z、yaw、dead、foodEaten、camouflageUntilMs、deerDecoyUsed、decoySmokeUntilMs。鹿的分身不是普通玩家,而是 RuntimeDecoy,它有 ownerUserId、位置、方向、速度、过期时间。快照生成时,会把活着且未过期的 decoy 追加到 players 列表里,并标记 decoy: true。
这样前端收到快照后,可以统一用玩家列表渲染鹿,但又能识别哪些是真玩家、哪些是 AI、哪些是分身。狼扑中分身时,服务端返回 skillConfirm,前端就能播放烟雾和提示,而不会把它计入胜负。
快照内容:把 UI 和场景都喂饱
服务端 snapshot 里包含:
serverTimeLeft:服务端剩余时间foundReal:已找到真人鹿数量realTotal:目标真人鹿总数mistakes:误伤数players:狼、鹿、AI、分身的状态列表skillConfirm:技能确认信息matchEnded、wolfWin:结算标记
这些字段既给 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_match、wh_match_player 的状态更新到数据库。这里同样有 settleLocks,保证一局只结算一次,避免多个输入同时触发结束时重复发奖励。
这个同步方案的边界
当前方案适合 Demo 和轻量实时对局,但它也有边界:
- 没有完整客户端预测和服务器回滚。移动延迟明显时,玩家会感到被快照拉扯。
- 快照是整包广播,不是状态差分。玩家规模扩大后要考虑压缩和只广播变化。
- AI 和物理仍然比较轻量,没有复杂导航和碰撞求解。
- WebSocket 断线恢复依赖重新获取当前对局,尚未做断线期间输入重放。
不过从技术分享角度看,这个版本的优点也正是“可理解”。它用很少的概念把实时对局闭环跑通了:输入、序号、运行时、快照、广播、结算。对于想从 Web 应用过渡到 Web 实时游戏的人来说,这是一个很好的中间形态。
小结
WildHunt 的实时同步没有追求一步到位的工业级网络架构,而是选择了一条比较务实的路线:服务端权威、客户端表现、输入序号防丢、防重复,首帧快照作为加载闸门,按 matchId 广播同步状态。这个架构能支撑一个 Web 3D 多人 Demo,也方便继续迭代。
后续如果要升级,我会优先做三个方向:第一,前端对服务端位置做插值和平滑,而不是直接跳到快照位置;第二,快照从全量改成关键字段差分;第三,把运行时 tick 从“输入驱动”升级成固定帧率调度器,服务端即使没有输入也能稳定推进 AI 和倒计时。到那一步,它就会更接近真正的实时游戏服务器。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)