> For the complete documentation index, see [llms.txt](/ja/llms.txt)


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

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



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

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

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

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

## 作業の流れ

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

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

## 1. ルールを書く {#rules}

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

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

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

```csharp
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 と同期スキーマを生成する {#generate}

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

```json
{
  "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.zip` | `main.gsl` だけを入れた配布用ファイル |
| `unity/EnemyFields.realtimeschema` | 手順 4 で敵の Prefab に指定する同期スキーマ |
| `source-map.json` | 生成された位置から元の C# を調べるための開発用ファイル |

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

## 3. 定義を登録する {#register}

GS2-StateMachine の [UpdateStateMachineMaster](#updatestatemachinemaster) で、生成したルールを登録します。

| 項目 | 指定する値 |
| --- | --- |
| `namespaceName` | ルールを保存する GS2-StateMachine のネームスペース |
| `mainStateMachineName` | `EnemyRoom`（ルールのクラス名） |
| `payload` | `main.gsl` の内容（文字列） |

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

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

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

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

## 4. 敵の Prefab を作る {#prefab}

### 同期スキーマを取り込む

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

### Prefab を作る

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

   | コンポーネント | 設定 |
   | --- | --- |
   | **NetworkEntity** | **Schema** に `EnemyFields` を指定 |
   | **NetworkTransform** | **Position Axes** は `All`、**Rotation Axes** は `None`（回転はサーバーが配信しないため） |
   | **SharedObject** | 設定なし。サーバーへの操作の送信に使う |

2. HP を表示する Slider を子に置き、次のスクリプトをルートに追加して、Slider を割り当てます。Slider の **Max Value** は 100 にします。

   ```csharp
   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` を呼びます。

```csharp
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. ルームに接続する {#connect}

### 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 は共有されません。プレイヤーが自分のオブジェクトを作る方法は[プレイヤーオブジェクトを作る](#player-objects)を参照してください。

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

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

```csharp
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. 動作を確かめる {#verify}

同じルームへ、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`）が届きます。

## 次に読むもの

- [Unity クライアント]()：位置の同期、操作権、RPC、プレイヤーオブジェクト、UDP、再接続の扱い。
- [Photon からの移行ガイド]()：PUN2 の API との対応表と移行の手順。
- [ルーム定義リファレンス]()：Room API、タイマー、変更の確定と取り消し、上限値。
- [C# 定義リファレンス]()：状態・操作・Step の書き方。



