feat(saga): enhance hover functionality and ownership management
- Introduce new hover command for second unit in saga scrubbing - Implement logic to manage hover ownership and prevent visual conflicts - Update WatchCaption to track blocked dossier writes during hover - Refactor LifeSagaViewController to maintain hover session integrity - Enhance harness scenarios to validate new hover behaviors and ownership checks
This commit is contained in:
parent
529d18dfa4
commit
1bbe642e5b
6 changed files with 210 additions and 24 deletions
|
|
@ -4280,6 +4280,99 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "saga_scrub_hover_second":
|
||||
{
|
||||
LifeSagaRail.Refresh();
|
||||
if (!LifeSagaViewController.IsHoverPreview)
|
||||
{
|
||||
LifeSagaRail.ShowHover(0);
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
long firstId = LifeSagaViewController.HoverPreviewId;
|
||||
string firstName = WatchCaption.VisibleNametagText;
|
||||
int secondIndex = -1;
|
||||
for (int i = 0; i < LifeSagaRoster.Cap; i++)
|
||||
{
|
||||
long id = LifeSagaRail.UnitIdAt(i);
|
||||
if (id != 0 && id != firstId)
|
||||
{
|
||||
secondIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LifeSagaRail.HideHover();
|
||||
bool deferredHeld = LifeSagaViewController.IsHoverPreview
|
||||
&& LifeSagaViewController.HoverPreviewId == firstId
|
||||
&& InterestDirector.ReadPauseActive
|
||||
&& string.Equals(
|
||||
WatchCaption.VisibleNametagText,
|
||||
firstName,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
if (secondIndex >= 0)
|
||||
{
|
||||
LifeSagaRail.ShowHover(secondIndex);
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
||||
string expected = (model?.Name ?? "").Replace("★ ", "").Trim();
|
||||
string beforeWriter = WatchCaption.VisibleNametagText;
|
||||
int beforeFont = WatchCaption.VisibleNametagFontSize;
|
||||
Vector2 beforeAvatar = DossierAvatar.HostAnchoredPosition;
|
||||
int blockedBefore = WatchCaption.SagaHoverBlockedDossierWrites;
|
||||
|
||||
// Reproduce the real contention: the camera dossier receives a normal bind
|
||||
// while a different Saga glyph owns the shared header.
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
if (focus != null && focus.isAlive())
|
||||
{
|
||||
WatchCaption.SetFromActor(focus);
|
||||
}
|
||||
WatchCaption.Update();
|
||||
WatchCaption.RequestRelayout();
|
||||
|
||||
string afterWriter = WatchCaption.VisibleNametagText;
|
||||
int afterFont = WatchCaption.VisibleNametagFontSize;
|
||||
Vector2 afterAvatar = DossierAvatar.HostAnchoredPosition;
|
||||
long secondId = LifeSagaViewController.HoverPreviewId;
|
||||
bool correctSecond = secondIndex >= 0
|
||||
&& secondId != 0
|
||||
&& secondId != firstId
|
||||
&& !string.IsNullOrEmpty(expected)
|
||||
&& afterWriter.IndexOf(expected, StringComparison.Ordinal) >= 0;
|
||||
bool stable = string.Equals(beforeWriter, afterWriter, StringComparison.Ordinal)
|
||||
&& beforeFont == afterFont
|
||||
&& Vector2.Distance(beforeAvatar, afterAvatar) < 0.1f;
|
||||
bool blocked = WatchCaption.SagaHoverBlockedDossierWrites > blockedBefore;
|
||||
bool ok = deferredHeld
|
||||
&& correctSecond
|
||||
&& stable
|
||||
&& blocked
|
||||
&& InterestDirector.ReadPauseActive;
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail:
|
||||
$"first={firstId} second={secondId} index={secondIndex} held={deferredHeld} "
|
||||
+ $"name='{firstName}'/'{beforeWriter}'/'{afterWriter}' expected='{expected}' "
|
||||
+ $"font={beforeFont}/{afterFont} avatar={beforeAvatar}/{afterAvatar} "
|
||||
+ $"blocked={blockedBefore}/{WatchCaption.SagaHoverBlockedDossierWrites} "
|
||||
+ $"pause={InterestDirector.ReadPauseActive}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_hide_hover":
|
||||
{
|
||||
LifeSagaRail.HideHover();
|
||||
|
|
@ -10403,6 +10496,11 @@ public static class AgentHarness
|
|||
+ $"visible={DossierAvatar.HostVisible} pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "saga_hover_nametag_ownership":
|
||||
{
|
||||
pass = WatchCaption.HarnessProbeSagaHoverNametagOwnership(out detail);
|
||||
break;
|
||||
}
|
||||
case "caption_mc_identity":
|
||||
{
|
||||
string idLine = WatchCaption.LastHeadline ?? "";
|
||||
|
|
|
|||
|
|
@ -3978,6 +3978,7 @@ internal static class HarnessScenarios
|
|||
Step("sad8b", "pick_unit", asset: "human", value: "other"),
|
||||
Step("sad9", "focus", asset: "human"),
|
||||
Step("sad10", "saga_force_admit_focus"),
|
||||
Step("sad10p", "saga_force_admit_partner"),
|
||||
Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander",
|
||||
expect: "harness_cast_relation"),
|
||||
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||
|
|
@ -4010,6 +4011,8 @@ 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"),
|
||||
Step("sad21a", "saga_scrub_hover_second"),
|
||||
Step("sad21a2", "assert", expect: "saga_hover_nametag_ownership"),
|
||||
Step("sad21b", "saga_hide_hover"),
|
||||
Step("sad21d", "saga_hover_tick_away"),
|
||||
Step("sad21h", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ public static class LifeSagaViewController
|
|||
return;
|
||||
}
|
||||
|
||||
FinishHoverExit();
|
||||
// PointerExit is delivered before PointerEnter when scrubbing directly from one
|
||||
// glyph to another. Keep Saga ownership alive across that event handoff so the
|
||||
// dossier cannot paint one intermediate header frame.
|
||||
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
|
||||
}
|
||||
|
||||
public static void Tick()
|
||||
|
|
@ -155,8 +158,14 @@ public static class LifeSagaViewController
|
|||
|
||||
public static void CancelPreviewAndPause()
|
||||
{
|
||||
bool hadPreview = _hoverPreviewId != 0;
|
||||
_hoverExitAt = -1f;
|
||||
_hoverPreviewId = 0;
|
||||
if (hadPreview)
|
||||
{
|
||||
WatchCaption.EndSagaHoverSession();
|
||||
}
|
||||
|
||||
if (_pauseHeld)
|
||||
{
|
||||
InterestDirector.ReleaseReadPause(ReadPauseOwner);
|
||||
|
|
@ -192,6 +201,7 @@ public static class LifeSagaViewController
|
|||
{
|
||||
_hoverExitAt = -1f;
|
||||
_hoverPreviewId = 0;
|
||||
WatchCaption.EndSagaHoverSession();
|
||||
if (_pauseHeld)
|
||||
{
|
||||
InterestDirector.ReleaseReadPause(ReadPauseOwner);
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ public static class WatchCaption
|
|||
// LastHeadline. Keep its display text and entry typography separate from that cache.
|
||||
private static string _sagaHoverHeadline = "";
|
||||
private static int _sagaHoverFontCeiling;
|
||||
private static int _sagaHoverBlockedDossierWrites;
|
||||
// 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.
|
||||
|
|
@ -181,6 +182,7 @@ public static class WatchCaption
|
|||
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
|
||||
public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0;
|
||||
public static int SagaHoverFontCeiling => _sagaHoverFontCeiling;
|
||||
public static int SagaHoverBlockedDossierWrites => _sagaHoverBlockedDossierWrites;
|
||||
|
||||
public static string LastDetail { get; private set; } = "";
|
||||
|
||||
|
|
@ -572,7 +574,7 @@ public static class WatchCaption
|
|||
|
||||
if (_nameText != null && (string.IsNullOrEmpty(_nameText.text) || _current == null))
|
||||
{
|
||||
_nameText.text = "Idle Spectator";
|
||||
PaintDossierHeadline("Idle Spectator");
|
||||
LastHeadline = "Idle Spectator";
|
||||
}
|
||||
|
||||
|
|
@ -661,10 +663,7 @@ public static class WatchCaption
|
|||
_boundActor = null;
|
||||
EnsureBuilt();
|
||||
ApplyVisual(null, null);
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = LastHeadline;
|
||||
}
|
||||
PaintDossierHeadline(LastHeadline);
|
||||
|
||||
Relayout(false, 0, 0, false, false, 0);
|
||||
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
|
||||
|
|
@ -804,9 +803,9 @@ public static class WatchCaption
|
|||
{
|
||||
ApplyVisual(focus ?? _boundActor, _current);
|
||||
}
|
||||
else if (_nameText != null)
|
||||
else
|
||||
{
|
||||
_nameText.text = LastHeadline;
|
||||
PaintDossierHeadline(LastHeadline);
|
||||
Relayout(false, 0, 0, false, false, 0);
|
||||
}
|
||||
|
||||
|
|
@ -815,10 +814,13 @@ public static class WatchCaption
|
|||
|
||||
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
|
||||
// must not leave the nametag on the previous person for a frame.
|
||||
// Caption Identity/Beat/Context always track camera focus; hover only adds panel depth.
|
||||
// Camera dossier state tracks focus, but Saga hover exclusively owns painted chrome.
|
||||
long perfStarted = StartPerfSample();
|
||||
LifeSagaViewController.Tick();
|
||||
if (!LifeSagaViewController.IsHoverPreview)
|
||||
{
|
||||
ReconcileDossierToFocus();
|
||||
}
|
||||
LastReconcileMs = EndPerfSample(perfStarted);
|
||||
float now = Time.unscaledTime;
|
||||
if (now < _nextRoutineLiveRefreshAt)
|
||||
|
|
@ -959,11 +961,26 @@ public static class WatchCaption
|
|||
public static void BeginSagaHoverSession()
|
||||
{
|
||||
_sagaHoverHeadline = "";
|
||||
_sagaHoverBlockedDossierWrites = 0;
|
||||
_sagaHoverFontCeiling = _nameText != null
|
||||
? Mathf.Clamp(_nameText.fontSize, 7, 10)
|
||||
: 10;
|
||||
}
|
||||
|
||||
/// <summary>Return nametag ownership to the camera dossier after a true rail exit.</summary>
|
||||
public static void EndSagaHoverSession()
|
||||
{
|
||||
_sagaHoverHeadline = "";
|
||||
_sagaHoverFontCeiling = 0;
|
||||
string shown = LastHeadline ?? "";
|
||||
if (shown.Length > 48)
|
||||
{
|
||||
shown = CaptionComposer.TruncateIdentity(shown, 48);
|
||||
}
|
||||
|
||||
PaintDossierHeadline(shown);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
|
@ -995,6 +1012,47 @@ public static class WatchCaption
|
|||
ApplySagaHoverHeader(model, deferPortraitReveal: true);
|
||||
}
|
||||
|
||||
/// <summary>Harness: prove a camera-dossier write cannot replace a hovered identity.</summary>
|
||||
public static bool HarnessProbeSagaHoverNametagOwnership(out string detail)
|
||||
{
|
||||
string before = VisibleNametagText;
|
||||
int blockedBefore = _sagaHoverBlockedDossierWrites;
|
||||
PaintDossierHeadline("__dossier_writer_probe__");
|
||||
string after = VisibleNametagText;
|
||||
bool pass = LifeSagaViewController.IsHoverPreview
|
||||
&& !string.IsNullOrEmpty(before)
|
||||
&& string.Equals(before, after, StringComparison.Ordinal)
|
||||
&& _sagaHoverBlockedDossierWrites == blockedBefore + 1;
|
||||
detail =
|
||||
$"hover={LifeSagaViewController.HoverPreviewId} before='{before}' after='{after}' "
|
||||
+ $"blocked={blockedBefore}/{_sagaHoverBlockedDossierWrites} pass={pass}";
|
||||
return pass;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single camera-dossier nametag write gate. Saga hover is the exclusive visual
|
||||
/// owner even while camera callbacks continue updating the underlying dossier model.
|
||||
/// </summary>
|
||||
private static void PaintDossierHeadline(string text)
|
||||
{
|
||||
if (_nameText == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (LifeSagaViewController.IsHoverPreview)
|
||||
{
|
||||
_sagaHoverBlockedDossierWrites++;
|
||||
return;
|
||||
}
|
||||
|
||||
string next = text ?? "";
|
||||
if (!string.Equals(_nameText.text, next, StringComparison.Ordinal))
|
||||
{
|
||||
_nameText.text = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A same-subject camera event can change its orange reason without rebuilding the
|
||||
/// dossier. Refresh only the owned reason/spine so UI and camera telemetry commit together.
|
||||
|
|
@ -1401,20 +1459,14 @@ public static class WatchCaption
|
|||
shown = CaptionComposer.TruncateIdentity(shown, 48);
|
||||
}
|
||||
|
||||
if (!string.Equals(_nameText.text, shown, StringComparison.Ordinal))
|
||||
{
|
||||
_nameText.text = shown;
|
||||
}
|
||||
PaintDossierHeadline(shown);
|
||||
}
|
||||
}
|
||||
else if (_current != null && !string.IsNullOrEmpty(_current.Headline))
|
||||
{
|
||||
// Fallen/archive: Compose has no live focus - keep dossier headline.
|
||||
LastHeadline = _current.Headline;
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = LastHeadline;
|
||||
}
|
||||
PaintDossierHeadline(LastHeadline);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1674,6 +1726,13 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
// A delayed model from the previous glyph must never repaint a newer hover.
|
||||
if (!LifeSagaViewController.IsHoverPreview
|
||||
|| model.UnitId != LifeSagaViewController.HoverPreviewId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor actor = EventFeedUtil.FindAliveById(model.UnitId);
|
||||
string name = (model.Name ?? "").Trim();
|
||||
if (name.StartsWith("★ ", StringComparison.Ordinal))
|
||||
|
|
@ -1905,7 +1964,10 @@ public static class WatchCaption
|
|||
/// <summary>Keep nametag Identity in sync via CaptionComposer (MC title or species/job).</summary>
|
||||
private static void RefreshLiveIdentity()
|
||||
{
|
||||
if (!_visible || _current == null || _headlineLocked)
|
||||
if (!_visible
|
||||
|| _current == null
|
||||
|| _headlineLocked
|
||||
|| LifeSagaViewController.IsHoverPreview)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -2282,6 +2344,15 @@ public static class WatchCaption
|
|||
&& actor.getID() == dossier.UnitId;
|
||||
_boundActor = hasLive ? actor : null;
|
||||
|
||||
// Saga hover owns the complete header/body presentation. SetFromActor and other
|
||||
// camera callbacks may still update the dossier snapshot, but they cannot repaint
|
||||
// shared UI until the hover session ends.
|
||||
if (LifeSagaViewController.IsHoverPreview)
|
||||
{
|
||||
_sagaHoverBlockedDossierWrites++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_speciesIcon != null)
|
||||
{
|
||||
Sprite species = null;
|
||||
|
|
@ -2318,12 +2389,10 @@ public static class WatchCaption
|
|||
_sexIcon.gameObject.SetActive(hasSex);
|
||||
}
|
||||
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = dossier != null && !string.IsNullOrEmpty(dossier.Headline)
|
||||
PaintDossierHeadline(
|
||||
dossier != null && !string.IsNullOrEmpty(dossier.Headline)
|
||||
? dossier.Headline
|
||||
: "";
|
||||
}
|
||||
: "");
|
||||
|
||||
bool hasBody = dossier != null && dossier.UnitId != 0;
|
||||
bool hoverDepth = LifeSagaViewController.IsHoverPreview;
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ Recognition should come from repeated, stable signals rather than extra prose on
|
|||
- Identity remains `Name · real title`, otherwise `Name (species/job)`.
|
||||
- Hover remains the place for Cast and Legacy depth; the live caption stays Identity + Beat +
|
||||
Context.
|
||||
- Treat a glyph-to-glyph scrub as one hover ownership session: defer exit briefly, block all
|
||||
camera-dossier writes to shared header chrome, and restore the camera name only after a true exit.
|
||||
|
||||
### MC connection in the camera dossier
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,10 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
|
|||
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.
|
||||
- Saga owns the shared nametag/species/sex/portrait chrome exclusively for the whole hover session.
|
||||
Camera dossier callbacks may update their semantic snapshot but cannot repaint that chrome.
|
||||
- Glyph exit is deferred for 50 ms; entering another glyph cancels the exit, producing an atomic
|
||||
cross-glyph handoff with no intermediate dossier name or typography frame.
|
||||
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
|
||||
- Missing live rail frames retry at most one inactive slot per ~1s rail tick; successful captures
|
||||
replace the temporary species icon and unsuccessful attempts never discard an older live frame.
|
||||
|
|
|
|||
Loading…
Reference in a new issue