UE5 C++(37-3):代码实现
1. 开始写代码前,先给37-1和37-2做个了结
1.1 前两讲搭好的底子是什么
如果你是从这个系列一路追过来的,应该还记得37-1我们做了一件事:把"玩家看向某个物体,屏幕下方弹出提示文字,按E键触发交互"这个流程,先在场景里用最原始的方式跑通了一遍。当时用的是非常直白的手段,蓝图连线的逻辑堆在关卡蓝图里,交互物本身用蓝图Actor挂碰撞盒,提示文字则直接拖了一个UMG控件到Viewport上。37-2则把UI部分单独拆了出来,做了一个名为WBP_PickupPrompt的用户控件,里面放了一个TextBlock,还顺手做好了一组淡入淡出的动画,方便后面切换显示的时候不至于太生硬。
但到了这一步,蓝图方案已经明显快要撑不住了。关卡蓝图里那一坨连线,稍微加两个交互物,可读性会立刻断崖式下降。更麻烦的是,每个交互物如果都要自己复制一整套"检测玩家、刷新文字、处理按键"的逻辑,后面迭代起来就是灾难。所以这一讲的代码实现,目标非常明确:把前两讲用蓝图验证过的流程,收拢成一组结构清晰的C++类,把关卡蓝图里的临时连线彻底解放掉。
1.2 这一讲的C++代码要接管哪三件事
我先直接告诉你这一讲做完之后的最终形态,免得你对着代码心里没底。完成之后,整个交互流程会拆成三个清晰的分工,各司其职。
第一件事,是角色侧统一负责发射一条可控的检测射线,这条射线从摄像机位置朝屏幕中心方向射出,命中低距离内的物体后,再判断这个物体能不能交互。也就是说,"玩家正在看什么"这件事完全由玩家角色类说了算,不需要每个交互物自己去问玩家在哪。
第二件事,是交互物侧只需要实现一个统一的接口,告诉外界两样信息:一个是"你该显示什么提示文字",另一个是"玩家按下交互键后你该干什么"。至于这个物体会不会旋转、会不会开门、会不会加血,全部交给它自己去实现,角色侧一概不管。
第三件事,是UI侧接收一个纯C++的更新指令,把新的文字塞进TextBlock里。这里的重点在于,UI不直接接触场景里任何一个交互物,它拿到的只是最终要显示的那个字符串。
这三件事谁都不越界,后面你加十个、二十个交互物,角色类和UI类一行都不用改。这也是我为什么在这个系列里坚持用C++来做收尾的原因——蓝图验证交互逻辑很直观,但要真正把逻辑边界理清楚,还是得靠代码强制约束出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么要先定一个纯C++接口类UPickupInterface
2.1 接口类解决了蓝图重复代码的问题
如果你做过几个交互类蓝图,大概率会踩到同一个坑:每个交互物里都要写一遍分支判断,检查玩家角色是否在范围内、按键是否触发,然后自己调用某个事件。两三张蓝图还好,一旦数量上去,你会发现复制粘贴改变量名的体力活非常恶心,而且某天你想调整一下交互键位,就得把所有蓝图挨个打开改一遍。
接口类的存在,就是专门干这个的。它在逻辑上规定了一套统一的"契约",不管你是旋转门、可拾取金币、机关按钮还是NPC对话,只要你实现了UPickupInterface,就意味着你向外界承诺了逻辑上那句"我知道该怎么被交互"。那么玩家角色就完全不需要知道你具体是什么类,它只需要对着接口说话。这样做的直接好处是,以后你加新类型交互物,甚至在别的项目里复用这套交互框架,角色类这边零改动。
2.2 UINTERFACE与蓝图接口的差异细节
不少同学看到UINTERFACE这个宏会想当然地以为,这就是给蓝图用的接口,跟编辑器里创建Blueprint Interface差不多。严格说,这套宏生成的确实是一个可以被蓝图实现的C++接口,但它和蓝图脚本建的接口有一个重要的差别:Blueprint Interface只能完全在蓝图逻辑里处理,而你用UINTERFACE声明的接口,允许你为它提供真实的C++默认实现,并且同时保留蓝图覆盖的能力。
这一点在不同项目里的意义不一样。对于纯蓝图团队,Blueprint Interface可能已经够用;但只要你愿意把代码层面的逻辑写到C++里,或者想让某些默认行为不依赖蓝图节点就成立,就必须走UINTERFACE。这个系列既然已经在往C++收口了,那我们采用的当然是后者:关键的默认行为尽量落在C++这边,蓝图只做扩展和具体表现。
2.3 具体代码:接口头文件怎么写
接口在UE5里其实是一对类。UINTERFACE标记的U类负责提供反射数据,而真正用来声明函数签名的,是第二个同名的普通C++类。用起来大概是这个样子:
cpp复制// PickupInterface.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "PickupInterface.generated.h"
UINTERFACE(MinimalAPI, Blueprintable)
class UPickupInterface : public UInterface
{
GENERATED_BODY()
};
class IPickupInterface
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Pickup")
FText GetInteractionPrompt() const;
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Pickup")
void OnInteract(AActor* Interactor);
};
这里两个函数的声明方式都用了BlueprintNativeEvent。这意味着接口会为这个函数生成一份C++默认实现,同时蓝图也可以覆写它。等会我们具体实现交互物的时候,就会在蓝图侧看到这两个函数变成了可覆写的事件节点,这对于希望美术和策划在不碰代码的情况下调表现,非常友好。
3. 核心链路:从角色射线检测到UI文本刷新
3.1 从"每个Actor各自为政"改成"角色统一检测"
37-1里每张交互蓝图是怎么写的?它自己加一个Sphere或Box碰撞组件,然后检测玩家是否进入触发区域。这放在东西少的Demo里完全没问题,但往大了想,一个复杂场景中几十个交互物同时都在做范围检测,每帧的碰撞开销并不划算。更关键的是,"玩家有没有在看这个东西"和"玩家是否在东西附近"是两码事。
我见过不少项目仍然习惯用碰撞盒来触发提示,因为这套路确实经典。但你要想做出一个更接近现代复杂3D游戏观感的交互体系,用"玩家朝向"来决定提示内容明显更自然。你往前走两步,视野里扫过三个交互物,屏幕上只会出现一个提示,那就是准星对准的那个。碰撞盒方案很难帮你做这种区分,而射线方案天生就是干这个的。
3.2 组件化检测还是纯函数检测
在实际开发中,"角色射线检测"有两种常见落地方式。一种是做一个InteractionComponent挂在角色身上,把射线发射、冷却、UI推送都封装进组件;另一种是不开组件,直接写在角色类的成员函数里。
如果是新手入门或者项目规模小,我建议不要急着上组件。组件的拆分虽然好听,但多一个组件就多一层嵌套,后续调试时你得经常在角色类和组件代码之间来回跳。第一版用纯函数写在角色类里,逻辑最直观,而且后续真要往组件里挪,函数的函数体几乎可以原封不动搬走。这一讲我们先用纯函数写法,把整条链路跑通,组件化的重构留到系列后面讲。
我确认一下这讲的目标文件数量:一个接口头文件,一个角色C++类(通常是你项目里玩家角色的基类),一个用户控件C++类,外加各自对应的cpp文件。也就是说,核心改动集中在三块代码上,没有多余的花活。
3.3 委托与事件:如何把命中结果"推送"给Widget
角色检测到可交互物体之后,接下来要做的不是自己直接抓Widget的引用然后硬塞文字,那样会把你所有类全都耦合到一起。优雅一点的做法是,角色类持有一个控件指针,但它对控件的唯一操作就是调用一个公开函数。
界面控件UPickupPromptWidget这边,则暴露一个SetPromptText(FText)函数,函数体内部把TextBlock的文本替换掉,并根据文本是否为空来决定播放淡入还是淡出动画。这里有一点可以单独拎出来说:我一直不太建议在UMG的Widget蓝图里做太多状态控制逻辑,状态分支写多了,日后你在编辑器里排查一个"为什么文字没更新"的问题,会比在代码里慢得多。C++这边只负责两件事:判断给什么文本,然后调用一次更新。
4. 代码实操:主角的交互检测流程完整拆解
4.1 SetupPlayerInputComponent里的输入映射
先看玩家角色类这边。构造方式不重要,你用引擎模板生成的Character就行,但头部声明里要加两个成员:
cpp复制// MyPlayerCharacter.h
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Interaction")
TObjectPtr<UPickupPromptWidget> PromptWidget;
UPROPERTY(EditDefaultsOnly, Category = "Interaction")
float InteractRange = 260.f;
PromptWidget要在BeginPlay里创建,但因为它是控件而不是Actor,不能用SpawnActor。标准的做法是先把UMG的模板类引用暴露出来:
cpp复制// MyPlayerCharacter.h
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Interaction")
TSubclassOf<UPickupPromptWidget> PromptWidgetClass;
然后你在角色蓝图里把这个类指定成WBP_PickupPrompt对应的那个控件蓝图类即可。千万别忘了这一步,我见过很多同学写完C++后控件始终是空指针,回头一查就是这边没赋值。
输入绑定的写法很简单,在SetupPlayerInputComponent里加上这样一行:
cpp复制void AMyPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
PlayerInputComponent->BindAction("Interact", IE_Pressed, this, &AMyPlayerCharacter::TryInteract);
}
注意这里我没有用Enhanced Input,原因是这个系列的前面部分还是传统输入映射驱动的按键。如果你项目已经切了Enhanced Input,那对应改成UActorComponent那套回调逻辑也不复杂,关键点是一样的:按下交互键,触发TryInteract。
4.2 命中检测的完整函数写法
这个函数是整篇代码实现的核心。它做的事可以概括为四行:取摄像机位置、算射线终点、发射检测、告诉交互物你被选中了。
cpp复制void AMyPlayerCharacter::TryInteract()
{
if (!Controller)
{
return;
}
FVector CameraLocation;
FRotator CameraRotation;
Controller->GetPlayerViewPoint(CameraLocation, CameraRotation);
const FVector TraceEnd = CameraLocation + CameraRotation.Vector() * InteractRange;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
QueryParams.bTraceComplex = false;
FHitResult HitResult;
const bool bHit = GetWorld()->LineTraceSingleByChannel(
HitResult,
CameraLocation,
TraceEnd,
ECC_GameTraceChannel1,
QueryParams
);
AActor* HitActor = bHit ? HitResult.GetActor() : nullptr;
if (HitActor && HitActor->Implements<UPickupInterface>())
{
IPickupInterface::Execute_OnInteract(HitActor, this);
}
}
可能有人会问,为什么不直接用ECC_Visibility或者ECC_WorldDynamic?因为这里应该有一个独立的阻断通道。默认的Visibility通道会被很多其他东西干扰,你在一个物件前面挡个半透明网格体,结果射线被网格体挡了,后面真正的交互物反而触发不了,非常讨厌。所以我建议在项目设置里新建一个Game Trace Channel,命名为Interact,对应枚举值ECC_GameTraceChannel1,然后在每个交互物的碰撞预设里把这个通道设为Block。
bTraceComplex建议设成false,否则射线会去检测每一块复杂碰撞的三角形面,完全不必要而且非常耗费性能。做交互检测一般用简单碰撞就够了。
4.3 接口回调触发UI更新的工作流
射线命中物体并且确认它实现了UPickupInterface之后,我们调用了Execute_OnInteract。但这里还有一个UI动作没做:屏幕下方的提示文字什么时候更新?
比较好的习惯是,在OnInteract被触发之前,角色先向交互物要一次提示文本,把UI刷出来;玩家看向别处没有可交互物体时,再主动把UI清空。也就是说,TryInteract函数里还需要有这段逻辑:
cpp复制void AMyPlayerCharacter::UpdateInteractionPrompt(FText NewPrompt)
{
if (PromptWidget)
{
PromptWidget->SetPromptText(NewPrompt);
}
}
然后你在TryInteract里根据bHit以及HitActor是否实现了接口,来决定调用UpdateInteractionPrompt时传入空文本还是传入IPickupInterface::Execute_GetInteractionPrompt(HitActor)的返回值。有的同学想偷懒,把UI更新直接塞进OnInteract回调里,这样会导致玩家还没按键时提示文字完全不出现,体验上是残缺的。正确的时间线是:准星对准交互物,提示立刻出现;按E,交互行为才发生。这是两码事,代码里也要分开。
为了减少Controller的空指针报警,我在BeginPlay里就把控件创建好,并AddToViewport。设置方式如下:
cpp复制void AMyPlayerCharacter::BeginPlay()
{
Super::BeginPlay();
if (PromptWidgetClass)
{
PromptWidget = CreateWidget<UPickupPromptWidget>(GetWorld(), PromptWidgetClass);
if (PromptWidget)
{
PromptWidget->AddToViewport();
}
}
}
这里有个细节:CreateWidget的Outer参数建议传GetWorld(),在有些GameInstance相关的写法里传GameInstance也行。不过对于玩家角色直接持有的全屏提示控件,常规的Widget蓝图类通过GetWorld创建不会有问题。
5. 把C++类接回蓝图层:编译、绑定与最终接线
5.1 编译后让现有蓝图切换到C++基类
代码写完第一件事,不是马上启动编辑器看效果,而是做一步很关键的转换:把37-1里做好的交互物蓝图、渲染以及相关的UI蓝图,基类设置成我们刚写的C++类。如果你不做这一步,蓝图里那些连线依旧存在,但C++定义的新逻辑不会凭空跑到蓝图上。
操作上,打开交互物的蓝图,右上角找到Class Settings,把Parent Class从Actor改成我们定义的C++基类(比如AInteractableActorBase)。改完之后,引擎会建议你重新编译蓝图,这时候要注意:蓝图原本自带的那几个组件如果和C++里声明的组件重名,会报冲突,请先在蓝图的Components面板里把旧的重复组件删掉,再改基类。
如果是纯C++环境下,这一切在编辑器外就能做掉,但考虑到这个系列是从蓝图过渡过来的,我会建议你别急着删蓝图里的旧连线,先把基类切换过去跑一遍看有没有报错,再一点点把蓝图侧的逻辑清空,让C++默认实现接管。
5.2 纯C++接口在蓝图里如何调用
由于接口函数声明为BlueprintNativeEvent,所以你切回蓝图面板时,在交互物蓝图的Function Override菜单里应该能看到两个可覆写的事件:GetInteractionPrompt和OnInteract。选一个你需要的覆写一下,在蓝图里写具体表现即可。
比如,一个可拾取的血量药水蓝图的OnInteract覆写,里面可以放这几个节点:AddHealth、PlaySound、DestroyActor。而GetInteractionPrompt的覆写则返回一个本地化的FText,比如"按下E拾取药水"。这些你都完全不用写任何C++。
反过来,如果你在C++里先实现了默认行为,蓝图又覆写了,蓝图的实际执行结果是蓝图覆写的那一份。所以别担心C++里的默认实现会跟蓝图里的硬编码打架,这正是BlueprintNativeEvent的便利之处:代码提供兜底逻辑,蓝图根据美术表现再做微调。
5.3 不要忘了给trace channel单独配置
这一步其实最容易被漏掉,而且漏掉后你调试半天都找不到问题。新建的Trace Channel只是定义了一个名字,它的默认响应是Ignore还是Block,还需要在具体交互物的碰撞预设里逐个设置。如果你漏了这步,射线照样直直穿过去,不会触发任何Hit。
设置方法有两种路径,我建议直接选前者,简单粗暴:打开交互物的StaticMesh或碰撞盒组件,在Collision面板里找到Object Type改成WorldDynamic,然后展开Collision Response,找到我们新创建的Interact通道,给它Block。
另一种路径是在项目设置里的Collision面板里创建Preset,然后给对应物体设置那个Preset。区别在于,Preset适合批量管理同类物体,而逐个设置通道响应则适合临时验证逻辑。系列教学里我一般建议先用逐个设置把逻辑跑通,后面项目大了再整理Preset,省得你一上来就在预设的抽象逻辑里绕晕。
6. 实测记录与三个最容易被忽略的坑
6.1 坑一:OnInteract被连续触发了两次
这个坑我第一次做的时候踩得非常隐蔽。原因是我除了BindAction绑定了Interact按键之外,关卡蓝图或者输入设置里还残留着37-1临时加的检测逻辑。两边同时响应,每次按E,物体会被触发两次。轻则音效播两遍,重则拾取逻辑执行了两次,道具直接被消耗掉两个。
排查的办法也很简单:在OnInteract的C++实现第一行加一句UE_LOG输出,按下E看打印了几次。如果输出两条,就去查一下是否有两处绑定;如果输出一条,但逻辑像跑了两遍,那就要看是不是逻辑本身调用了其他会再次触发OnInteract的事件。我建议大家在系列教程做到这一步时,回头看37-1关卡蓝图里放了哪些临时节点,把属于旧方案的接线全部删掉,只留新代码的入口。
6.2 坑二:提示文字刷新过一次后就不再变化
当时我遇到的情况是,准星对准药水的时候,文字正常显示了;移到旁边的机关上,文字不更新了。这种问题十有八九是控件的更新被某个条件挡住了。
排查思路是这样的:先确认TryInteract有没有被触发——打UE_LOG就能看到;然后确认GetInteractionPrompt是否返回了不同文本;最后再确认Widget的SetPromptText有没有被调用。如果全都正常,那问题多半出在控件本身的动画上,比如淡出动画播放完后Visibility被设成了Collapsed,这时候SetText虽然执行了,但控件不可见,看起来就像"没更新"。
最省事的做法是在SetPromptText里做一次统一的可见性恢复:
cpp复制void UPickupPromptWidget::SetPromptText(const FText& NewText)
{
PromptTextBlock->SetText(NewText);
if (!NewText.IsEmpty())
{
PlayFadeInAnim();
}
else
{
PlayFadeOutAnim();
}
}
然后再处理动画Widget内部根据Visibility自动切换的逻辑,确保动画结束时状态不覆盖代码里设置的可见性。
6.3 坑三:射线命中了,但Implements()始终返回false
这个问题有两个高频原因。第一个是,你确实实现了接口,但实现的Actor本身被场景里的另一个碰撞体挡住了,导致LineTraceSingleByChannel返回的HitActor是挡在中间的那块Mesh。你会看到提示文字总是在显示某块墙的交互信息。解决办法是好好检查每个物体的Trace Response,该Ignore的通道就Ignore掉。
第二个原因更基础,就是程序运行时接口代码没生效。很多情况下是因为你还在使用没有经过重新编译的旧编辑器进程,DLL没有重新加载。建议做任何C++改动后,在编译完成后完全关闭编辑器再启动。千万不要在编辑器开着的时候只编译DLL然后热重载,虽然很多时候能跑起来,但接口这种反射相关的代码偶尔就是在这种场景下出现诡异失效。
这三个坑其实都不难修,关键在于你得有一套清晰的排查路径,而不是在蓝图和C++之间盲目翻找。从我的实际体验来看,把UE_LOG埋点做足,绝大多数交互系统的表现问题都是五分钟内能定位的。
系列走到这里,一个完整可用的"玩家准星指向交互物,UI显示提示,按键执行交互"闭环就全通了。以后你想加新交互物,只需要新建一个继承自交互基类的蓝图,覆写那两个接口函数,多数情况下连C++都不用再碰。这也是C++做框架和约束的意义,框架部分稳定不变,具体表现交给每张蓝图自由发挥,开发效率才是实打实的提升。
