Chronicle
This commit is contained in:
parent
43cc098ac9
commit
3d6d8f27f3
11 changed files with 1138 additions and 227 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ using HarmonyLib;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Feeds Chronicle from real creature life events (kills, lovers, best friends).
|
||||
/// Feeds character History from kills, deaths, lovers, and best friends.
|
||||
/// </summary>
|
||||
[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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>Back-compat for harness / jump helpers.</summary>
|
||||
public long UnitId => SubjectId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<ChronicleEntry> Entries = new List<ChronicleEntry>();
|
||||
private static readonly Dictionary<long, List<ChronicleEntry>> Histories =
|
||||
new Dictionary<long, List<ChronicleEntry>>();
|
||||
private static readonly List<ChronicleEntry> WorldEntries = new List<ChronicleEntry>();
|
||||
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
|
||||
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
|
||||
private static readonly HashSet<long> DeathLogged = new HashSet<long>();
|
||||
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<ChronicleEntry> 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<ChronicleEntry> list) ? list.Count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ChronicleEntry> SnapshotForFocus()
|
||||
{
|
||||
return SnapshotForSubject(CurrentHistorySubjectId());
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ChronicleEntry> SnapshotForSubject(long subjectId)
|
||||
{
|
||||
if (subjectId == 0)
|
||||
{
|
||||
return System.Array.Empty<ChronicleEntry>();
|
||||
}
|
||||
|
||||
lock (Histories)
|
||||
{
|
||||
if (!Histories.TryGetValue(subjectId, out List<ChronicleEntry> list))
|
||||
{
|
||||
return System.Array.Empty<ChronicleEntry>();
|
||||
}
|
||||
|
||||
return list.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ChronicleEntry> SnapshotWorld()
|
||||
{
|
||||
lock (WorldEntries)
|
||||
{
|
||||
return WorldEntries.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Latest history entry for the current/last focus (harness + jump).</summary>
|
||||
public static ChronicleEntry Latest
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Entries)
|
||||
IReadOnlyList<ChronicleEntry> 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<ChronicleEntry> 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();
|
||||
}
|
||||
|
||||
/// <summary>Killer POV - character history only.</summary>
|
||||
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)})");
|
||||
}
|
||||
|
||||
/// <summary>Victim POV - cause of death on character history.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness: inject a life-event style line for the current focus.</summary>
|
||||
public static bool ForceNoteCurrentFocus(string prefix = "Slew")
|
||||
/// <summary>Harness: inject a life-event style line on the focused unit's History.</summary>
|
||||
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)
|
||||
/// <summary>Harness: inject a World-feed line (does not touch character History).</summary>
|
||||
public static bool ForceWorldNote(string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AppendWorld(null, label, Vector3.zero);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Harness: append a formatted death-cause line for the focused unit.</summary>
|
||||
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<ChronicleEntry> list))
|
||||
{
|
||||
list = new List<ChronicleEntry>();
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<GameObject> Rows = new List<GameObject>();
|
||||
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<RectTransform>();
|
||||
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<Image>();
|
||||
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<CanvasGroup>();
|
||||
group.blocksRaycasts = true;
|
||||
group.interactable = true;
|
||||
|
||||
_header = MakeText(_root.transform, "Header", "Chronicle · F9", 12);
|
||||
RectTransform headerRt = _header.GetComponent<RectTransform>();
|
||||
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<RectTransform>();
|
||||
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<RectTransform>();
|
||||
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<RectTransform>();
|
||||
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<RectTransform>();
|
||||
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<Image>();
|
||||
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
|
||||
scrollBg.color = new Color(0f, 0f, 0f, 0.05f);
|
||||
Mask mask = scrollGo.GetComponent<Mask>();
|
||||
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<ContentSizeFitter>();
|
||||
|
|
@ -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<ChronicleEntry> entries = Chronicle.Snapshot();
|
||||
_lastCount = entries.Count;
|
||||
RefreshTabChrome();
|
||||
|
||||
if (entries.Count == 0)
|
||||
long subjectId = Chronicle.CurrentHistorySubjectId();
|
||||
_lastSubjectId = subjectId;
|
||||
|
||||
IReadOnlyList<ChronicleEntry> 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<RectTransform>();
|
||||
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<Image>();
|
||||
bg.color = new Color(0.15f, 0.16f, 0.2f, 0.9f);
|
||||
|
||||
text = HudCanvas.MakeText(go.transform, "Label", label, 9);
|
||||
RectTransform textRt = text.GetComponent<RectTransform>();
|
||||
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<Button>();
|
||||
button.onClick.AddListener(onClick);
|
||||
}
|
||||
|
||||
private static void RefreshTabChrome()
|
||||
{
|
||||
Color active = new Color(0.28f, 0.32f, 0.4f, 0.95f);
|
||||
Color idle = new Color(0.12f, 0.13f, 0.16f, 0.7f);
|
||||
Color activeText = new Color(0.95f, 0.93f, 0.88f, 1f);
|
||||
Color idleText = new Color(0.65f, 0.68f, 0.72f, 1f);
|
||||
|
||||
if (_historyTabBg != null)
|
||||
{
|
||||
_historyTabBg.color = _tab == ChronicleTab.History ? active : idle;
|
||||
}
|
||||
|
||||
if (_worldTabBg != null)
|
||||
{
|
||||
_worldTabBg.color = _tab == ChronicleTab.World ? active : idle;
|
||||
}
|
||||
|
||||
if (_historyTabLabel != null)
|
||||
{
|
||||
_historyTabLabel.color = _tab == ChronicleTab.History ? activeText : idleText;
|
||||
_historyTabLabel.fontStyle = _tab == ChronicleTab.History ? FontStyle.Bold : FontStyle.Normal;
|
||||
}
|
||||
|
||||
if (_worldTabLabel != null)
|
||||
{
|
||||
_worldTabLabel.color = _tab == ChronicleTab.World ? activeText : idleText;
|
||||
_worldTabLabel.fontStyle = _tab == ChronicleTab.World ? FontStyle.Bold : FontStyle.Normal;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearRows()
|
||||
{
|
||||
foreach (GameObject row in Rows)
|
||||
|
|
@ -215,15 +347,15 @@ public static class ChronicleHud
|
|||
{
|
||||
GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
|
||||
RectTransform rt = go.GetComponent<RectTransform>();
|
||||
rt.sizeDelta = new Vector2(PanelWidth - 24f, 26f);
|
||||
rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeight);
|
||||
LayoutElement le = go.GetComponent<LayoutElement>();
|
||||
le.minHeight = 26f;
|
||||
le.preferredHeight = 26f;
|
||||
le.minHeight = RowHeight;
|
||||
le.preferredHeight = RowHeight;
|
||||
|
||||
Text label = MakeText(go.transform, "Text", text, 9);
|
||||
Stretch(label.GetComponent<RectTransform>(), 4f);
|
||||
Text label = HudCanvas.MakeText(go.transform, "Text", text, 9);
|
||||
Stretch(label.GetComponent<RectTransform>(), 2f);
|
||||
label.alignment = TextAnchor.MiddleLeft;
|
||||
label.color = new Color(0.7f, 0.72f, 0.75f, 0.95f);
|
||||
label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f);
|
||||
return go;
|
||||
}
|
||||
|
||||
|
|
@ -231,23 +363,23 @@ public static class ChronicleHud
|
|||
{
|
||||
GameObject go = new GameObject($"Row_{index}", typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement));
|
||||
RectTransform rt = go.GetComponent<RectTransform>();
|
||||
rt.sizeDelta = new Vector2(PanelWidth - 24f, 32f);
|
||||
rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeight);
|
||||
LayoutElement le = go.GetComponent<LayoutElement>();
|
||||
le.minHeight = 32f;
|
||||
le.preferredHeight = 32f;
|
||||
le.minHeight = RowHeight;
|
||||
le.preferredHeight = RowHeight;
|
||||
|
||||
Image bg = go.GetComponent<Image>();
|
||||
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.55f);
|
||||
bg.color = new Color(1f, 1f, 1f, 0.02f);
|
||||
|
||||
Text label = MakeText(go.transform, "Text", Truncate(entry.Line, 58), 9);
|
||||
Stretch(label.GetComponent<RectTransform>(), 6f);
|
||||
Text label = HudCanvas.MakeText(go.transform, "Text", Truncate(entry.Line, 44), 9);
|
||||
Stretch(label.GetComponent<RectTransform>(), 3f);
|
||||
label.alignment = TextAnchor.MiddleLeft;
|
||||
label.color = ColorForKind(entry.Kind);
|
||||
|
||||
Button button = go.GetComponent<Button>();
|
||||
ColorBlock colors = button.colors;
|
||||
colors.normalColor = Color.white;
|
||||
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
|
||||
colors.highlightedColor = new Color(1.2f, 1.2f, 1.25f, 1f);
|
||||
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
|
||||
button.colors = colors;
|
||||
|
||||
|
|
@ -265,6 +397,8 @@ public static class ChronicleHud
|
|||
{
|
||||
switch (kind)
|
||||
{
|
||||
case ChronicleKind.Death:
|
||||
return new Color(0.75f, 0.7f, 0.75f, 1f);
|
||||
case ChronicleKind.Kill:
|
||||
return new Color(0.95f, 0.55f, 0.45f, 1f);
|
||||
case ChronicleKind.Lover:
|
||||
|
|
@ -278,55 +412,12 @@ public static class ChronicleHud
|
|||
}
|
||||
}
|
||||
|
||||
private static Canvas ResolveCanvas()
|
||||
{
|
||||
if (CanvasMain.instance != null)
|
||||
{
|
||||
if (CanvasMain.instance.canvas_ui != null)
|
||||
{
|
||||
return CanvasMain.instance.canvas_ui;
|
||||
}
|
||||
|
||||
if (CanvasMain.instance.canvas_tooltip != null)
|
||||
{
|
||||
return CanvasMain.instance.canvas_tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.FindObjectOfType<Canvas>();
|
||||
}
|
||||
|
||||
private static Text MakeText(Transform parent, string name, string value, int size)
|
||||
{
|
||||
Text text = new GameObject(name, typeof(RectTransform), typeof(Text)).GetComponent<Text>();
|
||||
text.transform.SetParent(parent, false);
|
||||
try
|
||||
{
|
||||
OT.InitializeCommonText(text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
text.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
||||
}
|
||||
|
||||
text.text = value ?? "";
|
||||
text.fontSize = size;
|
||||
text.resizeTextForBestFit = true;
|
||||
text.resizeTextMinSize = 8;
|
||||
text.resizeTextMaxSize = size;
|
||||
text.color = Color.white;
|
||||
text.raycastTarget = false;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
return text;
|
||||
}
|
||||
|
||||
private static void Stretch(RectTransform rt, float pad)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = new Vector2(pad, 2f);
|
||||
rt.offsetMax = new Vector2(-pad, -2f);
|
||||
rt.offsetMin = new Vector2(pad, 1f);
|
||||
rt.offsetMax = new Vector2(-pad, -1f);
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
|
|
|
|||
|
|
@ -397,6 +397,7 @@ internal static class HarnessScenarios
|
|||
Step("ch2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("ch3", "set_setting", expect: "show_dossier_caption", value: "true"),
|
||||
Step("ch4", "set_setting", expect: "chronicle_enabled", value: "true"),
|
||||
Step("ch4b", "chronicle_clear"),
|
||||
Step("ch5", "pick_unit", asset: "auto"),
|
||||
Step("ch6", "spectator", value: "off"),
|
||||
Step("ch7", "spectator", value: "on"),
|
||||
|
|
@ -405,19 +406,44 @@ internal static class HarnessScenarios
|
|||
Step("ch10", "assert", expect: "health"),
|
||||
Step("ch11", "assert", expect: "dossier_contains", value: "auto"),
|
||||
Step("ch12", "assert", expect: "caption_contains", value: "auto"),
|
||||
Step("ch13", "chronicle_force", label: "Slew"),
|
||||
|
||||
// History inject
|
||||
Step("ch13", "chronicle_force", label: "Killed"),
|
||||
Step("ch14", "assert", expect: "chronicle_count", value: "1", label: "min"),
|
||||
Step("ch15", "assert", expect: "chronicle_latest_contains", value: "Slew"),
|
||||
Step("ch15", "assert", expect: "chronicle_latest_contains", value: "Killed"),
|
||||
Step("ch15b", "assert", expect: "chronicle_latest_contains", value: "auto"),
|
||||
Step("ch16", "chronicle_open"),
|
||||
Step("ch17", "wait", wait: 0.25f),
|
||||
Step("ch18", "assert", expect: "focus", value: "true"),
|
||||
Step("ch19", "assert", expect: "health"),
|
||||
Step("ch20", "chronicle_jump"),
|
||||
Step("ch21", "wait", wait: 0.25f),
|
||||
Step("ch22", "assert", expect: "focus", value: "true"),
|
||||
Step("ch23", "assert", expect: "dossier_contains", value: "auto"),
|
||||
Step("ch24", "assert", expect: "no_bad"),
|
||||
|
||||
// Death-cause wording samples (victim POV)
|
||||
Step("ch16a", "chronicle_death_sample", value: "Age"),
|
||||
Step("ch16b", "assert", expect: "chronicle_latest_contains", value: "old age"),
|
||||
Step("ch16c", "chronicle_death_sample", value: "Starvation"),
|
||||
Step("ch16d", "assert", expect: "chronicle_latest_contains", value: "Starved"),
|
||||
Step("ch16e", "chronicle_death_sample", value: "Drowning"),
|
||||
Step("ch16f", "assert", expect: "chronicle_latest_contains", value: "Drown"),
|
||||
Step("ch16g", "chronicle_death_sample", value: "Fire"),
|
||||
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
||||
|
||||
// World feed is separate from History
|
||||
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
|
||||
Step("ch17b", "assert", expect: "chronicle_count", value: "1", label: "world"),
|
||||
Step("ch17c", "assert", expect: "chronicle_world_latest_contains", value: "harness_alpha"),
|
||||
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
||||
|
||||
// Tabs
|
||||
Step("ch18", "chronicle_open"),
|
||||
Step("ch18b", "chronicle_tab", value: "history"),
|
||||
Step("ch18c", "assert", expect: "chronicle_tab", value: "history"),
|
||||
Step("ch18d", "chronicle_tab", value: "world"),
|
||||
Step("ch18e", "assert", expect: "chronicle_tab", value: "world"),
|
||||
Step("ch18f", "chronicle_tab", value: "history"),
|
||||
Step("ch18g", "wait", wait: 0.25f),
|
||||
Step("ch19", "assert", expect: "focus", value: "true"),
|
||||
Step("ch20", "assert", expect: "health"),
|
||||
Step("ch21", "chronicle_jump"),
|
||||
Step("ch22", "wait", wait: 0.25f),
|
||||
Step("ch23", "assert", expect: "focus", value: "true"),
|
||||
Step("ch24", "assert", expect: "dossier_contains", value: "auto"),
|
||||
Step("ch25", "assert", expect: "no_bad"),
|
||||
Step("ch99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
54
IdleSpectator/HudCanvas.cs
Normal file
54
IdleSpectator/HudCanvas.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using NeoModLoader.General;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Shared canvas / text helpers for compact non-modal HUD cards.
|
||||
/// </summary>
|
||||
public static class HudCanvas
|
||||
{
|
||||
public static Canvas Resolve()
|
||||
{
|
||||
if (CanvasMain.instance != null)
|
||||
{
|
||||
if (CanvasMain.instance.canvas_ui != null)
|
||||
{
|
||||
return CanvasMain.instance.canvas_ui;
|
||||
}
|
||||
|
||||
if (CanvasMain.instance.canvas_tooltip != null)
|
||||
{
|
||||
return CanvasMain.instance.canvas_tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.FindObjectOfType<Canvas>();
|
||||
}
|
||||
|
||||
public static Text MakeText(Transform parent, string name, string value, int size)
|
||||
{
|
||||
Text text = new GameObject(name, typeof(RectTransform), typeof(Text)).GetComponent<Text>();
|
||||
text.transform.SetParent(parent, false);
|
||||
try
|
||||
{
|
||||
OT.InitializeCommonText(text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
text.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
||||
}
|
||||
|
||||
text.text = value ?? "";
|
||||
text.fontSize = size;
|
||||
text.resizeTextForBestFit = true;
|
||||
text.resizeTextMinSize = 8;
|
||||
text.resizeTextMaxSize = size;
|
||||
text.color = Color.white;
|
||||
text.raycastTarget = false;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"enabled": "Enable Idle Spectator (I)",
|
||||
"show_watch_reasons": "Show brief watch tips",
|
||||
"show_dossier_caption": "Show creature dossier caption while watching",
|
||||
"chronicle_enabled": "Enable Character Chronicle side panel (F9)",
|
||||
"chronicle_enabled": "Enable Chronicle panel (F9) - character History + World events",
|
||||
"debug_state_probe": "Log focus/power-bar state (for debugging)",
|
||||
"IdleSpectator Chronicle": "Character Chronicle"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
|
|
@ -13,6 +12,7 @@ public sealed class UnitDossier
|
|||
public string Name = "";
|
||||
public string SpeciesId = "";
|
||||
public string Headline = "";
|
||||
public string ReasonLine = "";
|
||||
public string DetailLine = "";
|
||||
public string CaptionText = "";
|
||||
public float Score;
|
||||
|
|
@ -35,6 +35,7 @@ public sealed class UnitDossier
|
|||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
d.Headline = "Nobody";
|
||||
d.ReasonLine = "";
|
||||
d.DetailLine = "no living unit";
|
||||
d.CaptionText = d.Headline;
|
||||
return d;
|
||||
|
|
@ -58,14 +59,10 @@ public sealed class UnitDossier
|
|||
d.Score = WorldActivityScanner.ScoreActorPublic(actor, speciesCounts);
|
||||
CollectReasons(d, actor, speciesCounts);
|
||||
|
||||
string tierPrefix = watchTier.HasValue ? $"Watching [{watchTier.Value}]: " : "";
|
||||
d.Headline = tierPrefix + (string.IsNullOrEmpty(watchLabel)
|
||||
? WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts)
|
||||
: watchLabel);
|
||||
d.Headline = $"{d.Name} ({d.SpeciesId})";
|
||||
d.ReasonLine = BuildReasonLine(d, watchTier, watchLabel, actor, speciesCounts);
|
||||
d.DetailLine = BuildDetailLine(d);
|
||||
d.CaptionText = string.IsNullOrEmpty(d.DetailLine)
|
||||
? d.Headline
|
||||
: d.Headline + "\n" + d.DetailLine;
|
||||
d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine);
|
||||
return d;
|
||||
}
|
||||
|
||||
|
|
@ -78,11 +75,173 @@ public sealed class UnitDossier
|
|||
|
||||
return (CaptionText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (Headline ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (ReasonLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (DetailLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (Name ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (SpeciesId ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string JoinCaption(string headline, string reason, string detail)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(headline))
|
||||
{
|
||||
sb.Append(headline);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(reason))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(reason);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(detail))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(detail);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor,
|
||||
Dictionary<string, int> speciesCounts)
|
||||
{
|
||||
string tierPart = watchTier.HasValue ? watchTier.Value.ToString() : "";
|
||||
string why = "";
|
||||
if (!string.IsNullOrEmpty(watchLabel))
|
||||
{
|
||||
why = ShortenWatchLabel(watchLabel, d);
|
||||
}
|
||||
else
|
||||
{
|
||||
why = ShortenWatchLabel(
|
||||
WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts), d);
|
||||
// Ambient focus: FormatUnitLabel often equals the headline ("Name (species)").
|
||||
if (IsRedundantWithHeadline(why, d))
|
||||
{
|
||||
why = FirstFlavorTag(d);
|
||||
}
|
||||
}
|
||||
|
||||
if (IsRedundantWithHeadline(why, d))
|
||||
{
|
||||
why = "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(tierPart))
|
||||
{
|
||||
return why ?? "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(why) || why.IndexOf(tierPart, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return tierPart;
|
||||
}
|
||||
|
||||
return tierPart + " · " + why;
|
||||
}
|
||||
|
||||
private static string FirstFlavorTag(UnitDossier d)
|
||||
{
|
||||
for (int i = 0; i < d.ScoreReasons.Count; i++)
|
||||
{
|
||||
string reason = Humanize(d.ScoreReasons[i]);
|
||||
if (string.IsNullOrEmpty(reason) || IsRedundantWithHeadline(reason, d))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reason.IndexOf("kills", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return reason;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static bool IsRedundantWithHeadline(string text, UnitDossier d)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || d == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(text, d.Headline, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(text, d.Name, System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(text, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// "Name (species)" variants already covered by headline.
|
||||
if (!string.IsNullOrEmpty(d.Name) && !string.IsNullOrEmpty(d.SpeciesId)
|
||||
&& text.IndexOf(d.Name, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
&& text.IndexOf(d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ShortenWatchLabel(string label, UnitDossier d)
|
||||
{
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string s = label.Trim();
|
||||
if (s.StartsWith("New species:", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "New species";
|
||||
}
|
||||
|
||||
if (s.StartsWith("Watching [", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int close = s.IndexOf(']');
|
||||
if (close > 0 && close + 1 < s.Length)
|
||||
{
|
||||
s = s.Substring(close + 1).TrimStart(':', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
// Drop trailing "Name" duplicates like "Lone wolf: Waf" -> keep short form.
|
||||
int colon = s.IndexOf(':');
|
||||
if (colon > 0 && colon < 28)
|
||||
{
|
||||
s = s.Substring(0, colon).Trim();
|
||||
}
|
||||
|
||||
if (d != null && IsRedundantWithHeadline(s, d))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (s.Length > 36)
|
||||
{
|
||||
s = s.Substring(0, 33) + "...";
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
private static void CollectReasons(UnitDossier d, Actor actor, Dictionary<string, int> speciesCounts)
|
||||
{
|
||||
if (d.IsFavorite)
|
||||
|
|
@ -120,10 +279,6 @@ public sealed class UnitDossier
|
|||
{
|
||||
d.ScoreReasons.Add("10+ kills");
|
||||
}
|
||||
else if (d.Kills > 0)
|
||||
{
|
||||
d.ScoreReasons.Add($"{d.Kills} kills");
|
||||
}
|
||||
|
||||
if (d.IsLoneSpecies)
|
||||
{
|
||||
|
|
@ -137,10 +292,9 @@ public sealed class UnitDossier
|
|||
|
||||
if (d.Renown >= 100)
|
||||
{
|
||||
d.ScoreReasons.Add($"renown {d.Renown}");
|
||||
d.ScoreReasons.Add("renown " + d.Renown);
|
||||
}
|
||||
|
||||
// A few trait ids for flavor (keep short).
|
||||
try
|
||||
{
|
||||
if (actor.hasTraits())
|
||||
|
|
@ -153,7 +307,7 @@ public sealed class UnitDossier
|
|||
continue;
|
||||
}
|
||||
|
||||
d.ScoreReasons.Add(trait.id);
|
||||
d.ScoreReasons.Add(Humanize(trait.id));
|
||||
n++;
|
||||
if (n >= 2)
|
||||
{
|
||||
|
|
@ -171,46 +325,89 @@ public sealed class UnitDossier
|
|||
private static string BuildDetailLine(UnitDossier d)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append(d.SpeciesId);
|
||||
if (d.Age > 0)
|
||||
{
|
||||
sb.Append(" · age ").Append(d.Age);
|
||||
sb.Append("age ").Append(d.Age);
|
||||
}
|
||||
|
||||
if (d.Kills > 0)
|
||||
{
|
||||
sb.Append(" · ").Append(d.Kills).Append(" kills");
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append(" · ");
|
||||
}
|
||||
|
||||
sb.Append(d.Kills).Append(" kills");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(d.KingdomName))
|
||||
if (!string.IsNullOrEmpty(d.KingdomName)
|
||||
&& !string.Equals(d.KingdomName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(d.KingdomName, d.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.Append(" · ").Append(d.KingdomName);
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append(" · ");
|
||||
}
|
||||
|
||||
sb.Append(d.KingdomName);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(d.CityName))
|
||||
else if (!string.IsNullOrEmpty(d.CityName)
|
||||
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(d.CityName, d.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.Append(" · ").Append(d.CityName);
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append(" · ");
|
||||
}
|
||||
|
||||
sb.Append(d.CityName);
|
||||
}
|
||||
|
||||
for (int i = 0; i < d.ScoreReasons.Count && i < 4; i++)
|
||||
int tags = 0;
|
||||
for (int i = 0; i < d.ScoreReasons.Count && tags < 2; i++)
|
||||
{
|
||||
string reason = d.ScoreReasons[i];
|
||||
// Skip reasons already implied by headline pieces.
|
||||
if (reason == "fighting" || reason == "king" || reason == "favorite")
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sb.ToString().IndexOf(reason, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
if (reason.IndexOf("kills", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(" · ").Append(reason);
|
||||
string shown = Humanize(reason);
|
||||
if (IsRedundantWithHeadline(shown, d)
|
||||
|| sb.ToString().IndexOf(shown, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (!string.IsNullOrEmpty(d.ReasonLine)
|
||||
&& d.ReasonLine.IndexOf(shown, System.StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append(" · ");
|
||||
}
|
||||
|
||||
sb.Append(shown);
|
||||
tags++;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Humanize(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return raw.Replace('_', ' ');
|
||||
}
|
||||
|
||||
private static string SafeName(Actor actor)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
using NeoModLoader.General;
|
||||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent "who am I watching" caption while Idle Spectator is active.
|
||||
/// Persistent compact dossier card (top-left) while Idle Spectator is active.
|
||||
/// Replaces WorldTip for dossier text so AFK watching does not spam giant orange toasts.
|
||||
/// </summary>
|
||||
public static class WatchCaption
|
||||
{
|
||||
private const float RefreshSeconds = 4.5f;
|
||||
private const float PanelWidth = 260f;
|
||||
private const float PanelHeight = 72f;
|
||||
|
||||
private static float _lastShownAt = -999f;
|
||||
private static GameObject _root;
|
||||
private static Text _nameText;
|
||||
private static Text _reasonText;
|
||||
private static Text _metaText;
|
||||
private static UnitDossier _current;
|
||||
private static bool _visible;
|
||||
|
||||
public static UnitDossier Current => _current;
|
||||
|
||||
|
|
@ -27,15 +35,7 @@ public static class WatchCaption
|
|||
LastHeadline = "";
|
||||
LastDetail = "";
|
||||
LastCaptionText = "";
|
||||
_lastShownAt = -999f;
|
||||
try
|
||||
{
|
||||
WorldTip.hideNow();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
SetVisible(false);
|
||||
}
|
||||
|
||||
public static void SetFromInterest(InterestEvent interest)
|
||||
|
|
@ -52,7 +52,9 @@ public static class WatchCaption
|
|||
LastDetail = "";
|
||||
LastCaptionText = LastHeadline;
|
||||
_current = null;
|
||||
ShowNow(LastCaptionText, force: true);
|
||||
EnsureBuilt();
|
||||
ApplyLines(LastHeadline, "", "");
|
||||
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
@ -68,7 +70,9 @@ public static class WatchCaption
|
|||
LastHeadline = dossier.Headline ?? "";
|
||||
LastDetail = dossier.DetailLine ?? "";
|
||||
LastCaptionText = dossier.CaptionText ?? "";
|
||||
ShowNow(LastCaptionText, force: true);
|
||||
EnsureBuilt();
|
||||
ApplyLines(dossier.Headline, dossier.ReasonLine, dossier.DetailLine);
|
||||
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
|
||||
LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | "));
|
||||
}
|
||||
|
||||
|
|
@ -76,36 +80,118 @@ public static class WatchCaption
|
|||
{
|
||||
if (!SpectatorMode.Active || !ModSettings.ShowDossierCaption)
|
||||
{
|
||||
if (_visible)
|
||||
{
|
||||
SetVisible(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(LastCaptionText))
|
||||
if (_root == null && !string.IsNullOrEmpty(LastCaptionText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
EnsureBuilt();
|
||||
if (_current != null)
|
||||
{
|
||||
ApplyLines(_current.Headline, _current.ReasonLine, _current.DetailLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyLines(LastHeadline, "", LastDetail);
|
||||
}
|
||||
|
||||
if (Time.unscaledTime - _lastShownAt < RefreshSeconds)
|
||||
{
|
||||
return;
|
||||
SetVisible(true);
|
||||
}
|
||||
|
||||
// Refresh so the tip does not fade away during long AFK watches.
|
||||
ShowNow(LastCaptionText, force: false);
|
||||
}
|
||||
|
||||
private static void ShowNow(string text, bool force)
|
||||
private static void ApplyLines(string name, string reason, string meta)
|
||||
{
|
||||
if (!ModSettings.ShowDossierCaption || string.IsNullOrEmpty(text))
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = string.IsNullOrEmpty(name) ? "" : name;
|
||||
}
|
||||
|
||||
if (_reasonText != null)
|
||||
{
|
||||
_reasonText.text = string.IsNullOrEmpty(reason) ? "" : reason;
|
||||
}
|
||||
|
||||
if (_metaText != null)
|
||||
{
|
||||
_metaText.text = string.IsNullOrEmpty(meta) ? "" : meta;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetVisible(bool visible)
|
||||
{
|
||||
EnsureBuilt();
|
||||
if (_root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && Time.unscaledTime - _lastShownAt < 1f)
|
||||
_visible = visible;
|
||||
_root.SetActive(visible);
|
||||
}
|
||||
|
||||
private static void EnsureBuilt()
|
||||
{
|
||||
if (_root != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WorldTip.showNow(text, pTranslate: false, pPosition: "top", pTime: RefreshSeconds + 2f);
|
||||
_lastShownAt = Time.unscaledTime;
|
||||
Canvas canvas = HudCanvas.Resolve();
|
||||
if (canvas == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_root = new GameObject("IdleSpectatorDossierHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
|
||||
_root.transform.SetParent(canvas.transform, false);
|
||||
|
||||
RectTransform rt = _root.GetComponent<RectTransform>();
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(0f, 1f);
|
||||
rt.pivot = new Vector2(0f, 1f);
|
||||
rt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
|
||||
rt.anchoredPosition = new Vector2(12f, -12f);
|
||||
|
||||
Image bg = _root.GetComponent<Image>();
|
||||
bg.color = new Color(0.05f, 0.06f, 0.08f, 0.72f);
|
||||
bg.raycastTarget = false;
|
||||
|
||||
CanvasGroup group = _root.GetComponent<CanvasGroup>();
|
||||
group.blocksRaycasts = false;
|
||||
group.interactable = false;
|
||||
|
||||
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 12);
|
||||
PlaceLine(_nameText.GetComponent<RectTransform>(), -6f, 22f);
|
||||
_nameText.fontStyle = FontStyle.Bold;
|
||||
_nameText.color = new Color(0.95f, 0.93f, 0.88f, 1f);
|
||||
_nameText.alignment = TextAnchor.MiddleLeft;
|
||||
|
||||
_reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 10);
|
||||
PlaceLine(_reasonText.GetComponent<RectTransform>(), -28f, 18f);
|
||||
_reasonText.color = new Color(0.95f, 0.72f, 0.38f, 1f);
|
||||
_reasonText.alignment = TextAnchor.MiddleLeft;
|
||||
|
||||
_metaText = HudCanvas.MakeText(_root.transform, "Meta", "", 9);
|
||||
PlaceLine(_metaText.GetComponent<RectTransform>(), -46f, 18f);
|
||||
_metaText.color = new Color(0.72f, 0.75f, 0.78f, 1f);
|
||||
_metaText.alignment = TextAnchor.MiddleLeft;
|
||||
|
||||
_root.SetActive(false);
|
||||
_visible = false;
|
||||
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (top-left card)");
|
||||
}
|
||||
|
||||
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height)
|
||||
{
|
||||
lineRt.anchorMin = new Vector2(0f, 1f);
|
||||
lineRt.anchorMax = new Vector2(1f, 1f);
|
||||
lineRt.pivot = new Vector2(0.5f, 1f);
|
||||
lineRt.sizeDelta = new Vector2(-16f, height);
|
||||
lineRt.anchoredPosition = new Vector2(0f, yFromTop);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.8.0",
|
||||
"description": "AFK Idle Spectator (I) + Chronicle life-event HUD (F9). Dossier captions, kills/lovers/WorldLog chronicle.",
|
||||
"version": "0.9.1",
|
||||
"description": "AFK Idle Spectator (I) + Chronicle History|World (F9). Character death causes, life events.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue