using System; using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; namespace IdleSpectator; /// /// Dossier rail of life-saga MCs as a horizontal strip at the top of caption chrome. /// Click toggles Prefer only. Hover previews Cast/Legacy depth under the caption. /// public static class LifeSagaRail { private const int MaxSlots = LifeSagaRoster.Cap; public const float SlotSize = 18f; private const float Gap = 2f; private const float BadgeSize = 7f; private const float FaceRefreshSeconds = 1.0f; private static readonly List SlotScratch = new List(MaxSlots); private static readonly List InvolvedScratch = new List(8); private static readonly Dictionary LastFaces = new Dictionary(MaxSlots); private static readonly Slot[] Slots = new Slot[MaxSlots]; private static GameObject _row; private static RectTransform _rowRt; private static string _fingerprint = ""; private static float _nextFaceRefreshAt; private static int _retryCursor; /// Harness: tip text of the first visible rail slot after last Refresh. public static string LastTipSample { get; private set; } = ""; /// Harness: true when the watched MC slot used Active (not Prefer) chrome. public static bool LastActiveHighlight { get; private set; } /// Harness: count of Involved (non-follow) MC glyphs lit on last Refresh. public static int LastInvolvedHighlightCount { get; private set; } /// Harness: Prefer pip visible on first Prefer'd slot. public static bool LastPreferPipVisible { get; private set; } /// Harness: structured presentation row count for open preview (compat). public static int LastHoverRowCount { get; private set; } /// Harness: fixed saga body height while previewing. public static float LastHoverHeight { get; private set; } private sealed class Slot { public GameObject Root; public RectTransform Rt; public Image Bg; public Image Face; public Text Label; public Image Badge; public Image PreferPip; public Button Button; public long UnitId; public string Tip = ""; public LifeSagaRailHover Hover; } public static bool Visible => _row != null && _row.activeSelf; /// Natural rail row width after last Refresh (glyph strip only). public static float RowWidthPx => _rowRt != null && _row != null && _row.activeSelf ? _rowRt.sizeDelta.x : 0f; /// Natural rail row height after last Refresh. public static float RowHeightPx => Visible ? SlotSize : 0f; /// True when the pointer is over any visible rail glyph (not empty rail chrome). public static bool IsPointerOverGlyph() { for (int i = 0; i < Slots.Length; i++) { if (Slots[i]?.Rt == null || !Slots[i].Root.activeInHierarchy) { continue; } if (RectTransformUtility.RectangleContainsScreenPoint(Slots[i].Rt, Input.mousePosition, null)) { return true; } } return false; } /// True when the pointer is over the rail row or any visible glyph. public static bool IsPointerOverRail() { if (_row == null || !_row.activeInHierarchy || _rowRt == null) { return false; } if (RectTransformUtility.RectangleContainsScreenPoint(_rowRt, Input.mousePosition, null)) { return true; } return IsPointerOverGlyph(); } public static int LastShownCount { get; private set; } /// Harness: unit-keyed last-face snapshots retained for the current roster. public static int CachedFaceCount => LastFaces.Count; /// Harness/telemetry: bounded inactive live-capture attempts. public static int LiveCaptureRetryCount { get; private set; } public static float LastLiveCaptureMs { get; private set; } public static float MaxLiveCaptureMs { get; private set; } public static void EnsureBuilt(Transform parent) { if (_row != null || parent == null) { return; } _row = new GameObject("LifeSagaRail", typeof(RectTransform)); _row.transform.SetParent(parent, false); _rowRt = _row.GetComponent(); _rowRt.pivot = new Vector2(0f, 1f); _rowRt.anchorMin = new Vector2(0f, 1f); _rowRt.anchorMax = new Vector2(0f, 1f); for (int i = 0; i < MaxSlots; i++) { Slots[i] = BuildSlot(_row.transform, i); } _row.SetActive(false); } public static void Clear() { _fingerprint = ""; LastShownCount = 0; LastTipSample = ""; LastActiveHighlight = false; LastInvolvedHighlightCount = 0; LastPreferPipVisible = false; LastHoverRowCount = 0; LastHoverHeight = 0f; _retryCursor = 0; LiveCaptureRetryCount = 0; LastLiveCaptureMs = 0f; MaxLiveCaptureMs = 0f; LifeSagaViewController.CancelPreviewAndPause(); if (_row != null) { _row.SetActive(false); } for (int i = 0; i < Slots.Length; i++) { if (Slots[i]?.Root != null) { Slots[i].Root.SetActive(false); Slots[i].UnitId = 0; Slots[i].Tip = ""; } } } public static void Refresh() { if (_row == null) { return; } if (!SpectatorMode.Active && !SpectatorMode.BrowsePaused) { Clear(); return; } if (!ModSettings.ShowDossierCaption || WatchCaption.HasStatusBannerActive()) { Clear(); return; } float now = Time.unscaledTime; // Director already Ticks the roster each frame - do not Refill twice. SlotScratch.Clear(); LifeSagaRoster.CopySlots(SlotScratch); long watchId = EventFeedUtil.SafeId( MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); InvolvedScratch.Clear(); InterestCandidate tip = InterestDirector.CurrentCandidate; LifeSagaRoster.CollectTouchedMcIds(tip, InvolvedScratch); string fp = Fingerprint(SlotScratch, watchId, InvolvedScratch); bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal) || LifeSagaRoster.Dirty; bool facesDue = now >= _nextFaceRefreshAt; if (!structureChanged && !facesDue) { return; } if (structureChanged) { PruneFaceCache(); LifeSagaRoster.Dirty = false; _fingerprint = fp; LastShownCount = 0; LastTipSample = ""; LastActiveHighlight = false; LastInvolvedHighlightCount = 0; LastPreferPipVisible = false; float x = 0f; for (int i = 0; i < Slots.Length; i++) { Slot slot = Slots[i]; if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0) { slot.Root.SetActive(false); slot.UnitId = 0; slot.Tip = ""; continue; } LifeSagaSlot saga = SlotScratch[i]; bool watching = watchId != 0 && watchId == saga.UnitId; bool involved = !watching && IsInvolved(saga.UnitId); bool prefer = saga.Prefer || saga.GameFavorite; slot.UnitId = saga.UnitId; ApplyFace(slot, saga, refreshLive: watching); ApplyBadge(slot, saga); if (slot.PreferPip != null) { slot.PreferPip.gameObject.SetActive(prefer); if (prefer) { LastPreferPipVisible = true; } } if (watching) { slot.Bg.color = HudTheme.ValueOrange; LastActiveHighlight = true; } else if (involved) { // Softer gold - second saga lit while another MC is followed. slot.Bg.color = new Color(0.92f, 0.72f, 0.28f, 0.92f); LastInvolvedHighlightCount++; } else if (prefer) { slot.Bg.color = new Color(0.45f, 0.38f, 0.22f, 0.95f); } else { slot.Bg.color = new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.45f); } slot.Tip = LifeSagaPresentation.BuildPlainText(saga); if (LastShownCount == 0) { LastTipSample = slot.Tip ?? ""; } slot.Rt.anchoredPosition = new Vector2(x, 0f); slot.Root.SetActive(true); x += SlotSize + Gap; LastShownCount++; } _rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize); _row.SetActive(LastShownCount > 0); RetryOneMissingLiveFace(watchId); } else { // Animate only the Active (watched) glyph; others keep static faces. for (int i = 0; i < Slots.Length && i < SlotScratch.Count; i++) { if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null) { continue; } if (watchId == 0 || SlotScratch[i].UnitId != watchId) { continue; } ApplyFace(Slots[i], SlotScratch[i], refreshLive: true); } // A unit may enter the roster while its render frame is unavailable (egg, // atlas not ready, transient tiny crop). Retry at most one inactive missing // capture per one-second rail cadence; round-robin prevents an egg from // starving later slots. Species fallbacks are display-only and never cached. RetryOneMissingLiveFace(watchId); } if (LifeSagaViewController.IsHoverPreview) { LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation(); LastHoverRowCount = model.Cast.Count + model.Legacy.Count + (string.IsNullOrEmpty(model.StakeLine) ? 0 : 1); LastHoverHeight = LifeSagaPanel.BodyHeight; } else { LastHoverRowCount = 0; LastHoverHeight = 0f; } _nextFaceRefreshAt = now + FaceRefreshSeconds; } /// /// Place the horizontal rail at the top of caption chrome (stable under hover). /// Returns the next y below the rail. /// public static float Place(float y, float padX) { if (!Visible || _rowRt == null) { return y; } _rowRt.anchoredPosition = new Vector2(padX, -y); return y + SlotSize + 2f; } internal static void ShowHover(int index) { if (index < 0) { index = 0; } if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0) { // Rail glyphs can be empty after a Clear while roster still has MCs // (fingerprint early-out). Force a structural rebuild once. _fingerprint = ""; Refresh(); } if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0) { for (int i = 0; i < Slots.Length; i++) { if (Slots[i] != null && Slots[i].UnitId != 0) { index = i; break; } } } if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0) { return; } LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId); LastHoverHeight = Mathf.Max(LifeSagaPanel.BodyHeight, BodyHeightMinFallback); WatchCaption.RequestRelayout(); } private const float BodyHeightMinFallback = 1f; internal static void HideHover() { LifeSagaViewController.EndHoverPreview(); WatchCaption.RequestRelayout(); } public static long UnitIdAt(int index) { if (index < 0 || index >= Slots.Length || Slots[index] == null) { return 0; } return Slots[index].UnitId; } private static Slot BuildSlot(Transform parent, int index) { GameObject go = new GameObject("SagaRail" + index, typeof(RectTransform), typeof(Image), typeof(Button)); go.transform.SetParent(parent, false); RectTransform rt = go.GetComponent(); rt.pivot = new Vector2(0f, 1f); rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(0f, 1f); rt.sizeDelta = new Vector2(SlotSize, SlotSize); Image bg = go.GetComponent(); bg.color = HudTheme.ToolFill; bg.raycastTarget = true; GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image)); faceGo.transform.SetParent(go.transform, false); RectTransform faceRt = faceGo.GetComponent(); faceRt.anchorMin = Vector2.zero; faceRt.anchorMax = Vector2.one; faceRt.offsetMin = new Vector2(1f, 1f); faceRt.offsetMax = new Vector2(-1f, -1f); Image face = faceGo.GetComponent(); face.color = Color.white; face.preserveAspect = true; face.raycastTarget = false; Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 8); label.alignment = TextAnchor.MiddleCenter; label.resizeTextForBestFit = false; label.raycastTarget = false; RectTransform labelRt = label.GetComponent(); labelRt.anchorMin = Vector2.zero; labelRt.anchorMax = Vector2.one; labelRt.offsetMin = Vector2.zero; labelRt.offsetMax = Vector2.zero; label.gameObject.SetActive(false); GameObject badgeGo = new GameObject("Badge", typeof(RectTransform), typeof(Image)); badgeGo.transform.SetParent(go.transform, false); RectTransform badgeRt = badgeGo.GetComponent(); badgeRt.pivot = new Vector2(1f, 1f); badgeRt.anchorMin = new Vector2(1f, 1f); badgeRt.anchorMax = new Vector2(1f, 1f); badgeRt.anchoredPosition = new Vector2(1f, 1f); badgeRt.sizeDelta = new Vector2(BadgeSize, BadgeSize); Image badge = badgeGo.GetComponent(); badge.color = Color.white; badge.preserveAspect = true; badge.raycastTarget = false; badgeGo.SetActive(false); GameObject pipGo = new GameObject("PreferPip", typeof(RectTransform), typeof(Image)); pipGo.transform.SetParent(go.transform, false); RectTransform pipRt = pipGo.GetComponent(); pipRt.pivot = new Vector2(0f, 0f); pipRt.anchorMin = new Vector2(0f, 0f); pipRt.anchorMax = new Vector2(0f, 0f); pipRt.anchoredPosition = new Vector2(1f, 1f); pipRt.sizeDelta = new Vector2(5f, 5f); Image pip = pipGo.GetComponent(); pip.color = new Color(1f, 0.85f, 0.25f, 1f); pip.raycastTarget = false; Sprite star = HudIcons.FromUiIcon("iconFavorite") ?? HudIcons.FromUiIcon("iconStar") ?? HudIcons.FromUiIcon("iconImportant"); if (star != null) { pip.sprite = star; } pipGo.SetActive(false); Button button = go.GetComponent