基于ET框架与Unity实现客户端-服务端协作自动寻路Demo实战

📅 2026/7/8 17:35:51 👁️ 阅读次数 📝 编程学习
基于ET框架与Unity实现客户端-服务端协作自动寻路Demo实战

1. 项目概述与核心价值

最近在社区里看到不少朋友对ET框架和Unity结合做自动寻路Demo很感兴趣,但上手时总感觉无从下手,或者卡在某个环节。我正好用ET框架做过几个MMO项目,其中寻路是核心模块之一,今天就来拆解一下,如何从零开始,用ET框架和Unity快速搭建一个能跑起来的自动寻路Demo。这个Demo的重点是客户端实战,我们会把服务端的寻路能力“透传”到客户端,在Unity里实现一个角色从A点自动走到B点的完整流程。

为什么选择ET框架来做这件事?对于游戏开发,尤其是网络游戏,一个常见的痛点就是逻辑同步。寻路逻辑如果只在服务端跑,客户端只是播放动画,那手感会很“飘”,延迟感明显。ET框架的“双端C#”特性,让我们可以用同一套C#代码,在服务端进行权威的寻路计算和碰撞检测,同时在客户端进行预测和即时表现。这样既能保证逻辑一致性,又能通过客户端的即时反馈提升操作手感。这个Demo要做的,就是打通这个链路:在Unity编辑器里,你点击地面,角色就能平滑地走过去,背后是服务端计算路径、客户端接收并移动的完整闭环。

这个Demo适合谁呢?如果你是对ET框架感兴趣,想了解其客户端-服务端协作模式的Unity开发者;或者你是想学习如何在网络游戏里实现手感更好的自动寻路功能的同学;亦或是你厌倦了纯客户端的NavMesh,想探索服务端权威寻路如何与客户端表现结合,那么跟着走一遍,你就能得到一个可运行、可扩展的基石。整个过程我们会用到ET框架的基础组件、网络通信、以及Unity的动画和输入系统,最终你会得到一个点击移动、带寻路动画的完整Demo。

2. 环境准备与项目初始化

2.1 获取ET框架与Unity工程

第一步,是把ET框架的源码和示例工程拉下来。ET框架的源码托管在GitHub上,我们直接克隆即可。这里有个小建议,为了网络稳定,可以使用GitHub的镜像源或者直接下载ZIP包。打开命令行,找一个你习惯的工作目录,执行:

git clone https://github.com/egametang/ET.git

克隆完成后,你会得到一个名为ET的文件夹。里面包含了服务端(Server目录)、客户端(Unity目录)以及各种工具和文档。我们主要关注的是Unity目录下的工程。用Unity Hub打开这个工程,建议使用Unity 2021.3 LTS或2022.3 LTS版本,兼容性最好。第一次打开可能会需要一些时间导入资源和编译脚本。

注意:ET框架的Unity工程结构比较特别,它把服务端的代码也作为程序集引用放在了客户端工程里,这正是实现“双端共享逻辑”的基础。所以你会看到一些服务端的组件和系统定义,这在纯客户端项目里是不常见的,不要觉得奇怪。

2.2 理解ET框架的客户端工程结构

打开Unity工程后,花几分钟浏览一下关键目录,这对后续操作至关重要:

  • Assets/Model: 存放实体(Entity)和数据组件的定义。这些是纯数据类,不包含逻辑,客户端和服务端都会引用。我们后面要创建的“移动组件”、“寻路请求组件”都会放在这里。
  • Assets/Hotfix: 存放逻辑系统(System)的定义。这些是包含逻辑方法的静态类,通过为Entity编写扩展方法来实现功能。热更新的代码就放在这里。我们的寻路移动逻辑、输入处理逻辑都会写在这里。
  • Assets/ModelView: 存放客户端的视图组件和表现逻辑。这部分代码只会在客户端运行,用于将Model层的数据状态同步到Unity的GameObject和组件上。比如,把寻路目标位置转换成角色的Transform移动。
  • Assets/ThirdParty: 存放ET框架依赖的第三方库,比如用于序列化的MemoryPack、网络库等。
  • Assets/Res/Scenes: 存放场景文件。ET框架的Demo通常会有一个Init场景作为入口。

我们的目标是在这个已有的结构上,添加我们自己的寻路Demo逻辑,而不是从头创建一个新工程。这样可以省去大量配置网络、初始化框架的时间。

2.3 服务端环境配置(用于寻路计算)

虽然标题是客户端实战,但寻路计算本身是在服务端进行的(为了权威性)。因此,我们需要确保服务端能运行起来。ET框架的服务端是.NET Core/.NET 6+的项目,可以在Windows或Linux上运行。为了简化,我们就在Windows上用Visual Studio 2022来运行。

  1. 用VS2022打开ET/Server/ET.sln解决方案。
  2. 将启动项目设置为App.Location(位置服务器)、App.Map(地图/寻路服务器)、App.Gate(网关服务器)和App.Program(客户端连接入口)。ET框架提供了多进程启动的配置,但为了Demo简单,我们可以使用其“单进程多组件”模式,即所有服务器功能跑在一个进程里。框架自带的StartConfig/StartConfig/LocalAllServer.txt配置文件就是用于这种模式的。
  3. 确保App.Program的启动参数指向了正确的配置文件路径。通常可以直接运行,它会自动加载本地配置。

运行起来后,你应该能在控制台看到各个服务器组件初始化的日志。看到类似Map server start finish这样的日志,就说明包含寻路功能的地图服务器已经准备好了。服务端会监听特定的端口(如20002)等待客户端连接。

3. 核心思路与模块设计

3.1 双端协作的寻路流程拆解

在动手写代码前,我们必须把数据流和逻辑流想清楚。一个完整的点击寻路流程,涉及客户端、服务端多次交互:

  1. 客户端输入与请求:玩家在Unity场景中点击地面。客户端需要捕获这个屏幕点击,通过射线检测转换成3D世界坐标(Vector3)。然后,客户端需要创建一个寻路请求消息,里面包含角色ID、起始坐标(通常从服务端同步或本地预测)、目标坐标,并通过网络会话(Session)发送给服务端。
  2. 服务端权威计算:服务端的Map服务器收到寻路请求。它首先进行合法性校验,比如目标点是否在可行走区域、距离是否过远等。校验通过后,调用ET框架集成的Recast寻路库DotRecast)进行真正的路径计算。Recast会基于预先烘焙好的导航网格(NavMesh)数据,返回一个由多个路点(Vector3列表)构成的路径。
  3. 服务端广播路径:计算完成后,服务端不能只把路径发回给请求的客户端。因为在多人游戏中,其他玩家可能需要看到这个角色的移动。所以服务端会创建一个移动广播消息,里面包含角色ID、移动速度、路径点列表,然后广播给所有在相同AOI(兴趣区域)内的客户端(包括请求者自己)。
  4. 客户端接收与表现:客户端收到移动广播消息。ModelView层的移动系统会根据路径点,在本地驱动角色GameObject移动。这里通常采用插值(Lerp)跟随路径点的方式,让移动看起来平滑。同时,要播放对应的行走动画。
  5. 客户端预测与同步:为了更佳手感,客户端可以在发送请求后立即开始预测移动,即先假设服务端会同意,让角色先动起来。当收到服务端确认的路径后,再与预测的路径进行校正。对于Demo,我们可以先实现基础的非预测版本。

3.2 需要创建的组件与消息定义

根据上面的流程,我们需要在ET框架的架构下定义几个核心部分:

  • 消息(Message)

    • C2M_PathfindingResult:客户端发送给服务端的寻路请求消息。C2M代表Client to Map。
    • M2C_PathfindingResult:服务端广播给客户端的寻路结果消息。M2C代表Map to Client。
    • 消息定义要放在Assets/Model/Generate/Message目录下(如果是使用框架工具生成的),或者手动在Model层创建。它们必须是可序列化的,因为要通过网络传输。
  • 组件(Component)

    • PathfindingComponent:可以挂在玩家实体(Unit)上,用于在服务端存储临时的寻路目标、路径或状态。也可以作为寻路能力的标识。
    • MoveComponent:这是一个更通用的组件,挂在玩家实体上,用于存储和服务端同步的移动状态,如:是否在移动、移动速度、当前路径索引等。客户端和服务端可能都有这个组件,但用途略有不同。
  • 系统(System)

    • PathfindingHelper:一个静态工具类,封装对Recast寻路库的调用。服务端的寻路逻辑会调用它。
    • C2M_PathfindingResultHandler:服务端处理寻路请求的处理器(Handler)。这是一个MessageHandler,负责接收C2M_PathfindingResult消息,调用PathfindingHelper计算路径,然后构造并广播M2C_PathfindingResult消息。
    • UnitMoveSystem:在客户端的HotfixModelView层,这是一个驱动角色根据MoveComponent中路径进行移动的系统。它会在Update循环中,让角色Transform逐步走向下一个路点。

3.3 导航网格(NavMesh)数据的准备与导出

这是服务端寻路的基石。ET框架的服务端寻路依赖于Recast,而Recast需要导航网格数据。这个数据需要从Unity编辑器中导出。

  1. 在Unity中烘焙导航网格:在Demo场景中,为所有静态的、不可行走的物体(如墙壁、山脉)设置好碰撞体。然后打开Window -> AI -> Navigation面板。在Bake页签下,设置好合适的Agent Radius(角色半径)、Agent Height(角色高度)、Max Slope(最大坡度)等参数,点击Bake按钮。Unity会为场景生成一个蓝色的导航网格。
  2. 导出NavMesh数据:ET框架提供了工具将Unity烘焙的NavMesh转换成Recast可用的格式。你需要找到并运行ET框架提供的导出工具(通常在Tools目录下可能有脚本,或者需要参考文档编写一个简单的编辑器脚本)。这个工具的核心是调用Unity的NavMesh.CalculateTriangulation()方法获取网格的顶点和索引数据,然后将其序列化成二进制文件(如.obj或自定义的.nav格式)。
  3. 服务端加载导航数据:导出的导航数据文件需要放在服务端程序能访问到的目录(如Config/Recast)。在服务端Map服务器的初始化代码中,需要调用Recast库的接口加载这个文件,初始化导航网格上下文。这样,当寻路请求到来时,才能进行查询。

实操心得:烘焙导航网格时,Agent Radius不要设得太小,否则路径会太贴近障碍物,角色移动时容易“卡住”在视觉上。对于服务端寻路,可以适当放宽这个参数,让路径更“宽松”。导出的数据文件记得在服务端启动时检查是否加载成功,这是寻路失败最常见的原因之一。

4. 关键代码实现与解析

4.1 定义网络消息

首先,在Assets/Model层定义我们的消息类。ET框架通常使用MemoryPack进行序列化,我们需要为消息类添加[MemoryPackable]特性。

// 文件:Assets/Model/Generate/Message/MapMessage_C2M.cs // 客户端 -> 地图服务器 寻路请求 [Message(OuterOpcode.C2M_PathfindingResult)] [MemoryPackable] public partial class C2M_PathfindingResult: MessageObject, ILocationRequest { public static C2M_PathfindingResult Create(bool isFromPool = false) => ObjectPool.Instance.Fetch<C2M_PathfindingResult>(isFromPool); [MemoryPackOrder(0)] public long UnitId { get; set; } // 请求移动的角色ID [MemoryPackOrder(1)] public float TargetX { get; set; } // 目标点X坐标 [MemoryPackOrder(2)] public float TargetY { get; set; } [MemoryPackOrder(3)] public float TargetZ { get; set; } } // 文件:Assets/Model/Generate/Message/MapMessage_M2C.cs // 地图服务器 -> 客户端 寻路结果广播 [Message(OuterOpcode.M2C_PathfindingResult)] [MemoryPackable] public partial class M2C_PathfindingResult: MessageObject, ILocationMessage { public static M2C_PathfindingResult Create(bool isFromPool = false) => ObjectPool.Instance.Fetch<M2C_PathfindingResult>(isFromPool); [MemoryPackOrder(0)] public long UnitId { get; set; } // 移动的角色ID [MemoryPackOrder(1)] public List<float> Xs { get; set; } = new(); // 路径点X坐标列表 [MemoryPackOrder(2)] public List<float> Ys { get; set; } = new(); [MemoryPackOrder(3)] public List<float> Zs { get; set; } = new(); [MemoryPackOrder(4)] public float Speed { get; set; } = 5f; // 移动速度 }

定义完消息后,需要运行ET框架的代码生成工具(通常是菜单栏的ET -> Generate Code),它会自动为这些消息生成对应的处理器类骨架和序列化代码。

4.2 服务端寻路处理器实现

接下来,在服务端逻辑层(对于Demo,代码也在Unity工程的Assets/Hotfix下,但通过条件编译区分)实现消息处理器。

// 文件:Assets/Hotfix/Server/Module/Map/Handler/C2M_PathfindingResultHandler.cs public class C2M_PathfindingResultHandler : MessageHandler<C2M_PathfindingResult> { protected override async ETTask Run(Session session, C2M_PathfindingResult request) { // 1. 参数检查 if (session.DomainScene().SceneType != SceneType.Map) { Log.Error($"请求不是来自Map服务器, sceneType: {session.DomainScene().SceneType}"); return; } Scene mapScene = session.DomainScene(); long unitId = request.UnitId; // 2. 获取请求移动的单位实体 Unit unit = mapScene.GetComponent<UnitComponent>().Get(unitId); if (unit == null) { Log.Error($"C2M_PathfindingResultHandler: 未找到Unit, unitId: {unitId}"); return; } // 3. 获取或添加移动组件 MoveComponent moveComponent = unit.GetComponent<MoveComponent>(); if (moveComponent == null) { moveComponent = unit.AddComponent<MoveComponent>(); } // 4. 构建起始点和目标点 Vector3 startPos = unit.Position; Vector3 targetPos = new Vector3(request.TargetX, request.TargetY, request.TargetZ); // 5. 调用寻路帮助类计算路径 // PathfindingHelper是我们自己封装的工具类,内部调用Recast List<Vector3> path = await PathfindingHelper.SearchPath(mapScene, startPos, targetPos); if (path == null || path.Count < 2) // 路径无效或起点即终点 { Log.Info($"寻路失败或无需移动. unitId: {unitId}, from {startPos} to {targetPos}"); // 可以发送一个空路径或错误码给客户端 return; } // 6. 更新移动组件的路径 moveComponent.Target = targetPos; moveComponent.Speed = 5f; // 可以配置化 moveComponent.Path.Clear(); moveComponent.Path.AddRange(path); moveComponent.MoveForward(); // 开始移动 // 7. 广播移动消息给所有能看见此单位的客户端 M2C_PathfindingResult m2CPathfindingResult = M2C_PathfindingResult.Create(); m2CPathfindingResult.UnitId = unitId; m2CPathfindingResult.Speed = moveComponent.Speed; // 将Vector3路径列表转换成三个float列表,便于网络传输(MemoryPack对List<Vector3>的支持可能需要额外处理) foreach (Vector3 point in path) { m2CPathfindingResult.Xs.Add(point.x); m2CPathfindingResult.Ys.Add(point.y); m2CPathfindingResult.Zs.Add(point.z); } // 获取该Unit所在的地图场景,并广播给该场景中的所有玩家(简化版,实际应通过AOI系统) MapMessageHelper.BroadcastToMapUnits(mapScene, unit, m2CPathfindingResult); // 8. 回收消息对象 m2CPathfindingResult.Dispose(); } }

这里的PathfindingHelper.SearchPath是核心,其内部会调用ET框架封装好的Recast寻路接口。你需要根据ET框架的版本,找到对应的寻路服务或组件(例如RecastNavMeshComponent)来进行查询。

4.3 客户端点击与请求发送

在客户端,我们需要处理玩家的点击输入,并发送寻路请求。

// 文件:Assets/Hotfix/Client/Demo/Pathfinding/ClientPathfindingHelper.cs public static class ClientPathfindingHelper { public static async ETTask SendPathfindingRequest(Vector3 targetPosition) { // 1. 获取当前客户端的Session(网络会话) Session session = Game.Scene.GetComponent<NetClientComponent>().GetSession(); if (session == null || session.IsDisposed) { Log.Error("网络会话未连接或已断开"); return; } // 2. 获取当前控制的角色Unit(假设我们已经有一个本地玩家Unit) Unit unit = UnitHelper.GetMyUnitFromCurrentScene(); if (unit == null) { Log.Error("未找到本地玩家Unit"); return; } // 3. 创建寻路请求消息 C2M_PathfindingResult request = C2M_PathfindingResult.Create(); request.UnitId = unit.Id; request.TargetX = targetPosition.x; request.TargetY = targetPosition.y; request.TargetZ = targetPosition.z; // 4. 发送请求到Map服务器,并等待响应(这里我们发请求,但不等待特定响应,因为结果通过广播回来) // 如果需要确认,可以使用 session.Call(request) session.Send(request); // 5. (可选)客户端预测:立即在本地开始移动,提升响应速度 // 可以先在本地Unit上设置一个临时的目标点,由客户端的移动系统驱动 // MoveComponent moveComp = unit.GetComponent<MoveComponent>(); // if (moveComp == null) moveComp = unit.AddComponent<MoveComponent>(); // moveComp.Target = targetPosition; // moveComp.Speed = 5f; // moveComp.Path.Clear(); // 预测时可能没有详细路径,先直线移动 request.Dispose(); } } // 文件:Assets/Hotfix/Client/Demo/Pathfinding/ClickToMoveComponent.cs // 这是一个挂在客户端场景管理器上的组件,用于监听点击 public class ClickToMoveComponent: Entity, IAwake, IUpdate { private Camera m_MainCamera; public void Awake() { m_MainCamera = Camera.main; } public void Update() { if (!Input.GetMouseButtonDown(0)) // 监听鼠标左键点击 { return; } Ray ray = m_MainCamera.ScreenPointToRay(Input.mousePosition); RaycastHit hit; int groundLayer = LayerMask.GetMask("Ground"); // 假设地面层叫"Ground" if (Physics.Raycast(ray, out hit, 1000, groundLayer)) { Vector3 clickPoint = hit.point; // 可以在这里显示一个点击特效 // GameObject.Instantiate(clickEffectPrefab, clickPoint, Quaternion.identity); // 发送寻路请求 ClientPathfindingHelper.SendPathfindingRequest(clickPoint).Coroutine(); } } }

4.4 客户端移动表现系统

最后,我们需要一个系统来驱动角色根据服务端广播的路径进行移动。这个系统运行在客户端的ModelView层或Hotfix层。

// 文件:Assets/ModelView/Client/Demo/Pathfinding/UnitMoveSystem.cs // 这是一个View层的系统,每帧更新GameObject的位置 public class UnitMoveSystem: UpdateSystem<Unit> { public override void Update(Unit unit) { MoveComponent moveComponent = unit.GetComponent<MoveComponent>(); if (moveComponent == null || !moveComponent.IsMoving) { return; } GameObject go = unit.GameObject; if (go == null) { return; } Transform transform = go.transform; Vector3 currentPos = transform.position; // 获取当前目标路点(简化:直接走向MoveComponent.Target) // 更复杂的实现会遍历Path列表,逐个路点移动 Vector3 targetPos = moveComponent.Target; float speed = moveComponent.Speed; float distance = Vector3.Distance(currentPos, targetPos); if (distance > 0.1f) { // 计算移动方向 Vector3 direction = (targetPos - currentPos).normalized; // 计算本帧位移 float moveDistance = speed * Time.deltaTime; Vector3 newPosition = currentPos + direction * moveDistance; // 如果本帧会越过目标点,则直接到达目标点 if (Vector3.Distance(currentPos, targetPos) <= moveDistance) { newPosition = targetPos; moveComponent.IsMoving = false; // 到达目的地,停止移动 // 触发移动结束事件,例如停止行走动画 Game.EventSystem.Publish(new EventType.UnitMoveStop() { Unit = unit }); } transform.position = newPosition; // 让角色面向移动方向(忽略Y轴旋转) if (direction.sqrMagnitude > 0.01f) { Vector3 lookDirection = new Vector3(direction.x, 0, direction.z); if (lookDirection.sqrMagnitude > 0.01f) { Quaternion targetRotation = Quaternion.LookRotation(lookDirection); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, 10f * Time.deltaTime); } } // 更新Unit组件中的逻辑位置(可选,用于其他系统参考) unit.Position = newPosition; } else { moveComponent.IsMoving = false; Game.EventSystem.Publish(new EventType.UnitMoveStop() { Unit = unit }); } } } // 文件:Assets/Hotfix/Client/Demo/Pathfinding/M2C_PathfindingResultHandler.cs // 处理服务端广播的寻路结果 public class M2C_PathfindingResultHandler: MessageHandler<M2C_PathfindingResult> { protected override void Run(Session session, M2C_PathfindingResult message) { Scene currentScene = Game.Scene.CurrentScene(); Unit unit = currentScene.GetComponent<UnitComponent>().Get(message.UnitId); if (unit == null) { return; } // 将接收到的路径列表转换回Vector3 List<Vector3> path = new List<Vector3>(); for (int i = 0; i < message.Xs.Count; i++) { path.Add(new Vector3(message.Xs[i], message.Ys[i], message.Zs[i])); } MoveComponent moveComponent = unit.GetComponent<MoveComponent>(); if (moveComponent == null) { moveComponent = unit.AddComponent<MoveComponent>(); } // 更新移动组件信息 moveComponent.Path.Clear(); moveComponent.Path.AddRange(path); moveComponent.Speed = message.Speed; moveComponent.Target = path[path.Count - 1]; // 最终目标点是最后一个路点 moveComponent.IsMoving = true; moveComponent.CurrentPathIndex = 0; // 发布开始移动事件,可以用于播放动画等 Game.EventSystem.Publish(new EventType.UnitMoveStart() { Unit = unit }); } }

5. 系统集成与调试运行

5.1 组装与配置

代码写完后,需要将它们集成到ET框架的系统中。

  1. 注册消息处理器:确保C2M_PathfindingResultHandlerM2C_PathfindingResultHandler在相应的程序集(如Hotfix)中,并且被框架自动发现注册。ET框架通常通过特性或约定自动注册实现了IMessageHandler接口的类。
  2. 挂载组件:在创建玩家Unit的代码处,记得为其添加MoveComponent。可以在UnitFactory或角色创建系统中完成。
  3. 初始化点击监听:在客户端场景加载完成后(例如在EnterMapFinish_NotifyClient事件处理中),创建ClickToMoveComponent实体并将其添加到场景中。
  4. 配置导航数据路径:确保服务端的配置文件(如StartConfig/xxx.txt)中,Map进程的配置包含了导航数据文件的正确路径。

5.2 运行与测试

  1. 启动服务端:在Visual Studio中运行配置好的服务端项目(如App.Program),观察控制台日志,确认Map服务器启动成功,并加载了导航网格数据。
  2. 启动客户端:在Unity编辑器中,运行Init场景。客户端会自动连接到本地服务端。
  3. 进入场景:通常Demo会有登录和进入地图的流程。按照Demo的指引,让角色进入一个已经烘焙好NavMesh的场景。
  4. 点击测试:在Game视图中,点击地面。观察:
    • 客户端是否立即发送了请求(可以在ClientPathfindingHelper中加Log)。
    • 服务端控制台是否收到C2M_PathfindingResult请求并打印寻路日志。
    • 服务端是否广播了M2C_PathfindingResult消息。
    • 客户端角色是否开始向点击点移动。
    • 移动过程中,角色是否平滑转向,动画是否正常播放。

5.3 常见问题与排查技巧

在实际操作中,你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方法整理出来,能帮你节省大量时间。

问题1:点击后角色毫无反应,客户端无日志。

  • 检查点
    • ClickToMoveComponent是否被正确创建并启用了Update?在Awake里加个Log看看。
    • 射线检测是否成功?检查地面物体的Layer是否是Ground,以及Camera.main是否正确赋值。
    • 网络会话Session是否已建立?在点击事件里打印一下Game.Scene.GetComponent<NetClientComponent>().GetSession()是否为空。
  • 解决:确保登录流程走完,进入了游戏场景,并且网络连接是活跃状态。可以在点击代码里先写死一个目标坐标发送,排除射线检测问题。

问题2:客户端有发送日志,服务端收到请求但寻路失败或角色不动。

  • 检查点
    • 服务端日志:查看服务端控制台,C2M_PathfindingResultHandler中的Log是否打印。检查PathfindingHelper.SearchPath的返回值是否为null或空。
    • 导航数据:这是最常见的问题。确认服务端启动时是否成功加载了.nav.obj文件。检查文件路径是否正确,文件内容是否完整(可以从Unity重新导出一次)。
    • 坐标转换:Unity场景的坐标系(通常是左手系,Y轴向上)与Recast使用的坐标系(可能Z轴向上)可能存在差异。检查PathfindingHelper中是否进行了正确的坐标转换。一个简单的测试方法是,在服务端写死一个从(0,0,0)到(10,0,10)的寻路请求,看是否能返回路径。
    • 移动组件:服务端是否成功为Unit添加了MoveComponent并设置了PathIsMoving?广播的消息里路径点列表Xs/Ys/Zs是否不为空?
  • 解决:在PathfindingHelper中加入详细的调试日志,打印传入的起点、终点坐标,以及Recast查询的结果。对比Unity编辑器中场景的实际坐标。

问题3:角色移动了,但动作僵硬、不转向或卡顿。

  • 检查点
    • 移动系统Update频率UnitMoveSystem是否被正确注册到ET的UpdateSystem中?确保它每帧都在执行。
    • 插值方式:当前的移动是每帧直接position = newPosition,没有插值,可能会在路径点切换时产生“跳跃”。考虑使用Vector3.MoveTowards或更平滑的插值方法。
    • 路径跟随逻辑:上面的示例代码简化了路径跟随,只朝最终目标点移动。对于复杂路径,需要实现沿着moveComponent.Path列表逐个路点移动的逻辑。更新CurrentPathIndex,当接近当前路点时,索引加一,指向下一个路点。
    • 动画状态机:是否在UnitMoveStartUnitMoveStop事件中正确触发了动画状态切换?检查Animator Controller的参数是否被正确设置(如IsMoving布尔值)。
    • 网络同步频率:服务端广播M2C_PathfindingResult的频率是怎样的?如果角色每移动一小段就广播一次,会导致频繁重置客户端的移动状态,产生卡顿。通常是一次寻路计算完成后广播完整路径。
  • 解决:实现完整的路径点跟随逻辑。在UnitMoveSystem的Update中,判断与当前目标路点(moveComponent.Path[moveComponent.CurrentPathIndex])的距离,如果小于一个阈值(如0.1),则CurrentPathIndex++,如果到达最后一个路点,则停止移动。同时,使用Quaternion.LookRotationQuaternion.Slerp来实现平滑的转向。

问题4:多人情况下,其他客户端看不到角色移动。

  • 检查点
    • 广播范围MapMessageHelper.BroadcastToMapUnits这个方法是自己实现的吗?它是否正确地将消息发送给了所有在同一个地图场景、且在AOI范围内的玩家Session?ET框架通常有MessageHelper.BroadcastActorMessageSender等工具方法来广播给指定场景内的所有Unit。
    • Unit的可见性:其他客户端的场景中,是否已经创建了这个Unit的GameObject表示?移动系统UnitMoveSystem是否对所有Unit都生效(而不仅仅是本地玩家)?
  • 解决:确保广播方法正确。可以简化测试:在服务端Handler中,直接通过session.DomainScene().GetComponent<UnitComponent>().GetAll()遍历所有Unit,然后获取每个Unit的Session并发送消息(注意排除自己或无效Session)。在客户端,确保所有Unit(包括其他玩家)都挂载了MoveComponent,并且UnitMoveSystem能处理它们。

问题5:性能问题,频繁寻路导致服务端卡顿。

  • 检查点
    • 寻路调用频率:是否允许客户端无限制地每秒发送多次寻路请求?需要在服务端或客户端做节流。例如,在客户端的ClickToMoveComponent中,记录上次发送请求的时间,如果间隔太短(如小于0.5秒),则忽略新的点击。
    • Recast查询开销:复杂的导航网格上的长距离寻路是比较耗时的。考虑对寻路请求进行异步化处理,避免阻塞主线程。ET框架的async/await天然支持这一点,确保PathfindingHelper.SearchPathasync方法,并且内部调用的是异步的寻路接口(如果Recast库提供的话)。
    • 路径缓存:对于静态场景,相同起点和终点的寻路结果可以缓存一段时间。
  • 解决:在客户端输入层加入点击冷却时间。在服务端,如果Recast库是同步的,考虑将其调用包装到Task.Run中放入线程池执行,但要注意线程安全。

6. 优化与扩展方向

一个能跑的Demo只是起点。要让这个寻路系统真正可用,还需要考虑以下优化和扩展:

  1. 客户端预测与 reconciliation:实现真正的预测移动。客户端发送请求后立即开始沿射线方向直线移动。收到服务端路径后,比较预测位置和权威路径,如果偏差过大,则“拉扯”回正确路径,或者采用更平滑的校正。这能极大改善高延迟下的操作手感。
  2. 移动同步与状态同步:目前的Demo只同步了路径。在真正的MMO中,服务端需要定期(如每秒10-20次)同步角色的当前位置、速度、朝向给其他客户端,用于修正和插值,防止不同客户端看到的位置不一致。
  3. 障碍物动态更新:如果场景中有可移动的障碍物(如门、箱子),需要能动态更新服务端的导航网格。Recast有对应的动态障碍物API,需要在障碍物状态改变时,通知服务端更新寻路数据。
  4. 移动类型与速度:为MoveComponent增加移动类型(行走、跑步、游泳、飞行)和对应的速度曲线。寻路时可以根据移动类型选择不同的导航网格层(如果有的话)。
  5. 动画融合与根运动:将简单的播放行走动画升级为使用动画根运动(Root Motion)来驱动位移,这样角色的移动步频和距离会更贴合动画本身,看起来更自然。
  6. 点击特效与UI反馈:在点击点生成一个临时的光圈或箭头特效,持续一两秒后消失,给玩家明确的视觉反馈。同时,可以在角色头顶显示一个短暂的路径线预览。

这个从零到一的自动寻路Demo,核心价值在于打通了ET框架下客户端与服务端协作的完整链路。它不仅仅是一个寻路功能,更是一个理解ET框架消息通信、组件系统、双端逻辑分工的绝佳范例。当你亲手看到角色在你点击的位置平滑走去时,你会对“网络游戏客户端如何与服务端对话”有一个非常具象的认识。