State machine

main
Yan Wittmann 2024-11-24 17:47:16 +01:00
parent 63aa1c0cb5
commit 0930edc477
20 changed files with 776 additions and 26 deletions

View File

@ -0,0 +1,134 @@
{
"start": {
"state": "Idle"
},
"states": {
"Idle": {
"transitions": [
{
"target": "PickupTrash",
"signal": "waste_detected",
"conditions": [
{
"type": ">=",
"left": {
"value": 400
},
"right": {
"function": "distance",
"args": [
{
"accessor": [
"character",
"position"
]
},
{
"accessor": [
"signals",
"waste_detected",
"args",
"waste",
"position"
]
}
]
}
}
],
"transfer": {
"waste": {
"accessor": [
"signals",
"waste_detected",
"args",
"waste"
]
}
}
}
]
},
"PickupTrash": {
"transitions": [
{
"target": "ThrowTrashAway",
"signal": "waste_detected",
"conditions": [
{
"type": ">=",
"left": {
"value": 75
},
"right": {
"function": "distance",
"args": [
{
"accessor": [
"character",
"position"
]
},
{
"accessor": [
"signals",
"waste_detected",
"args",
"waste",
"position"
]
}
]
}
}
],
"transfer": {
"waste": {
"accessor": [
"signals",
"waste_detected",
"args",
"waste"
]
}
}
}
]
},
"ThrowTrashAway": {
"transitions": [
{
"target": "Idle",
"conditions": [
{
"type": ">=",
"left": {
"value": 90
},
"right": {
"function": "distance",
"args": [
{
"accessor": [
"character",
"position"
]
},
{
"accessor": [
"root_nodes",
"StateMachineWorld",
"child_nodes",
"TrashBin",
"position"
]
}
]
}
}
]
}
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cx04xknqfdscp"
path="res://.godot/imported/cleaning_robot.png-014de1c8447fd18dff622d82a883fa1e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/cleaning_robot.png"
dest_files=["res://.godot/imported/cleaning_robot.png-014de1c8447fd18dff622d82a883fa1e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

View File

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dkqw1wsjbvl0i"
path="res://.godot/imported/floor.png-38406586736af2fe805d7b886727ee47.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/floor.png"
dest_files=["res://.godot/imported/floor.png-38406586736af2fe805d7b886727ee47.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://s0vco3jt5y8m"
path="res://.godot/imported/trash_bag.png-26088d6e8428343ce319693f7898cb67.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/trash_bag.png"
dest_files=["res://.godot/imported/trash_bag.png-26088d6e8428343ce319693f7898cb67.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://di8eotyycurps"
path="res://.godot/imported/trash_bin.png-1284d871f56467e75f465b364d170d20.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/trash_bin.png"
dest_files=["res://.godot/imported/trash_bin.png-1284d871f56467e75f465b364d170d20.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -11,5 +11,18 @@ config_version=5
[application]
config/name="state"
run/main_scene="res://scenes/state/StateMachineWorld.tscn"
config/features=PackedStringArray("4.3", "Forward Plus")
config/icon="res://icon.svg"
[display]
window/stretch/mode="viewport"
[input]
spawn_trash={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}

View File

@ -0,0 +1,95 @@
extends CharacterBody2D
@onready var state_machine: StateMachine = $StateMachine
#
@onready var sprite: Node = $Sprite2D
#
const MAX_SPEED: float = 150.0
const MAX_ACCELERATION: float = 300.0
const SLOWING_RADIUS: float = 80.0
const SHAKE_INTENSITY: float = 8.0
const ROTATION_SPEED: float = 5.0 # adians per second
#
@onready var detection_area: Area2D = $VisionCone
signal waste_detected(waste)
func _ready() -> void:
pass
# detection_area.body_entered.connect(Callable(self, "_on_body_entered"))
# detection_area.area_entered.connect(Callable(self, "_on_area_entered"))
# func _on_body_entered(body):
# if body.is_in_group("waste"):
# emit_signal("waste_detected", body)
# func _on_area_entered(area):
# if area.is_in_group("waste"):
# emit_signal("waste_detected", area)
func _draw() -> void:
if velocity.length() > 0.1:
var local_velocity: Vector2 = global_transform.basis_xform_inv(velocity.normalized() * 400)
draw_line(Vector2.ZERO, local_velocity, Color(1, 0, 0), 2)
func _physics_process(delta: float) -> void:
rotate_towards_velocity(delta)
detect_waste()
queue_redraw()
func detect_waste() -> void:
var overlapping_bodies: Array[Node2D] = detection_area.get_overlapping_bodies()
for body in overlapping_bodies:
if body.is_in_group("waste"):
emit_signal("waste_detected", body)
func move_towards(target: Vector2, delta: float) -> void:
var to_target: Vector2 = target - position
var distance: float = to_target.length()
if distance < 5.0:
velocity = Vector2.ZERO
return
var desired_speed: float = MAX_SPEED
if distance < SLOWING_RADIUS:
desired_speed = lerp(0, int(MAX_SPEED), distance / SLOWING_RADIUS)
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
var steering: Vector2 = desired_velocity - velocity
var steering_length: float = steering.length()
if steering_length > MAX_ACCELERATION * delta:
steering = steering.normalized() * MAX_ACCELERATION * delta
velocity += steering
if velocity.length() > 0.1:
var shake: Vector2 = Vector2(
rand_range(-SHAKE_INTENSITY, SHAKE_INTENSITY),
rand_range(-SHAKE_INTENSITY, SHAKE_INTENSITY)
)
velocity += shake
if velocity.length() > MAX_SPEED:
velocity = velocity.normalized() * MAX_SPEED
func rotate_towards_velocity(delta: float) -> void:
if velocity.length() > 0.1:
var target_angle: float = velocity.angle()
rotation = lerp_angle(rotation, target_angle, ROTATION_SPEED * delta)
sprite.rotation = -rotation
func rand_range(min: float, max: float) -> float:
return randf() * (max - min) + min

View File

@ -0,0 +1,16 @@
extends Node2D
func _ready() -> void:
pass
func _process(delta: float) -> void:
if Input.is_action_just_pressed("spawn_trash"):
var trash_scene: PackedScene = preload("res://scenes/state/Waste.tscn")
var trash_instance: Node2D = trash_scene.instantiate() as Node2D
var spawn_location: Vector2 = get_global_mouse_position()
trash_instance.position = Vector2(spawn_location)
get_tree().get_root().add_child(trash_instance)
print("Spawned trash at: ", spawn_location, " ", trash_instance.position)

View File

@ -6,13 +6,28 @@ var character: CharacterBody2D
func _ready() -> void:
pass
pass
func state_enter() -> void:
pass
pass
func state_process(delta: float) -> void:
pass
pass
func state_exit() -> void:
pass
pass
func contribute_transfer_variables(transfer_variables: Dictionary) -> void:
pass
func change_state(new_state: State) -> void:
state_machine.current_state = new_state
func rand_range(min: float, max: float) -> float:
return randf() * (max - min) + min

View File

@ -1,19 +1,243 @@
class_name StateMachine
extends Node
var character: CharacterBody2D
# json read from file, contains all states and transitions
var state_machine_data: Dictionary = {}
# records all signals that were fired during the last frame, dictionary contains signal name and arguments
var received_signals: Array[Dictionary] = []
# state change parameters ("transfer")
var state_transfer_variables: Dictionary = {}
var current_state: State:
set(value):
if current_state:
current_state.state_exit()
current_state = value
current_state.state_enter()
set(value):
if current_state:
current_state.state_exit()
current_state = value
current_state.state_enter()
# INITIALIZATION
func _ready() -> void:
for child in self.get_children():
if true:
print(child)
if get_parent() is CharacterBody2D:
character = get_parent()
else:
push_error("No character as parent node.")
for child in self.get_children():
if child is State:
print('Detected state ', child)
child.state_machine = self
child.character = character
var loaded_state_machine_data: JSON = load_json("res://assets/character_state_machine.json")
if loaded_state_machine_data:
state_machine_data = loaded_state_machine_data.get_data() as Dictionary
print("Loaded JSON character_state_machine.json ", state_machine_data)
else:
push_error("Failed to load JSON character_state_machine.json")
current_state = find_state_by_name(state_machine_data["start"]["state"])
call_deferred("defer_ready")
func defer_ready():
character.waste_detected.connect(Callable(self, "_on_waste_detected"))
# STATE MACHINE
func _process(delta: float) -> void:
pass
if current_state:
current_state.state_process(delta)
check_transitions()
received_signals = []
func check_transitions() -> void:
if current_state:
for transition in state_machine_data["states"][current_state.get_name()]["transitions"]:
if "signal" in transition and transition["signal"] and not was_signal_received(transition["signal"]):
continue
if "conditions" in transition and transition["conditions"]:
var all_conditions_met: bool = true
for condition in transition["conditions"]:
if not transition_check_condition(condition):
all_conditions_met = false
break
if not all_conditions_met:
continue
# evaluate transfer parameters if any
state_transfer_variables = {}
if "transfer" in transition and transition["transfer"]:
for key in transition["transfer"]:
state_transfer_variables[key] = transition_resolve_parameter(transition["transfer"][key])
# allow current state to contribute to transfer variables
current_state.contribute_transfer_variables(state_transfer_variables)
print("All criteria were met for switching to state ", transition["target"], " with transfer variables ", state_transfer_variables)
current_state = find_state_by_name(transition["target"])
func transition_check_condition(condition: Dictionary) -> bool:
var type: String = condition["type"]
var left: Variant = transition_resolve_parameter(condition["left"])
var right: Variant = transition_resolve_parameter(condition["right"])
var result: bool = false
if type == "<":
result = left < right
elif type == ">":
result = left > right
elif type == "<=":
result = left <= right
elif type == ">=":
result = left >= right
elif type == "==":
result = left == right
elif type == "!=":
result = left != right
else:
print("Unknown condition type [", type, "]")
print("[check_condition] ", left, " ", type, " ", right, " = ", result)
return result
func transition_resolve_parameter(parameter: Dictionary) -> Variant:
if "value" in parameter:
return parameter["value"]
elif "function" in parameter:
return transition_resolve_function(parameter)
elif "accessor" in parameter:
return transition_resolve_accessor(parameter["accessor"])
else:
print("Unknown parameter type")
return null
func transition_resolve_function(function: Dictionary) -> Variant:
var args: Array = []
if "args" in function:
for arg in function["args"]:
if arg is Dictionary:
args.append(transition_resolve_parameter(arg))
else:
print("Unknown argument type [", arg, "]")
if function["function"] == "distance":
var result = args[0].distance_to(args[1])
print("[resolve_function] distance(", args[0], ", ", args[1], ") = ", result)
return result
else:
print("Unknown function [", function, "]")
return null
func transition_resolve_accessor(accessor: Array) -> Variant:
var current_item: Variant = self
for next_item in accessor:
print("[resolve_accessor] current_item: ", current_item, ' next_item: ', next_item)
if current_item == null:
print("[resolve_accessor] Null value in ", accessor)
break
if objects_equal(current_item, self):
if next_item == "signals":
current_item = received_signals
continue
if objects_equal(current_item, received_signals):
var detected_signal: Dictionary = find_detected_signal(next_item)
if detected_signal.size() > 0:
current_item = detected_signal
continue
if objects_equal(next_item, "child_nodes"):
# build a dictionary of names:child nodes
var child_nodes: Dictionary = {}
for child in current_item.get_children():
child_nodes[child.get_name()] = child
current_item = child_nodes
continue
if objects_equal(next_item, "root_nodes"):
var root_nodes: Dictionary = {}
for root in current_item.get_tree().get_root().get_children():
root_nodes[root.get_name()] = root
current_item = root_nodes
continue
if current_item is Dictionary:
print("[resolve_accessor] Dictionary accessor, keys: ", current_item.keys())
if next_item in current_item:
current_item = current_item[next_item]
continue
else:
break
current_item = current_item.get(next_item)
if current_item == null:
print("[resolve_accessor] Failed to resolve accessor ", accessor)
print("[resolve_accessor] resolved to ", current_item)
return current_item
# SIGNALS
func _on_waste_detected(waste):
received_signals.append({"signal": "waste_detected", "args": {"waste": waste}})
func find_detected_signal(signal_name: String) -> Dictionary:
for received_signal in received_signals:
if received_signal["signal"] == signal_name:
return received_signal
return {}
# UTILITY FUNCTIONS
func find_state_by_name(name: String) -> State:
for child in self.get_children():
if child is State:
if child.get_name() == name:
return child
return null
func was_signal_received(signal_name: String) -> bool:
for received_signal in received_signals:
if received_signal["signal"] == signal_name:
return true
return false
func load_json(path: String) -> JSON:
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if not file:
print("Cannot open file: ", path)
return JSON.new()
var json_string: String = file.get_as_text()
file.close()
var json = JSON.new()
var error = json.parse(json_string)
if error != OK:
print("JSON parse error: ", error)
return JSON.new()
return json
func objects_equal(a: Variant, b: Variant) -> bool:
if typeof(a) != typeof(b):
return false
return a == b

View File

@ -1,25 +1,63 @@
[gd_scene load_steps=5 format=3 uid="uid://cgdvptekjbpgq"]
[gd_scene load_steps=12 format=3 uid="uid://cgdvptekjbpgq"]
[ext_resource type="Texture2D" uid="uid://beggt5ndi6j4p" path="res://icon.svg" id="1_jgx5y"]
[ext_resource type="Script" path="res://scenes/state/SMCharacter.gd" id="1_84313"]
[ext_resource type="Script" path="res://scenes/state/SceneManagement.gd" id="1_s1suw"]
[ext_resource type="Texture2D" uid="uid://cx04xknqfdscp" path="res://assets/cleaning_robot.png" id="2_cwwab"]
[ext_resource type="Script" path="res://scenes/state/StateMachine.gd" id="2_d1xqo"]
[ext_resource type="Script" path="res://scenes/state/state_idle.gd" id="3_r1btx"]
[ext_resource type="Texture2D" uid="uid://dkqw1wsjbvl0i" path="res://assets/floor.png" id="6_15pjb"]
[ext_resource type="Script" path="res://scenes/state/state_PickupTrash.gd" id="7_1rfah"]
[ext_resource type="Script" path="res://scenes/state/state_ThrowTrashAway.gd" id="8_677fi"]
[ext_resource type="PackedScene" uid="uid://dng6a8i8kp4kw" path="res://scenes/state/TrashBin.tscn" id="9_sd6r3"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_tr1gq"]
radius = 60.0
height = 122.0
[sub_resource type="RectangleShape2D" id="RectangleShape2D_ji64e"]
size = Vector2(601.536, 254.293)
[node name="StateMachineWorld" type="Node2D"]
script = ExtResource("1_s1suw")
[node name="CharacterBody2D" type="CharacterBody2D" parent="."]
[node name="Floor" type="Sprite2D" parent="."]
position = Vector2(573, 329)
scale = Vector2(0.635347, 0.635347)
texture = ExtResource("6_15pjb")
[node name="Sprite2D" type="Sprite2D" parent="CharacterBody2D"]
texture = ExtResource("1_jgx5y")
[node name="TrashBin" parent="." instance=ExtResource("9_sd6r3")]
position = Vector2(67, 78)
scale = Vector2(4.42446, 4.42446)
[node name="CollisionShape2D" type="CollisionShape2D" parent="CharacterBody2D"]
[node name="CleaningRobotCB2D" type="CharacterBody2D" parent="."]
position = Vector2(546, 342)
script = ExtResource("1_84313")
[node name="Sprite2D" type="Sprite2D" parent="CleaningRobotCB2D"]
scale = Vector2(0.345498, 0.345498)
texture = ExtResource("2_cwwab")
[node name="CollisionShape2D" type="CollisionShape2D" parent="CleaningRobotCB2D"]
scale = Vector2(0.702629, 0.702629)
shape = SubResource("CapsuleShape2D_tr1gq")
[node name="StateMachine" type="Node" parent="CharacterBody2D"]
[node name="StateMachine" type="Node" parent="CleaningRobotCB2D"]
script = ExtResource("2_d1xqo")
[node name="idle" type="Node" parent="CharacterBody2D/StateMachine"]
[node name="Idle" type="Node" parent="CleaningRobotCB2D/StateMachine"]
script = ExtResource("3_r1btx")
[node name="PickupTrash" type="Node" parent="CleaningRobotCB2D/StateMachine"]
script = ExtResource("7_1rfah")
[node name="ThrowTrashAway" type="Node" parent="CleaningRobotCB2D/StateMachine"]
script = ExtResource("8_677fi")
[node name="VisionCone" type="Area2D" parent="CleaningRobotCB2D"]
[node name="CollisionShape2D" type="CollisionShape2D" parent="CleaningRobotCB2D/VisionCone"]
position = Vector2(295.232, -1)
scale = Vector2(1, 0.48)
shape = SubResource("RectangleShape2D_ji64e")
[node name="Node2D" type="Node2D" parent="CleaningRobotCB2D"]

View File

@ -0,0 +1,16 @@
[gd_scene load_steps=3 format=3 uid="uid://dng6a8i8kp4kw"]
[ext_resource type="Texture2D" uid="uid://di8eotyycurps" path="res://assets/trash_bin.png" id="1_eaj15"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_bhmyx"]
radius = 0.0
height = 0.0
[node name="TrashBin" type="StaticBody2D"]
[node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(0.0900715, 0.0900715)
texture = ExtResource("1_eaj15")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CapsuleShape2D_bhmyx")

View File

@ -0,0 +1,17 @@
[gd_scene load_steps=3 format=3 uid="uid://cqew2pkk5en21"]
[ext_resource type="Texture2D" uid="uid://s0vco3jt5y8m" path="res://assets/trash_bag.png" id="1_mr4i5"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_4fcy4"]
radius = 42.0
height = 128.0
[node name="Waste" type="StaticBody2D" groups=["waste"]]
[node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(0.5, 0.5)
texture = ExtResource("1_mr4i5")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
scale = Vector2(0.5, 0.5)
shape = SubResource("CapsuleShape2D_4fcy4")

View File

@ -0,0 +1,15 @@
extends State
var pickup_trash_target: Vector2 = Vector2.ZERO
func state_enter():
var waste = state_machine.state_transfer_variables["waste"]
pickup_trash_target = waste.position
print(waste, " ", pickup_trash_target)
func state_process(delta: float) -> void:
character.move_towards(pickup_trash_target, delta)
character.move_and_slide()

View File

@ -0,0 +1,25 @@
extends State
@onready var trash_bin: Node2D = $"../../../TrashBin"
var carry_trash: Node2D = null
func state_enter():
carry_trash = state_machine.state_transfer_variables["waste"]
# disable it's collision to avoid it from colliding with the character
carry_trash.get_node("CollisionShape2D").disabled = true
print("Carry ", carry_trash, " to ", trash_bin.position)
func state_process(delta: float) -> void:
# put the trash on the character
carry_trash.position = character.position
# carry the trash to the trash bin
var target_position: Vector2 = trash_bin.position
character.move_towards(target_position, delta)
character.move_and_slide()
func state_exit() -> void:
carry_trash.queue_free()

View File

@ -1,10 +1,16 @@
extends State
var idle_target: Vector2 = Vector2.ZERO
func state_enter():
print("idle enter")
idle_target = Vector2(rand_range(0, get_viewport().size.x), rand_range(0, get_viewport().size.y))
func state_process(delta: float) -> void:
print("idle process")
func state_exit() -> void:
print("idle exit")
# move towards the idle_target until reached, then set a new target randomly on the screen
if character.position.distance_to(idle_target) < 10:
idle_target = Vector2(rand_range(0, get_viewport().size.x), rand_range(0, get_viewport().size.y))
else:
character.move_towards(idle_target, delta)
character.move_and_slide()