using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; using UnityEngine.Events; using UnityEngine.EventSystems; using UnityEngine.UI; namespace IdleSpectator; /// /// 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. /// 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 Rows = new List(); private static readonly List GraveEntriesScratch = new List(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; /// True when Lore itself paused idle; close should auto-resume. 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; /// Lore is browsing one tile's grave stack (from a skull click). private static bool _graveStackActive; private static int _graveTileX; private static int _graveTileY; private static bool _openingGraveStack; private static float _ignoreDismissUntil; /// Rows actually built in the last character detail rebuild (harness). public static int LastDetailHistoryRows { get; private set; } /// Activity rows in the last Activity-pane rebuild (harness). public static int LastDetailActivityRows { get; private set; } /// Active detail sub-pane when a unit history is open. public static DetailPane ActiveDetailPane => _detailPane; /// Title of the first row in the last Living/Fallen list rebuild (harness). public static string LastListTopTitle { get; private set; } = ""; /// True when Living/Fallen list is older than new chronicle events (needs Refresh). 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; /// True when Lore is scoped to a gravestone stack (skull open). public static bool GraveStackScopeActive => _graveStackActive; public static int GraveStackTileX => _graveTileX; public static int GraveStackTileY => _graveTileY; /// Filtered stack list rows from the last grave-stack rebuild (harness). public static int GraveStackFilteredCount { get; private set; } /// /// Grave stack search should span the tools row (Keep/Del live in the title band). /// public static bool GraveSearchFillsTools { get { if (!_graveStackActive || _searchField == null || _charTools == null) { return false; } RectTransform searchRt = _searchField.GetComponent(); RectTransform toolsRt = _charTools.GetComponent(); 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; } } /// Ignore outside-click dismiss briefly after Lore open / Keep. public static float IgnoreDismissUntil => _ignoreDismissUntil; /// Arm a short grace so the open click does not instantly dismiss Lore. 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; /// True when Lore detail was opened via L/books and should track idle focus. 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(); ApplyRootPlacement(); Image bg = _root.GetComponent(); bg.raycastTarget = true; HudCanvas.StylePanel(bg, _root.transform); CanvasGroup group = _root.GetComponent(); group.blocksRaycasts = true; group.interactable = true; _hotkeyText = HudCanvas.MakeText(_root.transform, "Hotkey", "L", 8); RectTransform hotRt = _hotkeyText.GetComponent(); 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 = HudTheme.Muted; _hotkeyText.raycastTarget = false; _titleIcon = HudCanvas.MakeIcon(_root.transform, "TitleIcon", TitleIconSize); RectTransform titleIconRt = _titleIcon.GetComponent(); 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(); 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 = HudTheme.Name; _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(); PlaceTitleTool(_keepGraveBtn, -58f, 34f); _keepGraveBtn.SetActive(false); _deleteGraveBtn = BuildTabButton( _root.transform, "DeleteGrave", "Del", () => { DeleteActiveGraveStack(); }); _deleteGraveLabel = _deleteGraveBtn.transform.Find("Label")?.GetComponent(); if (_deleteGraveLabel != null) { _deleteGraveLabel.color = HudTheme.Danger; } 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(); 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(); bg.color = HudTheme.TabIdleFill; bg.raycastTarget = true; Button button = go.GetComponent