diff --git a/.gitignore b/.gitignore index 394abaf..7699cab 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ imgui.ini ## Engine.Physics/native/) is what the engine actually ships. native/*/build/ native/*/.fetchcontent-cache/ + +## scripts/run-physics-demo.sh copies the game-specific plugin's built +## output directly into its own folder (that's where its own pluginPaths +## entry in project.json points) — regenerated, not source. plugin.json, +## the .csproj, and the .cs files there stay tracked. +samples/*/GamePlugins/*/*.dll +samples/*/GamePlugins/*/*.pdb diff --git a/LinguaEngine.sln b/LinguaEngine.sln index 914442e..1250894 100644 --- a/LinguaEngine.sln +++ b/LinguaEngine.sln @@ -73,6 +73,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio", "plugins\eng EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Tests", "tests\Engine.Audio.Tests\Engine.Audio.Tests.csproj", "{E610BD6A-A732-4863-8D19-555B75134E13}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PhysicsDemo", "PhysicsDemo", "{51874E45-60A1-E43B-395E-A3BE4050752F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GamePlugins", "GamePlugins", "{9760813E-A577-3C56-DE20-0218A108BABD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "physics-demo-game", "physics-demo-game", "{83FF5113-4091-0AE3-A9AD-50B7BB917258}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame", "samples\PhysicsDemo\GamePlugins\physics-demo-game\PhysicsDemoGame.csproj", "{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame.Tests", "tests\PhysicsDemoGame.Tests\PhysicsDemoGame.Tests.csproj", "{0201686F-0E2A-400F-8166-CBD11B65D724}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -371,6 +383,30 @@ Global {E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.Build.0 = Release|Any CPU {E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.ActiveCfg = Release|Any CPU {E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.Build.0 = Release|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.Build.0 = Debug|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.Build.0 = Debug|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.Build.0 = Release|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.ActiveCfg = Release|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.Build.0 = Release|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.ActiveCfg = Release|Any CPU + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.Build.0 = Release|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.ActiveCfg = Debug|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.Build.0 = Debug|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.ActiveCfg = Debug|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.Build.0 = Debug|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.Build.0 = Release|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.ActiveCfg = Release|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.Build.0 = Release|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.ActiveCfg = Release|Any CPU + {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -408,5 +444,10 @@ Global {2C2E545D-6937-476D-AC62-F96CEA6403F9} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE} {C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE} {E610BD6A-A732-4863-8D19-555B75134E13} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE} + {51874E45-60A1-E43B-395E-A3BE4050752F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {9760813E-A577-3C56-DE20-0218A108BABD} = {51874E45-60A1-E43B-395E-A3BE4050752F} + {83FF5113-4091-0AE3-A9AD-50B7BB917258} = {9760813E-A577-3C56-DE20-0218A108BABD} + {1AFDA567-D86A-43C8-91B9-34CC13F48FEF} = {83FF5113-4091-0AE3-A9AD-50B7BB917258} + {0201686F-0E2A-400F-8166-CBD11B65D724} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/plugins/engine.render/Engine.Render.Contracts/CubeRenderer.cs b/plugins/engine.render/Engine.Render.Contracts/CubeRenderer.cs new file mode 100644 index 0000000..a5ad304 --- /dev/null +++ b/plugins/engine.render/Engine.Render.Contracts/CubeRenderer.cs @@ -0,0 +1,12 @@ +using Engine.Kernel.World; + +namespace Engine.Render.Contracts; + +/// +/// A unit cube (1x1x1 before Transform.LocalScale), same no-size-fields +/// reasoning as QuadRenderer — Transform.LocalScale already means "how +/// big." Added for M4's physics demo: a BoxCollider falling and settling +/// only reads as a real physics object on screen if it actually looks like +/// a box, not a flat card. +/// +public sealed class CubeRenderer : Component; diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs index 4ae757f..769c58f 100644 --- a/plugins/engine.render/Engine.Render/RenderPlugin.cs +++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs @@ -12,11 +12,14 @@ namespace Engine.Render; /// /// M3's render pipeline: a real perspective camera, drawing every -/// GameObject with a QuadRenderer at its own WorldMatrix — upgraded from -/// M2's single hardcoded NDC-space quad specifically because gizmos need -/// real 3D geometry and a real camera to mean anything (a handle dragged -/// in screen space has to map onto an actual 3D axis). See M3 in -/// docs/kernel-contract.md §8. +/// GameObject with a QuadRenderer or CubeRenderer at its own WorldMatrix — +/// upgraded from M2's single hardcoded NDC-space quad specifically because +/// gizmos need real 3D geometry and a real camera to mean anything (a +/// handle dragged in screen space has to map onto an actual 3D axis). See +/// M3 in docs/kernel-contract.md §8. CubeRenderer itself is M4's addition, +/// for the same reason M3's gizmo needed a real camera: a BoxCollider +/// falling and settling only reads as a physics object if it looks like +/// one, not a flat card. /// /// GameObject.WorldMatrix and this plugin's own matrices are both /// System.Numerics.Matrix4x4, which is row-vector (v' = v * M, and @@ -95,11 +98,64 @@ public sealed class RenderPlugin : IPlugin 0.5f, 0.5f, 0f, 1f, 1f, ]; + // Unit cube (1x1x1 before LocalScale), centered at its own origin — see + // CubeRenderer's own doc comment for why this exists (M4's physics + // demo needs falling BoxColliders to actually look like boxes). No + // face culling is enabled anywhere in this plugin, so winding order + // doesn't matter here the way it would with CullFace on. + private static readonly float[] CubeVertices = + [ + // position uv + -0.5f, -0.5f, 0.5f, 0f, 0f, // front (+Z) + 0.5f, -0.5f, 0.5f, 1f, 0f, + 0.5f, 0.5f, 0.5f, 1f, 1f, + 0.5f, 0.5f, 0.5f, 1f, 1f, + -0.5f, 0.5f, 0.5f, 0f, 1f, + -0.5f, -0.5f, 0.5f, 0f, 0f, + + 0.5f, -0.5f, -0.5f, 0f, 0f, // back (-Z) + -0.5f, -0.5f, -0.5f, 1f, 0f, + -0.5f, 0.5f, -0.5f, 1f, 1f, + -0.5f, 0.5f, -0.5f, 1f, 1f, + 0.5f, 0.5f, -0.5f, 0f, 1f, + 0.5f, -0.5f, -0.5f, 0f, 0f, + + -0.5f, -0.5f, -0.5f, 0f, 0f, // left (-X) + -0.5f, -0.5f, 0.5f, 1f, 0f, + -0.5f, 0.5f, 0.5f, 1f, 1f, + -0.5f, 0.5f, 0.5f, 1f, 1f, + -0.5f, 0.5f, -0.5f, 0f, 1f, + -0.5f, -0.5f, -0.5f, 0f, 0f, + + 0.5f, -0.5f, 0.5f, 0f, 0f, // right (+X) + 0.5f, -0.5f, -0.5f, 1f, 0f, + 0.5f, 0.5f, -0.5f, 1f, 1f, + 0.5f, 0.5f, -0.5f, 1f, 1f, + 0.5f, 0.5f, 0.5f, 0f, 1f, + 0.5f, -0.5f, 0.5f, 0f, 0f, + + -0.5f, 0.5f, 0.5f, 0f, 0f, // top (+Y) + 0.5f, 0.5f, 0.5f, 1f, 0f, + 0.5f, 0.5f, -0.5f, 1f, 1f, + 0.5f, 0.5f, -0.5f, 1f, 1f, + -0.5f, 0.5f, -0.5f, 0f, 1f, + -0.5f, 0.5f, 0.5f, 0f, 0f, + + -0.5f, -0.5f, -0.5f, 0f, 0f, // bottom (-Y) + 0.5f, -0.5f, -0.5f, 1f, 0f, + 0.5f, -0.5f, 0.5f, 1f, 1f, + 0.5f, -0.5f, 0.5f, 1f, 1f, + -0.5f, -0.5f, 0.5f, 0f, 1f, + -0.5f, -0.5f, -0.5f, 0f, 0f, + ]; + private GL? _gl; private IEngineWindow? _window; private CameraService? _camera; private uint _vao; private uint _vbo; + private uint _cubeVao; + private uint _cubeVbo; private uint _program; private uint _texture; private int _modelLocation; @@ -143,6 +199,20 @@ public sealed class RenderPlugin : IPlugin _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); _gl.EnableVertexAttribArray(1); + _gl.BindVertexArray(0); + + _cubeVao = _gl.GenVertexArray(); + _gl.BindVertexArray(_cubeVao); + + _cubeVbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _cubeVbo); + _gl.BufferData(BufferTargetARB.ArrayBuffer, CubeVertices, BufferUsageARB.StaticDraw); + + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(1); + _gl.BindVertexArray(0); _gl.Enable(EnableCap.DepthTest); @@ -159,7 +229,7 @@ public sealed class RenderPlugin : IPlugin ctx.Events.Subscribe(_onTextureReloaded); ctx.Services.Provide(new GlScreenCapture(_gl, _window)); - ctx.Schedule.Add(Stage.Render, Draw).Reads(); + ctx.Schedule.Add(Stage.Render, Draw).Reads().Reads(); ctx.Schedule.Add(Stage.Present, Present); ctx.Log.Info("GL context created, 3D quad pipeline ready"); } @@ -176,6 +246,8 @@ public sealed class RenderPlugin : IPlugin _gl.DeleteTexture(_texture); _gl.DeleteVertexArray(_vao); _gl.DeleteBuffer(_vbo); + _gl.DeleteVertexArray(_cubeVao); + _gl.DeleteBuffer(_cubeVbo); _gl.DeleteProgram(_program); _gl.Dispose(); } @@ -237,6 +309,13 @@ public sealed class RenderPlugin : IPlugin SetMatrix(_modelLocation, go.WorldMatrix); _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); } + + _gl.BindVertexArray(_cubeVao); + foreach (var go in world.Query()) + { + SetMatrix(_modelLocation, go.WorldMatrix); + _gl.DrawArrays(PrimitiveType.Triangles, 0, 36); + } } // Split from Draw() into its own Stage.Present system so engine.editor diff --git a/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj new file mode 100644 index 0000000..fef7ffc --- /dev/null +++ b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj @@ -0,0 +1,16 @@ + + + + PhysicsDemoGame + PhysicsDemoGame + + + + + + + + + + + diff --git a/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGamePlugin.cs b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGamePlugin.cs new file mode 100644 index 0000000..a8a139c --- /dev/null +++ b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGamePlugin.cs @@ -0,0 +1,119 @@ +using System.Numerics; +using Engine.Audio.Contracts; +using Engine.Input.Contracts; +using Engine.Kernel.Plugins; +using Engine.Kernel.Scheduling; +using Engine.Kernel.World; +using Engine.Physics.Contracts; +using Engine.Render.Contracts; +using Silk.NET.Input; + +namespace PhysicsDemoGame; + +/// +/// M4's "one small game, end to end": pressing Space drops a new box from +/// above the ground; engine.physics simulates it falling and settling; +/// engine.render draws it as a real cube (CubeRenderer, added specifically +/// for this); this plugin plays a bounce sound (engine.audio) the moment +/// each box's fall actually stops. Physics, audio, input, and rendering +/// all cooperate here through nothing but the kernel's own vocabulary — +/// this plugin never references engine.physics/audio/render's +/// implementation assemblies, only their Contracts. +/// +/// "Landed" isn't a Box3D contact event — the native shim doesn't expose +/// one (see native/physics-native/lingua_physics.c's own doc comment on +/// why nothing but scalars crosses that boundary; a contact callback would +/// need to carry structured per-contact data across it, exactly what's +/// kept out). Honest scope for a demo this size: watch each spawned box's +/// own Y velocity via IPhysicsService every frame, and call it landed the +/// first time a real fall (velocity past FallingThreshold at some point) +/// settles back near zero. +/// +public sealed class PhysicsDemoGamePlugin : IPlugin +{ + private const float SpawnHeight = 6f; + private const float FallingThreshold = -1f; + private const float RestThreshold = 0.3f; + + private readonly Random _random = new(); + private readonly List _spawned = []; + private readonly HashSet _hasFallen = []; + private readonly HashSet _landed = []; + + private IEngineInput? _input; + private IPhysicsService? _physics; + private IAudioService? _audio; + private GameObject? _bounceSound; + private bool _spacePressedLastFrame; + + public void Configure(IPluginContext ctx) + { + _input = ctx.Services.Require(); + _physics = ctx.Services.Require(); + _audio = ctx.Services.Require(); + + ctx.Schedule.Add(Stage.Update, Tick) + .Writes() + .Writes() + .Writes(); + + ctx.Log.Info("physics demo ready — press Space to drop a box"); + } + + public void Shutdown(IPluginContext ctx) + { + ctx.Schedule.RemoveAllFrom("physics-demo-game"); + _spawned.Clear(); + _hasFallen.Clear(); + _landed.Clear(); + _bounceSound = null; + _input = null; + _physics = null; + _audio = null; + } + + private void Tick(IWorld world) + { + _bounceSound ??= world.Roots.FirstOrDefault(go => go.Name == "Bounce"); + + var spacePressed = _input!.IsKeyDown(Key.Space); + if (spacePressed && !_spacePressedLastFrame) + Spawn(world); + _spacePressedLastFrame = spacePressed; + + foreach (var box in _spawned) + { + if (_landed.Contains(box)) + continue; + + var velocityY = _physics!.GetLinearVelocity(box).Y; + + if (velocityY < FallingThreshold) + { + _hasFallen.Add(box); + continue; + } + + if (_hasFallen.Contains(box) && MathF.Abs(velocityY) < RestThreshold) + { + _landed.Add(box); + if (_bounceSound is not null) + _audio!.Play(_bounceSound); + } + } + } + + private void Spawn(IWorld world) + { + var go = world.CreateGameObject($"Box{_spawned.Count}"); + go.Transform = Transform.Identity; + go.Transform.LocalPosition = new Vector3( + (float)(_random.NextDouble() * 4 - 2), SpawnHeight, (float)(_random.NextDouble() * 4 - 2)); + + go.AddComponent().Type = BodyType.Dynamic; + go.AddComponent(); + go.AddComponent(); + + _spawned.Add(go); + } +} diff --git a/samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json b/samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json new file mode 100644 index 0000000..04cae77 --- /dev/null +++ b/samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json @@ -0,0 +1,12 @@ +{ + "id": "physics-demo-game", + "version": "0.1.0", + "assembly": "PhysicsDemoGame.dll", + "dependsOn": { + "engine.input": "^0.1", + "engine.physics": "^0.1", + "engine.audio": "^0.1", + "engine.render": "^0.1" + }, + "reloadable": true +} diff --git a/samples/PhysicsDemo/assets/bounce.wav b/samples/PhysicsDemo/assets/bounce.wav new file mode 100644 index 0000000..3df2120 Binary files /dev/null and b/samples/PhysicsDemo/assets/bounce.wav differ diff --git a/samples/PhysicsDemo/assets/music.wav b/samples/PhysicsDemo/assets/music.wav new file mode 100644 index 0000000..59b4cd0 Binary files /dev/null and b/samples/PhysicsDemo/assets/music.wav differ diff --git a/samples/PhysicsDemo/assets/texture.png b/samples/PhysicsDemo/assets/texture.png new file mode 100644 index 0000000..524e41a Binary files /dev/null and b/samples/PhysicsDemo/assets/texture.png differ diff --git a/samples/PhysicsDemo/project.json b/samples/PhysicsDemo/project.json new file mode 100644 index 0000000..c5794ff --- /dev/null +++ b/samples/PhysicsDemo/project.json @@ -0,0 +1,13 @@ +{ + "engineVersion": "^0.1", + "plugins": [ + { "id": "engine.windowing" }, + { "id": "engine.assets" }, + { "id": "engine.render" }, + { "id": "engine.input" }, + { "id": "engine.physics" }, + { "id": "engine.audio" }, + { "id": "physics-demo-game" } + ], + "pluginPaths": ["GamePlugins"] +} diff --git a/samples/PhysicsDemo/scene.json b/samples/PhysicsDemo/scene.json new file mode 100644 index 0000000..239c76c --- /dev/null +++ b/samples/PhysicsDemo/scene.json @@ -0,0 +1,42 @@ +[ + { + "name": "Ground", + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [8, 1, 8] + }, + "components": [ + { "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 0, "Friction": 0.6, "Restitution": 0.3 } }, + { "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": { "HalfExtents": { "X": 4, "Y": 0.5, "Z": 4 } } }, + { "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} } + ], + "children": [] + }, + { + "name": "DemoBox", + "transform": { "position": [0, 3, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] }, + "components": [ + { "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 2, "Friction": 0.6, "Restitution": 0.3 } }, + { "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": {} }, + { "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} } + ], + "children": [] + }, + { + "name": "Music", + "transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] }, + "components": [ + { "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/music.wav", "Volume": 0.35, "Loop": true, "PlayOnAwake": true } } + ], + "children": [] + }, + { + "name": "Bounce", + "transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] }, + "components": [ + { "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/bounce.wav", "Volume": 0.8, "Loop": false, "PlayOnAwake": false } } + ], + "children": [] + } +] diff --git a/scripts/run-physics-demo.sh b/scripts/run-physics-demo.sh new file mode 100755 index 0000000..4f95a79 --- /dev/null +++ b/scripts/run-physics-demo.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Stages the shared engine plugins, builds+places the sample's own +# game-specific plugin (physics-demo-game, listed in this project's own +# pluginPaths rather than the shared engine catalog — it's this game's +# logic, not a reusable engine plugin), then runs samples/PhysicsDemo. +# Extra args are forwarded to `engine run`. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG="${CONFIGURATION:-Debug}" +STAGE_DIR="$REPO_ROOT/.stage/plugins" +GAME_DIR="$REPO_ROOT/samples/PhysicsDemo/GamePlugins/physics-demo-game" + +"$REPO_ROOT/scripts/stage-plugins.sh" "$STAGE_DIR" + +cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.dll "$GAME_DIR/" 2>/dev/null || true +cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.pdb "$GAME_DIR/" 2>/dev/null || true + +cd "$REPO_ROOT/samples/PhysicsDemo" +exec dotnet "$REPO_ROOT/src/Engine.Host/bin/$CONFIG/net9.0/Engine.Host.dll" run --windowed \ + --plugins "$STAGE_DIR" --project project.json --scene scene.json "$@" diff --git a/tests/PhysicsDemoGame.Tests/PhysicsDemoGame.Tests.csproj b/tests/PhysicsDemoGame.Tests/PhysicsDemoGame.Tests.csproj new file mode 100644 index 0000000..642567c --- /dev/null +++ b/tests/PhysicsDemoGame.Tests/PhysicsDemoGame.Tests.csproj @@ -0,0 +1,22 @@ + + + + false + + + + + + + + + + + + + + + + + + diff --git a/tests/PhysicsDemoGame.Tests/PhysicsDemoGamePluginTests.cs b/tests/PhysicsDemoGame.Tests/PhysicsDemoGamePluginTests.cs new file mode 100644 index 0000000..e6cb94b --- /dev/null +++ b/tests/PhysicsDemoGame.Tests/PhysicsDemoGamePluginTests.cs @@ -0,0 +1,176 @@ +using System.Numerics; +using Engine.Audio.Contracts; +using Engine.Input.Contracts; +using Engine.Kernel.Diagnostics; +using Engine.Kernel.Events; +using Engine.Kernel.Plugins; +using Engine.Kernel.Scheduling; +using Engine.Kernel.Services; +using Engine.Kernel.World; +using Engine.Physics.Contracts; +using PhysicsDemoGame; +using Silk.NET.Input; + +namespace PhysicsDemoGame.Tests; + +// No real window, no real GLFW keyboard, no real Box3D/miniaudio needed to +// test this plugin's OWN logic (spawn-on-press, landed-detection) — only +// whether it calls its three service dependencies correctly. Real OS-level +// key injection isn't available in this sandbox (no xdotool/ydotool under +// Wayland) — a fake IEngineInput the test drives directly is the honest +// substitute, not a compromise: it exercises PhysicsDemoGamePlugin's real +// Configure()/Tick() through the real Schedule (so SystemAccessScope's +// Writes() declarations are genuinely +// checked), with IPhysicsService/IAudioService swapped for controllable +// fakes since engine.physics/engine.audio's own correctness is already +// covered by their own test projects. + +internal sealed class FakeInput : IEngineInput +{ + public bool SpaceDown; + public bool IsKeyDown(Key key) => key == Key.Space && SpaceDown; + public IInputContext Native => throw new NotSupportedException(); +} + +internal sealed class FakePhysics : IPhysicsService +{ + public readonly Dictionary Velocity = []; + + public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true) { } + public Vector3 GetLinearVelocity(GameObject go) => Velocity.GetValueOrDefault(go, Vector3.Zero); + public void SetLinearVelocity(GameObject go, Vector3 velocity) => Velocity[go] = velocity; +} + +internal sealed class FakeAudio : IAudioService +{ + public readonly List Played = []; + + public void Play(GameObject go) => Played.Add(go); + public void Stop(GameObject go) { } + public bool IsPlaying(GameObject go) => false; +} + +internal sealed class NullLogger : ILogger +{ + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message) { } +} + +internal sealed class TestPluginContext( + IWorld world, IServiceRegistry services, ISchedule schedule, IEventBus events, ITime time) : IPluginContext +{ + public IWorld World { get; } = world; + public IServiceRegistry Services { get; } = services; + public ISchedule Schedule { get; } = schedule; + public IEventBus Events { get; } = events; + public ILogger Log { get; } = new NullLogger(); + public ITime Time { get; } = time; +} + +public class PhysicsDemoGamePluginTests +{ + private static (GameWorld World, Schedule Schedule, FakeInput Input, FakePhysics Physics, FakeAudio Audio) Setup() + { + var world = new GameWorld(); + var schedule = new Schedule(); + var services = new ServiceRegistry(); + var input = new FakeInput(); + var physics = new FakePhysics(); + var audio = new FakeAudio(); + + services.Provide(input); + services.Provide(physics); + services.Provide(audio); + + var ctx = new TestPluginContext(world, services, schedule, new EventBus(), new Time()); + new global::PhysicsDemoGame.PhysicsDemoGamePlugin().Configure(ctx); + + return (world, schedule, input, physics, audio); + } + + [Fact] + public void PressingSpace_SpawnsExactlyOneBox_EvenIfHeldAcrossFrames() + { + var (world, schedule, input, _, _) = Setup(); + + input.SpaceDown = true; + schedule.RunStage(Stage.Update, world); // press + schedule.RunStage(Stage.Update, world); // still held — no second spawn + + Assert.Single(world.Roots); + } + + [Fact] + public void ReleasingAndPressingAgain_SpawnsASecondBox() + { + var (world, schedule, input, _, _) = Setup(); + + input.SpaceDown = true; + schedule.RunStage(Stage.Update, world); + input.SpaceDown = false; + schedule.RunStage(Stage.Update, world); + input.SpaceDown = true; + schedule.RunStage(Stage.Update, world); + + Assert.Equal(2, world.Roots.Count); + } + + [Fact] + public void SpawnedBox_HasRigidbodyBoxColliderAndCubeRenderer() + { + var (world, schedule, input, _, _) = Setup(); + + input.SpaceDown = true; + schedule.RunStage(Stage.Update, world); + + var box = Assert.Single(world.Roots); + Assert.NotNull(box.GetComponent()); + Assert.NotNull(box.GetComponent()); + Assert.Equal(BodyType.Dynamic, box.GetComponent()!.Type); + } + + [Fact] + public void BoxThatNeverActuallyFalls_DoesNotTriggerTheBounceSound() + { + var (world, schedule, input, physics, audio) = Setup(); + var bounce = world.CreateGameObject("Bounce"); + + input.SpaceDown = true; + schedule.RunStage(Stage.Update, world); + var box = world.Roots.First(go => go != bounce); + + // Spawned at rest (velocity never set below the falling threshold) + // — must not be mistaken for "landed." + for (var i = 0; i < 5; i++) + schedule.RunStage(Stage.Update, world); + + Assert.Empty(audio.Played); + } + + [Fact] + public void BoxThatFallsThenSettles_TriggersTheBounceSoundExactlyOnce() + { + var (world, schedule, input, physics, audio) = Setup(); + var bounce = world.CreateGameObject("Bounce"); + + input.SpaceDown = true; + schedule.RunStage(Stage.Update, world); + input.SpaceDown = false; + var box = world.Roots.First(go => go != bounce); + + physics.Velocity[box] = new Vector3(0, -5, 0); // falling fast + schedule.RunStage(Stage.Update, world); + Assert.Empty(audio.Played); + + physics.Velocity[box] = new Vector3(0, 0, 0); // settled + schedule.RunStage(Stage.Update, world); + Assert.Single(audio.Played); + Assert.Same(bounce, audio.Played[0]); + + // Still resting on later frames — must not re-trigger. + schedule.RunStage(Stage.Update, world); + schedule.RunStage(Stage.Update, world); + Assert.Single(audio.Played); + } +}