worldbox-observer-mod/IdleSpectator/Story/LifeSagaViewController.cs
DazedAnon 4b6c829bd0 feat(saga): ship adaptive dossier tabs with durable memory
- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause
- Persist observed saga facts across roster churn; pin subject without stale fallback
- Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips
- Densify dossier header/chips and keep the cast rail as stable top chrome
- Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
2026-07-22 03:27:46 -05:00

317 lines
7.8 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";
public const float HoverExitGraceSeconds = 0.35f;
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;
}
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
}
public static void Tick()
{
if (_hoverPreviewId == 0)
{
return;
}
// Keep preview alive while the pointer stays on the dossier/rail chrome.
if (WatchCaption.IsPointerOverPanel() || LifeSagaRail.IsPointerOverRail())
{
_hoverExitAt = -1f;
return;
}
// Pointer left chrome after a panel dwell cleared the timer - re-arm grace.
if (_hoverExitAt < 0f)
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
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: simulate Tick clearing the exit timer while the pointer dwells on the panel.
/// </summary>
public static void HarnessSimulatePanelDwell()
{
if (_hoverPreviewId == 0)
{
return;
}
_hoverExitAt = -1f;
}
/// <summary>
/// Harness: run one hover Tick as if the pointer is off rail and panel chrome.
/// </summary>
public static void HarnessTickExitAwayFromChrome()
{
if (_hoverPreviewId == 0)
{
return;
}
if (_hoverExitAt < 0f)
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
return;
}
if (Time.unscaledTime < _hoverExitAt)
{
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;
}
}