diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 4b2af346..7cef3403 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -22,9 +22,9 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. collapse, streaming tick, and rescued-entity rebucketing. - [x] D — extract dispatcher/raw-mouse/combat input sampling without changing movement-command or UI-capture semantics. -- [ ] E — extract the complete local-player teleport owner and wire it between +- [x] E — extract the complete local-player teleport owner and wire it between the existing post-network liveness and player-mode auto-entry phases. -- [ ] F — extract the shared player-mode lifecycle plus fly/chase/player +- [x] F — extract the shared player-mode lifecycle plus fly/chase/player camera presentation and cut production over to one `UpdateFrameOrchestrator`. - [ ] G — delete the old `OnUpdate` bodies, callback facades, and obsolete @@ -455,6 +455,20 @@ lines at this checkpoint, down from the slice baseline of 15,723. - measure line/field/method count. The target is below 8,000 lines, but the ownership/ordering exit criteria outrank the count. +**Checkpoint F completed 2026-07-22.** `CameraFrameController` now owns fly +held input, retail chase adjustment, the inbound-created-player projection +and second reconcile, both chase-camera publications, and combat-target +tracking. `RetailLocalPlayerFrameController` and +`LocalPlayerProjectionController` resolve current identity, session, world, +shadow, and player slots through focused typed runtimes instead of callbacks +into `GameWindow`; the camera receives only the two-read player-presentation +seam. Projection and frame owners remain composition locals, so the window +retains no duplicate lifecycle state. Focused Release tests are green at +37/37, the App suite at 2,820 passed / 3 skipped, and the full suite at 7,179 +passed / 5 skipped. All three corrected-diff reviews are clean. +`GameWindow.cs` is 7,160 lines, down 8,563 lines (54.5%) from the 15,723-line +slice baseline. + ### H — release and connected gates After all three independent corrected-diff reviews are clean: diff --git a/src/AcDream.App/Combat/CombatCameraTargetSource.cs b/src/AcDream.App/Combat/CombatCameraTargetSource.cs new file mode 100644 index 00000000..2f2756ed --- /dev/null +++ b/src/AcDream.App/Combat/CombatCameraTargetSource.cs @@ -0,0 +1,53 @@ +using System.Numerics; +using AcDream.App.Interaction; +using AcDream.Core.Combat; +using AcDream.Core.Selection; + +namespace AcDream.App.Combat; + +internal interface ICombatCameraTargetSource +{ + Vector3? GetTrackedTargetPoint(); +} + +/// +/// Resolves retail's combat-camera target from canonical combat, selection, +/// gameplay-option, and world-selection owners. +/// +/// +/// ClientCombatSystem::UpdateTargetTracking @ 0x0056A950 enables +/// CameraSet::TrackTarget only for melee/missile combat with the option +/// enabled and a valid attack target. TrackTarget applies target-local offset +/// (0, 0, 0.5). +/// +internal sealed class CombatCameraTargetSource : ICombatCameraTargetSource +{ + private readonly ICombatGameplaySettingsSource _settings; + private readonly CombatState _combat; + private readonly SelectionState _selection; + private readonly IWorldSelectionQuery _world; + + public CombatCameraTargetSource( + ICombatGameplaySettingsSource settings, + CombatState combat, + SelectionState selection, + IWorldSelectionQuery world) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + } + + public Vector3? GetTrackedTargetPoint() + { + if (!_settings.ViewCombatTarget + || !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode) + || _selection.SelectedObjectId is not uint selected) + { + return null; + } + + return _world.GetCombatCameraTargetPoint(selected); + } +} diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs index 0d5ee7ee..c37f4328 100644 --- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -15,6 +15,7 @@ internal interface ICombatGameplaySettingsSource { bool AutoTarget { get; } bool AutoRepeatAttack { get; } + bool ViewCombatTarget { get; } } internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource @@ -22,6 +23,7 @@ internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource public GameplaySettings Value { get; set; } = GameplaySettings.Default; public bool AutoTarget => Value.AutoTarget; public bool AutoRepeatAttack => Value.AutoRepeatAttack; + public bool ViewCombatTarget => Value.ViewCombatTarget; } internal interface ICombatFeedbackSink diff --git a/src/AcDream.App/Input/DispatcherCameraInputSource.cs b/src/AcDream.App/Input/DispatcherCameraInputSource.cs new file mode 100644 index 00000000..4b4e982d --- /dev/null +++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs @@ -0,0 +1,72 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal readonly record struct FlyCameraInput( + bool Forward, + bool Left, + bool Backward, + bool Right, + bool Up, + bool Down, + bool Boost); + +internal readonly record struct ChaseCameraAdjustmentInput( + bool ZoomIn, + bool ZoomOut, + bool Raise, + bool Lower); + +internal interface ICameraFrameInputSource +{ + bool IsAvailable { get; } + FlyCameraInput CaptureFly(); + ChaseCameraAdjustmentInput CaptureChaseAdjustment(); +} + +/// +/// Samples held camera actions from the same semantic dispatcher used by +/// gameplay movement. Binding is a one-time startup edge. +/// +internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource +{ + private InputDispatcher? _dispatcher; + + public bool IsAvailable => _dispatcher is not null; + + public void Bind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher)) + throw new InvalidOperationException( + "The camera input source is already bound to another dispatcher."); + _dispatcher = dispatcher; + } + + public FlyCameraInput CaptureFly() + { + if (_dispatcher is not { } dispatcher) + return default; + + return new FlyCameraInput( + dispatcher.IsActionHeld(InputAction.MovementForward), + dispatcher.IsActionHeld(InputAction.MovementTurnLeft), + dispatcher.IsActionHeld(InputAction.MovementBackup), + dispatcher.IsActionHeld(InputAction.MovementTurnRight), + dispatcher.IsActionHeld(InputAction.MovementJump), + dispatcher.IsActionHeld(InputAction.AcdreamFlyDown), + dispatcher.IsActionHeld(InputAction.MovementRunLock)); + } + + public ChaseCameraAdjustmentInput CaptureChaseAdjustment() + { + if (_dispatcher is not { } dispatcher) + return default; + + return new ChaseCameraAdjustmentInput( + dispatcher.IsActionHeld(InputAction.CameraZoomIn), + dispatcher.IsActionHeld(InputAction.CameraZoomOut), + dispatcher.IsActionHeld(InputAction.CameraRaise), + dispatcher.IsActionHeld(InputAction.CameraLower)); + } +} diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index c5207ab7..b8a2e20f 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -20,6 +20,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource _capture = capture; public bool AutoRunActive { get; private set; } + public bool IsAvailable => _dispatcher is not null; public void Bind(InputDispatcher dispatcher) { diff --git a/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs new file mode 100644 index 00000000..f659ca88 --- /dev/null +++ b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs @@ -0,0 +1,122 @@ +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Input; + +/// +/// Focused runtime seam for the local player's retail object and command +/// phases. The frame controller owns ordering; this seam resolves the current +/// session-scoped owners without retaining callbacks into the window host. +/// +internal interface ILocalPlayerPresentationRuntime +{ + bool CanPresentPlayer { get; } + PlayerMovementController? Controller { get; } +} + +internal interface ILocalPlayerFrameRuntime : ILocalPlayerPresentationRuntime +{ + uint ResolveLocalEntityId(); + void HandleTargeting(); + bool IsHidden { get; } + RetailObjectClockDisposition ObjectClockDisposition { get; } + void Project(PlayerMovementController controller, MovementResult movement, bool hidden); + void SendPreNetwork(PlayerMovementController controller, MovementResult movement, bool hidden); + void SendPostNetwork(PlayerMovementController controller, bool hidden); +} + +/// +/// Production local-player frame runtime composed from canonical typed owners. +/// +internal sealed class LiveLocalPlayerFrameRuntime : ILocalPlayerFrameRuntime +{ + private readonly CameraController _camera; + private readonly ILocalPlayerModeSource _mode; + private readonly ILocalPlayerControllerSource _controller; + private readonly IChaseCameraSource _chase; + private readonly DispatcherMovementInputSource _input; + private readonly IInputCaptureSource _capture; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly ILocalPlayerPhysicsHostSource _physicsHost; + private readonly LocalPlayerProjectionController _projection; + private readonly LocalPlayerOutboundController _outbound; + private readonly ILiveWorldSessionSource _session; + + public LiveLocalPlayerFrameRuntime( + CameraController camera, + ILocalPlayerModeSource mode, + ILocalPlayerControllerSource controller, + IChaseCameraSource chase, + DispatcherMovementInputSource input, + IInputCaptureSource capture, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + ILocalPlayerPhysicsHostSource physicsHost, + LocalPlayerProjectionController projection, + LocalPlayerOutboundController outbound, + ILiveWorldSessionSource session) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _physicsHost = physicsHost ?? throw new ArgumentNullException(nameof(physicsHost)); + _projection = projection ?? throw new ArgumentNullException(nameof(projection)); + _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + } + + public bool CanPresentPlayer => + !_camera.IsFlyMode + && _mode.IsPlayerMode + && _controller.Controller is not null + && _chase.Legacy is not null + && _input.IsAvailable + && !_capture.DevToolsWantCaptureKeyboard; + + public PlayerMovementController? Controller => _controller.Controller; + + public uint ResolveLocalEntityId() => + _liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity) + ? entity.Id + : 0u; + + public void HandleTargeting() => _physicsHost.Host?.HandleTargetting(); + + public bool IsHidden => _liveEntities.IsHidden(_identity.ServerGuid); + + public RetailObjectClockDisposition ObjectClockDisposition => + _liveEntities.GetRootObjectClockDisposition(_identity.ServerGuid); + + public void Project( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => + _projection.Project(controller, movement, hidden); + + public void SendPreNetwork( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => + _outbound.SendPreNetworkActions( + _session.CurrentSession, + controller, + movement, + hidden); + + public void SendPostNetwork( + PlayerMovementController controller, + bool hidden) => + _outbound.SendPostNetworkPosition( + _session.CurrentSession, + controller, + hidden); +} diff --git a/src/AcDream.App/Input/LocalPlayerProjectionController.cs b/src/AcDream.App/Input/LocalPlayerProjectionController.cs index 0d5697bc..8a32c4d7 100644 --- a/src/AcDream.App/Input/LocalPlayerProjectionController.cs +++ b/src/AcDream.App/Input/LocalPlayerProjectionController.cs @@ -1,47 +1,77 @@ using AcDream.Core.World; +using AcDream.App.Physics; +using AcDream.App.World; namespace AcDream.App.Input; +internal interface ILocalPlayerProjectionRuntime +{ + WorldEntity? ResolveEntity(); + int LiveCenterX { get; } + int LiveCenterY { get; } + void SyncShadow(WorldEntity entity, uint cellId); + void Rebucket(uint serverGuid, uint landblockId); + bool IsCurrentVisibleProjection(WorldEntity entity); + void SuspendShadow(WorldEntity entity); +} + +internal sealed class LiveLocalPlayerProjectionRuntime + : ILocalPlayerProjectionRuntime +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly LiveWorldOriginState _origin; + private readonly LocalPlayerShadowSynchronizer _shadow; + + public LiveLocalPlayerProjectionRuntime( + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + LiveWorldOriginState origin, + LocalPlayerShadowSynchronizer shadow) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); + } + + public WorldEntity? ResolveEntity() => + _liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity) + ? entity + : null; + + public int LiveCenterX => _origin.CenterX; + public int LiveCenterY => _origin.CenterY; + + public void SyncShadow(WorldEntity entity, uint cellId) => + _shadow.Sync(entity, cellId); + + public void Rebucket(uint serverGuid, uint landblockId) => + _liveEntities.RebucketLiveEntity(serverGuid, landblockId); + + public bool IsCurrentVisibleProjection(WorldEntity entity) => + _shadow.IsCurrentVisibleProjection(entity); + + public void SuspendShadow(WorldEntity entity) => _shadow.Suspend(entity); +} + /// /// Projects the canonical local physics body into world rendering and spatial /// buckets without advancing it. /// public sealed class LocalPlayerProjectionController { - private readonly Func _resolveEntity; - private readonly Func _liveCenterX; - private readonly Func _liveCenterY; - private readonly Action _syncShadow; - private readonly Action _rebucket; - private readonly Func _isCurrentVisibleProjection; - private readonly Action _suspendShadow; + private readonly ILocalPlayerProjectionRuntime _runtime; - public LocalPlayerProjectionController( - Func resolveEntity, - Func liveCenterX, - Func liveCenterY, - Action syncShadow, - Action rebucket, - Func isCurrentVisibleProjection, - Action suspendShadow) - { - _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity)); - _liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX)); - _liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY)); - _syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow)); - _rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket)); - _isCurrentVisibleProjection = isCurrentVisibleProjection - ?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection)); - _suspendShadow = suspendShadow - ?? throw new ArgumentNullException(nameof(suspendShadow)); - } + internal LocalPlayerProjectionController(ILocalPlayerProjectionRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); public void Project( PlayerMovementController controller, MovementResult movement, bool hidden) { - WorldEntity? entity = _resolveEntity(); + WorldEntity? entity = _runtime.ResolveEntity(); if (entity is null) return; @@ -62,8 +92,8 @@ public sealed class LocalPlayerProjectionController else { System.Numerics.Vector3 position = controller.Position; - int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f); - int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f); + int landblockX = _runtime.LiveCenterX + (int)Math.Floor(position.X / 192f); + int landblockY = _runtime.LiveCenterY + (int)Math.Floor(position.Y / 192f); currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF); } @@ -76,15 +106,15 @@ public sealed class LocalPlayerProjectionController // membership, then replaces collision rows only while the object still // owns a loaded cell. Rebucket is a synchronous callback boundary: it // can move this incarnation to pending or replace the GUID entirely. - _rebucket(entity.ServerGuid, currentLandblock); + _runtime.Rebucket(entity.ServerGuid, currentLandblock); if (hidden || movement.CellId == 0 - || !_isCurrentVisibleProjection(entity)) + || !_runtime.IsCurrentVisibleProjection(entity)) { - _suspendShadow(entity); + _runtime.SuspendShadow(entity); return; } - _syncShadow(entity, movement.CellId); + _runtime.SyncShadow(entity, movement.CellId); } } diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs index 69c9efbe..c8966f67 100644 --- a/src/AcDream.App/Input/MouseLookController.cs +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -73,7 +73,13 @@ internal sealed class SilkMouseLookCursor : IMouseLookCursor /// The current chase-camera input targets. GameWindow keeps compatibility /// properties over this one slot until checkpoint F moves camera ownership. /// -internal sealed class ChaseCameraInputState +internal interface IChaseCameraSource +{ + ChaseCamera? Legacy { get; } + RetailChaseCamera? Retail { get; } +} + +internal sealed class ChaseCameraInputState : IChaseCameraSource { public ChaseCamera? Legacy { get; set; } public RetailChaseCamera? Retail { get; set; } diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs index a5599bdc..0d8d1356 100644 --- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -14,17 +14,8 @@ namespace AcDream.App.Input; /// internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase { - private readonly Func _canPresentPlayer; - private readonly Func _getController; + private readonly ILocalPlayerFrameRuntime _runtime; private readonly IMovementInputSource _movementInput; - private readonly Func _resolveLocalEntityId; - private readonly Action _handleTargeting; - private readonly Func _isHidden; - private readonly Action _project; - private readonly Action _sendPreNetwork; - private readonly Action _sendPostNetwork; - private readonly Func - _objectClockDisposition; private AdvancedFrame? _advancedFrame; @@ -51,38 +42,12 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram }; public RetailLocalPlayerFrameController( - Func canPresentPlayer, - Func getController, - IMovementInputSource movementInput, - Func resolveLocalEntityId, - Action handleTargeting, - Func isHidden, - Action project, - Action sendPreNetwork, - Action sendPostNetwork, - Func? - objectClockDisposition = null) + ILocalPlayerFrameRuntime runtime, + IMovementInputSource movementInput) { - _canPresentPlayer = canPresentPlayer - ?? throw new ArgumentNullException(nameof(canPresentPlayer)); - _getController = getController - ?? throw new ArgumentNullException(nameof(getController)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _movementInput = movementInput ?? throw new ArgumentNullException(nameof(movementInput)); - _resolveLocalEntityId = resolveLocalEntityId - ?? throw new ArgumentNullException(nameof(resolveLocalEntityId)); - _handleTargeting = handleTargeting - ?? throw new ArgumentNullException(nameof(handleTargeting)); - _isHidden = isHidden - ?? throw new ArgumentNullException(nameof(isHidden)); - _project = project - ?? throw new ArgumentNullException(nameof(project)); - _sendPreNetwork = sendPreNetwork - ?? throw new ArgumentNullException(nameof(sendPreNetwork)); - _sendPostNetwork = sendPostNetwork - ?? throw new ArgumentNullException(nameof(sendPostNetwork)); - _objectClockDisposition = objectClockDisposition - ?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance); } /// @@ -92,15 +57,15 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram public void AdvanceBeforeNetwork(float deltaSeconds) { _advancedFrame = null; - PlayerMovementController? controller = _getController(); - if (!_canPresentPlayer() || controller is null) + PlayerMovementController? controller = _runtime.Controller; + if (!_runtime.CanPresentPlayer || controller is null) return; if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f) { - bool rejectedHidden = _isHidden(); + bool rejectedHidden = _runtime.IsHidden; MovementResult rejected = controller.CapturePresentationResult(); - _project(controller, rejected, rejectedHidden); + _runtime.Project(controller, rejected, rejectedHidden); _advancedFrame = new AdvancedFrame( controller, rejected, @@ -110,10 +75,10 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram return; } - controller.LocalEntityId = _resolveLocalEntityId(); + controller.LocalEntityId = _runtime.ResolveLocalEntityId(); - bool hidden = _isHidden(); - if (_objectClockDisposition() + bool hidden = _runtime.IsHidden; + if (_runtime.ObjectClockDisposition is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend) { // CPhysicsObj::update_object returns before advancing time for a @@ -122,7 +87,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram // physics tick that retail did not run. controller.SuspendObjectUpdate(deltaSeconds); MovementResult paused = controller.CapturePresentationResult(); - _project(controller, paused, hidden); + _runtime.Project(controller, paused, hidden); _advancedFrame = new AdvancedFrame( controller, paused, @@ -133,11 +98,14 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram } MovementResult movement = hidden - ? controller.TickHidden(deltaSeconds, _handleTargeting) - : controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting); + ? controller.TickHidden(deltaSeconds, _runtime.HandleTargeting) + : controller.Update( + deltaSeconds, + _movementInput.Capture(), + _runtime.HandleTargeting); - _project(controller, movement, hidden); - _sendPreNetwork(controller, movement, hidden); + _runtime.Project(controller, movement, hidden); + _runtime.SendPreNetwork(controller, movement, hidden); _advancedFrame = new AdvancedFrame( controller, movement, @@ -153,18 +121,18 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram /// public void RunPostNetworkCommandPhase() { - PlayerMovementController? controller = _getController(); - if (!_canPresentPlayer() || controller is null) + PlayerMovementController? controller = _runtime.Controller; + if (!_runtime.CanPresentPlayer || controller is null) return; - bool hidden = _isHidden(); + bool hidden = _runtime.IsHidden; if (_advancedFrame is { } advanced && ReferenceEquals(advanced.Controller, controller)) { MovementResult movement = RefreshSpatialFields( advanced.Movement, controller); - _project(controller, movement, hidden); + _runtime.Project(controller, movement, hidden); _advancedFrame = new AdvancedFrame( controller, movement, @@ -176,7 +144,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram return; } - _sendPostNetwork(controller, hidden); + _runtime.SendPostNetwork(controller, hidden); } /// @@ -187,11 +155,11 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram public bool TryGetPresentationAfterNetwork(out PresentationFrame frame) { frame = default; - PlayerMovementController? controller = _getController(); - if (!_canPresentPlayer() || controller is null) + PlayerMovementController? controller = _runtime.Controller; + if (!_runtime.CanPresentPlayer || controller is null) return false; - bool hidden = _isHidden(); + bool hidden = _runtime.IsHidden; if (_advancedFrame is { } advanced && ReferenceEquals(advanced.Controller, controller)) { @@ -206,7 +174,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram } MovementResult initial = controller.CapturePresentationResult(); - _project(controller, initial, hidden); + _runtime.Project(controller, initial, hidden); frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false); return true; } diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 05df1a4d..cf0e24ae 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -48,6 +48,7 @@ internal interface IWorldSelectionQuery bool IsUseable(uint serverGuid); bool IsPickupable(uint serverGuid); bool TryGetApproach(uint serverGuid, out InteractionApproach approach); + Vector3? GetCombatCameraTargetPoint(uint serverGuid); } /// diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs new file mode 100644 index 00000000..ff56239d --- /dev/null +++ b/src/AcDream.App/Rendering/CameraFrameController.cs @@ -0,0 +1,114 @@ +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Update; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +/// +/// Owns the per-update fly/chase camera publication tail. +/// +/// +/// The local object phase normally publishes the player before inbound +/// traffic. When the player is first created by that inbound pass, the local +/// frame publishes its initial root here and the spatial reconciler refreshes +/// child/effect anchors before either chase camera samples it. +/// +internal sealed class CameraFrameController : ICameraFramePhase +{ + private readonly CameraController _camera; + private readonly IInputCaptureSource _capture; + private readonly ICameraFrameInputSource _input; + private readonly ILocalPlayerPresentationRuntime _player; + private readonly IChaseCameraSource _chase; + private readonly RetailLocalPlayerFrameController _localFrame; + private readonly ILiveSpatialReconcilePhase _spatialReconciler; + private readonly ICombatCameraTargetSource _combatTarget; + + public CameraFrameController( + CameraController camera, + IInputCaptureSource capture, + ICameraFrameInputSource input, + ILocalPlayerPresentationRuntime player, + IChaseCameraSource chase, + RetailLocalPlayerFrameController localFrame, + ILiveSpatialReconcilePhase spatialReconciler, + ICombatCameraTargetSource combatTarget) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _localFrame = localFrame ?? throw new ArgumentNullException(nameof(localFrame)); + _spatialReconciler = spatialReconciler + ?? throw new ArgumentNullException(nameof(spatialReconciler)); + _combatTarget = combatTarget ?? throw new ArgumentNullException(nameof(combatTarget)); + } + + public void Tick(UpdateFrameTiming timing) + { + if (_capture.DevToolsWantCaptureKeyboard || !_input.IsAvailable) + return; + + if (_camera.IsFlyMode) + { + FlyCameraInput input = _input.CaptureFly(); + _camera.Fly.Update( + timing.SimulationDeltaSeconds, + input.Forward, + input.Left, + input.Backward, + input.Right, + input.Up, + input.Down, + input.Boost); + return; + } + + PlayerMovementController? controller = _player.Controller; + ChaseCamera? legacy = _chase.Legacy; + RetailChaseCamera? retail = _chase.Retail; + if (!_player.CanPresentPlayer || controller is null || legacy is null) + return; + + if (CameraDiagnostics.UseRetailChaseCamera && retail is not null) + { + ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment(); + float adjustment = CameraDiagnostics.CameraAdjustmentSpeed + * timing.SimulationDeltaSecondsSingle; + if (input.ZoomIn) + retail.AdjustDistance(-adjustment); + if (input.ZoomOut) + retail.AdjustDistance(+adjustment); + if (input.Raise) + retail.AdjustPitch(+adjustment * 0.02f); + if (input.Lower) + retail.AdjustPitch(-adjustment * 0.02f); + } + + if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame)) + return; + + if (!playerFrame.AdvancedBeforeNetwork) + _spatialReconciler.Reconcile(); + + MovementResult result = playerFrame.Movement; + legacy.Update( + result.RenderPosition, + controller.Yaw, + isOnGround: result.IsOnGround, + dt: timing.SimulationDeltaSecondsSingle); + + retail?.Update( + result.RenderPosition, + controller.Yaw, + playerVelocity: controller.BodyVelocity, + isOnGround: result.IsOnGround, + contactPlaneNormal: controller.ContactPlane.Normal, + dt: timing.SimulationDeltaSecondsSingle, + cellId: controller.CellId, + selfEntityId: controller.LocalEntityId, + trackedTargetPoint: _combatTarget.GetTrackedTargetPoint()); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7d85fe39..3e8f2ded 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -170,11 +170,10 @@ public sealed class GameWindow : IDisposable _inboundEntityEvents = new(); private LiveEntityAnimationScheduler _liveAnimationScheduler = null!; private LiveEntityAnimationPresenter _animationPresenter = null!; - private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.MovementTruthDiagnosticController _movementTruthDiagnostics; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; - private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; + private AcDream.App.Update.ICameraFramePhase _cameraFrame = null!; private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!; private AcDream.App.Update.LiveSpatialPresentationReconciler _liveSpatialReconciler = null!; @@ -619,6 +618,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.RetainedUiInputCaptureSlot _retainedInputCapture; private readonly AcDream.App.Input.CompositeInputCaptureSource _inputCapture; private readonly AcDream.App.Input.DispatcherMovementInputSource _movementInput; + private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new(); private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor; private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame; // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, @@ -759,18 +759,6 @@ public sealed class GameWindow : IDisposable movement, cellId, update), (host, targetGuid) => _liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid)); - _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( - resolveEntity: () => - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) - ? entity - : null, - liveCenterX: () => _liveCenterX, - liveCenterY: () => _liveCenterY, - syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId), - rebucket: (serverGuid, landblockId) => - _liveEntities?.RebucketLiveEntity(serverGuid, landblockId), - isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection, - suspendShadow: SuspendLocalPlayerShadow); _movementTruthDiagnostics = new AcDream.App.Input.MovementTruthDiagnosticController( options.DumpMoveTruth, @@ -778,32 +766,6 @@ public sealed class GameWindow : IDisposable _localPlayerIdentity); _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController( _movementTruthDiagnostics); - _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController( - canPresentPlayer: CanAdvanceLocalPlayer, - getController: () => _playerController, - movementInput: _movementInput, - resolveLocalEntityId: () => - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) - ? entity.Id - : 0u, - handleTargeting: () => _playerHost?.HandleTargetting(), - isHidden: () => _liveEntities?.IsHidden(_playerServerGuid) == true, - project: (controller, movement, hidden) => - _localPlayerProjection.Project(controller, movement, hidden), - sendPreNetwork: (controller, movement, hidden) => - _localPlayerOutbound.SendPreNetworkActions( - LiveSession, - controller, - movement, - hidden), - sendPostNetwork: (controller, hidden) => - _localPlayerOutbound.SendPostNetworkPosition( - LiveSession, - controller, - hidden), - objectClockDisposition: () => - _liveEntities?.GetRootObjectClockDisposition(_playerServerGuid) - ?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance); } public void Run() @@ -896,6 +858,7 @@ public sealed class GameWindow : IDisposable _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); _movementInput.Bind(_inputDispatcher); + _cameraInput.Bind(_inputDispatcher); _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); _inputDispatcher.Fired += OnInputAction; Combat.CombatModeChanged += SetInputCombatScope; @@ -2675,19 +2638,6 @@ public sealed class GameWindow : IDisposable new AcDream.App.Update.SettingsParticleRangeSource( _settingsVm, _persistedDisplay.ParticleRange)); - var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController( - _inboundEntityEvents, - _localPlayerFrame, - _selectionInteractions, - _liveEntities, - _localPlayerIdentity, - _liveWorldOrigin, - _liveAnimationScheduler, - _staticAnimationScheduler, - _animationPresenter, - _animatedEntities, - _equippedChildRenderer!, - liveEffectFrame); _liveSpatialReconciler = new AcDream.App.Update.LiveSpatialPresentationReconciler( _entityEffects!, @@ -2708,6 +2658,44 @@ public sealed class GameWindow : IDisposable _localPlayerIdentity, _liveWorldOrigin, _localPlayerShadow); + var localPlayerProjection = + new AcDream.App.Input.LocalPlayerProjectionController( + new AcDream.App.Input.LiveLocalPlayerProjectionRuntime( + _liveEntities, + _localPlayerIdentity, + _liveWorldOrigin, + _localPlayerShadowSynchronizer)); + var localPlayerFrameRuntime = + new AcDream.App.Input.LiveLocalPlayerFrameRuntime( + _cameraController!, + _localPlayerMode, + _playerControllerSlot, + _chaseCameraInput, + _movementInput, + _inputCapture, + _liveEntities, + _localPlayerIdentity, + _playerHostSlot, + localPlayerProjection, + _localPlayerOutbound, + _liveSessionController); + var localPlayerFrame = + new AcDream.App.Input.RetailLocalPlayerFrameController( + localPlayerFrameRuntime, + _movementInput); + var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController( + _inboundEntityEvents, + localPlayerFrame, + _selectionInteractions, + _liveEntities, + _localPlayerIdentity, + _liveWorldOrigin, + _liveAnimationScheduler, + _staticAnimationScheduler, + _animationPresenter, + _animatedEntities, + _equippedChildRenderer!, + liveEffectFrame); _viewportAspect.Update(_window!.Size.X, _window.Size.Y); _playerModeController = new AcDream.App.Input.PlayerModeController( _localPlayerMode, @@ -2783,8 +2771,21 @@ public sealed class GameWindow : IDisposable liveObjectFrame, _worldState, _liveSessionController, - _localPlayerFrame, + localPlayerFrame, _liveSpatialReconciler); + _cameraFrame = new AcDream.App.Rendering.CameraFrameController( + _cameraController!, + _inputCapture, + _cameraInput, + localPlayerFrameRuntime, + _chaseCameraInput, + localPlayerFrame, + _liveSpatialReconciler, + new AcDream.App.Combat.CombatCameraTargetSource( + _gameplaySettings, + Combat, + _selection, + _worldSelectionQuery!)); AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionController.Start( _options, @@ -3288,59 +3289,6 @@ public sealed class GameWindow : IDisposable timestamps.ForcePosition); } - private void SyncLocalPlayerShadow( - AcDream.Core.World.WorldEntity playerEntity, - uint cellId, - bool force = false) - { - if (_liveEntities?.IsHidden(_playerServerGuid) == true - || cellId == 0 - || !IsCurrentVisibleLocalPlayerProjection(playerEntity)) - { - SuspendLocalPlayerShadow(playerEntity); - return; - } - - if (!force - && _localPlayerShadow.Current is { } last - && last.CellId == cellId - && System.Numerics.Vector3.DistanceSquared( - last.Position, playerEntity.Position) <= 1e-4f - && MathF.Abs(System.Numerics.Quaternion.Dot( - last.Orientation, playerEntity.Rotation)) >= 0.99999f) - { - return; - } - - AcDream.App.Physics.ShadowPositionSynchronizer.Sync( - _physicsEngine.ShadowObjects, - playerEntity.Id, - playerEntity.Position, - playerEntity.Rotation, - cellId, - _liveCenterX, - _liveCenterY); - _localPlayerShadow.Set( - playerEntity.Position, - playerEntity.Rotation, - cellId); - } - - private bool IsCurrentVisibleLocalPlayerProjection( - AcDream.Core.World.WorldEntity playerEntity) => - _liveEntities is { } runtime - && runtime.TryGetRecord(playerEntity.ServerGuid, out var record) - && ReferenceEquals(record.WorldEntity, playerEntity) - && runtime.IsCurrentSpatialRootObject(record); - - private void SuspendLocalPlayerShadow( - AcDream.Core.World.WorldEntity playerEntity) - { - _physicsEngine.ShadowObjects.Suspend(playerEntity.Id); - _localPlayerShadow.Clear(); - } - - // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player // body's cell-relative CellPosition. This is the ONE place the streaming center // (_liveCenter) is allowed to touch the physics frame — at the placement seam, @@ -3584,7 +3532,6 @@ public sealed class GameWindow : IDisposable AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance( new AcDream.App.Update.UpdateFrameInput(dt)); - double frameSeconds = frameTiming.SimulationDeltaSeconds; float frameDelta = frameTiming.SimulationDeltaSecondsSingle; // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps @@ -3634,131 +3581,9 @@ public sealed class GameWindow : IDisposable // OnInputAction (Ctrl+Tab) or DebugPanel. _playerModeAutoEntry?.TryEnter(); - if (_cameraController is null || _input is null) return; - - // Phase D.2a / K.1b — suppress game-side input polling when ImGui - // has keyboard focus (e.g. a text field is active). The InputDispatcher - // already gates KeyDown/MouseDown via WantCaptureKeyboard internally; - // this guard adds defense-in-depth for the per-frame IsActionHeld - // movement poll below (typing "walk" into a chat field shouldn't - // walk). - // ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a - // debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block - // below still runs so AUTORUN keeps driving the character while you type. Held WASD - // is silenced at the source instead — InputDispatcher.IsActionHeld returns false - // while WantCaptureKeyboard (which includes a focused chat input) is set. - bool suppressGameInput = - DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; - if (suppressGameInput) return; - - if (_cameraController.IsFlyMode) - { - // K.1b: fly-camera input flows through the dispatcher. Reuses - // movement actions (Forward/Backup/TurnLeft/TurnRight/Jump/ - // RunLock) — in fly mode "TurnLeft" semantically maps to - // strafe-left because A/D in fly is strafe, not turn (mouse - // delta drives fly heading instead). - if (_inputDispatcher is null) return; - _cameraController.Fly.Update( - frameSeconds, - w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward), - a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), - s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup), - d: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), - up: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump), - down: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.AcdreamFlyDown), - boost: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock)); - } - else if (_playerMode && _playerController is not null && _chaseCamera is not null) - { - // Phase B.2 / K.1b: player movement mode — every input flows - // through the dispatcher. WASD walks/runs, A/D turns, Z/X - // strafes, Shift runs, Space jumps. Mouse delta NEVER drives - // character yaw (regression-prevention per K.1b plan §D); - // MouseDeltaX is hardcoded 0f. RMB held still pans the chase - // camera (handled in the mouse-move handler via _rmbHeld). - if (_inputDispatcher is null) return; - - // Retail-style held-key offset integration. Only active when - // retail chase is selected; legacy camera ignores these. - if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null) - { - float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * frameDelta; - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn)) - _retailChaseCamera.AdjustDistance(-adj); - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut)) - _retailChaseCamera.AdjustDistance(+adj); - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraRaise)) - _retailChaseCamera.AdjustPitch(+adj * 0.02f); - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraLower)) - _retailChaseCamera.AdjustPitch(-adj * 0.02f); - } - - // K-fix1 (2026-04-26): retail-faithful movement semantics. - // * Default speed = RUN. Forward / backward / strafe all run - // by default; holding Shift (MovementWalkMode) drops to - // walk speed. - // * Q = AUTORUN TOGGLE: pressing Q latches forward-running - // until Q is pressed again. Handled in OnInputAction; here - // DispatcherMovementInputSource owns the autorun latch. - // * Mouse never drives character yaw (K.1b regression-prevention). - if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame)) - return; - - AcDream.App.Input.MovementResult result = playerFrame.Movement; - bool localPlayerHidden = playerFrame.Hidden; - if (!playerFrame.AdvancedBeforeNetwork) - { - // The player was materialized by this frame's inbound pass. - // Its non-advancing initial projection must publish child and - // effect anchors before the first draw too. - _liveSpatialReconciler.Reconcile(); - } - - // Update chase camera(s). The CameraController exposes whichever - // is currently selected via CameraDiagnostics.UseRetailChaseCamera; - // both update every frame so toggling the flag swaps instantly - // with the new camera already warm. - // - // Legacy ChaseCamera: pre-K-fix12 args (isOnGround pins Z during - // jumps as a workaround for the visual feel retail gets from - // low-stiffness damping). - _chaseCamera.Update(result.RenderPosition, _playerController.Yaw, - isOnGround: result.IsOnGround, - dt: frameDelta); - // RetailChaseCamera: heading is the player's facing direction - // projected onto the contact plane when grounded, or the - // world XY plane when airborne. The contact plane normal - // tilts the camera basis with terrain; the airborne - // fallback keeps the basis horizontal during jumps so the - // player visibly rises in frame without the camera - // swinging vertically (was the symptom of using raw - // velocity-vector heading). - System.Numerics.Vector3? trackedCombatTarget = GetCombatCameraTargetPoint(); - _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw, - playerVelocity: _playerController.BodyVelocity, - isOnGround: result.IsOnGround, - contactPlaneNormal: _playerController.ContactPlane.Normal, - dt: frameDelta, - cellId: _playerController.CellId, - selfEntityId: _playerController.LocalEntityId, - trackedTargetPoint: trackedCombatTarget); - - - // Update the player entity's animation cycle to match current motion. - } + _cameraFrame.Tick(frameTiming); } - private bool CanAdvanceLocalPlayer() => - _cameraController is not null - && _input is not null - && !_cameraController.IsFlyMode - && _playerMode - && _playerController is not null - && _chaseCamera is not null - && _inputDispatcher is not null - && !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); - private void OnCameraModeChanged(bool _modeBool) { if (_gameplayInputFrame?.MouseLookActive == true @@ -6800,22 +6625,6 @@ public sealed class GameWindow : IDisposable AcDream.App.Input.MovementResult result) => AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result); - /// - /// Resolves retail's combat-camera target. ClientCombatSystem:: - /// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only - /// for melee/missile combat with the option enabled and a valid attack - /// target. TrackTarget applies target-local offset (0,0,0.5). - /// - private System.Numerics.Vector3? GetCombatCameraTargetPoint() - { - if (!_persistedGameplay.ViewCombatTarget - || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode) - || _selection.SelectedObjectId is not uint selected) - return null; - - return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected); - } - /// /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs diff --git a/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs new file mode 100644 index 00000000..40932669 --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs @@ -0,0 +1,69 @@ +using System.Numerics; +using AcDream.App.Combat; +using AcDream.App.Interaction; +using AcDream.Core.Combat; +using AcDream.Core.Selection; + +namespace AcDream.App.Tests.Combat; + +public sealed class CombatCameraTargetSourceTests +{ + [Fact] + public void GetTrackedTargetPoint_RequiresOptionTargetedModeAndSelection() + { + const uint target = 0x70000001u; + Vector3 point = new(10f, 20f, 30f); + var settings = new GameplaySettingsState(); + var combat = new CombatState(); + var selection = new SelectionState(); + var world = new TargetQuery(point); + var source = new CombatCameraTargetSource( + settings, + combat, + selection, + world); + + Assert.Null(source.GetTrackedTargetPoint()); + + settings.Value = settings.Value with { ViewCombatTarget = true }; + combat.SetCombatMode(CombatMode.Melee); + selection.Select(target, SelectionChangeSource.World); + + Assert.Equal(point, source.GetTrackedTargetPoint()); + Assert.Equal(target, world.LastGuid); + + combat.SetCombatMode(CombatMode.Magic); + Assert.Null(source.GetTrackedTargetPoint()); + } + + private sealed class TargetQuery(Vector3 point) : IWorldSelectionQuery + { + public uint LastGuid { get; private set; } + public Vector3? GetCombatCameraTargetPoint(uint serverGuid) + { + LastGuid = serverGuid; + return point; + } + + public uint? PickAtCursor(bool includeSelf) => null; + public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => null; + public void BeginLightingPulse(uint serverGuid) { } + public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId) + { + localEntityId = 0; + return false; + } + public bool IsCurrent(uint serverGuid, uint localEntityId) => false; + public string Describe(uint serverGuid) => string.Empty; + public bool IsCreature(uint serverGuid) => false; + public bool IsHostileMonster(uint serverGuid) => false; + public ClosestCombatTarget? FindClosestHostileMonster() => null; + public bool IsUseable(uint serverGuid) => false; + public bool IsPickupable(uint serverGuid) => false; + public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) + { + approach = default; + return false; + } + } +} diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs index 41109816..16532049 100644 --- a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs +++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs @@ -120,6 +120,7 @@ public sealed class LiveCombatAttackOperationsTests { public bool AutoTarget { get; set; } public bool AutoRepeatAttack { get; set; } + public bool ViewCombatTarget { get; set; } } private sealed class FakeLiveSource : ILiveInWorldSource, ILiveWorldSessionSource diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs index 20575243..b712d4ec 100644 --- a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs @@ -26,14 +26,14 @@ public sealed class LocalPlayerProjectionControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; - var projection = new LocalPlayerProjectionController( + var projection = new LocalPlayerProjectionController(new TestProjectionRuntime( () => entity, () => 1, () => 1, (_, _) => { }, (_, _) => { }, _ => true, - _ => { }); + _ => { })); projection.Project( movement, @@ -64,14 +64,14 @@ public sealed class LocalPlayerProjectionControllerTests MeshRefs = Array.Empty(), }; var order = new List(); - var projection = new LocalPlayerProjectionController( + var projection = new LocalPlayerProjectionController(new TestProjectionRuntime( () => entity, () => 2, () => 2, (_, _) => order.Add("shadow"), (_, _) => order.Add("rebucket"), _ => false, - _ => order.Add("suspend")); + _ => order.Add("suspend"))); projection.Project( movement, @@ -100,7 +100,7 @@ public sealed class LocalPlayerProjectionControllerTests }; bool visible = false; var order = new List(); - var projection = new LocalPlayerProjectionController( + var projection = new LocalPlayerProjectionController(new TestProjectionRuntime( () => entity, () => 2, () => 2, @@ -111,7 +111,7 @@ public sealed class LocalPlayerProjectionControllerTests visible = true; }, _ => visible, - _ => order.Add("suspend")); + _ => order.Add("suspend"))); projection.Project( movement, @@ -120,4 +120,25 @@ public sealed class LocalPlayerProjectionControllerTests Assert.Equal(["rebucket", "shadow"], order); } + + private sealed class TestProjectionRuntime( + Func resolveEntity, + Func liveCenterX, + Func liveCenterY, + Action syncShadow, + Action rebucket, + Func isCurrentVisibleProjection, + Action suspendShadow) : ILocalPlayerProjectionRuntime + { + public WorldEntity? ResolveEntity() => resolveEntity(); + public int LiveCenterX => liveCenterX(); + public int LiveCenterY => liveCenterY(); + public void SyncShadow(WorldEntity entity, uint cellId) => + syncShadow(entity, cellId); + public void Rebucket(uint serverGuid, uint landblockId) => + rebucket(serverGuid, landblockId); + public bool IsCurrentVisibleProjection(WorldEntity entity) => + isCurrentVisibleProjection(entity); + public void SuspendShadow(WorldEntity entity) => suspendShadow(entity); + } } diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs index 794f40e1..01ce901c 100644 --- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -15,7 +15,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var calls = new List(); var preNetwork = new List<(PlayerState State, MovementResult Movement)>(); var postNetwork = new List(); - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput(Forward: true)), @@ -79,7 +79,7 @@ public sealed class RetailLocalPlayerFrameControllerTests int projections = 0; int preNetwork = 0; int postNetwork = 0; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => controller is not null, getController: () => controller, movementInput: Input(() => new MovementInput(Forward: true)), @@ -109,7 +109,7 @@ public sealed class RetailLocalPlayerFrameControllerTests { PlayerMovementController controller = CreateController(); Vector3 projectedRoot = default; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput()), @@ -140,7 +140,7 @@ public sealed class RetailLocalPlayerFrameControllerTests PlayerMovementController controller = CreateController(); Vector3 initial = controller.Position; Vector3 positionSeenByTargetManager = new(float.NaN); - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput(Forward: true)), @@ -166,7 +166,7 @@ public sealed class RetailLocalPlayerFrameControllerTests int projections = 0; int preNetwork = 0; int postNetwork = 0; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => @@ -201,7 +201,7 @@ public sealed class RetailLocalPlayerFrameControllerTests public void HiddenPoseIsDirtyOnlyWhenACompleteObjectQuantumRuns() { PlayerMovementController controller = CreateController(); - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput()), @@ -228,7 +228,7 @@ public sealed class RetailLocalPlayerFrameControllerTests int projections = 0; int preNetwork = 0; int postNetwork = 0; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => @@ -297,6 +297,65 @@ public sealed class RetailLocalPlayerFrameControllerTests private static IMovementInputSource Input(Func capture) => new TestMovementInputSource(capture); + private static RetailLocalPlayerFrameController CreateFrame( + Func canPresentPlayer, + Func getController, + IMovementInputSource movementInput, + Func resolveLocalEntityId, + Action handleTargeting, + Func isHidden, + Action project, + Action sendPreNetwork, + Action sendPostNetwork, + Func? objectClockDisposition = null) => + new( + new TestLocalPlayerFrameRuntime( + canPresentPlayer, + getController, + resolveLocalEntityId, + handleTargeting, + isHidden, + project, + sendPreNetwork, + sendPostNetwork, + objectClockDisposition), + movementInput); + + private sealed class TestLocalPlayerFrameRuntime( + Func canPresentPlayer, + Func getController, + Func resolveLocalEntityId, + Action handleTargeting, + Func isHidden, + Action project, + Action sendPreNetwork, + Action sendPostNetwork, + Func? objectClockDisposition) + : ILocalPlayerFrameRuntime + { + public bool CanPresentPlayer => canPresentPlayer(); + public PlayerMovementController? Controller => getController(); + public uint ResolveLocalEntityId() => resolveLocalEntityId(); + public void HandleTargeting() => handleTargeting(); + public bool IsHidden => isHidden(); + public RetailObjectClockDisposition ObjectClockDisposition => + objectClockDisposition?.Invoke() ?? RetailObjectClockDisposition.Advance; + + public void Project( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => project(controller, movement, hidden); + + public void SendPreNetwork( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => sendPreNetwork(controller, movement, hidden); + + public void SendPostNetwork( + PlayerMovementController controller, + bool hidden) => sendPostNetwork(controller, hidden); + } + private sealed class TestMovementInputSource(Func capture) : IMovementInputSource { diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 75963f82..18d0379b 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -55,6 +55,7 @@ public sealed class SelectionInteractionControllerTests public ClosestCombatTarget? FindClosestHostileMonster() => Closest; public bool IsUseable(uint serverGuid) => Useable; public bool IsPickupable(uint serverGuid) => Pickupable; + public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null; public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) { diff --git a/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs new file mode 100644 index 00000000..732906b3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs @@ -0,0 +1,231 @@ +using System.Numerics; +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Update; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Rendering; + +public sealed class CameraFrameControllerTests +{ + [Fact] + public void FlyMode_UsesOneSemanticHeldInputSnapshot() + { + CameraController camera = CreateCamera(); + camera.ToggleFly(); + Vector3 start = camera.Fly.Position; + var input = new InputSource + { + Fly = new FlyCameraInput( + Forward: true, + Left: false, + Backward: false, + Right: false, + Up: true, + Down: false, + Boost: false), + }; + CameraFrameController frame = CreateFrame(camera, input: input); + + frame.Tick(new UpdateFrameTiming(0.5, 0.5f, 0.5)); + + Assert.Equal(1, input.FlyCaptures); + Assert.InRange(camera.Fly.Position.Y - start.Y, 5.999f, 6.001f); + Assert.InRange(camera.Fly.Position.Z - start.Z, 5.999f, 6.001f); + } + + [Fact] + public void DevToolsKeyboardCapture_PausesFlyCamera() + { + CameraController camera = CreateCamera(); + camera.ToggleFly(); + Vector3 start = camera.Fly.Position; + var input = new InputSource + { + Fly = new FlyCameraInput(true, false, false, false, false, false, false), + }; + CameraFrameController frame = CreateFrame( + camera, + capture: new CaptureSource { DevToolsKeyboard = true }, + input: input); + + frame.Tick(new UpdateFrameTiming(1.0, 1f, 1.0)); + + Assert.Equal(start, camera.Fly.Position); + Assert.Equal(0, input.FlyCaptures); + } + + [Fact] + public void InboundCreatedPlayer_ProjectsThenReconcilesBeforeCameraPublication() + { + PlayerMovementController controller = CreatePlayer(); + var calls = new List(); + var runtime = new PlayerRuntime(controller, calls); + var localFrame = new RetailLocalPlayerFrameController( + runtime, + new StillMovementInput()); + CameraController camera = CreateCamera(); + var legacy = new ChaseCamera(); + var retail = new RetailChaseCamera(); + camera.EnterChaseMode(legacy, retail); + var chase = new ChaseSource(legacy, retail); + var reconciler = new Reconciler(calls); + var frame = new CameraFrameController( + camera, + new CaptureSource(), + new InputSource(), + runtime, + chase, + localFrame, + reconciler, + new CombatTargetSource()); + + frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0)); + + Assert.Equal(["project", "reconcile"], calls); + Assert.NotEqual(Vector3.Zero, legacy.Position); + Assert.NotEqual(Vector3.Zero, retail.Position); + } + + [Fact] + public void PreNetworkAdvancedPlayer_DoesNotRunTheInboundCreationReconcile() + { + PlayerMovementController controller = CreatePlayer(); + var calls = new List(); + var runtime = new PlayerRuntime(controller, calls); + var localFrame = new RetailLocalPlayerFrameController( + runtime, + new StillMovementInput()); + localFrame.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum); + calls.Clear(); + CameraController camera = CreateCamera(); + var legacy = new ChaseCamera(); + var retail = new RetailChaseCamera(); + camera.EnterChaseMode(legacy, retail); + var frame = new CameraFrameController( + camera, + new CaptureSource(), + new InputSource(), + runtime, + new ChaseSource(legacy, retail), + localFrame, + new Reconciler(calls), + new CombatTargetSource()); + + frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0)); + + Assert.Empty(calls); + } + + private static CameraFrameController CreateFrame( + CameraController camera, + CaptureSource? capture = null, + InputSource? input = null) + { + var runtime = new PlayerRuntime(null, []); + var localFrame = new RetailLocalPlayerFrameController( + runtime, + new StillMovementInput()); + return new CameraFrameController( + camera, + capture ?? new CaptureSource(), + input ?? new InputSource(), + runtime, + new ChaseSource(null, null), + localFrame, + new Reconciler([]), + new CombatTargetSource()); + } + + private static CameraController CreateCamera() => + new(new OrbitCamera(), new FlyCamera()); + + private static PlayerMovementController CreatePlayer() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u); + return controller; + } + + private sealed class CaptureSource : IInputCaptureSource + { + public bool DevToolsKeyboard { get; set; } + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => DevToolsKeyboard; + public bool DevToolsWantCaptureKeyboard => DevToolsKeyboard; + } + + private sealed class InputSource : ICameraFrameInputSource + { + public bool IsAvailable { get; set; } = true; + public FlyCameraInput Fly { get; set; } + public ChaseCameraAdjustmentInput Chase { get; set; } + public int FlyCaptures { get; private set; } + public FlyCameraInput CaptureFly() + { + FlyCaptures++; + return Fly; + } + public ChaseCameraAdjustmentInput CaptureChaseAdjustment() => Chase; + } + + private sealed class ChaseSource( + ChaseCamera? legacy, + RetailChaseCamera? retail) : IChaseCameraSource + { + public ChaseCamera? Legacy => legacy; + public RetailChaseCamera? Retail => retail; + } + + private sealed class StillMovementInput : IMovementInputSource + { + public MovementInput Capture() => default; + } + + private sealed class PlayerRuntime( + PlayerMovementController? controller, + List calls) : ILocalPlayerFrameRuntime + { + public bool CanPresentPlayer { get; set; } = controller is not null; + public PlayerMovementController? Controller => controller; + public uint ResolveLocalEntityId() => 7u; + public void HandleTargeting() { } + public bool IsHidden => false; + public RetailObjectClockDisposition ObjectClockDisposition => + RetailObjectClockDisposition.Advance; + public void Project( + PlayerMovementController owner, + MovementResult movement, + bool hidden) => calls.Add("project"); + public void SendPreNetwork( + PlayerMovementController owner, + MovementResult movement, + bool hidden) { } + public void SendPostNetwork(PlayerMovementController owner, bool hidden) { } + } + + private sealed class Reconciler(List calls) + : ILiveSpatialReconcilePhase + { + public void Reconcile() => calls.Add("reconcile"); + } + + private sealed class CombatTargetSource : ICombatCameraTargetSource + { + public Vector3? GetTrackedTargetPoint() => null; + } +} diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index ee92d1e7..12c1f907 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -619,6 +619,64 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); } + [Fact] + public void CameraAndLocalPlayerFrameOwnersUseTypedSeamsWithoutWindowCallbacks() + { + Type[] owners = + [ + typeof(AcDream.App.Rendering.CameraFrameController), + typeof(AcDream.App.Input.RetailLocalPlayerFrameController), + typeof(AcDream.App.Input.LocalPlayerProjectionController), + typeof(AcDream.App.Input.LiveLocalPlayerFrameRuntime), + typeof(AcDream.App.Input.LiveLocalPlayerProjectionRuntime), + typeof(AcDream.App.Combat.CombatCameraTargetSource), + ]; + + foreach (Type owner in owners) + { + FieldInfo[] fields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + string root = FindRepoRoot(); + string source = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + Assert.Equal(1, CountOccurrences(source, "_cameraFrame.Tick(frameTiming);")); + Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal); + Assert.DoesNotContain("GetCombatCameraTargetPoint()", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_cameraController.Fly.Update(", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_localPlayerFrame.TryGetPresentationAfterNetwork", source, + StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "_localPlayerTeleport!.Tick(frameDelta);", + "_playerModeAutoEntry?.TryEnter();", + "_cameraFrame.Tick(frameTiming);"); + + string cameraSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "CameraFrameController.cs")); + AssertAppearsInOrder( + cameraSource, + "_localFrame.TryGetPresentationAfterNetwork", + "_spatialReconciler.Reconcile();", + "legacy.Update(", + "retail?.Update("); + } + private static UpdateFrameOrchestrator Create( List calls, RecordingTeardown? teardown = null,