worldbox-observer-mod/IdleSpectator/GraveMarkers.cs
DazedAnon c11b87aa2e Implement gravestone features and enhance Lore interactions.
- Add gravestone management with max stack and lifetime settings
- Introduce new commands for grave interactions in scenarios
- Update Lore handling to include gravestone context and details
- Refactor death cause formatting for improved readability
- Increment version to 0.28.11 in mod.json
2026-07-17 16:32:10 -05:00

688 lines
19 KiB
C#

using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Session gravestone stacks keyed by world tile. Drawn via QuantumSprites; click opens Lore.
/// </summary>
public static class GraveMarkers
{
public const int PerStackSoftCap = 40;
public const string QuantumAssetId = "idle_spectator_gravestones";
/// <summary>World skull size - small tile marker, still hover/clickable.</summary>
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<GraveEntry> Entries = new List<GraveEntry>();
public float LastActivityAt;
public bool HasFavorite;
/// <summary>Kept via Lore Keep button - skips TTL; gold skull.</summary>
public bool Permanent;
public long Key => PackKey(TileX, TileY);
}
private static readonly Dictionary<long, GraveStack> Stacks = new Dictionary<long, GraveStack>();
private static readonly List<long> EvictScratch = new List<long>(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);
}
/// <summary>Harness / orphan path: place a grave entry at an explicit tile.</summary>
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;
}
/// <summary>Newest matching grave entry by display name (any stack).</summary>
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<GraveStack> into)
{
into.Clear();
lock (Stacks)
{
foreach (GraveStack stack in Stacks.Values)
{
into.Add(stack);
}
}
}
public static void SnapshotEntries(GraveStack stack, List<GraveEntry> 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;
}
}
/// <summary>Remove one tile stack (Delete in Lore). Returns false if missing.</summary>
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<long, GraveStack> 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<long, GraveStack> 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<long, GraveStack> 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<QuantumSpriteGroupSystem>();
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);
}
}
}
}