Compare commits

..
10 Commits
30 changed files with 1360 additions and 93 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<Version>0.2.0</Version>
<TargetFrameworks>net48;net8.0</TargetFrameworks>
<PlatformTarget Condition="'$(TargetFramework)' == 'net48'">x86</PlatformTarget>
<PlatformTarget Condition="'$(TargetFramework)' == 'net8.0'">x64</PlatformTarget>
+3
View File
@@ -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),
@@ -67,6 +69,7 @@ public static class Hotkeys
Toggle("Items", "InstantBreak", "Instant Break", () => Settings.InstantBreak, v => Settings.InstantBreak = v),
Toggle("Items", "InfItems", "Infinite Items", () => Settings.InfItems, v => Settings.InfItems = v),
Toggle("Items", "AutoSell", "Auto Sell", () => Settings.AutoSell, v => Settings.AutoSell = v),
Toggle("Items", "AutoDeposit", "Auto Deposit", () => Settings.AutoDeposit, v => Settings.AutoDeposit = v),
new("Items", "MaxStack", "Max Stack", ItemFeatures.MaxStack),
new("Items", "Duplicate", "Duplicate Item", ItemFeatures.Duplicate),
+3
View File
@@ -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",
@@ -68,6 +70,7 @@ public static partial class Settings
[nameof(InfItems)] = "Items.InfiniteItems",
[nameof(ItemVacuum)] = "Items.Vacuum",
[nameof(AutoSell)] = "Items.AutoSell.Enabled",
[nameof(AutoDeposit)] = "Items.AutoDeposit",
[nameof(BankButtons)] = "Items.BankButtons",
[nameof(AutoFish)] = "Fishing.Enabled",
+2 -1
View File
@@ -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;
@@ -61,7 +62,7 @@ public static partial class Settings
public static bool Aimbot, AimPrediction = true, AimDrawTarget = true, AutoAttack, AimExact = true, ShotPreview, MagicBullets, AutoDodge;
public static float AimFov = 250f, AimRange = 60f;
public static bool InstantBreak, InfItems, ItemVacuum, AutoSell, BankButtons = true;
public static bool InstantBreak, InfItems, ItemVacuum, AutoSell, AutoDeposit, BankButtons = true;
public static readonly HashSet<int> SellList = [];
public static bool AutoFish, AutoRecast = true, FishEnemies, Sonar;
+234 -33
View File
@@ -2,31 +2,53 @@ using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using TerraSharp.Core;
using TerraSharp.Simulation;
namespace TerraSharp.Features.Combat;
// Steers the local player out of predicted projectile and contact hits.
// Steers the local player out of predicted projectile and contact hits, using dashes and teleport rods when running and jumping are not enough.
public static class AutoDodge
{
const int Horizon = 24;
const int ContactHorizon = 12;
const int MaxThreats = 48;
const float ScanRange = 60f * 16f;
const float Margin = 6f;
const int WallCheckTicks = 6;
const int JumpCooldown = 12;
const float Gravity = 0.4f, MaxFall = 10f, FastFall = 8f;
// Slowest dash in Player.DashMovement (Tabi, Master Ninja Gear).
const float DashSpeed = 14.5f;
// Horizontal speed away from the player that counts as being knocked back, not drifting.
const float KnockbackSpeed = 1f;
// Teleport only for hits this close; a later run or jump may still avoid farther ones.
const int TeleportWithin = 8;
const int TeleportCooldown = 30;
static readonly float[] TeleportRadii = [6f, 10f, 14f, 18f];
const int TeleportDirections = 16;
const int TempleWall = 87;
sealed class Threat
{
public readonly Vector2[] Path = new Vector2[Horizon + 1];
public int Count, Width, Height;
public int Count, Width, Height, Damage;
}
readonly record struct Move(int Horizontal, int Vertical);
readonly record struct Move(int Horizontal, int Vertical, bool Dash = false);
static readonly Move[] Moves = [new(0, 0), new(-1, 0), new(1, 0), new(0, -1), new(-1, -1), new(1, -1), new(0, 1), new(-1, 1), new(1, 1)];
static readonly Move Stay = new(0, 0);
static readonly Move[] Moves = [new(-1, 0), new(1, 0), new(0, -1), new(-1, -1), new(1, -1), new(0, 1), new(-1, 1), new(1, 1)];
static readonly Move[] Dashes = [new(-1, 0, true), new(1, 0, true)];
static readonly Threat[] Pool = new Threat[MaxThreats];
static readonly List<Threat> Threats = [];
static readonly Vector2[] Trail = new Vector2[Horizon + 1];
static int _lastJump = -JumpCooldown, _lastTeleport = -TeleportCooldown;
static int _hitDamage;
static AutoDodge()
{
@@ -34,10 +56,34 @@ public static class AutoDodge
Pool[i] = new Threat();
}
// Player.HorizontalMovement prefix: controls and velocity set here drive this tick's movement.
static bool Grounded(Player player) => player.velocity.Y == 0f || player.sliding;
static bool Flying(Player player) => Settings.Fly || player.wingTime > 0f || player.rocketTime > 0 || MountFlies(player);
// Mirrors the jump conditions in Player.JumpMovement; the game itself performs the jump.
static bool CanRise(Player player) => Settings.Fly || (Clock.Tick - _lastJump >= JumpCooldown && (Grounded(player) || Flying(player) || HasExtraJump(player)));
// Player.DashMovement starts a dash only with a dash accessory, off a mount, once dashDelay is back to 0.
static bool CanDash(Player player) => player.dashType > 0 && player.dashDelay == 0 && !player.mount.Active && !Settings.Fly;
#if TMODLOADER
static bool HasExtraJump(Player player) => player.AnyExtraJumpUsable();
static bool MountFlies(Player player) => player.mount.Active && player.mount.CanFly();
static int Wall(int x, int y) => Main.tile[x, y].WallType;
#else
static bool MountFlies(Player player) => player.mount.Active && player.mount.CanFly(player);
static bool HasExtraJump(Player player) => player.canJumpAgain_Cloud || player.canJumpAgain_Sandstorm || player.canJumpAgain_Blizzard || player.canJumpAgain_Fart || player.canJumpAgain_Sail || player.canJumpAgain_Unicorn || player.canJumpAgain_Santank || player.canJumpAgain_WallOfFleshGoat || player.canJumpAgain_Basilisk;
static int Wall(int x, int y) => Main.tile[x, y]?.wall ?? 0;
#endif
// Player.HorizontalMovement prefix: DashMovement, JumpMovement and WingMovement run later this tick and read the controls set here.
public static void Steer(Player player)
{
if (!Settings.AutoDodge || player.dead)
if (!Settings.AutoDodge || Settings.GodMode || player.dead)
return;
CollectThreats(player);
@@ -45,68 +91,222 @@ public static class AutoDodge
return;
float speed = Settings.Fly ? Settings.FlySpeed : Math.Max(Math.Max(player.maxRunSpeed, player.accRunSpeed), 3f);
float jump = Player.jumpSpeed * 1.5f;
int baseline = FirstHit(player, player.velocity, false);
if (baseline > Horizon)
int bestHit = FirstHit(player, null, Stay, speed, false);
if (bestHit > Horizon)
return;
Move best = Moves[0];
int bestHit = baseline;
int incoming = _hitDamage;
Move best = Stay;
bool canRise = CanRise(player), canFall = !Grounded(player);
foreach (Move move in Moves)
{
if (move == Moves[0])
if ((move.Vertical < 0 && !canRise) || (move.Vertical > 0 && !canFall))
continue;
var velocity = new Vector2(move.Horizontal * speed, move.Vertical == 0 ? player.velocity.Y : move.Vertical * jump * player.gravDir);
int hit = FirstHit(player, velocity, true);
int hit = FirstHit(player, null, move, speed, true);
if (hit > bestHit)
(best, bestHit) = (move, hit);
}
if (best == Moves[0])
if (bestHit <= Horizon && CanDash(player))
foreach (Move move in Dashes)
{
int hit = FirstHit(player, null, move, speed, true);
if (hit > bestHit)
(best, bestHit) = (move, hit);
}
if (bestHit <= TeleportWithin && TryTeleport(player, speed, incoming))
return;
Apply(player, best, speed, jump);
if (best != Stay)
Apply(player, best, speed);
}
static void Apply(Player player, Move move, float speed, float jump)
static void Apply(Player player, Move move, float speed)
{
player.controlLeft = move.Horizontal < 0;
player.controlRight = move.Horizontal > 0;
if (move.Dash)
{
// DoCommonDashHandle reads a fresh press with dashTime already set as the second tap of a double tap.
player.dashTime = move.Horizontal * 15;
(player.releaseLeft, player.releaseRight) = (move.Horizontal < 0, move.Horizontal > 0);
return;
}
if (move.Horizontal != 0)
player.velocity.X = move.Horizontal * speed;
player.controlUp = move.Vertical < 0;
player.controlUp = move.Vertical < 0 && Settings.Fly;
player.controlDown = move.Vertical > 0;
if (move.Vertical != 0)
player.velocity.Y = move.Vertical * jump * player.gravDir;
if (move.Vertical > 0)
player.velocity.Y = Math.Max(player.velocity.Y * player.gravDir, FastFall) * player.gravDir;
if (move.Vertical >= 0)
return;
player.controlJump = true;
if (Grounded(player) || !Flying(player))
{
player.releaseJump = true;
_lastJump = Clock.Tick;
}
}
// First tick a threat overlaps the player moving at this velocity, or Horizon + 1 when none do.
static int FirstHit(Player player, Vector2 velocity, bool checkWalls)
// Rod of Harmony is free; Rod of Discord costs a seventh of max life under Chaos State, so it must cost less than the hit.
static bool TryTeleport(Player player, float speed, int incoming)
{
Vector2 size = new(player.width, player.height);
if (checkWalls && TileCollision.Sweep(player.Center, player.Center + velocity * WallCheckTicks, player.width, player.height))
return -1;
if (Clock.Tick - _lastTeleport < TeleportCooldown || FindRod(player) is not { } rod)
return false;
int cost = rod == ItemID.RodofDiscord && player.chaosState ? player.statLifeMax2 / 7 : 0;
if (cost > 0 && (cost >= incoming || cost >= player.statLife))
return false;
if (SafeSpot(player, speed) is not { } spot)
return false;
Teleport(player, spot, rod);
return true;
}
static int? FindRod(Player player)
{
int? found = null;
for (int i = 0; i < 58; i++)
{
int type = player.inventory[i].type;
if (type == ItemID.RodOfHarmony)
return type;
if (type == ItemID.RodofDiscord)
found = type;
}
return found;
}
// Nearest spot that Player.ItemCheck_UseTeleportRod accepts and no threat reaches.
static Vector2? SafeSpot(Player player, float speed)
{
foreach (float radius in TeleportRadii)
for (int i = 0; i < TeleportDirections; i++)
{
float angle = MathHelper.TwoPi * i / TeleportDirections;
Vector2 spot = player.position + new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle)) * radius * 16f;
if (spot.X <= 50f || spot.X >= Main.maxTilesX * 16 - 50 || spot.Y <= 50f || spot.Y >= Main.maxTilesY * 16 - 50)
continue;
if (Collision.SolidCollision(spot, player.width, player.height))
continue;
if (Wall((int)(spot.X / 16f), (int)(spot.Y / 16f)) == TempleWall && !NPC.downedPlantBoss)
continue;
if (FirstHit(player, spot, Stay, speed, false) > Horizon)
return spot;
}
return null;
}
// The rod's effect from Player.ItemCheck_UseTeleportRod, without holding or swinging it.
static void Teleport(Player player, Vector2 spot, int rod)
{
player.Teleport(spot, 1, 0);
NetMessage.SendData(MessageID.TeleportEntity, -1, -1, null, 0, player.whoAmI, spot.X, spot.Y, 1);
_lastTeleport = Clock.Tick;
if (rod != ItemID.RodofDiscord)
return;
if (player.chaosState)
{
player.statLife -= player.statLifeMax2 / 7;
player.lifeRegenCount = 0;
player.lifeRegenTime = 0f;
}
player.AddBuff(BuffID.ChaosState, 360, true);
}
// Player positions from the current position, or from a teleport spot: jumps rise at Player.jumpSpeed for Player.jumpHeight ticks, dashes keep DashSpeed, everything else falls.
static void Predict(Player player, Vector2? spot, Move move, float speed)
{
bool moving = spot is null;
bool grounded = moving && Grounded(player);
bool fly = Settings.Fly || (move.Vertical < 0 && Flying(player) && !grounded);
float horizontal = move.Dash ? move.Horizontal * DashSpeed : move.Horizontal != 0 ? move.Horizontal * speed : moving ? player.velocity.X : 0f;
float vertical = moving ? player.velocity.Y * player.gravDir : 0f;
if (move.Vertical > 0)
vertical = Math.Max(vertical, FastFall);
Vector2 position = spot ?? player.position;
Trail[0] = position;
for (int t = 1; t <= Horizon; t++)
{
Vector2 position = player.position + velocity * t;
var bounds = new Rectangle((int)(position.X - Margin), (int)(position.Y - Margin), (int)(size.X + Margin * 2), (int)(size.Y + Margin * 2));
if (move.Vertical < 0)
vertical = fly || t <= Player.jumpHeight ? -Player.jumpSpeed : Math.Min(vertical + Gravity, MaxFall);
else if (!grounded || move.Vertical > 0)
vertical = Math.Min(vertical + Gravity, MaxFall);
if (!moving && TileCollision.BoxSolid(position + new Vector2(player.width / 2f, player.height / 2f + vertical), player.width, player.height))
vertical = 0f;
position += new Vector2(horizontal, vertical * player.gravDir);
Trail[t] = position;
}
}
// First tick a threat overlaps the player, or Horizon + 1 when none do; immunity frames count as safe. Sets _hitDamage.
static int FirstHit(Player player, Vector2? spot, Move move, float speed, bool checkWalls)
{
_hitDamage = 0;
Predict(player, spot, move, speed);
if (checkWalls && TileCollision.Sweep(player.Center, Trail[WallCheckTicks] + new Vector2(player.width / 2f, player.height / 2f), player.width, player.height))
return -1;
int safeUntil = player.immune ? player.immuneTime : 0;
for (int t = Math.Max(1, safeUntil + 1); t <= Horizon; t++)
{
Vector2 position = Trail[t];
var bounds = new Rectangle((int)(position.X - Margin), (int)(position.Y - Margin), (int)(player.width + Margin * 2), (int)(player.height + Margin * 2));
foreach (Threat threat in Threats)
{
if (t >= threat.Count)
continue;
Vector2 center = threat.Path[t];
if (bounds.Intersects(new Rectangle((int)(center.X - threat.Width / 2f), (int)(center.Y - threat.Height / 2f), threat.Width, threat.Height)))
return t;
if (!bounds.Intersects(new Rectangle((int)(center.X - threat.Width / 2f), (int)(center.Y - threat.Height / 2f), threat.Width, threat.Height)))
continue;
_hitDamage = threat.Damage;
return t;
}
}
return Horizon + 1;
}
// Main.CalculateDamagePlayersTake from 1.4.5 (missing in tModLoader), then damage reduction.
static int Taken(Player player, int damage)
{
int defense = player.statDefense;
float armor = Main.masterMode ? 1f : Main.expertMode ? 0.75f : 0.5f;
return (int)(Math.Max(1f, damage - defense * armor) * (1f - player.endurance));
}
// Enemies the current swing will knock back, or that a hit is already pushing away, are not contact threats.
static bool KnockedAway(Player player, NPC npc)
{
if (npc.knockBackResist <= 0f)
return false;
int away = Math.Sign(npc.Center.X - player.Center.X);
if (npc.immune[player.whoAmI] > 0 && npc.velocity.X * away > KnockbackSpeed)
return true;
Item item = player.HeldItem;
if (player.itemAnimation <= 0 || item.damage <= 0 || item.noMelee || away != player.direction)
return false;
float reach = Math.Max(item.width, item.height) * item.scale + 8f;
float gap = Math.Abs(npc.Center.X - player.Center.X) - (npc.width + player.width) / 2f;
return gap <= reach && Math.Abs(npc.Center.Y - player.Center.Y) <= (npc.height + player.height) / 2f + reach;
}
static void CollectThreats(Player player)
{
Threats.Clear();
@@ -122,20 +322,21 @@ public static class AutoDodge
Threat threat = Pool[Threats.Count];
threat.Count = Ballistics.Path(Ballistics.Get(projectile.type), projectile.Center, velocity, age, Horizon, threat.Path);
(threat.Width, threat.Height) = (Math.Max(4, projectile.width), Math.Max(4, projectile.height));
// Projectile.Damage doubles hostile projectile damage against players.
(threat.Width, threat.Height, threat.Damage) = (Math.Max(4, projectile.width), Math.Max(4, projectile.height), Taken(player, projectile.damage * 2));
Threats.Add(threat);
}
for (int i = 0; i < Main.maxNPCs && Threats.Count < MaxThreats; i++)
{
NPC npc = Main.npc[i];
if (!Hostility.IsHostile(npc) || npc.damage <= 0 || Vector2.DistanceSquared(npc.Center, player.Center) > rangeSq)
if (!Hostility.IsHostile(npc) || npc.damage <= 0 || Vector2.DistanceSquared(npc.Center, player.Center) > rangeSq || KnockedAway(player, npc))
continue;
Threat threat = Pool[Threats.Count];
for (int t = 0; t <= Horizon; t++)
for (int t = 0; t <= ContactHorizon; t++)
threat.Path[t] = npc.Center + NpcMotion.Offset(npc, t);
(threat.Count, threat.Width, threat.Height) = (Horizon + 1, npc.width, npc.height);
(threat.Count, threat.Width, threat.Height, threat.Damage) = (ContactHorizon + 1, npc.width, npc.height, Taken(player, npc.damage));
Threats.Add(threat);
}
}
+63
View File
@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using TerraSharp.Core;
using TerraSharp.UI;
@@ -81,4 +84,64 @@ public static class ItemFeatures
if (sold)
Widgets.Sound(CoinSound);
}
const int CarriedSlots = 58;
static readonly int[] Coins = [ItemID.PlatinumCoin, ItemID.GoldCoin, ItemID.SilverCoin, ItemID.CopperCoin];
static long CoinValue(int type) => type switch
{
ItemID.CopperCoin => 1,
ItemID.SilverCoin => 100,
ItemID.GoldCoin => 10_000,
ItemID.PlatinumCoin => 1_000_000,
_ => 0,
};
public static void DepositCoins(Player player)
{
if (player.HeldItem.useAmmo == AmmoID.Coin)
return;
Item[] inventory = player.inventory;
long total = 0;
for (int i = 0; i < CarriedSlots; i++)
if (!inventory[i].favorited)
total += CoinValue(inventory[i].type) * inventory[i].stack;
if (total == 0)
return;
Item[] bank = player.bank.item;
var slots = new List<int>();
for (int i = 0; i < bank.Length; i++)
{
long value = CoinValue(bank[i].type);
if (value > 0 || bank[i].type == 0)
slots.Add(i);
total += value * bank[i].stack;
}
var stacks = new List<(int Type, int Stack)>();
foreach (int coin in Coins)
{
long count = total / CoinValue(coin);
total %= CoinValue(coin);
int maxStack = ContentSamples.ItemsByType[coin].maxStack;
for (; count > 0; count -= maxStack)
stacks.Add((coin, (int)Math.Min(count, maxStack)));
}
if (stacks.Count > slots.Count)
return;
for (int i = 0; i < CarriedSlots; i++)
if (!inventory[i].favorited && CoinValue(inventory[i].type) > 0)
inventory[i].TurnToAir();
foreach (int slot in slots)
bank[slot].TurnToAir();
for (int i = 0; i < stacks.Count; i++)
{
bank[slots[i]].SetDefaults(stacks[i].Type);
bank[slots[i]].stack = stacks[i].Stack;
}
}
}
@@ -147,6 +147,8 @@ public static class PlayerFeatures
AutoPotions(player);
if (Settings.AutoSell)
ItemFeatures.AutoSell(player);
if (Settings.AutoDeposit)
ItemFeatures.DepositCoins(player);
}
if (Settings.InfFlight)
@@ -284,6 +286,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()
{
+46
View File
@@ -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
}
}
+35
View File
@@ -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<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
+6 -2
View File
@@ -11,7 +11,7 @@ using TerraSharp.Features;
namespace TerraSharp.UI;
public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vector2(660, 160), 480, 540, "Search buffs by name or ID", "buffs", new IdIndex(() => BuffCount, Lang.GetBuffName), Categories)
public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vector2(660, 160), 480, 574, "Search buffs by name or ID", "buffs", new IdIndex(() => BuffCount, Lang.GetBuffName), Categories, [])
{
const string DurationSlider = "buffDuration";
@@ -45,7 +45,11 @@ 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)
#if TMODLOADER
protected override string? ModOf(int type) => BuffLoader.GetBuff(type)?.Mod.DisplayNameClean;
#endif
public static string FormatTime(int ticks)
{
int seconds = ticks / 60;
return seconds >= 3600 ? $"{seconds / 3600}h" : seconds >= 60 ? $"{seconds / 60}m" : $"{seconds}s";
+186 -18
View File
@@ -10,19 +10,50 @@ namespace TerraSharp.UI;
// Icon is a frame of Images/UI/Creative/Infinite_Icons; -1 draws the name instead.
public sealed record BrowserCategory(string Name, Func<int, bool> Fits, int Icon = -1, BrowserCategory[]? Subcategories = null);
public abstract class GridBrowser(string id, string title, Vector2 position, int width, int height, string placeholder, string noun, IdIndex index, BrowserCategory[] categories)
// A null order keeps ID order, with name-prefix matches first while searching.
public sealed record BrowserSort(string Name, Comparison<int>? Order);
public abstract class GridBrowser(string id, string title, Vector2 position, int width, int height, string placeholder, string noun, IdIndex index, BrowserCategory[] categories, BrowserSort[] sorts)
: Window(id, title, position, width, height)
{
const int SlotSize = 44, Gap = 4, Step = SlotSize + Gap, TabHeight = 32, SubTabHeight = 24, IconTabWidth = 38;
enum Menu { None, Mod, Sort }
const int SlotSize = 44, Gap = 4, Step = SlotSize + Gap, TabHeight = 32, SubTabHeight = 24, IconTabWidth = 38, ToolbarHeight = 28, MenuRowHeight = 26, MenuRows = 10;
const float MenuScale = 0.78f;
readonly TextField _search = new(placeholder);
readonly Scroll _scroll = new();
readonly Scroll _menuScroll = new();
string _lastQuery = "";
int _category, _subcategory;
int _category, _subcategory, _mod, _sort;
readonly BrowserCategory[]?[] _subTabs = categories.Select(category => category.Subcategories?.Prepend(new BrowserCategory("All", category.Fits)).ToArray()).ToArray();
readonly BrowserSort[] _sorts = [new("ID", null), new("Name", (a, b) => string.Compare(index.Name(a), index.Name(b), StringComparison.OrdinalIgnoreCase)), .. sorts];
// Mod display name per id, null for vanilla.
string?[]? _sources;
(string? Source, string Name, int Count)[] _mods = [];
Func<int, bool>? _fits;
(int Category, int Subcategory, int Mod) _fitsKey;
readonly Dictionary<BrowserCategory, int> _counts = [];
(int Version, int Mod) _countsKey = (-1, -1);
Menu _menu;
Rectangle _menuAnchor;
bool _menuOpened;
protected IdIndex Index { get; } = index;
public override bool Open
{
get => base.Open;
set
{
base.Open = value;
_menu = Menu.None;
}
}
public sealed override void DrawBody(Rectangle body)
{
if (Main.gameMenu)
@@ -30,31 +61,170 @@ public abstract class GridBrowser(string id, string title, Vector2 position, int
Render.TextCentered(Widgets.SpriteBatch, "Enter a world to use this.", body, Widgets.Dim, 0.9f);
return;
}
// An open menu takes all input until it closes.
bool blocked = Widgets.Blocked;
if (_menu != Menu.None)
Widgets.Blocked = true;
DrawContent(body);
Widgets.Blocked = blocked;
if (_menu != Menu.None)
DrawMenu(body);
}
protected abstract void DrawContent(Rectangle body);
protected abstract void DrawSlot(int id, Rectangle bounds, bool hot);
protected virtual string? ModOf(int id) => null;
protected void DrawSearchGrid(Rectangle body, int top, int bottom)
{
var field = new Rectangle(body.X, top, body.Width - 110, 36);
Widgets.Field(_search, field);
int gridTop = DrawCategories(body, top + 42);
Index.Filter(_search.Text, _subTabs[_category] is { } subTabs ? subTabs[_subcategory].Fits : categories[_category].Fits);
LoadSources();
Widgets.Field(_search, new Rectangle(body.X, top, body.Width, 36));
int toolbarY = top + 42;
int gridTop = DrawCategories(body, DrawToolbar(body, toolbarY));
Index.Filter(_search.Text, CurrentFits(), _sorts[_sort].Order);
if (_search.Text != _lastQuery)
{
_lastQuery = _search.Text;
_scroll.Reset();
}
Render.TextRight(Widgets.SpriteBatch, $"{Index.Ids.Count} {noun}", new Rectangle(field.Right, top, 106, 36), Widgets.Dim, 0.8f);
Render.TextRight(Widgets.SpriteBatch, $"{Index.Ids.Count} {noun}", new Rectangle(body.X, toolbarY, body.Width - 2, ToolbarHeight), Widgets.Dim, 0.8f);
var inner = new Rectangle(body.X, gridTop, body.Width, bottom - gridTop);
Widgets.Panel(inner, Widgets.InnerBg);
DrawGrid(Index.Ids, new Rectangle(inner.X + 10, inner.Y + 10, inner.Width - 34, inner.Height - 20));
}
void LoadSources()
{
if (_sources is not null)
return;
int[] ids = [.. Index.All()];
_sources = new string?[ids.Length == 0 ? 0 : ids[ids.Length - 1] + 1];
Dictionary<string, int> counts = [];
foreach (int id in ids)
if (ModOf(id) is { } mod)
{
_sources[id] = mod;
counts[mod] = counts.TryGetValue(mod, out int count) ? count + 1 : 1;
}
if (counts.Count == 0)
return;
_mods = [(null, "All mods", ids.Length), (null, "Terraria", ids.Length - counts.Values.Sum()), .. counts.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(pair => ((string?)pair.Key, pair.Key, pair.Value))];
}
string? Source(int id) => id < _sources!.Length ? _sources[id] : null;
Func<int, bool> WithMod(Func<int, bool> fits)
{
if (_mod == 0)
return fits;
string? source = _mods[_mod].Source;
return id => Source(id) == source && fits(id);
}
// IdIndex.Filter compares delegates by reference, so reuse one per selection.
Func<int, bool> CurrentFits()
{
if (_fits is not null && _fitsKey == (_category, _subcategory, _mod))
return _fits;
_fitsKey = (_category, _subcategory, _mod);
BrowserCategory selected = _subTabs[_category] is { } subTabs ? subTabs[_subcategory] : categories[_category];
return _fits = WithMod(selected.Fits);
}
int CountOf(BrowserCategory tab)
{
if (_countsKey != (Index.Version, _mod))
{
_counts.Clear();
_countsKey = (Index.Version, _mod);
}
if (!_counts.TryGetValue(tab, out int count))
_counts[tab] = count = Index.Count(WithMod(tab.Fits));
return count;
}
int DrawToolbar(Rectangle body, int y)
{
int x = body.X;
if (_mods.Length > 0)
x = MenuButton(new Rectangle(x, y, 190, ToolbarHeight), Menu.Mod, "Images/UI/Bestiary/Button_Filtering", _mods[_mod].Name) + 6;
MenuButton(new Rectangle(x, y, 150, ToolbarHeight), Menu.Sort, "Images/UI/Bestiary/Button_Sorting", $"Sort: {_sorts[_sort].Name}");
return y + ToolbarHeight + 6;
}
int MenuButton(Rectangle bounds, Menu menu, string icon, string label)
{
bool open = _menu == menu;
if (Widgets.Button(bounds, "", open, MenuScale))
{
(_menu, _menuOpened, open) = (menu, true, true);
_menuScroll.Reset();
}
if (open)
_menuAnchor = bounds;
Widgets.Icon(GameTextures.Request(icon), new Rectangle(bounds.X + 5, bounds.Y + 4, bounds.Height - 8, bounds.Height - 8));
var text = new Rectangle(bounds.X + bounds.Height + 2, bounds.Y, bounds.Width - bounds.Height - 8, bounds.Height);
Render.TextMid(Widgets.SpriteBatch, Render.Ellipsize(label, MenuScale, text.Width), text, open || Widgets.Hot(bounds) ? Widgets.Yellow : Color.White, MenuScale);
return bounds.Right;
}
void DrawMenu(Rectangle body)
{
(string Name, string Detail)[] entries = _menu == Menu.Mod ? [.. _mods.Select(mod => (mod.Name, mod.Count.ToString()))] : [.. _sorts.Select(sort => (sort.Name, ""))];
int selected = _menu == Menu.Mod ? _mod : _sort;
int widest = (int)entries.Max(entry => Render.Measure(entry.Name, MenuScale).X + Render.Measure(entry.Detail, MenuScale).X);
int contentHeight = entries.Length * MenuRowHeight;
var popup = new Rectangle(_menuAnchor.X, _menuAnchor.Bottom + 2, Math.Max(_menuAnchor.Width, widest + 56), Math.Min(Math.Min(entries.Length, MenuRows) * MenuRowHeight + 8, body.Bottom - _menuAnchor.Bottom - 2));
bool scrolls = contentHeight > popup.Height - 8;
Widgets.Panel(popup, Widgets.WindowBg);
var area = new Rectangle(popup.X + 4, popup.Y + 4, popup.Width - 8 - (scrolls ? 20 : 0), popup.Height - 8);
int y0 = _menuScroll.Begin(area);
int picked = -1;
for (int i = 0; i < entries.Length; i++)
{
var row = new Rectangle(area.X, y0 + i * MenuRowHeight, area.Width, MenuRowHeight);
if (row.Bottom < area.Y || row.Y > area.Bottom)
continue;
bool hot = Widgets.Hot(row);
if (hot)
Render.Fill(Widgets.SpriteBatch, row, Widgets.ButtonHot);
var text = new Rectangle(row.X + 6, row.Y, row.Width - 12, row.Height);
Render.TextMid(Widgets.SpriteBatch, entries[i].Name, text, hot || i == selected ? Widgets.Yellow : Color.White, MenuScale);
Render.TextRight(Widgets.SpriteBatch, entries[i].Detail, text, Widgets.Dim, MenuScale);
if (hot && Widgets.Click)
picked = i;
}
_menuScroll.End(area, contentHeight);
if (picked >= 0)
{
if (_menu == Menu.Mod)
_mod = picked;
else
_sort = picked;
_menu = Menu.None;
_scroll.Reset();
Widgets.Sound(12);
}
else if (Widgets.Click && !_menuOpened && !Widgets.Hot(popup))
{
_menu = Menu.None;
}
_menuOpened = false;
}
int DrawCategories(Rectangle body, int y)
{
if (DrawTabs(body, categories, ref _category, y, TabHeight, 0.78f))
@@ -91,17 +261,15 @@ public abstract class GridBrowser(string id, string title, Vector2 position, int
changed = true;
_scroll.Reset();
}
if (!iconTab)
continue;
if (icons is not null)
{
// Journey icon frames include a 2px separator on the right.
Rectangle frame = GameTextures.Frame(icons, 11, 1, tab.Icon, 0);
frame.Width -= 2;
Widgets.Icon(icons, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), frame, 1f, i == selected || Widgets.Hot(bounds) ? Color.White : Color.White * 0.7f);
}
if (Widgets.Hot(bounds))
Widgets.Tooltip = tab.Name;
Widgets.Tooltip = $"{tab.Name}: {CountOf(tab)} {noun}";
if (!iconTab || icons is null)
continue;
// Journey icon frames include a 2px separator on the right.
Rectangle frame = GameTextures.Frame(icons, 11, 1, tab.Icon, 0);
frame.Width -= 2;
Widgets.Icon(icons, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), frame, 1f, i == selected || Widgets.Hot(bounds) ? Color.White : Color.White * 0.7f);
}
return changed;
}
+45 -16
View File
@@ -8,9 +8,12 @@ public sealed class IdIndex(Func<int> count, Func<int, string?> name)
string[]? _names;
string? _lastQuery;
Func<int, bool>? _lastFits;
Comparison<int>? _lastOrder;
public List<int> Ids { get; } = [];
public int Version { get; private set; }
string[] Names
{
get
@@ -27,31 +30,57 @@ public sealed class IdIndex(Func<int> count, Func<int, string?> name)
public string Name(int id) => id > 0 && id < Names.Length ? Names[id] : "";
public void Refresh() => _lastQuery = null;
public IEnumerable<int> All()
{
string[] names = Names;
for (int i = 1; i < names.Length; i++)
if (names[i].Length > 0)
yield return i;
}
public void Filter(string query, Func<int, bool> fits)
public int Count(Func<int, bool> fits)
{
int total = 0;
foreach (int id in All())
if (fits(id))
total++;
return total;
}
public void Refresh()
{
_lastQuery = null;
Version++;
}
public void Filter(string query, Func<int, bool> fits, Comparison<int>? order)
{
query = query.Trim();
if (query == _lastQuery && fits == _lastFits)
if (query == _lastQuery && fits == _lastFits && order == _lastOrder)
return;
(_lastQuery, _lastFits) = (query, fits);
(_lastQuery, _lastFits, _lastOrder) = (query, fits, order);
string[] names = Names;
Ids.Clear();
if (query.Length == 0)
{
for (int i = 1; i < names.Length; i++)
if (names[i].Length > 0 && fits(i))
foreach (int i in All())
if (fits(i))
Ids.Add(i);
return;
}
if (int.TryParse(query, out int id) && id > 0 && id < names.Length && names[id].Length > 0 && fits(id))
Ids.Add(id);
foreach (bool prefixPass in (bool[])[true, false])
for (int i = 1; i < names.Length; i++)
{
int at = names[i].IndexOf(query, StringComparison.OrdinalIgnoreCase);
if ((prefixPass ? at == 0 : at > 0) && i != id && fits(i))
Ids.Add(i);
}
else
{
if (int.TryParse(query, out int id) && id > 0 && id < names.Length && names[id].Length > 0 && fits(id))
Ids.Add(id);
foreach (bool prefixPass in (bool[])[true, false])
for (int i = 1; i < names.Length; i++)
{
int at = names[i].IndexOf(query, StringComparison.OrdinalIgnoreCase);
if ((prefixPass ? at == 0 : at > 0) && i != id && fits(i))
Ids.Add(i);
}
}
if (order is not null)
Ids.Sort((a, b) => order(a, b) is var result and not 0 ? result : a.CompareTo(b));
}
}
+6 -1
View File
@@ -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()
{
+1 -1
View File
@@ -3,7 +3,7 @@ using Terraria;
namespace TerraSharp.UI;
public sealed class ItemBrowser() : ItemGrid("items", "Item Browser", new Vector2(660, 120), 534, 520, [])
public sealed class ItemBrowser() : ItemGrid("items", "Item Browser", new Vector2(660, 120), 534, 554, [])
{
const int InventorySlots = 50;
+30 -5
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.GameContent.UI;
using Terraria.ID;
#if TMODLOADER
using Terraria.ModLoader;
@@ -19,6 +20,8 @@ public sealed class ItemEditor() : Window("itemEditor", "Item Editor", new Vecto
readonly Scroll _scroll = new();
readonly Item _reference = new();
(int Type, int Prefix) _referenceKey = (-1, -1);
(int Prefix, int Value, int Rarity)[] _prefixOptions = [];
int _prefixType = -1;
int _x, _y, _width;
bool _firstGroup;
@@ -141,21 +144,43 @@ public sealed class ItemEditor() : Window("itemEditor", "Item Editor", new Vecto
return Lang.prefix[prefix].Value;
}
// Ranked by the value each prefix gives, like reforge tiers.
(int Prefix, int Value, int Rarity)[] PrefixOptions(Item item)
{
if (_prefixType == item.type)
return _prefixOptions;
var sample = new Item();
_prefixOptions = [.. Prefixes(item).Prepend(0).Select(prefix =>
{
sample.SetDefaults(item.type);
if (prefix > 0)
sample.Prefix(prefix);
return (prefix, sample.value, sample.rare);
}).OrderBy(option => option.prefix != 0).ThenByDescending(option => option.value).ThenBy(option => PrefixName(option.prefix), StringComparer.OrdinalIgnoreCase)];
_prefixType = item.type;
return _prefixOptions;
}
void DrawPrefixes(Item item)
{
int[] prefixes = [.. Prefixes(item).OrderBy(PrefixName, StringComparer.OrdinalIgnoreCase)];
if (prefixes.Length == 0)
(int Prefix, int Value, int Rarity)[] options = PrefixOptions(item);
if (options.Length <= 1)
return;
Group("Prefix");
const int Columns = 3, Gap = 6;
const float Scale = 0.76f;
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]);
int prefix = options[i].Prefix;
if (Widgets.Button(bounds, "", item.prefix == prefix, Scale) && item.prefix != prefix)
ApplyPrefix(item, prefix);
string name = Render.Ellipsize(prefix == 0 ? "None" : PrefixName(prefix), Scale, bounds.Width - 12);
Render.TextCentered(Widgets.SpriteBatch, name, bounds, ItemRarity.GetColor(options[i].Rarity), Scale);
}
_y += (options.Length + Columns - 1) / Columns * 34 + 4;
}
+32 -4
View File
@@ -13,7 +13,7 @@ using Terraria.ModLoader;
namespace TerraSharp.UI;
public abstract class ItemGrid(string id, string title, Vector2 position, int width, int height, BrowserCategory[] extra) : GridBrowser(id, title, position, width, height, "Search items by name or ID", "items", new IdIndex(() => ItemCount, Lang.GetItemNameValue), [.. Categories(), .. extra])
public abstract class ItemGrid(string id, string title, Vector2 position, int width, int height, BrowserCategory[] extra) : GridBrowser(id, title, position, width, height, "Search items by name or ID", "items", new IdIndex(() => ItemCount, Lang.GetItemNameValue), [.. Categories(), .. extra], Sorts)
{
#if TMODLOADER
static int ItemCount => ItemLoader.ItemCount;
@@ -25,17 +25,45 @@ public abstract class ItemGrid(string id, string title, Vector2 position, int wi
static readonly IItemEntryFilter Weapon = new ItemFilters.Weapon();
// Journey duplication filters paired with Infinite_Icons frame indices.
static readonly (IItemEntryFilter Filter, int Icon)[] Filters = [(new ItemFilters.Tools(), 6), (new ItemFilters.Armor(), 2), (new ItemFilters.Vanity(), 8), (new ItemFilters.Accessories(), 1), (new ItemFilters.MiscAccessories(), 9), (new ItemFilters.Consumables(), 3), (new ItemFilters.BuildingBlock(), 4), (new ItemFilters.Furniture(), 7), (new ItemFilters.Materials(), 10)];
// Journey duplication filters paired with Infinite_Icons frame indices; a part without a test holds the rest.
static readonly (IItemEntryFilter Filter, int Icon, (string Name, Func<Item, bool>? Fits)[]? Parts)[] Filters =
[
(new ItemFilters.Tools(), 6, [("Pickaxes", item => item.pick > 0), ("Axes", item => item.axe > 0), ("Hammers", item => item.hammer > 0), ("Fishing", item => item.fishingPole > 0), ("Other", null)]),
(new ItemFilters.Armor(), 2, null),
(new ItemFilters.Vanity(), 8, null),
(new ItemFilters.Accessories(), 1, [("Wings", item => item.wingSlot > 0), ("Boots", item => item.shoeSlot > 0), ("Shields", item => item.shieldSlot > 0), ("Other", null)]),
(new ItemFilters.MiscAccessories(), 9, [("Pets", item => item.buffType > 0 && Main.vanityPet[item.buffType]), ("Light Pets", item => item.buffType > 0 && Main.lightPet[item.buffType]), ("Minecarts", item => item.mountType >= 0 && MountID.Sets.Cart[item.mountType]), ("Mounts", item => item.mountType >= 0 && !MountID.Sets.Cart[item.mountType]), ("Hooks", item => Main.projHook[item.shoot])]),
(new ItemFilters.Consumables(), 3, null),
(new ItemFilters.BuildingBlock(), 4, null),
(new ItemFilters.Furniture(), 7, null),
(new ItemFilters.Materials(), 10, null),
];
static BrowserCategory[] Categories() =>
[
new("All", _ => true),
new(Language.GetTextValue(Weapon.GetDisplayNameKey()), type => Fits(Weapon, type), 0, [.. Enum.GetValues(typeof(WeaponClass)).Cast<WeaponClass>().Select(weaponClass => new BrowserCategory(weaponClass.ToString(), type => ContentSample(type) is { } item && ClassOf(item) == weaponClass)), new("Ammo", type => ContentSample(type) is { ammo: > 0 })]),
.. Filters.Select(entry => new BrowserCategory(Language.GetTextValue(entry.Filter.GetDisplayNameKey()), type => Fits(entry.Filter, type), entry.Icon)),
.. Filters.Select(entry => new BrowserCategory(Language.GetTextValue(entry.Filter.GetDisplayNameKey()), type => Fits(entry.Filter, type), entry.Icon, entry.Parts is null ? null : Split(entry.Filter, entry.Parts))),
new(Language.GetTextValue("CreativePowers.TabMisc"), type => !Fits(Weapon, type) && !Filters.Any(entry => Fits(entry.Filter, type)), 5),
];
static BrowserCategory[] Split(IItemEntryFilter filter, (string Name, Func<Item, bool>? Fits)[] parts) => [.. parts.Select(part => new BrowserCategory(part.Name, type => ContentSample(type) is { } item && filter.FitsFilter(item) && (part.Fits?.Invoke(item) ?? !parts.Any(other => other.Fits?.Invoke(item) ?? false))))];
static readonly BrowserSort[] Sorts = [new("Rarity", (a, b) => RarityRank(b).CompareTo(RarityRank(a))), new("Value", (a, b) => (ContentSample(b)?.value ?? 0).CompareTo(ContentSample(a)?.value ?? 0))];
// Expert (-12) and Master (-13) rank above Purple (11); Quest and Gray stay at the bottom.
static int RarityRank(int type) => ContentSample(type)?.rare switch
{
-13 => 13,
-12 => 12,
{ } rare => rare,
null => int.MinValue,
};
#if TMODLOADER
protected override string? ModOf(int type) => ItemLoader.GetItem(type)?.Mod.DisplayNameClean;
#endif
static Item? ContentSample(int type) => ContentSamples.ItemsByType.TryGetValue(type, out Item? item) ? item : null;
// Whips must be classified as summon weapons before checking melee.
+1 -1
View File
@@ -4,7 +4,7 @@ using Microsoft.Xna.Framework;
namespace TerraSharp.UI;
// Edits a persisted set of item types, such as the auto sell list.
public sealed class ItemPicker(string id, string title, Vector2 position, HashSet<int> list) : ItemGrid(id, title, position, 534, 520, [new("Selected", list.Contains)])
public sealed class ItemPicker(string id, string title, Vector2 position, HashSet<int> list) : ItemGrid(id, title, position, 534, 554, [new("Selected", list.Contains)])
{
protected override void DrawContent(Rectangle body)
{
+7
View File
@@ -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()
@@ -138,6 +144,7 @@ public sealed partial class MainWindow
if (Buttons(WindowManager.SellList.Open ? "Close Sell List" : "Edit Sell List") == 0)
WindowManager.Toggle(WindowManager.SellList);
});
Toggle("Auto Deposit", ref Settings.AutoDeposit);
Toggle("Bank Buttons", ref Settings.BankButtons);
Toggle("Infinite Items", ref Settings.InfItems);
Toggle("Instant Break", ref Settings.InstantBreak);
+3 -1
View File
@@ -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.",
@@ -31,7 +32,7 @@ public sealed partial class MainWindow
["Exact check"] = "Checks and corrects aim before firing. Auto Attack skips known misses. Orange brackets mean an unconfirmed shot.",
["Shot Preview"] = "Shows your next shot's path, including spread and homing.",
["Magic Bullets"] = "Your projectiles home in on the aimbot target or the nearest enemy, through blocks.",
["Auto Dodge"] = "Moves you out of the way of incoming projectiles and enemies.",
["Auto Dodge"] = "Moves you out of the way of incoming projectiles and enemies. Uses jumps, wings, dashes and Rod of Harmony or Discord when needed.",
["Auto Attack"] = "Swings at enemies in reach and shoots at enemies your weapon can hit.",
["Show target"] = "Shows the cursor radius, target brackets and aim point.",
["Kill Aura"] = "Kills enemies in range and drops their loot.",
@@ -40,6 +41,7 @@ public sealed partial class MainWindow
["Instant Break"] = "Break blocks in one hit with your held pickaxe, axe or hammer.",
["Infinite Items"] = "Keeps inventory stacks from shrinking.",
["Item Vacuum"] = "Pulls all dropped items to you.",
["Auto Deposit"] = "Sends all coins to the Piggy Bank.",
["Bank Buttons"] = "Piggy Bank, Safe, Defender's Forge and Void Vault buttons left of the inventory.",
["Auto Sell"] = "Turns listed items in your inventory into coins. Items worth nothing are deleted.",
["Item Editor"] = "Change the prefix, stats and stack of the held or cursor item.",
+2 -2
View File
@@ -30,10 +30,10 @@ 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)),
new Page("Items", ItemsPage, () => Count(Settings.InstantBreak, Settings.InfItems, Settings.ItemVacuum, Settings.AutoSell, Settings.AutoDeposit, Settings.AutoFish, Settings.Sonar)),
new Page("Visuals", VisualsPage, () => Count(Settings.Esp, Settings.Spelunker, Settings.Hunter, Settings.Shine, Settings.NightOwl, Settings.DangerSense, Settings.BiomeSight, Settings.FullBright, Settings.ExtendedZoom)),
new Page("World", WorldPage, () => Count(Settings.FreezeTime, Settings.SpawnRateBoost, Settings.MapTeleport)),
new Page("Keys", KeysPage, () => 0),
+7 -1
View File
@@ -9,7 +9,7 @@ using TerraSharp.Features;
namespace TerraSharp.UI;
public sealed class NpcBrowser() : GridBrowser("npcs", "NPC Spawner", new Vector2(660, 140), 532, 520, "Search NPCs by name or ID", "NPCs", new IdIndex(() => NpcCount, Lang.GetNPCNameValue), Categories)
public sealed class NpcBrowser() : GridBrowser("npcs", "NPC Spawner", new Vector2(660, 140), 532, 554, "Search NPCs by name or ID", "NPCs", new IdIndex(() => NpcCount, Lang.GetNPCNameValue), Categories, Sorts)
{
const int Batch = 10;
@@ -21,8 +21,14 @@ public sealed class NpcBrowser() : GridBrowser("npcs", "NPC Spawner", new Vector
static readonly BrowserCategory[] Categories = [new("All", NpcSpawner.Available), new("Bosses", type => NpcSpawner.Available(type) && IsBoss(type)), new("Enemies", type => NpcSpawner.Available(type) && Sample(type) is { friendly: false, townNPC: false } && !IsBoss(type) && !IsCritter(type)), new("Town", type => NpcSpawner.Available(type) && Sample(type) is { townNPC: true }), new("Critters", type => NpcSpawner.Available(type) && IsCritter(type))];
static readonly BrowserSort[] Sorts = [new("Life", (a, b) => (Sample(b)?.lifeMax ?? 0).CompareTo(Sample(a)?.lifeMax ?? 0))];
bool _multiplayer;
#if TMODLOADER
protected override string? ModOf(int type) => NPCLoader.GetNPC(type)?.Mod.DisplayNameClean;
#endif
static NPC? Sample(int type) => ContentSamples.NpcsByNetId.TryGetValue(type, out NPC? npc) ? npc : null;
static bool IsBoss(int type) => Sample(type) is { boss: true };
+189
View File
@@ -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<Rectangle> _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);
}
}
+97
View File
@@ -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<T>, 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();
}
}
+195
View File
@@ -0,0 +1,195 @@
using System;
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 = 40, FrameHeight = 62, LabelHeight = 20, HeaderHeight = 20;
// 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];
static readonly string[] ArmorLabels = ["Head", "Body", "Legs"], MiscLabels = ["Pet", "Light Pet", "Minecart", "Mount", "Hook"];
Tab _tab;
public int Target { get; private set; } = -1;
public override string Title => Target >= 0 && Main.player[Target] is { active: true } player ? $"Inspecting Player: {player.name}" : base.Title;
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;
}
int tabWidth = (body.Width - 12) / 3;
for (int i = 0; i < 3; i++)
if (Widgets.Button(new Rectangle(body.X + i * (tabWidth + 6), body.Y, tabWidth, 30), ((Tab)i).ToString(), _tab == (Tab)i, 0.82f))
_tab = (Tab)i;
int x = body.X + (body.Width - (Columns * SlotStep - (SlotStep - SlotSize))) / 2, y = body.Y + ContentTop;
int height = _tab switch
{
Tab.Equipment => DrawEquipment(player, body, y),
Tab.Inventory => DrawInventory(player, x, y),
_ => DrawBuffs(player, x, y, body),
};
Height = FrameHeight + ContentTop + height;
}
// Same column order as the vanilla equipment page.
static int DrawEquipment(Player player, Rectangle body, int y)
{
const int ArmorLabelWidth = 48, MiscLabelWidth = 64, BlockGap = 40;
int armorWidth = ArmorLabelWidth + 3 * SlotStep - (SlotStep - SlotSize), miscWidth = MiscLabelWidth + 2 * SlotStep - (SlotStep - SlotSize);
int x = body.X + (body.Width - armorWidth - BlockGap - miscWidth) / 2, miscX = x + armorWidth + BlockGap;
int rowsY = y + HeaderHeight;
ColumnHeaders(x + ArmorLabelWidth, y, "Dye", "Vanity", "Equip");
int rows = 0;
for (int i = 0; i < Columns; i++)
{
// Slots 8 and 9 need the Demon Heart and Master Mode.
if (!player.IsItemSlotUnlockedAndUsable(i))
continue;
int rowY = rowsY + rows * SlotStep;
rows++;
RowLabel(i < 3 ? ArmorLabels[i] : $"Acc {rows - 3}", x, rowY, ArmorLabelWidth);
Slot(player.dye[i], x + ArmorLabelWidth, rowY, "InventoryBack12", DyeIcon);
Slot(player.armor[i + Columns], x + ArmorLabelWidth + SlotStep, rowY, "InventoryBack8", i < 3 ? VanityArmorIcons[i] : VanityAccessoryIcon);
Slot(player.armor[i], x + ArmorLabelWidth + 2 * SlotStep, rowY, "InventoryBack3", i < 3 ? ArmorIcons[i] : AccessoryIcon);
}
ColumnHeaders(miscX + MiscLabelWidth, y, "Dye", "Equip");
for (int i = 0; i < player.miscEquips.Length; i++)
{
int rowY = rowsY + i * SlotStep;
RowLabel(MiscLabels[i], miscX, rowY, MiscLabelWidth);
Slot(player.miscDyes[i], miscX + MiscLabelWidth, rowY, "InventoryBack12", DyeIcon);
Slot(player.miscEquips[i], miscX + MiscLabelWidth + SlotStep, rowY, "InventoryBack3", MiscIcons[i]);
}
const int PortraitWidth = 96, PortraitMinHeight = 110;
int portraitY = rowsY + player.miscEquips.Length * SlotStep + 4;
var portrait = new Rectangle(miscX, portraitY, PortraitWidth, Math.Max(PortraitMinHeight, rowsY + rows * SlotStep - SlotStep + SlotSize - portraitY));
Widgets.Panel(portrait, Widgets.InnerBg);
PlayerPortrait.Draw(player, portrait);
int heldX = miscX + miscWidth - SlotSize;
Render.TextCenteredAt(Widgets.SpriteBatch, "Held", heldX + SlotSize / 2f, portrait.Bottom - SlotSize - HeaderHeight, Widgets.Dim, 0.7f);
Slot(player.HeldItem, heldX, portrait.Bottom - SlotSize, selected: true);
return Math.Max(HeaderHeight + rows * SlotStep, portrait.Bottom - y);
}
static void ColumnHeaders(int x, int y, params string[] titles)
{
for (int i = 0; i < titles.Length; i++)
Render.TextCenteredAt(Widgets.SpriteBatch, titles[i], x + i * SlotStep + SlotSize / 2f, y, Widgets.Dim, 0.7f);
}
static void RowLabel(string text, int x, int y, int width) => Render.TextMid(Widgets.SpriteBatch, text, new Rectangle(x, y, width, SlotSize), Widgets.Header, 0.75f);
// 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);
// Vanilla labels the hotbar 1-0 whatever the key bindings are.
for (int i = 0; i < Columns; i++)
Render.Text(Widgets.SpriteBatch, ((i + 1) % 10).ToString(), new Vector2(x + i * SlotStep + 5, y + 2), Color.White, 0.6f);
int labelY = y + 5 * SlotStep + 6, extraY = labelY + LabelHeight;
int groupWidth = 4 * SlotStep - (SlotStep - SlotSize);
Render.TextCenteredAt(Widgets.SpriteBatch, Lang.inter[26].Value, x + groupWidth / 2f, labelY, Widgets.Header, 0.75f);
Render.TextCenteredAt(Widgets.SpriteBatch, Lang.inter[27].Value, x + 6 * SlotStep + groupWidth / 2f, labelY, Widgets.Header, 0.75f);
for (int i = 0; i < 8; i++)
Slot(player.inventory[50 + i], x + (i < 4 ? i : i + 2) * SlotStep, extraY);
return 6 * SlotStep + 6 + LabelHeight;
}
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);
}
}
+69
View File
@@ -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";
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Reflection;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using TerraSharp.Core;
namespace TerraSharp.UI;
// Draws a player standing still and facing right, without the held item.
public static class PlayerPortrait
{
const float Scale = 1.5f;
static readonly Item Blank = new();
// Public on tModLoader, private on vanilla.
const BindingFlags AnyInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
static readonly FieldInfo? MountFrame = typeof(Mount).GetField("_frame", AnyInstance);
static readonly FieldInfo? MountData = typeof(Mount).GetField("_data", AnyInstance);
static readonly FieldInfo? StandingFrame = MountData?.FieldType.GetField("standingFrameStart", AnyInstance);
// Main.gameMenu makes BoringSetup skip world lighting and Dust.NewDust spawn nothing at the UI position.
public static void Draw(Player player, Rectangle bounds)
{
SpriteBatch spriteBatch = Widgets.SpriteBatch;
bool gameMenu = Main.gameMenu;
(int direction, int immuneAlpha, int wingFrame) = (player.direction, player.immuneAlpha, player.wingFrame);
(Rectangle bodyFrame, Rectangle legFrame, Rectangle headFrame) = (player.bodyFrame, player.legFrame, player.headFrame);
int selected = player.selectedItem, itemAnimation = player.itemAnimation;
(Item held, Item visualized) = (player.inventory[selected], player.lastVisualizedSelectedItem);
(Player.CompositeArmData frontArm, Player.CompositeArmData backArm) = (player.compositeFrontArm, player.compositeBackArm);
bool mounted = player.mount.Active;
object? mountFrame = mounted ? MountFrame?.GetValue(player.mount) : null;
try
{
// Dye shaders apply per draw, so the batch must be immediate.
spriteBatch.End();
spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, DepthStencilState.None, RasterizerState.CullNone, null, Main.UIScaleMatrix);
Main.gameMenu = true;
(player.direction, player.immuneAlpha, player.wingFrame) = (1, 0, 0);
// Mounted legs hold the seated pose; reset only the mount and the attacking arm.
if (mounted && MountData?.GetValue(player.mount) is { } data && StandingFrame?.GetValue(data) is int standing)
MountFrame?.SetValue(player.mount, standing);
if (mounted && itemAnimation > 0)
player.bodyFrame.Y = 0;
else if (!mounted)
player.bodyFrame.Y = player.legFrame.Y = player.headFrame.Y = 0;
// PlayerDrawSet takes the held item from lastVisualizedSelectedItem and the swing pose from the composite arms.
(player.inventory[selected], player.lastVisualizedSelectedItem, player.itemAnimation) = (Blank, Blank, 0);
player.compositeFrontArm.enabled = player.compositeBackArm.enabled = false;
var position = new Vector2(bounds.Center.X - player.width / 2f, bounds.Center.Y - player.height / 2f);
Main.PlayerRenderer.DrawPlayer(Main.Camera, player, position + Main.screenPosition, 0f, Vector2.Zero, 0f, Scale);
}
catch (Exception e)
{
Log.Error("Player portrait", e);
}
finally
{
Main.gameMenu = gameMenu;
(player.direction, player.immuneAlpha, player.wingFrame) = (direction, immuneAlpha, wingFrame);
(player.bodyFrame, player.legFrame, player.headFrame) = (bodyFrame, legFrame, headFrame);
(player.inventory[selected], player.lastVisualizedSelectedItem, player.itemAnimation) = (held, visualized, itemAnimation);
(player.compositeFrontArm, player.compositeBackArm) = (frontArm, backArm);
if (mountFrame is not null)
MountFrame!.SetValue(player.mount, mountFrame);
}
Widgets.Recover(spriteBatch, Main.UIScaleMatrix);
}
}
+2
View File
@@ -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);
}
+2 -2
View File
@@ -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);
+4 -1
View File
@@ -6,7 +6,7 @@ public abstract class Window(string id, string title, Vector2 position, int widt
{
public string Id { get; } = id;
public string Title { get; } = title;
public virtual string Title { get; } = title;
public Vector2 Pos { get; set; } = position;
@@ -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);
+6 -3
View File
@@ -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<Window> Stack = [Items, Buffs, Npcs, Editor, SellList, ChestList, Stats, Menu, StatsSetup];
static readonly List<Window> 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)
@@ -191,7 +194,7 @@ public static class WindowManager
var title = new Rectangle(bounds.X, bounds.Y, bounds.Width, TitleHeight);
if (hasTitle)
{
Render.TextMid(Widgets.SpriteBatch, window.Title, new Rectangle(bounds.X + 16, bounds.Y + 2, bounds.Width, TitleHeight), Color.White, 1.0f);
Render.TextMid(Widgets.SpriteBatch, Render.Ellipsize(window.Title, 1.0f, bounds.Width - 64), new Rectangle(bounds.X + 16, bounds.Y + 2, bounds.Width, TitleHeight), Color.White, 1.0f);
Widgets.Separator(bounds.X + 10, bounds.Y + TitleHeight, bounds.Width - 20);
}