网络游戏的状态同步实现

网络游戏的状态同步实现
MeteorCat相比于帧同步的复杂度和资源耗费, 状态同步的开发要求相对容易(主要实现起来很简单)
这里数据结构是采用 Protobuf 定义, 客户端和服务端数据交互的时候比较统一
假设目前想要设计个 2DRPG 游戏, 那么通用简单的玩家状态 proto 如下:
1 | syntax = "proto3"; |
这里的后续服务端功能都是基于
Pekko Actor开发, 所以需要有点 Actor 相关的知识点来阅读.
这里数据结构定义完成之后就能很简单看到共享的数据结构体功能:
-
PlayerState: 核心, 这就是需要被广播的玩家状态
-
PlayerInitialization: 进入房间|场景的初始化玩家状态列表
-
PlayerMove: 更新玩家的移动事件, 也就是会影响玩家状态
按照具体的业务流程来说, 这些数据结构就会形成以下执行链路:
-
客户端连接服务端加入房间/场景, 写入当前 PlayerState 状态, 初始化加载房间/场景所有玩家状态
-
客户端将本地 WASD 移动操作归一化数据提交给服务端
-
服务端接收到 PlayerMove 会去验证玩家ID是否和会话记录的玩家ID是否一致, 不一致为非法操作(记录不执行或者关闭连接)
-
服务端验证提交的归一化 Vector2 的
向量模(magnitude)是否大于1(向量模大于1代表异常, 可能是非法操作) -
服务端验证状态成立的时候, 会将成立的状态直接广播给当前房间/场景的玩家相关状态
-
客户端接收到状态更新, 渲染 UI 层面的动画和效果
也就是实际上的行为操作都是由 房间/场景管理(SecenManager) 独立的 Actor 来控制和评判, 为什么要这么操作?
主要问题就是在于场景是多人共享状态的, 必须要保证数据和行为的线程安全性; 否则场景内部出现可收集物资的话, 多人交互会出现很明显抢占问题
比如场景之后生成的怪物/资源/物资, 如果不做 Actor 处理就要靠锁来保证避免抢占数据出现的异常
这里采用 pekko-actor + pekko-protobuf-v3 生成的模拟游戏状态同步:
1 | import me.meteorcat.game.protobuf.PlayerMove; |
运行之后会生成机器人进入指定场景, 同时构建出自身 Actor 模拟出 会话登陆 - 加入场景 - 场景移动 - 广播移动 - 退出游戏 的流程
建议自己动手复制或者编写代码, 确认具体逻辑是否能跑通, 这个 Actor 架构就是很基础的 2D 游戏场景设计和管理.
相比于帧同步, 状态同步仅传输 状态变更, 网络开销更小(只需要广播客户端位移坐标数据)
| Actor 类型 | 核心职责 |
|---|---|
PekkoSessionActor |
玩家会话管理(单个玩家专属):处理玩家的连接/进入场景/移动/退出指令,接收场景广播的状态并响应。 |
PekkoSceneShared |
场景状态管理(多人共享):验证玩家指令合法性、维护所有玩家状态、广播状态变更、处理玩家进出场景。 |
深入思考
虽然从服务端来看已经构建出简单的 2D 游戏场景的广播服务端架构, 但是客户端应该怎么去使用他呢? 目前仅仅将服务端坐标广播客户端.
客户端需要避免直接把服务端下发的 Vector2 坐标修改成本地坐标, 而是应该采用 线性插值(Lerp) 让对象播放动画特效慢慢移动过去
如果直接客户端把坐标修改成服务端下发的位置, 就会产生出 “瞬移” 效果, 这样的表现看起来及其糟糕
游戏的线性插值 lerpSpeed 建议设置为 10-20, 主要是因为:
-
值太小: 修正过程太慢, 玩家会感觉 “被拖着走”
-
值太大: 修正太快, 接近瞬移, 失去平滑效果
| LerpSpeed值 | 效果描述 | 适用场景 |
|---|---|---|
| 10 | 修正较慢,手感偏 “软” | 休闲游戏、低延迟场景 |
| 15 | 修正速度适中,平衡手感和平滑度 | 大部分2DRPG |
| 20 | 修正较快,接近 “无感修正” | 竞技类游戏、高延迟场景 |
在 Unity 之中就实现类似如下插值:
1 | transform.position = Vector2.Lerp( |
这部分需要按照个人习惯偏好来调整, 目前视觉效果和操作手感比较好的是采用 预测移动 机制:
-
本地玩家输入归一方向
-
本地先行移动到指定位置, 同时提交移动指令到服务端
-
服务端后续下发移动坐标, 本地先行移动的位置调整成服务端下发位置
-
最后采用线性插值的方式将先行到达调整到服务端下发位置
-
结果就是本地坐标和服务坐标同步
本地和服务端坐标是允许有偏差值的, 两者偏差值不需要百分之百准确, 稍微偏移 0.1~0.2f 不是什么大问题(也就是上面的10-20)
这样的预测插值处理有以下好处:
-
操作手感: 本地输入后立即移动, 无网络延迟带来的 “按键没反应” 问题
-
状态一致: 最终以服务端权威位置为准, 避免客户端位置偏移
-
视觉体验: 插值修正过程平滑, 玩家感知不到“修正”动作
所以有时候会看到某些网络游戏的敌方明明距离你好像还有一段距离, 但就是会移动去触发敌方的战斗范围(服务端已经锁定触发事件)
另外还有服务端广播的问题, 上面的简单 2DRPG 场景案例没有说明, 注意服务端广播有两种区分:
-
立即广播: 上面的案例采用的就是这种直接广播方式, 实现起来简单, 但只适用于玩家人少的时候, 玩家数量上来时候会出问题 -
批量广播: 把周期的需要执行的广播状态保存成有序列表, 挂载场景 Actor 定时器批量将多条状态同步指令合并成一次响应返回给客户端
这里模拟过场景只采用立即广播的测试:
-
假设一个
PlayerState序列化后是 32 字节, 场景有 100 个玩家 -
单个玩家每秒移动 10 次 → 服务端每秒要发送
100 × 10 × 32 = 32000 字节 = 31.25KB -
10 个玩家同时移动 → 每秒
312.5KB, 100 个玩家同时移动 → 每秒3.125MB -
这还只是移动状态, 加上血量、技能等状态, 带宽会快速超过服务器承载上限
而采用批量广播则会将多条指令合并成一条, 经由序列化压缩一次性返回执行, 比起频繁做数据构建消息结构并响应网络好很多
| 维度 | 立即广播 | 批量广播 |
|---|---|---|
| 网络包数量 | 每状态变更1个包 | 每间隔Nms 1个包(合并所有状态) |
| 序列化开销 | 频繁序列化(每状态1次) | 单次序列化(合并所有状态) |
| 带宽占用 | 高(100玩家≈3MB/s) | 低(100玩家≈0.3MB/s,降低90%) |
| 实现复杂度 | 低 | 中(需缓存+定时器+批量消息) |
| 适用场景 | 低并发/高优先级事件 | 高并发/移动等高频状态 |
其实最好的方法是两者混合起来使用: 如果消息实行要求比较高就采用立即广播, 否则采用批量广播形式.
| 广播类型 | 事件/状态类型 | 采用原因 | 实现方式 |
|---|---|---|---|
| 立即广播 | 技能释放、血量变化、道具拾取、玩家进出场景、战斗触发 | 这类事件对实时性要求高,延迟会导致逻辑不一致(比如“我看到他没血了,但他还在打我”) | 保持当前的立即广播逻辑,事件发生后直接遍历所有玩家会话,发送单条状态广播消息 |
| 批量广播 | 玩家移动位置、移动方向、站立/移动状态 | 移动状态允许短时间延迟,客户端的预测+插值机制可以掩盖50~100ms的延迟 | 在场景管理器Actor中添加状态缓存(保留每个玩家最新状态)+ 定时(50~100ms)批量广播逻辑,合并多条状态为单次响应发送 |
批量广播的周期控制在 50~100ms 最好
这样处理能够让服务端更加高效处理共享的数据结构, 首要保证就是 服务器状态必须是唯一权威, 客户端最终的数据都是要以服务端为主
区域广播
上面案例就是针对整个场景广播, 一般都是归类到
场景广播
这里是从 大世界/大地区 架构另外衍生出来的概念; 在大地图同个场景100人的情况, 如果大地图要素交互元素过多, 采用场景广播效率是很差的
包括大部分在地图的时候, 没必要关心同场景所有人的操作指令广播, 而是只需要关注自己 “视窗” 附近的人广播即可
针对这个问题游戏开发中普遍采用 AOI(Area of Interest,兴趣区域) 机制来优化, 核心思想是: 只向玩家广播其视野范围内的实体状态变更
| 方案 | 全场景广播 | AOI 视野广播 |
|---|---|---|
| 网络开销 | 与场景玩家总数成正比(100人=100倍开销) | 与视野内玩家数成正比(10人=10倍开销) |
| 客户端计算 | 需处理全场景实体状态 | 仅处理视野内实体状态 |
| 适用场景 | 房间制小游戏(<20人) | 大世界MMO(>100人) |
| 性能瓶颈 | 玩家数超过50人后带宽飙升 | 支持千人同屏(依赖网格粒度优化) |
假设大世界场景有 1000 玩家, 每个玩家视野内只有 20 玩家:
-
场景广播: 每个玩家移动需要广播给 999 人 → 1000 玩家 × 10 次 / 秒 × 999 次 = 9990000 次广播 / 秒
-
区域广播: 每个玩家移动只广播给 20 人 → 1000 玩家 × 10 次 / 秒 × 20 次 = 200000 次广播 / 秒
-
广播次数降低 98%, 带宽和 CPU 开销呈指数级下降
而针对范围广播目前主要的划分方式有以下几种:
-
网格划分: 将地图划分为
gridSize × gridSize的正方形网格(比如 10m×10m), 每个网格对应单个
SceneActor(场景管理器) -
玩家视野: 玩家的兴趣区域为 以自身为中心的 3×3 网格(可配置), 即玩家会感知周围 8 个相邻网格的实体
-
分区切换: 玩家移动超出当前网格时, 场景 Actor 会将玩家从旧分区移除并加入新分区, 完成后通知新旧分区内的玩家更新视野列表
更进一步可以针对性追加的关键优化点:
-
大地图场景切分: 大地图将按照网格细分划分成多个场景分区
-
懒加载分区: 只有玩家进入的网格才会创建,避免空网格占用资源
-
3×3 视野配置: 可根据游戏类型调整(比如竞技游戏用 2×2,休闲游戏用 5×5)
-
分区切换优化: 仅计算新旧视野的差异网格, 避免重复加入 / 离开所有网格
-
批量广播兼容: 可在每个分区场景管理器内加入批量缓存逻辑, 进一步降低同分区内的广播频率
不过必须明确了解你的服务端承载的最大规模, 如果规模本身不大的情况其实使用分区即可
我之前的案例就是将城市的分区为 “上城区” 和 “下城区”, 这也是简单类型的地图分区
而对于 SLG 类型游戏, 一般做好性能优化和服务配置合理, 百人在线同屏对战其实也不是什么大问题:
| 维度 | SLG 同屏对战 | MMO 大世界漫游 |
|---|---|---|
| 移动频率 | 低(部队移动是路径规划+匀速行军,每秒 1-2 次状态更新) | 高(玩家自由移动,每秒 10+ 次状态更新) |
| 实体类型 | 单一(主要是部队、城池,无大量 NPC/道具) | 复杂(玩家、NPC、怪物、道具、技能特效等) |
| 交互逻辑 | 弱实时(对战指令是回合制/半回合制,无需毫秒级同步) | 强实时(技能释放、普攻需要毫秒级同步) |
这方面设计比起帧同步来说简单不少, 并且只需要客户端相互优化下视觉效果让其看起来更灵动些即可.
寻路与遇敌触发
网络游戏除了以键盘|手柄对玩家状态进行移动之外, 还有拖动大地图选中位置计算路线之后缓慢移动, 上面的场景管理器只讲的第一种.
目前主流的的处理方式就是采用 A*寻路算法 来做底层寻路技术, 完整链路如下:
-
客户端操作: 玩家拖动地图选中目标点, 发送
MotionPath给服务端请求路线 -
服务端寻路: 场景 Actor 接收请求, 用
A* 算法计算从玩家当前位置到目标点的可行路径(避开障碍物) -
路径下发: 服务端将路径点列表返回给客户端, 同时在服务端记录该玩家的移动路径
-
自动移动: 服务端按照路径点逐段移动玩家, 同时批量广播位置状态
-
遇敌检测: 移动过程中, 服务端实时检测玩家与视野内敌人的距离, 满足条件则触发战斗
在服务端下发路径之后, 自身也需要创建个移动定时器(100ms执行一次tick), 在服务端模拟客户端移动并且计算是否触发遇敌范围等.
请注意, 下发的路线其实是数组列表, 用来多个点形成一条完整的路线(起点→终点), Protobuf 定义如下:
1 | syntax = "proto3"; |
大部分情况 MotionPath 下发结果有以下情况(其实只需要关于 succeed 和 status 就知道是否正常):
1 | static class ERRORS { |
succeed 属性大部分情况都会被 status 覆盖, 实际上该属性可以考虑直接去除, 而只使用 status 来判断状态
具体的寻路运行流程如下:
-
服务端下发路线, 客户端和服务端必须采取相同的移动速度同时执行前往首个坐标点的状态更新
-
服务端的定时器每 Tick 都需要执行
速度 × 时间获取移动距离, 不断更新玩家坐标到服务端的PlayerState -
更新服务端 State 的同时采用批量下发形式, 将位置不断下发给客户端, 让客户端做插值平滑同步到服务端的坐标
-
在遍历所有路线点更新 State 之后需要做敌人仇恨值或者战斗范围检测, 如果进入战斗阶段就要修改切换 Moving 状态让定时器不继续走移动逻辑
-
触发战斗额外下发通知, 告诉客户端切换战斗场景处理, 这个时候服务端定时器处于空转状态, 必须要战斗结束切换可移动才能继续移动
-
直到最后 paths 列表的路线点全部到达完毕就代表执行完成
这部分需要很清晰了解 A* 算法和实现方式, 否则会出现路线完全不准的情况
在这个过程当中, 无论客户端怎么虚构数据来伪造已经到达都没用, 因为服务端已经运行并得出 权威结果, 客户端仅仅作为位置校正而已
游戏开发大部分情况下主要问题还是美术和客户端实现上, 服务端基本都是作为协调者负责处理脏活累活, 决定游戏是否成功还是看美术和客户端

