1630 lines
54 KiB
C#
1630 lines
54 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 bool _favoritesOnly;
|
|
private static string _searchText = "";
|
|
private static int _lastCharFingerprint = 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; } = "";
|
|
|
|
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(-20f, 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);
|
|
|
|
_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();
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Favorites filter is Living-only.
|
|
_favFilterBtn.gameObject.SetActive(_tab == LoreTab.Living && !detail);
|
|
}
|
|
|
|
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);
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (TryRetargetFollowFocus())
|
|
{
|
|
return;
|
|
}
|
|
|
|
int fp = CharacterFingerprint();
|
|
if (fp != _lastCharFingerprint)
|
|
{
|
|
Rebuild(force: true);
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
if (_detailUnitId != 0)
|
|
{
|
|
hash = (hash * 31) + Chronicle.HistoryCountFor(_detailUnitId);
|
|
return hash;
|
|
}
|
|
|
|
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(
|
|
_searchText,
|
|
_tab == LoreTab.Living && _favoritesOnly,
|
|
max: 12,
|
|
scope: _tab == LoreTab.Fallen
|
|
? Chronicle.CharacterListScope.Fallen
|
|
: Chronicle.CharacterListScope.Living);
|
|
hash = (hash * 31) + (int)_tab;
|
|
hash = (hash * 31) + list.Count;
|
|
for (int i = 0; i < list.Count && i < 8; i++)
|
|
{
|
|
hash = (hash * 31) + list[i].UnitId.GetHashCode();
|
|
hash = (hash * 31) + list[i].HistoryCount;
|
|
}
|
|
|
|
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 = scope == Chronicle.CharacterListScope.Living && _favoritesOnly;
|
|
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(_searchText, favOnly, scope: scope);
|
|
_lastCharFingerprint = CharacterFingerprint();
|
|
|
|
if (list.Count == 0)
|
|
{
|
|
if (scope == Chronicle.CharacterListScope.Fallen)
|
|
{
|
|
AddEmpty("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 (scope == Chronicle.CharacterListScope.Living
|
|
&& (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();
|
|
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(() =>
|
|
{
|
|
bool ok = Chronicle.JumpTo(captured);
|
|
LogService.LogInfo($"[IdleSpectator][MEMORY] jump ok={ok} 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);
|
|
}
|
|
}
|