Tyrannoseanus Tyrannoseanus 5 minute read

More State Machines For Rex's Ranch.

Spending more time changing the state machines in Rex’s Ranch for fun and profit. Well… just fun I guess, there’s no profit here and there won’t be if I keep refactoring my state machines instead of implementing the game.

I thought I was joking in my last “next time” line but apparently not, I am really enjoying state machines right now.

So in the previous example, we moved one of the task classes over to be represented more closely with a standard definition of a state machine - using terms like State, Transition, and Event. However, everything was defined separately in each of the Task classes and didn’t “have to” follow the same kind of pattern. We were also missing some of the other concepts that maybe we didn’t need, but could have made some parts of the state machine easier to implement.

For example - when catching fish we had a couple of “duplicate” states and events that differed in name, as we didn’t have the concept of a Guard or event parameters to work from. This wasn’t the worst thing in the world, but adding these concepts would make the implementation neater.

I also took this chance to move to a more “formal” state machine executor that could parse a definition and do what needed to be done rather than reimplement a whole state machine in each and every task.

This is still a work in progress and doesn’t have everything it needs yet to be reusable across everything, but it’s getting there. Here’s the new StateMachine that takes the definition and reacts to events to transition to the next state.

class_name StateMachine extends RefCounted

var _current_state: State
var _event_queue: Array = []
var _states: Dictionary = {}


func _init(definition: Dictionary):
	_load_definition(definition)
	set_initial_state(definition.get("initialState"))


func _load_definition(definition: Dictionary):
	for state_name in definition["states"]:
		var state_obj := State.new(state_name)
		var state_data: Dictionary = definition["states"][state_name]

		state_obj.is_final = state_data.get("is_final", false)
		_states[state_name] = state_obj

	for state_name in definition["states"]:
		var state_data = definition["states"][state_name]
		var state_obj = _states[state_name]

		if state_data.has("on"):
			for event in state_data["on"]:
				var transition_data = state_data["on"][event]
				_load_transitions(state_obj, event, transition_data)


func _load_transitions(state_obj: State, event, transition_data) -> void:
	if transition_data is Array:
		for transition in transition_data:
			var target_state = _states[transition["target"]]
			var action_callable = transition.get("action", Callable())
			var guard_callable = transition.get("guard", Callable())
			state_obj.add_transition(event, target_state, action_callable, guard_callable)
	else:
		var target_state = _states[transition_data["target"]]
		var action_callable = transition_data.get("action", Callable())
		var guard_callable = transition_data.get("guard", Callable())
		state_obj.add_transition(event, target_state, action_callable, guard_callable)


func process_next_event() -> void:
	if not _event_queue.is_empty():
		var event_to_process = _event_queue.pop_front()
		_handle_transition(event_to_process)


func raise_event(event: int) -> void:
	_event_queue.push_back(event)


func get_current_state() -> State:
	return _current_state


func set_initial_state(state_name: String) -> void:
	if _states.has(state_name):
		_current_state = _states[state_name]
	else:
		push_error("StateMachine: Initial state not found: ", state_name)


func _handle_transition(event: int) -> void:
	var transitions = _current_state.transitions.get(event)
	if not transitions:
		return

	if transitions is Array:
		for transition in transitions:
			if not transition.guard.is_valid() or transition.guard.call():
				_execute_transition(transition)
				return
	else:
		if not transition.guard.is_valid() or transition.guard.call():
		 _execute_transition(transitions)


func _execute_transition(transition: Transition) -> void:
	if transition.action.is_valid():
		transition.action.call()
	_current_state = transition.target_state


class State extends RefCounted:

	var name: String
	var transitions: Dictionary = {}
	var is_final: bool = false

	func _init(state_name: String):
		self.name = state_name

	func add_transition(event: int, target_state: State, action: Callable, guard: Callable):
		if transitions.has(event):
			if not transitions[event] is Array:
				transitions[event] = [transitions[event]]
			transitions[event].append(Transition.new(target_state, action, guard))
		else:
			transitions[event] = Transition.new(target_state, action, guard)


class Transition extends RefCounted:

	var target_state: State
	var action: Callable
	var guard: Callable

	func _init(target: State, action_callable: Callable = Callable(), guard_callable: Callable = Callable()):
		self.target_state = target
		self.action = action_callable
		self.guard = guard_callable

Ah that’s a lot of lines, but it means less lines elsewhere? And a more consistent approach across the codebase.

So we’ve still got State, Transition, Event, and Action that we had before. We’ve added Guard to ensure we pick the correct transition off the back of an event. More importantly, this lets us define our state machine configuration as data that we can just pass into the StateMachine class and have it do everything we need.

Guards and actions are still implemented in the tasks which we could improve a little bit - maybe in the future. Here’s what a task looks like now, using the ChopWoodTask from before to show the difference:

class_name ChopWoodTask extends Task

const NAME := "ChopWoodTask"
const TYPE := "direct"

enum Event {
	TASK_STARTED, ARRIVED_AT_TARGET, DISTANCE_INCREASED, TREE_CHOPPED_DOWN, CHOP_STARTED, CHOP_FINISHED
}

var _fsm: StateMachine = StateMachine.new({
	"initialState": "IDLE",
	"states": {
		"IDLE": {
			"on": {
				Event.TASK_STARTED: {"target": "MOVING", "action": _move},
			}
		},
		"MOVING": {
			"on": {
				Event.ARRIVED_AT_TARGET: {"target": "CHOPPING", "action": _chop},
			}
		},
		"CHOPPING": {
			"on": {
				Event.CHOP_STARTED: {"target": "COOLDOWN"},
				Event.DISTANCE_INCREASED: {"target": "MOVING", "action": _move},
			}
		},
		"COOLDOWN": {
			"on": {
				Event.CHOP_FINISHED: {"target": "CHOPPING", "action": _chop},
				Event.TREE_CHOPPED_DOWN: {"target": "COMPLETE"}
			}
		},
		"COMPLETE": {
			"is_final": true
		}
	}
})

var _target_tree: FarmTree


func _init(tree: FarmTree):
	_target_tree = tree
	SignalBus.tree_chopped.connect(_on_tree_chopped)


func execute(_delta: float) -> int:
	if !is_instance_valid(_target_tree):
		return BTNode.BTStatus.SUCCESS

	_raise_internal_events()
	_fsm.process_next_event()

	if _fsm.get_current_state().is_final:
		return BTNode.BTStatus.SUCCESS

	return BTNode.BTStatus.RUNNING


func _raise_internal_events() -> void:
	match _fsm.get_current_state().name:
		"IDLE":
			_fsm.raise_event(Event.TASK_STARTED)
		"MOVING":
			if actor.navigation_finished():
				_fsm.raise_event(Event.ARRIVED_AT_TARGET)


func _on_tree_chopped(tree: FarmTree) -> void:
	if tree == _target_tree:
		_fsm.raise_event(Event.TREE_CHOPPED_DOWN)


func _move() -> void:
	actor.move_to(_target_tree.global_position)


func _chop() -> void:
	if actor.global_position.distance_squared_to(_target_tree.global_position) > 1000:
		_fsm.raise_event(Event.DISTANCE_INCREASED)
		return
	actor.chop_wood(_target_tree)
	_fsm.raise_event(Event.CHOP_STARTED)
	GlobalTimer.create_timer(1.0).timeout.connect(_fsm.raise_event.bind(Event.CHOP_FINISHED))

Uh probably a bad example actually as this doesn’t show the new stuff like guards but that’s fine - we can see how this state machine definition fits into the StateMachine and has the same stuff and the same behaviour it had before, and we can look at guards in more detail later.

Still using an enum for the event because I didn’t think of a good way to incorporate those into the state machine definition without then having to reference them by name across different places, but either way it’s kind of the same, as some events are outside the realm of the state machine anyway.

So this is working the same way as before in regards to behaviour - when we’re in the IDLE state, a TASK_STARTED event will trigger a transition to the MOVING state, whilst in the MOVING state we’ll be checking where we are and raise the ARRIVED_AT_TARGET event when we arrive at the target, which will trigger the CHOPPING state, and so on. All of this is controlledvia the data in the state machine, and the events that are raised from actions and the environment of the state machine.

I want to add some more stuff to guards so they don’t need to be specific to the task classes any more, so maybe we’ll look at this yet again sometime soon.