480 lines
16 KiB
C#
480 lines
16 KiB
C#
using AcDream.App.Input;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Ui;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
namespace AcDream.App.Interaction;
|
|
|
|
/// <summary>
|
|
/// Owns world selection mutations and one-shot Use/PickUp interaction state.
|
|
/// Read-only classification stays in <see cref="WorldSelectionQuery"/>;
|
|
/// retained item policy stays in <see cref="ItemInteractionController"/>.
|
|
/// </summary>
|
|
internal sealed class SelectionInteractionController
|
|
{
|
|
private readonly SelectionState _selection;
|
|
private readonly IWorldSelectionQuery _query;
|
|
private readonly ItemInteractionController _items;
|
|
private readonly ISelectionInteractionTransport _transport;
|
|
private readonly IPlayerInteractionMovementSink _movement;
|
|
private readonly OutboundInteractionQueue _outbound = new();
|
|
private readonly Action<string>? _toast;
|
|
private PendingPostArrivalAction? _pendingPostArrival;
|
|
|
|
private readonly record struct QueuedInteractionIdentity(
|
|
uint ServerGuid,
|
|
uint? LocalEntityId,
|
|
ClientObject? ClientObject);
|
|
|
|
private readonly record struct PendingPostArrivalAction(
|
|
uint ServerGuid,
|
|
uint LocalEntityId,
|
|
bool IsPickup,
|
|
uint DestinationContainerId,
|
|
int Placement,
|
|
ulong PendingPlacementToken);
|
|
|
|
public SelectionInteractionController(
|
|
SelectionState selection,
|
|
IWorldSelectionQuery query,
|
|
ItemInteractionController items,
|
|
ISelectionInteractionTransport transport,
|
|
IPlayerInteractionMovementSink movement,
|
|
Action<string>? toast = null)
|
|
{
|
|
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
|
_query = query ?? throw new ArgumentNullException(nameof(query));
|
|
_items = items ?? throw new ArgumentNullException(nameof(items));
|
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
|
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
|
_toast = toast;
|
|
}
|
|
|
|
public bool HandleInputAction(InputAction action)
|
|
{
|
|
switch (action)
|
|
{
|
|
case InputAction.SelectionClosestMonster:
|
|
SelectClosestCombatTarget(showToast: true);
|
|
return true;
|
|
case InputAction.SelectionPreviousSelection:
|
|
_selection.SelectPrevious();
|
|
return true;
|
|
case InputAction.SelectLeft:
|
|
PickAndStoreSelection(useImmediately: false);
|
|
return true;
|
|
case InputAction.SelectDblLeft:
|
|
PickAndStoreSelection(useImmediately: true);
|
|
return true;
|
|
case InputAction.UseSelected:
|
|
UseCurrentSelection();
|
|
return true;
|
|
case InputAction.SelectionPickUp:
|
|
if (_selection.SelectedObjectId is uint pickupTarget)
|
|
{
|
|
EnqueueIdentityBound(
|
|
pickupTarget,
|
|
requireLiveEntity: true,
|
|
guid =>
|
|
{
|
|
if (ValidatePickupTarget(guid, showToast: true))
|
|
_items.PlaceWorldItemInBackpack(guid);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
_toast?.Invoke("Nothing selected");
|
|
}
|
|
return true;
|
|
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
|
|
_items.CancelTargetMode();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public uint? PickAtCursor(bool includeSelf)
|
|
=> _query.PickAtCursor(includeSelf);
|
|
|
|
public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
|
|
if (target != 0u)
|
|
_query.BeginLightingPulse(target);
|
|
_items.PlaceIn3D(payload, target);
|
|
}
|
|
|
|
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
|
|
{
|
|
if (_selection.SelectedObjectId is { } selected
|
|
&& _query.IsHostileMonster(selected))
|
|
{
|
|
return selected;
|
|
}
|
|
return autoTarget ? SelectClosestCombatTarget(showToast: false) : null;
|
|
}
|
|
|
|
public uint? SelectClosestCombatTarget(bool showToast)
|
|
{
|
|
ClosestCombatTarget? closest = _query.FindClosestHostileMonster();
|
|
uint? bestGuid = closest?.ServerGuid;
|
|
if (bestGuid is { } selected)
|
|
_selection.Select(selected, SelectionChangeSource.Keyboard);
|
|
else
|
|
_selection.Clear(SelectionChangeSource.Keyboard);
|
|
|
|
if (bestGuid is { } guid)
|
|
{
|
|
string label = _query.Describe(guid);
|
|
float distance = MathF.Sqrt(closest!.Value.DistanceSquared);
|
|
Console.WriteLine($"combat: selected target 0x{guid:X8} {label} dist={distance:F1}");
|
|
if (showToast)
|
|
_toast?.Invoke($"Target {label}");
|
|
}
|
|
else if (showToast)
|
|
{
|
|
_toast?.Invoke("No monster target");
|
|
Console.WriteLine("combat: no creature target found");
|
|
}
|
|
return bestGuid;
|
|
}
|
|
|
|
public void PickAndStoreSelection(bool useImmediately)
|
|
{
|
|
uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive);
|
|
if (picked is not uint guid)
|
|
{
|
|
if (!_items.IsAnyTargetModeActive)
|
|
_toast?.Invoke("Nothing to select");
|
|
return;
|
|
}
|
|
|
|
// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
|
// @ 0x004E5AD0 pulses before select/use/target-mode branching.
|
|
_query.BeginLightingPulse(guid);
|
|
if (_items.OfferPrimaryClick(guid) is not ItemPrimaryClickResult.NotActive)
|
|
return;
|
|
|
|
_selection.Select(guid, SelectionChangeSource.World);
|
|
string label = _query.Describe(guid);
|
|
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
|
_toast?.Invoke($"Selected: {label}");
|
|
if (useImmediately)
|
|
EnqueueIdentityBound(
|
|
guid,
|
|
requireLiveEntity: true,
|
|
selected =>
|
|
{
|
|
_items.ActivateItem(selected);
|
|
});
|
|
}
|
|
|
|
public void UseCurrentSelection()
|
|
{
|
|
if (_selection.SelectedObjectId is not uint selected)
|
|
{
|
|
_toast?.Invoke("Nothing selected");
|
|
return;
|
|
}
|
|
EnqueueIdentityBound(
|
|
selected,
|
|
requireLiveEntity: false,
|
|
guid =>
|
|
{
|
|
_items.UseSelectedOrEnterMode(guid);
|
|
});
|
|
}
|
|
|
|
public void SendUse(uint serverGuid)
|
|
{
|
|
CancelPendingApproach();
|
|
if (!_transport.IsInWorld)
|
|
{
|
|
_toast?.Invoke("Not in world");
|
|
return;
|
|
}
|
|
if (!_query.IsUseable(serverGuid)
|
|
|| !_query.TryGetApproach(serverGuid, out InteractionApproach approach))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (approach.IsCloseRange)
|
|
{
|
|
var pending = new PendingPostArrivalAction(
|
|
serverGuid,
|
|
approach.Target.LocalEntityId,
|
|
IsPickup: false,
|
|
DestinationContainerId: 0u,
|
|
Placement: 0,
|
|
PendingPlacementToken: 0u);
|
|
bool started = _movement.BeginApproach(
|
|
approach,
|
|
() => _pendingPostArrival = pending);
|
|
if (!started && _pendingPostArrival == pending)
|
|
CancelPendingApproach();
|
|
return;
|
|
}
|
|
|
|
_movement.BeginApproach(approach);
|
|
if (_transport.TrySendUse(serverGuid, out uint sequence))
|
|
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
|
|
}
|
|
|
|
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
|
|
{
|
|
CancelPendingApproach();
|
|
if (!_transport.IsInWorld)
|
|
{
|
|
_toast?.Invoke("Not in world");
|
|
CancelPickupPresentation(itemGuid);
|
|
return;
|
|
}
|
|
if (!ValidatePickupTarget(itemGuid, showToast: true)
|
|
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
|
|
{
|
|
CancelPickupPresentation(itemGuid);
|
|
return;
|
|
}
|
|
|
|
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
|
|
itemGuid,
|
|
out PendingBackpackPlacement pendingPlacement)
|
|
? pendingPlacement.Token
|
|
: 0u;
|
|
if (!IsCurrentPickupPresentation(
|
|
itemGuid,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (approach.IsCloseRange)
|
|
{
|
|
var pending = new PendingPostArrivalAction(
|
|
itemGuid,
|
|
approach.Target.LocalEntityId,
|
|
IsPickup: true,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken);
|
|
bool started = _movement.BeginApproach(
|
|
approach,
|
|
() => _pendingPostArrival = pending);
|
|
if (!started && _pendingPostArrival == pending)
|
|
CancelPendingApproach();
|
|
else if (!started)
|
|
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
|
return;
|
|
}
|
|
|
|
_movement.BeginApproach(approach);
|
|
if (!IsCurrentPickupPresentation(
|
|
itemGuid,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken))
|
|
{
|
|
return;
|
|
}
|
|
uint sequence = 0u;
|
|
if (_items.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.Pickup,
|
|
itemGuid,
|
|
() => _transport.TrySendPickup(
|
|
itemGuid,
|
|
destinationContainerId,
|
|
placement,
|
|
out sequence),
|
|
pendingPlacementToken))
|
|
{
|
|
Console.WriteLine(
|
|
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
|
|
}
|
|
else
|
|
{
|
|
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
|
public void OnNaturalMoveToComplete()
|
|
{
|
|
if (_pendingPostArrival is not { } pending)
|
|
return;
|
|
_pendingPostArrival = null;
|
|
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
|
|
{
|
|
if (pending.IsPickup)
|
|
CancelPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.PendingPlacementToken);
|
|
return;
|
|
}
|
|
|
|
if (pending.IsPickup)
|
|
{
|
|
if (!IsCurrentPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.DestinationContainerId,
|
|
pending.Placement,
|
|
pending.PendingPlacementToken))
|
|
{
|
|
return;
|
|
}
|
|
if (!_items.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.Pickup,
|
|
pending.ServerGuid,
|
|
() => _transport.TrySendPickup(
|
|
pending.ServerGuid,
|
|
pending.DestinationContainerId,
|
|
pending.Placement,
|
|
out _),
|
|
pending.PendingPlacementToken))
|
|
{
|
|
CancelPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.PendingPlacementToken);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_transport.TrySendUse(pending.ServerGuid, out _);
|
|
}
|
|
}
|
|
|
|
public void DrainOutbound() => _outbound.Drain();
|
|
|
|
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
|
|
|
|
public void OnEntityHidden(uint serverGuid)
|
|
{
|
|
if (_pendingPostArrival?.ServerGuid == serverGuid)
|
|
CancelPendingApproach();
|
|
if (_selection.SelectedObjectId == serverGuid)
|
|
{
|
|
_selection.Clear(
|
|
SelectionChangeSource.System,
|
|
SelectionChangeReason.Cleared);
|
|
}
|
|
}
|
|
|
|
public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
if (_pendingPostArrival is { } pending
|
|
&& pending.ServerGuid == record.ServerGuid
|
|
&& pending.LocalEntityId == record.LocalEntityId)
|
|
{
|
|
CancelPendingApproach();
|
|
}
|
|
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
|
|
{
|
|
_selection.Clear(
|
|
SelectionChangeSource.System,
|
|
SelectionChangeReason.SelectedObjectRemoved);
|
|
}
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
List<Exception> failures = [];
|
|
try { CancelPendingApproach(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _items.ResetSession(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _selection.Reset(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _outbound.Clear(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
_pendingPostArrival = null;
|
|
|
|
if (failures.Count != 0)
|
|
throw new AggregateException(
|
|
"One or more selection-interaction reset stages failed.",
|
|
failures);
|
|
}
|
|
|
|
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
|
|
{
|
|
if (_query.IsCreature(serverGuid))
|
|
{
|
|
if (showToast)
|
|
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
|
|
return false;
|
|
}
|
|
if (_query.IsPickupable(serverGuid))
|
|
return true;
|
|
if (showToast)
|
|
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid)));
|
|
return false;
|
|
}
|
|
|
|
private bool EnqueueIdentityBound(
|
|
uint serverGuid,
|
|
bool requireLiveEntity,
|
|
Action<uint> action)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(action);
|
|
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
|
|
? localId
|
|
: null;
|
|
ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured)
|
|
? captured
|
|
: null;
|
|
if ((requireLiveEntity && localEntityId is null)
|
|
|| (localEntityId is null && item is null))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item);
|
|
_outbound.Enqueue(() =>
|
|
{
|
|
if (IsCurrent(identity))
|
|
action(identity.ServerGuid);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
private bool IsCurrent(QueuedInteractionIdentity identity)
|
|
=> (identity.LocalEntityId is not uint localId
|
|
|| _query.IsCurrent(identity.ServerGuid, localId))
|
|
&& (identity.ClientObject is not { } item
|
|
|| _items.IsCurrentObjectIdentity(identity.ServerGuid, item));
|
|
|
|
private void CancelPendingApproach()
|
|
{
|
|
if (_pendingPostArrival is not { } pending)
|
|
return;
|
|
_pendingPostArrival = null;
|
|
if (pending.IsPickup)
|
|
CancelPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.PendingPlacementToken);
|
|
}
|
|
|
|
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
|
|
=> _items.CancelPendingBackpackPlacement(itemGuid, token);
|
|
|
|
private bool IsCurrentPickupPresentation(
|
|
uint itemGuid,
|
|
uint destinationContainerId,
|
|
int placement,
|
|
ulong token)
|
|
=> token != 0u
|
|
&& _items.TryGetPendingBackpackPlacement(
|
|
itemGuid,
|
|
out PendingBackpackPlacement pending)
|
|
&& pending.Token == token
|
|
&& pending.ContainerId == destinationContainerId
|
|
&& pending.Placement == placement;
|
|
}
|