UE5 塔防网格放置系统:只允许在指定地面、墙面、天花板、斜坡上放置陷阱
用 UE5 C++ 实现一套适合塔防游戏的网格放置系统:只允许在显式标记的建造面上放置陷阱,支持地面、墙面、天花板、斜坡,以及 1x1、1x2、2x2 和自定义占用形状。文章会从架构拆分、核心类职责、放置校验流程到编辑器使用方式,完整讲清楚这套系统为什么稳定、为什么适合新手继续扩展。
我这次想做的不是“随便找个地方丢个陷阱”,而是更接近《逆战塔防》那种感觉的建造系统。
核心要求其实很明确:
- 不是全地形都能放。
- 只能放在我明确标记过的区域里。
- 地面、墙面、天花板、斜坡都要支持。
- 陷阱有的占 1 格,有的占 2 格,有的占 4 格。
- 只要有任何一个格子不合法,就不能放。
如果你一开始就想着“我对整张地图做碰撞判断”,很快就会把逻辑写乱。因为地面、墙、斜坡的坐标系统都不一样,越往后加规则越难维护。
我这次采用的是另一种更稳的写法:不要对整张地图做建造判断,而是显式定义哪些区域可以建。
先说结论:把建造系统拆成三层
我最后把整个系统拆成了 3 个核心部分:
ABuildGridSurfaceActorUTrapPlacementDefinitionUTrapPlacementComponent
这个拆法的好处是职责非常清楚。
ABuildGridSurfaceActor 负责描述一块可建造区域。你可以把它理解成“游戏里的一块白名单建造面”。它自己知道:
- 自己是地板、墙、天花板还是斜坡
- 自己有多少行多少列
- 每个格子多大
- 哪些格子预设为不可建
- 哪些格子已经被别的陷阱占用了
UTrapPlacementDefinition 负责描述“某个陷阱的占地规则”。比如:
- 这个陷阱实际要生成什么 Actor
- 它占
1x1、2x1还是2x2 - 它是否允许旋转
- 它可以放在地板、墙壁还是斜坡上
UTrapPlacementComponent 负责跑完整个放置流程:
- 从玩家视角打一条射线
- 看射线打中了哪个可建造面
- 把命中点换算成网格坐标
- 调用校验逻辑判断能不能放
- 如果合法,就生成陷阱 Actor
这三个类配合起来,整个系统就很干净。
为什么我觉得这种方案比“全地图判断”优雅
很多人第一次做塔防放置,会下意识想这么做:
- 鼠标点地面
- 拿到世界坐标
- 看周围有没有遮挡
- 然后判断一下法线是不是朝上
这种方法看起来简单,但问题很快就会出现。
比如你后面加上:
- 墙壁陷阱
- 天花板陷阱
- 斜坡陷阱
- 特殊禁建格
- 多格占用
- 旋转后的占格变化
你会发现规则全都掺在一起了。
所以我更推荐把“允许建造”这件事,改成一个显式配置的概念。也就是:只有 ABuildGridSurfaceActor 覆盖到的地方,玩家才能放东西。这个思路有几个明显优势:
第一,规则很稳定。
第二,扩展新地形类型不需要重写算法。
第三,调试非常简单,因为问题会收敛到某一块建造面。
第四,策划和美术也更容易配合,因为你能在场景里直接摆出可建区域。
说白了,别把建造规则藏在“地图碰巧长成这样”里,而是把它做成一等数据。
第一层:ABuildGridSurfaceActor 负责“这块区域能不能建”
这个类是整套系统的核心。
它本质上是一个带局部坐标系的二维网格面。最重要的一点是:无论它在世界里是平着放、竖着放,还是斜着放,它内部都按统一的局部网格逻辑工作。
这就意味着:
- 平着摆就是地板
- 竖着摆就是墙
- 翻过去就是天花板
- 旋转一个角度就是斜坡
但代码完全不用分开写四套。
这个类里最关键的方法有三个。
1. ProjectWorldToGrid
这个函数负责把玩家点击到的世界坐标,转换成网格坐标。
它的思路是:
- 先把世界坐标变换到当前建造面的局部空间
- 判断这个点是否落在盒体范围内
- 再根据
CellSize计算它属于第几个格子
只有把世界坐标转换成格子坐标,后续才能判断“这个陷阱会占哪些格子”。
2. CanPlaceDefinitionAtCell
这个函数负责真正的合法性校验。
它会依次检查:
- 当前陷阱是否支持这个建造面类型
- 旋转后的占用格子是否越界
- 是否命中了预设禁建格
- 是否和已放置陷阱冲突
只要有任意一个格子不合法,直接返回失败。
这个点非常重要。因为塔防类放置通常不是“局部容忍一点误差”,而是要么全合法,要么完全不能放。
3. PlaceTrapDefinitionAtCell
这个函数负责真正生成 Actor,并把占用格登记起来。
它先调用 CanPlaceDefinitionAtCell 确认没问题,再 SpawnActor,最后把这个陷阱占掉的格子写入 OccupiedCells。
之后如果陷阱被摧毁,再通过 OnDestroyed 把格子释放回来。
这一步做完后,网格系统就形成闭环了。
第二层:UTrapPlacementDefinition 负责“这个陷阱占几个格子”
这个类我专门做成了 DataAsset,因为它本质上就是一份规则配置,而不是一个需要在世界里 Tick 的对象。
这个思路很适合新手记忆:
Actor负责世界里的实体Component负责流程DataAsset负责规则和配置
在这个类里,我主要放了几类数据:
1. TrapActorClass
表示这个规则最终会生成哪种陷阱 Actor。
2. FootprintSize
表示规则矩形占用。
例如:
(1, 1)表示占 1 格(2, 1)表示横着占 2 格(2, 2)表示占 4 格
3. CustomFootprintCells
如果你以后想做不规则陷阱,比如 L 型、十字型或者其他特殊结构,就可以直接填这个数组。
一旦这个数组有内容,就优先使用它,而不是 FootprintSize。
4. AllowedSurfaceMask
这里控制这个陷阱能放在哪些面上。
比如你可以做这种限制:
- 地刺只能放在
Ground - 墙炮只能放在
Wall - 吊顶陷阱只能放在
Ceiling - 某些机关允许
Ground | Ramp
5. GetFootprintCells
这是这个类最重要的方法。
它的作用是:根据当前旋转,计算最终占用哪些局部格子。
这里我做了一个细节处理:旋转后如果出现负坐标,会统一平移回从 0 开始的局部区域。这样外部系统永远只需要处理“从起始格开始往外扩”的逻辑,代码会简单很多。
第三层:UTrapPlacementComponent 负责“把整个流程串起来”
这个组件不关心格子到底怎么算,也不关心陷阱具体占几格。它只做一件事:把玩家操作转成一次完整的放置尝试。
它主要做三件事:
1. TraceFromPlayerView
从玩家相机位置向前打一条射线,拿到 HitResult。
2. EvaluatePlacementFromHit
只做预检测,不实际生成陷阱。
这个函数非常适合做“放置预览”:
- 如果返回成功,就显示绿色预览
- 如果返回失败,就显示红色预览,并把失败原因显示出来
3. TryPlaceFromHit
这个函数和预检测流程类似,但最后会真正调用 PlaceTrapDefinitionAtCell 把陷阱放下去。
所以如果你以后做交互,推荐这么分层:
- 鼠标移动时:调用
EvaluatePlacementFromHit - 鼠标点击时:调用
TryPlaceFromHit
这样预览和最终放置走的是同一套核心规则,不容易出现“预览能放,点击却失败”的错位问题。
在编辑器里怎么用
如果你想把它先跑起来,我建议按下面这个顺序做。
第一步:做可建造面 Blueprint
创建一个继承自 ABuildGridSurfaceActor 的蓝图,比如:
BP_BuildSurface_GroundBP_BuildSurface_WallBP_BuildSurface_CeilingBP_BuildSurface_Ramp
然后在细节面板里设置:
SurfaceTypeGridDimensionsCellSizePreBlockedCells
摆进场景后,这些区域就成了允许建造的白名单区域。
第二步:做陷阱规则 DataAsset
创建几个 UTrapPlacementDefinition 对应的数据资源,比如:
DA_Trap_FloorSpike_1x1DA_Trap_WallGun_2x1DA_Trap_Block_2x2
然后分别配置:
TrapActorClassFootprintSizeAllowedSurfaceMaskbAllowQuarterTurns
第三步:给玩家挂上 UTrapPlacementComponent 或直接使用示例控制器
你可以挂在 PlayerController、Pawn 或自定义建造管理器上。
如果你想最快看到效果,我这次已经额外补了一个 ABuildPlacementPlayerController 示例。它除了负责左键放置、R 键旋转之外,还会在 Tick 里根据鼠标命中的格子绘制红绿预览框:
- 绿色表示当前占用格全部合法
- 红色表示当前至少有一个格子不合法
- 按
R时,预览会跟着切换横放、竖放和 180/270 度
然后在输入逻辑里做两件事:
- 按
R切换EBuildGridRotation - 鼠标左键调用
TryPlaceFromHit
这样基本就能跑通一版了。
如果你只是想先验证“一个 2x1 的陷阱是否真的能横放和竖放”,我建议最简单的测试方式是:
- 建一个
4x4的ABuildGridSurfaceActor - 建一个
UTrapPlacementDefinition - 把
FootprintSize设成(2,1) - 把
bAllowQuarterTurns设成true - 使用一个带
R键切换旋转、鼠标左键放置的示例控制器
这样你会看到:
- 默认
Deg0时,占的是两个横向格子 - 按一次
R切到Deg90后,占的是两个纵向格子 - 再按一次会变成
Deg180 - 再按一次会变成
Deg270
也就是说,这套系统不仅支持“横放/竖放”,而且已经是完整的四方向旋转逻辑了。
这套系统为什么适合继续扩展
我比较喜欢这套结构的原因,不只是因为它能跑,而是因为它后面很好扩。
比如你之后可以继续加,或者继续把我这次已经补好的简易版往上升级:
1. 放置预览
我这次已经先补了一版最小可用实现:
ABuildPlacementPlayerController::Tick每帧更新预览ABuildGridSurfaceActor::GetFootprintCellsAt负责把当前旋转下的占格展开出来ABuildGridSurfaceActor::GetCellCenterWorld负责拿到每个格子的世界中心点ABuildGridSurfaceActor::GetCellPreviewHalfExtent负责计算调试框尺寸
当前实现使用 DrawDebugBox 画线框,好处是完全不依赖额外材质和预览模型,新项目里复制代码就能立刻看到效果。
2. 红绿格高亮
这个功能其实也已经包含在上面的预览里了。当前陷阱将要占用的格子会直接画出来:
- 可放时绿色
- 不可放时红色
3. 不规则占用
直接利用 CustomFootprintCells 做特殊陷阱。
4. 路径保护
把怪物路径经过的格子加入 PreBlockedCells,就能防止玩家堵路。
5. 不同面有不同陷阱池
比如墙上只能放墙炮,地板只能放地刺,这个现在已经有基础支持了。
我对这类系统的一个建议
如果你是新手,做这类系统时不要一上来就追求“超级通用”。
先把下面这几个核心问题做扎实:
- 世界坐标怎么转网格坐标
- 多格占用怎么计算
- 越界怎么处理
- 占用冲突怎么处理
- 销毁后格子怎么释放
只要这五件事是稳定的,后面的预览、特效、旋转、UI、陷阱升级,都是叠加层。
反过来,如果这五件事一开始没设计清楚,后面你会一直在修“为什么这里能卡进去”“为什么预览是绿的但放不下”“为什么两个陷阱重叠了”这种基础问题。
这次代码里最值得重点读的地方
如果你准备仔细阅读代码,我建议优先看这几个函数:
ABuildGridSurfaceActor::ProjectWorldToGridABuildGridSurfaceActor::CanPlaceDefinitionAtCellABuildGridSurfaceActor::MakePlacementTransformUTrapPlacementDefinition::GetFootprintCellsUTrapPlacementComponent::EvaluatePlacementFromHit
把这几段看明白,整套系统你就已经吃透一大半了。
最后
我越来越觉得,塔防类建造系统的关键,不是“怎么点中一个地方”,而是“怎么把建造规则收敛成一套稳定的数据结构”。
一旦你把“可建造面”和“陷阱占用规则”这两件事拆出来,后面的代码会顺很多。
这也是我这次选择这套写法的原因。它不是最炫的,但它足够稳,足够清晰,也很适合新手一层一层往下读。
如果你后面继续做,我下一步最推荐补的是两样:
- 把现在的
DrawDebugBox升级成真正的半透明预览 Actor - 把“整体红/绿”升级成“逐格高亮 + 失败原因提示”
这样做完之后,整个系统就会更接近真正的塔防建造体验。
完整示例代码
下面我把这次文章里提到的完整示例代码也贴上来,方便你一边看讲解,一边直接对照源码。
BuildGridTypes.h
#pragma once
#include "CoreMinimal.h"
#include "BuildGridTypes.generated.h"
// 可建造面的类型。
// 这里故意做成位掩码,这样一个陷阱就可以同时支持多个面,
// 例如“既能放地板,也能放斜坡”。
UENUM(BlueprintType, meta=(Bitflags, UseEnumValuesAsMaskValuesInEditor="true"))
enum class EBuildSurfaceType : uint8
{
None = 0 UMETA(Hidden),
Ground = 1 << 0,
Wall = 1 << 1,
Ceiling = 1 << 2,
Ramp = 1 << 3
};
ENUM_CLASS_FLAGS(EBuildSurfaceType)
// 网格内的 90 度旋转。
// 塔防类放置系统通常不需要任意角度旋转,四个方向更简单也更稳定。
UENUM(BlueprintType)
enum class EBuildGridRotation : uint8
{
Deg0 = 0,
Deg90 = 1,
Deg180 = 2,
Deg270 = 3
};
namespace BuildGrid
{
// 把一个“局部占用格”围绕原点旋转。
// 例如 1x2 的占用如果从 (0,0)(1,0) 旋转 90 度,
// 就会变成竖着的两个格子。
inline FIntPoint RotateCell(const FIntPoint& Cell, const EBuildGridRotation Rotation)
{
switch (Rotation)
{
case EBuildGridRotation::Deg90:
return FIntPoint(-Cell.Y, Cell.X);
case EBuildGridRotation::Deg180:
return FIntPoint(-Cell.X, -Cell.Y);
case EBuildGridRotation::Deg270:
return FIntPoint(Cell.Y, -Cell.X);
case EBuildGridRotation::Deg0:
default:
return Cell;
}
}
}
TrapPlacementDefinition.h
#pragma once
#include "BuildGridTypes.h"
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "TrapPlacementDefinition.generated.h"
// 一个 DataAsset 描述“一种陷阱要怎么放”。
// 它不负责真正生成或检测,只负责提供规则:
// 1. 这个陷阱会生成什么 Actor
// 2. 它占几个格子
// 3. 它允许放在什么建造面上
UCLASS(BlueprintType)
class DEMO_API UTrapPlacementDefinition : public UDataAsset
{
GENERATED_BODY()
public:
// 真正生成出来的陷阱 Actor 类型。
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Trap")
TSubclassOf<AActor> TrapActorClass;
// 规则形状时使用的尺寸,例如:
// (1,1) = 占 1 格
// (2,1) = 横着占 2 格
// (2,2) = 占 4 格
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Trap", meta=(ClampMin="1", UIMin="1"))
FIntPoint FootprintSize = FIntPoint(1, 1);
// 自定义占用形状。
// 如果这里有内容,就优先使用这里,而不是 FootprintSize。
// 例如 L 型、T 型之类的不规则陷阱都可以在这里描述。
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Trap")
TArray<FIntPoint> CustomFootprintCells;
// 允许放置的建造面类型。
// 这是位掩码,所以一个陷阱可以同时允许 Ground | Ramp。
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Trap", meta=(Bitmask, BitmaskEnum="/Script/demo.EBuildSurfaceType"))
int32 AllowedSurfaceMask = static_cast<int32>(EBuildSurfaceType::Ground);
// 是否允许按 90 度旋转。
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Trap")
bool bAllowQuarterTurns = true;
// 生成时附加的局部偏移。
// 如果你的陷阱模型中心点不在网格中心,可以在这里微调。
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Trap")
FVector LocalPlacementOffset = FVector::ZeroVector;
// 根据当前旋转,计算出最终占用的局部格子列表。
TArray<FIntPoint> GetFootprintCells(EBuildGridRotation Rotation) const;
// 判断某个建造面类型是否在允许列表里。
bool SupportsSurface(EBuildSurfaceType SurfaceType) const;
private:
// 先生成“未旋转前”的占用格子。
TArray<FIntPoint> BuildBaseFootprint() const;
};
TrapPlacementDefinition.cpp
#include "TrapPlacementDefinition.h"
TArray<FIntPoint> UTrapPlacementDefinition::BuildBaseFootprint() const
{
// 如果配置了不规则格子,直接使用用户手填的结果。
if (CustomFootprintCells.Num() > 0)
{
return CustomFootprintCells;
}
// 否则按矩形尺寸自动生成。
TArray<FIntPoint> Cells;
Cells.Reserve(FootprintSize.X * FootprintSize.Y);
for (int32 Y = 0; Y < FootprintSize.Y; ++Y)
{
for (int32 X = 0; X < FootprintSize.X; ++X)
{
Cells.Add(FIntPoint(X, Y));
}
}
return Cells;
}
TArray<FIntPoint> UTrapPlacementDefinition::GetFootprintCells(const EBuildGridRotation Rotation) const
{
// 某些陷阱可能不允许旋转,所以这里做一次保护。
const EBuildGridRotation SafeRotation = bAllowQuarterTurns ? Rotation : EBuildGridRotation::Deg0;
TArray<FIntPoint> Cells = BuildBaseFootprint();
if (Cells.Num() == 0)
{
return Cells;
}
// 旋转后,局部格子有可能出现负坐标。
// 例如 (0,0)(1,0) 旋转后,可能会变成 (0,0)(0,1) 之外的相对分布。
// 所以这里要把所有格子整体平移回“从 0 开始”的范围。
int32 MinX = MAX_int32;
int32 MinY = MAX_int32;
for (FIntPoint& Cell : Cells)
{
Cell = BuildGrid::RotateCell(Cell, SafeRotation);
MinX = FMath::Min(MinX, Cell.X);
MinY = FMath::Min(MinY, Cell.Y);
}
const FIntPoint Offset(MinX, MinY);
for (FIntPoint& Cell : Cells)
{
Cell -= Offset;
}
return Cells;
}
bool UTrapPlacementDefinition::SupportsSurface(const EBuildSurfaceType SurfaceType) const
{
// 位掩码判断。
return (AllowedSurfaceMask & static_cast<int32>(SurfaceType)) != 0;
}
BuildGridSurfaceActor.h
#pragma once
#include "BuildGridTypes.h"
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "BuildGridSurfaceActor.generated.h"
class UBoxComponent;
class USceneComponent;
class UTrapPlacementDefinition;
// 一块“允许建造”的表面。
// 它的本质是一个带局部坐标系的二维网格面:
// - 你可以把它放平当做地板
// - 也可以竖起来当墙
// - 也可以旋转成天花板或斜坡
//
// 这样做的好处是:所有放置规则都统一到“局部网格”里,
// 不需要针对地板、墙壁、斜坡分别写三套逻辑。
UCLASS(Blueprintable)
class DEMO_API ABuildGridSurfaceActor : public AActor
{
GENERATED_BODY()
public:
ABuildGridSurfaceActor();
virtual void OnConstruction(const FTransform& Transform) override;
// 把世界坐标投影到当前网格上,得到“命中了第几个格子”。
// 这是放置系统里最关键的一步:
// 玩家点击的是世界坐标,但校验占用时需要格子坐标。
UFUNCTION(BlueprintCallable, Category="Build Grid")
bool ProjectWorldToGrid(const FVector& WorldLocation, FIntPoint& OutCell, FVector& OutCellCenter) const;
// 根据起点格和陷阱定义,直接展开出“这次预期会占哪些格子”。
// 这个函数不会判断是否合法,只负责把局部占用转换成实际目标格。
TArray<FIntPoint> GetFootprintCellsAt(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, EBuildGridRotation Rotation) const;
// 获取某个格子中心在世界坐标中的位置。
// 预览系统会用它来画红/绿调试框。
FVector GetCellCenterWorld(const FIntPoint& Cell) const;
// 获取一个格子的半尺寸,用来绘制预览盒体。
FVector GetCellPreviewHalfExtent(float Inset = 6.0f) const;
// 检查某个陷阱能不能从 OriginCell 开始放下去。
// 这里会验证:
// 1. 建造面类型是否匹配
// 2. 占用格是否超出边界
// 3. 是否是预设禁建格
// 4. 是否已经被其他陷阱占用
bool CanPlaceDefinitionAtCell(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, EBuildGridRotation Rotation, FText& OutFailReason, TArray<FIntPoint>& OutFootprintCells) const;
// 根据占用格子,计算陷阱最终应当生成在什么位置和朝向。
FTransform MakePlacementTransform(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, EBuildGridRotation Rotation) const;
// 真正生成陷阱 Actor,并把占用格登记到 OccupiedCells。
AActor* PlaceTrapDefinitionAtCell(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, EBuildGridRotation Rotation, FText& OutFailReason);
UFUNCTION(BlueprintCallable, Category="Build Grid")
void ReleasePlacedTrap(AActor* PlacedTrap);
UFUNCTION(BlueprintPure, Category="Build Grid")
EBuildSurfaceType GetSurfaceType() const { return SurfaceType; }
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
TObjectPtr<USceneComponent> SceneRoot;
// 用来做点击检测的盒体。
// 它同时也表示这块建造面的物理范围。
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
TObjectPtr<UBoxComponent> BuildBounds;
// 这块建造面属于什么类型。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Grid")
EBuildSurfaceType SurfaceType = EBuildSurfaceType::Ground;
// 单个格子的边长,单位是厘米。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Grid", meta=(ClampMin="1.0", UIMin="1.0"))
float CellSize = 100.0f;
// 网格总尺寸,表示这块面有多少列、多少行。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Grid", meta=(ClampMin="1", UIMin="1"))
FIntPoint GridDimensions = FIntPoint(8, 4);
// 盒体厚度。
// 因为网格面不是无限薄,所以投影时允许一点厚度范围。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Grid", meta=(ClampMin="1.0", UIMin="1.0"))
float SurfaceThickness = 25.0f;
// 陷阱在法线方向上的附加偏移。
// 当模型容易和地板/墙面穿插时,这个值很有用。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Grid")
float PlacementNormalOffset = 0.0f;
// 是否把生成的陷阱挂到这个建造面下面。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Grid")
bool bAttachPlacedActors = true;
// 预先被标记为不可放置的格子。
// 例如出生点、路径点、机关预留位。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Grid")
TArray<FIntPoint> PreBlockedCells;
private:
// 已经被真实陷阱占用的格子。
TSet<FIntPoint> OccupiedCells;
// 编辑器里配置的永久禁建格。
TSet<FIntPoint> HardBlockedCells;
// 记录“哪个陷阱占了哪些格子”,方便销毁时释放。
TMap<AActor*, TArray<FIntPoint>> PlacementMap;
// 根据 CellSize 和 GridDimensions 更新盒体大小。
void RefreshBounds();
// 把可编辑数组 PreBlockedCells 转成便于查询的集合。
void RefreshBlockedCells();
bool IsCellInside(const FIntPoint& Cell) const;
bool IsCellAvailable(const FIntPoint& Cell) const;
// 得到某个格子中心在当前 Actor 局部空间下的位置。
FVector GetCellCenterLocal(const FIntPoint& Cell) const;
// 标记这些格子已被占用。
void ReserveCells(AActor* PlacedTrap, const TArray<FIntPoint>& Cells);
// 释放一个陷阱占用的所有格子。
void ReleaseCellsInternal(AActor* PlacedTrap);
UFUNCTION()
void HandlePlacedTrapDestroyed(AActor* DestroyedActor);
};
BuildGridSurfaceActor.cpp
#include "BuildGridSurfaceActor.h"
#include "Components/BoxComponent.h"
#include "Engine/World.h"
#include "TrapPlacementDefinition.h"
#define LOCTEXT_NAMESPACE "BuildGridSurfaceActor"
ABuildGridSurfaceActor::ABuildGridSurfaceActor()
{
PrimaryActorTick.bCanEverTick = false;
// Root 只负责承载坐标系。
SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
SetRootComponent(SceneRoot);
// 这个盒体是“允许玩家点到这块建造面”的基础。
BuildBounds = CreateDefaultSubobject<UBoxComponent>(TEXT("BuildBounds"));
BuildBounds->SetupAttachment(SceneRoot);
BuildBounds->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
BuildBounds->SetCollisionResponseToAllChannels(ECR_Ignore);
BuildBounds->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
BuildBounds->SetGenerateOverlapEvents(false);
BuildBounds->SetHiddenInGame(false);
RefreshBounds();
RefreshBlockedCells();
}
void ABuildGridSurfaceActor::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
// 在编辑器里改参数后,立即刷新网格盒体和禁建格缓存。
RefreshBounds();
RefreshBlockedCells();
}
void ABuildGridSurfaceActor::RefreshBounds()
{
// BoxExtent 是半尺寸,所以这里乘 0.5f。
const float SafeCellSize = FMath::Max(1.0f, CellSize);
const FVector Extent(
GridDimensions.X * SafeCellSize * 0.5f,
GridDimensions.Y * SafeCellSize * 0.5f,
FMath::Max(1.0f, SurfaceThickness) * 0.5f);
BuildBounds->SetBoxExtent(Extent);
}
void ABuildGridSurfaceActor::RefreshBlockedCells()
{
HardBlockedCells.Reset();
// 只接收合法范围内的格子,避免编辑器误填脏数据。
for (const FIntPoint& Cell : PreBlockedCells)
{
if (IsCellInside(Cell))
{
HardBlockedCells.Add(Cell);
}
}
}
bool ABuildGridSurfaceActor::IsCellInside(const FIntPoint& Cell) const
{
return Cell.X >= 0 && Cell.X < GridDimensions.X && Cell.Y >= 0 && Cell.Y < GridDimensions.Y;
}
bool ABuildGridSurfaceActor::IsCellAvailable(const FIntPoint& Cell) const
{
return IsCellInside(Cell) && !HardBlockedCells.Contains(Cell) && !OccupiedCells.Contains(Cell);
}
FVector ABuildGridSurfaceActor::GetCellCenterLocal(const FIntPoint& Cell) const
{
// 约定网格中心在 Actor 原点。
// 所以要先算出左下角偏移,再加上格子中心偏移。
const float HalfWidth = GridDimensions.X * CellSize * 0.5f;
const float HalfHeight = GridDimensions.Y * CellSize * 0.5f;
return FVector(
-HalfWidth + (Cell.X + 0.5f) * CellSize,
-HalfHeight + (Cell.Y + 0.5f) * CellSize,
PlacementNormalOffset);
}
bool ABuildGridSurfaceActor::ProjectWorldToGrid(const FVector& WorldLocation, FIntPoint& OutCell, FVector& OutCellCenter) const
{
// 先把世界坐标变到当前建造面的局部空间。
// 后续所有网格计算都在局部空间中进行。
const FVector LocalPosition = GetActorTransform().InverseTransformPosition(WorldLocation);
const float HalfWidth = GridDimensions.X * CellSize * 0.5f;
const float HalfHeight = GridDimensions.Y * CellSize * 0.5f;
// 点击点离建造面太远,就认为没有命中。
if (FMath::Abs(LocalPosition.Z) > (SurfaceThickness * 0.5f) + KINDA_SMALL_NUMBER)
{
return false;
}
// X/Y 超出矩形范围,说明点已经跑出网格外了。
if (LocalPosition.X < -HalfWidth || LocalPosition.X > HalfWidth || LocalPosition.Y < -HalfHeight || LocalPosition.Y > HalfHeight)
{
return false;
}
// 边界点稍微往里夹一下,避免正好点在最外边时出现越界。
const float SafeX = FMath::Clamp(LocalPosition.X, -HalfWidth, HalfWidth - KINDA_SMALL_NUMBER);
const float SafeY = FMath::Clamp(LocalPosition.Y, -HalfHeight, HalfHeight - KINDA_SMALL_NUMBER);
OutCell.X = FMath::FloorToInt((SafeX + HalfWidth) / CellSize);
OutCell.Y = FMath::FloorToInt((SafeY + HalfHeight) / CellSize);
if (!IsCellInside(OutCell))
{
return false;
}
OutCellCenter = GetActorTransform().TransformPosition(GetCellCenterLocal(OutCell));
return true;
}
TArray<FIntPoint> ABuildGridSurfaceActor::GetFootprintCellsAt(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, const EBuildGridRotation Rotation) const
{
TArray<FIntPoint> Result;
if (!TrapDefinition)
{
return Result;
}
const TArray<FIntPoint> LocalCells = TrapDefinition->GetFootprintCells(Rotation);
Result.Reserve(LocalCells.Num());
for (const FIntPoint& LocalCell : LocalCells)
{
Result.Add(OriginCell + LocalCell);
}
return Result;
}
FVector ABuildGridSurfaceActor::GetCellCenterWorld(const FIntPoint& Cell) const
{
return GetActorTransform().TransformPosition(GetCellCenterLocal(Cell));
}
FVector ABuildGridSurfaceActor::GetCellPreviewHalfExtent(const float Inset) const
{
const float SafeInset = FMath::Clamp(Inset, 0.0f, CellSize * 0.45f);
return FVector(
FMath::Max(2.0f, CellSize * 0.5f - SafeInset),
FMath::Max(2.0f, CellSize * 0.5f - SafeInset),
FMath::Max(2.0f, SurfaceThickness * 0.5f));
}
bool ABuildGridSurfaceActor::CanPlaceDefinitionAtCell(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, const EBuildGridRotation Rotation, FText& OutFailReason, TArray<FIntPoint>& OutFootprintCells) const
{
OutFootprintCells.Reset();
// 先排除空数据,后面的逻辑都依赖 TrapDefinition。
if (!TrapDefinition)
{
OutFailReason = LOCTEXT("MissingTrapDefinition", "TrapDefinition 为空。");
return false;
}
if (!TrapDefinition->SupportsSurface(SurfaceType))
{
OutFailReason = LOCTEXT("UnsupportedSurface", "这个陷阱不支持当前建造面类型。");
return false;
}
const TArray<FIntPoint> LocalCells = TrapDefinition->GetFootprintCells(Rotation);
if (LocalCells.Num() == 0)
{
OutFailReason = LOCTEXT("EmptyFootprint", "陷阱没有定义任何占用格子。");
return false;
}
// 把局部占用格平移到真正要放置的起点格子上,然后逐个检查。
for (const FIntPoint& LocalCell : LocalCells)
{
const FIntPoint TargetCell = OriginCell + LocalCell;
if (!IsCellInside(TargetCell))
{
OutFailReason = FText::Format(
LOCTEXT("CellOutsideBounds", "格子 ({0}, {1}) 超出可建区域。"),
FText::AsNumber(TargetCell.X),
FText::AsNumber(TargetCell.Y));
return false;
}
if (HardBlockedCells.Contains(TargetCell))
{
OutFailReason = FText::Format(
LOCTEXT("CellHardBlocked", "格子 ({0}, {1}) 被预设为不可建。"),
FText::AsNumber(TargetCell.X),
FText::AsNumber(TargetCell.Y));
return false;
}
if (OccupiedCells.Contains(TargetCell))
{
OutFailReason = FText::Format(
LOCTEXT("CellOccupied", "格子 ({0}, {1}) 已被占用。"),
FText::AsNumber(TargetCell.X),
FText::AsNumber(TargetCell.Y));
return false;
}
OutFootprintCells.Add(TargetCell);
}
return true;
}
FTransform ABuildGridSurfaceActor::MakePlacementTransform(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, const EBuildGridRotation Rotation) const
{
const TArray<FIntPoint> LocalCells = TrapDefinition ? TrapDefinition->GetFootprintCells(Rotation) : TArray<FIntPoint>();
FVector LocalCenter = FVector::ZeroVector;
// 多格陷阱的生成点取“所有占用格中心点的平均值”。
// 这样 1x1、1x2、2x2 都能得到合理的中心位置。
for (const FIntPoint& LocalCell : LocalCells)
{
LocalCenter += GetCellCenterLocal(OriginCell + LocalCell);
}
if (LocalCells.Num() > 0)
{
LocalCenter /= static_cast<float>(LocalCells.Num());
}
if (TrapDefinition)
{
LocalCenter += TrapDefinition->LocalPlacementOffset;
}
// 建造面本身可以已经旋转成墙或斜坡,这里再叠加一个“格子内旋转”。
const FQuat LocalRotation = FRotator(0.0f, static_cast<int32>(Rotation) * 90.0f, 0.0f).Quaternion();
const FQuat WorldRotation = GetActorQuat() * LocalRotation;
const FVector WorldLocation = GetActorTransform().TransformPosition(LocalCenter);
return FTransform(WorldRotation, WorldLocation);
}
AActor* ABuildGridSurfaceActor::PlaceTrapDefinitionAtCell(const UTrapPlacementDefinition* TrapDefinition, const FIntPoint& OriginCell, const EBuildGridRotation Rotation, FText& OutFailReason)
{
// 没有指定生成的 Actor,就算规则合法也没法真正放下去。
if (!TrapDefinition || !TrapDefinition->TrapActorClass)
{
OutFailReason = LOCTEXT("MissingTrapActorClass", "TrapActorClass 未设置。");
return nullptr;
}
TArray<FIntPoint> ReservedFootprint;
if (!CanPlaceDefinitionAtCell(TrapDefinition, OriginCell, Rotation, OutFailReason, ReservedFootprint))
{
return nullptr;
}
UWorld* World = GetWorld();
if (!World)
{
OutFailReason = LOCTEXT("MissingWorld", "当前 World 无效。");
return nullptr;
}
FActorSpawnParameters SpawnParameters;
SpawnParameters.Owner = this;
SpawnParameters.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
// 这里用 AlwaysSpawn,因为网格系统已经自己做了占位校验。
AActor* SpawnedTrap = World->SpawnActor<AActor>(TrapDefinition->TrapActorClass, MakePlacementTransform(TrapDefinition, OriginCell, Rotation), SpawnParameters);
if (!SpawnedTrap)
{
OutFailReason = LOCTEXT("SpawnFailed", "陷阱生成失败。");
return nullptr;
}
if (bAttachPlacedActors)
{
SpawnedTrap->AttachToActor(this, FAttachmentTransformRules::KeepWorldTransform);
}
ReserveCells(SpawnedTrap, ReservedFootprint);
return SpawnedTrap;
}
void ABuildGridSurfaceActor::ReserveCells(AActor* PlacedTrap, const TArray<FIntPoint>& Cells)
{
if (!PlacedTrap)
{
return;
}
// 每个被占的格子都写入集合,后续查询是否被占用就是 O(1)。
for (const FIntPoint& Cell : Cells)
{
OccupiedCells.Add(Cell);
}
// 同时把“Actor -> 占用格”存起来,方便销毁时逆向释放。
PlacementMap.Add(PlacedTrap, Cells);
PlacedTrap->OnDestroyed.AddDynamic(this, &ABuildGridSurfaceActor::HandlePlacedTrapDestroyed);
}
void ABuildGridSurfaceActor::ReleasePlacedTrap(AActor* PlacedTrap)
{
ReleaseCellsInternal(PlacedTrap);
}
void ABuildGridSurfaceActor::ReleaseCellsInternal(AActor* PlacedTrap)
{
if (!PlacedTrap)
{
return;
}
TArray<FIntPoint> ReservedCells;
if (!PlacementMap.RemoveAndCopyValue(PlacedTrap, ReservedCells))
{
return;
}
for (const FIntPoint& Cell : ReservedCells)
{
OccupiedCells.Remove(Cell);
}
// 解除绑定,避免重复触发。
PlacedTrap->OnDestroyed.RemoveDynamic(this, &ABuildGridSurfaceActor::HandlePlacedTrapDestroyed);
}
void ABuildGridSurfaceActor::HandlePlacedTrapDestroyed(AActor* DestroyedActor)
{
ReleaseCellsInternal(DestroyedActor);
}
#undef LOCTEXT_NAMESPACE
TrapPlacementComponent.h
#pragma once
#include "BuildGridTypes.h"
#include "Components/ActorComponent.h"
#include "CoreMinimal.h"
#include "TrapPlacementComponent.generated.h"
class ABuildGridSurfaceActor;
class APlayerController;
class UTrapPlacementDefinition;
// 这个组件更像“放置流程协调器”:
// - 它负责射线检测
// - 负责从 HitResult 找到对应的建造面
// - 再把验证和生成交给 BuildGridSurfaceActor
UCLASS(ClassGroup=(Build), meta=(BlueprintSpawnableComponent))
class DEMO_API UTrapPlacementComponent : public UActorComponent
{
GENERATED_BODY()
public:
UTrapPlacementComponent();
// 射线检测的最大距离。
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Build Grid")
float TraceDistance = 5000.0f;
// 使用哪个碰撞通道进行检测。
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Build Grid")
TEnumAsByte<ECollisionChannel> TraceChannel = ECC_Visibility;
// 从玩家相机位置向前打一条射线。
bool TraceFromPlayerView(APlayerController* PlayerController, FHitResult& OutHit) const;
// 只做预检,不真正放置。
// 很适合给“放置预览”使用。
bool EvaluatePlacementFromHit(const FHitResult& Hit, const UTrapPlacementDefinition* TrapDefinition, EBuildGridRotation Rotation, ABuildGridSurfaceActor*& OutSurface, FTransform& OutTransform, FText& OutFailReason) const;
// 基于命中结果直接尝试放置。
AActor* TryPlaceFromHit(const FHitResult& Hit, const UTrapPlacementDefinition* TrapDefinition, EBuildGridRotation Rotation, FText& OutFailReason) const;
// 命中的可能是 Actor 本身,也可能是它的某个组件。
// 所以这里做一层统一解析。
static ABuildGridSurfaceActor* ResolveSurfaceFromHit(const FHitResult& Hit);
};
TrapPlacementComponent.cpp
#include "TrapPlacementComponent.h"
#include "BuildGridSurfaceActor.h"
#include "Camera/PlayerCameraManager.h"
#include "GameFramework/PlayerController.h"
#include "TrapPlacementDefinition.h"
UTrapPlacementComponent::UTrapPlacementComponent()
{
PrimaryComponentTick.bCanEverTick = false;
}
bool UTrapPlacementComponent::TraceFromPlayerView(APlayerController* PlayerController, FHitResult& OutHit) const
{
// 放置系统依赖相机位置和当前 World,任何一个为空都不能继续。
if (!PlayerController || !PlayerController->PlayerCameraManager || !GetWorld())
{
return false;
}
// 从相机位置沿着朝向前方发射一条线。
const FVector TraceStart = PlayerController->PlayerCameraManager->GetCameraLocation();
const FVector TraceDirection = PlayerController->PlayerCameraManager->GetCameraRotation().Vector();
const FVector TraceEnd = TraceStart + TraceDirection * TraceDistance;
FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(TrapPlacementTrace), true, GetOwner());
return GetWorld()->LineTraceSingleByChannel(OutHit, TraceStart, TraceEnd, TraceChannel, QueryParams);
}
ABuildGridSurfaceActor* UTrapPlacementComponent::ResolveSurfaceFromHit(const FHitResult& Hit)
{
if (ABuildGridSurfaceActor* Surface = Cast<ABuildGridSurfaceActor>(Hit.GetActor()))
{
return Surface;
}
if (UActorComponent* HitComponent = Hit.GetComponent())
{
return Cast<ABuildGridSurfaceActor>(HitComponent->GetOwner());
}
return nullptr;
}
bool UTrapPlacementComponent::EvaluatePlacementFromHit(const FHitResult& Hit, const UTrapPlacementDefinition* TrapDefinition, const EBuildGridRotation Rotation, ABuildGridSurfaceActor*& OutSurface, FTransform& OutTransform, FText& OutFailReason) const
{
// 先找到被点中的建造面。
OutSurface = ResolveSurfaceFromHit(Hit);
if (!OutSurface)
{
OutFailReason = FText::FromString(TEXT("命中的不是 BuildGridSurfaceActor。"));
return false;
}
// 再把命中点换算成网格坐标。
FIntPoint OriginCell = FIntPoint::ZeroValue;
FVector SnappedLocation = FVector::ZeroVector;
if (!OutSurface->ProjectWorldToGrid(Hit.ImpactPoint, OriginCell, SnappedLocation))
{
OutFailReason = FText::FromString(TEXT("命中点不在可建网格内部。"));
return false;
}
TArray<FIntPoint> ReservedCells;
if (!OutSurface->CanPlaceDefinitionAtCell(TrapDefinition, OriginCell, Rotation, OutFailReason, ReservedCells))
{
return false;
}
// 这里不真正生成,只把“如果能放,最终应该出现在哪”算出来。
OutTransform = OutSurface->MakePlacementTransform(TrapDefinition, OriginCell, Rotation);
return true;
}
AActor* UTrapPlacementComponent::TryPlaceFromHit(const FHitResult& Hit, const UTrapPlacementDefinition* TrapDefinition, const EBuildGridRotation Rotation, FText& OutFailReason) const
{
// 这部分和 EvaluatePlacementFromHit 类似,
// 只是最后一步会真正 Spawn 陷阱。
ABuildGridSurfaceActor* Surface = ResolveSurfaceFromHit(Hit);
if (!Surface)
{
OutFailReason = FText::FromString(TEXT("命中的不是 BuildGridSurfaceActor。"));
return nullptr;
}
FIntPoint OriginCell = FIntPoint::ZeroValue;
FVector SnappedLocation = FVector::ZeroVector;
if (!Surface->ProjectWorldToGrid(Hit.ImpactPoint, OriginCell, SnappedLocation))
{
OutFailReason = FText::FromString(TEXT("命中点不在可建网格内部。"));
return nullptr;
}
return Surface->PlaceTrapDefinitionAtCell(TrapDefinition, OriginCell, Rotation, OutFailReason);
}
BuildPlacementPlayerController.h
#pragma once
#include "BuildGridTypes.h"
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "BuildPlacementPlayerController.generated.h"
class UTrapPlacementComponent;
class UTrapPlacementDefinition;
// 一个最小可用的建造控制器示例:
// - 鼠标左键:尝试放置当前陷阱
// - R 键:按 0/90/180/270 度循环旋转
//
// 这个类不是必须的核心系统,只是为了演示如何调用前面那套网格放置逻辑。
UCLASS()
class DEMO_API ABuildPlacementPlayerController : public APlayerController
{
GENERATED_BODY()
public:
ABuildPlacementPlayerController();
protected:
virtual void BeginPlay() override;
virtual void SetupInputComponent() override;
virtual void Tick(float DeltaSeconds) override;
// 当前选择的陷阱定义。
// 你可以在蓝图子类里直接指定一个 DataAsset 来测试。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Demo")
TObjectPtr<UTrapPlacementDefinition> SelectedTrapDefinition;
// 是否在屏幕和日志里输出调试信息。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Demo")
bool bPrintDebugMessages = true;
// 是否启用鼠标移动时的红/绿预览。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Demo|Preview")
bool bEnablePlacementPreview = true;
// 预览框离格子边缘收缩多少,避免和网格边界完全重叠。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Demo|Preview", meta=(ClampMin="0.0", UIMin="0.0"))
float PreviewInset = 8.0f;
// 预览线框的粗细。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Demo|Preview", meta=(ClampMin="0.5", UIMin="0.5"))
float PreviewLineThickness = 2.5f;
// 可放置时的颜色。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Demo|Preview")
FColor ValidPreviewColor = FColor(40, 220, 120, 255);
// 不可放置时的颜色。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Build Demo|Preview")
FColor InvalidPreviewColor = FColor(240, 70, 70, 255);
// 真正负责射线检测和调用放置逻辑的组件。
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Build Demo")
TObjectPtr<UTrapPlacementComponent> PlacementComponent;
// 当前旋转角度。
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Build Demo")
EBuildGridRotation CurrentRotation = EBuildGridRotation::Deg0;
private:
void HandlePlaceTrap();
void HandleRotateTrap();
void UpdatePlacementPreview() const;
void PrintDebugMessage(const FString& Message, const FColor Color = FColor::White) const;
static EBuildGridRotation GetNextRotation(EBuildGridRotation Rotation);
static FString RotationToString(EBuildGridRotation Rotation);
};
BuildPlacementPlayerController.cpp
#include "BuildPlacementPlayerController.h"
#include "BuildGridSurfaceActor.h"
#include "DrawDebugHelpers.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "GameFramework/PlayerInput.h"
#include "InputCoreTypes.h"
#include "TrapPlacementComponent.h"
#include "TrapPlacementDefinition.h"
ABuildPlacementPlayerController::ABuildPlacementPlayerController()
{
PrimaryActorTick.bCanEverTick = true;
bShowMouseCursor = true;
DefaultMouseCursor = EMouseCursor::Crosshairs;
PlacementComponent = CreateDefaultSubobject<UTrapPlacementComponent>(TEXT("PlacementComponent"));
}
void ABuildPlacementPlayerController::BeginPlay()
{
Super::BeginPlay();
PrintDebugMessage(TEXT("建造示例已启用:左键放置,R 键旋转。"), FColor::Green);
}
void ABuildPlacementPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
if (!InputComponent)
{
return;
}
// 这里直接绑定具体按键,目的是让示例尽量开箱即用。
InputComponent->BindKey(EKeys::LeftMouseButton, IE_Pressed, this, &ABuildPlacementPlayerController::HandlePlaceTrap);
InputComponent->BindKey(EKeys::R, IE_Pressed, this, &ABuildPlacementPlayerController::HandleRotateTrap);
}
void ABuildPlacementPlayerController::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
UpdatePlacementPreview();
}
void ABuildPlacementPlayerController::HandlePlaceTrap()
{
if (!PlacementComponent)
{
PrintDebugMessage(TEXT("PlacementComponent 不存在。"), FColor::Red);
return;
}
if (!SelectedTrapDefinition)
{
PrintDebugMessage(TEXT("还没有指定 SelectedTrapDefinition。"), FColor::Red);
return;
}
FHitResult Hit;
if (!PlacementComponent->TraceFromPlayerView(this, Hit))
{
PrintDebugMessage(TEXT("没有射线命中任何对象。"), FColor::Yellow);
return;
}
FText FailReason;
AActor* SpawnedTrap = PlacementComponent->TryPlaceFromHit(Hit, SelectedTrapDefinition, CurrentRotation, FailReason);
if (!SpawnedTrap)
{
const FString Reason = FailReason.IsEmpty() ? TEXT("未知原因。") : FailReason.ToString();
PrintDebugMessage(FString::Printf(TEXT("放置失败:%s"), *Reason), FColor::Red);
return;
}
PrintDebugMessage(
FString::Printf(TEXT("放置成功,当前旋转:%s"), *RotationToString(CurrentRotation)),
FColor::Green);
}
void ABuildPlacementPlayerController::HandleRotateTrap()
{
CurrentRotation = GetNextRotation(CurrentRotation);
PrintDebugMessage(
FString::Printf(TEXT("当前旋转切换为:%s"), *RotationToString(CurrentRotation)),
FColor::Cyan);
}
void ABuildPlacementPlayerController::UpdatePlacementPreview() const
{
if (!bEnablePlacementPreview || !PlacementComponent || !SelectedTrapDefinition || !GetWorld())
{
return;
}
FHitResult Hit;
if (!PlacementComponent->TraceFromPlayerView(const_cast<ABuildPlacementPlayerController*>(this), Hit))
{
return;
}
ABuildGridSurfaceActor* Surface = UTrapPlacementComponent::ResolveSurfaceFromHit(Hit);
if (!Surface)
{
return;
}
FIntPoint OriginCell = FIntPoint::ZeroValue;
FVector SnappedLocation = FVector::ZeroVector;
if (!Surface->ProjectWorldToGrid(Hit.ImpactPoint, OriginCell, SnappedLocation))
{
return;
}
const TArray<FIntPoint> PreviewCells = Surface->GetFootprintCellsAt(SelectedTrapDefinition, OriginCell, CurrentRotation);
if (PreviewCells.Num() == 0)
{
return;
}
FText FailReason;
TArray<FIntPoint> ValidatedCells;
const bool bCanPlace = Surface->CanPlaceDefinitionAtCell(SelectedTrapDefinition, OriginCell, CurrentRotation, FailReason, ValidatedCells);
const FColor PreviewColor = bCanPlace ? ValidPreviewColor : InvalidPreviewColor;
const FVector HalfExtent = Surface->GetCellPreviewHalfExtent(PreviewInset);
const FQuat SurfaceRotation = Surface->GetActorQuat();
// 每帧重画一次线框,让玩家持续看到当前鼠标下的落点预览。
for (const FIntPoint& Cell : PreviewCells)
{
DrawDebugBox(
GetWorld(),
Surface->GetCellCenterWorld(Cell),
HalfExtent,
SurfaceRotation,
PreviewColor,
false,
0.0f,
0,
PreviewLineThickness);
}
}
void ABuildPlacementPlayerController::PrintDebugMessage(const FString& Message, const FColor Color) const
{
if (!bPrintDebugMessages)
{
return;
}
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 2.0f, Color, Message);
}
UE_LOG(LogTemp, Log, TEXT("%s"), *Message);
}
EBuildGridRotation ABuildPlacementPlayerController::GetNextRotation(const EBuildGridRotation Rotation)
{
switch (Rotation)
{
case EBuildGridRotation::Deg0:
return EBuildGridRotation::Deg90;
case EBuildGridRotation::Deg90:
return EBuildGridRotation::Deg180;
case EBuildGridRotation::Deg180:
return EBuildGridRotation::Deg270;
case EBuildGridRotation::Deg270:
default:
return EBuildGridRotation::Deg0;
}
}
FString ABuildPlacementPlayerController::RotationToString(const EBuildGridRotation Rotation)
{
switch (Rotation)
{
case EBuildGridRotation::Deg0:
return TEXT("0 度");
case EBuildGridRotation::Deg90:
return TEXT("90 度");
case EBuildGridRotation::Deg180:
return TEXT("180 度");
case EBuildGridRotation::Deg270:
return TEXT("270 度");
default:
return TEXT("未知");
}
}