联机同步:移动包、预测、广播与纠偏 private
联机同步:移动包、预测、广播与纠偏
本篇是跨端专题:只在这里把 Unity 客户端和 Battle 服务端放进同一条链路。客户端导航实现见 20,Battle 内部校验见 21,会话建立与切服见 12。
1. 先给结论
QMGame 当前整体不是传统 lockstep 帧同步,而是以对象/状态同步为主;移动、技能和状态由客户端先表现,Battle 接收后做实体归属、状态和协议边界校验,再转发给其他客户端。对怪物还要再加一层特殊规则:怪物的 AI 决策不是由独立 Battle 服务器 Think,而是由服务端选出的 HostPlayer 对应 Unity 客户端执行,Battle 保存归属并转发 Host 产生的同步结果:
- 本地客户端根据输入或 AI 立即开始移动。
StateSyncSystem把起点、目标、速度、时长、策略和状态压成CS_MoveBegin。- Battle 检查关卡进度、实体归属、生命/动作状态和必填字段。
- 成功后 Battle 更新服务端记录,并把
SC_MoveBegin广播给发送者之外的客户端。 - 失败后只给请求方发送
SC_MoveFail + serverPos。 - 远端客户端通过 PacketCache、移动策略、受击保护和强制位置修正生成平滑表现。
因此“服务端权威”在当前实现中是有限权威:Battle 对实体所有权、状态和广播结果有最终决定权,但没有独立验证 Unity NavMesh 路径,也不按同一条路径逐帧模拟位置。
sequenceDiagram
participant A as 客户端 A(操作者)
participant B as Battle
participant C as 客户端 B(观察者)
A->>A: 输入 / AI 产生 MoveData
A->>A: 立即执行本地移动
A->>A: 量化 + ParamEx + DeltaCompress
A->>B: CS_MoveBegin
B->>B: 进度、归属、状态、字段校验
alt 校验失败
B-->>A: SC_MoveFail(reason, serverPos)
A->>A: RollbackSystem 修正本地 Hero
else 校验成功
B->>B: 更新位置/朝向记录
B-->>C: SC_MoveBegin(排除发送者)
C->>C: 解压 -> PacketCache -> 纠偏 -> 移动表现
end
2. 两端责任必须分清
| 能力 | 客户端 | Battle 服务端 |
|---|---|---|
| 读取玩家输入 | 是 | 否 |
| Unity NavMesh 寻路 | 是 | 否 |
| 本地角色即时响应 | 是 | 否 |
| 远端角色移动表现 | 是 | 否 |
| 实体归属校验 | 只能做前置过滤 | 是,findSelfEntity |
| 关卡进度校验 | 写入 ParamProgress | 是,拒绝旧进度包 |
| 生命/动作状态判定 | 用于本地状态机 | 是,决定是否接受 |
| NavMesh 可达性权威校验 | 本地可检查,不是权威证明 | 当前没有 |
| 广播给其他玩家 | 否 | 是 |
| 移动失败最终结果 | 接收并回滚 | 产生 MoveFail |
| 长期战斗结算 | 展示 | 是 |
2.1 当前项目的怪物 AI:Host 客户端执行,Battle 协调
这是本项目和服务器完整模拟 AI 方案最容易混淆的地方。
服务端保存的是实体与控制权
BaseScene.HostPlayerId 变化时会调用 ComMonsterMgr.OnChangeHost()。这个方法把场景内所有 Monster 的 playerId 改成新的 HostPlayerId,并广播 Packet_Battling_SC_ChangeHostPlayerId。
foreach (var monster in mEntitys)
{
monster.playerId = Scene.HostPlayerId;
binaryWriter.Write(monster.entityId);
binaryWriter.Write(monster.playerId);
}
Battle.ComPlayerMgr.Broadcast(
(ushort)emPacket_Battling_SC.ChangeHostPlayerId, packet);
这里没有 NavMesh 计算、目标搜索或 AI 状态选择。服务端 Monster 实体主要保存属性、技能缓存、Buff、死亡/掉落和收到的机器状态;Monster.Update() 处理基础实体更新、死亡延迟、掉落和 WaveTrap 保底等生命周期逻辑。
Host 客户端才推进 EnemyStateMachine
客户端 Enemy 的 Unity 生命周期直接推进状态机和移动控制器:
void Update()
{
if (!IsActorActive) return;
_stateMachine?.UpdateState(Time.deltaTime);
}
void FixedUpdate()
{
MoveController?.Update();
}
EnemyStateMachine 内含 Idle、Chase、Attack、Skill、Patrol、CombatIdle、Guard 等状态。目标搜索会调用 SearchTool.SearchClosestHero、MathTool.CheckLineOfSight,并按角度和最小搜索距离锁定目标:
var target = TauntSourceActor()
?? SearchTool.SearchClosestHero(_enemyInstance, checkRange);
if (target != null && MathTool.CheckLineOfSight(_enemyInstance, target)
&& MathTool.InAngleRange2D(..., angle))
{
_enemyInstance.LockOnTarget = target;
return true;
}
状态更新和技能释放的多个分支都检查 _enemyInstance.IsLocal;非本地怪物不会重复执行权威目标选择和技能决策。
ChangeHostPlayerId 如何变成 IsLocal
客户端 ChangeControllerSystem 收到实体归属表后设置 gameEntity.ControllerId,再用下面的关系判断当前客户端是否是控制者:
coreEntity.playerId.Value
== WorldAdapter.UniqueWorld.mainPlayer.property.ID
成为控制者时注入 SyncDesMoveController;失去控制时换成 SyncDesDummyMoveController。结果如下:
| 客户端角色 | EnemyStateMachine | 移动/技能结果 |
|---|---|---|
| 当前 Host | 本地执行目标搜索、状态切换、NavMesh 和技能 | 产生 MoveBegin、StateSync、FrameStateSync 等包 |
| 其他玩家 | 不做权威 AI 决策 | 消费 Host 经 Battle 转发的状态、移动和位置 |
| Battle 服务端 | 不运行 Unity AI Think | 保存 playerId、接收/转发协议、做基础实体与状态校验 |
Host 断线后的切换
当前主机放弃或断线时,Battle 通过 GiveUpHost、BegVoteHost、VoteHost 重新选择在线玩家;投票主要按 Ping60S 选择较优者,并排除曾经让出过主机的玩家。新的 HostPlayerId 写入后会再次触发 MonsterMgr.OnChangeHost(),所有客户端同步更新控制器。
准确结论:Battle 是怪物实体和同步协议的协调者;Host 客户端是怪物 AI 的执行者。Battle 不会独立重算目标、路径和技能决策。 读代码时不要把“谁计算了某个值”和“谁有最终决定权”混为一谈。例如
Duration是客户端根据本地路径算出的,Battle 会解析并广播它,但当前不会用服务端路径重新计算它。
3. 联机前提:先建立正确的 Battle 会话
移动包不会走 Login 或 Lobby。客户端经过三段连接:
Login(ip:port)
-> Login 返回 Lobby PublicAddr + SessionId
Lobby(ip:port, sessionId)
-> Lobby/房间返回 Battle PublicAddr + BattleId + PlayerId
Battle(ip:port, battleId, playerId)
-> 战斗移动、技能、实体和状态协议
客户端入口在 Assets/Code/Game/Network/NetworkManager.cs:
public void OnEnterLobby(string ip, ushort port, string sessionId)
{
CloseSessionLogin();
CloseSessionLobby();
_CacheSessionId = sessionId;
_CacheLobbyIp = ip;
_CacheLobbyPort = port;
_lobbyClient = new LobbyClient(_logger, sessionId);
_lobbyClient.OnReceivedPacket += PublishLobbyMessage;
_lobbyClient.Connect(ip, port);
}
public void CreateSessionBattle(string ip, ushort port, string battleId,
int playerid, INetPacketCompressor compressor)
{
_battleClient?.Dispose();
_battleClient = new BattleClient(
_logger, battleId, playerid, m_dataInGame, compressor);
_battleClient.OnReceivedPacket += PublishMessageInGame;
_battleClient.Connect(ip, port);
}
排错第一步必须确认 CS_MoveBegin 确实由 BattleClient 发送。登录成功只证明 Login/Lobby 链路工作,不代表 Battle 会话已经建立。
4. 客户端发送链路
4.1 从本地移动事件进入 StateSyncSystem
角色移动控制器发出 LogicMessageType.SyncMoveData。状态同步系统先过滤无效实体和私有实体,再决定是否允许这条同步:
MessageCenter.Instance
.ObserveOnNetworkMessage(LogicMessageType.SyncMoveData)
.Select(message => (
EntityCode: (ulong)message.Sender,
MoveData: (SyncMoveDataLocal)message.Data))
.Where(message =>
message.EntityCode != ulong.MinValue &&
message.MoveData != null)
.Select(message => (
message.EntityCode,
Entity: GetActorEntity(message.EntityCode),
message.MoveData))
.Where(message =>
message.Entity != null &&
message.Entity.Entity != null &&
message.Entity.Entity.isPrivateEntity == false)
.Where(message =>
DeadReckoningCheck(
message.EntityCode,
message.Entity,
message.MoveData));
privateEntity 不应发给 Battle。防御塔除 Rotation 外也会在后续过滤。发送前还会拒绝 MoveData.Data.IsDead == true。
4.2 Begin 和 Update 不是一回事
MoveControllerSyncParam 决定包的语义:
| 参数 | 含义 | 接收端行为 |
|---|---|---|
FROM_BEGIN_NO_WAITTING | 新移动立即开始 | 清理旧模拟,立即切策略并 Begin |
FROM_BEGIN_WAITTING_STATE | 新移动要等状态包 | 先缓存 MoveBegin,收到对应 State 后 Begin |
FROM_UPDATE | 更新正在进行的移动 | 不重建完整状态,只更新速度和目标 |
DeadReckoningCheck 对 Begin 直接放行并清除旧模拟缓存;对 Update 才根据策略和本地模拟状态做去重/失步控制。这段发送端检查处理的是“是否需要再发一次更新”,不是 Battle 对移动是否合法的验证。
4.3 NavMove 发送前补充路径信息
发送前,策略 Helper 可以补充协议数据。NavMove 会用客户端本地 NavMesh 重新计算路径长度:
NavMesh.CalculatePath(
target.transform.position,
moveData.TargetPosition,
target.NavMeshAgent.areaMask,
path);
moveData.MoveDistance = NavMeshTool.GetPathLength(
target.transform.position,
moveData.TargetPosition,
path);
moveData.Duration =
moveData.MoveDistance / moveData.MoveSpeed;
当前发送端只判断 path != null,没有要求 path.status == PathComplete。因此 Duration 是当前 corners 的估算,不是“服务端可以信任的完整路径证明”。目标点合法性和客户端导航细节见 20 客户端寻路、NavMesh 资源与移动表现。
4.4 CS_MoveBegin 怎样组包
核心组包逻辑:
var moveBegin = Packet_Battling_CS_MoveBegin.Spawn();
var moveData = t_MoveData.Spawn();
moveData.TargetPos = t_Vector3Int.Spawn();
moveData.SrcPos = t_Vector3Int.Spawn();
m_optimizer.GetOptimizeNetworkData(
message.MoveData.Data,
message.Entity.Entity.position.Value,
message.Entity.Entity.rotation.Value,
message.MoveData.StrategyType,
moveData);
moveBegin.Movedata = moveData;
moveBegin.EntityId = (int)message.EntityCode;
moveBegin.Timestamp = m_networkData.TimeStamp;
moveBegin.ParamEx = m_optimizer.GetOptimizeParamEx(
message.MoveData.StrategyType,
message.MoveData.Param,
message.Entity.State,
message.Entity.ActorType);
moveBegin.ParamProgress =
WorldAdapter.UniqueWorld?.GetGameplay()?.LevelProgress ?? 0;
m_optimizer.DeltaCompress(
ref moveBegin, message.MoveData.Param, message.Entity);
NetworkManager.Instance.SendInGameMessage(
emPacket_Battling_CS.MoveBegin, moveBegin);
协议定义:
message Packet_Battling_CS_MoveBegin {
int32 entityId = 1;
t_MoveData movedata = 2;
int32 timestamp = 3;
int32 paramEx = 4;
int32 paramProgress = 5;
}
t_MoveData 常用字段:
| 字段 | 作用 |
|---|---|
srcPos | 客户端发送时的逻辑起点,整数化坐标 |
targetPos | 目标点 |
moveDataType | Hero/Nav/Linear/Rotation 等数据类型 |
faceDirection | 量化朝向;0 时服务端可能由 src -> target 推导 |
moveSpeed | 量化速度 |
duration | 客户端估算的移动时长 |
stopDistance | 到达容差 |
flag | StopWhenCollision、FollowNavMeshHeight 等布尔位 |
keyframeBytes | 特殊曲线移动数据 |
4.5 ParamEx 的编码规则
ParamEx 把四个字段装进一个整数:
return (int)Param
+ State * 10
+ (int)StrategyType * 1000
+ (int)ActorType * 100000;
可按下面的十进制位段理解:
ActorType * 100000
StrategyType * 1000
State * 10
Param
这不是通用位运算协议,而是依赖取值范围的十进制打包:
Param预期 0~9。State预期 0~99。StrategyType预期 0~99。ActorType占更高位。
新增枚举或状态值越界会污染相邻字段。客户端组包后会用 CheckParamEx 立即解包并对比,但服务端接收到的值仍应视为不可信输入。
5. Battle 接收、校验与状态更新
5.1 接收入口顺序
服务端入口是:
server/server_chain/Server/NodeBattle/Handle/HandlePacketStateSync.cs
-> OnMoveBegin(Player player, Stream stream)
-> Character.moveStatusHandler.OnMoveBegin(...)
-> ParseMoveBegin(...)
入口的关键顺序:
if (player.IsAppProcessPause) return;
if (player.OwnerHero == null) return;
if (player.Scene == null) return;
var packet = new Packet_Battling_CS_MoveBegin();
packet.MergeFrom(stream);
if (Battle?.IsDifferentLevelProgress(
packet.ParamProgress, player) == true)
return;
MoveDataOptimizer.GetRawParamEx(
packet.ParamEx,
out int strategyType,
out MoveControllerSyncParam param,
out int state,
out int entityType);
Entity entity = findSelfEntity(
player,
packet.EntityId,
entityType == (int)emEntityType.Bullet,
"OnMoveBegin");
if (entity == null) return;
((Character)entity).moveStatusHandler.OnMoveBegin(
packet, player.Playerid, param,
state, strategyType, entityType);
这里完成三道边界:
- 玩家必须已经在有效 Battle Scene。
ParamProgress必须与当前关卡进度一致。entityId必须通过findSelfEntity的归属检查。
5.2 ParseMoveBegin 检查什么
当前校验包括:
LiveStatus == ACTIVE。ActionStatus没有进入不能移动的区间。- 当前生命值大于 0。
- 增量数据可以结合缓存还原。
Movedata不为空。TargetPos不为空。- 策略分支是当前代码认识的类型。
当前没有检查:
- 目标是否落在服务端可查询的导航区域。
- 起点到目标是否
PathComplete。 - 客户端速度是否超过配置上限。
Duration是否等于服务端重算结果。- 客户端地图版本是否与 Battle 数据严格一致。
因此不要把 SC_MoveBegin 理解为“服务端证明这条 Unity NavMesh 路径有效”,它只表示当前 Battle 校验集合接受了该移动。
5.3 服务端怎样解释位置
不同实体/策略使用的权威记录不同:
| 条件 | 服务端 newPos |
|---|---|
| Hero | targetPos |
| 非 Hero 的 NavMove | srcPos |
| Bullet | srcPos |
| Rotation | 不改位置 |
这解释了为什么服务端日志中的 Hero 位置可能直接跳到目标,而怪物 NavMove 仍记录源点。Battle 当前不是统一逐帧移动模拟器;排查时必须同时记录实体类型和策略。
6. 成功广播与失败回滚
6.1 成功:排除发送者广播
var response = new Packet_Battling_SC_MoveBegin
{
EntityId = ower.entityId,
Movedata = moveBeginData.Movedata,
Timestamp = moveBeginData.Timestamp,
PlayerId = playerId,
ParamEx = MoveDataOptimizer.GetOptimizeParamEx(
strategyType, param, state, entityType)
};
DeltaCompress(response, param);
ower.BroadcastExcludePlayer(
(ushort)emPacket_Battling_SC.MoveBegin,
response,
ower.playerId);
发送者没有收到自己的成功回声,因为本地移动已经开始。其他客户端以 Battle 广播为准创建远端表现。这种设计降低了一次 RTT 的操作延迟,但意味着请求方主要依赖失败包纠错。
6.2 失败:只回请求方
message Packet_Battling_SC_MoveFail {
int32 entityId = 1;
t_Vector3Int serverPos = 2;
emMoveFailError reason = 3;
}
目前错误类型主要是 LiveStatus、HeroActionStatus、NullMoveData、TargetPos、MoveStrategy,没有导航和速度相关错误。
客户端 RollbackSystem:
private void Rollback(ulong entityId, t_Vector3Int serverPos)
{
var actor = GetActorEntity(entityId);
if (actor != null &&
actor.IsLocal &&
actor.ActorType == ActorType.Hero)
{
ForceTeleport(actor, serverPos);
// ResetState(actor) 当前被注释
}
}
这次回滚只覆盖本地 Hero 的位置:
- 不处理远端 Hero 或 Enemy。
- 不自动停止旧移动策略。
- 不恢复状态机到 Entry。
- 最终调用
OnSyncPosition(..., isTeleport: false),视觉表现取决于 View 的实现。
所以收到 MoveFail 后仍有滑动或错误动画,不能只看位置是否改了,还要检查 MoveController 和状态机。
7. 远端客户端怎样应用 SC_MoveBegin
7.1 先解包和分流
PacketCacheSystem 订阅 SC_MoveBegin,先解压 ParamEx 和移动增量,再根据实体类型找到对应缓存。远端 Hero 使用玩家侧 packet cache,Monster/Boss 使用实体上的 EnemyPacketCache。
包缓存最后发布:
MessageCenter.Instance.PostNetworkMessage(
LogicMessageType.ApplyMoveData,
new ApplyMoveDataData(
accelerationRatio,
packet.MoveBeginPacket,
packet.MovePacketParam,
packet.PacketState,
packet.MovePacketStrategyType));
StateSyncMoveDataSyncSystem 收到 ApplyMoveData 后,才调用 RecordRemoteMoveData。
7.2 为什么要有 PacketCache
网络包不会严格按 Unity 渲染帧到达。PacketCache 的作用是:
- 按时间戳维护接收队列和应用队列。
- 让 Move 和 State 维持必要顺序。
- 对
FROM_BEGIN_WAITTING_STATE暂存移动,等对应状态到达。 - 根据积压时间或位置距离临时提高远端移动速度,追上目标时间线。
- 丢弃时间戳明显倒退或实体已销毁的包。
当前配置中的加速值是“额外倍率”:
| 时间偏差 | 配置值 | 最终约为 |
|---|---|---|
> 200 ms | 3 | 4.0x |
> 100 ms | 2 | 3.0x |
> 50 ms | 0.5 | 1.5x |
<= 50 ms | 0 | 1.0x |
因为应用时是:
moveData.MoveSpeed *= 1 + accelerationRatio;
远端短暂冲刺首先应查 PacketCache 的 TimeOffset 和 AccelerationRatio,不应立即归因于 NavMeshAgent。
7.3 FromBegin 和 FromUpdate 的应用差异
RecordRemoteMoveData 的分支:
FROM_BEGIN_NO_WAITTING:立即ApplyRemoteMoveData_FromBegin。FROM_BEGIN_WAITTING_STATE:按 entityId 缓存,等OnStateSync再应用。FROM_UPDATE:调用ApplyRemoteMoveData_FromUpdate。
FromBegin 会:
- 从协议创建本地
MoveData。 - 必要时修正到包内
SrcPos。 - 用策略 Helper 优化接收数据。
- 检查 CharacterNavMove 是否仍在 NavMesh。
- 执行受击保护检查。
EndBySync结束旧策略。- 切换策略并
BeginBySync。
FromUpdate 不重启完整策略,只执行:
entity.MoveController.SetMoveSpeed(data.MoveSpeed);
entity.MoveController.SetMoveDestination(data.TargetPosition);
这能避免追击目标频繁变化时不断重建控制器,但要求 Begin 上下文仍然存在。
8. 预测、纠偏和保护阈值
配置来自:
Assets/Resources_moved/GConfig/StateSyncSettings.asset
当前序列化值:
| 配置 | 当前值 | 实际意义 |
|---|---|---|
| 普通距离阈值 | 1 m | 模拟位置与实际位置偏差阈值 |
| 普通角度阈值 | 15° | Rotation 同步检测阈值 |
| 普通时长阈值 | 0.15 s | 模拟有效时间阈值 |
| 低帧界线 | 20 FPS | 低于该目标帧率使用另一套值 |
| 低帧距离/角度/时长 | 0.3 m / 10° / 0.1 s | 低帧时更敏感 |
| 强制位置修正 | 5 m | 包内 SrcPos 与远端逻辑位置差达到 5 米时对齐 |
| 受击保护 | 3 m | 远端英雄位置差过大时保护受击表现 |
| 最大时间偏差 | 100 ms | PacketCache 时间偏差配置 |
ForceNavMoveOnRemote | true | 远端 CharacterDirectionMove 转 CharacterNavMove |
EnablePacketCache | true | 启用远端包缓存 |
强制修正条件:
NeedForceTeleport
&& moveStrategy != MoveStrategyType.Rotation
&& Distance(packet.SrcPos, entity.LogicPosition)
>= ForceTeleportDistance;
另外,CharacterNavMove 若在 0.1 米范围内采样不到 NavMesh,也会再次修正到 SrcPos。这两个修正都是远端客户端表现恢复机制,不是 Battle 的路径验证。
9. 一个完整案例
假设玩家 A 点击地面,玩家 B 在同一场 Battle 中观察:
- A 的输入系统取得目标点,客户端 NavMesh 采样并启动本地 Agent。
- A 的移动控制器发布 Begin 型
SyncMoveData。 - A 的
StateSyncSystem计算路径时长,把策略、状态、ActorType 打入ParamEx。 - A 通过 BattleClient 发送
CS_MoveBegin,画面无需等待回包。 - Battle 确认 A 拥有该 Hero、关卡进度相同、Hero 可移动、字段完整。
- Battle 将 Hero 服务端位置记录为
targetPos,广播SC_MoveBegin给 B,不回发 A。 - B 的 PacketCache 接收并排序包;若 Begin 等状态,则先暂存。
- B 把协议转回
MoveData,检查 5 米源点偏差和 NavMesh 状态。 - B 结束旧远端策略,选择对应 MoveStrategy 并开始移动。
- 如果之后 A 更新追击目标,发送
FROM_UPDATE;B 只更新速度和目标。 - 如果第 5 步失败,Battle 给 A 发
MoveFail,A 仅回滚本地 Hero 位置。
这个案例解释了三种常见现象:
- A 很流畅、B 稍晚:A 是本地先行,B 等网络广播。
- B 短暂加速:PacketCache 在追赶积压时间。
- A 偶尔拉回:Battle 拒绝移动后,RollbackSystem 应用
serverPos。
10. 排错 SOP
10.1 本地角色不发包
依次检查:
- BattleClient 是否已经连接,不要只看 Login/Lobby。
- 实体是否为
privateEntity。 SyncMoveData的 Sender 是否是有效 entityCode。- MoveData 是否标记死亡。
- 防御塔是否发送了非 Rotation 策略。
ParamEx组包后CheckParamEx是否通过。SendInGameMessage(MoveBegin)是否真正进入网络发送队列。
10.2 服务端收到但不广播
依次检查:
IsAppProcessPause、OwnerHero、Scene是否导致静默 return。ParamProgress是否与 Battle 当前进度一致。findSelfEntity是否因归属或实体类型拒绝。ParseMoveBegin返回的emMoveFailError。- 服务端失败前后
ActionStatus是否被提前改成 MOVE。 - Hero 的复活搭档/延迟技能是否在失败时也被中断。
- 成功分支是否执行
BroadcastExcludePlayer。
10.3 其他客户端收到但不移动
依次检查:
SC_MoveBegin的 entityId 是否能找到非本地 Actor。- ParamEx 解包出的 ActorType、StrategyType、State、Param 是否正确。
- 包是否进入正确的 Hero/Enemy PacketCache。
- 时间戳是否被
IsValid判为旧包。 FROM_BEGIN_WAITTING_STATE是否一直等不到 StateSync。SyncDesMoveController是否注册了该策略 Helper。- CharacterNavMove 的 Agent 是否在 NavMesh 上。
10.4 抖动、冲刺或瞬移
同时记录:
battleId, playerId, entityId, entityType
strategyType, syncParam, state, paramProgress
timestamp, receiveTime, applyingTime
srcPos, targetPos, localLogicPos
moveSpeed, accelerationRatio, duration
isOnNavMesh, path.status, areaMask
forceTeleportDistance, beHitProtectDistance
判定顺序:
- 先看客户端与服务端地图/配置是否同版本。
- 再看包内
SrcPos与观察者逻辑位置是否超过 5 米。 - 再看 PacketCache 是否因积压使用 1.5x/3x/4x。
- 再看客户端重算路径是否
PathComplete。 - 最后才判断 Agent、动画或 Transform 同步问题。
11. 当前一致性和安全风险
11.1 ActionStatus 修改早于完整校验
ParseMoveBegin 在检查 Movedata/TargetPos 前把 ActionStatus 设为 MOVE。畸形包失败后没有恢复旧状态。合理的修复顺序是“纯校验 -> 解压到临时对象 -> 计算新状态 -> 一次性提交”,或至少保存旧状态并在失败时回滚。
11.2 Hero 中断逻辑没有绑定成功结果
HandlePacketStateSync.OnMoveBegin 调用 handler 后,只要实体是 Hero 就中断复活搭档和延迟技能;它没有拿到 Parse 的成功值。因此可能同时发生 MoveFail 和技能/复活被中断。
11.3 客户端输入字段可伪造
目标、速度、Duration、策略和 ParamEx 都来自客户端。归属检查能防止控制别人的实体,但不能证明数值合理。要提升权威性,Battle 至少需要:
- 对每类 Actor/Strategy 做白名单和值域检查。
- 按服务端配置限制速度、加速度、位移和更新频率。
- 对位置变化使用服务端时间窗口校验。
- 导出一份服务端可查询的导航区域/连接图,并带地图版本。
- 扩展
MoveFail为可诊断的导航、速度和版本错误。
11.4 回滚只改位置
客户端失败回滚没有统一停止移动控制器和恢复状态机。修复时应定义一个原子接口,例如:
RejectLocalMove(serverPos, authoritativeState, reason)
-> Stop current movement
-> Clear pending local simulation
-> Correct logic/view position
-> Restore authoritative state
-> Emit diagnostics
12. 修改同步代码时的检查清单
- 新增 MoveStrategy:同时检查 MoveController、SyncDesMoveController、MoveDataFactory、两端 ParamEx、服务端策略分支和 PacketCache。
- 修改 ParamEx:确认每段取值范围,前后端必须同批发布。
- 修改
t_MoveData:更新 proto 和两端生成物,不手改生成代码。 - 修改同步阈值:改运行时 asset,不只改 ScriptableObject 默认值;复测普通帧、低帧、弱网、重连和跨 NavMeshLink。
- 修改地图:客户端 bytes、服务端地图数据、导航 Prefab 和 GUID 索引一起发布。
- 修改 MoveFail:同时实现服务端 reason、客户端控制器停止、位置/状态回滚和日志。
- 修改广播策略:明确是否给发送者回成功确认,以及客户端预测如何对账。
13. 源码索引
客户端
| 文件 | 职责 |
|---|---|
Assets/Code/Game/Network/NetworkManager.cs | Login/Lobby/Battle 会话创建与切换 |
Assets/Code/Game/Temp/StateSyncSystem.cs | 发送 MoveBegin、收包分流、远端应用、纠偏 |
Assets/Code/Game/Temp/RollbackSystem.cs | MoveFail 的本地 Hero 位置修正 |
Assets/Code/Game/Temp/PacketCache/EnemyPacketCache.cs | 时间戳队列、等待状态、加速追赶 |
Assets/Code/Game/Temp/NetworkDataOptimizer/IMoveDataOptimizer.cs | MoveData 序列化、ParamEx 打包/解包 |
Assets/Code/Game/Server/Move/SyncDesMoveController.cs | 策略同步 Helper 注册和调用 |
Assets/Resources_moved/GConfig/StateSyncSettings.asset | 同步和纠偏真实运行值 |
服务端
| 文件 | 职责 |
|---|---|
server/server_chain/Server/NodeBattle/Handle/HandlePacketStateSync.cs | Battle 移动包接收、进度和归属检查 |
server/server_chain/Server/NodeBattle/BattleEntity/Starlit/Character/ActionStatusHnader.cs | ParseMoveBegin、位置/朝向更新、广播和失败包 |
server/server_chain/Packet_tool/Packet_Battling.proto | Battle 移动协议源 |
14. 关联文档
- 会话怎样建立:12 网络会话模块。
- 客户端 NavMesh 和移动策略:20 客户端寻路、NavMesh 资源与移动表现。
- Battle 校验逐行分析:21 Battle 移动权威与服务端校验。
- 地图数据如何同时给两端:13 地图导出模块。
- 本地服和日志:17 本地联调模块。