diff --git a/addons/godot_state_charts/animation_tree_state.svg b/addons/godot_state_charts/animation_tree_state.svg new file mode 100644 index 0000000..432dac9 --- /dev/null +++ b/addons/godot_state_charts/animation_tree_state.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/godot_state_charts/animation_tree_state.svg.import b/addons/godot_state_charts/animation_tree_state.svg.import new file mode 100644 index 0000000..6d03cb5 --- /dev/null +++ b/addons/godot_state_charts/animation_tree_state.svg.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/animation_tree_state.svg-b99077fc178cfa1e23b2c854c7735c4a.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot_state_charts/animation_tree_state.svg" +dest_files=[ "res://.import/animation_tree_state.svg-b99077fc178cfa1e23b2c854c7735c4a.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=false +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/godot_state_charts/atomic_state.gd b/addons/godot_state_charts/atomic_state.gd new file mode 100644 index 0000000..4344a31 --- /dev/null +++ b/addons/godot_state_charts/atomic_state.gd @@ -0,0 +1,23 @@ +tool +class_name AtomicState, "atomic_state.svg" +extends State + +func _handle_transition(transition: Transition, source: State) -> void: + # resolve target state + var target = transition.resolve_target() + if not target is State: + push_error("the target state: %s of transition from state: %s is not a state" % [str(transition.to), source.name]) + return + # atomic states can't transition, gotta ask mommy + get_parent()._handle_transition(transition, source) + +func _get_configuration_warning() -> String: + var warning := ._get_configuration_warning() + if not warning.empty(): + return warning + + for child in get_children(): + if not child is Transition: + return "atomic states cannot have children other than transitions" + + return "" diff --git a/addons/godot_state_charts/atomic_state.svg b/addons/godot_state_charts/atomic_state.svg new file mode 100644 index 0000000..85aba01 --- /dev/null +++ b/addons/godot_state_charts/atomic_state.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/godot_state_charts/atomic_state.svg.import b/addons/godot_state_charts/atomic_state.svg.import new file mode 100644 index 0000000..3121bfc --- /dev/null +++ b/addons/godot_state_charts/atomic_state.svg.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/atomic_state.svg-5ab16e5747cef5b5980c4bf84ef9b1af.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot_state_charts/atomic_state.svg" +dest_files=[ "res://.import/atomic_state.svg-5ab16e5747cef5b5980c4bf84ef9b1af.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=false +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/godot_state_charts/compound_state.gd b/addons/godot_state_charts/compound_state.gd new file mode 100644 index 0000000..bc4dd3a --- /dev/null +++ b/addons/godot_state_charts/compound_state.gd @@ -0,0 +1,104 @@ +tool +class_name CompoundState, "compound_state.svg" +extends State + +## initial state to activate when state is activated +export var initial_state: NodePath setget _set_initial_state + +var _active_state: State = null + +onready var _initial_state: State = get_node_or_null(initial_state) + +func _set_initial_state(value: NodePath) -> void: + initial_state = value + update_configuration_warning() + +func _state_init() -> void: + ._state_init() + + for child in get_children(): + if child is State: + child._state_init() + +func _state_enter() -> void: + ._state_enter() + + # activate initial state + if _initial_state != null: + _active_state = _initial_state + _active_state._state_enter() + else: + push_error("no initial state set for state %s" % name) + +func _state_exit() -> void: + # deactivate current state + if _active_state != null: + _active_state._state_exit() + _active_state = null + ._state_exit() + +func _state_event(event: String) -> bool: + if not active: + return false + + # forward event to active state + if is_instance_valid(_active_state): + if _active_state._state_event(event): + emit_signal("event_received", event) + return true + + # if event not handled by active state, handle here + return ._state_event(event) + +func _handle_transition(transition: Transition, source: State) -> void: + var target: State = transition.resolve_target() + if not target is State: + push_error("the target state: %s of transition from state: %s is not a state" % [str(transition.to), source.name]) + return + + # if direct child, just switch active state + if target in get_children(): + # deactivate current state + if is_instance_valid(_active_state): + _active_state._state_exit() + # activate target state + _active_state = target + _active_state._state_enter() + return + + # if ancestor, activate next state down and let it handle transition further + if self.is_a_parent_of(target): + # find which child is also ancestor + for child in get_children(): + if child.is_parent_of(target): + # change state if necessary + if _active_state != child: + if is_instance_valid(_active_state): + _active_state._state_exit() + _active_state = child + _active_state._state_enter() + child._handle_transition(transition, source) + return + return + + # target is a cousin, defer to mommy + get_parent()._handle_transition(transition, source) + +func _get_configuration_warning() -> String: + var warning := ._get_configuration_warning() + if not warning.empty(): + return warning + + if get_child_count() == 0: + return "compound states must have at least one child state" + + var child_state = get_node_or_null(initial_state) + + if not is_instance_valid(child_state): + return "initial state not found, is the path correct?" + if child_state.get_parent() != self: + return "initial state must be a direct child of this compound state" + if not child_state is State: + return "initial state must be a State" + + return "" diff --git a/addons/godot_state_charts/compound_state.svg b/addons/godot_state_charts/compound_state.svg new file mode 100644 index 0000000..5a3c507 --- /dev/null +++ b/addons/godot_state_charts/compound_state.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/godot_state_charts/compound_state.svg.import b/addons/godot_state_charts/compound_state.svg.import new file mode 100644 index 0000000..14d57d6 --- /dev/null +++ b/addons/godot_state_charts/compound_state.svg.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/compound_state.svg-84780d78ec1f15e1cbb9d20f4df031a7.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot_state_charts/compound_state.svg" +dest_files=[ "res://.import/compound_state.svg-84780d78ec1f15e1cbb9d20f4df031a7.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=false +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/godot_state_charts/godot_state_charts.gd b/addons/godot_state_charts/godot_state_charts.gd new file mode 100644 index 0000000..5e8aeaa --- /dev/null +++ b/addons/godot_state_charts/godot_state_charts.gd @@ -0,0 +1,10 @@ +tool +extends EditorPlugin + +# plugin initialization +func _enter_tree() -> void: + pass + +# plugin cleanup +func _exit_tree() -> void: + pass diff --git a/addons/godot_state_charts/history_state.svg b/addons/godot_state_charts/history_state.svg new file mode 100644 index 0000000..5bc1314 --- /dev/null +++ b/addons/godot_state_charts/history_state.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/godot_state_charts/history_state.svg.import b/addons/godot_state_charts/history_state.svg.import new file mode 100644 index 0000000..2617264 --- /dev/null +++ b/addons/godot_state_charts/history_state.svg.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/history_state.svg-7ed355ddc4d844fa3139e70c23187edd.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot_state_charts/history_state.svg" +dest_files=[ "res://.import/history_state.svg-7ed355ddc4d844fa3139e70c23187edd.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=false +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/godot_state_charts/parallel_state.gd b/addons/godot_state_charts/parallel_state.gd new file mode 100644 index 0000000..2b8d4df --- /dev/null +++ b/addons/godot_state_charts/parallel_state.gd @@ -0,0 +1,53 @@ +tool +class_name ParallelState, "parallel_state.svg" +extends State + +var _sub_states: Array = [] + +func _state_init() -> void: + ._state_init() + + for child in get_children(): + if child is State: + _sub_states.append(child) + child._state_init() + +func _state_enter() -> void: + ._state_enter() + # enter all children + for child in _sub_states: + child._state_enter() + +func _state_exit() -> void: + # exit all children + for child in _sub_states: + child._state_exit() + ._state_exit() + +func _state_event(event: String) -> bool: + if not active: + return false + + # forward to all children + var handled := false + for child in _sub_states: + var child_handled = child._state_event(event) + handled = handled or child_handled + + # if child handled event, no more touchy + if handled: + emit_signal("event_received", event) + return true + + # otherwise handle ourselves + return ._state_event(event) + +func _get_configuration_warning() -> String: + var warning := ._get_configuration_warning() + if not warning.empty(): + return warning + + if get_child_count() == 0: + return "parallel states should have at least one child state" + + return "" diff --git a/addons/godot_state_charts/parallel_state.svg b/addons/godot_state_charts/parallel_state.svg new file mode 100644 index 0000000..1f5e220 --- /dev/null +++ b/addons/godot_state_charts/parallel_state.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/godot_state_charts/parallel_state.svg.import b/addons/godot_state_charts/parallel_state.svg.import new file mode 100644 index 0000000..f24030c --- /dev/null +++ b/addons/godot_state_charts/parallel_state.svg.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/parallel_state.svg-33f40e94bafae79f072d67563e0adcd3.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot_state_charts/parallel_state.svg" +dest_files=[ "res://.import/parallel_state.svg-33f40e94bafae79f072d67563e0adcd3.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=false +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/godot_state_charts/plugin.cfg b/addons/godot_state_charts/plugin.cfg new file mode 100644 index 0000000..b2ad71a --- /dev/null +++ b/addons/godot_state_charts/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot State Charts" +description="A simple, yet powerful state charts library for Godot" +author="derkork" +version="0.0.1" +script="godot_state_charts.gd" diff --git a/addons/godot_state_charts/state.gd b/addons/godot_state_charts/state.gd new file mode 100644 index 0000000..ffef661 --- /dev/null +++ b/addons/godot_state_charts/state.gd @@ -0,0 +1,126 @@ +tool +class_name State +extends Node + +## called when state is entered +signal state_entered() + +## called when state is exited +signal state_exited() + +## called when state recieves an event while active +signal event_received(event) + +## called when the state is processing +signal state_processing(delta) + +## called when the state is physics_processing +signal state_physics_processing(delta) + +## processing mode +enum ProcessMode {IDLE, PHYSICS} + +## whether to process transition delays during physics or idle frames +export (ProcessMode) var transition_process_mode: int = ProcessMode.PHYSICS + +## whether the current state is active +var active: bool setget _set_active +func _set_active(value: bool): + active = value + set_process(value) + set_physics_process(value) + +## all of the state's transitions +var _transitions: Array = [] +## queued transition to take +var _queued_transition: Transition = null +## time until queued transition is taken +var _queued_transition_time: float = 0.0 + +## called when building the state chart +func _state_init() -> void: + _set_active(false) + + # get references to transitions + _transitions.clear() + for child in get_children(): + if child is Transition: + _transitions.append(child as Transition) + +## called when state is entered +func _state_enter() -> void: + _set_active(true) + emit_signal("state_entered") + + # check eventless transitions + for transition in _transitions: + if not transition.has_event() and transition.evaluate_guard(): + # first match is taken + _queue_transition(transition) + +## called when state is exited +func _state_exit() -> void: + _set_active(false) + emit_signal("state_exited") + +## handles given event. returns true if it is consumed +func _state_event(event: String) -> bool: + if not active: + return false + + # emit event received signal + emit_signal("event_received", event) + + #check for transitions reacting to event + for transition in _transitions: + if transition.event == event and transition.evaluate_guard(): + # first match is taken + _queue_transition(transition) + return true + return false + +func _process(delta: float) -> void: + if Engine.editor_hint: + return + + # emit processing signal + emit_signal("state_processing") + + # process transitions if mode is IDLE + if transition_process_mode == ProcessMode.IDLE: + _process_transition(delta) + +func _physics_process(delta: float) -> void: + if Engine.editor_hint: + return + + # emit physics processing signal + emit_signal("state_processing") + + # process transitions if mode is PHYSICS + if transition_process_mode == ProcessMode.PHYSICS: + _process_transition(delta) + +## queues a transition to be taken +func _queue_transition(transition: Transition) -> void: + _queued_transition = transition + _queued_transition_time = transition.delay + +## checks for and processes queued transition +func _process_transition(delta: float) -> void: + # check for queued transition + if _queued_transition != null: + _queued_transition_time -= delta + # if ready, handle transition and clear queue + if _queued_transition_time <= 0.0: + var transition = _queued_transition + _queued_transition = null + _queued_transition_time = 0.0 + _handle_transition(transition, self) + +## attempts to take transition +func _handle_transition(transition: Transition, source: State) -> void: + push_error("state %s cannot handle transitions" % name) + +func _get_configuration_warning() -> String: + return "" diff --git a/addons/godot_state_charts/state_chart.gd b/addons/godot_state_charts/state_chart.gd new file mode 100644 index 0000000..dbb6716 --- /dev/null +++ b/addons/godot_state_charts/state_chart.gd @@ -0,0 +1,51 @@ +tool +class_name StateChart, "state_chart.svg" +extends Node + +# root state of the state chart +var _state: State = null + +# values available to expression guards +var _expression_properties: Dictionary = {} + +func _ready() -> void: + if Engine.editor_hint: + return + + # make sure only one child exists + if get_child_count() != 1: + push_error("StateChart must have exactly one child") + return + + # verify child is a State + var child = get_child(0) + if not child is State: + push_error("StateChart's child must be a State") + return + + # initialize states + _state = child as State + _state._state_init() + + # enter root state + _state._state_enter() + +## sends an event to be propagated through the states +func send_event(event: String) -> void: + if not is_instance_valid(_state): + push_error("StateChart is not initialized properly") + return + + _state._state_event(event) + +## sets a property available to guard expressions in transitions +func set_expression_property(property: String, value) -> void: + _expression_properties[property] = value + +func _get_configuration_warning() -> String: + if get_child_count() != 1: + return "StateChart must have exactly one child" + elif not get_child(0) is State: + return "StateChart's child must be a State" + else: + return "" diff --git a/addons/godot_state_charts/state_chart.svg b/addons/godot_state_charts/state_chart.svg new file mode 100644 index 0000000..ea66f90 --- /dev/null +++ b/addons/godot_state_charts/state_chart.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/godot_state_charts/state_chart.svg.import b/addons/godot_state_charts/state_chart.svg.import new file mode 100644 index 0000000..37cf872 --- /dev/null +++ b/addons/godot_state_charts/state_chart.svg.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/state_chart.svg-5c268dd045b20d73dfacd5cdf7606676.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot_state_charts/state_chart.svg" +dest_files=[ "res://.import/state_chart.svg-5c268dd045b20d73dfacd5cdf7606676.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=false +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/godot_state_charts/transition.gd b/addons/godot_state_charts/transition.gd new file mode 100644 index 0000000..715104e --- /dev/null +++ b/addons/godot_state_charts/transition.gd @@ -0,0 +1,88 @@ +tool +class_name Transition, "transition.svg" +extends Node + +## cyclic reference workarounds :/ +var _State = load("res://addons/godot_state_charts/state.gd") +var _StateChart = load("res://addons/godot_state_charts/state_chart.gd") + +## state to transition to +export var to: NodePath + +## event to react to for transition +export var event: String + +## delay before taking transition +export var delay: float = 0.0 + +## expression to determine whether to take transition +## if it returns true the transition will be taken +## expression properties added via the StateChart are available here +## if states are listed in `monitored_states`, they will be available as +## boolean properties named "[state]_active" +export (String, MULTILINE) var guard_expression: String + +## states to check if active +## they are available in the guard expression as bools named "[state]_active" +export (Array, NodePath) var checked_states: Array = [] + +## returns true if the transition has an event to react to +func has_event() -> bool: + return event != null and not event.empty() + +## returns the target state +func resolve_target(): + if to == null or to.is_empty(): + return null + + var target = get_node_or_null(to) + if target is _State: + return target + + return null + +## returns true if the transition should be taken at this time +func evaluate_guard() -> bool: + # return true if there is no guard + if guard_expression == null or guard_expression.empty(): + return true + + # find root StateChart + var root = get_parent() + while is_instance_valid(root) and not root is _StateChart: + root = root.get_parent() + if not is_instance_valid(root): + push_error("could not find root StateChart, cannot evaluate expression") + return false + + # combine monitored states with expression properties + var properties: Dictionary = root._expression_properties.duplicate() + for node in checked_states: + var state = get_node_or_null(node) + if is_instance_valid(state) and state is _State: + properties["in_" + state.name] = state.active + + # construct expression and set up properties + var expression := Expression.new() + var input_names = properties.keys() + + # attempt to parse expression + var parse_result = expression.parse(guard_expression, input_names) + if parse_result != OK: + push_error("error parsing expression: " + expression.get_error_text()) + return false + + # create array of input values + var input_values = [] + for input_name in input_names: + input_values.append(properties[input_name]) + + # execute expression and validate result + var result = expression.execute(input_values) + if expression.has_execute_failed(): + push_error("failed to execute expression: " + expression.get_error_text()) + return false + if typeof(result) != TYPE_BOOL: + push_error("result is not a boolean value. returning false") + return false + return result diff --git a/addons/godot_state_charts/transition.svg b/addons/godot_state_charts/transition.svg new file mode 100644 index 0000000..c7c6c3a --- /dev/null +++ b/addons/godot_state_charts/transition.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/godot_state_charts/transition.svg.import b/addons/godot_state_charts/transition.svg.import new file mode 100644 index 0000000..c829b74 --- /dev/null +++ b/addons/godot_state_charts/transition.svg.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/transition.svg-20a1a52a85a71c731b2386952d47b2f7.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot_state_charts/transition.svg" +dest_files=[ "res://.import/transition.svg-20a1a52a85a71c731b2386952d47b2f7.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=false +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/project.godot b/project.godot index eb6ba88..5a74b43 100644 --- a/project.godot +++ b/project.godot @@ -9,13 +9,49 @@ config_version=4 _global_script_classes=[ { +"base": "State", +"class": "AtomicState", +"language": "GDScript", +"path": "res://addons/godot_state_charts/atomic_state.gd" +}, { +"base": "State", +"class": "CompoundState", +"language": "GDScript", +"path": "res://addons/godot_state_charts/compound_state.gd" +}, { "base": "Resource", "class": "LevelEntry", "language": "GDScript", "path": "res://scripts/level_entry.gd" +}, { +"base": "State", +"class": "ParallelState", +"language": "GDScript", +"path": "res://addons/godot_state_charts/parallel_state.gd" +}, { +"base": "Node", +"class": "State", +"language": "GDScript", +"path": "res://addons/godot_state_charts/state.gd" +}, { +"base": "Node", +"class": "StateChart", +"language": "GDScript", +"path": "res://addons/godot_state_charts/state_chart.gd" +}, { +"base": "Node", +"class": "Transition", +"language": "GDScript", +"path": "res://addons/godot_state_charts/transition.gd" } ] _global_script_class_icons={ -"LevelEntry": "" +"AtomicState": "res://addons/godot_state_charts/atomic_state.svg", +"CompoundState": "res://addons/godot_state_charts/compound_state.svg", +"LevelEntry": "", +"ParallelState": "res://addons/godot_state_charts/parallel_state.svg", +"State": "", +"StateChart": "res://addons/godot_state_charts/state_chart.svg", +"Transition": "res://addons/godot_state_charts/transition.svg" } [application] @@ -49,7 +85,7 @@ window/handheld/orientation="sensor_landscape" [editor_plugins] -enabled=PoolStringArray( ) +enabled=PoolStringArray( "res://addons/godot_state_charts/plugin.cfg" ) [global]