diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md
index ebe1029..fd726b5 100644
--- a/.cursor/skills/idle-spectator-e2e/SKILL.md
+++ b/.cursor/skills/idle-spectator-e2e/SKILL.md
@@ -86,7 +86,7 @@ A loaded world is still required.
| `director_tiers` | Curiosity accept, Action interrupt, curiosity blocked by Action |
| `discovery` | Present dedupe + force drain + tip match |
| `world_log` | Story/Epic interest interrupt via collector |
-| `chronicle_smoke` | Life-event chronicle inject + HUD open/jump (not watch-notes) |
+| `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump |
| `smoke` | Short enable/focus health check |
| `tip_match` | Tip asset must match focused unit |
| `regression` | Shell suite of all gate scenarios above |
diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs
index 05322be..d153192 100644
--- a/IdleSpectator/ActorChroniclePatches.cs
+++ b/IdleSpectator/ActorChroniclePatches.cs
@@ -3,7 +3,7 @@ using HarmonyLib;
namespace IdleSpectator;
///
-/// Feeds Chronicle from real creature life events (kills, lovers, best friends).
+/// Feeds character History from kills, deaths, lovers, and best friends.
///
[HarmonyPatch]
public static class ActorChroniclePatches
@@ -15,6 +15,42 @@ public static class ActorChroniclePatches
Chronicle.NoteKill(__instance, pDeadUnit);
}
+ [HarmonyPatch(typeof(Actor), "die")]
+ [HarmonyPrefix]
+ public static void PrefixDie(Actor __instance, out Actor __state)
+ {
+ __state = null;
+ try
+ {
+ if (__instance == null)
+ {
+ return;
+ }
+
+ BaseSimObject attacker = __instance.attackedBy;
+ if (attacker != null && !attacker.isRekt() && attacker.isActor() && attacker != __instance)
+ {
+ __state = attacker.a;
+ }
+ }
+ catch
+ {
+ __state = null;
+ }
+ }
+
+ [HarmonyPatch(typeof(Actor), "die")]
+ [HarmonyPostfix]
+ public static void PostfixDie(Actor __instance, AttackType pType, bool pCountDeath, Actor __state)
+ {
+ if (!pCountDeath || __instance == null)
+ {
+ return;
+ }
+
+ Chronicle.NoteDeath(__instance, pType, __state);
+ }
+
[HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))]
[HarmonyPostfix]
public static void PostfixLovers(Actor __instance, Actor pTarget)
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 8cf41f2..a0b29ea 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -316,7 +316,7 @@ public static class AgentHarness
case "chronicle_note":
{
bool ok = Chronicle.ForceNoteCurrentFocus(
- string.IsNullOrEmpty(cmd.label) ? "Slew" : cmd.label);
+ string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label);
if (ok)
{
_cmdOk++;
@@ -326,7 +326,81 @@ public static class AgentHarness
_cmdFail++;
}
- Emit(cmd, ok, detail: $"chronicle_count={Chronicle.Count} caption={WatchCaption.LastHeadline}");
+ Emit(cmd, ok, detail:
+ $"history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}");
+ break;
+ }
+
+ case "chronicle_clear":
+ {
+ Chronicle.ClearSession();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: "chronicle_cleared");
+ break;
+ }
+
+ case "chronicle_world_force":
+ {
+ string label = !string.IsNullOrEmpty(cmd.label)
+ ? cmd.label
+ : (!string.IsNullOrEmpty(cmd.value) ? cmd.value : "War: harness vs test");
+ bool ok = Chronicle.ForceWorldNote(label);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"world_count={Chronicle.WorldCount} label={label}");
+ break;
+ }
+
+ case "chronicle_death_sample":
+ {
+ string typeName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "Age";
+ bool ok = Chronicle.ForceDeathSample(typeName);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ ChronicleEntry latest = Chronicle.Latest;
+ Emit(cmd, ok, detail: $"type={typeName} latest={(latest != null ? latest.Line : "")}");
+ break;
+ }
+
+ case "chronicle_tab":
+ {
+ string tab = (cmd.value ?? "").Trim().ToLowerInvariant();
+ if (tab == "world")
+ {
+ ChronicleHud.SetTab(ChronicleTab.World);
+ }
+ else
+ {
+ ChronicleHud.SetTab(ChronicleTab.History);
+ tab = "history";
+ }
+
+ Chronicle.ShowHud();
+ bool ok = ChronicleHud.ActiveTab.ToString().Equals(tab, System.StringComparison.OrdinalIgnoreCase);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"tab={ChronicleHud.ActiveTab}");
break;
}
@@ -343,7 +417,7 @@ public static class AgentHarness
_cmdFail++;
}
- Emit(cmd, open, detail: $"hud={open}");
+ Emit(cmd, open, detail: $"hud={open} tab={ChronicleHud.ActiveTab}");
break;
}
@@ -989,17 +1063,23 @@ public static class AgentHarness
case "chronicle_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
- int have = Chronicle.Count;
+ int have = Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId());
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
{
pass = have >= want;
- detail = $"count={have} min={want}";
+ detail = $"history_count={have} min={want}";
+ }
+ else if (cmp == "world")
+ {
+ have = Chronicle.WorldCount;
+ pass = have >= want;
+ detail = $"world_count={have} min={want}";
}
else
{
pass = have == want;
- detail = $"count={have} want={want}";
+ detail = $"history_count={have} want={want}";
}
break;
@@ -1020,6 +1100,25 @@ public static class AgentHarness
detail = $"latest='{line}' needle='{needle}'";
break;
}
+ case "chronicle_world_latest_contains":
+ {
+ string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
+ ChronicleEntry latest = Chronicle.LatestWorld;
+ string line = latest != null ? latest.Line : "";
+ pass = latest != null
+ && !string.IsNullOrEmpty(needle)
+ && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
+ detail = $"world_latest='{line}' needle='{needle}'";
+ break;
+ }
+ case "chronicle_tab":
+ {
+ string want = (cmd.value ?? "history").Trim().ToLowerInvariant();
+ string have = ChronicleHud.ActiveTab.ToString().ToLowerInvariant();
+ pass = have == want;
+ detail = $"tab={have} want={want}";
+ break;
+ }
default:
pass = false;
detail = "unknown expect: " + expect;
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
index 957dd67..ebc81a2 100644
--- a/IdleSpectator/Chronicle.cs
+++ b/IdleSpectator/Chronicle.cs
@@ -6,6 +6,7 @@ namespace IdleSpectator;
public enum ChronicleKind
{
+ Death,
Kill,
Lover,
Friend,
@@ -13,62 +14,176 @@ public enum ChronicleKind
Other
}
+public enum ChronicleTab
+{
+ History,
+ World
+}
+
public sealed class ChronicleEntry
{
public float CreatedAt;
public ChronicleKind Kind;
- public long UnitId;
+ public long SubjectId;
+ public long OtherId;
public string Name = "";
public string SpeciesId = "";
public string Line = "";
public Vector3 Position;
+
+ /// Back-compat for harness / jump helpers.
+ public long UnitId => SubjectId;
}
///
-/// Session ring buffer of notable life/world events (kills, lovers, WorldLog), not spectator watch notes.
+/// Session chronicles: per-character History + separate World feed (WorldLog Story/Epic).
///
public static class Chronicle
{
public const KeyCode ToggleKey = KeyCode.F9;
- private const int MaxEntries = 300;
+ private const int MaxHistoryPerUnit = 40;
+ private const int MaxWorldEntries = 100;
- private static readonly List Entries = new List();
+ private static readonly Dictionary> Histories =
+ new Dictionary>();
+ private static readonly List WorldEntries = new List();
private static readonly HashSet LoverPairKeys = new HashSet();
private static readonly HashSet FriendPairKeys = new HashSet();
+ private static readonly HashSet DeathLogged = new HashSet();
private static bool _hudReady;
+ private static long _lastHistorySubjectId;
- public static int Count
+ public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + WorldCount;
+
+ public static int WorldCount
{
get
{
- lock (Entries)
+ lock (WorldEntries)
{
- return Entries.Count;
+ return WorldEntries.Count;
}
}
}
public static bool HudVisible => ChronicleHud.Visible;
- public static IReadOnlyList Snapshot()
+ public static long LastHistorySubjectId => _lastHistorySubjectId;
+
+ public static int HistoryCountFor(long subjectId)
{
- lock (Entries)
+ if (subjectId == 0)
{
- return Entries.ToArray();
+ return 0;
+ }
+
+ lock (Histories)
+ {
+ return Histories.TryGetValue(subjectId, out List list) ? list.Count : 0;
}
}
+ public static IReadOnlyList SnapshotForFocus()
+ {
+ return SnapshotForSubject(CurrentHistorySubjectId());
+ }
+
+ public static IReadOnlyList SnapshotForSubject(long subjectId)
+ {
+ if (subjectId == 0)
+ {
+ return System.Array.Empty();
+ }
+
+ lock (Histories)
+ {
+ if (!Histories.TryGetValue(subjectId, out List list))
+ {
+ return System.Array.Empty();
+ }
+
+ return list.ToArray();
+ }
+ }
+
+ public static IReadOnlyList SnapshotWorld()
+ {
+ lock (WorldEntries)
+ {
+ return WorldEntries.ToArray();
+ }
+ }
+
+ /// Latest history entry for the current/last focus (harness + jump).
public static ChronicleEntry Latest
{
get
{
- lock (Entries)
+ IReadOnlyList list = SnapshotForFocus();
+ return list.Count == 0 ? null : list[list.Count - 1];
+ }
+ }
+
+ public static ChronicleEntry LatestWorld
+ {
+ get
+ {
+ lock (WorldEntries)
{
- return Entries.Count == 0 ? null : Entries[Entries.Count - 1];
+ return WorldEntries.Count == 0 ? null : WorldEntries[WorldEntries.Count - 1];
}
}
}
+ public static long CurrentHistorySubjectId()
+ {
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ try
+ {
+ long id = MoveCamera._focus_unit.getID();
+ if (id != 0)
+ {
+ _lastHistorySubjectId = id;
+ return id;
+ }
+ }
+ catch
+ {
+ // fall through
+ }
+ }
+
+ return _lastHistorySubjectId;
+ }
+
+ public static string CurrentHistoryTitle()
+ {
+ long id = CurrentHistorySubjectId();
+ if (id == 0)
+ {
+ return "";
+ }
+
+ Actor unit = FindUnitById(id);
+ if (unit != null)
+ {
+ return $"{SafeName(unit)} ({SpeciesOf(unit)})";
+ }
+
+ IReadOnlyList list = SnapshotForSubject(id);
+ if (list.Count > 0)
+ {
+ ChronicleEntry e = list[list.Count - 1];
+ if (!string.IsNullOrEmpty(e.Name))
+ {
+ return string.IsNullOrEmpty(e.SpeciesId) ? e.Name : $"{e.Name} ({e.SpeciesId})";
+ }
+ }
+
+ return "Unit #" + id;
+ }
+
public static void EnsureWindow()
{
if (!ModSettings.ChronicleEnabled)
@@ -87,7 +202,7 @@ public static class Chronicle
if (ChronicleHud.IsReady)
{
_hudReady = true;
- LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, life-event side panel)");
+ LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)");
}
}
catch (System.Exception ex)
@@ -98,13 +213,20 @@ public static class Chronicle
public static void ClearSession()
{
- lock (Entries)
+ lock (Histories)
{
- Entries.Clear();
+ Histories.Clear();
+ }
+
+ lock (WorldEntries)
+ {
+ WorldEntries.Clear();
}
LoverPairKeys.Clear();
FriendPairKeys.Clear();
+ DeathLogged.Clear();
+ _lastHistorySubjectId = 0;
}
public static void PollInput()
@@ -155,7 +277,7 @@ public static class Chronicle
return false;
}
- Actor unit = FindUnitById(entry.UnitId);
+ Actor unit = FindUnitById(entry.SubjectId);
if (unit != null && unit.isAlive())
{
CameraDirector.FocusUnit(unit);
@@ -163,6 +285,17 @@ public static class Chronicle
return true;
}
+ if (entry.OtherId != 0)
+ {
+ Actor other = FindUnitById(entry.OtherId);
+ if (other != null && other.isAlive())
+ {
+ CameraDirector.FocusUnit(other);
+ WatchCaption.SetFromActor(other);
+ return true;
+ }
+ }
+
if (entry.Position != Vector3.zero)
{
CameraDirector.PanTo(entry.Position);
@@ -179,9 +312,27 @@ public static class Chronicle
return;
}
+ // Keep last history subject sticky when focus dies / drops briefly.
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ try
+ {
+ long id = MoveCamera._focus_unit.getID();
+ if (id != 0)
+ {
+ _lastHistorySubjectId = id;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
ChronicleHud.UpdateLive();
}
+ /// Killer POV - character history only.
public static void NoteKill(Actor killer, Actor victim)
{
if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
@@ -189,14 +340,38 @@ public static class Chronicle
return;
}
- string killerName = SafeName(killer);
- string victimName = SafeName(victim);
- string killerSpecies = SpeciesOf(killer);
- string victimSpecies = SpeciesOf(victim);
- Append(
+ AppendHistory(
ChronicleKind.Kill,
killer,
- $"Slew: {killerName} ({killerSpecies}) felled {victimName} ({victimSpecies})");
+ victim,
+ $"Killed {SafeName(victim)} ({SpeciesOf(victim)})");
+ }
+
+ /// Victim POV - cause of death on character history.
+ public static void NoteDeath(Actor victim, AttackType attackType, Actor killer)
+ {
+ if (!ModSettings.ChronicleEnabled || victim == null)
+ {
+ return;
+ }
+
+ long id = 0;
+ try
+ {
+ id = victim.getID();
+ }
+ catch
+ {
+ return;
+ }
+
+ if (id == 0 || !DeathLogged.Add(id))
+ {
+ return;
+ }
+
+ string line = FormatDeathCause(victim, attackType, killer);
+ AppendHistory(ChronicleKind.Death, victim, killer, line);
}
public static void NoteLovers(Actor a, Actor b)
@@ -212,10 +387,8 @@ public static class Chronicle
return;
}
- Append(
- ChronicleKind.Lover,
- a,
- $"Lovers: {SafeName(a)} and {SafeName(b)}");
+ AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}");
+ AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}");
}
public static void NoteBestFriends(Actor a, Actor b)
@@ -231,10 +404,8 @@ public static class Chronicle
return;
}
- Append(
- ChronicleKind.Friend,
- a,
- $"Friends: {SafeName(a)} befriended {SafeName(b)}");
+ AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}");
+ AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}");
}
public static void NoteWorldLog(WorldLogMessage message, string label)
@@ -267,15 +438,18 @@ public static class Chronicle
// ignore
}
- Append(
- ChronicleKind.World,
- unit,
- $"World: {label}",
- fallbackPosition: pos);
+ string line = label;
+ AppendWorld(unit, line, pos);
+
+ // Mirror onto that unit's History when WorldLog points at someone.
+ if (unit != null)
+ {
+ AppendHistory(ChronicleKind.World, unit, null, line);
+ }
}
- /// Harness: inject a life-event style line for the current focus.
- public static bool ForceNoteCurrentFocus(string prefix = "Slew")
+ /// Harness: inject a life-event style line on the focused unit's History.
+ public static bool ForceNoteCurrentFocus(string prefix = "Killed")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
@@ -284,15 +458,162 @@ public static class Chronicle
Actor unit = MoveCamera._focus_unit;
UnitDossier dossier = UnitDossier.FromActor(unit);
- string kindPrefix = string.IsNullOrEmpty(prefix) ? "Slew" : prefix;
- Append(
+ string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix;
+ AppendHistory(
ChronicleKind.Other,
unit,
+ null,
$"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event");
return true;
}
- private static void Append(ChronicleKind kind, Actor unit, string line, Vector3 fallbackPosition = default)
+ /// Harness: inject a World-feed line (does not touch character History).
+ public static bool ForceWorldNote(string label)
+ {
+ if (string.IsNullOrEmpty(label))
+ {
+ return false;
+ }
+
+ AppendWorld(null, label, Vector3.zero);
+ return true;
+ }
+
+ /// Harness: append a formatted death-cause line for the focused unit.
+ public static bool ForceDeathSample(string attackTypeName, Actor killer = null)
+ {
+ if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
+ {
+ return false;
+ }
+
+ if (!System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType attackType))
+ {
+ return false;
+ }
+
+ Actor victim = MoveCamera._focus_unit;
+ string line = FormatDeathCause(victim, attackType, killer);
+ AppendHistory(ChronicleKind.Death, victim, killer, line);
+ return true;
+ }
+
+ public static string FormatDeathCause(Actor victim, AttackType attackType, Actor killer)
+ {
+ string killerLabel = killer != null
+ ? $"{SafeName(killer)} ({SpeciesOf(killer)})"
+ : "";
+
+ switch (attackType)
+ {
+ case AttackType.Age:
+ return "Died of old age";
+ case AttackType.Starvation:
+ return "Starved to death";
+ case AttackType.Fire:
+ return IsOnLava(victim) ? "Burned in lava" : "Burned to death";
+ case AttackType.Drowning:
+ case AttackType.Water:
+ return "Drowned";
+ case AttackType.Poison:
+ return "Died of poison";
+ case AttackType.Plague:
+ return "Died of plague";
+ case AttackType.Infection:
+ return "Died of infection";
+ case AttackType.Tumor:
+ return "Died of a tumor";
+ case AttackType.Acid:
+ return "Dissolved in acid";
+ case AttackType.AshFever:
+ return "Died of ash fever";
+ case AttackType.Gravity:
+ return "Fell to their death";
+ case AttackType.Explosion:
+ return "Died in an explosion";
+ case AttackType.Divine:
+ return "Struck down by divine power";
+ case AttackType.Eaten:
+ return string.IsNullOrEmpty(killerLabel)
+ ? "Was eaten"
+ : $"Eaten by {killerLabel}";
+ case AttackType.Weapon:
+ case AttackType.Other:
+ case AttackType.Smile:
+ if (!string.IsNullOrEmpty(killerLabel))
+ {
+ return $"Killed by {killerLabel}";
+ }
+
+ return attackType == AttackType.Weapon ? "Killed in combat" : "Died";
+ case AttackType.Metamorphosis:
+ return "Transformed away";
+ case AttackType.None:
+ return "Died";
+ default:
+ return string.IsNullOrEmpty(killerLabel)
+ ? $"Died ({attackType})"
+ : $"Killed by {killerLabel}";
+ }
+ }
+
+ private static bool IsOnLava(Actor victim)
+ {
+ try
+ {
+ WorldTile tile = victim.current_tile;
+ return tile != null && tile.Type != null && tile.Type.lava;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static void AppendHistory(ChronicleKind kind, Actor subject, Actor other, string line)
+ {
+ if (subject == null || string.IsNullOrEmpty(line))
+ {
+ return;
+ }
+
+ long subjectId = subject.getID();
+ if (subjectId == 0)
+ {
+ return;
+ }
+
+ ChronicleEntry entry = new ChronicleEntry
+ {
+ CreatedAt = Time.unscaledTime,
+ Kind = kind,
+ SubjectId = subjectId,
+ OtherId = other != null ? other.getID() : 0,
+ Name = SafeName(subject),
+ SpeciesId = SpeciesOf(subject),
+ Line = line,
+ Position = subject.current_position
+ };
+
+ lock (Histories)
+ {
+ if (!Histories.TryGetValue(subjectId, out List list))
+ {
+ list = new List();
+ Histories[subjectId] = list;
+ }
+
+ list.Add(entry);
+ while (list.Count > MaxHistoryPerUnit)
+ {
+ list.RemoveAt(0);
+ }
+ }
+
+ LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {line}");
+ }
+
+ private static void AppendWorld(Actor unit, string line, Vector3 fallbackPosition)
{
if (string.IsNullOrEmpty(line))
{
@@ -302,24 +623,25 @@ public static class Chronicle
ChronicleEntry entry = new ChronicleEntry
{
CreatedAt = Time.unscaledTime,
- Kind = kind,
- UnitId = unit != null ? unit.getID() : 0,
+ Kind = ChronicleKind.World,
+ SubjectId = unit != null ? unit.getID() : 0,
+ OtherId = 0,
Name = unit != null ? SafeName(unit) : "",
SpeciesId = unit != null ? SpeciesOf(unit) : "",
Line = line,
Position = unit != null ? unit.current_position : fallbackPosition
};
- lock (Entries)
+ lock (WorldEntries)
{
- Entries.Add(entry);
- while (Entries.Count > MaxEntries)
+ WorldEntries.Add(entry);
+ while (WorldEntries.Count > MaxWorldEntries)
{
- Entries.RemoveAt(0);
+ WorldEntries.RemoveAt(0);
}
}
- LogService.LogInfo("[IdleSpectator][CHRONICLE] " + line);
+ LogService.LogInfo("[IdleSpectator][CHRONICLE][World] " + line);
}
private static string PairKey(long a, long b)
diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs
index aab1b1e..8a711c8 100644
--- a/IdleSpectator/ChronicleHud.cs
+++ b/IdleSpectator/ChronicleHud.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using NeoModLoader.General;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.Events;
@@ -8,25 +7,34 @@ using UnityEngine.UI;
namespace IdleSpectator;
///
-/// Non-modal right-side chronicle HUD. Works with focus/spectator - does not open a ScrollWindow.
-/// Styled as a compact event feed (inspired by vanilla tip/tooltip denseness, not a modal unit sheet).
+/// Compact bottom-right chronicle card with History | World tabs.
///
public static class ChronicleHud
{
- private const float PanelWidth = 300f;
- private const int MaxVisibleRows = 14;
+ private const float PanelWidth = 248f;
+ private const float PanelHeight = 178f;
+ private const int MaxVisibleRows = 5;
+ private const float RowHeight = 20f;
private static GameObject _root;
private static RectTransform _content;
- private static Text _header;
+ private static Text _titleText;
+ private static Text _historyTabLabel;
+ private static Text _worldTabLabel;
+ private static Image _historyTabBg;
+ private static Image _worldTabBg;
private static readonly List Rows = new List();
private static bool _visible;
private static int _lastCount = -1;
+ private static long _lastSubjectId = -1;
+ private static ChronicleTab _tab = ChronicleTab.History;
public static bool Visible => _visible && _root != null && _root.activeSelf;
public static bool IsReady => _root != null;
+ public static ChronicleTab ActiveTab => _tab;
+
public static void EnsureBuilt()
{
if (_root != null)
@@ -39,7 +47,7 @@ public static class ChronicleHud
return;
}
- Canvas canvas = ResolveCanvas();
+ Canvas canvas = HudCanvas.Resolve();
if (canvas == null)
{
return;
@@ -49,50 +57,54 @@ public static class ChronicleHud
_root.transform.SetParent(canvas.transform, false);
RectTransform rt = _root.GetComponent();
- rt.anchorMin = new Vector2(1f, 0.5f);
- rt.anchorMax = new Vector2(1f, 0.5f);
- rt.pivot = new Vector2(1f, 0.5f);
- rt.sizeDelta = new Vector2(PanelWidth, 440f);
- rt.anchoredPosition = new Vector2(-10f, 0f);
+ rt.anchorMin = new Vector2(1f, 0f);
+ rt.anchorMax = new Vector2(1f, 0f);
+ rt.pivot = new Vector2(1f, 0f);
+ rt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
+ rt.anchoredPosition = new Vector2(-12f, 12f);
Image bg = _root.GetComponent();
- bg.color = new Color(0.05f, 0.06f, 0.08f, 0.82f);
+ bg.color = new Color(0.05f, 0.06f, 0.08f, 0.65f);
bg.raycastTarget = true;
CanvasGroup group = _root.GetComponent();
group.blocksRaycasts = true;
group.interactable = true;
- _header = MakeText(_root.transform, "Header", "Chronicle · F9", 12);
- RectTransform headerRt = _header.GetComponent();
- headerRt.anchorMin = new Vector2(0f, 1f);
- headerRt.anchorMax = new Vector2(1f, 1f);
- headerRt.pivot = new Vector2(0.5f, 1f);
- headerRt.sizeDelta = new Vector2(-16f, 24f);
- headerRt.anchoredPosition = new Vector2(0f, -8f);
- _header.fontStyle = FontStyle.Bold;
- _header.alignment = TextAnchor.MiddleLeft;
- _header.color = new Color(0.92f, 0.9f, 0.82f, 1f);
+ BuildTabButton(_root.transform, "HistoryTab", "History", -6f, 0f, out _historyTabBg, out _historyTabLabel,
+ () => SetTab(ChronicleTab.History));
+ BuildTabButton(_root.transform, "WorldTab", "World", -6f, 72f, out _worldTabBg, out _worldTabLabel,
+ () => SetTab(ChronicleTab.World));
- Text sub = MakeText(_root.transform, "Sub", "Life & world events · click to jump", 9);
- RectTransform subRt = sub.GetComponent();
- subRt.anchorMin = new Vector2(0f, 1f);
- subRt.anchorMax = new Vector2(1f, 1f);
- subRt.pivot = new Vector2(0.5f, 1f);
- subRt.sizeDelta = new Vector2(-16f, 16f);
- subRt.anchoredPosition = new Vector2(0f, -30f);
- sub.alignment = TextAnchor.MiddleLeft;
- sub.color = new Color(0.65f, 0.68f, 0.72f, 1f);
+ Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 9);
+ RectTransform f9Rt = f9.GetComponent();
+ f9Rt.anchorMin = new Vector2(1f, 1f);
+ f9Rt.anchorMax = new Vector2(1f, 1f);
+ f9Rt.pivot = new Vector2(1f, 1f);
+ f9Rt.sizeDelta = new Vector2(28f, 16f);
+ f9Rt.anchoredPosition = new Vector2(-8f, -8f);
+ f9.alignment = TextAnchor.MiddleRight;
+ f9.color = new Color(0.55f, 0.58f, 0.62f, 1f);
+
+ _titleText = HudCanvas.MakeText(_root.transform, "Title", "", 9);
+ RectTransform titleRt = _titleText.GetComponent();
+ titleRt.anchorMin = new Vector2(0f, 1f);
+ titleRt.anchorMax = new Vector2(1f, 1f);
+ titleRt.pivot = new Vector2(0.5f, 1f);
+ titleRt.sizeDelta = new Vector2(-14f, 16f);
+ titleRt.anchoredPosition = new Vector2(0f, -26f);
+ _titleText.alignment = TextAnchor.MiddleLeft;
+ _titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f);
GameObject scrollGo = new GameObject("Scroll", typeof(RectTransform), typeof(Image), typeof(ScrollRect), typeof(Mask));
scrollGo.transform.SetParent(_root.transform, false);
RectTransform scrollRt = scrollGo.GetComponent();
scrollRt.anchorMin = new Vector2(0f, 0f);
scrollRt.anchorMax = new Vector2(1f, 1f);
- scrollRt.offsetMin = new Vector2(8f, 10f);
- scrollRt.offsetMax = new Vector2(-8f, -50f);
+ scrollRt.offsetMin = new Vector2(6f, 6f);
+ scrollRt.offsetMax = new Vector2(-6f, -44f);
Image scrollBg = scrollGo.GetComponent();
- scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
+ scrollBg.color = new Color(0f, 0f, 0f, 0.05f);
Mask mask = scrollGo.GetComponent();
mask.showMaskGraphic = false;
@@ -111,7 +123,7 @@ public static class ChronicleHud
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
- layout.spacing = 2f;
+ layout.spacing = 1f;
layout.padding = new RectOffset(0, 0, 0, 0);
ContentSizeFitter fitter = contentGo.GetComponent();
@@ -125,9 +137,17 @@ public static class ChronicleHud
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.viewport = scrollRt;
+ RefreshTabChrome();
_root.SetActive(false);
_visible = false;
- LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, non-modal side panel)");
+ LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)");
+ }
+
+ public static void SetTab(ChronicleTab tab)
+ {
+ _tab = tab;
+ RefreshTabChrome();
+ Rebuild(force: true);
}
public static void SetVisible(bool visible)
@@ -158,7 +178,12 @@ public static class ChronicleHud
return;
}
- if (Chronicle.Count != _lastCount)
+ long subjectId = Chronicle.CurrentHistorySubjectId();
+ int count = _tab == ChronicleTab.History
+ ? Chronicle.HistoryCountFor(subjectId)
+ : Chronicle.WorldCount;
+
+ if (count != _lastCount || subjectId != _lastSubjectId)
{
Rebuild(force: true);
}
@@ -173,15 +198,50 @@ public static class ChronicleHud
}
ClearRows();
- IReadOnlyList entries = Chronicle.Snapshot();
- _lastCount = entries.Count;
+ RefreshTabChrome();
- if (entries.Count == 0)
+ long subjectId = Chronicle.CurrentHistorySubjectId();
+ _lastSubjectId = subjectId;
+
+ IReadOnlyList entries;
+ if (_tab == ChronicleTab.World)
{
- GameObject empty = BuildStaticRow("Waiting for life events...");
- empty.transform.SetParent(_content, false);
- Rows.Add(empty);
- return;
+ entries = Chronicle.SnapshotWorld();
+ _lastCount = entries.Count;
+ if (_titleText != null)
+ {
+ _titleText.text = "World events";
+ }
+
+ if (entries.Count == 0)
+ {
+ AddEmpty("No world events yet");
+ return;
+ }
+ }
+ else
+ {
+ entries = Chronicle.SnapshotForFocus();
+ _lastCount = entries.Count;
+ string title = Chronicle.CurrentHistoryTitle();
+ if (_titleText != null)
+ {
+ _titleText.text = string.IsNullOrEmpty(title)
+ ? "Focus a creature to see history"
+ : title;
+ }
+
+ if (subjectId == 0)
+ {
+ AddEmpty("Focus a creature to see history");
+ return;
+ }
+
+ if (entries.Count == 0)
+ {
+ AddEmpty("No history yet");
+ return;
+ }
}
int shown = 0;
@@ -198,6 +258,78 @@ public static class ChronicleHud
}
}
+ private static void AddEmpty(string text)
+ {
+ GameObject empty = BuildStaticRow(text);
+ empty.transform.SetParent(_content, false);
+ Rows.Add(empty);
+ }
+
+ private static void BuildTabButton(
+ Transform parent,
+ string name,
+ string label,
+ float yFromTop,
+ float x,
+ out Image bg,
+ out Text text,
+ UnityAction onClick)
+ {
+ GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
+ go.transform.SetParent(parent, false);
+ RectTransform rt = go.GetComponent();
+ rt.anchorMin = new Vector2(0f, 1f);
+ rt.anchorMax = new Vector2(0f, 1f);
+ rt.pivot = new Vector2(0f, 1f);
+ rt.sizeDelta = new Vector2(68f, 16f);
+ rt.anchoredPosition = new Vector2(8f + x, yFromTop);
+
+ bg = go.GetComponent();
+ bg.color = new Color(0.15f, 0.16f, 0.2f, 0.9f);
+
+ text = HudCanvas.MakeText(go.transform, "Label", label, 9);
+ RectTransform textRt = text.GetComponent();
+ textRt.anchorMin = Vector2.zero;
+ textRt.anchorMax = Vector2.one;
+ textRt.offsetMin = Vector2.zero;
+ textRt.offsetMax = Vector2.zero;
+ text.alignment = TextAnchor.MiddleCenter;
+ text.raycastTarget = false;
+
+ Button button = go.GetComponent