- 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
688 lines
19 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|