From 89924fded6c46ad7ec0c1d982e79427ca1077e89 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Sun, 19 Jul 2026 14:20:38 +0300 Subject: [PATCH] feat: add playable Arena Strike FPS scene --- README.md | 4 + src/CortexEngine.App/FpsShooterGame.cs | 366 +++++++++++++++++++++ src/CortexEngine.App/Program.cs | 42 ++- src/Engine.Core/FreeFlyCameraController.cs | 7 +- src/Engine.Physics/PhysicsWorld.cs | 8 + 5 files changed, 414 insertions(+), 13 deletions(-) create mode 100644 src/CortexEngine.App/FpsShooterGame.cs diff --git a/README.md b/README.md index 064a3a6..f94eeae 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ dotnet build CORTEX_ENGINE.sln -c Debug # Run the alternative Solar Sanctuary demo scene ./scripts/run.sh -- --scene sanctuary +# Run the playable Arena Strike FPS demo +./scripts/run.sh -- --scene shooter + # Run with AI/MCP server ./scripts/run.sh -- --mcp-port 5000 @@ -53,6 +56,7 @@ dotnet test tests/Engine.Tests/Engine.Tests.csproj -c Debug - **Kinetic Gallery** (default) — sphere pyramid, diamond rain, orbiting torus rings and physics. - **Solar Sanctuary** (`--scene sanctuary`) — a luminous torus-knot core, three orbital rings, an obelisk circle and falling physical comets. +- **Arena Strike** (`--scene shooter`) — a playable FPS arena with hitscan targets, attacking drones, health, score, waves and reloadable ammunition. Use **LMB** to fire and **R** to reload. ## MCP Tools diff --git a/src/CortexEngine.App/FpsShooterGame.cs b/src/CortexEngine.App/FpsShooterGame.cs new file mode 100644 index 0000000..138586d --- /dev/null +++ b/src/CortexEngine.App/FpsShooterGame.cs @@ -0,0 +1,366 @@ +using System.Numerics; +using Engine.Core; +using Engine.Core.Components; +using Flecs.NET.Core; +using ImGuiNET; + +namespace CortexEngine.App; + +/// +/// Small, self-contained FPS game loop for the Arena Strike demo. It deliberately uses +/// deterministic hitscan against ECS targets instead of coupling gameplay to a renderer. +/// +internal sealed class FpsShooterGame +{ + private const int MagazineSize = 12; + private readonly World _world; + private readonly Func _loadMesh; + private readonly List _targets = []; + private readonly List _blockers = []; + private Mesh _targetMesh; + private Mesh _droneMesh; + private float _lastShotAt = -10f; + private float _reloadEndsAt; + private float _nextWaveAt; + private bool _wasFiring; + private string _eventText = "Clear the arena."; + private float _eventEndsAt = 3f; + private Vector3 _lastSafePlayerPosition = new(0, 2.3f, -17f); + + public int Score { get; private set; } + public int Kills { get; private set; } + public int Health { get; private set; } = 100; + public int Ammo { get; private set; } = MagazineSize; + public int ReserveAmmo { get; private set; } = 84; + public int Wave { get; private set; } = 1; + + public FpsShooterGame(World world, Func loadMesh) + { + _world = world; + _loadMesh = loadMesh; + } + + public void CreateArena() + { + _targets.Clear(); + _blockers.Clear(); + Score = 0; + Kills = 0; + Health = 100; + Ammo = MagazineSize; + ReserveAmmo = 84; + Wave = 1; + _reloadEndsAt = 0; + _nextWaveAt = 0; + _eventText = "WAVE 1 — eliminate the drones"; + _eventEndsAt = 4f; + + var wallMesh = _loadMesh("Content/cube.obj", new Vector3(0.06f, 0.09f, 0.16f)); + CreateStaticBlock("ArenaBackWall", new Vector3(0, 3.5f, 15), new Vector3(18, 4, 0.7f), wallMesh); + CreateStaticBlock("ArenaLeftWall", new Vector3(-18, 3.5f, 0), new Vector3(0.7f, 4, 15), wallMesh); + CreateStaticBlock("ArenaRightWall", new Vector3(18, 3.5f, 0), new Vector3(0.7f, 4, 15), wallMesh); + + var coverMesh = _loadMesh("Content/cube.obj", new Vector3(0.13f, 0.27f, 0.38f)); + var coverPositions = new[] + { + new Vector3(-8, 1.5f, -2), new Vector3(8, 1.5f, -1), + new Vector3(-5, 1.5f, 7), new Vector3(6, 1.5f, 8), + }; + for (var i = 0; i < coverPositions.Length; i++) + CreateStaticBlock($"ArenaCover_{i}", coverPositions[i], new Vector3(2.4f, 2.8f, 1.1f), coverMesh); + + var pillarMesh = _loadMesh("Content/cone.obj", new Vector3(0.8f, 0.16f, 0.04f)); + for (var i = 0; i < 6; i++) + { + var x = -13f + i * 5.2f; + _world.Entity($"ArenaPillar_{i}") + .Set(new Transform(new Vector3(x, 2.5f, 12.5f), Quaternion.Identity, new Vector3(1.3f, 3.5f, 1.3f))) + .Set(pillarMesh); + } + + _targetMesh = _loadMesh("Content/diamond.obj", new Vector3(1.0f, 0.08f, 0.16f)); + _droneMesh = _loadMesh("Content/sphere.obj", new Vector3(0.12f, 0.75f, 1.0f)); + SpawnWave(0f); + } + + public void Update(IInputState input, Entity cameraEntity, float totalTime, float deltaTime, bool allowGameplayInput) + { + if (totalTime >= _eventEndsAt) + _eventText = string.Empty; + if (_reloadEndsAt > 0 && totalTime >= _reloadEndsAt) + { + var loaded = Math.Min(MagazineSize - Ammo, ReserveAmmo); + Ammo += loaded; + ReserveAmmo -= loaded; + _reloadEndsAt = 0; + SetEvent(loaded > 0 ? "Reload complete" : "No reserve ammo", totalTime, 1.4f); + } + + if (allowGameplayInput && _reloadEndsAt <= 0 && input.IsKeyPressed(Key.R) && Ammo < MagazineSize && ReserveAmmo > 0) + { + _reloadEndsAt = totalTime + 1.05f; + SetEvent("Reloading…", totalTime, 1.1f); + } + + var firingEdge = input.MouseLeft && !_wasFiring; + _wasFiring = input.MouseLeft; + if (allowGameplayInput && firingEdge && _reloadEndsAt <= 0 && totalTime - _lastShotAt >= 0.12f) + Fire(cameraEntity.Get(), totalTime); + + var cameraPosition = cameraEntity.Get().Position; + for (var i = _targets.Count - 1; i >= 0; i--) + { + var target = _targets[i]; + if (!target.Entity.IsAlive()) + { + _targets.RemoveAt(i); + continue; + } + + var transform = target.Entity.Get(); + var orbit = new Vector3( + MathF.Cos(totalTime * target.Speed + target.Phase) * target.MotionRadius, + MathF.Sin(totalTime * (target.Speed * 1.7f) + target.Phase) * 0.65f, + MathF.Sin(totalTime * target.Speed + target.Phase) * target.MotionRadius); + transform.Position = target.Origin + orbit; + transform.Rotation = Quaternion.CreateFromYawPitchRoll(totalTime * target.Speed, totalTime * 0.45f, 0); + target.Entity.Set(transform); + + if (target.IsDrone && Vector3.Distance(transform.Position, cameraPosition) < 5f && + HasLineOfSight(transform.Position, cameraPosition) && totalTime >= target.NextAttackAt) + { + target.NextAttackAt = totalTime + 1.25f; + Health = Math.Max(0, Health - 8); + SetEvent("Drone hit — find cover!", totalTime, 1.1f); + if (Health == 0) + { + Health = 100; + Score = Math.Max(0, Score - 300); + SetEvent("Armor restored — score penalty", totalTime, 2.4f); + } + } + } + + if (_targets.Count == 0 && _nextWaveAt <= 0) + { + _nextWaveAt = totalTime + 2.2f; + SetEvent("Arena clear — next wave incoming", totalTime, 2.2f); + } + if (_nextWaveAt > 0 && totalTime >= _nextWaveAt) + { + Wave++; + _nextWaveAt = 0; + SpawnWave(totalTime); + SetEvent($"WAVE {Wave}", totalTime, 2f); + } + } + + public void DrawHud(int width, int height) + { + var flags = ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings | + ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoFocusOnAppearing; + ImGui.SetNextWindowBgAlpha(0.62f); + ImGui.SetNextWindowPos(new Vector2(18, height - 126), ImGuiCond.Always); + ImGui.Begin("Arena Strike HUD", flags); + ImGui.TextColored(new Vector4(0.25f, 0.86f, 1f, 1f), $"ARENA STRIKE · WAVE {Wave}"); + ImGui.Text($"HEALTH {Health,3} SCORE {Score,5} KILLS {Kills}"); + ImGui.Text(_reloadEndsAt > 0 ? "RELOADING…" : $"AMMO {Ammo,2} / {ReserveAmmo,2}"); + if (!string.IsNullOrEmpty(_eventText)) + ImGui.TextColored(new Vector4(1f, 0.42f, 0.3f, 1f), _eventText); + ImGui.TextDisabled("LMB fire · R reload · WASD move · RMB look"); + ImGui.End(); + + ImGui.SetNextWindowBgAlpha(0f); + ImGui.SetNextWindowPos(new Vector2(width * 0.5f - 5f, height * 0.5f - 11f), ImGuiCond.Always); + ImGui.Begin("##ArenaCrosshair", ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.AlwaysAutoResize); + ImGui.TextColored(new Vector4(0.9f, 0.96f, 1f, 0.95f), "+"); + ImGui.End(); + } + + private void Fire(Camera camera, float totalTime) + { + _lastShotAt = totalTime; + if (Ammo <= 0) + { + SetEvent(ReserveAmmo > 0 ? "Magazine empty — press R" : "Out of ammo", totalTime, 1.2f); + return; + } + + Ammo--; + var origin = camera.Position; + var direction = Vector3.Normalize(camera.Target - camera.Position); + var blockerDistance = float.MaxValue; + foreach (var blocker in _blockers) + { + if (RayAabb(origin, direction, blocker.Center, blocker.HalfExtent, out var distance)) + blockerDistance = Math.Min(blockerDistance, distance); + } + Target? hit = null; + var nearest = float.MaxValue; + foreach (var target in _targets) + { + if (!target.Entity.IsAlive()) continue; + var position = target.Entity.Get().Position; + if (RaySphere(origin, direction, position, target.Radius, out var distance) && distance < nearest && distance < blockerDistance) + { + nearest = distance; + hit = target; + } + } + + if (hit is null) + { + SetEvent(blockerDistance < float.MaxValue ? "Shot blocked by cover" : "Miss", totalTime, 0.55f); + return; + } + + var killed = hit; + if (killed.Entity.IsAlive()) killed.Entity.Destruct(); + _targets.Remove(killed); + Kills++; + Score += killed.IsDrone ? 150 : 100; + SetEvent(killed.IsDrone ? "Drone destroyed +150" : "Target destroyed +100", totalTime, 0.75f); + } + + private void SpawnWave(float totalTime) + { + var positions = new[] + { + new Vector3(-11, 4.5f, 10), new Vector3(-6, 6.5f, 7), new Vector3(0, 5.3f, 11), + new Vector3(6, 6.8f, 8), new Vector3(11, 4.7f, 10), new Vector3(-10, 3.2f, 3), + new Vector3(10, 3.5f, 3), new Vector3(-3, 7.3f, 12), new Vector3(4, 7.6f, 12), + }; + for (var i = 0; i < positions.Length; i++) + SpawnTarget($"ArenaTarget_{Wave}_{i}", positions[i], isDrone: i % 3 == 0, i * 0.91f + Wave); + } + + private void SpawnTarget(string name, Vector3 origin, bool isDrone, float phase) + { + var radius = isDrone ? 0.76f : 0.62f; + var mesh = isDrone ? _droneMesh : _targetMesh; + var entity = _world.Entity(name) + .Set(new Transform(origin, Quaternion.Identity, new Vector3(isDrone ? 1.25f : 0.9f))) + .Set(mesh); + _targets.Add(new Target(entity, origin, radius, isDrone, phase, isDrone ? 0.82f : 0.56f, isDrone ? 1.4f : 0.75f)); + } + + private void CreateStaticBlock(string name, Vector3 position, Vector3 scale, Mesh mesh) + { + _world.Entity(name) + .Set(new Transform(position, Quaternion.Identity, scale)) + .Set(mesh) + .Set(RigidBody.StaticBox(scale * 0.5f)); + _blockers.Add(new Blocker(position, scale * 0.5f)); + } + + public Vector3 ConstrainPlayerPosition(Vector3 desiredPosition) + { + const float playerRadius = 0.42f; + var constrained = desiredPosition; + constrained.X = Math.Clamp(constrained.X, -16.5f, 16.5f); + constrained.Z = Math.Clamp(constrained.Z, -17.5f, 14f); + + foreach (var blocker in _blockers) + { + if (constrained.Y > blocker.Center.Y + blocker.HalfExtent.Y + 0.25f) + continue; + if (MathF.Abs(constrained.X - blocker.Center.X) < blocker.HalfExtent.X + playerRadius && + MathF.Abs(constrained.Z - blocker.Center.Z) < blocker.HalfExtent.Z + playerRadius) + return _lastSafePlayerPosition; + } + + _lastSafePlayerPosition = constrained; + return constrained; + } + + private void SetEvent(string text, float totalTime, float duration) + { + _eventText = text; + _eventEndsAt = totalTime + duration; + } + + private static bool RaySphere(Vector3 origin, Vector3 direction, Vector3 center, float radius, out float distance) + { + var toCenter = center - origin; + var projection = Vector3.Dot(toCenter, direction); + var closestSquared = toCenter.LengthSquared() - projection * projection; + var radiusSquared = radius * radius; + if (projection < 0 || closestSquared > radiusSquared) + { + distance = 0; + return false; + } + distance = projection - MathF.Sqrt(radiusSquared - closestSquared); + return distance >= 0; + } + + private static bool RayAabb(Vector3 origin, Vector3 direction, Vector3 center, Vector3 halfExtent, out float distance) + { + var minimum = center - halfExtent; + var maximum = center + halfExtent; + var enter = 0f; + var exit = float.MaxValue; + for (var axis = 0; axis < 3; axis++) + { + var originAxis = axis == 0 ? origin.X : axis == 1 ? origin.Y : origin.Z; + var directionAxis = axis == 0 ? direction.X : axis == 1 ? direction.Y : direction.Z; + var minAxis = axis == 0 ? minimum.X : axis == 1 ? minimum.Y : minimum.Z; + var maxAxis = axis == 0 ? maximum.X : axis == 1 ? maximum.Y : maximum.Z; + if (MathF.Abs(directionAxis) < 0.00001f) + { + if (originAxis < minAxis || originAxis > maxAxis) { distance = 0; return false; } + continue; + } + var inverse = 1f / directionAxis; + var first = (minAxis - originAxis) * inverse; + var second = (maxAxis - originAxis) * inverse; + if (first > second) (first, second) = (second, first); + enter = Math.Max(enter, first); + exit = Math.Min(exit, second); + if (enter > exit) { distance = 0; return false; } + } + distance = enter; + return exit >= 0; + } + + private bool HasLineOfSight(Vector3 source, Vector3 destination) + { + var offset = destination - source; + var playerDistance = offset.Length(); + if (playerDistance < 0.001f) return true; + var direction = offset / playerDistance; + foreach (var blocker in _blockers) + { + if (RayAabb(source, direction, blocker.Center, blocker.HalfExtent, out var blockerDistance) && + blockerDistance < playerDistance - 0.05f) + return false; + } + return true; + } + + private sealed class Target + { + public Entity Entity { get; } + public Vector3 Origin { get; } + public float Radius { get; } + public bool IsDrone { get; } + public float Phase { get; } + public float Speed { get; } + public float MotionRadius { get; } + public float NextAttackAt { get; set; } + + public Target(Entity entity, Vector3 origin, float radius, bool isDrone, float phase, float speed, float motionRadius) + { + Entity = entity; + Origin = origin; + Radius = radius; + IsDrone = isDrone; + Phase = phase; + Speed = speed; + MotionRadius = motionRadius; + } + } + + private readonly record struct Blocker(Vector3 Center, Vector3 HalfExtent); +} diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index 3bad4fb..3da4ae7 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -22,6 +22,7 @@ class Program { KineticGallery, SolarSanctuary, + ArenaStrike, } static async Task Main(string[] args) @@ -61,9 +62,14 @@ class Program 0.1f, 100f)); - CreateScene(world, scene); + FpsShooterGame? shooter = scene == DemoScene.ArenaStrike + ? new FpsShooterGame(world, LoadMesh) + : null; + Func? playerPositionConstraint = shooter is null ? null : shooter.ConstrainPlayerPosition; + + CreateScene(world, scene, shooter); ConfigureCamera(cameraEntity, scene); - var cameraController = new FreeFlyCameraController(cameraEntity); + var cameraController = new FreeFlyCameraController(cameraEntity, playerPositionConstraint); Console.WriteLine("Camera: FreeFly (WASD + right-click mouse look, Q/E up/down, Shift boost)"); Console.WriteLine($"[Scene] Active demo: {GetSceneName(scene)}"); @@ -98,9 +104,10 @@ class Program timing.Tick(); renderTimer += timing.DeltaTime; - // Physics + input run at full speed (uncapped) - window.PumpEvents(); input.BeginFrame(); + // Physics + input run at full speed (uncapped). Edge input must be cleared + // before SDL events are pumped so IsKeyPressed works for gameplay controls. + window.PumpEvents(); #if !RELEASE_AOT frameCount++; @@ -140,6 +147,8 @@ class Program cameraController.Update(input, (float)timing.DeltaTime); totalTime += (float)timing.DeltaTime; + var allowGameplayInput = !hasImGui || (!ImGui.GetIO().WantCaptureMouse && !ImGui.GetIO().WantCaptureKeyboard); + shooter?.Update(input, cameraEntity, totalTime, (float)timing.DeltaTime, allowGameplayInput); var toInit = new List<(Entity, RigidBody, Transform)>(); world.Each((Entity e, ref RigidBody rb, ref Transform t) => @@ -263,14 +272,16 @@ class Program } } - CreateSceneObjects(world, scene); + CreateSceneObjects(world, scene, shooter); ConfigureCamera(cameraEntity, scene); - cameraController = new FreeFlyCameraController(cameraEntity); + cameraController = new FreeFlyCameraController(cameraEntity, playerPositionConstraint); physicsEnabled = true; Console.WriteLine($"[App] Scene reset: {toDelete.Count} entities deleted"); } ImGui.End(); + shooter?.DrawHud(window.Width, window.Height); + // Shadow parameters panel ImGui.Begin("Shadow & Light Parameters"); @@ -529,8 +540,9 @@ class Program { "gallery" or "kinetic-gallery" => DemoScene.KineticGallery, "sanctuary" or "solar-sanctuary" => DemoScene.SolarSanctuary, + "shooter" or "arena" or "arena-strike" => DemoScene.ArenaStrike, var name => throw new ArgumentException( - $"Unknown scene '{name}'. Use 'gallery' or 'sanctuary'.") + $"Unknown scene '{name}'. Use 'gallery', 'sanctuary', or 'shooter'.") }; } @@ -541,6 +553,7 @@ class Program { DemoScene.KineticGallery => "Kinetic Gallery", DemoScene.SolarSanctuary => "Solar Sanctuary", + DemoScene.ArenaStrike => "Arena Strike", _ => scene.ToString(), }; @@ -552,6 +565,11 @@ class Program camera.Position = new Vector3(18, 12, -22); camera.Target = new Vector3(0, 5, 0); } + else if (scene == DemoScene.ArenaStrike) + { + camera.Position = new Vector3(0, 2.3f, -17f); + camera.Target = new Vector3(0, 2.3f, -5f); + } else { camera.Position = new Vector3(0, 5, -15); @@ -560,7 +578,7 @@ class Program cameraEntity.Set(camera); } - static void CreateScene(World world, DemoScene scene) + static void CreateScene(World world, DemoScene scene, FpsShooterGame? shooter) { // Floor world.Entity("Floor") @@ -568,7 +586,7 @@ class Program .Set(LoadMesh("Content/cube.obj", new Vector3(0.3f, 0.3f, 0.35f))) .Set(RigidBody.StaticBox(new Vector3(20, 0.5f, 20))); - CreateSceneObjects(world, scene); + CreateSceneObjects(world, scene, shooter); // Lights (3 dynamic point lights, all cast shadows) world.Entity("MainLight") @@ -588,9 +606,11 @@ class Program Console.WriteLine($"[Scene] {entityCount} entities"); } - static void CreateSceneObjects(World world, DemoScene scene) + static void CreateSceneObjects(World world, DemoScene scene, FpsShooterGame? shooter = null) { - if (scene == DemoScene.SolarSanctuary) + if (scene == DemoScene.ArenaStrike) + (shooter ?? throw new InvalidOperationException("Shooter scene requires its game state.")).CreateArena(); + else if (scene == DemoScene.SolarSanctuary) CreateSolarSanctuary(world); else CreateKineticGallery(world); diff --git a/src/Engine.Core/FreeFlyCameraController.cs b/src/Engine.Core/FreeFlyCameraController.cs index aa74897..969f5d5 100644 --- a/src/Engine.Core/FreeFlyCameraController.cs +++ b/src/Engine.Core/FreeFlyCameraController.cs @@ -22,12 +22,14 @@ public sealed class FreeFlyCameraController : ICameraController private int _lastMouseY; private bool _wasRightMouseDown; private Vector3 _position; + private readonly Func? _positionConstraint; public string Name => "FreeFly"; - public FreeFlyCameraController(Entity cameraEntity) + public FreeFlyCameraController(Entity cameraEntity, Func? positionConstraint = null) { _cameraEntity = cameraEntity; + _positionConstraint = positionConstraint; var camera = cameraEntity.Get(); _position = camera.Position; @@ -60,7 +62,8 @@ public sealed class FreeFlyCameraController : ICameraController { move = Vector3.Normalize(move); var speed = input.IsKeyDown(Key.LeftShift) ? _fastSpeed : _speed; - _position += move * speed * deltaTime; + var desiredPosition = _position + move * speed * deltaTime; + _position = _positionConstraint?.Invoke(desiredPosition) ?? desiredPosition; } if (input.MouseWheelDelta != 0) diff --git a/src/Engine.Physics/PhysicsWorld.cs b/src/Engine.Physics/PhysicsWorld.cs index c39c617..8b5e40d 100644 --- a/src/Engine.Physics/PhysicsWorld.cs +++ b/src/Engine.Physics/PhysicsWorld.cs @@ -28,6 +28,7 @@ public sealed class PhysicsWorld : IDisposable private readonly ObjectVsBroadPhaseLayerFilterTable _objectVsBroadPhaseLayerFilter; private readonly Dictionary _entityToBody = new(); private readonly Dictionary _bodyToEntity = new(); + private readonly Dictionary _bodyObjects = new(); private readonly List _bodies = new(); private bool _disposed; @@ -102,6 +103,7 @@ public sealed class PhysicsWorld : IDisposable _entityToBody[entity] = body.ID; _bodyToEntity[body.ID] = entity; + _bodyObjects[body.ID] = body; _bodies.Add(body); } @@ -114,6 +116,11 @@ public sealed class PhysicsWorld : IDisposable _bodyInterface.DestroyBody(bodyId); _entityToBody.Remove(entity); _bodyToEntity.Remove(bodyId); + if (_bodyObjects.Remove(bodyId, out var body)) + { + _bodies.Remove(body); + body.Dispose(); + } } public void Update(float deltaTime, int collisionSteps = 1) @@ -187,6 +194,7 @@ public sealed class PhysicsWorld : IDisposable foreach (var body in _bodies) body.Dispose(); _bodies.Clear(); + _bodyObjects.Clear(); _entityToBody.Clear(); _bodyToEntity.Clear();