feat(ui): centralize retail selection state

This commit is contained in:
Erik 2026-07-11 00:51:20 +02:00
parent c7607f019c
commit 7983309d23
30 changed files with 591 additions and 108 deletions

View file

@ -16,6 +16,7 @@ public sealed class GameWindow : IDisposable
private readonly string _datDir;
private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents;
private readonly AcDream.Core.Selection.SelectionState _selection;
private IWindow? _window;
private GL? _gl;
private IInputContext? _input;
@ -1032,24 +1033,6 @@ public sealed class GameWindow : IDisposable
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
private uint? _selectedGuid;
/// <summary>Fires when the selected world object changes (retail gmToolbarUI selection-change event,
/// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes.</summary>
private event Action<uint?>? SelectionChanged;
/// <summary>Currently-selected world object guid. The setter fires <see cref="SelectionChanged"/> only on
/// an actual change (dedup), so all writes go through here; reads may use the field directly.</summary>
private uint? SelectedGuid
{
get => _selectedGuid;
set
{
if (_selectedGuid == value) return;
_selectedGuid = value;
SelectionChanged?.Invoke(value);
}
}
// 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;
@ -1085,13 +1068,18 @@ public sealed class GameWindow : IDisposable
private int _liveAnimRejectSingleFrame;
private int _liveAnimRejectPartFrames;
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
public GameWindow(
AcDream.App.RuntimeOptions options,
WorldGameState worldGameState,
WorldEvents worldEvents,
AcDream.Core.Selection.SelectionState selection,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_uiRegistry = uiRegistry;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
@ -1525,13 +1513,13 @@ public sealed class GameWindow : IDisposable
// B.7 Vivid Target Indicator — corner-triangle highlight
// around the currently-selected entity. Delegates pull
// live state from this GameWindow instance every frame:
// - selected guid → _selectedGuid (set by PickAndStoreSelection)
// - selected guid → shared SelectionState
// - entity resolver → position from _entitiesByServerGuid +
// itemType from ClientObjectTable (Objects) + last spawn
// - camera → _cameraController.Active or (zero) when not
// yet ready, in which case the panel bails on viewport==0.
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
selectedGuidProvider: () => _selectedGuid,
selectedGuidProvider: () => _selection.SelectedObjectId,
entityResolver: guid =>
{
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
@ -2078,7 +2066,7 @@ public sealed class GameWindow : IDisposable
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selectedGuid,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
@ -2103,7 +2091,7 @@ public sealed class GameWindow : IDisposable
AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
guid => SelectedGuid = guid,
_selection,
SetRetailUiLocked),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
@ -2114,8 +2102,7 @@ public sealed class GameWindow : IDisposable
_itemInteractionController,
(index, guid) => _liveSession?.SendAddShortcut(index, guid),
index => _liveSession?.SendRemoveShortcut(index),
handler => SelectionChanged += handler,
handler => SelectionChanged -= handler,
_selection,
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
@ -2137,7 +2124,8 @@ public sealed class GameWindow : IDisposable
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
_itemInteractionController),
_itemInteractionController,
_selection),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
@ -4478,8 +4466,12 @@ public sealed class GameWindow : IDisposable
_remoteLastMove.Remove(serverGuid);
_entitiesByServerGuid.Remove(serverGuid);
_lastSpawnByGuid.Remove(serverGuid);
if (_selectedGuid == serverGuid)
SelectedGuid = null;
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear(
AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
}
// A7 indoor lighting: release this entity's owned lights on EVERY
// removal, including the respawn-dedup path (former logDelete=false).
@ -11874,6 +11866,10 @@ public sealed class GameWindow : IDisposable
SelectClosestCombatTarget(showToast: true);
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection:
_selection.SelectPrevious();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode();
break;
@ -11903,7 +11899,7 @@ public sealed class GameWindow : IDisposable
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selectedGuid is uint pickupTarget)
if (_selection.SelectedObjectId is uint pickupTarget)
SendPickUp(pickupTarget);
else
_debugVm?.AddToast("Nothing selected");
@ -11979,7 +11975,7 @@ public sealed class GameWindow : IDisposable
private uint? GetSelectedOrClosestCombatTarget()
{
if (_selectedGuid is { } selected && IsLiveCreatureTarget(selected))
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
return selected;
return SelectClosestCombatTarget(showToast: false);
@ -12066,7 +12062,7 @@ public sealed class GameWindow : IDisposable
return;
}
SelectedGuid = guid;
_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
@ -12107,7 +12103,7 @@ public sealed class GameWindow : IDisposable
private void UseCurrentSelection()
{
if (_selectedGuid is not uint sel)
if (_selection.SelectedObjectId is not uint sel)
{
_debugVm?.AddToast("Nothing selected");
return;
@ -12503,7 +12499,10 @@ public sealed class GameWindow : IDisposable
bestGuid = guid;
}
SelectedGuid = bestGuid;
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);
@ -13592,6 +13591,8 @@ public sealed class GameWindow : IDisposable
_retailUiRuntime?.Dispose();
_retailUiRuntime = null;
_uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
// Phase A.1: join the streamer worker thread before tearing down GL
// state. The worker may still be processing a load job that references