using NeoModLoader.services; using UnityEngine; using UnityEngine.UI; namespace IdleSpectator; /// /// Compact dossier portrait that reuses vanilla /// (stone frame + floor tile), falling back to a plain live sprite if needed. /// 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; /// Egg/baby/adult + species - same unit id can change visual form. private static string _shownFormKey = ""; public static bool UsingVanilla => _usingVanilla; /// Live unit portrait is active (not the fallen species fallback). public static bool ShowingLiveAvatar => _shownActorId > 0; /// Fallen archive species-icon portrait is showing. public static bool ShowingSpeciesFallback => _shownActorId == -2; /// Harness/UI diagnostic: portrait host is currently paintable. public static bool HostVisible => _host != null && _host.gameObject.activeSelf; /// Harness/UI diagnostic: final local placement within the dossier card. public static Vector2 HostAnchoredPosition => _host != null ? _host.anchoredPosition : Vector2.zero; /// True if is inside the live portrait rect. 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(); _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(); 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); } } /// /// Keep the live portrait under saga/dossier chrome that must paint on top of it. /// 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()); } } /// /// Keep the compact stone-style dossier frame - strip king/clan vanity overrides. /// 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; } /// /// Visual form key for the live portrait. Same actor id can change (egg → baby → adult). /// 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(); } /// Species icon portrait when the unit is gone (Fallen archive). 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(); 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(); 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(); 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(); _fallbackBg.transform.SetParent(_host, false); RectTransform bgRt = _fallbackBg.GetComponent(); 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(); 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(true); for (int i = 0; i < graphics.Length; i++) { if (graphics[i] != null) { graphics[i].raycastTarget = false; } } Behaviour[] tips = root.GetComponentsInChildren(true); for (int i = 0; i < tips.Length; i++) { if (tips[i] != null) { tips[i].enabled = false; } } Button[] buttons = root.GetComponentsInChildren