Сквозной проект: один класс персонажа через весь курс
Задание «Практика» в каждой статье проверяет одну идею изолированно — этого достаточно, чтобы понять идею, но недостаточно, чтобы почувствовать, как дюжина таких идей уживается в одном живом классе, где решения предыдущих недель нельзя тихо переписать, если сегодняшняя тема их не устраивает. Этот проект — второй, параллельный трек: один класс AAcademyCharacter в маленькой survival-игре, который читатель ведёт от Level 2 («Объекты рантайма») до Level 8 («Production»), добавляя на каждом шаге ровно ту способность, которую даёт очередной Level — и ничего заранее.
1. Класс один и тот же от начала до конца. Не «новый пример на каждую тему», а один и тот же AAcademyCharacter (плюс сопутствующие классы — компоненты, контроллер, GameMode и так далее), который растёт вместе с прохождением курса — так же, как растёт настоящий проект в разработке. Ниже 12 шагов — 00…11 — а не 12 разных игр.
2. Более раннее решение не переписывается тихо. Если тема очередного шага предполагает другой подход к тому, что было сделано раньше — это фиксируется как осознанный рефакторинг с блоком «Было / Стало» и объяснением, что изменилось и почему, а не как молчаливая замена. В реальном проекте отменить прошлое решение без объяснения — не вариант.
3. Каждый шаг — это решение, а не копирование примера. Статьи курса дают UDataAsset, UHealthComponent, UGameplayAbility и десятки других фрагментов кода как иллюстрации к своей теме. Здесь задача — не скопировать фрагмент, а решить, как именно он входит в уже существующий класс, что в нём нужно переименовать, обобщить или сократить под конкретную задачу этого проекта.
Раскладка ниже идёт строго по шагам одной и той же игры — в порядке, в котором Level'ы курса дают для них материал. Каждая карточка — это то, что AAcademyCharacter (или его окружение) умеет после этого шага и не умел до него: какие статьи для этого нужны, конкретный список результата и — там, где шаг меняет уже существующий файл, а не создаёт новый с нуля, — блок «Было / Стало» с реальным diff'ом кода.
AAcademyCharacter появляется как голый наследник ACharacter: без здоровья, без инвентаря, без единой геймплейной способности. Капсула коллизии, скелетный меш и UCharacterMovementComponent у него уже есть — их создал конструктор ACharacter, и трогать их можно только через уже существующие подобъекты, а не пересоздавать заново (см. статью про жизненный цикл Actor — почему обязательные подобъекты создаются строго в конструкторе через CreateDefaultSubobject, а не позже). Цель шага — не спешить: прежде чем добавлять способности, зафиксировать, что в этом классе является геймплейным решением, а что — просто наследуется от движка бесплатно.UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AAcademyCharacter();
};
AAcademyCharacter::AAcademyCharacter()
{
// Capsule и Mesh уже существуют — их создал конструктор ACharacter.
// Трогаем только то, что реально отличает нашего персонажа от дефолтного.
GetCapsuleComponent()->InitCapsuleSize(42.f, 96.f);
bUseControllerRotationYaw = false;
}
- Явно объяснено (себе), почему
AAcademyCharacterнаследуется отACharacter, а не от гологоAPawn— движение по земле, приседание, капсула нужны с первого дня, а не «может пригодятся». - Ни одно поле ещё не добавлено «на будущее» — если способность не нужна прямо сейчас, её нет в классе.
- Зафиксировано, какая инициализация обязана ждать
BeginPlay(ничего в этом шаге её ещё не требует, но привычка называть это явно закладывается с первого файла).
UHealthComponent — решение, оправданное не «так принято», а тем самым комбинаторным взрывом, который разбирает статья про компоненты (представь, что здоровье нужно и Character, и будущему AAcademyEnemy из шага 08). По той же причине инвентарь — UInventoryComponent поверх TArray<FAcademyInventorySlot>, а не параллельные массивы «имена + количества» прямо в классе персонажа. Компонент здоровья сразу получает FOnHealthChanged — динамический мультикаст-делегат: он ещё ни на что не подписан (UI появится только в шаге 04), но именно с этого момента компонент не должен знать заранее, кто будет его слушать.UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AAcademyCharacter();
};
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;
};
HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("HealthComponent"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("InventoryComponent"));
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;
};
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.
"W"/"A"/"S"/"D" в коде, а через именованные UInputAction, подключённые через UInputMappingContext. Сразу заводится второй контекст — InventoryMappingContext — чтобы будущее открытие инвентаря (шаг 04) не заставляло персонажа драться за одни и те же клавиши с обычным передвижением: он добавляется через AddMappingContext при открытии и обязательно снимается через RemoveMappingContext при закрытии. Обработчик InteractAction уже объявлен как поле, но пока не подключён — взаимодействовать пока не с чем, это осознанно отложено до шага 03, а не «на всякий случай реализовано заранее».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);
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++.
Cast<AAcademyItem> и завтрашнего такого же Cast<AAcademyDoor> в коде интеракции вводится UInteractableInterface (UINTERFACE) с методом Interact — общая способность без общего предка. Сам метод — BlueprintNativeEvent, а не BlueprintImplementableEvent: C++ сохраняет базовую логику (кто взаимодействует, что произошло с предметом), а дизайнер добавляет визуальный эффект в Blueprint поверх неё, не переопределяя всё целиком. Характеристики предмета (имя, иконка, урон, стак) — не поля C++ класса, а UAcademyItemDataAsset: дизайнер балансирует игру без пересборки C++.protected:
// FocusedInteractable — сильная ссылка: пока предмет в фокусе, он должен
// быть жив и виден GC ровно как любое другое активное игровое состояние.
UPROPERTY()
TObjectPtr<AActor> FocusedInteractable;
void Interact(const FInputActionValue& Value);
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);
}
UINTERFACE(MinimalAPI, Blueprintable)
class UInteractableInterface : public UInterface { GENERATED_BODY() };
class IInteractableInterface
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintNativeEvent, Category="Interaction")
void Interact(AActor* Instigator);
};
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;
};
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и (на будущее) у любого нового интерактивного класса без общего предка между ними.- Метод
Interact—BlueprintNativeEvent, с объяснённой причиной, почему неBlueprintImplementableEvent. - Характеристики предмета —
UAcademyItemDataAsset, редактируемый без пересборки C++; иконка —TSoftObjectPtr, а не жёсткая ссылка.
BindWidget-полем на UProgressBar, но не подписывается на UHealthComponent напрямую через Cast<AAcademyCharacter> в виджете — вместо этого виджет получает значение через UAcademyHealthViewModel (MVVM), который сам слушает OnHealthChanged компонента здоровья из шага 01. Чтобы ViewModel вообще мог достучаться до компонентов, персонажу приходится сделать маленький, но осознанный шаг назад: добавить публичные геттеры к полям, которые были protected с шага 01 — это первый в проекте пример того, что решение предыдущего шага не «неправильное», а просто было принято для меньшей на тот момент задачи.// Было (шаг 01): доступ только изнутри класса и наследников.
protected:
TObjectPtr<UHealthComponent> HealthComponent;
TObjectPtr<UInventoryComponent> InventoryComponent;
// Стало (шаг 04): ViewModel и UI-код живут вне класса персонажа и
// не имеют права на Cast к его private-состоянию — им нужен официальный вход.
public:
UHealthComponent* GetHealthComponent() const { return HealthComponent; }
UInventoryComponent* GetInventoryComponent() const { return InventoryComponent; }
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 здоровье переезжает из UHealthComponent в UAcademyAttributeSet (GAS). UAcademyHealthViewModel, написанный здесь, придётся перепривязать к новому источнику данных — это не будет скрыто: см. блок «Было / Стало» в шаге 07.
AAcademyCharacter молчаливо совмещал «тело» и «мозг»: он сам читал Enhanced Input в шаге 02. Это не было ошибкой на тот момент — Level 3 (Gameplay Framework) ещё не пройден, — но теперь, когда роли известны, это явно фиксируется как рефакторинг, а не как то, что «всегда так и планировалось». Ввод и камера переезжают в AAcademyPlayerController: он подписывается на Enhanced Input и вызывает уже существующие методы персонажа, а не читает его внутренние поля. Данные, которые обязаны пережить смерть и респавн (счёт собранных предметов), переезжают в AAcademyPlayerState. Правила победы и точки спавна — на сервере, в AAcademyGameMode.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&);
public:
// Вызывается контроллером, а не привязан к клавише напрямую внутри персонажа.
void RequestMove(const FVector2D& Axis);
void RequestLook(const FVector2D& Axis);
void RequestInteract();
void RequestToggleInventory();
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>());
}
};
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реплицирует то, что должны видеть все клиенты.
AAcademyCharacter, AAcademyPlayerState или AAcademyGameState?Показать ожидаемый ход рассуждения
В AAcademyPlayerState. AAcademyCharacter уничтожается при смерти вместе со всеми своими полями — хранить там переживающие смерть данные означает терять их при каждом респавне. AAcademyGameState — тоже неверный уровень: это данные конкретного игрока, а не общее состояние матча, одинаковое для всех. Правило простое: если данные должны пережить смерть персонажа, но принадлежат одному конкретному игроку — это PlayerState, а не Character и не GameState.
UHealthComponent становится Replicated, интеракция из шага 03 перестаёт напрямую менять состояние на клиенте и превращается в Server RPC с проверкой дистанции и кулдауна уже на сервере (иначе любой клиент может подобрать предмет на другом конце уровня одним пакетом). Здесь же появляется первая версия атаки — прямой Server RPC, вычитающий здоровье через ApplyDamage. Это сознательно наивная реализация: в шаге 07 она станет UGameplayAbility, и здесь явно зафиксировано, что она временная.public:
void RequestInteract();
void RequestAttack(); // временно — до GAS в шаге 07
protected:
UFUNCTION(Server, Reliable)
void ServerInteract(AActor* Target);
UFUNCTION(Server, Reliable)
void ServerAttack();
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);
}
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth, VisibleAnywhere, BlueprintReadOnly, Category="Health")
float CurrentHealth;
UFUNCTION()
void OnRep_CurrentHealth(); // обновляет визуал урона на клиентах и вызывает OnHealthChanged
HealthComponent::CurrentHealth—UPROPERTY(Replicated), сOnRep-функцией, обновляющей визуал урона на клиентах.Interactиз шага 03 переработан: клиент вызывает Server RPC, сервер перепроверяет дистанцию/кулдаун независимо от того, что прислал клиент.- В коде персонажа есть минимум одно место, где явно проверяется
GetLocalRole()/GetRemoteRole()перед выполнением логики.
Interact вызывается через Server RPC. Нужно ли теперь убирать проверку дистанции с клиентской стороны, раз сервер всё равно перепроверяет?Показать ожидаемый ход рассуждения
Нет — клиентская проверка остаётся как ранняя, локальная отсечка, дающая мгновенный отклик игроку (не нужно ждать round-trip до сервера, чтобы понять «слишком далеко»), а серверная проверка остаётся единственным источником истины, которому нельзя доверять клиенту. Сервер обязан перепроверять любой Server RPC, а не полагаться на то, что клиент уже проверил.
UHealthComponent (шаг 01, реплицировано в шаге 06) переносится в UAcademyAttributeSet как FGameplayAttributeData с раздельными Base/CurrentValue — старое хранение простым Replicated float не умеет корректно откатывать один-единственный временный баф, если их наложено несколько. Наивный ServerAttack из шага 06 становится UGameplayAbility с явным ActivateAbility/EndAbility, урон применяется через UGameplayEffect (Instant), а не напрямую вычитанием. Тег State.Attacking через ActivationBlockedTags не даёт способности перезапуститься поверх самой себя — замена ручной if-проверки. Момент удара сохраняет источник урона в TWeakObjectPtr<AActor> — персонаж не должен ни владеть атакующим, ни удерживать его от сборки мусора одним фактом, что где-то в поле лежит указатель на него.protected:
TObjectPtr<UHealthComponent> HealthComponent;
UFUNCTION(Server, Reliable)
void ServerAttack();
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;
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;
};
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-проверки.
// Было (шаг 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);
}
UAcademyHealthViewModel::InitFor из шага 04 принимал UHealthComponent*. Теперь он принимает UAbilitySystemComponent* и подписывается на GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute()) вместо OnHealthChanged, как показано выше. Это не переписывание истории: правило 2 требует, чтобы такой переезд был явным пунктом рефакторинга этого шага, а не молчаливой правкой в шаге 04 задним числом.
AAcademyEnemy, переиспользует UAcademyAttributeSet и UGameplayEffect, спроектированные для игрока в шаге 07, — момент, когда становится видно, окупились ли решения о переиспользуемости. Поведение врага строится Behavior Tree (патрулировать → заметить игрока → преследовать → атаковать → отступить при низком HP), состояние «текущая цель» живёт в Blackboard, а не полем контроллера, а выбор позиции для атаки — через EQS-запрос, а не ручной перебор точек в 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;
};
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)
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— не отдельную копию логики атаки для врага.
UAcademyAttributeSet (шаг 07), содержимое InventoryComponent (шаг 01) и счёт из AAcademyPlayerState (шаг 05). UAcademySaveGame хранит версию сохранения с первого дня — не потому что она нужна сейчас, а потому что «просто добавить поле» в будущем обновлении — самый частый способ сломать чужие сохранения, если версии не было заложено заранее.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;
};
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);
}
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 нужен с первого дня — по нему код загрузки может явно спросить «это сохранение старее того момента, когда появилось оружие?» и подставить осознанный дефолт, а не полагаться на случайное поведение сериализации.
Tick под неё никто отдельно не обсуждал как тему, он просто тихо появился в классе по пути. Именно поэтому регенерация стамины каждый кадр дожила до этого шага незамеченной, хотя игроку не важно, произошло ли это на этом кадре или пятью кадрами позже. stat unit/stat game и трейс в Unreal Insights показывают это конкретно, а не гипотетически: Tick персонажа, BT-тики врагов из шага 08 и репликация из шага 06 — три реальных кандидата на бюджет кадра этого же проекта, не выдуманного.void AAcademyCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// Стамина восстанавливается каждый кадр — на 120 FPS это 120 бесполезных
// вызовов в секунду ради значения, которое UI всё равно обновляет не чаще 10 раз в секунду.
Stamina = FMath::Min(MaxStamina, Stamina + StaminaRegenRate * DeltaTime);
}
// Tick() для этой логики больше не переопределён — регенерация переехала на таймер
// с частотой, реально нужной геймплею, а не частотой кадра.
void AAcademyCharacter::BeginPlay()
{
Super::BeginPlay();
GetWorldTimerManager().SetTimer(StaminaTimer, this, &AAcademyCharacter::RegenStamina, 0.1f, true);
}
- Хотя бы один реальный
Tickпроекта (не гипотетический пример) заменён на таймер с обоснованной частотой. - Снят профиль сессии с этим же персонажем, врагом из шага 08 и репликацией из шага 06 в Unreal Insights — не абстрактной сцены.
- Названы (себе, письменно) конкретные
stat-команды, которыми проверяется регресс после этой оптимизации.
AAcademyCharacter прошёл путь от голого наследника ACharacter (шаг 00) до сетевого персонажа с компонентным здоровьем и инвентарём (шаг 01), управляемого без единой зашитой клавиши (шаг 02), взаимодействующего с описанными данными предметами (шаг 03), отображённого на экране через MVVM (шаг 04), с ролями, распределёнными по всему Gameplay Framework (шаг 05), реплицированного (шаг 06), с боевыми способностями на GAS (шаг 07), с противником, честно переиспользующим ту же боевую систему (шаг 08), сохраняющего прогресс (шаг 09) и профилированного как настоящий проект, а не гипотетический пример (шаг 10). Ни один из этих десяти шагов не переписал предыдущий тихо — каждый рефакторинг выше явно показывает «Было / Стало» и причину.Изолированное упражнение проверяет, понял ли ты идею. Сквозной проект проверяет кое-что другое — выдержит ли твоё сегодняшнее решение давление всех решений, которые ты примешь позже. Это давление, а не отдельная статья, и есть настоящий источник архитектурного опыта — в реальном проекте темы курса никогда не приходят по одной за раз.
Level Streaming/World Partition (Level 5.E) и Animation (Level 5.F) — полноценные разделы курса со своими статьями, но они сознательно не вошли ни в один из 12 шагов выше: тестовая комната, в которой живёт AAcademyCharacter, не успевает вырасти настолько, чтобы стриминг субуровней стал реальной необходимостью, а не искусственно пристёгнутым примером — то же самое с анимацией персонажа поверх уже готового набора способностей. Обе темы сознательно оставлены параллельными дорожками Level 5, не вплетёнными в единственную сюжетную линию survival-персонажа, чтобы не показывать код «потому что тема есть», а не потому что этот конкретный проект в нём нуждается. Архитектура кодовой базы этого масштаба, за которую этот проект в какой-то момент естественно перерастает свои границы, — тема закрывающей статьи курса.