using NeoModLoader.services; using UnityEngine; using UnityEngine.UI; namespace IdleSpectator; /// /// Persistent compact dossier (Option A / vanilla strip): /// header species+name+sex, live portrait + level/task, full-width trait strip, reason. /// public static class WatchCaption { private const float PanelWidth = 220f; private const float SpeciesSize = 16f; private const float SexSize = 14f; private const float LiveMax = 44f; private const float ChipIcon = 14f; private const float TraitIcon = 12f; private const float PadX = 4f; private const float PadTop = 4f; private const float HeaderH = 18f; private const float BodyH = 44f; private const float TraitsH = 16f; private const float ReasonH = 12f; private const float Gap = 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 GameObject _root; private static RectTransform _rootRt; private static Image _speciesIcon; private static Image _sexIcon; private static Text _nameText; private static Text _reasonText; private static Text _metaText; private static Image _levelIcon; private static Text _levelValue; private static Image _taskIcon; private static GameObject _metaCol; private static readonly TraitSlot[] _traitSlots = new TraitSlot[3]; private static GameObject _traitsRow; private static UnitDossier _current; private static Actor _boundActor; private static bool _visible; private sealed class TraitSlot { 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; } = ""; public static Vector2 LastPanelSize { get; private set; } public static bool LastLayoutOk { get; private set; } public static void Clear() { _current = null; _boundActor = null; LastHeadline = ""; LastDetail = ""; LastCaptionText = ""; LastPanelSize = Vector2.zero; LastLayoutOk = false; ApplyVisual(null, null); SetVisible(false); } 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; _current = null; _boundActor = null; EnsureBuilt(); ApplyVisual(null, null); if (_nameText != null) { _nameText.text = LastHeadline; } Relayout(false, 0, false, false); SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active); } return; } SetFromActor(unit, interest?.Tier, interest?.Label); } public static void SetFromActor(Actor actor, InterestTier? tier = null, string label = null) { 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); LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | ")); } public static void Update() { 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); } SetVisible(true); } RefreshLivePortrait(); } 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; } // Tile floor can change as the unit moves; keep portrait in sync. DossierAvatar.Show(actor); } private static void ApplyVisual(Actor actor, UnitDossier dossier) { EnsureBuilt(); if (_root == null) { return; } _boundActor = actor; if (_speciesIcon != null) { HudIcons.Apply(_speciesIcon, actor != null ? HudIcons.FromActor(actor) : null); } 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 && actor != null; DossierAvatar.SetActive(hasBody); if (_metaCol != null) { _metaCol.SetActive(hasBody); } if (hasBody) { DossierAvatar.Show(actor); HudIcons.Apply(_levelIcon, HudIcons.Level()); if (_levelValue != null) { _levelValue.text = dossier.Level.ToString(); } } 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 (_metaText != null) { _metaText.text = hasTask ? dossier.TaskText : ""; _metaText.gameObject.SetActive(hasTask); } int traitCount = 0; if (_traitsRow != null) { if (dossier != null && dossier.TopTraits.Count > 0) { _traitsRow.SetActive(true); 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); HudIcons.Apply(slot.Icon, HudIcons.FromTrait(chip.Trait)); if (slot.Label != null) { string label = chip.Name ?? ""; if (label.Length > 11) { label = label.Substring(0, 10) + "..."; } slot.Label.text = label; } traitCount++; } else { slot.Root.SetActive(false); } } } else { _traitsRow.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); } private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason) { float y = PadTop; PlaceHeader(y); y += HeaderH + Gap; if (hasBody) { PlaceBody(y); y += BodyH + Gap; } if (traitCount > 0) { 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) { _rootRt.sizeDelta = new Vector2(PanelWidth, height); LastPanelSize = _rootRt.sizeDelta; } LastLayoutOk = ProbeLayoutInside(height); if (!LastLayoutOk) { LogService.LogInfo( $"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} task={hasTask} reason={hasReason}"); } } private static void PlaceHeader(float yFromTop) { if (_speciesIcon != null) { RectTransform rt = _speciesIcon.GetComponent(); rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(0f, 1f); rt.pivot = new Vector2(0f, 1f); rt.anchoredPosition = new Vector2(PadX, -yFromTop); rt.sizeDelta = new Vector2(SpeciesSize, SpeciesSize); } if (_sexIcon != null) { RectTransform rt = _sexIcon.GetComponent(); rt.anchorMin = new Vector2(1f, 1f); rt.anchorMax = new Vector2(1f, 1f); rt.pivot = new Vector2(1f, 1f); rt.anchoredPosition = new Vector2(-PadX, -yFromTop - 1f); rt.sizeDelta = new Vector2(SexSize, SexSize); } if (_nameText != null) { RectTransform rt = _nameText.GetComponent(); rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f); rt.pivot = new Vector2(0.5f, 1f); float right = (_sexIcon != null && _sexIcon.gameObject.activeSelf) ? (PadX + SexSize + 2f) : PadX; rt.offsetMin = new Vector2(PadX + SpeciesSize + 4f, -(yFromTop + HeaderH)); rt.offsetMax = new Vector2(-right, -yFromTop); } } private static void PlaceBody(float yFromTop) { DossierAvatar.Place(PadX, yFromTop, LiveMax); if (_metaCol != null) { RectTransform col = _metaCol.GetComponent(); col.anchorMin = new Vector2(0f, 1f); col.anchorMax = new Vector2(1f, 1f); col.pivot = new Vector2(0.5f, 1f); col.offsetMin = new Vector2(PadX + LiveMax + 4f, -(yFromTop + BodyH)); col.offsetMax = new Vector2(-PadX, -yFromTop); } // Level row at top of meta col, task under it. if (_levelIcon != null) { RectTransform irt = _levelIcon.GetComponent(); irt.anchorMin = new Vector2(0f, 1f); irt.anchorMax = new Vector2(0f, 1f); irt.pivot = new Vector2(0f, 1f); irt.anchoredPosition = new Vector2(0f, -2f); irt.sizeDelta = new Vector2(ChipIcon, ChipIcon); } if (_levelValue != null) { RectTransform vrt = _levelValue.GetComponent(); vrt.anchorMin = new Vector2(0f, 1f); vrt.anchorMax = new Vector2(1f, 1f); vrt.pivot = new Vector2(0.5f, 1f); vrt.offsetMin = new Vector2(ChipIcon + 3f, -18f); vrt.offsetMax = new Vector2(0f, -2f); } if (_taskIcon != null) { RectTransform irt = _taskIcon.GetComponent(); irt.anchorMin = new Vector2(0f, 1f); irt.anchorMax = new Vector2(0f, 1f); irt.pivot = new Vector2(0f, 1f); irt.anchoredPosition = new Vector2(0f, -22f); irt.sizeDelta = new Vector2(ChipIcon, ChipIcon); } if (_metaText != null) { RectTransform trt = _metaText.GetComponent(); trt.anchorMin = new Vector2(0f, 1f); trt.anchorMax = new Vector2(1f, 1f); trt.pivot = new Vector2(0.5f, 1f); trt.offsetMin = new Vector2(ChipIcon + 3f, -38f); trt.offsetMax = new Vector2(0f, -22f); } } private static void PlaceTraits(float yFromTop, int traitCount) { if (_traitsRow == null) { return; } 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 innerW = PanelWidth - PadX * 2f; float slotW = innerW / Mathf.Max(1, traitCount); for (int i = 0; i < _traitSlots.Length; i++) { TraitSlot slot = _traitSlots[i]; if (slot == null || slot.Root == null || !slot.Root.activeSelf) { continue; } RectTransform rt = slot.Root.GetComponent(); rt.anchorMin = new Vector2(0f, 0f); rt.anchorMax = new Vector2(0f, 1f); rt.pivot = new Vector2(0f, 0.5f); rt.anchoredPosition = new Vector2(i * slotW, 0f); rt.sizeDelta = new Vector2(slotW - 2f, TraitsH); 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)); } 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; } 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) { 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(); _rootRt.anchorMin = new Vector2(0f, 1f); _rootRt.anchorMax = new Vector2(0f, 1f); _rootRt.pivot = new Vector2(0f, 1f); _rootRt.sizeDelta = new Vector2(PanelWidth, HeaderH + PadTop * 2f); _rootRt.anchoredPosition = new Vector2(12f, -12f); 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(); group.blocksRaycasts = false; group.interactable = 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; DossierAvatar.EnsureHost(_root.transform, LiveMax); _metaCol = new GameObject("MetaCol", typeof(RectTransform)); _metaCol.transform.SetParent(_root.transform, false); _levelIcon = HudCanvas.MakeIcon(_metaCol.transform, "LevelIcon", ChipIcon); _levelValue = HudCanvas.MakeText(_metaCol.transform, "LevelValue", "", 10); _levelValue.color = StatValueColor; _levelValue.fontStyle = FontStyle.Bold; _levelValue.alignment = TextAnchor.MiddleLeft; _levelValue.horizontalOverflow = HorizontalWrapMode.Overflow; _levelValue.resizeTextMinSize = 8; _levelValue.resizeTextMaxSize = 10; _taskIcon = HudCanvas.MakeIcon(_metaCol.transform, "TaskIcon", ChipIcon); _metaText = HudCanvas.MakeText(_metaCol.transform, "Task", "", 9); _metaText.color = TaskColor; _metaText.alignment = TextAnchor.MiddleLeft; _metaText.horizontalOverflow = HorizontalWrapMode.Overflow; _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; DossierAvatar.SetActive(false); _metaCol.SetActive(false); _traitsRow.SetActive(false); _reasonText.gameObject.SetActive(false); Relayout(false, 0, false, false); _root.SetActive(false); _visible = false; LogService.LogInfo("[IdleSpectator] Dossier HUD ready (vanilla strip + avatar frame)"); } 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); } }