worldbox-observer-mod/IdleSpectator/InterestDirector.cs
2026-07-14 14:59:26 -05:00

314 lines
9.3 KiB
C#

using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Picks the next interesting target using dwell, cooldown, and tier interrupts.
/// </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;
private static InterestEvent _current;
private static float _currentStartedAt = -999f;
private static float _lastSwitchAt = -999f;
private static float _lastInterestingAt = -999f;
private static float _lastAmbientAt = -999f;
private static float _lastActionPollAt = -999f;
private static float _enabledAt = -999f;
private const float InputExitGraceSeconds = 2.5f;
public static void OnSpectatorEnabled()
{
InterestCollector.Clear();
_current = null;
float now = Time.unscaledTime;
_currentStartedAt = now;
_lastSwitchAt = now;
_lastInterestingAt = now;
_lastAmbientAt = -999f;
_lastActionPollAt = -999f;
_enabledAt = now;
TryAmbient(force: true);
}
public static void OnSpectatorDisabled()
{
_current = null;
InterestCollector.Clear();
}
public static void Update()
{
if (!SpectatorMode.Active || !Config.game_loaded || World.world == null)
{
return;
}
// During harness batches, do not ambient-steal focus. The harness picks units explicitly.
if (AgentHarness.Busy)
{
return;
}
// Harness drives the camera/assert loop; ignore manual-input exit while commands run.
if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds
&& PlayerTookManualControl())
{
ExitFromManualInput();
return;
}
float now = Time.unscaledTime;
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
// Periodically surface live battles into the queue (Action tier).
if (now - _lastActionPollAt >= ActionPollSeconds)
{
_lastActionPollAt = now;
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
if (battle != null)
{
InterestCollector.EnqueueDirect(battle);
}
}
if (InterestCollector.TryGetBest(out InterestEvent candidate))
{
if (candidate.HasValidPosition && CanSwitchTo(candidate, onCurrent, sinceSwitch))
{
InterestCollector.Remove(candidate);
if (candidate.Tier >= InterestTier.Action)
{
_lastInterestingAt = now;
}
SwitchTo(candidate, now);
onCurrent = 0f;
sinceSwitch = 0f;
}
else if (!candidate.HasValidPosition)
{
InterestCollector.Remove(candidate);
}
else if (candidate.Tier >= InterestTier.Action)
{
_lastInterestingAt = now;
}
}
// Queued discovery tips beat ambient, but never stall ambient under Action/Story.
if (InterestCollector.HasPendingAtLeast(InterestTier.Curiosity)
&& (_current == null || _current.Tier <= InterestTier.Curiosity))
{
return;
}
bool quiet = now - _lastInterestingAt >= QuietWorldAmbientAfterSeconds;
bool dwellDone = _current == null || onCurrent >= DwellFor(_current);
bool ambientDue = now - _lastAmbientAt >= AmbientRotateSeconds;
if (_current == null && sinceSwitch >= 0.5f)
{
TryAmbient(force: true);
}
else if (quiet && dwellDone && ambientDue && sinceSwitch >= SwitchCooldownSeconds)
{
TryAmbient(force: false);
}
}
/// <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 InterestEvent { Tier = InterestTier.Curiosity },
onCurrent,
sinceSwitch);
}
/// <summary>
/// Harness: pulse camera-drag input and take the real manual-exit path.
/// </summary>
public static bool SimulateManualInputExit()
{
if (!SpectatorMode.Active)
{
return false;
}
MoveCamera.camera_drag_run = true;
bool detected = PlayerTookManualControl();
if (!detected)
{
MoveCamera.camera_drag_run = false;
return false;
}
ExitFromManualInput();
MoveCamera.camera_drag_run = false;
return !SpectatorMode.Active;
}
private static void ExitFromManualInput()
{
SpectatorMode.SetActive(false);
WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false);
LogService.LogInfo("[IdleSpectator] Spectator paused (manual input)");
}
private static bool PlayerTookManualControl()
{
// Rising-edge style checks only - avoid sticky flags like already_used_power
// that can trip when the power bar flashes or a tip appears.
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()
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
{
return true;
}
return false;
}
private static float DwellFor(InterestEvent current)
{
if (current == null)
{
return MinDwellSeconds;
}
return current.Tier <= InterestTier.Curiosity ? CuriosityDwellSeconds : MinDwellSeconds;
}
private static bool CanSwitchTo(InterestEvent candidate, float onCurrent, float sinceSwitch)
{
if (_current == null)
{
return true;
}
// Curiosity is a footnote: never interrupt Action/Story/Epic.
// Curiosity→curiosity uses a shorter rotate so discovery drains can surface.
if (candidate.Tier == InterestTier.Curiosity)
{
if (_current.Tier >= InterestTier.Action)
{
return false;
}
if (_current.Tier == InterestTier.Curiosity && onCurrent < CuriosityRotateSeconds)
{
return false;
}
if (_current.Tier == InterestTier.Story)
{
return false;
}
}
if (candidate.Tier > _current.Tier && onCurrent >= HighTierInterruptAfterSeconds)
{
return true;
}
return onCurrent >= DwellFor(_current) && sinceSwitch >= SwitchCooldownSeconds;
}
private static void SwitchTo(InterestEvent next, float now)
{
_current = next;
_currentStartedAt = now;
_lastSwitchAt = now;
if (next.Tier >= InterestTier.Action)
{
_lastInterestingAt = now;
}
CameraDirector.Watch(next);
}
private static void TryAmbient(bool force)
{
float now = Time.unscaledTime;
_lastAmbientAt = now;
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
if (ambient == null)
{
return;
}
if (!force && _current != null && _current.Tier >= InterestTier.Story)
{
return;
}
// Don't let ambient yank the camera off a curiosity tip after a few seconds.
if (!force && _current != null && _current.Tier == InterestTier.Curiosity
&& now - _currentStartedAt < AmbientRotateSeconds)
{
return;
}
if (!force && now - _lastSwitchAt < SwitchCooldownSeconds && _current != null)
{
return;
}
// Avoid re-following the exact same unit every ambient tick.
if (!force
&& _current != null
&& _current.FollowUnit != null
&& ambient.FollowUnit != null
&& _current.FollowUnit == ambient.FollowUnit
&& ambient.Tier <= InterestTier.Ambient)
{
return;
}
SwitchTo(ambient, now);
LogService.LogInfo("[IdleSpectator] Ambient: " + ambient.Label);
}
}