Padrão de projeto State

Introdução

Escrever um jogo pode ser difícil quando há muitos estados que necessitam ser manipulados, mas apenas um script pode ser anexado a um nó por vez. Em vez de criar uma máquina de estados no script de controle do jogador, o desenvolvimento poderia ser simplificado se os estados forem separados em diferentes classes.

Existem muitas formas de implementar uma máquina de estados com Godot, e alguns desses métodos estão logo abaixo:

  • O jogador pode ter um nó filho para cada estado, cujo são chamados quando utilizados.
  • Os enums podem ser utilizados em conjunto com uma declaração de match.
  • Os próprios scripts de estado podem ser trocados de um nó dinamicamente em tempo de execução.

Este tutorial se concentrará apenas em adicionar e remover nós que tem um script de estado anexado. Cada script de estado será uma implementação de um estado diferente.

Nota

Há um ótimo recurso explicando o conceito do padrão de projeto State aqui: https://gameprogrammingpatterns.com/state.html

Script setup

The feature of inheritance is useful for getting started with this design principle. A class should be created that describes the base features of the player. For now, a player will be limited to two actions: move left, move right. This means there will be two states: idle and run.

Logo abaixo está o estado genérico, do qual todos outros estados herdarão.

# 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

Algumas notas do script acima. Primeiro, esta implementação usa um método setup(change_state, animated_sprite, persistent_state) para atribuir referências. Essas referências serão instanciadas no pai desse estado. Isso ajuda com algo na programação conhecido como coesão. O estado do jogador não deseja a responsabilidade de criar essas variáveis, mas deseja poder utilizá-las. No entanto, isso torna o estado acoplado ao pai do estado. Isso significa que o estado é altamente dependente de ter um pai que contenha essas variáveis. Portanto, lembre-se de que acoplamento e coesão são conceitos importantes quando se trata de gerenciamento de código.

Nota

Consulte a página a seguir para obter mais detalhes sobre coesão e acoplamento: https://courses.cs.washington.edu/courses/cse403/96sp/coupling-cohesion.html

Second, there are some methods in the script for moving, but no implementation. The state script just uses pass to show that it will not execute any instructions when the methods are called. This is important.

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.

Por último, esse script está sendo designado como uma classe chamada State. Isso facilita a refatoração do código, pois o caminho do arquivo usando o as funções load() e preload() no godot não será necessário.

Portanto, agora que existe um estado base, os dois estados discutidos anteriormente podem ser implementados.

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

Nota

The since the Run and Idle states extend from State which extends Node2D, the function _physics_process(delta) is called from the bottom-up meaning Run and Idle will call their implementation of _physics_process(delta), then State will call its implementation, then Node2D will call its own implementation and so on. This may seem strange, but it is only relevant for predefined functions such as _ready(), _process(delta), etc. Custom functions use the normal inheritance rules of overriding the base implementation.

Existe um método alternativo para obter uma instância de estado. Uma fábrica de estado pode ser usada.

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

Isso procurará por estados em um dicionário e retornará o estado, se encontrado.

Agora que todos os estados estão definidos com seus próprios scripts, é hora de descobrir como as referências que passaram para eles serão instanciadas. Como essas referências não mudam, faz sentido chamar esse novo script de 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)

Nota

O script persistent_state.gd contém código para detecção de entrada. Isso foi feito para simplificar o tutorial, mas geralmente não é uma prática recomendada fazer isso.

Configuração do projeto

This tutorial made an assumption that the node it would be attached to contained a child node which is an AnimatedSprite. There is also the assumption that this AnimatedSprite has at least two animations, the idle and run animations. Also, the top-level node is assumed to be a KinematicBody2D.

../../_images/llama_run.gif

Nota

O arquivo zip da lhama usada neste tutorial é aqui. A fonte era de piskel_llama, mas não consegui encontrar as informações originais do criador nessa página... Também há um bom tutorial para animação sprite. Veja 2D Sprite Animation.

Portanto, o único script que deve ser anexado é o persistent_state.gd, que deve ser anexado ao nó superior do player, que é KinematicBody2D.

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

Agora o jogador utilizou o padrão de projeto State para implementar seus dois estados diferentes. A parte boa desse padrão é que, se alguém quiser adicionar outro estado, isso envolveria a criação de outra classe que só precisa se concentrar em si mesma e em como ela muda para outro estado. Cada estado é uma funcionalidade separada e instanciada dinamicamente.