Files
EmilandClaude Sonnet 5 2d0168104d 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
2026-09-02 17:47:15 +03:00

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);
}
}