using System; using System.Linq; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Spells; namespace AcDream.App.UI.Layout; /// /// Retained port of retail gmIndicatorsUI LayoutDesc /// 0x21000071 and its custom indicator buttons. The DAT owns geometry, /// sprites, and state names; this controller owns only live state selection and /// authored input actions. /// public sealed class IndicatorBarController : IRetainedPanelController { public const uint LayoutId = 0x21000071u; public const uint BurdenClassId = 0x10000001u; public const uint EffectsClassId = 0x10000002u; public const uint LinkClassId = 0x10000003u; public const uint MiniGameClassId = 0x10000004u; public const uint VitaeClassId = 0x10000006u; public const uint MiniGameButtonId = 0x100000F3u; public const uint VitaeButtonId = 0x100000F4u; public const uint HelpfulButtonId = 0x100000F5u; public const uint HarmfulButtonId = 0x100000F6u; public const uint BurdenButtonId = 0x100000F7u; public const uint LinkButtonId = 0x100000F8u; public const uint EndCharacterSessionButtonId = 0x100000FAu; public const uint UnencumberedState = 14u; public const uint EncumberedState = 15u; public const uint HeavilyEncumberedState = 16u; public const uint ConnectionGoodState = 17u; public const uint ConnectionUncertainState = 18u; public const uint ConnectionBadState = 19u; public const uint ConnectionDisconnectedState = 20u; private const uint EncumbranceValProperty = 5u; private const uint EncumbranceAugProperty = 0xE6u; private const double LinkUpdateSeconds = 4.0; private const double LinkFlashSeconds = 0.75; private readonly IndicatorBarBindings _bindings; private readonly UiButton _link; private readonly UiButton _helpful; private readonly UiButton _harmful; private readonly UiButton _vitae; private readonly UiButton _burden; private readonly UiButton _miniGame; private readonly UiButton _endCharacterSession; private LinkSemanticState _linkState = LinkSemanticState.Good; private double _lastLinkUpdate; private double _lastLinkFlash; private bool _disposed; private enum LinkSemanticState { Good = 1, Uncertain = 2, Bad = 3, Disconnected = 4, } private IndicatorBarController( IndicatorBarBindings bindings, UiButton link, UiButton helpful, UiButton harmful, UiButton vitae, UiButton burden, UiButton miniGame, UiButton endCharacterSession) { _bindings = bindings; _link = link; _helpful = helpful; _harmful = harmful; _vitae = vitae; _burden = burden; _miniGame = miniGame; _endCharacterSession = endCharacterSession; _helpful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.PositiveEffects); _harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects); _link.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.LinkStatus); _vitae.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Vitae); _burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.CharacterInformation); _miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame); _endCharacterSession.OnClick = bindings.RequestEndCharacterSession; bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments; bindings.Objects.ObjectAdded += OnObjectChanged; bindings.Objects.ObjectUpdated += OnObjectChanged; bindings.Objects.ObjectRemoved += OnObjectChanged; bindings.Objects.ObjectMoved += OnObjectMoved; bindings.Objects.ContainerContentsReplaced += OnContainerContentsReplaced; bindings.Objects.Cleared += OnObjectsCleared; // gmUIElement_LinkStatusIndicator::PostInit @ 0x004E66D0 starts // Good and does not query until the four-second time notice cadence. double now = bindings.CurrentTime(); _lastLinkUpdate = now; _lastLinkFlash = now; _link.TrySetRetailState(ConnectionGoodState); _miniGame.TrySetRetailState(UiButtonStateMachine.Ghosted); // Ordinary button 0x100000FA has no hover state: retail begins in its // authored Normal state (0x06007BB1) before any pointer notice. _endCharacterSession.TrySetRetailState(UiButtonStateMachine.Normal); UpdateEnchantments(); UpdateBurden(); } public static IndicatorBarController? Bind( ImportedLayout layout, IndicatorBarBindings bindings) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(bindings); if (layout.FindElement(LinkButtonId) is not UiButton link || layout.FindElement(HelpfulButtonId) is not UiButton helpful || layout.FindElement(HarmfulButtonId) is not UiButton harmful || layout.FindElement(VitaeButtonId) is not UiButton vitae || layout.FindElement(BurdenButtonId) is not UiButton burden || layout.FindElement(MiniGameButtonId) is not UiButton miniGame || layout.FindElement(EndCharacterSessionButtonId) is not UiButton endSession) return null; return new IndicatorBarController( bindings, link, helpful, harmful, vitae, burden, miniGame, endSession); } /// /// Port of gmUIElement_LinkStatusIndicator::UseTime @ 0x004E67C0. /// The absolute monotonic clock is injected so the retail 4.0/0.75-second /// boundaries are deterministic in tests. /// public void Tick() { double now = _bindings.CurrentTime(); if (now - _lastLinkUpdate >= LinkUpdateSeconds) { UpdateLinkState(now, _bindings.LinkStatus()); _lastLinkUpdate = now; } if (_linkState == LinkSemanticState.Bad && now - _lastLinkFlash >= LinkFlashSeconds) { _link.TrySetRetailState( _link.ActiveRetailStateId == ConnectionUncertainState ? ConnectionBadState : ConnectionUncertainState); _lastLinkFlash = now; } } /// Retail BeginGame/EndGame notice surface for the future mini-game owner. public void SetMiniGameActive(bool active) => _miniGame.TrySetRetailState( active ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); private void UpdateLinkState(double now, LinkStatusSnapshot snapshot) { // gmUIElement_LinkStatusIndicator::UpdateLinkState @ 0x004E6730. LinkSemanticState state = !snapshot.Connected || snapshot.SecondsSinceLastPacket >= 40d ? LinkSemanticState.Disconnected : snapshot.SecondsSinceLastPacket >= 20d ? LinkSemanticState.Bad : snapshot.SecondsSinceLastPacket >= 5d ? LinkSemanticState.Uncertain : LinkSemanticState.Good; SetLinkState(state, now); } private void SetLinkState(LinkSemanticState state, double now) { // gmUIElement_LinkStatusIndicator::SetLinkState @ 0x004E65E0 does // nothing for an unchanged semantic state (including an active flash). if (_linkState == state) return; _linkState = state; uint visual = state switch { LinkSemanticState.Good => ConnectionGoodState, LinkSemanticState.Uncertain => ConnectionUncertainState, LinkSemanticState.Bad => ConnectionBadState, LinkSemanticState.Disconnected => ConnectionDisconnectedState, _ => throw new InvalidOperationException($"Unknown link state {state}."), }; _link.TrySetRetailState(visual); if (state == LinkSemanticState.Bad) _lastLinkFlash = now; } private void UpdateEnchantments() { _helpful.TrySetRetailState(HasMatchingEffect(beneficial: true) ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); _harmful.TrySetRetailState(HasMatchingEffect(beneficial: false) ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); // gmUIElement_VitaeIndicator::Update @ 0x004E5FE0 enables the // indicator only when the singleton Vitae modifier is below 1.0. bool hasVitae = _bindings.Spellbook.ActiveEnchantmentSnapshot.Any(record => record.Bucket == 4u && record.StatModValue is float modifier && modifier < 1f); _vitae.TrySetRetailState(hasVitae ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); } private bool HasMatchingEffect(bool beneficial) // Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this split. => _bindings.Spellbook.ActiveEnchantmentSnapshot.Any(record => // CountSpellsInList @ 0x00593CD0 scans raw mult/add nodes; it // does not use the effects-list spell-category duel projection. record.Bucket is 1u or 2u && _bindings.Spellbook.TryGetMetadata( record.SpellId, out SpellMetadata metadata) && metadata.IsBeneficial == beneficial); private void OnObjectChanged(ClientObject _) => UpdateBurden(); private void OnObjectMoved(ClientObjectMove _) => UpdateBurden(); private void OnContainerContentsReplaced(uint _) => UpdateBurden(); private void OnObjectsCleared() => UpdateBurden(); private void UpdateBurden() { uint player = _bindings.PlayerGuid(); ClientObject? playerObject = _bindings.Objects.Get(player); int strength = _bindings.Strength() ?? 10; int augmentation = playerObject?.Properties.GetInt(EncumbranceAugProperty) ?? 0; int capacity = BurdenMath.EncumbranceCapacity(strength, augmentation); int burden = playerObject?.Properties.Ints.TryGetValue( EncumbranceValProperty, out int wireBurden) == true ? wireBurden : _bindings.Objects.SumCarriedBurden(player); float load = BurdenMath.LoadRatio(capacity, burden); // gmUIElement_BurdenIndicator::Update @ 0x004E6CE0. _burden.TrySetRetailState(load < 1f ? UnencumberedState : load < 2f ? EncumberedState : HeavilyEncumberedState); } public void Dispose() { if (_disposed) return; _disposed = true; _bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments; _bindings.Objects.ObjectAdded -= OnObjectChanged; _bindings.Objects.ObjectUpdated -= OnObjectChanged; _bindings.Objects.ObjectRemoved -= OnObjectChanged; _bindings.Objects.ObjectMoved -= OnObjectMoved; _bindings.Objects.ContainerContentsReplaced -= OnContainerContentsReplaced; _bindings.Objects.Cleared -= OnObjectsCleared; _link.OnClick = null; _helpful.OnClick = null; _harmful.OnClick = null; _vitae.OnClick = null; _burden.OnClick = null; _miniGame.OnClick = null; _endCharacterSession.OnClick = null; } } public sealed record IndicatorBarBindings( Spellbook Spellbook, ClientObjectTable Objects, Func PlayerGuid, Func Strength, Func LinkStatus, Func CurrentTime, Action TogglePanel, Action RequestEndCharacterSession);