- Introduce methods to observe and remember parent-child links for actors - Implement caching for observed relationships to improve performance - Update narrative event store to index child events by parent and child - Refactor LifeSagaPanel to ensure stable cast membership during saga sessions - Enhance documentation to clarify relationship handling and event indexing
555 lines
19 KiB
C#
555 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class LifeSagaPanel
|
|
{
|
|
/// <summary>Full inner hover width; Cast starts beside the portrait, Legacy spans below it.</summary>
|
|
public const float BodyWidthFixed = 232f;
|
|
/// <summary>Fixed hover depth; never derived from Cast or Legacy content.</summary>
|
|
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;
|
|
|
|
/// <summary>Visible Cast cells. The sixth cell becomes +N when candidates overflow.</summary>
|
|
public const int MaxCast = 6;
|
|
/// <summary>Relationships retained by the model before the visible grid is allocated.</summary>
|
|
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<string> LegacyParts = new List<string>(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;
|
|
/// <summary>Saga hover reuses the dossier avatar for the hovered character.</summary>
|
|
public static bool OwnsAvatar => true;
|
|
public static string LastLayoutDebug { get; private set; } = "";
|
|
/// <summary>Harness/UI diagnostic: rendered Cast relationship sublabels.</summary>
|
|
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<RectTransform>();
|
|
_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<LifeSagaCastMember>(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>();
|
|
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<RectTransform>();
|
|
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<string>(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<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);
|
|
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);
|
|
}
|
|
|
|
/// <summary>Harness: Cast membership is independent of the transient orange Beat.</summary>
|
|
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();
|
|
}
|
|
|
|
}
|