worldbox-observer-mod/IdleSpectator/CameraDirector.cs
DazedAnon 12e2ba0d01 feat(narrative): refine camera and narrative handling for civilized terminology
- Implement checks for civilized terminology in camera focus events
- Update event handling to suppress inappropriate pack language for civilized characters
- Enhance narrative presentation to differentiate between family and pack language
- Introduce new scenarios to validate significance of beats and relationships
- Improve documentation to clarify narrative structure and event significance
2026-07-24 14:06:23 -05:00

305 lines
10 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 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);
}
/// <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,
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}");
}
}
/// <summary>
/// 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.
/// </summary>
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<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;
}
}