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
177 lines
6.1 KiB
C#
177 lines
6.1 KiB
C#
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<Rigidbody/BoxCollider/CubeRenderer>() 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<GameObject, Vector3> 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<GameObject> 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<IEngineInput>(input);
|
|
services.Provide<IPhysicsService>(physics);
|
|
services.Provide<IAudioService>(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<Rigidbody>());
|
|
Assert.NotNull(box.GetComponent<BoxCollider>());
|
|
Assert.Equal(BodyType.Dynamic, box.GetComponent<Rigidbody>()!.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);
|
|
}
|
|
}
|