diff --git a/src/AcDream.App/Composition/HostInputCameraComposition.cs b/src/AcDream.App/Composition/HostInputCameraComposition.cs new file mode 100644 index 00000000..76f8d28a --- /dev/null +++ b/src/AcDream.App/Composition/HostInputCameraComposition.cs @@ -0,0 +1,333 @@ +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; +using Silk.NET.Maths; +using Silk.NET.OpenGL; + +namespace AcDream.App.Composition; + +internal interface IGameWindowHostInputCameraPublication +{ + void PublishGpuFrameFlights(GpuFrameFlightController value); + void PublishKeyboardSource(SilkKeyboardSource value); + void PublishMouseSource(SilkMouseSource value); + void PublishMouseLookCursor(IMouseLookCursor value); + void PublishInputDispatcher(InputDispatcher value); + void PublishCameraController(CameraController value); + void PublishCameraPointerInput(CameraPointerInputController value); +} + +internal sealed record HostInputCameraResult( + GpuFrameFlightController GpuFrameFlights, + WorldRenderDiagnostics WorldRenderDiagnostics, + SilkKeyboardSource? KeyboardSource, + SilkMouseSource? MouseSource, + IMouseLookCursor? MouseLookCursor, + InputDispatcher? InputDispatcher, + CameraController CameraController, + CameraPointerInputController? CameraPointerInput); + +internal sealed record HostInputCameraDependencies( + FramebufferResizeController FramebufferResize, + Vector2D InitialFramebufferSize, + HostQuiescenceGate HostQuiescence, + IInputCaptureSource InputCapture, + KeyBindings KeyBindings, + DispatcherMovementInputSource MovementInput, + DispatcherCameraInputSource CameraInput, + LocalPlayerModeState LocalPlayerMode, + ChaseCameraInputState ChaseCameraInput, + PointerPositionState PointerPosition, + IRenderFrameDiagnosticLog RenderDiagnosticLog); + +internal interface IHostInputCameraCompositionFactory +{ + IFramebufferViewportTarget CreateViewportTarget(GL gl); + GpuFrameFlightController CreateGpuFrameFlights(GL gl); + WorldRenderDiagnostics CreateWorldRenderDiagnostics( + GL gl, + IRenderFrameDiagnosticLog log); + SilkKeyboardSource CreateKeyboardSource( + IKeyboard keyboard, + HostQuiescenceGate quiescence); + SilkMouseSource CreateMouseSource( + IMouse mouse, + IInputCaptureSource capture, + IKeyboardSource? keyboard, + HostQuiescenceGate quiescence); + IMouseLookCursor CreateMouseLookCursor(IMouse mouse); + InputDispatcher CreateInputDispatcher( + IKeyboardSource keyboard, + IMouseSource mouse, + KeyBindings bindings); + CameraController CreateCameraController(); + IFramebufferCameraTarget CreateCameraTarget(CameraController camera); + CameraPointerInputController CreateCameraPointerInput( + IReadOnlyList mice, + HostQuiescenceGate quiescence, + IInputCaptureSource capture, + LocalPlayerModeState playerMode, + CameraController camera, + ChaseCameraInputState chase, + IMouseSource mouse, + PointerPositionState pointer); +} + +internal sealed class RetailHostInputCameraCompositionFactory + : IHostInputCameraCompositionFactory +{ + public IFramebufferViewportTarget CreateViewportTarget(GL gl) => + new SilkFramebufferViewportTarget(gl); + + public GpuFrameFlightController CreateGpuFrameFlights(GL gl) => new(gl); + + public WorldRenderDiagnostics CreateWorldRenderDiagnostics( + GL gl, + IRenderFrameDiagnosticLog log) => + new(new SilkRenderGlStateReader(gl), log); + + public SilkKeyboardSource CreateKeyboardSource( + IKeyboard keyboard, + HostQuiescenceGate quiescence) => + SilkKeyboardSource.CreateDetached(keyboard, quiescence); + + public SilkMouseSource CreateMouseSource( + IMouse mouse, + IInputCaptureSource capture, + IKeyboardSource? keyboard, + HostQuiescenceGate quiescence) => + SilkMouseSource.CreateDetached(mouse, capture, keyboard, quiescence); + + public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) => + new SilkMouseLookCursor(mouse); + + public InputDispatcher CreateInputDispatcher( + IKeyboardSource keyboard, + IMouseSource mouse, + KeyBindings bindings) => + InputDispatcher.CreateDetached(keyboard, mouse, bindings); + + public CameraController CreateCameraController() => + new(new OrbitCamera(), new FlyCamera()); + + public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) => + new CameraFramebufferTarget(camera); + + public CameraPointerInputController CreateCameraPointerInput( + IReadOnlyList mice, + HostQuiescenceGate quiescence, + IInputCaptureSource capture, + LocalPlayerModeState playerMode, + CameraController camera, + ChaseCameraInputState chase, + IMouseSource mouse, + PointerPositionState pointer) => + CameraPointerInputController.Create( + mice, + quiescence, + capture, + playerMode, + camera, + chase, + mouse, + pointer, + new EnvironmentInputMonotonicClock()); +} + +internal enum HostInputCameraCompositionPoint +{ + ViewportBound, + GpuFrameFlightsPublished, + KeyboardPublished, + KeyboardAttached, + MousePublished, + MouseAttached, + MouseLookCursorPublished, + DispatcherPublished, + DispatcherAttached, + MovementInputBound, + CameraInputBound, + CameraPublished, + CameraTargetBound, + InitialFramebufferApplied, + CameraPointerPublished, + CameraPointerAttached, +} + +/// +/// Production Phase 1. Callback-bearing owners publish to the lifetime shell +/// before attachment, so a side-effecting event accessor cannot leave an +/// unreachable subscription after partial startup failure. +/// +internal sealed class HostInputCameraCompositionPhase : + IHostInputCameraCompositionPhase< + GameWindowPlatformResult, + HostInputCameraResult> +{ + private readonly HostInputCameraDependencies _dependencies; + private readonly IGameWindowHostInputCameraPublication _publication; + private readonly IHostInputCameraCompositionFactory _factory; + private readonly Action? _faultInjection; + + public HostInputCameraCompositionPhase( + HostInputCameraDependencies dependencies, + IGameWindowHostInputCameraPublication publication, + IHostInputCameraCompositionFactory? factory = null, + Action? faultInjection = null) + { + _dependencies = dependencies + ?? throw new ArgumentNullException(nameof(dependencies)); + _publication = publication + ?? throw new ArgumentNullException(nameof(publication)); + _factory = factory ?? new RetailHostInputCameraCompositionFactory(); + _faultInjection = faultInjection; + } + + public HostInputCameraResult Compose( + GameWindowPlatformResult platform) + { + ArgumentNullException.ThrowIfNull(platform); + var scope = new CompositionAcquisitionScope(); + try + { + HostInputCameraResult result = ComposeCore(platform, scope); + scope.Complete(); + return result; + } + catch (Exception failure) + { + scope.RollbackAndThrow(failure); + throw new System.Diagnostics.UnreachableException(); + } + } + + private HostInputCameraResult ComposeCore( + GameWindowPlatformResult platform, + CompositionAcquisitionScope scope) + { + GL gl = platform.Graphics; + IInputContext input = platform.Input; + + _dependencies.FramebufferResize.BindViewport( + _factory.CreateViewportTarget(gl)); + Fault(HostInputCameraCompositionPoint.ViewportBound); + + GpuFrameFlightController gpuFrames = scope.Acquire( + "GPU frame flights", + () => _factory.CreateGpuFrameFlights(gl), + static value => value.Dispose()).Publish( + _publication.PublishGpuFrameFlights); + Fault(HostInputCameraCompositionPoint.GpuFrameFlightsPublished); + + WorldRenderDiagnostics diagnostics = + _factory.CreateWorldRenderDiagnostics( + gl, + _dependencies.RenderDiagnosticLog); + + IKeyboard? firstKeyboard = input.Keyboards.FirstOrDefault(); + IMouse? firstMouse = input.Mice.FirstOrDefault(); + SilkKeyboardSource? keyboard = null; + SilkMouseSource? mouse = null; + IMouseLookCursor? cursor = null; + InputDispatcher? dispatcher = null; + CameraPointerInputController? pointer = null; + + if (firstKeyboard is not null) + { + keyboard = scope.Acquire( + "keyboard source", + () => _factory.CreateKeyboardSource( + firstKeyboard, + _dependencies.HostQuiescence), + static value => value.Dispose()).Publish( + _publication.PublishKeyboardSource); + Fault(HostInputCameraCompositionPoint.KeyboardPublished); + keyboard.Attach(); + Fault(HostInputCameraCompositionPoint.KeyboardAttached); + } + + if (firstMouse is not null) + { + mouse = scope.Acquire( + "mouse source", + () => _factory.CreateMouseSource( + firstMouse, + _dependencies.InputCapture, + keyboard, + _dependencies.HostQuiescence), + static value => value.Dispose()).Publish( + _publication.PublishMouseSource); + Fault(HostInputCameraCompositionPoint.MousePublished); + mouse.Attach(); + Fault(HostInputCameraCompositionPoint.MouseAttached); + + cursor = _factory.CreateMouseLookCursor(firstMouse); + _publication.PublishMouseLookCursor(cursor); + Fault(HostInputCameraCompositionPoint.MouseLookCursorPublished); + } + + if (keyboard is not null && mouse is not null) + { + dispatcher = scope.Acquire( + "input dispatcher", + () => _factory.CreateInputDispatcher( + keyboard, + mouse, + _dependencies.KeyBindings), + static value => value.Dispose()).Publish( + _publication.PublishInputDispatcher); + Fault(HostInputCameraCompositionPoint.DispatcherPublished); + dispatcher.Attach(); + Fault(HostInputCameraCompositionPoint.DispatcherAttached); + + _dependencies.MovementInput.Bind(dispatcher); + Fault(HostInputCameraCompositionPoint.MovementInputBound); + _dependencies.CameraInput.Bind(dispatcher); + Fault(HostInputCameraCompositionPoint.CameraInputBound); + } + + CameraController camera = _factory.CreateCameraController(); + _publication.PublishCameraController(camera); + Fault(HostInputCameraCompositionPoint.CameraPublished); + _dependencies.FramebufferResize.BindCamera( + _factory.CreateCameraTarget(camera)); + Fault(HostInputCameraCompositionPoint.CameraTargetBound); + _dependencies.FramebufferResize.Resize( + _dependencies.InitialFramebufferSize); + Fault(HostInputCameraCompositionPoint.InitialFramebufferApplied); + + if (mouse is not null && firstMouse is not null) + { + pointer = scope.Acquire( + "camera pointer input", + () => _factory.CreateCameraPointerInput( + input.Mice, + _dependencies.HostQuiescence, + _dependencies.InputCapture, + _dependencies.LocalPlayerMode, + camera, + _dependencies.ChaseCameraInput, + mouse, + _dependencies.PointerPosition), + static value => value.Dispose()).Publish( + _publication.PublishCameraPointerInput); + Fault(HostInputCameraCompositionPoint.CameraPointerPublished); + pointer.AttachRaw(); + Fault(HostInputCameraCompositionPoint.CameraPointerAttached); + } + + return new HostInputCameraResult( + gpuFrames, + diagnostics, + keyboard, + mouse, + cursor, + dispatcher, + camera, + pointer); + } + + private void Fault(HostInputCameraCompositionPoint point) => + _faultInjection?.Invoke(point); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fa02ff8a..33b27fb7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,4 +1,5 @@ using AcDream.Core.Plugins; +using AcDream.App.Composition; using AcDream.App.Physics; using AcDream.App.Settings; using AcDream.App.World; @@ -11,7 +12,10 @@ using Silk.NET.Windowing; namespace AcDream.App.Rendering; -public sealed class GameWindow : IDisposable +public sealed class GameWindow : + IDisposable, + IGameWindowPlatformPublication, + IGameWindowHostInputCameraPublication { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -653,56 +657,96 @@ public sealed class GameWindow : IDisposable } } + void IGameWindowPlatformPublication.PublishGraphics( + GL graphics) => + PublishCompositionOwner(ref _gl, graphics, "graphics API"); + + void IGameWindowPlatformPublication.PublishInput( + IInputContext input) => + PublishCompositionOwner(ref _input, input, "input context"); + + void IGameWindowHostInputCameraPublication.PublishGpuFrameFlights( + GpuFrameFlightController value) => + PublishCompositionOwner(ref _gpuFrameFlights, value, "GPU frame flights"); + + void IGameWindowHostInputCameraPublication.PublishKeyboardSource( + AcDream.App.Input.SilkKeyboardSource value) => + PublishCompositionOwner(ref _kbSource, value, "keyboard source"); + + void IGameWindowHostInputCameraPublication.PublishMouseSource( + AcDream.App.Input.SilkMouseSource value) => + PublishCompositionOwner(ref _mouseSource, value, "mouse source"); + + void IGameWindowHostInputCameraPublication.PublishMouseLookCursor( + AcDream.App.Input.IMouseLookCursor value) => + PublishCompositionOwner(ref _mouseLookCursor, value, "mouse-look cursor"); + + void IGameWindowHostInputCameraPublication.PublishInputDispatcher( + AcDream.UI.Abstractions.Input.InputDispatcher value) => + PublishCompositionOwner(ref _inputDispatcher, value, "input dispatcher"); + + void IGameWindowHostInputCameraPublication.PublishCameraController( + CameraController value) => + PublishCompositionOwner(ref _cameraController, value, "camera controller"); + + void IGameWindowHostInputCameraPublication.PublishCameraPointerInput( + AcDream.App.Input.CameraPointerInputController value) => + PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input"); + + private static void PublishCompositionOwner( + ref T? destination, + T value, + string name) + where T : class + { + ArgumentNullException.ThrowIfNull(value); + if (destination is not null) + throw new InvalidOperationException( + $"The GameWindow composition shell already owns {name}."); + destination = value; + } + + [System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_gl), nameof(_input))] + private GameWindowPlatformResult AcquirePlatform() + { + GameWindowPlatformResult platform = + GameWindowPlatformAcquisition.Acquire( + () => GL.GetApi(_window!), + static gl => gl.Dispose(), + () => _window!.CreateInput(), + static input => input.Dispose(), + this); + if (_gl is null || _input is null) + throw new InvalidOperationException( + "Platform acquisition returned without publishing both owners."); + return platform; + } + private void OnLoad() { // Task 7: wire the physics data cache into the engine so Transition can // run narrow-phase BSP tests during FindObjCollisions. _physicsEngine.DataCache = _physicsDataCache; - _gl = GL.GetApi(_window!); - _framebufferResize.BindViewport( - new SilkFramebufferViewportTarget(_gl)); - _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); - var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( - new AcDream.App.Rendering.SilkRenderGlStateReader(_gl), - _renderDiagnosticLog); - _input = _window!.CreateInput(); + GameWindowPlatformResult platform = AcquirePlatform(); - // Phase K.1b — every keyboard/mouse handler routes through the - // InputDispatcher. The legacy direct kb.KeyDown / mouse.MouseDown - // switches are gone; subscribers below own all game-side reactions. - // We KEEP a direct mouse.MouseMove handler because mouse-delta is - // axis input, not chord input — but with explicit WantCaptureMouse - // gating and the previous "_playerMouseDeltaX +=" line dropped so - // mouse delta NEVER drives character yaw (regression-prevention - // per K.1b plan §D). - var firstKb = _input.Keyboards.FirstOrDefault(); - var firstMouse = _input.Mice.FirstOrDefault(); - if (firstKb is not null) - { - _kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached( - firstKb, - _hostQuiescence); - _kbSource.Attach(); - } - if (firstMouse is not null) - { - _mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached( - firstMouse, - _inputCapture, - _kbSource, - _hostQuiescence); - _mouseSource.Attach(); - _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); - } - if (_kbSource is not null && _mouseSource is not null) - { - _inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached( - _kbSource, _mouseSource, _keyBindings); - _inputDispatcher.Attach(); - _movementInput.Bind(_inputDispatcher); - _cameraInput.Bind(_inputDispatcher); - } + HostInputCameraResult hostInputCamera = + new HostInputCameraCompositionPhase( + new HostInputCameraDependencies( + _framebufferResize, + _window!.FramebufferSize, + _hostQuiescence, + _inputCapture, + _keyBindings, + _movementInput, + _cameraInput, + _localPlayerMode, + _chaseCameraInput, + _pointerPosition, + _renderDiagnosticLog), + this).Compose(platform); + WorldRenderDiagnostics worldRenderDiagnostics = + hostInputCamera.WorldRenderDiagnostics; _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.Enable(EnableCap.DepthTest); @@ -754,27 +798,6 @@ public sealed class GameWindow : IDisposable Console.WriteLine("world-hud font: no system monospace font found"); } - var orbit = new OrbitCamera(); - var fly = new FlyCamera(); - _cameraController = new CameraController(orbit, fly); - _framebufferResize.BindCamera( - new CameraFramebufferTarget(_cameraController)); - _framebufferResize.Resize(_window!.FramebufferSize); - if (_mouseSource is not null && firstMouse is not null) - { - _cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create( - _input.Mice, - _hostQuiescence, - _inputCapture, - _localPlayerMode, - _cameraController, - _chaseCameraInput, - _mouseSource, - _pointerPosition, - new AcDream.App.Input.EnvironmentInputMonotonicClock()); - _cameraPointerInput.AttachRaw(); - } - _dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir); _magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats); SpellBook.InstallMetadata(_magicCatalog.SpellTable); diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs new file mode 100644 index 00000000..399a94bd --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs @@ -0,0 +1,424 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Composition; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; +using Silk.NET.Maths; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Composition; + +public sealed class HostInputCameraCompositionTests +{ + [Fact] + public void ProductionPhasePreservesTheCompleteHostInputCameraOrder() + { + using var fixture = new Fixture(); + + HostInputCameraResult result = fixture.Phase().Compose(fixture.Platform); + + Assert.Equal( + Enum.GetValues(), + fixture.Points); + Assert.Same(fixture.Publication.GpuFrames, result.GpuFrameFlights); + Assert.Same(fixture.Publication.Keyboard, result.KeyboardSource); + Assert.Same(fixture.Publication.Mouse, result.MouseSource); + Assert.Same(fixture.Publication.Dispatcher, result.InputDispatcher); + Assert.Same(fixture.Publication.Camera, result.CameraController); + Assert.Same(fixture.Publication.Pointer, result.CameraPointerInput); + Assert.Equal(1280f / 720f, fixture.ViewportAspect.Aspect, precision: 5); + Assert.Equal((1280, 720), fixture.Factory.Viewport.Size); + } + + [Theory] + [MemberData(nameof(FaultPointValues))] + public void FailureAtEveryProductionBoundaryStopsTheExactSuffix( + int faultPointValue) + { + var faultPoint = (HostInputCameraCompositionPoint)faultPointValue; + using var fixture = new Fixture(faultPoint); + + Assert.Throws(() => + fixture.Phase().Compose(fixture.Platform)); + + HostInputCameraCompositionPoint[] expected = + Enum.GetValues() + .TakeWhile(point => point <= faultPoint) + .ToArray(); + Assert.Equal(expected, fixture.Points); + fixture.Publication.AssertPublishedThrough(faultPoint); + } + + public static TheoryData FaultPointValues() + { + var data = new TheoryData(); + foreach (HostInputCameraCompositionPoint point in + Enum.GetValues()) + { + data.Add((int)point); + } + return data; + } + + [Fact] + public void GameWindowUsesTheExactPlatformPreludeAndPhaseOneType() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("GameWindowPlatformAcquisition.Acquire(", source, + StringComparison.Ordinal); + Assert.Contains("new HostInputCameraCompositionPhase(", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_gl = GL.GetApi(_window!)", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_input = _window!.CreateInput()", source, + StringComparison.Ordinal); + } + + private sealed class Fixture : IDisposable + { + private readonly HostInputCameraCompositionPoint? _failure; + + public Fixture(HostInputCameraCompositionPoint? failure = null) + { + _failure = failure; + IKeyboard keyboard = DispatchProxy.Create(); + IMouse mouse = DispatchProxy.Create(); + Input = new InputContext(keyboard, mouse); + Platform = new GameWindowPlatformResult(null!, Input); + ViewportAspect = new ViewportAspectState(); + Framebuffer = new FramebufferResizeController(ViewportAspect); + Capture = new CaptureSource(); + Movement = new DispatcherMovementInputSource(Capture); + CameraInput = new DispatcherCameraInputSource(); + PlayerMode = new LocalPlayerModeState(); + Chase = new ChaseCameraInputState(); + Pointer = new PointerPositionState(); + Factory = new Factory(); + Publication = new Publication(); + } + + public List Points { get; } = []; + public InputContext Input { get; } + public GameWindowPlatformResult Platform { get; } + public ViewportAspectState ViewportAspect { get; } + public FramebufferResizeController Framebuffer { get; } + public CaptureSource Capture { get; } + public DispatcherMovementInputSource Movement { get; } + public DispatcherCameraInputSource CameraInput { get; } + public LocalPlayerModeState PlayerMode { get; } + public ChaseCameraInputState Chase { get; } + public PointerPositionState Pointer { get; } + public Factory Factory { get; } + public Publication Publication { get; } + + public HostInputCameraCompositionPhase Phase() => new( + new HostInputCameraDependencies( + Framebuffer, + new Vector2D(1280, 720), + new HostQuiescenceGate(), + Capture, + KeyBindings.RetailDefaults(), + Movement, + CameraInput, + PlayerMode, + Chase, + Pointer, + new DiagnosticLog()), + Publication, + Factory, + point => + { + Points.Add(point); + if (_failure == point) + throw new InvalidOperationException($"fault at {point}"); + }); + + public void Dispose() => Publication.Dispose(); + } + + private sealed class Publication : + IGameWindowHostInputCameraPublication, + IDisposable + { + public GpuFrameFlightController? GpuFrames { get; private set; } + public SilkKeyboardSource? Keyboard { get; private set; } + public SilkMouseSource? Mouse { get; private set; } + public IMouseLookCursor? Cursor { get; private set; } + public InputDispatcher? Dispatcher { get; private set; } + public CameraController? Camera { get; private set; } + public CameraPointerInputController? Pointer { get; private set; } + + public void PublishGpuFrameFlights(GpuFrameFlightController value) => + GpuFrames = PublishOnce(GpuFrames, value); + + public void PublishKeyboardSource(SilkKeyboardSource value) => + Keyboard = PublishOnce(Keyboard, value); + + public void PublishMouseSource(SilkMouseSource value) => + Mouse = PublishOnce(Mouse, value); + + public void PublishMouseLookCursor(IMouseLookCursor value) => + Cursor = PublishOnce(Cursor, value); + + public void PublishInputDispatcher(InputDispatcher value) => + Dispatcher = PublishOnce(Dispatcher, value); + + public void PublishCameraController(CameraController value) => + Camera = PublishOnce(Camera, value); + + public void PublishCameraPointerInput(CameraPointerInputController value) => + Pointer = PublishOnce(Pointer, value); + + public void AssertPublishedThrough(HostInputCameraCompositionPoint point) + { + Assert.Equal( + point >= HostInputCameraCompositionPoint.GpuFrameFlightsPublished, + GpuFrames is not null); + Assert.Equal( + point >= HostInputCameraCompositionPoint.KeyboardPublished, + Keyboard is not null); + Assert.Equal( + point >= HostInputCameraCompositionPoint.MousePublished, + Mouse is not null); + Assert.Equal( + point >= HostInputCameraCompositionPoint.MouseLookCursorPublished, + Cursor is not null); + Assert.Equal( + point >= HostInputCameraCompositionPoint.DispatcherPublished, + Dispatcher is not null); + Assert.Equal( + point >= HostInputCameraCompositionPoint.CameraPublished, + Camera is not null); + Assert.Equal( + point >= HostInputCameraCompositionPoint.CameraPointerPublished, + Pointer is not null); + } + + public void Dispose() + { + Pointer?.Dispose(); + Pointer = null; + Dispatcher?.Dispose(); + Dispatcher = null; + Mouse?.Dispose(); + Mouse = null; + Keyboard?.Dispose(); + Keyboard = null; + GpuFrames?.Dispose(); + GpuFrames = null; + } + + private static T PublishOnce(T? current, T value) + where T : class + { + if (current is not null) + throw new InvalidOperationException("duplicate publication"); + return value; + } + } + + private sealed class Factory : IHostInputCameraCompositionFactory + { + public ViewportTarget Viewport { get; } = new(); + private readonly KeyboardSurface _keyboard = new(); + private readonly MouseSurface _mouse = new(); + private readonly RawPointerSurface _rawPointer = new(); + + public IFramebufferViewportTarget CreateViewportTarget(GL gl) => Viewport; + + public GpuFrameFlightController CreateGpuFrameFlights(GL gl) => + new(new FenceApi()); + + public WorldRenderDiagnostics CreateWorldRenderDiagnostics( + GL gl, + IRenderFrameDiagnosticLog log) => + new(new GlStateReader(), log); + + public SilkKeyboardSource CreateKeyboardSource( + IKeyboard keyboard, + HostQuiescenceGate quiescence) => + SilkKeyboardSource.CreateDetached(_keyboard, quiescence); + + public SilkMouseSource CreateMouseSource( + IMouse mouse, + IInputCaptureSource capture, + IKeyboardSource? keyboard, + HostQuiescenceGate quiescence) => + SilkMouseSource.CreateDetached(_mouse, capture, keyboard, quiescence); + + public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) => new Cursor(); + + public InputDispatcher CreateInputDispatcher( + IKeyboardSource keyboard, + IMouseSource mouse, + KeyBindings bindings) => + InputDispatcher.CreateDetached(keyboard, mouse, bindings); + + public CameraController CreateCameraController() => + new(new OrbitCamera(), new FlyCamera()); + + public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) => + new CameraTarget(camera); + + public CameraPointerInputController CreateCameraPointerInput( + IReadOnlyList mice, + HostQuiescenceGate quiescence, + IInputCaptureSource capture, + LocalPlayerModeState playerMode, + CameraController camera, + ChaseCameraInputState chase, + IMouseSource mouse, + PointerPositionState pointer) => + new( + [_rawPointer], + new CursorModeTarget(), + quiescence, + capture, + playerMode, + camera, + chase, + mouse, + pointer, + new Clock()); + } + + private sealed class InputContext(IKeyboard keyboard, IMouse mouse) + : IInputContext + { + public nint Handle => 1; + public IReadOnlyList Gamepads { get; } = []; + public IReadOnlyList Joysticks { get; } = []; + public IReadOnlyList Keyboards { get; } = [keyboard]; + public IReadOnlyList Mice { get; } = [mouse]; + public IReadOnlyList OtherDevices { get; } = []; + public event Action? ConnectionChanged + { + add { } + remove { } + } + public void Dispose() { } + } + + public class NullDeviceProxy : DispatchProxy + { + protected override object? Invoke( + MethodInfo? targetMethod, + object?[]? args) + { + Type returnType = targetMethod?.ReturnType ?? typeof(void); + if (returnType == typeof(void)) + return null; + if (returnType == typeof(string)) + return string.Empty; + return returnType.IsValueType + ? Activator.CreateInstance(returnType) + : null; + } + } + + private sealed class KeyboardSurface : IKeyboardEventSurface + { + public void AddKeyDown(Action callback) { } + public void RemoveKeyDown(Action callback) { } + public void AddKeyUp(Action callback) { } + public void RemoveKeyUp(Action callback) { } + public bool IsKeyPressed(Key key) => false; + } + + private sealed class MouseSurface : IMouseEventSurface + { + public void AddMouseDown(Action callback) { } + public void RemoveMouseDown(Action callback) { } + public void AddMouseUp(Action callback) { } + public void RemoveMouseUp(Action callback) { } + public void AddMouseMove(Action callback) { } + public void RemoveMouseMove(Action callback) { } + public void AddScroll(Action callback) { } + public void RemoveScroll(Action callback) { } + public bool IsButtonPressed(MouseButton button) => false; + } + + private sealed class RawPointerSurface : IRawPointerSurface + { + public void AddMouseMove(Action callback) { } + public void RemoveMouseMove(Action callback) { } + } + + private sealed class CursorModeTarget : IPointerCursorModeTarget + { + public CursorMode CursorMode { get; set; } + } + + private sealed class Cursor : IMouseLookCursor + { + public bool HasSavedMode { get; private set; } + public void Hide() => HasSavedMode = true; + public void Restore() => HasSavedMode = false; + } + + private sealed class Clock : IInputMonotonicClock + { + public float NowSeconds => 0; + } + + private sealed class CaptureSource : IInputCaptureSource + { + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + public bool DevToolsWantCaptureKeyboard => false; + } + + private sealed class ViewportTarget : IFramebufferViewportTarget + { + public (int Width, int Height) Size { get; private set; } + public void ResizeViewport(int width, int height) => Size = (width, height); + } + + private sealed class CameraTarget(CameraController camera) + : IFramebufferCameraTarget + { + public void SetAspect(float aspect) => camera.SetAspect(aspect); + } + + private sealed class FenceApi : IGpuFenceApi + { + public nint Insert() => 1; + public GpuFenceWaitResult Wait( + nint fence, + bool flushCommands, + ulong timeoutNanoseconds) => GpuFenceWaitResult.Signaled; + public void Delete(nint fence) { } + } + + private sealed class GlStateReader : IRenderGlStateReader + { + public RenderGlStateSnapshot CaptureState() => default; + public RenderGlScissorSnapshot CaptureScissor() => default; + } + + private sealed class DiagnosticLog : IRenderFrameDiagnosticLog + { + public void WriteLine(string message) { } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index e1372add..b1ac6495 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -51,19 +51,9 @@ public sealed class GameWindowSlice8BoundaryTests AssertAppearsInOrder( body, - "_gl = GL.GetApi(_window!);", - "_input = _window!.CreateInput();", - "_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(", - "_kbSource.Attach();", - "_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(", - "_mouseSource.Attach();", - "_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(", - "_inputDispatcher.Attach();", - "_movementInput.Bind(_inputDispatcher);", - "_cameraInput.Bind(_inputDispatcher);", - "_cameraController = new CameraController(orbit, fly);", - "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", - "_cameraPointerInput.AttachRaw();", + "GameWindowPlatformResult platform = AcquirePlatform();", + "new HostInputCameraCompositionPhase(", + "this).Compose(platform);", "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "_runtimeSettings.ApplyStartup(", "new RuntimeSettingsStartupTargets(", @@ -86,15 +76,24 @@ public sealed class GameWindowSlice8BoundaryTests "_gameplayInputActions.Attach();", "_liveSessionHost.Start(_options)"); Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)")); - Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal); - Assert.Contains("if (firstMouse is not null)", body, StringComparison.Ordinal); + string phaseOne = Slice( + File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "HostInputCameraComposition.cs")), + "private HostInputCameraResult ComposeCore(", + "private void Fault("); + Assert.Contains("if (firstKeyboard is not null)", phaseOne, StringComparison.Ordinal); + Assert.Contains("if (firstMouse is not null)", phaseOne, StringComparison.Ordinal); Assert.Contains( - "if (_kbSource is not null && _mouseSource is not null)", - body, + "if (keyboard is not null && mouse is not null)", + phaseOne, StringComparison.Ordinal); Assert.DoesNotContain( - "if (firstKb is not null && firstMouse is not null)", - body, + "if (firstKeyboard is not null && firstMouse is not null)", + phaseOne, StringComparison.Ordinal); int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal); string postStart = body[start..]; @@ -199,11 +198,21 @@ public sealed class GameWindowSlice8BoundaryTests Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal); Assert.DoesNotContain("SetAspect(", body, StringComparison.Ordinal); Assert.DoesNotContain("ResetLayout(", body, StringComparison.Ordinal); + string phaseOne = Slice( + File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "HostInputCameraComposition.cs")), + "private HostInputCameraResult ComposeCore(", + "private void Fault("); AssertAppearsInOrder( - load, - "_framebufferResize.BindViewport(", - "_framebufferResize.BindCamera(", - "_framebufferResize.Resize(_window!.FramebufferSize);"); + phaseOne, + "_dependencies.FramebufferResize.BindViewport(", + "_publication.PublishCameraController(camera)", + "_dependencies.FramebufferResize.BindCamera(", + "_dependencies.FramebufferResize.Resize("); Assert.DoesNotContain( "_viewportAspect.Update(_window", load,