worldbox-observer-mod/IdleSpectator/DossierAvatar.cs
DazedAnon 96db725383 feat(caption): enhance saga hover functionality and visual stability
- Introduce new anchors for caption avatar and narrative positioning
- Implement stable typography capture during saga hover sessions
- Add diagnostics for avatar visibility and anchored position
- Refactor saga hover refresh logic to maintain visual consistency
- Update harness scenarios to validate hover visual stability
2026-07-24 14:29:10 -05:00

631 lines
19 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>Harness/UI diagnostic: portrait host is currently paintable.</summary>
public static bool HostVisible =>
_host != null && _host.gameObject.activeSelf;
/// <summary>Harness/UI diagnostic: final local placement within the dossier card.</summary>
public static Vector2 HostAnchoredPosition =>
_host != null ? _host.anchoredPosition : Vector2.zero;
/// <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)
{
// The portrait and life stage are unchanged. updateTileSprite() rebuilds vanilla
// avatar chrome/layout and produced 45-77ms caption stalls on developed worlds.
// The terrain tile is decorative; refresh it on focus/form changes via show().
HideFallback();
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;
}
}
}
}