feat(saga): stabilize hover dossier layout
- Fix Saga geometry so portraits and glyphs remain aligned during hover - Bound Cast and Legacy content with overflow and empty states - Prevent hidden camera beats from reserving Saga layout space - Add fixed-size regression coverage and document the UX contract
This commit is contained in:
parent
086921bd95
commit
0c60f1a2ba
8 changed files with 365 additions and 141 deletions
|
|
@ -9309,8 +9309,7 @@ public static class AgentHarness
|
|||
int rows = LifeSagaRail.LastHoverRowCount;
|
||||
float height = LifeSagaRail.LastHoverHeight;
|
||||
pass = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
|
||||
&& height >= LifeSagaPanel.BodyHeightMin - 0.5f
|
||||
&& height <= LifeSagaPanel.BodyHeightMax + 0.5f;
|
||||
&& Mathf.Abs(height - LifeSagaPanel.BodyHeightFixed) < 0.5f;
|
||||
detail =
|
||||
$"snapshot rows={rows} height={height:0.0} tab={LifeSagaViewController.EffectiveTab} min={LifeSagaPanel.BodyHeightMin} max={LifeSagaPanel.BodyHeightMax}";
|
||||
break;
|
||||
|
|
@ -9420,20 +9419,19 @@ public static class AgentHarness
|
|||
}
|
||||
case "saga_panel_fixed_size":
|
||||
{
|
||||
// Locked tabbed chrome width + content-height Saga body (capped).
|
||||
// Locked rail width + exact fixed-height Saga body.
|
||||
Vector2 size = WatchCaption.LastPanelSize;
|
||||
float bodyH = LifeSagaPanel.BodyHeight;
|
||||
float bodyW = LifeSagaPanel.BodyWidthPx;
|
||||
bool onSaga = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga;
|
||||
bool widthOk = Mathf.Abs(bodyW - LifeSagaPanel.BodyWidthFixed) < 0.5f;
|
||||
bool heightOk = bodyH >= LifeSagaPanel.BodyHeightMin - 0.5f
|
||||
&& bodyH <= LifeSagaPanel.BodyHeightMax + 0.5f;
|
||||
bool heightOk = Mathf.Abs(bodyH - LifeSagaPanel.BodyHeightFixed) < 0.5f;
|
||||
bool compact = widthOk && heightOk;
|
||||
if (onSaga)
|
||||
{
|
||||
compact = compact
|
||||
&& LifeSagaPanel.Visible
|
||||
&& size.y <= LifeSagaPanel.BodyHeightMax + 100f
|
||||
&& Mathf.Abs(size.y - WatchCaption.SagaHoverHeightFixed) < 0.5f
|
||||
&& size.x <= 260f
|
||||
&& size.x >= bodyW - 0.5f;
|
||||
}
|
||||
|
|
@ -9444,7 +9442,7 @@ public static class AgentHarness
|
|||
|
||||
pass = compact;
|
||||
detail =
|
||||
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} maxH={LifeSagaPanel.BodyHeightMax} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
|
||||
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
|
||||
break;
|
||||
}
|
||||
case "caption_mc_identity":
|
||||
|
|
|
|||
|
|
@ -4004,6 +4004,7 @@ internal static class HarnessScenarios
|
|||
Step("ccb22", "saga_show_hover_first"),
|
||||
Step("ccb23", "assert", expect: "caption_hover_keeps_beat"),
|
||||
Step("ccb23b", "assert", expect: "saga_panel_cast_first"),
|
||||
Step("ccb23c", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("ccb24", "saga_hide_hover"),
|
||||
Step("ccb25", "saga_hover_tick_away"),
|
||||
Step("ccb26", "spawn", asset: "sheep", count: 1),
|
||||
|
|
|
|||
|
|
@ -12,33 +12,37 @@ namespace IdleSpectator;
|
|||
/// 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.
|
||||
/// Height sizes to content (capped); Legacy admits complete prioritized lines and
|
||||
/// uses a final ellipsis when lower-priority entries do not fit.
|
||||
/// 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>Remaining hover width beside the 40px live portrait.</summary>
|
||||
public const float BodyWidthFixed = 188f;
|
||||
/// <summary>Hard cap so Saga never eats the screen.</summary>
|
||||
public const float BodyHeightMax = 172f;
|
||||
/// <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 = 74f;
|
||||
public const float BodyHeightMax = BodyHeightFixed;
|
||||
|
||||
public const float BodyWidthMin = BodyWidthFixed;
|
||||
public const float BodyWidthMax = BodyWidthFixed;
|
||||
public const float BodyHeightMin = 12f;
|
||||
public const float BodyHeightFixed = BodyHeightMax;
|
||||
public const float BodyHeightMin = BodyHeightFixed;
|
||||
|
||||
/// <summary>Shared cast budget for panel slots and presentation model.</summary>
|
||||
public const int MaxCast = 8;
|
||||
private const int MaxLegacy = 6;
|
||||
/// <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 = 16f;
|
||||
private const float CastSlotW = 54f;
|
||||
private const float SecLabelH = 10f;
|
||||
private const float LineH = 10f;
|
||||
private const float CastFace = 14f;
|
||||
private const float CastSlotW = 59f;
|
||||
private const float CastSlotH = 15f;
|
||||
private const float SecLabelH = 9f;
|
||||
private const float LineH = 9f;
|
||||
private const float PortraitReserveW = 44f;
|
||||
private const int BodyFont = 7;
|
||||
private const int BuildVersion = 12;
|
||||
private const int BuildVersion = 14;
|
||||
|
||||
private static GameObject _root;
|
||||
private static RectTransform _rootRt;
|
||||
|
|
@ -51,7 +55,7 @@ public static class LifeSagaPanel
|
|||
private static string _speciesId = "";
|
||||
private static string _fingerprint = "";
|
||||
private static int _builtVersion;
|
||||
private static float _laidOutHeight = BodyHeightMin;
|
||||
private static float _laidOutHeight = BodyHeightFixed;
|
||||
|
||||
private sealed class CastSlot
|
||||
{
|
||||
|
|
@ -99,7 +103,7 @@ public static class LifeSagaPanel
|
|||
_rootRt.pivot = new Vector2(0f, 1f);
|
||||
_rootRt.anchorMin = new Vector2(0f, 1f);
|
||||
_rootRt.anchorMax = new Vector2(0f, 1f);
|
||||
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
|
||||
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightFixed);
|
||||
|
||||
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
|
||||
_castHead.text = "Cast";
|
||||
|
|
@ -124,7 +128,7 @@ public static class LifeSagaPanel
|
|||
_speciesId = "";
|
||||
_fingerprint = "";
|
||||
LegacyParts.Clear();
|
||||
_laidOutHeight = BodyHeightMin;
|
||||
_laidOutHeight = BodyHeightFixed;
|
||||
if (_root != null)
|
||||
{
|
||||
_root.SetActive(false);
|
||||
|
|
@ -161,8 +165,8 @@ public static class LifeSagaPanel
|
|||
_speciesId = model.SpeciesId ?? "";
|
||||
if (!same)
|
||||
{
|
||||
int shownCast = 0;
|
||||
for (int i = 0; i < model.Cast.Count && shownCast < CastSlots.Length; i++)
|
||||
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 || CastDuplicatesCurrentBeat(model.UnitId, member.UnitId))
|
||||
|
|
@ -170,7 +174,25 @@ public static class LifeSagaPanel
|
|||
continue;
|
||||
}
|
||||
|
||||
BindCast(CastSlots[shownCast], member);
|
||||
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++;
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +228,9 @@ public static class LifeSagaPanel
|
|||
LegacyParts.Add(line);
|
||||
}
|
||||
|
||||
_legacyText.text = LegacyParts.Count > 0 ? string.Join("\n", LegacyParts) : "";
|
||||
_legacyText.text = LegacyParts.Count > 0
|
||||
? string.Join("\n", LegacyParts)
|
||||
: "Their story is still unfolding";
|
||||
}
|
||||
|
||||
Layout();
|
||||
|
|
@ -230,7 +254,7 @@ public static class LifeSagaPanel
|
|||
float fullX = Pad;
|
||||
float fullW = BodyWidthFixed - Pad * 2f;
|
||||
float y = Pad;
|
||||
float yMax = BodyHeightMax - Pad;
|
||||
float yMax = BodyHeightFixed - Pad;
|
||||
|
||||
// The Beat can update after the model binds in the same frame. Recheck by
|
||||
// structured ids; display-name substring matching removed legitimate Cast
|
||||
|
|
@ -259,68 +283,49 @@ public static class LifeSagaPanel
|
|||
}
|
||||
}
|
||||
|
||||
if (castN > 0)
|
||||
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++)
|
||||
{
|
||||
PlaceText(_castHead, fullX, y, fullW, SecLabelH);
|
||||
y += SecLabelH;
|
||||
float castRowH = CastFace + 18f;
|
||||
float castX = fullX;
|
||||
int col = 0;
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
if (!CastSlots[i].Root.activeSelf)
|
||||
{
|
||||
if (!CastSlots[i].Root.activeSelf)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (col >= CastPerRow)
|
||||
{
|
||||
y += castRowH + 2f;
|
||||
castX = fullX;
|
||||
col = 0;
|
||||
}
|
||||
|
||||
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;
|
||||
col++;
|
||||
continue;
|
||||
}
|
||||
|
||||
y += castRowH + 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_castHead);
|
||||
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++;
|
||||
}
|
||||
|
||||
float legacyBudget = Mathf.Max(0f, yMax - y - SecLabelH);
|
||||
// Reserve both Cast rows even when sparse so Legacy never moves between characters.
|
||||
y = 44f;
|
||||
float legacyBudget = Mathf.Max(LineH, yMax - y - SecLabelH);
|
||||
int omittedLegacy = 0;
|
||||
string fittedLegacy = FitLegacyToHeight(fullW, legacyBudget, out omittedLegacy);
|
||||
_legacyText.text = fittedLegacy;
|
||||
bool hasLegacy = !string.IsNullOrEmpty(fittedLegacy);
|
||||
if (hasLegacy && legacyBudget >= LineH)
|
||||
{
|
||||
PlaceText(_legacyHead, fullX, y, fullW, SecLabelH);
|
||||
y += SecLabelH;
|
||||
float legacyH = Mathf.Min(
|
||||
MeasureWrapHeight(_legacyText, fittedLegacy, fullW),
|
||||
Mathf.Max(LineH, yMax - y));
|
||||
PlaceText(_legacyText, fullX, y, fullW, legacyH);
|
||||
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_legacyText.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
y += legacyH + 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_legacyHead);
|
||||
Hide(_legacyText);
|
||||
hasLegacy = false;
|
||||
}
|
||||
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;
|
||||
|
||||
y = Mathf.Max(y + Pad, BodyHeightMin);
|
||||
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
|
||||
_laidOutHeight = BodyHeightFixed;
|
||||
|
||||
if (_rootRt != null)
|
||||
{
|
||||
|
|
@ -333,50 +338,35 @@ public static class LifeSagaPanel
|
|||
|
||||
private static string FitLegacyToHeight(float width, float maxHeight, out int omitted)
|
||||
{
|
||||
omitted = LegacyParts.Count;
|
||||
if (_legacyText == null || LegacyParts.Count == 0 || maxHeight < LineH)
|
||||
omitted = Mathf.Max(0, LegacyParts.Count - MaxLegacy);
|
||||
if (_legacyText == null || maxHeight < LineH)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string fitted = "";
|
||||
int included = 0;
|
||||
for (int i = 0; i < LegacyParts.Count; i++)
|
||||
if (LegacyParts.Count == 0)
|
||||
{
|
||||
string next = string.IsNullOrEmpty(fitted)
|
||||
? LegacyParts[i]
|
||||
: fitted + "\n" + LegacyParts[i];
|
||||
if (MeasureWrapHeight(_legacyText, next, width) > maxHeight + 0.5f)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
fitted = next;
|
||||
included++;
|
||||
return "Their story is still unfolding";
|
||||
}
|
||||
|
||||
omitted = LegacyParts.Count - included;
|
||||
if (omitted <= 0)
|
||||
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++)
|
||||
{
|
||||
return fitted;
|
||||
lines.Add(Ellipsize(LegacyParts[i], maxChars));
|
||||
}
|
||||
|
||||
string withEllipsis = string.IsNullOrEmpty(fitted) ? "…" : fitted + "\n…";
|
||||
return MeasureWrapHeight(_legacyText, withEllipsis, width) <= maxHeight + 0.5f
|
||||
? withEllipsis
|
||||
: fitted;
|
||||
omitted = Mathf.Max(0, LegacyParts.Count - lines.Count);
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private static float MeasureWrapHeight(Text text, string value, float width)
|
||||
private static string Ellipsize(string value, int maxChars)
|
||||
{
|
||||
if (text == null || string.IsNullOrEmpty(value))
|
||||
{
|
||||
return LineH;
|
||||
}
|
||||
|
||||
TextGenerationSettings settings = text.GetGenerationSettings(new Vector2(width, 0f));
|
||||
float pixels = text.cachedTextGeneratorForLayout.GetPreferredHeight(value, settings);
|
||||
return Mathf.Max(LineH, pixels / Mathf.Max(0.01f, text.pixelsPerUnit));
|
||||
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)
|
||||
|
|
@ -415,11 +405,11 @@ public static class LifeSagaPanel
|
|||
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(0f, -(CastFace + 1f));
|
||||
nameRt.sizeDelta = new Vector2(CastSlotW, 9f);
|
||||
nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
|
||||
nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 2f, 8f);
|
||||
RectTransform relRt = relation.rectTransform;
|
||||
relRt.anchoredPosition = new Vector2(0f, -(CastFace + 9f));
|
||||
relRt.sizeDelta = new Vector2(CastSlotW, 9f);
|
||||
relRt.anchoredPosition = new Vector2(CastFace + 2f, -8f);
|
||||
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 2f, 7f);
|
||||
|
||||
root.SetActive(false);
|
||||
return new CastSlot
|
||||
|
|
@ -453,6 +443,17 @@ public static class LifeSagaPanel
|
|||
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>The camera Beat already explains this exact member for this exact subject.</summary>
|
||||
private static bool CastDuplicatesCurrentBeat(long panelSubjectId, long castMemberId)
|
||||
{
|
||||
|
|
@ -502,7 +503,7 @@ public static class LifeSagaPanel
|
|||
}
|
||||
|
||||
string fitted = value ?? "";
|
||||
const int maxReadableChars = 16;
|
||||
const int maxReadableChars = 11;
|
||||
if (fitted.Length > maxReadableChars)
|
||||
{
|
||||
fitted = fitted.Substring(0, maxReadableChars - 1).TrimEnd() + "…";
|
||||
|
|
@ -512,7 +513,7 @@ public static class LifeSagaPanel
|
|||
text.resizeTextForBestFit = true;
|
||||
text.resizeTextMinSize = 6;
|
||||
text.resizeTextMaxSize = BodyFont;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
}
|
||||
|
||||
|
|
@ -555,14 +556,6 @@ public static class LifeSagaPanel
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ public static class LifeSagaPresentation
|
|||
|
||||
void AddLive(Actor other, string label, string truth)
|
||||
{
|
||||
if (other == null || model.Cast.Count >= LifeSagaPanel.MaxCast)
|
||||
if (other == null || model.Cast.Count >= LifeSagaPanel.MaxCastCandidates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -461,7 +461,7 @@ public static class LifeSagaPresentation
|
|||
|
||||
void AddObserved(LifeSagaIdentity identity, string label, string truth)
|
||||
{
|
||||
if (identity.Id == 0 || model.Cast.Count >= LifeSagaPanel.MaxCast || !seen.Add(identity.Id))
|
||||
if (identity.Id == 0 || model.Cast.Count >= LifeSagaPanel.MaxCastCandidates || !seen.Add(identity.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -518,9 +518,9 @@ public static class LifeSagaPresentation
|
|||
}
|
||||
|
||||
// Former bonds from memory when live cast still has room.
|
||||
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
|
||||
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCastCandidates)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++)
|
||||
{
|
||||
LifeSagaFact f = memory.Facts[i];
|
||||
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
|
||||
|
|
@ -540,9 +540,9 @@ public static class LifeSagaPresentation
|
|||
}
|
||||
|
||||
// Living Others from unresolved war/plot joins.
|
||||
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
|
||||
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCastCandidates)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++)
|
||||
{
|
||||
LifeSagaFact f = memory.Facts[i];
|
||||
if (f == null || f.Resolved || f.OtherId == 0 || seen.Contains(f.OtherId))
|
||||
|
|
@ -569,7 +569,7 @@ public static class LifeSagaPresentation
|
|||
|
||||
// Family peers fill remaining slots for crown/combat/plot/pack/bond lenses.
|
||||
if (actor != null
|
||||
&& model.Cast.Count < LifeSagaPanel.MaxCast
|
||||
&& model.Cast.Count < LifeSagaPanel.MaxCastCandidates
|
||||
&& (primary == LifeSagaLens.PackAlpha
|
||||
|| primary == LifeSagaLens.LoveGrief
|
||||
|| primary == LifeSagaLens.CrownClan
|
||||
|
|
@ -579,7 +579,7 @@ public static class LifeSagaPresentation
|
|||
string peerLabel = primary == LifeSagaLens.PackAlpha
|
||||
? SagaProse.CastLabelLive("packmate")
|
||||
: SagaProse.CastLabelLive("kin");
|
||||
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCast + 2))
|
||||
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCastCandidates + 2))
|
||||
{
|
||||
if (peer == null || peer == actor)
|
||||
{
|
||||
|
|
@ -587,7 +587,7 @@ public static class LifeSagaPresentation
|
|||
}
|
||||
|
||||
AddLive(peer, peerLabel, "is");
|
||||
if (model.Cast.Count >= LifeSagaPanel.MaxCast)
|
||||
if (model.Cast.Count >= LifeSagaPanel.MaxCastCandidates)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,6 +206,11 @@ public static class WatchCaption
|
|||
|
||||
public static Vector2 LastPanelSize { get; private set; }
|
||||
|
||||
/// <summary>Exact outer height of rail + nametag + fixed Saga body.</summary>
|
||||
public static float SagaHoverHeightFixed =>
|
||||
PadTop + LifeSagaRail.SlotSize + 2f + HeaderH + Gap
|
||||
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
|
||||
|
||||
public static bool LastLayoutOk { get; private set; }
|
||||
|
||||
/// <summary>True when the dossier card GameObject is active.</summary>
|
||||
|
|
@ -1062,10 +1067,10 @@ public static class WatchCaption
|
|||
if (panelMode)
|
||||
{
|
||||
// Hover depth prioritizes the full Identity. The live task is already represented
|
||||
// by the Beat and returns with the dossier rows as soon as hover ends.
|
||||
// by the camera dossier and returns with its rows as soon as hover ends. Beat and
|
||||
// Context are hidden, so neither may reserve phantom vertical space here.
|
||||
hasTask = false;
|
||||
hasReason = !string.IsNullOrEmpty(LastBeatLine)
|
||||
|| (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text));
|
||||
hasReason = false;
|
||||
}
|
||||
|
||||
bool hasBody = !panelMode
|
||||
|
|
@ -2824,6 +2829,13 @@ public static class WatchCaption
|
|||
bool panelMode = LifeSagaViewController.IsHoverPreview && !HasStatusBanner();
|
||||
bool railChrome = LifeSagaRail.Visible;
|
||||
|
||||
// Defensive ownership boundary: Saga preview never lays out camera Beat space even
|
||||
// if a caller still has a composed LastBeatLine from immediately before hover.
|
||||
if (panelMode)
|
||||
{
|
||||
hasReason = false;
|
||||
}
|
||||
|
||||
// Rail chrome keeps a constant width so hover preview cannot reflow the glyphs.
|
||||
if (railChrome || panelMode)
|
||||
{
|
||||
|
|
@ -2941,7 +2953,7 @@ public static class WatchCaption
|
|||
LifeSagaPanel.EnsureBuilt(_root.transform);
|
||||
DossierAvatar.Place(PadX, y, LiveMax);
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
y = LifeSagaPanel.Place(y, PadX + LiveMax + 4f);
|
||||
y = LifeSagaPanel.Place(y, PadX);
|
||||
if (railChrome)
|
||||
{
|
||||
_panelWidth = PanelWidthMax;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft c
|
|||
Chronicle stays a history book and never drives the camera.
|
||||
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
|
||||
Durable observed facts live in [`LifeSagaMemory`](../IdleSpectator/Story/LifeSagaMemory.cs) and survive roster churn.
|
||||
The fixed-layout hover redesign is specified in the [Saga UX redesign plan](saga-ux-redesign-plan.md).
|
||||
|
||||
AFK caption is a **character beat**: Identity (who) + Beat (live tip) + Context (spine or stake).
|
||||
[`CaptionComposer`](../IdleSpectator/Story/CaptionComposer.cs) merges saga identity into the live caption without baking titles into `InterestCandidate.Label`.
|
||||
|
|
|
|||
218
docs/saga-ux-redesign-plan.md
Normal file
218
docs/saga-ux-redesign-plan.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# Saga UX redesign plan
|
||||
|
||||
Goal: make the Saga surface a stable character browser for following the world's main
|
||||
characters. Moving between glyphs must change the character, never the geometry.
|
||||
|
||||
Status: **Design approved in principle; implementation pending.**
|
||||
|
||||
Related documents:
|
||||
|
||||
- [Life Saga](life-saga.md)
|
||||
- [Story system coherence](story-system-coherence-plan.md)
|
||||
- [Story Planner](story-planner.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The current hover dossier has the right information but its layout is still derived from
|
||||
its contents. Cast width, legacy length, and portrait state can alter the panel height. Since
|
||||
the card is bottom anchored, that makes the rail and nametag shift while the pointer moves
|
||||
between glyphs. The result feels unstable even when every individual field is correct.
|
||||
|
||||
The surface also risks presenting two identities at once: the camera subject and the hovered
|
||||
Saga character. During Saga preview, every identity-bearing element must belong to the Saga
|
||||
character and camera-specific Beat and Context must disappear.
|
||||
|
||||
## Product contract
|
||||
|
||||
At a glance, Saga answers:
|
||||
|
||||
1. **Who is this main character?** — live name, role/species, sex, favorite state, and unique appearance.
|
||||
2. **Who matters to them?** — a compact, prioritized Cast.
|
||||
3. **What has defined their life?** — a short Legacy.
|
||||
4. **What will interaction do?** — hover previews; the existing click action toggles Prefer.
|
||||
|
||||
Saga is a character browser. It does not restate the camera Beat or Story Planner Context.
|
||||
|
||||
## Fixed geometry
|
||||
|
||||
Use one fixed logical envelope for every Saga state:
|
||||
|
||||
| Region | Size | Contract |
|
||||
|---|---:|---|
|
||||
| Whole surface | `240 × 117` | Never content-sized |
|
||||
| Glyph rail | `232 × 18` | Fixed position and hit targets |
|
||||
| Gap | `232 × 2` | Reserved |
|
||||
| Nametag | `232 × 15` | One line; fixed controls at right |
|
||||
| Gap | `232 × 1` | Reserved |
|
||||
| Body | `232 × 74` | Portrait, Cast, and Legacy stay inside |
|
||||
| Outer padding | `4` horizontal, `3` vertical | Constant |
|
||||
|
||||
The body must never report a content-derived preferred height. Empty and full characters use
|
||||
the same bounds. Supported UI scaling may scale the entire envelope uniformly but must not
|
||||
reflow its internal regions.
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ [glyph] [glyph] [glyph] [glyph] ... │ rail
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ sprite Name · Role sex favorite │ nametag
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ Cast │
|
||||
│ │ │ [face Name] [face Name] [face Name] │
|
||||
│ │ live unique │ [face Name] [face Name] [face +N] │
|
||||
│ │ portrait │ │
|
||||
│ └──────────────┘ Legacy │
|
||||
│ • complete, bounded legacy line │
|
||||
│ • complete, bounded legacy line │
|
||||
│ • complete, bounded legacy line │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Portrait
|
||||
|
||||
- Reserve a fixed `40 × 40` vanilla stone frame at the upper-left of the body.
|
||||
- A living character uses their current animated sprite so unique equipment and appearance remain visible.
|
||||
- A fallen or unavailable character uses the last captured character sprite, then species art only as a fallback.
|
||||
- The frame and surrounding regions never move when the sprite source changes.
|
||||
- Vanity/decorative framing is disabled; the portrait identifies the character rather than decorating the card.
|
||||
|
||||
## Cast
|
||||
|
||||
Cast owns a fixed region to the right of the portrait.
|
||||
|
||||
- Maximum six visible entries in a `3 × 2` grid.
|
||||
- Priority: partner, child/parent, close friend, rival, then other known relationships.
|
||||
- Each entry has a fixed icon cell and a single text line.
|
||||
- If more than six entries qualify, the sixth cell becomes `+N` rather than creating a third row.
|
||||
- Long labels ellipsize inside their cell; they never shrink the panel or overlap adjacent cells.
|
||||
- Empty state: `No close ties recorded` in the same region.
|
||||
|
||||
## Legacy
|
||||
|
||||
Legacy owns the full-width region below the portrait and Cast.
|
||||
|
||||
- Maximum two rendered lines, allocated by durable narrative importance.
|
||||
- Prefer complete short statements. If a statement cannot fit its assigned line, ellipsize it.
|
||||
- Never add another row or increase the body height.
|
||||
- Do not repeat identity, species, role, relationship labels already visible in the nametag or Cast.
|
||||
- Empty state: `Their story is still unfolding`.
|
||||
|
||||
## Rail portraits
|
||||
|
||||
The rail is a visual index of individuals, not species.
|
||||
|
||||
- Cache by `UnitId`, using the latest successfully captured inspect sprite.
|
||||
- Capture on roster admission and refresh while watched or previewed at a bounded cadence.
|
||||
- Retain the last valid unique sprite after focus changes or death.
|
||||
- Never replace a valid character sprite with generic species art.
|
||||
- Use species art only until no unique capture has ever succeeded.
|
||||
- Remove cached entries only when the character leaves the roster permanently.
|
||||
|
||||
Two characters of the same species must remain visually distinguishable whenever the game has
|
||||
provided distinct character sprites.
|
||||
|
||||
## Interaction states
|
||||
|
||||
### Camera dossier
|
||||
|
||||
Outside Saga hover, the normal dossier owns the nametag, Beat, Context, and live camera portrait.
|
||||
|
||||
### Saga preview
|
||||
|
||||
While a glyph is hovered:
|
||||
|
||||
- Acquire the existing camera read-pause lease.
|
||||
- Rebind the primary nametag, species/role, sex, favorite, portrait, Cast, and Legacy to the hovered character.
|
||||
- Hide camera Beat, Context, and camera-specific dossier rows.
|
||||
- Keep the fixed Saga envelope and all controls in exactly the same coordinates.
|
||||
- Restore the latest camera dossier atomically on pointer exit.
|
||||
|
||||
Rapidly scrubbing across glyphs must not briefly restore the camera identity between two Saga identities.
|
||||
|
||||
### Click behavior
|
||||
|
||||
Phase one keeps the existing click-to-toggle-Prefer behavior. A persistent pinned preview would
|
||||
conflict with that undiscoverable action, so pinning is deferred until Prefer has its own explicit
|
||||
control. Status banners cancel preview and retain priority.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Element | Camera dossier | Saga preview |
|
||||
|---|---|---|
|
||||
| Glyph rail | `LifeSagaRail` | `LifeSagaRail` |
|
||||
| Nametag and controls | Camera subject | Hovered Saga character |
|
||||
| Large portrait | Camera subject | Hovered Saga character |
|
||||
| Beat / Context | Camera story | Hidden |
|
||||
| Cast / Legacy | Hidden | Hovered Saga character |
|
||||
| Camera focus | `InterestDirector` | Unchanged and paused for reading |
|
||||
|
||||
## Layout invariants
|
||||
|
||||
1. The glyph rail has the same screen coordinates for every roster member.
|
||||
2. The outer card rectangle has the same screen coordinates and dimensions for empty, short, and full content.
|
||||
3. The nametag control cluster never moves with name length.
|
||||
4. The large portrait frame never moves or resizes.
|
||||
5. Cast never creates a third row.
|
||||
6. Legacy never increases body height or exceeds two lines.
|
||||
7. Empty states reserve the same geometry as populated states.
|
||||
8. Entering or leaving hover does not move the glyph under the pointer.
|
||||
9. Pointer exit restores the complete current camera dossier, not a stale snapshot.
|
||||
10. Hover never changes camera focus or roster order.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Phase A — Fixed shell
|
||||
|
||||
1. Give the Saga root, rail, nametag, and body explicit fixed bounds.
|
||||
2. Remove content-driven preferred-height calculations from the hover path.
|
||||
3. Reserve portrait, Cast, and Legacy rectangles before binding any content.
|
||||
|
||||
### Phase B — Portrait and Cast
|
||||
|
||||
1. Place the restored live stone-frame portrait in its fixed rectangle.
|
||||
2. Replace flexible Cast layout with the bounded `3 × 2` grid.
|
||||
3. Add deterministic relationship priority, overflow, and empty-state behavior.
|
||||
|
||||
### Phase C — Legacy allocator
|
||||
|
||||
1. Rank durable facts by narrative importance.
|
||||
2. Remove facts redundant with the visible identity and Cast.
|
||||
3. Allocate at most two complete lines with measured ellipsis.
|
||||
|
||||
### Phase D — Interaction hardening
|
||||
|
||||
1. Make hover-to-hover swaps atomic.
|
||||
2. Make pointer exit restore the latest camera state.
|
||||
3. Verify status-banner cancellation and camera pause ownership.
|
||||
4. Consider pinned preview only after Prefer has an explicit separate control.
|
||||
|
||||
## Verification
|
||||
|
||||
Add deterministic scenarios:
|
||||
|
||||
- `saga_fixed_shell` — empty, sparse, and full subjects produce identical bounds.
|
||||
- `saga_hover_identity_swap` — every identity element follows the hovered glyph.
|
||||
- `saga_hover_restore_camera` — exit restores the latest camera dossier.
|
||||
- `saga_rail_portrait_cache` — same-species characters retain distinct cached sprites.
|
||||
- `saga_cast_fixed_grid` — seven or more ties render five entries plus `+N`, with no third row.
|
||||
- `saga_legacy_fixed_budget` — long legacy prose remains inside two lines.
|
||||
- `saga_hover_scrub_stability` — rapid glyph changes do not move the rail or flash camera identity.
|
||||
|
||||
Capture visual evidence for same-species characters, six Cast entries, a fallen cached portrait,
|
||||
empty Cast/Legacy, maximum-length names, and every supported UI scale.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- All layout invariants pass in deterministic harness coverage.
|
||||
- Existing Saga/coherence scenarios remain green.
|
||||
- No duplicate camera/Saga information appears during preview.
|
||||
- No text clips or paints beyond its assigned region.
|
||||
- Same-species characters remain distinguishable after their unique sprite has been observed.
|
||||
- A frame-by-frame comparison shows zero rail, nametag, portrait-frame, or card-edge movement while scrubbing glyphs.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Saga does not become a second Chronicle.
|
||||
- Saga does not drive or lock camera selection.
|
||||
- The card does not show the current camera Beat or Context while previewing another character.
|
||||
- Persistent pinning is not added until its interaction no longer conflicts with Prefer.
|
||||
|
|
@ -11,6 +11,7 @@ hardening remains before the three-pass release gate can be claimed**.
|
|||
Related design documents:
|
||||
|
||||
- [Life Saga](life-saga.md)
|
||||
- [Saga UX redesign](saga-ux-redesign-plan.md)
|
||||
- [Story Planner](story-planner.md)
|
||||
- [Camera presentability](camera-presentability-plan.md)
|
||||
- [Scoring model](scoring-model.md)
|
||||
|
|
|
|||
Loading…
Reference in a new issue