diff --git a/examples/2DCoopBallChallenge/.gitattributes b/examples/2DCoopBallChallenge/.gitattributes
new file mode 100755
index 0000000..8ad74f7
--- /dev/null
+++ b/examples/2DCoopBallChallenge/.gitattributes
@@ -0,0 +1,2 @@
+# Normalize EOL for all files that Git considers text files.
+* text=auto eol=lf
diff --git a/examples/2DCoopBallChallenge/.gitignore b/examples/2DCoopBallChallenge/.gitignore
new file mode 100755
index 0000000..7de8ea5
--- /dev/null
+++ b/examples/2DCoopBallChallenge/.gitignore
@@ -0,0 +1,3 @@
+# Godot 4+ specific ignores
+.godot/
+android/
diff --git a/examples/2DCoopBallChallenge/Platform2D.csproj b/examples/2DCoopBallChallenge/Platform2D.csproj
new file mode 100755
index 0000000..0e0abed
--- /dev/null
+++ b/examples/2DCoopBallChallenge/Platform2D.csproj
@@ -0,0 +1,9 @@
+
+
+ net8.0
+ true
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/2DCoopBallChallenge/Platform2D.sln b/examples/2DCoopBallChallenge/Platform2D.sln
new file mode 100755
index 0000000..5427097
--- /dev/null
+++ b/examples/2DCoopBallChallenge/Platform2D.sln
@@ -0,0 +1,19 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 2012
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Platform2D", "Platform2D.csproj", "{8552EC7B-EF81-42D4-828B-B6CD9D17C897}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ ExportDebug|Any CPU = ExportDebug|Any CPU
+ ExportRelease|Any CPU = ExportRelease|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
+ {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
+ {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
+ {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_2d.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_2d.gd
new file mode 100755
index 0000000..06d928b
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_2d.gd
@@ -0,0 +1,136 @@
+extends Node2D
+class_name AIController2D
+
+enum ControlModes {
+ INHERIT_FROM_SYNC, ## Inherit setting from sync node
+ HUMAN, ## Test the environment manually
+ TRAINING, ## Train a model
+ ONNX_INFERENCE, ## Load a pretrained model using an .onnx file
+ RECORD_EXPERT_DEMOS ## Record observations and actions for expert demonstrations
+}
+@export var control_mode: ControlModes = ControlModes.INHERIT_FROM_SYNC
+## The path to a trained .onnx model file to use for inference (overrides the path set in sync node).
+@export var onnx_model_path := ""
+## Once the number of steps has passed, the flag 'needs_reset' will be set to 'true' for this instance.
+@export var reset_after := 1000
+
+@export_group("Record expert demos mode options")
+## Path where the demos will be saved. The file can later be used for imitation learning.
+@export var expert_demo_save_path: String
+## The action that erases the last recorded episode from the currently recorded data.
+@export var remove_last_episode_key: InputEvent
+## Action will be repeated for n frames. Will introduce control lag if larger than 1.
+## Can be used to ensure that action_repeat on inference and training matches
+## the recorded demonstrations.
+@export var action_repeat: int = 1
+
+@export_group("Multi-policy mode options")
+## Allows you to set certain agents to use different policies.
+## Changing has no effect with default SB3 training. Works with Rllib example.
+## Tutorial: https://github.com/edbeeching/godot_rl_agents/blob/main/docs/TRAINING_MULTIPLE_POLICIES.md
+@export var policy_name: String = "shared_policy"
+
+var onnx_model: ONNXModel
+
+var heuristic := "human"
+var done := false
+var reward := 0.0
+var n_steps := 0
+var needs_reset := false
+
+var _player: Node2D
+
+
+func _ready():
+ add_to_group("AGENT")
+
+
+func init(player: Node2D):
+ _player = player
+
+
+#region Methods that need implementing using the "extend script" option in Godot
+func get_obs() -> Dictionary:
+ assert(false, "the get_obs method is not implemented when extending from ai_controller")
+ return {"obs": []}
+
+
+func get_reward() -> float:
+ assert(false, "the get_reward method is not implemented when extending from ai_controller")
+ return 0.0
+
+
+func get_action_space() -> Dictionary:
+ assert(
+ false, "the get_action_space method is not implemented when extending from ai_controller"
+ )
+ return {
+ "example_actions_continous": {"size": 2, "action_type": "continuous"},
+ "example_actions_discrete": {"size": 2, "action_type": "discrete"},
+ }
+
+
+func set_action(action) -> void:
+ assert(false, "the set_action method is not implemented when extending from ai_controller")
+
+
+#endregion
+
+
+#region Methods that sometimes need implementing using the "extend script" option in Godot
+# Only needed if you are recording expert demos with this AIController
+func get_action() -> Array:
+ assert(
+ false,
+ "the get_action method is not implemented in extended AIController but demo_recorder is used"
+ )
+ return []
+
+
+# For providing additional info (e.g. `is_success` for SB3 training)
+func get_info() -> Dictionary:
+ return {}
+
+
+#endregion
+
+
+func _physics_process(delta):
+ n_steps += 1
+ if n_steps > reset_after:
+ needs_reset = true
+
+
+func get_obs_space():
+ # may need overriding if the obs space is complex
+ var obs = get_obs()
+ return {
+ "obs": {"size": [len(obs["obs"])], "space": "box"},
+ }
+
+
+func reset():
+ n_steps = 0
+ needs_reset = false
+
+
+func reset_if_done():
+ if done:
+ reset()
+
+
+func set_heuristic(h):
+ # sets the heuristic from "human" or "model" nothing to change here
+ heuristic = h
+
+
+func get_done():
+ return done
+
+
+func set_done_false():
+ done = false
+
+
+func zero_reward():
+ reward = 0.0
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_2d.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_2d.gd.uid
new file mode 100755
index 0000000..323d04b
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_2d.gd.uid
@@ -0,0 +1 @@
+uid://23c15bj3ukdm
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_3d.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_3d.gd
new file mode 100755
index 0000000..61a0529
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_3d.gd
@@ -0,0 +1,136 @@
+extends Node3D
+class_name AIController3D
+
+enum ControlModes {
+ INHERIT_FROM_SYNC, ## Inherit setting from sync node
+ HUMAN, ## Test the environment manually
+ TRAINING, ## Train a model
+ ONNX_INFERENCE, ## Load a pretrained model using an .onnx file
+ RECORD_EXPERT_DEMOS ## Record observations and actions for expert demonstrations
+}
+@export var control_mode: ControlModes = ControlModes.INHERIT_FROM_SYNC
+## The path to a trained .onnx model file to use for inference (overrides the path set in sync node).
+@export var onnx_model_path := ""
+## Once the number of steps has passed, the flag 'needs_reset' will be set to 'true' for this instance.
+@export var reset_after := 1000
+
+@export_group("Record expert demos mode options")
+## Path where the demos will be saved. The file can later be used for imitation learning.
+@export var expert_demo_save_path: String
+## The action that erases the last recorded episode from the currently recorded data.
+@export var remove_last_episode_key: InputEvent
+## Action will be repeated for n frames. Will introduce control lag if larger than 1.
+## Can be used to ensure that action_repeat on inference and training matches
+## the recorded demonstrations.
+@export var action_repeat: int = 1
+
+@export_group("Multi-policy mode options")
+## Allows you to set certain agents to use different policies.
+## Changing has no effect with default SB3 training. Works with Rllib example.
+## Tutorial: https://github.com/edbeeching/godot_rl_agents/blob/main/docs/TRAINING_MULTIPLE_POLICIES.md
+@export var policy_name: String = "shared_policy"
+
+var onnx_model: ONNXModel
+
+var heuristic := "human"
+var done := false
+var reward := 0.0
+var n_steps := 0
+var needs_reset := false
+
+var _player: Node3D
+
+
+func _ready():
+ add_to_group("AGENT")
+
+
+func init(player: Node3D):
+ _player = player
+
+
+#region Methods that need implementing using the "extend script" option in Godot
+func get_obs() -> Dictionary:
+ assert(false, "the get_obs method is not implemented when extending from ai_controller")
+ return {"obs": []}
+
+
+func get_reward() -> float:
+ assert(false, "the get_reward method is not implemented when extending from ai_controller")
+ return 0.0
+
+
+func get_action_space() -> Dictionary:
+ assert(
+ false, "the get_action_space method is not implemented when extending from ai_controller"
+ )
+ return {
+ "example_actions_continous": {"size": 2, "action_type": "continuous"},
+ "example_actions_discrete": {"size": 2, "action_type": "discrete"},
+ }
+
+
+func set_action(action) -> void:
+ assert(false, "the set_action method is not implemented when extending from ai_controller")
+
+
+#endregion
+
+
+#region Methods that sometimes need implementing using the "extend script" option in Godot
+# Only needed if you are recording expert demos with this AIController
+func get_action() -> Array:
+ assert(
+ false,
+ "the get_action method is not implemented in extended AIController but demo_recorder is used"
+ )
+ return []
+
+
+# For providing additional info (e.g. `is_success` for SB3 training)
+func get_info() -> Dictionary:
+ return {}
+
+
+#endregion
+
+
+func _physics_process(delta):
+ n_steps += 1
+ if n_steps > reset_after:
+ needs_reset = true
+
+
+func get_obs_space():
+ # may need overriding if the obs space is complex
+ var obs = get_obs()
+ return {
+ "obs": {"size": [len(obs["obs"])], "space": "box"},
+ }
+
+
+func reset():
+ n_steps = 0
+ needs_reset = false
+
+
+func reset_if_done():
+ if done:
+ reset()
+
+
+func set_heuristic(h):
+ # sets the heuristic from "human" or "model" nothing to change here
+ heuristic = h
+
+
+func get_done():
+ return done
+
+
+func set_done_false():
+ done = false
+
+
+func zero_reward():
+ reward = 0.0
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_3d.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_3d.gd.uid
new file mode 100755
index 0000000..22711e4
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/controller/ai_controller_3d.gd.uid
@@ -0,0 +1 @@
+uid://sku0fuwlceyk
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/godot_rl_agents.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/godot_rl_agents.gd
new file mode 100755
index 0000000..e4fe136
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/godot_rl_agents.gd
@@ -0,0 +1,16 @@
+@tool
+extends EditorPlugin
+
+
+func _enter_tree():
+ # Initialization of the plugin goes here.
+ # Add the new type with a name, a parent type, a script and an icon.
+ add_custom_type("Sync", "Node", preload("sync.gd"), preload("icon.png"))
+ #add_custom_type("RaycastSensor2D2", "Node", preload("raycast_sensor_2d.gd"), preload("icon.png"))
+
+
+func _exit_tree():
+ # Clean-up of the plugin goes here.
+ # Always remember to remove it from the engine when deactivated.
+ remove_custom_type("Sync")
+ #remove_custom_type("RaycastSensor2D2")
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/godot_rl_agents.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/godot_rl_agents.gd.uid
new file mode 100755
index 0000000..dffdab8
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/godot_rl_agents.gd.uid
@@ -0,0 +1 @@
+uid://bydjyywoj7hh4
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/icon.png b/examples/2DCoopBallChallenge/addons/godot_rl_agents/icon.png
new file mode 100755
index 0000000..fd8190e
Binary files /dev/null and b/examples/2DCoopBallChallenge/addons/godot_rl_agents/icon.png differ
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs
new file mode 100755
index 0000000..58d4dc2
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs
@@ -0,0 +1,109 @@
+using Godot;
+using Microsoft.ML.OnnxRuntime;
+using Microsoft.ML.OnnxRuntime.Tensors;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GodotONNX
+{
+ ///
+ public partial class ONNXInference : GodotObject
+ {
+
+ private InferenceSession session;
+ ///
+ /// Path to the ONNX model. Use Initialize to change it.
+ ///
+ private string modelPath;
+ private int batchSize;
+
+ private SessionOptions SessionOpt;
+
+ ///
+ /// init function
+ ///
+ ///
+ ///
+ /// Returns the output size of the model
+ public int Initialize(string Path, int BatchSize)
+ {
+ modelPath = Path;
+ batchSize = BatchSize;
+ SessionOpt = SessionConfigurator.MakeConfiguredSessionOptions();
+ session = LoadModel(modelPath);
+ return session.OutputMetadata["output"].Dimensions[1];
+ }
+
+
+ ///
+ public Godot.Collections.Dictionary> RunInference(Godot.Collections.Array obs, int state_ins)
+ {
+ //Current model: Any (Godot Rl Agents)
+ //Expects a tensor of shape [batch_size, input_size] type float named obs and a tensor of shape [batch_size] type float named state_ins
+
+ //Fill the input tensors
+ // create span from inputSize
+ var span = new float[obs.Count]; //There's probably a better way to do this
+ for (int i = 0; i < obs.Count; i++)
+ {
+ span[i] = obs[i];
+ }
+
+ IReadOnlyCollection inputs = new List
+ {
+ NamedOnnxValue.CreateFromTensor("obs", new DenseTensor(span, new int[] { batchSize, obs.Count })),
+ NamedOnnxValue.CreateFromTensor("state_ins", new DenseTensor(new float[] { state_ins }, new int[] { batchSize }))
+ };
+ IReadOnlyCollection outputNames = new List { "output", "state_outs" }; //ONNX is sensible to these names, as well as the input names
+
+ IDisposableReadOnlyCollection results;
+ //We do not use "using" here so we get a better exception explaination later
+ try
+ {
+ results = session.Run(inputs, outputNames);
+ }
+ catch (OnnxRuntimeException e)
+ {
+ //This error usually means that the model is not compatible with the input, beacause of the input shape (size)
+ GD.Print("Error at inference: ", e);
+ return null;
+ }
+ //Can't convert IEnumerable to Variant, so we have to convert it to an array or something
+ Godot.Collections.Dictionary> output = new Godot.Collections.Dictionary>();
+ DisposableNamedOnnxValue output1 = results.First();
+ DisposableNamedOnnxValue output2 = results.Last();
+ Godot.Collections.Array output1Array = new Godot.Collections.Array();
+ Godot.Collections.Array output2Array = new Godot.Collections.Array();
+
+ foreach (float f in output1.AsEnumerable())
+ {
+ output1Array.Add(f);
+ }
+
+ foreach (float f in output2.AsEnumerable())
+ {
+ output2Array.Add(f);
+ }
+
+ output.Add(output1.Name, output1Array);
+ output.Add(output2.Name, output2Array);
+
+ //Output is a dictionary of arrays, ex: { "output" : [0.1, 0.2, 0.3, 0.4, ...], "state_outs" : [0.5, ...]}
+ results.Dispose();
+ return output;
+ }
+ ///
+ public InferenceSession LoadModel(string Path)
+ {
+ using Godot.FileAccess file = FileAccess.Open(Path, Godot.FileAccess.ModeFlags.Read);
+ byte[] model = file.GetBuffer((int)file.GetLength());
+ //file.Close(); file.Dispose(); //Close the file, then dispose the reference.
+ return new InferenceSession(model, SessionOpt); //Load the model
+ }
+ public void FreeDisposables()
+ {
+ session.Dispose();
+ SessionOpt.Dispose();
+ }
+ }
+}
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs.uid
new file mode 100755
index 0000000..1281226
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs.uid
@@ -0,0 +1 @@
+uid://bon0cie4ugxk8
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs
new file mode 100755
index 0000000..ad7a41c
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs
@@ -0,0 +1,131 @@
+using Godot;
+using Microsoft.ML.OnnxRuntime;
+
+namespace GodotONNX
+{
+ ///
+
+ public static class SessionConfigurator
+ {
+ public enum ComputeName
+ {
+ CUDA,
+ ROCm,
+ DirectML,
+ CoreML,
+ CPU
+ }
+
+ ///
+ public static SessionOptions MakeConfiguredSessionOptions()
+ {
+ SessionOptions sessionOptions = new();
+ SetOptions(sessionOptions);
+ return sessionOptions;
+ }
+
+ private static void SetOptions(SessionOptions sessionOptions)
+ {
+ sessionOptions.LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING;
+ ApplySystemSpecificOptions(sessionOptions);
+ }
+
+ ///
+ static public void ApplySystemSpecificOptions(SessionOptions sessionOptions)
+ {
+ //Most code for this function is verbose only, the only reason it exists is to track
+ //implementation progress of the different compute APIs.
+
+ //December 2022: CUDA is not working.
+
+ string OSName = OS.GetName(); //Get OS Name
+
+ //ComputeName ComputeAPI = ComputeCheck(); //Get Compute API
+ // //TODO: Get CPU architecture
+
+ //Linux can use OpenVINO (C#) on x64 and ROCm on x86 (GDNative/C++)
+ //Windows can use OpenVINO (C#) on x64
+ //TODO: try TensorRT instead of CUDA
+ //TODO: Use OpenVINO for Intel Graphics
+
+ // Temporarily using CPU on all platforms to avoid errors detected with DML
+ ComputeName ComputeAPI = ComputeName.CPU;
+
+ //match OS and Compute API
+ GD.Print($"OS: {OSName} Compute API: {ComputeAPI}");
+
+ // CPU is set by default without appending necessary
+ // sessionOptions.AppendExecutionProvider_CPU(0);
+
+ /*
+ switch (OSName)
+ {
+ case "Windows": //Can use CUDA, DirectML
+ if (ComputeAPI is ComputeName.CUDA)
+ {
+ //CUDA
+ //sessionOptions.AppendExecutionProvider_CUDA(0);
+ //sessionOptions.AppendExecutionProvider_DML(0);
+ }
+ else if (ComputeAPI is ComputeName.DirectML)
+ {
+ //DirectML
+ //sessionOptions.AppendExecutionProvider_DML(0);
+ }
+ break;
+ case "X11": //Can use CUDA, ROCm
+ if (ComputeAPI is ComputeName.CUDA)
+ {
+ //CUDA
+ //sessionOptions.AppendExecutionProvider_CUDA(0);
+ }
+ if (ComputeAPI is ComputeName.ROCm)
+ {
+ //ROCm, only works on x86
+ //Research indicates that this has to be compiled as a GDNative plugin
+ //GD.Print("ROCm not supported yet, using CPU.");
+ //sessionOptions.AppendExecutionProvider_CPU(0);
+ }
+ break;
+ case "macOS": //Can use CoreML
+ if (ComputeAPI is ComputeName.CoreML)
+ { //CoreML
+ //TODO: Needs testing
+ //sessionOptions.AppendExecutionProvider_CoreML(0);
+ //CoreML on ARM64, out of the box, on x64 needs .tar file from GitHub
+ }
+ break;
+ default:
+ GD.Print("OS not Supported.");
+ break;
+ }
+ */
+ }
+
+
+ ///
+ public static ComputeName ComputeCheck()
+ {
+ string adapterName = Godot.RenderingServer.GetVideoAdapterName();
+ //string adapterVendor = Godot.RenderingServer.GetVideoAdapterVendor();
+ adapterName = adapterName.ToUpper(new System.Globalization.CultureInfo(""));
+ //TODO: GPU vendors for MacOS, what do they even use these days?
+
+ if (adapterName.Contains("INTEL"))
+ {
+ return ComputeName.DirectML;
+ }
+ if (adapterName.Contains("AMD") || adapterName.Contains("RADEON"))
+ {
+ return ComputeName.DirectML;
+ }
+ if (adapterName.Contains("NVIDIA"))
+ {
+ return ComputeName.CUDA;
+ }
+
+ GD.Print("Graphics Card not recognized."); //Should use CPU
+ return ComputeName.CPU;
+ }
+ }
+}
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs.uid
new file mode 100755
index 0000000..13f5ebc
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs.uid
@@ -0,0 +1 @@
+uid://o2y2nsk8q5qh
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/docs/ONNXInference.xml b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/docs/ONNXInference.xml
new file mode 100755
index 0000000..91b07d6
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/docs/ONNXInference.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ The main ONNXInference Class that handles the inference process.
+
+
+
+
+ Starts the inference process.
+
+ Path to the ONNX model, expects a path inside resources.
+ How many observations will the model recieve.
+
+
+
+ Runs the given input through the model and returns the output.
+
+ Dictionary containing all observations.
+ How many different agents are creating these observations.
+ A Dictionary of arrays, containing instructions based on the observations.
+
+
+
+ Loads the given model into the inference process, using the best Execution provider available.
+
+ Path to the ONNX model, expects a path inside resources.
+ InferenceSession ready to run.
+
+
+
\ No newline at end of file
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/docs/SessionConfigurator.xml b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/docs/SessionConfigurator.xml
new file mode 100755
index 0000000..f160c02
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/csharp/docs/SessionConfigurator.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ The main SessionConfigurator Class that handles the execution options and providers for the inference process.
+
+
+
+
+ Creates a SessionOptions with all available execution providers.
+
+ SessionOptions with all available execution providers.
+
+
+
+ Appends any execution provider available in the current system.
+
+
+ This function is mainly verbose for tracking implementation progress of different compute APIs.
+
+
+
+
+ Checks for available GPUs.
+
+ An integer identifier for each compute platform.
+
+
+
\ No newline at end of file
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd
new file mode 100755
index 0000000..e27f2c3
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd
@@ -0,0 +1,51 @@
+extends Resource
+class_name ONNXModel
+var inferencer_script = load("res://addons/godot_rl_agents/onnx/csharp/ONNXInference.cs")
+
+var inferencer = null
+
+## How many action values the model outputs
+var action_output_size: int
+
+## Used to differentiate models
+## that only output continuous action mean (e.g. sb3, cleanrl export)
+## versus models that output mean and logstd (e.g. rllib export)
+var action_means_only: bool
+
+## Whether action_means_value has been set already for this model
+var action_means_only_set: bool
+
+# Must provide the path to the model and the batch size
+func _init(model_path, batch_size):
+ inferencer = inferencer_script.new()
+ action_output_size = inferencer.Initialize(model_path, batch_size)
+
+# This function is the one that will be called from the game,
+# requires the observation as an array and the state_ins as an int
+# returns an Array containing the action the model takes.
+func run_inference(obs: Array, state_ins: int) -> Dictionary:
+ if inferencer == null:
+ printerr("Inferencer not initialized")
+ return {}
+ return inferencer.RunInference(obs, state_ins)
+
+
+func _notification(what):
+ if what == NOTIFICATION_PREDELETE:
+ inferencer.FreeDisposables()
+ inferencer.free()
+
+# Check whether agent uses a continuous actions model with only action means or not
+func set_action_means_only(agent_action_space):
+ action_means_only_set = true
+ var continuous_only: bool = true
+ var continuous_actions: int
+ for action in agent_action_space:
+ if not agent_action_space[action]["action_type"] == "continuous":
+ continuous_only = false
+ break
+ else:
+ continuous_actions += agent_action_space[action]["size"]
+ if continuous_only:
+ if continuous_actions == action_output_size:
+ action_means_only = true
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd.uid
new file mode 100755
index 0000000..95f620d
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd.uid
@@ -0,0 +1 @@
+uid://gfec60oin817
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/plugin.cfg b/examples/2DCoopBallChallenge/addons/godot_rl_agents/plugin.cfg
new file mode 100755
index 0000000..b1bc988
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="GodotRLAgents"
+description="Custom nodes for the godot rl agents toolkit "
+author="Edward Beeching"
+version="0.1"
+script="godot_rl_agents.gd"
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward2D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward2D.gd
new file mode 100755
index 0000000..c4aab2a
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward2D.gd
@@ -0,0 +1,30 @@
+extends RewardFunction2D
+class_name ApproachNodeReward2D
+
+## Calculates the reward for approaching node
+## a reward is only added when the agent reaches a new
+## best distance to the target object.
+
+## Best distance reward will be calculated for this object
+@export var target_node: Node2D
+
+## Scales the reward, 1.0 means the reward is equal to
+## how much closer the agent is than the previous best.
+@export_range(0.0, 1.0, 0.0001, "or_greater") var reward_scale: float = 1.0
+
+var _best_distance
+
+
+func get_reward() -> float:
+ var reward := 0.0
+ var current_distance := global_position.distance_to(target_node.global_position)
+ if not _best_distance:
+ _best_distance = current_distance
+ if current_distance < _best_distance:
+ reward = (_best_distance - current_distance) * reward_scale
+ _best_distance = current_distance
+ return reward
+
+
+func reset():
+ _best_distance = null
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward2D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward2D.gd.uid
new file mode 100755
index 0000000..75c0907
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward2D.gd.uid
@@ -0,0 +1 @@
+uid://b8bbgclt0ufcp
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward3D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward3D.gd
new file mode 100755
index 0000000..8333106
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward3D.gd
@@ -0,0 +1,30 @@
+extends RewardFunction3D
+class_name ApproachNodeReward3D
+
+## Calculates the reward for approaching node
+## a reward is only added when the agent reaches a new
+## best distance to the target object.
+
+## Best distance reward will be calculated for this object
+@export var target_node: Node3D
+
+## Scales the reward, 1.0 means the reward is equal to
+## how much closer the agent is than the previous best.
+@export var reward_scale: float = 1.0
+
+var _best_distance
+
+
+func get_reward() -> float:
+ var reward := 0.0
+ var current_distance := global_position.distance_to(target_node.global_position)
+ if not _best_distance:
+ _best_distance = current_distance
+ if current_distance < _best_distance:
+ reward = (_best_distance - current_distance) * reward_scale
+ _best_distance = current_distance
+ return reward
+
+
+func reset():
+ _best_distance = null
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward3D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward3D.gd.uid
new file mode 100755
index 0000000..8a4d694
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/ApproachNodeReward3D.gd.uid
@@ -0,0 +1 @@
+uid://d2rb1qrjjb7g5
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction2D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction2D.gd
new file mode 100755
index 0000000..0285f78
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction2D.gd
@@ -0,0 +1,10 @@
+extends Node2D
+class_name RewardFunction2D
+
+
+func get_reward():
+ return 0.0
+
+
+func reset():
+ return
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction2D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction2D.gd.uid
new file mode 100755
index 0000000..beae294
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction2D.gd.uid
@@ -0,0 +1 @@
+uid://7waiw8dxm3eu
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction3D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction3D.gd
new file mode 100755
index 0000000..87da5f9
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction3D.gd
@@ -0,0 +1,10 @@
+extends Node3D
+class_name RewardFunction3D
+
+
+func get_reward():
+ return 0.0
+
+
+func reset():
+ return
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction3D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction3D.gd.uid
new file mode 100755
index 0000000..7b072a3
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/rewards/RewardFunction3D.gd.uid
@@ -0,0 +1 @@
+uid://pb88x08p5gsm
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ExampleRaycastSensor2D.tscn b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ExampleRaycastSensor2D.tscn
new file mode 100755
index 0000000..5edb6c7
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ExampleRaycastSensor2D.tscn
@@ -0,0 +1,48 @@
+[gd_scene load_steps=5 format=3 uid="uid://ddeq7mn1ealyc"]
+
+[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd" id="1"]
+
+[sub_resource type="GDScript" id="2"]
+script/source = "extends Node2D
+
+
+
+func _physics_process(delta: float) -> void:
+ print(\"step start\")
+
+"
+
+[sub_resource type="GDScript" id="1"]
+script/source = "extends RayCast2D
+
+var steps = 1
+
+func _physics_process(delta: float) -> void:
+ print(\"processing raycast\")
+ steps += 1
+ if steps % 2:
+ force_raycast_update()
+
+ print(is_colliding())
+"
+
+[sub_resource type="CircleShape2D" id="3"]
+
+[node name="ExampleRaycastSensor2D" type="Node2D"]
+script = SubResource("2")
+
+[node name="ExampleAgent" type="Node2D" parent="."]
+position = Vector2(573, 314)
+rotation = 0.286234
+
+[node name="RaycastSensor2D" type="Node2D" parent="ExampleAgent"]
+script = ExtResource("1")
+
+[node name="TestRayCast2D" type="RayCast2D" parent="."]
+script = SubResource("1")
+
+[node name="StaticBody2D" type="StaticBody2D" parent="."]
+position = Vector2(1, 52)
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="StaticBody2D"]
+shape = SubResource("3")
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd
new file mode 100755
index 0000000..48b132e
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd
@@ -0,0 +1,235 @@
+@tool
+extends ISensor2D
+class_name GridSensor2D
+
+@export var debug_view := false:
+ get:
+ return debug_view
+ set(value):
+ debug_view = value
+ _update()
+
+@export_flags_2d_physics var detection_mask := 0:
+ get:
+ return detection_mask
+ set(value):
+ detection_mask = value
+ _update()
+
+@export var collide_with_areas := false:
+ get:
+ return collide_with_areas
+ set(value):
+ collide_with_areas = value
+ _update()
+
+@export var collide_with_bodies := true:
+ get:
+ return collide_with_bodies
+ set(value):
+ collide_with_bodies = value
+ _update()
+
+@export_range(1, 200, 0.1) var cell_width := 20.0:
+ get:
+ return cell_width
+ set(value):
+ cell_width = value
+ _update()
+
+@export_range(1, 200, 0.1) var cell_height := 20.0:
+ get:
+ return cell_height
+ set(value):
+ cell_height = value
+ _update()
+
+@export_range(1, 21, 2, "or_greater") var grid_size_x := 3:
+ get:
+ return grid_size_x
+ set(value):
+ grid_size_x = value
+ _update()
+
+@export_range(1, 21, 2, "or_greater") var grid_size_y := 3:
+ get:
+ return grid_size_y
+ set(value):
+ grid_size_y = value
+ _update()
+
+var _obs_buffer: PackedFloat64Array
+var _rectangle_shape: RectangleShape2D
+var _collision_mapping: Dictionary
+var _n_layers_per_cell: int
+
+var _highlighted_cell_color: Color
+var _standard_cell_color: Color
+
+
+func get_observation():
+ return _obs_buffer
+
+
+func _update():
+ if Engine.is_editor_hint():
+ if is_node_ready():
+ _spawn_nodes()
+
+
+func _ready() -> void:
+ _set_colors()
+
+ if Engine.is_editor_hint():
+ if get_child_count() == 0:
+ _spawn_nodes()
+ else:
+ _spawn_nodes()
+
+
+func _set_colors() -> void:
+ _standard_cell_color = Color(100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0)
+ _highlighted_cell_color = Color(255.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0)
+
+
+func _get_collision_mapping() -> Dictionary:
+ # defines which layer is mapped to which cell obs index
+ var total_bits = 0
+ var collision_mapping = {}
+ for i in 32:
+ var bit_mask = 2 ** i
+ if (detection_mask & bit_mask) > 0:
+ collision_mapping[i] = total_bits
+ total_bits += 1
+
+ return collision_mapping
+
+
+func _spawn_nodes():
+ for cell in get_children():
+ cell.name = "_%s" % cell.name # Otherwise naming below will fail
+ cell.queue_free()
+
+ _collision_mapping = _get_collision_mapping()
+ #prints("collision_mapping", _collision_mapping, len(_collision_mapping))
+ # allocate memory for the observations
+ _n_layers_per_cell = len(_collision_mapping)
+ _obs_buffer = PackedFloat64Array()
+ _obs_buffer.resize(grid_size_x * grid_size_y * _n_layers_per_cell)
+ _obs_buffer.fill(0)
+ #prints(len(_obs_buffer), _obs_buffer )
+
+ _rectangle_shape = RectangleShape2D.new()
+ _rectangle_shape.set_size(Vector2(cell_width, cell_height))
+
+ var shift := Vector2(
+ -(grid_size_x / 2) * cell_width,
+ -(grid_size_y / 2) * cell_height,
+ )
+
+ for i in grid_size_x:
+ for j in grid_size_y:
+ var cell_position = Vector2(i * cell_width, j * cell_height) + shift
+ _create_cell(i, j, cell_position)
+
+
+func _create_cell(i: int, j: int, position: Vector2):
+ var cell := Area2D.new()
+ cell.position = position
+ cell.name = "GridCell %s %s" % [i, j]
+ cell.modulate = _standard_cell_color
+
+ if collide_with_areas:
+ cell.area_entered.connect(_on_cell_area_entered.bind(i, j))
+ cell.area_exited.connect(_on_cell_area_exited.bind(i, j))
+
+ if collide_with_bodies:
+ cell.body_entered.connect(_on_cell_body_entered.bind(i, j))
+ cell.body_exited.connect(_on_cell_body_exited.bind(i, j))
+
+ cell.collision_layer = 0
+ cell.collision_mask = detection_mask
+ cell.monitorable = true
+ add_child(cell)
+ cell.set_owner(get_tree().edited_scene_root)
+
+ var col_shape := CollisionShape2D.new()
+ col_shape.shape = _rectangle_shape
+ col_shape.name = "CollisionShape2D"
+ cell.add_child(col_shape)
+ col_shape.set_owner(get_tree().edited_scene_root)
+
+ if debug_view:
+ var quad = MeshInstance2D.new()
+ quad.name = "MeshInstance2D"
+ var quad_mesh = QuadMesh.new()
+
+ quad_mesh.set_size(Vector2(cell_width, cell_height))
+
+ quad.mesh = quad_mesh
+ cell.add_child(quad)
+ quad.set_owner(get_tree().edited_scene_root)
+
+
+func _update_obs(cell_i: int, cell_j: int, collision_layer: int, entered: bool):
+ for key in _collision_mapping:
+ var bit_mask = 2 ** key
+ if (collision_layer & bit_mask) > 0:
+ var collison_map_index = _collision_mapping[key]
+
+ var obs_index = (
+ (cell_i * grid_size_y * _n_layers_per_cell)
+ + (cell_j * _n_layers_per_cell)
+ + collison_map_index
+ )
+ #prints(obs_index, cell_i, cell_j)
+ if entered:
+ _obs_buffer[obs_index] += 1
+ else:
+ _obs_buffer[obs_index] -= 1
+
+
+func _toggle_cell(cell_i: int, cell_j: int):
+ var cell = get_node_or_null("GridCell %s %s" % [cell_i, cell_j])
+
+ if cell == null:
+ print("cell not found, returning")
+
+ var n_hits = 0
+ var start_index = (cell_i * grid_size_y * _n_layers_per_cell) + (cell_j * _n_layers_per_cell)
+ for i in _n_layers_per_cell:
+ n_hits += _obs_buffer[start_index + i]
+
+ if n_hits > 0:
+ cell.modulate = _highlighted_cell_color
+ else:
+ cell.modulate = _standard_cell_color
+
+
+func _on_cell_area_entered(area: Area2D, cell_i: int, cell_j: int):
+ #prints("_on_cell_area_entered", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, area.collision_layer, true)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
+ #print(_obs_buffer)
+
+
+func _on_cell_area_exited(area: Area2D, cell_i: int, cell_j: int):
+ #prints("_on_cell_area_exited", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, area.collision_layer, false)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
+
+
+func _on_cell_body_entered(body: Node2D, cell_i: int, cell_j: int):
+ #prints("_on_cell_body_entered", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, body.collision_layer, true)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
+
+
+func _on_cell_body_exited(body: Node2D, cell_i: int, cell_j: int):
+ #prints("_on_cell_body_exited", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, body.collision_layer, false)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd.uid
new file mode 100755
index 0000000..574fa09
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd.uid
@@ -0,0 +1 @@
+uid://u2fug3ps4b8q
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd
new file mode 100755
index 0000000..67669a1
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd
@@ -0,0 +1,25 @@
+extends Node2D
+class_name ISensor2D
+
+var _obs: Array = []
+var _active := false
+
+
+func get_observation():
+ pass
+
+
+func activate():
+ _active = true
+
+
+func deactivate():
+ _active = false
+
+
+func _update_observation():
+ pass
+
+
+func reset():
+ pass
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd.uid
new file mode 100755
index 0000000..c09ea17
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd.uid
@@ -0,0 +1 @@
+uid://cdiyvwaxj2cud
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd
new file mode 100755
index 0000000..34ed435
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd
@@ -0,0 +1,65 @@
+extends ISensor2D
+class_name PositionSensor2D
+
+@export var objects_to_observe: Array[Node2D]
+
+## Whether to include relative x position in obs
+@export var include_x := true
+## Whether to include relative y position in obs
+@export var include_y := true
+
+## Max distance, values in obs will be normalized,
+## 0 will represent the closest distance possible, and 1 the farthest.
+## Do not use a much larger value than needed, as it would make the obs
+## very small after normalization.
+@export_range(0.01, 20_000) var max_distance := 1.0
+
+@export var use_separate_direction: bool = false
+
+@export var debug_lines: bool = true
+@export var debug_color: Color = Color.GREEN
+
+@onready var line: Line2D
+
+
+func _ready() -> void:
+ if debug_lines:
+ line = Line2D.new()
+ add_child(line)
+ line.width = 1
+ line.default_color = debug_color
+
+func get_observation():
+ var observations: Array[float]
+
+ if debug_lines:
+ line.clear_points()
+
+ for obj in objects_to_observe:
+ var relative_position := Vector2.ZERO
+
+ ## If object has been removed, keep the zeroed position
+ if is_instance_valid(obj): relative_position = to_local(obj.global_position)
+
+ if debug_lines:
+ line.add_point(Vector2.ZERO)
+ line.add_point(relative_position)
+
+ var direction := Vector2.ZERO
+ var distance := 0.0
+ if use_separate_direction:
+ direction = relative_position.normalized()
+ distance = min(relative_position.length() / max_distance, 1.0)
+ if include_x:
+ observations.append(direction.x)
+ if include_y:
+ observations.append(direction.y)
+ observations.append(distance)
+ else:
+ relative_position = relative_position.limit_length(max_distance) / max_distance
+ if include_x:
+ observations.append(relative_position.x)
+ if include_y:
+ observations.append(relative_position.y)
+
+ return observations
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd.uid
new file mode 100755
index 0000000..0e01291
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd.uid
@@ -0,0 +1 @@
+uid://cyrvpwvwcjebl
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.gd
new file mode 100755
index 0000000..3159c3a
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.gd
@@ -0,0 +1,77 @@
+extends Node2D
+class_name RGBCameraSensor2D
+var camera_pixels = null
+
+@export var camera_zoom_factor := Vector2(0.1, 0.1)
+@onready var camera := $SubViewport/Camera
+@onready var preview_window := $Control
+@onready var camera_texture := $Control/CameraTexture as Sprite2D
+@onready var processed_texture := $Control/ProcessedTexture as Sprite2D
+@onready var sub_viewport := $SubViewport as SubViewport
+@onready var displayed_image: ImageTexture
+
+@export var render_image_resolution := Vector2(36, 36)
+## Display size does not affect rendered or sent image resolution.
+## Scale is relative to either render image or downscale image resolution
+## depending on which mode is set.
+@export var displayed_image_scale_factor := Vector2(8, 8)
+
+@export_group("Downscale image options")
+## Enable to downscale the rendered image before sending the obs.
+@export var downscale_image: bool = false
+## If downscale_image is true, will display the downscaled image instead of rendered image.
+@export var display_downscaled_image: bool = true
+## This is the resolution of the image that will be sent after downscaling
+@export var resized_image_resolution := Vector2(36, 36)
+
+
+func _ready():
+ DisplayServer.register_additional_output(self)
+
+ camera.zoom = camera_zoom_factor
+
+ var preview_size: Vector2
+
+ sub_viewport.world_2d = get_tree().get_root().get_world_2d()
+ sub_viewport.size = render_image_resolution
+ camera_texture.scale = displayed_image_scale_factor
+
+ if downscale_image and display_downscaled_image:
+ camera_texture.visible = false
+ processed_texture.scale = displayed_image_scale_factor
+ preview_size = displayed_image_scale_factor * resized_image_resolution
+ else:
+ processed_texture.visible = false
+ preview_size = displayed_image_scale_factor * render_image_resolution
+
+ preview_window.size = preview_size
+
+
+func get_camera_pixel_encoding():
+ var image := camera_texture.get_texture().get_image() as Image
+
+ if downscale_image:
+ image.resize(
+ resized_image_resolution.x, resized_image_resolution.y, Image.INTERPOLATE_NEAREST
+ )
+ if display_downscaled_image:
+ if not processed_texture.texture:
+ displayed_image = ImageTexture.create_from_image(image)
+ processed_texture.texture = displayed_image
+ else:
+ displayed_image.update(image)
+
+ return image.get_data().hex_encode()
+
+
+func get_camera_shape() -> Array:
+ var size = resized_image_resolution if downscale_image else render_image_resolution
+
+ assert(
+ size.x >= 36 and size.y >= 36,
+ "Camera sensor sent image resolution must be 36x36 or larger."
+ )
+ if sub_viewport.transparent_bg:
+ return [4, size.y, size.x]
+ else:
+ return [3, size.y, size.x]
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.gd.uid
new file mode 100755
index 0000000..e8b98c5
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.gd.uid
@@ -0,0 +1 @@
+uid://bcj2e56a0clpc
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.tscn b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.tscn
new file mode 100755
index 0000000..94ab778
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.tscn
@@ -0,0 +1,36 @@
+[gd_scene load_steps=3 format=3 uid="uid://bav1cl8uwc45c"]
+
+[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_2d/RGBCameraSensor2D.gd" id="1_txpo2"]
+
+[sub_resource type="ViewportTexture" id="ViewportTexture_jks1s"]
+viewport_path = NodePath("SubViewport")
+
+[node name="RGBCameraSensor2D" type="Node2D"]
+script = ExtResource("1_txpo2")
+displayed_image_scale_factor = Vector2(3, 3)
+
+[node name="RemoteTransform" type="RemoteTransform2D" parent="."]
+remote_path = NodePath("../SubViewport/Camera")
+
+[node name="SubViewport" type="SubViewport" parent="."]
+canvas_item_default_texture_filter = 0
+size = Vector2i(36, 36)
+render_target_update_mode = 4
+
+[node name="Camera" type="Camera2D" parent="SubViewport"]
+position_smoothing_speed = 2.0
+
+[node name="Control" type="Window" parent="."]
+canvas_item_default_texture_filter = 0
+title = "CameraSensor"
+position = Vector2i(20, 40)
+size = Vector2i(64, 64)
+theme_override_font_sizes/title_font_size = 12
+metadata/_edit_use_anchors_ = true
+
+[node name="CameraTexture" type="Sprite2D" parent="Control"]
+texture = SubResource("ViewportTexture_jks1s")
+centered = false
+
+[node name="ProcessedTexture" type="Sprite2D" parent="Control"]
+centered = false
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd
new file mode 100755
index 0000000..9bb54ed
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd
@@ -0,0 +1,118 @@
+@tool
+extends ISensor2D
+class_name RaycastSensor2D
+
+@export_flags_2d_physics var collision_mask := 1:
+ get:
+ return collision_mask
+ set(value):
+ collision_mask = value
+ _update()
+
+@export var collide_with_areas := false:
+ get:
+ return collide_with_areas
+ set(value):
+ collide_with_areas = value
+ _update()
+
+@export var collide_with_bodies := true:
+ get:
+ return collide_with_bodies
+ set(value):
+ collide_with_bodies = value
+ _update()
+
+@export var n_rays := 16.0:
+ get:
+ return n_rays
+ set(value):
+ n_rays = value
+ _update()
+
+@export_range(5, 3000, 5.0) var ray_length := 200:
+ get:
+ return ray_length
+ set(value):
+ ray_length = value
+ _update()
+@export_range(5, 360, 5.0) var cone_width := 360.0:
+ get:
+ return cone_width
+ set(value):
+ cone_width = value
+ _update()
+
+@export var debug_draw := true:
+ get:
+ return debug_draw
+ set(value):
+ debug_draw = value
+ _update()
+
+var _angles = []
+var rays := []
+
+
+func _update():
+ if Engine.is_editor_hint():
+ if debug_draw:
+ _spawn_nodes()
+ else:
+ for ray in get_children():
+ if ray is RayCast2D:
+ remove_child(ray)
+
+
+func _ready() -> void:
+ _spawn_nodes()
+
+
+func _spawn_nodes():
+ for ray in rays:
+ ray.queue_free()
+ rays = []
+
+ _angles = []
+ var step = cone_width / (n_rays)
+ var start = step / 2 - cone_width / 2
+
+ for i in n_rays:
+ var angle = start + i * step
+ var ray = RayCast2D.new()
+ ray.set_target_position(
+ Vector2(ray_length * cos(deg_to_rad(angle)), ray_length * sin(deg_to_rad(angle)))
+ )
+ ray.set_name("node_" + str(i))
+ ray.enabled = false
+ ray.collide_with_areas = collide_with_areas
+ ray.collide_with_bodies = collide_with_bodies
+ ray.collision_mask = collision_mask
+ add_child(ray)
+ rays.append(ray)
+
+ _angles.append(start + i * step)
+
+
+func get_observation() -> Array:
+ return self.calculate_raycasts()
+
+
+func calculate_raycasts() -> Array:
+ var result = []
+ for ray in rays:
+ ray.enabled = true
+ ray.force_raycast_update()
+ var distance = _get_raycast_distance(ray)
+ result.append(distance)
+ ray.enabled = false
+ return result
+
+
+func _get_raycast_distance(ray: RayCast2D) -> float:
+ if !ray.is_colliding():
+ return 0.0
+
+ var distance = (global_position - ray.get_collision_point()).length()
+ distance = clamp(distance, 0.0, ray_length)
+ return (ray_length - distance) / ray_length
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd.uid
new file mode 100755
index 0000000..363b7b7
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd.uid
@@ -0,0 +1 @@
+uid://dar4d1peo5076
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.tscn b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.tscn
new file mode 100755
index 0000000..5ca402c
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.tscn
@@ -0,0 +1,7 @@
+[gd_scene load_steps=2 format=3 uid="uid://drvfihk5esgmv"]
+
+[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd" id="1"]
+
+[node name="RaycastSensor2D" type="Node2D"]
+script = ExtResource("1")
+n_rays = 17.0
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ExampleRaycastSensor3D.tscn b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ExampleRaycastSensor3D.tscn
new file mode 100755
index 0000000..a8057c7
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ExampleRaycastSensor3D.tscn
@@ -0,0 +1,6 @@
+[gd_scene format=3 uid="uid://biu787qh4woik"]
+
+[node name="ExampleRaycastSensor3D" type="Node3D"]
+
+[node name="Camera3D" type="Camera3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.804183, 0, 2.70146)
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd
new file mode 100755
index 0000000..24de9a4
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd
@@ -0,0 +1,258 @@
+@tool
+extends ISensor3D
+class_name GridSensor3D
+
+@export var debug_view := false:
+ get:
+ return debug_view
+ set(value):
+ debug_view = value
+ _update()
+
+@export_flags_3d_physics var detection_mask := 0:
+ get:
+ return detection_mask
+ set(value):
+ detection_mask = value
+ _update()
+
+@export var collide_with_areas := false:
+ get:
+ return collide_with_areas
+ set(value):
+ collide_with_areas = value
+ _update()
+
+@export var collide_with_bodies := false:
+ # NOTE! The sensor will not detect StaticBody3D, add an area to static bodies to detect them
+ get:
+ return collide_with_bodies
+ set(value):
+ collide_with_bodies = value
+ _update()
+
+@export_range(0.1, 2, 0.1) var cell_width := 1.0:
+ get:
+ return cell_width
+ set(value):
+ cell_width = value
+ _update()
+
+@export_range(0.1, 2, 0.1) var cell_height := 1.0:
+ get:
+ return cell_height
+ set(value):
+ cell_height = value
+ _update()
+
+@export_range(1, 21, 1, "or_greater") var grid_size_x := 3:
+ get:
+ return grid_size_x
+ set(value):
+ grid_size_x = value
+ _update()
+
+@export_range(1, 21, 1, "or_greater") var grid_size_z := 3:
+ get:
+ return grid_size_z
+ set(value):
+ grid_size_z = value
+ _update()
+
+var _obs_buffer: PackedFloat64Array
+var _box_shape: BoxShape3D
+var _collision_mapping: Dictionary
+var _n_layers_per_cell: int
+
+var _highlighted_box_material: StandardMaterial3D
+var _standard_box_material: StandardMaterial3D
+
+
+func get_observation():
+ return _obs_buffer
+
+
+func reset():
+ _obs_buffer.fill(0)
+
+
+func _update():
+ if Engine.is_editor_hint():
+ if is_node_ready():
+ _spawn_nodes()
+
+
+func _ready() -> void:
+ _make_materials()
+
+ if Engine.is_editor_hint():
+ if get_child_count() == 0:
+ _spawn_nodes()
+ else:
+ _spawn_nodes()
+
+
+func _make_materials() -> void:
+ if _highlighted_box_material != null and _standard_box_material != null:
+ return
+
+ _standard_box_material = StandardMaterial3D.new()
+ _standard_box_material.set_transparency(1) # ALPHA
+ _standard_box_material.albedo_color = Color(
+ 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0
+ )
+
+ _highlighted_box_material = StandardMaterial3D.new()
+ _highlighted_box_material.set_transparency(1) # ALPHA
+ _highlighted_box_material.albedo_color = Color(
+ 255.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0
+ )
+
+
+func _get_collision_mapping() -> Dictionary:
+ # defines which layer is mapped to which cell obs index
+ var total_bits = 0
+ var collision_mapping = {}
+ for i in 32:
+ var bit_mask = 2 ** i
+ if (detection_mask & bit_mask) > 0:
+ collision_mapping[i] = total_bits
+ total_bits += 1
+
+ return collision_mapping
+
+
+func _spawn_nodes():
+ for cell in get_children():
+ cell.name = "_%s" % cell.name # Otherwise naming below will fail
+ cell.queue_free()
+
+ _collision_mapping = _get_collision_mapping()
+ #prints("collision_mapping", _collision_mapping, len(_collision_mapping))
+ # allocate memory for the observations
+ _n_layers_per_cell = len(_collision_mapping)
+ _obs_buffer = PackedFloat64Array()
+ _obs_buffer.resize(grid_size_x * grid_size_z * _n_layers_per_cell)
+ _obs_buffer.fill(0)
+ #prints(len(_obs_buffer), _obs_buffer )
+
+ _box_shape = BoxShape3D.new()
+ _box_shape.set_size(Vector3(cell_width, cell_height, cell_width))
+
+ var shift := Vector3(
+ -(grid_size_x / 2) * cell_width,
+ 0,
+ -(grid_size_z / 2) * cell_width,
+ )
+
+ for i in grid_size_x:
+ for j in grid_size_z:
+ var cell_position = Vector3(i * cell_width, 0.0, j * cell_width) + shift
+ _create_cell(i, j, cell_position)
+
+
+func _create_cell(i: int, j: int, position: Vector3):
+ var cell := Area3D.new()
+ cell.position = position
+ cell.name = "GridCell %s %s" % [i, j]
+
+ if collide_with_areas:
+ cell.area_entered.connect(_on_cell_area_entered.bind(i, j))
+ cell.area_exited.connect(_on_cell_area_exited.bind(i, j))
+
+ if collide_with_bodies:
+ cell.body_entered.connect(_on_cell_body_entered.bind(i, j))
+ cell.body_exited.connect(_on_cell_body_exited.bind(i, j))
+
+# cell.body_shape_entered.connect(_on_cell_body_shape_entered.bind(i, j))
+# cell.body_shape_exited.connect(_on_cell_body_shape_exited.bind(i, j))
+
+ cell.collision_layer = 0
+ cell.collision_mask = detection_mask
+ cell.monitorable = true
+ cell.input_ray_pickable = false
+ add_child(cell)
+ cell.set_owner(get_tree().edited_scene_root)
+
+ var col_shape := CollisionShape3D.new()
+ col_shape.shape = _box_shape
+ col_shape.name = "CollisionShape3D"
+ cell.add_child(col_shape)
+ col_shape.set_owner(get_tree().edited_scene_root)
+
+ if debug_view:
+ var box = MeshInstance3D.new()
+ box.name = "MeshInstance3D"
+ var box_mesh = BoxMesh.new()
+
+ box_mesh.set_size(Vector3(cell_width, cell_height, cell_width))
+ box_mesh.material = _standard_box_material
+
+ box.mesh = box_mesh
+ cell.add_child(box)
+ box.set_owner(get_tree().edited_scene_root)
+
+
+func _update_obs(cell_i: int, cell_j: int, collision_layer: int, entered: bool):
+ for key in _collision_mapping:
+ var bit_mask = 2 ** key
+ if (collision_layer & bit_mask) > 0:
+ var collison_map_index = _collision_mapping[key]
+
+ var obs_index = (
+ (cell_i * grid_size_z * _n_layers_per_cell)
+ + (cell_j * _n_layers_per_cell)
+ + collison_map_index
+ )
+ #prints(obs_index, cell_i, cell_j)
+ if entered:
+ _obs_buffer[obs_index] += 1
+ else:
+ _obs_buffer[obs_index] -= 1
+
+
+func _toggle_cell(cell_i: int, cell_j: int):
+ var cell = get_node_or_null("GridCell %s %s" % [cell_i, cell_j])
+
+ if cell == null:
+ print("cell not found, returning")
+
+ var n_hits = 0
+ var start_index = (cell_i * grid_size_z * _n_layers_per_cell) + (cell_j * _n_layers_per_cell)
+ for i in _n_layers_per_cell:
+ n_hits += _obs_buffer[start_index + i]
+
+ var cell_mesh = cell.get_node_or_null("MeshInstance3D")
+ if n_hits > 0:
+ cell_mesh.mesh.material = _highlighted_box_material
+ else:
+ cell_mesh.mesh.material = _standard_box_material
+
+
+func _on_cell_area_entered(area: Area3D, cell_i: int, cell_j: int):
+ #prints("_on_cell_area_entered", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, area.collision_layer, true)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
+ #print(_obs_buffer)
+
+
+func _on_cell_area_exited(area: Area3D, cell_i: int, cell_j: int):
+ #prints("_on_cell_area_exited", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, area.collision_layer, false)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
+
+
+func _on_cell_body_entered(body: Node3D, cell_i: int, cell_j: int):
+ #prints("_on_cell_body_entered", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, body.collision_layer, true)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
+
+
+func _on_cell_body_exited(body: Node3D, cell_i: int, cell_j: int):
+ #prints("_on_cell_body_exited", cell_i, cell_j)
+ _update_obs(cell_i, cell_j, body.collision_layer, false)
+ if debug_view:
+ _toggle_cell(cell_i, cell_j)
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd.uid
new file mode 100755
index 0000000..bd607b3
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd.uid
@@ -0,0 +1 @@
+uid://dws62r023ufl5
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd
new file mode 100755
index 0000000..aca3c2d
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd
@@ -0,0 +1,25 @@
+extends Node3D
+class_name ISensor3D
+
+var _obs: Array = []
+var _active := false
+
+
+func get_observation():
+ pass
+
+
+func activate():
+ _active = true
+
+
+func deactivate():
+ _active = false
+
+
+func _update_observation():
+ pass
+
+
+func reset():
+ pass
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd.uid
new file mode 100755
index 0000000..57ef130
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd.uid
@@ -0,0 +1 @@
+uid://c8m1l06ajp0ll
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/PositionSensor3D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/PositionSensor3D.gd
new file mode 100755
index 0000000..85fcf03
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/PositionSensor3D.gd
@@ -0,0 +1,79 @@
+extends ISensor3D
+class_name PositionSensor3D
+
+@export var objects_to_observe: Array[Node3D]
+
+## Whether to include relative x position in obs
+@export var include_x := true
+## Whether to include relative y position in obs
+@export var include_y := true
+## Whether to include relative z position in obs
+@export var include_z := true
+
+## Max distance, values in obs will be normalized,
+## 0 will represent the closest distance possible, and 1 the farthest.
+## Do not use a much larger value than needed, as it would make the obs
+## very small after normalization.
+@export_range(0.01, 2_500) var max_distance := 1.0
+
+@export var use_separate_direction: bool = false
+
+@export var debug_lines: bool = true
+@export var debug_color: Color = Color.GREEN
+
+@onready var mesh: ImmediateMesh
+
+
+func _ready() -> void:
+ if debug_lines:
+ var debug_mesh = MeshInstance3D.new()
+ add_child(debug_mesh)
+ var line_material := StandardMaterial3D.new()
+ line_material.albedo_color = debug_color
+ debug_mesh.material_override = line_material
+ debug_mesh.mesh = ImmediateMesh.new()
+ mesh = debug_mesh.mesh
+
+
+func get_observation():
+ var observations: Array[float]
+
+ if debug_lines:
+ mesh.clear_surfaces()
+ mesh.surface_begin(Mesh.PRIMITIVE_LINES)
+ mesh.surface_set_color(debug_color)
+
+ for obj in objects_to_observe:
+ var relative_position := Vector3.ZERO
+
+ ## If object has been removed, keep the zeroed position
+ if is_instance_valid(obj): relative_position = to_local(obj.global_position)
+
+ if debug_lines:
+ mesh.surface_add_vertex(Vector3.ZERO)
+ mesh.surface_add_vertex(relative_position)
+
+ var direction := Vector3.ZERO
+ var distance := 0.0
+ if use_separate_direction:
+ direction = relative_position.normalized()
+ distance = min(relative_position.length() / max_distance, 1.0)
+ if include_x:
+ observations.append(direction.x)
+ if include_y:
+ observations.append(direction.y)
+ if include_z:
+ observations.append(direction.z)
+ observations.append(distance)
+ else:
+ relative_position = relative_position.limit_length(max_distance) / max_distance
+ if include_x:
+ observations.append(relative_position.x)
+ if include_y:
+ observations.append(relative_position.y)
+ if include_z:
+ observations.append(relative_position.z)
+
+ if debug_lines:
+ mesh.surface_end()
+ return observations
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/PositionSensor3D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/PositionSensor3D.gd.uid
new file mode 100755
index 0000000..0dd0de1
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/PositionSensor3D.gd.uid
@@ -0,0 +1 @@
+uid://d0dmvwg7a0jcd
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd
new file mode 100755
index 0000000..96dfb6a
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd
@@ -0,0 +1,63 @@
+extends Node3D
+class_name RGBCameraSensor3D
+var camera_pixels = null
+
+@onready var camera_texture := $Control/CameraTexture as Sprite2D
+@onready var processed_texture := $Control/ProcessedTexture as Sprite2D
+@onready var sub_viewport := $SubViewport as SubViewport
+@onready var displayed_image: ImageTexture
+
+@export var render_image_resolution := Vector2(36, 36)
+## Display size does not affect rendered or sent image resolution.
+## Scale is relative to either render image or downscale image resolution
+## depending on which mode is set.
+@export var displayed_image_scale_factor := Vector2(8, 8)
+
+@export_group("Downscale image options")
+## Enable to downscale the rendered image before sending the obs.
+@export var downscale_image: bool = false
+## If downscale_image is true, will display the downscaled image instead of rendered image.
+@export var display_downscaled_image: bool = true
+## This is the resolution of the image that will be sent after downscaling
+@export var resized_image_resolution := Vector2(36, 36)
+
+
+func _ready():
+ sub_viewport.size = render_image_resolution
+ camera_texture.scale = displayed_image_scale_factor
+
+ if downscale_image and display_downscaled_image:
+ camera_texture.visible = false
+ processed_texture.scale = displayed_image_scale_factor
+ else:
+ processed_texture.visible = false
+
+
+func get_camera_pixel_encoding():
+ var image := camera_texture.get_texture().get_image() as Image
+
+ if downscale_image:
+ image.resize(
+ resized_image_resolution.x, resized_image_resolution.y, Image.INTERPOLATE_NEAREST
+ )
+ if display_downscaled_image:
+ if not processed_texture.texture:
+ displayed_image = ImageTexture.create_from_image(image)
+ processed_texture.texture = displayed_image
+ else:
+ displayed_image.update(image)
+
+ return image.get_data().hex_encode()
+
+
+func get_camera_shape() -> Array:
+ var size = resized_image_resolution if downscale_image else render_image_resolution
+
+ assert(
+ size.x >= 36 and size.y >= 36,
+ "Camera sensor sent image resolution must be 36x36 or larger."
+ )
+ if sub_viewport.transparent_bg:
+ return [4, size.y, size.x]
+ else:
+ return [3, size.y, size.x]
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd.uid
new file mode 100755
index 0000000..c5c7614
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd.uid
@@ -0,0 +1 @@
+uid://71gve1ya8dw8
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.tscn b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.tscn
new file mode 100755
index 0000000..d58649c
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.tscn
@@ -0,0 +1,35 @@
+[gd_scene load_steps=3 format=3 uid="uid://baaywi3arsl2m"]
+
+[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd" id="1"]
+
+[sub_resource type="ViewportTexture" id="ViewportTexture_y72s3"]
+viewport_path = NodePath("SubViewport")
+
+[node name="RGBCameraSensor3D" type="Node3D"]
+script = ExtResource("1")
+
+[node name="RemoteTransform" type="RemoteTransform3D" parent="."]
+remote_path = NodePath("../SubViewport/Camera")
+
+[node name="SubViewport" type="SubViewport" parent="."]
+size = Vector2i(36, 36)
+render_target_update_mode = 3
+
+[node name="Camera" type="Camera3D" parent="SubViewport"]
+near = 0.5
+
+[node name="Control" type="Control" parent="."]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+metadata/_edit_use_anchors_ = true
+
+[node name="CameraTexture" type="Sprite2D" parent="Control"]
+texture = SubResource("ViewportTexture_y72s3")
+centered = false
+
+[node name="ProcessedTexture" type="Sprite2D" parent="Control"]
+centered = false
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd
new file mode 100755
index 0000000..1357529
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd
@@ -0,0 +1,185 @@
+@tool
+extends ISensor3D
+class_name RayCastSensor3D
+@export_flags_3d_physics var collision_mask = 1:
+ get:
+ return collision_mask
+ set(value):
+ collision_mask = value
+ _update()
+@export_flags_3d_physics var boolean_class_mask = 1:
+ get:
+ return boolean_class_mask
+ set(value):
+ boolean_class_mask = value
+ _update()
+
+@export var n_rays_width := 6.0:
+ get:
+ return n_rays_width
+ set(value):
+ n_rays_width = value
+ _update()
+
+@export var n_rays_height := 6.0:
+ get:
+ return n_rays_height
+ set(value):
+ n_rays_height = value
+ _update()
+
+@export var ray_length := 10.0:
+ get:
+ return ray_length
+ set(value):
+ ray_length = value
+ _update()
+
+@export var cone_width := 60.0:
+ get:
+ return cone_width
+ set(value):
+ cone_width = value
+ _update()
+
+@export var cone_height := 60.0:
+ get:
+ return cone_height
+ set(value):
+ cone_height = value
+ _update()
+
+@export var collide_with_areas := false:
+ get:
+ return collide_with_areas
+ set(value):
+ collide_with_areas = value
+ _update()
+
+@export var collide_with_bodies := true:
+ get:
+ return collide_with_bodies
+ set(value):
+ collide_with_bodies = value
+ _update()
+
+@export var class_sensor := false
+
+var rays := []
+var geo = null
+
+
+func _update():
+ if Engine.is_editor_hint():
+ if is_node_ready():
+ _spawn_nodes()
+
+
+func _ready() -> void:
+ if Engine.is_editor_hint():
+ if get_child_count() == 0:
+ _spawn_nodes()
+ else:
+ _spawn_nodes()
+
+
+func _spawn_nodes():
+ print("spawning nodes")
+ for ray in get_children():
+ ray.queue_free()
+ if geo:
+ geo.clear()
+ #$Lines.remove_points()
+ rays = []
+
+ var horizontal_step = cone_width / (n_rays_width)
+ var vertical_step = cone_height / (n_rays_height)
+
+ var horizontal_start = horizontal_step / 2 - cone_width / 2
+ var vertical_start = vertical_step / 2 - cone_height / 2
+
+ var points = []
+
+ for i in n_rays_width:
+ for j in n_rays_height:
+ var angle_w = horizontal_start + i * horizontal_step
+ var angle_h = vertical_start + j * vertical_step
+ #angle_h = 0.0
+ var ray = RayCast3D.new()
+ var cast_to = to_spherical_coords(ray_length, angle_w, angle_h)
+ ray.set_target_position(cast_to)
+
+ points.append(cast_to)
+
+ ray.set_name("node_" + str(i) + " " + str(j))
+ ray.enabled = true
+ ray.collide_with_bodies = collide_with_bodies
+ ray.collide_with_areas = collide_with_areas
+ ray.collision_mask = collision_mask
+ add_child(ray)
+ ray.set_owner(get_tree().edited_scene_root)
+ rays.append(ray)
+ ray.force_raycast_update()
+
+
+# if Engine.editor_hint:
+# _create_debug_lines(points)
+
+
+func _create_debug_lines(points):
+ if not geo:
+ geo = ImmediateMesh.new()
+ add_child(geo)
+
+ geo.clear()
+ geo.begin(Mesh.PRIMITIVE_LINES)
+ for point in points:
+ geo.set_color(Color.AQUA)
+ geo.add_vertex(Vector3.ZERO)
+ geo.add_vertex(point)
+ geo.end()
+
+
+func display():
+ if geo:
+ geo.display()
+
+
+func to_spherical_coords(r, inc, azimuth) -> Vector3:
+ return Vector3(
+ r * sin(deg_to_rad(inc)) * cos(deg_to_rad(azimuth)),
+ r * sin(deg_to_rad(azimuth)),
+ r * cos(deg_to_rad(inc)) * cos(deg_to_rad(azimuth))
+ )
+
+
+func get_observation() -> Array:
+ return self.calculate_raycasts()
+
+
+func calculate_raycasts() -> Array:
+ var result = []
+ for ray in rays:
+ ray.set_enabled(true)
+ ray.force_raycast_update()
+ var distance = _get_raycast_distance(ray)
+
+ result.append(distance)
+ if class_sensor:
+ var hit_class: float = 0
+ if ray.get_collider():
+ var hit_collision_layer = ray.get_collider().collision_layer
+ hit_collision_layer = hit_collision_layer & collision_mask
+ hit_class = (hit_collision_layer & boolean_class_mask) > 0
+ result.append(float(hit_class))
+ ray.set_enabled(false)
+ return result
+
+
+func _get_raycast_distance(ray: RayCast3D) -> float:
+ if !ray.is_colliding():
+ return 0.0
+
+ var distance = (global_transform.origin - ray.get_collision_point()).length()
+ distance = clamp(distance, 0.0, ray_length)
+ return (ray_length - distance) / ray_length
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd.uid
new file mode 100755
index 0000000..6872c71
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd.uid
@@ -0,0 +1 @@
+uid://budiinoaq8rgn
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.tscn b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.tscn
new file mode 100755
index 0000000..35f9796
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.tscn
@@ -0,0 +1,27 @@
+[gd_scene load_steps=2 format=3 uid="uid://b803cbh1fmy66"]
+
+[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd" id="1"]
+
+[node name="RaycastSensor3D" type="Node3D"]
+script = ExtResource("1")
+n_rays_width = 4.0
+n_rays_height = 2.0
+ray_length = 11.0
+
+[node name="node_1 0" type="RayCast3D" parent="."]
+target_position = Vector3(-1.38686, -2.84701, 10.5343)
+
+[node name="node_1 1" type="RayCast3D" parent="."]
+target_position = Vector3(-1.38686, 2.84701, 10.5343)
+
+[node name="node_2 0" type="RayCast3D" parent="."]
+target_position = Vector3(1.38686, -2.84701, 10.5343)
+
+[node name="node_2 1" type="RayCast3D" parent="."]
+target_position = Vector3(1.38686, 2.84701, 10.5343)
+
+[node name="node_3 0" type="RayCast3D" parent="."]
+target_position = Vector3(4.06608, -2.84701, 9.81639)
+
+[node name="node_3 1" type="RayCast3D" parent="."]
+target_position = Vector3(4.06608, 2.84701, 9.81639)
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sync.gd b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sync.gd
new file mode 100755
index 0000000..e9b7ca5
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sync.gd
@@ -0,0 +1,598 @@
+extends Node
+class_name Sync
+
+# --fixed-fps 2000 --disable-render-loop
+
+enum ControlModes {
+ HUMAN, ## Test the environment manually
+ TRAINING, ## Train a model
+ ONNX_INFERENCE ## Load a pretrained model using an .onnx file
+}
+@export var control_mode: ControlModes = ControlModes.TRAINING
+## Action will be repeated for n frames (Godot physics steps).
+@export_range(1, 10, 1, "or_greater") var action_repeat := 8
+## Speeds up the physics in the environment to enable faster training.
+@export_range(0, 10, 0.1, "or_greater") var speed_up := 1.0
+## The path to a trained .onnx model file to use for inference (only needed for the 'Onnx Inference' control mode).
+@export var onnx_model_path := ""
+
+# Onnx model stored for each requested path
+var onnx_models: Dictionary
+
+@onready var start_time = Time.get_ticks_msec()
+
+const MAJOR_VERSION := "0"
+const MINOR_VERSION := "7"
+const DEFAULT_PORT := "11008"
+const DEFAULT_SEED := "1"
+var stream: StreamPeerTCP = null
+var connected = false
+var message_center
+var should_connect = true
+
+var all_agents: Array
+var agents_training: Array
+## Policy name of each agent, for use with multi-policy multi-agent RL cases
+var agents_training_policy_names: Array[String] = ["shared_policy"]
+var agents_inference: Array
+var agents_heuristic: Array
+
+## For recording expert demos
+var agent_demo_record: Node
+## File path for writing recorded trajectories
+var expert_demo_save_path: String
+## Stores recorded trajectories
+var demo_trajectories: Array
+## A trajectory includes obs: Array, acts: Array, terminal (set in Python env instead)
+var current_demo_trajectory: Array
+
+var need_to_send_obs = false
+var args = null
+var initialized = false
+var just_reset = false
+var onnx_model = null
+var n_action_steps = 0
+
+var _action_space_training: Array[Dictionary] = []
+var _action_space_inference: Array[Dictionary] = []
+var _obs_space_training: Array[Dictionary] = []
+
+
+# Called when the node enters the scene tree for the first time.
+func _ready():
+ await get_parent().ready
+ get_tree().set_pause(true)
+ _initialize()
+ await get_tree().create_timer(1.0).timeout
+ get_tree().set_pause(false)
+
+
+func _initialize():
+ _get_agents()
+ args = _get_args()
+ Engine.physics_ticks_per_second = _get_speedup() * 60 # Replace with function body.
+ Engine.time_scale = _get_speedup() * 1.0
+ prints(
+ "physics ticks",
+ Engine.physics_ticks_per_second,
+ Engine.time_scale,
+ _get_speedup(),
+ speed_up
+ )
+
+ _set_heuristic("human", all_agents)
+
+ _initialize_training_agents()
+ _initialize_inference_agents()
+ _initialize_demo_recording()
+
+ _set_seed()
+ _set_action_repeat()
+ initialized = true
+
+
+func _initialize_training_agents():
+ if agents_training.size() > 0:
+ _obs_space_training.resize(agents_training.size())
+ _action_space_training.resize(agents_training.size())
+ for agent_idx in range(0, agents_training.size()):
+ _obs_space_training[agent_idx] = agents_training[agent_idx].get_obs_space()
+ _action_space_training[agent_idx] = agents_training[agent_idx].get_action_space()
+ connected = connect_to_server()
+ if connected:
+ _set_heuristic("model", agents_training)
+ _handshake()
+ _send_env_info()
+ else:
+ push_warning(
+ "Couldn't connect to Python server, using human controls instead. ",
+ "Did you start the training server using e.g. `gdrl` from the console?"
+ )
+
+
+func _initialize_inference_agents():
+ if agents_inference.size() > 0:
+ if control_mode == ControlModes.ONNX_INFERENCE:
+ assert(
+ FileAccess.file_exists(onnx_model_path),
+ "Onnx Model Path set on Sync node does not exist: %s" % onnx_model_path
+ )
+ onnx_models[onnx_model_path] = ONNXModel.new(onnx_model_path, 1)
+
+ for agent in agents_inference:
+ var action_space = agent.get_action_space()
+ _action_space_inference.append(action_space)
+
+ var agent_onnx_model: ONNXModel
+ if agent.onnx_model_path.is_empty():
+ assert(
+ onnx_models.has(onnx_model_path),
+ (
+ "Node %s has no onnx model path set " % agent.get_path()
+ + "and sync node's control mode is not set to OnnxInference. "
+ + "Either add the path to the AIController, "
+ + "or if you want to use the path set on sync node instead, "
+ + "set control mode to OnnxInference."
+ )
+ )
+ prints(
+ "Info: AIController %s" % agent.get_path(),
+ "has no onnx model path set.",
+ "Using path set on the sync node instead."
+ )
+ agent_onnx_model = onnx_models[onnx_model_path]
+ else:
+ if not onnx_models.has(agent.onnx_model_path):
+ assert(
+ FileAccess.file_exists(agent.onnx_model_path),
+ (
+ "Onnx Model Path set on %s node does not exist: %s"
+ % [agent.get_path(), agent.onnx_model_path]
+ )
+ )
+ onnx_models[agent.onnx_model_path] = ONNXModel.new(agent.onnx_model_path, 1)
+ agent_onnx_model = onnx_models[agent.onnx_model_path]
+
+ agent.onnx_model = agent_onnx_model
+ if not agent_onnx_model.action_means_only_set:
+ agent_onnx_model.set_action_means_only(action_space)
+
+ _set_heuristic("model", agents_inference)
+
+
+func _initialize_demo_recording():
+ if agent_demo_record:
+ expert_demo_save_path = agent_demo_record.expert_demo_save_path
+ assert(
+ not expert_demo_save_path.is_empty(),
+ "Expert demo save path set in %s is empty." % agent_demo_record.get_path()
+ )
+
+ InputMap.add_action("RemoveLastDemoEpisode")
+ InputMap.action_add_event(
+ "RemoveLastDemoEpisode", agent_demo_record.remove_last_episode_key
+ )
+ current_demo_trajectory.resize(2)
+ current_demo_trajectory[0] = []
+ current_demo_trajectory[1] = []
+ agent_demo_record.heuristic = "demo_record"
+
+
+func _physics_process(_delta):
+ # two modes, human control, agent control
+ # pause tree, send obs, get actions, set actions, unpause tree
+
+ _demo_record_process()
+
+ if n_action_steps % action_repeat != 0:
+ n_action_steps += 1
+ return
+
+ n_action_steps += 1
+
+ _training_process()
+ _inference_process()
+ _heuristic_process()
+
+
+func _training_process():
+ if connected:
+ get_tree().set_pause(true)
+
+ var obs = _get_obs_from_agents(agents_training)
+ var info = _get_info_from_agents(agents_training)
+
+ if just_reset:
+ just_reset = false
+
+ var reply = {"type": "reset", "obs": obs, "info": info}
+ _send_dict_as_json_message(reply)
+ # this should go straight to getting the action and setting it checked the agent, no need to perform one phyics tick
+ get_tree().set_pause(false)
+ return
+
+ if need_to_send_obs:
+ need_to_send_obs = false
+ var reward = _get_reward_from_agents()
+ var done = _get_done_from_agents()
+ #_reset_agents_if_done() # this ensures the new observation is from the next env instance : NEEDS REFACTOR
+
+ var reply = {"type": "step", "obs": obs, "reward": reward, "done": done, "info": info}
+ _send_dict_as_json_message(reply)
+
+ var handled = handle_message()
+
+
+func _inference_process():
+ if agents_inference.size() > 0:
+ var obs: Array = _get_obs_from_agents(agents_inference)
+ var actions = []
+
+ for agent_id in range(0, agents_inference.size()):
+ var model: ONNXModel = agents_inference[agent_id].onnx_model
+ var action = model.run_inference(obs[agent_id]["obs"], 1.0)
+ var action_dict = _extract_action_dict(
+ action["output"], _action_space_inference[agent_id], model.action_means_only
+ )
+ actions.append(action_dict)
+
+ _set_agent_actions(actions, agents_inference)
+ _reset_agents_if_done(agents_inference)
+ get_tree().set_pause(false)
+
+
+func _demo_record_process():
+ if not agent_demo_record:
+ return
+
+ if Input.is_action_just_pressed("RemoveLastDemoEpisode"):
+ print("[Sync script][Demo recorder] Removing last recorded episode.")
+ demo_trajectories.remove_at(demo_trajectories.size() - 1)
+ print("Remaining episode count: %d" % demo_trajectories.size())
+
+ if n_action_steps % agent_demo_record.action_repeat != 0:
+ return
+
+ var obs_dict: Dictionary = agent_demo_record.get_obs()
+
+ # Get the current obs from the agent
+ assert(
+ obs_dict.has("obs"),
+ "Demo recorder needs an 'obs' key in get_obs() returned dictionary to record obs from."
+ )
+ current_demo_trajectory[0].append(obs_dict.obs)
+
+ # Get the action applied for the current obs from the agent
+ agent_demo_record.set_action()
+ var acts = agent_demo_record.get_action()
+
+ var terminal = agent_demo_record.get_done()
+ # Record actions only for non-terminal states
+ if terminal:
+ agent_demo_record.set_done_false()
+ else:
+ current_demo_trajectory[1].append(acts)
+
+ if terminal:
+ #current_demo_trajectory[2].append(true)
+ demo_trajectories.append(current_demo_trajectory.duplicate(true))
+ print("[Sync script][Demo recorder] Recorded episode count: %d" % demo_trajectories.size())
+ current_demo_trajectory[0].clear()
+ current_demo_trajectory[1].clear()
+
+
+func _heuristic_process():
+ for agent in agents_heuristic:
+ _reset_agents_if_done(agents_heuristic)
+
+
+func _extract_action_dict(action_array: Array, action_space: Dictionary, action_means_only: bool):
+ var index = 0
+ var result = {}
+ for key in action_space.keys():
+ var size = action_space[key]["size"]
+ var action_type = action_space[key]["action_type"]
+ if action_type == "discrete":
+ var largest_logit: float = -INF # Value of the largest logit for this action in the actions array
+ var largest_logit_idx: int = 0 # Index of the largest logit for this action in the actions array
+ for logit_idx in range(0, size):
+ var logit_value = action_array[index + logit_idx]
+ if logit_value > largest_logit:
+ largest_logit = logit_value
+ largest_logit_idx = logit_idx
+ result[key] = largest_logit_idx # Index of the largest logit is the discrete action value
+ index += size
+ elif action_type == "continuous":
+ # For continous actions, we only take the action mean values
+ result[key] = clamp_array(action_array.slice(index, index + size), -1.0, 1.0)
+ if action_means_only:
+ index += size # model only outputs action means, so we move index by size
+ else:
+ index += size * 2 # model outputs logstd after action mean, we skip the logstd part
+
+ else:
+ assert(
+ false,
+ (
+ 'Only "discrete" and "continuous" action types supported. Found: %s action type set.'
+ % action_type
+ )
+ )
+
+ return result
+
+
+## For AIControllers that inherit mode from sync, sets the correct mode.
+func _set_agent_mode(agent: Node):
+ var agent_inherits_mode: bool = agent.control_mode == agent.ControlModes.INHERIT_FROM_SYNC
+
+ if agent_inherits_mode:
+ match control_mode:
+ ControlModes.HUMAN:
+ agent.control_mode = agent.ControlModes.HUMAN
+ ControlModes.TRAINING:
+ agent.control_mode = agent.ControlModes.TRAINING
+ ControlModes.ONNX_INFERENCE:
+ agent.control_mode = agent.ControlModes.ONNX_INFERENCE
+
+
+func _get_agents():
+ all_agents = get_tree().get_nodes_in_group("AGENT")
+ for agent in all_agents:
+ _set_agent_mode(agent)
+
+ if agent.control_mode == agent.ControlModes.TRAINING:
+ agents_training.append(agent)
+ elif agent.control_mode == agent.ControlModes.ONNX_INFERENCE:
+ agents_inference.append(agent)
+ elif agent.control_mode == agent.ControlModes.HUMAN:
+ agents_heuristic.append(agent)
+ elif agent.control_mode == agent.ControlModes.RECORD_EXPERT_DEMOS:
+ assert(
+ not agent_demo_record,
+ "Currently only a single AIController can be used for recording expert demos."
+ )
+ agent_demo_record = agent
+
+ var training_agent_count = agents_training.size()
+ agents_training_policy_names.resize(training_agent_count)
+ for i in range(0, training_agent_count):
+ agents_training_policy_names[i] = agents_training[i].policy_name
+
+
+func _set_heuristic(heuristic, agents: Array):
+ for agent in agents:
+ agent.set_heuristic(heuristic)
+
+
+func _handshake():
+ print("performing handshake")
+
+ var json_dict = _get_dict_json_message()
+ assert(json_dict["type"] == "handshake")
+ var major_version = json_dict["major_version"]
+ var minor_version = json_dict["minor_version"]
+ if major_version != MAJOR_VERSION:
+ print("WARNING: major verison mismatch ", major_version, " ", MAJOR_VERSION)
+ if minor_version != MINOR_VERSION:
+ print("WARNING: minor verison mismatch ", minor_version, " ", MINOR_VERSION)
+
+ print("handshake complete")
+
+
+func _get_dict_json_message():
+ # returns a dictionary from of the most recent message
+ # this is not waiting
+ while stream.get_available_bytes() == 0:
+ stream.poll()
+ if stream.get_status() != 2:
+ print("server disconnected status, closing")
+ get_tree().quit()
+ return null
+
+ OS.delay_usec(10)
+
+ var message = stream.get_string()
+ var json_data = JSON.parse_string(message)
+
+ return json_data
+
+
+func _send_dict_as_json_message(dict):
+ stream.put_string(JSON.stringify(dict, "", false))
+
+
+func _send_env_info():
+ var json_dict = _get_dict_json_message()
+ assert(json_dict["type"] == "env_info")
+
+ var message = {
+ "type": "env_info",
+ "observation_space": _obs_space_training,
+ "action_space": _action_space_training,
+ "n_agents": len(agents_training),
+ "agent_policy_names": agents_training_policy_names
+ }
+ _send_dict_as_json_message(message)
+
+
+func connect_to_server():
+ print("Waiting for one second to allow server to start")
+ OS.delay_msec(1000)
+ print("trying to connect to server")
+ stream = StreamPeerTCP.new()
+
+ # "localhost" was not working on windows VM, had to use the IP
+ var ip = "127.0.0.1"
+ var port = _get_port()
+ var connect = stream.connect_to_host(ip, port)
+ stream.set_no_delay(true) # TODO check if this improves performance or not
+ stream.poll()
+ # Fetch the status until it is either connected (2) or failed to connect (3)
+ while stream.get_status() < 2:
+ stream.poll()
+ return stream.get_status() == 2
+
+
+func _get_args():
+ print("getting command line arguments")
+ var arguments = {}
+ for argument in OS.get_cmdline_args():
+ print(argument)
+ if argument.find("=") > -1:
+ var key_value = argument.split("=")
+ arguments[key_value[0].lstrip("--")] = key_value[1]
+ else:
+ # Options without an argument will be present in the dictionary,
+ # with the value set to an empty string.
+ arguments[argument.lstrip("--")] = ""
+
+ return arguments
+
+
+func _get_speedup():
+ print(args)
+ return args.get("speedup", str(speed_up)).to_float()
+
+
+func _get_port():
+ return args.get("port", DEFAULT_PORT).to_int()
+
+
+func _set_seed():
+ var _seed = args.get("env_seed", DEFAULT_SEED).to_int()
+ seed(_seed)
+
+
+func _set_action_repeat():
+ action_repeat = args.get("action_repeat", str(action_repeat)).to_int()
+
+
+func disconnect_from_server():
+ stream.disconnect_from_host()
+
+
+func handle_message() -> bool:
+ # get json message: reset, step, close
+ var message = _get_dict_json_message()
+ if message["type"] == "close":
+ print("received close message, closing game")
+ get_tree().quit()
+ get_tree().set_pause(false)
+ return true
+
+ if message["type"] == "reset":
+ print("resetting all agents")
+ _reset_agents()
+ just_reset = true
+ get_tree().set_pause(false)
+ #print("resetting forcing draw")
+# RenderingServer.force_draw()
+# var obs = _get_obs_from_agents()
+# print("obs ", obs)
+# var reply = {
+# "type": "reset",
+# "obs": obs
+# }
+# _send_dict_as_json_message(reply)
+ return true
+
+ if message["type"] == "call":
+ var method = message["method"]
+ var returns = _call_method_on_agents(method)
+ var reply = {"type": "call", "returns": returns}
+ print("calling method from Python")
+ _send_dict_as_json_message(reply)
+ return handle_message()
+
+ if message["type"] == "action":
+ var action = message["action"]
+ _set_agent_actions(action, agents_training)
+ need_to_send_obs = true
+ get_tree().set_pause(false)
+ return true
+
+ print("message was not handled")
+ return false
+
+
+func _call_method_on_agents(method):
+ var returns = []
+ for agent in all_agents:
+ returns.append(agent.call(method))
+
+ return returns
+
+
+func _reset_agents_if_done(agents = all_agents):
+ for agent in agents:
+ if agent.get_done():
+ agent.set_done_false()
+
+
+func _reset_agents(agents = all_agents):
+ for agent in agents:
+ agent.needs_reset = true
+ #agent.reset()
+
+
+func _get_obs_from_agents(agents: Array = all_agents):
+ var obs = []
+ for agent in agents:
+ obs.append(agent.get_obs())
+ return obs
+
+
+func _get_reward_from_agents(agents: Array = agents_training):
+ var rewards = []
+ for agent in agents:
+ rewards.append(agent.get_reward())
+ agent.zero_reward()
+ return rewards
+
+
+func _get_info_from_agents(agents: Array = all_agents):
+ var info = []
+ for agent in agents:
+ info.append(agent.get_info())
+ return info
+
+
+func _get_done_from_agents(agents: Array = agents_training):
+ var dones = []
+ for agent in agents:
+ var done = agent.get_done()
+ if done:
+ agent.set_done_false()
+ dones.append(done)
+ return dones
+
+
+func _set_agent_actions(actions, agents: Array = all_agents):
+ for i in range(len(actions)):
+ agents[i].set_action(actions[i])
+
+
+func clamp_array(arr: Array, min: float, max: float):
+ var output: Array = []
+ for a in arr:
+ output.append(clamp(a, min, max))
+ return output
+
+
+## Save recorded export demos on window exit (Close game window instead of "Stop" button in Godot Editor)
+func _notification(what):
+ if demo_trajectories.size() == 0 or expert_demo_save_path.is_empty():
+ return
+
+ if what == NOTIFICATION_PREDELETE:
+ var json_string = JSON.stringify(demo_trajectories, "", false)
+ var file = FileAccess.open(expert_demo_save_path, FileAccess.WRITE)
+
+ if not file:
+ var error: Error = FileAccess.get_open_error()
+ assert(not error, "There was an error opening the file: %d" % error)
+
+ file.store_line(json_string)
+ var error = file.get_error()
+ assert(not error, "There was an error after trying to write to the file: %d" % error)
diff --git a/examples/2DCoopBallChallenge/addons/godot_rl_agents/sync.gd.uid b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sync.gd.uid
new file mode 100755
index 0000000..3b71256
--- /dev/null
+++ b/examples/2DCoopBallChallenge/addons/godot_rl_agents/sync.gd.uid
@@ -0,0 +1 @@
+uid://dkxvjlyxwlu3t
diff --git a/examples/2DCoopBallChallenge/assets/goal.png b/examples/2DCoopBallChallenge/assets/goal.png
new file mode 100755
index 0000000..5e6fb70
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/goal.png differ
diff --git a/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump1.png b/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump1.png
new file mode 100755
index 0000000..9a1719b
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump1.png differ
diff --git a/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump2.png b/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump2.png
new file mode 100755
index 0000000..d64645e
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump2.png differ
diff --git a/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump3.png b/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump3.png
new file mode 100755
index 0000000..79cecf1
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/player/jump/Player1Jump3.png differ
diff --git a/examples/2DCoopBallChallenge/assets/player/move/Player-1.png b/examples/2DCoopBallChallenge/assets/player/move/Player-1.png
new file mode 100755
index 0000000..aacd953
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/player/move/Player-1.png differ
diff --git a/examples/2DCoopBallChallenge/assets/player/move/Player-2.png b/examples/2DCoopBallChallenge/assets/player/move/Player-2.png
new file mode 100755
index 0000000..e750568
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/player/move/Player-2.png differ
diff --git a/examples/2DCoopBallChallenge/assets/player/move/Player-3.png b/examples/2DCoopBallChallenge/assets/player/move/Player-3.png
new file mode 100755
index 0000000..f9b894b
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/player/move/Player-3.png differ
diff --git a/examples/2DCoopBallChallenge/assets/tilesheet.png b/examples/2DCoopBallChallenge/assets/tilesheet.png
new file mode 100755
index 0000000..ee1352d
Binary files /dev/null and b/examples/2DCoopBallChallenge/assets/tilesheet.png differ
diff --git a/examples/2DCoopBallChallenge/icon.svg b/examples/2DCoopBallChallenge/icon.svg
new file mode 100755
index 0000000..b370ceb
--- /dev/null
+++ b/examples/2DCoopBallChallenge/icon.svg
@@ -0,0 +1 @@
+
diff --git a/examples/2DCoopBallChallenge/license.md b/examples/2DCoopBallChallenge/license.md
new file mode 100755
index 0000000..5a2bf82
--- /dev/null
+++ b/examples/2DCoopBallChallenge/license.md
@@ -0,0 +1,5 @@
+2DCoopBallChallenge Environment made by Ivan Dodic (https://github.com/Ivan-267)
+
+The following license is only for the graphical assets in the folder "assets", specifically .png files:
+Author: Ivan Dodic (https://github.com/Ivan-267),
+License: https://creativecommons.org/licenses/by/4.0/
diff --git a/examples/2DCoopBallChallenge/onnx/model.onnx b/examples/2DCoopBallChallenge/onnx/model.onnx
new file mode 100755
index 0000000..fa23d3c
Binary files /dev/null and b/examples/2DCoopBallChallenge/onnx/model.onnx differ
diff --git a/examples/2DCoopBallChallenge/project.godot b/examples/2DCoopBallChallenge/project.godot
new file mode 100755
index 0000000..e626a33
--- /dev/null
+++ b/examples/2DCoopBallChallenge/project.godot
@@ -0,0 +1,72 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="2DCoopBallChallenge"
+run/main_scene="uid://b6fkgmq1op7kd"
+config/features=PackedStringArray("4.4", "C#", "Forward Plus")
+config/icon="res://icon.svg"
+
+[display]
+
+window/size/viewport_width=1920
+window/size/viewport_height=1080
+
+[dotnet]
+
+project/assembly_name="Platform2D"
+
+[editor_plugins]
+
+enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
+
+[global_group]
+
+AGENT2=""
+AGENT3=""
+AGENT4=""
+AGENT1=""
+AGENT5=""
+AGENT6=""
+
+[input]
+
+move_left={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+move_right={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+jump={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+kick_ball={
+"deadzone": 0.2,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
+]
+}
+
+[physics]
+
+common/max_physics_steps_per_frame=32
+
+[rendering]
+
+environment/defaults/default_clear_color=Color(0, 0, 0, 1)
diff --git a/examples/2DCoopBallChallenge/readme.md b/examples/2DCoopBallChallenge/readme.md
new file mode 100755
index 0000000..53db47a
--- /dev/null
+++ b/examples/2DCoopBallChallenge/readme.md
@@ -0,0 +1,95 @@
+# 2DCoopBallChallenge environment
+
+
+## Goal:
+- The ball needs to reach the goal, it must not fall and/or hit any ground tile with grass ("platform" tile), otherwise a negative reward is given (ground tiles the left-right and top sections of the maps are allowed).
+- Only one player is allowed to hit the ball at a time, so cooperation / taking turns is required.
+- There is a time out period as well with a negative reward if reached.
+
+## Observations:
+```gdscript
+func get_obs() -> Dictionary:
+ var obs: PackedFloat32Array
+
+ for sensor in sensors:
+ obs.append_array(sensor.get_observation())
+
+ for player in players.players:
+ (
+ obs
+ . append_array(
+ [
+ float(player.is_on_floor()),
+ float(player.last_to_hit_ball)
+ ]
+ )
+ )
+ return {"obs": obs}
+```
+
+The data collected from sensor includes:
+- 2 Raycast sensors from each player, one for ground tiles (that the ball can collide with), and other for the "platform" tiles.
+- Each player has a position sensor that tracks relative positions to: ball, goal, and other player.
+- There's an additional position sensor that tracks the relative position of the goal from the ball.
+- There's a velocity sensor that tracks velocities of both players and the ball.
+
+## Running inference:
+
+If you’d just like to test the env using the pre-trained onnx model, open `res://scenes/training_scene/inference_scene.tscn` in Godot, then press `F6`.
+
+## Training:
+
+There’s an included onnx file that was trained with https://github.com/edbeeching/godot_rl_agents/blob/main/examples/stable_baselines3_example.py
+
+Hyperparams used (you can modify the script to use these):
+```python
+if args.resume_model_path is None:
+ learning_rate = 0.00008 if not args.linear_lr_schedule else linear_schedule(0.0001)
+
+ model: PPO = PPO(
+ "MlpPolicy",
+ env,
+ verbose=2,
+ n_steps=32,
+ batch_size=32 * env.num_envs,
+ n_epochs=80,
+ target_kl=0.01,
+ learning_rate=learning_rate,
+ tensorboard_log=args.experiment_dir,
+ )
+```
+
+Note that while `MlpPolicy` was used which requires further change to:
+`env = SBGSingleObsEnv(
+ env_path=args.env_path, show_window=args.viz, seed=args.seed, n_parallel=args.n_parallel, speedup=args.speedup,
+)`
+
+and:
+
+`export_model_as_onnx(model, str(path_onnx), use_obs_array=True)`
+
+This is not necessary, you can just use:
+`"MultiInputPolicy"` instead, then you don't need the two changes above and it shouldn't affect the performance, but still the original setting is included above for reference.
+
+CL arguments used (also onnx export and model saving was used, enable as needed, add `env_path` too to set the exported executable of the platform):
+
+```python
+--onnx_export_path=model.onnx
+--timesteps=150_000_000
+--env_path=EXPORTED ENV PATH HERE
+--n_parallel=4
+--speedup=16
+--experiment_name=exp
+```
+
+Additionally, `--save_checkpoint_frequency=1_000_000` was used, you can optionally add it if you'd like to have checkpoints as the agent trains.
+
+Further note:
+It's possible that training was done using a slight modification of the sync node:
+` stream.set_no_delay(false) # TODO check if this improves performance or not `
+it might have been set to `false` instead of the default `true` (to which I returned when using Python inference on Linux, as I noticed with false it seemed laggier),
+however this shouldn't affect the training results and you shouldn't need to modify it.
+
+Stats from the training session (success rate only):
+
+
diff --git a/examples/2DCoopBallChallenge/scenes/ball/ball.tscn b/examples/2DCoopBallChallenge/scenes/ball/ball.tscn
new file mode 100755
index 0000000..993e109
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/ball/ball.tscn
@@ -0,0 +1,50 @@
+[gd_scene load_steps=7 format=3 uid="uid://wdjkbtjihjoq"]
+
+[ext_resource type="Script" uid="uid://8xjlwdi087jf" path="res://scenes/training_scene/ball.gd" id="1_nv6q1"]
+[ext_resource type="Script" uid="uid://b8bbgclt0ufcp" path="res://addons/godot_rl_agents/rewards/ApproachNodeReward2D.gd" id="2_stoaj"]
+[ext_resource type="Script" uid="uid://cyrvpwvwcjebl" path="res://addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd" id="3_stoaj"]
+
+[sub_resource type="SphereMesh" id="SphereMesh_dgtlv"]
+radius = 50.0
+height = 100.0
+
+[sub_resource type="CircleShape2D" id="CircleShape2D_jl3mj"]
+radius = 40.0
+
+[sub_resource type="CircleShape2D" id="CircleShape2D_3kmt7"]
+radius = 100.0
+
+[node name="Ball" type="CharacterBody2D" node_paths=PackedStringArray("approach_goal_reward")]
+collision_layer = 4
+collision_mask = 9
+motion_mode = 1
+wall_min_slide_angle = 0.0
+platform_on_leave = 2
+script = ExtResource("1_nv6q1")
+approach_goal_reward = NodePath("ApproachGoalReward")
+
+[node name="MeshInstance2D" type="MeshInstance2D" parent="."]
+mesh = SubResource("SphereMesh_dgtlv")
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+shape = SubResource("CircleShape2D_jl3mj")
+
+[node name="Area2D" type="Area2D" parent="."]
+process_mode = 1
+collision_layer = 0
+collision_mask = 19
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D"]
+shape = SubResource("CircleShape2D_3kmt7")
+
+[node name="ApproachGoalReward" type="Node2D" parent="."]
+script = ExtResource("2_stoaj")
+reward_scale = 0.1
+metadata/_custom_type_script = "uid://b8bbgclt0ufcp"
+
+[node name="PositionSensor2D" type="Node2D" parent="."]
+script = ExtResource("3_stoaj")
+debug_lines = false
+metadata/_custom_type_script = "uid://cyrvpwvwcjebl"
+
+[connection signal="body_shape_entered" from="Area2D" to="." method="_on_area_2d_body_shape_entered"]
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/all_game_scenes.tscn b/examples/2DCoopBallChallenge/scenes/game_scene/all_game_scenes.tscn
new file mode 100755
index 0000000..2bc490c
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/all_game_scenes.tscn
@@ -0,0 +1,16 @@
+[gd_scene load_steps=4 format=3 uid="uid://dl1ldlvoh3s4n"]
+
+[ext_resource type="PackedScene" uid="uid://e3y017d7w6rb" path="res://scenes/game_scene/game_scene_3.tscn" id="2_b1s8e"]
+[ext_resource type="PackedScene" uid="uid://p8ny2i03ct7i" path="res://scenes/game_scene/game_scene_1.tscn" id="2_uymmq"]
+[ext_resource type="PackedScene" uid="uid://d1ax4bj7gdem6" path="res://scenes/game_scene/game_scene_2.tscn" id="3_ik6hd"]
+
+[node name="AllGameScenes" type="Node2D"]
+
+[node name="GameScene1" parent="." instance=ExtResource("2_uymmq")]
+position = Vector2(-2908, -12000)
+
+[node name="GameScene2" parent="." instance=ExtResource("3_ik6hd")]
+position = Vector2(-2908, -1468)
+
+[node name="GameScene3" parent="." instance=ExtResource("2_b1s8e")]
+position = Vector2(-2908, 12000)
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/game_manager.gd b/examples/2DCoopBallChallenge/scenes/game_scene/game_manager.gd
new file mode 100755
index 0000000..ffdcaec
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/game_manager.gd
@@ -0,0 +1,43 @@
+extends Node2D
+class_name GameManager
+
+@export var players: Players
+@export var ball: Ball
+@export var goal: Goal
+
+
+func _ready():
+ end_episode(0)
+
+
+## If more steps passed than set in AI Controlled reset_after
+func episode_timed_out():
+ episode_failed()
+
+
+func episode_failed():
+ end_episode(-10.0)
+
+
+func episode_success():
+ end_episode(10.0, true)
+
+
+func player_hit_ball(player):
+ var index = players.players.find(player)
+ assert(index != -1, "Player that hit ball is not in the players array")
+ for i in players.players.size():
+ if index == i:
+ players.players[i].hit_ball()
+ else:
+ players.players[i].clear_hit_ball()
+
+
+func on_ball_goal_reached():
+ episode_success()
+
+
+func end_episode(reward, success := false):
+ players.end_episode(reward, success)
+ ball.reset()
+ goal.move_to_next_position()
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/game_manager.gd.uid b/examples/2DCoopBallChallenge/scenes/game_scene/game_manager.gd.uid
new file mode 100755
index 0000000..ff4eb4d
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/game_manager.gd.uid
@@ -0,0 +1 @@
+uid://4u78ls38rhtw
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_1.tscn b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_1.tscn
new file mode 100755
index 0000000..310b534
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_1.tscn
@@ -0,0 +1,41 @@
+[gd_scene load_steps=4 format=4 uid="uid://p8ny2i03ct7i"]
+
+[ext_resource type="PackedScene" uid="uid://ds7ud4f3aglol" path="res://scenes/game_scene/game_scene_base.tscn" id="1_a6u74"]
+[ext_resource type="Script" uid="uid://1dbj4cknn23h" path="res://scenes/game_scene/goal_position_manager.gd" id="2_2e0g5"]
+[ext_resource type="Texture2D" uid="uid://bbpqyprxxe2fk" path="res://assets/goal.png" id="3_udn15"]
+
+[node name="GameScene1" instance=ExtResource("1_a6u74")]
+
+[node name="TileMapLayer" parent="." index="0"]
+tile_map_data = PackedByteArray("AAAAAA8AAAABAAAAAAABAA8AAAABAAAAAAACAA8AAAABAAAAAAADAA8AAAABAAAAAAAEAA8AAAABAAAAAAAFAA8AAAABAAAAAAAGAA8AAAABAAAAAAAHAA8AAAABAAAAAAAIAA8AAAABAAAAAAAJAA8AAAABAAAAAAAKAA8AAAABAAAAAAALAA8AAAABAAAAAAAMAA8AAAABAAAAAAAcAP//AAABAAEAAAAbAP//AAABAAEAAAAaAP//AAABAAEAAAAZAP//AAABAAEAAAAYAP//AAABAAEAAAAXAP//AAABAAEAAAAWAP//AAABAAEAAAAVAP//AAABAAEAAAAUAP//AAABAAEAAAATAP//AAABAAEAAAASAP//AAABAAEAAAARAP//AAABAAEAAAAQAP//AAABAAEAAAAPAP//AAABAAEAAAAOAP//AAABAAEAAAANAP//AAABAAEAAAAMAP//AAABAAEAAAALAP//AAABAAEAAAAKAP//AAABAAEAAAAJAP//AAABAAEAAAAIAP//AAABAAEAAAAHAP//AAABAAEAAAAGAP//AAABAAEAAAAFAP//AAABAAEAAAAEAP//AAABAAEAAAADAP//AAABAAEAAAACAP//AAABAAEAAAABAP//AAABAAEAAAAAAP//AAABAAEAAAD/////AAABAAEAAAD//wAAAAABAAEAAAD//wEAAAABAAEAAAD//wIAAAABAAEAAAD//wMAAAABAAEAAAD//wQAAAABAAEAAAD//wUAAAABAAEAAAD//wYAAAABAAEAAAD//wcAAAABAAEAAAD//wgAAAABAAEAAAD//wkAAAABAAEAAAD//woAAAABAAEAAAD//wsAAAABAAEAAAD//wwAAAABAAEAAAD//w0AAAABAAEAAAD//w4AAAABAAEAAAD//w8AAAABAAEAAAANAA8AAAABAAAAAAAOAA8AAAABAAAAAAAPAA8AAAABAAAAAAAQAA8AAAABAAAAAAARAA8AAAABAAAAAAASAA8AAAABAAAAAAATAA8AAAABAAAAAAAUAA8AAAABAAAAAAAVAA8AAAABAAAAAAAWAA8AAAABAAAAAAAXAA8AAAABAAAAAAAYAA8AAAABAAAAAAAZAA8AAAABAAAAAAAaAA8AAAABAAAAAAAbAA8AAAABAAAAAAAcAA8AAAABAAAAAAAdAA8AAAABAAAAAAAeAA8AAAABAAAAAAAfAA8AAAABAAAAAAAgAA8AAAABAAAAAAAhAA8AAAABAAAAAAAiAA8AAAABAAAAAAAjAA8AAAABAAAAAAAkAA8AAAABAAAAAAAlAA8AAAABAAAAAAAmAA8AAAABAAAAAAAnAA8AAAABAAEAAAAdAP//AAABAAEAAAAeAP//AAABAAEAAAAfAP//AAABAAEAAAAgAP//AAABAAEAAAAhAP//AAABAAEAAAAiAP//AAABAAEAAAAjAP//AAABAAEAAAAkAP//AAABAAEAAAAlAP//AAABAAEAAAAmAP//AAABAAEAAAAnAP//AAABAAEAAAAnAA0AAAABAAEAAAAnAA4AAAABAAEAAAAnAAAAAAABAAEAAAAnAAEAAAABAAEAAAAnAAIAAAABAAEAAAAnAAMAAAABAAEAAAAnAAQAAAABAAEAAAAnAAUAAAABAAEAAAAnAAYAAAABAAEAAAAnAAcAAAABAAEAAAAnAAgAAAABAAEAAAAnAAkAAAABAAEAAAAnAAoAAAABAAEAAAAnAAsAAAABAAEAAAAnAAwAAAABAAEAAAA=")
+
+[node name="Ball" parent="." index="1"]
+position = Vector2(1965, 435)
+
+[node name="Goal" parent="." index="2" node_paths=PackedStringArray("goal_positions")]
+position = Vector2(618, 2232)
+goal_positions = NodePath("../GoalPositions")
+
+[node name="GoalPositions" type="Node2D" parent="." index="3"]
+position = Vector2(3119, 2239)
+script = ExtResource("2_2e0g5")
+
+[node name="Sprite2D3" type="Sprite2D" parent="GoalPositions" index="0"]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(-2500, -66.79)
+scale = Vector2(3, 3)
+texture = ExtResource("3_udn15")
+
+[node name="Sprite2D4" type="Sprite2D" parent="GoalPositions" index="1"]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(2500, -66.79)
+scale = Vector2(3, 3)
+texture = ExtResource("3_udn15")
+
+[node name="Players" parent="." index="4"]
+position = Vector2(3098, 2304)
+
+[editable path="Ball"]
+[editable path="Players"]
+[editable path="Players/Player"]
+[editable path="Players/Player2"]
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_2.tscn b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_2.tscn
new file mode 100755
index 0000000..262ab01
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_2.tscn
@@ -0,0 +1,41 @@
+[gd_scene load_steps=4 format=4 uid="uid://d1ax4bj7gdem6"]
+
+[ext_resource type="PackedScene" uid="uid://ds7ud4f3aglol" path="res://scenes/game_scene/game_scene_base.tscn" id="1_3or3n"]
+[ext_resource type="Script" uid="uid://1dbj4cknn23h" path="res://scenes/game_scene/goal_position_manager.gd" id="2_sew6x"]
+[ext_resource type="Texture2D" uid="uid://bbpqyprxxe2fk" path="res://assets/goal.png" id="3_h6xdb"]
+
+[node name="GameScene2" instance=ExtResource("1_3or3n")]
+
+[node name="TileMapLayer" parent="." index="0"]
+tile_map_data = PackedByteArray("AAAAAA8AAAABAAAAAAABAA8AAAABAAAAAAACAA8AAAABAAAAAAADAA8AAAABAAAAAAAEAA8AAAABAAAAAAAFAA8AAAABAAEAAAAGAA8AAAABAAEAAAAHAA8AAAABAAEAAAAIAA8AAAABAAEAAAAJAA8AAAABAAEAAAAKAA8AAAABAAEAAAALAA8AAAABAAEAAAAMAA8AAAABAAEAAAAcAP//AAABAAEAAAAbAP//AAABAAEAAAAaAP//AAABAAEAAAAZAP//AAABAAEAAAAYAP//AAABAAEAAAAXAP//AAABAAEAAAAWAP//AAABAAEAAAAVAP//AAABAAEAAAAUAP//AAABAAEAAAATAP//AAABAAEAAAASAP//AAABAAEAAAARAP//AAABAAEAAAAQAP//AAABAAEAAAAPAP//AAABAAEAAAAOAP//AAABAAEAAAANAP//AAABAAEAAAAMAP//AAABAAEAAAALAP//AAABAAEAAAAKAP//AAABAAEAAAAJAP//AAABAAEAAAAIAP//AAABAAEAAAAHAP//AAABAAEAAAAGAP//AAABAAEAAAAFAP//AAABAAEAAAAEAP//AAABAAEAAAADAP//AAABAAEAAAACAP//AAABAAEAAAABAP//AAABAAEAAAAAAP//AAABAAEAAAD/////AAABAAEAAAD//wAAAAABAAEAAAD//wEAAAABAAEAAAD//wIAAAABAAEAAAD//wMAAAABAAEAAAD//wQAAAABAAEAAAD//wUAAAABAAEAAAD//wYAAAABAAEAAAD//wcAAAABAAEAAAD//wgAAAABAAEAAAD//wkAAAABAAEAAAD//woAAAABAAEAAAD//wsAAAABAAEAAAD//wwAAAABAAEAAAD//w0AAAABAAEAAAD//w4AAAABAAEAAAD//w8AAAABAAEAAAANAA8AAAABAAEAAAAOAA8AAAABAAEAAAAPAA8AAAABAAEAAAAQAA8AAAABAAEAAAARAA8AAAABAAEAAAASAA8AAAABAAEAAAATAA8AAAABAAEAAAAUAA8AAAABAAEAAAAVAA8AAAABAAEAAAAWAA8AAAABAAEAAAAXAA8AAAABAAEAAAAYAA8AAAABAAEAAAAZAA8AAAABAAEAAAAaAA8AAAABAAEAAAAbAA8AAAABAAEAAAAcAA8AAAABAAEAAAAdAA8AAAABAAEAAAAeAA8AAAABAAEAAAAfAA8AAAABAAEAAAAgAA8AAAABAAEAAAAhAA8AAAABAAEAAAAiAA8AAAABAAAAAAAjAA8AAAABAAAAAAAkAA8AAAABAAAAAAAlAA8AAAABAAAAAAAmAA8AAAABAAAAAAAnAA8AAAABAAEAAAAdAP//AAABAAEAAAAeAP//AAABAAEAAAAfAP//AAABAAEAAAAgAP//AAABAAEAAAAhAP//AAABAAEAAAAiAP//AAABAAEAAAAjAP//AAABAAEAAAAkAP//AAABAAEAAAAlAP//AAABAAEAAAAmAP//AAABAAEAAAAnAP//AAABAAEAAAAnAA0AAAABAAEAAAAnAA4AAAABAAEAAAAnAAAAAAABAAEAAAAnAAEAAAABAAEAAAAnAAIAAAABAAEAAAAnAAMAAAABAAEAAAAnAAQAAAABAAEAAAAnAAUAAAABAAEAAAAnAAYAAAABAAEAAAAnAAcAAAABAAEAAAAnAAgAAAABAAEAAAAnAAkAAAABAAEAAAAnAAoAAAABAAEAAAAnAAsAAAABAAEAAAAnAAwAAAABAAEAAAAPAAkAAAABAAAAAAAQAAkAAAABAAAAAAARAAkAAAABAAAAAAASAAkAAAABAAAAAAATAAkAAAABAAAAAAAUAAkAAAABAAAAAAAVAAkAAAABAAAAAAAWAAkAAAABAAAAAAAXAAkAAAABAAAAAAAPAAoAAAABAAEAAAAPAAsAAAABAAEAAAAPAAwAAAABAAEAAAAPAA0AAAABAAEAAAAPAA4AAAABAAEAAAAQAAoAAAABAAEAAAAQAAsAAAABAAEAAAAQAAwAAAABAAEAAAAQAA0AAAABAAEAAAAQAA4AAAABAAEAAAARAAoAAAABAAEAAAARAAsAAAABAAEAAAARAAwAAAABAAEAAAARAA0AAAABAAEAAAARAA4AAAABAAEAAAASAAoAAAABAAEAAAASAAsAAAABAAEAAAASAAwAAAABAAEAAAASAA0AAAABAAEAAAASAA4AAAABAAEAAAATAAoAAAABAAEAAAATAAsAAAABAAEAAAATAAwAAAABAAEAAAATAA0AAAABAAEAAAATAA4AAAABAAEAAAAUAAoAAAABAAEAAAAUAAsAAAABAAEAAAAUAAwAAAABAAEAAAAUAA0AAAABAAEAAAAUAA4AAAABAAEAAAAVAAoAAAABAAEAAAAVAAsAAAABAAEAAAAVAAwAAAABAAEAAAAVAA0AAAABAAEAAAAVAA4AAAABAAEAAAAWAAoAAAABAAEAAAAWAAsAAAABAAEAAAAWAAwAAAABAAEAAAAWAA0AAAABAAEAAAAWAA4AAAABAAEAAAAXAAoAAAABAAEAAAAXAAsAAAABAAEAAAAXAAwAAAABAAEAAAAXAA0AAAABAAEAAAAXAA4AAAABAAEAAAANAAoAAAABAAAAAAAOAAoAAAABAAAAAAALAAsAAAABAAAAAAAMAAsAAAABAAAAAAAJAAwAAAABAAAAAAAKAAwAAAABAAAAAAAHAA0AAAABAAAAAAAIAA0AAAABAAAAAAAFAA4AAAABAAAAAAAGAA4AAAABAAAAAAAYAAoAAAABAAAAAAAZAAoAAAABAAAAAAAaAAsAAAABAAAAAAAbAAsAAAABAAAAAAAcAAwAAAABAAAAAAAdAAwAAAABAAAAAAAeAA0AAAABAAAAAAAfAA0AAAABAAAAAAAgAA4AAAABAAAAAAAhAA4AAAABAAAAAAAYAAsAAAABAAEAAAAYAAwAAAABAAEAAAAYAA0AAAABAAEAAAAYAA4AAAABAAEAAAAZAAsAAAABAAEAAAAZAAwAAAABAAEAAAAZAA0AAAABAAEAAAAZAA4AAAABAAEAAAAaAAwAAAABAAEAAAAaAA0AAAABAAEAAAAaAA4AAAABAAEAAAAbAAwAAAABAAEAAAAbAA0AAAABAAEAAAAbAA4AAAABAAEAAAAcAA0AAAABAAEAAAAcAA4AAAABAAEAAAAdAA0AAAABAAEAAAAdAA4AAAABAAEAAAAeAA4AAAABAAEAAAAfAA4AAAABAAEAAAANAAsAAAABAAEAAAAOAAsAAAABAAEAAAALAAwAAAABAAEAAAAMAAwAAAABAAEAAAANAAwAAAABAAEAAAAOAAwAAAABAAEAAAAJAA0AAAABAAEAAAAKAA0AAAABAAEAAAALAA0AAAABAAEAAAAMAA0AAAABAAEAAAANAA0AAAABAAEAAAAOAA0AAAABAAEAAAAHAA4AAAABAAEAAAAIAA4AAAABAAEAAAAJAA4AAAABAAEAAAAKAA4AAAABAAEAAAALAA4AAAABAAEAAAAMAA4AAAABAAEAAAANAA4AAAABAAEAAAAOAA4AAAABAAEAAAA=")
+
+[node name="Ball" parent="." index="1"]
+position = Vector2(3179, 642)
+
+[node name="Goal" parent="." index="2" node_paths=PackedStringArray("goal_positions")]
+position = Vector2(580, 2131)
+goal_positions = NodePath("../GoalPositions")
+
+[node name="GoalPositions" type="Node2D" parent="." index="3"]
+position = Vector2(3122.5, 2239)
+script = ExtResource("2_sew6x")
+
+[node name="Sprite2D3" type="Sprite2D" parent="GoalPositions" index="0"]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(-2750, -73.775)
+scale = Vector2(3, 3)
+texture = ExtResource("3_h6xdb")
+
+[node name="Sprite2D4" type="Sprite2D" parent="GoalPositions" index="1"]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(2750, -73.775)
+scale = Vector2(3, 3)
+texture = ExtResource("3_h6xdb")
+
+[node name="Players" parent="." index="4"]
+position = Vector2(2952, 1304)
+
+[editable path="Ball"]
+[editable path="Players"]
+[editable path="Players/Player"]
+[editable path="Players/Player2"]
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_3.tscn b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_3.tscn
new file mode 100755
index 0000000..c689977
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_3.tscn
@@ -0,0 +1,41 @@
+[gd_scene load_steps=4 format=4 uid="uid://e3y017d7w6rb"]
+
+[ext_resource type="PackedScene" uid="uid://ds7ud4f3aglol" path="res://scenes/game_scene/game_scene_base.tscn" id="1_4gcc6"]
+[ext_resource type="Script" uid="uid://1dbj4cknn23h" path="res://scenes/game_scene/goal_position_manager.gd" id="2_xduj0"]
+[ext_resource type="Texture2D" uid="uid://bbpqyprxxe2fk" path="res://assets/goal.png" id="3_i7o85"]
+
+[node name="GameScene3" instance=ExtResource("1_4gcc6")]
+
+[node name="TileMapLayer" parent="." index="0"]
+tile_map_data = PackedByteArray("AAAAAA8AAAABAAEAAAABAA8AAAABAAEAAAACAA8AAAABAAEAAAADAA8AAAABAAEAAAAEAA8AAAABAAEAAAAFAA8AAAABAAEAAAAGAA8AAAABAAEAAAAHAA8AAAABAAEAAAAIAA8AAAABAAEAAAAJAA8AAAABAAEAAAAKAA8AAAABAAEAAAALAA8AAAABAAEAAAAMAA8AAAABAAEAAAAcAP//AAABAAEAAAAbAP//AAABAAEAAAAaAP//AAABAAEAAAAZAP//AAABAAEAAAAYAP//AAABAAEAAAAXAP//AAABAAEAAAAWAP//AAABAAEAAAAVAP//AAABAAEAAAAUAP//AAABAAEAAAATAP//AAABAAEAAAASAP//AAABAAEAAAARAP//AAABAAEAAAAQAP//AAABAAEAAAAPAP//AAABAAEAAAAOAP//AAABAAEAAAANAP//AAABAAEAAAAMAP//AAABAAEAAAALAP//AAABAAEAAAAKAP//AAABAAEAAAAJAP//AAABAAEAAAAIAP//AAABAAEAAAAHAP//AAABAAEAAAAGAP//AAABAAEAAAAFAP//AAABAAEAAAAEAP//AAABAAEAAAADAP//AAABAAEAAAACAP//AAABAAEAAAABAP//AAABAAEAAAAAAP//AAABAAEAAAD/////AAABAAEAAAD//wAAAAABAAEAAAD//wEAAAABAAEAAAD//wIAAAABAAEAAAD//wMAAAABAAEAAAD//wQAAAABAAEAAAD//wUAAAABAAEAAAD//wYAAAABAAEAAAD//wcAAAABAAEAAAD//wgAAAABAAEAAAD//wkAAAABAAEAAAD//woAAAABAAEAAAD//wsAAAABAAEAAAD//wwAAAABAAEAAAD//w0AAAABAAEAAAD//w4AAAABAAEAAAD//w8AAAABAAEAAAANAA8AAAABAAEAAAAOAA8AAAABAAEAAAAPAA8AAAABAAEAAAAQAA8AAAABAAEAAAARAA8AAAABAAEAAAASAA8AAAABAAEAAAATAA8AAAABAAEAAAAUAA8AAAABAAEAAAAVAA8AAAABAAEAAAAWAA8AAAABAAEAAAAXAA8AAAABAAEAAAAYAA8AAAABAAEAAAAZAA8AAAABAAEAAAAaAA8AAAABAAEAAAAbAA8AAAABAAEAAAAcAA8AAAABAAEAAAAdAA8AAAABAAEAAAAeAA8AAAABAAEAAAAfAA8AAAABAAEAAAAgAA8AAAABAAEAAAAhAA8AAAABAAEAAAAiAA8AAAABAAEAAAAjAA8AAAABAAEAAAAkAA8AAAABAAEAAAAlAA8AAAABAAEAAAAmAA8AAAABAAEAAAAnAA8AAAABAAEAAAAdAP//AAABAAEAAAAeAP//AAABAAEAAAAfAP//AAABAAEAAAAgAP//AAABAAEAAAAhAP//AAABAAEAAAAiAP//AAABAAEAAAAjAP//AAABAAEAAAAkAP//AAABAAEAAAAlAP//AAABAAEAAAAmAP//AAABAAEAAAAnAP//AAABAAEAAAAXAA4AAAABAAAAAAAnAA0AAAABAAEAAAAnAA4AAAABAAEAAAAPAA4AAAABAAAAAAAnAAAAAAABAAEAAAAnAAEAAAABAAEAAAAnAAIAAAABAAEAAAAnAAMAAAABAAEAAAAnAAQAAAABAAEAAAAnAAUAAAABAAEAAAAnAAYAAAABAAEAAAAnAAcAAAABAAEAAAAnAAgAAAABAAEAAAAnAAkAAAABAAEAAAAnAAoAAAABAAEAAAAnAAsAAAABAAEAAAAnAAwAAAABAAEAAAAQAA4AAAABAAAAAAARAA4AAAABAAAAAAASAA4AAAABAAAAAAATAA4AAAABAAAAAAAUAA4AAAABAAAAAAAVAA4AAAABAAAAAAAWAA4AAAABAAAAAAAYAA4AAAABAAAAAAAZAA4AAAABAAAAAAAaAA0AAAABAAAAAAAaAA4AAAABAAEAAAAbAA0AAAABAAAAAAAbAA4AAAABAAEAAAAcAAwAAAABAAAAAAAcAA0AAAABAAEAAAAcAA4AAAABAAEAAAAdAAwAAAABAAAAAAAdAA0AAAABAAEAAAAdAA4AAAABAAEAAAAeAAsAAAABAAAAAAAeAAwAAAABAAEAAAAeAA0AAAABAAEAAAAeAA4AAAABAAEAAAAfAAsAAAABAAAAAAAfAAwAAAABAAEAAAAfAA0AAAABAAEAAAAfAA4AAAABAAEAAAAgAAsAAAABAAEAAAAgAAwAAAABAAEAAAAgAA0AAAABAAEAAAAgAA4AAAABAAEAAAAhAAsAAAABAAEAAAAhAAwAAAABAAEAAAAhAA0AAAABAAEAAAAhAA4AAAABAAEAAAAiAAkAAAABAAAAAAAiAAoAAAABAAEAAAAiAAsAAAABAAEAAAAiAAwAAAABAAEAAAAiAA0AAAABAAEAAAAiAA4AAAABAAEAAAAjAAkAAAABAAAAAAAjAAoAAAABAAEAAAAjAAsAAAABAAEAAAAjAAwAAAABAAEAAAAjAA0AAAABAAEAAAAjAA4AAAABAAEAAAAkAAkAAAABAAAAAAAkAAoAAAABAAEAAAAkAAsAAAABAAEAAAAkAAwAAAABAAEAAAAkAA0AAAABAAEAAAAkAA4AAAABAAEAAAAlAAkAAAABAAAAAAAlAAoAAAABAAEAAAAlAAsAAAABAAEAAAAlAAwAAAABAAEAAAAlAA0AAAABAAEAAAAlAA4AAAABAAEAAAAmAAkAAAABAAAAAAAmAAoAAAABAAEAAAAmAAsAAAABAAEAAAAmAAwAAAABAAEAAAAmAA0AAAABAAEAAAAmAA4AAAABAAEAAAANAA4AAAABAAAAAAAOAA4AAAABAAAAAAALAA0AAAABAAAAAAALAA4AAAABAAEAAAAMAA0AAAABAAAAAAAMAA4AAAABAAEAAAAJAAwAAAABAAAAAAAJAA0AAAABAAEAAAAJAA4AAAABAAEAAAAKAAwAAAABAAAAAAAKAA0AAAABAAEAAAAKAA4AAAABAAEAAAAHAAsAAAABAAAAAAAHAAwAAAABAAEAAAAHAA0AAAABAAEAAAAHAA4AAAABAAEAAAAIAAsAAAABAAAAAAAIAAwAAAABAAEAAAAIAA0AAAABAAEAAAAIAA4AAAABAAEAAAAAAAkAAAABAAAAAAAAAAoAAAABAAEAAAAAAAsAAAABAAEAAAAAAAwAAAABAAEAAAAAAA0AAAABAAEAAAAAAA4AAAABAAEAAAABAAkAAAABAAAAAAABAAoAAAABAAEAAAABAAsAAAABAAEAAAABAAwAAAABAAEAAAABAA0AAAABAAEAAAABAA4AAAABAAEAAAACAAkAAAABAAAAAAACAAoAAAABAAEAAAACAAsAAAABAAEAAAACAAwAAAABAAEAAAACAA0AAAABAAEAAAACAA4AAAABAAEAAAADAAkAAAABAAAAAAADAAoAAAABAAEAAAADAAsAAAABAAEAAAADAAwAAAABAAEAAAADAA0AAAABAAEAAAADAA4AAAABAAEAAAAEAAkAAAABAAAAAAAEAAoAAAABAAEAAAAEAAsAAAABAAEAAAAEAAwAAAABAAEAAAAEAA0AAAABAAEAAAAEAA4AAAABAAEAAAAFAAoAAAABAAAAAAAFAAsAAAABAAEAAAAFAAwAAAABAAEAAAAFAA0AAAABAAEAAAAFAA4AAAABAAEAAAAGAAoAAAABAAAAAAAGAAsAAAABAAEAAAAGAAwAAAABAAEAAAAGAA0AAAABAAEAAAAGAA4AAAABAAEAAAAgAAoAAAABAAAAAAAhAAoAAAABAAAAAAA=")
+
+[node name="Ball" parent="." index="1"]
+position = Vector2(3179, 642)
+
+[node name="Goal" parent="." index="2" node_paths=PackedStringArray("goal_positions")]
+position = Vector2(500, 1212)
+goal_positions = NodePath("../GoalPositions")
+
+[node name="GoalPositions" type="Node2D" parent="." index="3"]
+position = Vector2(3114.5, 1214.09)
+script = ExtResource("2_xduj0")
+
+[node name="Sprite2D3" type="Sprite2D" parent="GoalPositions" index="0"]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(-2750, 0)
+scale = Vector2(3, 3)
+texture = ExtResource("3_i7o85")
+
+[node name="Sprite2D4" type="Sprite2D" parent="GoalPositions" index="1"]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(2750, 0)
+scale = Vector2(3, 3)
+texture = ExtResource("3_i7o85")
+
+[node name="Players" parent="." index="4"]
+position = Vector2(2952, 1304)
+
+[editable path="Ball"]
+[editable path="Players"]
+[editable path="Players/Player"]
+[editable path="Players/Player2"]
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_base.tscn b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_base.tscn
new file mode 100755
index 0000000..002b744
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/game_scene_base.tscn
@@ -0,0 +1,65 @@
+[gd_scene load_steps=7 format=4 uid="uid://ds7ud4f3aglol"]
+
+[ext_resource type="Script" uid="uid://4u78ls38rhtw" path="res://scenes/game_scene/game_manager.gd" id="1_8bc8d"]
+[ext_resource type="TileSet" uid="uid://sdmrwh4xd6qj" path="res://scenes/tileset/tileset.tres" id="2_qcps1"]
+[ext_resource type="Script" uid="uid://375ynh2lk7vl" path="res://scenes/tilemap/tile_map_layer.gd" id="3_he3s8"]
+[ext_resource type="PackedScene" uid="uid://wdjkbtjihjoq" path="res://scenes/ball/ball.tscn" id="4_hv6x3"]
+[ext_resource type="PackedScene" uid="uid://ddxv6uu5j055x" path="res://scenes/goal/goal.tscn" id="6_08cpv"]
+[ext_resource type="PackedScene" uid="uid://d2qsl7semlkyv" path="res://scenes/player/players.tscn" id="9_m56ji"]
+
+[node name="GameSceneBase" type="Node2D" node_paths=PackedStringArray("players", "ball", "goal")]
+script = ExtResource("1_8bc8d")
+players = NodePath("Players")
+ball = NodePath("Ball")
+goal = NodePath("Goal")
+
+[node name="TileMapLayer" type="TileMapLayer" parent="."]
+tile_map_data = PackedByteArray("AAAAAA8AAAABAAEAAAABAA8AAAABAAEAAAACAA8AAAABAAEAAAADAA8AAAABAAEAAAAEAA8AAAABAAEAAAAFAA8AAAABAAEAAAAGAA8AAAABAAEAAAAHAA8AAAABAAEAAAAIAA8AAAABAAEAAAAJAA8AAAABAAEAAAAKAA8AAAABAAEAAAALAA8AAAABAAEAAAAMAA8AAAABAAEAAAAcAP//AAABAAEAAAAbAP//AAABAAEAAAAaAP//AAABAAEAAAAZAP//AAABAAEAAAAYAP//AAABAAEAAAAXAP//AAABAAEAAAAWAP//AAABAAEAAAAVAP//AAABAAEAAAAUAP//AAABAAEAAAATAP//AAABAAEAAAASAP//AAABAAEAAAARAP//AAABAAEAAAAQAP//AAABAAEAAAAPAP//AAABAAEAAAAOAP//AAABAAEAAAANAP//AAABAAEAAAAMAP//AAABAAEAAAALAP//AAABAAEAAAAKAP//AAABAAEAAAAJAP//AAABAAEAAAAIAP//AAABAAEAAAAHAP//AAABAAEAAAAGAP//AAABAAEAAAAFAP//AAABAAEAAAAEAP//AAABAAEAAAADAP//AAABAAEAAAACAP//AAABAAEAAAABAP//AAABAAEAAAAAAP//AAABAAEAAAD/////AAABAAEAAAD//wAAAAABAAEAAAD//wEAAAABAAEAAAD//wIAAAABAAEAAAD//wMAAAABAAEAAAD//wQAAAABAAEAAAD//wUAAAABAAEAAAD//wYAAAABAAEAAAD//wcAAAABAAEAAAD//wgAAAABAAEAAAD//wkAAAABAAEAAAD//woAAAABAAEAAAD//wsAAAABAAEAAAD//wwAAAABAAEAAAD//w0AAAABAAEAAAD//w4AAAABAAEAAAD//w8AAAABAAEAAAANAA8AAAABAAEAAAAOAA8AAAABAAEAAAAPAA8AAAABAAEAAAAQAA8AAAABAAEAAAARAA8AAAABAAEAAAASAA8AAAABAAEAAAATAA8AAAABAAEAAAAUAA8AAAABAAEAAAAVAA8AAAABAAEAAAAWAA8AAAABAAEAAAAXAA8AAAABAAEAAAAYAA8AAAABAAEAAAAZAA8AAAABAAEAAAAaAA8AAAABAAEAAAAbAA8AAAABAAEAAAAcAA8AAAABAAEAAAAdAA8AAAABAAEAAAAeAA8AAAABAAEAAAAfAA8AAAABAAEAAAAgAA8AAAABAAEAAAAhAA8AAAABAAEAAAAiAA8AAAABAAEAAAAjAA8AAAABAAEAAAAkAA8AAAABAAEAAAAlAA8AAAABAAEAAAAmAA8AAAABAAEAAAAnAA8AAAABAAEAAAAoAA8AAAABAAEAAAApAA8AAAABAAEAAAAqAA8AAAABAAEAAAArAA8AAAABAAEAAAAsAA8AAAABAAEAAAAtAA8AAAABAAEAAAAuAA8AAAABAAEAAAAvAA8AAAABAAEAAAAwAA8AAAABAAEAAAAxAA8AAAABAAEAAAAdAP//AAABAAEAAAAeAP//AAABAAEAAAAfAP//AAABAAEAAAAgAP//AAABAAEAAAAhAP//AAABAAEAAAAiAP//AAABAAEAAAAjAP//AAABAAEAAAAkAP//AAABAAEAAAAlAP//AAABAAEAAAAmAP//AAABAAEAAAAnAP//AAABAAEAAAAoAP//AAABAAEAAAApAP//AAABAAEAAAAqAP//AAABAAEAAAArAP//AAABAAEAAAAsAP//AAABAAEAAAAtAP//AAABAAEAAAAuAP//AAABAAEAAAAvAP//AAABAAEAAAAwAP//AAABAAEAAAAxAP//AAABAAEAAAAyAA8AAAABAAEAAAAyAA4AAAABAAEAAAAyAA0AAAABAAEAAAAyAAwAAAABAAEAAAAyAAsAAAABAAEAAAAyAAoAAAABAAEAAAAyAAkAAAABAAEAAAAyAAgAAAABAAEAAAAyAAcAAAABAAEAAAAyAAYAAAABAAEAAAAyAAUAAAABAAEAAAAyAAQAAAABAAEAAAAyAAMAAAABAAEAAAAyAAIAAAABAAEAAAAyAAEAAAABAAEAAAAyAAAAAAABAAEAAAAyAP//AAABAAEAAAA=")
+tile_set = ExtResource("2_qcps1")
+navigation_enabled = false
+script = ExtResource("3_he3s8")
+
+[node name="Ball" parent="." node_paths=PackedStringArray("game_manager") instance=ExtResource("4_hv6x3")]
+position = Vector2(3909, 126)
+game_manager = NodePath("..")
+
+[node name="ApproachGoalReward" parent="Ball" index="3" node_paths=PackedStringArray("target_node")]
+target_node = NodePath("../../Goal")
+reward_scale = 0.0
+
+[node name="PositionSensor2D" parent="Ball" index="4" node_paths=PackedStringArray("objects_to_observe")]
+objects_to_observe = [NodePath("../../Goal")]
+max_distance = 1000.0
+use_separate_direction = true
+
+[node name="Goal" parent="." instance=ExtResource("6_08cpv")]
+position = Vector2(2390, 1917)
+
+[node name="Players" parent="." node_paths=PackedStringArray("game_manager") instance=ExtResource("9_m56ji")]
+position = Vector2(3845, 1699)
+game_manager = NodePath("..")
+
+[node name="PositionSensor2D" parent="Players/Player" index="5" node_paths=PackedStringArray("objects_to_observe")]
+objects_to_observe = [NodePath("../../../Ball"), NodePath("../../../Goal"), NodePath("../../Player2")]
+max_distance = 2500.0
+use_separate_direction = true
+
+[node name="PositionSensor2D" parent="Players/Player2" index="5" node_paths=PackedStringArray("objects_to_observe")]
+objects_to_observe = [NodePath("../../../Ball"), NodePath("../../../Goal"), NodePath("../../Player")]
+max_distance = 2500.0
+use_separate_direction = true
+
+[node name="VelocitySensor2D" parent="Players" index="2" node_paths=PackedStringArray("objects_to_observe")]
+objects_to_observe = [NodePath("../Player"), NodePath("../Player2"), NodePath("../../Ball")]
+max_velocity = 500
+
+[node name="AIController2D" parent="Players" index="3" node_paths=PackedStringArray("sensors")]
+sensors = [NodePath("../Player/RaycastSensorPlatform"), NodePath("../Player/RaycastSensorGround"), NodePath("../Player/PositionSensor2D"), NodePath("../Player2/RaycastSensorPlatform"), NodePath("../Player2/RaycastSensorGround"), NodePath("../Player2/PositionSensor2D"), NodePath("../VelocitySensor2D"), NodePath("../../Ball/PositionSensor2D")]
+reset_after = 1500
+
+[connection signal="body_entered" from="Goal" to="Ball" method="_on_goal_body_entered"]
+
+[editable path="Ball"]
+[editable path="Players"]
+[editable path="Players/Player"]
+[editable path="Players/Player2"]
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/goal.gd b/examples/2DCoopBallChallenge/scenes/game_scene/goal.gd
new file mode 100755
index 0000000..6dbbd49
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/goal.gd
@@ -0,0 +1,33 @@
+extends Area2D
+class_name Goal
+
+@export var goal_positions: GoalPositions
+var current_position_index := 0
+
+func _ready() -> void:
+ if not goal_positions.is_node_ready():
+ await goal_positions.ready
+ move_to_random_position()
+
+
+## Moves the goal to next position, and wraps around
+func move_to_next_position():
+ await get_tree().physics_frame # Prevents the goal from moving before the ball fully resets
+ current_position_index = (current_position_index + 1) % goal_positions.goal_positions.size()
+ global_position = goal_positions.goal_positions[current_position_index].global_position
+
+
+func move_to_previous_position():
+ await get_tree().physics_frame # Prevents the goal from moving before the ball fully resets
+ current_position_index = max(current_position_index - 1, 0)
+ global_position = goal_positions.goal_positions[current_position_index].global_position
+
+
+func move_to_first_position():
+ await get_tree().physics_frame # Prevents the goal from moving before the ball fully resets
+ global_position = goal_positions.goal_positions[0].global_position
+
+
+func move_to_random_position():
+ await get_tree().physics_frame # Prevents the goal from moving before the ball fully resets
+ global_position = goal_positions.goal_positions.pick_random().global_position
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/goal.gd.uid b/examples/2DCoopBallChallenge/scenes/game_scene/goal.gd.uid
new file mode 100755
index 0000000..667f3b3
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/goal.gd.uid
@@ -0,0 +1 @@
+uid://dy75ik1x5kg8m
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/goal_position_manager.gd b/examples/2DCoopBallChallenge/scenes/game_scene/goal_position_manager.gd
new file mode 100755
index 0000000..1259a6f
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/goal_position_manager.gd
@@ -0,0 +1,4 @@
+extends Node2D
+class_name GoalPositions
+
+@onready var goal_positions = get_children()
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/goal_position_manager.gd.uid b/examples/2DCoopBallChallenge/scenes/game_scene/goal_position_manager.gd.uid
new file mode 100755
index 0000000..e156575
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/goal_position_manager.gd.uid
@@ -0,0 +1 @@
+uid://1dbj4cknn23h
diff --git a/examples/2DCoopBallChallenge/scenes/game_scene/spawn_positions.gd.uid b/examples/2DCoopBallChallenge/scenes/game_scene/spawn_positions.gd.uid
new file mode 100755
index 0000000..504e9c9
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/game_scene/spawn_positions.gd.uid
@@ -0,0 +1 @@
+uid://c8cyks5v7dfbk
diff --git a/examples/2DCoopBallChallenge/scenes/goal/goal.tscn b/examples/2DCoopBallChallenge/scenes/goal/goal.tscn
new file mode 100755
index 0000000..c0bea52
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/goal/goal.tscn
@@ -0,0 +1,22 @@
+[gd_scene load_steps=4 format=3 uid="uid://ddxv6uu5j055x"]
+
+[ext_resource type="Script" uid="uid://dy75ik1x5kg8m" path="res://scenes/game_scene/goal.gd" id="1_r84s2"]
+[ext_resource type="Texture2D" uid="uid://bbpqyprxxe2fk" path="res://assets/goal.png" id="2_0ivff"]
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_3kmt7"]
+size = Vector2(80, 81.6)
+
+[node name="Goal" type="Area2D"]
+collision_layer = 0
+collision_mask = 4
+monitorable = false
+script = ExtResource("1_r84s2")
+
+[node name="Sprite2D" type="Sprite2D" parent="."]
+scale = Vector2(3, 3)
+texture = ExtResource("2_0ivff")
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+position = Vector2(1.77636e-15, 0)
+scale = Vector2(5, 5)
+shape = SubResource("RectangleShape2D_3kmt7")
diff --git a/examples/2DCoopBallChallenge/scenes/goal/goal_positions.tscn b/examples/2DCoopBallChallenge/scenes/goal/goal_positions.tscn
new file mode 100755
index 0000000..24690e0
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/goal/goal_positions.tscn
@@ -0,0 +1,55 @@
+[gd_scene load_steps=3 format=3 uid="uid://lc8300re8fgd"]
+
+[ext_resource type="Script" uid="uid://1dbj4cknn23h" path="res://scenes/game_scene/goal_position_manager.gd" id="1_halm4"]
+[ext_resource type="Texture2D" uid="uid://bbpqyprxxe2fk" path="res://assets/goal.png" id="2_y5yss"]
+
+[node name="GoalPositions" type="Node2D"]
+script = ExtResource("1_halm4")
+
+[node name="Sprite2D3" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(407, 303)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
+
+[node name="Sprite2D4" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(3584, 313)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
+
+[node name="Sprite2D5" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(-244, 317)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
+
+[node name="Sprite2D6" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(4223, 313)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
+
+[node name="Sprite2D7" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(-873, 327)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
+
+[node name="Sprite2D8" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(4878, 322)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
+
+[node name="Sprite2D9" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(-1666, -307)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
+
+[node name="Sprite2D10" type="Sprite2D" parent="."]
+modulate = Color(1, 1, 1, 0.14902)
+position = Vector2(5675, -324)
+scale = Vector2(2, 2)
+texture = ExtResource("2_y5yss")
diff --git a/examples/2DCoopBallChallenge/scenes/player/VelocitySensor2D.gd b/examples/2DCoopBallChallenge/scenes/player/VelocitySensor2D.gd
new file mode 100755
index 0000000..b37f67d
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/VelocitySensor2D.gd
@@ -0,0 +1,19 @@
+extends ISensor2D
+
+## Reports character body velocities
+## Note: velocity is in global reference, not transformed
+## (as the players in this env do not rotate, it should be sufficient)
+
+@export var objects_to_observe: Array[CharacterBody2D]
+## Max expected velocity used for normalization (larger values will be clipped)
+@export var max_velocity := 1500
+
+
+func get_observation():
+ var observations: Array[float]
+
+ for obj in objects_to_observe:
+ var velocity := obj.get_real_velocity().limit_length(max_velocity) / max_velocity
+ observations.append_array([velocity.x, velocity.y])
+
+ return observations
diff --git a/examples/2DCoopBallChallenge/scenes/player/VelocitySensor2D.gd.uid b/examples/2DCoopBallChallenge/scenes/player/VelocitySensor2D.gd.uid
new file mode 100755
index 0000000..9eea306
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/VelocitySensor2D.gd.uid
@@ -0,0 +1 @@
+uid://bu0wvfxl80g6b
diff --git a/examples/2DCoopBallChallenge/scenes/player/player.gd b/examples/2DCoopBallChallenge/scenes/player/player.gd
new file mode 100755
index 0000000..f6f8e5a
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/player.gd
@@ -0,0 +1,77 @@
+extends CharacterBody2D
+class_name Player
+
+@export var speed := 2400.0
+@export var jump_velocity := -1400.0
+@export var ai_controller: PlayerAIController
+
+@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
+@onready var initial_transform := transform
+
+var requested_movement: float
+var requested_jump: bool
+
+var last_to_hit_ball: bool
+
+func hit_ball():
+ last_to_hit_ball = true
+ $CanHitBallIndicator.visible = false
+
+
+func clear_hit_ball():
+ last_to_hit_ball = false
+ $CanHitBallIndicator.visible = true
+
+
+func _physics_process(delta: float) -> void:
+ handle_movement(delta)
+
+
+func handle_movement(delta: float):
+ apply_gravity(delta)
+
+ # Controls (human or AI controlled)
+ var direction: float
+ if ai_controller.control_mode == AIController2D.ControlModes.HUMAN:
+ direction = Input.get_axis("move_left", "move_right")
+ requested_jump = Input.is_action_pressed("jump")
+ else:
+ direction = requested_movement
+
+ # Horizontal movement
+ velocity.x = direction * speed
+ if velocity.x:
+ animated_sprite.flip_h = velocity.x < 0
+ if is_on_floor():
+ animated_sprite.animation = "move"
+ animated_sprite.play()
+
+ # Jump
+ if requested_jump and is_on_floor():
+ velocity.y = jump_velocity
+ requested_jump = false
+ animated_sprite.animation = "jump"
+ animated_sprite.play()
+
+ # Stop animation if not moving
+ if velocity.length_squared() < 0.01 and is_on_floor():
+ animated_sprite.animation = "move"
+ animated_sprite.stop()
+
+ move_and_slide()
+
+
+func apply_gravity(delta) -> void:
+ if not is_on_floor():
+ velocity += Vector2.DOWN * 14000 * delta
+
+
+func reset() -> void:
+ transform = initial_transform
+
+ var state := PhysicsServer2D.body_get_direct_state(get_rid())
+ state.transform = global_transform
+ velocity = Vector2.ZERO
+ requested_movement = 0
+ requested_jump = 0
+ clear_hit_ball()
diff --git a/examples/2DCoopBallChallenge/scenes/player/player.gd.uid b/examples/2DCoopBallChallenge/scenes/player/player.gd.uid
new file mode 100755
index 0000000..454838d
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/player.gd.uid
@@ -0,0 +1 @@
+uid://b60kdidvpybwh
diff --git a/examples/2DCoopBallChallenge/scenes/player/player.tscn b/examples/2DCoopBallChallenge/scenes/player/player.tscn
new file mode 100755
index 0000000..90154c1
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/player.tscn
@@ -0,0 +1,94 @@
+[gd_scene load_steps=12 format=3 uid="uid://dyobkmccrdtji"]
+
+[ext_resource type="Script" uid="uid://b60kdidvpybwh" path="res://scenes/player/player.gd" id="1_uuo8p"]
+[ext_resource type="Texture2D" uid="uid://bapc1vq7fr0av" path="res://assets/player/jump/Player1Jump1.png" id="2_6wykd"]
+[ext_resource type="Texture2D" uid="uid://bmraxeklbi6h0" path="res://assets/player/move/Player-1.png" id="2_t2oqs"]
+[ext_resource type="Texture2D" uid="uid://33reagv1bqpr" path="res://assets/player/jump/Player1Jump2.png" id="3_0ssda"]
+[ext_resource type="Texture2D" uid="uid://wxq1h605mn72" path="res://assets/player/move/Player-2.png" id="3_y4ujj"]
+[ext_resource type="Texture2D" uid="uid://c7wxqpsnaqfpb" path="res://assets/player/move/Player-3.png" id="4_iy4ba"]
+[ext_resource type="Texture2D" uid="uid://dqwycbviv5ag2" path="res://assets/player/jump/Player1Jump3.png" id="4_ngojv"]
+[ext_resource type="Script" uid="uid://dar4d1peo5076" path="res://addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd" id="6_ybkrn"]
+[ext_resource type="Script" uid="uid://cyrvpwvwcjebl" path="res://addons/godot_rl_agents/sensors/sensors_2d/PositionSensor2D.gd" id="9_fm80t"]
+
+[sub_resource type="SpriteFrames" id="SpriteFrames_5tff7"]
+animations = [{
+"frames": [{
+"duration": 1.0,
+"texture": ExtResource("2_6wykd")
+}, {
+"duration": 1.0,
+"texture": ExtResource("3_0ssda")
+}, {
+"duration": 1.0,
+"texture": ExtResource("4_ngojv")
+}],
+"loop": true,
+"name": &"jump",
+"speed": 6.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": ExtResource("2_t2oqs")
+}, {
+"duration": 1.0,
+"texture": ExtResource("3_y4ujj")
+}, {
+"duration": 1.0,
+"texture": ExtResource("4_iy4ba")
+}],
+"loop": true,
+"name": &"move",
+"speed": 6.0
+}]
+
+[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_jl23j"]
+radius = 70.7
+height = 183.6
+
+[node name="Player" type="CharacterBody2D"]
+collision_layer = 2
+collision_mask = 11
+script = ExtResource("1_uuo8p")
+speed = 1400.0
+jump_velocity = -3900.0
+
+[node name="CanHitBallIndicator" type="Label" parent="."]
+offset_left = -37.0
+offset_top = -430.0
+offset_right = 36.0
+offset_bottom = -81.0
+theme_override_colors/font_color = Color(1, 1, 1, 1)
+theme_override_colors/font_shadow_color = Color(0, 0, 0, 1)
+theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
+theme_override_constants/outline_size = 4
+theme_override_constants/shadow_outline_size = 5
+theme_override_font_sizes/font_size = 256
+text = "."
+horizontal_alignment = 1
+
+[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."]
+scale = Vector2(1.5, 1.5)
+sprite_frames = SubResource("SpriteFrames_5tff7")
+animation = &"move"
+autoplay = "move"
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+shape = SubResource("CapsuleShape2D_jl23j")
+
+[node name="RaycastSensorPlatform" type="Node2D" parent="."]
+rotation = 1.5708
+script = ExtResource("6_ybkrn")
+n_rays = 8.0
+ray_length = 4000
+
+[node name="RaycastSensorGround" type="Node2D" parent="."]
+rotation = 1.5708
+script = ExtResource("6_ybkrn")
+collision_mask = 8
+n_rays = 8.0
+ray_length = 4000
+
+[node name="PositionSensor2D" type="Node2D" parent="."]
+visible = false
+script = ExtResource("9_fm80t")
+debug_lines = false
diff --git a/examples/2DCoopBallChallenge/scenes/player/player_ai_controller.gd b/examples/2DCoopBallChallenge/scenes/player/player_ai_controller.gd
new file mode 100755
index 0000000..798856b
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/player_ai_controller.gd
@@ -0,0 +1,79 @@
+extends AIController2D
+class_name PlayerAIController
+
+@export var players: Players
+@export var sensors: Array[Node2D]
+
+var is_success := false
+
+
+func _ready():
+ super._ready()
+
+
+func _physics_process(_delta):
+ n_steps += 1
+ if needs_reset:
+ reset()
+
+ if n_steps > reset_after:
+ players.game_manager.episode_timed_out()
+
+
+func end_episode(final_reward := 0.0, success := false) -> void:
+ is_success = success
+ reward += final_reward
+ done = true
+ reset()
+
+
+func reset():
+ old_obs.clear()
+ super.reset()
+
+
+func get_info() -> Dictionary:
+ if done:
+ return {"is_success": is_success}
+ return {}
+
+
+var old_obs: PackedFloat32Array
+
+func get_obs() -> Dictionary:
+ var obs: PackedFloat32Array
+
+ for sensor in sensors:
+ obs.append_array(sensor.get_observation())
+
+ for player in players.players:
+ (
+ obs
+ . append_array(
+ [
+ float(player.is_on_floor()),
+ float(player.last_to_hit_ball)
+ ]
+ )
+ )
+ return {"obs": obs}
+
+
+func get_reward() -> float:
+ reward += players.game_manager.ball.get_approach_goal_reward()
+ return reward
+
+
+func get_action_space() -> Dictionary:
+ return {
+ "actions_p1": {"size": 2, "action_type": "continuous"},
+ "actions_p2": {"size": 2, "action_type": "continuous"},
+ }
+
+
+func set_action(action: Dictionary) -> void:
+ players.players[0].requested_movement = action.actions_p1[0]
+ players.players[0].requested_jump = action.actions_p1[1] > 0.8
+
+ players.players[1].requested_movement = action.actions_p2[0]
+ players.players[1].requested_jump = action.actions_p2[1] > 0.8
diff --git a/examples/2DCoopBallChallenge/scenes/player/player_ai_controller.gd.uid b/examples/2DCoopBallChallenge/scenes/player/player_ai_controller.gd.uid
new file mode 100755
index 0000000..5b2d74a
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/player_ai_controller.gd.uid
@@ -0,0 +1 @@
+uid://dlkdrp0sajie
diff --git a/examples/2DCoopBallChallenge/scenes/player/players.gd b/examples/2DCoopBallChallenge/scenes/player/players.gd
new file mode 100755
index 0000000..993feec
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/players.gd
@@ -0,0 +1,20 @@
+extends Node2D
+class_name Players
+
+@export var game_manager: GameManager
+@export var players: Array[Player]
+@export var ai_controller: PlayerAIController
+
+
+func get_random_player():
+ return players.pick_random()
+
+
+func end_episode(reward, success := false):
+ ai_controller.end_episode(reward, success)
+ for player in players:
+ player.reset()
+
+
+func add_player_reward(reward: float):
+ ai_controller.reward += reward
diff --git a/examples/2DCoopBallChallenge/scenes/player/players.gd.uid b/examples/2DCoopBallChallenge/scenes/player/players.gd.uid
new file mode 100755
index 0000000..8ae906b
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/players.gd.uid
@@ -0,0 +1 @@
+uid://c5bbhwxv1im5u
diff --git a/examples/2DCoopBallChallenge/scenes/player/players.tscn b/examples/2DCoopBallChallenge/scenes/player/players.tscn
new file mode 100755
index 0000000..6089a4e
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/player/players.tscn
@@ -0,0 +1,31 @@
+[gd_scene load_steps=5 format=3 uid="uid://d2qsl7semlkyv"]
+
+[ext_resource type="PackedScene" uid="uid://dyobkmccrdtji" path="res://scenes/player/player.tscn" id="1_cr4op"]
+[ext_resource type="Script" uid="uid://c5bbhwxv1im5u" path="res://scenes/player/players.gd" id="1_kutnq"]
+[ext_resource type="Script" uid="uid://bu0wvfxl80g6b" path="res://scenes/player/VelocitySensor2D.gd" id="3_6l7rq"]
+[ext_resource type="Script" uid="uid://dlkdrp0sajie" path="res://scenes/player/player_ai_controller.gd" id="11_eplxt"]
+
+[node name="Players" type="Node2D" node_paths=PackedStringArray("players", "ai_controller")]
+script = ExtResource("1_kutnq")
+players = [NodePath("Player"), NodePath("Player2")]
+ai_controller = NodePath("AIController2D")
+
+[node name="Player" parent="." node_paths=PackedStringArray("ai_controller") instance=ExtResource("1_cr4op")]
+ai_controller = NodePath("../AIController2D")
+
+[node name="Player2" parent="." node_paths=PackedStringArray("ai_controller") instance=ExtResource("1_cr4op")]
+position = Vector2(300, 0)
+ai_controller = NodePath("../AIController2D")
+
+[node name="VelocitySensor2D" type="Node2D" parent="." node_paths=PackedStringArray("objects_to_observe")]
+script = ExtResource("3_6l7rq")
+objects_to_observe = [NodePath("../Player"), NodePath("../Player2"), null]
+
+[node name="AIController2D" type="Node2D" parent="." node_paths=PackedStringArray("players", "sensors")]
+script = ExtResource("11_eplxt")
+players = NodePath("..")
+sensors = [NodePath("../Player/RaycastSensorPlatform"), NodePath("../Player/RaycastSensorGround"), NodePath("../Player/PositionSensor2D"), NodePath("../Player2/RaycastSensorPlatform"), NodePath("../Player2/RaycastSensorGround"), NodePath("../Player2/PositionSensor2D"), NodePath("../VelocitySensor2D")]
+reset_after = 5000
+
+[editable path="Player"]
+[editable path="Player2"]
diff --git a/examples/2DCoopBallChallenge/scenes/tilemap/tile_map_layer.gd b/examples/2DCoopBallChallenge/scenes/tilemap/tile_map_layer.gd
new file mode 100755
index 0000000..81d4598
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/tilemap/tile_map_layer.gd
@@ -0,0 +1,14 @@
+extends TileMapLayer
+class_name MapManager
+
+## Maps tile names to tileset atlas coordinates
+class Tiles:
+ const PLATFORM_LEFT_EDGE = Vector2i(0, 0)
+ const PLATFORM_MIDDLE = Vector2i(1, 0)
+ const PLATFORM_RIGHT_EDGE = Vector2i(2, 0)
+ const GROUND = Vector2i(0, 1) # currently not used
+ const GROUND_2 = Vector2i(1, 1) # currently not used
+ const SPIKES = Vector2i(2, 1)
+ const COIN = Vector2i(0, 2)
+ const GOAL = Vector2i(1, 2)
+ const SPAWN = Vector2i(2, 2)
diff --git a/examples/2DCoopBallChallenge/scenes/tilemap/tile_map_layer.gd.uid b/examples/2DCoopBallChallenge/scenes/tilemap/tile_map_layer.gd.uid
new file mode 100755
index 0000000..a29a52c
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/tilemap/tile_map_layer.gd.uid
@@ -0,0 +1 @@
+uid://375ynh2lk7vl
diff --git a/examples/2DCoopBallChallenge/scenes/tileset/tileset.tres b/examples/2DCoopBallChallenge/scenes/tileset/tileset.tres
new file mode 100755
index 0000000..4bde33b
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/tileset/tileset.tres
@@ -0,0 +1,32 @@
+[gd_resource type="TileSet" load_steps=3 format=3 uid="uid://sdmrwh4xd6qj"]
+
+[ext_resource type="Texture2D" uid="uid://c26la8ahcrqow" path="res://assets/tilesheet.png" id="1_xi302"]
+
+[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_oky61"]
+texture = ExtResource("1_xi302")
+texture_region_size = Vector2i(160, 160)
+0:0/0 = 0
+0:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80)
+1:0/0 = 0
+1:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80)
+2:0/0 = 0
+2:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80)
+0:1/0 = 0
+0:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80)
+1:1/0 = 0
+1:1/0/physics_layer_1/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80)
+2:1/0 = 0
+2:1/0/physics_layer_2/polygon_0/points = PackedVector2Array(-80, 80, -50.9091, -7.27273, 50.9091, -7.27273, 80, 80)
+0:2/0 = 0
+0:2/0/physics_layer_1/polygon_0/points = PackedVector2Array(-21.8182, -21.8182, 21.8182, -21.8182, 21.8182, 21.8182, -21.8182, 21.8182, -21.8182, 21.8182)
+1:2/0 = 0
+1:2/0/physics_layer_3/polygon_0/points = PackedVector2Array(-50.9091, -50.9091, -7.27273, 80, 7.27273, 80, 50.9091, -50.9091)
+2:2/0 = 0
+
+[resource]
+tile_size = Vector2i(160, 160)
+physics_layer_0/collision_layer = 1
+physics_layer_1/collision_layer = 8
+physics_layer_2/collision_layer = 8
+physics_layer_3/collision_layer = 16
+sources/0 = SubResource("TileSetAtlasSource_oky61")
diff --git a/examples/2DCoopBallChallenge/scenes/training_scene/ball.gd b/examples/2DCoopBallChallenge/scenes/training_scene/ball.gd
new file mode 100755
index 0000000..2444f97
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/training_scene/ball.gd
@@ -0,0 +1,97 @@
+extends CharacterBody2D
+class_name Ball
+
+@export var game_manager: GameManager
+@export var approach_goal_reward: ApproachNodeReward2D
+
+@onready var initial_transform = transform
+
+var pre_slide_velocity: Vector2
+var drag: float = 1.5
+var last_player_collided: Player
+
+
+func get_approach_goal_reward():
+ return approach_goal_reward.get_reward()
+
+
+func _physics_process(delta: float) -> void:
+ pre_slide_velocity = velocity
+ velocity.x /= 1 + (drag * delta)
+ velocity.y /= 1 + (drag * delta)
+ velocity.x = clampf(velocity.x, -2500, 2500)
+ velocity.y = clampf(velocity.y, -2500, 2500)
+
+ move_and_slide()
+ bounce_on_ground_collision()
+ apply_gravity(delta)
+
+
+func apply_gravity(delta) -> void:
+ velocity += Vector2.DOWN * 2500.0 * delta
+
+
+func bounce_on_ground_collision():
+ for i in get_slide_collision_count():
+ var last_coll = get_slide_collision(i)
+ if last_coll:
+ var collider := last_coll.get_collider()
+ if collider is MapManager:
+ velocity = pre_slide_velocity.bounce(last_coll.get_normal())
+
+
+func reset():
+ approach_goal_reward.reset()
+ last_player_collided = null
+ transform = initial_transform
+ global_position = game_manager.players.get_random_player().global_position + Vector2.UP * 2000
+ position.y = max(70, position.y)
+ velocity = Vector2.ZERO
+
+
+var platform_collision_count: int = 0
+
+
+func episode_failed():
+ game_manager.episode_failed()
+
+
+func _on_area_2d_body_shape_entered(
+ body_rid: RID, collider: Node2D, _body_shape_index: int, _local_shape_index: int
+) -> void:
+ if collider is Player:
+ if collider == last_player_collided:
+ episode_failed()
+ else:
+ var move = collider.global_position.direction_to(global_position) * 85
+ if not test_move(global_transform, move):
+ global_position += move
+ velocity = (
+ (collider.global_position.direction_to(global_position))
+ * (
+ 1000
+ + collider.get_real_velocity().dot(
+ collider.global_position.direction_to(global_position)
+ )
+ )
+ )
+ game_manager.player_hit_ball(collider)
+ last_player_collided = collider
+
+ elif collider is MapManager:
+ var coords = collider.get_coords_for_body_rid(body_rid)
+ if (
+ collider.get_cell_atlas_coords(coords) == MapManager.Tiles.PLATFORM_LEFT_EDGE
+ or collider.get_cell_atlas_coords(coords) == MapManager.Tiles.PLATFORM_MIDDLE
+ or collider.get_cell_atlas_coords(coords) == MapManager.Tiles.PLATFORM_RIGHT_EDGE
+ ):
+ if platform_collision_count == 0:
+ platform_collision_count += 1
+ await get_tree().physics_frame
+ episode_failed()
+ platform_collision_count = 0
+
+
+func _on_goal_body_entered(body: Node2D) -> void:
+ if body == self:
+ game_manager.on_ball_goal_reached()
diff --git a/examples/2DCoopBallChallenge/scenes/training_scene/ball.gd.uid b/examples/2DCoopBallChallenge/scenes/training_scene/ball.gd.uid
new file mode 100755
index 0000000..d2538a3
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/training_scene/ball.gd.uid
@@ -0,0 +1 @@
+uid://8xjlwdi087jf
diff --git a/examples/2DCoopBallChallenge/scenes/training_scene/camera_2d.gd b/examples/2DCoopBallChallenge/scenes/training_scene/camera_2d.gd
new file mode 100755
index 0000000..a97c745
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/training_scene/camera_2d.gd
@@ -0,0 +1,5 @@
+extends Camera2D
+
+
+func _ready() -> void:
+ make_current()
diff --git a/examples/2DCoopBallChallenge/scenes/training_scene/camera_2d.gd.uid b/examples/2DCoopBallChallenge/scenes/training_scene/camera_2d.gd.uid
new file mode 100755
index 0000000..3e4e6ea
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/training_scene/camera_2d.gd.uid
@@ -0,0 +1 @@
+uid://bqt62hhqmt7bv
diff --git a/examples/2DCoopBallChallenge/scenes/training_scene/inference_scene.tscn b/examples/2DCoopBallChallenge/scenes/training_scene/inference_scene.tscn
new file mode 100755
index 0000000..5b5b2c6
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/training_scene/inference_scene.tscn
@@ -0,0 +1,65 @@
+[gd_scene load_steps=7 format=3 uid="uid://c2baxuisewykf"]
+
+[ext_resource type="PackedScene" uid="uid://dl1ldlvoh3s4n" path="res://scenes/game_scene/all_game_scenes.tscn" id="1_h4anc"]
+[ext_resource type="Script" uid="uid://bqt62hhqmt7bv" path="res://scenes/training_scene/camera_2d.gd" id="3_h4anc"]
+[ext_resource type="Script" uid="uid://dkxvjlyxwlu3t" path="res://addons/godot_rl_agents/sync.gd" id="3_m4b8y"]
+
+[sub_resource type="Animation" id="Animation_m4b8y"]
+length = 0.001
+tracks/0/type = "value"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath(".:position")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"times": PackedFloat32Array(0),
+"transitions": PackedFloat32Array(1),
+"update": 0,
+"values": [Vector2(200, -10722)]
+}
+
+[sub_resource type="Animation" id="Animation_h4anc"]
+resource_name = "new_animation"
+length = 30.0
+loop_mode = 2
+tracks/0/type = "value"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath(".:position")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"times": PackedFloat32Array(0, 6, 10, 16, 20),
+"transitions": PackedFloat32Array(1, 0.5, -2, 0.5, -2),
+"update": 0,
+"values": [Vector2(200, -10722), Vector2(206.521, -10722), Vector2(207, -299), Vector2(207, -299), Vector2(207, 13167)]
+}
+
+[sub_resource type="AnimationLibrary" id="AnimationLibrary_8edvk"]
+_data = {
+&"RESET": SubResource("Animation_m4b8y"),
+&"new_animation": SubResource("Animation_h4anc")
+}
+
+[node name="InferenceScene" type="Node2D"]
+
+[node name="Camera2D" type="Camera2D" parent="."]
+position = Vector2(200, -10722)
+zoom = Vector2(0.257, 0.257)
+script = ExtResource("3_h4anc")
+
+[node name="AnimationPlayer" type="AnimationPlayer" parent="Camera2D"]
+libraries = {
+&"": SubResource("AnimationLibrary_8edvk")
+}
+autoplay = "new_animation"
+speed_scale = 0.5
+
+[node name="AllGameScenes" parent="." instance=ExtResource("1_h4anc")]
+
+[node name="Sync" type="Node" parent="."]
+script = ExtResource("3_m4b8y")
+control_mode = 2
+action_repeat = 2
+onnx_model_path = "onnx/model.onnx"
diff --git a/examples/2DCoopBallChallenge/scenes/training_scene/training_scene.tscn b/examples/2DCoopBallChallenge/scenes/training_scene/training_scene.tscn
new file mode 100755
index 0000000..5bce5d4
--- /dev/null
+++ b/examples/2DCoopBallChallenge/scenes/training_scene/training_scene.tscn
@@ -0,0 +1,204 @@
+[gd_scene load_steps=4 format=3 uid="uid://b6fkgmq1op7kd"]
+
+[ext_resource type="Script" uid="uid://bqt62hhqmt7bv" path="res://scenes/training_scene/camera_2d.gd" id="2_milqm"]
+[ext_resource type="Script" uid="uid://dkxvjlyxwlu3t" path="res://addons/godot_rl_agents/sync.gd" id="3_n8d1s"]
+[ext_resource type="PackedScene" uid="uid://dl1ldlvoh3s4n" path="res://scenes/game_scene/all_game_scenes.tscn" id="4_n8d1s"]
+
+[node name="TrainingScene" type="Node2D"]
+
+[node name="Camera2D" type="Camera2D" parent="."]
+position = Vector2(4166, 2894)
+zoom = Vector2(0.107, 0.107)
+script = ExtResource("2_milqm")
+
+[node name="Sync" type="Node" parent="."]
+script = ExtResource("3_n8d1s")
+action_repeat = 2
+
+[node name="AllGameScenes" parent="." instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport2" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport2" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport3" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport3" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport4" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport4" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport5" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport5" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport6" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport6" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport7" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport7" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport8" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport8" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport9" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport9" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport10" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport10" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport11" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport11" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport12" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport12" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport13" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport13" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport14" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport14" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport15" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport15" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport16" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport16" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport17" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport17" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport18" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport18" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport19" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport19" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport20" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport20" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport21" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport21" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport22" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport22" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport23" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport23" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport24" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport24" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport25" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport25" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport26" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport26" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport27" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport27" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport28" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport28" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport29" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport29" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport30" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport30" instance=ExtResource("4_n8d1s")]
+
+[node name="SubViewport31" type="SubViewport" parent="."]
+disable_3d = true
+canvas_cull_mask = 4294967294
+
+[node name="AllGameScenes" parent="SubViewport31" instance=ExtResource("4_n8d1s")]