GS2-Quest

進捗管理機能

ゲームの進捗管理や、クエストの進捗管理を行います。

GS2-Quest はゲームのインゲーム(バトルやステージ)に挑戦するための「入口」と「出口」だけをサーバーで管理するマイクロサービスです。 インゲーム内部のロジックには関与せず、開始時のコスト消費・クリア時の報酬付与・前提条件の判定といった、サーバーで信頼すべき処理に責任を持ちます。

graph LR
  Start["クエスト開始<br/>StartAsync"] --> Battle["インゲームを実行<br/>(クライアント / 専用サーバー)"]
  Battle -- 成功 --> End1["クエスト終了報告<br/>EndAsync(isComplete:true)"]
  Battle -- 失敗 --> End2["クエスト終了報告<br/>EndAsync(isComplete:false)"]
  End1 --> Reward["クリア報酬を付与"]
  End2 --> FailedReward["失敗報酬を付与"]

クエスト

クエスト はインゲームの基本単位で、インゲームを開始する時に選択するエンティティです。 クエストには挑戦に必要なコストと、挑戦によって得られる報酬を設定でき、GS2-Quest はその開始・終了をAPIとして受け付けます。 つまり、GS2-Quest はインゲームの内容には関与しません。

クエストの挑戦コスト

クエストを開始状態にするために必要なコストを設定します。 一般的には GS2-Stamina で管理するスタミナを消費したり、GS2-Inventory で管理するアイテムを消費するようなコストを設定します。

QuestModelconsumeActions に消費アクションを設定することで、Start 実行時にトランザクションとしてアトミックに処理されます。

クエストの検証条件

QuestModelverifyActions に検証アクションを設定すると、クエスト開始時に追加の条件チェックを行えます。 たとえば「特定のアイテムを所持していること」「GS2-Dictionary に特定のエントリーが登録されていること」といった、消費せずに状態だけを確認する条件を表現できます。

クエストのクリア報酬

クエストに挑戦し、クリアした場合に得られる報酬を設定できます。 報酬には複数の種類(Contents)を用意することができます。各 Contents には抽選用の weight を設定でき、確率に応じてどの報酬パターンが適用されるかが決定されます。 この機能を利用すれば、一定の確率でレアモンスターが出現するバージョンのクエストが開始され、報酬が通常より豪華になるような設定が可能です。

初回クリア報酬

クエストを初めてクリアしたときにのみ、追加の報酬を得られるよう設定が可能です。 QuestModelfirstCompleteAcquireActions に入手アクションを設定します。

クリア報酬の減額

クエスト内で出現したモンスターを倒さなかった場合や、宝箱を見落とした場合にクエスト報酬を報酬から減額できます。

クエストの開始APIのレスポンスにはクエスト内で得られる報酬の最大値が応答され、クエストの完了APIにはそのうち実際に入手した数量をレポートします。 そのレポートの際に報酬を減らして報告することで減額が行われます。 報告の際に最大値を超える報酬をレポートしようとするとエラーが発生します。

クエストの失敗報酬

クエストに挑戦し、クリアできなかった場合に得られる報酬を設定できます。 QuestModelfailedAcquireActions に設定します。 クエストの失敗した場合は、挑戦時に支払ったスタミナを払い戻すような処理を実現できます。

クエストの前提条件

クエストに挑戦するために、他のクエストをクリアしていることを条件として設定することができます。 QuestModelpremiseQuestNames にクエスト名の配列を設定すると、指定した全てのクエストをクリア済みでないと挑戦できなくなります。 これによって、クエストをチェーンのように繋ぐことができます。

クエストの挑戦可能期間

クエストには挑戦可能期間として GS2-Schedule のイベントを関連づけることができます。 挑戦可能な期間は開始APIの実行時に判定され、終了処理時には判定されません。

そのため、終了報告までに期間が過ぎてもクエスト報酬が受け取れなくなるといった現象は発生しません。

クエストグループ

複数のクエストをまとめるエンティティです。 章やワールド単位でクエストをまとめて管理する用途で活用できます。

クエストグループの挑戦可能期間

クエストグループにも挑戦可能期間として GS2-Schedule のイベントを関連づけることができます。 クエストグループに挑戦可能期間を設定すると、配下のクエスト全てに条件が適用されます。

クエストグループとクエスト両方に挑戦可能な期間を設定した場合は、その両方のイベントが開催期間の時にのみクエストに挑戦可能となります。

進行中のクエスト(Progress)

クエストを開始すると、ユーザーごとに1件だけ進行中の Progress がサーバーに記録されます。 Progress には抽選済みの報酬最大値や、サーバー側で生成された乱数シードが保持されており、これを利用して報告される報酬数量の妥当性を検証します。

進行中の Progress は1ユーザーあたり1件のみ保持できるため、通信切断などで完了報告ができなかった場合は DeleteProgress で破棄してから次のクエストを開始する必要があります。 別のクエストを開始する場合、StartAsyncforce: true を指定することで、進行中の Progress を破棄しつつ新規開始することもできます。

クリア状況の管理

クエストをクリアすると、そのクエストの名前が CompletedQuestList に記録されます。 クエストグループ単位で別のエンティティとして管理されており、特定のクエストグループのクリア状況をまとめて取得できます。

クリア状況はサーバー側で前提条件の判定にも利用されるため、外部から書き換える際はトランザクションアクションを経由する必要があります。

スクリプトトリガー

ネームスペースに startQuestScriptcompleteQuestScriptfailedQuestScript を設定すると、クエスト開始・クリア・失敗処理のタイミングでカスタムスクリプトを呼び出せます。

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

  • startQuestScript: クエスト開始時
  • completeQuestScript: クエストクリア時
  • failedQuestScript: クエスト失敗時

スクリプト内で報酬の上書きや、クエスト開始の拒否といった判断を行うことができます。

マスターデータ運用

マスターデータを登録することでマイクロサービスで利用可能なデータや振る舞いを設定できます。

マスターデータの種類には以下があります。

  • QuestGroupModel: クエストのまとまりと挑戦期間
  • QuestModel: コストや報酬の定義

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

クエストモデルの主な設定項目は以下の通りです。

項目 内容
name クエスト名
contents 抽選される報酬パターン(completeAcquireActionsweight
firstCompleteAcquireActions 初回クリア時のみ付与される報酬
failedAcquireActions 失敗時に付与される報酬
consumeActions 開始時に消費するリソース
verifyActions 開始時の前提条件チェック
premiseQuestNames 前提となるクリア済みクエスト
challengePeriodEventId 挑戦可能期間を表す GS2-Schedule イベント

マスターデータの JSON 例:

{
  "version": "2022-02-15",
  "questGroupModels": [
    {
      "name": "main",
      "metadata": "main-story",
      "quests": [
        {
          "name": "quest-0001",
          "metadata": "intro",
          "contents": [
            {
              "metadata": "normal",
              "completeAcquireActions": [
                {
                  "action": "Gs2Inventory:AcquireItemSetByUserId",
                  "request": "{...}"
                }
              ],
              "weight": 9
            },
            {
              "metadata": "rare",
              "completeAcquireActions": [
                {
                  "action": "Gs2Inventory:AcquireItemSetByUserId",
                  "request": "{...}"
                }
              ],
              "weight": 1
            }
          ],
          "consumeActions": [
            {
              "action": "Gs2Stamina:ConsumeStaminaByUserId",
              "request": "{...}"
            }
          ],
          "premiseQuestNames": []
        }
      ]
    }
  ]
}

バフによる補正

GS2-Buff と連携すると、クエストモデルの completeAcquireActionsfirstCompleteAcquireActionsfailedAcquireActionsverifyActionsconsumeActions をバフで補正できます。イベントやキャンペーンに応じて報酬や参加条件、消費コストを柔軟に調整できます。

「ログインキャンペーン中はクエスト報酬1.5倍」「特定の装備を装着している間は挑戦コスト半額」といったゲーム体験を、マスターデータを書き換えずに実現できます。

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

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

  • 消費アクション: クエスト進行状況(Progress)の削除
  • 入手アクション: クエスト進行状況(Progress)の作成

「クエスト進行状況の作成」を入手アクションとして利用することで、ショップでの商品購入時や特定のミッション達成時の報酬として、直接特定のクエストを開始状態にするといった処理をトランザクション内で安全に実行できます。これにより、特定のアイテム購入後に即座にスペシャルクエストへ誘導するといった、シームレスなプレイ体験を提供することが容易になります。

実装例

クエストグループ一覧を取得

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

クエストモデルの一覧を取得

    var items = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).QuestGroupModel(
        questGroupName: "quest-group-0001"
    ).QuestModelsAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->QuestGroupModel(
        "quest-group-0001" // questGroupName
    );
    const auto It = Domain->QuestModels(
    );
    TArray<Gs2::UE5::Quest::Model::FEzQuestModelPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

クエストのクリア状況を取得

    var item = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).CompletedQuestList(
        questGroupName: "main"
    ).ModelAsync();

    var completedQuestNames = item.CompleteQuestNames;
    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->CompletedQuestList(
        "main" // questGroupName
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();

クエストを開始

    var result = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).StartAsync(
        questGroupName: "group-0001",
        questName: "quest-0001"
    );

    await result.WaitAsync();
    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto Future = Domain->Start(
        "group-0001", // questGroupName
        "quest-0001" // questName
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

    const auto Transaction = Future->GetTask().Result();
    const auto Future2 = Transaction->Wait();
    Future2->StartSynchronousTask();
    if (Future2->GetTask().IsError()) return false;

進行中のクエストを取得

クエスト開始時に抽選された報酬最大値を取得し、インゲームのクリア演出に活用できます。

    var item = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Progress(
    ).ModelAsync();

    var rewards = item.Rewards;
    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Progress(
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();

クエストの終了を報告

isComplete にクリアの可否を、rewards に実際に獲得した報酬数量をレポートします。 進行中の Progress が応答した最大値の範囲内であれば、その数量の報酬が付与されます。

    var result = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Progress(
    ).EndAsync(
        isComplete: true,
        rewards: new [] {
            new Gs2.Unity.Gs2Quest.Model.EzReward {
                Action = "Gs2Inventory:AcquireItemSetByUserId",
                ItemId = "grn:gs2:{region}:{ownerId}:inventory:namespace-0001:model:item-0001",
                Value = 3,
            },
        },
        config: null
    );

    await result.WaitAsync();
    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Progress(
    );
    const auto Future = Domain->End(
        true,
        []
        {
            const auto v = MakeShared<TArray<TSharedPtr<Gs2::Quest::Model::FReward>>>();
            v->Add(MakeShared<Gs2::Quest::Model::FReward>()
                ->WithAction(TOptional<FString>("Gs2Inventory:AcquireItemSetByUserId"))
                ->WithItemId(TOptional<FString>("grn:gs2:{region}:{ownerId}:inventory:namespace-0001:model:item-0001"))
                ->WithValue(TOptional<int32>(3)));
            return v;
        }(), // rewards
        nullptr // config
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

    const auto Transaction = Future->GetTask().Result();
    const auto Future2 = Transaction->Wait();
    Future2->StartSynchronousTask();
    if (Future2->GetTask().IsError()) return false;

進行中のクエストを破棄

通信断などで End が呼び出せなかった場合のリカバリーに使用します。

    await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Progress(
    ).DeleteProgressAsync();
    const auto Future = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Progress(
    )->DeleteProgress();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

その他の機能

クエストの分岐

通常の仕様では、クエストを分岐させることはできません。 クエスト内に2つの出口を用意し、どちらの出口を利用したかで次に挑戦できるクエストが変動するようなクエストを実装したい場合は以下のようなデータ構造を検討してください。

クエスト名 前提クエスト
Quest1
Quest1a Phantom
Quest1b Phantom
Quest2a Quest1a
Quest2b Quest1b

ややこしいですが、クエストの前提条件となるクエストにはマスターデータ内に存在しないクエスト名を設定できます。

今回は Quest1a / Quest1b は Phantom という名前のクエストを前提条件としていますが、Phantom というクエストはマスターデータ内に存在しません。 そのため、Quest1a / Quest1b は絶対に挑戦可能にならないクエストということになります。

Quest2a / Quest2b は Quest1a / Quest1b を前提クエストとしています。 この状態で、Quest1 のクリア報酬に 「Quest1a をクリア状態にする」「Quest1b をクリア状態にする」という報酬を設定しておき 利用した出口に応じて、どちらかのクリア状態を操作する報酬をプレイヤーに与えるかを決定します。

Quest1a / Quest1b が存在する理由は、マスターデータ内に存在しないクエストはクリア状態にできないためです。

graph TD
  Quest1 -- if use exit A --> Quest1a
  Quest1 -- if use exit B --> Quest1b
  phantom --- Quest1a
  phantom --- Quest1b
  Quest1a --> Quest2a
  Quest1b --> Quest2b
  linkStyle 2 stroke:#ccc,stroke-dasharray:4
  linkStyle 3 stroke:#ccc,stroke-dasharray:4
  class phantom pale
  class Quest1a pale
  class Quest1b pale

Config を使用したスクリプトへのパラメータ受け渡し

StartAsync / EndAsync には config パラメータを指定でき、スクリプトトリガー実行時に任意のキーと値のペアを渡せます。 プレイヤーが選択した難易度や使用したアイテム情報など、ゲーム固有の文脈をスクリプトに伝えることが可能です。

クリア状況のリセット

CompletedQuestList を削除することで、特定クエストグループのクリア状況を初期化できます。 イベントの再周回や、章のニューゲーム+などの実装で活用できます。

詳細なリファレンス