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:
parent
19e8863f4e
commit
6b1ae4fb76
27 changed files with 875 additions and 20 deletions
56
src/AcDream.App/UI/Layout/ItemCooldownAssets.cs
Normal file
56
src/AcDream.App/UI/Layout/ItemCooldownAssets.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/AcDream.App/UI/Layout/ItemCooldownUiController.cs
Normal file
101
src/AcDream.App/UI/Layout/ItemCooldownUiController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue