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 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 () { AbilitySystemComponent = CreateDefaultSubobject <UAbilitySystemComponent>(TEXT ("AbilitySystemComponent" )); AbilitySystemComponent->SetIsReplicated (true ); 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:三要素:
复制模式 :Minimal/Mixed控制同步粒度
RPC :技能激活走ServerTryActivateAbility
客户端预测 :GAS内置预测,减少延迟感
Q:PossessedBy和OnRep_PlayerState区别?
PossessedBy
OnRep_PlayerState
调用端
服务器
客户端
时机
Controller控制角色时
PlayerState复制完成时
用途
初始化服务器ASC
初始化客户端ASC
✅ 六、验收清单
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 Ability2. 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 1. Get Avatar Actor From Actor Info → 获取角色2. 设置冲刺方向(角色前向)3. 设置力量2000 ,持续时间0.3 秒4. 任务完成 → End Ability
3.3 方向优化 1 2 3 4 1. Get Last Movement Input Vector → 获取最后一次输入方向2. 如果有效 → 用输入方向3. 无效 → 回退到角色前向
面试八股 :为什么要用Last Input Vector?→ 解决转向时冲刺方向延迟,玩家按左键时即使角色未转向,也能向左冲刺。
3.4 速度问题修复
问题
原因
解决
空中冲刺后无限滑行
Velocity On Finish默认保持速度
改为Clamp Velocity,限制为Max Walk Speed
1 2 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 1. 获取目标角色2. 隐藏模型(Set Visibility false )3. 生成传送粒子,附着根组件4. 播放音效1. 显示模型2. 生成爆炸粒子3. 播放结束音效
4.3 技能中调用Cue 1 2 3 4 5 6 7 - 添加标签 "gameplay cue dash.active" - 技能结束自动移除 → 触发On Removed - 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状态错误
✅ 六、验收清单
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 : UPROPERTY (BlueprintReadOnly, Category = "Attributes" ) FGameplayAttributeData Health; UPROPERTY (BlueprintReadOnly, Category = "Attributes" ) FGameplayAttributeData MaxHealth; UPROPERTY (BlueprintReadOnly, Category = "Attributes" ) FGameplayAttributeData Stamina; UPROPERTY (BlueprintReadOnly, Category = "Attributes" ) FGameplayAttributeData MaxStamina; 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 () { 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 (); 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,不需要手动计算
✅ 八、验收清单
🎯 核心目标 实现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 1. Get Owning Player Pawn2. Get Ability System Component3. 获取属性值: - Health - MaxHealth - Stamina - MaxStamina 4. 存为变量5. 调用更新函数
2.2 监听属性变化 1 2 3 4 - 监听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(能力句柄) 1. Get Player ASC2. 通过AbilitySpecHandle获取Ability对象3. 获取Ability类名 → 显示在Text4. 初始化隐藏冷却层
3.3 冷却监听 1 2 3 4 5 6 7 8 9 - 监听 "Cooldown" 标签 - 进入冷却: - 显示冷却层 - 启动计时器(每0.1 秒更新) - 计算剩余时间 → 更新文本 - 冷却结束: - 隐藏冷却层 - 清除计时器
面试八股 :冷却时间计算方式?→ 通过标签判断冷却状态,用计时器驱动UI更新,避免每帧查询。
📦 四、AbilitiesContainer(能力容器) 4.1 初始化加载 1 2 3 4 5 6 7 1. Get Player ASC2. GetAllAbilities → 获取所有能力Spec Handle数组3. 遍历数组: - 创建AbilitySlot - 传入AbilitySpecHandle - 添加到Horizontal Box
4.2 问题:延迟加载 现象 :BeginPlay时能力还未赋予,UI显示为空
临时方案 :加Delay后重新加载(不推荐)
📡 五、GAS事件通信(核心) 5.1 发送事件 1 2 3 4 5 SendGameplayEventToActor ( EventTag = "event.abilities.changed" , Payload = ... )
5.2 监听事件 1 2 3 4 5 6 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)
冷却结束时隐藏层,清除计时器
✅ 八、验收清单
🎯 核心目标 优化能力小部件(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" ) 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 1. 获取玩家能力数量(N)2. 遍历N个能力 → 创建AbilityWidget3. 如果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稳定性:防止界面抖动
视觉一致性:固定布局
扩展预留:为后续能力留空间
✅ 八、验收清单
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 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 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 ()); } } 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类 + 数值 + 视觉效果 优点:新增效果只需建子类,改配置
✅ 六、验收清单
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 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) { FActorSpawnParameters SpawnParams; SpawnParams.Owner = GetOwner (); ABP_WeaponBase* NewWeapon = GetWorld ()->SpawnActor <ABP_WeaponBase>(WeaponClass, SpawnParams); FRS_WeaponConfig Config = NewWeapon->GetWeaponConfig (); USkeletalMeshComponent* Mesh = GetOwner ()->FindComponentByClass <USkeletalMeshComponent>(); NewWeapon->AttachToComponent (Mesh, FAttachmentTransformRules::SnapToTargetNotIncludingScale, Config.EquippedSocketName); Mesh->SetAnimInstanceClass (Config.AnimClass); CurrentWeapon = NewWeapon; }
3.3 卸下武器流程 1 2 3 4 5 6 7 8 9 10 11 12 13 void UWeaponsManagerComponent::UnequipWeapon () { if (CurrentWeapon) { CurrentWeapon->Destroy (); CurrentWeapon = nullptr ; USkeletalMeshComponent* Mesh = GetOwner ()->FindComponentByClass <USkeletalMeshComponent>(); Mesh->SetAnimInstanceClass (DefaultAnimClass); } }
🏃 四、移动属性配置 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 UPROPERTY (EditAnywhere, BlueprintReadOnly)FMovementProperties MovementProperties;
4.3 装备时应用 1 2 3 4 5 6 7 8 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 if (CurrentWeapon && CurrentWeapon->IsA (StaffClass)) UnequipWeapon (); else EquipWeapon (StaffClass); 同理
5.2 优化逻辑
📌 六、本集核心八股 6.1 武器系统设计模式 1 2 3 4 数据层:WeaponConfig(结构体) 表现层:WeaponActor(模型+插槽) 逻辑层:WeaponsManagerComponent 动画层:AnimBlueprint(继承+变量化)
6.2 动画蓝图复用技巧
基类实现状态机逻辑
子类只替换资源(Blend Space/Idle)
运行时通过变量动态赋值
6.3 武器管理器职责
生成/销毁武器Actor
管理插槽附着
切换动画蓝图
调整移动属性
不包含输入/技能逻辑
6.4 移动属性配置要点
属性
作用
适用场景
MaxWalkSpeed
移动速度
重武器慢,轻武器快
OrientRotationToMovement
转向跟随移动方向
近战攻击
UseControllerDesiredRotation
转向跟随控制器
远程施法
✅ 七、验收清单
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; 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) { ABP_WeaponBase* NewWeapon = GetStowedWeaponByClass (WeaponClass); if (!NewWeapon) return ; PreviouslyEquippedWeapon = CurrentWeapon; FRS_WeaponConfig Config = NewWeapon->GetWeaponConfig (); PlayMontage (Config.EquipMontage, "UpperBody" ); }
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 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 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 GE_EquipWeapon_Cooldown 类型:Duration 持续时间:2.0 秒 标签:Cooldown.Equip 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 为什么不用销毁/重建?
方式
优点
缺点
销毁/重建
实现简单
无法播放收起动画
切换插槽
支持完整动画
需管理多个武器实例
✅ 八、验收清单
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 ; ABP_WeaponBase* NewWeapon = SpawnWeapon (WeaponClass); AttachToStowedSocket (NewWeapon); 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 void UGA_Dash::ActivateAbility () { if (!CommitAbility (CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo)) { EndAbility (); return ; } }
3.2 问题2:方向不同步 1 2 3 4 现象: - 客户端:基于LastMovementInputVector冲刺(正确方向) - 服务器:基于角色朝向冲刺(错误方向) - 结果:客户端位置抖动
原因 :LastMovementInputVector是客户端本地变量,不同步到服务器
解决方案1:用可复制属性
1 2 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
方向不同步
用了本地变量
改用可复制属性或事件传递
✅ 六、验收清单
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; 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; PlayMontage (Config.EquipMontage, "UpperBody" ); 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 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 void GiveStartingWeapons () { if (!HasAuthority ()) return ; } if (HasAuthority ()){ GiveStartingWeapons (); }
✅ 七、验收清单
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 UFUNCTION (BlueprintCallable, Category = "GAS" )TArray<FGameplayAbilitySpecHandle> GrantAbilities (TArray<TSubclassOf<UGameplayAbility>> AbilitiesToGrant) ;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 ) ; FGameplayAbilitySpecHandle Handle = AbilitySystemComponent->GiveAbility (Spec); GrantedHandles.Add (Handle); } 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 ; AbilitySystemComponent->HandleGameplayEvent ( FGameplayTag::RequestGameplayTag (FName ("event.abilities.changed" )), &Payload ); }
3.2 UI监听事件 1 2 3 4 5 6 7 8 9 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 ; 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的能力
✅ 七、验收清单
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 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 (); 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 ; 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 动画通知的作用
精准控制伤害时机
与动画同步,避免伤害早于/晚于动画
支持多段攻击(多个窗口)
✅ 七、验收清单
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 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++; PlayMontageSection (ComboCount); } }
面试八股 :连击窗口的作用?→ 防止玩家乱按,只有在特定时间段内输入才有效,提升操作手感。
🌐 四、网络同步 4.1 问题:客户端输入同步 客户端点击左键,需要在服务器端执行连击逻辑
4.2 解决方案:RPC事件传递 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 UFUNCTION (Server, Reliable)void Server_SendGameplayEvent (FGameplayTag EventTag) ;void ANexusCharacterBase::Server_SendGameplayEvent_Implementation (FGameplayTag EventTag) { 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 钩子函数设计原则
在关键节点提供空白事件
子类按需重写
默认行为保留在父类
✅ 七、验收清单
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后,客户端完全信任服务器同步的动画位置,不进行本地预测,避免重复触发。
🔧 二、解决方案:拆分Notify 2.1 创建独立Notify 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 UCLASS ()class UANS_HitScanStart : public UAnimNotify{ virtual void Notify (USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation) override { SendGameplayEvent (MeshComp->GetOwner (), "hit_scan.start" ); } }; 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 UCLASS ()class UANS_ContinueComboStart : public UAnimNotify{ virtual void Notify (...) override { SendGameplayEvent (Owner, "combo.window.start" ); } }; 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开关两种状态
✅ 六、验收清单
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; UPROPERTY (EditDefaultsOnly)TSubclassOf<UGameplayCue> SpawnCueClass; UPROPERTY (EditDefaultsOnly)TSubclassOf<UGameplayCue> ImpactCueClass;
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 (); 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 ; UAbilitySystemComponent* TargetASC = OtherActor->FindComponentByClass <UAbilitySystemComponent>(); if (!TargetASC) return ; if (GetLocalRole () == ROLE_Authority) { TargetASC->ApplyGameplayEffectSpecToSelf (*DamageEffectSpecHandle.Data.Get ()); } 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 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); }
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 玩家瞄准 → 射线检测 → 返回目标位置 → 生成投射物 (目标位置复制到所有客户端,保证弹道一致)
✅ 七、验收清单
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 ); }
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 void UW_Crosshair::UpdateSize () { APawn* OwnerPawn = GetOwningPlayerPawn (); if (!OwnerPawn) return ; float Speed = OwnerPawn->GetVelocity ().Size (); float NormalizedSpeed = FMath::GetMappedRangeValueClamped ( FVector2D (0 , 600 ), FVector2D (0 , 1 ), Speed ); float TargetSize = FMath::Lerp (30.0f , 200.0f , NormalizedSpeed); SetRenderScale (FVector2D (TargetSize / 100.0f )); }
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系统(十字准星) ↓ 监听 各自独立响应,互不干扰
✅ 七、验收清单
🎯 核心目标 掌握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
面试八股 :为什么需要层级标签?→ 灵活控制粒度,上层标签批量处理,下层标签精确控制。
4.1 在UI中的应用 1 2 3 4 5 6 7 Activation Owned Tags: ability.melee.active 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 Asset Tags: ability.equip.weapon Block Abilities with Tag: ability.equip.weapon 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)
✅ 七、验收清单
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 UCLASS ()class AGATargetActor_GroundTraceDecal : public AGameplayAbilityTargetActor_GroundTrace{ GENERATED_BODY () UPROPERTY () UDecalComponent* DecalComp; virtual void StartTargeting (UGameplayAbility* Ability) override { Super::StartTargeting (Ability); 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); if (DecalComp && TraceHitResult.bBlockingHit) { DecalComp->SetWorldLocation (TraceHitResult.Location); 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 { 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 类型: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 冷却与输入设计
技能激活后立即进入冷却
二次按下可取消(提升体验)
冷却期间无法激活
✅ 八、验收清单
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 bool UGC_AoE_Indicator::OnActive_Implementation (AActor* Target, const FGameplayCueParameters& Parameters) { 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 UCLASS ()class UGC_TargetingCamera : public UGameplayCueNotify_Static{ UPROPERTY (EditDefaultsOnly) FCameraSettings CameraOffset; 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 () { AddGameplayCueToOwner (FGameplayTag::RequestGameplayTag ("targeting.camera.start" )); } void UGA_AOEAttack::EndAbility () { 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 优化要点
问题
解决
效果
摄像机切换延迟
提前到装备动画开始
技能激活立即生效
射线起点错误
改为武器插槽
贴花位置准确
✅ 五、验收清单
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 void UBasicAttributeSet::PostGameplayEffectExecute (const FGameplayEffectModCallbackData& Data) { Super::PostGameplayEffectExecute (Data); if (Data.EvaluatedData.Attribute == GetHealthAttribute ()) { 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 类型: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 void UBasicAttributeSet::PostAttributeChange (const FGameplayAttribute& Attribute, float OldValue, float NewValue) { Super::PostAttributeChange (Attribute, OldValue, NewValue); if (Attribute == GetHealthAttribute () && NewValue <= 0.0f ) { 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 () { USkeletalMeshComponent* Mesh = GetMesh (); Mesh->SetCollisionEnabled (ECollisionEnabled::PhysicsOnly); Mesh->SetSimulatePhysics (true ); GetCharacterMovement ()->DisableMovement (); FVector Impulse = -GetActorForwardVector () * 500.0f + FVector::UpVector * 200.0f ; Mesh->AddImpulse (Impulse, NAME_None, true ); SetLifeSpan (5.0f ); }
面试八股 :为什么用BlueprintNativeEvent?→ 提供默认C++实现,同时允许蓝图重载实现个性化死亡效果(玩家和敌人可不同)。
🚫 四、能力阻止机制 4.1 所有能力配置 1 2 3 4 5 6 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
只触发一次
✅ 六、验收清单
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 void AAIController::StartAttackLoop () { 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 ; void OnComboWindowStart () { bInComboWindow = true ; 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使用不同的控制器类,可以准确区分。
🎯 四、远程攻击目标处理 4.1 问题:无玩家控制器时崩溃
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>()) { 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 结构: - ProgressBar (HealthBar) - 颜色:红色调 - 大小:100 x20 绑定: - 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
✅ 七、验收清单
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 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); 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 本地化渲染优势
✅ 五、验收清单
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 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 类型: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 类型: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 { UAbilitySystemComponent* TargetASC = Spec.GetContext ().GetTargetAbilitySystemComponent (); if (!TargetASC) return 0.0f ; bool bHasShield = TargetASC->HasMatchingGameplayTag (FGameplayTag::RequestGameplayTag ("status.buff.shield" )); float RawDamage = Spec.GetSetByCallerMagnitude (FGameplayTag::RequestGameplayTag ("data.damage" ), false , 0.0f ); if (bHasShield) { return RawDamage * 0.5f ; } 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 等级1 : 1.0 (倍率) → 15 秒 等级2 : 1.5 → 22.5 秒 等级3 : 2.0 → 30 秒 等级4 : 2.5 → 37.5 秒 等级5 : 3.0 → 45 秒
5.2 应用等级效果 1 2 3 4 5 6 7 8 9 10 11 12 void UGA_Shield::ActivateAbility () { int32 AbilityLevel = GetAbilityLevel (); 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 才触发
避免护盾完全免疫时播放受击动画
保持音效/特效反馈(即使无动画)
✅ 七、验收清单
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); } };
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 ()) { 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 类型: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); 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 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 优势总结
灵活性:可轻松添加多种防御属性
可扩展:后续可加护甲、魔抗等
网络友好:只同步最终属性,中间计算在服务器
✅ 七、验收清单
Part 17.5: Polished Shield Ability 🎯 核心目标 优化护盾能力的视觉和音效反馈,实现护盾激活时的发光效果和破裂时的爆炸特效
✨ 一、护盾激活效果 1.1 护盾发光材质 1 2 3 4 - 在角色骨骼网格上叠加半透明发光材质 - 材质参数:颜色(蓝色/金色)、透明度、发光强度 - 动态调整:可根据护盾强度改变颜色/亮度
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); 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 { 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 void UBasicAttributeSet::PostAttributeChange (const FGameplayAttribute& Attribute, float OldValue, float NewValue) { Super::PostAttributeChange (Attribute, OldValue, NewValue); if (Attribute == GetShieldAttribute ()) { 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 类型:GameplayCueNotify_Burst 配置: - Niagara粒子:ShieldExplosion(护盾碎片飞散) - 音效:ShieldBreakSound(破裂音效) - 相机震动:可选 绑定标签:shield.burst
2.3 破裂效果执行 1 2 3 4 5 类型: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 ); 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:确保新玩家看到正确状态
关键:状态必须可查询/可恢复
✅ 五、验收清单
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 ); 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 ASC->ApplyGameplayEffectSpecToTarget (SpecHandle, TargetASC);
4.2 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 void AEffectArea::BeginPlay () { InstigatorASC = GetInstigator ()->FindComponentByClass <UAbilitySystemComponent>(); } void AEffectArea::ApplyDamage (AActor* Target) { UAbilitySystemComponent* TargetASC = Target->FindComponentByClass <UAbilitySystemComponent>(); if (!TargetASC || !InstigatorASC) return ; 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推断可能错误
✅ 七、验收清单
🎯 核心目标 实现基于技能类别的统一输入处理系统,解决网络同步问题,简化技能输入管理
📋 一、传统输入绑定的问题 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" ) , MovementAbility UMETA (DisplayName = "Movement" ) } ;
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 ) ;UNexusGameplayAbility* DefaultAbility = Cast <UNexusGameplayAbility>(AbilityClass->GetDefaultObject ()); if (DefaultAbility){ 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); AbilitySystemComponent->PressInputID (InputIDValue); } void ANexusCharacterBase::ReleaseAbilityInputID (EAbilityInputID InputID) { if (!AbilitySystemComponent) return ; uint8 InputIDValue = static_cast <uint8>(InputID); AbilitySystemComponent->ReleaseInputID (InputIDValue); }
🎮 四、增强输入系统配置 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 FVector DashDirection = GetLastMovementInputVector (); 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同步输入,复杂且易出错
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项目
✅ 九、验收清单
🎯 核心目标 为能力栏添加动态输入图标显示,实现按键绑定可视化、冷却状态反馈和激活状态提示
🖼️ 一、UI布局优化 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 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 ; uint8 InputID = GetInputIDFromSpecHandle (AbilitySpecHandle); UInputAction* InputAction = GetInputActionForID (InputID); if (!InputAction) return ; 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 (); } 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
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 扩展性考虑
显示开关:满足不同用户偏好
可配置透明度:适应不同视觉风格
字符串映射:支持任意输入设备
✅ 八、验收清单
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 类型: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; UPROPERTY () int32 MaxStacks = 3 ; UPROPERTY () TSubclassOf<UGameplayEffect> BurnEffectClass; virtual void ActivateAbility () override { 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 类型: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 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; 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:唯一状态
✅ 九、验收清单
🎯 核心目标 扩展状态效果系统,添加冰冻效果和状态效果UI,实现多栈显示和网络同步
❄️ 一、冰冻状态效果设计 1.1 冰冻效果特性 1 2 3 4 5 效果: - 减少移动速度(减速) - 降低护甲值(防御下降) - 视觉特效:冰霜覆盖 - 音效:冰冻爆炸
1.2 冰冻数据资产 1 2 3 4 5 6 7 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 类型: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 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 └── ...
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需要同步
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) { 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组件:自动适应任意效果
✅ 九、验收清单
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 ; 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 TalentType: Passive MaxLevel: 5 EffectsToApply: GE_IncreaseMaxHealth 类型:Infinite Modifier: - 属性:MaxHealth - 操作:Add - 数值:曲线表(等级1 :25 , 等级5 :50 )
3.2 触发天赋:耐力恢复 1 2 3 4 TalentType: Triggered MaxLevel: 3 AbilitiesToGrant: GA_StaminaRegen
3.3 主动天赋:冲刺 1 2 3 4 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 ; if (AbilitySpecsByTalent.Contains (Talent)) { for (FGameplayAbilitySpecHandle Handle : AbilitySpecsByTalent[Talent]) { SetAbilityLevel (Handle, NewLevel); } } 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 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 () { 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
✅ 九、验收清单
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; 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 ) { return AvailableTalentPoints >= 1 ; } else if (CurrentLevel < Talent->MaxLevel) { 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 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关闭 → 游戏模式 + 鼠标隐藏
✅ 八、验收清单
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 TalentType: Active MaxLevel: 3 AbilitiesToGrant: GA_Projectile_Fire Icon: T_Icon_Fireball Description: "发射火球术,造成火焰伤害" TalentType: Active MaxLevel: 3 AbilitiesToGrant: GA_MeleeAttack_AxeSwing, GA_Combo_Axe Icon: T_Icon_Axe Description: "使用斧头进行近战攻击" 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) { 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 ); } } }
面试八股 :为什么默认天赋不消耗点数?→ 初始技能是角色基础能力,不应消耗玩家的天赋点数资源。
🎯 五、武器技能激活限制 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 RequiredWeaponClass: BP_Weapon_Axe 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友好:自动根据武器显示技能
✅ 九、验收清单
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 (); } 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 类型: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 类型:Duration 持续时间:15 秒 Modifier:Shield = MaxShield 类型:Infinite Modifier:MaxShield = BaseMaxShield + (Level * 50 )
4.2 天赋配置 1 2 3 4 5 6 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 (); return BaseDamage + (Level - 1 ) * 50.0f ; }
5.2 起点控制 1 2 3 4 5 6 7 8 float GetDamageMultiplier () const { int32 Level = GetAbilityLevel (); if (Level == 1 ) return 1.0f ; return 1.0f + (Level - 1 ) * 0.25f ; }
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); } } for (auto AbilityClass : Talent->AbilitiesToRemove) { RemoveAbilityByClass (AbilityClass); } }
6.3 应用示例:火焰→冰霜 1 2 3 4 5 6 7 8 9 AbilitiesToGrant: GA_Projectile_Fire MutuallyExclusiveTalents: 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 系统完整性检查
所有技能可升级
成长曲线合理(避免数值爆炸)
互斥关系正确
网络同步正常
✅ 九、验收清单
🎉 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)