using System; using System.Collections.Generic; 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; } = ""; private const float ExactPresentationCooldownSeconds = 90f; private static readonly Dictionary PresentedLabels = new Dictionary(StringComparer.OrdinalIgnoreCase); 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 incomingLabel = (interest.Label ?? "").Trim(); if (!string.IsNullOrEmpty(incomingLabel) && !string.Equals(incomingLabel, LastWatchLabel, StringComparison.Ordinal) && WasPresentedRecently(incomingLabel, Time.unscaledTime)) { // Sticky maintain paths may reassert a prior label without going through // candidate selection. Do not turn that refresh into a new camera cut or log, // but do commit the label when it belongs to the already-focused active scene. long incomingId = EventFeedUtil.SafeId(interest.FollowUnit); long focusedId = EventFeedUtil.SafeId( MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); InterestCandidate active = InterestDirector.CurrentCandidate; if (incomingId != 0 && incomingId == focusedId && active != null && string.Equals(active.Label ?? "", incomingLabel, StringComparison.Ordinal)) { RefreshWatchPresentation(interest, suppressReplayLog: true); } return; } 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); } else { WatchCaption.RefreshOwnedReasonNow(); } CommitWatchTelemetry(interest); return; } // No accepted living follow: do not bind a rejected FollowUnit // (dead / species mismatch) as the dossier subject. bool unmatchedSpeciesTip = (interest.Label ?? "").StartsWith( "New species:", StringComparison.Ordinal); if (unmatchedSpeciesTip) { // Discovery may know the species before a matching live unit can be resolved. // Keep the current living focus; never clear it in favor of a ghost asset id. WatchCaption.SetFromInterest(interest); CommitWatchTelemetry(interest); return; } WatchCaption.SetFromLocationInterest(interest); ClearFollow(); PanCamera(interest.Position); CommitWatchTelemetry(interest); } /// /// Publish reason telemetry only after camera focus and caption ownership have committed. /// This makes Watching/focus/caption one observable transition rather than three frames. /// private static void CommitWatchTelemetry( InterestEvent interest, bool suppressReplayLog = false) { 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 && !suppressReplayLog) { NotePresentedLabel(label, Time.unscaledTime); LogService.LogInfo($"[IdleSpectator] {tip}"); } } /// /// Commit a same-focus sticky reframe without retargeting the camera or rebuilding the /// dossier. Used for Duel/Battle/Mass and headcount changes that are presentation-only. /// public static void RefreshWatchPresentation( InterestEvent interest, bool suppressReplayLog = false) { if (interest == null) { return; } WatchCaption.RefreshOwnedReasonNow(); CommitWatchTelemetry(interest, suppressReplayLog); } private static bool WasPresentedRecently(string label, float now) { string key = EventReason.ReplayStructureKey(label); return !string.IsNullOrEmpty(key) && PresentedLabels.TryGetValue(key, out float shownAt) && now - shownAt >= 0f && now - shownAt < ExactPresentationCooldownSeconds; } private static void NotePresentedLabel(string label, float now) { string key = EventReason.ReplayStructureKey(label); if (string.IsNullOrEmpty(key)) return; PresentedLabels[key] = now; if (PresentedLabels.Count <= 128) return; var expired = new List(); foreach (KeyValuePair entry in PresentedLabels) { if (now - entry.Value >= ExactPresentationCooldownSeconds * 2f) { expired.Add(entry.Key); } } for (int i = 0; i < expired.Count; i++) PresentedLabels.Remove(expired[i]); } public static void ClearReplayLedger() { PresentedLabels.Clear(); } public static bool HarnessProbeExactPresentationReplay(out string detail) { PresentedLabels.Clear(); NotePresentedLabel("Duel - Aro vs Bex", 10f); bool sameBlocked = WasPresentedRecently("Duel - Aro vs Bex", 20f); bool reframedBlocked = WasPresentedRecently("Mass - Bex (4) vs Aro (7)", 20f); bool otherOpen = !WasPresentedRecently("Duel - Aro vs Cid", 20f); bool expired = !WasPresentedRecently("Duel - Aro vs Bex", 101f); bool ok = sameBlocked && reframedBlocked && otherOpen && expired; detail = "same=" + sameBlocked + " reframed=" + reframedBlocked + " other=" + otherOpen + " expired=" + expired + " pass=" + ok; PresentedLabels.Clear(); return ok; } 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 ClearWatchReason() { LastWatchLabel = ""; LastWatchAssetId = ""; LastFormattedWatchTip = "Watching"; } 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; } }