GS2-Dictionary

図鑑機能

GS2-Dictionary では、ゲーム内で入手したアイテムやキャラクターの図鑑機能を実現します。

基本的に、GS2-Inventory のシンプルな実装バージョンと捉えていただければよく、入手済み・未入手の2値の所持状態を管理できます。

図鑑以外の用途にも使用できます。たとえばアバターパーツの所持状態はいい例です。 アバターパーツを持っているかは、パーツごとに2値で状態管理できれば十分なので GS2-Dictionary 向きです。 他にも、達成済みのチュートリアルステップ管理、開放済みのカットシーン管理、解禁済みのBGM一覧など、二値の状態として表現できる要素全般に活用できます。

graph TD
  Boss["ボスを討伐"] -- 報酬としてエントリー追加 --> Dictionary["GS2-Dictionary に記録"]
  Shop["ショップで購入"] -- 報酬としてエントリー追加 --> Dictionary
  Dictionary --> Browse["プレイヤーが図鑑を閲覧"]
  Dictionary -- 所持証明署名 --> Formation["GS2-Formation などで利用"]

GS2-Inventory との違い

スタックの概念の有無

GS2-Inventory は同一アイテムを複数所持する際に、スタックの概念があります。 これは、ポーションは最大99個スタック可能で、99個を超えると2個目のスタックを作成する というような仕様です。

この仕様があるため、GS2-Inventory では「ポーションの所持数量を取得する」というAPIの戻り値がリストになっています。 これはポーションが複数スタック存在する可能性があるためです。 この仕様はポーションのようなデータを管理するには都合がいいのですが、図鑑のようなシンプルな所持状態を管理するにはオーバースペックで、リストを取り扱うとコードの記述量も増加します。

入手処理で複数エントリーを登録可能

GS2-Inventory はポーションの入手とエリクサーの入手は別のAPIリクエストで処理する必要があります。 GS2-Dictionary は1回のAPIリクエストで最大100個のエントリーを登録できます。

これにより、クエストクリア時の報酬として「倒したモンスター全種類を一括で図鑑に登録」といった処理を1リクエストで効率的に行えます。

エントリーの検証と削除

GS2-Dictionary では、一度記録したエントリーを削除したり、特定のエントリーを入手済みか(または未入手か)を検証することができます。期間限定のイベントアイテムの所持状態をリセットしたり、特定のアイテムを入手している場合のみクエストを開始できるといった制限を設けるといった運用が可能です。

機能比較

項目 GS2-Dictionary GS2-Inventory
状態管理 所持/未所持の2値 数量・スタック管理
1リクエストでの登録数 最大100件 1件ずつ
お気に入り機能 あり なし
所持証明署名 あり あり(ItemSet 単位)
主用途 図鑑、アバターパーツ、解禁状態 通常のアイテム所持

マスターデータ(EntryModel)

図鑑に登録可能なエントリーをマスターデータとして定義します。 EntryModel の主な設定項目は以下の通りです。

項目 説明
name エントリー名(プレイヤーごとの記録キー)
metadata クライアントで利用する任意のメタデータ(表示名、画像参照、レアリティなど)

マスターデータの JSON 例:

{
  "version": "2020-04-30",
  "entryModels": [
    {
      "name": "monster-0001",
      "metadata": "{\"displayName\":\"スライム\",\"rarity\":1}"
    },
    {
      "name": "monster-0002",
      "metadata": "{\"displayName\":\"ゴブリン\",\"rarity\":2}"
    }
  ]
}

マスターデータの登録はマネージメントコンソールから登録する他、GitHubからデータを反映したり、GS2-Deployを使ってCIから登録するようなワークフローを組むことが可能です。

お気に入り機能

GS2-Dictionary では記録済みエントリーに「お気に入り」を付与できます。AddLikesDeleteLikes でお気に入りリストを管理し、Likes API で一覧取得や変更通知の購読が可能です。お気に入り情報を利用することで図鑑画面のフィルタリングやUIでの優先表示を実現できます。

お気に入りエントリーは内部的に LikeToc という単位でまとめて保存されており、1ユーザーが大量のお気に入りを登録しても効率的に取得できます。

所持証明署名

GS2-Dictionary はエントリーの所持状態に対して署名済みデータを発行できます。 他の GS2 マイクロサービスと連携する際、サーバー間通信を介さずに「本当にそのエントリーを所持していること」を保証できます。

sequenceDiagram
  participant Player
  participant Dictionary as GS2-Dictionary
  participant Formation as GS2-Formation
  Player ->> Dictionary: GetEntryWithSignature(entry, keyId)
  Dictionary -->> Player: Body + Signature
  Player ->> Formation: SetForm(itemId, body, signature)
  Formation ->> Formation: 署名検証
  Formation -->> Player: 成功

検証側のサービスは body の中に含まれる情報(エントリー名やユーザーID)を、signature を用いて検証できます。

スクリプトトリガー

ネームスペースに entryScriptduplicateEntryScript を設定すると、エントリー登録の前後や重複登録時にカスタムスクリプトを呼び出せます。スクリプトは同期・非同期の実行方式を選択でき、非同期では GS2-Script や Amazon EventBridge を利用した外部連携も可能です。

設定できる主なイベントトリガーとスクリプト設定名は以下の通りです。

  • entryScript(完了通知: entryDone): エントリー登録の前後
  • duplicateEntryScript: 既に登録済みのエントリーを再登録した際

duplicateEntryScript を利用すると、たとえば「すでに図鑑登録済みのレアモンスターを再度ドロップした場合は、代わりに別の通貨を付与する」といったコンバージョン処理を実装できます。

トランザクションアクション

GS2-Dictionary では以下のトランザクションアクションを提供しています。

  • 検証アクション: エントリー所持状況の検証
  • 消費アクション: エントリーの削除
  • 入手アクション: エントリーの追加

「エントリー所持状況の検証」を検証アクションとして利用することで、特定のモンスターを討伐済み(図鑑に登録済み)のプレイヤーのみが挑戦できるクエストや、特定のアイテムを一度でも入手したことがあるプレイヤーのみが購入できる商品といった制限を設けることが可能になります。

図鑑への記録方法

図鑑への記録(Entry の追加)はゲームエンジン用の SDK では直接呼び出せません。 GS2 のセキュリティモデル上、クライアントからの一方的な書き換えを禁止し、サーバーが信頼する経路でのみ追加されるよう設計されているためです。

具体的には、以下のような形でトランザクションアクションとして組み込みます。

  • GS2-Quest のクエストクリア報酬として「倒したモンスターを図鑑に登録する」入手アクションを設定
  • GS2-Showcase で購入したアイテムの報酬として「対応するエントリーを図鑑に登録する」入手アクションを設定
  • GS2-Mission のミッション達成報酬として「特定エントリーを登録する」入手アクションを設定

実装例

図鑑に記録可能なマスターデータのリスト取得

    var items = await gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).EntryModelsAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    );
    const auto It = Domain->EntryModels(
    );
    TArray<Gs2::UE5::Dictionary::Model::FEzEntryModelPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

図鑑に記録されたエントリーのリスト取得

    var items = await gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).EntriesAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto It = Domain->Entries(
    );
    TArray<Gs2::UE5::Dictionary::Model::FEzEntryPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

特定エントリーの取得

    var item = await gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Entry(
        entryModelName: "entry-0001"
    ).ModelAsync();

    var acquiredAt = item.AcquiredAt;
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Entry(
        "entry-0001" // entryModelName
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Result = Future->GetTask().Result();
    const auto AcquiredAt = Result->GetAcquiredAt();

所持証明署名の取得

GS2 内の他のマイクロサービスと連携する際に、本当に GS2-Dictionary でエントリーが記録済みか保証したデータを求められることがあります。

たとえば、GS2-Dictionary でアバターパーツの所持状態を管理しており、GS2-Formation でアバターパーツの編成状態を管理するとします。 GS2-Formation に髪型を設定する際に「hair-0001」というパーツを設定するよう APIリクエストを出すことになりますが GS2-Formation は所持証明署名をとあわせて「hair-0001」を指定するよう要求します。

これによって、GS2-Formation は裏で GS2-Dictionary と通信して、本当に所持しているパーツかを判断する必要がなくなります。

    var result = await gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Entry(
        entryModelName: "entry-0001"
    ).GetEntryWithSignatureAsync(
        keyId: "grn:gs2:{region}:{yourOwnerId}:key:namespace-0001:key:key-0001"
    );

    var body = result.Body;
    var signature = result.Signature;
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Entry(
        "entry-0001" // entryModelName
    );
    const auto Future = Domain->GetEntryWithSignature(
        "grn:gs2:{region}:{yourOwnerId}:key:namespace-0001:key:key-0001" // keyId
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

    const auto Future2 = Future->GetTask().Result()->Model();
    Future2->StartSynchronousTask();
    if (Future2->GetTask().IsError()) return false;
    const auto Result = Future2->GetTask().Result();
    const auto Body = Result->Body;
    const auto Signature = Result->Signature;

お気に入りエントリーの登録

    var domain = gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    );
    var result = await domain.AddLikesAsync(
        entryModelNames: new List<string> {
            "entry-0001",
        }
    );
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto Future = Domain->AddLikes(
        {
            "entry-0001",
        }
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

お気に入りエントリーのリスト取得

    var domain = gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    );
    var items = await domain.LikesAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto It = Domain->Likes(
    );
    TArray<Gs2::UE5::Dictionary::Model::FEzLikePtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

お気に入りエントリーの削除

    var domain = gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    );

    await domain.DeleteLikesAsync(
        entryModelNames: new List<string> {
            "entry-0001",
        }
    );
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );

    const auto Future = Domain->DeleteLikes(
        {
            "entry-0001",
        }
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

詳細なリファレンス