5、状态同步与帧同步:两种同步模式对比、服务器权威校验、状态回滚机制
聊到游戏反外挂,同步模式是个绕不开的话题。我见过太多团队,一上来就拍脑袋选帧同步,结果被外挂打得满地找牙。也有团队死守状态同步,结果卡顿问题搞不定。说白了,没有银弹。
今天我就把这两种模式掰开揉碎,讲讲它们的本质区别、服务器怎么当裁判,以及状态回滚这个保命技能。
5.1 两种同步模式的核心差异
先给个直观对比。你想想看,状态同步就像你在银行办业务——你填单子(输入),柜员处理(服务器计算),最后给你回执(结果)。帧同步呢?更像一群人跳交谊舞——大家听着同一首曲子(帧指令),各自跳自己的动作。
| 维度 | 状态同步 | 帧同步 |
|---|---|---|
| 同步内容 | 最终状态(位置、血量等) | 操作指令(移动、攻击等) |
| 计算主体 | 服务器为主 | 客户端各自计算 |
| 网络带宽 | 较低(只传状态变化) | 较高(每帧传指令) |
| 反外挂难度 | 相对容易(服务器权威) | 较难(客户端可篡改逻辑) |
| 典型游戏 | MMORPG、MOBA | RTS、格斗游戏 |
我在项目中遇到过最典型的案例:某MOBA项目初期选了帧同步,结果外挂直接修改客户端逻辑,让英雄技能无CD。后来被迫重构,改成状态同步+服务器校验,才把外挂压下去。这个教训让我深刻意识到——同步模式的选择,直接决定了反外挂的攻防成本。
5.2 服务器权威校验:反外挂的基石
不管选哪种同步模式,服务器必须拥有最终解释权。这是我做反外挂这么多年,最核心的一条原则。
5.2.1 状态同步下的权威校验
状态同步天然适合服务器权威。客户端发个「我要移动」的请求,服务器算好新位置,再广播给所有人。外挂想改位置?没用,服务器不认。
但这里有个坑——客户端预测。为了手感流畅,客户端会先自己算一下位置,等服务器结果回来再校正。外挂就钻这个空子,伪造预测结果。
// 服务器端移动校验伪代码
bool ValidateMovement(Player player, Vector3 predictedPos, float deltaTime) {
float maxSpeed = player.GetMaxSpeed(); // 从配置表读取
float maxDistance = maxSpeed * deltaTime * 1.2f; // 允许20%的误差
float actualDistance = Vector3.Distance(player.LastValidPos, predictedPos);
if (actualDistance > maxDistance) {
// 外挂嫌疑!拉回合法位置
player.Position = player.LastValidPos;
return false;
}
// 通过校验,更新合法位置
player.LastValidPos = predictedPos;
player.Position = predictedPos;
return true;
}
5.2.2 帧同步下的权威校验
帧同步就麻烦多了。客户端各自跑逻辑,服务器只负责转发指令。外挂改指令太容易了——比如把「攻击」改成「攻击+移动」,或者直接伪造一个「秒杀」指令。
我建议的做法是:服务器做轻量级逻辑校验。虽然服务器不跑完整游戏逻辑,但可以检查指令的合法性。
// 帧同步指令校验(服务器端)
bool ValidateFrameCommand(Player player, Command cmd) {
switch (cmd.Type) {
case CommandType.Attack:
// 检查目标是否在攻击范围内
if (Vector3.Distance(player.Position, cmd.TargetPos) > player.AttackRange) {
return false; // 非法攻击
}
// 检查技能CD
if (player.GetSkillCooldown(cmd.SkillId) > 0) {
return false; // 技能还在冷却
}
break;
case CommandType.Move:
// 检查移动速度是否异常
float expectedSpeed = player.GetMoveSpeed();
float actualSpeed = Vector3.Distance(player.Position, cmd.TargetPos) / cmd.DeltaTime;
if (actualSpeed > expectedSpeed * 1.5f) {
return false; // 移动速度异常
}
break;
// 其他指令类似...
}
return true;
}
5.3 状态回滚机制:反外挂的最后一道防线
就算做了权威校验,外挂还是有可能钻空子。比如利用网络延迟,发送「我明明打中了」的虚假信息。这时候就需要状态回滚。
说白了,状态回滚就是:服务器发现某个玩家的状态异常,就把这个玩家「回退」到之前某个可信的时间点。其他玩家不受影响。
5.3.1 什么时候需要回滚?
- 位置异常:玩家位置突然出现在不可能到达的地方
- 属性异常:血量、法力值等属性出现非正常变化
- 行为异常:短时间内执行了过多操作(比如一秒内攻击了20次)
- 逻辑矛盾:客户端上报的状态与服务器计算结果不一致
5.3.2 回滚的实现策略
我习惯用快照回滚。服务器每隔一段时间(比如每5秒)保存一次玩家的完整状态快照。发现异常时,直接加载最近一次快照。
- 核心状态(位置、血量、技能CD):每2秒存一次
- 次要状态(背包、任务进度):每10秒存一次
- 关键事件(击杀、拾取):事件触发时立即存
// 状态快照与回滚示例
class PlayerStateSnapshot {
Vector3 Position;
float Health;
float Mana;
Dictionary<int, float> SkillCooldowns; // 技能ID -> 剩余CD
long Timestamp;
}
class PlayerStateManager {
Queue<PlayerStateSnapshot> snapshotHistory; // 最多保留30个快照
void TakeSnapshot(Player player) {
var snapshot = new PlayerStateSnapshot {
Position = player.Position,
Health = player.Health,
Mana = player.Mana,
SkillCooldowns = player.GetSkillCooldowns(),
Timestamp = GetCurrentTime()
};
snapshotHistory.Enqueue(snapshot);
// 只保留最近30个快照
if (snapshotHistory.Count > 30) {
snapshotHistory.Dequeue();
}
}
bool Rollback(Player player, long targetTime) {
// 找到目标时间点最近的快照
var targetSnapshot = snapshotHistory
.Where(s => s.Timestamp <= targetTime)
.OrderByDescending(s => s.Timestamp)
.FirstOrDefault();
if (targetSnapshot == null) return false;
// 恢复状态
player.Position = targetSnapshot.Position;
player.Health = targetSnapshot.Health;
player.Mana = targetSnapshot.Mana;
player.SetSkillCooldowns(targetSnapshot.SkillCooldowns);
return true;
}
}
5.3.3 回滚的副作用与处理
回滚不是万能的。它有个明显的副作用——玩家体验会受影响。你想想看,一个玩家正打得爽,突然被拉回5秒前的位置,肯定要骂娘。
所以我的原则是:回滚是最后手段,能校验就别回滚。具体来说:
- 轻度异常:直接拒绝客户端请求,不处理(比如移动超速,直接拉回)
- 中度异常:记录日志,标记玩家为「可疑」,后续加强监控
- 重度异常:执行回滚,同时封禁玩家一段时间
5.4 两种模式的选型建议
说了这么多,到底怎么选?我个人的经验是:
- 如果你的游戏对实时性要求极高(格斗、FPS):可以考虑帧同步,但必须做好指令校验和状态回滚。同时要做好心理准备——反外挂的投入会很大。
- 如果你的游戏是MMO、卡牌、策略类:无脑选状态同步。服务器权威,反外挂成本低,开发效率高。
- 如果你在做MOBA:这是个灰色地带。MOBA既需要实时性,又需要反外挂。我建议混合模式——核心战斗用状态同步(服务器计算伤害、位置),非核心表现(动画、特效)用帧同步。
嗯,同步模式这块,说白了就是在「反外挂能力」和「玩家体验」之间找平衡。没有完美的方案,只有最适合你游戏的方案。
最后说一句,不管选哪种模式,日志和监控一定要做全。没有日志,你连外挂怎么进来的都不知道。我习惯把所有异常校验的日志都打到独立的「反外挂日志系统」里,方便事后分析。这个习惯,帮我抓过不少隐蔽的外挂。
公众号:蓝海资料掘金营,微信deep3321