From 8d6296676ab3e3da767fd5a94ec5419a8eb55bd4 Mon Sep 17 00:00:00 2001 From: LeonardoVAC Date: Fri, 16 Oct 2026 00:38:56 -0300 Subject: [PATCH] feat: Add item editor with prefix picker --- src/TerraSharp/Core/Hotkeys.cs | 3 + src/TerraSharp/Features/ItemFeatures.cs | 35 ++++ src/TerraSharp/UI/ItemEditor.cs | 222 +++++++++++++++++++++++ src/TerraSharp/UI/MainWindow.Pages.cs | 7 +- src/TerraSharp/UI/MainWindow.Tooltips.cs | 1 + src/TerraSharp/UI/Widgets.cs | 14 ++ src/TerraSharp/UI/WindowManager.cs | 3 +- 7 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 src/TerraSharp/UI/ItemEditor.cs diff --git a/src/TerraSharp/Core/Hotkeys.cs b/src/TerraSharp/Core/Hotkeys.cs index 3c308c1..90eed4d 100644 --- a/src/TerraSharp/Core/Hotkeys.cs +++ b/src/TerraSharp/Core/Hotkeys.cs @@ -29,6 +29,7 @@ public static class Hotkeys new(InterfaceGroup, "ItemBrowser", "Item Browser", () => WindowManager.Toggle(WindowManager.Items)), new(InterfaceGroup, "BuffBrowser", "Buff Browser", () => WindowManager.Toggle(WindowManager.Buffs)), new(InterfaceGroup, "NpcSpawner", "NPC Spawner", () => WindowManager.Toggle(WindowManager.Npcs)), + new(InterfaceGroup, "ItemEditor", "Item Editor", () => WindowManager.Toggle(WindowManager.Editor)), Toggle("Player", "GodMode", "God Mode", () => Settings.GodMode, v => Settings.GodMode = v), Toggle("Player", "GhostMode", "Ghost Mode", () => Settings.GhostMode, v => Settings.GhostMode = v), @@ -62,6 +63,8 @@ public static class Hotkeys Toggle("Items", "ItemVacuum", "Item Vacuum", () => Settings.ItemVacuum, v => Settings.ItemVacuum = v), Toggle("Items", "InstantBreak", "Instant Break", () => Settings.InstantBreak, v => Settings.InstantBreak = v), Toggle("Items", "InfItems", "Infinite Items", () => Settings.InfItems, v => Settings.InfItems = v), + new("Items", "MaxStack", "Max Stack", ItemFeatures.MaxStack), + new("Items", "Duplicate", "Duplicate Item", ItemFeatures.Duplicate), Toggle("Visuals", "Esp", "ESP", () => Settings.Esp, v => Settings.Esp = v), Toggle("Visuals", "Spelunker", "Spelunker Vision", () => Settings.Spelunker, v => Settings.Spelunker = v), diff --git a/src/TerraSharp/Features/ItemFeatures.cs b/src/TerraSharp/Features/ItemFeatures.cs index 8b8d3c8..9004ccc 100644 --- a/src/TerraSharp/Features/ItemFeatures.cs +++ b/src/TerraSharp/Features/ItemFeatures.cs @@ -1,5 +1,6 @@ using Microsoft.Xna.Framework; using Terraria; +using TerraSharp.UI; namespace TerraSharp.Features; @@ -24,4 +25,38 @@ public static class ItemFeatures drop.velocity = Vector2.Zero; } } + + const int InventorySlots = 50; + + // The cursor item, or the held item when the cursor is empty. + public static Item EditTarget => Main.mouseItem.type != 0 ? Main.mouseItem : Main.LocalPlayer.inventory[Main.LocalPlayer.selectedItem]; + + public static void MaxStack() + { + Item item = EditTarget; + if (item.type == 0 || item.stack >= item.maxStack) + return; + + item.stack = item.maxStack; + Widgets.Sound(7); + } + + public static void Duplicate() + { + Item item = EditTarget; + Item[] inventory = Main.LocalPlayer.inventory; + if (item.type == 0) + return; + + for (int i = 0; i < InventorySlots; i++) + { + if (inventory[i].type != 0) + continue; + + inventory[i] = item.Clone(); + inventory[i].favorited = false; + Widgets.Sound(7); + return; + } + } } diff --git a/src/TerraSharp/UI/ItemEditor.cs b/src/TerraSharp/UI/ItemEditor.cs new file mode 100644 index 0000000..20a6c7d --- /dev/null +++ b/src/TerraSharp/UI/ItemEditor.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Terraria; +using Terraria.ID; +#if TMODLOADER +using Terraria.ModLoader; +#endif +using TerraSharp.Features; + +namespace TerraSharp.UI; + +// Edits the cursor item, or the held item when the cursor is empty. Only type, stack and prefix survive a save. +public sealed class ItemEditor() : Window("itemEditor", "Item Editor", new Vector2(700, 100), 480, 600) +{ + const int RowHeight = 30; + + readonly Scroll _scroll = new(); + readonly Item _reference = new(); + (int Type, int Prefix) _referenceKey = (-1, -1); + int _x, _y, _width; + bool _firstGroup; + + public override void OnOpen() => Main.playerInventory = true; + + public override void DrawBody(Rectangle body) + { + if (Main.gameMenu) + { + Render.TextCentered(Widgets.SpriteBatch, "Enter a world to use this.", body, Widgets.Dim, 0.9f); + return; + } + + // Shows and edits base stats instead of the Damage Multiplier, Rapid Attack and Instant Break overrides. + HeldItemOverrides.Restore(); + Item item = ItemFeatures.EditTarget; + DrawHeader(body, item); + + int top = body.Y + 60; + var inner = new Rectangle(body.X, top, body.Width, body.Bottom - top); + Widgets.Panel(inner, Widgets.InnerBg); + if (item.type == 0) + { + Render.TextCentered(Widgets.SpriteBatch, "Hold an item or pick one up with the cursor.", inner, Widgets.Dim, 0.8f); + return; + } + + var area = new Rectangle(inner.X + 12, inner.Y + 8, inner.Width - 38, inner.Height - 16); + _y = _scroll.Begin(area); + int start = _y; + (_x, _width, _firstGroup) = (area.X, area.Width, true); + Reference(item); + DrawActions(item); + DrawPrefixes(item); + DrawStats(item); + _scroll.End(area, _y - start + 4); + } + + void DrawHeader(Rectangle body, Item item) + { + var slot = new Rectangle(body.X, body.Y, 52, 52); + Widgets.Slot(slot, false); + Render.Item(Widgets.SpriteBatch, item.type, new Rectangle(slot.X + 8, slot.Y + 8, slot.Width - 16, slot.Height - 16)); + if (item.stack > 1) + Render.Text(Widgets.SpriteBatch, item.stack.ToString(), new Vector2(slot.X + 8, slot.Bottom - 20), Color.White, 0.7f); + + string source = Main.mouseItem.type != 0 ? "Cursor item" : "Held item"; + Render.Text(Widgets.SpriteBatch, item.type == 0 ? "No item" : item.AffixName(), new Vector2(slot.Right + 12, body.Y + 4), item.type == 0 ? Widgets.Dim : Color.White, 0.95f); + Render.Text(Widgets.SpriteBatch, item.type == 0 ? source : $"{source} #{item.type}", new Vector2(slot.Right + 12, body.Y + 30), Widgets.Dim, 0.72f); + } + + // Defaults for the current type and prefix; right-click on a stat resets to these. + void Reference(Item item) + { + if (_referenceKey == (item.type, item.prefix)) + return; + + _reference.SetDefaults(item.type); + if (item.prefix > 0) + _reference.Prefix(item.prefix); + _referenceKey = (item.type, item.prefix); + } + + void Group(string title) + { + if (!_firstGroup) + _y += 8; + _firstGroup = false; + Widgets.Text(title, new Vector2(_x, _y + 2), Widgets.Header, 0.88f); + float width = Render.Measure(title, 0.88f).X; + Widgets.Separator(_x + (int)width + 12, _y + 12, _width - (int)width - 12); + _y += 30; + } + + void DrawActions(Item item) + { + Group("Actions"); + string[] labels = ["Max Stack", "Duplicate", "Reset Stats"]; + int width = (_width - 12) / labels.Length; + for (int i = 0; i < labels.Length; i++) + { + if (!Widgets.Button(new Rectangle(_x + i * (width + 6), _y + 2, width, 30), labels[i], false, 0.8f)) + continue; + + switch (i) + { + case 0: ItemFeatures.MaxStack(); break; + case 1: ItemFeatures.Duplicate(); break; + case 2: ResetStats(item); break; + } + } + _y += 38; + } + + static void ResetStats(Item item) + { + int stack = item.stack, prefix = item.prefix; + bool favorited = item.favorited; + item.SetDefaults(item.type); + if (prefix > 0) + item.Prefix(prefix); + (item.stack, item.favorited) = (stack, favorited); + } + + static IEnumerable Prefixes(Item item) + { +#if TMODLOADER + return Enumerable.Range(1, PrefixLoader.PrefixCount - 1).Where(item.CanApplyPrefix); +#else + return item.GetRollablePrefixes() ?? []; +#endif + } + + static string PrefixName(int prefix) + { +#if TMODLOADER + if (prefix >= PrefixID.Count) + return PrefixLoader.GetPrefix(prefix)?.DisplayName.Value ?? $"Prefix {prefix}"; +#endif + return Lang.prefix[prefix].Value; + } + + void DrawPrefixes(Item item) + { + int[] prefixes = [.. Prefixes(item).OrderBy(PrefixName, StringComparer.OrdinalIgnoreCase)]; + if (prefixes.Length == 0) + return; + + Group("Prefix"); + const int Columns = 3, Gap = 6; + int width = (_width - Gap * (Columns - 1)) / Columns; + int[] options = [0, .. prefixes]; + for (int i = 0; i < options.Length; i++) + { + var bounds = new Rectangle(_x + i % Columns * (width + Gap), _y + i / Columns * 34, width, 30); + if (Widgets.Button(bounds, options[i] == 0 ? "None" : PrefixName(options[i]), item.prefix == options[i], 0.76f) && item.prefix != options[i]) + ApplyPrefix(item, options[i]); + } + _y += (options.Length + Columns - 1) / Columns * 34 + 4; + } + + static void ApplyPrefix(Item item, int prefix) + { + int stack = item.stack; + bool favorited = item.favorited; + item.SetDefaults(item.type); + item.Prefix(prefix); + (item.stack, item.favorited) = (stack, favorited); + Widgets.Sound(2, -1, -1, 37); + } + + void DrawStats(Item item) + { + Group("Stats"); + bool weapon = _reference.damage > 0; + if (weapon) + { + item.damage = IntRow("Damage", item.damage, 0, Math.Max(200, _reference.damage * 10), _reference.damage); + item.crit = IntRow("Critical chance", item.crit, 0, 100, _reference.crit, "{0}%"); + item.knockBack = FloatRow("Knockback", item.knockBack, 0f, 20f, _reference.knockBack, "{0:0.0}"); + item.scale = FloatRow("Size", item.scale, 0.25f, 5f, _reference.scale, "{0:0.00}x"); + } + + if (_reference.useStyle != 0) + { + item.useTime = IntRow("Use time", item.useTime, 1, Math.Max(60, _reference.useTime), _reference.useTime, "{0} ticks"); + item.useAnimation = IntRow("Use animation", item.useAnimation, 1, Math.Max(60, _reference.useAnimation), _reference.useAnimation, "{0} ticks"); + } + + if (_reference.shoot > 0) + item.shootSpeed = FloatRow("Shot speed", item.shootSpeed, 0f, 40f, _reference.shootSpeed, "{0:0.0}"); + if (_reference.defense > 0 || _reference.accessory || _reference.headSlot >= 0 || _reference.bodySlot >= 0 || _reference.legSlot >= 0) + item.defense = IntRow("Defense", item.defense, 0, Math.Max(100, _reference.defense * 10), _reference.defense); + if (item.maxStack > 1) + item.stack = IntRow("Stack", item.stack, 1, item.maxStack, item.maxStack); + if (_reference.useStyle != 0) + item.autoReuse = BoolRow("Auto reuse", item.autoReuse); + } + + float FloatRow(string label, float value, float minimum, float maximum, float fallback, string format) + { + var row = new Rectangle(_x, _y, _width, RowHeight - 2); + string id = "itemEditor|" + label; + bool hot = Widgets.Hot(row) || Widgets.ActiveSlider == id; + if (hot) + Widgets.Tooltip = "Drag to change. Right-click to reset."; + Render.TextMid(Widgets.SpriteBatch, $"{label}: {string.Format(format, value)}", row, hot ? Widgets.Yellow : Widgets.Dim, 0.85f); + float result = Widgets.ValueBar(id, row, MathHelper.Clamp(value, minimum, maximum), minimum, maximum, fallback); + _y += RowHeight; + return Widgets.ActiveSlider == id || result != MathHelper.Clamp(value, minimum, maximum) ? result : value; + } + + int IntRow(string label, int value, int minimum, int maximum, int fallback, string format = "{0}") => (int)Math.Round(FloatRow(label, value, minimum, maximum, fallback, format.Replace("{0}", "{0:0}"))); + + bool BoolRow(string label, bool value) + { + value = Widgets.ToggleRow(new Rectangle(_x, _y, _width, 28), label, value); + _y += RowHeight; + return value; + } +} diff --git a/src/TerraSharp/UI/MainWindow.Pages.cs b/src/TerraSharp/UI/MainWindow.Pages.cs index aa4cea0..d99760a 100644 --- a/src/TerraSharp/UI/MainWindow.Pages.cs +++ b/src/TerraSharp/UI/MainWindow.Pages.cs @@ -140,8 +140,11 @@ public sealed partial class MainWindow Toggle("Sonar", ref Settings.Sonar); Group("Actions"); - if (Buttons(WindowManager.Items.Open ? "Close Item Browser" : "Item Browser") == 0) - WindowManager.Toggle(WindowManager.Items); + switch (Buttons(WindowManager.Items.Open ? "Close Item Browser" : "Item Browser", WindowManager.Editor.Open ? "Close Item Editor" : "Item Editor")) + { + case 0: WindowManager.Toggle(WindowManager.Items); break; + case 1: WindowManager.Toggle(WindowManager.Editor); break; + } } void VisualsPage() diff --git a/src/TerraSharp/UI/MainWindow.Tooltips.cs b/src/TerraSharp/UI/MainWindow.Tooltips.cs index 8c6a94d..87957c5 100644 --- a/src/TerraSharp/UI/MainWindow.Tooltips.cs +++ b/src/TerraSharp/UI/MainWindow.Tooltips.cs @@ -37,6 +37,7 @@ public sealed partial class MainWindow ["Infinite Items"] = "Keeps inventory stacks from shrinking.", ["Item Vacuum"] = "Pulls all dropped items to you.", ["Bank Buttons"] = "Piggy Bank, Safe, Defender's Forge and Void Vault buttons left of the inventory.", + ["Item Editor"] = "Change the prefix, stats and stack of the held or cursor item.", ["Auto Fishing"] = "Reels in on a bite while you hold a fishing pole.", ["Recast"] = "Casts again where you last cast by hand.", ["Catch enemies"] = "Reels in enemy catches too.", diff --git a/src/TerraSharp/UI/Widgets.cs b/src/TerraSharp/UI/Widgets.cs index 48a00e1..0b6b59e 100644 --- a/src/TerraSharp/UI/Widgets.cs +++ b/src/TerraSharp/UI/Widgets.cs @@ -227,6 +227,20 @@ public static class Widgets return true; } + public static bool ToggleRow(Rectangle row, string label, bool value) + { + bool hot = Hot(row); + if (hot && Click) + { + value = !value; + Sound(12); + } + + Render.TextMid(SpriteBatch, label, row, hot ? Yellow : value ? On : Color.White, 0.85f); + ToggleIcon(new Rectangle(row.Right - 28, row.Y + 2, 24, 24), value, hot); + return value; + } + public static void ToggleIcon(Rectangle bounds, bool on, bool hot) { Render.TextRight(SpriteBatch, on ? "On" : "Off", new Rectangle(bounds.X - 44, bounds.Y, 38, bounds.Height), on ? On : Dim * 0.75f, 0.78f); diff --git a/src/TerraSharp/UI/WindowManager.cs b/src/TerraSharp/UI/WindowManager.cs index 5917698..0eb8bc2 100644 --- a/src/TerraSharp/UI/WindowManager.cs +++ b/src/TerraSharp/UI/WindowManager.cs @@ -14,11 +14,12 @@ public static class WindowManager public static readonly ItemBrowser Items = new(); public static readonly BuffBrowser Buffs = new(); public static readonly NpcBrowser Npcs = new(); + public static readonly ItemEditor Editor = new(); const int TitleHeight = 40; const int MaxInputLength = 40; - static readonly List Stack = [Items, Buffs, Npcs, Menu]; + static readonly List Stack = [Items, Buffs, Npcs, Editor, Menu]; static Window? _dragging; static Vector2 _dragOffset;