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.Selection.RetailSelectionScene? _retailSelectionScene;
|
||||
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
|
||||
private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions;
|
||||
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
||||
/// support. Required at startup — missing bindless throws
|
||||
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
||||
|
|
@ -183,7 +184,6 @@ public sealed class GameWindow : IDisposable
|
|||
private readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
|
||||
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
|
||||
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.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
|
||||
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||
|
|
@ -1162,20 +1162,6 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
|
||||
_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
|
||||
// TargetManager voyeur system, registered in _physicsHosts so remote
|
||||
// entities chasing the player resolve it. Replaces the AP-79
|
||||
|
|
@ -2114,63 +2100,64 @@ public sealed class GameWindow : IDisposable
|
|||
Objects,
|
||||
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).
|
||||
if (_options.RetailUi)
|
||||
{
|
||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
_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(
|
||||
_itemInteractionController,
|
||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||
|
|
@ -2780,6 +2767,18 @@ public sealed class GameWindow : IDisposable
|
|||
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.
|
||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||
|
||||
|
|
@ -3020,10 +3019,13 @@ public sealed class GameWindow : IDisposable
|
|||
Objects.Clear();
|
||||
SpellBook.Clear();
|
||||
_magicRuntime?.Reset();
|
||||
_itemInteractionController?.ResetSession();
|
||||
_selection.Reset();
|
||||
_outboundInteractions.Clear();
|
||||
_pendingPostArrivalAction = null;
|
||||
if (_selectionInteractions is { } interactions)
|
||||
interactions.ResetSession();
|
||||
else
|
||||
{
|
||||
_itemInteractionController?.ResetSession();
|
||||
_selection.Reset();
|
||||
}
|
||||
_retailSelectionScene?.Reset();
|
||||
_particleVisibility.Reset();
|
||||
try
|
||||
|
|
@ -5493,16 +5495,12 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void ClearTargetForHiddenEntity(uint serverGuid)
|
||||
{
|
||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||
&& pendingGuid == serverGuid)
|
||||
_pendingPostArrivalAction = null;
|
||||
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
if (_selectionInteractions is { } interactions)
|
||||
interactions.OnEntityHidden(serverGuid);
|
||||
else if (_selection.SelectedObjectId == serverGuid)
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||||
}
|
||||
|
||||
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
|
||||
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||||
|
|
@ -5531,11 +5529,18 @@ public sealed class GameWindow : IDisposable
|
|||
() => _remoteTeleportController?.Forget(record),
|
||||
() =>
|
||||
{
|
||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||
&& pendingGuid == serverGuid
|
||||
&& _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId)
|
||||
bool replacementExists =
|
||||
_liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord current) == true
|
||||
&& !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.
|
||||
cleanups.Add(() => incarnation.RunIfNoReplacement(
|
||||
() => _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(() => LeaveWorldLiveEntityRuntimeComponents(record));
|
||||
// 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.
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
|
||||
&& IsDoorName(LiveName(update.Guid)))
|
||||
&& IsDoorName(Objects.Get(update.Guid)?.Name))
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[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
|
||||
// Use directly from Silk's key callback inverted that order and left
|
||||
// the client walking to a corpse whose server callback was cancelled.
|
||||
_outboundInteractions.Drain();
|
||||
_selectionInteractions?.DrainOutbound();
|
||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||
System.Numerics.Vector3? playerPosition =
|
||||
_entitiesByServerGuid.TryGetValue(
|
||||
|
|
@ -13503,6 +13498,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (_retailUiRuntime?.HandleInputAction(action) == true)
|
||||
return;
|
||||
|
||||
if (_selectionInteractions?.HandleInputAction(action) == true)
|
||||
return;
|
||||
|
||||
// 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 /
|
||||
// StrafeLeft / StrafeRight while autorun is active also cancels it
|
||||
|
|
@ -13599,38 +13597,10 @@ public sealed class GameWindow : IDisposable
|
|||
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
||||
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:
|
||||
ToggleLiveCombatMode();
|
||||
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:
|
||||
if (_itemInteractionController?.IsAnyTargetModeActive == true)
|
||||
_itemInteractionController.CancelTargetMode();
|
||||
|
|
@ -13757,15 +13727,8 @@ public sealed class GameWindow : IDisposable
|
|||
result);
|
||||
|
||||
private uint? GetSelectedOrClosestCombatTarget()
|
||||
{
|
||||
if (_selection.SelectedObjectId is { } selected && IsLiveHostileMonsterTarget(selected))
|
||||
return selected;
|
||||
|
||||
if (!_persistedGameplay.AutoTarget)
|
||||
return null;
|
||||
|
||||
return SelectClosestCombatTarget(showToast: false);
|
||||
}
|
||||
=> _selectionInteractions?.GetSelectedOrClosestCombatTarget(
|
||||
_persistedGameplay.AutoTarget);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
||||
=> _worldSelectionQuery?.PickAtCursor(includeSelf);
|
||||
=> _selectionInteractions?.PickAtCursor(includeSelf)
|
||||
?? _worldSelectionQuery?.PickAtCursor(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)
|
||||
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid);
|
||||
|
||||
private void PickAndStoreSelection(bool useImmediately)
|
||||
{
|
||||
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.
|
||||
// Contained/toolbar activation is not a world-selection interaction: it
|
||||
// sends directly without speculative TurnToObject/MoveToObject.
|
||||
private void UseItemByGuid(uint guid)
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||
_liveSession.SendGameAction(body);
|
||||
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}");
|
||||
uint sequence = _liveSession.NextGameActionSequence();
|
||||
_liveSession.SendGameAction(
|
||||
AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
|
||||
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)
|
||||
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
|
||||
|
||||
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
||||
{
|
||||
if (_playerController is not { } pc
|
||||
|| pc.MoveTo is null
|
||||
|| _worldSelectionQuery?.TryGetApproach(
|
||||
targetGuid,
|
||||
out AcDream.App.Interaction.InteractionApproach approach) != true)
|
||||
return;
|
||||
private void SendUse(uint guid)
|
||||
=> _selectionInteractions?.SendUse(guid);
|
||||
|
||||
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
|
||||
// from local distance so the speculative moveto uses the
|
||||
// same walk/run as the wire-triggered overwrite that arrives
|
||||
// moments later. ACE's Creature.SetWalkRunThreshold sets
|
||||
// CanCharge when player→target distance >= WalkRunThreshold /
|
||||
// 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 void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
|
||||
=> _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement);
|
||||
|
||||
private void OnAutoWalkArrivedSendDeferredAction()
|
||||
=> _selectionInteractions?.OnNaturalMoveToComplete();
|
||||
|
||||
private uint? SelectClosestCombatTarget(bool showToast)
|
||||
{
|
||||
AcDream.App.Interaction.ClosestCombatTarget? closest =
|
||||
_worldSelectionQuery?.FindClosestHostileMonster();
|
||||
uint? bestGuid = closest?.ServerGuid;
|
||||
=> _selectionInteractions?.SelectClosestCombatTarget(showToast);
|
||||
|
||||
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)
|
||||
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true;
|
||||
|
||||
|
|
@ -14288,91 +13845,6 @@ public sealed class GameWindow : IDisposable
|
|||
/// why our previous mesh-AABB indicator was smaller than retail's.
|
||||
/// </para>
|
||||
/// </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()
|
||||
{
|
||||
// Phase B.2 guard: only active when a live session is in-world.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue