跳到主内容
虚幻5
文章阅读

UE5 塔防网格放置系统:只允许在指定地面、墙面、天花板、斜坡上放置陷阱

用 UE5 C++ 实现一套适合塔防游戏的网格放置系统:只允许在显式标记的建造面上放置陷阱,支持地面、墙面、天花板、斜坡,以及 1x1、1x2、2x2 和自定义占用形状。文章会从架构拆分、核心类职责、放置校验流程到编辑器使用方式,完整讲清楚这套系统为什么稳定、为什么适合新手继续扩展。

2026/03/21702 次阅读53 分钟

我这次想做的不是“随便找个地方丢个陷阱”,而是更接近《逆战塔防》那种感觉的建造系统。

核心要求其实很明确:

  1. 不是全地形都能放。
  2. 只能放在我明确标记过的区域里。
  3. 地面、墙面、天花板、斜坡都要支持。
  4. 陷阱有的占 1 格,有的占 2 格,有的占 4 格。
  5. 只要有任何一个格子不合法,就不能放。

如果你一开始就想着“我对整张地图做碰撞判断”,很快就会把逻辑写乱。因为地面、墙、斜坡的坐标系统都不一样,越往后加规则越难维护。

我这次采用的是另一种更稳的写法:不要对整张地图做建造判断,而是显式定义哪些区域可以建。


先说结论:把建造系统拆成三层

我最后把整个系统拆成了 3 个核心部分:

  1. ABuildGridSurfaceActor
  2. UTrapPlacementDefinition
  3. UTrapPlacementComponent

这个拆法的好处是职责非常清楚。

ABuildGridSurfaceActor 负责描述一块可建造区域。你可以把它理解成“游戏里的一块白名单建造面”。它自己知道:

  • 自己是地板、墙、天花板还是斜坡
  • 自己有多少行多少列
  • 每个格子多大
  • 哪些格子预设为不可建
  • 哪些格子已经被别的陷阱占用了

UTrapPlacementDefinition 负责描述“某个陷阱的占地规则”。比如:

  • 这个陷阱实际要生成什么 Actor
  • 它占 1x12x1 还是 2x2
  • 它是否允许旋转
  • 它可以放在地板、墙壁还是斜坡上

UTrapPlacementComponent 负责跑完整个放置流程:

  • 从玩家视角打一条射线
  • 看射线打中了哪个可建造面
  • 把命中点换算成网格坐标
  • 调用校验逻辑判断能不能放
  • 如果合法,就生成陷阱 Actor

这三个类配合起来,整个系统就很干净。


为什么我觉得这种方案比“全地图判断”优雅

很多人第一次做塔防放置,会下意识想这么做:

  • 鼠标点地面
  • 拿到世界坐标
  • 看周围有没有遮挡
  • 然后判断一下法线是不是朝上

这种方法看起来简单,但问题很快就会出现。

比如你后面加上:

  • 墙壁陷阱
  • 天花板陷阱
  • 斜坡陷阱
  • 特殊禁建格
  • 多格占用
  • 旋转后的占格变化

你会发现规则全都掺在一起了。

所以我更推荐把“允许建造”这件事,改成一个显式配置的概念。也就是:只有 ABuildGridSurfaceActor 覆盖到的地方,玩家才能放东西。这个思路有几个明显优势:

第一,规则很稳定。
第二,扩展新地形类型不需要重写算法。
第三,调试非常简单,因为问题会收敛到某一块建造面。
第四,策划和美术也更容易配合,因为你能在场景里直接摆出可建区域。

说白了,别把建造规则藏在“地图碰巧长成这样”里,而是把它做成一等数据。


第一层:ABuildGridSurfaceActor 负责“这块区域能不能建”

这个类是整套系统的核心。

它本质上是一个带局部坐标系的二维网格面。最重要的一点是:无论它在世界里是平着放、竖着放,还是斜着放,它内部都按统一的局部网格逻辑工作。

这就意味着:

  • 平着摆就是地板
  • 竖着摆就是墙
  • 翻过去就是天花板
  • 旋转一个角度就是斜坡

但代码完全不用分开写四套。

这个类里最关键的方法有三个。

1. ProjectWorldToGrid

这个函数负责把玩家点击到的世界坐标,转换成网格坐标。

它的思路是:

  1. 先把世界坐标变换到当前建造面的局部空间
  2. 判断这个点是否落在盒体范围内
  3. 再根据 CellSize 计算它属于第几个格子

只有把世界坐标转换成格子坐标,后续才能判断“这个陷阱会占哪些格子”。

2. CanPlaceDefinitionAtCell

这个函数负责真正的合法性校验。

它会依次检查:

  1. 当前陷阱是否支持这个建造面类型
  2. 旋转后的占用格子是否越界
  3. 是否命中了预设禁建格
  4. 是否和已放置陷阱冲突

只要有任意一个格子不合法,直接返回失败。

这个点非常重要。因为塔防类放置通常不是“局部容忍一点误差”,而是要么全合法,要么完全不能放

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_Ground
  • BP_BuildSurface_Wall
  • BP_BuildSurface_Ceiling
  • BP_BuildSurface_Ramp

然后在细节面板里设置:

  • SurfaceType
  • GridDimensions
  • CellSize
  • PreBlockedCells

摆进场景后,这些区域就成了允许建造的白名单区域。

第二步:做陷阱规则 DataAsset

创建几个 UTrapPlacementDefinition 对应的数据资源,比如:

  • DA_Trap_FloorSpike_1x1
  • DA_Trap_WallGun_2x1
  • DA_Trap_Block_2x2

然后分别配置:

  • TrapActorClass
  • FootprintSize
  • AllowedSurfaceMask
  • bAllowQuarterTurns

第三步:给玩家挂上 UTrapPlacementComponent 或直接使用示例控制器

你可以挂在 PlayerControllerPawn 或自定义建造管理器上。

如果你想最快看到效果,我这次已经额外补了一个 ABuildPlacementPlayerController 示例。它除了负责左键放置、R 键旋转之外,还会在 Tick 里根据鼠标命中的格子绘制红绿预览框:

  • 绿色表示当前占用格全部合法
  • 红色表示当前至少有一个格子不合法
  • R 时,预览会跟着切换横放、竖放和 180/270 度

然后在输入逻辑里做两件事:

  • R 切换 EBuildGridRotation
  • 鼠标左键调用 TryPlaceFromHit

这样基本就能跑通一版了。

如果你只是想先验证“一个 2x1 的陷阱是否真的能横放和竖放”,我建议最简单的测试方式是:

  1. 建一个 4x4ABuildGridSurfaceActor
  2. 建一个 UTrapPlacementDefinition
  3. FootprintSize 设成 (2,1)
  4. bAllowQuarterTurns 设成 true
  5. 使用一个带 R 键切换旋转、鼠标左键放置的示例控制器

这样你会看到:

  • 默认 Deg0 时,占的是两个横向格子
  • 按一次 R 切到 Deg90 后,占的是两个纵向格子
  • 再按一次会变成 Deg180
  • 再按一次会变成 Deg270

也就是说,这套系统不仅支持“横放/竖放”,而且已经是完整的四方向旋转逻辑了。


这套系统为什么适合继续扩展

我比较喜欢这套结构的原因,不只是因为它能跑,而是因为它后面很好扩。

比如你之后可以继续加,或者继续把我这次已经补好的简易版往上升级:

1. 放置预览

我这次已经先补了一版最小可用实现:

  • ABuildPlacementPlayerController::Tick 每帧更新预览
  • ABuildGridSurfaceActor::GetFootprintCellsAt 负责把当前旋转下的占格展开出来
  • ABuildGridSurfaceActor::GetCellCenterWorld 负责拿到每个格子的世界中心点
  • ABuildGridSurfaceActor::GetCellPreviewHalfExtent 负责计算调试框尺寸

当前实现使用 DrawDebugBox 画线框,好处是完全不依赖额外材质和预览模型,新项目里复制代码就能立刻看到效果。

2. 红绿格高亮

这个功能其实也已经包含在上面的预览里了。当前陷阱将要占用的格子会直接画出来:

  • 可放时绿色
  • 不可放时红色

3. 不规则占用

直接利用 CustomFootprintCells 做特殊陷阱。

4. 路径保护

把怪物路径经过的格子加入 PreBlockedCells,就能防止玩家堵路。

5. 不同面有不同陷阱池

比如墙上只能放墙炮,地板只能放地刺,这个现在已经有基础支持了。


我对这类系统的一个建议

如果你是新手,做这类系统时不要一上来就追求“超级通用”。

先把下面这几个核心问题做扎实:

  1. 世界坐标怎么转网格坐标
  2. 多格占用怎么计算
  3. 越界怎么处理
  4. 占用冲突怎么处理
  5. 销毁后格子怎么释放

只要这五件事是稳定的,后面的预览、特效、旋转、UI、陷阱升级,都是叠加层。

反过来,如果这五件事一开始没设计清楚,后面你会一直在修“为什么这里能卡进去”“为什么预览是绿的但放不下”“为什么两个陷阱重叠了”这种基础问题。


这次代码里最值得重点读的地方

如果你准备仔细阅读代码,我建议优先看这几个函数:

  1. ABuildGridSurfaceActor::ProjectWorldToGrid
  2. ABuildGridSurfaceActor::CanPlaceDefinitionAtCell
  3. ABuildGridSurfaceActor::MakePlacementTransform
  4. UTrapPlacementDefinition::GetFootprintCells
  5. UTrapPlacementComponent::EvaluatePlacementFromHit

把这几段看明白,整套系统你就已经吃透一大半了。


最后

我越来越觉得,塔防类建造系统的关键,不是“怎么点中一个地方”,而是“怎么把建造规则收敛成一套稳定的数据结构”。

一旦你把“可建造面”和“陷阱占用规则”这两件事拆出来,后面的代码会顺很多。

这也是我这次选择这套写法的原因。它不是最炫的,但它足够稳,足够清晰,也很适合新手一层一层往下读。

如果你后面继续做,我下一步最推荐补的是两样:

  1. 把现在的 DrawDebugBox 升级成真正的半透明预览 Actor
  2. 把“整体红/绿”升级成“逐格高亮 + 失败原因提示”

这样做完之后,整个系统就会更接近真正的塔防建造体验。


完整示例代码

下面我把这次文章里提到的完整示例代码也贴上来,方便你一边看讲解,一边直接对照源码。

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("未知");
	}
}
返回顶部