worldbox-observer-mod/IdleSpectator/WatchCaption.cs

2408 lines
77 KiB
C#

using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, packed traits, reason.
/// </summary>
public static class WatchCaption
{
private const float PanelWidthMax = 420f;
private const float SpeciesSize = 16f;
private const float SexSize = 14f;
private const float LiveMax = 44f;
private const float ChipIcon = 12f;
/// <summary>Fixed nametag task label slot so text swaps do not autofit-tick the header.</summary>
private const float TaskLabelW = 72f;
private const float TraitIcon = 12f;
private const float HistoryIcon = 10f;
private const float HistoryColMinW = 118f;
private const float NameMinW = 28f;
/// <summary>Hard cap so long "Name (species)" lines cannot paint under the level chip.</summary>
private const float NameMaxW = 210f;
private const float PadX = 4f;
/// <summary>Inset from the canvas top-right corner (grows left via pivot).</summary>
private const float ScreenInset = 12f;
private const float PadTop = 4f;
private const float HeaderH = 18f;
private const float BodyH = 44f;
private const float TraitsH = 14f;
private const float ReasonH = 14f;
private const float HistoryLineMinH = 13f;
private const float HistoryLineMaxH = 100f;
private const float Gap = 2f;
private const int HistoryPeekMax = 3;
private const float BtnSize = 16f;
private const float StatusBannerSeconds = 5f;
/// <summary>History column grows with content until the dossier hits <see cref="PanelWidthMax"/>.</summary>
private static float HistoryColMaxW =>
Mathf.Max(HistoryColMinW, PanelWidthMax - PadX * 2f - LiveMax - 4f);
private static float _panelWidth = LiveMax + PadX * 2f;
private static readonly Color NameColor = new Color(0.95f, 0.93f, 0.88f, 1f);
private static readonly Color StatValueColor = new Color(1f, 0.62f, 0.28f, 1f);
private static readonly Color TaskColor = new Color(0.26f, 1f, 0.26f, 1f);
private static readonly Color ReasonColor = new Color(0.95f, 0.72f, 0.38f, 1f);
private static readonly Color TraitNameColor = new Color(0.78f, 0.8f, 0.84f, 1f);
private static readonly Color HistoryTextColor = new Color(0.82f, 0.84f, 0.86f, 1f);
private static readonly Color StatusBannerColor = new Color(1f, 0.55f, 0.18f, 1f);
private static GameObject _root;
private static RectTransform _rootRt;
private static Image _speciesIcon;
private static Image _sexIcon;
private static Text _nameText;
private static Text _reasonText;
private static Image _levelIcon;
private static Text _levelValue;
private static Image _taskIcon;
private static Text _taskText;
private static readonly TraitSlot[] _traitSlots = new TraitSlot[3];
private static GameObject _traitsRow;
private static GameObject _historyCol;
private static Image _activityBoxBg;
private static RectTransform _lifeSep;
private static readonly HistorySlot[] _historySlots = new HistorySlot[HistoryPeekMax];
private static readonly float[] _historySlotHeights = new float[HistoryPeekMax];
private static readonly bool[] _historySlotIsActivity = new bool[HistoryPeekMax];
private static float _historyColW = HistoryColMinW;
private static int _lastHistoryCount = -1;
private static long _lastHistorySubjectId;
private const float LifeSepH = 3f;
private static Button _favoriteBtn;
private static Image _favoriteIcon;
private static Button _historyBtn;
private static Image _historyIcon;
private static bool _pinnedWhilePaused;
private static UnitDossier _current;
private static Actor _boundActor;
private static bool _visible;
private static string _statusBanner = "";
private static float _statusBannerUntil;
private sealed class TraitSlot
{
public GameObject Root;
public Image Icon;
public Text Label;
}
private sealed class HistorySlot
{
public GameObject Root;
public Image Icon;
public Text Label;
}
public static UnitDossier Current => _current;
public static string LastHeadline { get; private set; } = "";
public static string LastDetail { get; private set; } = "";
public static string LastCaptionText { get; private set; } = "";
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
public static string LastHistoryPreview { get; private set; } = "";
/// <summary>Harness: all visible peek lines joined (activity + life).</summary>
public static string LastHistoryJoined { get; private set; } = "";
/// <summary>Harness: how many history lines are currently visible on the dossier.</summary>
public static int LastHistoryShown { get; private set; }
/// <summary>Harness: activity feed lines currently visible.</summary>
public static int LastActivityShown { get; private set; }
/// <summary>Harness: Chronicle Life lines currently visible in the dossier column.</summary>
public static int LastLifeShown { get; private set; }
/// <summary>Current dossier subject id (0 if none).</summary>
public static long CurrentUnitId => _current != null ? _current.UnitId : 0;
/// <summary>True when dossier is shown while idle is paused for Lore browsing.</summary>
public static bool PinnedWhilePaused => _pinnedWhilePaused;
/// <summary>True when the history column fills the body width (no empty strip beside it).</summary>
public static bool LastHistoryFillsBody { get; private set; }
/// <summary>Legacy alias; dossier history is no longer scrollable.</summary>
public static bool LastHistoryScrollable => false;
/// <summary>Legacy alias for harness compatibility.</summary>
public static bool HistoryExpanded => false;
/// <summary>Harness: comma-joined top trait labels currently shown.</summary>
public static string LastTraitsPreview { get; private set; } = "";
public static Vector2 LastPanelSize { get; private set; }
public static bool LastLayoutOk { get; private set; }
/// <summary>True when the dossier card GameObject is active.</summary>
public static bool Visible => _visible && _root != null && _root.activeSelf;
/// <summary>Screen-pixel rect of the dossier card (Unity bottom-left origin), if visible.</summary>
public static bool TryGetScreenPixelRect(out Rect pixelRect)
{
pixelRect = default;
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
{
return false;
}
Vector3[] corners = new Vector3[4];
_rootRt.GetWorldCorners(corners);
float xMin = float.MaxValue;
float yMin = float.MaxValue;
float xMax = float.MinValue;
float yMax = float.MinValue;
for (int i = 0; i < 4; i++)
{
Vector2 sp = RectTransformUtility.WorldToScreenPoint(null, corners[i]);
xMin = Mathf.Min(xMin, sp.x);
yMin = Mathf.Min(yMin, sp.y);
xMax = Mathf.Max(xMax, sp.x);
yMax = Mathf.Max(yMax, sp.y);
}
float pad = 4f;
pixelRect = Rect.MinMaxRect(xMin - pad, yMin - pad, xMax + pad, yMax + pad);
return pixelRect.width > 8f && pixelRect.height > 8f;
}
/// <summary>True if the mouse is over the dossier panel (including the live sprite).</summary>
public static bool IsPointerOverPanel()
{
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
{
return false;
}
return RectTransformUtility.RectangleContainsScreenPoint(_rootRt, Input.mousePosition, null);
}
/// <summary>True if the mouse is over the dossier live sprite only.</summary>
public static bool IsPointerOverAvatar()
{
return DossierAvatar.ContainsScreenPoint(Input.mousePosition);
}
/// <summary>
/// Favorite control (and the panel chrome around it).
/// Used so idle does not treat those clicks as "manual pause".
/// </summary>
public static bool IsPointerOverInteractive()
{
if (!IsPointerOverPanel())
{
return false;
}
if (IsPointerOverButton(_favoriteBtn) || IsPointerOverButton(_historyBtn))
{
return true;
}
// Whole dossier counts as UI while visible so history/trait chrome is safe to click.
return _root != null && _root.activeInHierarchy;
}
private static bool IsPointerOverButton(Button button)
{
if (button == null || !button.gameObject.activeInHierarchy)
{
return false;
}
RectTransform rt = button.GetComponent<RectTransform>();
return rt != null
&& RectTransformUtility.RectangleContainsScreenPoint(rt, Input.mousePosition, null);
}
public static void ToggleFavorite()
{
Actor actor = _boundActor;
if (actor == null || !actor.isAlive())
{
if (MoveCamera.hasFocusUnit())
{
actor = MoveCamera._focus_unit;
}
}
if (actor != null && actor.isAlive())
{
try
{
actor.switchFavorite();
}
catch
{
return;
}
Chronicle.SyncFavoriteFromActor(actor);
RefreshFavoriteVisual(actor);
LogService.LogInfo(
$"[IdleSpectator] Favorite toggled name={SafeActorName(actor)} favorite={actor.isFavorite()}");
return;
}
// Fallen / archive: track in Chronicle so the Fallen filter stays consistent.
long id = _current != null ? _current.UnitId : 0;
if (id == 0)
{
id = Chronicle.CurrentHistorySubjectId();
}
if (id == 0)
{
return;
}
bool next = !Chronicle.IsFavoriteSubject(id);
Chronicle.SetFavoriteSubject(id, next);
if (_current != null && _current.UnitId == id)
{
_current.IsFavorite = next;
}
RefreshFavoriteVisual(null);
LogService.LogInfo($"[IdleSpectator] Favorite toggled fallen id={id} favorite={next}");
}
public static void PinWhilePaused()
{
_pinnedWhilePaused = true;
if (ModSettings.ShowDossierCaption)
{
SetVisible(true);
}
}
public static void ClearPausePin()
{
_pinnedWhilePaused = false;
}
/// <summary>Open this unit's full history in the Lore panel and pause idle auto-follow.</summary>
public static void OpenFullHistoryInLore()
{
// Prefer the live camera focus so a pinned dossier cannot open the wrong unit's lore.
long id = Chronicle.CurrentHistorySubjectId();
if (id == 0 && _current != null)
{
id = _current.UnitId;
}
if (id == 0)
{
return;
}
ChronicleHud.OpenUnitHistory(id, pauseIdle: true, followFocus: true);
LogService.LogInfo($"[IdleSpectator] Open full history in Lore unitId={id}");
}
private static string SafeActorName(Actor actor)
{
try
{
string n = actor.getName();
if (!string.IsNullOrEmpty(n))
{
return n;
}
}
catch
{
// ignore
}
return "unit";
}
public static void Clear()
{
_current = null;
_boundActor = null;
LastHeadline = "";
LastDetail = "";
LastCaptionText = "";
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
_pinnedWhilePaused = false;
LastTraitsPreview = "";
LastPanelSize = Vector2.zero;
LastLayoutOk = false;
_lastHistoryCount = -1;
_lastHistorySubjectId = 0;
_historyColW = HistoryColMinW;
_statusBanner = "";
_statusBannerUntil = 0f;
ApplyVisual(null, null);
SetVisible(false);
}
/// <summary>
/// Show a short status line on the dossier (replaces the top WorldTip for pause exits).
/// </summary>
public static void ShowStatusBanner(string message, float seconds = StatusBannerSeconds)
{
if (!ModSettings.ShowDossierCaption || string.IsNullOrEmpty(message))
{
return;
}
EnsureBuilt();
_statusBanner = message;
_statusBannerUntil = Time.unscaledTime + Mathf.Max(0.5f, seconds);
if (_nameText != null && (string.IsNullOrEmpty(_nameText.text) || _current == null))
{
_nameText.text = "Idle Spectator";
LastHeadline = "Idle Spectator";
}
if (_reasonText != null)
{
_reasonText.text = message;
_reasonText.color = StatusBannerColor;
_reasonText.supportRichText = false;
_reasonText.resizeTextForBestFit = false;
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.gameObject.SetActive(true);
}
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
// Re-fill from the current subject so Relayout cannot resurrect stale history slots
// from a previously focused unit (activeSelf stays true while the column is hidden).
int hist = _current != null ? FillHistory(_current.UnitId) : 0;
int traits = CountActiveTraitSlots();
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasBody = _boundActor != null || hist > 0 || _current != null;
Relayout(hasBody, traits, hasTask, hasReason: true, hist);
SetVisible(true);
LogService.LogInfo("[IdleSpectator][CAPTION] status=" + message);
}
public static void SetFromInterest(InterestEvent interest)
{
Actor unit = interest != null && interest.HasFollowUnit
? interest.FollowUnit
: (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
if (unit == null || !unit.isAlive())
{
if (interest != null)
{
// Location-only / no unit: tip text for logs; dossier has no owned subject reason.
LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = "";
LastCaptionText = LastHeadline;
LastHistoryPreview = "";
LastHistoryJoined = "";
_current = null;
_boundActor = null;
EnsureBuilt();
ApplyVisual(null, null);
if (_nameText != null)
{
_nameText.text = LastHeadline;
}
Relayout(false, 0, false, false, 0);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
}
return;
}
// Reason ownership comes from InterestDirector.TryGetOwnedReasonCandidate
// (active dwell + owned unit), not from a sticky InterestEvent.Label.
SetFromActor(unit, label: null);
}
public static void SetFromActor(Actor actor, string label = null)
{
if (!_pinnedWhilePaused)
{
ClearStatusBanner();
}
UnitDossier dossier = UnitDossier.FromActor(actor, label);
_current = dossier;
_boundActor = actor;
LastHeadline = dossier.Headline ?? "";
LastDetail = dossier.DetailLine ?? "";
LastCaptionText = dossier.CaptionText ?? "";
EnsureBuilt();
ApplyVisual(actor, dossier);
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | "));
}
/// <summary>
/// Show a stored dossier with no living actor (Fallen archive last living state).
/// </summary>
public static void SetFromDossier(UnitDossier dossier)
{
if (dossier == null || dossier.UnitId == 0)
{
return;
}
if (!_pinnedWhilePaused)
{
ClearStatusBanner();
}
_current = dossier;
_boundActor = null;
LastHeadline = dossier.Headline ?? "";
LastDetail = dossier.DetailLine ?? "";
LastCaptionText = dossier.CaptionText ?? "";
EnsureBuilt();
ApplyVisual(null, dossier);
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
LogService.LogInfo(
"[IdleSpectator][CAPTION] fallen dossier " + LastCaptionText.Replace("\n", " | "));
}
public static void Update()
{
// History / manual pause: keep dossier pinned even after the banner timer expires.
if (_pinnedWhilePaused && ModSettings.ShowDossierCaption)
{
if (SpectatorMode.Active)
{
ClearPausePin();
ClearStatusBanner();
}
else
{
if (!_visible)
{
SetVisible(true);
}
// Keep the pause reason visible while browsing.
if (!string.IsNullOrEmpty(_statusBanner))
{
_statusBannerUntil = Time.unscaledTime + 1f;
}
RefreshLivePortrait();
RefreshLiveTask();
RefreshOwnedReason();
RefreshHistoryIfChanged();
return;
}
}
if (HasStatusBanner())
{
if (!_visible && ModSettings.ShowDossierCaption)
{
SetVisible(true);
}
if (!SpectatorMode.Active)
{
return;
}
// Idle resumed - drop banner and restore normal reason styling next ApplyVisual.
ClearStatusBanner();
}
if (!SpectatorMode.Active || !ModSettings.ShowDossierCaption)
{
if (_visible)
{
SetVisible(false);
}
return;
}
if (_root == null && !string.IsNullOrEmpty(LastCaptionText))
{
EnsureBuilt();
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (_current != null)
{
ApplyVisual(focus ?? _boundActor, _current);
}
else if (_nameText != null)
{
_nameText.text = LastHeadline;
Relayout(false, 0, false, false, 0);
}
SetVisible(true);
}
RefreshLivePortrait();
RefreshLiveTask();
RefreshOwnedReason();
RefreshHistoryIfChanged();
}
private static bool HasStatusBanner()
{
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
}
private static void ClearStatusBanner()
{
_statusBanner = "";
_statusBannerUntil = 0f;
if (_reasonText != null)
{
_reasonText.color = ReasonColor;
}
}
private static int CountActiveHistorySlots()
{
if (_historyCol == null || !_historyCol.activeSelf)
{
return 0;
}
int n = 0;
for (int i = 0; i < _historySlots.Length; i++)
{
if (_historySlots[i]?.Root != null && _historySlots[i].Root.activeSelf)
{
n++;
}
}
return n;
}
private static int CountActiveTraitSlots()
{
int n = 0;
for (int i = 0; i < _traitSlots.Length; i++)
{
if (_traitSlots[i]?.Root != null && _traitSlots[i].Root.activeSelf)
{
n++;
}
}
return n;
}
private static void RefreshLivePortrait()
{
if (!_visible)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
DossierAvatar.Show(actor);
BringHeaderFront();
}
/// <summary>
/// Keep orange reason in sync with director active dwell (clears in quiet_grace).
/// </summary>
private static void RefreshOwnedReason()
{
if (!_visible || _current == null || HasStatusBanner())
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
string next = UnitDossier.OwnedEventReason(actor, _current);
string prev = _current.ReasonLine ?? "";
if (next == prev)
{
return;
}
_current.ReasonLine = next;
LastDetail = _current.DetailLine ?? "";
LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine);
bool hasReason = !string.IsNullOrEmpty(next);
if (_reasonText != null)
{
_reasonText.text = hasReason ? next : "";
_reasonText.gameObject.SetActive(hasReason);
}
bool hasTask = !string.IsNullOrEmpty(_current.TaskText);
int traits = _current.TopTraits != null ? _current.TopTraits.Count : 0;
int hist = CountActiveHistorySlots();
Relayout(_current.UnitId != 0, traits, hasTask, hasReason, hist);
}
private static string JoinCaptionLines(string headline, string reason, string detail)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(headline))
{
sb.Append(headline);
}
if (!string.IsNullOrEmpty(reason))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(reason);
}
if (!string.IsNullOrEmpty(detail))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(detail);
}
return sb.ToString();
}
/// <summary>
/// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps).
/// Never clears the chip on empty AI gaps - only replaces text when a new task is present.
/// </summary>
private static void RefreshLiveTask()
{
if (!_visible || _current == null)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
string live = UnitDossier.ReadLiveTask(actor);
// Hold the last shown label through brief no-task gaps so autofit does not collapse.
if (string.IsNullOrEmpty(live))
{
return;
}
string prev = _current.TaskText ?? "";
if (live == prev)
{
return;
}
bool wasShowing = _taskText != null && _taskText.gameObject.activeSelf;
_current.TaskText = live;
ReplaceTaskChip(live);
// First appearance needs layout; later swaps only change text inside a fixed-width slot.
if (!wasShowing)
{
bool hasBody = _current.UnitId != 0;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
Relayout(hasBody, CountActiveTraitSlots(), hasTask: true, hasReason, CountActiveHistorySlots());
}
BringHeaderFront();
}
private static Actor ResolveBoundLiveActor()
{
Actor actor = _boundActor;
if (actor == null || !actor.isAlive())
{
if (MoveCamera.hasFocusUnit())
{
actor = MoveCamera._focus_unit;
}
}
if (actor == null || !actor.isAlive())
{
return null;
}
if (_current != null && actor.getID() != _current.UnitId)
{
return null;
}
return actor;
}
private static string TruncateTaskLabel(string taskText)
{
if (string.IsNullOrEmpty(taskText))
{
return "";
}
return taskText.Length > 14 ? taskText.Substring(0, 13) + "..." : taskText;
}
/// <summary>Show or replace the task chip without ever blanking mid-update.</summary>
private static void ReplaceTaskChip(string taskText)
{
if (string.IsNullOrEmpty(taskText))
{
return;
}
if (_taskIcon != null)
{
HudIcons.Apply(_taskIcon, HudIcons.Task());
_taskIcon.gameObject.SetActive(true);
}
if (_taskText != null)
{
_taskText.text = TruncateTaskLabel(taskText);
_taskText.gameObject.SetActive(true);
}
}
private static void ApplyTaskChip(string taskText)
{
if (!string.IsNullOrEmpty(taskText))
{
ReplaceTaskChip(taskText);
return;
}
if (_taskIcon != null)
{
HudIcons.Apply(_taskIcon, null);
_taskIcon.gameObject.SetActive(false);
}
if (_taskText != null)
{
_taskText.text = "";
_taskText.gameObject.SetActive(false);
}
}
/// <summary>
/// Harness: simulate a stale empty snapshot, then sync from live AI (replace, never stay blank if live has a task).
/// </summary>
public static string HarnessBlankAndRefreshTask()
{
if (_current != null)
{
_current.TaskText = "";
}
// Do not hide the chip here - RefreshLiveTask must replace in place when live task exists.
RefreshLiveTask();
return _current != null ? (_current.TaskText ?? "") : "";
}
/// <summary>Harness: text currently shown on the nametag (may be ellipsis-truncated).</summary>
public static string ShownNameChipText =>
_nameText != null && _nameText.gameObject.activeSelf ? (_nameText.text ?? "") : "";
/// <summary>Harness: text currently shown on the nametag task chip.</summary>
public static string ShownTaskChipText =>
_taskText != null && _taskText.gameObject.activeSelf ? (_taskText.text ?? "") : "";
/// <summary>Harness: force a long nametag headline and relayout (layout overlap tests).</summary>
public static void ForceNametagHeadline(string headline)
{
EnsureBuilt();
string text = string.IsNullOrEmpty(headline) ? "Nobody" : headline.Trim();
LastHeadline = text;
if (_nameText != null)
{
_nameText.text = text;
}
if (_current != null)
{
_current.Headline = text;
}
bool hasBody = _current != null && _current.UnitId != 0;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
Relayout(hasBody, CountActiveTraitSlots(), hasTask, hasReason, CountActiveHistorySlots());
}
private static void BringHeaderFront()
{
// Draw nametag above vanilla avatar chrome (which can paint outside its host).
void Front(Component c)
{
if (c != null)
{
c.transform.SetAsLastSibling();
}
}
Front(_speciesIcon);
Front(_nameText);
Front(_levelIcon);
Front(_levelValue);
Front(_taskIcon);
Front(_taskText);
Front(_sexIcon);
Front(_favoriteBtn);
Front(_historyBtn);
}
/// <summary>Harness: rebuild the dossier peek column after activity injects.</summary>
public static void ForceRefreshHistory()
{
_lastHistoryCount = -1;
if (!_visible || _current == null)
{
return;
}
RefreshHistoryIfChanged();
}
/// <summary>
/// Harness: force JobLabel (and optional watch reason) onto the focused dossier reason row.
/// </summary>
public static bool ForceJobLabelOnFocus(string jobLabel, string reasonOverride = "")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return false;
}
UnitDossier.HarnessJobLabelOverride = jobLabel ?? "";
UnitDossier.HarnessReasonOverride = reasonOverride ?? "";
SetFromActor(MoveCamera._focus_unit);
return _current != null
&& (!string.IsNullOrEmpty(_current.JobLabel) || !string.IsNullOrEmpty(_current.ReasonLine));
}
public static void ClearHarnessJobOverrides()
{
UnitDossier.ClearHarnessOverrides();
}
private static void RefreshHistoryIfChanged()
{
if (!_visible || _current == null)
{
return;
}
long id = _current.UnitId;
int count = ActivityLog.CountFor(id) + Chronicle.HistoryCountFor(id);
if (id == _lastHistorySubjectId && count == _lastHistoryCount)
{
return;
}
int shown = FillHistory(id);
// Re-run layout only when history presence flips or line count changes.
bool hasBody = _current != null;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
int traits = 0;
if (_traitsRow != null && _traitsRow.activeSelf)
{
for (int i = 0; i < _traitSlots.Length; i++)
{
if (_traitSlots[i] != null && _traitSlots[i].Root != null && _traitSlots[i].Root.activeSelf)
{
traits++;
}
}
}
Relayout(hasBody, traits, hasTask, hasReason, shown);
}
private static void ApplyVisual(Actor actor, UnitDossier dossier)
{
EnsureBuilt();
if (_root == null)
{
return;
}
bool hasLive = actor != null
&& actor.isAlive()
&& dossier != null
&& actor.getID() == dossier.UnitId;
_boundActor = hasLive ? actor : null;
if (_speciesIcon != null)
{
Sprite species = null;
if (hasLive)
{
species = HudIcons.FromActor(actor);
}
else if (dossier != null)
{
species = HudIcons.FromSpeciesId(dossier.SpeciesId);
}
HudIcons.Apply(_speciesIcon, species);
}
bool hasSex = false;
if (_sexIcon != null)
{
Sprite sexSprite = null;
if (dossier != null)
{
if (dossier.IsMale)
{
sexSprite = HudIcons.SexMale();
}
else if (dossier.IsFemale)
{
sexSprite = HudIcons.SexFemale();
}
}
HudIcons.Apply(_sexIcon, sexSprite);
hasSex = sexSprite != null;
_sexIcon.gameObject.SetActive(hasSex);
}
if (_nameText != null)
{
_nameText.text = dossier != null && !string.IsNullOrEmpty(dossier.Headline)
? dossier.Headline
: "";
}
bool hasBody = dossier != null && dossier.UnitId != 0;
DossierAvatar.SetActive(hasBody);
// Live units always show level; archive snapshots hide a blank 0.
bool hasLevel = hasBody && (hasLive || dossier.Level > 0);
if (_levelIcon != null)
{
HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null);
_levelIcon.gameObject.SetActive(hasLevel);
}
if (_levelValue != null)
{
_levelValue.text = hasLevel ? dossier.Level.ToString() : "";
_levelValue.gameObject.SetActive(hasLevel);
}
if (hasLive)
{
DossierAvatar.Show(actor);
ActivityLog.EnsureCurrentTask(actor);
}
else if (hasBody)
{
DossierAvatar.ShowSpecies(dossier.SpeciesId);
}
else
{
DossierAvatar.ClearActor();
}
bool hasTask = dossier != null && !string.IsNullOrEmpty(dossier.TaskText);
ApplyTaskChip(hasTask ? dossier.TaskText : "");
int traitCount = 0;
LastTraitsPreview = "";
if (_traitsRow != null)
{
if (dossier != null && dossier.TopTraits.Count > 0)
{
_traitsRow.SetActive(true);
System.Text.StringBuilder traitPreview = new System.Text.StringBuilder();
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null)
{
continue;
}
if (i < dossier.TopTraits.Count)
{
UnitDossier.TraitChip chip = dossier.TopTraits[i];
slot.Root.SetActive(true);
Sprite traitSprite = chip != null ? HudIcons.FromTrait(chip.Trait) : null;
if (traitSprite == null && chip != null && !string.IsNullOrEmpty(chip.Id))
{
traitSprite = HudIcons.FromUiIcon(chip.Id) ?? HudIcons.FromUiIcon("icon" + chip.Id);
}
HudIcons.Apply(slot.Icon, traitSprite);
string name = chip.Name ?? "";
if (slot.Label != null)
{
slot.Label.text = name;
}
if (traitPreview.Length > 0)
{
traitPreview.Append(", ");
}
traitPreview.Append(name);
traitCount++;
}
else
{
slot.Root.SetActive(false);
}
}
LastTraitsPreview = traitPreview.ToString();
}
else
{
_traitsRow.SetActive(false);
}
}
int historyCount = 0;
if (dossier != null)
{
historyCount = FillHistory(dossier.UnitId);
}
else
{
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
if (_historyCol != null)
{
_historyCol.SetActive(false);
}
}
bool hasReason = dossier != null && !string.IsNullOrEmpty(dossier.ReasonLine);
if (_reasonText != null)
{
_reasonText.text = hasReason ? dossier.ReasonLine : "";
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.resizeTextForBestFit = false;
_reasonText.gameObject.SetActive(hasReason);
}
Relayout(hasBody, traitCount, hasTask, hasReason, historyCount);
RefreshFavoriteVisual(hasLive ? actor : null);
}
private static int FillHistory(long unitId)
{
// Activity first (prominent), then Chronicle Life fills remaining peek slots.
// Always reserve one Life slot when the subject has chronicle history so Life
// does not vanish under a busy activity ring (and chronicle smoke stays valid).
int chronicleCount = Chronicle.HistoryCountFor(unitId);
int actCap = chronicleCount > 0 ? HistoryPeekMax - 1 : HistoryPeekMax;
IReadOnlyList<ActivityEntry> activity =
ActivityLog.LatestForSubject(unitId, actCap);
int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
IReadOnlyList<ChronicleEntry> life =
lifeNeed > 0
? Chronicle.LatestForSubject(unitId, lifeNeed)
: System.Array.Empty<ChronicleEntry>();
_lastHistorySubjectId = unitId;
_lastHistoryCount = ActivityLog.CountFor(unitId) + chronicleCount;
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
_historyColW = HistoryColMinW;
for (int i = 0; i < _historySlotHeights.Length; i++)
{
_historySlotHeights[i] = HistoryLineMinH;
}
if (_historyCol == null)
{
return 0;
}
int total = (activity?.Count ?? 0) + (life?.Count ?? 0);
if (total == 0)
{
// Soft fallback: show current task as a single activity line when the ring is empty.
string taskFallback = "";
try
{
Actor live = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (live != null && live.isAlive() && live.getID() == unitId && live.hasTask()
&& live.ai?.task != null)
{
taskFallback = live.ai.task.getLocalizedText() ?? "";
}
}
catch
{
taskFallback = "";
}
if (string.IsNullOrEmpty(taskFallback))
{
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root != null)
{
slot.Root.SetActive(false);
}
if (slot?.Label != null)
{
slot.Label.text = "";
}
_historySlotHeights[i] = 0f;
}
_historyCol.SetActive(false);
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
LastHistoryPreview = "";
LastHistoryJoined = "";
return 0;
}
activity = new[]
{
new ActivityEntry
{
DisplayLine = taskFallback,
Line = taskFallback,
Kind = ActivityKind.TaskStart
}
};
total = 1;
}
_historyCol.SetActive(true);
var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)>(HistoryPeekMax);
if (activity != null)
{
for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++)
{
ActivityEntry e = activity[i];
if (e == null)
{
continue;
}
string plain = !string.IsNullOrEmpty(e.DisplayLine) ? e.DisplayLine : e.Line;
if (string.IsNullOrEmpty(plain))
{
continue;
}
string rich = !string.IsNullOrEmpty(e.DisplayLineRich) ? e.DisplayLineRich : plain;
lines.Add((rich, plain, true, null, e.TaskId ?? ""));
}
}
if (life != null)
{
for (int i = 0; i < life.Count && lines.Count < HistoryPeekMax; i++)
{
ChronicleEntry e = life[i];
if (e == null)
{
continue;
}
string plain = e.DisplayLine ?? e.HudLine ?? "";
string rich = e.DisplayLineRich ?? plain;
if (string.IsNullOrEmpty(plain) && string.IsNullOrEmpty(rich))
{
continue;
}
lines.Add((rich, plain, false, e.Kind, ""));
}
}
float widestLabel = 0f;
for (int i = 0; i < lines.Count && i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Label == null)
{
continue;
}
Text label = slot.Label;
label.supportRichText = true;
label.resizeTextForBestFit = false;
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.alignment = TextAnchor.UpperLeft;
label.text = lines[i].plain;
Canvas.ForceUpdateCanvases();
try
{
widestLabel = Mathf.Max(widestLabel, label.preferredWidth);
}
catch
{
widestLabel = Mathf.Max(widestLabel, lines[i].plain.Length * 6.2f);
}
}
_historyColW = Mathf.Clamp(
widestLabel + HistoryIcon + 4f,
HistoryColMinW,
HistoryColMaxW);
ApplyCombinedSlotContents(lines, _historyColW);
LastHistoryShown = CountActiveHistorySlots();
return LastHistoryShown;
}
private static void ApplyCombinedSlotContents(
List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)> lines,
float colW)
{
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
LastHistoryPreview = "";
LastHistoryJoined = "";
LastActivityShown = 0;
LastLifeShown = 0;
var joined = new List<string>(HistoryPeekMax);
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root == null)
{
continue;
}
if (i >= lines.Count)
{
slot.Root.SetActive(false);
_historySlotHeights[i] = 0f;
_historySlotIsActivity[i] = false;
continue;
}
var row = lines[i];
_historySlotIsActivity[i] = row.isActivity;
slot.Root.SetActive(true);
Sprite icon = row.isActivity
? (HudIcons.ForActivityKey(row.activityKey)
?? HudIcons.FromUiIcon("iconClock")
?? HudIcons.ForChronicleKind(ChronicleKind.Other))
: HudIcons.ForChronicleKind(row.kind ?? ChronicleKind.Other);
HudIcons.Apply(slot.Icon, icon);
if (slot.Label != null)
{
slot.Label.supportRichText = true;
slot.Label.horizontalOverflow = HorizontalWrapMode.Wrap;
slot.Label.verticalOverflow = VerticalWrapMode.Overflow;
slot.Label.color = row.isActivity
? new Color(0.88f, 0.9f, 0.78f, 1f)
: HistoryTextColor;
slot.Label.text = row.rich ?? row.plain ?? "";
RectTransform lrt = slot.Label.GetComponent<RectTransform>();
lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
Canvas.ForceUpdateCanvases();
float needed = HistoryLineMinH;
try
{
needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
}
catch
{
int chars = (row.plain ?? "").Length;
int perLine = Mathf.Max(12, (int)(labelW / 6.2f));
int wrapLines = Mathf.Max(1, (chars + perLine - 1) / perLine);
needed = HistoryLineMinH * wrapLines;
}
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
}
else
{
_historySlotHeights[i] = HistoryLineMinH;
}
if (row.isActivity)
{
LastActivityShown++;
}
else
{
LastLifeShown++;
}
string plain = (row.plain ?? "").Replace("\n", " ");
joined.Add(plain);
if (string.IsNullOrEmpty(LastHistoryPreview))
{
LastHistoryPreview = plain;
}
}
LastHistoryJoined = string.Join(" | ", joined);
}
private static void RemeasureHistoryForWidth(float colW)
{
_historyColW = Mathf.Clamp(colW, HistoryColMinW, HistoryColMaxW);
float labelW = Mathf.Max(24f, _historyColW - HistoryIcon - 2f);
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root == null || !slot.Root.activeSelf || slot.Label == null)
{
continue;
}
RectTransform lrt = slot.Label.GetComponent<RectTransform>();
lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
Canvas.ForceUpdateCanvases();
float needed = HistoryLineMinH;
try
{
needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
}
catch
{
needed = _historySlotHeights[i] > 0.01f ? _historySlotHeights[i] : HistoryLineMinH;
}
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
}
}
private static float MeasureHistoryContentHeight(int historyCount)
{
if (historyCount <= 0)
{
return BodyH;
}
float total = 0f;
int used = 0;
bool hasAct = false;
bool hasLife = false;
for (int i = 0; i < _historySlotHeights.Length && used < historyCount; i++)
{
if (_historySlotHeights[i] <= 0.01f)
{
continue;
}
total += _historySlotHeights[i];
if (_historySlotIsActivity[i])
{
hasAct = true;
}
else
{
hasLife = true;
}
used++;
}
if (hasAct && hasLife)
{
total += LifeSepH;
}
return Mathf.Max(BodyH, total);
}
private static float MeasureHistoryColumnHeight(int historyCount)
{
return MeasureHistoryContentHeight(historyCount);
}
/// <summary>
/// Grow the reason row to fit full text (wrap, never ellipsis-truncate).
/// </summary>
private static float MeasureReasonHeight()
{
if (_reasonText == null || string.IsNullOrEmpty(_reasonText.text))
{
return ReasonH;
}
float width = Mathf.Max(40f, _panelWidth - PadX * 2f);
RectTransform rt = _reasonText.GetComponent<RectTransform>();
if (rt != null)
{
// preferredHeight needs a laid-out width.
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
}
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
float needed = _reasonText.preferredHeight + 2f;
if (needed < ReasonH)
{
return ReasonH;
}
return needed;
}
private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason, int historyCount)
{
float headerW = MeasureHeaderWidth(hasTask);
float traitsW = 0f;
bool traitsBeside = hasBody && historyCount <= 0 && traitCount > 0;
if (traitCount > 0 && !traitsBeside)
{
// Traits row spans panel; width contribution is handled after panel size.
traitsW = HistoryColMinW;
}
else if (traitsBeside)
{
traitsW = HistoryColMinW;
}
float bodyW = 0f;
if (hasBody)
{
if (historyCount > 0)
{
bodyW = LiveMax + 4f + _historyColW;
}
else if (traitsBeside)
{
bodyW = LiveMax + 4f + traitsW;
}
else
{
bodyW = LiveMax;
}
}
_panelWidth = Mathf.Clamp(
PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, LiveMax),
LiveMax + PadX * 2f,
PanelWidthMax);
LastHistoryFillsBody = historyCount <= 0;
if (historyCount > 0)
{
float fillW = _panelWidth - PadX * 2f - LiveMax - 4f;
if (fillW > _historyColW + 0.5f)
{
RemeasureHistoryForWidth(fillW);
}
else
{
_historyColW = Mathf.Min(_historyColW, fillW);
}
LastHistoryFillsBody = _historyColW >= fillW - 1f;
}
float y = PadTop;
PlaceHeader(y, hasTask);
y += HeaderH + Gap;
if (hasBody)
{
float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH;
PlaceBody(y, historyCount, bodyH);
if (historyCount > 0)
{
y += bodyH + Gap;
}
else if (traitsBeside)
{
PlaceTraitsBesideAvatar(y, traitCount);
y += BodyH + Gap;
}
else
{
y += BodyH + Gap;
}
}
if (traitCount > 0 && !traitsBeside)
{
PlaceTraits(y, traitCount);
y += TraitsH + Gap;
}
if (hasReason)
{
float reasonH = MeasureReasonHeight();
PlaceLine(_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null, y, reasonH, PadX);
y += reasonH + Gap;
}
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
if (_rootRt != null)
{
ApplyRootPlacement();
_rootRt.sizeDelta = new Vector2(_panelWidth, height);
LastPanelSize = _rootRt.sizeDelta;
}
BringHeaderFront();
LastLayoutOk = ProbeLayoutInside(height)
&& headerW > SpeciesSize
&& (historyCount <= 0 || LastHistoryFillsBody);
if (!LastLayoutOk)
{
LogService.LogInfo(
$"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} hist={historyCount} headerW={headerW} histFill={LastHistoryFillsBody} histW={_historyColW}");
}
}
/// <summary>Left-pack nametag on the root: [species] Name [lv]n [task] [sex].</summary>
private static float PlaceHeader(float yFromTop, bool hasTask)
{
float x = PadX;
if (_speciesIcon != null)
{
PlaceLeftChip(_speciesIcon.rectTransform, x, yFromTop + 1f, SpeciesSize, SpeciesSize);
x += SpeciesSize + 3f;
}
if (_nameText != null)
{
float nameW = FitNameChipWidth();
PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH);
x += nameW + 5f;
}
if (_levelIcon != null && _levelIcon.gameObject.activeSelf)
{
PlaceLeftChip(_levelIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
x += ChipIcon + 1f;
}
if (_levelValue != null && _levelValue.gameObject.activeSelf)
{
float lvW = Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f);
PlaceLeftChip(_levelValue.rectTransform, x, yFromTop, lvW, HeaderH);
x += lvW + 4f;
}
if (hasTask && _taskIcon != null && _taskText != null)
{
_taskIcon.gameObject.SetActive(true);
_taskText.gameObject.SetActive(true);
PlaceLeftChip(_taskIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
x += ChipIcon + 1f;
PlaceLeftChip(_taskText.rectTransform, x, yFromTop, TaskLabelW, HeaderH);
x += TaskLabelW + 4f;
}
else
{
if (_taskText != null)
{
_taskText.gameObject.SetActive(false);
}
if (_taskIcon != null)
{
_taskIcon.gameObject.SetActive(false);
}
}
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
{
PlaceLeftChip(_sexIcon.rectTransform, x, yFromTop + 2f, SexSize, SexSize);
x += SexSize;
}
x += 6f;
PlaceHeaderButton(_favoriteBtn, x, yFromTop);
x += BtnSize + 2f;
PlaceHeaderButton(_historyBtn, x, yFromTop);
x += BtnSize;
RefreshFavoriteVisual(_boundActor);
BringHeaderFront();
return Mathf.Max(0f, x - PadX);
}
private static float MeasureHeaderWidth(bool hasTask)
{
float x = PadX + SpeciesSize + 3f;
if (_nameText != null)
{
x += FitNameChipWidth() + 5f;
}
if (_levelIcon != null && _levelIcon.gameObject.activeSelf)
{
x += ChipIcon + 1f;
}
if (_levelValue != null && _levelValue.gameObject.activeSelf)
{
x += Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f) + 4f;
}
if (hasTask && _taskIcon != null && _taskText != null)
{
x += ChipIcon + 1f;
x += TaskLabelW + 4f;
}
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
{
x += SexSize;
}
x += 6f + BtnSize + 2f + BtnSize;
return Mathf.Max(0f, x - PadX);
}
private static void PlaceHeaderButton(Button button, float x, float yFromTop)
{
if (button == null)
{
return;
}
button.gameObject.SetActive(true);
PlaceLeftChip(button.GetComponent<RectTransform>(), x, yFromTop + 1f, BtnSize, BtnSize);
}
private static void RefreshFavoriteVisual(Actor actor)
{
if (_favoriteIcon == null)
{
return;
}
bool fav = false;
try
{
if (actor != null && actor.isAlive() && actor.isFavorite())
{
fav = true;
}
}
catch
{
fav = false;
}
if (!fav)
{
long id = _current != null ? _current.UnitId : 0;
if (id == 0 && actor != null)
{
try
{
id = actor.getID();
}
catch
{
id = 0;
}
}
fav = Chronicle.IsFavoriteSubject(id) || (_current != null && _current.IsFavorite);
}
HudIcons.Apply(_favoriteIcon, HudIcons.Favorite());
_favoriteIcon.color = fav
? new Color(1f, 0.85f, 0.25f, 1f)
: new Color(0.55f, 0.58f, 0.62f, 1f);
}
private static void PlaceLeftChip(RectTransform rt, float x, float yFromTop, float width, float height)
{
if (rt == null)
{
return;
}
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.anchoredPosition = new Vector2(x, -yFromTop);
rt.sizeDelta = new Vector2(width, height);
}
/// <summary>
/// Size the name chip to the visible text and ellipsis-truncate when over <see cref="NameMaxW"/>.
/// Prevents Overflow paint from landing under the level badge (long "Name (species)" lines).
/// </summary>
private static float FitNameChipWidth()
{
if (_nameText == null)
{
return NameMinW;
}
_nameText.resizeTextForBestFit = false;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? "");
if (string.IsNullOrEmpty(full))
{
_nameText.text = "";
return NameMinW;
}
_nameText.text = full;
float natural = MeasureTextWidth(_nameText, 72f);
if (natural <= NameMaxW)
{
return Mathf.Clamp(natural, NameMinW, NameMaxW);
}
const string ellipsis = "...";
int lo = 0;
int hi = full.Length;
string best = ellipsis;
while (lo <= hi)
{
int mid = (lo + hi) / 2;
string candidate = mid <= 0
? ellipsis
: (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis);
_nameText.text = candidate;
float w = MeasureTextWidth(_nameText, NameMaxW);
if (w <= NameMaxW)
{
best = candidate;
lo = mid + 1;
}
else
{
hi = mid - 1;
}
}
_nameText.text = best;
return Mathf.Clamp(MeasureTextWidth(_nameText, NameMaxW), NameMinW, NameMaxW);
}
private static float MeasureTextWidth(Text text, float fallback)
{
if (text == null || string.IsNullOrEmpty(text.text))
{
return fallback;
}
try
{
float w = text.preferredWidth;
if (w > 1f)
{
return w + 2f;
}
}
catch
{
// fall through
}
return fallback;
}
private static void PlaceBody(float yFromTop, int historyCount, float bodyH)
{
DossierAvatar.Place(PadX, yFromTop, LiveMax);
if (_historyCol == null)
{
return;
}
if (historyCount <= 0)
{
_historyCol.SetActive(false);
if (_activityBoxBg != null)
{
_activityBoxBg.gameObject.SetActive(false);
}
if (_lifeSep != null)
{
_lifeSep.gameObject.SetActive(false);
}
return;
}
_historyCol.SetActive(true);
RectTransform col = _historyCol.GetComponent<RectTransform>();
col.anchorMin = new Vector2(0f, 1f);
col.anchorMax = new Vector2(0f, 1f);
col.pivot = new Vector2(0f, 1f);
col.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop);
col.sizeDelta = new Vector2(_historyColW, bodyH);
float top = 0f;
float activityTop = -1f;
float activityBottom = 0f;
bool sawLife = false;
bool placedSep = false;
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
{
continue;
}
bool isAct = _historySlotIsActivity[i];
if (!isAct && !placedSep && activityTop >= 0f)
{
// Thin rule between Activity box and Life.
if (_lifeSep != null)
{
_lifeSep.gameObject.SetActive(true);
_lifeSep.SetParent(_historyCol.transform, false);
_lifeSep.anchorMin = new Vector2(0f, 1f);
_lifeSep.anchorMax = new Vector2(1f, 1f);
_lifeSep.pivot = new Vector2(0.5f, 1f);
_lifeSep.offsetMin = new Vector2(2f, -(top + LifeSepH));
_lifeSep.offsetMax = new Vector2(-2f, -top);
}
top += LifeSepH;
placedSep = true;
}
if (!isAct)
{
sawLife = true;
}
float lineH = _historySlotHeights[i] > 0.01f ? _historySlotHeights[i] : HistoryLineMinH;
if (isAct)
{
if (activityTop < 0f)
{
activityTop = top;
}
activityBottom = top + lineH;
}
RectTransform rt = slot.Root.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(0f, -(top + lineH));
rt.offsetMax = new Vector2(0f, -top);
top += lineH;
if (slot.Icon != null)
{
RectTransform irt = slot.Icon.GetComponent<RectTransform>();
irt.anchorMin = new Vector2(0f, 1f);
irt.anchorMax = new Vector2(0f, 1f);
irt.pivot = new Vector2(0f, 1f);
irt.anchoredPosition = new Vector2(0f, -1f);
irt.sizeDelta = new Vector2(HistoryIcon, HistoryIcon);
}
if (slot.Label != null)
{
RectTransform lrt = slot.Label.GetComponent<RectTransform>();
lrt.anchorMin = new Vector2(0f, 0f);
lrt.anchorMax = new Vector2(1f, 1f);
lrt.offsetMin = new Vector2(HistoryIcon + 2f, 0f);
lrt.offsetMax = new Vector2(0f, -1f);
}
}
if (_lifeSep != null && (!sawLife || activityTop < 0f))
{
_lifeSep.gameObject.SetActive(false);
}
if (_activityBoxBg != null)
{
if (activityTop >= 0f && activityBottom > activityTop)
{
_activityBoxBg.gameObject.SetActive(true);
RectTransform box = _activityBoxBg.rectTransform;
box.SetParent(_historyCol.transform, false);
box.SetAsFirstSibling();
box.anchorMin = new Vector2(0f, 1f);
box.anchorMax = new Vector2(1f, 1f);
box.pivot = new Vector2(0.5f, 1f);
box.offsetMin = new Vector2(0f, -activityBottom);
box.offsetMax = new Vector2(0f, -activityTop);
}
else
{
_activityBoxBg.gameObject.SetActive(false);
}
}
}
private static float PlaceTraits(float yFromTop, int traitCount)
{
if (_traitsRow == null)
{
return 0f;
}
RectTransform row = _traitsRow.GetComponent<RectTransform>();
row.anchorMin = new Vector2(0f, 1f);
row.anchorMax = new Vector2(1f, 1f);
row.pivot = new Vector2(0.5f, 1f);
row.offsetMin = new Vector2(PadX, -(yFromTop + TraitsH));
row.offsetMax = new Vector2(-PadX, -yFromTop);
float x = 0f;
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
{
continue;
}
float labelW = slot.Label != null
? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f)
: 28f;
float slotW = TraitIcon + 2f + labelW;
PlaceTraitSlot(slot, x, 0f, slotW, TraitsH);
x += slotW + 3f;
}
return x > 0f ? x - 3f : 0f;
}
/// <summary>Vertical trait stack in the body-right slot when History is empty.</summary>
private static float PlaceTraitsBesideAvatar(float yFromTop, int traitCount)
{
if (_traitsRow == null)
{
return 0f;
}
float colW = 0f;
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf || slot.Label == null)
{
continue;
}
float labelW = Mathf.Max(MeasureTextWidth(slot.Label, 36f), 24f);
colW = Mathf.Max(colW, TraitIcon + 2f + labelW);
}
if (colW < 1f)
{
colW = 56f;
}
RectTransform row = _traitsRow.GetComponent<RectTransform>();
row.anchorMin = new Vector2(0f, 1f);
row.anchorMax = new Vector2(0f, 1f);
row.pivot = new Vector2(0f, 1f);
row.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop);
row.sizeDelta = new Vector2(colW, BodyH);
float line = Mathf.Min(HistoryLineMinH, BodyH / Mathf.Max(1, traitCount));
int shown = 0;
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
{
continue;
}
PlaceTraitSlot(slot, 0f, -(shown * line), colW, line);
shown++;
}
return colW;
}
private static void PlaceTraitSlot(TraitSlot slot, float x, float y, float slotW, float slotH)
{
RectTransform rt = slot.Root.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.anchoredPosition = new Vector2(x, y);
rt.sizeDelta = new Vector2(slotW, slotH);
if (slot.Icon != null)
{
RectTransform irt = slot.Icon.GetComponent<RectTransform>();
irt.anchorMin = new Vector2(0f, 0.5f);
irt.anchorMax = new Vector2(0f, 0.5f);
irt.pivot = new Vector2(0f, 0.5f);
irt.anchoredPosition = Vector2.zero;
irt.sizeDelta = new Vector2(TraitIcon, TraitIcon);
}
if (slot.Label != null)
{
RectTransform lrt = slot.Label.GetComponent<RectTransform>();
lrt.anchorMin = new Vector2(0f, 0f);
lrt.anchorMax = new Vector2(1f, 1f);
lrt.offsetMin = new Vector2(TraitIcon + 2f, 0f);
lrt.offsetMax = Vector2.zero;
}
}
private static bool ProbeLayoutInside(float panelHeight)
{
if (_nameText == null)
{
return false;
}
return LineInside(_nameText.GetComponent<RectTransform>(), panelHeight)
&& (_reasonText == null || !_reasonText.gameObject.activeSelf
|| LineInside(_reasonText.GetComponent<RectTransform>(), panelHeight))
&& (_traitsRow == null || !_traitsRow.activeSelf
|| LineInside(_traitsRow.GetComponent<RectTransform>(), panelHeight))
&& (_historyCol == null || !_historyCol.activeSelf
|| LineInside(_historyCol.GetComponent<RectTransform>(), panelHeight));
}
private static bool LineInside(RectTransform line, float panelHeight)
{
if (line == null)
{
return true;
}
float top = -line.offsetMax.y;
float bottom = -line.offsetMin.y;
if (Mathf.Approximately(line.offsetMin.y, 0f) && Mathf.Approximately(line.offsetMax.y, 0f)
&& line.anchorMin.y == line.anchorMax.y)
{
float y = -line.anchoredPosition.y;
float h = line.sizeDelta.y > 0.1f ? line.sizeDelta.y : TraitsH;
return y >= 0f && y + h <= panelHeight + 0.5f;
}
if (line.anchorMin.y == line.anchorMax.y && line.anchorMin.x == line.anchorMax.x)
{
float y = -line.anchoredPosition.y;
float h = line.sizeDelta.y > 0.1f ? line.sizeDelta.y : HeaderH;
return y >= 0f && y + h <= panelHeight + 0.5f;
}
return top >= 0f && bottom <= panelHeight + 0.5f && top < bottom;
}
private static void SetVisible(bool visible)
{
EnsureBuilt();
if (_root == null)
{
return;
}
_visible = visible;
_root.SetActive(visible);
}
private static void EnsureBuilt()
{
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform
|| _historyCol == null
|| _historyBtn == null
|| _root.transform.Find("HistoryScroll") != null))
{
// Stale HUD from an older build (nested nametag row / non-scroll history) - rebuild.
try
{
Object.Destroy(_root);
}
catch
{
// ignore
}
_root = null;
_rootRt = null;
_speciesIcon = null;
_sexIcon = null;
_nameText = null;
_reasonText = null;
_levelIcon = null;
_levelValue = null;
_taskIcon = null;
_taskText = null;
_traitsRow = null;
_historyCol = null;
_activityBoxBg = null;
_lifeSep = null;
_favoriteBtn = null;
_favoriteIcon = null;
_historyBtn = null;
_historyIcon = null;
DossierAvatar.ResetHost();
}
if (_root != null)
{
return;
}
Canvas canvas = HudCanvas.Resolve();
if (canvas == null)
{
return;
}
_root = new GameObject("IdleSpectatorDossierHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
_root.transform.SetParent(canvas.transform, false);
_rootRt = _root.GetComponent<RectTransform>();
ApplyRootPlacement();
_rootRt.sizeDelta = new Vector2(LiveMax + PadX * 2f, HeaderH + PadTop * 2f);
Image bg = _root.GetComponent<Image>();
bg.raycastTarget = false;
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.9f));
CanvasGroup group = _root.GetComponent<CanvasGroup>();
// Buttons need raycasts; panel chrome still ignores hits via raycastTarget=false on bg/text.
group.blocksRaycasts = true;
group.interactable = true;
// Avatar/history/traits first so nametag chips can SetAsLastSibling on top.
DossierAvatar.EnsureHost(_root.transform, LiveMax);
_historyCol = new GameObject("HistoryCol", typeof(RectTransform));
_historyCol.transform.SetParent(_root.transform, false);
GameObject boxGo = new GameObject("ActivityBox", typeof(RectTransform), typeof(Image));
boxGo.transform.SetParent(_historyCol.transform, false);
_activityBoxBg = boxGo.GetComponent<Image>();
_activityBoxBg.color = new Color(0.14f, 0.16f, 0.14f, 0.55f);
_activityBoxBg.raycastTarget = false;
boxGo.SetActive(false);
GameObject sepGo = new GameObject("LifeSep", typeof(RectTransform), typeof(Image));
sepGo.transform.SetParent(_historyCol.transform, false);
_lifeSep = sepGo.GetComponent<RectTransform>();
Image sepImg = sepGo.GetComponent<Image>();
sepImg.color = new Color(0.55f, 0.58f, 0.62f, 0.55f);
sepImg.raycastTarget = false;
sepGo.SetActive(false);
for (int i = 0; i < _historySlots.Length; i++)
{
GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform));
slotGo.transform.SetParent(_historyCol.transform, false);
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", HistoryIcon);
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
label.color = HistoryTextColor;
label.alignment = TextAnchor.MiddleLeft;
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.supportRichText = true;
label.resizeTextMinSize = 6;
label.resizeTextMaxSize = 8;
_historySlots[i] = new HistorySlot { Root = slotGo, Icon = icon, Label = label };
slotGo.SetActive(false);
}
_traitsRow = new GameObject("TraitsRow", typeof(RectTransform));
_traitsRow.transform.SetParent(_root.transform, false);
for (int i = 0; i < _traitSlots.Length; i++)
{
GameObject slotGo = new GameObject("Trait" + i, typeof(RectTransform));
slotGo.transform.SetParent(_traitsRow.transform, false);
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
label.color = TraitNameColor;
label.alignment = TextAnchor.MiddleLeft;
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.resizeTextMinSize = 6;
label.resizeTextMaxSize = 8;
_traitSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label };
slotGo.SetActive(false);
}
_reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 8);
_reasonText.color = ReasonColor;
_reasonText.alignment = TextAnchor.UpperLeft;
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.resizeTextForBestFit = false;
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11);
_nameText.fontStyle = FontStyle.Bold;
_nameText.color = NameColor;
_nameText.alignment = TextAnchor.MiddleLeft;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
_levelIcon = HudCanvas.MakeIcon(_root.transform, "LevelIcon", ChipIcon);
_levelValue = HudCanvas.MakeText(_root.transform, "LevelValue", "", 9);
_levelValue.color = StatValueColor;
_levelValue.fontStyle = FontStyle.Bold;
_levelValue.alignment = TextAnchor.MiddleLeft;
_levelValue.horizontalOverflow = HorizontalWrapMode.Overflow;
_taskIcon = HudCanvas.MakeIcon(_root.transform, "TaskIcon", ChipIcon);
_taskText = HudCanvas.MakeText(_root.transform, "Task", "", 8);
_taskText.color = TaskColor;
_taskText.alignment = TextAnchor.MiddleLeft;
_taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
BuildHeaderButtons();
DossierAvatar.SetActive(false);
_historyCol.SetActive(false);
_traitsRow.SetActive(false);
_reasonText.gameObject.SetActive(false);
_levelIcon.gameObject.SetActive(false);
_levelValue.gameObject.SetActive(false);
_taskIcon.gameObject.SetActive(false);
_taskText.gameObject.SetActive(false);
Relayout(false, 0, false, false, 0);
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag chips + mini history)");
}
private static void BuildHeaderButtons()
{
_favoriteBtn = BuildIconButton(_root.transform, "FavoriteBtn", HudIcons.Favorite(), ToggleFavorite);
_favoriteIcon = _favoriteBtn.transform.Find("Icon")?.GetComponent<Image>();
_historyBtn = BuildIconButton(_root.transform, "HistoryBtn", HudIcons.ExpandHistory(), OpenFullHistoryInLore);
_historyIcon = _historyBtn.transform.Find("Icon")?.GetComponent<Image>();
if (_historyIcon != null)
{
_historyIcon.color = new Color(0.75f, 0.78f, 0.85f, 1f);
}
}
private static Button BuildIconButton(Transform parent, string name, Sprite icon, UnityAction onClick)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(BtnSize, BtnSize);
Image bg = go.GetComponent<Image>();
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.85f);
bg.raycastTarget = true;
Image iconImg = HudCanvas.MakeIcon(go.transform, "Icon", BtnSize - 4f);
RectTransform iconRt = iconImg.GetComponent<RectTransform>();
iconRt.anchorMin = new Vector2(0.5f, 0.5f);
iconRt.anchorMax = new Vector2(0.5f, 0.5f);
iconRt.pivot = new Vector2(0.5f, 0.5f);
iconRt.anchoredPosition = Vector2.zero;
iconImg.raycastTarget = false;
HudIcons.Apply(iconImg, icon);
Button button = go.GetComponent<Button>();
button.transition = Selectable.Transition.ColorTint;
button.navigation = new Navigation { mode = Navigation.Mode.None };
button.targetGraphic = bg;
button.onClick.AddListener(onClick);
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
colors.pressedColor = new Color(0.8f, 0.8f, 0.85f, 1f);
button.colors = colors;
return button;
}
/// <summary>
/// Pin the card to the canvas top-right. Pivot (1,1) so width growth expands left and stays on-screen.
/// </summary>
private static void ApplyRootPlacement()
{
if (_rootRt == null)
{
return;
}
_rootRt.anchorMin = new Vector2(1f, 1f);
_rootRt.anchorMax = new Vector2(1f, 1f);
_rootRt.pivot = new Vector2(1f, 1f);
_rootRt.anchoredPosition = new Vector2(-ScreenInset, -ScreenInset);
_rootRt.localScale = Vector3.one;
}
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height, float left)
{
if (lineRt == null)
{
return;
}
lineRt.anchorMin = new Vector2(0f, 1f);
lineRt.anchorMax = new Vector2(1f, 1f);
lineRt.pivot = new Vector2(0.5f, 1f);
lineRt.offsetMin = new Vector2(left, -(yFromTop + height));
lineRt.offsetMax = new Vector2(-PadX, -yFromTop);
}
}