Live HUD for character

This commit is contained in:
DazedAnon 2026-07-14 17:12:26 -05:00
parent 3d6d8f27f3
commit 343c4c686e
12 changed files with 1470 additions and 147 deletions

View file

@ -55,6 +55,8 @@ Use harness `fast_timing` + `director_run` / `age_current` for scheduling tests.
- `current_tier` / `would_accept_curiosity` - director scheduling
- `focus_same` - after `remember_focus` (ghost guard)
- `presented` / `pending_discovery` - species discovery buffer
- `caption_layout_ok` - dossier panel size/layout bounds (catches off-panel text)
- `screenshot` harness action writes `.harness/<name>.png` via Unity ScreenCapture
## Extending scenarios

View file

@ -28,6 +28,7 @@ public static class AgentHarness
private static string _lastSpawnedAssetId = "";
private static bool _directorRunActive;
private static string _rememberedFocusKey = "";
private static string LastScreenshotPath = "";
private static readonly string[] PreferredSpawnAssets =
{
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
@ -437,6 +438,22 @@ public static class AgentHarness
break;
}
case "screenshot":
{
bool ok = CaptureHarnessScreenshot(cmd.value);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"path={LastScreenshotPath} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}");
break;
}
case "spawn":
DoSpawn(cmd);
break;
@ -1100,6 +1117,16 @@ public static class AgentHarness
detail = $"latest='{line}' needle='{needle}'";
break;
}
case "caption_layout_ok":
{
pass = WatchCaption.LastLayoutOk
&& WatchCaption.LastPanelSize.y > 20f
&& WatchCaption.LastPanelSize.y < 120f
&& WatchCaption.LastPanelSize.x <= 280f;
detail =
$"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}'";
break;
}
case "chronicle_world_latest_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
@ -1772,6 +1799,39 @@ public static class AgentHarness
return true;
}
private static bool CaptureHarnessScreenshot(string fileName)
{
try
{
string dir = HarnessDir();
if (string.IsNullOrEmpty(dir))
{
return false;
}
Directory.CreateDirectory(dir);
string name = string.IsNullOrEmpty(fileName) ? "hud.png" : fileName;
if (!name.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
{
name += ".png";
}
string path = Path.Combine(dir, name);
// Absolute path required so Unity does not write under the game cwd.
ScreenCapture.CaptureScreenshot(path);
LastScreenshotPath = path;
LogService.LogInfo(
$"[IdleSpectator][HARNESS] screenshot queued path={path} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}");
return true;
}
catch (Exception ex)
{
LastScreenshotPath = "";
LogService.LogInfo("[IdleSpectator][HARNESS] screenshot failed: " + ex.Message);
return false;
}
}
private static string HarnessDir()
{
if (string.IsNullOrEmpty(ModClass.ModFolder))

View file

@ -52,6 +52,18 @@ public static class Chronicle
private static readonly HashSet<long> DeathLogged = new HashSet<long>();
private static bool _hudReady;
private static long _lastHistorySubjectId;
private static object _boundWorld;
public static void OnSpectatorEnabled()
{
object world = World.world;
if (!ReferenceEquals(world, _boundWorld))
{
ClearSession();
_boundWorld = world;
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
}
}
public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + WorldCount;
@ -227,6 +239,7 @@ public static class Chronicle
FriendPairKeys.Clear();
DeathLogged.Clear();
_lastHistorySubjectId = 0;
// Keep _boundWorld so re-enable on same map does not loop-clear.
}
public static void PollInput()

View file

@ -7,18 +7,21 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Compact bottom-right chronicle card with History | World tabs.
/// Compact bottom-right chronicle card with History | World tabs and vanilla icons.
/// </summary>
public static class ChronicleHud
{
private const float PanelWidth = 248f;
private const float PanelHeight = 178f;
private const float PanelWidth = 260f;
private const float PanelHeight = 186f;
private const int MaxVisibleRows = 5;
private const float RowHeight = 20f;
private const float RowHeight = 24f;
private const float KindIconSize = 14f;
private const float TitleIconSize = 16f;
private static GameObject _root;
private static RectTransform _content;
private static Text _titleText;
private static Image _titleIcon;
private static Text _historyTabLabel;
private static Text _worldTabLabel;
private static Image _historyTabBg;
@ -61,20 +64,20 @@ public static class ChronicleHud
rt.anchorMax = new Vector2(1f, 0f);
rt.pivot = new Vector2(1f, 0f);
rt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
rt.anchoredPosition = new Vector2(-12f, 12f);
rt.anchoredPosition = new Vector2(-12f, 56f);
Image bg = _root.GetComponent<Image>();
bg.color = new Color(0.05f, 0.06f, 0.08f, 0.65f);
bg.raycastTarget = true;
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.82f));
CanvasGroup group = _root.GetComponent<CanvasGroup>();
group.blocksRaycasts = true;
group.interactable = true;
BuildTabButton(_root.transform, "HistoryTab", "History", -6f, 0f, out _historyTabBg, out _historyTabLabel,
() => SetTab(ChronicleTab.History));
BuildTabButton(_root.transform, "WorldTab", "World", -6f, 72f, out _worldTabBg, out _worldTabLabel,
() => SetTab(ChronicleTab.World));
BuildTabButton(_root.transform, "HistoryTab", "History", "iconBooks", -6f, 0f,
out _historyTabBg, out _historyTabLabel, () => SetTab(ChronicleTab.History));
BuildTabButton(_root.transform, "WorldTab", "World", "iconWar", -6f, 88f,
out _worldTabBg, out _worldTabLabel, () => SetTab(ChronicleTab.World));
Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 9);
RectTransform f9Rt = f9.GetComponent<RectTransform>();
@ -86,13 +89,20 @@ public static class ChronicleHud
f9.alignment = TextAnchor.MiddleRight;
f9.color = new Color(0.55f, 0.58f, 0.62f, 1f);
_titleIcon = HudCanvas.MakeIcon(_root.transform, "TitleIcon", TitleIconSize);
RectTransform titleIconRt = _titleIcon.GetComponent<RectTransform>();
titleIconRt.anchorMin = new Vector2(0f, 1f);
titleIconRt.anchorMax = new Vector2(0f, 1f);
titleIconRt.pivot = new Vector2(0.5f, 0.5f);
titleIconRt.anchoredPosition = new Vector2(16f, -34f);
_titleText = HudCanvas.MakeText(_root.transform, "Title", "", 9);
RectTransform titleRt = _titleText.GetComponent<RectTransform>();
titleRt.anchorMin = new Vector2(0f, 1f);
titleRt.anchorMax = new Vector2(1f, 1f);
titleRt.pivot = new Vector2(0.5f, 1f);
titleRt.sizeDelta = new Vector2(-14f, 16f);
titleRt.anchoredPosition = new Vector2(0f, -26f);
titleRt.offsetMin = new Vector2(28f, -44f);
titleRt.offsetMax = new Vector2(-10f, -28f);
_titleText.alignment = TextAnchor.MiddleLeft;
_titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f);
@ -102,9 +112,9 @@ public static class ChronicleHud
scrollRt.anchorMin = new Vector2(0f, 0f);
scrollRt.anchorMax = new Vector2(1f, 1f);
scrollRt.offsetMin = new Vector2(6f, 6f);
scrollRt.offsetMax = new Vector2(-6f, -44f);
scrollRt.offsetMax = new Vector2(-6f, -48f);
Image scrollBg = scrollGo.GetComponent<Image>();
scrollBg.color = new Color(0f, 0f, 0f, 0.05f);
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
Mask mask = scrollGo.GetComponent<Mask>();
mask.showMaskGraphic = false;
@ -123,7 +133,7 @@ public static class ChronicleHud
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
layout.spacing = 1f;
layout.spacing = 2f;
layout.padding = new RectOffset(0, 0, 0, 0);
ContentSizeFitter fitter = contentGo.GetComponent<ContentSizeFitter>();
@ -140,7 +150,7 @@ public static class ChronicleHud
RefreshTabChrome();
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)");
LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World + icons)");
}
public static void SetTab(ChronicleTab tab)
@ -213,6 +223,8 @@ public static class ChronicleHud
_titleText.text = "World events";
}
HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconWar") ?? HudIcons.FromUiIcon("iconBooks"));
if (entries.Count == 0)
{
AddEmpty("No world events yet");
@ -231,6 +243,16 @@ public static class ChronicleHud
: title;
}
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (focus != null)
{
HudIcons.Apply(_titleIcon, HudIcons.FromActor(focus));
}
else
{
HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks"));
}
if (subjectId == 0)
{
AddEmpty("Focus a creature to see history");
@ -269,6 +291,7 @@ public static class ChronicleHud
Transform parent,
string name,
string label,
string iconName,
float yFromTop,
float x,
out Image bg,
@ -281,19 +304,27 @@ public static class ChronicleHud
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.sizeDelta = new Vector2(68f, 16f);
rt.sizeDelta = new Vector2(82f, 18f);
rt.anchoredPosition = new Vector2(8f + x, yFromTop);
bg = go.GetComponent<Image>();
bg.color = new Color(0.15f, 0.16f, 0.2f, 0.9f);
Image icon = HudCanvas.MakeIcon(go.transform, "Icon", 12f);
RectTransform iconRt = icon.GetComponent<RectTransform>();
iconRt.anchorMin = new Vector2(0f, 0.5f);
iconRt.anchorMax = new Vector2(0f, 0.5f);
iconRt.pivot = new Vector2(0.5f, 0.5f);
iconRt.anchoredPosition = new Vector2(10f, 0f);
HudIcons.Apply(icon, HudIcons.FromUiIcon(iconName));
text = HudCanvas.MakeText(go.transform, "Label", label, 9);
RectTransform textRt = text.GetComponent<RectTransform>();
textRt.anchorMin = Vector2.zero;
textRt.anchorMax = Vector2.one;
textRt.offsetMin = Vector2.zero;
textRt.offsetMax = Vector2.zero;
text.alignment = TextAnchor.MiddleCenter;
textRt.offsetMin = new Vector2(20f, 0f);
textRt.offsetMax = new Vector2(-2f, 0f);
text.alignment = TextAnchor.MiddleLeft;
text.raycastTarget = false;
Button button = go.GetComponent<Button>();
@ -302,7 +333,7 @@ public static class ChronicleHud
private static void RefreshTabChrome()
{
Color active = new Color(0.28f, 0.32f, 0.4f, 0.95f);
Color active = new Color(0.32f, 0.38f, 0.48f, 0.95f);
Color idle = new Color(0.12f, 0.13f, 0.16f, 0.7f);
Color activeText = new Color(0.95f, 0.93f, 0.88f, 1f);
Color idleText = new Color(0.65f, 0.68f, 0.72f, 1f);
@ -353,7 +384,7 @@ public static class ChronicleHud
le.preferredHeight = RowHeight;
Text label = HudCanvas.MakeText(go.transform, "Text", text, 9);
Stretch(label.GetComponent<RectTransform>(), 2f);
Stretch(label.GetComponent<RectTransform>(), 4f);
label.alignment = TextAnchor.MiddleLeft;
label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f);
return go;
@ -369,10 +400,22 @@ public static class ChronicleHud
le.preferredHeight = RowHeight;
Image bg = go.GetComponent<Image>();
bg.color = new Color(1f, 1f, 1f, 0.02f);
bg.color = new Color(1f, 1f, 1f, 0.03f);
Text label = HudCanvas.MakeText(go.transform, "Text", Truncate(entry.Line, 44), 9);
Stretch(label.GetComponent<RectTransform>(), 3f);
Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize);
RectTransform kindRt = kindIcon.GetComponent<RectTransform>();
kindRt.anchorMin = new Vector2(0f, 0.5f);
kindRt.anchorMax = new Vector2(0f, 0.5f);
kindRt.pivot = new Vector2(0.5f, 0.5f);
kindRt.anchoredPosition = new Vector2(10f, 0f);
HudIcons.Apply(kindIcon, HudIcons.ForChronicleKind(entry.Kind));
Text label = HudCanvas.MakeText(go.transform, "Text", Truncate(entry.Line, 40), 9);
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero;
labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = new Vector2(22f, 1f);
labelRt.offsetMax = new Vector2(-4f, -1f);
label.alignment = TextAnchor.MiddleLeft;
label.color = ColorForKind(entry.Kind);
@ -398,15 +441,15 @@ public static class ChronicleHud
switch (kind)
{
case ChronicleKind.Death:
return new Color(0.75f, 0.7f, 0.75f, 1f);
return new Color(0.78f, 0.72f, 0.78f, 1f);
case ChronicleKind.Kill:
return new Color(0.95f, 0.55f, 0.45f, 1f);
return new Color(0.95f, 0.58f, 0.48f, 1f);
case ChronicleKind.Lover:
return new Color(0.95f, 0.55f, 0.75f, 1f);
return new Color(0.95f, 0.58f, 0.78f, 1f);
case ChronicleKind.Friend:
return new Color(0.55f, 0.85f, 0.95f, 1f);
return new Color(0.58f, 0.85f, 0.95f, 1f);
case ChronicleKind.World:
return new Color(0.95f, 0.85f, 0.45f, 1f);
return new Color(0.95f, 0.85f, 0.48f, 1f);
default:
return new Color(0.88f, 0.88f, 0.9f, 1f);
}

View file

@ -0,0 +1,371 @@
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;
public static bool UsingVanilla => _usingVanilla;
public static void EnsureHost(Transform parent, float size)
{
if (_host != null)
{
return;
}
GameObject hostGo = new GameObject("DossierAvatarHost", typeof(RectTransform));
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();
if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf)
{
try
{
_instance.updateTileSprite();
}
catch
{
// ignore tile refresh failures
}
return;
}
if (_usingVanilla && _instance != null)
{
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)
{
// Keep unit readable inside the compact frame.
_instance.avatarLoader.avatarSize = 0.55f;
}
_instance.show(actor);
_shownActorId = id;
DisableInteractions(_instance.gameObject);
return;
}
catch (System.Exception ex)
{
LogService.LogInfo("[IdleSpectator] Vanilla avatar show failed, using fallback: " + ex.Message);
FallbackToSprite(actor);
return;
}
}
FallbackToSprite(actor);
_shownActorId = id;
}
public static void ClearActor()
{
_shownActorId = -1;
if (_usingVanilla && _instance != null)
{
try
{
_instance.gameObject.SetActive(false);
}
catch
{
// ignore
}
}
if (_fallbackSprite != null)
{
_fallbackSprite.enabled = 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)
{
Sprite live = HudIcons.FromActorLive(actor);
HudIcons.Apply(_fallbackSprite, live, preserveAspect: true);
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;
}
}
}
}

View file

@ -406,6 +406,8 @@ internal static class HarnessScenarios
Step("ch10", "assert", expect: "health"),
Step("ch11", "assert", expect: "dossier_contains", value: "auto"),
Step("ch12", "assert", expect: "caption_contains", value: "auto"),
Step("ch12b", "assert", expect: "caption_layout_ok"),
Step("ch12c", "assert", expect: "caption_contains", value: "age"),
// History inject
Step("ch13", "chronicle_force", label: "Killed"),
@ -431,12 +433,15 @@ internal static class HarnessScenarios
// Tabs
Step("ch18", "chronicle_open"),
Step("ch18a", "screenshot", value: "hud-chronicle_smoke.png"),
Step("ch18a2", "wait", wait: 0.6f),
Step("ch18b", "chronicle_tab", value: "history"),
Step("ch18c", "assert", expect: "chronicle_tab", value: "history"),
Step("ch18d", "chronicle_tab", value: "world"),
Step("ch18e", "assert", expect: "chronicle_tab", value: "world"),
Step("ch18f", "chronicle_tab", value: "history"),
Step("ch18g", "wait", wait: 0.25f),
Step("ch18c", "assert", expect: "caption_layout_ok"),
Step("ch18d", "assert", expect: "chronicle_tab", value: "history"),
Step("ch18e", "chronicle_tab", value: "world"),
Step("ch18f", "assert", expect: "chronicle_tab", value: "world"),
Step("ch18g", "chronicle_tab", value: "history"),
Step("ch18h", "wait", wait: 0.25f),
Step("ch19", "assert", expect: "focus", value: "true"),
Step("ch20", "assert", expect: "health"),
Step("ch21", "chronicle_jump"),

View file

@ -5,7 +5,7 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Shared canvas / text helpers for compact non-modal HUD cards.
/// Shared canvas / text / panel helpers for compact non-modal HUD cards.
/// </summary>
public static class HudCanvas
{
@ -51,4 +51,43 @@ public static class HudCanvas
text.verticalOverflow = VerticalWrapMode.Truncate;
return text;
}
public static Image MakeIcon(Transform parent, string name, float size)
{
Image image = new GameObject(name, typeof(RectTransform), typeof(Image)).GetComponent<Image>();
image.transform.SetParent(parent, false);
RectTransform rt = image.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(size, size);
image.raycastTarget = false;
image.preserveAspect = true;
image.color = Color.white;
image.enabled = false;
return image;
}
/// <summary>Dark fill + lighter 1px edge so panels read as WorldBox chrome.</summary>
public static void StylePanel(Image fill, Transform root, Color fillColor)
{
if (fill != null)
{
fill.color = fillColor;
}
if (root == null)
{
return;
}
GameObject edge = new GameObject("Edge", typeof(RectTransform), typeof(Image));
edge.transform.SetParent(root, false);
edge.transform.SetAsFirstSibling();
RectTransform edgeRt = edge.GetComponent<RectTransform>();
edgeRt.anchorMin = Vector2.zero;
edgeRt.anchorMax = Vector2.one;
edgeRt.offsetMin = new Vector2(-1f, -1f);
edgeRt.offsetMax = new Vector2(1f, 1f);
Image edgeImg = edge.GetComponent<Image>();
edgeImg.color = new Color(0.42f, 0.45f, 0.5f, 0.55f);
edgeImg.raycastTarget = false;
}
}

198
IdleSpectator/HudIcons.cs Normal file
View file

@ -0,0 +1,198 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Loads WorldBox ui/Icons sprites for HUD chrome.
/// </summary>
public static class HudIcons
{
public static Sprite FromUiIcon(string iconName)
{
if (string.IsNullOrEmpty(iconName))
{
return null;
}
try
{
Sprite sprite = SpriteTextureLoader.getSprite("ui/Icons/" + iconName);
if (sprite != null)
{
return sprite;
}
// Some paths are lower-case in older content.
return SpriteTextureLoader.getSprite("ui/icons/" + iconName);
}
catch
{
return null;
}
}
public static Sprite FromAsset(ActorAsset asset)
{
if (asset == null)
{
return FromUiIcon("iconQuestionMark");
}
try
{
Sprite sprite = asset.getSpriteIcon();
if (sprite != null)
{
return sprite;
}
}
catch
{
// fall through
}
try
{
if (!string.IsNullOrEmpty(asset.icon))
{
return FromUiIcon(asset.icon);
}
}
catch
{
// ignore
}
return FromUiIcon("iconQuestionMark");
}
public static Sprite FromActor(Actor actor)
{
if (actor == null)
{
return FromUiIcon("iconQuestionMark");
}
return FromAsset(actor.asset);
}
/// <summary>
/// Live unit sprite as shown in inspect UI (phenotype/kingdom tint), not the species icon.
/// </summary>
public static Sprite FromActorLive(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return null;
}
try
{
Sprite main = actor.calculateMainSprite();
if (main == null)
{
return FromActor(actor);
}
Sprite ui = DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main);
if (ui != null)
{
return ui;
}
if (actor.hasColoredSprite())
{
Sprite colored = actor.calculateColoredSprite(main);
if (colored != null)
{
return colored;
}
}
return main;
}
catch
{
return FromActor(actor);
}
}
public static Sprite ForChronicleKind(ChronicleKind kind)
{
switch (kind)
{
case ChronicleKind.Death:
return FromUiIcon("iconDead") ?? FromUiIcon("iconSkulls");
case ChronicleKind.Kill:
return FromUiIcon("iconKills") ?? FromUiIcon("iconAttack");
case ChronicleKind.Lover:
return FromUiIcon("iconArrowLover");
case ChronicleKind.Friend:
return FromUiIcon("iconFavoriteStar");
case ChronicleKind.World:
return FromUiIcon("iconWar") ?? FromUiIcon("iconBooks");
default:
return FromUiIcon("iconClock");
}
}
public static Sprite Age() => FromUiIcon("iconAge");
public static Sprite Level() => FromUiIcon("iconLevels");
public static Sprite Kills() => FromUiIcon("iconKills") ?? FromUiIcon("iconAttack");
public static Sprite Task() => FromUiIcon("iconShowTasks") ?? FromUiIcon("iconWalker");
public static Sprite SexMale() => FromUiIcon("IconMale");
public static Sprite SexFemale() => FromUiIcon("IconFemale");
public static Sprite FromTrait(ActorTrait trait)
{
if (trait == null)
{
return null;
}
try
{
Sprite sprite = trait.getSprite();
if (sprite != null)
{
return sprite;
}
}
catch
{
// fall through
}
try
{
if (!string.IsNullOrEmpty(trait.path_icon))
{
return SpriteTextureLoader.getSprite(trait.path_icon);
}
}
catch
{
// ignore
}
return null;
}
public static void Apply(UnityEngine.UI.Image image, Sprite sprite, bool preserveAspect = true)
{
if (image == null)
{
return;
}
image.sprite = sprite;
image.enabled = sprite != null;
image.preserveAspect = preserveAspect;
image.color = Color.white;
}
}

View file

@ -38,6 +38,7 @@ public static class SpectatorMode
{
InterestDirector.OnSpectatorEnabled();
SpeciesDiscovery.OnSpectatorEnabled();
Chronicle.OnSpectatorEnabled();
WorldTip.showNowTop("Idle Spectator ON (I or any input to stop; F9 chronicle)", pTranslate: false);
LogService.LogInfo("[IdleSpectator] Spectator mode enabled");
}

View file

@ -18,6 +18,7 @@ public sealed class UnitDossier
public float Score;
public int Kills;
public int Age;
public int Level;
public int Renown;
public bool IsFavorite;
public bool IsKing;
@ -25,9 +26,20 @@ public sealed class UnitDossier
public bool IsFighting;
public bool IsSpectacle;
public bool IsLoneSpecies;
public bool IsMale;
public bool IsFemale;
public string TaskText = "";
public string KingdomName = "";
public string CityName = "";
public readonly List<string> ScoreReasons = new List<string>();
public readonly List<TraitChip> TopTraits = new List<TraitChip>();
public sealed class TraitChip
{
public string Id = "";
public string Name = "";
public ActorTrait Trait;
}
public static UnitDossier FromActor(Actor actor, InterestTier? watchTier = null, string watchLabel = null)
{
@ -47,6 +59,7 @@ public sealed class UnitDossier
d.SpeciesId = actor.asset != null ? actor.asset.id : "creature";
d.Kills = actor.data != null ? actor.data.kills : 0;
d.Age = SafeAge(actor);
d.Level = SafeLevel(actor);
d.Renown = actor.renown;
d.IsFavorite = actor.isFavorite();
d.IsKing = actor.isKing();
@ -57,9 +70,15 @@ public sealed class UnitDossier
d.KingdomName = actor.kingdom != null ? actor.kingdom.name : "";
d.CityName = actor.city != null ? actor.city.name : "";
d.Score = WorldActivityScanner.ScoreActorPublic(actor, speciesCounts);
FillSex(d, actor);
d.TaskText = SafeTask(actor);
FillTopTraits(d, actor);
CollectReasons(d, actor, speciesCounts);
d.Headline = $"{d.Name} ({d.SpeciesId})";
// Species icon is shown separately; keep id in the title for scanability.
d.Headline = string.IsNullOrEmpty(d.SpeciesId)
? d.Name
: $"{d.Name} ({d.SpeciesId})";
d.ReasonLine = BuildReasonLine(d, watchTier, watchLabel, actor, speciesCounts);
d.DetailLine = BuildDetailLine(d);
d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine);
@ -73,12 +92,41 @@ public sealed class UnitDossier
return false;
}
return (CaptionText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Headline ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (ReasonLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (DetailLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Name ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (SpeciesId ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
if ((CaptionText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Headline ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (ReasonLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (DetailLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (Name ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (SpeciesId ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (TaskText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
// Harness probes "age" after we moved age onto an icon chip.
if (needle.Equals("age", System.StringComparison.OrdinalIgnoreCase) && Age > 0)
{
return true;
}
for (int i = 0; i < TopTraits.Count; i++)
{
TraitChip chip = TopTraits[i];
if (chip == null)
{
continue;
}
if ((!string.IsNullOrEmpty(chip.Name)
&& chip.Name.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)
|| (!string.IsNullOrEmpty(chip.Id)
&& chip.Id.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0))
{
return true;
}
}
return false;
}
private static string JoinCaption(string headline, string reason, string detail)
@ -132,7 +180,7 @@ public sealed class UnitDossier
}
}
if (IsRedundantWithHeadline(why, d))
if (IsRedundantWithHeadline(why, d) || IsShownTraitName(d, why))
{
why = "";
}
@ -144,7 +192,7 @@ public sealed class UnitDossier
if (string.IsNullOrEmpty(why) || why.IndexOf(tierPart, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return tierPart;
return IsShownTraitName(d, tierPart) ? "" : tierPart;
}
return tierPart + " · " + why;
@ -165,12 +213,45 @@ public sealed class UnitDossier
continue;
}
// Traits already render as icon chips; don't repeat them as the reason line.
if (IsShownTraitName(d, reason))
{
continue;
}
return reason;
}
return "";
}
private static bool IsShownTraitName(UnitDossier d, string text)
{
if (d == null || string.IsNullOrEmpty(text))
{
return false;
}
for (int i = 0; i < d.TopTraits.Count; i++)
{
TraitChip chip = d.TopTraits[i];
if (chip == null)
{
continue;
}
if ((!string.IsNullOrEmpty(chip.Name)
&& string.Equals(text, chip.Name, System.StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrEmpty(chip.Id)
&& string.Equals(text, Humanize(chip.Id), System.StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
private static bool IsRedundantWithHeadline(string text, UnitDossier d)
{
if (string.IsNullOrEmpty(text) || d == null)
@ -295,49 +376,16 @@ public sealed class UnitDossier
d.ScoreReasons.Add("renown " + d.Renown);
}
try
{
if (actor.hasTraits())
{
int n = 0;
foreach (ActorTrait trait in actor.getTraits())
{
if (trait == null || string.IsNullOrEmpty(trait.id))
{
continue;
}
d.ScoreReasons.Add(Humanize(trait.id));
n++;
if (n >= 2)
{
break;
}
}
}
}
catch
{
// ignore trait access failures on exotic units
}
// Trait names are shown as dossier chips; keep ScoreReasons for non-trait flavor only.
}
private static string BuildDetailLine(UnitDossier d)
{
// Compact text fallback for logs/harness: task + optional kingdom.
StringBuilder sb = new StringBuilder();
if (d.Age > 0)
if (!string.IsNullOrEmpty(d.TaskText))
{
sb.Append("age ").Append(d.Age);
}
if (d.Kills > 0)
{
if (sb.Length > 0)
{
sb.Append(" · ");
}
sb.Append(d.Kills).Append(" kills");
sb.Append(d.TaskText);
}
if (!string.IsNullOrEmpty(d.KingdomName)
@ -363,39 +411,113 @@ public sealed class UnitDossier
sb.Append(d.CityName);
}
int tags = 0;
for (int i = 0; i < d.ScoreReasons.Count && tags < 2; i++)
return sb.ToString();
}
private static void FillSex(UnitDossier d, Actor actor)
{
try
{
string reason = d.ScoreReasons[i];
if (string.IsNullOrEmpty(reason))
d.IsMale = actor.isSexMale();
d.IsFemale = actor.isSexFemale();
}
catch
{
d.IsMale = false;
d.IsFemale = false;
}
}
private static void FillTopTraits(UnitDossier d, Actor actor)
{
d.TopTraits.Clear();
try
{
if (!actor.hasTraits())
{
continue;
return;
}
if (reason.IndexOf("kills", System.StringComparison.OrdinalIgnoreCase) >= 0)
foreach (ActorTrait trait in actor.getTraits())
{
continue;
}
if (trait == null || string.IsNullOrEmpty(trait.id))
{
continue;
}
string shown = Humanize(reason);
if (IsRedundantWithHeadline(shown, d)
|| sb.ToString().IndexOf(shown, System.StringComparison.OrdinalIgnoreCase) >= 0
|| (!string.IsNullOrEmpty(d.ReasonLine)
&& d.ReasonLine.IndexOf(shown, System.StringComparison.OrdinalIgnoreCase) >= 0))
d.TopTraits.Add(new TraitChip
{
Id = trait.id,
Name = TraitDisplayName(trait),
Trait = trait
});
if (d.TopTraits.Count >= 3)
{
break;
}
}
}
catch
{
// ignore trait access failures on exotic units
}
}
private static string TraitDisplayName(ActorTrait trait)
{
try
{
string locale = trait.getLocaleID();
if (!string.IsNullOrEmpty(locale))
{
continue;
string localized = LocalizedTextManager.getText(locale);
if (!string.IsNullOrEmpty(localized) && localized != locale)
{
return localized;
}
}
if (sb.Length > 0)
{
sb.Append(" · ");
}
sb.Append(shown);
tags++;
}
catch
{
// fall through
}
return sb.ToString();
return Humanize(trait.id);
}
private static string SafeTask(Actor actor)
{
try
{
if (!actor.hasTask() || actor.ai == null || actor.ai.task == null)
{
return "";
}
string text = actor.ai.task.getLocalizedText();
if (!string.IsNullOrEmpty(text) && text != "-")
{
return text;
}
return Humanize(actor.ai.task.id);
}
catch
{
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
return Humanize(actor.ai.task.id);
}
}
catch
{
// ignore
}
return "";
}
}
private static string Humanize(string raw)
@ -437,4 +559,16 @@ public sealed class UnitDossier
return 0;
}
}
private static int SafeLevel(Actor actor)
{
try
{
return actor.data != null ? actor.data.level : 0;
}
catch
{
return 0;
}
}
}

View file

@ -1,4 +1,3 @@
using NeoModLoader.General;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.UI;
@ -6,21 +5,58 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Persistent compact dossier card (top-left) while Idle Spectator is active.
/// Replaces WorldTip for dossier text so AFK watching does not spam giant orange toasts.
/// Persistent compact dossier (Option A / vanilla strip):
/// header species+name+sex, live portrait + level/task, full-width trait strip, reason.
/// </summary>
public static class WatchCaption
{
private const float PanelWidth = 260f;
private const float PanelHeight = 72f;
private const float PanelWidth = 220f;
private const float SpeciesSize = 16f;
private const float SexSize = 14f;
private const float LiveMax = 44f;
private const float ChipIcon = 14f;
private const float TraitIcon = 12f;
private const float PadX = 4f;
private const float PadTop = 4f;
private const float HeaderH = 18f;
private const float BodyH = 44f;
private const float TraitsH = 16f;
private const float ReasonH = 12f;
private const float Gap = 2f;
private static readonly Color NameColor = new Color(0.95f, 0.93f, 0.88f, 1f);
private static readonly Color StatValueColor = new Color(1f, 0.62f, 0.28f, 1f);
private static readonly Color TaskColor = new Color(0.26f, 1f, 0.26f, 1f);
private static readonly Color ReasonColor = new Color(0.95f, 0.72f, 0.38f, 1f);
private static readonly Color TraitNameColor = new Color(0.78f, 0.8f, 0.84f, 1f);
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 _metaText;
private static Image _levelIcon;
private static Text _levelValue;
private static Image _taskIcon;
private static GameObject _metaCol;
private static readonly TraitSlot[] _traitSlots = new TraitSlot[3];
private static GameObject _traitsRow;
private static UnitDossier _current;
private static Actor _boundActor;
private static bool _visible;
private sealed class TraitSlot
{
public GameObject Root;
public Image Icon;
public Text Label;
}
public static UnitDossier Current => _current;
public static string LastHeadline { get; private set; } = "";
@ -29,12 +65,20 @@ public static class WatchCaption
public static string LastCaptionText { get; private set; } = "";
public static Vector2 LastPanelSize { get; private set; }
public static bool LastLayoutOk { get; private set; }
public static void Clear()
{
_current = null;
_boundActor = null;
LastHeadline = "";
LastDetail = "";
LastCaptionText = "";
LastPanelSize = Vector2.zero;
LastLayoutOk = false;
ApplyVisual(null, null);
SetVisible(false);
}
@ -52,8 +96,15 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = LastHeadline;
_current = null;
_boundActor = null;
EnsureBuilt();
ApplyLines(LastHeadline, "", "");
ApplyVisual(null, null);
if (_nameText != null)
{
_nameText.text = LastHeadline;
}
Relayout(false, 0, false, false);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
}
@ -67,11 +118,12 @@ public static class WatchCaption
{
UnitDossier dossier = UnitDossier.FromActor(actor, tier, label);
_current = dossier;
_boundActor = actor;
LastHeadline = dossier.Headline ?? "";
LastDetail = dossier.DetailLine ?? "";
LastCaptionText = dossier.CaptionText ?? "";
EnsureBuilt();
ApplyLines(dossier.Headline, dossier.ReasonLine, dossier.DetailLine);
ApplyVisual(actor, dossier);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | "));
}
@ -91,35 +143,397 @@ public static class WatchCaption
if (_root == null && !string.IsNullOrEmpty(LastCaptionText))
{
EnsureBuilt();
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (_current != null)
{
ApplyLines(_current.Headline, _current.ReasonLine, _current.DetailLine);
ApplyVisual(focus ?? _boundActor, _current);
}
else
else if (_nameText != null)
{
ApplyLines(LastHeadline, "", LastDetail);
_nameText.text = LastHeadline;
Relayout(false, 0, false, false);
}
SetVisible(true);
}
RefreshLivePortrait();
}
private static void ApplyLines(string name, string reason, string meta)
private static void RefreshLivePortrait()
{
if (_nameText != null)
if (!_visible)
{
_nameText.text = string.IsNullOrEmpty(name) ? "" : name;
return;
}
if (_reasonText != null)
Actor actor = _boundActor;
if (actor == null || !actor.isAlive())
{
_reasonText.text = string.IsNullOrEmpty(reason) ? "" : reason;
if (MoveCamera.hasFocusUnit())
{
actor = MoveCamera._focus_unit;
}
}
if (actor == null || !actor.isAlive())
{
return;
}
if (_current != null && actor.getID() != _current.UnitId)
{
return;
}
// Tile floor can change as the unit moves; keep portrait in sync.
DossierAvatar.Show(actor);
}
private static void ApplyVisual(Actor actor, UnitDossier dossier)
{
EnsureBuilt();
if (_root == null)
{
return;
}
_boundActor = actor;
if (_speciesIcon != null)
{
HudIcons.Apply(_speciesIcon, actor != null ? HudIcons.FromActor(actor) : null);
}
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 && actor != null;
DossierAvatar.SetActive(hasBody);
if (_metaCol != null)
{
_metaCol.SetActive(hasBody);
}
if (hasBody)
{
DossierAvatar.Show(actor);
HudIcons.Apply(_levelIcon, HudIcons.Level());
if (_levelValue != null)
{
_levelValue.text = dossier.Level.ToString();
}
}
else
{
DossierAvatar.ClearActor();
}
bool hasTask = dossier != null && !string.IsNullOrEmpty(dossier.TaskText);
if (_taskIcon != null)
{
HudIcons.Apply(_taskIcon, hasTask ? HudIcons.Task() : null);
_taskIcon.gameObject.SetActive(hasTask);
}
if (_metaText != null)
{
_metaText.text = string.IsNullOrEmpty(meta) ? "" : meta;
_metaText.text = hasTask ? dossier.TaskText : "";
_metaText.gameObject.SetActive(hasTask);
}
int traitCount = 0;
if (_traitsRow != null)
{
if (dossier != null && dossier.TopTraits.Count > 0)
{
_traitsRow.SetActive(true);
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);
HudIcons.Apply(slot.Icon, HudIcons.FromTrait(chip.Trait));
if (slot.Label != null)
{
string label = chip.Name ?? "";
if (label.Length > 11)
{
label = label.Substring(0, 10) + "...";
}
slot.Label.text = label;
}
traitCount++;
}
else
{
slot.Root.SetActive(false);
}
}
}
else
{
_traitsRow.SetActive(false);
}
}
bool hasReason = dossier != null && !string.IsNullOrEmpty(dossier.ReasonLine);
if (_reasonText != null)
{
_reasonText.text = hasReason ? dossier.ReasonLine : "";
_reasonText.gameObject.SetActive(hasReason);
}
Relayout(hasBody, traitCount, hasTask, hasReason);
}
private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason)
{
float y = PadTop;
PlaceHeader(y);
y += HeaderH + Gap;
if (hasBody)
{
PlaceBody(y);
y += BodyH + Gap;
}
if (traitCount > 0)
{
PlaceTraits(y, traitCount);
y += TraitsH + Gap;
}
if (hasReason)
{
PlaceLine(_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null, y, ReasonH, PadX);
y += ReasonH + Gap;
}
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
if (_rootRt != null)
{
_rootRt.sizeDelta = new Vector2(PanelWidth, height);
LastPanelSize = _rootRt.sizeDelta;
}
LastLayoutOk = ProbeLayoutInside(height);
if (!LastLayoutOk)
{
LogService.LogInfo(
$"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} task={hasTask} reason={hasReason}");
}
}
private static void PlaceHeader(float yFromTop)
{
if (_speciesIcon != null)
{
RectTransform rt = _speciesIcon.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.anchoredPosition = new Vector2(PadX, -yFromTop);
rt.sizeDelta = new Vector2(SpeciesSize, SpeciesSize);
}
if (_sexIcon != null)
{
RectTransform rt = _sexIcon.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(1f, 1f);
rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(1f, 1f);
rt.anchoredPosition = new Vector2(-PadX, -yFromTop - 1f);
rt.sizeDelta = new Vector2(SexSize, SexSize);
}
if (_nameText != null)
{
RectTransform rt = _nameText.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
float right = (_sexIcon != null && _sexIcon.gameObject.activeSelf) ? (PadX + SexSize + 2f) : PadX;
rt.offsetMin = new Vector2(PadX + SpeciesSize + 4f, -(yFromTop + HeaderH));
rt.offsetMax = new Vector2(-right, -yFromTop);
}
}
private static void PlaceBody(float yFromTop)
{
DossierAvatar.Place(PadX, yFromTop, LiveMax);
if (_metaCol != null)
{
RectTransform col = _metaCol.GetComponent<RectTransform>();
col.anchorMin = new Vector2(0f, 1f);
col.anchorMax = new Vector2(1f, 1f);
col.pivot = new Vector2(0.5f, 1f);
col.offsetMin = new Vector2(PadX + LiveMax + 4f, -(yFromTop + BodyH));
col.offsetMax = new Vector2(-PadX, -yFromTop);
}
// Level row at top of meta col, task under it.
if (_levelIcon != null)
{
RectTransform irt = _levelIcon.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, -2f);
irt.sizeDelta = new Vector2(ChipIcon, ChipIcon);
}
if (_levelValue != null)
{
RectTransform vrt = _levelValue.GetComponent<RectTransform>();
vrt.anchorMin = new Vector2(0f, 1f);
vrt.anchorMax = new Vector2(1f, 1f);
vrt.pivot = new Vector2(0.5f, 1f);
vrt.offsetMin = new Vector2(ChipIcon + 3f, -18f);
vrt.offsetMax = new Vector2(0f, -2f);
}
if (_taskIcon != null)
{
RectTransform irt = _taskIcon.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, -22f);
irt.sizeDelta = new Vector2(ChipIcon, ChipIcon);
}
if (_metaText != null)
{
RectTransform trt = _metaText.GetComponent<RectTransform>();
trt.anchorMin = new Vector2(0f, 1f);
trt.anchorMax = new Vector2(1f, 1f);
trt.pivot = new Vector2(0.5f, 1f);
trt.offsetMin = new Vector2(ChipIcon + 3f, -38f);
trt.offsetMax = new Vector2(0f, -22f);
}
}
private static void PlaceTraits(float yFromTop, int traitCount)
{
if (_traitsRow == null)
{
return;
}
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 innerW = PanelWidth - PadX * 2f;
float slotW = innerW / Mathf.Max(1, traitCount);
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
{
continue;
}
RectTransform rt = slot.Root.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 0f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 0.5f);
rt.anchoredPosition = new Vector2(i * slotW, 0f);
rt.sizeDelta = new Vector2(slotW - 2f, TraitsH);
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))
&& (_traitsRow == null || !_traitsRow.activeSelf
|| LineInside(_traitsRow.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;
}
return top >= 0f && bottom <= panelHeight + 0.5f && top < bottom;
}
private static void SetVisible(bool visible)
@ -150,48 +564,91 @@ public static class WatchCaption
_root = new GameObject("IdleSpectatorDossierHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
_root.transform.SetParent(canvas.transform, false);
RectTransform rt = _root.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
rt.anchoredPosition = new Vector2(12f, -12f);
_rootRt = _root.GetComponent<RectTransform>();
_rootRt.anchorMin = new Vector2(0f, 1f);
_rootRt.anchorMax = new Vector2(0f, 1f);
_rootRt.pivot = new Vector2(0f, 1f);
_rootRt.sizeDelta = new Vector2(PanelWidth, HeaderH + PadTop * 2f);
_rootRt.anchoredPosition = new Vector2(12f, -12f);
Image bg = _root.GetComponent<Image>();
bg.color = new Color(0.05f, 0.06f, 0.08f, 0.72f);
bg.raycastTarget = false;
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.9f));
CanvasGroup group = _root.GetComponent<CanvasGroup>();
group.blocksRaycasts = false;
group.interactable = false;
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 12);
PlaceLine(_nameText.GetComponent<RectTransform>(), -6f, 22f);
_nameText.fontStyle = FontStyle.Bold;
_nameText.color = new Color(0.95f, 0.93f, 0.88f, 1f);
_nameText.alignment = TextAnchor.MiddleLeft;
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
_reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 10);
PlaceLine(_reasonText.GetComponent<RectTransform>(), -28f, 18f);
_reasonText.color = new Color(0.95f, 0.72f, 0.38f, 1f);
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11);
_nameText.fontStyle = FontStyle.Bold;
_nameText.color = NameColor;
_nameText.alignment = TextAnchor.MiddleLeft;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
DossierAvatar.EnsureHost(_root.transform, LiveMax);
_metaCol = new GameObject("MetaCol", typeof(RectTransform));
_metaCol.transform.SetParent(_root.transform, false);
_levelIcon = HudCanvas.MakeIcon(_metaCol.transform, "LevelIcon", ChipIcon);
_levelValue = HudCanvas.MakeText(_metaCol.transform, "LevelValue", "", 10);
_levelValue.color = StatValueColor;
_levelValue.fontStyle = FontStyle.Bold;
_levelValue.alignment = TextAnchor.MiddleLeft;
_levelValue.horizontalOverflow = HorizontalWrapMode.Overflow;
_levelValue.resizeTextMinSize = 8;
_levelValue.resizeTextMaxSize = 10;
_taskIcon = HudCanvas.MakeIcon(_metaCol.transform, "TaskIcon", ChipIcon);
_metaText = HudCanvas.MakeText(_metaCol.transform, "Task", "", 9);
_metaText.color = TaskColor;
_metaText.alignment = TextAnchor.MiddleLeft;
_metaText.horizontalOverflow = HorizontalWrapMode.Overflow;
_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));
slotGo.transform.SetParent(_traitsRow.transform, false);
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
label.color = TraitNameColor;
label.alignment = TextAnchor.MiddleLeft;
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.resizeTextMinSize = 6;
label.resizeTextMaxSize = 8;
_traitSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label };
slotGo.SetActive(false);
}
_reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 8);
_reasonText.color = ReasonColor;
_reasonText.alignment = TextAnchor.MiddleLeft;
_metaText = HudCanvas.MakeText(_root.transform, "Meta", "", 9);
PlaceLine(_metaText.GetComponent<RectTransform>(), -46f, 18f);
_metaText.color = new Color(0.72f, 0.75f, 0.78f, 1f);
_metaText.alignment = TextAnchor.MiddleLeft;
DossierAvatar.SetActive(false);
_metaCol.SetActive(false);
_traitsRow.SetActive(false);
_reasonText.gameObject.SetActive(false);
Relayout(false, 0, false, false);
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (top-left card)");
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (vanilla strip + avatar frame)");
}
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height)
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.sizeDelta = new Vector2(-16f, height);
lineRt.anchoredPosition = new Vector2(0f, yFromTop);
lineRt.offsetMin = new Vector2(left, -(yFromTop + height));
lineRt.offsetMax = new Vector2(-PadX, -yFromTop);
}
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.9.1",
"description": "AFK Idle Spectator (I) + Chronicle History|World (F9). Character death causes, life events.",
"version": "0.9.10",
"description": "AFK Idle Spectator (I) + Chronicle (F9). Vanilla-strip dossier with unit avatar frame.",
"GUID": "com.dazed.idlespectator"
}