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 string LastScreenshotPath = "";
|
||||||
private static Vector2 _sagaPanelSizeAnchor;
|
private static Vector2 _sagaPanelSizeAnchor;
|
||||||
private static string _captionBeatAnchor = "";
|
private static string _captionBeatAnchor = "";
|
||||||
|
private static Vector2 _captionAvatarAnchor = Vector2.zero;
|
||||||
|
private static float _captionBodyAnchorY;
|
||||||
|
private static float _captionNarrativeAnchorY;
|
||||||
private static readonly string[] PreferredSpawnAssets =
|
private static readonly string[] PreferredSpawnAssets =
|
||||||
{
|
{
|
||||||
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
|
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
|
||||||
|
|
@ -4171,6 +4174,7 @@ public static class AgentHarness
|
||||||
{
|
{
|
||||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||||
LifeSagaRail.Refresh();
|
LifeSagaRail.Refresh();
|
||||||
|
WatchCaption.RequestRelayout();
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
Emit(
|
Emit(
|
||||||
cmd,
|
cmd,
|
||||||
|
|
@ -4182,8 +4186,16 @@ public static class AgentHarness
|
||||||
|
|
||||||
case "caption_capture_beat":
|
case "caption_capture_beat":
|
||||||
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
|
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
|
||||||
|
_captionAvatarAnchor = DossierAvatar.HostAnchoredPosition;
|
||||||
|
_captionBodyAnchorY = WatchCaption.LastBodyAnchorY;
|
||||||
|
_captionNarrativeAnchorY = WatchCaption.LastNarrativeAnchorY;
|
||||||
_cmdOk++;
|
_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;
|
break;
|
||||||
|
|
||||||
case "saga_show_hover_first":
|
case "saga_show_hover_first":
|
||||||
|
|
@ -4252,12 +4264,17 @@ public static class AgentHarness
|
||||||
LifeSagaRail.Refresh();
|
LifeSagaRail.Refresh();
|
||||||
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||||
bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
|
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();
|
WatchCaption.RequestRelayout();
|
||||||
// Prefer toggle only - does not open hover / pin saga subject.
|
// Prefer toggle only - does not open hover / pin saga subject.
|
||||||
bool preferAfter = LifeSagaRail.LastPreferPipVisible
|
bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
|
||||||
|| (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
|
bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter;
|
||||||
bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
|
bool ok = clicked && focusPreferAfter;
|
||||||
// After click, Prefer state on slot 0 should be readable.
|
// After click, Prefer state on slot 0 should be readable.
|
||||||
var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||||
LifeSagaRoster.CopySlots(slots);
|
LifeSagaRoster.CopySlots(slots);
|
||||||
|
|
@ -4275,7 +4292,12 @@ public static class AgentHarness
|
||||||
_cmdFail++;
|
_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;
|
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}";
|
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
|
||||||
break;
|
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":
|
case "caption_mc_identity":
|
||||||
{
|
{
|
||||||
string idLine = WatchCaption.LastHeadline ?? "";
|
string idLine = WatchCaption.LastHeadline ?? "";
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ public static class DossierAvatar
|
||||||
/// <summary>Fallen archive species-icon portrait is showing.</summary>
|
/// <summary>Fallen archive species-icon portrait is showing.</summary>
|
||||||
public static bool ShowingSpeciesFallback => _shownActorId == -2;
|
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>
|
/// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary>
|
||||||
public static bool ContainsScreenPoint(Vector2 screenPoint)
|
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",
|
Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander",
|
||||||
expect: "harness_cast_relation"),
|
expect: "harness_cast_relation"),
|
||||||
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
|
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||||
|
Step("sad11a", "saga_rail_refresh"),
|
||||||
// saga_select_tab saga → hover preview of watched MC (compat).
|
// saga_select_tab saga → hover preview of watched MC (compat).
|
||||||
Step("sad11b", "caption_capture_beat"),
|
Step("sad11b", "caption_capture_beat"),
|
||||||
Step("sad12", "saga_select_tab", value: "saga"),
|
Step("sad12", "saga_select_tab", value: "saga"),
|
||||||
|
|
@ -3986,6 +3987,7 @@ internal static class HarnessScenarios
|
||||||
Step("sad14", "assert", expect: "saga_panel_bound"),
|
Step("sad14", "assert", expect: "saga_panel_bound"),
|
||||||
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
|
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
|
||||||
Step("sad14c", "assert", expect: "caption_hover_keeps_beat"),
|
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("sad14d", "assert", expect: "saga_panel_cast_first"),
|
||||||
Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"),
|
Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"),
|
||||||
Step("sad15", "screenshot", value: "saga-hover-depth"),
|
Step("sad15", "screenshot", value: "saga-hover-depth"),
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,26 @@ public static class LifeSagaRail
|
||||||
OnClick(index);
|
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)
|
private static void OnClick(int index)
|
||||||
{
|
{
|
||||||
if (index < 0 || index >= Slots.Length)
|
if (index < 0 || index >= Slots.Length)
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,13 @@ public static class LifeSagaViewController
|
||||||
return;
|
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;
|
_hoverExitAt = -1f;
|
||||||
_hoverPreviewId = unitId;
|
_hoverPreviewId = unitId;
|
||||||
WatchCaption.PrimeSagaHoverHeader(unitId);
|
WatchCaption.PrimeSagaHoverHeader(unitId);
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,10 @@ public static class WatchCaption
|
||||||
private static string _statusBanner = "";
|
private static string _statusBanner = "";
|
||||||
private static float _statusBannerUntil;
|
private static float _statusBannerUntil;
|
||||||
private static string _lastLoggedCaption = "";
|
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
|
// OwnedEventReason performs stranger-name presentability checks against the living
|
||||||
// population. A developed world makes that expensive, while the active director
|
// population. A developed world makes that expensive, while the active director
|
||||||
// candidate is normally unchanged for many 0.2-second caption refreshes.
|
// 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 LastHeadline { get; private set; } = "";
|
||||||
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
|
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; } = "";
|
public static string LastDetail { get; private set; } = "";
|
||||||
|
|
||||||
|
|
@ -246,6 +252,10 @@ public static class WatchCaption
|
||||||
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
|
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
|
||||||
|
|
||||||
public static bool LastLayoutOk { get; private set; }
|
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>
|
/// <summary>True when the dossier card GameObject is active.</summary>
|
||||||
public static bool Visible => _visible && _root != null && _root.activeSelf;
|
public static bool Visible => _visible && _root != null && _root.activeSelf;
|
||||||
|
|
@ -537,6 +547,8 @@ public static class WatchCaption
|
||||||
_nextRoutineLiveRefreshAt = 0f;
|
_nextRoutineLiveRefreshAt = 0f;
|
||||||
_nextRoutineSpineAt = 0f;
|
_nextRoutineSpineAt = 0f;
|
||||||
_headlineLocked = false;
|
_headlineLocked = false;
|
||||||
|
_sagaHoverHeadline = "";
|
||||||
|
_sagaHoverFontCeiling = 0;
|
||||||
_historyColW = HistoryColMinW;
|
_historyColW = HistoryColMinW;
|
||||||
_statusBanner = "";
|
_statusBanner = "";
|
||||||
_statusBannerUntil = 0f;
|
_statusBannerUntil = 0f;
|
||||||
|
|
@ -940,6 +952,18 @@ public static class WatchCaption
|
||||||
RefreshStorySpine(force: true);
|
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>
|
/// <summary>
|
||||||
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
|
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
|
||||||
/// immediate even when the deeper saga model has several relationships to resolve.
|
/// immediate even when the deeper saga model has several relationships to resolve.
|
||||||
|
|
@ -968,7 +992,7 @@ public static class WatchCaption
|
||||||
SpeciesId = slot?.SpeciesId
|
SpeciesId = slot?.SpeciesId
|
||||||
?? (actor != null && actor.asset != null ? actor.asset.id : "")
|
?? (actor != null && actor.asset != null ? actor.asset.id : "")
|
||||||
};
|
};
|
||||||
ApplySagaHoverHeader(model);
|
ApplySagaHoverHeader(model, deferPortraitReveal: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -986,6 +1010,15 @@ public static class WatchCaption
|
||||||
RefreshOwnedReason();
|
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()
|
private static bool HasStatusBanner()
|
||||||
{
|
{
|
||||||
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
||||||
|
|
@ -1632,7 +1665,9 @@ public static class WatchCaption
|
||||||
LifeSagaPanel.Bind(model);
|
LifeSagaPanel.Bind(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplySagaHoverHeader(LifeSagaViewModel model)
|
private static void ApplySagaHoverHeader(
|
||||||
|
LifeSagaViewModel model,
|
||||||
|
bool deferPortraitReveal = false)
|
||||||
{
|
{
|
||||||
if (model == null || model.UnitId == 0)
|
if (model == null || model.UnitId == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1656,11 +1691,12 @@ public static class WatchCaption
|
||||||
string headline = !string.IsNullOrEmpty(title)
|
string headline = !string.IsNullOrEmpty(title)
|
||||||
? name + " · " + title
|
? name + " · " + title
|
||||||
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
|
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
|
||||||
if (_nameText != null)
|
_sagaHoverHeadline = headline.Length > 48
|
||||||
{
|
|
||||||
_nameText.text = headline.Length > 48
|
|
||||||
? CaptionComposer.TruncateIdentity(headline, 48)
|
? CaptionComposer.TruncateIdentity(headline, 48)
|
||||||
: headline;
|
: headline;
|
||||||
|
if (_nameText != null)
|
||||||
|
{
|
||||||
|
_nameText.text = _sagaHoverHeadline;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_speciesIcon != null)
|
if (_speciesIcon != null)
|
||||||
|
|
@ -1692,6 +1728,13 @@ public static class WatchCaption
|
||||||
_sexIcon.gameObject.SetActive(sexSprite != null);
|
_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())
|
if (actor != null && actor.isAlive())
|
||||||
{
|
{
|
||||||
DossierAvatar.Show(actor);
|
DossierAvatar.Show(actor);
|
||||||
|
|
@ -1700,7 +1743,6 @@ public static class WatchCaption
|
||||||
{
|
{
|
||||||
DossierAvatar.ShowSpecies(model.SpeciesId);
|
DossierAvatar.ShowSpecies(model.SpeciesId);
|
||||||
}
|
}
|
||||||
DossierAvatar.SetHostVisible(true);
|
|
||||||
|
|
||||||
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
|
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
|
||||||
}
|
}
|
||||||
|
|
@ -3130,6 +3172,8 @@ public static class WatchCaption
|
||||||
bool hasReason,
|
bool hasReason,
|
||||||
int historyCount)
|
int historyCount)
|
||||||
{
|
{
|
||||||
|
LastBodyAnchorY = 0f;
|
||||||
|
LastNarrativeAnchorY = 0f;
|
||||||
float headerW = MeasureHeaderWidth(hasTask);
|
float headerW = MeasureHeaderWidth(hasTask);
|
||||||
// Traits beside avatar only when history and statuses leave the body-right slot free.
|
// Traits beside avatar only when history and statuses leave the body-right slot free.
|
||||||
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
|
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
|
||||||
|
|
@ -3218,37 +3262,6 @@ public static class WatchCaption
|
||||||
|
|
||||||
if (!panelMode)
|
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
|
// 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).
|
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
|
||||||
if (historyCount <= 0 && _historyCol != null)
|
if (historyCount <= 0 && _historyCol != null)
|
||||||
|
|
@ -3268,6 +3281,7 @@ public static class WatchCaption
|
||||||
|
|
||||||
if (hasBody)
|
if (hasBody)
|
||||||
{
|
{
|
||||||
|
LastBodyAnchorY = y;
|
||||||
float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH;
|
float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH;
|
||||||
PlaceBody(y, historyCount, bodyH);
|
PlaceBody(y, historyCount, bodyH);
|
||||||
if (historyCount > 0)
|
if (historyCount > 0)
|
||||||
|
|
@ -3298,14 +3312,50 @@ public static class WatchCaption
|
||||||
WidenPanelForChipRow(traitsUsed);
|
WidenPanelForChipRow(traitsUsed);
|
||||||
y += TraitsH + Gap;
|
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)
|
if (panelMode)
|
||||||
{
|
{
|
||||||
LifeSagaPanel.EnsureBuilt(_root.transform);
|
LifeSagaPanel.EnsureBuilt(_root.transform);
|
||||||
|
DossierAvatar.SetHostVisible(false);
|
||||||
|
LastBodyAnchorY = y;
|
||||||
|
LastNarrativeAnchorY = 0f;
|
||||||
DossierAvatar.Place(PadX, y, LiveMax);
|
DossierAvatar.Place(PadX, y, LiveMax);
|
||||||
DossierAvatar.SetHostVisible(true);
|
|
||||||
y = LifeSagaPanel.Place(y, PadX);
|
y = LifeSagaPanel.Place(y, PadX);
|
||||||
|
DossierAvatar.SetHostVisible(true);
|
||||||
if (railChrome)
|
if (railChrome)
|
||||||
{
|
{
|
||||||
_panelWidth = PanelWidthMax;
|
_panelWidth = PanelWidthMax;
|
||||||
|
|
@ -3605,8 +3655,14 @@ public static class WatchCaption
|
||||||
maxW = Mathf.Max(NameMinW, maxW);
|
maxW = Mathf.Max(NameMinW, maxW);
|
||||||
_nameText.resizeTextForBestFit = false;
|
_nameText.resizeTextForBestFit = false;
|
||||||
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||||
_nameText.fontSize = 10;
|
bool hover = LifeSagaViewController.IsHoverPreview;
|
||||||
string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? "");
|
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))
|
if (string.IsNullOrEmpty(full))
|
||||||
{
|
{
|
||||||
_nameText.text = "";
|
_nameText.text = "";
|
||||||
|
|
@ -3620,7 +3676,10 @@ public static class WatchCaption
|
||||||
return Mathf.Clamp(natural, NameMinW, maxW);
|
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.fontSize = fittedSize;
|
||||||
_nameText.text = full;
|
_nameText.text = full;
|
||||||
_nameText.verticalOverflow = VerticalWrapMode.Truncate;
|
_nameText.verticalOverflow = VerticalWrapMode.Truncate;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ validation window is pending.
|
||||||
- Civilized families use `Family` / `Kin`; `Pack` / `Packmate` / `Pack Alpha` are reserved for
|
- 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
|
non-civilized creatures. WorldBox's shared Family/alpha storage is never exposed as human pack
|
||||||
terminology.
|
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.
|
It no longer invokes the full dossier/species-population scan before the visible name swap.
|
||||||
|
|
||||||
## Product outcome
|
## 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
|
When the camera follows a non-MC because of an MC connection, the viewer must see that connection
|
||||||
immediately.
|
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
|
```text
|
||||||
Identity Omya (Human)
|
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
|
- 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
|
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.
|
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`,
|
- 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
|
`Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
|
||||||
`Caught in Waf's city`.
|
`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
|
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
|
changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits
|
||||||
every frame.
|
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
|
8. Measure maximum authored Connection strings at supported UI scales and keep them to one
|
||||||
ellipsized line.
|
ellipsized line.
|
||||||
9. Include Connection identity in the dossier fingerprint so focus changes update atomically
|
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),
|
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
|
while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps
|
||||||
running underneath and restores immediately when hover ends.
|
running underneath and restores immediately when hover ends.
|
||||||
Outside hover, the visible order is Identity, Connection, Beat, Context, then portrait/history and
|
Outside hover, the visible order is Identity, portrait/history, status/trait details, then
|
||||||
status/trait details, so the reason for the current shot is never stranded below biography chrome.
|
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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
- Hover restores the hovered character's live stone-frame dossier portrait beside the panel
|
||||||
(species fallback only when no living actor is available).
|
(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).
|
- 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
|
- Hover identity is primed before Cast/Legacy binding and uses direct actor sex metadata; it does
|
||||||
not call the population-counting dossier builder.
|
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.
|
- 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.
|
- 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.
|
- 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
|
## Session
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue