From b2619927d601169b6d79d174f168c63c8e15d21f Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 12 Apr 2026 15:18:46 +0800 Subject: [PATCH 01/13] Update rank and playmode access for new game cache API --- .../Gameplay/CardReading/RankReader.cs | 52 ++++--------------- BazaarAccess/Screens/HeroSelectScreen.cs | 6 ++- 2 files changed, 13 insertions(+), 45 deletions(-) diff --git a/BazaarAccess/Gameplay/CardReading/RankReader.cs b/BazaarAccess/Gameplay/CardReading/RankReader.cs index b8b34bc..0869c39 100644 --- a/BazaarAccess/Gameplay/CardReading/RankReader.cs +++ b/BazaarAccess/Gameplay/CardReading/RankReader.cs @@ -1,6 +1,6 @@ using System; -using System.Reflection; using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.TempoNet.Enums; using TheBazaar; namespace BazaarAccess.Gameplay.CardReading; @@ -12,55 +12,20 @@ internal static class RankReader { /// /// Gets the player's ranked rank (e.g., "Silver 3", "Gold 1", "Legendary"). - /// Uses reflection to access Data.Rank.CurrentSeasonRank. + /// Uses the current client rank cache populated by the game. /// public static string GetPlayerRank() { try { - var dataType = typeof(Data); - var rankProp = dataType.GetProperty("Rank", BindingFlags.Public | BindingFlags.Static); - if (rankProp == null) return null; - - var rankObj = rankProp.GetValue(null); - if (rankObj == null) return null; - - var seasonRankProp = rankObj.GetType().GetProperty("CurrentSeasonRank", - BindingFlags.Public | BindingFlags.Instance); - if (seasonRankProp == null) return null; - - var seasonRank = seasonRankProp.GetValue(rankObj); - if (seasonRank == null) return null; - - var seasonType = seasonRank.GetType(); - - // Get ERank (Bronze, Silver, Gold, Diamond, Legendary) - string rankName = null; - var rankEnumProp = seasonType.GetProperty("Rank", BindingFlags.Public | BindingFlags.Instance); - if (rankEnumProp != null) - { - var val = rankEnumProp.GetValue(seasonRank); - if (val != null) rankName = val.ToString(); - } + if (!ClientCache.Rank.HasData) return null; + var rank = ClientCache.Rank.Value; + string rankName = rank.Rank.ToString(); if (string.IsNullOrEmpty(rankName)) return null; - // Legendary has no division - if (rankName == "Legendary") return "Legendary"; - - // Get Division (1-4) - var divProp = seasonType.GetProperty("Division", BindingFlags.Public | BindingFlags.Instance); - if (divProp != null) - { - var divVal = divProp.GetValue(seasonRank); - if (divVal != null) - { - string div = divVal.ToString(); - if (!string.IsNullOrEmpty(div) && div != "0") - return $"{rankName} {div}"; - } - } - + if (rank.Rank == ERank.Legendary) return rankName; + if (rank.Division > 0) return $"{rankName} {rank.Division}"; return rankName; } catch (Exception ex) @@ -77,7 +42,8 @@ public static bool IsRankedMode() { try { - return Data.RunConfiguration?.RunType == EPlayMode.Ranked; + return ClientCache.RunConfig.HasData && + ClientCache.RunConfig.Value.RunType == EPlayMode.Ranked; } catch { diff --git a/BazaarAccess/Screens/HeroSelectScreen.cs b/BazaarAccess/Screens/HeroSelectScreen.cs index 919376b..fa3cf3a 100644 --- a/BazaarAccess/Screens/HeroSelectScreen.cs +++ b/BazaarAccess/Screens/HeroSelectScreen.cs @@ -160,7 +160,8 @@ private string GetCasualButtonText() { try { - if (Data.RunConfiguration != null && Data.RunConfiguration.RunType == EPlayMode.Unranked) + if (ClientCache.RunConfig.HasData && + ClientCache.RunConfig.Value.RunType == EPlayMode.Unranked) { return "Casual, selected"; } @@ -173,7 +174,8 @@ private string GetRankedButtonText() { try { - if (Data.RunConfiguration != null && Data.RunConfiguration.RunType == EPlayMode.Ranked) + if (ClientCache.RunConfig.HasData && + ClientCache.RunConfig.Value.RunType == EPlayMode.Ranked) { string rank = Gameplay.ItemReader.GetPlayerRank(); if (!string.IsNullOrEmpty(rank)) From a4fa8bb82c60c06a61d89304ebfaec4c176aa24a Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 12 Apr 2026 15:19:01 +0800 Subject: [PATCH 02/13] Read battle pass challenges from client cache --- BazaarAccess/Screens/BattlePassScreen.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BazaarAccess/Screens/BattlePassScreen.cs b/BazaarAccess/Screens/BattlePassScreen.cs index 8649a7f..fbb19fe 100644 --- a/BazaarAccess/Screens/BattlePassScreen.cs +++ b/BazaarAccess/Screens/BattlePassScreen.cs @@ -81,11 +81,11 @@ private void RefreshChallenges() { try { - var profile = Data.ChallengesProfile; - if (profile != null) + if (ClientCache.Challenges.HasData) { - _dailyChallenges = profile.DailyChallenges ?? Array.Empty(); - _weeklyChallenges = profile.WeeklyChallenges ?? Array.Empty(); + var profile = ClientCache.Challenges.Value; + _dailyChallenges = profile.dailyChallenges ?? Array.Empty(); + _weeklyChallenges = profile.weeklyChallenges ?? Array.Empty(); } else { From 11006b2898328b44160aa6b3737d7250ed5a62ca Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 12 Apr 2026 15:19:24 +0800 Subject: [PATCH 03/13] Handle updated chest reward collectible IDs --- BazaarAccess/UI/ChestRewardsUI.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BazaarAccess/UI/ChestRewardsUI.cs b/BazaarAccess/UI/ChestRewardsUI.cs index 8e68cf3..53b266d 100644 --- a/BazaarAccess/UI/ChestRewardsUI.cs +++ b/BazaarAccess/UI/ChestRewardsUI.cs @@ -63,7 +63,7 @@ private async Task LoadRewardNamesAsync() DuplicateGems = reward.DuplicateGems, RankedVouchers = reward.rankedVouchers, BonusChests = reward.bonusChest?.Length ?? 0, - HasCollectible = reward.collectibleItem != null && !string.IsNullOrEmpty(reward.collectibleItem.itemId) + HasCollectible = !string.IsNullOrEmpty(reward.collectibleItemId) }; // Try to load the collectible name @@ -71,7 +71,7 @@ private async Task LoadRewardNamesAsync() { try { - var asset = await Addressables.LoadAssetAsync(reward.collectibleItem.itemId).Task; + var asset = await Addressables.LoadAssetAsync(reward.collectibleItemId).Task; if (asset != null) { info.ItemName = asset.LocalizableName?.GetLocalizedText() ?? asset.Name ?? "Item"; From 1c85be664f226258fb5541b9a848da44e7ea2600 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 12 Apr 2026 16:51:19 +0800 Subject: [PATCH 04/13] Announce focused hero skill when entering skills list, fixes a list of one skill not being read --- .../Gameplay/Navigation/HeroNavigator.cs | 74 +++++++++---------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs b/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs index a52d853..08db69d 100644 --- a/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs +++ b/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs @@ -70,16 +70,7 @@ public void NextSubsection() { if (_subsection == HeroSubsection.Stats) { - if (_skills.Count > 0) - { - _subsection = HeroSubsection.Skills; - _skillIndex = 0; - AnnounceSubsection(); - } - else - { - TolkWrapper.Speak("No skills equipped"); - } + EnterSkillsSubsection(); } else { @@ -102,19 +93,28 @@ public void PreviousSubsection() } else { - if (_skills.Count > 0) - { - _subsection = HeroSubsection.Skills; - _skillIndex = 0; - AnnounceSubsection(); - } - else - { - TolkWrapper.Speak("No skills equipped"); - } + EnterSkillsSubsection(); } } + private void EnterSkillsSubsection() + { + if (_skills.Count == 0) + { + TolkWrapper.Speak("No skills equipped"); + return; + } + + _subsection = HeroSubsection.Skills; + + if (_skillIndex < 0) + _skillIndex = 0; + else if (_skillIndex >= _skills.Count) + _skillIndex = _skills.Count - 1; + + AnnounceSubsection(); + } + /// /// Announces the current hero subsection name and count. /// @@ -131,7 +131,9 @@ public void AnnounceSubsection() } else { - TolkWrapper.Speak($"Hero skills, {_skills.Count} skills"); + string skillAnnouncement = GetCurrentSkillAnnouncement(); + TolkWrapper.Speak($"Hero skills, {_skills.Count} skills. {skillAnnouncement}"); + OnSkillVisualSelect?.Invoke(_skillIndex); } } @@ -149,34 +151,24 @@ public int GetStatCount() /// Announces the current hero skill with its description. /// public void AnnounceSkill() + { + TolkWrapper.Speak(GetCurrentSkillAnnouncement()); + // Trigger visual selection via callback + OnSkillVisualSelect?.Invoke(_skillIndex); + } + + private string GetCurrentSkillAnnouncement() { if (_skillIndex < 0 || _skillIndex >= _skills.Count) - { - TolkWrapper.Speak("No skill"); - return; - } + return "No skill"; var skill = _skills[_skillIndex]; if (skill == null) - { - TolkWrapper.Speak("Empty slot"); - return; - } + return "Empty slot"; string name = ItemReader.GetCardName(skill); string desc = ItemReader.GetFullDescription(skill); - - if (!string.IsNullOrEmpty(desc)) - { - TolkWrapper.Speak($"{name}: {desc}"); - } - else - { - TolkWrapper.Speak(name); - } - - // Trigger visual selection via callback - OnSkillVisualSelect?.Invoke(_skillIndex); + return !string.IsNullOrEmpty(desc) ? $"{name}: {desc}" : name; } /// From 5573af322d8702ea3423166160881b44e4a7fa95 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 12 Apr 2026 17:29:06 +0800 Subject: [PATCH 05/13] read monster level, exp and gold for combat encounters --- .../Gameplay/CardReading/DetailLineBuilder.cs | 5 ++ .../Gameplay/CardReading/EncounterReader.cs | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs b/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs index ffa43c8..9ab8cdb 100644 --- a/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs +++ b/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs @@ -164,6 +164,11 @@ public static List GetDetailLines(Card card) if (card.Type == ECardType.PvpEncounter) return EncounterReader.GetPvpEncounterDetailLines(card); + if (card.Type == ECardType.CombatEncounter) + { + return EncounterReader.GetCombatEncounterDetailLines(card); + } + return BuildDetailLines(card, enemyOrder: false); } diff --git a/BazaarAccess/Gameplay/CardReading/EncounterReader.cs b/BazaarAccess/Gameplay/CardReading/EncounterReader.cs index 9a05c20..084a932 100644 --- a/BazaarAccess/Gameplay/CardReading/EncounterReader.cs +++ b/BazaarAccess/Gameplay/CardReading/EncounterReader.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Text; using BazaarGameClient.Domain.Models.Cards; +using BazaarGameShared.Domain.Cards.Encounter.Combat; using BazaarGameShared.Domain.Core.Types; using TheBazaar; @@ -39,6 +40,32 @@ public static string GetEncounterInfo(Card card) return $"{name}, {type}, {tier}"; } + internal static List GetCombatEncounterDetailLines(Card card) + { + var lines = new List(); + if (card == null) return lines; + + lines.Add(CardProperties.GetCardName(card)); + lines.Add(CardProperties.GetTierName(card)); + + if (TryGetCombatEncounterRewards(card, out uint monsterLevel, out int xpReward, out int goldReward)) + { + lines.Add($"Level: {monsterLevel}"); + lines.Add($"XP: {xpReward}"); + lines.Add($"Gold: {goldReward}"); + } + + string desc = CardProperties.GetDescription(card); + if (!string.IsNullOrEmpty(desc)) + lines.Add(desc); + + string flavor = CardProperties.GetFlavorText(card); + if (!string.IsNullOrEmpty(flavor)) + lines.Add(flavor); + + return lines; + } + public static string GetEncounterDetailedInfo(Card card) { if (card == null) return "Empty"; @@ -78,6 +105,17 @@ public static string GetEncounterDetailedInfo(Card card) sb.Append(", "); sb.Append(GetEncounterTypeName(card.Type)); + if (TryGetCombatEncounterRewards(card, out uint monsterLevel, out int xpReward, out int goldReward)) + { + sb.Append(", Level "); + sb.Append(monsterLevel); + sb.Append(", "); + sb.Append(xpReward); + sb.Append(" XP, "); + sb.Append(goldReward); + sb.Append(" gold"); + } + string desc = CardProperties.GetDescription(card); if (!string.IsNullOrEmpty(desc)) { @@ -234,6 +272,24 @@ private static string GetEncounterTypeName(ECardType type) }; } + private static bool TryGetCombatEncounterRewards(Card card, out uint monsterLevel, out int xpReward, out int goldReward) + { + monsterLevel = 0; + xpReward = 0; + goldReward = 0; + + if (card?.Type != ECardType.CombatEncounter) + return false; + + if (card.Template is not TCardEncounterCombat combatTemplate) + return false; + + monsterLevel = (combatTemplate.CombatantType as TCombatantMonster)?.Level ?? 0; + xpReward = combatTemplate.RewardCombatXp; + goldReward = combatTemplate.RewardCombatGold; + return true; + } + private static T GetPvpProperty(object pvpOpponent, Type type, string propertyName) { try From c5cd58ce7727925bf1d6a420532a07889142b00b Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Mon, 13 Apr 2026 14:01:30 +0800 Subject: [PATCH 06/13] add support for combat encounter loadout preview, press x when on combat encounters --- BazaarAccess/Accessibility/AccessibleMenu.cs | 1 + BazaarAccess/Core/KeyboardNavigator.cs | 4 + .../CombatEncounterPreviewFactory.cs | 135 +++ .../CombatEncounterPreviewModel.cs | 12 + .../CombatEncounterPreviewNavigator.cs | 839 ++++++++++++++++++ BazaarAccess/Gameplay/GameplayScreen.cs | 35 +- .../Gameplay/Navigation/VisualSelector.cs | 108 ++- 7 files changed, 1132 insertions(+), 2 deletions(-) create mode 100644 BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewFactory.cs create mode 100644 BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewModel.cs create mode 100644 BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewNavigator.cs diff --git a/BazaarAccess/Accessibility/AccessibleMenu.cs b/BazaarAccess/Accessibility/AccessibleMenu.cs index 4861699..ef4b663 100644 --- a/BazaarAccess/Accessibility/AccessibleMenu.cs +++ b/BazaarAccess/Accessibility/AccessibleMenu.cs @@ -380,6 +380,7 @@ public enum AccessibleKey PrevMessage, // Comma - Previous message // Additional information Info, // I - Property/keyword info for the item + Inspect, // X - more details for current selection, currently combat encounters // Upgrade Upgrade, // Shift+U - Upgrade item at pedestal // Board and Stash info diff --git a/BazaarAccess/Core/KeyboardNavigator.cs b/BazaarAccess/Core/KeyboardNavigator.cs index 30f6cf6..9725953 100644 --- a/BazaarAccess/Core/KeyboardNavigator.cs +++ b/BazaarAccess/Core/KeyboardNavigator.cs @@ -166,6 +166,10 @@ private AccessibleKey MapKey(Event e) case KeyCode.I: return AccessibleKey.Info; + // Inspect current selection + case KeyCode.X: + return AccessibleKey.Inspect; + // Upgrade item (U or Shift+U) case KeyCode.U: return AccessibleKey.Upgrade; diff --git a/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewFactory.cs b/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewFactory.cs new file mode 100644 index 0000000..326d37b --- /dev/null +++ b/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewFactory.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BazaarAccess.Gameplay.CardReading; +using BazaarGameClient.Domain.Models.Cards; +using BazaarGameShared.Domain.Cards; +using BazaarGameShared.Domain.Cards.Item; +using BazaarGameShared.Domain.Cards.Skill; +using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Domain.Players; +using TheBazaar; + +namespace BazaarAccess.Gameplay.CombatEncounterPreview; + +internal static class CombatEncounterPreviewFactory +{ + public static CombatEncounterPreviewModel Create(Card card) + { + if (card is not CombatEncounterCard combatEncounter) + return null; + + try + { + TMonster monster = combatEncounter.GetMonsterTemplate().GetAwaiter().GetResult(); + if (monster?.Player == null) + return null; + + var model = new CombatEncounterPreviewModel + { + EnemyName = GetEnemyName(card, monster), + Health = GetPreviewHealth(monster) + }; + + if (monster.Player.Skills != null) + { + foreach (var skill in monster.Player.Skills) + { + var previewSkill = PreviewCardFactory.Create(skill, ECardType.Skill); + if (previewSkill != null) + model.Skills.Add(previewSkill); + } + } + + if (monster.Player.Hand?.Items != null) + { + IEnumerable orderedItems = monster.Player.Hand.Items + .OrderBy(item => item?.SocketId.HasValue == true ? (int)item.SocketId.Value : int.MaxValue) + .ThenBy(item => item?.InstanceId ?? string.Empty); + + foreach (var item in orderedItems) + { + var previewItem = PreviewCardFactory.Create(item, ECardType.Item); + if (previewItem != null) + model.Items.Add(previewItem); + } + } + + return model; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"CombatEncounterPreviewFactory.Create error: {ex.Message}"); + return null; + } + } + + private static string GetEnemyName(Card encounterCard, TMonster monster) + { + string encounterName = ItemReader.GetCardName(encounterCard); + if (!string.IsNullOrWhiteSpace(encounterName)) + return encounterName; + + if (!string.IsNullOrWhiteSpace(monster.InternalName)) + return monster.InternalName; + + return "Enemy"; + } + + private static int GetPreviewHealth(TMonster monster) + { + if (monster.Player.Attributes.TryGetValue(EPlayerAttributeType.HealthMax, out int healthMax) && healthMax > 0) + return healthMax; + + if (monster.Player.Attributes.TryGetValue(EPlayerAttributeType.Health, out int health)) + return health; + + return 0; + } + + private static class PreviewCardFactory + { + public static Card Create(TCardInstance instance, ECardType type) + { + if (instance == null) return null; + + try + { + var staticData = Data.GetStatic().GetAwaiter().GetResult(); + var template = staticData.GetCardById(instance.TemplateId); + if (template == null) + return null; + + Card previewCard = DTOUtils.CreateCard(instance.InstanceId, instance.TemplateId.ToString(), type); + previewCard.Template = template; + previewCard.Type = type; + previewCard.Tier = instance.Tier; + previewCard.Size = template.Size; + previewCard.Tags = template.Tags != null + ? new HashSet(template.Tags) + : new HashSet(); + previewCard.Attributes = instance.Attributes != null + ? new Dictionary(instance.Attributes) + : new Dictionary(); + + Dictionary previewAttributes = + TheBazaar.CardExtensions.BuildAttributeDictionaryForTier(previewCard, template, instance.Tier); + + if (previewCard is ItemCard previewItem && + template is TCardItem itemTemplate && + instance is TCardInstanceItem itemInstance) + { + TheBazaar.CardExtensions.ApplyPreviewEnchantment(itemTemplate, previewItem, previewAttributes, itemInstance.EnchantmentType); + } + + previewCard.Attributes = previewAttributes; + return previewCard; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"PreviewCardFactory.Create error: {ex.Message}"); + return null; + } + } + } +} diff --git a/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewModel.cs b/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewModel.cs new file mode 100644 index 0000000..bd6877b --- /dev/null +++ b/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewModel.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using BazaarGameClient.Domain.Models.Cards; + +namespace BazaarAccess.Gameplay.CombatEncounterPreview; + +internal sealed class CombatEncounterPreviewModel +{ + public string EnemyName { get; set; } + public int Health { get; set; } + public List Skills { get; } = new List(); + public List Items { get; } = new List(); +} diff --git a/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewNavigator.cs b/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewNavigator.cs new file mode 100644 index 0000000..87a8df3 --- /dev/null +++ b/BazaarAccess/Gameplay/CombatEncounterPreview/CombatEncounterPreviewNavigator.cs @@ -0,0 +1,839 @@ +using BazaarAccess.Accessibility; +using BazaarAccess.Core; +using BazaarAccess.Gameplay.Navigation; +using BazaarGameClient.Domain.Models.Cards; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; +using TheBazaar; +using TheBazaar.Tooltips; +using TheBazaar.UI; +using TheBazaar.UI.Tooltips; + +namespace BazaarAccess.Gameplay.CombatEncounterPreview; + +internal sealed class CombatEncounterPreviewNavigator +{ + private enum CombatEncounterPreviewSection + { + Stats, + Skills, + Board + } + + private CombatEncounterPreviewModel _model; + private Card _sourceEncounterCard; + private CardTooltipController _visualTooltipController; + private bool _visualPending; + private bool _tooltipUnlockObserved; + private object _tooltipUnlockEvent; + private CombatEncounterPreviewSection _section = CombatEncounterPreviewSection.Stats; + private int _skillIndex; + private int _itemIndex; + private readonly DetailReader _detailReader = new DetailReader(); + + public bool IsActive => _model != null; + + public bool TryEnter(Card card) + { + var model = CombatEncounterPreviewFactory.Create(card); + if (model == null) + { + TolkWrapper.Speak("Nothing to inspect"); + return false; + } + + _model = model; + _sourceEncounterCard = card; + _visualPending = true; + _tooltipUnlockObserved = false; + _section = CombatEncounterPreviewSection.Stats; + _skillIndex = 0; + _itemIndex = 0; + _detailReader.Clear(); + + TolkWrapper.Speak($"{_model.EnemyName} loadout"); + return true; + } + + public void Exit(bool announce = true) + { + if (!IsActive) return; + + UnsubscribeTooltipEvents(); + HideCurrentPreviewHover(); + SetEncounterCursorState(isOverCard: false); + UnlockVisualPreview(); + + _model = null; + _sourceEncounterCard = null; + _visualTooltipController = null; + _visualPending = false; + _tooltipUnlockObserved = false; + _tooltipUnlockEvent = null; + _section = CombatEncounterPreviewSection.Stats; + _skillIndex = 0; + _itemIndex = 0; + _detailReader.Clear(); + + if (announce) + TolkWrapper.Speak("Exited preview"); + } + + public IEnumerator ShowVisualPreview() + { + if (_sourceEncounterCard == null) + { + _visualPending = false; + yield break; + } + + var boardManager = BoardStashNavigator.GetBoardManager(); + if (boardManager == null) + { + _visualPending = false; + yield break; + } + + var controller = VisualSelector.FindCardController(_sourceEncounterCard, boardManager); + if (controller is not EncounterController encounterController) + { + _visualPending = false; + yield break; + } + + SetCursorOverCard(encounterController, true); + SubscribeTooltipEvents(); + + if (!TryShowEncounterTooltip(encounterController)) + { + SetCursorOverCard(encounterController, false); + _visualPending = false; + Exit(announce: false); + yield break; + } + + float waited = 0f; + const float step = 0.05f; + const float maxWait = 2.0f; + float lastShowRetry = 0f; + bool lockRequested = false; + + while (waited < maxWait) + { + if (waited - lastShowRetry >= 0.25f) + { + TryShowEncounterTooltip(encounterController); + lastShowRetry = waited; + } + + var tooltipController = GetTooltipController(); + if (!lockRequested && + tooltipController != null && + tooltipController.CurrentTooltipData is CardTooltipData tooltipData && + tooltipData.CardInstance == _sourceEncounterCard && + tooltipController.HasShown) + { + _visualTooltipController = tooltipController; + tooltipController.Lock(); + lockRequested = true; + } + + waited += step; + yield return new UnityEngine.WaitForSeconds(step); + } + + waited = 0f; + while (waited < maxWait) + { + if (IsVisualActive()) + { + _visualPending = false; + SyncVisualFocus(); + yield break; + } + + waited += step; + yield return new UnityEngine.WaitForSeconds(step); + } + + _visualPending = false; + Exit(announce: false); + } + + public IEnumerator MonitorVisualState() + { + while (IsActive) + { + if (_tooltipUnlockObserved) + { + Exit(announce: false); + yield break; + } + + if (!_visualPending && !IsVisualActive()) + { + Exit(announce: false); + yield break; + } + + yield return new UnityEngine.WaitForSeconds(0.1f); + } + } + + public bool IsVisualActive() + { + if (_visualPending) + return true; + + if (!IsActive || _sourceEncounterCard == null) + return false; + + var tooltipController = GetTooltipController(); + if (tooltipController == null) + return false; + + if (!IsTooltipLocked(tooltipController)) + return false; + + if (tooltipController.CurrentTooltipData is not CardTooltipData tooltipData) + return false; + + return tooltipData.CardInstance == _sourceEncounterCard; + } + + public void HandleInput(AccessibleKey key) + { + if (!IsActive) return; + + switch (key) + { + case AccessibleKey.Back: + Exit(); + return; + + case AccessibleKey.GoToEnemy: + _section = CombatEncounterPreviewSection.Stats; + _detailReader.Clear(); + AnnounceStatsSection(); + return; + + case AccessibleKey.GoToStash: + _section = CombatEncounterPreviewSection.Board; + _detailReader.Clear(); + AnnounceBoardSection(); + return; + } + + switch (_section) + { + case CombatEncounterPreviewSection.Stats: + HandleStatsInput(key); + break; + + case CombatEncounterPreviewSection.Skills: + HandleSkillsInput(key); + break; + + case CombatEncounterPreviewSection.Board: + HandleBoardInput(key); + break; + } + } + + private void HandleStatsInput(AccessibleKey key) + { + switch (key) + { + case AccessibleKey.Left: + AnnounceStats(); + SyncVisualFocus(); + return; + + case AccessibleKey.Up: + TolkWrapper.Speak("Start of list"); + SyncVisualFocus(); + return; + + case AccessibleKey.Down: + TolkWrapper.Speak("End of list"); + SyncVisualFocus(); + return; + + case AccessibleKey.Right: + if (_model.Skills.Count == 0) + { + TolkWrapper.Speak("No skills"); + return; + } + + _section = CombatEncounterPreviewSection.Skills; + _skillIndex = 0; + _detailReader.Clear(); + AnnounceSkillsSection(); + SyncVisualFocus(); + return; + + case AccessibleKey.Confirm: + AnnounceStats(); + SyncVisualFocus(); + return; + } + } + + private void HandleSkillsInput(AccessibleKey key) + { + switch (key) + { + case AccessibleKey.Left: + _section = CombatEncounterPreviewSection.Stats; + _detailReader.Clear(); + AnnounceStatsSection(); + SyncVisualFocus(); + return; + + case AccessibleKey.Right: + AnnounceCurrentSkill(); + SyncVisualFocus(); + return; + + case AccessibleKey.Up: + if (_model.Skills.Count == 0) + { + TolkWrapper.Speak("No skills"); + return; + } + + if (_skillIndex <= 0) + { + TolkWrapper.Speak("Start of list"); + SyncVisualFocus(); + return; + } + + _skillIndex--; + + AnnounceCurrentSkill(); + SyncVisualFocus(); + return; + + case AccessibleKey.Down: + if (_model.Skills.Count == 0) + { + TolkWrapper.Speak("No skills"); + return; + } + + if (_skillIndex >= _model.Skills.Count - 1) + { + TolkWrapper.Speak("End of list"); + SyncVisualFocus(); + return; + } + + _skillIndex++; + + AnnounceCurrentSkill(); + SyncVisualFocus(); + return; + + case AccessibleKey.Confirm: + Card skill = GetCurrentSkill(); + if (skill == null) + { + TolkWrapper.Speak("No skill"); + return; + } + + TolkWrapper.Speak(ItemReader.GetDetailedDescription(skill)); + SyncVisualFocus(); + return; + } + } + + private void HandleBoardInput(AccessibleKey key) + { + switch (key) + { + case AccessibleKey.Left: + NavigateBoard(-1); + return; + + case AccessibleKey.Right: + NavigateBoard(1); + return; + + case AccessibleKey.Up: + SpeakBoardDetail(up: true); + return; + + case AccessibleKey.Down: + SpeakBoardDetail(up: false); + return; + + case AccessibleKey.Confirm: + Card item = GetCurrentItem(); + if (item == null) + { + TolkWrapper.Speak("No item"); + return; + } + + TolkWrapper.Speak(ItemReader.GetDetailedDescription(item)); + return; + } + } + + private void NavigateBoard(int delta) + { + if (_model.Items.Count == 0) + { + TolkWrapper.Speak("No items"); + return; + } + + _detailReader.Clear(); + + int nextIndex = _itemIndex + delta; + if (nextIndex < 0) + { + TolkWrapper.Speak("Start of list"); + SyncVisualFocus(); + return; + } + else if (nextIndex >= _model.Items.Count) + { + TolkWrapper.Speak("End of list"); + SyncVisualFocus(); + return; + } + + _itemIndex = nextIndex; + AnnounceCurrentItem(); + SyncVisualFocus(); + } + + private void SpeakBoardDetail(bool up) + { + Card item = GetCurrentItem(); + if (item == null) + { + TolkWrapper.Speak("No item"); + return; + } + + _detailReader.Init(item, c => ItemReader.GetEnemyDetailLines(c)); + if (!_detailReader.HasLines) + { + TolkWrapper.Speak("No details"); + return; + } + + string line = up ? _detailReader.LineUp() : _detailReader.LineDown(); + TolkWrapper.Speak(line ?? "No details"); + SyncVisualFocus(); + } + + private void AnnounceStats() + { + TolkWrapper.Speak($"Health: {_model.Health}"); + } + + private void AnnounceStatsSection() + { + TolkWrapper.Speak($"Stats. Health: {_model.Health}"); + } + + private void AnnounceSkillsSection() + { + if (_model.Skills.Count == 0) + { + TolkWrapper.Speak("Skills (0)"); + return; + } + + TolkWrapper.Speak($"Skills ({_model.Skills.Count}). {GetCurrentSkillAnnouncement()}"); + } + + private void AnnounceBoardSection() + { + if (_model.Items.Count == 0) + { + TolkWrapper.Speak("Board. No items"); + return; + } + + TolkWrapper.Speak($"Board. {GetCurrentItemAnnouncement()}"); + } + + private void AnnounceCurrentSkill() + { + Card skill = GetCurrentSkill(); + if (skill == null) + { + TolkWrapper.Speak("No skill"); + return; + } + + TolkWrapper.Speak(GetCurrentSkillAnnouncement()); + } + + private void AnnounceCurrentItem() + { + Card item = GetCurrentItem(); + if (item == null) + { + TolkWrapper.Speak("No item"); + return; + } + + TolkWrapper.Speak(GetCurrentItemAnnouncement()); + } + + private Card GetCurrentSkill() + { + if (_model == null || _skillIndex < 0 || _skillIndex >= _model.Skills.Count) + return null; + + return _model.Skills[_skillIndex]; + } + + private Card GetCurrentItem() + { + if (_model == null || _itemIndex < 0 || _itemIndex >= _model.Items.Count) + return null; + + return _model.Items[_itemIndex]; + } + + private string GetCurrentSkillAnnouncement() + { + Card skill = GetCurrentSkill(); + if (skill == null) + return "No skill"; + + string name = ItemReader.GetCardName(skill); + string description = ItemReader.GetFullDescription(skill); + return !string.IsNullOrEmpty(description) ? $"{name}: {description}" : name; + } + + private string GetCurrentItemAnnouncement() + { + Card item = GetCurrentItem(); + if (item == null) + return "No item"; + + return ItemReader.GetEnemyCompactDescription(item); + } + + private CardTooltipController GetTooltipController() + { + if (_visualTooltipController != null) + return _visualTooltipController; + + if (_sourceEncounterCard == null || Data.TooltipParentComponent == null) + return null; + + return Data.TooltipParentComponent.GetCardTooltipController(_sourceEncounterCard); + } + + private bool TryShowEncounterTooltip(EncounterController encounterController) + { + try + { + if (encounterController == null || Data.TooltipParentComponent == null) + return false; + + var tooltipData = encounterController.GetTooltipData(); + if (tooltipData == null) + return false; + + Data.TooltipParentComponent.ShowCardTooltipController( + encounterController.transform, + GetTooltipOffset(encounterController), + tooltipData); + + return true; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"TryShowEncounterTooltip error: {ex.Message}"); + return false; + } + } + + private static Vector3 GetTooltipOffset(CardController controller) + { + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + PropertyInfo property = typeof(CardController).GetProperty("TooltipOffset", flags); + if (property?.GetValue(controller) is Vector3 offset) + return offset; + + FieldInfo field = typeof(CardController).GetField("tooltipOffset", flags); + if (field?.GetValue(controller) is Vector3 fieldOffset) + return fieldOffset; + } + catch + { + // Fall through to zero offset fallback. + } + + return Vector3.zero; + } + + private static bool IsTooltipLocked(CardTooltipController tooltipController) + { + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + PropertyInfo property = typeof(CardTooltipController).GetProperty("IsLocked", flags); + if (property?.GetValue(tooltipController) is bool propertyValue) + return propertyValue; + + FieldInfo field = typeof(BaseTooltipController).GetField("isLocked", flags); + if (field?.GetValue(tooltipController) is bool fieldValue) + return fieldValue; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"IsTooltipLocked reflection error: {ex.Message}"); + } + + return Data.TooltipParentComponent != null && + Data.TooltipParentComponent.IsCardTooltipControllerLocked(tooltipController); + } + + private void SubscribeTooltipEvents() + { + try + { + var tooltipUnlockEvent = GetTooltipUnlockEvent(); + if (tooltipUnlockEvent == null) + return; + + _tooltipUnlockEvent = tooltipUnlockEvent; + + MethodInfo removeListener = tooltipUnlockEvent.GetType().GetMethod( + "RemoveListener", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + null, + new[] { typeof(Action) }, + null); + + MethodInfo addListener = tooltipUnlockEvent.GetType().GetMethod( + "AddListener", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + null, + new[] { typeof(Action), typeof(MonoBehaviour) }, + null); + + removeListener?.Invoke(tooltipUnlockEvent, new object[] { (Action)OnTooltipUnlock }); + addListener?.Invoke(tooltipUnlockEvent, new object[] { (Action)OnTooltipUnlock, null }); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"SubscribeTooltipEvents error: {ex.Message}"); + } + } + + private void UnsubscribeTooltipEvents() + { + try + { + var tooltipUnlockEvent = _tooltipUnlockEvent ?? GetTooltipUnlockEvent(); + if (tooltipUnlockEvent == null) + return; + + MethodInfo removeListener = tooltipUnlockEvent.GetType().GetMethod( + "RemoveListener", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + null, + new[] { typeof(Action) }, + null); + + removeListener?.Invoke(tooltipUnlockEvent, new object[] { (Action)OnTooltipUnlock }); + _tooltipUnlockEvent = null; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"UnsubscribeTooltipEvents error: {ex.Message}"); + } + } + + private void OnTooltipUnlock() + { + if (IsActive) + { + _tooltipUnlockObserved = true; + } + } + + private static object GetTooltipUnlockEvent() + { + const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public; + Type eventsType = typeof(Data).Assembly.GetType("TheBazaar.Events"); + FieldInfo field = eventsType?.GetField("TooltipUnlock", flags); + return field?.GetValue(null); + } + + private void SetEncounterCursorState(bool isOverCard) + { + try + { + var boardManager = BoardStashNavigator.GetBoardManager(); + if (boardManager == null || _sourceEncounterCard == null) + return; + + var controller = VisualSelector.FindCardController(_sourceEncounterCard, boardManager) as EncounterController; + if (controller != null) + { + SetCursorOverCard(controller, isOverCard); + } + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"SetEncounterCursorState error: {ex.Message}"); + } + } + + private static void SetCursorOverCard(CardController controller, bool isOverCard) + { + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + PropertyInfo property = typeof(CardController).GetProperty("IsCursorOverCard", flags); + MethodInfo setter = property?.GetSetMethod(nonPublic: true); + setter?.Invoke(controller, new object[] { isOverCard }); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"SetCursorOverCard error: {ex.Message}"); + } + } + + private void UnlockVisualPreview() + { + try + { + var tooltipController = GetTooltipController(); + if (tooltipController != null && IsTooltipLocked(tooltipController)) + { + tooltipController.Unlock(); + } + else if (tooltipController != null) + { + tooltipController.StartTooltipFadeOut(); + } + + if (Data.TooltipParentComponent != null) + { + Data.TooltipParentComponent.HideSecondaryCardTooltipController(); + } + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"UnlockVisualPreview error: {ex.Message}"); + } + } + + private void SyncVisualFocus() + { + try + { + if (!IsVisualActive()) + return; + + HideCurrentPreviewHover(); + + if (_section == CombatEncounterPreviewSection.Stats) + return; + + if (!TryGetMonsterBoardTooltip(out var monsterBoardTooltip)) + return; + + if (_section == CombatEncounterPreviewSection.Skills) + { + var skills = GetActivePreviewCards(monsterBoardTooltip, "_activeSkills"); + if (_skillIndex >= 0 && _skillIndex < skills.Count) + { + skills[_skillIndex]?.OnHover(); + } + } + else if (_section == CombatEncounterPreviewSection.Board) + { + var items = GetActivePreviewCards(monsterBoardTooltip, "_activeCards"); + if (_itemIndex >= 0 && _itemIndex < items.Count) + { + items[_itemIndex]?.OnHover(); + } + } + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"SyncVisualFocus error: {ex.Message}"); + } + } + + private void HideCurrentPreviewHover() + { + try + { + if (Data.TooltipParentComponent != null) + Data.TooltipParentComponent.HideSecondaryCardTooltipController(); + + if (!TryGetMonsterBoardTooltip(out var monsterBoardTooltip)) + return; + + foreach (var preview in GetActivePreviewCards(monsterBoardTooltip, "_activeSkills")) + { + preview?.OnHoverOut(); + } + + foreach (var preview in GetActivePreviewCards(monsterBoardTooltip, "_activeCards")) + { + preview?.OnHoverOut(); + } + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"HideCurrentPreviewHover error: {ex.Message}"); + } + } + + private bool TryGetMonsterBoardTooltip(out MonsterBoardTooltip monsterBoardTooltip) + { + monsterBoardTooltip = null; + + var tooltipController = GetTooltipController(); + if (tooltipController == null) + return false; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var field = typeof(CardTooltipController).GetField("_monsterBoardTooltip", flags); + monsterBoardTooltip = field?.GetValue(tooltipController) as MonsterBoardTooltip; + return monsterBoardTooltip != null; + } + + private static List GetActivePreviewCards(MonsterBoardTooltip monsterBoardTooltip, string fieldName) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var field = typeof(MonsterBoardTooltip).GetField(fieldName, flags); + if (field?.GetValue(monsterBoardTooltip) is System.Collections.IEnumerable enumerable) + { + var result = new List(); + foreach (var entry in enumerable) + { + if (entry is CardPreviewBase preview) + result.Add(preview); + } + return result; + } + + return new List(); + } +} diff --git a/BazaarAccess/Gameplay/GameplayScreen.cs b/BazaarAccess/Gameplay/GameplayScreen.cs index 6a037d0..19835ec 100644 --- a/BazaarAccess/Gameplay/GameplayScreen.cs +++ b/BazaarAccess/Gameplay/GameplayScreen.cs @@ -3,6 +3,7 @@ using BazaarAccess.Accessibility; using BazaarAccess.Core; using BazaarAccess.Patches; +using BazaarAccess.Gameplay.CombatEncounterPreview; using BazaarAccess.UI; using BazaarGameClient.Domain.Models.Cards; using BazaarGameShared.Domain.Core.Types; @@ -26,6 +27,7 @@ public class GameplayScreen : IAccessibleScreen private readonly ActionMenuHandler _actionMenu; private readonly CombatInputHandler _combatHandler; private readonly ReplayInputHandler _replayHandler; + private readonly CombatEncounterPreviewNavigator _combatEncounterPreview; private bool _isValid = true; private ERunState _lastState = ERunState.Choice; @@ -35,10 +37,17 @@ public GameplayScreen() _actionMenu = new ActionMenuHandler(_navigator, HandleUpgradeConfirm, RefreshAndAnnounce); _combatHandler = new CombatInputHandler(_navigator); _replayHandler = new ReplayInputHandler(_navigator, TriggerReplayContinue, TriggerReplayReplay, TriggerReplayRecap); + _combatEncounterPreview = new CombatEncounterPreviewNavigator(); } public void HandleInput(AccessibleKey key) { + if (_combatEncounterPreview.IsActive) + { + _combatEncounterPreview.HandleInput(key); + return; + } + // Handle action mode input (when in action mode) if (_actionMenu.IsActive) { @@ -164,6 +173,10 @@ private void HandleNormalInput(AccessibleKey key) _navigator.ReadEnemyInfo(); break; + case AccessibleKey.Inspect: + HandleInspect(); + break; + // Navegación dentro de la sección actual case AccessibleKey.Right: if (_navigator.IsInHeroSection) @@ -379,7 +392,7 @@ public string GetHelp() { return "Left/Right: Navigate items. Up/Down: Read details. " + "Tab: Switch section. Space: Toggle stash. G: Go to stash. " + - "B: Board. V: Hero. C: Choices. F: Enemy. I: Properties. W: Wins. " + + "B: Board. V: Hero. C: Choices. F: Enemy. X: Inspect. I: Properties. W: Wins. " + "Enter: Select/Buy or Action menu on board items. E: Exit. R: Refresh. " + "In Action menu: S sell, U upgrade, M move, Arrows reorder. " + "Ctrl+Arrows: Detail reading. Period/Comma: Messages."; @@ -387,6 +400,7 @@ public string GetHelp() public void OnFocus() { + _combatEncounterPreview.Exit(announce: false); _lastState = StateChangePatch.GetCurrentRunState(); _navigator.Refresh(); @@ -409,6 +423,7 @@ public void OnFocus() /// True si el estado realmente cambió (calculado por StateChangePatch) public void OnStateChanged(ERunState newState, bool stateActuallyChanged = true) { + _combatEncounterPreview.Exit(announce: false); _lastState = newState; // Durante combate, no anunciar nada aquí (OnCombatStateChanged lo hará) @@ -581,6 +596,22 @@ private void HandleConfirm() _navigator.ReadDetailedInfo(); } + private void HandleInspect() + { + var card = _navigator.GetCurrentCard(); + if (card?.Type != ECardType.CombatEncounter) + { + TolkWrapper.Speak("Nothing to inspect"); + return; + } + + if (_combatEncounterPreview.TryEnter(card)) + { + Plugin.Instance.StartCoroutine(_combatEncounterPreview.ShowVisualPreview()); + Plugin.Instance.StartCoroutine(_combatEncounterPreview.MonitorVisualState()); + } + } + private void HandleCardConfirm(Card card) { switch (card.Type) @@ -1133,6 +1164,7 @@ private System.Collections.IEnumerator DelayedRefreshAfterUpgrade() /// public void OnCombatStateChanged(bool inCombat) { + _combatEncounterPreview.Exit(announce: false); _navigator.SetCombatMode(inCombat); if (inCombat) @@ -1197,6 +1229,7 @@ private System.Collections.IEnumerator DelayedStashAnnounce() /// public void OnReplayStateChanged(bool inReplayState) { + _combatEncounterPreview.Exit(announce: false); _navigator.SetReplayMode(inReplayState); if (inReplayState) diff --git a/BazaarAccess/Gameplay/Navigation/VisualSelector.cs b/BazaarAccess/Gameplay/Navigation/VisualSelector.cs index d314f9a..a303e69 100644 --- a/BazaarAccess/Gameplay/Navigation/VisualSelector.cs +++ b/BazaarAccess/Gameplay/Navigation/VisualSelector.cs @@ -15,6 +15,8 @@ namespace BazaarAccess.Gameplay.Navigation; /// public static class VisualSelector { + private static int _syntheticPointerNonce; + /// /// Finds the CardController for a card and triggers visual selection (hover, sound, tooltip). /// @@ -163,10 +165,17 @@ public static CardController FindCardController(Card card, BoardManager bm) /// private static void ApplySelection(CardController controller) { + if (controller is EncounterController encounterController) + { + ApplyEncounterSelection(encounterController); + return; + } + var eventSystem = EventSystem.current; + Vector2 pointerPosition = GetSyntheticPointerPosition(); var pointerData = new PointerEventData(eventSystem) { - position = Vector2.zero + position = pointerPosition }; controller.OnPointerEnter(pointerData); @@ -174,6 +183,59 @@ private static void ApplySelection(CardController controller) TriggerHoverSound(controller); } + private static void ApplyEncounterSelection(EncounterController controller) + { + try + { + if (controller == null || Data.TooltipParentComponent == null) + return; + + SetCursorOverCard(controller, true); + + var tooltipData = controller.GetTooltipData(); + if (tooltipData != null) + { + Data.TooltipParentComponent.ShowCardTooltipController( + controller.transform, + GetTooltipOffset(controller), + tooltipData); + } + + controller.HoverMove(); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"ApplyEncounterSelection error: {ex.Message}"); + } + } + + /// + /// EncounterController only starts hover when the pointer position changes. + /// Use a non-zero screen position that keeps changing so revisiting the same + /// encounter still counts as mouse movement. + /// + private static Vector2 GetSyntheticPointerPosition() + { + int nonce = _syntheticPointerNonce++; + float jitterX = (nonce % 29) + 1f; + float jitterY = ((nonce / 29) % 11) + 1f; + + try + { + Vector3 mousePosition = Input.mousePosition; + if (mousePosition.x > 0f || mousePosition.y > 0f) + return new Vector2(mousePosition.x + jitterX, mousePosition.y + jitterY); + } + catch + { + // Fall through to center-screen fallback. + } + + float x = Screen.width > 0 ? Screen.width * 0.5f : 1f; + float y = Screen.height > 0 ? Screen.height * 0.5f : 1f; + return new Vector2(x + jitterX, y + jitterY); + } + /// /// Plays the hover sound for controller types that don't play it in HoverMove(). /// EncounterController already plays sound in its HoverMove() override. @@ -219,6 +281,7 @@ private static void ResetAllCardSelections(BoardManager bm) foreach (var socket in bm.playerItemSockets) { socket?.CardController?.ResetPosition(hideTooltips: true); + SetCursorOverCard(socket?.CardController, false); } } @@ -227,6 +290,7 @@ private static void ResetAllCardSelections(BoardManager bm) foreach (var socket in bm.opponentItemSockets) { socket?.CardController?.ResetPosition(hideTooltips: true); + SetCursorOverCard(socket?.CardController, false); } } @@ -235,6 +299,7 @@ private static void ResetAllCardSelections(BoardManager bm) foreach (var socket in bm.playerSkillSockets) { socket?.CardController?.ResetPosition(hideTooltips: true); + SetCursorOverCard(socket?.CardController, false); } } @@ -243,6 +308,7 @@ private static void ResetAllCardSelections(BoardManager bm) foreach (var socket in bm.opponentSkillSockets) { socket?.CardController?.ResetPosition(hideTooltips: true); + SetCursorOverCard(socket?.CardController, false); } } @@ -251,6 +317,7 @@ private static void ResetAllCardSelections(BoardManager bm) foreach (var socket in bm.playerStorageSockets) { socket?.CardController?.ResetPosition(hideTooltips: true); + SetCursorOverCard(socket?.CardController, false); } } } @@ -265,4 +332,43 @@ private static BoardManager GetBoardManager() try { return Singleton.Instance; } catch { return null; } } + + private static Vector3 GetTooltipOffset(CardController controller) + { + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + PropertyInfo property = typeof(CardController).GetProperty("TooltipOffset", flags); + if (property?.GetValue(controller) is Vector3 offset) + return offset; + + FieldInfo field = typeof(CardController).GetField("tooltipOffset", flags); + if (field?.GetValue(controller) is Vector3 fieldOffset) + return fieldOffset; + } + catch + { + // Fall back to zero offset below. + } + + return Vector3.zero; + } + + private static void SetCursorOverCard(CardController controller, bool isOverCard) + { + try + { + if (controller == null) + return; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + PropertyInfo property = typeof(CardController).GetProperty("IsCursorOverCard", flags); + MethodInfo setter = property?.GetSetMethod(nonPublic: true); + setter?.Invoke(controller, new object[] { isOverCard }); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"VisualSelector.SetCursorOverCard error: {ex.Message}"); + } + } } From a7ebbb75aa693424c0103145a96f6e54136b5303 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Tue, 14 Apr 2026 11:13:21 +0800 Subject: [PATCH 07/13] Replace the custom combat stats tracker with the game's native recap statistics Removed h for combat stats summary. When pressing e to enter recap mode, you can now view the board to see recap statistics for each item. State is synced with the game's visual display, so enter recap also shows the recap visually. This fixes bugs caused by the mod's custom stats tracking, such as you and the opponent having the same item having inaccurate stats. --- .../Gameplay/CardReading/DetailLineBuilder.cs | 42 ++++ .../Gameplay/CardReading/RecapStatsReader.cs | 101 ++++++++++ .../Gameplay/Combat/CardStatsTracker.cs | 182 ------------------ BazaarAccess/Gameplay/CombatDescriber.cs | 50 ++--- BazaarAccess/Gameplay/GameplayNavigator.cs | 55 ++++-- BazaarAccess/Gameplay/GameplayScreen.cs | 143 +++++++++++++- .../Gameplay/Navigation/NavigationTypes.cs | 3 +- .../Gameplay/Navigation/RecapNavigator.cs | 64 +----- BazaarAccess/Gameplay/ReplayInputHandler.cs | 25 +-- 9 files changed, 347 insertions(+), 318 deletions(-) create mode 100644 BazaarAccess/Gameplay/CardReading/RecapStatsReader.cs delete mode 100644 BazaarAccess/Gameplay/Combat/CardStatsTracker.cs diff --git a/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs b/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs index 9ab8cdb..22bdc14 100644 --- a/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs +++ b/BazaarAccess/Gameplay/CardReading/DetailLineBuilder.cs @@ -229,6 +229,48 @@ private static List BuildDetailLines(Card card, bool enemyOrder) lines.Add(CardProperties.GetCardName(card)); + if (RecapStatsReader.IsRecapViewActive()) + { + if (enemyOrder) + { + lines.AddRange(RecapStatsReader.GetRecapLines(card)); + AddDescriptionLines(lines, card); + AddAbilityLines(lines, card); + + string recapCooldown = GetCooldownLineText(card); + if (recapCooldown != null) lines.Add(recapCooldown); + + AddAllCombatStats(lines, card, EnemyCombatStats); + + lines.Add(CardProperties.GetTierName(card)); + + string recapTags = CardProperties.GetTags(card); + if (!string.IsNullOrEmpty(recapTags)) lines.Add(recapTags); + + string recapSizeText = GetSizeText(card); + if (recapSizeText != null) lines.Add(recapSizeText); + } + else + { + // Player board details are passed through DetailReader, which reverses lines before reading. + // Build recap-mode lines in reverse so the spoken order starts with name -> recap -> uses -> tier. + var reversedLines = new List(); + + AddAbilityLines(reversedLines, card); + AddDescriptionLines(reversedLines, card); + reversedLines.Add(CardProperties.GetTierName(card)); + + var recapLines = RecapStatsReader.GetRecapLines(card); + recapLines.Reverse(); + reversedLines.AddRange(recapLines); + + reversedLines.Add(CardProperties.GetCardName(card)); + return reversedLines; + } + + return lines; + } + if (enemyOrder) { AddDescriptionLines(lines, card); diff --git a/BazaarAccess/Gameplay/CardReading/RecapStatsReader.cs b/BazaarAccess/Gameplay/CardReading/RecapStatsReader.cs new file mode 100644 index 0000000..24ca053 --- /dev/null +++ b/BazaarAccess/Gameplay/CardReading/RecapStatsReader.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using BazaarGameClient.Domain.Models.Cards; +using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Infra.Messages.CombatSimEvents; +using TheBazaar; +using TheBazaar.AppFramework; + +namespace BazaarAccess.Gameplay.CardReading; + +/// +/// Reads the native post-combat recap stats shown by the game for recap cards. +/// +internal static class RecapStatsReader +{ + private struct RecapStatEntry + { + public readonly ECardStats Stat; + public readonly string Label; + + public RecapStatEntry(ECardStats stat, string label) + { + Stat = stat; + Label = label; + } + } + + private static readonly FieldInfo LastCombatSimField = + typeof(BoardManager).GetField("_lastCombatSim", BindingFlags.Instance | BindingFlags.NonPublic); + + private static readonly RecapStatEntry[] OrderedRecapStats = + { + new RecapStatEntry(ECardStats.DamageDone, "Damage dealt"), + new RecapStatEntry(ECardStats.HealAdded, "Heal applied"), + new RecapStatEntry(ECardStats.ShieldAdded, "Shield applied"), + new RecapStatEntry(ECardStats.BurnAdded, "Burn applied"), + new RecapStatEntry(ECardStats.PoisonAdded, "Poison applied"), + new RecapStatEntry(ECardStats.RegenAdded, "Regeneration applied"), + new RecapStatEntry(ECardStats.RageAdded, "Rage applied"), + new RecapStatEntry(ECardStats.HastedCardsCount, "Hasted items"), + new RecapStatEntry(ECardStats.SlowedCardsCount, "Slowed items"), + new RecapStatEntry(ECardStats.FrozenCardsCount, "Frozen items"), + }; + + public static bool IsRecapViewActive() + { + try + { + return Singleton.Instance?.IsRecapViewOpen == true; + } + catch + { + return false; + } + } + + public static List GetRecapLines(Card card) + { + if (card == null || !IsRecapViewActive()) + { + return new List(); + } + + var lines = new List { "Recap" }; + var stats = GetStats(card); + + lines.Add($"Uses: {GetStatValue(stats, ECardStats.UseCount)}"); + + foreach (var entry in OrderedRecapStats) + { + int value = GetStatValue(stats, entry.Stat); + if (value > 0) + { + lines.Add($"{entry.Label}: {value}"); + } + } + + return lines; + } + + private static Dictionary GetStats(Card card) + { + var boardManager = Singleton.Instance; + var combatSim = LastCombatSimField?.GetValue(boardManager) as CombatSim; + + if (combatSim?.CardStats == null) + { + return new Dictionary(); + } + + return combatSim.CardStats.TryGetValue(card.InstanceId.ToString(), out var stats) + ? stats + : new Dictionary(); + } + + private static int GetStatValue(Dictionary stats, ECardStats stat) + { + return stats != null && stats.TryGetValue(stat, out int value) ? value : 0; + } +} diff --git a/BazaarAccess/Gameplay/Combat/CardStatsTracker.cs b/BazaarAccess/Gameplay/Combat/CardStatsTracker.cs deleted file mode 100644 index 21fc5f1..0000000 --- a/BazaarAccess/Gameplay/Combat/CardStatsTracker.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using BazaarGameClient.Domain.Models.Cards; -using BazaarGameShared.Infra.Messages.CombatSimEvents; -using TheBazaar; - -namespace BazaarAccess.Gameplay.Combat; - -/// -/// Tracks per-card combat statistics (damage, heal, shield, triggers, crits, repairs). -/// Stats persist through wave announcements and are cleared at combat start. -/// Used by the H key recap and post-combat stats screen. -/// -public static class CardStatsTracker -{ - private static Dictionary _playerCardStats = new Dictionary(); - private static Dictionary _enemyCardStats = new Dictionary(); - - /// - /// Per-card stats accumulated over the entire combat. - /// - public class CardCombatStats - { - public int Damage; - public int Heal; - public int Shield; - public int Triggers; - public int Crits; - public int Repairs; - - public string Format(string name) - { - var parts = new List(); - parts.Add(name); - if (Damage > 0) parts.Add($"{Damage} damage"); - if (Heal > 0) parts.Add($"{Heal} heal"); - if (Shield > 0) parts.Add($"{Shield} shield"); - if (Repairs > 0) parts.Add($"{Repairs} repairs"); - if (Crits > 0) parts.Add($"{Crits} crits"); - parts.Add($"{Triggers} triggers"); - return string.Join(", ", parts); - } - } - - /// - /// Clears all tracked stats for a new combat. - /// - public static void Clear() - { - _playerCardStats.Clear(); - _enemyCardStats.Clear(); - } - - /// - /// Whether there are any per-card stats available (combat has occurred). - /// - public static bool HasCombatStats => _playerCardStats.Count > 0 || _enemyCardStats.Count > 0; - - /// - /// Gets or creates a CardCombatStats entry for the given item. - /// - private static CardCombatStats GetOrCreateStats(string itemName, bool isPlayerItem) - { - var dict = isPlayerItem ? _playerCardStats : _enemyCardStats; - if (!dict.TryGetValue(itemName, out var stats)) - { - stats = new CardCombatStats(); - dict[itemName] = stats; - } - return stats; - } - - /// - /// Tracks trigger count for ALL combat events, regardless of action type. - /// This ensures passive items (Water Wheel, Keychain, etc.) appear in combat stats. - /// - public static void TrackTriggerCount(string itemName, bool isPlayerItem) - { - if (string.IsNullOrEmpty(itemName)) return; - - var stats = GetOrCreateStats(itemName, isPlayerItem); - stats.Triggers++; - } - - /// - /// Tracks detailed per-card stats (damage, heal, etc.) for relevant actions only. - /// Trigger count is handled separately by TrackTriggerCount. - /// - public static void TrackCardStats(string itemName, bool isPlayerItem, ActionType actionType, int amount, bool isCrit, CombatActionData data) - { - if (string.IsNullOrEmpty(itemName)) return; - - var stats = GetOrCreateStats(itemName, isPlayerItem); - - // Don't increment Triggers here - TrackTriggerCount handles it - if (isCrit) stats.Crits++; - - switch (actionType) - { - case ActionType.PlayerDamage: - stats.Damage += amount; - break; - case ActionType.PlayerHeal: - stats.Heal += amount; - break; - case ActionType.PlayerShieldApply: - stats.Shield += amount; - break; - case ActionType.CardRepair: - stats.Repairs++; - break; - } - } - - /// - /// Gets formatted per-card combat stats for the recap screen. - /// Returns a list of lines: summary first, then player items sorted by damage, then enemy items. - /// - public static List GetCombatStatsLines(int totalDamageDealt, int totalDamageTaken) - { - var lines = new List(); - - // Summary line - lines.Add($"Combat stats. You dealt {totalDamageDealt}, took {totalDamageTaken}"); - - // Player items sorted by damage (highest first), then by triggers - if (_playerCardStats.Count > 0) - { - lines.Add($"--- Your items: {_playerCardStats.Count} ---"); - var sorted = _playerCardStats.OrderByDescending(kv => kv.Value.Damage) - .ThenByDescending(kv => kv.Value.Triggers); - foreach (var kv in sorted) - { - lines.Add(kv.Value.Format(kv.Key)); - } - } - - // Enemy items sorted by damage (highest first) - if (_enemyCardStats.Count > 0) - { - lines.Add($"--- Enemy items: {_enemyCardStats.Count} ---"); - var sorted = _enemyCardStats.OrderByDescending(kv => kv.Value.Damage) - .ThenByDescending(kv => kv.Value.Triggers); - foreach (var kv in sorted) - { - lines.Add(kv.Value.Format(kv.Key)); - } - } - - if (_playerCardStats.Count == 0 && _enemyCardStats.Count == 0) - { - lines.Add("No combat data recorded"); - } - - return lines; - } - - /// - /// Determines if a card belongs to the player using the Owner property. - /// - public static bool IsPlayerCard(Card card) - { - if (card == null) return false; - - try - { - var player = Data.Run?.Player; - if (player == null) return true; // Default to player if no run data - - // Card.Owner is set to the owning Player instance - // Player cards have Owner == Data.Run.Player - // Enemy cards have Owner == Data.Run.Opponent - // Unowned cards (encounters, etc.) have Owner == null - if (card.Owner == null) return true; // Unowned cards default to player - return card.Owner == player; - } - catch - { - return true; - } - } -} diff --git a/BazaarAccess/Gameplay/CombatDescriber.cs b/BazaarAccess/Gameplay/CombatDescriber.cs index 8a83c44..4d146dc 100644 --- a/BazaarAccess/Gameplay/CombatDescriber.cs +++ b/BazaarAccess/Gameplay/CombatDescriber.cs @@ -34,7 +34,6 @@ namespace BazaarAccess.Gameplay; /// /// Sub-components: /// - HealthTracker: Health monitoring, periodic announcements, threshold warnings -/// - CardStatsTracker: Per-card combat statistics for recap /// - EffectFormatter: Combat effect text formatting and relevance filtering /// public static class CombatDescriber @@ -139,12 +138,6 @@ public string GetTopItem() } // Backward-compatible inner class alias - /// - /// Per-card stats accumulated over the entire combat. Persists until next combat starts. - /// Delegates to CardStatsTracker.CardCombatStats. - /// - public class CardCombatStats : CardStatsTracker.CardCombatStats { } - /// /// Starts combat narration. /// @@ -166,9 +159,6 @@ public static void StartDescribing() _totalPlayerDamageDealt = 0; _totalPlayerDamageTaken = 0; - // Reset per-card stats - CardStatsTracker.Clear(); - // Get enemy name _enemyName = GetEnemyName(); @@ -308,21 +298,15 @@ internal static void OnEffectTriggered(EffectTriggeredEvent evt) if (sourceCard == null) return; // Determine owner - bool isPlayerItem = CardStatsTracker.IsPlayerCard(sourceCard); + bool isPlayerItem = IsPlayerCard(sourceCard); string itemName = ItemReader.GetCardName(sourceCard); - // Track trigger count for ALL events (so items like Water Wheel, Keychain appear in stats) - CardStatsTracker.TrackTriggerCount(itemName, isPlayerItem); - if (!EffectFormatter.IsRelevantAction(data.ActionType)) return; // Calculate details for relevant actions int amount = EffectFormatter.CalculateEffectAmount(data); bool isCrit = data.IsCrit; - // Track detailed per-card stats (damage, heal, etc.) - CardStatsTracker.TrackCardStats(itemName, isPlayerItem, data.ActionType, amount, isCrit, data); - // Dispatch to the appropriate mode handler if (UseBatchedMode) HandleBatchedEffect(itemName, isPlayerItem, data, amount, isCrit); @@ -343,21 +327,6 @@ internal static void OnPlayerHealthChanged(PlayerHealthChangedEvent evt) HealthTracker.OnPlayerHealthChanged(evt); } - /// - /// Gets formatted per-card combat stats for the recap screen. - /// Delegates to CardStatsTracker. - /// - public static List GetCombatStatsLines() - { - return CardStatsTracker.GetCombatStatsLines(_totalPlayerDamageDealt, _totalPlayerDamageTaken); - } - - /// - /// Whether there are any per-card stats available (combat has occurred). - /// Delegates to CardStatsTracker. - /// - public static bool HasCombatStats => CardStatsTracker.HasCombatStats; - #region ===== BATCHED MODE ===== // All batched mode specific code here. // Developer can modify this section without affecting individual mode. @@ -562,6 +531,23 @@ private static void HandleIndividualEffect(string itemName, bool isPlayerItem, C } } + private static bool IsPlayerCard(BazaarGameClient.Domain.Models.Cards.Card card) + { + if (card == null) return false; + + try + { + var player = Data.Run?.Player; + if (player == null) return true; + if (card.Owner == null) return true; + return card.Owner == player; + } + catch + { + return true; + } + } + #endregion #region ===== BATCHED MODE: WAVE METHODS ===== diff --git a/BazaarAccess/Gameplay/GameplayNavigator.cs b/BazaarAccess/Gameplay/GameplayNavigator.cs index 2c57260..e920f74 100644 --- a/BazaarAccess/Gameplay/GameplayNavigator.cs +++ b/BazaarAccess/Gameplay/GameplayNavigator.cs @@ -22,7 +22,7 @@ public class GameplayNavigator // Mode flags private bool _inCombat = false; private bool _inReplayMode = false; - private bool _inRecapMode = false; + private bool _wasVisualRecapOpen = false; // Sub-navigators public readonly HeroNavigator Hero; @@ -73,7 +73,7 @@ private void EnterRecapPlayerBoardInternal() public bool IsInHeroSection => _currentSection == NavigationSection.Hero; public bool IsInCombat => _inCombat; public bool IsInReplayMode => _inReplayMode; - public bool IsInRecapMode => _inRecapMode; + public bool IsInRecapMode => _inReplayMode && IsVisualRecapOpen(); // --- Hero delegates --- public HeroSubsection CurrentHeroSubsection => Hero.CurrentSubsection; @@ -105,7 +105,7 @@ private void EnterRecapPlayerBoardInternal() public void EnterOpponentBoardMode() { - bool shouldSetRecap = Enemy.EnterBoardMode(_inRecapMode); + bool shouldSetRecap = Enemy.EnterBoardMode(IsInRecapMode); if (shouldSetRecap) Recap.SetSection(RecapSection.EnemyBoard); } @@ -124,9 +124,6 @@ public void EnterOpponentBoardMode() public void RecapEnemyToStats() => Recap.EnemyToStats(); public void RecapEnemyToSkills() => Recap.EnemyToSkills(); public void EnterRecapPlayerBoardMode() => Recap.EnterPlayerBoardMode(); - public void EnterRecapCombatStatsMode() => Recap.EnterCombatStatsMode(); - public void RecapCombatStatsPrevious() => Recap.CombatStatsPrevious(); - public void RecapCombatStatsNext() => Recap.CombatStatsNext(); // --- Detail delegates --- public void ClearDetailCache() => Detail.Clear(); @@ -303,15 +300,16 @@ public void SetReplayMode(bool inReplayMode) { _inReplayMode = inReplayMode; if (inReplayMode) + { _inCombat = false; + _wasVisualRecapOpen = IsInRecapMode; + } else - _inRecapMode = false; - } - - public void SetRecapMode(bool inRecapMode) - { - _inRecapMode = inRecapMode; - if (!inRecapMode) Recap.Reset(); + { + _wasVisualRecapOpen = false; + Recap.Reset(); + Enemy.Exit(); + } } public void SetStashState(bool isOpen) => Board.SetStashState(isOpen, _currentSection); @@ -543,6 +541,37 @@ private void InitDetailLines() } } + public void SyncVisualRecapState() + { + bool isRecapOpen = IsInRecapMode; + if (isRecapOpen == _wasVisualRecapOpen) + { + return; + } + + Recap.Reset(); + Detail.Clear(); + + if (!isRecapOpen) + { + Enemy.Exit(); + } + + _wasVisualRecapOpen = isRecapOpen; + } + + private static bool IsVisualRecapOpen() + { + try + { + return Singleton.Instance?.IsRecapViewOpen == true; + } + catch + { + return false; + } + } + public void TriggerVisualSelection() { try diff --git a/BazaarAccess/Gameplay/GameplayScreen.cs b/BazaarAccess/Gameplay/GameplayScreen.cs index 19835ec..51a6eeb 100644 --- a/BazaarAccess/Gameplay/GameplayScreen.cs +++ b/BazaarAccess/Gameplay/GameplayScreen.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Generic; +using System.Reflection; using BazaarAccess.Accessibility; using BazaarAccess.Core; using BazaarAccess.Patches; @@ -36,12 +37,14 @@ public GameplayScreen() _navigator = new GameplayNavigator(); _actionMenu = new ActionMenuHandler(_navigator, HandleUpgradeConfirm, RefreshAndAnnounce); _combatHandler = new CombatInputHandler(_navigator); - _replayHandler = new ReplayInputHandler(_navigator, TriggerReplayContinue, TriggerReplayReplay, TriggerReplayRecap); + _replayHandler = new ReplayInputHandler(_navigator, TriggerReplayContinue, TriggerReplayReplay, TriggerReplayRecap, TriggerReplayRecapBack); _combatEncounterPreview = new CombatEncounterPreviewNavigator(); } public void HandleInput(AccessibleKey key) { + _navigator.SyncVisualRecapState(); + if (_combatEncounterPreview.IsActive) { _combatEncounterPreview.HandleInput(key); @@ -1231,6 +1234,7 @@ public void OnReplayStateChanged(bool inReplayState) { _combatEncounterPreview.Exit(announce: false); _navigator.SetReplayMode(inReplayState); + _navigator.SyncVisualRecapState(); if (inReplayState) { @@ -1295,6 +1299,14 @@ public void TriggerReplayContinue() var exitMethod = replayStateType.GetMethod("Exit"); if (exitMethod != null) { + // Recap hover tooltips can persist into the end-of-run victory/defeat screen unless we clear them first. + ClearAllTooltips(); + + if (_navigator.IsInRecapMode) + { + HideNativeRecapView(); + } + TolkWrapper.Speak("Continuing"); exitMethod.Invoke(currentState, null); // NO llamar a SetReplayMode(false) aquí - OnReplayStateChanged lo hará cuando el estado cambie @@ -1333,8 +1345,6 @@ public void TriggerReplayReplay() { TolkWrapper.Speak("Replaying combat"); replayMethod.Invoke(currentState, null); - // Salir del modo recap si estábamos en él (R inicia animación) - _navigator.SetRecapMode(false); } } catch (System.Exception ex) @@ -1364,10 +1374,9 @@ public void TriggerReplayRecap() var recapMethod = replayStateType.GetMethod("Recap"); if (recapMethod != null) { - TolkWrapper.Speak("Recap. V hero, F enemy, G enemy board, B your board, H combat stats."); recapMethod.Invoke(currentState, null); - // Activar modo recap - ahora G funcionará - _navigator.SetRecapMode(true); + ShowNativeRecapView(); + Plugin.Instance.StartCoroutine(WaitForRecapVisibility(true, "Recap. V hero, F enemy, G enemy board, B your board.")); } } catch (System.Exception ex) @@ -1375,4 +1384,126 @@ public void TriggerReplayRecap() Plugin.Logger.LogError($"TriggerReplayRecap error: {ex.Message}"); } } + + /// + /// Triggers the Back action while in Recap view. + /// Mirrors the native Back button by closing the recap board and returning to replay controls. + /// + public void TriggerReplayRecapBack() + { + try + { + var currentState = AppState.CurrentState; + if (currentState == null) return; + + var replayStateType = typeof(AppState).Assembly.GetType("TheBazaar.ReplayState"); + if (replayStateType == null || !replayStateType.IsInstanceOfType(currentState)) + { + _navigator.SetReplayMode(false); + return; + } + + HideNativeRecapView(); + + var recapBackMethod = replayStateType.GetMethod("RecapBack"); + recapBackMethod?.Invoke(currentState, null); + Plugin.Instance.StartCoroutine(WaitForRecapVisibility(false, "Exited recap. Enter to continue, R to replay, E to return to recap.")); + } + catch (System.Exception ex) + { + Plugin.Logger.LogError($"TriggerReplayRecapBack error: {ex.Message}"); + } + } + + private static void ShowNativeRecapView() + { + try + { + var boardManager = Singleton.Instance; + if (boardManager == null) + { + return; + } + + boardManager.ToggleOpponentPortrait(isVisible: true); + + var showRecapMethod = boardManager.GetType().GetMethod("ShowRecapView", BindingFlags.Instance | BindingFlags.NonPublic); + showRecapMethod?.Invoke(boardManager, null); + } + catch (System.Exception ex) + { + Plugin.Logger.LogError($"ShowNativeRecapView error: {ex.Message}"); + } + } + + private static void HideNativeRecapView() + { + try + { + var boardManager = Singleton.Instance; + if (boardManager == null || !boardManager.IsRecapViewOpen) + { + return; + } + + var hideRecapMethod = boardManager.GetType().GetMethod("HideRecapView", BindingFlags.Instance | BindingFlags.NonPublic); + hideRecapMethod?.Invoke(boardManager, null); + } + catch (System.Exception ex) + { + Plugin.Logger.LogError($"HideNativeRecapView error: {ex.Message}"); + } + } + + private static void ClearAllTooltips() + { + try + { + var tooltipParent = Data.TooltipParentComponent; + if (tooltipParent == null) + { + return; + } + + tooltipParent.UnlockCardTooltipController(); + tooltipParent.HideSecondaryCardTooltipController(); + tooltipParent.HideAuxiliaryTooltipController(); + tooltipParent.HideCardTooltipController(); + } + catch (System.Exception ex) + { + Plugin.Logger.LogError($"ClearAllTooltips error: {ex.Message}"); + } + } + + private IEnumerator WaitForRecapVisibility(bool expectedVisible, string message) + { + const float maxWait = 2f; + float waited = 0f; + + while (waited < maxWait && IsNativeRecapVisible() != expectedVisible) + { + yield return null; + waited += Time.unscaledDeltaTime > 0f ? Time.unscaledDeltaTime : 0.02f; + } + + _navigator.SyncVisualRecapState(); + + if (IsNativeRecapVisible() == expectedVisible && !string.IsNullOrEmpty(message)) + { + TolkWrapper.Speak(message); + } + } + + private static bool IsNativeRecapVisible() + { + try + { + return Singleton.Instance?.IsRecapViewOpen == true; + } + catch + { + return false; + } + } } diff --git a/BazaarAccess/Gameplay/Navigation/NavigationTypes.cs b/BazaarAccess/Gameplay/Navigation/NavigationTypes.cs index 6cad165..15a4fb4 100644 --- a/BazaarAccess/Gameplay/Navigation/NavigationTypes.cs +++ b/BazaarAccess/Gameplay/Navigation/NavigationTypes.cs @@ -43,8 +43,7 @@ public enum RecapSection EnemyStats, // Enemy hero stats (F) EnemySkills, // Enemy hero skills (F + Right) EnemyBoard, // Enemy board (G) - PlayerBoard, // Own board (B) - CombatStats // Per-card combat stats (H) + PlayerBoard // Own board (B) } /// diff --git a/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs b/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs index 7bf4ea7..c2428c4 100644 --- a/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs +++ b/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs @@ -9,7 +9,7 @@ namespace BazaarAccess.Gameplay.Navigation; /// /// Handles all recap mode navigation (post-combat with E key): -/// hero stats/skills, enemy stats/skills, player board, and combat stats. +/// hero stats/skills, enemy stats/skills, and player board. /// Extracted from GameplayNavigator to reduce file size. /// public class RecapNavigator @@ -21,8 +21,6 @@ public class RecapNavigator private RecapSection _currentSection = RecapSection.None; private int _enemyStatIndex = 0; private int _enemyHeroSkillIndex = 0; - private List _combatStatsLines = new List(); - private int _combatStatsIndex = 0; public RecapSection CurrentSection => _currentSection; @@ -307,64 +305,6 @@ public void EnterPlayerBoardMode() _onEnterPlayerBoard?.Invoke(); } - // =============================================== - // COMBAT STATS RECAP (H key in recap mode) - // =============================================== - - /// - /// Enter combat stats mode in recap (H key). - /// - public void EnterCombatStatsMode() - { - _combatStatsLines = CombatDescriber.GetCombatStatsLines(); - _combatStatsIndex = 0; - _currentSection = RecapSection.CombatStats; - - if (_combatStatsLines.Count > 0) - { - TolkWrapper.Speak(_combatStatsLines[0]); - } - else - { - TolkWrapper.Speak("No combat data"); - } - } - - /// - /// Navigate to previous combat stat line (Up key). - /// - public void CombatStatsPrevious() - { - if (_combatStatsLines.Count == 0) return; - - if (_combatStatsIndex <= 0) - { - _combatStatsIndex = 0; - TolkWrapper.Speak(_combatStatsLines[0]); - return; - } - - _combatStatsIndex--; - TolkWrapper.Speak(_combatStatsLines[_combatStatsIndex]); - } - - /// - /// Navigate to next combat stat line (Down key). - /// - public void CombatStatsNext() - { - if (_combatStatsLines.Count == 0) return; - - if (_combatStatsIndex >= _combatStatsLines.Count - 1) - { - TolkWrapper.Speak(_combatStatsLines[_combatStatsIndex]); - return; - } - - _combatStatsIndex++; - TolkWrapper.Speak(_combatStatsLines[_combatStatsIndex]); - } - // =============================================== // UTILITIES // =============================================== @@ -386,8 +326,6 @@ public void Reset() _currentSection = RecapSection.None; _enemyStatIndex = 0; _enemyHeroSkillIndex = 0; - _combatStatsLines.Clear(); - _combatStatsIndex = 0; } /// diff --git a/BazaarAccess/Gameplay/ReplayInputHandler.cs b/BazaarAccess/Gameplay/ReplayInputHandler.cs index a13b15c..4283f5c 100644 --- a/BazaarAccess/Gameplay/ReplayInputHandler.cs +++ b/BazaarAccess/Gameplay/ReplayInputHandler.cs @@ -6,7 +6,7 @@ namespace BazaarAccess.Gameplay; /// /// Handles replay/recap input routing. -/// Manages recap sub-mode navigation (hero stats, enemy stats, enemy board, player board, combat stats). +/// Manages recap sub-mode navigation (hero stats, enemy stats, enemy board, player board). /// public class ReplayInputHandler { @@ -14,13 +14,15 @@ public class ReplayInputHandler private readonly Action _onContinue; private readonly Action _onReplay; private readonly Action _onRecap; + private readonly Action _onRecapBack; - public ReplayInputHandler(GameplayNavigator navigator, Action onContinue, Action onReplay, Action onRecap) + public ReplayInputHandler(GameplayNavigator navigator, Action onContinue, Action onReplay, Action onRecap, Action onRecapBack) { _navigator = navigator; _onContinue = onContinue; _onReplay = onReplay; _onRecap = onRecap; + _onRecapBack = onRecapBack; } /// @@ -59,13 +61,8 @@ public void HandleRecapInput(AccessibleKey key) _navigator.AnnounceWins(); return; - case AccessibleKey.CombatSummary: // H = Combat stats per card - _navigator.EnterRecapCombatStatsMode(); - return; - case AccessibleKey.Back: // Backspace = Exit recap - _navigator.SetRecapMode(false); - TolkWrapper.Speak("Exited recap. Enter to continue, R to replay, E to return to recap."); + _onRecapBack(); return; } @@ -144,18 +141,6 @@ public void HandleRecapInput(AccessibleKey key) return; } } - else if (recapSection == RecapSection.CombatStats) - { - switch (key) - { - case AccessibleKey.Up: - _navigator.RecapCombatStatsPrevious(); - return; - case AccessibleKey.Down: - _navigator.RecapCombatStatsNext(); - return; - } - } } /// From 0ecddd307769eeacb1defb966ff9898d58fe2487 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 19 Apr 2026 10:18:20 +0800 Subject: [PATCH 08/13] Add support for item inspect (right-click on items). Press x on an item or use the context menu to access this --- BazaarAccess/Accessibility/AccessibleMenu.cs | 2 +- BazaarAccess/Gameplay/ActionMenuHandler.cs | 24 +- BazaarAccess/Gameplay/GameplayScreen.cs | 30 +- .../ItemInspect/ItemInspectNavigator.cs | 901 ++++++++++++++++++ 4 files changed, 954 insertions(+), 3 deletions(-) create mode 100644 BazaarAccess/Gameplay/ItemInspect/ItemInspectNavigator.cs diff --git a/BazaarAccess/Accessibility/AccessibleMenu.cs b/BazaarAccess/Accessibility/AccessibleMenu.cs index ef4b663..278aa8b 100644 --- a/BazaarAccess/Accessibility/AccessibleMenu.cs +++ b/BazaarAccess/Accessibility/AccessibleMenu.cs @@ -380,7 +380,7 @@ public enum AccessibleKey PrevMessage, // Comma - Previous message // Additional information Info, // I - Property/keyword info for the item - Inspect, // X - more details for current selection, currently combat encounters + Inspect, // X - inspect the current selection when the game supports an inspect-style tooltip // Upgrade Upgrade, // Shift+U - Upgrade item at pedestal // Board and Stash info diff --git a/BazaarAccess/Gameplay/ActionMenuHandler.cs b/BazaarAccess/Gameplay/ActionMenuHandler.cs index 98da203..76e8d13 100644 --- a/BazaarAccess/Gameplay/ActionMenuHandler.cs +++ b/BazaarAccess/Gameplay/ActionMenuHandler.cs @@ -17,6 +17,7 @@ namespace BazaarAccess.Gameplay; /// public enum ActionOption { + Details, Sell, Upgrade, Enchant, @@ -34,6 +35,7 @@ public class ActionMenuHandler private readonly GameplayNavigator _navigator; private readonly Action _onUpgradeConfirm; private readonly Action _onRefreshAndAnnounce; + private readonly Action _onShowDetails; // Action mode state private bool _isInActionMode = false; @@ -51,11 +53,16 @@ public class ActionMenuHandler /// public Card ActionCard => _actionCard; - public ActionMenuHandler(GameplayNavigator navigator, Action onUpgradeConfirm, Action onRefreshAndAnnounce) + public ActionMenuHandler( + GameplayNavigator navigator, + Action onUpgradeConfirm, + Action onRefreshAndAnnounce, + Action onShowDetails) { _navigator = navigator; _onUpgradeConfirm = onUpgradeConfirm; _onRefreshAndAnnounce = onRefreshAndAnnounce; + _onShowDetails = onShowDetails; } /// @@ -78,6 +85,11 @@ public void Enter(Card card) bool isInStash = _navigator.CurrentSection == NavigationSection.Stash; bool stashOpen = _navigator.IsStashOpen(); + if (card is ItemCard) + { + _actionOptions.Add(ActionOption.Details); + } + // Build available options // At pedestal, show Upgrade/Enchant first (primary action) if (currentState == ERunState.Pedestal) @@ -331,6 +343,9 @@ private string GetActionOptionText(ActionOption option) { switch (option) { + case ActionOption.Details: + return "Details"; + case ActionOption.Sell: int sellPrice = ItemReader.GetSellPrice(_actionCard); return $"Sell for {sellPrice} gold (S)"; @@ -398,6 +413,13 @@ private void ExecuteActionOption(ActionOption option) switch (option) { + case ActionOption.Details: + if (itemCard != null) + { + _onShowDetails?.Invoke(itemCard); + } + break; + case ActionOption.Sell: if (itemCard != null) { diff --git a/BazaarAccess/Gameplay/GameplayScreen.cs b/BazaarAccess/Gameplay/GameplayScreen.cs index 51a6eeb..42f044c 100644 --- a/BazaarAccess/Gameplay/GameplayScreen.cs +++ b/BazaarAccess/Gameplay/GameplayScreen.cs @@ -5,6 +5,7 @@ using BazaarAccess.Core; using BazaarAccess.Patches; using BazaarAccess.Gameplay.CombatEncounterPreview; +using BazaarAccess.Gameplay.ItemInspect; using BazaarAccess.UI; using BazaarGameClient.Domain.Models.Cards; using BazaarGameShared.Domain.Core.Types; @@ -29,22 +30,30 @@ public class GameplayScreen : IAccessibleScreen private readonly CombatInputHandler _combatHandler; private readonly ReplayInputHandler _replayHandler; private readonly CombatEncounterPreviewNavigator _combatEncounterPreview; + private readonly ItemInspectNavigator _itemInspect; private bool _isValid = true; private ERunState _lastState = ERunState.Choice; public GameplayScreen() { _navigator = new GameplayNavigator(); - _actionMenu = new ActionMenuHandler(_navigator, HandleUpgradeConfirm, RefreshAndAnnounce); + _actionMenu = new ActionMenuHandler(_navigator, HandleUpgradeConfirm, RefreshAndAnnounce, TryStartItemInspect); _combatHandler = new CombatInputHandler(_navigator); _replayHandler = new ReplayInputHandler(_navigator, TriggerReplayContinue, TriggerReplayReplay, TriggerReplayRecap, TriggerReplayRecapBack); _combatEncounterPreview = new CombatEncounterPreviewNavigator(); + _itemInspect = new ItemInspectNavigator(); } public void HandleInput(AccessibleKey key) { _navigator.SyncVisualRecapState(); + if (_itemInspect.IsActive) + { + _itemInspect.HandleInput(key); + return; + } + if (_combatEncounterPreview.IsActive) { _combatEncounterPreview.HandleInput(key); @@ -403,6 +412,7 @@ public string GetHelp() public void OnFocus() { + _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); _lastState = StateChangePatch.GetCurrentRunState(); _navigator.Refresh(); @@ -426,6 +436,7 @@ public void OnFocus() /// True si el estado realmente cambió (calculado por StateChangePatch) public void OnStateChanged(ERunState newState, bool stateActuallyChanged = true) { + _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); _lastState = newState; @@ -602,6 +613,12 @@ private void HandleConfirm() private void HandleInspect() { var card = _navigator.GetCurrentCard(); + if (card is ItemCard) + { + TryStartItemInspect(card); + return; + } + if (card?.Type != ECardType.CombatEncounter) { TolkWrapper.Speak("Nothing to inspect"); @@ -615,6 +632,15 @@ private void HandleInspect() } } + private void TryStartItemInspect(Card card) + { + if (!_itemInspect.TryEnter(card)) + return; + + Plugin.Instance.StartCoroutine(_itemInspect.ShowVisualPreview()); + Plugin.Instance.StartCoroutine(_itemInspect.MonitorVisualState()); + } + private void HandleCardConfirm(Card card) { switch (card.Type) @@ -1167,6 +1193,7 @@ private System.Collections.IEnumerator DelayedRefreshAfterUpgrade() /// public void OnCombatStateChanged(bool inCombat) { + _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); _navigator.SetCombatMode(inCombat); @@ -1232,6 +1259,7 @@ private System.Collections.IEnumerator DelayedStashAnnounce() /// public void OnReplayStateChanged(bool inReplayState) { + _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); _navigator.SetReplayMode(inReplayState); _navigator.SyncVisualRecapState(); diff --git a/BazaarAccess/Gameplay/ItemInspect/ItemInspectNavigator.cs b/BazaarAccess/Gameplay/ItemInspect/ItemInspectNavigator.cs new file mode 100644 index 0000000..77c3481 --- /dev/null +++ b/BazaarAccess/Gameplay/ItemInspect/ItemInspectNavigator.cs @@ -0,0 +1,901 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using BazaarAccess.Accessibility; +using BazaarAccess.Core; +using BazaarAccess.Gameplay.Navigation; +using BazaarGameClient.Domain.Models.Cards; +using TMPro; +using TheBazaar; +using TheBazaar.Tooltips; +using TheBazaar.UI.Tooltips; +using UnityEngine; + +namespace BazaarAccess.Gameplay.ItemInspect; + +internal sealed class ItemInspectNavigator +{ + private enum ItemInspectSection + { + Legend, + Tier, + Enchantment, + Stats, + Reset, + Exit + } + + private readonly DetailReader _detailReader = new DetailReader(); + + private ItemCard _sourceItemCard; + private CardTooltipController _visualTooltipController; + private object _tooltipUnlockEvent; + private bool _isPending; + private bool _isActive; + private bool _tooltipUnlockObserved; + private bool _announceOnClose; + private ItemInspectSection _currentSection = ItemInspectSection.Stats; + + public bool IsActive => _isActive; + + public bool TryEnter(Card card) + { + if (_isActive || _isPending || card is not ItemCard itemCard) + return false; + + _sourceItemCard = itemCard; + _visualTooltipController = null; + _tooltipUnlockEvent = null; + _tooltipUnlockObserved = false; + _announceOnClose = false; + _isPending = true; + _isActive = false; + _currentSection = ItemInspectSection.Stats; + _detailReader.Clear(); + return true; + } + + public void Exit(bool announce = false) + { + if (!_isActive && !_isPending) + return; + + UnsubscribeTooltipEvents(); + SetItemCursorState(isOverCard: false); + UnlockVisualPreviewIfOwned(); + + _sourceItemCard = null; + _visualTooltipController = null; + _tooltipUnlockEvent = null; + _tooltipUnlockObserved = false; + _announceOnClose = false; + _isPending = false; + _isActive = false; + _currentSection = ItemInspectSection.Stats; + _detailReader.Clear(); + + if (announce) + TolkWrapper.Speak("Exited item inspect"); + } + + public IEnumerator ShowVisualPreview() + { + if (_sourceItemCard == null) + { + Exit(); + yield break; + } + + var boardManager = BoardStashNavigator.GetBoardManager(); + if (boardManager == null) + { + Exit(); + yield break; + } + + CardController controller = VisualSelector.FindCardController(_sourceItemCard, boardManager); + if (controller == null) + { + Exit(); + yield break; + } + + SetCursorOverCard(controller, true); + SubscribeTooltipEvents(); + + if (!TryShowItemTooltip(controller)) + { + Exit(); + yield break; + } + + float waited = 0f; + const float step = 0.05f; + const float maxWait = 2f; + float lastShowRetry = 0f; + bool lockRequested = false; + + while (waited < maxWait) + { + if (waited - lastShowRetry >= 0.25f) + { + TryShowItemTooltip(controller); + lastShowRetry = waited; + } + + var tooltipController = GetTooltipController(); + if (!lockRequested && + tooltipController != null && + tooltipController.CurrentTooltipData is CardTooltipData tooltipData && + tooltipData.CardInstance == _sourceItemCard && + tooltipController.HasShown) + { + _visualTooltipController = tooltipController; + tooltipController.LockTooltipToggle(); + lockRequested = true; + } + + if (IsVisualActive()) + { + _isPending = false; + _isActive = true; + _currentSection = GetDefaultSection(); + _detailReader.Clear(); + TolkWrapper.Speak("Item inspect"); + ReadCurrentSection(); + yield break; + } + + waited += step; + yield return new WaitForSeconds(step); + } + + Exit(); + } + + public IEnumerator MonitorVisualState() + { + while (_isPending || _isActive) + { + if (_tooltipUnlockObserved) + { + Exit(_announceOnClose); + yield break; + } + + if (_isActive && !IsVisualActive()) + { + Exit(_announceOnClose); + yield break; + } + + yield return new WaitForSeconds(0.1f); + } + } + + public bool IsVisualActive() + { + if (_sourceItemCard == null) + return false; + + CardTooltipController tooltipController = GetTooltipController(); + if (tooltipController == null || Data.TooltipParentComponent == null) + return false; + + if (!Data.TooltipParentComponent.CardTooltipControllerAreCardsEqual(_sourceItemCard, tooltipController)) + return false; + + if (!IsTooltipLocked(tooltipController)) + return false; + + return IsLockVariantPanelVisible(tooltipController); + } + + public void HandleInput(AccessibleKey key) + { + if (!_isActive) + return; + + if (key == AccessibleKey.Back) + { + RequestClose(); + return; + } + + EnsureCurrentSectionVisible(); + + switch (key) + { + case AccessibleKey.Left: + Navigate(-1); + return; + + case AccessibleKey.Right: + Navigate(1); + return; + + case AccessibleKey.Up: + HandleAdjust(-1); + return; + + case AccessibleKey.Down: + HandleAdjust(1); + return; + + case AccessibleKey.Home: + HandleJumpToBoundary(first: true); + return; + + case AccessibleKey.End: + HandleJumpToBoundary(first: false); + return; + + case AccessibleKey.Confirm: + HandleConfirm(); + return; + } + } + + private void Navigate(int delta) + { + List visibleSections = GetVisibleSections(); + if (visibleSections.Count == 0) + return; + + int currentIndex = visibleSections.IndexOf(_currentSection); + if (currentIndex < 0) + { + _currentSection = visibleSections[0]; + ReadCurrentSection(); + return; + } + + int nextIndex = currentIndex + delta; + if (nextIndex < 0) + { + TolkWrapper.Speak("Start of list"); + return; + } + + if (nextIndex >= visibleSections.Count) + { + TolkWrapper.Speak("End of list"); + return; + } + + _currentSection = visibleSections[nextIndex]; + _detailReader.Clear(); + ReadCurrentSection(); + } + + private void HandleAdjust(int direction) + { + switch (_currentSection) + { + case ItemInspectSection.Legend: + SpeakLegend(); + return; + + case ItemInspectSection.Tier: + AdjustDropdown(GetTierDropdown(), direction); + return; + + case ItemInspectSection.Enchantment: + AdjustDropdown(GetEnchantmentDropdown(), direction); + return; + + case ItemInspectSection.Stats: + SpeakStatsDetail(up: direction < 0); + return; + } + } + + private void HandleConfirm() + { + switch (_currentSection) + { + case ItemInspectSection.Legend: + SpeakLegend(); + return; + + case ItemInspectSection.Tier: + case ItemInspectSection.Enchantment: + ReadCurrentSection(); + return; + + case ItemInspectSection.Stats: + { + Card previewCard = GetPreviewCard(); + if (previewCard != null) + TolkWrapper.Speak(ItemReader.GetDetailedDescription(previewCard)); + return; + } + + case ItemInspectSection.Reset: + ClickResetButton(); + return; + + case ItemInspectSection.Exit: + RequestClose(); + return; + } + } + + private void HandleJumpToBoundary(bool first) + { + switch (_currentSection) + { + case ItemInspectSection.Tier: + SetDropdownToBoundary(GetTierDropdown(), first); + return; + + case ItemInspectSection.Enchantment: + SetDropdownToBoundary(GetEnchantmentDropdown(), first); + return; + } + } + + private void ReadCurrentSection() + { + string speech = GetCurrentSectionSpeech(); + if (!string.IsNullOrWhiteSpace(speech)) + TolkWrapper.Speak(speech); + } + + private string GetCurrentSectionSpeech() + { + EnsureCurrentSectionVisible(); + + switch (_currentSection) + { + case ItemInspectSection.Legend: + return GetLegendSpeech(); + + case ItemInspectSection.Tier: + return GetComboBoxSpeech("Tier", GetTierDropdown()); + + case ItemInspectSection.Enchantment: + return GetComboBoxSpeech("Enchantment", GetEnchantmentDropdown()); + + case ItemInspectSection.Stats: + { + _detailReader.Clear(); + Card previewCard = GetPreviewCard(); + if (previewCard == null) + { + return "No item"; + } + + return ItemReader.GetShortDescription(previewCard); + } + + case ItemInspectSection.Reset: + return "Reset"; + + case ItemInspectSection.Exit: + return "Exit"; + } + + return string.Empty; + } + + private void SpeakLegend() + { + string speech = GetLegendSpeech(); + if (!string.IsNullOrWhiteSpace(speech)) + TolkWrapper.Speak(speech); + } + + private string GetLegendSpeech() + { + string legendText = GetLegendText(); + return string.IsNullOrWhiteSpace(legendText) + ? string.Empty + : $"Legend. {legendText}"; + } + + private void SpeakStatsDetail(bool up) + { + Card previewCard = GetPreviewCard(); + if (previewCard == null) + { + TolkWrapper.Speak("No item"); + return; + } + + _detailReader.Init(previewCard, ItemReader.GetDetailLines); + if (!_detailReader.HasLines) + { + TolkWrapper.Speak("No details"); + return; + } + + string line = up ? _detailReader.LineUp() : _detailReader.LineDown(); + TolkWrapper.Speak(line ?? "No details"); + } + + private void AdjustDropdown(TMP_Dropdown dropdown, int direction) + { + if (dropdown == null) + return; + + int optionCount = dropdown.options?.Count ?? 0; + int newIndex = dropdown.value + direction; + if (newIndex < 0 || newIndex >= optionCount) + return; + + _detailReader.Clear(); + dropdown.value = newIndex; + + string value = GetDropdownCurrentText(dropdown); + if (!string.IsNullOrEmpty(value)) + TolkWrapper.Speak(value); + } + + private static void SetDropdownToBoundary(TMP_Dropdown dropdown, bool first) + { + if (dropdown == null) + return; + + int optionCount = dropdown.options?.Count ?? 0; + if (optionCount <= 0) + return; + + int targetIndex = first ? 0 : optionCount - 1; + if (dropdown.value == targetIndex) + return; + + dropdown.value = targetIndex; + + string value = GetDropdownCurrentText(dropdown); + if (!string.IsNullOrEmpty(value)) + TolkWrapper.Speak(value); + } + + private void ClickResetButton() + { + ButtonCustom button = GetResetButton(); + if (button == null || !button.gameObject.activeInHierarchy) + return; + + _detailReader.Clear(); + button.OnMouseClickCustom(); + TolkWrapper.Speak("Reset"); + } + + private void RequestClose() + { + if (!_isActive) + return; + + _announceOnClose = true; + _detailReader.Clear(); + + ButtonCustom button = GetExitButton(); + if (button != null && button.gameObject.activeInHierarchy) + { + button.OnMouseClickCustom(); + return; + } + + CardTooltipController tooltipController = GetTooltipController(); + tooltipController?.LockTooltipToggle(); + } + + private void EnsureCurrentSectionVisible() + { + List visibleSections = GetVisibleSections(); + if (visibleSections.Count > 0 && !visibleSections.Contains(_currentSection)) + _currentSection = visibleSections[0]; + } + + private ItemInspectSection GetDefaultSection() + { + List visibleSections = GetVisibleSections(); + return visibleSections.Count > 0 ? visibleSections[0] : ItemInspectSection.Stats; + } + + private List GetVisibleSections() + { + var sections = new List(); + + if (HasLegend()) + sections.Add(ItemInspectSection.Legend); + if (IsTierVisible()) + sections.Add(ItemInspectSection.Tier); + if (IsEnchantmentVisible()) + sections.Add(ItemInspectSection.Enchantment); + + sections.Add(ItemInspectSection.Stats); + + if (IsResetVisible()) + sections.Add(ItemInspectSection.Reset); + if (IsExitVisible()) + sections.Add(ItemInspectSection.Exit); + + return sections; + } + + private bool HasLegend() + { + CardTooltipController tooltipController = GetTooltipController(); + return tooltipController?.LegendTooltipComponent != null && + tooltipController.LegendTooltipComponent.HasText; + } + + private string GetLegendText() + { + CardTooltipController tooltipController = GetTooltipController(); + LegendTooltipComponent legend = tooltipController?.LegendTooltipComponent; + if (legend == null || !legend.HasText) + return string.Empty; + + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo rowsField = typeof(LegendTooltipComponent).GetField("_spawnedLegendRows", flags); + if (rowsField?.GetValue(legend) is not System.Collections.IEnumerable rows) + return string.Empty; + + var parts = new List(); + foreach (object rowObj in rows) + { + if (rowObj is not LegendTooltipRowComponent row || !row.IsVisible) + continue; + + string title = TextHelper.CleanText(row.TitleText?.text ?? string.Empty); + string description = TextHelper.CleanText(row.DescriptionText?.text ?? string.Empty); + if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(description)) + parts.Add($"{title}: {description}"); + else if (!string.IsNullOrWhiteSpace(description)) + parts.Add(description); + else if (!string.IsNullOrWhiteSpace(title)) + parts.Add(title); + } + + return string.Join(". ", parts); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"GetLegendText error: {ex.Message}"); + return string.Empty; + } + } + + private bool IsTierVisible() + => GetTierGroupObject()?.activeInHierarchy == true; + + private bool IsEnchantmentVisible() + => GetEnchantmentGroupObject()?.activeInHierarchy == true; + + private bool IsResetVisible() + => GetResetButton()?.gameObject.activeInHierarchy == true; + + private bool IsExitVisible() + => GetExitButton()?.gameObject.activeInHierarchy == true; + + private Card GetPreviewCard() + { + CardTooltipController tooltipController = GetTooltipController(); + if (tooltipController?.CurrentTooltipData is CardTooltipData tooltipData) + return tooltipData.CardInstance; + + return _sourceItemCard; + } + + private CardTooltipController GetTooltipController() + { + if (_visualTooltipController != null) + return _visualTooltipController; + + if (_sourceItemCard == null || Data.TooltipParentComponent == null) + return null; + + return Data.TooltipParentComponent.GetCardTooltipController(_sourceItemCard); + } + + private bool TryShowItemTooltip(CardController controller) + { + try + { + if (controller == null || Data.TooltipParentComponent == null) + return false; + + ITooltipData tooltipData = controller.GetTooltipData(); + if (tooltipData == null) + return false; + + Data.TooltipParentComponent.ShowCardTooltipController( + controller.transform, + GetTooltipOffset(controller), + tooltipData); + + return true; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"TryShowItemTooltip error: {ex.Message}"); + return false; + } + } + + private void UnlockVisualPreviewIfOwned() + { + try + { + CardTooltipController tooltipController = GetTooltipController(); + if (tooltipController != null && + Data.TooltipParentComponent != null && + Data.TooltipParentComponent.CardTooltipControllerAreCardsEqual(_sourceItemCard, tooltipController)) + { + if (IsTooltipLocked(tooltipController)) + tooltipController.Unlock(); + else + tooltipController.StartTooltipFadeOut(); + } + + Data.TooltipParentComponent?.HideSecondaryCardTooltipController(); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"UnlockVisualPreviewIfOwned error: {ex.Message}"); + } + } + + private bool IsLockVariantPanelVisible(CardTooltipController tooltipController) + { + CardLockVariantPanel panel = GetLockVariantPanel(tooltipController); + if (panel == null) + return false; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo panelRootField = typeof(CardLockVariantPanel).GetField("_panelRoot", flags); + return panelRootField?.GetValue(panel) is RectTransform panelRoot && + panelRoot.gameObject.activeInHierarchy; + } + + private CardLockVariantPanel GetLockVariantPanel(CardTooltipController tooltipController) + { + if (tooltipController?.LockModeContainer == null) + return null; + + return tooltipController.LockModeContainer.GetComponent(); + } + + private GameObject GetTierGroupObject() + { + CardLockVariantPanel panel = GetLockVariantPanel(GetTooltipController()); + if (panel == null) + return null; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo field = typeof(CardLockVariantPanel).GetField("_tierGroupObject", flags); + return field?.GetValue(panel) as GameObject; + } + + private GameObject GetEnchantmentGroupObject() + { + CardLockVariantPanel panel = GetLockVariantPanel(GetTooltipController()); + if (panel == null) + return null; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo field = typeof(CardLockVariantPanel).GetField("_enchantmentGroupObject", flags); + return field?.GetValue(panel) as GameObject; + } + + private TMP_Dropdown GetTierDropdown() + { + CardLockVariantPanel panel = GetLockVariantPanel(GetTooltipController()); + if (panel == null) + return null; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo field = typeof(CardLockVariantPanel).GetField("_tierDropdown", flags); + return field?.GetValue(panel) as TMP_Dropdown; + } + + private TMP_Dropdown GetEnchantmentDropdown() + { + CardLockVariantPanel panel = GetLockVariantPanel(GetTooltipController()); + if (panel == null) + return null; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo field = typeof(CardLockVariantPanel).GetField("_enchantmentDropdown", flags); + return field?.GetValue(panel) as TMP_Dropdown; + } + + private ButtonCustom GetResetButton() + { + CardLockVariantPanel panel = GetLockVariantPanel(GetTooltipController()); + if (panel == null) + return null; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo field = typeof(CardLockVariantPanel).GetField("_resetButton", flags); + return field?.GetValue(panel) as ButtonCustom; + } + + private ButtonCustom GetExitButton() + { + CardTooltipController tooltipController = GetTooltipController(); + if (tooltipController == null) + return null; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + FieldInfo field = typeof(CardTooltipController).GetField("lockModeExitButton", flags); + return field?.GetValue(tooltipController) as ButtonCustom; + } + + private static string GetDropdownCurrentText(TMP_Dropdown dropdown) + { + if (dropdown == null) + return string.Empty; + + string caption = TextHelper.CleanText(dropdown.captionText?.text ?? string.Empty); + if (!string.IsNullOrEmpty(caption)) + return caption; + + int index = dropdown.value; + if (dropdown.options != null && index >= 0 && index < dropdown.options.Count) + return TextHelper.CleanText(dropdown.options[index]?.text ?? string.Empty); + + return string.Empty; + } + + private static string GetComboBoxSpeech(string label, TMP_Dropdown dropdown) + { + string value = GetDropdownCurrentText(dropdown); + return $"{label} combo box: {value}. Use home, end, up or down arrows to change."; + } + + private void SubscribeTooltipEvents() + { + try + { + object tooltipUnlockEvent = GetTooltipUnlockEvent(); + if (tooltipUnlockEvent == null) + return; + + _tooltipUnlockEvent = tooltipUnlockEvent; + + MethodInfo removeListener = tooltipUnlockEvent.GetType().GetMethod( + "RemoveListener", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + null, + new[] { typeof(Action) }, + null); + + MethodInfo addListener = tooltipUnlockEvent.GetType().GetMethod( + "AddListener", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + null, + new[] { typeof(Action), typeof(MonoBehaviour) }, + null); + + removeListener?.Invoke(tooltipUnlockEvent, new object[] { (Action)OnTooltipUnlock }); + addListener?.Invoke(tooltipUnlockEvent, new object[] { (Action)OnTooltipUnlock, null }); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"ItemInspect SubscribeTooltipEvents error: {ex.Message}"); + } + } + + private void UnsubscribeTooltipEvents() + { + try + { + object tooltipUnlockEvent = _tooltipUnlockEvent ?? GetTooltipUnlockEvent(); + if (tooltipUnlockEvent == null) + return; + + MethodInfo removeListener = tooltipUnlockEvent.GetType().GetMethod( + "RemoveListener", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + null, + new[] { typeof(Action) }, + null); + + removeListener?.Invoke(tooltipUnlockEvent, new object[] { (Action)OnTooltipUnlock }); + _tooltipUnlockEvent = null; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"ItemInspect UnsubscribeTooltipEvents error: {ex.Message}"); + } + } + + private void OnTooltipUnlock() + { + if (_isPending || _isActive) + _tooltipUnlockObserved = true; + } + + private static object GetTooltipUnlockEvent() + { + const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public; + Type eventsType = typeof(Data).Assembly.GetType("TheBazaar.Events"); + FieldInfo field = eventsType?.GetField("TooltipUnlock", flags); + return field?.GetValue(null); + } + + private void SetItemCursorState(bool isOverCard) + { + try + { + BoardManager boardManager = BoardStashNavigator.GetBoardManager(); + if (boardManager == null || _sourceItemCard == null) + return; + + CardController controller = VisualSelector.FindCardController(_sourceItemCard, boardManager); + if (controller != null) + SetCursorOverCard(controller, isOverCard); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"SetItemCursorState error: {ex.Message}"); + } + } + + private static void SetCursorOverCard(CardController controller, bool isOverCard) + { + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + PropertyInfo property = typeof(CardController).GetProperty("IsCursorOverCard", flags); + MethodInfo setter = property?.GetSetMethod(nonPublic: true); + setter?.Invoke(controller, new object[] { isOverCard }); + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"ItemInspect SetCursorOverCard error: {ex.Message}"); + } + } + + private static Vector3 GetTooltipOffset(CardController controller) + { + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + PropertyInfo property = typeof(CardController).GetProperty("TooltipOffset", flags); + if (property?.GetValue(controller) is Vector3 offset) + return offset; + + FieldInfo field = typeof(CardController).GetField("tooltipOffset", flags); + if (field?.GetValue(controller) is Vector3 fieldOffset) + return fieldOffset; + } + catch + { + // Fall through to zero offset fallback. + } + + return Vector3.zero; + } + + private static bool IsTooltipLocked(CardTooltipController tooltipController) + { + try + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + PropertyInfo property = typeof(CardTooltipController).GetProperty("IsLocked", flags); + if (property?.GetValue(tooltipController) is bool propertyValue) + return propertyValue; + + FieldInfo field = typeof(BaseTooltipController).GetField("isLocked", flags); + if (field?.GetValue(tooltipController) is bool fieldValue) + return fieldValue; + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"ItemInspect IsTooltipLocked reflection error: {ex.Message}"); + } + + return Data.TooltipParentComponent != null && + Data.TooltipParentComponent.IsCardTooltipControllerLocked(tooltipController); + } +} From 46b942a07287a58c9d92be4cb1016ed370c49226 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 19 Apr 2026 10:29:12 +0800 Subject: [PATCH 09/13] Update the buy, sell, enchantment and upgrade confirmation menus to use up / down instead of left / right to cycle between options for consistency with all other screens --- BazaarAccess/UI/ConfirmActionUI.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BazaarAccess/UI/ConfirmActionUI.cs b/BazaarAccess/UI/ConfirmActionUI.cs index 7d2ed37..48fb3a0 100644 --- a/BazaarAccess/UI/ConfirmActionUI.cs +++ b/BazaarAccess/UI/ConfirmActionUI.cs @@ -62,8 +62,8 @@ public void HandleInput(AccessibleKey key) { switch (key) { - case AccessibleKey.Left: - case AccessibleKey.Right: + case AccessibleKey.Up: + case AccessibleKey.Down: // Cambiar entre Confirm y Cancel _selectedOption = (_selectedOption + 1) % 2; AnnounceCurrentOption(); @@ -101,7 +101,7 @@ public void HandleInput(AccessibleKey key) public string GetHelp() { - return "Left/Right: switch option. Enter: confirm. Escape: cancel."; + return "Up/Down: switch option. Enter: confirm. Escape: cancel."; } public void OnFocus() From 97a1205b55601adee9afdcc4ff4bbe7633d01932 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 19 Apr 2026 11:28:06 +0800 Subject: [PATCH 10/13] Remove upgrade and enchantment previews which didnt work reliably in favour of the item inspect panel. Remove separate confirm action for upgrades and enchants --- BazaarAccess/Gameplay/ActionMenuHandler.cs | 11 +- BazaarAccess/Gameplay/GameplayScreen.cs | 164 +------------- BazaarAccess/Gameplay/PedestalManager.cs | 252 --------------------- BazaarAccess/UI/ConfirmActionUI.cs | 14 +- 4 files changed, 14 insertions(+), 427 deletions(-) diff --git a/BazaarAccess/Gameplay/ActionMenuHandler.cs b/BazaarAccess/Gameplay/ActionMenuHandler.cs index 76e8d13..f311812 100644 --- a/BazaarAccess/Gameplay/ActionMenuHandler.cs +++ b/BazaarAccess/Gameplay/ActionMenuHandler.cs @@ -33,7 +33,7 @@ public enum ActionOption public class ActionMenuHandler { private readonly GameplayNavigator _navigator; - private readonly Action _onUpgradeConfirm; + private readonly Action _onUsePedestalAction; private readonly Action _onRefreshAndAnnounce; private readonly Action _onShowDetails; @@ -55,12 +55,12 @@ public class ActionMenuHandler public ActionMenuHandler( GameplayNavigator navigator, - Action onUpgradeConfirm, + Action onUsePedestalAction, Action onRefreshAndAnnounce, Action onShowDetails) { _navigator = navigator; - _onUpgradeConfirm = onUpgradeConfirm; + _onUsePedestalAction = onUsePedestalAction; _onRefreshAndAnnounce = onRefreshAndAnnounce; _onShowDetails = onShowDetails; } @@ -432,17 +432,16 @@ private void ExecuteActionOption(ActionOption option) break; case ActionOption.Upgrade: - // Show confirmation dialog with preview instead of executing directly if (itemCard != null) { - _onUpgradeConfirm(itemCard, false); + _onUsePedestalAction?.Invoke(itemCard); } break; case ActionOption.Enchant: if (itemCard != null) { - _onUpgradeConfirm(itemCard, true); + _onUsePedestalAction?.Invoke(itemCard); } break; diff --git a/BazaarAccess/Gameplay/GameplayScreen.cs b/BazaarAccess/Gameplay/GameplayScreen.cs index 42f044c..866c721 100644 --- a/BazaarAccess/Gameplay/GameplayScreen.cs +++ b/BazaarAccess/Gameplay/GameplayScreen.cs @@ -37,7 +37,7 @@ public class GameplayScreen : IAccessibleScreen public GameplayScreen() { _navigator = new GameplayNavigator(); - _actionMenu = new ActionMenuHandler(_navigator, HandleUpgradeConfirm, RefreshAndAnnounce, TryStartItemInspect); + _actionMenu = new ActionMenuHandler(_navigator, HandlePedestalAction, RefreshAndAnnounce, TryStartItemInspect); _combatHandler = new CombatInputHandler(_navigator); _replayHandler = new ReplayInputHandler(_navigator, TriggerReplayContinue, TriggerReplayReplay, TriggerReplayRecap, TriggerReplayRecapBack); _combatEncounterPreview = new CombatEncounterPreviewNavigator(); @@ -341,30 +341,10 @@ private void HandleUpgrade() return; } - // Route through confirmation dialog with explicit pedestal type detection var currentState = StateChangePatch.GetCurrentRunState(); if (currentState == ERunState.Pedestal) { - var pedestalInfo = PedestalManager.GetCurrentPedestalInfo(); - Plugin.Logger.LogInfo($"HandleUpgrade: detected pedestal type={pedestalInfo.Type}"); - if (pedestalInfo.Type == PedestalManager.PedestalType.Enchant || - pedestalInfo.Type == PedestalManager.PedestalType.EnchantRandom) - { - HandleUpgradeConfirm(card, isEnchant: true); - } - else if (pedestalInfo.Type == PedestalManager.PedestalType.Upgrade) - { - HandleUpgradeConfirm(card, isEnchant: false); - } - else - { - // Detection failed - use the pedestal directly (game handles the logic) - Plugin.Logger.LogWarning("HandleUpgrade: detection failed, using pedestal directly"); - if (PedestalManager.UseCurrentPedestal(card)) - { - Plugin.Instance.StartCoroutine(DelayedRefreshAndAnnounce()); - } - } + HandlePedestalAction(card); } else { @@ -749,15 +729,6 @@ private void HandleSellConfirm(Card card) var itemCard = card as ItemCard; if (itemCard == null) { TolkWrapper.Speak("Cannot sell this"); return; } - // Check if we're in Pedestal state - offer pedestal action instead of sell - var currentState = StateChangePatch.GetCurrentRunState(); - if (currentState == ERunState.Pedestal && PedestalManager.CanUpgrade()) - { - bool isEnchant = PedestalManager.IsEnchantPedestal(); - HandleUpgradeConfirm(card, isEnchant: isEnchant); - return; - } - if (!_navigator.CanSellInCurrentState()) { TolkWrapper.Speak("Cannot sell right now"); @@ -776,136 +747,17 @@ private void HandleSellConfirm(Card card) AccessibilityMgr.ShowUI(ui); } - /// - /// Shows upgrade confirmation dialog with tier information. - /// - /// The card to upgrade/enchant - /// If known: true=enchant, false=upgrade. Null=auto-detect from pedestal. - private void HandleUpgradeConfirm(Card card, bool? isEnchant = null) + private void HandlePedestalAction(Card card) { - Plugin.Logger.LogInfo($"HandleUpgradeConfirm called for card: {card?.GetType().Name ?? "null"}, isEnchant={isEnchant}"); - - string name = ItemReader.GetCardName(card); - - // Get pedestal info - var pedestalInfo = PedestalManager.GetCurrentPedestalInfo(); - Plugin.Logger.LogInfo($"HandleUpgradeConfirm: pedestalInfo.Type={pedestalInfo.Type}, name={name}"); - - // Determine if this is an enchant action - bool doEnchant; - if (isEnchant.HasValue) - { - doEnchant = isEnchant.Value; - } - else + if (card == null) { - doEnchant = pedestalInfo.Type == PedestalManager.PedestalType.Enchant || - pedestalInfo.Type == PedestalManager.PedestalType.EnchantRandom; + TolkWrapper.Speak("No item selected"); + return; } - if (doEnchant) - { - // Enchantment altar - // Check if already enchanted - if (card is ItemCard itemCard && itemCard.Enchantment.HasValue) - { - string currentEnchant = ItemReader.GetEnchantmentName(itemCard.Enchantment.Value); - TolkWrapper.Speak($"{name} is already enchanted with {currentEnchant}"); - return; - } - - string enchantName = pedestalInfo.EnchantmentName ?? "random"; - - // Build message with preview - var messageParts = new List(); - if (pedestalInfo.Type == PedestalManager.PedestalType.EnchantRandom) - { - messageParts.Add($"Enchant {name} with a random enchantment?"); - } - else - { - messageParts.Add($"Enchant {name} with {enchantName}."); - // Get enchantment preview - var preview = PedestalManager.GetEnchantPreview(card, enchantName); - if (preview.Count > 0) - { - messageParts.Add("Effects: " + string.Join(", ", preview)); - } - } - messageParts.Add("Press U to confirm, Backspace to cancel."); - - string message = string.Join(" ", messageParts); - - var ui = new ConfirmActionUI(ConfirmActionType.Upgrade, name, 0, message, - onConfirm: () => { - if (PedestalManager.UseCurrentPedestal(card)) - { - Plugin.Instance.StartCoroutine(DelayedRefreshAndAnnounce()); - } - }, - onCancel: () => TolkWrapper.Speak("Cancelled")); - AccessibilityMgr.ShowUI(ui); - } - else + if (PedestalManager.UseCurrentPedestal(card)) { - // Upgrade altar - string currentTier = ItemReader.GetTierName(card); - var upgradeInfo = PedestalManager.GetCurrentPedestalInfo(); - - // Check if already at max tier - if (card.Tier == ETier.Legendary) - { - TolkWrapper.Speak($"{name} is already at {currentTier}, cannot upgrade further"); - return; - } - - // Build message with preview - var messageParts = new List(); - - if (upgradeInfo.TargetTier.HasValue) - { - if (upgradeInfo.TargetTier.Value == card.Tier) - { - messageParts.Add($"Upgrade {name} stats. Stays {currentTier}."); - } - else - { - messageParts.Add($"Upgrade {name} from {currentTier} to {ItemReader.GetTierName(upgradeInfo.TargetTier.Value)}."); - } - } - else - { - string nextTier = TierHelper.GetNextName(card.Tier); - messageParts.Add($"Upgrade {name} from {currentTier} to {nextTier}."); - } - - // Get post-upgrade stats - try - { - var preview = PedestalManager.GetUpgradePreview(card); - if (preview.Count > 0) - { - messageParts.Add("After upgrade: " + string.Join(", ", preview)); - } - } - catch (System.Exception ex) - { - Plugin.Logger.LogWarning($"GetUpgradePreview failed: {ex.Message}"); - } - - messageParts.Add("Press U to confirm, Backspace to cancel."); - - string message = string.Join(" ", messageParts); - - var ui = new ConfirmActionUI(ConfirmActionType.Upgrade, name, 0, message, - onConfirm: () => { - if (PedestalManager.UpgradeItem(card)) - { - Plugin.Instance.StartCoroutine(DelayedRefreshAndAnnounce()); - } - }, - onCancel: () => TolkWrapper.Speak("Cancelled")); - AccessibilityMgr.ShowUI(ui); + Plugin.Instance.StartCoroutine(DelayedRefreshAndAnnounce()); } } diff --git a/BazaarAccess/Gameplay/PedestalManager.cs b/BazaarAccess/Gameplay/PedestalManager.cs index 6a1193a..d6f4070 100644 --- a/BazaarAccess/Gameplay/PedestalManager.cs +++ b/BazaarAccess/Gameplay/PedestalManager.cs @@ -605,258 +605,6 @@ public static string GetPedestalActionDescription(Card card) } } - /// - /// Gets a preview of post-upgrade stats for the target tier. - /// Shows full stats the item will have after upgrading. - /// - public static List GetUpgradePreview(Card card) - { - var stats = new List(); - if (card == null) return stats; - - try - { - var pedestalInfo = GetCurrentPedestalInfo(); - ETier targetTier = pedestalInfo.TargetTier ?? TierHelper.GetNextTier(card.Tier); - - if (targetTier == card.Tier) - { - stats.Add("Stats will improve (same tier)"); - return stats; - } - - // Get the card template to access tier data - var templateProp = card.GetType().GetProperty("Template"); - if (templateProp == null) return stats; - - var template = templateProp.GetValue(card); - if (template == null) return stats; - - // Try GetAttributeBaseValueAtTier method first - var getAttrMethod = template.GetType().GetMethod("GetAttributeBaseValueAtTier", - BindingFlags.Public | BindingFlags.Instance); - - if (getAttrMethod == null) - { - foreach (var iface in template.GetType().GetInterfaces()) - { - getAttrMethod = iface.GetMethod("GetAttributeBaseValueAtTier"); - if (getAttrMethod != null) break; - } - } - - if (getAttrMethod == null) - { - // Fallback: try Tiers dictionary - var tiersProp = template.GetType().GetProperty("Tiers", BindingFlags.Public | BindingFlags.Instance); - if (tiersProp != null) - { - var tiersDict = tiersProp.GetValue(template) as System.Collections.IDictionary; - if (tiersDict != null) - return GetStatsFromTiersDictionary(tiersDict, targetTier); - } - return stats; - } - - // Read target tier stats - var attrTypes = new ECardAttributeType[] - { - ECardAttributeType.DamageAmount, - ECardAttributeType.HealAmount, - ECardAttributeType.ShieldApplyAmount, - ECardAttributeType.Cooldown, - ECardAttributeType.CooldownMax, - ECardAttributeType.Ammo, - ECardAttributeType.AmmoMax, - ECardAttributeType.CritChance, - ECardAttributeType.Multicast, - ECardAttributeType.BurnApplyAmount, - ECardAttributeType.PoisonApplyAmount, - ECardAttributeType.HasteAmount, - ECardAttributeType.SlowAmount, - ECardAttributeType.FreezeAmount, - }; - var attrNames = new string[] - { - "Damage", "Heal", "Shield", "Cooldown", "Cooldown Max", - "Ammo", "Max Ammo", "Crit", "Multicast", - "Burn", "Poison", "Haste", "Slow", "Freeze" - }; - - for (int i = 0; i < attrTypes.Length; i++) - { - try - { - var val = getAttrMethod.Invoke(template, new object[] { attrTypes[i], targetTier }); - if (val == null) continue; - int value = (int)val; - if (value <= 0) continue; - - if (attrTypes[i] == ECardAttributeType.Cooldown || attrTypes[i] == ECardAttributeType.CooldownMax) - stats.Add($"{attrNames[i]} {value / 1000f:F1}s"); - else - stats.Add($"{attrNames[i]} {value}"); - } - catch { } - } - } - catch (System.Exception ex) - { - Plugin.Logger.LogError($"GetUpgradePreview error: {ex.Message}"); - } - - return stats; - } - - /// - /// Gets stats from a Tiers dictionary for a specific tier. - /// - private static List GetStatsFromTiersDictionary(System.Collections.IDictionary tiersDict, ETier targetTier) - { - var stats = new List(); - - try - { - object targetTierData = null; - foreach (System.Collections.DictionaryEntry entry in tiersDict) - { - if ((ETier)entry.Key == targetTier) - { - targetTierData = entry.Value; - break; - } - } - - if (targetTierData == null) return stats; - - var attrsProp = targetTierData.GetType().GetProperty("Attributes"); - if (attrsProp == null) return stats; - - var attrs = attrsProp.GetValue(targetTierData) as System.Collections.IDictionary; - if (attrs == null) return stats; - - var attrNameMap = new Dictionary - { - {"DamageAmount", "Damage"}, {"HealAmount", "Heal"}, - {"ShieldApplyAmount", "Shield"}, {"Cooldown", "Cooldown"}, - {"CooldownMax", "Cooldown Max"}, {"Ammo", "Ammo"}, - {"AmmoMax", "Max Ammo"}, {"CritChance", "Crit"}, - {"Multicast", "Multicast"}, {"BurnApplyAmount", "Burn"}, - {"PoisonApplyAmount", "Poison"}, {"HasteAmount", "Haste"}, - {"SlowAmount", "Slow"}, {"FreezeAmount", "Freeze"}, - }; - - foreach (System.Collections.DictionaryEntry entry in attrs) - { - int value = (int)entry.Value; - if (value <= 0) continue; - - string attrTypeName = entry.Key.ToString(); - string displayName = attrNameMap.ContainsKey(attrTypeName) ? attrNameMap[attrTypeName] : attrTypeName; - - if (attrTypeName.Contains("Cooldown")) - stats.Add($"{displayName} {value / 1000f:F1}s"); - else - stats.Add($"{displayName} {value}"); - } - } - catch (System.Exception ex) - { - Plugin.Logger.LogError($"GetStatsFromTiersDictionary error: {ex.Message}"); - } - - return stats; - } - - /// - /// Gets a preview of what an enchantment will add to an item. - /// - public static List GetEnchantPreview(Card card, string enchantmentName) - { - var effects = new List(); - if (card == null) return effects; - - try - { - // Get the card template - var templateProp = card.GetType().GetProperty("Template"); - if (templateProp == null) return effects; - - var template = templateProp.GetValue(card); - if (template == null) return effects; - - // Try to find the enchantment in the template - var enchantmentsProp = template.GetType().GetProperty("Enchantments"); - if (enchantmentsProp == null) - { - Plugin.Logger.LogDebug("GetEnchantPreview: Enchantments property not found"); - effects.Add($"Adds {enchantmentName} enchantment"); - return effects; - } - - var enchantments = enchantmentsProp.GetValue(template) as System.Collections.IDictionary; - if (enchantments == null) - { - effects.Add($"Adds {enchantmentName} enchantment"); - return effects; - } - - // Find matching enchantment by name - foreach (System.Collections.DictionaryEntry entry in enchantments) - { - var enchant = entry.Value; - if (enchant == null) continue; - - // Get localization to check name - var locProp = enchant.GetType().GetProperty("Localization"); - if (locProp != null) - { - var loc = locProp.GetValue(enchant); - if (loc != null) - { - var titleProp = loc.GetType().GetProperty("Title"); - if (titleProp != null) - { - var title = titleProp.GetValue(loc) as string; - if (title != null && title.Equals(enchantmentName, System.StringComparison.OrdinalIgnoreCase)) - { - // Found the enchantment, get its attributes - var attrsProp = enchant.GetType().GetProperty("Attributes"); - if (attrsProp != null) - { - var attrs = attrsProp.GetValue(enchant) as System.Collections.IDictionary; - if (attrs != null && attrs.Count > 0) - { - foreach (System.Collections.DictionaryEntry attr in attrs) - { - string attrName = attr.Key.ToString(); - int value = (int)attr.Value; - string sign = value >= 0 ? "+" : ""; - effects.Add($"{attrName} {sign}{value}"); - } - } - } - break; - } - } - } - } - } - - if (effects.Count == 0) - { - effects.Add($"Adds {enchantmentName} effects"); - } - } - catch (System.Exception ex) - { - Plugin.Logger.LogError($"GetEnchantPreview error: {ex.Message}"); - effects.Add($"Adds {enchantmentName} enchantment"); - } - - return effects; - } - /// /// Checks if the current pedestal is for enchanting. /// diff --git a/BazaarAccess/UI/ConfirmActionUI.cs b/BazaarAccess/UI/ConfirmActionUI.cs index 48fb3a0..21908e9 100644 --- a/BazaarAccess/UI/ConfirmActionUI.cs +++ b/BazaarAccess/UI/ConfirmActionUI.cs @@ -12,8 +12,7 @@ public enum ConfirmActionType Buy, Sell, Move, - Select, - Upgrade + Select } /// @@ -53,7 +52,6 @@ public ConfirmActionUI(ConfirmActionType actionType, string itemName, int price, ConfirmActionType.Sell => "Confirm Sale", ConfirmActionType.Move => "Confirm Move", ConfirmActionType.Select => "Confirm Selection", - ConfirmActionType.Upgrade => "Confirm Upgrade", _ => "Confirm" }; } @@ -83,15 +81,6 @@ public void HandleInput(AccessibleKey key) Close(); break; - case AccessibleKey.Upgrade: - // U key confirms directly for upgrade/enchant dialogs - if (_actionType == ConfirmActionType.Upgrade) - { - _onConfirm?.Invoke(); - Close(); - } - break; - case AccessibleKey.Back: _onCancel?.Invoke(); Close(); @@ -120,7 +109,6 @@ public void OnFocus() ConfirmActionType.Sell => $"Sell {_itemName} for {_price} gold?", ConfirmActionType.Move => $"Move {_itemName}?", ConfirmActionType.Select => $"Select {_itemName}?", - ConfirmActionType.Upgrade => $"Upgrade {_itemName}?", _ => $"Confirm action for {_itemName}?" }; } From 596ef2c8ce064e0b05bef6694c9bbf2104edbdb6 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 19 Apr 2026 11:51:20 +0800 Subject: [PATCH 11/13] Add details menu item to shop menu, remove buy item confirmation, remove confirm menu since it is now unused --- BazaarAccess/Gameplay/GameplayScreen.cs | 69 +++++---- BazaarAccess/Gameplay/ShopItemMenuHandler.cs | 140 +++++++++++++++++++ BazaarAccess/UI/ConfirmActionUI.cs | 134 ------------------ 3 files changed, 180 insertions(+), 163 deletions(-) create mode 100644 BazaarAccess/Gameplay/ShopItemMenuHandler.cs delete mode 100644 BazaarAccess/UI/ConfirmActionUI.cs diff --git a/BazaarAccess/Gameplay/GameplayScreen.cs b/BazaarAccess/Gameplay/GameplayScreen.cs index 866c721..0c37362 100644 --- a/BazaarAccess/Gameplay/GameplayScreen.cs +++ b/BazaarAccess/Gameplay/GameplayScreen.cs @@ -6,7 +6,6 @@ using BazaarAccess.Patches; using BazaarAccess.Gameplay.CombatEncounterPreview; using BazaarAccess.Gameplay.ItemInspect; -using BazaarAccess.UI; using BazaarGameClient.Domain.Models.Cards; using BazaarGameShared.Domain.Core.Types; using BazaarGameShared.Domain.Runs; @@ -31,6 +30,7 @@ public class GameplayScreen : IAccessibleScreen private readonly ReplayInputHandler _replayHandler; private readonly CombatEncounterPreviewNavigator _combatEncounterPreview; private readonly ItemInspectNavigator _itemInspect; + private readonly ShopItemMenuHandler _shopItemMenu; private bool _isValid = true; private ERunState _lastState = ERunState.Choice; @@ -42,6 +42,7 @@ public GameplayScreen() _replayHandler = new ReplayInputHandler(_navigator, TriggerReplayContinue, TriggerReplayReplay, TriggerReplayRecap, TriggerReplayRecapBack); _combatEncounterPreview = new CombatEncounterPreviewNavigator(); _itemInspect = new ItemInspectNavigator(); + _shopItemMenu = new ShopItemMenuHandler(BuyShopItem, TryStartItemInspect); } public void HandleInput(AccessibleKey key) @@ -60,6 +61,12 @@ public void HandleInput(AccessibleKey key) return; } + if (_shopItemMenu.IsActive) + { + _shopItemMenu.HandleInput(key); + return; + } + // Handle action mode input (when in action mode) if (_actionMenu.IsActive) { @@ -385,7 +392,7 @@ public string GetHelp() return "Left/Right: Navigate items. Up/Down: Read details. " + "Tab: Switch section. Space: Toggle stash. G: Go to stash. " + "B: Board. V: Hero. C: Choices. F: Enemy. X: Inspect. I: Properties. W: Wins. " + - "Enter: Select/Buy or Action menu on board items. E: Exit. R: Refresh. " + + "Enter: Select, or open a menu on shop and board items. E: Exit. R: Refresh. " + "In Action menu: S sell, U upgrade, M move, Arrows reorder. " + "Ctrl+Arrows: Detail reading. Period/Comma: Messages."; } @@ -394,6 +401,7 @@ public void OnFocus() { _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); + _shopItemMenu.Exit(announce: false); _lastState = StateChangePatch.GetCurrentRunState(); _navigator.Refresh(); @@ -418,6 +426,7 @@ public void OnStateChanged(ERunState newState, bool stateActuallyChanged = true) { _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); + _shopItemMenu.Exit(announce: false); _lastState = newState; // Durante combate, no anunciar nada aquí (OnCombatStateChanged lo hará) @@ -626,7 +635,14 @@ private void HandleCardConfirm(Card card) switch (card.Type) { case ECardType.Item: - BuyItem(card); + if (_navigator.IsSelectionFree()) + { + BuyItem(card); + } + else + { + EnterShopItemMenu(card as ItemCard); + } break; case ECardType.Skill: @@ -652,8 +668,6 @@ private void BuyItem(Card card) var itemCard = card as ItemCard; if (itemCard == null) { TolkWrapper.Speak("Not an item"); return; } - string name = ItemReader.GetCardName(card); - if (_navigator.IsSelectionFree()) { // En Loot/Rewards, los items son gratuitos @@ -663,14 +677,21 @@ private void BuyItem(Card card) } else { - int price = ItemReader.GetBuyPrice(card); - var ui = new ConfirmActionUI(ConfirmActionType.Buy, name, price, - onConfirm: () => { - ActionHelper.BuyItem(itemCard); - Plugin.Instance.StartCoroutine(DelayedRefreshAndAnnounce()); - }, - onCancel: () => TolkWrapper.Speak("Cancelled")); - AccessibilityMgr.ShowUI(ui); + BuyShopItem(itemCard); + } + } + + private void BuyShopItem(ItemCard itemCard) + { + if (itemCard == null) + { + TolkWrapper.Speak("Not an item"); + return; + } + + if (ActionHelper.BuyItem(itemCard)) + { + Plugin.Instance.StartCoroutine(DelayedRefreshAndAnnounce()); } } @@ -724,27 +745,15 @@ private System.Collections.IEnumerator DelayedRefreshOnly() _navigator.Refresh(); } - private void HandleSellConfirm(Card card) + private void EnterShopItemMenu(ItemCard itemCard) { - var itemCard = card as ItemCard; - if (itemCard == null) { TolkWrapper.Speak("Cannot sell this"); return; } - - if (!_navigator.CanSellInCurrentState()) + if (itemCard == null) { - TolkWrapper.Speak("Cannot sell right now"); + TolkWrapper.Speak("Not an item"); return; } - string name = ItemReader.GetCardName(card); - int price = ItemReader.GetSellPrice(card); - - var ui = new ConfirmActionUI(ConfirmActionType.Sell, name, price, - onConfirm: () => { - ActionHelper.SellItem(itemCard); - RefreshAndAnnounce(); - }, - onCancel: () => TolkWrapper.Speak("Cancelled")); - AccessibilityMgr.ShowUI(ui); + _shopItemMenu.Enter(itemCard); } private void HandlePedestalAction(Card card) @@ -1047,6 +1056,7 @@ public void OnCombatStateChanged(bool inCombat) { _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); + _shopItemMenu.Exit(announce: false); _navigator.SetCombatMode(inCombat); if (inCombat) @@ -1113,6 +1123,7 @@ public void OnReplayStateChanged(bool inReplayState) { _itemInspect.Exit(); _combatEncounterPreview.Exit(announce: false); + _shopItemMenu.Exit(announce: false); _navigator.SetReplayMode(inReplayState); _navigator.SyncVisualRecapState(); diff --git a/BazaarAccess/Gameplay/ShopItemMenuHandler.cs b/BazaarAccess/Gameplay/ShopItemMenuHandler.cs new file mode 100644 index 0000000..29b313c --- /dev/null +++ b/BazaarAccess/Gameplay/ShopItemMenuHandler.cs @@ -0,0 +1,140 @@ +using System; +using BazaarAccess.Accessibility; +using BazaarAccess.Core; +using BazaarGameClient.Domain.Models.Cards; + +namespace BazaarAccess.Gameplay; + +internal enum ShopItemActionOption +{ + Details, + Buy, + Cancel +} + +internal sealed class ShopItemMenuHandler +{ + private readonly Action _onBuy; + private readonly Action _onShowDetails; + + private bool _isActive; + private ItemCard _itemCard; + private int _currentIndex; + + private static readonly ShopItemActionOption[] _options = + { + ShopItemActionOption.Details, + ShopItemActionOption.Buy, + ShopItemActionOption.Cancel + }; + + public bool IsActive => _isActive; + + public ShopItemMenuHandler(Action onBuy, Action onShowDetails) + { + _onBuy = onBuy; + _onShowDetails = onShowDetails; + } + + public void Enter(ItemCard itemCard) + { + if (itemCard == null) + return; + + _itemCard = itemCard; + _currentIndex = 0; + _isActive = true; + + string name = ItemReader.GetCardName(itemCard); + TolkWrapper.Speak($"{name}. {GetOptionText(_options[0])}. {_options.Length} actions. Backspace to cancel."); + } + + public void Exit(bool announce = true) + { + if (!_isActive) + return; + + _isActive = false; + _itemCard = null; + + if (announce) + TolkWrapper.Speak("Exited"); + } + + public void HandleInput(AccessibleKey key) + { + if (!_isActive || _itemCard == null) + { + Exit(announce: false); + return; + } + + switch (key) + { + case AccessibleKey.Up: + Navigate(-1); + break; + + case AccessibleKey.Down: + Navigate(1); + break; + + case AccessibleKey.Confirm: + ExecuteCurrentOption(); + break; + + case AccessibleKey.Back: + Exit(); + break; + } + } + + private void Navigate(int direction) + { + _currentIndex = (_currentIndex + direction + _options.Length) % _options.Length; + TolkWrapper.Speak($"{GetOptionText(_options[_currentIndex])}, {_currentIndex + 1} of {_options.Length}"); + } + + private void ExecuteCurrentOption() + { + var option = _options[_currentIndex]; + var itemCard = _itemCard; + + _isActive = false; + _itemCard = null; + + switch (option) + { + case ShopItemActionOption.Details: + _onShowDetails?.Invoke(itemCard); + break; + + case ShopItemActionOption.Buy: + _onBuy?.Invoke(itemCard); + break; + + case ShopItemActionOption.Cancel: + TolkWrapper.Speak("Exited"); + break; + } + } + + private string GetOptionText(ShopItemActionOption option) + { + switch (option) + { + case ShopItemActionOption.Details: + return "Details"; + + case ShopItemActionOption.Buy: + int price = ItemReader.GetBuyPrice(_itemCard); + return price > 0 ? $"Buy for {price} gold" : "Buy"; + + case ShopItemActionOption.Cancel: + return "Cancel"; + + default: + return option.ToString(); + } + } +} diff --git a/BazaarAccess/UI/ConfirmActionUI.cs b/BazaarAccess/UI/ConfirmActionUI.cs deleted file mode 100644 index 21908e9..0000000 --- a/BazaarAccess/UI/ConfirmActionUI.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using BazaarAccess.Accessibility; -using BazaarAccess.Core; - -namespace BazaarAccess.UI; - -/// -/// Tipo de acción a confirmar. -/// -public enum ConfirmActionType -{ - Buy, - Sell, - Move, - Select -} - -/// -/// UI de confirmación para acciones de compra/venta/mover/upgrade. -/// -public class ConfirmActionUI : IAccessibleUI -{ - public string UIName { get; } - - private readonly string _itemName; - private readonly int _price; - private readonly string _customMessage; - private readonly ConfirmActionType _actionType; - private readonly Action _onConfirm; - private readonly Action _onCancel; - - private int _selectedOption = 0; // 0 = Confirm, 1 = Cancel - private bool _isValid = true; - - public ConfirmActionUI(ConfirmActionType actionType, string itemName, int price, Action onConfirm, Action onCancel = null) - : this(actionType, itemName, price, null, onConfirm, onCancel) - { - } - - public ConfirmActionUI(ConfirmActionType actionType, string itemName, int price, string customMessage, Action onConfirm, Action onCancel = null) - { - _actionType = actionType; - _itemName = itemName; - _price = price; - _customMessage = customMessage; - _onConfirm = onConfirm; - _onCancel = onCancel; - - UIName = actionType switch - { - ConfirmActionType.Buy => "Confirm Purchase", - ConfirmActionType.Sell => "Confirm Sale", - ConfirmActionType.Move => "Confirm Move", - ConfirmActionType.Select => "Confirm Selection", - _ => "Confirm" - }; - } - - public void HandleInput(AccessibleKey key) - { - switch (key) - { - case AccessibleKey.Up: - case AccessibleKey.Down: - // Cambiar entre Confirm y Cancel - _selectedOption = (_selectedOption + 1) % 2; - AnnounceCurrentOption(); - break; - - case AccessibleKey.Confirm: - if (_selectedOption == 0) - { - // Confirmar - _onConfirm?.Invoke(); - } - else - { - // Cancelar - _onCancel?.Invoke(); - } - Close(); - break; - - case AccessibleKey.Back: - _onCancel?.Invoke(); - Close(); - break; - } - } - - public string GetHelp() - { - return "Up/Down: switch option. Enter: confirm. Escape: cancel."; - } - - public void OnFocus() - { - // Use custom message if provided - string question; - if (!string.IsNullOrEmpty(_customMessage)) - { - question = _customMessage; - } - else - { - question = _actionType switch - { - ConfirmActionType.Buy => $"Buy {_itemName} for {_price} gold?", - ConfirmActionType.Sell => $"Sell {_itemName} for {_price} gold?", - ConfirmActionType.Move => $"Move {_itemName}?", - ConfirmActionType.Select => $"Select {_itemName}?", - _ => $"Confirm action for {_itemName}?" - }; - } - - TolkWrapper.Speak(question); - AnnounceCurrentOption(); - } - - public bool IsValid() => _isValid; - - private void AnnounceCurrentOption() - { - string option = _selectedOption == 0 ? "Confirm" : "Cancel"; - int position = _selectedOption + 1; - TolkWrapper.Speak($"{option}, {position} of 2"); - } - - private void Close() - { - _isValid = false; - AccessibilityMgr.PopUI(); - } -} From ffb5d4a62d8af45485e79e1b4537fca31fdcfebe Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 19 Apr 2026 13:08:49 +0800 Subject: [PATCH 12/13] Add announcements for you or the enemy gaining and losing enrage --- BazaarAccess/Patches/CombatEventHandler.cs | 52 ++++++++++++++++++++++ BazaarAccess/Patches/StateChangePatch.cs | 5 +++ 2 files changed, 57 insertions(+) diff --git a/BazaarAccess/Patches/CombatEventHandler.cs b/BazaarAccess/Patches/CombatEventHandler.cs index b0a9fff..99afaec 100644 --- a/BazaarAccess/Patches/CombatEventHandler.cs +++ b/BazaarAccess/Patches/CombatEventHandler.cs @@ -4,6 +4,7 @@ using BazaarAccess.Gameplay; using BazaarBattleService.Models; using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Domain.Runs; using BazaarGameShared.Infra.Messages.GameSimEvents; using TheBazaar; using UnityEngine; @@ -61,6 +62,36 @@ public static void OnCombatEnded() screen?.OnCombatStateChanged(false); } + /// + /// Announces when a combatant becomes enraged during combat. + /// + public static void OnPlayerEnragedStarted(PlayerEnragedEvent evt) + { + if (!StateChangePatch.IsInCombat || evt == null) + return; + + string message = evt.CombatantId == ECombatantId.Player + ? "You are enraged" + : $"{GetOpponentDisplayName()} is enraged"; + + TolkWrapper.Speak(message); + } + + /// + /// Announces when a combatant is no longer enraged during combat. + /// + public static void OnPlayerEnragedEnded(PlayerEnragedEvent evt) + { + if (!StateChangePatch.IsInCombat || evt == null) + return; + + string message = evt.CombatantId == ECombatantId.Player + ? "Your enrage ends" + : $"{GetOpponentDisplayName()}'s enrage ends"; + + TolkWrapper.Speak(message); + } + /// /// When combat finishes with a result (fires for BOTH PvE and PvP). /// Uses a short delay to consolidate with victory/prestige updates into one message. @@ -161,4 +192,25 @@ private static System.Collections.IEnumerator DelayedSetCombatBoardReady() Plugin.Logger.LogInfo("Combat board ready (after delay)"); } } + + private static string GetOpponentDisplayName() + { + try + { + var currentState = Data.CurrentState?.StateName; + bool isPvpCombat = currentState == ERunState.PVPCombat; + var pvpOpponent = Data.SimPvpOpponent; + + if (isPvpCombat && pvpOpponent != null && !string.IsNullOrEmpty(pvpOpponent.Name)) + { + return pvpOpponent.Name; + } + } + catch (Exception ex) + { + Plugin.Logger.LogWarning($"GetOpponentDisplayName error: {ex.Message}"); + } + + return "Enemy"; + } } diff --git a/BazaarAccess/Patches/StateChangePatch.cs b/BazaarAccess/Patches/StateChangePatch.cs index afa4b9e..0ecfa71 100644 --- a/BazaarAccess/Patches/StateChangePatch.cs +++ b/BazaarAccess/Patches/StateChangePatch.cs @@ -14,6 +14,7 @@ // For combat describer events using EffectTriggeredEvent = TheBazaar.EffectTriggeredEvent; using PlayerHealthChangedEvent = TheBazaar.PlayerHealthChangedEvent; +using PlayerEnragedEvent = TheBazaar.PlayerEnragedEvent; namespace BazaarAccess.Patches; @@ -83,6 +84,10 @@ public static void Subscribe() // === Combat events === SubscribeToEventNoParam("CombatStarted", CombatEventHandler.OnCombatStarted); SubscribeToEventNoParam("CombatEnded", CombatEventHandler.OnCombatEnded); + SubscribeToEvent("PlayerEnragedStarted", typeof(Action), + (Action)CombatEventHandler.OnPlayerEnragedStarted); + SubscribeToEvent("PlayerEnragedEnded", typeof(Action), + (Action)CombatEventHandler.OnPlayerEnragedEnded); // === Victory/defeat events === // OnCombatPvEFinish fires for ALL combats (PvE and PvP) with the result From 9ec8aed05169f84ee75873557f44cca88b071d40 Mon Sep 17 00:00:00 2001 From: Dickson Tan Date: Sun, 19 Apr 2026 14:24:56 +0800 Subject: [PATCH 13/13] Read rage stat on your hero and the opponent --- .../Gameplay/Navigation/HeroNavigator.cs | 89 +++++++++++++++++-- .../Gameplay/Navigation/RecapNavigator.cs | 17 +--- 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs b/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs index 08db69d..8a0a765 100644 --- a/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs +++ b/BazaarAccess/Gameplay/Navigation/HeroNavigator.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using BazaarAccess.Core; +using BazaarGameClient.Domain.Models; using BazaarGameClient.Domain.Models.Cards; using BazaarGameShared.Domain.Core.Types; using TheBazaar; @@ -11,6 +12,8 @@ namespace BazaarAccess.Gameplay.Navigation; /// public class HeroNavigator { + private const int ShieldStatIndex = 6; + public static readonly EPlayerAttributeType[] HeroStats = new[] { EPlayerAttributeType.Health, @@ -141,12 +144,28 @@ public void AnnounceSubsection() /// Returns the total number of hero stats including rank if in ranked mode. /// public int GetStatCount() + { + return GetStatCount(Data.Run?.Player, includeRank: ItemReader.IsRankedMode()); + } + + public int GetStatCount(Player player, bool includeRank) + { + return GetStatCountInternal(player, includeRank); + } + + private int GetStatCountInternal(Player player, bool includeRank) { int count = HeroStats.Length; - if (ItemReader.IsRankedMode()) count++; + if (SupportsRage(player)) count++; + if (includeRank) count++; return count; } + public static bool SupportsRage(Player player) + { + return (player?.GetAttributeValue(EPlayerAttributeType.RageMax) ?? 0) > 0; + } + /// /// Announces the current hero skill with its description. /// @@ -274,6 +293,13 @@ public void ReadAllStats() var level = player.GetAttributeValue(EPlayerAttributeType.Level); if (level.HasValue) parts.Add($"Level {level.Value}"); + if (SupportsRage(player)) + { + int rageMax = player.GetAttributeValue(EPlayerAttributeType.RageMax) ?? 0; + int rage = player.GetAttributeValue(EPlayerAttributeType.Rage) ?? 0; + parts.Add($"Rage {rage} / {rageMax}"); + } + var shield = player.GetAttributeValue(EPlayerAttributeType.Shield); if (shield.HasValue && shield.Value > 0) parts.Add($"Shield {shield.Value}"); @@ -285,24 +311,71 @@ public void ReadAllStats() /// public void AnnounceStat() { - // Check if this is the rank slot (last slot in ranked mode) - if (ItemReader.IsRankedMode() && _statIndex >= HeroStats.Length) + var player = Data.Run?.Player; + AnnounceStat( + player, + _statIndex, + includeRank: ItemReader.IsRankedMode(), + rankText: ItemReader.GetPlayerRank()); + } + + public void AnnounceStat(Player player, int statIndex, bool includeRank, string rankText = null) + { + if (player == null) { TolkWrapper.Speak("No hero data"); return; } + + bool isRageStat; + bool isRankStat; + int baseStatIndex = GetBaseStatIndex(player, statIndex, includeRank, out isRageStat, out isRankStat); + + if (isRankStat) { - string rank = ItemReader.GetPlayerRank(); - TolkWrapper.Speak(!string.IsNullOrEmpty(rank) ? $"Rank: {rank}" : "Rank: unranked"); + TolkWrapper.Speak(!string.IsNullOrEmpty(rankText) ? $"Rank: {rankText}" : "Rank: unranked"); return; } - var player = Data.Run?.Player; - if (player == null) { TolkWrapper.Speak("No hero data"); return; } + if (isRageStat) + { + int rage = player.GetAttributeValue(EPlayerAttributeType.Rage) ?? 0; + int rageMax = player.GetAttributeValue(EPlayerAttributeType.RageMax) ?? 0; + TolkWrapper.Speak($"Rage: {rage} / {rageMax}"); + return; + } - var type = HeroStats[_statIndex]; + var type = HeroStats[baseStatIndex]; var value = player.GetAttributeValue(type); string name = GetStatName(type); TolkWrapper.Speak(value.HasValue ? $"{name}: {value.Value}" : $"{name}: none"); } + private static int GetBaseStatIndex(Player player, int statIndex, bool includeRank, out bool isRageStat, out bool isRankStat) + { + isRageStat = false; + isRankStat = false; + + bool shouldShowRage = SupportsRage(player); + int rankIndex = HeroStats.Length + (shouldShowRage ? 1 : 0); + + if (shouldShowRage && statIndex == ShieldStatIndex) + { + isRageStat = true; + return -1; + } + + if (includeRank && statIndex == rankIndex) + { + isRankStat = true; + return -1; + } + + if (shouldShowRage && statIndex > ShieldStatIndex) + { + return statIndex - 1; + } + + return statIndex; + } + /// /// Gets a human-readable name for a player attribute type. /// diff --git a/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs b/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs index c2428c4..666e185 100644 --- a/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs +++ b/BazaarAccess/Gameplay/Navigation/RecapNavigator.cs @@ -239,7 +239,8 @@ public void EnemyStatsNext() { if (_currentSection == RecapSection.EnemyStats) { - if (_enemyStatIndex >= HeroNavigator.HeroStats.Length - 1) + int maxIndex = _hero.GetStatCount(Data.Run?.Opponent, includeRank: false) - 1; + if (_enemyStatIndex >= maxIndex) { AnnounceEnemyStat(); return; @@ -330,7 +331,7 @@ public void Reset() /// /// Announce current enemy hero stat. - /// Uses HeroNavigator.HeroStats array for stat types and GetStatName for display. + /// Uses HeroNavigator's dynamic stat mapping for combat/recap stat views. /// private void AnnounceEnemyStat() { @@ -341,17 +342,7 @@ private void AnnounceEnemyStat() return; } - var type = HeroNavigator.HeroStats[_enemyStatIndex]; - string name = _hero.GetStatName(type); - - if (opponent.Attributes.TryGetValue(type, out int value)) - { - TolkWrapper.Speak($"{name}: {value}"); - } - else - { - TolkWrapper.Speak($"{name}: none"); - } + _hero.AnnounceStat(opponent, _enemyStatIndex, includeRank: false); } ///