クエストシステム

このサンプルでは、以下の目的での Cloud Code の使用について説明します。

  • サーバー主導型チート対策
  • 他の UGS サービスレスポンスのキャッシュ
  • プレイヤーの Cloud Save データの変更

問題の記述

このサンプルのクエストシステムでは、プレイヤーがクエストの発行をリクエストできます。クエストは Remote Config に格納されます。Cloud Code は、Remote Config にアクセスしてアクティブなクエストの現在のリストを取得し、ランダムに 1 つを選択するために使用されます。クエストは Cloud Save に格納されます。

プレイヤーは、クエストの進行状況に加算されるアクションを実行できます。Cloud Code は、プレイヤーが最後に進行したのはいつかを確認し、進行状況に加算されるアクションを許可または拒否します。Cloud Code は、1 分間に許可される進行を定義することで、これを実現します。毎分、プレイヤーは変数で定義されている量の進行のみ実行できます。これは、プレイヤーがクエストの進行ポイントを受け取ることができるレートを制限することで、シンプルなチート対策手法として機能します。

クエストの設定

Remote Config で、QUESTS という変数を作成し、そのタイプを JSON として選択します。その値として以下を貼り付けます。

[{
    "id": 1,
    "name": "Chop Trees",
    "progress_required": 5,
    "progress_per_minute": 1,
    "reward": 5
}, {
    "id": 2,
    "name": "Mine Gold",
    "progress_required": 5,
    "progress_per_minute": 1,
    "reward": 10
}]

これは、アクティブなクエストデータベースとして機能します。このサンプルでは、Chop Trees や Mine Gold などのシンプルなクエストを使用します。progress_required は、各クエストがどれだけ進行すれば完了とみなされるかを示します。progress_per_minute 変数は、プレイヤーが 1 分間に取得できる進行ポイントの数を制限します。この場合、1 分間のクールダウンが終わるたびにプレイヤーがアクションを実行することを想定すると、Chop Trees クエストを完了できるのは 5 分後です。

C# モジュールの設定

このサンプルが機能するためには、Cloud Code C# モジュールを設定する必要があります。Cloud Code C# モジュールは、Cloud Code サービスにデプロイするためにビルドされる .NET プロジェクトです。Cloud Code C# モジュールを作成してデプロイする方法について詳しくは、Hello World のデプロイ を参照してください。

Cloud Save の設定

ユーザーが自分の Cloud Save データを修正して不正を行うことを防ぐために、UGS のリソースポリシーを作成する必要があります。アクセス制御の使用方法 (Unity Gaming Services の概要) を参照してください。

{
	"Sid": "deny-cloud-save-data-write-access",
	"Effect": "Deny",
	"Action": ["Write"],
	"Principal": "Player",
	"Resource": "urn:ugs:cloud-save:/v1/data/projects/*/players/*/items**"
},

クエストサービス

QuestService.cs というファイルを作成します。これにより、Remote Config からクエストがフェッチされ、設定した期間にわたりキャッシュされます。

using System.Text.Json;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;

namespace QuestSystem;

public interface IQuestService
{
    IList<Quest> GetAvailableQuests(IExecutionContext ctx);
}

public class QuestService : IQuestService
{
    private readonly IGameApiClient _apiClient;

    public QuestService(IGameApiClient apiClient)
    {
        _apiClient = apiClient;
    }

    private DateTime? CacheExpiryTime { get; set; }

    // Reminder: cache cannot be guaranteed to be consistent across all requests
    private IList<Quest>? QuestCache { get; set; }

    public IList<Quest> GetAvailableQuests(IExecutionContext ctx)
    {
        if (QuestCache == null || DateTime.Now > CacheExpiryTime)
        {
            var quests = FetchQuestsFromRC(ctx);
            QuestCache = quests;
            CacheExpiryTime = DateTime.Now.AddMinutes(5); // data in cache expires after 5 mins
        }

        return QuestCache;
    }

    private IList<Quest> FetchQuestsFromRC(IExecutionContext ctx)
    {
        var result = _apiClient.RemoteConfigSettings.AssignSettingsGetAsync(ctx, ctx.AccessToken, ctx.ProjectId,
            ctx.ProjectId, null, new List<string> { "QUESTS" });

        var settings = result.Result.Data.Configs.Settings;

        return JsonSerializer.Deserialize<List<Quest>>(settings["QUESTS"].ToString());
    }
}

データクラス

データのフィールドを含む 2 つのクラスが必要です。これらのクラスを DataClasses.cs というファイル内に配置します。これらのクラスは、Cloud Code が Remote Config および Cloud Save から受信するレスポンスの JSON デシリアライズで使用されます。

using System.Text.Json.Serialization;

namespace QuestSystem;

public class Quest
{
    [JsonPropertyName("id")] public int ID { get; set; }

    [JsonPropertyName("name")] public string? Name { get; set; }

    [JsonPropertyName("reward")] public int Reward { get; set; }

    [JsonPropertyName("progress_required")]
    public int ProgressRequired { get; set; }

    [JsonPropertyName("progress_per_minute")]
    public int ProgressPerMinute { get; set; }
}

public class QuestData
{
    public QuestData()
    {

    }

    public QuestData(string questName, int reward, int progressLeft, int progressPerMinute, DateTime questStartTime)
    {
        QuestName = questName;
        Reward = reward;
        ProgressLeft = progressLeft;
        ProgressPerMinute = progressPerMinute;
        QuestStartTime = questStartTime;
        LastProgressTime = new DateTime();
    }

    [JsonPropertyName("quest-name")] public string? QuestName { get; set; }

    [JsonPropertyName("reward")] public long Reward { get; set; }

    [JsonPropertyName("progress-left")] public long ProgressLeft { get; set; }

    [JsonPropertyName("progress-per-minute")]
    public long ProgressPerMinute { get; set; }

    [JsonPropertyName("quest-start-time")] public DateTime QuestStartTime { get; set; }

    [JsonPropertyName("last-progress-time")] public DateTime LastProgressTime { get; set; }
}

Cloud Code の設定

依存性注入を機能させるには、CloudCodeSetup という別のクラスが必要です。これにより、Cloud Code 関数が呼び出されるたびに、QuestServiceGameAPIClient が何であるかが認識されます。ここで、Cloud Code の依存性注入について詳しく読むことができます: 依存性注入

using Microsoft.Extensions.DependencyInjection;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;

namespace QuestSystem;

public class CloudCodeSetup : ICloudCodeSetup
{
    public void Setup(ICloudCodeConfig config)
    {
        config.Dependencies.AddSingleton<IQuestService, QuestService>();
        config.Dependencies.AddSingleton<IGameApiClient>(s => GameApiClient.Create());
    }
}

クエストコントローラー

QuestController は、モジュールのメインクラスです。これは、サービスへのユーザートラフィックのエントリーポイントとして機能します。

using System.Text.Json;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudSave.Model;

namespace QuestSystem;

public class QuestController
{
    [CloudCodeFunction("AssignQuest")]
    public async Task<string> AssignQuest(IExecutionContext ctx, IQuestService questService, IGameApiClient apiClient)
    {
        var questData = await GetQuestData(ctx, apiClient);

        if (questData?.QuestName != null) return "Player already has a quest in progress!";

        var availableQuests = questService.GetAvailableQuests(ctx);
        var random = new Random();
        var index = random.Next(availableQuests.Count);
        var quest = availableQuests[index];

        questData = new QuestData(quest.Name, quest.Reward, quest.ProgressRequired, quest.ProgressPerMinute,
            DateTime.Now);

        await SetQuestData(ctx, apiClient, "quest-data", JsonSerializer.Serialize(questData));

        return $"Player was assigned quest: {quest.Name}!";
    }

    [CloudCodeFunction("PerformAction")]
    public async Task<string> PerformAction(IExecutionContext ctx, IGameApiClient apiClient)
    {
        var questData = await GetQuestData(ctx, apiClient);

        if (questData?.QuestName == null) return "Player does not have a quest in progress!";

        if (questData.ProgressLeft == 0) return "Player has already completed their quest!";

        if (DateTime.Now < questData.LastProgressTime.AddSeconds(60 / questData.ProgressPerMinute)) return "Player cannot make quest progress yet!";

        questData.LastProgressTime = DateTime.Now;
        questData.ProgressLeft--;

        await SetQuestData(ctx, apiClient, "quest-data", JsonSerializer.Serialize(questData));

        return "Player made quest progress!";
    }

    private async Task<QuestData?> GetQuestData(IExecutionContext ctx, IGameApiClient apiClient)
    {
        var result = await apiClient.CloudSaveData.GetItemsAsync(
            ctx, ctx.AccessToken, ctx.ProjectId, ctx.PlayerId,
            new List<string> { "quest-data" });

        if (result.Data.Results.Count == 0) return null;

        return JsonSerializer.Deserialize<QuestData>(result.Data.Results.First().Value.ToString());
    }

    private async Task<string> SetQuestData(IExecutionContext ctx, IGameApiClient apiClient, string key, string value)
    {
        var result = await apiClient.CloudSaveData
            .SetItemAsync(ctx, ctx.AccessToken, ctx.ProjectId, ctx.PlayerId, new SetItemBody(key, value));

        return result.Data.ToJson();
    }
}

Cloud Code 関数 AssignQuest

AssignQuest 関数は、前のこのサンプルで Remote Config に公開した現在アクティブなクエストを取得します。関数では、プレイヤーがすでにクエストを処理中かどうかも確認し、処理中でない場合はクエストをプレイヤーに割り当てます。

Cloud Code 関数 PerformAction

クエストの進行ポイントを生成する可能性のあるアクションをプレイヤーが実行するたびに、PerformAction 関数を呼び出します。クールダウンの期限が切れたか、これがプレイヤーの最初の進行関数呼び出しであると想定すると、プレイヤーには進行ポイントが授与され、Cloud Save 内での progress-left は減ります。

この関数がクールダウン中に呼び出された場合、Cloud Code からのメッセージは以下のようになります: Player cannot make quest progress yet!。プレイヤーが成功した (関数がクールダウン期間外に呼び出された) 場合、Cloud Code は Player made quest progress! で応答し、プレイヤーの progress-left は減らされ、last-progress-time が更新されます。

プレイヤーの progress-left がゼロの場合、PerformAction 関数への後続の呼び出しは Player has already completed their quest! で応答します。ここから、クエストを発行した場所または画面に戻ってゲーム内報酬を取得するようプレイヤーに通知できます。