using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// /// Picks the next interesting target using dwell, cooldown, and tier interrupts. /// 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; } if (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); } } /// /// True when a drained New species tip would be allowed to take the camera. /// 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); } }