godot-parkour/addons/godot_state_charts/compound_state.gd

246 lines
8.6 KiB
GDScript3
Raw Permalink Normal View History

2024-02-23 20:37:00 +00:00
@tool
@icon("compound_state.svg")
## A compound state is a state that has multiple sub-states of which exactly one can
## be active at any given time.
class_name CompoundState
extends State
## Called when a child state is entered.
signal child_state_entered()
## Called when a child state is exited.
signal child_state_exited()
## The initial state which should be activated when this state is activated.
@export_node_path("State") var initial_state:NodePath:
get:
return initial_state
set(value):
initial_state = value
update_configuration_warnings()
## The currently active substate.
var _active_state:State = null
## The initial state
@onready var _initial_state:State = get_node_or_null(initial_state)
## The history states of this compound state.
var _history_states:Array[HistoryState] = []
## Whether any of the history states needs a deep history.
var _needs_deep_history = false
func _state_init():
super._state_init()
# check if we have any history states
for child in get_children():
if child is HistoryState:
var child_as_history_state:HistoryState = child as HistoryState
_history_states.append(child_as_history_state)
# remember if any of the history states needs a deep history
_needs_deep_history = _needs_deep_history or child_as_history_state.deep
# initialize all substates. find all children of type State and call _state_init on them.
for child in get_children():
if child is State:
var child_as_state:State = child as State
child_as_state._state_init()
child_as_state.state_entered.connect(func(): child_state_entered.emit())
child_as_state.state_exited.connect(func(): child_state_exited.emit())
func _state_enter(expect_transition:bool = false):
super._state_enter()
# activate the initial state unless we expect a transition
if not expect_transition:
if _initial_state != null:
_active_state = _initial_state
_active_state._state_enter()
else:
push_error("No initial state set for state '" + name + "'.")
func _state_step():
super._state_step()
if _active_state != null:
_active_state._state_step()
func _state_save(saved_state:SavedState, child_levels:int = -1):
super._state_save(saved_state, child_levels)
# in addition save all history states, as they are never active and normally would not be saved
var parent = saved_state.get_substate_or_null(self)
if parent == null:
push_error("Probably a bug: The state of '" + name + "' was not saved.")
return
for history_state in _history_states:
history_state._state_save(parent, child_levels)
func _state_restore(saved_state:SavedState, child_levels:int = -1):
super._state_restore(saved_state, child_levels)
# in addition check if we are now active and if so determine the current active state
if active:
# find the currently active child
for child in get_children():
if child is State and child.active:
_active_state = child
break
func _state_exit():
# if we have any history states, we need to save the current active state
if _history_states.size() > 0:
var saved_state = SavedState.new()
# we save the entire hierarchy if any of the history states needs a deep history
# otherwise we only save this level. This way we can save memory and processing time
_state_save(saved_state, -1 if _needs_deep_history else 1)
# now save the saved state in all history states
for history_state in _history_states:
# when saving history it's ok when we save deep history in a history state that doesn't need it
# because at restore time we will use the state's deep flag to determine if we need to restore
# the entire hierarchy or just this level. This way we don't need multiple copies of the same
# state hierarchy.
history_state.history = saved_state
# deactivate the current state
if _active_state != null:
_active_state._state_exit()
_active_state = null
super._state_exit()
func _process_transitions(event:StringName, property_change:bool = false) -> bool:
if not active:
return false
# forward to the active state
if is_instance_valid(_active_state):
if _active_state._process_transitions(event, property_change):
# emit the event_received signal, unless this is a property change
if not property_change:
self.event_received.emit(event)
return true
# if the event was not handled by the active state, we handle it here
# base class will also emit the event_received signal
return super._process_transitions(event, property_change)
func _handle_transition(transition:Transition, source:State):
# print("CompoundState._handle_transition: " + name + " from " + source.name + " to " + str(transition.to))
# resolve the target state
var target = transition.resolve_target()
if not target is State:
push_error("The target state '" + str(transition.to) + "' of the transition from '" + source.name + "' is not a state.")
return
# the target state can be
# 0. this state. in this case exit this state and re-enter it. This can happen when
# a child state transfers to its parent state.
# 1. a direct child of this state. this is the easy case in which
# we will deactivate the current _active_state and activate the target
# 2. a descendant of this state. in this case we find the direct child which
# is the ancestor of the target state, activate it and then ask it to perform
# the transition.
# 3. no descendant of this state. in this case, we ask our parent state to
# perform the transition
if target == self:
# exit this state and re-enter it
_state_exit()
_state_enter(false)
return
if target in get_children():
# all good, now first deactivate the current state
if is_instance_valid(_active_state):
_active_state._state_exit()
# now check if the target is a history state, if this is the
# case, we need to restore the saved state
if target is HistoryState:
# print("Target is history state, restoring saved state.")
var saved_state = target.history
if saved_state != null:
# restore the saved state
_state_restore(saved_state, -1 if target.deep else 1)
return
# print("No history saved so far, activating default state.")
# if we don't have history, we just activate the default state
var default_state = target.get_node_or_null(target.default_state)
if is_instance_valid(default_state):
_active_state = default_state
_active_state._state_enter()
return
else:
push_error("The default state '" + target.default_state + "' of the history state '" + target.name + "' cannot be found.")
return
# else, just activate the target state
_active_state = target
_active_state._state_enter()
return
if self.is_ancestor_of(target):
# find the child which is the ancestor of the new target.
for child in get_children():
if child is State and child.is_ancestor_of(target):
# found it.
# change active state if necessary
if _active_state != child:
if is_instance_valid(_active_state):
_active_state._state_exit()
_active_state = child
# set the "expect_transition" flag to true because we will send
# the transition to the child state right after we activate it.
# this avoids the child needlessly entering the initial state
_active_state._state_enter(true)
# ask child to handle the transition
child._handle_transition(transition, source)
return
return
# ask the parent
get_parent()._handle_transition(transition, source)
func add_child(node:Node, force_readable_name:bool = false, internal:InternalMode = INTERNAL_MODE_DISABLED) -> void:
super.add_child(node, force_readable_name, internal)
# when a child is added in the editor and the child is a state
# and we don't have an initial state yet, set the initial state
# to the newly added child
if Engine.is_editor_hint() and node is State:
if initial_state.is_empty():
# the newly added node may have a random name now,
# so we need to defer the call to build a node path
# to the next frame, so the editor has time to rename
# the node to its final name
(func(): initial_state = get_path_to(node)).call_deferred()
func _get_configuration_warnings() -> PackedStringArray:
var warnings = super._get_configuration_warnings()
# count the amount of child states
var child_count = 0
for child in get_children():
if child is State:
child_count += 1
if child_count < 2:
warnings.append("Compound states should have at two child states.")
var the_initial_state = get_node_or_null(initial_state)
if not is_instance_valid(the_initial_state):
warnings.append("Initial state could not be resolved, is the path correct?")
elif the_initial_state.get_parent() != self:
warnings.append("Initial state must be a direct child of this compound state.")
return warnings