联机同步:移动包、预测、广播与纠偏 private

联机同步:移动包、预测、广播与纠偏

本篇是跨端专题:只在这里把 Unity 客户端和 Battle 服务端放进同一条链路。客户端导航实现见 20,Battle 内部校验见 21,会话建立与切服见 12。

返回技术文档目录

1. 先给结论

QMGame 当前整体不是传统 lockstep 帧同步,而是以对象/状态同步为主;移动、技能和状态由客户端先表现,Battle 接收后做实体归属、状态和协议边界校验,再转发给其他客户端。对怪物还要再加一层特殊规则:怪物的 AI 决策不是由独立 Battle 服务器 Think,而是由服务端选出的 HostPlayer 对应 Unity 客户端执行,Battle 保存归属并转发 Host 产生的同步结果:

  1. 本地客户端根据输入或 AI 立即开始移动。
  2. StateSyncSystem 把起点、目标、速度、时长、策略和状态压成 CS_MoveBegin。
  3. Battle 检查关卡进度、实体归属、生命/动作状态和必填字段。
  4. 成功后 Battle 更新服务端记录,并把 SC_MoveBegin 广播给发送者之外的客户端。
  5. 失败后只给请求方发送 SC_MoveFail + serverPos。
  6. 远端客户端通过 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目标点
moveDataTypeHero/Nav/Linear/Rotation 等数据类型
faceDirection量化朝向;0 时服务端可能由 src -> target 推导
moveSpeed量化速度
duration客户端估算的移动时长
stopDistance到达容差
flagStopWhenCollision、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);

这里完成三道边界:

  1. 玩家必须已经在有效 Battle Scene。
  2. ParamProgress 必须与当前关卡进度一致。
  3. entityId 必须通过 findSelfEntity 的归属检查。

5.2 ParseMoveBegin 检查什么

当前校验包括:

  • LiveStatus == ACTIVE。
  • ActionStatus 没有进入不能移动的区间。
  • 当前生命值大于 0。
  • 增量数据可以结合缓存还原。
  • Movedata 不为空。
  • TargetPos 不为空。
  • 策略分支是当前代码认识的类型。

当前没有检查:

  • 目标是否落在服务端可查询的导航区域。
  • 起点到目标是否 PathComplete。
  • 客户端速度是否超过配置上限。
  • Duration 是否等于服务端重算结果。
  • 客户端地图版本是否与 Battle 数据严格一致。

因此不要把 SC_MoveBegin 理解为“服务端证明这条 Unity NavMesh 路径有效”,它只表示当前 Battle 校验集合接受了该移动。

5.3 服务端怎样解释位置

不同实体/策略使用的权威记录不同:

条件服务端 newPos
HerotargetPos
非 Hero 的 NavMovesrcPos
BulletsrcPos
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 ms34.0x
> 100 ms23.0x
> 50 ms0.51.5x
<= 50 ms01.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 会:

  1. 从协议创建本地 MoveData。
  2. 必要时修正到包内 SrcPos。
  3. 用策略 Helper 优化接收数据。
  4. 检查 CharacterNavMove 是否仍在 NavMesh。
  5. 执行受击保护检查。
  6. EndBySync 结束旧策略。
  7. 切换策略并 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 msPacketCache 时间偏差配置
ForceNavMoveOnRemotetrue远端 CharacterDirectionMove 转 CharacterNavMove
EnablePacketCachetrue启用远端包缓存

强制修正条件:

NeedForceTeleport
    && moveStrategy != MoveStrategyType.Rotation
    && Distance(packet.SrcPos, entity.LogicPosition)
       >= ForceTeleportDistance;

另外,CharacterNavMove 若在 0.1 米范围内采样不到 NavMesh,也会再次修正到 SrcPos。这两个修正都是远端客户端表现恢复机制,不是 Battle 的路径验证。

9. 一个完整案例

假设玩家 A 点击地面,玩家 B 在同一场 Battle 中观察:

  1. A 的输入系统取得目标点,客户端 NavMesh 采样并启动本地 Agent。
  2. A 的移动控制器发布 Begin 型 SyncMoveData。
  3. A 的 StateSyncSystem 计算路径时长,把策略、状态、ActorType 打入 ParamEx。
  4. A 通过 BattleClient 发送 CS_MoveBegin,画面无需等待回包。
  5. Battle 确认 A 拥有该 Hero、关卡进度相同、Hero 可移动、字段完整。
  6. Battle 将 Hero 服务端位置记录为 targetPos,广播 SC_MoveBegin 给 B,不回发 A。
  7. B 的 PacketCache 接收并排序包;若 Begin 等状态,则先暂存。
  8. B 把协议转回 MoveData,检查 5 米源点偏差和 NavMesh 状态。
  9. B 结束旧远端策略,选择对应 MoveStrategy 并开始移动。
  10. 如果之后 A 更新追击目标,发送 FROM_UPDATE;B 只更新速度和目标。
  11. 如果第 5 步失败,Battle 给 A 发 MoveFail,A 仅回滚本地 Hero 位置。

这个案例解释了三种常见现象:

  • A 很流畅、B 稍晚:A 是本地先行,B 等网络广播。
  • B 短暂加速:PacketCache 在追赶积压时间。
  • A 偶尔拉回:Battle 拒绝移动后,RollbackSystem 应用 serverPos。

10. 排错 SOP

10.1 本地角色不发包

依次检查:

  1. BattleClient 是否已经连接,不要只看 Login/Lobby。
  2. 实体是否为 privateEntity。
  3. SyncMoveData 的 Sender 是否是有效 entityCode。
  4. MoveData 是否标记死亡。
  5. 防御塔是否发送了非 Rotation 策略。
  6. ParamEx 组包后 CheckParamEx 是否通过。
  7. SendInGameMessage(MoveBegin) 是否真正进入网络发送队列。

10.2 服务端收到但不广播

依次检查:

  1. IsAppProcessPause、OwnerHero、Scene 是否导致静默 return。
  2. ParamProgress 是否与 Battle 当前进度一致。
  3. findSelfEntity 是否因归属或实体类型拒绝。
  4. ParseMoveBegin 返回的 emMoveFailError。
  5. 服务端失败前后 ActionStatus 是否被提前改成 MOVE。
  6. Hero 的复活搭档/延迟技能是否在失败时也被中断。
  7. 成功分支是否执行 BroadcastExcludePlayer。

10.3 其他客户端收到但不移动

依次检查:

  1. SC_MoveBegin 的 entityId 是否能找到非本地 Actor。
  2. ParamEx 解包出的 ActorType、StrategyType、State、Param 是否正确。
  3. 包是否进入正确的 Hero/Enemy PacketCache。
  4. 时间戳是否被 IsValid 判为旧包。
  5. FROM_BEGIN_WAITTING_STATE 是否一直等不到 StateSync。
  6. SyncDesMoveController 是否注册了该策略 Helper。
  7. 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

判定顺序:

  1. 先看客户端与服务端地图/配置是否同版本。
  2. 再看包内 SrcPos 与观察者逻辑位置是否超过 5 米。
  3. 再看 PacketCache 是否因积压使用 1.5x/3x/4x。
  4. 再看客户端重算路径是否 PathComplete。
  5. 最后才判断 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.csLogin/Lobby/Battle 会话创建与切换
Assets/Code/Game/Temp/StateSyncSystem.cs发送 MoveBegin、收包分流、远端应用、纠偏
Assets/Code/Game/Temp/RollbackSystem.csMoveFail 的本地 Hero 位置修正
Assets/Code/Game/Temp/PacketCache/EnemyPacketCache.cs时间戳队列、等待状态、加速追赶
Assets/Code/Game/Temp/NetworkDataOptimizer/IMoveDataOptimizer.csMoveData 序列化、ParamEx 打包/解包
Assets/Code/Game/Server/Move/SyncDesMoveController.cs策略同步 Helper 注册和调用
Assets/Resources_moved/GConfig/StateSyncSettings.asset同步和纠偏真实运行值

服务端

文件职责
server/server_chain/Server/NodeBattle/Handle/HandlePacketStateSync.csBattle 移动包接收、进度和归属检查
server/server_chain/Server/NodeBattle/BattleEntity/Starlit/Character/ActionStatusHnader.csParseMoveBegin、位置/朝向更新、广播和失败包
server/server_chain/Packet_tool/Packet_Battling.protoBattle 移动协议源

14. 关联文档


上一篇:Battle 移动权威与服务端校验