実行時のBlueprintデフォルト変更が失敗する理由:安全なUnreal Engineアップグレードショップの設計
要点まとめ
Unreal Engineの実行時において、BlueprintのClass Default Object (CDO) を直接変更しようとすると、Replicationの破損やシリアライズの問題が発生し、武器のアップグレードなどの動的変更が消失してしまいます。これを解決するには、スタッツ(データ)をビジュアルのActorから切り離し、`PlayerState`などの永続的なクラス内にstructとして定義・管理するアーキテクチャが有効です。さらに、Multiplayer環境での不正行為を防止するためには、クライアントの入力を検証するサーバーオーソリティに基づく実装が不可欠です。また、ゲーム再起動後もデータを保持するためには、[horizOn](https://horizon.pm)などのBackendデータベースと連携してプレイヤーデータをクラウド上に安全に永続化させる必要があります。
Unreal Engineでモジュール式の武器システムを何週間もかけて構築し、ライフルの子Blueprintをインスタンス化し、リロード速度をアップグレードするための洗練されたUMGショップメニューをセットアップしたものの、プレイヤーが武器を切り替えたりレベルを再起動したりした瞬間にアップグレードが消失してしまう。さらに悪いことに、widgetメニューでBlueprintクラスにキャストして変数を変更しようとしても、何も起こらない。これは、プログレッシブシステムを構築するデベロッパーにとって、悪名高いボトルネックです。
このチュートリアルでは、実行時のBlueprint変更が失敗する理由、永続的な武器システムの設計方法、そしてそれらのアップグレードをデータベースに安全に保存する方法について詳しく解説します。
根本的な問題:実行時のBlueprint Class Defaultsの変更が失敗する理由
Unreal Editor内のBlueprintで変数を編集する場合、Class Default Object (CDO) を変更していることになります。CDOは、ゲームワールドにスポーンされるそのクラスのすべてのインスタンスのマスターテンプレートとして機能します。しかし、実行時にCDOを直接変更することは厳しく制限されていますが、それには十分な理由があります。CDOの変数を変更すると、将来スポーンされるすべてのインスタンスのデフォルト値が変更されてしまう危険性があり、シリアライズの問題を引き起こしたり、Replicationを破損させたりする可能性があります。
最もよくある間違いは、クラス参照(TSubclassOf<AActor>)をクラスのインスタンスにキャストしようとすることです。widgetメニューに「Weapon Class」型(例:BP_Rifle_Child)の変数が含まれており、その変数を直接設定しようとすると、プレイヤーの手にあるアクティブなActorではなく、クラステンプレートをターゲットにしていることになります。仮にアクティブなActorインスタンス(例:現在装備されているライフル)へのキャストに成功し、そのBaseDamageを25から50に変更したとしても、その変更は一時的なもの(transient)に過ぎません。
プレイヤーがライフルをしまい、ピストルに切り替え、再びライフルを装備した瞬間、ゲームは古いライフルのActorを破棄し、新しいActorをスポーンします。新しいActorはCDOテンプレートから新しくスポーンされるため、アップグレードされたBaseDamageはデフォルトの25にリセットされてしまいます。アップグレードを永続化させるには、武器のスタッツを、ワールドにスポーンされるビジュアルとしてのActorから切り離す(decouple)必要があります。
永続的な武器システムのアーキテクチャ
これを解決するには、State(武器のスタッツ)とPresentation(武器をレンダリングし、プロジェクトファイルのスポーンを処理するActor)を分離する必要があります。オーソリティを持つダメージ、リロード速度、弾薬容量を武器のActor自体に保存するのではなく、永続的なデータ構造に保存します。この構造は、APlayerStateやAGameState、あるいはカスタムのインベントリコンポーネントなど、Actorの破棄をまたいで永続するクラス内に配置する必要があります。
Multiplayerゲームの場合、これらの変数をカスタムコンポーネントに保存するには、Ownershipの慎重な管理が必要です。武器のStateをカスタムのActorComponentに保存する場合、Replication中のActorComponentのOwnershipの不一致によって発生するmultiplayer inventory nightmaresに陥らないように注意してください。オーソリティを持つデータをAPlayerStateに格納することで、プレイヤーが死亡したり、レベルを変更したり、武器を切り替えたりした場合でも、データが確実に保護されます。
プレイヤーがアップグレードショップを開くと、UIのwidgetはプレイヤーのデータStateと直接やり取りします。アップグレードを購入すると、武器のActorではなく、永続的なstructが変更されます。プレイヤーが武器を装備すると、キャラクタークラスは武器のActorをスポーンし、プレイヤーのStateからのデータstructを使用して即座に初期化します。これにより、新しくスポーンされたすべての武器が、アップグレードされた正しいスタッツを確実に継承するようになります。
ステップ・バイ・ステップの実装:データとActorロジックの分離
この分離されたアーキテクチャのクリーンなC++実装を書いてみましょう。アップグレード可能な値を保持するFWeaponStats structと、動的に設定可能なベースとなる武器クラスを定義します。
1. Weapon Stats Structの定義
まず、データ構造を定義します。この構造はBlueprintからアクセス可能であるため、UIのwidgetやデザイナー向けの子Blueprintがスタッツを簡単に読み書きできます。
#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;
};
武器のスタッツを単一のFWeaponStats structにカプセル化することで、シリアライズ、Replication、およびデータの受け渡しが非常に容易になります。5つの個別のReplication対象のfloat変数を管理する代わりに、単一のstructをReplicationするため、Replicationのオーバーヘッドを削減できます。
2. Base Weaponクラスの作成
次に、ベースとなる武器クラスAWeaponBaseを作成します。このクラスはワールド内の物理的なActorを表し、新しいスタッツで自身を初期化する関数を含みます。
#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);
}
初期化関数では、渡されたスタッツをReplication対象のWeaponStats変数に代入します。実際の開発シナリオでは、ここからビジュアルや機能の調整(マガジンメッシュのサイズ変更、発射速度タイマーの変更、新しいスタッツに基づいたパーティクルシステムのスケール変更など)もトリガーすることになります。スタッツが変更されるたびに20MBのBlueprintアセットをリロードする代わりに、48バイトのC++ structを渡すことで、メモリのオーバーヘッドを大幅に削減できます。
3. アップグレードショップのWidgetとPlayer Stateの統合
アップグレードをオーソリティの下で管理するため、UIのショップメニューは一時的なActorインスタンスに直接キャストするのではなく、PlayerStateとやり取りする必要があります。プレイヤーのスタッツを管理し、アップグレードを検証するAShooterPlayerStateクラスを設計してみましょう。
#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);
}
AShooterPlayerStateクラスは、プレイヤーのゲーム内通貨と武器スタッツのインベントリを管理します。RPC(Server_UpgradeWeapon)とReplicationを利用することで、サーバーはプレイヤーのスタッツに対する唯一の信頼できる情報源であり続けます。
Server_UpgradeWeapon_Validate関数に注目してください。UnrealのRPC検証システムは、ゴールドが足りない状態で武器をアップグレードしようとするなど、無効なリクエストを送信したクライアントを自動的に切断します。
サーバーがアップグレードを処理すると、ゴールドを差し引き、スタッツを更新し、その変更をクライアントにReplicateします。このReplicationにより、Replicationコールバックを介してクライアント側のUI更新が自動的にトリガーされます。
アップグレードショップにおけるクライアント側の不正行為の防止
ゲームがMultiplayerである場合、またはシングルプレイヤーのゲーム内経済をメモリ改ざんツールから保護したい場合、クライアントにアップグレードの管理を委ねることはできません。クライアント側のUIのwidgetがServer_UpgradeWeapon(int32 NewDamage)を呼び出せるようになっていると、ハッカーはネットワークパケットを簡単に傍受したり、Cheat Engineのようなツールを使用したりして、武器のダメージが999,999であると主張するパケットを送信できてしまいます。
厳格なサーバーオーソリティに基づく検証を行わないと、クライアントのローカルUIはアップグレードされた武器を持っていると考えているにもかかわらず、サーバーは元のレベル1のスタッツに基づいてダメージを処理するという、ゲームを崩壊させるmultiplayer state desyncsが発生します。これを防ぐために、クライアントはServer_RequestUpgrade(FName WeaponID)のようにアップグレードの*意図(intent)*のみを送信し、サーバーがそのトランザクションをオーソリティに基づいて実行しなければなりません。
サーバー側の検証フローは以下の手順に従う必要があります:
- リソースの検証(Verify Resources): プレイヤーがアップグレードを購入するのに十分なゴールドや素材(scrap)を実際に持っているか確認します。
- アップグレードパスの検証(Validate Upgrade Path): リクエストされたアップグレードが順番通りであるか確認します(例:レベル2からレベル3へのアップグレードであり、レベル10へスキップしていないか)。
- コストの差し引きと適用(Deduct Cost and Apply): サーバー側で通貨を差し引き、プレイヤーの永続的なスタッツのstructを更新します。
- Replicateと同期(Replicate and Sync): 更新されたstructをクライアントにReplicateし、装備されている武器を自動的に更新します。
バックエンドデータベースへのアップグレードの永続化
シングルマッチの間はPlayerStateにスタッツを保持するだけで機能しますが、プレイヤーがゲームを終了したり、サーバーが再起動したりした瞬間にそれらのスタッツは消去されてしまいます。真のプログレッションループを作成するには、これらの動的な変数の変更を永続的なBackendのデータベースに保存する必要があります。
これを手動で自前で構築するのは膨大な作業になります。SQLまたはNoSQLデータベースをプロビジョニングし、OAuth2認証を備えたAPIゲートウェイをセットアップし、JSONペイロードをパースするカスタムのサーバーロジックを実装し、接続タイムアウトやデータベースシャーディングなどのエッジケースを処理する必要があります。このインフラストラクチャのセットアップには、簡単に4〜6週間の専任の開発期間がかかり、コアとなるゲームプレイのループの磨き込みから集中力を奪ってしまいます。
ここで登場するのがhorizOnです。これにより、Backendデータベースのコードを書くことなく、プレイヤーのプログレッションデータをクラウドに安全に保存できます。BackendのSDKを使用して、FWeaponStats structをJSONにシリアライズし、サーバーオーソリティに基づくセキュリティルールが適用されたプレイヤーのクラウドプロフィールに直接保存できます。一般的な武器スタッツのJSONペイロードは500バイト未満(標準的なロードアウトで約320バイト)であり、データベース操作は15ミリ秒未満で実行されます。この速度により、UIの遷移は軽快に保たれ、プレイヤーがショップメニューを開いたり取引を完了したりする際にカクつきを体験することはありません。
例えば、アップグレード購入を検証する安全なCloud Code関数をBackend側に記述できます。プレイヤーがwidgetメニューで「Buy Upgrade」をクリックすると、ゲームは安全なAPIリクエストを送信します。データベースはプレイヤーのインベントリを検証し、通貨を差し引き、新しい武器レベルを書き込んで、更新されたスタッツのペイロードを返します。これにより、プレイヤーがローカルメモリをハッキングしたとしても、オーソリティを持つクラウドデータベースは安全に保たれます。
Unreal Engineのアップグレードシステムにおけるベストプラクティス
アップグレードショップの安定性とパフォーマンスを確保するために、以下の核となる原則に従ってください:
- 実行時にClass Default Object (CDO) を決して編集しないこと: CDOは読み取り専用のBlueprintとして扱います。デフォルトのビジュアルメッシュのスポーンや初期テンプレートの作成にのみ使用してください。
- スタッツをActorから切り離すこと: オーソリティを持つスタッツは、
APlayerStateやGameInstanceなどの永続クラスに保存し、スポーン時にActorに渡します。 - サーバーオーソリティ(Server Authority)を強制すること: クライアントがスタッツの変更を直接決定できるようにしてはいけません。クライアントはアップグレードをリクエストし、サーバーがリソースを検証した上で変更を適用します。
- シリアライズにはStructを使用すること: アップグレード可能なスタッツをUSTRUCTにグループ化します。これにより、ローカル保存やBackendデータベース呼び出しのためのシリアライズがシームレスになります。
- データベースのペイロードを最適化すること: プレイヤーのプロフィールは軽量に保ちます。データベースにはアップグレードレベル(例:
WeaponLevel: 3)のみを保存し、ゲームサーバー上で実際のfloat値(例:Damage: 45.0f)を再構成します。
まとめと次のステップ
子Blueprintの動的な変数の更新を解決するには、Actor中心の設計からデータ中心 of 設計へとシフトする必要があります。スタッツをPlayerStateの永続的なstructに保存し、武器を動的に初期化することで、武器を切り替えた際にアップグレードが消失するのを防ぐことができます。このプログレッシブシステムをMultiplayerに対応させ、クラウドで安全に保護する準備ができたら、Backendデータベースで裏支えすることが不可欠です。
プログレッシブシステムを拡張し、プレイヤーのインベントリを安全に保護する準備はできましたか?無料でhorizOnを試すか、API documentationをチェックして、今すぐBackendの構築を始めましょう。