Compare commits

29 Commits
Author SHA1 Message Date
VAC 0d9de7dd02 build: Bump version to 0.2.0 2026-10-16 22:59:39 -03:00
VAC 118eb96429 refactor: Rename Coins to Piggy Bank to Auto Deposit 2026-10-16 22:33:32 -03:00
VAC cfe56356c4 feat: Add Coins to Piggy Bank toggle 2026-10-16 22:33:06 -03:00
VAC c5ff9a75fb feat: Rework player inspector with vertical equipment, portrait and slot labels 2026-10-16 22:15:55 -03:00
VAC 23ac33cb93 feat: Rank item editor prefixes by value and color them by rarity 2026-10-16 22:15:54 -03:00
VAC 249787187d feat: Add Tools, Accessories and Equipment sub tabs to item browsers 2026-10-16 22:15:54 -03:00
VAC aae755913d feat: Add mod filter, sort menu and category counts to browsers 2026-10-16 22:15:54 -03:00
VAC 9de8112ce9 feat: Auto dodge uses dashes and teleport rods 2026-10-16 01:58:27 -03:00
VAC 4e95719c2f fix: Auto dodge ignores knockback and lifts without a way to fly 2026-10-16 01:58:26 -03:00
VAC 9d748d679b feat: Add player list, player inspector and party frames 2026-10-16 01:37:26 -03:00
VAC d68de62cf5 feat: Add configurable stats overlay 2026-10-16 00:39:04 -03:00
VAC 6701dd0305 feat: Add auto dodge 2026-10-16 00:39:02 -03:00
VAC afb8235c08 feat: Add magic bullets 2026-10-16 00:39:01 -03:00
VAC f84ad4faeb feat: Add chest finder ESP 2026-10-16 00:38:59 -03:00
VAC 7b768fde2c feat: Add auto sell list 2026-10-16 00:38:57 -03:00
VAC 8d6296676a feat: Add item editor with prefix picker 2026-10-16 00:38:56 -03:00
VAC 73998f1aaa feat: Add map teleport 2026-10-16 00:38:54 -03:00
VAC 9df9f0a322 feat: Add map reveal 2026-10-16 00:38:53 -03:00
VAC 2692887684 feat: Add extended zoom 2026-10-16 00:38:51 -03:00
VAC a9081d28d9 feat: Add full bright 2026-10-16 00:38:50 -03:00
VAC 73a90f47f1 feat: Add defense multiplier 2026-10-16 00:38:48 -03:00
VAC 7d5115ded4 feat: Add ghost mode 2026-10-16 00:38:47 -03:00
VAC 6b3dcccc7c refactor: Split item grid out of ItemBrowser 2026-10-16 00:38:45 -03:00
VAC 3195e7ba92 feat: Color damage numbers by damage class 2026-10-16 00:30:44 -03:00
VAC 746ad799a0 feat: Add boss cursor for off-screen bosses 2026-10-16 00:30:40 -03:00
VAC bcf4e71558 fix: Browser tabs cut off text labels 2026-10-16 00:29:39 -03:00
VAC be429a9448 fix: Window clamp applies the UI scale twice 2026-10-16 00:18:05 -03:00
VAC 993b91a46b feat: Add bank buttons under builder toggles 2026-10-16 00:08:37 -03:00
VAC 55150f3c47 feat: Add NPC spawner and spawn rate multiplier 2026-10-15 21:47:06 -03:00
46 changed files with 3239 additions and 153 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>
+19
View File
@@ -28,8 +28,15 @@ public static class Hotkeys
new(InterfaceGroup, "Menu", "Open / close menu", () => WindowManager.Toggle(WindowManager.Menu)),
new(InterfaceGroup, "ItemBrowser", "Item Browser", () => WindowManager.Toggle(WindowManager.Items)),
new(InterfaceGroup, "BuffBrowser", "Buff Browser", () => WindowManager.Toggle(WindowManager.Buffs)),
new(InterfaceGroup, "NpcSpawner", "NPC Spawner", () => WindowManager.Toggle(WindowManager.Npcs)),
new(InterfaceGroup, "ItemEditor", "Item Editor", () => WindowManager.Toggle(WindowManager.Editor)),
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),
Toggle("Player", "DefenseBoost", "Defense Multiplier", () => Settings.DefenseBoost, v => Settings.DefenseBoost = v),
Toggle("Player", "AutoHeal", "Auto Heal", () => Settings.AutoHeal, v => Settings.AutoHeal = v),
Toggle("Player", "AutoMana", "Auto Mana", () => Settings.AutoMana, v => Settings.AutoMana = v),
new("Player", "FullHeal", "Full Heal", PlayerFeatures.FullHeal),
@@ -52,6 +59,8 @@ public static class Hotkeys
Toggle("Combat", "FreezeEnemies", "Freeze Enemies", () => Settings.FreezeEnemies, v => Settings.FreezeEnemies = v),
Toggle("Combat", "WeakenEnemies", "Weaken Enemies", () => Settings.WeakenEnemies, v => Settings.WeakenEnemies = v),
Toggle("Combat", "RapidAttack", "Rapid Attack", () => Settings.RapidAttack, v => Settings.RapidAttack = v),
Toggle("Combat", "MagicBullets", "Magic Bullets", () => Settings.MagicBullets, v => Settings.MagicBullets = v),
Toggle("Combat", "AutoDodge", "Auto Dodge", () => Settings.AutoDodge, v => Settings.AutoDodge = v),
new("Combat", "GatherAll", "Gather All Enemies", CombatFeatures.GatherAll),
new("Combat", "KillAll", "Kill All Enemies", CombatFeatures.KillAll),
@@ -59,6 +68,10 @@ public static class Hotkeys
Toggle("Items", "ItemVacuum", "Item Vacuum", () => Settings.ItemVacuum, v => Settings.ItemVacuum = v),
Toggle("Items", "InstantBreak", "Instant Break", () => Settings.InstantBreak, v => Settings.InstantBreak = v),
Toggle("Items", "InfItems", "Infinite Items", () => Settings.InfItems, v => Settings.InfItems = v),
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),
Toggle("Visuals", "Esp", "ESP", () => Settings.Esp, v => Settings.Esp = v),
Toggle("Visuals", "Spelunker", "Spelunker Vision", () => Settings.Spelunker, v => Settings.Spelunker = v),
@@ -67,9 +80,15 @@ public static class Hotkeys
Toggle("Visuals", "BiomeSight", "Biome Sight", () => Settings.BiomeSight, v => Settings.BiomeSight = v),
Toggle("Visuals", "NightOwl", "Night Owl", () => Settings.NightOwl, v => Settings.NightOwl = v),
Toggle("Visuals", "Shine", "Shine", () => Settings.Shine, v => Settings.Shine = v),
Toggle("Visuals", "FullBright", "Full Bright", () => Settings.FullBright, v => Settings.FullBright = v),
Toggle("Visuals", "ExtendedZoom", "Extended Zoom", () => Settings.ExtendedZoom, v => Settings.ExtendedZoom = v),
Toggle("Visuals", "ChestFinder", "Chest Finder", () => Settings.EspChests, v => Settings.EspChests = v),
Toggle("World", "FreezeTime", "Freeze Time", () => Settings.FreezeTime, v => Settings.FreezeTime = v),
Toggle("World", "SpawnRate", "Spawn Rate", () => Settings.SpawnRateBoost, v => Settings.SpawnRateBoost = v),
new("World", "SavePosition", "Save Current Position", Waypoints.AddHere),
Toggle("World", "MapTeleport", "Map Teleport", () => Settings.MapTeleport, v => Settings.MapTeleport = v),
new("World", "RevealMap", "Reveal Map", MapFeatures.StartReveal),
];
// Windows virtual-key codes for mouse buttons; XNA's Keys leaves them unassigned.
+33
View File
@@ -17,6 +17,14 @@ public static partial class Settings
[nameof(AutoMana)] = "Player.AutoMana.Enabled",
[nameof(AutoManaAt)] = "Player.AutoMana.Threshold",
[nameof(BuffMinutes)] = "Player.Buffs.Minutes",
[nameof(GhostMode)] = "Player.GhostMode",
[nameof(DefenseBoost)] = "Player.DefenseBoost.Enabled",
[nameof(DefenseMult)] = "Player.DefenseBoost.Multiplier",
[nameof(ShowStats)] = "Player.Stats.Enabled",
[nameof(StatLines)] = "Player.Stats.Lines",
[nameof(StatIcons)] = "Player.Stats.Icons",
[nameof(PartyFrames)] = "Player.PartyFrames.Enabled",
[nameof(PartySelf)] = "Player.PartyFrames.Self",
[nameof(Fly)] = "Movement.Fly.Enabled",
[nameof(FlySpeed)] = "Movement.Fly.Speed",
@@ -55,10 +63,15 @@ public static partial class Settings
[nameof(ShotPreview)] = "Combat.Aim.ShotPreview",
[nameof(AimFov)] = "Combat.Aim.CursorRadius",
[nameof(AimRange)] = "Combat.Aim.Range",
[nameof(MagicBullets)] = "Combat.MagicBullets",
[nameof(AutoDodge)] = "Combat.AutoDodge",
[nameof(InstantBreak)] = "Items.InstantBreak",
[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",
[nameof(AutoRecast)] = "Fishing.Recast",
@@ -87,6 +100,7 @@ public static partial class Settings
[nameof(ColTown)] = "Esp.TownNpcs.Color",
[nameof(EspBossRainbow)] = "Esp.Bosses.Rainbow",
[nameof(ColBoss)] = "Esp.Bosses.Color",
[nameof(EspBossCursor)] = "Esp.Bosses.Cursor",
[nameof(EspItems)] = "Esp.Items.Enabled",
[nameof(ColItems)] = "Esp.Items.Color",
[nameof(EspProjHostile)] = "Esp.HostileProjectiles.Enabled",
@@ -95,6 +109,18 @@ public static partial class Settings
[nameof(ColProjFriendly)] = "Esp.FriendlyProjectiles.Color",
[nameof(EspWaypoints)] = "Esp.Waypoints.Enabled",
[nameof(ColWaypoint)] = "Esp.Waypoints.Color",
[nameof(EspChests)] = "Esp.Chests.Enabled",
[nameof(ColChests)] = "Esp.Chests.Color",
[nameof(EspChestsListed)] = "Esp.Chests.OnlyListed",
[nameof(DamageMelee)] = "DamageNumbers.Melee.Enabled",
[nameof(ColDamageMelee)] = "DamageNumbers.Melee.Color",
[nameof(DamageRanged)] = "DamageNumbers.Ranged.Enabled",
[nameof(ColDamageRanged)] = "DamageNumbers.Ranged.Color",
[nameof(DamageMagic)] = "DamageNumbers.Magic.Enabled",
[nameof(ColDamageMagic)] = "DamageNumbers.Magic.Color",
[nameof(DamageSummon)] = "DamageNumbers.Summon.Enabled",
[nameof(ColDamageSummon)] = "DamageNumbers.Summon.Color",
[nameof(Spelunker)] = "Vision.Spelunker",
[nameof(Hunter)] = "Vision.Hunter",
@@ -102,12 +128,19 @@ public static partial class Settings
[nameof(NightOwl)] = "Vision.NightOwl",
[nameof(DangerSense)] = "Vision.DangerSense",
[nameof(BiomeSight)] = "Vision.BiomeSight",
[nameof(FullBright)] = "Vision.FullBright",
[nameof(ExtendedZoom)] = "Vision.ExtendedZoom",
[nameof(FreezeTime)] = "World.FreezeTime",
[nameof(SpawnRateBoost)] = "World.SpawnRate.Enabled",
[nameof(SpawnRateMult)] = "World.SpawnRate.Multiplier",
[nameof(MapTeleport)] = "World.MapTeleport",
[nameof(LogBallistics)] = "Ballistics.Log",
};
static readonly string[] ListPaths = ["Items.AutoSell.Items", "Esp.Chests.Items"];
static void SetValue(Dictionary<string, object> document, string path, object value)
{
string[] parts = path.Split('.');
+59 -8
View File
@@ -19,12 +19,36 @@ public enum EspBoxStyle
Filled,
}
[Flags]
public enum StatLine
{
None = 0,
Dps = 1 << 0,
PeakDps = 1 << 1,
Damage = 1 << 2,
BestHit = 1 << 3,
Kills = 1 << 4,
Time = 1 << 5,
Deaths = 1 << 6,
Minions = 1 << 7,
Sentries = 1 << 8,
Defense = 1 << 9,
DamageReduction = 1 << 10,
Crit = 1 << 11,
LifeRegen = 1 << 12,
Speed = 1 << 13,
Luck = 1 << 14,
}
// Public mutable fields are persisted through Settings.Json.cs.
public static partial class Settings
{
public static bool GodMode, InfHealth, InfMana, InfBreath, InfFlight, InstantRespawn;
public static bool AutoHeal, AutoMana;
public static float AutoHealAt = 40f, AutoManaAt = 25f, BuffMinutes = 10f;
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;
public static float FlySpeed = 12f, SpeedMult = 3f;
@@ -35,20 +59,25 @@ public static partial class Settings
public static bool KillAura, HitMagnet, FreezeEnemies, WeakenEnemies;
public static float KillAuraRange = 20f, HitMagnetRange = 30f;
public static int KillAuraInterval = 10;
public static bool Aimbot, AimPrediction = true, AimDrawTarget = true, AutoAttack, AimExact = true, ShotPreview;
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;
public static bool InstantBreak, InfItems, ItemVacuum, AutoSell, AutoDeposit, BankButtons = true;
public static readonly HashSet<int> SellList = [];
public static bool AutoFish, AutoRecast = true, FishEnemies, Sonar;
public static bool Esp, EspNames = true, EspHealth = true, EspHealthText, EspDistance, EspLines, EspProjPath = true;
public static bool EspLocal, EspPlayers = true, EspHostile = true, EspCritters, EspTown, EspBossRainbow = true, EspItems, EspProjHostile, EspProjFriendly, EspWaypoints = true;
public static bool EspLocal, EspPlayers = true, EspHostile = true, EspCritters, EspTown, EspBossRainbow = true, EspBossCursor = true, EspItems, EspProjHostile, EspProjFriendly, EspWaypoints = true, EspChests, EspChestsListed;
public static readonly HashSet<int> ChestList = [];
public static EspBoxStyle EspStyle;
public static float EspThickness = 1f, EspMaxDistance; // tiles, 0 = unlimited
public static Color ColLocal = Color.White, ColPlayers = new(70, 200, 255), ColHostile = new(235, 60, 60), ColCritters = new(200, 170, 120), ColTown = new(80, 220, 110), ColBoss = new(255, 120, 40), ColItems = new(255, 210, 70), ColProjHostile = new(255, 80, 200), ColProjFriendly = new(120, 160, 255), ColWaypoint = new(220, 90, 255);
public static bool Spelunker, Hunter, Shine, NightOwl, DangerSense, BiomeSight;
public static Color ColLocal = Color.White, ColPlayers = new(70, 200, 255), ColHostile = new(235, 60, 60), ColCritters = new(200, 170, 120), ColTown = new(80, 220, 110), ColBoss = new(255, 120, 40), ColItems = new(255, 210, 70), ColProjHostile = new(255, 80, 200), ColProjFriendly = new(120, 160, 255), ColWaypoint = new(220, 90, 255), ColChests = new(240, 170, 60);
public static bool DamageMelee, DamageRanged, DamageMagic, DamageSummon;
public static Color ColDamageMelee = new(255, 110, 60), ColDamageRanged = new(120, 230, 90), ColDamageMagic = new(90, 150, 255), ColDamageSummon = new(200, 110, 255);
public static bool Spelunker, Hunter, Shine, NightOwl, DangerSense, BiomeSight, FullBright, ExtendedZoom;
public static bool FreezeTime;
public static bool FreezeTime, SpawnRateBoost, MapTeleport = true;
public static float SpawnRateMult = 5f;
public static bool LogBallistics = true;
@@ -92,6 +121,9 @@ public static partial class Settings
binds[bind.Key] = bind.Value.ToString();
document["Binds"] = binds;
SetValue(document, ListPaths[0], SortedList(SellList));
SetValue(document, ListPaths[1], SortedList(ChestList));
var windows = new Dictionary<string, int[]>();
foreach (Window window in WindowManager.All)
windows[window.Id] = [(int)window.Pos.X, (int)window.Pos.Y];
@@ -121,6 +153,9 @@ public static partial class Settings
if (TryGetValue(document, setting.Value, out object? value))
TryLoad(setting.Value, () => Apply(setting.Key, Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""));
LoadList(document, ListPaths[0], SellList);
LoadList(document, ListPaths[1], ChestList);
foreach (KeyValuePair<string, object> bind in Section(document, "Binds"))
TryLoad("Binds." + bind.Key, () => Binds[bind.Key] = (Keys)Enum.Parse(typeof(Keys), (string)bind.Value));
@@ -149,6 +184,20 @@ public static partial class Settings
}
}
static int[] SortedList(HashSet<int> list)
{
int[] ids = [.. list];
Array.Sort(ids);
return ids;
}
static void LoadList(Dictionary<string, object> document, string path, HashSet<int> list)
{
list.Clear();
if (TryGetValue(document, path, out object? value))
TryLoad(path, () => list.UnionWith(Json.ConvertTo<int[]>(value!)));
}
static void TryLoad(string key, Action load)
{
try
@@ -208,6 +257,8 @@ public static partial class Settings
{
foreach (KeyValuePair<string, object> pair in Defaults)
typeof(Settings).GetField(pair.Key)!.SetValue(null, pair.Value);
SellList.Clear();
ChestList.Clear();
}
public static string Hex(Color color) => $"{color.R:X2}{color.G:X2}{color.B:X2}{color.A:X2}";
+343
View File
@@ -0,0 +1,343 @@
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, 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, Damage;
}
readonly record struct Move(int Horizontal, int Vertical, bool Dash = false);
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()
{
for (int i = 0; i < Pool.Length; i++)
Pool[i] = new Threat();
}
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 || Settings.GodMode || player.dead)
return;
CollectThreats(player);
if (Threats.Count == 0)
return;
float speed = Settings.Fly ? Settings.FlySpeed : Math.Max(Math.Max(player.maxRunSpeed, player.accRunSpeed), 3f);
int bestHit = FirstHit(player, null, Stay, speed, false);
if (bestHit > Horizon)
return;
int incoming = _hitDamage;
Move best = Stay;
bool canRise = CanRise(player), canFall = !Grounded(player);
foreach (Move move in Moves)
{
if ((move.Vertical < 0 && !canRise) || (move.Vertical > 0 && !canFall))
continue;
int hit = FirstHit(player, null, move, speed, true);
if (hit > bestHit)
(best, bestHit) = (move, hit);
}
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;
if (best != Stay)
Apply(player, best, speed);
}
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 && Settings.Fly;
player.controlDown = move.Vertical > 0;
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;
}
}
// 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)
{
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++)
{
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)))
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();
float rangeSq = ScanRange * ScanRange;
for (int i = 0; i < Main.maxProjectiles && Threats.Count < MaxThreats; i++)
{
Projectile projectile = Main.projectile[i];
if (!projectile.active || !projectile.hostile || projectile.damage <= 0 || Vector2.DistanceSquared(projectile.Center, player.Center) > rangeSq)
continue;
if (!Ballistics.Current(i, out Vector2 velocity, out int age))
(velocity, age) = (projectile.velocity * (1 + projectile.extraUpdates), 0);
Threat threat = Pool[Threats.Count];
threat.Count = Ballistics.Path(Ballistics.Get(projectile.type), projectile.Center, velocity, age, Horizon, threat.Path);
// 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 || KnockedAway(player, npc))
continue;
Threat threat = Pool[Threats.Count];
for (int t = 0; t <= ContactHorizon; t++)
threat.Path[t] = npc.Center + NpcMotion.Offset(npc, t);
(threat.Count, threat.Width, threat.Height, threat.Damage) = (ContactHorizon + 1, npc.width, npc.height, Taken(player, npc.damage));
Threats.Add(threat);
}
}
}
@@ -0,0 +1,69 @@
using System;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using TerraSharp.Core;
using TerraSharp.Simulation;
namespace TerraSharp.Features.Combat;
public static class MagicBullets
{
const float MinSpeed = 8f;
const int GrappleStyle = 7, YoyoStyle = 99, FlailStyle = 15;
// Projectile type per slot while steered, 0 otherwise.
static readonly int[] Steered = new int[Main.maxProjectiles];
public static bool IsSteered(int slot) => Steered[slot] != 0 && Steered[slot] == Main.projectile[slot].type;
static bool Eligible(Projectile projectile) => projectile is { friendly: true, hostile: false, minion: false, sentry: false, bobber: false, npcProj: false, damage: > 0 } && projectile.aiStyle is not (GrappleStyle or YoyoStyle or FlailStyle) && !Main.projPet[projectile.type] && !ProjectileID.Sets.IsAWhip[projectile.type] && !Sandbox.Held(projectile);
// Projectile.AI postfix for the local player's projectiles.
public static void PostAI(Projectile projectile)
{
int slot = projectile.whoAmI;
NPC? target = Eligible(projectile) ? TargetFor(projectile) : null;
if (target is null)
{
if (IsSteered(slot) && ContentSamples.ProjectilesByType.TryGetValue(projectile.type, out Projectile? sample))
projectile.tileCollide = sample.tileCollide;
Steered[slot] = 0;
return;
}
Vector2 offset = target.Center - projectile.Center;
if (offset.LengthSquared() < 1f)
return;
projectile.velocity = Vector2.Normalize(offset) * Math.Max(projectile.velocity.Length(), MinSpeed);
projectile.tileCollide = false;
Steered[slot] = projectile.type;
}
static NPC? TargetFor(Projectile projectile)
{
if (Aimbot.Target is { } locked && Hostility.IsDamageable(locked))
return locked;
Player player = Main.player[projectile.owner];
float range = Settings.AimRange * 16f, rangeSq = range * range;
NPC? best = null;
float bestDistance = float.MaxValue;
for (int i = 0; i < Main.maxNPCs; i++)
{
NPC npc = Main.npc[i];
if (!Hostility.IsDamageable(npc) || Vector2.DistanceSquared(npc.Center, player.Center) > rangeSq)
continue;
float distance = Vector2.DistanceSquared(npc.Center, projectile.Center);
if (distance < bestDistance)
{
bestDistance = distance;
best = npc;
}
}
return best;
}
}
+76
View File
@@ -0,0 +1,76 @@
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using TerraSharp.Core;
#if TMODLOADER
using Terraria.ModLoader;
#endif
namespace TerraSharp.Features;
public enum DamageKind { None, Melee, Ranged, Magic, Summon }
// Hit paths set Current around StrikeNPC; CombatText.NewText reads it.
public static class DamageNumbers
{
public static DamageKind Current;
public static Color? Recolor(Color color)
{
bool crit = color == CombatText.DamagedHostileCrit;
if (!crit && color != CombatText.DamagedHostile)
return null;
(bool enabled, Color chosen) = Current switch
{
DamageKind.Melee => (Settings.DamageMelee, Settings.ColDamageMelee),
DamageKind.Ranged => (Settings.DamageRanged, Settings.ColDamageRanged),
DamageKind.Magic => (Settings.DamageMagic, Settings.ColDamageMagic),
DamageKind.Summon => (Settings.DamageSummon, Settings.ColDamageSummon),
_ => (false, default),
};
if (!enabled)
return null;
var opaque = new Color(chosen.R, chosen.G, chosen.B);
return crit ? Color.Lerp(opaque, Color.White, 0.4f) : opaque;
}
#if TMODLOADER
// Whips count as melee too, so summon is checked first.
public static DamageKind Of(DamageClass? damageClass)
{
if (damageClass is null)
return DamageKind.None;
if (damageClass.CountsAsClass(DamageClass.Summon))
return DamageKind.Summon;
if (damageClass.CountsAsClass(DamageClass.Magic))
return DamageKind.Magic;
if (damageClass.CountsAsClass(DamageClass.Ranged))
return DamageKind.Ranged;
return damageClass.CountsAsClass(DamageClass.Melee) ? DamageKind.Melee : DamageKind.None;
}
#else
public static DamageKind Of(Projectile projectile)
{
if (projectile.minion || projectile.sentry || ProjectileID.Sets.IsAWhip[projectile.type] || ProjectileID.Sets.MinionShot[projectile.type] || ProjectileID.Sets.SentryShot[projectile.type])
return DamageKind.Summon;
if (projectile.magic)
return DamageKind.Magic;
if (projectile.ranged)
return DamageKind.Ranged;
return projectile.melee ? DamageKind.Melee : DamageKind.None;
}
public static DamageKind Of(Item item)
{
if (item.summon || item.sentry)
return DamageKind.Summon;
if (item.magic)
return DamageKind.Magic;
if (item.ranged)
return DamageKind.Ranged;
return item.melee ? DamageKind.Melee : DamageKind.None;
}
#endif
}
+120
View File
@@ -1,5 +1,10 @@
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using TerraSharp.Core;
using TerraSharp.UI;
namespace TerraSharp.Features;
@@ -24,4 +29,119 @@ public static class ItemFeatures
drop.velocity = Vector2.Zero;
}
}
const int InventorySlots = 50;
const int CoinSound = 18;
// The cursor item, or the held item when the cursor is empty.
public static Item EditTarget => Main.mouseItem.type != 0 ? Main.mouseItem : Main.LocalPlayer.inventory[Main.LocalPlayer.selectedItem];
public static void MaxStack()
{
Item item = EditTarget;
if (item.type == 0 || item.stack >= item.maxStack)
return;
item.stack = item.maxStack;
Widgets.Sound(7);
}
public static void Duplicate()
{
Item item = EditTarget;
Item[] inventory = Main.LocalPlayer.inventory;
if (item.type == 0)
return;
for (int i = 0; i < InventorySlots; i++)
{
if (inventory[i].type != 0)
continue;
inventory[i] = item.Clone();
inventory[i].favorited = false;
Widgets.Sound(7);
return;
}
}
// Items worth nothing are deleted; items worth something stay when no slot can take the coins.
public static void AutoSell(Player player)
{
bool sold = false;
for (int i = 0; i < InventorySlots; i++)
{
Item item = player.inventory[i];
if (item.type == 0 || item.favorited || !Settings.SellList.Contains(item.type) || (i == player.selectedItem && player.itemAnimation > 0))
continue;
if (!player.SellItem(item) && item.value > 0)
continue;
item.TurnToAir();
sold = true;
}
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;
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using Terraria;
using TerraSharp.Core;
using TerraSharp.UI;
namespace TerraSharp.Features;
public static class MapFeatures
{
const int ColumnsPerTick = 24;
const float WorldEdge = 42f;
// MapHelper.GetBackgroundType samples map tiles up to 36 away; WorldMap.UnlockMapSection keeps the same margin.
const int Margin = 40;
static int _revealColumn = -1;
static bool _rightWasDown;
public static bool Revealing => _revealColumn >= 0;
public static float RevealProgress => Revealing ? (_revealColumn - Margin) / (float)(Main.maxTilesX - 2 * Margin) : 0f;
public static void StartReveal() => _revealColumn = Margin;
// Main.DoUpdate postfix.
public static void Update()
{
if (Main.gameMenu)
{
_revealColumn = -1;
return;
}
RevealStep();
if (Settings.MapTeleport)
TeleportOnRightClick();
}
// A large world is about 20 million map tiles, so the reveal is spread over ticks.
static void RevealStep()
{
if (_revealColumn < 0)
return;
int start = _revealColumn, end = Math.Min(Main.maxTilesX - Margin, start + ColumnsPerTick);
_revealColumn = end < Main.maxTilesX - Margin ? end : -1;
for (int x = start; x < end; x++)
for (int y = Margin; y < Main.maxTilesY - Margin; y++)
Main.Map.Update(x, y, byte.MaxValue);
if (_revealColumn < 0)
Main.refreshMap = true;
}
// Main.DrawMap puts tile mapFullscreenPos at the screen center, scaled by mapFullscreenScale.
static void TeleportOnRightClick()
{
MouseState mouse = Mouse.GetState();
bool down = mouse.RightButton == ButtonState.Pressed;
bool clicked = down && !_rightWasDown;
_rightWasDown = down;
if (!clicked || !Main.mapFullscreen || !Main.instance.IsActive || WindowManager.MouseOver)
return;
Vector2 tile = Main.mapFullscreenPos + (new Vector2(mouse.X, mouse.Y) - new Vector2(Main.screenWidth, Main.screenHeight) / 2f) / Main.mapFullscreenScale;
Vector2 feet = new Vector2(MathHelper.Clamp(tile.X, WorldEdge, Main.maxTilesX - WorldEdge), MathHelper.Clamp(tile.Y, WorldEdge, Main.maxTilesY - WorldEdge)) * 16f;
Player player = Main.LocalPlayer;
PlayerFeatures.TeleportTo(feet - new Vector2(player.width / 2f, player.height));
Main.mapFullscreen = false;
Widgets.Sound(2, -1, -1, 6);
}
}
+59
View File
@@ -0,0 +1,59 @@
using System;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using TerraSharp.Core;
namespace TerraSharp.Features;
public static class NpcSpawner
{
const float SpawnDistance = 10 * 16f;
const int MultiplayerClient = 1;
#if TMODLOADER
static readonly EntitySource_DebugCommand Source = new(nameof(NpcSpawner));
#else
static readonly EntitySource_DebugCommand Source = new();
#endif
public static bool Multiplayer => Main.netMode == MultiplayerClient;
// The server only honors summon requests for these types.
public static bool CanSummon(int type) => type >= 0 && type < NPCID.Sets.MPAllowedEnemies.Length && NPCID.Sets.MPAllowedEnemies[type];
public static bool Available(int type) => !Multiplayer || CanSummon(type);
public static void Spawn(int type, int count)
{
if (Multiplayer)
{
if (CanSummon(type))
NetMessage.SendData(MessageID.SpawnBossUseLicenseStartEvent, -1, -1, null, Main.myPlayer, type);
return;
}
Player player = Main.LocalPlayer;
int x = (int)(player.Center.X + player.direction * SpawnDistance);
int y = (int)player.Bottom.Y;
for (int i = 0; i < count; i++)
if (NPC.NewNPC(Source, x, y, type) >= Main.maxNPCs)
break;
}
// Spawner.GetSpawnRate postfix; spawnRate is the 1-in-N chance per tick.
public static void ScaleSpawnRate(ref int spawnRate, ref int maxSpawns)
{
if (!Settings.SpawnRateBoost)
return;
if (Settings.SpawnRateMult <= 0f)
{
maxSpawns = 0;
return;
}
spawnRate = Math.Max(1, (int)(spawnRate / Settings.SpawnRateMult));
maxSpawns = (int)Math.Ceiling(maxSpawns * Settings.SpawnRateMult);
}
}
+31
View File
@@ -2,6 +2,7 @@ using System;
using Microsoft.Xna.Framework;
using Terraria;
using TerraSharp.Core;
using TerraSharp.Features.Combat;
using TerraSharp.UI;
namespace TerraSharp.Features;
@@ -92,7 +93,26 @@ public static class PlayerFeatures
Player.tileRangeY = Settings.ReachY;
}
if (Settings.DefenseBoost)
#if TMODLOADER
player.statDefense.FinalMultiplier *= Settings.DefenseMult;
#else
player.statDefense = (int)(player.statDefense * Settings.DefenseMult);
#endif
if (Settings.GhostMode)
ApplyGhost(player);
ApplyVision(player);
AutoDodge.Steer(player);
}
// ResetEffects clears both every tick; NPC.TargetClosest and SetTargetTrackingValues read them.
static void ApplyGhost(Player player)
{
player.aggro = -10000;
bool[] passive = player.npcTypeNoAggro;
for (int i = 0; i < passive.Length; i++)
passive[i] = true;
}
static void ApplyVision(Player player)
@@ -125,6 +145,10 @@ public static class PlayerFeatures
if (Settings.InfBreath)
player.breath = player.breathMax;
AutoPotions(player);
if (Settings.AutoSell)
ItemFeatures.AutoSell(player);
if (Settings.AutoDeposit)
ItemFeatures.DepositCoins(player);
}
if (Settings.InfFlight)
@@ -262,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
}
}
+78
View File
@@ -0,0 +1,78 @@
using System;
using Terraria;
namespace TerraSharp.Features;
// Damage is the life the local player removed from NPCs, read from per-tick life changes.
public static class Stats
{
const int DpsTicks = 180;
static readonly int[] LastLife = new int[Main.maxNPCs], LastType = new int[Main.maxNPCs];
static readonly int[] Recent = new int[DpsTicks];
static int _recentIndex, _recentSum;
static int _world = -1;
static bool _wasDead;
public static long Damage { get; private set; }
public static int BestHit { get; private set; }
public static int Kills { get; private set; }
public static int Deaths { get; private set; }
public static int Ticks { get; private set; }
public static float Dps => _recentSum * 60f / DpsTicks;
public static float PeakDps { get; private set; }
public static void Reset()
{
Array.Clear(Recent, 0, Recent.Length);
(_recentIndex, _recentSum) = (0, 0);
(Damage, BestHit, Kills, Deaths, Ticks, PeakDps) = (0, 0, 0, 0, 0, 0f);
}
// Player.Update postfix for the local player.
public static void Update(Player player)
{
if (Main.worldID != _world)
{
_world = Main.worldID;
Reset();
}
Ticks++;
if (player.dead && !_wasDead)
Deaths++;
_wasDead = player.dead;
int dealt = 0;
for (int i = 0; i < Main.maxNPCs; i++)
{
NPC npc = Main.npc[i];
int life = npc.active ? Math.Max(0, npc.life) : 0;
bool killed = !npc.active && npc.life <= 0;
// Worm segments share one life pool owned by the head.
bool counts = LastType[i] != 0 && npc.type == LastType[i] && npc.lastInteraction == Main.myPlayer && (npc.realLife < 0 || npc.realLife == i) && (npc.active || killed);
if (counts && life < LastLife[i])
{
int hit = LastLife[i] - life;
dealt += hit;
BestHit = Math.Max(BestHit, hit);
if (life == 0 && !npc.friendly && !npc.townNPC && npc.lifeMax > 5)
Kills++;
}
(LastType[i], LastLife[i]) = npc.active ? (npc.type, life) : (0, 0);
}
Damage += dealt;
_recentSum += dealt - Recent[_recentIndex];
Recent[_recentIndex] = dealt;
_recentIndex = (_recentIndex + 1) % DpsTicks;
PeakDps = Math.Max(PeakDps, Dps);
}
}
@@ -0,0 +1,103 @@
using System;
using Microsoft.Xna.Framework;
using Terraria;
using TerraSharp.Core;
using TerraSharp.Features;
#if !TMODLOADER
using Terraria.GameContent;
using Terraria.ID;
#endif
namespace TerraSharp.Patches;
internal static class DamageNumberPatches
{
delegate int NewTextOriginal(Rectangle location, Color color, string text, bool dramatic, bool dot);
internal static void Install(HookRegistry hooks)
{
hooks.Add(HookRegistry.Method(typeof(CombatText), nameof(CombatText.NewText), [typeof(Rectangle), typeof(Color), typeof(string), typeof(bool), typeof(bool)]), (Func<NewTextOriginal, Rectangle, Color, string, bool, bool, int>)NewText);
#if TMODLOADER
hooks.Add(HookRegistry.Method(typeof(NPC), nameof(NPC.StrikeNPC), [typeof(NPC.HitInfo), typeof(bool), typeof(bool)]), (Func<StrikeOriginal, NPC, NPC.HitInfo, bool, bool, int>)Strike);
#else
hooks.Add(typeof(Projectile), nameof(Projectile.Damage), (Action<ProjectileDamageOriginal, Projectile>)ProjectileDamage);
hooks.Add(typeof(Player), "ProcessHitAgainstNPC", (Action<MeleeHitOriginal, Player, Item, Rectangle, int, float, int>)MeleeHit);
hooks.Add(HookRegistry.Method(typeof(Player), nameof(Player.ApplyDamageToNPC), [typeof(NPC), typeof(int), typeof(float), typeof(int), typeof(bool), typeof(PlayerNPCHitSource)]), (Action<ApplyDamageOriginal, Player, NPC, int, float, int, bool, PlayerNPCHitSource>)ApplyDamage);
#endif
}
static int NewText(NewTextOriginal original, Rectangle location, Color color, string text, bool dramatic, bool dot)
{
if (Plugin.Active && DamageNumbers.Current != DamageKind.None && DamageNumbers.Recolor(color) is { } recolored)
color = recolored;
return original(location, color, text, dramatic, dot);
}
#if TMODLOADER
delegate int StrikeOriginal(NPC self, NPC.HitInfo hit, bool fromNet, bool noPlayerInteraction);
static int Strike(StrikeOriginal original, NPC self, NPC.HitInfo hit, bool fromNet, bool noPlayerInteraction)
{
DamageKind previous = DamageNumbers.Current;
DamageNumbers.Current = DamageNumbers.Of(hit.DamageType);
try
{
return original(self, hit, fromNet, noPlayerInteraction);
}
finally
{
DamageNumbers.Current = previous;
}
}
#else
delegate void ProjectileDamageOriginal(Projectile self);
delegate void MeleeHitOriginal(Player self, Item item, Rectangle itemRectangle, int originalDamage, float knockBack, int npcIndex);
delegate void ApplyDamageOriginal(Player self, NPC npc, int damage, float knockback, int direction, bool crit, PlayerNPCHitSource source);
static void ProjectileDamage(ProjectileDamageOriginal original, Projectile self)
{
DamageKind previous = DamageNumbers.Current;
DamageNumbers.Current = DamageNumbers.Of(self);
try
{
original(self);
}
finally
{
DamageNumbers.Current = previous;
}
}
static void MeleeHit(MeleeHitOriginal original, Player self, Item item, Rectangle itemRectangle, int originalDamage, float knockBack, int npcIndex)
{
DamageKind previous = DamageNumbers.Current;
DamageNumbers.Current = DamageNumbers.Of(item);
try
{
original(self, item, itemRectangle, originalDamage, knockBack, npcIndex);
}
finally
{
DamageNumbers.Current = previous;
}
}
// Dashes, buffs and contact damage pass the item or projectile that caused the hit.
static void ApplyDamage(ApplyDamageOriginal original, Player self, NPC npc, int damage, float knockback, int direction, bool crit, PlayerNPCHitSource source)
{
DamageKind previous = DamageNumbers.Current;
if (source.ProjType > 0 && ContentSamples.ProjectilesByType.TryGetValue(source.ProjType, out Projectile? projectile))
DamageNumbers.Current = DamageNumbers.Of(projectile);
else if (source.ItemType > 0 && ContentSamples.ItemsByType.TryGetValue(source.ItemType, out Item? item))
DamageNumbers.Current = DamageNumbers.Of(item);
try
{
original(self, npc, damage, knockback, direction, crit, source);
}
finally
{
DamageNumbers.Current = previous;
}
}
#endif
}
+4
View File
@@ -21,6 +21,10 @@ internal sealed class HookRegistry : IDisposable
MainPatches.Install(hooks);
PlayerPatches.Install(hooks);
NpcPatches.Install(hooks);
ProjectilePatches.Install(hooks);
DamageNumberPatches.Install(hooks);
VisionPatches.Install(hooks);
SpawnRatePatch.Install(hooks);
ItemCheckShootPatch.Install(hooks);
SandboxGuards.Install(hooks);
return hooks;
+1
View File
@@ -44,6 +44,7 @@ internal static class MainUpdatePatch
Hotkeys.Update();
Sandbox.NewTick();
WorldFeatures.Update();
MapFeatures.Update();
Settings.AutoSave();
Profiler.EndTick();
}
+18
View File
@@ -15,6 +15,7 @@ internal static class PlayerPatches
PlayerUpdatePatch.Install(hooks);
PlayerItemCheckPatch.Install(hooks);
PlayerMovementPatch.Install(hooks);
ChestRangePatch.Install(hooks);
}
}
@@ -48,6 +49,7 @@ internal static class PlayerUpdatePatch
{
Clock.Advance();
PlayerFeatures.PostUpdate(player);
Stats.Update(player);
CombatFeatures.PostPlayerUpdate(player);
Ballistics.Observe();
NpcMotion.Track();
@@ -99,3 +101,19 @@ internal static class PlayerMovementPatch
original(self);
}
}
// Closes any open chest or bank once the player leaves range of chestX/chestY.
internal static class ChestRangePatch
{
delegate void Original(Player self);
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Player), "HandleBeingInChestRange", (Action<Original, Player>)HandleRange);
static void HandleRange(Original original, Player self)
{
if (Plugin.Active && self.whoAmI == Main.myPlayer && BankButtons.KeepsOpen(self))
return;
original(self);
}
}
@@ -0,0 +1,22 @@
using System;
using Terraria;
using TerraSharp.Core;
using TerraSharp.Features.Combat;
using TerraSharp.Simulation;
namespace TerraSharp.Patches;
// After the AI chose its velocity, before Projectile.Update moves it.
internal static class ProjectilePatches
{
delegate void Original(Projectile self);
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Projectile), nameof(Projectile.AI), (Action<Original, Projectile>)AI);
static void AI(Original original, Projectile self)
{
original(self);
if (Plugin.Active && Settings.MagicBullets && !Sandbox.Active && self.owner == Main.myPlayer)
Guard.Run("Projectile.AI postfix", MagicBullets.PostAI, self);
}
}
+53
View File
@@ -0,0 +1,53 @@
using System;
using Terraria;
using TerraSharp.Core;
using TerraSharp.Features;
#if TMODLOADER
using Terraria.ModLoader;
#endif
namespace TerraSharp.Patches;
// Natural spawns run only in single player and on the server.
internal static class SpawnRatePatch
{
#if TMODLOADER
// tModLoader's NPC.SpawnNPC passes the final rate through NPCLoader.EditSpawnRate.
delegate void Original(Player player, ref int spawnRate, ref int maxSpawns);
delegate void Hooked(Original original, Player player, ref int spawnRate, ref int maxSpawns);
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(NPCLoader), nameof(NPCLoader.EditSpawnRate), (Hooked)EditSpawnRate);
static void EditSpawnRate(Original original, Player player, ref int spawnRate, ref int maxSpawns)
{
original(player, ref spawnRate, ref maxSpawns);
Scale(ref spawnRate, ref maxSpawns);
}
#else
delegate void Original(NPC.Spawner self, Player player, out int spawnRate, out int maxSpawns);
delegate void Hooked(Original original, NPC.Spawner self, Player player, out int spawnRate, out int maxSpawns);
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(NPC.Spawner), "GetSpawnRate", (Hooked)GetSpawnRate);
static void GetSpawnRate(Original original, NPC.Spawner self, Player player, out int spawnRate, out int maxSpawns)
{
original(self, player, out spawnRate, out maxSpawns);
Scale(ref spawnRate, ref maxSpawns);
}
#endif
static void Scale(ref int spawnRate, ref int maxSpawns)
{
if (!Plugin.Active)
return;
try
{
NpcSpawner.ScaleSpawnRate(ref spawnRate, ref maxSpawns);
}
catch (Exception e)
{
Log.Error("Spawn rate", e);
}
}
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using Microsoft.Xna.Framework;
using Mono.Cecil.Cil;
using MonoMod.Cil;
using Terraria;
using Terraria.Graphics.Light;
using TerraSharp.Core;
#if TMODLOADER
using TerraSharp.Features;
#endif
namespace TerraSharp.Patches;
internal static class VisionPatches
{
internal static void Install(HookRegistry hooks)
{
FullBrightPatch.Install(hooks);
ZoomPatch.Install(hooks);
#if TMODLOADER
SpectateCameraPatch.Install(hooks);
#endif
}
}
// Every Lighting query reaches the active engine's GetColor, millions of times per frame, so the check is a bare field load.
internal static class FullBrightPatch
{
internal static void Install(HookRegistry hooks)
{
hooks.Modify(HookRegistry.Method(typeof(LightingEngine), nameof(LightingEngine.GetColor)), ReturnWhite);
hooks.Modify(HookRegistry.Method(typeof(LegacyLighting), nameof(LegacyLighting.GetColor)), ReturnWhite);
}
static void ReturnWhite(ILContext context)
{
var cursor = new ILCursor(context);
ILLabel body = cursor.DefineLabel();
cursor.Emit(OpCodes.Ldsfld, typeof(Settings).GetField(nameof(Settings.FullBright))!);
cursor.Emit(OpCodes.Brfalse, body);
cursor.Emit(OpCodes.Call, typeof(Vector3).GetProperty(nameof(Vector3.One))!.GetGetMethod()!);
cursor.Emit(OpCodes.Ret);
cursor.Emit(OpCodes.Nop);
body.Target = cursor.Prev;
}
}
// Main.DoDraw and Main.UpdateViewZoomKeys clamp Main.GameZoomTarget to [1, 2].
internal static class ZoomPatch
{
public const float MaxZoom = 8f;
const int SearchWindow = 8;
// Tile and wall targets cover the screen plus Main.offScreenRange on each side.
public static float MinZoom => Math.Max(Main.screenWidth / (float)(Main.screenWidth + 2 * Main.offScreenRange), Main.screenHeight / (float)(Main.screenHeight + 2 * Main.offScreenRange));
internal static void Install(HookRegistry hooks)
{
hooks.Modify(HookRegistry.Method(typeof(Main), "DoDraw"), ReplaceClamps);
hooks.Modify(HookRegistry.Method(typeof(Main), nameof(Main.UpdateViewZoomKeys)), ReplaceClamps);
}
public static float Clamp(float value, float minimum, float maximum) => Settings.ExtendedZoom && Plugin.Active ? MathHelper.Clamp(value, MinZoom, MaxZoom) : MathHelper.Clamp(value, minimum, maximum);
static void ReplaceClamps(ILContext context)
{
var cursor = new ILCursor(context);
int replaced = 0;
while (cursor.TryGotoNext(MoveType.After, i => i.MatchLdsfld(typeof(Main), nameof(Main.GameZoomTarget))))
{
Instruction? instruction = cursor.Next;
for (int i = 0; i < SearchWindow && instruction is not null; i++, instruction = instruction.Next)
{
if (instruction.OpCode != OpCodes.Call || instruction.Operand is not Mono.Cecil.MethodReference { Name: "Clamp" })
continue;
instruction.Operand = context.Import(typeof(ZoomPatch).GetMethod(nameof(Clamp))!);
replaced++;
break;
}
}
if (replaced == 0)
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
+1 -1
View File
@@ -133,7 +133,7 @@ public static class Ballistics
track.Age++;
// Tile hits, bounces and homing turns show up as sharp changes.
if (!track.Spoiled && track.Velocities.Count > 0 && (velocity - track.LastVelocity).Length() > Math.Max(4f, track.LastVelocity.Length() * 0.5f))
if (!track.Spoiled && (MagicBullets.IsSteered(i) || (track.Velocities.Count > 0 && (velocity - track.LastVelocity).Length() > Math.Max(4f, track.LastVelocity.Length() * 0.5f))))
track.Spoiled = true;
if (!track.Spoiled && track.Velocities.Count < MaxSamples)
track.Velocities.Add(velocity);
+141
View File
@@ -0,0 +1,141 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Terraria.GameInput;
using Terraria.ID;
using Terraria.Localization;
using TerraSharp.Core;
using TerraSharp.Patches;
#if TMODLOADER
using Terraria.ModLoader;
#endif
namespace TerraSharp.UI;
public enum Bank
{
PiggyBank = -2,
Safe = -3,
DefendersForge = -4,
VoidVault = -5,
}
// Bank icons stacked under the builder accessory toggles, left of the inventory.
public static class BankButtons
{
const int Size = 18;
const int Spacing = 24;
const float ColumnX = 10f;
static readonly (Bank Bank, int Item)[] Banks = [(Bank.PiggyBank, ItemID.PiggyBank), (Bank.Safe, ItemID.Safe), (Bank.DefendersForge, ItemID.DefendersForge), (Bank.VoidVault, ItemID.VoidVault)];
static readonly Action<Player, int, int, int> OpenChest = (Action<Player, int, int, int>)Delegate.CreateDelegate(typeof(Action<Player, int, int, int>), HookRegistry.Method(typeof(Player), "OpenChest", [typeof(int), typeof(int), typeof(int)]));
// Bank opened by a button; HandleBeingInChestRange would close it for being out of range.
public static Bank? Remote { get; private set; }
public static bool KeepsOpen(Player player)
{
if (Settings.BankButtons && Remote is { } bank && player.chest == (int)bank)
return true;
Remote = null;
return false;
}
public static void Draw(SpriteBatch spriteBatch)
{
Player player = Main.LocalPlayer;
if (!Main.playerInventory || player.sign >= 0)
return;
float y = FirstSlotY(player);
var mouse = new Point(Main.mouseX, Main.mouseY);
foreach ((Bank bank, int item) in Banks)
{
var center = new Vector2(ColumnX, y);
y += Spacing;
Rectangle bounds = Utils.CenteredRectangle(center, new Vector2(Size));
bool hover = bounds.Contains(mouse) && !PlayerInput.IgnoreMouseInterface;
bool open = player.chest == (int)bank;
if (hover)
bounds.Inflate(2, 2);
Render.Item(spriteBatch, item, bounds, hover || open ? Color.White : new Color(180, 180, 180));
if (!hover)
continue;
player.mouseInterface = true;
Main.instance.MouseText(Lang.GetItemNameValue(item), 0, 0, -1, -1, -1, -1, 0);
Main.mouseText = true;
if (Main.mouseLeft && Main.mouseLeftRelease)
{
Main.mouseLeftRelease = false;
Toggle(player, bank);
}
}
}
// Center of the slot after the last builder toggle; mirrors Main.DrawBuilderAccToggles.
static float FirstSlotY(Player player)
{
#if TMODLOADER
int count = BuilderToggleLoader.ActiveBuilderToggles(), page = BuilderToggleLoader.BuilderTogglePage;
bool shiftHotbarLock = count / 12 != page || count % 12 >= 10;
int start = 0, end = count;
bool previousArrow = false, nextArrow = false;
if (count > 12)
{
start = 12 * page;
end = count - start < 12 ? count : start == 0 ? start + 12 : start + 11;
previousArrow = page != 0;
nextArrow = page != count / 12;
}
return 77f - (shiftHotbarLock ? 42 : 21) + Spacing * (end - start + previousArrow.ToInt() + nextArrow.ToInt());
#else
int torchIcons = player.unlockedBiomeTorches.ToInt();
int toggles = player.InfoAccMechShowWires.ToInt() * 6 + player.rulerLine.ToInt() + player.rulerGrid.ToInt() + player.autoActuator.ToInt() + player.autoPaint.ToInt();
bool pushUp = toggles + 1 + torchIcons >= 10;
return 77f + Spacing * (torchIcons + toggles - pushUp.ToInt());
#endif
}
static void Toggle(Player player, Bank bank)
{
if (player.chest == (int)bank)
{
player.chest = -1;
Remote = null;
Widgets.Sound(11);
return;
}
bool wasClosed = player.chest == -1;
player.SetTalkNPC(-1);
Main.npcChatCornerItem = 0;
Main.npcChatText = "";
if (Main.editChest)
{
Widgets.Sound(12);
Main.editChest = false;
}
if (player.editedChestName && player.chest >= 0)
{
NetMessage.SendData(MessageID.ChestName, -1, -1, NetworkText.FromLiteral(Main.chest[player.chest].name), player.chest, 1f);
player.editedChestName = false;
}
player.piggyBankProjTracker.Clear();
player.voidLensChest.Clear();
Main.stackSplit = 600;
var tile = new Point((int)(player.Center.X / 16f), (int)(player.Center.Y / 16f));
OpenChest(player, tile.X, tile.Y, (int)bank);
Remote = bank;
Widgets.Sound(wasClosed ? 10 : 12);
}
}
+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";
+192 -20
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))
@@ -71,7 +241,11 @@ public abstract class GridBrowser(string id, string title, Vector2 position, int
const int TabGap = 4;
int iconTabs = tabs.Count(tab => tab.Icon >= 0);
int textTabs = tabs.Length - iconTabs;
int textWidth = textTabs == 0 ? 0 : (body.Width - TabGap * (tabs.Length - 1) - iconTabs * IconTabWidth) / textTabs;
int available = body.Width - TabGap * (tabs.Length - 1);
int LabelWidth(BrowserCategory tab) => (int)Math.Ceiling(Render.Measure(tab.Name, scale).X) + 16;
int labels = tabs.Where(tab => tab.Icon < 0).Sum(LabelWidth);
int iconWidth = iconTabs == 0 ? 0 : Math.Max(24, Math.Min(IconTabWidth, (available - labels) / iconTabs));
int spare = textTabs == 0 ? 0 : (available - labels - iconTabs * iconWidth) / textTabs;
Texture2D? icons = iconTabs > 0 ? GameTextures.Request("Images/UI/Creative/Infinite_Icons") : null;
bool changed = false;
int x = body.X;
@@ -79,7 +253,7 @@ public abstract class GridBrowser(string id, string title, Vector2 position, int
{
BrowserCategory tab = tabs[i];
bool iconTab = tab.Icon >= 0;
var bounds = new Rectangle(x, y, iconTab ? IconTabWidth : textWidth, height);
var bounds = new Rectangle(x, y, iconTab ? iconWidth : LabelWidth(tab) + spare, height);
x += bounds.Width + TabGap;
if (Widgets.Button(bounds, iconTab ? "" : tab.Name, i == selected, scale) && i != selected)
{
@@ -87,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;
}
+46 -15
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,29 +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 Filter(string query, Func<int, bool> fits)
public IEnumerable<int> All()
{
string[] names = Names;
for (int i = 1; i < names.Length; i++)
if (names[i].Length > 0)
yield return i;
}
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));
}
}
+26 -1
View File
@@ -31,17 +31,26 @@ 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));
int healthBarIndex = layers.FindIndex(l => l.Name == "Vanilla: Entity Health Bars");
layers.Insert(healthBarIndex < 0 ? 1 : healthBarIndex + 1, new LegacyGameInterfaceLayer(Prefix + "Health Bars", DrawHealthBars, InterfaceScaleType.Game));
int builderIndex = layers.FindIndex(l => l.Name == "Vanilla: Builder Accessories Bar");
if (builderIndex >= 0)
layers.Insert(builderIndex + 1, new LegacyGameInterfaceLayer(Prefix + "Bank Buttons", DrawBankButtons, InterfaceScaleType.UI));
int mouseTextIndex = layers.FindIndex(l => l.Name == "Vanilla: Mouse Text");
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()
{
@@ -81,6 +90,22 @@ public static class InterfaceLayers
return true;
}
static bool DrawBankButtons()
{
try
{
if (Plugin.Active && !Main.gameMenu && Settings.BankButtons)
BankButtons.Draw(Main.spriteBatch);
}
catch (Exception e)
{
Log.Error("Bank buttons layer", e);
Widgets.Recover(Main.spriteBatch, Main.UIScaleMatrix);
}
return true;
}
static bool DrawWindows()
{
Unblock();
+2 -85
View File
@@ -1,89 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.GameContent.Creative;
using Terraria.ID;
using Terraria.Localization;
using Terraria.UI;
#if TMODLOADER
using Terraria.ModLoader;
#endif
namespace TerraSharp.UI;
public sealed class ItemBrowser() : GridBrowser("items", "Item Browser", new Vector2(660, 120), 532, 520, "Search items by name or ID", "items", new IdIndex(() => ItemCount, Lang.GetItemNameValue), Categories())
public sealed class ItemBrowser() : ItemGrid("items", "Item Browser", new Vector2(660, 120), 534, 554, [])
{
const int InventorySlots = 50;
#if TMODLOADER
static int ItemCount => ItemLoader.ItemCount;
#else
static int ItemCount => ItemID.Count;
#endif
enum WeaponClass { Melee, Ranged, Magic, Summon, Other }
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)];
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)),
new(Language.GetTextValue("CreativePowers.TabMisc"), type => !Fits(Weapon, type) && !Filters.Any(entry => Fits(entry.Filter, type)), 5),
];
static Item? ContentSample(int type) => ContentSamples.ItemsByType.TryGetValue(type, out Item? item) ? item : null;
// Whips must be classified as summon weapons before checking melee.
static WeaponClass? ClassOf(Item item)
{
if (!Weapon.FitsFilter(item) || item.pick > 0 || item.axe > 0 || item.hammer > 0 || item.ammo > 0)
return null;
#if TMODLOADER
if (item.CountsAsClass(DamageClass.Summon))
return WeaponClass.Summon;
if (item.CountsAsClass(DamageClass.Magic))
return WeaponClass.Magic;
if (item.CountsAsClass(DamageClass.Ranged))
return WeaponClass.Ranged;
if (item.CountsAsClass(DamageClass.Melee))
return WeaponClass.Melee;
#else
if (item.summon)
return WeaponClass.Summon;
if (item.magic)
return WeaponClass.Magic;
if (item.ranged)
return WeaponClass.Ranged;
if (item.melee)
return WeaponClass.Melee;
#endif
return WeaponClass.Other;
}
static bool Fits(IItemEntryFilter filter, int type) => ContentSample(type) is { } item && filter.FitsFilter(item);
readonly Dictionary<int, Item> _samples = [];
public override void OnOpen() => Main.playerInventory = true;
Item Sample(int type)
{
if (!_samples.TryGetValue(type, out Item? item))
{
item = new Item();
item.SetDefaults(type);
_samples[type] = item;
}
return item;
}
protected override void DrawContent(Rectangle body)
{
DrawSearchGrid(body, body.Y, body.Bottom - 30);
@@ -92,18 +17,10 @@ public sealed class ItemBrowser() : GridBrowser("items", "Item Browser", new Vec
protected override void DrawSlot(int type, Rectangle bounds, bool hot)
{
Widgets.Slot(bounds, hot);
Render.Item(Widgets.SpriteBatch, type, new Rectangle(bounds.X + 6, bounds.Y + 6, bounds.Width - 12, bounds.Height - 12));
DrawItem(type, bounds, hot);
if (!hot)
return;
#if TMODLOADER
Item sample = Sample(type);
ItemSlot.MouseHover(ref sample, ItemSlot.Context.CreativeInfinite);
#else
ItemSlot.MouseHover(Sample(type), ItemSlot.Context.CreativeInfinite);
#endif
if (Widgets.Click)
Give(type, Widgets.Shift);
else if (Widgets.RightClick)
+247
View File
@@ -0,0 +1,247 @@
using System;
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;
#endif
using TerraSharp.Features;
namespace TerraSharp.UI;
// Edits the cursor item, or the held item when the cursor is empty. Only type, stack and prefix survive a save.
public sealed class ItemEditor() : Window("itemEditor", "Item Editor", new Vector2(700, 100), 480, 600)
{
const int RowHeight = 30;
readonly Scroll _scroll = new();
readonly Item _reference = new();
(int Type, int Prefix) _referenceKey = (-1, -1);
(int Prefix, int Value, int Rarity)[] _prefixOptions = [];
int _prefixType = -1;
int _x, _y, _width;
bool _firstGroup;
public override void OnOpen() => Main.playerInventory = true;
public override void DrawBody(Rectangle body)
{
if (Main.gameMenu)
{
Render.TextCentered(Widgets.SpriteBatch, "Enter a world to use this.", body, Widgets.Dim, 0.9f);
return;
}
// Shows and edits base stats instead of the Damage Multiplier, Rapid Attack and Instant Break overrides.
HeldItemOverrides.Restore();
Item item = ItemFeatures.EditTarget;
DrawHeader(body, item);
int top = body.Y + 60;
var inner = new Rectangle(body.X, top, body.Width, body.Bottom - top);
Widgets.Panel(inner, Widgets.InnerBg);
if (item.type == 0)
{
Render.TextCentered(Widgets.SpriteBatch, "Hold an item or pick one up with the cursor.", inner, Widgets.Dim, 0.8f);
return;
}
var area = new Rectangle(inner.X + 12, inner.Y + 8, inner.Width - 38, inner.Height - 16);
_y = _scroll.Begin(area);
int start = _y;
(_x, _width, _firstGroup) = (area.X, area.Width, true);
Reference(item);
DrawActions(item);
DrawPrefixes(item);
DrawStats(item);
_scroll.End(area, _y - start + 4);
}
void DrawHeader(Rectangle body, Item item)
{
var slot = new Rectangle(body.X, body.Y, 52, 52);
Widgets.Slot(slot, false);
Render.Item(Widgets.SpriteBatch, item.type, new Rectangle(slot.X + 8, slot.Y + 8, slot.Width - 16, slot.Height - 16));
if (item.stack > 1)
Render.Text(Widgets.SpriteBatch, item.stack.ToString(), new Vector2(slot.X + 8, slot.Bottom - 20), Color.White, 0.7f);
string source = Main.mouseItem.type != 0 ? "Cursor item" : "Held item";
Render.Text(Widgets.SpriteBatch, item.type == 0 ? "No item" : item.AffixName(), new Vector2(slot.Right + 12, body.Y + 4), item.type == 0 ? Widgets.Dim : Color.White, 0.95f);
Render.Text(Widgets.SpriteBatch, item.type == 0 ? source : $"{source} #{item.type}", new Vector2(slot.Right + 12, body.Y + 30), Widgets.Dim, 0.72f);
}
// Defaults for the current type and prefix; right-click on a stat resets to these.
void Reference(Item item)
{
if (_referenceKey == (item.type, item.prefix))
return;
_reference.SetDefaults(item.type);
if (item.prefix > 0)
_reference.Prefix(item.prefix);
_referenceKey = (item.type, item.prefix);
}
void Group(string title)
{
if (!_firstGroup)
_y += 8;
_firstGroup = false;
Widgets.Text(title, new Vector2(_x, _y + 2), Widgets.Header, 0.88f);
float width = Render.Measure(title, 0.88f).X;
Widgets.Separator(_x + (int)width + 12, _y + 12, _width - (int)width - 12);
_y += 30;
}
void DrawActions(Item item)
{
Group("Actions");
string[] labels = ["Max Stack", "Duplicate", "Reset Stats"];
int width = (_width - 12) / labels.Length;
for (int i = 0; i < labels.Length; i++)
{
if (!Widgets.Button(new Rectangle(_x + i * (width + 6), _y + 2, width, 30), labels[i], false, 0.8f))
continue;
switch (i)
{
case 0: ItemFeatures.MaxStack(); break;
case 1: ItemFeatures.Duplicate(); break;
case 2: ResetStats(item); break;
}
}
_y += 38;
}
static void ResetStats(Item item)
{
int stack = item.stack, prefix = item.prefix;
bool favorited = item.favorited;
item.SetDefaults(item.type);
if (prefix > 0)
item.Prefix(prefix);
(item.stack, item.favorited) = (stack, favorited);
}
static IEnumerable<int> Prefixes(Item item)
{
#if TMODLOADER
return Enumerable.Range(1, PrefixLoader.PrefixCount - 1).Where(item.CanApplyPrefix);
#else
return item.GetRollablePrefixes() ?? [];
#endif
}
static string PrefixName(int prefix)
{
#if TMODLOADER
if (prefix >= PrefixID.Count)
return PrefixLoader.GetPrefix(prefix)?.DisplayName.Value ?? $"Prefix {prefix}";
#endif
return Lang.prefix[prefix].Value;
}
// 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 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;
for (int i = 0; i < options.Length; i++)
{
var bounds = new Rectangle(_x + i % Columns * (width + Gap), _y + i / Columns * 34, width, 30);
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;
}
static void ApplyPrefix(Item item, int prefix)
{
int stack = item.stack;
bool favorited = item.favorited;
item.SetDefaults(item.type);
item.Prefix(prefix);
(item.stack, item.favorited) = (stack, favorited);
Widgets.Sound(2, -1, -1, 37);
}
void DrawStats(Item item)
{
Group("Stats");
bool weapon = _reference.damage > 0;
if (weapon)
{
item.damage = IntRow("Damage", item.damage, 0, Math.Max(200, _reference.damage * 10), _reference.damage);
item.crit = IntRow("Critical chance", item.crit, 0, 100, _reference.crit, "{0}%");
item.knockBack = FloatRow("Knockback", item.knockBack, 0f, 20f, _reference.knockBack, "{0:0.0}");
item.scale = FloatRow("Size", item.scale, 0.25f, 5f, _reference.scale, "{0:0.00}x");
}
if (_reference.useStyle != 0)
{
item.useTime = IntRow("Use time", item.useTime, 1, Math.Max(60, _reference.useTime), _reference.useTime, "{0} ticks");
item.useAnimation = IntRow("Use animation", item.useAnimation, 1, Math.Max(60, _reference.useAnimation), _reference.useAnimation, "{0} ticks");
}
if (_reference.shoot > 0)
item.shootSpeed = FloatRow("Shot speed", item.shootSpeed, 0f, 40f, _reference.shootSpeed, "{0:0.0}");
if (_reference.defense > 0 || _reference.accessory || _reference.headSlot >= 0 || _reference.bodySlot >= 0 || _reference.legSlot >= 0)
item.defense = IntRow("Defense", item.defense, 0, Math.Max(100, _reference.defense * 10), _reference.defense);
if (item.maxStack > 1)
item.stack = IntRow("Stack", item.stack, 1, item.maxStack, item.maxStack);
if (_reference.useStyle != 0)
item.autoReuse = BoolRow("Auto reuse", item.autoReuse);
}
float FloatRow(string label, float value, float minimum, float maximum, float fallback, string format)
{
var row = new Rectangle(_x, _y, _width, RowHeight - 2);
string id = "itemEditor|" + label;
bool hot = Widgets.Hot(row) || Widgets.ActiveSlider == id;
if (hot)
Widgets.Tooltip = "Drag to change. Right-click to reset.";
Render.TextMid(Widgets.SpriteBatch, $"{label}: {string.Format(format, value)}", row, hot ? Widgets.Yellow : Widgets.Dim, 0.85f);
float result = Widgets.ValueBar(id, row, MathHelper.Clamp(value, minimum, maximum), minimum, maximum, fallback);
_y += RowHeight;
return Widgets.ActiveSlider == id || result != MathHelper.Clamp(value, minimum, maximum) ? result : value;
}
int IntRow(string label, int value, int minimum, int maximum, int fallback, string format = "{0}") => (int)Math.Round(FloatRow(label, value, minimum, maximum, fallback, format.Replace("{0}", "{0:0}")));
bool BoolRow(string label, bool value)
{
value = Widgets.ToggleRow(new Rectangle(_x, _y, _width, 28), label, value);
_y += RowHeight;
return value;
}
}
+125
View File
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.GameContent.Creative;
using Terraria.ID;
using Terraria.Localization;
using Terraria.UI;
#if TMODLOADER
using Terraria.ModLoader;
#endif
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], Sorts)
{
#if TMODLOADER
static int ItemCount => ItemLoader.ItemCount;
#else
static int ItemCount => ItemID.Count;
#endif
enum WeaponClass { Melee, Ranged, Magic, Summon, Other }
static readonly IItemEntryFilter Weapon = new ItemFilters.Weapon();
// 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, 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.
static WeaponClass? ClassOf(Item item)
{
if (!Weapon.FitsFilter(item) || item.pick > 0 || item.axe > 0 || item.hammer > 0 || item.ammo > 0)
return null;
#if TMODLOADER
if (item.CountsAsClass(DamageClass.Summon))
return WeaponClass.Summon;
if (item.CountsAsClass(DamageClass.Magic))
return WeaponClass.Magic;
if (item.CountsAsClass(DamageClass.Ranged))
return WeaponClass.Ranged;
if (item.CountsAsClass(DamageClass.Melee))
return WeaponClass.Melee;
#else
if (item.summon)
return WeaponClass.Summon;
if (item.magic)
return WeaponClass.Magic;
if (item.ranged)
return WeaponClass.Ranged;
if (item.melee)
return WeaponClass.Melee;
#endif
return WeaponClass.Other;
}
static bool Fits(IItemEntryFilter filter, int type) => ContentSample(type) is { } item && filter.FitsFilter(item);
readonly Dictionary<int, Item> _samples = [];
Item Sample(int type)
{
if (!_samples.TryGetValue(type, out Item? item))
{
item = new Item();
item.SetDefaults(type);
_samples[type] = item;
}
return item;
}
protected void DrawItem(int type, Rectangle bounds, bool hot, bool selected = false)
{
Widgets.Slot(bounds, hot, selected);
Render.Item(Widgets.SpriteBatch, type, new Rectangle(bounds.X + 6, bounds.Y + 6, bounds.Width - 12, bounds.Height - 12));
if (!hot)
return;
#if TMODLOADER
Item sample = Sample(type);
ItemSlot.MouseHover(ref sample, ItemSlot.Context.CreativeInfinite);
#else
ItemSlot.MouseHover(Sample(type), ItemSlot.Context.CreativeInfinite);
#endif
}
}
+32
View File
@@ -0,0 +1,32 @@
using System.Collections.Generic;
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, 554, [new("Selected", list.Contains)])
{
protected override void DrawContent(Rectangle body)
{
DrawSearchGrid(body, body.Y, body.Bottom - 40);
var footer = new Rectangle(body.X + 4, body.Bottom - 34, body.Width - 4, 32);
Render.TextMid(Widgets.SpriteBatch, $"{list.Count} selected Click: add or remove", footer, Widgets.Dim, 0.72f);
if (!Widgets.Button(new Rectangle(footer.Right - 100, footer.Y + 1, 100, 30), "Clear", false, 0.8f))
return;
list.Clear();
Index.Refresh();
}
protected override void DrawSlot(int type, Rectangle bounds, bool hot)
{
DrawItem(type, bounds, hot, list.Contains(type));
if (!hot || !Widgets.Click)
return;
if (!list.Remove(type))
list.Add(type);
Index.Refresh();
Widgets.Sound(12);
}
}
+69 -2
View File
@@ -7,6 +7,7 @@ using Terraria.GameContent.Events;
using TerraSharp.Core;
using TerraSharp.Features;
using TerraSharp.Features.Combat;
using TerraSharp.Patches;
using TerraSharp.Simulation;
namespace TerraSharp.UI;
@@ -19,6 +20,9 @@ public sealed partial class MainWindow
{
Group("Survival");
Toggle("God Mode", ref Settings.GodMode);
Toggle("Ghost Mode", ref Settings.GhostMode);
if (Toggle("Defense Multiplier", ref Settings.DefenseBoost))
Sub(() => Slider("Multiplier", ref Settings.DefenseMult, 1f, 20f, 3f, "x{0:0.0}"));
Toggle("Infinite Breath", ref Settings.InfBreath);
Toggle("Infinite Flight", ref Settings.InfFlight);
Toggle("Infinite Health", ref Settings.InfHealth);
@@ -44,6 +48,16 @@ public sealed partial class MainWindow
case 0: PlayerFeatures.FullHeal(); break;
case 1: PlayerFeatures.TeleportSpawn(); break;
}
Group("Stats");
if (Toggle("Stats Overlay", ref Settings.ShowStats) && Buttons("Configure Overlay") == 0)
WindowManager.StatsSetup.ShowBeside(Bounds);
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()
@@ -97,6 +111,8 @@ public sealed partial class MainWindow
Slider("Max range", ref Settings.AimRange, 5f, 150f, 60f, "{0:0} tiles");
Toggle("Show target", ref Settings.AimDrawTarget);
});
Toggle("Auto Dodge", ref Settings.AutoDodge);
Toggle("Magic Bullets", ref Settings.MagicBullets);
Toggle("Shot Preview", ref Settings.ShotPreview);
Group("Enemies");
@@ -121,6 +137,15 @@ public sealed partial class MainWindow
void ItemsPage()
{
Group("Items");
if (Toggle("Auto Sell", ref Settings.AutoSell))
Sub(() =>
{
Note($"{Settings.SellList.Count} items listed. Favorited items are never sold.");
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);
Toggle("Item Vacuum", ref Settings.ItemVacuum);
@@ -135,8 +160,11 @@ public sealed partial class MainWindow
Toggle("Sonar", ref Settings.Sonar);
Group("Actions");
if (Buttons(WindowManager.Items.Open ? "Close Item Browser" : "Item Browser") == 0)
WindowManager.Toggle(WindowManager.Items);
switch (Buttons(WindowManager.Items.Open ? "Close Item Browser" : "Item Browser", WindowManager.Editor.Open ? "Close Item Editor" : "Item Editor"))
{
case 0: WindowManager.Toggle(WindowManager.Items); break;
case 1: WindowManager.Toggle(WindowManager.Editor); break;
}
}
void VisualsPage()
@@ -154,7 +182,17 @@ public sealed partial class MainWindow
Toggle("Tracers", ref Settings.EspLines);
Group("Targets");
Toggle("Boss cursor", ref Settings.EspBossCursor);
ToggleColor("Bosses (Rainbow)", ref Settings.EspBossRainbow, ref Settings.ColBoss, nameof(Settings.ColBoss));
ToggleColor("Chests", ref Settings.EspChests, ref Settings.ColChests, nameof(Settings.ColChests));
if (Settings.EspChests)
Sub(() =>
{
if (Toggle("Only with listed items", ref Settings.EspChestsListed))
Note($"{Settings.ChestList.Count} items listed. Multiplayer: only opened chests.");
if (Buttons(WindowManager.ChestList.Open ? "Close Item List" : "Edit Item List") == 0)
WindowManager.Toggle(WindowManager.ChestList);
});
ToggleColor("Critters", ref Settings.EspCritters, ref Settings.ColCritters, nameof(Settings.ColCritters));
ToggleColor("Friendly projectiles", ref Settings.EspProjFriendly, ref Settings.ColProjFriendly, nameof(Settings.ColProjFriendly));
ToggleColor("Ground items", ref Settings.EspItems, ref Settings.ColItems, nameof(Settings.ColItems));
@@ -173,6 +211,23 @@ public sealed partial class MainWindow
Toggle("Night Owl", ref Settings.NightOwl);
Toggle("Shine", ref Settings.Shine);
Toggle("Spelunker", ref Settings.Spelunker);
Group("Damage Numbers");
Note("Colors your hits by damage class. Crits are lighter.");
ToggleColor("Magic", ref Settings.DamageMagic, ref Settings.ColDamageMagic, nameof(Settings.ColDamageMagic));
ToggleColor("Melee", ref Settings.DamageMelee, ref Settings.ColDamageMelee, nameof(Settings.ColDamageMelee));
ToggleColor("Ranged", ref Settings.DamageRanged, ref Settings.ColDamageRanged, nameof(Settings.ColDamageRanged));
ToggleColor("Summon", ref Settings.DamageSummon, ref Settings.ColDamageSummon, nameof(Settings.ColDamageSummon));
Group("View");
Toggle("Full Bright", ref Settings.FullBright);
if (Toggle("Extended Zoom", ref Settings.ExtendedZoom))
Sub(() =>
{
float zoom = Main.GameZoomTarget;
Slider("Zoom", ref zoom, ZoomPatch.MinZoom, ZoomPatch.MaxZoom, 1f, "{0:0.00}x");
Main.GameZoomTarget = zoom;
});
}
void WorldPage()
@@ -228,6 +283,18 @@ public sealed partial class MainWindow
if (WorldFeatures.InvasionName is { } active && Buttons($"Stop {active}") == 0)
WorldFeatures.StopInvasion();
Group("Map");
Toggle("Map Teleport", ref Settings.MapTeleport);
if (Buttons(MapFeatures.Revealing ? $"Revealing... {MapFeatures.RevealProgress:0%}" : "Reveal Map") == 0 && !MapFeatures.Revealing)
MapFeatures.StartReveal();
Group("Spawning");
Note(NpcSpawner.Multiplayer ? "Multiplayer: boss summons only, spawn rate is ignored." : "Spawn rate only affects single player.");
if (Toggle("Spawn Rate", ref Settings.SpawnRateBoost))
Sub(() => Slider("Multiplier", ref Settings.SpawnRateMult, 0f, 20f, 5f, "x{0:0.0}", v => v < 0.05f ? "No spawns" : null));
if (Buttons(WindowManager.Npcs.Open ? "Close NPC Spawner" : "NPC Spawner") == 0)
WindowManager.Toggle(WindowManager.Npcs);
Group("Waypoints");
Note("Saved per world.");
if (Buttons("Save Current Position") == 0)
+18
View File
@@ -12,6 +12,11 @@ public sealed partial class MainWindow
["Infinite Flight"] = "Unlimited wing and rocket boot flight time.",
["Auto Heal"] = "Uses Quick Heal below the set health level. Waits for potion sickness to end.",
["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.",
["Gravity Flip"] = "Walk on the ceiling.",
["Lava Immune"] = "Also protects against hot blocks.",
@@ -26,6 +31,8 @@ public sealed partial class MainWindow
["Prediction"] = "Leads moving enemies and accounts for projectile drop.",
["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. 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.",
@@ -34,6 +41,10 @@ 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.",
["Auto Fishing"] = "Reels in on a bite while you hold a fishing pole.",
["Recast"] = "Casts again where you last cast by hand.",
["Catch enemies"] = "Reels in enemy catches too.",
@@ -44,6 +55,7 @@ public sealed partial class MainWindow
["Distance"] = "Shown in feet. 1 tile = 2 ft.",
["Tracers"] = "Draws lines from you to each target.",
["Projectile paths"] = "Shows projectile paths until they hit a block or expire.",
["Boss cursor"] = "Shows off-screen bosses at the screen edge with their map head.",
["Bosses (Rainbow)"] = "Cycles boss colors. Turn off to use the chosen color.",
["Spelunker"] = "Makes ores, gems and treasure glow.",
["Hunter"] = "Makes enemies glow.",
@@ -51,6 +63,12 @@ public sealed partial class MainWindow
["Night Owl"] = "Brightens dark areas.",
["Danger Sense"] = "Makes traps and hazards glow.",
["Biome Sight"] = "Makes Corruption, Crimson and Hallow blocks glow.",
["Full Bright"] = "Lights everything at full brightness.",
["Extended Zoom"] = "Zoom in up to 8x and out until the edge of what the game draws.",
["Chests"] = "Marks chests. Their contents can be filtered with an item list.",
["Only with listed items"] = "Only marks chests holding an item from the list.",
["Map Teleport"] = "Right-click the fullscreen map to teleport there.",
["Reveal Map"] = "Reveals the whole map. Multiplayer only reveals areas the server has sent.",
["Full Heal"] = "Refills health and mana.",
["TP to Spawn"] = "Recalls like a Magic Mirror: to your bed if set, otherwise the world's spawn point.",
["Reset Learned Ballistics"] = "Clears learned projectile data. Learns it again as you shoot.",
+5 -5
View File
@@ -30,12 +30,12 @@ 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)),
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)),
new Page("Items", ItemsPage, () => Count(Settings.InstantBreak, Settings.InfItems, Settings.ItemVacuum, Settings.AutoFish, Settings.Sonar)),
new Page("Visuals", VisualsPage, () => Count(Settings.Esp, Settings.Spelunker, Settings.Hunter, Settings.Shine, Settings.NightOwl, Settings.DangerSense, Settings.BiomeSight)),
new Page("World", WorldPage, () => Count(Settings.FreezeTime)),
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.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),
];
+68
View File
@@ -0,0 +1,68 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Terraria.ID;
#if TMODLOADER
using Terraria.ModLoader;
#endif
using TerraSharp.Features;
namespace TerraSharp.UI;
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;
#if TMODLOADER
static int NpcCount => NPCLoader.NPCCount;
#else
static int NpcCount => NPCID.Count;
#endif
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 };
static bool IsCritter(int type) => NPCID.Sets.CountsAsCritter[type] || Main.npcCatchable[type];
protected override void DrawContent(Rectangle body)
{
if (_multiplayer != NpcSpawner.Multiplayer)
{
_multiplayer = NpcSpawner.Multiplayer;
Index.Refresh();
}
DrawSearchGrid(body, body.Y, body.Bottom - 30);
string hint = _multiplayer ? "Multiplayer: only boss summons the server accepts" : $"Click: spawn in front of you Shift click: spawn {Batch}";
Render.TextMid(Widgets.SpriteBatch, hint, new Rectangle(body.X + 4, body.Bottom - 26, body.Width, 26), Widgets.Dim, 0.72f);
}
protected override void DrawSlot(int type, Rectangle bounds, bool hot)
{
Widgets.Slot(bounds, hot);
Main.instance.LoadNPC(type);
if (GameTextures.Get("Npc", type) is { } texture)
Widgets.Icon(texture, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), GameTextures.Frame(texture, 1, Main.npcFrameCount[type], 0, 0));
if (!hot)
return;
string action = _multiplayer ? "Click to summon" : "Click to spawn";
Widgets.Tooltip = $"{Index.Name(type)}\n{(IsBoss(type) ? "Boss" : "NPC")} #{type} {action}";
if (!Widgets.Click)
return;
NpcSpawner.Spawn(type, Widgets.Shift ? Batch : 1);
Widgets.Sound(12);
}
}
+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);
}
}
+4 -2
View File
@@ -127,7 +127,7 @@ public static class Render
return text + "...";
}
public static void Item(SpriteBatch spriteBatch, int type, Rectangle bounds)
public static void Item(SpriteBatch spriteBatch, int type, Rectangle bounds, Color? color = null)
{
if (type <= 0)
return;
@@ -139,8 +139,10 @@ public static class Render
float scale = Math.Min(1f, Math.Min(bounds.Width / (float)frame.Width, bounds.Height / (float)frame.Height));
Vector2 size = new Vector2(frame.Width, frame.Height) * scale;
spriteBatch.Draw(texture, new Vector2(bounds.X + (bounds.Width - size.X) / 2f, bounds.Y + (bounds.Height - size.Y) / 2f), frame, Color.White, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
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);
}
+43
View File
@@ -0,0 +1,43 @@
using Microsoft.Xna.Framework;
using Terraria;
using TerraSharp.Core;
using TerraSharp.Features;
namespace TerraSharp.UI;
public sealed class StatsConfig() : Window("statsConfig", "Stats Overlay", Vector2.Zero, 300, 640)
{
const int RowHeight = 30;
public override bool Popup => true;
public void ShowBeside(Rectangle anchor)
{
Pos = new Vector2(anchor.Right + 8, anchor.Y);
WindowManager.Show(this);
}
public override void DrawBody(Rectangle body)
{
int y = body.Y;
Render.TextMid(Widgets.SpriteBatch, "Drag the overlay while the menu is open.", new Rectangle(body.X + 2, y, body.Width, 22), Widgets.Dim * 0.8f, 0.7f);
y += 26;
Settings.StatIcons = Widgets.ToggleRow(new Rectangle(body.X, y, body.Width, 28), "Icons", Settings.StatIcons);
y += RowHeight;
Widgets.Separator(body.X, y + 2, body.Width);
y += 10;
foreach ((StatLine line, string label, _) in StatsWindow.Lines)
{
bool shown = Widgets.ToggleRow(new Rectangle(body.X, y, body.Width, 28), label, (Settings.StatLines & line) != 0);
Settings.StatLines = shown ? Settings.StatLines | line : Settings.StatLines & ~line;
y += RowHeight;
}
if (Widgets.Button(new Rectangle(body.X, body.Bottom - 32, body.Width, 32), "Reset Stats", false, 0.82f))
Stats.Reset();
if (Widgets.Hot(new Rectangle(body.X, body.Bottom - 32, body.Width, 32)))
Widgets.Tooltip = "Clears damage, kills, deaths and session time.";
}
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using TerraSharp.Core;
using TerraSharp.Features;
namespace TerraSharp.UI;
// Overlay without a frame; it only takes the mouse, to be dragged, while the menu is open.
public sealed class StatsWindow() : Window("stats", "", new Vector2(20, 280), PanelWidth, RowHeight)
{
const int PanelWidth = 240, RowHeight = 24, IconSize = 20;
const float TextScale = 0.8f;
// Main.DrawInfoAccs converts velocity to the stopwatch's miles per hour this way.
const float MilesPerHour = 216000f / 42240f;
// Icons are the accessories and items that show or affect each stat in vanilla.
public static readonly (StatLine Line, string Label, int Icon)[] Lines =
[
(StatLine.Dps, "DPS", ItemID.DPSMeter),
(StatLine.PeakDps, "Peak DPS", ItemID.DestroyerEmblem),
(StatLine.Damage, "Damage dealt", ItemID.WarriorEmblem),
(StatLine.BestHit, "Best hit", ItemID.AvengerEmblem),
(StatLine.Kills, "Kills", ItemID.TallyCounter),
(StatLine.Time, "Session time", ItemID.GoldWatch),
(StatLine.Deaths, "Deaths", ItemID.Tombstone),
(StatLine.Minions, "Minions", ItemID.SlimeStaff),
(StatLine.Sentries, "Sentries", ItemID.QueenSpiderStaff),
(StatLine.Defense, "Defense", ItemID.CobaltShield),
(StatLine.DamageReduction, "Damage reduction", ItemID.WormScarf),
(StatLine.Crit, "Critical chance", ItemID.EyeoftheGolem),
(StatLine.LifeRegen, "Life regen", ItemID.BandofRegeneration),
(StatLine.Speed, "Speed", ItemID.Stopwatch),
(StatLine.Luck, "Luck", ItemID.LuckPotion),
];
public override bool Open { get => Settings.ShowStats; set => Settings.ShowStats = value; }
public override bool Frameless => true;
public override bool Interactive => WindowManager.Menu.Open && !Main.gameMenu;
public override void DrawBody(Rectangle body)
{
if (Main.gameMenu)
return;
if (Interactive)
Render.Outline(Widgets.SpriteBatch, body, Widgets.Hot(body) ? Widgets.Yellow * 0.6f : Color.White * 0.2f);
Player player = Main.LocalPlayer;
int y = body.Y, shown = 0;
foreach ((StatLine line, string label, int icon) in Lines)
{
if ((Settings.StatLines & line) == 0)
continue;
Row(new Rectangle(body.X, y, body.Width, RowHeight), label, Value(line, player), Settings.StatIcons ? icon : null);
y += RowHeight;
shown++;
}
Height = Math.Max(1, shown) * RowHeight;
}
static string Value(StatLine line, Player player) => line switch
{
StatLine.Dps => $"{Stats.Dps:0}",
StatLine.PeakDps => $"{Stats.PeakDps:0}",
StatLine.Damage => $"{Stats.Damage:N0}",
StatLine.BestHit => $"{Stats.BestHit:N0}",
StatLine.Kills => $"{Stats.Kills:N0}",
StatLine.Time => SessionTime(),
StatLine.Deaths => $"{Stats.Deaths}",
StatLine.Minions => $"{player.slotsMinions:0.#} / {player.maxMinions}",
StatLine.Sentries => $"{Sentries(player)} / {player.maxTurrets}",
StatLine.Defense => $"{(int)player.statDefense}",
StatLine.DamageReduction => $"{player.endurance:0%}",
StatLine.Crit => player.HeldItem.damage > 0 ? $"{player.GetWeaponCrit(player.HeldItem)}%" : "-",
StatLine.LifeRegen => $"{player.lifeRegen / 2f:0.#}/s",
StatLine.Speed => $"{player.velocity.Length() * MilesPerHour:0} mph",
StatLine.Luck => $"{player.luck:0.00}",
_ => "",
};
static string SessionTime()
{
TimeSpan time = TimeSpan.FromSeconds(Stats.Ticks / 60);
return $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}";
}
static int Sentries(Player player)
{
int count = 0;
for (int i = 0; i < Main.maxProjectiles; i++)
if (Main.projectile[i] is { active: true, sentry: true } projectile && projectile.owner == player.whoAmI)
count++;
return count;
}
static void Row(Rectangle row, string label, string value, int? icon)
{
int textX = row.X;
if (icon is { } type)
{
Render.Item(Widgets.SpriteBatch, type, new Rectangle(row.X, row.Y + (row.Height - IconSize) / 2, IconSize, IconSize));
textX += IconSize + 6;
}
Render.TextMid(Widgets.SpriteBatch, label, new Rectangle(textX, row.Y, row.Right - textX, row.Height), Widgets.Dim, TextScale);
Render.TextRight(Widgets.SpriteBatch, value, row, Color.White, TextScale);
}
}
+16 -2
View File
@@ -227,6 +227,20 @@ public static class Widgets
return true;
}
public static bool ToggleRow(Rectangle row, string label, bool value)
{
bool hot = Hot(row);
if (hot && Click)
{
value = !value;
Sound(12);
}
Render.TextMid(SpriteBatch, label, row, hot ? Yellow : value ? On : Color.White, 0.85f);
ToggleIcon(new Rectangle(row.Right - 28, row.Y + 2, 24, 24), value, hot);
return value;
}
public static void ToggleIcon(Rectangle bounds, bool on, bool hot)
{
Render.TextRight(SpriteBatch, on ? "On" : "Off", new Rectangle(bounds.X - 44, bounds.Y, 38, bounds.Height), on ? On : Dim * 0.75f, 0.78f);
@@ -247,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);
+15 -3
View File
@@ -6,15 +6,27 @@ 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;
public int Width { get; } = width;
public int Height { get; } = height;
public int Height { get; protected set; } = height;
public bool Open { get; set; }
public virtual bool Open { get; set; }
// Draws only the body: no panel, title or close button.
public virtual bool Frameless => false;
// Closes on a click outside it or when the menu closes.
public virtual bool Popup => false;
// A window that is not interactive lets the mouse through to the game.
public virtual bool Interactive => true;
// 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);
+40 -6
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
@@ -13,11 +14,20 @@ public static class WindowManager
public static readonly MainWindow Menu = new();
public static readonly ItemBrowser Items = new();
public static readonly BuffBrowser Buffs = new();
public static readonly NpcBrowser Npcs = new();
public static readonly ItemEditor Editor = new();
public static readonly ItemPicker SellList = new("sellList", "Auto Sell List", new Vector2(680, 130), Settings.SellList);
public static readonly ItemPicker ChestList = new("chestList", "Chest Finder Items", new Vector2(700, 140), Settings.ChestList);
public static readonly StatsWindow Stats = new();
public static readonly StatsConfig StatsSetup = new();
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, Menu];
static readonly List<Window> Stack = [Party, Items, Buffs, Npcs, Editor, SellList, ChestList, Stats, Players, Inspector, Menu, StatsSetup];
static Window? _dragging;
static Vector2 _dragOffset;
@@ -25,11 +35,11 @@ public static class WindowManager
public static bool MouseOver { get; private set; }
public static bool AnyOpen => Stack.Exists(w => w.Open);
public static bool AnyOpen => Stack.Exists(w => w.Open && w.Interactive);
public static IEnumerable<Window> All => Stack;
public static bool Contains(Point point) => Stack.Exists(w => w.Open && w.Bounds.Contains(point));
public static bool Contains(Point point) => Stack.Exists(w => w.Open && w.Interactive && w.Bounds.Contains(point));
public static void Show(Window window)
{
@@ -51,6 +61,10 @@ public static class WindowManager
window.Open = false;
Widgets.Sound(11);
Unfocus();
if (window == Menu)
foreach (Window popup in Stack)
if (popup.Popup)
popup.Open = false;
}
public static void Toggle(Window window)
@@ -107,7 +121,8 @@ public static class WindowManager
Unfocus();
}
static Vector2 ClampPosition(Window window, Vector2 position) => new(MathHelper.Clamp(position.X, 0f, Math.Max(0f, Main.screenWidth / Main.UIScale - window.Width)), MathHelper.Clamp(position.Y, 0f, Math.Max(0f, Main.screenHeight / Main.UIScale - window.Height)));
// PlayerInput.SetZoom_UI already divides Main.screenWidth/Height by the UI scale inside UI layers.
static Vector2 ClampPosition(Window window, Vector2 position) => new(MathHelper.Clamp(position.X, 0f, Math.Max(0f, PlayerInput.RealScreenWidth / Main.UIScale - window.Width)), MathHelper.Clamp(position.Y, 0f, Math.Max(0f, PlayerInput.RealScreenHeight / Main.UIScale - window.Height)));
public static void Draw(SpriteBatch spriteBatch)
{
@@ -117,8 +132,10 @@ public static class WindowManager
foreach (Window window in Stack)
window.Pos = ClampPosition(window, window.Pos);
Window? top = Stack.FindLast(w => w.Open && w.Bounds.Contains(Widgets.Mouse));
Window? top = Stack.FindLast(w => w.Open && w.Interactive && w.Bounds.Contains(Widgets.Mouse));
MouseOver = Widgets.InputOk && top is not null;
// Popups the click landed outside of; a button that toggles one this frame still works.
Window[] dismissed = Widgets.Click ? [.. Stack.Where(w => w.Open && w.Popup && w != top)] : [];
if (Widgets.Click)
if (top is null)
Unfocus();
@@ -143,6 +160,8 @@ public static class WindowManager
}
Widgets.Blocked = false;
foreach (Window popup in dismissed)
popup.Open = false;
if (Widgets.Tooltip is not null && MouseOver)
Main.instance.MouseText(Widgets.Tooltip, 0, 0, -1, -1, -1, -1, 0);
@@ -151,6 +170,21 @@ public static class WindowManager
static void DrawFrame(Window window)
{
Rectangle bounds = window.Bounds;
if (window.Frameless)
{
bool hot = Widgets.Hot(bounds) && window.Draggable;
if (hot)
Widgets.Tooltip = "Drag to move";
if (hot && Widgets.Click)
{
_dragging = window;
_dragOffset = window.Pos - new Vector2(Widgets.Mouse.X, Widgets.Mouse.Y);
}
window.DrawBody(bounds);
return;
}
Render.Fill(Widgets.SpriteBatch, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), new Color(16, 20, 40) * 0.92f);
Widgets.Panel(bounds, Widgets.WindowBg);
@@ -160,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);
}
+80
View File
@@ -36,9 +36,51 @@ public static class Esp
DrawNpcs(spriteBatch);
if (Settings.EspItems)
DrawItems(spriteBatch);
if (Settings.EspChests)
DrawChests(spriteBatch);
if (Settings.EspWaypoints)
foreach (Waypoint waypoint in Waypoints.Current)
Box(spriteBatch, waypoint.Position, _player.width, _player.height, Premultiplied(Settings.ColWaypoint), waypoint.Name, -1, -1, true, true);
if (Settings.EspBossCursor)
DrawBossCursors(spriteBatch);
}
// Off-screen bosses get their map head and an arrow at the screen edge.
static void DrawBossCursors(SpriteBatch spriteBatch)
{
const float Margin = 40f;
var center = new Vector2(Main.screenWidth / 2f, Main.screenHeight / 2f);
Color color = Premultiplied(Settings.EspBossRainbow ? Rainbow() : Settings.ColBoss);
for (int i = 0; i < Main.maxNPCs; i++)
{
NPC npc = Main.npc[i];
if (!npc.active)
continue;
int head = npc.GetBossHeadTextureIndex();
if (head < 0 && !npc.boss)
continue;
Vector2 topLeft = Render.ToScreen(npc.position), bottomRight = Render.ToScreen(npc.position + new Vector2(npc.width, npc.height));
if (bottomRight.X >= 0 && bottomRight.Y >= 0 && topLeft.X <= Main.screenWidth && topLeft.Y <= Main.screenHeight)
continue;
Vector2 offset = (topLeft + bottomRight) / 2f - center;
float scale = Math.Min((center.X - Margin) / Math.Abs(offset.X), (center.Y - Margin) / Math.Abs(offset.Y));
Vector2 position = center + offset * scale, direction = Vector2.Normalize(offset), side = new(-direction.Y, direction.X);
Vector2 tip = position + direction * 28f, back = tip - direction * 10f;
Render.Line(spriteBatch, back + side * 8f, tip, color, 2f);
Render.Line(spriteBatch, back - side * 8f, tip, color, 2f);
if (GameTextures.Get("NpcHeadBoss", head) is { } texture)
spriteBatch.Draw(texture, position, null, Color.White, npc.GetBossHeadRotation(), new Vector2(texture.Width, texture.Height) / 2f, 1f, npc.GetBossHeadSpriteEffects(), 0f);
else
Render.Fill(spriteBatch, new Rectangle((int)position.X - 6, (int)position.Y - 6, 12, 12), color);
if (Settings.EspDistance)
Render.TextCenteredAt(spriteBatch, $"{(int)(Vector2.Distance(npc.Center, _player.Center) / 8f)} ft", position.X, position.Y + 18f, color, 0.7f);
}
}
static void DrawProjectiles(SpriteBatch spriteBatch)
@@ -116,6 +158,44 @@ public static class Esp
}
}
// Multiplayer clients only know the contents of chests they have opened.
static void DrawChests(SpriteBatch spriteBatch)
{
Color color = Premultiplied(Settings.ColChests);
for (int i = 0; i < Main.maxChests; i++)
{
Chest? chest = Main.chest[i];
if (chest is not null && ChestLabel(chest) is { } label)
Box(spriteBatch, new Vector2(chest.x * 16, chest.y * 16), 32, 32, color, label, -1, -1, true, true);
}
}
static string? ChestLabel(Chest chest)
{
string name = string.IsNullOrEmpty(chest.name) ? "Chest" : chest.name;
if (!Settings.EspChestsListed)
return name;
if (chest.item is null)
return null;
Item? first = null;
int matches = 0;
foreach (Item? item in chest.item)
{
if (item is null || item.type == 0 || !Settings.ChestList.Contains(item.type))
continue;
first ??= item;
matches++;
}
if (first is null)
return null;
string found = first.stack > 1 ? $"{first.Name} x{first.stack}" : first.Name;
return matches > 1 ? $"{name}: {found} +{matches - 1}" : $"{name}: {found}";
}
// Anchors and scale match Main.DrawInterface_14_EntityHealthBars.
static (Vector2 Position, float Scale) NpcBar(NPC npc)
{