using System; using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.UI; namespace IdleSpectator; /// /// Saga depth card under the character beat caption: Cast + Legacy. /// Bound to the hover preview id. The primary nametag identifies the hovered /// Saga character, so no duplicate identity chrome is rendered here. /// No portrait / name / title / stake chrome here - that space goes to Cast. /// Width fills the stable rail chrome beside the live dossier portrait. /// The body has fixed geometry: compact 3x2 Cast cells above a two-line Legacy. /// Content changes never move the rail, nametag, portrait, or card edge. /// public static class LifeSagaPanel { /// Full inner hover width; Cast starts beside the portrait, Legacy spans below it. public const float BodyWidthFixed = 232f; /// Fixed hover depth; never derived from Cast or Legacy content. public const float BodyHeightFixed = 82f; public const float BodyHeightMax = BodyHeightFixed; public const float BodyWidthMin = BodyWidthFixed; public const float BodyWidthMax = BodyWidthFixed; public const float BodyHeightMin = BodyHeightFixed; /// Visible Cast cells. The sixth cell becomes +N when candidates overflow. public const int MaxCast = 6; /// Relationships retained by the model before the visible grid is allocated. public const int MaxCastCandidates = 10; private const int MaxLegacy = 2; private const int CastPerRow = 3; private const float Pad = 3f; private const float CastFace = 14f; private const float CastSlotW = 59f; private const float CastSlotH = 18f; private const float SecLabelH = 9f; private const float LineH = 9f; private const float PortraitReserveW = 44f; private const int BodyFont = 7; private const int BuildVersion = 15; private static GameObject _root; private static RectTransform _rootRt; private static Text _castHead; private static Text _legacyHead; private static Text _legacyText; private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast]; private static readonly List LegacyParts = new List(MaxLegacy); private static long _boundId; private static string _speciesId = ""; private static string _fingerprint = ""; private static int _builtVersion; private static float _laidOutHeight = BodyHeightFixed; 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; /// Saga hover reuses the dossier avatar for the hovered character. public static bool OwnsAvatar => true; public static string LastLayoutDebug { get; private set; } = ""; /// Harness/UI diagnostic: rendered Cast relationship sublabels. public static int LastVisibleRelationCount { get; private set; } public static void EnsureBuilt(Transform parent) { if (parent == null) { return; } if (_root != null && _builtVersion == BuildVersion) { return; } if (_root != null) { UnityEngine.Object.Destroy(_root); _root = null; _rootRt = null; _castHead = null; _legacyHead = null; _legacyText = null; } _root = new GameObject("LifeSagaPanel", typeof(RectTransform)); _root.transform.SetParent(parent, false); _rootRt = _root.GetComponent(); _rootRt.pivot = new Vector2(0f, 1f); _rootRt.anchorMin = new Vector2(0f, 1f); _rootRt.anchorMax = new Vector2(0f, 1f); _rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightFixed); _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 = ""; LegacyParts.Clear(); LastVisibleRelationCount = 0; _laidOutHeight = BodyHeightFixed; if (_root != null) { _root.SetActive(false); } } public static void SetVisible(bool visible) { if (_root == null) { return; } _root.SetActive(visible); } public static void Bind(LifeSagaViewModel model) { if (_root == null) { return; } if (model == null || model.UnitId == 0) { Clear(); 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) { var eligibleCast = new List(model.Cast.Count); for (int i = 0; i < model.Cast.Count; i++) { LifeSagaCastMember member = model.Cast[i]; if (member == null || member.UnitId == 0) { continue; } eligibleCast.Add(member); } int visiblePeople = Mathf.Min(eligibleCast.Count, CastSlots.Length); bool hasOverflow = eligibleCast.Count > CastSlots.Length; if (hasOverflow) { visiblePeople = CastSlots.Length - 1; } int shownCast = 0; for (; shownCast < visiblePeople; shownCast++) { BindCast(CastSlots[shownCast], eligibleCast[shownCast]); } if (hasOverflow) { BindCastOverflow(CastSlots[shownCast], eligibleCast.Count - visiblePeople); shownCast++; } for (int i = shownCast; i < CastSlots.Length; i++) { CastSlots[i].Root.SetActive(false); CastSlots[i].UnitId = 0; } LegacyParts.Clear(); var seen = new HashSet(); string stakeKey = (model.StakeLine ?? "").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) : "Their story is still unfolding"; } 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); return y + _laidOutHeight + 2f; } private static void Layout() { float fullX = Pad; float fullW = BodyWidthFixed - Pad * 2f; float y = Pad; float yMax = BodyHeightFixed - Pad; int castN = 0; LastVisibleRelationCount = 0; for (int i = 0; i < CastSlots.Length; i++) { if (CastSlots[i].Root.activeSelf) { castN++; if (CastSlots[i].Relation != null && !string.IsNullOrEmpty(CastSlots[i].Relation.text)) { LastVisibleRelationCount++; } } } float castX0 = PortraitReserveW; float castW = BodyWidthFixed - castX0 - Pad; _castHead.text = castN > 0 ? "Cast" : "Cast · No close ties recorded"; PlaceText(_castHead, castX0, y, castW, SecLabelH); y += SecLabelH; float castX = castX0; int col = 0; for (int i = 0; i < CastSlots.Length; i++) { if (!CastSlots[i].Root.activeSelf) { continue; } if (col >= CastPerRow) { y += CastSlotH + 2f; castX = castX0; col = 0; } RectTransform rt = CastSlots[i].Root.GetComponent(); rt.anchoredPosition = new Vector2(castX, -y); rt.sizeDelta = new Vector2(CastSlotW, CastSlotH); CastSlots[i].Root.transform.SetAsLastSibling(); castX += CastSlotW + 1f; col++; } // Reserve both Cast rows even when sparse so Legacy never moves between characters. y = 52f; float legacyBudget = Mathf.Max(LineH, yMax - y - SecLabelH); int omittedLegacy = 0; string fittedLegacy = FitLegacyToHeight(fullW, legacyBudget, out omittedLegacy); _legacyText.text = fittedLegacy; bool hasLegacy = LegacyParts.Count > 0; PlaceText(_legacyHead, fullX, y, fullW, SecLabelH); y += SecLabelH; PlaceText(_legacyText, fullX, y, fullW, Mathf.Min(2f * LineH, yMax - y)); _legacyText.horizontalOverflow = HorizontalWrapMode.Overflow; _legacyText.verticalOverflow = VerticalWrapMode.Truncate; _laidOutHeight = BodyHeightFixed; if (_rootRt != null) { _rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight); } LastLayoutDebug = $"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} omitted={omittedLegacy} castFirst=1"; } private static string FitLegacyToHeight(float width, float maxHeight, out int omitted) { omitted = Mathf.Max(0, LegacyParts.Count - MaxLegacy); if (_legacyText == null || maxHeight < LineH) { return ""; } if (LegacyParts.Count == 0) { return "Their story is still unfolding"; } int lineCount = Mathf.Min(MaxLegacy, Mathf.FloorToInt(maxHeight / LineH)); var lines = new List(lineCount); int maxChars = Mathf.Max(12, Mathf.FloorToInt(width / 4.2f)); for (int i = 0; i < LegacyParts.Count && lines.Count < lineCount; i++) { lines.Add(Ellipsize(LegacyParts[i], maxChars)); } omitted = Mathf.Max(0, LegacyParts.Count - lines.Count); return string.Join("\n", lines); } private static string Ellipsize(string value, int maxChars) { string clean = (value ?? "").Replace('\n', ' ').Replace('\r', ' ').Trim(); return clean.Length <= maxChars ? clean : clean.Substring(0, Mathf.Max(1, maxChars - 1)).TrimEnd() + "…"; } private static CastSlot BuildCast(int index) { GameObject root = new GameObject("Cast" + index, typeof(RectTransform)); root.transform.SetParent(_root.transform, false); RectTransform rootRt = root.GetComponent(); 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(); bg.color = new Color(0.12f, 0.13f, 0.16f, 0.95f); bg.raycastTarget = false; RectTransform bgRt = bgGo.GetComponent(); 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(); face.preserveAspect = true; face.raycastTarget = false; RectTransform faceRt = faceGo.GetComponent(); 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); Text relation = MakeLabel(root.transform, "Rel", BodyFont, HudTheme.Muted, FontStyle.Normal); RectTransform nameRt = name.rectTransform; nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f); nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 2f, 9f); RectTransform relRt = relation.rectTransform; relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f); relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 2f, 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; string name = string.IsNullOrEmpty(member.Name) ? "Someone" : member.Name; string rel = string.IsNullOrEmpty(member.Relation) ? "Linked" : member.Relation; FitCastText(slot.Name, name); FitCastText(slot.Relation, rel); 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.FaceBg.color = member.Alive ? new Color(0.12f, 0.13f, 0.16f, 0.95f) : new Color(0.18f, 0.12f, 0.12f, 0.95f); slot.Root.SetActive(true); } private static void BindCastOverflow(CastSlot slot, int omitted) { slot.UnitId = 0; slot.Face.sprite = null; slot.Face.enabled = false; slot.FaceBg.color = new Color(0.12f, 0.13f, 0.16f, 0.95f); FitCastText(slot.Name, "+" + Mathf.Max(1, omitted)); FitCastText(slot.Relation, "more ties"); slot.Root.SetActive(true); } /// Harness: Cast membership is independent of the transient orange Beat. internal static bool HarnessProbeStableCastSnapshot(out string detail) { var model = new LifeSagaViewModel { UnitId = 10, Name = "Parent" }; model.Cast.Add(new LifeSagaCastMember { UnitId = 20, Name = "Child", Relation = "Child", Alive = true }); string before = Fingerprint(model); string after = Fingerprint(model); bool pass = before == after && model.Cast.Count == 1 && model.Cast[0].UnitId == 20; detail = $"pass={pass} fingerprintStable={before == after} cast={model.Cast.Count} member={model.Cast[0].UnitId}"; return pass; } private static void FitCastText(Text text, string value) { if (text == null) { return; } string fitted = value ?? ""; const int maxReadableChars = 11; if (fitted.Length > maxReadableChars) { fitted = fitted.Substring(0, maxReadableChars - 1).TrimEnd() + "…"; } text.text = fitted; text.resizeTextForBestFit = true; text.resizeTextMinSize = 6; text.resizeTextMaxSize = BodyFont; text.horizontalOverflow = HorizontalWrapMode.Overflow; text.verticalOverflow = VerticalWrapMode.Truncate; } private static Text MakeLabel(Transform parent, string name, int fontSize, Color color, FontStyle style) { int fitSize = fontSize < 7 ? 7 : fontSize; Text text = HudCanvas.MakeText(parent, name, "", fitSize); text.color = color; text.fontStyle = style; text.alignment = TextAnchor.UpperLeft; text.horizontalOverflow = HorizontalWrapMode.Overflow; 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 string Fingerprint(LifeSagaViewModel model) { var sb = new StringBuilder(192); sb.Append(model.UnitId).Append('|').Append(model.Name).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(); } }