Pourquoi modifier les Blueprint Defaults au runtime échoue : architecture d'un système d'amélioration d'armes sécurisé dans Unreal Engine
En bref
Ce tutoriel explique pourquoi la modification directe des Class Default Objects (CDO) au runtime dans Unreal Engine échoue et entraîne la perte des améliorations d'armes lors des changements d'actors. Il présente une architecture découplée en C++ qui stocke les statistiques persistantes dans le `APlayerState` sous forme de struct répliquée pour assurer la synchronisation en multiplayer. Enfin, il aborde la sécurisation des transactions via la validation RPC sur le serveur et la persistance des données à long terme dans une base de données cloud grâce à horizOn.
Vous passez des semaines à concevoir un système d'armes modulaire dans Unreal Engine, instanciez un Blueprint enfant pour votre fusil, configurez un élégant menu de boutique UMG pour améliorer sa vitesse de rechargement, pour finalement constater que les améliorations disparaissent dès que le joueur change d'arme ou redémarre le niveau. Pire encore, lorsque vous tentez de faire un cast vers la classe Blueprint dans votre menu widget et de modifier les variables, rien ne se produit. C'est un goulot d'étranglement bien connu pour les développeurs qui conçoivent des systèmes de progression.
Dans ce tutoriel, nous allons analyser pourquoi les modifications de Blueprint au runtime échouent, comment concevoir un système d'armes persistant et comment stocker ces améliorations de manière sécurisée dans une base de données.
Le problème de fond : pourquoi la modification des Blueprint Class Defaults échoue au runtime
Lorsque vous modifiez des variables dans un Blueprint au sein de l'Unreal Editor, vous modifiez le Class Default Object (CDO). Le CDO sert de master template pour chaque instance de cette classe spawned dans le monde de jeu. Au runtime, cependant, modifier le CDO directement est extrêmement restreint, et pour cause. Si vous modifiez une variable du CDO, vous risquez d'altérer la valeur par défaut de toutes les futures instances créées, provoquant des problèmes de sérialisation et brisant la réplication.
L'erreur la plus fréquente consiste à tenter de caster une référence de classe (TSubclassOf<AActor>) vers une instance de la classe. Si votre menu widget contient une variable de type « Weapon Class » (par exemple, BP_Rifle_Child) et que vous tentez de définir ses variables directement, vous ciblez le template de classe, et non l'actor actif entre les mains du joueur. Même si vous réussissez le cast vers l'instance d'actor active (par exemple, le fusil actuellement équipé) et modifiez son BaseDamage de 25 à 50, ce changement reste temporaire.
Dès que le joueur range son fusil, passe au pistolet et équipe à nouveau le fusil, le jeu détruit l'ancien actor du fusil et en spawn un nouveau. Le nouvel actor est spawn à partir du template CDO, réinitialisant votre BaseDamage amélioré à sa valeur par défaut de 25. Pour rendre les améliorations persistantes, vous devez découpler les statistiques de l'arme de l'actor visuel spawned dans le monde.
L'architecture d'un système d'armes persistant
Pour résoudre cela, nous devons séparer l'État (les statistiques de l'arme) de la Présentation (l'actor qui effectue le rendu de l'arme et gère les spawns de projectiles). Au lieu de stocker les valeurs faisant autorité pour les dégâts, la vitesse de rechargement et la capacité des munitions dans l'actor de l'arme lui-même, nous les stockons dans une structure de données persistante. Cette structure doit résider dans une classe qui survit à la destruction des actors, telle que APlayerState, AGameState ou un composant d'inventaire personnalisé.
Pour les jeux multiplayer, stocker ces variables dans un composant personnalisé exige une gestion rigoureuse de l'ownership. Si vous stockez les états d'armes dans un ActorComponent personnalisé, veillez à ne pas vous heurter aux multiplayer inventory nightmares causés par un désalignement de l'ownership des composants d'actors lors de la replication. En logeant les données faisant autorité dans le APlayerState, vous garantissez la préservation des données même lorsque le joueur meurt, change de niveau ou change d'arme.
Lorsque le joueur ouvre la boutique d'améliorations, le widget d'UI interagit directement avec l'état des données du joueur. L'achat d'une amélioration modifie la struct persistante, et non l'actor de l'arme. Lorsque le joueur équipe une arme, la classe du personnage spawn l'actor de l'arme et l'initialise immédiatement à l'aide de la struct de données provenant de l'état du joueur. Cela garantit que chaque nouvelle arme spawned hérite des statistiques correctes et améliorées.
Implémentation étape par étape : découplage des données et de la logique de l'Actor
Écrivons une implémentation C++ propre de cette architecture découplée. Nous allons définir une struct FWeaponStats qui contient les valeurs améliorables, ainsi qu'une classe d'arme de base pouvant être configurée dynamiquement.
1. Définir la struct Weapon Stats
Tout d'abord, nous définissons notre structure de données. Cette structure est accessible en Blueprint, ce qui permet à vos widgets d'UI et aux Blueprints enfants destinés aux designers de lire et d'écrire facilement les statistiques.
#pragma once
#include "CoreMinimal.h"
#include "WeaponStats.generated.h"
USTRUCT(BlueprintType)
struct FWeaponStats
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float BaseDamage = 25.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float ReloadSpeedModifier = 1.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 MaxAmmo = 30;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 CurrentUpgradeLevel = 0;
};
En encapsulant les statistiques de l'arme dans une seule struct FWeaponStats, il devient trivial de la sérialiser, de la répliquer et de la transmettre. Au lieu de gérer cinq variables float répliquées distinctes, nous répliquons une seule struct, réduisant ainsi l'overhead de réplication.
2. Créer la classe d'arme de base
Ensuite, nous créons la classe d'arme de base AWeaponBase. Cette classe représente l'actor physique dans le monde et contient une fonction pour s'initialiser elle-même avec les nouvelles statistiques.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "WeaponStats.h"
#include "WeaponBase.generated.h"
UCLASS()
class SHOOTER_API AWeaponBase : public AActor
{
GENERATED_BODY()
public:
AWeaponBase();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Weapon")
USkeletalMeshComponent* WeaponMesh;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Weapon")
FWeaponStats WeaponStats;
UFUNCTION(BlueprintCallable, Category = "Weapon")
void InitializeWeapon(const FWeaponStats& NewStats);
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
#include "WeaponBase.h"
#include "Net/UnrealNetwork.h"
AWeaponBase::AWeaponBase()
{
PrimaryActorTick.bCanEverTick = false;
bReplicates = true;
WeaponMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponMesh"));
RootComponent = WeaponMesh;
}
void AWeaponBase::InitializeWeapon(const FWeaponStats& NewStats)
{
WeaponStats = NewStats;
// Apply changes dynamically to the active actor
// e.g., Adjust weapon mesh scale, update firing rate variables, or UI indicators
}
void AWeaponBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AWeaponBase, WeaponStats);
}
Dans la fonction d'initialisation, nous attribuons les statistiques transmises à notre variable répliquée WeaponStats. Dans un scénario réel, vous déclencheriez également des ajustements visuels ou fonctionnels ici — comme le redimensionnement d'un mesh de chargeur, la modification des timers de cadence de tir ou l'ajustement de l'échelle des systèmes de particules en fonction des nouvelles statistiques. Au lieu de recharger un asset de blueprint de 20 Mo à chaque changement de statistique, la transmission d'une struct C++ de 48 octets évite un overhead de mémoire massif.
3. Intégrer le widget de boutique d'améliorations avec le Player State
Pour gérer les améliorations de manière autoritative, le menu de boutique de l'UI doit interagir avec le PlayerState au lieu d'essayer de faire un cast directement vers des instances d'actors temporaires. Concevons la classe AShooterPlayerState pour gérer les statistiques du joueur et valider les améliorations.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "WeaponStats.h"
#include "ShooterPlayerState.generated.h"
UCLASS()
class SHOOTER_API AShooterPlayerState : public APlayerState
{
GENERATED_BODY()
public:
AShooterPlayerState();
UPROPERTY(ReplicatedUsing = OnRep_WeaponInventory, BlueprintReadOnly, Category = "Inventory")
TArray<FWeaponStats> WeaponInventory;
UFUNCTION(BlueprintCallable, Category = "Inventory")
FWeaponStats GetWeaponStats(int32 WeaponIndex) const;
UFUNCTION(Server, Reliable, WithValidation, BlueprintCallable, Category = "Inventory")
void Server_UpgradeWeapon(int32 WeaponIndex);
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Economy")
int32 PlayerGold = 500;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
UFUNCTION()
void OnRep_WeaponInventory();
};
#include "ShooterPlayerState.h"
#include "Net/UnrealNetwork.h"
AShooterPlayerState::AShooterPlayerState()
{
bReplicates = true;
}
FWeaponStats AShooterPlayerState::GetWeaponStats(int32 WeaponIndex) const
{
if (WeaponInventory.IsValidIndex(WeaponIndex))
{
return WeaponInventory[WeaponIndex];
}
return FWeaponStats();
}
bool AShooterPlayerState::Server_UpgradeWeapon_Validate(int32 WeaponIndex)
{
if (!WeaponInventory.IsValidIndex(WeaponIndex)) return false;
int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
return PlayerGold >= UpgradeCost;
}
void AShooterPlayerState::Server_UpgradeWeapon_Implementation(int32 WeaponIndex)
{
int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
PlayerGold -= UpgradeCost;
FWeaponStats& Stats = WeaponInventory[WeaponIndex];
Stats.CurrentUpgradeLevel++;
Stats.BaseDamage += 10.0f;
Stats.ReloadSpeedModifier *= 0.9f;
}
void AShooterPlayerState::OnRep_WeaponInventory()
{
// Update local UI representation or bind to UI delegates
}
void AShooterPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AShooterPlayerState, WeaponInventory);
DOREPLIFETIME(AShooterPlayerState, PlayerGold);
}
La classe AShooterPlayerState gère la monnaie du joueur et son inventaire de statistiques d'armes. En utilisant des RPC (Server_UpgradeWeapon) et la réplication, le serveur reste l'unique source de vérité pour les statistiques du joueur.
Notez la fonction Server_UpgradeWeapon_Validate. Le système de validation RPC d'Unreal bannit automatiquement les clients qui envoient des requêtes invalides, par exemple s'ils tentent d'améliorer une arme sans avoir assez d'or.
Une fois que le serveur a traité l'amélioration, il déduit l'or, met à jour les statistiques et réplique les modifications vers le client. Cette réplication déclenche automatiquement les mises à jour de l'UI côté client via des callbacks de réplication.
Prévenir les exploits côté client dans votre boutique d'améliorations
Si votre jeu est multiplayer, ou si vous souhaitez protéger votre économie solo des éditeurs de mémoire, vous ne pouvez pas faire confiance au client pour gérer ses propres améliorations. Si le widget d'UI côté client est autorisé à appeler Server_UpgradeWeapon(int32 NewDamage), un hacker peut facilement intercepter les paquets réseau ou utiliser des outils comme Cheat Engine pour envoyer un paquet affirmant que les dégâts de son arme sont de 999 999.
Sans une validation faisant autorité sur le serveur (server-authoritative), votre jeu subira des multiplayer state desyncs majeurs, où l'UI locale du client pense disposer d'une arme améliorée alors que le serveur calcule les dégâts sur la base des statistiques d'origine de niveau 1. Pour éviter cela, le client doit uniquement envoyer une intention d'amélioration, comme Server_RequestUpgrade(FName WeaponID). Le serveur exécute ensuite la transaction de manière autoritative.
Le flux de validation du serveur doit suivre ces étapes :
- Vérifier les ressources : Vérifier si le joueur dispose effectivement d'assez d'or ou de matériaux (scrap) pour acheter l'amélioration.
- Valider le chemin d'amélioration : Confirmer que l'amélioration demandée est la suivante dans la séquence (par exemple, passage du niveau 2 au niveau 3, et non un saut direct au niveau 10).
- Déduire le coût et appliquer : Déduire la monnaie sur le serveur et mettre à jour la struct de statistiques persistantes du joueur.
- Répliquer et synchroniser : Répliquer la struct mise à jour vers le client, ce qui met automatiquement à jour l'arme équipée.
Persistance des améliorations dans la base de données Backend
Bien que conserver les statistiques dans le PlayerState fonctionne durant une seule partie, ces statistiques sont effacées dès que le joueur ferme le jeu ou que le serveur redémarre. Pour créer une véritable boucle de progression, vous devez enregistrer ces modifications de variables dynamiques dans une base de données backend persistante.
Construire cela vous-même manuellement représente un travail colossal. Vous devriez provisionner une base de données SQL ou NoSQL, configurer une passerelle API (API gateway) avec authentification OAuth2, implémenter une logique serveur personnalisée pour parser les payloads JSON, et gérer des cas particuliers comme les interruptions de connexion ou le sharding de base de données. Mettre en place cette infrastructure peut facilement nécessiter 4 à 6 semaines de développement dédié, vous détournant ainsi du peaufinage de votre boucle de gameplay principale (core gameplay loop).
C'est ici qu'intervient horizOn, qui vous permet de stocker les données de progression des joueurs en toute sécurité dans le cloud sans écrire une seule ligne de code de base de données backend. Vous pouvez utiliser le SDK backend pour sérialiser votre struct FWeaponStats en JSON et l'enregistrer directement sur le profil cloud du joueur avec des règles de sécurité server-authoritative. Un payload JSON typique de statistiques d'armes fait moins de 500 octets (environ 320 octets pour un loadout standard), ce qui signifie que les opérations en base de données s'exécutent en moins de 15 ms. Cette rapidité garantit des transitions d'UI fluides et évite que les joueurs ne subissent des saccades lors de l'ouverture des menus de boutique ou de la finalisation des transactions.
Par exemple, vous pouvez écrire une fonction Cloud Code sécurisée dans le backend pour valider l'achat de l'amélioration. Lorsque le joueur clique sur « Buy Upgrade » dans votre menu widget, le jeu envoie une requête API sécurisée. La base de données verifie l'inventaire du joueur, déduit la monnaie, enregistre le nouveau niveau de l'arme et renvoie le payload des statistiques mises à jour. Cela garantit que même si un joueur modifie sa mémoire locale, la base de données cloud faisant autorité reste sécurisée.
Bonnes pratiques pour les systèmes d'améliorations dans Unreal Engine
Pour garantir la stabilité et la performance de votre boutique d'améliorations, suivez ces principes fondamentaux :
- Ne modifiez jamais les Class Default Objects (CDOs) au runtime : Traitez les CDO comme des Blueprints en lecture seule. Utilisez-les uniquement pour le spawn des meshes visuels par défaut et des templates initiaux.
- Découplez les statistiques des Actors : Stockez les statistiques faisant autorité dans une classe persistante comme
APlayerStateouGameInstance, et transmettez-les à l'actor lors de son spawn. - Impératif de Server Authority : Ne laissez jamais les clients dicter directement les changements de statistiques. Les clients demandent des améliorations ; le serveur valide les ressources et applique le changement.
- Utilisez des Structs pour la sérialisation : Regroupez les statistiques améliorables dans des USTRUCT. Cela rend la sérialisation transparente pour les sauvegardes locales ou les appels de base de données backend.
- Optimisez les payloads de base de données : Gardez les profils de joueurs légers. Ne stockez que les niveaux d'amélioration (par exemple,
WeaponLevel: 3) dans la base de données, et reconstruisez les floats réels (par exemple,Damage: 45.0f) sur le serveur de jeu.
Résumé et prochaines étapes
La résolution des mises à jour de variables dynamiques sur les Blueprints enfants nécessite de passer d'une conception centrée sur l'actor à une conception orientée données. En stockant les statistiques dans des structs persistantes sur le PlayerState et en initialisant vos armes dynamiquement, vous évitez que les améliorations ne disparaissent lors d'un changement d'arme. Lorsque vous serez prêt à passer ce système de progression en multiplayer et à le sécuriser dans le cloud, son intégration avec une base de données backend est indispensable.
Prêt à faire évoluer vos systèmes de progression et à sécuriser les inventaires des joueurs ? Essayez horizOn gratuitement ou consultez la documentation de l'API pour commencer à développer votre backend dès aujourd'hui.