Refactor Chronicle to World Memory, enhancing HUD and functionality. Updated entry handling, added age chapter support, and improved history display. Adjusted test scenarios and assertions to align with new features.
This commit is contained in:
parent
7b4ff19b01
commit
0f30f18bcb
12 changed files with 1630 additions and 361 deletions
|
|
@ -47,6 +47,8 @@ Use harness `fast_timing` + `director_run` / `age_current` for scheduling tests.
|
||||||
| Director tests take forever | production dwell/rotate | `fast_timing` (not game 5x) |
|
| Director tests take forever | production dwell/rotate | `fast_timing` (not game 5x) |
|
||||||
| Game never opens / `Cannot run as root user` | Shell sandboxed; Steam rejects root-like env | re-run launch/`harness-run.sh` with Shell `required_permissions: ["all"]` |
|
| Game never opens / `Cannot run as root user` | Shell sandboxed; Steam rejects root-like env | re-run launch/`harness-run.sh` with Shell `required_permissions: ["all"]` |
|
||||||
| Old mod still loaded after edit | NML caches until restart | kill `.../common/worldbox/worldbox`, bump `mod.json`, relaunch with `all` perms |
|
| Old mod still loaded after edit | NML caches until restart | kill `.../common/worldbox/worldbox`, bump `mod.json`, relaunch with `all` perms |
|
||||||
|
| Harness says ready but mod broken | Stale `Harmony patches applied` / missed `error CS` | runner fails boot on IdleSpectator `error CS`; ready requires Harmony in current (truncated) Player.log |
|
||||||
|
| Harness stuck on `o...` forever | Launch marker written into Player.log then wiped by Unity | do not put boot markers in Player.log; wait for process + Harmony |
|
||||||
|
|
||||||
## Useful asserts
|
## Useful asserts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -317,8 +317,19 @@ public static class AgentHarness
|
||||||
case "chronicle_force":
|
case "chronicle_force":
|
||||||
case "chronicle_note":
|
case "chronicle_note":
|
||||||
{
|
{
|
||||||
bool ok = Chronicle.ForceNoteCurrentFocus(
|
int n = Math.Max(1, cmd.count);
|
||||||
string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label);
|
string baseLabel = string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label;
|
||||||
|
int okN = 0;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
string label = n > 1 ? $"{baseLabel} #{i + 1}" : baseLabel;
|
||||||
|
if (Chronicle.ForceNoteCurrentFocus(label))
|
||||||
|
{
|
||||||
|
okN++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = okN == n;
|
||||||
if (ok)
|
if (ok)
|
||||||
{
|
{
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
|
|
@ -329,7 +340,7 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
|
|
||||||
Emit(cmd, ok, detail:
|
Emit(cmd, ok, detail:
|
||||||
$"history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}");
|
$"forced={okN}/{n} history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,21 +390,12 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
|
|
||||||
case "chronicle_tab":
|
case "chronicle_tab":
|
||||||
|
case "world_memory_open":
|
||||||
{
|
{
|
||||||
string tab = (cmd.value ?? "").Trim().ToLowerInvariant();
|
// Tabs removed - World Memory is a single panel.
|
||||||
if (tab == "world")
|
|
||||||
{
|
|
||||||
ChronicleHud.SetTab(ChronicleTab.World);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ChronicleHud.SetTab(ChronicleTab.History);
|
|
||||||
tab = "history";
|
|
||||||
}
|
|
||||||
|
|
||||||
Chronicle.ShowHud();
|
Chronicle.ShowHud();
|
||||||
bool ok = ChronicleHud.ActiveTab.ToString().Equals(tab, System.StringComparison.OrdinalIgnoreCase);
|
bool open = Chronicle.HudVisible;
|
||||||
if (ok)
|
if (open)
|
||||||
{
|
{
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
}
|
}
|
||||||
|
|
@ -402,7 +404,7 @@ public static class AgentHarness
|
||||||
_cmdFail++;
|
_cmdFail++;
|
||||||
}
|
}
|
||||||
|
|
||||||
Emit(cmd, ok, detail: $"tab={ChronicleHud.ActiveTab}");
|
Emit(cmd, open, detail: $"hud={open} memory={Chronicle.MemoryCount} age={Chronicle.CurrentAgeName}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,7 +421,71 @@ public static class AgentHarness
|
||||||
_cmdFail++;
|
_cmdFail++;
|
||||||
}
|
}
|
||||||
|
|
||||||
Emit(cmd, open, detail: $"hud={open} tab={ChronicleHud.ActiveTab}");
|
Emit(cmd, open, detail: $"hud={open} memory={Chronicle.MemoryCount} age={Chronicle.CurrentAgeName}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "dossier_favorite":
|
||||||
|
{
|
||||||
|
Actor target = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
string v = (cmd.value ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (v == "true" || v == "1" || v == "on" || v == "false" || v == "0" || v == "off")
|
||||||
|
{
|
||||||
|
bool want = v == "true" || v == "1" || v == "on";
|
||||||
|
bool ok = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (target != null && target.isAlive())
|
||||||
|
{
|
||||||
|
if (target.isFavorite() != want)
|
||||||
|
{
|
||||||
|
target.switchFavorite();
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = target.isFavorite() == want;
|
||||||
|
WatchCaption.SetFromActor(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(cmd, ok, detail: $"favorite={want} set");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchCaption.ToggleFavorite();
|
||||||
|
bool fav = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fav = target != null && target.isFavorite();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
_cmdOk++;
|
||||||
|
Emit(cmd, ok: true, detail: $"favorite={fav}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "dossier_expand":
|
||||||
|
{
|
||||||
|
// Expand control removed: history is always full-list + scroll when tall.
|
||||||
|
_cmdOk++;
|
||||||
|
Emit(cmd, ok: true,
|
||||||
|
detail: $"expand_removed shown={WatchCaption.LastHistoryShown} scrollable={WatchCaption.LastHistoryScrollable}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1140,12 +1206,14 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
case "caption_layout_ok":
|
case "caption_layout_ok":
|
||||||
{
|
{
|
||||||
|
float maxH = 220f;
|
||||||
pass = WatchCaption.LastLayoutOk
|
pass = WatchCaption.LastLayoutOk
|
||||||
&& WatchCaption.LastPanelSize.y > 20f
|
&& WatchCaption.LastPanelSize.y > 20f
|
||||||
&& WatchCaption.LastPanelSize.y < 220f
|
&& WatchCaption.LastPanelSize.y < maxH
|
||||||
&& WatchCaption.LastPanelSize.x <= 440f;
|
&& WatchCaption.LastPanelSize.x <= 440f
|
||||||
|
&& (WatchCaption.LastHistoryShown <= 0 || WatchCaption.LastHistoryFillsBody);
|
||||||
detail =
|
detail =
|
||||||
$"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'";
|
$"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} histFill={WatchCaption.LastHistoryFillsBody} scrollable={WatchCaption.LastHistoryScrollable} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "dossier_traits_ok":
|
case "dossier_traits_ok":
|
||||||
|
|
@ -1183,22 +1251,118 @@ public static class AgentHarness
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chronicle_world_latest_contains":
|
case "chronicle_world_latest_contains":
|
||||||
|
case "world_memory_contains":
|
||||||
{
|
{
|
||||||
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
||||||
ChronicleEntry latest = Chronicle.LatestWorld;
|
ChronicleEntry latest = Chronicle.LatestWorld;
|
||||||
string line = latest != null ? latest.Line : "";
|
string line = latest != null ? latest.Line : "";
|
||||||
|
string lore = latest != null ? latest.HudLine : "";
|
||||||
|
string display = latest != null ? latest.DisplayLine : "";
|
||||||
pass = latest != null
|
pass = latest != null
|
||||||
|
&& latest.Kind != ChronicleKind.AgeChapter
|
||||||
&& !string.IsNullOrEmpty(needle)
|
&& !string.IsNullOrEmpty(needle)
|
||||||
&& line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
&& (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
detail = $"world_latest='{line}' needle='{needle}'";
|
|| lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0);
|
||||||
|
detail = $"world_latest='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}'";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "world_memory_age":
|
||||||
|
{
|
||||||
|
pass = !string.IsNullOrEmpty(Chronicle.CurrentAgeId)
|
||||||
|
|| Chronicle.MemoryCount > 0;
|
||||||
|
detail = $"ageId='{Chronicle.CurrentAgeId}' ageName='{Chronicle.CurrentAgeName}' memory={Chronicle.MemoryCount}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "dossier_favorite":
|
||||||
|
case "is_favorite":
|
||||||
|
{
|
||||||
|
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
bool fav = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fav = unit != null && unit.isFavorite();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
string wantRaw = (cmd.value ?? "true").Trim().ToLowerInvariant();
|
||||||
|
bool want = wantRaw != "false" && wantRaw != "0" && wantRaw != "off";
|
||||||
|
pass = fav == want;
|
||||||
|
detail = $"favorite={fav} want={want}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "dossier_history_shown":
|
||||||
|
{
|
||||||
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
||||||
|
int have = WatchCaption.LastHistoryShown;
|
||||||
|
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
|
||||||
|
if (cmp == "exact" || cmp == "==")
|
||||||
|
{
|
||||||
|
pass = have == want;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pass = have >= want;
|
||||||
|
}
|
||||||
|
|
||||||
|
detail =
|
||||||
|
$"shown={have} want={want} scrollable={WatchCaption.LastHistoryScrollable} histFill={WatchCaption.LastHistoryFillsBody}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "dossier_history_scrollable":
|
||||||
|
{
|
||||||
|
pass = WatchCaption.LastHistoryScrollable && WatchCaption.LastHistoryShown > 3;
|
||||||
|
detail =
|
||||||
|
$"scrollable={WatchCaption.LastHistoryScrollable} shown={WatchCaption.LastHistoryShown}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "dossier_history_fills_body":
|
||||||
|
{
|
||||||
|
pass = WatchCaption.LastHistoryShown > 0 && WatchCaption.LastHistoryFillsBody;
|
||||||
|
detail =
|
||||||
|
$"histFill={WatchCaption.LastHistoryFillsBody} shown={WatchCaption.LastHistoryShown} size={WatchCaption.LastPanelSize}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "hud_no_overlap":
|
||||||
|
{
|
||||||
|
bool hasDossier = WatchCaption.TryGetScreenPixelRect(out Rect dossier);
|
||||||
|
bool hasMemory = ChronicleHud.TryGetScreenPixelRect(out Rect memory);
|
||||||
|
bool overlap = false;
|
||||||
|
if (hasDossier && hasMemory && ChronicleHud.Visible)
|
||||||
|
{
|
||||||
|
overlap = dossier.Overlaps(memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
pass = hasDossier && (!ChronicleHud.Visible || (hasMemory && !overlap));
|
||||||
|
detail =
|
||||||
|
$"dossier={(hasDossier ? dossier.ToString() : "-")} memory={(hasMemory ? memory.ToString() : "-")} visible={ChronicleHud.Visible} overlap={overlap}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "world_memory_compact":
|
||||||
|
{
|
||||||
|
bool hasMemory = ChronicleHud.TryGetScreenPixelRect(out Rect memory);
|
||||||
|
// Same canvas scale as dossier (no localScale damp). Reject tiny or near-fullscreen.
|
||||||
|
float minW = Screen.width * 0.22f;
|
||||||
|
float maxW = Screen.width * 0.55f;
|
||||||
|
float minH = Screen.height * 0.16f;
|
||||||
|
float maxH = Screen.height * 0.50f;
|
||||||
|
pass = ChronicleHud.Visible
|
||||||
|
&& hasMemory
|
||||||
|
&& memory.width >= minW
|
||||||
|
&& memory.height >= minH
|
||||||
|
&& memory.width <= maxW
|
||||||
|
&& memory.height <= maxH;
|
||||||
|
detail =
|
||||||
|
$"memory={(hasMemory ? memory.ToString() : "-")} screen={Screen.width}x{Screen.height} band=({minW:0}-{maxW:0} x {minH:0}-{maxH:0})";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chronicle_tab":
|
case "chronicle_tab":
|
||||||
{
|
{
|
||||||
string want = (cmd.value ?? "history").Trim().ToLowerInvariant();
|
// Deprecated: panel is always World Memory when open.
|
||||||
string have = ChronicleHud.ActiveTab.ToString().ToLowerInvariant();
|
pass = Chronicle.HudVisible;
|
||||||
pass = have == want;
|
detail = $"hud={Chronicle.HudVisible} memory={Chronicle.MemoryCount} (tabs removed)";
|
||||||
detail = $"tab={have} want={want}";
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,9 @@ public enum ChronicleKind
|
||||||
Lover,
|
Lover,
|
||||||
Friend,
|
Friend,
|
||||||
World,
|
World,
|
||||||
Other
|
Other,
|
||||||
}
|
/// <summary>Age chapter header row in World Memory (not a clickable landmark).</summary>
|
||||||
|
AgeChapter
|
||||||
public enum ChronicleTab
|
|
||||||
{
|
|
||||||
History,
|
|
||||||
World
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ChronicleEntry
|
public sealed class ChronicleEntry
|
||||||
|
|
@ -32,28 +28,39 @@ public sealed class ChronicleEntry
|
||||||
public long OtherId;
|
public long OtherId;
|
||||||
public string Name = "";
|
public string Name = "";
|
||||||
public string SpeciesId = "";
|
public string SpeciesId = "";
|
||||||
|
/// <summary>Raw fact line (asserts / debug).</summary>
|
||||||
public string Line = "";
|
public string Line = "";
|
||||||
|
/// <summary>Chronicle-voice line for HUD; falls back to <see cref="Line"/>.</summary>
|
||||||
|
public string LoreLine = "";
|
||||||
public Vector3 Position;
|
public Vector3 Position;
|
||||||
|
public string AssetId = "";
|
||||||
|
public string AgeId = "";
|
||||||
|
public string AgeName = "";
|
||||||
|
/// <summary>True when this row is a curated character legend in World Memory.</summary>
|
||||||
|
public bool IsLegend;
|
||||||
|
|
||||||
/// <summary>Back-compat for harness / jump helpers.</summary>
|
/// <summary>Back-compat for harness / jump helpers.</summary>
|
||||||
public long UnitId => SubjectId;
|
public long UnitId => SubjectId;
|
||||||
|
|
||||||
/// <summary>Plain date-prefixed line for logs / asserts.</summary>
|
public string HudLine => !string.IsNullOrEmpty(LoreLine) ? LoreLine : (Line ?? "");
|
||||||
|
|
||||||
|
/// <summary>Plain date-prefixed line for logs / asserts (uses lore when present).</summary>
|
||||||
public string DisplayLine
|
public string DisplayLine
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
string body = HudLine;
|
||||||
if (string.IsNullOrEmpty(DateLabel))
|
if (string.IsNullOrEmpty(DateLabel))
|
||||||
{
|
{
|
||||||
return Line ?? "";
|
return body ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(Line))
|
if (string.IsNullOrEmpty(body))
|
||||||
{
|
{
|
||||||
return "[" + DateLabel + "]";
|
return "[" + DateLabel + "]";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "[" + DateLabel + "] " + Line;
|
return "[" + DateLabel + "] " + body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,41 +69,48 @@ public sealed class ChronicleEntry
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
string body = HudLine;
|
||||||
if (string.IsNullOrEmpty(DateLabel))
|
if (string.IsNullOrEmpty(DateLabel))
|
||||||
{
|
{
|
||||||
return Line ?? "";
|
return body ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const string color = "#A8B4C8";
|
const string color = "#A8B4C8";
|
||||||
string stamped = "<color=" + color + ">[" + DateLabel + "]</color>";
|
string stamped = "<color=" + color + ">[" + DateLabel + "]</color>";
|
||||||
if (string.IsNullOrEmpty(Line))
|
if (string.IsNullOrEmpty(body))
|
||||||
{
|
{
|
||||||
return stamped;
|
return stamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
return stamped + " " + Line;
|
return stamped + " " + body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Session chronicles: per-character History + separate World feed (WorldLog Story/Epic).
|
/// Session chronicles: per-character History + World Memory landmarks (age chapters).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Chronicle
|
public static class Chronicle
|
||||||
{
|
{
|
||||||
public const KeyCode ToggleKey = KeyCode.F9;
|
public const KeyCode ToggleKey = KeyCode.F9;
|
||||||
private const int MaxHistoryPerUnit = 40;
|
public const int MaxHistoryPerUnit = 40;
|
||||||
private const int MaxWorldEntries = 100;
|
private const int MaxLandmarks = 80;
|
||||||
|
private const int MaxLandmarksPerAge = 8;
|
||||||
|
|
||||||
private static readonly Dictionary<long, List<ChronicleEntry>> Histories =
|
private static readonly Dictionary<long, List<ChronicleEntry>> Histories =
|
||||||
new Dictionary<long, List<ChronicleEntry>>();
|
new Dictionary<long, List<ChronicleEntry>>();
|
||||||
private static readonly List<ChronicleEntry> WorldEntries = new List<ChronicleEntry>();
|
private static readonly List<ChronicleEntry> MemoryLandmarks = new List<ChronicleEntry>();
|
||||||
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
|
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
|
||||||
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
|
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
|
||||||
private static readonly HashSet<long> DeathLogged = new HashSet<long>();
|
private static readonly HashSet<long> DeathLogged = new HashSet<long>();
|
||||||
|
private static readonly HashSet<string> LandmarkDedupe = new HashSet<string>();
|
||||||
|
private static readonly Dictionary<string, int> LandmarksPerAge = new Dictionary<string, int>();
|
||||||
|
|
||||||
private static bool _hudReady;
|
private static bool _hudReady;
|
||||||
private static long _lastHistorySubjectId;
|
private static long _lastHistorySubjectId;
|
||||||
private static object _boundWorld;
|
private static object _boundWorld;
|
||||||
|
private static string _currentAgeId = "";
|
||||||
|
private static string _currentAgeName = "";
|
||||||
|
|
||||||
public static void OnSpectatorEnabled()
|
public static void OnSpectatorEnabled()
|
||||||
{
|
{
|
||||||
|
|
@ -107,17 +121,22 @@ public static class Chronicle
|
||||||
_boundWorld = world;
|
_boundWorld = world;
|
||||||
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
|
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshAgeChapter(force: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + WorldCount;
|
public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + MemoryCount;
|
||||||
|
|
||||||
public static int WorldCount
|
/// <summary>World Memory landmark count (replaces the old World feed).</summary>
|
||||||
|
public static int WorldCount => MemoryCount;
|
||||||
|
|
||||||
|
public static int MemoryCount
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (WorldEntries)
|
lock (MemoryLandmarks)
|
||||||
{
|
{
|
||||||
return WorldEntries.Count;
|
return MemoryLandmarks.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,6 +145,10 @@ public static class Chronicle
|
||||||
|
|
||||||
public static long LastHistorySubjectId => _lastHistorySubjectId;
|
public static long LastHistorySubjectId => _lastHistorySubjectId;
|
||||||
|
|
||||||
|
public static string CurrentAgeId => _currentAgeId;
|
||||||
|
|
||||||
|
public static string CurrentAgeName => _currentAgeName;
|
||||||
|
|
||||||
public static int HistoryCountFor(long subjectId)
|
public static int HistoryCountFor(long subjectId)
|
||||||
{
|
{
|
||||||
if (subjectId == 0)
|
if (subjectId == 0)
|
||||||
|
|
@ -186,11 +209,17 @@ public static class Chronicle
|
||||||
return newest;
|
return newest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>World Memory landmarks oldest→newest (HUD reverses for display).</summary>
|
||||||
public static IReadOnlyList<ChronicleEntry> SnapshotWorld()
|
public static IReadOnlyList<ChronicleEntry> SnapshotWorld()
|
||||||
{
|
{
|
||||||
lock (WorldEntries)
|
return SnapshotMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ChronicleEntry> SnapshotMemory()
|
||||||
|
{
|
||||||
|
lock (MemoryLandmarks)
|
||||||
{
|
{
|
||||||
return WorldEntries.ToArray();
|
return MemoryLandmarks.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,9 +237,9 @@ public static class Chronicle
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (WorldEntries)
|
lock (MemoryLandmarks)
|
||||||
{
|
{
|
||||||
return WorldEntries.Count == 0 ? null : WorldEntries[WorldEntries.Count - 1];
|
return MemoryLandmarks.Count == 0 ? null : MemoryLandmarks[MemoryLandmarks.Count - 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -282,12 +311,12 @@ public static class Chronicle
|
||||||
if (ChronicleHud.IsReady)
|
if (ChronicleHud.IsReady)
|
||||||
{
|
{
|
||||||
_hudReady = true;
|
_hudReady = true;
|
||||||
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)");
|
LogService.LogInfo("[IdleSpectator] World Memory HUD ready (F9)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (System.Exception ex)
|
||||||
{
|
{
|
||||||
LogService.LogInfo("[IdleSpectator] Chronicle HUD init failed: " + ex.Message);
|
LogService.LogInfo("[IdleSpectator] World Memory HUD init failed: " + ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,15 +327,19 @@ public static class Chronicle
|
||||||
Histories.Clear();
|
Histories.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
lock (WorldEntries)
|
lock (MemoryLandmarks)
|
||||||
{
|
{
|
||||||
WorldEntries.Clear();
|
MemoryLandmarks.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
LoverPairKeys.Clear();
|
LoverPairKeys.Clear();
|
||||||
FriendPairKeys.Clear();
|
FriendPairKeys.Clear();
|
||||||
DeathLogged.Clear();
|
DeathLogged.Clear();
|
||||||
|
LandmarkDedupe.Clear();
|
||||||
|
LandmarksPerAge.Clear();
|
||||||
_lastHistorySubjectId = 0;
|
_lastHistorySubjectId = 0;
|
||||||
|
_currentAgeId = "";
|
||||||
|
_currentAgeName = "";
|
||||||
// Keep _boundWorld so re-enable on same map does not loop-clear.
|
// Keep _boundWorld so re-enable on same map does not loop-clear.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,12 +381,19 @@ public static class Chronicle
|
||||||
|
|
||||||
public static bool JumpToLatest()
|
public static bool JumpToLatest()
|
||||||
{
|
{
|
||||||
|
// Prefer a Memory landmark when it can resolve a unit/position; else focus history.
|
||||||
|
ChronicleEntry memory = LatestWorld;
|
||||||
|
if (memory != null && memory.Kind != ChronicleKind.AgeChapter && JumpTo(memory))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return JumpTo(Latest);
|
return JumpTo(Latest);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool JumpTo(ChronicleEntry entry)
|
public static bool JumpTo(ChronicleEntry entry)
|
||||||
{
|
{
|
||||||
if (entry == null)
|
if (entry == null || entry.Kind == ChronicleKind.AgeChapter)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -393,7 +433,8 @@ public static class Chronicle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep last history subject sticky when focus dies / drops briefly.
|
RefreshAgeChapter(force: false);
|
||||||
|
|
||||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -413,7 +454,7 @@ public static class Chronicle
|
||||||
ChronicleHud.UpdateLive();
|
ChronicleHud.UpdateLive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Killer POV - character history only.</summary>
|
/// <summary>Killer POV - character history only (may promote to Memory if notable).</summary>
|
||||||
public static void NoteKill(Actor killer, Actor victim)
|
public static void NoteKill(Actor killer, Actor victim)
|
||||||
{
|
{
|
||||||
if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
|
if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
|
||||||
|
|
@ -519,14 +560,16 @@ public static class Chronicle
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
string line = label;
|
string assetId = message.asset_id ?? "";
|
||||||
AppendWorld(unit, line, pos);
|
AppendLandmark(
|
||||||
|
ChronicleKind.World,
|
||||||
// Mirror onto that unit's History when WorldLog points at someone.
|
unit,
|
||||||
if (unit != null)
|
null,
|
||||||
{
|
label,
|
||||||
AppendHistory(ChronicleKind.World, unit, null, line);
|
LoreProse.Rewrite(ChronicleKind.World, label, assetId),
|
||||||
}
|
pos,
|
||||||
|
assetId,
|
||||||
|
isLegend: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Harness: inject a life-event style line on the focused unit's History.</summary>
|
/// <summary>Harness: inject a life-event style line on the focused unit's History.</summary>
|
||||||
|
|
@ -539,8 +582,6 @@ public static class Chronicle
|
||||||
|
|
||||||
Actor unit = MoveCamera._focus_unit;
|
Actor unit = MoveCamera._focus_unit;
|
||||||
string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix;
|
string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix;
|
||||||
// Short tags keep the old "Killed: Name (species) - life event" shape.
|
|
||||||
// Long strings are treated as the full event line (dossier wrap tests).
|
|
||||||
string line;
|
string line;
|
||||||
if (kindPrefix.Length <= 24 && kindPrefix.IndexOf(' ') < 0)
|
if (kindPrefix.Length <= 24 && kindPrefix.IndexOf(' ') < 0)
|
||||||
{
|
{
|
||||||
|
|
@ -556,7 +597,7 @@ public static class Chronicle
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Harness: inject a World-feed line (does not touch character History).</summary>
|
/// <summary>Harness: inject a World Memory landmark (does not touch character History).</summary>
|
||||||
public static bool ForceWorldNote(string label)
|
public static bool ForceWorldNote(string label)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(label))
|
if (string.IsNullOrEmpty(label))
|
||||||
|
|
@ -564,7 +605,15 @@ public static class Chronicle
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
AppendWorld(null, label, Vector3.zero);
|
AppendLandmark(
|
||||||
|
ChronicleKind.World,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
label,
|
||||||
|
LoreProse.Rewrite(ChronicleKind.World, label),
|
||||||
|
Vector3.zero,
|
||||||
|
"harness",
|
||||||
|
isLegend: false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -672,7 +721,9 @@ public static class Chronicle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshAgeChapter(force: false);
|
||||||
StampWorldDate(out double worldTime, out string dateLabel);
|
StampWorldDate(out double worldTime, out string dateLabel);
|
||||||
|
string lore = LoreProse.Rewrite(kind, line);
|
||||||
ChronicleEntry entry = new ChronicleEntry
|
ChronicleEntry entry = new ChronicleEntry
|
||||||
{
|
{
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
|
|
@ -684,7 +735,10 @@ public static class Chronicle
|
||||||
Name = SafeName(subject),
|
Name = SafeName(subject),
|
||||||
SpeciesId = SpeciesOf(subject),
|
SpeciesId = SpeciesOf(subject),
|
||||||
Line = line,
|
Line = line,
|
||||||
Position = subject.current_position
|
LoreLine = lore,
|
||||||
|
Position = subject.current_position,
|
||||||
|
AgeId = _currentAgeId,
|
||||||
|
AgeName = _currentAgeName
|
||||||
};
|
};
|
||||||
|
|
||||||
lock (Histories)
|
lock (Histories)
|
||||||
|
|
@ -703,40 +757,239 @@ public static class Chronicle
|
||||||
}
|
}
|
||||||
|
|
||||||
LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {entry.DisplayLine}");
|
LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {entry.DisplayLine}");
|
||||||
|
|
||||||
|
if (ShouldPromoteLegend(kind, subject, other))
|
||||||
|
{
|
||||||
|
AppendLandmark(
|
||||||
|
kind,
|
||||||
|
subject,
|
||||||
|
other,
|
||||||
|
line,
|
||||||
|
lore,
|
||||||
|
subject.current_position,
|
||||||
|
"legend:" + kind,
|
||||||
|
isLegend: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AppendWorld(Actor unit, string line, Vector3 fallbackPosition)
|
private static bool ShouldPromoteLegend(ChronicleKind kind, Actor subject, Actor other)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(line))
|
if (kind == ChronicleKind.Other || kind == ChronicleKind.AgeChapter)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsNotable(subject) || IsNotable(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNotable(Actor actor)
|
||||||
|
{
|
||||||
|
if (actor == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return actor.isFavorite() || actor.isKing() || actor.isCityLeader();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendLandmark(
|
||||||
|
ChronicleKind kind,
|
||||||
|
Actor subject,
|
||||||
|
Actor other,
|
||||||
|
string rawLine,
|
||||||
|
string loreLine,
|
||||||
|
Vector3 fallbackPosition,
|
||||||
|
string assetId,
|
||||||
|
bool isLegend)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(rawLine) && string.IsNullOrEmpty(loreLine))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshAgeChapter(force: false);
|
||||||
|
string ageId = string.IsNullOrEmpty(_currentAgeId) ? "unknown" : _currentAgeId;
|
||||||
|
|
||||||
|
string dedupeKey = ageId + "|" + assetId + "|" + rawLine;
|
||||||
|
if (!LandmarkDedupe.Add(dedupeKey))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (MemoryLandmarks)
|
||||||
|
{
|
||||||
|
if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount))
|
||||||
|
{
|
||||||
|
ageCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ageCount >= MaxLandmarksPerAge)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StampWorldDate(out double worldTime, out string dateLabel);
|
||||||
|
ChronicleEntry entry = new ChronicleEntry
|
||||||
|
{
|
||||||
|
CreatedAt = Time.unscaledTime,
|
||||||
|
WorldTime = worldTime,
|
||||||
|
DateLabel = dateLabel,
|
||||||
|
Kind = kind,
|
||||||
|
SubjectId = subject != null ? subject.getID() : 0,
|
||||||
|
OtherId = other != null ? other.getID() : 0,
|
||||||
|
Name = subject != null ? SafeName(subject) : "",
|
||||||
|
SpeciesId = subject != null ? SpeciesOf(subject) : "",
|
||||||
|
Line = rawLine ?? "",
|
||||||
|
LoreLine = string.IsNullOrEmpty(loreLine) ? (rawLine ?? "") : loreLine,
|
||||||
|
Position = subject != null ? subject.current_position : fallbackPosition,
|
||||||
|
AssetId = assetId ?? "",
|
||||||
|
AgeId = ageId,
|
||||||
|
AgeName = _currentAgeName,
|
||||||
|
IsLegend = isLegend
|
||||||
|
};
|
||||||
|
|
||||||
|
MemoryLandmarks.Add(entry);
|
||||||
|
LandmarksPerAge[ageId] = ageCount + 1;
|
||||||
|
while (MemoryLandmarks.Count > MaxLandmarks)
|
||||||
|
{
|
||||||
|
ChronicleEntry removed = MemoryLandmarks[0];
|
||||||
|
MemoryLandmarks.RemoveAt(0);
|
||||||
|
if (!string.IsNullOrEmpty(removed.AgeId)
|
||||||
|
&& LandmarksPerAge.TryGetValue(removed.AgeId, out int c)
|
||||||
|
&& c > 0)
|
||||||
|
{
|
||||||
|
LandmarksPerAge[removed.AgeId] = c - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogService.LogInfo("[IdleSpectator][MEMORY] " + entry.DisplayLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RefreshAgeChapter(bool force)
|
||||||
|
{
|
||||||
|
string ageId;
|
||||||
|
string ageName;
|
||||||
|
if (!TryReadCurrentAge(out ageId, out ageName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && ageId == _currentAgeId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed = ageId != _currentAgeId;
|
||||||
|
_currentAgeId = ageId;
|
||||||
|
_currentAgeName = ageName;
|
||||||
|
|
||||||
|
if (!changed && !force)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New age chapter: reset per-age cap tracking for the new id (keep old counts).
|
||||||
|
if (!LandmarksPerAge.ContainsKey(ageId))
|
||||||
|
{
|
||||||
|
LandmarksPerAge[ageId] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
StampWorldDate(out double worldTime, out string dateLabel);
|
StampWorldDate(out double worldTime, out string dateLabel);
|
||||||
ChronicleEntry entry = new ChronicleEntry
|
ChronicleEntry chapter = new ChronicleEntry
|
||||||
{
|
{
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
WorldTime = worldTime,
|
WorldTime = worldTime,
|
||||||
DateLabel = dateLabel,
|
DateLabel = dateLabel,
|
||||||
Kind = ChronicleKind.World,
|
Kind = ChronicleKind.AgeChapter,
|
||||||
SubjectId = unit != null ? unit.getID() : 0,
|
Line = ageName,
|
||||||
OtherId = 0,
|
LoreLine = ageName,
|
||||||
Name = unit != null ? SafeName(unit) : "",
|
AgeId = ageId,
|
||||||
SpeciesId = unit != null ? SpeciesOf(unit) : "",
|
AgeName = ageName,
|
||||||
Line = line,
|
AssetId = "age:" + ageId
|
||||||
Position = unit != null ? unit.current_position : fallbackPosition
|
|
||||||
};
|
};
|
||||||
|
|
||||||
lock (WorldEntries)
|
lock (MemoryLandmarks)
|
||||||
{
|
{
|
||||||
WorldEntries.Add(entry);
|
// Avoid duplicate chapter headers for the same age.
|
||||||
while (WorldEntries.Count > MaxWorldEntries)
|
for (int i = MemoryLandmarks.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
WorldEntries.RemoveAt(0);
|
ChronicleEntry e = MemoryLandmarks[i];
|
||||||
|
if (e != null && e.Kind == ChronicleKind.AgeChapter && e.AgeId == ageId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MemoryLandmarks.Add(chapter);
|
||||||
|
while (MemoryLandmarks.Count > MaxLandmarks)
|
||||||
|
{
|
||||||
|
MemoryLandmarks.RemoveAt(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LogService.LogInfo("[IdleSpectator][CHRONICLE][World] " + entry.DisplayLine);
|
LogService.LogInfo("[IdleSpectator][MEMORY] Age chapter: " + ageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadCurrentAge(out string ageId, out string ageName)
|
||||||
|
{
|
||||||
|
ageId = "";
|
||||||
|
ageName = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (World.world == null || World.world.era_manager == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldAgeAsset age = World.world.era_manager.getCurrentAge();
|
||||||
|
if (age == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ageId = age.id ?? "unknown";
|
||||||
|
ageName = ageId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string locale = age.getLocaleID();
|
||||||
|
if (!string.IsNullOrEmpty(locale))
|
||||||
|
{
|
||||||
|
string translated = LocalizedTextManager.getText(locale);
|
||||||
|
if (!string.IsNullOrEmpty(translated))
|
||||||
|
{
|
||||||
|
ageName = translated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// keep id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(ageName))
|
||||||
|
{
|
||||||
|
ageName = ageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -761,7 +1014,6 @@ public static class Chronicle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// raw: [day, month, year] - UI uses month + year only
|
|
||||||
string monthName = Date.formatMonth(raw[1]);
|
string monthName = Date.formatMonth(raw[1]);
|
||||||
if (string.IsNullOrEmpty(monthName))
|
if (string.IsNullOrEmpty(monthName))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,41 +7,43 @@ using UnityEngine.UI;
|
||||||
namespace IdleSpectator;
|
namespace IdleSpectator;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compact bottom-right chronicle card with History | World tabs and vanilla icons.
|
/// Compact bottom-right World Memory card: age chapters + lore landmarks (F9).
|
||||||
|
/// Sized in canvas units (same scaler as the dossier) to stay clear of top-right.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ChronicleHud
|
public static class ChronicleHud
|
||||||
{
|
{
|
||||||
private const float PanelWidth = 380f;
|
// Reference units on CanvasMain (~285x420). Keep modest so high-res scale is not a wall.
|
||||||
private const float PanelHeight = 280f;
|
private const float PanelWidth = 220f;
|
||||||
private const int MaxVisibleRows = 24;
|
private const float PanelHeight = 124f;
|
||||||
private const float RowHeightMin = 28f;
|
private const float ScreenInset = 12f;
|
||||||
private const float KindIconSize = 14f;
|
private const float BottomClearance = 64f;
|
||||||
private const float TitleIconSize = 16f;
|
private const int MaxVisibleRows = 20;
|
||||||
|
private const float RowHeightMin = 24f;
|
||||||
|
private const float ChapterHeight = 18f;
|
||||||
|
private const float KindIconSize = 12f;
|
||||||
|
private const float TitleIconSize = 14f;
|
||||||
|
|
||||||
private static GameObject _root;
|
private static GameObject _root;
|
||||||
|
private static RectTransform _rootRt;
|
||||||
private static RectTransform _content;
|
private static RectTransform _content;
|
||||||
private static Text _titleText;
|
private static Text _titleText;
|
||||||
private static Image _titleIcon;
|
private static Image _titleIcon;
|
||||||
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 readonly List<GameObject> Rows = new List<GameObject>();
|
||||||
private static bool _visible;
|
private static bool _visible;
|
||||||
private static int _lastCount = -1;
|
private static int _lastCount = -1;
|
||||||
private static long _lastSubjectId = -1;
|
private static string _lastAgeId = "";
|
||||||
private static ChronicleTab _tab = ChronicleTab.History;
|
|
||||||
|
|
||||||
public static bool Visible => _visible && _root != null && _root.activeSelf;
|
public static bool Visible => _visible && _root != null && _root.activeSelf;
|
||||||
|
|
||||||
public static bool IsReady => _root != null;
|
public static bool IsReady => _root != null;
|
||||||
|
|
||||||
public static ChronicleTab ActiveTab => _tab;
|
|
||||||
|
|
||||||
public static void EnsureBuilt()
|
public static void EnsureBuilt()
|
||||||
{
|
{
|
||||||
|
DestroyOrphanHuds();
|
||||||
|
|
||||||
if (_root != null)
|
if (_root != null)
|
||||||
{
|
{
|
||||||
|
ApplyRootPlacement();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,69 +58,73 @@ public static class ChronicleHud
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_root = new GameObject("IdleSpectatorChronicleHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
|
_root = new GameObject("IdleSpectatorWorldMemoryHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
|
||||||
_root.transform.SetParent(canvas.transform, false);
|
_root.transform.SetParent(canvas.transform, false);
|
||||||
|
|
||||||
RectTransform rt = _root.GetComponent<RectTransform>();
|
_rootRt = _root.GetComponent<RectTransform>();
|
||||||
rt.anchorMin = new Vector2(1f, 0f);
|
ApplyRootPlacement();
|
||||||
rt.anchorMax = new Vector2(1f, 0f);
|
|
||||||
rt.pivot = new Vector2(1f, 0f);
|
|
||||||
rt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
|
|
||||||
rt.anchoredPosition = new Vector2(-12f, 56f);
|
|
||||||
|
|
||||||
Image bg = _root.GetComponent<Image>();
|
Image bg = _root.GetComponent<Image>();
|
||||||
bg.raycastTarget = true;
|
bg.raycastTarget = true;
|
||||||
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.82f));
|
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.88f));
|
||||||
|
|
||||||
CanvasGroup group = _root.GetComponent<CanvasGroup>();
|
CanvasGroup group = _root.GetComponent<CanvasGroup>();
|
||||||
group.blocksRaycasts = true;
|
group.blocksRaycasts = true;
|
||||||
group.interactable = true;
|
group.interactable = true;
|
||||||
|
|
||||||
BuildTabButton(_root.transform, "HistoryTab", "History", "iconBooks", -6f, 0f,
|
Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 8);
|
||||||
out _historyTabBg, out _historyTabLabel, () => SetTab(ChronicleTab.History));
|
|
||||||
BuildTabButton(_root.transform, "WorldTab", "World", "iconWar", -6f, 88f,
|
|
||||||
out _worldTabBg, out _worldTabLabel, () => SetTab(ChronicleTab.World));
|
|
||||||
|
|
||||||
Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 9);
|
|
||||||
RectTransform f9Rt = f9.GetComponent<RectTransform>();
|
RectTransform f9Rt = f9.GetComponent<RectTransform>();
|
||||||
f9Rt.anchorMin = new Vector2(1f, 1f);
|
f9Rt.anchorMin = new Vector2(1f, 1f);
|
||||||
f9Rt.anchorMax = new Vector2(1f, 1f);
|
f9Rt.anchorMax = new Vector2(1f, 1f);
|
||||||
f9Rt.pivot = new Vector2(1f, 1f);
|
f9Rt.pivot = new Vector2(1f, 1f);
|
||||||
f9Rt.sizeDelta = new Vector2(28f, 16f);
|
f9Rt.sizeDelta = new Vector2(24f, 14f);
|
||||||
f9Rt.anchoredPosition = new Vector2(-8f, -8f);
|
f9Rt.anchoredPosition = new Vector2(-6f, -4f);
|
||||||
f9.alignment = TextAnchor.MiddleRight;
|
f9.alignment = TextAnchor.MiddleRight;
|
||||||
f9.color = new Color(0.55f, 0.58f, 0.62f, 1f);
|
f9.color = new Color(0.55f, 0.58f, 0.62f, 1f);
|
||||||
|
f9.raycastTarget = false;
|
||||||
|
|
||||||
_titleIcon = HudCanvas.MakeIcon(_root.transform, "TitleIcon", TitleIconSize);
|
_titleIcon = HudCanvas.MakeIcon(_root.transform, "TitleIcon", TitleIconSize);
|
||||||
RectTransform titleIconRt = _titleIcon.GetComponent<RectTransform>();
|
RectTransform titleIconRt = _titleIcon.GetComponent<RectTransform>();
|
||||||
titleIconRt.anchorMin = new Vector2(0f, 1f);
|
titleIconRt.anchorMin = new Vector2(0f, 1f);
|
||||||
titleIconRt.anchorMax = new Vector2(0f, 1f);
|
titleIconRt.anchorMax = new Vector2(0f, 1f);
|
||||||
titleIconRt.pivot = new Vector2(0.5f, 0.5f);
|
titleIconRt.pivot = new Vector2(0.5f, 0.5f);
|
||||||
titleIconRt.anchoredPosition = new Vector2(16f, -34f);
|
titleIconRt.anchoredPosition = new Vector2(14f, -14f);
|
||||||
|
HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks") ?? HudIcons.FromUiIcon("iconWar"));
|
||||||
|
|
||||||
_titleText = HudCanvas.MakeText(_root.transform, "Title", "", 9);
|
_titleText = HudCanvas.MakeText(_root.transform, "Title", "World Memory", 9);
|
||||||
RectTransform titleRt = _titleText.GetComponent<RectTransform>();
|
RectTransform titleRt = _titleText.GetComponent<RectTransform>();
|
||||||
titleRt.anchorMin = new Vector2(0f, 1f);
|
titleRt.anchorMin = new Vector2(0f, 1f);
|
||||||
titleRt.anchorMax = new Vector2(1f, 1f);
|
titleRt.anchorMax = new Vector2(1f, 1f);
|
||||||
titleRt.pivot = new Vector2(0.5f, 1f);
|
titleRt.pivot = new Vector2(0.5f, 1f);
|
||||||
titleRt.offsetMin = new Vector2(28f, -44f);
|
titleRt.offsetMin = new Vector2(24f, -24f);
|
||||||
titleRt.offsetMax = new Vector2(-10f, -28f);
|
titleRt.offsetMax = new Vector2(-28f, -4f);
|
||||||
_titleText.alignment = TextAnchor.MiddleLeft;
|
_titleText.alignment = TextAnchor.MiddleLeft;
|
||||||
_titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f);
|
_titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f);
|
||||||
|
_titleText.fontStyle = FontStyle.Bold;
|
||||||
|
_titleText.raycastTarget = false;
|
||||||
|
_titleText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||||
|
|
||||||
GameObject scrollGo = new GameObject("Scroll", typeof(RectTransform), typeof(Image), typeof(ScrollRect), typeof(Mask));
|
GameObject scrollGo = new GameObject(
|
||||||
|
"Scroll",
|
||||||
|
typeof(RectTransform),
|
||||||
|
typeof(Image),
|
||||||
|
typeof(ScrollRect),
|
||||||
|
typeof(RectMask2D));
|
||||||
scrollGo.transform.SetParent(_root.transform, false);
|
scrollGo.transform.SetParent(_root.transform, false);
|
||||||
RectTransform scrollRt = scrollGo.GetComponent<RectTransform>();
|
RectTransform scrollRt = scrollGo.GetComponent<RectTransform>();
|
||||||
scrollRt.anchorMin = new Vector2(0f, 0f);
|
scrollRt.anchorMin = new Vector2(0f, 0f);
|
||||||
scrollRt.anchorMax = new Vector2(1f, 1f);
|
scrollRt.anchorMax = new Vector2(1f, 1f);
|
||||||
scrollRt.offsetMin = new Vector2(6f, 6f);
|
scrollRt.offsetMin = new Vector2(5f, 5f);
|
||||||
scrollRt.offsetMax = new Vector2(-6f, -48f);
|
scrollRt.offsetMax = new Vector2(-5f, -26f);
|
||||||
Image scrollBg = scrollGo.GetComponent<Image>();
|
Image scrollBg = scrollGo.GetComponent<Image>();
|
||||||
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
|
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
|
||||||
Mask mask = scrollGo.GetComponent<Mask>();
|
scrollBg.raycastTarget = true;
|
||||||
mask.showMaskGraphic = false;
|
|
||||||
|
|
||||||
GameObject contentGo = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
|
GameObject contentGo = new GameObject(
|
||||||
|
"Content",
|
||||||
|
typeof(RectTransform),
|
||||||
|
typeof(VerticalLayoutGroup),
|
||||||
|
typeof(ContentSizeFitter));
|
||||||
contentGo.transform.SetParent(scrollGo.transform, false);
|
contentGo.transform.SetParent(scrollGo.transform, false);
|
||||||
_content = contentGo.GetComponent<RectTransform>();
|
_content = contentGo.GetComponent<RectTransform>();
|
||||||
_content.anchorMin = new Vector2(0f, 1f);
|
_content.anchorMin = new Vector2(0f, 1f);
|
||||||
|
|
@ -133,7 +139,7 @@ public static class ChronicleHud
|
||||||
layout.childControlWidth = true;
|
layout.childControlWidth = true;
|
||||||
layout.childForceExpandHeight = false;
|
layout.childForceExpandHeight = false;
|
||||||
layout.childForceExpandWidth = true;
|
layout.childForceExpandWidth = true;
|
||||||
layout.spacing = 3f;
|
layout.spacing = 2f;
|
||||||
layout.padding = new RectOffset(0, 0, 0, 0);
|
layout.padding = new RectOffset(0, 0, 0, 0);
|
||||||
|
|
||||||
ContentSizeFitter fitter = contentGo.GetComponent<ContentSizeFitter>();
|
ContentSizeFitter fitter = contentGo.GetComponent<ContentSizeFitter>();
|
||||||
|
|
@ -147,17 +153,111 @@ public static class ChronicleHud
|
||||||
scroll.movementType = ScrollRect.MovementType.Clamped;
|
scroll.movementType = ScrollRect.MovementType.Clamped;
|
||||||
scroll.viewport = scrollRt;
|
scroll.viewport = scrollRt;
|
||||||
|
|
||||||
RefreshTabChrome();
|
|
||||||
_root.SetActive(false);
|
_root.SetActive(false);
|
||||||
_visible = false;
|
_visible = false;
|
||||||
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World + icons)");
|
LogService.LogInfo("[IdleSpectator] World Memory HUD ready (F9, compact)");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SetTab(ChronicleTab tab)
|
private static void DestroyOrphanHuds()
|
||||||
{
|
{
|
||||||
_tab = tab;
|
// Hot-reload / rename can leave old full-screen-looking cards in the canvas.
|
||||||
RefreshTabChrome();
|
try
|
||||||
Rebuild(force: true);
|
{
|
||||||
|
Canvas canvas = HudCanvas.Resolve();
|
||||||
|
if (canvas == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = canvas.transform.childCount - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
Transform child = canvas.transform.GetChild(i);
|
||||||
|
if (child == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string n = child.name;
|
||||||
|
if (n != "IdleSpectatorChronicleHud" && n != "IdleSpectatorWorldMemoryHud")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_root != null && child.gameObject == _root)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.Destroy(child.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyRootPlacement()
|
||||||
|
{
|
||||||
|
if (_root == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rootRt == null)
|
||||||
|
{
|
||||||
|
_rootRt = _root.GetComponent<RectTransform>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rootRt == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom-right, same canvas units as the dossier so PlayerConfig UI size scales both.
|
||||||
|
_rootRt.anchorMin = new Vector2(1f, 0f);
|
||||||
|
_rootRt.anchorMax = new Vector2(1f, 0f);
|
||||||
|
_rootRt.pivot = new Vector2(1f, 0f);
|
||||||
|
_rootRt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
|
||||||
|
_rootRt.anchoredPosition = new Vector2(-ScreenInset, BottomClearance);
|
||||||
|
_rootRt.localScale = Vector3.one;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsPointerOverPanel()
|
||||||
|
{
|
||||||
|
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RectTransformUtility.RectangleContainsScreenPoint(_rootRt, Input.mousePosition, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetScreenPixelRect(out Rect pixelRect)
|
||||||
|
{
|
||||||
|
pixelRect = default;
|
||||||
|
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3[] corners = new Vector3[4];
|
||||||
|
_rootRt.GetWorldCorners(corners);
|
||||||
|
float xMin = float.MaxValue;
|
||||||
|
float yMin = float.MaxValue;
|
||||||
|
float xMax = float.MinValue;
|
||||||
|
float yMax = float.MinValue;
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
Vector2 sp = RectTransformUtility.WorldToScreenPoint(null, corners[i]);
|
||||||
|
xMin = Mathf.Min(xMin, sp.x);
|
||||||
|
yMin = Mathf.Min(yMin, sp.y);
|
||||||
|
xMax = Mathf.Max(xMax, sp.x);
|
||||||
|
yMax = Mathf.Max(yMax, sp.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
pixelRect = Rect.MinMaxRect(xMin, yMin, xMax, yMax);
|
||||||
|
return pixelRect.width > 8f && pixelRect.height > 8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SetVisible(bool visible)
|
public static void SetVisible(bool visible)
|
||||||
|
|
@ -168,6 +268,7 @@ public static class ChronicleHud
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyRootPlacement();
|
||||||
_visible = visible;
|
_visible = visible;
|
||||||
_root.SetActive(visible);
|
_root.SetActive(visible);
|
||||||
if (visible)
|
if (visible)
|
||||||
|
|
@ -188,12 +289,11 @@ public static class ChronicleHud
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long subjectId = Chronicle.CurrentHistorySubjectId();
|
ApplyRootPlacement();
|
||||||
int count = _tab == ChronicleTab.History
|
|
||||||
? Chronicle.HistoryCountFor(subjectId)
|
|
||||||
: Chronicle.WorldCount;
|
|
||||||
|
|
||||||
if (count != _lastCount || subjectId != _lastSubjectId)
|
int count = Chronicle.MemoryCount;
|
||||||
|
string ageId = Chronicle.CurrentAgeId;
|
||||||
|
if (count != _lastCount || ageId != _lastAgeId)
|
||||||
{
|
{
|
||||||
Rebuild(force: true);
|
Rebuild(force: true);
|
||||||
}
|
}
|
||||||
|
|
@ -207,69 +307,40 @@ public static class ChronicleHud
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyRootPlacement();
|
||||||
ClearRows();
|
ClearRows();
|
||||||
RefreshTabChrome();
|
|
||||||
|
|
||||||
long subjectId = Chronicle.CurrentHistorySubjectId();
|
IReadOnlyList<ChronicleEntry> entries = Chronicle.SnapshotMemory();
|
||||||
_lastSubjectId = subjectId;
|
_lastCount = entries.Count;
|
||||||
|
_lastAgeId = Chronicle.CurrentAgeId;
|
||||||
|
|
||||||
IReadOnlyList<ChronicleEntry> entries;
|
string ageTitle = Chronicle.CurrentAgeName;
|
||||||
if (_tab == ChronicleTab.World)
|
if (_titleText != null)
|
||||||
{
|
{
|
||||||
entries = Chronicle.SnapshotWorld();
|
_titleText.text = string.IsNullOrEmpty(ageTitle)
|
||||||
_lastCount = entries.Count;
|
? "World Memory"
|
||||||
if (_titleText != null)
|
: "World Memory · " + ageTitle;
|
||||||
{
|
|
||||||
_titleText.text = "World events";
|
|
||||||
}
|
|
||||||
|
|
||||||
HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconWar") ?? HudIcons.FromUiIcon("iconBooks"));
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
||||||
if (focus != null)
|
|
||||||
{
|
|
||||||
HudIcons.Apply(_titleIcon, HudIcons.FromActor(focus));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subjectId == 0)
|
|
||||||
{
|
|
||||||
AddEmpty("Focus a creature to see history");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entries.Count == 0)
|
|
||||||
{
|
|
||||||
AddEmpty("No history yet");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entries.Count == 0)
|
||||||
|
{
|
||||||
|
AddEmpty("No landmarks yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChronicleEntry> display = BuildDisplayOrder(entries);
|
||||||
int shown = 0;
|
int shown = 0;
|
||||||
for (int i = entries.Count - 1; i >= 0; i--)
|
for (int i = 0; i < display.Count; i++)
|
||||||
{
|
{
|
||||||
GameObject row = BuildClickableRow(entries[i], shown);
|
ChronicleEntry entry = display[i];
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameObject row = entry.Kind == ChronicleKind.AgeChapter
|
||||||
|
? BuildChapterRow(entry)
|
||||||
|
: BuildClickableRow(entry, shown);
|
||||||
row.transform.SetParent(_content, false);
|
row.transform.SetParent(_content, false);
|
||||||
Rows.Add(row);
|
Rows.Add(row);
|
||||||
shown++;
|
shown++;
|
||||||
|
|
@ -280,6 +351,89 @@ public static class ChronicleHud
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<ChronicleEntry> BuildDisplayOrder(IReadOnlyList<ChronicleEntry> chronological)
|
||||||
|
{
|
||||||
|
var ageOrder = new List<string>();
|
||||||
|
var chapters = new Dictionary<string, ChronicleEntry>();
|
||||||
|
var byAge = new Dictionary<string, List<ChronicleEntry>>();
|
||||||
|
|
||||||
|
for (int i = 0; i < chronological.Count; i++)
|
||||||
|
{
|
||||||
|
ChronicleEntry e = chronological[i];
|
||||||
|
if (e == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string ageId = string.IsNullOrEmpty(e.AgeId) ? "unknown" : e.AgeId;
|
||||||
|
if (e.Kind == ChronicleKind.AgeChapter)
|
||||||
|
{
|
||||||
|
chapters[ageId] = e;
|
||||||
|
if (!ageOrder.Contains(ageId))
|
||||||
|
{
|
||||||
|
ageOrder.Add(ageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byAge.TryGetValue(ageId, out List<ChronicleEntry> list))
|
||||||
|
{
|
||||||
|
list = new List<ChronicleEntry>();
|
||||||
|
byAge[ageId] = list;
|
||||||
|
if (!ageOrder.Contains(ageId))
|
||||||
|
{
|
||||||
|
ageOrder.Add(ageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ChronicleEntry>();
|
||||||
|
for (int a = ageOrder.Count - 1; a >= 0; a--)
|
||||||
|
{
|
||||||
|
string ageId = ageOrder[a];
|
||||||
|
bool newestAge = a == ageOrder.Count - 1;
|
||||||
|
|
||||||
|
if (chapters.TryGetValue(ageId, out ChronicleEntry chapter))
|
||||||
|
{
|
||||||
|
result.Add(chapter);
|
||||||
|
}
|
||||||
|
else if (byAge.ContainsKey(ageId))
|
||||||
|
{
|
||||||
|
string name = byAge[ageId].Count > 0 ? byAge[ageId][0].AgeName : ageId;
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
name = ageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new ChronicleEntry
|
||||||
|
{
|
||||||
|
Kind = ChronicleKind.AgeChapter,
|
||||||
|
AgeId = ageId,
|
||||||
|
AgeName = name,
|
||||||
|
Line = name,
|
||||||
|
LoreLine = name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byAge.TryGetValue(ageId, out List<ChronicleEntry> landmarks) || landmarks.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cap = newestAge ? MaxVisibleRows : 2;
|
||||||
|
int take = Mathf.Min(cap, landmarks.Count);
|
||||||
|
for (int i = landmarks.Count - 1; i >= landmarks.Count - take; i--)
|
||||||
|
{
|
||||||
|
result.Add(landmarks[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddEmpty(string text)
|
private static void AddEmpty(string text)
|
||||||
{
|
{
|
||||||
GameObject empty = BuildStaticRow(text);
|
GameObject empty = BuildStaticRow(text);
|
||||||
|
|
@ -287,80 +441,6 @@ public static class ChronicleHud
|
||||||
Rows.Add(empty);
|
Rows.Add(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BuildTabButton(
|
|
||||||
Transform parent,
|
|
||||||
string name,
|
|
||||||
string label,
|
|
||||||
string iconName,
|
|
||||||
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(82f, 18f);
|
|
||||||
rt.anchoredPosition = new Vector2(8f + x, yFromTop);
|
|
||||||
|
|
||||||
bg = go.GetComponent<Image>();
|
|
||||||
bg.color = new Color(0.15f, 0.16f, 0.2f, 0.9f);
|
|
||||||
|
|
||||||
Image icon = HudCanvas.MakeIcon(go.transform, "Icon", 12f);
|
|
||||||
RectTransform iconRt = icon.GetComponent<RectTransform>();
|
|
||||||
iconRt.anchorMin = new Vector2(0f, 0.5f);
|
|
||||||
iconRt.anchorMax = new Vector2(0f, 0.5f);
|
|
||||||
iconRt.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
iconRt.anchoredPosition = new Vector2(10f, 0f);
|
|
||||||
HudIcons.Apply(icon, HudIcons.FromUiIcon(iconName));
|
|
||||||
|
|
||||||
text = HudCanvas.MakeText(go.transform, "Label", label, 9);
|
|
||||||
RectTransform textRt = text.GetComponent<RectTransform>();
|
|
||||||
textRt.anchorMin = Vector2.zero;
|
|
||||||
textRt.anchorMax = Vector2.one;
|
|
||||||
textRt.offsetMin = new Vector2(20f, 0f);
|
|
||||||
textRt.offsetMax = new Vector2(-2f, 0f);
|
|
||||||
text.alignment = TextAnchor.MiddleLeft;
|
|
||||||
text.raycastTarget = false;
|
|
||||||
|
|
||||||
Button button = go.GetComponent<Button>();
|
|
||||||
button.onClick.AddListener(onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RefreshTabChrome()
|
|
||||||
{
|
|
||||||
Color active = new Color(0.32f, 0.38f, 0.48f, 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()
|
private static void ClearRows()
|
||||||
{
|
{
|
||||||
foreach (GameObject row in Rows)
|
foreach (GameObject row in Rows)
|
||||||
|
|
@ -374,22 +454,42 @@ public static class ChronicleHud
|
||||||
Rows.Clear();
|
Rows.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GameObject BuildChapterRow(ChronicleEntry entry)
|
||||||
|
{
|
||||||
|
string title = !string.IsNullOrEmpty(entry.AgeName) ? entry.AgeName : entry.HudLine;
|
||||||
|
GameObject go = new GameObject("Chapter", typeof(RectTransform), typeof(LayoutElement));
|
||||||
|
RectTransform rt = go.GetComponent<RectTransform>();
|
||||||
|
rt.sizeDelta = new Vector2(PanelWidth - 14f, ChapterHeight);
|
||||||
|
LayoutElement le = go.GetComponent<LayoutElement>();
|
||||||
|
le.minHeight = ChapterHeight;
|
||||||
|
le.preferredHeight = ChapterHeight;
|
||||||
|
|
||||||
|
Text label = HudCanvas.MakeText(go.transform, "Text", "— " + title + " —", 8);
|
||||||
|
Stretch(label.GetComponent<RectTransform>(), 2f);
|
||||||
|
label.alignment = TextAnchor.MiddleCenter;
|
||||||
|
label.color = new Color(0.7f, 0.74f, 0.82f, 1f);
|
||||||
|
label.fontStyle = FontStyle.Bold;
|
||||||
|
label.raycastTarget = false;
|
||||||
|
return go;
|
||||||
|
}
|
||||||
|
|
||||||
private static GameObject BuildStaticRow(string text)
|
private static GameObject BuildStaticRow(string text)
|
||||||
{
|
{
|
||||||
GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
|
GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
|
||||||
RectTransform rt = go.GetComponent<RectTransform>();
|
RectTransform rt = go.GetComponent<RectTransform>();
|
||||||
rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeightMin);
|
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
|
||||||
LayoutElement le = go.GetComponent<LayoutElement>();
|
LayoutElement le = go.GetComponent<LayoutElement>();
|
||||||
le.minHeight = RowHeightMin;
|
le.minHeight = RowHeightMin;
|
||||||
le.preferredHeight = RowHeightMin;
|
le.preferredHeight = RowHeightMin;
|
||||||
|
|
||||||
Text label = HudCanvas.MakeText(go.transform, "Text", text, 9);
|
Text label = HudCanvas.MakeText(go.transform, "Text", text, 8);
|
||||||
Stretch(label.GetComponent<RectTransform>(), 4f);
|
Stretch(label.GetComponent<RectTransform>(), 3f);
|
||||||
label.alignment = TextAnchor.MiddleLeft;
|
label.alignment = TextAnchor.MiddleLeft;
|
||||||
label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f);
|
label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f);
|
||||||
label.resizeTextForBestFit = false;
|
label.resizeTextForBestFit = false;
|
||||||
label.horizontalOverflow = HorizontalWrapMode.Wrap;
|
label.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||||
label.verticalOverflow = VerticalWrapMode.Overflow;
|
label.verticalOverflow = VerticalWrapMode.Overflow;
|
||||||
|
label.raycastTarget = false;
|
||||||
return go;
|
return go;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -403,7 +503,7 @@ public static class ChronicleHud
|
||||||
typeof(LayoutElement),
|
typeof(LayoutElement),
|
||||||
typeof(ContentSizeFitter));
|
typeof(ContentSizeFitter));
|
||||||
RectTransform rt = go.GetComponent<RectTransform>();
|
RectTransform rt = go.GetComponent<RectTransform>();
|
||||||
rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeightMin);
|
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
|
||||||
|
|
||||||
LayoutElement le = go.GetComponent<LayoutElement>();
|
LayoutElement le = go.GetComponent<LayoutElement>();
|
||||||
le.minHeight = RowHeightMin;
|
le.minHeight = RowHeightMin;
|
||||||
|
|
@ -414,30 +514,34 @@ public static class ChronicleHud
|
||||||
rowFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
rowFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
||||||
|
|
||||||
Image bg = go.GetComponent<Image>();
|
Image bg = go.GetComponent<Image>();
|
||||||
bg.color = new Color(1f, 1f, 1f, 0.03f);
|
bg.color = entry.IsLegend
|
||||||
|
? new Color(0.95f, 0.85f, 0.45f, 0.06f)
|
||||||
|
: new Color(1f, 1f, 1f, 0.03f);
|
||||||
|
bg.raycastTarget = true;
|
||||||
|
|
||||||
Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize);
|
Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize);
|
||||||
RectTransform kindRt = kindIcon.GetComponent<RectTransform>();
|
RectTransform kindRt = kindIcon.GetComponent<RectTransform>();
|
||||||
kindRt.anchorMin = new Vector2(0f, 1f);
|
kindRt.anchorMin = new Vector2(0f, 1f);
|
||||||
kindRt.anchorMax = new Vector2(0f, 1f);
|
kindRt.anchorMax = new Vector2(0f, 1f);
|
||||||
kindRt.pivot = new Vector2(0.5f, 1f);
|
kindRt.pivot = new Vector2(0.5f, 1f);
|
||||||
kindRt.anchoredPosition = new Vector2(10f, -7f);
|
kindRt.anchoredPosition = new Vector2(9f, -5f);
|
||||||
|
kindIcon.raycastTarget = false;
|
||||||
HudIcons.Apply(kindIcon, HudIcons.ForChronicleKind(entry.Kind));
|
HudIcons.Apply(kindIcon, HudIcons.ForChronicleKind(entry.Kind));
|
||||||
|
|
||||||
Text label = HudCanvas.MakeText(go.transform, "Text", entry.DisplayLineRich, 9);
|
Text label = HudCanvas.MakeText(go.transform, "Text", entry.DisplayLineRich, 8);
|
||||||
RectTransform labelRt = label.GetComponent<RectTransform>();
|
RectTransform labelRt = label.GetComponent<RectTransform>();
|
||||||
labelRt.anchorMin = new Vector2(0f, 0f);
|
labelRt.anchorMin = new Vector2(0f, 0f);
|
||||||
labelRt.anchorMax = new Vector2(1f, 1f);
|
labelRt.anchorMax = new Vector2(1f, 1f);
|
||||||
labelRt.offsetMin = new Vector2(22f, 4f);
|
labelRt.offsetMin = new Vector2(18f, 2f);
|
||||||
labelRt.offsetMax = new Vector2(-6f, -4f);
|
labelRt.offsetMax = new Vector2(-4f, -2f);
|
||||||
label.alignment = TextAnchor.UpperLeft;
|
label.alignment = TextAnchor.UpperLeft;
|
||||||
label.color = ColorForKind(entry.Kind);
|
label.color = ColorForKind(entry.Kind);
|
||||||
label.supportRichText = true;
|
label.supportRichText = true;
|
||||||
label.resizeTextForBestFit = false;
|
label.resizeTextForBestFit = false;
|
||||||
label.horizontalOverflow = HorizontalWrapMode.Wrap;
|
label.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||||
label.verticalOverflow = VerticalWrapMode.Overflow;
|
label.verticalOverflow = VerticalWrapMode.Overflow;
|
||||||
|
label.raycastTarget = false;
|
||||||
|
|
||||||
// Drive row height from wrapped text preferred height.
|
|
||||||
ContentSizeFitter textFit = label.gameObject.AddComponent<ContentSizeFitter>();
|
ContentSizeFitter textFit = label.gameObject.AddComponent<ContentSizeFitter>();
|
||||||
textFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
textFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||||
textFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
textFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
||||||
|
|
@ -453,7 +557,7 @@ public static class ChronicleHud
|
||||||
button.onClick.AddListener(new UnityAction(() =>
|
button.onClick.AddListener(new UnityAction(() =>
|
||||||
{
|
{
|
||||||
bool ok = Chronicle.JumpTo(captured);
|
bool ok = Chronicle.JumpTo(captured);
|
||||||
LogService.LogInfo($"[IdleSpectator][CHRONICLE] jump ok={ok} line={captured.DisplayLine}");
|
LogService.LogInfo($"[IdleSpectator][MEMORY] jump ok={ok} line={captured.DisplayLine}");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return go;
|
return go;
|
||||||
|
|
@ -473,6 +577,8 @@ public static class ChronicleHud
|
||||||
return new Color(0.58f, 0.85f, 0.95f, 1f);
|
return new Color(0.58f, 0.85f, 0.95f, 1f);
|
||||||
case ChronicleKind.World:
|
case ChronicleKind.World:
|
||||||
return new Color(0.95f, 0.85f, 0.48f, 1f);
|
return new Color(0.95f, 0.85f, 0.48f, 1f);
|
||||||
|
case ChronicleKind.AgeChapter:
|
||||||
|
return new Color(0.7f, 0.74f, 0.82f, 1f);
|
||||||
default:
|
default:
|
||||||
return new Color(0.88f, 0.88f, 0.9f, 1f);
|
return new Color(0.88f, 0.88f, 0.9f, 1f);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -441,24 +441,37 @@ internal static class HarnessScenarios
|
||||||
Step("ch16g", "chronicle_death_sample", value: "Fire"),
|
Step("ch16g", "chronicle_death_sample", value: "Fire"),
|
||||||
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
||||||
|
|
||||||
// World feed is separate from History
|
// World Memory landmarks (replaces History|World tabs)
|
||||||
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
|
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
|
||||||
Step("ch17b", "assert", expect: "chronicle_count", value: "1", label: "world"),
|
Step("ch17b", "assert", expect: "chronicle_count", value: "1", label: "world"),
|
||||||
Step("ch17c", "assert", expect: "chronicle_world_latest_contains", value: "harness_alpha"),
|
Step("ch17c", "assert", expect: "world_memory_contains", value: "harness_alpha"),
|
||||||
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
|
||||||
|
Step("ch17e", "assert", expect: "world_memory_age"),
|
||||||
|
|
||||||
// Tabs
|
// World Memory panel + dossier controls
|
||||||
Step("ch18", "chronicle_open"),
|
Step("ch18", "chronicle_open"),
|
||||||
Step("ch18a", "screenshot", value: "hud-chronicle_smoke.png"),
|
Step("ch18a", "screenshot", value: "hud-chronicle_smoke.png"),
|
||||||
Step("ch18a2", "wait", wait: 0.6f),
|
Step("ch18a2", "wait", wait: 0.6f),
|
||||||
Step("ch18b", "chronicle_tab", value: "history"),
|
Step("ch18b", "assert", expect: "world_memory_compact"),
|
||||||
|
Step("ch18b2", "assert", expect: "hud_no_overlap"),
|
||||||
Step("ch18c", "assert", expect: "caption_layout_ok"),
|
Step("ch18c", "assert", expect: "caption_layout_ok"),
|
||||||
Step("ch18c2", "assert", expect: "dossier_traits_ok"),
|
Step("ch18c2", "assert", expect: "dossier_traits_ok"),
|
||||||
Step("ch18d", "assert", expect: "chronicle_tab", value: "history"),
|
Step("ch18d", "assert", expect: "world_memory_contains", value: "harness_alpha"),
|
||||||
Step("ch18e", "chronicle_tab", value: "world"),
|
Step("ch18e", "dossier_favorite", value: "true"),
|
||||||
Step("ch18f", "assert", expect: "chronicle_tab", value: "world"),
|
Step("ch18f", "assert", expect: "is_favorite", value: "true"),
|
||||||
Step("ch18g", "chronicle_tab", value: "history"),
|
Step("ch18g", "dossier_favorite", value: "false"),
|
||||||
Step("ch18h", "wait", wait: 0.25f),
|
Step("ch18h", "assert", expect: "is_favorite", value: "false"),
|
||||||
|
// Full per-unit buffer: auto-scroll + history column must fill the dossier body.
|
||||||
|
Step("ch18h1", "chronicle_force", label: "Hist", count: 40),
|
||||||
|
Step("ch18h2", "wait", wait: 0.25f),
|
||||||
|
Step("ch18k", "assert", expect: "dossier_history_shown", value: "40", label: "exact"),
|
||||||
|
Step("ch18k2", "assert", expect: "dossier_history_scrollable"),
|
||||||
|
Step("ch18k3", "assert", expect: "dossier_history_fills_body"),
|
||||||
|
Step("ch18l", "assert", expect: "caption_layout_ok"),
|
||||||
|
Step("ch18l1", "assert", expect: "hud_no_overlap"),
|
||||||
|
Step("ch18l2", "screenshot", value: "hud-dossier-expanded.png"),
|
||||||
|
Step("ch18l3", "wait", wait: 0.55f),
|
||||||
|
Step("ch18p", "assert", expect: "hud_no_overlap"),
|
||||||
Step("ch19", "assert", expect: "focus", value: "true"),
|
Step("ch19", "assert", expect: "focus", value: "true"),
|
||||||
Step("ch20", "assert", expect: "health"),
|
Step("ch20", "assert", expect: "health"),
|
||||||
Step("ch21", "chronicle_jump"),
|
Step("ch21", "chronicle_jump"),
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,19 @@ public static class HudIcons
|
||||||
return FromUiIcon("iconFavoriteStar");
|
return FromUiIcon("iconFavoriteStar");
|
||||||
case ChronicleKind.World:
|
case ChronicleKind.World:
|
||||||
return FromUiIcon("iconWar") ?? FromUiIcon("iconBooks");
|
return FromUiIcon("iconWar") ?? FromUiIcon("iconBooks");
|
||||||
|
case ChronicleKind.AgeChapter:
|
||||||
|
return FromUiIcon("iconAge") ?? FromUiIcon("iconClock");
|
||||||
default:
|
default:
|
||||||
return FromUiIcon("iconClock");
|
return FromUiIcon("iconClock");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Sprite Favorite() =>
|
||||||
|
FromUiIcon("iconFavorite") ?? FromUiIcon("iconFavoriteStar") ?? FromUiIcon("iconStar");
|
||||||
|
|
||||||
|
public static Sprite ExpandHistory() =>
|
||||||
|
FromUiIcon("iconBooks") ?? FromUiIcon("iconList") ?? FromUiIcon("iconClock");
|
||||||
|
|
||||||
public static Sprite Age() => FromUiIcon("iconAge");
|
public static Sprite Age() => FromUiIcon("iconAge");
|
||||||
|
|
||||||
public static Sprite Level() => FromUiIcon("iconLevels");
|
public static Sprite Level() => FromUiIcon("iconLevels");
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,8 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!World.world.isOverUI()
|
if (!World.world.isOverUI()
|
||||||
|
&& !WatchCaption.IsPointerOverInteractive()
|
||||||
|
&& !ChronicleHud.IsPointerOverPanel()
|
||||||
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
|
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"enabled": "Enable Idle Spectator (I)",
|
"enabled": "Enable Idle Spectator (I)",
|
||||||
"show_watch_reasons": "Show brief watch tips",
|
"show_watch_reasons": "Show brief watch tips",
|
||||||
"show_dossier_caption": "Show creature dossier caption while watching",
|
"show_dossier_caption": "Show creature dossier caption while watching",
|
||||||
"chronicle_enabled": "Enable Chronicle panel (F9) - character History + World events",
|
"chronicle_enabled": "Enable World Memory panel (F9) - ages, landmarks, legends",
|
||||||
"debug_state_probe": "Log focus/power-bar state (for debugging)",
|
"debug_state_probe": "Log focus/power-bar state (for debugging)",
|
||||||
"IdleSpectator Chronicle": "Character Chronicle"
|
"IdleSpectator Chronicle": "World Memory"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
270
IdleSpectator/LoreProse.cs
Normal file
270
IdleSpectator/LoreProse.cs
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chronicle-voice templates. Hook = fact (<see cref="ChronicleEntry.Line"/>);
|
||||||
|
/// sentence = optional poetry (<see cref="ChronicleEntry.LoreLine"/>). Never invents facts.
|
||||||
|
/// </summary>
|
||||||
|
public static class LoreProse
|
||||||
|
{
|
||||||
|
private static readonly Regex KillPattern =
|
||||||
|
new Regex(@"^Killed (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex KilledByPattern =
|
||||||
|
new Regex(@"^Killed by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex EatenByPattern =
|
||||||
|
new Regex(@"^Eaten by (.+) \(([^)]+)\)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex LoversPattern =
|
||||||
|
new Regex(@"^Became lovers with (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex FriendPattern =
|
||||||
|
new Regex(@"^Befriended (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex WarVsPattern =
|
||||||
|
new Regex(@"^War: (.+) vs (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex TotalWarPattern =
|
||||||
|
new Regex(@"^Total war: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex KingdomFellPattern =
|
||||||
|
new Regex(@"^Kingdom fell: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex KingdomNewPattern =
|
||||||
|
new Regex(@"^New kingdom: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex CityDestroyedPattern =
|
||||||
|
new Regex(@"^City destroyed: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex CityNewPattern =
|
||||||
|
new Regex(@"^New city: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex NewKingPattern =
|
||||||
|
new Regex(@"^New king: (.+) \((.+)\)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex KingDiedPattern =
|
||||||
|
new Regex(@"^King died: (.+) \((.+)\)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex KingKilledPattern =
|
||||||
|
new Regex(@"^King killed: (.+) \((.+)\)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex AlliancePattern =
|
||||||
|
new Regex(@"^Alliance: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex AllianceDissolvedPattern =
|
||||||
|
new Regex(@"^Alliance dissolved: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex WarEndedPattern =
|
||||||
|
new Regex(@"^War ended: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex RevoltPattern =
|
||||||
|
new Regex(@"^Revolt: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex ExtinctPattern =
|
||||||
|
new Regex(@"^Species extinct: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex FavoriteFellPattern =
|
||||||
|
new Regex(@"^Favorite fell: (.+)$", RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex DisasterHint =
|
||||||
|
new Regex(@"disaster", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
/// <summary>Rewrite a raw history/world fact line into short legendary prose.</summary>
|
||||||
|
public static string Rewrite(ChronicleKind kind, string rawLine, string assetId = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(rawLine))
|
||||||
|
{
|
||||||
|
return rawLine ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long free-form harness / player lines stay as written.
|
||||||
|
if (rawLine.Length > 96)
|
||||||
|
{
|
||||||
|
return rawLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ChronicleKind.Kill:
|
||||||
|
return RewriteKill(rawLine);
|
||||||
|
case ChronicleKind.Death:
|
||||||
|
return RewriteDeath(rawLine);
|
||||||
|
case ChronicleKind.Lover:
|
||||||
|
return RewriteLovers(rawLine);
|
||||||
|
case ChronicleKind.Friend:
|
||||||
|
return RewriteFriend(rawLine);
|
||||||
|
case ChronicleKind.World:
|
||||||
|
return RewriteWorld(rawLine, assetId);
|
||||||
|
default:
|
||||||
|
return rawLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RewriteKill(string raw)
|
||||||
|
{
|
||||||
|
Match m = KillPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Slew " + m.Groups[1].Value + " of the " + m.Groups[2].Value + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RewriteDeath(string raw)
|
||||||
|
{
|
||||||
|
Match by = KilledByPattern.Match(raw);
|
||||||
|
if (by.Success)
|
||||||
|
{
|
||||||
|
return "Fell at " + by.Groups[1].Value + "'s hand";
|
||||||
|
}
|
||||||
|
|
||||||
|
Match eaten = EatenByPattern.Match(raw);
|
||||||
|
if (eaten.Success)
|
||||||
|
{
|
||||||
|
return "Devoured by " + eaten.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (raw)
|
||||||
|
{
|
||||||
|
case "Died of old age":
|
||||||
|
return "Passed from old age";
|
||||||
|
case "Starved to death":
|
||||||
|
return "Starved in lean seasons";
|
||||||
|
case "Burned to death":
|
||||||
|
return "Burned to ash";
|
||||||
|
case "Burned in lava":
|
||||||
|
return "Claimed by lava";
|
||||||
|
case "Drowned":
|
||||||
|
return "Drowned beneath the waves";
|
||||||
|
case "Died of poison":
|
||||||
|
return "Succumbed to poison";
|
||||||
|
case "Died of plague":
|
||||||
|
return "Taken by plague";
|
||||||
|
case "Died of infection":
|
||||||
|
return "Taken by infection";
|
||||||
|
case "Died of a tumor":
|
||||||
|
return "Claimed by a tumor";
|
||||||
|
case "Dissolved in acid":
|
||||||
|
return "Dissolved in acid";
|
||||||
|
case "Died of ash fever":
|
||||||
|
return "Taken by ash fever";
|
||||||
|
case "Fell to their death":
|
||||||
|
return "Fell to their death";
|
||||||
|
case "Died in an explosion":
|
||||||
|
return "Perished in an explosion";
|
||||||
|
case "Struck down by divine power":
|
||||||
|
return "Struck down by divine power";
|
||||||
|
case "Was eaten":
|
||||||
|
return "Was eaten";
|
||||||
|
case "Killed in combat":
|
||||||
|
return "Fell in combat";
|
||||||
|
case "Transformed away":
|
||||||
|
return "Transformed away";
|
||||||
|
case "Died":
|
||||||
|
return "Met their end";
|
||||||
|
default:
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RewriteLovers(string raw)
|
||||||
|
{
|
||||||
|
Match m = LoversPattern.Match(raw);
|
||||||
|
return m.Success ? "Heartbound to " + m.Groups[1].Value : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RewriteFriend(string raw)
|
||||||
|
{
|
||||||
|
Match m = FriendPattern.Match(raw);
|
||||||
|
return m.Success ? "Bound in friendship with " + m.Groups[1].Value : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RewriteWorld(string raw, string assetId)
|
||||||
|
{
|
||||||
|
Match m;
|
||||||
|
m = WarVsPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "War kindled: " + m.Groups[1].Value + " and " + m.Groups[2].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = TotalWarPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Total war engulfed " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = KingdomFellPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Kingdom fell: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = KingdomNewPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "A kingdom rose: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = CityDestroyedPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "City razed: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = CityNewPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "City founded: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = NewKingPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Crowned: " + m.Groups[1].Value + " of " + m.Groups[2].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = KingDiedPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "A king passed: " + m.Groups[1].Value + " of " + m.Groups[2].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = KingKilledPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "A king slain: " + m.Groups[1].Value + " of " + m.Groups[2].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = AlliancePattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Alliance forged: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = AllianceDissolvedPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Alliance broken: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = WarEndedPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "War ended: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = RevoltPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Revolt in " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = ExtinctPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "Extinct: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = FavoriteFellPattern.Match(raw);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return "A favorite fell: " + m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(assetId) && DisasterHint.IsMatch(assetId))
|
||||||
|
{
|
||||||
|
return "Disaster struck: " + raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(assetId) && assetId.IndexOf("shattered", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
return raw.Replace("Kingdom shattered:", "Kingdom shattered:");
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using NeoModLoader.services;
|
using NeoModLoader.services;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEngine.Events;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
|
|
||||||
namespace IdleSpectator;
|
namespace IdleSpectator;
|
||||||
|
|
@ -29,8 +30,14 @@ public static class WatchCaption
|
||||||
private const float HistoryLineMinH = 13f;
|
private const float HistoryLineMinH = 13f;
|
||||||
private const float HistoryLineMaxH = 72f;
|
private const float HistoryLineMaxH = 72f;
|
||||||
private const float Gap = 2f;
|
private const float Gap = 2f;
|
||||||
private const int HistoryMax = 3;
|
private const int HistoryMaxSlots = Chronicle.MaxHistoryPerUnit;
|
||||||
|
private const float BtnSize = 16f;
|
||||||
private const float StatusBannerSeconds = 5f;
|
private const float StatusBannerSeconds = 5f;
|
||||||
|
/// <summary>
|
||||||
|
/// Visible history peek (~3 lines). Full buffer stays in the scroll content.
|
||||||
|
/// </summary>
|
||||||
|
private const int HistoryPeekLines = 3;
|
||||||
|
private const float HistoryBodyMax = HistoryLineMinH * HistoryPeekLines;
|
||||||
|
|
||||||
/// <summary>History column grows with content until the dossier hits <see cref="PanelWidthMax"/>.</summary>
|
/// <summary>History column grows with content until the dossier hits <see cref="PanelWidthMax"/>.</summary>
|
||||||
private static float HistoryColMaxW =>
|
private static float HistoryColMaxW =>
|
||||||
|
|
@ -61,13 +68,19 @@ public static class WatchCaption
|
||||||
private static readonly TraitSlot[] _traitSlots = new TraitSlot[3];
|
private static readonly TraitSlot[] _traitSlots = new TraitSlot[3];
|
||||||
private static GameObject _traitsRow;
|
private static GameObject _traitsRow;
|
||||||
|
|
||||||
|
private static GameObject _historyViewport;
|
||||||
|
private static ScrollRect _historyScroll;
|
||||||
|
private static GameObject _historyScrollbar;
|
||||||
private static GameObject _historyCol;
|
private static GameObject _historyCol;
|
||||||
private static readonly HistorySlot[] _historySlots = new HistorySlot[HistoryMax];
|
private static readonly HistorySlot[] _historySlots = new HistorySlot[HistoryMaxSlots];
|
||||||
private static readonly float[] _historySlotHeights = new float[HistoryMax];
|
private static readonly float[] _historySlotHeights = new float[HistoryMaxSlots];
|
||||||
private static float _historyColW = HistoryColMinW;
|
private static float _historyColW = HistoryColMinW;
|
||||||
private static int _lastHistoryCount = -1;
|
private static int _lastHistoryCount = -1;
|
||||||
private static long _lastHistorySubjectId;
|
private static long _lastHistorySubjectId;
|
||||||
|
|
||||||
|
private static Button _favoriteBtn;
|
||||||
|
private static Image _favoriteIcon;
|
||||||
|
|
||||||
private static UnitDossier _current;
|
private static UnitDossier _current;
|
||||||
private static Actor _boundActor;
|
private static Actor _boundActor;
|
||||||
private static bool _visible;
|
private static bool _visible;
|
||||||
|
|
@ -99,6 +112,18 @@ public static class WatchCaption
|
||||||
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
|
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
|
||||||
public static string LastHistoryPreview { get; private set; } = "";
|
public static string LastHistoryPreview { get; private set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Harness: how many history lines are currently visible on the dossier.</summary>
|
||||||
|
public static int LastHistoryShown { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>True when history content is taller than the viewport (ScrollRect active).</summary>
|
||||||
|
public static bool LastHistoryScrollable { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>True when the history column fills the body width (no empty strip beside it).</summary>
|
||||||
|
public static bool LastHistoryFillsBody { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Deprecated: expand control removed; kept as alias of <see cref="LastHistoryScrollable"/>.</summary>
|
||||||
|
public static bool HistoryExpanded => LastHistoryScrollable;
|
||||||
|
|
||||||
/// <summary>Harness: comma-joined top trait labels currently shown.</summary>
|
/// <summary>Harness: comma-joined top trait labels currently shown.</summary>
|
||||||
public static string LastTraitsPreview { get; private set; } = "";
|
public static string LastTraitsPreview { get; private set; } = "";
|
||||||
|
|
||||||
|
|
@ -152,6 +177,86 @@ public static class WatchCaption
|
||||||
return DossierAvatar.ContainsScreenPoint(Input.mousePosition);
|
return DossierAvatar.ContainsScreenPoint(Input.mousePosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Favorite control (and the panel chrome around it).
|
||||||
|
/// Used so idle does not treat those clicks as "manual pause".
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPointerOverInteractive()
|
||||||
|
{
|
||||||
|
if (!IsPointerOverPanel())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPointerOverButton(_favoriteBtn))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole dossier counts as UI while visible so history/trait chrome is safe to click.
|
||||||
|
return _root != null && _root.activeInHierarchy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPointerOverButton(Button button)
|
||||||
|
{
|
||||||
|
if (button == null || !button.gameObject.activeInHierarchy)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RectTransform rt = button.GetComponent<RectTransform>();
|
||||||
|
return rt != null
|
||||||
|
&& RectTransformUtility.RectangleContainsScreenPoint(rt, Input.mousePosition, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ToggleFavorite()
|
||||||
|
{
|
||||||
|
Actor actor = _boundActor;
|
||||||
|
if (actor == null || !actor.isAlive())
|
||||||
|
{
|
||||||
|
if (MoveCamera.hasFocusUnit())
|
||||||
|
{
|
||||||
|
actor = MoveCamera._focus_unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor == null || !actor.isAlive())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
actor.switchFavorite();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshFavoriteVisual(actor);
|
||||||
|
LogService.LogInfo(
|
||||||
|
$"[IdleSpectator] Favorite toggled name={SafeActorName(actor)} favorite={actor.isFavorite()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeActorName(Actor actor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string n = actor.getName();
|
||||||
|
if (!string.IsNullOrEmpty(n))
|
||||||
|
{
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unit";
|
||||||
|
}
|
||||||
|
|
||||||
public static void Clear()
|
public static void Clear()
|
||||||
{
|
{
|
||||||
_current = null;
|
_current = null;
|
||||||
|
|
@ -160,6 +265,9 @@ public static class WatchCaption
|
||||||
LastDetail = "";
|
LastDetail = "";
|
||||||
LastCaptionText = "";
|
LastCaptionText = "";
|
||||||
LastHistoryPreview = "";
|
LastHistoryPreview = "";
|
||||||
|
LastHistoryShown = 0;
|
||||||
|
LastHistoryScrollable = false;
|
||||||
|
LastHistoryFillsBody = false;
|
||||||
LastTraitsPreview = "";
|
LastTraitsPreview = "";
|
||||||
LastPanelSize = Vector2.zero;
|
LastPanelSize = Vector2.zero;
|
||||||
LastLayoutOk = false;
|
LastLayoutOk = false;
|
||||||
|
|
@ -401,6 +509,7 @@ public static class WatchCaption
|
||||||
Front(_taskIcon);
|
Front(_taskIcon);
|
||||||
Front(_taskText);
|
Front(_taskText);
|
||||||
Front(_sexIcon);
|
Front(_sexIcon);
|
||||||
|
Front(_favoriteBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RefreshHistoryIfChanged()
|
private static void RefreshHistoryIfChanged()
|
||||||
|
|
@ -581,9 +690,11 @@ public static class WatchCaption
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LastHistoryPreview = "";
|
LastHistoryPreview = "";
|
||||||
if (_historyCol != null)
|
LastHistoryShown = 0;
|
||||||
|
LastHistoryScrollable = false;
|
||||||
|
if (_historyViewport != null)
|
||||||
{
|
{
|
||||||
_historyCol.SetActive(false);
|
_historyViewport.SetActive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,36 +706,40 @@ public static class WatchCaption
|
||||||
}
|
}
|
||||||
|
|
||||||
Relayout(hasBody, traitCount, hasTask, hasReason, historyCount);
|
Relayout(hasBody, traitCount, hasTask, hasReason, historyCount);
|
||||||
|
RefreshFavoriteVisual(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int FillHistory(long unitId)
|
private static int FillHistory(long unitId)
|
||||||
{
|
{
|
||||||
IReadOnlyList<ChronicleEntry> entries = Chronicle.LatestForSubject(unitId, HistoryMax);
|
IReadOnlyList<ChronicleEntry> entries =
|
||||||
|
Chronicle.LatestForSubject(unitId, HistoryMaxSlots);
|
||||||
|
bool subjectChanged = _lastHistorySubjectId != unitId;
|
||||||
_lastHistorySubjectId = unitId;
|
_lastHistorySubjectId = unitId;
|
||||||
_lastHistoryCount = Chronicle.HistoryCountFor(unitId);
|
_lastHistoryCount = Chronicle.HistoryCountFor(unitId);
|
||||||
|
|
||||||
int shown = 0;
|
|
||||||
LastHistoryPreview = "";
|
LastHistoryPreview = "";
|
||||||
|
LastHistoryShown = 0;
|
||||||
|
LastHistoryScrollable = false;
|
||||||
_historyColW = HistoryColMinW;
|
_historyColW = HistoryColMinW;
|
||||||
for (int i = 0; i < _historySlotHeights.Length; i++)
|
for (int i = 0; i < _historySlotHeights.Length; i++)
|
||||||
{
|
{
|
||||||
_historySlotHeights[i] = HistoryLineMinH;
|
_historySlotHeights[i] = HistoryLineMinH;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_historyCol == null)
|
if (_historyViewport == null || _historyCol == null)
|
||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries == null || entries.Count == 0)
|
if (entries == null || entries.Count == 0)
|
||||||
{
|
{
|
||||||
_historyCol.SetActive(false);
|
_historyViewport.SetActive(false);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_historyCol.SetActive(true);
|
_historyViewport.SetActive(true);
|
||||||
|
|
||||||
// Pass 1: grow the history column toward content width (capped by dossier max).
|
// Content-based minimum width; Relayout stretches to fill the panel body.
|
||||||
float widestLabel = 0f;
|
float widestLabel = 0f;
|
||||||
for (int i = 0; i < _historySlots.Length; i++)
|
for (int i = 0; i < _historySlots.Length; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -656,9 +771,20 @@ public static class WatchCaption
|
||||||
widestLabel + HistoryIcon + 4f,
|
widestLabel + HistoryIcon + 4f,
|
||||||
HistoryColMinW,
|
HistoryColMinW,
|
||||||
HistoryColMaxW);
|
HistoryColMaxW);
|
||||||
float labelW = Mathf.Max(24f, _historyColW - HistoryIcon - 2f);
|
ApplyHistorySlotContents(entries, _historyColW);
|
||||||
|
LastHistoryShown = CountActiveHistorySlots();
|
||||||
|
if (subjectChanged && _historyScroll != null)
|
||||||
|
{
|
||||||
|
_historyScroll.verticalNormalizedPosition = 1f;
|
||||||
|
}
|
||||||
|
|
||||||
// Pass 2: wrap at the chosen column width and size each row height.
|
return LastHistoryShown;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyHistorySlotContents(IReadOnlyList<ChronicleEntry> entries, float colW)
|
||||||
|
{
|
||||||
|
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
|
||||||
|
LastHistoryPreview = "";
|
||||||
for (int i = 0; i < _historySlots.Length; i++)
|
for (int i = 0; i < _historySlots.Length; i++)
|
||||||
{
|
{
|
||||||
HistorySlot slot = _historySlots[i];
|
HistorySlot slot = _historySlots[i];
|
||||||
|
|
@ -667,7 +793,7 @@ public static class WatchCaption
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i < entries.Count && entries[i] != null)
|
if (entries != null && i < entries.Count && entries[i] != null)
|
||||||
{
|
{
|
||||||
ChronicleEntry e = entries[i];
|
ChronicleEntry e = entries[i];
|
||||||
slot.Root.SetActive(true);
|
slot.Root.SetActive(true);
|
||||||
|
|
@ -696,12 +822,10 @@ public static class WatchCaption
|
||||||
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
|
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shown == 0)
|
if (string.IsNullOrEmpty(LastHistoryPreview))
|
||||||
{
|
{
|
||||||
LastHistoryPreview = e.DisplayLine ?? "";
|
LastHistoryPreview = e.DisplayLine ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
shown++;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -709,11 +833,38 @@ public static class WatchCaption
|
||||||
_historySlotHeights[i] = 0f;
|
_historySlotHeights[i] = 0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return shown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float MeasureHistoryColumnHeight(int historyCount)
|
private static void RemeasureHistoryForWidth(float colW)
|
||||||
|
{
|
||||||
|
_historyColW = Mathf.Clamp(colW, HistoryColMinW, HistoryColMaxW);
|
||||||
|
float labelW = Mathf.Max(24f, _historyColW - HistoryIcon - 2f);
|
||||||
|
for (int i = 0; i < _historySlots.Length; i++)
|
||||||
|
{
|
||||||
|
HistorySlot slot = _historySlots[i];
|
||||||
|
if (slot?.Root == null || !slot.Root.activeSelf || slot.Label == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RectTransform lrt = slot.Label.GetComponent<RectTransform>();
|
||||||
|
lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
|
||||||
|
Canvas.ForceUpdateCanvases();
|
||||||
|
float needed = HistoryLineMinH;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
needed = _historySlotHeights[i] > 0.01f ? _historySlotHeights[i] : HistoryLineMinH;
|
||||||
|
}
|
||||||
|
|
||||||
|
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float MeasureHistoryContentHeight(int historyCount)
|
||||||
{
|
{
|
||||||
if (historyCount <= 0)
|
if (historyCount <= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -736,15 +887,68 @@ public static class WatchCaption
|
||||||
return Mathf.Max(BodyH, total);
|
return Mathf.Max(BodyH, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static float MeasureHistoryColumnHeight(int historyCount)
|
||||||
|
{
|
||||||
|
float contentH = MeasureHistoryContentHeight(historyCount);
|
||||||
|
return Mathf.Min(contentH, HistoryBodyMax);
|
||||||
|
}
|
||||||
|
|
||||||
private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason, int historyCount)
|
private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason, int historyCount)
|
||||||
{
|
{
|
||||||
float y = PadTop;
|
float headerW = MeasureHeaderWidth(hasTask);
|
||||||
float headerW = PlaceHeader(y, hasTask);
|
|
||||||
y += HeaderH + Gap;
|
|
||||||
|
|
||||||
float bodyW = 0f;
|
|
||||||
float traitsW = 0f;
|
float traitsW = 0f;
|
||||||
bool traitsBeside = hasBody && historyCount <= 0 && traitCount > 0;
|
bool traitsBeside = hasBody && historyCount <= 0 && traitCount > 0;
|
||||||
|
if (traitCount > 0 && !traitsBeside)
|
||||||
|
{
|
||||||
|
// Traits row spans panel; width contribution is handled after panel size.
|
||||||
|
traitsW = HistoryColMinW;
|
||||||
|
}
|
||||||
|
else if (traitsBeside)
|
||||||
|
{
|
||||||
|
traitsW = HistoryColMinW;
|
||||||
|
}
|
||||||
|
|
||||||
|
float bodyW = 0f;
|
||||||
|
if (hasBody)
|
||||||
|
{
|
||||||
|
if (historyCount > 0)
|
||||||
|
{
|
||||||
|
bodyW = LiveMax + 4f + _historyColW;
|
||||||
|
}
|
||||||
|
else if (traitsBeside)
|
||||||
|
{
|
||||||
|
bodyW = LiveMax + 4f + traitsW;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bodyW = LiveMax;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_panelWidth = Mathf.Clamp(
|
||||||
|
PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, LiveMax),
|
||||||
|
LiveMax + PadX * 2f,
|
||||||
|
PanelWidthMax);
|
||||||
|
|
||||||
|
LastHistoryFillsBody = historyCount <= 0;
|
||||||
|
if (historyCount > 0)
|
||||||
|
{
|
||||||
|
float fillW = _panelWidth - PadX * 2f - LiveMax - 4f;
|
||||||
|
if (fillW > _historyColW + 0.5f)
|
||||||
|
{
|
||||||
|
RemeasureHistoryForWidth(fillW);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_historyColW = Mathf.Min(_historyColW, fillW);
|
||||||
|
}
|
||||||
|
|
||||||
|
LastHistoryFillsBody = _historyColW >= fillW - 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float y = PadTop;
|
||||||
|
PlaceHeader(y, hasTask);
|
||||||
|
y += HeaderH + Gap;
|
||||||
|
|
||||||
if (hasBody)
|
if (hasBody)
|
||||||
{
|
{
|
||||||
|
|
@ -752,26 +956,22 @@ public static class WatchCaption
|
||||||
PlaceBody(y, historyCount, bodyH);
|
PlaceBody(y, historyCount, bodyH);
|
||||||
if (historyCount > 0)
|
if (historyCount > 0)
|
||||||
{
|
{
|
||||||
bodyW = LiveMax + 4f + _historyColW;
|
|
||||||
y += bodyH + Gap;
|
y += bodyH + Gap;
|
||||||
}
|
}
|
||||||
else if (traitsBeside)
|
else if (traitsBeside)
|
||||||
{
|
{
|
||||||
// Fill the body-right void with a vertical trait stack beside the portrait.
|
PlaceTraitsBesideAvatar(y, traitCount);
|
||||||
traitsW = PlaceTraitsBesideAvatar(y, traitCount);
|
|
||||||
bodyW = LiveMax + 4f + traitsW;
|
|
||||||
y += BodyH + Gap;
|
y += BodyH + Gap;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bodyW = LiveMax;
|
|
||||||
y += BodyH + Gap;
|
y += BodyH + Gap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (traitCount > 0 && !traitsBeside)
|
if (traitCount > 0 && !traitsBeside)
|
||||||
{
|
{
|
||||||
traitsW = PlaceTraits(y, traitCount);
|
PlaceTraits(y, traitCount);
|
||||||
y += TraitsH + Gap;
|
y += TraitsH + Gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -781,11 +981,6 @@ public static class WatchCaption
|
||||||
y += ReasonH + Gap;
|
y += ReasonH + Gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
_panelWidth = Mathf.Clamp(
|
|
||||||
PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, LiveMax),
|
|
||||||
LiveMax + PadX * 2f,
|
|
||||||
PanelWidthMax);
|
|
||||||
|
|
||||||
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
|
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
|
||||||
if (_rootRt != null)
|
if (_rootRt != null)
|
||||||
{
|
{
|
||||||
|
|
@ -796,11 +991,13 @@ public static class WatchCaption
|
||||||
|
|
||||||
BringHeaderFront();
|
BringHeaderFront();
|
||||||
|
|
||||||
LastLayoutOk = ProbeLayoutInside(height) && headerW > SpeciesSize;
|
LastLayoutOk = ProbeLayoutInside(height)
|
||||||
|
&& headerW > SpeciesSize
|
||||||
|
&& (historyCount <= 0 || LastHistoryFillsBody);
|
||||||
if (!LastLayoutOk)
|
if (!LastLayoutOk)
|
||||||
{
|
{
|
||||||
LogService.LogInfo(
|
LogService.LogInfo(
|
||||||
$"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} hist={historyCount} headerW={headerW}");
|
$"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} hist={historyCount} headerW={headerW} histFill={LastHistoryFillsBody} histW={_historyColW}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -864,10 +1061,83 @@ public static class WatchCaption
|
||||||
x += SexSize;
|
x += SexSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
x += 6f;
|
||||||
|
PlaceHeaderButton(_favoriteBtn, x, yFromTop);
|
||||||
|
x += BtnSize;
|
||||||
|
|
||||||
|
RefreshFavoriteVisual(_boundActor);
|
||||||
|
|
||||||
BringHeaderFront();
|
BringHeaderFront();
|
||||||
return Mathf.Max(0f, x - PadX);
|
return Mathf.Max(0f, x - PadX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static float MeasureHeaderWidth(bool hasTask)
|
||||||
|
{
|
||||||
|
float x = PadX + SpeciesSize + 3f;
|
||||||
|
if (_nameText != null)
|
||||||
|
{
|
||||||
|
x += Mathf.Clamp(MeasureTextWidth(_nameText, 72f), 28f, 120f) + 5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_levelIcon != null && _levelIcon.gameObject.activeSelf)
|
||||||
|
{
|
||||||
|
x += ChipIcon + 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_levelValue != null && _levelValue.gameObject.activeSelf)
|
||||||
|
{
|
||||||
|
x += Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f) + 4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTask && _taskIcon != null && _taskText != null)
|
||||||
|
{
|
||||||
|
x += ChipIcon + 1f;
|
||||||
|
x += Mathf.Clamp(MeasureTextWidth(_taskText, 36f), 20f, 72f) + 4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
|
||||||
|
{
|
||||||
|
x += SexSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
x += 6f + BtnSize;
|
||||||
|
return Mathf.Max(0f, x - PadX);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PlaceHeaderButton(Button button, float x, float yFromTop)
|
||||||
|
{
|
||||||
|
if (button == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.gameObject.SetActive(true);
|
||||||
|
PlaceLeftChip(button.GetComponent<RectTransform>(), x, yFromTop + 1f, BtnSize, BtnSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RefreshFavoriteVisual(Actor actor)
|
||||||
|
{
|
||||||
|
if (_favoriteIcon == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool fav = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fav = actor != null && actor.isAlive() && actor.isFavorite();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
fav = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
HudIcons.Apply(_favoriteIcon, HudIcons.Favorite());
|
||||||
|
_favoriteIcon.color = fav
|
||||||
|
? new Color(1f, 0.85f, 0.25f, 1f)
|
||||||
|
: new Color(0.55f, 0.58f, 0.62f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
private static void PlaceLeftChip(RectTransform rt, float x, float yFromTop, float width, float height)
|
private static void PlaceLeftChip(RectTransform rt, float x, float yFromTop, float width, float height)
|
||||||
{
|
{
|
||||||
if (rt == null)
|
if (rt == null)
|
||||||
|
|
@ -909,24 +1179,65 @@ public static class WatchCaption
|
||||||
{
|
{
|
||||||
DossierAvatar.Place(PadX, yFromTop, LiveMax);
|
DossierAvatar.Place(PadX, yFromTop, LiveMax);
|
||||||
|
|
||||||
if (_historyCol == null)
|
if (_historyViewport == null || _historyCol == null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (historyCount <= 0)
|
if (historyCount <= 0)
|
||||||
{
|
{
|
||||||
_historyCol.SetActive(false);
|
_historyViewport.SetActive(false);
|
||||||
|
if (_historyScrollbar != null)
|
||||||
|
{
|
||||||
|
_historyScrollbar.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
LastHistoryScrollable = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_historyCol.SetActive(true);
|
_historyViewport.SetActive(true);
|
||||||
|
RectTransform viewport = _historyViewport.GetComponent<RectTransform>();
|
||||||
|
viewport.anchorMin = new Vector2(0f, 1f);
|
||||||
|
viewport.anchorMax = new Vector2(0f, 1f);
|
||||||
|
viewport.pivot = new Vector2(0f, 1f);
|
||||||
|
viewport.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop);
|
||||||
|
viewport.sizeDelta = new Vector2(_historyColW, bodyH);
|
||||||
|
|
||||||
|
float contentH = MeasureHistoryContentHeight(historyCount);
|
||||||
RectTransform col = _historyCol.GetComponent<RectTransform>();
|
RectTransform col = _historyCol.GetComponent<RectTransform>();
|
||||||
col.anchorMin = new Vector2(0f, 1f);
|
col.anchorMin = new Vector2(0f, 1f);
|
||||||
col.anchorMax = new Vector2(0f, 1f);
|
col.anchorMax = new Vector2(1f, 1f);
|
||||||
col.pivot = new Vector2(0f, 1f);
|
col.pivot = new Vector2(0.5f, 1f);
|
||||||
col.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop);
|
col.anchoredPosition = Vector2.zero;
|
||||||
col.sizeDelta = new Vector2(_historyColW, bodyH);
|
col.sizeDelta = new Vector2(0f, contentH);
|
||||||
|
|
||||||
|
bool scrollable = contentH > bodyH + 0.5f;
|
||||||
|
LastHistoryScrollable = scrollable;
|
||||||
|
float scrollGutter = scrollable ? 5f : 0f;
|
||||||
|
if (_historyScroll != null)
|
||||||
|
{
|
||||||
|
_historyScroll.enabled = true;
|
||||||
|
_historyScroll.vertical = scrollable;
|
||||||
|
if (!scrollable)
|
||||||
|
{
|
||||||
|
_historyScroll.verticalNormalizedPosition = 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_historyScrollbar != null)
|
||||||
|
{
|
||||||
|
_historyScrollbar.SetActive(scrollable);
|
||||||
|
if (scrollable)
|
||||||
|
{
|
||||||
|
RectTransform barRt = _historyScrollbar.GetComponent<RectTransform>();
|
||||||
|
barRt.anchorMin = new Vector2(0f, 1f);
|
||||||
|
barRt.anchorMax = new Vector2(0f, 1f);
|
||||||
|
barRt.pivot = new Vector2(1f, 1f);
|
||||||
|
barRt.anchoredPosition = new Vector2(PadX + LiveMax + 4f + _historyColW, -yFromTop);
|
||||||
|
barRt.sizeDelta = new Vector2(4f, bodyH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
float top = 0f;
|
float top = 0f;
|
||||||
for (int i = 0; i < _historySlots.Length; i++)
|
for (int i = 0; i < _historySlots.Length; i++)
|
||||||
|
|
@ -943,7 +1254,7 @@ public static class WatchCaption
|
||||||
rt.anchorMax = new Vector2(1f, 1f);
|
rt.anchorMax = new Vector2(1f, 1f);
|
||||||
rt.pivot = new Vector2(0.5f, 1f);
|
rt.pivot = new Vector2(0.5f, 1f);
|
||||||
rt.offsetMin = new Vector2(0f, -(top + lineH));
|
rt.offsetMin = new Vector2(0f, -(top + lineH));
|
||||||
rt.offsetMax = new Vector2(0f, -top);
|
rt.offsetMax = new Vector2(-scrollGutter, -top);
|
||||||
top += lineH;
|
top += lineH;
|
||||||
|
|
||||||
if (slot.Icon != null)
|
if (slot.Icon != null)
|
||||||
|
|
@ -1092,8 +1403,8 @@ public static class WatchCaption
|
||||||
|| LineInside(_reasonText.GetComponent<RectTransform>(), panelHeight))
|
|| LineInside(_reasonText.GetComponent<RectTransform>(), panelHeight))
|
||||||
&& (_traitsRow == null || !_traitsRow.activeSelf
|
&& (_traitsRow == null || !_traitsRow.activeSelf
|
||||||
|| LineInside(_traitsRow.GetComponent<RectTransform>(), panelHeight))
|
|| LineInside(_traitsRow.GetComponent<RectTransform>(), panelHeight))
|
||||||
&& (_historyCol == null || !_historyCol.activeSelf
|
&& (_historyViewport == null || !_historyViewport.activeSelf
|
||||||
|| LineInside(_historyCol.GetComponent<RectTransform>(), panelHeight));
|
|| LineInside(_historyViewport.GetComponent<RectTransform>(), panelHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool LineInside(RectTransform line, float panelHeight)
|
private static bool LineInside(RectTransform line, float panelHeight)
|
||||||
|
|
@ -1137,9 +1448,12 @@ public static class WatchCaption
|
||||||
|
|
||||||
private static void EnsureBuilt()
|
private static void EnsureBuilt()
|
||||||
{
|
{
|
||||||
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform))
|
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform
|
||||||
|
|| _historyViewport == null || _historyScroll == null
|
||||||
|
|| _historyScrollbar == null
|
||||||
|
|| _root.transform.Find("ExpandHistBtn") != null))
|
||||||
{
|
{
|
||||||
// Stale HUD from an older build (nested nametag row) - rebuild.
|
// Stale HUD from an older build (nested nametag row / non-scroll history) - rebuild.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Object.Destroy(_root);
|
Object.Destroy(_root);
|
||||||
|
|
@ -1160,7 +1474,12 @@ public static class WatchCaption
|
||||||
_taskIcon = null;
|
_taskIcon = null;
|
||||||
_taskText = null;
|
_taskText = null;
|
||||||
_traitsRow = null;
|
_traitsRow = null;
|
||||||
|
_historyViewport = null;
|
||||||
|
_historyScroll = null;
|
||||||
|
_historyScrollbar = null;
|
||||||
_historyCol = null;
|
_historyCol = null;
|
||||||
|
_favoriteBtn = null;
|
||||||
|
_favoriteIcon = null;
|
||||||
DossierAvatar.ResetHost();
|
DossierAvatar.ResetHost();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1187,14 +1506,64 @@ public static class WatchCaption
|
||||||
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.9f));
|
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.9f));
|
||||||
|
|
||||||
CanvasGroup group = _root.GetComponent<CanvasGroup>();
|
CanvasGroup group = _root.GetComponent<CanvasGroup>();
|
||||||
group.blocksRaycasts = false;
|
// Buttons need raycasts; panel chrome still ignores hits via raycastTarget=false on bg/text.
|
||||||
group.interactable = false;
|
group.blocksRaycasts = true;
|
||||||
|
group.interactable = true;
|
||||||
|
|
||||||
// Avatar/history/traits first so nametag chips can SetAsLastSibling on top.
|
// Avatar/history/traits first so nametag chips can SetAsLastSibling on top.
|
||||||
DossierAvatar.EnsureHost(_root.transform, LiveMax);
|
DossierAvatar.EnsureHost(_root.transform, LiveMax);
|
||||||
|
|
||||||
|
_historyViewport = new GameObject(
|
||||||
|
"HistoryScroll",
|
||||||
|
typeof(RectTransform),
|
||||||
|
typeof(Image),
|
||||||
|
typeof(ScrollRect),
|
||||||
|
typeof(RectMask2D));
|
||||||
|
_historyViewport.transform.SetParent(_root.transform, false);
|
||||||
|
Image historyVpBg = _historyViewport.GetComponent<Image>();
|
||||||
|
historyVpBg.color = new Color(0f, 0f, 0f, 0.01f);
|
||||||
|
historyVpBg.raycastTarget = true;
|
||||||
|
_historyScroll = _historyViewport.GetComponent<ScrollRect>();
|
||||||
|
_historyScroll.horizontal = false;
|
||||||
|
_historyScroll.vertical = true;
|
||||||
|
_historyScroll.movementType = ScrollRect.MovementType.Clamped;
|
||||||
|
_historyScroll.scrollSensitivity = 24f;
|
||||||
|
_historyScroll.viewport = _historyViewport.GetComponent<RectTransform>();
|
||||||
|
|
||||||
_historyCol = new GameObject("HistoryCol", typeof(RectTransform));
|
_historyCol = new GameObject("HistoryCol", typeof(RectTransform));
|
||||||
_historyCol.transform.SetParent(_root.transform, false);
|
_historyCol.transform.SetParent(_historyViewport.transform, false);
|
||||||
|
_historyScroll.content = _historyCol.GetComponent<RectTransform>();
|
||||||
|
|
||||||
|
_historyScrollbar = new GameObject(
|
||||||
|
"HistoryScrollbar",
|
||||||
|
typeof(RectTransform),
|
||||||
|
typeof(Image),
|
||||||
|
typeof(Scrollbar));
|
||||||
|
_historyScrollbar.transform.SetParent(_root.transform, false);
|
||||||
|
Image barBg = _historyScrollbar.GetComponent<Image>();
|
||||||
|
barBg.color = new Color(0.12f, 0.13f, 0.15f, 0.55f);
|
||||||
|
barBg.raycastTarget = true;
|
||||||
|
|
||||||
|
GameObject handleGo = new GameObject("Handle", typeof(RectTransform), typeof(Image));
|
||||||
|
handleGo.transform.SetParent(_historyScrollbar.transform, false);
|
||||||
|
RectTransform handleRt = handleGo.GetComponent<RectTransform>();
|
||||||
|
handleRt.anchorMin = Vector2.zero;
|
||||||
|
handleRt.anchorMax = Vector2.one;
|
||||||
|
handleRt.offsetMin = new Vector2(0.5f, 0.5f);
|
||||||
|
handleRt.offsetMax = new Vector2(-0.5f, -0.5f);
|
||||||
|
Image handleImg = handleGo.GetComponent<Image>();
|
||||||
|
handleImg.color = new Color(0.72f, 0.74f, 0.78f, 0.85f);
|
||||||
|
handleImg.raycastTarget = true;
|
||||||
|
|
||||||
|
Scrollbar scrollbar = _historyScrollbar.GetComponent<Scrollbar>();
|
||||||
|
scrollbar.direction = Scrollbar.Direction.BottomToTop;
|
||||||
|
scrollbar.handleRect = handleRt;
|
||||||
|
scrollbar.targetGraphic = handleImg;
|
||||||
|
scrollbar.numberOfSteps = 0;
|
||||||
|
_historyScroll.verticalScrollbar = scrollbar;
|
||||||
|
_historyScroll.verticalScrollbarVisibility = ScrollRect.ScrollbarVisibility.Permanent;
|
||||||
|
_historyScrollbar.SetActive(false);
|
||||||
|
|
||||||
for (int i = 0; i < _historySlots.Length; i++)
|
for (int i = 0; i < _historySlots.Length; i++)
|
||||||
{
|
{
|
||||||
GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform));
|
GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform));
|
||||||
|
|
@ -1253,8 +1622,10 @@ public static class WatchCaption
|
||||||
_taskText.alignment = TextAnchor.MiddleLeft;
|
_taskText.alignment = TextAnchor.MiddleLeft;
|
||||||
_taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
_taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||||
|
|
||||||
|
BuildHeaderButtons();
|
||||||
|
|
||||||
DossierAvatar.SetActive(false);
|
DossierAvatar.SetActive(false);
|
||||||
_historyCol.SetActive(false);
|
_historyViewport.SetActive(false);
|
||||||
_traitsRow.SetActive(false);
|
_traitsRow.SetActive(false);
|
||||||
_reasonText.gameObject.SetActive(false);
|
_reasonText.gameObject.SetActive(false);
|
||||||
_levelIcon.gameObject.SetActive(false);
|
_levelIcon.gameObject.SetActive(false);
|
||||||
|
|
@ -1267,6 +1638,45 @@ public static class WatchCaption
|
||||||
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag chips + mini history)");
|
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag chips + mini history)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void BuildHeaderButtons()
|
||||||
|
{
|
||||||
|
_favoriteBtn = BuildIconButton(_root.transform, "FavoriteBtn", HudIcons.Favorite(), ToggleFavorite);
|
||||||
|
_favoriteIcon = _favoriteBtn.transform.Find("Icon")?.GetComponent<Image>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Button BuildIconButton(Transform parent, string name, Sprite icon, UnityAction onClick)
|
||||||
|
{
|
||||||
|
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
|
||||||
|
go.transform.SetParent(parent, false);
|
||||||
|
RectTransform rt = go.GetComponent<RectTransform>();
|
||||||
|
rt.sizeDelta = new Vector2(BtnSize, BtnSize);
|
||||||
|
|
||||||
|
Image bg = go.GetComponent<Image>();
|
||||||
|
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.85f);
|
||||||
|
bg.raycastTarget = true;
|
||||||
|
|
||||||
|
Image iconImg = HudCanvas.MakeIcon(go.transform, "Icon", BtnSize - 4f);
|
||||||
|
RectTransform iconRt = iconImg.GetComponent<RectTransform>();
|
||||||
|
iconRt.anchorMin = new Vector2(0.5f, 0.5f);
|
||||||
|
iconRt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||||
|
iconRt.pivot = new Vector2(0.5f, 0.5f);
|
||||||
|
iconRt.anchoredPosition = Vector2.zero;
|
||||||
|
iconImg.raycastTarget = false;
|
||||||
|
HudIcons.Apply(iconImg, icon);
|
||||||
|
|
||||||
|
Button button = go.GetComponent<Button>();
|
||||||
|
button.transition = Selectable.Transition.ColorTint;
|
||||||
|
button.navigation = new Navigation { mode = Navigation.Mode.None };
|
||||||
|
button.targetGraphic = bg;
|
||||||
|
button.onClick.AddListener(onClick);
|
||||||
|
ColorBlock colors = button.colors;
|
||||||
|
colors.normalColor = Color.white;
|
||||||
|
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
|
||||||
|
colors.pressedColor = new Color(0.8f, 0.8f, 0.85f, 1f);
|
||||||
|
button.colors = colors;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pin the card to the canvas top-right. Pivot (1,1) so width growth expands left and stays on-screen.
|
/// Pin the card to the canvas top-right. Pivot (1,1) so width growth expands left and stays on-screen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -1281,6 +1691,7 @@ public static class WatchCaption
|
||||||
_rootRt.anchorMax = new Vector2(1f, 1f);
|
_rootRt.anchorMax = new Vector2(1f, 1f);
|
||||||
_rootRt.pivot = new Vector2(1f, 1f);
|
_rootRt.pivot = new Vector2(1f, 1f);
|
||||||
_rootRt.anchoredPosition = new Vector2(-ScreenInset, -ScreenInset);
|
_rootRt.anchoredPosition = new Vector2(-ScreenInset, -ScreenInset);
|
||||||
|
_rootRt.localScale = Vector3.one;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height, float left)
|
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height, float left)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "IdleSpectator",
|
"name": "IdleSpectator",
|
||||||
"author": "dazed",
|
"author": "dazed",
|
||||||
"version": "0.9.31",
|
"version": "0.10.10",
|
||||||
"description": "AFK Idle Spectator (I) + Chronicle (F9). Dossier top-right; tip on world hover or live sprite.",
|
"description": "AFK Idle Spectator (I) + World Memory (F9). Dossier favorite + 3-line scrollable history.",
|
||||||
"GUID": "com.dazed.idlespectator"
|
"GUID": "com.dazed.idlespectator"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,17 +82,48 @@ worldbox_running() {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod_compile_failed() {
|
||||||
|
[[ -f "$LOG" ]] || return 1
|
||||||
|
# Current Player.log session only (Unity truncates the file on each boot).
|
||||||
|
rg -q "error CS" "$LOG" 2>/dev/null || return 1
|
||||||
|
# Only fail when IdleSpectator is implicated, or NML reports a mod compile error near CS lines.
|
||||||
|
if rg -q "IdleSpectator.*error CS|error CS.*IdleSpectator|WatchCaption\.cs|Chronicle\.cs|LoreProse\.cs|AgentHarness\.cs|ChronicleHud\.cs" "$LOG" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Broader: any error CS in the same boot as an IdleSpectator compile attempt.
|
||||||
|
if rg -q "Compile Mod IdleSpectator" "$LOG" 2>/dev/null && rg -q "error CS" "$LOG" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
mod_ready() {
|
mod_ready() {
|
||||||
[[ -f "$LOG" ]] || return 1
|
[[ -f "$LOG" ]] || return 1
|
||||||
if tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
|
if mod_compile_failed; then
|
||||||
return 2
|
return 2
|
||||||
fi
|
fi
|
||||||
tail -n 200 "$LOG" 2>/dev/null | rg -q "\[IdleSpectator\]: Harmony patches applied"
|
rg -q "\[IdleSpectator\]: Harmony patches applied" "$LOG" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
report_compile_errors() {
|
||||||
|
echo "IdleSpectator compile failed:" >&2
|
||||||
|
if [[ -f "$LOG" ]]; then
|
||||||
|
rg -n "error CS|Compile Mod IdleSpectator|Failed to compile|WatchCaption\.cs|AgentHarness\.cs" "$LOG" | tail -n 50 >&2 || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_worldbox() {
|
ensure_worldbox() {
|
||||||
if worldbox_running; then
|
if worldbox_running; then
|
||||||
echo "WorldBox already running"
|
echo "WorldBox already running"
|
||||||
|
local ready_rc=0
|
||||||
|
mod_ready || ready_rc=$?
|
||||||
|
if (( ready_rc == 2 )); then
|
||||||
|
report_compile_errors
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if (( ready_rc != 0 )); then
|
||||||
|
echo "WARNING: IdleSpectator harmony ready-line not seen in log (continuing)" >&2
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -101,27 +132,37 @@ ensure_worldbox() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Do NOT write a marker into Player.log - Unity truncates that file on boot.
|
||||||
echo "Starting WorldBox (steam app $STEAM_APP_ID) ..."
|
echo "Starting WorldBox (steam app $STEAM_APP_ID) ..."
|
||||||
echo "===== HARNESS LAUNCH $(date -Iseconds) =====" >> "$LOG" 2>/dev/null || true
|
echo "===== HARNESS LAUNCH $(date -Iseconds) =====" >> /tmp/idle-spectator-harness-launch.log 2>/dev/null || true
|
||||||
nohup steam -applaunch "$STEAM_APP_ID" >/tmp/idle-spectator-harness-launch.log 2>&1 &
|
nohup steam -applaunch "$STEAM_APP_ID" >/tmp/idle-spectator-harness-launch.log 2>&1 &
|
||||||
disown || true
|
disown || true
|
||||||
|
|
||||||
local deadline=$((SECONDS + BOOT_TIMEOUT_SEC))
|
local deadline=$((SECONDS + BOOT_TIMEOUT_SEC))
|
||||||
|
local saw_process=0
|
||||||
while (( SECONDS < deadline )); do
|
while (( SECONDS < deadline )); do
|
||||||
if mod_ready; then
|
|
||||||
echo "WorldBox + IdleSpectator ready"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ -f "$LOG" ]] && tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
|
|
||||||
echo "IdleSpectator compile failed:" >&2
|
|
||||||
rg -n "error CS" "$LOG" | tail -n 40 >&2 || true
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if worldbox_running; then
|
if worldbox_running; then
|
||||||
|
saw_process=1
|
||||||
printf '.'
|
printf '.'
|
||||||
else
|
else
|
||||||
printf 'o'
|
printf 'o'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local ready_rc=0
|
||||||
|
mod_ready || ready_rc=$?
|
||||||
|
if (( ready_rc == 0 )); then
|
||||||
|
# Avoid accepting a stale log from a previous boot before the new process exists.
|
||||||
|
if (( saw_process == 1 )) || worldbox_running; then
|
||||||
|
echo
|
||||||
|
echo "WorldBox + IdleSpectator ready"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if (( ready_rc == 2 )); then
|
||||||
|
echo
|
||||||
|
report_compile_errors
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo >&2
|
echo >&2
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue