Unreal Engine——《GAS_Nexus》技术报告

Unreal Engine——《GAS_Nexus》技术报告

ELecmark VIP

GAS_Nexus技术报告(UE5.6GAS)

Part 1: The Setup

🎯 核心目标

搭建GAS基础框架:角色继承、ASC集成、网络复制配置


🔧 一、项目配置

Build.cs(必加模块)

1
2
3
4
5
PrivateDependencyModuleNames.AddRange(new string[] {
"GameplayAbilities", // 核心系统
"GameplayTasks", // 技能任务支持
"GameplayTags" // 标签系统
});

面试八股:GAS三大模块缺一不可,Abilities是核心,Tasks用于异步操作,Tags用于状态标识。


🧬 二、角色基类设计

头文件(.h)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 必须继承IAbilitySystemInterface
class ANexusCharacterBase : public ACharacter, public IAbilitySystemInterface
{
GENERATED_BODY()

protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
UAbilitySystemComponent* AbilitySystemComponent;

public:
virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override
{
return AbilitySystemComponent;
}
};

面试八股IAbilitySystemInterface提供统一获取ASC的接口,方便UI、技能等其他系统调用。

构造函数(.cpp)

1
2
3
4
5
6
7
8
9
10
ANexusCharacterBase::ANexusCharacterBase()
{
// ASC是UObject,必须用CreateDefaultSubobject创建
AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));

// 启用网络复制
AbilitySystemComponent->SetIsReplicated(true);
// 默认复制模式:Mixed(玩家用)
AbilitySystemComponent->ReplicationMode = EGameplayEffectReplicationMode::Mixed;
}

面试八股

  • ASC为什么不能在蓝图创建?→ ASC继承自UObject,不是ActorComponent,必须C++实例化
  • Mixed模式的作用?→ 同步技能状态(冷却、激活等),适合玩家

🔄 三、初始化时机(网络核心)

初始化代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 服务器调用
void ANexusCharacterBase::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
if (AbilitySystemComponent)
AbilitySystemComponent->InitAbilityActorInfo(this, this);
}

// 客户端调用
void ANexusCharacterBase::OnRep_PlayerState()
{
Super::OnRep_PlayerState();
if (AbilitySystemComponent)
AbilitySystemComponent->InitAbilityActorInfo(this, this);
}

面试八股

问题 答案
为什么两个地方初始化? 服务器走PossessedBy,客户端等PlayerState复制完走OnRep_PlayerState
InitAbilityActorInfo作用? 设置ASC的Owner(数据持有者)和Avatar(表现体),不调用无法激活技能
Owner和Avatar区别? Owner常驻(如PlayerState),Avatar可切换(如死亡换尸体)

🌐 四、复制模式配置

三种复制模式

1
2
3
4
5
6
enum class EGameplayEffectReplicationMode
{
Minimal, // 最小复制(敌人用)
Mixed, // 混合模式(玩家默认)
Full // 完全复制(极少用)
};

使用场景

模式 适用角色 原因
Minimal 敌人 只同步必要效果,省带宽
Mixed 玩家 同步冷却、激活状态,UI需要

面试八股:敌人用Minimal,因为客户端不需要知道敌人技能内部状态(如冷却剩余时间),只需知道是否被施加了效果。


📌 五、核心八股速记

GAS核心组件关系

1
2
3
4
5
6
7
AttributeSet (属性集) → 存数值

AbilitySystemComponent (ASC) → 管理Ability/Effect,持有AttributeSet
├── GameplayAbility (技能) → 执行逻辑
├── GameplayEffect (效果) → 改属性、加Tag
└── GameplayTag (标签) → 状态标识(冷却/无敌)
GameplayCue (提示) → 视觉/音效

高频面试题

Q:GAS如何支持多人?
A:三要素:

  1. 复制模式:Minimal/Mixed控制同步粒度
  2. RPC:技能激活走ServerTryActivateAbility
  3. 客户端预测:GAS内置预测,减少延迟感

Q:PossessedByOnRep_PlayerState区别?

PossessedBy OnRep_PlayerState
调用端 服务器 客户端
时机 Controller控制角色时 PlayerState复制完成时
用途 初始化服务器ASC 初始化客户端ASC

✅ 六、验收清单

  • GAS三大模块:Abilities/Tasks/Tags

  • 角色继承IAbilitySystemInterface

  • ASC用CreateDefaultSubobject创建

  • 区分MixedMinimal使用场景

  • 掌握InitAbilityActorInfo调用时机

  • 能说清PossessedByOnRep_PlayerState区别

Part 2: Dash Ability

🎯 核心目标

实现第一个可用的GAS技能——冲刺(Dash),掌握Ability激活、任务系统、GameplayCue的使用


🧠 一、GAS四大核心模块

模块 作用 冲刺中的应用
Gameplay Ability 技能逻辑 冲刺移动逻辑
Gameplay Cue 视觉/音效反馈 隐藏模型、粒子特效
Gameplay Effect 属性修改 体力消耗、冷却
Gameplay Attribute 角色属性 体力值

面试八股:GAS四要素分离,逻辑、反馈、消耗、属性各司其职,便于扩展和维护。


🧪 二、测试技能(验证系统)

创建测试技能

1
2
3
4
5
6
// 蓝图步骤
1. 新建蓝图,父类 Gameplay Ability
2. Activate Ability事件 → Print "ability activated"
3. End Ability事件 → Print "ability ended"
4. 玩家蓝图 BeginPlay → 给技能
5. 绑定按键 → Try Activate Ability By Class

核心原理:先确保ASC和技能激活链路正常,再开发复杂功能。


🏃 三、冲刺技能实现

3.1 技能配置

配置项 设置 说明
技能标签 gameplay ability.movement.dash 标识技能类型
实例化策略 Instance Per Actor 变量持久化,支持复制
复制策略 不复制 移动由MovementComponent同步

面试八股

  • Instance Per Actor vs Instance Per Execution?
    Per Actor:技能实例随角色存在,变量可保存(如使用次数)
    Per Execution:每次激活新建实例,无状态
  • 冲刺为什么不复制?→ 移动由CharacterMovementComponent同步,技能只需在本地激活

3.2 移动实现(Root Motion)

1
2
3
4
5
// 使用Ability Task:Apply Root Motion Constant Force
1. Get Avatar Actor From Actor Info → 获取角色
2. 设置冲刺方向(角色前向)
3. 设置力量2000,持续时间0.3
4. 任务完成 → End Ability

3.3 方向优化

1
2
3
4
// Get Dash Direction 函数
1. Get Last Movement Input Vector → 获取最后一次输入方向
2. 如果有效 → 用输入方向
3. 无效 → 回退到角色前向

面试八股:为什么要用Last Input Vector?→ 解决转向时冲刺方向延迟,玩家按左键时即使角色未转向,也能向左冲刺。

3.4 速度问题修复

问题 原因 解决
空中冲刺后无限滑行 Velocity On Finish默认保持速度 改为Clamp Velocity,限制为Max Walk Speed
1
2
// Get Max Speed
Get Character Movement → Get Max Speed → 输出

🎨 四、Gameplay Cue(视觉反馈)

4.1 Cue类型对比

类型 特点 适用场景
Static 无Actor实例,触发一次 瞬时效果
Burst Static子类,可配粒子/音效 爆炸、命中
Actor 有Actor实例,持续存在 护盾、光环

4.2 冲刺Cue实现(GC_Dash)

1
2
3
4
5
6
7
8
9
10
// On Active
1. 获取目标角色
2. 隐藏模型(Set Visibility false
3. 生成传送粒子,附着根组件
4. 播放音效

// On Removed
1. 显示模型
2. 生成爆炸粒子
3. 播放结束音效

4.3 技能中调用Cue

1
2
3
4
5
6
7
// Add Gameplay Cue To Owner(带Remove On Ability End)
- 添加标签 "gameplay cue dash.active"
- 技能结束自动移除 → 触发On Removed

// 与Execute区别
- Add:持续存在,触发On Active/On Removed
- Execute:单次触发,不持续

面试八股:Add和Execute的区别?→ Add有生命周期,Execute只触发一次执行事件。


📊 五、本集核心八股

5.1 Ability激活流程

1
2
3
4
5
按键 → Try Activate Ability By Class
→ Can Activate(检查标签/资源)
→ Activate Ability(执行逻辑)
→ 执行Tasks(移动/等待)
→ End Ability(清理)

5.2 Root Motion在GAS中的使用

  • 用Ability Task而不是直接操作MovementComponent
  • 原因:Task支持网络预测、自动处理同步

5.3 Gameplay Cue生命周期

1
2
3
4
5
6
Add时:
On Active(服务器+客户端)
While Active(保持状态)
On Removed(清理)
Execute时:
On Execute(单次)

5.4 技能结束必调End Ability

后果:不调End Ability → 技能卡死,无法再次激活,UI状态错误


✅ 六、验收清单

  • 测试技能验证系统正常

  • GA_Dash配置正确(标签/实例化)

  • Root Motion Constant Force实现移动

  • 方向优化(Last Input Vector)

  • 空中速度问题修复

  • GC_Dash实现隐藏/显示+粒子音效

  • 区分Add和Execute的使用场景

  • 技能结束调用End Ability

Part 3: Attributes & Effects

🎯 核心目标

实现属性系统(Attributes)和效果系统(Effects),为冲刺技能添加体力消耗、冷却和自动回复


📊 一、属性系统基础

1.1 属性结构

1
2
3
4
5
6
// 属性是包含两个值的结构体
struct FGameplayAttributeData
{
float BaseValue; // 基础值(默认)
float CurrentValue; // 当前值(受效果影响后)
};

面试八股:为什么属性分Base和Current?→ 支持临时修改(Buff/Debuff),方便效果叠加和移除后恢复。

1.2 只能通过Gameplay Effect修改

1
2
3
4
5
// 错误写法
Health = 50; // 禁止!

// 正确写法
ApplyGameplayEffect(GE_Damage); // 通过效果修改

🧱 二、创建属性集(Attribute Set)

2.1 头文件定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
UCLASS()
class UBasicAttributeSet : public UAttributeSet
{
GENERATED_BODY()

public:
// 属性定义(必须用FGameplayAttributeData)
UPROPERTY(BlueprintReadOnly, Category = "Attributes")
FGameplayAttributeData Health;

UPROPERTY(BlueprintReadOnly, Category = "Attributes")
FGameplayAttributeData MaxHealth;

UPROPERTY(BlueprintReadOnly, Category = "Attributes")
FGameplayAttributeData Stamina;

UPROPERTY(BlueprintReadOnly, Category = "Attributes")
FGameplayAttributeData MaxStamina;

// 属性访问器宏(UE5.6+)
ATTRIBUTE_ACCESSORS(UBasicAttributeSet, Health)
ATTRIBUTE_ACCESSORS(UBasicAttributeSet, MaxHealth)
ATTRIBUTE_ACCESSORS(UBasicAttributeSet, Stamina)
ATTRIBUTE_ACCESSORS(UBasicAttributeSet, MaxStamina)

// 网络同步回调
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

UFUNCTION()
virtual void OnRep_Health(const FGameplayAttributeData& OldValue);

UFUNCTION()
virtual void OnRep_Stamina(const FGameplayAttributeData& OldValue);
};

2.2 构造函数初始化

1
2
3
4
5
6
7
8
UBasicAttributeSet::UBasicAttributeSet()
{
// 初始化默认值
Health = 100.0f;
MaxHealth = 100.0f;
Stamina = 100.0f;
MaxStamina = 100.0f;
}

2.3 网络同步实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void UBasicAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// 所有属性同步给所有客户端
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, Health, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, Stamina, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, MaxStamina, COND_None, REPNOTIFY_Always);
}

void UBasicAttributeSet::OnRep_Health(const FGameplayAttributeData& OldValue)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UBasicAttributeSet, Health, OldValue);
}

void UBasicAttributeSet::OnRep_Stamina(const FGameplayAttributeData& OldValue)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UBasicAttributeSet, Stamina, OldValue);
}

🔗 三、将属性集添加到角色

3.1 角色头文件

1
2
3
4
5
6
UCLASS()
class ANexusCharacterBase : public ACharacter, public IAbilitySystemInterface
{
UPROPERTY()
class UBasicAttributeSet* BasicAttributeSet;
};

3.2 构造函数中创建

1
2
3
4
5
6
7
8
ANexusCharacterBase::ANexusCharacterBase()
{
// 创建ASC(已实现)
AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));

// 创建属性集
BasicAttributeSet = CreateDefaultSubobject<UBasicAttributeSet>(TEXT("BasicAttributeSet"));
}

⚡ 四、创建Gameplay Effect

4.1 效果类型对比

类型 说明 应用场景
Instant 立即生效,一次性 伤害、治疗、消耗
Duration 持续一段时间 冷却、Buff
Infinite 无限持续,手动移除 被动技能、状态

4.2 冲刺消耗效果(GE_Dash_Cost)

1
2
3
4
5
类型:Instant
Modifier:
- 属性:Stamina
- 操作:Add
- 数值:-25.0

4.3 冲刺冷却效果(GE_Dash_Cooldown)

1
2
3
4
类型:Duration
持续时间:2.0秒
添加标签:Cooldown.Dash
(无Modifier,只加标签)

4.4 耐力回复效果(GE_Stamina_Regen)

1
2
3
4
5
6
7
8
类型:Infinite
周期:1/30秒(30次/秒)
每周期Modifier:
- 属性:Stamina
- 操作:Add
- 数值:+0.2
添加标签:Status.StaminaRegen
堆叠策略:No stacking(防止叠加)

🎮 五、技能中调用效果

5.1 CommitAbility机制

1
2
3
4
5
6
// 冲刺技能中调用
CommitAbility(); // 同时执行Cost和Cooldown

// 可分开调用
CommitCost(); // 只消耗资源
CommitCooldown(); // 只触发冷却

面试八股:为什么用Commit而不是直接Apply?→ Commit自动检查资源是否充足,资源不足时技能无法激活。

5.2 耐力回复逻辑(蓝图)

1
2
3
4
5
6
1. Wait For Attribute Changed(监听Stamina)
2. 当Stamina减少时:
- Delay 1秒
- Apply Gameplay Effect Spec to Self(GE_Stamina_Regen)
3. 当Stamina达到Max时:
- Remove Active Gameplay Effect(移除回复效果)

🔍 六、GAS调试工具

6.1 控制台命令

1
2
3
abilitysystem.debugattribute stamina  // 显示耐力详情
abilitysystem.debugattribute health // 显示生命详情
abilitysystem.debug ability // 显示激活的能力

6.2 Gameplay Debugger

1
2
按 ' 键打开
数字键3 → 查看能力/效果/标签

面试八股:GAS调试必备技能,面试常问如何排查技能问题。


📌 七、本集核心八股

7.1 Attribute和Effect关系

1
2
3
4
5
6
Attribute(数值容器)
↑ 修改
Gameplay Effect(修改规则)
├── Instant(立即生效)
├── Duration(带时间)
└── Infinite(持续)

7.2 CommitAbility流程

1
2
3
4
5
Can Activate?(检查标签/资源)
→ CommitAbility
→ CommitCost(扣资源)
→ CommitCooldown(加冷却标签)
→ Activate Ability(执行逻辑)

7.3 三种Effect类型选择

需求 选型 原因
扣血/扣蓝 Instant 一次性,立即生效
冷却 Duration 有时长,到期自动移除
光环/被动 Infinite 一直存在,手动控制移除

7.4 为什么属性要分Base/Current?

  • Base:永久值,如角色基础生命100
  • Current:临时修改值,如受伤后50
  • 效果移除时恢复Base,不需要手动计算

✅ 八、验收清单

  • AttributeSet定义正确(FGameplayAttributeData + 访问器)
  • 实现GetLifetimeReplicatedProps和OnRep函数
  • 属性集添加到角色并注册到ASC
  • 创建三种Effect:消耗、冷却、回复
  • 冲刺技能中调用CommitAbility
  • 实现耐力回复逻辑(监听+延迟+应用)
  • 掌握GAS调试命令
  • 理解三种Effect类型的区别

Part 4: UI/Widgets

🎯 核心目标

实现GAS与UI的通信,构建模块化UI系统,动态显示生命值、耐力条和能力状态


🧩 一、UI架构设计

1.1 模块化拆分

1
2
3
4
WBP_PlayerHUD(容器)
├── PlayerVitals(生命/耐力条)
├── AbilitiesContainer(能力容器)
└── AbilitySlot(单个能力槽)

面试八股:为什么要模块化?→ 职责单一,易于维护和复用,避免单个HUD蓝图臃肿。

1.2 HUD创建

1
2
3
// 玩家控制器中
WBP_PlayerHUD* HUD = CreateWidget<WBP_PlayerHUD>(GetWorld(), HUDClass);
HUD->AddToViewport();

注意:只在本地玩家创建,避免为所有客户端都生成UI。


❤️ 二、PlayerVitals(生命/耐力条)

2.1 初始化获取属性

1
2
3
4
5
6
7
8
9
10
// Event Construct中
1. Get Owning Player Pawn
2. Get Ability System Component
3. 获取属性值:
- Health
- MaxHealth
- Stamina
- MaxStamina
4. 存为变量
5. 调用更新函数

2.2 监听属性变化

1
2
3
4
// Wait for Attribute Changed节点
- 监听Stamina变化 → 更新耐力条
- 监听Health变化 → 更新生命条
- 监听MaxHealth/MaxStamina变化 → 更新最大值

面试八股:为什么用Wait而不是Tick?→ 事件驱动,性能好,实时响应。


🎮 三、AbilitySlot(能力槽)

3.1 UI结构

1
2
3
4
5
6
7
Size Box (100x100)
└── Overlay
├── Background(背景色)
├── Text(能力名称)
└── Cooldown Overlay(冷却层)
├── Background(半透明黑)
└── Text(剩余时间)

3.2 数据绑定

1
2
3
4
5
6
7
8
// 变量
AbilitySpecHandle(能力句柄)

// Event Construct中
1. Get Player ASC
2. 通过AbilitySpecHandle获取Ability对象
3. 获取Ability类名 → 显示在Text
4. 初始化隐藏冷却层

3.3 冷却监听

1
2
3
4
5
6
7
8
9
// Wait for Tag Count Changed
- 监听 "Cooldown" 标签
- 进入冷却:
- 显示冷却层
- 启动计时器(每0.1秒更新)
- 计算剩余时间 → 更新文本
- 冷却结束:
- 隐藏冷却层
- 清除计时器

面试八股:冷却时间计算方式?→ 通过标签判断冷却状态,用计时器驱动UI更新,避免每帧查询。


📦 四、AbilitiesContainer(能力容器)

4.1 初始化加载

1
2
3
4
5
6
7
// Event Construct
1. Get Player ASC
2. GetAllAbilities → 获取所有能力Spec Handle数组
3. 遍历数组:
- 创建AbilitySlot
- 传入AbilitySpecHandle
- 添加到Horizontal Box

4.2 问题:延迟加载

现象:BeginPlay时能力还未赋予,UI显示为空

临时方案:加Delay后重新加载(不推荐)


📡 五、GAS事件通信(核心)

5.1 发送事件

1
2
3
4
5
// 在GiveAbility时发送自定义事件
SendGameplayEventToActor(
EventTag = "event.abilities.changed",
Payload = ... // 可携带数据
)

5.2 监听事件

1
2
3
4
5
6
// UI中监听Gameplay Event
1. Bind to "event.abilities.changed"
2. 事件触发时:
- 清空Horizontal Box
- 重新加载所有能力
- 重新创建AbilitySlot

面试八股:为什么用事件而不是直接更新?→

  • 解耦:UI不需要知道能力何时变化
  • 网络同步:事件可在客户端间广播
  • 扩展性:任意模块都可监听

🔄 六、完整数据流

1
2
3
4
5
6
7
服务器端:
GiveAbility → 发送event.abilities.changed

客户端:
UI监听事件 → 清空容器 → 重新加载能力 → 创建新Slot

更新显示

优势

  • ✅ 无需Delay,立即响应
  • ✅ 动态增删能力自动更新
  • ✅ 网络环境下自动同步

📌 七、本集核心八股

7.1 GAS与UI通信的三种方式

方式 适用场景 优点 缺点
Wait for Attribute 属性变化 精准,性能好 只能监听属性
Wait for Tag 状态变化 适合冷却/激活 需要标签系统
Gameplay Event 自定义事件 最灵活,可传数据 需要手动发送

7.2 AbilitySpecHandle的作用

  • 能力的唯一标识
  • 包含能力实例和元数据
  • 用于获取能力信息和激活能力

7.3 UI延迟问题的根治方案

1
2
❌ 错误:加Delay(不可靠,不优雅)
✅ 正确:事件驱动(监听能力变化事件)

7.4 冷却UI实现要点

  • 用标签判断冷却状态(Cooldown.Dash)
  • 用计时器更新剩余时间(不要用Tick)
  • 冷却结束时隐藏层,清除计时器

✅ 八、验收清单

  • HUD模块化拆分(容器/生命条/能力槽/能力容器)
  • PlayerVitals正确获取并显示属性
  • 用Wait for Attribute实现动态更新
  • AbilitySlot显示能力名称和冷却
  • 用Wait for Tag实现冷却监听
  • AbilitiesContainer动态加载能力
  • 发送和监听Gameplay Event实现UI刷新
  • 理解事件驱动UI的优势

Part 4.5: UI/Widgets

🎯 核心目标

优化能力小部件(Ability Widget),添加图标、冷却进度条、激活状态等高级UI功能


🖼️ 一、UI结构升级

1.1 新旧对比

元素 简单版 优化版
能力标识 文本(类名) 图标
冷却反馈 半透明层+文本 进度条+文本(可选)
激活状态 黄色高亮边框
空位显示 空白槽位占位

1.2 新UI结构

1
2
3
4
5
6
7
8
Size Box (132x132)
└── Overlay
├── Background(背景图)
├── Ability Image(能力图标)
├── Active Frame(激活边框,默认隐藏)
└── Cooldown Container
├── Cooldown Progress(冷却进度条)
└── Cooldown Text(冷却时间文本)

🎨 二、能力图标绑定

2.1 数据表驱动

1
2
3
4
5
6
7
8
9
10
11
12
// 数据表结构
RowName: "GA_Shield_C" // 能力类名
Icon: Texture2D // 对应图标

// 绑定函数
SetAbilityImage(AbilityObjectReference)
{
1. 获取能力类名
2. 用类名查数据表
3. 获取图标 → 设置到Ability Image
4. 失败时用默认占位图
}

面试八股:为什么用类名做键?→ 简单直接,但重命名能力需同步更新数据表。

2.2 替代方案讨论

方案 优点 缺点
类名绑定 实现简单 重命名需同步
标签绑定 灵活,不依赖类名 Blueprint获取标签复杂
数据资产 配置集中 需额外维护

⏱️ 三、冷却进度条实现

3.1 进度条设计

1
2
3
背景:透明,仅边框(灰色)
前景:黄色填充,可调色调
填充方向:Bottom to Top(推荐)

3.2 进度计算

1
2
3
4
5
6
7
// 冷却开始时
TotalCooldownTime = RemainingTime(记录总时长)

// 每帧更新
RemainingTime = GetRemainingTime()
Percent = 1 - (RemainingTime / TotalCooldownTime)
ProgressBar->SetPercent(Percent)

3.3 冷却文本开关

1
2
3
4
5
6
7
// 布尔变量控制
ShouldShowCooldownTimeRemaining

if (ShouldShowCooldownTimeRemaining)
CooldownText->SetText(FString::FromInt(FMath::CeilToInt(RemainingTime)))
else
CooldownText->SetText("")

面试八股:为什么冷却进度条比纯文本好?→ 视觉更直观,玩家一眼看出冷却进度,符合现代游戏UI设计。


✨ 四、激活状态实现

4.1 标签监听

1
2
3
4
5
6
7
8
9
10
// 能力配置
ActivationOwnedTags.Add("movement_dash.active")

// UI中监听
Wait for Tag Count Changed(监听该标签)
if (TagCount > 0)
ActiveFrame->SetVisibility(Visible)
ActiveFrame->SetColor(Yellow)
else
ActiveFrame->SetVisibility(Hidden)

4.2 设计建议

  • 为每种能力类型创建专用激活标签(如melee_attack.active
  • 基类统一添加通用激活标签(如ability.active
  • 激活框架颜色可选(黄/绿/蓝),区分不同能力类型

📦 五、能力栏(Abilities Bar)优化

5.1 最小槽位机制

1
2
3
4
5
6
7
8
9
// 变量
MinimumSlots = 4 // 最小显示槽位数

// FillAbilitiesBar函数
1. 获取玩家能力数量(N)
2. 遍历N个能力 → 创建AbilityWidget
3. 如果N < MinimumSlots:
for (i = N; i < MinimumSlots; i++)
创建EmptyAbilityWidget(空白槽位)

5.2 空白槽位设计

1
2
Size Box (100x100)
└── Image(半透明边框,无图标)

面试八股:为什么要最小槽位?→ 保持UI布局稳定,避免能力少时界面空荡荡,提升视觉效果。


🔄 六、完整工作流程

1
2
3
4
5
6
7
8
9
初始化:
PreConstruct阶段 → 填充最小槽位空位(编辑器预览)
Construct阶段 → 监听能力变化事件

能力变化时:
FillAbilitiesBar
├── 清空所有槽位
├── 遍历玩家能力 → 创建对应AbilityWidget
└── 补充空位到最小槽位数

📌 七、本集核心八股

7.1 图标绑定的三种方式

方式 适用场景 优缺点
硬编码 原型开发 简单但不可扩展
数据表 中小项目 配置集中,需手动维护
数据资产 大型项目 最灵活,支持继承

7.2 冷却进度计算

1
Percent = 1 - (剩余时间 / 总冷却时间)

注意:总冷却时间在冷却开始时记录一次,不要每帧重新获取。

7.3 激活标签设计原则

  • 具体能力用具体标签(dash.active
  • 能力类型用类型标签(melee.active
  • 所有能力用通用标签(ability.active
  • 三层标签满足各种查询需求

7.4 最小槽位的作用

  • UI稳定性:防止界面抖动
  • 视觉一致性:固定布局
  • 扩展预留:为后续能力留空间

✅ 八、验收清单

  • 新UI结构(图标+激活框+冷却进度条)
  • 数据表配置图标映射
  • 冷却进度条正确计算和更新
  • 冷却文本显示开关
  • 激活状态监听和显示
  • 最小槽位机制实现
  • 空白槽位占位显示
  • 理解三种图标绑定方式的优劣

Part 5: Damage Effects

🎯 核心目标

实现伤害和治疗系统,掌握持续效果区域(Area of Effect)、动态伤害值、属性限制(Clamping)和Gameplay Cue反馈


🔥 一、持续伤害效果设计

1.1 创建持续伤害效果(GE_Damage_Overtime_Infinite)

1
2
3
4
5
6
7
8
类型:Infinite
周期:1.0秒(每1秒触发一次伤害)
周期执行选项:关闭"Execute Periodic Effect On Application"(避免踏入立即受伤)

Modifier:
- 属性:Health
- 操作:Add Final
- 数值:Set By Caller(标签:data.damage)

面试八股

  • Add Final vs Add to Base?→ Add Final直接修改CurrentValue,不受其他效果影响;Add to Base修改BaseValue,会被后续效果叠加
  • 为什么要关”Execute on Application”?→ 避免玩家刚踏入区域就被立即伤害,给0.5-1秒反应时间

1.2 Set By Caller机制

1
2
3
4
// 创建Effect Spec时动态传值
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(GE_Damage_Overtime_Infinite);
SpecHandle.Data->SetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag(FName("data.damage")), -15.0f);
ApplyGameplayEffectSpecToSelf(SpecHandle);

面试八股:Set By Caller解决了什么问题?→ 同一个Effect蓝图可以复用,伤害数值由调用者动态传入,避免为每个数值创建独立Effect。


🌍 二、效果区域Actor设计

2.1 基础区域类(BP_EffectArea_Base)

1
2
3
4
5
6
7
8
9
10
11
12
13
组件:
- Sphere Collision(碰撞检测)
- Particle System(可选,视觉效果)

事件:
ActorBeginOverlap:
1. 获取对方ASC
2. 验证有效 → MakeOutgoingGameplayEffectSpec
3. SetByCaller赋值(-15伤害)
4. ApplyGameplayEffectSpecToSelf

ActorEndOverlap:
1. 移除该Effect(Remove Active Gameplay Effect)

2.2 子类化扩展

1
2
3
4
5
6
7
8
9
10
11
BP_EffectArea_Damage
├── GE Class: GE_Damage_Overtime_Infinite
├── Data Tag: data.damage
├── Value: -15
└── Visual: 火焰粒子

BP_EffectArea_Heal
├── GE Class: GE_Heal_Overtime
├── Data Tag: data.heal
├── Value: +10
└── Visual: 绿色治疗粒子

面试八股:为什么要用子类化而不是在基类配置?→ 职责清晰,每个区域只负责一种效果,便于美术和策划独立配置。


🔒 三、属性限制(Clamping)

3.1 问题现象

  • 治疗会超过最大生命值(Health > MaxHealth)
  • 伤害会低于0(Health < 0)

3.2 解决方案:重写AttributeSet函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// PreAttributeChange(属性被修改前调用)
void UBasicAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
Super::PreAttributeChange(Attribute, NewValue);

if (Attribute == GetHealthAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHealth());
}
else if (Attribute == GetStaminaAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxStamina());
}
}

// PostGameplayEffectExecute(Effect执行后调用)
void UBasicAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);

if (Data.EvaluatedData.Attribute == GetHealthAttribute())
{
SetHealth(FMath::Clamp(GetHealth(), 0.0f, GetMaxHealth()));
}
else if (Data.EvaluatedData.Attribute == GetStaminaAttribute())
{
SetStamina(FMath::Clamp(GetStamina(), 0.0f, GetMaxStamina()));
}
}

面试八股

  • PreAttributeChange vs PostGameplayEffectExecute区别?
    • Pre:属性被手动设置前调用(如初始化、直接赋值)
    • Post:Effect执行后调用,适用于伤害/治疗等游戏效果
  • 为什么要两个都重写?→ 覆盖所有修改途径,确保属性永远合法

🎯 四、Gameplay Cue反馈

4.1 创建Burst Cue

GC_Damage_Burst

1
2
3
4
5
6
类型:Gameplay Cue Burst
标签:GameplayCue.Damage.Burst
配置:
- Niagara粒子(伤害特效)
- 音效(伤害声音)
- 相机震动(可选)

GC_Heal_Burst

1
2
3
4
5
类型:Gameplay Cue Burst
标签:GameplayCue.Heal.Burst
配置:
- Niagara粒子(治疗特效)
- 音效(治疗声音)

4.2 在Effect中绑定Cue

1
2
3
4
GE_Damage_Overtime_Infinite
Gameplay Cues数组:
- 添加:GameplayCue.Damage.Burst(触发时播放)
- 添加条件:Execute(每次周期执行时触发)

面试八股:为什么用Gameplay Cue而不是直接在Actor里播放?→

  • 网络同步:Cue自动在客户端播放
  • 解耦:伤害逻辑和视觉分离
  • 可配置:策划可独立调整特效

📌 五、本集核心八股

5.1 Effect类型选择

需求 选型 原因
单次伤害/治疗 Instant 一次性,立即生效
区域持续伤害 Infinite + Period 周期触发,可进出区域
Buff/Debuff Duration 有时长,到期自动移除
被动光环 Infinite 一直存在,手动移除

5.2 属性限制的两层防护

1
2
Effect执行 → PostGameplayEffectExecute(第一层,针对Effect)
手动设置 → PreAttributeChange(第二层,兜底)

5.3 Set By Caller vs 硬编码

方式 优点 缺点
硬编码 简单直观 每个数值都要新Effect
Set By Caller 一个Effect复用 需要传参,略复杂

5.4 区域效果设计模式

1
2
3
基类:碰撞检测 + Effect应用逻辑
子类:配置Effect类 + 数值 + 视觉效果
优点:新增效果只需建子类,改配置

✅ 六、验收清单

  • 创建持续伤害Effect(Infinite + Period)
  • 正确配置Set By Caller传值
  • 区域Actor实现Overlap/EndOverlap逻辑
  • 创建伤害和治疗子类
  • 重写PreAttributeChange和PostGameplayEffectExecute
  • 实现属性Clamping(Health/Stamina)
  • 创建Burst类型Gameplay Cue
  • 在Effect中绑定Cue并测试反馈

Part 6: Weapons

🎯 核心目标

构建武器系统基础架构,实现武器切换、动画蓝图切换和移动属性动态调整


🧱 一、武器基础类设计

1.1 武器基类(BP_WeaponBase)

1
2
3
4
5
6
7
组件:
- Static Mesh(武器模型,关闭碰撞)

属性:
- bReplicates = true(支持多人游戏)

作用:仅作为容器,存储武器数据,不包含逻辑

1.2 武器配置结构体(RS_WeaponConfig)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
USTRUCT(BlueprintType)
struct FRS_WeaponConfig
{
GENERATED_BODY()

// 赋予的能力列表
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<TSubclassOf<UGameplayAbility>> AbilitiesToGrant;

// 装备时附着的骨骼插槽
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FName EquippedSocketName;

// 对应的动画蓝图
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TSubclassOf<UAnimInstance> AnimClass;
};

面试八股:为什么用结构体而不是直接在武器类定义属性?→ 数据驱动设计,便于配置和扩展,后续可轻松添加新属性(如移动速度、摄像机设置)。


🎬 二、动画蓝图设计

2.1 动画蓝图继承体系

1
2
3
ABP_Unarmed(空手,基类)
├── ABP_Staff(法杖,替换Locomotion Blend Space)
└── ABP_Axe(斧头,替换Locomotion Blend Space)

2.2 变量化设计

1
2
3
4
5
6
7
8
// 在ABP_Unarmed中
UPROPERTY(BlueprintReadWrite, EditAnywhere)
UBlendSpace* LocomotionBlendSpace; // 移动动画混合空间

UPROPERTY(BlueprintReadWrite, EditAnywhere)
UAnimSequence* IdleAnimation; // 待机动画

// 动画蓝图中动态赋值

面试八股:为什么要用继承+变量化?→ 避免为每种武器创建完全独立的动画蓝图,复用基础逻辑,只需替换资源。


⚙️ 三、武器管理器组件(WeaponsManagerComponent)

3.1 组件声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UCLASS()
class UWeaponsManagerComponent : public UActorComponent
{
GENERATED_BODY()

public:
UFUNCTION(BlueprintCallable)
void EquipWeapon(TSubclassOf<ABP_WeaponBase> WeaponClass);

UFUNCTION(BlueprintCallable)
void UnequipWeapon();

private:
UPROPERTY()
ABP_WeaponBase* CurrentWeapon; // 当前装备武器
};

3.2 装备武器流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void UWeaponsManagerComponent::EquipWeapon(TSubclassOf<ABP_WeaponBase> WeaponClass)
{
// 1. 生成武器实例
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = GetOwner();
ABP_WeaponBase* NewWeapon = GetWorld()->SpawnActor<ABP_WeaponBase>(WeaponClass, SpawnParams);

// 2. 获取武器配置
FRS_WeaponConfig Config = NewWeapon->GetWeaponConfig();

// 3. 附着到指定插槽
USkeletalMeshComponent* Mesh = GetOwner()->FindComponentByClass<USkeletalMeshComponent>();
NewWeapon->AttachToComponent(Mesh, FAttachmentTransformRules::SnapToTargetNotIncludingScale, Config.EquippedSocketName);

// 4. 切换动画蓝图
Mesh->SetAnimInstanceClass(Config.AnimClass);

// 5. 保存引用
CurrentWeapon = NewWeapon;
}

3.3 卸下武器流程

1
2
3
4
5
6
7
8
9
10
11
12
13
void UWeaponsManagerComponent::UnequipWeapon()
{
if (CurrentWeapon)
{
// 1. 销毁武器
CurrentWeapon->Destroy();
CurrentWeapon = nullptr;

// 2. 恢复默认动画蓝图
USkeletalMeshComponent* Mesh = GetOwner()->FindComponentByClass<USkeletalMeshComponent>();
Mesh->SetAnimInstanceClass(DefaultAnimClass); // ABP_Unarmed
}
}

🏃 四、移动属性配置

4.1 移动属性结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
USTRUCT(BlueprintType)
struct FMovementProperties
{
GENERATED_BODY()

UPROPERTY(EditAnywhere, BlueprintReadOnly)
float MaxWalkSpeed = 600.0f;

UPROPERTY(EditAnywhere, BlueprintReadOnly)
bool bOrientRotationToMovement = true;

UPROPERTY(EditAnywhere, BlueprintReadOnly)
bool bUseControllerDesiredRotation = false;
};

4.2 武器配置中集成

1
2
3
// RS_WeaponConfig新增
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FMovementProperties MovementProperties;

4.3 装备时应用

1
2
3
4
5
6
7
8
// EquipWeapon中
UCharacterMovementComponent* Movement = GetOwner()->FindComponentByClass<UCharacterMovementComponent>();
if (Movement)
{
Movement->MaxWalkSpeed = Config.MovementProperties.MaxWalkSpeed;
Movement->bOrientRotationToMovement = Config.MovementProperties.bOrientRotationToMovement;
Movement->bUseControllerDesiredRotation = Config.MovementProperties.bUseControllerDesiredRotation;
}

面试八股:为什么要动态调整移动属性?→ 不同武器应有不同手感,法杖施法时可能需要面朝目标,斧头攻击时需要自由转向。


🎮 五、武器切换逻辑优化

5.1 基础切换

1
2
3
4
5
6
7
8
// 按键1 → 装备法杖
if (CurrentWeapon && CurrentWeapon->IsA(StaffClass))
UnequipWeapon(); // 相同武器,卸下
else
EquipWeapon(StaffClass); // 不同武器,切换

// 按键2 → 装备斧头
同理

5.2 优化逻辑

1
2
3
// 如果已装备武器且与请求相同 → 卸下
// 如果已装备武器且不同 → 先卸下,再装备新武器
// 如果未装备 → 直接装备新武器

📌 六、本集核心八股

6.1 武器系统设计模式

1
2
3
4
数据层:WeaponConfig(结构体)
表现层:WeaponActor(模型+插槽)
逻辑层:WeaponsManagerComponent
动画层:AnimBlueprint(继承+变量化)

6.2 动画蓝图复用技巧

  • 基类实现状态机逻辑
  • 子类只替换资源(Blend Space/Idle)
  • 运行时通过变量动态赋值

6.3 武器管理器职责

  • 生成/销毁武器Actor
  • 管理插槽附着
  • 切换动画蓝图
  • 调整移动属性
  • 不包含输入/技能逻辑

6.4 移动属性配置要点

属性 作用 适用场景
MaxWalkSpeed 移动速度 重武器慢,轻武器快
OrientRotationToMovement 转向跟随移动方向 近战攻击
UseControllerDesiredRotation 转向跟随控制器 远程施法

✅ 七、验收清单

  • BP_WeaponBase创建(Static Mesh + 复制)
  • RS_WeaponConfig结构体定义
  • 武器子类(BP_Weapon_Staff/Axe)配置数据
  • 动画蓝图继承体系(ABP_Unarmed → ABP_Staff/Axe)
  • 武器管理器组件实现装备/卸下
  • 插槽附着和动画切换
  • 移动属性结构体定义和应用
  • 武器切换逻辑(相同卸下/不同切换)

Part 6.5: Polished Weapons Manager

🎯 核心目标

优化武器管理器,添加装备/收起动画、武器背部收纳和动画分层混合,提升武器切换的视觉表现


🎬 一、动画资源准备

1.1 新增动画Montage

武器 装备Montage 收起Montage
斧头 equip_axe_montage unequip_axe_montage
法杖 equip_staff_montage unequip_staff_montage

1.2 Montage通知(Notify)

1
2
3
4
equip montage中:
- Equip Notify(武器从背部→手中)
unequip montage中:
- Unequip Notify(武器从手中→背部)

面试八股:为什么用Montage Notify而不是直接在代码中切换?→ 动画和逻辑同步,确保武器在正确的时间点切换位置,避免穿模。


🔧 二、武器数据结构扩展

2.1 新增配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
USTRUCT(BlueprintType)
struct FRS_WeaponConfig
{
// ...原有配置

// 新增
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FName StowedSocketName; // 背部收纳插槽

UPROPERTY(EditAnywhere, BlueprintReadOnly)
UAnimMontage* EquipMontage; // 装备动画

UPROPERTY(EditAnywhere, BlueprintReadOnly)
UAnimMontage* UnequipMontage; // 收起动画
};

2.2 新增骨骼插槽

1
2
3
4
5
角色骨骼网格体:
- axe_equipped_socket(右手)
- axe_stowed_socket(spine_03,背部)
- staff_equipped_socket(右手)
- staff_stowed_socket(spine_03,背部)

📦 三、武器收纳系统

3.1 新增变量

1
2
3
4
5
UPROPERTY(Replicated)
TArray<ABP_WeaponBase*> StowedWeapons; // 背部收纳武器数组

UPROPERTY()
ABP_WeaponBase* PreviouslyEquippedWeapon; // 之前装备的武器(用于收起动画)

3.2 起始武器赋予

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 新增配置
UPROPERTY(EditAnywhere)
TArray<TSubclassOf<ABP_WeaponBase>> StartingWeapons;

// BeginPlay中
void UWeaponsManagerComponent::GiveStartingWeapons()
{
if (!HasAuthority()) return; // 仅服务器执行

for (auto WeaponClass : StartingWeapons)
{
// 生成武器
ABP_WeaponBase* Weapon = SpawnWeapon(WeaponClass);

// 附加到背部收纳插槽
FRS_WeaponConfig Config = Weapon->GetWeaponConfig();
AttachToSocket(Weapon, Config.StowedSocketName);

// 隐藏武器(初始不可见)
Weapon->SetActorHiddenInGame(true);

// 加入收纳数组
StowedWeapons.Add(Weapon);
}
}

3.3 根据类查找背部武器

1
2
3
4
5
6
7
8
9
ABP_WeaponBase* UWeaponsManagerComponent::GetStowedWeaponByClass(TSubclassOf<ABP_WeaponBase> WeaponClass)
{
for (auto Weapon : StowedWeapons)
{
if (Weapon && Weapon->IsA(WeaponClass))
return Weapon;
}
return nullptr;
}

🔄 四、装备流程优化

4.1 新装备逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void UWeaponsManagerComponent::EquipWeapon(TSubclassOf<ABP_WeaponBase> WeaponClass)
{
// 1. 从背部查找已有武器
ABP_WeaponBase* NewWeapon = GetStowedWeaponByClass(WeaponClass);
if (!NewWeapon) return;

// 2. 保存当前武器(用于收起动画)
PreviouslyEquippedWeapon = CurrentWeapon;

// 3. 播放装备动画
FRS_WeaponConfig Config = NewWeapon->GetWeaponConfig();
PlayMontage(Config.EquipMontage, "UpperBody");

// 4. 动画通知中执行实际切换
// 见下文
}

4.2 动画通知回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 绑定Montage Notify
void UWeaponsManagerComponent::OnEquipNotify()
{
if (!CurrentEquippingWeapon) return;

// 将武器从背部切换到手中
FRS_WeaponConfig Config = CurrentEquippingWeapon->GetWeaponConfig();
AttachToSocket(CurrentEquippingWeapon, Config.EquippedSocketName);
CurrentEquippingWeapon->SetActorHiddenInGame(false);

// 设置当前武器
CurrentWeapon = CurrentEquippingWeapon;
CurrentEquippingWeapon = nullptr;

// 应用武器属性(动画/移动速度等)
ApplyWeaponProperties(CurrentWeapon);
}

void UWeaponsManagerComponent::OnUnequipNotify()
{
if (!PreviouslyEquippedWeapon) return;

// 将武器从手中切换到背部
FRS_WeaponConfig Config = PreviouslyEquippedWeapon->GetWeaponConfig();
AttachToSocket(PreviouslyEquippedWeapon, Config.StowedSocketName);
PreviouslyEquippedWeapon->SetActorHiddenInGame(true);

// 清空当前武器
CurrentWeapon = nullptr;
PreviouslyEquippedWeapon = nullptr;

// 恢复默认属性
ApplyDefaultProperties();
}

🧬 五、动画分层混合

5.1 问题:装备动画打断移动

默认动画蓝图只有Default Slot,播放Montage时会覆盖全身动画,导致移动动画被中断。

5.2 解决方案:Upper Body Slot

1
2
3
// 复制Default Slot,新建Upper Body Slot
// 装备/收起Montage指定播放到Upper Body Slot
PlayMontage(EquipMontage, "UpperBody");

5.3 动画蓝图设置

1
2
3
4
5
6
7
8
9
10
11
1. 缓存姿势(Cached Pose):
- Main States(下半身移动)
- Main States + Upper Body(上半身装备动画)

2. Layered Blend Per Bone节点:
- 从Spine_02开始混合上半身
- 下半身保持Main States

3. Blend Poses By Bool节点:
- 静止时:播放完整Montage(含下半身)
- 移动时:只混合上半身,下半身保持移动

面试八股:Layered Blend Per Bone解决了什么问题?→ 实现上半身播放装备动画的同时,下半身保持移动/奔跑,提升真实感。


⏱️ 六、冷却机制

6.1 问题:装备动作可被连续触发

玩家快速按1/2键会导致动画混乱

6.2 解决方案:冷却Effect

1
2
3
4
5
6
7
8
// 创建冷却Effect
GE_EquipWeapon_Cooldown
类型:Duration
持续时间:2.0
标签:Cooldown.Equip

// 装备Ability中
CommitAbility(); // 触发冷却

📌 七、本集核心八股

7.1 武器收纳设计模式

1
2
3
起始武器:生成 → 附加到背部 → 隐藏
装备时:背部查找 → 播放动画 → Notify切换插槽
收起时:播放动画 → Notify切回背部

7.2 动画通知的作用

  • 确保动画和逻辑同步
  • 避免硬编码延迟时间
  • 支持不同动画时长

7.3 分层混合原理

1
2
3
Default Slot:全身动画
Upper Body Slot:仅上半身
Layered Blend Per Bone:按骨骼层级混合

7.4 为什么不用销毁/重建?

方式 优点 缺点
销毁/重建 实现简单 无法播放收起动画
切换插槽 支持完整动画 需管理多个武器实例

✅ 八、验收清单

  • 武器配置新增收纳插槽和动画Montage
  • 角色骨骼新增背部插槽
  • 起始武器赋予并附加到背部
  • GetStowedWeaponByClass函数实现
  • 装备动画播放和Notify回调
  • 收起动画播放和Notify回调
  • Upper Body Slot配置
  • Layered Blend Per Bone实现
  • 装备冷却机制

Part 7: Multiplayer

🎯 核心目标

实现多人游戏支持,掌握网络复制(Replication)、RPC调用、客户端预测(Client-side Prediction)机制,解决武器管理和冲刺技能的网络同步问题


🌐 一、网络基础概念

1.1 Unreal网络架构

1
2
3
4
服务器(Server):权威(Authority),管理游戏状态
客户端(Clients):各自运行副本,通过服务器同步
复制(Replication):服务器持续同步对象状态给所有客户端
RPC(远程过程调用):客户端请求服务器执行动作

面试八股

  • 什么是权威(Authority)?→ 服务器拥有游戏状态的最终决定权,客户端修改只对自己生效
  • 为什么要服务器权威?→ 防止作弊,保证所有客户端状态一致

1.2 GAS在网络中的作用

GAS特性 作用
技能激活RPC 自动处理Server/Client调用
客户端预测 减少延迟感
Effect复制 效果自动同步
属性复制 AttributeSet自动同步

面试八股:GAS帮开发者做了什么?→ 自动处理技能激活的RPC调用、客户端预测、效果复制,开发者只需关注游戏逻辑。


🔧 二、武器装备网络同步问题

2.1 问题表现

  • 客户端装备武器后,服务器看不到武器
  • 服务器装备武器后,客户端动画/移动属性未同步
  • 客户端出现重复武器(本地和服务各一把)

2.2 原因分析

1
2
3
4
❌ 错误做法:
- 装备逻辑同时在服务器和客户端执行
- 移动属性/动画未设置复制
- 武器生成在客户端也执行

2.3 解决方案:拆分逻辑

Step 1: 组件设置复制

1
2
3
4
5
// 武器管理器构造函数
UWeaponsManagerComponent::UWeaponsManagerComponent()
{
SetIsReplicated(true); // 启用组件复制
}

Step 2: 关键变量RepNotify

1
2
3
4
5
6
7
8
UPROPERTY(ReplicatedUsing=OnRep_EquipWeapon)
ABP_WeaponBase* EquipWeapon; // 当前装备武器(带复制通知)

UPROPERTY(Replicated)
ABP_WeaponBase* PreviouslyEquippedWeapon; // 之前装备武器(只复制)

UFUNCTION()
void OnRep_EquipWeapon();

Step 3: 服务器执行核心逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void UWeaponsManagerComponent::Server_EquipWeapon_Implementation(TSubclassOf<ABP_WeaponBase> WeaponClass)
{
if (!HasAuthority()) return; // 确保只在服务器执行

// 1. 生成武器(仅服务器)
ABP_WeaponBase* NewWeapon = SpawnWeapon(WeaponClass);

// 2. 附加到背部(仅服务器)
AttachToStowedSocket(NewWeapon);

// 3. 设置装备变量(触发复制)
EquipWeapon = NewWeapon;
}

// 客户端响应
void UWeaponsManagerComponent::OnRep_EquipWeapon()
{
if (EquipWeapon)
{
// 客户端:更新动画、移动属性、显示武器
ApplyWeaponProperties(EquipWeapon);
EquipWeapon->SetActorHiddenInGame(false);
}
else
{
// 卸下武器:恢复默认
ApplyDefaultProperties();
}
}

面试八股:为什么用RepNotify而不是直接在客户端执行?→ 服务器作为权威,只复制最终状态,客户端通过回调更新表现,避免状态不一致。


🏃 三、冲刺技能网络问题

3.1 问题1:冷却不同步

1
2
现象:服务器冲刺后正常冷却,客户端可无限冲刺
原因:Commit发生在异步任务完成后,客户端未正确提交

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 错误:在异步任务完成后Commit
// ✅ 正确:在同步事件中Commit
void UGA_Dash::ActivateAbility()
{
// 同步阶段Commit
if (!CommitAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo))
{
EndAbility();
return;
}

// 然后执行异步任务
}

3.2 问题2:方向不同步

1
2
3
4
现象:
- 客户端:基于LastMovementInputVector冲刺(正确方向)
- 服务器:基于角色朝向冲刺(错误方向)
- 结果:客户端位置抖动

原因LastMovementInputVector是客户端本地变量,不同步到服务器

解决方案1:用可复制属性

1
2
// 改用CurrentAcceleration(可复制)
FVector DashDirection = GetCharacter()->GetCharacterMovement()->GetCurrentAcceleration().GetSafeNormal();

解决方案2:通过事件传递数据

1
2
3
4
5
6
7
8
// 发送事件时携带方向
FGameplayEventData Payload;
Payload.TargetData = ...;
Payload.Instigator = this;
Payload.EventMagnitude = ...; // 可编码向量

// 技能中解析
FVector DashDirection = Payload.EventMagnitude; // 需自定义解析逻辑

面试八股:如何传递自定义数据到技能?→

  • HitResult的Location字段(简单向量)
  • Optional Object(复杂数据,需管理内存)
  • SetByCaller(仅限数值)

📡 四、网络同步最佳实践

4.1 权限控制

1
2
3
4
5
6
7
8
// 检查是否有服务器权限
if (!HasAuthority())
{
// 客户端:只做表现,不修改状态
return;
}

// 服务器:执行核心逻辑

4.2 复制变量设计原则

变量类型 复制策略 用途
当前装备 RepNotify 状态变化需客户端响应
之前装备 普通复制 仅保存状态,无需回调
武器数组 普通复制 列表同步

4.3 RPC选择

RPC类型 调用端 执行端 用途
Server 客户端 服务器 请求执行动作
Client 服务器 特定客户端 通知单个客户端
Multicast 服务器 所有客户端 广播事件

📌 五、本集核心八股

5.1 网络同步三要素

1
2
3
复制(Replication):状态同步
RPC:动作同步
权威(Authority):谁说了算

5.2 RepNotify工作流程

1
服务器设置变量 → 变量复制到客户端 → OnRep触发 → 客户端更新表现

5.3 GAS网络优势

  • 技能激活自动RPC
  • Effect自动复制
  • 属性集自动同步
  • 客户端预测内置

5.4 常见网络问题排查

问题 可能原因 解决方案
客户端看不到效果 变量未复制 加Replicated
客户端重复执行 逻辑未加Authority判断 加HasAuthority
方向不同步 用了本地变量 改用可复制属性或事件传递

✅ 六、验收清单

  • 武器管理器设置Replicated=true
  • EquipWeapon用RepNotify
  • 服务器执行生成/销毁
  • 客户端通过OnRep更新表现
  • 冲刺技能Commit移到同步阶段
  • 解决方向同步问题(用CurrentAcceleration或事件)
  • 理解Authority判断的重要性
  • 掌握RepNotify工作流程

Part 7.5: Multiplayer Polished Weapons Manager

🎯 核心目标

优化多人环境下的武器管理器,实现装备/卸下动画的完整网络同步,通过事件拆分和复制变量优化提升性能和稳定性


🔄 一、核心设计理念

1.1 不再销毁武器

1
2
❌ 旧方案:卸下武器时销毁,装备时重新生成
✅ 新方案:武器始终存在,只在背部/手中切换位置

面试八股:为什么不销毁武器?→ 支持收起动画,武器状态可保留(如充能进度、耐久度),性能更好(避免频繁Spawn/Destroy)。

1.2 事件拆分原则

1
2
服务器专属事件:生成武器、附加到插槽、设置复制变量
双端执行事件:应用动画、调整移动速度、播放Montage

📋 二、复制变量优化

2.1 变量设置

1
2
3
4
5
UPROPERTY(ReplicatedUsing=OnRep_EquipWeapon)
ABP_WeaponBase* EquipWeapon; // RepNotify:状态变化需客户端响应

UPROPERTY(Replicated)
ABP_WeaponBase* PreviouslyEquippedWeapon; // 普通复制:仅保存状态,无需回调

面试八股:为什么Previous变量不用RepNotify?→

  • 同一帧设置两个复制变量会合并为单个网络请求
  • 只需一个RepNotify即可触发更新
  • 减少不必要的网络回调

2.2 OnRep实现

1
2
3
4
5
6
7
8
9
10
11
12
13
void UWeaponsManagerComponent::OnRep_EquipWeapon()
{
if (EquipWeapon)
{
// 装备武器:应用属性
SetEquipWeaponProperties(EquipWeapon);
}
else
{
// 卸下武器:恢复默认
SetUnarmedWeaponConfig();
}
}

⚙️ 三、事件拆分实现

3.1 装备武器事件流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
服务器调用:
Server_EquipWeapon(WeaponClass)
├── 从背部查找武器
├── 设置PreviouslyEquippedWeapon = CurrentWeapon
├── 设置EquipWeapon = NewWeapon(触发复制)
└── 结束

客户端响应(OnRep触发):
SetEquipWeaponProperties
├── 验证武器有效性
├── 获取武器配置
├── 设置动画类
├── 调整移动速度
└── 播放装备Montage

3.2 核心函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void UWeaponsManagerComponent::SetEquipWeaponProperties(ABP_WeaponBase* Weapon)
{
if (!Weapon) return;

// 获取配置
FRS_WeaponConfig Config = Weapon->GetWeaponConfig();

// 设置动画
USkeletalMeshComponent* Mesh = GetOwner()->FindComponentByClass<USkeletalMeshComponent>();
Mesh->SetAnimInstanceClass(Config.AnimClass);

// 调整移动速度
UCharacterMovementComponent* Movement = GetOwner()->FindComponentByClass<UCharacterMovementComponent>();
Movement->MaxWalkSpeed = Config.MovementProperties.MaxWalkSpeed;
Movement->bOrientRotationToMovement = Config.MovementProperties.bOrientRotationToMovement;
Movement->bUseControllerDesiredRotation = Config.MovementProperties.bUseControllerDesiredRotation;

// 播放装备动画(Upper Body Slot)
PlayMontage(Config.EquipMontage, "UpperBody");

// 显示武器(已经通过Attach切换插槽)
Weapon->SetActorHiddenInGame(false);
}

void UWeaponsManagerComponent::SetUnarmedWeaponConfig()
{
// 恢复默认动画
USkeletalMeshComponent* Mesh = GetOwner()->FindComponentByClass<USkeletalMeshComponent>();
Mesh->SetAnimInstanceClass(DefaultAnimClass);

// 恢复默认移动速度
UCharacterMovementComponent* Movement = GetOwner()->FindComponentByClass<UCharacterMovementComponent>();
Movement->MaxWalkSpeed = DefaultWalkSpeed;
Movement->bOrientRotationToMovement = true;
Movement->bUseControllerDesiredRotation = false;
}

🎮 四、武器发放逻辑

4.1 BeginPlay中发放

1
2
3
4
5
6
7
8
9
10
void UWeaponsManagerComponent::BeginPlay()
{
Super::BeginPlay();

// 仅服务器执行
if (GetOwner()->HasAuthority())
{
GiveStartingWeapons();
}
}

4.2 处理无效复制

1
2
3
4
5
6
7
8
9
10
11
12
13
// OnRep中处理装备武器为空的情况
void UWeaponsManagerComponent::OnRep_EquipWeapon()
{
if (EquipWeapon)
{
SetEquipWeaponProperties(EquipWeapon);
}
else
{
// 复制的装备武器无效(已卸下)
SetUnarmedWeaponConfig();
}
}

🔄 五、完整工作流程示例

5.1 装备斧头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
服务器:
1. 按键1 → Server_EquipWeapon(AxeClass)
2. 从背部找到斧头实例
3. PreviouslyEquippedWeapon = 当前武器(可能是法杖)
4. EquipWeapon = 斧头(触发复制)

客户端A(装备者):
1. OnRep_EquipWeapon触发
2. 斧头有效 → SetEquipWeaponProperties
- 切换动画到ABP_Axe
- 移动速度调整为250
- 播放装备Montage
- 斧头从背部移动到手中

客户端B(其他玩家):
1. 收到EquipWeapon复制
2. OnRep触发
3. 看到斧头在手中,动画同步

5.2 卸下武器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
服务器:
1. 按键1(已装备斧头)→ Server_UnequipWeapon
2. PreviouslyEquippedWeapon = 斧头
3. EquipWeapon = nullptr(触发复制)

客户端A:
1. OnRep_EquipWeapon触发(武器为空)
2. SetUnarmedWeaponConfig
- 恢复默认动画ABP_Unarmed
- 恢复默认移动速度600
- 播放收起Montage
- 斧头从手中回到背部

客户端B:
1. 看到斧头回到背部,动画同步

📌 六、本集核心八股

6.1 复制变量设计原则

变量 复制策略 原因
EquipWeapon RepNotify 状态变化需要客户端响应(换动画/移动速度)
PreviouslyEquippedWeapon 普通复制 仅保存引用,无需响应
StowedWeapons 普通复制 列表同步,UI可能需要

6.2 事件拆分模式

1
2
3
服务器:处理状态变更(生成/销毁/设置变量)
双端:处理表现(动画/移动/特效)
优势:状态一致,表现同步,避免重复执行

6.3 为什么武器始终存在?

  • ✅ 支持收起动画
  • ✅ 保存武器状态(耐久/充能)
  • ✅ 避免频繁Spawn/Destroy
  • ✅ 网络同步更简单

6.4 Authority判断位置

1
2
3
4
5
6
7
8
9
10
11
// ❌ 错误:在GiveStartingWeapons内部判断
void GiveStartingWeapons()
{
if (!HasAuthority()) return; // 太晚,函数已被调用
}

// ✅ 正确:在调用前判断
if (HasAuthority())
{
GiveStartingWeapons();
}

✅ 七、验收清单

  • 武器不再销毁,改为切换插槽
  • EquipWeapon用RepNotify,Previous用普通复制
  • 实现SetEquipWeaponProperties(双端执行)
  • 实现SetUnarmedWeaponConfig(双端执行)
  • 服务器只执行生成/设置变量
  • BeginPlay中发放武器加Authority判断
  • OnRep处理武器为空的情况
  • 测试装备/卸下在多客户端同步

Part 8: Granting Abilities the Right Way

🎯 核心目标

构建集中式能力管理系统,实现能力的批量赋予/移除、网络同步和UI更新,扩展GAS核心类以满足项目定制需求


📦 一、集中式能力管理函数

1.1 批量赋予能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// NexusCharacterBase.h
UFUNCTION(BlueprintCallable, Category = "GAS")
TArray<FGameplayAbilitySpecHandle> GrantAbilities(TArray<TSubclassOf<UGameplayAbility>> AbilitiesToGrant);

// NexusCharacterBase.cpp
TArray<FGameplayAbilitySpecHandle> ANexusCharacterBase::GrantAbilities(TArray<TSubclassOf<UGameplayAbility>> AbilitiesToGrant)
{
TArray<FGameplayAbilitySpecHandle> GrantedHandles;

if (!HasAuthority() || !AbilitySystemComponent) // 仅服务器执行
return GrantedHandles;

for (auto AbilityClass : AbilitiesToGrant)
{
if (!AbilityClass) continue;

FGameplayAbilitySpec Spec(AbilityClass, 1, -1, this); // 等级1,无输入ID
FGameplayAbilitySpecHandle Handle = AbilitySystemComponent->GiveAbility(Spec);
GrantedHandles.Add(Handle);
}

// 触发UI更新事件
SendAbilitiesChangedEvent();

return GrantedHandles;
}

1.2 批量移除能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ANexusCharacterBase::RemoveAbilities(TArray<FGameplayAbilitySpecHandle> HandlesToRemove)
{
if (!HasAuthority() || !AbilitySystemComponent)
return;

for (auto Handle : HandlesToRemove)
{
if (Handle.IsValid())
{
AbilitySystemComponent->ClearAbility(Handle);
}
}

SendAbilitiesChangedEvent();
}

面试八股:为什么要集中管理?→

  • 避免在多个地方重复写GiveAbility逻辑
  • 统一处理权限验证
  • 集中发送UI更新事件

🎬 二、起始能力管理

2.1 配置数组

1
2
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "GAS")
TArray<TSubclassOf<UGameplayAbility>> StartingAbilities;

2.2 在PossessedBy中赋予

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ANexusCharacterBase::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);

if (AbilitySystemComponent)
{
AbilitySystemComponent->InitAbilityActorInfo(this, this);

// 赋予起始能力(仅服务器)
if (HasAuthority())
{
GrantAbilities(StartingAbilities);
}
}
}

面试八股:为什么用PossessedBy而不是BeginPlay?→ PossessedBy只在服务器和拥有者控制器上调用,避免所有客户端都执行导致的多余调用。


📡 三、UI事件同步

3.1 发送能力变化事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void ANexusCharacterBase::SendAbilitiesChangedEvent()
{
if (!AbilitySystemComponent) return;

FGameplayEventData Payload;
Payload.Instigator = this;
Payload.Target = this;

// 发送自定义Gameplay Event
AbilitySystemComponent->HandleGameplayEvent(
FGameplayTag::RequestGameplayTag(FName("event.abilities.changed")),
&Payload
);
}

3.2 UI监听事件

1
2
3
4
5
6
7
8
9
// UI中
Event Construct:
1. 绑定到"event.abilities.changed"
2. 触发时调用FillAbilitiesBar()

FillAbilitiesBar:
1. 清空所有能力槽
2. 获取所有能力Spec Handle
3. 为每个能力创建AbilityWidget

🔄 四、客户端同步问题

4.1 问题分析

1
2
3
4
5
6
7
现状:
- GrantAbilities只在服务器执行
- 客户端收不到能力变化事件
- UI无法更新

尝试方案:
- Multicast事件?→ 客户端能力状态未同步,事件提前触发

4.2 根本原因

客户端能力状态同步完成前,事件触发时UI读取不到正确数据。

4.3 解决方案:监听OnRep_ActivatableAbilities

Step 1: 自定义ASC

1
2
3
4
5
6
7
8
9
10
11
UCLASS()
class UNexusAbilitySystemComponent : public UAbilitySystemComponent
{
GENERATED_BODY()

public:
// 记录上一次的能力列表
TArray<FGameplayAbilitySpec> LastActivatableAbilities;

virtual void OnRep_ActivatableAbilities() override;
};

Step 2: 重写OnRep

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void UNexusAbilitySystemComponent::OnRep_ActivatableAbilities()
{
Super::OnRep_ActivatableAbilities();

// 获取拥有者角色
ANexusCharacterBase* OwnerCharacter = Cast<ANexusCharacterBase>(GetOwnerActor());
if (!OwnerCharacter) return;

// 比较当前和上一次的能力列表是否有变化
if (HasAbilitiesChanged())
{
OwnerCharacter->SendAbilitiesChangedEvent();
UpdateLastAbilities();
}
}

Step 3: 比较能力列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool UNexusAbilitySystemComponent::HasAbilitiesChanged()
{
// 获取当前能力列表
const TArray<FGameplayAbilitySpec>& CurrentSpecs = GetActivatableAbilities();

if (CurrentSpecs.Num() != LastActivatableAbilities.Num())
return true;

// 比较每个能力(简略版,实际需比较Spec Handle)
for (int32 i = 0; i < CurrentSpecs.Num(); i++)
{
if (CurrentSpecs[i].Handle != LastActivatableAbilities[i].Handle)
return true;
}

return false;
}

面试八股:为什么监听OnRep_ActivatableAbilities而不是直接发事件?→

  • OnRep保证客户端能力状态已同步
  • 避免UI读取到空数据
  • GAS内置的同步机制

🎨 五、能力显示筛选

5.1 自定义基础能力类

1
2
3
4
5
6
7
8
9
10
UCLASS()
class UNexusGameplayAbility : public UGameplayAbility
{
GENERATED_BODY()

public:
// 是否显示在能力栏
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
bool bShouldShowInAbilitiesBar = false;
};

5.2 UI筛选逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取应显示的能力
TArray<FGameplayAbilitySpecHandle> GetVisibleAbilities()
{
TArray<FGameplayAbilitySpecHandle> VisibleHandles;

if (!AbilitySystemComponent) return VisibleHandles;

for (const FGameplayAbilitySpec& Spec : AbilitySystemComponent->GetActivatableAbilities())
{
UNexusGameplayAbility* Ability = Cast<UNexusGameplayAbility>(Spec.Ability);
if (Ability && Ability->bShouldShowInAbilitiesBar)
{
VisibleHandles.Add(Spec.Handle);
}
}

return VisibleHandles;
}

面试八股:为什么要加显示筛选?→ 避免装备武器、被动技能等辅助能力显示在UI中,只展示玩家需要主动使用的技能。


📌 六、本集核心八股

6.1 能力管理架构

1
2
3
角色类:GrantAbilities/RemoveAbilities(服务器执行)
ASC:OnRep_ActivatableAbilities(客户端同步)
UI:监听事件 → 刷新显示

6.2 网络同步流程

1
2
3
服务器:GiveAbility → 复制到客户端
客户端:OnRep_ActivatableAbilities触发 → 发送事件
UI:收到事件 → 重新加载能力

6.3 为什么需要自定义ASC?

  • 重写OnRep获取能力同步完成时机
  • 存储上一次能力列表用于比较
  • 避免频繁刷新UI

6.4 能力显示筛选设计

1
2
3
基类:bShouldShowInAbilitiesBar(默认false)
子类:主动技能设为true
UI:只显示true的能力

✅ 七、验收清单

  • GrantAbilities/RemoveAbilities函数实现
  • StartingAbilities数组配置
  • PossessedBy中赋予起始能力
  • 发送event.abilities.changed事件
  • UI监听事件并刷新
  • 自定义ASC并重写OnRep_ActivatableAbilities
  • 能力列表比较逻辑
  • UNexusGameplayAbility添加显示开关
  • UI筛选显示能力

Part 9: Melee Attack Ability

🎯 核心目标

实现第一个近战攻击能力,掌握动画、能力和武器三方协作的模块化设计,实现可复用的伤害判定系统


🎬 一、动画资源准备

1.1 动画蒙太奇配置

1
2
3
4
montage_melee_attack_axe_swing
├── 动画片段:斧头挥砍
├── Niagara特效:挥斧轨迹(Trail Effect)
└── 动画通知:hit_scan_window(伤害判定窗口)

1.2 动画通知状态(Anim Notify State)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 自定义Notify State
UCLASS()
class UANS_HitScanWindow : public UAnimNotifyState
{
GENERATED_BODY()

virtual void NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration) override
{
// 发送开始事件
SendGameplayEvent(MeshComp->GetOwner(), "hit_scan.start");
}

virtual void NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation) override
{
// 发送结束事件
SendGameplayEvent(MeshComp->GetOwner(), "hit_scan.end");
}
};

面试八股:为什么用Notify State而不是两个独立的Notify?→ 确保开始和结束成对出现,便于管理伤害判定窗口的持续时间。


⚔️ 二、近战攻击能力蓝图

2.1 能力配置

1
2
3
4
5
类:GA_MeleeAttack_AxeSwing
父类:UNexusGameplayAbility
实例化策略:Instance Per Actor
标签:melee_attack_swing
显示开关:bShouldShowInAbilitiesBar = true

2.2 激活逻辑

1
2
3
4
5
6
7
8
9
10
11
void UGA_MeleeAttack_AxeSwing::ActivateAbility()
{
// 播放动画蒙太奇
UAnimMontage* Montage = GetMontage(); // 从配置获取
PlayMontageAndWait(Montage);

// 等待动画通知事件
FGameplayEventData Payload;
WaitForGameplayEvent("hit_scan.start", OnHitScanStart);
WaitForGameplayEvent("hit_scan.end", OnHitScanEnd);
}

2.3 事件响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void OnHitScanStart()
{
// 获取当前装备武器
AWeaponBase* Weapon = GetEquippedWeapon();
if (Weapon)
{
// 调用武器开始伤害判定
Weapon->StartHitScan();
}
}

void OnHitScanEnd()
{
AWeaponBase* Weapon = GetEquippedWeapon();
if (Weapon)
{
Weapon->EndHitScan();
}
}

面试八股:为什么能力不直接执行伤害判定?→ 武器负责判定(范围、形状),能力负责效果类型(伤害值、特效),动画负责时机,三者解耦,便于扩展。


🗡️ 三、武器类伤害判定

3.1 判定组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UCLASS()
class AWeaponBase : public AActor
{
// 判定起点和终点(可在子类调整位置)
UPROPERTY(VisibleAnywhere)
USceneComponent* TraceStart;

UPROPERTY(VisibleAnywhere)
USceneComponent* TraceEnd;

// 已命中目标(防止重复伤害)
TArray<AActor*> HitActors;

// 定时器句柄
FTimerHandle HitScanTimerHandle;
};

3.2 核心判定函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
void AWeaponBase::HitScan()
{
// 多球形检测
TArray<FHitResult> HitResults;
UKismetSystemLibrary::SphereTraceMulti(
GetWorld(),
TraceStart->GetComponentLocation(),
TraceEnd->GetComponentLocation(),
TraceRadius, // 判定半径
UEngineTypes::ConvertToTraceType(ECC_Pawn),
false,
IgnoreActors, // 忽略自身和队友
EDrawDebugTrace::None,
HitResults,
true
);

// 处理每个命中目标
for (auto& Hit : HitResults)
{
AActor* HitActor = Hit.GetActor();

// 防止重复伤害
if (HitActors.Contains(HitActor))
continue;

HitActors.Add(HitActor);

// 应用伤害
ApplyDamage(HitActor);
}
}

void AWeaponBase::StartHitScan()
{
// 清空上一轮的命中记录
HitActors.Empty();

// 启动定时器(每秒30次)
GetWorld()->GetTimerManager().SetTimer(
HitScanTimerHandle,
this,
&AWeaponBase::HitScan,
1.0f / 30.0f,
true
);
}

void AWeaponBase::EndHitScan()
{
// 停止定时器
GetWorld()->GetTimerManager().ClearTimer(HitScanTimerHandle);
}

面试八股:为什么要用定时器而不是单次检测?→ 动画窗口可能持续多帧,定时器保证在窗口期内持续检测,避免漏掉快速移动的目标。


💥 四、伤害效果设计

4.1 即时伤害效果(GE_Damage_Instant)

1
2
3
4
5
类型:Instant
Modifier:
- 属性:Health
- 操作:Add
- 数值:Set By Caller(data.damage)

4.2 应用伤害

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void AWeaponBase::ApplyDamage(AActor* Target)
{
UAbilitySystemComponent* TargetASC = Target->FindComponentByClass<UAbilitySystemComponent>();
if (!TargetASC) return;

// 创建伤害效果Spec
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(GE_Damage_Instant);

// 设置伤害值(从能力传入)
SpecHandle.Data->SetSetByCallerMagnitude(
FGameplayTag::RequestGameplayTag(FName("data.damage")),
DamageAmount
);

// 应用伤害
TargetASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
}

🔧 五、Bug修复

5.1 攻击中断问题

1
2
3
4
现象:攻击被强制中断时,hit_scan.end事件不触发,伤害判定持续
原因:动画提前结束,Notify State未执行End

解决方案:
1
2
3
4
5
6
7
8
9
10
11
void UGA_MeleeAttack_AxeSwing::EndAbility()
{
// 强制结束伤害判定
AWeaponBase* Weapon = GetEquippedWeapon();
if (Weapon)
{
Weapon->EndHitScan();
}

Super::EndAbility();
}

面试八股:为什么在EndAbility中也要调用EndHitScan?→ 作为兜底,确保任何情况下伤害判定都会停止,防止技能卡死。


📌 六、本集核心八股

6.1 近战攻击的三层架构

1
2
3
动画层:控制伤害时机(Notify State)
武器层:执行伤害判定(Trace + Timer)
能力层:定义伤害效果(GE + SetByCaller)

6.2 伤害判定设计要点

  • 多球形检测:覆盖武器轨迹
  • 重复伤害防止:HitActors数组记录
  • 持续检测:定时器驱动
  • 位置可调:TraceStart/TraceEnd组件

6.3 为什么武器类负责判定?

原因 说明
尺寸不同 斧头和法杖攻击范围不同
位置可变 可在子类调整Trace组件
复用性 所有近战武器共用一套判定逻辑

6.4 动画通知的作用

  • 精准控制伤害时机
  • 与动画同步,避免伤害早于/晚于动画
  • 支持多段攻击(多个窗口)

✅ 七、验收清单

  • 创建斧头挥砍动画蒙太奇
  • 实现HitScanWindow Notify State
  • GA_MeleeAttack_AxeSwing蓝图配置
  • PlayMontageAndWait + WaitForGameplayEvent
  • 武器类添加TraceStart/TraceEnd组件
  • 实现HitScan(多球形检测)
  • StartHitScan(定时器)和EndHitScan
  • HitActors数组防止重复伤害
  • 创建GE_Damage_Instant(SetByCaller)
  • EndAbility中兜底结束判定

Part 10: Combo Attack (Modular Ability)

🎯 核心目标

设计可配置、可复用的模块化能力系统,通过基础类继承和事件驱动,实现三连击组合技


🧱 一、模块化基础能力设计

1.1 提取通用逻辑

将斧头挥砍能力中的通用逻辑提取到基础近战攻击类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
UCLASS()
class UMeleeAttackBase : public UNexusGameplayAbility
{
GENERATED_BODY()

protected:
// 可配置变量
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
UAnimMontage* AttackMontage;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TSubclassOf<UGameplayEffect> DamageEffectClass;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
float DamageAmount;

// 事件封装
UFUNCTION(BlueprintImplementableEvent)
void OnMontageStarted();

UFUNCTION(BlueprintImplementableEvent)
void OnHitScanStart();

UFUNCTION(BlueprintImplementableEvent)
void OnHitScanEnd();

virtual void ActivateAbility() override
{
// 播放蒙太奇
PlayMontageAndWait(AttackMontage);

// 等待事件
WaitForGameplayEvent("hit_scan.start", OnHitScanStart);
WaitForGameplayEvent("hit_scan.end", OnHitScanEnd);

// 钩子函数
OnMontageStarted();
}
};

面试八股:为什么要提取基础类?→ 一次构建,所有近战技能复用,后续只需配置变量和重写事件。


🔨 二、子类继承实现

2.1 单次斧击能力

1
2
3
4
5
6
GA_MeleeAttack_AxeSwing
继承自:UMeleeAttackBase
配置:
- AttackMontage:montage_axe_swing
- DamageEffectClass:GE_Damage_Instant
- DamageAmount:33

2.2 三连击能力

1
2
3
4
5
6
GA_Combo_Axe
继承自:UMeleeAttackBase
配置:
- AttackMontage:montage_axe_combo(三连击动画)
- DamageEffectClass:GE_Damage_Instant
- DamageAmount:25(基础值,连击递增)

🔄 三、连击机制实现

3.1 组合窗口动画通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 自定义Notify State:ContinueComboWindow
UCLASS()
class UANS_ContinueComboWindow : public UAnimNotifyState
{
virtual void NotifyBegin(...) override
{
SendGameplayEvent(Owner, "combo.window.start");
}

virtual void NotifyEnd(...) override
{
SendGameplayEvent(Owner, "combo.window.end");
}
};

3.2 连击能力变量

1
2
3
4
5
6
7
UCLASS()
class UGA_Combo_Axe : public UMeleeAttackBase
{
int32 ComboCount = 0; // 当前连击数
bool bInComboWindow = false; // 是否在组合窗口内
bool bComboInputReceived = false; // 是否收到连击输入
};

3.3 重写事件监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void UGA_Combo_Axe::OnMontageStarted() override
{
// 监听组合窗口事件
WaitForGameplayEvent("combo.window.start", OnComboWindowStart);
WaitForGameplayEvent("combo.window.end", OnComboWindowEnd);
// 监听连击输入事件
WaitForGameplayEvent("continue.combo.input", OnComboInput);
}

void UGA_Combo_Axe::OnComboWindowStart()
{
bInComboWindow = true;
}

void UGA_Combo_Axe::OnComboWindowEnd()
{
bInComboWindow = false;

if (!bComboInputReceived)
{
// 窗口内没有输入,结束连击
EndAbility();
}
}

void UGA_Combo_Axe::OnComboInput()
{
bComboInputReceived = true;

if (bInComboWindow)
{
// 窗口内收到输入,继续连击
ComboCount++;
// 继续播放下一段动画(Montage支持分段播放)
PlayMontageSection(ComboCount);
}
}

面试八股:连击窗口的作用?→ 防止玩家乱按,只有在特定时间段内输入才有效,提升操作手感。


🌐 四、网络同步

4.1 问题:客户端输入同步

客户端点击左键,需要在服务器端执行连击逻辑

4.2 解决方案:RPC事件传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 基础角色类中添加ServerRPC函数
UFUNCTION(Server, Reliable)
void Server_SendGameplayEvent(FGameplayTag EventTag);

void ANexusCharacterBase::Server_SendGameplayEvent_Implementation(FGameplayTag EventTag)
{
// 服务器端发送事件到自己的ASC
if (AbilitySystemComponent)
{
FGameplayEventData Payload;
Payload.Instigator = this;
Payload.Target = this;
AbilitySystemComponent->HandleGameplayEvent(EventTag, &Payload);
}
}
1
2
3
// 玩家蓝图中调用
Input_Combo按键按下:
Server_SendGameplayEvent("continue.combo.input")

面试八股:为什么用RPC而不是直接调用?→ 输入发生在客户端,但连击逻辑需要在服务器执行,RPC确保服务器收到输入事件。


📈 五、伤害递增扩展

5.1 重写伤害判定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void UGA_Combo_Axe::OnHitScanStart() override
{
// 根据连击数计算伤害倍率
float ComboMultiplier = 1.0f;
switch (ComboCount)
{
case 1: ComboMultiplier = 1.0f; break; // 第一击
case 2: ComboMultiplier = 1.5f; break; // 第二击
case 3: ComboMultiplier = 2.0f; break; // 第三击
}

float FinalDamage = DamageAmount * ComboMultiplier;

// 调用父类逻辑(实际应用伤害)
Super::OnHitScanStart(FinalDamage); // 需修改父类支持传参
}

📌 六、本集核心八股

6.1 模块化能力设计模式

1
2
3
4
5
6
7
8
9
基础类(MeleeAttackBase)
├── 通用逻辑:播放动画、等待事件
├── 可配置变量:Montage、Effect、伤害值
└── 钩子事件:OnMontageStarted、OnHitScanStart

子类(Combo_Axe)
├── 重写钩子函数
├── 添加连击专用变量
└── 扩展伤害计算

6.2 连击系统核心要素

要素 实现方式 作用
连击窗口 AnimNotifyState 控制输入有效时机
连击计数 能力内变量 记录当前连击段数
输入检测 RPC事件 客户端→服务器同步
伤害递增 重写HitScan 连击数影响伤害

6.3 为什么用事件驱动连击?

  • 解耦:动画和逻辑分离
  • 网络友好:事件可RPC传递
  • 扩展性:新增连击类型只需监听不同事件

6.4 钩子函数设计原则

  • 在关键节点提供空白事件
  • 子类按需重写
  • 默认行为保留在父类

✅ 七、验收清单

  • 创建MeleeAttackBase基础类
  • 提取通用逻辑到基础类
  • 将AxeSwing改为继承基础类
  • 创建Combo_Axe能力
  • 实现ContinueComboWindow NotifyState
  • 监听combo.window.start/end事件
  • 实现RPC事件传递(Server_SendGameplayEvent)
  • 连击计数和窗口判断逻辑
  • 伤害递增扩展
  • 测试三连击完整流程

Part 10.5: Fixing Attack Bugs

🎯 核心目标

修复多人游戏中动画通知窗口(Anim Notify State)导致的同步bug,通过拆分为独立Notify解决客户端重复触发问题


🐛 一、问题发现

1.1 问题表现

1
2
3
4
5
6
现象:
- 关闭Root Motion后,攻击的Hit Scan只在客户端执行
- 服务器端不触发伤害判定
- 客户端重复触发开始/结束事件多次

预期:服务器权威执行伤害,客户端同步表现

1.2 根本原因

1
2
3
4
5
6
7
8
// 动画蒙太奇在多人环境中的同步机制
1. 服务器播放Montage → 同步进度给客户端
2. 客户端接收到进度更新 → 视为动画重新开始
3. Notify State(带开始/结束)→ 每次更新都重复触发
4. 结果:客户端多次触发HitScanStart/HitScanEnd

// Root Motion开启时
客户端基于服务器权威数据播放,不进行预测 → 问题消失

面试八股:为什么Root Motion开启时没问题?→ 开启Root Motion后,客户端完全信任服务器同步的动画位置,不进行本地预测,避免重复触发。


🔧 二、解决方案:拆分Notify

2.1 创建独立Notify

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// HitScanStart(单帧触发)
UCLASS()
class UANS_HitScanStart : public UAnimNotify
{
virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation) override
{
SendGameplayEvent(MeshComp->GetOwner(), "hit_scan.start");
}
};

// HitScanEnd(单帧触发)
UCLASS()
class UANS_HitScanEnd : public UAnimNotify
{
virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation) override
{
SendGameplayEvent(MeshComp->GetOwner(), "hit_scan.end");
}
};

2.2 替换动画蒙太奇

1
2
3
4
5
6
❌ 之前:
[HitScanWindow] ← Notify State(持续一段时间)

✅ 之后:
[HitScanStart] [HitScanEnd]
时间点1 时间点2

面试八股:为什么普通Notify不会重复触发?→ 普通Notify只在特定帧触发一次,不受动画进度同步更新影响。


🔄 三、连击系统同样修复

3.1 连击窗口拆分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ContinueComboStart
UCLASS()
class UANS_ContinueComboStart : public UAnimNotify
{
virtual void Notify(...) override
{
SendGameplayEvent(Owner, "combo.window.start");
}
};

// ContinueComboEnd
UCLASS()
class UANS_ContinueComboEnd : public UAnimNotify
{
virtual void Notify(...) override
{
SendGameplayEvent(Owner, "combo.window.end");
}
};

3.2 更新连击动画蒙太奇

1
2
3
4
5
6
❌ 之前:
[ContinueComboWindow] ← Notify State

✅ 之后:
[ContinueComboStart] [ContinueComboEnd]
连击窗口开始 连击窗口结束

✅ 四、验证结果

4.1 测试场景

场景 Root Motion关闭 Root Motion开启
修复前 ❌ 客户端重复触发 ✅ 正常
修复后 ✅ 服务器权威执行 ✅ 正常

4.2 最终效果

  • 服务器端Hit Scan事件正常触发
  • 客户端不会重复触发
  • 伤害判定由服务器权威执行
  • 所有客户端表现同步

📌 五、本集核心八股

5.1 Notify State vs Notify对比

类型 触发方式 多人环境问题 适用场景
Notify State 开始+结束,持续 客户端重复触发 单机/不依赖精确时机
Notify 单帧触发一次 无重复问题 多人游戏/需要精确控制

5.2 根本原因总结

1
服务器同步动画进度 → 客户端更新动画 → Notify State视为重新开始 → 重复触发

5.3 解决方案核心

1
2
3
用两个独立Notify替代一个Notify State
开始和结束分别在不同帧触发
客户端进度更新不会导致重复

5.4 最佳实践

  • 多人游戏中避免使用Anim Notify State
  • 优先使用普通Anim Notify(单帧触发)
  • 通过成对Notify模拟开始/结束逻辑
  • 充分测试Root Motion开关两种状态

✅ 六、验收清单

  • 创建HitScanStart/HitScanEnd独立Notify
  • 替换斧头攻击动画中的Notify State
  • 创建ContinueComboStart/ContinueComboEnd
  • 替换连击动画中的Notify State
  • 删除所有Notify State轨道
  • 测试Root Motion关闭状态(服务器权威)
  • 测试Root Motion开启状态(正常)
  • 确认客户端无重复触发

Part 11: PROJECTILES

🎯 核心目标

实现可配置、支持多玩家同步的弹道投射物系统,掌握投射物Actor设计、目标锁定、动画事件触发和网络同步


🧱 一、投射物基础类设计

1.1 Actor组件配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
UCLASS()
class AProjectileBase : public AActor
{
GENERATED_BODY()

UPROPERTY(VisibleAnywhere)
USphereComponent* CollisionComp; // 碰撞体

UPROPERTY(VisibleAnywhere)
UProjectileMovementComponent* MovementComp; // 弹道运动组件

UPROPERTY(VisibleAnywhere)
UParticleSystemComponent* ParticleComp; // 粒子特效

AProjectileBase()
{
// 碰撞设置
CollisionComp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
CollisionComp->SetCollisionObjectType(ECC_WorldDynamic);
CollisionComp->SetCollisionResponseToAllChannels(ECR_Overlap);
CollisionComp->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block);
CollisionComp->SetCollisionResponseToChannel(ECC_WorldDynamic, ECR_Block);

// 弹道运动组件配置
MovementComp->InitialSpeed = 2000.0f;
MovementComp->MaxSpeed = 2000.0f;
MovementComp->bRotationFollowsVelocity = true;
MovementComp->ProjectileGravityScale = 0.0f; // 无重力
MovementComp->bAutoActivate = false; // 手动激活

// 网络同步
bReplicates = true;
bReplicateMovement = true;
}
};

面试八股:为什么ProjectileMovementComponent要手动激活?→ 需要在设置速度和方向后再激活,避免生成瞬间就朝默认方向飞去。


📦 二、投射物核心变量

2.1 变量定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 可配置变量(实例可编辑)
UPROPERTY(Replicated, BlueprintReadWrite)
float Speed; // 弹道速度

UPROPERTY(Replicated)
FVector TargetLocation; // 目标位置

UPROPERTY()
FGameplayEffectSpecHandle DamageEffectSpecHandle; // 伤害效果句柄

// Gameplay Cue(子类覆盖)
UPROPERTY(EditDefaultsOnly)
TSubclassOf<UGameplayCue> SpawnCueClass; // 生成时Cue

UPROPERTY(EditDefaultsOnly)
TSubclassOf<UGameplayCue> ImpactCueClass; // 命中时Cue

2.2 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void AProjectileBase::InitProjectile(FVector InTargetLocation, float InSpeed, FGameplayEffectSpecHandle InEffectHandle)
{
TargetLocation = InTargetLocation;
Speed = InSpeed;
DamageEffectSpecHandle = InEffectHandle;

// 计算方向
FVector Direction = (TargetLocation - GetActorLocation()).GetSafeNormal();

// 设置速度
MovementComp->Velocity = Direction * Speed;
MovementComp->Activate();

// 播放生成Cue
if (SpawnCueClass)
{
FGameplayCueParameters CueParams;
CueParams.Location = GetActorLocation();
GetInstigator()->GetAbilitySystemComponent()->ExecuteGameplayCue(SpawnCueClass, CueParams);
}
}

💥 三、碰撞事件处理

3.1 重叠检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void AProjectileBase::OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor)
{
// 忽略自身和队友
if (OtherActor == GetInstigator() || OtherActor == GetOwner())
return;

// 获取目标的ASC
UAbilitySystemComponent* TargetASC = OtherActor->FindComponentByClass<UAbilitySystemComponent>();
if (!TargetASC) return;

// 应用伤害(仅服务器)
if (GetLocalRole() == ROLE_Authority)
{
TargetASC->ApplyGameplayEffectSpecToSelf(*DamageEffectSpecHandle.Data.Get());
}

// 播放命中Cue
if (ImpactCueClass)
{
FGameplayCueParameters CueParams;
CueParams.Location = GetActorLocation();
GetInstigator()->GetAbilitySystemComponent()->ExecuteGameplayCue(ImpactCueClass, CueParams);
}

// 销毁投射物
Destroy();
}

void AProjectileBase::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor)
{
// 撞到世界静态物体,只销毁不造成伤害
Destroy();
}

面试八股:为什么伤害应用要判断Role?→ 服务器是权威,负责伤害计算;客户端只负责表现,避免重复伤害或作弊。


🎯 四、目标锁定系统

4.1 使用GAS目标数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 能力中等待目标数据
void UGA_Projectile::ActivateAbility()
{
// 等待目标数据(地面射线)
WaitTargetData(
EGameplayTargetingConfirmation::UserConfirmed, // 用户确认
AGameplayAbilityTargetActor_GroundTrace::StaticClass()
);

// 目标数据返回时触发
OnTargetDataReady()
{
FVector TargetLocation = TargetData->GetHitResult()->Location;

// 生成投射物
SpawnProjectile(TargetLocation);
}
}

4.2 摄像机偏移优化

1
2
// 调整摄像机臂偏移,避免遮挡
CameraBoom->SocketOffset = FVector(0, 100, 0); // 右肩视角

4.3 武器插槽作为生成点

1
2
3
4
5
6
// 武器蓝图中定义生成点
UPROPERTY(VisibleAnywhere)
USceneComponent* ProjectileSpawnPoint;

// 能力中获取
FVector SpawnLocation = GetEquippedWeapon()->ProjectileSpawnPoint->GetComponentLocation();

面试八股:为什么用武器插槽而不是角色中心?→ 投射物应从武器口发出,更真实;不同武器位置不同,需要在子类调整。


🌐 五、多玩家同步

5.1 生成逻辑

1
2
3
4
5
6
7
8
9
10
11
12
// 能力中使用Gameplay Task生成
UGameplayTask_SpawnActor* SpawnTask = UGameplayTask_SpawnActor::SpawnActor(
this,
ProjectileClass,
SpawnLocation,
SpawnRotation
);

SpawnTask->SetActorParameter("DamageEffectSpecHandle", DamageEffectSpecHandle);
SpawnTask->SetFloatParameter("Speed", Speed);
SpawnTask->SetVectorParameter("TargetLocation", TargetLocation);
SpawnTask->ReadyForActivation();

面试八股:为什么用Gameplay Task而不是直接SpawnActor?→

  • Task保证只在服务器生成
  • 自动处理网络同步
  • 可与Ability生命周期绑定

5.2 变量复制

1
2
3
4
5
6
7
8
void AProjectileBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME(AProjectileBase, Speed);
DOREPLIFETIME(AProjectileBase, TargetLocation);
// 注意:EffectSpecHandle不需要复制,只在服务器使用
}

5.3 客户端表现

1
2
3
4
5
6
7
8
服务器生成投射物 → 复制到所有客户端
客户端收到后:
- 显示模型
- 播放生成特效(Gameplay Cue)
- 运动组件同步位置
命中时:
- 服务器执行伤害
- 所有客户端播放命中特效

📌 六、本集核心八股

6.1 投射物系统架构

1
2
3
数据层:Speed/TargetLocation/EffectSpecHandle(可配置)
逻辑层:碰撞检测/伤害应用(服务器权威)
表现层:Gameplay Cue(粒子/音效,全客户端)

6.2 网络同步策略

操作 执行者 同步方式
生成 服务器 Replication
移动 服务器 ReplicateMovement
伤害 服务器 直接应用
特效 服务器 Gameplay Cue自动同步

6.3 Gameplay Cue优势

  • 自动网络同步
  • 与逻辑解耦
  • 可配置/可扩展
  • 支持多种类型(Burst/Actor)

6.4 目标锁定流程

1
2
玩家瞄准 → 射线检测 → 返回目标位置 → 生成投射物
(目标位置复制到所有客户端,保证弹道一致)

✅ 七、验收清单

  • 投射物基类(碰撞+运动组件)
  • 变量配置(Speed/TargetLocation/EffectHandle)
  • InitProjectile初始化函数
  • OnOverlapBegin伤害处理(服务器判断)
  • OnHit处理静态物体
  • 生成和命中Gameplay Cue
  • 能力中使用WaitTargetData
  • 武器插槽作为生成点
  • Gameplay Task生成投射物
  • 变量复制配置
  • 测试多玩家同步

Part 11.5: Custom Camera Settings

🎯 核心目标

实现武器切换时动态调整摄像机设置(FOV/偏移)和十字准星显示,提升不同武器下的操作体验


📐 一、摄像机设置结构体

1.1 定义结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
USTRUCT(BlueprintType)
struct FCameraSettings
{
GENERATED_BODY()

// 摄像机视野
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float FieldOfView = 90.0f;

// 摄像机臂偏移
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FVector CameraBoomOffset = FVector(0, 0, 0);

// 是否覆盖默认设置
UPROPERTY(EditAnywhere, BlueprintReadOnly)
bool bShouldOverride = false;

// 是否显示十字准星
UPROPERTY(EditAnywhere, BlueprintReadOnly)
bool bShouldShowCrosshair = false;
};

1.2 武器配置中集成

1
2
3
4
5
6
7
8
USTRUCT(BlueprintType)
struct FRS_WeaponConfig
{
// ...原有配置

UPROPERTY(EditAnywhere, BlueprintReadOnly)
FCameraSettings CameraSettings;
};

📡 二、事件分发机制

2.1 武器管理器添加事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
UCLASS()
class UWeaponsManagerComponent : public UActorComponent
{
GENERATED_BODY()

public:
// 武器切换事件分发器
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnWeaponChanged, ABP_WeaponBase*, NewWeapon);

UPROPERTY(BlueprintAssignable)
FOnWeaponChanged OnWeaponChanged;

void EquipWeapon(TSubclassOf<ABP_WeaponBase> WeaponClass)
{
// ...装备逻辑

// 广播事件
OnWeaponChanged.Broadcast(CurrentWeapon);
}

void UnequipWeapon()
{
// ...卸下逻辑

// 广播事件(空武器表示卸下)
OnWeaponChanged.Broadcast(nullptr);
}
};

面试八股:为什么用事件分发器而不是直接调用?→ 解耦,武器管理器只负责武器状态,不关心谁监听;玩家角色、UI等可独立监听响应。


🎥 三、玩家角色摄像机管理

3.1 变量定义

1
2
3
4
5
6
7
8
9
10
11
12
UCLASS()
class ANexusPlayer : public ACharacter
{
// 默认摄像机设置
FCameraSettings DefaultCameraSettings;

// 当前目标设置
FCameraSettings TargetCameraSettings;

// 插值时间轴
FTimeline CameraTimeline;
};

3.2 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ANexusPlayer::BeginPlay()
{
Super::BeginPlay();

// 保存默认设置
DefaultCameraSettings.FieldOfView = CameraComponent->FieldOfView;
DefaultCameraSettings.CameraBoomOffset = CameraBoom->SocketOffset;

// 绑定武器切换事件
UWeaponsManagerComponent* WeaponsManager = FindComponentByClass<UWeaponsManagerComponent>();
if (WeaponsManager)
{
WeaponsManager->OnWeaponChanged.AddDynamic(this, &ANexusPlayer::OnWeaponChanged);
}

// 初始化时间轴
SetupCameraTimeline();
}

3.3 武器切换响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void ANexusPlayer::OnWeaponChanged(ABP_WeaponBase* NewWeapon)
{
if (NewWeapon)
{
FRS_WeaponConfig Config = NewWeapon->GetWeaponConfig();

if (Config.CameraSettings.bShouldOverride)
{
// 使用武器自定义设置
TargetCameraSettings = Config.CameraSettings;
}
else
{
// 恢复默认
TargetCameraSettings = DefaultCameraSettings;
}
}
else
{
// 卸下武器,恢复默认
TargetCameraSettings = DefaultCameraSettings;
}

// 启动插值
CameraTimeline.PlayFromStart();
}

🔄 四、摄像机参数插值

4.1 时间轴设置

1
2
3
4
5
6
7
8
9
10
11
void ANexusPlayer::SetupCameraTimeline()
{
// 创建曲线
UCurveFloat* Curve = LoadObject<UCurveFloat>(nullptr, TEXT("/Game/Curves/CameraLerpCurve"));

FOnTimelineFloat ProgressFunction;
ProgressFunction.BindUFunction(this, "UpdateCameraLerp");
CameraTimeline.AddInterpFloat(Curve, ProgressFunction);

CameraTimeline.SetTimelineLength(0.3f); // 0.3秒过渡
}

4.2 插值更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void ANexusPlayer::UpdateCameraLerp(float Value)
{
// 线性插值
float CurrentFOV = FMath::Lerp(
CameraComponent->FieldOfView,
TargetCameraSettings.FieldOfView,
Value
);

FVector CurrentOffset = FMath::Lerp(
CameraBoom->SocketOffset,
TargetCameraSettings.CameraBoomOffset,
Value
);

// 应用
CameraComponent->FieldOfView = CurrentFOV;
CameraBoom->SocketOffset = CurrentOffset;
}

面试八股:为什么要用时间轴插值而不是直接设置?→ 平滑过渡,避免视角突变,提升用户体验。


🎯 五、十字准星UI

5.1 十字准星控件(W_Crosshair)

1
2
3
4
5
6
7
8
结构:
┌─┐
│ │ 四个方向的线段
└─┘

动态调整:
- 根据玩家速度改变大小
- 移动时变大,静止时收缩

5.2 大小动态调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Tick中
void UW_Crosshair::UpdateSize()
{
APawn* OwnerPawn = GetOwningPlayerPawn();
if (!OwnerPawn) return;

float Speed = OwnerPawn->GetVelocity().Size();

// 速度归一化(0-1)
float NormalizedSpeed = FMath::GetMappedRangeValueClamped(
FVector2D(0, 600), // 最小-最大速度
FVector2D(0, 1), // 输出范围
Speed
);

// 大小范围:30-200
float TargetSize = FMath::Lerp(30.0f, 200.0f, NormalizedSpeed);

// 应用大小
SetRenderScale(FVector2D(TargetSize / 100.0f)); // 基础大小100
}

5.3 显示控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void ANexusPlayer::OnWeaponChanged(ABP_WeaponBase* NewWeapon)
{
// ...摄像机设置

// 控制十字准星显示
if (HUD)
{
bool bShowCrosshair = NewWeapon ?
NewWeapon->GetWeaponConfig().CameraSettings.bShouldShowCrosshair :
false;

HUD->SetCrosshairVisibility(bShowCrosshair);
}
}

📌 六、本集核心八股

6.1 摄像机管理架构

1
2
3
武器管理器:OnWeaponChanged事件
玩家角色:监听事件 → 设置目标参数 → 插值过渡
摄像机:实时更新FOV/偏移

6.2 插值参数设计

参数 作用 推荐值
时长 过渡平滑度 0.3秒
曲线 缓动效果 先快后慢
插值方式 计算 线性插值

6.3 十字准星设计要点

  • 速度关联:提升动态感
  • 武器配置:只有远程武器显示
  • 本地表现:只影响当前玩家

6.4 事件驱动优势

1
2
3
4
5
6
7
武器管理器(状态源)
↓ 广播事件
玩家角色(摄像机)
↓ 监听
UI系统(十字准星)
↓ 监听
各自独立响应,互不干扰

✅ 七、验收清单

  • FCameraSettings结构体定义
  • 武器配置中集成摄像机设置
  • 武器管理器添加OnWeaponChanged事件
  • 玩家角色监听事件
  • 保存默认摄像机设置
  • 实现摄像机插值(时间轴)
  • 武器切换时平滑过渡
  • 创建动态十字准星控件
  • 速度关联大小调整
  • 根据武器配置显示/隐藏准星

Part 12: Ability Tags

🎯 核心目标

掌握Gameplay Tags在技能系统中的核心应用,实现技能间的取消(Cancel)、阻挡(Block)和优先级管理


🏷️ 一、能力标签类型

1.1 五种核心标签

标签类型 作用 示例
Asset Tags 技能自身标识 ability.melee.axe
Cancel Abilities with Tag 激活时取消指定标签技能 Dash取消melee.axe
Block Abilities with Tag 激活时阻止指定标签技能激活 Dash阻挡projectile
Activation Owned Tags 激活时给角色添加的标签 dash.active
Required/Block Tags 激活条件检查 需要/阻止某些标签才能激活

面试八股:标签系统解决了什么问题?→ 技能间交互解耦,通过标签定义关系,避免硬编码技能类名。


⚡ 二、技能优先级实战

2.1 Dash技能配置(最高优先级)

1
2
3
4
Asset Tags: ability.movement.dash
Cancel Abilities with Tag: ability.melee.attack // 取消所有近战
Block Abilities with Tag: ability.shoot.projectile // 阻挡远程
Activation Owned Tags: ability.dash.active

2.2 斧头挥砍配置

1
2
3
Asset Tags: ability.melee.attack.axe
Cancel Abilities with Tag: (无)
Block Abilities with Tag: (无)

2.3 效果演示

1
2
3
4
5
6
7
8
场景1:斧头挥砍中 → 激活Dash
Dash取消斧头挥砍,优先执行

场景2:Dash中 → 尝试激活斧头挥砍
斧头挥砍被阻挡,无法激活

场景3:Dash中 → 尝试激活射击
射击被阻挡,无法激活

面试八股:为什么Dash能取消斧头但斧头不能取消Dash?→ 通过标签配置实现优先级,Dash的Cancel列表包含近战标签,斧头没有配置取消Dash的标签。


🔄 三、层级标签设计

3.1 标签层级结构

1
2
3
4
5
6
7
8
9
10
ability(根)
├── movement
│ └── dash
├── melee
│ ├── attack
│ │ ├── axe
│ │ └── staff
│ └── combo
└── shoot
└── projectile

3.2 批量控制优势

1
2
3
4
5
// 使用父级标签批量取消
Cancel Abilities with Tag: ability.melee // 取消所有近战技能

// 使用子级标签精确控制
Cancel Abilities with Tag: ability.melee.attack.axe // 只取消斧头

面试八股:为什么需要层级标签?→ 灵活控制粒度,上层标签批量处理,下层标签精确控制。


🎨 四、激活标签(Activation Owned Tags)

4.1 在UI中的应用

1
2
3
4
5
6
7
// 技能激活时
Activation Owned Tags: ability.melee.active

// UI监听
Wait for Tag Count Changed (ability.melee.active)
if (TagCount > 0)
ShowActiveFrame(GetCurrentAbility()); // 显示对应技能激活框

4.2 基类统一管理

1
2
3
4
5
6
7
8
9
UCLASS()
class UNexusGameplayAbility : public UGameplayAbility
{
UNexusGameplayAbility()
{
// 所有技能激活时都添加通用激活标签
ActivationOwnedTags.AddTag(FGameplayTag::RequestGameplayTag("ability.active"));
}
};

面试八股:为什么要在基类添加通用激活标签?→ 方便快速判断角色是否有任何技能处于激活状态,用于UI显示、状态检查等。


🚫 五、激活条件标签

5.1 标签类型

类型 作用 示例
Activation Required Tags 必须拥有这些标签才能激活 需要weapon.equipped
Activation Block Tags 拥有这些标签时无法激活 state.stunned时不能激活

5.2 实战:装备切换阻断

1
2
3
4
5
6
7
8
9
10
// 需求:技能激活时不能切换武器

// 1. 为装备技能添加标签
Asset Tags: ability.equip.weapon

// 2. 在近战技能中阻挡装备
Block Abilities with Tag: ability.equip.weapon

// 3. 在装备技能中添加激活阻挡
Activation Block Tags: ability.active // 有任何技能激活都不能装备

面试八股:为什么不用在近战技能中逐个添加阻挡?→ 只需在装备技能中配置一次激活阻挡,所有技能自动生效,更易维护。


🧠 六、本集核心八股

6.1 标签系统工作流程

1
2
3
4
5
6
技能激活
├── 检查Activation Required Tags(必须要有)
├── 检查Activation Block Tags(不能有)
├── 检查其他激活技能的Block列表(是否被阻挡)
├── 取消其他技能的Cancel列表中的技能
└── 添加Activation Owned Tags到角色

6.2 优先级设计模式

1
2
3
4
5
6
高优先级技能:
Cancel Abilities: [低优先级标签]
Block Abilities: [低优先级标签]

低优先级技能:
无配置

6.3 层级标签优势

粒度 示例 作用
粗粒度 ability.melee 批量控制所有近战
中粒度 ability.melee.attack 控制单次攻击
细粒度 ability.melee.attack.axe 控制特定武器

6.4 激活标签的三种用途

  • UI反馈(显示激活状态)
  • 状态查询(是否有技能激活)
  • 条件检查(Activation Block Tags)

✅ 七、验收清单

  • 理解五种标签类型的作用
  • Dash技能配置Cancel/Block标签
  • 斧头挥砍技能配置Asset Tags
  • 测试技能优先级(Dash最高)
  • 创建层级标签结构
  • 基类添加通用激活标签
  • UI监听激活标签显示状态
  • 装备技能添加激活阻挡
  • 验证技能激活时无法切换武器

Part 13: AOE Attack

🎯 核心目标

实现双阶段AOE范围攻击技能,掌握目标锁定、视觉指示器、多阶段动画和网络同步


🎬 一、技能整体设计

1.1 双阶段流程

1
2
3
4
5
6
7
8
9
10
11
第一阶段:目标锁定
输入1(E键)→ 进入目标锁定模式
→ 播放循环动画
→ 生成地面Decal指示器
→ 等待确认输入

第二阶段:攻击释放
输入2(鼠标左键)→ 确认目标位置
→ 播放攻击动画
→ 伤害判定
→ 视觉/音效反馈

1.2 输入处理

按键 状态 行为
E 技能未激活 激活技能,进入锁定阶段
E 技能已激活 取消技能
鼠标左键 锁定阶段 确认目标,进入攻击阶段

面试八股:为什么设计二次按下取消?→ 符合玩家直觉,允许反悔,提升操作灵活性。


🎯 二、目标锁定阶段

2.1 自定义目标Actor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 继承自地面射线目标Actor,添加Decal显示
UCLASS()
class AGATargetActor_GroundTraceDecal : public AGameplayAbilityTargetActor_GroundTrace
{
GENERATED_BODY()

UPROPERTY()
UDecalComponent* DecalComp; // 地面指示器

virtual void StartTargeting(UGameplayAbility* Ability) override
{
Super::StartTargeting(Ability);

// 创建Decal
DecalComp = NewObject<UDecalComponent>(this);
DecalComp->RegisterComponent();
DecalComp->SetDecalMaterial(DecalMaterial);
DecalComp->SetWorldScale3D(FVector(DecalSize, DecalSize, 1.0f));
}

virtual void Tick(float DeltaSeconds) override
{
Super::Tick(DeltaSeconds);

// 更新Decal位置到射线检测点
if (DecalComp && TraceHitResult.bBlockingHit)
{
DecalComp->SetWorldLocation(TraceHitResult.Location);
// 旋转90度贴合地面
DecalComp->SetWorldRotation(FRotationMatrix::MakeFromZ(TraceHitResult.Normal).Rotator());
}
}
};

2.2 动画循环控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
UCLASS()
class UGA_AOEAttack : public UNexusGameplayAbility
{
bool bIsWaitingTargetData = false;

void ActivateAbility()
{
bIsWaitingTargetData = true;

// 播放循环锁定动画
PlayMontage(LoopingMontage);

// 等待目标数据
WaitTargetData(AGATargetActor_GroundTraceDecal::StaticClass());
}

void OnTargetDataReady()
{
bIsWaitingTargetData = false;

// 保存目标位置
FVector TargetLocation = TargetData->GetHitResult()->Location;

// 进入攻击阶段
ExecuteAttack(TargetLocation);
}

void OnMontageEnded()
{
if (bIsWaitingTargetData)
{
// 还在等待目标,继续循环
PlayMontage(LoopingMontage);
}
}
};

面试八股:为什么要自定义TargetActor?→ 需要在目标位置显示Decal,内置类只有射线没有视觉反馈。


⚡ 三、攻击释放阶段

3.1 攻击动画与事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
void UGA_AOEAttack::ExecuteAttack(FVector TargetLocation)
{
// 提交资源消耗和冷却
if (!CommitAbility())
{
EndAbility();
return;
}

// 播放攻击动画(单次)
PlayMontage(AttackMontage);

// 等待动画通知触发伤害
WaitForGameplayEvent("aoe.damage.trigger");
}

void UGA_AOEAttack::OnDamageTrigger()
{
// 执行范围伤害
TArray<FHitResult> HitResults;

// 多球形检测
UKismetSystemLibrary::SphereTraceMulti(
GetWorld(),
TargetLocation,
TargetLocation,
AOERadius,
UEngineTypes::ConvertToTraceType(ECC_Pawn),
false,
IgnoreActors, // 忽略自身
EDrawDebugTrace::None,
HitResults,
true
);

// 应用伤害
for (auto& Hit : HitResults)
{
AActor* HitActor = Hit.GetActor();
if (HitActors.Contains(HitActor)) continue;

HitActors.Add(HitActor);

UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(HitActor);
if (TargetASC)
{
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(DamageEffectClass);
SpecHandle.Data->SetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag("data.damage"), DamageAmount);
TargetASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
}
}
}

🎨 四、视觉与音效反馈

4.1 攻击前指示器(GC_AoE_Indicator)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
UCLASS()
class UGC_AoE_Indicator : public UGameplayCueNotify_Actor
{
UPROPERTY()
UDecalComponent* DecalComp;

virtual bool OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
// 生成Decal
DecalComp = NewObject<UDecalComponent>(Target);
DecalComp->RegisterComponent();

// 设置大小和位置
float Radius = Parameters.RawMagnitude; // 从技能传入半径
DecalComp->SetWorldScale3D(FVector(Radius, Radius, 1.0f));
DecalComp->SetWorldLocation(Parameters.Location);

// 区分敌我颜色
if (Parameters.Instigator->IsLocallyControlled())
DecalComp->SetMaterial(0, GreenMaterial); // 自己绿色
else
DecalComp->SetMaterial(0, RedMaterial); // 敌人红色

return true;
}

virtual bool OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
if (DecalComp)
DecalComp->DestroyComponent();
return true;
}
};

4.2 攻击特效(GC_LightningBolt)

1
2
3
4
5
类型:Burst
配置:
- Niagara粒子:闪电爆炸
- 音效:雷击(带延迟匹配动画)
- 相机震动:可选

面试八股:为什么用Gameplay Cue而不是直接在Actor里播放?→ 自动网络同步,所有客户端看到一致效果;与逻辑解耦,可独立调整。


🌐 五、多玩家同步

5.1 目标锁定阶段

1
2
3
4
5
6
7
8
本地客户端:
- 显示Decal(仅自己可见)
- 播放循环动画
- 等待确认

确认后:
- 目标位置同步到服务器
- 服务器广播攻击阶段

5.2 攻击阶段同步

1
2
3
4
5
6
7
服务器:
- 执行伤害判定
- 通过Gameplay Cue广播特效

所有客户端:
- 播放攻击动画
- 看到伤害数字/特效

⏱️ 六、冷却与输入管理

6.1 冷却效果

1
2
3
4
// GE_AOE_Cooldown
类型:Duration
持续时间:5.0
标签:cooldown.aoe

6.2 输入处理优化

1
2
3
4
5
6
7
8
9
10
// 绑定输入
Input_AOE (E键):
if (技能未激活)
激活技能
else
取消技能

Input_Confirm (鼠标左键):
if (在目标锁定阶段)
确认目标

📌 七、本集核心八股

7.1 双阶段技能设计模式

1
2
3
4
5
6
7
8
9
阶段1:目标获取(本地)
├── 视觉反馈(Decal)
├── 动画循环
└── 等待确认

阶段2:效果执行(服务器权威)
├── 资源消耗
├── 伤害判定
└── 特效同步

7.2 目标锁定实现要点

要素 实现 说明
射线检测 GroundTrace 获取地面位置
视觉反馈 Custom Decal Actor 绑定到目标点
范围指示 Decal大小 对应实际伤害半径
本地执行 仅客户端 避免信息泄露

7.3 多阶段网络同步

1
2
3
本地阶段:不涉及网络,快速响应
确认阶段:目标位置同步到服务器
执行阶段:服务器权威,全客户端同步

7.4 冷却与输入设计

  • 技能激活后立即进入冷却
  • 二次按下可取消(提升体验)
  • 冷却期间无法激活

✅ 八、验收清单

  • 自定义TargetActor(带Decal)
  • 双阶段动画控制(循环/单次)
  • 目标位置获取和保存
  • CommitAbility消耗资源
  • 多球形范围伤害检测
  • GC_AoE_Indicator实现(敌我颜色区分)
  • GC_LightningBolt特效
  • 冷却Effect配置
  • 二次按下取消逻辑
  • 多玩家同步测试

Part 13.5: AOE Custom Decals & Camera Settings

🎯 核心目标

优化AOE技能的视觉反馈和操作体验,实现动态Decal颜色区分和摄像机视野调整


🎨 一、动态Decal颜色区分

1.1 问题背景

1
2
3
4
5
6
三个阶段使用相同Decal:
- 目标选定阶段:暗色(提示进行中)
- 己方确认阶段:绿色(自己确认)
- 敌方确认阶段:红色(敌人视角)

但默认颜色相同,无法区分

1.2 解决方案:Instigator判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 在GC_AoE_Indicator中
bool UGC_AoE_Indicator::OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters)
{
// ...生成Decal

// 判断当前玩家是否为本地控制
bool bIsLocallyControlled = Parameters.Instigator->IsLocallyControlled();

// 根据阶段和视角设置颜色
if (Parameters.RawMagnitude == 1.0f) // 目标选定阶段
{
SetDecalColor(FLinearColor(0.1f, 0.1f, 0.1f)); // 暗灰色
}
else if (bIsLocallyControlled) // 己方确认
{
SetDecalColor(FLinearColor::Green);
}
else // 敌方确认
{
SetDecalColor(FLinearColor::Red);
}

return true;
}

面试八股:为什么用Instigator判断而不是直接GetPlayerController?→ Instigator由Ability正确传递,确保在多玩家环境下区分不同施法者。


📷 二、目标选择阶段摄像机调整

2.1 设计目标

1
2
需求:目标锁定阶段获得更宽阔视野,方便观察战场
实现:临时增加FOV和摄像机臂长

2.2 静态Gameplay Cue实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// GC_TargetingCamera(静态类型)
UCLASS()
class UGC_TargetingCamera : public UGameplayCueNotify_Static
{
UPROPERTY(EditDefaultsOnly)
FCameraSettings CameraOffset; // 偏移量(FOV+20, Boom+120)

virtual void HandleGameplayCue(AActor* Target, FGameplayTag EventTag, const FGameplayCueParameters& Parameters) override
{
ANexusPlayer* Player = Cast<ANexusPlayer>(Target);
if (!Player) return;

if (EventTag == FGameplayTag::RequestGameplayTag("targeting.camera.start"))
{
// 保存当前设置,应用偏移
Player->ApplyCameraOffset(CameraOffset);
}
else if (EventTag == FGameplayTag::RequestGameplayTag("targeting.camera.end"))
{
// 恢复原始设置
Player->RevertCameraOffset();
}
}
};

2.3 技能中调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void UGA_AOEAttack::ActivateAbility()
{
// 进入目标锁定阶段

// 添加摄像机调整Cue
AddGameplayCueToOwner(FGameplayTag::RequestGameplayTag("targeting.camera.start"));

// ...目标锁定逻辑
}

void UGA_AOEAttack::EndAbility()
{
// 移除摄像机调整Cue
RemoveGameplayCueFromOwner(FGameplayTag::RequestGameplayTag("targeting.camera.end"));

Super::EndAbility();
}

面试八股:为什么用静态Gameplay Cue而不是直接在能力中修改摄像机?→

  • 解耦:摄像机逻辑独立
  • 复用:多个技能可共享同一个Cue
  • 自动管理:技能结束时自动移除

🔧 三、优化修复

3.1 摄像机切换时机优化

1
2
3
问题:武器挂载完成后才触发摄像机更新,技能激活时来不及切换

解决:将触发时机提前到装备动画开始时
1
2
3
4
5
6
7
void UWeaponsManagerComponent::OnEquipNotify()
{
// 广播武器即将装备
OnWeaponChanging.Broadcast(CurrentEquippingWeapon);

// ...原有逻辑
}

3.2 目标定位射线起点优化

1
2
3
问题:射线从角色中心发出,贴花位置错误(提前落在近处平台)

解决:改为从武器插槽发出
1
2
3
4
5
6
7
8
9
FVector GetTraceStartLocation()
{
AWeaponBase* Weapon = GetEquippedWeapon();
if (Weapon && Weapon->ProjectileSpawnPoint)
{
return Weapon->ProjectileSpawnPoint->GetComponentLocation();
}
return GetActorLocation(); // 回退到角色中心
}

📌 四、本集核心八股

4.1 动态Decal设计模式

阶段 颜色 判断条件
目标选定 暗灰色 Parameters.RawMagnitude == 1
己方确认 绿色 Instigator本地控制
敌方确认 红色 Instigator非本地控制

4.2 临时摄像机调整实现

1
2
技能激活 → Add TargetingCamera Cue → 应用偏移
技能结束 → Remove Cue → 恢复默认

4.3 静态Gameplay Cue优势

  • 无Actor实例,轻量级
  • 自动网络同步(通过参数)
  • 生命周期与技能绑定

4.4 优化要点

问题 解决 效果
摄像机切换延迟 提前到装备动画开始 技能激活立即生效
射线起点错误 改为武器插槽 贴花位置准确

✅ 五、验收清单

  • Decal颜色区分(暗灰/绿/红)
  • Instigator本地控制判断
  • 静态Gameplay Cue实现摄像机调整
  • 技能激活时应用摄像机偏移
  • 技能结束时恢复默认
  • 摄像机切换时机优化
  • 射线起点改为武器插槽
  • 测试多玩家视角颜色区分

Part 14: Hit Reaction & Death

🎯 核心目标

实现受击反应和死亡功能,掌握基于标签的事件驱动设计和网络同步


💥 一、受击反应能力设计

1.1 能力配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UCLASS()
class UGA_HitReaction : public UNexusGameplayAbility
{
GENERATED_BODY()

UGA_HitReaction()
{
// 资产标签
AbilityTags.AddTag(FGameplayTag::RequestGameplayTag("ability.hitreaction"));

// 实例化策略:每次执行新建实例(允许多次叠加)
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerExecution;

// 激活时取消其他能力,但允许被中断
CancelAbilitiesWithTag.AddTag(FGameplayTag::RequestGameplayTag("ability"));
}
};

面试八股:为什么用Instance Per Execution?→ 允许连续受击时多次触发,每个受击反应独立,不会相互覆盖。

1.2 触发逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 在BasicAttributeSet中
void UBasicAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);

if (Data.EvaluatedData.Attribute == GetHealthAttribute())
{
// 检查Effect是否带有受击反应标签
if (Data.EffectSpec.GetDynamicAssetTags().HasTag(FGameplayTag::RequestGameplayTag("effect.hitreaction")))
{
// 获取拥有者角色
AActor* OwnerActor = GetOwningActor();

// 通过标签激活受击反应能力
UAbilitySystemComponent* ASC = OwnerActor->FindComponentByClass<UAbilitySystemComponent>();
if (ASC)
{
FGameplayTagContainer TagContainer;
TagContainer.AddTag(FGameplayTag::RequestGameplayTag("ability.hitreaction"));
ASC->TryActivateAbilitiesByTag(TagContainer);
}
}
}
}

面试八股:为什么用标签判断而不是所有伤害都触发?→ 避免治疗、持续伤害等误触发受击动画,只有特定伤害类型(如近战打击)才触发。


💀 二、死亡功能设计

2.1 死亡能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
UCLASS()
class UGA_Death : public UNexusGameplayAbility
{
GENERATED_BODY()

UGA_Death()
{
// 资产标签
AbilityTags.AddTag(FGameplayTag::RequestGameplayTag("ability.death"));

// 取消并阻止所有其他能力
CancelAbilitiesWithTag.AddTag(FGameplayTag::RequestGameplayTag("ability"));
BlockAbilitiesWithTag.AddTag(FGameplayTag::RequestGameplayTag("ability"));

// 实例化策略:每个角色只实例化一次
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
}

virtual void ActivateAbility() override
{
// 应用死亡效果(添加死亡标签)
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(GE_Death);
ApplyGameplayEffectSpecToOwner(SpecHandle);

EndAbility();
}
};

2.2 死亡效果

1
2
3
4
5
// GE_Death
类型:Infinite
持续时间:永久
GrantedTags:State.Dead
(无Modifier,只添加标签)

2.3 死亡触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在BasicAttributeSet中
void UBasicAttributeSet::PostAttributeChange(const FGameplayAttribute& Attribute, float OldValue, float NewValue)
{
Super::PostAttributeChange(Attribute, OldValue, NewValue);

if (Attribute == GetHealthAttribute() && NewValue <= 0.0f)
{
// 血量≤0,激活死亡能力
AActor* OwnerActor = GetOwningActor();
UAbilitySystemComponent* ASC = OwnerActor->FindComponentByClass<UAbilitySystemComponent>();
if (ASC)
{
FGameplayTagContainer TagContainer;
TagContainer.AddTag(FGameplayTag::RequestGameplayTag("ability.death"));
ASC->TryActivateAbilitiesByTag(TagContainer);
}
}
}

🔄 三、死亡标签监听

3.1 角色类中监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void ANexusCharacterBase::BeginPlay()
{
Super::BeginPlay();

if (AbilitySystemComponent)
{
// 监听死亡标签
AbilitySystemComponent->RegisterGameplayTagEvent(
FGameplayTag::RequestGameplayTag("State.Dead"),
EGameplayTagEventType::NewOrRemoved
).AddUObject(this, &ANexusCharacterBase::OnDeadTagChanged);
}
}

void ANexusCharacterBase::OnDeadTagChanged(const FGameplayTag Tag, int32 Count)
{
if (Count > 0)
{
// 标签添加,执行死亡处理
HandleDeath();
}
}

3.2 死亡处理(BlueprintNativeEvent)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 可被蓝图重载的死亡处理函数
UFUNCTION(BlueprintNativeEvent, Category = "Death")
void HandleDeath();

void ANexusCharacterBase::HandleDeath_Implementation()
{
// 默认实现
// 1. 启用布娃娃物理
USkeletalMeshComponent* Mesh = GetMesh();
Mesh->SetCollisionEnabled(ECollisionEnabled::PhysicsOnly);
Mesh->SetSimulatePhysics(true);

// 2. 禁用角色移动
GetCharacterMovement()->DisableMovement();

// 3. 施加冲击力
FVector Impulse = -GetActorForwardVector() * 500.0f + FVector::UpVector * 200.0f;
Mesh->AddImpulse(Impulse, NAME_None, true);

// 4. 设置生命周期(可选)
SetLifeSpan(5.0f);
}

面试八股:为什么用BlueprintNativeEvent?→ 提供默认C++实现,同时允许蓝图重载实现个性化死亡效果(玩家和敌人可不同)。


🚫 四、能力阻止机制

4.1 所有能力配置

1
2
3
4
5
6
// 在UNexusGameplayAbility基类中
UNexusGameplayAbility::UNexusGameplayAbility()
{
// 死亡状态下阻止所有能力激活
ActivationBlockedTags.AddTag(FGameplayTag::RequestGameplayTag("State.Dead"));
}

4.2 效果

1
2
角色死亡 → 获得State.Dead标签
任何能力激活时检查ActivationBlockedTags → 发现死亡标签 → 阻止激活

📌 五、本集核心八股

5.1 受击反应设计模式

1
2
3
4
5
6
7
伤害Effect(带hitreaction标签)

PostGameplayEffectExecute检测

TryActivateAbilitiesByTag激活受击能力

播放受击动画

5.2 死亡系统架构

1
2
3
4
5
血量≤0 → 激活死亡能力 → 应用死亡效果 → 添加State.Dead标签

标签监听触发 → HandleDeath(布娃娃/禁用移动)

ActivationBlockedTags阻止新能力

5.3 标签事件驱动优势

场景 传统做法 标签驱动
死亡 直接调用函数 监听标签变化
受击 硬编码判断 Effect带标签
状态同步 手动复制 标签自动同步

5.4 实例化策略选择

能力类型 策略 原因
受击反应 Per Execution 允许多次叠加
死亡 Per Actor 只触发一次

✅ 六、验收清单

  • GA_HitReaction创建(Instance Per Execution)
  • 受击动画蒙太奇配置
  • PostGameplayEffectExecute检测Effect标签
  • 通过标签激活受击能力
  • GA_Death创建(Instance Per Actor)
  • GE_Death添加State.Dead标签
  • PostAttributeChange监听血量≤0
  • 注册死亡标签监听
  • HandleDeath默认实现(布娃娃)
  • 基类配置ActivationBlockedTags
  • 测试死亡后无法激活技能

Part 15: Enemy AI Abilities

🎯 核心目标

为敌人角色添加AI能力,实现自动攻击、连击和远程投射物,并解决敌人血条UI的显示与同步问题


🎮 一、敌人武器配置

1.1 添加武器管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在敌人基础角色蓝图中
Components:
- WeaponsManagerComponent

Starting Weapons:
- BP_Weapon_Axe
- BP_Weapon_Staff

Starting Abilities:
- GA_EquipWeapon
- GA_MeleeAttack_AxeSwing
- GA_Combo_Axe
- GA_Projectile

1.2 服务器事件触发装备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void ANexusEnemyBase::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);

// 延迟一帧,确保能力已赋予
GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ANexusEnemyBase::EquipDefaultWeapon);
}

void ANexusEnemyBase::EquipDefaultWeapon()
{
if (HasAuthority() && WeaponsManagerComponent)
{
// 触发装备能力
AbilitySystemComponent->TryActivateAbilityByClass(GA_EquipWeapon);
}
}

面试八股:为什么要延迟一帧?→ 避免在同一帧内激活尚未完全赋予的能力,确保能力调用顺利。


🤖 二、敌人攻击行为

2.1 简单攻击循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 敌人AI控制器中
void AAIController::StartAttackLoop()
{
// 每2秒尝试一次攻击
GetWorld()->GetTimerManager().SetTimer(
AttackTimerHandle,
this,
&AAIController::TryAttack,
2.0f,
true
);
}

void AAIController::TryAttack()
{
APawn* ControlledPawn = GetPawn();
if (!ControlledPawn) return;

UAbilitySystemComponent* ASC = ControlledPawn->FindComponentByClass<UAbilitySystemComponent>();
if (!ASC) return;

// 随机选择一种攻击方式
TArray<FGameplayTag> AttackTags = {
FGameplayTag::RequestGameplayTag("ability.melee.axe"),
FGameplayTag::RequestGameplayTag("ability.shoot.projectile")
};

int32 RandomIndex = FMath::RandRange(0, AttackTags.Num() - 1);
ASC->TryActivateAbilitiesByTag(FGameplayTagContainer(AttackTags[RandomIndex]));
}

🔄 三、敌人连击逻辑调整

3.1 问题:连击依赖玩家输入

敌人没有输入,无法自动连击

3.2 解决方案:添加AI模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
UCLASS()
class UGA_Combo_Axe : public UMeleeAttackBase
{
bool bAlwaysContinueCombo = false; // AI模式:总是继续连击

void OnComboWindowStart()
{
bInComboWindow = true;

// 如果是AI,自动继续连击
if (bAlwaysContinueCombo)
{
ContinueCombo();
}
}

void ContinueCombo()
{
ComboCount++;
PlayMontageSection(ComboCount);
}
};

3.3 玩家/AI判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 辅助函数:判断是否为玩家控制器
bool ANexusCharacterBase::IsPlayerControlled() const
{
return GetController() && GetController()->IsPlayerController();
}

// 能力初始化时设置
void UGA_Combo_Axe::OnGiveAbility()
{
ANexusCharacterBase* Owner = Cast<ANexusCharacterBase>(GetAvatarActorFromActorInfo());
if (Owner)
{
bAlwaysContinueCombo = !Owner->IsPlayerControlled(); // 非玩家即AI
}
}

面试八股:为什么通过控制器类型判断?→ 玩家和AI使用不同的控制器类,可以准确区分。


🎯 四、远程攻击目标处理

4.1 问题:无玩家控制器时崩溃

1
2
// 远程投射物能力中
WaitForTargetData(); // 需要玩家控制器,AI调用会崩溃

4.2 解决方案:通用目标接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 创建目标接口
UINTERFACE()
class UTargetingInterface : public UInterface
{
GENERATED_BODY()
};

class ITargetingInterface
{
GENERATED_BODY()

public:
UFUNCTION(BlueprintNativeEvent)
AActor* GetAttackTarget();
};

// 敌人实现接口
AActor* ANexusEnemyBase::GetAttackTarget_Implementation()
{
// 返回最近的玩家
return GetNearestPlayer();
}

4.3 改造投射物能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void UGA_Projectile::ActivateAbility()
{
AActor* Owner = GetAvatarActorFromActorInfo();

// 检查是否有目标接口
if (Owner->Implements<UTargetingInterface>())
{
// AI模式:直接获取目标
AActor* Target = ITargetingInterface::Execute_GetAttackTarget(Owner);
if (Target)
{
FVector TargetLocation = Target->GetActorLocation();
SpawnProjectile(TargetLocation);
return;
}
}

// 玩家模式:等待目标数据
WaitForTargetData();
}

面试八股:接口设计的优势?→ 统一目标获取方式,玩家和敌人可各自实现,能力代码无需修改。


📊 五、敌人血条UI

5.1 血条控件设计

1
2
3
4
5
6
7
8
9
// WBP_EnemyHealthBar
结构:
- ProgressBar (HealthBar)
- 颜色:红色调
- 大小:100x20

绑定:
- OwnerActor(传入敌人自身)
- 监听血量/最大血量变化

5.2 创建和绑定

1
2
3
4
5
6
7
8
9
10
11
12
void ANexusEnemyBase::BeginPlay()
{
Super::BeginPlay();

// 创建血条控件
if (IsLocallyControlled() || !HasAuthority()) // 客户端创建
{
UW_EnemyHealthBar* HealthBar = CreateWidget<UW_EnemyHealthBar>(GetWorld(), HealthBarClass);
HealthBar->SetOwnerActor(this);
HealthBar->AddToViewport();
}
}

5.3 血量更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 血条控件中
void UW_EnemyHealthBar::NativeConstruct()
{
Super::NativeConstruct();

if (OwnerActor)
{
UAbilitySystemComponent* ASC = OwnerActor->FindComponentByClass<UAbilitySystemComponent>();
if (ASC)
{
// 监听血量变化
ASC->GetGameplayAttributeValueChangeDelegate(UGameplayAttribute::StaticClass()).AddUObject(this, &UW_EnemyHealthBar::OnHealthChanged);
}
}
}

void UW_EnemyHealthBar::OnHealthChanged(const FOnAttributeChangeData& Data)
{
if (Data.Attribute == UBasicAttributeSet::GetHealthAttribute())
{
float Health = Data.NewValue;
float MaxHealth = ...; // 获取最大血量

HealthBar->SetPercent(Health / MaxHealth);
}
}

5.4 死亡时清理

1
2
3
4
5
6
7
8
9
10
11
void ANexusEnemyBase::HandleDeath_Implementation()
{
// 销毁血条
if (HealthBarWidget)
{
HealthBarWidget->RemoveFromParent();
HealthBarWidget = nullptr;
}

Super::HandleDeath_Implementation();
}

📌 六、本集核心八股

6.1 AI能力设计模式

1
2
3
4
武器管理器:服务器赋予武器和能力
攻击循环:定时器驱动,随机选择能力
连击逻辑:AI模式自动继续
目标获取:接口统一,玩家/AI各自实现

6.2 玩家vsAI差异处理

场景 玩家 AI
连击 依赖输入 自动继续
目标 鼠标瞄准 锁定最近玩家
触发 按键 定时器

6.3 目标接口优势

  • 解耦:能力不关心目标来源
  • 复用:玩家和AI共享同一能力
  • 扩展:新增目标逻辑只需实现接口

6.4 血条UI要点

  • 创建时机:客户端创建,服务器不关心
  • 数据绑定:通过OwnerActor传递ASC引用
  • 死亡清理:避免残留UI

✅ 七、验收清单

  • 敌人添加武器管理器
  • PossessedBy延迟装备武器
  • AI攻击定时器实现
  • 连击能力添加AI模式
  • 控制器类型判断
  • 创建TargetingInterface
  • 敌人实现GetAttackTarget
  • 投射物能力改造(判断接口)
  • 敌人血条控件创建
  • 血量监听和更新
  • 死亡时清理血条

Part 15.5: Polished Enemy UI

🎯 核心目标

优化敌人血条UI,实现动态缩放和受伤动画效果,提升视觉反馈体验


📏 一、血条动态缩放

1.1 设计目标

1
2
3
4
需求:血条根据玩家距离动态变化
- 距离近时:血条放大(最大比例1.0)
- 距离远时:血条缩小(最小比例0.4)
- 作用:优化视野,避免UI遮挡

1.2 缩放参数配置

参数 说明 默认值
Min Distance 开始缩放的最近距离 300
Max Distance 完全缩小的最远距离 1000
Min Scale 最小缩放比例 0.4
Max Scale 最大缩放比例 1.0

1.3 实现逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 在血条控件的Tick中
void UW_EnemyHealthBar::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);

if (!OwnerActor) return;

// 获取玩家位置
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (!PC || !PC->GetPawn()) return;

FVector PlayerLocation = PC->GetPawn()->GetActorLocation();
FVector EnemyLocation = OwnerActor->GetActorLocation();

// 计算距离
float Distance = FVector::Distance(PlayerLocation, EnemyLocation);

// 归一化(0-1)
float NormalizedDistance = FMath::GetMappedRangeValueClamped(
FVector2D(MinDistance, MaxDistance),
FVector2D(0.0f, 1.0f),
Distance
);

// 计算缩放比例(反向:距离越近,比例越大)
float Scale = FMath::Lerp(MaxScale, MinScale, NormalizedDistance);

// 应用缩放
SetRenderScale(FVector2D(Scale, Scale));
}

面试八股:为什么在Tick中更新?→ 需要实时响应玩家移动,Tick是最直接的方式(性能可接受,因为只有几个敌人)。


💔 二、受伤动画效果

2.1 双进度条设计

1
2
3
顶层进度条:实时反映当前生命值(快速跳变)
底层进度条:平滑动画过渡(缓慢减少)
效果:受伤时底层条逐渐追赶上顶层条,表现出血量流失的动画

2.2 控件结构

1
2
3
Overlay
├── BackgroundBar(底层,暗色,动画条)
└── HealthBar(顶层,亮色,实时条)

2.3 动画实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 变量
float CurrentHealthPercent; // 当前生命值百分比(实时)
float AnimatedHealthPercent; // 动画进度条百分比
FTimerHandle AnimationTimerHandle;

void UW_EnemyHealthBar::OnHealthChanged(float NewHealth, float MaxHealth)
{
float OldPercent = CurrentHealthPercent;
CurrentHealthPercent = NewHealth / MaxHealth;

// 只有受伤时才触发动画(生命值减少)
if (CurrentHealthPercent < OldPercent)
{
StartHealthAnimation(OldPercent, CurrentHealthPercent);
}

// 实时条直接更新
HealthBar->SetPercent(CurrentHealthPercent);
}

void UW_EnemyHealthBar::StartHealthAnimation(float FromPercent, float ToPercent)
{
// 显示动画条
BackgroundBar->SetVisibility(ESlateVisibility::Visible);
BackgroundBar->SetPercent(FromPercent);

// 启动定时器,逐步减少
GetWorld()->GetTimerManager().SetTimer(
AnimationTimerHandle,
this,
&UW_EnemyHealthBar::UpdateHealthAnimation,
0.003f, // 定时器间隔
true
);
}

void UW_EnemyHealthBar::UpdateHealthAnimation()
{
float Step = 0.025f; // 每步减少的量

float Current = BackgroundBar->GetPercent();
float NewValue = FMath::Max(Current - Step, CurrentHealthPercent);

BackgroundBar->SetPercent(NewValue);

// 动画结束
if (NewValue <= CurrentHealthPercent + 0.01f)
{
GetWorld()->GetTimerManager().ClearTimer(AnimationTimerHandle);
BackgroundBar->SetVisibility(ESlateVisibility::Hidden);
}
}

面试八股:为什么用定时器而不是插值?→ 动画速度和节奏更可控,且不需要每帧计算,性能更好。


🎮 三、多人游戏适配

3.1 本地客户端独享

1
2
3
4
5
6
7
8
// 血条只在本地客户端创建和更新
if (GetNetMode() != NM_DedicatedServer) // 非专用服务器
{
CreateHealthBar();
}

// 缩放和动画只在本地计算
// 不影响其他玩家看到的血条

3.2 性能优化

1
2
3
每帧计算:距离和缩放(少量敌人,可接受)
定时器驱动:受伤动画(事件触发,不持续)
可见性控制:动画结束时隐藏底层条

📌 四、本集核心八股

4.1 动态缩放设计

1
2
3
4
输入:玩家与敌人距离
处理:归一化 → 反向插值
输出:缩放比例
特点:本地计算,不影响网络

4.2 双进度条动画原理

1
2
3
实时层:立即响应(数字准确)
动画层:缓慢过渡(视觉反馈)
当动画层追上实时层时,动画结束

4.3 参数调优指南

参数 作用 调优建议
Min/Max Distance 缩放范围 根据关卡大小调整
Min/Max Scale 大小范围 0.4-1.0较为合适
递减步长 动画速度 0.025(约1秒完成)
定时器间隔 动画流畅度 0.003秒(≈330FPS)

4.4 本地化渲染优势

  • 减少网络带宽
  • 每个玩家视角独立
  • 不影响游戏逻辑

✅ 五、验收清单

  • 血条缩放参数配置
  • NativeTick中计算距离
  • 距离归一化和反向插值
  • 应用渲染缩放
  • 双进度条控件结构
  • 受伤时触发动画
  • 定时器驱动动画更新
  • 动画结束时隐藏底层条
  • 本地客户端判断(非专用服务器)
  • 测试多人环境缩放和动画

Part 16: SHIELD

🎯 核心目标

实现护盾防御能力,掌握自定义伤害计算、效果动态缩放和标签阻挡机制


🛡️ 一、护盾能力设计

1.1 能力配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
UCLASS()
class UGA_Shield : public UNexusGameplayAbility
{
GENERATED_BODY()

UGA_Shield()
{
// 标签配置
AbilityTags.AddTag(FGameplayTag::RequestGameplayTag("ability.defense.shield"));

// 激活时给角色添加护盾状态标签
ActivationOwnedTags.AddTag(FGameplayTag::RequestGameplayTag("status.buff.shield"));

// 阻挡其他防御技能(防止多重护盾)
BlockAbilitiesWithTag.AddTag(FGameplayTag::RequestGameplayTag("ability.defense"));

// 实例化策略
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
}
};

1.2 动画流程

1
2
3
4
1. 播放Powerup动画
2. 动画Notify触发 → 应用护盾效果
3. 护盾气泡显示(Gameplay Cue Actor)
4. 持续时间结束 → 护盾消失

面试八股:为什么用ActivationOwnedTags而不是直接设置变量?→ 标签系统自动网络同步,其他系统(如UI)可监听标签变化。


✨ 二、护盾视觉效果

2.1 护盾气泡Cue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// GC_ShieldBubble(Actor类型)
UCLASS()
class AGC_ShieldBubble : public AGameplayCueNotify_Actor
{
UPROPERTY()
UParticleSystemComponent* ShieldEffect;

virtual bool OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
// 附着到目标角色
USkeletalMeshComponent* Mesh = Target->FindComponentByClass<USkeletalMeshComponent>();
if (Mesh)
{
ShieldEffect->AttachToComponent(Mesh, FAttachmentTransformRules::SnapToTargetNotIncludingScale);
ShieldEffect->SetRelativeScale3D(FVector(1.2f)); // 稍微放大包裹全身
ShieldEffect->Activate();
}
return true;
}

virtual bool OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
ShieldEffect->Deactivate();
return true;
}
};

2.2 护盾效果配置

1
2
3
4
5
6
// GE_GiveShield
类型:Duration
持续时间:15.0
GrantedTags:status.buff.shield
GameplayCues:
- Add:GC_ShieldBubble(持续显示)

🔄 三、防止重复激活

3.1 激活阻挡

1
2
3
// 在护盾能力中配置
ActivationBlockedTags.AddTag(FGameplayTag::RequestGameplayTag("status.buff.shield"));
// 已有护盾标签时,无法再次激活

3.2 冷却效果

1
2
3
4
5
6
7
// GE_ShieldCooldown
类型:Duration
持续时间:30.0
GrantedTags:status.cooldown.shield

// 护盾能力中绑定
CooldownGameplayEffectClass = GE_ShieldCooldown;

面试八股:为什么需要冷却?→ 防止无限套盾,平衡游戏性。


⚔️ 四、伤害阻挡机制

4.1 方案对比

方案 原理 优点 缺点
标签阻挡 伤害Effect要求目标无护盾标签 简单直接 只能完全阻挡,需修改所有伤害Effect
自定义计算 计算伤害时检查护盾标签 灵活(可减伤/免伤) 实现稍复杂

4.2 自定义伤害计算类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
UCLASS()
class UDMG_ShieldMitigation : public UGameplayModMagnitudeCalculation
{
GENERATED_BODY()

virtual float CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const override
{
// 获取目标ASC
UAbilitySystemComponent* TargetASC = Spec.GetContext().GetTargetAbilitySystemComponent();
if (!TargetASC) return 0.0f;

// 检查目标是否有护盾标签
bool bHasShield = TargetASC->HasMatchingGameplayTag(FGameplayTag::RequestGameplayTag("status.buff.shield"));

// 获取原始伤害值(SetByCaller)
float RawDamage = Spec.GetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag("data.damage"), false, 0.0f);

if (bHasShield)
{
// 有护盾时伤害减半
return RawDamage * 0.5f;
// 或 return 0.0f; // 完全免疫
}

return RawDamage;
}
};

4.3 伤害Effect配置

1
2
3
GE_Damage_Instant
Modifier:Health,操作Add,数值使用自定义计算类
SetByCaller:data.damage(传入原始伤害)

4.4 受击动画控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void UBasicAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
if (Data.EvaluatedData.Attribute == GetHealthAttribute())
{
// 获取实际受到的伤害
float ActualDamage = -Data.EvaluatedData.Magnitude; // 负值转正

// 只有实际受到伤害时才触发受击动画
if (ActualDamage > 0.0f)
{
// 触发受击反应
TryActivateHitReaction();
}
}
}

面试八股:自定义计算类的优势?→ 可读取目标状态、施法者属性,实现复杂伤害逻辑,且不影响伤害Effect的结构。


📈 五、基于等级的动态效果

5.1 曲线表配置

1
2
3
4
5
6
// Curve Table: ShieldDurationCurve
等级1: 1.0 (倍率) → 15
等级2: 1.522.5
等级3: 2.030
等级4: 2.537.5
等级5: 3.045

5.2 应用等级效果

1
2
3
4
5
6
7
8
9
10
11
12
// 护盾能力中
void UGA_Shield::ActivateAbility()
{
// 获取技能等级(从天赋系统)
int32 AbilityLevel = GetAbilityLevel();

// 创建效果Spec时传入等级
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(GE_GiveShield, AbilityLevel);

// 曲线表自动根据等级计算持续时间
ApplyGameplayEffectSpecToOwner(SpecHandle);
}

📌 六、本集核心八股

6.1 护盾系统架构

1
2
3
4
能力层:GA_Shield(动画+触发)
效果层:GE_GiveShield(标签+持续时间)
表现层:GC_ShieldBubble(视觉反馈)
计算层:Custom Calculation(伤害减免)

6.2 伤害阻挡方案对比

方案 维护成本 灵活性 适用场景
标签阻挡 高(改所有伤害Effect) 低(只能阻挡) 简单项目
自定义计算 低(集中管理) 高(可减伤/免伤) 复杂项目

6.3 等级缩放实现

1
2
3
曲线表:存储等级对应的倍率
Effect:设置持续时间策略为"Has Curve"
能力:传入等级,自动计算

6.4 受击动画优化

  • 检查实际伤害值 > 0 才触发
  • 避免护盾完全免疫时播放受击动画
  • 保持音效/特效反馈(即使无动画)

✅ 七、验收清单

  • GA_Shield配置(标签/动画/冷却)
  • GE_GiveShield(Duration + 护盾标签)
  • GC_ShieldBubble实现
  • 激活阻挡配置(已有护盾时无法激活)
  • 冷却Effect创建和绑定
  • 自定义伤害计算类实现
  • 伤害Effect配置使用计算类
  • 受击动画判断实际伤害
  • 曲线表配置等级倍率
  • 能力中传入等级参数

Part 17: Advanced Damage Calculation

🎯 核心目标

引入元属性(Meta Attribute)和盾牌属性,实现优先扣除盾牌再扣生命值的伤害计算机制


📊 一、元属性设计

1.1 什么是元属性?

1
2
3
4
元属性(Meta Attribute):仅在服务器端存在的临时属性
- 不进行网络同步
- 用于中间计算和数据传递
- 每次攻击时临时存在,用完即清零

面试八股:为什么要引入元属性?→ 分离伤害数值和属性应用,避免直接修改生命值,支持复杂伤害逻辑(如先扣盾再扣血)。

1.2 添加Damage元属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UCLASS()
class UBasicAttributeSet : public UAttributeSet
{
// 伤害元属性(不复制)
UPROPERTY(BlueprintReadOnly, Category = "Meta Attributes")
FGameplayAttributeData Damage;

ATTRIBUTE_ACCESSORS(UBasicAttributeSet, Damage)

// 取消复制
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// 不添加Damage属性的复制条件
}
};

1.3 修改伤害Effect

1
2
3
4
GE_Damage_Instant
Modifier: Damage(元属性)
操作: Override(覆盖,不是Add)
数值: SetByCaller (data.damage) 正值

🛡️ 二、盾牌属性

2.1 添加盾牌属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
UCLASS()
class UBasicAttributeSet : public UAttributeSet
{
UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Shield)
FGameplayAttributeData Shield;

UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_MaxShield)
FGameplayAttributeData MaxShield;

ATTRIBUTE_ACCESSORS(UBasicAttributeSet, Shield)
ATTRIBUTE_ACCESSORS(UBasicAttributeSet, MaxShield)

UFUNCTION()
virtual void OnRep_Shield(const FGameplayAttributeData& OldValue);

UFUNCTION()
virtual void OnRep_MaxShield(const FGameplayAttributeData& OldValue);

UBasicAttributeSet()
{
MaxShield = 100.0f;
Shield = 0.0f; // 默认无盾
}
};

2.2 盾牌限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void UBasicAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
Super::PreAttributeChange(Attribute, NewValue);

if (Attribute == GetShieldAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxShield());
}
else if (Attribute == GetMaxShieldAttribute())
{
// 最大盾牌不能小于0
NewValue = FMath::Max(NewValue, 0.0f);
}
}

🔄 三、伤害计算逻辑

3.1 PostGameplayEffectExecute处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void UBasicAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);

// 处理伤害元属性
if (Data.EvaluatedData.Attribute == GetDamageAttribute())
{
float DamageValue = GetDamage();
SetDamage(0.0f); // 清零

if (DamageValue > 0.0f)
{
// 先扣盾牌
float ShieldValue = GetShield();
if (ShieldValue > 0.0f)
{
float DamageToShield = FMath::Min(DamageValue, ShieldValue);
SetShield(ShieldValue - DamageToShield);
DamageValue -= DamageToShield;
}

// 剩余伤害扣生命值
if (DamageValue > 0.0f)
{
float HealthValue = GetHealth();
SetHealth(HealthValue - DamageValue);
}

// 触发受击反应(只要有实际伤害)
TryActivateHitReaction();
}
}
}

面试八股:为什么要在Post中清零Damage?→ 元属性只用于本次计算,下次攻击前必须清零,避免累积。


💉 四、盾牌填充效果

4.1 填充效果配置

1
2
3
4
5
6
// GE_FillShield
类型:Instant
Modifier:
- 属性: Shield
- 操作: Override
- 数值: 从MaxShield获取

4.2 盾牌填充能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
UCLASS()
class UGA_FillShield : public UNexusGameplayAbility
{
virtual void ActivateAbility() override
{
// 播放动画
PlayMontage(FillShieldMontage);

// 动画Notify时应用效果
WaitForGameplayEvent("shield.fill.trigger");
}

void OnFillTriggered()
{
// 应用填充效果
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(GE_FillShield);
ApplyGameplayEffectSpecToOwner(SpecHandle);

EndAbility();
}
};

📱 五、UI更新

5.1 复用护甲条显示盾牌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在PlayerVitals控件中
void UpdateShieldBar()
{
if (!ASC) return;

float Shield = ASC->GetNumericAttribute(UBasicAttributeSet::GetShieldAttribute());
float MaxShield = ASC->GetNumericAttribute(UBasicAttributeSet::GetMaxShieldAttribute());

ShieldBar->SetPercent(Shield / MaxShield);

// 监听盾牌变化
ASC->GetGameplayAttributeValueChangeDelegate(UBasicAttributeSet::GetShieldAttribute())
.AddUObject(this, &UPlayerVitals::OnShieldChanged);
}

5.2 测试效果

1
2
3
4
初始:盾牌0,生命100
受到50伤害 → 盾牌0,生命50(无盾时直接扣血)
使用护盾技能 → 盾牌100,生命50
受到80伤害 → 盾牌20,生命50(先扣盾)

📌 六、本集核心八股

6.1 元属性设计模式

1
2
3
4
5
6
伤害Effect → 修改Damage元属性
PostGameplayEffectExecute → 读取Damage → 清零

复杂伤害逻辑(先扣盾/后扣血)

修改实际属性(Shield/Health)

6.2 属性计算流程

1
2
3
4
5
6
7
原始伤害(SetByCaller)

Damage元属性(临时存储)

Post处理:
├── 扣盾牌
└── 扣生命值

6.3 盾牌系统架构

1
2
3
4
属性层:Shield/MaxShield(同步)
效果层:GE_FillShield(填充)
逻辑层:PostGameplayEffectExecute(扣减)
UI层:ShieldBar显示

6.4 优势总结

  • 灵活性:可轻松添加多种防御属性
  • 可扩展:后续可加护甲、魔抗等
  • 网络友好:只同步最终属性,中间计算在服务器

✅ 七、验收清单

  • 添加Damage元属性(不复制)
  • 修改伤害Effect改为修改Damage
  • 添加Shield/MaxShield属性(带复制)
  • 实现PreAttributeChange限制
  • PostGameplayEffectExecute处理伤害
  • 先扣盾再扣血逻辑
  • Damage清零
  • 创建GE_FillShield(Override操作)
  • 实现GA_FillShield(动画+触发)
  • UI显示盾牌条
  • 测试优先扣盾效果

Part 17.5: Polished Shield Ability

🎯 核心目标

优化护盾能力的视觉和音效反馈,实现护盾激活时的发光效果和破裂时的爆炸特效


✨ 一、护盾激活效果

1.1 护盾发光材质

1
2
3
4
// 创建叠加材质(Overlay Material)
- 在角色骨骼网格上叠加半透明发光材质
- 材质参数:颜色(蓝色/金色)、透明度、发光强度
- 动态调整:可根据护盾强度改变颜色/亮度

1.2 护盾激活Cue(Actor类型)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
UCLASS()
class AGC_ShieldActivate : public AGameplayCueNotify_Actor
{
GENERATED_BODY()

UPROPERTY()
UMaterialInstanceDynamic* OverlayMaterial;

virtual bool OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
USkeletalMeshComponent* Mesh = Target->FindComponentByClass<USkeletalMeshComponent>();
if (!Mesh) return false;

// 创建动态材质实例并叠加
OverlayMaterial = Mesh->CreateDynamicMaterialInstance(0, ShieldOverlayMaterial);
Mesh->SetOverlayMaterial(OverlayMaterial);

// 播放激活音效(只在OnActive播放一次)
UGameplayStatics::PlaySoundAtLocation(this, ActivateSound, Target->GetActorLocation());

return true;
}

virtual bool OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
USkeletalMeshComponent* Mesh = Target->FindComponentByClass<USkeletalMeshComponent>();
if (Mesh)
{
// 移除叠加材质
Mesh->SetOverlayMaterial(nullptr);
}
return true;
}

virtual bool WhileActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
// Late Join玩家进入时,确保材质显示
USkeletalMeshComponent* Mesh = Target->FindComponentByClass<USkeletalMeshComponent>();
if (Mesh && !Mesh->GetOverlayMaterial())
{
Mesh->SetOverlayMaterial(OverlayMaterial);
}
return true;
}
};

面试八股:为什么需要WhileActive?→ OnActive只在Cue添加时调用一次,Late Join玩家看不到效果,WhileActive确保新连接的玩家也能看到护盾状态。


💥 二、护盾破裂效果

2.1 护盾值变化监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 在BasicAttributeSet中
void UBasicAttributeSet::PostAttributeChange(const FGameplayAttribute& Attribute, float OldValue, float NewValue)
{
Super::PostAttributeChange(Attribute, OldValue, NewValue);

if (Attribute == GetShieldAttribute())
{
// 护盾从正数降到0
if (OldValue > 0.0f && NewValue <= 0.0f)
{
// 触发护盾破裂效果
AActor* OwnerActor = GetOwningActor();
UAbilitySystemComponent* ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(OwnerActor);
if (ASC)
{
FGameplayEffectSpecHandle SpecHandle = ASC->MakeOutgoingSpec(GE_ShieldBurst, 1, ASC->MakeEffectContext());
ASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
}
}
}
}

2.2 破裂Cue(Burst类型)

1
2
3
4
5
6
7
8
9
// GC_ShieldBurst
类型:GameplayCueNotify_Burst

配置:
- Niagara粒子:ShieldExplosion(护盾碎片飞散)
- 音效:ShieldBreakSound(破裂音效)
- 相机震动:可选

绑定标签:shield.burst

2.3 破裂效果执行

1
2
3
4
5
// GE_ShieldBurst
类型:Instant
GameplayCues:
- Execute:GC_ShieldBurst(单次执行)
(无Modifier,只触发特效)

面试八股:为什么用Execute而不是Add?→ 破裂是一次性效果,不需要持续存在,Burst类型最合适。


🎬 三、完整护盾生命周期

3.1 状态流转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
护盾激活:
1. 添加GC_ShieldActivate
→ OnActive:播放音效,添加发光材质
→ WhileActive:保持材质(Late Join支持)

护盾持续:
- 发光材质保持
- 护盾值可能被伤害消耗

护盾破裂:
1. 护盾值从正→0
2. 触发GE_ShieldBurst
3. Execute GC_ShieldBurst:播放爆炸粒子+音效
4. 移除GC_ShieldActivate
→ OnRemove:移除发光材质

3.2 护盾受伤反馈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void AGC_ShieldActivate::OnShieldHit()
{
// 护盾被击中时的反馈
// 可播放闪烁效果、撞击音效等
if (OverlayMaterial)
{
// 临时提高发光强度
OverlayMaterial->SetScalarParameterValue("GlowIntensity", 2.0f);

// 0.1秒后恢复
FTimerHandle Handle;
GetWorld()->GetTimerManager().SetTimer(Handle, [this]()
{
OverlayMaterial->SetScalarParameterValue("GlowIntensity", 1.0f);
}, 0.1f, false);
}
}

📌 四、本集核心八股

4.1 Gameplay Cue生命周期

1
2
3
4
5
6
7
Add Cue:
├── OnActive(立即触发,只一次)
├── WhileActive(保持状态,Late Join时触发)
└── OnRemove(移除时触发)

Execute Cue:
└── OnExecute(单次触发,立即执行)

4.2 护盾视觉设计模式

阶段 Cue类型 效果
激活 Actor 持续发光+音效
持续 WhileActive 保持视觉效果
受伤 临时参数 闪烁反馈
破裂 Burst 爆炸特效+音效

4.3 状态监听时机

1
2
3
属性变化 → PostAttributeChange
└── 护盾从正→0 → 触发破裂
└── 护盾从0→正 → 触发激活(已在能力中处理)

4.4 Late Join支持

  • OnActive:新玩家加入时不会触发
  • WhileActive:确保新玩家看到正确状态
  • 关键:状态必须可查询/可恢复

✅ 五、验收清单

  • 创建护盾发光叠加材质
  • GC_ShieldActivate实现(OnActive/WhileActive/OnRemove)
  • 激活时播放音效
  • Late Join测试(材质显示)
  • PostAttributeChange监听护盾变化
  • 护盾从正→0时触发破裂
  • 创建GC_ShieldBurst(Burst类型)
  • 添加爆炸粒子和音效
  • 护盾受伤闪烁反馈
  • 测试完整生命周期(激活→受伤→破裂)

Part 18: Combat Attributes

🎯 核心目标

实现战斗属性系统,添加力量和护甲属性,实现基于攻击者和目标属性的动态伤害计算


📊 一、战斗属性集设计

1.1 创建战斗属性集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
UCLASS()
class UCombatAttributeSet : public UAttributeSet
{
GENERATED_BODY()

public:
// 力量属性(提升伤害)
UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Strength)
FGameplayAttributeData Strength;

UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_MaxStrength)
FGameplayAttributeData MaxStrength;

// 护甲属性(减免伤害)
UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Armor)
FGameplayAttributeData Armor;

UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_MaxArmor)
FGameplayAttributeData MaxArmor;

ATTRIBUTE_ACCESSORS(UCombatAttributeSet, Strength)
ATTRIBUTE_ACCESSORS(UCombatAttributeSet, MaxStrength)
ATTRIBUTE_ACCESSORS(UCombatAttributeSet, Armor)
ATTRIBUTE_ACCESSORS(UCombatAttributeSet, MaxArmor)

UCombatAttributeSet()
{
MaxStrength = 100.0f;
Strength = 0.0f;
MaxArmor = 100.0f;
Armor = 0.0f;
}

// 属性限制
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override
{
if (Attribute == GetStrengthAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxStrength());
}
else if (Attribute == GetArmorAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxArmor());
}
}

// 网络复制
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME_CONDITION_NOTIFY(UCombatAttributeSet, Strength, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UCombatAttributeSet, MaxStrength, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UCombatAttributeSet, Armor, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UCombatAttributeSet, MaxArmor, COND_None, REPNOTIFY_Always);
}
};

1.2 添加到角色

1
2
3
4
5
6
7
8
9
10
11
UCLASS()
class ANexusCharacterBase : public ACharacter, public IAbilitySystemInterface
{
UPROPERTY()
UCombatAttributeSet* CombatAttributeSet;

ANexusCharacterBase()
{
CombatAttributeSet = CreateDefaultSubobject<UCombatAttributeSet>(TEXT("CombatAttributeSet"));
}
};

⚔️ 二、自定义伤害计算

2.1 捕获属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
UCLASS()
class UDMG_CombatCalculation : public UGameplayModMagnitudeCalculation
{
GENERATED_BODY()

// 定义要捕获的属性
FGameplayEffectAttributeCaptureDefinition StrengthDef;
FGameplayEffectAttributeCaptureDefinition ArmorDef;

UDMG_CombatCalculation()
{
// 捕获施法者的力量
StrengthDef = FGameplayEffectAttributeCaptureDefinition(
UCombatAttributeSet::GetStrengthAttribute(),
EGameplayEffectAttributeCaptureSource::Source, // 从施法者捕获
false
);

// 捕获目标的护甲
ArmorDef = FGameplayEffectAttributeCaptureDefinition(
UCombatAttributeSet::GetArmorAttribute(),
EGameplayEffectAttributeCaptureSource::Target, // 从目标捕获
false
);

RelevantAttributesToCapture.Add(StrengthDef);
RelevantAttributesToCapture.Add(ArmorDef);
}

virtual float CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const override
{
// 获取捕获的属性值
float Strength = 0.0f;
GetCapturedAttributeMagnitude(StrengthDef, Spec, Strength);

float Armor = 0.0f;
GetCapturedAttributeMagnitude(ArmorDef, Spec, Armor);

// 获取基础伤害
float BaseDamage = Spec.GetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag("data.damage"), false, 0.0f);

// 计算公式:最终伤害 = 基础伤害 × (1 + 0.05 × 力量) ÷ (1 + 0.05 × 护甲)
float StrengthMultiplier = 1.0f + 0.05f * Strength;
float ArmorMitigation = 1.0f / (1.0f + 0.05f * Armor);

float FinalDamage = BaseDamage * StrengthMultiplier * ArmorMitigation;

return FinalDamage;
}
};

面试八股:为什么用捕获(Capture)而不是直接读取?→ 捕获机制确保在伤害计算时使用正确的快照值,避免计算过程中属性变化导致不一致。


🎯 三、伤害计算公式分析

3.1 公式解读

1
2
3
4
5
6
7
力量加成:每点力量提升5%伤害
- 力量10 → 伤害提升50%
- 力量20 → 伤害提升100%

护甲减免:每点护甲减少约4.76%伤害(非线性)
- 护甲10 → 伤害减免约33%
- 护甲20 → 伤害减免约50%

3.2 测试数据

力量 护甲 基础伤害 最终伤害 说明
0 0 100 100 基准
10 0 100 150 力量加成
0 10 100 66.7 护甲减免
10 10 100 100 抵消

🔄 四、Effect应用方式详解

4.1 三种应用方式

1
2
3
4
5
6
7
8
9
// 1. ApplyGameplayEffectSpecToSelf
// 效果应用于调用者自身,调用者既是Source也是Target

// 2. ApplyGameplayEffectSpecToTarget
// 需要指定Source和Target,支持客户端预测
ASC->ApplyGameplayEffectSpecToTarget(SpecHandle, TargetASC);

// 3. ApplyGameplayEffectToSelf/Target
// 不使用SpecHandle,Source/Target通过上下文推断

4.2 Source和Target的重要性

1
2
3
4
5
6
// 伤害计算中:
// Source → 力量属性来源(攻击者)
// Target → 护甲属性来源(受击者)

// 错误用法:用ApplyToSelf导致Source=Target
// 正确用法:用ApplyToTarget,指定Source为攻击者,Target为受击者

面试八股:Source和Target的区别?→ Source是施法者,Target是受击者,伤害计算需要双方属性,必须正确区分。


🔥 五、解决火焰伤害问题

5.1 问题:火焰无施法者

火焰区域Actor没有AbilitySystemComponent,无法作为Source

5.2 解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 在火焰区域Actor中
void AEffectArea::BeginPlay()
{
// 保存创建者的ASC
InstigatorASC = GetInstigator()->FindComponentByClass<UAbilitySystemComponent>();
}

void AEffectArea::ApplyDamage(AActor* Target)
{
UAbilitySystemComponent* TargetASC = Target->FindComponentByClass<UAbilitySystemComponent>();
if (!TargetASC || !InstigatorASC) return;

// 创建Spec时指定Source
FGameplayEffectContextHandle Context = InstigatorASC->MakeEffectContext();
Context.AddInstigator(GetInstigator(), GetInstigator());

FGameplayEffectSpecHandle SpecHandle = InstigatorASC->MakeOutgoingGameplayEffectSpec(GE_Damage, 1, Context);
SpecHandle.Data->SetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag("data.damage"), DamageAmount);

// 应用到目标
InstigatorASC->ApplyGameplayEffectSpecToTarget(SpecHandle, TargetASC);
}

📌 六、本集核心八股

6.1 战斗属性系统架构

1
2
3
4
属性集:Strength/Armor(双方独立)
捕获:Source Strength + Target Armor
计算:自定义MMC类
应用:ApplyToTarget指定Source/Target

6.2 属性捕获要点

属性 捕获源 原因
力量 Source 攻击者的攻击能力
护甲 Target 防御者的防护能力

6.3 公式设计原则

  • 线性加成:简单易懂,每点价值固定
  • 递减收益:护甲越高,每点收益越低(避免数值爆炸)
  • 平衡性:力量和护甲可相互抵消

6.4 Effect应用选择

方式 适用场景 注意事项
ToSelf 自Buff/自伤害 Source=Target
ToTarget 攻击他人 必须指定Source
无Spec 简单场景 Source推断可能错误

✅ 七、验收清单

  • 创建CombatAttributeSet(Strength/Armor)
  • 添加到角色基类
  • 实现属性复制和限制
  • 创建自定义MMC计算类
  • 配置属性捕获(Source/Target)
  • 实现伤害计算公式
  • 测试力量加成效果
  • 测试护甲减免效果
  • 修复火焰伤害(传递Instigator)
  • 理解Source/Target区别

Part 19: Input Handling

🎯 核心目标

实现基于技能类别的统一输入处理系统,解决网络同步问题,简化技能输入管理


📋 一、传统输入绑定的问题

1.1 问题分析

1
2
3
4
5
6
7
8
9
传统做法:为每个技能单独绑定输入动作
- 新增技能需要创建新Input Action
- 每个技能都要写激活逻辑
- 难以维护和扩展

后果:
- 代码重复
- 项目膨胀
- 网络同步复杂

🏷️ 二、技能类别枚举

2.1 定义枚举

1
2
3
4
5
6
7
8
9
UENUM(BlueprintType)
enum class EAbilityInputID : uint8
{
None UMETA(DisplayName = "None"),
PrimaryAbility UMETA(DisplayName = "Primary Attack"), // 主攻击(左键)
SecondaryAbility UMETA(DisplayName = "Secondary Attack"), // 副攻击(右键)
DefensiveAbility UMETA(DisplayName = "Defensive"), // 防御技能(R键)
MovementAbility UMETA(DisplayName = "Movement") // 移动技能(Shift)
};

2.2 技能类中添加输入ID

1
2
3
4
5
6
7
8
9
10
UCLASS()
class UNexusGameplayAbility : public UGameplayAbility
{
GENERATED_BODY()

public:
// 技能对应的输入类别
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
EAbilityInputID AbilityInputID = EAbilityInputID::None;
};

面试八股:为什么用枚举而不是字符串?→ 枚举编译期检查,避免拼写错误,性能更好。


🔄 三、动态输入ID绑定

3.1 获取技能默认输入ID

1
2
3
4
5
6
7
8
9
10
11
12
// 赋予技能时
FGameplayAbilitySpec Spec(AbilityClass, 1, -1, this);

// 获取技能类的默认对象,读取输入ID
UNexusGameplayAbility* DefaultAbility = Cast<UNexusGameplayAbility>(AbilityClass->GetDefaultObject());
if (DefaultAbility)
{
// 枚举转整数作为输入ID
Spec.InputID = static_cast<uint8>(DefaultAbility->AbilityInputID);
}

ASC->GiveAbility(Spec);

3.2 输入处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ANexusCharacterBase::PressAbilityInputID(EAbilityInputID InputID)
{
if (!AbilitySystemComponent) return;

// 枚举转整数
uint8 InputIDValue = static_cast<uint8>(InputID);

// 调用ASC的输入处理
AbilitySystemComponent->PressInputID(InputIDValue);
}

void ANexusCharacterBase::ReleaseAbilityInputID(EAbilityInputID InputID)
{
if (!AbilitySystemComponent) return;

uint8 InputIDValue = static_cast<uint8>(InputID);
AbilitySystemComponent->ReleaseInputID(InputIDValue);
}

🎮 四、增强输入系统配置

4.1 创建Input Actions

1
2
3
4
IA_PrimaryAttack  → 鼠标左键
IA_SecondaryAttack → 鼠标右键
IA_Defensive → R键
IA_Movement → Shift键

4.2 Input Mapping Context

1
2
将四个Input Actions添加到同一个Mapping Context
在玩家控制器中Add Mapping Context

4.3 蓝图调用

1
2
3
4
5
6
7
8
// 在玩家蓝图中
InputAction IA_PrimaryAttack (Pressed):
PressAbilityInputID(PrimaryAbility)

InputAction IA_PrimaryAttack (Released):
ReleaseAbilityInputID(PrimaryAbility)

// 其他输入同理

🏃 五、冲刺技能改造

5.1 问题:方向同步

1
2
3
4
5
// 旧方案:通过事件传递LastMovementInputVector(不同步)
FVector DashDirection = GetLastMovementInputVector();

// 新方案:用CurrentAcceleration(可同步)
FVector DashDirection = GetCharacterMovement()->GetCurrentAcceleration().GetSafeNormal();

5.2 改造冲刺能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
UCLASS()
class UGA_Dash : public UNexusGameplayAbility
{
virtual void ActivateAbility() override
{
// 直接通过输入激活,不需要事件
if (!CommitAbility()) return;

// 获取加速度方向
ACharacter* Character = Cast<ACharacter>(GetAvatarActorFromActorInfo());
FVector Direction = Character->GetCharacterMovement()->GetCurrentAcceleration().GetSafeNormal();

if (Direction.IsNearlyZero())
{
// 无输入时,朝角色面向方向冲刺
Direction = Character->GetActorForwardVector();
}

// 执行冲刺
ApplyRootMotionConstantForce(Direction * DashStrength, DashDuration);
}
};

🔄 六、连击技能改造

6.1 旧方案的问题

1
需要发送自定义事件,通过RPC同步输入,复杂且易出错

6.2 新方案:WaitInputPress

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
UCLASS()
class UGA_Combo_Axe : public UMeleeAttackBase
{
virtual void ActivateAbility() override
{
// 播放第一段动画
PlayMontage(ComboMontage, 1); // 第一段

// 等待下一次输入
UAbilityTask_WaitInputPress* WaitInputTask = UAbilityTask_WaitInputPress::WaitInputPress(this);
WaitInputTask->OnPress.AddDynamic(this, &UGA_Combo_Axe::OnComboInput);
WaitInputTask->ReadyForActivation();
}

void OnComboInput(float TimeWaited)
{
// 收到输入,继续连击
CurrentCombo++;
if (CurrentCombo <= MaxCombo)
{
PlayMontage(ComboMontage, CurrentCombo);

// 继续等待下一次输入
UAbilityTask_WaitInputPress* WaitInputTask = UAbilityTask_WaitInputPress::WaitInputPress(this);
WaitInputTask->OnPress.AddDynamic(this, &UGA_Combo_Axe::OnComboInput);
WaitInputTask->ReadyForActivation();
}
}
};

面试八股:WaitInputPress的优势?→ 自动处理客户端预测和网络同步,无需手动RPC。


❌ 七、AOE技能取消

7.1 二次按下取消

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void ANexusPlayer::OnAOEInputPressed()
{
UAbilitySystemComponent* ASC = GetAbilitySystemComponent();
if (!ASC) return;

// 检查技能是否已激活
FGameplayTagContainer AbilityTags;
AbilityTags.AddTag(FGameplayTag::RequestGameplayTag("ability.aoe"));

if (ASC->GetTagCount(AbilityTags) > 0)
{
// 已激活,取消技能
ASC->CancelAbilities(&AbilityTags);
}
else
{
// 未激活,激活技能
PressAbilityInputID(EAbilityInputID::SecondaryAbility);
}
}

📌 八、本集核心八股

8.1 输入处理架构

1
2
3
枚举定义 → 技能类配置 → 赋予时读取 → 输入ID绑定

增强输入触发 → PressInputID → ASC处理

8.2 三种输入处理方式对比

方式 适用场景 优点 缺点
传统事件 复杂技能 灵活 需手动RPC
InputID 基础技能 简单,网络友好 只能绑定一个ID
WaitInputPress 连击/多段 自动同步 只能等待当前技能

8.3 同步问题解决方案

问题 原因 解决
方向不同步 用LastMovementInputVector 改用CurrentAcceleration
连击不同步 手动RPC复杂 用WaitInputPress
激活/取消 状态不同步 用InputID统一管理

8.4 设计原则

  • 技能类别固定:适合武器技能固定的游戏
  • 槽位绑定:适合自定义快捷键的游戏(参考WoW)
  • 数据驱动:复杂需求参考Lyra项目

✅ 九、验收清单

  • 定义EAbilityInputID枚举
  • 技能基类添加AbilityInputID属性
  • 赋予技能时读取默认值并设置InputID
  • 实现Press/ReleaseAbilityInputID函数
  • 创建四个Input Actions
  • 配置Input Mapping Context
  • 蓝图中调用输入处理函数
  • 冲刺技能改造(CurrentAcceleration)
  • 连击技能改造(WaitInputPress)
  • AOE技能二次按下取消
  • 测试网络同步

Part 19.5: Ability Widget With Input Icons

🎯 核心目标

为能力栏添加动态输入图标显示,实现按键绑定可视化、冷却状态反馈和激活状态提示


🖼️ 一、UI布局优化

1.1 新版Ability Widget结构

1
2
3
4
5
6
7
8
Size Box (132x132)
└── Overlay
├── Background(背景图)
├── Ability Image(能力图标)
├── Active Frame(激活边框,黄色)
└── Input Icon Container(输入图标容器)
├── Input Background(输入背景图)
└── Input Icon(输入图标)

1.2 图标资源准备

1
2
3
4
5
6
7
项目文件夹:/Content/UI/InputIcons/
命名规范:
- LeftMouseButton.png
- RightMouseButton.png
- Key_R.png
- Key_Shift.png
- Gamepad_A.png(可选,支持手柄)

🔗 二、输入名称映射

2.1 字符串到图标映射表

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在Ability Widget中
UPROPERTY()
TMap<FString, UTexture2D*> InputIconMap;

void UAbilityWidget::BuildInputIconMap()
{
// 初始化映射表
InputIconMap.Add("LeftMouseButton", LeftMouseIcon);
InputIconMap.Add("RightMouseButton", RightMouseIcon);
InputIconMap.Add("R", RKeyIcon);
InputIconMap.Add("LeftShift", ShiftIcon);
// ...其他按键
}

面试八股:为什么用字符串映射而不是枚举?→ Input Action的短名称是字符串,直接用字符串匹配最简单。

2.2 获取当前输入绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void UAbilityWidget::UpdateInputIcon()
{
if (!AbilitySpecHandle.IsValid()) return;

// 获取Input ID
uint8 InputID = GetInputIDFromSpecHandle(AbilitySpecHandle);

// 通过Input ID获取对应的Input Action
UInputAction* InputAction = GetInputActionForID(InputID);
if (!InputAction) return;

// 获取Input Action的短显示名称
FString InputName = InputAction->GetShortDisplayName().ToString();

// 查找对应图标
UTexture2D** IconPtr = InputIconMap.Find(InputName);
if (IconPtr)
{
InputIcon->SetBrushFromTexture(*IconPtr);
InputIcon->SetVisibility(ESlateVisibility::Visible);
}
}

🔄 三、动态更新时机

3.1 构造后延迟更新

1
2
3
4
5
6
7
void UAbilityWidget::NativeConstruct()
{
Super::NativeConstruct();

// 延迟一帧,确保输入绑定已生效
GetWorld()->GetTimerManager().SetTimerForNextTick(this, &UAbilityWidget::UpdateInputIcon);
}

3.2 输入重绑定时更新

1
2
3
4
5
6
7
8
9
10
11
// 在玩家控制器或设置界面中
void OnInputRebound(EAbilityInputID InputID, UInputAction* NewAction)
{
// 更新映射配置

// 广播输入变更事件
OnInputBindingsChanged.Broadcast();
}

// Ability Widget监听
OnInputBindingsChanged.AddDynamic(this, &UAbilityWidget::UpdateInputIcon);

🎨 四、状态反馈

4.1 冷却状态

1
2
3
4
5
6
7
8
9
10
11
12
13
void UAbilityWidget::OnCooldownStart(float Duration)
{
// 降低图标透明度
InputIcon->SetRenderOpacity(0.3f);

// 启动计时器,结束时恢复
GetWorld()->GetTimerManager().SetTimer(CooldownTimer, this, &UAbilityWidget::OnCooldownEnd, Duration, false);
}

void UAbilityWidget::OnCooldownEnd()
{
InputIcon->SetRenderOpacity(1.0f);
}

4.2 激活状态

1
2
3
4
5
6
7
8
9
10
11
12
13
void UAbilityWidget::OnAbilityActivate(bool bActive)
{
if (bActive)
{
// 激活时变为黄色
InputIcon->SetColorAndOpacity(FLinearColor::Yellow);
}
else
{
// 非激活时为白色
InputIcon->SetColorAndOpacity(FLinearColor::White);
}
}

面试八股:为什么用透明度表示冷却?→ 直观且不占用额外空间,玩家一眼能看出技能是否可用。


⚙️ 五、可配置选项

5.1 显示开关

1
2
3
4
5
6
7
8
9
10
11
12
UCLASS()
class UAbilityWidget : public UUserWidget
{
UPROPERTY(EditAnywhere, BlueprintReadOnly)
bool bShowInputIcons = true; // 是否显示输入图标

UPROPERTY(EditAnywhere, BlueprintReadOnly)
bool bShowCooldownText = false; // 是否显示冷却数字

UPROPERTY(EditAnywhere, BlueprintReadOnly)
float CooldownOpacity = 0.3f; // 冷却时透明度
};

5.2 布局调整

1
2
3
4
5
void UAbilityWidget::UpdateLayout()
{
InputIconContainer->SetVisibility(bShowInputIcons ?
ESlateVisibility::Visible : ESlateVisibility::Collapsed);
}

📦 六、扩展与迁移

6.1 新增输入图标

1
2
3
4
5
6
7
// 步骤:
1. 将新图标放入Content/UI/InputIcons/
2. 在AbilityWidget中加载
3. 添加到InputIconMap

// 命名必须与InputAction的ShortDisplayName一致
// 可通过控制台命令查看:showdebug input

6.2 项目迁移注意事项

1
2
3
4
5
6
7
8
9
依赖项:
- EAbilityInputID枚举
- UNexusGameplayAbility基类
- 输入系统配置

需要同步的内容:
- 枚举定义
- 图标资源
- 映射表初始化

📌 七、本集核心八股

7.1 输入图标系统架构

1
2
3
4
数据源:InputAction ShortDisplayName
映射表:字符串 → 图标资源
触发器:构造/输入重绑定
反馈:冷却/激活状态叠加

7.2 动态更新流程

1
2
3
能力赋予 → 记录InputID → 获取InputAction → 读取名称

查映射表 → 设置图标 → 绑定状态事件

7.3 状态反馈设计

状态 表现 实现
冷却 半透明 SetRenderOpacity
激活 黄色 SetColorAndOpacity
不可用 灰色 组合以上

7.4 扩展性考虑

  • 显示开关:满足不同用户偏好
  • 可配置透明度:适应不同视觉风格
  • 字符串映射:支持任意输入设备

✅ 八、验收清单

  • 新UI结构(添加输入图标容器)
  • 图标资源准备和导入
  • 字符串到图标映射表
  • 从InputID获取InputAction
  • 读取ShortDisplayName
  • 延迟一帧更新(确保绑定生效)
  • 冷却状态透明度变化
  • 激活状态颜色变化
  • 显示开关配置
  • 输入重绑定时更新
  • 测试手柄图标支持(可选)

Part 20: Status Effects & Debuffs

🎯 核心目标

实现反应性能力(Reactive Abilities)和状态效果系统,掌握叠加减益(Debuff)、条件触发和数据资产驱动设计


⚡ 一、反应性能力设计

1.1 什么是反应性能力?

1
2
3
4
5
反应性能力:始终处于激活状态的能力
- 监听属性变化或事件触发
- 自动响应特定条件
- 不消耗资源,无冷却
- 不能被普通能力取消

1.2 耐力回复反应性能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
UCLASS()
class UGA_StaminaRegen : public UNexusGameplayAbility
{
GENERATED_BODY()

UGA_StaminaRegen()
{
// 自动激活(授予即激活)
bAutoActivateWhenGranted = true;

// 不被其他能力取消
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
}

virtual void ActivateAbility() override
{
// 监听耐力变化
UAbilityTask_WaitAttributeChange* WaitTask = UAbilityTask_WaitAttributeChange::WaitForAttributeChange(
this,
UBasicAttributeSet::GetStaminaAttribute(),
FGameplayTag(),
false
);
WaitTask->OnChange.AddDynamic(this, &UGA_StaminaRegen::OnStaminaChanged);
WaitTask->ReadyForActivation();
}

void OnStaminaChanged(const FOnAttributeChangeData& Data)
{
float NewStamina = Data.NewValue;
float OldStamina = Data.OldValue;

// 耐力减少时,启动回复
if (NewStamina < OldStamina)
{
StartRegen();
}
}
};

面试八股:反应性能力与传统能力的区别?→ 传统能力需要玩家主动激活,反应性能力自动响应事件,常用于被动技能和状态管理。


🔥 三、叠加减益设计

3.1 火焰减益效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// GE_FireDebuff
类型:Infinite
叠加策略:Aggregate by Source(按来源叠加)
最大叠加层数:无限制
周期:1.0
周期执行:Execute Periodic Effect On Application = false

Modifier:
- 属性:Health
- 操作:Add
- 数值:SetByCaller (data.burnDamage) -5.0

GrantedTags:
- debuff.fire

3.2 叠加策略详解

策略 说明 适用场景
No Stacking 禁止叠加 唯一状态
Aggregate by Source 每个施法者独立叠加 多玩家攻击
Aggregate by Target 目标总层数叠加 单玩家连击

面试八股:为什么火焰减益用Aggregate by Source?→ 多个玩家可独立叠加火焰层数,各自触发燃烧状态,符合游戏设计。


🔥 四、基于层数的燃烧状态

4.1 监听叠加层数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
UCLASS()
class UGA_BurnReaction : public UNexusGameplayAbility
{
UPROPERTY()
FGameplayTag DebuffTag; // debuff.fire

UPROPERTY()
int32 MaxStacks = 3; // 触发燃烧的层数阈值

UPROPERTY()
TSubclassOf<UGameplayEffect> BurnEffectClass; // 燃烧效果

virtual void ActivateAbility() override
{
// 监听Gameplay Effect应用事件
UAbilityTask_WaitGameplayEffectApplied* WaitTask = UAbilityTask_WaitGameplayEffectApplied::WaitGameplayEffectAppliedToTarget(
this,
FGameplayTargetTagFilter(),
FGameplayTagQuery(),
false
);
WaitTask->OnApplied.AddDynamic(this, &UGA_BurnReaction::OnEffectApplied);
WaitTask->ReadyForActivation();
}

void OnEffectApplied(AActor* Source, FGameplayEffectSpecHandle SpecHandle)
{
// 检查应用的效果是否带有火焰标签
if (!SpecHandle.Data->DynamicGrantedTags.HasTag(DebuffTag)) return;

// 获取当前叠加层数
UAbilitySystemComponent* TargetASC = GetAbilitySystemComponentFromActorInfo();
int32 StackCount = TargetASC->GetGameplayEffectCount(GE_FireDebuff, nullptr);

// 达到阈值时触发燃烧
if (StackCount >= MaxStacks)
{
ApplyBurnEffect();
}
}

void ApplyBurnEffect()
{
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(BurnEffectClass);
ApplyGameplayEffectSpecToOwner(SpecHandle);
}
};

面试八股:为什么反应性能力挂在攻击者而不是受击者?→ 每个攻击者独立监听自己施加的减益,避免多个攻击者互相干扰。


🔥 五、燃烧状态效果

5.1 燃烧效果配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// GE_Burning
类型:Duration
持续时间:5.0
周期:0.5

Modifier:
- 属性:Health
- 操作:Add
- 数值:SetByCaller (data.burnDamage) -10.0

GrantedTags:
- status.burning

GameplayCues:
- Add:GC_Burning(持续火焰特效)

5.2 燃烧视觉特效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// GC_Burning(Actor类型)
UCLASS()
class AGC_Burning : public AGameplayCueNotify_Actor
{
UPROPERTY()
UParticleSystemComponent* FireEffect;

virtual bool OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
// 附着火焰粒子到目标
USkeletalMeshComponent* Mesh = Target->FindComponentByClass<USkeletalMeshComponent>();
if (Mesh)
{
FireEffect->AttachToComponent(Mesh, FAttachmentTransformRules::SnapToTargetIncludingScale);
FireEffect->Activate();
}
return true;
}

virtual bool OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
FireEffect->Deactivate();
return true;
}
};

📊 六、数据资产驱动设计

6.1 状态效果数据资产

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
UCLASS(BlueprintType)
class UStatusEffectData : public UPrimaryDataAsset
{
GENERATED_BODY()

public:
// 基础信息
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FGameplayTag DebuffTag; // 减益标签

UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 MaxStacks = 3; // 触发阈值

UPROPERTY(EditAnywhere, BlueprintReadOnly)
TSubclassOf<UGameplayEffect> StatusEffectClass; // 触发的状态效果

UPROPERTY(EditAnywhere, BlueprintReadOnly)
float StatusDuration = 5.0f; // 状态持续时间

// 视觉配置
UPROPERTY(EditAnywhere, BlueprintReadOnly)
UTexture2D* Icon; // UI图标

UPROPERTY(EditAnywhere, BlueprintReadOnly)
FLinearColor IconColor = FLinearColor::Red; // 图标颜色
};

6.2 数据驱动的反应性能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
UCLASS()
class UGA_StatusEffectReaction : public UNexusGameplayAbility
{
UPROPERTY(EditDefaultsOnly)
UStatusEffectData* EffectData; // 数据资产引用

void OnEffectApplied()
{
int32 StackCount = GetStackCount(EffectData->DebuffTag);

if (StackCount >= EffectData->MaxStacks)
{
ApplyStatusEffect();
}
}

void ApplyStatusEffect()
{
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(EffectData->StatusEffectClass);
SpecHandle.Data->SetDuration(EffectData->StatusDuration, true);
ApplyGameplayEffectSpecToOwner(SpecHandle);
}
};

面试八股:数据资产驱动的优势?→ 非程序员可配置新状态效果,无需修改代码;配置集中,易于平衡调整。


🌐 七、多人游戏支持

7.1 多玩家叠加

1
2
3
4
5
6
7
玩家A攻击 → 施加火焰减益(层数+1)
玩家B攻击 → 施加火焰减益(层数+1)
目标火焰层数:2(尚未触发燃烧)

玩家A再次攻击 → 层数+1 = 3
玩家A触发燃烧效果(来源为玩家A)
目标获得燃烧状态

7.2 网络同步

  • 减益层数通过Gameplay Effect自动同步
  • 燃烧状态通过Effect复制
  • 视觉特效通过Gameplay Cue同步

📌 八、本集核心八股

8.1 反应性能力架构

1
授予即激活 → 监听事件/属性变化 → 条件判断 → 触发效果

8.2 状态效果系统设计

1
减益层数(叠加)→ 达到阈值 → 触发状态效果 → 视觉/音效反馈

8.3 数据驱动优势

方面 传统方式 数据驱动
新增效果 新建蓝图 配置数据资产
调整参数 修改代码 改数据表
扩展性 有限 无限

8.4 叠加策略选择

  • Aggregate by Source:多人独立叠加
  • Aggregate by Target:总层数叠加
  • No Stacking:唯一状态

✅ 九、验收清单

  • 耐力回复反应性能力实现
  • 火焰减益Effect配置(可叠加)
  • 叠加策略选择(Aggregate by Source)
  • 监听Effect应用事件
  • 计算叠加层数
  • 达到阈值触发燃烧
  • 燃烧Effect配置(周期伤害)
  • 燃烧视觉特效(Gameplay Cue)
  • 创建StatusEffectData资产类
  • 数据驱动反应性能力改造
  • 多人叠加测试

Part 20.5: New Status Effects & Status Widgets

🎯 核心目标

扩展状态效果系统,添加冰冻效果和状态效果UI,实现多栈显示和网络同步


❄️ 一、冰冻状态效果设计

1.1 冰冻效果特性

1
2
3
4
5
效果:
- 减少移动速度(减速)
- 降低护甲值(防御下降)
- 视觉特效:冰霜覆盖
- 音效:冰冻爆炸

1.2 冰冻数据资产

1
2
3
4
5
6
7
// DA_StatusEffect_Freeze
DebuffTag: debuff.ice
MaxStacks: 3
StatusEffectClass: GE_Freeze
StatusDuration: 10.0f
Icon: T_Icon_Freeze
IconColor: FLinearColor(0.2, 0.6, 1.0) // 冰蓝色

1.3 冰冻效果实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// GE_Freeze
类型:Duration
持续时间:10.0
周期:无(持续效果)

Modifiers:
- 属性:MoveSpeed(需自定义属性)
操作:Multiply
数值:0.5(减速50%)

- 属性:Armor
操作:Multiply
数值:0.5(护甲减半)

GrantedTags:
- status.frozen

GameplayCues:
- Add:GC_Freeze(冰霜覆盖特效)

面试八股:为什么用Multiply操作而不是Add?→ 百分比减益适合用乘法,与属性值大小无关,避免数值溢出。


🧊 四、冰冻视觉特效

4.1 冰霜覆盖Cue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// GC_Freeze(Actor类型)
UCLASS()
class AGC_Freeze : public AGameplayCueNotify_Actor
{
UPROPERTY()
UParticleSystemComponent* IceEffect;

UPROPERTY()
UMaterialInstanceDynamic* IceMaterial;

virtual bool OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
USkeletalMeshComponent* Mesh = Target->FindComponentByClass<USkeletalMeshComponent>();
if (!Mesh) return false;

// 添加冰霜粒子
IceEffect->AttachToComponent(Mesh, FAttachmentTransformRules::SnapToTargetIncludingScale);
IceEffect->Activate();

// 添加冰霜覆盖材质
IceMaterial = Mesh->CreateDynamicMaterialInstance(0, IceOverlayMaterial);
Mesh->SetOverlayMaterial(IceMaterial);

// 播放冰冻音效
UGameplayStatics::PlaySoundAtLocation(this, FreezeSound, Target->GetActorLocation());

return true;
}

virtual bool OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
IceEffect->Deactivate();

USkeletalMeshComponent* Mesh = Target->FindComponentByClass<USkeletalMeshComponent>();
if (Mesh)
{
Mesh->SetOverlayMaterial(nullptr);
}

// 播放破碎音效
UGameplayStatics::PlaySoundAtLocation(this, BreakSound, Target->GetActorLocation());

return true;
}
};

📊 五、状态效果UI组件

5.1 UI架构

1
2
3
4
5
6
7
8
StatusEffectsBar(水平容器)
├── StatusEffectWidget(单个效果)
│ ├── Icon(图标)
│ ├── StackCount(叠加层数)
│ ├── FillProgress(层数进度)
│ └── TimeProgress(时间进度)
├── StatusEffectWidget
└── ...

5.2 单个效果Widget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
UCLASS()
class UStatusEffectWidget : public UUserWidget
{
UPROPERTY(meta = (BindWidget))
UImage* IconImage;

UPROPERTY(meta = (BindWidget))
UTextBlock* StackText;

UPROPERTY(meta = (BindWidget))
UProgressBar* FillProgress; // 层数进度(当前层/最大层)

UPROPERTY(meta = (BindWidget))
UProgressBar* TimeProgress; // 时间进度

UPROPERTY()
FActiveGameplayEffectHandle EffectHandle;

UPROPERTY()
UStatusEffectData* EffectData;

void UpdateStack(int32 CurrentStacks, int32 MaxStacks)
{
StackText->SetText(FText::FromString(FString::Printf(TEXT("%d/%d"), CurrentStacks, MaxStacks)));
FillProgress->SetPercent((float)CurrentStacks / MaxStacks);
}

void UpdateTime(float RemainingTime, float Duration)
{
TimeProgress->SetPercent(RemainingTime / Duration);
}
};

5.3 监听状态变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
UCLASS()
class UStatusEffectsBar : public UUserWidget
{
UPROPERTY()
AActor* OwnerActor;

UPROPERTY()
UAbilitySystemComponent* ASC;

UPROPERTY()
TMap<FActiveGameplayEffectHandle, UStatusEffectWidget*> ActiveEffects;

void SetOwnerActor(AActor* InOwner)
{
OwnerActor = InOwner;
ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(OwnerActor);

if (ASC)
{
// 监听效果添加/移除
ASC->OnActiveGameplayEffectAddedDelegateToSelf.AddUObject(this, &UStatusEffectsBar::OnEffectAdded);
ASC->OnAnyGameplayEffectRemovedDelegate().AddUObject(this, &UStatusEffectsBar::OnEffectRemoved);

// 初始化现有效果
RefreshAllEffects();
}
}

void OnEffectAdded(UAbilitySystemComponent* Target, const FGameplayEffectSpec& Spec, FActiveGameplayEffectHandle Handle)
{
// 检查是否有状态效果标签
if (Spec.Def->InheritableOwnedTagsContainer.CombinedTags.HasTag(FGameplayTag::RequestGameplayTag("status")))
{
AddEffectWidget(Handle);
}
}
};

面试八股:为什么要监听效果事件而不是Tick轮询?→ 事件驱动,性能更好,实时响应。


🔄 六、网络同步实现

6.1 问题:状态效果UI需要同步

1
2
// 状态效果数据在服务器,UI在客户端
// 需要确保所有客户端看到一致的效果

6.2 解决方案:多播事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 在角色类中添加多播函数
UFUNCTION(NetMulticast, Reliable)
void Multicast_StatusEffectUpdated(FActiveGameplayEffectHandle EffectHandle, EStatusEffectUpdateType UpdateType);

void ANexusCharacterBase::Multicast_StatusEffectUpdated_Implementation(FActiveGameplayEffectHandle EffectHandle, EStatusEffectUpdateType UpdateType)
{
// 在本地客户端更新UI
if (StatusEffectsBar)
{
switch (UpdateType)
{
case EStatusEffectUpdateType::Added:
StatusEffectsBar->AddEffect(EffectHandle);
break;
case EStatusEffectUpdateType::Removed:
StatusEffectsBar->RemoveEffect(EffectHandle);
break;
case EStatusEffectUpdateType::StackChanged:
StatusEffectsBar->UpdateStack(EffectHandle);
break;
}
}
}

6.3 层数变化通知

1
2
3
4
5
6
7
8
9
10
11
12
13
void UBasicAttributeSet::OnStackChanged(FActiveGameplayEffectHandle Handle, int32 NewStacks, int32 OldStacks)
{
AActor* Owner = GetOwningActor();
if (Owner && Owner->HasAuthority())
{
// 服务器广播层数变化
ANexusCharacterBase* Character = Cast<ANexusCharacterBase>(Owner);
if (Character)
{
Character->Multicast_StatusEffectUpdated(Handle, EStatusEffectUpdateType::StackChanged);
}
}
}

🎯 七、投射物扩展

7.1 元素投射物基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
UCLASS()
class AProjectile_Elemental : public AProjectileBase
{
UPROPERTY(EditDefaultsOnly)
UStatusEffectData* StatusEffectData; // 施加的状态效果

virtual void OnHit(AActor* Target) override
{
Super::OnHit(Target);

if (StatusEffectData && Target)
{
ApplyStatusEffect(Target);
}
}

void ApplyStatusEffect(AActor* Target)
{
UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Target);
if (!TargetASC) return;

// 应用减益效果
FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(GE_Debuff, 1);
SpecHandle.Data->DynamicGrantedTags.AddTag(StatusEffectData->DebuffTag);
TargetASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
}
};

7.2 子类配置

1
2
3
4
5
6
7
8
9
BP_Projectile_Fire
- StatusEffectData: DA_StatusEffect_Fire
- Visual: 火焰粒子
- Sound: 火焰音效

BP_Projectile_Ice
- StatusEffectData: DA_StatusEffect_Freeze
- Visual: 冰霜粒子
- Sound: 冰霜音效

📌 八、本集核心八股

8.1 状态效果UI架构

1
2
3
数据层:Gameplay Effect + StatusEffectData
监听层:ASC事件 → 多播广播
表现层:StatusEffectsBar + StatusEffectWidget

8.2 双进度条设计

进度条 作用 更新时机
FillProgress 当前层数/最大层数 层数变化
TimeProgress 剩余时间/总时间 Tick或定时器

8.3 网络同步方案

1
2
服务器:Effect变化 → 多播RPC
客户端:接收事件 → 更新UI

8.4 扩展性设计

  • 数据资产:新效果只需配置
  • 投射物基类:新元素只需继承
  • UI组件:自动适应任意效果

✅ 九、验收清单

  • 冰冻数据资产配置
  • GE_Freeze实现(减速+降甲)
  • GC_Freeze视觉特效(粒子+材质)
  • 冰冻音效
  • StatusEffectsBar UI容器
  • StatusEffectWidget设计(双进度条)
  • 监听Effect添加/移除事件
  • 多播RPC实现网络同步
  • 层数变化通知
  • 元素投射物基类
  • 火焰/冰冻投射物子类
  • 测试多人同步效果

Part 21: Skill Tree Setup

🎯 核心目标

实现天赋树系统的基础架构,包括天赋数据资产设计、天赋组件、赋予/升级机制和网络同步


📦 一、天赋数据资产设计

1.1 天赋类型枚举

1
2
3
4
5
6
7
UENUM(BlueprintType)
enum class ETalentType : uint8
{
Active UMETA(DisplayName = "Active"), // 主动技能
Passive UMETA(DisplayName = "Passive"), // 被动属性
Triggered UMETA(DisplayName = "Triggered") // 触发型(反应性)
};

1.2 天赋数据资产

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
UCLASS(BlueprintType)
class UTalentData : public UPrimaryDataAsset
{
GENERATED_BODY()

public:
// 基础信息
UPROPERTY(EditAnywhere, BlueprintReadOnly)
ETalentType TalentType;

UPROPERTY(EditAnywhere, BlueprintReadOnly)
FName TalentName;

UPROPERTY(EditAnywhere, BlueprintReadOnly)
UTexture2D* Icon;

UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText Description;

// 规则
UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 MaxLevel = 1; // 默认1级(不可升级)

// 赋予的内容
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<TSubclassOf<UGameplayAbility>> AbilitiesToGrant;

UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<TSubclassOf<UGameplayEffect>> EffectsToApply;
};

面试八股:为什么用PrimaryDataAsset而不是普通DataAsset?→ PrimaryDataAsset支持资产管理器加载和引用修复,适合作为核心配置数据。


📝 三、示例天赋配置

3.1 被动天赋:增加最大生命值

1
2
3
4
5
6
7
8
9
10
11
// DA_Talent_HealthIncrease
TalentType: Passive
MaxLevel: 5
EffectsToApply: GE_IncreaseMaxHealth

// GE_IncreaseMaxHealth
类型:Infinite
Modifier:
- 属性:MaxHealth
- 操作:Add
- 数值:曲线表(等级1:25, 等级5:50

3.2 触发天赋:耐力恢复

1
2
3
4
// DA_Talent_StaminaRegen
TalentType: Triggered
MaxLevel: 3
AbilitiesToGrant: GA_StaminaRegen // 反应性能力

3.3 主动天赋:冲刺

1
2
3
4
// DA_Talent_Dash
TalentType: Active
MaxLevel: 3
AbilitiesToGrant: GA_Dash

⚙️ 四、天赋树组件设计

4.1 已授予天赋结构体

1
2
3
4
5
6
7
8
9
10
11
USTRUCT(BlueprintType)
struct FGrantedTalent
{
GENERATED_BODY()

UPROPERTY()
UTalentData* Talent = nullptr;

UPROPERTY()
int32 Level = 0;
};

4.2 天赋树组件声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
UCLASS()
class UTalentTreeComponent : public UActorComponent
{
GENERATED_BODY()

public:
UTalentTreeComponent();

// 已授予的天赋(带复制)
UPROPERTY(ReplicatedUsing=OnRep_GrantedTalents)
TArray<FGrantedTalent> GrantedTalents;

// 默认天赋(在服务器授予)
UPROPERTY(EditAnywhere)
TArray<UTalentData*> DefaultTalents;

UFUNCTION(BlueprintCallable, Category = "Talents")
bool GrantTalent(UTalentData* Talent, int32 Level = 1, bool bSkipPointsCheck = false);

UFUNCTION(BlueprintCallable, Category = "Talents")
bool LevelUpTalent(UTalentData* Talent);

UFUNCTION(BlueprintCallable, Category = "Talents")
int32 GetTalentLevel(UTalentData* Talent) const;

UFUNCTION()
void OnRep_GrantedTalents();

private:
// 句柄映射(用于升级时调整等级)
UPROPERTY()
TMap<UTalentData*, TArray<FGameplayAbilitySpecHandle>> AbilitySpecsByTalent;

UPROPERTY()
TMap<UTalentData*, TArray<FActiveGameplayEffectHandle>> EffectHandlesByTalent;
};

面试八股:为什么用结构体数组而不是Map?→ 结构体数组支持网络复制,Map在网络同步上有问题。


🎁 五、天赋赋予流程

5.1 默认天赋初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
void UTalentTreeComponent::BeginPlay()
{
Super::BeginPlay();

// 仅在服务器执行
if (GetOwner()->HasAuthority())
{
for (UTalentData* Talent : DefaultTalents)
{
GrantTalent(Talent, 1, true); // 跳过点数检查
}
}
}

5.2 授予天赋实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
bool UTalentTreeComponent::GrantTalent(UTalentData* Talent, int32 Level, bool bSkipPointsCheck)
{
if (!Talent || !GetOwner()->HasAuthority()) return false;

// 检查是否已拥有
if (GetTalentLevel(Talent) > 0) return false;

// 检查点数(略)

UAbilitySystemComponent* ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(GetOwner());
if (!ASC) return false;

TArray<FGameplayAbilitySpecHandle> NewAbilityHandles;
TArray<FActiveGameplayEffectHandle> NewEffectHandles;

// 赋予能力
for (auto AbilityClass : Talent->AbilitiesToGrant)
{
if (AbilityClass)
{
FGameplayAbilitySpec Spec(AbilityClass, Level, -1, GetOwner());
FGameplayAbilitySpecHandle Handle = ASC->GiveAbility(Spec);
NewAbilityHandles.Add(Handle);
}
}

// 应用效果
for (auto EffectClass : Talent->EffectsToApply)
{
if (EffectClass)
{
FGameplayEffectSpecHandle SpecHandle = ASC->MakeOutgoingSpec(EffectClass, Level, ASC->MakeEffectContext());
if (SpecHandle.IsValid())
{
FActiveGameplayEffectHandle Handle = ASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
NewEffectHandles.Add(Handle);
}
}
}

// 保存记录
AbilitySpecsByTalent.Add(Talent, NewAbilityHandles);
EffectHandlesByTalent.Add(Talent, NewEffectHandles);

// 添加到已授予数组
FGrantedTalent Granted;
Granted.Talent = Talent;
Granted.Level = Level;
GrantedTalents.Add(Granted);

return true;
}

⬆️ 六、天赋升级流程

6.1 升级实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
bool UTalentTreeComponent::LevelUpTalent(UTalentData* Talent)
{
if (!Talent || !GetOwner()->HasAuthority()) return false;

int32 CurrentLevel = GetTalentLevel(Talent);
if (CurrentLevel <= 0 || CurrentLevel >= Talent->MaxLevel) return false;

int32 NewLevel = CurrentLevel + 1;

// 1. 升级能力
if (AbilitySpecsByTalent.Contains(Talent))
{
for (FGameplayAbilitySpecHandle Handle : AbilitySpecsByTalent[Talent])
{
SetAbilityLevel(Handle, NewLevel); // 自定义函数
}
}

// 2. 升级效果(重新应用)
if (EffectHandlesByTalent.Contains(Talent))
{
// 移除旧效果
for (FActiveGameplayEffectHandle Handle : EffectHandlesByTalent[Talent])
{
ASC->RemoveActiveGameplayEffect(Handle);
}

// 应用新效果
TArray<FActiveGameplayEffectHandle> NewHandles;
for (auto EffectClass : Talent->EffectsToApply)
{
FGameplayEffectSpecHandle SpecHandle = ASC->MakeOutgoingSpec(EffectClass, NewLevel, ASC->MakeEffectContext());
FActiveGameplayEffectHandle Handle = ASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
NewHandles.Add(Handle);
}
EffectHandlesByTalent[Talent] = NewHandles;
}

// 更新等级
for (FGrantedTalent& Granted : GrantedTalents)
{
if (Granted.Talent == Talent)
{
Granted.Level = NewLevel;
break;
}
}

return true;
}

6.3 自定义能力等级设置

1
2
3
4
5
6
7
8
9
10
11
12
// 在NexusAbilitySystemComponent中
void UNexusAbilitySystemComponent::SetAbilityLevel(FGameplayAbilitySpecHandle Handle, int32 NewLevel)
{
FGameplayAbilitySpec* Spec = FindAbilitySpecFromHandle(Handle);
if (Spec)
{
Spec->Level = NewLevel;

// 触发能力等级变化事件(可选)
OnAbilityLevelChanged.Broadcast(Handle, NewLevel);
}
}

面试八股:为什么Effect需要重新应用而能力只需改等级?→ Effect的等级在创建Spec时确定,不能动态修改;能力Spec的Level属性可直接更新。


🌐 七、网络同步

7.1 复制设置

1
2
3
4
5
6
7
void UTalentTreeComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// 只复制给拥有者
DOREPLIFETIME_CONDITION(UTalentTreeComponent, GrantedTalents, COND_OwnerOnly);
}

7.2 RepNotify

1
2
3
4
5
void UTalentTreeComponent::OnRep_GrantedTalents()
{
// 客户端更新UI
OnTalentsChanged.Broadcast();
}

📌 八、本集核心八股

8.1 天赋系统架构

1
2
3
4
数据层:UTalentData(配置)
逻辑层:UTalentTreeComponent(管理)
表现层:UI(展示)
网络层:Replication + RepNotify

8.2 天赋与能力/效果的关系

1
2
3
天赋(Talent)→ 包含多个能力(Abilities)和效果(Effects)
能力:主动/触发型技能
效果:被动属性修改

8.3 升级处理差异

类型 升级方式 原因
能力 直接改Level 能力实例可动态调整
效果 重新应用 Effect等级在Spec中固定

8.4 网络同步策略

  • GrantedTalents只复制给拥有者
  • 服务器执行所有修改
  • 客户端通过RepNotify更新UI

✅ 九、验收清单

  • ETalentType枚举定义
  • UTalentData资产类创建
  • 被动/触发/主动天赋示例配置
  • FGrantedTalent结构体
  • UTalentTreeComponent声明
  • GrantTalent实现(赋予能力/效果)
  • 句柄映射保存
  • LevelUpTalent实现
  • SetAbilityLevel自定义函数
  • 网络复制配置
  • 默认天赋初始化
  • 测试授予和升级

Part 22: Talent Tree UI

🎯 核心目标

实现天赋树用户界面,包括天赋图标显示、点数管理、交互逻辑和多玩家同步


🎨 一、单个天赋图标设计

1.1 控件结构

1
2
3
4
5
6
TalentWidget (Size: 100x130)
└── Overlay
├── IconImage (100x100)
├── NameText (底部居中)
├── LevelText (右上角)
└── InvisibleButton (全屏点击)

1.2 数据绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
UCLASS()
class UTalentWidget : public UUserWidget
{
GENERATED_BODY()

public:
UPROPERTY(meta = (BindWidget))
UImage* IconImage;

UPROPERTY(meta = (BindWidget))
UTextBlock* NameText;

UPROPERTY(meta = (BindWidget))
UTextBlock* LevelText;

UPROPERTY(meta = (BindWidget))
UButton* ClickButton;

UPROPERTY()
UTalentData* TalentData;

UPROPERTY()
UTalentTreeComponent* TalentTree;

void SetTalentData(UTalentData* InTalent, UTalentTreeComponent* InTree)
{
TalentData = InTalent;
TalentTree = InTree;

// 更新UI
IconImage->SetBrushFromTexture(InTalent->Icon);
NameText->SetText(FText::FromName(InTalent->TalentName));

// 绑定点击事件
ClickButton->OnClicked.AddDynamic(this, &UTalentWidget::OnTalentClicked);

// 更新等级显示
UpdateLevel();
}

void UpdateLevel()
{
if (!TalentTree || !TalentData) return;

int32 Level = TalentTree->GetTalentLevel(TalentData);
int32 MaxLevel = TalentData->MaxLevel;

LevelText->SetText(FText::FromString(FString::Printf(TEXT("%d/%d"), Level, MaxLevel)));

// 根据等级调整透明度/颜色
if (Level == 0)
{
// 未学习:半透明
IconImage->SetRenderOpacity(0.5f);
}
else if (Level == MaxLevel)
{
// 满级:金色边框(需额外图像)
}
}

UFUNCTION()
void OnTalentClicked()
{
if (!TalentTree || !TalentData) return;

int32 CurrentLevel = TalentTree->GetTalentLevel(TalentData);

if (CurrentLevel == 0)
{
// 未学习:尝试授予
TalentTree->GrantTalent(TalentData);
}
else if (CurrentLevel < TalentData->MaxLevel)
{
// 已学习但未满级:尝试升级
TalentTree->LevelUpTalent(TalentData);
}
}
};

面试八股:为什么用透明按钮而不是直接点击图标?→ 透明按钮可覆盖整个控件区域,点击响应更稳定,不受图标形状影响。


🌲 二、天赋树整体UI

2.1 容器设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
UCLASS()
class UTalentTreeWidget : public UUserWidget
{
GENERATED_BODY()

public:
UPROPERTY(meta = (BindWidget))
UCanvasPanel* BackgroundPanel; // 背景

UPROPERTY(meta = (BindWidget))
UWrapBox* TalentsContainer; // 天赋容器

UPROPERTY(meta = (BindWidget))
UTextBlock* TalentPointsText; // 可用点数显示

UPROPERTY()
UTalentTreeComponent* TalentTree;

UPROPERTY()
TArray<UTalentData*> AllTalents; // 所有可显示的天赋

UPROPERTY(EditAnywhere)
TSubclassOf<UTalentWidget> TalentWidgetClass;

virtual void NativeConstruct() override
{
Super::NativeConstruct();

// 获取玩家天赋树组件
APlayerController* PC = GetOwningPlayer();
if (PC && PC->GetPawn())
{
TalentTree = PC->GetPawn()->FindComponentByClass<UTalentTreeComponent>();
if (TalentTree)
{
// 监听天赋变化
TalentTree->OnTalentsChanged.AddDynamic(this, &UTalentTreeWidget::RefreshTalents);
TalentTree->OnTalentPointsChanged.AddDynamic(this, &UTalentTreeWidget::UpdatePointsDisplay);

// 初始刷新
RefreshTalents();
}
}
}

UFUNCTION()
void RefreshTalents()
{
if (!TalentTree || !TalentsContainer) return;

// 清空容器
TalentsContainer->ClearChildren();

// 重新创建所有天赋图标
for (UTalentData* Talent : AllTalents)
{
UTalentWidget* TalentWidget = CreateWidget<UTalentWidget>(this, TalentWidgetClass);
TalentWidget->SetTalentData(Talent, TalentTree);
TalentsContainer->AddChild(TalentWidget);
}
}

UFUNCTION()
void UpdatePointsDisplay()
{
if (TalentTree && TalentPointsText)
{
int32 Points = TalentTree->GetAvailableTalentPoints();
TalentPointsText->SetText(FText::FromString(FString::Printf(TEXT("天赋点数: %d"), Points)));
}
}
};

💰 三、天赋点数系统

3.1 点数变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UCLASS()
class UTalentTreeComponent : public UActorComponent
{
UPROPERTY(ReplicatedUsing=OnRep_TalentPoints)
int32 AvailableTalentPoints = 0;

UFUNCTION()
void OnRep_TalentPoints()
{
OnTalentPointsChanged.Broadcast();
}

public:
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FTalentPointsChanged);
UPROPERTY(BlueprintAssignable)
FOnTalentsChanged OnTalentPointsChanged;
};

3.2 点数消耗逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool UTalentTreeComponent::CanSpendPointsOnTalent(UTalentData* Talent)
{
int32 CurrentLevel = GetTalentLevel(Talent);

if (CurrentLevel == 0)
{
// 未学习:需要1点
return AvailableTalentPoints >= 1;
}
else if (CurrentLevel < Talent->MaxLevel)
{
// 升级:也需要1点
return AvailableTalentPoints >= 1;
}

return false; // 已满级
}

bool UTalentTreeComponent::SpendTalentPoint()
{
if (AvailableTalentPoints <= 0) return false;

AvailableTalentPoints--;
return true;
}

面试八股:为什么点数消耗独立实现?→ 避免自动调用导致同步问题,手动控制确保只有成功时才扣点。


🚫 四、UI禁用状态

4.1 可学性判断

1
2
3
4
5
6
7
8
9
10
11
12
bool UTalentWidget::CanAfford() const
{
if (!TalentTree || !TalentData) return false;

int32 Level = TalentTree->GetTalentLevel(TalentData);

// 已满级:不可学
if (Level >= TalentData->MaxLevel) return false;

// 检查点数
return TalentTree->GetAvailableTalentPoints() >= 1;
}

4.2 视觉反馈

1
2
3
4
5
6
7
8
void UTalentWidget::UpdateVisualState()
{
float Opacity = CanAfford() ? 1.0f : 0.3f;
IconImage->SetRenderOpacity(Opacity);

// 按钮可交互性
ClickButton->SetIsEnabled(CanAfford());
}

🎮 五、输入模式管理

5.1 显示/隐藏逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void ANexusPlayer::ToggleTalentTree()
{
if (!TalentTreeWidget)
{
// 创建并显示
TalentTreeWidget = CreateWidget<UTalentTreeWidget>(GetController(), TalentTreeClass);
TalentTreeWidget->AddToViewport();

// 切换输入模式
GetController()->SetInputMode(FInputModeGameAndUI());
GetController()->bShowMouseCursor = true;

// 禁用玩家移动
GetCharacterMovement()->DisableMovement();
}
else
{
// 隐藏
TalentTreeWidget->RemoveFromParent();
TalentTreeWidget = nullptr;

// 恢复输入模式
GetController()->SetInputMode(FInputModeGameOnly());
GetController()->bShowMouseCursor = false;

// 恢复移动
GetCharacterMovement()->SetDefaultMovementMode();
}
}

5.2 键盘关闭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 在TalentTreeWidget中
FReply UTalentTreeWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
if (InKeyEvent.GetKey() == EKeys::T)
{
// 关闭自身
RemoveFromParent();

// 恢复玩家输入
APlayerController* PC = GetOwningPlayer();
if (PC)
{
PC->SetInputMode(FInputModeGameOnly());
PC->bShowMouseCursor = false;
}

return FReply::Handled();
}

return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
}

面试八股:为什么需要手动切换输入模式?→ 防止玩家在UI打开时移动/攻击,同时允许鼠标点击UI。


🔄 六、多玩家同步

6.1 复制策略

1
2
// 天赋点只复制给拥有者
DOREPLIFETIME_CONDITION(UTalentTreeComponent, AvailableTalentPoints, COND_OwnerOnly);

6.2 UI更新流程

1
2
3
服务器:点数变化 → 触发RepNotify
客户端:OnRep_TalentPoints → 广播事件
UI:监听事件 → 更新显示

📌 七、本集核心八股

7.1 天赋UI架构

1
2
3
4
5
6
7
8
9
TalentWidget(单个)
├── 显示图标/名称/等级
├── 点击处理(授予/升级)
└── 状态反馈(透明度/颜色)

TalentTreeWidget(容器)
├── 管理所有天赋
├── 监听组件事件
└── 刷新显示

7.2 交互逻辑

状态 点击行为 点数要求
未学习 GrantTalent 1点
学习中 LevelUpTalent 1点
已满级 无响应 -

7.3 视觉反馈三要素

  • 透明度:可学(1.0)/不可学(0.3)
  • 等级显示:当前/最大
  • 按钮状态:Enable/Disable

7.4 输入模式管理

1
2
UI打开 → 游戏+UI模式 + 鼠标显示
UI关闭 → 游戏模式 + 鼠标隐藏

✅ 八、验收清单

  • TalentWidget控件设计(图标+名称+等级+按钮)
  • 数据绑定和点击事件
  • 等级显示更新
  • 可学性判断(点数和等级)
  • 视觉反馈(透明度)
  • TalentTreeWidget容器
  • WrapBox布局天赋
  • 监听天赋组件事件
  • 天赋点数显示
  • 输入模式切换
  • 键盘关闭UI
  • 网络同步测试

Part 23: Active Talents

🎯 核心目标

将主动技能(武器技能和通用技能)改造为天赋驱动,实现技能与武器的解耦和统一管理


🔧 一、问题分析

1.1 当前问题

1
2
3
4
5
武器直接赋予技能:
- 技能无法通过天赋点升级
- 武器切换时技能管理混乱
- 无法统一控制技能显示
- 敌人AI也需要独立处理

1.2 解决方案

1
2
3
4
5
所有技能改为天赋数据资产:
- 武器技能 → 主动天赋
- 通用技能(冲刺)→ 主动天赋
- 天赋树组件统一管理
- 武器只提供激活条件

📦 二、主动天赋数据资产

2.1 主动天赋配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DA_Talent_Projectile_Fire
TalentType: Active
MaxLevel: 3
AbilitiesToGrant: GA_Projectile_Fire
Icon: T_Icon_Fireball
Description: "发射火球术,造成火焰伤害"

// DA_Talent_Melee_Axe
TalentType: Active
MaxLevel: 3
AbilitiesToGrant: GA_MeleeAttack_AxeSwing, GA_Combo_Axe
Icon: T_Icon_Axe
Description: "使用斧头进行近战攻击"

// DA_Talent_Dash
TalentType: Active
MaxLevel: 3
AbilitiesToGrant: GA_Dash
Icon: T_Icon_Dash
Description: "向前冲刺一段距离"

面试八股:为什么一个天赋可以赋予多个能力?→ 复杂技能可能需要多个能力配合(如单次攻击+连击),统一管理更方便。


🔄 三、重构武器赋予逻辑

3.1 移除武器技能配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 旧版本:武器配置中包含技能
USTRUCT(BlueprintType)
struct FRS_WeaponConfig
{
UPROPERTY(EditAnywhere)
TArray<TSubclassOf<UGameplayAbility>> AbilitiesToGrant; // ❌ 移除
};

// 新版本:武器只保留基本属性
USTRUCT(BlueprintType)
struct FRS_WeaponConfig
{
UPROPERTY(EditAnywhere)
FName EquippedSocketName;

UPROPERTY(EditAnywhere)
TSubclassOf<UAnimInstance> AnimClass;

UPROPERTY(EditAnywhere)
FMovementProperties MovementProperties;
};

3.2 清理武器管理器

1
2
3
4
5
6
7
8
9
10
void UWeaponsManagerComponent::EquipWeapon(TSubclassOf<ABP_WeaponBase> WeaponClass)
{
// ... 生成和附着武器

// ❌ 不再从武器获取技能
// 不再调用 GrantAbilities

// 只广播武器变化事件
OnWeaponChanged.Broadcast(CurrentWeapon);
}

🎁 四、默认天赋赋予

4.1 默认天赋配置

1
2
3
4
5
6
7
8
9
UCLASS()
class UTalentTreeComponent : public UActorComponent
{
UPROPERTY(EditAnywhere)
TArray<UTalentData*> DefaultTalents; // 默认拥有的天赋

UPROPERTY(EditAnywhere)
TMap<TSubclassOf<ABP_WeaponBase>, UTalentData*> WeaponTalentMap; // 武器→天赋映射
};

4.2 游戏开始时赋予

1
2
3
4
5
6
7
8
9
10
11
12
13
void UTalentTreeComponent::BeginPlay()
{
Super::BeginPlay();

if (GetOwner()->HasAuthority())
{
// 赋予默认天赋(不消耗点数)
for (UTalentData* Talent : DefaultTalents)
{
GrantTalent(Talent, 1, true); // skip points check
}
}
}

面试八股:为什么默认天赋不消耗点数?→ 初始技能是角色基础能力,不应消耗玩家的天赋点数资源。


🎯 五、武器技能激活限制

5.1 武器技能基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
UCLASS()
class UGA_NexusWeaponAbility : public UNexusGameplayAbility
{
GENERATED_BODY()

public:
// 所需装备的武器
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TSubclassOf<ABP_WeaponBase> RequiredWeaponClass;

virtual bool CanActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const override
{
if (!Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags))
return false;

// 获取当前装备的武器
AActor* Avatar = ActorInfo->AvatarActor.Get();
if (!Avatar) return false;

UWeaponsManagerComponent* WeaponsManager = Avatar->FindComponentByClass<UWeaponsManagerComponent>();
if (!WeaponsManager) return false;

ABP_WeaponBase* CurrentWeapon = WeaponsManager->GetCurrentWeapon();

// 检查武器是否匹配
return CurrentWeapon && CurrentWeapon->IsA(RequiredWeaponClass);
}
};

5.2 改造武器技能

1
2
3
4
5
// GA_MeleeAttack_AxeSwing 改为继承 UGA_NexusWeaponAbility
RequiredWeaponClass: BP_Weapon_Axe

// GA_Projectile_Fire 改为继承 UGA_NexusWeaponAbility
RequiredWeaponClass: BP_Weapon_Staff

面试八股:为什么重写CanActivateAbility而不是在Activate中判断?→ 在激活前就阻止,避免消耗资源和触发冷却。


🎨 六、UI技能栏过滤

6.1 获取可显示技能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
TArray<FGameplayAbilitySpecHandle> UAbilityWidgetContainer::GetVisibleAbilities()
{
TArray<FGameplayAbilitySpecHandle> VisibleHandles;

if (!ASC) return VisibleHandles;

// 获取当前装备武器
UWeaponsManagerComponent* WeaponsManager = GetOwner()->FindComponentByClass<UWeaponsManagerComponent>();
ABP_WeaponBase* CurrentWeapon = WeaponsManager ? WeaponsManager->GetCurrentWeapon() : nullptr;

for (const FGameplayAbilitySpec& Spec : ASC->GetActivatableAbilities())
{
UNexusGameplayAbility* Ability = Cast<UNexusGameplayAbility>(Spec.Ability);
if (!Ability || !Ability->bShouldShowInAbilitiesBar) continue;

// 如果是武器技能,检查武器匹配
UGA_NexusWeaponAbility* WeaponAbility = Cast<UGA_NexusWeaponAbility>(Spec.Ability);
if (WeaponAbility)
{
if (!CurrentWeapon || !CurrentWeapon->IsA(WeaponAbility->RequiredWeaponClass))
continue;
}

VisibleHandles.Add(Spec.Handle);
}

return VisibleHandles;
}

6.2 监听武器切换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void UAbilityWidgetContainer::BindWeaponEvents()
{
UWeaponsManagerComponent* WeaponsManager = GetOwner()->FindComponentByClass<UWeaponsManagerComponent>();
if (WeaponsManager)
{
WeaponsManager->OnWeaponChanged.AddDynamic(this, &UAbilityWidgetContainer::OnWeaponChanged);
}
}

UFUNCTION()
void UAbilityWidgetContainer::OnWeaponChanged(ABP_WeaponBase* NewWeapon)
{
// 刷新技能栏显示
RefreshAbilitiesBar();
}

🧩 七、敌人AI适配

7.1 敌人天赋配置

1
2
3
4
5
// 在敌人蓝图类中配置
DefaultTalents:
- DA_Talent_Melee_Axe
- DA_Talent_Projectile_Fire
- DA_Talent_Dash

7.2 AI攻击选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void AAIController::SelectAttack()
{
UAbilitySystemComponent* ASC = GetPawn()->FindComponentByClass<UAbilitySystemComponent>();
if (!ASC) return;

// 获取所有可用的武器技能
TArray<FGameplayAbilitySpec> AvailableAbilities;

for (const FGameplayAbilitySpec& Spec : ASC->GetActivatableAbilities())
{
UGA_NexusWeaponAbility* WeaponAbility = Cast<UGA_NexusWeaponAbility>(Spec.Ability);
if (WeaponAbility && WeaponAbility->CanActivateAbility(Spec.Handle, ...))
{
AvailableAbilities.Add(Spec);
}
}

// 随机选择一个
if (AvailableAbilities.Num() > 0)
{
int32 Index = FMath::RandRange(0, AvailableAbilities.Num() - 1);
ASC->TryActivateAbility(AvailableAbilities[Index].Handle);
}
}

📌 八、本集核心八股

8.1 主动天赋系统架构

1
2
3
4
5
6
7
8
9
天赋数据(配置)

天赋组件(管理)

能力系统(执行)

武器条件(限制)

UI展示(过滤)

8.2 武器与技能解耦

旧方案 新方案
武器包含技能 天赋包含技能
装备时赋予 默认天赋
切换时重新赋予 武器检查激活条件
技能无法升级 可天赋升级

8.3 技能激活流程

1
2
3
4
5
6
7
8
玩家尝试激活技能

检查技能类型

如果是武器技能 → 检查当前武器

匹配 → 激活
不匹配 → 失败

8.4 优势总结

  • 统一管理:所有技能都在天赋树中
  • 可升级:技能等级由天赋控制
  • 灵活配置:武器只提供激活条件
  • UI友好:自动根据武器显示技能

✅ 九、验收清单

  • 创建所有主动天赋数据资产
  • 移除武器配置中的技能列表
  • 清理武器管理器技能相关代码
  • 默认天赋数组配置
  • BeginPlay中赋予默认天赋
  • 创建UGA_NexusWeaponAbility基类
  • 添加RequiredWeaponClass属性
  • 重写CanActivateAbility检查武器
  • 改造所有武器技能继承新基类
  • UI技能栏过滤逻辑
  • 监听武器切换刷新UI
  • 敌人AI适配
  • 测试武器切换时技能栏更新

Part 24: Leveling Up Talents

🎯 核心目标

实现天赋升级机制,通过技能等级驱动多维度成长(冷却缩减、伤害提升、次数增加、功能扩展)


📈 一、技能等级成长维度

1.1 可扩展的维度

维度 说明 示例
功能性扩展 增加新机制 冲刺次数+1
冷却缩减 减少冷却时间 30秒→20秒
消耗降低 减少资源消耗 体力25→15
伤害提升 增加伤害数值 100→150
多效果组合 同时影响多个属性 护盾值+冷却缩减

面试八股:为什么技能等级能影响多个维度?→ 通过组合多个Gameplay Effect和曲线表实现,每个维度独立控制。


🏃 二、冲刺次数扩展

2.1 次数变量设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
UCLASS()
class UGA_Dash : public UNexusGameplayAbility
{
// 当前已用次数
int32 DashChargesUsed = 0;

// 获取最大次数(基于等级)
int32 GetMaxDashCharges() const
{
return GetAbilityLevel(); // 等级1=1次,等级2=2次,等级3=3次
}

virtual void ActivateAbility() override
{
if (!CommitAbility()) return;

DashChargesUsed++;

// 执行冲刺逻辑
PerformDash();
}

bool CanDash() const
{
return DashChargesUsed < GetMaxDashCharges();
}

void OnCooldownExpired()
{
// 冷却结束后重置次数
DashChargesUsed = 0;
}
};

面试八股:为什么用次数计数而不是直接允许连续激活?→ 可以精确控制资源恢复时机(如冷却结束后重置所有次数)。


⏱️ 三、冷却缩减实现

3.1 曲线表配置

1
2
3
4
5
6
Curve: Talent_CooldownReduction
等级1: 1.0 (30秒)
等级2: 0.8 (24秒)
等级3: 0.6 (18秒)
等级4: 0.4 (12秒)
等级5: 0.2 (6秒)

3.2 动态冷却效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// GE_Dash_Cooldown
类型:Duration
持续时间:曲线表驱动

// 在能力中
void UGA_Dash::GetCooldownTime(float& TimeRemaining, float& CooldownDuration) const
{
// 从曲线表获取当前等级的冷却时间
UCurveTable* CurveTable = GetCurveTable();
static FRealCurve* Curve = CurveTable->FindCurve("Talent_CooldownReduction", "");

float Multiplier = Curve->Eval(GetAbilityLevel());
CooldownDuration = BaseCooldown * Multiplier;

// 计算剩余时间
TimeRemaining = GetCooldownTimeRemaining();
}

💪 四、护盾技能双重扩展

4.1 护盾效果组合

1
2
3
4
5
6
7
8
// GE_GiveShield(基础护盾)
类型:Duration
持续时间:15
Modifier:Shield = MaxShield

// GE_IncreaseMaxShield(增加最大护盾)
类型:Infinite
Modifier:MaxShield = BaseMaxShield + (Level * 50)

4.2 天赋配置

1
2
3
4
5
6
// DA_Talent_Shield
MaxLevel: 5
AbilitiesToGrant: GA_Shield
EffectsToApply:
- GE_GiveShield(每次激活时)
- GE_IncreaseMaxShield(永久,随等级提升)

面试八股:为什么要分两个Effect?→ 一个负责激活时的护盾填充,一个负责永久属性提升,职责分离,便于单独调整。


🔥 五、伤害增长实现

5.1 数学表达式控制

1
2
3
4
5
6
7
8
9
10
11
12
float UGA_MeleeAttack::CalculateDamage() const
{
float BaseDamage = 100.0f;
int32 Level = GetAbilityLevel();

// 公式:100 + (等级-1) * 50
// 等级1:100,等级2:150,等级3:200
return BaseDamage + (Level - 1) * 50.0f;

// 或使用乘法:100 * (1 + (等级-1) * 0.5)
// return BaseDamage * (1.0f + (Level - 1) * 0.5f);
}

5.2 起点控制

1
2
3
4
5
6
7
8
// 需求:等级1不增加伤害,从等级2开始递增
float GetDamageMultiplier() const
{
int32 Level = GetAbilityLevel();

if (Level == 1) return 1.0f;
return 1.0f + (Level - 1) * 0.25f; // 等级2:1.25, 等级3:1.5
}

5.3 连击伤害递增

1
2
3
4
5
6
7
8
// 连击技能中,每段伤害也随等级提升
float GetComboDamage(int32 ComboIndex) const
{
float BaseComboDamage[3] = { 50.0f, 75.0f, 100.0f };
float LevelMultiplier = 1.0f + (GetAbilityLevel() - 1) * 0.2f;

return BaseComboDamage[ComboIndex] * LevelMultiplier;
}

🔄 六、技能替换机制

6.1 天赋互斥设计

1
2
3
4
5
6
7
8
9
10
11
UCLASS()
class UTalentData : public UPrimaryDataAsset
{
// 互斥天赋(学习此天赋时移除的)
UPROPERTY(EditAnywhere)
TArray<UTalentData*> MutuallyExclusiveTalents;

// 前置天赋(需要先学习的)
UPROPERTY(EditAnywhere)
TArray<UTalentData*> PrerequisiteTalents;
};

6.2 技能替换实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool UTalentTreeComponent::GrantTalent(UTalentData* Talent, int32 Level, bool bSkipPointsCheck)
{
// ... 前置检查

// 移除互斥天赋
for (UTalentData* Exclusive : Talent->MutuallyExclusiveTalents)
{
if (GetTalentLevel(Exclusive) > 0)
{
RemoveTalent(Exclusive);
}
}

// 移除旧技能(通过AbilitiesToRemove配置)
for (auto AbilityClass : Talent->AbilitiesToRemove)
{
RemoveAbilityByClass(AbilityClass);
}

// 授予新天赋
// ...
}

6.3 应用示例:火焰→冰霜

1
2
3
4
5
6
7
8
9
// DA_Talent_Fireball
AbilitiesToGrant: GA_Projectile_Fire
MutuallyExclusiveTalents: DA_Talent_Frostbolt

// DA_Talent_Frostbolt
AbilitiesToGrant: GA_Projectile_Frost
MutuallyExclusiveTalents: DA_Talent_Fireball

// 效果:学习冰霜时自动移除火焰,反之亦然

面试八股:互斥天赋的设计意义?→ 实现职业/专精选择,玩家必须在不同流派间做出选择,增加策略性。


📊 七、完整成长示例

7.1 冲刺技能成长表

等级 最大次数 冷却时间 消耗体力 伤害
1 1次 30秒 25 0
2 2次 24秒 20 0
3 3次 18秒 15 0
4 3次 12秒 10 冲锋伤害+50
5 3次 6秒 5 冲锋伤害+100

7.2 护盾技能成长表

等级 持续时间 护盾值 冷却时间 额外效果
1 15秒 100 30秒 -
2 15秒 150 27秒 -
3 15秒 200 24秒 受击时减速敌人
4 15秒 250 21秒 受击时反弹伤害
5 15秒 300 18秒 免疫控制

📌 八、本集核心八股

8.1 技能成长实现方式

维度 实现方式 优点
功能性 能力内变量判断 灵活,可做复杂逻辑
冷却 曲线表 集中配置,易于调整
消耗 曲线表 同上
伤害 数学表达式 可精细控制曲线
组合 多Effect叠加 职责分离,可复用

8.2 曲线表 vs 硬编码

方式 优点 缺点
曲线表 可配置,易调整 需要额外资源
硬编码 简单直接 每次改需求要改代码

8.3 天赋互斥设计模式

1
2
天赋A → 互斥列表 [天赋B, 天赋C]
学习A时 → 自动移除B和C

8.4 系统完整性检查

  • 所有技能可升级
  • 成长曲线合理(避免数值爆炸)
  • 互斥关系正确
  • 网络同步正常

✅ 九、验收清单

  • 冲刺次数扩展实现
  • 冷却缩减曲线表配置
  • 消耗缩减曲线表配置
  • 护盾双重效果(护盾值+最大护盾)
  • 伤害增长数学表达式
  • 伤害起点控制(等级1不增加)
  • 连击伤害随等级提升
  • 天赋互斥配置
  • 移除互斥天赋逻辑
  • 技能替换(AbilitiesToRemove)
  • 完整技能成长表测试
  • 网络同步验证

🎉 GAS系列完结 🎉

本系列共24部分,完整覆盖了从基础搭建到复杂天赋系统的全部内容,包括:

  • GAS基础架构(Part 1-3)
  • UI系统(Part 4-4.5)
  • 武器系统(Part 6-7.5)
  • 技能系统(Part 8-13.5)
  • 状态与死亡(Part 14-15.5)
  • 防御与伤害(Part 16-18)
  • 输入与UI(Part 19-19.5)
  • 状态效果(Part 20-20.5)
  • 天赋系统(Part 21-24)
  • Title: Unreal Engine——《GAS_Nexus》技术报告
  • Author: ELecmark
  • Created at : 2026-03-19 15:16:49
  • Updated at : 2026-03-20 00:13:08
  • Link: https://elecmark.github.io/2026/03/19/《GAS-Nexus》技术报告/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
Unreal Engine——《GAS_Nexus》技术报告