Compare commits

...

5 Commits

138 changed files with 4829 additions and 6865 deletions
+15
View File
@@ -0,0 +1,15 @@
---
name: godot
description: Runs all godot-mcp tool interactions (reading/editing scenes, running the project, in-game verification, screenshots, debug output) on Haiku to keep verbose Godot tool outputs out of the main model's context and reduce cost. Give it a concrete, self-contained task and tell it exactly what to report back.
tools: mcp__godot-mcp__*, Read, Grep, Glob, Bash
model: haiku
---
You operate the Godot project at `Game/` (repo root: the parent directory) via the godot-mcp tools. Prefer godot-mcp tools over shell commands or manual file parsing for anything involving scenes, nodes, scripts, the input map, or the running game.
Ground rules:
- The Godot executable is at `/Applications/Godot.app/Contents/MacOS/Godot` if you need the CLI (e.g. headless runs); otherwise use the MCP tools.
- `get_debug_output` and `stop_project` can return enormous logs — never dump them into your report. Grep/filter for the relevant lines and quote only those.
- When verifying gameplay, follow the existing patterns: interact via input actions (`game_key_press` with action names from `project.godot`), inspect state with `game_get_property`/`game_get_scene_tree`, and check `game_get_errors`.
- Always stop a running project before finishing unless told otherwise.
- Report back exactly what was asked: the outcome, key evidence (short quotes, property values), and any errors — not raw tool output.
+8 -1
View File
@@ -1 +1,8 @@
.DS_Store .DS_Store
# RL training artifacts (training/ code is committed; outputs are not)
training/.venv/
training/logs/
training/checkpoints/
training/smoke_run.log
training/__pycache__/
+3
View File
@@ -0,0 +1,3 @@
[submodule "mcp/godot-mcp"]
path = mcp/godot-mcp
url = https://github.com/tugcantopaloglu/godot-mcp.git
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"godot-mcp": {
"command": "node",
"args": ["mcp/godot-mcp/build/index.js"],
"env": {
"GODOT_PATH": ""
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Important rule: never create co-authored commits. Never mention Claude in commits.
## Project overview
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
## Godot MCP server
This repo vendors [godot-mcp](https://github.com/tugcantopaloglu/godot-mcp) as a git submodule at `mcp/godot-mcp` and registers it in `.mcp.json`. **Prefer the godot-mcp tools over manual file edits or shell commands** when the task involves inspecting or modifying the Godot project — reading/editing scenes, nodes, scripts, running the project, or interacting with a live Godot editor/runtime instance. It understands Godot's scene tree and `.tscn`/`.gd` structures directly, which is more reliable than hand-parsing them.
Setup after cloning (submodules aren't checked out by default):
```bash
git submodule update --init --recursive
cd mcp/godot-mcp
npm install
npm run build
```
`GODOT_PATH` (env var in `.mcp.json`) is left blank to auto-detect the Godot executable; set it explicitly if auto-detection fails on your machine.
## Commands
There is no build step, linter, or automated test suite for the GDScript project itself — Godot projects run directly from source.
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
## Architecture
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework (see `TODO.md` for the deferred work). The three load-bearing seams are the controller abstraction, the arena/game-mode split, and code-driven spawning.
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the ship, camera rig, and game mode via groups (`"ship"`, `"ship_camera"`, `"game"`), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks.
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
## Reinforcement learning / AI bots
See `TRAINING.md` for the full workflow (training, exporting, evaluating, difficulty tiers). Architecture summary:
- `scenes/training.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
- `scripts/ship_observations.gd` is the shared observation builder used by both training and in-game inference — never fork or diverge these two paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/`; Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent.
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`).
- The flattened action space is Box(7): thrust xyz, rotation xyz, turbo (>0 = on) — this is `ShipAction` verbatim; change either only deliberately and together.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Edward Beeching
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3
View File
@@ -0,0 +1,3 @@
Vendored from https://github.com/edbeeching/godot_rl_agents_plugin
commit 998c357a0cd09b37f40a36d70c7867fc9f682338 (2026-06-13), MIT license (see LICENSE).
The onnx/csharp files are unused (require the .NET Godot build); in-game inference uses scripts/policy_network.gd instead.
@@ -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
@@ -0,0 +1 @@
uid://dgfbh3y7s07g7
@@ -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
@@ -0,0 +1 @@
uid://qm8wpg7ccydk
@@ -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")
@@ -0,0 +1 @@
uid://bdnkavfpf6ge6
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bxy3je5atsh68"
path="res://.godot/imported/icon.png-45a871b53434e556222f5901d598ab34.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/godot_rl_agents/icon.png"
dest_files=["res://.godot/imported/icon.png-45a871b53434e556222f5901d598ab34.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,115 @@
using Godot;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using System.Collections.Generic;
using System.Linq;
namespace GodotONNX
{
/// <include file='docs/ONNXInference.xml' path='docs/members[@name="ONNXInference"]/ONNXInference/*'/>
public partial class ONNXInference : GodotObject
{
private InferenceSession session;
/// <summary>
/// Path to the ONNX model. Use Initialize to change it.
/// </summary>
private string modelPath;
private int batchSize;
private SessionOptions SessionOpt;
/// <summary>
/// init function
/// </summary>
/// <param name="Path"></param>
/// <param name="BatchSize"></param>
/// <returns>Returns the output size of the model</returns>
public int Initialize(string Path, int BatchSize)
{
modelPath = Path;
batchSize = BatchSize;
SessionOpt = SessionConfigurator.MakeConfiguredSessionOptions();
session = LoadModel(modelPath);
return session.OutputMetadata["output"].Dimensions[1];
}
/// <include file='docs/ONNXInference.xml' path='docs/members[@name="ONNXInference"]/Run/*'/>
public Godot.Collections.Dictionary<string, Godot.Collections.Array<float>> RunInference(Godot.Collections.Dictionary<string, Godot.Collections.Array<float>> obs, int state_ins)
{
//Current model: Any (Godot Rl Agents)
//Expects a tensor of shape [batch_size, input_size] type float for any output of the agents observation dictionary and a tensor of shape [batch_size] type float named state_ins
var modelInputsList = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("state_ins", new DenseTensor<float>(new float[] { state_ins }, new int[] { batchSize }))
};
foreach (var key in obs.Keys)
{
var subObs = obs[key];
// Fill the input tensors for each key of the observation
// create span of observation from specific inputSize
var obsData = new float[subObs.Count]; //There's probably a better way to do this
for (int i = 0; i < subObs.Count; i++)
{
obsData[i] = subObs[i];
}
modelInputsList.Add(
NamedOnnxValue.CreateFromTensor(key, new DenseTensor<float>(obsData, new int[] { batchSize, subObs.Count }))
);
}
IReadOnlyCollection<string> outputNames = new List<string> { "output", "state_outs" }; //ONNX is sensible to these names, as well as the input names
IDisposableReadOnlyCollection<DisposableNamedOnnxValue> results;
//We do not use "using" here so we get a better exception explaination later
try
{
results = session.Run(modelInputsList, 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<float> to Variant, so we have to convert it to an array or something
Godot.Collections.Dictionary<string, Godot.Collections.Array<float>> output = new Godot.Collections.Dictionary<string, Godot.Collections.Array<float>>();
DisposableNamedOnnxValue output1 = results.First();
DisposableNamedOnnxValue output2 = results.Last();
Godot.Collections.Array<float> output1Array = new Godot.Collections.Array<float>();
Godot.Collections.Array<float> output2Array = new Godot.Collections.Array<float>();
foreach (float f in output1.AsEnumerable<float>())
{
output1Array.Add(f);
}
foreach (float f in output2.AsEnumerable<float>())
{
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;
}
/// <include file='docs/ONNXInference.xml' path='docs/members[@name="ONNXInference"]/Load/*'/>
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();
}
}
}
@@ -0,0 +1,131 @@
using Godot;
using Microsoft.ML.OnnxRuntime;
namespace GodotONNX
{
/// <include file='docs/SessionConfigurator.xml' path='docs/members[@name="SessionConfigurator"]/SessionConfigurator/*'/>
public static class SessionConfigurator
{
public enum ComputeName
{
CUDA,
ROCm,
DirectML,
CoreML,
CPU
}
/// <include file='docs/SessionConfigurator.xml' path='docs/members[@name="SessionConfigurator"]/GetSessionOptions/*'/>
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);
}
/// <include file='docs/SessionConfigurator.xml' path='docs/members[@name="SessionConfigurator"]/SystemCheck/*'/>
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;
}
*/
}
/// <include file='docs/SessionConfigurator.xml' path='docs/members[@name="SessionConfigurator"]/ComputeCheck/*'/>
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;
}
}
}
@@ -0,0 +1,31 @@
<docs>
<members name="ONNXInference">
<ONNXInference>
<summary>
The main <c>ONNXInference</c> Class that handles the inference process.
</summary>
</ONNXInference>
<Initialize>
<summary>
Starts the inference process.
</summary>
<param name="Path">Path to the ONNX model, expects a path inside resources.</param>
<param name="BatchSize">How many observations will the model recieve.</param>
</Initialize>
<Run>
<summary>
Runs the given input through the model and returns the output.
</summary>
<param name="obs">Dictionary containing all observations.</param>
<param name="state_ins">How many different agents are creating these observations.</param>
<returns>A Dictionary of arrays, containing instructions based on the observations.</returns>
</Run>
<Load>
<summary>
Loads the given model into the inference process, using the best Execution provider available.
</summary>
<param name="Path">Path to the ONNX model, expects a path inside resources.</param>
<returns>InferenceSession ready to run.</returns>
</Load>
</members>
</docs>
@@ -0,0 +1,29 @@
<docs>
<members name="SessionConfigurator">
<SessionConfigurator>
<summary>
The main <c>SessionConfigurator</c> Class that handles the execution options and providers for the inference process.
</summary>
</SessionConfigurator>
<GetSessionOptions>
<summary>
Creates a SessionOptions with all available execution providers.
</summary>
<returns>SessionOptions with all available execution providers.</returns>
</GetSessionOptions>
<SystemCheck>
<summary>
Appends any execution provider available in the current system.
</summary>
<remarks>
This function is mainly verbose for tracking implementation progress of different compute APIs.
</remarks>
</SystemCheck>
<ComputeCheck>
<summary>
Checks for available GPUs.
</summary>
<returns>An integer identifier for each compute platform.</returns>
</ComputeCheck>
</members>
</docs>
@@ -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 observations as an Dictionary and the state_ins as an int
# returns a Dictionary containing the action the model takes.
func run_inference(obs: Dictionary, 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
@@ -0,0 +1 @@
uid://c35ckkxpe764s
+7
View File
@@ -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"
@@ -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
@@ -0,0 +1 @@
uid://2jcows6svnje
@@ -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_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
@@ -0,0 +1 @@
uid://bpgeoecqatvwi
@@ -0,0 +1,10 @@
extends Node2D
class_name RewardFunction2D
func get_reward():
return 0.0
func reset():
return
@@ -0,0 +1 @@
uid://52jl48u122l8
@@ -0,0 +1,10 @@
extends Node3D
class_name RewardFunction3D
func get_reward():
return 0.0
func reset():
return
@@ -0,0 +1 @@
uid://bwqq2ytgnepid
@@ -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")
@@ -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)
@@ -0,0 +1 @@
uid://ctfloulhiht0v
@@ -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
@@ -0,0 +1 @@
uid://dwhx5ltv86b4e
@@ -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
@@ -0,0 +1 @@
uid://dvny3m75wta1e
@@ -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 := Vector2i(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 := Vector2i(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 := Vector2i(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]
@@ -0,0 +1 @@
uid://c8f8s63q3yyu1
@@ -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
@@ -0,0 +1,123 @@
@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 := false:
get:
return debug_draw
set(value):
debug_draw = value
_update()
var _angles = []
var rays := []
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():
for ray in get_children():
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)))
)
if debug_draw:
ray.enabled = true
else:
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)
ray.set_owner(get_tree().edited_scene_root)
ray.set_name("node_" + str(i))
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:
if not debug_draw:
ray.enabled = true
ray.force_raycast_update()
var distance = _get_raycast_distance(ray)
result.append(distance)
if not debug_draw:
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
@@ -0,0 +1 @@
uid://c0eh0jjfthdgw
@@ -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
@@ -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)
@@ -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)
@@ -0,0 +1 @@
uid://me8mehqmblq8
@@ -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
@@ -0,0 +1 @@
uid://b6dvaob0xndoh
@@ -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
@@ -0,0 +1 @@
uid://cew2a213sw1q
@@ -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 := Vector2i(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 := Vector2i(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 := Vector2i(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]
@@ -0,0 +1 @@
uid://6e38006xhqf0
@@ -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
@@ -0,0 +1,197 @@
@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
@export var debug_draw := false:
get:
return debug_draw
set(value):
debug_draw = value
_update()
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)
if debug_draw:
ray.enabled = true
else:
ray.enabled = false
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)
ray.set_name("node_" + str(i) + " " + str(j))
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:
if not debug_draw:
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))
if not debug_draw:
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
@@ -0,0 +1 @@
uid://glop37kjpwci
@@ -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)
+621
View File
@@ -0,0 +1,621 @@
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 := ""
## Whether the inference will be deterministic (NOTE: Only applies to discrete actions in onnx inference mode)
@export var deterministic_inference := true
# 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], 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 # 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
if deterministic_inference:
result[key] = largest_logit_idx # Index of the largest logit is the discrete action value
else:
var exp_logit_sum: float # Sum of exp of each logit
var exp_logits: Array[float]
for logit_idx in range(0, size):
# Normalize using the max logit to add stability in case a logit would be huge after exp
exp_logits.append(exp(action_array[index + logit_idx] - largest_logit))
exp_logit_sum += exp_logits[logit_idx]
# Choose a random number, will be used to select an action
var random_value = randf_range(0, exp_logit_sum)
# Select the first index at which the sum is larger than the random number
var sum: float
for exp_logit_idx in exp_logits.size():
sum += exp_logits[exp_logit_idx]
if sum > random_value:
result[key] = exp_logit_idx
break
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)
+1
View File
@@ -0,0 +1 @@
uid://b4vk6sql5v1jl
@@ -15,9 +15,13 @@ dest_files=["res://.godot/imported/gold_ball.blend-c2b8b4e1fb4c47cd0c20f77e5daf3
nodes/root_type="" nodes/root_type=""
nodes/root_name="" nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true nodes/apply_root_scale=true
nodes/root_scale=1.0 nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true meshes/ensure_tangents=true
meshes/generate_lods=true meshes/generate_lods=true
@@ -32,6 +36,9 @@ animation/trimming=false
animation/remove_immutable_tracks=true animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false animation/import_rest_as_RESET=false
import_script/path="" import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={} _subresources={}
blender/nodes/visible=0 blender/nodes/visible=0
blender/nodes/active_collection_only=false blender/nodes/active_collection_only=false
@@ -39,10 +46,11 @@ blender/nodes/punctual_lights=true
blender/nodes/cameras=true blender/nodes/cameras=true
blender/nodes/custom_properties=true blender/nodes/custom_properties=true
blender/nodes/modifiers=1 blender/nodes/modifiers=1
blender/meshes/colors=false blender/meshes/vertex_colors=2
blender/meshes/uvs=true blender/meshes/uvs=true
blender/meshes/normals=true blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true blender/meshes/tangents=true
blender/meshes/skins=2 blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false blender/meshes/export_bones_deforming_mesh_only=false
@@ -51,3 +59,5 @@ blender/materials/export_materials=1
blender/animation/limit_playback=true blender/animation/limit_playback=true
blender/animation/always_sample=true blender/animation/always_sample=true
blender/animation/group_tracks=true blender/animation/group_tracks=true
gltf/naming_version=2
gltf/texture_map_mode=0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

-35
View File
@@ -1,35 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cqubdkwj11v1i"
path.s3tc="res://.godot/imported/Grass.jpg-6a640b9c5e86d03c7318eda5c14b9c10.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/models/Grass.jpg"
dest_files=["res://.godot/imported/Grass.jpg-6a640b9c5e86d03c7318eda5c14b9c10.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
-13
View File
@@ -1,13 +0,0 @@
# Blender MTL File: 'None'
# Material Count: 1
newmtl Grass
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2
map_Kd C:\\Users\\Daniel\\Desktop\\Godot Projects\\OpenWorldGame\\Procedural Generation\\Textures\\Grass.jpg
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
[remap]
importer="wavefront_obj"
importer_version=1
type="Mesh"
uid="uid://cndcxj6gf0smr"
path="res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"
[deps]
files=["res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"]
source_file="res://assets/models/Terrain.obj"
dest_files=["res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh", "res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"]
[params]
generate_tangents=true
generate_lods=true
generate_shadow_mesh=true
generate_lightmap_uv2=false
generate_lightmap_uv2_texel_size=0.2
scale_mesh=Vector3(1, 1, 1)
offset_mesh=Vector3(0, 0, 0)
force_disable_mesh_compression=false
-6
View File
@@ -1,6 +0,0 @@
[gd_resource type="StandardMaterial3D" load_steps=2 format=3 uid="uid://drm3clvqpis42"]
[ext_resource type="Texture2D" uid="uid://cqubdkwj11v1i" path="res://assets/models/Grass.jpg" id="1_imo8j"]
[resource]
albedo_texture = ExtResource("1_imo8j")
+10 -1
View File
@@ -15,9 +15,13 @@ dest_files=["res://.godot/imported/gold_ball.glb-704aa0b9ad2b63d580121e20c448248
nodes/root_type="Node3D" nodes/root_type="Node3D"
nodes/root_name="Scene Root" nodes/root_name="Scene Root"
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true nodes/apply_root_scale=true
nodes/root_scale=1.0 nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true meshes/ensure_tangents=true
meshes/generate_lods=true meshes/generate_lods=true
@@ -32,6 +36,9 @@ animation/trimming=false
animation/remove_immutable_tracks=true animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false animation/import_rest_as_RESET=false
import_script/path="" import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={ _subresources={
"meshes": { "meshes": {
"gold_ball_Sphere": { "gold_ball_Sphere": {
@@ -41,10 +48,12 @@ _subresources={
"lods/normal_merge_angle": 60.0, "lods/normal_merge_angle": 60.0,
"lods/normal_split_angle": 25.0, "lods/normal_split_angle": 25.0,
"save_to_file/enabled": true, "save_to_file/enabled": true,
"save_to_file/fallback_path": "res://assets/models/gold_ball.res",
"save_to_file/make_streamable": "", "save_to_file/make_streamable": "",
"save_to_file/path": "res://assets/models/gold_ball.res" "save_to_file/path": "uid://ucw1lyo43yi4"
} }
} }
} }
gltf/naming_version=0 gltf/naming_version=0
gltf/embedded_image_handling=1 gltf/embedded_image_handling=1
gltf/texture_map_mode=0
Binary file not shown.
File diff suppressed because one or more lines are too long
+6
View File
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.cte
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true process/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false
+94
View File
@@ -0,0 +1,94 @@
[gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://scripts/arena_boundary.gd" id="1_bndry"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
albedo_color = Color(0.08, 0.09, 0.12, 1)
metallic = 0.3
roughness = 0.6
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_field"]
transparency = 1
shading_mode = 0
albedo_color = Color(0, 0.368627, 1, 0.08)
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
material = SubResource("StandardMaterial3D_floor")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_ceiling"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_side_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(1, 13, 38)
[sub_resource type="BoxMesh" id="BoxMesh_end_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(26, 13, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_slab"]
size = Vector3(28, 1, 40)
[sub_resource type="BoxShape3D" id="BoxShape3D_side_wall"]
size = Vector3(1, 13, 38)
[sub_resource type="BoxShape3D" id="BoxShape3D_end_wall"]
size = Vector3(26, 13, 1)
[node name="ArenaBoundary" type="StaticBody3D"]
script = ExtResource("1_bndry")
[node name="FloorShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="FloorMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
mesh = SubResource("BoxMesh_floor")
[node name="WallPosXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallPosXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallNegXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallNegXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallPosZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallPosZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="WallNegZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallNegZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="CeilingShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="CeilingMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_ceiling")
+2 -1
View File
@@ -8,9 +8,10 @@
bounce = 0.8 bounce = 0.8
friction = 0.3 friction = 0.3
[node name="Ball" type="RigidBody3D"] [node name="Ball" type="RigidBody3D" groups=["ball"]]
mass = 3 mass = 3
physics_material_override = SubResource("PhysicsMaterial_ball") physics_material_override = SubResource("PhysicsMaterial_ball")
continuous_cd = true
inertia = Vector3(3, 3, 3) inertia = Vector3(3, 3, 3)
gravity_scale = 0.8 gravity_scale = 0.8
linear_damp = 0.1 linear_damp = 0.1
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=5 format=3 uid="uid://cofdcxo5170rs"] [gd_scene load_steps=5 format=3 uid="uid://cofdcxo5170rs"]
[ext_resource type="Script" path="res://scripts/goal2.gd" id="1_v8ikr"] [ext_resource type="Script" path="res://scripts/goal.gd" id="1_v8ikr"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ptddx"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ptddx"]
transparency = 1 transparency = 1
+7 -8
View File
@@ -1,7 +1,10 @@
[gd_scene load_steps=5 format=3 uid="uid://p07epxnh8wwp"] [gd_scene load_steps=5 format=3 uid="uid://p07epxnh8wwp"]
[ext_resource type="Script" uid="uid://dyq1n7q1bqjps" path="res://scripts/ship.gd" id="1_efag7"] [ext_resource type="Script" uid="uid://dyq1n7q1bqjps" path="res://scripts/ship.gd" id="1_efag7"]
[ext_resource type="PackedScene" uid="uid://c8kak2l3m4n5" path="res://scenes/HUD.tscn" id="2_hud_scene"]
[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_ship"]
friction = 0.1
bounce = 0.2
[sub_resource type="BoxMesh" id="BoxMesh_efag7"] [sub_resource type="BoxMesh" id="BoxMesh_efag7"]
size = Vector3(1, 1, 4) size = Vector3(1, 1, 4)
@@ -10,8 +13,9 @@ size = Vector3(1, 1, 4)
size = Vector3(1, 1, 4) size = Vector3(1, 1, 4)
[node name="Ship" type="RigidBody3D"] [node name="Ship" type="RigidBody3D"]
mass = 10.0 mass = 5.0
gravity_scale = 10.0 physics_material_override = SubResource("PhysicsMaterial_ship")
inertia = Vector3(1, 1, 1)
script = ExtResource("1_efag7") script = ExtResource("1_efag7")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."] [node name="MeshInstance3D" type="MeshInstance3D" parent="."]
@@ -19,8 +23,3 @@ mesh = SubResource("BoxMesh_efag7")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."] [node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_dsjou") shape = SubResource("BoxShape3D_dsjou")
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.867366, 0.497671, 0, -0.497671, 0.867366, 0, 2.04336, 3.31556)
[node name="HUD" parent="." instance=ExtResource("2_hud_scene")]
-30
View File
@@ -1,30 +0,0 @@
[gd_scene load_steps=5 format=3 uid="uid://dnra1buk328d"]
[ext_resource type="Script" uid="uid://byy2mu4mxdlgl" path="res://scripts/Vehicle.gd" id="1_5snef"]
[sub_resource type="BoxShape3D" id="BoxShape3D_0otfk"]
size = Vector3(0.5, 0.5, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hrmfk"]
albedo_color = Color(0.670588, 0, 0, 1)
[sub_resource type="BoxMesh" id="BoxMesh_uwum7"]
material = SubResource("StandardMaterial3D_hrmfk")
size = Vector3(0.5, 0.5, 1)
[node name="Vehicle" type="RigidBody3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.08165e-12, 0.5, 2)
mass = 10.0
gravity_scale = 10.0
lock_rotation = true
script = ExtResource("1_5snef")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_0otfk")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("BoxMesh_uwum7")
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, -4.68079e-16, 3.27752e-16, 3.27752e-16, 0.939693, 0.34202, -4.68079e-16, -0.34202, 0.939693, 2.08165e-12, 0.8, 1.2)
current = true
+17 -6
View File
@@ -8,15 +8,23 @@
config_version=5 config_version=5
[animation]
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application] [application]
config/name="Cosmic Clash" config/name="Cosmic Clash"
config/description="A fast-paced, physics-based sports game set in space. From Raymond Studios." config/description="A fast-paced, physics-based sports game set in space. From Raymond Studios."
config/version="0.0.1" config/version="0.0.1"
run/main_scene="uid://bcq14356s3e2i" run/main_scene="uid://bcq14356s3e2i"
config/features=PackedStringArray("4.4", "Forward Plus") config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
[editor_plugins]
enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
[display] [display]
window/size/viewport_width=1920 window/size/viewport_width=1920
@@ -27,6 +35,8 @@ window/stretch/aspect="expand"
[input] [input]
reset_ball={"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":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]}
move_forward={ move_forward={
"deadzone": 0.2, "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":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) "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":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
@@ -47,11 +57,6 @@ move_right={
"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) "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)
] ]
} }
move_backward={
"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":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
move_up={ move_up={
"deadzone": 0.2, "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) "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)
@@ -108,3 +113,9 @@ roll_right={
[physics] [physics]
3d/physics_engine="Jolt Physics" 3d/physics_engine="Jolt Physics"
[autoload]
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene load_steps=3 format=3 uid="uid://c8kak2l3m4n5"] [gd_scene load_steps=3 format=3 uid="uid://c8kak2l3m4n5"]
[ext_resource type="Script" uid="uid://bx9j8k7l6m5n" path="res://scripts/HUDController.gd" id="1_hud_controller"] [ext_resource type="Script" uid="uid://du7y176h5aaq4" path="res://scripts/HUDController.gd" id="1_hud_controller"]
[sub_resource type="LabelSettings" id="LabelSettings_hud"] [sub_resource type="LabelSettings" id="LabelSettings_hud"]
font_size = 32 font_size = 32
+70
View File
@@ -0,0 +1,70 @@
[gd_scene load_steps=8 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
[sub_resource type="Shader" id="Shader_stars"]
code = "shader_type sky;
// Procedural starfield: sparse hash-based stars over near-black space, with a
// faint blue nebula glow toward the horizon.
void sky() {
vec3 dir = EYEDIR;
vec3 col = vec3(0.01, 0.012, 0.02);
col += vec3(0.02, 0.03, 0.08) * pow(1.0 - abs(dir.y), 3.0);
vec3 cell = floor(dir * 300.0);
float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 45.164))) * 43758.5453);
if (h > 0.998) {
vec3 in_cell = fract(dir * 300.0) - 0.5;
float star = smoothstep(0.4, 0.0, length(in_cell));
col += vec3(star * (0.5 + fract(h * 999.0)));
}
COLOR = col;
}
"
[sub_resource type="ShaderMaterial" id="ShaderMaterial_stars"]
shader = SubResource("Shader_stars")
[sub_resource type="Sky" id="Sky_space"]
sky_material = SubResource("ShaderMaterial_stars")
[sub_resource type="Environment" id="Environment_space"]
background_mode = 2
sky = SubResource("Sky_space")
ambient_light_source = 2
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
ambient_light_energy = 0.4
[node name="Arena" type="Node3D"]
script = ExtResource("1_iywne")
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
shadow_enabled = true
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_space")
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 17)
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -17)
team = 1
[node name="BallSpawn" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
[node name="SpawnsTeam0" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 13.5)
[node name="SpawnsTeam1" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -13.5)
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/free_play.gd" id="1_fp"]
[ext_resource type="PackedScene" path="res://scenes/arena_01.tscn" id="2_fp"]
[ext_resource type="PackedScene" uid="uid://c8kak2l3m4n5" path="res://scenes/HUD.tscn" id="3_fp"]
[node name="FreePlay" type="Node3D"]
script = ExtResource("1_fp")
[node name="Arena" parent="." instance=ExtResource("2_fp")]
[node name="HUD" parent="." instance=ExtResource("3_fp")]
+35 -36
View File
@@ -1,44 +1,43 @@
[gd_scene load_steps=3 format=3 uid="uid://bcq14356s3e2i"] [gd_scene load_steps=2 format=3 uid="uid://bcq14356s3e2i"]
[ext_resource type="Script" uid="uid://c1tfuurttt8ft" path="res://scripts/main_menu_play_button.gd" id="1_28flt"] [ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"]
[sub_resource type="LabelSettings" id="LabelSettings_erv1k"] [node name="MainMenu" type="Control"]
layout_mode = 3
[node name="MainMenu" type="Node2D"] anchors_preset = 15
anchor_right = 1.0
[node name="Label" type="Label" parent="."] anchor_bottom = 1.0
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -51.0
offset_top = -11.0
offset_right = 253.0
offset_bottom = 109.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
size_flags_horizontal = 4 script = ExtResource("1_menu")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
layout_mode = 2
theme_override_constants/separation = 24
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 48
text = "Cosmic Clash" text = "Cosmic Clash"
label_settings = SubResource("LabelSettings_erv1k")
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1
[node name="Button" type="Button" parent="."] [node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
anchors_preset = 8 custom_minimum_size = Vector2(330, 60)
anchor_left = 0.5 layout_mode = 2
anchor_top = 0.5 text = "Free Play"
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = 347.0
offset_top = 233.0
offset_right = 676.0
offset_bottom = 373.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 4
size_flags_vertical = 4
text = "Play"
script = ExtResource("1_28flt")
[connection signal="pressed" from="Button" to="Button" method="_on_pressed"] [node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(330, 60)
layout_mode = 2
text = "Match"
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
+13
View File
@@ -0,0 +1,13 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/match_mode.gd" id="1_m"]
[ext_resource type="PackedScene" path="res://scenes/arena_01.tscn" id="2_m"]
[ext_resource type="PackedScene" uid="uid://c8kak2l3m4n5" path="res://scenes/HUD.tscn" id="3_m"]
[node name="Match" type="Node3D"]
script = ExtResource("1_m")
bot_model_path = "res://bots/rookie.json"
[node name="Arena" parent="." instance=ExtResource("2_m")]
[node name="HUD" parent="." instance=ExtResource("3_m")]
+9
View File
@@ -0,0 +1,9 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/ship_camera.gd" id="1_rig"]
[node name="ShipCameraRig" type="Node3D"]
script = ExtResource("1_rig")
[node name="Camera3D" type="Camera3D" parent="."]
current = true
+15
View File
@@ -0,0 +1,15 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/training_mode.gd" id="1_tr"]
[ext_resource type="PackedScene" path="res://scenes/arena_01.tscn" id="2_tr"]
[ext_resource type="Script" path="res://addons/godot_rl_agents/sync.gd" id="3_tr"]
[node name="Training" type="Node3D"]
script = ExtResource("1_tr")
[node name="Arena" parent="." instance=ExtResource("2_tr")]
[node name="Sync" type="Node" parent="."]
script = ExtResource("3_tr")
action_repeat = 8
speed_up = 8.0
+16 -14
View File
@@ -25,22 +25,26 @@ func _initialize_hud():
# Find the ship # Find the ship
ship = get_tree().get_first_node_in_group("ship") ship = get_tree().get_first_node_in_group("ship")
if not ship: if not ship:
# Try to find it as our parent (ship contains this HUD) push_error("HUDController: No ship found in 'ship' group")
var parent_node = get_parent() return
if parent_node and parent_node.is_in_group("ship"):
ship = parent_node
else:
push_error("HUDController: No ship found in 'ship' group")
return
print("HUDController: Found ship: ", ship.name) print("HUDController: Found ship: ", ship.name)
_connect_ship_signals() _connect_ship_signals()
# Connect to game manager's timer signal # Camera mode comes from the camera rig, not the ship
var camera_rig = get_tree().get_first_node_in_group("ship_camera")
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
# Connect to game manager's timer signal; modes without a timer
# (e.g. free play) just don't show one
var game_manager = get_tree().get_first_node_in_group("game") var game_manager = get_tree().get_first_node_in_group("game")
if game_manager and game_manager.has_signal("timer_updated"): var has_timer = game_manager and game_manager.has_signal("timer_updated")
if has_timer:
game_manager.timer_updated.connect(_on_timer_updated) game_manager.timer_updated.connect(_on_timer_updated)
print("HUDController: Connected to game timer") print("HUDController: Connected to game timer")
if timer_label and is_instance_valid(timer_label):
timer_label.visible = has_timer
func _connect_ship_signals(): func _connect_ship_signals():
# Connect ship signals to label update methods # Connect ship signals to label update methods
@@ -51,8 +55,6 @@ func _connect_ship_signals():
ship.altitude_changed.connect(_on_ship_altitude_changed) ship.altitude_changed.connect(_on_ship_altitude_changed)
if ship.has_signal("angular_velocity_changed"): if ship.has_signal("angular_velocity_changed"):
ship.angular_velocity_changed.connect(_on_ship_angular_velocity_changed) ship.angular_velocity_changed.connect(_on_ship_angular_velocity_changed)
if ship.has_signal("camera_mode_changed"):
ship.camera_mode_changed.connect(_on_ship_camera_mode_changed)
if ship.has_signal("attitude_changed"): if ship.has_signal("attitude_changed"):
ship.attitude_changed.connect(_on_ship_attitude_changed) ship.attitude_changed.connect(_on_ship_attitude_changed)
if ship.has_signal("heading_changed"): if ship.has_signal("heading_changed"):
@@ -79,7 +81,7 @@ func _on_ship_camera_mode_changed(is_ball_cam: bool):
var camera_mode = "Ball Cam" if is_ball_cam else "Ship Cam" var camera_mode = "Ball Cam" if is_ball_cam else "Ship Cam"
camera_mode_label.text = "Camera: %s" % camera_mode camera_mode_label.text = "Camera: %s" % camera_mode
func _on_ship_attitude_changed(pitch: float, roll: float, yaw: float): func _on_ship_attitude_changed(pitch: float, roll: float, _yaw: float):
if attitude_label and is_instance_valid(attitude_label): if attitude_label and is_instance_valid(attitude_label):
attitude_label.text = "Pitch: %.0f° Roll: %.0f°" % [pitch, roll] attitude_label.text = "Pitch: %.0f° Roll: %.0f°" % [pitch, roll]
-1
View File
@@ -1 +0,0 @@
uid://scnbnslvru0b
-30
View File
@@ -1,30 +0,0 @@
extends RigidBody3D
# Speed variable can be adjusted by subclasses
var speed = 10.0
func _integrate_forces(state):
# input_vector represents the movement input relative to the vehicle
var input_vector = get_input_vector()
# Scale the input_vector by speed
input_vector = input_vector.normalized() * speed
# Set the linear velocity based on the input
state.linear_velocity = input_vector
# Get the input vector from subclasses
func get_input_vector() -> Vector3:
var input_vector = Vector3.ZERO
# Subclasses will implement this function to provide their input mappings
return input_vector
# Called when the node enters the scene tree for the first time.
func _ready():
pass # Replace with function body.
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
pass
-1
View File
@@ -1 +0,0 @@
uid://byy2mu4mxdlgl
+90
View File
@@ -0,0 +1,90 @@
class_name AIShipController
extends ShipController
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
# same canonical observation as training (ShipObservations) and runs the
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
# .NET, or network dependency.
#
# Difficulty is (model, reaction_ticks, action_noise): weaker checkpoints make
# easier bots outright, and the two knobs handicap a given model further —
# slower reactions and noisier execution. Models live in res://bots/.
@export_file("*.json") var model_path: String = ""
# Decide a new action every N physics ticks, holding the last one between
# decisions. 8 matches the training action_repeat; larger = slower reactions.
@export_range(1, 60) var reaction_ticks: int = 8
# Uniform noise magnitude added to each action axis (0 = play at full skill).
@export_range(0.0, 1.0) var action_noise: float = 0.0
var _policy: PolicyNetwork
var _action := ShipAction.new()
var _ticks_until_decision := 0
var _ship: Ship
var _opponent: Ship
var _ball: RigidBody3D
var _attack_goal_position: Vector3
var _scene_refs_ready := false
func _ready():
if not model_path.is_empty():
_policy = PolicyNetwork.load_from_file(model_path)
func get_action() -> ShipAction:
if _policy == null:
return _action # unloaded model: behaves like the inert placeholder
if not _scene_refs_ready and not _discover_scene_refs():
return _action
_ticks_until_decision -= 1
if _ticks_until_decision <= 0:
_ticks_until_decision = reaction_ticks
_decide()
return _action
func _decide() -> void:
var obs := ShipObservations.build(_ship, _opponent, _ball, _attack_goal_position)
var out := _policy.forward(obs)
# Output layout matches the flattened training action space (Box(7)):
# thrust xyz, rotation xyz, turbo (> 0 means on).
_action.thrust = Vector3(
_axis(out[0]),
_axis(out[1]),
_axis(out[2])
)
_action.rotation = Vector3(
_axis(out[3]),
_axis(out[4]),
_axis(out[5])
)
_action.turbo = out[6] > 0.0
func _axis(value: float) -> float:
if action_noise > 0.0:
value += randf_range(-action_noise, action_noise)
return clampf(value, -1.0, 1.0)
# Find ship/ball/opponent/goal once everything is spawned. ShipAction axes
# are body-frame so only observations need team context (ShipObservations).
func _discover_scene_refs() -> bool:
_ship = get_parent() as Ship
if _ship == null or not is_inside_tree():
return false
_ball = get_tree().get_first_node_in_group("ball")
for node in get_tree().get_nodes_in_group("ship"):
if node != _ship:
_opponent = node
break
for goal in get_tree().get_nodes_in_group("goal"):
if goal.team == 1 - _ship.team:
_attack_goal_position = goal.global_position
if _ball == null:
return false
_scene_refs_ready = true
return true
+1
View File
@@ -0,0 +1 @@
uid://4emuhiolrkb2
+32
View File
@@ -0,0 +1,32 @@
class_name Arena
extends Node3D
# A reusable stadium: terrain, lighting, environment, two team goals, and
# spawn markers. An arena holds no rules and no state — game modes query it
# for spawn transforms and goals, then spawn ships/ball themselves.
func _ready():
add_to_group("arena")
func get_ball_spawn() -> Transform3D:
return $BallSpawn.global_transform
func get_ship_spawns(team: int) -> Array[Transform3D]:
var spawns: Array[Transform3D] = []
var container := get_node_or_null("SpawnsTeam%d" % team)
if container:
for child in container.get_children():
if child is Marker3D:
spawns.append(child.global_transform)
return spawns
func get_goals() -> Array[Goal]:
var goals: Array[Goal] = []
for child in get_children():
if child is Goal:
goals.append(child)
return goals
+1
View File
@@ -0,0 +1 @@
uid://c1kq2m6gnwjxo
+13
View File
@@ -0,0 +1,13 @@
class_name ArenaBoundary
extends StaticBody3D
# The standard arena play volume (inner faces of the enclosure). Every arena
# instances objects/arena_boundary.tscn so all arenas share one size; code
# that needs field dimensions derives them from these constants rather than
# restating numbers.
const INNER_HALF_X := 12.0
const INNER_HALF_Z := 18.0
const INNER_HEIGHT := 12.0
# Goal-centre distance from arena centre; the end walls sit 1 m behind, so a
# ball pinned against them still overlaps the goal sensor.
const GOAL_LINE_Z := 17.0
+1
View File
@@ -0,0 +1 @@
uid://f32otkpo3lvr
+22
View File
@@ -0,0 +1,22 @@
extends GameMode
# Free Play: one player ship, one ball, no timer, no score — practice like
# Rocket League's free play. R resets the ball, Esc returns to the menu.
func _start() -> void:
spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
spawn_camera_rig(player_ship)
func _on_goal_scored(conceding_team: int) -> void:
print("Goal! (into team %d's goal)" % conceding_team)
reset_ball()
func _unhandled_input(event):
if event.is_action_pressed("reset_ball"):
reset_ball()
else:
super(event)
+1
View File
@@ -0,0 +1 @@
uid://coirkjf1pbhi7
-26
View File
@@ -1,26 +0,0 @@
extends Node3D
@onready var game_timer: Timer = get_node("Timer")
signal timer_updated(minutes: int, seconds: int)
func _ready():
# Add to group for discovery by HUD
add_to_group("game")
# Set timer to 2 minutes 30 seconds (150 seconds)
game_timer.wait_time = 150.0
game_timer.one_shot = true # Timer runs once
game_timer.start()
func _process(_delta):
if game_timer.time_left > 0:
var time_left = game_timer.time_left
var minutes = int(time_left) / 60
var seconds = int(time_left) % 60
timer_updated.emit(minutes, seconds)
else:
print("Timer finished!")
func _on_timer_timeout() -> void:
if game_timer.time_left == 0:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
-1
View File
@@ -1 +0,0 @@
uid://bpxm8ge52w5g8
+108
View File
@@ -0,0 +1,108 @@
class_name GameMode
extends Node3D
# Base for game modes (Free Play, Match; later Vs-AI and multiplayer).
# A mode's scene contains an Arena (the stadium) and a HUD; the mode itself
# spawns the ball, ships, controllers, and camera in code — variable ship
# counts with mixed controller types (player/AI/network) is exactly what
# future modes need. Subclasses override _start() and _on_goal_scored().
@export var ship_scene: PackedScene = preload("res://objects/ship.tscn")
@export var ball_scene: PackedScene = preload("res://objects/ball.tscn")
const CAMERA_RIG_SCENE = preload("res://scenes/ship_camera_rig.tscn")
const MAIN_MENU_SCENE_PATH = "res://scenes/main_menu.tscn"
var arena: Arena
var ball: RigidBody3D
var ships: Array[Ship] = []
var _ship_spawn_transforms := {}
func _ready():
# Group lets the HUD discover the game mode for timer/score signals
add_to_group("game")
for child in get_children():
if child is Arena:
arena = child
break
if not arena:
push_error("GameMode: scene has no Arena child")
return
for goal in arena.get_goals():
goal.goal_scored.connect(_handle_goal_scored)
_start()
# Virtual: subclasses spawn their ball/ships/camera here.
func _start() -> void:
pass
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
func _on_goal_scored(_conceding_team: int) -> void:
pass
# Debounce: a fast ball can re-trigger the goal area before the deferred
# reset teleports it away, which would double-count the goal.
var _goal_cooldown := false
func _handle_goal_scored(conceding_team: int) -> void:
if _goal_cooldown:
return
_goal_cooldown = true
get_tree().create_timer(0.5).timeout.connect(func(): _goal_cooldown = false)
_on_goal_scored(conceding_team)
func spawn_ball() -> RigidBody3D:
ball = ball_scene.instantiate()
add_child(ball)
ball.global_transform = arena.get_ball_spawn()
return ball
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
var ship: Ship = ship_scene.instantiate()
ship.name = "ShipTeam%d_%d" % [team, ships.size()]
add_child(ship)
var spawns := arena.get_ship_spawns(team)
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
ship.global_transform = spawn_transform
ship.team = team
if controller:
ship.set_controller(controller)
ships.append(ship)
_ship_spawn_transforms[ship] = spawn_transform
return ship
func spawn_camera_rig(target: Ship) -> ShipCameraRig:
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
add_child(rig)
rig.target = target
return rig
func reset_ball() -> void:
if is_instance_valid(ball):
_reset_body(ball, arena.get_ball_spawn())
func reset_ships() -> void:
for ship in ships:
if is_instance_valid(ship):
_reset_body(ship, _ship_spawn_transforms[ship])
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
# Deferred: a RigidBody3D transform can't be set mid-physics-step
body.set_deferred("global_transform", to)
body.set_deferred("linear_velocity", Vector3.ZERO)
body.set_deferred("angular_velocity", Vector3.ZERO)
func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"):
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
+1
View File
@@ -0,0 +1 @@
uid://c6qkhqup6h0pk
+21
View File
@@ -0,0 +1,21 @@
class_name Goal
extends Area3D
# A goal is a dumb sensor: it detects the ball crossing its plane and emits
# goal_scored. The game mode owns all consequences (score, resets). Two of
# these live in each arena, one per team.
# The team that concedes when the ball enters this goal.
@export var team: int = 0
signal goal_scored(team: int)
func _ready():
# Group lets AI controllers and game modes discover goals
add_to_group("goal")
func _on_body_entered(body):
if body.is_in_group("ball"):
goal_scored.emit(team)

Some files were not shown because too many files have changed in this diff Show More