worldbox-observer-mod/IdleSpectator/Story/LifeSagaPanel.cs
DazedAnon 6fb62c47d6 feat(saga): turn panel into a story card and snap hover restore
- Drop Evidence stats and Open Lore; keep lens as a quiet eyebrow
- Wrap stake/Cast/Legacy under the portrait; stack Legacy as lines
- End glyph hover immediately without panel-dwell keep-alive
- Re-apply dossier reason/traits/history on exit so the tip does not lag
2026-07-22 03:57:31 -05:00

649 lines
20 KiB
C#

using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Saga story card: identity + stake beside the portrait, Cast and Legacy full-width under it.
/// No Evidence stats dump and no Open Lore button (L still opens Lore).
/// Width is fixed to the tabbed chrome so right-anchored hover cannot slide the rail.
/// Height sizes to content (capped) so lines wrap fully instead of ellipsis.
/// </summary>
public static class LifeSagaPanel
{
/// <summary>Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads).</summary>
public const float BodyWidthFixed = 232f;
/// <summary>Hard cap so Saga never eats the screen.</summary>
public const float BodyHeightMax = 148f;
public const float BodyWidthMin = BodyWidthFixed;
public const float BodyWidthMax = BodyWidthFixed;
public const float BodyHeightMin = 56f;
public const float BodyHeightFixed = BodyHeightMax;
private const float Pad = 3f;
private const float AvatarSize = 32f;
private const float ColGap = 6f;
private const float CastFace = 16f;
private const float CastSlotW = 54f;
private const float SecLabelH = 10f;
private const float LineH = 10f;
private const int EyebrowFont = 7;
private const int NameFont = 9;
private const int TitleFont = 7;
private const int StakeFont = 8;
private const int BodyFont = 7;
private const int MaxCast = 4;
private const int MaxLegacy = 5;
private const int BuildVersion = 8;
private static GameObject _root;
private static RectTransform _rootRt;
private static Text _eyebrowText;
private static Text _nameText;
private static Text _titleText;
private static Text _stakeText;
private static Text _castHead;
private static Text _legacyHead;
private static Text _legacyText;
private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast];
private static long _boundId;
private static string _speciesId = "";
private static string _fingerprint = "";
private static bool _ownsAvatar;
private static int _builtVersion;
private static float _laidOutHeight = BodyHeightMin;
private sealed class CastSlot
{
public GameObject Root;
public Image FaceBg;
public Image Face;
public Text Name;
public Text Relation;
public long UnitId;
}
public static bool Visible => _root != null && _root.activeSelf;
public static float BodyHeight => _laidOutHeight;
public static float BodyWidthPx => BodyWidthFixed;
public static long BoundUnitId => _boundId;
public static bool OwnsAvatar => _ownsAvatar && Visible;
public static string LastLayoutDebug { get; private set; } = "";
public static void EnsureBuilt(Transform parent)
{
if (parent == null)
{
return;
}
if (_root != null && _builtVersion == BuildVersion)
{
return;
}
if (_root != null)
{
Object.Destroy(_root);
_root = null;
_rootRt = null;
_eyebrowText = null;
_nameText = null;
_titleText = null;
_stakeText = null;
_castHead = null;
_legacyHead = null;
_legacyText = null;
}
_root = new GameObject("LifeSagaPanel", typeof(RectTransform));
_root.transform.SetParent(parent, false);
_rootRt = _root.GetComponent<RectTransform>();
_rootRt.pivot = new Vector2(0f, 1f);
_rootRt.anchorMin = new Vector2(0f, 1f);
_rootRt.anchorMax = new Vector2(0f, 1f);
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
_eyebrowText = MakeLabel(_root.transform, "SagaEyebrow", EyebrowFont, HudTheme.Muted, FontStyle.Normal);
_nameText = MakeLabel(_root.transform, "SagaName", NameFont, HudTheme.ValueOrange, FontStyle.Bold);
_titleText = MakeLabel(_root.transform, "SagaTitle", TitleFont, HudTheme.Muted, FontStyle.Normal);
_stakeText = MakeLabel(_root.transform, "StakeBody", StakeFont, HudTheme.ValueOrange, FontStyle.Bold);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_castHead.text = "Cast";
_legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_legacyHead.text = "Legacy";
_legacyText = MakeLabel(_root.transform, "LegacyBody", BodyFont, HudTheme.Body, FontStyle.Normal);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
for (int i = 0; i < MaxCast; i++)
{
CastSlots[i] = BuildCast(i);
}
_builtVersion = BuildVersion;
_root.SetActive(false);
}
public static void Clear()
{
_boundId = 0;
_speciesId = "";
_fingerprint = "";
_ownsAvatar = false;
_laidOutHeight = BodyHeightMin;
if (_root != null)
{
_root.SetActive(false);
}
}
public static void SetVisible(bool visible)
{
if (_root == null)
{
return;
}
_root.SetActive(visible);
if (!visible)
{
_ownsAvatar = false;
}
}
public static void Bind(LifeSagaViewModel model)
{
if (_root == null)
{
return;
}
if (model == null || model.UnitId == 0)
{
BindEmptyPickState();
return;
}
string fp = Fingerprint(model);
bool same = string.Equals(fp, _fingerprint, System.StringComparison.Ordinal) && _boundId == model.UnitId;
_fingerprint = fp;
_boundId = model.UnitId;
_speciesId = model.SpeciesId ?? "";
if (!same)
{
_eyebrowText.text = model.LensEyebrow ?? "";
_nameText.text = StripStar(model.Name);
_titleText.text = model.Title ?? "";
_stakeText.text = model.StakeLine ?? "";
for (int i = 0; i < CastSlots.Length; i++)
{
if (i < model.Cast.Count && model.Cast[i] != null)
{
BindCast(CastSlots[i], model.Cast[i]);
}
else
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
}
var legacyParts = new List<string>(MaxLegacy);
var seen = new HashSet<string>();
string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant();
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
{
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
{
continue;
}
string line = model.Legacy[i].Line.Trim();
string key = line.ToLowerInvariant();
if (!string.IsNullOrEmpty(stakeKey)
&& string.Equals(key, stakeKey, System.StringComparison.Ordinal))
{
continue;
}
if (!seen.Add(key))
{
continue;
}
legacyParts.Add(line);
}
_legacyText.text = legacyParts.Count > 0 ? string.Join("\n", legacyParts) : "";
}
RefreshAvatar(model);
Layout();
_root.SetActive(true);
}
public static float Place(float y, float padX)
{
if (!Visible || _rootRt == null)
{
return y;
}
_rootRt.anchoredPosition = new Vector2(padX, -y);
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
if (_ownsAvatar)
{
DossierAvatar.Place(padX + Pad, y + Pad, AvatarSize);
DossierAvatar.DrawBehind(_root.transform);
DossierAvatar.ForceCompactFrame();
}
return y + _laidOutHeight + 2f;
}
private static void RefreshAvatar(LifeSagaViewModel model)
{
if (model == null || model.UnitId == 0)
{
_ownsAvatar = false;
return;
}
DossierAvatar.EnsureHost(_root.transform.parent, AvatarSize);
DossierAvatar.SetHostVisible(true);
Actor live = EventFeedUtil.FindAliveById(model.UnitId);
if (live != null && live.isAlive())
{
DossierAvatar.Show(live);
DossierAvatar.ForceCompactFrame();
}
else
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
_ownsAvatar = true;
}
private static void Layout()
{
bool useAvatarCol = _ownsAvatar;
float colX = useAvatarCol ? Pad + AvatarSize + ColGap : Pad;
float textW = BodyWidthFixed - colX - Pad;
float fullX = Pad;
float fullW = BodyWidthFixed - Pad * 2f;
float y = Pad;
float avatarBottom = useAvatarCol ? Pad + AvatarSize : Pad;
if (!string.IsNullOrEmpty(_eyebrowText.text))
{
PlaceText(_eyebrowText, colX, y, textW, LineH);
y += LineH;
}
else
{
Hide(_eyebrowText);
}
PlaceText(_nameText, colX, y, textW, NameFont + 2f);
y += NameFont + 2f;
if (!string.IsNullOrEmpty(_titleText.text))
{
PlaceText(_titleText, colX, y, textW, LineH);
y += LineH;
}
else
{
Hide(_titleText);
}
// Stake + Cast + Legacy wrap under the portrait (identity stays in the header band).
y = Mathf.Max(y + 2f, avatarBottom + 2f);
if (!string.IsNullOrEmpty(_stakeText.text))
{
float stakeH = Mathf.Clamp(
EstimateWrapHeight(_stakeText.text, fullW, StakeFont),
LineH,
LineH * 3f);
PlaceText(_stakeText, fullX, y, fullW, stakeH);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
y += stakeH + 2f;
}
else
{
Hide(_stakeText);
}
int castN = 0;
for (int i = 0; i < CastSlots.Length; i++)
{
if (CastSlots[i].Root.activeSelf)
{
castN++;
}
}
float yMax = BodyHeightMax - Pad;
if (castN > 0)
{
PlaceText(_castHead, fullX, y, fullW, SecLabelH);
y += SecLabelH;
float castX = fullX;
float castRowH = CastFace + 18f;
for (int i = 0; i < CastSlots.Length; i++)
{
if (!CastSlots[i].Root.activeSelf)
{
continue;
}
RectTransform rt = CastSlots[i].Root.GetComponent<RectTransform>();
rt.anchoredPosition = new Vector2(castX, -y);
rt.sizeDelta = new Vector2(CastSlotW, castRowH);
CastSlots[i].Root.transform.SetAsLastSibling();
castX += CastSlotW + 2f;
}
y += castRowH + 2f;
}
else
{
Hide(_castHead);
}
bool hasLegacy = !string.IsNullOrEmpty(_legacyText.text);
if (hasLegacy && y + SecLabelH + LineH <= yMax)
{
PlaceText(_legacyHead, fullX, y, fullW, SecLabelH);
y += SecLabelH;
float legacyH = Mathf.Clamp(
EstimateWrapHeight(_legacyText.text, fullW, BodyFont),
LineH,
LineH * 5f);
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
PlaceText(_legacyText, fullX, y, fullW, legacyH);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
y += legacyH + 2f;
}
else
{
Hide(_legacyHead);
Hide(_legacyText);
hasLegacy = false;
}
y = Mathf.Max(y + Pad, useAvatarCol ? AvatarSize + Pad * 2f : BodyHeightMin);
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
if (_rootRt != null)
{
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
}
LastLayoutDebug =
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' eyebrow='{Short(_eyebrowText?.text)}' stake='{Short(_stakeText?.text)}' cast={castN} legacy={(hasLegacy ? 1 : 0)}";
}
private static float EstimateWrapHeight(string text, float width, float fontSize)
{
if (string.IsNullOrEmpty(text))
{
return LineH;
}
string[] paragraphs = text.Split('\n');
float total = 0f;
float charsPerLine = Mathf.Max(8f, width / Mathf.Max(3.8f, fontSize * 0.55f));
for (int i = 0; i < paragraphs.Length; i++)
{
string p = paragraphs[i];
int lines = Mathf.Max(1, Mathf.CeilToInt(Mathf.Max(1, p.Length) / charsPerLine));
total += lines * (fontSize + 2f);
}
return total;
}
private static CastSlot BuildCast(int index)
{
GameObject root = new GameObject("Cast" + index, typeof(RectTransform));
root.transform.SetParent(_root.transform, false);
RectTransform rootRt = root.GetComponent<RectTransform>();
rootRt.pivot = new Vector2(0f, 1f);
rootRt.anchorMin = new Vector2(0f, 1f);
rootRt.anchorMax = new Vector2(0f, 1f);
GameObject bgGo = new GameObject("FaceBg", typeof(RectTransform), typeof(Image));
bgGo.transform.SetParent(root.transform, false);
Image bg = bgGo.GetComponent<Image>();
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.95f);
bg.raycastTarget = false;
RectTransform bgRt = bgGo.GetComponent<RectTransform>();
bgRt.pivot = new Vector2(0f, 1f);
bgRt.anchorMin = new Vector2(0f, 1f);
bgRt.anchorMax = new Vector2(0f, 1f);
bgRt.anchoredPosition = Vector2.zero;
bgRt.sizeDelta = new Vector2(CastFace, CastFace);
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
faceGo.transform.SetParent(bgGo.transform, false);
Image face = faceGo.GetComponent<Image>();
face.preserveAspect = true;
face.raycastTarget = false;
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
faceRt.pivot = new Vector2(0.5f, 0.5f);
faceRt.anchorMin = new Vector2(0.5f, 0.5f);
faceRt.anchorMax = new Vector2(0.5f, 0.5f);
faceRt.anchoredPosition = Vector2.zero;
faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f);
Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Normal);
RectTransform nameRt = name.rectTransform;
nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
Text relation = MakeLabel(root.transform, "Relation", BodyFont, HudTheme.Muted, FontStyle.Normal);
RectTransform relRt = relation.rectTransform;
relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f);
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
root.SetActive(false);
return new CastSlot
{
Root = root,
FaceBg = bg,
Face = face,
Name = name,
Relation = relation
};
}
private static void BindCast(CastSlot slot, LifeSagaCastMember member)
{
slot.UnitId = member.UnitId;
slot.Name.text = FirstNonEmpty(member.Name, "Someone");
slot.Relation.text = FirstNonEmpty(member.Relation, "Linked");
Actor live = member.Alive ? EventFeedUtil.FindAliveById(member.UnitId) : null;
Sprite sprite = HudIcons.FromActorRailFace(live, member.SpeciesId)
?? HudIcons.FromSpeciesId(member.SpeciesId)
?? HudIcons.FromUiIcon("iconCitizen");
slot.Face.sprite = sprite;
slot.Face.enabled = sprite != null;
slot.Face.color = sprite != null ? Color.white : new Color(0.35f, 0.36f, 0.4f, 1f);
slot.Root.SetActive(true);
}
private static void BindEmptyPickState()
{
_boundId = 0;
_speciesId = "";
_fingerprint = "empty-pick";
_ownsAvatar = false;
if (_eyebrowText != null)
{
_eyebrowText.text = "";
}
if (_nameText != null)
{
_nameText.text = "Pick a life";
}
if (_titleText != null)
{
_titleText.text = "from the rail";
}
if (_stakeText != null)
{
_stakeText.text = "Hover or click a glyph to read their saga.";
}
if (_legacyText != null)
{
_legacyText.text = "";
}
for (int i = 0; i < CastSlots.Length; i++)
{
if (CastSlots[i]?.Root != null)
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
}
DossierAvatar.SetHostVisible(false);
Layout();
if (_root != null)
{
_root.SetActive(true);
}
}
private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style)
{
int fitSize = size < 7 ? 7 : size;
Text text = HudCanvas.MakeText(parent, name, "", fitSize);
text.color = color;
text.fontStyle = style;
text.fontSize = fitSize;
text.resizeTextForBestFit = false;
text.alignment = TextAnchor.UpperLeft;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Overflow;
text.raycastTarget = false;
RectTransform rt = text.rectTransform;
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
return text;
}
private static void PlaceText(Text text, float x, float y, float w, float h)
{
if (text == null)
{
return;
}
text.resizeTextForBestFit = false;
text.enabled = true;
Color c = text.color;
if (c.a < 0.99f)
{
c.a = 1f;
text.color = c;
}
RectTransform rt = text.rectTransform;
rt.anchoredPosition = new Vector2(x, -y);
rt.sizeDelta = new Vector2(w, h);
text.gameObject.SetActive(!string.IsNullOrEmpty(text.text));
}
private static void Hide(Text text)
{
if (text != null)
{
text.gameObject.SetActive(false);
}
}
private static string Fingerprint(LifeSagaViewModel model)
{
var sb = new StringBuilder(192);
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.Title);
sb.Append('|').Append(model.LensEyebrow).Append('|').Append(model.StakeLine);
for (int i = 0; i < model.Cast.Count; i++)
{
var c = model.Cast[i];
if (c == null)
{
continue;
}
sb.Append('|').Append(c.UnitId).Append(':').Append(c.Relation).Append(':').Append(c.Name);
}
for (int i = 0; i < model.Legacy.Count; i++)
{
if (model.Legacy[i] != null)
{
sb.Append('#').Append(model.Legacy[i].Line);
}
}
return sb.ToString();
}
private static string StripStar(string name)
{
if (string.IsNullOrEmpty(name))
{
return "";
}
return name.StartsWith("★ ") ? name.Substring(2) : name;
}
private static string FirstNonEmpty(params string[] parts)
{
if (parts == null)
{
return "";
}
for (int i = 0; i < parts.Length; i++)
{
if (!string.IsNullOrEmpty(parts[i]))
{
return parts[i];
}
}
return "";
}
private static string Short(string s)
{
if (string.IsNullOrEmpty(s))
{
return "";
}
return s.Length <= 28 ? s : s.Substring(0, 27) + "…";
}
}