321 lines
10 KiB
C#
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;
|
|
}
|