- Introduce new saga rail commands for species frame auditing and sweeping - Implement retry logic for capturing inactive live frames in the rail - Refactor face resolution logic to prioritize live frames over species icons - Update harness scenarios to validate new rail functionality and performance - Enhance documentation to clarify live capture behavior and fallback handling
883 lines
28 KiB
C#
883 lines
28 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 at the top of caption chrome.
|
|
/// Click toggles Prefer only. Hover previews Cast/Legacy depth under the caption.
|
|
/// </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 Dictionary<long, Sprite> LastFaces = new Dictionary<long, Sprite>(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;
|
|
|
|
/// <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; }
|
|
/// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary>
|
|
public static int CachedFaceCount => LastFaces.Count;
|
|
/// <summary>Harness/telemetry: bounded inactive live-capture attempts.</summary>
|
|
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<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;
|
|
_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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Place the horizontal rail at the top of caption chrome (stable under hover).
|
|
/// 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 = 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<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 refreshLive)
|
|
{
|
|
if (slot == null || saga == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Sprite sprite = ResolveFace(saga, refreshLive);
|
|
|
|
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 Sprite ResolveFace(LifeSagaSlot saga, bool refreshLive)
|
|
{
|
|
if (saga == null || saga.UnitId == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
LastFaces.TryGetValue(saga.UnitId, out Sprite sprite);
|
|
// Capture every character once, then refresh only the watched glyph. A failed
|
|
// refresh never discards the last good phenotype/kingdom-tinted frame.
|
|
if (refreshLive)
|
|
{
|
|
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
Sprite live = HudIcons.FromActorLiveRailFace(saga.Resolve());
|
|
long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - started;
|
|
LastLiveCaptureMs = (float)(
|
|
elapsed * 1000.0 / System.Diagnostics.Stopwatch.Frequency);
|
|
MaxLiveCaptureMs = Mathf.Max(MaxLiveCaptureMs, LastLiveCaptureMs);
|
|
if (HudIcons.IsUsableRailFace(live))
|
|
{
|
|
sprite = live;
|
|
LastFaces[saga.UnitId] = live;
|
|
}
|
|
}
|
|
|
|
// Display fallback only. Do not let it masquerade as a retained live frame.
|
|
return HudIcons.IsUsableRailFace(sprite)
|
|
? sprite
|
|
: HudIcons.FromSpeciesId(saga.SpeciesId);
|
|
}
|
|
|
|
private static void RetryOneMissingLiveFace(long watchId)
|
|
{
|
|
int count = Mathf.Min(Slots.Length, SlotScratch.Count);
|
|
if (count <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_retryCursor = Mathf.Clamp(_retryCursor, 0, count - 1);
|
|
for (int offset = 0; offset < count; offset++)
|
|
{
|
|
int index = (_retryCursor + offset) % count;
|
|
LifeSagaSlot saga = SlotScratch[index];
|
|
Slot slot = Slots[index];
|
|
if (saga == null
|
|
|| slot == null
|
|
|| !slot.Root.activeSelf
|
|
|| saga.UnitId == 0
|
|
|| saga.UnitId == watchId
|
|
|| LastFaces.ContainsKey(saga.UnitId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Actor actor = saga.Resolve();
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ApplyFace(slot, saga, refreshLive: true);
|
|
LiveCaptureRetryCount++;
|
|
_retryCursor = (index + 1) % count;
|
|
return;
|
|
}
|
|
}
|
|
|
|
public static bool HarnessProbeFallbackCacheIsolation(out string detail)
|
|
{
|
|
const long syntheticId = -9100001;
|
|
int before = LastFaces.Count;
|
|
LastFaces.Remove(syntheticId);
|
|
var synthetic = new LifeSagaSlot
|
|
{
|
|
UnitId = syntheticId,
|
|
SpeciesId = "human"
|
|
};
|
|
Sprite fallback = ResolveFace(synthetic, refreshLive: true);
|
|
bool fallbackShown = HudIcons.IsUsableRailFace(fallback);
|
|
bool cachedFallback = LastFaces.ContainsKey(syntheticId);
|
|
|
|
// A retained frame must survive a later failed/dead refresh.
|
|
Sprite retainedSeed = HudIcons.FromSpeciesId("human");
|
|
bool retained = false;
|
|
if (HudIcons.IsUsableRailFace(retainedSeed))
|
|
{
|
|
LastFaces[syntheticId] = retainedSeed;
|
|
retained = ResolveFace(synthetic, refreshLive: true) == retainedSeed;
|
|
}
|
|
|
|
LastFaces.Remove(syntheticId);
|
|
bool countRestored = LastFaces.Count == before;
|
|
bool pass = fallbackShown && !cachedFallback && retained && countRestored;
|
|
detail =
|
|
$"fallbackShown={fallbackShown} cachedFallback={cachedFallback} "
|
|
+ $"retained={retained} count={before}/{LastFaces.Count} pass={pass}";
|
|
return pass;
|
|
}
|
|
|
|
public static bool HarnessProbeFocusUsesDistinctLiveFrame(out string detail)
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
long id = EventFeedUtil.SafeId(focus);
|
|
LastFaces.TryGetValue(id, out Sprite cached);
|
|
Sprite species = focus != null ? HudIcons.FromActor(focus) : null;
|
|
bool cachedUsable = HudIcons.IsUsableRailFace(cached);
|
|
bool distinct = cachedUsable
|
|
&& species != null
|
|
&& !HudIcons.SameSpriteSource(cached, species);
|
|
bool generated = HudIcons.MatchesGeneratedActorFrame(focus, cached);
|
|
detail =
|
|
$"id={id} asset={focus?.asset?.id} cached={DescribeSprite(cached)} "
|
|
+ $"species={DescribeSprite(species)} distinct={distinct} generated={generated} "
|
|
+ $"captureMs={LastLiveCaptureMs:0.00}/{MaxLiveCaptureMs:0.00} "
|
|
+ HudIcons.DescribeActorRailSources(focus);
|
|
return distinct && generated;
|
|
}
|
|
|
|
private static string DescribeSprite(Sprite sprite)
|
|
{
|
|
if (sprite == null)
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
try
|
|
{
|
|
return (sprite.name ?? "?")
|
|
+ ":" + sprite.rect.width.ToString("0")
|
|
+ "x" + sprite.rect.height.ToString("0");
|
|
}
|
|
catch
|
|
{
|
|
return "invalid";
|
|
}
|
|
}
|
|
|
|
private static void PruneFaceCache()
|
|
{
|
|
var keep = new HashSet<long>();
|
|
for (int i = 0; i < SlotScratch.Count; i++)
|
|
{
|
|
if (SlotScratch[i] != null && SlotScratch[i].UnitId != 0)
|
|
{
|
|
keep.Add(SlotScratch[i].UnitId);
|
|
}
|
|
}
|
|
|
|
var remove = new List<long>();
|
|
foreach (long id in LastFaces.Keys)
|
|
{
|
|
if (!keep.Contains(id))
|
|
{
|
|
remove.Add(id);
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < remove.Count; i++)
|
|
{
|
|
LastFaces.Remove(remove[i]);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Harness: click the visible slot for an exact unit instead of assuming slot 0.</summary>
|
|
internal static bool HarnessClickUnit(long unitId)
|
|
{
|
|
if (unitId == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < Slots.Length; i++)
|
|
{
|
|
if (Slots[i] != null && Slots[i].UnitId == unitId)
|
|
{
|
|
OnClick(i);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
|
|
_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();
|
|
}
|
|
}
|