Battle 移动权威与服务端校验 private

服务端寻路权威与移动同步模块

本篇只讲 Battle 服务端收到移动请求以后做什么,以及当前实现没有做什么。结论必须先说清:当前 Battle 不运行 Unity NavMesh,也不在 MapProcessor.ProcessNavMesh/ProcessNavPortal 中建立服务端导航图;它加载共享地图数据,校验移动包的状态和字段,然后记录/广播位置。客户端负责实际的 NavMesh 路径执行;完整前后端时序统一见 22 联机同步。

返回技术文档目录

1. 当前权威模型

sequenceDiagram
    participant C as 客户端
    participant H as HandlePacketStateSync
    participant M as MoveStatusHandler
    participant S as Battle Scene/Entity
    participant O as 其他客户端
    C->>C: NavMeshAgent 计算路径
    C->>C: StateSyncSystem 计算 Distance/Duration
    C->>H: CS_MoveBegin
    H->>H: 校验 ParamProgress、实体归属
    H->>M: OnMoveBegin(...)
    M->>M: 状态/目标/策略字段校验
    M->>S: 更新朝向和位置记录
    M-->>O: SC_MoveBegin 广播(排除发送者)
    M-->>C: SC_MoveFail(失败时只回发送者)

当前系统的边界如下:

问题当前实现权威方
目标点是否在 Unity NavMeshBattle 不重算客户端在发包前检查
实际路径拐点Battle 不计算客户端 NavMeshAgent
移动开始是否合法Battle 检查Battle
英雄/怪物位置记录Battle 按策略取目标或源点Battle
远端表现客户端插值/Dead Reckoning客户端表现,数据来自 Battle
最终伤害、死亡、掉落BattleBattle

这不是“客户端完全权威”。更准确地说:移动几何目前是客户端计算、服务端接受并传播;战斗结果和实体生命周期仍由 Battle 权威处理。这个边界决定了反作弊风险,也决定了排错时不能假设服务端有一个隐藏的 A*。

2. 服务端地图加载链路

2.1 Battle 读取地图二进制

Scene.LoadMap 从 MapDatas.GetMapAssetPath(mapName) 得到地图路径,读取 .bytes 后交给 MapProcessor.Process:

var assetpath = MapDatas.GetMapAssetPath(mapName);
var buff = MapDatas.GetFileData(assetpath);
using MemoryStream fileStream = new MemoryStream(buff);
mMapProcessor.Process(fileStream);

MapProcessor 用共享的 Common.MapData 和 BinaryMapFormatter 解码 MapElementType。服务端能识别 NavMeshElement、NavPortalElement,但当前处理器是空方法:

void ProcessNavMesh(IMapElement e, float4x4 matrix)
{
    var navMesh = (NavMeshElement)e;
}

void ProcessNavPortal(IMapElement e, float4x4 matrix)
{
    var portal = (NavPortalElement)e;
}

这两个方法的存在表示协议/地图格式支持导航元素,不表示服务器已经有可查询的 NavMesh。服务端真正依赖地图数据的是出生点、刷怪点、触发器、空间 Cell 和战斗规则。

2.2 前后端共享格式的意义

NavMeshElement 只包含导航资源 GUID,NavPortalElement 包含:

AgentTypeId
StartPoint / EndPoint
Width
CostModifier
Bidirectional
AutoUpdatePosition
Area[]

这些字段由客户端 MapNavObjectFactory 恢复成 Unity NavMeshLink。服务端目前保留它们,是为了地图格式兼容和将来做服务端验证;修改字段仍然必须重新导出客户端和服务端地图包。

3. CS_MoveBegin 协议

3.1 包体

Packet_tool/Packet_Battling.proto 中,移动请求是:

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线性/朝向等数据类型
faceDirection量化后的朝向
duration客户端按路径长度计算的移动时长
moveSpeed量化移动速度
stopDistance到达判定半径
chaseDuration追击类移动的额外时间字段
flag多个布尔/方向位
keyframeBytes特殊曲线移动压缩数据

paramEx 不是普通业务参数,而是把 StrategyType、同步来源、状态和 ActorType 打包后的协议字段。服务端用 MoveDataOptimizer.GetRawParamEx 解包,不能把它当成任意整数透传。

3.2 服务端接收入口

HandlePacketStateSync.OnMoveBegin 的顺序:

  1. App 暂停、没有 OwnerHero 或没有 Scene 时直接返回。
  2. 从流中反序列化 Packet_Battling_CS_MoveBegin。
  3. 用 ParamProgress 检查玩家是否仍处在同一关卡进度。
  4. 解包 ParamEx,得到 strategyType、param、state、entityType。
  5. 用 findSelfEntity 限制实体归属,避免玩家驱动不属于自己的实体。
  6. 调用 Character.moveStatusHandler.OnMoveBegin。
  7. 只要实体是 Hero,处理 MoveBegin 后就中断复活搭档和延迟技能;调用点没有取得 ParseMoveBegin 的成功结果,因此即使后续发送 MoveFail 也可能发生中断。
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)Rpc.emEntityType.Bullet,
    "OnMoveBegin");
if (entity == null) return;

((Character)entity).moveStatusHandler.OnMoveBegin(
    packet, player.Playerid, param, state,
    strategyType, entityType);

HandlePacketStateSync.OnMoveBegin 并不根据 moveStatusHandler.OnMoveBegin 的成功/失败返回值决定是否执行 Hero 的中断逻辑。排查“移动失败但蓄力、复活或延迟技能仍被打断”时,不能只看是否广播了 SC_MoveBegin,还要看这个接收入口后续的 Hero 分支。

4. MoveStatusHandler.ParseMoveBegin 的实际校验

4.1 活跃状态和生命值

服务端先拒绝不能移动的实体:

if (ower.LiveStatus != ActorLiveStatus.ACTIVE ||
    ower.ActionStatus >= ActorActionStatus.REVIVE ||
    ower.property.CurHP <= 0)
{
    reason = emMoveFailError.LiveStatus;
    return false;
}

服务端随后把 ower.ActionStatus 设为 MOVE,然后才做增量包解压和必填字段检查。这个赋值早于 Movedata、TargetPos 等失败分支:

ower.ActionStatus = ActorActionStatus.MOVE;
// 后续才可能因为 Movedata/TargetPos/MoveStrategy 返回 false

当前方法没有在失败分支把 ActionStatus 恢复为原状态。因此 SC_MoveFail 不等于服务端完全没有状态副作用:畸形包或解压失败可能留下 MOVE 状态,同时 Hero 接收入口还可能已经中断复活/延迟技能。这是现有实现的异常状态风险,修复时应把所有纯校验前移,或保存旧状态并在失败时回滚。

4.2 必填字段和朝向

空 Movedata 返回 NullMoveData;空 TargetPos 返回 TargetPos。目标和源点通过 PBDataOptimizer.GetPos 解码。

若 faceDirection == 0,服务端使用 targetPos - srcPos 计算水平朝向;否则使用量化旋转值恢复 Quaternion:

if (movedata.FaceDirection == 0)
{
    Vector3 dir = targetPos - srcPos;
    dir.y = 0;
    dir.Normalize();
    ower.transform.Rotation = Quaternion.LookRotation(dir);
}
else
{
    quaternion dir = OptimizeUtils.RotationYFromByte(
        (byte)movedata.FaceDirection);
    ower.transform.Rotation = new Quaternion(
        dir.value.x, dir.value.y,
        dir.value.z, dir.value.w);
}

4.3 不同策略对位置的处理

当前代码明确区分了策略和实体类型:

条件newPos 来源
英雄targetPos
非英雄 NavMovesrcPos
子弹srcPos
原地 Rotation不改位置,直接返回成功

这段代码非常容易被误读成“服务端把怪物瞬移到目标点”。实际对怪物 NavMove,服务端只记录/刷新源位置,移动轨迹由客户端 NavMeshAgent 表现;英雄移动则直接把目标点作为服务端位置快照。

位置更新还有 0.1 的 X/Z 阈值,减少无意义写入:

if (Math.Abs(newPos.x - oldPostion.x) >= 0.1f ||
    Math.Abs(newPos.z - oldPostion.z) >= 0.1f)
{
    oldPostion = new Vector3(newPos.x, newPos.y, newPos.z);
    ower.position = oldPostion;
}

怪物成功接受移动后会 ResetWaveTrapCheckTime(),避免波次触发器使用旧的移动检查时间。

5. 成功广播与失败回包

5.1 成功:广播给其他客户端

OnMoveBegin 校验成功后构造 Packet_Battling_SC_MoveBegin:

sc_MoveBegin.EntityId = ower.entityId;
sc_MoveBegin.Movedata = moveBeginData.Movedata;
sc_MoveBegin.Timestamp = moveBeginData.Timestamp;
sc_MoveBegin.PlayerId = playerId;
sc_MoveBegin.ParamEx = MoveDataOptimizer.GetOptimizeParamEx(
    strategyType, param, state, entityType);

DeltaCompress(sc_MoveBegin, param);
ower.BroadcastExcludePlayer(
    (ushort)Rpc.emPacket_Battling_SC.MoveBegin,
    sc_MoveBegin, ower.playerId);

发送者本地已经在移动,不重复接收自己的广播;其他客户端收到后进入 StateSyncSystem,按包中的策略和时长表现移动。

5.2 失败:只回请求方

失败包包含实体当前服务端位置和失败原因:

message Packet_Battling_SC_MoveFail {
    int32 entityId = 1;
    t_Vector3Int serverPos = 2;
    emMoveFailError reason = 3;
}

当前枚举只有五类失败:LiveStatus、HeroActionStatus、NullMoveData、TargetPos、MoveStrategy。目前没有 TargetNotOnNavMesh、PathPartial 或 SpeedTooHigh 这类服务端错误码。

5.3 客户端收到 MoveFail 后怎样回滚

RollbackSystem 监听 SC_MoveFail,先记录失败原因,再按 EntityId 查找本地 Actor。真正执行位置回滚的条件很窄:

if (actor != null &&
    actor.IsLocal &&
    actor.ActorType == ActorType.Hero)
{
    ForceTeleport(actor, serverPos);
}

因此:

  • 只回滚本地 Hero;远端 Hero、Enemy 和其他 Actor 不在这个分支处理。
  • 回滚位置来自失败包的 serverPos。
  • ResetState(actor) 已被注释,失败后不会自动把角色状态机重置到 Entry State。
  • 方法名叫 ForceTeleport,但最终调用 OnSyncPosition(..., isTeleport: false),表现层是否立即跳转还取决于 OnSyncPosition 对该标志的实现。

这也是为什么 MoveFail 排错不能只确认“服务端回包了”:还要确认 EntityId 找到的是本地 Hero、ViewObject 存在,并观察旧移动控制器和动画状态是否继续运行。

6. 客户端与服务端如何共同完成一次移动

flowchart TD
    A[输入 / AI 产生目标点] --> B[客户端 SamplePosition]
    B -->|失败| X[不发包 / 结束本次移动]
    B -->|成功| C[NavMeshAgent SetDestination]
    C --> D[CalculatePath + corners]
    D --> E[GetPathLength / MoveSpeed = Duration]
    E --> F[整数化和 DeltaCompress]
    F --> G[CS_MoveBegin]
    G --> H[ParamProgress 校验]
    H --> I[实体归属校验]
    I --> J[LiveStatus / Target / Strategy 校验]
    J -->|失败| K[SC_MoveFail + serverPos]
    K --> K2[RollbackSystem\n仅本地 Hero 位置回滚]
    J -->|成功| L[更新服务端位置 / 朝向]
    L --> M[SC_MoveBegin 广播其他客户端]
    M --> N[远端 ApplyMoveData / Dead Reckoning]

这里最容易出现的误会是把 Duration 当成服务器计时器。当前服务器解析并转发这个值,但 ParseMoveBegin 没有按照 NavMesh 路径重算 duration,也没有一个服务端移动 Tick 去逐帧推进怪物位置。

客户端发送端也不是严格的“完整路径证明”:NavMoveSyncHelper.ProcessMoveDataBeforeSend 调用 CalculatePath 后没有检查 PathComplete,Partial/Invalid 时仍可能按已有 corners 写入 Duration。接收端本地重算速度时才要求完整路径。服务端目前既不补这个检查,也不重新计算,所以“包里有 Duration”不能证明目标合法。

7. 地图版本和关卡进度保护

ParamProgress 用于防止旧地图/旧关卡状态的移动包进入当前 Battle。服务端在 OnMoveBegin 中先调用 Battle.IsDifferentLevelProgress。客户端发包时的值来自 WorldAdapter.UniqueWorld?.GetGameplay()?.LevelProgress ?? 0。

这只能保护“关卡进度”维度,不能替代地图资源版本校验。客户端和服务端的 Maps/Data、资源 GUID 索引或模板组合不一致时,仍可能出现:

  • 客户端路径可达,服务端位置快照却落在另一块地图。
  • 一个端有 Portal,另一个端没有。
  • 远端 Agent 使用不同 areaMask,表现速度和到达时间不同。

地图发布必须把客户端 Maps/Data、服务端地图数据和 AssetsMapPath.bytes 当成一个版本一起发布。

8. 当前实现的风险与排错方法

8.1 风险清单

  1. 客户端可伪造目标点/速度/时长:服务端只做字段和状态校验,未验证目标是否在导航区。
  2. 服务端位置语义不统一:英雄取目标点,怪物 NavMove 取源点,调试时必须结合实体类型和策略看日志。
  3. 服务端没有路径长度基准:客户端与服务端无法独立确认 Duration 是否合理。
  4. 增量压缩依赖上下文:丢包、重连或 param 不匹配会导致解压失败,必须先看 ParamEx 和缓存状态。
  5. 失败包可能已有状态副作用:ActionStatus = MOVE 发生在空数据等检查之前,失败时没有回滚。
  6. Hero 中断与移动成功没有绑定:接收入口处理 Hero 时可能在 MoveFail 的同时中断复活搭档或延迟技能。
  7. 回滚覆盖面有限:客户端 RollbackSystem 只修正本地 Hero 的位置,不重置状态机,也不处理 Enemy。

8.2 一次移动失败的日志顺序

  1. 客户端是否真的执行 NavMeshTool.IsOnNavMesh,目标 area 是否正确。
  2. 客户端 StateSyncSystem 是否发出了 CS_MoveBegin,检查 EntityId/ParamEx/ParamProgress。
  3. Battle 是否打印 Different Level Progress。
  4. findSelfEntity 是否找到实体,尤其是 Bullet/OwnerHero 分支。
  5. ParseMoveBegin 失败原因是否为 LiveStatus、NullMoveData 或 TargetPos,并记录失败前后的 ActionStatus。
  6. 若收到 SC_MoveFail,确认客户端找到的是本地 Hero、ViewObject 存在,并检查回滚后移动器/动画是否仍在旧状态。
  7. 成功后是否有 SC_MoveBegin 广播,其他客户端是否在 ApplyMoveData 阶段丢弃旧更新。
  8. 若只有表现偏差,再回到客户端 Duration、路径状态、PacketCache 加速倍率和 Dead Reckoning 阈值。

8.3 推荐临时日志字段

battleId, playerId, entityId, entityType
strategyType, syncParam, state, paramProgress
srcPos, targetPos, moveSpeed, duration, stopDistance
serverPosition, client isOnNavMesh, agent.areaMask

9. 如果要把服务端升级为真正的导航权威

这是架构改造,不是给 ParseMoveBegin 加一个 Distance 判断就结束。至少要分四步:

9.1 统一可查询导航数据

服务端需要获得与客户端同版本的导航几何或可验证的区域图,不能依赖 Unity Editor 中不存在的 NavMeshAgent。可选方案是导出多边形/连接图、采用固定版本服务端导航库,或先做区域/边界验证。

9.2 服务端重新计算关键量

至少重算:

target 是否在允许区域
source -> target 是否 PathComplete
pathLength / moveSpeed = duration
最大速度、加速度和 stopDistance 是否符合配置

9.3 扩展可诊断失败原因

增加 TargetNotOnNavMesh、PathPartial、SpeedLimit、DurationMismatch、MapVersionMismatch,并保留 serverPos。客户端收到后应停止本地 Agent、Warp 到服务端位置并重新请求。

9.4 处理动态障碍和确定性

如果服务端只验证静态图,而客户端有动态门、NavMeshObstacle 或 Link 开关,双方仍会分叉。必须把动态障碍事件纳入战斗状态,或者明确规定服务器只做静态区域验证、客户端负责表现,不能两边各自做不同决策。

10. 与其他模块的关系

上一篇:客户端寻路、NavMesh 资源与移动表现 · 下一篇:刷怪与战斗模块