feat(items): port retail shared cooldown overlays

Preserve public shared-cooldown metadata, resolve the authoritative cooldown enchantment with retail expiry semantics, and project the exact ten DAT-authored radial steps through the shared retained item-slot architecture.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 10:41:16 +02:00
parent 19e8863f4e
commit 6b1ae4fb76
27 changed files with 875 additions and 20 deletions

View file

@ -0,0 +1,101 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared retained-UI owner for retail item cooldowns. It performs the retail
/// heartbeat before drawing and gives every current and future
/// <see cref="UiItemSlot"/> the same display projection.
/// </summary>
public sealed class ItemCooldownUiController
{
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<double> _currentTime;
private readonly List<UiItemList> _lists = new();
private readonly Dictionary<uint, int> _stepByItemId = new();
private ItemCooldownUiController(
Spellbook spellbook,
ClientObjectTable objects,
Func<double> currentTime)
{
_spellbook = spellbook;
_objects = objects;
_currentTime = currentTime;
}
public static ItemCooldownUiController Bind(
UiElement root,
Spellbook spellbook,
ClientObjectTable objects,
Func<double> currentTime,
ItemCooldownAssets assets)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(currentTime);
var controller = new ItemCooldownUiController(
spellbook,
objects,
currentTime);
controller.Configure(root, assets.Sprites);
controller.Tick();
return controller;
}
public void Tick()
{
double now = _currentTime();
_stepByItemId.Clear();
// Retail updates cooldown visibility from UIElement_UIItem::DoHeartbeat,
// not while drawing. Query each represented item once so an expired
// registry node can be removed safely before the render walk.
foreach (UiItemList list in _lists)
{
for (int index = 0; index < list.GetNumUIItems(); index++)
{
UiItemSlot? cell = list.GetItem(index);
if (cell is null)
continue;
uint itemId = cell.ItemId;
if (itemId == 0u || _stepByItemId.ContainsKey(itemId))
continue;
int step = 0;
ClientObject? item = _objects.Get(itemId);
if (item?.CooldownId is { } cooldownId
&& item.CooldownDuration is { } duration
&& _spellbook.OnCooldown(
cooldownId,
now,
out double remaining))
step = ItemCooldownDisplay.GetOverlayStep(
duration,
remaining);
_stepByItemId.Add(itemId, step);
}
}
}
internal int GetOverlayStep(uint itemId)
=> _stepByItemId.TryGetValue(itemId, out int step) ? step : 0;
private void Configure(UiElement element, IReadOnlyList<uint> sprites)
{
if (element is UiItemList list)
{
_lists.Add(list);
list.CooldownSprites = sprites;
list.CooldownStepProvider = GetOverlayStep;
}
foreach (UiElement child in element.Children)
Configure(child, sprites);
}
}