diff --git a/CORTEX_ENGINE.sln b/CORTEX_ENGINE.sln
index aa9e55e..b28c102 100644
--- a/CORTEX_ENGINE.sln
+++ b/CORTEX_ENGINE.sln
@@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Graphics", "src\Engi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CortexEngine.App", "src\CortexEngine.App\CortexEngine.App.csproj", "{33333333-3333-3333-3333-333333333333}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.AI", "src\Engine.AI\Engine.AI.csproj", "{44444444-4444-4444-4444-444444444444}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -33,5 +35,11 @@ Global
{33333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU
{33333333-3333-3333-3333-333333333333}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU
{33333333-3333-3333-3333-333333333333}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU
+ {44444444-4444-4444-4444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {44444444-4444-4444-4444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {44444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {44444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU
+ {44444444-4444-4444-4444-444444444444}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU
+ {44444444-4444-4444-4444-444444444444}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU
EndGlobalSection
EndGlobal
diff --git a/src/CortexEngine.App/CortexEngine.App.csproj b/src/CortexEngine.App/CortexEngine.App.csproj
index 50516b6..36e8553 100644
--- a/src/CortexEngine.App/CortexEngine.App.csproj
+++ b/src/CortexEngine.App/CortexEngine.App.csproj
@@ -22,6 +22,7 @@
+
diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs
index 7229682..7e2f233 100644
--- a/src/CortexEngine.App/Program.cs
+++ b/src/CortexEngine.App/Program.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Numerics;
+using Engine.AI;
using Engine.Core;
using Engine.Core.Components;
using Engine.Graphics;
@@ -13,23 +14,22 @@ class Program
{
static void Main(string[] args)
{
- Console.WriteLine("Cortex Engine Step 4 — Starting up...");
+ Console.WriteLine("Cortex Engine Step 5 — Starting up...");
try
{
using var world = World.Create();
- using var window = new Sdl3Window("Cortex Engine — Step 4", 1280, 720);
+ using var window = new Sdl3Window("Cortex Engine — Step 5", 1280, 720);
var timing = new Timing();
var input = new InputMapping();
using var vulkan = new VulkanContext(window, enableValidation: false);
using var swapchain = new Swapchain(vulkan);
using var renderer = new MeshRenderer(vulkan, swapchain);
+ var ai = new AiCommandProcessor(world, LoadModel);
+
var modelPath = FindModelPath(args);
- var mesh = modelPath.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase)
- || modelPath.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)
- ? GltfLoader.Load(modelPath, new Vector3(0.7f, 0.6f, 0.5f))
- : ObjLoader.Load(modelPath, new Vector3(0.7f, 0.6f, 0.5f));
+ var mesh = LoadModel(modelPath);
var model = world.Entity("Model")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, new Vector3(0.5f)))
@@ -45,6 +45,13 @@ class Program
0.1f,
100.0f));
+ // Demo: AI agent commands.
+ Console.WriteLine("AI demo commands:");
+ Console.WriteLine(ai.Process("""{ "type": "list_entities" }""").Message);
+ Console.WriteLine(ai.Process("""{ "type": "spawn_model", "name": "SecondCube", "modelPath": "Content/cube.obj", "position": [0.8, 0, 0], "scale": [0.3, 0.3, 0.3] }""").Message);
+ Console.WriteLine(ai.Process("""{ "type": "list_entities" }""").Message);
+ Console.WriteLine(ai.Process("""{ "type": "set_transform", "name": "SecondCube", "position": [0.8, 0.5, 0], "rotation": [0, 0, 0, 1], "scale": [0.3, 0.3, 0.3] }""").Message);
+
var frames = 0;
var lastFpsTime = 0.0;
var lastWidth = window.Width;
@@ -90,6 +97,14 @@ class Program
}
}
+ private static Mesh LoadModel(string path)
+ {
+ return path.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase)
+ || path.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)
+ ? GltfLoader.Load(path, new Vector3(0.7f, 0.6f, 0.5f))
+ : ObjLoader.Load(path, new Vector3(0.7f, 0.6f, 0.5f));
+ }
+
private static string FindModelPath(string[] args)
{
if (args.Length > 0 && File.Exists(args[0]))
diff --git a/src/Engine.AI/AiCommandProcessor.cs b/src/Engine.AI/AiCommandProcessor.cs
new file mode 100644
index 0000000..333b2e0
--- /dev/null
+++ b/src/Engine.AI/AiCommandProcessor.cs
@@ -0,0 +1,124 @@
+using System.Numerics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Engine.AI.Commands;
+using Engine.AI.Serialization;
+using Engine.Core;
+using Engine.Core.Components;
+using Flecs.NET.Core;
+
+namespace Engine.AI;
+
+///
+/// Parses and executes JSON commands from an AI agent.
+///
+public sealed class AiCommandProcessor
+{
+ private readonly World _world;
+ private readonly Func _modelLoader;
+ private readonly JsonSerializerOptions _jsonOptions;
+
+ public AiCommandProcessor(World world, Func modelLoader)
+ {
+ _world = world;
+ _modelLoader = modelLoader;
+ _jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters =
+ {
+ new JsonStringEnumConverter(),
+ new Vector3JsonConverter(),
+ new QuaternionJsonConverter()
+ }
+ };
+ }
+
+ ///
+ /// Process a single JSON command string.
+ ///
+ public AiCommandResult Process(string json)
+ {
+ try
+ {
+ var command = JsonSerializer.Deserialize(json, _jsonOptions);
+ if (command == null)
+ return AiCommandResult.Error("Failed to parse command.");
+
+ return command switch
+ {
+ SpawnModelCommand c => SpawnModel(c),
+ SetTransformCommand c => SetTransform(c),
+ DeleteEntityCommand c => DeleteEntity(c),
+ ListEntitiesCommand => ListEntities(),
+ _ => AiCommandResult.Error($"Unknown command type: {command.Type}")
+ };
+ }
+ catch (Exception ex)
+ {
+ return AiCommandResult.Error($"Command execution failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Process a batch of JSON commands separated by newlines.
+ ///
+ public AiCommandResult[] ProcessBatch(string jsonBatch)
+ {
+ var lines = jsonBatch.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
+ return lines.Select(Process).ToArray();
+ }
+
+ private AiCommandResult SpawnModel(SpawnModelCommand command)
+ {
+ var mesh = _modelLoader(command.ModelPath);
+ var entity = _world.Entity(command.Name)
+ .Set(new Transform(command.Position, command.Rotation, command.Scale))
+ .Set(mesh);
+
+ return AiCommandResult.Ok($"Spawned entity '{command.Name}' with model '{command.ModelPath}' (id {(ulong)entity.Id}).");
+ }
+
+ private AiCommandResult SetTransform(SetTransformCommand command)
+ {
+ var entity = _world.Lookup(command.Name);
+ if ((ulong)entity.Id == 0)
+ return AiCommandResult.Error($"Entity '{command.Name}' not found.");
+
+ ref var transform = ref entity.Ensure();
+
+ if (command.Position.HasValue)
+ transform.Position = command.Position.Value;
+ if (command.Rotation.HasValue)
+ transform.Rotation = command.Rotation.Value;
+ if (command.Scale.HasValue)
+ transform.Scale = command.Scale.Value;
+
+ return AiCommandResult.Ok($"Updated transform for entity '{command.Name}'.");
+ }
+
+ private AiCommandResult DeleteEntity(DeleteEntityCommand command)
+ {
+ var entity = _world.Lookup(command.Name);
+ if ((ulong)entity.Id == 0)
+ return AiCommandResult.Error($"Entity '{command.Name}' not found.");
+
+ entity.Destruct();
+
+ return AiCommandResult.Ok($"Deleted entity '{command.Name}'.");
+ }
+
+ private AiCommandResult ListEntities()
+ {
+ var names = new List();
+ _world.Each((Entity e, ref Transform t) =>
+ {
+ var name = e.Name();
+ if (!string.IsNullOrEmpty(name))
+ names.Add(name);
+ });
+
+ var json = JsonSerializer.Serialize(names, _jsonOptions);
+ return AiCommandResult.Ok(json);
+ }
+}
diff --git a/src/Engine.AI/AiCommandResult.cs b/src/Engine.AI/AiCommandResult.cs
new file mode 100644
index 0000000..cf225bb
--- /dev/null
+++ b/src/Engine.AI/AiCommandResult.cs
@@ -0,0 +1,10 @@
+namespace Engine.AI;
+
+///
+/// Result returned by the AI command processor.
+///
+public sealed record AiCommandResult(bool Success, string Message)
+{
+ public static AiCommandResult Ok(string message) => new(true, message);
+ public static AiCommandResult Error(string message) => new(false, message);
+}
diff --git a/src/Engine.AI/Commands/AiCommand.cs b/src/Engine.AI/Commands/AiCommand.cs
new file mode 100644
index 0000000..ecd0778
--- /dev/null
+++ b/src/Engine.AI/Commands/AiCommand.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+
+namespace Engine.AI.Commands;
+
+///
+/// Base class for AI commands sent to the engine as JSON.
+/// The discriminator is the type property.
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
+[JsonDerivedType(typeof(SpawnModelCommand), "spawn_model")]
+[JsonDerivedType(typeof(SetTransformCommand), "set_transform")]
+[JsonDerivedType(typeof(DeleteEntityCommand), "delete_entity")]
+[JsonDerivedType(typeof(ListEntitiesCommand), "list_entities")]
+public abstract record AiCommand
+{
+ public string Type => GetType().Name.Replace("Command", "").ToLowerInvariant();
+}
diff --git a/src/Engine.AI/Commands/DeleteEntityCommand.cs b/src/Engine.AI/Commands/DeleteEntityCommand.cs
new file mode 100644
index 0000000..9c1f4d4
--- /dev/null
+++ b/src/Engine.AI/Commands/DeleteEntityCommand.cs
@@ -0,0 +1,9 @@
+namespace Engine.AI.Commands;
+
+///
+/// Delete an entity by name.
+///
+public sealed record DeleteEntityCommand : AiCommand
+{
+ public required string Name { get; init; }
+}
diff --git a/src/Engine.AI/Commands/ListEntitiesCommand.cs b/src/Engine.AI/Commands/ListEntitiesCommand.cs
new file mode 100644
index 0000000..9e55677
--- /dev/null
+++ b/src/Engine.AI/Commands/ListEntitiesCommand.cs
@@ -0,0 +1,6 @@
+namespace Engine.AI.Commands;
+
+///
+/// List all named entities in the ECS world.
+///
+public sealed record ListEntitiesCommand : AiCommand;
diff --git a/src/Engine.AI/Commands/SetTransformCommand.cs b/src/Engine.AI/Commands/SetTransformCommand.cs
new file mode 100644
index 0000000..7cb30e8
--- /dev/null
+++ b/src/Engine.AI/Commands/SetTransformCommand.cs
@@ -0,0 +1,14 @@
+using System.Numerics;
+
+namespace Engine.AI.Commands;
+
+///
+/// Update the Transform component of an existing entity by name.
+///
+public sealed record SetTransformCommand : AiCommand
+{
+ public required string Name { get; init; }
+ public Vector3? Position { get; init; }
+ public Quaternion? Rotation { get; init; }
+ public Vector3? Scale { get; init; }
+}
diff --git a/src/Engine.AI/Commands/SpawnModelCommand.cs b/src/Engine.AI/Commands/SpawnModelCommand.cs
new file mode 100644
index 0000000..310d217
--- /dev/null
+++ b/src/Engine.AI/Commands/SpawnModelCommand.cs
@@ -0,0 +1,15 @@
+using System.Numerics;
+
+namespace Engine.AI.Commands;
+
+///
+/// Spawn a named entity with a Mesh loaded from a model file and a Transform.
+///
+public sealed record SpawnModelCommand : AiCommand
+{
+ public required string Name { get; init; }
+ public required string ModelPath { get; init; }
+ public Vector3 Position { get; init; } = Vector3.Zero;
+ public Quaternion Rotation { get; init; } = Quaternion.Identity;
+ public Vector3 Scale { get; init; } = Vector3.One;
+}
diff --git a/src/Engine.AI/Engine.AI.csproj b/src/Engine.AI/Engine.AI.csproj
new file mode 100644
index 0000000..298a0d7
--- /dev/null
+++ b/src/Engine.AI/Engine.AI.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
diff --git a/src/Engine.AI/Serialization/QuaternionJsonConverter.cs b/src/Engine.AI/Serialization/QuaternionJsonConverter.cs
new file mode 100644
index 0000000..66b449e
--- /dev/null
+++ b/src/Engine.AI/Serialization/QuaternionJsonConverter.cs
@@ -0,0 +1,39 @@
+using System.Numerics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Engine.AI.Serialization;
+
+public sealed class QuaternionJsonConverter : JsonConverter
+{
+ public override Quaternion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartArray)
+ throw new JsonException("Expected array for Quaternion.");
+
+ reader.Read();
+ var x = reader.GetSingle();
+ reader.Read();
+ var y = reader.GetSingle();
+ reader.Read();
+ var z = reader.GetSingle();
+ reader.Read();
+ var w = reader.GetSingle();
+ reader.Read();
+
+ if (reader.TokenType != JsonTokenType.EndArray)
+ throw new JsonException("Expected 4 elements for Quaternion.");
+
+ return new Quaternion(x, y, z, w);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Quaternion value, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+ writer.WriteNumberValue(value.X);
+ writer.WriteNumberValue(value.Y);
+ writer.WriteNumberValue(value.Z);
+ writer.WriteNumberValue(value.W);
+ writer.WriteEndArray();
+ }
+}
diff --git a/src/Engine.AI/Serialization/Vector3JsonConverter.cs b/src/Engine.AI/Serialization/Vector3JsonConverter.cs
new file mode 100644
index 0000000..de44a50
--- /dev/null
+++ b/src/Engine.AI/Serialization/Vector3JsonConverter.cs
@@ -0,0 +1,36 @@
+using System.Numerics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Engine.AI.Serialization;
+
+public sealed class Vector3JsonConverter : JsonConverter
+{
+ public override Vector3 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartArray)
+ throw new JsonException("Expected array for Vector3.");
+
+ reader.Read();
+ var x = reader.GetSingle();
+ reader.Read();
+ var y = reader.GetSingle();
+ reader.Read();
+ var z = reader.GetSingle();
+ reader.Read();
+
+ if (reader.TokenType != JsonTokenType.EndArray)
+ throw new JsonException("Expected 3 elements for Vector3.");
+
+ return new Vector3(x, y, z);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+ writer.WriteNumberValue(value.X);
+ writer.WriteNumberValue(value.Y);
+ writer.WriteNumberValue(value.Z);
+ writer.WriteEndArray();
+ }
+}