From d68de62cf564877320b4f7806f16a58a8031a4a9 Mon Sep 17 00:00:00 2001 From: LeonardoVAC Date: Fri, 16 Oct 2026 00:39:04 -0300 Subject: [PATCH] feat: Add configurable stats overlay --- src/TerraSharp/Core/Hotkeys.cs | 1 + src/TerraSharp/Core/Settings.Json.cs | 3 + src/TerraSharp/Core/Settings.cs | 23 +++++ src/TerraSharp/Features/Stats.cs | 78 +++++++++++++++ src/TerraSharp/Patches/PlayerPatches.cs | 1 + src/TerraSharp/UI/MainWindow.Pages.cs | 4 + src/TerraSharp/UI/MainWindow.Tooltips.cs | 2 + src/TerraSharp/UI/MainWindow.cs | 2 +- src/TerraSharp/UI/StatsConfig.cs | 43 +++++++++ src/TerraSharp/UI/StatsWindow.cs | 115 +++++++++++++++++++++++ src/TerraSharp/UI/Window.cs | 13 ++- src/TerraSharp/UI/WindowManager.cs | 34 ++++++- 12 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 src/TerraSharp/Features/Stats.cs create mode 100644 src/TerraSharp/UI/StatsConfig.cs create mode 100644 src/TerraSharp/UI/StatsWindow.cs diff --git a/src/TerraSharp/Core/Hotkeys.cs b/src/TerraSharp/Core/Hotkeys.cs index c3a2e60..6c8c531 100644 --- a/src/TerraSharp/Core/Hotkeys.cs +++ b/src/TerraSharp/Core/Hotkeys.cs @@ -30,6 +30,7 @@ public static class Hotkeys 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)), + new(InterfaceGroup, "Stats", "Stats Overlay", () => WindowManager.Toggle(WindowManager.Stats)), Toggle("Player", "GodMode", "God Mode", () => Settings.GodMode, v => Settings.GodMode = v), Toggle("Player", "GhostMode", "Ghost Mode", () => Settings.GhostMode, v => Settings.GhostMode = v), diff --git a/src/TerraSharp/Core/Settings.Json.cs b/src/TerraSharp/Core/Settings.Json.cs index 497d612..e0e0670 100644 --- a/src/TerraSharp/Core/Settings.Json.cs +++ b/src/TerraSharp/Core/Settings.Json.cs @@ -20,6 +20,9 @@ public static partial class Settings [nameof(GhostMode)] = "Player.GhostMode", [nameof(DefenseBoost)] = "Player.DefenseBoost.Enabled", [nameof(DefenseMult)] = "Player.DefenseBoost.Multiplier", + [nameof(ShowStats)] = "Player.Stats.Enabled", + [nameof(StatLines)] = "Player.Stats.Lines", + [nameof(StatIcons)] = "Player.Stats.Icons", [nameof(Fly)] = "Movement.Fly.Enabled", [nameof(FlySpeed)] = "Movement.Fly.Speed", diff --git a/src/TerraSharp/Core/Settings.cs b/src/TerraSharp/Core/Settings.cs index 49c58a3..9c90ff8 100644 --- a/src/TerraSharp/Core/Settings.cs +++ b/src/TerraSharp/Core/Settings.cs @@ -19,12 +19,35 @@ public enum EspBoxStyle Filled, } +[Flags] +public enum StatLine +{ + None = 0, + Dps = 1 << 0, + PeakDps = 1 << 1, + Damage = 1 << 2, + BestHit = 1 << 3, + Kills = 1 << 4, + Time = 1 << 5, + Deaths = 1 << 6, + Minions = 1 << 7, + Sentries = 1 << 8, + Defense = 1 << 9, + DamageReduction = 1 << 10, + Crit = 1 << 11, + LifeRegen = 1 << 12, + Speed = 1 << 13, + Luck = 1 << 14, +} + // Public mutable fields are persisted through Settings.Json.cs. public static partial class Settings { public static bool GodMode, InfHealth, InfMana, InfBreath, InfFlight, InstantRespawn; public static bool AutoHeal, AutoMana, GhostMode, DefenseBoost; public static float AutoHealAt = 40f, AutoManaAt = 25f, BuffMinutes = 10f, DefenseMult = 3f; + public static bool ShowStats, StatIcons = true; + public static StatLine StatLines = StatLine.Dps | StatLine.Damage | StatLine.Kills | StatLine.Minions | StatLine.Sentries | StatLine.Defense; public static bool Fly, SuperSpeed, HighJump, NoFallDamage, LavaImmune, WaterWalk, NoKnockback, GravityFlip, ExtendedReach; public static float FlySpeed = 12f, SpeedMult = 3f; diff --git a/src/TerraSharp/Features/Stats.cs b/src/TerraSharp/Features/Stats.cs new file mode 100644 index 0000000..7eeac9c --- /dev/null +++ b/src/TerraSharp/Features/Stats.cs @@ -0,0 +1,78 @@ +using System; +using Terraria; + +namespace TerraSharp.Features; + +// Damage is the life the local player removed from NPCs, read from per-tick life changes. +public static class Stats +{ + const int DpsTicks = 180; + + static readonly int[] LastLife = new int[Main.maxNPCs], LastType = new int[Main.maxNPCs]; + static readonly int[] Recent = new int[DpsTicks]; + static int _recentIndex, _recentSum; + static int _world = -1; + static bool _wasDead; + + public static long Damage { get; private set; } + + public static int BestHit { get; private set; } + + public static int Kills { get; private set; } + + public static int Deaths { get; private set; } + + public static int Ticks { get; private set; } + + public static float Dps => _recentSum * 60f / DpsTicks; + + public static float PeakDps { get; private set; } + + public static void Reset() + { + Array.Clear(Recent, 0, Recent.Length); + (_recentIndex, _recentSum) = (0, 0); + (Damage, BestHit, Kills, Deaths, Ticks, PeakDps) = (0, 0, 0, 0, 0, 0f); + } + + // Player.Update postfix for the local player. + public static void Update(Player player) + { + if (Main.worldID != _world) + { + _world = Main.worldID; + Reset(); + } + + Ticks++; + if (player.dead && !_wasDead) + Deaths++; + _wasDead = player.dead; + + int dealt = 0; + for (int i = 0; i < Main.maxNPCs; i++) + { + NPC npc = Main.npc[i]; + int life = npc.active ? Math.Max(0, npc.life) : 0; + bool killed = !npc.active && npc.life <= 0; + // Worm segments share one life pool owned by the head. + bool counts = LastType[i] != 0 && npc.type == LastType[i] && npc.lastInteraction == Main.myPlayer && (npc.realLife < 0 || npc.realLife == i) && (npc.active || killed); + if (counts && life < LastLife[i]) + { + int hit = LastLife[i] - life; + dealt += hit; + BestHit = Math.Max(BestHit, hit); + if (life == 0 && !npc.friendly && !npc.townNPC && npc.lifeMax > 5) + Kills++; + } + + (LastType[i], LastLife[i]) = npc.active ? (npc.type, life) : (0, 0); + } + + Damage += dealt; + _recentSum += dealt - Recent[_recentIndex]; + Recent[_recentIndex] = dealt; + _recentIndex = (_recentIndex + 1) % DpsTicks; + PeakDps = Math.Max(PeakDps, Dps); + } +} diff --git a/src/TerraSharp/Patches/PlayerPatches.cs b/src/TerraSharp/Patches/PlayerPatches.cs index ba5ce88..4b0bb5e 100644 --- a/src/TerraSharp/Patches/PlayerPatches.cs +++ b/src/TerraSharp/Patches/PlayerPatches.cs @@ -49,6 +49,7 @@ internal static class PlayerUpdatePatch { Clock.Advance(); PlayerFeatures.PostUpdate(player); + Stats.Update(player); CombatFeatures.PostPlayerUpdate(player); Ballistics.Observe(); NpcMotion.Track(); diff --git a/src/TerraSharp/UI/MainWindow.Pages.cs b/src/TerraSharp/UI/MainWindow.Pages.cs index 2df3fc3..7425627 100644 --- a/src/TerraSharp/UI/MainWindow.Pages.cs +++ b/src/TerraSharp/UI/MainWindow.Pages.cs @@ -48,6 +48,10 @@ public sealed partial class MainWindow case 0: PlayerFeatures.FullHeal(); break; case 1: PlayerFeatures.TeleportSpawn(); break; } + + Group("Stats"); + if (Toggle("Stats Overlay", ref Settings.ShowStats) && Buttons("Configure Overlay") == 0) + WindowManager.StatsSetup.ShowBeside(Bounds); } void MovementPage() diff --git a/src/TerraSharp/UI/MainWindow.Tooltips.cs b/src/TerraSharp/UI/MainWindow.Tooltips.cs index 583d239..c3fc2cf 100644 --- a/src/TerraSharp/UI/MainWindow.Tooltips.cs +++ b/src/TerraSharp/UI/MainWindow.Tooltips.cs @@ -14,6 +14,8 @@ public sealed partial class MainWindow ["Auto Mana"] = "Uses Quick Mana below the set mana level.", ["Ghost Mode"] = "Enemies stop chasing you. Bosses and some flying enemies still attack.", ["Defense Multiplier"] = "Multiplies your total defense.", + ["Stats Overlay"] = "Shows the selected stats on screen. Damage, kills and deaths count from when you enter the world.", + ["Configure Overlay"] = "Choose the lines the overlay shows.", ["Fly"] = "Move through blocks with your movement keys. Up or Jump rises; Down descends.", ["Gravity Flip"] = "Walk on the ceiling.", ["Lava Immune"] = "Also protects against hot blocks.", diff --git a/src/TerraSharp/UI/MainWindow.cs b/src/TerraSharp/UI/MainWindow.cs index 12e5739..9beed85 100644 --- a/src/TerraSharp/UI/MainWindow.cs +++ b/src/TerraSharp/UI/MainWindow.cs @@ -30,7 +30,7 @@ public sealed partial class MainWindow : Window Open = true; _pages = [ - new Page("Player", PlayerPage, () => Count(Settings.GodMode, Settings.InfHealth, Settings.InfMana, Settings.InfBreath, Settings.InfFlight, Settings.InstantRespawn, Settings.AutoHeal, Settings.AutoMana, Settings.GhostMode, Settings.DefenseBoost)), + new Page("Player", PlayerPage, () => Count(Settings.GodMode, Settings.InfHealth, Settings.InfMana, Settings.InfBreath, Settings.InfFlight, Settings.InstantRespawn, Settings.AutoHeal, Settings.AutoMana, Settings.GhostMode, Settings.DefenseBoost, Settings.ShowStats)), new Page("Movement", MovementPage, () => Count(Settings.Fly, Settings.SuperSpeed, Settings.HighJump, Settings.GravityFlip, Settings.NoFallDamage, Settings.LavaImmune, Settings.WaterWalk, Settings.NoKnockback, Settings.ExtendedReach)), new Page("Combat", CombatPage, () => Count(Settings.RapidAttack, Settings.AutoSwing, Settings.DamageBoost, Settings.NoManaCost, Settings.NoSpread, Settings.Aimbot, Settings.AutoAttack, Settings.KillAura, Settings.HitMagnet, Settings.FreezeEnemies, Settings.WeakenEnemies, Settings.MagicBullets, Settings.AutoDodge)), new Page("Items", ItemsPage, () => Count(Settings.InstantBreak, Settings.InfItems, Settings.ItemVacuum, Settings.AutoSell, Settings.AutoFish, Settings.Sonar)), diff --git a/src/TerraSharp/UI/StatsConfig.cs b/src/TerraSharp/UI/StatsConfig.cs new file mode 100644 index 0000000..952be22 --- /dev/null +++ b/src/TerraSharp/UI/StatsConfig.cs @@ -0,0 +1,43 @@ +using Microsoft.Xna.Framework; +using Terraria; +using TerraSharp.Core; +using TerraSharp.Features; + +namespace TerraSharp.UI; + +public sealed class StatsConfig() : Window("statsConfig", "Stats Overlay", Vector2.Zero, 300, 640) +{ + const int RowHeight = 30; + + public override bool Popup => true; + + public void ShowBeside(Rectangle anchor) + { + Pos = new Vector2(anchor.Right + 8, anchor.Y); + WindowManager.Show(this); + } + + public override void DrawBody(Rectangle body) + { + int y = body.Y; + Render.TextMid(Widgets.SpriteBatch, "Drag the overlay while the menu is open.", new Rectangle(body.X + 2, y, body.Width, 22), Widgets.Dim * 0.8f, 0.7f); + y += 26; + + Settings.StatIcons = Widgets.ToggleRow(new Rectangle(body.X, y, body.Width, 28), "Icons", Settings.StatIcons); + y += RowHeight; + Widgets.Separator(body.X, y + 2, body.Width); + y += 10; + + foreach ((StatLine line, string label, _) in StatsWindow.Lines) + { + bool shown = Widgets.ToggleRow(new Rectangle(body.X, y, body.Width, 28), label, (Settings.StatLines & line) != 0); + Settings.StatLines = shown ? Settings.StatLines | line : Settings.StatLines & ~line; + y += RowHeight; + } + + if (Widgets.Button(new Rectangle(body.X, body.Bottom - 32, body.Width, 32), "Reset Stats", false, 0.82f)) + Stats.Reset(); + if (Widgets.Hot(new Rectangle(body.X, body.Bottom - 32, body.Width, 32))) + Widgets.Tooltip = "Clears damage, kills, deaths and session time."; + } +} diff --git a/src/TerraSharp/UI/StatsWindow.cs b/src/TerraSharp/UI/StatsWindow.cs new file mode 100644 index 0000000..419b152 --- /dev/null +++ b/src/TerraSharp/UI/StatsWindow.cs @@ -0,0 +1,115 @@ +using System; +using Microsoft.Xna.Framework; +using Terraria; +using Terraria.ID; +using TerraSharp.Core; +using TerraSharp.Features; + +namespace TerraSharp.UI; + +// Overlay without a frame; it only takes the mouse, to be dragged, while the menu is open. +public sealed class StatsWindow() : Window("stats", "", new Vector2(20, 280), PanelWidth, RowHeight) +{ + const int PanelWidth = 240, RowHeight = 24, IconSize = 20; + const float TextScale = 0.8f; + + // Main.DrawInfoAccs converts velocity to the stopwatch's miles per hour this way. + const float MilesPerHour = 216000f / 42240f; + + // Icons are the accessories and items that show or affect each stat in vanilla. + public static readonly (StatLine Line, string Label, int Icon)[] Lines = + [ + (StatLine.Dps, "DPS", ItemID.DPSMeter), + (StatLine.PeakDps, "Peak DPS", ItemID.DestroyerEmblem), + (StatLine.Damage, "Damage dealt", ItemID.WarriorEmblem), + (StatLine.BestHit, "Best hit", ItemID.AvengerEmblem), + (StatLine.Kills, "Kills", ItemID.TallyCounter), + (StatLine.Time, "Session time", ItemID.GoldWatch), + (StatLine.Deaths, "Deaths", ItemID.Tombstone), + (StatLine.Minions, "Minions", ItemID.SlimeStaff), + (StatLine.Sentries, "Sentries", ItemID.QueenSpiderStaff), + (StatLine.Defense, "Defense", ItemID.CobaltShield), + (StatLine.DamageReduction, "Damage reduction", ItemID.WormScarf), + (StatLine.Crit, "Critical chance", ItemID.EyeoftheGolem), + (StatLine.LifeRegen, "Life regen", ItemID.BandofRegeneration), + (StatLine.Speed, "Speed", ItemID.Stopwatch), + (StatLine.Luck, "Luck", ItemID.LuckPotion), + ]; + + public override bool Open { get => Settings.ShowStats; set => Settings.ShowStats = value; } + + public override bool Frameless => true; + + public override bool Interactive => WindowManager.Menu.Open && !Main.gameMenu; + + public override void DrawBody(Rectangle body) + { + if (Main.gameMenu) + return; + + if (Interactive) + Render.Outline(Widgets.SpriteBatch, body, Widgets.Hot(body) ? Widgets.Yellow * 0.6f : Color.White * 0.2f); + + Player player = Main.LocalPlayer; + int y = body.Y, shown = 0; + foreach ((StatLine line, string label, int icon) in Lines) + { + if ((Settings.StatLines & line) == 0) + continue; + + Row(new Rectangle(body.X, y, body.Width, RowHeight), label, Value(line, player), Settings.StatIcons ? icon : null); + y += RowHeight; + shown++; + } + + Height = Math.Max(1, shown) * RowHeight; + } + + static string Value(StatLine line, Player player) => line switch + { + StatLine.Dps => $"{Stats.Dps:0}", + StatLine.PeakDps => $"{Stats.PeakDps:0}", + StatLine.Damage => $"{Stats.Damage:N0}", + StatLine.BestHit => $"{Stats.BestHit:N0}", + StatLine.Kills => $"{Stats.Kills:N0}", + StatLine.Time => SessionTime(), + StatLine.Deaths => $"{Stats.Deaths}", + StatLine.Minions => $"{player.slotsMinions:0.#} / {player.maxMinions}", + StatLine.Sentries => $"{Sentries(player)} / {player.maxTurrets}", + StatLine.Defense => $"{(int)player.statDefense}", + StatLine.DamageReduction => $"{player.endurance:0%}", + StatLine.Crit => player.HeldItem.damage > 0 ? $"{player.GetWeaponCrit(player.HeldItem)}%" : "-", + StatLine.LifeRegen => $"{player.lifeRegen / 2f:0.#}/s", + StatLine.Speed => $"{player.velocity.Length() * MilesPerHour:0} mph", + StatLine.Luck => $"{player.luck:0.00}", + _ => "", + }; + + static string SessionTime() + { + TimeSpan time = TimeSpan.FromSeconds(Stats.Ticks / 60); + return $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"; + } + + static int Sentries(Player player) + { + int count = 0; + for (int i = 0; i < Main.maxProjectiles; i++) + if (Main.projectile[i] is { active: true, sentry: true } projectile && projectile.owner == player.whoAmI) + count++; + return count; + } + + static void Row(Rectangle row, string label, string value, int? icon) + { + int textX = row.X; + if (icon is { } type) + { + Render.Item(Widgets.SpriteBatch, type, new Rectangle(row.X, row.Y + (row.Height - IconSize) / 2, IconSize, IconSize)); + textX += IconSize + 6; + } + + Render.TextMid(Widgets.SpriteBatch, label, new Rectangle(textX, row.Y, row.Right - textX, row.Height), Widgets.Dim, TextScale); + Render.TextRight(Widgets.SpriteBatch, value, row, Color.White, TextScale); + } +} diff --git a/src/TerraSharp/UI/Window.cs b/src/TerraSharp/UI/Window.cs index 8be7007..8c1198a 100644 --- a/src/TerraSharp/UI/Window.cs +++ b/src/TerraSharp/UI/Window.cs @@ -12,9 +12,18 @@ public abstract class Window(string id, string title, Vector2 position, int widt public int Width { get; } = width; - public int Height { get; } = height; + public int Height { get; protected set; } = height; - public bool Open { get; set; } + public virtual bool Open { get; set; } + + // Draws only the body: no panel, title or close button. + public virtual bool Frameless => false; + + // Closes on a click outside it or when the menu closes. + public virtual bool Popup => false; + + // A window that is not interactive lets the mouse through to the game. + public virtual bool Interactive => true; public Rectangle Bounds => new((int)Pos.X, (int)Pos.Y, Width, Height); diff --git a/src/TerraSharp/UI/WindowManager.cs b/src/TerraSharp/UI/WindowManager.cs index 0e64603..c6299f9 100644 --- a/src/TerraSharp/UI/WindowManager.cs +++ b/src/TerraSharp/UI/WindowManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Terraria; @@ -17,11 +18,13 @@ public static class WindowManager public static readonly ItemEditor Editor = new(); public static readonly ItemPicker SellList = new("sellList", "Auto Sell List", new Vector2(680, 130), Settings.SellList); public static readonly ItemPicker ChestList = new("chestList", "Chest Finder Items", new Vector2(700, 140), Settings.ChestList); + public static readonly StatsWindow Stats = new(); + public static readonly StatsConfig StatsSetup = new(); const int TitleHeight = 40; const int MaxInputLength = 40; - static readonly List Stack = [Items, Buffs, Npcs, Editor, SellList, ChestList, Menu]; + static readonly List Stack = [Items, Buffs, Npcs, Editor, SellList, ChestList, Stats, Menu, StatsSetup]; static Window? _dragging; static Vector2 _dragOffset; @@ -29,11 +32,11 @@ public static class WindowManager public static bool MouseOver { get; private set; } - public static bool AnyOpen => Stack.Exists(w => w.Open); + public static bool AnyOpen => Stack.Exists(w => w.Open && w.Interactive); public static IEnumerable All => Stack; - public static bool Contains(Point point) => Stack.Exists(w => w.Open && w.Bounds.Contains(point)); + public static bool Contains(Point point) => Stack.Exists(w => w.Open && w.Interactive && w.Bounds.Contains(point)); public static void Show(Window window) { @@ -55,6 +58,10 @@ public static class WindowManager window.Open = false; Widgets.Sound(11); Unfocus(); + if (window == Menu) + foreach (Window popup in Stack) + if (popup.Popup) + popup.Open = false; } public static void Toggle(Window window) @@ -122,8 +129,10 @@ public static class WindowManager foreach (Window window in Stack) window.Pos = ClampPosition(window, window.Pos); - Window? top = Stack.FindLast(w => w.Open && w.Bounds.Contains(Widgets.Mouse)); + Window? top = Stack.FindLast(w => w.Open && w.Interactive && w.Bounds.Contains(Widgets.Mouse)); MouseOver = Widgets.InputOk && top is not null; + // Popups the click landed outside of; a button that toggles one this frame still works. + Window[] dismissed = Widgets.Click ? [.. Stack.Where(w => w.Open && w.Popup && w != top)] : []; if (Widgets.Click) if (top is null) Unfocus(); @@ -148,6 +157,8 @@ public static class WindowManager } Widgets.Blocked = false; + foreach (Window popup in dismissed) + popup.Open = false; if (Widgets.Tooltip is not null && MouseOver) Main.instance.MouseText(Widgets.Tooltip, 0, 0, -1, -1, -1, -1, 0); @@ -156,6 +167,21 @@ public static class WindowManager static void DrawFrame(Window window) { Rectangle bounds = window.Bounds; + if (window.Frameless) + { + bool hot = Widgets.Hot(bounds); + if (hot) + Widgets.Tooltip = "Drag to move"; + if (hot && Widgets.Click) + { + _dragging = window; + _dragOffset = window.Pos - new Vector2(Widgets.Mouse.X, Widgets.Mouse.Y); + } + + window.DrawBody(bounds); + return; + } + Render.Fill(Widgets.SpriteBatch, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), new Color(16, 20, 40) * 0.92f); Widgets.Panel(bounds, Widgets.WindowBg);