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);
|
||||
}
|
||||
}
|
||||
|
|
@ -198,6 +198,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private RetailItemConfirmationController? _itemConfirmationController;
|
||||
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
|
||||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||
private ItemCooldownUiController? _itemCooldownController;
|
||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private bool _disposed;
|
||||
|
|
@ -242,6 +243,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountPlugins();
|
||||
MountInventory();
|
||||
MountExternalContainer();
|
||||
MountItemCooldowns();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
SyncToolbarWindowButtons();
|
||||
|
|
@ -346,6 +348,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ExternalContainerController?.Tick();
|
||||
_itemCooldownController?.Tick();
|
||||
DialogFactory?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
|
|
@ -1760,6 +1763,29 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
|
||||
}
|
||||
|
||||
private void MountItemCooldowns()
|
||||
{
|
||||
ItemCooldownAssets? assets;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
assets = ItemCooldownAssets.TryLoad(_bindings.Assets.Dats);
|
||||
|
||||
if (assets is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] shared UIItem cooldown overlays missing from LayoutDesc 0x21000037.");
|
||||
return;
|
||||
}
|
||||
|
||||
_itemCooldownController = ItemCooldownUiController.Bind(
|
||||
Host.Root,
|
||||
_bindings.Magic.Spellbook,
|
||||
_bindings.Magic.Objects,
|
||||
_bindings.Magic.ServerTime,
|
||||
assets.Value);
|
||||
Console.WriteLine(
|
||||
"[UI] retail shared item cooldown overlays ready (10 DAT-authored steps).");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,30 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
private IReadOnlyList<uint>? _cooldownSprites;
|
||||
public IReadOnlyList<uint>? CooldownSprites
|
||||
{
|
||||
get => _cooldownSprites;
|
||||
set
|
||||
{
|
||||
_cooldownSprites = value;
|
||||
foreach (UiItemSlot cell in _cells)
|
||||
cell.CooldownSprites = value;
|
||||
}
|
||||
}
|
||||
|
||||
private Func<uint, int>? _cooldownStepProvider;
|
||||
public Func<uint, int>? CooldownStepProvider
|
||||
{
|
||||
get => _cooldownStepProvider;
|
||||
set
|
||||
{
|
||||
_cooldownStepProvider = value;
|
||||
foreach (UiItemSlot cell in _cells)
|
||||
cell.CooldownStepProvider = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
|
||||
/// physical-item drops continue through <see cref="DragHandler"/>.
|
||||
|
|
@ -92,6 +116,8 @@ public sealed class UiItemList : UiElement
|
|||
public void AddItem(UiItemSlot cell)
|
||||
{
|
||||
cell.SpriteResolve ??= SpriteResolve;
|
||||
cell.CooldownSprites ??= _cooldownSprites;
|
||||
cell.CooldownStepProvider ??= _cooldownStepProvider;
|
||||
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
|
||||
// The list lays cells out procedurally (grid offset + scroll clip), so cells must be
|
||||
// EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor
|
||||
|
|
@ -271,6 +297,8 @@ public sealed class UiItemList : UiElement
|
|||
{
|
||||
UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot();
|
||||
cell.SpriteResolve ??= SpriteResolve;
|
||||
cell.CooldownSprites ??= _cooldownSprites;
|
||||
cell.CooldownStepProvider ??= _cooldownStepProvider;
|
||||
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
|
||||
cell.Anchors = AnchorEdges.None;
|
||||
_cells.Add(cell);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,18 @@ public class UiItemSlot : UiElement
|
|||
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DAT-authored 10%..100% radial cooldown overlays from element ids
|
||||
/// <c>0x1000054F..0x10000558</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint>? CooldownSprites { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Bound by the shared cooldown controller. Returns 0 for hidden or 1..10
|
||||
/// for the exact retail overlay step.
|
||||
/// </summary>
|
||||
public Func<uint, int>? CooldownStepProvider { get; set; }
|
||||
|
||||
public void SetItem(
|
||||
uint itemId,
|
||||
uint iconTexture,
|
||||
|
|
@ -387,6 +399,28 @@ public class UiItemSlot : UiElement
|
|||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
// UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20 selects exactly
|
||||
// one of ten authored radial overlays. The shared prototype gives these
|
||||
// children ReadOrder 8, above shortcut, waiting, selection, trade, and
|
||||
// drag-accept layers, so the countdown is the final UIItem draw.
|
||||
uint cooldownSprite = ActiveCooldownSprite();
|
||||
if (cooldownSprite != 0u && SpriteResolve is not null)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(cooldownSprite);
|
||||
if (texture != 0u)
|
||||
ctx.DrawSprite(
|
||||
texture,
|
||||
0f,
|
||||
0f,
|
||||
Width,
|
||||
Height,
|
||||
0f,
|
||||
0f,
|
||||
1f,
|
||||
1f,
|
||||
Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells
|
||||
|
|
@ -409,4 +443,17 @@ public class UiItemSlot : UiElement
|
|||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
internal uint ActiveCooldownSprite()
|
||||
{
|
||||
if (ItemId == 0u
|
||||
|| CooldownSprites is null
|
||||
|| CooldownStepProvider is null)
|
||||
return 0u;
|
||||
|
||||
int step = CooldownStepProvider(ItemId);
|
||||
return step is >= 1 and <= 10 && step <= CooldownSprites.Count
|
||||
? CooldownSprites[step - 1]
|
||||
: 0u;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,11 @@ public static class CreateObject
|
|||
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
|
||||
// wire field is u16 and widens into retail's u32 runtime member.
|
||||
uint? SpellId = null,
|
||||
// PublicWeenieDesc second-header cooldown metadata. The group id is
|
||||
// compared against the player's cooldown enchantment list; the
|
||||
// duration scales the shared UIItem radial countdown.
|
||||
uint? CooldownId = null,
|
||||
double? CooldownDuration = null,
|
||||
// Complete immutable PhysicsDesc projection. Kept alongside the
|
||||
// legacy convenience fields while live-entity ownership migrates to
|
||||
// LiveEntityRuntime in Step 2.
|
||||
|
|
@ -871,6 +876,8 @@ public static class CreateObject
|
|||
uint uiEffects = 0;
|
||||
uint weenieFlags2 = 0;
|
||||
uint? petOwnerId = null;
|
||||
uint? cooldownId = null;
|
||||
double? cooldownDuration = null;
|
||||
try
|
||||
{
|
||||
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
|
||||
|
|
@ -1075,11 +1082,12 @@ public static class CreateObject
|
|||
if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32
|
||||
{
|
||||
if (body.Length - pos < 4) throw new FormatException("trunc CooldownId");
|
||||
pos += 4;
|
||||
cooldownId = ReadU32(body, ref pos);
|
||||
}
|
||||
if ((weenieFlags2 & 0x00000004u) != 0) // CooldownDuration f64
|
||||
{
|
||||
if (body.Length - pos < 8) throw new FormatException("trunc CooldownDuration");
|
||||
cooldownDuration = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
|
||||
pos += 8;
|
||||
}
|
||||
if ((weenieFlags2 & 0x00000008u) != 0) // PetOwner u32
|
||||
|
|
@ -1119,6 +1127,8 @@ public static class CreateObject
|
|||
PetOwnerId: petOwnerId,
|
||||
AmmoType: ammoType,
|
||||
SpellId: spellId,
|
||||
CooldownId: cooldownId,
|
||||
CooldownDuration: cooldownDuration,
|
||||
Physics: physics);
|
||||
}
|
||||
catch
|
||||
|
|
|
|||
|
|
@ -184,5 +184,7 @@ public static class ObjectTableWiring
|
|||
PluralName: s.PluralName,
|
||||
PetOwnerId: s.PetOwnerId,
|
||||
AmmoType: s.AmmoType,
|
||||
SpellId: s.SpellId);
|
||||
SpellId: s.SpellId,
|
||||
CooldownId: s.CooldownId,
|
||||
CooldownDuration: s.CooldownDuration);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,8 @@ public sealed class WorldSession : IDisposable
|
|||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null,
|
||||
uint? SpellId = null,
|
||||
uint? CooldownId = null,
|
||||
double? CooldownDuration = null,
|
||||
PhysicsSpawnData? Physics = null);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -215,6 +217,8 @@ public sealed class WorldSession : IDisposable
|
|||
PetOwnerId: parsed.PetOwnerId,
|
||||
AmmoType: parsed.AmmoType,
|
||||
SpellId: parsed.SpellId,
|
||||
CooldownId: parsed.CooldownId,
|
||||
CooldownDuration: parsed.CooldownDuration,
|
||||
Physics: parsed.Physics);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,17 @@ public sealed class ClientObject
|
|||
public ushort? AmmoType { get; set; }
|
||||
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
|
||||
public uint? SpellId { get; set; }
|
||||
/// <summary>
|
||||
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
|
||||
/// shared item-cooldown group whose player enchantment id is
|
||||
/// <c>CooldownId + 0x8000</c>.
|
||||
/// </summary>
|
||||
public uint? CooldownId { get; set; }
|
||||
/// <summary>
|
||||
/// Retail <c>PublicWeenieDesc._cooldown_duration</c>, in seconds. This is
|
||||
/// the denominator used by <c>UIElement_UIItem::UpdateCooldownDisplay</c>.
|
||||
/// </summary>
|
||||
public double? CooldownDuration { get; set; }
|
||||
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
|
||||
public int TradeState { get; set; }
|
||||
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
|
||||
|
|
@ -261,7 +272,9 @@ public readonly record struct WeenieData(
|
|||
string? PluralName = null,
|
||||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null,
|
||||
uint? SpellId = null);
|
||||
uint? SpellId = null,
|
||||
uint? CooldownId = null,
|
||||
double? CooldownDuration = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ public sealed class ClientObjectTable
|
|||
/// <see cref="ClientObject.Effects"/>.</summary>
|
||||
public const uint UiEffectsPropertyId = 18u;
|
||||
public const uint CurrentWieldedLocationPropertyId = 10u;
|
||||
public const uint SharedCooldownPropertyId = 280u;
|
||||
public const uint CooldownDurationPropertyId = 167u;
|
||||
|
||||
public int ObjectCount => _objects.Count;
|
||||
public int ContainerCount => _containers.Count;
|
||||
|
|
@ -613,6 +615,7 @@ public sealed class ClientObjectTable
|
|||
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
|
||||
ApplyCooldownProperties(item, incoming);
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -640,6 +643,7 @@ public sealed class ClientObjectTable
|
|||
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
|
||||
ApplyCooldownProperties(item, incoming);
|
||||
if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
|
||||
}
|
||||
|
||||
|
|
@ -656,6 +660,7 @@ public sealed class ClientObjectTable
|
|||
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
|
||||
item.Properties.Ints[propertyId] = value;
|
||||
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
|
||||
if (propertyId == SharedCooldownPropertyId) item.CooldownId = (uint)value;
|
||||
if (propertyId == CurrentWieldedLocationPropertyId)
|
||||
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
|
||||
if (propertyId == CurrentWieldedLocationPropertyId)
|
||||
|
|
@ -664,6 +669,20 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
private static void ApplyCooldownProperties(
|
||||
ClientObject item,
|
||||
PropertyBundle properties)
|
||||
{
|
||||
if (properties.Ints.TryGetValue(
|
||||
SharedCooldownPropertyId,
|
||||
out int cooldownId))
|
||||
item.CooldownId = (uint)cooldownId;
|
||||
if (properties.Floats.TryGetValue(
|
||||
CooldownDurationPropertyId,
|
||||
out double cooldownDuration))
|
||||
item.CooldownDuration = cooldownDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single PropertyInt64 update to an object's bundle and fire
|
||||
/// ObjectUpdated so bound widgets refresh. Int64 counterpart of
|
||||
|
|
@ -738,6 +757,9 @@ public sealed class ClientObjectTable
|
|||
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
|
||||
if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType;
|
||||
if (d.SpellId is { } spellId) obj.SpellId = spellId;
|
||||
if (d.CooldownId is { } cooldownId) obj.CooldownId = cooldownId;
|
||||
if (d.CooldownDuration is { } cooldownDuration)
|
||||
obj.CooldownDuration = cooldownDuration;
|
||||
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
||||
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||
|
|
|
|||
31
src/AcDream.Core/Items/ItemCooldownDisplay.cs
Normal file
31
src/AcDream.Core/Items/ItemCooldownDisplay.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Retail item-cooldown projection shared by inventory, equipment, and shortcut
|
||||
/// UIItems.
|
||||
///
|
||||
/// <para>
|
||||
/// Port of <c>UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20</c>.
|
||||
/// The remaining fraction selects one of ten authored radial overlays.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ItemCooldownDisplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail's exact ten-step display formula:
|
||||
/// <c>trunc((remaining / itemDuration) * 10 + 1)</c>. Results 1..9 select
|
||||
/// the 10%..90% art, while 10 or above selects the 100% art.
|
||||
/// </summary>
|
||||
public static int GetOverlayStep(double itemDuration, double remaining)
|
||||
{
|
||||
if (!(itemDuration > 0d) || !(remaining > 0d))
|
||||
return 0;
|
||||
|
||||
double step = Math.Truncate((remaining / itemDuration) * 10d + 1d);
|
||||
if (!(step >= 1d))
|
||||
return 0;
|
||||
if (step >= 10d)
|
||||
return 10;
|
||||
return (int)step;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,9 @@ namespace AcDream.Core.Spells;
|
|||
/// </summary>
|
||||
public sealed class Spellbook
|
||||
{
|
||||
public const uint CooldownSpellOffset = 0x8000u;
|
||||
public const uint CooldownBucket = 8u;
|
||||
|
||||
private readonly Dictionary<uint, float> _learnedSpells = new();
|
||||
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
|
||||
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
|
||||
|
|
@ -240,6 +243,46 @@ public sealed class Spellbook
|
|||
if (changed) NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CEnchantmentRegistry::OnCooldown @ 0x005943C0</c>.
|
||||
/// Scans the cooldown list head-to-tail, stops at the first shared-group
|
||||
/// match, and removes that node when its monotonic lifetime has expired.
|
||||
/// </summary>
|
||||
public bool OnCooldown(
|
||||
uint cooldownId,
|
||||
double currentTime,
|
||||
out double remaining)
|
||||
{
|
||||
remaining = 0d;
|
||||
if (cooldownId == 0u
|
||||
|| !_enchantmentOrderByBucket.TryGetValue(
|
||||
CooldownBucket,
|
||||
out List<uint>? order))
|
||||
return false;
|
||||
|
||||
uint cooldownSpellId = unchecked(cooldownId + CooldownSpellOffset);
|
||||
for (int index = 0; index < order.Count; index++)
|
||||
{
|
||||
uint identity = order[index];
|
||||
if (!_activeById.TryGetValue(
|
||||
identity,
|
||||
out ActiveEnchantmentRecord record)
|
||||
|| (record.SpellId & 0xFFFFu) != cooldownSpellId)
|
||||
continue;
|
||||
|
||||
remaining = record.Duration + record.StartTime - currentTime;
|
||||
if (remaining > 0d)
|
||||
return true;
|
||||
|
||||
RemoveActiveEnchantment(identity, out _);
|
||||
EnchantmentRemoved?.Invoke(record);
|
||||
NotifyEnchantmentsChanged();
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
|
||||
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue