using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; namespace IdleSpectator; /// /// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, packed traits, reason. /// 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; private const float TraitIcon = 12f; private const float HistoryIcon = 10f; private const float HistoryColMinW = 118f; private const float PadX = 4f; /// Inset from the canvas top-right corner (grows left via pivot). 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; /// History column grows with content until the dossier hits . 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; } = ""; /// Harness: newest mini-history line currently shown (if any). public static string LastHistoryPreview { get; private set; } = ""; /// Harness: all visible peek lines joined (activity + life). public static string LastHistoryJoined { get; private set; } = ""; /// Harness: how many history lines are currently visible on the dossier. public static int LastHistoryShown { get; private set; } /// Harness: activity feed lines currently visible. public static int LastActivityShown { get; private set; } /// Harness: Chronicle Life lines currently visible in the dossier column. public static int LastLifeShown { get; private set; } /// Current dossier subject id (0 if none). public static long CurrentUnitId => _current != null ? _current.UnitId : 0; /// True when dossier is shown while idle is paused for Lore browsing. public static bool PinnedWhilePaused => _pinnedWhilePaused; /// True when the history column fills the body width (no empty strip beside it). public static bool LastHistoryFillsBody { get; private set; } /// Legacy alias; dossier history is no longer scrollable. public static bool LastHistoryScrollable => false; /// Legacy alias for harness compatibility. public static bool HistoryExpanded => false; /// Harness: comma-joined top trait labels currently shown. public static string LastTraitsPreview { get; private set; } = ""; public static Vector2 LastPanelSize { get; private set; } public static bool LastLayoutOk { get; private set; } /// True when the dossier card GameObject is active. public static bool Visible => _visible && _root != null && _root.activeSelf; /// Screen-pixel rect of the dossier card (Unity bottom-left origin), if visible. 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; } /// True if the mouse is over the dossier panel (including the live sprite). public static bool IsPointerOverPanel() { if (_rootRt == null || _root == null || !_root.activeInHierarchy) { return false; } return RectTransformUtility.RectangleContainsScreenPoint(_rootRt, Input.mousePosition, null); } /// True if the mouse is over the dossier live sprite only. public static bool IsPointerOverAvatar() { return DossierAvatar.ContainsScreenPoint(Input.mousePosition); } /// /// Favorite control (and the panel chrome around it). /// Used so idle does not treat those clicks as "manual pause". /// 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(); 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; } /// Open this unit's full history in the Lore panel and pause idle auto-follow. 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); } /// /// Show a short status line on the dossier (replaces the top WorldTip for pause exits). /// 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) { 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; } SetFromActor(unit, interest?.Tier, interest?.Label); } public static void SetFromActor(Actor actor, InterestTier? tier = null, string label = null) { if (!_pinnedWhilePaused) { ClearStatusBanner(); } UnitDossier dossier = UnitDossier.FromActor(actor, tier, 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", " | ")); } /// /// Show a stored dossier with no living actor (Fallen archive last living state). /// 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(); 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(); 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 = _boundActor; if (actor == null || !actor.isAlive()) { if (MoveCamera.hasFocusUnit()) { actor = MoveCamera._focus_unit; } } if (actor == null || !actor.isAlive()) { return; } if (_current != null && actor.getID() != _current.UnitId) { return; } DossierAvatar.Show(actor); BringHeaderFront(); } 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); } /// Harness: rebuild the dossier peek column after activity injects. public static void ForceRefreshHistory() { _lastHistoryCount = -1; if (!_visible || _current == null) { return; } RefreshHistoryIfChanged(); } 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); if (_taskIcon != null) { HudIcons.Apply(_taskIcon, hasTask ? HudIcons.Task() : null); _taskIcon.gameObject.SetActive(hasTask); } if (_taskText != null) { string task = hasTask ? dossier.TaskText : ""; if (task.Length > 14) { task = task.Substring(0, 13) + "..."; } _taskText.text = task; _taskText.gameObject.SetActive(hasTask); } 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.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 activity = ActivityLog.LatestForSubject(unitId, actCap); int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0)); IReadOnlyList life = lifeNeed > 0 ? Chronicle.LatestForSubject(unitId, lifeNeed) : System.Array.Empty(); _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)>(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)); } } 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)> lines, float colW) { float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f); LastHistoryPreview = ""; LastHistoryJoined = ""; LastActivityShown = 0; LastLifeShown = 0; var joined = new List(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.FromUiIcon("iconClock") ?? HudIcons.FromUiIcon("iconTask") ?? 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(); 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(); 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); } 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) { PlaceLine(_reasonText != null ? _reasonText.GetComponent() : 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}"); } } /// Left-pack nametag on the root: [species] Name [lv]n [task] [sex]. 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 = Mathf.Clamp(MeasureTextWidth(_nameText, 72f), 28f, 120f); 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; float taskW = Mathf.Clamp(MeasureTextWidth(_taskText, 36f), 20f, 72f); PlaceLeftChip(_taskText.rectTransform, x, yFromTop, taskW, HeaderH); x += taskW + 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 += Mathf.Clamp(MeasureTextWidth(_nameText, 72f), 28f, 120f) + 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 += Mathf.Clamp(MeasureTextWidth(_taskText, 36f), 20f, 72f) + 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(), 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); } 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(); 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(); 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(); 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(); 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(); 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; } /// Vertical trait stack in the body-right slot when History is empty. 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(); 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(); 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(); 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(); 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(), panelHeight) && (_reasonText == null || !_reasonText.gameObject.activeSelf || LineInside(_reasonText.GetComponent(), panelHeight)) && (_traitsRow == null || !_traitsRow.activeSelf || LineInside(_traitsRow.GetComponent(), panelHeight)) && (_historyCol == null || !_historyCol.activeSelf || LineInside(_historyCol.GetComponent(), 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(); ApplyRootPlacement(); _rootRt.sizeDelta = new Vector2(LiveMax + PadX * 2f, HeaderH + PadTop * 2f); Image bg = _root.GetComponent(); bg.raycastTarget = false; HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.9f)); CanvasGroup group = _root.GetComponent(); // 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(); _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(); Image sepImg = sepGo.GetComponent(); 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.MiddleLeft; _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(); _historyBtn = BuildIconButton(_root.transform, "HistoryBtn", HudIcons.ExpandHistory(), OpenFullHistoryInLore); _historyIcon = _historyBtn.transform.Find("Icon")?.GetComponent(); 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(); rt.sizeDelta = new Vector2(BtnSize, BtnSize); Image bg = go.GetComponent(); 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(); 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