把一个 Actor 蓝图拆成 components 的思维练习
道理都懂,真拆的时候还是会卡住:这一坨到底算一张卡还是三张卡?这一页拿一个人人都写过的敌人蓝图开刀,四步拆完,顺便说清三条原则,以及「拆过头」和「拆不够」各自长什么样。这是纸上练习——目的是先把手感练出来,代码是第 27 课的事。
一、今天的病人:BP_PatrolEnemy
规格很标准,你八成写过一个几乎一样的:
- 有模型和待机动画,看得见;
- 沿一串巡逻点来回走;
- 玩家进入 800 单位半径就转为追击;
- 有 120 点血,被打会掉血,掉到 0 就死;
- 死的时候播一个特效、放一声惨叫;
- 死后掉 1 把武器 + 3 个金币;
- 头顶有个血条;
- 掉光血 5 秒后重生。
在 Actor 时代,这些全在一个蓝图里,大概 400 个节点、14 个变量,还继承自某个 BP_CharacterBase。现在把它拆开。
二、四步拆解
第一步:把蓝图翻译成一串动词。不要想「组件」,只问「它在做哪些事」。一句一件,能拆多细拆多细——这一步宁滥勿缺:
显示模型 / 播放动画 / 沿路径移动 / 检测玩家距离 / 切换巡逻与追击 / 存血量 / 扣血 / 判定死亡 / 播死亡特效 / 播死亡音效 / 生成掉落物 / 显示血条 / 计时重生。
十三个动词。注意其中没有一个叫「敌人」——「敌人」是名词,名词属于 entity 和 prefab,不属于 component。
第二步:按「谁会跟谁一起变」归堆。这是整个练习里唯一需要动脑的一步。判据不是「功能像不像」,而是改需求的时候,这几件事会不会一起改。
- 「存血量 / 扣血 / 判定死亡」永远一起改 → 一张卡(生命值)。
- 「沿路径移动 / 检测距离 / 切换巡逻与追击」是同一套 AI 逻辑 → 一张卡(巡逻 AI)。但注意「检测玩家进入半径」这件事,宝箱、陷阱、自动门都要用,它会单独被复用 → 这一件拎出来单独一张卡(范围检测)。
- 「播死亡特效 / 播死亡音效」——特效和音效是两种资产,由两类 component 提供(粒子系统、声音),它们本来就分开;真正要写的是「死的时候触发它们」,而这件事属于生命值那张卡发出的一个事件。
- 「生成掉落物」和血量没关系:宝箱也掉东西,任务奖励也掉东西 → 单独一张卡(掉落表)。
- 「显示血条」是表现,不是规则。血条读生命值,但生命值不该知道血条存在 → 单独一张卡(血条 UI)。
- 「计时重生」是关卡节奏,和「这个东西是敌人」无关,平台、宝箱都可能要 → 单独一张卡(重生)。
第三步:决定谁在本体、谁下沉到子 entity。规则只有两条,课内都讲过:需要单独运动的部件给自己一个 entity;同一 entity 上同类型 component 只能有一个。所以:武器要能单独挥动 → 子 entity;血条要浮在头顶、跟着但不跟着转 → 子 entity;死亡特效和惨叫都是「一个粒子 + 一个声音」,可以留在本体,但如果你还要一个受击特效,那就是两个粒子系统——同类型只能一个,得再开一个子 entity。
第四步:落成清单。拆完长这样:
P_PatrolEnemy entity(prefab 根)
· 网格 component 身体模型
· 粒子系统 component 死亡特效
· 声音 component 死亡惨叫
· health_component ← 自定义:血量、扣血、死亡事件
· patrol_ai_component ← 自定义:巡逻点、追击、移动
· proximity_component ← 自定义:进入/离开半径 → 发事件
· loot_table_component ← 自定义:死亡事件 → 生成掉落
· respawn_component ← 自定义:死亡事件 → 计时 → 复位
│
├─ HealthBar entity(子实体:头顶血条)
│ · 血条 UI component 订阅 health_component 的变更
│
└─ WeaponSocket entity(子实体:手持武器)
· 网格 component 武器模型
五张自定义卡,加上四五个由资产直接提供的现成 component。对照一下原来那个 400 节点的蓝图:同样的功能,现在每一块的边界都写在面板上。而且——health_component、loot_table_component、respawn_component 这三张卡,装到宝箱上一样能用。这就是拆的全部回报。
三、拆的三条原则
原则一:单一职责——一张卡只有一个「改它的理由」。判断法很土但很准:念一遍「我什么时候会改这张卡?」如果答案里有「或者」,就该拆了。「我在调数值平衡的时候会改它,或者在美术换特效的时候会改它」——两个不相干的人为了两件不相干的事改同一张卡,那就是两张卡。
原则二:可复用——问「它能不能装到宝箱上?」这是本页最好用的一句话。把每张卡拿起来,想象把它装到一个完全无关的物件上(宝箱、平台、一扇门)。health_component 装宝箱上?可以,宝箱能被打碎。proximity_component 装门上?可以,自动门。patrol_ai_component 装宝箱上?不太行——但那没关系,不是每张卡都必须通用,这个问题的作用是帮你发现「其实可以通用、只是被你写死在敌人身上」的那部分。
原则三:数据与行为分离——谁存状态,谁读状态。血条那张卡不存血量,它读 health_component 的血量;掉落卡不存「我死了没」,它订阅死亡事件。让状态只有一个主人,其它卡一律做订阅者。这条做到位,你会发现依赖关系自然变成了一棵单向的树,而不是一张互相拽的网。
顺带说,官方最佳实践里有一条正好落在这里:在开始模拟时把需要的子组件找一次、存下引用或订阅它们的事件,之后靠事件反应,不要在游戏运行中反复到层级里搜。这既是性能建议,也是架构建议——它逼你在启动时就把关系网接好,而不是运行时到处找人。
四、拆过头与拆不够
两边都是坑,而且长得很不一样。
| 症状 | 后果 | 解法 | |
|---|---|---|---|
| 拆不够 | 出现一张叫 enemy_component 的卡,里面 300 行,什么都干 |
你只是把上帝类换了个文件名。组合的好处一个都没拿到,还多了一层间接 | 念「我什么时候会改它」,凡是答案里有「或者」就切一刀 |
| 拆过头 | 血量、最大血量、扣血、死亡判定被拆成四张卡;或者出现只有 3 行的 set_visible_component |
40 张卡互相订阅,谁触发谁全靠猜。调一个 bug 要在五个文件之间跳 | 把「永远一起改」的几张卡合回去 |
拆过头有个很好识别的信号:两张卡之间开始出现「必须按顺序初始化」的隐式约定。如果 A 卡必须在 B 卡之后才能工作,而这件事没有写在任何地方——它们大概本来就该是一张卡。
拆不够也有个信号:你在一张卡的 @editable 属性里看到了两组毫不相干的参数。「巡逻速度、巡逻点数量」和「掉落金币数、掉落概率」出现在同一张卡的面板上,说明这张卡在服务两个不同的人。
最后给一个不太浪漫但很有效的收尾:先拆少,不够再拆。把一张卡切成两张,是十分钟的工作;把散在五张卡里的逻辑合回去,通常要半天,还容易漏。第一版按「三到六张自定义卡」的规模起步,等到你第二次为了同一个需求同时改两张卡时,再决定合并;等到你第二次想把一张卡的一半功能装到别的东西上时,再决定切分。
五、随手自查清单
- 卡的名字里有名词(门、敌人、宝箱)吗?→ 多半拆错了,能力卡的名字该是动词或形容词。
- 念一遍「我什么时候会改它」,答案里有「或者」吗?→ 该拆。
- 这张卡能装到宝箱上吗?→ 不能也没关系,但想一下有没有能通用却被写死的部分。
- 同一个状态,有几张卡在写它?→ 超过一张就该收拢到一个主人,其余改成订阅。
- 面板上出现了两组不相干的参数吗?→ 该拆。
- 有两张卡必须按特定顺序初始化,而这事没写在任何地方吗?→ 该合。
- 有需要单独运动的部件吗?→ 给它自己的子 entity。
- 需要两个同类型 component 吗?→ 不行,拆子 entity。
六、小测验
拆敌人时,你写了一张 health_component,里面同时管:血量数值、扣血、死亡判定、播放死亡特效、生成掉落物。按本页的原则,该怎么改?
来源
本页引用的框架规则(entity 与 component 的关系、同类型 component 数量限制、开始模拟时预存引用的最佳实践)来自 Epic 官方文档:Getting Started in Scene Graph in Fortnite(官方文档)↗ · Scene Graph Best Practices in Fortnite(官方文档)↗ · Components in Unreal Editor for Fortnite(官方文档)↗
清单里的 health_component、patrol_ai_component 等都是本页为练习虚构的自定义组件名,不是官方 API;真实的内置组件类型与命名以官方文档为准。拆分原则属于通用软件设计经验,非 Epic 官方表述。