☰
UE5 生存建造游戏开发:用 UGameInstanceSubsystem 管理 Actor 与碰撞检测的完整流程
2026/10/8 23:21:56 网站建设 项目流程

1. 生存建造玩法里 Actor 满天飞,为什么我最后把逻辑全塞进 UGameInstanceSubsystem

做生存建造类游戏,绕不开一个核心循环:玩家点一下建造按钮,场景里出现一个半透明的预览 Actor,鼠标移动它跟着走,位置合法就绿色、不合法就红色,左键确认落地。听起来简单,但真写起来,问题会一个接一个冒出来。

我最早的做法是把这套逻辑全写在 PlayerController 里。结果呢?采集系统要判断"这个位置能不能放箱子",交互系统要判断"这个门会不会卡住 NPC",建造系统自己还要判断预览框有没有重叠。三套代码各写一份 SweepSingleByChannel,参数还不一样,改一个碰撞通道要翻三个文件。更麻烦的是,PlayerController 在关卡切换、PIE 重启时会被重建,我存在里面的PreviewBuilding指针直接变野指针,编辑器里点停止再点播放,崩溃日志能刷一屏。

后来我把这套东西抽出来,放进一个UGameInstanceSubsystem。这个类的生命周期是"游戏实例创建到销毁",在 PIE 里点结束按钮它不会被销毁,下次播放还是同一个实例。这意味着建造状态、预览 Actor 引用、当前选中的建筑模板,全都可以安全地挂在这里,不用每次开局重新初始化。

这篇就按我实际项目的组织方式,把整条链路拆开讲:Subsystem 怎么注册、Actor 怎么生成、碰撞通道怎么配、鼠标移动怎么绑定、左键确认后怎么解绑,最后附上 PIE 下我踩过的报错清单。适合已经会写基础 C++ Actor、但被"建造预览"这套交互绕晕的 UE5 开发者。核心检索词就三个:UGameInstanceSubsystem 做全局管理器、SweepSingleByChannel 做碰撞检测、Actor 生命周期跟着建造流程走。

先说清楚 Subsystem 的定位。它不是万能的,别把采集、背包、任务全塞进去。我的划分标准是:只要一个状态需要跨关卡、跨 PlayerController 存活,并且被多个系统查询,就放 Subsystem。建造预览恰好符合——采集系统要问它"这块地能不能放",交互系统要问它"当前有没有在建造模式",UI 要问它"现在选的是哪个建筑"。这三方都不该直接持有 PlayerController 的指针。

UGameInstanceSubsystem的获取方式有三种,我在不同场景下用不同的:

// 方式一:从 PlayerController 拿(UI 回调里最常用) UBuildSubsystem* Build = GetGameInstance()->GetSubsystem<UBuildSubsystem>(); // 方式二:从 World 拿(Actor 的 BeginPlay 里用) UWorld* World = GetWorld(); UBuildSubsystem* Build = World->GetGameInstance()->GetSubsystem<UBuildSubsystem>(); // 方式三:从 GEngine 拿(调试、控制台命令里用,正式逻辑别用) GEngine->GetCurrentPlayWorld()->GetGameInstance()->GetSubsystem<UBuildSubsystem>();

方式三我只在写调试命令时用过,因为它依赖当前 PlayWorld,打包后多窗口场景会拿到错的。正式逻辑统一走方式一和方式二。

这里有个容易忽略的点:Subsystem 的Initialize会在 GameInstance 初始化时调用,但那时候 World 可能还没准备好。所以别在Initialize里做GetWorld()相关的操作,我一般只在这里初始化纯数据成员,比如SpawnLocation = FVector::ZeroVector;。真正涉及 World 的逻辑,等第一次被调用时再懒加载。

还有一个坑:Subsystem 里持有AActor*指针时,一定要加UPROPERTY()。我一开始图省事写成裸指针AActor* PreviewBuilding;,结果 GC 在某次鼠标移动后把预览 Actor 回收了,下一帧SetActorLocation直接访问已释放内存。加上UPROPERTY()后,GC 会正确追踪这个引用,问题消失。这个细节在官方文档里提得不多,但实际项目里必踩。

2. 前置准备:TaoToken 接入与 UBuildSubsystem 骨架搭建

在动手写建造逻辑之前,我习惯先把开发环境里跟 AI 辅助编码相关的部分配好。写 UE5 的 C++ 时,经常需要让模型帮忙补全碰撞参数、检查 UPROPERTY 宏有没有漏,这时候一个稳定的 API 入口能省不少事。TaoToken 的接入方式很简单,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。

如果你用的是 Claude Code 做代码补全,可以在项目根目录建一个配置文件,把 Base URL、Key、Model ID 三件套写全。我实测下来,写 UE 的 C++ 时模型对GENERATED_BODY()、UFUNCTION这些宏的补全准确率还不错,但碰撞通道枚举经常写错,需要自己核对。

{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的密钥", "model": "claude-sonnet-4-20250514" }

Key 的获取在控制台的 API Keys 页面,模型对话入口可以用来先验证 Key 是否可用。长期做编码和 Agent 任务的话,Coding Plan 更划算,具体在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite 可以看。

环境配好后,回到 UE 这边。先建 Subsystem 的头文件。我把它放在Source/AFarmSimulation/SystemSystem/目录下,跟其他系统类放一起。

// BuildSubsystem.h #pragma once #include "CoreMinimal.h" #include "Subsystems/GameInstanceSubsystem.h" #include "BuildSubsystem.generated.h" UCLASS() class AFARMSIMULATION_API UBuildSubsystem : public UGameInstanceSubsystem { GENERATED_BODY() public: virtual void Initialize(FSubsystemCollectionBase& Collection) override; virtual void Deinitialize() override; // 生成预览 Actor UFUNCTION(BlueprintCallable, Category = "Build System") bool ShowPreview(TSubclassOf<AActor> BuildingTemplate, const FVector& Location, const FRotator& Rotation); // 销毁当前预览 UFUNCTION(BlueprintCallable, Category = "Build System") void DestoryCurrentActor(); // 更新预览位置 UFUNCTION(BlueprintCallable, Category = "Build System") void ChangePreviewActorPosition(FVector position); // 隐藏碰撞框(确认建造后调用) void SetCollisionBoxVisibilityHide(); // 当前位置是否合法 bool GetCurrentIsRight(); UPROPERTY(VisibleAnywhere, Category = "Build System") FVector SpawnLocation; private: bool CheckBuildLocation(const FVector& Location, const FRotator& Rotation, const FVector& Extents) const; bool BuildSystemSpawnActor(TSubclassOf<AActor> BuildingTemplate, const FVector& Location, const FRotator& Rotation, FActorSpawnParameters SpawnParams); void SetCollisionBoxColor(); UPROPERTY() AActor* PreviewBuilding; };

注意PreviewBuilding前面的UPROPERTY(),这是防 GC 的关键。SpawnLocation用VisibleAnywhere是为了在编辑器细节面板里能直接看到当前记录的位置,调试时很方便。

Initialize和Deinitialize里我只做最轻量的初始化:

void UBuildSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); SpawnLocation = FVector::ZeroVector; PreviewBuilding = nullptr; } void UBuildSubsystem::Deinitialize() { Super::Deinitialize(); }

这里PreviewBuilding = nullptr是保险,虽然 UPROPERTY 会处理,但显式置空能避免 Deinitialize 后还有异步回调访问。

接下来是碰撞检测的核心函数CheckBuildLocation。这个函数被SetCollisionBoxColor调用,用来判断预览框当前位置有没有跟场景物体重叠。

bool UBuildSubsystem::CheckBuildLocation(const FVector& Location, const FRotator& Rotation, const FVector& Extents) const { UWorld* World = GetWorld(); if (!World) return false; FCollisionQueryParams CollisionParams; CollisionParams.AddIgnoredActor(PreviewBuilding); // 忽略自身,否则永远检测到自己 FHitResult HitResult; bool bHit = World->SweepSingleByChannel( HitResult, Location, // 起始位置 Location, // 结束位置(相同 = 原地重叠检测) Rotation.Quaternion(), ECC_WorldDynamic, // 碰撞通道,按项目实际调整 FCollisionShape::MakeBox(Extents), CollisionParams ); return bHit; }

SweepSingleByChannel的起始和结束位置传同一个值,效果就是"在当前形状位置做一次重叠检测"。如果传不同值,就是沿路径扫掠。建造预览用同位置检测就够了,因为鼠标移动是逐帧更新的,不需要预测路径。

AddIgnoredActor(PreviewBuilding)这行必须加。我一开始忘了,结果预览框永远检测到自己,颜色一直是红的,排查了半小时才发现。

3. 可复制配置:碰撞通道、Actor 组件与鼠标绑定

碰撞通道的配置是这套流程里最容易出错的地方。UE 默认的碰撞通道有ECC_Visibility、ECC_WorldStatic、ECC_WorldDynamic、ECC_Pawn等。建造检测该用哪个,取决于你的项目设置。

我的做法是在DefaultEngine.ini里加一个自定义通道,专门给建造检测用:

[/Script/Engine.CollisionProfile] +DefaultChannelResponses=(Channel=ECC_GameTraceChannel1,DefaultResponse=ECR_Block,bTraceType=True,bStaticObject=False,Name="BuildTrace")

然后在代码里用ECC_GameTraceChannel1代替ECC_WorldDynamic。这样建造检测跟其他系统的碰撞查询互不干扰。如果你不想改 ini,直接用ECC_WorldDynamic也能跑,但后期加系统时容易冲突。

预览 Actor 的组件结构,我用一个UStaticMeshComponent做外观,一个UBoxComponent做碰撞框。Box 的大小手动调,要跟 Mesh 的包围盒匹配。

// 在预览 Actor 的构造函数里 UStaticMeshComponent* MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComp")); RootComponent = MeshComp; UBoxComponent* BoxComp = CreateDefaultSubobject<UBoxComponent>(TEXT("BoxComp")); BoxComp->SetupAttachment(MeshComp); BoxComp->SetBoxExtent(FVector(100.f, 100.f, 100.f)); // 按实际建筑尺寸调 BoxComp->SetCollisionEnabled(ECollisionEnabled::QueryOnly); BoxComp->SetCollisionResponseToAllChannels(ECR_Overlap); BoxComp->SetGenerateOverlapEvents(true);

SetCollisionEnabled(ECollisionEnabled::QueryOnly)表示只参与查询,不参与物理模拟。建造预览不需要物理,这样性能更好。

生成 Actor 的函数:

bool UBuildSubsystem::BuildSystemSpawnActor(TSubclassOf<AActor> BuildingTemplate, const FVector& Location, const FRotator& Rotation, FActorSpawnParameters SpawnParams) { UWorld* World = GetWorld(); if (!World) return false; PreviewBuilding = World->SpawnActor<AActor>(BuildingTemplate, Location, Rotation, SpawnParams); if (PreviewBuilding) { TArray<UBoxComponent*> BoxComponents; PreviewBuilding->GetComponents<UBoxComponent>(BoxComponents); for (auto ite : BoxComponents) { ite->SetHiddenInGame(false); ite->SetVisibility(true, true); ite->SetLineThickness(2.0f); } return true; } return false; }

这里SetHiddenInGame(false)和SetVisibility(true, true)是让碰撞框在游戏中可见。注意这两个函数不要放在 Tick 或循环里反复调用。我踩过的坑:在鼠标移动回调里每帧调SetHiddenInGame,参数没变,但引擎内部状态机不响应,导致后面想改成true时没效果。正确做法是只在生成时设一次,后续要改状态时确保参数真的变了。

SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;这行在ShowPreview里设置,保证预览 Actor 即使跟场景重叠也能生成,否则会被碰撞系统拒绝。

鼠标移动的绑定在 PlayerController 里:

void AAFarmPlayerController::OpenBuildMode() { InputComponent->BindAction("MouseLeftClicked", IE_Pressed, this, &AAFarmPlayerController::MouseLeftClicked); InputComponent->BindAxis("BuildMouseXY", this, &AAFarmPlayerController::MouseXY); FHitResult HitResult; GetHitResultUnderCursor(ECC_Visibility, false, HitResult); FRotator Ratotor(0, 0, 0); UBuildSubsystem* Build = GetGameInstance()->GetSubsystem<UBuildSubsystem>(); if (Build) { if (Build->ShowPreview(TreeClass, HitResult.Location, Ratotor)) { SpawnLocation = HitResult.Location; } } }

GetHitResultUnderCursor(ECC_Visibility, false, HitResult)本质是射线检测,从摄像机穿过鼠标位置打到场景。ECC_Visibility表示只检测可见物体,这样鼠标不会打到隐藏的碰撞体上。

鼠标移动回调:

void AAFarmPlayerController::MouseXY(float XY) { FHitResult HitResult; GetHitResultUnderCursor(ECC_WorldStatic, false, HitResult); UBuildSubsystem* Build = GetGameInstance()->GetSubsystem<UBuildSubsystem>(); FRotator Ratotor(0, 0, 0); if (Build) { Build->ChangePreviewActorPosition(HitResult.Location); } }

注意这里用的是ECC_WorldStatic,因为地面通常是静态物体。如果你用ECC_Visibility,鼠标可能会打到预览 Actor 自己身上,导致位置抖动。

左键确认:

void AAFarmPlayerController::MouseLeftClicked() { UBuildSubsystem* Build = GetGameInstance()->GetSubsystem<UBuildSubsystem>(); if (Build->GetCurrentIsRight()) { Build->SetCollisionBoxVisibilityHide(); InputComponent->RemoveActionBinding("MouseLeftClicked", IE_Pressed); InputComponent->RemoveAxisBinding("BuildMouseXY"); } }

RemoveAxisBinding("BuildMouseXY")是关键。因为预览 Actor 已经落地,鼠标移动不该再改变它的位置。如果不解绑,鼠标一动建筑就跟着跑,玩家会以为建造失败了。

4. 验证请求:PIE 下跑通建造流程与成功结果

配置写完后,在 PIE 里验证。步骤我按顺序列一下,每一步都有明确的预期结果。

第一步,启动 PIE,按 Play。在 Output Log 里应该看到 Subsystem 初始化没有报错。如果看到LogGameInstance: Warning: Failed to get subsystem,说明类没注册成功,检查UCLASS()宏和模块的 Build.cs 有没有加对应依赖。

第二步,点击 UI 上的建造按钮。预期:场景里出现一个半透明的建筑预览,碰撞框显示为绿色线框。如果预览没出现,先看 Output Log 有没有SpawnActor failed,通常是SpawnCollisionHandlingOverride没设成AlwaysSpawn。

第三步,移动鼠标。预期:预览 Actor 跟着鼠标在地面上滑动,碰撞框颜色实时变化——位置合法绿色,跟其他建筑重叠变红色。如果颜色不变,检查SetCollisionBoxColor有没有被调用,以及CheckBuildLocation的返回值。

第四步,把预览移到另一个建筑上面。预期:碰撞框变红。如果还是绿色,说明AddIgnoredActor没生效,或者碰撞通道配错了。

第五步,在红色状态下点左键。预期:没有任何反应,预览还在。这是GetCurrentIsRight()返回 false 的保护逻辑。

第六步,在绿色状态下点左键。预期:碰撞框消失,预览 Actor 变成实体建筑,鼠标移动不再影响它。Output Log 里如果有RemoveAxisBinding的调试信息,说明解绑成功。

我实测下来,整个流程从点击到落地,在普通台式机上延迟低于一帧,体感是即时的。碰撞检测的开销主要在SweepSingleByChannel,如果场景里建筑很多,可以考虑加空间分区,但一般生存建造游戏几十个建筑不需要。

验证时可以用控制台命令show Collision打开碰撞体显示,能直观看到 Box 的范围跟 Mesh 是否匹配。如果 Box 比 Mesh 大很多,建造时会觉得"明明没碰到却变红",这时候调SetBoxExtent的参数。

还有一个验证技巧:在CheckBuildLocation里加DrawDebugBox(GetWorld(), Location, Extents, FColor::Blue, false, 0.1f);,PIE 里能看到检测框的实际位置。调试完记得注释掉,正式打包别带。

5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth 报错对照

写这套逻辑时,我遇到的报错分两类:一类是 UE 引擎本身的,一类是 AI 辅助编码工具接入时的。分开说。

UE 侧报错:

Access violation - code c0000005在SetActorLocation处崩溃。原因:PreviewBuilding被 GC 回收了。排查:检查PreviewBuilding有没有加UPROPERTY()。我一开始就是漏了这个,加上后不再崩。

PreviewBuilding为 nullptr 但代码继续执行。原因:ShowPreview返回 true 但 Actor 没生成成功。排查:在BuildSystemSpawnActor里加UE_LOG,确认SpawnActor的返回值。常见原因是BuildingTemplate传了空指针,或者蓝图类没编译。

碰撞框颜色不更新。原因:SetCollisionBoxColor只在ShowPreview和ChangePreviewActorPosition里调用,如果鼠标没移动,颜色不会刷新。排查:确认MouseXY有没有绑定成功,可以在函数开头加UE_LOG(LogTemp, Warning, TEXT("MouseXY called"));。

SetHiddenInGame调用后没反应。原因:前面说的,参数没变时引擎不响应。排查:确保不在循环里反复调用,只在状态真正改变时调一次。

AI 工具接入侧报错:

401 Unauthorized。原因:API Key 错了或过期。排查:去控制台的 API Keys 页面重新生成,确认 Key 没有多余空格。模型对话入口可以先测一下 Key 是否可用。

local proxy failed。原因:本地网络配置问题,请求没发出去。排查:检查 Base URL 是不是https://taotoken.net/api,注意不要带 UTM 参数。如果公司网络有防火墙,确认出口规则。

reading choices报错。原因:返回的 JSON 结构跟预期不符,通常是模型 ID 写错了。排查:确认 Model ID 跟平台文档一致,别用已下线的模型名。

OAuth相关报错。原因:认证流程没走完,或者 token 过期。排查:重新走一遍授权流程,确认回调地址配置正确。

这三件套(Base URL、Key、Model ID)在任何 AI 编码工具里都要写全。我用 Claude Code 时,配置文件里少写一个 Model ID,就会报reading choices,补上就好。

排查顺序建议:先看 Output Log 的最后一条 Error,再往上找第一条 Warning。UE 的报错经常是连锁的,第一条才是根因。

6. 建造系统跑通后,下一步该往哪走

这套 Subsystem 架构跑通后,扩展方向其实很清晰。采集系统可以直接调UBuildSubsystem::GetCurrentIsRight()来判断"玩家当前选的位置能不能放采集点",不用自己再写一套碰撞检测。交互系统可以监听 Subsystem 的状态变化,在建造模式下禁用其他交互。

我下一步准备做的是建筑旋转。现在预览 Actor 的 Rotation 是写死的FRotator(0,0,0),加个鼠标滚轮绑定,每次转 15 度,CheckBuildLocation的 Rotation 参数跟着变就行。碰撞框的SetBoxExtent也要跟着旋转后的包围盒调整,这块稍微麻烦点,但逻辑是通的。

如果你在接入 AI 辅助编码时遇到 Key 或模型配置问题,API Keys 页面和接入文档能解决大部分。验证模型是否可用走模型对话入口,长期做编码任务看 Coding Plan。地址都在前面提过,按需取用。

最后说个实用技巧:Subsystem 里的SpawnLocation我建议一直保留,不要确认建造后就清空。因为玩家可能想连续放多个建筑,保留上一个位置可以做"吸附到网格"或者"连续建造"的功能。我现在的项目里,按住 Shift 点左键就是连续建造,位置自动偏移一个建筑宽度,体验比一个个点好很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询