using SoulHarvest.Common; using SoulHarvest.Items; using SoulHarvest.Tiles; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using System; using System.Collections.Generic; using Terraria; using Terraria.Audio; using Terraria.GameContent; using Terraria.GameContent.UI.Elements; using Terraria.ID; using Terraria.Localization; using Terraria.ModLoader; using Terraria.UI; namespace SoulHarvest.UI; internal class DeathAltarUIState : UIState { private const float BasePanelWidth = 1180f; private const float BasePanelHeight = 700f; private const float TreeLeft = 228f; private readonly List availableEquipment = []; private DeathAltarFramePanel? panel; private UIPanel? selectorPanel; private UIList? itemList; private UIScrollbar? itemScrollbar; private UIText? titleText; private UIText? selectorTitleText; private UIText? itemText; private UIText? resourceText; private AltarTextButton? closeButton; private AltarTextButton? recipeHelpButton; private ReaperProgressionPanel? characterProgressionPanel; private AltarEquipmentProgressionPanel? equipmentProgressionPanel; private UIPanel? recipeHelpPanel; private UIList? recipeList; private UIScrollbar? recipeScrollbar; private UIText? recipeHelpTitle; private UIText? noRecipesText; private bool recipeHelpVisible; private AltarEquipmentEntry? selectedEquipment; private int tileX; private int tileY; private int itemSignature; private float layoutScale = 1f; private int layoutWidth = -1; private int layoutHeight = -1; public override void OnInitialize() { panel = new DeathAltarFramePanel(); panel.BackgroundColor = new Color(10, 9, 11) * 0.985f; panel.BorderColor = new Color(116, 34, 43); Append(panel); titleText = new UIText(string.Empty, 1.08f, true) { DynamicallyScaleDownToWidth = true, TextOriginX = 0.5f, TextColor = new Color(232, 219, 204) }; panel.Append(titleText); closeButton = new AltarTextButton( "×", 1f, new Color(90, 35, 55), new Color(142, 49, 72), new Color(180, 82, 105)); closeButton.OnLeftClick += (_, _) => { SoundEngine.PlaySound(SoundID.MenuClose); ModContent.GetInstance().Hide(); }; panel.Append(closeButton); recipeHelpButton = new AltarTextButton( string.Empty, 0.72f, new Color(42, 35, 39), new Color(70, 42, 46), new Color(151, 54, 63)); recipeHelpButton.OnLeftClick += (_, _) => ToggleRecipeHelp(); panel.Append(recipeHelpButton); selectorTitleText = new UIText(string.Empty, 0.86f, true) { DynamicallyScaleDownToWidth = true, TextColor = new Color(218, 203, 190) }; panel.Append(selectorTitleText); selectorPanel = new UIPanel(); selectorPanel.BackgroundColor = new Color(15, 14, 17) * 0.94f; selectorPanel.BorderColor = new Color(83, 48, 51); panel.Append(selectorPanel); itemList = new UIList(); selectorPanel.Append(itemList); itemScrollbar = new UIScrollbar(); selectorPanel.Append(itemScrollbar); itemList.SetScrollbar(itemScrollbar); itemText = new UIText(string.Empty, 0.85f, true) { DynamicallyScaleDownToWidth = true, TextColor = new Color(237, 222, 208) }; panel.Append(itemText); resourceText = new UIText(string.Empty, 0.78f) { DynamicallyScaleDownToWidth = true, TextColor = new Color(202, 96, 104) }; panel.Append(resourceText); BuildRecipeHelpPanel(); BuildCharacterProgressionPanel(); BuildEquipmentProgressionPanel(); ApplyResponsiveLayout(UserInterface.ActiveInstance.GetDimensions()); } public override void OnDeactivate() { CancelTransientInteraction(); base.OnDeactivate(); } public override void Recalculate() { ApplyResponsiveLayout(UserInterface.ActiveInstance.GetDimensions()); base.Recalculate(); } private void ApplyResponsiveLayout(CalculatedStyle viewport) { if (panel is null) return; int viewportWidth = Math.Max(1, (int)MathF.Round(viewport.Width)); int viewportHeight = Math.Max(1, (int)MathF.Round(viewport.Height)); if (viewportWidth == layoutWidth && viewportHeight == layoutHeight) return; layoutWidth = viewportWidth; layoutHeight = viewportHeight; float widthScale = Math.Max(0.01f, (viewport.Width - 24f) / BasePanelWidth); float heightScale = Math.Max(0.01f, (viewport.Height - 24f) / BasePanelHeight); // Active UI dimensions can be as small as 400x300 when the game is run at // 800x600 with 200% UI scale. Scale against both axes and preserve a small // safety margin instead of forcing a minimum that can leave the frame off-screen. layoutScale = Math.Clamp(Math.Min(1f, Math.Min(widthScale, heightScale)), 0.25f, 1f); float panelWidth = BasePanelWidth * layoutScale; float panelHeight = BasePanelHeight * layoutScale; panel.Left.Set(-panelWidth * 0.5f, 0.5f); panel.Top.Set(-panelHeight * 0.5f, 0.5f); panel.Width.Set(panelWidth, 0f); panel.Height.Set(panelHeight, 0f); panel.SetPadding(14f * layoutScale); panel.LayoutScale = layoutScale; panel.SidebarDividerX = 218f * layoutScale; if (titleText is not null) { titleText.Left.Set(240f * layoutScale, 0f); titleText.Top.Set(4f * layoutScale, 0f); titleText.Width.Set(-480f * layoutScale, 1f); titleText.Height.Set(30f * layoutScale, 0f); SetConstrainedText(titleText, titleText.Text, 1.08f, large: true); } LayoutButton(closeButton, -34f, 1f, 0f, 30f, 30f, 1f); LayoutButton(recipeHelpButton, -156f, 1f, 0f, 116f, 30f, 0.72f); if (selectorTitleText is not null) { selectorTitleText.Left.Set(10f * layoutScale, 0f); selectorTitleText.Top.Set(49f * layoutScale, 0f); selectorTitleText.Width.Set(198f * layoutScale, 0f); selectorTitleText.Height.Set(24f * layoutScale, 0f); SetConstrainedText(selectorTitleText, selectorTitleText.Text, 0.86f, large: true); } if (selectorPanel is not null) { selectorPanel.Left.Set(4f * layoutScale, 0f); selectorPanel.Top.Set(78f * layoutScale, 0f); selectorPanel.Width.Set(206f * layoutScale, 0f); selectorPanel.Height.Set(564f * layoutScale, 0f); selectorPanel.SetPadding(6f * layoutScale); } if (itemList is not null) { itemList.Width.Set(-18f * layoutScale, 1f); itemList.Height.Set(0f, 1f); itemList.ListPadding = 5f * layoutScale; } if (itemScrollbar is not null) { itemScrollbar.Left.Set(-14f * layoutScale, 1f); itemScrollbar.Width.Set(14f * layoutScale, 0f); itemScrollbar.Height.Set(0f, 1f); } LayoutTreeHeaderText(itemText, 48f, 0.85f, large: true); LayoutTreeHeaderText(resourceText, 75f, 0.78f, large: false); LayoutRecipeHelpPanel(); LayoutCharacterProgressionPanel(); LayoutEquipmentProgressionPanel(); if (availableEquipment.Count > 0) RebuildSelectors(); if (recipeHelpVisible) RefreshRecipeHelp(); SyncSelectedContentLayer(); } private void LayoutTreeHeaderText(UIText? text, float top, float textScale, bool large) { if (text is null) return; text.Left.Set(TreeLeft * layoutScale, 0f); text.Top.Set(top * layoutScale, 0f); text.Width.Set(-(TreeLeft + 8f) * layoutScale, 1f); text.Height.Set(24f * layoutScale, 0f); SetConstrainedText(text, text.Text, textScale, large); } private void LayoutButton(AltarTextButton? button, float left, float leftPercent, float top, float width, float height, float textScale) { if (button is null) return; button.Left.Set(left * layoutScale, leftPercent); button.Top.Set(top * layoutScale, 0f); button.Width.Set(width * layoutScale, 0f); button.Height.Set(height * layoutScale, 0f); button.SetPadding(0f); button.TextScale = textScale * layoutScale; } private void SetConstrainedText(UIText text, string value, float textScale, bool large) { text.SetText(value, textScale * layoutScale, large); text.MinWidth.Set(0f, 0f); } public override void Update(GameTime gameTime) { base.Update(gameTime); if (panel?.ContainsPoint(Main.MouseScreen) == true) Main.LocalPlayer.mouseInterface = true; Player player = Main.LocalPlayer; RefreshAvailableEquipment(player); IDeathAltarUpgradeable? upgradeable = null; Item? selected = null; bool characterSickleSelected = selectedEquipment is AltarEquipmentEntry selection && selection.IsCharacterSickle; if (selectedEquipment is AltarEquipmentEntry selectedEntry && selectedEntry.TryGetDisplayItem(player, out Item resolved)) { selected = resolved; if (selectedEntry.IsCharacterDeathDomain) upgradeable = player.GetModPlayer().DeathDomainProgression; else if (!selectedEntry.IsCharacterSickle) upgradeable = resolved.ModItem as IDeathAltarUpgradeable; } int essenceCount = player.CountItem(ModContent.ItemType()); int souls = player.GetModPlayer().Souls; if (!characterSickleSelected && selectedEquipment is AltarEquipmentEntry equipmentEntry && selected is not null && upgradeable is not null && equipmentProgressionPanel is not null) { equipmentProgressionPanel.Bind(equipmentEntry, selected, upgradeable); equipmentProgressionPanel.Refresh(selected, upgradeable, essenceCount, souls); } if (titleText is not null) SetConstrainedText(titleText, Language.GetTextValue("Mods.SoulHarvest.UI.AltarTitle"), 1.08f, large: true); if (selectorTitleText is not null) SetConstrainedText(selectorTitleText, Language.GetTextValue("Mods.SoulHarvest.UI.SelectEquipment"), 0.86f, large: true); if (itemText is not null) SetConstrainedText(itemText, selected is null ? Language.GetTextValue("Mods.SoulHarvest.UI.NoUpgradeableItems") : Language.GetTextValue("Mods.SoulHarvest.UI.CurrentItem", selected.Name), 0.85f, large: true); if (resourceText is not null) SetConstrainedText(resourceText, Language.GetTextValue("Mods.SoulHarvest.UI.AltarResources", essenceCount, souls), 0.78f, large: false); recipeHelpButton?.SetText(Language.GetTextValue(recipeHelpVisible ? "Mods.SoulHarvest.UI.BackToSkillTree" : "Mods.SoulHarvest.UI.RecipeHelpButton")); if (characterSickleSelected) characterProgressionPanel?.Refresh(player, essenceCount, souls); SyncSelectedContentLayer(); } public void SetAltarPosition(int x, int y) { CancelTransientInteraction(); tileX = x; tileY = y; itemSignature = 0; selectedEquipment = AltarEquipmentEntry.CreateCharacterSickle(); SetRecipeHelpVisible(false, playSound: false); SyncSelectedContentLayer(); } internal void CancelTransientInteraction() { characterProgressionPanel?.CancelInteraction(); equipmentProgressionPanel?.DeactivateInteraction(); } public bool IsPlayerNearAltar() { Tile tile = Framing.GetTileSafely(tileX, tileY); if (!tile.HasTile || tile.TileType != ModContent.TileType()) return false; Vector2 altar = new((tileX + 0.5f) * 16f, (tileY + 0.5f) * 16f); return Vector2.Distance(Main.LocalPlayer.Center, altar) <= 12f * 16f; } internal void SelectEquipment(AltarEquipmentEntry entry) { if (selectedEquipment is AltarEquipmentEntry current && IsSameEquipmentEntry(current, entry)) return; CancelTransientInteraction(); selectedEquipment = entry; SetRecipeHelpVisible(false, playSound: false); RebuildSelectors(); SyncSelectedContentLayer(); SoundEngine.PlaySound(SoundID.MenuTick); } internal void RequestUpgrade(DeathAltarUpgradeType type) { if (selectedEquipment?.ItemReference is not DeathAltarItemReference reference) return; SoundEngine.PlaySound(SoundID.MenuTick); ModContent.GetInstance().RequestUpgrade(type, reference, tileX, tileY); } private void RefreshAvailableEquipment(Player player) { AltarEquipmentEntry characterSickle = AltarEquipmentEntry.CreateCharacterSickle(); AltarEquipmentEntry characterDeathDomain = AltarEquipmentEntry.CreateCharacterDeathDomain(); List found = [characterSickle, characterDeathDomain]; int signature = HashCode.Combine(17, (int)characterSickle.Kind, characterSickle.ItemType); signature = HashCode.Combine(signature, (int)characterDeathDomain.Kind, characterDeathDomain.ItemType); ScanContainer(player.inventory, DeathAltarItemStorage.Inventory, Math.Min(58, player.inventory.Length), found, ref signature); ScanContainer(player.armor, DeathAltarItemStorage.Armor, player.armor.Length, found, ref signature); if (signature == itemSignature && found.Count == availableEquipment.Count) return; CancelTransientInteraction(); AltarEquipmentEntry? previous = selectedEquipment; availableEquipment.Clear(); availableEquipment.AddRange(found); selectedEquipment = previous is AltarEquipmentEntry old ? FindSameEquipmentEntry(old) : null; selectedEquipment ??= characterSickle; itemSignature = signature; RebuildSelectors(); SyncSelectedContentLayer(); } private static void ScanContainer(Item[] container, DeathAltarItemStorage storage, int accessibleSlots, List found, ref int signature) { for (int slot = 0; slot < accessibleSlots && slot <= byte.MaxValue; slot++) { Item item = container[slot]; if (item.IsAir || item.ModItem is not IDeathAltarUpgradeable) continue; DeathAltarItemReference reference = new(storage, (byte)slot, item.type); found.Add(AltarEquipmentEntry.CreateItem(reference)); signature = HashCode.Combine(signature, (int)storage, slot, item.type); } } private AltarEquipmentEntry? FindSameEquipmentEntry(AltarEquipmentEntry old) { foreach (AltarEquipmentEntry candidate in availableEquipment) if (IsSameEquipmentEntry(candidate, old)) return candidate; return null; } private static bool IsSameEquipmentEntry(AltarEquipmentEntry left, AltarEquipmentEntry right) { if (left.Kind != right.Kind) return false; if (left.IsCharacterProgression) return true; return left.ItemReference is DeathAltarItemReference leftReference && right.ItemReference is DeathAltarItemReference rightReference && leftReference.Storage == rightReference.Storage && leftReference.Slot == rightReference.Slot; } private void RebuildSelectors() { if (itemList is null) return; itemList.Clear(); foreach (AltarEquipmentEntry entry in availableEquipment) { if (!entry.TryGetDisplayItem(Main.LocalPlayer, out Item item)) continue; bool isSelected = selectedEquipment is AltarEquipmentEntry current && IsSameEquipmentEntry(current, entry); itemList.Add(new AltarItemSelector(this, entry, item, isSelected, layoutScale)); } } private void BuildRecipeHelpPanel() { recipeHelpPanel = new UIPanel(); recipeHelpPanel.BackgroundColor = new Color(13, 12, 14) * 0.995f; recipeHelpPanel.BorderColor = new Color(111, 42, 49); recipeHelpTitle = new UIText(string.Empty, 0.9f, true) { HAlign = 0.5f }; recipeHelpTitle.DynamicallyScaleDownToWidth = true; recipeHelpPanel.Append(recipeHelpTitle); recipeList = new UIList(); recipeHelpPanel.Append(recipeList); recipeScrollbar = new UIScrollbar(); recipeHelpPanel.Append(recipeScrollbar); recipeList.SetScrollbar(recipeScrollbar); noRecipesText = new UIText(string.Empty, 0.72f) { HAlign = 0.5f }; noRecipesText.DynamicallyScaleDownToWidth = true; LayoutRecipeHelpPanel(); } private void BuildCharacterProgressionPanel() { characterProgressionPanel = new ReaperProgressionPanel(); characterProgressionPanel.BackgroundColor = new Color(13, 11, 14) * 0.995f; characterProgressionPanel.BorderColor = new Color(126, 39, 49); LayoutCharacterProgressionPanel(); } private void BuildEquipmentProgressionPanel() { equipmentProgressionPanel = new AltarEquipmentProgressionPanel(this); LayoutEquipmentProgressionPanel(); } private void LayoutCharacterProgressionPanel() { if (characterProgressionPanel is null) return; characterProgressionPanel.Left.Set((TreeLeft - 6f) * layoutScale, 0f); characterProgressionPanel.Top.Set(42f * layoutScale, 0f); characterProgressionPanel.Width.Set(-(TreeLeft - 2f) * layoutScale, 1f); characterProgressionPanel.Height.Set(-46f * layoutScale, 1f); characterProgressionPanel.SetPadding(10f * layoutScale); characterProgressionPanel.ApplyLayoutScale(layoutScale); } private void LayoutEquipmentProgressionPanel() { if (equipmentProgressionPanel is null) return; equipmentProgressionPanel.Left.Set((TreeLeft - 6f) * layoutScale, 0f); equipmentProgressionPanel.Top.Set(42f * layoutScale, 0f); equipmentProgressionPanel.Width.Set(-(TreeLeft - 2f) * layoutScale, 1f); equipmentProgressionPanel.Height.Set(-46f * layoutScale, 1f); equipmentProgressionPanel.SetPadding(0f); equipmentProgressionPanel.ApplyLayoutScale(layoutScale); } private void LayoutRecipeHelpPanel() { if (recipeHelpPanel is null) return; recipeHelpPanel.Left.Set((TreeLeft - 6f) * layoutScale, 0f); recipeHelpPanel.Top.Set(42f * layoutScale, 0f); recipeHelpPanel.Width.Set(-(TreeLeft - 2f) * layoutScale, 1f); recipeHelpPanel.Height.Set(-42f * layoutScale, 1f); recipeHelpPanel.SetPadding(10f * layoutScale); if (recipeHelpTitle is not null) { recipeHelpTitle.HAlign = 0f; recipeHelpTitle.Top.Set(2f * layoutScale, 0f); recipeHelpTitle.Width.Set(0f, 1f); recipeHelpTitle.Height.Set(28f * layoutScale, 0f); recipeHelpTitle.TextOriginX = 0.5f; SetConstrainedText(recipeHelpTitle, recipeHelpTitle.Text, 0.9f, large: true); } if (recipeList is not null) { recipeList.Top.Set(38f * layoutScale, 0f); recipeList.Width.Set(-20f * layoutScale, 1f); recipeList.Height.Set(-42f * layoutScale, 1f); recipeList.ListPadding = 8f * layoutScale; } if (recipeScrollbar is not null) { recipeScrollbar.Left.Set(-15f * layoutScale, 1f); recipeScrollbar.Top.Set(38f * layoutScale, 0f); recipeScrollbar.Width.Set(14f * layoutScale, 0f); recipeScrollbar.Height.Set(-42f * layoutScale, 1f); } if (noRecipesText is not null) { noRecipesText.HAlign = 0f; noRecipesText.Left.Set(10f * layoutScale, 0f); noRecipesText.Top.Set(82f * layoutScale, 0f); noRecipesText.Width.Set(-20f * layoutScale, 1f); noRecipesText.Height.Set(22f * layoutScale, 0f); noRecipesText.TextOriginX = 0.5f; SetConstrainedText(noRecipesText, noRecipesText.Text, 0.72f, large: false); } } private void ToggleRecipeHelp() { SetRecipeHelpVisible(!recipeHelpVisible, playSound: true); } private bool IsCharacterSickleSelected() { return selectedEquipment is AltarEquipmentEntry entry && entry.IsCharacterSickle; } private void SyncSelectedContentLayer() { if (panel is null || characterProgressionPanel is null || equipmentProgressionPanel is null) return; bool showCharacterTree = IsCharacterSickleSelected() && !recipeHelpVisible; bool showEquipmentTree = !IsCharacterSickleSelected() && selectedEquipment is not null && !recipeHelpVisible; if (showCharacterTree) { if (characterProgressionPanel.Parent is null) { characterProgressionPanel.CancelInteraction(); panel.Append(characterProgressionPanel); } } else if (characterProgressionPanel.Parent is not null) { characterProgressionPanel.CancelInteraction(); panel.RemoveChild(characterProgressionPanel); } if (showEquipmentTree) { if (equipmentProgressionPanel.Parent is null) { equipmentProgressionPanel.DeactivateInteraction(); panel.Append(equipmentProgressionPanel); } } else if (equipmentProgressionPanel.Parent is not null) { equipmentProgressionPanel.DeactivateInteraction(); panel.RemoveChild(equipmentProgressionPanel); } } private void SetRecipeHelpVisible(bool visible, bool playSound) { if (visible) CancelTransientInteraction(); recipeHelpVisible = visible; if (panel is null || recipeHelpPanel is null) return; if (visible) { SyncSelectedContentLayer(); RefreshRecipeHelp(); BringRecipeHelpToFront(); } else if (recipeHelpPanel.Parent is not null) { panel.RemoveChild(recipeHelpPanel); } if (!visible) SyncSelectedContentLayer(); recipeHelpButton?.SetText(Language.GetTextValue(visible ? "Mods.SoulHarvest.UI.BackToSkillTree" : "Mods.SoulHarvest.UI.RecipeHelpButton")); if (playSound) SoundEngine.PlaySound(SoundID.MenuTick); } private void BringRecipeHelpToFront() { if (!recipeHelpVisible || panel is null || recipeHelpPanel is null) return; if (recipeHelpPanel.Parent is not null) panel.RemoveChild(recipeHelpPanel); panel.Append(recipeHelpPanel); } private void RefreshRecipeHelp() { if (recipeList is null || recipeHelpPanel is null) return; recipeList.Clear(); int recipeCount = 0; Mod deathMod = ModContent.GetInstance(); for (int index = 0; index < Main.recipe.Length; index++) { Recipe recipe = Main.recipe[index]; if (recipe is null || recipe.Disabled || recipe.createItem.IsAir || recipe.createItem.ModItem?.Mod != deathMod) { continue; } recipeList.Add(new AltarRecipeEntry(recipe, layoutScale)); recipeCount++; } if (recipeHelpTitle is not null) SetConstrainedText(recipeHelpTitle, Language.GetTextValue("Mods.SoulHarvest.UI.RecipeHelpTitle", recipeCount), 0.9f, large: true); if (noRecipesText is not null) SetConstrainedText(noRecipesText, Language.GetTextValue("Mods.SoulHarvest.UI.NoModRecipes"), 0.72f, large: false); if (noRecipesText is not null) { if (recipeCount == 0 && noRecipesText.Parent is null) recipeHelpPanel.Append(noRecipesText); else if (recipeCount > 0 && noRecipesText.Parent is not null) recipeHelpPanel.RemoveChild(noRecipesText); } } internal static string FormatPrice(DeathAltarPrice price) { string key = price.Currency switch { DeathAltarCurrency.Souls => "Souls", DeathAltarCurrency.SoulEssenceRefund => "SoulEssenceRefund", _ => "SoulEssence" }; return Language.GetTextValue($"Mods.SoulHarvest.UI.UpgradeCosts.{key}", price.Amount); } }