METTRE EN PRATIQUE, PAS SEULEMENT LIRE

Projet final : une seule classe de personnage à travers tout le cours

L'exercice « Pratique » de chaque article teste une idée de manière isolée — assez pour comprendre l'idée, mais pas assez pour ressentir comment une douzaine d'idées coexistent dans une seule classe vivante, où les décisions des semaines précédentes ne peuvent pas être discrètement réécrites si le sujet du jour ne leur convient pas. Ce projet constitue une deuxième voie, parallèle : une seule classe AAcademyCharacter dans un petit jeu de survie, que le lecteur fait évoluer du Niveau 2 (« Objets d'exécution ») au Niveau 8 (« Production »), en ajoutant exactement la capacité que chaque nouveau Niveau fournit à chaque étape — et rien d'avance.

Les trois règles du projet

1. La classe est la même du début à la fin. Pas « un nouvel exemple pour chaque sujet », mais le même AAcademyCharacter (plus les classes compagnons — composants, contrôleur, GameMode, etc.) qui grandit au fur et à mesure du cours — exactement comme un vrai projet évolue en développement. Voici 12 étapes — 00…11 — pas 12 jeux différents.

2. Les décisions antérieures ne sont pas réécrites silencieusement. Si le sujet de l'étape suivante suggère une approche différente de ce qui a été fait plus tôt, cela est documenté comme un remaniement explicite avec un bloc « Avant / Après » expliquant ce qui a changé et pourquoi, pas comme un remplacement silencieux. Dans un vrai projet, annuler une décision passée sans explication n'est pas une option.

3. Chaque étape est une décision, pas une copie d'exemple. Les articles du cours fournissent UDataAsset, UHealthComponent, UGameplayAbility et des dizaines d'autres extraits de code comme illustrations pour leurs sujets. Ici, il ne s'agit pas de copier un extrait, mais de décider exactement comment il s'intègre dans une classe déjà existante, ce qui doit être renommé, généralisé ou adapté à la tâche spécifique de ce projet.

La présentation ci-dessous suit strictement les étapes du même jeu — dans l'ordre où les Niveaux du cours fournissent le matériel correspondant. Chaque carte représente ce que AAcademyCharacter (ou son environnement) peut faire après cette étape et ne pouvait pas faire avant : quels articles sont nécessaires, la liste spécifique des résultats et — lorsqu'une étape modifie un fichier déjà existant plutôt que d'en créer un nouveau de zéro — un bloc « Avant / Après » avec le diff de code réel.

00
Étape 00 — Le personnage naît (Niveau 2)
La première ligne de code du projet — et c'est délibérément ennuyeux. AAcademyCharacter apparaît comme un simple héritier d'ACharacter : pas de santé, pas d'inventaire, pas la moindre capacité de gameplay. La capsule de collision, le mesh squelettique et UCharacterMovementComponent sont déjà là — ils ont été créés par le constructeur d'ACharacter, et ne peuvent être modifiés qu'à travers les sous-objets existants, pas recréés (voir l'article sur le cycle de vie de l'Actor — pourquoi les sous-objets obligatoires sont créés strictement dans le constructeur via CreateDefaultSubobject, pas plus tard). L'objectif de cette étape n'est pas de se précipiter : avant d'ajouter des capacités, établir ce qui dans cette classe relève d'une décision de gameplay, et ce qui est simplement hérité du moteur gratuitement.
AAcademyCharacter.hC++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.cppC++
AAcademyCharacter::AAcademyCharacter()
{
    // La capsule et le mesh existent déjà — ils ont été créés par le constructeur d'ACharacter.
    // Nous ne modifions que ce qui distingue réellement notre personnage du personnage par défaut.
    GetCapsuleComponent()->InitCapsuleSize(42.f, 96.f);
    bUseControllerRotationYaw = false;
}
  • Avoir explicitement expliqué (à soi-même) pourquoi AAcademyCharacter hérite d'ACharacter plutôt que d'APawn brut — le déplacement au sol, le crouch et la capsule sont nécessaires dès le premier jour, pas « au cas où ».
  • Aucun champ n'est ajouté « pour plus tard » — si une capacité n'est pas nécessaire maintenant, elle n'est pas dans la classe.
  • Avoir documenté quelle initialisation doit attendre BeginPlay (rien dans cette étape ne le requiert encore, mais l'habitude de l'énoncer explicitement est établie dès le premier fichier).
01
Étape 01 — Santé et inventaire : des composants, pas des champs (Niveau 2)
La santé est extraite non pas comme un champ du personnage, mais comme un UHealthComponent séparé — une décision justifiée non pas par « c'est comme ça qu'on fait », mais par l'explosion combinatoire analysée dans l'article sur les composants (imaginez la santé nécessaire à la fois au personnage et au futur AAcademyEnemy de l'étape 08). Pour la même raison, l'inventaire est un UInventoryComponent avec TArray<FAcademyInventorySlot>, pas des tableaux parallèles « noms + quantités » directement dans la classe du personnage. Le composant de santé reçoit immédiatement FOnHealthChanged — un délégué multicast dynamique : il n'est encore abonné à rien (l'UI n'apparaîtra qu'à l'étape 04), mais à partir de ce moment, le composant ne doit pas savoir à l'avance qui va l'écouter.
AAcademyCharacter.h — avant (étape 00)C++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.h — aprèsC++
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 — ajouté au constructeurC++
HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("HealthComponent"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("InventoryComponent"));
HealthComponent.h — nouveau fichierC++
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 — nouveau fichierC++
USTRUCT(BlueprintType)
struct FAcademyInventorySlot
{
    GENERATED_BODY()

    // Le type complet UAcademyItemDataAsset n'apparaîtra qu'à l'étape 03 — pour l'instant,
    // une déclaration anticipée suffit ; le composant n'a pas besoin de savoir de quoi se compose un objet.
    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 et UInventoryComponent sont des UActorComponents réutilisables, pas des champs directement dans le personnage.
  • FOnHealthChanged est déclaré à l'intérieur du composant et invoqué par lui quand la santé change — pas interrogé depuis l'extérieur à chaque frame.
  • TArray<FAcademyInventorySlot> pour l'inventaire avec une structure de slot significative (objet + quantité), pas des tableaux parallèles.
Fork
Le designer demande d'ajouter un coffre destructible avec sa propre santé et la même barre de dégâts que le personnage. Le coffre doit-il hériter d'AAcademyCharacter pour réutiliser la santé ?
Afficher le raisonnement attendu

Non — un coffre n'est pas un « personnage » dans aucun sens significatif (pas de mouvement, d'input, de contrôleur), hériter d'AAcademyCharacter pour un seul champ partagé est précisément l'erreur analysée dans l'article sur les composants. La solution correcte est le même UHealthComponent attaché à une classe séparée, beaucoup plus simple, AAcademyChest : public AActor. Le composant est conçu précisément pour que cette réutilisation ne nécessite pas un ancêtre commun — l'étape 08 appliquera le même schéma à AAcademyEnemy.

02
Étape 02 — Enhanced Input : un contrôle sans touches codées en dur (Niveau 3 / 5.A)
Le personnage peut enfin se déplacer — mais pas via "W"/"A"/"S"/"D" dans le code, mais via des UInputActions nommées, connectées à travers un UInputMappingContext. Un deuxième contexte — InventoryMappingContext — est créé immédiatement, afin que la future ouverture de l'inventaire (étape 04) ne se batte pas pour les mêmes touches que le déplacement normal : il est ajouté via AddMappingContext lorsqu'il est ouvert et toujours retiré via RemoveMappingContext lorsqu'il est fermé. Le gestionnaire InteractAction est déjà déclaré comme champ, mais pas encore connecté — il n'y a encore rien avec quoi interagir ; cela est délibérément reporté à l'étape 03, pas « implémenté au cas où à l'avance ».
AAcademyCharacter.h — après (ajouté à l'étape 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; // nous le connecterons à l'étape 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); } // priorité supérieure à la base
    bInventoryOpen = !bInventoryOpen;
}
  • Au moins deux UInputMappingContexts : base (déplacement, regard, future interaction) et inventaire.
  • L'ouverture de l'inventaire ajoute le deuxième contexte avec une priorité plus élevée, la fermeture — le retire (deux ouvertures-fermetures consécutives ne laissent pas de contexte dupliqué).
  • Toutes les actions de gameplay sont des UInputActions nommées ; aucune ne lit une touche physique par son nom directement en C++.
03
Étape 03 — Interaction et objets comme données (Niveau 4)
Le monde reçoit des objets pouvant être ramassés. Au lieu d'utiliser Cast<AAcademyItem> et demain un similaire Cast<AAcademyDoor> dans le code d'interaction, UInteractableInterface (UINTERFACE) est introduit avec une méthode Interact — une capacité commune sans ancêtre commun. La méthode elle-même est BlueprintNativeEvent, pas BlueprintImplementableEvent : le C++ conserve la logique de base (qui interagit, ce qui arrive à l'objet), tandis que le designer ajoute des effets visuels en Blueprint par-dessus, sans tout redéfinir. Les caractéristiques d'un objet (nom, icône, dégâts, stack) ne sont pas des champs de classe C++, mais un UAcademyItemDataAsset : le designer peut équilibrer le jeu sans recompiler le C++.
AAcademyCharacter.h — après (ajouté à l'étape 02)C++
protected:
    // FocusedInteractable — une référence forte : tant que l'objet est en focus, il doit
    // être vivant et visible par le GC exactement comme n'importe quel autre état de gameplay actif.
    UPROPERTY()
    TObjectPtr<AActor> FocusedInteractable;

    void Interact(const FInputActionValue& Value);
AAcademyCharacter.cpp — InteractAction enfin connecté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 — nouveau fichierC++
UINTERFACE(MinimalAPI, Blueprintable)
class UInteractableInterface : public UInterface { GENERATED_BODY() };

class IInteractableInterface
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintNativeEvent, Category="Interaction")
    void Interact(AActor* Instigator);
};
AcademyItemDataAsset.h — nouveau fichierC++
UCLASS(BlueprintType)
class ACADEMYGAME_API UAcademyItemDataAsset : public UDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly, Category="Item")
    FText DisplayName;

    // Une texture d'aperçu lourde n'est nécessaire que lorsque l'inventaire est ouvert (étape 04) —
    // TSoftObjectPtr ne la tire pas en mémoire avec le DataAsset lui-même.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    TSoftObjectPtr<UTexture2D> Icon;

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

    // Utilisé par l'attaque de l'étape 07 — si l'objet est équipé comme arme.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    float AttackDamage = 0.f;
};
AcademyItem.h — nouveau fichier, un objet ramassable dans le niveauC++
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 implémenté par AAcademyItem et (plus tard) par toute nouvelle classe interactive sans ancêtre commun entre elles.
  • La méthode Interact est un BlueprintNativeEvent, avec la raison expliquée pourquoi pas BlueprintImplementableEvent.
  • Les caractéristiques de l'objet sont un UAcademyItemDataAsset, modifiable sans recompiler le C++ ; l'icône est un TSoftObjectPtr, pas une référence dure.
04
Étape 04 — UI : santé et inventaire à l'écran (Niveau 5.B)
La barre de vie est construite comme un widget UMG avec un champ BindWidget pour une UProgressBar, mais il ne s'abonne pas directement à UHealthComponent via un Cast<AAcademyCharacter> dans le widget — au lieu de cela, le widget reçoit la valeur via UAcademyHealthViewModel (MVVM), qui écoute lui-même le OnHealthChanged du composant de santé de l'étape 01. Pour permettre au ViewModel d'atteindre les composants, le personnage doit faire un petit recul conscient : ajouter des getters publics pour des champs qui étaient protégés depuis l'étape 01 — c'est le premier exemple dans le projet où une décision d'une étape précédente n'est pas « fausse », mais simplement prise pour un problème plus petit à l'époque.
AAcademyCharacter.h — avant / aprèsC++
// Avant (étape 01) : accès uniquement depuis l'intérieur de la classe et ses sous-classes.
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    TObjectPtr<UInventoryComponent> InventoryComponent;

// Après (étape 04) : le ViewModel et le code UI vivent en dehors de la classe du personnage et
// n'ont pas le droit de Cast vers son état privé — ils ont besoin d'un point d'entrée officiel.
public:
    UHealthComponent* GetHealthComponent() const { return HealthComponent; }
    UInventoryComponent* GetInventoryComponent() const { return InventoryComponent; }
AcademyHealthViewModel.h — nouveau fichierC++
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);
};
  • La barre de vie est un widget UMG avec BindWidget, recevant les données via un ViewModel, pas un Cast direct vers AAcademyCharacter.
  • Le ViewModel s'abonne à FOnHealthChanged du composant de santé de l'étape 01, plutôt que de l'interroger chaque frame.
  • L'écran d'inventaire lit InventoryComponent->Slots via le même principe de getter que la santé — pas en fouillant directement dans les champs du personnage.
Remarque — l'étape 07 reviendra ici

À l'étape 07, la santé passe de UHealthComponent à UAcademyAttributeSet (GAS). UAcademyHealthViewModel, écrit ici, devra être relié à la nouvelle source de données — cela ne sera pas caché : voir le bloc « Avant / Après » à l'étape 07.

05
Étape 05 — Gameplay Framework : formalisation rétroactive des rôles (Niveau 3)
Jusqu'à cette étape, AAcademyCharacter combinait silencieusement « corps » et « cerveau » : il lisait lui-même l'Enhanced Input à l'étape 02. Ce n'était pas une erreur à l'époque — le Niveau 3 (Gameplay Framework) n'avait pas encore été couvert — mais maintenant que les rôles sont connus, ceci est explicitement documenté comme un remaniement, pas comme quelque chose « toujours prévu comme ça ». L'input et la caméra se déplacent vers AAcademyPlayerController : il s'abonne à l'Enhanced Input et appelle les méthodes existantes du personnage, plutôt que de lire ses champs internes. Les données qui doivent survivre à la mort et à la réapparition (le nombre d'objets collectés) vont dans AAcademyPlayerState. Les conditions de victoire et les points d'apparition — sur le serveur, dans AAcademyGameMode.
AAcademyCharacter.h — avant (étapes 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 — après : tout le bloc Input retiré, le personnage n'est plus qu'un exécutantC++
public:
    // Appelé par le contrôleur, pas lié à une touche directement dans le personnage.
    void RequestMove(const FVector2D& Axis);
    void RequestLook(const FVector2D& Axis);
    void RequestInteract();
    void RequestToggleInventory();
AcademyPlayerController.h — nouveau fichier, reprend le bloc Input du personnageC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerController : public APlayerController
{
    GENERATED_BODY()
protected:
    virtual void SetupInputComponent() override; // la même logique AddMappingContext/BindAction qu'à l'étape 02

    void Move(const FInputActionValue& Value)
    {
        if (auto* Character = GetPawn<AAcademyCharacter>())
            Character->RequestMove(Value.Get<FVector2D>());
    }
};
AcademyPlayerState.h — nouveau fichierC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerState : public APlayerState
{
    GENERATED_BODY()
public:
    // Survit à la mort et à la réapparition du personnage — AAcademyCharacter lui-même ne survit pas.
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Progress")
    int32 ItemsEverCollected = 0;
};
  • L'input et le contrôle de la caméra sont dans AAcademyPlayerController ; le personnage ne lit plus l'input directement en lui-même.
  • Les données qui survivent à la recréation du Pawn (le nombre d'objets collectés) sont dans AAcademyPlayerState, pas dans AAcademyCharacter lui-même.
  • AAcademyGameMode n'existe que sur le serveur et stocke les règles de victoire/réapparition ; AAcademyGameState réplique ce que tous les clients doivent voir.
Fork
Le personnage meurt et réapparaît comme un nouveau Pawn après 5 secondes. Où le score accumulé durant la partie doit-il être stocké — dans AAcademyCharacter, AAcademyPlayerState ou AAcademyGameState ?
Afficher le raisonnement attendu

Dans AAcademyPlayerState. AAcademyCharacter est détruit à la mort avec tous ses champs — stocker des données qui survivent à la mort là signifie les perdre à chaque réapparition. AAcademyGameState est également le mauvais niveau : ce sont les données d'un joueur spécifique, pas l'état commun de la partie. La règle est simple : si les données doivent survivre à la mort du personnage mais appartiennent à un joueur spécifique — c'est PlayerState, pas Character et pas GameState.

06
Étape 06 — Multijoueur : le personnage n'est plus local (Niveau 6)
Le premier remaniement véritablement douloureux du projet : la santé de UHealthComponent devient Replicated, l'interaction de l'étape 03 ne modifie plus directement l'état sur le client et se transforme en un Server RPC avec la distance et le cooldown revérifiés sur le serveur (sinon n'importe quel client pourrait ramasser un objet à l'autre bout du niveau avec un seul paquet). Ici, la première version de l'attaque apparaît également — un Server RPC direct qui retire de la santé via ApplyDamage. C'est délibérément une implémentation naïve : à l'étape 07, elle deviendra une UGameplayAbility, et il est explicitement noté ici qu'elle est temporaire.
AAcademyCharacter.h — après (ajouté à l'étape 05)C++
public:
    void RequestInteract();
    void RequestAttack(); // temporaire — jusqu'à GAS à l'étape 07

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

    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.cppC++
void AAcademyCharacter::ServerInteract_Implementation(AActor* Target)
{
    // Le serveur ne fait pas confiance au client — la distance et le cooldown sont revérifiés,
    // même si le client les a déjà vérifiés localement pour un retour instantané.
    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 — après : Replicated + rôleC++
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth, VisibleAnywhere, BlueprintReadOnly, Category="Health")
float CurrentHealth;

UFUNCTION()
void OnRep_CurrentHealth(); // met à jour les visuels de dégâts sur les clients et appelle OnHealthChanged
  • HealthComponent::CurrentHealth est UPROPERTY(Replicated), avec une fonction OnRep qui met à jour les visuels de dégâts sur les clients.
  • Interact de l'étape 03 est retravaillé : le client appelle un Server RPC, le serveur revérifie la distance/le cooldown indépendamment de ce que le client a envoyé.
  • Le code du personnage a au moins un endroit où GetLocalRole()/GetRemoteRole() est explicitement vérifié avant d'exécuter la logique.
Fork
Après cette étape, Interact est appelé via un Server RPC. Faut-il maintenant supprimer la vérification de distance côté client, puisque le serveur la revérifie de toute façon ?
Afficher le raisonnement attendu

Non — la vérification côté client reste en tant que coupure locale anticipée, fournissant un retour instantané au joueur (pas besoin d'attendre l'aller-retour vers le serveur pour savoir si « trop loin »), tandis que la vérification côté serveur reste la source unique de vérité à laquelle on ne peut pas faire confiance au client. Le serveur doit revérifier chaque Server RPC, et ne pas se reposer sur le fait que le client a déjà vérifié.

07
Étape 07 — GAS : des abilities sur un personnage déjà répliqué (Niveau 5.C)
Le plus grand remaniement du projet : la santé de UHealthComponent (étape 01, répliqué à l'étape 06) est déplacée dans UAcademyAttributeSet en tant que FGameplayAttributeData avec Base/CurrentValue séparés — l'ancien stockage sous forme d'un simple Replicated float ne peut pas annuler correctement un seul buff temporaire si plusieurs sont appliqués. Le naïf ServerAttack de l'étape 06 devient une UGameplayAbility avec ActivateAbility/EndAbility explicites ; les dégâts sont appliqués via un UGameplayEffect (Instant), pas par soustraction directe. Le tag State.Attacking via ActivationBlockedTags empêche la capacité de se relancer par-dessus elle-même — remplaçant un if-check manuel. Le moment de l'impact stocke la source des dégâts dans TWeakObjectPtr<AActor> — le personnage ne doit ni posséder l'attaquant ni l'empêcher d'être collecté par le garbage collector simplement parce qu'un pointeur vers lui traîne quelque part.
AAcademyCharacter.h — avant (étape 06)C++
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.h — aprèsC++
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // HealthComponent des étapes 01/06 est remplacé — l'ancien champ Health n'est pas supprimé silencieusement :
    // la raison est documentée dans le texte de l'étape ; le composant ne pouvait pas annuler les buffs.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;

    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;

    // Qui a infligé les derniers dégâts — pour le HUD « tué par le joueur X ». Référence faible :
    // le personnage ne possède pas l'attaquant et ne doit pas empêcher sa collecte par le GC.
    TWeakObjectPtr<AActor> LastDamageInstigator;
AcademyAttributeSet.h — nouveau fichier, remplace 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 — nouveau fichier, remplace ServerAttackC++
UCLASS()
class ACADEMYGAME_API UAcademyAttackAbility : public UGameplayAbility
{
    GENERATED_BODY()
public:
    UAcademyAttackAbility()
    {
        // Un tag plutôt qu'un "if (bIsAttacking) return" manuel — GAS lui-même n'autorisera pas
        // la capacité à s'activer à nouveau tant que le tag n'est pas retiré dans 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 avec Health/MaxHealth en tant que FGameplayAttributeData — l'ancien champ HealthComponent est explicitement marqué comme remplacé, pas simplement supprimé sans trace.
  • L'attaque est une UGameplayAbility activée via l'ASC, pas l'ancien Server RPC directement.
  • L'application des dégâts est un UGameplayEffect (Instant) avec un Modifier sur Health, appliqué via ApplyGameplayEffectToTarget.
  • Au moins une capacité utilise ActivationBlockedTags au lieu d'un if-check manuel.
AcademyHealthViewModel.h/.cpp — avant (étape 04) / après (étape 07)C++
// Avant (étape 04) : le ViewModel ne connaissait que le composant.
void UAcademyHealthViewModel::InitFor(UHealthComponent* InHealthComponent)
{
    InHealthComponent->OnHealthChanged.AddDynamic(this, &UAcademyHealthViewModel::HandleHealthChanged);
}

// Après (étape 07) : le ViewModel écoute directement l'attribut de l'ASC, HealthComponent disparaît de la signature.
void UAcademyHealthViewModel::InitFor(UAbilitySystemComponent* InASC)
{
    InASC->GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute())
        .AddUObject(this, &UAcademyHealthViewModel::HandleAttributeChanged);
}
Retour à l'étape 04 — le ViewModel est relié à la nouvelle source

UAcademyHealthViewModel::InitFor de l'étape 04 acceptait un UHealthComponent*. Maintenant il accepte un UAbilitySystemComponent* et s'abonne à GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute()) au lieu de OnHealthChanged, comme montré ci-dessus. Ce n'est pas une réécriture de l'histoire : la règle 2 exige qu'un tel déplacement soit un point de remaniement explicite de cette étape, pas une modification silencieuse rétroactive de l'étape 04.

08
Étape 08 — Ennemi IA sur les mêmes systèmes que le joueur (Niveau 5.D)
Le premier ennemi PNJ, AAcademyEnemy, réutilise UAcademyAttributeSet et UGameplayEffect conçus pour le joueur à l'étape 07 — le moment où l'on voit si les décisions de réutilisabilité ont porté leurs fruits. Le comportement de l'ennemi est construit comme un Behavior Tree (patrouille → repère le joueur → poursuite → attaque → repli à HP bas) ; l'état « cible actuelle » réside dans le Blackboard, pas dans un champ du contrôleur, et le choix de la position pour l'attaque se fait via une requête EQS, pas une énumération manuelle de points en C++.
AcademyEnemy.h — nouveau fichierC++
UCLASS()
class ACADEMYGAME_API AAcademyEnemy : public ACharacter, public IAbilitySystemInterface
{
    GENERATED_BODY()
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // Les mêmes classes que dans AAcademyCharacter depuis l'étape 07 — ni la santé
    // ni les dégâts ne sont dupliqués dans une implémentation séparée pour l'ennemi.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;
};
Behavior Tree — la structure du scriptDiagramme
Root
 └─ Selector
     ├─ Sequence "Attack" (condition : cible à portée d'attaque)
     │   └─ Task: AcademyAttack (active UAcademyAttackAbility via l'ASC)
     ├─ Sequence "Chase" (condition : cible visible, HP > 20%)
     │   ├─ Task: EQS Query "BestChasePoint"
     │   └─ Task: MoveTo (clé Blackboard TargetActor)
     ├─ Sequence "Retreat" (condition : HP <= 20%)
     │   └─ Task: MoveTo (point de repli)
     └─ Task: Patrol (clé Blackboard PatrolPoint)
BTTask_AcademyAttack.cpp — nouveau fichier, le nœud "Attack" de l'arbre ci-dessusC++
void UBTTask_AcademyAttack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    // La même classe de capacité que le joueur à l'étape 07 — l'ennemi n'obtient pas
    // sa propre copie de la logique d'attaque, il active UAcademyAttackAbility via son propre ASC.
    if (auto* Enemy = Cast<AAcademyEnemy>(OwnerComp.GetAIOwner()->GetPawn()))
    {
        Enemy->GetAbilitySystemComponent()->TryActivateAbilityByClass(UAcademyAttackAbility::StaticClass());
    }
}
  • AAcademyEnemy utilise le même UAcademyAttributeSet que le joueur, sans dupliquer sa logique.
  • Behavior Tree avec au moins cinq nœuds du scénario « patrouille → repère → poursuit → attaque → repli ».
  • Une clé Blackboard "current target" au lieu d'un champ dans l'AIController ; au moins une requête EQS pour choisir un point d'attaque.
  • Le nœud BT "Attack" active la même UAcademyAttackAbility que le joueur utilise à l'étape 07 via TryActivateAbilityByClass — pas une copie séparée de la logique d'attaque pour l'ennemi.
09
Étape 09 — Sauvegarde/Chargement : sauver la progression du même personnage (Niveau 8)
Ce n'est pas un exemple abstrait qui est sauvegardé, mais l'état réel d'un projet déjà existant : la santé de l'UAcademyAttributeSet (étape 07), le contenu de l'InventoryComponent (étape 01) et le score de l'AAcademyPlayerState (étape 05). UAcademySaveGame stocke la version de sauvegarde dès le premier jour — pas parce que c'est nécessaire maintenant, mais parce que « juste ajouter un champ » dans une future mise à jour est le moyen le plus courant de casser les sauvegardes des autres si la version n'a pas été mise en place à l'avance.
AcademySaveGame.h — nouveau fichierC++
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 — après : la sauvegarde lit depuis les sous-systèmes déjà existantsC++
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 : la santé est restaurée via GAS, pas par une écriture directeC++
void AAcademyCharacter::LoadProgress()
{
    auto* Save = Cast<UAcademySaveGame>(UGameplayStatics::LoadGameFromSlot(TEXT("Slot0"), 0));
    if (!Save) return;

    // Santé — pas Save->SavedHealth écrit directement dans le champ privé de l'AttributeSet, mais un
    // GameplayEffect avec SetByCaller : le même chemin que tout autre changement de santé dans GAS, pas de raccourci.
    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 avec SaveVersion dès le premier jour, pas ajouté rétroactivement après la première mise à jour qui a cassé quelque chose.
  • Sauvegarde les vrais sous-systèmes du projet — AttributeSet, InventoryComponent, PlayerState — pas un exemple abstrait de « sauvegarder un nombre ».
  • Le chargement restaure la santé via GAS (modificateur d'attribut), pas par écriture directe dans le champ privé de l'AttributeSet.
Fork
Dans la prochaine mise à jour, UAcademySaveGame doit ajouter un champ EquippedWeaponId. Que se passe-t-il avec les anciennes sauvegardes si le champ est simplement ajouté sans changer SaveVersion ni la logique de chargement ?
Afficher le raisonnement attendu

Formellement, la désérialisation ne plantera pas — le nouveau champ dans les anciennes sauvegardes prendra simplement sa valeur par défaut. Le problème n'est pas un crash, mais une perte silencieuse de sens : le jeu décidera que l'arme n'est pas équipée, alors que le joueur l'avait choisie avant la mise à jour. C'est exactement pourquoi SaveVersion est nécessaire dès le premier jour — grâce à elle, le code de chargement peut explicitement demander « cette sauvegarde est-elle antérieure au moment où les armes sont apparues ? » et substituer une valeur par défaut consciente, plutôt que de se fier au comportement aléatoire de la sérialisation.

10
Étape 10 — Optimisation et profilage d'un projet déjà existant (Niveau 8)
Cette étape concerne du code déjà écrit, pas un exemple abstrait de « imaginez un projet avec des problèmes de performance ». Le champ Stamina et le sprint construit dessus sont apparus dès l'étape 02 avec l'Enhanced Input, mais à l'époque c'était une ligne à l'intérieur de Move() — personne n'a discuté d'un Tick pour cela en tant que sujet propre ; c'est simplement apparu tranquillement dans la classe au fil du développement. C'est précisément pour cela que régénérer la stamina à chaque frame est passé inaperçu jusqu'à cette étape, alors que le joueur ne se soucie pas que cela se produise sur cette frame ou cinq frames plus tard. stat unit/stat game et une trace dans Unreal Insights le montrent spécifiquement, pas hypothétiquement : le Tick du personnage, les ticks du BT de l'étape 08 et la réplication de l'étape 06 sont trois candidats réels pour le budget de frame de ce même projet, pas inventés.
AAcademyCharacter.cpp — avantC++
void AAcademyCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // La stamina se régénère chaque frame — à 120 FPS, cela fait 120 appels
    // inutiles par seconde pour une valeur que l'UI met à jour 10 fois par seconde au maximum de toute façon.
    Stamina = FMath::Min(MaxStamina, Stamina + StaminaRegenRate * DeltaTime);
}
AAcademyCharacter.cpp — aprèsC++
// Tick() n'est plus surchargé pour cette logique — la régénération est déplacée vers un timer
// avec une fréquence réellement nécessaire au gameplay, pas la fréquence d'images.
void AAcademyCharacter::BeginPlay()
{
    Super::BeginPlay();
    GetWorldTimerManager().SetTimer(StaminaTimer, this, &AAcademyCharacter::RegenStamina, 0.1f, true);
}
  • Au moins un vrai Tick du projet (pas un exemple hypothétique) est remplacé par un timer avec une fréquence justifiée.
  • Un profil de session a été capturé avec ce même personnage, l'ennemi de l'étape 08 et la réplication de l'étape 06 dans Unreal Insights — pas une scène abstraite.
  • Des commandes stat spécifiques utilisées pour vérifier les régressions après cette optimisation sont nommées (pour soi-même, par écrit).
11
Étape 11 — Build final
À ce stade, AAcademyCharacter est passé d'un simple héritier d'ACharacter (étape 00) à un personnage en réseau avec une santé et un inventaire basés sur des composants (étape 01), contrôlé sans aucune touche codée en dur (étape 02), interagissant avec des objets définis par données (étape 03), affiché à l'écran via MVVM (étape 04), avec des rôles distribués sur l'ensemble du Gameplay Framework (étape 05), répliqué (étape 06), avec des capacités de combat sur GAS (étape 07), un ennemi réutilisant honnêtement le même système de combat (étape 08), sauvegardant sa progression (étape 09), et profilé comme un vrai projet plutôt qu'un exemple hypothétique (étape 10). Aucune de ces dix étapes n'a silencieusement réécrit la précédente — chaque remaniement ci-dessus montre clairement « Avant / Après » et la raison.
💡 Réflexions d'un sénior

Un exercice isolé vérifie si vous avez compris une idée. Un projet final vérifie autre chose — si votre décision d'aujourd'hui peut résister à la pression de toutes les décisions que vous prendrez plus tard. Cette pression, pas un article isolé, est la véritable source de l'expérience architecturale — dans un vrai projet, les sujets du cours n'arrivent jamais un par un.

Ce qui est intentionnellement absent du projet

Le Level Streaming/World Partition (Niveau 5.E) et l'Animation (Niveau 5.F) sont des sections à part entière du cours avec leurs propres articles, mais ils ont été consciemment laissés de côté dans les 12 étapes ci-dessus : la salle de test où vit AAcademyCharacter ne devient jamais assez grande pour que le streaming de sous-niveaux devienne une nécessité réelle, plutôt qu'un exemple artificiellement attaché — il en va de même pour l'animation du personnage en plus d'un ensemble déjà complet de capacités. Les deux sujets sont consciemment laissés comme des pistes parallèles du Niveau 5, pas tissés dans la ligne narrative unique du personnage de survie, afin d'éviter de montrer du code « parce que le sujet existe » plutôt que parce que ce projet particulier en a besoin. L'architecture d'une base de code d'une échelle que ce projet dépasse naturellement à un moment donné est le sujet de l'article de clôture du cours.