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.
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.
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.UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AAcademyCharacter();
};
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
AAcademyCharacterhérite d'ACharacterplutôt que d'APawnbrut — 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).
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.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()
// 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);
};
UHealthComponentetUInventoryComponentsont desUActorComponents réutilisables, pas des champs directement dans le personnage.FOnHealthChangedest 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.
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.
"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 ».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);
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++.
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++.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);
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;
// 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;
};
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;
};
UInteractableInterfaceimplémenté parAAcademyItemet (plus tard) par toute nouvelle classe interactive sans ancêtre commun entre elles.- La méthode
Interactest unBlueprintNativeEvent, avec la raison expliquée pourquoi pasBlueprintImplementableEvent. - Les caractéristiques de l'objet sont un
UAcademyItemDataAsset, modifiable sans recompiler le C++ ; l'icône est unTSoftObjectPtr, pas une référence dure.
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.// 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; }
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 unCastdirect versAAcademyCharacter. - Le ViewModel s'abonne à
FOnHealthChangeddu composant de santé de l'étape 01, plutôt que de l'interroger chaque frame. - L'écran d'inventaire lit
InventoryComponent->Slotsvia le même principe de getter que la santé — pas en fouillant directement dans les champs du personnage.
À 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.
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.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:
// 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();
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>());
}
};
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 dansAAcademyCharacterlui-même. AAcademyGameModen'existe que sur le serveur et stocke les règles de victoire/réapparition ;AAcademyGameStateréplique ce que tous les clients doivent voir.
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.
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.public:
void RequestInteract();
void RequestAttack(); // temporaire — jusqu'à GAS à l'étape 07
protected:
UFUNCTION(Server, Reliable)
void ServerInteract(AActor* Target);
UFUNCTION(Server, Reliable)
void ServerAttack();
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);
}
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::CurrentHealthestUPROPERTY(Replicated), avec une fonctionOnRepqui met à jour les visuels de dégâts sur les clients.Interactde 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.
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é.
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.protected:
TObjectPtr<UHealthComponent> HealthComponent;
UFUNCTION(Server, Reliable)
void ServerAttack();
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;
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()
{
// 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;
};
UAcademyAttributeSetavec Health/MaxHealth en tant queFGameplayAttributeData— l'ancien champHealthComponentest explicitement marqué comme remplacé, pas simplement supprimé sans trace.- L'attaque est une
UGameplayAbilityactivé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é viaApplyGameplayEffectToTarget. - Au moins une capacité utilise
ActivationBlockedTagsau lieu d'un if-check manuel.
// 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);
}
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.
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++.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;
};
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)
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());
}
}
AAcademyEnemyutilise le mêmeUAcademyAttributeSetque 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
UAcademyAttackAbilityque le joueur utilise à l'étape 07 viaTryActivateAbilityByClass— pas une copie séparée de la logique d'attaque pour l'ennemi.
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.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;
// 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;
}
UAcademySaveGameavecSaveVersiondè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.
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.
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.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);
}
// 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
Tickdu 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
statspécifiques utilisées pour vérifier les régressions après cette optimisation sont nommées (pour soi-même, par écrit).
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.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.
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.