尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

UE5 C++构建CRPG战斗网格系统:数据结构、动态更新与寻路优化

UE5 C++构建CRPG战斗网格系统:数据结构、动态更新与寻路优化 上周在群里看到有朋友问UE5里做战棋或回合制战斗怎么处理角色移动和技能范围。他试过用蓝图拖拽发现小规模测试还行一旦角色多了、地形复杂了或者想动态改变可移动区域代码就变得又乱又难维护。这其实是个很典型的问题很多教程只教你怎么“画”出一个网格但没讲清楚网格数据在战斗系统里到底应该怎么存、怎么算、怎么更新——而这恰恰是后期卡顿和Bug的根源。今天我们就深入聊聊在UE5 C里构建CRPG战斗系统时网格Grid到底该怎么设计。这不是一个简单的视觉显示问题而是一个数据结构和状态管理问题。我会带你从最基础的网格表示开始一步步构建出可查询、可更新、甚至支持动态阻挡和高级寻路的网格系统。你会发现把网格当成“数据”而非“贴图”来思考整个战斗系统的稳定性和扩展性会完全不一样。1. 为什么你的战斗网格不应该只是一个视觉装饰很多刚开始用UE5做战棋或回合制项目的开发者第一个直觉是我用StaticMesh拼一个棋盘或者用ProceduralMeshComponent画一个网格不就行了吗视觉上确实可以但问题很快就会暴露。假设你的角色要移动到某个格子你需要知道这个格子坐标X3, Y5对应世界空间中的哪个位置这个格子现在是否被其他角色占据这个格子是否是可通行的地形比如不是墙壁、不是深渊从角色当前位置到目标格子路径是否畅通如果我要施放一个范围技能哪些格子在影响范围内如果你的网格只是一个视觉模型那么上述每一个问题你都需要通过昂贵的物理查询如LineTrace或Overlap来实时计算。角色一多帧率就会骤降。更麻烦的是状态管理会变得极其松散一个格子是否被占据可能散落在角色Actor的某个变量里地形是否可通行可能要靠碰撞体积来判断。当你需要“获取所有可移动格子”或“判断技能是否可施放”时代码就会变成一堆难以维护的Getter集合。所以我们需要的第一个核心认知是战斗网格首先是一个高效的内存数据结构其次才是一个可视化的表现。它的核心职责是快速查询给定一个逻辑坐标GridX, GridY立刻能知道它的世界位置、通行状态、占据者等信息。集中管理所有格子的状态空闲、占据、不可通行、危险区域等由一个中心化的系统管理避免状态分散。批量计算支持快速计算移动范围、技能影响区域、寻路等。在C中这意味着我们需要一个UGridSystem这样的类它内部维护一个二维数组或更高效的数据结构每个元素是一个FGridCell结构体包含了这个格子所有的逻辑状态。// 示例一个简化的网格格子数据单元 USTRUCT(BlueprintType) struct FGridCell { GENERATED_BODY() // 逻辑坐标 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) FIntPoint Coordinate; // 对应的世界空间中心位置可缓存避免重复计算 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) FVector WorldCenter; // 格子类型空地、障碍、高台、水域等 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) EGridType GridType EGridType::Empty; // 当前占据该格子的Actor如角色、障碍物 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) TWeakObjectPtrAActor OccupyingActor; // 是否为技能或效果的影响区域 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) bool bIsAffectedArea false; // 移动至此格子的消耗用于寻路算法如平地1森林2山地3 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) int32 MovementCost 1; };有了这个基础数据结构UGridSystem就可以提供一系列高效的方法GetCell(const FIntPoint Coord) - FGridCell*SetCellOccupant(const FIntPoint Coord, AActor* Occupant)GetReachableCells(const FIntPoint StartCoord, int32 MovementRange) - TArrayFIntPointIsLineOfSightClear(const FIntPoint From, const FIntPoint To) - bool这个系统完全独立于渲染。你可以先用简单的Debug绘制如DrawDebugBox来验证逻辑后期再替换成精美的网格模型。先让数据跑通再考虑美化这是避免项目后期陷入重构的关键。2. 网格系统的初始化与动态更新不只是加载关卡时的事初始化网格系统很多人会想到在游戏模式GameMode或关卡蓝图Level Blueprint开始时遍历整个场景根据物理碰撞或地形标签来设置每个格子的GridType和MovementCost。这没错但只做了一半。一个健壮的网格系统必须考虑动态更新。战斗不是静态的箱子可以被推走障碍消失桥梁可能被炸毁通行区域变不可通行角色释放了改变地形的法术如创造一片冰面或焦土。如果你的网格数据在初始化后就一成不变那么这些动态变化要么无法表现要么又得绕回昂贵的实时物理检测。因此你的UGridSystem应该提供动态更新接口并且能够通知所有关心网格状态变化的系统如寻路、AI决策、UI高亮。// 在GridSystem中声明动态更新方法 class UGridSystem : public UObject { // ... 其他成员 ... public: // 动态更新一个格子的类型和消耗例如将一块草地变为燃烧的焦土 UFUNCTION(BlueprintCallable, Category Grid System) void UpdateCellType(const FIntPoint Coord, EGridType NewType, int32 NewMovementCost); // 动态设置/清除格子的占据者 UFUNCTION(BlueprintCallable, Category Grid System) void SetCellOccupant(const FIntPoint Coord, AActor* Occupant); UFUNCTION(BlueprintCallable, Category Grid System) void ClearCellOccupant(const FIntPoint Coord); // 声明一个多播委托当网格数据变化时通知订阅者 DECLARE_MULTICAST_DELEGATE_TwoParams(FOnGridDataChanged, const FIntPoint /*ChangedCoord*/, const FGridCell /*NewCellData*/); FOnGridDataChanged OnGridDataChanged; }; // 实现动态更新并触发通知 void UGridSystem::UpdateCellType(const FIntPoint Coord, EGridType NewType, int32 NewMovementCost) { if(FGridCell* Cell GetCell(Coord)) { Cell-GridType NewType; Cell-MovementCost NewMovementCost; // 数据已变通知其他系统如寻路重新计算、UI更新高亮 OnGridDataChanged.Broadcast(Coord, *Cell); // 可能还需要更新NavMesh或寻路图的对应区域 UpdatePathfindingForCell(Coord); } }那么哪些系统需要监听网格变化呢寻路系统Pathfinding这是最重要的。当地形成本或通行性改变寻路算法如A*所使用的图Graph必须更新否则角色会试图走向已不存在的路。战斗AIAI在决策移动或施法时会基于网格信息计算价值。网格变了AI的决策依据也应变。用户界面UI用于高亮可移动区域、技能范围的UI组件需要根据最新的网格数据刷新显示。技能系统范围技能的影响区域计算需要基于最新的格子状态如哪些格子有敌人、哪些是友方单位。注意动态更新虽然强大但也要注意性能。避免在每帧高频更新大量格子。通常地形变化是离散的事件如技能触发、物体被破坏在事件发生时进行局部更新即可。3. 从格子坐标到世界空间建立稳定可靠的映射关系这是很多新手会栽跟头的地方如何将逻辑上的网格坐标(X, Y)准确无误地映射到游戏世界中的位置(WorldX, WorldY, WorldZ)反过来如何根据一个角色的世界位置快速判断它位于哪个格子里一个常见的错误是简单地将WorldLocation FVector(X * GridSize, Y * GridSize, 0)。这忽略了几个关键问题网格原点Grid Origin你的网格(0,0)点对应世界坐标的哪里是关卡原点(0,0,0)吗还是某个特定Actor的位置地面高度Ground Height游戏地形通常不是平坦的。格子中心点的Z值高度如何确定是取地形碰撞体表面的高度还是角色站立的一个固定平面旋转Rotation如果你的网格不是对齐世界轴比如地图是旋转的那么坐标转换还需要考虑旋转矩阵。一个健壮的映射模块应该处理所有这些情况。我建议在UGridSystem中封装这些转换函数并考虑缓存结果以提高性能。// 在GridSystem中处理坐标转换 FVector UGridSystem::GridToWorld(const FIntPoint GridCoord, bool bSnapToGround) const { // 1. 计算基于原点和网格大小的平面位置 FVector BaseWorldLocation GridOrigin FVector(GridCoord.X * GridWidth, GridCoord.Y * GridHeight, 0.0f); // 2. 如果需要贴地考虑地形起伏 if(bSnapToGround) { FHitResult HitResult; FVector TraceStart BaseWorldLocation FVector(0, 0, TraceHeightAbove); FVector TraceEnd BaseWorldLocation - FVector(0, 0, TraceDepthBelow); // 执行射线检测寻找地面如ECC_WorldStatic通道 if(GetWorld()-LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_WorldStatic)) { BaseWorldLocation.Z HitResult.Location.Z GroundOffset; // 加上一个偏移量让角色站在地面上方 } // 如果没检测到地面可以回退到预设高度或记录错误 } else { // 使用预设的固定高度适合平面战斗 BaseWorldLocation.Z FixedGroundHeight; } return BaseWorldLocation; } FIntPoint UGridSystem::WorldToGrid(const FVector WorldLocation) const { // 将世界位置转换到网格局部空间考虑网格旋转 FVector LocalOffset WorldLocation - GridOrigin; // 如果网格有旋转需要逆旋转LocalOffset // FVector RotatedOffset GridRotation.UnrotateVector(LocalOffset); // 假设GridRotation是FRotator或FQuat // 计算网格索引向下取整 int32 GridX FMath::FloorToInt(LocalOffset.X / GridWidth); int32 GridY FMath::FloorToInt(LocalOffset.Y / GridHeight); return FIntPoint(GridX, GridY); }这里有一个关键实践建议在项目早期就用DrawDebugBox或DrawDebugSphere在GridToWorld返回的位置绘制调试图形。移动你的角色用WorldToGrid获取其所在格子并高亮显示。这个简单的调试步骤能帮你及早发现映射错误避免后期移动和寻路出现诡异漂移。4. 将网格数据用于寻路与范围计算超越简单的相邻遍历当你的网格数据结构就绪并且能正确映射坐标后就可以实现战斗系统的核心逻辑移动寻路和技能范围计算。很多人在这里会写一个简单的洪水填充Flood Fill算法从起点开始遍历上下左右相邻格子如果可通行且移动消耗足够就加入可达集合。这对于移动范围显示是可行的。但真正的战斗寻路Pathfinding需要更多它需要找到消耗最小的路径而不仅仅是可达。这时你需要一个寻路算法如A*A-Star。A算法需要一个图Graph而你的网格本质上就是一个图每个格子是节点相邻关系是边移动消耗是边的权重。你需要实现一个适配器将你的FGridCell数据转换为A算法能理解的图节点。// 一个简化的A*节点结构用于寻路计算 struct FPathNode { FIntPoint Coord; FPathNode* Parent nullptr; float G 0; // 从起点到当前节点的实际消耗 float H 0; // 到终点的预估消耗启发式函数如曼哈顿距离 float F() const { return G H; } bool operator(const FPathNode Other) const { return F() Other.F(); } }; // 在GridSystem中实现寻路方法 TArrayFIntPoint UGridSystem::FindPath(const FIntPoint Start, const FIntPoint Goal) { TArrayFIntPoint Path; // 边界检查 if(!IsValidCoord(Start) || !IsValidCoord(Goal)) return Path; // 简单检查目标是否可通行可能被占据或不可移动 if(!CanMoveToCell(Goal)) return Path; // 使用优先队列最小堆来存储待探索节点按F值排序 std::priority_queueFPathNode OpenSet; TMapFIntPoint, FPathNode AllNodes; // 初始化起点 FPathNode StartNode; StartNode.Coord Start; StartNode.H HeuristicCost(Start, Goal); // 启发式成本估算 OpenSet.push(StartNode); AllNodes.Add(Start, StartNode); while(!OpenSet.empty()) { // 取出F值最小的节点 FPathNode Current OpenSet.top(); OpenSet.pop(); // 如果到达目标回溯构建路径 if(Current.Coord Goal) { while(Current.Parent ! nullptr) { Path.Insert(Current.Coord, 0); // 通过Coord从AllNodes找到父节点这里简化处理实际需要存储父节点指针或引用 // ... 回溯逻辑 ... } break; } // 遍历当前节点的所有邻居四方向或八方向 TArrayFIntPoint Neighbors GetNeighbors(Current.Coord); for(const FIntPoint NeighborCoord : Neighbors) { // 检查邻居是否可通行 if(!CanMoveToCell(NeighborCoord)) continue; // 计算从起点经当前节点到邻居的新G值 float NewG Current.G GetMovementCost(Current.Coord, NeighborCoord); // 如果该邻居节点未被探索或找到更优路径 if(!AllNodes.Contains(NeighborCoord) || NewG AllNodes[NeighborCoord].G) { FPathNode NeighborNode AllNodes.FindOrAdd(NeighborCoord); NeighborNode.Coord NeighborCoord; NeighborNode.Parent // ... 设置父节点为Current ... NeighborNode.G NewG; NeighborNode.H HeuristicCost(NeighborCoord, Goal); OpenSet.push(NeighborNode); } } } return Path; }对于技能范围计算如圆形、锥形、直线范围原理类似但更侧重于“哪些格子被覆盖”而不是“如何走过去”。你可以基于网格坐标进行几何计算。例如对于一个以Center为圆心、Radius为半径的圆形范围TArrayFIntPoint UGridSystem::GetCellsInRadius(const FIntPoint Center, int32 Radius) { TArrayFIntPoint Result; for(int dx -Radius; dx Radius; dx) { for(int dy -Radius; dy Radius; dy) { FIntPoint Candidate Center FIntPoint(dx, dy); // 检查坐标是否有效 if(!IsValidCoord(Candidate)) continue; // 计算实际距离根据你的距离度量如曼哈顿距离、切比雪夫距离或欧几里得距离 if(GetDistance(Center, Candidate) Radius) { Result.Add(Candidate); } } } return Result; }关键点GetDistance函数的定义会影响技能范围的表现。战棋游戏常用曼哈顿距离|dx||dy|或切比雪夫距离max(|dx|, |dy|)它们计算出的范围是菱形或正方形。而欧几里得距离sqrt(dx*dx dy*dy)会得到圆形但计算开销稍大且可能产生非整数格子的取舍问题。根据你的游戏规则选择一致的距离度量标准。5. 性能考量与高级优化当你的网格变得很大时当你的战斗地图从几十格扩展到上百甚至上千格并且每帧可能有多个角色在查询移动范围、AI在计算路径时性能问题就会浮现。单纯的二维数组遍历和A*搜索可能会成为瓶颈。这里有几个优化方向1. 空间分区与查询优化分层网格Hierarchical Grid对于超大地图可以使用粗粒度网格管理区域快速排除无关区域。例如先将地图划分为16x16的大区块查询时先定位到大区块再在大区块内的精细网格中搜索。空间哈希Spatial Hashing对于动态物体如角色、可移动障碍物的快速位置查询可以将他们的网格坐标映射到哈希表中实现O(1)复杂度的邻近查询。2. 寻路优化缓存路径对于静态环境中的常见移动请求如从A点到B点可以缓存计算结果。如果网格没有动态变化同一对起点终点的路径是固定的。使用更高效的A*实现使用二叉堆Binary Heap或斐波那契堆Fibonacci Heap作为优先队列。UE本身也提供了UNavigationSystem和NavMesh但对于严格的网格化战棋自定义的网格A*通常更可控。跳点搜索Jump Point Search, JPS对于均匀网格JPS算法可以跳过大量不必要的节点显著提升A*速度特别适合开放区域多的地图。3. 数据存储优化使用位图Bitmask表示状态如果一个格子只有几种简单状态如可通行/不可通行被占据/空闲可以用一个位bit来表示将多个格子的状态压缩到一个整数中节省内存并提高缓存效率。按需加载对于开放世界中的超大规模网格不必一次性加载全部数据。可以根据玩家或战斗区域的位置动态加载和卸载网格数据块。4. 异步计算将寻路放入工作线程A*寻路尤其是长距离寻路是CPU密集型任务。可以使用UE的AsyncTask或自定义工作线程将寻路计算移出游戏线程避免卡顿。计算完成后再将结果传回游戏线程应用。// 伪代码示例异步寻路请求 void AMyCharacter::RequestPathAsync(const FIntPoint Goal) { FIntPoint Start GetCurrentGridCoord(); AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this, Start, Goal]() { TArrayFIntPoint Path GridSystem-FindPath(Start, Goal); // 在后台线程计算 // 将结果派发回游戏线程 AsyncTask(ENamedThreads::GameThread, [this, Path]() { OnPathFound(Path); // 处理找到的路径 }); }); }6. 与蓝图和游戏框架的集成让数据系统驱动游戏逻辑最后我们构建的CUGridSystem需要暴露给蓝图和游戏的其他部分使用。这不仅仅是技术集成更是架构清晰度的体现。1. 对蓝图暴露关键功能使用UFUNCTION(BlueprintCallable)和UPROPERTY(BlueprintReadWrite)将网格查询、状态更新、寻路等核心功能暴露给蓝图。这样关卡设计师可以在蓝图中方便地设置初始障碍、触发地形变化事件UI设计师可以查询格子状态来驱动高亮显示。// 在GridSystem头文件中 UFUNCTION(BlueprintCallable, Category Grid System|Query) bool IsCellPassable(const FIntPoint Coord) const; UFUNCTION(BlueprintCallable, Category Grid System|Query) TArrayFIntPoint GetMovementRange(const FIntPoint StartCoord, int32 MaxMovementCost); UFUNCTION(BlueprintCallable, Category Grid System|Update) void MarkCellAsHazard(const FIntPoint Coord, bool bIsHazard);2. 与角色和战斗组件的通信你的角色类如ACRPGCharacter应该持有对UGridSystem的引用。在角色移动开始时向网格系统查询路径在移动结束时通知网格系统更新占据状态SetCellOccupant。同理技能系统在施放范围技能时也需要查询和更新网格状态如标记一片区域为“燃烧”。3. 作为游戏状态的子系统的初始化通常UGridSystem应该在游戏状态GameState或某个自定义的游戏管理器GameManager中创建和初始化。在关卡开始运行时根据关卡数据可能是数据资产或场景中的Volume构建网格。这样整个游戏世界中的任何对象都可以通过GetGameState()-GetGridSystem()这样的方式来访问统一的网格数据。// 在游戏状态中持有并初始化网格系统 class ACRPGGameState : public AGameStateBase { GENERATED_BODY() public: UPROPERTY(BlueprintReadOnly, Category CRPG) UGridSystem* GridSystem nullptr; virtual void BeginPlay() override { Super::BeginPlay(); GridSystem NewObjectUGridSystem(this); GridSystem-InitializeGrid(/* 参数地图大小、原点、格子大小等 */); } };一个完整的流程示例玩家点击角色意图移动。角色控制器调用GridSystem-GetMovementRange(CurrentCoord, MovementPoints)获取所有可达格子坐标。UI系统接收坐标列表调用GridSystem-GridToWorld转换为世界位置并生成高亮指示器。玩家选择目标格子。角色控制器调用GridSystem-FindPath(CurrentCoord, TargetCoord)获取路径点。角色沿路径移动。移动开始时角色通知GridSystem-ClearCellOccupant(OldCoord)。移动结束时角色通知GridSystem-SetCellOccupant(NewCoord, this)。网格数据始终保持最新为下一个角色或技能提供准确依据。通过这样一套以数据为中心的网格系统你的CRPG战斗就从一堆临时的视觉反馈和物理检测变成了一个状态清晰、计算高效、易于调试和扩展的坚固框架。这为后续实现更复杂的机制如高低差、掩体系统、区域效果持续伤害DoT、动态变化的战场环境打下了坚实的基础。记住好的战斗系统代码读起来应该像棋盘上的规则一样清晰而不是一团纠缠不清的线。
返回列表