Kinda stable
This commit is contained in:
parent
9bd1c084c7
commit
f76788db69
17 changed files with 813 additions and 54 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -48,3 +48,7 @@ desktop.ini
|
|||
.dotnet/
|
||||
.local/
|
||||
*.decompiled.cs
|
||||
|
||||
# local test arm files
|
||||
IdleSpectator/.auto-test
|
||||
IdleSpectator/.force-on
|
||||
|
|
|
|||
105
IdleSpectator/AutoTest.cs
Normal file
105
IdleSpectator/AutoTest.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
using System.IO;
|
||||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>.auto-test</c>
|
||||
/// file in the mod folder (deleted when finished).
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
IdleSpectator/CanvasMainPatches.cs
Normal file
20
IdleSpectator/CanvasMainPatches.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(CanvasMain), nameof(CanvasMain.isBottomBarShowing))]
|
||||
internal static class CanvasMainPatches
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
private static void KeepPowerBarHiddenWhileIdleSpectating(ref bool __result)
|
||||
{
|
||||
if (SpectatorMode.Active)
|
||||
{
|
||||
__result = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
6
IdleSpectator/Locales/en.json
Normal file
6
IdleSpectator/Locales/en.json
Normal file
|
|
@ -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)"
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.IO;
|
||||
using HarmonyLib;
|
||||
using NeoModLoader.api;
|
||||
using NeoModLoader.services;
|
||||
|
|
@ -6,13 +7,14 @@ using UnityEngine;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Idle Spectator entry: F8 AFK camera director over WorldLog events.
|
||||
/// Idle Spectator entry: I key AFK camera director over WorldLog events.
|
||||
/// </summary>
|
||||
public class ModClass : MonoBehaviour, IMod
|
||||
public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
|
||||
{
|
||||
private ModDeclare _declare;
|
||||
private GameObject _gameObject;
|
||||
private Harmony _harmony;
|
||||
private ModConfig _config;
|
||||
|
||||
/// <summary>Absolute path to this mod's folder (parent of mod.json).</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
IdleSpectator/ModSettings.cs
Normal file
74
IdleSpectator/ModSettings.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using System.IO;
|
||||
using NeoModLoader.api;
|
||||
using NeoModLoader.constants;
|
||||
using NeoModLoader.services;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// NML ModConfig accessors for Idle Spectator.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>NML config callback: IdleSpectator.ModSettings:OnEnabledChanged</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
121
IdleSpectator/SpeciesDiscovery.cs
Normal file
121
IdleSpectator/SpeciesDiscovery.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using System.Collections.Generic;
|
||||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Captures subspecies creation during map gen / before spectator is on,
|
||||
/// then drains a few as Curiosity once the world is playable.
|
||||
/// </summary>
|
||||
public static class SpeciesDiscovery
|
||||
{
|
||||
private const float DrainIntervalSeconds = 12f;
|
||||
private const int MaxBuffered = 24;
|
||||
private const int MaxDrainPerSession = 8;
|
||||
|
||||
private static readonly Queue<PendingSpecies> Pending = new Queue<PendingSpecies>();
|
||||
private static readonly HashSet<string> SeenAssets = new HashSet<string>();
|
||||
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})");
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@ using UnityEngine;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// ManualControl vs SpectatorMode toggle (F8).
|
||||
/// ManualControl vs SpectatorMode toggle (I).
|
||||
/// </summary>
|
||||
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
|
|||
/// </summary>
|
||||
private static void TryForceOnFile()
|
||||
{
|
||||
if (_forceFileChecked || Active)
|
||||
if (_forceFileChecked || Active || !ModSettings.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
161
IdleSpectator/StateProbe.cs
Normal file
161
IdleSpectator/StateProbe.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Samples focus / spectator / power-bar state and logs only on changes or faults.
|
||||
/// Tail Player.log for <c>[IdleSpectator][STATE]</c> and <c>[IdleSpectator][BAD]</c>.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, float> LastLiveEnqueuedAt = new Dictionary<string, float>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
/// <summary>
|
||||
/// Nearest living unit, optionally restricted to a specific actor asset id.
|
||||
/// </summary>
|
||||
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<Actor> EnumerateAliveUnits()
|
||||
{
|
||||
List<Actor> prepared = World.world?.units?.units_only_alive;
|
||||
|
|
|
|||
20
IdleSpectator/default_config.json
Normal file
20
IdleSpectator/default_config.json
Normal file
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
45
scripts/monitor-spectator.sh
Executable file
45
scripts/monitor-spectator.sh
Executable file
|
|
@ -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
|
||||
Loading…
Reference in a new issue