worldbox-observer-mod/IdleSpectator/WatchCaption.cs
2026-07-14 17:12:26 -05:00

654 lines
22 KiB
C#

using NeoModLoader.services;
using UnityEngine;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Persistent compact dossier (Option A / vanilla strip):
/// header species+name+sex, live portrait + level/task, full-width trait strip, reason.
/// </summary>
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<RectTransform>() : 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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<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 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<RectTransform>();
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<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));
}
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<RectTransform>();
_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<Image>();
bg.raycastTarget = false;
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.9f));
CanvasGroup group = _root.GetComponent<CanvasGroup>();
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);
}
}