ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

UE5 C++ 网格系统构建:CRPG战斗逻辑与A*寻路实现

UE5 C++ 网格系统构建:CRPG战斗逻辑与A*寻路实现 如果你正在用UE5开发一款CRPG并且已经完成了角色、技能、UI等基础模块那么接下来最让你头疼的很可能就是战斗系统的“灵魂”——网格。为什么网格如此关键想象一下你的战士冲锋、法师施法、弓箭手射击这些行为的核心逻辑都依赖于一个根本问题角色能站在哪里能移动到哪技能能影响多大范围如果没有一个清晰、稳定、可计算的网格系统你的战斗就会变成一场混乱的“自由搏击”数值平衡和策略深度无从谈起。很多开发者尤其是从蓝图转向C的开发者容易陷入一个误区认为网格就是简单地用UBoxComponent或USphereComponent做碰撞检测。这确实能实现“碰到”的效果但离一个成熟的、支持寻路、区域划分、动态阻挡的战术网格还差得很远。真正的网格系统是连接游戏逻辑技能目标、移动成本与游戏世界地形高低、障碍物的桥梁。本文将深入UE5 C底层从零构建一个专为CRPG设计的网格系统。这不是一个简单的“画格子”教程而是会带你理解网格的数据结构核心如何用C高效存储和查询网格数据。网格与世界的关联如何将抽象的网格坐标映射到真实的游戏世界位置。网格的动态性如何处理移动单位、临时障碍物对网格通行性的影响。寻路算法的集成基于网格实现A*寻路并考虑移动力消耗、地形惩罚等因素。通过本课你将获得一套可直接用于项目的、基于C的网格系统实现方案彻底解决CRPG战斗中的位置与移动逻辑难题。1. 网格系统要解决的核心问题在深入代码之前我们必须明确一个战斗网格系统究竟要承担哪些职责。这决定了我们的数据结构设计和接口规划。1.1 空间量化与离散化连续的三维游戏世界对计算机来说过于复杂。网格的核心作用是将连续空间离散化为一个个规则的“单元格”(Cell)。每个单元格有一个唯一的坐标如FIntPoint并存储一系列属性是否可通行是否有静态障碍墙壁、山体。移动成本草地、沼泽、道路的成本不同。占据状态当前是否有单位站在上面。高度信息用于处理斜坡、楼梯等地形变化。特殊标记是否是技能施法点、危险区域等。1.2 提供空间查询能力游戏逻辑需要频繁地问网格一些问题“角色在A点他的攻击范围半径3格内有哪些敌人”“从起点到目标点消耗移动力最少或路径最短的路线是什么”“我想在这个位置释放一个扇形火焰会波及到哪些格子” 一个高效的网格系统必须能快速回答这些范围查询和路径查询。1.3 管理动态状态战斗是动态的。单位会移动会死亡会召唤临时障碍如冰墙。网格系统必须能实时更新单元格的占据状态。通知受状态变化影响的系统如寻路系统需要重新计算。高效处理大量动态更新避免每帧全量刷新。1.4 与游戏世界可视化关联网格本身是逻辑概念但我们需要在编辑器中可视化调试绘制网格线也需要将网格坐标(GridX, GridY)转换为世界坐标(FVector)以及反向转换。这关系到技能特效播放、单位站位校准等视觉效果。理解了这些核心问题我们就能有的放矢地设计C类了。2. 核心类设计与数据结构我们将创建几个核心C类来构建网格系统。建议在UE编辑器中创建对应的C类。2.1UBattleGridComponent网格的逻辑核心这是一个UActorComponent可以挂载到任何代表“战场”的Actor上例如一个BattlefieldActor。它负责管理整个网格的数据。// 文件BattleGridComponent.h #pragma once #include CoreMinimal.h #include Components/ActorComponent.h #include BattleGridComponent.generated.h // 预定义网格类型六边形/正方形 UENUM(BlueprintType) enum class EGridType : uint8 { Square, Hexagon }; // 单个网格单元格的数据 USTRUCT(BlueprintType) struct FGridCellData { GENERATED_BODY() public: // 网格逻辑坐标如 0,0 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) FIntPoint GridCoordinate; // 对应的世界空间中心位置 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) FVector WorldCenter; // 是否可通行静态障碍 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) bool bWalkable true; // 基础移动成本1.0为正常1.0更难走 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid, meta (ClampMin 0.1, ClampMax 10.0)) float MovementCost 1.0f; // 当前被哪个单位占据用于动态阻挡 UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category Grid) AActor* OccupyingActor nullptr; // 高度值用于地形差异 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Grid) float Height 0.0f; }; UCLASS(ClassGroup(Custom), meta(BlueprintSpawnableComponent)) class YOURPROJECT_API UBattleGridComponent : public UActorComponent { GENERATED_BODY() public: UBattleGridComponent(); protected: virtual void BeginPlay() override; public: // 初始化网格 UFUNCTION(BlueprintCallable, Category BattleGrid) void InitializeGrid(int32 InWidth, int32 InHeight, float InCellSize, EGridType InGridType EGridType::Square); // 根据世界坐标获取网格坐标 UFUNCTION(BlueprintCallable, Category BattleGrid) bool WorldToGrid(const FVector WorldLocation, FIntPoint OutGridCoord) const; // 根据网格坐标获取世界中心位置 UFUNCTION(BlueprintCallable, Category BattleGrid) FVector GridToWorld(const FIntPoint GridCoord) const; // 获取单元格数据 UFUNCTION(BlueprintCallable, Category BattleGrid) FGridCellData* GetCellData(const FIntPoint GridCoord); // 设置单元格被占据 UFUNCTION(BlueprintCallable, Category BattleGrid) void SetCellOccupied(const FIntPoint GridCoord, AActor* Occupant); // 清除单元格占据 UFUNCTION(BlueprintCallable, Category BattleGrid) void ClearCellOccupation(const FIntPoint GridCoord); // 检查路径是否通畅简单直线或使用寻路 UFUNCTION(BlueprintCallable, Category BattleGrid) bool IsPathClear(const FIntPoint Start, const FIntPoint End) const; // 获取移动范围内的所有单元格用于显示移动高亮 UFUNCTION(BlueprintCallable, Category BattleGrid) void GetCellsInMovementRange(const FIntPoint Center, int32 Range, TArrayFIntPoint OutCells) const; // 在编辑器中绘制调试网格仅Development模式 void DrawDebugGrid() const; private: // 网格宽度格子数 UPROPERTY(EditAnywhere, Category Grid Settings, meta (ClampMin 1)) int32 GridWidth 10; // 网格高度格子数 UPROPERTY(EditAnywhere, Category Grid Settings, meta (ClampMin 1)) int32 GridHeight 10; // 每个格子的边长世界单位 UPROPERTY(EditAnywhere, Category Grid Settings, meta (ClampMin 10.0)) float CellSize 100.0f; // 网格类型 UPROPERTY(EditAnywhere, Category Grid Settings) EGridType GridType EGridType::Square; // 存储所有单元格数据的二维数组 UPROPERTY() TArrayTArrayFGridCellData GridData; // 网格原点在世界中的位置通常是组件Owner的Location FVector GridWorldOrigin; };这个组件是数据容器和基础服务提供者。GridData是核心它是一个二维数组按坐标索引所有单元格。2.2AGridManager网格系统管理器可选但推荐对于更复杂的游戏可以创建一个独立的AGridManager单例Actor来协调多个网格组件、处理高级寻路请求、管理网格事件广播等。这有助于解耦。// 文件GridManager.h (简化示例) UCLASS() class YOURPROJECT_API AGridManager : public AActor { GENERATED_BODY() public: // 获取全局实例简单的游戏实例引用 UFUNCTION(BlueprintPure, Category Grid, meta (WorldContext WorldContextObject)) static AGridManager* GetGridManager(const UObject* WorldContextObject); // 注册一个战场网格 void RegisterBattleGrid(UBattleGridComponent* GridComp); // 根据位置查找对应的网格如果你的游戏有多个独立战场 UBattleGridComponent* FindGridForLocation(const FVector WorldLocation); // 请求路径可能涉及多个网格的复杂逻辑 bool RequestPath(const FVector Start, const FVector End, TArrayFVector OutPath); private: UPROPERTY() TArrayUBattleGridComponent* RegisteredGrids; };2.3 寻路节点与A*算法寻路是网格系统的关键应用。我们需要一个节点类供A*算法使用。// 文件GridPathFindingNode.h #pragma once struct FGridPathFindingNode { FIntPoint GridCoord; FGridPathFindingNode* Parent nullptr; // 用于回溯路径 float GCost 0; // 从起点到本节点的实际代价 float HCost 0; // 到终点的预估代价启发式 float FCost() const { return GCost HCost; } // 用于TArray排序和TSet比较 bool operator(const FGridPathFindingNode Other) const { return FCost() Other.FCost(); } bool operator(const FIntPoint OtherCoord) const { return GridCoord OtherCoord; } }; // 自定义哈希用于将FIntPoint放入TSet FORCEINLINE uint32 GetTypeHash(const FIntPoint Point) { // 一个简单的哈希组合可根据网格大小调整 return (Point.X 16) | (Point.Y 0xFFFF); }A*算法的实现我们将在下一节详细展开。3. 网格的初始化与坐标转换这是网格系统运行的基础。所有高级功能都建立在准确的坐标映射之上。3.1 初始化网格数据在UBattleGridComponent::InitializeGrid中我们需要创建并填充GridData数组。// 文件BattleGridComponent.cpp #include BattleGridComponent.h #include DrawDebugHelpers.h // 用于调试绘制 void UBattleGridComponent::InitializeGrid(int32 InWidth, int32 InHeight, float InCellSize, EGridType InGridType) { GridWidth InWidth; GridHeight InHeight; CellSize InCellSize; GridType InGridType; GridWorldOrigin GetOwner()-GetActorLocation(); // 假设网格原点在Owner位置 // 清空并重新初始化二维数组 GridData.Empty(); GridData.SetNum(GridWidth); for (int32 X 0; X GridWidth; X) { GridData[X].SetNum(GridHeight); for (int32 Y 0; Y GridHeight; Y) { FGridCellData Cell GridData[X][Y]; Cell.GridCoordinate FIntPoint(X, Y); // 计算该格子的世界中心坐标 Cell.WorldCenter GridToWorld(FIntPoint(X, Y)); // 默认是可通行的移动成本为1.0 Cell.bWalkable true; Cell.MovementCost 1.0f; Cell.OccupyingActor nullptr; Cell.Height 0.0f; // TODO: 在这里可以添加基于物理扫描的初始通行性检测 // 例如使用LineTrace或Overlap检查该位置是否有静态障碍物 // 如果检测到则设置 Cell.bWalkable false; } } UE_LOG(LogTemp, Log, TEXT(Battle Grid Initialized: %dx%d, CellSize: %.1f), GridWidth, GridHeight, CellSize); }3.2 世界坐标与网格坐标的相互转换这是最核心的函数之一必须保证精确。FVector UBattleGridComponent::GridToWorld(const FIntPoint GridCoord) const { if (!IsCellValid(GridCoord)) { return FVector::ZeroVector; } FVector WorldLocation; switch (GridType) { case EGridType::Square: { // 正方形网格简单偏移 // 假设X轴对应世界X轴Y轴对应世界Y轴 WorldLocation.X GridWorldOrigin.X (GridCoord.X * CellSize); WorldLocation.Y GridWorldOrigin.Y (GridCoord.Y * CellSize); WorldLocation.Z GridWorldOrigin.Z; // Z轴后续可根据Height调整 break; } case EGridType::Hexagon: { // 六边形网格转换稍复杂需要偏移计算 // 这是“平顶”六边形的转换公式 float HexWidth CellSize * 2.0f; float HexHeight FMath::Sqrt(3.0f) * CellSize; WorldLocation.X GridWorldOrigin.X (GridCoord.X * HexWidth * 0.75f); WorldLocation.Y GridWorldOrigin.Y ((GridCoord.Y (GridCoord.X % 2) * 0.5f) * HexHeight); WorldLocation.Z GridWorldOrigin.Z; break; } default: WorldLocation FVector::ZeroVector; break; } // 应用该单元格的高度偏移 const FGridCellData* Cell GetCellData(GridCoord); if (Cell) { WorldLocation.Z Cell-Height; } return WorldLocation; } bool UBattleGridComponent::WorldToGrid(const FVector WorldLocation, FIntPoint OutGridCoord) const { // 计算相对于网格原点的偏移 FVector LocalOffset WorldLocation - GridWorldOrigin; switch (GridType) { case EGridType::Square: { // 简单除法取整 int32 GridX FMath::FloorToInt(LocalOffset.X / CellSize); int32 GridY FMath::FloorToInt(LocalOffset.Y / CellSize); OutGridCoord FIntPoint(GridX, GridY); break; } case EGridType::Hexagon: { // 六边形网格的反向计算更复杂通常使用矩阵或查找表 // 这里提供一个简化版思路遍历所有格子找到距离WorldLocation最近的格子中心 // 对于性能要求高的场景需要更高效的算法如使用轴向坐标转换 float MinDistSq MAX_FLT; FIntPoint ClosestCoord(-1, -1); for (int32 X 0; X GridWidth; X) { for (int32 Y 0; Y GridHeight; Y) { FVector CellWorldCenter GridToWorld(FIntPoint(X, Y)); float DistSq FVector::DistSquared(CellWorldCenter, WorldLocation); if (DistSq MinDistSq) { MinDistSq DistSq; ClosestCoord FIntPoint(X, Y); } } } OutGridCoord ClosestCoord; break; } default: return false; } // 检查坐标是否在有效范围内 return IsCellValid(OutGridCoord); } bool UBattleGridComponent::IsCellValid(const FIntPoint GridCoord) const { return GridCoord.X 0 GridCoord.X GridWidth GridCoord.Y 0 GridCoord.Y GridHeight; }注意六边形网格的坐标转换是一个专门课题上述简化版在格子数多时性能较差。生产环境建议使用成熟的轴向坐标(Axial Coordinates)或立方体坐标(Cube Coordinates)系统。4. 动态状态管理与单位占据战斗是动态的单位移动、死亡、召唤物出现都会改变网格的通行状态。4.1 占据与释放单元格void UBattleGridComponent::SetCellOccupied(const FIntPoint GridCoord, AActor* Occupant) { FGridCellData* CellData GetCellData(GridCoord); if (CellData CellData-bWalkable) // 只有可通行的格子才能被占据 { // 如果该格子已被其他单位占据先清除例如处理单位重叠或替换 if (CellData-OccupyingActor CellData-OccupyingActor ! Occupant) { // 可以在这里触发事件比如通知原占据者被挤开 UE_LOG(LogTemp, Warning, TEXT(Cell [%d,%d] was already occupied by %s, now occupied by %s), GridCoord.X, GridCoord.Y, *CellData-OccupyingActor-GetName(), *Occupant-GetName()); } CellData-OccupyingActor Occupant; // 注意这里我们只标记占据但不改变bWalkable。 // 寻路时会同时检查bWalkable和OccupyingActor。 } else { UE_LOG(LogTemp, Warning, TEXT(Attempted to occupy a non-walkable or invalid cell [%d,%d]), GridCoord.X, GridCoord.Y); } } void UBattleGridComponent::ClearCellOccupation(const FIntPoint GridCoord) { FGridCellData* CellData GetCellData(GridCoord); if (CellData) { CellData-OccupyingActor nullptr; } } FGridCellData* UBattleGridComponent::GetCellData(const FIntPoint GridCoord) { if (IsCellValid(GridCoord)) { return GridData[GridCoord.X][GridCoord.Y]; } return nullptr; }4.2 在单位移动时同步更新通常你会有一个代表战斗单位的Actor例如ABattleUnit。在其移动逻辑中需要与网格系统交互。// 文件BattleUnit.cpp (部分代码) void ABattleUnit::MoveToGridCell(const FIntPoint TargetGridCoord) { UBattleGridComponent* BattleGrid GetBattleGrid(); // 假设有方法获取当前战场网格 if (!BattleGrid) { return; } FGridCellData* TargetCell BattleGrid-GetCellData(TargetGridCoord); if (!TargetCell || !TargetCell-bWalkable) { UE_LOG(LogTemp, Warning, TEXT(Target cell is not walkable!)); return; } // 检查是否被其他友方单位占据可选取决于游戏规则 if (TargetCell-OccupyingActor TargetCell-OccupyingActor ! this) { // 处理单位阻挡逻辑 return; } // 1. 清除原位置的占据 BattleGrid-ClearCellOccupation(CurrentGridPosition); // 2. 设置新位置的占据 BattleGrid-SetCellOccupied(TargetGridCoord, this); // 3. 更新单位内部状态 CurrentGridPosition TargetGridCoord; // 4. 实际移动单位到世界位置可以插值动画 FVector TargetWorldLocation BattleGrid-GridToWorld(TargetGridCoord); // ... 执行移动逻辑如设置AI移动目标或播放移动动画 }5. 范围查询获取移动与技能范围这是战斗系统的另一个核心功能。我们需要根据中心点和范围半径获取所有符合条件的格子。5.1 正方形网格的范围查询曼哈顿距离或切比雪夫距离对于CRPG移动力通常按格子数计算。我们实现一个通用的“获取范围内所有格子”的函数。void UBattleGridComponent::GetCellsInMovementRange(const FIntPoint Center, int32 Range, TArrayFIntPoint OutCells) const { OutCells.Empty(); // 使用简单的“菱形”或“正方形”范围取决于游戏规则 // 这里以“菱形”曼哈顿距离为例更符合棋盘类移动 for (int32 dx -Range; dx Range; dx) { for (int32 dy -Range; dy Range; dy) { // 曼哈顿距离|dx| |dy| if (FMath::Abs(dx) FMath::Abs(dy) Range) { continue; // 超出菱形范围 } FIntPoint TargetCoord FIntPoint(Center.X dx, Center.Y dy); if (IsCellValid(TargetCoord)) { // 可选检查格子是否可通行且未被敌方单位占据 const FGridCellData* Cell GetCellData(TargetCoord); if (Cell Cell-bWalkable) { OutCells.Add(TargetCoord); } } } } }5.2 技能范围查询圆形、扇形、直线技能范围更复杂。以圆形范围欧几里得距离为例void UBattleGridComponent::GetCellsInCircleRange(const FIntPoint Center, float Radius, TArrayFIntPoint OutCells) const { OutCells.Empty(); // 计算半径对应的最大格子数保守估计 int32 GridRadius FMath::CeilToInt(Radius / CellSize); for (int32 dx -GridRadius; dx GridRadius; dx) { for (int32 dy -GridRadius; dy GridRadius; dy) { FIntPoint TargetCoord FIntPoint(Center.X dx, Center.Y dy); if (!IsCellValid(TargetCoord)) { continue; } // 计算两个格子中心的世界距离 FVector CenterWorld GridToWorld(Center); FVector TargetWorld GridToWorld(TargetCoord); float Distance FVector::Dist2D(CenterWorld, TargetWorld); // 忽略Z轴高度 if (Distance Radius) { OutCells.Add(TargetCoord); } } } }对于扇形锥形范围你需要计算目标点相对于施法者朝向的角度是否在扇形夹角内。对于直线范围如激光你需要使用 Bresenham 直线算法遍历格子。6. 集成A*寻路算法寻路是网格系统最经典的应用。我们将实现一个基于网格的A*寻路。6.1 A*算法实现在UBattleGridComponent中添加寻路函数。// BattleGridComponent.h 新增声明 UFUNCTION(BlueprintCallable, Category BattleGrid|Pathfinding) bool FindPath(const FIntPoint Start, const FIntPoint Goal, TArrayFIntPoint OutPath, AActor* Requestor nullptr); // BattleGridComponent.cpp 实现 bool UBattleGridComponent::FindPath(const FIntPoint Start, const FIntPoint Goal, TArrayFIntPoint OutPath, AActor* Requestor) { OutPath.Empty(); // 0. 快速检查 if (!IsCellValid(Start) || !IsCellValid(Goal)) { return false; } if (Start Goal) { OutPath.Add(Start); return true; } // 1. 初始化开放集和关闭集 TArrayFGridPathFindingNode* OpenSet; // 待评估节点 TSetFIntPoint ClosedSet; // 已评估节点 // 使用TMap来存储节点数据键为网格坐标 TMapFIntPoint, FGridPathFindingNode AllNodesMap; // 创建起始节点 FGridPathFindingNode StartNode; StartNode.GridCoord Start; StartNode.GCost 0; StartNode.HCost CalculateHeuristic(Start, Goal); AllNodesMap.Add(Start, StartNode); OpenSet.HeapPush(AllNodesMap[Start]); // 2. A*主循环 while (OpenSet.Num() 0) { // 取出FCost最小的节点 FGridPathFindingNode* CurrentNode; OpenSet.HeapPop(CurrentNode); // 如果到达目标构建路径并返回 if (CurrentNode-GridCoord Goal) { TArrayFIntPoint Path; while (CurrentNode) { Path.Add(CurrentNode-GridCoord); // 通过Parent指针回溯 if (CurrentNode-Parent) { // 从Map中通过坐标找到Parent节点指针 CurrentNode AllNodesMap[CurrentNode-Parent-GridCoord]; } else { CurrentNode nullptr; } } // 反转路径从起点到终点 Algo::Reverse(Path); OutPath Path; return true; } // 将当前节点加入关闭集 ClosedSet.Add(CurrentNode-GridCoord); // 遍历邻居四方向或八方向这里以四方向为例 TArrayFIntPoint Neighbors; GetNeighbors(CurrentNode-GridCoord, Neighbors); for (const FIntPoint NeighborCoord : Neighbors) { // 跳过已在关闭集中的节点 if (ClosedSet.Contains(NeighborCoord)) { continue; } // 检查邻居是否可通行 FGridCellData* NeighborCell GetCellData(NeighborCoord); if (!NeighborCell || !NeighborCell-bWalkable) { continue; } // 动态阻挡检查如果该格子被其他单位占据且不是请求者自己则不可通行 if (NeighborCell-OccupyingActor NeighborCell-OccupyingActor ! Requestor) { // 注意目标格子Goal可能被敌人占据这是否阻挡取决于游戏规则例如攻击时需要走到相邻格 // 这里简单处理为阻挡你可以根据需求调整 if (NeighborCoord ! Goal) { continue; } } // 计算从起点到该邻居的新GCost float NewGCost CurrentNode-GCost GetMovementCost(CurrentNode-GridCoord, NeighborCoord); // 查找或创建邻居节点 FGridPathFindingNode* NeighborNode AllNodesMap.Find(NeighborCoord); bool bNeighborIsNew (NeighborNode nullptr); if (bNeighborIsNew) { FGridPathFindingNode NewNode; NewNode.GridCoord NeighborCoord; AllNodesMap.Add(NeighborCoord, NewNode); NeighborNode AllNodesMap[NeighborCoord]; } // 如果新路径更好则更新邻居节点 if (NewGCost NeighborNode-GCost || bNeighborIsNew) { NeighborNode-Parent CurrentNode; // 注意这里存储的是指针需要确保CurrentNode在Map中一直有效 NeighborNode-GCost NewGCost; NeighborNode-HCost CalculateHeuristic(NeighborCoord, Goal); if (bNeighborIsNew) { OpenSet.HeapPush(NeighborNode); } else { // 如果节点已在开放集中需要重新调整堆 OpenSet.HeapSort(); } } } } // 开放集为空未找到路径 return false; } // 辅助函数获取邻居坐标四方向 void UBattleGridComponent::GetNeighbors(const FIntPoint Coord, TArrayFIntPoint OutNeighbors) const { OutNeighbors.Empty(); TArrayFIntPoint Directions { FIntPoint(1, 0), // 右 FIntPoint(-1, 0), // 左 FIntPoint(0, 1), // 上假设Y轴正向为前 FIntPoint(0, -1) // 下 }; for (const FIntPoint Dir : Directions) { FIntPoint Neighbor Coord Dir; if (IsCellValid(Neighbor)) { OutNeighbors.Add(Neighbor); } } } // 辅助函数启发式函数曼哈顿距离 float UBattleGridComponent::CalculateHeuristic(const FIntPoint A, const FIntPoint B) const { // 曼哈顿距离适用于四方向移动 return FMath::Abs(A.X - B.X) FMath::Abs(A.Y - B.Y); // 如果是八方向移动可以使用切比雪夫距离FMath::Max(FMath::Abs(A.X - B.X), FMath::Abs(A.Y - B.Y)) // 如果是允许斜角移动且代价为1.4可以使用欧几里得距离的近似。 } // 辅助函数获取从A到B的移动成本这里简单返回B格子的成本 float UBattleGridComponent::GetMovementCost(const FIntPoint From, const FIntPoint To) const { const FGridCellData* ToCell GetCellData(To); if (ToCell) { return ToCell-MovementCost; } return 1.0f; // 默认成本 }这是一个标准的A*实现考虑了格子移动成本和动态阻挡。你可以根据需要扩展为八方向、支持高度差惩罚等。6.2 在蓝图中测试寻路在UE编辑器中你可以在某个Actor的蓝图中调用这些函数进行测试。获取UBattleGridComponent实例。调用FindPath函数传入起点和终点的网格坐标。将返回的路径坐标(TArrayFIntPoint)转换为世界坐标(TArrayFVector)。使用DrawDebugSphere或DrawDebugLine在游戏中可视化路径。7. 调试与可视化在开发阶段可视化网格至关重要。7.1 绘制调试网格在UBattleGridComponent::DrawDebugGrid中实现void UBattleGridComponent::DrawDebugGrid() const { #if ENABLE_DRAW_DEBUG // 确保只在调试绘制启用时编译 UWorld* World GetWorld(); if (!World) { return; } FColor GridColor FColor::Green; float LineThickness 1.0f; float DrawDuration 0.0f; // 0.0f表示持续一帧 // 绘制格子边界 for (int32 X 0; X GridWidth; X) { FVector Start GridWorldOrigin FVector(X * CellSize, 0, 0); FVector End Start FVector(0, GridHeight * CellSize, 0); DrawDebugLine(World, Start, End, GridColor, false, DrawDuration, 0, LineThickness); } for (int32 Y 0; Y GridHeight; Y) { FVector Start GridWorldOrigin FVector(0, Y * CellSize, 0); FVector End Start FVector(GridWidth * CellSize, 0, 0); DrawDebugLine(World, Start, End, GridColor, false, DrawDuration, 0, LineThickness); } // 可选绘制不可通行格子的红色方块 for (int32 X 0; X GridWidth; X) { for (int32 Y 0; Y GridHeight; Y) { const FGridCellData* Cell GetCellData(FIntPoint(X, Y)); if (Cell !Cell-bWalkable) { FVector Center GridToWorld(FIntPoint(X, Y)); FVector Extent FVector(CellSize * 0.45f, CellSize * 0.45f, 5.0f); // 扁平的盒子 DrawDebugBox(World, Center, Extent, FColor::Red, false, DrawDuration, 0, LineThickness); } } } #endif }在Tick或一个调试命令中调用DrawDebugGrid()即可在游戏中看到绿色的网格线和红色的不可通行区域。7.2 创建编辑器工具对于更友好的设计可以创建一个UBattleGridDebugComponent或编辑器的可视化工具允许关卡设计师在编辑器中看到网格并直接设置格子的属性如通行性、成本。8. 性能优化与进阶考虑当网格变大如100x100或单位众多时性能可能成为瓶颈。8.1 数据结构优化稀疏网格如果大部分格子是空的或属性相同考虑使用TMapFIntPoint, FGridCellData代替二维数组只存储有数据的格子。空间分区对于超大世界可以将网格划分为多个区块(Chunk)只加载和更新视野内的区块。缓存查询结果对于静态的、频繁查询的范围如单位常驻的移动范围可以缓存结果。8.2 寻路优化使用更快的容器A*中的开放集(OpenSet)使用二叉堆(TArrayHeapPush/Pop)是合适的。关闭集(ClosedSet)使用TSet或TBitArray如果网格坐标连续可能更快。方向探索在GetNeighbors中优先探索朝向目标的方向可以减少探索的节点数。分层寻路先在大格子如4x4的超级格子上寻路再在小格子内寻路。预计算对于静态障碍物可以预计算每个格子到最近障碍物的距离场(Distance Field)用于快速估算启发式成本。8.3 动态阻挡优化局部更新当单位移动时只更新受影响的寻路查询而不是全局重算。导航网格集成对于复杂地形UE自带的NavMesh可能更合适。你可以将我们的逻辑网格作为“高层战略网格”而用NavMesh处理精确的移动和避障。两者可以结合使用。9. 常见问题与排查问题现象可能原因排查方式解决方案单位移动时位置不对没站在格子中心GridToWorld计算错误或单位移动的目标点不是格子中心。1. 检查GridWorldOrigin是否正确设置。2. 打印GridToWorld返回的坐标。3. 检查单位的移动逻辑是否最终定位到了WorldCenter。确保单位移动的终点是Cell.WorldCenter而不是简单用网格坐标乘以CellSize。寻路找不到明明可通行的路径1. 起点或终点坐标无效。2. 动态阻挡逻辑过于严格如目标格被占据就算阻挡。3. 启发式函数 (HCost) 估计不准导致搜索方向错误。4. 移动成本 (MovementCost) 设置过高算法认为路径代价太大。1. 使用DrawDebugGrid可视化起点、终点和障碍物。2. 在FindPath函数中增加详细日志打印每个被检查的格子状态。3. 检查GetNeighbors函数是否返回了所有合法邻居。1. 确保IsCellValid检查通过。2. 调整动态阻挡规则例如允许移动到被敌人占据的格子触发攻击。3. 尝试使用不同的启发式函数曼哈顿、切比雪夫、欧几里得。4. 检查格子MovementCost的值确保没有设置为0或极大值。范围查询如技能范围包含的格子不对距离计算方式错误网格距离 vs 世界距离。对于六边形网格使用了正方形的距离公式。1. 可视化查询结果将返回的格子用不同颜色绘制出来。2. 对比手动计算的距离与函数计算的距离。1. 明确你的游戏规则使用哪种距离曼哈顿、切比雪夫、欧几里得。2. 为六边形网格实现专用的距离计算函数。性能问题单位多时卡顿1. 每帧进行全网格范围查询或寻路。2. A*算法在复杂地形中搜索节点过多。3. 调试绘制 (DrawDebug) 开销过大。1. 使用性能分析工具如Unreal Insights定位热点函数。2. 检查是否在Tick中进行了不必要的重计算。1. 对查询和寻路进行帧率限制或异步处理。2. 引入路径缓存相同起止点的路径在一段时间内复用。3. 在发布版本中禁用所有调试绘制。编辑器里看不到调试网格线1. 没有在游戏视口中启用“游戏”模式或“模拟”模式。2.DrawDebug函数在非Development配置中被编译掉。3. 绘制代码没有被调用。1. 确认在BeginPlay或Tick中调用了DrawDebugGrid。2. 检查控制台是否有相关日志输出。3. 尝试在控制台输入showdebug查看其他调试信息是否显示。1. 确保在正确的时机调用绘制函数如每帧或按特定键。2. 使用#if WITH_EDITOR或 #if !(UE_BUILD_SHIPPING10. 总结与最佳实践构建一个健壮的网格系统是CRPG战斗逻辑的基石。回顾本文我们完成了从数据结构设计、坐标转换、动态状态管理到范围查询和A*寻路的完整实现链。核心要点总结明确需求首先确定你的网格是正方形还是六边形使用何种距离度量移动和技能的规则是什么。这直接决定了数据结构与算法。数据与渲染分离UBattleGridComponent只负责逻辑和数据。网格的视觉表现如高亮可移动区域、显示技能范围应通过另一个渲染组件或材质系统来实现保持逻辑层纯净。善用调试工具在开发初期就投入时间完善DrawDebugGrid等可视化功能它能节省你大量的调试时间。考虑扩展性使用FGridCellData结构体存储单元格信息便于未来添加新的属性如天气效果、地面类型、魔法场等。性能意识对于大型地图A*寻路和范围查询可能是性能热点。在实现基础功能后应尽早进行性能测试和优化。下一步学习方向六边形网格高级应用深入学习六边形网格的坐标系统轴向坐标、偏移坐标、距离计算和邻居查找算法。与UE导航系统集成研究如何将自定义网格数据与UE的UNavigationSystem和NavMesh结合实现更复杂的3D地形寻路。网络同步在多人游戏中网格状态如单位位置、临时障碍物需要在客户端间同步。你需要设计一套高效的状态同步机制。高级AI决策基于网格系统可以实现更复杂的AI如寻找最佳攻击位置、计算夹击机会、评估区域控制权等。将本文的代码集成到你的CRPG项目中你就能获得一个完全可控、深度定制的战斗网格底层。这不仅是实现回合制或战棋制战斗的前提也为任何需要精确位置管理的游戏系统如建造、资源采集提供了强大的基础设施。建议从一个小型测试关卡开始逐步完善功能最终构建出充满策略深度的战斗体验。
返回列表