Pull to refresh
0
Social Quantum
Разработчик мобильных игр

Gameplay Ability System: как мы используем плагин для разработки Survival RPG

Reading time14 min
Views21K
В Social Quantum сейчас кипит работа над новыми проектами, которые мы разрабатываем на Unreal Engine 4. Для одной из этих игр мы активно используем плагин Gameplay Ability System (далее GAS). Многие о нем слышали, но редко применяли для своих проектов. Этому есть несколько причин: количество документации, объемность и сложность подсистемы. Наша команда решила подготовить серию материалов, где мы подробно расскажем об опыте работы и развеем некоторые мифы.

Для начала разберем самые базовые принципы GAS и основные ее сущности, а также копнем чуть дальше, чем «делаем зелье для пополнения здоровья».





Плюсы и минусы


Плагин GAS до версии 4.22 был в статусе «Unsupported», сейчас это упоминание сменилось на бету-версию. Но практика показывает, что он очень широко используется крупными игроками индустрии, например, в Fortnite.

Несмотря на присутствие слова «Ability» в названии, возможности системы очень широки и использовать ее можно для любых геймплейных взаимодействий, не только в MOBA/MMO играх. Примером тому наш Survival проект, где на базе абилок построены все действия игрока (перекаты, пинки, удары, стрельба, перезарядка, взаимодействие с объектами уровня и т.п.). Основные плюсы, которые вы получаете при использовании этой системы:

  • Очень большая гибкость;
  • Может использоваться гейм-дизайнерами без участия программиста (на самом деле для изначального сетапа придется залезть в c++, но для остального скиллы программиста, по большому счету, не нужны);
  • Multiplayer-ready решение (очень жирный плюс, подробнее расскажем о мультиплеере в следующих статьях);
  • Не нужно тратить время на написание и поддержку самой системы.

Ну и минусы, куда же без них:

  • Достаточно крутая кривая обучения (широкие возможности накладываются на ограниченное количество документации и статей);
  • С ростом количества эффектов/тегов/абилок сложнее отследить причины возникновения ошибок.

Взвесив все за и против, мы с головой нырнули в изучение возможностей системы и перевели практически все игровые взаимодействия на абилки, ни разу об этом не пожалев. Далее постараемся поделиться этим опытом и с вами, разобрав несколько не совсем тривиальных механик (хотя с виду они такими и не кажутся).

Включение GAS


По умолчанию плагин выключен, поэтому, если вы не включали его ранее, нужно сделать это в меню Settings -> Plugins



Рестартуем анрил и можно переходить непосредственно к абилкам.

Абилка Ролл


Я старался не рассматривать «простые» абилки и эффекты, которые только и делают, что модифицируют значения аттрибут сета.

AttributeSet
Практически все параметры, которыми впоследствии будет управлять АбилитиСистема, должны являться аттрибутами персонажа. Все аттрибуты являются частью AttributeSet. Это специальная структура, помимо того, что является контейнером аттрибутов, предоставляет несколько хелперных методов для манипуляций над этими параметрами (репликация, гетеры, сетеры, коллбеки на Pre/Post изменения значений и т.п.).

Механика затронет некоторые нюансы взаимодействия сущностей, расширения функционала геймплей эффектов и реакций на них. Приступим, но для начала убедимся, что:

  1. Используется Unreal Engine 4.24 (в принципе, это не особо важно, но от версии к версии могут быть небольшие расхождения в API);
  2. Плагин Gameplay Abilities включен (см. «Включение GAS»);
  3. Запущен темплейтный проект Third Person (с поддержкой c++), будем основываться на нем.

Дизайн абилки закладываем такой: при нажатии на кнопку, персонаж совершает кувырок вперед. Во время переката временно отключим коллизии с другими павнами, чтобы легко выбираться из окружения врагов. Также, если кто-то из NPC заденет нас во время переката — получит дебаф на скорость передвижения на некоторое время. В конечном итоге получим вот такой скилл:

gif


Для начала создадим новый класс персонажа, который впоследствии станет базовым для игрока и мобов. Сразу же добавим в него AbilitySystemComponet и реализуем интерфейс AbilitySystemInterface.

AbilitySystemComponent
Основная сущность GAS. В компоненте инкапсулирована вся логика активации абилок, эффектов, тасков и пр. Каждая сущность, которая будет взаимодействовать с любым из элементов GAS (выполнять абилки, принимать эффекты) должна иметь у себя такую компоненту.

Я намеренно буду опускать директивы include для улучшения читаемости.

Код
//h
UCLASS()
class AGASBaseCharacter : public ACharacter, public IAbilitySystemInterface
{
    GENERATED_BODY()
public:
    AGASBaseCharacter();

    UAbilitySystemComponent* GetAbilitySystemComponent() const override;

protected:
    UPROPERTY()
    UAbilitySystemComponent* AbilitySystemComponent;
};

//cpp
AGASBaseCharacter::AGASBaseCharacter()
{
    AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>("AbilitySystemComponent");
}

UAbilitySystemComponent* AGASBaseCharacter::GetAbilitySystemComponent() const
{
    return AbilitySystemComponent;
}

После завершения, главное не забыть унаследовать сгенерированного темплейт-проектом персонажа от только что созданного класса.

Т.к. мы хотим накладывать на персонажа эффекты и тем самым управлять его скоростью, создаем простенький AttributeSet, который позволит эти значения изменять.

Код

#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName)       	\
    GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName)           	\
    GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName)
UCLASS()
class UGASAttributeSet : public UAttributeSet
{
    GENERATED_BODY()
public:

    UPROPERTY(BlueprintReadOnly)
    FGameplayAttributeData Health = 100.0f;
    ATTRIBUTE_ACCESSORS(UGASAttributeSet, Health)

    UPROPERTY(BlueprintReadOnly)
    FGameplayAttributeData MoveSpeed = 600.0f;
    ATTRIBUTE_ACCESSORS(UGASAttributeSet, MoveSpeed)
};

Несколько пугает наличие макросов. Все они объявлены в файле AttributeSet.h. Если вкратце — GAMEPLAYATTRIBUTE_PROPERTY_GETTER добавляет геттер для аттрибута, GAMEPLAYATTRIBUTE_VALUE_GETTER/GAMEPLAYATTRIBUTE_VALUE_SETTER добавляют геттер и сеттер для значения аттрибута. Таким образом мы добавили 2 аттрибута в наш AttributeSet. Это MoveSpeed — скорость бега, Health — здоровье.

Добавим AttributeSet к нашему, только что созданному, классу персонажа:

Код

//h
UCLASS()
class AGASBaseCharacter : public ACharacter, public IAbilitySystemInterface
{
...
protected:
    UPROPERTY()
    UGASAttributeSet* AttributeSet;
...
};

//cpp
AGASBaseCharacter::AGASBaseCharacter()
{
    AttributeSet = CreateDefaultSubobject<UGASAttributeSet>(TEXT("AttributeSet"));
}


Теперь свяжем изменения значений AttributeSet с реальной скоростью передвижения персонажа в CharacterMovementComponent.

Код

void AGASBaseCharacter::BeginPlay()
{
    Super::BeginPlay();
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSet->GetMoveSpeedAttribute()).AddUObject(this, &AGASBaseCharacter::OnMovementAttributeChanged);
}

void AGASBaseCharacter::OnMovementAttributeChanged(const FOnAttributeChangeData& Data)
{
    GetCharacterMovement()->MaxWalkSpeed = AttributeSet->GetMoveSpeed();
}

На данном с c++ пока можно остановиться. Компилируем и открываем проект в UE4. Создаем новый Gameplay Ability Blueprint:



Я назвал свою абилку GA_CharacterRoll.

Добавляем в EventGraph абилки событие CommitExecute. Данный эвент вызывается автоматически при успешном запуске абилки (прошли проверки на Required/Blocked теги, успешно списались косты, применились кулдауны). Следом за ним добавляем геймплей таску PlayMontageAndWait. По эвенту OnCompleted сразу заканчиваем абилку. Таким образом, при запуске скилла мы проиграем монтаж, а по его концу завершим абилку. Для начала неплохо.



Чтобы персонаж не крутился на месте (как Sonic), предадим ему небольшое ускорение вперед. Для этого будем использовать ноду ApplyRootMotionConstantForce. Она добавляет постоянный импульс в течении указанного времени. У меня в поле Duration стоит -1. Таким образом, персонаж будет двигаться до тех пор, пока не закончится абилка.



Осталась последняя часть — замедлить всех персонажей, которых мы задели в процессе кувырка. Для начала изменим CollisionResponse нашей капсулы для Pawn канала на время действия абилки. По окончанию, разумеется, вернем все назад.





Момент пересечения с другими Pawn будем отслеживать в кастомной GameplayAblityTask.

GameplayAblityTask
Создать свою GAS таску достаточно легко. Нужно унаследоваться от UAbilityTask, добавить статический метод создания, переопределить Activate/OnDestroy и добавить в таску один или более делегатов. На последние можно будет подписаться в GameplayAbility и отслеживать прогресс/эвенты таски (единственно о чем нужно помнить — все делегаты должны быть одного типа).

Для этого вернемся в c++ и напишем чуть-чуть кода:

Код

//h
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FGASWaitOverlapDelegate, const FGameplayAbilityTargetDataHandle&, TargetData);

UCLASS()
class UGASAbilityTask_WaitForComponentOverlap : public UAbilityTask
{
    GENERATED_BODY()

    UPROPERTY(BlueprintAssignable)
    FGASWaitOverlapDelegate OnOverlap;

    virtual void Activate() override;

    UFUNCTION(BlueprintCallable, Category = "Ability|Tasks", meta = (HidePin = "OwningAbility", DefaultToSelf = "OwningAbility"))
    static UGASAbilityTask_WaitForComponentOverlap* WaitForComponentOverlap(UGameplayAbility* OwningAbility, UPrimitiveComponent* Component);

private:
    virtual void OnDestroy(bool AbilityEnded) override;

    UFUNCTION()
    void OnHitCallback(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

    UPROPERTY()
    UPrimitiveComponent* Component;
};

//cpp
void UGASAbilityTask_WaitForComponentOverlap::Activate()
{
    Super::Activate();

    if (Component)
    {
        Component->OnComponentBeginOverlap.AddDynamic(this, &UGASAbilityTask_WaitForComponentOverlap::OnHitCallback);
    }
}

UGASAbilityTask_WaitForComponentOverlap* UGASAbilityTask_WaitForComponentOverlap::WaitForComponentOverlap(UGameplayAbility* OwningAbility, UPrimitiveComponent* Component)
{
    auto abilityTask = NewAbilityTask<UGASAbilityTask_WaitForComponentOverlap>(OwningAbility);
    abilityTask->Component = Component;
    return abilityTask;
}

void UGASAbilityTask_WaitForComponentOverlap::OnDestroy(bool AbilityEnded)
{
    if (Component)
    {
        Component->OnComponentBeginOverlap.RemoveAll(this);
    }

    Super::OnDestroy(AbilityEnded);
}

void UGASAbilityTask_WaitForComponentOverlap::OnHitCallback(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    if (OtherActor)
    {
        auto targetData = new FGameplayAbilityTargetData_ActorArray();
        targetData->TargetActorArray.Add(OtherActor);

        auto targetDataHandle = FGameplayAbilityTargetDataHandle(targetData);
        if (ShouldBroadcastAbilityTaskDelegates())
        {
            OnOverlap.Broadcast(targetDataHandle);
        }
    }
}

Статический метод WaitForComponentOverlap создает экземпляр нашей таски. На вход мы принимаем компоненту для которой будем отслеживать Overlap эвенты, затем через функцию NewAbilityTask создаем и инициализируем ее. В методе Activate подписываемся на Overlap эвенты компоненты и по их наступлению вызываем делегат OnOverlap. Стоит обратить внимание, что в аргумент мы отдаем не самого актора, а структуру FGameplayAbilityTargetDataHandle. Это специальная структура, которая указывает на цель, содержит либо акторов либо точку в мире, над которыми необходимо совершить некоторые действия ( в нашем случае — наложить эффект). Т.к. эффекты мы применяем к акторам, используем FGameplayAbilityTargetData_ActorArray и в массив добавляем Overlap актора. В методе OnDestroy отписываемся от всех эвентов на которые подписались.

Возвращаемся в анрил, самое время создать эффект замедления. Создаем новый блюпринт и наследуем его от GameplayEffect класса.

GameplayEffect
Атомарная сущность которая манипулирует аттрибут сетом и тегами того, на кого накладывается эффект. Стоит разобрать параметры геймлей эффекта более подробно:

Всего бывает 3 вида эффектов: Instant, Infinite, Has Duration. Этим управляет параметр DurationPolicy.

  • Instant — мгновенный эффект, котрый меняет значение в аттрибут сете. Важно помнить, что Instant эффект не может накладывать на персонажа никаких тегов, они просто будут проигнорированы
  • Infinite — эффект, который единовременно изменяет значения AttributeSet и накладывает/снимает с персонажа указанные теги. Сам эффект никогда не заканчивается, снять его можно только явно.
  • Has Duration — геймплей эффект, который накладывается на ограниченное количество времени. Полностью аналогичен infinite за исключением того, что он будет снят по истечению указанного времени.


Важно помнить, что все манипуляции с AttributeSet, которые производят Infinite и Has Duration эффекты (для простоты просто Duration эффекты) будут отменены при снятии эффекта.
Так же Duration эффекты могут иметь период. В таком случае они будут применяться раз в указанный период, при этом все изменения что они производят будут трактоваться как Instant эффекты.

Каждый эффект може иметь несколько модифаеров — атомарных операций над аттрибут сетом. Основные операции над аттрибут сетом: Add/Multiply/Override. То, на какое конкретно значение измениться данный аттрибут определяется параметром ModifierMagnitude. Есть несколько видов модифаеров:
  • Scalable float — обычное float значение, может быть загружено из CurveTable.
  • AttributeBased — значение вычисляется на основе другого аттрибута
  • CustomCalculation — Позволяет использовать кастомный C++ класс для вычисления значения аттрибута
  • SetByCaller — значение аттрибута будет установлено извне (через FGameplayEffectSpec, с привязкой к конкретному тегу).

Каждый эффект имеет шанс применения (Chance to Apply to Target) и необходимые условия для применения. Это могут быть как простые условия, описываемые с помощью тегов (Application Tag Requirements), так и кастомные, описываемые извне (класс UGameplayEffectCustomApplicationRequirement).

Как уже упоминалось ранее, эффекты могут накладывать или снимать теги (Granted Tags), автоматически сниматься при условии наличия тех или иных тегов (RemovalTag Requirements), а также снимать с таргета другие эффекты, которые удовлетворяют условиям Remove Gameplay Effects with Tags или Remove Gameplay Effect Query.

На самом деле это не весь функционал геймплей эффектов, я упустил моменты с GameplayEffectExecutionCalculation, стаками эффектов, переполнением стаков, Immunity тегами, GameplayCue и много другое. Часть из этого будет упомянуто в этой статье дальше, часть будет затронуто в дальнейших статьях.



В эффекте настраиваем DurationPolicy -> Has Duration и меняем длительность действия. У меня это 5 секунд. Добавляем в эффект Modifier для аттрибута MoveSpeed, в качестве оператора выбираем Multiply и ставим магнитуду в 0.2 (таким образом снижаем скорость врагов на 80%).

Возвращаемся в эвент граф абалки GA_CharacterRoll и в самый конец дописываем только что созданную геймплей таску и наложение эффекта:



Эффект я вынес в отдельную переменную с типом Class Reference, в значение по умолчанию вписал свежесозданный эффект GE_Slowdown. Сама абилка закончена, однако мы не написали никакой логики по ее активации. Как я упоминал в самом начале, GAS умеет привязывать активацию скилов к InputAction движка. Для начала в Project Settings -> Engine/Input добавим 2 новых Action Mapping.



Я добавил Roll и Kick экшены. Теперь добавим персонажу возможность активировать абилки по наступлению этих событий. Для этого опять вернемся в c++. Первым делом создадим enum который будет описывать действия:

Код

UENUM(BlueprintType)
enum class EGASInputActions : uint8
{
    None,
    Roll,
    Kick
};

В класс персонажа добавим маппинг экшена на абилити

Код

UCLASS()
class AGASBaseCharacter : public ACharacter, public IAbilitySystemInterface
{
...
public:
    UPROPERTY(EditDefaultsOnly)
    TMap<EGASInputActions, TSubclassOf<UGameplayAbility>> StandartAbilities;
};

и в BeginPlay «выдадим» персонажу все абилки, что мы наконфигурировали

Код

void AGASBaseCharacter::BeginPlay()
{
...
    for (const auto& ability : StandartAbilities)
    {
   	 AbilitySystemComponent->GiveAbility(FGameplayAbilitySpec(ability.Value, 1, static_cast<int32>(ability.Key)));
    }
...
}

Самое интересное — нужно зарегистрировать соответствие между именами Action Mapping и индексами EGASInputActions. Сделать это можно в методе SetupPlayerInputComponent, достаточно добавить туда строку:

AbilitySystemComponent->BindAbilityActivationToInputComponent(PlayerInputComponent, FGameplayAbilityInputBinds(FString("ConfirmTarget"), FString("CancelTarget"), FString("EGASInputActions")));


Возвращаемся в анрил, открываем блюпринт персонажа и настраиваем для способность GA_CharacterRoll



Вот и все, можно запускать и проверять только что созданную способность.

Абилка пинок


Перейдем к реализации способности пинка. Суть довольно проста: игрок делает удар ногой, если он попадает по врагу, то тот отлетает назад, падает на спину и затем встает. Механически это будет выглядеть следующим образом: запускаем абилку пинка, в нужный момент находим цель перед собой, применяем на нее эффект отбрасывания, заканчиваем абилку.

Начнем с создания новой абилки, создаем блюпринт, наследника GameplayAbility. В эвент CommitExecute добавляем таску на проигрывание монтажа и таску ожидания GameplayEvent. Сам по себе GameplayEvent это событие, которое имеет тег и некоторые данные (Payload).



Конкретно в данном случае мы ожидаем эвента Event.Montage.Kick. Давайте напишем код, который этот эвент будет посылать. Для этого создадим новый блюпринт (BP_AnimNotify_SendGameplayEvent) и унаследуем его от AnimNotify. В Variables добавим одну публичную переменную с именем EventTag и типом GameplayTag. Переопределим функцию Received_Notify и отправим наш новый GameplayEvent актору на котором сработал данный нотифай.



Теперь в монтаж пинка можно добавить наш новый нотифай, значение EventTag сделаем равным Event.Montage.Kick:



Продолжим создание абилки. По наступлению GameplayEvent осталось всего-то определить цель и наложить на нее эффект падения. Перейдем к его созданию.

Рассмотрим более комплексный подход к обработке эффектов, когда нам не требуется модификация какого-либо атрибута, однако нужно каким-то гейм-механическим образом реагировать на применение того или иного эффекта. Для этого создадим новый класс эффекта в c++:

Код

UCLASS(BlueprintType)
class UKnockbackGameplayEffect : public UGameplayEffect
{
    GENERATED_BODY()

public:

    UPROPERTY(EditDefaultsOnly)
    float Strength = 100;
};

Манипулировать будем «силой» пинка, чем она больше, тем дальше отбрасывает нашу цель. В нашем базовом классе персонажа подпишемся на делегат OnGameplayEffectAppliedDelegateToSelf — он вызывается, когда геймплей эффект применяется на овнера AbilitySystemComponent (однако только на сервере, стоит это помнить).

Код

//h
UCLASS()
class AGASBaseCharacter : public ACharacter, public IAbilitySystemInterface
{
   ...
    UFUNCTION()
    void OnGameplayEffectApplied(UAbilitySystemComponent* Source, const FGameplayEffectSpec& Spec, FActiveGameplayEffectHandle Handle);
   ...
};

//cpp

void AGASBaseCharacter::BeginPlay()
{
...
    AbilitySystemComponent->OnGameplayEffectAppliedDelegateToSelf.AddUObject(this, &AGASBaseCharacter::OnGameplayEffectApplied);
...
}

void AGASBaseCharacter::OnGameplayEffectApplied(UAbilitySystemComponent* Source, const FGameplayEffectSpec& Spec, FActiveGameplayEffectHandle Handle)
{
    if (auto knockbackEffect = Cast<UKnockbackGameplayEffect>(Spec.Def))
    {
        if (auto movementComponent = GetCharacterMovement())
        {
            auto instigator = Spec.GetEffectContext().GetInstigator();
            auto direction = instigator ? (GetActorLocation() - instigator->GetActorLocation()).GetSafeNormal2D() : GetActorForwardVector() * -1.0f;

            auto constantForce = new FRootMotionSource_ConstantForce();
            constantForce->AccumulateMode = ERootMotionAccumulateMode::Additive;
            constantForce->Force = direction * knockbackEffect->Strength;
            constantForce->Duration = Spec.GetDuration();
            constantForce->FinishVelocityParams.Mode = ERootMotionFinishVelocityMode::SetVelocity;
            constantForce->FinishVelocityParams.SetVelocity = FVector::ZeroVector;
            movementComponent->ApplyRootMotionSource(constantForce);
        }
    }
}

В обработчике проверяем что на нас пытаются наложить knockback эффект, если это так — применяем уже знакомый RootMotionSource в направлении, противоположном
источнику эффекта. Переходим в редактор и создаем блюпринт KnockbackGameplayEffect.



В блюпринте настраиваем силу и длительность эффекта. Осталось проиграть анимацию падения. Для всех «косметических» эффектов (звуки, партиклы, анимации и пр.) в GAS предусмотрена механика GameplayCue. Работает она так же, посредством геймплей тегов, с единственным ограничением: тег должен быть в категории GameplayCue. Добавим новую геймплей кью в наш эффект.



Т.к. наш эффект не модифицирует ни одного атрибута, уберем чекбокс “Require Modifier Success to Trigger Cues”. Добавим обработчик данной Cue, для этого откроем окно GameplayCueEditor (Window -> GameplayCueEditor):



Нажимаем Add new напротив интересующего нас тега (GameplayCue.Knockback) и создаем Gameplay Cue Notify Static обработчик. Переопределяем OnActivate функцию и проигрываем в ней монтаж падения.



Все, на этом эффект падения закончен. Можно добавить код по его активации в блюпринт абилки:



Не буду сильно останавливаться на функции GetTarget. На самом деле, для определения целей в GAS существует отдельная механика (TargetActor), но пока что реализуем этот функционал самым простейшим способом — LineTrace.



Падение мы реализовали. Теперь нужно сделать так, чтобы во время этого наша цель потеряла возможность двигаться. Для этого добавим последний эффект — стан. Создаем новый блюпринт от класса GameplayEffect. Все что он буддет делать — это накладывать на персонажа тег Effect.Stun на нужное количество времени.



Перейдем в c++ и напишем обработчик данного события:

Код

//h
UCLASS()
class AGASBaseCharacter : public ACharacter, public IAbilitySystemInterface
{
  ...

    UFUNCTION()
    void OnStunTagChanged(const FGameplayTag Tag, int32 Count);
...
};

//cpp

void AGASBaseCharacter::BeginPlay()
{
...

AbilitySystemComponent->RegisterGameplayTagEvent(FGameplayTag::RequestGameplayTag("Effect.Stun")).AddUObject(this, &AGASBaseCharacter::OnStunTagChanged);

...
}

void AGASBaseCharacter::OnStunTagChanged(const FGameplayTag Tag, int32 Count)
{
    if (auto aiController = Cast<AAIController>(GetController()))
    {
   	 if (Count > 0)
   	 {
   		 aiController->GetBrainComponent()->PauseLogic(TEXT(""));
   	 }
   	 else
   	 {
   		 aiController->GetBrainComponent()->ResumeLogic(TEXT(""));
   	 }
    }
}

Подписываемся на событие изменения тега Effect.Stun, если количество тегов больше нуля — выключаем всю AI логику у персонажа, если меньше или равно нулю — включаем.
Дело за малым, добавляем применение второго эффекта на цель:



Финальный вид скилла:

gif


Как видите, Gameplay Ability System открывает множество возможностей для разработчиков. В следующем материале мы подробно разберем мультиплеер составляющую GAS и таргетинг.
Tags:
Hubs:
+4
Comments16

Articles

Information

Website
www.socialquantum.ru
Registered
Founded
2010
Employees
201–500 employees
Location
Россия