worldbox-observer-mod/IdleSpectator/DossierAvatar.cs
DazedAnon 90d5b1604f Enhance dossier interactions and improve Lore click handling.
- Add related unit tracking for activity logs and dossiers
- Implement colorized names for related units in reason lines
- Update harness scenarios to validate new interactions
- Increment version to 0.26.1 in mod.json
2026-07-17 13:36:33 -05:00

518 lines
15 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 SetActive(bool active)
{
if (_host != null)
{
_host.gameObject.SetActive(active);
}
}
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);
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 = new Color(0.04f, 0.045f, 0.055f, 0.95f);
_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 = new Color(0.04f, 0.045f, 0.055f, 0.95f);
_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;
}
}
}
}