using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// /// Session gravestone stacks keyed by world tile. Drawn via QuantumSprites; click opens Lore. /// public static class GraveMarkers { public const int PerStackSoftCap = 40; public const string QuantumAssetId = "idle_spectator_gravestones"; /// World skull size - small tile marker, still hover/clickable. public const float SkullBaseScale = 0.055f; public sealed class GraveEntry { public long UnitId; public string DisplayName = ""; public string MannerLabel = ""; public float ExpireAt; public bool Favorite; public float CreatedAt; } public sealed class GraveStack { public int TileX; public int TileY; public Vector3 WorldPos; public readonly List Entries = new List(); public float LastActivityAt; public bool HasFavorite; /// Kept via Lore Keep button - skips TTL; gold skull. public bool Permanent; public long Key => PackKey(TileX, TileY); } private static readonly Dictionary Stacks = new Dictionary(); private static readonly List EvictScratch = new List(64); private static Sprite _skullSprite; private static bool _assetRegistered; private static Color _tempTint = new Color(0.85f, 0.88f, 0.92f, 0.95f); private static Color _permTint = new Color(0.92f, 0.78f, 0.42f, 0.98f); public static int StackCount { get { lock (Stacks) { return Stacks.Count; } } } public static void Clear() { lock (Stacks) { Stacks.Clear(); } } public static void Update() { if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled) { if (StackCount > 0) { Clear(); } return; } EnsureQuantumAsset(); Prune(); GraveHover.Update(); // Stack size is in the Lore title (Grave ยท N); no world count badges. GraveCountBadges.Hide(); } public static void AddDeath(Actor victim) { if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled || victim == null) { return; } if (ModSettings.GravestoneMaxStacks <= 0) { return; } long unitId = 0; string name = "Nameless"; bool favorite = false; Vector3 pos = Vector3.zero; int tileX = 0; int tileY = 0; try { unitId = victim.getID(); name = victim.getName(); if (string.IsNullOrEmpty(name)) { name = "Nameless"; } favorite = victim.isFavorite(); if (victim.current_tile != null) { tileX = victim.current_tile.x; tileY = victim.current_tile.y; pos = victim.current_tile.posV3; } else { pos = victim.current_position; tileX = Mathf.FloorToInt(pos.x); tileY = Mathf.FloorToInt(pos.y); } } catch { return; } if (unitId == 0) { return; } string mannerLabel = Chronicle.FormatGraveDeathSubtitle(unitId); AddEntry(tileX, tileY, pos, unitId, name, mannerLabel, favorite); } /// Harness / orphan path: place a grave entry at an explicit tile. public static bool ForceAdd( long unitId, string displayName, int tileX, int tileY, Vector3 worldPos, string mannerLabel = "", bool favorite = false) { if (unitId == 0 || ModSettings.GravestoneMaxStacks <= 0) { return false; } if (worldPos.sqrMagnitude < 0.0001f) { worldPos = new Vector3(tileX + 0.5f, tileY + 0.5f, 0f); } AddEntry( tileX, tileY, worldPos, unitId, string.IsNullOrEmpty(displayName) ? "Nameless" : displayName, mannerLabel ?? "", favorite); return true; } public static int EntryCountAt(int tileX, int tileY) { long key = PackKey(tileX, tileY); lock (Stacks) { return Stacks.TryGetValue(key, out GraveStack stack) ? stack.Entries.Count : 0; } } public static bool TryGetStack(int tileX, int tileY, out GraveStack stack) { long key = PackKey(tileX, tileY); lock (Stacks) { if (Stacks.TryGetValue(key, out stack)) { return true; } } stack = null; return false; } public static bool TryGetStackAtWorld(Vector3 worldPos, out GraveStack stack) { int tileX = Mathf.FloorToInt(worldPos.x); int tileY = Mathf.FloorToInt(worldPos.y); return TryGetStack(tileX, tileY, out stack); } public static GraveStack FindNearestStack(Vector2 worldPos, float maxDist) { float maxSq = maxDist * maxDist; GraveStack best = null; float bestSq = maxSq; lock (Stacks) { foreach (GraveStack stack in Stacks.Values) { float dx = stack.WorldPos.x - worldPos.x; float dy = stack.WorldPos.y - worldPos.y; float sq = dx * dx + dy * dy; if (sq <= bestSq) { bestSq = sq; best = stack; } } } return best; } /// Newest matching grave entry by display name (any stack). public static bool TryFindEntryByName(string nameNeedle, out GraveEntry entry, out GraveStack stack) { entry = null; stack = null; if (string.IsNullOrEmpty(nameNeedle)) { return false; } lock (Stacks) { foreach (GraveStack s in Stacks.Values) { for (int i = s.Entries.Count - 1; i >= 0; i--) { GraveEntry e = s.Entries[i]; if (e.DisplayName != null && e.DisplayName.IndexOf(nameNeedle, System.StringComparison.OrdinalIgnoreCase) >= 0) { entry = e; stack = s; return true; } } } } return false; } public static void CopyStacks(List into) { into.Clear(); lock (Stacks) { foreach (GraveStack stack in Stacks.Values) { into.Add(stack); } } } public static void SnapshotEntries(GraveStack stack, List into) { into.Clear(); if (stack == null) { return; } lock (Stacks) { for (int i = 0; i < stack.Entries.Count; i++) { into.Add(stack.Entries[i]); } } } public static Color TempTint => _tempTint; public static Color PermTint => _permTint; public static bool TrySetStackPermanent(int tileX, int tileY, bool permanent) { long key = PackKey(tileX, tileY); float now = Time.unscaledTime; float ttl = ModSettings.GravestoneTtlSeconds; lock (Stacks) { if (!Stacks.TryGetValue(key, out GraveStack stack)) { return false; } stack.Permanent = permanent; stack.LastActivityAt = now; for (int i = 0; i < stack.Entries.Count; i++) { stack.Entries[i].ExpireAt = permanent ? float.PositiveInfinity : now + ttl; } return true; } } public static bool IsStackPermanent(int tileX, int tileY) { long key = PackKey(tileX, tileY); lock (Stacks) { return Stacks.TryGetValue(key, out GraveStack stack) && stack.Permanent; } } /// Remove one tile stack (Delete in Lore). Returns false if missing. public static bool TryRemoveStack(int tileX, int tileY) { long key = PackKey(tileX, tileY); lock (Stacks) { return Stacks.Remove(key); } } public static Sprite SkullSprite { get { if (_skullSprite == null) { _skullSprite = SpriteTextureLoader.getSprite("ui/Icons/iconSkulls") ?? SpriteTextureLoader.getSprite("ui/Icons/iconDead") ?? SpriteTextureLoader.getSprite("ui/icons/iconSkulls"); } return _skullSprite; } } public static long PackKey(int tileX, int tileY) { return ((long)tileX << 32) ^ (uint)tileY; } private static void AddEntry( int tileX, int tileY, Vector3 worldPos, long unitId, string displayName, string mannerLabel, bool favorite) { float now = Time.unscaledTime; float ttl = ModSettings.GravestoneTtlSeconds; long key = PackKey(tileX, tileY); lock (Stacks) { if (!Stacks.TryGetValue(key, out GraveStack stack)) { stack = new GraveStack { TileX = tileX, TileY = tileY, WorldPos = worldPos.sqrMagnitude > 0.0001f ? worldPos : new Vector3(tileX + 0.5f, tileY + 0.5f, 0f), LastActivityAt = now }; Stacks[key] = stack; } else if (worldPos.sqrMagnitude > 0.0001f) { stack.WorldPos = worldPos; } float expireAt = stack.Permanent ? float.PositiveInfinity : now + ttl; // Refresh existing entry for same unit if re-added. for (int i = 0; i < stack.Entries.Count; i++) { if (stack.Entries[i].UnitId == unitId) { stack.Entries.RemoveAt(i); break; } } stack.Entries.Add(new GraveEntry { UnitId = unitId, DisplayName = displayName, MannerLabel = mannerLabel ?? "", ExpireAt = expireAt, Favorite = favorite, CreatedAt = now }); stack.LastActivityAt = now; while (stack.Entries.Count > PerStackSoftCap) { stack.Entries.RemoveAt(0); } RefreshFavoriteFlag(stack); EnforceStackCapUnlocked(); } } private static void Prune() { float now = Time.unscaledTime; lock (Stacks) { EvictScratch.Clear(); foreach (KeyValuePair kv in Stacks) { GraveStack stack = kv.Value; if (!stack.Permanent) { for (int i = stack.Entries.Count - 1; i >= 0; i--) { if (stack.Entries[i].ExpireAt <= now) { stack.Entries.RemoveAt(i); } } } if (stack.Entries.Count == 0) { EvictScratch.Add(kv.Key); } else { RefreshFavoriteFlag(stack); } } for (int i = 0; i < EvictScratch.Count; i++) { Stacks.Remove(EvictScratch[i]); } EnforceStackCapUnlocked(); } } private static void EnforceStackCapUnlocked() { int max = ModSettings.GravestoneMaxStacks; if (max <= 0) { Stacks.Clear(); return; } while (Stacks.Count > max) { long victimKey = 0; float oldest = float.PositiveInfinity; bool foundNonFav = false; foreach (KeyValuePair kv in Stacks) { if (kv.Value.HasFavorite || kv.Value.Permanent) { continue; } if (kv.Value.LastActivityAt < oldest) { oldest = kv.Value.LastActivityAt; victimKey = kv.Key; foundNonFav = true; } } if (!foundNonFav) { oldest = float.PositiveInfinity; foreach (KeyValuePair kv in Stacks) { if (kv.Value.LastActivityAt < oldest) { oldest = kv.Value.LastActivityAt; victimKey = kv.Key; } } } if (victimKey == 0 && Stacks.Count > 0) { foreach (long k in Stacks.Keys) { victimKey = k; break; } } if (victimKey == 0) { break; } Stacks.Remove(victimKey); } } private static void RefreshFavoriteFlag(GraveStack stack) { bool fav = false; for (int i = 0; i < stack.Entries.Count; i++) { if (stack.Entries[i].Favorite) { fav = true; break; } } stack.HasFavorite = fav; } private static void EnsureQuantumAsset() { if (_assetRegistered) { EnsureGroupSystem(); return; } try { if (AssetManager.quantum_sprites == null) { return; } QuantumSpriteAsset existing = null; try { existing = AssetManager.quantum_sprites.get(QuantumAssetId); } catch { existing = null; } if (existing == null) { QuantumSpriteAsset asset = new QuantumSpriteAsset { id = QuantumAssetId, id_prefab = "p_gameSprite", base_scale = SkullBaseScale, render_gameplay = true, render_map = true, default_amount = 64, create_object = (QuantumSpriteAsset _, QuantumSprite q) => { Sprite sprite = SkullSprite; if (sprite != null) { q.setSprite(sprite); } try { q.sprite_renderer.sortingLayerID = SortingLayer.NameToID("Objects"); q.sprite_renderer.sortingOrder = 2; } catch { // sorting optional } }, draw_call = DrawGraves }; AssetManager.quantum_sprites.add(asset); LogService.LogInfo("[IdleSpectator] Registered gravestone QuantumSprite asset"); } _assetRegistered = true; EnsureGroupSystem(); } catch (System.Exception ex) { LogService.LogInfo($"[IdleSpectator] Gravestone QuantumSprite register failed: {ex.Message}"); } } private static void EnsureGroupSystem() { try { QuantumSpriteAsset asset = AssetManager.quantum_sprites.get(QuantumAssetId); if (asset == null) { return; } if (asset.group_system != null) { return; } QuantumSpriteGroupSystem system = new GameObject("IdleSpectatorGravesQS") .AddComponent(); system.create(asset); asset.group_system = system; asset.group_system.turn_off_renderer = asset.turn_off_renderer; } catch (System.Exception ex) { LogService.LogInfo($"[IdleSpectator] Gravestone group_system init failed: {ex.Message}"); } } private static void DrawGraves(QuantumSpriteAsset asset) { if (!ModSettings.Enabled || !ModSettings.GravestonesEnabled || asset?.group_system == null) { return; } Sprite sprite = SkullSprite; if (sprite == null) { return; } // Keep tiny even if an older asset instance kept a larger base_scale. asset.base_scale = SkullBaseScale; long highlightKey = GraveHover.HighlightStackKey; float pulse = 0.55f + 0.45f * Mathf.Abs(Mathf.Sin(Time.unscaledTime * 9f)); lock (Stacks) { foreach (GraveStack stack in Stacks.Values) { if (stack.Entries.Count == 0) { continue; } QuantumSprite qs = asset.group_system.getNext(); if (qs == null) { continue; } Vector3 pos = stack.WorldPos; pos.y += 0.12f; float scale = SkullBaseScale; if (stack.Entries.Count > 1) { scale *= Mathf.Min(1.25f, 1f + 0.02f * (stack.Entries.Count - 1)); } Color baseTint = stack.Permanent ? _permTint : _tempTint; Color tint = baseTint; bool highlight = highlightKey != 0 && stack.Key == highlightKey; if (highlight) { // Bright flicker so hover/selection is obvious on a tiny sprite. Color flash = stack.Permanent ? new Color(1f, 0.95f, 0.55f, 1f) : new Color(1f, 1f, 1f, 1f); tint = Color.Lerp(baseTint, flash, pulse); scale *= 1.12f + 0.1f * pulse; } qs.set(ref pos, scale); qs.setSprite(sprite); qs.setColor(ref tint); } } } }