worldbox-observer-mod/IdleSpectator/Story/LifeSagaViewController.cs
DazedAnon 6fb62c47d6 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
2026-07-22 03:57:31 -05:00

301 lines
7.5 KiB
C#

using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Owns Dossier/Saga tab selection, hover preview, and camera read-pause lease.
/// </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;
public enum Tab
{
Dossier = 0,
Saga = 1
}
private static Tab _selectedTab = Tab.Dossier;
private static long _persistentSagaId;
private static bool _persistentPinned;
private static long _hoverPreviewId;
private static Tab _tabBeforeHover = Tab.Dossier;
private static float _hoverExitAt = -1f;
private static bool _pauseHeld;
private static bool _loreReturnToSaga;
private static long _loreReturnSagaId;
public static Tab SelectedTab => _selectedTab;
public static long PersistentSagaId => _persistentSagaId;
public static long HoverPreviewId => _hoverPreviewId;
public static bool IsHoverPreview => _hoverPreviewId != 0;
public static Tab EffectiveTab =>
_hoverPreviewId != 0 ? Tab.Saga : _selectedTab;
public static long EffectiveSagaId
{
get
{
if (_hoverPreviewId != 0)
{
return _hoverPreviewId;
}
return ResolvePersistentSagaId();
}
}
public static void SelectTab(Tab tab)
{
// Explicit tab click wins over hover preview restore.
if (_hoverPreviewId != 0 || _pauseHeld)
{
CancelPreviewAndPause();
_tabBeforeHover = tab;
}
_selectedTab = tab;
if (tab == Tab.Saga)
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else if (!IsValidMc(_persistentSagaId) || !_persistentPinned)
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
}
public static void SetPersistentSaga(long unitId)
{
if (unitId == 0 || !LifeSagaRoster.IsMc(unitId))
{
return;
}
_persistentSagaId = unitId;
_persistentPinned = true;
}
/// <summary>Stash Saga return target before opening Lore from the Saga panel.</summary>
public static void StashLoreReturn(long unitId)
{
_loreReturnToSaga = true;
_loreReturnSagaId = unitId;
}
/// <summary>
/// After Lore closes, restore Saga tab + subject when a stash is present.
/// Returns true when a restore was applied.
/// </summary>
public static bool TryRestoreAfterLore()
{
if (!_loreReturnToSaga)
{
return false;
}
long id = _loreReturnSagaId;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
CancelPreviewAndPause();
_selectedTab = Tab.Saga;
_tabBeforeHover = Tab.Saga;
if (IsValidMc(id))
{
_persistentSagaId = id;
_persistentPinned = true;
}
else
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
return true;
}
public static void BeginHoverPreview(long unitId)
{
if (unitId == 0)
{
return;
}
_hoverExitAt = -1f;
if (_hoverPreviewId == 0)
{
_tabBeforeHover = _selectedTab;
}
_hoverPreviewId = 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 Dossier 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();
_selectedTab = Tab.Dossier;
_persistentSagaId = 0;
_persistentPinned = false;
_tabBeforeHover = Tab.Dossier;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
}
/// <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;
_selectedTab = _tabBeforeHover;
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
_pauseHeld = false;
}
WatchCaption.RequestRelayout();
}
/// <summary>
/// Watched MC wins when on roster. Otherwise only an explicitly pinned alive MC.
/// Never falls back to first-roster / stale last-active filler.
/// </summary>
private static long ResolvePersistentSagaId()
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
return watch;
}
if (_persistentPinned && IsValidMc(_persistentSagaId))
{
return _persistentSagaId;
}
_persistentSagaId = 0;
_persistentPinned = false;
return 0;
}
private static bool IsValidMc(long unitId)
{
return unitId != 0
&& LifeSagaRoster.IsMc(unitId)
&& EventFeedUtil.FindAliveById(unitId) != null;
}
private static long WatchedMcId()
{
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
return watchId != 0 && LifeSagaRoster.IsMc(watchId) ? watchId : 0;
}
}