- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause - Persist observed saga facts across roster churn; pin subject without stale fallback - Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips - Densify dossier header/chips and keep the cast rail as stable top chrome - Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
605 lines
19 KiB
C#
605 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
using UnityEngine.UI;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Dossier rail of life-saga MCs as a horizontal strip under the tabs.
|
|
/// Click toggles Prefer (and pins Saga subject while on Saga). Hover previews.
|
|
/// </summary>
|
|
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 = 0.28f;
|
|
|
|
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(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;
|
|
|
|
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
|
|
public static string LastTipSample { get; private set; } = "";
|
|
|
|
/// <summary>Harness: true when the watched MC slot used Active (not Prefer) chrome.</summary>
|
|
public static bool LastActiveHighlight { get; private set; }
|
|
|
|
/// <summary>Harness: Prefer pip visible on first Prefer'd slot.</summary>
|
|
public static bool LastPreferPipVisible { get; private set; }
|
|
|
|
/// <summary>Harness: structured presentation row count for open preview (compat).</summary>
|
|
public static int LastHoverRowCount { get; private set; }
|
|
|
|
/// <summary>Harness: fixed saga body height while previewing.</summary>
|
|
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;
|
|
|
|
/// <summary>Natural rail row width after last Refresh (glyph strip only).</summary>
|
|
public static float RowWidthPx =>
|
|
_rowRt != null && _row != null && _row.activeSelf ? _rowRt.sizeDelta.x : 0f;
|
|
|
|
/// <summary>Natural rail row height after last Refresh.</summary>
|
|
public static float RowHeightPx =>
|
|
Visible ? SlotSize : 0f;
|
|
|
|
/// <summary>True when the pointer is over any visible rail glyph.</summary>
|
|
public static bool IsPointerOverRail()
|
|
{
|
|
if (_row == null || !_row.activeInHierarchy || _rowRt == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (RectTransformUtility.RectangleContainsScreenPoint(_rowRt, Input.mousePosition, null))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
public static int LastShownCount { 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<RectTransform>();
|
|
_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;
|
|
LastPreferPipVisible = false;
|
|
LastHoverRowCount = 0;
|
|
LastHoverHeight = 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;
|
|
LifeSagaRoster.Tick(now);
|
|
SlotScratch.Clear();
|
|
LifeSagaRoster.CopySlots(SlotScratch);
|
|
string fp = Fingerprint(SlotScratch);
|
|
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|
|
|| LifeSagaRoster.Dirty;
|
|
bool facesDue = now >= _nextFaceRefreshAt;
|
|
if (!structureChanged && !facesDue)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (structureChanged)
|
|
{
|
|
LifeSagaRoster.Dirty = false;
|
|
_fingerprint = fp;
|
|
LastShownCount = 0;
|
|
LastTipSample = "";
|
|
LastActiveHighlight = false;
|
|
LastPreferPipVisible = false;
|
|
long watchId = EventFeedUtil.SafeId(
|
|
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
|
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 prefer = saga.Prefer || saga.GameFavorite;
|
|
slot.UnitId = saga.UnitId;
|
|
ApplyFace(slot, saga);
|
|
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 (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);
|
|
}
|
|
else
|
|
{
|
|
for (int i = 0; i < Slots.Length && i < SlotScratch.Count; i++)
|
|
{
|
|
if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ApplyFace(Slots[i], SlotScratch[i]);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Place the horizontal rail under the tabs (stable top chrome).
|
|
/// Returns the next y below the rail.
|
|
/// </summary>
|
|
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 >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
|
|
LastHoverHeight = LifeSagaPanel.BodyHeight;
|
|
WatchCaption.RequestRelayout();
|
|
}
|
|
|
|
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<RectTransform>();
|
|
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<Image>();
|
|
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<RectTransform>();
|
|
faceRt.anchorMin = Vector2.zero;
|
|
faceRt.anchorMax = Vector2.one;
|
|
faceRt.offsetMin = new Vector2(1f, 1f);
|
|
faceRt.offsetMax = new Vector2(-1f, -1f);
|
|
Image face = faceGo.GetComponent<Image>();
|
|
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<RectTransform>();
|
|
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<RectTransform>();
|
|
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<Image>();
|
|
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<RectTransform>();
|
|
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<Image>();
|
|
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<Button>();
|
|
button.targetGraphic = bg;
|
|
ColorBlock colors = button.colors;
|
|
colors.normalColor = Color.white;
|
|
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
|
|
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
|
|
button.colors = colors;
|
|
int captured = index;
|
|
button.onClick.AddListener(new UnityAction(() => OnClick(captured)));
|
|
|
|
LifeSagaRailHover hover = go.AddComponent<LifeSagaRailHover>();
|
|
hover.Index = index;
|
|
|
|
go.SetActive(false);
|
|
return new Slot
|
|
{
|
|
Root = go,
|
|
Rt = rt,
|
|
Bg = bg,
|
|
Face = face,
|
|
Label = label,
|
|
Badge = badge,
|
|
PreferPip = pip,
|
|
Button = button,
|
|
Hover = hover
|
|
};
|
|
}
|
|
|
|
private static void ApplyFace(Slot slot, LifeSagaSlot saga)
|
|
{
|
|
if (slot == null || saga == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor actor = saga.Resolve();
|
|
Sprite sprite = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
|
|
if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null)
|
|
{
|
|
slot.Face.sprite = sprite;
|
|
slot.Face.color = Color.white;
|
|
slot.Face.preserveAspect = true;
|
|
slot.Face.gameObject.SetActive(true);
|
|
RectTransform faceRt = slot.Face.rectTransform;
|
|
faceRt.anchorMin = new Vector2(0.5f, 0.5f);
|
|
faceRt.anchorMax = new Vector2(0.5f, 0.5f);
|
|
faceRt.pivot = new Vector2(0.5f, 0.5f);
|
|
faceRt.anchoredPosition = Vector2.zero;
|
|
float w = Mathf.Max(1f, sprite.rect.width);
|
|
float h = Mathf.Max(1f, sprite.rect.height);
|
|
float max = SlotSize - 2f;
|
|
float scale = max / Mathf.Max(w, h);
|
|
faceRt.sizeDelta = new Vector2(w * scale, h * scale);
|
|
if (slot.Label != null)
|
|
{
|
|
slot.Label.gameObject.SetActive(false);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (slot.Face != null)
|
|
{
|
|
slot.Face.sprite = null;
|
|
slot.Face.gameObject.SetActive(false);
|
|
}
|
|
|
|
if (slot.Label != null)
|
|
{
|
|
slot.Label.text = GlyphFallback(saga);
|
|
slot.Label.color = HudTheme.Body;
|
|
slot.Label.gameObject.SetActive(true);
|
|
}
|
|
}
|
|
|
|
private static void ApplyBadge(Slot slot, LifeSagaSlot saga)
|
|
{
|
|
if (slot?.Badge == null || saga == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string icon = null;
|
|
if (saga.IsKing)
|
|
{
|
|
icon = "iconKings";
|
|
}
|
|
else if (saga.IsClanChief)
|
|
{
|
|
icon = "iconClan";
|
|
}
|
|
else if (saga.IsLeader)
|
|
{
|
|
icon = "iconLeaders";
|
|
}
|
|
else if (saga.IsAlpha)
|
|
{
|
|
icon = "iconFamilies";
|
|
}
|
|
else if (saga.IsArmyCaptain)
|
|
{
|
|
icon = "iconArmy";
|
|
}
|
|
else if (saga.IsFounder)
|
|
{
|
|
icon = "iconCity";
|
|
}
|
|
else if (saga.IsPlotAuthor)
|
|
{
|
|
icon = "iconPlot";
|
|
}
|
|
|
|
Sprite sprite = !string.IsNullOrEmpty(icon) ? HudIcons.FromUiIcon(icon) : null;
|
|
if (sprite == null && saga.IsKing)
|
|
{
|
|
sprite = HudIcons.FromUiIcon("iconKing");
|
|
}
|
|
|
|
if (sprite == null)
|
|
{
|
|
slot.Badge.gameObject.SetActive(false);
|
|
return;
|
|
}
|
|
|
|
slot.Badge.sprite = sprite;
|
|
slot.Badge.gameObject.SetActive(true);
|
|
}
|
|
|
|
internal static void HarnessClick(int index)
|
|
{
|
|
OnClick(index);
|
|
}
|
|
|
|
private static void OnClick(int index)
|
|
{
|
|
if (index < 0 || index >= Slots.Length)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id = Slots[index].UnitId;
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!SpectatorMode.Active && SpectatorMode.BrowsePaused)
|
|
{
|
|
SpectatorMode.SetActive(true, quiet: true);
|
|
}
|
|
|
|
LifeSagaRoster.TogglePrefer(id);
|
|
if (LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
|
|
|| (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
|
|
&& !LifeSagaViewController.IsHoverPreview))
|
|
{
|
|
LifeSagaViewController.SetPersistentSaga(id);
|
|
}
|
|
|
|
_fingerprint = "";
|
|
LifeSagaRoster.Dirty = true;
|
|
Refresh();
|
|
WatchCaption.RequestRelayout();
|
|
}
|
|
|
|
private static string GlyphFallback(LifeSagaSlot saga)
|
|
{
|
|
if (saga == null)
|
|
{
|
|
return "?";
|
|
}
|
|
|
|
if (saga.IsKing) return "K";
|
|
if (saga.IsClanChief) return "C";
|
|
if (saga.IsAlpha) return "A";
|
|
if (saga.IsArmyCaptain) return "P";
|
|
if (saga.IsLeader) return "L";
|
|
if (saga.Prefer || saga.GameFavorite) return "★";
|
|
string sp = saga.SpeciesId ?? "";
|
|
if (sp.Length > 0)
|
|
{
|
|
return char.ToUpperInvariant(sp[0]).ToString();
|
|
}
|
|
|
|
return "?";
|
|
}
|
|
|
|
private static string Fingerprint(List<LifeSagaSlot> board)
|
|
{
|
|
var sb = new StringBuilder();
|
|
long watchId = EventFeedUtil.SafeId(
|
|
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
|
sb.Append(watchId).Append('|');
|
|
for (int i = 0; i < board.Count; i++)
|
|
{
|
|
LifeSagaSlot s = board[i];
|
|
if (s == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
sb.Append(s.UnitId).Append(':')
|
|
.Append(s.Prefer ? 'P' : '-')
|
|
.Append(s.GameFavorite ? 'F' : '-')
|
|
.Append(s.IsKing ? 'K' : '-')
|
|
.Append(s.IsClanChief ? 'C' : '-')
|
|
.Append(s.IsArmyCaptain ? 'A' : '-')
|
|
.Append(s.Chapters.Count).Append(':')
|
|
.Append(Mathf.RoundToInt(LifeSagaRoster.EffectiveInterest(s))).Append(':')
|
|
.Append(s.DisplayName ?? "").Append(';');
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
}
|