worldbox-observer-mod/IdleSpectator/Story/LifeSagaRail.cs
DazedAnon ba58a9ff38 feat(saga): deepen session cast stakes and cut idle hitch spikes
- Keep LifeSagaSession across Clear; wipe on map load/clear with bind key
- Soft-bias Prefer/cross-MC/cast and prefer roster MCs in aftermath/recover/park
- Resolve war/plot principals plus discrete/WorldLog labels from live libraries
- Amortize roster world scans, gate Relayout, and add hitch probe/save-slot helpers
2026-07-22 14:37:36 -05:00

667 lines
21 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 = 1.0f;
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
private static readonly List<long> InvolvedScratch = new List<long>(8);
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: count of Involved (non-follow) MC glyphs lit on last Refresh.</summary>
public static int LastInvolvedHighlightCount { 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 (not empty rail chrome).</summary>
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;
}
/// <summary>True when the pointer is over the rail row or any visible glyph.</summary>
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; }
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;
LastInvolvedHighlightCount = 0;
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;
// 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)
{
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, preferLive: 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);
}
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], preferLive: true);
}
}
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, bool preferLive)
{
if (slot == null || saga == null)
{
return;
}
Actor actor = saga.Resolve();
// Live calculateMainSprite is expensive - only the watched glyph animates with it.
// Other rail faces use the species icon (stable between structure changes).
Sprite sprite = preferLive
? HudIcons.FromActorRailFace(actor, saga.SpeciesId)
: HudIcons.FromSpeciesId(saga.SpeciesId);
if (!HudIcons.IsUsableRailFace(sprite) && preferLive)
{
sprite = HudIcons.FromSpeciesId(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 bool IsInvolved(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < InvolvedScratch.Count; i++)
{
if (InvolvedScratch[i] == unitId)
{
return true;
}
}
return false;
}
private static string Fingerprint(List<LifeSagaSlot> board, long watchId, List<long> involved)
{
var sb = new StringBuilder();
sb.Append(watchId).Append('|');
if (involved != null)
{
for (int i = 0; i < involved.Count; i++)
{
sb.Append('i').Append(involved[i]).Append(',');
}
}
sb.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();
}
}