チュートリアル: サーバーで HP を管理する敵を作る
このチュートリアルでは、ルームに参加した全員が同じ敵を攻撃できる、小さなルームを作ります。
- 敵は 1 体で、HP は 100 です。
- プレイヤーが攻撃するたびに、HP が 25 減ります。
- HP が 0 になると、8 秒後に HP 100 で復活します。
HP の計算はサーバーで行います。Unity は「攻撃する」という要求を送り、サーバーが確定した HP を表示するだけです。そのため、複数のプレイヤーが同時に攻撃しても、全員に同じ HP が表示されます。
サーバータイプは stateMachine を使います。サーバータイプの違いは構成と選び方を参照してください。
作業の流れ
| 手順 | 作業する場所 | 作るもの |
|---|---|---|
| 1. ルールを書く | テキストエディター / IDE | 敵の生成・攻撃・復活を書いた C# |
| 2. GSL と同期スキーマを生成する | Unity Editor | main.gsl、EnemyFields.realtimeschema |
| 3. 定義を登録する | GS2 | StateMachineMaster と Realtime のネームスペース設定 |
| 4. 敵の Prefab を作る | Unity Editor | HP を表示し、攻撃を送る 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.csEnemyRoom.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"
}- Unity のメニューから GS2 > Local State Machine > Generate from C# を選びます。
- Existing definition > Project の … から
GameRules/EnemyRoom/project.jsonを選びます。 - 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# 定義リファレンスを参照してください。
3. 定義を登録する
GS2-StateMachine の UpdateStateMachineMaster で、生成したルールを登録します。
| 項目 | 指定する値 |
|---|---|
namespaceName | ルールを保存する GS2-StateMachine のネームスペース |
mainStateMachineName | EnemyRoom(ルールのクラス名) |
payload | main.gsl の内容(文字列) |
次に、Realtime のネームスペースで、ルームで実行するルールを指定します。
| 設定する内容 | 設定項目 | 指定する値 |
|---|---|---|
| サーバータイプ | serverType | stateMachine |
| ルールの保存先 | stateMachineSetting.namespaceId | 上で登録した GS2-StateMachine のネームスペース |
| ルールのバージョン | stateMachineSetting.version | 省略(最新を使う) |
バージョンを省略すると、ルームは割り当てられたときに、その時点で登録されている最新のルールを一度だけ読み込み、ルームが終わるまでそのルールで動きます。ルールを登録し直すと、それ以降に割り当てられるルームから新しいルールが使われます。
特定のバージョンに固定したいときは、登録結果のバージョンを指定します。固定している間は、ルールを登録し直してもルームには反映されません。
4. 敵の Prefab を作る
同期スキーマを取り込む
Assets/Generated/EnemyRoom/unity/EnemyFields.realtimeschema は、Unity に取り込まれると同期スキーマのアセットになります。生成先が Assets の中なので、そのまま使えます。
Prefab を作る
敵の見た目になる Prefab を作り、ルートに次のコンポーネントを追加します。
コンポーネント 設定 NetworkEntity Schema に EnemyFieldsを指定NetworkTransform Position Axes は All、Rotation Axes はNone(回転はサーバーが配信しないため)SharedObject 設定なし。サーバーへの操作の送信に使う 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; } }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 を置く
- Project ウィンドウの Create > GS2 > Realtime > Prefab Catalog でカタログを作ります。
- カタログの Prefabs に、Id
7と手順 4 の敵の Prefab を登録します。7はRoomSchema(7)で指定した Prefab ID です。 - シーンに空の 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 体表示され、HP が 100 になることを確かめます。
- 片方から攻撃し、両方の表示が 75 になることを確かめます。
- 両方から攻撃を送り、合計 4 回で HP が 0 になることを確かめます。
- HP が 0 の間は攻撃しても減らず、約 8 秒後に両方の表示が 100 に戻ることを確かめます。
- 一方を切断して接続し直し、HP が初期値に戻らず、その時点の値で表示されることを確かめます。
攻撃が拒否されたかどうかは、NetworkWorld.Client.CommandCompleted で確かめられます。HP が 0 の敵への攻撃では、ルールで指定した 1002(AlreadyDefeated)が届きます。
次に読むもの
- Unity クライアント:位置の同期、操作権、RPC、プレイヤーオブジェクト、UDP、再接続の扱い。
- Photon からの移行ガイド:PUN2 の API との対応表と移行の手順。
- ルーム定義リファレンス:Room API、タイマー、変更の確定と取り消し、上限値。
- C# 定義リファレンス:状態・操作・Step の書き方。