去年接了一个 3D 塔防的活儿,第一版我图省事,把开炮逻辑、索敌、光环、升级全塞进一个ATowerBase里,靠一堆if (TowerType == ...)分支撑着。两周后需求改成"塔旁边站着己方士兵时给塔加成攻速,反过来塔的光环也影响士兵",我打开那个文件愣了半天——塔需要知道兵、兵需要知道塔、两边还都得知道彼此的加成从哪来,直接绕成了死循环。这篇就聊 UE4 3D塔防的 C++ 架构里两件最容易被低估的事:士兵-塔协同该怎么组织,以及数据驱动设计到底该驱动什么。内容偏向工程落地,适合已经能用 C++ 写 Actor、正在纠结架构怎么分层的同学;纯蓝图党也能看,思路是通用的。前面会讲为什么这么拆,后面给可直接抄的结构体、注册表和时序方案,踩过的坑一并摆出来。
1. 塔和兵塞进同一个继承树,是这类项目第一个要绕开的坑
1.1 从"一个基类打天下"到职责分离的临界点
塔防项目开局都很像:一个AUnitBase,派生ATower和ASoldier,看起来很干净。问题在于两者只是长得像,生命周期完全不同。塔是静态的、常驻的、有建造和升级阶段;士兵是动态的、成批生成的、会死会走。它们的共同点只有"有血量、能吃伤害、有攻击力"这几条数据,而这些恰好可以抽成组件或者结构体,没必要捆成继承关系。
我用一个很朴素的判断标准来划线:两个类的差异如果体现在"状态机分支"上,就该拆开;体现在"数据数值"上,才该共用基类。塔的状态是"待建 → 建造中 → 运转 → 升级 → 拆毁",兵的状态是"待命 → 移动 → 交战 → 死亡",分支完全不同,强行合并的结果就是基类里一半成员对子类永远为空。后来我把项目拆成三条线:AActor只负责表现和位置;战斗数据走UCombatComponent;行为逻辑走独立的 Ability/Task 对象。拆完之后那个 2000 行的ATowerBase缩到了 300 行左右,剩下的全变成配置。
这个改动最直接的收益不是代码好看,而是策划能自己调平衡了。原本改一个塔的攻速要程序员改默认值重编译,现在改一个资产保存一下,编辑器里立刻生效。
1.2 三种对象的生命周期差异决定了内存策略
塔、兵、投射物在这个项目里的数量级完全不同:塔通常 20~60 个,兵峰值可能 300+,投射物一秒钟能冒出上百个。把这三类东西用同一套生成销毁逻辑处理,一定会出问题。
塔适合预生成 + 激活。地图加载时按关卡配置把塔位对应的 Actor 全部 Spawn 出来,设为隐藏和不可用,玩家花钱建造只是切换状态和加载网格。这样做的好处是塔位坐标固定、寻路数据稳定、UI 绑定简单,省掉了运行期 Spawn 带来的卡顿。
兵适合对象池。这个我踩过实打实的坑:早期每次出兵都SpawnActor,怪一多帧率就从 90 掉到 40,Profiler 一看SpawnActor里的构造和组件注册占了大头。改成池之后,峰值帧耗时直接降了六成。池的实现不难,预生成 N 个、SetActorHiddenInGame(true)+SetActorTickEnabled(false)回收,取用时重置状态。关键是重置要彻底:血量、Buff、动画状态、绑定的事件、定时器句柄,漏一个就会出现"新兵一出生就带着上一只的减速 Buff"这种玄学 Bug。
投射物我最后干脆不用 Actor 了。塔防的弹道大多简单,用UNiagaraComponent或者自己写一个轻量的UProjectileManager每帧算位置,视觉上完全够用,省下来的开销很可观。这个取舍看你项目的美术需求,如果弹道要做碰撞反弹、要做物理,那还是老老实实 Actor。
1.3 数据驱动到底"驱动"了哪几层
很多人说数据驱动,实际做的只是"把数值抽到表里"。真正的数据驱动应该分三层,每层的载体都不一样:
| 层次 | 内容 | 载体 | 谁改 |
|---|---|---|---|
| 数值层 | 血量、攻击、射程、攻速、造价 | DataTable 行 | 数值策划 |
| 行为层 | 这个塔是单体还是范围、有没有连锁、攻击几次 | 能力配置资产 | 系统策划 |
| 表现层 | 网格、材质、特效、音效、动画 | 软引用字段 | 美术 |
三层分离的意义在于改动频率不同。数值天天调,行为偶尔加,表现一次做完基本不动。如果混在一张表里,每次调数值都会让整张表重新导入,行为字段的引用一断,资产里一堆空指针。分开之后,数值表可以做成 Excel 一键导出的 CSV,行为配置用UPrimaryDataAsset一个个手搓,表现资源走软引用按需加载,互不干扰。
我在第二个项目里还加了一层"规则层"——比如"这个塔只能建在水边""这个兵只能部署在塔的射程内",这些校验写成独立的UTowerPlacementRule资产,在建造流程里跑一遍。策划想加限制条件就新建一个资产,不用碰 C++。这一层是纯经验产物,第一个项目被"特殊地形限制"这个需求折磨过之后才加上去的。
2. 配置资产怎么组织:DataTable 和 DataAsset 别乱用
2.1 两者的分工边界,以及我为什么最终两个都用
刚上手的时候我特别迷信 DataTable,觉得"Excel 导表才是数据驱动"。结果做到第一个塔的升级链就卡住了:三级升级,每级有独立的网格、独立的能力、独立的光环参数,这些东西用行结构表达就是Mesh1/Mesh2/Mesh3、Ability1/Ability2/Ability3这种反人类的字段。后来拆成UPrimaryDataAsset,每个塔一个资产,里面直接挂一个数组,清爽很多。
我现在的分工是这样的:DataTable 只放纯数值,而且是不带任何资源引用的纯数值。它的优势是批量、可排序、可以用 Excel 和写脚本批量生成;劣势是没法定向引用其他资产、不支持继承、合并冲突难解。凡是需要"引用别的资产""有层级关系""需要继承一份再改几个字段"的,一律用UPrimaryDataAsset。
数据资产还有一个 DataTable 比不了的好处:UPrimaryDataAsset支持GetPrimaryAssetId(),能纳入 AssetManager 的异步加载体系。塔防经常要做"关卡里会出现哪些塔"的加载规划,靠这个可以做到进战斗前把整关需要的资产预热完,避免打一半卡一下。
2.2 塔的配置结构体字段设计
先看数值行,存放在 DataTable 里:
USTRUCT(BlueprintType) struct FTowerStatRow : public FTableRowBase { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadOnly) float BaseDamage = 10.f; UPROPERTY(EditAnywhere, BlueprintReadOnly) float AttackInterval = 1.0f; UPROPERTY(EditAnywhere, BlueprintReadOnly) float Range = 600.f; // 升级带来的线性成长系数,和等级一起算,避免每级一行 UPROPERTY(EditAnywhere, BlueprintReadOnly) float DamagePerLevel = 2.5f; UPROPERTY(EditAnywhere, BlueprintReadOnly) int32 MaxLevel = 3; };这里有个细节值得说:DamagePerLevel这种线性成长字段,比"每级一行"省事得多,策划改起来也直观。但一旦某级出现非线性跃迁(比如三级解锁特殊效果),线性字段就不够用了,这时候要换成TArray<float>按等级索引,并且.越界的兜底一定要写——我见过策划配了 4 级的表但MaxLevel忘改,运行期数组越界直接崩,日志里什么都看不出来。
行为配置则是资产:
UCLASS(BlueprintType) class UTowerDefinition : public UPrimaryDataAsset { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly, Category = "Identity") FName TowerId; UPROPERTY(EditDefaultsOnly, Category = "Stat") FDataTableRowHandle StatRow; UPROPERTY(EditDefaultsOnly, Category = "Visual") TSoftObjectPtr<UStaticMesh> BaseMesh; UPROPERTY(EditDefaultsOnly, Category = "Visual") TSoftObjectPtr<UTexture2D> Icon; // 攻击行为:单体/范围/连锁,各自一个 UObject 子类 UPROPERTY(EditDefaultsOnly, Instanced, Category = "Ability") TObjectPtr<UTowerAbility> PrimaryAbility; // 光环:给范围内士兵加成的配置 UPROPERTY(EditDefaultsOnly, Category = "Aura") TArray<FAuraConfig> AuraConfigs; };Instanced这个标记很关键。不加它,你在编辑器里给PrimaryAbility赋值时只能在资产浏览器里挑一个已存在的对象,没法直接在塔定义里内联创建配置。加了Instanced(配合EditInlineNew),策划点一下就能在塔资产里现场搓一个能力对象出来,OO 的手感一下就出来了。这两行属性是我认为这个架构里性价比最高的改动之一。
2.3 软引用没管好,加载卡顿会教你做人
TSoftObjectPtr用得爽,但第一次LoadSynchronous()的代价很多人没概念。塔防的关卡经常要一次出十几座不同的塔,每座塔有网格、材质、特效、图标,资源数量不小。我的做法是在UTowerDefinition里提供一个PreloadAsync()的接口,关卡初始化阶段用RequestAsyncLoad批量提交,回调里把句柄存下来。
void UTowerDefinition::PreloadAsync(FStreamableDelegate OnDone) { TArray<FSoftObjectPath> Paths; Paths.Add(BaseMesh.ToSoftObjectPath()); Paths.Add(Icon.ToSoftObjectPath()); for (const FAuraConfig& Cfg : AuraConfigs) { if (!Cfg.AuraVFX.IsNull()) { Paths.Add(Cfg.AuraVFX.ToSoftObjectPath()); } } if (Paths.Num() == 0) { OnDone.ExecuteIfBound(); return; } CachedHandle = UAssetManager::GetStreamableManager().RequestAsyncLoad(Paths, OnDone); }有个坑必须提醒:导出句柄一定要存成TSharedPtr<FStreamableHandle>成员,如果只是局部变量,函数返回后句柄析构,加载会被取消,然后你在LoadSynchronous的时候又卡一次,而且没法复现。我第一次遇到的时候查了半天,以为资源丢了。
另外TSoftObjectPtr在构造里别急着Get(),那个阶段资产还没准备好,拿到 nullptr 的概率很高,而且日志里只会说"空指针",不会告诉你是时序问题。
3. 士兵和塔的协同:别让他们互相持有指针
3.1 双向引用为什么会失控
最直觉的做法是塔持有一个TArray<ASoldierUnit*>记录范围内的兵,兵持有TArray<ATowerBase*>记录自己的增益来源。这个结构能跑,但调试起来会疯:兵死了塔没来得及清理,塔拆了兵还挂着一个已经开洞的增益,查到最后往往是"某个路径没走 Unregister"。更麻烦的是增益重复计算——两个塔给同一个兵加攻击,一个加 20%,一个加 15%,如果塔各自直接改兵的AttackDamage,先乘后乘的顺序会导致结果不同,数值策划那边会收到"加成不准"的反馈。
我的方案是把关系反转:塔和兵都不认识对方,统一往一个中立的地方报到。塔不具备"知道范围内有谁"的能力,它只在每次结算时向注册表请求"给这个圆里所有友方加 Buff";兵同样不在自己身上存增益来源数组,只存一串带着来源标识的 Modifier。
3.2 目标注册表:用网格分区把索敌压到可接受
注册表我用UWorldSubsystem实现,生命周期跟着世界走,不用手动管理。核心结构是网格哈希:
UCLASS() class UTacticalSubsystem : public UWorldSubsystem { GENERATED_BODY() public: void RegisterUnit(ASoldierUnit* Unit); void UnregisterUnit(ASoldierUnit* Unit); void QueryUnitsInRadius(const FVector& Center, float Radius, EUnitFaction Faction, TArray<ASoldierUnit*>& Out) const; void ForEachUnitInRadius(const FVector& Center, float Radius, EUnitFaction Faction, TFunctionRef<void(ASoldierUnit*)> Fn) const; private: FIntPoint CellOf(const FVector& Location) const; static constexpr float CellSize = 400.f; TMap<FIntPoint, TArray<TWeakObjectPtr<ASoldierUnit>>> Cells; };CellSize的选择有讲究。它应该接近最小攻击射程的一半到一倍之间:太小,一次查询要遍历的格子多,开销变成 map 查找;太大,每格里的人数多,过滤成本上去。我们的射程区间是 400~1200,取 400 比较均衡。如果你的塔全是远程大炮,这个值可以放大到 800。
TWeakObjectPtr是刻意的。兵被池回收后对象还在,但逻辑上已经"死"了,用弱引用配合IsValid()判断,可以容忍偶尔漏掉的 Unregister,不会崩。代价是每次查询要多一层校验,这部分开销在实测里可以忽略。
查询接口我用TFunctionRef而不是填TArray&,原因是不产生临时数组分配。塔的索敌是高频操作,300 个怪、20 座塔、每秒 2 次索敌,一次TArray分配看着不多,累积起来 GC 压力不小。
3.3 一帧内的执行顺序,以及"打空"是怎么来的
塔防里有一类特别隐蔽的 Bug:塔明明开炮了,特效也出了,但怪没掉血。九成是时序问题——塔在Tick里锁定了目标,同一帧里目标被别人的伤害打死了,伤害结算时Target已经进了池,一次攻击白给。
我的处理方式是把战斗相关的 Tick 统一到一个"战斗组"里,用FTickFunction的TickGroup加上自定义顺序来保证:注册表更新 → 塔索敌锁定 → 士兵行为 → 伤害结算 → 死亡处理。这样做还有个好处是可以整体开关——暂停时把战斗组停掉,UI 动画照常跑。
void ATowerUnit::Tick(float DeltaTime) { Super::Tick(DeltaTime); AttackCooldown -= DeltaTime; if (AttackCooldown > 0.f) return; // 每帧都重新校验,别缓存超过一帧的目标 if (!CachedTarget.IsValid() || !IsTargetAttackable(CachedTarget.Get())) { CachedTarget = AcquireTarget(); } if (!CachedTarget.IsValid()) return; AttackCooldown = CurrentStat.AttackInterval; ExecuteAttack(CachedTarget.Get()); }关键在于AcquireTarget之后到真正结算之间,不能再假设目标还活着。所以伤害入口要写幂等校验:
void UCombatComponent::ApplyDamage(AActor* Instigator, const FDamagePacket& Packet) { if (bDead || !IsValid(Instigator)) return; if (CurrentHP <= 0.f) return; // 双重保险 CurrentHP -= Packet.Amount; if (CurrentHP <= 0.f) { bDead = true; OnDeath.Broadcast(); } }bDead的检查放在最前面而不是减血之后,是为了防止同一帧里两个来源同时把它打死、死亡事件广播两次。我见过一次广播两次导致掉落翻倍、玩家白拿资源的事故,查了两天才定位到。
3.4 增益叠加:用 Modifier 而不是直接改数值
塔给兵加攻击,千万别写Soldier->AttackDamage *= 1.2f。正确做法是每个兵维护一个 Modifier 列表:
USTRUCT(BlueprintType) struct FStatModifier { GENERATED_BODY() UPROPERTY() FName SourceId; UPROPERTY() EStatType Stat; UPROPERTY() float Additive = 0.f; UPROPERTY() float Multiplicative = 1.f; UPROPERTY() float ExpireTime = -1.f; };结算顺序固定为(Base + Sum(Additive)) * Product(Multiplicative),先加后乘。这个顺序不能改,一旦定下来所有配置都按这个算,改了所有数值都要重配。护甲、暴击、攻速都用同一套,策划只需要关心填到哪个字段。
SourceId是必填项。塔的光环每帧或者每 0.5 秒刷新一次 Modifier,刷新时的做法是"先按 SourceId 移除旧的,再插新的",天然做到幂等。没有 SourceId,两座同类塔的光环会重复叠加,或者塔被拆了增益还在。这个字段在我这里是"不加就不许提交"的级别。
过期用ExpireTime而不是依赖外部定时器,结算时统一扫一遍过期的,简单且不会漏。扫描频率和索敌同步,0.2~0.5 秒一次,对数值精度要求没那么高。
4. C++ 和蓝图的边界划在哪,直接决定后期维护成本
4.1 三个原则:状态在 C++、表现给蓝图、一次性逻辑用事件
我在这个项目里定的规矩很简单:属性、状态机、数值结算、注册注销这些"错了就崩"的逻辑放 C++;特效、动画、音效、UI 刷新这些"错了只是不好看"的放蓝图。中间用BlueprintImplementableEvent和BlueprintNativeEvent连接。
两者的区别要拎清:BlueprintImplementableEvent是纯 C++ 声明、蓝图实现,C++ 里不能有默认行为,调用时如果蓝图上没实现就是空跑;BlueprintNativeEvent是 C++ 给实现、蓝图可以覆盖,适合"默认行为已经有了,个别角色要特化"的场景。塔的开火特效我用前者,塔的开火结算(扣冷却、算伤害、记日志)用后者,这样某些特殊塔可以在蓝图里加额外逻辑,但不影响主流程。
有个细节:函数签名里别传AActor*,传具体类型,蓝图端的类型检查会友好很多。也别在BlueprintImplementableEvent里传引用参数,蓝图不支持,会让你改成指针然后到处判空。
4.2 UPROPERTY 暴露那几个参数,踩过的坑比想象中多
暴露属性是小事,但错一个就够你查半天:
| 写法 | 实际效果 | 什么时候用 |
|---|---|---|
EditAnywhere | 实例和类默认值都能改 | 每个实例需要不同的值 |
EditDefaultsOnly | 只能改类默认值/资产 | 大部分配置项 |
VisibleAnywhere | 只读显示 | 调试用的运行时状态 |
BlueprintReadOnly | 蓝图可读不可写 | 状态查询 |
Transient | 不序列化 | 缓存、运行时句柄 |
最常见的错误是给运行时缓存字段加了EditAnywhere却没加Transient,结果在编辑器里改了一次,存进资产,下次加载时这个"缓存"带着上一次的旧值跑起来,表现就是"改了配置没生效"。这类问题特别难查,因为代码看起来完全正确。
还有一个:TArray<TSubclassOf<ASoldierUnit>>这种类型在蓝图里可以正常填,但如果你写的是TArray<UClass*>,Compile能过,蓝图端填的值在打包后可能丢失。泛化类型在编辑器里可以随便指,打包时会被裁掉,这个坑我记得论坛上讨论过不少次。
4.3 走一遍建塔流程,看边界怎么切
从玩家点空地到塔开始开炮,完整链路是这样切的:
- 玩家点击地面 → 蓝图中的玩家控制器发出
ServerTryBuildTower(TowerDef, Location)的 RPC; - C++ 里跑校验:
UTowerPlacementRule数组逐个检查,地形、间距、资源够不够; - 校验通过 → 从对象池取一个塔 Actor,
InitFromDefinition(TowerDef),设置位置和朝向; - C++ 广播
OnTowerBuilt,蓝图监听这个事件播放建造动画和音效; - 建造进度走一个 C++ 定时器,期间塔不可攻击;
- 完成 → 状态切到运转,注册进
UTacticalSubsystem,把光环 Modifier 推给范围内的兵。
第 3 步的InitFromDefinition是核心,它要做的事包括:加载软引用资源(此时应该已经预加载过,直接Get()就够)、从 DataTable 拉数值、实例化PrimaryAbility并设置好 Outer、初始化 Modifier 列表。Outer 一定要设成塔本身,否则能力对象没有归属,GC 会在下一次回收时把它清掉,表现就是"塔打了两分钟突然不开炮了"。这个坑我吃了两次,第二次才记住。
第 4 步为什么要用事件而不是让蓝图轮询?因为建造是偶发的低频事件,蓝图里挂一堆Tick去查状态既浪费又容易漏。事件驱动的写法在蓝图端就三四个节点,策划自己都能改。
5. 同屏几百个单位时的性能账,以及几个必踩的坑
5.1 Tick 是最大的开销来源,能砍就砍
300 个兵每个都 Tick,一帧就是 300 次Tick调用,每次里面还可能有索敌、移动、动画状态检查。实测下来这部分能占到总帧耗时的 40%。我的优化顺序是:
第一,按功能拆频率。移动需要每帧,索敌 0.2 秒一次够了,光环刷新 0.5 秒一次够了,动画状态 0.1 秒一次够用。全部塞Tick里是浪费,拆成不同频率的定时器立刻省一截。
第二,用管理器统一驱动。UTacticalSubsystem里维护几个数组,每帧只 Tick 子系统一次,内部循环调各单位的UpdateMovement。这样避免了几百个 Actor 各自调TickFunction的开销,而且顺序可控。
第三,远处降频。屏幕外的单位索敌和动画频率直接砍半,反正玩家看不见。这一条在塔防里收益巨大,因为塔防的战场通常比屏幕大。
5.2 对象池的重置逻辑,漏一个就出玄学
写过一个池的都知道,难点不在取,在还。我列的检查清单长这样:
- 血量、护甲、Buff 列表清空;
- 所有
FTimerHandle用ClearAllTimersForObject清掉; - 绑定的委托全部解绑(尤其是往子系统里注册的);
- 动画蒙太奇停止,动画实例状态复位;
- 碰撞从
NoCollision恢复成默认配置; SetActorHiddenInGame(true)、SetActorTickEnabled(false)、SetActorLocation挪到视野外。
有一项漏了都会留下隐患。我最惨的一次是委托没解绑,池里复用的兵同时挂了三份OnDeath回调,死一个掉三份资源,玩家资源曲线直接爆炸。从那以后池的回收函数我都是照着清单写,写完再对一遍。
5.3 悬空指针和 GC:弱引用不是万能药
TWeakObjectPtr能防悬空,但IsValid()通过不代表对象可用——它可能已经被标记为待销毁(IsPendingKill),这时候调方法还能跑,但状态是残的。对池化对象尤其要注意,对象没被 GC 但逻辑上已经死了,必须在业务层再加一层bDead之类的标志判断。
另一种情况是UPROPERTY忘了加。指向 UObject 的裸指针成员没标UPROPERTY(),GC 完全看不到这个引用,某个时刻就把对象收了,然后你的指针变成野的。这个错误编译期毫无提示,运行期崩溃还随机。我的习惯是所有UObject*成员一律先写UPROPERTY(),哪怕是TWeakObjectPtr也写上——弱引用不需要 GC 跟踪,但写上不亏,还能避免某些反射相关的意外。
5.4 一个具体的小优化:索敌半径用平方比较
听起来微不足道,但 300 单位 × 每秒 2 次索敌 × 每格平均 15 人,一次Dist的开销乘以这个基数就不小了。所有距离判断改成平方距离比较,省掉开方:
const float RangeSq = FMath::Square(CurrentStat.Range); for (ASoldierUnit* Unit : Candidates) { if (FVector::DistSquared2D(MyLocation, Unit->GetActorLocation()) <= RangeSq) { // 命中 } }用DistSquared2D而不是DistSquared,因为塔防基本是平面战场,Z 轴差异没意义,还能顺手处理掉地形高低差带来的误判。这个改动我实测在峰值场景下省了大概 3% 的帧时间,不多,但不要钱。
还有一点经验:索敌时先按格子遍历,再按距离精筛,顺序不能反。先距离后格子的写法在逻辑上等价,但你得遍历全部单位才能筛,网格分区就白做了。
6. 这套架构跑下来,我认为最值钱的三个决定
第一个决定是把塔和兵拆成两条独立的继承线,中间只通过注册表和 Modifier 通信。这个改动带来的直接好处是新增单位类型不用动已有代码。项目后期加了一种"可以移动的塔"(本质上是能建造的兵),因为两条线已经解耦,只写了一个新类挂上去就通了,半天搞定。如果还是原来那个合并的继承树,这个需求能把人逼疯。
第二个决定是数值层和行为层彻底分开。DataTable 只放纯数值,资产放引用和结构。这让策划的调参速度和我们的资源加载规划都变得可控。副作用是初期配置工作量大一些,但一旦跑起来,后面每次改需求都省事。
第三个决定是所有跨对象的交互都走事件和注册,禁止直接持有指针。这条规则前期写起来啰嗦,尤其是想快速验证一个想法的时候会觉得麻烦。但它把"对象生命周期"这个最大的不确定性从业务逻辑里赶了出去,后面调试基本不需要再想"这个指针会不会已经死了"。我在踩过池化对象的悬空指针之后,觉得为这条规则多写的那些样板代码完全值。
最后一个建议,如果你正准备开这类项目:先把UTacticalSubsystem和UTowerDefinition这两个类写出来,别急着做 UI 和关卡。这两个类定型了,后面所有塔、所有兵、所有技能都是往里填配置的事;定型不了,后面会一直在改架构和补锅之间反复横跳。我第一个项目就是先做了两星期的关卡和美术,等发现架构不对的时候,已经有四十多座塔的配置挂在旧结构上了,迁移花的时间比重新做还多。