チュートリアル: サーバーで HP を管理する敵を作る

C# でルームのルールを書き、Unity で複数のプレイヤーが同じ敵を攻撃できるようにするまでの手順

このチュートリアルでは、ルームに参加した全員が同じ敵を攻撃できる、小さなルームを作ります。

  • 敵は 1 体で、HP は 100 です。
  • プレイヤーが攻撃するたびに、HP が 25 減ります。
  • HP が 0 になると、8 秒後に HP 100 で復活します。

HP の計算はサーバーで行います。Unity は「攻撃する」という要求を送り、サーバーが確定した HP を表示するだけです。そのため、複数のプレイヤーが同時に攻撃しても、全員に同じ HP が表示されます。

サーバータイプは stateMachine を使います。サーバータイプの違いは構成と選び方を参照してください。

作業の流れ

手順作業する場所作るもの
1. ルールを書くテキストエディター / IDE敵の生成・攻撃・復活を書いた C#
2. GSL と同期スキーマを生成するUnity Editormain.gsl、EnemyFields.realtimeschema
3. 定義を登録するGS2StateMachineMaster と Realtime のネームスペース設定
4. 敵の Prefab を作るUnity EditorHP を表示し、攻撃を送る Prefab
5. ルームに接続するUnity Editor受信した状態から敵を表示するシーン
6. 動作を確かめるUnity(2 クライアント)—

GS2 SDK for Unity と、LocalStateMachineKit の C# 生成機能を含む版を導入した Unity プロジェクト(Unity 2021.3 以降)で作業します。

1. ルールを書く

Unity プロジェクトの Assets の外に、次のフォルダーとファイルを作ります。ここに書く C# は Unity で実行するコードではなく、GSL を生成するための入力です。

GameRules/
└── EnemyRoom/
    ├── project.json
    └── Rules/
        └── EnemyRoom.cs

EnemyRoom.cs に、ルームのルールを書きます。

using Gs2.LocalStateMachine.Authoring;
using Step = Gs2.LocalStateMachine.Authoring.Step<
    GameRules.RoomState, GameRules.RoomResult, GameRules.RoomError>;

namespace GameRules;

// 敵の同期項目。Prefab ID 7、位置と HP をクライアントへ配信する
[RoomSchema(7, StandardFields = RoomSync.Position)]
public enum EnemyFields
{
    Health = 6,
}

public record RoomState(int EnemyId);
public record RoomResult(bool Finished);
// 拒否理由はクライアントの CommandCompleted にそのまま届く。システムの結果コードと重ならない値にする
public enum RoomError { InvalidTarget = 1001, AlreadyDefeated = 1002 }

public static class EnemyRoom
{
    // ルームの開始時に一度だけ実行する
    public static Step Start()
    {
        Room.DefineServerPrefab<EnemyFields>();
        var enemy = Room.Spawn<EnemyFields>(0, 0, 0);
        Room.Set(enemy, EnemyFields.Health, 100);
        return Step.WaitFor(nameof(Interact), new RoomState(enemy))
            .Or(nameof(Timer));
    }

    // プレイヤーが SharedObject.Interact を呼んだとき
    public static Step Interact(RoomState state, int objectId, int method)
    {
        if (objectId != state.EnemyId || method != 1)
            return Step.Ignore(RoomError.InvalidTarget);

        var hp = Room.Get(objectId, EnemyFields.Health);
        if (hp == 0)
            return Step.Ignore(RoomError.AlreadyDefeated);

        hp = System.Math.Max(0, hp - 25);
        Room.Set(objectId, EnemyFields.Health, hp);
        if (hp == 0)
            Room.After(objectId, 8000, 1); // 8 秒後に Timer(eventId 1) を受け取る
        return Step.Stay(state);
    }

    // Room.After で予約した時刻になったとき
    public static Step Timer(RoomState state, int objectId, int eventId)
    {
        if (objectId != state.EnemyId || eventId != 1)
            return Step.Ignore(RoomError.InvalidTarget);

        Room.Set(objectId, EnemyFields.Health, 100);
        return Step.Stay(state);
    }
}

ポイントは次の 3 つです。

  • 同期する値は RoomSchema で宣言します。 EnemyFields の宣言から、サーバーの定義と Unity の同期スキーマの両方が生成されます。enum の値(Health = 6)は通信上のフィールド番号です。位置が 0〜2 を使うので、独自の値は 6 以降にします。
  • HP は共有オブジェクトに持たせます。 クライアントへ配信されるのは共有オブジェクトの値で、RoomState は配信されません。RoomState には、敵のオブジェクト ID のようにサーバーだけが使う値を持たせます。
  • 攻撃の可否はサーバーが判断します。 対象や回数の検証は Interact に書きます。実際のゲームでは、攻撃できる距離やクールダウンもここで検証します。

2. GSL と同期スキーマを生成する

GameRules/EnemyRoom/project.json に、生成設定を保存します。パスはこのファイルからの相対指定です。hostProfile に RealtimeRoomV1 を指定すると、Realtime のルームで実行する GSL を生成します。

{
  "format": 1,
  "source": "Rules",
  "output": "../../Assets/Generated/EnemyRoom",
  "hostProfile": "RealtimeRoomV1"
}
  1. Unity のメニューから GS2 > Local State Machine > Generate from C# を選びます。
  2. Existing definition > Project の … から GameRules/EnemyRoom/project.json を選びます。
  3. Generate を押します。

Assets/Generated/EnemyRoom に、次のファイルが生成されます。

ファイル使い道
main.gsl手順 3 で GS2 に登録するルール
package.zipmain.gsl だけを入れた配布用ファイル
unity/EnemyFields.realtimeschema手順 4 で敵の Prefab に指定する同期スキーマ
source-map.json生成された位置から元の C# を調べるための開発用ファイル

生成エラーは Console に、元の C# のファイル名と行番号付きで表示されます。使える C# の範囲は C# 定義リファレンスを参照してください。

3. 定義を登録する

GS2-StateMachine の UpdateStateMachineMaster で、生成したルールを登録します。

項目指定する値
namespaceNameルールを保存する GS2-StateMachine のネームスペース
mainStateMachineNameEnemyRoom(ルールのクラス名)
payloadmain.gsl の内容(文字列)

次に、Realtime のネームスペースで、ルームで実行するルールを指定します。

設定する内容設定項目指定する値
サーバータイプserverTypestateMachine
ルールの保存先stateMachineSetting.namespaceId上で登録した GS2-StateMachine のネームスペース
ルールのバージョンstateMachineSetting.version省略(最新を使う)

バージョンを省略すると、ルームは割り当てられたときに、その時点で登録されている最新のルールを一度だけ読み込み、ルームが終わるまでそのルールで動きます。ルールを登録し直すと、それ以降に割り当てられるルームから新しいルールが使われます。

特定のバージョンに固定したいときは、登録結果のバージョンを指定します。固定している間は、ルールを登録し直してもルームには反映されません。

4. 敵の Prefab を作る

同期スキーマを取り込む

Assets/Generated/EnemyRoom/unity/EnemyFields.realtimeschema は、Unity に取り込まれると同期スキーマのアセットになります。生成先が Assets の中なので、そのまま使えます。

Prefab を作る

  1. 敵の見た目になる Prefab を作り、ルートに次のコンポーネントを追加します。

    コンポーネント設定
    NetworkEntitySchema に EnemyFields を指定
    NetworkTransformPosition Axes は All、Rotation Axes は None(回転はサーバーが配信しないため)
    SharedObject設定なし。サーバーへの操作の送信に使う
  2. HP を表示する Slider を子に置き、次のスクリプトをルートに追加して、Slider を割り当てます。Slider の Max Value は 100 にします。

    using UnityEngine;
    using UnityEngine.UI;
    
    public sealed class EnemyHealthView : MonoBehaviour
    {
        [SerializeField] private Slider health;
    
        public void SetHealth(int value)
        {
            health.value = value;
        }
    }
  3. NetworkEntity > Property bindings > Add binding で項目を追加し、Field に Health を選びます。Apply to に EnemyHealthView を登録し、Dynamic int > SetHealth を選びます。これで、受信した HP が Slider に反映されます。

攻撃を送る

攻撃は、SharedObject.Interact でサーバーへ送ります。次のスクリプトを Prefab のルートに追加し、攻撃ボタンなどから Attack を呼びます。

using Gs2.Realtime.Sync;
using UnityEngine;

[RequireComponent(typeof(SharedObject))]
public sealed class EnemyAttack : MonoBehaviour
{
    public void Attack()
    {
        var target = GetComponent<SharedObject>();
        if (target.Object != null)
            target.Interact(1); // ルールの Interact に method 1 として届く
    }
}

Interact(1) は「この敵に method 1 の操作をする」という要求です。HP を直接書き換えるのではなく、サーバーのルールが攻撃を検証して HP を更新し、その結果が全員に配信されます。

5. ルームに接続する

NetworkWorld を置く

  1. Project ウィンドウの Create > GS2 > Realtime > Prefab Catalog でカタログを作ります。
  2. カタログの Prefabs に、Id 7 と手順 4 の敵の Prefab を登録します。7 は RoomSchema(7) で指定した Prefab ID です。
  3. シーンに空の GameObject を作り、NetworkWorld を追加して、Prefab Catalog にカタログを指定します。

NetworkWorld は、サーバーから受け取った共有オブジェクトを、カタログの Prefab から生成します。Unity の Instantiate で作った GameObject は共有されません。プレイヤーが自分のオブジェクトを作る方法はプレイヤーオブジェクトを作るを参照してください。

接続スクリプトを追加する

NetworkWorld と同じ GameObject に、次のスクリプトを追加します。ルームの接続情報(IP アドレス・ポート・暗号鍵)とアクセストークンは、ゲームサーバーへの接続情報を取得の方法で取得して渡します。

using System.Collections;
using System.Collections.Generic;
using Google.Protobuf;
using Gs2.Core;
using Gs2.Gs2Realtime.Message;
using Gs2.Realtime.Sync;
using Gs2.Unity.Gs2Realtime;
using UnityEngine;

[RequireComponent(typeof(NetworkWorld))]
public sealed class EnemyRoomConnection : MonoBehaviour
{
    private readonly Queue<BinaryMessage> outgoing = new Queue<BinaryMessage>();
    private RealtimeSession session;

    public IEnumerator Connect(string accessToken, string ipAddress, int port, string encryptionKey)
    {
        var world = GetComponent<NetworkWorld>();
        session = new RealtimeSession(accessToken, ipAddress, port, encryptionKey, ByteString.Empty);
        session.OnClose += _ => world.Detach();

        AsyncResult<bool> result = null;
        yield return session.Connect(null, value => result = value);
        if (result == null || result.Error != null || !session.Connected)
            yield break;

        // 受信処理を登録してから Dispatch を始め、参加直後のスナップショットを取りこぼさない
        world.Attach(session.MyConnectionId, (bytes, count) =>
        {
            if (outgoing.Count >= 32 || !session.Connected)
                return false;
            outgoing.Enqueue(new BinaryMessage { Data = ByteString.CopyFrom(bytes, 0, count) });
            return true;
        });
        // メタデータのサーバー時刻を渡すと、world.ServerTime と時刻に沿った補間が使える
        session.OnMessageWithMetadata += (message, metadata) => world.Receive(message.Data.ToByteArray(), metadata.LifeTimeMilliSeconds);
        StartCoroutine(session.Dispatch());
        StartCoroutine(SendQueued());
    }

    private IEnumerator SendQueued()
    {
        while (session != null && session.Connected)
        {
            if (outgoing.Count == 0)
            {
                yield return null;
                continue;
            }
            var sent = false;
            yield return session.Send(result => sent = result.Error == null && result.Result, outgoing.Dequeue());
            if (!sent)
            {
                // 送れなかった変更を捨てずに、切断して状態を取り直す
                yield return session.Close();
                yield break;
            }
        }
    }
}

Attach に渡す関数は、送信するデータを受け取って送信キューに積み、積めたかどうかを返します。NetworkWorld は変更をまとめて、この関数から送信します。

接続に成功すると、サーバーから現在のルームの状態(スナップショット)が届き、NetworkWorld が敵を生成します。

6. 動作を確かめる

同じルームへ、2 つのクライアントから接続します。同じ userId で接続すると先の接続が置き換わるため、別々の userId のアクセストークンを使ってください。

  1. 両方に同じ敵が 1 体表示され、HP が 100 になることを確かめます。
  2. 片方から攻撃し、両方の表示が 75 になることを確かめます。
  3. 両方から攻撃を送り、合計 4 回で HP が 0 になることを確かめます。
  4. HP が 0 の間は攻撃しても減らず、約 8 秒後に両方の表示が 100 に戻ることを確かめます。
  5. 一方を切断して接続し直し、HP が初期値に戻らず、その時点の値で表示されることを確かめます。

攻撃が拒否されたかどうかは、NetworkWorld.Client.CommandCompleted で確かめられます。HP が 0 の敵への攻撃では、ルールで指定した 1002(AlreadyDefeated)が届きます。

次に読むもの