Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions MapIconsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,60 @@
using ExileCore.Shared.Interfaces;
using ExileCore.Shared.Nodes;
using MinimapIcons.IconsBuilder;
using System;

namespace MinimapIcons;

/// <summary>
/// One "never draw an icon for this" entry, editable from the plugin menu.
/// </summary>
/// <remarks>
/// The existing ignore mechanisms are both applied when the icon is *built*: entries in
/// <c>ignored_entities.txt</c> and in <see cref="MinimapIcons.Ignored"/> make
/// <c>IconsBuilder.SkipIcon</c> 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.
/// </remarks>
[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);
Expand All @@ -21,6 +72,23 @@ public class MapIconsSettings : ISettings
public RangeNode<int> IconListRefreshPeriod { get; set; } = new RangeNode<int>(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<IconIgnoreRule> HiddenIcons { get; set; } =
new ContentNode<IconIgnoreRule>()
{
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<TextNode> AlwaysShownIngameIcons { get; set; } =
new ContentNode<TextNode>()
Expand Down
88 changes: 88 additions & 0 deletions MinimapIcons.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,6 +159,18 @@ public class MinimapIcons : BaseSettingsPlugin<MapIconsSettings>

private readonly Dictionary<string, bool> IgnoreCache = new Dictionary<string, bool>();

// 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<string, Regex> _hiddenIconRegexes = new(StringComparer.Ordinal);
private readonly HashSet<string> _badHiddenIconPatterns = new(StringComparer.Ordinal);
private readonly Dictionary<string, bool> _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;
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -316,6 +334,76 @@ icon is not CustomIcon &&
}
}

/// <summary>
/// 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.
/// </summary>
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);
Expand Down