☰
UE4 C++塔防架构:模块拆分与数据驱动实战
2026/10/28 11:33:14 网站建设 项目流程

塔防在所有游戏品类里看起来是最"简单"的一种——塔站着不动,敌人沿路走,血量归零扣命,玩家偶尔点两下升级。真在UE4里用C++搭一套能撑住几十种塔、上百个单位、后期还要不断加内容改数值的架构,才会发现坑全埋在看不见的地方:塔的索敌每帧扫全场,帧率掉到三十;策划改一个攻击间隔要等程序重新编译打包;士兵吃到两个光环,伤害加成叠了三层还是取最大值谁也说不清;塔被打掉之后指针还挂在士兵的Buff列表里,一访问就崩。这篇东西讲的就是我在做3D塔防时怎么拆模块、怎么让"士兵"和"塔"这两种完全不同的单位共享一套战斗逻辑,以及怎么把数值从C++里赶出去交给数据表。内容偏工程实现,默认你能看懂UCLASS、UPROPERTY、TSubclassOf这些基础写法,也适合正在做塔防或者准备做防守类项目的人直接抄结构。前半段讲设计思路和取舍,后半段全是代码片段、表结构、参数和踩过的坑,能对着改。

1. 先搞清塔防的架构重心到底在哪

1.1 从玩法结构反推代码结构

做动作游戏的时候,架构重心是"输入响应"——一个玩家、少量敌人、高频操作,逻辑几乎都压在角色身上,写成一个巨大的Character类也能跑。塔防完全反过来:玩家大部分时间不操作,场上同时存在几十上百个单位,每个单位都在跑自己的小逻辑,而画面上的表现(弹道、命中特效、伤害飘字)又特别密集。这种"多单位、低频决策、高频表现"的结构,决定了代码不能围着某一个主角写,得围着"批量"和"配置"写。

我在第一个原型里就犯了这个错,把所有逻辑塞进一个ATowerBase里,索敌、开火、升级、特效全在一个类,前两周跑得挺顺,等塔的类型到第八种、敌人开始分空中地面、Buff系统加进来之后,这个类膨胀到两千多行,改一处就崩另一处。后来重新拆,核心判断只有一条:凡是"会变化的数值",一律不进C++;凡是"会重复的行为",一律抽成组件或接口。这条判断看起来朴素,但它直接决定了后面所有模块的边界。

再往深一层说,塔防有个特殊性——塔是静态的,士兵是移动的,但两者做的事情高度重合:都要有血量、都要能吃Buff、都要能对目标造成伤害、都要能被销毁。如果给塔和士兵各写一套战斗逻辑,短期看不出问题,中期就会分裂成两套数值口径,后期加一个新机制(比如"塔受到伤害时给周围士兵回血")就得在两个地方各改一遍。所以架构的第一优先级不是"塔怎么写",而是"塔和士兵怎么共用一套底层"。

1.2 士兵和塔必须共享同一条伤害管线

这一条是我踩过最疼的坑。早期为了赶进度,塔的攻击直接调Target->TakeDamage(),士兵的攻击走另一条自己写的扣血函数。结果出现了三个问题:第一,塔的暴击只对塔生效,士兵的暴击公式跟塔不一样,玩家看到的伤害数字对不上;第二,减伤Buff只挂在士兵那条管线上,塔打出去无视减伤;第三,做伤害统计面板的时候发现数据来源有两个,加起来的数字和实际血量变化不一致。

后来我强制所有伤害都走同一个入口,签名大概是这样:

// Combat/DamageTypes.h USTRUCT(BlueprintType) struct FDamageContext { GENERATED_BODY() UPROPERTY(BlueprintReadWrite) TWeakObjectPtr<AActor> Instigator; // 谁打的 UPROPERTY(BlueprintReadWrite) TWeakObjectPtr<AActor> Target; // 打谁 UPROPERTY(BlueprintReadWrite) float BaseDamage = 0.f; // 表里配的原始值 UPROPERTY(BlueprintReadWrite) float FinalDamage = 0.f; // 结算后的值 UPROPERTY(BlueprintReadWrite) EAttackType AttackType = EAttackType::Physical; UPROPERTY(BlueprintReadWrite) uint8 bCritical : 1; UPROPERTY(BlueprintReadWrite) FName SourceRow; // 来源配置行,方便排查 };

然后所有单位实现同一个接口:

UINTERFACE(MinimalAPI, BlueprintType) class UDamageable : public UInterface { GENERATED_BODY() }; class IDamageable { GENERATED_BODY() public: virtual void ReceiveDamage(const FDamageContext& Context) = 0; virtual bool IsAlive() const = 0; virtual ETargetFaction GetFaction() const = 0; };

塔和士兵都实现IDamageable,伤害管线(UDamagePipeline,一个UObject子系统或者静态函数集合)负责按顺序跑:基础值 → 攻击方加成 → 防御方减伤 → 抗性 → 最终取整 → 广播OnDamageResolved事件。这么做的好处是,塔和士兵在伤害这件事上彻底同质化,加一个"护盾吸收"或者"伤害上限"机制,只需要在管线里插一段,两边同时生效。

注意:接口里不要放UFUNCTION(BlueprintCallable)的虚函数然后指望蓝图实现,UE4里BlueprintImplementableEvent和C++虚函数混用会绕晕。我的做法是接口只做C++纯虚,蓝图需要扩展的走组件或者事件广播,别把两套机制缠在一起。

1.3 数据驱动不是"把所有东西塞进表"

"数据驱动"这四个字很容易被过度执行。我见过一个项目,把塔的行为逻辑也用表来描述——表里写"第0秒执行ACTION_MOVE,第1秒执行ACTION_ATTACK",最后策划配不明白,程序调试时对着表看不到堆栈,改一个Boss行为花了三天。这属于典型的过度抽象。

我给的边界是:数值、引用、开关进表;流程、分支、时序留在代码。具体分三类:

内容类型放哪理由
血量、攻击力、攻击间隔、射程、移速、造价DataTable纯数值,改得最频繁,策划能自助
模型、图标、弹道类、特效类、音效DataTable(软引用)换美术资源不需要动代码
目标类型掩码、攻击属性、特殊标记DataTable(位掩码/枚举)组合性强,代码里读一次就够
攻击时序(前摇-命中-后摇)C++ 固定流程流程稳定,表里描述反而容易配错
特殊行为(比如"每第三次攻击造成范围伤害")C++ 子类或组件有状态、有分支,表描述不了

这张表我贴在了项目文档里,后面凡是争论"这个要不要进表",都拿它当裁判。经验是:表里只放"能被一行填满"的东西,一行放不下、需要写条件、需要引用另一行的复杂行为,就应该变成代码。

2. 模块拆分:五个能独立编译的骨架

2.1 模块清单与依赖方向

UE4的项目结构默认是单个Game模块,塔防这种内容量,早期单模块够用,但一旦塔的种类超过二十、士兵超过十种、还要做升级树,单模块编译时间会失控,而且任何人改任何文件都会触发全量重编。我的做法是拆成五个模块,用独立的.Build.cs管理,缺点是配置麻烦一点,优点是依赖关系被编译器强制约束,谁也别想偷偷乱引用。

模块名职责允许依赖
Core枚举、接口、通用结构体、日志分类、宏无(只依赖引擎)
Data表结构体、数据校验、数据访问单例Core
Units士兵、敌人的基类、移动、血量、死亡Core, Data
Towers塔基类、索敌、开火、光环Core, Data, Units
Combat伤害管线、弹道、命中结算、状态效果Core, Data, Units
Flow波次、经济、胜负、关卡流程以上全部

依赖方向是单向的:Flow → Towers → Units → Data → Core。Towers可以引用Units,Units绝对不引用Towers。这一条不好好执行的话,很容易出现"士兵想知道自己被哪个塔打死了"这种需求,然后Units里include了Towers,编译直接成环,或者靠前向声明糊过去,运行时崩溃。

2.2 单向依赖怎么落到工程上

Build.cs里写法很直接:

// Units.Build.cs PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "Data" }); PrivateDependencyModuleNames.AddRange(new string[] { }); // 注意:这里没有 Towers,谁加谁就是破坏架构

真正的难点不在配置,而在需求变形的时候怎么守住。士兵要显示"被哪个塔强化了",直觉是让士兵持有塔的指针,但那样Units就依赖Towers了。我的解法是在Core里放一个事件总线(一个UObject单例),塔强化士兵的时候往总线发一个带FName BuffSource的事件,士兵订阅事件只拿到字符串ID,界面上显示名字的时候再去Data模块查表。整条链路上没有任何跨层引用。

这套东西刚上线时被同事吐槽"绕",说直接include多省事。我坚持的原因很现实:这个项目后面还要加"塔被摧毁后原地留下废墟,废墟会影响路过的敌人"这种需求,如果Units和Towers已经互相引用,废墟这种既不是塔也不是士兵的东西就没法优雅地插进去。单向依赖换来的是后面加中间层的时候不用重构。

2.3 接口层该写多细

接口最容易写成"什么都想往里塞"。我见过一个IUnitInterface里塞了二十多个纯虚函数,结果每个实现类都得写一堆空实现。我的做法是按能力拆,一个接口只管一件事:

  • IDamageable:受伤、存活判断、阵营
  • ITargetable:能否被索敌、索敌优先级、索敌锚点位置
  • IAuraAffectable:能否接受光环影响
  • IPoolable:对象池的回收与重置

拆细的好处是,一个纯装饰物(比如路障)只需要实现ITargetable,不用被迫写一堆用不上的函数。坏处是索敌的时候要判断能力,多一次Cast。我用Implements<UInterface>()做快速判断,实测一百个单位每秒判断二十次,开销可以忽略。

实操心得:接口里的纯虚函数尽量返回简单类型,别返回FVector以外的复杂结构体然后按值返回,虚函数加上临时对象构造,在批量调用的时候会有可见的开销。索敌相关的接口我都改成输出参数。

3. 士兵与塔的协同到底怎么实现

3.1 索敌:频率、形状、评分三件事

索敌是塔防里开销最大的逻辑,没有之一。新手写法是塔的Tick里每帧做一次Overlap,场上有五十个塔的时候,每帧五十次物理查询,帧率直接腰斩。我的做法是把索敌拆成三个可调参数:频率、形状、评分。

频率上,塔不需要每帧索敌,攻击间隔普遍在零点五秒以上,索敌频率设成0.2s完全够用,而且要做错峰——所有塔同时索敌会形成周期性卡顿。错峰的方法很简单,在BeginPlay里给每个塔一个随机初始延迟:

void ATowerBase::BeginPlay() { Super::BeginPlay(); // 错峰:0 ~ AcquireInterval 之间的随机延迟,避免所有塔同一帧索敌 const float FirstDelay = FMath::FRandRange(0.f, AcquireInterval); GetWorldTimerManager().SetTimer( AcquireTimerHandle, this, &ATowerBase::AcquireTarget, AcquireInterval, true, FirstDelay); }

形状上,球形查询最省事但最不准,尤其是射程远、地形有高低差的时候,球形会把墙后面的敌人也扫进来。我一般用球形做粗筛,再用视线检测(LineTraceSingleByChannel)做精筛,只对候选列表里的前三个做视线判断,避免全量射线。空中单位和地面单位分流的话,用FCollisionObjectQueryParams分两个通道查,比查完再过滤快。

评分上,塔的目标选择策略应该可配。最常见的有四种:最近、最远(沿路径进度最高)、血量最低、血量最高。我把评分逻辑抽成一个可配置的枚举加一个权重函数:

float ATowerBase::ScoreTarget(const AActor* Candidate) const { const float Dist = FVector::Dist(GetActorLocation(), Candidate->GetActorLocation()); switch (TargetPriority) { case ETargetPriority::Nearest: return -Dist; case ETargetPriority::Farthest: return Dist; case ETargetPriority::LowestHP: return -IDamageable::Execute_GetHealthPercent(Candidate); case ETargetPriority::HighestHP: return IDamageable::Execute_GetHealthPercent(Candidate); default: return -Dist; } }

注意评分函数要返回"越大越好",这样上层只需要比大小,不用为每种策略写一遍遍历。这个约定省了我后面加"优先打空军"这种组合策略时的大量判断。

3.2 攻击拆成三段:冷却、前摇、命中

攻击时序这块我强烈建议一开始就拆成三段,后面加机制的时候会谢自己。三段是:冷却(Cooldown)→ 前摇(Windup)→ 命中结算(Resolve)。

冷却决定能不能开火,前摇决定开火后多久真正生效,命中结算负责伤害和特效。为什么要拆?因为塔防里几乎所有"高级机制"都卡在这三段上:有的塔前摇长但伤害高(炮台),有的塔前摇几乎为零(机枪),有的塔前摇期间被打断就取消攻击(蓄力塔),有的塔命中后才结算范围伤害(溅射)。如果写成"开火即扣血",这些机制全靠特判,代码会烂掉。

void ATowerBase::TryAttack() { if (!IsValid(TargetActor) || !IsTargetInRange(TargetActor)) return; if (GetWorldTimerManager().IsTimerActive(WindupTimerHandle)) return; // 进入前摇,期间如果目标死亡或超范围,直接取消 PendingTarget = TargetActor; GetWorldTimerManager().SetTimer( WindupTimerHandle, this, &ATowerBase::ResolveAttack, WindupTime, false); PlayAttackMontage(); // 表现先走,逻辑后到 } void ATowerBase::ResolveAttack() { AActor* Target = PendingTarget.Get(); if (!IsValid(Target) || !IsTargetInRange(Target)) { // 目标没了,取消这次攻击,但不退冷却(这点很关键,退冷却会导致高频重试) return; } if (ProjectileClass) { // 有弹道:生成弹道,伤害在弹道命中时结算 SpawnProjectile(Target); } else { // 瞬发:直接走伤害管线 FDamageContext Ctx; Ctx.Instigator = this; Ctx.Target = Target; Ctx.BaseDamage = CurrentDamage; Ctx.AttackType = AttackType; Ctx.SourceRow = ConfigRowName; UDamagePipeline::ApplyDamage(Ctx); } GetWorldTimerManager().SetTimer( CooldownTimerHandle, CurrentAttackInterval, false); }

这里有个细节值得单独说:目标丢失的时候要不要退冷却。我试过退,结果是塔在目标刚死的那一帧重新开火,然后目标又死了,形成高频循环,性能雪崩。后来统一不退冷却,代价是偶尔会"空放"一次攻击,玩家几乎感知不到,但服务器和客户端都稳了。

弹道这块还有一个坑,命中判定到底用碰撞还是用距离。用碰撞简单,但高速弹道会穿模(tunneling);用距离检测(每帧算弹道到目标的距离,小于阈值判定命中)更稳,但要处理目标移动导致追不上的情况。我最后用的是"追踪弹道 + 距离阈值",追踪速度设成目标的1.5倍,阈值0.5米,既不会穿模也不会追丢。

3.3 光环与增益叠加:必须定死规则

光环是塔和士兵协同最直接的体现,也是最容易数值失控的地方。一个攻击光环塔旁边站三个不同的攻击光环塔,士兵到底吃几层?我的项目里定死了两条规则,写在文档里,任何人都不能改:

同类光环取最大值,不同类光环相乘。也就是说,三个"攻击力+20%"的光环同时作用,最终只加20%;一个"攻击力+20%"和一个"攻速+15%",两者相乘。取最大值的原因是光环通常来自同类塔,玩家叠一堆同类塔不应该获得线性收益,否则最优解永远是堆同一种塔,玩法直接死掉。

实现上我用一个FActiveModifier结构和一个挂在单位身上的UModifierComponent:

USTRUCT() struct FActiveModifier { GENERATED_BODY() UPROPERTY() FName ModifierId; // 光环来源的唯一ID UPROPERTY() EModifierType Type; // Attack / Speed / Range ... UPROPERTY() float Value = 0.f; UPROPERTY() TWeakObjectPtr<AActor> Source; UPROPERTY() float ExpireTime = 0.f; // 用于过期清理 };

组件里维护一个TArray<FActiveModifier>,每次取值时按类型分组算最大值再相乘,同时给每个Modifier一个过期时间,由光环塔定期刷新。用弱指针存Source,塔被摧毁后指针自动失效,不会悬垂。

注意:不要用OnComponentBeginOverlap来做光环范围判断。UE4的Overlap在角色移动瞬移、传送、生成时机的处理上并不总是触发,尤其是敌人从池子里取出来直接SetActorLocation的时候,Overlap事件可能完全不发。我用的是光环塔自己起一个0.3s的定时器,主动做一次球形查询刷新受影响列表,稳得多。

3.4 单位之间怎么通信:别裸指针

士兵和塔之间要不要直接持有指针?答案是一般不要。塔持有士兵指针,士兵死了指针悬垂;士兵持有塔指针,塔被拆了同样悬垂。UE4有GC,但GC不管裸指针,AActor*在对象被Destroy()之后不会自动变空,你必须用TWeakObjectPtr或者IsValid()检查。

我的做法是三层:广播事件、弱引用传数据、ID查表取配置。

塔要通知周围士兵"我被强化了",走事件总线;士兵需要知道自己被哪些光环影响,存在自己的UModifierComponent里,用TWeakObjectPtr<AActor>存来源;UI要显示光环名字,用FName去Data模块查。三层各管一件事,没有任何一层需要依赖另一层的具体类型。

事件总线本身也要注意,UE4的DECLARE_DYNAMIC_MULTICAST_DELEGATE在蓝图里绑定之后,如果监听者被销毁但没有解绑,广播的时候会访问已销毁的对象。我在总线里统一用AddUniqueDynamic加RemoveDynamic,并且在监听者的EndPlay里强制解绑。这个坑我在测试阶段遇到过两次,表现是编辑器里一切正常,打包后偶发崩溃,查了两天才定位到。

4. 数据驱动:表结构、校验、热更新

4.1 行结构体怎么设计才不至于失控

DataTable 的行结构体是整个数据驱动的地基,设计得不好,后期加字段就是灾难。我的原则是:公共字段放基类,专属字段放子表,用行名做外键。

基类大概长这样:

USTRUCT(BlueprintType) struct FUnitConfigRow : public FTableRowBase { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadOnly) FText DisplayName; UPROPERTY(EditAnywhere, BlueprintReadOnly) TSoftObjectPtr<UTexture2D> Icon; UPROPERTY(EditAnywhere, BlueprintReadOnly) TSoftClassPtr<AActor> ActorClass; UPROPERTY(EditAnywhere, BlueprintReadOnly) float MaxHealth = 100.f; UPROPERTY(EditAnywhere, BlueprintReadOnly) float Armor = 0.f; UPROPERTY(EditAnywhere, BlueprintReadOnly) ETargetFaction Faction = ETargetFaction::Enemy; // 位掩码:Ground / Air / Structure UPROPERTY(EditAnywhere, BlueprintReadOnly, meta=(Bitmask, BitmaskEnum="ETargetMask")) int32 TargetMask = 0; };

塔的配置表继承它,加上射程、攻击间隔、前摇、弹道类、索敌优先级。士兵的配置表继承它,加上移速、路径类型、死亡特效。这样公共字段只写一次,改一个字段名不用改三张表。

这里有个细节我很坚持:用TSoftObjectPtr和TSoftClassPtr,不用硬引用。硬引用会把所有美术资源拖进同一批 cook,导致内存里常驻一堆当前关卡用不到的东西。软引用在初始化时才异步加载,对塔防这种资源量大的项目非常关键。代价是加载完成前不能直接用,得处理空指针,这个后面会说。

4.2 成长曲线:别把等级直接乘系数

塔防的塔一般都要升级,一级到十级。新手写法是Damage = BaseDamage * (1 + 0.2f * Level),线性增长看着简单,实际跑起来会发现十级塔打十级敌人太强,前期又太弱,因为敌人血量通常是指数增长的。

我后来改成曲线资产加公式兜底:表里配一个UCurveFloat的软引用,横轴是等级,纵轴是倍率,策划在编辑器里拖曲线,改一次不用重新编译。没有配曲线的用公式兜底:

float UTowerConfigLib::GetLevelMultiplier(const FTowerConfigRow& Row, int32 Level) { if (UCurveFloat* Curve = Row.LevelCurve.LoadSynchronous()) { return Curve->GetFloatValue(static_cast<float>(Level)); } // 兜底:1.0 起步,每级 1.12 倍,十级约 3.1 倍 return FMath::Pow(1.12f, static_cast<float>(Level - 1)); }

这个1.12不是我拍脑袋定的,是拿关卡时长反推的。假设一局二十分钟,玩家能升到满级的塔大概三到四座,满级塔的输出要让最终Boss在八秒内被打掉,倒推回去每级倍率就在1.1到1.15之间。这种"从目标反推数值"的做法比"看别人游戏怎么配"靠谱得多,因为你的关卡节奏和别人不一样。

4.3 校验与失败兜底:数据错了要早发现

数据驱动的最大风险是"表配错了但没报错"。指向了一个不存在的行名、引用的类被删了、射程填成负数——这些在编辑器里跑起来可能只是行为诡异,打包后直接崩。我的做法是写一个编辑器模块的校验函数,在保存表的时候自动跑一遍:

#if WITH_EDITOR void UTowerConfigValidator::ValidateAll() { const UDataTable* Table = LoadObject<UDataTable>(nullptr, TEXT("/Game/Data/DT_Towers")); if (!Table) return; TSet<FName> AllRows; Table->ForeachRow<FTowerConfigRow>(TEXT("Validate"), [&AllRows](const FName& Key, const FTowerConfigRow& Row) { AllRows.Add(Key); if (Row.MaxHealth <= 0.f) UE_LOG(LogTD, Error, TEXT("[%s] MaxHealth 必须大于 0"), *Key.ToString()); if (Row.AttackRange < 0.f) UE_LOG(LogTD, Error, TEXT("[%s] AttackRange 不能为负"), *Key.ToString()); if (!Row.ActorClass.IsNull() && Row.ActorClass.LoadSynchronous() == nullptr) UE_LOG(LogTD, Error, TEXT("[%s] ActorClass 加载失败"), *Key.ToString()); }); // 第二遍检查外键:升级指向的行必须存在 Table->ForeachRow<FTowerConfigRow>(TEXT("ValidateRef"), [&AllRows](const FName& Key, const FTowerConfigRow& Row) { if (!Row.UpgradeTo.IsNone() && !AllRows.Contains(Row.UpgradeTo)) UE_LOG(LogTD, Error, TEXT("[%s] UpgradeTo 指向不存在的行 %s"), *Key.ToString(), *Row.UpgradeTo.ToString()); }); } #endif

校验函数一定要分两遍,第一遍收集所有行名,第二遍检查外键。一遍走完就检查外键的话,引用后面行的配置会误报。

运行时也要兜底,我的做法是数据管理器在加载表之后,把每一行都转成一个运行时结构,转换失败的行走默认值并打一条Warning,而不是直接崩。塔防这种项目,一个塔的配置错了不应该让整局游戏挂掉。

4.4 数值和表现分开,打包才不会出事

这一条是我在打包阶段被坑出来的。编辑器里跑得好好的,打包之后所有塔的模型都是空的,查了半天发现是DataTable里配了硬引用的StaticMesh,打包时因为"没有被任何蓝图直接引用",资产被裁掉了。UE4的cook规则是只打包被引用到的资源,而DataTable里的软引用不算强引用。

解决办法有两条,我两条都用:一是加PrimaryAssetLabel把数据目录标记为总是cook,二是在关卡里放一个"数据预加载"的Actor,把所有软引用在BeginPlay里LoadAsync一遍。第二条同时解决了运行时的卡顿问题——塔在第一次被建造的时候才异步加载模型,会有一瞬间的空白,预加载能把这个空白消掉。

void ADataPreloader::BeginPlay() { Super::BeginPlay(); TArray<FSoftObjectPath> Paths; for (const auto& Pair : TowerTable->GetRowMap()) { const FTowerConfigRow* Row = reinterpret_cast<FTowerConfigRow*>(Pair.Value); if (!Row) continue; if (!Row->ActorClass.IsNull()) Paths.Add(Row->ActorClass.ToSoftObjectPath()); if (!Row->Icon.IsNull()) Paths.Add(Row->Icon.ToSoftObjectPath()); } if (Paths.Num() > 0) { PreloadHandle = StreamableManager.RequestAsyncLoad(Paths, FStreamableDelegate::CreateUObject(this, &ADataPreloader::OnPreloadDone)); } }

5. 性能与排查:那些只会在真机上出现的问题

5.1 Tick 与 Timer 怎么选

UE4里PrimaryActorTick是双刃剑。士兵的移动确实需要每帧更新,但索敌、Buff刷新、攻击判定这些都不需要。我的原则是:只要逻辑有固定周期,一律用 Timer,不用 Tick。塔不开Tick,纯靠Timer;士兵开Tick但只做移动插值,其他逻辑全走Timer。

士兵数量大的时候(一局后期能到八十到一百二十个),UCharacterMovementComponent的开销会很明显,因为它每帧做一堆地面检测和网络同步准备。塔防的士兵移动路径是固定的,完全可以用简化的移动:AActor加一个沿样条(USplineComponent)的位置推进,朝向用插值。我实测过,一百二十个敌人,用CharacterMovement跑到四十五帧左右,换成样条加简单插值能到八十帧以上。

还有个小技巧:远处的敌人降低更新频率。根据敌人到摄像机的距离分三档,近的每帧更新,中的每两帧,远的每四帧。因为玩家根本看不清远处单位的细节,但帧率能实打实提上来。

5.2 对象池:不池化就等着卡顿

塔防的敌人是持续生成的,一波二三十个,一局下来几百个。每次SpawnActor都要走一遍构造、组件注册、初始化,Destroy又要走一遍销毁和GC标记,这些开销在波次间隙集中爆发,表现就是一卡一卡的。

我用的方案是一个通用的UActorPoolSubsystem,按类维护空闲列表。取出时调SetActorHiddenInGame(false)和SetActorEnableCollision(true),重置血量、位置、状态;归还时反向操作。要注意在EndPlay里把池子清空,否则退出关卡时会因为池子里还挂着Actor导致泄漏警告。

注意:池化之后,BeginPlay只会走一次。所有依赖"每次生成时重新初始化"的逻辑,都必须挪到一个显式的OnSpawnFromPool函数里。我在这个坑上浪费了半天,表现是从池子里取出来的敌人血量是上一局残留的。

5.3 常见问题速查表

下面这张表是我从项目开始记到现在的,基本都是编辑器里看不出来、打包或者高压场景才暴露的问题:

现象可能原因排查方向
塔不攻击,但索敌正常冷却Timer被重复Clear,或者前摇期间目标丢失后没重置状态打印CooldownTimerHandle.IsValid()和PendingTarget
士兵吃了光环但数值没变光环刷新频率太低,或者取最大值逻辑覆盖了新值检查UModifierComponent里的数组内容和过期时间
打包后模型全是空的DataTable软引用未cook加PrimaryAssetLabel,或用预加载Actor强制引用
敌人走到终点不扣命终点触发器用了Overlap,池化后位置瞬移不触发改成Tick里判断距离,或者手动调用重叠检测
帧率随时间下降事件总线监听者未解绑,或者特效Actor未回收用Stat Game和Memory看对象数量是否单调增长
塔的攻击音效错位音效在冷却开始时播,不在命中时播把音效挂到ResolveAttack里,而不是TryAttack
编辑器正常,打包后崩溃硬编码资产路径,或ConstructorHelpers在打包环境失效全项目搜索FObjectFinder,改成软引用配置

这张表里最坑的是"敌人走到终点不扣命"。当时查了很久以为是碰撞通道配错了,最后发现是池化导致的:敌人从池子里取出来直接SetActorLocation到出生点,UE4的Overlap事件在瞬移时不保证触发,尤其是当敌人的碰撞体本来就被关掉又重新打开的情况下。改成在移动组件里每帧算一次到终点的距离,小于阈值就判定到达,问题消失。这个教训是:凡是"到达某个位置"的判定,都不要依赖Overlap。

5.4 调试手段:早点把可视化做出来

调试塔防最痛苦的是"看不见"。五十个塔同时开火,你不知道哪个塔在打谁、哪个塔没索敌到、哪个士兵身上挂了几层Buff。我的做法是早期就写一个调试绘制,按~键切换,把射程圈、当前目标、Buff层数全画出来:

void ATowerBase::DrawDebugInfo() const { #if ENABLE_DRAW_DEBUG if (!bShowDebug) return; DrawDebugSphere(GetWorld(), GetActorLocation(), CurrentRange, 24, FColor::Green, false, 0.1f); if (AActor* Target = TargetActor.Get()) { DrawDebugLine(GetWorld(), GetActorLocation(), Target->GetActorLocation(), FColor::Red, false, 0.1f, 0, 2.f); } // 冷却进度 const float Remain = GetWorldTimerManager().GetTimerRemaining(CooldownTimerHandle); DrawDebugString(GetWorld(), FVector(0, 0, 150), FString::Printf(TEXT("CD: %.2f"), Remain), nullptr, FColor::Yellow, 0.1f); #endif }

这套绘制在开发期帮我省下的时间,远超写它的时间。特别是排查"某个塔为什么不出手"的时候,直接看射程圈和目标连线,十秒就能判断是索敌问题还是冷却问题,不用加一堆日志再翻输出。

另外我习惯在伤害管线里加一个开关,打开之后每次伤害结算都打一条带来源行名的日志。塔防的伤害事件密度不高(每秒几十次),日志不会刷屏,但能完整还原"这一下伤害是怎么算出来的"——基础值多少、加成多少、减伤多少、最终多少。数值出问题的时候,这一条日志比看代码快得多。

做完这一整套,我最大的感受是:塔防的架构难度不在"写出来",在于"后面还要改三年"。士兵和塔的协同,本质上是提前想清楚它们共享什么、差异什么,然后把共享的部分下沉、把差异的部分上浮;数据驱动,本质上是把改动的成本和风险从程序转移到流程上,用校验和兜底把风险接住。这两件事如果一开始没做对,后面的每一版更新都像在还债。

这套结构后来还被我拿去改过一个防守类的变种项目,改动量最大的是Flow模块(波次规则完全换了),Towers和Units几乎没动。如果你的项目也准备长期迭代,那五个模块的边界和那条单向依赖,建议别偷懒。

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

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

立即咨询