using SoulHarvest.Common; using SoulHarvest.Items; using SoulHarvest.Projectiles; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; using Terraria; using Terraria.Audio; using Terraria.GameContent; using Terraria.GameInput; using Terraria.ID; using Terraria.ModLoader; using Terraria.UI; namespace SoulHarvest.UI; [Autoload(Side = ModSide.Client)] public sealed class ReaperFormWheelUISystem : ModSystem { private readonly List wheelForms = []; private bool visible; private bool keyWasHeld; private ReaperFormId? hoveredForm; /// /// Lets the combat-input layer suppress overlapping keybinds while the radial /// selector owns the mouse. The wheel itself also clears item/tile use below. /// public static bool IsWheelOpen => ModContent.GetInstance().visible; /// /// Shared client-side guard for special/ultimate trigger handling. Keeping the /// check public prevents rebound combat keys from firing through the wheel, /// inventory, chat, altar, map, NPC dialogue or another vanilla UI state. /// public static bool ShouldBlockReaperCombatInput(Player player) { return !Main.dedServ && (IsWheelOpen || HasBlockingInterface(player)); } public override void Load() { wheelForms.Clear(); wheelForms.AddRange(ReaperUIData.BranchForms); } public override void Unload() { wheelForms.Clear(); visible = false; keyWasHeld = false; hoveredForm = null; } public override void UpdateUI(GameTime gameTime) { #if DEBUG // A freshly isolated test profile can spend its first world tick before // tML has repopulated mod trigger keys. Do not let that transient test // setup state abort the entire update loop. bool keyHeld = PlayerInput.Triggers.Current.KeyStatus.TryGetValue( "SoulHarvest/FormWheel", out bool debugKeyHeld) && debugKeyHeld; #else bool keyHeld = SoulHarvest.FormWheelKeybind?.Current == true; #endif Player player = Main.LocalPlayer; bool validContext = !Main.gameMenu && player.active && !player.dead && player.HeldItem.ModItem is NormalSickle; bool interfaceBlocked = HasBlockingInterface(player); if (!validContext || interfaceBlocked && !visible) { visible = false; hoveredForm = null; keyWasHeld = keyHeld; return; } if (visible && interfaceBlocked) { visible = false; hoveredForm = null; keyWasHeld = keyHeld; SoundEngine.PlaySound(SoundID.MenuClose); return; } if (keyHeld && !keyWasHeld) { visible = true; hoveredForm = null; SoundEngine.PlaySound(SoundID.MenuOpen); } if (visible && keyHeld) { player.mouseInterface = true; player.controlUseItem = false; player.controlUseTile = false; UpdateHoveredForm(player.GetModPlayer().ReaperProgression); } if (visible && !keyHeld && keyWasHeld) { // Sample the cursor again on the release frame. Previously the hover // state was only refreshed while the key was held, so a cursor move // and key release in the same frame could commit the segment that was // highlighted one tick earlier instead of the segment under the mouse. UpdateHoveredForm(player.GetModPlayer().ReaperProgression); CommitSelection(player, player.GetModPlayer().ReaperProgression); visible = false; hoveredForm = null; } keyWasHeld = keyHeld; } public override void ModifyInterfaceLayers(List layers) { int mouseTextIndex = layers.FindIndex(layer => layer.Name == "Vanilla: Mouse Text"); if (mouseTextIndex < 0) return; layers.Insert(mouseTextIndex, new LegacyGameInterfaceLayer( "SoulHarvest: Reaper Form Wheel", DrawWheel, InterfaceScaleType.UI)); } private void UpdateHoveredForm(ReaperProgressionState progression) { hoveredForm = null; if (wheelForms.Count == 0) return; Vector2 center = new(Main.screenWidth * 0.5f, Main.screenHeight * 0.5f); Vector2 mouse = Main.MouseScreen; Vector2 fromCenter = mouse - center; if (fromCenter.LengthSquared() < 54f * 54f) { hoveredForm = ReaperFormId.Base; return; } if (progression.DeathFormUnlocked && GetDeathButtonBounds(center, GetWheelRadius()).Contains(mouse.ToPoint())) { hoveredForm = ReaperFormId.Death; return; } if (fromCenter.LengthSquared() > 225f * 225f) return; float radius = GetWheelRadius(); float bestDistance = float.MaxValue; foreach ((ReaperFormId form, Vector2 position) in EnumeratePositions(center, radius)) { float distance = Vector2.DistanceSquared(mouse, position); if (distance >= bestDistance) continue; bestDistance = distance; hoveredForm = form; } if (bestDistance > 58f * 58f) hoveredForm = null; } private void CommitSelection(Player player, ReaperProgressionState progression) { if (hoveredForm is not ReaperFormId form || !ReaperUIData.IsUnlocked(progression, form)) { SoundEngine.PlaySound(SoundID.MenuClose); return; } if (progression.CurrentForm != form) { if (IsReaperActionInProgress(player)) { Main.NewText(Terraria.Localization.Language.GetTextValue( "Mods.SoulHarvest.UI.ReaperWheel.SwitchQueued", ReaperUIData.GetFormName(form)), ReaperUIData.GetPrimaryColor(form)); } SoulHarvest.RequestReaperFormSwitch(form); } SoundEngine.PlaySound(SoundID.MenuTick); } private static bool IsReaperActionInProgress(Player player) { return player.itemAnimation > 0 || player.itemTime > 0 || player.ownedProjectileCounts[ModContent.ProjectileType()] > 0 || player.ownedProjectileCounts[ModContent.ProjectileType()] > 0 || player.GetModPlayer().IsReaperFormAssemblyActive(); } private static bool HasBlockingInterface(Player player) { return Main.playerInventory || Main.drawingPlayerChat || Main.editSign || Main.editChest || Main.mapFullscreen || Main.ingameOptionsWindow || player.talkNPC >= 0 || player.mouseInterface || Main.InGameUI.IsVisible || ModContent.GetInstance().IsVisible; } private bool DrawWheel() { if (!visible || Main.gameMenu || wheelForms.Count == 0) return true; Player player = Main.LocalPlayer; MyPlayer modPlayer = player.GetModPlayer(); ReaperProgressionState progression = modPlayer.ReaperProgression; Texture2D pixel = TextureAssets.MagicPixel.Value; Vector2 center = new(Main.screenWidth * 0.5f, Main.screenHeight * 0.5f); float radius = GetWheelRadius(); DrawBackdrop(Main.spriteBatch, pixel, center, radius, progression.DeathFormUnlocked); foreach ((ReaperFormId form, Vector2 position) in EnumeratePositions(center, radius)) DrawForm(Main.spriteBatch, pixel, modPlayer, progression, form, position); DrawCenter(Main.spriteBatch, pixel, progression, center); if (progression.DeathFormUnlocked) DrawDeathTerminal(Main.spriteBatch, pixel, modPlayer, progression, center, radius); return true; } private void DrawBackdrop(SpriteBatch spriteBatch, Texture2D pixel, Vector2 center, float radius, bool showDeathTerminal) { int width = (int)MathF.Round(radius * 2f + 104f); int top = (int)MathF.Round(center.Y - radius - 52f); int bottom = (int)MathF.Round(center.Y + radius + 52f); if (showDeathTerminal) bottom = GetDeathButtonBounds(center, radius).Bottom + 18; Rectangle shade = new((int)center.X - width / 2, top, width, Math.Max(1, bottom - top)); spriteBatch.Draw(pixel, shade, new Color(7, 5, 16) * 0.58f); AltarDrawHelpers.DrawBorder(spriteBatch, pixel, shade, new Color(103, 58, 137) * 0.75f, 2); foreach ((ReaperFormId form, Vector2 position) in EnumeratePositions(center, radius)) { Vector2 delta = position - center; Color color = ReaperUIData.GetPrimaryColor(form) * (hoveredForm == form ? 0.62f : 0.25f); spriteBatch.Draw(pixel, center + delta.SafeNormalize(Vector2.UnitX) * 48f, null, color, delta.ToRotation(), Vector2.Zero, new Vector2(Math.Max(1f, delta.Length() - 78f) / pixel.Width, (hoveredForm == form ? 3f : 1.5f) / pixel.Height), SpriteEffects.None, 0f); } } private void DrawForm(SpriteBatch spriteBatch, Texture2D pixel, MyPlayer modPlayer, ReaperProgressionState progression, ReaperFormId form, Vector2 center) { bool unlocked = ReaperUIData.IsUnlocked(progression, form); bool selected = hoveredForm == form; bool active = progression.CurrentForm == form; Color primary = ReaperUIData.GetPrimaryColor(form); float pulse = active ? 0.82f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * 4f) * 0.16f : 0.72f; float slotSize = selected ? 68f : 60f; Rectangle bounds = new((int)(center.X - slotSize * 0.5f), (int)(center.Y - slotSize * 0.5f), (int)slotSize, (int)slotSize); spriteBatch.Draw(pixel, bounds, (unlocked ? Color.Lerp(new Color(24, 18, 38), primary, selected ? 0.30f : 0.14f) : new Color(24, 22, 31)) * 0.98f); AltarDrawHelpers.DrawBorder(spriteBatch, pixel, bounds, unlocked ? primary * (selected ? 1f : pulse) : new Color(74, 69, 82), selected || active ? 3 : 1); DrawFormIcon( spriteBatch, form, center, selected ? 48f : 42f, unlocked ? Color.White : new Color(80, 80, 80)); string stageText = form == ReaperFormId.Death ? (unlocked ? "III+" : "—") : unlocked ? $"{(int)progression.GetStage(form)}/3" : "—"; Utils.DrawBorderString(spriteBatch, stageText, center + new Vector2(15f, 20f), unlocked ? Color.White : new Color(130, 124, 137), 0.50f); bool ultimateUnlocked = form == ReaperFormId.Death ? progression.DeathFormUnlocked : progression.GetStage(form) >= ReaperStage.StageIII; string energyText = unlocked ? $"{(int)MathF.Floor(modPlayer.GetReaperEnergy(form))}%" : "--"; Utils.DrawBorderString(spriteBatch, energyText, center + new Vector2(-27f, 20f), ultimateUnlocked ? ReaperUIData.GetSecondaryColor(form) : new Color(116, 108, 126), 0.47f); if (selected) { string name = ReaperUIData.GetFormName(form); Vector2 size = FontAssets.MouseText.Value.MeasureString(name) * 0.66f; Utils.DrawBorderString(spriteBatch, name, new Vector2(center.X - size.X * 0.5f, bounds.Bottom + 7f), unlocked ? primary : new Color(150, 142, 157), 0.66f); } } private void DrawCenter(SpriteBatch spriteBatch, Texture2D pixel, ReaperProgressionState progression, Vector2 center) { bool baseSelected = hoveredForm == ReaperFormId.Base; bool active = progression.CurrentForm == ReaperFormId.Base; Color primary = ReaperUIData.GetPrimaryColor(ReaperFormId.Base); const int centerSize = 94; Rectangle bounds = new((int)center.X - centerSize / 2, (int)center.Y - centerSize / 2, centerSize, centerSize); Color centerBackground = baseSelected ? Color.Lerp(new Color(15, 11, 27), primary, 0.28f) : new Color(15, 11, 27); spriteBatch.Draw(pixel, bounds, centerBackground * 0.99f); float pulse = active ? 0.78f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * 4f) * 0.17f : 0.62f; AltarDrawHelpers.DrawBorder(spriteBatch, pixel, bounds, primary * (baseSelected ? 1f : pulse), baseSelected || active ? 3 : 2); DrawFormIcon( spriteBatch, ReaperFormId.Base, center - Vector2.UnitY * 8f, baseSelected ? 56f : 50f, Color.White); string name = ReaperUIData.GetFormName(ReaperFormId.Base); float nameScale = 0.58f; string fitted = AltarDrawHelpers.FitText(name, bounds.Width - 10f, nameScale); Vector2 nameSize = FontAssets.MouseText.Value.MeasureString(fitted) * nameScale; Utils.DrawBorderString(spriteBatch, fitted, new Vector2(center.X - nameSize.X * 0.5f, bounds.Bottom - 23f), Color.White, nameScale); string hint = Terraria.Localization.Language.GetTextValue("Mods.SoulHarvest.UI.ReaperWheel.ReleaseHint"); float hintScale = 0.48f; Vector2 hintSize = FontAssets.MouseText.Value.MeasureString(hint) * hintScale; Utils.DrawBorderString(spriteBatch, hint, new Vector2(center.X - hintSize.X * 0.5f, bounds.Bottom + 15f), new Color(190, 178, 207), hintScale); } private void DrawDeathTerminal( SpriteBatch spriteBatch, Texture2D pixel, MyPlayer modPlayer, ReaperProgressionState progression, Vector2 wheelCenter, float radius) { Rectangle bounds = GetDeathButtonBounds(wheelCenter, radius); bool selected = hoveredForm == ReaperFormId.Death; bool active = progression.CurrentForm == ReaperFormId.Death; Color primary = ReaperUIData.GetPrimaryColor(ReaperFormId.Death); Color secondary = ReaperUIData.GetSecondaryColor(ReaperFormId.Death); float pulse = active ? 0.76f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * 5f) * 0.18f : 0.66f; Rectangle shadow = bounds; shadow.Inflate(selected ? 5 : 3, selected ? 4 : 2); spriteBatch.Draw(pixel, shadow, new Color(70, 3, 18) * 0.70f); spriteBatch.Draw(pixel, bounds, Color.Lerp(new Color(31, 7, 18), primary, selected ? 0.34f : 0.18f)); AltarDrawHelpers.DrawBorder(spriteBatch, pixel, bounds, primary * (selected ? 1f : pulse), selected || active ? 3 : 2); DrawFormIcon( spriteBatch, ReaperFormId.Death, new Vector2(bounds.Left + 29f, bounds.Center.Y), selected ? 45f : 40f, Color.White); string name = ReaperUIData.GetFormName(ReaperFormId.Death); const float nameScale = 0.61f; string fitted = AltarDrawHelpers.FitText(name, bounds.Width - 66f, nameScale); Utils.DrawBorderString(spriteBatch, fitted, new Vector2(bounds.Left + 57f, bounds.Top + 8f), Color.White, nameScale); string energy = $"III+ · {(int)MathF.Floor(modPlayer.GetReaperEnergy(ReaperFormId.Death))}%"; Utils.DrawBorderString(spriteBatch, energy, new Vector2(bounds.Left + 58f, bounds.Top + 30f), secondary, 0.49f); } private IEnumerable<(ReaperFormId Form, Vector2 Position)> EnumeratePositions(Vector2 center, float radius) { for (int index = 0; index < wheelForms.Count; index++) { float angle = -MathHelper.PiOver2 + index * MathHelper.TwoPi / wheelForms.Count; yield return (wheelForms[index], center + angle.ToRotationVector2() * radius); } } private static void DrawFormIcon( SpriteBatch spriteBatch, ReaperFormId form, Vector2 center, float maximumSize, Color color) { // The legacy form ModItems deliberately share NormalSickle.Texture so old // saves retain one physical shell. Constructing those items therefore // always drew the base icon. Form presentation must address the retained // artwork directly instead of going through TextureAssets.Item. Texture2D texture = ReaperUIData.GetFormTexture(form); Rectangle frame = texture.Frame(); float scale = Math.Min(1f, maximumSize / Math.Max(frame.Width, frame.Height)); spriteBatch.Draw( texture, center, frame, color, 0f, frame.Size() * 0.5f, scale, SpriteEffects.None, 0f); } private static Rectangle GetDeathButtonBounds(Vector2 center, float radius) { const int width = 166; const int height = 56; int buttonCenterY = (int)MathF.Round(center.Y + radius + 92f); return new Rectangle((int)MathF.Round(center.X) - width / 2, buttonCenterY - height / 2, width, height); } private static float GetWheelRadius() => Math.Clamp(Math.Min(Main.screenWidth, Main.screenHeight) * 0.19f, 118f, 166f); }