- Introduce new probes for framing approval and family coalescing - Implement exact presentation replay checks to prevent duplicate events - Refactor InterestVariety to manage exact replay cooldowns effectively - Update EventPresentability to improve candidate approval logic - Enhance InterestFeeds to streamline event registration and labeling
264 lines
8.7 KiB
C#
264 lines
8.7 KiB
C#
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<string, float> PresentedLabels =
|
|
new Dictionary<string, float>(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/caption cut.
|
|
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);
|
|
}
|
|
|
|
CommitWatchTelemetry(interest);
|
|
return;
|
|
}
|
|
|
|
// No accepted living follow: 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);
|
|
}
|
|
|
|
CommitWatchTelemetry(interest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publish reason telemetry only after camera focus and caption ownership have committed.
|
|
/// This makes Watching/focus/caption one observable transition rather than three frames.
|
|
/// </summary>
|
|
private static void CommitWatchTelemetry(InterestEvent interest)
|
|
{
|
|
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)
|
|
{
|
|
NotePresentedLabel(label, Time.unscaledTime);
|
|
LogService.LogInfo($"[IdleSpectator] {tip}");
|
|
}
|
|
}
|
|
|
|
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<string>();
|
|
foreach (KeyValuePair<string, float> 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;
|
|
}
|
|
}
|