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:
Erik 2026-07-03 09:18:43 +02:00
parent e3fc7ac5ba
commit b7dc91a053
74 changed files with 6669 additions and 238 deletions

View 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;
}
}