- Allow multiple named entries for grave force command on the same tile - Update dismiss behavior to clear Lore and dossier while keeping idle off - Introduce new methods for managing dismiss grace period - Refactor focus handling for fallen subjects and their killers - Increment version to 0.28.13 in mod.json
2373 lines
79 KiB
C#
2373 lines
79 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
|
|
}
|
|
|
|
public enum DetailPane
|
|
{
|
|
Life = 0,
|
|
Activity = 1
|
|
}
|
|
|
|
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 = "LoreV10";
|
|
|
|
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 readonly List<GraveMarkers.GraveEntry> GraveEntriesScratch =
|
|
new List<GraveMarkers.GraveEntry>(64);
|
|
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;
|
|
/// <summary>True when Lore itself paused idle; close should auto-resume.</summary>
|
|
private static bool _pausedIdleForLore;
|
|
private static GameObject _backBtn;
|
|
private static Text _backBtnLabel;
|
|
private static GameObject _focusKillerBtn;
|
|
private static Text _focusKillerLabel;
|
|
private static GameObject _keepGraveBtn;
|
|
private static Text _keepGraveLabel;
|
|
private static GameObject _deleteGraveBtn;
|
|
private static Text _deleteGraveLabel;
|
|
private static GameObject _detailPaneBar;
|
|
private static GameObject _detailActivityBtn;
|
|
private static GameObject _detailLifeBtn;
|
|
private static DetailPane _detailPane = DetailPane.Life;
|
|
|
|
/// <summary>Lore is browsing one tile's grave stack (from a skull click).</summary>
|
|
private static bool _graveStackActive;
|
|
private static int _graveTileX;
|
|
private static int _graveTileY;
|
|
private static bool _openingGraveStack;
|
|
private static float _ignoreDismissUntil;
|
|
|
|
/// <summary>Rows actually built in the last character detail rebuild (harness).</summary>
|
|
public static int LastDetailHistoryRows { get; private set; }
|
|
|
|
/// <summary>Activity rows in the last Activity-pane rebuild (harness).</summary>
|
|
public static int LastDetailActivityRows { get; private set; }
|
|
|
|
/// <summary>Active detail sub-pane when a unit history is open.</summary>
|
|
public static DetailPane ActiveDetailPane => _detailPane;
|
|
|
|
/// <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;
|
|
|
|
/// <summary>True when Lore is scoped to a gravestone stack (skull open).</summary>
|
|
public static bool GraveStackScopeActive => _graveStackActive;
|
|
|
|
public static int GraveStackTileX => _graveTileX;
|
|
public static int GraveStackTileY => _graveTileY;
|
|
|
|
/// <summary>Filtered stack list rows from the last grave-stack rebuild (harness).</summary>
|
|
public static int GraveStackFilteredCount { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Grave stack search should span the tools row (Keep/Del live in the title band).
|
|
/// </summary>
|
|
public static bool GraveSearchFillsTools
|
|
{
|
|
get
|
|
{
|
|
if (!_graveStackActive || _searchField == null || _charTools == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
RectTransform searchRt = _searchField.GetComponent<RectTransform>();
|
|
RectTransform toolsRt = _charTools.GetComponent<RectTransform>();
|
|
if (searchRt == null || toolsRt == null || !searchRt.gameObject.activeInHierarchy)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Full stretch within CharTools (no right inset reserved for Refresh/Fav).
|
|
return Mathf.Abs(searchRt.offsetMax.x) < 0.5f
|
|
&& searchRt.anchorMin.x <= 0.01f
|
|
&& searchRt.anchorMax.x >= 0.99f
|
|
&& toolsRt.rect.width > 1f
|
|
&& searchRt.rect.width >= toolsRt.rect.width - 1f;
|
|
}
|
|
}
|
|
|
|
/// <summary>Ignore outside-click dismiss briefly after Lore open / Keep.</summary>
|
|
public static float IgnoreDismissUntil => _ignoreDismissUntil;
|
|
|
|
/// <summary>Arm a short grace so the open click does not instantly dismiss Lore.</summary>
|
|
public static void ArmDismissGrace(float seconds = 0.4f)
|
|
{
|
|
_ignoreDismissUntil = Time.unscaledTime + Mathf.Max(0.05f, seconds);
|
|
}
|
|
|
|
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;
|
|
_focusKillerBtn = null;
|
|
_focusKillerLabel = null;
|
|
_keepGraveBtn = null;
|
|
_keepGraveLabel = null;
|
|
_deleteGraveBtn = null;
|
|
_deleteGraveLabel = null;
|
|
_detailPaneBar = null;
|
|
_detailActivityBtn = null;
|
|
_detailLifeBtn = null;
|
|
_detailUnitId = 0;
|
|
_followFocus = false;
|
|
_detailPane = DetailPane.Life;
|
|
ClearGraveStackScope();
|
|
}
|
|
|
|
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;
|
|
|
|
// Grave Keep/Del live in the title band so search can use the full tools row.
|
|
_keepGraveBtn = BuildTabButton(_root.transform, "KeepGrave", "Keep", ToggleKeepGrave);
|
|
_keepGraveLabel = _keepGraveBtn.transform.Find("Label")?.GetComponent<Text>();
|
|
PlaceTitleTool(_keepGraveBtn, -58f, 34f);
|
|
_keepGraveBtn.SetActive(false);
|
|
|
|
_deleteGraveBtn = BuildTabButton(
|
|
_root.transform, "DeleteGrave", "Del", () => { DeleteActiveGraveStack(); });
|
|
_deleteGraveLabel = _deleteGraveBtn.transform.Find("Label")?.GetComponent<Text>();
|
|
if (_deleteGraveLabel != null)
|
|
{
|
|
_deleteGraveLabel.color = new Color(0.92f, 0.62f, 0.58f, 1f);
|
|
}
|
|
|
|
PlaceTitleTool(_deleteGraveBtn, -22f, 34f);
|
|
_deleteGraveBtn.SetActive(false);
|
|
|
|
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 PlaceTitleTool(GameObject btn, float xFromRight, float width)
|
|
{
|
|
if (btn == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RectTransform rt = btn.GetComponent<RectTransform>();
|
|
rt.anchorMin = new Vector2(1f, 1f);
|
|
rt.anchorMax = new Vector2(1f, 1f);
|
|
rt.pivot = new Vector2(1f, 1f);
|
|
rt.sizeDelta = new Vector2(width, 14f);
|
|
rt.anchoredPosition = new Vector2(xFromRight, -4f);
|
|
}
|
|
|
|
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.28f, 1f);
|
|
backRt.offsetMin = Vector2.zero;
|
|
backRt.offsetMax = Vector2.zero;
|
|
_backBtn.SetActive(false);
|
|
|
|
_focusKillerBtn = BuildTabButton(_charTools.transform, "FocusKiller", "Killer", () => FocusDetailKiller());
|
|
_focusKillerLabel = _focusKillerBtn.transform.Find("Label")?.GetComponent<Text>();
|
|
RectTransform killerRt = _focusKillerBtn.GetComponent<RectTransform>();
|
|
killerRt.anchorMin = new Vector2(0.29f, 0f);
|
|
killerRt.anchorMax = new Vector2(0.48f, 1f);
|
|
killerRt.offsetMin = Vector2.zero;
|
|
killerRt.offsetMax = Vector2.zero;
|
|
_focusKillerBtn.SetActive(false);
|
|
|
|
_detailPaneBar = new GameObject("DetailPanes", typeof(RectTransform));
|
|
_detailPaneBar.transform.SetParent(_charTools.transform, false);
|
|
RectTransform paneRt = _detailPaneBar.GetComponent<RectTransform>();
|
|
paneRt.anchorMin = new Vector2(0.50f, 0f);
|
|
paneRt.anchorMax = new Vector2(1f, 1f);
|
|
paneRt.offsetMin = Vector2.zero;
|
|
paneRt.offsetMax = Vector2.zero;
|
|
_detailActivityBtn = BuildTabButton(
|
|
_detailPaneBar.transform, "ActivityPane", "Activity", () => SetDetailPane(DetailPane.Activity));
|
|
_detailLifeBtn = BuildTabButton(
|
|
_detailPaneBar.transform, "LifePane", "Life", () => SetDetailPane(DetailPane.Life));
|
|
PlaceDetailPane(_detailActivityBtn, 0f);
|
|
PlaceDetailPane(_detailLifeBtn, 1f);
|
|
_detailPaneBar.SetActive(false);
|
|
|
|
_charTools.SetActive(false);
|
|
RefreshFavFilterVisual();
|
|
RefreshStaleVisual();
|
|
}
|
|
|
|
private static void PlaceDetailPane(GameObject tab, float index)
|
|
{
|
|
if (tab == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RectTransform rt = tab.GetComponent<RectTransform>();
|
|
float w = 0.5f;
|
|
rt.anchorMin = new Vector2(index * w, 0f);
|
|
rt.anchorMax = new Vector2(index * w + w, 1f);
|
|
rt.offsetMin = new Vector2(1f, 1f);
|
|
rt.offsetMax = new Vector2(-1f, -1f);
|
|
}
|
|
|
|
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();
|
|
bool wasVisible = _visible;
|
|
_visible = visible;
|
|
_root.SetActive(visible);
|
|
if (visible)
|
|
{
|
|
ArmDismissGrace();
|
|
Rebuild(force: true);
|
|
}
|
|
else
|
|
{
|
|
ClearGraveStackScope();
|
|
if (_searchField != null && EventSystem.current != null
|
|
&& EventSystem.current.currentSelectedGameObject == _searchField.gameObject)
|
|
{
|
|
EventSystem.current.SetSelectedGameObject(null);
|
|
}
|
|
|
|
if (wasVisible)
|
|
{
|
|
TryResumeIdleAfterLoreClose();
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void Toggle()
|
|
{
|
|
if (Visible)
|
|
{
|
|
SetVisible(false);
|
|
return;
|
|
}
|
|
|
|
OpenSyncedFromFocus();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear Lore→idle resume sticky when the user toggles Idle with I while Lore is open.
|
|
/// </summary>
|
|
public static void NotifyManualIdleToggle()
|
|
{
|
|
_pausedIdleForLore = false;
|
|
}
|
|
|
|
private static void TryResumeIdleAfterLoreClose()
|
|
{
|
|
if (!_pausedIdleForLore)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_pausedIdleForLore = false;
|
|
if (!ModSettings.Enabled || SpectatorMode.Active)
|
|
{
|
|
return;
|
|
}
|
|
|
|
SpectatorMode.SetActive(true, quiet: true);
|
|
LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Open Lore for the focused / dossier unit (locks idle; close auto-resumes).
|
|
/// With no subject, opens the Characters browser.
|
|
/// </summary>
|
|
public static void OpenSyncedFromFocus()
|
|
{
|
|
EnsureBuilt();
|
|
ClearGraveStackScope();
|
|
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 (!_openingGraveStack)
|
|
{
|
|
ClearGraveStackScope();
|
|
}
|
|
|
|
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>Open Lore scoped to one gravestone tile (skull click / harness).</summary>
|
|
public static bool OpenGraveStack(int tileX, int tileY, bool pauseIdle = true)
|
|
{
|
|
if (!GraveMarkers.TryGetStack(tileX, tileY, out GraveMarkers.GraveStack stack)
|
|
|| stack.Entries.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
EnsureBuilt();
|
|
_openingGraveStack = true;
|
|
try
|
|
{
|
|
InspectUi.MarkGraveSession();
|
|
_graveStackActive = true;
|
|
_graveTileX = tileX;
|
|
_graveTileY = tileY;
|
|
ArmDismissGrace();
|
|
_searchText = "";
|
|
if (_searchField != null)
|
|
{
|
|
_searchField.SetTextWithoutNotify("");
|
|
}
|
|
|
|
if (pauseIdle && SpectatorMode.Active)
|
|
{
|
|
_pausedIdleForLore = true;
|
|
SpectatorMode.SetActive(false, quiet: true);
|
|
}
|
|
else if (pauseIdle)
|
|
{
|
|
_pausedIdleForLore = false;
|
|
}
|
|
|
|
CameraDirector.ClearFollow();
|
|
CameraDirector.PanTo(stack.WorldPos);
|
|
WatchCaption.PinWhilePaused();
|
|
if (pauseIdle)
|
|
{
|
|
WatchCaption.ShowStatusBanner("Paused (viewing grave)");
|
|
}
|
|
|
|
if (stack.Entries.Count == 1)
|
|
{
|
|
long id = stack.Entries[stack.Entries.Count - 1].UnitId;
|
|
OpenUnitHistory(id, pauseIdle: false, followFocus: false, preferPlace: true);
|
|
_graveStackActive = true;
|
|
_graveTileX = tileX;
|
|
_graveTileY = tileY;
|
|
ApplyTabChrome();
|
|
return ChronicleHud.Visible && DetailUnitId == id;
|
|
}
|
|
|
|
_detailUnitId = 0;
|
|
_followFocus = false;
|
|
_detailPane = DetailPane.Life;
|
|
_tab = LoreTab.Fallen;
|
|
_lastCharFingerprint = int.MinValue;
|
|
SetVisible(true);
|
|
ApplyTabChrome();
|
|
Rebuild(force: true);
|
|
return Visible && GraveStackScopeActive && DetailUnitId == 0;
|
|
}
|
|
finally
|
|
{
|
|
_openingGraveStack = false;
|
|
}
|
|
}
|
|
|
|
public static void ClearGraveStackScope()
|
|
{
|
|
_graveStackActive = false;
|
|
_graveTileX = 0;
|
|
_graveTileY = 0;
|
|
GraveStackFilteredCount = 0;
|
|
}
|
|
|
|
private static void ToggleKeepGrave()
|
|
{
|
|
if (!_graveStackActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool next = !GraveMarkers.IsStackPermanent(_graveTileX, _graveTileY);
|
|
if (!GraveMarkers.TrySetStackPermanent(_graveTileX, _graveTileY, next))
|
|
{
|
|
return;
|
|
}
|
|
|
|
ArmDismissGrace();
|
|
RefreshKeepGraveVisual();
|
|
}
|
|
|
|
/// <summary>Remove the open grave stack from the world and close Lore.</summary>
|
|
public static bool DeleteActiveGraveStack()
|
|
{
|
|
if (!_graveStackActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int tx = _graveTileX;
|
|
int ty = _graveTileY;
|
|
if (!GraveMarkers.TryRemoveStack(tx, ty))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ArmDismissGrace();
|
|
LogService.LogInfo($"[IdleSpectator][LORE] deleted grave tile={tx},{ty}");
|
|
InspectUi.DismissAll();
|
|
return true;
|
|
}
|
|
|
|
private static void RefreshKeepGraveVisual()
|
|
{
|
|
if (_keepGraveLabel == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool kept = _graveStackActive && GraveMarkers.IsStackPermanent(_graveTileX, _graveTileY);
|
|
_keepGraveLabel.text = kept ? "Kept" : "Keep";
|
|
Image bg = _keepGraveBtn != null ? _keepGraveBtn.GetComponent<Image>() : null;
|
|
if (bg != null)
|
|
{
|
|
bg.color = kept
|
|
? new Color(0.45f, 0.36f, 0.12f, 0.95f)
|
|
: new Color(0.12f, 0.13f, 0.16f, 0.95f);
|
|
}
|
|
}
|
|
|
|
/// <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.
|
|
/// Fallen subjects always open on death place / skull; use Focus Killer for the killer.
|
|
/// </summary>
|
|
public static void OpenUnitHistory(
|
|
long unitId,
|
|
bool pauseIdle = true,
|
|
bool followFocus = false,
|
|
bool preferPlace = true)
|
|
{
|
|
if (unitId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnsureBuilt();
|
|
_detailUnitId = unitId;
|
|
_followFocus = followFocus;
|
|
_detailPane = DetailPane.Life;
|
|
|
|
// Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that
|
|
// would instantly drop the camera off the browse subject.
|
|
if (pauseIdle && SpectatorMode.Active)
|
|
{
|
|
_pausedIdleForLore = true;
|
|
SpectatorMode.SetActive(false, quiet: true);
|
|
}
|
|
else if (pauseIdle)
|
|
{
|
|
// Lore opened while idle was already off - do not auto-resume on close.
|
|
_pausedIdleForLore = false;
|
|
}
|
|
|
|
bool live = Chronicle.FocusCameraForBrowse(unitId);
|
|
if (!live)
|
|
{
|
|
// Default preferPlace=true: death place / skull. Killer only via Focus Killer.
|
|
Chronicle.FocusFallenSubject(unitId, preferPlace);
|
|
}
|
|
|
|
_tab = live ? LoreTab.Living : LoreTab.Fallen;
|
|
WatchCaption.PinWhilePaused();
|
|
|
|
if (pauseIdle)
|
|
{
|
|
WatchCaption.ShowStatusBanner(
|
|
live ? "Paused (viewing history)" : Chronicle.FallenPauseBanner());
|
|
}
|
|
|
|
SetVisible(true);
|
|
ApplyTabChrome();
|
|
Rebuild(force: true);
|
|
}
|
|
|
|
/// <summary>Harness / Lore chrome: follow the living killer of the open fallen subject.</summary>
|
|
public static bool FocusDetailKiller()
|
|
{
|
|
if (_detailUnitId == 0 || _tab != LoreTab.Fallen)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool ok = Chronicle.FocusFallenKiller(_detailUnitId);
|
|
if (ok)
|
|
{
|
|
WatchCaption.ShowStatusBanner(Chronicle.FallenPauseBanner());
|
|
}
|
|
|
|
ApplyTabChrome();
|
|
return ok;
|
|
}
|
|
|
|
public static void ClearUnitDetail()
|
|
{
|
|
_detailUnitId = 0;
|
|
_followFocus = false;
|
|
_detailPane = DetailPane.Life;
|
|
_lastCharFingerprint = int.MinValue;
|
|
ApplyTabChrome();
|
|
if (Visible && IsCastTab)
|
|
{
|
|
Rebuild(force: true);
|
|
}
|
|
}
|
|
|
|
public static void SetDetailPane(DetailPane pane)
|
|
{
|
|
if (_detailUnitId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_detailPane == pane)
|
|
{
|
|
StyleDetailPanes();
|
|
return;
|
|
}
|
|
|
|
_detailPane = pane;
|
|
StyleDetailPanes();
|
|
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);
|
|
}
|
|
}
|
|
|
|
bool grave = _graveStackActive && _tab == LoreTab.Fallen;
|
|
|
|
if (_searchField != null)
|
|
{
|
|
_searchField.gameObject.SetActive(chars && !detail);
|
|
if (chars && !detail)
|
|
{
|
|
RectTransform searchRt = _searchField.GetComponent<RectTransform>();
|
|
if (searchRt != null)
|
|
{
|
|
// Living/Fallen: leave room for Refresh+Fav. Grave: full width (Keep/Del in title).
|
|
searchRt.offsetMax = new Vector2(grave ? 0f : -40f, 0f);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_titleText != null)
|
|
{
|
|
RectTransform titleRt = _titleText.GetComponent<RectTransform>();
|
|
if (titleRt != null)
|
|
{
|
|
// Leave room for Keep+Del+L when grave-scoped.
|
|
titleRt.offsetMax = new Vector2(grave ? -96f : -22f, -2f);
|
|
}
|
|
}
|
|
|
|
if (_favFilterBtn != null)
|
|
{
|
|
_favFilterBtn.gameObject.SetActive(chars && !detail && !grave);
|
|
}
|
|
|
|
if (_refreshBtn != null)
|
|
{
|
|
_refreshBtn.gameObject.SetActive(chars && !detail && !grave);
|
|
RectTransform refreshRt = _refreshBtn.GetComponent<RectTransform>();
|
|
if (refreshRt != null)
|
|
{
|
|
refreshRt.anchoredPosition = new Vector2(-19f, 0f);
|
|
}
|
|
}
|
|
|
|
if (_backBtn != null)
|
|
{
|
|
_backBtn.SetActive(detail);
|
|
if (_backBtnLabel != null)
|
|
{
|
|
if (grave)
|
|
{
|
|
_backBtnLabel.text = "← Grave";
|
|
}
|
|
else
|
|
{
|
|
_backBtnLabel.text = _tab == LoreTab.Fallen ? "← Fallen" : "← Living";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_focusKillerBtn != null)
|
|
{
|
|
bool showKiller = detail
|
|
&& _tab == LoreTab.Fallen
|
|
&& Chronicle.FindLivingKillerFor(_detailUnitId) != null;
|
|
_focusKillerBtn.SetActive(showKiller);
|
|
if (_focusKillerLabel != null)
|
|
{
|
|
_focusKillerLabel.text = "Killer";
|
|
}
|
|
}
|
|
|
|
if (_keepGraveBtn != null)
|
|
{
|
|
_keepGraveBtn.SetActive(grave);
|
|
if (grave)
|
|
{
|
|
PlaceTitleTool(_keepGraveBtn, -58f, 34f);
|
|
RefreshKeepGraveVisual();
|
|
}
|
|
}
|
|
|
|
if (_deleteGraveBtn != null)
|
|
{
|
|
_deleteGraveBtn.SetActive(grave);
|
|
if (grave)
|
|
{
|
|
PlaceTitleTool(_deleteGraveBtn, -22f, 34f);
|
|
}
|
|
}
|
|
|
|
if (_detailPaneBar != null)
|
|
{
|
|
_detailPaneBar.SetActive(detail);
|
|
if (detail)
|
|
{
|
|
RectTransform paneRt = _detailPaneBar.GetComponent<RectTransform>();
|
|
if (paneRt != null)
|
|
{
|
|
// Full Activity|Life strip; Keep/Del sit in the title band.
|
|
paneRt.anchorMin = new Vector2(0.50f, 0f);
|
|
paneRt.anchorMax = new Vector2(1f, 1f);
|
|
}
|
|
}
|
|
|
|
StyleDetailPanes();
|
|
}
|
|
|
|
StyleTab(_worldTabBtn, _tab == LoreTab.World);
|
|
StyleTab(_livingTabBtn, _tab == LoreTab.Living);
|
|
StyleTab(_fallenTabBtn, _tab == LoreTab.Fallen);
|
|
RefreshStaleVisual();
|
|
ApplyScrollInsets();
|
|
RefreshTitle();
|
|
}
|
|
|
|
private static void StyleDetailPanes()
|
|
{
|
|
StyleTab(_detailActivityBtn, _detailPane == DetailPane.Activity);
|
|
StyleTab(_detailLifeBtn, _detailPane == DetailPane.Life);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (_graveStackActive && _tab == LoreTab.Fallen)
|
|
{
|
|
int n = GraveMarkers.EntryCountAt(_graveTileX, _graveTileY);
|
|
_titleText.text = n <= 1 ? "Grave" : $"Grave · {n}";
|
|
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;
|
|
hash = (hash * 31) + (_graveStackActive ? 1 : 0);
|
|
hash = (hash * 31) + _graveTileX;
|
|
hash = (hash * 31) + _graveTileY;
|
|
hash = (hash * 31) + GraveMarkers.EntryCountAt(_graveTileX, _graveTileY);
|
|
// 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);
|
|
hash = (hash * 31) + ActivityLog.CountFor(_detailUnitId);
|
|
hash = (hash * 31) + (int)_detailPane;
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (_detailPane == DetailPane.Activity)
|
|
{
|
|
RebuildActivityDetail(_detailUnitId);
|
|
}
|
|
else
|
|
{
|
|
RebuildUnitHistoryDetail(_detailUnitId);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_graveStackActive && _tab == LoreTab.Fallen)
|
|
{
|
|
RebuildGraveStackList();
|
|
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();
|
|
GraveStackFilteredCount = 0;
|
|
|
|
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 RebuildGraveStackList()
|
|
{
|
|
RefreshTitle();
|
|
LastListTopTitle = "";
|
|
GraveStackFilteredCount = 0;
|
|
if (!GraveMarkers.TryGetStack(_graveTileX, _graveTileY, out GraveMarkers.GraveStack stack))
|
|
{
|
|
ClearGraveStackScope();
|
|
AddEmpty("Grave gone");
|
|
return;
|
|
}
|
|
|
|
GraveMarkers.SnapshotEntries(stack, GraveEntriesScratch);
|
|
string q = (_searchText ?? "").Trim();
|
|
_lastCharFingerprint = CharacterFingerprint();
|
|
_builtAtRevision = Chronicle.HistoryRevision;
|
|
RefreshStaleVisual();
|
|
|
|
int shown = 0;
|
|
// Newest first (same order as the old grave panel).
|
|
for (int i = GraveEntriesScratch.Count - 1; i >= 0; i--)
|
|
{
|
|
GraveMarkers.GraveEntry e = GraveEntriesScratch[i];
|
|
if (e == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (q.Length > 0
|
|
&& (e.DisplayName == null
|
|
|| e.DisplayName.IndexOf(q, System.StringComparison.OrdinalIgnoreCase) < 0))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var row = new ChronicleCharacterSummary
|
|
{
|
|
UnitId = e.UnitId,
|
|
Name = e.DisplayName ?? "",
|
|
SpeciesId = "",
|
|
HistoryCount = Chronicle.HistoryCountFor(e.UnitId),
|
|
IsFavorite = e.Favorite,
|
|
IsAlive = false,
|
|
DeathManner = Chronicle.LatestDeathMannerFor(e.UnitId),
|
|
SortTime = e.CreatedAt
|
|
};
|
|
if (shown == 0)
|
|
{
|
|
LastListTopTitle = row.Title;
|
|
}
|
|
|
|
GameObject go = BuildCharacterRow(row, shown);
|
|
go.transform.SetParent(_content, false);
|
|
Rows.Add(go);
|
|
shown++;
|
|
if (shown >= MaxVisibleRows)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
GraveStackFilteredCount = shown;
|
|
if (shown == 0)
|
|
{
|
|
AddEmpty(q.Length > 0 ? "No names match" : "Empty grave");
|
|
}
|
|
}
|
|
|
|
private static void RebuildActivityDetail(long unitId)
|
|
{
|
|
IReadOnlyList<ActivityEntry> entries =
|
|
ActivityLog.LatestForSubject(unitId, ActivityLog.MaxLinesPerSubject);
|
|
_lastCharFingerprint = CharacterFingerprint();
|
|
_builtAtRevision = Chronicle.HistoryRevision;
|
|
RefreshStaleVisual();
|
|
LastDetailHistoryRows = 0;
|
|
if (entries == null || entries.Count == 0)
|
|
{
|
|
LastDetailActivityRows = 0;
|
|
AddEmpty("No activity yet");
|
|
return;
|
|
}
|
|
|
|
AddSection(entries.Count + " actions");
|
|
int shown = 0;
|
|
for (int i = 0; i < entries.Count; i++)
|
|
{
|
|
ActivityEntry entry = entries[i];
|
|
if (entry == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
GameObject row = BuildActivityDetailRow(entry, shown);
|
|
row.transform.SetParent(_content, false);
|
|
Rows.Add(row);
|
|
shown++;
|
|
}
|
|
|
|
LastDetailActivityRows = shown;
|
|
if (_scroll != null)
|
|
{
|
|
_scroll.verticalNormalizedPosition = 1f;
|
|
}
|
|
}
|
|
|
|
private static GameObject BuildActivityDetailRow(ActivityEntry entry, int index)
|
|
{
|
|
GameObject go = new GameObject(
|
|
$"ActDetail_{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,
|
|
HudIcons.ForActivityKey(entry.TaskId)
|
|
?? HudIcons.FromUiIcon("iconClock")
|
|
?? HudIcons.FromUiIcon("iconTask")
|
|
?? HudIcons.ForChronicleKind(ChronicleKind.Other));
|
|
|
|
string text = !string.IsNullOrEmpty(entry.DisplayLineRich)
|
|
? entry.DisplayLineRich
|
|
: (!string.IsNullOrEmpty(entry.DisplayLine) ? entry.DisplayLine : entry.Line);
|
|
Text label = HudCanvas.MakeText(go.transform, "Text", text ?? "", 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 = new Color(0.88f, 0.9f, 0.78f, 1f);
|
|
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 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 = Chronicle.FormatGraveDeathSubtitle(summary.UnitId);
|
|
if (string.IsNullOrEmpty(manner))
|
|
{
|
|
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);
|
|
}
|
|
}
|