8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unityの状態管理コード地獄を設計で解決した話【Logic Toolkit】

8
Last updated at Posted at 2026-06-29

※この記事はLogic Toolkit公式サイトのブログに掲載した記事を再整理したものです。

どうもUnityのエディタ拡張系アセットを開発しているケットシーウェアと申します。
今回はUnityアセット「Logic Toolkit」の開発にあたって、Unityの状態管理コードの複雑さに対しどういう理屈で改善したかの考えをまとめます。
アセットの紹介も最後に含みますが、基本的には状態管理コードの設計整理方法などの私なりの考え・理屈解説がメインです。

2026/07/01追記: Stateクラス定義の疑似コードに「動き・条件の呼び出しメソッド例」を追加、Logic Toolkitでの実装例を追加

コードベース状態管理の限界と改善の道のり

ゲームとは「複雑な状態遷移管理の集合」とも言えるシステムで成り立っています。

分かりやすく、ここではバナナ大好きモンキーたちを例に、コードベースでの実装の限界について解説してみます。

タンジュンモンキー

単純な行動タイプのモンキーを考えてみます。

  • 散策中にバナナが見つかったらまっ先に向かう
  • バナナの位置に到着したらその場で食べる

単純ですね。
それが罠だったらかかり放題です。
ついでなので、罠だったら捕まる状態も追加しておきましょう。

  • 散策中にバナナが見つかったらまっ先に向かう
  • バナナの位置に到着したらその場で食べる
  • 罠だったら捕まる

これを疑似コードで書いてみるとこんな感じかと思います。

散策中:
    if(MoveTo(散策移動先)==TaskResult.Completed) {
        散策移動先 = ランダムな移動先;
    }
    if(視界にバナナが入った) {
        対象バナナ = 見つかったバナナ;
        state = バナナに向かう;
    }
    break;
バナナに向かう:
    if(MoveTo(対象バナナ.位置) == TaskResult.Completed) {
        state = バナナを食べる;
    }
    break;
バナナを食べる:
    if(Eat(対象バナナ) == TaskResult.Completed) {
        if(対象バナナ.イズ罠) {
            state = 捕まり中;
        } else {
            対象バナナ = 空;
            散策移動先 = ランダムな移動先;
            state = 散策中;
        }
    }
    break;
捕まり中:
    悲しむモーション;
    break;

これはあくまで疑似コードなので、本格的に対応するとなればこのコード量では済まないほどの設計・実装が必要になるかと思います。
しかし、このタンジュンモンキー1体つくるだけでいいならコードも1回作って終わりです。
この段階では、高度な制御システム(それ用アセットなど)が必要になるとはこれっぽっちも思わないでしょうね。

チョットウタグリモンキー

次に、ちょっと疑り深いモンキーに登場していただきましょう。

  • 散策中にバナナが見つかったらバナナの位置まで向かう
  • 着いたらバナナ周辺をチェックする
  • 安全そうならその場で食べる
  • 安全そうじゃなかったら無視する
  • 食べた結果罠だったら捕まる

実際に作るなら「安全そうなら~」の判定方法次第ですが、そこは本筋ではないのでそういう判定がある前提で行きます。

このパターンの疑似コードは以下のようになります。

散策中:
    if(MoveTo(散策移動先)==TaskResult.Completed) {
        散策移動先 = ランダムな移動先;
    }
    if(視界にバナナが入った) {
        対象バナナ = 見つかったバナナ;
        state = バナナに向かう;
    }
    break;
バナナに向かう:
    if(MoveTo(対象バナナ.位置) == TaskResult.Completed) {
        state = バナナチェック;
    }
    break;
バナナチェック:
    if(LookAround() == TaskResult.Completed) {
        if(罠バナナ判定(対象バナナ) == 安全そう) {
            state = バナナを食べる;
        } else {
            無視バナナs.Add(対象バナナ);
            対象バナナ = 空;
            散策移動先 = ランダムな移動先;
            state = 散策中;
        }
    }
    break;
バナナを食べる:
    if(Eat(対象バナナ) == TaskResult.Completed) {
        if(対象バナナ.イズ罠) {
            state = 捕まり中;
        } else {
            対象バナナ = 空;
            散策移動先 = ランダムな移動先;
            state = 散策中;
        }
    }
    break;
捕まり中:
    悲しむモーション;
    break;

タンジュンモンキーとの違いは、ほぼバナナチェック状態が追加されただけです。
無視バナナについては「視界にバナナが入った」判定で無視バナナsに含まれているなら無視していると思ってください。

こう書いていると、同じところは共通化したいところですが、「バナナに向かう」完了後の遷移先も違うので、「バナナに向かう」部分も共通化できません。
2体くらいならまだいいや、とそのまま進めるとしましょう。

カナリウタグリモンキー

もう細かな例は出しませんが、さらに追加でカナリウタグリモンキーを追加したいとなったらどうでしょう。

いくつかの共通状態を持つモンキーがさらに追加されることになります。
それぞれの状態が必ずしも共通化できるとも限らず、もしかしたら迂回してバナナに向かうかもしれません。
バナナに向かう前に周囲を見回すかもしれません。
その場合、どこを共通化したら良いのでしょうか。

私の考える正解は「状態の流れは共通コード化しないほうが良い」です。
これらのモンキーシステムで共通化できるのは、MoveTo・Eat・LookAroundなどそれぞれ単体で動いて完了する「動き」部分です。
そしてもうメソッドとして切り出しているなら、動きの処理のコードは共通化できていることになります。
これ以上共通化しない方が良いなら、現時点ではあのような状態遷移コードをもう一つ書くことになりますね。

めざせモンキータイプ100種

もしモンキーがこれら3種類だけではなく100種類作るとなったらどうしますか?
100種類全部、あのような状態遷移コードを手打ちしますか?
「はい」と答えた方はここから先読む必要はないです、頑張ってください。
「いいえ」と答えた方はもっと共通化ポイントを探っていきましょう。

ステートパターン(各状態ごとに型を作って制御する仕組み)で整理すればいいでしょ、という方もちょっと待ってください。
仮に1モンキーあたり10ステートだとして、100モンキー作るならなら1000モンキーステートを実装することになります。
10モンキーであっても100モンキーステート必要です。
必要なステート分のコード・クラスを書くことに変わりないので大変です。

先ほどタンジュンモンキーとチョットウタグリモンキーの共通点はMoveToなどの動き部分だと言いましたが、状態遷移構造そのものも共通化できそうだと思いませんか?

MoveTo(対象バナナ.位置) == TaskResult.Completed

Eat(対象バナナ) == TaskResult.Completed

の部分と。

if(視界にバナナが入った) ~ state = バナナに向かう;

if(対象バナナ.イズ罠) ~ state = 捕まり中;

の部分。
「何かやり終えたら・条件を満たしたら他のことやりに行く」という構造自体が同じで、「何をするか」・「遷移するかの判定条件」・「遷移先の指定」が違うだけだったりします。
つまり「動き」・「遷移条件」・「遷移先指定」の3構成に分けて組み合わせたものがステートの構成要素と言えます。
それらの組み合わせで流れを組んでいけることになります。

例えば必要な動きが10種類、条件が5種類、遷移先は作った状態を参照するだけ、だったとします。
専用のコードを書くのはその部分だけです。
1000モンキーステートのクラス実装からかなり数が絞れます。
この「動き+条件+遷移先」をStateクラスとして実装できます。

class State {
    public Task task;
    public Condition condition;
    public bool alwaysCheck; // 常に判定するか、タスク終わったら判定するか。
    public State successTransition;
    public State failureTransition;

    // 現在の状態を実行し、遷移先の状態を返す。自身を返したら次も実行される
    public State Execute()
    {
        var completed = task.Execute() == TaskResult.Completed;
        if(alwaysCheck || completed)
        {
            if(condition == null || condition.Check())
            {
                return successTransition;
            }
            else
            {
                return failureTransition;
            }
        }
        return this;
    }            
}

こんなの。
(Execute()の内部実装は、こんな感じにしたら共通化しても問題ないかな~程度の例として見ていただければと……)
TaskとConditionはそれぞれ継承していろいろ組む必要があります。
必要となる「動き」や「条件」となる、MoveToTaskバナナ視界判定Conditionとかを実装していくことになります。
Stateの構造はそのままに「動き」・「条件」の責務を分離して、柔軟に対応できるようになったということですね。

これで、10種類の動き・5種類の条件のそれぞれのクラスを実装し、遷移の流れとしてStateを必要な分だけnew&Addするコードをモンキー100種類分書けば完了です。
合計1000モンキーステートを実装するための1000個のState継承クラスは必要ないわけですね。
その代わり1000個のnew&Addを書くことになりますが……
「それなら簡単だ、問題もなさそうだしあとは組むだけ!」と思った方は卒業です、お達者で。
「それでも面倒くさい」と思った方はさらに考えることがあります。

データ化

100モンキーを作るにあたって、100モンキー分のステート設定用クラスを用意するのも現実的ではありません。
大規模開発のプログラマーなら「動き+条件+遷移先」の設定をモンキーデザイナーに投げたくなります。
小規模開発であってもMonkey001.csからMonkey100.csまでのソースコードファイルを書きたくなかったりします。
もしMonkey089の動きだけをちょっと調整検証したいときに、Monkey089.csのコードちょっと変えてはコンパイルして検証して~というのを繰り返すのも効率が悪いです。

「動き+条件+遷移先」の設定をモンキーデザイナー(あるいは個人開発でのデザイナー人格の自分)にやってもらうにはどうすれば良いでしょうか。
ここでデータ化の出番です。
スプレッドシート・簡易スクリプト言語(DSL)・Unityのシリアライズデータなどで、「動き(タスク)+条件+遷移先」のセットリストを設定してもらえば良いわけです。

[System.Serializable]
class StateData
{
    public StateId stateId;
    [SerializeReference] public Task task;
    [SerializeReference] public Condition condition;
    public bool alwaysCheck;
    public StateId successTransition;
    public StateId failureTransition;
}

Unityならこんなデータを作って、SciptableObjectやMonoBehaviourにList<StateData>などと持たせるだけで設定できるようになりますね。
(SerializeReference属性でTaskを継承した型のデータも埋め込めるようになります。ただし埋め込む型を指定するためのエディタ実装か対応アセットが必要です)

こうしてデータとして切り出しているので、ちょっと数値を変えるだけ、ちょっと流れを変えたいだけ~って時にコンパイルなしで変更確認できます。
Monkey089.prefabだけ弄って即プレイボタンを押せば良いのです。
なんならプレイ中のままMonkey089オブジェクトを変更して様子見しても良いですね。

なにも利点は、プログラマがデザイナに丸投げできるだけではありません。
データを追加・変更するだけならコンパイルが不要になるので、リリース後の更新が行いやすくなります。
Addressablesと組み合わせれば修正やバージョンアップ・DLCなどで新モンキーを追加しやすくなるわけです。

これで開発上の問題は解決できそうですか?
「これで実装できそう・問題が無さそう」なら、それで済む開発規模ということかもしれません。
その規模で良かったですね。
済みそうにないならもっと考えることがありそうです。

エディタ&可視化

データ構造化できたのは良いですが、「この辺の流れ同じやん」ってときはコピペしたいですし、
「この辺がっつりいらんやん」ってときは範囲選択して一括で削除したくなります。
遷移先がどのステートなのか設定する際も、リストから名前見て選択するよりもマウスドラッグでノード繋ぐ方が分かりやすかったりもします。

そもそも状態遷移構造はノードグラフ構造と言えます。
仕様書になんちゃって状態遷移図とかを載せては手動でコードに落とし込んでいた時代もありました。
構造からしてまず図式化するのが自然なわけです。
図として可視化され、図として編集できることに意味が出てきます。

また、テストプレイ中に「なんかこいつの動き変だけどなんで?」ってときも、ゲームエンジン上で動作確認してるならエディタで「今何してる状態か」が確認しやすいです。

このように、流れの一覧性や編集のしやすさを考慮すると、この手の状態管理データはノードグラフ形式のGUIで編集する方向に落ち着きます。

エディタがあると組みやすくなるのは分かっていただけたかと思います。
これで課題はすべて解決でしょうか。
先ほどのStateDataという定義だけですべてのパターンを実装出来るでしょうか?
「なんとかなりそうや」って方は頑張ってください。

より複雑な挙動に対応するには

実際のゲーム開発での状態遷移・挙動管理はより複雑で、先ほどのStateDataの構造だけでは済まないかもしれません。
同時に複数の動きをしたり、遷移条件も複数ありどこに遷移するかも入り乱れたりします。
キャラクターAI(NPC)の講演などでも、ステートマシンでは対応力が足りず、ビヘイビアツリーも使うという話がよく出ていたりします。
そのような複雑な挙動を制御するにはどういう構造が良いでしょうか。
今回はモンキーの行動パターンを例にしましたが、状態管理の複雑さはキャラクターAIだけの問題なのでしょうか。
状態に紐づく概念は「動き」や「条件」だけなのでしょうか。
まだまだ考えることは多そうです。

Logic Toolkit

私なりに複雑な挙動に対応する方法を検討した結果、Logic Toolkitの設計構造に行き着きました。
「動き」や「条件」はゲームによってさまざまなので、コードで書ける柔軟性・拡張性が不可欠でしょう。
「流れの制御方法」にもいろいろなパターンがあり、一般的な実行フローだったりステートマシンだったりビヘイビアツリーだったりがあります。
それぞれの「流れ」にあったノード構造を作れば良さそうです。
「動き」や「条件」はそのまま共有できるので、流れ方だけ違うシステムが同じ基盤上で動かせそうです。
こうして、ここまでの例のように「動き・条件を組み合わせた流れ」を構造化・データ化することでLogic Toolkitができました。

「動き」であれば、例えば以下のようなコードをユーザーが自由に実装できます。

[System.Serializable]
public class WaitForSeconds : TaskComponent
{
    [SerializeField]
    private InputField<float> _Seconds = new InputField<float>();

    private float _StartTime;

    protected override void OnActivated()
    {
        _StartTime = Time.time;
    }

    protected override TaskStatus OnExecute()
    {
        var elapsed = Time.time - _StartTime;
        if(elapsed >= _Seconds.Value)
        {
            return TaskStatus.Success;
        }

        return TaskStatus.Running;
    }
}

※これはコード実装の一例であり、実際にはアセットに同様のコードが含められています。

このような「動き」を1回作るだけで、あとはエディタで以下のように様々なフローを好きに組めるようになります。

WaitForSecondsで様々なフロー.png
※画像内では同梱版のWaitForSecondsを使用しています

上段は「実行フロー」で、完了したら次に切り替わる単純なもの。
中段は「ステートマシン形式」で、1ステートに複数の「動き」や「遷移条件」を設定できるもの(WaitForScondsの説明のため複数挙動の設定はしてませんが)。
下段は「ビヘイビアツリー形式」で、木構造としてフローを制御するもの。
です。
どれも同じWaitForSecondsを使っています。

WaitForSecondsで様々なフロープレイ中.gif
実行中はこんな感じでどこが動いているか見れて、ちょっと数値調整して様子見したりもできます。
(プレイ中に赤くなるのは色設定変えてあるからでアセットとは無関係です)

このようなTaskComponent以外にも、条件判定などに使えるEvaluateComponentなどがあります。

ちなみに、1枚のグラフ上でステートマシン形式からビヘイビアツリー形式へそのままつなげることもできます。

つまり

  • 「動き」・「条件」などをコードで組む柔軟性・拡張性
  • 複雑な状態遷移・挙動制御の流れを組めるように抽象化・データ構造化
  • GUIで編集・可視化

それがUnityアセット「Logic Toolkit」です。

無料の試用版もありますので、今回の構造設計の話などが気になったらぜひ触って参考にしてみてください。

各種リンクは以下の通りです。

まとめ

まとめると、

  • 状態管理はコードベースで進めるとツライ
  • 状態管理の構造の複雑さを分析
  • 状態とは「動き+遷移条件+遷移先指定」がひとまとめになっている説
  • それらを分解し抽象化データ化することでコード地獄から改善できる
  • いろいろと構造検証した結果、Logic Toolkitというアセットが作れた

ということでした。

ここまで読んでいただきありがとうございました。
よいゲーム開発を!

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?