411 lines
13 KiB
C#
411 lines
13 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;
|
|
public const float InputExitGraceSeconds = 2.5f;
|
|
|
|
// Production defaults (copied when leaving harness fast timing).
|
|
private static float _minDwell = MinDwellSeconds;
|
|
private static float _curiosityDwell = CuriosityDwellSeconds;
|
|
private static float _curiosityRotate = CuriosityRotateSeconds;
|
|
private static float _switchCooldown = SwitchCooldownSeconds;
|
|
private static float _highTierInterrupt = HighTierInterruptAfterSeconds;
|
|
private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds;
|
|
private static float _ambientRotate = AmbientRotateSeconds;
|
|
private static float _actionPoll = ActionPollSeconds;
|
|
private static float _inputExitGrace = InputExitGraceSeconds;
|
|
|
|
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;
|
|
|
|
public static string CurrentTierName =>
|
|
_current == null ? "none" : _current.Tier.ToString();
|
|
|
|
public static string CurrentLabel =>
|
|
_current == null ? "" : (_current.Label ?? "");
|
|
|
|
public static bool InInputGrace =>
|
|
SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace;
|
|
|
|
/// <summary>
|
|
/// Harness-only: shorten dwell/rotate so director tests finish in seconds.
|
|
/// Game 5x speed does not help - this code uses <see cref="Time.unscaledTime"/>.
|
|
/// </summary>
|
|
public static void SetHarnessFastTiming(bool enabled)
|
|
{
|
|
if (enabled)
|
|
{
|
|
_minDwell = 1.5f;
|
|
_curiosityDwell = 1.2f;
|
|
_curiosityRotate = 1.5f;
|
|
_switchCooldown = 0.75f;
|
|
_highTierInterrupt = 0.75f;
|
|
_quietAmbientAfter = 1.5f;
|
|
_ambientRotate = 2.5f;
|
|
_actionPoll = 0.35f;
|
|
_inputExitGrace = 0.6f;
|
|
}
|
|
else
|
|
{
|
|
_minDwell = MinDwellSeconds;
|
|
_curiosityDwell = CuriosityDwellSeconds;
|
|
_curiosityRotate = CuriosityRotateSeconds;
|
|
_switchCooldown = SwitchCooldownSeconds;
|
|
_highTierInterrupt = HighTierInterruptAfterSeconds;
|
|
_quietAmbientAfter = QuietWorldAmbientAfterSeconds;
|
|
_ambientRotate = AmbientRotateSeconds;
|
|
_actionPoll = ActionPollSeconds;
|
|
_inputExitGrace = InputExitGraceSeconds;
|
|
}
|
|
}
|
|
|
|
/// <summary>Harness: pretend the current interest started <paramref name="seconds"/> ago.</summary>
|
|
public static void HarnessAgeCurrent(float seconds)
|
|
{
|
|
float age = Mathf.Max(0f, seconds);
|
|
float now = Time.unscaledTime;
|
|
_currentStartedAt = now - age;
|
|
_lastSwitchAt = now - age;
|
|
}
|
|
|
|
/// <summary>Harness: expire input-exit grace immediately.</summary>
|
|
public static void HarnessExpireInputGrace()
|
|
{
|
|
_enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stop idle auto-follow the same way manual input does (quiet), for Lore history browsing.
|
|
/// Prefer <see cref="ChronicleHud.OpenUnitHistory"/> which focuses first then banners.
|
|
/// </summary>
|
|
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();
|
|
|
|
// During harness batches, freeze unless director_run explicitly unfreezes.
|
|
if (AgentHarness.FreezeDirector)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Time.unscaledTime - _enabledAt >= _inputExitGrace
|
|
&& 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 >= _actionPoll)
|
|
{
|
|
_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 >= _quietAmbientAfter;
|
|
bool dwellDone = _current == null || onCurrent >= DwellFor(_current);
|
|
bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
|
|
if (_current == null && sinceSwitch >= 0.5f)
|
|
{
|
|
TryAmbient(force: true);
|
|
}
|
|
else if (quiet && dwellDone && ambientDue && sinceSwitch >= _switchCooldown)
|
|
{
|
|
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 (or honorGrace path) and take the real manual-exit path.
|
|
/// When <paramref name="honorGrace"/> is true, input during the post-enable grace does not exit.
|
|
/// </summary>
|
|
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()
|
|
{
|
|
// 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()
|
|
&& !WatchCaption.IsPointerOverInteractive()
|
|
&& !ChronicleHud.IsPointerOverPanel()
|
|
&& (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 ? _curiosityDwell : _minDwell;
|
|
}
|
|
|
|
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 < _curiosityRotate)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (_current.Tier == InterestTier.Story)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (candidate.Tier > _current.Tier && onCurrent >= _highTierInterrupt)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return onCurrent >= DwellFor(_current) && sinceSwitch >= _switchCooldown;
|
|
}
|
|
|
|
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 < _ambientRotate)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!force && now - _lastSwitchAt < _switchCooldown && _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);
|
|
}
|
|
}
|