280 lines
8.4 KiB
C#
280 lines
8.4 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;
|
|
}
|
|
|
|
// Harness drives the camera/assert loop; ignore manual-input exit while commands run.
|
|
if (!AgentHarness.Busy
|
|
&& Time.unscaledTime - _enabledAt >= InputExitGraceSeconds
|
|
&& PlayerTookManualControl())
|
|
{
|
|
SpectatorMode.SetActive(false);
|
|
WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false);
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|