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:
parent
4b6c829bd0
commit
6fb62c47d6
10 changed files with 243 additions and 430 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue