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,56 @@
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// DAT-authored ten-step radial cooldown art owned by the shared retail
/// <c>UIElement_UIItem</c> prototype.
/// </summary>
public readonly record struct ItemCooldownAssets(IReadOnlyList<uint> Sprites)
{
public const uint CatalogLayoutId = 0x21000037u;
public const uint SharedItemPrototypeId = 0x1000033Eu;
public const uint FirstOverlayElementId = 0x1000054Fu;
public const int OverlayCount = 10;
public static ItemCooldownAssets? TryLoad(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
// 0x1000033E is the common UIItem base inherited by physical item,
// container, and shortcut prototypes. The empty-slot prototype
// 0x10000341 is only background art and intentionally has no cooldown
// children.
ElementInfo? prototype = LayoutImporter.ImportInfos(
dats,
CatalogLayoutId,
SharedItemPrototypeId);
if (prototype is null)
return null;
var sprites = new uint[OverlayCount];
for (int index = 0; index < sprites.Length; index++)
{
ElementInfo? overlay = Find(
prototype,
FirstOverlayElementId + (uint)index);
if (overlay is null
|| !overlay.StateMedia.TryGetValue("", out var media)
|| media.File == 0u)
return null;
sprites[index] = media.File;
}
return new ItemCooldownAssets(sprites);
}
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id)
return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } match)
return match;
return null;
}
}

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