feat: Add configurable stats overlay

This commit is contained in:
VAC committed 2026-10-16 00:39:04 -03:00
1 parent 6701dd0305
commit d68de62cf5
12 files changed
+312 -7

No files matched your search

+1
View File
@@ -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),
+3
View File
@@ -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",
+23
View File
@@ -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;
+78
View File
@@ -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);
}
}
+1
View File
@@ -49,6 +49,7 @@ internal static class PlayerUpdatePatch
{
Clock.Advance();
PlayerFeatures.PostUpdate(player);
Stats.Update(player);
CombatFeatures.PostPlayerUpdate(player);
Ballistics.Observe();
NpcMotion.Track();
+4
View File
@@ -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()
+2
View File
@@ -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.",
+1 -1
View File
@@ -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)),
+43
View File
@@ -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.";
}
}
+115
View File
@@ -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);
}
}
+11 -2
View File
@@ -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);
+30 -4
View File
@@ -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<Window> Stack = [Items, Buffs, Npcs, Editor, SellList, ChestList, Menu];
static readonly List<Window> 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<Window> 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);