ステート(状態)設計パターン

はじめに

処理が必要な状態が多数ある場合、ゲームのスクリプトを作成するのは困難ですが、一度に1つのノードに接続できるスクリプトは1つだけです。プレイヤーの制御スクリプト内にステートマシンを作成する代わりに、ステートが異なるクラスに分けられていれば、開発が簡単になります。

Godotでステートマシンを実装する方法は多数ありますが、他にもいくつかの方法があります:

  • プレイヤーは、ステートごとに子ノードを持つことができます。子ノードは、使用されるときに呼び出されます。
  • 列挙型は、matchステートメントと組み合わせて使用できます。
  • ステートスクリプト自体は、実行時にノードから動的に交換できます。

このチュートリアルでは、ステートスクリプトが添付されているノードの追加と削除のみに焦点を当てます。各ステートスクリプトは、異なるステートの実装になります。

注釈

ここにステート設計パターンの概念を説明する素晴らしいリソースがあります: https://gameprogrammingpatterns.com/state.html

スクリプトのセットアップ

継承の機能は、この設計原則を始めるのに役立ちます。プレイヤーの基本機能を記述するクラスを作成する必要があります。今のところ、プレイヤーは2つのアクションに制限されます: 左に移動右に移動。これは、idlerun の2つのステートがあることを意味します。

以下は、他のすべてのステートが継承する一般的なステートです。

# state.gd

extends Node2D

class_name State

var change_state
var animated_sprite
var persistent_state
var velocity

# Writing _delta instead of delta here prevents the unused variable warning.
func _physics_process(_delta):
    persistent_state.move_and_slide(persistent_state.velocity, Vector2.UP)

func setup(change_state, animated_sprite, persistent_state):
    self.change_state = change_state
    self.animated_sprite = animated_sprite
    self.persistent_state = persistent_state

func move_left():
    pass

func move_right():
    pass

上記のスクリプトに関するいくつかの注意事項。まず、この実装は setup(change_state, animated_sprite, persistent_state) メソッドを使用して参照を割り当てます。これらの参照は、この状態の親でインスタンス化されます。これは、凝集 として知られるプログラミングの考えに役立ちます。プレイヤーの状態は、これらの変数を作成する責任を負いませんが、これらを使用できるようにしたいと考えています。ただし、これにより、ステートはステートの親に 結合 されます。これは、ステートがこれらの変数を含む親を持っているかどうかに大きく依存していることを意味します。したがって、コード管理に関しては、結合凝集 が重要な概念であることを忘れないでください。

注釈

凝集と結合の詳細については、次のページを参照してください: https://courses.cs.washington.edu/courses/cse403/96sp/couple-cohesion.html

2つ目は、スクリプトには移動のためのメソッドがいくつかありますが、実装はありません。ステートスクリプトは単に pass を使用して、メソッドが呼び出されたときに命令を実行しないことを示します。これは重要です。

Third, the _physics_process(delta) method is actually implemented here. This allows the states to have a default _physics_process(delta) implementation where velocity is used to move the player. The way that the states can modify the movement of the player is to use the velocity variable defined in their base class.

最後に、このスクリプトは実際には State という名前のクラスとして指定されています。これにより、godotで load() および preload() 関数で使用するファイルパスが不要になるため、コードのリファクタリングが容易になります。

そのため、基準ステートがあるので、前に説明した2つの状態を実装できます。

# idle_state.gd

extends State

class_name IdleState

func _ready():
    animated_sprite.play("idle")

func _flip_direction():
    animated_sprite.flip_h = not animated_sprite.flip_h

func move_left():
    if animated_sprite.flip_h:
        change_state.call_func("run")
    else:
        _flip_direction()

func move_right():
    if not animated_sprite.flip_h:
        change_state.call_func("run")
    else:
        _flip_direction()
# run_state.gd

extends State

class_name RunState

var move_speed = Vector2(180, 0)
var min_move_speed = 0.005
var friction = 0.32

func _ready():
    animated_sprite.play("run")
    if animated_sprite.flip_h:
        move_speed.x *= -1
    persistent_state.velocity += move_speed

func _physics_process(_delta):
    if abs(velocity) < min_move_speed:
         change_state.call_func("idle")
    persistent_state.velocity.x *= friction

func move_left():
    if animated_sprite.flip_h:
        persistent_state.velocity += move_speed
    else:
        change_state.call_func("idle")

func move_right():
    if not animated_sprite.flip_h:
        persistent_state.velocity += move_speed
    else:
        change_state.call_func("idle")

注釈

Run および Idle のステートは Node2D を拡張する State から拡張されるため、関数 _physics_process(delta)ボトムアップ から呼び出されます。つまり、Run および Idle_physics_process(delta) の実装を呼び出し、State はその実装を呼び出し、次にNode2Dと、独自の実装などを呼び出します。これは奇妙に思えるかもしれませんが、ready()_process(delta) などの事前定義関数にのみ関係します。独自の関数は、基本実装をオーバーライドする通常の継承ルールを使用します。

ステートインスタンスを取得するためのラウンドアバウトメソッドがあります。ステートファクトリを使用できます。

# state_factory.gd

class_name StateFactory

var states

func _init():
    states = {
        "idle": IdleState,
        "run": RunState
}

func get_state(state_name):
    if states.has(state_name):
        return states.get(state_name)
    else:
        printerr("No state ", state_name, " in state factory!")

これにより、dictionary内のステートが検索され、見つかった場合はそのステートが返されます。

すべてのステートが独自のスクリプトで定義されたので、渡された参照がどのようにインスタンス化されるかを理解する時が来ました。これらの参照は変更されないため、この新しいスクリプト persistent_state.gd を呼び出すことは理にかなっています。

# persistent_state.gd

extends KinematicBody2D

class_name PersistentState

var state
var state_factory

var velocity = Vector2()

func _ready():
    state_factory = StateFactory.new()
    change_state("idle")

# Input code was placed here for tutorial purposes.
func _process(_delta):
if Input.is_action_pressed("ui_left"):
    move_left()
elif Input.is_action_pressed("ui_right"):
    move_right()

func move_left():
    state.move_left()

func move_right():
    state.move_right()

func change_state(new_state_name):
    state.queue_free()
    state = state_factory.get_state(new_state_name).new()
    state.setup(funcref(self, "change_state"), $AnimatedSprite, self)
    state.name = "current_state"
    add_child(state)

注釈

persistent_state.gd スクリプトには、入力を検出するためのコードが含まれています。これはチュートリアルを簡単にするためでしたが、通常これを行うのはベストプラクティスではありません。

プロジェクトの設定

このチュートリアルでは、接続先のノードに、AnimatedSprite である子ノードが含まれると仮定しました。また、この AnimatedSprite には、少なくとも2つのアニメーション(待機アニメーションと走るアニメーション)があるという仮定があります。また、最上位ノードは KinematicBody2D であると想定されます。

../../_images/llama_run.gif

注釈

このチュートリアルで使用されるラマのzipファイルは、ここ です。ソースは piskel_llama からでしたが、そのページには元の作成者情報が見つかりませんでした...。スプライトアニメーションのための良いチュートリアルも既にあるので参照してください。2Dスプライトアニメーション

したがって、アタッチする必要がある唯一のスクリプトは persistent_state.gd で、これはプレイヤーの最上位ノードにアタッチする必要があります。これは KinematicBody2D です。

../../_images/state_design_node_setup.png ../../_images/state_design_complete.gif

プレイヤーは、ステート設計パターンを使用して、2つの異なるステートを実装しました。このパターンの良い点は、別のステートを追加する場合は、自分自身と他のステートにどのように変化するかにのみに焦点を当てる別のクラスを作成すれば済むということです。各ステートは機能的に分離され、動的にインスタンス化されます。