- 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
210 lines
5.5 KiB
C#
210 lines
5.5 KiB
C#
using System;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Owns hover preview and camera read-pause lease for the life-saga rail.
|
|
/// AFK chrome is always character beat caption (Identity + Beat + Context);
|
|
/// the saga panel is hover depth only (no Dossier/Saga tabs).
|
|
/// </summary>
|
|
public static class LifeSagaViewController
|
|
{
|
|
public const string ReadPauseOwner = "saga_hover";
|
|
/// <summary>Only used if an exit was deferred; glyph leave finishes immediately.</summary>
|
|
public const float HoverExitGraceSeconds = 0.05f;
|
|
|
|
/// <summary>
|
|
/// Compatibility enum for harness asserts. AFK chrome never pins Saga;
|
|
/// <see cref="EffectiveTab"/> is Saga only while a hover preview is active.
|
|
/// </summary>
|
|
public enum Tab
|
|
{
|
|
Dossier = 0,
|
|
Saga = 1
|
|
}
|
|
|
|
private static long _hoverPreviewId;
|
|
private static float _hoverExitAt = -1f;
|
|
private static bool _pauseHeld;
|
|
|
|
public static Tab SelectedTab => Tab.Dossier;
|
|
public static long PersistentSagaId => 0;
|
|
public static long HoverPreviewId => _hoverPreviewId;
|
|
public static bool IsHoverPreview => _hoverPreviewId != 0;
|
|
|
|
/// <summary>Saga while hovering a rail glyph; otherwise Dossier (caption body).</summary>
|
|
public static Tab EffectiveTab =>
|
|
_hoverPreviewId != 0 ? Tab.Saga : Tab.Dossier;
|
|
|
|
public static long EffectiveSagaId => _hoverPreviewId;
|
|
|
|
/// <summary>
|
|
/// Harness/compat: Dossier cancels hover. Saga begins hover preview of the watched MC
|
|
/// when on the roster (no persistent pin / empty pick chrome).
|
|
/// </summary>
|
|
public static void SelectTab(Tab tab)
|
|
{
|
|
if (tab == Tab.Dossier)
|
|
{
|
|
CancelPreviewAndPause();
|
|
WatchCaption.RequestRelayout();
|
|
return;
|
|
}
|
|
|
|
long watch = WatchedMcId();
|
|
if (watch != 0)
|
|
{
|
|
BeginHoverPreview(watch);
|
|
return;
|
|
}
|
|
|
|
CancelPreviewAndPause();
|
|
WatchCaption.RequestRelayout();
|
|
}
|
|
|
|
/// <summary>No-op: Prefer click no longer pins a persistent Saga subject.</summary>
|
|
public static void SetPersistentSaga(long unitId)
|
|
{
|
|
_ = unitId;
|
|
}
|
|
|
|
/// <summary>No-op: Lore return no longer restores a Saga tab.</summary>
|
|
public static void StashLoreReturn(long unitId)
|
|
{
|
|
_ = unitId;
|
|
}
|
|
|
|
/// <summary>No-op: Lore close leaves caption chrome as-is.</summary>
|
|
public static bool TryRestoreAfterLore()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public static void BeginHoverPreview(long unitId)
|
|
{
|
|
if (unitId == 0)
|
|
{
|
|
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);
|
|
if (!_pauseHeld)
|
|
{
|
|
InterestDirector.AcquireReadPause(ReadPauseOwner);
|
|
_pauseHeld = true;
|
|
}
|
|
|
|
WatchCaption.RequestRelayout();
|
|
}
|
|
|
|
public static void EndHoverPreview()
|
|
{
|
|
if (_hoverPreviewId == 0 && !_pauseHeld)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Relayout / glyph→glyph can fire Exit while the pointer is still on a glyph.
|
|
// Keep the preview in that case; otherwise restore caption body immediately.
|
|
if (LifeSagaRail.IsPointerOverGlyph())
|
|
{
|
|
_hoverExitAt = -1f;
|
|
return;
|
|
}
|
|
|
|
FinishHoverExit();
|
|
}
|
|
|
|
public static void Tick()
|
|
{
|
|
if (_hoverPreviewId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Actively hovering (no exit armed) - keep preview until EndHoverPreview.
|
|
if (_hoverExitAt < 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Deferred exit was armed; cancel it if the pointer landed on another glyph.
|
|
if (LifeSagaRail.IsPointerOverGlyph())
|
|
{
|
|
_hoverExitAt = -1f;
|
|
return;
|
|
}
|
|
|
|
if (Time.unscaledTime < _hoverExitAt)
|
|
{
|
|
return;
|
|
}
|
|
|
|
FinishHoverExit();
|
|
}
|
|
|
|
public static void CancelPreviewAndPause()
|
|
{
|
|
_hoverExitAt = -1f;
|
|
_hoverPreviewId = 0;
|
|
if (_pauseHeld)
|
|
{
|
|
InterestDirector.ReleaseReadPause(ReadPauseOwner);
|
|
_pauseHeld = false;
|
|
}
|
|
}
|
|
|
|
public static void Clear()
|
|
{
|
|
CancelPreviewAndPause();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: force-complete hover exit as if the pointer left every glyph.
|
|
/// </summary>
|
|
public static void HarnessTickExitAwayFromChrome()
|
|
{
|
|
if (_hoverPreviewId == 0 && !_pauseHeld)
|
|
{
|
|
return;
|
|
}
|
|
|
|
FinishHoverExit();
|
|
}
|
|
|
|
public static LifeSagaViewModel BuildEffectivePresentation()
|
|
{
|
|
long id = EffectiveSagaId;
|
|
return id != 0 ? LifeSagaPresentation.Build(id) : new LifeSagaViewModel();
|
|
}
|
|
|
|
private static void FinishHoverExit()
|
|
{
|
|
_hoverExitAt = -1f;
|
|
_hoverPreviewId = 0;
|
|
if (_pauseHeld)
|
|
{
|
|
InterestDirector.ReleaseReadPause(ReadPauseOwner);
|
|
_pauseHeld = false;
|
|
}
|
|
|
|
WatchCaption.RequestRelayout();
|
|
}
|
|
|
|
private static long WatchedMcId()
|
|
{
|
|
long watchId = EventFeedUtil.SafeId(
|
|
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
|
return watchId != 0 && LifeSagaRoster.IsMc(watchId) ? watchId : 0;
|
|
}
|
|
}
|