概要
動作確認環境: UE 5.4+
「プレイヤーの進行状況をセーブしたいが、どこから手をつけるべきかわからない…」――セーブ/ロードシステムはほぼすべてのゲームで必要ですが、データの永続化、スロット管理、非同期処理など考慮すべきポイントが多く、初めて実装すると戸惑いがちです。
UEの USaveGame クラスを使えば、保存したいデータを定義するカスタムコンテナを作成し、スロット管理・非同期セーブまでシンプルなAPIで実装できます。本記事ではC++での実装を中心に、基本概念からベストプラクティスまで解説します。
基本概念
Save/Loadシステムを構成する主要な要素を整理します。
| 要素 | 説明 |
|---|---|
| SaveGameクラス | USaveGame を継承したカスタムデータコンテナ。保存したい変数を定義する |
| Save Slot Name | セーブファイルの識別名。複数スロット対応に使う |
| User Index | ユーザープロファイルの識別番号(分割画面マルチプレイヤー向け) |
| 同期セーブ | SaveGameToSlot — 処理完了までブロックする。メニュー画面向き |
| 非同期セーブ | AsyncSaveGameToSlot — バックグラウンドで実行(推奨) |
| 非同期ロード | AsyncLoadGameFromSlot — バックグラウンドでロード |
| バイナリセーブ | SaveGameToMemory / LoadGameFromMemory — ディスクを介さずメモリ上で処理。クラウドセ ーブやネットワーク送信時に使う |
セーブ処理の基本的な流れは、「SaveGameオブジェクトにゲーム状態を転送 → スロットに保存」です。ロード時はその逆で、「スロットからSaveGameオブジェクトを復元 → ゲーム内オブジェクトにデータを転送」となります。
推奨: ゲームプレイ中のセーブには
AsyncSaveGameToSlotを使ってください。同期セーブはメインスレッドをブロックし、フレームレート低下の原因になります。
開発環境では、セーブファイルはプロジェクトの Saved/SaveGames/ フォルダに .sav 拡張子で保存されます。パッケージ化ビルドでは、プラットフォームに応じた場所(Windowsでは AppData/Local/[GameName]/Saved/SaveGames/)に保存されます。
SaveGameクラスの作成
Save/Loadシステムの最初のステップは、保存するデータを定義するカスタム SaveGame クラスの作成です。このクラスはゲーム状態とディスク間の中間コンテナとして機能します。
C++での定義
// MySaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
UCLASS()
class UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(VisibleAnywhere, Category = "SaveData")
FString PlayerName;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
int32 PlayerScore = 0;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
FVector PlayerLocation = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
TArray<FName> InventoryItemIDs;
};
保存する変数は 必要最小限に留めましょう。ロード時に再計算・再取得できるデータ(HPの最大値、アイテムの表示名など)は保存しなくて構いません。
Blueprintでの作成
C++を使わない場合は、コンテンツブラウザで右クリック > Blueprint Class > 親クラスに SaveGame を選択して作成します。変数を追加すれば、Blueprintでもまったく同じ機能を実現できます。
ゲームの保存(C++)
UGameplayStatics クラスの関数を使って、SaveGameオブジェクトをディスクに保存します。以下は非同期セーブの実装例です。
#include "Kismet/GameplayStatics.h"
#include "MySaveGame.h"
void AMyPlayerController::SaveGameData()
{
const FString SlotName = TEXT("MainSlot");
const int32 UserIndex = 0;
// 既存データがあればロード、なければ新規作成
UMySaveGame* SaveInstance = nullptr;
if (UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
{
SaveInstance = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex));
}
if (!SaveInstance)
{
SaveInstance = Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
}
// 現在のゲーム状態をSaveGameオブジェクトに転送
SaveInstance->PlayerName = TEXT("Hero");
SaveInstance->PlayerScore = CurrentScore;
SaveInstance->PlayerLocation = GetPawn()->GetActorLocation();
// 非同期で保存(メインスレッドをブロックしない)
FAsyncSaveGameToSlotDelegate OnSaved;
OnSaved.BindUObject(this, &AMyPlayerController::OnGameSaved);
UGameplayStatics::AsyncSaveGameToSlot(SaveInstance, SlotName, UserIndex, OnSaved);
}
void AMyPlayerController::OnGameSaved(const FString& SlotName, const int32 UserIndex, bool bSuccess)
{
if (bSuccess)
{
UE_LOG(LogTemp, Log, TEXT("Save succeeded: %s"), *SlotName);
}
else
{
UE_LOG(LogTemp, Error, TEXT("Save failed: %s"), *SlotName);
}
}
DoesSaveGameExist で既存セーブの有無を確認し、あれば上書き、なければ新規作成するのが一般的なパターンです。AsyncSaveGameToSlot は完了時にデリゲートを呼び出すため、成功・失敗に応じたフィードバック(UIへの通知など)を実装できます。
セーブタイミングの指針
- チェックポイント到達時 / レベルクリア時 に自動セーブ
- メニュー画面 からの手動セーブは同期処理でも可
- 頻繁すぎる自動セーブはディスクI/O負荷に注意
ゲームの読み込み(C++)
ロード処理は保存処理の逆です。セーブデータからSaveGameオブジェクトを復元し、ゲーム内オブジェクトにデータを適用します。
同期ロード
ロードはメニュー画面等で行われることが多く、同期処理で問題ないケースがほとんどです。
void AMyPlayerController::LoadGameData()
{
const FString SlotName = TEXT("MainSlot");
const int32 UserIndex = 0;
if (!UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
{
UE_LOG(LogTemp, Warning, TEXT("No save data in slot: %s"), *SlotName);
return;
}
UMySaveGame* Loaded = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex));
if (!Loaded) return;
// SaveGameオブジェクトからゲーム状態を復元
SetPlayerName(Loaded->PlayerName);
if (APawn* MyPawn = GetPawn())
{
MyPawn->SetActorLocation(Loaded->PlayerLocation);
}
}
非同期ロード
大規模なセーブデータを扱う場合は、AsyncLoadGameFromSlot でバックグラウンドロードも可能です。
void AMyPlayerController::AsyncLoadGameData()
{
const FString SlotName = TEXT("MainSlot");
const int32 UserIndex = 0;
FAsyncLoadGameFromSlotDelegate OnLoaded;
OnLoaded.BindUObject(this, &AMyPlayerController::OnGameLoaded);
UGameplayStatics::AsyncLoadGameFromSlot(SlotName, UserIndex, OnLoaded);
}
void AMyPlayerController::OnGameLoaded(const FString& SlotName, const int32 UserIndex, USaveGame* LoadedData)
{
if (UMySaveGame* Loaded = Cast<UMySaveGame>(LoadedData))
{
// ゲーム状態を復元
SetPlayerName(Loaded->PlayerName);
}
}
最適化とトラブルシューティング
最適化のポイント
セーブデータの設計次第で、保存・ロード時間やファイルサイズに大きな差が出ます。
- 必要最小限のデータのみ保存: ロード時に再生成可能なデータ(派生値、デフォルト設定など)は省略する
- 構造体でグループ化: 関連するデータを
USTRUCTにまとめると管理しやすい - IDで参照する:
UObjectへの直接参照は避け、FSoftObjectPathやRowNameを保存する。ロード時にLoadObjectやFindObjectで再取得する - レベル内アクターの保存: アクターの名前やタグをキーとして保存し、ロード時に
GetAllActorsOfClass等で検索して状態を復元する - データバージョニング: ゲームの更新で SaveGame クラスの構造が変わった場合に備え、バージョン番号をセーブデータに 含めておくと互換性を維持しやすい。以下のように
SaveVersionを持たせ、ロード時にマイグレーション処理を分岐させるのが一般的なパターン
UPROPERTY(VisibleAnywhere, Category = "SaveData")
int32 SaveVersion = 1; // バージョンが上がるたびにインクリメント
- 暗号化・改ざん防止: 出荷ビルドではセーブデータの改ざん対策も考慮が必要。
FArchiveをカスタムしてAES暗号化を挟む方法や、チェックサムを付与する方法がある。プロジェクト設定の Crypto セクションでパッケージ暗号化キーも設定可能
よくある問題と対処
| 問題 | 対処 |
|---|---|
| セーブが保存されない | SaveGameToSlot の戻り値とログを確認。C++では UPROPERTY() の指定漏れにも注意 |
| ロード後の状態が不正 | データ転送処理をデバッグ。レベルロード順序とアクターのスポーンタイミングに注意 |
| パッケージ化ビルドで失敗 | セーブ先フォルダへの書き込み権限を確認 |
| ロード時にクラッシュ | Nullチェック漏れ。存在しないアクターやオブジェクトへのアクセスがないか確認 |
まとめ
USaveGameを継承してカスタムデータコンテナを定義し、保存したい変数を宣言- 非同期セーブ (
AsyncSaveGameToSlot) でフレームレートへの影響を回避 - 非同期ロード (
AsyncLoadGameFromSlot) で大規模データのバックグラウンド読み込みも可能 - スロット名とユーザーインデックスで複数セーブファイルを管理
UObjectの直接参照は避け、ID・パスで保存する