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