refactor(interaction): cut GameWindow over to controller
Route selection input, frame-ordered dispatch, movement completion, hidden/delete cleanup, and session reset through SelectionInteractionController. Keep ItemInteractionController backend-independent by constructing it outside the retained-UI gate.
This commit is contained in:
parent
fa8d5232cc
commit
d2bb5af453
1 changed files with 111 additions and 639 deletions
|
|
@ -45,6 +45,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||||||
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
|
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
|
||||||
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
|
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
|
||||||
|
private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions;
|
||||||
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
||||||
/// support. Required at startup — missing bindless throws
|
/// support. Required at startup — missing bindless throws
|
||||||
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
||||||
|
|
@ -183,7 +184,6 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
|
private readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
|
||||||
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
|
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
|
||||||
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
|
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
|
||||||
private readonly AcDream.App.Input.OutboundInteractionQueue _outboundInteractions = new();
|
|
||||||
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
|
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
|
||||||
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
|
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
|
||||||
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||||
|
|
@ -1162,20 +1162,6 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
|
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
|
||||||
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
|
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
|
||||||
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
|
|
||||||
// once the local auto-walk overlay reports arrival (body has finished
|
|
||||||
// rotating to face the target). Only set for close-range Use/PickUp;
|
|
||||||
// far-range sends fire the wire packet immediately at SendUse/SendPickUp
|
|
||||||
// time. Cleared before the deferred send fires — single-fire, no retry.
|
|
||||||
private PendingPostArrivalAction? _pendingPostArrivalAction;
|
|
||||||
|
|
||||||
private readonly record struct PendingPostArrivalAction(
|
|
||||||
uint Guid,
|
|
||||||
uint LocalEntityId,
|
|
||||||
bool IsPickup,
|
|
||||||
uint DestinationContainerId = 0u,
|
|
||||||
int Placement = 0);
|
|
||||||
|
|
||||||
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
|
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
|
||||||
// TargetManager voyeur system, registered in _physicsHosts so remote
|
// TargetManager voyeur system, registered in _physicsHosts so remote
|
||||||
// entities chasing the player resolve it. Replaces the AP-79
|
// entities chasing the player resolve it. Replaces the AP-79
|
||||||
|
|
@ -2114,63 +2100,64 @@ public sealed class GameWindow : IDisposable
|
||||||
Objects,
|
Objects,
|
||||||
guid => _liveSession?.SendNoLongerViewingContents(guid));
|
guid => _liveSession?.SendNoLongerViewingContents(guid));
|
||||||
|
|
||||||
|
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
|
||||||
|
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
||||||
|
Objects,
|
||||||
|
playerGuid: () => _playerServerGuid,
|
||||||
|
// ItemHolder::UseObject is the common policy owner, but world
|
||||||
|
// activation still has to pass through the application-level
|
||||||
|
// use adapter. It owns speculative turn/move, the close-range
|
||||||
|
// deferred send, and ACE's far-range MoveTo callback. Calling
|
||||||
|
// WorldSession directly here made keyboard R approach a corpse
|
||||||
|
// without completing the same open transaction as double-click.
|
||||||
|
sendUse: SendUse,
|
||||||
|
sendExamine: g => _liveSession?.SendAppraise(g),
|
||||||
|
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
||||||
|
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||||
|
sendDrop: item => _liveSession?.SendDropItem(item),
|
||||||
|
sendGive: (target, item, amount) =>
|
||||||
|
_liveSession?.SendGiveObject(target, item, amount),
|
||||||
|
dragOnPlayerOpensSecureTrade: () =>
|
||||||
|
(_characterOptions1
|
||||||
|
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
||||||
|
.DragItemOnPlayerOpensSecureTrade) != 0,
|
||||||
|
toast: text => _debugVm?.AddToast(text),
|
||||||
|
readyForInventoryRequest: () => _liveSession is not null
|
||||||
|
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
|
||||||
|
playerOnGround: GetDebugPlayerOnGround,
|
||||||
|
inNonCombatMode: () => Combat.CurrentMode
|
||||||
|
== AcDream.Core.Combat.CombatMode.NonCombat,
|
||||||
|
combatState: Combat,
|
||||||
|
sendChangeCombatMode: mode =>
|
||||||
|
_liveSession?.SendChangeCombatMode(mode),
|
||||||
|
isComponentPack: magicCatalog.IsComponentPack,
|
||||||
|
placeInBackpack: SendPickUp,
|
||||||
|
backpackContainerId: () =>
|
||||||
|
_retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId
|
||||||
|
?? _playerServerGuid,
|
||||||
|
// Retail ItemHolder::DetermineUseResult treats children of the
|
||||||
|
// current ground object as loot. Keep this late-bound because
|
||||||
|
// ViewContents can replace/close the external container while
|
||||||
|
// the retained controller remains alive.
|
||||||
|
groundObjectId: () => _externalContainers.CurrentContainerId,
|
||||||
|
sendSplitToWorld: (item, amount) =>
|
||||||
|
_liveSession?.SendStackableSplitTo3D(item, amount),
|
||||||
|
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
|
||||||
|
stackSplitQuantity: _stackSplitQuantity,
|
||||||
|
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||||
|
sendPutItemInContainer: (item, container, placement) =>
|
||||||
|
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||||
|
sendSplitToContainer: (item, container, placement, amount) =>
|
||||||
|
_liveSession?.SendStackableSplitToContainer(
|
||||||
|
item, container, placement, amount),
|
||||||
|
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
|
||||||
|
|
||||||
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
||||||
if (_options.RetailUi)
|
if (_options.RetailUi)
|
||||||
{
|
{
|
||||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
||||||
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
|
|
||||||
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
|
||||||
Objects,
|
|
||||||
playerGuid: () => _playerServerGuid,
|
|
||||||
// ItemHolder::UseObject is the common policy owner, but world
|
|
||||||
// activation still has to pass through the application-level
|
|
||||||
// use adapter. It owns speculative turn/move, the close-range
|
|
||||||
// deferred send, and ACE's far-range MoveTo callback. Calling
|
|
||||||
// WorldSession directly here made keyboard R approach a corpse
|
|
||||||
// without completing the same open transaction as double-click.
|
|
||||||
sendUse: SendUse,
|
|
||||||
sendExamine: g => _liveSession?.SendAppraise(g),
|
|
||||||
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
|
||||||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
|
||||||
sendDrop: item => _liveSession?.SendDropItem(item),
|
|
||||||
sendGive: (target, item, amount) =>
|
|
||||||
_liveSession?.SendGiveObject(target, item, amount),
|
|
||||||
dragOnPlayerOpensSecureTrade: () =>
|
|
||||||
(_characterOptions1
|
|
||||||
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
|
||||||
.DragItemOnPlayerOpensSecureTrade) != 0,
|
|
||||||
toast: text => _debugVm?.AddToast(text),
|
|
||||||
readyForInventoryRequest: () => _liveSession is not null
|
|
||||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
|
|
||||||
playerOnGround: GetDebugPlayerOnGround,
|
|
||||||
inNonCombatMode: () => Combat.CurrentMode
|
|
||||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
|
||||||
combatState: Combat,
|
|
||||||
sendChangeCombatMode: mode =>
|
|
||||||
_liveSession?.SendChangeCombatMode(mode),
|
|
||||||
isComponentPack: magicCatalog.IsComponentPack,
|
|
||||||
placeInBackpack: SendPickUp,
|
|
||||||
backpackContainerId: () =>
|
|
||||||
_retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId
|
|
||||||
?? _playerServerGuid,
|
|
||||||
// Retail ItemHolder::DetermineUseResult treats children of the
|
|
||||||
// current ground object as loot. Keep this late-bound because
|
|
||||||
// ViewContents can replace/close the external container while
|
|
||||||
// the retained controller remains alive.
|
|
||||||
groundObjectId: () => _externalContainers.CurrentContainerId,
|
|
||||||
sendSplitToWorld: (item, amount) =>
|
|
||||||
_liveSession?.SendStackableSplitTo3D(item, amount),
|
|
||||||
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
|
|
||||||
stackSplitQuantity: _stackSplitQuantity,
|
|
||||||
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
|
||||||
sendPutItemInContainer: (item, container, placement) =>
|
|
||||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
|
||||||
sendSplitToContainer: (item, container, placement, amount) =>
|
|
||||||
_liveSession?.SendStackableSplitToContainer(
|
|
||||||
item, container, placement, amount),
|
|
||||||
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
|
|
||||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||||
_itemInteractionController,
|
_itemInteractionController,
|
||||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||||
|
|
@ -2780,6 +2767,18 @@ public sealed class GameWindow : IDisposable
|
||||||
return (sphere.Origin, sphere.Radius);
|
return (sphere.Origin, sphere.Radius);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (_itemInteractionController is { } itemInteractions)
|
||||||
|
{
|
||||||
|
_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(
|
||||||
|
_selection,
|
||||||
|
_worldSelectionQuery,
|
||||||
|
itemInteractions,
|
||||||
|
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
|
||||||
|
() => _liveSession),
|
||||||
|
new AcDream.App.Interaction.PlayerInteractionMovementSink(
|
||||||
|
() => _playerController),
|
||||||
|
text => _debugVm?.AddToast(text));
|
||||||
|
}
|
||||||
// A.5 T22.5: apply A2C gate from quality preset.
|
// A.5 T22.5: apply A2C gate from quality preset.
|
||||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||||
|
|
||||||
|
|
@ -3020,10 +3019,13 @@ public sealed class GameWindow : IDisposable
|
||||||
Objects.Clear();
|
Objects.Clear();
|
||||||
SpellBook.Clear();
|
SpellBook.Clear();
|
||||||
_magicRuntime?.Reset();
|
_magicRuntime?.Reset();
|
||||||
_itemInteractionController?.ResetSession();
|
if (_selectionInteractions is { } interactions)
|
||||||
_selection.Reset();
|
interactions.ResetSession();
|
||||||
_outboundInteractions.Clear();
|
else
|
||||||
_pendingPostArrivalAction = null;
|
{
|
||||||
|
_itemInteractionController?.ResetSession();
|
||||||
|
_selection.Reset();
|
||||||
|
}
|
||||||
_retailSelectionScene?.Reset();
|
_retailSelectionScene?.Reset();
|
||||||
_particleVisibility.Reset();
|
_particleVisibility.Reset();
|
||||||
try
|
try
|
||||||
|
|
@ -5493,16 +5495,12 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private void ClearTargetForHiddenEntity(uint serverGuid)
|
private void ClearTargetForHiddenEntity(uint serverGuid)
|
||||||
{
|
{
|
||||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
if (_selectionInteractions is { } interactions)
|
||||||
&& pendingGuid == serverGuid)
|
interactions.OnEntityHidden(serverGuid);
|
||||||
_pendingPostArrivalAction = null;
|
else if (_selection.SelectedObjectId == serverGuid)
|
||||||
|
|
||||||
if (_selection.SelectedObjectId == serverGuid)
|
|
||||||
{
|
|
||||||
_selection.Clear(
|
_selection.Clear(
|
||||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||||
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||||||
}
|
|
||||||
|
|
||||||
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
|
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
|
||||||
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||||||
|
|
@ -5531,11 +5529,18 @@ public sealed class GameWindow : IDisposable
|
||||||
() => _remoteTeleportController?.Forget(record),
|
() => _remoteTeleportController?.Forget(record),
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
bool replacementExists =
|
||||||
&& pendingGuid == serverGuid
|
_liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord current) == true
|
||||||
&& _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId)
|
&& !ReferenceEquals(current, record);
|
||||||
|
if (_selectionInteractions is { } interactions)
|
||||||
{
|
{
|
||||||
_pendingPostArrivalAction = null;
|
interactions.OnEntityRemoved(record, replacementExists);
|
||||||
|
}
|
||||||
|
else if (!replacementExists && _selection.SelectedObjectId == serverGuid)
|
||||||
|
{
|
||||||
|
_selection.Clear(
|
||||||
|
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||||
|
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -5599,16 +5604,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// incarnation.
|
// incarnation.
|
||||||
cleanups.Add(() => incarnation.RunIfNoReplacement(
|
cleanups.Add(() => incarnation.RunIfNoReplacement(
|
||||||
() => _remoteLastMove.Remove(serverGuid)));
|
() => _remoteLastMove.Remove(serverGuid)));
|
||||||
cleanups.Add(() => incarnation.RunIfNoReplacement(() =>
|
|
||||||
{
|
|
||||||
if (_selection.SelectedObjectId == serverGuid)
|
|
||||||
{
|
|
||||||
_selection.Clear(
|
|
||||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
|
||||||
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
|
cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
|
||||||
cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
|
cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
|
||||||
// Logical teardown ends the retained shadow payload too. The weaker
|
// Logical teardown ends the retained shadow payload too. The weaker
|
||||||
|
|
@ -5948,7 +5943,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
// Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
|
// Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
|
||||||
&& IsDoorName(LiveName(update.Guid)))
|
&& IsDoorName(Objects.Get(update.Guid)?.Name))
|
||||||
{
|
{
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
|
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
|
||||||
|
|
@ -11495,7 +11490,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// active server MoveTo chain when it receives MoveToState, so sending
|
// active server MoveTo chain when it receives MoveToState, so sending
|
||||||
// Use directly from Silk's key callback inverted that order and left
|
// Use directly from Silk's key callback inverted that order and left
|
||||||
// the client walking to a corpse whose server callback was cancelled.
|
// the client walking to a corpse whose server callback was cancelled.
|
||||||
_outboundInteractions.Drain();
|
_selectionInteractions?.DrainOutbound();
|
||||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||||
System.Numerics.Vector3? playerPosition =
|
System.Numerics.Vector3? playerPosition =
|
||||||
_entitiesByServerGuid.TryGetValue(
|
_entitiesByServerGuid.TryGetValue(
|
||||||
|
|
@ -13503,6 +13498,9 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_retailUiRuntime?.HandleInputAction(action) == true)
|
if (_retailUiRuntime?.HandleInputAction(action) == true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_selectionInteractions?.HandleInputAction(action) == true)
|
||||||
|
return;
|
||||||
|
|
||||||
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
|
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
|
||||||
// Q to start, press Q again to stop. Pressing Backup / Stop /
|
// Q to start, press Q again to stop. Pressing Backup / Stop /
|
||||||
// StrafeLeft / StrafeRight while autorun is active also cancels it
|
// StrafeLeft / StrafeRight while autorun is active also cancels it
|
||||||
|
|
@ -13599,38 +13597,10 @@ public sealed class GameWindow : IDisposable
|
||||||
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster:
|
|
||||||
SelectClosestCombatTarget(showToast: true);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection:
|
|
||||||
_selection.SelectPrevious();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
||||||
ToggleLiveCombatMode();
|
ToggleLiveCombatMode();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
|
|
||||||
PickAndStoreSelection(useImmediately: false);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft:
|
|
||||||
PickAndStoreSelection(useImmediately: true);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.UseSelected:
|
|
||||||
UseCurrentSelection();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
|
|
||||||
if (_selection.SelectedObjectId is uint pickupTarget)
|
|
||||||
_outboundInteractions.Enqueue(() =>
|
|
||||||
_itemInteractionController?.PlaceWorldItemInBackpack(pickupTarget));
|
|
||||||
else
|
|
||||||
_debugVm?.AddToast("Nothing selected");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||||
if (_itemInteractionController?.IsAnyTargetModeActive == true)
|
if (_itemInteractionController?.IsAnyTargetModeActive == true)
|
||||||
_itemInteractionController.CancelTargetMode();
|
_itemInteractionController.CancelTargetMode();
|
||||||
|
|
@ -13757,15 +13727,8 @@ public sealed class GameWindow : IDisposable
|
||||||
result);
|
result);
|
||||||
|
|
||||||
private uint? GetSelectedOrClosestCombatTarget()
|
private uint? GetSelectedOrClosestCombatTarget()
|
||||||
{
|
=> _selectionInteractions?.GetSelectedOrClosestCombatTarget(
|
||||||
if (_selection.SelectedObjectId is { } selected && IsLiveHostileMonsterTarget(selected))
|
_persistedGameplay.AutoTarget);
|
||||||
return selected;
|
|
||||||
|
|
||||||
if (!_persistedGameplay.AutoTarget)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return SelectClosestCombatTarget(showToast: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves retail's combat-camera target. ClientCombatSystem::
|
/// Resolves retail's combat-camera target. ClientCombatSystem::
|
||||||
|
|
@ -13798,450 +13761,44 @@ public sealed class GameWindow : IDisposable
|
||||||
/// pick the local player while plain selection excludes it.
|
/// pick the local player while plain selection excludes it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
||||||
=> _worldSelectionQuery?.PickAtCursor(includeSelf);
|
=> _selectionInteractions?.PickAtCursor(includeSelf)
|
||||||
|
?? _worldSelectionQuery?.PickAtCursor(includeSelf);
|
||||||
|
|
||||||
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
||||||
=> _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf);
|
=> _selectionInteractions?.PickAt(mouseX, mouseY, includeSelf)
|
||||||
|
?? _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf);
|
||||||
|
|
||||||
private void BeginSelectionLightingPulse(uint serverGuid)
|
private void BeginSelectionLightingPulse(uint serverGuid)
|
||||||
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid);
|
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid);
|
||||||
|
|
||||||
private void PickAndStoreSelection(bool useImmediately)
|
// Contained/toolbar activation is not a world-selection interaction: it
|
||||||
{
|
// sends directly without speculative TurnToObject/MoveToObject.
|
||||||
if (_cameraController is null || _window is null) return;
|
|
||||||
|
|
||||||
// Target-use mode picks WITH self (retail: kit-heal yourself by
|
|
||||||
// clicking your own toon); plain selection keeps the self-exclusion.
|
|
||||||
var picked = PickWorldGuidAtCursor(
|
|
||||||
includeSelf: _itemInteractionController?.IsAnyTargetModeActive == true);
|
|
||||||
|
|
||||||
if (picked is uint guid)
|
|
||||||
{
|
|
||||||
// Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
|
||||||
// @ 0x004E5AD0 pulses every successfully found click before it
|
|
||||||
// branches into select/use/targeted-use behavior.
|
|
||||||
BeginSelectionLightingPulse(guid);
|
|
||||||
|
|
||||||
if (_itemInteractionController?.OfferPrimaryClick(guid)
|
|
||||||
is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World);
|
|
||||||
string label = DescribeLiveEntity(guid);
|
|
||||||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
|
||||||
// B.7 (2026-05-15): one-shot per-pick diagnostic so we can
|
|
||||||
// see exactly which ItemType + PWD bitfield bits + resolved
|
|
||||||
// RadarBlipColor are produced for the just-picked entity.
|
|
||||||
// Helps verify whether a "green NPC" really is flagged as
|
|
||||||
// Vendor server-side or whether our lookup is wrong.
|
|
||||||
uint rawItemType = (uint)LiveItemType(guid);
|
|
||||||
uint pwdBits = 0;
|
|
||||||
uint? pickUseability = null;
|
|
||||||
float? pickUseRadius = null;
|
|
||||||
float pickScale = 1f;
|
|
||||||
uint? pickSetupId = null;
|
|
||||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
|
||||||
{
|
|
||||||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
|
||||||
pickUseability = spawn.Useability;
|
|
||||||
pickUseRadius = spawn.UseRadius;
|
|
||||||
pickScale = spawn.ObjScale ?? 1f;
|
|
||||||
pickSetupId = spawn.SetupTableId;
|
|
||||||
}
|
|
||||||
var col = AcDream.Core.Ui.RadarBlipColors.For(rawItemType, pwdBits);
|
|
||||||
string useStr = pickUseability.HasValue ? $"0x{pickUseability.Value:X4}" : "null";
|
|
||||||
string radStr = pickUseRadius.HasValue ? pickUseRadius.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "null";
|
|
||||||
string setupStr = pickSetupId.HasValue ? $"0x{pickSetupId.Value:X8}" : "null";
|
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
|
||||||
$"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})"));
|
|
||||||
_debugVm?.AddToast($"Selected: {label}");
|
|
||||||
if (useImmediately)
|
|
||||||
{
|
|
||||||
// Route world double-click through the same retail
|
|
||||||
// ItemHolder policy as keyboard Use. This both establishes
|
|
||||||
// ClientUISystem::groundObject for containers and preserves
|
|
||||||
// movement-release-before-Use wire ordering.
|
|
||||||
_outboundInteractions.Enqueue(() =>
|
|
||||||
_itemInteractionController?.ActivateItem(guid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (_itemInteractionController?.IsAnyTargetModeActive == true)
|
|
||||||
return;
|
|
||||||
_debugVm?.AddToast("Nothing to select");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UseCurrentSelection()
|
|
||||||
{
|
|
||||||
if (_selection.SelectedObjectId is not uint sel)
|
|
||||||
{
|
|
||||||
_debugVm?.AddToast("Nothing selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retail sends keyboard Use through ItemHolder::UseObject @ 0x00588A80,
|
|
||||||
// the same policy owner used by the toolbar. Keeping a second classifier here
|
|
||||||
// made BF_CORPSE containers look pickupable and sent PutItemInContainer,
|
|
||||||
// which ACE correctly rejected as Stuck (0x0029).
|
|
||||||
uint selected = sel;
|
|
||||||
_outboundInteractions.Enqueue(() =>
|
|
||||||
_itemInteractionController?.UseSelectedOrEnterMode(selected));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SendUse(uint guid)
|
|
||||||
{
|
|
||||||
if (_liveSession is null
|
|
||||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
|
||||||
{
|
|
||||||
_debugVm?.AddToast("Not in world");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retail-faithful useability gate (acclient_2013_pseudo_c.txt:256455
|
|
||||||
// ItemUses::IsUseable). Signs / banners with useability=0 silently
|
|
||||||
// ignore Use.
|
|
||||||
if (!IsUseableTarget(guid))
|
|
||||||
{
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
|
|
||||||
// through the player's MoveToManager so close-range Use rotates the
|
|
||||||
// body to face before the action fires. For FAR targets, ACE's
|
|
||||||
// CreateMoveToChain (Player_Move.cs:37-179) takes over via inbound
|
|
||||||
// MovementType=6, whose PerformMovement re-targets with ACE's
|
|
||||||
// wire-supplied radius.
|
|
||||||
//
|
|
||||||
// Close-range deferral fires the wire packet ONCE on
|
|
||||||
// MoveToComplete(None) (turn-first done), not a retry of an
|
|
||||||
// earlier failed send. No re-send path.
|
|
||||||
if (_worldSelectionQuery?.TryGetInteractionTarget(
|
|
||||||
guid,
|
|
||||||
out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool closeRange = IsCloseRangeTarget(guid);
|
|
||||||
InstallSpeculativeTurnToTarget(guid);
|
|
||||||
|
|
||||||
if (closeRange)
|
|
||||||
{
|
|
||||||
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
|
|
||||||
// will fire it after rotation completes.
|
|
||||||
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
|
||||||
guid,
|
|
||||||
useTarget.LocalEntityId,
|
|
||||||
IsPickup: false);
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Far range: send Use ONCE. ACE's CreateMoveToChain
|
|
||||||
// (Player_Use.cs:205) holds a callback (TryUseItem) and fires
|
|
||||||
// it server-side when WithinUseRadius passes during the
|
|
||||||
// MoveToChain poll (Player_Move.cs:150). No client-side retry
|
|
||||||
// needed — the Phase B.6 MoveToState-suppression fix
|
|
||||||
// (GameWindow.cs:6410) keeps ACE's chain alive during the
|
|
||||||
// walk.
|
|
||||||
var seq = _liveSession.NextGameActionSequence();
|
|
||||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
|
||||||
_liveSession.SendGameAction(body);
|
|
||||||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
{
|
|
||||||
string label = DescribeLiveEntity(guid);
|
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
|
||||||
$"[autowalk-out] op=use target=0x{guid:X8} name=\"{label}\" seq={seq}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase D.5.1 — direct use-by-guid for toolbar shortcut clicks.
|
|
||||||
// Mirrors the B.4b far-range send path; no proximity / auto-walk needed
|
|
||||||
// for items already in the player's inventory.
|
|
||||||
private void UseItemByGuid(uint guid)
|
private void UseItemByGuid(uint guid)
|
||||||
{
|
{
|
||||||
if (_liveSession is null
|
if (_liveSession is null
|
||||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||||
return;
|
return;
|
||||||
var seq = _liveSession.NextGameActionSequence();
|
uint sequence = _liveSession.NextGameActionSequence();
|
||||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
_liveSession.SendGameAction(
|
||||||
_liveSession.SendGameAction(body);
|
AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
|
||||||
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}");
|
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
|
|
||||||
{
|
|
||||||
if (_liveSession is null
|
|
||||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
|
||||||
{
|
|
||||||
_debugVm?.AddToast("Not in world");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creature-pickup block (with retail toast). Comes BEFORE the
|
|
||||||
// generic IsPickupableTarget gate so creatures get the specific
|
|
||||||
// "cannot pick up creatures!" message instead of the generic
|
|
||||||
// "can't be picked up!".
|
|
||||||
// Retail string acclient_2013_pseudo_c.txt:401642 (data_7e22b4).
|
|
||||||
if (IsLiveCreatureTarget(itemGuid))
|
|
||||||
{
|
|
||||||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotPickUpCreatures);
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
Console.WriteLine($"[B.5] SendPickUp ignored — creature item=0x{itemGuid:X8}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic non-pickupable gate (signs, banners, decorative scenery).
|
|
||||||
// Retail string acclient_2013_pseudo_c.txt:401589 (sprintf
|
|
||||||
// "The %s can't be picked up!").
|
|
||||||
if (!IsPickupableTarget(itemGuid))
|
|
||||||
{
|
|
||||||
string label = DescribeLiveEntity(itemGuid);
|
|
||||||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CantBePickedUp(label));
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
Console.WriteLine($"[B.5] SendPickUp ignored — not pickupable item=0x{itemGuid:X8}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// B.6 (2026-05-15): same speculative turn-to-target + deferral as
|
|
||||||
// SendUse — close-range pickup rotates locally to face the
|
|
||||||
// item first, then the wire packet fires when the local
|
|
||||||
// overlay reports arrival.
|
|
||||||
//
|
|
||||||
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
|
|
||||||
if (_worldSelectionQuery?.TryGetInteractionTarget(
|
|
||||||
itemGuid,
|
|
||||||
out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool closeRange = IsCloseRangeTarget(itemGuid);
|
|
||||||
InstallSpeculativeTurnToTarget(itemGuid);
|
|
||||||
|
|
||||||
if (closeRange)
|
|
||||||
{
|
|
||||||
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
|
||||||
itemGuid,
|
|
||||||
pickupTarget.LocalEntityId,
|
|
||||||
IsPickup: true,
|
|
||||||
destinationContainerId,
|
|
||||||
placement);
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
|
|
||||||
// callback pattern as SendUse — ACE's chain fires
|
|
||||||
// PutItemInContainer/Move server-side when in range. No
|
|
||||||
// client-side retry; Phase B.6 MoveToState suppression keeps
|
|
||||||
// ACE's chain alive.
|
|
||||||
var seq = _liveSession.NextGameActionSequence();
|
|
||||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
|
||||||
seq, itemGuid, destinationContainerId, placement);
|
|
||||||
_liveSession.SendGameAction(body);
|
|
||||||
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={seq}");
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
{
|
|
||||||
string label = DescribeLiveEntity(itemGuid);
|
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
|
||||||
$"[autowalk-out] op=pickup target=0x{itemGuid:X8} name=\"{label}\" seq={seq}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action
|
|
||||||
/// once the player's moveto completes naturally (the MoveToManager's
|
|
||||||
/// <c>MoveToComplete(None)</c> client seam — the body has finished
|
|
||||||
/// rotating to face / walking to the target; a user-input cancel never
|
|
||||||
/// fires it). This is a FIRST send — not a retry of an earlier failed
|
|
||||||
/// send. Far-range Use/PickUp paths fire the wire packet immediately at
|
|
||||||
/// <see cref="SendUse"/>/<see cref="SendPickUp"/> time and never touch
|
|
||||||
/// <c>_pendingPostArrivalAction</c>.
|
|
||||||
/// </summary>
|
|
||||||
private void OnAutoWalkArrivedSendDeferredAction()
|
|
||||||
{
|
|
||||||
if (_pendingPostArrivalAction is not { } pending)
|
|
||||||
return;
|
|
||||||
_pendingPostArrivalAction = null;
|
|
||||||
|
|
||||||
if (_liveSession is null
|
|
||||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
|
||||||
return;
|
|
||||||
if (_worldSelectionQuery?.IsCurrent(
|
|
||||||
pending.Guid,
|
|
||||||
pending.LocalEntityId) != true)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var seq = _liveSession.NextGameActionSequence();
|
|
||||||
if (pending.IsPickup)
|
|
||||||
{
|
|
||||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
|
||||||
seq,
|
|
||||||
pending.Guid,
|
|
||||||
pending.DestinationContainerId,
|
|
||||||
pending.Placement);
|
|
||||||
_liveSession.SendGameAction(body);
|
|
||||||
Console.WriteLine($"[B.5] pickup-deferred item=0x{pending.Guid:X8} container=0x{pending.DestinationContainerId:X8} placement={pending.Placement} seq={seq}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, pending.Guid);
|
|
||||||
_liveSession.SendGameAction(body);
|
|
||||||
Console.WriteLine($"[B.4b] use-deferred guid=0x{pending.Guid:X8} seq={seq}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// B.6 (2026-05-15). Install a local auto-walk overlay against the
|
|
||||||
/// given target entity at Use / PickUp send time. ACE's response
|
|
||||||
/// branches by distance:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>Far target → ACE broadcasts a <c>MovementType=6</c>
|
|
||||||
/// MoveToObject which arrives shortly after and overwrites
|
|
||||||
/// our speculative state with ACE's wire-supplied use-radius
|
|
||||||
/// and origin. No conflict; same target either way.</item>
|
|
||||||
/// <item>Close target → ACE skips MoveToChain (WithinUseRadius
|
|
||||||
/// shortcut at <c>Player_Move.cs:66</c>) and rotates the body
|
|
||||||
/// server-side via <c>Rotate(target)</c>. ACE doesn't broadcast
|
|
||||||
/// anything actionable to us, so our pre-installed overlay
|
|
||||||
/// handles the local rotation — body turns to face the target
|
|
||||||
/// in place, then ends.</item>
|
|
||||||
/// </list>
|
|
||||||
/// <para>
|
|
||||||
/// Per-type use radius mirrors the picker's radius heuristic:
|
|
||||||
/// 3 m for creatures, 2 m for doors / lifestones / portals,
|
|
||||||
/// 0.6 m for everything else (ground items).
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
/// <summary>
|
|
||||||
/// B.6 (2026-05-15). True if the local player is already inside the
|
|
||||||
/// target's use radius right now — i.e. ACE will NOT auto-walk us
|
|
||||||
/// when we send the action. Used to gate the close-range deferral
|
|
||||||
/// in SendUse / SendPickUp: when close, we hold the wire packet
|
|
||||||
/// until our local rotation overlay reports alignment, then fire.
|
|
||||||
/// </summary>
|
|
||||||
private bool IsCloseRangeTarget(uint targetGuid)
|
|
||||||
=> _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true
|
|
||||||
&& approach.IsCloseRange;
|
|
||||||
|
|
||||||
private bool IsWithinExternalContainerUseRange(uint targetGuid)
|
private bool IsWithinExternalContainerUseRange(uint targetGuid)
|
||||||
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
|
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
|
||||||
|
|
||||||
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
private void SendUse(uint guid)
|
||||||
{
|
=> _selectionInteractions?.SendUse(guid);
|
||||||
if (_playerController is not { } pc
|
|
||||||
|| pc.MoveTo is null
|
|
||||||
|| _worldSelectionQuery?.TryGetApproach(
|
|
||||||
targetGuid,
|
|
||||||
out AcDream.App.Interaction.InteractionApproach approach) != true)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
|
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
|
||||||
// from local distance so the speculative moveto uses the
|
=> _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement);
|
||||||
// same walk/run as the wire-triggered overwrite that arrives
|
|
||||||
// moments later. ACE's Creature.SetWalkRunThreshold sets
|
private void OnAutoWalkArrivedSendDeferredAction()
|
||||||
// CanCharge when player→target distance >= WalkRunThreshold /
|
=> _selectionInteractions?.OnNaturalMoveToComplete();
|
||||||
// 2 = 7.5 m (the 15 m wire default halved). Match exactly so
|
|
||||||
// the speculative install doesn't flip walk↔run when ACE's
|
|
||||||
// MoveToObject broadcast overwrites it (PerformMovement
|
|
||||||
// cancels + restarts — retail-consistent re-target).
|
|
||||||
// R4-V5: retail's client-initiated use flow issues TurnToObject /
|
|
||||||
// MoveToObject through the SAME manager the wire path uses (decomp
|
|
||||||
// §9a/§9b callers) — in-range targets get a pure turn-to-face;
|
|
||||||
// out-of-range targets get the local moveto that ACE's mt-6
|
|
||||||
// broadcast will re-target moments later. MovementParameters ctor
|
|
||||||
// defaults (0x1EE0F: MoveTowards, UseSpheres, threshold 15,
|
|
||||||
// MinDistance 0) match the old BeginServerAutoWalk install's
|
|
||||||
// semantics; only the AP-23 radius + the #77 CanCharge prediction
|
|
||||||
// are non-default.
|
|
||||||
var p = new AcDream.Core.Physics.Motion.MovementParameters
|
|
||||||
{
|
|
||||||
DistanceToObject = approach.UseRadius,
|
|
||||||
CanCharge = approach.CanCharge,
|
|
||||||
};
|
|
||||||
var ms = new AcDream.Core.Physics.MovementStruct
|
|
||||||
{
|
|
||||||
ObjectId = targetGuid,
|
|
||||||
TopLevelId = targetGuid,
|
|
||||||
Pos = new AcDream.Core.Physics.Position(
|
|
||||||
approach.Player.CellId, approach.Target.Entity.Position,
|
|
||||||
System.Numerics.Quaternion.Identity),
|
|
||||||
Params = p,
|
|
||||||
Type = approach.IsCloseRange
|
|
||||||
? AcDream.Core.Physics.MovementType.TurnToObject
|
|
||||||
: AcDream.Core.Physics.MovementType.MoveToObject,
|
|
||||||
};
|
|
||||||
// R5-V3 (#171, diff-review find): retail resolves the TARGET's
|
|
||||||
// PartArray radius/height at EVERY MoveToObject call site — the
|
|
||||||
// client-initiated use flow included (CPhysicsObj::MoveToObject
|
|
||||||
// 0x005128e9/0x00512903), not just the wire mt-6 route. Without
|
|
||||||
// this, the player's now-real own radius made the UseSpheres
|
|
||||||
// arrival hybrid (centerDist − playerRadius ≤ useRadius) — the
|
|
||||||
// auto-walk stopped ~one player-radius farther out than the AP-23
|
|
||||||
// constants intended, and the two MoveToObject sites disagreed.
|
|
||||||
(ms.Radius, ms.Height) = (approach.TargetRadius, approach.TargetHeight);
|
|
||||||
// Part of this install's AP-23 adaptation: store the autonomy flag
|
|
||||||
// exactly as the wire mt-6 this install anticipates would (the P1
|
|
||||||
// unpack store, IsAutonomous=false) — without it the per-tick
|
|
||||||
// pump's A3 dispatch stays on the raw branch (the user's last input
|
|
||||||
// set it autonomous) and clobbers this moveto's dispatched motions
|
|
||||||
// with the idle raw state.
|
|
||||||
_playerController.SetLastMoveWasAutonomous(false);
|
|
||||||
// R5-V5: through the facade (MovementManager::PerformMovement
|
|
||||||
// 0x005240d0) — retail's client-initiated use flow reaches the same
|
|
||||||
// manager the wire path uses.
|
|
||||||
pc.Movement.PerformMovement(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
private uint? SelectClosestCombatTarget(bool showToast)
|
private uint? SelectClosestCombatTarget(bool showToast)
|
||||||
{
|
=> _selectionInteractions?.SelectClosestCombatTarget(showToast);
|
||||||
AcDream.App.Interaction.ClosestCombatTarget? closest =
|
|
||||||
_worldSelectionQuery?.FindClosestHostileMonster();
|
|
||||||
uint? bestGuid = closest?.ServerGuid;
|
|
||||||
|
|
||||||
if (bestGuid is { } selection)
|
|
||||||
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
|
||||||
else
|
|
||||||
_selection.Clear(AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
|
||||||
if (bestGuid is { } selected)
|
|
||||||
{
|
|
||||||
string label = DescribeLiveEntity(selected);
|
|
||||||
float distance = MathF.Sqrt(closest!.Value.DistanceSquared);
|
|
||||||
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
|
|
||||||
if (showToast)
|
|
||||||
_debugVm?.AddToast($"Target {label}");
|
|
||||||
}
|
|
||||||
else if (showToast)
|
|
||||||
{
|
|
||||||
_debugVm?.AddToast("No monster target");
|
|
||||||
Console.WriteLine("combat: no creature target found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestGuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsLiveCreatureTarget(uint guid)
|
|
||||||
=> _worldSelectionQuery?.IsCreature(guid) == true;
|
|
||||||
|
|
||||||
private bool IsLiveHostileMonsterTarget(uint guid)
|
|
||||||
=> _worldSelectionQuery?.IsHostileMonster(guid) == true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
|
|
||||||
/// Exact projection of retail's <c>IsPlayer() || pet_owner ||
|
|
||||||
/// ClientCombatSystem::ObjectIsAttackable()</c> gate from
|
|
||||||
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>.
|
|
||||||
/// </summary>
|
|
||||||
private bool IsHealthBarTarget(uint guid)
|
private bool IsHealthBarTarget(uint guid)
|
||||||
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true;
|
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true;
|
||||||
|
|
||||||
|
|
@ -14288,91 +13845,6 @@ public sealed class GameWindow : IDisposable
|
||||||
/// why our previous mesh-AABB indicator was smaller than retail's.
|
/// why our previous mesh-AABB indicator was smaller than retail's.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool TryGetEntitySelectionSphere(uint guid,
|
|
||||||
out System.Numerics.Vector3 worldCenter,
|
|
||||||
out float worldRadius)
|
|
||||||
{
|
|
||||||
if (_worldSelectionQuery is { } query)
|
|
||||||
return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius);
|
|
||||||
worldCenter = default;
|
|
||||||
worldRadius = 0f;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 2026-05-15. Retail-faithful gate for R-key Use / F-key PickUp.
|
|
||||||
/// Returns true when the entity is interactable from the world per
|
|
||||||
/// the server-supplied <c>ITEM_USEABLE _useability</c> field
|
|
||||||
/// (acclient.h:6478) — specifically when the <c>USEABLE_REMOTE
|
|
||||||
/// (0x20)</c> bit is set OR a composite form containing it
|
|
||||||
/// (<c>USEABLE_REMOTE_NEVER_WALK = 0x60</c>, the
|
|
||||||
/// <c>SOURCE_X_TARGET_REMOTE</c> variants in the 0x200000+ range).
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Retail behaviour for non-useable entities (signs, banners,
|
|
||||||
/// decorative scenery): the R-key does nothing — no walk, no Use
|
|
||||||
/// packet, no toast. The retail client checks useability before
|
|
||||||
/// any action and silently ignores the press. We honor that with
|
|
||||||
/// a silent early return at the call site.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// <b>Fallback when useability is unknown.</b> The wire's
|
|
||||||
/// <c>weenieFlags & 0x10</c> bit gates whether ACE serializes
|
|
||||||
/// useability at all. If absent, <see cref="CreateObject.Parsed.Useability"/>
|
|
||||||
/// is null. Conservatively we permit Use for entities we've
|
|
||||||
/// historically been able to interact with — creatures, doors,
|
|
||||||
/// lifestones, portals, corpses — to avoid regressing the existing
|
|
||||||
/// M1 flows. Pure-scenery untyped entities (the sign case) fall
|
|
||||||
/// through to "blocked".
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private bool IsUseableTarget(uint guid)
|
|
||||||
=> _worldSelectionQuery?.IsUseable(guid) == true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// The earlier <c>(useability & USEABLE_REMOTE) != 0u</c> check
|
|
||||||
/// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE
|
|
||||||
/// action ("can the player activate this item from the world");
|
|
||||||
/// PICKUP is a separate action governed by retail's
|
|
||||||
/// PutItemInContainer handler, which accepts any small-item-class
|
|
||||||
/// entity from the world regardless of useability bits. A spell
|
|
||||||
/// component with useability=USEABLE_NO=1 is still pickupable in
|
|
||||||
/// retail — USEABLE_NO blocks using the component (you can't
|
|
||||||
/// "activate" it standalone), not picking it up.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Now matches retail: a non-Stuck small-item ItemType class
|
|
||||||
/// → pickupable. Server validates the request server-side
|
|
||||||
/// (in-range, target-still-exists, container-has-room).
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private bool IsPickupableTarget(uint guid)
|
|
||||||
=> _worldSelectionQuery?.IsPickupable(guid) == true;
|
|
||||||
|
|
||||||
private AcDream.Core.Items.ItemType LiveItemType(uint guid) =>
|
|
||||||
_worldSelectionQuery?.GetItemType(guid) ?? AcDream.Core.Items.ItemType.None;
|
|
||||||
|
|
||||||
private string? LiveName(uint guid) => Objects.Get(guid)?.Name;
|
|
||||||
|
|
||||||
private string DescribeLiveEntity(uint guid)
|
|
||||||
{
|
|
||||||
return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// K.1b: Tab handler extracted into a method so the dispatcher
|
|
||||||
/// subscriber can call it. Same body as the previous Tab branch in
|
|
||||||
/// the legacy direct keyboard handler — toggle player↔fly mode, set
|
|
||||||
/// up <see cref="PlayerMovementController"/> + <see cref="ChaseCamera"/>
|
|
||||||
/// when entering player mode, tear them down on exit.
|
|
||||||
/// K.2: also disarms the auto-entry trigger when the user toggles
|
|
||||||
/// manually (their choice wins).
|
|
||||||
/// </summary>
|
|
||||||
private void TogglePlayerMode()
|
private void TogglePlayerMode()
|
||||||
{
|
{
|
||||||
// Phase B.2 guard: only active when a live session is in-world.
|
// Phase B.2 guard: only active when a live session is in-world.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue