M4: PhysicsDemo — the "one small game, end to end"
samples/PhysicsDemo ties physics, audio, input, and rendering together through nothing but the kernel's own vocabulary — no plugin here references another plugin's implementation, only Contracts. Press Space to drop a box; engine.physics simulates it falling onto a static ground; CubeRenderer (new — engine.render's QuadRenderer drew flat cards, no good for a physics demo where a BoxCollider needs to actually look like a box) draws it; physics-demo-game watches each spawned box's own Y velocity via IPhysicsService and plays a bounce sound via IAudioService the moment a real fall settles. A looping ambient track plays throughout via AudioSource's own PlayOnAwake. project.json deliberately excludes engine.editor — this is the shippable configuration M4's "done when" actually asks for, not the dev one. "Landed" isn't a Box3D contact event — the native shim never exposed one (nothing but scalars crosses that boundary, see lingua_physics.c). Watching velocity every frame is the honest, right-sized alternative for a demo this size, not a shortcut around missing infrastructure. Verified two ways. First, real Space-key input isn't simulable here (no xdotool/ydotool under this Wayland session) — so PhysicsDemoGame.Tests drives the actual PhysicsDemoGamePlugin.Configure/Tick through the real Schedule with a controllable fake IEngineInput (and fake IPhysicsService/ IAudioService, since engine.physics/engine.audio's own correctness is already covered elsewhere): spawn-on-press with edge detection, the Rigidbody/BoxCollider/CubeRenderer combo the spawned box actually gets, and — the case most likely to be subtly wrong — a box that never actually falls doesn't false-positive as "landed," only one that fell past FallingThreshold and then settled does, exactly once. All 5 passed immediately. Second, a real windowed run: a box placed in scene.json above the ground visibly falls and settles onto it on screen after a real few-second wait — screenshotting by --screenshot-after-frames alone turned out not to prove this (VSync is off, so frames race by far faster than real physics time passes; enough elapsed frames isn't enough elapsed seconds), an interactive run with a real sleep before the screenshot command is what actually shows it. Full suite: 92 tests. Remaining for M4: the Linux + Windows build pipeline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>PhysicsDemoGame</RootNamespace>
|
||||
<AssemblyName>PhysicsDemoGame</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../../src/Engine.Kernel/Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.physics/Engine.Physics.Contracts/Engine.Physics.Contracts.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.audio/Engine.Audio.Contracts/Engine.Audio.Contracts.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<GameObject> _spawned = [];
|
||||
private readonly HashSet<GameObject> _hasFallen = [];
|
||||
private readonly HashSet<GameObject> _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<IEngineInput>();
|
||||
_physics = ctx.Services.Require<IPhysicsService>();
|
||||
_audio = ctx.Services.Require<IAudioService>();
|
||||
|
||||
ctx.Schedule.Add(Stage.Update, Tick)
|
||||
.Writes<Rigidbody>()
|
||||
.Writes<BoxCollider>()
|
||||
.Writes<CubeRenderer>();
|
||||
|
||||
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<Rigidbody>().Type = BodyType.Dynamic;
|
||||
go.AddComponent<BoxCollider>();
|
||||
go.AddComponent<CubeRenderer>();
|
||||
|
||||
_spawned.Add(go);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 84 B |
@@ -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"]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user