- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause - Persist observed saga facts across roster churn; pin subject without stale fallback - Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips - Densify dossier header/chips and keep the cast rail as stable top chrome - Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
630 lines
18 KiB
C#
630 lines
18 KiB
C#
using NeoModLoader.services;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Compact dossier portrait that reuses vanilla <see cref="UiUnitAvatarElement"/>
|
|
/// (stone frame + floor tile), falling back to a plain live sprite if needed.
|
|
/// </summary>
|
|
public static class DossierAvatar
|
|
{
|
|
private static UiUnitAvatarElement _template;
|
|
private static UiUnitAvatarElement _instance;
|
|
private static RectTransform _host;
|
|
private static Image _fallbackBg;
|
|
private static Image _fallbackSprite;
|
|
private static bool _usingVanilla;
|
|
private static bool _loggedMode;
|
|
private static long _shownActorId = -1;
|
|
/// <summary>Egg/baby/adult + species - same unit id can change visual form.</summary>
|
|
private static string _shownFormKey = "";
|
|
|
|
public static bool UsingVanilla => _usingVanilla;
|
|
|
|
/// <summary>Live unit portrait is active (not the fallen species fallback).</summary>
|
|
public static bool ShowingLiveAvatar => _shownActorId > 0;
|
|
|
|
/// <summary>Fallen archive species-icon portrait is showing.</summary>
|
|
public static bool ShowingSpeciesFallback => _shownActorId == -2;
|
|
|
|
/// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary>
|
|
public static bool ContainsScreenPoint(Vector2 screenPoint)
|
|
{
|
|
if (_host == null || !_host.gameObject.activeInHierarchy)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return RectTransformUtility.RectangleContainsScreenPoint(_host, screenPoint, null);
|
|
}
|
|
|
|
public static void ResetHost()
|
|
{
|
|
_template = null;
|
|
_instance = null;
|
|
_host = null;
|
|
_fallbackBg = null;
|
|
_fallbackSprite = null;
|
|
_usingVanilla = false;
|
|
_loggedMode = false;
|
|
_shownActorId = -1;
|
|
_shownFormKey = "";
|
|
}
|
|
|
|
public static void EnsureHost(Transform parent, float size)
|
|
{
|
|
if (_host != null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
GameObject hostGo = new GameObject("DossierAvatarHost", typeof(RectTransform), typeof(RectMask2D));
|
|
hostGo.transform.SetParent(parent, false);
|
|
_host = hostGo.GetComponent<RectTransform>();
|
|
_host.anchorMin = new Vector2(0f, 1f);
|
|
_host.anchorMax = new Vector2(0f, 1f);
|
|
_host.pivot = new Vector2(0f, 1f);
|
|
_host.sizeDelta = new Vector2(size, size);
|
|
|
|
TrySpawnVanilla(size);
|
|
if (!_usingVanilla)
|
|
{
|
|
BuildFallback(size);
|
|
}
|
|
}
|
|
|
|
public static void Place(float padX, float yFromTop, float size)
|
|
{
|
|
if (_host == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_host.anchoredPosition = new Vector2(padX, -yFromTop);
|
|
_host.sizeDelta = new Vector2(size, size);
|
|
if (_instance != null)
|
|
{
|
|
RectTransform rt = _instance.GetComponent<RectTransform>();
|
|
if (rt != null)
|
|
{
|
|
rt.anchorMin = Vector2.zero;
|
|
rt.anchorMax = Vector2.one;
|
|
rt.offsetMin = Vector2.zero;
|
|
rt.offsetMax = Vector2.zero;
|
|
rt.localScale = Vector3.one;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void SetHostVisible(bool visible)
|
|
{
|
|
if (_host != null)
|
|
{
|
|
_host.gameObject.SetActive(visible);
|
|
}
|
|
}
|
|
|
|
public static void SetActive(bool active)
|
|
{
|
|
if (_host != null)
|
|
{
|
|
_host.gameObject.SetActive(active);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keep the live portrait under saga/dossier chrome that must paint on top of it.
|
|
/// </summary>
|
|
public static void DrawBehind(Transform sibling)
|
|
{
|
|
if (_host == null || sibling == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Insert immediately under the sibling so the opaque stone frame cannot cover copy.
|
|
int index = sibling.GetSiblingIndex();
|
|
_host.SetSiblingIndex(index);
|
|
if (_host.GetSiblingIndex() > sibling.GetSiblingIndex())
|
|
{
|
|
_host.SetSiblingIndex(sibling.GetSiblingIndex());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keep the compact stone-style dossier frame - strip king/clan vanity overrides.
|
|
/// </summary>
|
|
public static void ForceCompactFrame()
|
|
{
|
|
if (_host == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_usingVanilla && _instance != null && _instance.gameObject.activeSelf)
|
|
{
|
|
try
|
|
{
|
|
_instance.show_banner_kingdom = false;
|
|
_instance.show_banner_clan = false;
|
|
if (_instance.kingdomBanner != null)
|
|
{
|
|
_instance.kingdomBanner.gameObject.SetActive(false);
|
|
}
|
|
|
|
if (_instance.clanBanner != null)
|
|
{
|
|
_instance.clanBanner.gameObject.SetActive(false);
|
|
}
|
|
|
|
if (_instance.avatarLoader != null)
|
|
{
|
|
_instance.avatarLoader.avatarSize = 0.55f;
|
|
}
|
|
|
|
// Clear king/leader ornate frame overrides when the game exposes them.
|
|
var prop = _instance.GetType().GetProperty("override_avatar_frames");
|
|
if (prop != null && prop.CanWrite)
|
|
{
|
|
prop.SetValue(_instance, false, null);
|
|
}
|
|
|
|
var field = _instance.GetType().GetField("override_avatar_frames");
|
|
if (field != null)
|
|
{
|
|
field.SetValue(_instance, false);
|
|
}
|
|
|
|
StripNamedVanity(_instance.transform);
|
|
}
|
|
catch
|
|
{
|
|
// keep showing whatever frame we have
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void StripNamedVanity(Transform root)
|
|
{
|
|
if (root == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < root.childCount; i++)
|
|
{
|
|
Transform child = root.GetChild(i);
|
|
if (child == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string n = child.name ?? "";
|
|
string lower = n.ToLowerInvariant();
|
|
if (lower.Contains("banner")
|
|
|| lower.Contains("jewel")
|
|
|| lower.Contains("crown")
|
|
|| lower.Contains("king_frame")
|
|
|| lower.Contains("frame_king")
|
|
|| lower.Contains("ornate"))
|
|
{
|
|
child.gameObject.SetActive(false);
|
|
}
|
|
|
|
StripNamedVanity(child);
|
|
}
|
|
}
|
|
|
|
public static void Show(Actor actor)
|
|
{
|
|
if (_host == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
ClearActor();
|
|
return;
|
|
}
|
|
|
|
long id = actor.getID();
|
|
string formKey = ActorFormKey(actor);
|
|
if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf
|
|
&& formKey == _shownFormKey)
|
|
{
|
|
// Same unit + same life stage: only refresh the floor tile underfoot.
|
|
HideFallback();
|
|
try
|
|
{
|
|
_instance.updateTileSprite();
|
|
}
|
|
catch
|
|
{
|
|
// ignore tile refresh failures
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_usingVanilla && _instance != null)
|
|
{
|
|
try
|
|
{
|
|
HideFallback();
|
|
_instance.gameObject.SetActive(true);
|
|
_instance.show_banner_kingdom = false;
|
|
_instance.show_banner_clan = false;
|
|
if (_instance.kingdomBanner != null)
|
|
{
|
|
_instance.kingdomBanner.gameObject.SetActive(false);
|
|
}
|
|
|
|
if (_instance.clanBanner != null)
|
|
{
|
|
_instance.clanBanner.gameObject.SetActive(false);
|
|
}
|
|
|
|
if (_instance.avatarLoader != null)
|
|
{
|
|
// Keep unit readable inside the compact frame.
|
|
_instance.avatarLoader.avatarSize = 0.55f;
|
|
}
|
|
|
|
// Re-load avatar whenever form changes (egg → chick) even if unit id is unchanged.
|
|
_instance.show(actor);
|
|
_shownActorId = id;
|
|
_shownFormKey = formKey;
|
|
DisableInteractions(_instance.gameObject);
|
|
ForceCompactFrame();
|
|
return;
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
LogService.LogInfo("[IdleSpectator] Vanilla avatar show failed, using fallback: " + ex.Message);
|
|
FallbackToSprite(actor);
|
|
_shownActorId = id;
|
|
_shownFormKey = formKey;
|
|
return;
|
|
}
|
|
}
|
|
|
|
FallbackToSprite(actor);
|
|
_shownActorId = id;
|
|
_shownFormKey = formKey;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Visual form key for the live portrait. Same actor id can change (egg → baby → adult).
|
|
/// </summary>
|
|
private static string ActorFormKey(Actor actor)
|
|
{
|
|
if (actor == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string species = "";
|
|
try
|
|
{
|
|
species = actor.asset != null ? (actor.asset.id ?? "") : "";
|
|
}
|
|
catch
|
|
{
|
|
species = "";
|
|
}
|
|
|
|
string stage = "adult";
|
|
try
|
|
{
|
|
if (actor.isEgg())
|
|
{
|
|
stage = "egg";
|
|
}
|
|
else if (actor.isBaby())
|
|
{
|
|
stage = "baby";
|
|
}
|
|
else if (!actor.isAdult())
|
|
{
|
|
stage = "young";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
stage = "unknown";
|
|
}
|
|
|
|
return species + "|" + stage;
|
|
}
|
|
|
|
public static void ClearActor()
|
|
{
|
|
_shownActorId = -1;
|
|
_shownFormKey = "";
|
|
HideVanillaInstance();
|
|
HideFallback();
|
|
}
|
|
|
|
/// <summary>Species icon portrait when the unit is gone (Fallen archive).</summary>
|
|
public static void ShowSpecies(string speciesId)
|
|
{
|
|
if (_host == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Sprite sprite = HudIcons.FromSpeciesId(speciesId);
|
|
HideVanillaInstance();
|
|
|
|
if (_fallbackSprite == null)
|
|
{
|
|
BuildFallback(_host.sizeDelta.x > 1f ? _host.sizeDelta.x : 44f);
|
|
}
|
|
|
|
if (_fallbackBg != null)
|
|
{
|
|
_fallbackBg.gameObject.SetActive(true);
|
|
_fallbackBg.sprite = null;
|
|
_fallbackBg.color = HudTheme.InsetFill;
|
|
_fallbackBg.type = Image.Type.Simple;
|
|
}
|
|
|
|
if (_fallbackSprite != null)
|
|
{
|
|
_fallbackSprite.gameObject.SetActive(true);
|
|
HudIcons.Apply(_fallbackSprite, sprite, preserveAspect: true);
|
|
_fallbackSprite.enabled = sprite != null;
|
|
if (sprite != null)
|
|
{
|
|
float w = Mathf.Max(1f, sprite.rect.width);
|
|
float h = Mathf.Max(1f, sprite.rect.height);
|
|
float max = _host.sizeDelta.x;
|
|
float scale = (max * 0.85f) / Mathf.Max(w, h);
|
|
_fallbackSprite.rectTransform.sizeDelta = new Vector2(w * scale, h * scale);
|
|
}
|
|
}
|
|
|
|
_shownActorId = -2;
|
|
}
|
|
|
|
private static void HideVanillaInstance()
|
|
{
|
|
if (_usingVanilla && _instance != null)
|
|
{
|
|
try
|
|
{
|
|
_instance.gameObject.SetActive(false);
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void HideFallback()
|
|
{
|
|
if (_fallbackSprite != null)
|
|
{
|
|
_fallbackSprite.enabled = false;
|
|
_fallbackSprite.gameObject.SetActive(false);
|
|
}
|
|
|
|
if (_fallbackBg != null)
|
|
{
|
|
_fallbackBg.gameObject.SetActive(false);
|
|
}
|
|
}
|
|
|
|
private static void TrySpawnVanilla(float size)
|
|
{
|
|
UiUnitAvatarElement template = FindTemplate();
|
|
if (template == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
GameObject clone = Object.Instantiate(template.gameObject, _host, false);
|
|
clone.name = "VanillaUnitAvatar";
|
|
clone.SetActive(true);
|
|
|
|
_instance = clone.GetComponent<UiUnitAvatarElement>();
|
|
if (_instance == null)
|
|
{
|
|
Object.Destroy(clone);
|
|
return;
|
|
}
|
|
|
|
_template = template;
|
|
_usingVanilla = true;
|
|
_instance.show_banner_kingdom = false;
|
|
_instance.show_banner_clan = false;
|
|
|
|
RectTransform rt = clone.GetComponent<RectTransform>();
|
|
if (rt != null)
|
|
{
|
|
rt.anchorMin = Vector2.zero;
|
|
rt.anchorMax = Vector2.one;
|
|
rt.offsetMin = Vector2.zero;
|
|
rt.offsetMax = Vector2.zero;
|
|
rt.localScale = Vector3.one;
|
|
rt.sizeDelta = Vector2.zero;
|
|
}
|
|
|
|
DisableInteractions(clone);
|
|
if (!_loggedMode)
|
|
{
|
|
_loggedMode = true;
|
|
LogService.LogInfo("[IdleSpectator] Dossier portrait using vanilla UiUnitAvatarElement frame");
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
_usingVanilla = false;
|
|
_instance = null;
|
|
LogService.LogInfo("[IdleSpectator] Could not clone unit avatar: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
private static UiUnitAvatarElement FindTemplate()
|
|
{
|
|
if (_template != null)
|
|
{
|
|
return _template;
|
|
}
|
|
|
|
UiUnitAvatarElement prefab = null;
|
|
UiUnitAvatarElement scene = null;
|
|
UiUnitAvatarElement[] all = Resources.FindObjectsOfTypeAll<UiUnitAvatarElement>();
|
|
for (int i = 0; i < all.Length; i++)
|
|
{
|
|
UiUnitAvatarElement el = all[i];
|
|
if (el == null || el.gameObject == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Skip our own clone if already present.
|
|
if (el.gameObject.name.StartsWith("VanillaUnitAvatar"))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!el.gameObject.scene.IsValid())
|
|
{
|
|
prefab = el;
|
|
break;
|
|
}
|
|
|
|
if (scene == null)
|
|
{
|
|
scene = el;
|
|
}
|
|
}
|
|
|
|
_template = prefab != null ? prefab : scene;
|
|
return _template;
|
|
}
|
|
|
|
private static void BuildFallback(float size)
|
|
{
|
|
_fallbackBg = new GameObject("FallbackBg", typeof(RectTransform), typeof(Image)).GetComponent<Image>();
|
|
_fallbackBg.transform.SetParent(_host, false);
|
|
RectTransform bgRt = _fallbackBg.GetComponent<RectTransform>();
|
|
bgRt.anchorMin = Vector2.zero;
|
|
bgRt.anchorMax = Vector2.one;
|
|
bgRt.offsetMin = Vector2.zero;
|
|
bgRt.offsetMax = Vector2.zero;
|
|
_fallbackBg.color = HudTheme.InsetFill;
|
|
_fallbackBg.raycastTarget = false;
|
|
|
|
_fallbackSprite = HudCanvas.MakeIcon(_host, "FallbackLive", size);
|
|
RectTransform spRt = _fallbackSprite.GetComponent<RectTransform>();
|
|
spRt.anchorMin = new Vector2(0.5f, 0.5f);
|
|
spRt.anchorMax = new Vector2(0.5f, 0.5f);
|
|
spRt.pivot = new Vector2(0.5f, 0.5f);
|
|
spRt.anchoredPosition = Vector2.zero;
|
|
_fallbackSprite.preserveAspect = true;
|
|
|
|
if (!_loggedMode)
|
|
{
|
|
_loggedMode = true;
|
|
LogService.LogInfo("[IdleSpectator] Dossier portrait using fallback live sprite (no vanilla avatar template)");
|
|
}
|
|
}
|
|
|
|
private static void FallbackToSprite(Actor actor)
|
|
{
|
|
if (_fallbackSprite == null && _host != null)
|
|
{
|
|
BuildFallback(_host.sizeDelta.x);
|
|
}
|
|
|
|
if (_instance != null)
|
|
{
|
|
_instance.gameObject.SetActive(false);
|
|
}
|
|
|
|
if (_fallbackBg != null)
|
|
{
|
|
_fallbackBg.gameObject.SetActive(true);
|
|
// Prefer the unit's current floor tile as portrait ground, like vanilla.
|
|
try
|
|
{
|
|
if (actor != null && actor.current_tile != null && actor.current_tile.Type != null
|
|
&& actor.current_tile.Type.sprites != null && actor.current_tile.Type.sprites.main != null)
|
|
{
|
|
Sprite tile = actor.current_tile.Type.sprites.main.sprite;
|
|
if (tile != null)
|
|
{
|
|
_fallbackBg.sprite = tile;
|
|
_fallbackBg.color = Color.white;
|
|
_fallbackBg.type = Image.Type.Simple;
|
|
_fallbackBg.preserveAspect = false;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// keep flat fill
|
|
}
|
|
}
|
|
|
|
if (_fallbackSprite != null)
|
|
{
|
|
_fallbackSprite.gameObject.SetActive(true);
|
|
Sprite live = HudIcons.FromActorLive(actor);
|
|
HudIcons.Apply(_fallbackSprite, live, preserveAspect: true);
|
|
_fallbackSprite.enabled = live != null;
|
|
if (live != null)
|
|
{
|
|
float w = Mathf.Max(1f, live.rect.width);
|
|
float h = Mathf.Max(1f, live.rect.height);
|
|
float max = _host != null ? _host.sizeDelta.x : 44f;
|
|
float scale = (max * 0.85f) / Mathf.Max(w, h);
|
|
_fallbackSprite.rectTransform.sizeDelta = new Vector2(w * scale, h * scale);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void DisableInteractions(GameObject root)
|
|
{
|
|
if (root == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Graphic[] graphics = root.GetComponentsInChildren<Graphic>(true);
|
|
for (int i = 0; i < graphics.Length; i++)
|
|
{
|
|
if (graphics[i] != null)
|
|
{
|
|
graphics[i].raycastTarget = false;
|
|
}
|
|
}
|
|
|
|
Behaviour[] tips = root.GetComponentsInChildren<TipButton>(true);
|
|
for (int i = 0; i < tips.Length; i++)
|
|
{
|
|
if (tips[i] != null)
|
|
{
|
|
tips[i].enabled = false;
|
|
}
|
|
}
|
|
|
|
Button[] buttons = root.GetComponentsInChildren<Button>(true);
|
|
for (int i = 0; i < buttons.Length; i++)
|
|
{
|
|
if (buttons[i] != null)
|
|
{
|
|
buttons[i].enabled = false;
|
|
buttons[i].interactable = false;
|
|
}
|
|
}
|
|
}
|
|
}
|