diff --git a/MapIconsSettings.cs b/MapIconsSettings.cs index 4195147..d114d28 100644 --- a/MapIconsSettings.cs +++ b/MapIconsSettings.cs @@ -2,9 +2,60 @@ using ExileCore.Shared.Interfaces; using ExileCore.Shared.Nodes; using MinimapIcons.IconsBuilder; +using System; namespace MinimapIcons; +/// +/// One "never draw an icon for this" entry, editable from the plugin menu. +/// +/// +/// The existing ignore mechanisms are both applied when the icon is *built*: entries in +/// ignored_entities.txt and in make +/// IconsBuilder.SkipIcon return before an icon object exists. That is fine for a +/// permanent exclusion, but it means an entry cannot be undone without an area change, and +/// the hardcoded list cannot be undone at all. These rules are applied at draw time instead, +/// so toggling one takes effect on the next frame in both directions. +/// +[Submenu] +public class IconIgnoreRule +{ + public IconIgnoreRule() + { + } + + public IconIgnoreRule(string label, string metadataRegex, bool hide) + { + Label = new TextNode(label); + MetadataRegex = new TextNode(metadataRegex); + Hide = new ToggleNode(hide); + } + + [Menu("Hide", "On = matching entities get no icon. Off = they draw as normal.")] + public ToggleNode Hide { get; set; } = new ToggleNode(true); + + [Menu("Label", "Free text, so the row is recognisable in this list. Not used for matching.")] + public TextNode Label { get; set; } = new TextNode(""); + + [Menu("Metadata regex", "Unanchored regex tested against the entity metadata path.")] + public TextNode MetadataRegex { get; set; } = new TextNode(""); + + // Names the row in the settings UI, which otherwise falls back to object.ToString() and shows + // every row as "MinimapIcons.IconIgnoreRule". The trailing ### keeps the ImGui id of the row + // stable while the visible part of the label changes, so the row does not collapse itself + // while you are typing in the label or pattern field. + public override string ToString() + { + var label = !string.IsNullOrWhiteSpace(Label?.Value) + ? Label.Value + : !string.IsNullOrWhiteSpace(MetadataRegex?.Value) + ? MetadataRegex.Value + : "(empty rule)"; + + return $"{label}###"; + } +} + public class MapIconsSettings : ISettings { public ToggleNode DrawMonsters { get; set; } = new ToggleNode(true); @@ -21,6 +72,23 @@ public class MapIconsSettings : ISettings public RangeNode IconListRefreshPeriod { get; set; } = new RangeNode(100, 0, 1000); public ToggleNode HighlightHiddenMonsters { get; set; } = new ToggleNode(true); + [Menu("Hidden icons", "Entities you never want an icon for, each with its own on/off. Unlike " + + "ignored_entities.txt these are applied at draw time, so a toggle takes effect " + + "immediately and can be undone without changing areas. Empty by default.", + CollapsedByDefault = true)] + public ContentNode HiddenIcons { get; set; } = + new ContentNode() + { + Content = + [ + ], + EnableControls = true, + EnableItemCollapsing = true, + ItemFactory = () => new IconIgnoreRule(), + ItemFilter = (o, s) => o.Label.Value.Contains(s, StringComparison.OrdinalIgnoreCase) || + o.MetadataRegex.Value.Contains(s, StringComparison.OrdinalIgnoreCase), + }; + [Menu(null, CollapsedByDefault = true)] public ContentNode AlwaysShownIngameIcons { get; set; } = new ContentNode() diff --git a/MinimapIcons.cs b/MinimapIcons.cs index c4d2446..2aadddd 100644 --- a/MinimapIcons.cs +++ b/MinimapIcons.cs @@ -11,6 +11,7 @@ using System.Collections.Generic; using System.Drawing; using System.Linq; +using System.Text.RegularExpressions; using Color = SharpDX.Color; using RectangleF = SharpDX.RectangleF; using Vector2 = System.Numerics.Vector2; @@ -158,6 +159,18 @@ public class MinimapIcons : BaseSettingsPlugin private readonly Dictionary IgnoreCache = new Dictionary(); + // Compiled regexes for Settings.HiddenIcons keyed by pattern text, plus the per-path answers + // derived from them. A breach puts hundreds of icons behind a handful of distinct paths, so + // this turns the regex work into a dictionary hit after the first sighting of each path. + private readonly Dictionary _hiddenIconRegexes = new(StringComparer.Ordinal); + private readonly HashSet _badHiddenIconPatterns = new(StringComparer.Ordinal); + private readonly Dictionary _hiddenIconCache = new(StringComparer.Ordinal); + + // Cheap hash of the rule set, so an edit in the menu invalidates the cache above. Recomputed + // once per frame rather than per icon, and an int rather than a joined string, because + // Render() runs every frame and should not allocate. + private int _hiddenIconSignature; + private IngameUIElements _ingameUi; private bool? _largeMap; private float _mapScale; @@ -257,6 +270,8 @@ public override void Render() var baseIcons = _iconListCache.Value; if (baseIcons == null) return; + RefreshHiddenIconRules(); + foreach (var icon in baseIcons) { if (icon?.Entity == null) continue; @@ -267,6 +282,9 @@ public override void Render() if (IgnoreCache.GetOrAdd(icon.Entity.Path, () => Ignored.Any(x => icon.Entity.Path.StartsWith(x)))) continue; + if (IsHiddenByRule(icon.Entity.Path)) + continue; + if (icon.Entity.Path.StartsWith( "Metadata/Monsters/AtlasExiles/BasiliskInfluenceMonsters/BasiliskBurrowingViper", StringComparison.Ordinal) && icon.Entity.Rarity != MonsterRarity.Unique) @@ -316,6 +334,76 @@ icon is not CustomIcon && } } + /// + /// Drops the cached per-path answers whenever a rule's toggle or pattern changes, so an edit + /// in the menu takes effect on the next frame. Called once per frame, before the icon loop. + /// + private void RefreshHiddenIconRules() + { + var rules = Settings.HiddenIcons?.Content; + var signature = 17; + if (rules != null) + { + foreach (var rule in rules) + { + if (rule == null) continue; + signature = signature * 31 + (rule.Hide.Value ? 1 : 0); + signature = signature * 31 + (rule.MetadataRegex?.Value?.GetHashCode() ?? 0); + } + } + + if (signature == _hiddenIconSignature) return; + _hiddenIconSignature = signature; + _hiddenIconCache.Clear(); + } + + private bool IsHiddenByRule(string path) + { + if (string.IsNullOrEmpty(path)) return false; + var rules = Settings.HiddenIcons?.Content; + if (rules == null || rules.Count == 0) return false; + if (_hiddenIconCache.TryGetValue(path, out var cached)) return cached; + + var hidden = false; + foreach (var rule in rules) + { + if (rule == null || !rule.Hide.Value) continue; + var pattern = rule.MetadataRegex?.Value; + if (string.IsNullOrWhiteSpace(pattern)) continue; + + var regex = GetHiddenIconRegex(pattern); + if (regex != null && regex.IsMatch(path)) + { + hidden = true; + break; + } + } + + _hiddenIconCache[path] = hidden; + return hidden; + } + + private Regex GetHiddenIconRegex(string pattern) + { + if (_hiddenIconRegexes.TryGetValue(pattern, out var cached)) return cached; + // A half-typed pattern is a normal state while editing the field, so a pattern that fails + // to compile is reported once and then skipped rather than logged every frame. + if (_badHiddenIconPatterns.Contains(pattern)) return null; + + try + { + var regex = new Regex(pattern, RegexOptions.Compiled); + _hiddenIconRegexes[pattern] = regex; + return regex; + } + catch (ArgumentException ex) + { + _badHiddenIconPatterns.Add(pattern); + LogError($"MinimapIcons: invalid hidden icon regex '{pattern}' -- rule skipped. {ex.Message}"); + return null; + } + } + private const float CameraAngle = 38.7f * MathF.PI / 180; private static readonly float CameraAngleCos = MathF.Cos(CameraAngle); private static readonly float CameraAngleSin = MathF.Sin(CameraAngle);