refactor(runtime): own local movement and outbound cadence
Move the canonical local movement controller, body/motion managers, object clock, movement wire data, and MTS/jump/AP sender into AcDream.Runtime. Replace process skill defaults with typed Runtime character options, make graphical and direct commands borrow one autorun owner, retain the construction-time PartArray seam, and include movement in terminal ownership convergence. Preserve the accepted pre-inbound movement/jump and post-inbound autonomous-position order while moving the exact packet/cadence fixtures into Runtime tests. Add graphical/direct parity, two-instance isolation, teardown, allocation, architecture, and divergence-path coverage. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
3456dff038
commit
aa3f4a60f8
36 changed files with 878 additions and 276 deletions
|
|
@ -883,8 +883,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.PlayerController,
|
||||
worldReveal,
|
||||
d.UpdateClock,
|
||||
live.SelectionInteractions,
|
||||
gameplayInput);
|
||||
live.SelectionInteractions);
|
||||
bindings.Adopt("current game runtime adapter", gameRuntime);
|
||||
bindings.Adopt(
|
||||
"retained-UI game runtime commands",
|
||||
|
|
|
|||
7
src/AcDream.App/GlobalUsings.cs
Normal file
7
src/AcDream.App/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
global using AcDream.Runtime.Gameplay;
|
||||
global using LocalPlayerControllerSlot =
|
||||
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
|
||||
global using ILocalPlayerControllerSource =
|
||||
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerControllerSource;
|
||||
global using ILocalPlayerMotionSource =
|
||||
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
|
||||
|
|
@ -8,18 +8,25 @@ internal interface IMovementInputSource
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns held movement sampling and the retail autorun latch. The input
|
||||
/// dispatcher remains the only keyboard/mouse-button state source.
|
||||
/// Samples held graphical input and maps press edges onto the Runtime-owned
|
||||
/// retail autorun latch. The dispatcher remains the only physical
|
||||
/// keyboard/mouse-button state source.
|
||||
/// </summary>
|
||||
internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
||||
{
|
||||
private readonly RuntimeLocalPlayerMovementState _movement;
|
||||
private readonly IInputCaptureSource? _capture;
|
||||
private InputDispatcher? _dispatcher;
|
||||
|
||||
public DispatcherMovementInputSource(IInputCaptureSource? capture = null) =>
|
||||
public DispatcherMovementInputSource(
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
IInputCaptureSource? capture = null)
|
||||
{
|
||||
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
||||
_capture = capture;
|
||||
}
|
||||
|
||||
public bool AutoRunActive { get; private set; }
|
||||
public bool AutoRunActive => _movement.AutoRunActive;
|
||||
public bool IsAvailable => _dispatcher is not null;
|
||||
|
||||
public void Bind(InputDispatcher dispatcher)
|
||||
|
|
@ -70,10 +77,8 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
public bool HandlePressedAction(InputAction action)
|
||||
{
|
||||
if (action == InputAction.MovementRunLock)
|
||||
{
|
||||
AutoRunActive = !AutoRunActive;
|
||||
return true;
|
||||
}
|
||||
return _movement.Execute(
|
||||
AcDream.Runtime.RuntimeMovementCommand.ToggleRunLock);
|
||||
|
||||
if (AutoRunActive && action is (
|
||||
InputAction.MovementBackup
|
||||
|
|
@ -81,11 +86,11 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
or InputAction.MovementStrafeLeft
|
||||
or InputAction.MovementStrafeRight))
|
||||
{
|
||||
AutoRunActive = false;
|
||||
_movement.CancelAutoRun();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ResetSession() => AutoRunActive = false;
|
||||
public void ResetSession() => _movement.ResetInputIntent();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,89 +16,6 @@ internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource
|
|||
public uint ServerGuid { get; set; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerControllerSource
|
||||
{
|
||||
PlayerMovementController? Controller { get; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerMotionSource
|
||||
{
|
||||
MotionInterpreter? Motion { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
|
||||
/// assignment; update and presentation owners receive the read-only seam.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerControllerSlot
|
||||
: ILocalPlayerControllerSource,
|
||||
ILocalPlayerMotionSource
|
||||
{
|
||||
private PlayerMovementController? _preparingMotionOwner;
|
||||
|
||||
public PlayerMovementController? Controller { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The motion owner visible to the local PartArray completion relay.
|
||||
/// During player-mode construction this is the fully animation-bound
|
||||
/// candidate controller; all other consumers continue to see only the
|
||||
/// committed <see cref="Controller"/>.
|
||||
/// </summary>
|
||||
MotionInterpreter? ILocalPlayerMotionSource.Motion =>
|
||||
_preparingMotionOwner?.Motion ?? Controller?.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the narrow construction-time ownership seam required by retail's
|
||||
/// CPhysicsObj/PartArray lifetime. Initial placement synchronously queues
|
||||
/// and completes StopCompletely, so its MotionDone relay must reach the
|
||||
/// candidate interpreter before the complete controller is published.
|
||||
/// </summary>
|
||||
public IDisposable BeginMotionPreparation(
|
||||
PlayerMovementController controller,
|
||||
Action? drainPriorAnimationQueue = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(controller);
|
||||
if (_preparingMotionOwner is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A local player motion owner is already being prepared.");
|
||||
}
|
||||
|
||||
// The live PartArray exists before the local player controller. Drain
|
||||
// any animation completions produced under that prior ownership while
|
||||
// they still resolve to the prior (or null) interpreter. Publishing
|
||||
// the fresh interpreter first would let those old callbacks pop its
|
||||
// new pending_motions queue and leave an unmatched Ready sentinel.
|
||||
drainPriorAnimationQueue?.Invoke();
|
||||
_preparingMotionOwner = controller;
|
||||
return new MotionPreparation(this, controller);
|
||||
}
|
||||
|
||||
private void EndMotionPreparation(PlayerMovementController controller)
|
||||
{
|
||||
if (!ReferenceEquals(_preparingMotionOwner, controller))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The local player motion preparation owner changed unexpectedly.");
|
||||
}
|
||||
|
||||
_preparingMotionOwner = null;
|
||||
}
|
||||
|
||||
private sealed class MotionPreparation(
|
||||
LocalPlayerControllerSlot owner,
|
||||
PlayerMovementController controller) : IDisposable
|
||||
{
|
||||
private LocalPlayerControllerSlot? _owner = owner;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LocalPlayerControllerSlot? current = Interlocked.Exchange(ref _owner, null);
|
||||
current?.EndMotionPreparation(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerPhysicsHostSource
|
||||
{
|
||||
EntityPhysicsHost? Host { get; }
|
||||
|
|
|
|||
|
|
@ -3,23 +3,6 @@ using AcDream.Core.Net;
|
|||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IMovementTruthDiagnosticSink
|
||||
{
|
||||
void OnOutbound(
|
||||
string kind,
|
||||
uint sequence,
|
||||
MovementResult result,
|
||||
Vector3 wirePosition,
|
||||
uint wireCellId,
|
||||
byte contactByte);
|
||||
|
||||
void OnServerEcho(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
Vector3 serverWorldPosition);
|
||||
|
||||
void ResetSession();
|
||||
}
|
||||
|
||||
internal sealed class MovementTruthDiagnosticController
|
||||
: IMovementTruthDiagnosticSink
|
||||
{
|
||||
|
|
@ -129,33 +112,3 @@ internal sealed class MovementTruthDiagnosticController
|
|||
? FormattableString.Invariant($"0x{command.Value:X8}")
|
||||
: "-";
|
||||
}
|
||||
|
||||
internal sealed class DelegateMovementTruthDiagnosticSink
|
||||
: IMovementTruthDiagnosticSink
|
||||
{
|
||||
private readonly Action<string, uint, MovementResult, Vector3, uint, byte>
|
||||
_outbound;
|
||||
|
||||
public DelegateMovementTruthDiagnosticSink(
|
||||
Action<string, uint, MovementResult, Vector3, uint, byte> outbound) =>
|
||||
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
||||
|
||||
public void OnOutbound(
|
||||
string kind,
|
||||
uint sequence,
|
||||
MovementResult result,
|
||||
Vector3 wirePosition,
|
||||
uint wireCellId,
|
||||
byte contactByte) =>
|
||||
_outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
|
||||
|
||||
public void OnServerEcho(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
Vector3 serverWorldPosition)
|
||||
{
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,7 +256,8 @@ internal sealed class PlayerModeController :
|
|||
{
|
||||
var controller = new PlayerMovementController(
|
||||
_physics,
|
||||
playerRecord.ObjectClock);
|
||||
playerRecord.ObjectClock,
|
||||
PlayerMovementConstructionOptions.From(_skills.Snapshot));
|
||||
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
|
||||
|
||||
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ public sealed class GameWindow :
|
|||
private LiveEntityAnimationPresenter _animationPresenter = null!;
|
||||
private readonly AcDream.App.Input.MovementTruthDiagnosticController
|
||||
_movementTruthDiagnostics;
|
||||
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
|
||||
private readonly LocalPlayerOutboundController _localPlayerOutbound;
|
||||
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
|
||||
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||
// Step 7 projectile presentation. The controller owns no identity map;
|
||||
|
|
@ -431,8 +431,8 @@ public sealed class GameWindow :
|
|||
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
|
||||
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
|
||||
// Phase B.2: player movement mode.
|
||||
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
|
||||
private AcDream.App.Input.PlayerMovementController? _playerController
|
||||
private readonly RuntimeLocalPlayerMovementState _playerControllerSlot = new();
|
||||
private PlayerMovementController? _playerController
|
||||
=> _playerControllerSlot.Controller;
|
||||
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
|
||||
private AcDream.App.Rendering.ChaseCamera? _chaseCamera
|
||||
|
|
@ -586,6 +586,7 @@ public sealed class GameWindow :
|
|||
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
|
||||
_retainedInputCapture);
|
||||
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
|
||||
_playerControllerSlot,
|
||||
_inputCapture);
|
||||
_framebufferResize = new FramebufferResizeController(_viewportAspect);
|
||||
_datDir = options.DatDir;
|
||||
|
|
@ -620,7 +621,7 @@ public sealed class GameWindow :
|
|||
options.DumpMoveTruth,
|
||||
_playerControllerSlot,
|
||||
_localPlayerIdentity);
|
||||
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
|
||||
_localPlayerOutbound = new LocalPlayerOutboundController(
|
||||
_movementTruthDiagnostics);
|
||||
}
|
||||
|
||||
|
|
@ -1587,6 +1588,7 @@ public sealed class GameWindow :
|
|||
_runtimeCharacter,
|
||||
_runtimeCommunication,
|
||||
_runtimeActions,
|
||||
_playerControllerSlot,
|
||||
_renderSceneShadow,
|
||||
_livePresentationBindings,
|
||||
_entityEffectAdvance,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ internal sealed record LiveShutdownRoots(
|
|||
RuntimeCharacterState Character,
|
||||
RuntimeCommunicationState Communication,
|
||||
RuntimeActionState Actions,
|
||||
RuntimeLocalPlayerMovementState Movement,
|
||||
RenderSceneShadowRuntime? RenderSceneShadow,
|
||||
LivePresentationRuntimeBindings? PresentationBindings,
|
||||
DeferredEntityEffectAdvanceSource EffectAdvance,
|
||||
|
|
@ -392,6 +393,9 @@ internal static class GameWindowShutdownManifest
|
|||
]),
|
||||
new ResourceShutdownStage("runtime entity/object stream",
|
||||
[
|
||||
Hard(
|
||||
"runtime local movement state",
|
||||
live.Movement.Dispose),
|
||||
Hard(
|
||||
"runtime action state",
|
||||
live.Actions.Dispose),
|
||||
|
|
|
|||
|
|
@ -38,11 +38,10 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
RuntimeCharacterState character,
|
||||
RuntimeCommunicationState communication,
|
||||
RuntimeActionState actions,
|
||||
ILocalPlayerControllerSource playerController,
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
WorldRevealCoordinator worldReveal,
|
||||
IGameRuntimeClock clock,
|
||||
SelectionInteractionController selection,
|
||||
GameplayInputFrameController gameplayInput)
|
||||
SelectionInteractionController selection)
|
||||
{
|
||||
_view = new CurrentGameRuntimeViewAdapter(
|
||||
session,
|
||||
|
|
@ -53,7 +52,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
character,
|
||||
communication,
|
||||
actions,
|
||||
playerController,
|
||||
movement,
|
||||
worldReveal,
|
||||
clock);
|
||||
entityObjects.BindEventContext(
|
||||
|
|
@ -71,8 +70,8 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
inventory,
|
||||
character,
|
||||
actions,
|
||||
movement,
|
||||
selection,
|
||||
gameplayInput,
|
||||
_events);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
private readonly RuntimeInventoryState _inventory;
|
||||
private readonly RuntimeCharacterState _character;
|
||||
private readonly RuntimeActionState _actions;
|
||||
private readonly RuntimeLocalPlayerMovementState _movement;
|
||||
private readonly SelectionInteractionController _selection;
|
||||
private readonly GameplayInputFrameController _gameplayInput;
|
||||
private readonly ICurrentGameRuntimeEventSink _events;
|
||||
|
||||
public CurrentGameRuntimeCommandAdapter(
|
||||
|
|
@ -46,8 +46,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
RuntimeInventoryState inventory,
|
||||
RuntimeCharacterState character,
|
||||
RuntimeActionState actions,
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
SelectionInteractionController selection,
|
||||
GameplayInputFrameController gameplayInput,
|
||||
ICurrentGameRuntimeEventSink events)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
|
|
@ -58,9 +58,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
_character = character ?? throw new ArgumentNullException(nameof(character));
|
||||
_actions = actions
|
||||
?? throw new ArgumentNullException(nameof(actions));
|
||||
_movement = movement
|
||||
?? throw new ArgumentNullException(nameof(movement));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_gameplayInput = gameplayInput
|
||||
?? throw new ArgumentNullException(nameof(gameplayInput));
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
}
|
||||
|
||||
|
|
@ -246,22 +246,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
|
||||
InputAction action = command switch
|
||||
{
|
||||
RuntimeMovementCommand.ToggleRunLock => InputAction.MovementRunLock,
|
||||
RuntimeMovementCommand.Stop => InputAction.MovementStop,
|
||||
_ => InputAction.None,
|
||||
};
|
||||
RuntimeCommandStatus status;
|
||||
if (action == InputAction.None)
|
||||
{
|
||||
status = RuntimeCommandStatus.Unsupported;
|
||||
}
|
||||
else
|
||||
{
|
||||
_gameplayInput.HandlePressedMovementAction(action);
|
||||
status = RuntimeCommandStatus.Accepted;
|
||||
}
|
||||
RuntimeCommandStatus status = _movement.Execute(command)
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Unsupported;
|
||||
|
||||
_events.EmitCommand(RuntimeCommandDomain.Movement, (int)command, status);
|
||||
return Result(status);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
|
|||
private readonly IRuntimeSocialView _socialView;
|
||||
private readonly IRuntimeChatView _chatView;
|
||||
private readonly IRuntimeActionView _actionView;
|
||||
private readonly MovementView _movementView;
|
||||
private readonly IRuntimeMovementView _movementView;
|
||||
private readonly PortalView _portalView;
|
||||
private bool _active = true;
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
|
|||
RuntimeCharacterState character,
|
||||
RuntimeCommunicationState communication,
|
||||
RuntimeActionState actions,
|
||||
ILocalPlayerControllerSource playerController,
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
WorldRevealCoordinator worldReveal,
|
||||
IGameRuntimeClock clock)
|
||||
{
|
||||
|
|
@ -65,10 +65,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
|
|||
_socialView = communication.SocialView;
|
||||
_actionView = (
|
||||
actions ?? throw new ArgumentNullException(nameof(actions))).View;
|
||||
_movementView = new MovementView(
|
||||
playerController
|
||||
?? throw new ArgumentNullException(nameof(playerController)),
|
||||
_clock);
|
||||
_movementView = (
|
||||
movement ?? throw new ArgumentNullException(nameof(movement))).View;
|
||||
_portalView = new PortalView(
|
||||
worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)));
|
||||
}
|
||||
|
|
@ -132,35 +130,6 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
|
|||
|
||||
internal void Deactivate() => _active = false;
|
||||
|
||||
private sealed class MovementView(
|
||||
ILocalPlayerControllerSource owner,
|
||||
IGameRuntimeClock clock)
|
||||
: IRuntimeMovementView
|
||||
{
|
||||
public RuntimeMovementSnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
PlayerMovementController? controller = owner.Controller;
|
||||
return controller is null
|
||||
? new RuntimeMovementSnapshot(
|
||||
false,
|
||||
0u,
|
||||
default,
|
||||
default,
|
||||
false,
|
||||
clock.SimulationTimeSeconds)
|
||||
: new RuntimeMovementSnapshot(
|
||||
true,
|
||||
controller.LocalEntityId,
|
||||
controller.CellPosition,
|
||||
controller.BodyVelocity,
|
||||
controller.IsAirborne,
|
||||
clock.SimulationTimeSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PortalView(WorldRevealCoordinator owner)
|
||||
: IRuntimePortalView
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ namespace AcDream.Core.Physics;
|
|||
///
|
||||
/// <para>
|
||||
/// L.3a (2026-04-30): added optional collision-normal fields so the
|
||||
/// caller (typically <see cref="AcDream.App.Input.PlayerMovementController"/>)
|
||||
/// caller (typically
|
||||
/// <see cref="AcDream.Runtime.Gameplay.PlayerMovementController"/>)
|
||||
/// can apply retail's velocity-reflection bounce
|
||||
/// (<c>v_new = v - (1 + elasticity) * dot(v, n) * n</c>) to the
|
||||
/// PhysicsBody after the geometric resolve completes. ACE port mirror:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Runtime.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.App" />
|
||||
<InternalsVisibleTo Include="AcDream.App.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.Core.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ public readonly record struct RuntimeMovementSnapshot(
|
|||
Position Position,
|
||||
System.Numerics.Vector3 Velocity,
|
||||
bool IsAirborne,
|
||||
double SimulationTimeSeconds);
|
||||
double SimulationTimeSeconds,
|
||||
long Revision = 0,
|
||||
bool AutoRunActive = false);
|
||||
|
||||
public interface IRuntimeMovementView
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,24 @@ using AcDream.Core.Net;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public interface IMovementTruthDiagnosticSink
|
||||
{
|
||||
void OnOutbound(
|
||||
string kind,
|
||||
uint sequence,
|
||||
MovementResult result,
|
||||
Vector3 wirePosition,
|
||||
uint wireCellId,
|
||||
byte contactByte);
|
||||
|
||||
void OnServerEcho(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
Vector3 serverWorldPosition);
|
||||
|
||||
void ResetSession();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes input-originated output from the object-phase result and the
|
||||
|
|
@ -250,3 +267,33 @@ public sealed class LocalPlayerOutboundController
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DelegateMovementTruthDiagnosticSink
|
||||
: IMovementTruthDiagnosticSink
|
||||
{
|
||||
private readonly Action<string, uint, MovementResult, Vector3, uint, byte>
|
||||
_outbound;
|
||||
|
||||
public DelegateMovementTruthDiagnosticSink(
|
||||
Action<string, uint, MovementResult, Vector3, uint, byte> outbound) =>
|
||||
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
||||
|
||||
public void OnOutbound(
|
||||
string kind,
|
||||
uint sequence,
|
||||
MovementResult result,
|
||||
Vector3 wirePosition,
|
||||
uint wireCellId,
|
||||
byte contactByte) =>
|
||||
_outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
|
||||
|
||||
public void OnServerEcho(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
Vector3 serverWorldPosition)
|
||||
{
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ using System;
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Input state for a single frame of player movement.
|
||||
|
|
@ -18,6 +18,29 @@ public readonly record struct MovementInput(
|
|||
float MouseDeltaX = 0f,
|
||||
bool Jump = false);
|
||||
|
||||
/// <summary>
|
||||
/// Typed construction policy for the local movement owner. Server-authoritative
|
||||
/// values come from <see cref="RuntimeMovementSkillState"/>; the fallback only
|
||||
/// preserves the pre-description test/login baseline and never reads process
|
||||
/// environment state.
|
||||
/// </summary>
|
||||
public readonly record struct PlayerMovementConstructionOptions(
|
||||
int RunSkill,
|
||||
int JumpSkill)
|
||||
{
|
||||
public const int FallbackRunSkill = 200;
|
||||
public const int FallbackJumpSkill = 300;
|
||||
|
||||
public static PlayerMovementConstructionOptions Fallback =>
|
||||
new(FallbackRunSkill, FallbackJumpSkill);
|
||||
|
||||
public static PlayerMovementConstructionOptions From(
|
||||
RuntimeMovementSkillSnapshot skills) =>
|
||||
new(
|
||||
skills.RunSkill >= 0 ? skills.RunSkill : FallbackRunSkill,
|
||||
skills.JumpSkill >= 0 ? skills.JumpSkill : FallbackJumpSkill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only presentation snapshot of retail's pending jump build. The movement
|
||||
/// controller remains the sole owner of charge timing; retained UI only projects
|
||||
|
|
@ -456,6 +479,14 @@ public sealed class PlayerMovementController
|
|||
public PlayerMovementController(
|
||||
PhysicsEngine physics,
|
||||
RetailObjectQuantumClock? objectClock = null)
|
||||
: this(physics, objectClock, PlayerMovementConstructionOptions.Fallback)
|
||||
{
|
||||
}
|
||||
|
||||
public PlayerMovementController(
|
||||
PhysicsEngine physics,
|
||||
RetailObjectQuantumClock? objectClock,
|
||||
PlayerMovementConstructionOptions options)
|
||||
{
|
||||
_physics = physics;
|
||||
_objectClock = objectClock ?? new RetailObjectQuantumClock();
|
||||
|
|
@ -467,20 +498,20 @@ public sealed class PlayerMovementController
|
|||
|
||||
// Default skills — tuned toward mid-retail feel. Real characters'
|
||||
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
|
||||
// pushes them via SetCharacterSkills once the controller exists
|
||||
// (K-fix7; PD arrives at login before auto-entry). These env-var
|
||||
// defaults only cover tests / pre-PD frames:
|
||||
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
|
||||
// Runtime supplies them through typed construction options and
|
||||
// updates them via SetCharacterSkills when later authoritative
|
||||
// values arrive.
|
||||
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
|
||||
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
|
||||
// skill=200 → 3.01m max (felt too low — user complaint)
|
||||
// skill=300 → 4.21m max (closer to a typical retail mid-tier
|
||||
// character's "I can clear that fence" hop)
|
||||
// Until #7 ships and PlayerDescription gives us the server's real
|
||||
// skill, this default is the right "feels like retail" baseline.
|
||||
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
|
||||
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
|
||||
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
|
||||
// Until PlayerDescription supplies both values, retain the typed
|
||||
// construction baseline. RuntimeMovementSkillState updates this same
|
||||
// PlayerWeenie after authoritative character data arrives.
|
||||
_weenie = new PlayerWeenie(
|
||||
runSkill: options.RunSkill,
|
||||
jumpSkill: options.JumpSkill);
|
||||
_motion = new MotionInterpreter(_body, _weenie);
|
||||
// R5-V5: the MovementManager facade owns the interp from birth
|
||||
// (retail CPhysicsObj::movement_manager); the moveto child binds
|
||||
|
|
@ -2,20 +2,22 @@ namespace AcDream.Runtime.Gameplay;
|
|||
|
||||
/// <summary>
|
||||
/// One allocation-free ledger over the gameplay-state lifetime group through
|
||||
/// J5.1. It contains no state of its own; graphical and no-window hosts capture
|
||||
/// the same four canonical owners.
|
||||
/// J5.4. It contains no state of its own; graphical and no-window hosts capture
|
||||
/// the same canonical owners.
|
||||
/// </summary>
|
||||
public readonly record struct RuntimeGameplayOwnershipSnapshot(
|
||||
RuntimeInventoryOwnershipSnapshot Inventory,
|
||||
RuntimeCharacterOwnershipSnapshot Character,
|
||||
RuntimeCommunicationOwnershipSnapshot Communication,
|
||||
RuntimeActionOwnershipSnapshot Actions)
|
||||
RuntimeActionOwnershipSnapshot Actions,
|
||||
RuntimeLocalMovementOwnershipSnapshot Movement)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
Inventory.IsConverged
|
||||
&& Character.IsConverged
|
||||
&& Communication.IsConverged
|
||||
&& Actions.IsConverged;
|
||||
&& Actions.IsConverged
|
||||
&& Movement.IsConverged;
|
||||
}
|
||||
|
||||
public static class RuntimeGameplayOwnership
|
||||
|
|
@ -24,16 +26,19 @@ public static class RuntimeGameplayOwnership
|
|||
RuntimeInventoryState inventory,
|
||||
RuntimeCharacterState character,
|
||||
RuntimeCommunicationState communication,
|
||||
RuntimeActionState actions)
|
||||
RuntimeActionState actions,
|
||||
RuntimeLocalPlayerMovementState movement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
ArgumentNullException.ThrowIfNull(character);
|
||||
ArgumentNullException.ThrowIfNull(communication);
|
||||
ArgumentNullException.ThrowIfNull(actions);
|
||||
ArgumentNullException.ThrowIfNull(movement);
|
||||
return new RuntimeGameplayOwnershipSnapshot(
|
||||
inventory.CaptureOwnership(),
|
||||
character.CaptureOwnership(),
|
||||
communication.CaptureOwnership(),
|
||||
actions.CaptureOwnership());
|
||||
actions.CaptureOwnership(),
|
||||
movement.CaptureOwnership());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
200
src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
Normal file
200
src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public interface IRuntimeLocalPlayerControllerSource
|
||||
{
|
||||
PlayerMovementController? Controller { get; }
|
||||
}
|
||||
|
||||
public interface IRuntimeLocalPlayerMotionSource
|
||||
{
|
||||
MotionInterpreter? Motion { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical local movement lifetime and intent owner. Graphical input,
|
||||
/// presentation, diagnostics, and future no-window hosts borrow this exact
|
||||
/// state; they never mirror the controller or the autorun latch.
|
||||
/// </summary>
|
||||
public sealed class RuntimeLocalPlayerMovementState
|
||||
: IRuntimeLocalPlayerControllerSource,
|
||||
IRuntimeLocalPlayerMotionSource,
|
||||
IRuntimeMovementView,
|
||||
IDisposable
|
||||
{
|
||||
private PlayerMovementController? _controller;
|
||||
private PlayerMovementController? _preparingMotionOwner;
|
||||
private bool _autoRunActive;
|
||||
private bool _disposed;
|
||||
private long _revision;
|
||||
|
||||
public PlayerMovementController? Controller
|
||||
{
|
||||
get => _controller;
|
||||
set
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (ReferenceEquals(_controller, value))
|
||||
return;
|
||||
_controller = value;
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
}
|
||||
|
||||
public bool AutoRunActive => _autoRunActive;
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
public IRuntimeMovementView View => this;
|
||||
|
||||
MotionInterpreter? IRuntimeLocalPlayerMotionSource.Motion =>
|
||||
_preparingMotionOwner?.Motion ?? _controller?.Motion;
|
||||
|
||||
public RuntimeMovementSnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
PlayerMovementController? controller = _controller;
|
||||
return controller is null
|
||||
? new RuntimeMovementSnapshot(
|
||||
false,
|
||||
0u,
|
||||
default,
|
||||
default,
|
||||
false,
|
||||
0d,
|
||||
Revision,
|
||||
_autoRunActive)
|
||||
: new RuntimeMovementSnapshot(
|
||||
true,
|
||||
controller.LocalEntityId,
|
||||
controller.CellPosition,
|
||||
controller.BodyVelocity,
|
||||
controller.IsAirborne,
|
||||
controller.SimTimeSeconds,
|
||||
Revision,
|
||||
_autoRunActive);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the retail construction seam where a PartArray completion can
|
||||
/// reach the candidate MotionInterpreter before the controller is
|
||||
/// published to ordinary borrowers.
|
||||
/// </summary>
|
||||
public IDisposable BeginMotionPreparation(
|
||||
PlayerMovementController controller,
|
||||
Action? drainPriorAnimationQueue = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(controller);
|
||||
if (_preparingMotionOwner is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A local player motion owner is already being prepared.");
|
||||
}
|
||||
|
||||
drainPriorAnimationQueue?.Invoke();
|
||||
_preparingMotionOwner = controller;
|
||||
return new MotionPreparation(this, controller);
|
||||
}
|
||||
|
||||
public bool Execute(RuntimeMovementCommand command)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
switch (command)
|
||||
{
|
||||
case RuntimeMovementCommand.ToggleRunLock:
|
||||
_autoRunActive = !_autoRunActive;
|
||||
Interlocked.Increment(ref _revision);
|
||||
return true;
|
||||
case RuntimeMovementCommand.Stop:
|
||||
CancelAutoRun();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CancelAutoRun()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_autoRunActive)
|
||||
return false;
|
||||
_autoRunActive = false;
|
||||
Interlocked.Increment(ref _revision);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ResetInputIntent()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_autoRunActive)
|
||||
return;
|
||||
_autoRunActive = false;
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
|
||||
new(
|
||||
_disposed,
|
||||
_controller is not null,
|
||||
_preparingMotionOwner is not null,
|
||||
_autoRunActive,
|
||||
Revision);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_autoRunActive = false;
|
||||
_controller = null;
|
||||
_preparingMotionOwner = null;
|
||||
Interlocked.Increment(ref _revision);
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void EndMotionPreparation(PlayerMovementController controller)
|
||||
{
|
||||
// Terminal disposal clears the unpublished construction seam. A
|
||||
// caller may still unwind the already-issued lease afterward; that
|
||||
// unwind is idempotent rather than resurrecting or faulting the
|
||||
// retired runtime owner.
|
||||
if (_disposed && _preparingMotionOwner is null)
|
||||
return;
|
||||
|
||||
if (!ReferenceEquals(_preparingMotionOwner, controller))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The local player motion preparation owner changed unexpectedly.");
|
||||
}
|
||||
_preparingMotionOwner = null;
|
||||
}
|
||||
|
||||
private sealed class MotionPreparation(
|
||||
RuntimeLocalPlayerMovementState owner,
|
||||
PlayerMovementController controller) : IDisposable
|
||||
{
|
||||
private RuntimeLocalPlayerMovementState? _owner = owner;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RuntimeLocalPlayerMovementState? current =
|
||||
Interlocked.Exchange(ref _owner, null);
|
||||
current?.EndMotionPreparation(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
|
||||
bool IsDisposed,
|
||||
bool HasController,
|
||||
bool HasPreparingMotionOwner,
|
||||
bool AutoRunActive,
|
||||
long Revision)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
&& !HasController
|
||||
&& !HasPreparingMotionOwner
|
||||
&& !AutoRunActive;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue