worldbox-observer-mod/IdleSpectator/InterestDirector.cs

668 lines
20 KiB
C#

using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Event-centered scene director: registry intake, protected sessions, Epic-only preemption
/// for Action+ scenes, soft 70/30 variety, completion via <see cref="InterestCompletion.IsActive"/>.
/// </summary>
public static class InterestDirector
{
public const float MinDwellSeconds = 10f;
public const float CuriosityDwellSeconds = 6f;
public const float CuriosityRotateSeconds = 10f;
public const float SwitchCooldownSeconds = 5f;
public const float HighTierInterruptAfterSeconds = 3f;
public const float QuietWorldAmbientAfterSeconds = 8f;
public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f;
public const float InputExitGraceSeconds = 2.5f;
public const float QuietGraceSeconds = 1.2f;
public const float CameraSettleGraceSeconds = 3f;
private static float _minDwell = MinDwellSeconds;
private static float _curiosityDwell = CuriosityDwellSeconds;
private static float _curiosityRotate = CuriosityRotateSeconds;
private static float _switchCooldown = SwitchCooldownSeconds;
private static float _settleGrace = CameraSettleGraceSeconds;
private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds;
private static float _ambientRotate = AmbientRotateSeconds;
private static float _feedsTick = ActionPollSeconds;
private static float _inputExitGrace = InputExitGraceSeconds;
private static float _quietGrace = QuietGraceSeconds;
private static InterestCandidate _current;
private static InterestCandidate _interrupted;
private static float _currentStartedAt = -999f;
private static float _lastSwitchAt = -999f;
private static float _lastInterestingAt = -999f;
private static float _lastAmbientAt = -999f;
private static float _lastFeedsAt = -999f;
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
public static string CurrentTierName =>
_current == null ? "none" : _current.Urgency.ToString();
public static string CurrentLabel =>
_current == null ? "" : (_current.Label ?? "");
public static string CurrentKey =>
_current == null ? "" : (_current.Key ?? "");
public static bool CurrentIsActive
{
get
{
if (_current == null)
{
return false;
}
return InterestCompletion.IsActive(_current, _currentStartedAt, Time.unscaledTime);
}
}
public static InterestCandidate CurrentCandidate => _current;
public static bool InInputGrace =>
SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace;
public static void SetHarnessFastTiming(bool enabled)
{
if (enabled)
{
_minDwell = 1.5f;
_curiosityDwell = 1.2f;
_curiosityRotate = 1.5f;
_switchCooldown = 0.75f;
_settleGrace = 0.75f;
_quietAmbientAfter = 1.5f;
_ambientRotate = 2.5f;
_feedsTick = 0.35f;
_inputExitGrace = 0.6f;
_quietGrace = 0.35f;
}
else
{
_minDwell = MinDwellSeconds;
_curiosityDwell = CuriosityDwellSeconds;
_curiosityRotate = CuriosityRotateSeconds;
_switchCooldown = SwitchCooldownSeconds;
_settleGrace = CameraSettleGraceSeconds;
_quietAmbientAfter = QuietWorldAmbientAfterSeconds;
_ambientRotate = AmbientRotateSeconds;
_feedsTick = ActionPollSeconds;
_inputExitGrace = InputExitGraceSeconds;
_quietGrace = QuietGraceSeconds;
}
}
public static void HarnessAgeCurrent(float seconds)
{
float age = Mathf.Max(0f, seconds);
float now = Time.unscaledTime;
_currentStartedAt = now - age;
_lastSwitchAt = now - age;
}
public static void HarnessExpireInputGrace()
{
_enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f;
}
/// <summary>Harness: force the given candidate as the active session.</summary>
public static bool HarnessForceSession(InterestCandidate candidate)
{
if (candidate == null || !candidate.HasValidPosition)
{
return false;
}
InterestRegistry.Upsert(candidate);
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
return true;
}
public static void OnSpectatorEnabled()
{
InterestRegistry.Clear();
InterestVariety.Clear();
InterestScoring.ClearCache();
InterestFeeds.Reset();
_current = null;
_interrupted = null;
float now = Time.unscaledTime;
_currentStartedAt = now;
_lastSwitchAt = now;
_lastInterestingAt = now;
_lastAmbientAt = -999f;
_lastFeedsAt = -999f;
_enabledAt = now;
_inactiveSince = -999f;
// Harness scenarios inject their own candidates after enable/focus.
if (!AgentHarness.Busy)
{
TryCharacterFill(force: true);
}
}
public static void OnSpectatorDisabled()
{
_current = null;
_interrupted = null;
InterestRegistry.Clear();
}
public static void PauseForBrowsing(string banner = "Paused (viewing history)")
{
if (SpectatorMode.Active)
{
SpectatorMode.SetActive(false, quiet: true);
}
WatchCaption.PinWhilePaused();
if (!string.IsNullOrEmpty(banner))
{
WatchCaption.ShowStatusBanner(banner);
}
LogService.LogInfo("[IdleSpectator] Spectator paused (viewing history)");
}
public static void Update()
{
if (!SpectatorMode.Active || !Config.game_loaded || World.world == null)
{
return;
}
WatchCaption.ClearPausePin();
if (AgentHarness.FreezeDirector)
{
return;
}
if (Time.unscaledTime - _enabledAt >= _inputExitGrace
&& PlayerTookManualControl())
{
ExitFromManualInput();
return;
}
float now = Time.unscaledTime;
if (now - _lastFeedsAt >= _feedsTick)
{
_lastFeedsAt = now;
InterestFeeds.Tick();
InterestRegistry.ExpireStale(now);
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
UpdateSessionLiveness(now, onCurrent);
InterestCandidate next = SelectNext(now);
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
{
if (next.Urgency >= InterestTier.Action)
{
_lastInterestingAt = now;
}
SwitchTo(next, now, resumableInterrupt: false);
onCurrent = 0f;
sinceSwitch = 0f;
}
// Queued discovery tips beat ambient fill, but never stall under Action/Story.
if (InterestRegistry.HasPendingAtLeast(InterestTier.Curiosity)
&& (_current == null || _current.Urgency <= InterestTier.Curiosity))
{
return;
}
bool quiet = now - _lastInterestingAt >= _quietAmbientAfter;
bool sceneDone = _current == null || !SessionProtected(now, onCurrent);
bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
if (_current == null && sinceSwitch >= 0.5f)
{
TryCharacterFill(force: true);
}
else if (sceneDone && quiet && ambientDue && sinceSwitch >= _switchCooldown)
{
TryCharacterFill(force: false);
}
}
private static void UpdateSessionLiveness(float now, float onCurrent)
{
if (_current == null)
{
_inactiveSince = -999f;
return;
}
// Max cap ends the scene even if still "active".
if (onCurrent >= MaxWatchFor(_current))
{
EndCurrent(now, reason: "max_cap");
return;
}
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (active)
{
_inactiveSince = -999f;
TryHandoffFollow();
return;
}
if (_inactiveSince < 0f)
{
_inactiveSince = now;
}
if (now - _inactiveSince >= _quietGrace)
{
EndCurrent(now, reason: "quiet_grace");
}
}
private static void TryHandoffFollow()
{
if (_current == null)
{
return;
}
if (_current.HasFollowUnit)
{
return;
}
// Prefer related (grief survivor is already FollowUnit); try related then participants.
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
_current.FollowUnit = _current.RelatedUnit;
CameraDirector.Watch(_current.ToInterestEvent());
return;
}
Actor near = WorldActivityScanner.FindNearestAliveUnit(_current.Position, 18f);
if (near != null)
{
_current.FollowUnit = near;
CameraDirector.Watch(_current.ToInterestEvent());
}
}
private static void EndCurrent(float now, string reason)
{
if (_current != null)
{
InterestRegistry.Remove(_current.Key);
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
}
// Resume interrupted Epic-preempted scene if still valid.
if (_interrupted != null
&& _interrupted.HasValidPosition
&& InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now))
{
InterestCandidate resume = _interrupted;
_interrupted = null;
SwitchTo(resume, now, resumableInterrupt: false);
return;
}
_interrupted = null;
_current = null;
_inactiveSince = -999f;
}
private static InterestCandidate SelectNext(float now)
{
InterestRegistry.CopyPending(PendingScratch);
if (PendingScratch.Count == 0)
{
return null;
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
for (int i = PendingScratch.Count - 1; i >= 0; i--)
{
InterestCandidate c = PendingScratch[i];
if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch))
{
PendingScratch.RemoveAt(i);
}
}
if (PendingScratch.Count == 0)
{
return null;
}
InterestScoring.EnrichTopK(PendingScratch);
return InterestVariety.Pick(PendingScratch, _current);
}
/// <summary>
/// True when a drained New species tip would be allowed to take the camera.
/// </summary>
public static bool WouldAcceptCuriosity()
{
if (_current == null)
{
return true;
}
float onCurrent = Time.unscaledTime - _currentStartedAt;
float sinceSwitch = Time.unscaledTime - _lastSwitchAt;
return CanSwitchTo(
new InterestCandidate { Urgency = InterestTier.Curiosity, Key = "probe:curiosity" },
onCurrent,
sinceSwitch);
}
public static bool SimulateManualInputExit(bool honorGrace = false)
{
if (!SpectatorMode.Active)
{
return false;
}
MoveCamera.camera_drag_run = true;
bool detected = PlayerTookManualControl();
if (!detected)
{
MoveCamera.camera_drag_run = false;
return false;
}
if (honorGrace && Time.unscaledTime - _enabledAt < _inputExitGrace)
{
MoveCamera.camera_drag_run = false;
return false;
}
ExitFromManualInput();
MoveCamera.camera_drag_run = false;
return !SpectatorMode.Active;
}
private static void ExitFromManualInput()
{
SpectatorMode.SetActive(false, quiet: true);
WatchCaption.ShowStatusBanner("Paused (manual input)");
LogService.LogInfo("[IdleSpectator] Spectator paused (manual input)");
}
private static bool PlayerTookManualControl()
{
if (MoveCamera.camera_drag_run)
{
return true;
}
if (Mathf.Abs(Input.mouseScrollDelta.y) > 0.01f)
{
return true;
}
if (HotkeyLibrary.left != null
&& (HotkeyLibrary.left.isHolding()
|| HotkeyLibrary.right.isHolding()
|| HotkeyLibrary.up.isHolding()
|| HotkeyLibrary.down.isHolding()))
{
return true;
}
if (HotkeyLibrary.zoom_in != null
&& (HotkeyLibrary.zoom_in.isHolding() || HotkeyLibrary.zoom_out.isHolding()))
{
return true;
}
if (!World.world.isOverUI()
&& !WatchCaption.IsPointerOverInteractive()
&& !ChronicleHud.IsPointerOverPanel()
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
{
return true;
}
return false;
}
private static float MinDwellFor(InterestCandidate current)
{
if (current == null)
{
return _minDwell;
}
if (current.MinWatch > 0f)
{
return current.Urgency <= InterestTier.Curiosity
? Mathf.Min(current.MinWatch, _curiosityDwell)
: Mathf.Max(current.MinWatch, _minDwell * 0.15f);
}
return current.Urgency <= InterestTier.Curiosity ? _curiosityDwell : _minDwell;
}
private static float MaxWatchFor(InterestCandidate current)
{
if (current == null)
{
return 45f;
}
float cap = current.MaxWatch > 0f ? current.MaxWatch : 45f;
// Harness fast timing compresses caps.
if (_minDwell < MinDwellSeconds * 0.5f)
{
cap = Mathf.Min(cap, 8f);
}
return cap;
}
/// <summary>
/// Protected Action+ scenes: only Epic may preempt after settle grace.
/// Ambient/Curiosity may be replaced by higher urgency after settle.
/// </summary>
private static bool SessionProtected(float now, float onCurrent)
{
if (_current == null)
{
return false;
}
if (onCurrent >= MaxWatchFor(_current))
{
return false;
}
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!active)
{
if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace)
{
return false;
}
// Still in quiet grace - treat as protected briefly.
return _inactiveSince < 0f || now - _inactiveSince < _quietGrace;
}
return true;
}
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
{
if (candidate == null)
{
return false;
}
if (_current == null)
{
return true;
}
float now = Time.unscaledTime;
bool protectedScene = SessionProtected(now, onCurrent);
InterestTier cur = _current.Urgency;
InterestTier next = candidate.Urgency;
// Curiosity never interrupts Action+.
if (next == InterestTier.Curiosity && cur >= InterestTier.Action)
{
return false;
}
if (next == InterestTier.Curiosity
&& cur == InterestTier.Curiosity
&& onCurrent < _curiosityRotate)
{
return false;
}
if (protectedScene && cur >= InterestTier.Action)
{
// Action/Story protected: only Epic after settle grace.
if (next >= InterestTier.Epic && onCurrent >= _settleGrace)
{
return true;
}
return false;
}
// Ambient/Curiosity (or unprotected): higher urgency after settle; else dwell+cooldown.
if (next > cur && onCurrent >= _settleGrace)
{
return true;
}
if (!protectedScene && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
{
return true;
}
return onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown && next >= cur;
}
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
{
if (next == null)
{
return;
}
if (_current != null
&& next.Urgency >= InterestTier.Epic
&& _current.Urgency < InterestTier.Epic
&& _current.Resumable)
{
_interrupted = _current.CloneShallow();
}
_current = next;
_currentStartedAt = now;
_lastSwitchAt = now;
_inactiveSince = -999f;
if (next.Urgency >= InterestTier.Action)
{
_lastInterestingAt = now;
}
InterestRegistry.MarkSelected(next.Key);
InterestVariety.NoteSelection(next);
CameraDirector.Watch(next.ToInterestEvent());
}
private static void TryCharacterFill(bool force)
{
float now = Time.unscaledTime;
_lastAmbientAt = now;
if (!force && _current != null && SessionProtected(now, now - _currentStartedAt)
&& _current.Urgency >= InterestTier.Story)
{
return;
}
if (!force && _current != null && _current.Urgency == InterestTier.Curiosity
&& now - _currentStartedAt < _ambientRotate)
{
return;
}
if (!force && now - _lastSwitchAt < _switchCooldown && _current != null)
{
return;
}
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
if (ambient == null)
{
return;
}
// Harness batches inject their own candidates; never let a live Action/Story
// ambient fill steal the camera before trigger_interest / discovery steps.
if (AgentHarness.Busy && ambient.Tier >= InterestTier.Action)
{
ambient.Tier = InterestTier.Ambient;
if (string.IsNullOrEmpty(ambient.Label))
{
ambient.Label = "Ambient";
}
}
InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient);
if (candidate == null)
{
return;
}
candidate.LeadKind = InterestLeadKind.CharacterLed;
candidate.Category = "CharacterVignette";
candidate.Completion = InterestCompletionKind.CharacterVignette;
if (AgentHarness.Busy)
{
candidate.Urgency = InterestTier.Ambient;
candidate.ForceActive = false;
InterestScoring.ScoreCheap(candidate);
}
if (!force
&& _current != null
&& _current.FollowUnit != null
&& candidate.FollowUnit != null
&& _current.FollowUnit == candidate.FollowUnit
&& candidate.Urgency <= InterestTier.Ambient)
{
return;
}
if (!force && !CanSwitchTo(candidate, now - _currentStartedAt, now - _lastSwitchAt))
{
return;
}
SwitchTo(candidate, now, resumableInterrupt: false);
LogService.LogInfo("[IdleSpectator] Ambient: " + candidate.Label);
}
}