using System; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; public static class CameraDirector { public static string LastWatchLabel { get; private set; } = ""; public static string LastWatchAssetId { get; private set; } = ""; public static string LastFormattedWatchTip { get; private set; } = ""; public static void FocusUnit(Actor actor, bool updateCaption = true) { if (actor != null && actor.isAlive()) { RetargetFollow(actor); if (updateCaption && SpectatorMode.Active) { WatchCaption.SetFromActor(actor); } } } public static void Watch(InterestEvent interest) { if (interest == null || !interest.HasValidPosition) { return; } if (!Config.game_loaded || World.world == null) { return; } string tip = FormatWatchTip(interest); string label = interest.Label ?? ""; string assetId = interest.AssetId ?? ""; // AssetId-only churn (live_combat ↔ live_battle) must not re-log the same tip. bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal) || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal); bool framingOnly = tipChanged && (EventReason.IsHeadcountOnlyChange(LastWatchLabel, label) || EventReason.IsCombatFramingOnlyChange(LastWatchLabel, label)); LastFormattedWatchTip = tip; // LastWatchLabel is tip/harness telemetry only - never dossier reason truth. LastWatchLabel = label; LastWatchAssetId = assetId; // Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only. if (tipChanged && !framingOnly) { LogService.LogInfo($"[IdleSpectator] {tip}"); } Actor follow = ResolveFollowUnit(interest); if (follow != null && follow.isAlive()) { // Focus first, then nametag - same order as FocusUnit, so tip/focus/dossier // never diverge across a handoff frame. RetargetFollow(follow); // Same subject: skip full UnitDossier.FromActor rebuild (was walking every // alive unit for species counts on every Mass headcount tip refresh). // WatchCaption.Update already refreshes reason/task/identity live. long followId = EventFeedUtil.SafeId(follow); if (followId == 0 || followId != WatchCaption.CurrentUnitId) { WatchCaption.SetFromActor(follow); } return; } // No accepted living follow: tip already logged. Do not bind a rejected FollowUnit // (dead / species mismatch) as the dossier subject. if (!interest.HasFollowUnit) { WatchCaption.SetFromInterest(interest); } if (!MoveCamera.hasFocusUnit()) { PanCamera(interest.Position); } } private static Actor ResolveFollowUnit(InterestEvent interest) { if (interest == null) { return null; } Actor follow = interest.HasFollowUnit ? interest.FollowUnit : null; // Species discovery: only attach a species-matched unit, never a random stranger. bool speciesTip = interest.Label != null && interest.Label.StartsWith("New species:"); if (follow == null && speciesTip && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle" && interest.AssetId != "scored_unit") { follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId); } if (follow == null || !follow.isAlive()) { return null; } if (speciesTip && !string.IsNullOrEmpty(interest.AssetId) && follow.asset != null && follow.asset.id != interest.AssetId) { return WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId); } return follow; } public static string FormatWatchTip(InterestEvent interest) { if (interest == null || string.IsNullOrEmpty(interest.Label)) { return "Watching"; } return "Watching: " + interest.Label; } public static void ClearFollow() { MoveCamera.clearFocusUnitOnly(); } public static void PanTo(Vector3 pos) { PanCamera(pos); } private static void RetargetFollow(Actor actor) { if (MoveCamera.isCameraFollowingUnit(actor)) { return; } bool firstFocus = !MoveCamera.hasFocusUnit(); MoveCamera.setFocusUnit(actor); Chronicle.NoteFocus(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; } }