ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

UE5多人游戏开发实践:用C++与GAS构建同步技能系统

UE5多人游戏开发实践:用C++与GAS构建同步技能系统 很多人在学习虚幻引擎时第一段成就往往来自蓝图。拖拖节点连几根线控制台就能跑出一个可以跳跃、可以攻击的小场景。蓝图的学习成本确实低这一点几乎没人反对。但如果你把目标定在“多人对战游戏”比如团队射击、MOBA、合作生存蓝图的优势期通常撑不到项目中期。真正让多人游戏开发变难的不是某个技能写不出来而是当十几个技能、几十种状态效果同时存在时所有逻辑必须在每个客户端和服务器之间保持一致。玩家A给玩家B挂了一个减速效果B的客户端看到的是减速服务器也要认可这个减速A的客户端还不能因为网络延迟产生错乱。这种状态下蓝图节点的连线图不再是一目了然的流程图而是一张随时可能交叉污染的状态网。UE5 里解决这类复杂度的最佳实践是 C GASGameplay Ability System的组合。C 负责把逻辑变成可维护的代码结构GAS 负责把技能、属性、状态效果变成一套可组合、可同步、可预测的系统。这篇文章不打算从创建空项目开始一步步截图而是直接讲清这条进阶路线的关键模块GAS 的核心概念、C 工程接入方式、属性集和能力的完整实现、多人同步的判断方法以及实际项目中大概率会踩到的坑。1. 多人游戏开发为什么最终要选 C 和 GAS1.1 蓝图解决的是上手问题不是复杂度问题蓝图适合的单人项目特点是逻辑简单、状态少、所有判断都发生在本地。你在自己的客户端里修改自己的血量和位置没有任何人会和你争执数据的正确性因为整个世界的裁判就是本地玩家自己。多人游戏完全不一样。服务器和每个客户端都有自己的状态副本为了避免作弊和状态冲突服务器必须是权威。这意味着同一个技能在不同机器上会同时跑一遍客户端为了手感还要做本地预测预测错了还要回滚。蓝图节点在这里会遇到两个问题节点图难以表达复杂的异步流程比如“等待动画播完→生成发射物→命中后结算伤害→处理暴击”。手动处理网络同步非常容易漏漏掉一个Multicast或Replicated玩家就会看到完全不同的世界。C 的价值不是“把蓝图翻译成代码”而是让复杂逻辑拥有明确的函数边界、继承关系和数据流。当技能数量超过 10 个状态叠加超过 5 层时代码的维护成本会显著低于连线图。1.2 服务器权威多人游戏的规则核心多人游戏里最重要的概念是“谁能拍板”。如果玩家自己说了算那么修改内存、作弊修改血量、删除冷却时间都是可能的。因此网络架构上必须有一个权威者通常是服务器。GAS 在初始化时并不会神奇解决所有网络问题但它的整个设计都默认服务器权威能力的最终激活权限在服务器。属性值的最终修改在服务器。客户端的本地表现通过“预测”来补偿延迟但最终以服务器结果为准。这意味着如果你直接用蓝图去SetHealth本地可能立刻看到变化但其他玩家看不到服务器也不认可。而 GAS 里的GameplayEffect天然知道该在哪个机器执行、怎样提交给服务器、怎样同步给客户端观察者。1.3 GAS 的由来从商业项目沉淀出来的框架GAS 不是第三方插件也不是某个教程作者发明的最佳实践。它最早来自 Epic 在《Paragon》项目中的积累后来被多个商业级项目验证。它的目标非常简单把游戏中的技能、属性、增益、减益、冷却、消耗统一成一个可组合的系统。如果一个技能只是“打出 50 点伤害”那不需要 GAS。但如果这个伤害可以被装备加成、被护甲减免、被暴击翻倍、被眩晕打断、被队友的 Buff 强化你就需要一套通用的规则引擎让这些模块互不冲突地叠加和结算。GAS 就是这套引擎。1.4 传统方式与 GAS 的对比对比维度纯蓝图自定义技能GAS 框架原型搭建速度很快适合验证想法初期较慢需要理解组件技能组合能力弱改动容易互相影响强效果可叠加、可取消多人同步需要手动做 RPC 和复制内置服务器权威流程数值扩展容易写死数据驱动数值在资产中配置团队协作蓝图难并行修改C 类 数据资产可分工我的判断是如果你做的是小型单机 Demo可以不学 GAS直接用蓝图如果你打算做面向多人的数值型游戏GAS 是 UE5 里最值得投入的学习方向之一。2. GAS 核心概念属性、能力、效果、标签第一次接触 GAS 时最容易被一堆新名词劝退。这里先建立一个整体画面GAS 由几个核心对象共同工作分别承担不同职责。2.1 五个核心组件AbilitySystemComponentASC这是 GAS 的“控制台”。它挂在角色或 PlayerState 上负责管理能力列表、应用效果、处理属性变化。几乎所有 GAS 操作都要经过它。AttributeSet属性集它定义了角色有哪些属性比如生命值、最大生命值、魔法值、攻击力。它不是简单的浮点数而是包装成FGameplayAttributeData支持网络同步、属性变化监听和数值修改。GameplayAbility能力能力就是“技能”。它可以是一个主动释放的伤害技能也可以是被动触发的闪避效果。能力负责技能的整体流程启动、提交、执行、结束。GameplayEffect效果它是属性的“修改规则”。一个效果可以瞬间扣血也可以在 5 秒内持续回复还可以无限期地提供一个 Buff。效果本身不写逻辑而是声明“改哪个属性、改多少、持续多久、是否受等级影响”。GameplayTag标签标签是项目自定义的状态标记比如State.Stunned、State.Invincible、Ability.Cooldown。GAS 用标签来管理技能是否被禁用、效果是否会被移除、角色是否处于某种状态。这些对象的关系可以这样类比ASC 是机箱AttributeSet 是仪表盘GameplayAbility 是按钮和操作杆GameplayEffect 是电路规则GameplayTag 是贴在各个部件上的标识贴纸。2.2 效果与生命周期GameplayEffect 有三种时间策略这个一定要分清Instant瞬时立刻修改属性一次性的比如直接扣血。Duration持续在一段时间内持续生效比如 3 秒中毒每秒扣 5 点血。Infinite无限期永久生效直到被移除比如装备一个攻击力加成的被动。对多人网络来说这三种策略的同步逻辑不太一样。Instant 相对简单服务器算一次把结果同步给客户端Duration 和 Infinite 需要持续性地关联目标并且处理到期、延长、堆叠、移除所以它们的管理逻辑都在 ASC 内部完成。2.3 标签是关键约束很多新手会忽略 GameplayTag实际上它是 GAS 复杂度控制的核心手段。你可以用标签做技能禁用被眩晕标签覆盖时不允许激活新的能力。效果冲突同一个 Buff 标签只能存在一个实例重复添加时刷新持续时间。自动取消角色进入某种状态时自动取消带特定标签的技能。3. 环境准备创建 UE5 C 项目与模块配置3.1 工具链准备在 Windows 上做 UE5 的 C 开发建议准备UE5 任意稳定版本5.1 及以上都支持 GAS。Visual Studio 2022并勾选“使用 C 的游戏开发”工作负载。一个 UE5 的 C 基础项目不要选蓝图空白模板。如果你之前只做过蓝图项目需要留意C 项目在第一次打开时编辑器会提示编译编译之后才能正常操作。Visual Studio 的版本与 UE5 版本需要匹配否则可能遇到“编译器版本不兼容”的报错。3.2 在 Build.cs 中添加 GAS 模块GAS 以插件方式存在于引擎中所以工程必须主动声明依赖模块。打开你项目目录下的源文件找到*.Build.cs在依赖列表中加入// 文件路径Source/你的项目名/你的项目名.Build.cs PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, InputCore, EnhancedInput, GameplayAbilities, GameplayTasks, GameplayTags });也可以打开编辑器菜单“插件”搜索GameplayAbilities确认插件处于启用状态。最稳妥的做法是两件事都做Build.cs 声明模块插件面板确认启用。如果不加这三个模块代码中使用UAbilitySystemComponent、UGameplayAbility时会直接报编译错误。这个错误是第一道门槛后面章节还会谈到排查方式。3.3 在角色类上挂载 ASC 和 AttributeSet假设我们创建了一个AMyCharacter它继承自ACharacter同时实现IAbilitySystemInterface。这是接入 GAS 最常见的方式。// 文件路径Source/你的项目名/Public/MyCharacter.h #pragma once #include CoreMinimal.h #include GameFramework/Character.h #include AbilitySystemInterface.h #include MyCharacter.generated.h class UAbilitySystemComponent; class UMyAttributeSet; UCLASS() class MYGAME_API AMyCharacter : public ACharacter, public IAbilitySystemInterface { GENERATED_BODY() public: AMyCharacter(); virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override; protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category GAS) UAbilitySystemComponent* AbilitySystemComponent; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category GAS) UMyAttributeSet* AttributeSet; };// 文件路径Source/你的项目名/Private/MyCharacter.cpp #include MyCharacter.h #include AbilitySystemComponent.h #include MyAttributeSet.h AMyCharacter::AMyCharacter() { AbilitySystemComponent CreateDefaultSubobjectUAbilitySystemComponent(TEXT(AbilitySystemComponent)); AttributeSet CreateDefaultSubobjectUMyAttributeSet(TEXT(AttributeSet)); } UAbilitySystemComponent* AMyCharacter::GetAbilitySystemComponent() const { return AbilitySystemComponent; }然后在BeginPlay或合适的时机完成 ASC 的初始化void AMyCharacter::BeginPlay() { Super::BeginPlay(); if (AbilitySystemComponent) { AbilitySystemComponent-InitAbilityActorInfo(this, this); } }InitAbilityActorInfo的第一个参数是 OwnerActor通常是 PlayerState 或 Controller第二个参数是 AvatarActor通常是角色本身。这里直接传递this可以理解成“我拥有这套能力系统我也是它在场景中的形象”。怎样拆分 Owner 和 Avatar 是后话先跑通是最重要的。4. 属性集定义生命值与网络同步4.1 为什么属性要用 FGameplayAttributeData如果你只是在 C 里定义一个float Health那么它只能被单一逻辑使用无法被 GameplayEffect 修改、无法被多人复制、无法被 UI 监听变化。GAS 要求所有属性都用FGameplayAttributeData包装这是一种专门为属性系统设计的数据类型。4.2 编写 MyAttributeSet我们先定义两个最基础的属性当前生命值Health和最大生命值MaxHealth。// 文件路径Source/你的项目名/Public/MyAttributeSet.h #pragma once #include CoreMinimal.h #include AttributeSet.h #include AbilitySystemComponent.h #include MyAttributeSet.generated.h UCLASS() class MYGAME_API UMyAttributeSet : public UAttributeSet { GENERATED_BODY() public: UMyAttributeSet(); UPROPERTY(BlueprintReadOnly, ReplicatedUsing OnRep_Health, Category GAS|Attributes) FGameplayAttributeData Health; UPROPERTY(BlueprintReadOnly, ReplicatedUsing OnRep_MaxHealth, Category GAS|Attributes) FGameplayAttributeData MaxHealth; UFUNCTION() void OnRep_Health(const FGameplayAttributeData OldHealth); UFUNCTION() void OnRep_MaxHealth(const FGameplayAttributeData OldMaxHealth); virtual void GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const override; };接下来是具体实现。默认将生命值初始化为 100并把两个属性放进网络复制列表。// 文件路径Source/你的项目名/Private/MyAttributeSet.cpp #include MyAttributeSet.h #include Net/UnrealNetwork.h UMyAttributeSet::UMyAttributeSet() { Health 100.0f; MaxHealth 100.0f; } void UMyAttributeSet::GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME_CONDITION_NOTIFY(UMyAttributeSet, Health, COND_None, REPNOTIFY_Always); DOREPLIFETIME_CONDITION_NOTIFY(UMyAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always); } void UMyAttributeSet::OnRep_Health(const FGameplayAttributeData OldHealth) { GAMEPLAYATTRIBUTE_REPNOTIFY(UMyAttributeSet, Health, OldHealth); } void UMyAttributeSet::OnRep_MaxHealth(const FGameplayAttributeData OldMaxHealth) { GAMEPLAYATTRIBUTE_REPNOTIFY(UMyAttributeSet, MaxHealth, OldMaxHealth); }这段代码里最重要的不是初始化值而是两套网络机制DOREPLIFETIME_CONDITION_NOTIFY声明这两个属性会被复制到客户端。GAMEPLAYATTRIBUTE_REPNOTIFY当客户端收到新值后让 GAS 内部属性缓存同步更新并触发属性变化回调。在 GAS 的实际用法中属性值并不是直接用 50来修改的而是通过GameplayEffect去驱动。上面这段只负责定义和同步值的变化统一交给后面的效果系统。5. 完整示例实现一个 GameplayAbility5.1 能力类的基本结构能力类继承自UGameplayAbility。最常用的重写方法是ActivateAbility它表示技能被激活时执行的主要逻辑。// 文件路径Source/你的项目名/Public/MyDamageAbility.h #pragma once #include CoreMinimal.h #include Abilities/GameplayAbility.h #include MyDamageAbility.generated.h UCLASS() class MYGAME_API UMyDamageAbility : public UGameplayAbility { GENERATED_BODY() public: UMyDamageAbility(); virtual void ActivateAbility( const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) override; protected: UPROPERTY(EditDefaultsOnly, Category Effects) TSubclassOfUGameplayEffect DamageEffectClass; };这里声明了一个DamageEffectClass它是一个 GameplayEffect 蓝图类的引用。实际项目中我们通常在编辑器里创建一个BP_DamageEffect在它的Modifiers面板里配置“降低 Health 20 点”然后在能力资产里指定这个类。这种做法的好处是伤害数值是数据不是硬编码在 C 逻辑里的。策划改数值时不需要编译工程直接调资产参数。5.2 能力激活与效果应用下面用一个简化逻辑演示“对自己造成伤害”它足够说明效果的创建和应用流程。实际多人游戏中你要把目标换成敌人身上的 ASC但核心代码模式是一样的。// 文件路径Source/你的项目名/Private/MyDamageAbility.cpp #include MyDamageAbility.h #include AbilitySystemComponent.h #include GameplayEffect.h UMyDamageAbility::UMyDamageAbility() { // 默认该技能可以在服务器执行客户端通过预测可获得即时反馈 bServerRespectsRemoteAbilityCancellation true; } void UMyDamageAbility::ActivateAbility( const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) { if (UAbilitySystemComponent* ASC GetAbilitySystemComponentFromActorInfo()) { FGameplayEffectContextHandle EffectContext ASC-MakeEffectContext(); EffectContext.AddSourceObject(GetOwningActorFromActorInfo()); int32 Level GetAbilityLevel(); FGameplayEffectSpecHandle SpecHandle ASC-MakeOutgoingSpec( DamageEffectClass, Level, EffectContext ); if (SpecHandle.IsValid()) { ASC-ApplyGameplayEffectSpecToSelf(SpecHandle); } } // 如果不需要持续能力执行完立即结束 EndAbility(Handle, ActorInfo, ActivationInfo, true, false); }这段代码的核心流程是获取角色身上的 ASC。生成一个效果上下文EffectContext记录效果的来源对象。使用MakeOutgoingSpec把效果蓝图类变成一个可以执行的效果 Spec。调用ApplyGameplayEffectSpecToSelf将该效果应用到 ASC 所属的角色身上。如果要做对敌人施放伤害最直观的改造是拿到敌人的 ASC然后调用TargetASC-ApplyGameplayEffectSpecToSelf(SpecHandle);原因在于效果 Spec 本身记录了“来源是谁”而执行者必须是目标的 ASC。这个模式是 GAS 中“源”和“目标”分离的核心思想。5.3 能力的赋予与触发能力定义好了还需要在某个时机“授予”给角色。通常在服务器端给最常见的位置是角色初始化完成时。void AMyCharacter::GiveDefaultAbilities() { if (!AbilitySystemComponent) { return; } UMyDamageAbility* BaseAbility DamageAbilityClass.GetDefaultObject(); if (BaseAbility) { AbilitySystemComponent-GiveAbility( FGameplayAbilitySpec( DamageAbilityClass, 1, INDEX_NONE, this ) ); } }GiveAbility之后能力并不会立刻启动它只是进入角色 ASC 的能力列表。玩家或系统可以通过蓝图节点调用TryActivateAbilityByTag也可以在 C 里用标签或 Spec Handle 来激活。这就是“技能注册”和“技能触发”分离的好处能力列表和输入按键、UI 按钮都解耦了。6. 多人同步的关键服务器权威与 RPC6.1 服务器才是最终裁判在 GAS 中你不需要手动写“客户端扣血然后通知服务器”也不需要写“服务器广播给所有人”。正确流程是客户端发激活请求。服务器接到请求后真正激活能力。服务器上执行GameplayEffect修改AttributeSet。被修改的属性通过网络复制同步给客户端。客户端收到属性值变化后自动刷新 UI 和表现。这套流程的优点是天然防作弊。服务器只认可自己执行的能力和效果客户端无法直接把自己的血量改成 9999。6.2 哪些东西需要复制下面这些是需要明确的同步策略AttributeSet中的属性复制OnRep触发显示刷新。GameplayAbility数量和解锁状态通常服务器授予后复制给客户端。GameplayEffect本身不需要同步整个对象只需要同步最终属性变化或效果标签。GameplayTag作为标签变量复制或者通过MinimalReplicationTags处理。6.3 什么时候还需要 RPCGAS 能省掉很多手写 RPC但并非所有逻辑都适合塞进技能。例如播放在服务器与客户端上表现不一致的动画可以在能力内部用Multicast处理。角色自定义事件比如“死亡后调用服务器重置关卡”可以保留独立的 RPC。发射物命中后如果用普通 Actor 而不是 GAS 能力就仍然需要OnRep或Server/Client RPC。GAS 的设计哲学是你尽量把“状态变化”交给它把“表现变化”留给你自己。不要因为存在 RPC 就用 RPC 去改属性那会和 GAS 的效果系统形成两条并行路径最终导致状态不同步。7. 运行验证与调试方法7.1 多人测试环境设置在编辑器中打开运行设置把Number of Players改为 2。默认会是“单进程多客户端”模式这种模式适合验证同步表现。你也可以选择Listen Server后从另一个终端连接但前期开发用编辑器自带的多客户端更快。测试时要注意角色在服务器和客户端都应该存在。如果某个客户端看不到另一个角色通常是 Actor 的Replicates属性没开或者移动组件的复制设置不对。7.2 验证属性是否同步在技能ActivateAbility里加一个日志是最快判断服务器执行情况的方法UE_LOG(LogTemp, Warning, TEXT([Ability] Activate, Authority: %d), HasAuthority());如果值是 1说明在服务器上执行如果是 0说明在客户端执行。配合编辑器控制台输入AbilitySystem.Debug相关命令可以显示 ASC 当前正在处理的属性、效果和能力状态是排查同步问题最直接的途径。7.3 验证属性变化在蓝图中绑定OnAttributeChange或者在 C 中监听AttributeSet的变化回调可以确认属性值是否按预期改变。最常见的问题是服务器改了属性客户端显示没变那就是GetLifetimeReplicatedProps没写或者OnRep没有被正确标记。8. 常见问题与排查思路问题现象可能原因排查方式解决方案编译报错找不到 GameplayAbilities 模块Build.cs 缺少模块依赖检查 Build.cs 和插件启用状态添加GameplayAbilities、GameplayTasks、GameplayTags模块能力在客户端无法激活能力只在服务器被授予在ActivateAbility中打印HasAuthority确认服务器授予能力客户端通过复制获取能力列表属性变化不同步AttributeSet 缺少复制声明检查GetLifetimeReplicatedProps使用DOREPLIFETIME_CONDITION_NOTIFY声明属性复制使用 GameplayEffect 没有实际扣血效果类未配置 Modifiers 或 DurationPolicy 不对在编辑器中打开效果资产检查 Modifiers 面板确认 Modifier 绑定的属性是目标 AttributeSet 中的有效属性UI 不刷新没有监听属性变化事件确认OnRep是否触发绑定OnAttributeChange或使用FOnGameplayAttributeValueChange客户端表现和服务器不一致客户端直接修改了属性检查是否有直接SetHealth调用统一改走 GameplayEffect保证服务器权威技能启动后 AI 被卡住能力未正确 EndAbility查看能力状态机日志确认ActivateAbility结束前调用EndAbility或处理好循环能力9. 最佳实践与工程建议9.1 数据驱动优先GAS 最大的优势之一就是数据驱动。把伤害数值、持续时间、施放消耗、冷却时间都放进 GameplayEffect 或 GameplayAbility 数据资产而不是写在代码里。这样策划可以直接调整数值程序员不需要重新编译。代码只负责“流程”资产负责“数值”。9.2 能力尽量小、尽量专一一个技能类只做一个核心行为。比如“造成一次伤害”是一个能力“施加持续灼烧”是另一个能力“触发击退”是第三个能力。通过组合多个小能力可以拼出更丰富的技能表现而不是在一个大类里堆满 if/else。9.3 用 GameplayTag 管理状态少用布尔值项目里如果用了计多个布尔变量来判断眩晕、无敌、闪光等状态状态组合很快就不可控。GAS 里用 Tag 标记状态后技能可以用BlockAbilitiesWithTag、CancelAbilitiesWithTag等配置直接约束不需要写复杂的条件判断。9.4 不要让 GAS 承担一切逻辑GAS 擅长管理角色属性、状态和技能但不要让所有业务逻辑都进去。震动反馈、镜头控制、UI 展示、AI 行为这些仍然应该以独立系统方式运行。GAS 是状态核心不是万能容器。9.5 重视日志和调试规范在能力激活、效果应用、属性变化三个关键位置添加清晰日志。多人项目排查问题时需要同时看服务器和客户端日志然后对比哪个状态在哪个节点开始分叉。没有日志的多人项目问题定位成本会高得离谱。9.6 团队协作时的分工方式程序员维护 C 的能力基类和公共模块策划和关卡设计通过派生蓝图定义具体技能数值通过 GameplayEffect 资产配置。这个分工可以把 GAS 的学习成本控制在有限的几个人身上同时让整个团队享受框架带来的协作效率。10. 总结与后续学习方向到这里你已经完成了一条 GAS 的关键路径从模块配置到角色挂载 ASC再到定义 AttributeSet、创建 GameplayAbility、用 GameplayEffect 触发属性变化并理解了服务器权威和客户端同步的基本规则。这条路走通之后你可以开始往更进阶的方向探索实现真正的指定目标伤害技能需要完善TargetActor / TargetData的获取与传递。学习AbilityTask把技能拆成等待动画、等待输入、等待碰撞等多个可暂停的异步步骤。深入客户端预测与回滚机制理解 GAS 如何处理延迟补偿。研究GameplayEffect 堆叠规则处理同类 Buff 叠加和刷新问题。对于动手实践的起点我的建议是先在单人 PIE 里让一个角色释放技能并看到属性变化再开两个客户端验证同步。如果直接把所有代码都写完再测试排错时会同时面对编译错误、逻辑错误、网络错误三类问题难度会大很多。等你真正在一个多人会话里稳定跑通第一个 GAS 技能后就会明白为什么那么多大型 UE 项目愿意为这套框架投入学习成本。
返回列表