feat(caption): enhance saga hover functionality and visual stability
- Introduce new anchors for caption avatar and narrative positioning - Implement stable typography capture during saga hover sessions - Add diagnostics for avatar visibility and anchored position - Refactor saga hover refresh logic to maintain visual consistency - Update harness scenarios to validate hover visual stability
This commit is contained in:
parent
12e2ba0d01
commit
96db725383
8 changed files with 236 additions and 54 deletions
|
|
@ -42,6 +42,9 @@ public static class AgentHarness
|
|||
private static string LastScreenshotPath = "";
|
||||
private static Vector2 _sagaPanelSizeAnchor;
|
||||
private static string _captionBeatAnchor = "";
|
||||
private static Vector2 _captionAvatarAnchor = Vector2.zero;
|
||||
private static float _captionBodyAnchorY;
|
||||
private static float _captionNarrativeAnchorY;
|
||||
private static readonly string[] PreferredSpawnAssets =
|
||||
{
|
||||
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
|
||||
|
|
@ -4171,6 +4174,7 @@ public static class AgentHarness
|
|||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
LifeSagaRail.Refresh();
|
||||
WatchCaption.RequestRelayout();
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
|
|
@ -4182,8 +4186,16 @@ public static class AgentHarness
|
|||
|
||||
case "caption_capture_beat":
|
||||
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
|
||||
_captionAvatarAnchor = DossierAvatar.HostAnchoredPosition;
|
||||
_captionBodyAnchorY = WatchCaption.LastBodyAnchorY;
|
||||
_captionNarrativeAnchorY = WatchCaption.LastNarrativeAnchorY;
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"beat='{_captionBeatAnchor}'");
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail:
|
||||
$"beat='{_captionBeatAnchor}' avatar={_captionAvatarAnchor} "
|
||||
+ $"anchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0}");
|
||||
break;
|
||||
|
||||
case "saga_show_hover_first":
|
||||
|
|
@ -4252,12 +4264,17 @@ public static class AgentHarness
|
|||
LifeSagaRail.Refresh();
|
||||
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
|
||||
LifeSagaRail.HarnessClick(0);
|
||||
int clicks = preferBefore ? 2 : 1;
|
||||
bool clicked = true;
|
||||
for (int i = 0; i < clicks; i++)
|
||||
{
|
||||
clicked = LifeSagaRail.HarnessClickUnit(focusId) && clicked;
|
||||
}
|
||||
WatchCaption.RequestRelayout();
|
||||
// Prefer toggle only - does not open hover / pin saga subject.
|
||||
bool preferAfter = LifeSagaRail.LastPreferPipVisible
|
||||
|| (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
|
||||
bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
|
||||
bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
|
||||
bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter;
|
||||
bool ok = clicked && focusPreferAfter;
|
||||
// After click, Prefer state on slot 0 should be readable.
|
||||
var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||
LifeSagaRoster.CopySlots(slots);
|
||||
|
|
@ -4275,7 +4292,12 @@ public static class AgentHarness
|
|||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"preferBefore={preferBefore} preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail:
|
||||
$"clicked={clicked} clicks={clicks} focus={focusId} preferBefore={preferBefore} "
|
||||
+ $"preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -10279,6 +10301,57 @@ public static class AgentHarness
|
|||
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
|
||||
break;
|
||||
}
|
||||
case "saga_hover_visual_stable":
|
||||
{
|
||||
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
||||
string hoverName = (model?.Name ?? "").Replace("★ ", "").Trim();
|
||||
|
||||
// Organic idle refresh: no layout invalidation. This catches visibility
|
||||
// ownership bugs hidden by the initial pointer-enter relayout.
|
||||
WatchCaption.HarnessRefreshSagaSteadyState();
|
||||
bool steadyPanel = LifeSagaPanel.Visible;
|
||||
bool steadyAvatar = DossierAvatar.HostVisible;
|
||||
|
||||
string beforeName = WatchCaption.VisibleNametagText;
|
||||
int beforeFont = WatchCaption.VisibleNametagFontSize;
|
||||
Vector2 beforeAvatar = DossierAvatar.HostAnchoredPosition;
|
||||
|
||||
// A second same-hover refresh used to overwrite the hovered name with the
|
||||
// camera headline, enlarge the font, and reveal the portrait before placement.
|
||||
WatchCaption.RequestRelayout();
|
||||
|
||||
string afterName = WatchCaption.VisibleNametagText;
|
||||
int afterFont = WatchCaption.VisibleNametagFontSize;
|
||||
Vector2 afterAvatar = DossierAvatar.HostAnchoredPosition;
|
||||
bool correctName = !string.IsNullOrEmpty(hoverName)
|
||||
&& beforeName.IndexOf(hoverName, StringComparison.Ordinal) >= 0
|
||||
&& afterName.IndexOf(hoverName, StringComparison.Ordinal) >= 0;
|
||||
bool fontStable = beforeFont == afterFont
|
||||
&& afterFont > 0
|
||||
&& afterFont <= WatchCaption.SagaHoverFontCeiling;
|
||||
bool avatarStable = DossierAvatar.HostVisible
|
||||
&& Vector2.Distance(beforeAvatar, afterAvatar) < 0.1f
|
||||
&& Vector2.Distance(_captionAvatarAnchor, afterAvatar) < 0.1f;
|
||||
bool narrativeBelowBody = _captionNarrativeAnchorY
|
||||
>= _captionBodyAnchorY + 39.9f;
|
||||
pass = LifeSagaViewController.IsHoverPreview
|
||||
&& LifeSagaPanel.Visible
|
||||
&& steadyPanel
|
||||
&& steadyAvatar
|
||||
&& correctName
|
||||
&& fontStable
|
||||
&& avatarStable
|
||||
&& narrativeBelowBody;
|
||||
detail =
|
||||
$"name='{beforeName}'/'{afterName}' hoverName='{hoverName}' "
|
||||
+ $"font={beforeFont}/{afterFont} ceiling={WatchCaption.SagaHoverFontCeiling} "
|
||||
+ $"avatar={_captionAvatarAnchor}/{beforeAvatar}/{afterAvatar} "
|
||||
+ $"dossierAnchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0} "
|
||||
+ $"hoverAnchor={WatchCaption.LastBodyAnchorY:0.0} below={narrativeBelowBody} "
|
||||
+ $"steady={steadyPanel}/{steadyAvatar} "
|
||||
+ $"visible={DossierAvatar.HostVisible} pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_mc_identity":
|
||||
{
|
||||
string idLine = WatchCaption.LastHeadline ?? "";
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ public static class DossierAvatar
|
|||
/// <summary>Fallen archive species-icon portrait is showing.</summary>
|
||||
public static bool ShowingSpeciesFallback => _shownActorId == -2;
|
||||
|
||||
/// <summary>Harness/UI diagnostic: portrait host is currently paintable.</summary>
|
||||
public static bool HostVisible =>
|
||||
_host != null && _host.gameObject.activeSelf;
|
||||
|
||||
/// <summary>Harness/UI diagnostic: final local placement within the dossier card.</summary>
|
||||
public static Vector2 HostAnchoredPosition =>
|
||||
_host != null ? _host.anchoredPosition : Vector2.zero;
|
||||
|
||||
/// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary>
|
||||
public static bool ContainsScreenPoint(Vector2 screenPoint)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3979,6 +3979,7 @@ internal static class HarnessScenarios
|
|||
Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander",
|
||||
expect: "harness_cast_relation"),
|
||||
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||
Step("sad11a", "saga_rail_refresh"),
|
||||
// saga_select_tab saga → hover preview of watched MC (compat).
|
||||
Step("sad11b", "caption_capture_beat"),
|
||||
Step("sad12", "saga_select_tab", value: "saga"),
|
||||
|
|
@ -3986,6 +3987,7 @@ internal static class HarnessScenarios
|
|||
Step("sad14", "assert", expect: "saga_panel_bound"),
|
||||
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad14c", "assert", expect: "caption_hover_keeps_beat"),
|
||||
Step("sad14c2", "assert", expect: "saga_hover_visual_stable"),
|
||||
Step("sad14d", "assert", expect: "saga_panel_cast_first"),
|
||||
Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"),
|
||||
Step("sad15", "screenshot", value: "saga-hover-depth"),
|
||||
|
|
|
|||
|
|
@ -629,6 +629,26 @@ public static class LifeSagaRail
|
|||
OnClick(index);
|
||||
}
|
||||
|
||||
/// <summary>Harness: click the visible slot for an exact unit instead of assuming slot 0.</summary>
|
||||
internal static bool HarnessClickUnit(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Slots.Length; i++)
|
||||
{
|
||||
if (Slots[i] != null && Slots[i].UnitId == unitId)
|
||||
{
|
||||
OnClick(i);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void OnClick(int index)
|
||||
{
|
||||
if (index < 0 || index >= Slots.Length)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,13 @@ public static class LifeSagaViewController
|
|||
return;
|
||||
}
|
||||
|
||||
if (_hoverPreviewId == 0)
|
||||
{
|
||||
// Capture the already-painted dossier typography before hover changes ownership
|
||||
// of the header. Every glyph in this hover session uses that same ceiling.
|
||||
WatchCaption.BeginSagaHoverSession();
|
||||
}
|
||||
|
||||
_hoverExitAt = -1f;
|
||||
_hoverPreviewId = unitId;
|
||||
WatchCaption.PrimeSagaHoverHeader(unitId);
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ public static class WatchCaption
|
|||
private static string _statusBanner = "";
|
||||
private static float _statusBannerUntil;
|
||||
private static string _lastLoggedCaption = "";
|
||||
// Saga hover owns the painted header without changing the camera dossier's semantic
|
||||
// LastHeadline. Keep its display text and entry typography separate from that cache.
|
||||
private static string _sagaHoverHeadline = "";
|
||||
private static int _sagaHoverFontCeiling;
|
||||
// OwnedEventReason performs stranger-name presentability checks against the living
|
||||
// population. A developed world makes that expensive, while the active director
|
||||
// candidate is normally unchanged for many 0.2-second caption refreshes.
|
||||
|
|
@ -175,6 +179,8 @@ public static class WatchCaption
|
|||
|
||||
public static string LastHeadline { get; private set; } = "";
|
||||
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
|
||||
public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0;
|
||||
public static int SagaHoverFontCeiling => _sagaHoverFontCeiling;
|
||||
|
||||
public static string LastDetail { get; private set; } = "";
|
||||
|
||||
|
|
@ -246,6 +252,10 @@ public static class WatchCaption
|
|||
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
|
||||
|
||||
public static bool LastLayoutOk { get; private set; }
|
||||
/// <summary>Harness/UI: top anchor shared by the dossier and Saga portrait.</summary>
|
||||
public static float LastBodyAnchorY { get; private set; }
|
||||
/// <summary>Harness/UI: first bottom narrative row after body/status/trait details.</summary>
|
||||
public static float LastNarrativeAnchorY { get; private set; }
|
||||
|
||||
/// <summary>True when the dossier card GameObject is active.</summary>
|
||||
public static bool Visible => _visible && _root != null && _root.activeSelf;
|
||||
|
|
@ -537,6 +547,8 @@ public static class WatchCaption
|
|||
_nextRoutineLiveRefreshAt = 0f;
|
||||
_nextRoutineSpineAt = 0f;
|
||||
_headlineLocked = false;
|
||||
_sagaHoverHeadline = "";
|
||||
_sagaHoverFontCeiling = 0;
|
||||
_historyColW = HistoryColMinW;
|
||||
_statusBanner = "";
|
||||
_statusBannerUntil = 0f;
|
||||
|
|
@ -940,6 +952,18 @@ public static class WatchCaption
|
|||
RefreshStorySpine(force: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture stable typography before Saga hover takes temporary ownership of the
|
||||
/// painted header. Switching between glyphs must not make the name pulse larger.
|
||||
/// </summary>
|
||||
public static void BeginSagaHoverSession()
|
||||
{
|
||||
_sagaHoverHeadline = "";
|
||||
_sagaHoverFontCeiling = _nameText != null
|
||||
? Mathf.Clamp(_nameText.fontSize, 7, 10)
|
||||
: 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
|
||||
/// immediate even when the deeper saga model has several relationships to resolve.
|
||||
|
|
@ -968,7 +992,7 @@ public static class WatchCaption
|
|||
SpeciesId = slot?.SpeciesId
|
||||
?? (actor != null && actor.asset != null ? actor.asset.id : "")
|
||||
};
|
||||
ApplySagaHoverHeader(model);
|
||||
ApplySagaHoverHeader(model, deferPortraitReveal: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -986,6 +1010,15 @@ public static class WatchCaption
|
|||
RefreshOwnedReason();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: exercise the periodic same-hover refresh path without marking geometry dirty.
|
||||
/// Cast/Legacy and portrait must remain visible when Relayout correctly has no work.
|
||||
/// </summary>
|
||||
public static void HarnessRefreshSagaSteadyState()
|
||||
{
|
||||
RefreshStorySpine(force: true);
|
||||
}
|
||||
|
||||
private static bool HasStatusBanner()
|
||||
{
|
||||
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
||||
|
|
@ -1632,7 +1665,9 @@ public static class WatchCaption
|
|||
LifeSagaPanel.Bind(model);
|
||||
}
|
||||
|
||||
private static void ApplySagaHoverHeader(LifeSagaViewModel model)
|
||||
private static void ApplySagaHoverHeader(
|
||||
LifeSagaViewModel model,
|
||||
bool deferPortraitReveal = false)
|
||||
{
|
||||
if (model == null || model.UnitId == 0)
|
||||
{
|
||||
|
|
@ -1656,11 +1691,12 @@ public static class WatchCaption
|
|||
string headline = !string.IsNullOrEmpty(title)
|
||||
? name + " · " + title
|
||||
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
|
||||
_sagaHoverHeadline = headline.Length > 48
|
||||
? CaptionComposer.TruncateIdentity(headline, 48)
|
||||
: headline;
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = headline.Length > 48
|
||||
? CaptionComposer.TruncateIdentity(headline, 48)
|
||||
: headline;
|
||||
_nameText.text = _sagaHoverHeadline;
|
||||
}
|
||||
|
||||
if (_speciesIcon != null)
|
||||
|
|
@ -1692,6 +1728,13 @@ public static class WatchCaption
|
|||
_sexIcon.gameObject.SetActive(sexSprite != null);
|
||||
}
|
||||
|
||||
// The pointer-enter prime stages the new portrait while hidden. Periodic same-hover
|
||||
// refreshes must preserve its current visibility because unchanged geometry correctly
|
||||
// skips Relayout.
|
||||
if (deferPortraitReveal)
|
||||
{
|
||||
DossierAvatar.SetHostVisible(false);
|
||||
}
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
DossierAvatar.Show(actor);
|
||||
|
|
@ -1700,7 +1743,6 @@ public static class WatchCaption
|
|||
{
|
||||
DossierAvatar.ShowSpecies(model.SpeciesId);
|
||||
}
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
|
||||
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
|
||||
}
|
||||
|
|
@ -3130,6 +3172,8 @@ public static class WatchCaption
|
|||
bool hasReason,
|
||||
int historyCount)
|
||||
{
|
||||
LastBodyAnchorY = 0f;
|
||||
LastNarrativeAnchorY = 0f;
|
||||
float headerW = MeasureHeaderWidth(hasTask);
|
||||
// Traits beside avatar only when history and statuses leave the body-right slot free.
|
||||
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
|
||||
|
|
@ -3218,37 +3262,6 @@ public static class WatchCaption
|
|||
|
||||
if (!panelMode)
|
||||
{
|
||||
bool hasConnection = _connectionText != null
|
||||
&& _connectionText.gameObject.activeSelf
|
||||
&& !string.IsNullOrEmpty(_connectionText.text);
|
||||
if (hasConnection)
|
||||
{
|
||||
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
|
||||
y += ConnectionH + Gap;
|
||||
}
|
||||
|
||||
// Narrative header order: Identity → Connection → Beat → Context. The event that
|
||||
// explains the shot must be readable before portrait, history, status, and traits.
|
||||
if (hasReason)
|
||||
{
|
||||
float reasonH = MeasureReasonHeight();
|
||||
PlaceLine(
|
||||
_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null,
|
||||
y,
|
||||
reasonH,
|
||||
PadX);
|
||||
y += reasonH + Gap;
|
||||
}
|
||||
|
||||
bool hasStorySpine = _storySpineText != null
|
||||
&& _storySpineText.gameObject.activeSelf
|
||||
&& !string.IsNullOrEmpty(_storySpineText.text);
|
||||
if (hasStorySpine)
|
||||
{
|
||||
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
|
||||
y += StorySpineH + Gap;
|
||||
}
|
||||
|
||||
// Row visibility is owned by Place*/fill - never leave empty rows active at stale
|
||||
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
|
||||
if (historyCount <= 0 && _historyCol != null)
|
||||
|
|
@ -3268,6 +3281,7 @@ public static class WatchCaption
|
|||
|
||||
if (hasBody)
|
||||
{
|
||||
LastBodyAnchorY = y;
|
||||
float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH;
|
||||
PlaceBody(y, historyCount, bodyH);
|
||||
if (historyCount > 0)
|
||||
|
|
@ -3298,14 +3312,50 @@ public static class WatchCaption
|
|||
WidenPanelForChipRow(traitsUsed);
|
||||
y += TraitsH + Gap;
|
||||
}
|
||||
|
||||
// Keep the portrait/body anchor invariant between Dossier and Saga hover.
|
||||
// Connection, orange Beat, and Context explain the shot after the character
|
||||
// details instead of inserting a variable-height gap above the portrait.
|
||||
LastNarrativeAnchorY = y;
|
||||
bool hasConnection = _connectionText != null
|
||||
&& _connectionText.gameObject.activeSelf
|
||||
&& !string.IsNullOrEmpty(_connectionText.text);
|
||||
if (hasConnection)
|
||||
{
|
||||
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
|
||||
y += ConnectionH + Gap;
|
||||
}
|
||||
|
||||
if (hasReason)
|
||||
{
|
||||
float reasonH = MeasureReasonHeight();
|
||||
PlaceLine(
|
||||
_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null,
|
||||
y,
|
||||
reasonH,
|
||||
PadX);
|
||||
y += reasonH + Gap;
|
||||
}
|
||||
|
||||
bool hasStorySpine = _storySpineText != null
|
||||
&& _storySpineText.gameObject.activeSelf
|
||||
&& !string.IsNullOrEmpty(_storySpineText.text);
|
||||
if (hasStorySpine)
|
||||
{
|
||||
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
|
||||
y += StorySpineH + Gap;
|
||||
}
|
||||
}
|
||||
|
||||
if (panelMode)
|
||||
{
|
||||
LifeSagaPanel.EnsureBuilt(_root.transform);
|
||||
DossierAvatar.SetHostVisible(false);
|
||||
LastBodyAnchorY = y;
|
||||
LastNarrativeAnchorY = 0f;
|
||||
DossierAvatar.Place(PadX, y, LiveMax);
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
y = LifeSagaPanel.Place(y, PadX);
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
if (railChrome)
|
||||
{
|
||||
_panelWidth = PanelWidthMax;
|
||||
|
|
@ -3605,8 +3655,14 @@ public static class WatchCaption
|
|||
maxW = Mathf.Max(NameMinW, maxW);
|
||||
_nameText.resizeTextForBestFit = false;
|
||||
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
_nameText.fontSize = 10;
|
||||
string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? "");
|
||||
bool hover = LifeSagaViewController.IsHoverPreview;
|
||||
int baseFontSize = hover && _sagaHoverFontCeiling > 0
|
||||
? Mathf.Clamp(_sagaHoverFontCeiling, 7, 10)
|
||||
: 10;
|
||||
_nameText.fontSize = baseFontSize;
|
||||
string full = hover && !string.IsNullOrEmpty(_sagaHoverHeadline)
|
||||
? _sagaHoverHeadline
|
||||
: (!string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""));
|
||||
if (string.IsNullOrEmpty(full))
|
||||
{
|
||||
_nameText.text = "";
|
||||
|
|
@ -3620,7 +3676,10 @@ public static class WatchCaption
|
|||
return Mathf.Clamp(natural, NameMinW, maxW);
|
||||
}
|
||||
|
||||
int fittedSize = Mathf.Clamp(Mathf.FloorToInt(10f * maxW / natural), 7, 10);
|
||||
int fittedSize = Mathf.Clamp(
|
||||
Mathf.FloorToInt(baseFontSize * maxW / natural),
|
||||
7,
|
||||
baseFontSize);
|
||||
_nameText.fontSize = fittedSize;
|
||||
_nameText.text = full;
|
||||
_nameText.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ validation window is pending.
|
|||
- Civilized families use `Family` / `Kin`; `Pack` / `Packmate` / `Pack Alpha` are reserved for
|
||||
non-civilized creatures. WorldBox's shared Family/alpha storage is never exposed as human pack
|
||||
terminology.
|
||||
- Saga hover primes the new identity immediately and reads sex directly from the hovered actor.
|
||||
- Saga hover primes the new identity immediately, reads sex directly from the hovered actor, keeps
|
||||
the entry font ceiling, and reveals the staged portrait only after its fixed layout is placed;
|
||||
steady-state refresh never hides already-placed hover chrome.
|
||||
It no longer invokes the full dossier/species-population scan before the visible name swap.
|
||||
|
||||
## Product outcome
|
||||
|
|
@ -198,7 +200,8 @@ Recognition should come from repeated, stable signals rather than extra prose on
|
|||
When the camera follows a non-MC because of an MC connection, the viewer must see that connection
|
||||
immediately.
|
||||
|
||||
- Add one muted, single-line **Connection** row directly below Identity and above the orange Beat:
|
||||
- Add one muted, single-line **Connection** row at the start of the bottom narrative group,
|
||||
directly above the orange Beat:
|
||||
|
||||
```text
|
||||
Identity Omya (Human)
|
||||
|
|
@ -214,7 +217,8 @@ immediately.
|
|||
- When an event's exact named participant has a typed relation to a current MC and that relation is
|
||||
not already stated, Beat may add one compact qualifier: `Kills Omya (child of Waf)`. This explains
|
||||
significance without turning the focused character's Connection into duplicate prose.
|
||||
- Render Identity → Connection → Beat → Context before portrait/history and detail chips.
|
||||
- Render Identity → portrait/history → status/trait details → Connection → Beat → Context.
|
||||
Bottom-grouping the narrative rows keeps the portrait anchored when Saga hover hides them.
|
||||
- Prefer the clearest confirmed relation: `Waf's partner`, `Waf's daughter`, `Waf's father`,
|
||||
`Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
|
||||
`Caught in Waf's city`.
|
||||
|
|
@ -327,7 +331,7 @@ refill budget.
|
|||
6. Bind and relayout the rail only when its ordered unit-id sequence or visible state actually
|
||||
changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits
|
||||
every frame.
|
||||
7. Add the one-line Connection slot to the camera dossier between Identity and Beat.
|
||||
7. Add the one-line Connection slot above Beat in the bottom narrative group.
|
||||
8. Measure maximum authored Connection strings at supported UI scales and keep them to one
|
||||
ellipsized line.
|
||||
9. Include Connection identity in the dossier fingerprint so focus changes update atomically
|
||||
|
|
|
|||
|
|
@ -96,8 +96,9 @@ Outside hover, caption Identity/Beat/Context track camera focus. During Saga hov
|
|||
primary nametag temporarily binds to the hovered MC (species, name/title, sex, favorite),
|
||||
while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps
|
||||
running underneath and restores immediately when hover ends.
|
||||
Outside hover, the visible order is Identity, Connection, Beat, Context, then portrait/history and
|
||||
status/trait details, so the reason for the current shot is never stranded below biography chrome.
|
||||
Outside hover, the visible order is Identity, portrait/history, status/trait details, then
|
||||
Connection, Beat, and Context. Narrative explanation remains grouped at the bottom while the
|
||||
portrait keeps the same body anchor used by Saga hover.
|
||||
|
||||
## Rail UX
|
||||
|
||||
|
|
@ -111,6 +112,10 @@ status/trait details, so the reason for the current shot is never stranded below
|
|||
- Hovering a glyph opens Cast/Legacy depth (`LifeSagaPanel`) and pauses camera switching.
|
||||
- Leaving the glyph hides the panel, restores dossier body rows, and releases the pause lease.
|
||||
- Hover temporarily swaps the primary nametag to the hovered life; it does not change camera focus.
|
||||
- The hovered name, font, portrait, and Cast/Legacy body commit as one visual transition. The name
|
||||
keeps the dossier's entry font ceiling for the full hover session, and pointer-enter stages the
|
||||
portrait hidden until its fixed position is assigned. Steady-state refresh preserves visibility
|
||||
when unchanged geometry correctly skips relayout.
|
||||
- The orange camera Beat and Context are hidden during hover rather than attributed to the hovered life.
|
||||
- Hover restores the hovered character's live stone-frame dossier portrait beside the panel
|
||||
(species fallback only when no living actor is available).
|
||||
|
|
@ -198,10 +203,14 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
|
|||
- Live portrait tile refresh is ~5 Hz; rail live sprites are only for the watched glyph (others use species icons).
|
||||
- Hover identity is primed before Cast/Legacy binding and uses direct actor sex metadata; it does
|
||||
not call the population-counting dossier builder.
|
||||
- Hover fitting reads its own display headline rather than the camera dossier's cached identity;
|
||||
repeated relayouts therefore cannot briefly restore the wrong name or enlarge the font.
|
||||
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
|
||||
- Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel.
|
||||
- RelationEvidence caches invalidate on memory revision + live lover/friend change.
|
||||
- Hitch probe: harness `hitch_probe` / load save via `load_save_slot` (slot 2 = Box of Magic). Optional `.harness/autoload_slot`.
|
||||
- Hitch probe: run harness scenario `narrative_scheduler_health` (it enables the
|
||||
`hitch_probe` action) / load save via `load_save_slot` (slot 2 = Box of Magic).
|
||||
Optional `.harness/autoload_slot`.
|
||||
|
||||
## Session
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue