- Prioritize MCs, related cast, meaningful disasters, and exceptional events - Show MC connections in the dossier when following related characters - Promote rare creatures and recurring antagonists through bounded scoring - Reduce roster churn and repetitive love/combat coverage - Add performance telemetry, soak auditing, documentation, and harness coverage
4434 lines
145 KiB
C#
4434 lines
145 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using NeoModLoader.services;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
using UnityEngine.UI;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Compact dossier: character beat caption (Identity + Beat + Context), avatar + mini History,
|
|
/// statuses, traits; life-saga rail + hover Cast/Legacy depth.
|
|
/// </summary>
|
|
public static class WatchCaption
|
|
{
|
|
/// <summary>
|
|
/// Locked outer width whenever the rail is shown so hover depth cannot slide
|
|
/// right-anchored glyphs out from under the hover cursor.
|
|
/// </summary>
|
|
private const float PanelWidthMax = 240f;
|
|
private const float SpeciesSize = 14f;
|
|
private const float SexSize = 12f;
|
|
private const float LiveMax = 40f;
|
|
private const float ChipIcon = 11f;
|
|
/// <summary>Max nametag task label slot; PlaceHeader may shrink further to clear sex/favorite.</summary>
|
|
private const float TaskLabelW = 72f;
|
|
private const float TraitIcon = 11f;
|
|
private const float HistoryIcon = 9f;
|
|
private const float HistoryColMinW = 118f;
|
|
private const float NameMinW = 24f;
|
|
/// <summary>Hard cap so long "Name (Species/Job)" lines cannot paint under the level chip.</summary>
|
|
private const float NameMaxW = 160f;
|
|
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 = 3f;
|
|
private const float HeaderH = 15f;
|
|
private const float BodyH = 40f;
|
|
private const float TraitsH = 13f;
|
|
private const float StatusesH = 13f;
|
|
private const float ReasonH = 13f;
|
|
private const float ConnectionH = 11f;
|
|
private const float StorySpineH = 11f;
|
|
private const float HistoryLineMinH = 12f;
|
|
private const float HistoryLineMaxH = 100f;
|
|
private const float Gap = 1f;
|
|
private const int HistoryPeekMax = 3;
|
|
private const float BtnSize = 14f;
|
|
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);
|
|
|
|
/// <summary>Inner width available to dossier/saga body under the rail.</summary>
|
|
private static float BodyColumnInnerWidth() =>
|
|
Mathf.Max(120f, PanelWidthMax - PadX * 2f);
|
|
|
|
private static float _panelWidth = LiveMax + PadX * 2f;
|
|
|
|
private static readonly Color NameColor = HudTheme.Name;
|
|
private static readonly Color StatValueColor = HudTheme.ValueOrange;
|
|
private static readonly Color TaskColor = HudTheme.TaskGreen;
|
|
private static readonly Color ReasonColor = HudTheme.ValueOrange;
|
|
private static readonly Color StorySpineColor = HudTheme.Muted;
|
|
private static readonly Color ConnectionColor = HudTheme.Muted;
|
|
private static readonly Color TraitNameColor = HudTheme.Body;
|
|
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
|
|
private static readonly Color HistoryTextColor = HudTheme.Body;
|
|
private static readonly Color StatusBannerColor = HudTheme.ValueOrange;
|
|
|
|
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 _connectionText;
|
|
private static Text _storySpineText;
|
|
private static long _reasonOtherId;
|
|
|
|
private static Image _levelIcon;
|
|
private static Text _levelValue;
|
|
private static Image _taskIcon;
|
|
private static Text _taskText;
|
|
|
|
private static readonly TraitSlot[] _traitSlots = new TraitSlot[UnitDossier.MaxTraitChips];
|
|
private static GameObject _traitsRow;
|
|
private static readonly TraitSlot[] _statusSlots = new TraitSlot[UnitDossier.MaxStatusChips];
|
|
private static readonly UnitDossier StatusProbe = new UnitDossier();
|
|
private static GameObject _statusesRow;
|
|
private static int _lastStatusesFingerprint = int.MinValue;
|
|
|
|
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 static int _lastLiveHistFingerprint = int.MinValue;
|
|
private const float LifeSepH = 3f;
|
|
|
|
private static Button _favoriteBtn;
|
|
private static Image _favoriteIcon;
|
|
private static bool _pinnedWhilePaused;
|
|
/// <summary>Harness ForceNametagHeadline lock - skip live identity overwrite until rebuild.</summary>
|
|
private static bool _headlineLocked;
|
|
|
|
private static UnitDossier _current;
|
|
private static Actor _boundActor;
|
|
private static bool _visible;
|
|
private static bool _layoutDirty = true;
|
|
private static string _layoutKey = "";
|
|
private static string _lastAppliedCaptionFingerprint = "";
|
|
private static int _lastIdentityMemoryRevision = int.MinValue;
|
|
private static float _nextRoutineLiveRefreshAt;
|
|
private static float _nextRoutineSpineAt;
|
|
private const float RoutineLiveRefreshSeconds = 0.2f;
|
|
private const float RoutineSpineRefreshSeconds = 0.15f;
|
|
private static bool _dossierRowsNeedRestore;
|
|
private static float _nextPortraitAt;
|
|
private static long _portraitActorId;
|
|
private const float PortraitRefreshSeconds = 1f;
|
|
private static string _statusBanner = "";
|
|
private static float _statusBannerUntil;
|
|
private static string _lastLoggedCaption = "";
|
|
// OwnedEventReason performs stranger-name presentability checks against the living
|
|
// population. A developed world makes that expensive, while the active director
|
|
// candidate is normally unchanged for many 0.2-second caption refreshes.
|
|
private static bool _ownedReasonCacheValid;
|
|
private static long _ownedReasonActorId;
|
|
private static InterestCandidate _ownedReasonScene;
|
|
private static string _ownedReasonSceneKey = "";
|
|
private static string _ownedReasonSceneLabel = "";
|
|
private static long _ownedReasonSubjectId;
|
|
private static long _ownedReasonRelatedId;
|
|
private static bool _ownedReasonQuietGrace;
|
|
private static string _ownedReasonHarnessOverride = "";
|
|
private static long _ownedReasonHarnessRelatedId;
|
|
private static string _ownedReasonHarnessRelatedName = "";
|
|
public static float LastReconcileMs { get; private set; }
|
|
public static float LastPortraitMs { get; private set; }
|
|
public static float LastTaskMs { get; private set; }
|
|
public static float LastIdentityMs { get; private set; }
|
|
public static float LastStatusesMs { get; private set; }
|
|
public static float LastHistoryMs { get; private set; }
|
|
public static float LastReasonMs { get; private set; }
|
|
public static float LastSpineMs { get; private set; }
|
|
|
|
private sealed class TraitSlot
|
|
{
|
|
public GameObject Root;
|
|
public Image Icon;
|
|
public Text Label;
|
|
public DossierChipTip Tip;
|
|
}
|
|
|
|
private sealed class HistorySlot
|
|
{
|
|
public GameObject Root;
|
|
public Image Icon;
|
|
public Text Label;
|
|
public Button Button;
|
|
public Image Hit;
|
|
public long OtherId;
|
|
}
|
|
|
|
public static UnitDossier Current => _current;
|
|
|
|
/// <summary>Living actor currently bound to the dossier (null for fallen archive).</summary>
|
|
public static Actor BoundActor => _boundActor;
|
|
|
|
public static string LastHeadline { get; private set; } = "";
|
|
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
|
|
|
|
public static string LastDetail { get; private set; } = "";
|
|
|
|
public static string LastCaptionText { get; private set; } = "";
|
|
|
|
/// <summary>Harness: composed beat line (may include relation clause).</summary>
|
|
public static string LastBeatLine => CaptionComposer.LastBeatLine;
|
|
|
|
/// <summary>Actual Beat painted this frame; may lead composer state during a handoff.</summary>
|
|
internal static string VisibleBeatLine =>
|
|
_reasonText != null && _reasonText.gameObject.activeSelf
|
|
? (_reasonText.text ?? "")
|
|
: "";
|
|
|
|
/// <summary>Structured unit id named by the currently composed camera Beat.</summary>
|
|
internal static long VisibleReasonRelatedId =>
|
|
_reasonText != null && _reasonText.gameObject.activeSelf && _current != null
|
|
? _current.ReasonRelatedId
|
|
: 0;
|
|
|
|
/// <summary>Harness: composed context line (spine or stake).</summary>
|
|
public static string LastContextLine => CaptionComposer.LastContextLine;
|
|
public static string LastConnectionLine { get; private set; } = "";
|
|
|
|
/// <summary>Harness/UI: active context line under the beat (spine or stake), empty when none.</summary>
|
|
public static string LastStorySpine { 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; } = "";
|
|
|
|
/// <summary>Harness: comma-joined top status labels currently shown.</summary>
|
|
public static string LastStatusesPreview { get; private set; } = "";
|
|
|
|
public static Vector2 LastPanelSize { get; private set; }
|
|
|
|
/// <summary>Exact outer height of rail + nametag + fixed Saga body.</summary>
|
|
public static float SagaHoverHeightFixed =>
|
|
PadTop + LifeSagaRail.SlotSize + 2f + HeaderH + Gap
|
|
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
|
|
|
|
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>True if the mouse is over a trait/status chip (vanilla tip host).</summary>
|
|
public static bool IsPointerOverAssetChip()
|
|
{
|
|
if (!_visible || _root == null || !_root.activeInHierarchy)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Vector2 mouse = Input.mousePosition;
|
|
for (int i = 0; i < _traitSlots.Length; i++)
|
|
{
|
|
if (IsPointerOverChipRoot(_traitSlots[i]?.Root, mouse))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < _statusSlots.Length; i++)
|
|
{
|
|
if (IsPointerOverChipRoot(_statusSlots[i]?.Root, mouse))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsPointerOverChipRoot(GameObject root, Vector2 mouse)
|
|
{
|
|
if (root == null || !root.activeInHierarchy)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
RectTransform rt = root.GetComponent<RectTransform>();
|
|
return rt != null && RectTransformUtility.RectangleContainsScreenPoint(rt, mouse, null);
|
|
}
|
|
|
|
/// <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))
|
|
{
|
|
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()
|
|
{
|
|
long hoverId = LifeSagaViewController.HoverPreviewId;
|
|
if (hoverId != 0)
|
|
{
|
|
Actor hovered = EventFeedUtil.FindAliveById(hoverId);
|
|
if (hovered != null && hovered.isAlive())
|
|
{
|
|
try
|
|
{
|
|
hovered.switchFavorite();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
Chronicle.SyncFavoriteFromActor(hovered);
|
|
}
|
|
else
|
|
{
|
|
Chronicle.SetFavoriteSubject(hoverId, !Chronicle.IsFavoriteSubject(hoverId));
|
|
}
|
|
|
|
RequestRelayout();
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Hide dossier after outside-click / inspect dismiss (idle already off).</summary>
|
|
public static void HideForDismiss()
|
|
{
|
|
_pinnedWhilePaused = false;
|
|
ClearStatusBanner();
|
|
SetVisible(false);
|
|
}
|
|
|
|
public static void ClearPausePin()
|
|
{
|
|
_pinnedWhilePaused = false;
|
|
}
|
|
|
|
/// <summary>Open this unit's full history in the Lore panel (L / harness); 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()
|
|
{
|
|
InvalidateOwnedReasonCache();
|
|
_current = null;
|
|
_boundActor = null;
|
|
LastHeadline = "";
|
|
LastDetail = "";
|
|
LastCaptionText = "";
|
|
LastStorySpine = "";
|
|
LastConnectionLine = "";
|
|
CaptionComposer.Clear();
|
|
LifeSagaViewController.Clear();
|
|
LifeSagaPanel.Clear();
|
|
LifeSagaRail.Clear();
|
|
LastHistoryPreview = "";
|
|
LastHistoryJoined = "";
|
|
LastHistoryShown = 0;
|
|
LastActivityShown = 0;
|
|
LastLifeShown = 0;
|
|
LastHistoryFillsBody = false;
|
|
_pinnedWhilePaused = false;
|
|
LastTraitsPreview = "";
|
|
LastStatusesPreview = "";
|
|
LastPanelSize = Vector2.zero;
|
|
LastLayoutOk = false;
|
|
_lastHistoryCount = -1;
|
|
_lastHistorySubjectId = 0;
|
|
_lastStatusesFingerprint = int.MinValue;
|
|
_lastAppliedCaptionFingerprint = "";
|
|
_lastIdentityMemoryRevision = int.MinValue;
|
|
_nextRoutineLiveRefreshAt = 0f;
|
|
_nextRoutineSpineAt = 0f;
|
|
_headlineLocked = false;
|
|
_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);
|
|
WireReasonClick(0);
|
|
}
|
|
|
|
// Banner owns Beat only. Keep Identity + Context composed from the live
|
|
// camera subject so a multi-second pause banner cannot freeze the dossier.
|
|
Actor focus = ResolveBoundLiveActor();
|
|
if (focus == null && MoveCamera.hasFocusUnit())
|
|
{
|
|
focus = MoveCamera._focus_unit;
|
|
}
|
|
|
|
ApplyComposedCaption(
|
|
focus,
|
|
_current != null ? (_current.ReasonLine ?? "") : "",
|
|
_current != null ? _current.ReasonRelatedId : 0,
|
|
statusBannerOwnsBeat: 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;
|
|
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
|
|
bool hasBody = _boundActor != null || hist > 0 || _current != null;
|
|
Relayout(
|
|
hasBody,
|
|
CountActiveTraitSlots(),
|
|
CountActiveStatusSlots(),
|
|
hasTask,
|
|
hasReason: true,
|
|
hist);
|
|
SetVisible(true);
|
|
LogCaptionIfChanged("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 = "";
|
|
InvalidateOwnedReasonCache();
|
|
_current = null;
|
|
_boundActor = null;
|
|
EnsureBuilt();
|
|
ApplyVisual(null, null);
|
|
if (_nameText != null)
|
|
{
|
|
_nameText.text = LastHeadline;
|
|
}
|
|
|
|
Relayout(false, 0, 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)
|
|
{
|
|
InvalidateOwnedReasonCache();
|
|
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);
|
|
_nextRoutineLiveRefreshAt = Time.unscaledTime + RoutineLiveRefreshSeconds;
|
|
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
|
|
LogCaptionIfChanged(LastCaptionText);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
InvalidateOwnedReasonCache();
|
|
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));
|
|
LogCaptionIfChanged("fallen dossier " + LastCaptionText);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CAPTION logs only when the text changes - busy Mass tip ticks used to flood Player.log
|
|
/// by rebuilding the dossier (and logging) on every sticky Watch refresh.
|
|
/// </summary>
|
|
private static void LogCaptionIfChanged(string caption)
|
|
{
|
|
string text = caption ?? "";
|
|
if (string.Equals(text, _lastLoggedCaption, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastLoggedCaption = text;
|
|
LogService.LogInfo("[IdleSpectator][CAPTION] " + text.Replace("\n", " | "));
|
|
}
|
|
|
|
public static void Update()
|
|
{
|
|
ResetUpdateTelemetry();
|
|
|
|
// 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();
|
|
RefreshLiveIdentity();
|
|
RefreshLiveStatuses();
|
|
RefreshOwnedReason();
|
|
RefreshStorySpine();
|
|
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, 0, false, false, 0);
|
|
}
|
|
|
|
SetVisible(true);
|
|
}
|
|
|
|
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
|
|
// must not leave the nametag on the previous person for a frame.
|
|
// Caption Identity/Beat/Context always track camera focus; hover only adds panel depth.
|
|
long perfStarted = StartPerfSample();
|
|
LifeSagaViewController.Tick();
|
|
ReconcileDossierToFocus();
|
|
LastReconcileMs = EndPerfSample(perfStarted);
|
|
float now = Time.unscaledTime;
|
|
if (now < _nextRoutineLiveRefreshAt)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_nextRoutineLiveRefreshAt = now + RoutineLiveRefreshSeconds;
|
|
if (!LifeSagaViewController.IsHoverPreview)
|
|
{
|
|
perfStarted = StartPerfSample();
|
|
RefreshLivePortrait();
|
|
LastPortraitMs = EndPerfSample(perfStarted);
|
|
|
|
perfStarted = StartPerfSample();
|
|
RefreshLiveTask();
|
|
LastTaskMs = EndPerfSample(perfStarted);
|
|
}
|
|
|
|
perfStarted = StartPerfSample();
|
|
RefreshLiveIdentity();
|
|
LastIdentityMs = EndPerfSample(perfStarted);
|
|
if (!LifeSagaViewController.IsHoverPreview)
|
|
{
|
|
perfStarted = StartPerfSample();
|
|
RefreshLiveStatuses();
|
|
LastStatusesMs = EndPerfSample(perfStarted);
|
|
|
|
perfStarted = StartPerfSample();
|
|
RefreshHistoryIfChanged();
|
|
LastHistoryMs = EndPerfSample(perfStarted);
|
|
}
|
|
|
|
perfStarted = StartPerfSample();
|
|
RefreshOwnedReason();
|
|
LastReasonMs = EndPerfSample(perfStarted);
|
|
|
|
perfStarted = StartPerfSample();
|
|
RefreshStorySpine();
|
|
LastSpineMs = EndPerfSample(perfStarted);
|
|
}
|
|
|
|
private static void ResetUpdateTelemetry()
|
|
{
|
|
LastReconcileMs = 0f;
|
|
LastPortraitMs = 0f;
|
|
LastTaskMs = 0f;
|
|
LastIdentityMs = 0f;
|
|
LastStatusesMs = 0f;
|
|
LastHistoryMs = 0f;
|
|
LastReasonMs = 0f;
|
|
LastSpineMs = 0f;
|
|
}
|
|
|
|
private static long StartPerfSample()
|
|
{
|
|
return IdleHitchProbe.Enabled
|
|
? System.Diagnostics.Stopwatch.GetTimestamp()
|
|
: 0;
|
|
}
|
|
|
|
private static float EndPerfSample(long started)
|
|
{
|
|
if (started == 0)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - started;
|
|
return (float)(elapsed * 1000.0 / System.Diagnostics.Stopwatch.Frequency);
|
|
}
|
|
|
|
/// <summary>
|
|
/// While Idle Spectator is live, dossier nametag must track the camera focus unit.
|
|
/// Skips pause/fallen pins where the archive subject intentionally differs from focus.
|
|
/// </summary>
|
|
private static void ReconcileDossierToFocus()
|
|
{
|
|
if (_pinnedWhilePaused || !SpectatorMode.Active)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor focus = MoveCamera._focus_unit;
|
|
if (!focus.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
long focusId = 0;
|
|
try
|
|
{
|
|
focusId = focus.getID();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (focusId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_current != null && _current.UnitId == focusId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
SetFromActor(focus);
|
|
}
|
|
|
|
/// <summary>True while a temporary status banner owns the reason row.</summary>
|
|
public static bool HasStatusBannerActive() => HasStatusBanner();
|
|
|
|
/// <summary>Storyline rail hover/commit asks for a soft relayout without a full rebuild.</summary>
|
|
public static void RequestRelayout()
|
|
{
|
|
if (!_visible)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_layoutDirty = true;
|
|
// Route through spine/rail refresh so caption compose and hover panel stay consistent.
|
|
RefreshStorySpine(force: true);
|
|
}
|
|
|
|
private static bool HasStatusBanner()
|
|
{
|
|
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
|
}
|
|
|
|
public static void ClearStatusBanner()
|
|
{
|
|
if (!string.IsNullOrEmpty(_statusBanner) || _statusBannerUntil != 0f)
|
|
{
|
|
InvalidateOwnedReasonCache();
|
|
}
|
|
|
|
_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 int CountActiveStatusSlots()
|
|
{
|
|
int n = 0;
|
|
for (int i = 0; i < _statusSlots.Length; i++)
|
|
{
|
|
if (_statusSlots[i]?.Root != null && _statusSlots[i].Root.activeSelf)
|
|
{
|
|
n++;
|
|
}
|
|
}
|
|
|
|
return n;
|
|
}
|
|
|
|
private static void RefreshLivePortrait()
|
|
{
|
|
if (!_visible)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor actor = ResolveBoundLiveActor();
|
|
if (actor == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id = EventFeedUtil.SafeId(actor);
|
|
float now = Time.unscaledTime;
|
|
// The vanilla tile refresh can trigger an expensive UI/layout pass. A portrait is
|
|
// supporting context, not animation-critical, so refresh it at 1 Hz while held.
|
|
// Focus change always applies immediately.
|
|
if (id == _portraitActorId && now < _nextPortraitAt)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_portraitActorId = id;
|
|
_nextPortraitAt = now + PortraitRefreshSeconds;
|
|
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;
|
|
}
|
|
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
long actorId = EventFeedUtil.SafeId(actor);
|
|
string sceneKey = scene != null ? (scene.Key ?? "") : "";
|
|
string sceneLabel = scene != null ? (scene.Label ?? "") : "";
|
|
long subjectId = scene != null ? scene.SubjectId : 0;
|
|
long relatedId = scene != null ? scene.RelatedId : 0;
|
|
bool quietGrace = InterestDirector.InQuietGrace;
|
|
string harnessOverride = UnitDossier.HarnessReasonOverride ?? "";
|
|
long harnessRelatedId = UnitDossier.HarnessReasonRelatedId;
|
|
string harnessRelatedName = UnitDossier.HarnessReasonRelatedName ?? "";
|
|
|
|
if (_ownedReasonCacheValid
|
|
&& _ownedReasonActorId == actorId
|
|
&& ReferenceEquals(_ownedReasonScene, scene)
|
|
&& string.Equals(_ownedReasonSceneKey, sceneKey, StringComparison.Ordinal)
|
|
&& string.Equals(_ownedReasonSceneLabel, sceneLabel, StringComparison.Ordinal)
|
|
&& _ownedReasonSubjectId == subjectId
|
|
&& _ownedReasonRelatedId == relatedId
|
|
&& _ownedReasonQuietGrace == quietGrace
|
|
&& string.Equals(_ownedReasonHarnessOverride, harnessOverride, StringComparison.Ordinal)
|
|
&& _ownedReasonHarnessRelatedId == harnessRelatedId
|
|
&& string.Equals(_ownedReasonHarnessRelatedName, harnessRelatedName, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Honor harness reason override the same way FromActor/BuildStoryBeat does.
|
|
string next;
|
|
if (!string.IsNullOrEmpty(harnessOverride))
|
|
{
|
|
string subject = _current.Name ?? "";
|
|
if (string.IsNullOrEmpty(subject))
|
|
{
|
|
subject = EventFeedUtil.SafeName(actor);
|
|
}
|
|
|
|
string relatedName = harnessRelatedName;
|
|
_current.ReasonRelatedId = harnessRelatedId != 0
|
|
? harnessRelatedId
|
|
: ActivityLog.ResolveLivingUnitIdByName(relatedName, _current.UnitId);
|
|
next = ActivityProse.ColorizePersonNames(
|
|
harnessOverride.Trim(),
|
|
subject,
|
|
relatedName);
|
|
}
|
|
else
|
|
{
|
|
next = UnitDossier.OwnedEventReason(actor, _current) ?? "";
|
|
}
|
|
|
|
_ownedReasonCacheValid = true;
|
|
_ownedReasonActorId = actorId;
|
|
_ownedReasonScene = scene;
|
|
_ownedReasonSceneKey = sceneKey;
|
|
_ownedReasonSceneLabel = sceneLabel;
|
|
_ownedReasonSubjectId = subjectId;
|
|
_ownedReasonRelatedId = relatedId;
|
|
_ownedReasonQuietGrace = quietGrace;
|
|
_ownedReasonHarnessOverride = harnessOverride;
|
|
_ownedReasonHarnessRelatedId = harnessRelatedId;
|
|
_ownedReasonHarnessRelatedName = harnessRelatedName;
|
|
|
|
string prev = _current.ReasonLine ?? "";
|
|
if (next == prev)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Transient ownership miss mid-handoff: keep the last orange reason while a
|
|
// labeled event tip is still active (avoids a blank frame during Love Follow swaps).
|
|
// Do not keep across character-fill / empty-Label vignettes.
|
|
if (string.IsNullOrEmpty(next)
|
|
&& !string.IsNullOrEmpty(prev)
|
|
&& scene != null
|
|
&& !InterestDirector.InQuietGrace
|
|
&& scene.LeadKind != InterestLeadKind.CharacterLed
|
|
&& !string.IsNullOrEmpty(scene.Label))
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next);
|
|
_current.ReasonLine = next;
|
|
LastDetail = _current.DetailLine ?? "";
|
|
|
|
// Mass tip headcount ticks must not Relayout the whole dossier every second.
|
|
// RefreshStorySpine recomposes Beat+Context and Relayouts when chrome changes.
|
|
RefreshStorySpine(force: true);
|
|
if (headcountOnly)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LastCaptionText = JoinCaptionLines(
|
|
LastHeadline,
|
|
LastBeatLine,
|
|
detail: null,
|
|
LastContextLine);
|
|
}
|
|
|
|
private static void InvalidateOwnedReasonCache()
|
|
{
|
|
_ownedReasonCacheValid = false;
|
|
_ownedReasonActorId = 0;
|
|
_ownedReasonScene = null;
|
|
_ownedReasonSceneKey = "";
|
|
_ownedReasonSceneLabel = "";
|
|
_ownedReasonSubjectId = 0;
|
|
_ownedReasonRelatedId = 0;
|
|
_ownedReasonQuietGrace = false;
|
|
_ownedReasonHarnessOverride = "";
|
|
_ownedReasonHarnessRelatedId = 0;
|
|
_ownedReasonHarnessRelatedName = "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Quiet context under the orange beat (story spine or stake).
|
|
/// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering.
|
|
/// </summary>
|
|
private static void RefreshStorySpine(bool force = false)
|
|
{
|
|
if (!_visible)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
if (!force && now < _nextRoutineSpineAt)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_nextRoutineSpineAt = now + RoutineSpineRefreshSeconds;
|
|
bool statusBanner = HasStatusBanner();
|
|
if (statusBanner)
|
|
{
|
|
LifeSagaViewController.CancelPreviewAndPause();
|
|
LifeSagaPanel.Clear();
|
|
LifeSagaRail.Clear();
|
|
_layoutDirty = true;
|
|
_dossierRowsNeedRestore = true;
|
|
}
|
|
|
|
Actor focus = ResolveBoundLiveActor();
|
|
if (focus == null && MoveCamera.hasFocusUnit())
|
|
{
|
|
focus = MoveCamera._focus_unit;
|
|
}
|
|
|
|
string presentable = _current != null ? (_current.ReasonLine ?? "") : "";
|
|
long related = _current != null ? _current.ReasonRelatedId : 0;
|
|
ApplyComposedCaption(focus, presentable, related, statusBannerOwnsBeat: statusBanner);
|
|
|
|
if (!statusBanner)
|
|
{
|
|
LifeSagaRail.Refresh();
|
|
}
|
|
|
|
bool panelMode = !statusBanner && LifeSagaViewController.IsHoverPreview;
|
|
int traitCount = 0;
|
|
int statusCount = 0;
|
|
int historyCount = 0;
|
|
bool hasTask = false;
|
|
bool hasReason = false;
|
|
if (panelMode)
|
|
{
|
|
RefreshSagaBody();
|
|
ApplyReasonText("", 0);
|
|
ApplyConnection("");
|
|
ApplyStorySpine("");
|
|
_dossierRowsNeedRestore = true;
|
|
}
|
|
else
|
|
{
|
|
LifeSagaPanel.SetVisible(false);
|
|
RestoreCameraHeaderChrome();
|
|
// Hover force-hides dossier rows. Restore once when leaving hover so exit does
|
|
// not wait for a tip change (not every frame).
|
|
if (_dossierRowsNeedRestore)
|
|
{
|
|
RestoreDossierRowsAfterSaga();
|
|
_dossierRowsNeedRestore = false;
|
|
_layoutDirty = true;
|
|
}
|
|
|
|
traitCount = CountActiveTraitSlots();
|
|
statusCount = CountActiveStatusSlots();
|
|
historyCount = CountActiveHistorySlots();
|
|
hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
|
|
hasReason = !string.IsNullOrEmpty(LastBeatLine)
|
|
|| (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text));
|
|
}
|
|
|
|
if (panelMode)
|
|
{
|
|
// Hover depth prioritizes the full Identity. The live task is already represented
|
|
// by the camera dossier and returns with its rows as soon as hover ends. Beat and
|
|
// Context are hidden, so neither may reserve phantom vertical space here.
|
|
hasTask = false;
|
|
hasReason = false;
|
|
}
|
|
|
|
bool hasBody = !panelMode
|
|
&& (_boundActor != null
|
|
|| historyCount > 0
|
|
|| _current != null);
|
|
string layoutKey =
|
|
$"{(panelMode ? 1 : 0)}|{LastConnectionLine}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
|
|
+ $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}"
|
|
+ $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}"
|
|
+ $"|{LastBeatLine}|{LastHeadline}";
|
|
if (_layoutDirty || !string.Equals(layoutKey, _layoutKey, StringComparison.Ordinal))
|
|
{
|
|
_layoutKey = layoutKey;
|
|
_layoutDirty = false;
|
|
Relayout(hasBody || panelMode, traitCount, statusCount, hasTask, hasReason, historyCount);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply CaptionComposer Identity + Beat + Context to nametag / reason / spine.
|
|
/// Filters spine against the presentable beat before EnrichBeat appends clauses.
|
|
/// </summary>
|
|
private static void ApplyComposedCaption(
|
|
Actor focus,
|
|
string presentableBeat,
|
|
long reasonRelatedId,
|
|
bool statusBannerOwnsBeat)
|
|
{
|
|
string spineRaw = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption
|
|
? StoryPlanner.FormatSpineLabel()
|
|
: "";
|
|
string spineFiltered = FilterRedundantStorySpine(spineRaw, presentableBeat ?? "");
|
|
InterestCandidate tip = InterestDirector.CurrentCandidate;
|
|
CaptionModel model = CaptionComposer.Compose(
|
|
focus,
|
|
tip,
|
|
presentableBeat ?? "",
|
|
reasonRelatedId,
|
|
spineFiltered,
|
|
statusBannerOwnsBeat);
|
|
ApplyConnection(model.ConnectionLine ?? "");
|
|
if (string.Equals(
|
|
model.Fingerprint,
|
|
_lastAppliedCaptionFingerprint,
|
|
StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastAppliedCaptionFingerprint = model.Fingerprint;
|
|
|
|
if (!_headlineLocked)
|
|
{
|
|
if (!string.IsNullOrEmpty(model.IdentityLine))
|
|
{
|
|
LastHeadline = model.IdentityLine;
|
|
if (_current != null)
|
|
{
|
|
_current.Headline = LastHeadline;
|
|
}
|
|
|
|
if (_nameText != null)
|
|
{
|
|
string shown = LastHeadline;
|
|
if (shown.Length > 48)
|
|
{
|
|
shown = CaptionComposer.TruncateIdentity(shown, 48);
|
|
}
|
|
|
|
if (!string.Equals(_nameText.text, shown, StringComparison.Ordinal))
|
|
{
|
|
_nameText.text = shown;
|
|
}
|
|
}
|
|
}
|
|
else if (_current != null && !string.IsNullOrEmpty(_current.Headline))
|
|
{
|
|
// Fallen/archive: Compose has no live focus - keep dossier headline.
|
|
LastHeadline = _current.Headline;
|
|
if (_nameText != null)
|
|
{
|
|
_nameText.text = LastHeadline;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!statusBannerOwnsBeat)
|
|
{
|
|
bool hasBeat = !string.IsNullOrEmpty(model.BeatLine);
|
|
ApplyReasonText(hasBeat ? model.BeatLine : "", hasBeat ? model.ReasonRelatedId : 0);
|
|
if (_current != null && model.ReasonRelatedId != 0)
|
|
{
|
|
_current.ReasonRelatedId = model.ReasonRelatedId;
|
|
}
|
|
}
|
|
|
|
ApplyStorySpine(model.ContextLine ?? "");
|
|
// Character beat CAPTION fingerprint: Identity / Beat / Context only.
|
|
// DetailLine is the live task chip - keep it out of the joined caption log.
|
|
LastCaptionText = JoinCaptionLines(
|
|
LastHeadline,
|
|
statusBannerOwnsBeat ? (_reasonText != null ? _reasonText.text : "") : model.BeatLine,
|
|
detail: null,
|
|
model.ContextLine);
|
|
}
|
|
|
|
private static void ApplyConnection(string connection)
|
|
{
|
|
string next = connection ?? "";
|
|
bool show = !string.IsNullOrEmpty(next)
|
|
&& !LifeSagaViewController.IsHoverPreview;
|
|
LastConnectionLine = show ? next : "";
|
|
if (_connectionText == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!string.Equals(_connectionText.text, LastConnectionLine, StringComparison.Ordinal))
|
|
{
|
|
_connectionText.text = LastConnectionLine;
|
|
EllipsisFitLabel(_connectionText, BodyColumnInnerWidth());
|
|
}
|
|
_connectionText.color = ConnectionColor;
|
|
_connectionText.gameObject.SetActive(show);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-show trait/status/history chrome after hover hid them.
|
|
/// Uses the bound dossier snapshot so hover-exit is instant; recomposes caption.
|
|
/// </summary>
|
|
private static void RestoreDossierRowsAfterSaga()
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ApplyTraitChips(_current);
|
|
ApplyStatusChips(_current);
|
|
FillHistory(_current.UnitId);
|
|
bool hasLevel = _current.UnitId != 0
|
|
&& ((_boundActor != null && _boundActor.isAlive()) || _current.Level > 0);
|
|
if (_levelIcon != null)
|
|
{
|
|
HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null);
|
|
_levelIcon.gameObject.SetActive(hasLevel);
|
|
}
|
|
|
|
if (_levelValue != null)
|
|
{
|
|
_levelValue.text = hasLevel ? _current.Level.ToString() : "";
|
|
_levelValue.gameObject.SetActive(hasLevel);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(_current.TaskText))
|
|
{
|
|
ApplyTaskChip(_current.TaskText);
|
|
}
|
|
|
|
Actor focus = ResolveBoundLiveActor();
|
|
ApplyComposedCaption(
|
|
focus,
|
|
_current.ReasonLine ?? "",
|
|
_current.ReasonRelatedId,
|
|
statusBannerOwnsBeat: HasStatusBanner());
|
|
}
|
|
|
|
/// <summary>Harness: dossier nametag/reason visible after leaving Saga hover.</summary>
|
|
public static bool HarnessDossierChromeRestored()
|
|
{
|
|
if (_nameText == null || !_nameText.gameObject.activeSelf)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string line = _current != null ? (_current.ReasonLine ?? "") : "";
|
|
if (string.IsNullOrEmpty(line))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return _reasonText != null
|
|
&& _reasonText.gameObject.activeSelf
|
|
&& !string.IsNullOrEmpty(_reasonText.text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drop Kind · Climax when the presentable beat already leads with that kind (e.g. "Duel - A vs B").
|
|
/// Keep Aftermath / Epilogue and unmatched kinds. Combat Climax is also dropped when the
|
|
/// tip already leads with any combat scale word (covers brief Kind sync lag).
|
|
/// Filter against the pre-enrichment beat so trailing ` · clause` does not matter;
|
|
/// Kind token detection already uses the start of the tip.
|
|
/// </summary>
|
|
private static string FilterRedundantStorySpine(string spine, string reason)
|
|
{
|
|
if (string.IsNullOrEmpty(spine))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
int sep = spine.IndexOf('·');
|
|
if (sep < 0)
|
|
{
|
|
return spine;
|
|
}
|
|
|
|
string kind = spine.Substring(0, sep).Trim();
|
|
string phase = spine.Substring(sep + 1).Trim();
|
|
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase))
|
|
{
|
|
return spine;
|
|
}
|
|
|
|
if (!string.Equals(phase, "Climax", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return spine;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(reason))
|
|
{
|
|
return spine;
|
|
}
|
|
|
|
if (ReasonLeadsWithToken(reason, kind)
|
|
|| ReasonSemanticallyMatchesStoryKind(reason, kind))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
// Tip already carries combat scale; hide Kind · Climax even if spine Kind lags SyncActiveClimax.
|
|
if (IsCombatSpineKind(kind) && ReasonLeadsWithCombatScale(reason))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return spine;
|
|
}
|
|
|
|
private static bool ReasonSemanticallyMatchesStoryKind(string reason, string kind)
|
|
{
|
|
string text = (reason ?? "").Trim().ToLowerInvariant();
|
|
string story = (kind ?? "").Trim().ToLowerInvariant();
|
|
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(story))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch (story)
|
|
{
|
|
case "love":
|
|
return text.IndexOf("love", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("smitten", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("mates with", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("intimacy", StringComparison.Ordinal) >= 0;
|
|
case "grief":
|
|
return text.IndexOf("mourn", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("despair", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("grief", StringComparison.Ordinal) >= 0;
|
|
case "duel":
|
|
case "skirmish":
|
|
case "battle":
|
|
case "mass":
|
|
return ReasonLeadsWithCombatScale(reason)
|
|
|| text.IndexOf(" is fighting", StringComparison.Ordinal) >= 0;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static bool HarnessProbeStorySpineRelevance(out string detail)
|
|
{
|
|
string love = FilterRedundantStorySpine("Love · Climax", "Falls in love with Omyahel");
|
|
string duel = FilterRedundantStorySpine("Duel · Climax", "Joon is fighting");
|
|
string pregnancy = FilterRedundantStorySpine("Love · Climax", "Becomes pregnant");
|
|
string aftermath = FilterRedundantStorySpine("Love · Aftermath", "Finds love with Omyahel");
|
|
bool ok = string.IsNullOrEmpty(love)
|
|
&& string.IsNullOrEmpty(duel)
|
|
&& pregnancy == "Love · Climax"
|
|
&& aftermath == "Love · Aftermath";
|
|
detail = "love='" + love + "' duel='" + duel + "' pregnancy='" + pregnancy
|
|
+ "' aftermath='" + aftermath + "' pass=" + ok;
|
|
return ok;
|
|
}
|
|
|
|
private static bool IsCombatSpineKind(string kind)
|
|
{
|
|
return string.Equals(kind, "Duel", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(kind, "Skirmish", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(kind, "Battle", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(kind, "Mass", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool ReasonLeadsWithCombatScale(string reason)
|
|
{
|
|
return ReasonLeadsWithToken(reason, "Duel")
|
|
|| ReasonLeadsWithToken(reason, "Skirmish")
|
|
|| ReasonLeadsWithToken(reason, "Battle")
|
|
|| ReasonLeadsWithToken(reason, "Mass");
|
|
}
|
|
|
|
/// <summary>True when reason starts with the token before " - " / " · " / whitespace.</summary>
|
|
private static bool ReasonLeadsWithToken(string reason, string token)
|
|
{
|
|
if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(token))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string lead = reason.TrimStart();
|
|
int cut = lead.Length;
|
|
for (int i = 0; i < lead.Length; i++)
|
|
{
|
|
char c = lead[i];
|
|
if (c == '-' || c == '·' || char.IsWhiteSpace(c))
|
|
{
|
|
cut = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
string head = lead.Substring(0, cut).Trim();
|
|
return string.Equals(head, token, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static void RefreshSagaBody()
|
|
{
|
|
EnsureBuilt();
|
|
LifeSagaPanel.EnsureBuilt(_root.transform);
|
|
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
|
ApplySagaHoverHeader(model);
|
|
LifeSagaPanel.Bind(model);
|
|
}
|
|
|
|
private static void ApplySagaHoverHeader(LifeSagaViewModel model)
|
|
{
|
|
if (model == null || model.UnitId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor actor = EventFeedUtil.FindAliveById(model.UnitId);
|
|
string name = (model.Name ?? "").Trim();
|
|
if (name.StartsWith("★ ", StringComparison.Ordinal))
|
|
{
|
|
name = name.Substring(2).TrimStart();
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(name))
|
|
{
|
|
name = "Someone";
|
|
}
|
|
|
|
string title = (model.Title ?? "").Trim();
|
|
string species = ActivityAssetCatalog.SpeciesDisplayLabel(model.SpeciesId);
|
|
string headline = !string.IsNullOrEmpty(title)
|
|
? name + " · " + title
|
|
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
|
|
if (_nameText != null)
|
|
{
|
|
_nameText.text = headline.Length > 48
|
|
? CaptionComposer.TruncateIdentity(headline, 48)
|
|
: headline;
|
|
}
|
|
|
|
if (_speciesIcon != null)
|
|
{
|
|
Sprite sprite = actor != null && actor.isAlive()
|
|
? HudIcons.FromActor(actor)
|
|
: HudIcons.FromSpeciesId(model.SpeciesId);
|
|
HudIcons.Apply(_speciesIcon, sprite);
|
|
}
|
|
|
|
Sprite sexSprite = null;
|
|
if (actor != null && actor.isAlive())
|
|
{
|
|
UnitDossier hovered = UnitDossier.FromActor(actor);
|
|
if (hovered != null)
|
|
{
|
|
sexSprite = hovered.IsMale
|
|
? HudIcons.SexMale()
|
|
: (hovered.IsFemale ? HudIcons.SexFemale() : null);
|
|
}
|
|
}
|
|
|
|
if (_sexIcon != null)
|
|
{
|
|
HudIcons.Apply(_sexIcon, sexSprite);
|
|
_sexIcon.gameObject.SetActive(sexSprite != null);
|
|
}
|
|
|
|
if (actor != null && actor.isAlive())
|
|
{
|
|
DossierAvatar.Show(actor);
|
|
}
|
|
else
|
|
{
|
|
DossierAvatar.ShowSpecies(model.SpeciesId);
|
|
}
|
|
DossierAvatar.SetHostVisible(true);
|
|
|
|
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
|
|
}
|
|
|
|
private static void RestoreCameraHeaderChrome()
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor actor = ResolveBoundLiveActor();
|
|
if (_speciesIcon != null)
|
|
{
|
|
Sprite sprite = actor != null && actor.isAlive()
|
|
? HudIcons.FromActor(actor)
|
|
: HudIcons.FromSpeciesId(_current.SpeciesId);
|
|
HudIcons.Apply(_speciesIcon, sprite);
|
|
}
|
|
|
|
if (_sexIcon != null)
|
|
{
|
|
Sprite sexSprite = _current.IsMale
|
|
? HudIcons.SexMale()
|
|
: (_current.IsFemale ? HudIcons.SexFemale() : null);
|
|
HudIcons.Apply(_sexIcon, sexSprite);
|
|
_sexIcon.gameObject.SetActive(sexSprite != null);
|
|
}
|
|
|
|
if (actor != null && actor.isAlive())
|
|
{
|
|
DossierAvatar.Show(actor);
|
|
}
|
|
else
|
|
{
|
|
DossierAvatar.ShowSpecies(_current.SpeciesId);
|
|
}
|
|
DossierAvatar.SetHostVisible(_current.UnitId != 0);
|
|
|
|
RefreshFavoriteVisual(actor);
|
|
}
|
|
|
|
private static void ApplyStorySpine(string spine)
|
|
{
|
|
string next = spine ?? "";
|
|
bool show = !string.IsNullOrEmpty(next);
|
|
if (string.Equals(LastStorySpine, next, StringComparison.Ordinal)
|
|
&& (_storySpineText == null
|
|
|| (_storySpineText.gameObject.activeSelf == show
|
|
&& string.Equals(_storySpineText.text, next, StringComparison.Ordinal))))
|
|
{
|
|
return;
|
|
}
|
|
|
|
LastStorySpine = next;
|
|
if (_storySpineText == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// This row is Caption Context (short-arc spine or durable stake). A status
|
|
// banner replaces Beat only, so Context remains visible and live.
|
|
_storySpineText.text = show ? LastStorySpine : "";
|
|
_storySpineText.color = StorySpineColor;
|
|
_storySpineText.gameObject.SetActive(show);
|
|
}
|
|
|
|
private static string JoinCaptionLines(string headline, string reason, string detail, string storySpine = null)
|
|
{
|
|
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(storySpine))
|
|
{
|
|
if (sb.Length > 0)
|
|
{
|
|
sb.Append('\n');
|
|
}
|
|
|
|
sb.Append(storySpine);
|
|
}
|
|
|
|
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(),
|
|
CountActiveStatusSlots(),
|
|
hasTask: true,
|
|
hasReason,
|
|
CountActiveHistorySlots());
|
|
}
|
|
|
|
BringHeaderFront();
|
|
}
|
|
|
|
/// <summary>Keep nametag Identity in sync via CaptionComposer (MC title or species/job).</summary>
|
|
private static void RefreshLiveIdentity()
|
|
{
|
|
if (!_visible || _current == null || _headlineLocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor actor = ResolveBoundLiveActor();
|
|
if (actor == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string job = UnitDossier.ReadLiveJobLabel(actor);
|
|
string tag = UnitDossier.BuildIdentityTag(_current.SpeciesId, job);
|
|
string prevHeadline = LastHeadline ?? "";
|
|
string prevJob = _current.JobLabel ?? "";
|
|
string prevTag = _current.IdentityTag ?? "";
|
|
int memoryRevision = SagaProse.MemoryRevision;
|
|
if (string.Equals(job, prevJob, StringComparison.Ordinal)
|
|
&& string.Equals(tag, prevTag, StringComparison.Ordinal)
|
|
&& memoryRevision == _lastIdentityMemoryRevision)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastIdentityMemoryRevision = memoryRevision;
|
|
ApplyComposedCaption(
|
|
actor,
|
|
_current.ReasonLine ?? "",
|
|
_current.ReasonRelatedId,
|
|
statusBannerOwnsBeat: HasStatusBanner());
|
|
_current.JobLabel = job;
|
|
_current.IdentityTag = tag;
|
|
|
|
if (string.Equals(LastHeadline, prevHeadline, StringComparison.Ordinal)
|
|
&& string.Equals(job, prevJob, StringComparison.Ordinal)
|
|
&& string.Equals(tag, prevTag, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
|
|
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
|
|
Relayout(
|
|
_current.UnitId != 0,
|
|
CountActiveTraitSlots(),
|
|
CountActiveStatusSlots(),
|
|
hasTask,
|
|
hasReason,
|
|
CountActiveHistorySlots());
|
|
BringHeaderFront();
|
|
}
|
|
|
|
/// <summary>Keep status chips in sync with live status set (fingerprint on top-4 ids).</summary>
|
|
private static void RefreshLiveStatuses()
|
|
{
|
|
if (!_visible || _current == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor actor = ResolveBoundLiveActor();
|
|
if (actor == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UnitDossier.RefreshTopStatuses(StatusProbe, actor);
|
|
int fp = StatusFingerprint(StatusProbe);
|
|
if (fp == _lastStatusesFingerprint
|
|
&& StatusChipsMatch(_current.TopStatuses, StatusProbe.TopStatuses))
|
|
{
|
|
return;
|
|
}
|
|
|
|
UnitDossier.RefreshTopStatuses(_current, actor);
|
|
int statusCount = ApplyStatusChips(_current);
|
|
_lastStatusesFingerprint = fp;
|
|
bool hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
|
|
bool hasReason = (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text))
|
|
|| (_current != null && !string.IsNullOrEmpty(_current.ReasonLine));
|
|
Relayout(
|
|
_current.UnitId != 0,
|
|
CountActiveTraitSlots(),
|
|
statusCount,
|
|
hasTask,
|
|
hasReason,
|
|
CountActiveHistorySlots());
|
|
}
|
|
|
|
private static int StatusFingerprint(UnitDossier dossier)
|
|
{
|
|
if (dossier == null || dossier.TopStatuses == null || dossier.TopStatuses.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
unchecked
|
|
{
|
|
int h = 17;
|
|
for (int i = 0; i < dossier.TopStatuses.Count; i++)
|
|
{
|
|
string id = dossier.TopStatuses[i]?.Id ?? "";
|
|
h = h * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(id);
|
|
}
|
|
|
|
return h;
|
|
}
|
|
}
|
|
|
|
private static bool StatusChipsMatch(
|
|
System.Collections.Generic.List<UnitDossier.StatusChip> a,
|
|
System.Collections.Generic.List<UnitDossier.StatusChip> b)
|
|
{
|
|
int na = a != null ? a.Count : 0;
|
|
int nb = b != null ? b.Count : 0;
|
|
if (na != nb)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < na; i++)
|
|
{
|
|
string idA = a[i]?.Id ?? "";
|
|
string idB = b[i]?.Id ?? "";
|
|
if (!idA.Equals(idB, System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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();
|
|
_headlineLocked = true;
|
|
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(),
|
|
CountActiveStatusSlots(),
|
|
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);
|
|
}
|
|
|
|
/// <summary>Harness: rebuild the dossier peek column after activity injects.</summary>
|
|
public static void ForceRefreshHistory()
|
|
{
|
|
_lastHistoryCount = -1;
|
|
_lastLiveHistFingerprint = int.MinValue;
|
|
if (_current == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
SetFromActor(MoveCamera._focus_unit);
|
|
}
|
|
|
|
if (!_visible || _current == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RefreshHistoryIfChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: force JobLabel onto the nametag identity tag (and optional orange reason beat).
|
|
/// </summary>
|
|
public static bool ForceJobLabelOnFocus(
|
|
string jobLabel,
|
|
string reasonOverride = "",
|
|
long reasonRelatedId = 0,
|
|
string reasonRelatedName = "")
|
|
{
|
|
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
UnitDossier.HarnessJobLabelOverride = jobLabel ?? "";
|
|
UnitDossier.HarnessReasonOverride = reasonOverride ?? "";
|
|
UnitDossier.HarnessReasonRelatedId = reasonRelatedId;
|
|
UnitDossier.HarnessReasonRelatedName = reasonRelatedName ?? "";
|
|
SetFromActor(MoveCamera._focus_unit);
|
|
return _current != null
|
|
&& !string.IsNullOrEmpty(_current.JobLabel)
|
|
&& (_current.Headline ?? "").IndexOf(_current.JobLabel, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
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);
|
|
Actor live = ResolveBoundLiveActor();
|
|
int fingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
|
|
if (id == _lastHistorySubjectId
|
|
&& count == _lastHistoryCount
|
|
&& fingerprint == _lastLiveHistFingerprint)
|
|
{
|
|
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 && !string.IsNullOrEmpty(_taskText.text);
|
|
bool hasReason = (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text))
|
|
|| (_current != null && !string.IsNullOrEmpty(_current.ReasonLine));
|
|
Relayout(
|
|
hasBody,
|
|
CountActiveTraitSlots(),
|
|
CountActiveStatusSlots(),
|
|
hasTask,
|
|
hasReason,
|
|
shown);
|
|
}
|
|
|
|
private static void ApplyVisual(Actor actor, UnitDossier dossier)
|
|
{
|
|
EnsureBuilt();
|
|
if (_root == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_headlineLocked = false;
|
|
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;
|
|
bool hoverDepth = LifeSagaViewController.IsHoverPreview;
|
|
// Hover Cast/Legacy owns the body band - keep the large portrait off.
|
|
DossierAvatar.SetActive(hasBody && !hoverDepth);
|
|
|
|
// 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 (hoverDepth)
|
|
{
|
|
DossierAvatar.SetHostVisible(false);
|
|
}
|
|
else 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 = ApplyTraitChips(dossier);
|
|
int statusCount = ApplyStatusChips(dossier);
|
|
_lastStatusesFingerprint = StatusFingerprint(dossier);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
string presentable = dossier != null ? (dossier.ReasonLine ?? "") : "";
|
|
long related = dossier != null ? dossier.ReasonRelatedId : 0;
|
|
ApplyComposedCaption(hasLive ? actor : null, presentable, related, statusBannerOwnsBeat: false);
|
|
bool hasReason = !string.IsNullOrEmpty(LastBeatLine)
|
|
|| !string.IsNullOrEmpty(presentable);
|
|
|
|
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
|
|
RefreshFavoriteVisual(hasLive ? actor : null);
|
|
}
|
|
|
|
/// <summary>Orange story beat with rich-text gold names; empty hides the row.</summary>
|
|
private static void ApplyReasonText(string reasonRichOrPlain, long otherId = 0)
|
|
{
|
|
if (_reasonText == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool hasReason = !string.IsNullOrEmpty(reasonRichOrPlain);
|
|
string next = hasReason ? reasonRichOrPlain : "";
|
|
long subjectId = CurrentUnitId;
|
|
long nextOtherId = otherId != 0 && otherId != subjectId ? otherId : 0;
|
|
if (_reasonText.gameObject.activeSelf == hasReason
|
|
&& string.Equals(_reasonText.text, next, StringComparison.Ordinal)
|
|
&& _reasonOtherId == nextOtherId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_reasonText.supportRichText = true;
|
|
_reasonText.color = ReasonColor;
|
|
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
|
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
|
|
_reasonText.resizeTextForBestFit = false;
|
|
_reasonText.text = next;
|
|
_reasonText.gameObject.SetActive(hasReason);
|
|
WireReasonClick(hasReason ? otherId : 0);
|
|
}
|
|
|
|
private static void WireReasonClick(long otherId)
|
|
{
|
|
long subjectId = CurrentUnitId;
|
|
_reasonOtherId = otherId != 0 && otherId != subjectId ? otherId : 0;
|
|
if (_reasonText == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var entry = _reasonText.gameObject.GetComponent<UnityEngine.EventSystems.EventTrigger>();
|
|
if (entry == null)
|
|
{
|
|
entry = _reasonText.gameObject.AddComponent<UnityEngine.EventSystems.EventTrigger>();
|
|
}
|
|
|
|
entry.triggers.Clear();
|
|
bool clickable = _reasonOtherId != 0 && _reasonText.gameObject.activeSelf;
|
|
_reasonText.raycastTarget = clickable;
|
|
if (!clickable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long captured = _reasonOtherId;
|
|
var down = new UnityEngine.EventSystems.EventTrigger.Entry
|
|
{
|
|
eventID = UnityEngine.EventSystems.EventTriggerType.PointerDown
|
|
};
|
|
down.callback.AddListener(_ => OpenHistoryOtherInLore(captured));
|
|
entry.triggers.Add(down);
|
|
}
|
|
|
|
/// <summary>Harness: orange reason names another living unit.</summary>
|
|
public static bool ReasonOtherClickable => _reasonOtherId != 0;
|
|
|
|
/// <summary>Harness: click the orange reason to open the related unit in Lore.</summary>
|
|
public static bool ClickReasonOther(out long otherId)
|
|
{
|
|
otherId = _reasonOtherId;
|
|
if (otherId == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
OpenHistoryOtherInLore(otherId);
|
|
return true;
|
|
}
|
|
|
|
private static int ApplyTraitChips(UnitDossier dossier)
|
|
{
|
|
int traitCount = 0;
|
|
LastTraitsPreview = "";
|
|
if (_traitsRow == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (dossier == null || dossier.TopTraits.Count <= 0)
|
|
{
|
|
_traitsRow.SetActive(false);
|
|
return 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 != null ? (chip.Name ?? "") : "";
|
|
if (slot.Label != null)
|
|
{
|
|
slot.Label.text = name;
|
|
}
|
|
|
|
if (slot.Tip != null)
|
|
{
|
|
if (chip != null && (chip.Trait != null || !string.IsNullOrEmpty(chip.Id)))
|
|
{
|
|
slot.Tip.BindTrait(chip.Trait, chip.Id);
|
|
slot.Tip.enabled = true;
|
|
}
|
|
else
|
|
{
|
|
slot.Tip.Clear();
|
|
slot.Tip.enabled = false;
|
|
}
|
|
}
|
|
|
|
if (traitPreview.Length > 0)
|
|
{
|
|
traitPreview.Append(", ");
|
|
}
|
|
|
|
traitPreview.Append(name);
|
|
traitCount++;
|
|
}
|
|
else
|
|
{
|
|
if (slot.Tip != null)
|
|
{
|
|
slot.Tip.Clear();
|
|
slot.Tip.enabled = false;
|
|
}
|
|
|
|
slot.Root.SetActive(false);
|
|
}
|
|
}
|
|
|
|
LastTraitsPreview = traitPreview.ToString();
|
|
return traitCount;
|
|
}
|
|
|
|
private static int ApplyStatusChips(UnitDossier dossier)
|
|
{
|
|
int statusCount = 0;
|
|
LastStatusesPreview = "";
|
|
if (_statusesRow == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (dossier == null || dossier.TopStatuses == null || dossier.TopStatuses.Count <= 0)
|
|
{
|
|
_statusesRow.SetActive(false);
|
|
for (int i = 0; i < _statusSlots.Length; i++)
|
|
{
|
|
if (_statusSlots[i]?.Root != null)
|
|
{
|
|
_statusSlots[i].Root.SetActive(false);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
_statusesRow.SetActive(true);
|
|
System.Text.StringBuilder preview = new System.Text.StringBuilder();
|
|
for (int i = 0; i < _statusSlots.Length; i++)
|
|
{
|
|
TraitSlot slot = _statusSlots[i];
|
|
if (slot == null || slot.Root == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (i < dossier.TopStatuses.Count)
|
|
{
|
|
UnitDossier.StatusChip chip = dossier.TopStatuses[i];
|
|
slot.Root.SetActive(true);
|
|
Sprite sprite = chip != null
|
|
? (HudIcons.FromStatus(chip.Status) ?? HudIcons.FromStatusId(chip.Id))
|
|
: null;
|
|
HudIcons.Apply(slot.Icon, sprite);
|
|
string name = chip != null ? (chip.Name ?? "") : "";
|
|
if (slot.Label != null)
|
|
{
|
|
slot.Label.text = name;
|
|
slot.Label.color = StatusNameColor;
|
|
}
|
|
|
|
if (slot.Tip != null)
|
|
{
|
|
if (chip != null && (chip.Status != null || !string.IsNullOrEmpty(chip.Id)))
|
|
{
|
|
slot.Tip.BindStatus(chip.Status, chip.Id);
|
|
slot.Tip.enabled = true;
|
|
}
|
|
else
|
|
{
|
|
slot.Tip.Clear();
|
|
slot.Tip.enabled = false;
|
|
}
|
|
}
|
|
|
|
if (preview.Length > 0)
|
|
{
|
|
preview.Append(", ");
|
|
}
|
|
|
|
preview.Append(name);
|
|
statusCount++;
|
|
}
|
|
else
|
|
{
|
|
if (slot.Tip != null)
|
|
{
|
|
slot.Tip.Clear();
|
|
slot.Tip.enabled = false;
|
|
}
|
|
|
|
slot.Root.SetActive(false);
|
|
}
|
|
}
|
|
|
|
LastStatusesPreview = preview.ToString();
|
|
return statusCount;
|
|
}
|
|
|
|
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;
|
|
Actor live = null;
|
|
try
|
|
{
|
|
live = ResolveBoundLiveActor();
|
|
if (live != null && live.isAlive() && live.getID() != unitId)
|
|
{
|
|
live = null;
|
|
}
|
|
|
|
if (live == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null
|
|
&& MoveCamera._focus_unit.isAlive()
|
|
&& MoveCamera._focus_unit.getID() == unitId)
|
|
{
|
|
live = MoveCamera._focus_unit;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
live = null;
|
|
}
|
|
|
|
IReadOnlyList<ActivityEntry> activity = live != null
|
|
? ActivityLog.LatestRelevantForSubject(live, unitId, actCap)
|
|
: 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;
|
|
_lastLiveHistFingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
|
|
|
|
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 focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
if (focus != null && focus.isAlive() && focus.getID() == unitId && focus.hasTask()
|
|
&& focus.ai?.task != null)
|
|
{
|
|
taskFallback = focus.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, long otherId)>(
|
|
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;
|
|
long otherId = ResolveHistoryOtherId(unitId, e.RelatedId, e.TargetLabel);
|
|
lines.Add((rich, plain, true, null, e.TaskId ?? "", otherId));
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
long otherId = e.OtherId != 0 && e.OtherId != unitId ? e.OtherId : 0;
|
|
lines.Add((rich, plain, false, e.Kind, "", otherId));
|
|
}
|
|
}
|
|
|
|
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, long otherId)> 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);
|
|
slot.OtherId = 0;
|
|
WireHistoryRowClick(slot, 0);
|
|
_historySlotHeights[i] = 0f;
|
|
_historySlotIsActivity[i] = false;
|
|
continue;
|
|
}
|
|
|
|
var row = lines[i];
|
|
_historySlotIsActivity[i] = row.isActivity;
|
|
slot.Root.SetActive(true);
|
|
slot.OtherId = row.otherId;
|
|
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
|
|
? HudTheme.Body
|
|
: 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;
|
|
}
|
|
|
|
WireHistoryRowClick(slot, row.otherId);
|
|
|
|
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 WireHistoryRowClick(HistorySlot slot, long otherId)
|
|
{
|
|
if (slot?.Root == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
slot.OtherId = otherId;
|
|
if (slot.Hit == null)
|
|
{
|
|
slot.Hit = slot.Root.GetComponent<Image>();
|
|
if (slot.Hit == null)
|
|
{
|
|
slot.Hit = slot.Root.AddComponent<Image>();
|
|
}
|
|
|
|
slot.Hit.color = new Color(1f, 1f, 1f, 0.001f);
|
|
}
|
|
|
|
if (slot.Button == null)
|
|
{
|
|
slot.Button = slot.Root.GetComponent<Button>();
|
|
if (slot.Button == null)
|
|
{
|
|
slot.Button = slot.Root.AddComponent<Button>();
|
|
}
|
|
|
|
ColorBlock colors = slot.Button.colors;
|
|
colors.normalColor = Color.white;
|
|
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
|
|
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
|
|
slot.Button.colors = colors;
|
|
slot.Button.transition = Selectable.Transition.ColorTint;
|
|
slot.Button.targetGraphic = slot.Hit;
|
|
}
|
|
|
|
slot.Button.onClick.RemoveAllListeners();
|
|
bool clickable = otherId != 0;
|
|
slot.Hit.raycastTarget = clickable;
|
|
slot.Button.interactable = clickable;
|
|
var entry = slot.Root.GetComponent<UnityEngine.EventSystems.EventTrigger>();
|
|
if (entry == null)
|
|
{
|
|
entry = slot.Root.AddComponent<UnityEngine.EventSystems.EventTrigger>();
|
|
}
|
|
|
|
entry.triggers.Clear();
|
|
if (!clickable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// PointerDown (not Button release) so Lore opens on the same frame as the press.
|
|
long captured = otherId;
|
|
var down = new UnityEngine.EventSystems.EventTrigger.Entry
|
|
{
|
|
eventID = UnityEngine.EventSystems.EventTriggerType.PointerDown
|
|
};
|
|
down.callback.AddListener(_ => OpenHistoryOtherInLore(captured));
|
|
entry.triggers.Add(down);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prefer stored related id; if missing (older happiness rings), resolve by living name.
|
|
/// </summary>
|
|
private static long ResolveHistoryOtherId(long subjectId, long relatedId, string relatedName)
|
|
{
|
|
if (relatedId != 0 && relatedId != subjectId)
|
|
{
|
|
return relatedId;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(relatedName))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
return ActivityLog.ResolveLivingUnitIdByName(relatedName, subjectId);
|
|
}
|
|
|
|
/// <summary>Whole-row click: focus other unit (or fallen path) and open Lore biography.</summary>
|
|
public static void OpenHistoryOtherInLore(long otherId)
|
|
{
|
|
if (otherId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ChronicleHud.OpenUnitHistory(otherId, pauseIdle: true, followFocus: true);
|
|
}
|
|
|
|
/// <summary>Harness: click first peek row that names another unit.</summary>
|
|
public static bool ClickFirstHistoryOtherRow(out long otherId)
|
|
{
|
|
otherId = 0;
|
|
for (int i = 0; i < _historySlots.Length; i++)
|
|
{
|
|
HistorySlot slot = _historySlots[i];
|
|
if (slot?.Root == null || !slot.Root.activeSelf || slot.OtherId == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
otherId = slot.OtherId;
|
|
OpenHistoryOtherInLore(otherId);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public static int CountHistoryOtherRows()
|
|
{
|
|
int n = 0;
|
|
for (int i = 0; i < _historySlots.Length; i++)
|
|
{
|
|
HistorySlot slot = _historySlots[i];
|
|
if (slot?.Root != null && slot.Root.activeSelf && slot.OtherId != 0)
|
|
{
|
|
n++;
|
|
}
|
|
}
|
|
|
|
return n;
|
|
}
|
|
|
|
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,
|
|
int statusCount,
|
|
bool hasTask,
|
|
bool hasReason,
|
|
int historyCount)
|
|
{
|
|
float headerW = MeasureHeaderWidth(hasTask);
|
|
// Traits beside avatar only when history and statuses leave the body-right slot free.
|
|
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
|
|
float traitsRowW = traitCount > 0 && !traitsBeside ? MeasureTraitsRowWidth() : 0f;
|
|
float traitsBesideW = traitsBeside ? MeasureTraitsBesideWidth() : 0f;
|
|
float statusesRowW = statusCount > 0 ? MeasureStatusesRowWidth() : 0f;
|
|
float traitsW = 0f;
|
|
if (traitsBeside)
|
|
{
|
|
traitsW = Mathf.Max(HistoryColMinW, traitsBesideW);
|
|
}
|
|
else if (traitCount > 0)
|
|
{
|
|
traitsW = Mathf.Max(HistoryColMinW, traitsRowW);
|
|
}
|
|
|
|
float bodyW = 0f;
|
|
if (hasBody)
|
|
{
|
|
if (historyCount > 0)
|
|
{
|
|
bodyW = LiveMax + 4f + _historyColW;
|
|
}
|
|
else if (traitsBeside)
|
|
{
|
|
bodyW = LiveMax + 4f + traitsW;
|
|
}
|
|
else
|
|
{
|
|
bodyW = LiveMax;
|
|
}
|
|
}
|
|
|
|
bool panelMode = LifeSagaViewController.IsHoverPreview && !HasStatusBanner();
|
|
bool railChrome = LifeSagaRail.Visible;
|
|
|
|
// Defensive ownership boundary: Saga preview never lays out camera Beat space even
|
|
// if a caller still has a composed LastBeatLine from immediately before hover.
|
|
if (panelMode)
|
|
{
|
|
hasReason = false;
|
|
}
|
|
|
|
// Rail chrome keeps a constant width so hover preview cannot reflow the glyphs.
|
|
if (railChrome || panelMode)
|
|
{
|
|
_panelWidth = PanelWidthMax;
|
|
}
|
|
else
|
|
{
|
|
_panelWidth = Mathf.Clamp(
|
|
PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, statusesRowW, LiveMax),
|
|
LiveMax + PadX * 2f,
|
|
PanelWidthMax);
|
|
}
|
|
|
|
LastHistoryFillsBody = historyCount <= 0;
|
|
if (!panelMode && historyCount > 0)
|
|
{
|
|
float fillW = BodyColumnInnerWidth() - LiveMax - 4f;
|
|
if (fillW > _historyColW + 0.5f)
|
|
{
|
|
RemeasureHistoryForWidth(fillW);
|
|
}
|
|
else
|
|
{
|
|
_historyColW = Mathf.Min(_historyColW, fillW);
|
|
}
|
|
|
|
LastHistoryFillsBody = _historyColW >= fillW - 1f;
|
|
}
|
|
|
|
float y = PadTop;
|
|
|
|
// Stable top chrome: rail first so hover body swaps cannot move glyphs.
|
|
if (LifeSagaRail.Visible)
|
|
{
|
|
y = LifeSagaRail.Place(y, PadX);
|
|
}
|
|
|
|
// Hover depth hides dossier rows but reuses the live avatar for the hovered MC.
|
|
SetDossierBodyVisible(!panelMode);
|
|
|
|
PlaceHeader(y, hasTask);
|
|
y += HeaderH + Gap;
|
|
|
|
if (!panelMode)
|
|
{
|
|
bool hasConnection = _connectionText != null
|
|
&& _connectionText.gameObject.activeSelf
|
|
&& !string.IsNullOrEmpty(_connectionText.text);
|
|
if (hasConnection)
|
|
{
|
|
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
|
|
y += ConnectionH + Gap;
|
|
}
|
|
|
|
// Row visibility is owned by Place*/fill - never leave empty rows active at stale
|
|
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
|
|
if (historyCount <= 0 && _historyCol != null)
|
|
{
|
|
_historyCol.SetActive(false);
|
|
}
|
|
|
|
if (statusCount <= 0 && _statusesRow != null)
|
|
{
|
|
_statusesRow.SetActive(false);
|
|
}
|
|
|
|
if (traitCount <= 0 && _traitsRow != null)
|
|
{
|
|
_traitsRow.SetActive(false);
|
|
}
|
|
|
|
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 (statusCount > 0)
|
|
{
|
|
float statusesUsed = PlaceStatuses(y, statusCount);
|
|
WidenPanelForChipRow(statusesUsed);
|
|
y += StatusesH + Gap;
|
|
}
|
|
|
|
if (traitCount > 0 && !traitsBeside)
|
|
{
|
|
float traitsUsed = PlaceTraits(y, traitCount);
|
|
WidenPanelForChipRow(traitsUsed);
|
|
y += TraitsH + Gap;
|
|
}
|
|
}
|
|
|
|
if (hasReason)
|
|
{
|
|
float reasonH = MeasureReasonHeight();
|
|
PlaceLine(_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null, y, reasonH, PadX);
|
|
y += reasonH + Gap;
|
|
}
|
|
|
|
bool hasStorySpine = _storySpineText != null
|
|
&& _storySpineText.gameObject.activeSelf
|
|
&& !string.IsNullOrEmpty(_storySpineText.text);
|
|
if (hasStorySpine)
|
|
{
|
|
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
|
|
y += StorySpineH + Gap;
|
|
}
|
|
|
|
if (panelMode)
|
|
{
|
|
LifeSagaPanel.EnsureBuilt(_root.transform);
|
|
DossierAvatar.Place(PadX, y, LiveMax);
|
|
DossierAvatar.SetHostVisible(true);
|
|
y = LifeSagaPanel.Place(y, PadX);
|
|
if (railChrome)
|
|
{
|
|
_panelWidth = PanelWidthMax;
|
|
}
|
|
}
|
|
|
|
float heightFinal = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
|
|
if (_rootRt != null)
|
|
{
|
|
ApplyRootPlacement();
|
|
_rootRt.sizeDelta = new Vector2(_panelWidth, heightFinal);
|
|
LastPanelSize = _rootRt.sizeDelta;
|
|
}
|
|
|
|
BringHeaderFront();
|
|
|
|
LastLayoutOk = panelMode
|
|
|| (ProbeLayoutInside(heightFinal)
|
|
&& headerW > SpeciesSize
|
|
&& (historyCount <= 0 || LastHistoryFillsBody));
|
|
if (!LastLayoutOk)
|
|
{
|
|
LogService.LogInfo(
|
|
$"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} statuses={statusCount} hist={historyCount} headerW={headerW} histFill={LastHistoryFillsBody} histW={_historyColW}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toggle dossier depth rows (avatar / traits / statuses / history).
|
|
/// Never hides nametag Identity, Beat reason, or Context spine - those stay for camera focus
|
|
/// even while the hover Cast/Legacy panel is open.
|
|
/// </summary>
|
|
private static void SetDossierBodyVisible(bool visible)
|
|
{
|
|
if (!visible)
|
|
{
|
|
if (_historyCol != null) _historyCol.SetActive(false);
|
|
if (_traitsRow != null) _traitsRow.SetActive(false);
|
|
if (_statusesRow != null) _statusesRow.SetActive(false);
|
|
if (!LifeSagaPanel.OwnsAvatar)
|
|
{
|
|
DossierAvatar.SetHostVisible(false);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Showing dossier body again - avatar host is restored when panel does not own it.
|
|
// History / traits / statuses stay under Fill*/Place* ownership.
|
|
if (!LifeSagaPanel.OwnsAvatar)
|
|
{
|
|
DossierAvatar.SetHostVisible(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nametag: left-pack [species] Name [lv]n [task]; right-pin [sex] [★] to the panel edge.
|
|
/// Middle chips shrink/ellipsis so they never paint under the right cluster.
|
|
/// </summary>
|
|
private static float PlaceHeader(float yFromTop, bool hasTask)
|
|
{
|
|
float panelW = Mathf.Max(_panelWidth, LiveMax + PadX * 2f);
|
|
bool depthMode = LifeSagaViewController.IsHoverPreview;
|
|
if (depthMode)
|
|
{
|
|
// Identity is the only header datum needed while reading Saga depth.
|
|
// Explicitly hide these so a live task refresh cannot leave stale chips
|
|
// over a long title between relayouts.
|
|
if (_levelIcon != null) _levelIcon.gameObject.SetActive(false);
|
|
if (_levelValue != null) _levelValue.gameObject.SetActive(false);
|
|
if (_taskIcon != null) _taskIcon.gameObject.SetActive(false);
|
|
if (_taskText != null) _taskText.gameObject.SetActive(false);
|
|
hasTask = false;
|
|
}
|
|
|
|
// Sex + favorite hug the dossier's top-right (right-justified).
|
|
float right = panelW - PadX;
|
|
PlaceHeaderButton(_favoriteBtn, right - BtnSize, yFromTop);
|
|
right -= BtnSize;
|
|
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
|
|
{
|
|
right -= 2f;
|
|
PlaceLeftChip(_sexIcon.rectTransform, right - SexSize, yFromTop + 2f, SexSize, SexSize);
|
|
right -= SexSize;
|
|
}
|
|
|
|
const float minGap = 6f;
|
|
float leftBudget = Mathf.Max(NameMinW, right - PadX - minGap);
|
|
|
|
float x = PadX;
|
|
if (_speciesIcon != null)
|
|
{
|
|
PlaceLeftChip(_speciesIcon.rectTransform, x, yFromTop + 1f, SpeciesSize, SpeciesSize);
|
|
x += SpeciesSize + 3f;
|
|
}
|
|
|
|
bool hasLevel = _levelIcon != null && _levelIcon.gameObject.activeSelf
|
|
&& _levelValue != null && _levelValue.gameObject.activeSelf;
|
|
float levelW = 0f;
|
|
if (hasLevel)
|
|
{
|
|
levelW = ChipIcon + 1f
|
|
+ Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f)
|
|
+ 4f;
|
|
}
|
|
|
|
float taskReserve = hasTask ? ChipIcon + 1f + 28f + 4f : 0f;
|
|
float nameBudget = Mathf.Clamp(
|
|
leftBudget - (x - PadX) - levelW - taskReserve,
|
|
NameMinW,
|
|
NameMaxW);
|
|
|
|
if (_nameText != null)
|
|
{
|
|
float nameW = FitNameChipWidth(nameBudget);
|
|
PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH);
|
|
x += nameW + 4f;
|
|
}
|
|
|
|
if (hasLevel)
|
|
{
|
|
PlaceLeftChip(_levelIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
|
|
x += ChipIcon + 1f;
|
|
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);
|
|
float taskBudget = Mathf.Clamp(
|
|
leftBudget - (x - PadX) - ChipIcon - 1f,
|
|
24f,
|
|
TaskLabelW);
|
|
float taskW = FitTaskChipWidth(taskBudget);
|
|
PlaceLeftChip(_taskIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
|
|
x += ChipIcon + 1f;
|
|
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);
|
|
}
|
|
}
|
|
|
|
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.Min(FitNameChipWidth(NameMaxW), NameMaxW) + 4f;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Reserve a gap + right cluster so sex/favorite can pin to the panel edge.
|
|
const float minGap = 6f;
|
|
float rightCluster = BtnSize;
|
|
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
|
|
{
|
|
rightCluster += 2f + SexSize;
|
|
}
|
|
|
|
x += minGap + rightCluster;
|
|
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);
|
|
}
|
|
|
|
long currentId = _current != null ? _current.UnitId : EventFeedUtil.SafeId(actor);
|
|
RefreshFavoriteVisualFor(currentId, actor, fav);
|
|
}
|
|
|
|
private static void RefreshFavoriteVisualFor(long unitId, Actor actor, bool fallbackFavorite)
|
|
{
|
|
if (_favoriteIcon == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool fav = fallbackFavorite || Chronicle.IsFavoriteSubject(unitId);
|
|
try
|
|
{
|
|
fav = fav || (actor != null && actor.isAlive() && actor.isFavorite());
|
|
}
|
|
catch
|
|
{
|
|
// Keep snapshot/Chronicle favorite state.
|
|
}
|
|
|
|
HudIcons.Apply(_favoriteIcon, HudIcons.Favorite());
|
|
_favoriteIcon.color = fav ? HudTheme.FavoriteOn : HudTheme.FavoriteOff;
|
|
}
|
|
|
|
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 shrink long Identity titles to fit.
|
|
/// Keep the full title rather than character-truncating role/place information.
|
|
/// </summary>
|
|
private static float FitNameChipWidth(float maxW = NameMaxW)
|
|
{
|
|
if (_nameText == null)
|
|
{
|
|
return NameMinW;
|
|
}
|
|
|
|
maxW = Mathf.Max(NameMinW, maxW);
|
|
_nameText.resizeTextForBestFit = false;
|
|
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
_nameText.fontSize = 10;
|
|
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 <= maxW)
|
|
{
|
|
return Mathf.Clamp(natural, NameMinW, maxW);
|
|
}
|
|
|
|
int fittedSize = Mathf.Clamp(Mathf.FloorToInt(10f * maxW / natural), 7, 10);
|
|
_nameText.fontSize = fittedSize;
|
|
_nameText.text = full;
|
|
_nameText.verticalOverflow = VerticalWrapMode.Truncate;
|
|
return Mathf.Clamp(MeasureTextWidth(_nameText, maxW), NameMinW, maxW);
|
|
}
|
|
|
|
/// <summary>Ellipsis-fit the task chip into the remaining header budget.</summary>
|
|
private static float FitTaskChipWidth(float maxW)
|
|
{
|
|
if (_taskText == null)
|
|
{
|
|
return Mathf.Clamp(maxW, 24f, TaskLabelW);
|
|
}
|
|
|
|
maxW = Mathf.Clamp(maxW, 24f, TaskLabelW);
|
|
_taskText.resizeTextForBestFit = false;
|
|
_taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
string full = TruncateTaskLabel(_taskText.text ?? "");
|
|
if (string.IsNullOrEmpty(full))
|
|
{
|
|
_taskText.text = "";
|
|
return maxW;
|
|
}
|
|
|
|
_taskText.text = full;
|
|
float natural = MeasureTextWidth(_taskText, maxW);
|
|
if (natural <= maxW)
|
|
{
|
|
return Mathf.Clamp(natural, 24f, maxW);
|
|
}
|
|
|
|
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);
|
|
_taskText.text = candidate;
|
|
float w = MeasureTextWidth(_taskText, maxW);
|
|
if (w <= maxW)
|
|
{
|
|
best = candidate;
|
|
lo = mid + 1;
|
|
}
|
|
else
|
|
{
|
|
hi = mid - 1;
|
|
}
|
|
}
|
|
|
|
_taskText.text = best;
|
|
return Mathf.Clamp(MeasureTextWidth(_taskText, maxW), 24f, maxW);
|
|
}
|
|
|
|
private static void EllipsisFitLabel(Text label, float maxW)
|
|
{
|
|
if (label == null || maxW < 8f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string full = label.text ?? "";
|
|
if (string.IsNullOrEmpty(full))
|
|
{
|
|
return;
|
|
}
|
|
|
|
label.resizeTextForBestFit = false;
|
|
label.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
if (MeasureTextWidth(label, maxW) <= maxW)
|
|
{
|
|
return;
|
|
}
|
|
|
|
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);
|
|
label.text = candidate;
|
|
if (MeasureTextWidth(label, maxW) <= maxW)
|
|
{
|
|
best = candidate;
|
|
lo = mid + 1;
|
|
}
|
|
else
|
|
{
|
|
hi = mid - 1;
|
|
}
|
|
}
|
|
|
|
label.text = best;
|
|
}
|
|
|
|
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 MeasureChipRowWidth(TraitSlot[] slots, float fallbackLabelW)
|
|
{
|
|
if (slots == null)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
try
|
|
{
|
|
Canvas.ForceUpdateCanvases();
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
float x = 0f;
|
|
int n = 0;
|
|
for (int i = 0; i < slots.Length; i++)
|
|
{
|
|
TraitSlot slot = slots[i];
|
|
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float labelW = slot.Label != null
|
|
? Mathf.Max(MeasureTextWidth(slot.Label, fallbackLabelW), 18f)
|
|
: fallbackLabelW;
|
|
// Prefer preferredWidth; fall back to character estimate so long labels
|
|
// still widen the panel before Unity finishes a layout pass.
|
|
if (slot.Label != null && !string.IsNullOrEmpty(slot.Label.text)
|
|
&& labelW <= fallbackLabelW + 0.5f)
|
|
{
|
|
labelW = Mathf.Max(labelW, slot.Label.text.Length * 6.2f);
|
|
}
|
|
|
|
float slotW = TraitIcon + 2f + labelW;
|
|
x += slotW + 3f;
|
|
n++;
|
|
}
|
|
|
|
return n > 0 ? x - 3f : 0f;
|
|
}
|
|
|
|
private static float MeasureStatusesRowWidth() => MeasureChipRowWidth(_statusSlots, 28f);
|
|
|
|
private static float MeasureTraitsRowWidth() => MeasureChipRowWidth(_traitSlots, 28f);
|
|
|
|
/// <summary>Grow the dossier when chip rows need more width than the pre-pass estimate.</summary>
|
|
private static void WidenPanelForChipRow(float rowContentW)
|
|
{
|
|
if (rowContentW < 1f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float need = PadX * 2f + rowContentW;
|
|
if (need > _panelWidth + 0.5f)
|
|
{
|
|
_panelWidth = Mathf.Clamp(need, LiveMax + PadX * 2f, PanelWidthMax);
|
|
}
|
|
}
|
|
|
|
private static float MeasureTraitsBesideWidth()
|
|
{
|
|
float colW = 0f;
|
|
if (_traitSlots == null)
|
|
{
|
|
return 56f;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
return colW > 1f ? colW : 56f;
|
|
}
|
|
|
|
private static float PlaceStatuses(float yFromTop, int statusCount)
|
|
{
|
|
if (_statusesRow == null)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
_statusesRow.SetActive(true);
|
|
RectTransform row = _statusesRow.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 + StatusesH));
|
|
row.offsetMax = new Vector2(-PadX, -yFromTop);
|
|
|
|
float maxRow = Mathf.Max(40f, BodyColumnInnerWidth());
|
|
float x = 0f;
|
|
for (int i = 0; i < _statusSlots.Length; i++)
|
|
{
|
|
TraitSlot slot = _statusSlots[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;
|
|
if (x + slotW > maxRow && slot.Label != null)
|
|
{
|
|
labelW = Mathf.Max(12f, maxRow - x - TraitIcon - 2f);
|
|
EllipsisFitLabel(slot.Label, labelW);
|
|
slotW = TraitIcon + 2f + labelW;
|
|
}
|
|
|
|
if (x + slotW > maxRow + 0.5f)
|
|
{
|
|
slot.Root.SetActive(false);
|
|
continue;
|
|
}
|
|
|
|
PlaceTraitSlot(slot, x, 0f, slotW, StatusesH);
|
|
x += slotW + 3f;
|
|
}
|
|
|
|
return x > 0f ? x - 3f : 0f;
|
|
}
|
|
|
|
private static float PlaceTraits(float yFromTop, int traitCount)
|
|
{
|
|
if (_traitsRow == null)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
_traitsRow.SetActive(true);
|
|
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 maxRow = Mathf.Max(40f, BodyColumnInnerWidth());
|
|
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;
|
|
if (x + slotW > maxRow && slot.Label != null)
|
|
{
|
|
labelW = Mathf.Max(12f, maxRow - x - TraitIcon - 2f);
|
|
EllipsisFitLabel(slot.Label, labelW);
|
|
slotW = TraitIcon + 2f + labelW;
|
|
}
|
|
|
|
if (x + slotW > maxRow + 0.5f)
|
|
{
|
|
slot.Root.SetActive(false);
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
_traitsRow.SetActive(true);
|
|
float colW = MeasureTraitsBesideWidth();
|
|
|
|
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))
|
|
&& (_storySpineText == null || !_storySpineText.gameObject.activeSelf
|
|
|| LineInside(_storySpineText.GetComponent<RectTransform>(), panelHeight))
|
|
&& (_statusesRow == null || !_statusesRow.activeSelf
|
|
|| LineInside(_statusesRow.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
|
|
|| _statusesRow == null
|
|
|| _connectionText == null
|
|
|| _favoriteBtn == null
|
|
|| _root.transform.Find("HistoryBtn") != null
|
|
|| _root.transform.Find("HistoryScroll") != null))
|
|
{
|
|
// Stale HUD from an older build (books button / nested history) - rebuild.
|
|
try
|
|
{
|
|
UnityEngine.Object.Destroy(_root);
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
_root = null;
|
|
_rootRt = null;
|
|
_speciesIcon = null;
|
|
_sexIcon = null;
|
|
_nameText = null;
|
|
_reasonText = null;
|
|
_connectionText = null;
|
|
_storySpineText = null;
|
|
_reasonOtherId = 0;
|
|
LastStorySpine = "";
|
|
LastConnectionLine = "";
|
|
_levelIcon = null;
|
|
_levelValue = null;
|
|
_taskIcon = null;
|
|
_taskText = null;
|
|
_traitsRow = null;
|
|
_statusesRow = null;
|
|
_historyCol = null;
|
|
_activityBoxBg = null;
|
|
_lifeSep = null;
|
|
_favoriteBtn = null;
|
|
_favoriteIcon = 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);
|
|
|
|
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 = HudTheme.InsetFill;
|
|
_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 = HudTheme.Separator;
|
|
sepImg.raycastTarget = false;
|
|
sepGo.SetActive(false);
|
|
|
|
for (int i = 0; i < _historySlots.Length; i++)
|
|
{
|
|
GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform), typeof(Image), typeof(Button));
|
|
slotGo.transform.SetParent(_historyCol.transform, false);
|
|
Image hit = slotGo.GetComponent<Image>();
|
|
hit.color = new Color(1f, 1f, 1f, 0.001f);
|
|
hit.raycastTarget = false;
|
|
Button button = slotGo.GetComponent<Button>();
|
|
button.transition = Selectable.Transition.ColorTint;
|
|
button.targetGraphic = hit;
|
|
button.interactable = false;
|
|
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", HistoryIcon);
|
|
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7);
|
|
label.color = HistoryTextColor;
|
|
label.alignment = TextAnchor.MiddleLeft;
|
|
label.horizontalOverflow = HorizontalWrapMode.Wrap;
|
|
label.supportRichText = true;
|
|
label.resizeTextMinSize = 6;
|
|
label.resizeTextMaxSize = 7;
|
|
_historySlots[i] = new HistorySlot
|
|
{
|
|
Root = slotGo,
|
|
Icon = icon,
|
|
Label = label,
|
|
Button = button,
|
|
Hit = hit,
|
|
OtherId = 0
|
|
};
|
|
slotGo.SetActive(false);
|
|
}
|
|
|
|
_statusesRow = new GameObject("StatusesRow", typeof(RectTransform));
|
|
_statusesRow.transform.SetParent(_root.transform, false);
|
|
for (int i = 0; i < _statusSlots.Length; i++)
|
|
{
|
|
GameObject slotGo = new GameObject("Status" + i, typeof(RectTransform), typeof(Image));
|
|
slotGo.transform.SetParent(_statusesRow.transform, false);
|
|
Image hit = slotGo.GetComponent<Image>();
|
|
hit.color = new Color(1f, 1f, 1f, 0.001f);
|
|
hit.raycastTarget = true;
|
|
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
|
|
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7);
|
|
label.color = StatusNameColor;
|
|
label.alignment = TextAnchor.MiddleLeft;
|
|
label.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
label.resizeTextMinSize = 6;
|
|
label.resizeTextMaxSize = 7;
|
|
DossierChipTip tip = slotGo.AddComponent<DossierChipTip>();
|
|
tip.enabled = false;
|
|
_statusSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip };
|
|
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), typeof(Image));
|
|
slotGo.transform.SetParent(_traitsRow.transform, false);
|
|
Image hit = slotGo.GetComponent<Image>();
|
|
hit.color = new Color(1f, 1f, 1f, 0.001f);
|
|
hit.raycastTarget = true;
|
|
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
|
|
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7);
|
|
label.color = TraitNameColor;
|
|
label.alignment = TextAnchor.MiddleLeft;
|
|
label.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
label.resizeTextMinSize = 6;
|
|
label.resizeTextMaxSize = 7;
|
|
DossierChipTip tip = slotGo.AddComponent<DossierChipTip>();
|
|
tip.enabled = false;
|
|
_traitSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip };
|
|
slotGo.SetActive(false);
|
|
}
|
|
|
|
_reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 7);
|
|
_reasonText.color = ReasonColor;
|
|
_reasonText.supportRichText = true;
|
|
_reasonText.alignment = TextAnchor.UpperLeft;
|
|
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
|
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
|
|
_reasonText.resizeTextForBestFit = false;
|
|
WireReasonClick(0);
|
|
|
|
_connectionText = HudCanvas.MakeText(_root.transform, "Connection", "", 7);
|
|
_connectionText.color = ConnectionColor;
|
|
_connectionText.supportRichText = false;
|
|
_connectionText.alignment = TextAnchor.UpperLeft;
|
|
_connectionText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
_connectionText.verticalOverflow = VerticalWrapMode.Truncate;
|
|
_connectionText.resizeTextForBestFit = false;
|
|
_connectionText.raycastTarget = false;
|
|
|
|
_storySpineText = HudCanvas.MakeText(_root.transform, "StorySpine", "", 7);
|
|
_storySpineText.color = StorySpineColor;
|
|
_storySpineText.supportRichText = false;
|
|
_storySpineText.alignment = TextAnchor.UpperLeft;
|
|
_storySpineText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
_storySpineText.verticalOverflow = VerticalWrapMode.Truncate;
|
|
_storySpineText.resizeTextForBestFit = false;
|
|
_storySpineText.raycastTarget = false;
|
|
|
|
LifeSagaRail.EnsureBuilt(_root.transform);
|
|
LifeSagaPanel.EnsureBuilt(_root.transform);
|
|
|
|
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
|
|
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
|
|
|
|
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 10);
|
|
_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", "", 8);
|
|
_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", "", 7);
|
|
_taskText.color = TaskColor;
|
|
_taskText.alignment = TextAnchor.MiddleLeft;
|
|
_taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
|
|
|
BuildHeaderButtons();
|
|
|
|
DossierAvatar.SetActive(false);
|
|
_historyCol.SetActive(false);
|
|
_statusesRow.SetActive(false);
|
|
_traitsRow.SetActive(false);
|
|
_reasonText.gameObject.SetActive(false);
|
|
_connectionText.gameObject.SetActive(false);
|
|
_storySpineText.gameObject.SetActive(false);
|
|
_levelIcon.gameObject.SetActive(false);
|
|
_levelValue.gameObject.SetActive(false);
|
|
_taskIcon.gameObject.SetActive(false);
|
|
_taskText.gameObject.SetActive(false);
|
|
Relayout(false, 0, 0, false, false, 0);
|
|
_root.SetActive(false);
|
|
_visible = false;
|
|
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits + story spine)");
|
|
}
|
|
|
|
private static void BuildHeaderButtons()
|
|
{
|
|
_favoriteBtn = BuildIconButton(_root.transform, "FavoriteBtn", HudIcons.Favorite(), ToggleFavorite);
|
|
_favoriteIcon = _favoriteBtn.transform.Find("Icon")?.GetComponent<Image>();
|
|
}
|
|
|
|
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 = HudTheme.ToolFill;
|
|
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);
|
|
}
|
|
}
|