feat(ui): D.2b item interaction + retail cursors + live character sheet
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
383
src/AcDream.App/UI/ItemInteractionController.cs
Normal file
383
src/AcDream.App/UI/ItemInteractionController.cs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
using System;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
|
||||
/// target acquisition, and drag-out drops here instead of duplicating
|
||||
/// ItemHolder::UseObject fragments in each panel.
|
||||
/// </summary>
|
||||
public sealed class ItemInteractionController
|
||||
{
|
||||
private static readonly EquipMask[] AutoEquipOrder =
|
||||
{
|
||||
EquipMask.HeadWear,
|
||||
EquipMask.ChestWear,
|
||||
EquipMask.AbdomenWear,
|
||||
EquipMask.UpperArmWear,
|
||||
EquipMask.LowerArmWear,
|
||||
EquipMask.HandWear,
|
||||
EquipMask.UpperLegWear,
|
||||
EquipMask.LowerLegWear,
|
||||
EquipMask.FootWear,
|
||||
EquipMask.ChestArmor,
|
||||
EquipMask.AbdomenArmor,
|
||||
EquipMask.UpperArmArmor,
|
||||
EquipMask.LowerArmArmor,
|
||||
EquipMask.UpperLegArmor,
|
||||
EquipMask.LowerLegArmor,
|
||||
EquipMask.NeckWear,
|
||||
EquipMask.WristWearLeft,
|
||||
EquipMask.WristWearRight,
|
||||
EquipMask.FingerWearLeft,
|
||||
EquipMask.FingerWearRight,
|
||||
EquipMask.Shield,
|
||||
EquipMask.MissileAmmo,
|
||||
EquipMask.MeleeWeapon,
|
||||
EquipMask.MissileWeapon,
|
||||
EquipMask.Held,
|
||||
EquipMask.TwoHanded,
|
||||
EquipMask.TrinketOne,
|
||||
EquipMask.Cloak,
|
||||
EquipMask.SigilOne,
|
||||
EquipMask.SigilTwo,
|
||||
EquipMask.SigilThree,
|
||||
};
|
||||
|
||||
private const long RetailUseThrottleMs = 200;
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<long> _nowMs;
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint, uint>? _sendUseWithTarget;
|
||||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint>? _sendDrop;
|
||||
private readonly Action<string>? _toast;
|
||||
|
||||
private long _lastUseMs = long.MinValue / 2;
|
||||
|
||||
public ItemInteractionController(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint, uint>? sendUseWithTarget,
|
||||
Action<uint, uint>? sendWield,
|
||||
Action<uint>? sendDrop,
|
||||
Func<long>? nowMs = null,
|
||||
Action<string>? toast = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_sendUse = sendUse;
|
||||
_sendUseWithTarget = sendUseWithTarget;
|
||||
_sendWield = sendWield;
|
||||
_sendDrop = sendDrop;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
}
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
||||
public uint PlayerGuid => _playerGuid();
|
||||
|
||||
public uint PendingSourceItem { get; private set; }
|
||||
|
||||
public bool IsTargetModeActive => PendingSourceItem != 0;
|
||||
|
||||
public bool IsPendingSource(uint itemGuid)
|
||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||
|
||||
public bool IsCurrentTargetCompatible(uint targetGuid)
|
||||
{
|
||||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||
|
||||
var source = _objects.Get(PendingSourceItem);
|
||||
return source?.Useability is { } useability
|
||||
&& TargetCompatible(source, targetGuid, useability);
|
||||
}
|
||||
|
||||
public bool ActivateItem(uint itemGuid)
|
||||
{
|
||||
if (itemGuid == 0) return false;
|
||||
|
||||
if (IsTargetModeActive)
|
||||
return AcquireTarget(itemGuid);
|
||||
|
||||
var item = _objects.Get(itemGuid);
|
||||
if (item is null) return false;
|
||||
|
||||
if (!ConsumeUseThrottle())
|
||||
return true;
|
||||
|
||||
if (item.Useability is { } targetedUse
|
||||
&& ItemUseability.IsTargeted(targetedUse)
|
||||
&& SourceCompatible(item, targetedUse))
|
||||
{
|
||||
EnterTargetMode(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsContainer(item))
|
||||
{
|
||||
_sendUse?.Invoke(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryAutoWield(item))
|
||||
return true;
|
||||
|
||||
if (item.Useability is { } directUse
|
||||
&& ItemUseability.IsDirectUseable(directUse)
|
||||
&& SourceCompatible(item, directUse))
|
||||
{
|
||||
_sendUse?.Invoke(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool AcquireTarget(uint targetGuid)
|
||||
{
|
||||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||
|
||||
uint sourceGuid = PendingSourceItem;
|
||||
ClearTargetMode();
|
||||
|
||||
var source = _objects.Get(sourceGuid);
|
||||
if (source?.Useability is not { } useability)
|
||||
return false;
|
||||
|
||||
if (!TargetCompatible(source, targetGuid, useability))
|
||||
return false;
|
||||
|
||||
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool AcquireSelfTarget()
|
||||
=> IsTargetModeActive && AcquireTarget(_playerGuid());
|
||||
|
||||
public void CancelTargetMode()
|
||||
{
|
||||
if (!IsTargetModeActive) return;
|
||||
ClearTargetMode();
|
||||
}
|
||||
|
||||
public bool DropToWorld(ItemDragPayload payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return false;
|
||||
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null)
|
||||
return false;
|
||||
|
||||
_objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1);
|
||||
_sendDrop?.Invoke(payload.ObjId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnterTargetMode(uint sourceGuid)
|
||||
{
|
||||
PendingSourceItem = sourceGuid;
|
||||
StateChanged?.Invoke();
|
||||
var name = _objects.Get(sourceGuid)?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
_toast?.Invoke($"Choose a target for the {name}");
|
||||
}
|
||||
|
||||
private void ClearTargetMode()
|
||||
{
|
||||
PendingSourceItem = 0;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private bool ConsumeUseThrottle()
|
||||
{
|
||||
long now = _nowMs();
|
||||
if (now - _lastUseMs < RetailUseThrottleMs)
|
||||
return false;
|
||||
_lastUseMs = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryAutoWield(ClientObject item)
|
||||
{
|
||||
if (item.ValidLocations == EquipMask.None
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
return false;
|
||||
|
||||
EquipMask mask = BestAvailableEquipMask(item);
|
||||
if (mask == EquipMask.None)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||
return false;
|
||||
|
||||
_sendWield?.Invoke(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
}
|
||||
|
||||
private EquipMask BestAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
if (ItemEquipRules.IsAutoWearItem(item))
|
||||
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
|
||||
|
||||
return FirstAvailableEquipMask(item);
|
||||
}
|
||||
|
||||
private bool AutoWearIsLegal(ClientObject item)
|
||||
{
|
||||
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
|
||||
if ((item.Priority & priorityMask) == 0)
|
||||
return true;
|
||||
|
||||
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations;
|
||||
return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null;
|
||||
}
|
||||
|
||||
private EquipMask FirstAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
foreach (var mask in AutoEquipOrder)
|
||||
{
|
||||
if ((item.ValidLocations & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (!EquipMaskOccupied(mask, item.ObjectId))
|
||||
return mask;
|
||||
}
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
|
||||
{
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
|
||||
{
|
||||
uint mask = 0;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.Priority;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
|
||||
{
|
||||
EquipMask mask = EquipMask.None;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.CurrentlyEquippedLocation;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
|
||||
{
|
||||
if (locationMask == EquipMask.None)
|
||||
return null;
|
||||
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if (!IsEquippedByPlayer(o))
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
|
||||
continue;
|
||||
if ((o.Priority & priority) != 0 || priority == 0)
|
||||
return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsContainer(ClientObject item)
|
||||
=> item.ContainerTypeHint != 0
|
||||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|| item.ItemsCapacity > 0;
|
||||
|
||||
private bool SourceCompatible(ClientObject source, uint useability)
|
||||
{
|
||||
uint flags = ItemUseability.SourceFlags(useability);
|
||||
if ((flags & ItemUseability.Remote) != 0) return true;
|
||||
if ((flags & ItemUseability.Viewed) != 0) return true;
|
||||
if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true;
|
||||
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true;
|
||||
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability)
|
||||
{
|
||||
uint flags = ItemUseability.TargetFlags(useability);
|
||||
uint player = _playerGuid();
|
||||
|
||||
if (targetGuid == source.ObjectId && (flags & ItemUseability.ObjSelf) != 0)
|
||||
return true;
|
||||
|
||||
if (targetGuid == player)
|
||||
return (flags & ItemUseability.Self) != 0;
|
||||
|
||||
var target = _objects.Get(targetGuid);
|
||||
if (target is null)
|
||||
return (flags & ItemUseability.Remote) != 0;
|
||||
|
||||
if (source.TargetType is { } targetType && targetType != 0
|
||||
&& ((uint)target.Type & targetType) == 0)
|
||||
return false;
|
||||
|
||||
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(target)) return true;
|
||||
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(target)) return true;
|
||||
if ((flags & ItemUseability.Viewed) != 0 && IsCarriedByPlayer(target)) return true;
|
||||
if ((flags & ItemUseability.Remote) != 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsWieldedByPlayer(ClientObject item)
|
||||
=> IsEquippedByPlayer(item);
|
||||
|
||||
private bool IsEquippedByPlayer(ClientObject item)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
return item.CurrentlyEquippedLocation != EquipMask.None
|
||||
&& (item.WielderId == player || item.ContainerId == player);
|
||||
}
|
||||
|
||||
private bool IsCarriedByPlayer(ClientObject item)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
uint container = item.ContainerId;
|
||||
for (int hops = 0; container != 0 && hops < 8; hops++)
|
||||
{
|
||||
if (container == player) return true;
|
||||
container = _objects.Get(container)?.ContainerId ?? 0u;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue