acdream/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs

321 lines
10 KiB
C#

using System.Diagnostics;
using AcDream.Core.Combat;
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 _hasCommandInput;
private MovementInput _commandInput;
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 bool HasCommandInput => _hasCommandInput;
public MovementInput CommandInput => _commandInput;
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,
_hasCommandInput,
_commandInput)
: new RuntimeMovementSnapshot(
true,
controller.LocalEntityId,
controller.CellPosition,
controller.BodyVelocity,
controller.IsAirborne,
controller.SimTimeSeconds,
Revision,
_autoRunActive,
_hasCommandInput,
_commandInput);
}
}
/// <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();
ClearCommandInput();
return true;
case RuntimeMovementCommand.Ready:
case RuntimeMovementCommand.Sit:
case RuntimeMovementCommand.Crouch:
case RuntimeMovementCommand.Sleep:
CancelAutoRun();
ClearCommandInput();
uint motion = command switch
{
RuntimeMovementCommand.Ready =>
MotionCommand.Ready,
RuntimeMovementCommand.Sit =>
MotionCommand.Sitting,
RuntimeMovementCommand.Crouch =>
MotionCommand.Crouch,
RuntimeMovementCommand.Sleep =>
MotionCommand.Sleeping,
_ => throw new UnreachableException(),
};
return _controller?.RequestPosture(motion) == true;
default:
return false;
}
}
public bool CancelAutoRun()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_autoRunActive)
return false;
_autoRunActive = false;
Interlocked.Increment(ref _revision);
return true;
}
/// <summary>
/// Installs the persistent semantic movement level sampled by either host.
/// The graphical input adapter and direct host both consume this exact
/// owner; no bot-only movement latch exists.
/// </summary>
public void SetCommandInput(in MovementInput input)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_hasCommandInput && _commandInput == input)
return;
_commandInput = input;
_hasCommandInput = true;
Interlocked.Increment(ref _revision);
}
public bool ClearCommandInput()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_hasCommandInput)
return false;
_commandInput = default;
_hasCommandInput = false;
Interlocked.Increment(ref _revision);
return true;
}
/// <summary>
/// Direct-host projection of the same combat readiness query used by the
/// graphical attack adapter. A host without a constructed local movement
/// owner is not ready; Slice K's content host must hydrate that owner
/// before combat packets can leave.
/// </summary>
public bool IsReadyForAttack(CombatMode mode)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_controller is not { } controller)
return false;
var motion = controller.Motion.InterpretedState;
return CombatInputPlanner.PlayerInReadyPositionForAttack(
mode,
motion.CurrentStyle,
motion.ForwardCommand);
}
public bool IsDualWield
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _controller?.Motion.InterpretedState.CurrentStyle
== CombatInputPlanner.DualWieldCombatStyle;
}
}
public bool PrepareForAttackRequest()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CancelAutoRun();
return _controller?.PrepareForAttackRequest() == true;
}
public void ResetInputIntent()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_autoRunActive && !_hasCommandInput)
return;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
Interlocked.Increment(ref _revision);
}
/// <summary>
/// Retires every character-generation movement owner while keeping the
/// process-scoped Runtime root reusable for the next session.
/// Graphical hosts release their camera/physics projections first; direct
/// hosts have no parallel projection to coordinate.
/// </summary>
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
bool changed =
_autoRunActive
|| _hasCommandInput
|| _controller is not null
|| _preparingMotionOwner is not null;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
_controller = null;
_preparingMotionOwner = null;
if (changed)
Interlocked.Increment(ref _revision);
}
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
new(
_disposed,
_controller is not null,
_preparingMotionOwner is not null,
_autoRunActive,
_hasCommandInput,
Revision);
public void Dispose()
{
if (_disposed)
return;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
_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,
bool HasCommandInput,
long Revision)
{
public bool IsConverged =>
IsDisposed
&& !HasController
&& !HasPreparingMotionOwner
&& !AutoRunActive
&& !HasCommandInput;
}