refactor(input): own gameplay action routing
Move the sole semantic action-priority graph, combat and diagnostic commands, and retained-root item-drop lifetime behind focused typed owners. Preserve retail toggle behavior, explicit auto-wield cancellation, shutdown quiescence, and symmetric callback cleanup. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
8b8afeefa3
commit
4eae9b4f5a
25 changed files with 2608 additions and 418 deletions
|
|
@ -74,13 +74,19 @@ internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
|
|||
}
|
||||
}
|
||||
|
||||
internal interface IPointerSensitivityCommands
|
||||
{
|
||||
string AdjustSensitivity(float factor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns raw pointer subscriptions and the camera-facing pointer policy that
|
||||
/// previously lived in GameWindow. Physical event removal is retriable; logical
|
||||
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot
|
||||
/// re-enter gameplay while shutdown is closing the live session.
|
||||
/// </summary>
|
||||
internal sealed class CameraPointerInputController : IDisposable
|
||||
internal sealed class CameraPointerInputController
|
||||
: IDisposable, IPointerSensitivityCommands
|
||||
{
|
||||
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
|
||||
private readonly IPointerCursorModeTarget _cursor;
|
||||
|
|
|
|||
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal file
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IGameplayInputActionSurface
|
||||
{
|
||||
void AddFired(Action<InputAction, ActivationType> callback);
|
||||
|
||||
void RemoveFired(Action<InputAction, ActivationType> callback);
|
||||
|
||||
void SetCombatScope(InputScope? scope);
|
||||
}
|
||||
|
||||
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
|
||||
: IGameplayInputActionSurface
|
||||
{
|
||||
private readonly InputDispatcher _dispatcher = dispatcher
|
||||
?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
||||
public void AddFired(Action<InputAction, ActivationType> callback) =>
|
||||
_dispatcher.Fired += callback;
|
||||
|
||||
public void RemoveFired(Action<InputAction, ActivationType> callback) =>
|
||||
_dispatcher.Fired -= callback;
|
||||
|
||||
public void SetCombatScope(InputScope? scope) =>
|
||||
_dispatcher.SetCombatScope(scope);
|
||||
}
|
||||
|
||||
internal interface ICombatModeEventSurface
|
||||
{
|
||||
CombatMode CurrentMode { get; }
|
||||
|
||||
void AddChanged(Action<CombatMode> callback);
|
||||
|
||||
void RemoveChanged(Action<CombatMode> callback);
|
||||
}
|
||||
|
||||
internal sealed class CombatStateModeEventSurface(CombatState combat)
|
||||
: ICombatModeEventSurface
|
||||
{
|
||||
private readonly CombatState _combat = combat
|
||||
?? throw new ArgumentNullException(nameof(combat));
|
||||
|
||||
public CombatMode CurrentMode => _combat.CurrentMode;
|
||||
|
||||
public void AddChanged(Action<CombatMode> callback) =>
|
||||
_combat.CombatModeChanged += callback;
|
||||
|
||||
public void RemoveChanged(Action<CombatMode> callback) =>
|
||||
_combat.CombatModeChanged -= callback;
|
||||
}
|
||||
|
||||
internal interface IGameplayInputPriorityTargets
|
||||
{
|
||||
bool HandlePointerAction(InputAction action, ActivationType activation);
|
||||
|
||||
void HandleScroll(InputAction action);
|
||||
|
||||
bool HandleCombatAction(InputAction action, ActivationType activation);
|
||||
|
||||
bool HandleRetainedUiAction(InputAction action);
|
||||
|
||||
bool HandleSelectionAction(InputAction action);
|
||||
|
||||
bool HandlePressedMovementAction(InputAction action);
|
||||
|
||||
void HandleCommand(InputAction action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed adapter over canonical gameplay owners. Optional presentation owners
|
||||
/// remain no-ops when their stack is disabled; no state is mirrored here.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeGameplayInputPriorityTargets
|
||||
: IGameplayInputPriorityTargets
|
||||
{
|
||||
private readonly GameplayInputFrameController _frame;
|
||||
private readonly CameraPointerInputController _pointer;
|
||||
private readonly RetailUiRuntime? _retainedUi;
|
||||
private readonly SelectionInteractionController? _selection;
|
||||
private readonly IGameplayInputCommandTarget _commands;
|
||||
|
||||
public RuntimeGameplayInputPriorityTargets(
|
||||
GameplayInputFrameController frame,
|
||||
CameraPointerInputController pointer,
|
||||
RetailUiRuntime? retainedUi,
|
||||
SelectionInteractionController? selection,
|
||||
IGameplayInputCommandTarget commands)
|
||||
{
|
||||
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
||||
_retainedUi = retainedUi;
|
||||
_selection = selection;
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
}
|
||||
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
|
||||
_frame.HandlePointerAction(action, activation);
|
||||
|
||||
public void HandleScroll(InputAction action) =>
|
||||
_pointer.HandleScroll(action);
|
||||
|
||||
public bool HandleCombatAction(InputAction action, ActivationType activation) =>
|
||||
_frame.HandleCombatAction(action, activation);
|
||||
|
||||
public bool HandleRetainedUiAction(InputAction action) =>
|
||||
_retainedUi?.HandleInputAction(action) == true;
|
||||
|
||||
public bool HandleSelectionAction(InputAction action) =>
|
||||
_selection?.HandleInputAction(action) == true;
|
||||
|
||||
public bool HandlePressedMovementAction(InputAction action) =>
|
||||
_frame.HandlePressedMovementAction(action);
|
||||
|
||||
public void HandleCommand(InputAction action) =>
|
||||
_commands.Handle(action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sole gameplay subscriber to <see cref="InputDispatcher.Fired"/>. The route
|
||||
/// preserves the accepted priority exactly: pointer, scroll, combat,
|
||||
/// Press/DoubleClick gate, retained UI, selection, pressed movement, commands.
|
||||
/// Subscription ownership is reversible and callbacks become inert before the
|
||||
/// live-session shutdown barrier.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputActionRouter : IDisposable
|
||||
{
|
||||
private readonly IGameplayInputActionSurface _actions;
|
||||
private readonly ICombatModeEventSurface _combat;
|
||||
private readonly IGameplayInputPriorityTargets _targets;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<InputAction, ActivationType> _fired;
|
||||
private readonly Action<CombatMode> _combatModeChanged;
|
||||
private readonly bool[] _attached = new bool[2];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public GameplayInputActionRouter(
|
||||
IGameplayInputActionSurface actions,
|
||||
ICombatModeEventSurface combat,
|
||||
IGameplayInputPriorityTargets targets,
|
||||
HostQuiescenceGate quiescence,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_log = log ?? (_ => { });
|
||||
_fired = OnFired;
|
||||
_combatModeChanged = OnCombatModeChanged;
|
||||
}
|
||||
|
||||
public static GameplayInputActionRouter Create(
|
||||
InputDispatcher dispatcher,
|
||||
CombatState combat,
|
||||
IGameplayInputPriorityTargets targets,
|
||||
HostQuiescenceGate quiescence,
|
||||
Action<string>? log = null) =>
|
||||
new(
|
||||
new DispatcherGameplayInputActionSurface(dispatcher),
|
||||
new CombatStateModeEventSurface(combat),
|
||||
targets,
|
||||
quiescence,
|
||||
log);
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
_attached.All(static attached => !attached);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Gameplay action attachment has already started.");
|
||||
}
|
||||
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
// Mark each edge before invoking its custom accessor. An accessor
|
||||
// may perform the add and then throw; rollback must still remove it.
|
||||
_attached[0] = true;
|
||||
_actions.AddFired(_fired);
|
||||
_attached[1] = true;
|
||||
_combat.AddChanged(_combatModeChanged);
|
||||
|
||||
Volatile.Write(ref _active, 1);
|
||||
SetCombatScope(_combat.CurrentMode);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Gameplay action registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Gameplay action registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Gameplay action registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnFired(InputAction action, ActivationType activation) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
Route(action, activation);
|
||||
});
|
||||
|
||||
private void OnCombatModeChanged(CombatMode mode) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
SetCombatScope(mode);
|
||||
});
|
||||
|
||||
private void Route(InputAction action, ActivationType activation)
|
||||
{
|
||||
_log($"[input] {action} {activation}");
|
||||
|
||||
if (_targets.HandlePointerAction(action, activation))
|
||||
return;
|
||||
|
||||
if (action is InputAction.ScrollUp or InputAction.ScrollDown)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
_targets.HandleScroll(action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_targets.HandleCombatAction(action, activation))
|
||||
return;
|
||||
|
||||
if (activation is not ActivationType.Press
|
||||
and not ActivationType.DoubleClick)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_targets.HandleRetainedUiAction(action))
|
||||
return;
|
||||
if (_targets.HandleSelectionAction(action))
|
||||
return;
|
||||
if (_targets.HandlePressedMovementAction(action))
|
||||
return;
|
||||
|
||||
_targets.HandleCommand(action);
|
||||
}
|
||||
|
||||
private void SetCombatScope(CombatMode mode) =>
|
||||
_actions.SetCombatScope(mode switch
|
||||
{
|
||||
CombatMode.Melee => InputScope.MeleeCombat,
|
||||
CombatMode.Missile => InputScope.MissileCombat,
|
||||
CombatMode.Magic => InputScope.MagicCombat,
|
||||
_ => null,
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("gameplay action callbacks",
|
||||
[
|
||||
new("combat mode", () => Remove(1)),
|
||||
new("dispatcher fired", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
|
||||
switch (index)
|
||||
{
|
||||
case 0:
|
||||
_actions.RemoveFired(_fired);
|
||||
break;
|
||||
case 1:
|
||||
_combat.RemoveChanged(_combatModeChanged);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal file
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IRetainedGameplayWindowCommands
|
||||
{
|
||||
void ToggleInventory();
|
||||
}
|
||||
|
||||
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
||||
: IRetainedGameplayWindowCommands
|
||||
{
|
||||
private readonly RetailUiRuntime? _runtime = runtime;
|
||||
|
||||
public void ToggleInventory() =>
|
||||
_runtime?.ToggleWindow(WindowNames.Inventory);
|
||||
}
|
||||
|
||||
internal interface IDevToolsGameplayCommands
|
||||
{
|
||||
void ToggleDebugPanel();
|
||||
|
||||
void FocusChatInput();
|
||||
|
||||
void ToggleSettingsPanel();
|
||||
}
|
||||
|
||||
internal sealed class DevToolsGameplayCommands(DevToolsFramePresenter? presenter)
|
||||
: IDevToolsGameplayCommands
|
||||
{
|
||||
private readonly DevToolsFramePresenter? _presenter = presenter;
|
||||
|
||||
public void ToggleDebugPanel() => _presenter?.ToggleDebugPanel();
|
||||
|
||||
public void FocusChatInput() => _presenter?.FocusChatInput();
|
||||
|
||||
public void ToggleSettingsPanel() => _presenter?.ToggleSettingsPanel();
|
||||
}
|
||||
|
||||
internal interface IPlayerModeGameplayCommands
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
|
||||
void ToggleFlyOrChase();
|
||||
|
||||
void TogglePlayerMode();
|
||||
|
||||
void ExitPlayerMode();
|
||||
}
|
||||
|
||||
internal sealed class PlayerModeGameplayCommands(
|
||||
ILocalPlayerModeSource mode,
|
||||
PlayerModeController controller) : IPlayerModeGameplayCommands
|
||||
{
|
||||
private readonly ILocalPlayerModeSource _mode = mode
|
||||
?? throw new ArgumentNullException(nameof(mode));
|
||||
private readonly PlayerModeController _controller = controller
|
||||
?? throw new ArgumentNullException(nameof(controller));
|
||||
|
||||
public bool IsPlayerMode => _mode.IsPlayerMode;
|
||||
|
||||
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
|
||||
|
||||
public void TogglePlayerMode() => _controller.Toggle();
|
||||
|
||||
public void ExitPlayerMode() => _controller.Exit();
|
||||
}
|
||||
|
||||
internal interface IItemTargetModeCommands
|
||||
{
|
||||
bool IsAnyTargetModeActive { get; }
|
||||
|
||||
void CancelTargetMode();
|
||||
}
|
||||
|
||||
internal sealed class ItemTargetModeCommands(ItemInteractionController items)
|
||||
: IItemTargetModeCommands
|
||||
{
|
||||
private readonly ItemInteractionController _items = items
|
||||
?? throw new ArgumentNullException(nameof(items));
|
||||
|
||||
public bool IsAnyTargetModeActive => _items.IsAnyTargetModeActive;
|
||||
|
||||
public void CancelTargetMode() => _items.CancelTargetMode();
|
||||
}
|
||||
|
||||
internal interface IGameplayCameraModeCommands
|
||||
{
|
||||
bool IsFlyMode { get; }
|
||||
|
||||
void ExitFlyMode();
|
||||
}
|
||||
|
||||
internal sealed class GameplayCameraModeCommands(CameraController camera)
|
||||
: IGameplayCameraModeCommands
|
||||
{
|
||||
private readonly CameraController _camera = camera
|
||||
?? throw new ArgumentNullException(nameof(camera));
|
||||
|
||||
public bool IsFlyMode => _camera.IsFlyMode;
|
||||
|
||||
public void ExitFlyMode() => _camera.ToggleFly();
|
||||
}
|
||||
|
||||
internal interface IGameplayWindowCommands
|
||||
{
|
||||
void Close();
|
||||
}
|
||||
|
||||
internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands
|
||||
{
|
||||
private readonly Action _close = close
|
||||
?? throw new ArgumentNullException(nameof(close));
|
||||
|
||||
public void Close() => _close();
|
||||
}
|
||||
|
||||
internal interface IGameplayInputCommandTarget
|
||||
{
|
||||
bool Handle(InputAction action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the final command tier of the frozen gameplay-action priority graph.
|
||||
/// It coordinates canonical owners but stores no UI, camera, player, combat,
|
||||
/// item-target, or diagnostic state of its own.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget
|
||||
{
|
||||
private readonly IRetainedGameplayWindowCommands _retained;
|
||||
private readonly IDevToolsGameplayCommands _devTools;
|
||||
private readonly IRuntimeDiagnosticCommands _diagnostics;
|
||||
private readonly IPlayerModeGameplayCommands _playerMode;
|
||||
private readonly IItemTargetModeCommands _targetMode;
|
||||
private readonly IGameplayCameraModeCommands _camera;
|
||||
private readonly ILiveCombatModeCommand _combat;
|
||||
private readonly IGameplayWindowCommands _window;
|
||||
|
||||
public GameplayInputCommandController(
|
||||
IRetainedGameplayWindowCommands retained,
|
||||
IDevToolsGameplayCommands devTools,
|
||||
IRuntimeDiagnosticCommands diagnostics,
|
||||
IPlayerModeGameplayCommands playerMode,
|
||||
IItemTargetModeCommands targetMode,
|
||||
IGameplayCameraModeCommands camera,
|
||||
ILiveCombatModeCommand combat,
|
||||
IGameplayWindowCommands window)
|
||||
{
|
||||
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
|
||||
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||||
}
|
||||
|
||||
public bool Handle(InputAction action)
|
||||
{
|
||||
if (_diagnostics.Handle(action))
|
||||
return true;
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.ToggleInventoryPanel:
|
||||
_retained.ToggleInventory();
|
||||
return true;
|
||||
case InputAction.AcdreamToggleDebugPanel:
|
||||
_devTools.ToggleDebugPanel();
|
||||
return true;
|
||||
case InputAction.AcdreamToggleFlyMode:
|
||||
_playerMode.ToggleFlyOrChase();
|
||||
return true;
|
||||
case InputAction.AcdreamTogglePlayerMode:
|
||||
_playerMode.TogglePlayerMode();
|
||||
return true;
|
||||
case InputAction.ToggleChatEntry:
|
||||
_devTools.FocusChatInput();
|
||||
return true;
|
||||
case InputAction.ToggleOptionsPanel:
|
||||
_devTools.ToggleSettingsPanel();
|
||||
return true;
|
||||
case InputAction.CombatToggleCombat:
|
||||
_combat.Toggle();
|
||||
return true;
|
||||
case InputAction.EscapeKey:
|
||||
HandleEscape();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEscape()
|
||||
{
|
||||
if (_targetMode.IsAnyTargetModeActive)
|
||||
_targetMode.CancelTargetMode();
|
||||
else if (_camera.IsFlyMode)
|
||||
_camera.ExitFlyMode();
|
||||
else if (_playerMode.IsPlayerMode)
|
||||
_playerMode.ExitPlayerMode();
|
||||
else
|
||||
_window.Close();
|
||||
}
|
||||
}
|
||||
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal file
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IRetainedUiDragReleaseSurface
|
||||
{
|
||||
void AddReleasedOutside(Action<object, int, int> callback);
|
||||
|
||||
void RemoveReleasedOutside(Action<object, int, int> callback);
|
||||
}
|
||||
|
||||
internal sealed class UiRootDragReleaseSurface(UiRoot root)
|
||||
: IRetainedUiDragReleaseSurface
|
||||
{
|
||||
private readonly UiRoot _root = root
|
||||
?? throw new ArgumentNullException(nameof(root));
|
||||
|
||||
public void AddReleasedOutside(Action<object, int, int> callback) =>
|
||||
_root.DragReleasedOutsideUi += callback;
|
||||
|
||||
public void RemoveReleasedOutside(Action<object, int, int> callback) =>
|
||||
_root.DragReleasedOutsideUi -= callback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the retained-root gameplay edge used when an item drag is released in
|
||||
/// the world. Logical deactivation precedes live-session teardown and physical
|
||||
/// removal remains retriable if a custom event accessor fails.
|
||||
/// </summary>
|
||||
internal sealed class RetainedUiGameplayBinding : IDisposable
|
||||
{
|
||||
private readonly IRetainedUiDragReleaseSurface _surface;
|
||||
private readonly Action<ItemDragPayload, int, int> _placeDraggedItem;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<object, int, int> _releasedOutside;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attached;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public RetainedUiGameplayBinding(
|
||||
IRetainedUiDragReleaseSurface surface,
|
||||
Action<ItemDragPayload, int, int> placeDraggedItem,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_placeDraggedItem = placeDraggedItem
|
||||
?? throw new ArgumentNullException(nameof(placeDraggedItem));
|
||||
_quiescence = quiescence
|
||||
?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_releasedOutside = OnReleasedOutside;
|
||||
}
|
||||
|
||||
public static RetainedUiGameplayBinding Create(
|
||||
UiRoot root,
|
||||
Action<ItemDragPayload, int, int> placeDraggedItem,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(new UiRootDragReleaseSurface(root), placeDraggedItem, quiescence);
|
||||
|
||||
public bool IsDisposalComplete => !_attached;
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Retained gameplay attachment has already started.");
|
||||
}
|
||||
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
// Publish before the custom accessor: it may add and then throw.
|
||||
_attached = true;
|
||||
_surface.AddReleasedOutside(_releasedOutside);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Retained gameplay registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Retained gameplay registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Retained gameplay registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnReleasedOutside(object payload, int x, int y) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0
|
||||
&& payload is ItemDragPayload item)
|
||||
{
|
||||
_placeDraggedItem(item, x, y);
|
||||
}
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("retained gameplay callbacks",
|
||||
[
|
||||
new("drag released outside UI", Remove),
|
||||
]));
|
||||
|
||||
private void Remove()
|
||||
{
|
||||
if (!_attached)
|
||||
return;
|
||||
|
||||
_surface.RemoveReleasedOutside(_releasedOutside);
|
||||
_attached = false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue