anasayfa / blog / oynanış
Yayın: 28 Ağustos 2026 12 dk okuma oynanış
Unreal Engine 5 GAS: yetenek sistemini doğru kurmak

Unreal Engine 5 GAS: yetenek sistemini doğru kurmak

Dash yeteneğim paketlenmiş build'de client tarafında bir kez çalışıp sustu. Sorun GAS'ta değil, attribute'a GameplayEffect'siz dokunan kendi kodumdaydı.

Cooldown client tarafında neden hiç dolmadı

Geçen yıl dört kişilik bir co-op prototipinde dash yeteneğini yazdım. Editörde ve listen server'da kusursuz çalışıyordu. Paketlenmiş build'de iki client bağlanınca dash bir kez tetikleniyor, sonra tuş ölüyordu. Cooldown göstergesi ekranda sonsuza kadar dolu kalıyordu.

Hata bendeydi. Stamina'yı ActivateAbility içinde doğrudan SetStamina() ile düşürüyor, cooldown'ı da kendi float sayacımda tutuyordum. Server o sayacı hiç görmedi, client de otoriteye hiçbir şey sormadı. Unreal Engine 5 Gameplay Ability System'in var olma sebebi tam olarak buydu ve ben onu kullanmadan GAS kullandığımı sanıyordum.

Hatayı bulmam neden üç gün sürdü? Çünkü hiçbir yerde çökme yok. Client kendi sayacına bakıp "cooldown sürüyor" diyor, server aynı yetenek için hiçbir kayıt tutmuyor ve ikisi de kendi içinde tutarlı görünüyor. showdebug abilitysystem konsolunu ilk kez açtığımda client tarafında tek bir aktif effect olmadığını görmek yetti.

Oradan çıkardığım sonuç şu: GAS bir yetenek kütüphanesi değil, bir muhasebe sistemi. "Kim neyi ne zaman değiştirdi" sorusunun tek bir cevabı olsun diye var. Attribute'a UGameplayEffect dışında dokunan her satır o muhasebeyi sessizce bozuyor, üstelik tek bir uyarı bile üretmiyor.

Dört sınıf birbirine nasıl bağlanır

UAbilitySystemComponent (ASC) sistemin merkezi; verilmiş yetenek listesini, aktif effect'leri ve tag'leri o tutar. Oyuncu karakterlerinde ASC'yi APlayerState üzerine koyuyorum, çünkü karakterle birlikte yok olan bir ASC respawn'da bütün buff'ları da götürüyor. Yapay zekâ için ACharacter üzerinde durması yeterli; onlar zaten yeniden doğmuyor. İkisi arasında sonradan geçmek bütün başlatma sırasını yeniden yazmak demek, o yüzden bu kararı ilk gün verin.

UAttributeSet sayıların yaşadığı yer. Her alan FGameplayAttributeData tipinde ve ATTRIBUTE_ACCESSORS makrosuyla tanımlanıyor; makro getter, setter ve init yardımcılarını birlikte üretiyor. Sağlığın 0 ile MaxHealth arasında kalması gibi kurallar PreAttributeChange içine yazılır, ability'ye değil — çünkü attribute'u değiştiren tek şey ability değildir.

UGameplayAbility ise sadece "ne olacak" sorusunun cevabı. GiveAbility ile ASC'ye verilir, bir input tag'i ya da FGameplayAbilitySpecHandle üzerinden tetiklenir. Attribute'a asla dokunmaz, sadece effect uygular. Instancing politikasını Instanced Per Actor yapıyorum; aksi halde ability içinde üye değişken tutmak güvenli değil.

Dördüncü parça UGameplayEffect ve içinde tek satır kod olmayan tek parça: aslında bir veri varlığı. Üç süre politikası var — Instant anında uygulanıp kaybolur, Has Duration belirli bir süre yaşar, Infinite siz kaldırana kadar durur. Dört parçanın kuralı tek cümleye sığıyor: ability karar verir, effect uygular, attribute saklar, ASC muhasebeyi tutar.

OFKGameplayAbility.cpp
#include "OFKGameplayAbility.h" #include "Abilities/Tasks/AbilityTask_WaitDelay.h" void UOFKGameplayAbility::ActivateAbility( const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) { // CommitAbility pays the cost and starts the cooldown in a single call. if (!CommitAbility(Handle, ActorInfo, ActivationInfo)) { EndAbility(Handle, ActorInfo, ActivationInfo, true, true); return; } // Damage lives in a GameplayEffect, never in a direct attribute setter. if (HasAuthority(&ActivationInfo) && DashImpactEffect) { ApplyGameplayEffectToOwner(Handle, ActorInfo, ActivationInfo, DashImpactEffect->GetDefaultObject<UGameplayEffect>(), GetAbilityLevel()); } // The "is dashing" state comes from ActivationOwnedTags, not from a bool. UAbilityTask_WaitDelay* Task = UAbilityTask_WaitDelay::WaitDelay(this, DashDuration); Task->OnFinish.AddDynamic(this, &UOFKGameplayAbility::OnDashFinished); Task->ReadyForActivation(); }
cppOFKGameplayAbility.cpp

Cooldown ve cost neden ayrı GameplayEffect

Cost bir Instant effect: Stamina attribute'una -25 modifier uygular ve ömrü orada biter. Cooldown ise Has Duration effect; hiçbir attribute'a dokunmaz, sadece Cooldown.Ability.Dash tag'ini dört saniye boyunca sahibine takar. CanActivateAbility ikisini birden kontrol eder, yani stamina yetmiyorsa ya da tag hâlâ duruyorsa yetenek hiç başlamaz.

Ayrımın pratik karşılığı CommitAbility çağrısında görünüyor. Tek satır hem maliyeti düşürüyor hem cooldown'ı başlatıyor, ve iki yarı ayrı ayrı başarısız olabiliyor. Tasarımcı süreyi SetByCaller ile bir data table'dan besliyor; ben C++ tarafına hiç dokunmuyorum ve kimse derleme beklemiyor. Aynı cooldown effect'ini birkaç yeteneğe birden vererek ortak bir global cooldown kurmak da tek bir asset düzenlemesine iniyor.

Kendi float LastUsedTime sayacımı denedim, iki yerde çöktü. Birincisi, %20 cooldown azaltma veren bir eşya eklediğimde her yetenek için ayrı çarpan mantığı yazmam gerekti; effect tarafında bu tek bir modifier ve yığılma kuralları hazır geliyor. İkincisi, UI GetCooldownTimeRemaining() çağıramadığı için ilerleme çubuğuna ayrı bir replikasyon yolu açmak zorunda kaldım.

Üçüncü fayda tahminleme tarafında. CommitAbility client'ta bir tahmin penceresi içinde çalışıyor; cost ve cooldown ekranda anında uygulanıyor, server aktivasyonu reddederse ikisi birden geri alınıyor. Bunu elle yazmaya kalkarsanız geri alma mantığını da elle yazmanız gerekir, ki asıl zor yarısı orasıdır.

Replikasyon modu: Full, Mixed, Minimal

Modu SetReplicationMode() ile, actor info başlatılmadan önce seçiyorsunuz; ucuz görünüp pahalıya patlayan bir karar. Full modda ASC her effect'i bağlı bütün client'lara replike eder. Tek oyunculu ya da dört kişilik bir yapımda bu sorun değil, üstelik hata ayıklaması en kolay mod. Sorun ölçekle birlikte başlıyor: replike edilen şey her düşmandaki her hasar tiki olduğunda bant genişliği doğrusal değil, çarpımsal artıyor.

Mixed, oyuncu kontrollü karakterler için doğru seçim: effect'in tamamı yalnızca sahibine gider, herkese giden şey tag'ler ve GameplayCue'lardır. Tek şartı var — ASC APlayerState üzerinde olmalı. Pawn üzerindeki bir ASC ile Mixed kullanırsanız effect'ler sahibi olmayan client'larda tamamen kaybolur.

Aynı yerde ikinci bir tuzak duruyor: PlayerState varsayılan olarak saniyede bir kez replike edilir. NetUpdateFrequency değerini 30'a çekmezseniz cooldown göstergesi gözle görülür biçimde geç güncellenir; bende tuşa basmayla göstergenin hareket etmesi arasında yaklaşık 400 ms fark vardı. Değeri yükselttikten sonra bu fark 40 ms'nin altına indi.

Minimal yapay zekâ içindir: hiçbir effect replike edilmez, yalnızca tag'ler ve cue'lar gider. 32 düşmanlı bir arena testinde Full'dan Minimal'e geçtiğimde oyuncu başına ASC trafiği ~58 KB/s'den ~11 KB/s'ye indi. Ekranda görünen hiçbir şey değişmedi, çünkü client'ın bilmesi gereken tek şey düşmanın sersemlemiş olduğuydu.

GameplayTag ile durum yönetimi

GAS'a geçmeden önce karakterimde bIsStunned, bIsDashing, bCanAttack gibi yedi bool vardı ve her biri ayrı replike ediliyordu. GameplayTag bunların yerine tek bir FGameplayTagContainer koyuyor. State.Stunned tag'i duruyorsa saldırı yeteneği Activation Blocked Tags sayesinde zaten başlamıyor; ben tek satır kontrol kodu yazmıyorum. Yedi ayrı bool yerine tek bir container replike edildiği için ağ tarafında da kazanç var, ama asıl kazanç kontrol kodunun tamamen yok olması.

Tag'ler hiyerarşik. State.Debuff.Stun etiketi MatchesTag(State.Debuff) sorgusuna evet cevabı verir, yani "herhangi bir debuff varken koşamaz" kuralı tek satıra iniyor. İsimlendirmeyi baştan sabitleyin: bende Ability.*, Cooldown.*, State.*, Event.* ve Cue.* olmak üzere beş kök var, hepsi DefaultGameplayTags.ini içinde tanımlı.

Tag'i her seferinde isimle aramayın. RequestGameplayTag(FName("State.Stunned")) her çağrıda bir tablo araması yapıyor ve bunu Tick içinde yapan bir kodu profillerken maliyetin ölçülebilir hale geldiğini gördüm. UE_DEFINE_GAMEPLAY_TAG_STATIC ile tanımlanan native tag'ler bir kez çözülüyor, sonrasında karşılaştırma tek bir tamsayı karşılaştırmasına iniyor.

En sinsi tuzak AddLooseGameplayTag. İsmi masum ama loose tag'ler replike edilmez ve effect kaldırıldığında kendiliğinden temizlenmez. Server'da eklediğim bir loose tag client'ta hiç görünmediği için bozuk bir animasyon geçişini iki gün kovaladım. Kural basit: durum bir effect'ten geliyorsa tag de o effect'in Granted Tags alanından gelsin.

En çok vakit kaybettiren dört tuzak

En pahalısı sıralama hatası: InitAbilityActorInfo çağrısını tek bir yerde yapmak. Server'da PossessedBy, client'ta OnRep_PlayerState içinde çağrılması gerekiyor. Yalnızca birini yazarsanız client'ta ASC boş kalır, hiçbir yetenek tetiklenmez ve log'a tek satır uyarı düşmez. Bu tek hata bana iki ayrı projede toplam bir haftaya mal oldu.

İkincisi, attribute başlangıç değerlerini PostInitializeComponents içinde elle set etmek; bunları bir Instant effect ile verin, yoksa replikasyon OnRep sırasında değerleri ezer. Üçüncüsü, GameplayCue içinde oyun mantığı çalıştırmak: cue'lar client'ta atlanabilir, orada yalnızca ses, partikül ve kamera sarsıntısı olmalı.

Dördüncüsü daha sessiz: yeteneklerin yalnızca server'da GiveAbility ile verilmesi gerektiğini unutmak. Client tarafında verilen yetenek gerçek spec listesine girmez, girmiş gibi görünen şey yerel bir kopyadır ve ilk aktivasyonda sessizce düşer. Ben bunun tamamını PossessedBy içinden çağrılan tek bir InitializeAbilities() fonksiyonuna topladım ve bir daha karşılaşmadım.

Bugün sıfırdan başlasam sırayı şöyle kurardım: önce AttributeSet ve init effect'i, sonra hiçbir işe yaramayan tek bir test ability'si, ardından showdebug abilitysystem ile tag'lerin ve effect sürelerinin doğru göründüğünü doğrulama. PIE'yi de ilk günden bir dedicated server ve iki client ile çalıştırın. GAS'ın öğrenme eğrisi dik değil, geri bildirimi sessiz; görünürlüğü açtığınız anda üç günlük hatalar üç dakikaya iniyor.

← Tüm yazılar