ПРИМЕНИ, А НЕ ТОЛЬКО ПРОЧИТАЙ

Сквозной проект: один класс персонажа через весь курс

Задание «Практика» в каждой статье проверяет одну идею изолированно — этого достаточно, чтобы понять идею, но недостаточно, чтобы почувствовать, как дюжина таких идей уживается в одном живом классе, где решения предыдущих недель нельзя тихо переписать, если сегодняшняя тема их не устраивает. Этот проект — второй, параллельный трек: один класс AAcademyCharacter в маленькой survival-игре, который читатель ведёт от Level 2 («Объекты рантайма») до Level 8 («Production»), добавляя на каждом шаге ровно ту способность, которую даёт очередной Level — и ничего заранее.

Три правила проекта

1. Класс один и тот же от начала до конца. Не «новый пример на каждую тему», а один и тот же AAcademyCharacter (плюс сопутствующие классы — компоненты, контроллер, GameMode и так далее), который растёт вместе с прохождением курса — так же, как растёт настоящий проект в разработке. Ниже 12 шагов — 00…11 — а не 12 разных игр.

2. Более раннее решение не переписывается тихо. Если тема очередного шага предполагает другой подход к тому, что было сделано раньше — это фиксируется как осознанный рефакторинг с блоком «Было / Стало» и объяснением, что изменилось и почему, а не как молчаливая замена. В реальном проекте отменить прошлое решение без объяснения — не вариант.

3. Каждый шаг — это решение, а не копирование примера. Статьи курса дают UDataAsset, UHealthComponent, UGameplayAbility и десятки других фрагментов кода как иллюстрации к своей теме. Здесь задача — не скопировать фрагмент, а решить, как именно он входит в уже существующий класс, что в нём нужно переименовать, обобщить или сократить под конкретную задачу этого проекта.

Раскладка ниже идёт строго по шагам одной и той же игры — в порядке, в котором Level'ы курса дают для них материал. Каждая карточка — это то, что AAcademyCharacter (или его окружение) умеет после этого шага и не умел до него: какие статьи для этого нужны, конкретный список результата и — там, где шаг меняет уже существующий файл, а не создаёт новый с нуля, — блок «Было / Стало» с реальным diff'ом кода.

00
Шаг 00 — Персонаж рождается (Level 2)
Первая строка кода проекта — и она сознательно скучная. AAcademyCharacter появляется как голый наследник ACharacter: без здоровья, без инвентаря, без единой геймплейной способности. Капсула коллизии, скелетный меш и UCharacterMovementComponent у него уже есть — их создал конструктор ACharacter, и трогать их можно только через уже существующие подобъекты, а не пересоздавать заново (см. статью про жизненный цикл Actor — почему обязательные подобъекты создаются строго в конструкторе через CreateDefaultSubobject, а не позже). Цель шага — не спешить: прежде чем добавлять способности, зафиксировать, что в этом классе является геймплейным решением, а что — просто наследуется от движка бесплатно.
AAcademyCharacter.hC++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.cppC++
AAcademyCharacter::AAcademyCharacter()
{
    // Capsule и Mesh уже существуют — их создал конструктор ACharacter.
    // Трогаем только то, что реально отличает нашего персонажа от дефолтного.
    GetCapsuleComponent()->InitCapsuleSize(42.f, 96.f);
    bUseControllerRotationYaw = false;
}
  • Явно объяснено (себе), почему AAcademyCharacter наследуется от ACharacter, а не от голого APawn — движение по земле, приседание, капсула нужны с первого дня, а не «может пригодятся».
  • Ни одно поле ещё не добавлено «на будущее» — если способность не нужна прямо сейчас, её нет в классе.
  • Зафиксировано, какая инициализация обязана ждать BeginPlay (ничего в этом шаге её ещё не требует, но привычка называть это явно закладывается с первого файла).
01
Шаг 01 — Здоровье и инвентарь: компоненты, не поля (Level 2)
Здоровье выносится не полем персонажа, а отдельным UHealthComponent — решение, оправданное не «так принято», а тем самым комбинаторным взрывом, который разбирает статья про компоненты (представь, что здоровье нужно и Character, и будущему AAcademyEnemy из шага 08). По той же причине инвентарь — UInventoryComponent поверх TArray<FAcademyInventorySlot>, а не параллельные массивы «имена + количества» прямо в классе персонажа. Компонент здоровья сразу получает FOnHealthChanged — динамический мультикаст-делегат: он ещё ни на что не подписан (UI появится только в шаге 04), но именно с этого момента компонент не должен знать заранее, кто будет его слушать.
AAcademyCharacter.h — было (шаг 00)C++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.h — сталоC++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();

protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    TObjectPtr<UHealthComponent> HealthComponent;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    TObjectPtr<UInventoryComponent> InventoryComponent;
};
AAcademyCharacter.cpp — добавлено в конструкторC++
HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("HealthComponent"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("InventoryComponent"));
HealthComponent.h — новый файлC++
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth);

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class ACADEMYGAME_API UHealthComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Health")
    float MaxHealth = 100.f;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Health")
    float CurrentHealth;

    UPROPERTY(BlueprintAssignable)
    FOnHealthChanged OnHealthChanged;

    void ApplyDamage(float Amount);
protected:
    virtual void BeginPlay() override;
};
InventoryComponent.h — новый файлC++
USTRUCT(BlueprintType)
struct FAcademyInventorySlot
{
    GENERATED_BODY()

    // Полный тип UAcademyItemDataAsset появится только в шаге 03 — пока достаточно
    // forward-декларации, компонент не обязан знать, из чего состоит предмет.
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TObjectPtr<UAcademyItemDataAsset> Item;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 Count = 0;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class ACADEMYGAME_API UInventoryComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Inventory")
    TArray<FAcademyInventorySlot> Slots;

    bool AddItem(UAcademyItemDataAsset* Item, int32 Count);
};
  • UHealthComponent и UInventoryComponent — переиспользуемые UActorComponent, а не поля прямо в персонаже.
  • FOnHealthChanged объявлен изнутри компонента и вызывается им самим при изменении здоровья — не читается снаружи опросом каждый кадр.
  • TArray<FAcademyInventorySlot> для инвентаря с осмысленной структурой слота (предмет + количество), а не параллельные массивы.
Развилка
Дизайнер просит добавить в игру разрушаемый сундук с собственным здоровьем и таким же баром урона, что и у персонажа. Стоит ли сундуку наследоваться от AAcademyCharacter, чтобы переиспользовать здоровье?
Показать ожидаемый ход рассуждения

Нет — сундук не «является» персонажем ни в одном осмысленном смысле (у него нет движения, ввода, контроллера), наследование от AAcademyCharacter ради одного общего поля — именно та ошибка, которую разбирает статья про компоненты. Правильное решение — тот же UHealthComponent, добавленный к отдельному, гораздо более простому классу AAcademyChest : public AActor. Компонент спроектирован как раз для того, чтобы это переиспользование не требовало общего предка — тот же паттерн шаг 08 применит к AAcademyEnemy.

02
Шаг 02 — Enhanced Input: управление без зашитых клавиш (Level 3 / 5.A)
Персонаж наконец умеет двигаться — но не через "W"/"A"/"S"/"D" в коде, а через именованные UInputAction, подключённые через UInputMappingContext. Сразу заводится второй контекст — InventoryMappingContext — чтобы будущее открытие инвентаря (шаг 04) не заставляло персонажа драться за одни и те же клавиши с обычным передвижением: он добавляется через AddMappingContext при открытии и обязательно снимается через RemoveMappingContext при закрытии. Обработчик InteractAction уже объявлен как поле, но пока не подключён — взаимодействовать пока не с чем, это осознанно отложено до шага 03, а не «на всякий случай реализовано заранее».
AAcademyCharacter.h — стало (добавлено к шагу 01)C++
protected:
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputMappingContext> DefaultMappingContext;

    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputMappingContext> InventoryMappingContext;

    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> MoveAction;
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> LookAction;
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> InteractAction; // подключим в шаге 03
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> ToggleInventoryAction;

    virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
    void Move(const FInputActionValue& Value);
    void Look(const FInputActionValue& Value);
    void ToggleInventory(const FInputActionValue& Value);
AAcademyCharacter.cppC++
void AAcademyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    if (auto* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetController<APlayerController>()->GetLocalPlayer()))
    {
        Subsystem->AddMappingContext(DefaultMappingContext, 0);
    }

    if (auto* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        EIC->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AAcademyCharacter::Move);
        EIC->BindAction(LookAction, ETriggerEvent::Triggered, this, &AAcademyCharacter::Look);
        EIC->BindAction(ToggleInventoryAction, ETriggerEvent::Started, this, &AAcademyCharacter::ToggleInventory);
    }
}

void AAcademyCharacter::ToggleInventory(const FInputActionValue& Value)
{
    auto* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetController<APlayerController>()->GetLocalPlayer());
    if (!Subsystem) return;

    if (bInventoryOpen) { Subsystem->RemoveMappingContext(InventoryMappingContext); }
    else            { Subsystem->AddMappingContext(InventoryMappingContext, 1); } // приоритет выше базового
    bInventoryOpen = !bInventoryOpen;
}
  • Минимум два UInputMappingContext: базовый (движение, взгляд, будущий интеракт) и инвентарный.
  • Открытие инвентаря добавляет второй контекст с более высоким приоритетом, закрытие — гарантированно его снимает (два подряд открытия-закрытия не оставляют дублирующийся контекст).
  • Все игровые действия — именованные UInputAction, ни одно не читает физическую клавишу по имени напрямую в C++.
03
Шаг 03 — Интеракция и предметы как данные (Level 4)
Мир получает предметы, которые можно подобрать. Вместо Cast<AAcademyItem> и завтрашнего такого же Cast<AAcademyDoor> в коде интеракции вводится UInteractableInterface (UINTERFACE) с методом Interact — общая способность без общего предка. Сам метод — BlueprintNativeEvent, а не BlueprintImplementableEvent: C++ сохраняет базовую логику (кто взаимодействует, что произошло с предметом), а дизайнер добавляет визуальный эффект в Blueprint поверх неё, не переопределяя всё целиком. Характеристики предмета (имя, иконка, урон, стак) — не поля C++ класса, а UAcademyItemDataAsset: дизайнер балансирует игру без пересборки C++.
AAcademyCharacter.h — стало (добавлено к шагу 02)C++
protected:
    // FocusedInteractable — сильная ссылка: пока предмет в фокусе, он должен
    // быть жив и виден GC ровно как любое другое активное игровое состояние.
    UPROPERTY()
    TObjectPtr<AActor> FocusedInteractable;

    void Interact(const FInputActionValue& Value);
AAcademyCharacter.cpp — InteractAction наконец подключёнC++
EIC->BindAction(InteractAction, ETriggerEvent::Started, this, &AAcademyCharacter::Interact);

void AAcademyCharacter::Interact(const FInputActionValue& Value)
{
    if (!FocusedInteractable || !FocusedInteractable->Implements<UInteractableInterface>()) return;
    IInteractableInterface::Execute_Interact(FocusedInteractable, this);
}
InteractableInterface.h — новый файлC++
UINTERFACE(MinimalAPI, Blueprintable)
class UInteractableInterface : public UInterface { GENERATED_BODY() };

class IInteractableInterface
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintNativeEvent, Category="Interaction")
    void Interact(AActor* Instigator);
};
AcademyItemDataAsset.h — новый файлC++
UCLASS(BlueprintType)
class ACADEMYGAME_API UAcademyItemDataAsset : public UDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly, Category="Item")
    FText DisplayName;

    // Тяжёлая текстура превью нужна только когда открыт инвентарь (шаг 04) —
    // TSoftObjectPtr не тащит её в память вместе с самим DataAsset.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    TSoftObjectPtr<UTexture2D> Icon;

    UPROPERTY(EditDefaultsOnly, Category="Item")
    int32 MaxStack = 1;

    // Используется атакой из шага 07 — если предмет экипирован как оружие.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    float AttackDamage = 0.f;
};
AcademyItem.h — новый файл, подбираемый предмет на уровнеC++
UCLASS()
class ACADEMYGAME_API AAcademyItem : public AActor, public IInteractableInterface
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, Category="Item")
    TObjectPtr<UAcademyItemDataAsset> ItemData;

    virtual void Interact_Implementation(AActor* Instigator) override;
};
  • UInteractableInterface, реализованный у AAcademyItem и (на будущее) у любого нового интерактивного класса без общего предка между ними.
  • Метод InteractBlueprintNativeEvent, с объяснённой причиной, почему не BlueprintImplementableEvent.
  • Характеристики предмета — UAcademyItemDataAsset, редактируемый без пересборки C++; иконка — TSoftObjectPtr, а не жёсткая ссылка.
04
Шаг 04 — UI: здоровье и инвентарь на экране (Level 5.B)
Полоса здоровья строится как UMG-виджет с BindWidget-полем на UProgressBar, но не подписывается на UHealthComponent напрямую через Cast<AAcademyCharacter> в виджете — вместо этого виджет получает значение через UAcademyHealthViewModel (MVVM), который сам слушает OnHealthChanged компонента здоровья из шага 01. Чтобы ViewModel вообще мог достучаться до компонентов, персонажу приходится сделать маленький, но осознанный шаг назад: добавить публичные геттеры к полям, которые были protected с шага 01 — это первый в проекте пример того, что решение предыдущего шага не «неправильное», а просто было принято для меньшей на тот момент задачи.
AAcademyCharacter.h — было / сталоC++
// Было (шаг 01): доступ только изнутри класса и наследников.
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    TObjectPtr<UInventoryComponent> InventoryComponent;

// Стало (шаг 04): ViewModel и UI-код живут вне класса персонажа и
// не имеют права на Cast к его private-состоянию — им нужен официальный вход.
public:
    UHealthComponent* GetHealthComponent() const { return HealthComponent; }
    UInventoryComponent* GetInventoryComponent() const { return InventoryComponent; }
AcademyHealthViewModel.h — новый файлC++
UCLASS()
class ACADEMYGAME_API UAcademyHealthViewModel : public UMVVMViewModelBase
{
    GENERATED_BODY()
public:
    void InitFor(UHealthComponent* InHealthComponent);

    UPROPERTY(FieldNotify, BlueprintReadOnly)
    float HealthFraction = 1.f;

private:
    void HandleHealthChanged(float NewHealth);
};
  • Полоса здоровья — UMG-виджет с BindWidget, получающий данные через ViewModel, а не прямым Cast к AAcademyCharacter.
  • ViewModel подписывается на FOnHealthChanged компонента здоровья из шага 01, а не опрашивает его каждый кадр.
  • Экран инвентаря читает InventoryComponent->Slots через тот же принцип геттера, что и здоровье — не напрямую копаясь в полях персонажа.
На заметку — шаг 07 вернётся сюда

В шаге 07 здоровье переезжает из UHealthComponent в UAcademyAttributeSet (GAS). UAcademyHealthViewModel, написанный здесь, придётся перепривязать к новому источнику данных — это не будет скрыто: см. блок «Было / Стало» в шаге 07.

05
Шаг 05 — Gameplay Framework: ретроактивная формализация ролей (Level 3)
До этого шага AAcademyCharacter молчаливо совмещал «тело» и «мозг»: он сам читал Enhanced Input в шаге 02. Это не было ошибкой на тот момент — Level 3 (Gameplay Framework) ещё не пройден, — но теперь, когда роли известны, это явно фиксируется как рефакторинг, а не как то, что «всегда так и планировалось». Ввод и камера переезжают в AAcademyPlayerController: он подписывается на Enhanced Input и вызывает уже существующие методы персонажа, а не читает его внутренние поля. Данные, которые обязаны пережить смерть и респавн (счёт собранных предметов), переезжают в AAcademyPlayerState. Правила победы и точки спавна — на сервере, в AAcademyGameMode.
AAcademyCharacter.h — было (шаг 02–04)C++
protected:
    TObjectPtr<UInputMappingContext> DefaultMappingContext;
    TObjectPtr<UInputMappingContext> InventoryMappingContext;
    TObjectPtr<UInputAction> MoveAction, LookAction, InteractAction, ToggleInventoryAction;

    virtual void SetupPlayerInputComponent(UInputComponent*) override;
    void Move(const FInputActionValue&);
    void Look(const FInputActionValue&);
    void Interact(const FInputActionValue&);
    void ToggleInventory(const FInputActionValue&);
AAcademyCharacter.h — стало: весь Input-блок удалён, персонаж — только исполнительC++
public:
    // Вызывается контроллером, а не привязан к клавише напрямую внутри персонажа.
    void RequestMove(const FVector2D& Axis);
    void RequestLook(const FVector2D& Axis);
    void RequestInteract();
    void RequestToggleInventory();
AcademyPlayerController.h — новый файл, забирает Input-блок из персонажаC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerController : public APlayerController
{
    GENERATED_BODY()
protected:
    virtual void SetupInputComponent() override; // та же логика AddMappingContext/BindAction, что была в шаге 02

    void Move(const FInputActionValue& Value)
    {
        if (auto* Character = GetPawn<AAcademyCharacter>())
            Character->RequestMove(Value.Get<FVector2D>());
    }
};
AcademyPlayerState.h — новый файлC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerState : public APlayerState
{
    GENERATED_BODY()
public:
    // Переживает смерть и респавн персонажа — сам AAcademyCharacter не переживает.
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Progress")
    int32 ItemsEverCollected = 0;
};
  • Ввод и управление камерой — в AAcademyPlayerController, персонаж больше не читает Input напрямую в себе.
  • Данные, переживающие пересоздание Pawn (счёт собранных предметов) — в AAcademyPlayerState, не в самом AAcademyCharacter.
  • AAcademyGameMode существует только на сервере и хранит правила победы/спавна; AAcademyGameState реплицирует то, что должны видеть все клиенты.
Развилка
Персонаж умирает и через 5 секунд респавнится новым Pawn'ом. Где должно храниться количество набранных за игру очков — в AAcademyCharacter, AAcademyPlayerState или AAcademyGameState?
Показать ожидаемый ход рассуждения

В AAcademyPlayerState. AAcademyCharacter уничтожается при смерти вместе со всеми своими полями — хранить там переживающие смерть данные означает терять их при каждом респавне. AAcademyGameState — тоже неверный уровень: это данные конкретного игрока, а не общее состояние матча, одинаковое для всех. Правило простое: если данные должны пережить смерть персонажа, но принадлежат одному конкретному игроку — это PlayerState, а не Character и не GameState.

06
Шаг 06 — Multiplayer: персонаж перестаёт быть локальным (Level 6)
Первый по-настоящему болезненный рефакторинг проекта: здоровье из UHealthComponent становится Replicated, интеракция из шага 03 перестаёт напрямую менять состояние на клиенте и превращается в Server RPC с проверкой дистанции и кулдауна уже на сервере (иначе любой клиент может подобрать предмет на другом конце уровня одним пакетом). Здесь же появляется первая версия атаки — прямой Server RPC, вычитающий здоровье через ApplyDamage. Это сознательно наивная реализация: в шаге 07 она станет UGameplayAbility, и здесь явно зафиксировано, что она временная.
AAcademyCharacter.h — стало (добавлено к шагу 05)C++
public:
    void RequestInteract();
    void RequestAttack(); // временно — до GAS в шаге 07

protected:
    UFUNCTION(Server, Reliable)
    void ServerInteract(AActor* Target);

    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.cppC++
void AAcademyCharacter::ServerInteract_Implementation(AActor* Target)
{
    // Сервер не доверяет клиенту — расстояние и кулдаун проверяются заново,
    // даже если клиент уже проверил их у себя ради мгновенного отклика.
    if (!Target || GetDistanceTo(Target) > InteractRange) return;
    IInteractableInterface::Execute_Interact(Target, this);
}

void AAcademyCharacter::ServerAttack_Implementation()
{
    if (AActor* Target = FindAttackTarget())
        Target->FindComponentByClass<UHealthComponent>()->ApplyDamage(10.f);
}
HealthComponent.h — стало: Replicated + рольC++
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth, VisibleAnywhere, BlueprintReadOnly, Category="Health")
float CurrentHealth;

UFUNCTION()
void OnRep_CurrentHealth(); // обновляет визуал урона на клиентах и вызывает OnHealthChanged
  • HealthComponent::CurrentHealthUPROPERTY(Replicated), с OnRep-функцией, обновляющей визуал урона на клиентах.
  • Interact из шага 03 переработан: клиент вызывает Server RPC, сервер перепроверяет дистанцию/кулдаун независимо от того, что прислал клиент.
  • В коде персонажа есть минимум одно место, где явно проверяется GetLocalRole()/GetRemoteRole() перед выполнением логики.
Развилка
После этого шага Interact вызывается через Server RPC. Нужно ли теперь убирать проверку дистанции с клиентской стороны, раз сервер всё равно перепроверяет?
Показать ожидаемый ход рассуждения

Нет — клиентская проверка остаётся как ранняя, локальная отсечка, дающая мгновенный отклик игроку (не нужно ждать round-trip до сервера, чтобы понять «слишком далеко»), а серверная проверка остаётся единственным источником истины, которому нельзя доверять клиенту. Сервер обязан перепроверять любой Server RPC, а не полагаться на то, что клиент уже проверил.

07
Шаг 07 — GAS: способности поверх уже реплицированного персонажа (Level 5.C)
Самый крупный рефакторинг проекта: здоровье из UHealthComponent (шаг 01, реплицировано в шаге 06) переносится в UAcademyAttributeSet как FGameplayAttributeData с раздельными Base/CurrentValue — старое хранение простым Replicated float не умеет корректно откатывать один-единственный временный баф, если их наложено несколько. Наивный ServerAttack из шага 06 становится UGameplayAbility с явным ActivateAbility/EndAbility, урон применяется через UGameplayEffect (Instant), а не напрямую вычитанием. Тег State.Attacking через ActivationBlockedTags не даёт способности перезапуститься поверх самой себя — замена ручной if-проверки. Момент удара сохраняет источник урона в TWeakObjectPtr<AActor> — персонаж не должен ни владеть атакующим, ни удерживать его от сборки мусора одним фактом, что где-то в поле лежит указатель на него.
AAcademyCharacter.h — было (шаг 06)C++
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.h — сталоC++
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // HealthComponent из шага 01/06 заменён — старое поле Health удалено не молча:
    // причина зафиксирована выше в тексте шага, компонент не умел откатывать бафы.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;

    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;

    // Кто последним нанёс урон — для HUD "убит игроком X". Слабая ссылка:
    // персонаж не владеет атакующим и не должен мешать его сборке мусора.
    TWeakObjectPtr<AActor> LastDamageInstigator;
AcademyAttributeSet.h — новый файл, заменяет HealthComponentC++
UCLASS()
class ACADEMYGAME_API UAcademyAttributeSet : public UAttributeSet
{
    GENERATED_BODY()
public:
    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData Health;
    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData MaxHealth;

    virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
};
AcademyAttackAbility.h — новый файл, заменяет ServerAttackC++
UCLASS()
class ACADEMYGAME_API UAcademyAttackAbility : public UGameplayAbility
{
    GENERATED_BODY()
public:
    UAcademyAttackAbility()
    {
        // Тег вместо ручного "if (bIsAttacking) return" — GAS сам не даст
        // активировать способность повторно, пока тег не снят в EndAbility.
        ActivationBlockedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("State.Attacking")));
    }

    virtual void ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo,
        const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) override;
};
  • UAcademyAttributeSet с Health/MaxHealth как FGameplayAttributeData — старое поле HealthComponent явно помечено как заменённое, а не просто удалено без следа.
  • Атака — UGameplayAbility, активируемая через ASC, а не старый Server RPC напрямую.
  • Нанесение урона — UGameplayEffect (Instant) с Modifier по Health, применяемый через ApplyGameplayEffectToTarget.
  • Хотя бы одна способность использует ActivationBlockedTags вместо ручной if-проверки.
AcademyHealthViewModel.h/.cpp — было (шаг 04) / стало (шаг 07)C++
// Было (шаг 04): ViewModel знал только про компонент.
void UAcademyHealthViewModel::InitFor(UHealthComponent* InHealthComponent)
{
    InHealthComponent->OnHealthChanged.AddDynamic(this, &UAcademyHealthViewModel::HandleHealthChanged);
}

// Стало (шаг 07): ViewModel слушает атрибут ASC напрямую, HealthComponent из сигнатуры уходит.
void UAcademyHealthViewModel::InitFor(UAbilitySystemComponent* InASC)
{
    InASC->GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute())
        .AddUObject(this, &UAcademyHealthViewModel::HandleAttributeChanged);
}
Возврат к шагу 04 — ViewModel перепривязывается

UAcademyHealthViewModel::InitFor из шага 04 принимал UHealthComponent*. Теперь он принимает UAbilitySystemComponent* и подписывается на GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute()) вместо OnHealthChanged, как показано выше. Это не переписывание истории: правило 2 требует, чтобы такой переезд был явным пунктом рефакторинга этого шага, а не молчаливой правкой в шаге 04 задним числом.

08
Шаг 08 — AI-противник на тех же системах, что и игрок (Level 5.D)
Первый NPC-противник, AAcademyEnemy, переиспользует UAcademyAttributeSet и UGameplayEffect, спроектированные для игрока в шаге 07, — момент, когда становится видно, окупились ли решения о переиспользуемости. Поведение врага строится Behavior Tree (патрулировать → заметить игрока → преследовать → атаковать → отступить при низком HP), состояние «текущая цель» живёт в Blackboard, а не полем контроллера, а выбор позиции для атаки — через EQS-запрос, а не ручной перебор точек в C++.
AcademyEnemy.h — новый файлC++
UCLASS()
class ACADEMYGAME_API AAcademyEnemy : public ACharacter, public IAbilitySystemInterface
{
    GENERATED_BODY()
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // Те же классы, что у AAcademyCharacter из шага 07 — ни здоровье,
    // ни урон не продублированы отдельной реализацией для врага.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;
};
Behavior Tree — структура сценарияДиаграмма
Root
 └─ Selector
     ├─ Sequence "Атака" (условие: цель в радиусе атаки)
     │   └─ Task: AcademyAttack (активирует UAcademyAttackAbility через ASC)
     ├─ Sequence "Преследование" (условие: цель видна, HP > 20%)
     │   ├─ Task: EQS Query "BestChasePoint"
     │   └─ Task: MoveTo (Blackboard-ключ TargetActor)
     ├─ Sequence "Отступление" (условие: HP <= 20%)
     │   └─ Task: MoveTo (точка отступления)
     └─ Task: Patrol (Blackboard-ключ PatrolPoint)
BTTask_AcademyAttack.cpp — новый файл, узел «Attack» из дерева вышеC++
void UBTTask_AcademyAttack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    // Тот же класс способности, что и у игрока в шаге 07 — враг не получает
    // собственную копию логики атаки, он активирует UAcademyAttackAbility через свой ASC.
    if (auto* Enemy = Cast<AAcademyEnemy>(OwnerComp.GetAIOwner()->GetPawn()))
    {
        Enemy->GetAbilitySystemComponent()->TryActivateAbilityByClass(UAcademyAttackAbility::StaticClass());
    }
}
  • AAcademyEnemy использует тот же UAcademyAttributeSet, что и игрок, без дублирования его логики.
  • Behavior Tree минимум с пятью узлами из сценария «патруль → заметить → преследовать → атаковать → отступить».
  • Blackboard-ключ «текущая цель» вместо поля AIController; минимум один EQS-запрос для выбора точки атаки.
  • Узел BT «Attack» активирует ту же UAcademyAttackAbility, что и игрок в шаге 07, через TryActivateAbilityByClass — не отдельную копию логики атаки для врага.
09
Шаг 09 — Save/Load: сохранение прогресса того же персонажа (Level 8)
Сохраняется не абстрактный пример, а реальное состояние уже существующего проекта: здоровье из UAcademyAttributeSet (шаг 07), содержимое InventoryComponent (шаг 01) и счёт из AAcademyPlayerState (шаг 05). UAcademySaveGame хранит версию сохранения с первого дня — не потому что она нужна сейчас, а потому что «просто добавить поле» в будущем обновлении — самый частый способ сломать чужие сохранения, если версии не было заложено заранее.
AcademySaveGame.h — новый файлC++
UCLASS()
class ACADEMYGAME_API UAcademySaveGame : public USaveGame
{
    GENERATED_BODY()
public:
    UPROPERTY()
    int32 SaveVersion = 1;

    UPROPERTY()
    float SavedHealth = 100.f;

    UPROPERTY()
    TArray<FAcademyInventorySlot> SavedInventory;

    UPROPERTY()
    int32 SavedItemsEverCollected = 0;
};
AAcademyCharacter.h / .cpp — стало: сохранение читает уже существующие подсистемыC++
public:
    void SaveProgress();
    void LoadProgress();

// .cpp
void AAcademyCharacter::SaveProgress()
{
    auto* Save = Cast<UAcademySaveGame>(UGameplayStatics::CreateSaveGameObject(UAcademySaveGame::StaticClass()));
    Save->SavedHealth = AbilitySystemComponent->GetNumericAttribute(UAcademyAttributeSet::GetHealthAttribute());
    Save->SavedInventory = InventoryComponent->Slots;
    Save->SavedItemsEverCollected = GetPlayerState<AAcademyPlayerState>()->ItemsEverCollected;
    UGameplayStatics::SaveGameToSlot(Save, TEXT("Slot0"), 0);
}
AAcademyCharacter.cpp — LoadProgress: здоровье восстанавливается через GAS, а не прямой записьюC++
void AAcademyCharacter::LoadProgress()
{
    auto* Save = Cast<UAcademySaveGame>(UGameplayStatics::LoadGameFromSlot(TEXT("Slot0"), 0));
    if (!Save) return;

    // Health — не Save->SavedHealth напрямую в приватное поле AttributeSet, а GameplayEffect
    // с SetByCaller: то же самое, что и любое другое изменение здоровья в GAS, без обходного пути.
    FGameplayEffectContextHandle Context = AbilitySystemComponent->MakeEffectContext();
    FGameplayEffectSpecHandle Spec = AbilitySystemComponent->MakeOutgoingSpec(UAcademySetHealthEffect::StaticClass(), 1.f, Context);
    Spec.Data->SetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag(FName("Data.Health")), Save->SavedHealth);
    AbilitySystemComponent->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());

    InventoryComponent->Slots = Save->SavedInventory;
    GetPlayerState<AAcademyPlayerState>()->ItemsEverCollected = Save->SavedItemsEverCollected;
}
  • UAcademySaveGame с SaveVersion с первого дня, а не добавленным задним числом после первого обновления, которое что-то сломало.
  • Сохраняются реальные подсистемы проекта — AttributeSet, InventoryComponent, PlayerState — а не абстрактный пример «сохрани число».
  • Загрузка восстанавливает здоровье через GAS (модификатор атрибута), а не прямой записью в приватное поле AttributeSet.
Развилка
В следующем обновлении в UAcademySaveGame нужно добавить поле EquippedWeaponId. Что случится со старыми сохранениями, если просто добавить поле без изменения SaveVersion и логики загрузки?
Показать ожидаемый ход рассуждения

Формально десериализация не упадёт — новое поле у старых сохранений просто примет значение по умолчанию. Проблема не в краше, а в тихой потере смысла: игра решит, что оружие не экипировано, хотя игрок его выбрал до обновления. Именно поэтому SaveVersion нужен с первого дня — по нему код загрузки может явно спросить «это сохранение старее того момента, когда появилось оружие?» и подставить осознанный дефолт, а не полагаться на случайное поведение сериализации.

10
Шаг 10 — Оптимизация и профилирование уже существующего проекта (Level 8)
Это шаг про уже написанный код, а не про абстрактный пример «представь проект с проблемой производительности». Поле Stamina и спринт на ней появились ещё в шаге 02 вместе с Enhanced Input, но тогда это была одна строка внутри Move() — Tick под неё никто отдельно не обсуждал как тему, он просто тихо появился в классе по пути. Именно поэтому регенерация стамины каждый кадр дожила до этого шага незамеченной, хотя игроку не важно, произошло ли это на этом кадре или пятью кадрами позже. stat unit/stat game и трейс в Unreal Insights показывают это конкретно, а не гипотетически: Tick персонажа, BT-тики врагов из шага 08 и репликация из шага 06 — три реальных кандидата на бюджет кадра этого же проекта, не выдуманного.
AAcademyCharacter.cpp — былоC++
void AAcademyCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // Стамина восстанавливается каждый кадр — на 120 FPS это 120 бесполезных
    // вызовов в секунду ради значения, которое UI всё равно обновляет не чаще 10 раз в секунду.
    Stamina = FMath::Min(MaxStamina, Stamina + StaminaRegenRate * DeltaTime);
}
AAcademyCharacter.cpp — сталоC++
// Tick() для этой логики больше не переопределён — регенерация переехала на таймер
// с частотой, реально нужной геймплею, а не частотой кадра.
void AAcademyCharacter::BeginPlay()
{
    Super::BeginPlay();
    GetWorldTimerManager().SetTimer(StaminaTimer, this, &AAcademyCharacter::RegenStamina, 0.1f, true);
}
  • Хотя бы один реальный Tick проекта (не гипотетический пример) заменён на таймер с обоснованной частотой.
  • Снят профиль сессии с этим же персонажем, врагом из шага 08 и репликацией из шага 06 в Unreal Insights — не абстрактной сцены.
  • Названы (себе, письменно) конкретные stat-команды, которыми проверяется регресс после этой оптимизации.
11
Шаг 11 — Итоговая сборка
К этому моменту AAcademyCharacter прошёл путь от голого наследника ACharacter (шаг 00) до сетевого персонажа с компонентным здоровьем и инвентарём (шаг 01), управляемого без единой зашитой клавиши (шаг 02), взаимодействующего с описанными данными предметами (шаг 03), отображённого на экране через MVVM (шаг 04), с ролями, распределёнными по всему Gameplay Framework (шаг 05), реплицированного (шаг 06), с боевыми способностями на GAS (шаг 07), с противником, честно переиспользующим ту же боевую систему (шаг 08), сохраняющего прогресс (шаг 09) и профилированного как настоящий проект, а не гипотетический пример (шаг 10). Ни один из этих десяти шагов не переписал предыдущий тихо — каждый рефакторинг выше явно показывает «Было / Стало» и причину.
💡 Мысли Senior

Изолированное упражнение проверяет, понял ли ты идею. Сквозной проект проверяет кое-что другое — выдержит ли твоё сегодняшнее решение давление всех решений, которые ты примешь позже. Это давление, а не отдельная статья, и есть настоящий источник архитектурного опыта — в реальном проекте темы курса никогда не приходят по одной за раз.

Чего в проекте намеренно нет

Level Streaming/World Partition (Level 5.E) и Animation (Level 5.F) — полноценные разделы курса со своими статьями, но они сознательно не вошли ни в один из 12 шагов выше: тестовая комната, в которой живёт AAcademyCharacter, не успевает вырасти настолько, чтобы стриминг субуровней стал реальной необходимостью, а не искусственно пристёгнутым примером — то же самое с анимацией персонажа поверх уже готового набора способностей. Обе темы сознательно оставлены параллельными дорожками Level 5, не вплетёнными в единственную сюжетную линию survival-персонажа, чтобы не показывать код «потому что тема есть», а не потому что этот конкретный проект в нём нуждается. Архитектура кодовой базы этого масштаба, за которую этот проект в какой-то момент естественно перерастает свои границы, — тема закрывающей статьи курса.