worldbox-observer-mod/IdleSpectator/ChronicleHud.cs

1708 lines
57 KiB
C#

using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Bottom-right Lore panel (L): World Memory + Living + Fallen tabs.
/// Sized in canvas units (same scaler as the dossier) to stay clear of top-right.
/// </summary>
public static class ChronicleHud
{
public enum LoreTab
{
World = 0,
Living = 1,
Fallen = 2
}
private const float PanelWidth = 268f;
private const float PanelHeight = 188f;
private const float ScreenInset = 12f;
private const float BottomClearance = 64f;
private const int MaxVisibleRows = 24;
private const float RowHeightMin = 20f;
private const float ChapterHeight = 15f;
private const float KindIconSize = 11f;
private const float TitleIconSize = 13f;
private const float TitleBandH = 22f;
private const float TabH = 15f;
private const float CharToolsH = 17f;
private const float ScrollSensitivity = 55f;
private const string ChromeMarker = "LoreV5";
private static GameObject _root;
private static RectTransform _rootRt;
private static RectTransform _content;
private static RectTransform _scrollRt;
private static ScrollRect _scroll;
private static Text _titleText;
private static Text _hotkeyText;
private static Image _titleIcon;
private static readonly List<GameObject> Rows = new List<GameObject>();
private static bool _visible;
private static int _lastCount = -1;
private static string _lastAgeId = "";
private static LoreTab _tab = LoreTab.World;
private static GameObject _tabsBar;
private static GameObject _worldTabBtn;
private static GameObject _livingTabBtn;
private static GameObject _fallenTabBtn;
private static GameObject _charTools;
private static InputField _searchField;
private static Button _favFilterBtn;
private static Image _favFilterIcon;
private static Button _refreshBtn;
private static Image _refreshBg;
private static Text _refreshLabel;
private static bool _favoritesOnly;
private static string _searchText = "";
private static int _lastCharFingerprint = int.MinValue;
private static int _builtAtRevision = int.MinValue;
private static long _detailUnitId;
private static bool _followFocus;
private static GameObject _backBtn;
private static Text _backBtnLabel;
/// <summary>Rows actually built in the last character detail rebuild (harness).</summary>
public static int LastDetailHistoryRows { get; private set; }
/// <summary>Title of the first row in the last Living/Fallen list rebuild (harness).</summary>
public static string LastListTopTitle { get; private set; } = "";
/// <summary>True when Living/Fallen list is older than new chronicle events (needs Refresh).</summary>
public static bool CastListStale =>
Visible && IsCastTab && _detailUnitId == 0 && Chronicle.HistoryRevision != _builtAtRevision;
public static bool Visible => _visible && _root != null && _root.activeSelf;
public static bool IsReady => _root != null;
public static LoreTab ActiveTab => _tab;
public static bool FavoritesOnly => _favoritesOnly;
public static string SearchText => _searchText ?? "";
public static long DetailUnitId => _detailUnitId;
private static bool IsCastTab => _tab == LoreTab.Living || _tab == LoreTab.Fallen;
/// <summary>True when Lore detail was opened via L/books and should track idle focus.</summary>
public static bool FollowFocus => _followFocus;
public static bool IsTypingSearch =>
_detailUnitId == 0
&& _searchField != null
&& _searchField.isFocused
&& _root != null
&& _root.activeInHierarchy;
public static void EnsureBuilt()
{
DestroyOrphanHuds();
if (_root != null
&& (_root.transform.Find("Tabs") == null || _root.transform.Find(ChromeMarker) == null))
{
try
{
Object.Destroy(_root);
}
catch
{
// ignore
}
_root = null;
_rootRt = null;
_content = null;
_scrollRt = null;
_scroll = null;
_tabsBar = null;
_searchField = null;
_backBtn = null;
_backBtnLabel = null;
_detailUnitId = 0;
_followFocus = false;
}
if (_root != null)
{
ApplyRootPlacement();
return;
}
if (!Config.game_loaded && CanvasMain.instance == null)
{
return;
}
Canvas canvas = HudCanvas.Resolve();
if (canvas == null)
{
return;
}
_root = new GameObject("IdleSpectatorWorldMemoryHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
_root.transform.SetParent(canvas.transform, false);
new GameObject(ChromeMarker, typeof(RectTransform)).transform.SetParent(_root.transform, false);
_rootRt = _root.GetComponent<RectTransform>();
ApplyRootPlacement();
Image bg = _root.GetComponent<Image>();
bg.raycastTarget = true;
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.92f));
CanvasGroup group = _root.GetComponent<CanvasGroup>();
group.blocksRaycasts = true;
group.interactable = true;
_hotkeyText = HudCanvas.MakeText(_root.transform, "Hotkey", "L", 8);
RectTransform hotRt = _hotkeyText.GetComponent<RectTransform>();
hotRt.anchorMin = new Vector2(1f, 1f);
hotRt.anchorMax = new Vector2(1f, 1f);
hotRt.pivot = new Vector2(1f, 1f);
hotRt.sizeDelta = new Vector2(16f, 12f);
hotRt.anchoredPosition = new Vector2(-6f, -5f);
_hotkeyText.alignment = TextAnchor.MiddleRight;
_hotkeyText.color = new Color(0.5f, 0.53f, 0.58f, 0.95f);
_hotkeyText.raycastTarget = false;
_titleIcon = HudCanvas.MakeIcon(_root.transform, "TitleIcon", TitleIconSize);
RectTransform titleIconRt = _titleIcon.GetComponent<RectTransform>();
titleIconRt.anchorMin = new Vector2(0f, 1f);
titleIconRt.anchorMax = new Vector2(0f, 1f);
titleIconRt.pivot = new Vector2(0.5f, 0.5f);
titleIconRt.anchoredPosition = new Vector2(13f, -TitleBandH * 0.5f - 1f);
HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks") ?? HudIcons.FromUiIcon("iconWar"));
_titleText = HudCanvas.MakeText(_root.transform, "Title", "Lore", 9);
RectTransform titleRt = _titleText.GetComponent<RectTransform>();
titleRt.anchorMin = new Vector2(0f, 1f);
titleRt.anchorMax = new Vector2(1f, 1f);
titleRt.pivot = new Vector2(0.5f, 1f);
titleRt.offsetMin = new Vector2(24f, -TitleBandH);
titleRt.offsetMax = new Vector2(-22f, -2f);
_titleText.alignment = TextAnchor.MiddleLeft;
_titleText.color = new Color(0.9f, 0.91f, 0.93f, 1f);
_titleText.fontStyle = FontStyle.Bold;
_titleText.raycastTarget = false;
_titleText.horizontalOverflow = HorizontalWrapMode.Overflow;
BuildTabs();
BuildCharTools();
BuildScroll();
_root.SetActive(false);
_visible = false;
ApplyTabChrome();
LogService.LogInfo("[IdleSpectator] Lore HUD ready (L, World + Living + Fallen)");
}
private static void BuildTabs()
{
_tabsBar = new GameObject("Tabs", typeof(RectTransform));
_tabsBar.transform.SetParent(_root.transform, false);
RectTransform tabsRt = _tabsBar.GetComponent<RectTransform>();
tabsRt.anchorMin = new Vector2(0f, 1f);
tabsRt.anchorMax = new Vector2(1f, 1f);
tabsRt.pivot = new Vector2(0.5f, 1f);
tabsRt.anchoredPosition = new Vector2(0f, -TitleBandH);
tabsRt.sizeDelta = new Vector2(-12f, TabH);
_worldTabBtn = BuildTabButton(_tabsBar.transform, "WorldTab", "World", () => SetTab(LoreTab.World));
_livingTabBtn = BuildTabButton(_tabsBar.transform, "LivingTab", "Living", () => SetTab(LoreTab.Living));
_fallenTabBtn = BuildTabButton(_tabsBar.transform, "FallenTab", "Fallen", () => SetTab(LoreTab.Fallen));
PlaceTab(_worldTabBtn, 0f, 3f);
PlaceTab(_livingTabBtn, 1f, 3f);
PlaceTab(_fallenTabBtn, 2f, 3f);
}
private static GameObject BuildTabButton(Transform parent, string name, string label, UnityAction onClick)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
Image bg = go.GetComponent<Image>();
bg.color = new Color(0.1f, 0.11f, 0.13f, 0.35f);
bg.raycastTarget = true;
Button button = go.GetComponent<Button>();
button.targetGraphic = bg;
button.onClick.AddListener(onClick);
Text text = HudCanvas.MakeText(go.transform, "Label", label, 8);
StretchFull(text.GetComponent<RectTransform>(), 1f);
text.alignment = TextAnchor.MiddleCenter;
text.color = new Color(0.7f, 0.72f, 0.76f, 1f);
text.raycastTarget = false;
// Active underline accent (toggled in StyleTab).
GameObject underline = new GameObject("Underline", typeof(RectTransform), typeof(Image));
underline.transform.SetParent(go.transform, false);
RectTransform uRt = underline.GetComponent<RectTransform>();
uRt.anchorMin = new Vector2(0.18f, 0f);
uRt.anchorMax = new Vector2(0.82f, 0f);
uRt.pivot = new Vector2(0.5f, 0f);
uRt.sizeDelta = new Vector2(0f, 2f);
uRt.anchoredPosition = Vector2.zero;
Image uImg = underline.GetComponent<Image>();
uImg.color = new Color(0.55f, 0.72f, 0.95f, 0f);
uImg.raycastTarget = false;
return go;
}
private static void PlaceTab(GameObject tab, float index, float count = 3f)
{
RectTransform rt = tab.GetComponent<RectTransform>();
float w = 1f / Mathf.Max(1f, count);
rt.anchorMin = new Vector2(index * w, 0f);
rt.anchorMax = new Vector2((index + 1f) * w, 1f);
rt.offsetMin = new Vector2(index == 0f ? 0f : 1f, 0f);
rt.offsetMax = new Vector2(index >= count - 1f ? 0f : -1f, 0f);
}
private static void BuildCharTools()
{
_charTools = new GameObject("CharTools", typeof(RectTransform));
_charTools.transform.SetParent(_root.transform, false);
RectTransform toolsRt = _charTools.GetComponent<RectTransform>();
toolsRt.anchorMin = new Vector2(0f, 1f);
toolsRt.anchorMax = new Vector2(1f, 1f);
toolsRt.pivot = new Vector2(0.5f, 1f);
toolsRt.anchoredPosition = new Vector2(0f, -(TitleBandH + TabH + 1f));
toolsRt.sizeDelta = new Vector2(-12f, CharToolsH);
GameObject searchGo = new GameObject(
"Search",
typeof(RectTransform),
typeof(Image),
typeof(InputField));
searchGo.transform.SetParent(_charTools.transform, false);
RectTransform searchRt = searchGo.GetComponent<RectTransform>();
searchRt.anchorMin = new Vector2(0f, 0f);
searchRt.anchorMax = new Vector2(1f, 1f);
searchRt.offsetMin = Vector2.zero;
searchRt.offsetMax = new Vector2(-40f, 0f);
Image searchBg = searchGo.GetComponent<Image>();
searchBg.color = new Color(0.05f, 0.06f, 0.08f, 0.95f);
searchBg.raycastTarget = true;
Text placeholder = HudCanvas.MakeText(searchGo.transform, "Placeholder", "Search name…", 8);
StretchFull(placeholder.GetComponent<RectTransform>(), 4f);
placeholder.alignment = TextAnchor.MiddleLeft;
placeholder.color = new Color(0.5f, 0.52f, 0.56f, 0.9f);
placeholder.raycastTarget = false;
Text searchText = HudCanvas.MakeText(searchGo.transform, "Text", "", 8);
StretchFull(searchText.GetComponent<RectTransform>(), 4f);
searchText.alignment = TextAnchor.MiddleLeft;
searchText.color = new Color(0.9f, 0.9f, 0.92f, 1f);
searchText.supportRichText = false;
searchText.raycastTarget = false;
_searchField = searchGo.GetComponent<InputField>();
_searchField.textComponent = searchText;
_searchField.placeholder = placeholder;
_searchField.targetGraphic = searchBg;
_searchField.caretColor = Color.white;
_searchField.customCaretColor = true;
_searchField.onValueChanged.AddListener(OnSearchChanged);
_refreshBtn = BuildTextTool(_charTools.transform, "Refresh", "↻", RefreshCastList);
_refreshBg = _refreshBtn.GetComponent<Image>();
_refreshLabel = _refreshBtn.transform.Find("Label")?.GetComponent<Text>();
RectTransform refreshRt = _refreshBtn.GetComponent<RectTransform>();
refreshRt.anchorMin = new Vector2(1f, 0f);
refreshRt.anchorMax = new Vector2(1f, 1f);
refreshRt.pivot = new Vector2(1f, 0.5f);
refreshRt.sizeDelta = new Vector2(18f, 0f);
refreshRt.anchoredPosition = new Vector2(-19f, 0f);
_favFilterBtn = BuildIconTool(_charTools.transform, "FavFilter", HudIcons.Favorite(), ToggleFavoritesOnly);
_favFilterIcon = _favFilterBtn.transform.Find("Icon")?.GetComponent<Image>();
RectTransform favRt = _favFilterBtn.GetComponent<RectTransform>();
favRt.anchorMin = new Vector2(1f, 0f);
favRt.anchorMax = new Vector2(1f, 1f);
favRt.pivot = new Vector2(1f, 0.5f);
favRt.sizeDelta = new Vector2(18f, 0f);
favRt.anchoredPosition = Vector2.zero;
_backBtn = BuildTabButton(_charTools.transform, "BackBtn", "← Living", ClearUnitDetail);
_backBtnLabel = _backBtn.transform.Find("Label")?.GetComponent<Text>();
RectTransform backRt = _backBtn.GetComponent<RectTransform>();
backRt.anchorMin = new Vector2(0f, 0f);
backRt.anchorMax = new Vector2(0.42f, 1f);
backRt.offsetMin = Vector2.zero;
backRt.offsetMax = Vector2.zero;
_backBtn.SetActive(false);
_charTools.SetActive(false);
RefreshFavFilterVisual();
RefreshStaleVisual();
}
private static Button BuildTextTool(Transform parent, string name, string label, UnityAction onClick)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
Image bg = go.GetComponent<Image>();
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.95f);
bg.raycastTarget = true;
Button button = go.GetComponent<Button>();
button.targetGraphic = bg;
button.onClick.AddListener(onClick);
Text text = HudCanvas.MakeText(go.transform, "Label", label, 10);
StretchFull(text.GetComponent<RectTransform>(), 0f);
text.alignment = TextAnchor.MiddleCenter;
text.color = new Color(0.78f, 0.8f, 0.84f, 1f);
text.raycastTarget = false;
return button;
}
private static Button BuildIconTool(Transform parent, string name, Sprite icon, UnityAction onClick)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
Image bg = go.GetComponent<Image>();
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.95f);
bg.raycastTarget = true;
Button button = go.GetComponent<Button>();
button.targetGraphic = bg;
button.onClick.AddListener(onClick);
Image iconImg = HudCanvas.MakeIcon(go.transform, "Icon", 12f);
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;
iconRt.sizeDelta = new Vector2(12f, 12f);
HudIcons.Apply(iconImg, icon);
return button;
}
private static void BuildScroll()
{
GameObject scrollGo = new GameObject(
"Scroll",
typeof(RectTransform),
typeof(Image),
typeof(ScrollRect),
typeof(RectMask2D));
scrollGo.transform.SetParent(_root.transform, false);
_scrollRt = scrollGo.GetComponent<RectTransform>();
Image scrollBg = scrollGo.GetComponent<Image>();
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
scrollBg.raycastTarget = true;
GameObject contentGo = new GameObject(
"Content",
typeof(RectTransform),
typeof(VerticalLayoutGroup),
typeof(ContentSizeFitter));
contentGo.transform.SetParent(scrollGo.transform, false);
_content = contentGo.GetComponent<RectTransform>();
_content.anchorMin = new Vector2(0f, 1f);
_content.anchorMax = new Vector2(1f, 1f);
_content.pivot = new Vector2(0.5f, 1f);
_content.anchoredPosition = Vector2.zero;
_content.sizeDelta = new Vector2(0f, 0f);
VerticalLayoutGroup layout = contentGo.GetComponent<VerticalLayoutGroup>();
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = true;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
layout.spacing = 2f;
layout.padding = new RectOffset(0, 0, 0, 0);
ContentSizeFitter fitter = contentGo.GetComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
ScrollRect scroll = scrollGo.GetComponent<ScrollRect>();
_scroll = scroll;
scroll.content = _content;
scroll.horizontal = false;
scroll.vertical = true;
scroll.inertia = true;
scroll.decelerationRate = 0.135f;
scroll.scrollSensitivity = ScrollSensitivity;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.viewport = _scrollRt;
ApplyScrollInsets();
}
private static void ApplyScrollInsets()
{
if (_scrollRt == null)
{
return;
}
bool chars = IsCastTab;
bool detail = chars && _detailUnitId != 0;
float top = TitleBandH + 2f;
if (!detail)
{
top += TabH + 1f;
}
if (chars)
{
top += CharToolsH + 2f;
}
_scrollRt.anchorMin = new Vector2(0f, 0f);
_scrollRt.anchorMax = new Vector2(1f, 1f);
_scrollRt.offsetMin = new Vector2(6f, 6f);
_scrollRt.offsetMax = new Vector2(-6f, -top);
if (_scroll != null)
{
_scroll.scrollSensitivity = ScrollSensitivity;
}
}
private static void DestroyOrphanHuds()
{
try
{
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;
}
_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;
}
/// <summary>
/// True when scroll over Lore / dossier should not zoom the map.
/// Vanilla allows wheel zoom over UI while a unit is focused (spectator mode).
/// </summary>
public static bool ShouldAbsorbScrollZoom()
{
return IsPointerOverPanel() || WatchCaption.IsPointerOverPanel();
}
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)
{
EnsureBuilt();
if (_root == null)
{
return;
}
ApplyRootPlacement();
_visible = visible;
_root.SetActive(visible);
if (visible)
{
Rebuild(force: true);
}
else if (_searchField != null && EventSystem.current != null
&& EventSystem.current.currentSelectedGameObject == _searchField.gameObject)
{
EventSystem.current.SetSelectedGameObject(null);
}
}
public static void Toggle()
{
if (Visible)
{
SetVisible(false);
return;
}
OpenSyncedFromFocus();
}
/// <summary>
/// Open Lore for the focused / dossier unit (locks idle like the books button).
/// With no subject, opens the Characters browser.
/// </summary>
public static void OpenSyncedFromFocus()
{
EnsureBuilt();
long id = WatchCaption.CurrentUnitId;
if (id == 0)
{
id = Chronicle.CurrentHistorySubjectId();
}
if (id != 0)
{
OpenUnitHistory(id, pauseIdle: true, followFocus: true);
return;
}
_detailUnitId = 0;
_followFocus = false;
_tab = LoreTab.Living;
SetVisible(true);
}
public static void SetTab(LoreTab tab)
{
EnsureBuilt();
if (tab == LoreTab.World)
{
_detailUnitId = 0;
_followFocus = false;
}
if (_tab == tab && _detailUnitId == 0)
{
ApplyTabChrome();
return;
}
_tab = tab;
_lastCharFingerprint = int.MinValue;
ApplyTabChrome();
Rebuild(force: true);
}
/// <summary>
/// Show one unit's full chronicle in Lore (Living or Fallen tab).
/// <paramref name="followFocus"/> true (L/books) retargets with idle; false (list) pins the archive.
/// </summary>
public static void OpenUnitHistory(long unitId, bool pauseIdle = true, bool followFocus = false)
{
if (unitId == 0)
{
return;
}
EnsureBuilt();
_detailUnitId = unitId;
_followFocus = followFocus;
if (pauseIdle && SpectatorMode.Active)
{
SpectatorMode.SetActive(false, quiet: true);
}
bool live = Chronicle.FocusCameraForBrowse(unitId);
_tab = live ? LoreTab.Living : LoreTab.Fallen;
if (live)
{
WatchCaption.PinWhilePaused();
}
else
{
// Dead / missing: keep dossier if any; pan to killer or death place.
WatchCaption.PinWhilePaused();
Chronicle.FocusFallenSubject(unitId);
}
if (pauseIdle)
{
WatchCaption.ShowStatusBanner(
live ? "Paused (viewing history)" : "Paused (viewing fallen)");
}
SetVisible(true);
ApplyTabChrome();
Rebuild(force: true);
}
public static void ClearUnitDetail()
{
_detailUnitId = 0;
_followFocus = false;
_lastCharFingerprint = int.MinValue;
ApplyTabChrome();
if (Visible && IsCastTab)
{
Rebuild(force: true);
}
}
public static void SetFavoritesOnly(bool on)
{
if (_favoritesOnly == on)
{
return;
}
_favoritesOnly = on;
RefreshFavFilterVisual();
if (IsCastTab)
{
Rebuild(force: true);
}
}
public static void SetSearch(string text)
{
EnsureBuilt();
string next = text ?? "";
if (_searchField != null && _searchField.text != next)
{
_searchField.onValueChanged.RemoveListener(OnSearchChanged);
_searchField.text = next;
_searchField.onValueChanged.AddListener(OnSearchChanged);
}
_searchText = next;
if (IsCastTab)
{
Rebuild(force: true);
}
}
/// <summary>Harness: open the first listed living character's full history (pinned archive).</summary>
public static bool PickFirstCharacter(string nameNeedle = "")
{
EnsureBuilt();
ClearUnitDetail();
SetTab(LoreTab.Living);
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(
_searchText, _favoritesOnly, scope: Chronicle.CharacterListScope.Living);
string needle = (nameNeedle ?? "").Trim().ToLowerInvariant();
for (int i = 0; i < list.Count; i++)
{
ChronicleCharacterSummary row = list[i];
if (row == null)
{
continue;
}
if (!string.IsNullOrEmpty(needle)
&& (row.Title ?? "").ToLowerInvariant().IndexOf(needle, System.StringComparison.Ordinal) < 0)
{
continue;
}
OpenUnitHistory(row.UnitId, pauseIdle: true, followFocus: false);
return true;
}
return false;
}
/// <summary>Harness: open the first fallen (dead) character with history.</summary>
public static bool PickFirstFallen(string nameNeedle = "")
{
EnsureBuilt();
ClearUnitDetail();
SetTab(LoreTab.Fallen);
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(
_searchText, favoritesOnly: false, scope: Chronicle.CharacterListScope.Fallen);
string needle = (nameNeedle ?? "").Trim().ToLowerInvariant();
for (int i = 0; i < list.Count; i++)
{
ChronicleCharacterSummary row = list[i];
if (row == null || row.HistoryCount <= 0)
{
continue;
}
if (!string.IsNullOrEmpty(needle)
&& (row.Title ?? "").ToLowerInvariant().IndexOf(needle, System.StringComparison.Ordinal) < 0)
{
continue;
}
OpenUnitHistory(row.UnitId, pauseIdle: true, followFocus: false);
return true;
}
return false;
}
private static void ToggleFavoritesOnly()
{
SetFavoritesOnly(!_favoritesOnly);
}
private static void OnSearchChanged(string value)
{
_searchText = value ?? "";
if (IsCastTab)
{
Rebuild(force: true);
}
}
private static void ApplyTabChrome()
{
bool chars = IsCastTab;
bool detail = chars && _detailUnitId != 0;
if (_tabsBar != null)
{
// Detail is a full-page character history: drop the chunky tab strip.
_tabsBar.SetActive(!detail);
}
if (_charTools != null)
{
_charTools.SetActive(chars);
RectTransform toolsRt = _charTools.GetComponent<RectTransform>();
if (toolsRt != null)
{
float y = detail ? -TitleBandH : -(TitleBandH + TabH + 1f);
toolsRt.anchoredPosition = new Vector2(0f, y);
}
}
if (_searchField != null)
{
_searchField.gameObject.SetActive(chars && !detail);
}
if (_favFilterBtn != null)
{
_favFilterBtn.gameObject.SetActive(chars && !detail);
}
if (_refreshBtn != null)
{
_refreshBtn.gameObject.SetActive(chars && !detail);
RectTransform refreshRt = _refreshBtn.GetComponent<RectTransform>();
if (refreshRt != null)
{
// Search | Refresh | Fav on both Living and Fallen.
refreshRt.anchoredPosition = new Vector2(-19f, 0f);
}
}
if (_backBtn != null)
{
_backBtn.SetActive(detail);
if (_backBtnLabel != null)
{
_backBtnLabel.text = _tab == LoreTab.Fallen ? "← Fallen" : "← Living";
}
}
StyleTab(_worldTabBtn, _tab == LoreTab.World);
StyleTab(_livingTabBtn, _tab == LoreTab.Living);
StyleTab(_fallenTabBtn, _tab == LoreTab.Fallen);
RefreshStaleVisual();
ApplyScrollInsets();
RefreshTitle();
}
private static void RefreshTitle()
{
if (_titleText == null)
{
return;
}
if (IsCastTab)
{
if (_detailUnitId != 0)
{
string title = Chronicle.TitleForSubject(_detailUnitId);
_titleText.text = string.IsNullOrEmpty(title) ? "History" : title;
return;
}
_titleText.text = _tab == LoreTab.Fallen ? "Fallen" : "Living";
return;
}
string ageTitle = Chronicle.CurrentAgeName;
_titleText.text = string.IsNullOrEmpty(ageTitle)
? "World"
: "World · " + ageTitle;
}
private static void StyleTab(GameObject tab, bool active)
{
if (tab == null)
{
return;
}
Image bg = tab.GetComponent<Image>();
if (bg != null)
{
bg.color = active
? new Color(0.16f, 0.18f, 0.22f, 0.55f)
: new Color(0.08f, 0.09f, 0.1f, 0.15f);
}
Text label = tab.transform.Find("Label")?.GetComponent<Text>();
if (label != null)
{
label.color = active
? new Color(0.95f, 0.96f, 0.98f, 1f)
: new Color(0.62f, 0.65f, 0.7f, 1f);
label.fontStyle = active ? FontStyle.Bold : FontStyle.Normal;
}
Image underline = tab.transform.Find("Underline")?.GetComponent<Image>();
if (underline != null)
{
underline.color = active
? new Color(0.55f, 0.72f, 0.95f, 0.95f)
: new Color(0.55f, 0.72f, 0.95f, 0f);
}
}
private static void RefreshFavFilterVisual()
{
if (_favFilterIcon == null)
{
return;
}
HudIcons.Apply(_favFilterIcon, HudIcons.Favorite());
_favFilterIcon.color = _favoritesOnly
? new Color(1f, 0.85f, 0.25f, 1f)
: new Color(0.55f, 0.58f, 0.62f, 1f);
}
private static void RefreshStaleVisual()
{
if (_refreshBg == null)
{
return;
}
bool stale = CastListStale;
_refreshBg.color = stale
? new Color(0.28f, 0.34f, 0.22f, 0.98f)
: new Color(0.12f, 0.13f, 0.16f, 0.95f);
if (_refreshLabel != null)
{
_refreshLabel.color = stale
? new Color(0.85f, 0.95f, 0.55f, 1f)
: new Color(0.78f, 0.8f, 0.84f, 1f);
}
}
/// <summary>Manual reload of Living/Fallen list (or open detail history).</summary>
public static void RefreshCastList()
{
if (!Visible || !IsCastTab)
{
return;
}
Rebuild(force: true);
LogService.LogInfo("[IdleSpectator][LORE] cast list refreshed");
}
public static void UpdateLive()
{
if (!Visible)
{
return;
}
ApplyRootPlacement();
if (_tab == LoreTab.World)
{
int count = Chronicle.MemoryCount;
string ageId = Chronicle.CurrentAgeId;
if (count != _lastCount || ageId != _lastAgeId)
{
Rebuild(force: true);
}
return;
}
// Living/Fallen: snapshot until Refresh (or tab/search/fav). Follow mode still retargets.
if (TryRetargetFollowFocus())
{
return;
}
RefreshStaleVisual();
}
/// <summary>
/// Follow mode: while idle is active, keep Lore detail on the dossier/focus subject.
/// </summary>
private static bool TryRetargetFollowFocus()
{
if (!_followFocus || _detailUnitId == 0 || !IsCastTab)
{
return false;
}
if (!SpectatorMode.Active)
{
return false;
}
long id = WatchCaption.CurrentUnitId;
if (id == 0)
{
id = Chronicle.CurrentHistorySubjectId();
}
if (id == 0 || id == _detailUnitId)
{
return false;
}
_detailUnitId = id;
ApplyTabChrome();
Rebuild(force: true);
return true;
}
/// <summary>
/// Idle resumed (I): if Lore is open on a character page, switch back to Follow mode
/// so the archive pin from a list pick does not stick forever.
/// </summary>
public static void OnIdleResumed()
{
if (!Visible || !IsCastTab || _detailUnitId == 0)
{
return;
}
_followFocus = true;
long id = WatchCaption.CurrentUnitId;
if (id == 0)
{
id = Chronicle.CurrentHistorySubjectId();
}
if (id != 0 && id != _detailUnitId)
{
_detailUnitId = id;
ApplyTabChrome();
Rebuild(force: true);
}
}
private static int CharacterFingerprint()
{
unchecked
{
int hash = Chronicle.RecentFocusCount * 397;
hash = (hash * 31) + (_favoritesOnly ? 1 : 0);
hash = (hash * 31) + (_searchText ?? "").GetHashCode();
hash = (hash * 31) + _detailUnitId.GetHashCode();
hash = (hash * 31) + (int)_tab;
// Cheap dirty bits - never rescan all subjects every frame.
hash = (hash * 31) + Chronicle.HistoryRevision;
hash = (hash * 31) + Chronicle.HistorySubjectCount;
hash = (hash * 31) + Chronicle.MemoryCount;
if (_detailUnitId != 0)
{
hash = (hash * 31) + Chronicle.HistoryCountFor(_detailUnitId);
}
return hash;
}
}
public static void Rebuild(bool force = false)
{
EnsureBuilt();
if (_content == null || (!force && !Visible))
{
return;
}
ApplyRootPlacement();
ApplyTabChrome();
ClearRows();
if (IsCastTab)
{
RebuildCharacters();
return;
}
RebuildWorld();
}
private static void RebuildWorld()
{
IReadOnlyList<ChronicleEntry> entries = Chronicle.SnapshotMemory();
_lastCount = entries.Count;
_lastAgeId = Chronicle.CurrentAgeId;
RefreshTitle();
if (entries.Count == 0)
{
AddEmpty("No landmarks yet");
return;
}
List<ChronicleEntry> display = BuildDisplayOrder(entries);
int shown = 0;
for (int i = 0; i < display.Count; i++)
{
ChronicleEntry entry = display[i];
if (entry == null)
{
continue;
}
GameObject row = entry.Kind == ChronicleKind.AgeChapter
? BuildChapterRow(entry)
: BuildClickableRow(entry, shown);
row.transform.SetParent(_content, false);
Rows.Add(row);
shown++;
if (shown >= MaxVisibleRows)
{
break;
}
}
}
private static void RebuildCharacters()
{
RefreshTitle();
LastListTopTitle = "";
if (_detailUnitId != 0)
{
RebuildUnitHistoryDetail(_detailUnitId);
return;
}
Chronicle.CharacterListScope scope = _tab == LoreTab.Fallen
? Chronicle.CharacterListScope.Fallen
: Chronicle.CharacterListScope.Living;
bool favOnly = _favoritesOnly;
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(_searchText, favOnly, scope: scope);
_lastCharFingerprint = CharacterFingerprint();
_builtAtRevision = Chronicle.HistoryRevision;
RefreshStaleVisual();
if (list.Count == 0)
{
if (scope == Chronicle.CharacterListScope.Fallen)
{
AddEmpty(favOnly ? "No favorite fallen" : "No fallen yet");
}
else
{
AddEmpty(favOnly ? "No favorites match" : "No living yet");
}
return;
}
LastListTopTitle = list[0]?.Title ?? "";
bool wroteFavoritesHeader = false;
int shown = 0;
for (int i = 0; i < list.Count; i++)
{
ChronicleCharacterSummary row = list[i];
if (row == null)
{
continue;
}
if ((favOnly || row.IsFavorite) && !wroteFavoritesHeader)
{
AddSection("Favorites");
wroteFavoritesHeader = true;
}
GameObject go = BuildCharacterRow(row, shown);
go.transform.SetParent(_content, false);
Rows.Add(go);
shown++;
if (shown >= MaxVisibleRows)
{
break;
}
}
}
private static void RebuildUnitHistoryDetail(long unitId)
{
IReadOnlyList<ChronicleEntry> entries = Chronicle.LatestForSubject(unitId, Chronicle.MaxHistoryPerUnit);
_lastCharFingerprint = CharacterFingerprint();
_builtAtRevision = Chronicle.HistoryRevision;
RefreshStaleVisual();
if (entries == null || entries.Count == 0)
{
LastDetailHistoryRows = 0;
AddEmpty("No history yet");
return;
}
string section = entries.Count + " events";
ChronicleEntry death = Chronicle.LatestDeathEntry(unitId);
if (death != null)
{
section = Chronicle.DeathMannerLabel(death.ResolvedDeathManner) + " · " + entries.Count + " events";
}
AddSection(section);
int shown = 0;
int cap = Chronicle.MaxHistoryPerUnit;
for (int i = 0; i < entries.Count; i++)
{
ChronicleEntry entry = entries[i];
if (entry == null)
{
continue;
}
GameObject row = BuildHistoryDetailRow(entry, shown);
row.transform.SetParent(_content, false);
Rows.Add(row);
shown++;
if (shown >= cap)
{
break;
}
}
LastDetailHistoryRows = shown;
if (_scroll != null)
{
_scroll.verticalNormalizedPosition = 1f;
}
}
private static GameObject BuildHistoryDetailRow(ChronicleEntry entry, int index)
{
GameObject go = new GameObject(
$"HistDetail_{index}",
typeof(RectTransform),
typeof(Image),
typeof(LayoutElement),
typeof(ContentSizeFitter));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = RowHeightMin;
le.preferredHeight = -1f;
ContentSizeFitter fit = go.GetComponent<ContentSizeFitter>();
fit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
Image bg = go.GetComponent<Image>();
bg.color = index % 2 == 0
? new Color(1f, 1f, 1f, 0.04f)
: new Color(1f, 1f, 1f, 0.015f);
bg.raycastTarget = false;
Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize);
RectTransform kindRt = kindIcon.GetComponent<RectTransform>();
kindRt.anchorMin = new Vector2(0f, 1f);
kindRt.anchorMax = new Vector2(0f, 1f);
kindRt.pivot = new Vector2(0.5f, 1f);
kindRt.anchoredPosition = new Vector2(9f, -5f);
kindIcon.raycastTarget = false;
HudIcons.Apply(
kindIcon,
entry.Kind == ChronicleKind.Death
? HudIcons.ForDeathManner(entry.ResolvedDeathManner)
: HudIcons.ForChronicleKind(entry.Kind));
Text label = HudCanvas.MakeText(go.transform, "Text", entry.DisplayLineRich, 8);
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = new Vector2(0f, 0f);
labelRt.anchorMax = new Vector2(1f, 1f);
labelRt.offsetMin = new Vector2(18f, 2f);
labelRt.offsetMax = new Vector2(-4f, -2f);
label.alignment = TextAnchor.UpperLeft;
label.color = entry.Kind == ChronicleKind.Death
? ColorForDeathManner(entry.ResolvedDeathManner, listRow: false)
: ColorForKind(entry.Kind);
label.supportRichText = true;
label.resizeTextForBestFit = false;
label.horizontalOverflow = HorizontalWrapMode.Wrap;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.raycastTarget = false;
ContentSizeFitter textFit = label.gameObject.AddComponent<ContentSizeFitter>();
textFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
textFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
return go;
}
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)
{
GameObject empty = BuildStaticRow(text);
empty.transform.SetParent(_content, false);
Rows.Add(empty);
}
private static void AddSection(string title)
{
GameObject go = new GameObject("Section", 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);
StretchFull(label.GetComponent<RectTransform>(), 2f);
label.alignment = TextAnchor.MiddleLeft;
label.color = new Color(0.7f, 0.74f, 0.82f, 1f);
label.fontStyle = FontStyle.Bold;
label.raycastTarget = false;
go.transform.SetParent(_content, false);
Rows.Add(go);
}
private static void ClearRows()
{
foreach (GameObject row in Rows)
{
if (row != null)
{
Object.DestroyImmediate(row);
}
}
Rows.Clear();
LastDetailHistoryRows = 0;
}
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);
StretchFull(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)
{
GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = RowHeightMin;
le.preferredHeight = RowHeightMin;
Text label = HudCanvas.MakeText(go.transform, "Text", text, 8);
StretchFull(label.GetComponent<RectTransform>(), 3f);
label.alignment = TextAnchor.MiddleLeft;
label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f);
label.resizeTextForBestFit = false;
label.horizontalOverflow = HorizontalWrapMode.Wrap;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.raycastTarget = false;
return go;
}
private static GameObject BuildCharacterRow(ChronicleCharacterSummary summary, int index)
{
GameObject go = new GameObject(
$"Char_{index}",
typeof(RectTransform),
typeof(Image),
typeof(Button),
typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = RowHeightMin;
le.preferredHeight = RowHeightMin;
Image bg = go.GetComponent<Image>();
bg.color = summary.IsFavorite
? new Color(0.95f, 0.85f, 0.45f, 0.07f)
: (summary.IsAlive
? new Color(1f, 1f, 1f, 0.03f)
: ColorForDeathManner(summary.DeathManner, listRow: true));
bg.raycastTarget = true;
Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize);
RectTransform kindRt = kindIcon.GetComponent<RectTransform>();
kindRt.anchorMin = new Vector2(0f, 0.5f);
kindRt.anchorMax = new Vector2(0f, 0.5f);
kindRt.pivot = new Vector2(0.5f, 0.5f);
kindRt.anchoredPosition = new Vector2(9f, 0f);
kindIcon.raycastTarget = false;
HudIcons.Apply(
kindIcon,
summary.IsFavorite
? HudIcons.Favorite()
: (summary.IsAlive
? (HudIcons.FromUiIcon("iconUnit") ?? HudIcons.FromUiIcon("iconPopulation"))
: HudIcons.ForDeathManner(summary.DeathManner)));
string title = summary.Title;
if (!summary.IsAlive && !string.IsNullOrEmpty(title))
{
string manner = summary.DeathLabel;
title += string.IsNullOrEmpty(manner) ? " · fallen" : " · " + manner;
}
Text label = HudCanvas.MakeText(go.transform, "Text", title, 8);
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = new Vector2(0f, 0f);
labelRt.anchorMax = new Vector2(1f, 1f);
labelRt.offsetMin = new Vector2(18f, 1f);
labelRt.offsetMax = new Vector2(-4f, -1f);
label.alignment = TextAnchor.MiddleLeft;
label.color = summary.IsAlive
? new Color(0.9f, 0.9f, 0.92f, 1f)
: ColorForDeathManner(summary.DeathManner, listRow: false);
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.raycastTarget = false;
Button button = go.GetComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.2f, 1.2f, 1.25f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
long capturedId = summary.UnitId;
string capturedTitle = title;
button.onClick.AddListener(new UnityAction(() =>
{
OpenUnitHistory(capturedId, pauseIdle: true, followFocus: false);
LogService.LogInfo($"[IdleSpectator][LORE] open history unit={capturedTitle}");
}));
return go;
}
private static GameObject BuildClickableRow(ChronicleEntry entry, int index)
{
GameObject go = new GameObject(
$"Row_{index}",
typeof(RectTransform),
typeof(Image),
typeof(Button),
typeof(LayoutElement),
typeof(ContentSizeFitter));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = RowHeightMin;
le.preferredHeight = -1f;
ContentSizeFitter rowFit = go.GetComponent<ContentSizeFitter>();
rowFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
rowFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
Image bg = go.GetComponent<Image>();
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);
RectTransform kindRt = kindIcon.GetComponent<RectTransform>();
kindRt.anchorMin = new Vector2(0f, 1f);
kindRt.anchorMax = new Vector2(0f, 1f);
kindRt.pivot = new Vector2(0.5f, 1f);
kindRt.anchoredPosition = new Vector2(9f, -5f);
kindIcon.raycastTarget = false;
HudIcons.Apply(kindIcon, HudIcons.ForChronicleKind(entry.Kind));
Text label = HudCanvas.MakeText(go.transform, "Text", entry.DisplayLineRich, 8);
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = new Vector2(0f, 0f);
labelRt.anchorMax = new Vector2(1f, 1f);
labelRt.offsetMin = new Vector2(18f, 2f);
labelRt.offsetMax = new Vector2(-4f, -2f);
label.alignment = TextAnchor.UpperLeft;
label.color = ColorForKind(entry.Kind);
label.supportRichText = true;
label.resizeTextForBestFit = false;
label.horizontalOverflow = HorizontalWrapMode.Wrap;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.raycastTarget = false;
ContentSizeFitter textFit = label.gameObject.AddComponent<ContentSizeFitter>();
textFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
textFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
Button button = go.GetComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.2f, 1.2f, 1.25f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
ChronicleEntry captured = entry;
button.onClick.AddListener(new UnityAction(() =>
{
if (SpectatorMode.Active)
{
SpectatorMode.SetActive(false, quiet: true);
WatchCaption.PinWhilePaused();
WatchCaption.ShowStatusBanner(
captured.Kind == ChronicleKind.World || captured.IsLegend
? "Paused (viewing world)"
: "Paused (viewing landmark)");
}
bool ok = Chronicle.JumpTo(captured);
LogService.LogInfo(
$"[IdleSpectator][MEMORY] jump ok={ok} detail={Chronicle.LastJumpDetail} line={captured.DisplayLine}");
}));
return go;
}
private static Color ColorForKind(ChronicleKind kind)
{
switch (kind)
{
case ChronicleKind.Death:
return new Color(0.78f, 0.72f, 0.78f, 1f);
case ChronicleKind.Kill:
return new Color(0.95f, 0.58f, 0.48f, 1f);
case ChronicleKind.Lover:
return new Color(0.95f, 0.58f, 0.78f, 1f);
case ChronicleKind.Friend:
return new Color(0.58f, 0.85f, 0.95f, 1f);
case ChronicleKind.World:
return new Color(0.95f, 0.85f, 0.48f, 1f);
case ChronicleKind.AgeChapter:
return new Color(0.7f, 0.74f, 0.82f, 1f);
default:
return new Color(0.88f, 0.88f, 0.9f, 1f);
}
}
private static Color ColorForDeathManner(DeathManner manner, bool listRow)
{
float a = listRow ? 0.08f : 1f;
Color c;
switch (manner)
{
case DeathManner.Slain:
c = new Color(0.95f, 0.45f, 0.42f, a);
break;
case DeathManner.OldAge:
c = new Color(0.72f, 0.7f, 0.78f, a);
break;
case DeathManner.Starvation:
c = new Color(0.85f, 0.7f, 0.45f, a);
break;
case DeathManner.Drowned:
c = new Color(0.45f, 0.65f, 0.9f, a);
break;
case DeathManner.Burned:
c = new Color(0.95f, 0.55f, 0.28f, a);
break;
case DeathManner.Plague:
c = new Color(0.55f, 0.85f, 0.4f, a);
break;
case DeathManner.Divine:
c = new Color(0.95f, 0.9f, 0.45f, a);
break;
case DeathManner.Accident:
c = new Color(0.75f, 0.55f, 0.85f, a);
break;
default:
c = new Color(0.62f, 0.58f, 0.64f, a);
break;
}
if (!listRow)
{
c.a = 0.95f;
}
return c;
}
private static void StretchFull(RectTransform rt, float pad)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(pad, 1f);
rt.offsetMax = new Vector2(-pad, -1f);
}
}