Modèle de conception état

Introduction

Écrire les scripts d'un jeu peut être difficile lorsqu'il y a de nombreux états à gérer, mais un seul script peut être attaché à un nœud à la fois. Au lieu de créer une machine à états dans le script de contrôle du joueur, le développement serait plus simple si les états étaient séparés en différentes classes.

Il existe de nombreuses façons de mettre en place une machine à état avec Godot, et quelques autres méthodes sont présentées ci-dessous :

  • Le joueur peut avoir un nœud enfant pour chaque état, qui sont appelés lorsqu'ils sont utilisés.
  • Les énumérations peuvent être utilisées en conjonction avec une instruction match.
  • Les scripts d'état eux-mêmes peuvent être échangés dynamiquement à partir d'un nœud au moment de l'exécution.

Ce tutoriel se concentrera uniquement sur l'ajout et la suppression des nœuds auxquels un script d'état est attaché. Chaque script d'état sera une implémentation d'un état différent.

Note

Il existe une excellente ressource expliquant le concept de modèle de conception état ici : https://gameprogrammingpatterns.com/state.html

Configuration du script

La caractéristique de l'héritage est utile pour commencer à appliquer ce principe de conception. Une classe doit être créée qui décrit les caractéristiques de base du joueur. Pour l'instant, un joueur sera limité à deux actions : move left, move right. Cela signifie qu'il y aura deux états : idle et run.

Voici l'état générique, dont tous les autres états hériteront.

# 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

Quelques notes sur le script ci-dessus. Premièrement, cette implémentation utilise une méthode setup(change_state, animated_sprite, persistent_state) pour assigner les références. Ces références seront instanciées dans le parent de cet état. Cela aide à quelque chose appelé en programmation cohesion. L'état du joueur ne veut pas avoir la responsabilité de créer ces variables, mais il veut pouvoir les utiliser. Cependant, cela rend l'état couplé au parent de l'état. Cela signifie que l'état dépend fortement du fait qu'il ait un parent qui contient ces variables. Donc, rappelez-vous que le couplage et la cohésion sont des concepts importants lorsqu'il s'agit de la gestion du code.

Note

Voir la page suivante pour plus de détails sur la cohésion et le couplage : https://courses.cs.washington.edu/courses/cse403/96sp/coupling-cohesion.html

Deuxièmement, le scénario prévoit certaines méthodes de déplacement, mais aucune implementation. Le script d'état utilise simplement pass pour montrer qu'il n'exécutera aucune instruction lorsque les méthodes sont appelées. Ceci est important.

Troisièmement, la méthode physics_process(delta) est implémentée ici. Cela permet aux états d'avoir une implémentation par défaut de phyics_process(delta) où la velocity est utilisée pour déplacer le joueur. La façon dont les états peuvent modifier le mouvement du joueur est d'utiliser la variable velocity définie dans leur classe de base.

Enfin, ce script est en fait désigné comme une classe nommée State. Cela rend la refactorisation du code plus facile, puisque le chemin d'accès au fichier ne sera pas nécessaire pour utiliser les fonctions load() et preload() dans godot.

Ainsi, maintenant qu'il existe un état de base, les deux états dont il a été question plus haut peuvent être mis en œuvre.

# 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")

Note

Puisque les états Run et Idle s'étendent à partir de State qui étend de Node2D, la fonction _physics_process(delta) est appelée à partir de bottom-up, ce qui signifie Run et Idle appelleront leur implémentation de _physics_process(delta), puis State appellera sot implémentation, puis Node2D` appellera sa propre implémentation et ainsi de suite. Cela peut sembler étrange, mais cela n'est pertinent que pour des fonctions prédéfinies telles que _ready(), _process(delta), etc. Les fonctions personnalisées utilisent les règles d'héritage normales qui consistent à surcharger l'implémentation de base.

Il existe une méthode indirecte pour obtenir une instance d'état. Une usine à état peut être utilisée.

# 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!")

Cela recherchera les états dans un dictionnaire et retournera l'état s'il est trouvé.

Maintenant que tous les états sont définis avec leurs propres scripts, il est temps de comprendre comment les références qui leur sont passées seront instanciées. Étant donné que ces références ne changeront pas, il est logique d'appeler ce nouveau script 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)

Note

Le script persistent_state.gd contient du code pour détecter les entrées. C'était pour rendre le tutoriel simple, mais ce n'est généralement pas la meilleure pratique de le faire.

Configuration du projet

Ce tutoriel suppose que le noeud auquel il est attaché contient un noeud enfant qui est un AnimatedSprite. Il y a aussi l'hypothèse que ce AnimatedSprite a au moins deux animations, l' animations= au repos et celle de cours. De plus, le noeud de plus haut niveau est supposé être un KinematicBody2D.

../../_images/llama_run.gif

Note

Le fichier zip du llama utilisé dans ce tutoriel est ici. La source provient de piskel_llama, mais je n'ai pas pu trouver d'informations sur le créateur original sur cette page... Il y a aussi déjà un bon tutoriel sur l'animation de sprites. Voir 2D Sprite Animation.

Ainsi, le seul script qui doit être attaché est persistent_state.gd, qui doit être attaché au noeud de plus au niveau du joueur, qui est un KinematicBody2D.

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

Maintenant, le joueur a utilisé le modèle de conception d'état pour implémenter ses deux états différents. La bonne partie de ce modèle est que si l'on voulait ajouter un autre état, cela impliquerait de créer une autre classe qui ne doit se concentrer que sur elle-même et sur la façon dont elle passe à un autre état. Chaque état est fonctionnellement séparé et instancié dynamiquement.