
Unity/C#ゲーム開発における、クライアントでのマスタデータの扱い方
はじめに
こんにちは!23新卒クライアントエンジニアの角田です!
この記事では、弊社開発タイトルにてクライアント側がどのようにマスタデータを扱っているかについてご紹介します!
ゲーム開発では膨大な量のデータを取り扱うため、データの取り扱い方を工夫する必要があります。
今回は、その中でもユーザー/マスタデータの取り扱い方の仕組みや工夫について紹介させていただきます。
今回ご紹介する仕組みは、弊社開発タイトルでは「FINAL FANTASY Ⅶ EVER CRISIS」で初めて採用され、今後の新規開発タイトルにも導入を検討しています。
設計の考え方
マスタデータは、ゲーム内での不変共通パラメータであり、サーバーから取得したものをクライアント側で保持しています。
クライアントで保持しているマスタデータは、さまざまな箇所からアクセスできるため、クライアント側の意図しない書き換えが発生してしまう恐れがあります。
この書き換えが発生することにより、クライアント側でのデータ不整合によるエラーや不具合が起きたりする可能性があるため、クライアント側で管理しているマスタデータが書き換えられない構造にする必要があります。
この構造を取り入れ、かつデータを扱いやすくした仕組みをこれから紹介します。
データの扱い方と実装方法
上記の設計の考え方から、以下の図のような仕組みを利用しました。

それぞれの仕組みについて、詳しく説明していきます。
MasterMemory
マスタデータを取得する仕組みとして、CysharpのMasterMemoryを導入しています。
こちらは読み取り専用のインメモリデータベースです。
アプリ起動時にサーバーにあるマスタデータを、サーバー側と同じテーブル構造で取得してクライアント側でキャッシュします。
クライアントでマスタデータを扱う仕組み
MasterMemoryはデータベースであり、直接アクセスする場合、取得しようとしているデータ、およびそれに関連するテーブルを検索する処理がアクセスする度に走ります。
中には複数のテーブルにアクセスして検索する場合もあり、これがアクセスする度に走ると検索コストが大きくなってしまいます。
そこで、MasterMemoryに直接アクセスするのではなく、クライアント側で扱いやすい形に整理して保存し、データへのアクセスはこのクライアント側で保存されたデータから行うようにしました。
こうすることで、一通り検索した結果をまとめておくことができ、データベースの検索処理を走らせずにデータを取得できるようになります。
この実装について説明します。
Workクラス
MasterMemoryでキャッシュされたデータベースにアクセスし、データを管理するクラスです。
データベースにアクセスする必要がある全てのデータについて、関連するデータごとにまとめたWorkを生成し、データを取得する場合は、このクラス経由で取得するようにしています。
例えば、キャラクターの情報を管理するCharacterWorkクラスを作り、後述するStoreクラスの形でキャラクターに関するデータを保持しています。
また、データの更新が発生した場合はWorkで更新処理を行い、データの整合性を保っています。
例えば、キャラクターのレベルが上がってユーザーデータの更新が必要になった場合、マスタにアクセスして上昇したレベルに対応するステータスを反映し、情報の更新を行なっています。
以下のコードは、キャラクター関連のデータを管理するCharacterWorkクラスのコード例です。
public class CharacterWork : WorkBase
{
// Workで管理しているStore
private readonly Dictionary<long, CharacterStore> _characterStores = new Dictionary<long, CharacterStore>();
// CharacterInfo 取得
public ICharacterInfo GetCharacterInfo(long characterId)
{
return GetOrCreateCharacterStore(characterId);
}
// WorkからStoreを取得、無ければ構築する処理
private CharacterStore GetOrCreateCharacterStore(long characterId)
{
// キャラが存在しない場合、何もせずデフォルト値を返す
if (characterId == 0)
{
return default;
}
if (_characterStores.TryGetValue(characterId, out var characterStore) == false)
{
// MasterMemoryでキャッシュされているCharacterTableにアクセス
if (DataStore.Master.DB.CharacterTable.TryGet(characterId, out var masterCharacter))
{
// CharacterStoreのコンストラクタでマスタデータを登録
_characterStores[characterId] = characterStore = new CharacterStore(this, masterCharacter);
}
}
return characterStore;
}
// 更新通知 Subject
private readonly Subject<Unit> _updateSubject = new Subject<Unit>();
// ユーザーデータが更新された時、自動で呼ばれる処理
// 各Workの基底クラスであるWorkBaseクラスの関数をoverride
protected override void OnUserDataUpdateInternal(Entity.Tables entityTables)
{
var dirtyCharacterIds = new HashSet<long>();
// キャラクターの更新情報がレスポンス内にある場合は、キャラクターデータを更新する
if (entityTables.UserCharacterList.ExistsAny())
{
foreach (var userCharacter in entityTables.UserCharacterList)
{
var characterId = userCharacter.CharacterId;
var characterStore = GetOrCreateCharacterStore(characterId);
if (characterStore != null)
{
characterStore.SetUserCharacter(userCharacter);
dirtyCharacterIds.Add(characterId);
}
}
}
// 更新情報があるCharacterStoreを一括更新
foreach (var characterId in dirtyCharacterIds)
{
GetOrCreateCharacterStore(characterId)?.Update();
}
// 更新通知を飛ばす
if (dirtyCharacterIds.Any())
{
_updateSubject.OnNext(Unit.Default);
}
}
...
}
Storeクラス
マスターデータとユーザーデータを組み合わせて、ゲーム上で扱いやすいデータ形式に変換したデータを取り扱うクラスです。
Workで管理されているデータの実体がこのStoreです。
readonlyのプロパティを持つインターフェースを定義し、そのインターフェースをStoreが実装しています。
そうすることでStore直接ではなくインターフェースとして画面側に返すことができる為、画面側で不用意なデータの書き換えを防止することができます。
下記のコードは、CharacterWorkクラス内でのCharacterStoreの使用例です。
// WorkからStoreを取得、無ければ構築する処理
private CharacterStore GetOrCreateCharacterStore(long characterId)
{
// キャラが存在しない場合、何もせずデフォルト値を返す
if (characterId == 0)
{
return default;
}
if (_characterStores.TryGetValue(characterId, out var characterStore) == false)
{
// MasterMemoryでキャッシュされているCharacterTableにアクセス
if (DataStore.Master.DB.CharacterTable.TryGet(characterId, out var masterCharacter))
{
// CharacterStoreのコンストラクタでマスタデータを登録
_characterStores[characterId] = characterStore = new CharacterStore(this, masterCharacter);
}
}
return characterStore;
}
上記のGetOrCreateCharacterStoreを、readonlyのプロパティを持つインターフェースであるICharacterInfoで返しています。
// 実際に画面側から呼ばれるデータ取得メソッド
// readonlyのインターフェースであるICharacterInfoで返す
public ICharacterInfo GetCharacterInfo(long characterId)
{
return GetOrCreateCharacterStore(characterId);
}
// キャラクターの情報 interface
public interface ICharacterInfo
{
// キャラID
long CharacterId { get; }
// 表記名
string CharacterName { get; }
// キャラクタのフルネーム
string CharacterFullName { get; }
...
}
次に、この実装によるメリット、デメリットをご説明します。
メリット
クライアントでのデータの不整合が起きない
ユーザーデータを含むデータにアクセスする際、先述のGetCharacterInfoのように、readonlyのプロパティを持つインターフェースとして画面側で取得のみを行うことができるため、クライアントの実装によって意図しないデータの書き換えが発生しないようになっています。
これによりクライアント側でのデータ不整合による不具合が発生しなくなっています。
データの流れがわかりやすい
サーバーでマスタの更新があった場合、クライアント側でMasterMemoryへデータを取得、WorkによるStoreの更新でデータを構築しなおしています。
この仕組みにより、以下のようにデータの流れが一方向でわかりやすくなっています。
クライアントからサーバーへAPI通信→レスポンスから更新されるユーザーデータを取得→WorkがStoreを更新→画面側からStoreの情報を取得→表示に反映
Workで管理、Storeで取得という役割が明確なので、どこで何をしているかがわかりやすい仕組みになっています。
こういった仕組みがどの画面でも共通で使われているため、余計なことを考えずにデータを扱いやすくなっています。
デメリット
メモリ使用量の増加
インメモリデータベースのMasterMemoryに加え、Storeという形でデータを保持するため、メモリの使用量が増加します。
この対策として、起動時に全データのStoreを生成するのではなく、必要になったタイミングで必要な分だけ生成することで、メモリの使用量を最低限に抑えています。
起動時の負荷の増加
一度に多くのStoreを構築すると、その生成時に負荷が増加します。
全データのStoreを一括で生成すると負荷が大きいため、メモリ使用量の増加対策と同様に、必要になったタイミングでStoreを生成することで負荷を分散しています。
終わりに
この記事では、弊社開発タイトルにおけるクライアントでのデータの扱い方をまとめさせていただきました。
実際、開発しているエンジニアからもデータを扱いやすいといった意見も上がっており開発の効率化に繋がっていると感じています。
今後の新規開発にもこの仕組みを導入していこうと思います。

この記事へのコメントはありません。