diff --git a/.gitignore b/.gitignore index 1dfd80c..6cffc46 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ desktop.ini .dotnet/ .local/ *.decompiled.cs + +# local test arm files +IdleSpectator/.auto-test +IdleSpectator/.force-on diff --git a/IdleSpectator/AutoTest.cs b/IdleSpectator/AutoTest.cs new file mode 100644 index 0000000..a23d525 --- /dev/null +++ b/IdleSpectator/AutoTest.cs @@ -0,0 +1,105 @@ +using System.IO; +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// One-shot automated session: enable spectator when a world is loaded, run for a while, +/// log a pass/fail verdict from StateProbe, then quit. Arm with an empty .auto-test +/// file in the mod folder (deleted when finished). +/// +public static class AutoTest +{ + private const float DurationSeconds = 50f; + private static bool _armed; + private static bool _running; + private static float _startedAt = -1f; + private static bool _finished; + + public static void Update() + { + if (_finished) + { + return; + } + + if (!_armed) + { + string path = AutoTestPath(); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + return; + } + + _armed = true; + LogService.LogInfo("[IdleSpectator][AUTOTEST] armed - waiting for world load"); + } + + if (!Config.game_loaded || World.world == null || SmoothLoader.isLoading()) + { + return; + } + + if (!_running) + { + _running = true; + _startedAt = Time.unscaledTime; + StateProbe.ResetCounters(); + if (!SpectatorMode.Active) + { + SpectatorMode.SetActive(true); + } + + LogService.LogInfo( + $"[IdleSpectator][AUTOTEST] started for {DurationSeconds:0}s (idle={SpectatorMode.Active})"); + return; + } + + if (Time.unscaledTime - _startedAt < DurationSeconds) + { + return; + } + + _finished = true; + int bad = StateProbe.BadEventCount; + int states = StateProbe.StateEventCount; + bool focusOk = MoveCamera.hasFocusUnit(); + bool barHidden = !CanvasMain.isBottomBarShowing(); + bool pass = bad == 0 && SpectatorMode.Active && focusOk && barHidden; + string verdict = pass ? "PASS" : "FAIL"; + LogService.LogInfo( + $"[IdleSpectator][AUTOTEST] {verdict} duration={DurationSeconds:0}s states={states} bad={bad} idle={SpectatorMode.Active} focus={focusOk} powerBar={!barHidden}"); + + try + { + string path = AutoTestPath(); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // ignore + } + + // Do not Application.Quit() - that often freezes the Steam/Unity process on Linux. + LogService.LogInfo("[IdleSpectator][AUTOTEST] finished (leaving game running)"); + if (SpectatorMode.Active) + { + SpectatorMode.SetActive(false); + } + } + + private static string AutoTestPath() + { + string modFolder = ModClass.ModFolder; + if (string.IsNullOrEmpty(modFolder)) + { + return null; + } + + return Path.Combine(modFolder, ".auto-test"); + } +} diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index a7a77da..09f5898 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -17,25 +17,109 @@ public static class CameraDirector return; } - string tip = string.IsNullOrEmpty(interest.Label) - ? "Watching event" - : "Watching: " + interest.Label; - WorldTip.showNowTop(tip, pTranslate: false); + string tip = FormatWatchTip(interest); + if (ModSettings.ShowWatchReasons) + { + WorldTip.showNowTop(tip, pTranslate: false); + } + LogService.LogInfo($"[IdleSpectator] {tip}"); - if (interest.HasFollowUnit) + Actor follow = interest.HasFollowUnit + ? interest.FollowUnit + : null; + + // Never attach a random nearby unit to a "new species" tip - that caused ghost focuses + // (e.g. tip says crab, camera follows a cat). + if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle" + && interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:")) { - World.world.locateAndFollow(interest.FollowUnit, null, null); + follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId); + } + else if (follow == null) + { + follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f); + } + + if (follow != null && follow.isAlive()) + { + // If this is a species event, refuse mismatched units. + if (interest.Label != null && interest.Label.StartsWith("New species:") + && !string.IsNullOrEmpty(interest.AssetId) + && follow.asset != null && follow.asset.id != interest.AssetId) + { + Actor matched = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId); + if (matched == null) + { + return; + } + + follow = matched; + } + + RetargetFollow(follow); return; } - World.world.locatePosition(interest.Position); + // No unit available: pan only if we are not already following someone. + // Never clear an existing focus unit here - that flashes the power bar. + if (!MoveCamera.hasFocusUnit()) + { + PanCamera(interest.Position); + } + } + + public static string FormatWatchTip(InterestEvent interest) + { + string tier = interest.Tier.ToString(); + string reason = string.IsNullOrEmpty(interest.Label) + ? "something interesting" + : interest.Label; + return $"Watching [{tier}]: {reason}"; } public static void ClearFollow() { - // PowerButtonSelector.instance is internal to the game assembly. - // Clearing the focus unit is enough to leave native spectator follow. MoveCamera.clearFocusUnitOnly(); } + + private static void RetargetFollow(Actor actor) + { + if (MoveCamera.isCameraFollowingUnit(actor)) + { + return; + } + + bool firstFocus = !MoveCamera.hasFocusUnit(); + MoveCamera.setFocusUnit(actor); + + MoveCamera cam = MoveCamera.instance; + if (cam == null) + { + return; + } + + // Same as vanilla killer handoff: reset ease so the camera lerps to the new unit. + cam._focus_timer = 0f; + if (firstFocus) + { + cam._target_zoom = 15f; + cam._focus_zoom = 15f; + } + } + + private static void PanCamera(Vector3 pos) + { + MoveCamera cam = MoveCamera.instance; + if (cam == null) + { + World.world.locatePosition(pos); + return; + } + + pos.z = cam.transform.position.z; + cam.transform.position = pos; + cam._target_zoom = 15f; + cam._focus_zoom = 15f; + } } diff --git a/IdleSpectator/CanvasMainPatches.cs b/IdleSpectator/CanvasMainPatches.cs new file mode 100644 index 0000000..9068ff5 --- /dev/null +++ b/IdleSpectator/CanvasMainPatches.cs @@ -0,0 +1,20 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// +/// Keep the power bar hidden for the whole Idle Spectator session, even if focus +/// briefly drops between targets (vanilla only hides it while a focus unit is set). +/// +[HarmonyPatch(typeof(CanvasMain), nameof(CanvasMain.isBottomBarShowing))] +internal static class CanvasMainPatches +{ + [HarmonyPostfix] + private static void KeepPowerBarHiddenWhileIdleSpectating(ref bool __result) + { + if (SpectatorMode.Active) + { + __result = false; + } + } +} diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index b79680e..4ee111a 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -23,8 +23,7 @@ public static class InterestDirector private static float _lastAmbientAt = -999f; private static float _lastActionPollAt = -999f; private static float _enabledAt = -999f; - private static bool _prevCameraDrag; - private const float DragExitGraceSeconds = 2.5f; + private const float InputExitGraceSeconds = 2.5f; public static void OnSpectatorEnabled() { @@ -37,7 +36,6 @@ public static class InterestDirector _lastAmbientAt = -999f; _lastActionPollAt = -999f; _enabledAt = now; - _prevCameraDrag = MoveCamera.camera_drag_run; TryAmbient(force: true); } @@ -54,16 +52,12 @@ public static class InterestDirector return; } - bool dragging = MoveCamera.camera_drag_run; - bool pastGrace = Time.unscaledTime - _enabledAt >= DragExitGraceSeconds; - if (pastGrace && dragging && !_prevCameraDrag) + if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds && PlayerTookManualControl()) { - _prevCameraDrag = dragging; SpectatorMode.SetActive(false); - WorldTip.showNowTop("Idle Spectator paused (camera drag)", pTranslate: false); + WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false); return; } - _prevCameraDrag = dragging; float now = Time.unscaledTime; float onCurrent = now - _currentStartedAt; @@ -116,6 +110,44 @@ public static class InterestDirector } } + 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) @@ -189,6 +221,13 @@ public static class InterestDirector 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; diff --git a/IdleSpectator/Locales/en.json b/IdleSpectator/Locales/en.json new file mode 100644 index 0000000..f57ab76 --- /dev/null +++ b/IdleSpectator/Locales/en.json @@ -0,0 +1,6 @@ +{ + "Spectator": "Idle Spectator", + "enabled": "Enable Idle Spectator (I)", + "show_watch_reasons": "Show why the camera is watching", + "debug_state_probe": "Log focus/power-bar state (for debugging)" +} diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index ab36a21..031c634 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -1,3 +1,4 @@ +using System.IO; using HarmonyLib; using NeoModLoader.api; using NeoModLoader.services; @@ -6,13 +7,14 @@ using UnityEngine; namespace IdleSpectator; /// -/// Idle Spectator entry: F8 AFK camera director over WorldLog events. +/// Idle Spectator entry: I key AFK camera director over WorldLog events. /// -public class ModClass : MonoBehaviour, IMod +public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable { private ModDeclare _declare; private GameObject _gameObject; private Harmony _harmony; + private ModConfig _config; /// Absolute path to this mod's folder (parent of mod.json). public static string ModFolder { get; private set; } @@ -32,21 +34,35 @@ public class ModClass : MonoBehaviour, IMod return ""; } + public ModConfig GetConfig() + { + return _config; + } + + public string GetLocaleFilesDirectory(ModDeclare pModDeclare) + { + return Path.Combine(pModDeclare.FolderPath, "Locales"); + } + public void OnLoad(ModDeclare pModDecl, GameObject pGameObject) { _declare = pModDecl; _gameObject = pGameObject; ModFolder = pModDecl.FolderPath; + _config = ModSettings.Load(pModDecl); _harmony = new Harmony(pModDecl.UID); _harmony.PatchAll(typeof(ModClass).Assembly); LogService.LogInfo($"[{pModDecl.Name}]: Harmony patches applied"); - LogService.LogInfo($"[{pModDecl.Name}]: Press F8 to toggle Idle Spectator"); + LogService.LogInfo($"[{pModDecl.Name}]: Press I to toggle Idle Spectator (disable in mod settings)"); } private void Update() { SpectatorMode.PollInput(); InterestDirector.Update(); + SpeciesDiscovery.Update(); + StateProbe.Update(); + AutoTest.Update(); } } diff --git a/IdleSpectator/ModSettings.cs b/IdleSpectator/ModSettings.cs new file mode 100644 index 0000000..f9dbc75 --- /dev/null +++ b/IdleSpectator/ModSettings.cs @@ -0,0 +1,74 @@ +using System.IO; +using NeoModLoader.api; +using NeoModLoader.constants; +using NeoModLoader.services; + +namespace IdleSpectator; + +/// +/// NML ModConfig accessors for Idle Spectator. +/// +public static class ModSettings +{ + public const string GroupId = "Spectator"; + public const string EnabledId = "enabled"; + public const string ShowWatchReasonsId = "show_watch_reasons"; + public const string DebugStateProbeId = "debug_state_probe"; + + private static ModConfig _config; + + public static bool Enabled => GetBool(EnabledId, defaultValue: true); + + public static bool ShowWatchReasons => GetBool(ShowWatchReasonsId, defaultValue: true); + + public static bool DebugStateProbe => GetBool(DebugStateProbeId, defaultValue: true); + + public static ModConfig Load(ModDeclare declare) + { + string persistentPath = Path.Combine(Paths.ModsConfigPath, declare.UID + ".config"); + ModConfig persistent = new ModConfig(persistentPath, pIsPersistent: true); + string defaultsPath = Path.Combine(declare.FolderPath, Paths.ModDefaultConfigFileName); + if (File.Exists(defaultsPath)) + { + ModConfig defaults = new ModConfig(defaultsPath); + persistent.MergeWith(defaults); + } + + _config = persistent; + return _config; + } + + /// NML config callback: IdleSpectator.ModSettings:OnEnabledChanged + public static void OnEnabledChanged(bool enabled) + { + if (!enabled && SpectatorMode.Active) + { + SpectatorMode.SetActive(false); + LogService.LogInfo("[IdleSpectator] Disabled via settings"); + } + } + + private static bool GetBool(string id, bool defaultValue) + { + if (_config == null) + { + return defaultValue; + } + + try + { + ModConfigItem item = _config[GroupId][id]; + object value = item.GetValue(); + if (value is bool b) + { + return b; + } + } + catch + { + // Fall through to default. + } + + return defaultValue; + } +} diff --git a/IdleSpectator/SpeciesDiscovery.cs b/IdleSpectator/SpeciesDiscovery.cs new file mode 100644 index 0000000..783c262 --- /dev/null +++ b/IdleSpectator/SpeciesDiscovery.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Captures subspecies creation during map gen / before spectator is on, +/// then drains a few as Curiosity once the world is playable. +/// +public static class SpeciesDiscovery +{ + private const float DrainIntervalSeconds = 12f; + private const int MaxBuffered = 24; + private const int MaxDrainPerSession = 8; + + private static readonly Queue Pending = new Queue(); + private static readonly HashSet SeenAssets = new HashSet(); + private static float _lastDrainAt = -999f; + private static int _drainedThisSession; + + private struct PendingSpecies + { + public string AssetId; + public Vector3 Position; + public Subspecies Subspecies; + } + + public static void OnSpectatorEnabled() + { + _drainedThisSession = 0; + _lastDrainAt = Time.unscaledTime - DrainIntervalSeconds + 2f; + } + + public static void OnSpectatorDisabled() + { + // Keep buffer so re-enabling can still discover; clear session drain counter only. + _drainedThisSession = 0; + } + + public static void NoteCreated(ActorAsset asset, WorldTile tile, bool mutation, Subspecies subspecies) + { + if (mutation || asset == null || string.IsNullOrEmpty(asset.id)) + { + return; + } + + if (!SeenAssets.Add(asset.id)) + { + return; + } + + if (Pending.Count >= MaxBuffered) + { + Pending.Dequeue(); + } + + Vector3 pos = tile != null ? tile.posV3 : Vector3.zero; + Pending.Enqueue(new PendingSpecies + { + AssetId = asset.id, + Position = pos, + Subspecies = subspecies + }); + } + + public static void Update() + { + if (!SpectatorMode.Active || !Config.game_loaded || World.world == null) + { + return; + } + + if (SmoothLoader.isLoading()) + { + return; + } + + if (_drainedThisSession >= MaxDrainPerSession || Pending.Count == 0) + { + return; + } + + float now = Time.unscaledTime; + if (now - _lastDrainAt < DrainIntervalSeconds) + { + return; + } + + PendingSpecies next = Pending.Dequeue(); + ActorAsset asset = AssetManager.actor_library?.get(next.AssetId); + if (asset == null) + { + return; + } + + InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(next.Subspecies, asset, null); + if (curiosity == null) + { + // Unit may not exist yet - put back once, try later. + if (Pending.Count < MaxBuffered) + { + Pending.Enqueue(next); + } + + _lastDrainAt = now - DrainIntervalSeconds + 3f; + return; + } + + // Prefer the recorded spawn tile when the unit search used a weak position. + if (next.Position != Vector3.zero && curiosity.Position == Vector3.zero) + { + curiosity.Position = next.Position; + } + + _lastDrainAt = now; + _drainedThisSession++; + InterestCollector.EnqueueDirect(curiosity); + LogService.LogInfo($"[IdleSpectator] Discovery drain: {next.AssetId} ({_drainedThisSession}/{MaxDrainPerSession}, queued={Pending.Count})"); + } +} diff --git a/IdleSpectator/SpectatorMode.cs b/IdleSpectator/SpectatorMode.cs index c1a3d79..d8fd341 100644 --- a/IdleSpectator/SpectatorMode.cs +++ b/IdleSpectator/SpectatorMode.cs @@ -4,11 +4,11 @@ using UnityEngine; namespace IdleSpectator; /// -/// ManualControl vs SpectatorMode toggle (F8). +/// ManualControl vs SpectatorMode toggle (I). /// public static class SpectatorMode { - public const KeyCode ToggleKey = KeyCode.F8; + public const KeyCode ToggleKey = KeyCode.I; public static bool Active { get; private set; } @@ -21,6 +21,13 @@ public static class SpectatorMode public static void SetActive(bool active) { + if (active && !ModSettings.Enabled) + { + WorldTip.showNowTop("Idle Spectator disabled in mod settings", pTranslate: false); + LogService.LogInfo("[IdleSpectator] Enable blocked: disabled in settings"); + return; + } + if (Active == active) { return; @@ -29,14 +36,17 @@ public static class SpectatorMode Active = active; if (Active) { - InterestDirector.OnSpectatorEnabled(); - WorldTip.showNowTop("Idle Spectator ON (F8 to stop)", pTranslate: false); + InterestDirector.OnSpectatorEnabled(); + SpeciesDiscovery.OnSpectatorEnabled(); + WorldTip.showNowTop("Idle Spectator ON (I or any input to stop)", pTranslate: false); LogService.LogInfo("[IdleSpectator] Spectator mode enabled"); } else { InterestDirector.OnSpectatorDisabled(); + SpeciesDiscovery.OnSpectatorDisabled(); CameraDirector.ClearFollow(); + StateProbe.Reset(); WorldTip.showNowTop("Idle Spectator OFF", pTranslate: false); LogService.LogInfo("[IdleSpectator] Spectator mode disabled"); } @@ -49,6 +59,16 @@ public static class SpectatorMode return; } + if (!ModSettings.Enabled) + { + if (Active) + { + SetActive(false); + } + + return; + } + TryForceOnFile(); if (Input.GetKeyDown(ToggleKey)) @@ -62,7 +82,7 @@ public static class SpectatorMode /// private static void TryForceOnFile() { - if (_forceFileChecked || Active) + if (_forceFileChecked || Active || !ModSettings.Enabled) { return; } diff --git a/IdleSpectator/StateProbe.cs b/IdleSpectator/StateProbe.cs new file mode 100644 index 0000000..d3c73a8 --- /dev/null +++ b/IdleSpectator/StateProbe.cs @@ -0,0 +1,161 @@ +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Samples focus / spectator / power-bar state and logs only on changes or faults. +/// Tail Player.log for [IdleSpectator][STATE] and [IdleSpectator][BAD]. +/// +public static class StateProbe +{ + private static bool _prevIdle; + private static bool _prevFocus; + private static bool _prevVanillaSpec; + private static bool _prevBar; + private static string _prevUnit = ""; + private static float _focusLostSince = -1f; + private static float _lastHeartbeatAt = -999f; + private static float _lastBadPowerBarAt = -999f; + private static float _lastBadGapAt = -999f; + private static bool _hadFocusWhileIdle; + private const float HeartbeatSeconds = 10f; + private const float FocusGapBadSeconds = 0.35f; + + public static int BadEventCount { get; private set; } + public static int StateEventCount { get; private set; } + + public static void ResetCounters() + { + BadEventCount = 0; + StateEventCount = 0; + _lastBadPowerBarAt = -999f; + _lastBadGapAt = -999f; + _focusLostSince = -1f; + _lastHeartbeatAt = -999f; + _hadFocusWhileIdle = false; + } + + public static void Update() + { + if (!ModSettings.DebugStateProbe || !Config.game_loaded || World.world == null) + { + return; + } + + bool idle = SpectatorMode.Active; + bool focus = MoveCamera.hasFocusUnit(); + bool vanillaSpec = MoveCamera.inSpectatorMode(); + bool bar = CanvasMain.isBottomBarShowing(); + string unit = FocusUnitLabel(); + + bool changed = idle != _prevIdle + || focus != _prevFocus + || vanillaSpec != _prevVanillaSpec + || bar != _prevBar + || unit != _prevUnit; + + if (changed) + { + StateEventCount++; + LogService.LogInfo( + $"[IdleSpectator][STATE] idle={idle} focus={focus} vanillaSpec={vanillaSpec} powerBar={bar} unit={unit}"); + } + + if (idle && focus) + { + _hadFocusWhileIdle = true; + } + + if (idle && _prevFocus && !focus) + { + BadEventCount++; + LogService.LogInfo("[IdleSpectator][BAD] focus dropped while Idle Spectator is on"); + _focusLostSince = Time.unscaledTime; + } + + // Only treat prolonged no-focus as bad after we have successfully acquired a target. + // Fresh maps often have a short empty window before the first unit exists. + if (idle && !focus && _hadFocusWhileIdle) + { + if (_focusLostSince < 0f) + { + _focusLostSince = Time.unscaledTime; + } + else if (Time.unscaledTime - _focusLostSince >= FocusGapBadSeconds + && Time.unscaledTime - _lastBadGapAt >= 1f) + { + _lastBadGapAt = Time.unscaledTime; + BadEventCount++; + LogService.LogInfo( + $"[IdleSpectator][BAD] no focus unit for {Time.unscaledTime - _focusLostSince:0.00}s while idle on (power bar risk)"); + } + } + else if (focus) + { + _focusLostSince = -1f; + } + + if (idle && bar && Time.unscaledTime - _lastBadPowerBarAt >= 1f) + { + _lastBadPowerBarAt = Time.unscaledTime; + BadEventCount++; + LogService.LogInfo("[IdleSpectator][BAD] power bar showing while Idle Spectator is on"); + } + + if (idle && Time.unscaledTime - _lastHeartbeatAt >= HeartbeatSeconds) + { + _lastHeartbeatAt = Time.unscaledTime; + StateEventCount++; + LogService.LogInfo( + $"[IdleSpectator][STATE] heartbeat idle={idle} focus={focus} vanillaSpec={vanillaSpec} powerBar={bar} unit={unit}"); + } + + _prevIdle = idle; + _prevFocus = focus; + _prevVanillaSpec = vanillaSpec; + _prevBar = bar; + _prevUnit = unit; + } + + public static void Reset() + { + _prevIdle = false; + _prevFocus = false; + _prevVanillaSpec = false; + _prevBar = false; + _prevUnit = ""; + _focusLostSince = -1f; + _lastHeartbeatAt = -999f; + ResetCounters(); + } + + private static string FocusUnitLabel() + { + if (!MoveCamera.hasFocusUnit()) + { + return "-"; + } + + Actor unit = MoveCamera._focus_unit; + if (unit == null) + { + return "?"; + } + + try + { + string name = unit.getName(); + if (!string.IsNullOrEmpty(name)) + { + return name; + } + } + catch + { + // ignore + } + + return unit.asset != null ? unit.asset.id : "unit"; + } +} diff --git a/IdleSpectator/SubspeciesPatches.cs b/IdleSpectator/SubspeciesPatches.cs index e20178b..d7dd0e8 100644 --- a/IdleSpectator/SubspeciesPatches.cs +++ b/IdleSpectator/SubspeciesPatches.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using HarmonyLib; using UnityEngine; @@ -6,23 +7,44 @@ namespace IdleSpectator; [HarmonyPatch(typeof(SubspeciesManager), nameof(SubspeciesManager.newSpecies))] public static class SubspeciesNewSpeciesPatch { + private static readonly Dictionary LastLiveEnqueuedAt = new Dictionary(); + private const float PerSpeciesCooldownSeconds = 45f; + public static void Postfix(SubspeciesManager __instance, ActorAsset pAsset, WorldTile pTile, bool pMutation, Subspecies __result) { - if (!SpectatorMode.Active) + // Always remember first-seen assets (including during map gen) for later discovery drain. + SpeciesDiscovery.NoteCreated(pAsset, pTile, pMutation, __result); + + if (!SpectatorMode.Active || pMutation) { return; } - // Avoid flooding the queue during map generation / mass spawn. + // Live path: only while the world is playable, not during SmoothLoader gen flood. if (SmoothLoader.isLoading()) { return; } - InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(__result, pAsset, pTile); - if (curiosity != null) + string assetId = pAsset != null ? pAsset.id : null; + if (string.IsNullOrEmpty(assetId)) { - InterestCollector.EnqueueDirect(curiosity); + return; } + + float now = Time.unscaledTime; + if (LastLiveEnqueuedAt.TryGetValue(assetId, out float last) && now - last < PerSpeciesCooldownSeconds) + { + return; + } + + InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(__result, pAsset, pTile); + if (curiosity == null) + { + return; + } + + LastLiveEnqueuedAt[assetId] = now; + InterestCollector.EnqueueDirect(curiosity); } } diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index b5613d8..e84a4db 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -371,45 +371,51 @@ public static class WorldActivityScanner public static InterestEvent FromNewSpecies(Subspecies subspecies, ActorAsset asset, WorldTile tile) { - Vector3 pos = tile != null ? tile.posV3 : Vector3.zero; - Actor follow = null; - if (asset?.units != null) - { - foreach (Actor unit in asset.units) - { - if (unit != null && unit.isAlive() && (subspecies == null || unit.subspecies == subspecies)) - { - follow = unit; - pos = unit.current_position; - break; - } - } - } - - if (follow == null && pos == Vector3.zero) + if (asset == null) + { + return null; + } + + Vector3 pos = tile != null ? tile.posV3 : Vector3.zero; + Actor follow = FindMatchingSpeciesUnit(asset, subspecies, pos, 30f); + if (follow != null) + { + pos = follow.current_position; + } + + // No matching unit yet = ghost tip (wrong creature gets focused). Skip until one exists. + if (follow == null) { return null; } - string name = asset != null ? asset.id : (subspecies != null ? subspecies.name : "species"); return new InterestEvent { Tier = InterestTier.Curiosity, Score = 5f, Position = pos, FollowUnit = follow, - Label = $"New species: {name}", + Label = $"New species: {asset.id}", CreatedAt = Time.unscaledTime, - AssetId = "new_species" + AssetId = asset.id }; } - private static Actor FindNearestAliveUnit(Vector3 pos, float maxDist) + /// + /// Nearest living unit, optionally restricted to a specific actor asset id. + /// + public static Actor FindNearestAliveUnit(Vector3 pos, float maxDist, string requireAssetId = null) { Actor best = null; float bestDist = maxDist * maxDist; foreach (Actor actor in EnumerateAliveUnits()) { + if (requireAssetId != null + && (actor.asset == null || actor.asset.id != requireAssetId)) + { + continue; + } + float dx = actor.current_position.x - pos.x; float dy = actor.current_position.y - pos.y; float d2 = dx * dx + dy * dy; @@ -423,6 +429,22 @@ public static class WorldActivityScanner return best; } + private static Actor FindMatchingSpeciesUnit(ActorAsset asset, Subspecies subspecies, Vector3 nearPos, float maxDist) + { + if (asset?.units != null) + { + foreach (Actor unit in asset.units) + { + if (unit != null && unit.isAlive() && (subspecies == null || unit.subspecies == subspecies)) + { + return unit; + } + } + } + + return FindNearestAliveUnit(nearPos, maxDist, asset != null ? asset.id : null); + } + private static IEnumerable EnumerateAliveUnits() { List prepared = World.world?.units?.units_only_alive; diff --git a/IdleSpectator/default_config.json b/IdleSpectator/default_config.json new file mode 100644 index 0000000..8612a6a --- /dev/null +++ b/IdleSpectator/default_config.json @@ -0,0 +1,20 @@ +{ + "Spectator": [ + { + "Id": "enabled", + "Type": "SWITCH", + "BoolVal": true, + "Callback": "IdleSpectator.ModSettings:OnEnabledChanged" + }, + { + "Id": "show_watch_reasons", + "Type": "SWITCH", + "BoolVal": true + }, + { + "Id": "debug_state_probe", + "Type": "SWITCH", + "BoolVal": true + } + ] +} diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index a4c1d91..5af44ce 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.3.0", - "description": "AFK Idle Spectator (F8): follows wars, disasters, battles, creatures, and new species from fresh worlds to empires.", + "version": "0.3.9", + "description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species from fresh worlds to empires.", "GUID": "com.dazed.idlespectator" } diff --git a/README.md b/README.md index 19e00af..1b70f0b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director. ## Controls -- **F8** - toggle Idle Spectator on/off +- **I** - toggle Idle Spectator on/off - Dragging the camera while Spectator is on turns it off (after a short grace period) ## What it follows diff --git a/scripts/monitor-spectator.sh b/scripts/monitor-spectator.sh new file mode 100755 index 0000000..fe69d17 --- /dev/null +++ b/scripts/monitor-spectator.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Live-monitor Idle Spectator focus / power-bar health from Player.log. +# Usage: ./scripts/monitor-spectator.sh +# Restart WorldBox after enabling debug_state_probe, press I, leave it running. +set -euo pipefail + +LOG="${PLAYER_LOG:-$HOME/.config/unity3d/mkarpenko/WorldBox/Player.log}" + +if [[ ! -f "$LOG" ]]; then + echo "Waiting for Player.log at $LOG" + while [[ ! -f "$LOG" ]]; do sleep 1; done +fi + +echo "Monitoring $LOG" +echo "Looking for [IdleSpectator][STATE] / [IdleSpectator][BAD]" +echo "Healthy: idle=True focus=True powerBar=False while spectating" +echo "Bad: [BAD] lines, or powerBar=True while idle=True" +echo + +bad_count=0 +state_count=0 + +# Follow new lines; also print recent matching history once. +rg -n "\[IdleSpectator\]\[(STATE|BAD)\]|Spectator mode (enabled|disabled)|Watching \[" "$LOG" | tail -30 || true +echo "---- live ----" + +tail -n 0 -F "$LOG" | while IFS= read -r line; do + if [[ "$line" == *"[IdleSpectator][BAD]"* ]]; then + bad_count=$((bad_count + 1)) + printf '\033[1;31m%s\033[0m\n' "$line" + printf '\033[1;31m ^^ BAD #%s\033[0m\n' "$bad_count" + elif [[ "$line" == *"[IdleSpectator][STATE]"* ]]; then + state_count=$((state_count + 1)) + if [[ "$line" == *"idle=True"* && "$line" == *"powerBar=True"* ]]; then + printf '\033[1;31m%s\033[0m\n' "$line" + printf '\033[1;31m ^^ power bar visible while idle\033[0m\n' + elif [[ "$line" == *"idle=True"* && "$line" == *"focus=False"* ]]; then + printf '\033[1;33m%s\033[0m\n' "$line" + else + printf '\033[1;32m%s\033[0m\n' "$line" + fi + elif [[ "$line" == *"[IdleSpectator]"* ]]; then + echo "$line" + fi +done