C# ステートマシン定義チュートリアル

Unity Editor で小さなゲームを作り、C# のルールを編集してゲーム画面から操作するチュートリアル

このページでは、数値をボタン操作で増やし、9 になると終了するサンプル MyGame を作ります。ルールは次の三つです。

  • 数値 Progress は 0 から始まります。
  • Small を選ぶと 1、Large を選ぶと 3 増えます。
  • Progress が 9 になると終了します。9 を超える操作でも、値は 9 に収めます。

手順 1〜2 でひな形を Editor のプレビューで動かし、手順 3 でルールを変更、手順 4 で Unity のシーンから操作します。手順 5 でサーバーによる検証を追加します。

1. ひな形を作る

GS2 SDK for Unity、GS2-CSharp-SDK、LocalStateMachineKit の C# 生成機能を含む版を導入した Unity プロジェクトで操作します。生成機能は Unity 2021.2 以降が対象です。

  1. Unity のメニューから GS2 > Local State Machine > Generate from C# を選びます。GS2 C# Authoring ウィンドウが開きます。
  2. New definition の Name に MyGame を入力します。
  3. Create and preview を押します。
  4. 生成とコンパイルが終わると、GS2 Local Preview ウィンドウが開きます。

この手順では、Project の選択や Generation settings の展開・変更は不要です。 作成先と生成先は自動で設定されます。ログインやサーバー設定もまだ必要ありません。

MyGame がすでに存在する場合は作り直さず、Existing definition > Project の … から GameRules/MyGame/project.json を選び、Generate and preview を押してください。

2. プレビューで操作する

GS2 Local Preview には、上側の View と下側の Operations があります。

  • View > State > Progress:操作で増やす数値。最初は 0 です。
  • Operations:choice の選択欄と Advance ボタンがあります。

choice を Small に変更し、Advance を押してください。Progress が 1 になり、Accepted: Advance と表示されます。ここでの Advance はゲームを進める操作ボタンです。

choice で選ぶ値Advance を押した結果
SmallProgress が 1 増える
LargeProgress が 3 増える。ただし 9 を超えない
NoneProgress は変わらず、Ignored: InvalidChoice (1001) と表示される

Reset を押してから Large を 3 回実行すると、Progress が 0 → 3 → 6 → 9 と変化します。9 になると Result > Progress にも 9 が表示され、Advance ボタンは押せなくなります。もう一度試すときは Reset を押します。

このプレビューは Unity の Play ボタンを押さずに使えます。

3. Small で増える量を 1 から 2 に変える

GS2 C# Authoring ウィンドウへ戻り、Open rules を押します。Finder / エクスプローラーにルールのフォルダーが表示されるので、MyGame.cs をテキストエディターや IDE で開きます。

ファイルは Unity プロジェクト直下の GameRules/MyGame/Rules/MyGame.cs にあります。Assets の外にあるため、Unity の Project ウィンドウには表示されません。

ひな形の内容は次のルールです(読みやすいように改行しています)。

using System;
using Gs2.LocalStateMachine.Authoring;
using Step = Gs2.LocalStateMachine.Authoring.Step<
    GameRules.MyGameState, GameRules.MyGameResult, GameRules.MyGameError>;

namespace GameRules;

public enum MyGameError { InvalidChoice = 1001 }
public enum MyGameChoice { None, Small, Large }
public record MyGameState(int Progress);
public record MyGameResult(int Progress);

public static class MyGame
{
    public static Step Start() =>
        Step.WaitFor(nameof(Advance), new MyGameState(0));

    public static Step Advance(MyGameState state, MyGameChoice choice)
    {
        if (choice != MyGameChoice.Small && choice != MyGameChoice.Large)
            return Step.Ignore(MyGameError.InvalidChoice);

        var amount = 1;
        if (choice == MyGameChoice.Large)
            amount = 3;

        var next = state with { Progress = Math.Min(9, state.Progress + amount) };
        if (next.Progress == 9)
            return Step.End(new MyGameResult(next.Progress), next);

        return Step.WaitFor(nameof(Advance), next);
    }
}

ここでは、次の順番で処理しています。

  1. Start で Progress が 0 の状態を作り、Advance の入力を待ちます。
  2. Advance は現在の状態 state と、プレイヤーが選んだ choice を受け取ります。
  3. None なら Ignore を返し、入力を採用しません。
  4. 有効な選択なら、state with { ... } で更新後の状態を作ります。
  5. 9 に到達したら End で終了し、それ以外は次の Advance を待ちます。

MyGameState は進行中の状態、MyGameResult は終了時に受け取る結果です。using Step = ... は、この二つの型と入力を無視する理由の型をまとめて指定するための略記です。

var amount = 1; を var amount = 2; に変更し、保存してください。 Unity に戻って GS2 C# Authoring > Generate and preview を押します。プレビューは最初から始まり、今度は Small で Progress が 0 → 2 になります。

Generate and preview は、定義から GSL と Session を生成します。GSL はルールを実行するためのファイル、Session は Unity から操作を呼ぶための C# クラスです。以降も「ルールを編集して保存 → Generate and preview」で試せます。生成された GSL や Session は直接編集しません。

4. Unity のゲーム画面から呼ぶ

次に、同じ MyGame をシーン内のボタンから操作します。

ゲームのルールを書く C# と、Unity の画面を書く C# は役割が異なります。

書くもの役割
ゲームの定義操作を受け付ける条件、状態の変化、終了条件を書く
Unity の画面ボタンから操作を呼び、現在の状態を表示する

ここでは Unity の画面側を書きます。

  1. Assets 以下に MyGamePreview.cs を作り、次の内容を保存します。
  2. シーンに空の GameObject を作り、このスクリプトを付けます。
  3. Unity の Play ボタンを押します。
  4. Game ビューの Small / Large を押し、Progress の変化を確認します。前の手順でルールを変更済みなので、Small を押すと Progress が 2 増えます。
#if UNITY_EDITOR && !GS2_DISABLE_LOCAL_STATE_MACHINE_KIT
using System.IO;
using Generated.MyGame;
using Gs2.Unity.Gs2StateMachine.Local;
using UnityEngine;

public sealed class MyGamePreview : MonoBehaviour
{
    private MyGameSession game;

    private void Start()
    {
        var path = Path.Combine(Application.dataPath, "Generated/MyGame/machine.gsl");
        game = MyGameSession.FromExecutor(
            LocalStateMachineExecutor.CreatePreview(File.ReadAllText(path), "MyGame"));
    }

    private void OnGUI()
    {
        if (game == null) return;
        GUILayout.Label("Progress: " + game.View.State.Progress);

        using (new UnityEditor.EditorGUI.DisabledScope(!game.View.Actions.Advance))
        {
            if (GUILayout.Button("Small")) game.Advance(MyGameChoice.Small);
            if (GUILayout.Button("Large")) game.Advance(MyGameChoice.Large);
        }

        if (game.View.Result != null)
            GUILayout.Label("Finished: " + game.View.Result.Progress);
    }
}
#endif

game.Advance(...) が操作の呼び出し、game.View.State が表示に使う現在の状態です。定義の Advance にある state 引数は実行環境が渡すため、画面側から渡すのは choice だけです。

game.View.Actions.Advance は「今、Advance を呼べるか」を表します。この例では終了後に false になるため、ボタンを無効にしています。素材不足などのゲーム固有の条件がある場合は、操作関数内で検査して Ignore を返します。

View.State は読み取り専用のコピーです。状態を直接書き換える代わりに操作関数を呼んでください。変更時だけ通知を受けたい場合は、Start の Session 作成直後に次の行を追加できます。

game.ViewChanged += view => Debug.Log("Progress: " + view.State.Progress);

ここまでで、定義の編集・再生成・ゲーム画面からの操作を試せました。この MyGamePreview は Editor 内で確認するためのコードです。アプリに組み込む場合は、次の接続手順へ進みます。

5. サーバーの検証を使う

サーバーへ接続しても、画面側で呼ぶ game.Advance(...) と game.View.State は同じです。変更するのは、Session の作成と操作履歴の送信です。

まず GS2 側を準備します。

  1. GS2-StateMachine のネームスペースを用意し、投機的実行を有効にします。
  2. Assets/Generated/MyGame/machine.gsl を読み、マスター登録の payload に指定します。mainStateMachineName は MyGame です。マスター登録の説明を参照してください。
  3. ゲームの開始処理から、対象ユーザーのステートマシンを開始します。開始 APIや開始用のアクションを利用します。

次は、初期化・ログイン済みのアプリ内で使う UniTask の例です。gs2 と gameSession は初期化済みの SDK 接続とログイン情報、namespaceName はネームスペース名、statusName は手順 3 で開始したステータスの名前です。

var game = await Generated.MyGame.MyGameSession.OpenAsync(
    gs2, gameSession, namespaceName, statusName);

var outcome = game.Advance(Generated.MyGame.MyGameChoice.Small);
if (outcome.Ignored)
    Debug.Log("入力を採用しませんでした: " + outcome.Code);

await game.ConfirmAsync(gs2, gameSession);

OpenAsync は、手順 3 で開始したステータスへ接続します。UniTask を使わない場合は OpenFuture / ConfirmFuture を使用します。

ConfirmAsync は、それまでの操作履歴をサーバーへ送り、検証結果を待ちます。終了時だけでなく、ゲームの途中でも呼べます。サーバーは同じルールを実行して結果を照合します。ダメージ計算や報酬条件などの重要なルールも、ゲームの定義側に書いてください。

Editor のプレビューには、サーバーへ送る履歴はありません。 プレビューを途中まで遊んでから接続するのではなく、サーバーで開始した状態を OpenAsync で開きます。

定義の仕様を調べる

手順どおりに進めない場合

状況確認すること
Create and preview ではなく Create and generate が表示されるGS2_DISABLE_LOCAL_STATE_MACHINE_KIT を外し、再コンパイルする。生成機能だけを使う場合は外す必要はない
同じ名前で作成できない既存の project.json を選び、Generate and preview を押す
Open rules を押してもコード編集画面が出ないフォルダーを開くボタンなので、その中の MyGame.cs をエディターで開く
編集後も動きが変わらないルールを保存し、Generate and preview を押す。Console に生成エラーがないか確認する
9 に到達した後に操作できないゲームが終了した状態。プレビューウィンドウなら Reset、シーンなら Play を停止して再開する

生成に失敗した場合は、前回成功した生成物を残します。エラーがあるまま古い生成物で動作を確認しないよう、Console のエラーを解消してから再生成してください。