第一次在编辑器里双击打开 BP_PlayerController,看到满屏五颜六色的连线,我的第一反应跟很多 C++ 出身的人一样:这玩意儿能干啥正经事?把它全部重写成 C++ 不就完了。
劝你别这么想。ActionRPG 这个项目,C++ 只负责定义"能力",真正把数据和逻辑装配起来的活,一大半在蓝图里。你要是看不懂蓝图,等于这个项目你只能读懂三分之一。这一篇就是带你跨过这道坎,目标不是让你会画蓝图,而是让你能读懂别人画的蓝图,并且能完成一次最基本的 C++ 和蓝图的来回协作。
先认门:编辑器五件套,各管一摊
在钻进那团连线之前,先花两分钟认认门。UE 编辑器窗口多到吓人,但你天天打交道的就这五个,搞清楚它们各自干嘛,你就不会迷路了。
内容浏览器(Content Browser) ,一般在底部。它就是 Content/ 目录的可视化文件管理器,所有 .uasset、.umap 都在这儿。找资产、新建资产、双击打开资产,都从这里开始。它对应的就是你硬盘上 Content\Blueprints\、Content\UI\ 这些文件夹,只不过显示的是资产的友好名字和缩略图,而不是文件名。
关卡编辑器(Level Editor) ,就是中间那个能看到 3D 场景的主窗口。它本身又拼着几块子面板:Viewport (视口)是你能拖动相机、摆放物体的 3D 预览;World Outliner (世界大纲,右上角,有的版本就叫 Outliner)列出当前关卡里所有的 Actor,是一棵树------在 Viewport 里点一个箱子,World Outliner 里对应那行会高亮,反过来也一样;左侧通常还停着一个 Place Actors(放置 Actor 面板),从这儿能把灯光、摄像机、基础几何体、触发器这类东西直接拖进场景。World Outliner 和 Place Actors 都是「搭关卡」用的------本课只读蓝图、走一次 C++ 往返,并不摆关卡,所以用不太上,你点开界面看到它们时知道是干嘛的就行。
细节面板(Details) ,通常在右侧。选中任何东西(关卡里的一个 Actor、蓝图里的一个变量、一个数据资产)它就显示这个东西所有可编辑的属性。你 C++ 里写的 UPROPERTY(EditAnywhere),最终就显示在这个面板上让人改。比如 ARPGCharacterBase 里的 CharacterLevel 标了 EditAnywhere,在细节面板里就是一个能填数字的格子。

蓝图编辑器(Blueprint Editor) ,双击任意 BP_ 资产打开的那个独立窗口。后面要讲的 Event Graph 就在这里,还包括 Viewport(看这个蓝图自己的组件长啥样)、My Blueprint 面板(变量和函数列表)、以及编译按钮。改完蓝图记得点左上角的 Compile,不然不生效。

数据资产编辑器 ,其实没有一个单独叫这名字的窗口。当你双击一个数据资产(比如 Content/Items/ 下的物品,那些继承自 URPGItem 的东西)时,打开的就是一个纯属性编辑界面,整个窗口基本就是一个大号的细节面板。因为数据资产没有逻辑,只有数据,所以它没有 Event Graph。这正好呼应了项目"数据驱动"的思路:物品的数值全填在这种资产里。

门认完了,其中最该花时间的是蓝图编辑器------因为这个项目一大半逻辑都在它的 Event Graph 里。下面就钻进那团连线。
蓝图到底是什么
一句话:蓝图就是可视化的、面向设计师的脚本。它编译之后跑在虚拟机上,本质和你写的 C++ 类是一回事,只是你用拖拽连线代替了打字。
打开任意一个 BP_ 资产,你会看到几个标签页,最关键的是 Event Graph (事件图表)。这里就是写逻辑的地方。图里那些方块叫节点(Node),一个节点可能是一次函数调用、一个事件触发、一次分支判断。节点和节点之间靠两种线连起来,分清这两种线,你就读懂了蓝图的一半。
白色的线是执行流(Execution)。它表示"先做这个,再做那个",就是你 C++ 里那种从上往下一行行执行的顺序。每个节点左上角有个朝右的白色三角箭头是入口,右上角的三角是出口。白线串起来的顺序,就是代码的执行顺序。如果一个节点没有白色引脚,说明它是纯计算节点(Pure),不参与执行流,谁用到它的结果它才算一次。
彩色的线是数据流(Data)。蓝色、绿色、红色、黄色各代表一种数据类型,比如布尔是红色、浮点是绿色、对象引用是蓝色、整数是青色。这些线把一个节点算出来的值,喂给另一个节点当输入参数。数据引脚是圆点,实心表示已连接。
这么对应你就清楚了:
- 节点 = 函数调用 / 语句
- 白线 = 语句执行顺序(control flow)
- 彩线 = 变量在表达式之间的传递(data flow)
- 事件 (Event)= 入口点,比如
Event BeginPlay、Event Tick,等价于回调函数被引擎调用 - 变量(Variable)= 成员变量,在左侧 My Blueprint 面板里,拖进图里就是读,按住 Alt 拖进去就是写
- 函数(Function)= 你自己封装的子图,等价于成员函数
举个本项目里的真实例子。BP_Character 这个蓝图继承自 C++ 的 ARPGCharacterBase。C++ 里有这么个函数:
cpp
UFUNCTION(BlueprintCallable)
virtual float GetHealth() const;
在蓝图的 Event Graph 里你就能拖出一个 Get Health 节点,它右侧有个绿色输出引脚(float),把这根绿线拉到比如一个判断节点的输入上,就能做"血量低于某值就播放受伤动画"这种逻辑。整个过程不写一行代码。这就是 C++ 定义能力、蓝图装配逻辑的典型样子。
读蓝图的诀窍:顺着白线走主流程,遇到不懂的节点再看它的彩线从哪来、到哪去。别一上来就盯着某根线死磕。

关卡:一个装 Actor 的容器
关卡(Level / Map)你可以直接理解成一个容器,里面装的是这个场景中所有的 Actor:地形、灯光、角色出生点、触发器、敌人刷新框,等等。本项目 Content/Maps/ 下有四个:ActionRPG_Main、ActionRPG_P、ActionRPG_Dungeon02_Asset、ActionRPG_Dungeon02_Lights。打开任意一个,Outliner 里列出来的就是这个关卡装的所有 Actor。
这里有个团队协作的大坑,C++ 程序员尤其要早点知道。
.umap 和 .uasset 全是二进制文件 。你没法用文本编辑器打开,没法 git diff 看改了啥,更没法 git merge 解决冲突。看看本仓库现在的 git 状态就明白了:
bash
M Content/Maps/ActionRPG_Dungeon02_Lights.umap
这一行 M(modified)你只知道这张地图被改过,但具体改了哪盏灯、移了哪个 Actor,git 一个字都告诉不了你。两个人同时改同一张地图,提交时必然冲突,而这种冲突 git 没法自动合并,结果就是必有一个人的改动整个丢掉。
所以真实团队里处理二进制资产,靠的不是工具而是约定:要么用文件锁(Perforce 那套 exclusive checkout,谁在改谁锁住,别人改不了),要么就是在群里喊一嗓子"这张图我在动,别人先别碰"。用纯 Git 的小团队基本只能靠后者。改动二进制资产之前先确认没别人在动,这是纪律问题,不是技术问题。
实操:从 C++ 加个函数,到蓝图里把它跑起来
光看不练记不住。下面走一遍完整的 C++ 和蓝图来回。这里说的"来回"其实有两个方向 :一个是蓝图调 C++ (BlueprintCallable),另一个是C++ 调蓝图 (BlueprintImplementableEvent)。先把前一个方向走通------在 ARPGCharacterBase 上加一个 BlueprintCallable 函数,编译后到 BP_Character 里调用它、运行验证------再回头补上反方向。
第一步,在 C++ 里加函数声明。 打开 Source/ActionRPG/Public/RPGCharacterBase.h,在那一堆 Get* 函数旁边加一个:
cpp
/** 测试用:返回当前血量百分比 0~1 */
UFUNCTION(BlueprintCallable, Category = "Test")
float GetHealthPercent() const;
BlueprintCallable 这个标记是关键,它告诉反射系统"这个函数要暴露给蓝图调用"。没有它,蓝图里根本搜不到这个节点。Category 决定它在蓝图右键菜单里归到哪个分组,纯粹为了好找。
第二步,在 cpp 里实现。 打开 Source/ActionRPG/Private/RPGCharacterBase.cpp,加上实现:
cpp
float ARPGCharacterBase::GetHealthPercent() const
{
const float Max = GetMaxHealth();
return Max > 0.f ? GetHealth() / Max : 0.f;
}
这里直接复用了项目已有的 GetHealth() 和 GetMaxHealth(),它俩本来就是 BlueprintCallable。
第三步,编译。 按 CLAUDE.md 里的命令编译编辑器目标,或者直接在编辑器工具栏点 Compile 按钮(C++ 的热重载)。编译成功后,新函数才会出现在蓝图里。
第四步,到蓝图里连线。 在内容浏览器里找到 Content/Blueprints/BP_Character,双击打开。进 Event Graph,在空白处右键,菜单里搜 GetHealthPercent,就能看到你刚加的节点,把它拖出来。给它接个执行流:从某个事件(比如临时拖一个 Event BeginPlay)拉白线进来;再拖一个 Print String 节点,把 GetHealthPercent 输出的绿线接到 Print String 的 In String 输入上(中间会自动插一个 float 转 string 的节点)。连完点 Compile、Save。
需要注意的是,默认配置是2秒,改的长一些(如下图所示12.0)才能看到日志

第五步,运行验证。 关掉蓝图窗口,回到关卡编辑器,点工具栏的 Play 。游戏一开始,屏幕左上角就会打印出当前血量百分比(1.0)。看到数字,这趟 C++ 到蓝图的往返就闭环了。

整个过程你体会一下分工:函数的逻辑在 C++ 里(你的主场),但什么时候调它、调完拿结果干嘛,是在蓝图里装配的。这就是 UE 正向开发的日常。
延伸:把阶段一那个 GetMagicNumber 也打印出来
还记得阶段一验收清单里加的那个 GetMagicNumber 吗?当时只让你「改一行 C++ → 编译过」,留了一句「运行看到效果,等阶段二三学会连线再说」。现在就来把这半程补上------它和上面 GetHealthPercent 的手顺一模一样,唯一的区别是函数名和返回值。
编译后回到 BP_Character 的 Event Graph,重复上面的第四步:右键搜节点,这次搜 GetMagicNumber(注意它归在 Debug 分组下,跟 GetHealthPercent 的 Test 分组不是一处),拖出来,输出的绿线接到 Print String,执行流从 Event BeginPlay 拉白线进来。Compile、Save,Play。
这回屏幕左上角打印的不是血量百分比,而是固定的 42------因为这个函数不读任何状态,就硬返回一个常量。看到这个 42,阶段一那条「改一行 C++ → 编译 → 运行看到效果」的闭环才算真正合上。
顺带体会一个对比:GetHealthPercent 的返回值会随血量变化(它读了 GetHealth()),而 GetMagicNumber 永远是 42。同样是 BlueprintCallable,一个是「把 C++ 算出来的动态结果递给蓝图」,一个是「给蓝图一个写死的常量」。蓝图侧的连线方式完全不分这两者------它只管拿到一个 float 往下用,至于这个值是算出来的还是写死的,是 C++ 那头的事。这正是「C++ 定义能力、蓝图装配逻辑」分工的一个小注脚。

反方向:让 C++ 来调用蓝图
上面两个例子都是蓝图调 C++ :函数实现写在 C++,蓝图把它当节点拖出来调。但 ActionRPG 里更常见的其实是反过来的方向------C++ 声明一个事件,蓝图去实现它,再由 C++ 在合适的时机触发 。这座桥就是 BlueprintImplementableEvent。
不用自己造例子,项目里现成的就有一堆。打开 Source/ActionRPG/Public/RPGCharacterBase.h,能看到这么几个:
cpp
UFUNCTION(BlueprintImplementableEvent)
void OnHealthChanged(float DeltaValue, const struct FGameplayTagContainer& EventTags);
UFUNCTION(BlueprintImplementableEvent)
void OnDamaged(float DamageAmount, const FHitResult& HitInfo, const struct FGameplayTagContainer& DamageTags, ARPGCharacterBase* InstigatorCharacter, AActor* DamageCauser);
注意一个反常的地方:这俩函数在 .cpp 里根本没有实现体 。你去 RPGCharacterBase.cpp 翻,找不到 ARPGCharacterBase::OnHealthChanged 的函数体。这不是漏写------BlueprintImplementableEvent 的意思就是"C++ 只管声明,函数体由蓝图来画",代码生成器会替你生成一个跳进蓝图的桩子。
那 C++ 怎么"调用"一个实现在蓝图里的函数?看 RPGCharacterBase.cpp 里这段:
cpp
void ARPGCharacterBase::HandleHealthChanged(float DeltaValue, const struct FGameplayTagContainer& EventTags)
{
// 技能初始化阶段的血量变动不通知蓝图,避免误触
if (bAbilitiesInitialized)
{
OnHealthChanged(DeltaValue, EventTags); // 执行流从这里瞬间跳进蓝图
}
}
血量一发生变化,C++ 这边走到 HandleHealthChanged,它调一下 OnHealthChanged(...),执行流就瞬间跳进 BP_Character 里你为这个事件连的那串节点------比如弹个飘字、刷新血条、播放受伤特效。OnDamaged 同理,由 HandleDamage 触发。
蓝图侧怎么实现它?打开 BP_Character 的 Event Graph,因为 C++ 已经声明了这些事件,左侧 My Blueprint 面板就能找到 On Health Changed,把它拖进图里,它就是一个红色的事件节点 (带白色执行输出),从它的白线往后接你想做的事就行。这和你拖一个 Event BeginPlay 出来本质一样,只不过这个事件的触发者是 C++,不是引擎。
把两座桥摆在一起,往返就完整了:
| 方向 | 修饰符 | 谁写实现 | 谁来调用 | 蓝图里长什么样 |
|---|---|---|---|---|
| 蓝图调 C++ | BlueprintCallable |
C++ | 蓝图 | 一个可以拖出来调用的函数节点 (如 Get Health Percent) |
| C++ 调蓝图 | BlueprintImplementableEvent |
蓝图 | C++ | 一个由 C++ 触发的事件节点 (如 On Health Changed) |
这座
BlueprintImplementableEvent桥在阶段二讲BP_GameMode的K2_OnGameOver时已经露过脸,这里换成角色身上的真实例子再走一遍,是为了让本节的"往返"名副其实------一来(蓝图调 C++)、一回(C++ 调蓝图),才是完整的协作闭环。
给 C++ 程序员的几句真心话
最容易犯的错,是看蓝图不顺眼就想全搬回 C++。我理解这种冲动,蓝图改大了确实难维护,连线乱成一锅意大利面。但你得分清场景。
逻辑骨架、性能敏感的计算、复杂的数据结构,这些放 C++ 没问题,本项目也是这么做的。可那些设计师要反复调的东西,比如某个技能的伤害数值、某个怪的血量、UI 上一个按钮的位置和触发的事件,全塞进 C++ 意味着改一个数字就得重新编译一次,设计师还得来找你。这活儿留在蓝图和数据资产里,人家自己就改了。
UE 正向开发的常态就是混合开发。把蓝图当成"可视化的、面向设计师的那一层 C++",而不是低级玩具。你读得懂蓝图,才能读懂这个项目超过一半的逻辑。真到了蓝图复杂得维护不动那天,再把其中稳定的部分下沉到 C++ 也不迟,这是优化,不是起手式。

验收清单
照着下面这几条自检,全过了这一阶段就算扎实了:
- 随便打开一个
BP_资产(比如BP_PlayerCharacter),能分清白线(执行流)和彩线(数据流),能顺着白线讲出主流程大概在干嘛。 - 说得出编辑器五件套各自是干啥的:内容浏览器、关卡编辑器(Viewport + Outliner)、细节面板、蓝图编辑器、数据资产编辑器。
- 理解关卡是 Actor 的容器,并且清楚
.umap/.uasset是二进制、不能 diff/merge,知道团队里靠文件锁或口头约定避免冲突。 - 独立完成一次"C++ 加
BlueprintCallable函数 → 编译 → 蓝图里连进 Event Graph → Play 验证"的完整往返。 - 分得清两座桥的方向:
BlueprintCallable是蓝图调 C++(实现在 C++),BlueprintImplementableEvent是 C++ 调蓝图(实现在蓝图的事件节点里,由 C++ 触发);能在RPGCharacterBase里指认出OnHealthChanged、OnDamaged这类反方向的例子。 - 想清楚了一件事:哪些逻辑该留蓝图、哪些该进 C++,并且不再有"把蓝图全重写成 C++"的冲动。