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
This commit is contained in:
DazedAnon 2026-07-22 03:57:31 -05:00
parent 4b6c829bd0
commit 6fb62c47d6
10 changed files with 243 additions and 430 deletions

View file

@ -3092,23 +3092,6 @@ public static class AgentHarness
break;
}
case "saga_hover_panel_dwell":
{
LifeSagaViewController.HarnessSimulatePanelDwell();
bool ok = LifeSagaViewController.IsHoverPreview;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"hoverId={LifeSagaViewController.HoverPreviewId}");
break;
}
case "saga_hover_tick_away":
{
LifeSagaViewController.HarnessTickExitAwayFromChrome();
@ -3142,25 +3125,6 @@ public static class AgentHarness
break;
}
case "saga_open_lore":
{
LifeSagaViewController.SelectTab(LifeSagaViewController.Tab.Saga);
WatchCaption.RequestRelayout();
LifeSagaPanel.HarnessOpenLore();
bool ok = Chronicle.HudVisible;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"lore={ok} bound={LifeSagaPanel.BoundUnitId}");
break;
}
case "saga_select_tab":
{
string raw = (cmd.value ?? "dossier").Trim().ToLowerInvariant();
@ -8517,9 +8481,10 @@ public static class AgentHarness
int rows = LifeSagaRail.LastHoverRowCount;
float height = LifeSagaRail.LastHoverHeight;
pass = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
&& height >= 100f
&& height <= 200f;
detail = $"snapshot rows={rows} height={height:0.0} tab={LifeSagaViewController.EffectiveTab} compact={pass}";
&& height >= LifeSagaPanel.BodyHeightMin - 0.5f
&& height <= LifeSagaPanel.BodyHeightMax + 0.5f;
detail =
$"snapshot rows={rows} height={height:0.0} tab={LifeSagaViewController.EffectiveTab} min={LifeSagaPanel.BodyHeightMin} max={LifeSagaPanel.BodyHeightMax}";
break;
}
case "saga_tab_selected":
@ -8567,14 +8532,25 @@ public static class AgentHarness
detail = $"boundId={model?.UnitId ?? 0} name='{model?.Name}' lens={model?.PrimaryLens}";
break;
}
case "saga_panel_evidence":
case "saga_dossier_chrome_restored":
{
bool onDossier = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Dossier
&& !LifeSagaViewController.IsHoverPreview;
bool sagaHidden = !LifeSagaPanel.Visible;
bool chrome = WatchCaption.HarnessDossierChromeRestored();
pass = onDossier && sagaHidden && chrome;
detail =
$"onDossier={onDossier} sagaHidden={sagaHidden} chrome={chrome} reason='{WatchCaption.LastDetail}'";
break;
}
case "saga_panel_lens":
{
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = model != null && !string.IsNullOrEmpty(model.EvidenceLine);
bool have = model != null && !string.IsNullOrEmpty(model.LensEyebrow);
pass = have == want;
detail =
$"evidence='{model?.EvidenceTitle}:{model?.EvidenceLine}' want={want} lens={model?.PrimaryLens}";
$"lensEyebrow='{model?.LensEyebrow}' want={want} primary={model?.PrimaryLens}";
break;
}
case "saga_panel_empty":

View file

@ -3044,16 +3044,14 @@ internal static class HarnessScenarios
Step("sad19", "assert", expect: "saga_tab_effective", value: "saga"),
Step("sad20", "assert", expect: "saga_hover_preview", value: "true"),
Step("sad21", "assert", expect: "saga_read_pause", value: "true"),
// Panel dwell clears the exit timer; leaving chrome must re-arm and restore Dossier.
Step("sad21b", "saga_hover_panel_dwell"),
Step("sad21c", "saga_hover_tick_away"),
Step("sad21d", "wait", wait: 0.1f),
Step("sad21e", "saga_hover_tick_away"),
Step("sad21f", "wait", wait: 0.4f),
Step("sad21g", "saga_hover_tick_away"),
// Leaving the glyph restores Dossier immediately (no panel-dwell keep-alive).
Step("sad21b", "saga_hide_hover"),
Step("sad21d", "saga_hover_tick_away"),
Step("sad21h", "assert", expect: "saga_hover_preview", value: "false"),
Step("sad21i", "assert", expect: "saga_tab_effective", value: "dossier"),
Step("sad21j", "assert", expect: "saga_read_pause", value: "false"),
// Dossier chrome must restore immediately (not wait for a tip ownership change).
Step("sad21j2", "assert", expect: "saga_dossier_chrome_restored"),
Step("sad21k", "saga_show_hover_first"),
Step("sad21l", "assert", expect: "saga_hover_preview", value: "true"),
// Tab click during preview cancels preview and applies the selected tab.
@ -3062,14 +3060,15 @@ internal static class HarnessScenarios
Step("sad21o", "assert", expect: "saga_tab_effective", value: "dossier"),
Step("sad22", "saga_show_hover_first"),
Step("sad22b", "saga_hide_hover"),
Step("sad23", "wait", wait: 0.4f),
Step("sad23b", "saga_hover_tick_away"),
Step("sad24", "assert", expect: "saga_hover_preview", value: "false"),
Step("sad25", "assert", expect: "saga_tab_effective", value: "dossier"),
Step("sad26", "assert", expect: "saga_read_pause", value: "false"),
Step("sad27", "assert", expect: "saga_overview_tip_not", value: "Fighting |Why |Now |Notable "),
Step("sad28", "saga_select_tab", value: "saga"),
Step("sad29", "assert", expect: "saga_panel_evidence", value: "true"),
Step("sad29", "assert", expect: "saga_panel_lens", value: "true"),
Step("sad29b", "assert", expect: "saga_overview_tip_not",
value: "Evidence|kills ·|remembered clashes|Open Lore"),
// Non-MC watch with no pin → empty pick state; rail click pins subject.
Step("sad30", "interest_saga_clear"),
Step("sad31", "spawn", asset: "human", count: 2),
@ -3085,12 +3084,6 @@ internal static class HarnessScenarios
Step("sad41", "assert", expect: "saga_panel_empty", value: "false"),
Step("sad42", "assert", expect: "saga_panel_bound"),
Step("sad43", "screenshot", value: "saga-rail-top"),
// Open Lore from Saga and restore Saga on close.
Step("sad44", "saga_open_lore"),
Step("sad45", "assert", expect: "chronicle_visible", value: "true"),
Step("sad46", "lore_close"),
Step("sad47", "assert", expect: "saga_tab_effective", value: "saga"),
Step("sad48", "assert", expect: "saga_panel_bound"),
Step("sad90", "fast_timing", value: "false"),
Step("sad99", "snapshot"),
};
@ -3155,7 +3148,7 @@ internal static class HarnessScenarios
Step("shp17", "assert", expect: "tip_contains", value: "PauseHold"),
Step("shp18", "assert", expect: "tip_not_contains", value: "ShouldNotCut"),
Step("shp19", "saga_hide_hover"),
Step("shp20", "wait", wait: 0.35f),
Step("shp20", "saga_hover_tick_away"),
Step("shp21", "assert", expect: "saga_read_pause", value: "false"),
Step("shp90", "fast_timing", value: "false"),
Step("shp99", "snapshot"),

View file

@ -44,12 +44,12 @@ public static class LifeSagaOverview
});
}
if (!string.IsNullOrEmpty(model.EvidenceLine))
if (!string.IsNullOrEmpty(model.LensEyebrow))
{
snapshot.Rows.Add(new LifeSagaSnapshotRow
{
Kind = LifeSagaSnapshotRowKind.Current,
Text = model.EvidenceTitle + ": " + model.EvidenceLine
Text = model.LensEyebrow
});
}

View file

@ -1,13 +1,13 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Saga body matching Design A (cast + stakes): identity, stake, cast, evidence, legacy, Open Lore.
/// 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>
@ -28,29 +28,26 @@ public static class LifeSagaPanel
private const float ColGap = 6f;
private const float CastFace = 16f;
private const float CastSlotW = 54f;
private const float LoreH = 11f;
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 = 7;
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 _evidenceHead;
private static Text _evidenceText;
private static Text _legacyHead;
private static Text _legacyText;
private static Button _loreBtn;
private static Text _loreBtnLabel;
private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast];
private static long _boundId;
private static string _speciesId = "";
@ -93,16 +90,13 @@ public static class LifeSagaPanel
Object.Destroy(_root);
_root = null;
_rootRt = null;
_eyebrowText = null;
_nameText = null;
_titleText = null;
_stakeText = null;
_castHead = null;
_evidenceHead = null;
_evidenceText = null;
_legacyHead = null;
_legacyText = null;
_loreBtn = null;
_loreBtnLabel = null;
}
_root = new GameObject("LifeSagaPanel", typeof(RectTransform));
@ -113,6 +107,7 @@ public static class LifeSagaPanel
_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);
@ -120,11 +115,6 @@ public static class LifeSagaPanel
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_castHead.text = "Cast";
_evidenceHead = MakeLabel(_root.transform, "EvidenceHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_evidenceHead.text = "Evidence";
_evidenceText = MakeLabel(_root.transform, "EvidenceBody", BodyFont, HudTheme.Body, FontStyle.Normal);
_evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
_evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
_legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_legacyHead.text = "Legacy";
_legacyText = MakeLabel(_root.transform, "LegacyBody", BodyFont, HudTheme.Body, FontStyle.Normal);
@ -136,23 +126,6 @@ public static class LifeSagaPanel
CastSlots[i] = BuildCast(i);
}
GameObject loreGo = new GameObject("SagaLoreBtn", typeof(RectTransform), typeof(Image), typeof(Button));
loreGo.transform.SetParent(_root.transform, false);
Image loreBg = loreGo.GetComponent<Image>();
loreBg.color = new Color(0f, 0f, 0f, 0f);
loreBg.raycastTarget = true;
_loreBtn = loreGo.GetComponent<Button>();
_loreBtn.targetGraphic = loreBg;
_loreBtn.onClick.AddListener(new UnityAction(OpenLore));
_loreBtnLabel = MakeLabel(loreGo.transform, "Label", BodyFont, HudTheme.ValueOrange, FontStyle.Normal);
_loreBtnLabel.text = "Open Lore";
_loreBtnLabel.alignment = TextAnchor.MiddleLeft;
RectTransform loreRt = loreGo.GetComponent<RectTransform>();
loreRt.pivot = new Vector2(0f, 1f);
loreRt.anchorMin = new Vector2(0f, 1f);
loreRt.anchorMax = new Vector2(0f, 1f);
loreRt.sizeDelta = new Vector2(72f, LoreH);
_builtVersion = BuildVersion;
_root.SetActive(false);
}
@ -184,7 +157,7 @@ public static class LifeSagaPanel
}
}
public static void Bind(LifeSagaViewModel model, bool loreInteractive)
public static void Bind(LifeSagaViewModel model)
{
if (_root == null)
{
@ -197,7 +170,7 @@ public static class LifeSagaPanel
return;
}
string fp = Fingerprint(model, loreInteractive);
string fp = Fingerprint(model);
bool same = string.Equals(fp, _fingerprint, System.StringComparison.Ordinal) && _boundId == model.UnitId;
_fingerprint = fp;
_boundId = model.UnitId;
@ -205,6 +178,7 @@ public static class LifeSagaPanel
if (!same)
{
_eyebrowText.text = model.LensEyebrow ?? "";
_nameText.text = StripStar(model.Name);
_titleText.text = model.Title ?? "";
_stakeText.text = model.StakeLine ?? "";
@ -222,8 +196,6 @@ public static class LifeSagaPanel
}
}
_evidenceText.text = FormatEvidence(model);
var legacyParts = new List<string>(MaxLegacy);
var seen = new HashSet<string>();
string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant();
@ -250,13 +222,7 @@ public static class LifeSagaPanel
legacyParts.Add(line);
}
_legacyText.text = legacyParts.Count > 0 ? string.Join(" · ", legacyParts) : "";
}
if (_loreBtn != null)
{
_loreBtn.gameObject.SetActive(loreInteractive);
_loreBtn.interactable = loreInteractive;
_legacyText.text = legacyParts.Count > 0 ? string.Join("\n", legacyParts) : "";
}
RefreshAvatar(model);
@ -283,34 +249,6 @@ public static class LifeSagaPanel
return y + _laidOutHeight + 2f;
}
private static string FormatEvidence(LifeSagaViewModel model)
{
if (model == null || string.IsNullOrEmpty(model.EvidenceLine))
{
return "";
}
var sb = new StringBuilder(96);
if (!string.IsNullOrEmpty(model.EvidenceTitle))
{
sb.Append(model.EvidenceTitle).Append(" · ");
}
sb.Append(model.EvidenceLine);
if (!string.IsNullOrEmpty(model.SecondaryEvidenceLine))
{
sb.Append(" · ");
if (!string.IsNullOrEmpty(model.SecondaryEvidenceTitle))
{
sb.Append(model.SecondaryEvidenceTitle).Append(" · ");
}
sb.Append(model.SecondaryEvidenceLine);
}
return sb.ToString();
}
private static void RefreshAvatar(LifeSagaViewModel model)
{
if (model == null || model.UnitId == 0)
@ -337,9 +275,23 @@ public static class LifeSagaPanel
private static void Layout()
{
float colX = Pad + AvatarSize + ColGap;
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;
@ -353,15 +305,16 @@ public static class LifeSagaPanel
Hide(_titleText);
}
y += 2f;
bool hasStake = !string.IsNullOrEmpty(_stakeText.text);
if (hasStake)
// 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, textW, StakeFont),
EstimateWrapHeight(_stakeText.text, fullW, StakeFont),
LineH,
LineH * 3f);
PlaceText(_stakeText, colX, y, textW, stakeH);
PlaceText(_stakeText, fullX, y, fullW, stakeH);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
y += stakeH + 2f;
@ -380,11 +333,12 @@ public static class LifeSagaPanel
}
}
float yMax = BodyHeightMax - Pad;
if (castN > 0)
{
PlaceText(_castHead, colX, y, textW, SecLabelH);
PlaceText(_castHead, fullX, y, fullW, SecLabelH);
y += SecLabelH;
float castX = colX;
float castX = fullX;
float castRowH = CastFace + 18f;
for (int i = 0; i < CastSlots.Length; i++)
{
@ -407,43 +361,17 @@ public static class LifeSagaPanel
Hide(_castHead);
}
bool hasEvidence = !string.IsNullOrEmpty(_evidenceText.text);
bool hasLegacy = !string.IsNullOrEmpty(_legacyText.text);
bool loreActive = _loreBtn != null && _loreBtn.gameObject.activeSelf;
float loreReserve = loreActive ? LoreH + 2f : 0f;
float yMax = BodyHeightMax - Pad - loreReserve;
if (hasEvidence && y + SecLabelH + LineH <= yMax)
{
_evidenceHead.text = "Evidence";
PlaceText(_evidenceHead, colX, y, textW, SecLabelH);
y += SecLabelH;
float evidenceH = Mathf.Clamp(
EstimateWrapHeight(_evidenceText.text, textW, BodyFont),
LineH,
LineH * 3f);
evidenceH = Mathf.Min(evidenceH, Mathf.Max(LineH, yMax - y - (hasLegacy ? SecLabelH + LineH : 0f)));
PlaceText(_evidenceText, colX, y, textW, evidenceH);
_evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
_evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
y += evidenceH + 2f;
}
else
{
Hide(_evidenceHead);
Hide(_evidenceText);
}
if (hasLegacy && y + SecLabelH + LineH <= yMax)
{
PlaceText(_legacyHead, colX, y, textW, SecLabelH);
PlaceText(_legacyHead, fullX, y, fullW, SecLabelH);
y += SecLabelH;
float legacyH = Mathf.Clamp(
EstimateWrapHeight(_legacyText.text, textW, BodyFont),
EstimateWrapHeight(_legacyText.text, fullW, BodyFont),
LineH,
LineH * 3f);
LineH * 5f);
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
PlaceText(_legacyText, colX, y, textW, legacyH);
PlaceText(_legacyText, fullX, y, fullW, legacyH);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
y += legacyH + 2f;
@ -452,26 +380,10 @@ public static class LifeSagaPanel
{
Hide(_legacyHead);
Hide(_legacyText);
hasLegacy = false;
}
if (loreActive)
{
y = Mathf.Max(y, AvatarSize + Pad + 2f);
RectTransform loreRt = _loreBtn.GetComponent<RectTransform>();
loreRt.anchoredPosition = new Vector2(colX, -y);
loreRt.sizeDelta = new Vector2(72f, LoreH);
_loreBtn.transform.SetAsLastSibling();
if (_loreBtnLabel != null)
{
RectTransform labelRt = _loreBtnLabel.rectTransform;
labelRt.anchoredPosition = Vector2.zero;
labelRt.sizeDelta = new Vector2(72f, LoreH);
}
y += LoreH;
}
y = Mathf.Max(y + Pad, AvatarSize + Pad * 2f);
y = Mathf.Max(y + Pad, useAvatarCol ? AvatarSize + Pad * 2f : BodyHeightMin);
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
if (_rootRt != null)
@ -480,7 +392,7 @@ public static class LifeSagaPanel
}
LastLayoutDebug =
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' stake='{Short(_stakeText?.text)}' cast={castN} evidence={(hasEvidence ? 1 : 0)} legacy={(hasLegacy ? 1 : 0)}";
$"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)
@ -490,53 +402,58 @@ public static class LifeSagaPanel
return LineH;
}
string[] paragraphs = text.Split('\n');
float total = 0f;
float charsPerLine = Mathf.Max(8f, width / Mathf.Max(3.8f, fontSize * 0.55f));
int lines = Mathf.Max(1, Mathf.CeilToInt(text.Length / charsPerLine));
return lines * (fontSize + 2f);
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 rt = root.GetComponent<RectTransform>();
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
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);
Image bg = bgGo.GetComponent<Image>();
bg.color = HudTheme.InsetFill;
bg.raycastTarget = false;
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
faceGo.transform.SetParent(root.transform, false);
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
faceRt.pivot = new Vector2(0.5f, 0.5f);
faceRt.anchorMin = new Vector2(0f, 1f);
faceRt.anchorMax = new Vector2(0f, 1f);
faceRt.anchoredPosition = new Vector2(CastFace * 0.5f, -CastFace * 0.5f);
faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f);
faceGo.transform.SetParent(bgGo.transform, false);
Image face = faceGo.GetComponent<Image>();
face.color = Color.white;
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.Bold);
name.alignment = TextAnchor.UpperLeft;
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);
relation.alignment = TextAnchor.UpperLeft;
RectTransform relRt = relation.rectTransform;
relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f);
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
@ -574,6 +491,11 @@ public static class LifeSagaPanel
_speciesId = "";
_fingerprint = "empty-pick";
_ownsAvatar = false;
if (_eyebrowText != null)
{
_eyebrowText.text = "";
}
if (_nameText != null)
{
_nameText.text = "Pick a life";
@ -589,11 +511,6 @@ public static class LifeSagaPanel
_stakeText.text = "Hover or click a glyph to read their saga.";
}
if (_evidenceText != null)
{
_evidenceText.text = "";
}
if (_legacyText != null)
{
_legacyText.text = "";
@ -608,12 +525,6 @@ public static class LifeSagaPanel
}
}
if (_loreBtn != null)
{
_loreBtn.gameObject.SetActive(false);
_loreBtn.interactable = false;
}
DossierAvatar.SetHostVisible(false);
Layout();
if (_root != null)
@ -622,23 +533,6 @@ public static class LifeSagaPanel
}
}
/// <summary>Harness: invoke Open Lore for the bound saga life.</summary>
public static void HarnessOpenLore()
{
OpenLore();
}
private static void OpenLore()
{
if (_boundId == 0 || LifeSagaViewController.IsHoverPreview)
{
return;
}
LifeSagaViewController.StashLoreReturn(_boundId);
ChronicleHud.OpenUnitHistory(_boundId, pauseIdle: true, followFocus: false);
}
private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style)
{
int fitSize = size < 7 ? 7 : size;
@ -688,13 +582,11 @@ public static class LifeSagaPanel
}
}
private static string Fingerprint(LifeSagaViewModel model, bool loreInteractive)
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.StakeLine).Append('|').Append(model.EvidenceLine);
sb.Append('|').Append(model.SecondaryEvidenceLine);
sb.Append('|').Append(loreInteractive ? '1' : '0');
sb.Append('|').Append(model.LensEyebrow).Append('|').Append(model.StakeLine);
for (int i = 0; i < model.Cast.Count; i++)
{
var c = model.Cast[i];

View file

@ -56,7 +56,7 @@ public static class LifeSagaPresentation
model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is");
BuildCast(model, slot, actor, memory, primary);
BuildEvidence(model, primary, secondary, slot, actor, memory);
model.LensEyebrow = FormatLensEyebrow(primary, secondary);
BuildLegacy(model, unitId, stake);
return model;
}
@ -326,8 +326,13 @@ public static class LifeSagaPresentation
Add(LifeSagaLens.PackAlpha, 10f);
}
primary = LifeSagaLens.FounderWanderer;
primary = LifeSagaLens.None;
secondary = LifeSagaLens.None;
if (scores.Count == 0)
{
return;
}
float best = -1f;
float second = -1f;
foreach (KeyValuePair<LifeSagaLens, float> kv in scores)
@ -557,102 +562,35 @@ public static class LifeSagaPresentation
_ = slot;
}
private static void BuildEvidence(
LifeSagaViewModel model,
LifeSagaLens primary,
LifeSagaLens secondary,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
private static string FormatLensEyebrow(LifeSagaLens primary, LifeSagaLens secondary)
{
model.EvidenceTitle = EvidenceTitle(primary);
model.EvidenceLine = EvidenceLine(primary, slot, actor, memory);
if (secondary != LifeSagaLens.None)
string a = LensLabel(primary);
string b = LensLabel(secondary);
if (string.IsNullOrEmpty(a))
{
model.SecondaryEvidenceTitle = EvidenceTitle(secondary);
model.SecondaryEvidenceLine = EvidenceLine(secondary, slot, actor, memory);
return b;
}
if (string.IsNullOrEmpty(b) || string.Equals(a, b, StringComparison.Ordinal))
{
return a;
}
return a + " · " + b;
}
private static string EvidenceTitle(LifeSagaLens lens)
private static string LensLabel(LifeSagaLens lens)
{
switch (lens)
{
case LifeSagaLens.CrownClan: return "Realm";
case LifeSagaLens.CrownClan: return "Crown";
case LifeSagaLens.PackAlpha: return "Pack";
case LifeSagaLens.WarriorKiller: return "Combat";
case LifeSagaLens.Plotter: return "Intrigue";
case LifeSagaLens.FounderWanderer: return "Journey";
case LifeSagaLens.LoveGrief: return "Bond";
case LifeSagaLens.LastOfKind: return "Survival";
default: return "Evidence";
}
}
private static string EvidenceLine(
LifeSagaLens lens,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
{
switch (lens)
{
case LifeSagaLens.CrownClan:
{
string realm = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor));
int wars = CountFacts(memory, LifeSagaFactKind.WarJoin);
if (!string.IsNullOrEmpty(realm) && wars > 0)
{
return realm + " · " + wars + " war" + (wars == 1 ? "" : "s") + " observed";
}
return FirstNonEmpty(realm, "Holding power");
}
case LifeSagaLens.PackAlpha:
{
int children = CountFacts(memory, LifeSagaFactKind.ParentChild);
return children > 0
? "Pack line with " + children + " recorded offspring"
: "Leads the family pack";
}
case LifeSagaLens.WarriorKiller:
{
int kills = slot?.Kills ?? 0;
int encounters = CountFacts(memory, LifeSagaFactKind.CombatEncounter)
+ CountFacts(memory, LifeSagaFactKind.Kill);
if (kills > 0)
{
return kills + " kills · " + encounters + " remembered clashes";
}
return encounters > 0 ? encounters + " remembered clashes" : "A fighter's reputation";
}
case LifeSagaLens.Plotter:
{
int plots = CountFacts(memory, LifeSagaFactKind.PlotJoin);
return plots > 0 ? plots + " plot thread" + (plots == 1 ? "" : "s") : "Scheming still";
}
case LifeSagaLens.FounderWanderer:
{
int founded = CountFacts(memory, LifeSagaFactKind.Founding);
return founded > 0 ? "Founded " + founded + " lasting place" + (founded == 1 ? "" : "s")
: FirstNonEmpty(CityName(actor), "Building a place in the world");
}
case LifeSagaLens.LoveGrief:
{
int bonds = CountFacts(memory, LifeSagaFactKind.BondFormed);
int losses = CountFacts(memory, LifeSagaFactKind.BondBroken);
if (losses > 0)
{
return "Carrying " + losses + " observed loss" + (losses == 1 ? "" : "es");
}
return bonds > 0 ? "Bound " + bonds + " time" + (bonds == 1 ? "" : "s") : "A life shaped by attachment";
}
case LifeSagaLens.LastOfKind:
return "The last living thread of their kind";
default:
return "";
default: return "";
}
}
@ -701,25 +639,6 @@ public static class LifeSagaPresentation
}
}
private static int CountFacts(LifeSagaLifeMemory memory, LifeSagaFactKind kind)
{
if (memory == null)
{
return 0;
}
int n = 0;
for (int i = 0; i < memory.Facts.Count; i++)
{
if (memory.Facts[i] != null && memory.Facts[i].Kind == kind)
{
n++;
}
}
return n;
}
private static string KingdomName(Actor actor)
{
try { return actor?.kingdom?.name ?? ""; }
@ -810,10 +729,7 @@ public sealed class LifeSagaViewModel
public string StakeProvenance = "";
public LifeSagaLens PrimaryLens;
public LifeSagaLens SecondaryLens;
public string EvidenceTitle = "";
public string EvidenceLine = "";
public string SecondaryEvidenceTitle = "";
public string SecondaryEvidenceLine = "";
public string LensEyebrow = "";
public readonly List<LifeSagaCastMember> Cast = new List<LifeSagaCastMember>(4);
public readonly List<LifeSagaLegacyBeat> Legacy = new List<LifeSagaLegacyBeat>(5);
@ -825,6 +741,12 @@ public sealed class LifeSagaViewModel
sb.Append(Name);
}
if (!string.IsNullOrEmpty(LensEyebrow))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(LensEyebrow);
}
if (!string.IsNullOrEmpty(Title))
{
if (sb.Length > 0) sb.Append('\n');
@ -848,18 +770,6 @@ public sealed class LifeSagaViewModel
}
}
if (!string.IsNullOrEmpty(EvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(EvidenceTitle).Append(" ").Append(EvidenceLine);
}
if (!string.IsNullOrEmpty(SecondaryEvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(SecondaryEvidenceTitle).Append(" ").Append(SecondaryEvidenceLine);
}
for (int i = 0; i < Legacy.Count; i++)
{
if (sb.Length > 0) sb.Append('\n');

View file

@ -67,19 +67,9 @@ public static class LifeSagaRail
public static float RowHeightPx =>
Visible ? SlotSize : 0f;
/// <summary>True when the pointer is over any visible rail glyph.</summary>
public static bool IsPointerOverRail()
/// <summary>True when the pointer is over any visible rail glyph (not empty rail chrome).</summary>
public static bool IsPointerOverGlyph()
{
if (_row == null || !_row.activeInHierarchy || _rowRt == null)
{
return false;
}
if (RectTransformUtility.RectangleContainsScreenPoint(_rowRt, Input.mousePosition, null))
{
return true;
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i]?.Rt == null || !Slots[i].Root.activeInHierarchy)
@ -96,6 +86,22 @@ public static class LifeSagaRail
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; }
public static void EnsureBuilt(Transform parent)

View file

@ -9,7 +9,8 @@ namespace IdleSpectator;
public static class LifeSagaViewController
{
public const string ReadPauseOwner = "saga_hover";
public const float HoverExitGraceSeconds = 0.35f;
/// <summary>Only used if an exit was deferred; glyph leave finishes immediately.</summary>
public const float HoverExitGraceSeconds = 0.05f;
public enum Tab
{
@ -166,7 +167,15 @@ public static class LifeSagaViewController
return;
}
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
// Relayout / glyph→glyph can fire Exit while the pointer is still on a glyph.
// Keep the preview in that case; otherwise restore Dossier immediately.
if (LifeSagaRail.IsPointerOverGlyph())
{
_hoverExitAt = -1f;
return;
}
FinishHoverExit();
}
public static void Tick()
@ -176,17 +185,16 @@ public static class LifeSagaViewController
return;
}
// Keep preview alive while the pointer stays on the dossier/rail chrome.
if (WatchCaption.IsPointerOverPanel() || LifeSagaRail.IsPointerOverRail())
// Actively hovering (no exit armed) - keep preview until EndHoverPreview.
if (_hoverExitAt < 0f)
{
_hoverExitAt = -1f;
return;
}
// Pointer left chrome after a panel dwell cleared the timer - re-arm grace.
if (_hoverExitAt < 0f)
// Deferred exit was armed; cancel it if the pointer landed on another glyph.
if (LifeSagaRail.IsPointerOverGlyph())
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
_hoverExitAt = -1f;
return;
}
@ -221,35 +229,11 @@ public static class LifeSagaViewController
}
/// <summary>
/// Harness: simulate Tick clearing the exit timer while the pointer dwells on the panel.
/// </summary>
public static void HarnessSimulatePanelDwell()
{
if (_hoverPreviewId == 0)
{
return;
}
_hoverExitAt = -1f;
}
/// <summary>
/// Harness: run one hover Tick as if the pointer is off rail and panel chrome.
/// Harness: force-complete hover exit as if the pointer left every glyph.
/// </summary>
public static void HarnessTickExitAwayFromChrome()
{
if (_hoverPreviewId == 0)
{
return;
}
if (_hoverExitAt < 0f)
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
return;
}
if (Time.unscaledTime < _hoverExitAt)
if (_hoverPreviewId == 0 && !_pauseHeld)
{
return;
}

View file

@ -961,6 +961,11 @@ public static class WatchCaption
LifeSagaRail.Refresh();
RefreshTabChrome();
bool sagaMode = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga;
int traitCount = 0;
int statusCount = 0;
int historyCount = 0;
bool hasTask = false;
bool hasReason = false;
if (sagaMode)
{
RefreshSagaBody();
@ -968,21 +973,66 @@ public static class WatchCaption
else
{
LifeSagaPanel.SetVisible(false);
// Saga force-hides dossier rows and deactivates the reason GO. Restore from the
// bound snapshot before Relayout so hover-exit does not wait for a tip change.
RestoreDossierRowsAfterSaga();
traitCount = CountActiveTraitSlots();
statusCount = CountActiveStatusSlots();
historyCount = CountActiveHistorySlots();
hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
hasReason = (_current != null && !string.IsNullOrEmpty(_current.ReasonLine))
|| (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text));
}
bool hasReason = !sagaMode && _reasonText != null && _reasonText.gameObject.activeSelf;
bool hasTask = !sagaMode && _taskText != null && _taskText.gameObject.activeSelf;
bool hasBody = sagaMode
|| _boundActor != null
|| CountActiveHistorySlots() > 0
|| historyCount > 0
|| _current != null;
Relayout(
hasBody,
sagaMode ? 0 : CountActiveTraitSlots(),
sagaMode ? 0 : CountActiveStatusSlots(),
hasTask,
hasReason,
sagaMode ? 0 : CountActiveHistorySlots());
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
}
/// <summary>
/// Re-show trait/status/history/reason chrome after Saga hid them.
/// Uses the bound dossier snapshot so hover-exit is instant.
/// </summary>
private static void RestoreDossierRowsAfterSaga()
{
if (_current == null)
{
return;
}
ApplyTraitChips(_current);
ApplyStatusChips(_current);
FillHistory(_current.UnitId);
if (!string.IsNullOrEmpty(_current.TaskText))
{
ApplyTaskChip(_current.TaskText);
}
if (!string.IsNullOrEmpty(_current.ReasonLine))
{
ApplyReasonText(_current.ReasonLine, _current.ReasonRelatedId);
}
}
/// <summary>Harness: dossier nametag/reason visible after leaving Saga hover.</summary>
public static bool HarnessDossierChromeRestored()
{
if (_nameText == null || !_nameText.gameObject.activeSelf)
{
return false;
}
string line = _current != null ? (_current.ReasonLine ?? "") : "";
if (string.IsNullOrEmpty(line))
{
return true;
}
return _reasonText != null
&& _reasonText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_reasonText.text);
}
/// <summary>Orange reason line currently shown (or bound), for spine dedupe.</summary>
@ -1091,9 +1141,7 @@ public static class WatchCaption
EnsureBuilt();
LifeSagaPanel.EnsureBuilt(_root.transform);
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
bool lore = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
&& !LifeSagaViewController.IsHoverPreview;
LifeSagaPanel.Bind(model, lore);
LifeSagaPanel.Bind(model);
}
private static void RefreshTabChrome()
@ -1361,8 +1409,9 @@ public static class WatchCaption
UnitDossier.RefreshTopStatuses(_current, actor);
int statusCount = ApplyStatusChips(_current);
_lastStatusesFingerprint = fp;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
bool hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
bool hasReason = (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text))
|| (_current != null && !string.IsNullOrEmpty(_current.ReasonLine));
Relayout(
_current.UnitId != 0,
CountActiveTraitSlots(),
@ -1633,8 +1682,9 @@ public static class WatchCaption
int shown = FillHistory(id);
// Re-run layout only when history presence flips or line count changes.
bool hasBody = _current != null;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
bool hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
bool hasReason = (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text))
|| (_current != null && !string.IsNullOrEmpty(_current.ReasonLine));
Relayout(
hasBody,
CountActiveTraitSlots(),

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.29.48",
"version": "0.29.53",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -60,13 +60,15 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
- **Dossier** shows the watched unit dossier; **Saga** shows the adaptive saga panel for the watched MC when on the roster.
- If the watched unit is not an MC and nothing is pinned, Saga shows an empty *Pick a life from the rail* state (never a stale first-roster filler).
- Hovering a glyph temporarily previews that life in the Saga body and pauses camera switching.
- Leaving the rail (or panel after a dwell) re-arms a short grace, then restores the prior tab and releases the pause lease.
- Leaving the glyph restores the prior tab (usually Dossier) immediately and releases the pause lease.
- Hover-exit re-applies dossier reason/traits/status/history so the orange tip does not wait for a tip change.
- Moving onto the panel or empty rail chrome does not keep the preview - only glyph hover does.
- Relayout / glyph-to-glyph exits are ignored while the pointer is still over a glyph.
- Clicking a tab during hover cancels the preview immediately and applies that tab.
- Tabbed chrome locks a constant outer width so Dossier↔Saga hover cannot slide glyphs.
- Saga body matches Design A: identity, stake (accent wrap), Cast (face + name + relation), Evidence (lens module), Legacy, Open Lore.
- Open Lore from Saga stashes return-to-Saga; Lore close restores the Saga tab and subject when still an MC.
- Body height sizes to content up to a cap; lines wrap fully (no ellipsis truncation).
- Glyph hover keeps the preview while the pointer stays on the rail or dossier panel.
- Saga body is a story card: lens eyebrow + name + title beside the portrait; stake, Cast, and Legacy wrap full-width under it.
- No Evidence stats dump (kills/realm live on Dossier). No Open Lore button (press L).
- Body height sizes to content up to a cap; Legacy beats stack as separate lines (no · soup).
- Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax.
- Never show live task/status/trait rows or call a live attack target a rival.
- Earned rivals require memory evidence (repeated encounters, kin kills, war/plot opposition).