Problem with using yield() in conjunction with finite state machine

:information_source: Attention Topic was automatically imported from the old Question2Answer platform.
:bust_in_silhouette: Asked By AUD_FOR_IUV

I’m making a simple 2D sidescrolling platformer using Godot 3.2, and I’m using a basic finite state machine to manage the different player states. I want to use signals in conjunction with this state machine to allow for “cutscene”-style effects, where the player character transitions through potentially several states as part of a predetermined sequence. For example, when the player approaches a save point and hits the save prompt, I want to disable manual control of the player character, move the player character to a specific point on the screen, initiate other effects like particles and lighting, and finally return control of player character to the player.

However, I’m running into an issue where attempting to yield on the signals from the player in this case runs into an infinite loop that I can’t seem to diagnose. I’ve managed to recreate this effect in a minimum working example project linked here.

The important bits of code are the player script:

extends KinematicBody2D
class_name Player

signal state_changed(old_state_enum, new_state_enum)

enum State {
    NO_CHANGE,
    IDLE,
    WALK,
    WALK_TO_POINT,
}

onready var states: Dictionary = {
    State.IDLE:          $States/Idle,
    State.WALK:          $States/Walk,
    State.WALK_TO_POINT: $States/WalkToPoint,
}

var current_state: Node = null
var current_state_enum: int = -1

func _ready() -> void:
    current_state_enum = State.IDLE
    current_state = states[current_state_enum]
    change_state({'new_state': State.IDLE})

func _unhandled_input(event: InputEvent) -> void:
    var new_state_dict = current_state.handle_input(self, event)
    if new_state_dict['new_state'] != State.NO_CHANGE:
        change_state(new_state_dict)

func _physics_process(delta: float) -> void:
    var new_state_dict = current_state.process(self, delta)
    if new_state_dict['new_state'] != State.NO_CHANGE:
        change_state(new_state_dict)

func change_state(new_state_dict: Dictionary) -> void:
    var old_state_enum := current_state_enum
    var new_state_enum: int = new_state_dict['new_state']

    # Before passing along the new_state_dict to the new state (since we want
    # any additional metadata keys passed too), rename the 'new_state' key to
    # 'previous_state'.
    new_state_dict.erase('new_state')
    new_state_dict['previous_state'] = old_state_enum

    current_state.exit(self)
    current_state_enum = new_state_enum
    current_state = states[new_state_enum]
    current_state.enter(self, new_state_dict)

    emit_signal('state_changed', old_state_enum, new_state_enum)

The WalkToPoint state script (the state used in the cutscene example):

extends Node

signal walked_to_point

const SPEED := 6 * 16.0

var velocity := Vector2.ZERO

var x_point := 0.0
var direction_to_x_point := 0

func enter(player: Player, previous_state_dict: Dictionary) -> void:
    assert('x_point' in previous_state_dict)
    x_point = previous_state_dict['x_point']
    direction_to_x_point = int(sign((x_point - player.global_position.x)))
    print('direction to x point: ', direction_to_x_point)

func exit(player: Player) -> void:
    emit_signal('walked_to_point')

func handle_input(player: Player, event: InputEvent) -> Dictionary:
    return {'new_state': Player.State.NO_CHANGE}

func process(player: Player, delta: float) -> Dictionary:
    match direction_to_x_point:
        0: # Already at x point
            return {'new_state': Player.State.IDLE}
        -1: # Left
            if player.global_position.x <= x_point:
                return {'new_state': Player.State.IDLE}
        1: # Right
            if player.global_position.x >= x_point:
                return {'new_state': Player.State.IDLE}

    velocity = player.move_and_slide(Vector2(SPEED * direction_to_x_point, 0), Vector2.UP)

    return {'new_state': Player.State.NO_CHANGE}

and the main World script that handles running the “cutscene” when the player presses “ui_down”:

extends Node2D

# Controls:
#    * "ui_left"/"ui_right" to move the character left and right
#    * "ui_up" to move the character to the center of the screen via the WALK_TO_POINT state
#    * "ui_down" to initiate the "cutscene" and move the player to various points while changing color

func _ready() -> void:
    $Player.connect('state_changed', self, '_on_player_state_changed')

func _input(event: InputEvent) -> void:
    if event.is_action_pressed('ui_down'):
        set_process_input(false)

        cutscene()

        set_process_input(true)

func cutscene() -> void:
    # Move player to left side of the screen.
    $Player.change_state({
        'new_state': Player.State.WALK_TO_POINT,
        'x_point': 0.0
    })
    yield($Player/States/WalkToPoint, 'walked_to_point')

    # Change player color to green.
    $Player.modulate = Color.green

    # Move player to right side of the screen.
    $Player.change_state({
        'new_state': Player.State.WALK_TO_POINT,
        'x_point': 320.0
    })
    yield($Player/States/WalkToPoint, 'walked_to_point')

    # Change player color to red.
    $Player.modulate = Color.red

    # Move player back to center of the screen.
    $Player.change_state({
        'new_state': Player.State.WALK_TO_POINT,
        'x_point': 160.0
    })
    yield($Player/States/WalkToPoint, 'walked_to_point')

    # Remove color from player.
    $Player.modulate = Color.white

func _on_player_state_changed(old_state_enum: int, new_state_enum: int) -> void:
    $Labels/CurrentState.text = 'Current state: ' + $Player.states[new_state_enum].get_name()
    $Labels/PreviousState.text = 'Previous state: ' + $Player.states[old_state_enum].get_name()

Whenever I press “ui_down” to start the cutscene, the cutscene code crashes once it reaches the left side of the screen, and seems to enter an infinite recursive loop that blows up the stack (seems to continuously yield, then change state, then yield, then change state…)

Any help with this would be greatly appreciated!

:bust_in_silhouette: Reply From: SpkingR

This works:

$Player.modulate = Color.green
yield(self.get_tree(), 'idle_frame')  # new line added

$Player.modulate = Color.red
yield(self.get_tree(), 'idle_frame')  # new line added

Interesting, so I have to wait a frame after the initial walked_to_point yield to make sure the state change has actually happened then (or something like that).

Thanks for your help!

AUD_FOR_IUV | 2020-02-09 17:06