diff --git a/4.0.0/MiniMapLibrary.dll b/4.0.0/MiniMapLibrary.dll deleted file mode 100644 index af5d162..0000000 Binary files a/4.0.0/MiniMapLibrary.dll and /dev/null differ diff --git a/4.0.0/MiniMapMod.dll b/4.0.0/MiniMapMod.dll deleted file mode 100644 index 3182d44..0000000 Binary files a/4.0.0/MiniMapMod.dll and /dev/null differ diff --git a/4.0.0/manifest.json b/4.0.0/manifest.json deleted file mode 100644 index 0fe10bf..0000000 --- a/4.0.0/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "MiniMapMod", - "version_number": "4.0.0", - "website_url": "/ArtyRedd", - "description": "Adds a MiniMap to your game v4", - "dependencies": [ - "bbepis-BepInExPack-5.4.9", - "RiskofThunder-HookGenPatcher-1.2.1" - ] -} diff --git a/4.2.0/MiniMapLibrary.dll b/4.2.0/MiniMapLibrary.dll new file mode 100644 index 0000000..cac428f Binary files /dev/null and b/4.2.0/MiniMapLibrary.dll differ diff --git a/4.2.0/MiniMapMod.dll b/4.2.0/MiniMapMod.dll new file mode 100644 index 0000000..d75f827 Binary files /dev/null and b/4.2.0/MiniMapMod.dll differ diff --git a/4.0.0/README.md b/4.2.0/README.md similarity index 76% rename from 4.0.0/README.md rename to 4.2.0/README.md index 136c6be..cbfb5b4 100644 --- a/4.0.0/README.md +++ b/4.2.0/README.md @@ -5,8 +5,10 @@ Adds a contemporary Minimap to the game. - Implements a Mini Map In-Game. - Show interactable items, shrines, teleporters, enemies and more. +- Shows dropped items and equipment as small cyan dots. - Single and Multiplayer Compatible. - Every icon is configurable for color, size and can be toggled on/off. +- Works with all DLCs (Survivors of the Void, Seekers of the Storm, Alloyed Collective, Hallowed Concepts). #### Available Icons | Icon | Available | Configurable | @@ -32,10 +34,11 @@ Adds a contemporary Minimap to the game. | Fans/Special | ✔ | ✔ | | Portals | ✔ | ✔ | | Totems | ✔ | ✔ | -| Dropped Items | ✔ | ✔ | +| Dropped Items / Equipment / Lunar Coins | ✔ | ✔ | +| Command Cubes / Void Potentials | ✔ | ✔ | | Newt Altar | ✔ | ✔ | -| Timed Chests | ❌ | ❌ | -| Stealth Chests | ❌ | ❌ | +| Timed Chests | ✔ (off by default) | ✔ | +| Stealth Chests | ✔ (off by default) | ✔ | ### Showcase @@ -43,16 +46,21 @@ Adds a contemporary Minimap to the game. ### Dependencies - BepInEx +- HookGenPatcher +- RoR2BepInExPack ### Manual Installation - Place MiniMapLibrary.dll into your BepInEx/plugins folder. - Place MiniMapMod.dll into your BepInEx/plugins folder. +- BepInExPack 5.4.2122 needs Newtonsoft.Json, which the game doesn't ship anymore. RoR2BepInExPack provides it, so install that too (mod managers do this automatically). ### How to Use -- Use 'M' to hide or show the minimap. +- Use 'M' to hide or show the minimap. +- Use '=' and '-' to zoom in and out. ### Troubleshooting - Toggle 'M' to hide and show minimap to force a refresh and/or a re-draw if something wonky is happening on the minimap. +- Set `LogLevel = debug` under `[Settings.General]` in the config and play a stage. Every few seconds the log shows what the minimap found and drew, which helps a lot with bug reports. ### Config BepInEx Config. Each icon is individually configurable. @@ -67,6 +75,8 @@ BepInEx Config. Each icon is individually configurable. - Elevation Marker Height - Elevation Marker Offset +Map size, position, camera rotation, zoom keys and scan intervals are configurable too. + ### Upcoming Changes None - Final Release @@ -76,8 +86,21 @@ If you have an issue, discover a bug, or have a recommendation please file an is ### Collaborators Huge thanks to these awesome people who assisted in creating awesome features for this mod [Arcafanetiz](https://github.com/Arcafanetiz) +[winterofhell](https://github.com/winterofhell) ### Change Log +Release 4.2.0 +- updated for the current game version and all DLCs (Seekers of the Storm, Alloyed Collective, Hallowed Concepts) +- dropped items, equipment, lunar coins, command cubes and void potentials now show up as small light-cyan dots, and disappear as soon as they're picked up +- all icons are about 10% bigger, old configs that still use the default sizes get the new ones automatically +- objects are now tracked through the game's own registries instead of rescanning the whole scene, so no more stutter every few seconds +- timed and stealth chests can be turned on in the config (still off by default) +- added End of the Cycle enemies +- map no longer overlaps the objective panel +- added map size, position and rotation settings +- added debug logging for bug reports +- fixed duplicate icons and icons sticking around after objects were destroyed + Release 4.0.0 - Finally added newt altars to the search, since everyone has been begging for it for literally years diff --git a/4.0.0/icon.png b/4.2.0/icon.png similarity index 100% rename from 4.0.0/icon.png rename to 4.2.0/icon.png diff --git a/4.2.0/manifest.json b/4.2.0/manifest.json new file mode 100644 index 0000000..1741848 --- /dev/null +++ b/4.2.0/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "MiniMapMod", + "version_number": "4.2.0", + "website_url": "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/artyredd/MiniMapMod", + "description": "A configurable HUD minimap showing chests, shops, shrines, teleporters, drones, players, enemies and dropped items, with elevation arrows and zoom. Supports all DLCs.", + "dependencies": [ + "bbepis-BepInExPack-5.4.2122", + "RiskofThunder-HookGenPatcher-1.2.9", + "RiskofThunder-RoR2BepInExPack-1.46.0" + ] +} diff --git a/MiniMapLibrary/Basic/Dimension2D.cs b/MiniMapLibrary/Basic/Dimension2D.cs index ab01a8a..0374481 100644 --- a/MiniMapLibrary/Basic/Dimension2D.cs +++ b/MiniMapLibrary/Basic/Dimension2D.cs @@ -15,6 +15,8 @@ public float Width get => _Width; set { + if (_Width == value) + return; _Width = value; OnChanged?.Invoke(this); } @@ -24,6 +26,8 @@ public float Height get => _Height; set { + if (_Height == value) + return; _Height = value; OnChanged?.Invoke(this); } diff --git a/MiniMapLibrary/Basic/MapProjection.cs b/MiniMapLibrary/Basic/MapProjection.cs new file mode 100644 index 0000000..2e9745f --- /dev/null +++ b/MiniMapLibrary/Basic/MapProjection.cs @@ -0,0 +1,51 @@ +using System; +using UnityEngine; + +namespace MiniMapLibrary +{ + public static class MapProjection + { + // Use one scale on both axes so bearings remain accurate on narrow stages. + public static float GetScale(Range3D bounds, Dimension2D size, float zoom, float worldRadius = 0f) + { + float width = Numeric.Clamp(size.Width, 1f, 4096f, 180f); + float height = Numeric.Clamp(size.Height, 1f, 4096f, 180f); + zoom = Numeric.Clamp(zoom, 0.1f, 20f, 1f); + + if (Numeric.IsFinite(worldRadius) && worldRadius > 0f) + return Math.Min(width, height) * zoom / (2f * Math.Max(worldRadius, 1f)); + + float xSpan = Numeric.Clamp(bounds?.X.Difference ?? 0f, 100f, 1000000f, 100f); + float zSpan = Numeric.Clamp(bounds?.Z.Difference ?? 0f, 100f, 1000000f, 100f); + return Math.Min(width / xSpan, height / zSpan) * zoom; + } + + public static Vector2 ProjectOffset(Vector3 position, Vector3 origin, float scale) + { + return new Vector2((position.x - origin.x) * scale, (position.z - origin.z) * scale); + } + + public static bool TryProject(Vector3 position, Vector3 origin, float scale, float sine, float cosine, out Vector2 result) + { + result = Vector2.zero; + if (!Numeric.IsFinite(position.x) || !Numeric.IsFinite(position.y) || !Numeric.IsFinite(position.z) + || !Numeric.IsFinite(origin.x) || !Numeric.IsFinite(origin.y) || !Numeric.IsFinite(origin.z) + || !Numeric.IsFinite(scale) || scale <= 0f || !Numeric.IsFinite(sine) || !Numeric.IsFinite(cosine)) + return false; + result = Rotate(ProjectOffset(position, origin, scale), sine, cosine); + return Numeric.IsFinite(result.x) && Numeric.IsFinite(result.y); + } + + public static bool IsInside(Vector2 position, float sine, float cosine, float halfWidth, float halfHeight, float padding) + { + return IsInside(Rotate(position, sine, cosine), halfWidth, halfHeight, padding); + } + + public static Vector2 Rotate(Vector2 position, float sine, float cosine) => new( + position.x * cosine - position.y * sine, position.x * sine + position.y * cosine); + + public static bool IsInside(Vector2 position, float halfWidth, float halfHeight, float padding) => + Numeric.IsFinite(position.x) && Numeric.IsFinite(position.y) + && Math.Abs(position.x) <= halfWidth + padding && Math.Abs(position.y) <= halfHeight + padding; + } +} diff --git a/MiniMapLibrary/Basic/Numeric.cs b/MiniMapLibrary/Basic/Numeric.cs new file mode 100644 index 0000000..3af5d8a --- /dev/null +++ b/MiniMapLibrary/Basic/Numeric.cs @@ -0,0 +1,14 @@ +using System; + +namespace MiniMapLibrary +{ + public static class Numeric + { + public static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + + public static float Clamp(float value, float minimum, float maximum, float fallback) + { + return IsFinite(value) ? Math.Max(minimum, Math.Min(maximum, value)) : fallback; + } + } +} diff --git a/MiniMapLibrary/Basic/Range.cs b/MiniMapLibrary/Basic/Range.cs index 9f61a93..d60bc19 100644 --- a/MiniMapLibrary/Basic/Range.cs +++ b/MiniMapLibrary/Basic/Range.cs @@ -1,43 +1,29 @@ -using System; -using System.Collections.Generic; -using System.Text; - namespace MiniMapLibrary { public class Range1D { - public float Min { get; set; } - - public float Max { get; set; } + public float Min { get; set; } = float.PositiveInfinity; + public float Max { get; set; } = float.NegativeInfinity; - /// - /// Equivalent to - - /// - public float Difference => Max - Min; - - /// - /// Equivalent to -(neg) - /// - public float Offset => -Min; + public bool IsValid => Numeric.IsFinite(Min) && Numeric.IsFinite(Max) && Min <= Max; + public float Difference => IsValid ? Max - Min : 0f; + public float Offset => IsValid ? -Min : 0f; public void CheckValue(float value) { + if (!Numeric.IsFinite(value)) + return; + if (value < Min) - { Min = value; - return; - } if (value > Max) - { Max = value; - return; - } } public void Clear() { - Min = float.MaxValue; - Max = float.MinValue; + Min = float.PositiveInfinity; + Max = float.NegativeInfinity; } } } diff --git a/MiniMapLibrary/Basic/Range3D.cs b/MiniMapLibrary/Basic/Range3D.cs index 6be178f..15ee703 100644 --- a/MiniMapLibrary/Basic/Range3D.cs +++ b/MiniMapLibrary/Basic/Range3D.cs @@ -1,22 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Text; using UnityEngine; namespace MiniMapLibrary { public class Range3D { - public Range1D X { get; set; } = new Range1D(); - - public Range1D Y { get; set; } = new Range1D(); - - public Range1D Z { get; set; } = new Range1D(); + public Range1D X { get; set; } = new(); + public Range1D Y { get; set; } = new(); + public Range1D Z { get; set; } = new(); public void CheckValue(Vector3 position) { X.CheckValue(position.x); - //Y.CheckValue(position.y); + Y.CheckValue(position.y); Z.CheckValue(position.z); } diff --git a/MiniMapLibrary/Basic/Timer.cs b/MiniMapLibrary/Basic/Timer.cs index 2c5df6c..7c53173 100644 --- a/MiniMapLibrary/Basic/Timer.cs +++ b/MiniMapLibrary/Basic/Timer.cs @@ -1,46 +1,51 @@ -using System; -using System.Collections.Generic; -using System.Text; +using System; #nullable enable namespace MiniMapLibrary { public class Timer { - public float Duration { get; private set; } = 1.0f; + public float Duration { get; } public float Value { get; set; } public event Action? OnFinished; public object? State { get; set; } public bool AutoReset { get; set; } public bool Started { get; set; } = true; - public bool Expired => Value <= 0; + public bool Expired => Value <= 0f; public Timer(float duration, bool autoReset = true) { + if (!Numeric.IsFinite(duration) || duration <= 0f) + throw new ArgumentOutOfRangeException(nameof(duration)); + Duration = duration; - this.AutoReset = autoReset; + AutoReset = autoReset; + Value = duration; } public void Update(float deltaTime) { - if (Started) + if (!Started || !Numeric.IsFinite(deltaTime) || deltaTime <= 0f) + return; + + Value -= deltaTime; + if (!Expired) + return; + + if (AutoReset) + { + // Keep fractional time without firing repeatedly after a long frame. + Value = Duration + Value % Duration; + if (Value <= 0f) + Value = Duration; + } + else { - Value -= deltaTime; - - if (Expired) - { - if (AutoReset) - { - Reset(); - } - else - { - Started = false; - } - - OnFinished?.Invoke(State); - } + Value = 0f; + Started = false; } + + OnFinished?.Invoke(State); } public void Start() => Started = true; @@ -51,9 +56,6 @@ public void Reset() Started = AutoReset; } - public override string ToString() - { - return $"{typeof(Timer).Name} [{Value}/{Duration}s] [{nameof(AutoReset)}: {AutoReset}] [{nameof(Started)}: {Started}] [{nameof(Expired)}: {Expired}]"; - } + public override string ToString() => $"Timer [{Value}/{Duration}s] [AutoReset: {AutoReset}] [Started: {Started}]"; } } diff --git a/MiniMapLibrary/Config/Interfaces/IMutableConfigEntry.cs b/MiniMapLibrary/Config/Interfaces/IMutableConfigEntry.cs new file mode 100644 index 0000000..c6e718a --- /dev/null +++ b/MiniMapLibrary/Config/Interfaces/IMutableConfigEntry.cs @@ -0,0 +1,7 @@ +namespace MiniMapLibrary.Config +{ + public interface IMutableConfigEntry : IConfigEntry + { + new T Value { get; set; } + } +} diff --git a/MiniMapLibrary/Helpers/Sprites.cs b/MiniMapLibrary/Helpers/Sprites.cs index 652e74d..4a3e038 100644 --- a/MiniMapLibrary/Helpers/Sprites.cs +++ b/MiniMapLibrary/Helpers/Sprites.cs @@ -11,19 +11,17 @@ public static class Sprites { public static GameObject CreateIcon(Sprite sprite, float width, float height, Color color) { - GameObject icon = new GameObject(); - - var transform = icon.AddComponent(); + GameObject icon = new GameObject("Icon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); + var transform = (RectTransform)icon.transform; transform.sizeDelta = new Vector2(width, height); - icon.AddComponent(); - - var image = icon.AddComponent(); + var image = icon.GetComponent(); image.sprite = sprite; image.color = color; + image.raycastTarget = false; return icon; } diff --git a/MiniMapLibrary/Helpers/Transforms.cs b/MiniMapLibrary/Helpers/Transforms.cs index 8cf13a8..dc48c5b 100644 --- a/MiniMapLibrary/Helpers/Transforms.cs +++ b/MiniMapLibrary/Helpers/Transforms.cs @@ -9,13 +9,12 @@ public static class Transforms { public static void SetParent(Transform child, Transform parent) { - child.transform.SetParent(parent); - - child.transform.localRotation = Quaternion.identity; - - child.transform.localPosition = new Vector3(0, 0, 0); - - child.transform.localScale = new Vector3(1, 1, 1); + child.SetParent(parent, false); + // Layers aren't inherited, and the game's UI camera doesn't render the Default layer. + child.gameObject.layer = parent.gameObject.layer; + child.localRotation = Quaternion.identity; + child.localPosition = Vector3.zero; + child.localScale = Vector3.one; } } } diff --git a/MiniMapLibrary/Interactible/InteractableKind.cs b/MiniMapLibrary/Interactible/InteractableKind.cs index b4edbd3..2d79fd1 100644 --- a/MiniMapLibrary/Interactible/InteractableKind.cs +++ b/MiniMapLibrary/Interactible/InteractableKind.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System; namespace MiniMapLibrary { + [Flags] public enum InteractableKind { none = 0, @@ -30,6 +27,10 @@ public enum InteractableKind Totem = 1 << 18, Neutral = 1 << 19, NewtAltar = 1 << 20, + StealthChest = 1 << 21, + TimedChest = 1 << 22, + EnemyEndOfCycle = 1 << 23, + Pickup = 1 << 24, All = int.MaxValue } } diff --git a/MiniMapLibrary/Interactible/InteractibleSetting.cs b/MiniMapLibrary/Interactible/InteractibleSetting.cs index 26efde9..c3585ff 100644 --- a/MiniMapLibrary/Interactible/InteractibleSetting.cs +++ b/MiniMapLibrary/Interactible/InteractibleSetting.cs @@ -8,6 +8,7 @@ namespace MiniMapLibrary { public class InteractibleSetting { + public bool Enabled { get; set; } = true; public Dimension2D Dimensions { get; set; } public Color ActiveColor { get; set; } public Color InactiveColor { get; set; } diff --git a/MiniMapLibrary/MiniMapLibrary.csproj b/MiniMapLibrary/MiniMapLibrary.csproj index 4783c31..623be7f 100644 --- a/MiniMapLibrary/MiniMapLibrary.csproj +++ b/MiniMapLibrary/MiniMapLibrary.csproj @@ -2,23 +2,39 @@ netstandard2.1 + 12.0 + 4.2.0 + 1.0.0.0 + 4.2.0.0 + false + true + true - - ..\MiniMapMod\libs\UnityEngine.dll - - + ..\MiniMapMod\libs\UnityEngine.CoreModule.dll + false - ..\MiniMapMod\libs\UnityEngine.UI.dll + ..\MiniMapMod\libs\UnityEngine.UI.dll + false - ..\MiniMapMod\libs\UnityEngine.UIModule.dll + ..\MiniMapMod\libs\UnityEngine.UIModule.dll + false - ..\MiniMapMod\libs\LegacyResourcesAPI.dll + ..\MiniMapMod\libs\LegacyResourcesAPI.dll + false + + + ..\MiniMapMod\libs\Unity.Addressables.dll + false + + + ..\MiniMapMod\libs\Unity.ResourceManager.dll + false diff --git a/MiniMapLibrary/Scanner/DefaultSorter.cs b/MiniMapLibrary/Scanner/DefaultSorter.cs index 4543fcb..a1ce7f6 100644 --- a/MiniMapLibrary/Scanner/DefaultSorter.cs +++ b/MiniMapLibrary/Scanner/DefaultSorter.cs @@ -14,7 +14,6 @@ public class DefaultSorter : ISorter private readonly Func? selector; private readonly Func converter; private readonly Func? activeChecker; - private readonly bool enabled; public DefaultSorter(InteractableKind kind, Func converter, @@ -23,12 +22,15 @@ public DefaultSorter(InteractableKind kind, { Kind = kind; this.selector = selector; - this.converter = converter; + this.converter = converter ?? throw new ArgumentNullException(nameof(converter)); this.activeChecker = activeChecker; - this.enabled = Settings.GetSetting(kind).Config.Enabled.Value; } - public bool IsKind(T value) => enabled && (selector?.Invoke(value) ?? false); + public bool IsKind(T value) + { + InteractibleSetting setting = Settings.GetSetting(Kind); + return (setting.Config?.Enabled.Value ?? setting.Enabled) && (selector?.Invoke(value) ?? true); + } public GameObject GetGameObject(T value) => converter(value); diff --git a/MiniMapLibrary/Scanner/ElevationTrackedObject.cs b/MiniMapLibrary/Scanner/ElevationTrackedObject.cs index 492011b..4c40b95 100644 --- a/MiniMapLibrary/Scanner/ElevationTrackedObject.cs +++ b/MiniMapLibrary/Scanner/ElevationTrackedObject.cs @@ -1,145 +1,97 @@ -using System; -using System.Collections.Generic; -using System.Text; +using System; using UnityEngine; +using UnityEngine.UI; namespace MiniMapLibrary.Scanner { public class ElevationTrackedObject : ITrackedObject { - private const float margin = 5.0f; - private readonly ITrackedObject backing; private readonly ISpriteManager spriteManager; private readonly Func heightRetriever; - private static readonly Quaternion upDirection; - private static readonly Quaternion downDirection; - + private readonly InteractibleSetting setting; private Transform arrowTransform; - private GameObject arrow; - - private bool flag_initialized; - private bool flag_disabled; - - static ElevationTrackedObject() { - upDirection = Quaternion.AngleAxis(-90.0f, Vector3.forward); - downDirection = Quaternion.AngleAxis(90.0f, Vector3.forward); - } + private Image arrowImage; + private int direction; public ElevationTrackedObject(ITrackedObject backing, ISpriteManager spriteManager, Func heightRetriever) { - this.backing = backing; - this.spriteManager = spriteManager; - this.heightRetriever = heightRetriever; + this.backing = backing ?? throw new ArgumentNullException(nameof(backing)); + this.spriteManager = spriteManager ?? throw new ArgumentNullException(nameof(spriteManager)); + this.heightRetriever = heightRetriever ?? throw new ArgumentNullException(nameof(heightRetriever)); + setting = Settings.GetSetting(backing.InteractableType); } public GameObject gameObject { get => backing.gameObject; set => backing.gameObject = value; } - public InteractableKind InteractableType { get => backing.InteractableType; set => backing.InteractableType = value; } - - public RectTransform MinimapTransform { get => backing.MinimapTransform; set => backing.MinimapTransform = value; } - public bool DynamicObject { get => backing.DynamicObject; set => backing.DynamicObject = value; } - public bool Active => backing.Active; - - private bool Initialized() + public RectTransform MinimapTransform { - // minimap transform, is lazily loaded by minimap at runtime and isn't available - // until first update call after the scan that produced this object was created - if (backing.MinimapTransform is null) + get => backing.MinimapTransform; + set { - return false; + if (ReferenceEquals(backing.MinimapTransform, value)) + return; + arrowTransform = null; + arrowImage = null; + direction = 0; + backing.MinimapTransform = value; } - - // only run this method once - if (flag_initialized) - { - return true; - } - - // rotate down - InteractibleSetting setting = Settings.GetSetting(backing.InteractableType); - - if (setting.Config.EnabledElevationMarker.Value is false) - { - flag_disabled = true; - return false; - } - - Sprite arrowSprite = spriteManager.GetOrCache(Settings.Icons.Arrow); - - arrow = Helpers.Sprites.CreateIcon(arrowSprite, - setting.Config.ElevationMarkerWidth.Value, - setting.Config.ElevationMarkerHeight.Value, - setting.Config.ElevationMarkerColor.Value); - - // attach arrow - Helpers.Transforms.SetParent(arrow.transform, backing.MinimapTransform); - - arrowTransform = arrow.transform; - - // move arrow - arrowTransform.localPosition += new Vector3(setting.Config.ElevationMarkerOffset.Value.x, setting.Config.ElevationMarkerOffset.Value.y, 0); - - // rotate arrow upright - arrowTransform.localRotation = upDirection; - - // hide arrow - arrow.SetActive(false); - - flag_initialized = true; - - return true; } - public void CheckActive() + public void CheckActive() { - // check color from backing object backing.CheckActive(); + if (gameObject) + UpdateElevation(heightRetriever(), gameObject.transform.position.y, Settings.ElevationThreshold); + } - // if the config does not allow arrows don't bother trying to update it, it doesn't exist - if (flag_disabled) - { + public void UpdateElevation(float viewerHeight, float objectHeight, float threshold) + { + if (!MinimapTransform || !setting.ElevationMarkerEnabled) return; - } - if (Initialized()) + int nextDirection = !Active ? 0 : objectHeight > viewerHeight + threshold ? 1 : objectHeight < viewerHeight - threshold ? -1 : 0; + if (!arrowTransform) { - // check if we need to show an arrow denoting height - if (backing.Active) + // Reuse the arrow when its icon has returned from the pool. + arrowTransform = MinimapTransform.Find("Elevation"); + if (!arrowTransform && nextDirection == 0) + return; + if (!arrowTransform) { - // since the object is active - // show arrow if player is too smol - float y = heightRetriever(); - - if (y > (backing.gameObject.transform.position.y + margin)) - { - // item is below player show down arrow - arrow.SetActive(true); - - arrowTransform.localRotation = downDirection; - } - else if (y < (backing.gameObject.transform.position.y - margin)) - { - // item is above player show up arrow - arrow.SetActive(true); - - arrowTransform.localRotation = upDirection; - } - else - { - arrow.SetActive(false); - } - } - // hide the arrow when the object is no longer active - else if (arrow.activeSelf) - { - arrow.SetActive(false); + GameObject arrow = Helpers.Sprites.CreateIcon(spriteManager.GetOrCache(Settings.Icons.Arrow), + setting.ElevationMarkerDimensions.Width, setting.ElevationMarkerDimensions.Height, setting.ElevationMarkerColor); + arrow.name = "Elevation"; + Helpers.Transforms.SetParent(arrow.transform, MinimapTransform); + arrow.transform.localPosition = new Vector3(setting.ElevationMarkerOffset.Width, setting.ElevationMarkerOffset.Height, 0f); + arrowTransform = arrow.transform; } + arrowImage = arrowTransform.GetComponent(); + direction = int.MinValue; } + + if (direction == nextDirection) + return; + direction = nextDirection; + arrowTransform.gameObject.SetActive(direction != 0); + if (direction != 0) + arrowTransform.localRotation = Quaternion.Euler(0f, 0f, direction > 0 ? -90f : 90f); } - public void Destroy() => backing.Destroy(); + public void Destroy() + { + backing.Destroy(); + arrowTransform = null; + arrowImage = null; + direction = 0; + } + + public void RefreshSprite() + { + if (arrowImage) + arrowImage.sprite = spriteManager.GetOrCache(Settings.Icons.Arrow); + } } } diff --git a/MiniMapLibrary/Scanner/MonoBehaviorScanner.cs b/MiniMapLibrary/Scanner/MonoBehaviorScanner.cs index 203af4e..b00d564 100644 --- a/MiniMapLibrary/Scanner/MonoBehaviorScanner.cs +++ b/MiniMapLibrary/Scanner/MonoBehaviorScanner.cs @@ -1,39 +1,20 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using UnityEngine; -#nullable enable namespace MiniMapLibrary.Scanner { public class MonoBehaviorScanner : IScanner where T : MonoBehaviour { private readonly ILogger logger; - public MonoBehaviorScanner(ILogger logger) - { - this.logger = logger; - - this.logger.LogDebug($"Created {nameof(MonoBehaviorScanner)}<{typeof(T).FullName}>"); - } + public MonoBehaviorScanner(ILogger logger) => this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); public IEnumerable Scan() { - IEnumerable? found = GameObject.FindObjectsOfType(typeof(T))?.Select(x => (T)x); - - if (found is null) - { - logger.LogDebug($"Failed to find any objects with type {typeof(T).FullName}s"); - } - else - { - logger.LogDebug($"Found {found.Count()} objects with type {typeof(T).FullName}s"); - } - - // never return a null list, caller should always assume a list will - // be provided regardless if nothing is found - return found ?? new List(); + T[] objects = UnityEngine.Object.FindObjectsOfType(); + logger.LogDebug($"Found {objects.Length} objects of type {typeof(T).FullName}."); + return objects; } } } diff --git a/MiniMapLibrary/Scanner/MonoBehaviourSorter.cs b/MiniMapLibrary/Scanner/MonoBehaviourSorter.cs index b77374d..ad9445d 100644 --- a/MiniMapLibrary/Scanner/MonoBehaviourSorter.cs +++ b/MiniMapLibrary/Scanner/MonoBehaviourSorter.cs @@ -9,21 +9,27 @@ namespace MiniMapLibrary.Scanner public class MonoBehaviourSorter : IInteractibleSorter { private readonly ISorter[] sorters; + private readonly Func[] activeCheckers; + private static readonly Func AlwaysActive = _ => true; public MonoBehaviourSorter(ISorter[] sorters) { - this.sorters = sorters; + this.sorters = sorters ?? throw new ArgumentNullException(nameof(sorters)); + activeCheckers = new Func[sorters.Length]; + for (int i = 0; i < sorters.Length; i++) + activeCheckers[i] = sorters[i].CheckActive; } public bool TrySort(T value, out InteractableKind kind, out GameObject? gameObject, out Func activeChecker) { kind = InteractableKind.none; gameObject = null; - activeChecker = (x) => true; + activeChecker = AlwaysActive; // iterate though our sorters - foreach (ISorter sorter in sorters) + for (int i = 0; i < sorters.Length; i++) { + ISorter sorter = sorters[i]; // check if the provided value meets the criteria to be of this sorters // kind if (sorter.IsKind(value)) @@ -31,9 +37,9 @@ public bool TrySort(T value, out InteractableKind kind, out GameObject? gameObje // set the out kind and grab the gameobject kind = sorter.Kind; gameObject = sorter.GetGameObject(value); - activeChecker = sorter.CheckActive; + activeChecker = activeCheckers[i]; - return true; + return gameObject != null; } } diff --git a/MiniMapLibrary/Scanner/MultiKindScanner.cs b/MiniMapLibrary/Scanner/MultiKindScanner.cs index 98e502b..ddc7e3a 100644 --- a/MiniMapLibrary/Scanner/MultiKindScanner.cs +++ b/MiniMapLibrary/Scanner/MultiKindScanner.cs @@ -13,25 +13,31 @@ public class MultiKindScanner : ITrackedObjectScanner private readonly Range3D range; private readonly ISpriteManager spriteManager; private readonly Func playerHeightRetriever; + private readonly HashSet existingIds = new(); public MultiKindScanner(bool dynamic, IScanner scanner, IInteractibleSorter sorter, Range3D range, ISpriteManager spriteManager, Func playerHeightRetriever) { - this.scanner = scanner; - this.sorter = sorter; + this.scanner = scanner ?? throw new ArgumentNullException(nameof(scanner)); + this.sorter = sorter ?? throw new ArgumentNullException(nameof(sorter)); this.dynamic = dynamic; - this.range = range; - this.spriteManager = spriteManager; - this.playerHeightRetriever = playerHeightRetriever; + this.range = range ?? throw new ArgumentNullException(nameof(range)); + this.spriteManager = spriteManager ?? throw new ArgumentNullException(nameof(spriteManager)); + this.playerHeightRetriever = playerHeightRetriever ?? throw new ArgumentNullException(nameof(playerHeightRetriever)); } public void ScanScene(IList list) { IEnumerable foundObjects = scanner.Scan(); + ScannerUtilities.CollectExistingIds(list, existingIds); foreach (var item in foundObjects) { + if (item is null || (item is UnityEngine.Object source && !source)) + continue; if (sorter.TrySort(item, out InteractableKind kind, out GameObject gameObject, out Func activeChecker)) { + if (gameObject == null || !existingIds.Add(gameObject.GetInstanceID())) + continue; list.Add(new ElevationTrackedObject(new TrackedObject(kind, gameObject, null) { BackingObject = item, diff --git a/MiniMapLibrary/Scanner/ReconciledSet.cs b/MiniMapLibrary/Scanner/ReconciledSet.cs new file mode 100644 index 0000000..0a1d089 --- /dev/null +++ b/MiniMapLibrary/Scanner/ReconciledSet.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; + +namespace MiniMapLibrary.Scanner +{ + // Keeps markers alive between registry snapshots: anything not marked between + // BeginScan and EndScan is released. Removal is O(1) swap-remove. + public sealed class ReconciledSet where T : class + { + private sealed class Entry + { + public int Key; + public int Generation; + public T Value; + } + + private readonly Dictionary entries = new(); + private readonly List values = new(); + private int generation; + + public int Count => values.Count; + public T this[int index] => values[index].Value; + public int GetKey(int index) => values[index].Key; + + public void BeginScan() => generation = unchecked(generation + 1); + + public bool TryGetAndMark(int key, out T value) + { + if (entries.TryGetValue(key, out Entry entry)) + { + entry.Generation = generation; + value = entry.Value; + return true; + } + + value = null; + return false; + } + + public bool TryGetValue(int key, out T value) + { + if (entries.TryGetValue(key, out Entry entry)) + { + value = entry.Value; + return true; + } + value = null; + return false; + } + + public void Add(int key, T value) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + var entry = new Entry { Key = key, Generation = generation, Value = value }; + entries.Add(key, entry); + values.Add(entry); + } + + public void EndScan(Action release) + { + for (int i = values.Count - 1; i >= 0; i--) + { + if (values[i].Generation != generation) + RemoveAt(i, release); + } + } + + public void Replace(int key, T value, Action release) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + if (!entries.TryGetValue(key, out Entry entry)) + { + Add(key, value); + return; + } + + T previous = entry.Value; + entry.Value = value; + entry.Generation = generation; + if (!ReferenceEquals(previous, value)) + release?.Invoke(previous); + } + + public void RemoveAt(int index, Action release) + { + Entry removed = values[index]; + int lastIndex = values.Count - 1; + values[index] = values[lastIndex]; + values.RemoveAt(lastIndex); + entries.Remove(removed.Key); + release?.Invoke(removed.Value); + } + + public void Clear(Action release) + { + for (int i = values.Count - 1; i >= 0; i--) + release?.Invoke(values[i].Value); + + values.Clear(); + entries.Clear(); + } + } +} diff --git a/MiniMapLibrary/Scanner/ScannerUtilities.cs b/MiniMapLibrary/Scanner/ScannerUtilities.cs new file mode 100644 index 0000000..47a56b2 --- /dev/null +++ b/MiniMapLibrary/Scanner/ScannerUtilities.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace MiniMapLibrary.Scanner +{ + internal static class ScannerUtilities + { + public static void CollectExistingIds(IList objects, HashSet ids) + { + ids.Clear(); + for (int i = 0; i < objects.Count; i++) + { + if (objects[i].gameObject) + ids.Add(objects[i].gameObject.GetInstanceID()); + } + } + } +} diff --git a/MiniMapLibrary/Scanner/SingleKindScanner.cs b/MiniMapLibrary/Scanner/SingleKindScanner.cs index 14df0c6..487f8d4 100644 --- a/MiniMapLibrary/Scanner/SingleKindScanner.cs +++ b/MiniMapLibrary/Scanner/SingleKindScanner.cs @@ -17,7 +17,7 @@ public class SingleKindScanner : ITrackedObjectScanner where T : MonoBehaviou private readonly Range3D range; private readonly ISpriteManager spriteManager; private readonly Func playerHeightRetriver; - private readonly bool enabled; + private readonly HashSet existingIds = new(); public SingleKindScanner(InteractableKind kind, bool dynamic, IScanner scanner, Range3D range, ISpriteManager spriteManager, Func playerHeightRetriever, Func converter, Func? activeChecker = null, Func? selector = null) { @@ -34,27 +34,30 @@ public SingleKindScanner(InteractableKind kind, bool dynamic, IScanner scanne this.range = range ?? throw new ArgumentNullException(nameof(range)); this.spriteManager = spriteManager ?? throw new ArgumentNullException(nameof(spriteManager)); - this.playerHeightRetriver = playerHeightRetriever; - enabled = Settings.GetSetting(kind).Config.Enabled.Value; + this.playerHeightRetriver = playerHeightRetriever ?? throw new ArgumentNullException(nameof(playerHeightRetriever)); } public void ScanScene(IList list) { // if the kind is disabled, don't bother scanning - if (enabled is false) + InteractibleSetting setting = Settings.GetSetting(kind); + if (!(setting.Config?.Enabled.Value ?? setting.Enabled)) { return; } IEnumerable foundObjects = scanner.Scan(); + ScannerUtilities.CollectExistingIds(list, existingIds); foreach (var item in foundObjects) { - if (selector(item)) + if (item != null && selector(item)) { GameObject gameObject = converter(item); + if (gameObject == null || !existingIds.Add(gameObject.GetInstanceID())) + continue; - list.Add(new ElevationTrackedObject(new TrackedObject(kind, converter(item), null) + list.Add(new ElevationTrackedObject(new TrackedObject(kind, gameObject, null) { BackingObject = item, ActiveChecker = activeChecker, diff --git a/MiniMapLibrary/Scanner/TagScanner.cs b/MiniMapLibrary/Scanner/TagScanner.cs index f6da458..17f94d8 100644 --- a/MiniMapLibrary/Scanner/TagScanner.cs +++ b/MiniMapLibrary/Scanner/TagScanner.cs @@ -1,10 +1,7 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using UnityEngine; -#nullable enable namespace MiniMapLibrary.Scanner { public class TagScanner : IScanner @@ -12,28 +9,18 @@ public class TagScanner : IScanner private readonly string targetTag; private readonly ILogger logger; - public TagScanner(InteractableKind kind, bool dynamic, string tag, ILogger logger, Func? selector = null, Func? activeChecker = null) + public TagScanner(InteractableKind kind, bool dynamic, string tag, ILogger logger, + Func selector = null, Func activeChecker = null) { - this.targetTag = tag; - this.logger = logger; - - logger.LogDebug($"Created {nameof(TagScanner)} for tag {tag}"); + targetTag = tag ?? throw new ArgumentNullException(nameof(tag)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public IEnumerable Scan() { - IEnumerable? found = GameObject.FindGameObjectsWithTag(targetTag); - - if (found is null) - { - logger.LogDebug($"Failed to find any objects with tag {targetTag}"); - } - else - { - logger.LogDebug($"Found {found.Count()} objects with tag {targetTag}"); - } - - return found ?? new List(); + GameObject[] objects = GameObject.FindGameObjectsWithTag(targetTag); + logger.LogDebug($"Found {objects.Length} objects tagged {targetTag}."); + return objects; } } } diff --git a/MiniMapLibrary/Scanner/TrackedObject.cs b/MiniMapLibrary/Scanner/TrackedObject.cs index 559f728..f440e2e 100644 --- a/MiniMapLibrary/Scanner/TrackedObject.cs +++ b/MiniMapLibrary/Scanner/TrackedObject.cs @@ -1,101 +1,100 @@ -using MiniMapLibrary; using System; -using System.Collections.Generic; -using System.Text; using UnityEngine; using UnityEngine.UI; -using MiniMapLibrary.Scanner; namespace MiniMapLibrary.Scanner { public class TrackedObject : ITrackedObject { - public GameObject gameObject { get; set; } - - public RectTransform MinimapTransform { get; set; } - - public bool DynamicObject { get; set; } = false; + private RectTransform minimapTransform; + private Image minimapImage; + private Color appliedColor; + private bool hasAppliedColor; + private bool reportedFailure; - public T BackingObject { get; set; } + public GameObject gameObject { get; set; } + public RectTransform MinimapTransform + { + get => minimapTransform; + set + { + if (ReferenceEquals(minimapTransform, value)) + return; + minimapTransform = value; + minimapImage = null; + hasAppliedColor = false; + } + } public Image MinimapImage { get { - if (_MinimapImage is null && MinimapTransform != null) - { - _MinimapImage = MinimapTransform.GetComponent(); - } - - return _MinimapImage; + if (!minimapImage && minimapTransform) + minimapImage = minimapTransform.GetComponent(); + return minimapImage; } - set => _MinimapImage = value; + set { minimapImage = value; hasAppliedColor = false; } } - private Image _MinimapImage; - + public bool DynamicObject { get; set; } + public T BackingObject { get; set; } public InteractableKind InteractableType { get; set; } - public Func ActiveChecker { get; set; } - public bool Active { get; private set; } = true; - - private bool PreviousActive = true; + public ILogger ErrorLogger { get; set; } public TrackedObject(InteractableKind interactableType, GameObject gameObject, RectTransform minimapTransform) { + InteractableType = interactableType; this.gameObject = gameObject; MinimapTransform = minimapTransform; - InteractableType = interactableType; } public void Destroy() { - try - { - GameObject.Destroy(MinimapTransform.gameObject); - } - catch (Exception) - { - // intentionally eat the exception while trying to destroy the gameobject - // if other mods or the scene destroys the UI we dont want to throw exceptions - // this is expected - } + if (minimapTransform) + UnityEngine.Object.Destroy(minimapTransform.gameObject); + MinimapTransform = null; } public void CheckActive() { - if (BackingObject is null) + if (!gameObject || BackingObject == null || (BackingObject is UnityEngine.Object source && !source)) { Active = false; - - UpdateColor(); - - return; - } - - try - { - Active = ActiveChecker?.Invoke(BackingObject) ?? false; } - catch (Exception) + else { - // intentionally eat the exception while trying to destroy the gameobject - // if other mods or the scene destroys the UI we dont want to throw exceptions - // this is expected - Active = false; + try + { + Active = ActiveChecker?.Invoke(BackingObject) ?? true; + } + catch (Exception exception) + { + Active = false; + if (!reportedFailure) + { + reportedFailure = true; + ErrorLogger?.LogException(exception, $"Availability check failed for {InteractableType}."); + } + } } - UpdateColor(); + ApplyColor(); } - private void UpdateColor() + public void ApplyColor() { - if (PreviousActive != Active) + Image image = MinimapImage; + if (!image) + return; + Color color = Settings.GetColor(InteractableType, Active); + if (!hasAppliedColor || appliedColor != color) { - PreviousActive = Active; - - MinimapImage.color = Settings.GetColor(InteractableType, Active); + image.color = color; + appliedColor = color; + hasAppliedColor = true; } } } diff --git a/MiniMapLibrary/Settings.cs b/MiniMapLibrary/Settings.cs index ee5d182..f3e46fd 100644 --- a/MiniMapLibrary/Settings.cs +++ b/MiniMapLibrary/Settings.cs @@ -1,20 +1,12 @@ -using MiniMapLibrary.Config; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using MiniMapLibrary.Config; using UnityEngine; namespace MiniMapLibrary { - public enum LogLevel - { - none, - info, - debug, - all - } + // Names are part of MiniMap.cfg; don't rename. + public enum LogLevel { none, info, debug, all } public static class Settings { @@ -38,317 +30,282 @@ public static class Icons public const string Pencil = "Textures/MiscIcons/texEditIcon"; public const string Portal = "Textures/MiscIcons/texQuickplay"; public const string Lunar = "Textures/ItemIcons/BG/texLunarBGIcon"; + // Not a game asset: SpriteManager draws this one itself. + public const string Dot = "MiniMap/Dot"; } public static class Colors { - public static Color Yellow = new Color(255 / 255.0f, 219 / 255.0f, 88 / 255.0f); - public static Color Purple = new Color(122 / 255.0f, 105 / 255.0f, 244 / 255.0f); - public static Color DarkPurple = new Color(76 / 255.0f, 53 / 255.0f, 244 / 255.0f); - public static Color Teal = new Color(94 / 255.0f, 178 / 255.0f, 242 / 255.0f); - public static Color Pink = new Color(172 / 255.0f, 95 / 255.0f, 243 / 255.0f); - public static Color DarkPink = new Color(146 / 255.0f, 39 / 255.0f, 243 / 255.0f); - public static Color Orange = new Color(255 / 255.0f, 146 / 255.0f, 0 / 255.0f); - public static Color DarkTeal = new Color(94 / 255.0f, 178 / 255.0f, 242 / 255.0f); + public static Color Yellow = new(1f, 219f / 255f, 88f / 255f); + public static Color Purple = new(122f / 255f, 105f / 255f, 244f / 255f); + public static Color DarkPurple = new(76f / 255f, 53f / 255f, 244f / 255f); + public static Color Teal = new(94f / 255f, 178f / 255f, 242f / 255f); + public static Color Pink = new(172f / 255f, 95f / 255f, 243f / 255f); + public static Color DarkPink = new(146f / 255f, 39f / 255f, 243f / 255f); + public static Color Orange = new(1f, 146f / 255f, 0f); + public static Color DarkTeal = new(94f / 255f, 178f / 255f, 242f / 255f); + public static Color LightCyan = new(156f / 255f, 245f / 255f, 1f); } - public static Dimension2D MinimapSize { get; set; } = new Dimension2D(100, 100); - - public static Dimension2D ViewfinderSize { get; set; } = new Dimension2D(10, 100); - + public static Dimension2D MinimapSize { get; set; } = new(180f, 180f); + public static Dimension2D ViewfinderSize { get; set; } = new(180f, 180f); public static IDictionary InteractibleSettings { get; } = new Dictionary(); - - private static readonly Dimension2D DefaultUIElementSize = new Dimension2D(10, 10); - public static Color PlayerIconColor { get; set; } = Color.white; - public static Color DefaultActiveColor { get; set; } = Colors.Yellow; - public static Color DefaultInactiveColor { get; set; } = Color.grey; - public static Color DefaultElevationMarkerColor { get; set; } = Color.white; + public static Dimension2D DefaultElevationSize { get; set; } = new(3f, 3f); - public static Dimension2D DefaultElevationSize { get; set; } = new Dimension2D(3.0f, 3.0f); - - public static LogLevel LogLevel => _logLevel.Value; - - private static IConfigEntry _logLevel; - - public static float MinimapScale => _minimapScale.Value; - private static IConfigEntry _minimapScale; - - public static KeyCode MinimapKey => _MiniMapKey.Value; - private static IConfigEntry _MiniMapKey; - - public static KeyCode MinimapIncreaseScaleKey => _MinimapIncreaseScaleKey.Value; - private static IConfigEntry _MinimapIncreaseScaleKey; - - public static KeyCode MinimapDecreaseScaleKey => _MinimapDecreaseScaleKey.Value; - private static IConfigEntry _MinimapDecreaseScaleKey; - + private static readonly InteractibleSetting fallbackSetting = new() + { + Dimensions = new Dimension2D(11f, 11f), + ActiveColor = Colors.Yellow, + InactiveColor = Color.grey, + IconPath = Icons.Default, + Description = "Unknown interactable", + ElevationMarkerDimensions = new Dimension2D(3f, 3f), + ElevationMarkerOffset = new Dimension2D(0f, 0f) + }; + + private const int CurrentConfigRevision = 1; private static ILogger logger; + private static IConfigEntry configRevision; + private static IConfigEntry logLevel; + private static IConfigEntry minimapKey, increaseScaleKey, decreaseScaleKey; + private static IConfigEntry minimapScale, interactionScanInterval, bodyScanInterval, stateRefreshInterval, sceneScanInterval; + private static IConfigEntry mapWidth, mapHeight, worldRadius, elevationThreshold; + private static IConfigEntry rotateWithCamera, showUnclassifiedInteractables; + private static IConfigEntry backgroundColor; + private static IConfigEntry positionOffset; + + public static LogLevel LogLevel => logLevel?.Value ?? LogLevel.info; + public static KeyCode MinimapKey => minimapKey?.Value ?? KeyCode.M; + public static KeyCode MinimapIncreaseScaleKey => increaseScaleKey?.Value ?? KeyCode.Equals; + public static KeyCode MinimapDecreaseScaleKey => decreaseScaleKey?.Value ?? KeyCode.Minus; + public static float MinimapScale => Numeric.Clamp(minimapScale?.Value ?? 1f, 0.1f, 20f, 1f); + public static float InteractionScanInterval => Numeric.Clamp(interactionScanInterval?.Value ?? 1f, 0.25f, 30f, 1f); + public static float BodyScanInterval => Numeric.Clamp(bodyScanInterval?.Value ?? 0.25f, 0.1f, 5f, 0.25f); + public static float StateRefreshInterval => Numeric.Clamp(stateRefreshInterval?.Value ?? 0.2f, 0.1f, 5f, 0.2f); + public static float SceneScanInterval => Numeric.Clamp(sceneScanInterval?.Value ?? 5f, 2f, 60f, 5f); + public static bool DebugLogging => LogLevel >= LogLevel.debug; + public static float WorldRadius => Numeric.Clamp(worldRadius?.Value ?? 0f, 0f, 100000f, 0f); + public static float ElevationThreshold => Numeric.Clamp(elevationThreshold?.Value ?? 5f, 0f, 10000f, 5f); + public static bool RotateWithCamera => rotateWithCamera?.Value ?? true; + public static bool ShowUnclassifiedInteractables => showUnclassifiedInteractables?.Value ?? true; + public static Color BackgroundColor => SanitizeColor(backgroundColor?.Value ?? new Color(0.03f, 0.04f, 0.06f, 0.65f)); + public static Vector2 PositionOffset + { + get + { + Vector2 offset = positionOffset?.Value ?? new Vector2(0f, -8f); + return new Vector2(Numeric.Clamp(offset.x, -4096f, 4096f, 0f), Numeric.Clamp(offset.y, -4096f, 4096f, -8f)); + } + } static Settings() { - InitializeDefaultSettings(); + Add(InteractableKind.Chest, 9f, 7f, "Chests and adaptive chests", Icons.Chest); + Add(InteractableKind.StealthChest, 9f, 7f, "Cloaked chests (reveals their locations when enabled)", Icons.Chest, enabled: false); + Add(InteractableKind.TimedChest, 9f, 7f, "Timed security chests (grey after locking or opening)", Icons.Chest, enabled: false); + Add(InteractableKind.Shop, 8f, 6f, "Shops and terminals", Icons.Chest, Colors.Teal); + Add(InteractableKind.Equipment, 9f, 7f, "Equipment barrels", Icons.Chest, Colors.Orange); + Add(InteractableKind.Printer, 11f, 9f, "Printers", Icons.Chest, Colors.Purple); + Add(InteractableKind.Utility, 6f, 6f, "Scrappers, cleansing pools and drone stations", Icons.Wrench); + Add(InteractableKind.LunarPod, 8f, 8f, "Lunar pods", Icons.LootBag, Color.cyan); + Add(InteractableKind.Shrine, 11f, 11f, "Shrines (excluding Newt and the Altar of Gold)", Icons.Shrine); + Add(InteractableKind.NewtAltar, 11f, 11f, "Newt altars", Icons.Lunar); + Add(InteractableKind.Teleporter, 17f, 17f, "Teleporters", Icons.Boss, Color.white, Color.green); + Add(InteractableKind.Barrel, 3f, 3f, "Barrels", Icons.Circle); + Add(InteractableKind.Drone, 8f, 8f, "Broken drones and turrets", Icons.Drone); + Add(InteractableKind.Special, 6f, 6f, "Other interactables, landing pods, fans and stage objectives", Icons.Default); + Add(InteractableKind.EnemyMonster, 3f, 3f, "Enemies", Icons.Circle, Color.red, elevationColor: Color.red); + Add(InteractableKind.EnemyLunar, 3f, 3f, "Lunar enemies", Icons.Circle, Color.red, elevationColor: Color.red); + Add(InteractableKind.EnemyVoid, 13f, 13f, "Void enemies", Icons.Attack, Color.magenta, elevationColor: Color.magenta); + Add(InteractableKind.EnemyEndOfCycle, 4f, 4f, "End of the Cycle enemies (Hallowed Concepts)", Icons.Attack, Colors.Orange, elevationColor: Colors.Orange); + Add(InteractableKind.Minion, 3f, 3f, "Allied drones and minions", Icons.Circle, Color.green, elevationColor: Color.green); + Add(InteractableKind.Player, 9f, 9f, "The viewed character and other players", Icons.Sprint, PlayerIconColor, PlayerIconColor); + Add(InteractableKind.Item, 3f, 3f, "Item choice terminals that are not dropped pickups", Icons.Circle, Colors.Teal, Color.cyan); + Add(InteractableKind.Portal, 8f, 8f, "Portals, the Altar of Gold and ritual gongs", Icons.Portal); + Add(InteractableKind.Totem, 8f, 8f, "Halcyon beacons and power pedestals", Icons.Cube, Colors.DarkTeal); + Add(InteractableKind.Neutral, 4f, 4f, "Neutral objects and NPCs", Icons.Circle, Color.white); + Add(InteractableKind.Pickup, 2f, 2f, "Dropped items, equipment, lunar coins, command cubes and void potentials", Icons.Dot, + Colors.LightCyan, elevationMarker: false); } - private static void InitializeDefaultSettings() + // 4.1.x (and upstream 4.0.0) defaults. Configs still holding these values + // are moved to the current defaults once; customised sizes are left alone. + private static readonly Dictionary legacySizes = new() + { + [InteractableKind.Chest] = (8f, 6f), + [InteractableKind.StealthChest] = (8f, 6f), + [InteractableKind.TimedChest] = (8f, 6f), + [InteractableKind.Shop] = (7f, 5f), + [InteractableKind.Equipment] = (8f, 6f), + [InteractableKind.Printer] = (10f, 8f), + [InteractableKind.Utility] = (5f, 5f), + [InteractableKind.LunarPod] = (7f, 7f), + [InteractableKind.Shrine] = (10f, 10f), + [InteractableKind.NewtAltar] = (10f, 10f), + [InteractableKind.Teleporter] = (15f, 15f), + [InteractableKind.Drone] = (7f, 7f), + [InteractableKind.Special] = (5f, 5f), + [InteractableKind.EnemyVoid] = (12f, 12f), + [InteractableKind.Player] = (8f, 8f), + [InteractableKind.Portal] = (7f, 7f), + [InteractableKind.Totem] = (7f, 7f), + }; + + private static readonly Dictionary defaultSizes = new(); + + private static void Add(InteractableKind kind, float width, float height, string description, string path, + Color? activeColor = null, Color? inactiveColor = null, Color? elevationColor = null, bool enabled = true, bool elevationMarker = true) { - static void Add(InteractableKind type, - float width = -1, - float height = -1, - Color activeColor = default, - Color inactiveColor = default, - string description = "", - string path = Icons.Default, - Color elevationMarkerColor = default - ) + defaultSizes.Add(kind, (width, height)); + InteractibleSettings.Add(kind, new InteractibleSetting { - activeColor = activeColor == default ? DefaultActiveColor : activeColor; - inactiveColor = inactiveColor == default ? DefaultInactiveColor : inactiveColor; - elevationMarkerColor = elevationMarkerColor == default ? DefaultElevationMarkerColor : elevationMarkerColor; - - Dimension2D size = DefaultUIElementSize; - - if (width != -1 || height != -1) - { - size = new Dimension2D(width, height); - } - - var setting = new InteractibleSetting() - { - ActiveColor = activeColor, - InactiveColor = inactiveColor, - Dimensions = size - }; - - setting.Description = description; - setting.IconPath = path; - - setting.ElevationMarkerColor = elevationMarkerColor; - setting.ElevationMarkerEnabled = true; - setting.ElevationMarkerDimensions = new Dimension2D(DefaultElevationSize.Width, DefaultElevationSize.Height); - setting.ElevationMarkerOffset = new Dimension2D( - (size.Width / 2.0f) + (DefaultElevationSize.Width / 2.0f), - (size.Height / 2.0f) + (DefaultElevationSize.Height / 2.0f) - ); - - InteractibleSettings.Add(type, setting); - } - - Add(InteractableKind.Chest, 8, 6, - description: "Chests, Roulette Chests", - path: Icons.Chest); - - Add(InteractableKind.Shop, 7, 5, - activeColor: Colors.Teal, - description: "Shops", - path: Icons.Chest); - - Add(InteractableKind.Equipment, 8, 6, - activeColor: Colors.Orange, - description: "Equipment Barrels", - path: Icons.Chest); - - Add(InteractableKind.Printer, 10, 8, - activeColor: Colors.Purple, - description: "Printers", - path: Icons.Chest); - - Add(InteractableKind.Utility, 5, 5, - description: "Scrappers", - path: Icons.Wrench); - - Add(InteractableKind.LunarPod, 7, 7, - description: "Lunar pods (chests)", - activeColor: Color.cyan, - path: Icons.LootBag); - - Add(InteractableKind.Shrine, - description: "All shrines (excluding Newt)", - path: Icons.Shrine); - - Add(InteractableKind.NewtAltar, - description: "Newt Altar", - path: Icons.Lunar); - - Add(InteractableKind.Teleporter, 15, 15, - activeColor: Color.white, - inactiveColor: Color.green, - description: "Boss teleporters", - path: Icons.Boss); - - Add(InteractableKind.Barrel, 3, 3, - description: "Barrels", - path: Icons.Circle); - - Add(InteractableKind.Drone, 7, 7, - description: "Drones", - path: Icons.Drone); - - Add(InteractableKind.Special, 5, 5, - description: "Special interactibles such as the landing pod and fans", - path: Icons.Default); - - Add(InteractableKind.EnemyMonster, 3, 3, - activeColor: Color.red, - elevationMarkerColor: Color.red, - description: "Enemies", - path: Icons.Circle); - - Add(InteractableKind.EnemyLunar, 3, 3, - activeColor: Color.red, - elevationMarkerColor: Color.red, - description: "Lunar enemies", - path: Icons.Circle); - - Add(InteractableKind.EnemyVoid, 12, 12, - activeColor: Color.magenta, - elevationMarkerColor: Color.magenta, - description: "Void touched enemies", - path: Icons.Attack); - - Add(InteractableKind.Minion, 3, 3, - activeColor: Color.green, - elevationMarkerColor: Color.green, - description: "Minions", - path: Icons.Circle); + Enabled = enabled, + Dimensions = new Dimension2D(width, height), + ActiveColor = activeColor ?? DefaultActiveColor, + InactiveColor = inactiveColor ?? DefaultInactiveColor, + Description = description, + IconPath = path, + ElevationMarkerEnabled = elevationMarker, + ElevationMarkerColor = elevationColor ?? DefaultElevationMarkerColor, + ElevationMarkerDimensions = new Dimension2D(DefaultElevationSize.Width, DefaultElevationSize.Height), + ElevationMarkerOffset = ElevationOffset(width, height) + }); + } - Add(InteractableKind.Player, 8, 8, - activeColor: PlayerIconColor, - inactiveColor: PlayerIconColor, - description: "Player, including friends", - path: Icons.Sprint); + private static Dimension2D ElevationOffset(float width, float height) => + new((width + DefaultElevationSize.Width) * 0.5f, (height + DefaultElevationSize.Height) * 0.5f); - Add(InteractableKind.Item, 3, 3, - activeColor: Colors.Teal, - inactiveColor: Color.cyan, - description: "Dropped items and lunar coins", - path: Icons.Circle); + public static InteractibleSetting GetSetting(InteractableKind type) => + InteractibleSettings.TryGetValue(type, out InteractibleSetting setting) ? setting : fallbackSetting; - Add(InteractableKind.Portal, 7, 7, - activeColor: DefaultActiveColor, - inactiveColor: DefaultInactiveColor, - description: "Portal markers, or objects that spawn portals when interacted with (excluding newt altar)", - path: Icons.Portal); + public static Color GetColor(InteractableKind type, bool active) + { + InteractibleSetting setting = GetSetting(type); + return active ? setting.ActiveColor : setting.InactiveColor; + } - Add(InteractableKind.Totem, 7, 7, - activeColor: Colors.DarkTeal, - inactiveColor: DefaultInactiveColor, - description: "Totems, or totem adjacent objects", - path: Icons.Cube); + public static Dimension2D GetInteractableSize(InteractableKind type) => GetSetting(type).Dimensions; - Add(InteractableKind.Neutral, 4, 4, - activeColor: Color.white, - inactiveColor: DefaultInactiveColor, - description: "Neutral objects or NPCs", - path: Icons.Circle); + public static void LoadApplicationSettings(ILogger logger, IConfig config) + { + Settings.logger = logger; + logLevel = config.Bind("Settings.General", "LogLevel", LogLevel.info, "Console logging detail. Errors and warnings are always logged."); + minimapKey = config.Bind("Settings.General", "EnableKey", KeyCode.M, "Show or hide the minimap."); + increaseScaleKey = config.Bind("Settings.Zoom", "ZoomKey", KeyCode.Equals, "Zoom in."); + decreaseScaleKey = config.Bind("Settings.Zoom", "UnZoomKey", KeyCode.Minus, "Zoom out."); + minimapScale = config.Bind("Settings.Zoom", "zoomLevel", 1f, "Saved map zoom (0.1-20). Icon sizes remain readable while zooming."); + interactionScanInterval = config.Bind("Settings.Performance", "InteractableScanInterval", 1f, "Seconds between interactable registry and cached scene-candidate refreshes (0.25-30)."); + bodyScanInterval = config.Bind("Settings.Performance", "BodyScanInterval", 0.25f, "Seconds between character and dropped pickup refreshes (0.1-5)."); + stateRefreshInterval = config.Bind("Settings.Performance", "StateRefreshInterval", 0.2f, "Seconds between icon availability/color checks (0.1-5). Positions update each frame."); + sceneScanInterval = config.Bind("Settings.Performance", "SceneScanInterval", 5f, "Seconds between fallback searches for objects missing from the game's registries (2-60). After the first few passes of a stage it only keeps running if such objects were found."); + mapWidth = config.Bind("Settings.Layout", "Width", 180f, "Map width in HUD units (60-800), affected by the game's HUD scale."); + mapHeight = config.Bind("Settings.Layout", "Height", 180f, "Map height in HUD units (60-800)."); + positionOffset = config.Bind("Settings.Layout", "PositionOffset", new Vector2(0f, -8f), "Offset from the map's reserved HUD space. Positive X moves right; positive Y moves up."); + backgroundColor = config.Bind("Settings.Layout", "BackgroundColor", new Color(0.03f, 0.04f, 0.06f, 0.65f), "Background color and opacity. Alpha zero makes it transparent."); + rotateWithCamera = config.Bind("Settings.Layout", "RotateWithCamera", true, "Rotate with the camera. Disable for a north-up map."); + worldRadius = config.Bind("Settings.Zoom", "WorldRadius", 0f, "World units from center to the shortest edge at zoom 1. Zero fits stage bounds automatically."); + elevationThreshold = config.Bind("Settings.General", "ElevationThreshold", 5f, "Vertical distance before an elevation arrow appears."); + configRevision = config.Bind("Settings.General", "ConfigRevision", 0, "Used to migrate old default values. Do not change."); + showUnclassifiedInteractables = config.Bind("Settings.General", "ShowUnclassifiedInteractables", true, "Show other scanner-visible interactables as Special, including new DLC and modded objects registered with the game."); + Refresh(); } - public static InteractibleSetting GetSetting(InteractableKind type) + public static void LoadConfigEntries(IConfig config) { - if (InteractibleSettings.ContainsKey(type)) - { - return InteractibleSettings[type]; - } - - return new InteractibleSetting() - { - Dimensions = DefaultUIElementSize, - ActiveColor = DefaultActiveColor, - InactiveColor = DefaultInactiveColor, - Description = "NO_DESCRIPTION", - IconPath = Icons.Default - }; + foreach (InteractableKind kind in InteractibleSettings.Keys) + LoadConfigEntries(kind, config); + if (configRevision is IMutableConfigEntry revision && revision.Value < CurrentConfigRevision) + revision.Value = CurrentConfigRevision; } - public static Color GetColor(InteractableKind type, bool active) + public static void LoadConfigEntries(InteractableKind type, IConfig config) { - if (InteractibleSettings.ContainsKey(type)) + InteractableKind kind = type; + if (!InteractibleSettings.TryGetValue(kind, out InteractibleSetting setting)) { - var setting = InteractibleSettings[type]; - - return active ? setting.ActiveColor : setting.InactiveColor; + logger?.LogError($"No icon settings registered for {kind}."); + return; } - return active ? DefaultActiveColor : DefaultInactiveColor; + string section = $"Icon.{kind}"; + setting.Config = new SettingConfigGroup( + config.Bind(section, "enabled", setting.Enabled, $"Show {setting.Description} on the minimap."), + config.Bind(section, "height", setting.Dimensions.Height, "Icon height in HUD units."), + config.Bind(section, "width", setting.Dimensions.Width, "Icon width in HUD units."), + config.Bind(section, "activeColor", setting.ActiveColor, "Color while available."), + config.Bind(section, "inactiveColor", setting.InactiveColor, "Color after use or while unavailable."), + config.Bind(section, "icon", setting.IconPath, "Legacy resource path of the icon. Existing icon paths remain supported."), + config.Bind(section, "elevationMarkerEnabled", setting.ElevationMarkerEnabled, "Show an elevation marker."), + config.Bind(section, "elevationMarkerOffset", new Vector2(setting.ElevationMarkerOffset.Width, setting.ElevationMarkerOffset.Height), "Elevation marker offset from the icon center."), + config.Bind(section, "elevationMarkerHeight", setting.ElevationMarkerDimensions.Height, "Elevation marker height."), + config.Bind(section, "elevationMarkerWidth", setting.ElevationMarkerDimensions.Width, "Elevation marker width."), + config.Bind(section, "elevationMarkerColor", setting.ElevationMarkerColor, "Elevation marker color.")); + if ((configRevision?.Value ?? CurrentConfigRevision) < 1 && legacySizes.TryGetValue(kind, out var legacy)) + MigrateLegacySize(setting.Config, legacy, defaultSizes[kind]); + RefreshSetting(kind, setting); } - public static Dimension2D GetInteractableSize(InteractableKind type) + private static void MigrateLegacySize(ISettingConfigGroup config, (float Width, float Height) legacy, (float Width, float Height) current) { - if (InteractibleSettings.ContainsKey(type)) + if (config.Width is IMutableConfigEntry width && Same(width.Value, legacy.Width)) + width.Value = current.Width; + if (config.Height is IMutableConfigEntry height && Same(height.Value, legacy.Height)) + height.Value = current.Height; + Dimension2D legacyOffset = ElevationOffset(legacy.Width, legacy.Height); + if (config.ElevationMarkerOffset is IMutableConfigEntry offset + && Same(offset.Value.x, legacyOffset.Width) && Same(offset.Value.y, legacyOffset.Height)) { - return InteractibleSettings[type]?.Dimensions ?? DefaultUIElementSize; + Dimension2D next = ElevationOffset(current.Width, current.Height); + offset.Value = new Vector2(next.Width, next.Height); } - return DefaultUIElementSize; + static bool Same(float a, float b) => Math.Abs(a - b) < 0.001f; } - public static void LoadApplicationSettings(ILogger logger, IConfig config) + public static void SetMinimapScale(float zoom) { - Settings.logger = logger; - _logLevel = config.Bind($"Settings.General", "LogLevel", LogLevel.info, "The amount of information that the minimap mod should output to the console during runtime"); - _MinimapDecreaseScaleKey = config.Bind($"Settings.Zoom", "UnZoomKey", KeyCode.Minus, "Zooms the minimap OUT"); - _MinimapIncreaseScaleKey = config.Bind($"Settings.Zoom", "ZoomKey", KeyCode.Equals, "Zooms the minimap IN"); - _MiniMapKey = config.Bind($"Settings.General", "EnableKey", KeyCode.M, "Key that enables or disabled the minimap"); - _minimapScale = config.Bind($"Settings.Zoom", "zoomLevel", 1.0f, "How far the minimap should be zoomed in"); - - logger.LogDebug($"Loaded log level: {Settings.LogLevel} Minimap[Key: {MinimapKey}] Zoom[{Settings.MinimapScale * 100}% UnZoomKey:{MinimapDecreaseScaleKey} ZoomKey:{MinimapIncreaseScaleKey}]"); + if (minimapScale is IMutableConfigEntry mutable) + mutable.Value = Numeric.Clamp(zoom, 0.1f, 20f, 1f); } - public static void LoadConfigEntries(InteractableKind type, IConfig config) + public static void Refresh() { - InteractibleSetting setting; + float width = Numeric.Clamp(mapWidth?.Value ?? 180f, 60f, 800f, 180f); + float height = Numeric.Clamp(mapHeight?.Value ?? 180f, 60f, 800f, 180f); + ViewfinderSize.Width = MinimapSize.Width = width; + ViewfinderSize.Height = MinimapSize.Height = height; + foreach (KeyValuePair pair in InteractibleSettings) + RefreshSetting(pair.Key, pair.Value); + } - if (InteractibleSettings.ContainsKey(type) is false) - { - logger.LogError($"Failed to find an interactible setting for {type}, aborting config loading for {type}s"); + private static void RefreshSetting(InteractableKind kind, InteractibleSetting setting) + { + ISettingConfigGroup config = setting.Config; + if (config == null) return; - } - - setting = InteractibleSettings[type]; - - string sectionTitle = $"Icon.{type}"; - - IConfigEntry enabled = config.Bind(sectionTitle, "enabled", true, $"Whether or not {setting.Description} should be shown on the minimap"); - IConfigEntry activeColor = config.Bind(sectionTitle, "activeColor", setting.ActiveColor, "The color the icon should be when it has not been interacted with"); - IConfigEntry inactiveColor = config.Bind(sectionTitle, "inactiveColor", setting.InactiveColor, "The color the icon should be when it has used/bought"); - IConfigEntry width = config.Bind(sectionTitle, "width", setting.Dimensions.Width, "Width of the icon"); - IConfigEntry height = config.Bind(sectionTitle, "height", setting.Dimensions.Height, "Width of the icon"); - IConfigEntry path = config.Bind(sectionTitle, "icon", setting.IconPath ?? Icons.Default, $"The streaming assets path of the icon"); - - IConfigEntry elevationEnabled = config.Bind(sectionTitle, "elevationMarkerEnabled", true, $"Whether or not the elevation marker for this icon should be shown on the minimap"); - IConfigEntry elevationActiveColor = config.Bind(sectionTitle, "elevationMarkerColor", setting.ElevationMarkerColor, "The color the elevation marker should be when it is shown"); - IConfigEntry elevationWidth = config.Bind(sectionTitle, "elevationMarkerWidth", setting.ElevationMarkerDimensions.Width, "Width of the elevation marker icon"); - IConfigEntry elevationHeight = config.Bind(sectionTitle, "elevationMarkerHeight", setting.ElevationMarkerDimensions.Height, "Width of the elevation marker icon"); - IConfigEntry elevationOffset = config.Bind(sectionTitle, "elevationMarkerOffset", new Vector2(setting.ElevationMarkerOffset.Width, setting.ElevationMarkerOffset.Height), "The value of the x and y of the elevation marker's position relative to the main icon's center"); - setting.Config = new SettingConfigGroup( - enabled, - height, - width, - activeColor, - inactiveColor, - path, - elevationEnabled, - elevationOffset, - elevationHeight, - elevationWidth, - elevationActiveColor - ); - - setting.ActiveColor = activeColor.Value; - setting.InactiveColor = inactiveColor.Value; - setting.Dimensions.Height = height.Value; - setting.Dimensions.Width = width.Value; - setting.IconPath = path.Value; - setting.ElevationMarkerColor = elevationActiveColor.Value; - setting.ElevationMarkerEnabled = elevationEnabled.Value; - setting.ElevationMarkerOffset = new Dimension2D(elevationOffset.Value.x, elevationOffset.Value.y); - setting.ElevationMarkerDimensions = new Dimension2D(elevationWidth.Value, elevationHeight.Value); - - logger.LogInfo($"Loaded {type} config [{(setting.Config.Enabled.Value ? "enabled" : "disabled")}, {setting.ActiveColor}, {setting.InactiveColor}, ({setting.Dimensions.Width}x{setting.Dimensions.Height})] Elevation [{(setting.Config.EnabledElevationMarker.Value ? "enabled" : "disabled")}, {setting.ElevationMarkerColor}, dimensions: ({setting.ElevationMarkerDimensions.Width}x{setting.ElevationMarkerDimensions.Height}), offset: ({setting.ElevationMarkerOffset.Width}x{setting.ElevationMarkerOffset.Height})]"); + (float width, float height) = defaultSizes.TryGetValue(kind, out var size) ? size : (11f, 11f); + setting.Enabled = config.Enabled.Value; + setting.Dimensions.Width = Numeric.Clamp(config.Width.Value, 0.5f, 128f, width); + setting.Dimensions.Height = Numeric.Clamp(config.Height.Value, 0.5f, 128f, height); + setting.ActiveColor = SanitizeColor(config.ActiveColor.Value); + setting.InactiveColor = SanitizeColor(config.InactiveColor.Value); + setting.IconPath = string.IsNullOrWhiteSpace(config.IconPath.Value) ? Icons.Default : config.IconPath.Value; + setting.ElevationMarkerEnabled = config.EnabledElevationMarker?.Value ?? false; + setting.ElevationMarkerColor = SanitizeColor(config.ElevationMarkerColor?.Value ?? Color.white); + setting.ElevationMarkerDimensions.Width = Numeric.Clamp(config.ElevationMarkerWidth?.Value ?? 3f, 0.5f, 64f, 3f); + setting.ElevationMarkerDimensions.Height = Numeric.Clamp(config.ElevationMarkerHeight?.Value ?? 3f, 0.5f, 64f, 3f); + Vector2 offset = config.ElevationMarkerOffset?.Value ?? Vector2.zero; + setting.ElevationMarkerOffset.Width = Numeric.Clamp(offset.x, -256f, 256f, 0f); + setting.ElevationMarkerOffset.Height = Numeric.Clamp(offset.y, -256f, 256f, 0f); } + + private static Color SanitizeColor(Color color) => new( + Numeric.Clamp(color.r, 0f, 1f, 1f), Numeric.Clamp(color.g, 0f, 1f, 1f), + Numeric.Clamp(color.b, 0f, 1f, 1f), Numeric.Clamp(color.a, 0f, 1f, 1f)); } } diff --git a/MiniMapLibrary/Sprites/SpriteManager.cs b/MiniMapLibrary/Sprites/SpriteManager.cs index a872102..6eacff9 100644 --- a/MiniMapLibrary/Sprites/SpriteManager.cs +++ b/MiniMapLibrary/Sprites/SpriteManager.cs @@ -1,76 +1,197 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using UnityEngine; -using RoR2; +using UnityEngine.AddressableAssets; +using UnityEngine.ResourceManagement.AsyncOperations; -#nullable enable namespace MiniMapLibrary { - public sealed class SpriteManager : IDisposable, ISpriteManager + public sealed class SpriteManager : ISpriteManager { - private readonly Dictionary SpriteCache = new Dictionary(); + private sealed class Request + { + public readonly string Path; + public readonly Action> Completed; + public AsyncOperationHandle Handle; + public Sprite Sprite; + public bool Finished; + + public Request(SpriteManager owner, string path) + { + Path = path; + Completed = operation => owner.Complete(this, operation); + } + } + private readonly Dictionary requests = new(StringComparer.Ordinal); private readonly ILogger logger; + private readonly bool asynchronous; + private enum Shape { Diamond, Arrow, Dot } - public SpriteManager(ILogger logger) - { - this.logger = logger; - } + private readonly Sprite[] generated = new Sprite[3]; + private bool disposed; - public void Dispose() + public int Revision { get; private set; } + + // MiniMapMod loads asynchronously and redraws icons when Revision changes. + public SpriteManager(ILogger logger) : this(logger, false) { } + + public SpriteManager(ILogger logger, bool asynchronous) { - SpriteCache.Clear(); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.asynchronous = asynchronous; } - public Sprite? GetSprite(InteractableKind type) + public Sprite GetSprite(InteractableKind type) => type == InteractableKind.none ? null : GetOrCache(Settings.GetSetting(type).IconPath); + + public void PreloadConfiguredIcons() { - if (type == InteractableKind.none) + GetOrCache(Settings.Icons.Default); + GetOrCache(Settings.Icons.Arrow); + foreach (InteractibleSetting setting in Settings.InteractibleSettings.Values) { - return null; + if (setting.Enabled) + GetOrCache(setting.IconPath); } + } - string? path = Settings.GetSetting(type)?.Config?.IconPath?.Value; + public Sprite GetOrCache(string Path) + { + string path = Path; + if (disposed) + throw new ObjectDisposedException(nameof(SpriteManager)); + if (string.IsNullOrWhiteSpace(path)) + path = Settings.Icons.Default; + if (path == Settings.Icons.Dot) + return Generated(Shape.Dot); + if (requests.TryGetValue(path, out Request request)) + return request.Sprite ? request.Sprite : GetFallback(path); - if (path != null) + request = new Request(this, path); + requests.Add(path, request); + try { - return GetOrCache(path); - } + // Configs store the old Resources paths; map them to Addressables keys. + string key = RoR2.LegacyResourcesAPI.GetPathGuidString(path) ?? path; + request.Handle = Addressables.LoadAssetAsync(key); + if (!asynchronous && !request.Handle.IsDone) + request.Handle.WaitForCompletion(); - throw new MissingComponentException($"MissingTextureException: Interactible.{type} does not have a registered texture path to load."); + if (request.Handle.IsDone) + Complete(request, request.Handle); + else + request.Handle.Completed += request.Completed; + } + catch (Exception exception) + { + request.Finished = true; + logger.LogException(exception, $"Could not request minimap icon '{path}'. Using a fallback."); + } + return request.Sprite ? request.Sprite : GetFallback(path); } - public Sprite? GetOrCache(string Path) + private void Complete(Request request, AsyncOperationHandle operation) { - if (SpriteCache.ContainsKey(Path)) + if (disposed) + return; + request.Finished = true; + if (operation.Status == AsyncOperationStatus.Succeeded && operation.Result) + request.Sprite = operation.Result; + else { - return SpriteCache[Path]; + logger.LogWarning($"Minimap icon '{request.Path}' was unavailable; using a fallback. This path will not be retried during this session."); + if (operation.OperationException != null) + logger.LogException(operation.OperationException, $"Minimap sprite load failed: {request.Path}"); } + Revision++; + } - Sprite? loaded = RoR2.LegacyResourcesAPI.Load(Path); - - if (loaded != null) + public string DescribeRequests() + { + int loaded = 0, pending = 0, failed = 0; + foreach (Request request in requests.Values) { - SpriteCache.Add(Path, loaded); + if (request.Sprite) loaded++; + else if (request.Finished) failed++; + else pending++; } - else - { - loaded = RoR2.LegacyResourcesAPI.Load(Settings.Icons.Default); + return $"requests={requests.Count} loaded={loaded} pending={pending} failed={failed} generatedFallback={(bool)generated[(int)Shape.Diamond]} revision={Revision}"; + } - if (loaded is null) - { - logger.LogError($"Attempted to use default icon for non-existen texture at {Path} but default icon path of {Settings.Icons.Default} also failed to load from the streaming assets path."); - return null; - } + private Sprite GetFallback(string path) + { + if (path == Settings.Icons.Arrow) + return Generated(Shape.Arrow); + if (requests.TryGetValue(Settings.Icons.Default, out Request defaultRequest) && defaultRequest.Sprite) + return defaultRequest.Sprite; + return Generated(Shape.Diamond); + } - logger.LogWarning($"Attempted to load icon texture at streaming asset path: {Path}, but it was not found, using default [?] instead."); + private Sprite Generated(Shape shape) + { + ref Sprite sprite = ref generated[(int)shape]; + if (!sprite) + sprite = CreateSprite(shape); + return sprite; + } - SpriteCache.Add(Path, loaded); + private static Sprite CreateSprite(Shape shape) + { + const int size = 16; + const float center = (size - 1) * 0.5f; + var pixels = new Color32[size * size]; + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + float dx = x - center, dy = Math.Abs(y - center); + float coverage = shape switch + { + Shape.Arrow => x >= dy && x <= 12f ? 1f : 0f, + // Soft edge so the dot stays round when scaled down to a few pixels. + Shape.Dot => Math.Clamp(center + 0.5f - (float)Math.Sqrt(dx * dx + dy * dy), 0f, 1f), + _ => Math.Abs(dx) + dy <= center ? 1f : 0f + }; + pixels[y * size + x] = new Color32(255, 255, 255, (byte)(coverage * 255f)); + } } + var texture = new Texture2D(size, size, TextureFormat.RGBA32, false) + { + name = "MiniMap." + shape, + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Bilinear, + wrapMode = TextureWrapMode.Clamp + }; + texture.SetPixels32(pixels); + texture.Apply(false, true); + Sprite sprite = Sprite.Create(texture, new Rect(0f, 0f, size, size), new Vector2(0.5f, 0.5f)); + sprite.name = texture.name; + sprite.hideFlags = HideFlags.HideAndDontSave; + return sprite; + } - return loaded; + public void Dispose() + { + if (disposed) + return; + disposed = true; + foreach (Request request in requests.Values) + { + if (!request.Handle.IsValid()) + continue; + request.Handle.Completed -= request.Completed; + Addressables.Release(request.Handle); + } + requests.Clear(); + // Loaded sprites belong to Addressables; only our generated ones are destroyed here. + for (int i = 0; i < generated.Length; i++) + { + if (!generated[i]) + continue; + UnityEngine.Object.Destroy(generated[i].texture); + UnityEngine.Object.Destroy(generated[i]); + generated[i] = null; + } } } } diff --git a/MiniMapMod/Adapters/ConfigAdapter.cs b/MiniMapMod/Adapters/ConfigAdapter.cs index f305627..869fde4 100644 --- a/MiniMapMod/Adapters/ConfigAdapter.cs +++ b/MiniMapMod/Adapters/ConfigAdapter.cs @@ -13,7 +13,7 @@ public class ConfigAdapter : IConfig public ConfigAdapter(ConfigFile plugin) { - this.plugin = plugin; + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin)); } public IConfigEntry Bind(string section, string key, T defaultValue, string description) => new ConfigEntryAdapter(plugin.Bind(section, key, defaultValue, description)); diff --git a/MiniMapMod/Adapters/ConfigEntryAdapter.cs b/MiniMapMod/Adapters/ConfigEntryAdapter.cs index 0c4c07e..97ba952 100644 --- a/MiniMapMod/Adapters/ConfigEntryAdapter.cs +++ b/MiniMapMod/Adapters/ConfigEntryAdapter.cs @@ -6,15 +6,15 @@ namespace MiniMapMod.Adapters { - public class ConfigEntryAdapter : IConfigEntry + public class ConfigEntryAdapter : IMutableConfigEntry { private readonly ConfigEntry entry; public ConfigEntryAdapter(ConfigEntry entry) { - this.entry = entry; + this.entry = entry ?? throw new ArgumentNullException(nameof(entry)); } - public T Value => entry.Value; + public T Value { get => entry.Value; set => entry.Value = value; } } } diff --git a/MiniMapMod/Log.cs b/MiniMapMod/Log.cs index 7f7812d..b640082 100644 --- a/MiniMapMod/Log.cs +++ b/MiniMapMod/Log.cs @@ -10,34 +10,23 @@ public class Log : ILogger public Log(ManualLogSource logSource) { - _logSource = logSource; + _logSource = logSource ?? throw new ArgumentNullException(nameof(logSource)); } - public void LogDebug(object data){ if(Settings.LogLevel > MiniMapLibrary.LogLevel.info) _logSource.LogDebug(data); } + // BepInEx filters Debug out of the disk log by default, so debug output goes out as Info. + public void LogDebug(object data) { if (Settings.DebugLogging) _logSource.LogInfo($"[DEBUG] {data}"); } public void LogError(object data) => _logSource.LogError(data); public void LogFatal(object data) => _logSource.LogFatal(data); - public void LogInfo(object data) { if (Settings.LogLevel > MiniMapLibrary.LogLevel.none) _logSource.LogDebug(data); } + public void LogInfo(object data) { if (Settings.LogLevel > MiniMapLibrary.LogLevel.none) _logSource.LogInfo(data); } public void LogMessage(object data) => _logSource.LogMessage(data); public void LogWarning(object data) => _logSource.LogWarning(data); public void LogException(Exception head, string message = "") { - LogError(message); - - // log the exception linked list's messages - // this is only really used for weird event and async method - // behaviour, most of the time this is unnessesary - while (head != null) - { - LogError($"\t{head.Message}"); - - LogError($"\t{head.StackTrace}"); - - if (head.InnerException != null) - { - head = head.InnerException; - } - } + if (!string.IsNullOrEmpty(message)) + LogError(message); + if (head != null) + LogError(head); } } -} \ No newline at end of file +} diff --git a/MiniMapMod/MarkerRenderer.cs b/MiniMapMod/MarkerRenderer.cs new file mode 100644 index 0000000..a07d1c7 --- /dev/null +++ b/MiniMapMod/MarkerRenderer.cs @@ -0,0 +1,123 @@ +using System; +using MiniMapLibrary; +using MiniMapLibrary.Scanner; +using UnityEngine; +using UnityEngine.UI; + +namespace MiniMapMod +{ + internal sealed class MarkerRenderer + { + private readonly Minimap minimap; + private readonly SpriteManager sprites; + private readonly SceneTracker tracker; + public int Frames { get; private set; } + // Breaks Visible down further; costs a few native calls per icon, so debug only. + public bool Diagnostics { get; set; } + public int Tracked, Visible, Icons, Disabled, Hidden, Dead, Clipped, InvalidCoordinates; + public int Transparent, MissingSprite, HierarchyHidden, CanvasCulled, Failures, Retrying; + + public MarkerRenderer(Minimap minimap, SpriteManager sprites, SceneTracker tracker) + { + this.minimap = minimap; + this.sprites = sprites; + this.tracker = tracker; + } + + public void BeginFrame() + { + Frames++; + Tracked = Visible = Icons = Disabled = Hidden = Dead = Clipped = InvalidCoordinates = 0; + Transparent = MissingSprite = HierarchyHidden = CanvasCulled = Failures = Retrying = 0; + } + + public void Reset() { BeginFrame(); Frames = 0; } + + public void Render(ReconciledSet markers, Vector3 viewerPosition, float scale) + { + float halfWidth = Settings.ViewfinderSize.Width * 0.5f; + float halfHeight = Settings.ViewfinderSize.Height * 0.5f; + for (int i = markers.Count - 1; i >= 0; i--) + { + SceneMarker marker = markers[i]; + Tracked++; + if (!marker.Object || !marker.Source || !marker.WorldTransform) + { + tracker.RemoveMarker(markers, i); + Dead++; + continue; + } + try + { + if (Time.unscaledTime < marker.NextRenderAttempt) { Retrying++; Hide(marker); continue; } + if (!marker.Setting.Enabled) { Disabled++; Hide(marker); continue; } + if (!marker.DisplayAllowed || !marker.Source.isActiveAndEnabled) { Hidden++; Hide(marker); continue; } + if (marker.IsBody && marker.Health && !marker.Health.alive) { Dead++; Hide(marker); continue; } + Vector3 position = marker.WorldTransform.position; + if (!MapProjection.TryProject(position, viewerPosition, scale, minimap.RotationSine, minimap.RotationCosine, out Vector2 mapPosition)) + { + InvalidCoordinates++; + Hide(marker); + continue; + } + float padding = Math.Max(marker.Setting.Dimensions.Width, marker.Setting.Dimensions.Height); + if (!MapProjection.IsInside(mapPosition, halfWidth, halfHeight, padding)) { Clipped++; Hide(marker); continue; } + + RectTransform icon = marker.Icon.MinimapTransform; + if (!icon) + { + icon = minimap.CreateIcon(marker.Kind, mapPosition, sprites); + if (!icon) throw new InvalidOperationException("Icon factory returned no RectTransform for " + marker.Kind); + marker.Icon.MinimapTransform = icon; + marker.State.CheckActive(); + marker.HasPosition = false; + } + if (!marker.DisplayAllowed) { Hidden++; Hide(marker); continue; } + if (!icon.gameObject.activeSelf) + icon.gameObject.SetActive(true); + if (!marker.HasPosition || (marker.LastPosition - mapPosition).sqrMagnitude > 0.0001f) + { + icon.anchoredPosition = mapPosition; + marker.LastPosition = mapPosition; + marker.HasPosition = true; + } + marker.Icon.UpdateElevation(viewerPosition.y, position.y, Settings.ElevationThreshold); + Image image = marker.State.MinimapImage; + if (!image) throw new InvalidOperationException("Icon has no Unity UI Image for " + marker.Kind); + if (marker.SpriteRevision != sprites.Revision) + { + image.sprite = sprites.GetSprite(marker.Kind); + marker.Icon.RefreshSprite(); + marker.SpriteRevision = sprites.Revision; + } + Icons++; + if (!Diagnostics) Visible++; + else if (!image.sprite) MissingSprite++; + else if (image.color.a <= 0f) Transparent++; + else if (!icon.gameObject.activeInHierarchy) HierarchyHidden++; + else if (image.canvasRenderer.cull) CanvasCulled++; + else Visible++; + } + catch (Exception exception) + { + Failures++; + // Back off so a broken icon doesn't throw every frame. + marker.NextRenderAttempt = Time.unscaledTime + 2f; + Hide(marker); + tracker.ReportFailure(marker.Source.GetType(), "render/icon", exception); + } + } + } + + private static void Hide(SceneMarker marker) + { + RectTransform icon = marker.Icon.MinimapTransform; + if (icon && icon.gameObject.activeSelf) + icon.gameObject.SetActive(false); + } + + public override string ToString() => $"frames={Frames} tracked={Tracked} icons={Icons} visible={Visible}" + + $" filtered(disabled={Disabled},hidden={Hidden},dead={Dead},clipped={Clipped}) invalidCoordinates={InvalidCoordinates}" + + $" transparent={Transparent} missingSprite={MissingSprite} hierarchyHidden={HierarchyHidden} canvasCulled={CanvasCulled} errors={Failures} retrying={Retrying}"; + } +} diff --git a/MiniMapMod/MiniMapMod.csproj b/MiniMapMod/MiniMapMod.csproj index bff7de7..78e8248 100644 --- a/MiniMapMod/MiniMapMod.csproj +++ b/MiniMapMod/MiniMapMod.csproj @@ -2,8 +2,13 @@ netstandard2.1 - preview - true + 12.0 + 4.2.0 + 1.0.0.0 + 4.2.0.0 + false + true + true @@ -17,310 +22,38 @@ - - libs\Assembly-CSharp.dll - false - - libs\BepInEx.dll - - - libs\com.unity.multiplayer-hlapi.Runtime.dll - - - libs\Facepunch.Steamworks.dll - false - - - libs\HGCSharpUtils.dll - false - - - libs\HGUnityUtils.dll - false - - - libs\MMHOOK_Assembly-CSharp.dll - false - - - libs\Mono.Security.dll - false - - - libs\Rewired_Core.dll - false - - - libs\Rewired_CSharp.dll + libs\BepInEx.dll false - libs\RoR2.dll - - - libs\SimpleJSON.dll - false - - - libs\Unity.Postprocessing.Runtime.dll - false - - - libs\Unity.RenderPipelines.Core.Runtime.dll - false - - - libs\Unity.RenderPipelines.Core.ShaderLibrary.dll - false - - - libs\Unity.TextMeshPro.dll + libs\RoR2.dll false libs\UnityEngine.dll false - - libs\UnityEngine.AccessibilityModule.dll - false - - - libs\UnityEngine.AIModule.dll - false - - - libs\UnityEngine.AnimationModule.dll - false - - - libs\UnityEngine.ARModule.dll - false - - - libs\UnityEngine.AssetBundleModule.dll - false - - - libs\UnityEngine.AudioModule.dll - false - - - libs\UnityEngine.ClothModule.dll - false - - - libs\UnityEngine.ClusterInputModule.dll - false - - - libs\UnityEngine.ClusterRendererModule.dll + + libs\com.unity.multiplayer-hlapi.Runtime.dll false libs\UnityEngine.CoreModule.dll false - - libs\UnityEngine.CrashReportingModule.dll - false - - - libs\UnityEngine.DirectorModule.dll - false - - - libs\UnityEngine.GameCenterModule.dll - false - - - libs\UnityEngine.GridModule.dll - false - - - libs\UnityEngine.HotReloadModule.dll - false - - - libs\UnityEngine.ImageConversionModule.dll - false - - - libs\UnityEngine.IMGUIModule.dll - false - - libs\UnityEngine.InputLegacyModule.dll - - - libs\UnityEngine.InputModule.dll - false - - - libs\UnityEngine.JSONSerializeModule.dll - false - - - libs\UnityEngine.LocalizationModule.dll - false - - - libs\UnityEngine.ParticleSystemModule.dll - false - - - libs\UnityEngine.PerformanceReportingModule.dll - false - - - libs\UnityEngine.Physics2DModule.dll - false - - - libs\UnityEngine.PhysicsModule.dll - false - - - libs\UnityEngine.ProfilerModule.dll - false - - - libs\UnityEngine.ScreenCaptureModule.dll - false - - - libs\UnityEngine.SharedInternalsModule.dll - false - - - libs\UnityEngine.SpatialTracking.dll - false - - - libs\UnityEngine.SpriteMaskModule.dll - false - - - libs\UnityEngine.SpriteShapeModule.dll - false - - - libs\UnityEngine.StreamingModule.dll - false - - - libs\UnityEngine.SubstanceModule.dll - false - - - libs\UnityEngine.TerrainModule.dll - false - - - libs\UnityEngine.TerrainPhysicsModule.dll - false - - - libs\UnityEngine.TextCoreModule.dll - false - - - libs\UnityEngine.TextRenderingModule.dll - false - - - libs\UnityEngine.TilemapModule.dll - false - - - libs\UnityEngine.TLSModule.dll + libs\UnityEngine.InputLegacyModule.dll false libs\UnityEngine.UI.dll false - - libs\UnityEngine.UIElementsModule.dll - false - libs\UnityEngine.UIModule.dll false - - libs\UnityEngine.UmbraModule.dll - false - - - libs\UnityEngine.UNETModule.dll - false - - - libs\UnityEngine.UnityAnalyticsModule.dll - false - - - libs\UnityEngine.UnityConnectModule.dll - false - - - libs\UnityEngine.UnityTestProtocolModule.dll - false - - - libs\UnityEngine.UnityWebRequestAssetBundleModule.dll - false - - - libs\UnityEngine.UnityWebRequestAudioModule.dll - false - - - libs\UnityEngine.UnityWebRequestModule.dll - false - - - libs\UnityEngine.UnityWebRequestTextureModule.dll - false - - - libs\UnityEngine.UnityWebRequestWWWModule.dll - false - - - libs\UnityEngine.VehiclesModule.dll - false - - - libs\UnityEngine.VFXModule.dll - false - - - libs\UnityEngine.VideoModule.dll - false - - - libs\UnityEngine.VRModule.dll - false - - - libs\UnityEngine.WindModule.dll - false - - - libs\UnityEngine.XRModule.dll - false - - - libs\Wwise.dll - false - - - libs\Zio.dll - false - diff --git a/MiniMapMod/MiniMapPlugin.cs b/MiniMapMod/MiniMapPlugin.cs index 672d49c..01253c5 100644 --- a/MiniMapMod/MiniMapPlugin.cs +++ b/MiniMapMod/MiniMapPlugin.cs @@ -1,572 +1,385 @@ -using BepInEx; -using RoR2; -using UnityEngine; -using MiniMapLibrary; -using System.Collections.Generic; -using System.Linq; using System; +using BepInEx; using BepInEx.Configuration; +using MiniMapLibrary; using MiniMapMod.Adapters; -using MiniMapLibrary.Config; -using MiniMapLibrary.Scanner; +using RoR2; +using RoR2.UI; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UI; namespace MiniMapMod { - [BepInPlugin("MiniMap", "Mini Map Mod", "4.0.0")] + [BepInPlugin(PluginGuid, PluginName, PluginVersion)] + [DefaultExecutionOrder(1000)] public class MiniMapPlugin : BaseUnityPlugin { - private ISpriteManager SpriteManager; - - private readonly List TrackedObjects = new(); - - private Minimap Minimap; - - private readonly Range3D TrackedDimensions = new(); - - private bool Enable = true; - - private bool ScannedStaticObjects = false; - - private IConfig config; + public const string PluginGuid = "MiniMap"; + public const string PluginName = "Mini Map Mod"; + public const string PluginVersion = "4.2.0"; + private readonly SceneBounds bounds = new(); private MiniMapLibrary.ILogger logger; - - private ITrackedObjectScanner[] staticScanners; - private ITrackedObjectScanner[] dynamicScanners; - - private readonly Timer dynamicScanTimer = new(5.0f); - private readonly Timer cooldownTimer = new(2.0f, false) { Value = -1.0f }; + private SpriteManager sprites; + private Minimap minimap; + private SceneTracker tracker; + private MarkerRenderer renderer; + private Run currentRun; + private HUD hud; private CameraRigController cameraRig; - - public void Awake() + private SceneInfo sceneInfo; + private GameObject viewedObject; + private Transform viewedTransform; + private Vector3 viewerPosition; + private float zoom = 1f; + private float nextHudAttempt, nextInteractionScan, nextBodyScan, nextStateRefresh, nextSceneScan, nextErrorLog, nextDiagnostics; + private float nextLayoutCheck; + private float lastScale; + private string sceneChangeReason = "enable"; + private bool initialized, subscribed, sceneDirty; + private volatile bool configurationDirty, zoomDirty; + private bool visible = true; + private int spriteRevision = -1; + private int sceneScansThisStage; + + private void Awake() { - // wrap the bepinex logger with an adapter so - // we can pass it to the business layer - logger = new Log(base.Logger); - - SpriteManager = new SpriteManager(logger); - - // create the minimap controller - Minimap = new(logger); - - // SETUP CONFIG - - // wrap bepinex config so we can pass it to business layer - config = new ConfigAdapter(this.Config); - - Settings.LoadApplicationSettings(logger, config); - - // bind options - InteractableKind[] kinds = Enum.GetValues(typeof(InteractableKind)).Cast().Where(x => x != InteractableKind.none && x != InteractableKind.All).ToArray(); - - foreach (var item in kinds) - { - Settings.LoadConfigEntries(item, config); - } - - logger.LogInfo("Creating scene scan hooks"); - - // fill the scanner arrays - CreateStaticScanners(); - - CreateDynamicScanners(); - - // hook events so the minimaps updates - // scan scene should NEVER throw exceptions - // doing so prevents all other subscribing events to not fire (after the exception) - - // this will re-scan the scene every time any npc, player dies - GlobalEventManager.onCharacterDeathGlobal += (x) => ScanScene(); - - // this will re-scan when the player uses anything like a chest - // or the landing pod - GlobalEventManager.OnInteractionsGlobal += (x, y, z) => ScanScene(); - - // update the minimap automatically every N seconds regardless of deaths etc - dynamicScanTimer.OnFinished += (x) => ScanScene(); - } + if (Application.isBatchMode) + return; - //The Update() method is run on every frame of the game. - private void Update() - { - if (UnityEngine.Input.GetKeyDown(Settings.MinimapKey)) + logger = new Log(Logger); + try { - Enable = !Enable; - - if (Enable == false) - { - logger.LogInfo("Resetting minimap"); - Reset(); - return; - } + var config = new ConfigAdapter(Config); + Settings.LoadApplicationSettings(logger, config); + Settings.LoadConfigEntries(config); + + zoom = Settings.MinimapScale; + sprites = new SpriteManager(logger, asynchronous: true); + minimap = new Minimap(logger); + tracker = new SceneTracker(minimap, sprites, logger, () => viewerPosition.y); + renderer = new MarkerRenderer(minimap, sprites, tracker); + initialized = true; + logger.LogInfo($"MiniMap {PluginVersion} initialized."); + logger.LogDebug($"[scanner.init] interactables={Settings.InteractionScanInterval:F2}s bodies={Settings.BodyScanInterval:F2}s sceneFallback={Settings.SceneScanInterval:F2}s state={Settings.StateRefreshInterval:F2}s; worldRadius={Settings.WorldRadius} (0=automatic) zoom={zoom:F2}."); } - - cooldownTimer.Update(Time.deltaTime); - dynamicScanTimer.Update(Time.deltaTime); - - if (Enable) + catch (Exception exception) { - // the main camera becomes null when the scene ends on death or quits - if (Camera.main == null) - { - logger.LogDebug("Main camera was null, resetting minimap"); - Reset(); - return; - } - - if (Minimap.Created) - { - try - { - Minimap.SetRotation(Camera.main.transform.rotation); - - UpdateIconPositions(); - - if (Input.GetKeyDown(Settings.MinimapIncreaseScaleKey)) - { - Minimap.Container.transform.localScale *= 1.1f; - } - else - if (Input.GetKeyDown(Settings.MinimapDecreaseScaleKey)) - { - Minimap.Container.transform.localScale *= 0.90f; - } - } - catch (NullReferenceException) - { - // we'll encounter null references when other mods or the game itself - // destroys entities we are tracking at runtime - logger.LogDebug($"{nameof(NullReferenceException)} was encountered while updating positions, reseting minimap"); - Reset(); - } - } - else - { - if (TryCreateMinimap()) - { - TrackedDimensions.Clear(); - - ScanScene(); - } - } + logger.LogException(exception, "MiniMap could not initialize."); + enabled = false; } } - private void UpdateIconPositions() + private void OnEnable() { - // only perform this calculation once per frame - Vector2 cameraPositionMinimap = GetPlayerPosition().ToMinimapPosition(TrackedDimensions); - - for (int i = 0; i < TrackedObjects.Count; i++) - { - ITrackedObject item = TrackedObjects[i]; - - // if we dont have a reference to the gameobject any more, remove it and continue - if (item.gameObject == null) - { - TrackedObjects.RemoveAt(i); - - // if we still have a icon for the now discarded item destroy it - if (item.MinimapTransform != null) - { - GameObject.Destroy(item.MinimapTransform.gameObject); - } - - continue; - } - - // convert the world positions to minimap positions - // remember the minimap is calculated on a scale from 0d to 1d where 0d is the least most coord of any interactible and 1d is the largest coord of any interactible - - Vector2 itemMinimapPosition = item.gameObject.transform.position.ToMinimapPosition(TrackedDimensions) - cameraPositionMinimap; - - // there exists no icon when .MinimapTransform is null - if (item.MinimapTransform == null) - { - // create one - item.MinimapTransform = Minimap.CreateIcon(item.InteractableType, itemMinimapPosition, this.SpriteManager); - } - else - { - // since it was already created update the position - item.MinimapTransform.localPosition = itemMinimapPosition; - - // becuase we don't want the icons to spin WITH the minimap set their rotation - // every frame so they're always facing right-side up - // (they inherit their position from the minimap) - item.MinimapTransform.rotation = Quaternion.identity; - } - - // check to see if its active and whether to change its color - // check the position to see if we should show arrow - item.CheckActive(); - } + if (!initialized || subscribed) + return; + SceneManager.activeSceneChanged += OnActiveSceneChanged; + Stage.onStageStartGlobal += OnStageStarted; + Run.onRunDestroyGlobal += OnRunDestroyed; + Config.SettingChanged += OnConfigurationChanged; + subscribed = true; + sceneDirty = true; } - private bool TryCreateMinimap() + private void OnDisable() { - GameObject objectivePanel = GameObject.Find("ObjectivePanel"); - - if (objectivePanel == null || this.SpriteManager == null) + if (subscribed) { - Minimap.Destroy(); - return false; + SceneManager.activeSceneChanged -= OnActiveSceneChanged; + Stage.onStageStartGlobal -= OnStageStarted; + Run.onRunDestroyGlobal -= OnRunDestroyed; + Config.SettingChanged -= OnConfigurationChanged; + subscribed = false; } - - logger.LogInfo("Creating Minimap object"); - - Minimap.CreateMinimap(this.SpriteManager, objectivePanel.gameObject); - - Minimap.Container.transform.localScale = Vector3.one * Settings.MinimapScale; - - logger.LogInfo("Finished creating Minimap"); - - return true; + ResetScene(); } - private void Reset() + private void OnDestroy() { - logger.LogDebug($"Clearing {nameof(TrackedObjects)}"); - TrackedObjects.Clear(); - - logger.LogDebug($"Clearing {nameof(TrackedDimensions)}"); - TrackedDimensions.Clear(); - - logger.LogDebug($"Destroying {nameof(Minimap)}"); - Minimap.Destroy(); - - dynamicScanTimer.Reset(); - cooldownTimer.Reset(); + OnDisable(); + sprites?.Dispose(); + sprites = null; + initialized = false; + } - // mark the scene as scannable again so we scan for chests etc.. - ScannedStaticObjects = false; + // Only set flags here; an exception thrown inside a global event would hit other subscribers. + private void OnActiveSceneChanged(Scene previous, Scene current) { sceneDirty = true; sceneChangeReason = $"scene {previous.name} -> {current.name}"; } + private void OnStageStarted(Stage stage) { sceneDirty = true; sceneChangeReason = "stage start"; } + private void OnRunDestroyed(Run run) { sceneDirty = true; sceneChangeReason = "run destroyed"; } - cameraRig = null; + private void OnConfigurationChanged(object sender, SettingChangedEventArgs args) + { + ConfigDefinition definition = args.ChangedSetting.Definition; + if (definition.Section == "Settings.Zoom" && definition.Key == "zoomLevel") + zoomDirty = true; + else + configurationDirty = true; } - private void ScanScene() + private void Update() { - // don't scan if the minimap isn't enabled - if (Enable == false) - { + if (!initialized) return; - } - // when other mods hook into the various global events - // and this method throws exceptions, the entire event will throw and fail to invoke their methods - // as a result, this method should never throw an exception and should output meaningful - // errors + float now = Time.unscaledTime; try { - if (cooldownTimer.Started is false) + if (sceneDirty || sceneInfo != SceneInfo.instance || currentRun != Run.instance) { - cooldownTimer.Start(); + ResetScene(); + sceneInfo = SceneInfo.instance; + currentRun = Run.instance; + sceneDirty = false; + logger.LogDebug($"[scene] {sceneChangeReason}; active={SceneManager.GetActiveScene().name} run={(bool)Run.instance} sceneInfo={(bool)sceneInfo}. Tracking and UI cleared."); } - else if (cooldownTimer.Expired is false) + if (configurationDirty) { - return; + Settings.Refresh(); + tracker.Clear(); + renderer.Reset(); + minimap.Destroy(); + hud = null; + nextHudAttempt = nextInteractionScan = nextBodyScan = nextStateRefresh = nextSceneScan = 0f; + sceneScansThisStage = 0; + configurationDirty = false; + logger.LogDebug("[config] Refreshed existing settings and rebuilt marker state."); } - - cooldownTimer.Reset(); - cooldownTimer.Start(); - - logger.LogDebug("Scanning Scene"); - - logger.LogDebug("Clearing dynamically tracked objects"); - ClearDynamicTrackedObjects(); - - logger.LogDebug("Scanning static types"); - ScanStaticTypes(); - - logger.LogDebug("Scanning dynamic types"); - ScanDynamicTypes(); - } - catch (Exception e) - { - logger.LogException(e, $"Fatal exception within minimap"); - - // intentionally consume the error, again we never want to throw an exception in - // a global event delegate (unless we're the last event, but that is never garunteed) - } - } - - private ITrackedObjectScanner CreateChestScanner(Func activeChecker) - { - bool TryGetPurchaseToken(T value, out string out_token) where T : MonoBehaviour - { - var token = value?.GetComponent()?.contextToken; - - // mods that implement ChestBehaviour, may not also PurchaseInteraction - if (token is null) + if (zoomDirty) { - logger.LogDebug($"No {nameof(PurchaseInteraction)} component on {typeof(T).Name}. GameObject.name = {value.gameObject.name}"); + zoom = Settings.MinimapScale; + zoomDirty = false; } + if (!Run.instance) + return; - out_token = token; - - return token != null; - } + HandleInput(); + if (!visible) + return; - // given any chest object this will retur true if it should be displayed as a chest - bool ChestSelector(ChestBehavior chest) - { - if (TryGetPurchaseToken(chest, out string token)) + // Keep scanning even without a HUD so diagnostics can tell the two failures apart. + if (now >= nextHudAttempt) + EnsureHud(now); + if (hud && cameraRig && cameraRig.sceneCam) + UpdateViewer(); + if (now >= nextSceneScan) { - return token.Contains("CHEST") && token != "LUNAR_CHEST_CONTEXT" && token.Contains("STEALTH") == false; + tracker.RefreshSceneCandidates(); + sceneScansThisStage++; + // Vanilla objects all register with the game, so the full search is only a + // safety net: a few passes while the stage populates, then only if it ever + // turned up something unregistered (typically from another mod). + nextSceneScan = sceneScansThisStage < 3 || tracker.UnregisteredCandidates > 0 + ? now + Settings.SceneScanInterval : float.PositiveInfinity; + nextInteractionScan = 0f; + } + if (now >= nextInteractionScan) + { + nextInteractionScan = now + Settings.InteractionScanInterval; + tracker.RefreshInteractables(); + bounds.Refresh(sceneInfo, tracker, viewerPosition); + } + if (now >= nextBodyScan) + { + nextBodyScan = now + Settings.BodyScanInterval; + tracker.RefreshBodies(viewedObject); + tracker.RefreshPickups(); + } + if (now >= nextStateRefresh) + { + nextStateRefresh = now + Settings.StateRefreshInterval; + tracker.UpdateStates(); } - - return false; } - - bool LunarPodSelector(ChestBehavior chest) + catch (Exception exception) + { + HandleFailure(exception, now); + } + finally { - if (TryGetPurchaseToken(chest, out string token)) + if (Settings.DebugLogging && now >= nextDiagnostics) { - return token == "LUNAR_CHEST_CONTEXT"; + nextDiagnostics = now + 5f; + ReportDiagnostics(); } - - return false; } - - return new MultiKindScanner(false, - new MonoBehaviorScanner(logger), new MonoBehaviourSorter( - new ISorter[] { - new DefaultSorter(InteractableKind.Chest, x => x.gameObject, ChestSelector, activeChecker), - new DefaultSorter(InteractableKind.LunarPod, x => x.gameObject, LunarPodSelector, activeChecker), - } - ), TrackedDimensions, SpriteManager, () => GetPlayerPosition().y); - } - - private ITrackedObjectScanner CreateGenericInteractionScanner() - { - bool PortalSelector(GenericInteraction interaction) => interaction?.contextToken?.Contains("PORTAL") ?? false; - - bool DefaultSelector(GenericInteraction interaction) => true; - - bool GenericActiveChecker(GenericInteraction interaction) => interaction?.isActiveAndEnabled ?? true; - - GameObject DefaultConverter(T value) where T : MonoBehaviour => value?.gameObject; - - return new MultiKindScanner(false, - new MonoBehaviorScanner(logger), new MonoBehaviourSorter( - new ISorter[] { - new DefaultSorter(InteractableKind.Portal, DefaultConverter, PortalSelector, GenericActiveChecker), - new DefaultSorter(InteractableKind.Special, DefaultConverter, DefaultSelector, GenericActiveChecker) - } - ), TrackedDimensions, SpriteManager, () => GetPlayerPosition().y); - } - - private ITrackedObjectScanner CreatePurchaseInteractionScanner() - { - bool FanSelector(PurchaseInteraction interaction) => interaction?.contextToken == "FAN_CONTEXT"; - - bool PrinterSelector(PurchaseInteraction interaction) => interaction?.contextToken?.Contains("DUPLICATOR") ?? false; - - bool ShopSelector(PurchaseInteraction interaction) => interaction?.contextToken?.Contains("TERMINAL") ?? false; - - bool EquipmentSelector(PurchaseInteraction interaction) => interaction?.contextToken?.Contains("EQUIPMENTBARREL") ?? false; - - bool GoldShoresSelector(PurchaseInteraction interaction) => interaction?.contextToken?.Contains("GOLDSHORE") ?? false; - - bool GoldShoresBeaconSelector(PurchaseInteraction interaction) => interaction?.contextToken?.Contains("TOTEM") ?? false; - - bool InteractionActiveChecker(PurchaseInteraction interaction) => interaction?.available ?? true; - - bool LunarShopSelector(PurchaseInteraction interaction) => interaction?.GetComponent()?.portalType == PortalStatueBehavior.PortalType.Shop; - - GameObject DefaultConverter(T value) where T : MonoBehaviour => value?.gameObject; - - return new MultiKindScanner(false, - new MonoBehaviorScanner(logger), new MonoBehaviourSorter( - new ISorter[] { - new DefaultSorter(InteractableKind.Printer, DefaultConverter, PrinterSelector, InteractionActiveChecker), - new DefaultSorter(InteractableKind.Special, DefaultConverter, FanSelector, InteractionActiveChecker), - new DefaultSorter(InteractableKind.Shop, DefaultConverter, ShopSelector, InteractionActiveChecker), - new DefaultSorter(InteractableKind.Equipment, DefaultConverter, EquipmentSelector, InteractionActiveChecker), - new DefaultSorter(InteractableKind.Portal, DefaultConverter, GoldShoresSelector, InteractionActiveChecker), - new DefaultSorter(InteractableKind.Totem, DefaultConverter, GoldShoresBeaconSelector, GoldShoresSelector), - new DefaultSorter(InteractableKind.NewtAltar, DefaultConverter, LunarShopSelector, InteractionActiveChecker) - } - ), TrackedDimensions, SpriteManager, () => GetPlayerPosition().y); } - private void CreateStaticScanners() + private void HandleInput() { - GameObject DefaultConverter(T value) where T : MonoBehaviour => value?.gameObject; - - bool DefaultActiveChecker(T value) where T : MonoBehaviour + if (ChatBox.IsActive) + return; + if (Input.GetKeyDown(Settings.MinimapKey)) { - if (value is PurchaseInteraction isInteraction) - { - return isInteraction.available; - } - - PurchaseInteraction interaction = value?.GetComponent(); - - if (interaction != null) - { - return interaction.available; - } - - // default always active; - return true; + visible = !visible; + minimap.SetVisible(visible); + logger.LogDebug($"[visibility] visible={visible} key={Settings.MinimapKey}."); + if (visible) + nextHudAttempt = nextInteractionScan = nextBodyScan = nextStateRefresh = 0f; } + if (!visible) + return; - ITrackedObjectScanner SimpleScanner(InteractableKind kind, Func activeChecker = null, Func selector = null, Func converter = null) where T : MonoBehaviour + float nextZoom = zoom; + if (Input.GetKeyDown(Settings.MinimapIncreaseScaleKey)) + nextZoom *= 1.1f; + else if (Input.GetKeyDown(Settings.MinimapDecreaseScaleKey)) + nextZoom /= 1.1f; + nextZoom = Numeric.Clamp(nextZoom, 0.1f, 20f, 1f); + if (Math.Abs(nextZoom - zoom) > 0.00001f) { - return new SingleKindScanner( - kind: kind, - dynamic: false, - scanner: new MonoBehaviorScanner(logger), - range: TrackedDimensions, - spriteManager: SpriteManager, - playerHeightRetriever: () => GetPlayerPosition().y, - converter: converter ?? DefaultConverter, - activeChecker: activeChecker ?? DefaultActiveChecker, - selector: selector - ); + zoom = nextZoom; + Settings.SetMinimapScale(zoom); } - - staticScanners = new ITrackedObjectScanner[] { - CreateChestScanner(DefaultActiveChecker), - CreatePurchaseInteractionScanner(), - CreateGenericInteractionScanner(), - SimpleScanner(InteractableKind.Chest), - SimpleScanner(InteractableKind.Shrine), - SimpleScanner(InteractableKind.Shrine), - SimpleScanner(InteractableKind.Shrine), - SimpleScanner(InteractableKind.Shrine), - SimpleScanner(InteractableKind.Shrine), - SimpleScanner(InteractableKind.Shrine), - SimpleScanner(InteractableKind.Utility), - SimpleScanner(InteractableKind.Teleporter, activeChecker: (teleporter) => teleporter.activationState != TeleporterInteraction.ActivationState.Charged), - SimpleScanner(InteractableKind.Drone), - SimpleScanner(InteractableKind.Barrel, activeChecker: barrel => !barrel.Networkopened), - }; } - private ITrackedObjectScanner CreateAliveEntityScanner() + private bool EnsureHud(float now) { - bool EnemyMonsterSelector(TeamComponent team) => team?.teamIndex == TeamIndex.Monster; - - bool EnemyLunarSelector(TeamComponent team) => team?.teamIndex == TeamIndex.Lunar; - - bool EnemyVoidSelector(TeamComponent team) => team?.teamIndex == TeamIndex.Void; - - bool NeutralSelector(TeamComponent team) => team?.teamIndex == TeamIndex.Neutral; - - bool MinionSelector(TeamComponent team) + if (hud && hud.isActiveAndEnabled && cameraRig && cameraRig == hud.cameraRigController && cameraRig.sceneCam && minimap.Created) { - var isAlly = team?.teamIndex == TeamIndex.Player; - var isPlayer = team?.GetComponent()?.isPlayerControlled; - if (isPlayer is null) + if (now >= nextLayoutCheck) { - return isAlly; + nextLayoutCheck = now + 0.5f; + GameObject nextParent = FindMapParent(out bool useLayout); + if (nextParent && minimap.SetHudParent(nextParent, useLayout)) + logger.LogDebug($"[hud.layout] Adopted late run-info layout: parent={nextParent.name} objectiveLayout={useLayout}."); } - return isAlly && (isPlayer == false); + return true; } - bool PlayerSelector(TeamComponent team) + tracker.DetachIcons(); + minimap.Destroy(); + hud = null; + cameraRig = null; + nextHudAttempt = now + 0.5f; + var huds = HUD.readOnlyInstanceList; + for (int i = 0; i < huds.Count; i++) { - var isOwner = team?.GetComponent()?.hasAuthority; - var isPlayer = team?.GetComponent()?.isPlayerControlled; - if (isOwner is null || isPlayer is null) - { - return false; - } - return (isOwner == false) && (isPlayer == true); - } + HUD candidate = huds[i]; + if (!candidate || !candidate.isActiveAndEnabled || candidate.localUserViewer == null + || !candidate.cameraRigController || !candidate.cameraRigController.sceneCam) + continue; - GameObject DefaultConverter(T value) where T : MonoBehaviour => value?.gameObject; + hud = candidate; + cameraRig = candidate.cameraRigController; + break; + } + if (!hud) + return false; - return new MultiKindScanner(true, - new MonoBehaviorScanner(logger), new MonoBehaviourSorter( - new ISorter[] - { - new DefaultSorter(InteractableKind.EnemyMonster, DefaultConverter, EnemyMonsterSelector, x => true), - new DefaultSorter(InteractableKind.EnemyLunar, DefaultConverter, EnemyLunarSelector, x => true), - new DefaultSorter(InteractableKind.EnemyVoid, DefaultConverter, EnemyVoidSelector, x => true), - new DefaultSorter(InteractableKind.Minion, DefaultConverter, MinionSelector, x => true), - new DefaultSorter(InteractableKind.Player, DefaultConverter, PlayerSelector, x => true), - new DefaultSorter(InteractableKind.Neutral, DefaultConverter, NeutralSelector, x => x?.gameObject.activeSelf ?? true), - } - ), TrackedDimensions, SpriteManager, () => GetPlayerPosition().y); + GameObject parent = FindMapParent(out bool useObjectiveLayout); + if (!parent || !parent.GetComponentInParent()) + return false; + sprites.PreloadConfiguredIcons(); + minimap.CreateMinimap(sprites, parent, useObjectiveLayout); + spriteRevision = -1; + nextHudAttempt = 0f; + nextLayoutCheck = now + 0.5f; + logger.LogDebug($"[hud] parent={parent.name} objectiveLayout={useObjectiveLayout} {minimap.DescribeUI()}"); + return minimap.Created; } - private Vector3 GetPlayerPosition() + private GameObject FindMapParent(out bool useObjectiveLayout) { - if (cameraRig == null) + ObjectivePanelController objectives = hud.GetComponentInChildren(true); + if (objectives && objectives.transform.IsChildOf(hud.transform)) { - cameraRig = Camera.main.transform.parent.GetComponent(); - - if (cameraRig == null) + // RightInfoBar (the objective panel's parent) is the column that lays everything out. + Transform column = objectives.transform.parent; + if (column && column.GetComponent() is VerticalLayoutGroup layout && layout.enabled) + { + useObjectiveLayout = true; + return column.gameObject; + } + if (objectives.GetComponent() is VerticalLayoutGroup innerLayout && innerLayout.enabled) { - logger.LogError("Failed to retrieve camera rig in scene to retrieve camera position"); + useObjectiveLayout = true; + return objectives.gameObject; } } - - return cameraRig.target?.transform?.position ?? Camera.main.transform.position; + useObjectiveLayout = false; + return hud.mainContainer; } - private void CreateDynamicScanners() + private void UpdateViewer() { - dynamicScanners = new ITrackedObjectScanner[] + GameObject target = cameraRig.target ? cameraRig.target : hud.targetBodyObject; + if (target != viewedObject) { - CreateAliveEntityScanner(), - new SingleKindScanner( - kind: InteractableKind.Item, - dynamic: true, - scanner: new MonoBehaviorScanner(logger), - range: TrackedDimensions, - spriteManager: SpriteManager, - playerHeightRetriever: () => GetPlayerPosition().y, - converter: x => x.gameObject, - activeChecker: x => true - ), - CreateGenericInteractionScanner() - }; + viewedObject = target; + viewedTransform = target ? target.transform : null; + tracker.ViewerInteractor = target ? target.GetComponent() : null; + nextBodyScan = nextStateRefresh = 0f; + } + viewerPosition = viewedTransform ? viewedTransform.position : cameraRig.sceneCam.transform.position; + minimap.SetPlayerVisible(viewedTransform && Settings.GetSetting(InteractableKind.Player).Enabled); } - private void ScanStaticTypes() + private void LateUpdate() { - // if we have alreadys scanned don't scan again until we die or the scene changes (this method has sever performance implications) - if (ScannedStaticObjects) - { + if (!initialized) return; + renderer.BeginFrame(); + renderer.Diagnostics = Settings.DebugLogging; + if (!Run.instance || !visible || !minimap.Created || !cameraRig || !cameraRig.sceneCam || !hud) + return; + try + { + UpdateViewer(); + minimap.SetViewRotation(cameraRig.sceneCam.transform.rotation); + if (spriteRevision != sprites.Revision) + { + minimap.RefreshPlayerSprite(sprites); + spriteRevision = sprites.Revision; + } + lastScale = MapProjection.GetScale(bounds.Range, Settings.MinimapSize, zoom, Settings.WorldRadius); + renderer.Render(tracker.Bodies, viewerPosition, lastScale); + renderer.Render(tracker.Interactables, viewerPosition, lastScale); + renderer.Render(tracker.Pickups, viewerPosition, lastScale); } - - for (int i = 0; i < staticScanners.Length; i++) + catch (Exception exception) { - staticScanners[i].ScanScene(TrackedObjects); + HandleFailure(exception, Time.unscaledTime); } - - ScannedStaticObjects = true; } - private void ScanDynamicTypes() + private void ReportDiagnostics() { - for (int i = 0; i < dynamicScanners.Length; i++) - { - dynamicScanners[i].ScanScene(TrackedObjects); - } + logger.LogDebug($"[state] scene={SceneManager.GetActiveScene().name} run={(bool)Run.instance} sceneInfo={(bool)SceneInfo.instance} stage={(bool)Stage.instance} visible={visible} hud={(bool)hud} camera={(bool)cameraRig} viewer={(bool)viewedTransform} center={viewerPosition} radius={Settings.WorldRadius} zoom={zoom:F2} scale={lastScale:F5} bounds={bounds.Source}."); + logger.LogDebug($"[scan.scene] calls={tracker.SceneScans} components={tracker.SceneComponents} unregistered={tracker.UnregisteredCandidates} milliseconds={tracker.SceneScanMilliseconds:F2} next={(float.IsPositiveInfinity(nextSceneScan) ? "never" : (nextSceneScan - Time.unscaledTime).ToString("F1") + "s")}; [scan.interactables] {tracker.InteractionScan}; [scan.bodies] {tracker.BodyScan}; [scan.pickups] {tracker.PickupScan}."); + logger.LogDebug($"[track] interactables={tracker.Interactables.Count} bodies={tracker.Bodies.Count} pickups={tracker.Pickups.Count} activeStates={tracker.ActiveStates} scannerHidden={tracker.HiddenStates} repeatedErrors={tracker.RepeatedErrors}; [render] {renderer}."); + logger.LogDebug($"[ui] {minimap.DescribeUI()}; [sprites] {sprites.DescribeRequests()}"); } - private void ClearDynamicTrackedObjects() + private void ResetScene() { - if (ScannedStaticObjects is false) - { - return; - } + tracker?.Clear(); + renderer?.Reset(); + minimap?.Destroy(); + bounds.Clear(); + hud = null; + cameraRig = null; + viewedObject = null; + viewedTransform = null; + sceneInfo = null; + currentRun = null; + viewerPosition = Vector3.zero; + lastScale = 0f; + nextHudAttempt = nextInteractionScan = nextBodyScan = nextStateRefresh = nextSceneScan = 0f; + sceneScansThisStage = 0; + } - for (int i = 0; i < TrackedObjects.Count; i++) + private void HandleFailure(Exception exception, float now) + { + if (now >= nextErrorLog) { - var obj = TrackedObjects[i]; - - if (obj.DynamicObject) - { - obj.Destroy(); - TrackedObjects.RemoveAt(i); - } + nextErrorLog = now + 10f; + logger.LogException(exception, "MiniMap update failed; retrying after a short delay."); } + ResetScene(); + sceneInfo = SceneInfo.instance; + currentRun = Run.instance; + nextHudAttempt = now + 2f; } } } diff --git a/MiniMapMod/Minimap.cs b/MiniMapMod/Minimap.cs index 8b8e57c..34a690f 100644 --- a/MiniMapMod/Minimap.cs +++ b/MiniMapMod/Minimap.cs @@ -1,9 +1,5 @@ -using MiniMapMod; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using UnityEngine; using UnityEngine.UI; @@ -11,179 +7,322 @@ namespace MiniMapLibrary { public partial class Minimap { - private readonly MiniMapLibrary.ILogger Logger; + private const int MaximumPooledIcons = 256; + private readonly ILogger logger; + private readonly Dictionary> iconPool = new(); + private RectTransform containerTransform; + private RectTransform bodyLayer, interactableLayer, pickupLayer; + private Image playerImage; + private Transform poolRoot; + private int pooledCount; + private float yaw = float.NaN; + private bool rotatesContainer; + private RectTransform viewportTransform; + private Image backgroundImage; + private bool usesObjectiveLayout; + public int IconsCreated { get; private set; } + public int IconsReused { get; private set; } + public int IconFailures { get; private set; } + public bool PlayerVisible => playerImage && playerImage.gameObject.activeInHierarchy && playerImage.color.a > 0f && playerImage.sprite; - public Minimap(ILogger logger) - { - Logger = logger; - } -// ignore naming violation, name choses to match unity conventions -#pragma warning disable IDE1006 public static GameObject gameObject { get; private set; } -#pragma warning restore IDE1006 - public static GameObject Container { get; private set; } + public bool Created => gameObject && Container && containerTransform; + public int RotationVersion { get; private set; } + public float RotationSine { get; private set; } + public float RotationCosine { get; private set; } = 1f; + public Quaternion IconRotation { get; private set; } = Quaternion.identity; - private RectTransform ContainerTransform; + public Minimap(ILogger logger) => this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - public bool Created { get; private set; } = false; + public void CreateMinimap(ISpriteManager spriteManager, GameObject Parent) => CreateMinimap(spriteManager, Parent, true); - public void CreateMinimap(ISpriteManager spriteManager, GameObject Parent) + public void CreateMinimap(ISpriteManager spriteManager, GameObject parent, bool useObjectiveLayout) { if (Created) - { return; + if (!parent) + throw new ArgumentNullException(nameof(parent)); + + // Live inside the HUD's own canvas; a separate canvas on the Default layer gets culled by the UI camera. + gameObject = new GameObject("MiniMap", typeof(RectTransform), typeof(LayoutElement)); + var slot = (RectTransform)gameObject.transform; + SetHudParent(parent, useObjectiveLayout); + + var panel = new GameObject("Viewport", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(RectMask2D)); + var panelTransform = (RectTransform)panel.transform; + viewportTransform = panelTransform; + Helpers.Transforms.SetParent(panelTransform, slot); + panelTransform.anchorMin = panelTransform.anchorMax = panelTransform.pivot = Vector2.one; + panelTransform.sizeDelta = new Vector2(Settings.ViewfinderSize.Width, Settings.ViewfinderSize.Height); + panelTransform.anchoredPosition = Settings.PositionOffset; + Image background = panel.GetComponent(); + backgroundImage = background; + background.color = Settings.BackgroundColor; + background.raycastTarget = false; + + Container = new GameObject("Icons", typeof(RectTransform)); + containerTransform = (RectTransform)Container.transform; + Helpers.Transforms.SetParent(containerTransform, panelTransform); + containerTransform.anchorMin = containerTransform.anchorMax = new Vector2(0.5f, 0.5f); + containerTransform.anchoredPosition = Vector2.zero; + containerTransform.sizeDelta = panelTransform.sizeDelta; + bodyLayer = CreateLayer("Characters"); + interactableLayer = CreateLayer("Interactables"); + // Drops usually land next to the chest they came from; keep them on top. + pickupLayer = CreateLayer("Pickups"); + + var inactivePool = new GameObject("IconPool", typeof(RectTransform)); + poolRoot = inactivePool.transform; + Helpers.Transforms.SetParent(poolRoot, slot); + inactivePool.SetActive(false); + + InteractibleSetting playerSetting = Settings.GetSetting(InteractableKind.Player); + if (playerSetting.Enabled) + { + GameObject player = Helpers.Sprites.CreateIcon(spriteManager.GetSprite(InteractableKind.Player), + playerSetting.Dimensions.Width, playerSetting.Dimensions.Height, playerSetting.ActiveColor); + player.name = "ViewedCharacter"; + Helpers.Transforms.SetParent(player.transform, panelTransform); + Center((RectTransform)player.transform); + playerImage = player.GetComponent(); } - - // set created flag - Created = true; - - gameObject = CreateMask(); - - SetParent(gameObject, Parent); - - // make sure it's the first thing within the objective box - gameObject.transform.SetAsFirstSibling(); - - CreateIconContainer(); - - CreatePlayerIcon(spriteManager); + yaw = float.NaN; + SetRotation(Quaternion.identity); + logger.LogDebug($"Minimap HUD created: layer={gameObject.layer}, size={panelTransform.sizeDelta}, backgroundAlpha={background.color.a:F2}, playerSprite={(playerImage && playerImage.sprite)}, inheritedCanvas={(gameObject.GetComponentInParent() != null)}."); } - public void Destroy() + // The run-info panel shows up after the main HUD, so the map moves there once it exists. + public bool SetHudParent(GameObject parent, bool useObjectiveLayout) { - if (Created == false) + if (!gameObject || !parent) + return false; + var slot = (RectTransform)gameObject.transform; + if (slot.parent == parent.transform && usesObjectiveLayout == useObjectiveLayout) + return false; + + Helpers.Transforms.SetParent(slot, parent.transform); + usesObjectiveLayout = useObjectiveLayout; + slot.anchorMin = slot.anchorMax = slot.pivot = Vector2.one; + // Reserve room for a negative Y offset too, or the map overlaps the objectives. + slot.sizeDelta = new Vector2(Settings.ViewfinderSize.Width, + Settings.ViewfinderSize.Height + Math.Max(16f, -Settings.PositionOffset.y + 8f)); + LayoutElement layout = gameObject.GetComponent(); + layout.ignoreLayout = !useObjectiveLayout; + layout.minHeight = layout.preferredHeight = slot.sizeDelta.y; + layout.preferredWidth = Settings.ViewfinderSize.Width; + layout.flexibleWidth = layout.flexibleHeight = 0f; + slot.anchoredPosition = useObjectiveLayout ? Vector2.zero : new Vector2(-24f, -160f); + if (useObjectiveLayout) { - return; + slot.SetAsFirstSibling(); + if (parent.transform is RectTransform rect) + LayoutRebuilder.MarkLayoutForRebuild(rect); } - - Logger.LogDebug("Destroying minimap gameobject"); - GameObject.Destroy(gameObject); - - gameObject = null; - Container = null; - ContainerTransform = null; - - Created = false; + return true; } - public void DestroyIcons() + private RectTransform CreateLayer(string name) { - foreach (Transform child in Container.transform) - { - GameObject.Destroy(child.gameObject); - } + var layer = new GameObject(name, typeof(RectTransform)); + var transform = (RectTransform)layer.transform; + Helpers.Transforms.SetParent(transform, containerTransform); + Center(transform); + transform.sizeDelta = containerTransform.sizeDelta; + return transform; } - public void SetRotation(Quaternion rotation) + private static void Center(RectTransform transform) { - var euler = rotation.eulerAngles; + transform.anchorMin = transform.anchorMax = transform.pivot = new Vector2(0.5f, 0.5f); + transform.anchoredPosition = Vector2.zero; + } - ContainerTransform.localRotation = Quaternion.Euler(0, 0, euler.y); + public void RefreshPlayerSprite(ISpriteManager sprites) + { + if (playerImage) + playerImage.sprite = sprites.GetSprite(InteractableKind.Player); } - public RectTransform CreateIcon(InteractableKind type, Vector3 minimapPosition, ISpriteManager spriteManager) + public void SetPlayerVisible(bool visible) { - Sprite sprite; + if (playerImage && playerImage.gameObject.activeSelf != visible) + playerImage.gameObject.SetActive(visible); + } - try - { - sprite = spriteManager.GetSprite(type); - } - catch (MissingComponentException e) + public string DescribeUI() + { + if (!Created) + return "created=false"; + Canvas canvas = gameObject.GetComponentInParent(); + Canvas rootCanvas = canvas ? canvas.rootCanvas : null; + Camera camera = rootCanvas ? rootCanvas.worldCamera : null; + float parentAlpha = 1f; + foreach (CanvasGroup group in gameObject.GetComponentsInParent(true)) { - Logger.LogError($"Failed to get sprite for type {type}"); - Logger.LogError(e.Message); - - return null; + parentAlpha *= group.alpha; + if (group.ignoreParentGroups) break; } - - GameObject icon = CreateIcon(type, sprite); - - var transform = icon.GetComponent(); - - transform.localPosition = minimapPosition; - - Helpers.Transforms.SetParent(transform, ContainerTransform); - - return transform; + return $"created=true hierarchyActive={gameObject.activeInHierarchy} layer={gameObject.layer}" + + $" parent={gameObject.transform.parent.name} objectiveLayout={usesObjectiveLayout}" + + $" canvas={(canvas ? canvas.name : "missing")} canvasEnabled={(canvas && canvas.isActiveAndEnabled)}" + + $" rootCanvas={(rootCanvas ? rootCanvas.name : "missing")} renderMode={(rootCanvas ? rootCanvas.renderMode.ToString() : "none")}" + + $" camera={(camera ? camera.name : "none")} mask={(camera ? camera.cullingMask.ToString("X8") : "none")}" + + $" layerInMask={(!camera || (camera.cullingMask & (1 << gameObject.layer)) != 0)}" + + $" rect={viewportTransform.rect.size} scale={gameObject.transform.lossyScale}" + + $" backgroundAlpha={backgroundImage.color.a:F2} parentAlpha={parentAlpha:F2} backgroundCulled={backgroundImage.canvasRenderer.cull}" + + $" player={PlayerVisible} createdIcons={IconsCreated} reusedIcons={IconsReused} pooledIcons={pooledCount} iconFailures={IconFailures}"; } - private void CreateIconContainer() + public void SetVisible(bool visible) { - GameObject container = Create(obj => - { - obj.name = "icon_container"; - - ContainerTransform = obj.AddComponent(); + if (gameObject && gameObject.activeSelf != visible) + gameObject.SetActive(visible); + } - ContainerTransform.sizeDelta = new(Settings.MinimapSize.Width, Settings.MinimapSize.Height); - }); + public void SetRotation(Quaternion rotation) => UpdateRotation(rotation, true); - SetParent(container, gameObject); + // Rotation is applied to projected positions instead, so icons don't need counter-rotating. + public void SetViewRotation(Quaternion rotation) => UpdateRotation(rotation, false); - Container = container; + private void UpdateRotation(Quaternion rotation, bool rotateContainer) + { + if (!containerTransform) + return; + float nextYaw = Settings.RotateWithCamera ? rotation.eulerAngles.y : 0f; + if (!Numeric.IsFinite(nextYaw)) + nextYaw = 0f; + if (rotatesContainer == rotateContainer && Numeric.IsFinite(yaw) && Mathf.Abs(Mathf.DeltaAngle(nextYaw, yaw)) < 0.01f) + return; + yaw = nextYaw; + if (rotateContainer || rotatesContainer) + containerTransform.localRotation = rotateContainer ? Quaternion.Euler(0f, 0f, yaw) : Quaternion.identity; + rotatesContainer = rotateContainer; + IconRotation = rotateContainer ? Quaternion.Euler(0f, 0f, -yaw) : Quaternion.identity; + float radians = yaw * Mathf.Deg2Rad; + RotationSine = Mathf.Sin(radians); + RotationCosine = Mathf.Cos(radians); + RotationVersion++; } - private void CreatePlayerIcon(ISpriteManager spriteManager) + public RectTransform CreateIcon(InteractableKind type, Vector3 minimapPosition, ISpriteManager spriteManager) { - GameObject playerIcon = CreateIcon(InteractableKind.Player, spriteManager.GetSprite(InteractableKind.Player)); - - playerIcon.GetComponent().color = Settings.PlayerIconColor; - - SetParent(playerIcon, gameObject); + try { return CreateIconCore(type, minimapPosition, spriteManager); } + catch + { + IconFailures++; + throw; + } } - private GameObject CreateIcon(InteractableKind type, Sprite iconTexture) + private RectTransform CreateIconCore(InteractableKind type, Vector3 minimapPosition, ISpriteManager spriteManager) { - InteractibleSetting settings = Settings.GetSetting(type); - - GameObject icon = Helpers.Sprites.CreateIcon(iconTexture, settings.Dimensions.Width, settings.Dimensions.Height, settings.ActiveColor); - - icon.name = type.ToString(); + InteractableKind kind = type; + Vector3 position = minimapPosition; + if (!Created) + { + IconFailures++; + return null; + } + RectTransform icon = null; + if (iconPool.TryGetValue(kind, out Stack pool)) + { + while (pool.Count > 0 && !icon) + { + icon = pool.Pop(); + pooledCount--; + } + } + if (!icon) + { + InteractibleSetting setting = Settings.GetSetting(kind); + GameObject newIcon = Helpers.Sprites.CreateIcon(spriteManager.GetSprite(kind), + setting.Dimensions.Width, setting.Dimensions.Height, setting.ActiveColor); + newIcon.name = kind.ToString(); + icon = (RectTransform)newIcon.transform; + IconsCreated++; + } + else + IconsReused++; + + const InteractableKind characters = InteractableKind.EnemyMonster | InteractableKind.EnemyLunar + | InteractableKind.EnemyVoid | InteractableKind.EnemyEndOfCycle | InteractableKind.Player + | InteractableKind.Minion | InteractableKind.Neutral; + Helpers.Transforms.SetParent(icon, kind == InteractableKind.Pickup ? pickupLayer + : (kind & characters) != 0 ? bodyLayer : interactableLayer); + Center(icon); + icon.localPosition = position; + icon.localRotation = IconRotation; + icon.gameObject.SetActive(true); return icon; } - private GameObject CreateMask() + public void ReleaseIcon(InteractableKind kind, RectTransform icon) { - return Create(mask => + if (!icon) + return; + icon.gameObject.SetActive(false); + if (!Created || !poolRoot || pooledCount >= MaximumPooledIcons) { - mask.name = "Minimap"; - - RectTransform newTransform = mask.AddComponent(); - - newTransform.sizeDelta = new Vector2(Settings.ViewfinderSize.Width, Settings.ViewfinderSize.Height); - - mask.AddComponent(); - - mask.AddComponent(); - - var maskComponent = mask.AddComponent(); - - maskComponent.showMaskGraphic = false; - - LayoutElement element = mask.AddComponent(); + UnityEngine.Object.Destroy(icon.gameObject); + return; + } - element.minHeight = Settings.MinimapSize.Height; - element.flexibleHeight = 0; - }); + icon.SetParent(poolRoot, false); + if (!iconPool.TryGetValue(kind, out Stack pool)) + { + pool = new Stack(); + iconPool.Add(kind, pool); + } + pool.Push(icon); + pooledCount++; } - private GameObject SetParent(GameObject child, GameObject parent) + public void DestroyIcons() { - Helpers.Transforms.SetParent(child.transform, parent.transform); - - return child; + if (bodyLayer) + DestroyChildren(bodyLayer); + if (interactableLayer) + DestroyChildren(interactableLayer); + if (pickupLayer) + DestroyChildren(pickupLayer); + if (poolRoot) + DestroyChildren(poolRoot); + iconPool.Clear(); + pooledCount = 0; } - private GameObject Create(Action Expression) + private static void DestroyChildren(Transform parent) { - GameObject newObj = new(); - - Expression(newObj); + for (int i = parent.childCount - 1; i >= 0; i--) + { + GameObject child = parent.GetChild(i).gameObject; + child.SetActive(false); + UnityEngine.Object.Destroy(child); + } + } - return newObj; + public void Destroy() + { + if (gameObject) + { + gameObject.SetActive(false); + UnityEngine.Object.Destroy(gameObject); + } + gameObject = null; + Container = null; + containerTransform = null; + bodyLayer = interactableLayer = pickupLayer = null; + playerImage = null; + poolRoot = null; + iconPool.Clear(); + pooledCount = 0; + yaw = float.NaN; + rotatesContainer = false; + viewportTransform = null; + backgroundImage = null; + IconsCreated = IconsReused = IconFailures = 0; } } } diff --git a/MiniMapMod/MinimapExtensions.cs b/MiniMapMod/MinimapExtensions.cs index e8158c9..7d5fb56 100644 --- a/MiniMapMod/MinimapExtensions.cs +++ b/MiniMapMod/MinimapExtensions.cs @@ -1,7 +1,5 @@ -using MiniMapLibrary; using System; -using System.Collections.Generic; -using System.Text; +using MiniMapLibrary; using UnityEngine; #nullable enable @@ -9,41 +7,13 @@ namespace MiniMapMod { public static class MinimapExtensions { - /// - /// Converts the provided from world posiion to a 2D represetation defined by - /// - /// - /// - /// public static Vector2 ToMinimapPosition(this Vector3 position, Range3D dimensions) { - // may consider switching to integer math for whole library for performance - // reasons but for now 32bit floats are fine - float x = position.x; - float z = position.z; - - // the offset represents the vertical and horizontal "scrolling" of the map - x += dimensions.X.Offset; - z += dimensions.Z.Offset; - - // ensure the dimensions are always between 0 and 1 - x /= dimensions.X.Difference; - z /= dimensions.Z.Difference; - - return new(x * Settings.MinimapSize.Width, z * Settings.MinimapSize.Height); + float scale = MapProjection.GetScale(dimensions, Settings.MinimapSize, 1f); + return new Vector2((position.x + dimensions.X.Offset) * scale, (position.z + dimensions.Z.Offset) * scale); } - /// - /// Checks the given expression, if it returns true, value passed in is returned allowing chaining of check - /// other wise returns null, use with colaescing operators to short circuit - /// - /// - /// - /// - /// - public static T? Check(this T value, Func expression) where T : class - { - return expression?.Invoke(value) is null or false ? (T?)null : value; - } + public static T? Check(this T value, Func expression) where T : class => + expression?.Invoke(value) == true ? value : null; } } diff --git a/MiniMapMod/PurchaseTokenClassifier.cs b/MiniMapMod/PurchaseTokenClassifier.cs new file mode 100644 index 0000000..08b0624 --- /dev/null +++ b/MiniMapMod/PurchaseTokenClassifier.cs @@ -0,0 +1,51 @@ +using System; +using MiniMapLibrary; + +namespace MiniMapMod +{ + // Context tokens checked against the game's English language files. + internal static class PurchaseTokenClassifier + { + public static InteractableKind Classify(string token, bool isShrine = false, bool isDrone = false) + { + token ??= string.Empty; + if (Contains(token, "STEALTH") && Contains(token, "CHEST")) + return InteractableKind.StealthChest; + if (Contains(token, "TIMEDCHEST")) + return InteractableKind.TimedChest; + if (token == "LUNAR_CHEST_CONTEXT") + return InteractableKind.LunarPod; + if (token == "SHRINE_GOLDSHORES_CONTEXT") + return InteractableKind.Portal; + if (token == "GOLDTOTEM_CONTEXT") + return InteractableKind.Totem; + if (Contains(token, "DUPLICATOR")) + return InteractableKind.Printer; + if (Contains(token, "SCRAPPER") || token == "SHRINE_CLEANSE_CONTEXT" + || token == "DRONE_ASSEMBLY_CONTEXT" || token == "DRONE_COMBINER_CONTEXT") + return InteractableKind.Utility; + if (Contains(token, "EQUIPMENTBARREL")) + return InteractableKind.Equipment; + if (Contains(token, "TERMINAL")) + return InteractableKind.Shop; + if (Contains(token, "CHEST") || token == "HOLLOWROULETTE_CONTEXT") + return InteractableKind.Chest; + if (Contains(token, "PORTAL") || token == "TRIALGONG_CONTEXT") + return InteractableKind.Portal; + if (isShrine || Contains(token, "SHRINE") || token.StartsWith("HALLOWEDCAULDRON_", StringComparison.Ordinal)) + return InteractableKind.Shrine; + if (isDrone) + return InteractableKind.Drone; + return InteractableKind.Special; + } + + public static bool TimedChestAvailable(bool purchased, float lockTime, float stopwatch) + { + // The game truncates the remaining time to an int before comparing. + return !purchased && Numeric.IsFinite(lockTime) && Numeric.IsFinite(stopwatch) + && (int)(lockTime - stopwatch) > 0; + } + + private static bool Contains(string token, string part) => token.IndexOf(part, StringComparison.Ordinal) >= 0; + } +} diff --git a/MiniMapMod/ScanMetrics.cs b/MiniMapMod/ScanMetrics.cs new file mode 100644 index 0000000..2d04a71 --- /dev/null +++ b/MiniMapMod/ScanMetrics.cs @@ -0,0 +1,23 @@ +namespace MiniMapMod +{ + // Counters from the latest scan, printed every few seconds with LogLevel=debug. + internal sealed class ScanMetrics + { + public int Invocations, RegistryCandidates, SceneCandidates, Unique, Classified, Reused; + public int Inactive, Disabled, Duplicates, Local, Dead, PreferredInteraction, InvalidTeam, Errors; + public bool RegistryAvailable; + + public void Begin() + { + Invocations++; + RegistryAvailable = false; + RegistryCandidates = SceneCandidates = Unique = Classified = Reused = 0; + Inactive = Disabled = Duplicates = Local = Dead = PreferredInteraction = InvalidTeam = Errors = 0; + } + + public override string ToString() => $"calls={Invocations} registryAvailable={RegistryAvailable} registry={RegistryCandidates}" + + $" sceneCached={SceneCandidates} unique={Unique} classifiedNew={Classified} reused={Reused}" + + $" filtered(inactive={Inactive},disabled={Disabled},duplicate={Duplicates},local={Local},dead={Dead}," + + $"interaction={PreferredInteraction},invalidTeam={InvalidTeam}) errors={Errors}"; + } +} diff --git a/MiniMapMod/SceneBounds.cs b/MiniMapMod/SceneBounds.cs new file mode 100644 index 0000000..9078288 --- /dev/null +++ b/MiniMapMod/SceneBounds.cs @@ -0,0 +1,77 @@ +using MiniMapLibrary; +using RoR2; +using RoR2.Navigation; +using UnityEngine; + +namespace MiniMapMod +{ + internal sealed class SceneBounds + { + private bool hasSceneBounds; + public Range3D Range { get; } = new(); + public string Source { get; private set; } = "empty"; + + public void Clear() + { + Range.Clear(); + hasSceneBounds = false; + Source = "empty"; + } + + public void Refresh(SceneInfo scene, SceneTracker tracker, Vector3 viewerPosition) + { + if (Settings.WorldRadius > 0f) + { + Source = "configured radius"; + return; + } + if (hasSceneBounds) + return; + + if (scene && scene.approximateMapBoundMesh) + { + Bounds bounds = scene.approximateMapBoundMesh.bounds; + if (IsUsable(bounds.size)) + { + Range.Clear(); + Range.CheckValue(bounds.min); + Range.CheckValue(bounds.max); + hasSceneBounds = true; + Source = "scene mesh"; + return; + } + } + + NodeGraph nodes = scene ? scene.groundNodes : null; + if (nodes && nodes.GetNodeCount() > 0) + { + Range.Clear(); + int count = nodes.GetNodeCount(); + for (int i = 0; i < count; i++) + { + if (nodes.GetNodePosition(new NodeGraph.NodeIndex(i), out Vector3 position)) + Range.CheckValue(position); + } + hasSceneBounds = Range.X.IsValid && Range.Z.IsValid; + if (hasSceneBounds) + { + Source = "navigation nodes"; + return; + } + } + + // Modded stages may have neither. Use interactables only; enemies would keep changing the scale. + if (!Range.X.IsValid || !Range.Z.IsValid) + Range.CheckValue(viewerPosition); + for (int i = 0; i < tracker.Interactables.Count; i++) + { + SceneMarker marker = tracker.Interactables[i]; + if (marker.Setting.Enabled && marker.DisplayAllowed && marker.WorldTransform) + Range.CheckValue(marker.WorldTransform.position); + } + Source = "discovered positions (minimum span 100)"; + } + + private static bool IsUsable(Vector3 size) => Numeric.IsFinite(size.x) && Numeric.IsFinite(size.z) && size.x > 1f && size.z > 1f; + } +} diff --git a/MiniMapMod/SceneMarker.cs b/MiniMapMod/SceneMarker.cs new file mode 100644 index 0000000..626e977 --- /dev/null +++ b/MiniMapMod/SceneMarker.cs @@ -0,0 +1,59 @@ +using System; +using MiniMapLibrary; +using MiniMapLibrary.Scanner; +using RoR2; +using UnityEngine; + +namespace MiniMapMod +{ + internal sealed class SceneMarker + { + public readonly GameObject Object; + public readonly Transform WorldTransform; + public readonly Behaviour Source; + public readonly IInteractable Interaction; + public readonly InteractableKind Kind; + public readonly InteractibleSetting Setting; + public readonly TrackedObject State; + public readonly ElevationTrackedObject Icon; + public readonly string Token; + public readonly ChestBehavior Chest; + public readonly bool UseScannerVisibility; + public bool DisplayAllowed = true; + public CharacterBody Body; + public HealthComponent Health; + public bool IsBody; + public bool HasPosition; + public Vector2 LastPosition; + public int SpriteRevision = -1; + public float NextRenderAttempt; + + public SceneMarker(GameObject gameObject, Behaviour source, IInteractable interaction, InteractableKind kind, + ISpriteManager sprites, Func viewerHeight, Func activeChecker, MiniMapLibrary.ILogger logger, + bool useScannerVisibility = false) + { + Object = gameObject; + WorldTransform = gameObject.transform; + Source = source; + Interaction = interaction; + Kind = kind; + Setting = Settings.GetSetting(kind); + UseScannerVisibility = useScannerVisibility; + Token = source is PurchaseInteraction purchase ? purchase.contextToken + : source is GenericInteraction generic ? generic.contextToken : null; + Chest = gameObject.GetComponent(); + State = new TrackedObject(kind, gameObject, null) + { + BackingObject = this, + ActiveChecker = activeChecker, + DynamicObject = true, + ErrorLogger = logger + }; + Icon = new ElevationTrackedObject(State, sprites, viewerHeight); + } + + public bool SourceUnchanged => Source && Source.isActiveAndEnabled && WorldTransform + && (Source is not PurchaseInteraction purchase || purchase.contextToken == Token) + && (Source is not GenericInteraction generic || generic.contextToken == Token); + } +} diff --git a/MiniMapMod/SceneTracker.cs b/MiniMapMod/SceneTracker.cs new file mode 100644 index 0000000..abaa504 --- /dev/null +++ b/MiniMapMod/SceneTracker.cs @@ -0,0 +1,518 @@ +using System; +using System.Collections.Generic; +using MiniMapLibrary; +using MiniMapLibrary.Scanner; +using RoR2; +using UnityEngine; + +namespace MiniMapMod +{ + // Builds markers from the game's own registries. A slow scene search only + // backs this up for objects (usually modded) that never register themselves. + internal sealed class SceneTracker + { + private readonly Minimap minimap; + private readonly ISpriteManager sprites; + private readonly MiniMapLibrary.ILogger logger; + private readonly Func viewerHeight; + private readonly Func activeChecker; + private readonly Action release; + private readonly HashSet<(Type, string)> reportedFailures = new(); + private readonly HashSet candidateIds = new(); + private readonly List sceneInteractions = new(); + + public ReconciledSet Interactables { get; } = new(); + public ReconciledSet Bodies { get; } = new(); + public ReconciledSet Pickups { get; } = new(); + public Interactor ViewerInteractor { get; set; } + public ScanMetrics InteractionScan { get; } = new(); + public ScanMetrics BodyScan { get; } = new(); + public ScanMetrics PickupScan { get; } = new(); + public int SceneScans { get; private set; } + public int SceneComponents { get; private set; } + public int UnregisteredCandidates => sceneInteractions.Count; + public double SceneScanMilliseconds { get; private set; } + public int ActiveStates { get; private set; } + public int HiddenStates { get; private set; } + public int RepeatedErrors { get; private set; } + + public SceneTracker(Minimap minimap, ISpriteManager sprites, MiniMapLibrary.ILogger logger, Func viewerHeight) + { + this.minimap = minimap; + this.sprites = sprites; + this.logger = logger; + this.viewerHeight = viewerHeight; + activeChecker = IsActive; + release = Release; + } + + public void RefreshSceneCandidates() + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + candidateIds.Clear(); + try + { + List registered = InterfaceInstanceTracker.GetInstancesList(); + if (registered != null) + { + for (int i = 0; i < registered.Count; i++) + { + if (registered[i] is Component component && component) + candidateIds.Add(component.gameObject.GetInstanceID()); + } + } + } + catch (Exception exception) + { + ReportFailure(typeof(IInteractable), "registry", exception); + } + if (TeleporterInteraction.instance) + candidateIds.Add(TeleporterInteraction.instance.gameObject.GetInstanceID()); + + // FindObjectsOfType only returns active scene objects; Resources.FindObjectsOfTypeAll would include prefabs. + MonoBehaviour[] components = UnityEngine.Object.FindObjectsOfType(); + sceneInteractions.Clear(); + SceneComponents = components.Length; + SceneScans++; + for (int i = 0; i < components.Length; i++) + { + MonoBehaviour component = components[i]; + if ((component is IInteractable or IChestBehavior or RouletteChestController + or ShrineBehavior or ScrapperController or SummonMasterBehavior or DronePurchaseRepair + or HalcyoniteShrineInteractable or ShrineRebirthController) + && IsSceneComponent(component) && !IsWorldPickup(component) + && !candidateIds.Contains(component.gameObject.GetInstanceID())) + sceneInteractions.Add(component); + } + candidateIds.Clear(); + watch.Stop(); + SceneScanMilliseconds = watch.Elapsed.TotalMilliseconds; + } + + private static bool IsSceneComponent(Behaviour component) => component && component.isActiveAndEnabled + && component.gameObject.scene.IsValid() && component.gameObject.scene.isLoaded; + + // Command cubes and void potentials are pickers carrying a PickupIndexNetworker; + // scrappers, cleansing pools and other choice terminals have none. + private static bool IsWorldPickup(Behaviour component) => component is GenericPickupController + || (component is PickupPickerController && component.GetComponent()); + + public void RefreshInteractables() + { + InteractionScan.Begin(); + candidateIds.Clear(); + Interactables.BeginScan(); + try + { + List instances = InterfaceInstanceTracker.GetInstancesList(); + InteractionScan.RegistryAvailable = instances != null; + InteractionScan.RegistryCandidates = instances?.Count ?? 0; + if (instances != null) + { + for (int i = 0; i < instances.Count; i++) + TrackInteraction(instances[i] as Behaviour); + } + } + catch (Exception exception) + { + InteractionScan.Errors++; + ReportFailure(typeof(IInteractable), "registry", exception); + } + // Cached fallback objects take part in every refresh, otherwise EndScan would drop them. + InteractionScan.SceneCandidates = sceneInteractions.Count; + for (int i = sceneInteractions.Count - 1; i >= 0; i--) + { + if (!sceneInteractions[i]) + sceneInteractions.RemoveAt(i); + else + TrackInteraction(sceneInteractions[i]); + } + if (TeleporterInteraction.instance) + TrackInteraction(TeleporterInteraction.instance); + Interactables.EndScan(release); + for (int i = Bodies.Count - 1; i >= 0; i--) + { + if (Bodies[i].Kind != InteractableKind.Player && HasPreferredInteraction(Bodies.GetKey(i))) + Bodies.RemoveAt(i, release); + } + } + + private void TrackInteraction(Behaviour component) + { + if (!IsSceneComponent(component)) + { + InteractionScan.Inactive++; + return; + } + if (component is GenericPickupController) + return; + try + { + GameObject gameObject = component.gameObject; + int id = gameObject.GetInstanceID(); + if (!candidateIds.Add(id)) + { + InteractionScan.Duplicates++; + return; + } + IInteractable candidate = component as IInteractable; + if (Interactables.TryGetAndMark(id, out SceneMarker marker) + && ReferenceEquals(marker.Object, gameObject) && marker.SourceUnchanged + && (marker.Interaction != null || candidate == null)) + { + InteractionScan.Unique++; + InteractionScan.Reused++; + if (!marker.Setting.Enabled) + InteractionScan.Disabled++; + return; + } + // Tracked separately by RefreshPickups. + if (IsWorldPickup(component)) + return; + InteractionScan.Unique++; + Classify(gameObject, component, candidate, out Behaviour source, out IInteractable interaction, + out InteractableKind kind, out bool useScannerVisibility); + var next = new SceneMarker(gameObject, source, interaction, kind, sprites, viewerHeight, activeChecker, logger, useScannerVisibility); + InteractionScan.Classified++; + if (next.Setting.Enabled) + next.State.CheckActive(); + else + InteractionScan.Disabled++; + Interactables.Replace(id, next, release); + } + catch (Exception exception) + { + InteractionScan.Errors++; + ReportFailure(component.GetType(), "classify", exception); + } + } + + public void RefreshPickups() + { + PickupScan.Begin(); + candidateIds.Clear(); + Pickups.BeginScan(); + try + { + List drops = InstanceTracker.GetInstancesList(); + List pickers = InstanceTracker.GetInstancesList(); + PickupScan.RegistryAvailable = true; + PickupScan.RegistryCandidates = drops.Count + pickers.Count; + for (int i = 0; i < drops.Count; i++) + TrackPickup(drops[i], false); + for (int i = 0; i < pickers.Count; i++) + TrackPickup(pickers[i], true); + } + catch (Exception exception) + { + PickupScan.Errors++; + ReportFailure(typeof(GenericPickupController), "registry", exception); + } + Pickups.EndScan(release); + } + + private void TrackPickup(Behaviour component, bool picker) + { + if (!IsSceneComponent(component)) { PickupScan.Inactive++; return; } + try + { + GameObject gameObject = component.gameObject; + int id = gameObject.GetInstanceID(); + if (Pickups.TryGetAndMark(id, out SceneMarker marker) && ReferenceEquals(marker.Source, component)) + { + candidateIds.Add(id); + PickupScan.Reused++; + return; + } + if (picker && !gameObject.GetComponent()) + return; + if (!candidateIds.Add(id)) { PickupScan.Duplicates++; return; } + PickupScan.Unique++; + var next = new SceneMarker(gameObject, component, (IInteractable)component, InteractableKind.Pickup, + sprites, viewerHeight, activeChecker, logger); + if (next.Setting.Enabled) + next.State.CheckActive(); + else + PickupScan.Disabled++; + Pickups.Replace(id, next, release); + PickupScan.Classified++; + } + catch (Exception exception) + { + PickupScan.Errors++; + ReportFailure(component.GetType(), "classify", exception); + } + } + + public void RefreshBodies(GameObject viewedObject) + { + BodyScan.Begin(); + candidateIds.Clear(); + Bodies.BeginScan(); + try + { + for (int teamIndex = (int)TeamIndex.Neutral; teamIndex < (int)TeamIndex.Count; teamIndex++) + { + var members = TeamComponent.GetTeamMembers((TeamIndex)teamIndex); + if (members == null) + continue; + BodyScan.RegistryAvailable = true; + BodyScan.RegistryCandidates += members.Count; + for (int i = 0; i < members.Count; i++) + TrackBody(members[i], viewedObject); + } + } + catch (Exception exception) + { + BodyScan.Errors++; + ReportFailure(typeof(TeamComponent), "registry", exception); + } + Bodies.EndScan(release); + } + + private void TrackBody(TeamComponent team, GameObject viewedObject) + { + if (!IsSceneComponent(team)) { BodyScan.Inactive++; return; } + try + { + int id = team.gameObject.GetInstanceID(); + if (!candidateIds.Add(id)) { BodyScan.Duplicates++; return; } + BodyScan.Unique++; + if (team.teamIndex < TeamIndex.Neutral || team.teamIndex >= TeamIndex.Count) { BodyScan.InvalidTeam++; return; } + CharacterBody body = team.body; + HealthComponent health = body ? body.healthComponent : null; + if (health && !health.alive) { BodyScan.Dead++; return; } + if (team.gameObject == viewedObject) { BodyScan.Local++; return; } + InteractableKind kind = ClassifyTeam(team.teamIndex, body && body.isPlayerControlled); + if (!Settings.GetSetting(kind).Enabled) { BodyScan.Disabled++; return; } + if (kind != InteractableKind.Player && HasPreferredInteraction(id)) { BodyScan.PreferredInteraction++; return; } + if (Bodies.TryGetAndMark(id, out SceneMarker marker) && marker.Kind == kind && ReferenceEquals(marker.Source, team)) + { + marker.Body = body; + marker.Health = health; + BodyScan.Reused++; + return; + } + var next = new SceneMarker(team.gameObject, team, null, kind, sprites, viewerHeight, activeChecker, logger) + { + IsBody = true, Body = body, Health = health + }; + Bodies.Replace(id, next, release); + BodyScan.Classified++; + } + catch (Exception exception) + { + BodyScan.Errors++; + ReportFailure(team.GetType(), "classify", exception); + } + } + + private bool HasPreferredInteraction(int id) + { + if (!Interactables.TryGetValue(id, out SceneMarker interaction) || !interaction.Source || !interaction.Source.isActiveAndEnabled) + return false; + // Hidden chests must not leak back in as a neutral body marker. + return interaction.Kind is InteractableKind.StealthChest or InteractableKind.TimedChest + || (interaction.Setting.Enabled && interaction.DisplayAllowed); + } + + internal static InteractableKind ClassifyTeam(TeamIndex team, bool isPlayer) => team switch + { + TeamIndex.Neutral => InteractableKind.Neutral, + TeamIndex.Player => isPlayer ? InteractableKind.Player : InteractableKind.Minion, + TeamIndex.Lunar => InteractableKind.EnemyLunar, + TeamIndex.Void => InteractableKind.EnemyVoid, + TeamIndex.EndOfTheCycle => InteractableKind.EnemyEndOfCycle, + _ => InteractableKind.EnemyMonster + }; + + private static void Classify(GameObject gameObject, Behaviour component, IInteractable candidate, + out Behaviour source, out IInteractable interaction, out InteractableKind kind, out bool useScannerVisibility) + { + source = component; + interaction = candidate; + useScannerVisibility = false; + + // One marker per GameObject: a chest can carry both a purchase and a picker, for example. + if (gameObject.TryGetComponent(out TeleporterInteraction teleporter) && teleporter.isActiveAndEnabled) + { + source = teleporter; + interaction = teleporter; + kind = InteractableKind.Teleporter; + } + else if (gameObject.TryGetComponent(out TimedChestController timed) && timed.isActiveAndEnabled) + { + source = timed; + interaction = timed; + kind = InteractableKind.TimedChest; + } + else if (gameObject.TryGetComponent(out PurchaseInteraction purchase) && purchase.isActiveAndEnabled) + { + source = purchase; + interaction = purchase; + kind = PurchaseTokenClassifier.Classify(purchase.contextToken, purchase.isShrine, purchase.isDrone); + if (gameObject.TryGetComponent(out PortalStatueBehavior statue) && statue.portalType == PortalStatueBehavior.PortalType.Shop) + kind = InteractableKind.NewtAltar; + else if (kind == InteractableKind.Special) + { + if (gameObject.GetComponent() != null || gameObject.GetComponent()) + kind = InteractableKind.Chest; + else if (gameObject.GetComponent() || gameObject.GetComponent()) + kind = InteractableKind.Drone; + else if (gameObject.GetComponent() || gameObject.GetComponent() + || gameObject.GetComponent()) + kind = InteractableKind.Shrine; + } + } + else if (component is IChestBehavior or RouletteChestController) + kind = InteractableKind.Chest; + else if (component is ScrapperController) + kind = InteractableKind.Utility; + else if (component is ShrineBehavior or HalcyoniteShrineInteractable or ShrineRebirthController) + kind = InteractableKind.Shrine; + else if (component is SummonMasterBehavior or DronePurchaseRepair) + kind = InteractableKind.Drone; + else if (component is BarrelInteraction) + kind = InteractableKind.Barrel; + // TieredPickupPickerController is the Alloyed Collective drone assembly station, not a drop. + else if (component is TieredPickupPickerController or DroneScrapperPickerController or DroneCombinerController or ShrineCleanseBehavior) + kind = InteractableKind.Utility; + else if (component is PickupPickerController picker) + { + kind = PurchaseTokenClassifier.Classify(picker.contextString); + if (kind == InteractableKind.Special) + kind = gameObject.GetComponent() ? InteractableKind.Utility : InteractableKind.Item; + } + else if (component is GenericInteraction generic) + { + kind = PurchaseTokenClassifier.Classify(generic.contextToken); + if (kind == InteractableKind.Special && gameObject.GetComponent()) + kind = InteractableKind.Shrine; + } + else if (component is TrialGongInteraction) + kind = InteractableKind.Portal; + else if (component is GrantHexesController) + kind = InteractableKind.Shrine; + else if (component is PowerPedestal) + kind = InteractableKind.Totem; + else + { + kind = InteractableKind.Special; + useScannerVisibility = interaction != null; + } + } + + private bool IsActive(SceneMarker marker) + { + if (!marker.Source || !marker.Source.isActiveAndEnabled) + return false; + if (marker.IsBody) + return !marker.Health || marker.Health.alive; + + try + { + if (marker.Kind == InteractableKind.Pickup) + { + // Not GetInteractability: it reports Disabled for the first half second + // after a drop lands and for players who can't take that pickup. + marker.DisplayAllowed = marker.Source switch + { + GenericPickupController drop => drop.pickup.pickupIndex.isValid, + PickupPickerController picker => picker.available, + _ => true + }; + return marker.DisplayAllowed; + } + marker.DisplayAllowed = !marker.UseScannerVisibility + || (Settings.ShowUnclassifiedInteractables && marker.Interaction.ShouldShowOnScanner()); + return marker.DisplayAllowed && marker.Source switch + { + PurchaseInteraction purchase => purchase.available && (!marker.Chest || !marker.Chest.isChestOpened), + TeleporterInteraction teleporter => !teleporter.isCharged, + BarrelInteraction barrel => !barrel.Networkopened, + TimedChestController timed => Run.instance && PurchaseTokenClassifier.TimedChestAvailable(timed.purchased, timed.lockTime, Run.instance.GetRunStopwatch()), + GenericInteraction generic => generic.interactability == Interactability.Available, + PickupPickerController picker => picker.available, + _ => marker.Chest ? !marker.Chest.isChestOpened + : marker.Interaction == null || !ViewerInteractor || marker.Interaction.GetInteractability(ViewerInteractor) == Interactability.Available + }; + } + catch (Exception exception) + { + marker.DisplayAllowed = false; + ReportFailure(marker.Source.GetType(), "state", exception); + return false; + } + } + + public void UpdateStates() + { + ActiveStates = HiddenStates = 0; + UpdateStates(Interactables); + UpdateStates(Bodies); + UpdateStates(Pickups); + } + + private void UpdateStates(ReconciledSet markers) + { + for (int i = 0; i < markers.Count; i++) + { + SceneMarker marker = markers[i]; + if (marker.Setting.Enabled) + { + marker.State.CheckActive(); + if (marker.State.Active) ActiveStates++; + if (!marker.DisplayAllowed) HiddenStates++; + } + } + } + + public void DetachIcons() + { + DetachIcons(Interactables); + DetachIcons(Bodies); + DetachIcons(Pickups); + } + + private void DetachIcons(ReconciledSet markers) + { + for (int i = 0; i < markers.Count; i++) + Release(markers[i]); + } + + private void Release(SceneMarker marker) + { + minimap.ReleaseIcon(marker.Kind, marker.Icon.MinimapTransform); + marker.Icon.MinimapTransform = null; + marker.HasPosition = false; + marker.SpriteRevision = -1; + } + + public void Clear() + { + Interactables.Clear(release); + Bodies.Clear(release); + Pickups.Clear(release); + ViewerInteractor = null; + reportedFailures.Clear(); + sceneInteractions.Clear(); + candidateIds.Clear(); + SceneScans = SceneComponents = ActiveStates = HiddenStates = RepeatedErrors = 0; + SceneScanMilliseconds = 0; + InteractionScan.Begin(); + BodyScan.Begin(); + PickupScan.Begin(); + InteractionScan.Invocations = BodyScan.Invocations = PickupScan.Invocations = 0; + } + + public void RemoveMarker(ReconciledSet markers, int index) => markers.RemoveAt(index, release); + + internal void ReportFailure(Type type, string stage, Exception exception) + { + if (reportedFailures.Add((type, stage))) + logger.LogException(exception, $"MiniMap {stage} failed for {type.FullName}. Other markers continue; repeat counts appear in DEBUG diagnostics."); + else + RepeatedErrors++; + } + } +} diff --git a/MiniMapMod/libs/BepInEx.dll b/MiniMapMod/libs/BepInEx.dll index 8aedff0..d7e2bc6 100644 Binary files a/MiniMapMod/libs/BepInEx.dll and b/MiniMapMod/libs/BepInEx.dll differ diff --git a/MiniMapMod/libs/LegacyResourcesAPI.dll b/MiniMapMod/libs/LegacyResourcesAPI.dll index 7d08d06..9bb02a6 100644 Binary files a/MiniMapMod/libs/LegacyResourcesAPI.dll and b/MiniMapMod/libs/LegacyResourcesAPI.dll differ diff --git a/MiniMapMod/libs/RoR2.dll b/MiniMapMod/libs/RoR2.dll index 06c1f7d..3002690 100644 Binary files a/MiniMapMod/libs/RoR2.dll and b/MiniMapMod/libs/RoR2.dll differ diff --git a/MiniMapMod/libs/Unity.Addressables.dll b/MiniMapMod/libs/Unity.Addressables.dll index 331d751..cf37012 100644 Binary files a/MiniMapMod/libs/Unity.Addressables.dll and b/MiniMapMod/libs/Unity.Addressables.dll differ diff --git a/MiniMapMod/libs/Unity.ResourceManager.dll b/MiniMapMod/libs/Unity.ResourceManager.dll index 6d15fc4..3f1f7a1 100644 Binary files a/MiniMapMod/libs/Unity.ResourceManager.dll and b/MiniMapMod/libs/Unity.ResourceManager.dll differ diff --git a/MiniMapMod/libs/UnityEngine.CoreModule.dll b/MiniMapMod/libs/UnityEngine.CoreModule.dll index 538a4fc..e074911 100644 Binary files a/MiniMapMod/libs/UnityEngine.CoreModule.dll and b/MiniMapMod/libs/UnityEngine.CoreModule.dll differ diff --git a/MiniMapMod/libs/UnityEngine.InputLegacyModule.dll b/MiniMapMod/libs/UnityEngine.InputLegacyModule.dll index 1067ff9..3a71ce2 100644 Binary files a/MiniMapMod/libs/UnityEngine.InputLegacyModule.dll and b/MiniMapMod/libs/UnityEngine.InputLegacyModule.dll differ diff --git a/MiniMapMod/libs/UnityEngine.UI.dll b/MiniMapMod/libs/UnityEngine.UI.dll index 7824705..cdae8f9 100644 Binary files a/MiniMapMod/libs/UnityEngine.UI.dll and b/MiniMapMod/libs/UnityEngine.UI.dll differ diff --git a/MiniMapMod/libs/UnityEngine.UIModule.dll b/MiniMapMod/libs/UnityEngine.UIModule.dll index e082dda..f42d624 100644 Binary files a/MiniMapMod/libs/UnityEngine.UIModule.dll and b/MiniMapMod/libs/UnityEngine.UIModule.dll differ diff --git a/MiniMapMod/libs/UnityEngine.dll b/MiniMapMod/libs/UnityEngine.dll index 6f1c131..e693018 100644 Binary files a/MiniMapMod/libs/UnityEngine.dll and b/MiniMapMod/libs/UnityEngine.dll differ diff --git a/MiniMapMod/libs/com.unity.multiplayer-hlapi.Runtime.dll b/MiniMapMod/libs/com.unity.multiplayer-hlapi.Runtime.dll index ac024eb..ea8dcb5 100644 Binary files a/MiniMapMod/libs/com.unity.multiplayer-hlapi.Runtime.dll and b/MiniMapMod/libs/com.unity.multiplayer-hlapi.Runtime.dll differ diff --git a/README.md b/README.md index 136c6be..cbfb5b4 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ Adds a contemporary Minimap to the game. - Implements a Mini Map In-Game. - Show interactable items, shrines, teleporters, enemies and more. +- Shows dropped items and equipment as small cyan dots. - Single and Multiplayer Compatible. - Every icon is configurable for color, size and can be toggled on/off. +- Works with all DLCs (Survivors of the Void, Seekers of the Storm, Alloyed Collective, Hallowed Concepts). #### Available Icons | Icon | Available | Configurable | @@ -32,10 +34,11 @@ Adds a contemporary Minimap to the game. | Fans/Special | ✔ | ✔ | | Portals | ✔ | ✔ | | Totems | ✔ | ✔ | -| Dropped Items | ✔ | ✔ | +| Dropped Items / Equipment / Lunar Coins | ✔ | ✔ | +| Command Cubes / Void Potentials | ✔ | ✔ | | Newt Altar | ✔ | ✔ | -| Timed Chests | ❌ | ❌ | -| Stealth Chests | ❌ | ❌ | +| Timed Chests | ✔ (off by default) | ✔ | +| Stealth Chests | ✔ (off by default) | ✔ | ### Showcase @@ -43,16 +46,21 @@ Adds a contemporary Minimap to the game. ### Dependencies - BepInEx +- HookGenPatcher +- RoR2BepInExPack ### Manual Installation - Place MiniMapLibrary.dll into your BepInEx/plugins folder. - Place MiniMapMod.dll into your BepInEx/plugins folder. +- BepInExPack 5.4.2122 needs Newtonsoft.Json, which the game doesn't ship anymore. RoR2BepInExPack provides it, so install that too (mod managers do this automatically). ### How to Use -- Use 'M' to hide or show the minimap. +- Use 'M' to hide or show the minimap. +- Use '=' and '-' to zoom in and out. ### Troubleshooting - Toggle 'M' to hide and show minimap to force a refresh and/or a re-draw if something wonky is happening on the minimap. +- Set `LogLevel = debug` under `[Settings.General]` in the config and play a stage. Every few seconds the log shows what the minimap found and drew, which helps a lot with bug reports. ### Config BepInEx Config. Each icon is individually configurable. @@ -67,6 +75,8 @@ BepInEx Config. Each icon is individually configurable. - Elevation Marker Height - Elevation Marker Offset +Map size, position, camera rotation, zoom keys and scan intervals are configurable too. + ### Upcoming Changes None - Final Release @@ -76,8 +86,21 @@ If you have an issue, discover a bug, or have a recommendation please file an is ### Collaborators Huge thanks to these awesome people who assisted in creating awesome features for this mod [Arcafanetiz](https://github.com/Arcafanetiz) +[winterofhell](https://github.com/winterofhell) ### Change Log +Release 4.2.0 +- updated for the current game version and all DLCs (Seekers of the Storm, Alloyed Collective, Hallowed Concepts) +- dropped items, equipment, lunar coins, command cubes and void potentials now show up as small light-cyan dots, and disappear as soon as they're picked up +- all icons are about 10% bigger, old configs that still use the default sizes get the new ones automatically +- objects are now tracked through the game's own registries instead of rescanning the whole scene, so no more stutter every few seconds +- timed and stealth chests can be turned on in the config (still off by default) +- added End of the Cycle enemies +- map no longer overlaps the objective panel +- added map size, position and rotation settings +- added debug logging for bug reports +- fixed duplicate icons and icons sticking around after objects were destroyed + Release 4.0.0 - Finally added newt altars to the search, since everyone has been begging for it for literally years diff --git a/manifest.json b/manifest.json index 0fe10bf..1741848 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,11 @@ -{ +{ "name": "MiniMapMod", - "version_number": "4.0.0", - "website_url": "/ArtyRedd", - "description": "Adds a MiniMap to your game v4", + "version_number": "4.2.0", + "website_url": "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/artyredd/MiniMapMod", + "description": "A configurable HUD minimap showing chests, shops, shrines, teleporters, drones, players, enemies and dropped items, with elevation arrows and zoom. Supports all DLCs.", "dependencies": [ - "bbepis-BepInExPack-5.4.9", - "RiskofThunder-HookGenPatcher-1.2.1" + "bbepis-BepInExPack-5.4.2122", + "RiskofThunder-HookGenPatcher-1.2.9", + "RiskofThunder-RoR2BepInExPack-1.46.0" ] }