From 9d748d679b9d0061f140fd50ebfd79b5b541287e Mon Sep 17 00:00:00 2001 From: LeonardoVAC Date: Fri, 16 Oct 2026 01:37:26 -0300 Subject: [PATCH] feat: Add player list, player inspector and party frames --- src/TerraSharp/Core/Hotkeys.cs | 2 + src/TerraSharp/Core/Settings.Json.cs | 2 + src/TerraSharp/Core/Settings.cs | 1 + src/TerraSharp/Features/PlayerFeatures.cs | 7 + src/TerraSharp/Features/Spectate.cs | 46 ++++++ src/TerraSharp/Patches/VisionPatches.cs | 35 ++++ src/TerraSharp/UI/BuffBrowser.cs | 2 +- src/TerraSharp/UI/InterfaceLayers.cs | 7 +- src/TerraSharp/UI/MainWindow.Pages.cs | 6 + src/TerraSharp/UI/MainWindow.Tooltips.cs | 1 + src/TerraSharp/UI/MainWindow.cs | 2 +- src/TerraSharp/UI/PartyFrames.cs | 189 ++++++++++++++++++++++ src/TerraSharp/UI/PlayerHeads.cs | 97 +++++++++++ src/TerraSharp/UI/PlayerInspector.cs | 155 ++++++++++++++++++ src/TerraSharp/UI/PlayerList.cs | 69 ++++++++ src/TerraSharp/UI/Render.cs | 2 + src/TerraSharp/UI/Widgets.cs | 4 +- src/TerraSharp/UI/Window.cs | 3 + src/TerraSharp/UI/WindowManager.cs | 7 +- 19 files changed, 630 insertions(+), 7 deletions(-) create mode 100644 src/TerraSharp/Features/Spectate.cs create mode 100644 src/TerraSharp/UI/PartyFrames.cs create mode 100644 src/TerraSharp/UI/PlayerHeads.cs create mode 100644 src/TerraSharp/UI/PlayerInspector.cs create mode 100644 src/TerraSharp/UI/PlayerList.cs diff --git a/src/TerraSharp/Core/Hotkeys.cs b/src/TerraSharp/Core/Hotkeys.cs index 6c8c531..eb7bb26 100644 --- a/src/TerraSharp/Core/Hotkeys.cs +++ b/src/TerraSharp/Core/Hotkeys.cs @@ -31,6 +31,8 @@ public static class Hotkeys 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)), + new(InterfaceGroup, "Players", "Player List", () => WindowManager.Toggle(WindowManager.Players)), + new(InterfaceGroup, "PartyFrames", "Party Frames", () => WindowManager.Toggle(WindowManager.Party)), 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 e0e0670..1071349 100644 --- a/src/TerraSharp/Core/Settings.Json.cs +++ b/src/TerraSharp/Core/Settings.Json.cs @@ -23,6 +23,8 @@ public static partial class Settings [nameof(ShowStats)] = "Player.Stats.Enabled", [nameof(StatLines)] = "Player.Stats.Lines", [nameof(StatIcons)] = "Player.Stats.Icons", + [nameof(PartyFrames)] = "Player.PartyFrames.Enabled", + [nameof(PartySelf)] = "Player.PartyFrames.Self", [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 9c90ff8..f79ebda 100644 --- a/src/TerraSharp/Core/Settings.cs +++ b/src/TerraSharp/Core/Settings.cs @@ -47,6 +47,7 @@ public static partial class Settings 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 bool PartyFrames, PartySelf; 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; diff --git a/src/TerraSharp/Features/PlayerFeatures.cs b/src/TerraSharp/Features/PlayerFeatures.cs index 5782230..f3559ac 100644 --- a/src/TerraSharp/Features/PlayerFeatures.cs +++ b/src/TerraSharp/Features/PlayerFeatures.cs @@ -284,6 +284,13 @@ public static class PlayerFeatures player.fallStart = (int)(player.position.Y / 16f); } + // Same jump as the Wormhole Potion in Player.HandleSpectatingControls, without the potion. + public static void TeleportToPlayer(Player target) + { + Player player = Main.LocalPlayer; + player.UnityTeleport(player.position + target.Bottom - player.Bottom); + } + // Matches the Magic Mirror branch of Player.ItemCheck_Inner. public static void TeleportSpawn() { diff --git a/src/TerraSharp/Features/Spectate.cs b/src/TerraSharp/Features/Spectate.cs new file mode 100644 index 0000000..3a1b241 --- /dev/null +++ b/src/TerraSharp/Features/Spectate.cs @@ -0,0 +1,46 @@ +using Terraria; + +namespace TerraSharp.Features; + +public static class Spectate +{ + public static bool Available => NpcSpawner.Multiplayer; + +#if TMODLOADER + // tModLoader 1.4.4 has no spectating; SpectateCameraPatch follows this player instead. + public static int Target { get; private set; } = -1; +#else + // The client sets spectating to itself until the server confirms the target. + public static int Target => Main.LocalPlayer.spectating is var who && who != Main.myPlayer ? who : -1; +#endif + + public static void Toggle(int who) + { + if (Target == who) + Stop(); + else + Start(who); + } + + static void Start(int who) + { + if (!Available) + return; + +#if TMODLOADER + Target = who; +#else + Main.LocalPlayer.SetOrRequestSpectating(who); +#endif + } + + public static void Stop() + { +#if TMODLOADER + Target = -1; +#else + if (Main.LocalPlayer.spectating >= 0) + Main.LocalPlayer.SetOrRequestSpectating(-1); +#endif + } +} diff --git a/src/TerraSharp/Patches/VisionPatches.cs b/src/TerraSharp/Patches/VisionPatches.cs index c0b9e6d..a80beac 100644 --- a/src/TerraSharp/Patches/VisionPatches.cs +++ b/src/TerraSharp/Patches/VisionPatches.cs @@ -5,6 +5,9 @@ using MonoMod.Cil; using Terraria; using Terraria.Graphics.Light; using TerraSharp.Core; +#if TMODLOADER +using TerraSharp.Features; +#endif namespace TerraSharp.Patches; @@ -14,6 +17,9 @@ internal static class VisionPatches { FullBrightPatch.Install(hooks); ZoomPatch.Install(hooks); +#if TMODLOADER + SpectateCameraPatch.Install(hooks); +#endif } } @@ -78,3 +84,32 @@ internal static class ZoomPatch throw new MissingMethodException(context.Method.FullName, "Clamp"); } } + +#if TMODLOADER +// Centers the camera the way Player.SpectatingCameraPosition does in 1.4.5. +internal static class SpectateCameraPatch +{ + internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Main), "DoDraw_UpdateCameraPosition", (Action)UpdateCamera); + + static void UpdateCamera(Action original) + { + original(); + Guard.Run("Spectate camera", Follow); + } + + static void Follow() + { + if (Spectate.Target < 0) + return; + + if (!Plugin.Active || Main.gameMenu || !Spectate.Available || Main.player[Spectate.Target] is not { active: true } target) + { + Spectate.Stop(); + return; + } + + Vector2 center = target.Bottom + new Vector2(0f, target.gfxOffY - 21f); + Main.screenPosition = (center - new Vector2(Main.screenWidth, Main.screenHeight) / 2f).Floor(); + } +} +#endif diff --git a/src/TerraSharp/UI/BuffBrowser.cs b/src/TerraSharp/UI/BuffBrowser.cs index 73c6e95..eb714ed 100644 --- a/src/TerraSharp/UI/BuffBrowser.cs +++ b/src/TerraSharp/UI/BuffBrowser.cs @@ -45,7 +45,7 @@ public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vec static bool IsMount(int type) => BuffID.Sets.MountType[type] >= 0; #endif - static string FormatTime(int ticks) + public static string FormatTime(int ticks) { int seconds = ticks / 60; return seconds >= 3600 ? $"{seconds / 3600}h" : seconds >= 60 ? $"{seconds / 60}m" : $"{seconds}s"; diff --git a/src/TerraSharp/UI/InterfaceLayers.cs b/src/TerraSharp/UI/InterfaceLayers.cs index a81b6b1..dda9871 100644 --- a/src/TerraSharp/UI/InterfaceLayers.cs +++ b/src/TerraSharp/UI/InterfaceLayers.cs @@ -31,6 +31,7 @@ public static class InterfaceLayers return; layers.RemoveAll(l => l.Name.StartsWith(Prefix, StringComparison.Ordinal)); + PlayerHeads.Install(); layers.Insert(0, new LegacyGameInterfaceLayer(Prefix + "Under UI", DrawUnderUi, InterfaceScaleType.None)); @@ -45,7 +46,11 @@ public static class InterfaceLayers layers.Insert(mouseTextIndex < 0 ? layers.Count : mouseTextIndex, new LegacyGameInterfaceLayer(Prefix + "Windows", DrawWindows, InterfaceScaleType.UI)); } - public static void Uninstall() => Layers?.RemoveAll(l => l.Name.StartsWith(Prefix, StringComparison.Ordinal)); + public static void Uninstall() + { + Layers?.RemoveAll(l => l.Name.StartsWith(Prefix, StringComparison.Ordinal)); + PlayerHeads.Uninstall(); + } static bool DrawUnderUi() { diff --git a/src/TerraSharp/UI/MainWindow.Pages.cs b/src/TerraSharp/UI/MainWindow.Pages.cs index 7425627..9b00410 100644 --- a/src/TerraSharp/UI/MainWindow.Pages.cs +++ b/src/TerraSharp/UI/MainWindow.Pages.cs @@ -52,6 +52,12 @@ public sealed partial class MainWindow Group("Stats"); if (Toggle("Stats Overlay", ref Settings.ShowStats) && Buttons("Configure Overlay") == 0) WindowManager.StatsSetup.ShowBeside(Bounds); + + Group("Party"); + if (Toggle("Party Frames", ref Settings.PartyFrames)) + Sub(() => Toggle("Include yourself", ref Settings.PartySelf)); + if (Buttons(WindowManager.Players.Open ? "Close Player List" : "Player List") == 0) + WindowManager.Toggle(WindowManager.Players); } void MovementPage() diff --git a/src/TerraSharp/UI/MainWindow.Tooltips.cs b/src/TerraSharp/UI/MainWindow.Tooltips.cs index c3fc2cf..9c6d498 100644 --- a/src/TerraSharp/UI/MainWindow.Tooltips.cs +++ b/src/TerraSharp/UI/MainWindow.Tooltips.cs @@ -14,6 +14,7 @@ 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.", + ["Party Frames"] = "Shows your teammates' health and mana on screen. Open the menu to move it.", ["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.", diff --git a/src/TerraSharp/UI/MainWindow.cs b/src/TerraSharp/UI/MainWindow.cs index 9beed85..c0af0e8 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, Settings.ShowStats)), + 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, Settings.PartyFrames)), 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/PartyFrames.cs b/src/TerraSharp/UI/PartyFrames.cs new file mode 100644 index 0000000..81bf891 --- /dev/null +++ b/src/TerraSharp/UI/PartyFrames.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Terraria; +using Terraria.ID; +using TerraSharp.Core; +using TerraSharp.Features; + +namespace TerraSharp.UI; + +// Teammate health (similar to what we have in MMOs) +public sealed class PartyFrames() : Window("party", "", new Vector2(20, 440), FrameWidth, RowHeight) +{ + const int FrameWidth = 240, RowHeight = 40, IconSize = 16; + + static readonly Color Life = new(220, 50, 50), Mana = new(70, 110, 235); + + public override bool Open { get => Settings.PartyFrames; set => Settings.PartyFrames = value; } + + public override bool Frameless => true; + + // Bounds from the last draw; input is resolved before the body draws. + readonly List _frames = [], _eyes = []; + + public override bool Interactive => !Main.gameMenu && (WindowManager.Menu.Open || _frames.Exists(frame => frame.Contains(Widgets.Mouse))); + + public override bool Draggable => WindowManager.Menu.Open && !_eyes.Exists(eye => eye.Contains(Widgets.Mouse)); + + public override void DrawBody(Rectangle body) + { + if (Main.gameMenu) + return; + + _frames.Clear(); + _eyes.Clear(); + Player local = Main.LocalPlayer; + int y = body.Y; + if (Settings.PartySelf) + y += Frame(local, body.X, y, body.Width); + + for (int i = 0; i < Main.maxPlayers && local.team > 0; i++) + if (i != Main.myPlayer && Main.player[i] is { active: true } player && player.team == local.team) + y += Frame(player, body.X, y, body.Width); + + Height = Math.Max(RowHeight, y - body.Y); + if (!WindowManager.Menu.Open) + return; + + if (y == body.Y) + Render.TextMid(Widgets.SpriteBatch, "Party frames: no teammates", body, Widgets.Dim, 0.75f); + Render.Outline(Widgets.SpriteBatch, body, Widgets.Hot(body) ? Widgets.Yellow * 0.6f : Color.White * 0.2f); + } + + // Returns the height used, including the gap below. + int Frame(Player player, int x, int y, int width) + { + int debuffs = 0; + foreach (int t in player.buffType) + if (ShownDebuff(t)) + debuffs++; + + var frame = new Rectangle(x, y, width, RowHeight - 4 + (debuffs > 0 ? IconSize + 3 : 0)); + _frames.Add(frame); + float fade = player.dead ? 0.5f : 1f; + Render.FillSoft(Widgets.SpriteBatch, frame, Color.Black * 0.45f); + if (Spectate.Target == player.whoAmI) + Render.Outline(Widgets.SpriteBatch, frame, Widgets.Yellow * 0.8f); + var eye = new Rectangle(frame.Right - 21, frame.Y + 18, IconSize, IconSize); + Actions(player, frame, eye); + + PlayerHeads.Draw(player, new Vector2(frame.X + 18, frame.Y + (RowHeight - 4) / 2f), 0.8f); + if (!player.dead && !player.HeldItem.IsAir) + Render.Item(Widgets.SpriteBatch, player.HeldItem.type, new Rectangle(eye.X, frame.Y + 2, IconSize, IconSize)); + + int barX = frame.X + 38, barWidth = frame.Right - 26 - barX; + Eye(player, eye); + + // The name line fills from the right: life, defense, potion sickness, then the name in what is left. + int right = barX + barWidth; + string life = player.dead ? DeathText(player) : $"{player.statLife}/{player.statLifeMax2}"; + right = Stat(life, right, frame.Y, player.dead ? null : GameTextures.Get("Heart"), null, (player.dead ? Widgets.Red : Color.White) * fade, null); + if (!player.dead && GameTextures.Get("Extra", 58) is { } shield) + { + int defense = (int)player.statDefense; + right = Stat($"{defense}", right - 4, frame.Y, shield, GameTextures.Frame(shield, 3, 2, 0, 0), Color.White * fade, $"{defense} {Lang.inter[10].Value}"); + } + if (player.FindBuffIndex(BuffID.PotionSickness) is var sickness and >= 0) + { + right -= IconSize + 4; + PotionSickness(player, sickness, new Rectangle(right, frame.Y + 3, IconSize, IconSize)); + } + + Render.Text(Widgets.SpriteBatch, Render.Ellipsize(player.name, 0.7f, right - barX - 6), new Vector2(barX, frame.Y + 2), Render.TeamColor(player) * fade, 0.7f); + + Bar(new Rectangle(barX, frame.Y + 21, barWidth, 8), player.dead ? 0f : player.statLife / (float)Math.Max(1, player.statLifeMax2), Life); + Bar(new Rectangle(barX, frame.Y + 31, barWidth, 3), player.dead ? 0f : player.statMana / (float)Math.Max(1, player.statManaMax2), Mana); + + if (debuffs > 0) + Debuffs(player, new Rectangle(barX, frame.Y + RowHeight - 4, barWidth, IconSize)); + return frame.Height + 4; + } + + // Clicks act only while the menu is closed; with it open, a click drags the frames. + static void Actions(Player player, Rectangle frame, Rectangle eye) + { + bool other = player.whoAmI != Main.myPlayer && Spectate.Available; + if (!other || !Widgets.Hot(frame) || eye.Contains(Widgets.Mouse)) + return; + + Widgets.Tooltip = WindowManager.Menu.Open ? "Right-click to teleport" : "Click to spectate, right-click to teleport"; + if (Widgets.Click && !WindowManager.Menu.Open) + Spectate.Toggle(player.whoAmI); + if (Widgets.RightClick && !player.dead) + PlayerFeatures.TeleportToPlayer(player); + } + + void Eye(Player player, Rectangle bounds) + { + _eyes.Add(bounds); + bool hot = Widgets.Hot(bounds); + bool inspecting = WindowManager.Inspector.Open && WindowManager.Inspector.Target == player.whoAmI; + Widgets.Icon(GameTextures.Get(inspecting ? "InventoryTickOff" : "InventoryTickOn"), bounds, null, 1f, hot ? Widgets.Yellow : Color.White * 0.7f); + if (!hot) + return; + + Widgets.Tooltip = inspecting ? "Close inspect" : "Inspect"; + if (!Widgets.Click) + return; + + if (inspecting) + WindowManager.Hide(WindowManager.Inspector); + else + WindowManager.Inspector.Inspect(player.whoAmI); + } + + // respawnTimer only counts down where the game updates it; otherwise just "Dead". + static string DeathText(Player player) => player.respawnTimer > 0 ? $"Dead {(player.respawnTimer + 59) / 60}s" : "Dead"; + + // Draws an icon and value ending at right; returns where it starts. + static int Stat(string text, int right, int y, Texture2D? icon, Rectangle? frame, Color color, string? tooltip) + { + const int statIcon = 12; + int textWidth = (int)Render.Measure(text, 0.65f).X; + Render.Text(Widgets.SpriteBatch, text, new Vector2(right - textWidth, y + 3), color, 0.65f); + if (icon is null) + return right - textWidth; + + var bounds = new Rectangle(right - textWidth - statIcon - 2, y + 4, statIcon, statIcon); + Widgets.Icon(icon, bounds, frame, 1f, color); + if (tooltip is not null && Widgets.Hot(new Rectangle(bounds.X, y + 2, right - bounds.X, IconSize))) + Widgets.Tooltip = tooltip; + return bounds.X; + } + + static void PotionSickness(Player player, int index, Rectangle bounds) + { + Render.Item(Widgets.SpriteBatch, ItemID.HealingPotion, bounds, Color.White * 0.55f); + Render.Line(Widgets.SpriteBatch, new Vector2(bounds.X, bounds.Bottom), new Vector2(bounds.Right, bounds.Y), Widgets.Red, 2f); + if (Widgets.Hot(bounds)) + Widgets.Tooltip = player.buffTime[index] > 0 ? $"{Lang.GetBuffName(BuffID.PotionSickness)}: {BuffBrowser.FormatTime(player.buffTime[index])}" : Lang.GetBuffName(BuffID.PotionSickness); + } + + // Potion sickness has its own icon next to the name. + static bool ShownDebuff(int type) => type > 0 && type != BuffID.PotionSickness && Main.debuff[type]; + + static void Debuffs(Player player, Rectangle row) + { + int x = row.X; + for (int i = 0; i < player.buffType.Length && x + IconSize <= row.Right; i++) + { + int type = player.buffType[i]; + if (!ShownDebuff(type)) + continue; + + var bounds = new Rectangle(x, row.Y, IconSize, IconSize); + Widgets.Icon(GameTextures.Get("Buff", type), bounds); + if (Widgets.Hot(bounds)) + Widgets.Tooltip = player.buffTime[i] > 0 ? $"{Lang.GetBuffName(type)}: {BuffBrowser.FormatTime(player.buffTime[i])}" : Lang.GetBuffName(type); + x += IconSize + 2; + } + } + + static void Bar(Rectangle bounds, float fraction, Color color) + { + Render.Fill(Widgets.SpriteBatch, bounds, Color.Black * 0.6f); + Render.Fill(Widgets.SpriteBatch, new Rectangle(bounds.X, bounds.Y, (int)(bounds.Width * MathHelper.Clamp(fraction, 0f, 1f)), bounds.Height), color); + } +} diff --git a/src/TerraSharp/UI/PlayerHeads.cs b/src/TerraSharp/UI/PlayerHeads.cs new file mode 100644 index 0000000..3116363 --- /dev/null +++ b/src/TerraSharp/UI/PlayerHeads.cs @@ -0,0 +1,97 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Terraria; +using Terraria.DataStructures; +using Terraria.GameContent; +using TerraSharp.Core; + +namespace TerraSharp.UI; + +// MapHeadRenderer with its own targets, so heads are always facing right (lol) +public sealed class PlayerHeads : INeedRenderTargetContent +{ + static readonly PlayerHeads Instance = new(); + + readonly PlayerHeadDrawRenderTargetContent[] _heads = new PlayerHeadDrawRenderTargetContent[Main.maxPlayers]; + bool _anyRequested; + + PlayerHeads() + { + for (int i = 0; i < _heads.Length; i++) + _heads[i] = new PlayerHeadDrawRenderTargetContent(); + } + + public static void Install() + { + Main.ContentThatNeedsRenderTargets.Remove(Instance); + Main.ContentThatNeedsRenderTargets.Add(Instance); + } + + public static void Uninstall() + { + Main.ContentThatNeedsRenderTargets.Remove(Instance); + Instance.Reset(); + } + + // Requested every frame, appearance changes show on the next frame. + public static void Draw(Player player, Vector2 center, float scale) + { + PlayerHeadDrawRenderTargetContent head = Instance._heads[player.whoAmI]; + head.UsePlayer(player); + head.UseColor(Main.GetPlayerHeadBordersColor(player)); + head.Request(); + Instance._anyRequested = true; + if (!head.IsReady) + return; + + RenderTarget2D target = head.GetTarget(); + // DrawData constructors overload on ReLogic's Asset, so set the fields directly. + var data = default(DrawData); + data.texture = target; + data.position = center; + data.sourceRect = target.Bounds; + data.color = Color.White; + data.origin = new Vector2(target.Width, target.Height) / 2f; + data.scale = new Vector2(scale); + PlayerDrawHelper.SetShaderForData(player, player.cHead, ref data); + data.Draw(Widgets.SpriteBatch); + Main.pixelShader.CurrentTechnique.Passes[0].Apply(); + } + + public bool IsReady => !_anyRequested; + + // PlayerDrawHeadSet.BoringSetup reads the direction while the target renders. + public void PrepareRenderTarget(GraphicsDevice device, SpriteBatch spriteBatch) + { + if (!_anyRequested) + return; + + _anyRequested = false; + for (int i = 0; i < _heads.Length; i++) + { + Player player = Main.player[i]; + int direction = player.direction; + player.direction = 1; + try + { + _heads[i].PrepareRenderTarget(device, spriteBatch); + } + catch (Exception e) + { + Log.Error("Player head target", e); + } + finally + { + player.direction = direction; + } + } + } + + public void Reset() + { + _anyRequested = false; + foreach (PlayerHeadDrawRenderTargetContent head in _heads) + head.Reset(); + } +} diff --git a/src/TerraSharp/UI/PlayerInspector.cs b/src/TerraSharp/UI/PlayerInspector.cs new file mode 100644 index 0000000..2ce1125 --- /dev/null +++ b/src/TerraSharp/UI/PlayerInspector.cs @@ -0,0 +1,155 @@ +using Microsoft.Xna.Framework; +using Terraria; +using Terraria.UI; + +namespace TerraSharp.UI; + +// Read-only view of another player. +public sealed class PlayerInspector() : Window("inspect", "Inspect Player", new Vector2(700, 170), 470, 300) +{ + enum Tab { Equipment, Inventory, Buffs } + + const int SlotSize = 36, SlotStep = 40, Columns = 10, ContentTop = 72, FrameHeight = 62; + + // Empty-slot icons are frames of TextureAssets.Extra[54], as ItemSlot.Draw picks them per context. + const int AccessoryIcon = 11, VanityAccessoryIcon = 2, DyeIcon = 1; + static readonly int[] ArmorIcons = [0, 6, 12], VanityArmorIcons = [3, 9, 15], MiscIcons = [4, 13, 7, 10, 17]; + + Tab _tab; + + public int Target { get; private set; } = -1; + + public void Inspect(int who) + { + Target = who; + WindowManager.Show(this); + } + + public override void DrawBody(Rectangle body) + { + if (Target < 0 || Main.player[Target] is not { active: true } player) + { + Render.TextMid(Widgets.SpriteBatch, "Player left the world.", new Rectangle(body.X + 2, body.Y, body.Width, 26), Widgets.Dim, 0.85f); + return; + } + + Render.TextMid(Widgets.SpriteBatch, Render.Ellipsize(player.name, 0.9f, body.Width), new Rectangle(body.X + 2, body.Y, body.Width, 26), Render.TeamColor(player), 0.9f); + + // The held item sits right of the tabs, on the hotbar's selected slot background. + int tabWidth = (body.Width - 12 - SlotStep) / 3; + for (int i = 0; i < 3; i++) + if (Widgets.Button(new Rectangle(body.X + i * (tabWidth + 6), body.Y + 32, tabWidth, 30), ((Tab)i).ToString(), _tab == (Tab)i, 0.82f)) + _tab = (Tab)i; + Slot(player.HeldItem, body.Right - SlotSize, body.Y + 29, selected: true); + + int x = body.X + (body.Width - (Columns * SlotStep - (SlotStep - SlotSize))) / 2, y = body.Y + ContentTop; + int height = _tab switch + { + Tab.Equipment => DrawEquipment(player, x, y), + Tab.Inventory => DrawInventory(player, x, y), + _ => DrawBuffs(player, x, y, body), + }; + Height = FrameHeight + ContentTop + height; + } + + // Rows follow the vanilla equipment page: armor and accessories, vanity, dyes, then misc equips and their dyes. + static int DrawEquipment(Player player, int x, int y) + { + for (int i = 0; i < Columns; i++) + { + Slot(player.armor[i], x + i * SlotStep, y, "InventoryBack3", i < 3 ? ArmorIcons[i] : AccessoryIcon); + Slot(player.armor[i + Columns], x + i * SlotStep, y + SlotStep, "InventoryBack8", i < 3 ? VanityArmorIcons[i] : VanityAccessoryIcon); + Slot(player.dye[i], x + i * SlotStep, y + 2 * SlotStep, "InventoryBack12", DyeIcon); + } + + int miscY = y + 3 * SlotStep + 8; + for (int i = 0; i < player.miscEquips.Length; i++) + { + Slot(player.miscEquips[i], x + i * SlotStep, miscY, "InventoryBack3", MiscIcons[i]); + Slot(player.miscDyes[i], x + (i + player.miscEquips.Length) * SlotStep, miscY, "InventoryBack12", DyeIcon); + } + + return 4 * SlotStep + 8; + } + + // Slots 0-49 are the main inventory, 50-53 coins and 54-57 ammo. + static int DrawInventory(Player player, int x, int y) + { + for (int i = 0; i < 50; i++) + Slot(player.inventory[i], x + i % Columns * SlotStep, y + i / Columns * SlotStep, selected: i == player.selectedItem); + + int extraY = y + 5 * SlotStep + 8; + for (int i = 0; i < 8; i++) + Slot(player.inventory[50 + i], x + (i < 4 ? i : i + 2) * SlotStep, extraY); + + return 6 * SlotStep + 8; + } + + static int DrawBuffs(Player player, int x, int y, Rectangle body) + { + int shown = 0; + for (int i = 0; i < player.buffType.Length; i++) + { + int type = player.buffType[i]; + if (type <= 0) + continue; + + var bounds = new Rectangle(x + shown % Columns * SlotStep, y + shown / Columns * SlotStep, SlotSize, SlotSize); + bool hot = Widgets.Hot(bounds); + Widgets.Slot(bounds, hot); + Widgets.Icon(GameTextures.Get("Buff", type), new Rectangle(bounds.X + 4, bounds.Y + 4, 28, 28)); + // Remote buff times are only known when the game syncs them. + if (player.buffTime[i] > 0 && !Main.buffNoTimeDisplay[type]) + Render.TextCentered(Widgets.SpriteBatch, BuffBrowser.FormatTime(player.buffTime[i]), new Rectangle(bounds.X, bounds.Bottom - 12, bounds.Width, 14), Color.White, 0.6f); + if (hot) + Widgets.Tooltip = $"{Lang.GetBuffName(type)}\n{Lang.GetBuffDescription(type)}"; + shown++; + } + + if (shown == 0) + { + Render.TextMid(Widgets.SpriteBatch, "No active buffs", new Rectangle(body.X + 2, y, body.Width, SlotSize), Widgets.Dim * 0.8f, 0.8f); + return SlotStep; + } + + return (shown + Columns - 1) / Columns * SlotStep; + } + + static void Slot(Item item, int x, int y, string background = "InventoryBack", int emptyIcon = -1, bool selected = false) + { + var bounds = new Rectangle(x, y, SlotSize, SlotSize); + bool hot = Widgets.Hot(bounds); + Widgets.Slot(bounds, hot, selected, background); + if (item.IsAir) + { + EmptyIcon(bounds, emptyIcon); + return; + } + + Render.Item(Widgets.SpriteBatch, item.type, new Rectangle(bounds.X + 5, bounds.Y + 5, bounds.Width - 10, bounds.Height - 10)); + if (item.stack > 1) + Render.Text(Widgets.SpriteBatch, item.stack.ToString(), new Vector2(bounds.X + 4, bounds.Bottom - 16), Color.White, 0.6f); + if (!hot) + return; + +#if TMODLOADER + ItemSlot.MouseHover(ref item, ItemSlot.Context.InventoryItem); +#else + ItemSlot.MouseHover(item, ItemSlot.Context.InventoryItem); +#endif + } + + // Frames are square, so the row count follows from the sheet size; it differs between 1.4.4 and 1.4.5. + static void EmptyIcon(Rectangle bounds, int icon) + { + if (icon < 0 || GameTextures.Get("Extra", 54) is not { } sheet) + return; + + int size = sheet.Width / 3; + var frame = new Rectangle(icon % 3 * size, icon / 3 * size, size - 2, size - 2); + if (frame.Bottom > sheet.Height) + return; + + Widgets.Icon(sheet, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), frame, 1f, Color.White * 0.35f); + } +} diff --git a/src/TerraSharp/UI/PlayerList.cs b/src/TerraSharp/UI/PlayerList.cs new file mode 100644 index 0000000..4812344 --- /dev/null +++ b/src/TerraSharp/UI/PlayerList.cs @@ -0,0 +1,69 @@ +using Microsoft.Xna.Framework; +using Terraria; +using TerraSharp.Features; + +namespace TerraSharp.UI; + +public sealed class PlayerList() : Window("players", "Players", new Vector2(640, 150), 600, 440) +{ + const int RowHeight = 48, ButtonWidth = 84, ButtonGap = 6; + + readonly Scroll _scroll = new(); + + public override void DrawBody(Rectangle body) + { + int online = 0; + for (int i = 0; i < Main.maxPlayers; i++) + if (Main.player[i].active) + online++; + + Render.TextMid(Widgets.SpriteBatch, $"{online} online", new Rectangle(body.X + 2, body.Y, body.Width, 26), Widgets.Dim, 0.82f); + + var area = new Rectangle(body.X, body.Y + 32, body.Width - 22, body.Height - 32); + int y = _scroll.Begin(area), top = y; + for (int i = 0; i < Main.maxPlayers; i++) + { + if (!Main.player[i].active) + continue; + + Row(Main.player[i], new Rectangle(area.X, y, area.Width, RowHeight - 4)); + y += RowHeight; + } + + _scroll.End(area, y - top); + } + + static void Row(Player player, Rectangle row) + { + Widgets.Panel(row, Widgets.InnerBg); + PlayerHeads.Draw(player, new Vector2(row.X + 24, row.Y + row.Height / 2f), 1f); + + int textX = row.X + 50, buttonsX = row.Right - 3 * (ButtonWidth + ButtonGap); + bool local = player.whoAmI == Main.myPlayer; + string name = Render.Ellipsize(local ? player.name + " (you)" : player.name, 0.85f, buttonsX - textX - 8); + Render.Text(Widgets.SpriteBatch, name, new Vector2(textX, row.Y + 4), Render.TeamColor(player), 0.85f); + Render.Text(Widgets.SpriteBatch, Details(player, local), new Vector2(textX, row.Y + 24), player.dead ? Widgets.Red : Widgets.Dim, 0.72f); + + var button = new Rectangle(buttonsX, row.Y + (row.Height - 28) / 2, ButtonWidth, 28); + if (!local && !player.dead && Spectate.Available && Widgets.Button(button, "Teleport", false, 0.78f)) + PlayerFeatures.TeleportToPlayer(player); + + button.X += ButtonWidth + ButtonGap; + if (!local && Spectate.Available && Widgets.Button(button, Spectate.Target == player.whoAmI ? "Stop" : "Spectate", Spectate.Target == player.whoAmI, 0.78f)) + Spectate.Toggle(player.whoAmI); + + button.X += ButtonWidth + ButtonGap; + bool inspecting = WindowManager.Inspector.Open && WindowManager.Inspector.Target == player.whoAmI; + if (Widgets.Button(button, "Inspect", inspecting, 0.78f)) + WindowManager.Inspector.Inspect(player.whoAmI); + } + + static string Details(Player player, bool local) + { + if (player.dead) + return "Dead"; + + string stats = $"Life {player.statLife}/{player.statLifeMax2} Mana {player.statMana}/{player.statManaMax2}"; + return local ? stats : $"{stats} {(int)(Vector2.Distance(player.Center, Main.LocalPlayer.Center) / 16f)} tiles"; + } +} diff --git a/src/TerraSharp/UI/Render.cs b/src/TerraSharp/UI/Render.cs index 340e8e4..94a2a0e 100644 --- a/src/TerraSharp/UI/Render.cs +++ b/src/TerraSharp/UI/Render.cs @@ -142,5 +142,7 @@ public static class Render spriteBatch.Draw(texture, new Vector2(bounds.X + (bounds.Width - size.X) / 2f, bounds.Y + (bounds.Height - size.Y) / 2f), frame, color ?? Color.White, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f); } + public static Color TeamColor(Player player) => player.team > 0 ? Main.teamColor[player.team] : Color.White; + public static Vector2 ToScreen(Vector2 world) => Vector2.Transform(world - Main.screenPosition, Main.GameViewMatrix.TransformationMatrix); } diff --git a/src/TerraSharp/UI/Widgets.cs b/src/TerraSharp/UI/Widgets.cs index 0b6b59e..7d66649 100644 --- a/src/TerraSharp/UI/Widgets.cs +++ b/src/TerraSharp/UI/Widgets.cs @@ -261,9 +261,9 @@ public static class Widgets Render.Fill(SpriteBatch, new Rectangle(bounds.X + 1, bounds.Y + 1, bounds.Width - 2, bounds.Height - 2), color); } - public static void Slot(Rectangle bounds, bool hot, bool selected = false) + public static void Slot(Rectangle bounds, bool hot, bool selected = false, string background = "InventoryBack") { - if (GameTextures.Get(selected ? "InventoryBack14" : hot ? "InventoryBack15" : "InventoryBack") is { } back) + if (GameTextures.Get(selected ? "InventoryBack14" : hot ? "InventoryBack15" : background) is { } back) SpriteBatch.Draw(back, bounds, SlotTint); else Panel(bounds, ButtonBg); diff --git a/src/TerraSharp/UI/Window.cs b/src/TerraSharp/UI/Window.cs index 8c1198a..3189580 100644 --- a/src/TerraSharp/UI/Window.cs +++ b/src/TerraSharp/UI/Window.cs @@ -25,6 +25,9 @@ public abstract class Window(string id, string title, Vector2 position, int widt // A window that is not interactive lets the mouse through to the game. public virtual bool Interactive => true; + // Whether a click on a frameless window starts dragging it. + public virtual bool Draggable => true; + public Rectangle Bounds => new((int)Pos.X, (int)Pos.Y, Width, Height); public abstract void DrawBody(Rectangle body); diff --git a/src/TerraSharp/UI/WindowManager.cs b/src/TerraSharp/UI/WindowManager.cs index c6299f9..62f7014 100644 --- a/src/TerraSharp/UI/WindowManager.cs +++ b/src/TerraSharp/UI/WindowManager.cs @@ -20,11 +20,14 @@ public static class WindowManager 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(); + public static readonly PlayerList Players = new(); + public static readonly PlayerInspector Inspector = new(); + public static readonly PartyFrames Party = new(); const int TitleHeight = 40; const int MaxInputLength = 40; - static readonly List Stack = [Items, Buffs, Npcs, Editor, SellList, ChestList, Stats, Menu, StatsSetup]; + static readonly List Stack = [Party, Items, Buffs, Npcs, Editor, SellList, ChestList, Stats, Players, Inspector, Menu, StatsSetup]; static Window? _dragging; static Vector2 _dragOffset; @@ -169,7 +172,7 @@ public static class WindowManager Rectangle bounds = window.Bounds; if (window.Frameless) { - bool hot = Widgets.Hot(bounds); + bool hot = Widgets.Hot(bounds) && window.Draggable; if (hot) Widgets.Tooltip = "Drag to move"; if (hot && Widgets.Click)