worldbox-observer-mod/IdleSpectator/Chronicle.cs
2026-07-14 15:55:22 -05:00

372 lines
8.4 KiB
C#

using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
public enum ChronicleKind
{
Kill,
Lover,
Friend,
World,
Other
}
public sealed class ChronicleEntry
{
public float CreatedAt;
public ChronicleKind Kind;
public long UnitId;
public string Name = "";
public string SpeciesId = "";
public string Line = "";
public Vector3 Position;
}
/// <summary>
/// Session ring buffer of notable life/world events (kills, lovers, WorldLog), not spectator watch notes.
/// </summary>
public static class Chronicle
{
public const KeyCode ToggleKey = KeyCode.F9;
private const int MaxEntries = 300;
private static readonly List<ChronicleEntry> Entries = new List<ChronicleEntry>();
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
private static bool _hudReady;
public static int Count
{
get
{
lock (Entries)
{
return Entries.Count;
}
}
}
public static bool HudVisible => ChronicleHud.Visible;
public static IReadOnlyList<ChronicleEntry> Snapshot()
{
lock (Entries)
{
return Entries.ToArray();
}
}
public static ChronicleEntry Latest
{
get
{
lock (Entries)
{
return Entries.Count == 0 ? null : Entries[Entries.Count - 1];
}
}
}
public static void EnsureWindow()
{
if (!ModSettings.ChronicleEnabled)
{
return;
}
if (_hudReady && ChronicleHud.IsReady)
{
return;
}
try
{
ChronicleHud.EnsureBuilt();
if (ChronicleHud.IsReady)
{
_hudReady = true;
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, life-event side panel)");
}
}
catch (System.Exception ex)
{
LogService.LogInfo("[IdleSpectator] Chronicle HUD init failed: " + ex.Message);
}
}
public static void ClearSession()
{
lock (Entries)
{
Entries.Clear();
}
LoverPairKeys.Clear();
FriendPairKeys.Clear();
}
public static void PollInput()
{
if (!Config.game_loaded || !ModSettings.ChronicleEnabled)
{
return;
}
EnsureWindow();
if (Input.GetKeyDown(ToggleKey))
{
ToggleWindow();
}
}
public static void ToggleWindow()
{
if (!ModSettings.ChronicleEnabled)
{
return;
}
EnsureWindow();
ChronicleHud.Toggle();
}
public static void ShowHud()
{
EnsureWindow();
ChronicleHud.SetVisible(true);
}
public static void HideHud()
{
ChronicleHud.SetVisible(false);
}
public static bool JumpToLatest()
{
return JumpTo(Latest);
}
public static bool JumpTo(ChronicleEntry entry)
{
if (entry == null)
{
return false;
}
Actor unit = FindUnitById(entry.UnitId);
if (unit != null && unit.isAlive())
{
CameraDirector.FocusUnit(unit);
WatchCaption.SetFromActor(unit);
return true;
}
if (entry.Position != Vector3.zero)
{
CameraDirector.PanTo(entry.Position);
return true;
}
return false;
}
public static void Update()
{
if (!ModSettings.ChronicleEnabled || !Config.game_loaded || World.world == null)
{
return;
}
ChronicleHud.UpdateLive();
}
public static void NoteKill(Actor killer, Actor victim)
{
if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
{
return;
}
string killerName = SafeName(killer);
string victimName = SafeName(victim);
string killerSpecies = SpeciesOf(killer);
string victimSpecies = SpeciesOf(victim);
Append(
ChronicleKind.Kill,
killer,
$"Slew: {killerName} ({killerSpecies}) felled {victimName} ({victimSpecies})");
}
public static void NoteLovers(Actor a, Actor b)
{
if (!ModSettings.ChronicleEnabled || a == null || b == null)
{
return;
}
string key = PairKey(a.getID(), b.getID());
if (!LoverPairKeys.Add(key))
{
return;
}
Append(
ChronicleKind.Lover,
a,
$"Lovers: {SafeName(a)} and {SafeName(b)}");
}
public static void NoteBestFriends(Actor a, Actor b)
{
if (!ModSettings.ChronicleEnabled || a == null || b == null)
{
return;
}
string key = PairKey(a.getID(), b.getID());
if (!FriendPairKeys.Add(key))
{
return;
}
Append(
ChronicleKind.Friend,
a,
$"Friends: {SafeName(a)} befriended {SafeName(b)}");
}
public static void NoteWorldLog(WorldLogMessage message, string label)
{
if (!ModSettings.ChronicleEnabled || message == null || string.IsNullOrEmpty(label))
{
return;
}
Actor unit = null;
try
{
if (message.hasFollowLocation())
{
unit = message.unit;
}
}
catch
{
// ignore
}
Vector3 pos = Vector3.zero;
try
{
pos = message.getLocation();
}
catch
{
// ignore
}
Append(
ChronicleKind.World,
unit,
$"World: {label}",
fallbackPosition: pos);
}
/// <summary>Harness: inject a life-event style line for the current focus.</summary>
public static bool ForceNoteCurrentFocus(string prefix = "Slew")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return false;
}
Actor unit = MoveCamera._focus_unit;
UnitDossier dossier = UnitDossier.FromActor(unit);
string kindPrefix = string.IsNullOrEmpty(prefix) ? "Slew" : prefix;
Append(
ChronicleKind.Other,
unit,
$"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event");
return true;
}
private static void Append(ChronicleKind kind, Actor unit, string line, Vector3 fallbackPosition = default)
{
if (string.IsNullOrEmpty(line))
{
return;
}
ChronicleEntry entry = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
Kind = kind,
UnitId = unit != null ? unit.getID() : 0,
Name = unit != null ? SafeName(unit) : "",
SpeciesId = unit != null ? SpeciesOf(unit) : "",
Line = line,
Position = unit != null ? unit.current_position : fallbackPosition
};
lock (Entries)
{
Entries.Add(entry);
while (Entries.Count > MaxEntries)
{
Entries.RemoveAt(0);
}
}
LogService.LogInfo("[IdleSpectator][CHRONICLE] " + line);
}
private static string PairKey(long a, long b)
{
long lo = a < b ? a : b;
long hi = a < b ? b : a;
return lo + ":" + hi;
}
private static Actor FindUnitById(long id)
{
if (id == 0)
{
return null;
}
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor != null && actor.getID() == id && actor.isAlive())
{
return actor;
}
}
return null;
}
private static string SpeciesOf(Actor actor)
{
return actor?.asset != null ? actor.asset.id : "creature";
}
private static string SafeName(Actor actor)
{
try
{
string name = actor.getName();
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
catch
{
// ignore
}
return SpeciesOf(actor);
}
}