feat(ui): centralize retail selection state
This commit is contained in:
parent
c7607f019c
commit
7983309d23
30 changed files with 591 additions and 108 deletions
|
|
@ -4,16 +4,23 @@ namespace AcDream.App.Plugins;
|
|||
|
||||
public sealed class AppPluginHost : IPluginHost
|
||||
{
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
|
||||
public AppPluginHost(
|
||||
IPluginLogger log,
|
||||
IGameState state,
|
||||
IEvents events,
|
||||
ISelectionService selection,
|
||||
IUiRegistry ui)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
Events = events;
|
||||
Selection = selection;
|
||||
Ui = ui;
|
||||
}
|
||||
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State { get; }
|
||||
public IEvents Events { get; }
|
||||
public ISelectionService Selection { get; }
|
||||
public IUiRegistry Ui { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
|||
|
||||
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
||||
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
||||
var selection = new AcDream.Core.Selection.SelectionState();
|
||||
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
|
||||
var host = new AppPluginHost(
|
||||
new SerilogAdapter(Log.Logger), worldGameState, worldEvents, selection, uiRegistry);
|
||||
|
||||
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||
|
|
@ -65,7 +67,8 @@ try
|
|||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
|
||||
using var window = new GameWindow(
|
||||
runtimeOptions, worldGameState, worldEvents, selection, uiRegistry);
|
||||
window.Run();
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ public static class FixtureProvider
|
|||
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
|
||||
|
||||
var iconIds = MakeIconIds(stack);
|
||||
var selection = new AcDream.Core.Selection.SelectionState();
|
||||
|
||||
InventoryController.Bind(
|
||||
layout,
|
||||
|
|
@ -120,6 +121,7 @@ public static class FixtureProvider
|
|||
playerGuid: () => SampleData.PlayerGuid,
|
||||
iconIds: iconIds,
|
||||
strength: () => 100,
|
||||
selection: selection,
|
||||
datFont: stack.VitalsDatFont,
|
||||
contentsEmptySprite: contentsEmpty,
|
||||
sideBagEmptySprite: sideBagEmpty,
|
||||
|
|
@ -133,6 +135,7 @@ public static class FixtureProvider
|
|||
objects,
|
||||
playerGuid: () => SampleData.PlayerGuid,
|
||||
iconIds: iconIds,
|
||||
selection: selection,
|
||||
sendWield: null, // no live session in the studio
|
||||
emptySlotSprite: contentsEmpty,
|
||||
datFont: stack.VitalsDatFont);
|
||||
|
|
|
|||
54
src/AcDream.App/UI/InteractionState.cs
Normal file
54
src/AcDream.App/UI/InteractionState.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
public enum InteractionModeKind
|
||||
{
|
||||
None,
|
||||
Use,
|
||||
Examine,
|
||||
UseItemOnTarget,
|
||||
}
|
||||
|
||||
public readonly record struct InteractionMode(
|
||||
InteractionModeKind Kind,
|
||||
uint SourceObjectId = 0)
|
||||
{
|
||||
public static InteractionMode None => new(InteractionModeKind.None);
|
||||
}
|
||||
|
||||
public readonly record struct InteractionModeTransition(
|
||||
InteractionMode Previous,
|
||||
InteractionMode Current);
|
||||
|
||||
/// <summary>
|
||||
/// Single App-layer owner for retail's pointer interaction mode. Core selection
|
||||
/// remains session truth; this state represents temporary UI orchestration.
|
||||
/// </summary>
|
||||
public sealed class InteractionState
|
||||
{
|
||||
public InteractionMode Current { get; private set; } = InteractionMode.None;
|
||||
|
||||
public event Action<InteractionModeTransition>? Changed;
|
||||
|
||||
public bool EnterUse() => Set(new InteractionMode(InteractionModeKind.Use));
|
||||
|
||||
public bool EnterExamine() => Set(new InteractionMode(InteractionModeKind.Examine));
|
||||
|
||||
public bool EnterUseItemOnTarget(uint sourceObjectId)
|
||||
{
|
||||
if (sourceObjectId == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(sourceObjectId));
|
||||
return Set(new InteractionMode(InteractionModeKind.UseItemOnTarget, sourceObjectId));
|
||||
}
|
||||
|
||||
public bool Clear() => Set(InteractionMode.None);
|
||||
|
||||
private bool Set(InteractionMode mode)
|
||||
{
|
||||
if (Current == mode)
|
||||
return false;
|
||||
InteractionMode previous = Current;
|
||||
Current = mode;
|
||||
Changed?.Invoke(new InteractionModeTransition(previous, mode));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ namespace AcDream.App.UI;
|
|||
/// target acquisition, and drag-out drops here instead of duplicating
|
||||
/// ItemHolder::UseObject fragments in each panel.
|
||||
/// </summary>
|
||||
public sealed class ItemInteractionController
|
||||
public sealed class ItemInteractionController : IDisposable
|
||||
{
|
||||
private static readonly EquipMask[] AutoEquipOrder =
|
||||
{
|
||||
|
|
@ -55,8 +55,10 @@ public sealed class ItemInteractionController
|
|||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint>? _sendDrop;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly InteractionState _interactionState;
|
||||
|
||||
private long _lastUseMs = long.MinValue / 2;
|
||||
private bool _disposed;
|
||||
|
||||
public ItemInteractionController(
|
||||
ClientObjectTable objects,
|
||||
|
|
@ -66,7 +68,8 @@ public sealed class ItemInteractionController
|
|||
Action<uint, uint>? sendWield,
|
||||
Action<uint>? sendDrop,
|
||||
Func<long>? nowMs = null,
|
||||
Action<string>? toast = null)
|
||||
Action<string>? toast = null,
|
||||
InteractionState? interactionState = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -76,15 +79,23 @@ public sealed class ItemInteractionController
|
|||
_sendDrop = sendDrop;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
_interactionState = interactionState ?? new InteractionState();
|
||||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
}
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
||||
public uint PlayerGuid => _playerGuid();
|
||||
|
||||
public uint PendingSourceItem { get; private set; }
|
||||
public InteractionState InteractionState => _interactionState;
|
||||
|
||||
public bool IsTargetModeActive => PendingSourceItem != 0;
|
||||
public uint PendingSourceItem
|
||||
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
|
||||
? mode.SourceObjectId
|
||||
: 0u;
|
||||
|
||||
public bool IsTargetModeActive
|
||||
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
|
||||
|
||||
public bool IsPendingSource(uint itemGuid)
|
||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||
|
|
@ -188,8 +199,7 @@ public sealed class ItemInteractionController
|
|||
|
||||
private void EnterTargetMode(uint sourceGuid)
|
||||
{
|
||||
PendingSourceItem = sourceGuid;
|
||||
StateChanged?.Invoke();
|
||||
_interactionState.EnterUseItemOnTarget(sourceGuid);
|
||||
var name = _objects.Get(sourceGuid)?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
_toast?.Invoke($"Choose a target for the {name}");
|
||||
|
|
@ -197,8 +207,17 @@ public sealed class ItemInteractionController
|
|||
|
||||
private void ClearTargetMode()
|
||||
{
|
||||
PendingSourceItem = 0;
|
||||
StateChanged?.Invoke();
|
||||
_interactionState.Clear();
|
||||
}
|
||||
|
||||
private void OnInteractionModeChanged(InteractionModeTransition _)
|
||||
=> StateChanged?.Invoke();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_interactionState.Changed -= OnInteractionModeChanged;
|
||||
}
|
||||
|
||||
private bool ConsumeUseThrottle()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
||||
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square)
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
|
|
@ -70,6 +71,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
SelectionState selection,
|
||||
Func<string>? ownerName,
|
||||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite,
|
||||
|
|
@ -90,6 +92,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_itemInteraction = itemInteraction;
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
|
||||
WindowChromeController.BindCloseButton(layout, onClose);
|
||||
|
||||
|
|
@ -152,8 +155,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
// Rebuild on any change to the player's possessions.
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
|
||||
|
|
@ -184,6 +188,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
SelectionState selection,
|
||||
UiDatFont? datFont,
|
||||
Func<string>? ownerName = null,
|
||||
uint contentsEmptySprite = 0u,
|
||||
|
|
@ -194,14 +199,27 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action? onClose = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont,
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
|
||||
ownerName, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction,
|
||||
onClose);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectRemoved(ClientObject o)
|
||||
{
|
||||
if (_selection.SelectedObjectId == o.ObjectId)
|
||||
{
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
if (Concerns(o)) Populate();
|
||||
}
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||
|
||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
|
|
@ -402,8 +420,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private void SelectItem(uint guid)
|
||||
{
|
||||
if (guid == 0) return;
|
||||
_selectedItem = guid;
|
||||
ApplyIndicators();
|
||||
_selection.Select(guid, SelectionChangeSource.Inventory);
|
||||
}
|
||||
|
||||
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
||||
|
|
@ -413,7 +430,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private void OpenContainer(uint guid)
|
||||
{
|
||||
if (guid == 0) return;
|
||||
_selectedItem = guid; // the container cell is also the selected item
|
||||
_selection.Select(guid, SelectionChangeSource.Inventory);
|
||||
uint open = EffectiveOpen();
|
||||
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
||||
|
||||
|
|
@ -444,7 +461,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
var cell = list.GetItem(i);
|
||||
if (cell is null) continue;
|
||||
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
|
||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource;
|
||||
cell.Selected = cell.ItemId != 0
|
||||
&& cell.ItemId == _selection.SelectedObjectId
|
||||
&& !pendingTargetSource;
|
||||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||||
}
|
||||
}
|
||||
|
|
@ -533,8 +552,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_disposed = true;
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
||||
|
||||
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -86,11 +88,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
|
||||
private PaperdollController(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
|
||||
Action<uint, uint>? sendWield,
|
||||
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
|
||||
{
|
||||
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
||||
_itemInteraction = itemInteraction;
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
|
||||
for (int i = 0; i < SlotMap.Length; i++)
|
||||
{
|
||||
|
|
@ -100,7 +104,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
list.Cell.SourceKind = ItemDragSource.Equipment;
|
||||
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
||||
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
||||
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
|
||||
list.Cell.Clicked = () =>
|
||||
{
|
||||
if (_itemInteraction?.IsTargetModeActive == true)
|
||||
{
|
||||
_itemInteraction.AcquireSelfTarget();
|
||||
return;
|
||||
}
|
||||
if (list.Cell.ItemId != 0)
|
||||
_selection.Select(list.Cell.ItemId, SelectionChangeSource.Paperdoll);
|
||||
};
|
||||
list.Cell.DoubleClicked = () =>
|
||||
{
|
||||
if (list.Cell.ItemId != 0)
|
||||
|
|
@ -119,6 +132,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
|
||||
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
||||
foreach (var id in ArmorSlotElementIds)
|
||||
|
|
@ -154,14 +168,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
|
||||
public static PaperdollController Bind(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
|
||||
Action<uint, uint>? sendWield = null,
|
||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
|
||||
ItemInteractionController? itemInteraction = null)
|
||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction);
|
||||
=> new PaperdollController(
|
||||
layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite,
|
||||
datFont, itemInteraction);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
|
||||
|
||||
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
||||
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
||||
|
|
@ -196,6 +214,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
|
||||
list.Cell.SetItem(worn.ObjectId, tex);
|
||||
}
|
||||
ApplySelectionIndicators();
|
||||
}
|
||||
|
||||
private void ApplySelectionIndicators()
|
||||
{
|
||||
foreach (var (_, list) in _slots)
|
||||
{
|
||||
list.Cell.Selected = list.Cell.ItemId != 0
|
||||
&& list.Cell.ItemId == _selection.SelectedObjectId;
|
||||
}
|
||||
}
|
||||
|
||||
private EquipMask MaskFor(UiItemList list)
|
||||
|
|
@ -251,5 +279,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
private readonly Func<uint, bool> _hasHealth;
|
||||
private readonly Func<uint, uint> _stackSize;
|
||||
private readonly Action<uint> _sendQueryHealth;
|
||||
private readonly Action<Action<uint?>> _unsubscribeSelectionChanged;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||||
|
||||
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||
|
|
@ -96,8 +97,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
|
||||
private SelectedObjectController(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged,
|
||||
Action<Action<uint?>> unsubscribeSelectionChanged,
|
||||
SelectionState selection,
|
||||
Action<Action<uint, float>> subscribeHealthChanged,
|
||||
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
|
|
@ -114,7 +114,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_hasHealth = hasHealth;
|
||||
_stackSize = stackSize;
|
||||
_sendQueryHealth = sendQueryHealth;
|
||||
_unsubscribeSelectionChanged = unsubscribeSelectionChanged;
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||||
|
||||
// Find elements — silently skip absent ones (partial/test layouts).
|
||||
|
|
@ -171,8 +171,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
}
|
||||
|
||||
// Register the handlers LAST so the initial state is fully set up first.
|
||||
subscribeSelectionChanged(OnSelectionChanged);
|
||||
_selection.Changed += OnSelectionTransition;
|
||||
subscribeHealthChanged(OnHealthChanged);
|
||||
if (_selection.SelectedObjectId is { } initial)
|
||||
ApplySelection(initial);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -180,8 +182,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
|
||||
/// <param name="subscribeSelectionChanged">Called once with <see cref="OnSelectionChanged"/>
|
||||
/// (typical host: <c>h => SelectionChanged += h</c>).</param>
|
||||
/// <param name="selection">The single Core selected-object owner.</param>
|
||||
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
|
||||
/// (typical host: <c>h => Combat.HealthChanged += h</c>) — drives meter visibility.</param>
|
||||
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
|
||||
|
|
@ -195,8 +196,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
|
||||
public static SelectedObjectController Bind(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged,
|
||||
Action<Action<uint?>> unsubscribeSelectionChanged,
|
||||
SelectionState selection,
|
||||
Action<Action<uint, float>> subscribeHealthChanged,
|
||||
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
|
|
@ -207,7 +207,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
=> new SelectedObjectController(
|
||||
layout, subscribeSelectionChanged, unsubscribeSelectionChanged,
|
||||
layout, selection,
|
||||
subscribeHealthChanged, unsubscribeHealthChanged,
|
||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
||||
|
||||
|
|
@ -215,7 +215,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
||||
/// clear-then-populate the selected-object strip on any selection change.
|
||||
/// </summary>
|
||||
public void OnSelectionChanged(uint? guid)
|
||||
private void ApplySelection(uint? guid)
|
||||
{
|
||||
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
|
||||
// + SetVisible(0) on the meters). ──────────────────────────────────────
|
||||
|
|
@ -279,11 +279,14 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_overlay?.TrySetRetailState(state);
|
||||
}
|
||||
|
||||
private void OnSelectionTransition(SelectionTransition transition)
|
||||
=> ApplySelection(transition.SelectedObjectId);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_unsubscribeSelectionChanged(OnSelectionChanged);
|
||||
_selection.Changed -= OnSelectionTransition;
|
||||
_unsubscribeHealthChanged(OnHealthChanged);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.App.UI.Testing;
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
|
@ -31,7 +32,7 @@ public sealed record ChatRuntimeBindings(ChatVM ViewModel, Func<ICommandBus> Com
|
|||
|
||||
public sealed record RadarRuntimeBindings(
|
||||
Func<UiRadarSnapshot> Snapshot,
|
||||
Action<uint> SelectObject,
|
||||
SelectionState Selection,
|
||||
Action<bool> SetUiLocked);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
|
|
@ -43,8 +44,7 @@ public sealed record ToolbarRuntimeBindings(
|
|||
ItemInteractionController ItemInteraction,
|
||||
Action<uint, uint>? SendAddShortcut,
|
||||
Action<uint>? SendRemoveShortcut,
|
||||
Action<Action<uint?>> SubscribeSelectionChanged,
|
||||
Action<Action<uint?>> UnsubscribeSelectionChanged,
|
||||
SelectionState Selection,
|
||||
Action<Action<uint, float>> SubscribeHealthChanged,
|
||||
Action<Action<uint, float>> UnsubscribeHealthChanged,
|
||||
Func<uint, bool> IsHealthTarget,
|
||||
|
|
@ -65,7 +65,8 @@ public sealed record InventoryRuntimeBindings(
|
|||
Action<uint>? SendNoLongerViewing,
|
||||
Action<uint, uint, int>? SendPutItemInContainer,
|
||||
Action<uint, uint>? SendWield,
|
||||
ItemInteractionController ItemInteraction);
|
||||
ItemInteractionController ItemInteraction,
|
||||
SelectionState Selection);
|
||||
|
||||
public sealed record RetailUiPersistenceBindings(
|
||||
SettingsStore Store,
|
||||
|
|
@ -256,7 +257,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
RadarController controller = RadarController.Bind(
|
||||
layout,
|
||||
_bindings.Radar.Snapshot,
|
||||
_bindings.Radar.SelectObject,
|
||||
guid => _bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar),
|
||||
setUiLocked: _bindings.Radar.SetUiLocked,
|
||||
datFont: _bindings.Assets.DefaultFont);
|
||||
RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite,
|
||||
|
|
@ -354,8 +355,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut);
|
||||
SelectedObjectController = Layout.SelectedObjectController.Bind(
|
||||
layout,
|
||||
b.SubscribeSelectionChanged,
|
||||
b.UnsubscribeSelectionChanged,
|
||||
b.Selection,
|
||||
b.SubscribeHealthChanged,
|
||||
b.UnsubscribeHealthChanged,
|
||||
b.IsHealthTarget,
|
||||
|
|
@ -570,13 +570,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
InventoryRuntimeBindings b = _bindings.Inventory;
|
||||
InventoryController inventory = InventoryController.Bind(
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength,
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
|
||||
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
||||
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
||||
b.SendPutItemInContainer, b.ItemInteraction,
|
||||
() => CloseWindow(WindowNames.Inventory));
|
||||
PaperdollController paperdoll = PaperdollController.Bind(
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.SendWield,
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
|
||||
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);
|
||||
Host.WindowManager.AttachController(
|
||||
WindowNames.Inventory,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ namespace AcDream.App.UI;
|
|||
/// on internal state types:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>selectedGuidProvider</c> — host's current <c>_selectedGuid</c>.</item>
|
||||
/// <item><c>selectedGuidProvider</c> — Core <c>SelectionState</c>'s current guid.</item>
|
||||
/// <item><c>entityResolver</c> — returns
|
||||
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
|
||||
/// the entity is no longer in the world (despawned).</item>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue