diff --git a/ExpeditionIcons.cs b/ExpeditionIcons.cs index bd6b024..6f7a836 100644 --- a/ExpeditionIcons.cs +++ b/ExpeditionIcons.cs @@ -62,6 +62,8 @@ public class ExpeditionIcons : BaseSettingsPlugin private List _editedPath; private int? _editedIndex = null; private PathPlanner.DetailedLootScore _editedPathEval; + private readonly Dictionary _hotkeyHandlers = new(); + private bool _settingsHooksAttached; private PathPlanner.DetailedLootScore EditedOrNativeScore => _editedPathEval ?? _plannerRunner?.CurrentBestPath; private Camera Camera => GameController.Game.IngameState.Camera; @@ -84,7 +86,7 @@ public class ExpeditionIcons : BaseSettingsPlugin } private Entity DetonatorEntity => - GameController.EntityListWrapper.ValidEntitiesByType[EntityType.IngameIcon] + ValidEntitiesOfType(EntityType.IngameIcon) .FirstOrDefault(x => x.Path == "Metadata/MiscellaneousObjects/Expedition/ExpeditionDetonator" || x.Path == "Metadata/MiscellaneousObjects/Expedition/ExpeditionDetonatorTreasureIsland"); @@ -93,7 +95,7 @@ public class ExpeditionIcons : BaseSettingsPlugin private Vector2i? PlacementIndicatorPos => ExpeditionInfo.IsExplosivePlacementActive - ? GameController.EntityListWrapper.ValidEntitiesByType[EntityType.MiscellaneousObjects] + ? ValidEntitiesOfType(EntityType.MiscellaneousObjects) .FirstOrDefault(x => x.Path == "Metadata/MiscellaneousObjects/Expedition/ExpeditionPlacementIndicator")?.GridPos.RoundToVector2I() ?? ExpeditionInfo.PlacementIndicatorGridPosition : null; @@ -104,22 +106,67 @@ public class ExpeditionIcons : BaseSettingsPlugin public override bool Initialise() { - GameController.SoundController.PreloadSound("expedition_attention", Path.Join(DirectoryFullName, "attention.wav")); + var soundPath = Path.Join(DirectoryFullName, "attention.wav"); + if (File.Exists(soundPath)) + GameController.SoundController.PreloadSound(PathPlannerRunner.SoundId, soundPath); Graphics.InitImage(TextureName); IconPickerDrawer.Instance._iconsImageId = Graphics.GetTextureId(TextureName); Settings.PlannerSettings.StartSearch.OnPressed += StartSearch; Settings.PlannerSettings.StopSearch.OnPressed += StopSearch; Settings.PlannerSettings.ClearSearch.OnPressed += ClearSearch; + Settings.Enable.OnValueChanged += OnEnableChanged; + _settingsHooksAttached = true; RegisterHotkey(Settings.PlannerSettings.StartSearchHotkey); RegisterHotkey(Settings.PlannerSettings.StopSearchHotkey); RegisterHotkey(Settings.PlannerSettings.ClearSearchHotkey); return base.Initialise(); } - private static void RegisterHotkey(HotkeyNode hotkey) + private void RegisterHotkey(HotkeyNodeV2 hotkey) { - Input.RegisterKey(hotkey); - hotkey.OnValueChanged += () => { Input.RegisterKey(hotkey); }; + Input.RegisterKey(hotkey.Value); + Action handler = () => Input.RegisterKey(hotkey.Value); + hotkey.OnValueChanged += handler; + _hotkeyHandlers[hotkey] = handler; + } + + private void OnEnableChanged(object _, bool enabled) + { + if (enabled) return; + StopSearch(); + _cachedEntities.Clear(); + _detonatorPos = null; + _zoneCleared = false; + } + + public override void OnPluginDestroyForHotReload() + { + DetachSettingsHooks(); + StopSearch(); + base.OnPluginDestroyForHotReload(); + } + + public override void Dispose() + { + DetachSettingsHooks(); + StopSearch(); + base.Dispose(); + } + + private void DetachSettingsHooks() + { + if (_settingsHooksAttached) + { + Settings.PlannerSettings.StartSearch.OnPressed -= StartSearch; + Settings.PlannerSettings.StopSearch.OnPressed -= StopSearch; + Settings.PlannerSettings.ClearSearch.OnPressed -= ClearSearch; + Settings.Enable.OnValueChanged -= OnEnableChanged; + _settingsHooksAttached = false; + } + + foreach (var (hotkey, handler) in _hotkeyHandlers) + hotkey.OnValueChanged -= handler; + _hotkeyHandlers.Clear(); } private void StopSearch() @@ -208,13 +255,15 @@ private void DrawCirclesInWorld(List positions, float radius, Color col { const int segments = 90; const int segmentSpan = 360 / segments; + if (!float.IsFinite(radius) || radius <= 0) return; var playerPos = GameController.Player?.GetComponent()?.WorldPos; - if (playerPos == null) + if (playerPos == null || !IsFinite(playerPos.Value)) { return; } foreach (var position in positions + .Where(IsFinite) .Where(x => playerPos.Value.Distance(new Vector2(x.X, x.Y)) < 80 * GridToWorldMultiplier + radius)) { foreach (var segmentId in Enumerable.Range(0, segments)) @@ -225,12 +274,14 @@ private void DrawCirclesInWorld(List positions, float radius, Color col var offset = new Vector2(cos, sin) * radius; var xy = position.Xy() + offset; var screen = Camera.WorldToScreen(ExpandWithTerrainHeight(xy.WorldToGrid())); + if (!IsFinite(screen)) return (xy, new Vector2(float.NaN, float.NaN)); return (xy, screen); } var segmentOrigin = segmentId * segmentSpan; var (w1, c1) = GetVector(segmentOrigin); var (w2, c2) = GetVector(segmentOrigin + segmentSpan); + if (!IsFinite(c1) || !IsFinite(c2)) continue; if (Settings.ExplosivesSettings.EnableExplosiveRadiusMerging) { if (positions @@ -250,6 +301,9 @@ private void DrawCirclesInWorld(List positions, float radius, Color col public override void Tick() { + if (!Settings.Enable) + return; + IconPickerDrawer.Instance._iconsImageId = Graphics.GetTextureId(TextureName); Settings.PlannerSettings.SearchState = _plannerRunner switch { @@ -265,6 +319,7 @@ public override void Tick() return; } + if (!IsFinite(playerGridPos.Value)) return; _playerGridPos = playerGridPos.Value; if (detonatorPos is { Pos: var dp } && _playerGridPos.Distance(dp) < 90) { @@ -282,7 +337,9 @@ public override void Tick() _largeMapOpen = largeMap.IsVisible; _mapScale = GameController.IngameState.Camera.Height / 677f * largeMap.Zoom; _mapCenter = largeMap.GetClientRect().TopLeft + largeMap.Shift + largeMap.DefaultShift; - _playerZ = GameController.Player.GetComponent().Z; + _playerZ = GameController.Player.GetComponent()?.Z ?? float.NaN; + if (!double.IsFinite(_mapScale) || _mapScale <= 0 || !IsFinite(_mapCenter) || !float.IsFinite(_playerZ)) + return; _explosiveRadius = Settings.ExplosivesSettings.CalculateRadiusAutomatically //ReSharper disable once PossibleLossOfFraction @@ -293,9 +350,11 @@ public override void Tick() //rounding here is extremely important to get right, this is taken from the game's code _explosiveRange = ExplosiveBaseRange * (100 + (GameController.IngameState.Data.MapStats?.GetValueOrDefault(GameStat.MapExpeditionMaximumPlacementDistancePct) ?? 0)) / 100 * GridToWorldMultiplier; + if (!float.IsFinite(_explosiveRadius) || _explosiveRadius <= 0 || !float.IsFinite(_explosiveRange) || _explosiveRange <= 0) + return; - foreach (var entity in new[] { EntityType.IngameIcon, EntityType.Terrain } - .SelectMany(x => GameController.EntityListWrapper.ValidEntitiesByType[x])) + foreach (var entity in ValidEntitiesOfType(EntityType.IngameIcon) + .Concat(ValidEntitiesOfType(EntityType.Terrain))) { if (GetEntityType(entity.Path) != ExpeditionEntityType.None) { @@ -461,6 +520,9 @@ private bool IsValidPlacement(Vector2 x) public override void Render() { + if (!Settings.Enable) + return; + if (Settings.PlannerSettings.ClearSearchHotkey.PressedOnce()) { ClearSearch(); @@ -481,7 +543,7 @@ public override void Render() StartSearch(); } - var explosives3D = GameController.EntityListWrapper.ValidEntitiesByType[EntityType.IngameIcon] + var explosives3D = ValidEntitiesOfType(EntityType.IngameIcon) .Where(x => x.Path == ExplosivePath) .Select(x => x.Pos) .ToList(); @@ -624,11 +686,16 @@ public override void Render() var point = path[i].Point; if (_largeMapOpen) { - Graphics.DrawLine(GetMapScreenPosition(prevPoint), GetMapScreenPosition(point), 1, Settings.PlannerSettings.MapLineColor); + var mapPrev = GetMapScreenPosition(prevPoint); + var mapPoint = GetMapScreenPosition(point); + if (IsFinite(mapPrev) && IsFinite(mapPoint)) + Graphics.DrawLine(mapPrev, mapPoint, 1, Settings.PlannerSettings.MapLineColor); } var worldPos = GetWorldScreenPosition(point); - Graphics.DrawLine(GetWorldScreenPosition(prevPoint), worldPos, 1, Settings.PlannerSettings.WorldLineColor); + var worldPrev = GetWorldScreenPosition(prevPoint); + if (IsFinite(worldPrev) && IsFinite(worldPos)) + Graphics.DrawLine(worldPrev, worldPos, 1, Settings.PlannerSettings.WorldLineColor); var text = $"#{i}"; using (Graphics.SetTextScale(Settings.PlannerSettings.TextMarkerScale)) { @@ -638,10 +705,8 @@ public override void Render() } } - if (Settings.PlannerSettings.IsSearchRunning) - { + if (Settings.PlannerSettings.IsSearchRunning && double.IsFinite(score.TotalScore)) _scoreHistory.Add((float)score.TotalScore); - } ShowSearchWindow(score); @@ -652,6 +717,17 @@ public override void Render() } } + private IEnumerable ValidEntitiesOfType(EntityType type) + { + if (GameController.EntityListWrapper?.ValidEntitiesByType is not { } byType || + !byType.TryGetValue(type, out var entities) || entities is null) + { + return Enumerable.Empty(); + } + + return entities; + } + private void ShowSearchWindow(PathPlanner.DetailedLootScore score) { if (Settings.PlannerSettings.ShowScoreHistory && @@ -672,7 +748,8 @@ private void ShowSearchWindow(PathPlanner.DetailedLootScore score) DrawCirclesInWorld([ExpandWithTerrainHeight(pos)], _explosiveRadius, Color.LightBlue); Graphics.DrawLine(GetWorldScreenPosition(_editedPath[editedIndex]), GetWorldScreenPosition(pos), 1, Settings.PlannerSettings.WorldLineColor); - if (Input.IsKeyDown(Settings.PlannerSettings.ConfirmEditorPlacementHotkey)) + var confirmKey = Settings.PlannerSettings.ConfirmEditorPlacementHotkey.Value; + if (confirmKey?.Mode == HotkeyNodeV2.HotkeyNodeMode.Keyboard && Input.IsKeyDown((int)confirmKey.Key)) { _editedPath[editedIndex] = pos; _editedPathEval = pp.GetDetailedScore(_editedPath, score.Environment); @@ -799,9 +876,13 @@ private void ShowSearchWindow(PathPlanner.DetailedLootScore score) } } - ImGui.PlotLines("Score over time", ref CollectionsMarshal.AsSpan(_scoreHistory)[0], - _scoreHistory.Count, 0, "", 0, _scoreHistory.Max(), - new Vector2(0, ImGui.GetContentRegionAvail().Y)); + if (_scoreHistory.Count > 0) + { + var maxScore = _scoreHistory.Where(float.IsFinite).DefaultIfEmpty(0f).Max(); + ImGui.PlotLines("Score over time", ref CollectionsMarshal.AsSpan(_scoreHistory)[0], + _scoreHistory.Count, 0, "", 0, maxScore, + new Vector2(0, ImGui.GetContentRegionAvail().Y)); + } ImGui.End(); } } @@ -864,6 +945,7 @@ private void DrawIconOnMap(EntityCacheItem entity, MapIconsIndex icon, Color? co var point = GetEntityPosOnMapScreen(entity) + offset * halfsize * 2; var entityPos = entity.Pos; var entityPos2 = new Vector2(entityPos.X, entityPos.Y); + if (!float.IsFinite(halfsize) || halfsize <= 0 || !IsFinite(point) || !IsFinite(entityPos2)) return; DrawIcon(icon, color, point, entityPos2, Settings.ExplosivesSettings.HideCapturedEntitiesOnMap, @@ -881,6 +963,7 @@ private void DrawIconInWorld(EntityCacheItem entity, MapIconsIndex icon, Color? var entityPos = entity.Pos; var entityPos2 = new Vector2(entityPos.X, entityPos.Y); var point = Camera.WorldToScreen(entityPos) + offset * halfsize * 2; + if (!float.IsFinite(halfsize) || halfsize <= 0 || !IsFinite(point) || !IsFinite(entityPos2)) return; DrawIcon(icon, color, point, entityPos2, Settings.ExplosivesSettings.HideCapturedEntitiesInWorld, Settings.ExplosivesSettings.MarkCapturedEntitiesInWorld, @@ -902,6 +985,7 @@ private void DrawIcon( int frameThickness, float iconSize) { + if (!float.IsFinite(iconSize) || iconSize <= 0 || !IsFinite(displayPosition) || !IsFinite(worldPosition)) return; var halfsize = iconSize / 2.0f; var rect = new RectangleF(displayPosition.X, displayPosition.Y, 0, 0); rect.Inflate(halfsize, halfsize); @@ -936,26 +1020,41 @@ private void DrawIcon( private Vector2 GetMapScreenPosition(Vector2 gridPos) { - return _mapCenter + TranslateGridDeltaToMapDelta(gridPos - _playerGridPos, GameController.IngameState.Data.GetTerrainHeightAt(gridPos) - _playerZ); + if (!IsFinite(gridPos) || !IsFinite(_playerGridPos) || !double.IsFinite(_mapScale) || _mapScale <= 0) return new Vector2(float.NaN, float.NaN); + var height = GameController.IngameState.Data.GetTerrainHeightAt(gridPos); + if (!float.IsFinite(height)) return new Vector2(float.NaN, float.NaN); + return _mapCenter + TranslateGridDeltaToMapDelta(gridPos - _playerGridPos, height - _playerZ); } private Vector2 GetWorldScreenPosition(Vector2 gridPos) { - return Camera.WorldToScreen(ExpandWithTerrainHeight(gridPos)); + if (!IsFinite(gridPos)) return new Vector2(float.NaN, float.NaN); + var screen = Camera.WorldToScreen(ExpandWithTerrainHeight(gridPos)); + return IsFinite(screen) ? screen : new Vector2(float.NaN, float.NaN); } private Vector2 GetEntityPosOnMapScreen(EntityCacheItem entity) { + if (!IsFinite(entity.GridPos) || !IsFinite(_playerGridPos) || !double.IsFinite(_mapScale) || _mapScale <= 0 || + !float.IsFinite(entity.RenderZ ?? 0f) || !float.IsFinite(_playerZ)) return new Vector2(float.NaN, float.NaN); var point = _mapCenter + TranslateGridDeltaToMapDelta(entity.GridPos - _playerGridPos, (entity.RenderZ ?? 0) - _playerZ); - return point; + return IsFinite(point) ? point : new Vector2(float.NaN, float.NaN); } private Vector2 TranslateGridDeltaToMapDelta(Vector2 delta, float deltaZ) { + if (!IsFinite(delta) || !float.IsFinite(deltaZ) || !double.IsFinite(_mapScale) || _mapScale <= 0) + return new Vector2(float.NaN, float.NaN); deltaZ /= GridToWorldMultiplier; //z is normally "world" units, translate to grid return (float)_mapScale * new Vector2((delta.X - delta.Y) * CameraAngleCos, (deltaZ - (delta.X + delta.Y)) * CameraAngleSin); } + private static bool IsFinite(Vector2 value) + => float.IsFinite(value.X) && float.IsFinite(value.Y); + + private static bool IsFinite(Vector3 value) + => float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z); + private enum ExpeditionEntityType { None, @@ -1012,4 +1111,4 @@ private static EntityCacheItem BuildCacheItem(Entity entity) entity.GetComponent()?.Bounds is { } b ? Math.Min(b.X, b.Y) : null, entity.GetComponent()?.IsHide); } -} \ No newline at end of file +} diff --git a/ExpeditionIconsSettings.cs b/ExpeditionIconsSettings.cs index 3d2d0b4..bfb2602 100644 --- a/ExpeditionIconsSettings.cs +++ b/ExpeditionIconsSettings.cs @@ -298,10 +298,10 @@ public PlannerSettings() }; } - public HotkeyNode StartSearchHotkey { get; set; } = new HotkeyNode(Keys.F13); - public HotkeyNode StopSearchHotkey { get; set; } = new HotkeyNode(Keys.F13); - public HotkeyNode ClearSearchHotkey { get; set; } = new HotkeyNode(Keys.F13); - public HotkeyNode ConfirmEditorPlacementHotkey { get; set; } = new HotkeyNode(Keys.Enter); + public HotkeyNodeV2 StartSearchHotkey { get; set; } = new HotkeyNodeV2(Keys.F13); + public HotkeyNodeV2 StopSearchHotkey { get; set; } = new HotkeyNodeV2(Keys.F13); + public HotkeyNodeV2 ClearSearchHotkey { get; set; } = new HotkeyNodeV2(Keys.F13); + public HotkeyNodeV2 ConfirmEditorPlacementHotkey { get; set; } = new HotkeyNodeV2(Keys.Enter); [JsonIgnore] [ConditionalDisplay(nameof(IsSearchRunning), false)] @@ -516,4 +516,4 @@ public enum SearchState Empty, Searching, Stopped, -} \ No newline at end of file +} diff --git a/Icons.cs b/Icons.cs index 2a3467c..12c15fe 100644 --- a/Icons.cs +++ b/Icons.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using ExileCore2.Shared.Enums; using ExpeditionIcons.PathPlannerData; @@ -246,6 +246,7 @@ public static IExpeditionRelic GetRelicType(string relicMod, PlannerSettings pla "Metadata/Terrain/Doodads/Leagues/Expedition/ChestMarkers/ChestRitual.ao" }, }, + /* new() { IconPickerIndex = IconPickerIndex.MetamorphChest, @@ -255,6 +256,7 @@ public static IExpeditionRelic GetRelicType(string relicMod, PlannerSettings pla "Metadata/Terrain/Doodads/Leagues/Expedition/ChestMarkers/ChestMetamorph.ao" }, }, + */ new() { IconPickerIndex = IconPickerIndex.MapsChest, diff --git a/PathPlanner.cs b/PathPlanner.cs index 499efe7..42d941b 100644 --- a/PathPlanner.cs +++ b/PathPlanner.cs @@ -253,6 +253,7 @@ public void Init(ExpeditionEnvironment environment) RunicMonster => environment.IsLogbook ? _settings.RunicMonsterLogbookWeight : _settings.RunicMonsterWeight, Chest { Type: var type } => _settings.ChestSettingsMap.GetValueOrDefault(type, new ChestSettings()).Weight, NormalMonster => _settings.NormalMonsterWeight, + _ => 0, }; } @@ -331,4 +332,4 @@ private List BuildPath(ExpeditionEnvironment environment) return path; } -} \ No newline at end of file +} diff --git a/PathPlannerRunner.cs b/PathPlannerRunner.cs index a4c83b2..9891319 100644 --- a/PathPlannerRunner.cs +++ b/PathPlannerRunner.cs @@ -14,6 +14,8 @@ namespace ExpeditionIcons; public class PathPlannerRunner { + public const string SoundId = "ExpeditionIcons:attention"; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public bool IsRunning => _task is { IsCompleted: false }; private PathPlanner _pathPlanner; @@ -99,9 +101,9 @@ private async Task Run(PlannerSettings settings, ExpeditionEnvironment environme finally { DebugWindow.LogMsg("ExpeditionIcons PathPlanner finished."); - if (settings.PlaySoundOnFinish) + if (settings.PlaySoundOnFinish && !_cts.IsCancellationRequested) { - soundController.PlaySound("expedition_attention"); + soundController.PlaySound(SoundId); } _ = CurrentBestPath; @@ -113,4 +115,4 @@ private async Task Run(PlannerSettings settings, ExpeditionEnvironment environme public void Stop() => _cts.Cancel(); } -public record BestValue(List Path, double Score, int Iteration, double LastGenerationTime); \ No newline at end of file +public record BestValue(List Path, double Score, int Iteration, double LastGenerationTime); diff --git a/README.md b/README.md index 80980e7..a5a3d7a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,33 @@ -# ExpeditionIcons +# ExpeditionIcons — PoE2 -If you like it, you can donate via: +Expedition encounter overlay and optional path planner for ExileCore2. -BTC: bc1qke67907s6d5k3cm7lx7m020chyjp9e8ysfwtuz +## What it does -ETH: 0x3A37B3f57453555C2ceabb1a2A4f55E0eB969105 +- Classifies PoE2 Expedition markers, chests, relics, detonator state, and + encounter metadata. +- Draws world icons, labels, explosive radii, minimap/large-map markers, and + optional planner routes. +- Scores routes with user-configured terrain, distance, and loot weights. + +## Logic + +`Initialise` loads icons/settings and registers lifecycle handlers. `Tick` +refreshes encounter state and map geometry. A planner request snapshots the +current encounter and runs on a cancellable worker; a validated result is +published back to the main thread. `Render` consumes the snapshot and draws the +icons/radii/route. Area changes, disable, hot reload, and dispose cancel workers +and clear stale state. It is read-only and performs no game input. + +Entity reads fail closed when ExileCore2 has not populated an `EntityType` bucket +yet, which is common during startup and area transitions. Cancelling a planner +also suppresses its completion sound, so stopping a search or unloading the +plugin cannot report a false successful route. + +## Status + +Build: **PASS**. Classification: **CURRENT_WITH_WARNINGS**; a live current-league +Expedition and logbook test is still required. + +Detailed report: [PoE2 plugin catalog](../../README.md) · +[audit](../../../docs/plugins/ExpeditionIcons/AUDIT.md).