Perfecting the dossier hud
This commit is contained in:
parent
343c4c686e
commit
9ae7f7ffa1
13 changed files with 971 additions and 168 deletions
|
|
@ -42,6 +42,31 @@ After any feature or bug fix in `IdleSpectator/`:
|
|||
6. Iterate until **all repeats PASS**. Do not stop after one flaky pass.
|
||||
7. Report: scenario name(s), PASS/FAIL counts, and any new asserts.
|
||||
|
||||
### HUD / visual changes - screenshot gate (required)
|
||||
|
||||
For dossier, Chronicle HUD, captions, icons, layout, or any on-screen chrome:
|
||||
|
||||
1. Run a scenario that takes a screenshot (e.g. `chronicle_smoke`).
|
||||
The harness writes:
|
||||
- `.harness/hud-….png` (full frame)
|
||||
- `.harness/hud-…-dossier.png` (**exact dossier UI rect** via `ReadPixels` - use this)
|
||||
2. Wait ~1s after the run so the end-of-frame crop finishes, then:
|
||||
```bash
|
||||
./scripts/crop-dossier-hud.py --strict IdleSpectator/.harness/hud-*-dossier.png
|
||||
```
|
||||
3. **Open the `*-dossier.png` with the Read tool.** Never judge layout from the raw 2880p frame.
|
||||
4. If the player pastes an in-game shot, treat that as ground truth.
|
||||
5. Checklist - fail the change if any are wrong:
|
||||
- Nametag row visible: species icon, `Name (species)`, level icon **and number**, task, sex
|
||||
- No large empty void beside the avatar when History is empty (traits or history should fill it)
|
||||
- History lines appear beside the avatar only when the subject has History
|
||||
- Traits packed (no huge empty gaps); prefer full labels when space allows
|
||||
- No clipped/overlapping chips; no header buried under the portrait frame
|
||||
6. Fix and re-screenshot until the dossier crop matches the intended layout.
|
||||
7. Report the `*-dossier.png` path(s) and what you verified.
|
||||
|
||||
Harness asserts catch logic; Unity dossier crops catch layout. Both are required for UI work.
|
||||
|
||||
For tiny doc-only edits, harness is optional.
|
||||
For anything that touches camera, spectator, tips, settings, discovery, chronicle, or input exit, harness is required.
|
||||
|
||||
|
|
@ -118,7 +143,7 @@ Use `fast_timing` / `director_run` instead.
|
|||
- `set_setting` (`expect=enabled|show_watch_reasons|show_dossier_caption|chronicle_enabled`)
|
||||
- `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain`
|
||||
- `chronicle_force`, `chronicle_open`, `chronicle_jump`
|
||||
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `chronicle_count`, `chronicle_latest_contains`
|
||||
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `dossier_traits_ok`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`
|
||||
- `snapshot`, `reset_counters`, `scenario`
|
||||
|
||||
## Process safety
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@ Use harness `fast_timing` + `director_run` / `age_current` for scheduling tests.
|
|||
- `caption_layout_ok` - dossier panel size/layout bounds (catches off-panel text)
|
||||
- `screenshot` harness action writes `.harness/<name>.png` via Unity ScreenCapture
|
||||
|
||||
## Screenshots
|
||||
|
||||
Harness `screenshot` writes:
|
||||
|
||||
- `IdleSpectator/.harness/<name>.png` - full frame
|
||||
- `IdleSpectator/.harness/<name>-dossier.png` - **exact dossier rect** (`ReadPixels`)
|
||||
|
||||
For HUD work:
|
||||
|
||||
1. Prefer reading `*-dossier.png` (wait ~1s after the run).
|
||||
2. Validate with `./scripts/crop-dossier-hud.py --strict IdleSpectator/.harness/hud-*-dossier.png`
|
||||
3. Do not judge layout from raw 2880p frames.
|
||||
4. Player-pasted shots override harness crops.
|
||||
|
||||
## Extending scenarios
|
||||
|
||||
Edit `IdleSpectator/HarnessScenarios.cs`:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
|
|
@ -1121,10 +1122,44 @@ public static class AgentHarness
|
|||
{
|
||||
pass = WatchCaption.LastLayoutOk
|
||||
&& WatchCaption.LastPanelSize.y > 20f
|
||||
&& WatchCaption.LastPanelSize.y < 120f
|
||||
&& WatchCaption.LastPanelSize.x <= 280f;
|
||||
&& WatchCaption.LastPanelSize.y < 140f
|
||||
&& WatchCaption.LastPanelSize.x <= 300f;
|
||||
detail =
|
||||
$"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}'";
|
||||
$"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'";
|
||||
break;
|
||||
}
|
||||
case "dossier_traits_ok":
|
||||
{
|
||||
UnitDossier dossier = WatchCaption.Current;
|
||||
string preview = WatchCaption.LastTraitsPreview ?? "";
|
||||
int count = dossier != null ? dossier.TopTraits.Count : 0;
|
||||
bool noEllipsis = preview.IndexOf("...", System.StringComparison.Ordinal) < 0;
|
||||
bool bounded = count >= 0 && count <= 3;
|
||||
bool labelsMatch = true;
|
||||
if (dossier != null)
|
||||
{
|
||||
for (int i = 0; i < dossier.TopTraits.Count; i++)
|
||||
{
|
||||
string name = dossier.TopTraits[i].Name ?? "";
|
||||
if (name.IndexOf("...", System.StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
labelsMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pass = bounded && noEllipsis && labelsMatch;
|
||||
detail = $"traits={count} preview='{preview}' layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}";
|
||||
break;
|
||||
}
|
||||
case "dossier_history_contains":
|
||||
{
|
||||
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
||||
string hist = WatchCaption.LastHistoryPreview ?? "";
|
||||
pass = !string.IsNullOrEmpty(needle)
|
||||
&& hist.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
detail = $"hist='{hist}' needle='{needle}'";
|
||||
break;
|
||||
}
|
||||
case "chronicle_world_latest_contains":
|
||||
|
|
@ -1820,8 +1855,17 @@ public static class AgentHarness
|
|||
// Absolute path required so Unity does not write under the game cwd.
|
||||
ScreenCapture.CaptureScreenshot(path);
|
||||
LastScreenshotPath = path;
|
||||
|
||||
string cropPath = Path.Combine(
|
||||
dir,
|
||||
Path.GetFileNameWithoutExtension(name) + "-dossier.png");
|
||||
if (ModClass.Instance != null)
|
||||
{
|
||||
ModClass.Instance.StartCoroutine(CaptureDossierCropEndOfFrame(cropPath));
|
||||
}
|
||||
|
||||
LogService.LogInfo(
|
||||
$"[IdleSpectator][HARNESS] screenshot queued path={path} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}");
|
||||
$"[IdleSpectator][HARNESS] screenshot queued path={path} crop={cropPath} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -1832,6 +1876,42 @@ public static class AgentHarness
|
|||
}
|
||||
}
|
||||
|
||||
private static IEnumerator CaptureDossierCropEndOfFrame(string cropPath)
|
||||
{
|
||||
yield return new WaitForEndOfFrame();
|
||||
try
|
||||
{
|
||||
if (!WatchCaption.TryGetScreenPixelRect(out Rect rect))
|
||||
{
|
||||
LogService.LogInfo("[IdleSpectator][HARNESS] dossier crop skipped: no visible dossier rect");
|
||||
yield break;
|
||||
}
|
||||
|
||||
int x = Mathf.Clamp(Mathf.FloorToInt(rect.xMin), 0, Screen.width - 1);
|
||||
int y = Mathf.Clamp(Mathf.FloorToInt(rect.yMin), 0, Screen.height - 1);
|
||||
int w = Mathf.Clamp(Mathf.CeilToInt(rect.width), 1, Screen.width - x);
|
||||
int h = Mathf.Clamp(Mathf.CeilToInt(rect.height), 1, Screen.height - y);
|
||||
if (w < 16 || h < 16)
|
||||
{
|
||||
LogService.LogInfo($"[IdleSpectator][HARNESS] dossier crop skipped: tiny rect {w}x{h}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
Texture2D tex = new Texture2D(w, h, TextureFormat.RGB24, false);
|
||||
tex.ReadPixels(new Rect(x, y, w, h), 0, 0);
|
||||
tex.Apply();
|
||||
byte[] png = tex.EncodeToPNG();
|
||||
UnityEngine.Object.Destroy(tex);
|
||||
File.WriteAllBytes(cropPath, png);
|
||||
LogService.LogInfo(
|
||||
$"[IdleSpectator][HARNESS] dossier crop written path={cropPath} rect=({x},{y},{w},{h}) screen={Screen.width}x{Screen.height}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.LogInfo("[IdleSpectator][HARNESS] dossier crop failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string HarnessDir()
|
||||
{
|
||||
if (string.IsNullOrEmpty(ModClass.ModFolder))
|
||||
|
|
|
|||
|
|
@ -118,6 +118,30 @@ public static class Chronicle
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Newest-first slice for compact dossier HUD.</summary>
|
||||
public static IReadOnlyList<ChronicleEntry> LatestForSubject(long subjectId, int max)
|
||||
{
|
||||
if (max <= 0)
|
||||
{
|
||||
return System.Array.Empty<ChronicleEntry>();
|
||||
}
|
||||
|
||||
IReadOnlyList<ChronicleEntry> all = SnapshotForSubject(subjectId);
|
||||
if (all.Count == 0)
|
||||
{
|
||||
return all;
|
||||
}
|
||||
|
||||
int take = Mathf.Min(max, all.Count);
|
||||
ChronicleEntry[] newest = new ChronicleEntry[take];
|
||||
for (int i = 0; i < take; i++)
|
||||
{
|
||||
newest[i] = all[all.Count - 1 - i];
|
||||
}
|
||||
|
||||
return newest;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ChronicleEntry> SnapshotWorld()
|
||||
{
|
||||
lock (WorldEntries)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ public static class DossierAvatar
|
|||
|
||||
public static bool UsingVanilla => _usingVanilla;
|
||||
|
||||
public static void ResetHost()
|
||||
{
|
||||
_template = null;
|
||||
_instance = null;
|
||||
_host = null;
|
||||
_fallbackBg = null;
|
||||
_fallbackSprite = null;
|
||||
_usingVanilla = false;
|
||||
_loggedMode = false;
|
||||
_shownActorId = -1;
|
||||
}
|
||||
|
||||
public static void EnsureHost(Transform parent, float size)
|
||||
{
|
||||
if (_host != null)
|
||||
|
|
@ -28,7 +40,7 @@ public static class DossierAvatar
|
|||
return;
|
||||
}
|
||||
|
||||
GameObject hostGo = new GameObject("DossierAvatarHost", typeof(RectTransform));
|
||||
GameObject hostGo = new GameObject("DossierAvatarHost", typeof(RectTransform), typeof(RectMask2D));
|
||||
hostGo.transform.SetParent(parent, false);
|
||||
_host = hostGo.GetComponent<RectTransform>();
|
||||
_host.anchorMin = new Vector2(0f, 1f);
|
||||
|
|
|
|||
|
|
@ -407,13 +407,17 @@ internal static class HarnessScenarios
|
|||
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"),
|
||||
Step("ch12c", "assert", expect: "dossier_traits_ok"),
|
||||
Step("ch12d", "screenshot", value: "hud-dossier-empty-hist.png"),
|
||||
Step("ch12e", "wait", wait: 0.55f),
|
||||
|
||||
// History inject
|
||||
Step("ch13", "chronicle_force", label: "Killed"),
|
||||
Step("ch14", "assert", expect: "chronicle_count", value: "1", label: "min"),
|
||||
Step("ch15", "assert", expect: "chronicle_latest_contains", value: "Killed"),
|
||||
Step("ch15b", "assert", expect: "chronicle_latest_contains", value: "auto"),
|
||||
Step("ch15c", "wait", wait: 0.2f),
|
||||
Step("ch15d", "assert", expect: "dossier_history_contains", value: "Killed"),
|
||||
|
||||
// Death-cause wording samples (victim POV)
|
||||
Step("ch16a", "chronicle_death_sample", value: "Age"),
|
||||
|
|
@ -437,6 +441,7 @@ internal static class HarnessScenarios
|
|||
Step("ch18a2", "wait", wait: 0.6f),
|
||||
Step("ch18b", "chronicle_tab", value: "history"),
|
||||
Step("ch18c", "assert", expect: "caption_layout_ok"),
|
||||
Step("ch18c2", "assert", expect: "dossier_traits_ok"),
|
||||
Step("ch18d", "assert", expect: "chronicle_tab", value: "history"),
|
||||
Step("ch18e", "chronicle_tab", value: "world"),
|
||||
Step("ch18f", "assert", expect: "chronicle_tab", value: "world"),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
|
|||
/// <summary>Absolute path to this mod's folder (parent of mod.json).</summary>
|
||||
public static string ModFolder { get; private set; }
|
||||
|
||||
public static ModClass Instance { get; private set; }
|
||||
|
||||
public ModDeclare GetDeclaration()
|
||||
{
|
||||
return _declare;
|
||||
|
|
@ -46,6 +48,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
|
|||
|
||||
public void OnLoad(ModDeclare pModDecl, GameObject pGameObject)
|
||||
{
|
||||
Instance = this;
|
||||
_declare = pModDecl;
|
||||
_gameObject = pGameObject;
|
||||
ModFolder = pModDecl.FolderPath;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
|
|
@ -438,6 +439,8 @@ public sealed class UnitDossier
|
|||
return;
|
||||
}
|
||||
|
||||
// Pick the most interesting traits: rarity first, then non-default / dramatic.
|
||||
List<ActorTrait> ranked = new List<ActorTrait>();
|
||||
foreach (ActorTrait trait in actor.getTraits())
|
||||
{
|
||||
if (trait == null || string.IsNullOrEmpty(trait.id))
|
||||
|
|
@ -445,16 +448,20 @@ public sealed class UnitDossier
|
|||
continue;
|
||||
}
|
||||
|
||||
ranked.Add(trait);
|
||||
}
|
||||
|
||||
ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor)));
|
||||
|
||||
for (int i = 0; i < ranked.Count && d.TopTraits.Count < 3; i++)
|
||||
{
|
||||
ActorTrait trait = ranked[i];
|
||||
d.TopTraits.Add(new TraitChip
|
||||
{
|
||||
Id = trait.id,
|
||||
Name = TraitDisplayName(trait),
|
||||
Trait = trait
|
||||
});
|
||||
if (d.TopTraits.Count >= 3)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
|
@ -463,6 +470,103 @@ public sealed class UnitDossier
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spectator-facing interest: Legendary/Epic over Normal, non-default over species baseline,
|
||||
/// Positive/Negative over bland Other. Ties break on rarer birth rates.
|
||||
/// </summary>
|
||||
private static int TraitInterestScore(ActorTrait trait, Actor actor)
|
||||
{
|
||||
if (trait == null)
|
||||
{
|
||||
return int.MinValue;
|
||||
}
|
||||
|
||||
int score = 0;
|
||||
try
|
||||
{
|
||||
score += (int)trait.rarity * 100;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// older builds without rarity
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (trait.type == TraitType.Negative)
|
||||
{
|
||||
score += 25;
|
||||
}
|
||||
else if (trait.type == TraitType.Positive)
|
||||
{
|
||||
score += 15;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!IsDefaultTraitForActor(trait, actor))
|
||||
{
|
||||
score += 50;
|
||||
}
|
||||
else
|
||||
{
|
||||
score -= 40;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Low birth rate = unusual to spawn with; more interesting on a watch card.
|
||||
if (trait.rate_birth > 0 && trait.rate_birth <= 5)
|
||||
{
|
||||
score += 20;
|
||||
}
|
||||
else if (trait.rate_birth >= 40)
|
||||
{
|
||||
score -= 15;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
string id = trait.id ?? "";
|
||||
if (id.IndexOf("miracle", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("immortal", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("zombie", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("blessed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("evil", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
score += 35;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool IsDefaultTraitForActor(ActorTrait trait, Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (trait == null || actor == null || actor.asset == null
|
||||
|| trait.default_for_actor_assets == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return trait.default_for_actor_assets.Contains(actor.asset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TraitDisplayName(ActorTrait trait)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
|
@ -5,30 +6,36 @@ using UnityEngine.UI;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent compact dossier (Option A / vanilla strip):
|
||||
/// header species+name+sex, live portrait + level/task, full-width trait strip, reason.
|
||||
/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, packed traits, reason.
|
||||
/// </summary>
|
||||
public static class WatchCaption
|
||||
{
|
||||
private const float PanelWidth = 220f;
|
||||
private const float PanelWidthMax = 340f;
|
||||
private const float SpeciesSize = 16f;
|
||||
private const float SexSize = 14f;
|
||||
private const float LiveMax = 44f;
|
||||
private const float ChipIcon = 14f;
|
||||
private const float ChipIcon = 12f;
|
||||
private const float TraitIcon = 12f;
|
||||
private const float HistoryIcon = 10f;
|
||||
private const float HistoryColW = 118f;
|
||||
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 TraitsH = 14f;
|
||||
private const float ReasonH = 12f;
|
||||
private const float HistoryLineH = 13f;
|
||||
private const float Gap = 2f;
|
||||
private const int HistoryMax = 3;
|
||||
|
||||
private static float _panelWidth = LiveMax + PadX * 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 readonly Color HistoryTextColor = new Color(0.82f, 0.84f, 0.86f, 1f);
|
||||
|
||||
private static GameObject _root;
|
||||
private static RectTransform _rootRt;
|
||||
|
|
@ -36,16 +43,20 @@ public static class WatchCaption
|
|||
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 Text _taskText;
|
||||
|
||||
private static readonly TraitSlot[] _traitSlots = new TraitSlot[3];
|
||||
private static GameObject _traitsRow;
|
||||
|
||||
private static GameObject _historyCol;
|
||||
private static readonly HistorySlot[] _historySlots = new HistorySlot[HistoryMax];
|
||||
private static int _lastHistoryCount = -1;
|
||||
private static long _lastHistorySubjectId;
|
||||
|
||||
private static UnitDossier _current;
|
||||
private static Actor _boundActor;
|
||||
private static bool _visible;
|
||||
|
|
@ -57,6 +68,13 @@ public static class WatchCaption
|
|||
public Text Label;
|
||||
}
|
||||
|
||||
private sealed class HistorySlot
|
||||
{
|
||||
public GameObject Root;
|
||||
public Image Icon;
|
||||
public Text Label;
|
||||
}
|
||||
|
||||
public static UnitDossier Current => _current;
|
||||
|
||||
public static string LastHeadline { get; private set; } = "";
|
||||
|
|
@ -65,10 +83,45 @@ public static class WatchCaption
|
|||
|
||||
public static string LastCaptionText { get; private set; } = "";
|
||||
|
||||
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
|
||||
public static string LastHistoryPreview { get; private set; } = "";
|
||||
|
||||
/// <summary>Harness: comma-joined top trait labels currently shown.</summary>
|
||||
public static string LastTraitsPreview { get; private set; } = "";
|
||||
|
||||
public static Vector2 LastPanelSize { get; private set; }
|
||||
|
||||
public static bool LastLayoutOk { get; private set; }
|
||||
|
||||
/// <summary>Screen-pixel rect of the dossier card (Unity bottom-left origin), if visible.</summary>
|
||||
public static bool TryGetScreenPixelRect(out Rect pixelRect)
|
||||
{
|
||||
pixelRect = default;
|
||||
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Vector3[] corners = new Vector3[4];
|
||||
_rootRt.GetWorldCorners(corners);
|
||||
float xMin = float.MaxValue;
|
||||
float yMin = float.MaxValue;
|
||||
float xMax = float.MinValue;
|
||||
float yMax = float.MinValue;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
Vector2 sp = RectTransformUtility.WorldToScreenPoint(null, corners[i]);
|
||||
xMin = Mathf.Min(xMin, sp.x);
|
||||
yMin = Mathf.Min(yMin, sp.y);
|
||||
xMax = Mathf.Max(xMax, sp.x);
|
||||
yMax = Mathf.Max(yMax, sp.y);
|
||||
}
|
||||
|
||||
float pad = 4f;
|
||||
pixelRect = Rect.MinMaxRect(xMin - pad, yMin - pad, xMax + pad, yMax + pad);
|
||||
return pixelRect.width > 8f && pixelRect.height > 8f;
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_current = null;
|
||||
|
|
@ -76,8 +129,12 @@ public static class WatchCaption
|
|||
LastHeadline = "";
|
||||
LastDetail = "";
|
||||
LastCaptionText = "";
|
||||
LastHistoryPreview = "";
|
||||
LastTraitsPreview = "";
|
||||
LastPanelSize = Vector2.zero;
|
||||
LastLayoutOk = false;
|
||||
_lastHistoryCount = -1;
|
||||
_lastHistorySubjectId = 0;
|
||||
ApplyVisual(null, null);
|
||||
SetVisible(false);
|
||||
}
|
||||
|
|
@ -95,6 +152,7 @@ public static class WatchCaption
|
|||
LastHeadline = CameraDirector.FormatWatchTip(interest);
|
||||
LastDetail = "";
|
||||
LastCaptionText = LastHeadline;
|
||||
LastHistoryPreview = "";
|
||||
_current = null;
|
||||
_boundActor = null;
|
||||
EnsureBuilt();
|
||||
|
|
@ -104,7 +162,7 @@ public static class WatchCaption
|
|||
_nameText.text = LastHeadline;
|
||||
}
|
||||
|
||||
Relayout(false, 0, false, false);
|
||||
Relayout(false, 0, false, false, 0);
|
||||
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
|
||||
}
|
||||
|
||||
|
|
@ -151,13 +209,14 @@ public static class WatchCaption
|
|||
else if (_nameText != null)
|
||||
{
|
||||
_nameText.text = LastHeadline;
|
||||
Relayout(false, 0, false, false);
|
||||
Relayout(false, 0, false, false, 0);
|
||||
}
|
||||
|
||||
SetVisible(true);
|
||||
}
|
||||
|
||||
RefreshLivePortrait();
|
||||
RefreshHistoryIfChanged();
|
||||
}
|
||||
|
||||
private static void RefreshLivePortrait()
|
||||
|
|
@ -186,8 +245,62 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
// Tile floor can change as the unit moves; keep portrait in sync.
|
||||
DossierAvatar.Show(actor);
|
||||
BringHeaderFront();
|
||||
}
|
||||
|
||||
private static void BringHeaderFront()
|
||||
{
|
||||
// Draw nametag above vanilla avatar chrome (which can paint outside its host).
|
||||
void Front(Component c)
|
||||
{
|
||||
if (c != null)
|
||||
{
|
||||
c.transform.SetAsLastSibling();
|
||||
}
|
||||
}
|
||||
|
||||
Front(_speciesIcon);
|
||||
Front(_nameText);
|
||||
Front(_levelIcon);
|
||||
Front(_levelValue);
|
||||
Front(_taskIcon);
|
||||
Front(_taskText);
|
||||
Front(_sexIcon);
|
||||
}
|
||||
|
||||
private static void RefreshHistoryIfChanged()
|
||||
{
|
||||
if (!_visible || _current == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long id = _current.UnitId;
|
||||
int count = Chronicle.HistoryCountFor(id);
|
||||
if (id == _lastHistorySubjectId && count == _lastHistoryCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int shown = FillHistory(id);
|
||||
// Re-run layout only when history presence flips or line count changes.
|
||||
bool hasBody = _boundActor != null;
|
||||
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
|
||||
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
|
||||
int traits = 0;
|
||||
if (_traitsRow != null && _traitsRow.activeSelf)
|
||||
{
|
||||
for (int i = 0; i < _traitSlots.Length; i++)
|
||||
{
|
||||
if (_traitSlots[i] != null && _traitSlots[i].Root != null && _traitSlots[i].Root.activeSelf)
|
||||
{
|
||||
traits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Relayout(hasBody, traits, hasTask, hasReason, shown);
|
||||
}
|
||||
|
||||
private static void ApplyVisual(Actor actor, UnitDossier dossier)
|
||||
|
|
@ -235,19 +348,23 @@ public static class WatchCaption
|
|||
|
||||
bool hasBody = dossier != null && actor != null;
|
||||
DossierAvatar.SetActive(hasBody);
|
||||
if (_metaCol != null)
|
||||
|
||||
bool hasLevel = hasBody;
|
||||
if (_levelIcon != null)
|
||||
{
|
||||
_metaCol.SetActive(hasBody);
|
||||
HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null);
|
||||
_levelIcon.gameObject.SetActive(hasLevel);
|
||||
}
|
||||
|
||||
if (_levelValue != null)
|
||||
{
|
||||
_levelValue.text = hasLevel ? dossier.Level.ToString() : "";
|
||||
_levelValue.gameObject.SetActive(hasLevel);
|
||||
}
|
||||
|
||||
if (hasBody)
|
||||
{
|
||||
DossierAvatar.Show(actor);
|
||||
HudIcons.Apply(_levelIcon, HudIcons.Level());
|
||||
if (_levelValue != null)
|
||||
{
|
||||
_levelValue.text = dossier.Level.ToString();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -261,18 +378,26 @@ public static class WatchCaption
|
|||
_taskIcon.gameObject.SetActive(hasTask);
|
||||
}
|
||||
|
||||
if (_metaText != null)
|
||||
if (_taskText != null)
|
||||
{
|
||||
_metaText.text = hasTask ? dossier.TaskText : "";
|
||||
_metaText.gameObject.SetActive(hasTask);
|
||||
string task = hasTask ? dossier.TaskText : "";
|
||||
if (task.Length > 14)
|
||||
{
|
||||
task = task.Substring(0, 13) + "...";
|
||||
}
|
||||
|
||||
_taskText.text = task;
|
||||
_taskText.gameObject.SetActive(hasTask);
|
||||
}
|
||||
|
||||
int traitCount = 0;
|
||||
LastTraitsPreview = "";
|
||||
if (_traitsRow != null)
|
||||
{
|
||||
if (dossier != null && dossier.TopTraits.Count > 0)
|
||||
{
|
||||
_traitsRow.SetActive(true);
|
||||
System.Text.StringBuilder traitPreview = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < _traitSlots.Length; i++)
|
||||
{
|
||||
TraitSlot slot = _traitSlots[i];
|
||||
|
|
@ -286,17 +411,18 @@ public static class WatchCaption
|
|||
UnitDossier.TraitChip chip = dossier.TopTraits[i];
|
||||
slot.Root.SetActive(true);
|
||||
HudIcons.Apply(slot.Icon, HudIcons.FromTrait(chip.Trait));
|
||||
string name = chip.Name ?? "";
|
||||
if (slot.Label != null)
|
||||
{
|
||||
string label = chip.Name ?? "";
|
||||
if (label.Length > 11)
|
||||
{
|
||||
label = label.Substring(0, 10) + "...";
|
||||
}
|
||||
|
||||
slot.Label.text = label;
|
||||
slot.Label.text = name;
|
||||
}
|
||||
|
||||
if (traitPreview.Length > 0)
|
||||
{
|
||||
traitPreview.Append(", ");
|
||||
}
|
||||
|
||||
traitPreview.Append(name);
|
||||
traitCount++;
|
||||
}
|
||||
else
|
||||
|
|
@ -304,6 +430,8 @@ public static class WatchCaption
|
|||
slot.Root.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
LastTraitsPreview = traitPreview.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -311,6 +439,20 @@ public static class WatchCaption
|
|||
}
|
||||
}
|
||||
|
||||
int historyCount = 0;
|
||||
if (dossier != null)
|
||||
{
|
||||
historyCount = FillHistory(dossier.UnitId);
|
||||
}
|
||||
else
|
||||
{
|
||||
LastHistoryPreview = "";
|
||||
if (_historyCol != null)
|
||||
{
|
||||
_historyCol.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasReason = dossier != null && !string.IsNullOrEmpty(dossier.ReasonLine);
|
||||
if (_reasonText != null)
|
||||
{
|
||||
|
|
@ -318,24 +460,104 @@ public static class WatchCaption
|
|||
_reasonText.gameObject.SetActive(hasReason);
|
||||
}
|
||||
|
||||
Relayout(hasBody, traitCount, hasTask, hasReason);
|
||||
Relayout(hasBody, traitCount, hasTask, hasReason, historyCount);
|
||||
}
|
||||
|
||||
private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason)
|
||||
private static int FillHistory(long unitId)
|
||||
{
|
||||
IReadOnlyList<ChronicleEntry> entries = Chronicle.LatestForSubject(unitId, HistoryMax);
|
||||
_lastHistorySubjectId = unitId;
|
||||
_lastHistoryCount = Chronicle.HistoryCountFor(unitId);
|
||||
|
||||
int shown = 0;
|
||||
LastHistoryPreview = "";
|
||||
if (_historyCol == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (entries == null || entries.Count == 0)
|
||||
{
|
||||
_historyCol.SetActive(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
_historyCol.SetActive(true);
|
||||
for (int i = 0; i < _historySlots.Length; i++)
|
||||
{
|
||||
HistorySlot slot = _historySlots[i];
|
||||
if (slot == null || slot.Root == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i < entries.Count && entries[i] != null)
|
||||
{
|
||||
ChronicleEntry e = entries[i];
|
||||
slot.Root.SetActive(true);
|
||||
HudIcons.Apply(slot.Icon, HudIcons.ForChronicleKind(e.Kind));
|
||||
string line = e.Line ?? "";
|
||||
if (line.Length > 28)
|
||||
{
|
||||
line = line.Substring(0, 27) + "...";
|
||||
}
|
||||
|
||||
if (slot.Label != null)
|
||||
{
|
||||
slot.Label.text = line;
|
||||
}
|
||||
|
||||
if (shown == 0)
|
||||
{
|
||||
LastHistoryPreview = e.Line ?? "";
|
||||
}
|
||||
|
||||
shown++;
|
||||
}
|
||||
else
|
||||
{
|
||||
slot.Root.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
return shown;
|
||||
}
|
||||
|
||||
private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason, int historyCount)
|
||||
{
|
||||
float y = PadTop;
|
||||
PlaceHeader(y);
|
||||
float headerW = PlaceHeader(y, hasTask);
|
||||
y += HeaderH + Gap;
|
||||
|
||||
float bodyW = 0f;
|
||||
float traitsW = 0f;
|
||||
bool traitsBeside = hasBody && historyCount <= 0 && traitCount > 0;
|
||||
|
||||
if (hasBody)
|
||||
{
|
||||
PlaceBody(y);
|
||||
y += BodyH + Gap;
|
||||
PlaceBody(y, historyCount);
|
||||
if (historyCount > 0)
|
||||
{
|
||||
bodyW = LiveMax + 4f + HistoryColW;
|
||||
y += BodyH + Gap;
|
||||
}
|
||||
else if (traitsBeside)
|
||||
{
|
||||
// Fill the body-right void with a vertical trait stack beside the portrait.
|
||||
traitsW = PlaceTraitsBesideAvatar(y, traitCount);
|
||||
bodyW = LiveMax + 4f + traitsW;
|
||||
y += BodyH + Gap;
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyW = LiveMax;
|
||||
y += BodyH + Gap;
|
||||
}
|
||||
}
|
||||
|
||||
if (traitCount > 0)
|
||||
if (traitCount > 0 && !traitsBeside)
|
||||
{
|
||||
PlaceTraits(y, traitCount);
|
||||
traitsW = PlaceTraits(y, traitCount);
|
||||
y += TraitsH + Gap;
|
||||
}
|
||||
|
||||
|
|
@ -345,116 +567,194 @@ public static class WatchCaption
|
|||
y += ReasonH + Gap;
|
||||
}
|
||||
|
||||
_panelWidth = Mathf.Clamp(
|
||||
PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, LiveMax),
|
||||
LiveMax + PadX * 2f,
|
||||
PanelWidthMax);
|
||||
|
||||
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
|
||||
if (_rootRt != null)
|
||||
{
|
||||
_rootRt.sizeDelta = new Vector2(PanelWidth, height);
|
||||
_rootRt.sizeDelta = new Vector2(_panelWidth, height);
|
||||
LastPanelSize = _rootRt.sizeDelta;
|
||||
}
|
||||
|
||||
LastLayoutOk = ProbeLayoutInside(height);
|
||||
BringHeaderFront();
|
||||
|
||||
LastLayoutOk = ProbeLayoutInside(height) && headerW > SpeciesSize;
|
||||
if (!LastLayoutOk)
|
||||
{
|
||||
LogService.LogInfo(
|
||||
$"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} task={hasTask} reason={hasReason}");
|
||||
$"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} hist={historyCount} headerW={headerW}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void PlaceHeader(float yFromTop)
|
||||
/// <summary>Left-pack nametag on the root: [species] Name [lv]n [task] [sex].</summary>
|
||||
private static float PlaceHeader(float yFromTop, bool hasTask)
|
||||
{
|
||||
float x = PadX;
|
||||
|
||||
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);
|
||||
PlaceLeftChip(_speciesIcon.rectTransform, x, yFromTop + 1f, SpeciesSize, SpeciesSize);
|
||||
x += SpeciesSize + 3f;
|
||||
}
|
||||
|
||||
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);
|
||||
float nameW = Mathf.Clamp(MeasureTextWidth(_nameText, 72f), 28f, 120f);
|
||||
PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH);
|
||||
x += nameW + 5f;
|
||||
}
|
||||
|
||||
if (_levelIcon != null && _levelIcon.gameObject.activeSelf)
|
||||
{
|
||||
PlaceLeftChip(_levelIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
|
||||
x += ChipIcon + 1f;
|
||||
}
|
||||
|
||||
if (_levelValue != null && _levelValue.gameObject.activeSelf)
|
||||
{
|
||||
float lvW = Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f);
|
||||
PlaceLeftChip(_levelValue.rectTransform, x, yFromTop, lvW, HeaderH);
|
||||
x += lvW + 4f;
|
||||
}
|
||||
|
||||
if (hasTask && _taskIcon != null && _taskText != null)
|
||||
{
|
||||
_taskIcon.gameObject.SetActive(true);
|
||||
_taskText.gameObject.SetActive(true);
|
||||
PlaceLeftChip(_taskIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
|
||||
x += ChipIcon + 1f;
|
||||
float taskW = Mathf.Clamp(MeasureTextWidth(_taskText, 36f), 20f, 72f);
|
||||
PlaceLeftChip(_taskText.rectTransform, x, yFromTop, taskW, HeaderH);
|
||||
x += taskW + 4f;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_taskText != null)
|
||||
{
|
||||
_taskText.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (_taskIcon != null)
|
||||
{
|
||||
_taskIcon.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
|
||||
{
|
||||
PlaceLeftChip(_sexIcon.rectTransform, x, yFromTop + 2f, SexSize, SexSize);
|
||||
x += SexSize;
|
||||
}
|
||||
|
||||
BringHeaderFront();
|
||||
return Mathf.Max(0f, x - PadX);
|
||||
}
|
||||
|
||||
private static void PlaceBody(float yFromTop)
|
||||
private static void PlaceLeftChip(RectTransform rt, float x, float yFromTop, float width, float height)
|
||||
{
|
||||
if (rt == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(0f, 1f);
|
||||
rt.pivot = new Vector2(0f, 1f);
|
||||
rt.anchoredPosition = new Vector2(x, -yFromTop);
|
||||
rt.sizeDelta = new Vector2(width, height);
|
||||
}
|
||||
|
||||
private static float MeasureTextWidth(Text text, float fallback)
|
||||
{
|
||||
if (text == null || string.IsNullOrEmpty(text.text))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
float w = text.preferredWidth;
|
||||
if (w > 1f)
|
||||
{
|
||||
return w + 2f;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void PlaceBody(float yFromTop, int historyCount)
|
||||
{
|
||||
DossierAvatar.Place(PadX, yFromTop, LiveMax);
|
||||
|
||||
if (_metaCol != null)
|
||||
if (_historyCol == 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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Level row at top of meta col, task under it.
|
||||
if (_levelIcon != null)
|
||||
if (historyCount <= 0)
|
||||
{
|
||||
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);
|
||||
_historyCol.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
_historyCol.SetActive(true);
|
||||
RectTransform col = _historyCol.GetComponent<RectTransform>();
|
||||
col.anchorMin = new Vector2(0f, 1f);
|
||||
col.anchorMax = new Vector2(0f, 1f);
|
||||
col.pivot = new Vector2(0f, 1f);
|
||||
col.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop);
|
||||
col.sizeDelta = new Vector2(HistoryColW, BodyH);
|
||||
|
||||
if (_taskIcon != null)
|
||||
for (int i = 0; i < _historySlots.Length; i++)
|
||||
{
|
||||
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);
|
||||
}
|
||||
HistorySlot slot = _historySlots[i];
|
||||
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
RectTransform rt = slot.Root.GetComponent<RectTransform>();
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(1f, 1f);
|
||||
rt.pivot = new Vector2(0.5f, 1f);
|
||||
float top = i * HistoryLineH;
|
||||
rt.offsetMin = new Vector2(0f, -(top + HistoryLineH));
|
||||
rt.offsetMax = new Vector2(0f, -top);
|
||||
|
||||
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(HistoryIcon, HistoryIcon);
|
||||
}
|
||||
|
||||
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(HistoryIcon + 2f, 0f);
|
||||
lrt.offsetMax = Vector2.zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void PlaceTraits(float yFromTop, int traitCount)
|
||||
private static float PlaceTraits(float yFromTop, int traitCount)
|
||||
{
|
||||
if (_traitsRow == null)
|
||||
{
|
||||
return;
|
||||
return 0f;
|
||||
}
|
||||
|
||||
RectTransform row = _traitsRow.GetComponent<RectTransform>();
|
||||
|
|
@ -464,8 +764,7 @@ public static class WatchCaption
|
|||
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);
|
||||
float x = 0f;
|
||||
for (int i = 0; i < _traitSlots.Length; i++)
|
||||
{
|
||||
TraitSlot slot = _traitSlots[i];
|
||||
|
|
@ -474,31 +773,93 @@ public static class WatchCaption
|
|||
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);
|
||||
float labelW = slot.Label != null
|
||||
? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f)
|
||||
: 28f;
|
||||
float slotW = TraitIcon + 2f + labelW;
|
||||
PlaceTraitSlot(slot, x, 0f, slotW, TraitsH);
|
||||
x += slotW + 3f;
|
||||
}
|
||||
|
||||
if (slot.Icon != null)
|
||||
return x > 0f ? x - 3f : 0f;
|
||||
}
|
||||
|
||||
/// <summary>Vertical trait stack in the body-right slot when History is empty.</summary>
|
||||
private static float PlaceTraitsBesideAvatar(float yFromTop, int traitCount)
|
||||
{
|
||||
if (_traitsRow == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float colW = 0f;
|
||||
for (int i = 0; i < _traitSlots.Length; i++)
|
||||
{
|
||||
TraitSlot slot = _traitSlots[i];
|
||||
if (slot == null || slot.Root == null || !slot.Root.activeSelf || slot.Label == 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);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slot.Label != null)
|
||||
float labelW = Mathf.Max(MeasureTextWidth(slot.Label, 36f), 24f);
|
||||
colW = Mathf.Max(colW, TraitIcon + 2f + labelW);
|
||||
}
|
||||
|
||||
if (colW < 1f)
|
||||
{
|
||||
colW = 56f;
|
||||
}
|
||||
|
||||
RectTransform row = _traitsRow.GetComponent<RectTransform>();
|
||||
row.anchorMin = new Vector2(0f, 1f);
|
||||
row.anchorMax = new Vector2(0f, 1f);
|
||||
row.pivot = new Vector2(0f, 1f);
|
||||
row.anchoredPosition = new Vector2(PadX + LiveMax + 4f, -yFromTop);
|
||||
row.sizeDelta = new Vector2(colW, BodyH);
|
||||
|
||||
float line = Mathf.Min(HistoryLineH, BodyH / Mathf.Max(1, traitCount));
|
||||
int shown = 0;
|
||||
for (int i = 0; i < _traitSlots.Length; i++)
|
||||
{
|
||||
TraitSlot slot = _traitSlots[i];
|
||||
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
|
||||
{
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
|
||||
PlaceTraitSlot(slot, 0f, -(shown * line), colW, line);
|
||||
shown++;
|
||||
}
|
||||
|
||||
return colW;
|
||||
}
|
||||
|
||||
private static void PlaceTraitSlot(TraitSlot slot, float x, float y, float slotW, float slotH)
|
||||
{
|
||||
RectTransform rt = slot.Root.GetComponent<RectTransform>();
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(0f, 1f);
|
||||
rt.pivot = new Vector2(0f, 1f);
|
||||
rt.anchoredPosition = new Vector2(x, y);
|
||||
rt.sizeDelta = new Vector2(slotW, slotH);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -513,7 +874,9 @@ public static class WatchCaption
|
|||
&& (_reasonText == null || !_reasonText.gameObject.activeSelf
|
||||
|| LineInside(_reasonText.GetComponent<RectTransform>(), panelHeight))
|
||||
&& (_traitsRow == null || !_traitsRow.activeSelf
|
||||
|| LineInside(_traitsRow.GetComponent<RectTransform>(), panelHeight));
|
||||
|| LineInside(_traitsRow.GetComponent<RectTransform>(), panelHeight))
|
||||
&& (_historyCol == null || !_historyCol.activeSelf
|
||||
|| LineInside(_historyCol.GetComponent<RectTransform>(), panelHeight));
|
||||
}
|
||||
|
||||
private static bool LineInside(RectTransform line, float panelHeight)
|
||||
|
|
@ -533,6 +896,13 @@ public static class WatchCaption
|
|||
return y >= 0f && y + h <= panelHeight + 0.5f;
|
||||
}
|
||||
|
||||
if (line.anchorMin.y == line.anchorMax.y && line.anchorMin.x == line.anchorMax.x)
|
||||
{
|
||||
float y = -line.anchoredPosition.y;
|
||||
float h = line.sizeDelta.y > 0.1f ? line.sizeDelta.y : HeaderH;
|
||||
return y >= 0f && y + h <= panelHeight + 0.5f;
|
||||
}
|
||||
|
||||
return top >= 0f && bottom <= panelHeight + 0.5f && top < bottom;
|
||||
}
|
||||
|
||||
|
|
@ -550,6 +920,33 @@ public static class WatchCaption
|
|||
|
||||
private static void EnsureBuilt()
|
||||
{
|
||||
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform))
|
||||
{
|
||||
// Stale HUD from an older build (nested nametag row) - rebuild.
|
||||
try
|
||||
{
|
||||
Object.Destroy(_root);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
_root = null;
|
||||
_rootRt = null;
|
||||
_speciesIcon = null;
|
||||
_sexIcon = null;
|
||||
_nameText = null;
|
||||
_reasonText = null;
|
||||
_levelIcon = null;
|
||||
_levelValue = null;
|
||||
_taskIcon = null;
|
||||
_taskText = null;
|
||||
_traitsRow = null;
|
||||
_historyCol = null;
|
||||
DossierAvatar.ResetHost();
|
||||
}
|
||||
|
||||
if (_root != null)
|
||||
{
|
||||
return;
|
||||
|
|
@ -568,7 +965,7 @@ public static class WatchCaption
|
|||
_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.sizeDelta = new Vector2(LiveMax + PadX * 2f, HeaderH + PadTop * 2f);
|
||||
_rootRt.anchoredPosition = new Vector2(12f, -12f);
|
||||
|
||||
Image bg = _root.GetComponent<Image>();
|
||||
|
|
@ -579,33 +976,25 @@ public static class WatchCaption
|
|||
group.blocksRaycasts = false;
|
||||
group.interactable = false;
|
||||
|
||||
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
|
||||
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
|
||||
|
||||
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11);
|
||||
_nameText.fontStyle = FontStyle.Bold;
|
||||
_nameText.color = NameColor;
|
||||
_nameText.alignment = TextAnchor.MiddleLeft;
|
||||
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
|
||||
// Avatar/history/traits first so nametag chips can SetAsLastSibling on top.
|
||||
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;
|
||||
_historyCol = new GameObject("HistoryCol", typeof(RectTransform));
|
||||
_historyCol.transform.SetParent(_root.transform, false);
|
||||
for (int i = 0; i < _historySlots.Length; i++)
|
||||
{
|
||||
GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform));
|
||||
slotGo.transform.SetParent(_historyCol.transform, false);
|
||||
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", HistoryIcon);
|
||||
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
|
||||
label.color = HistoryTextColor;
|
||||
label.alignment = TextAnchor.MiddleLeft;
|
||||
label.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
label.resizeTextMinSize = 6;
|
||||
label.resizeTextMaxSize = 8;
|
||||
_historySlots[i] = new HistorySlot { Root = slotGo, Icon = icon, Label = label };
|
||||
slotGo.SetActive(false);
|
||||
}
|
||||
|
||||
_traitsRow = new GameObject("TraitsRow", typeof(RectTransform));
|
||||
_traitsRow.transform.SetParent(_root.transform, false);
|
||||
|
|
@ -628,14 +1017,40 @@ public static class WatchCaption
|
|||
_reasonText.color = ReasonColor;
|
||||
_reasonText.alignment = TextAnchor.MiddleLeft;
|
||||
|
||||
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
|
||||
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
|
||||
|
||||
_nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11);
|
||||
_nameText.fontStyle = FontStyle.Bold;
|
||||
_nameText.color = NameColor;
|
||||
_nameText.alignment = TextAnchor.MiddleLeft;
|
||||
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
|
||||
_levelIcon = HudCanvas.MakeIcon(_root.transform, "LevelIcon", ChipIcon);
|
||||
_levelValue = HudCanvas.MakeText(_root.transform, "LevelValue", "", 9);
|
||||
_levelValue.color = StatValueColor;
|
||||
_levelValue.fontStyle = FontStyle.Bold;
|
||||
_levelValue.alignment = TextAnchor.MiddleLeft;
|
||||
_levelValue.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
|
||||
_taskIcon = HudCanvas.MakeIcon(_root.transform, "TaskIcon", ChipIcon);
|
||||
_taskText = HudCanvas.MakeText(_root.transform, "Task", "", 8);
|
||||
_taskText.color = TaskColor;
|
||||
_taskText.alignment = TextAnchor.MiddleLeft;
|
||||
_taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
|
||||
DossierAvatar.SetActive(false);
|
||||
_metaCol.SetActive(false);
|
||||
_historyCol.SetActive(false);
|
||||
_traitsRow.SetActive(false);
|
||||
_reasonText.gameObject.SetActive(false);
|
||||
Relayout(false, 0, false, false);
|
||||
_levelIcon.gameObject.SetActive(false);
|
||||
_levelValue.gameObject.SetActive(false);
|
||||
_taskIcon.gameObject.SetActive(false);
|
||||
_taskText.gameObject.SetActive(false);
|
||||
Relayout(false, 0, false, false, 0);
|
||||
_root.SetActive(false);
|
||||
_visible = false;
|
||||
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (vanilla strip + avatar frame)");
|
||||
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag chips + mini history)");
|
||||
}
|
||||
|
||||
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height, float left)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.9.10",
|
||||
"description": "AFK Idle Spectator (I) + Chronicle (F9). Vanilla-strip dossier with unit avatar frame.",
|
||||
"version": "0.9.20",
|
||||
"description": "AFK Idle Spectator (I) + Chronicle (F9). Full trait names; rarity-ranked; E2E trait assert.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
BIN
scripts/__pycache__/crop-dossier-hud.cpython-314.pyc
Normal file
BIN
scripts/__pycache__/crop-dossier-hud.cpython-314.pyc
Normal file
Binary file not shown.
112
scripts/crop-dossier-hud.py
Executable file
112
scripts/crop-dossier-hud.py
Executable file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate / normalize IdleSpectator dossier crops for agent visual checks.
|
||||
|
||||
Prefer Unity-written ``*-dossier.png`` files (exact UI rect via ReadPixels).
|
||||
For player pastes or legacy full frames, produce a readable 2x nearest upscale.
|
||||
|
||||
Usage:
|
||||
./scripts/crop-dossier-hud.py --strict IdleSpectator/.harness/hud-chronicle_smoke-dossier.png
|
||||
./scripts/crop-dossier-hud.py IdleSpectator/.harness/hud-chronicle_smoke.png # warns: use -dossier.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def analyze_nametag(im: Image.Image) -> tuple[bool, int]:
|
||||
"""Heuristic: top ~28% of a dossier crop should contain bright nametag pixels."""
|
||||
arr = np.array(im.convert("RGB"))
|
||||
h, w, _ = arr.shape
|
||||
band = arr[: max(1, h // 3), :]
|
||||
white = (band[:, :, 0] > 165) & (band[:, :, 1] > 150) & (band[:, :, 2] > 110)
|
||||
green = (band[:, :, 1] > 185) & (band[:, :, 0] < 130)
|
||||
orange = (band[:, :, 0] > 190) & (band[:, :, 1] > 90) & (band[:, :, 1] < 180) & (band[:, :, 2] < 100)
|
||||
bright = int(white.sum() + green.sum() + orange.sum())
|
||||
# Tiny crops still need a handful of lit pixels; larger crops need more.
|
||||
need = max(25, (w * h) // 400)
|
||||
return bright >= need, bright
|
||||
|
||||
|
||||
def normalize(path: Path, scale: int = 2) -> dict:
|
||||
im = Image.open(path).convert("RGB")
|
||||
w, h = im.size
|
||||
is_fullframe = w >= 1600 or h >= 1000
|
||||
stem = path.stem
|
||||
if stem.endswith("-dossier"):
|
||||
out = path
|
||||
method = "unity_or_existing_crop"
|
||||
elif is_fullframe:
|
||||
# Do not pretend pixel heuristics can find the card reliably on 2880p.
|
||||
out = path.with_name(path.stem + "-dossier.png")
|
||||
# Top-left fallback only so agents have *something*; mark nametag unknown.
|
||||
panel = im.crop((0, int(h * 0.08), min(w, int(w * 0.22)), int(h * 0.08) + min(280, h // 4)))
|
||||
panel.resize((panel.width * scale, panel.height * scale), Image.NEAREST).save(out)
|
||||
method = "fullframe_fallback_topleft"
|
||||
else:
|
||||
out = path.with_name(path.stem + "-dossier.png")
|
||||
im.resize((w * scale, h * scale), Image.NEAREST).save(out)
|
||||
method = "player_paste_upscale"
|
||||
|
||||
check_im = Image.open(out)
|
||||
ok, bright = analyze_nametag(check_im)
|
||||
if method == "fullframe_fallback_topleft":
|
||||
ok = False # never trust full-frame fallback for PASS
|
||||
|
||||
report = {
|
||||
"source": str(path),
|
||||
"dossier": str(out),
|
||||
"method": method,
|
||||
"size": [check_im.width, check_im.height],
|
||||
"nametag_bright_pixels": bright,
|
||||
"nametag_ok": ok,
|
||||
"is_fullframe_source": is_fullframe,
|
||||
}
|
||||
report_path = Path(str(out).replace(".png", ".json"))
|
||||
if report_path.suffix != ".json":
|
||||
report_path = out.with_suffix(".json")
|
||||
# Prefer sibling *-dossier.json next to crop.
|
||||
if out.name.endswith("-dossier.png"):
|
||||
report_path = out.with_name(out.stem + ".json")
|
||||
report_path.write_text(json.dumps(report, indent=2) + "\n")
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("images", nargs="+", type=Path)
|
||||
ap.add_argument("--strict", action="store_true", help="Exit 1 if nametag band looks empty")
|
||||
args = ap.parse_args()
|
||||
|
||||
worst = 0
|
||||
for path in args.images:
|
||||
if not path.is_file():
|
||||
print(f"MISSING {path}", file=sys.stderr)
|
||||
worst = 1
|
||||
continue
|
||||
report = normalize(path)
|
||||
status = "OK" if report["nametag_ok"] else "WARN_NO_NAMETAG"
|
||||
if report["method"] == "fullframe_fallback_topleft":
|
||||
status = "WARN_FULLFRAME"
|
||||
print(
|
||||
f"{status} {report['dossier']} - prefer Unity '*-dossier.png' from harness screenshot action",
|
||||
file=sys.stderr,
|
||||
)
|
||||
worst = max(worst, 1 if args.strict else 0)
|
||||
else:
|
||||
print(
|
||||
f"{status} {report['dossier']} method={report['method']} "
|
||||
f"bright={report['nametag_bright_pixels']} size={report['size']}"
|
||||
)
|
||||
if args.strict and not report["nametag_ok"]:
|
||||
worst = 1
|
||||
return worst
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -168,6 +168,15 @@ queue_and_wait() {
|
|||
rg -n "\[IdleSpectator\]\[(HARNESS|ASSERT|SNAP)\]|dismissed window|retry id=" "$LOG" | tail -n 50 || true
|
||||
fi
|
||||
if [[ "$result_status" == "PASS" ]]; then
|
||||
# Validate Unity-written dossier crops (exact UI rect), not 2880p full frames.
|
||||
sleep 1.0
|
||||
mapfile -t dossier_crops < <(compgen -G "$HARNESS/hud-*-dossier.png" || true)
|
||||
if ((${#dossier_crops[@]} > 0)); then
|
||||
python3 "$ROOT/scripts/crop-dossier-hud.py" --strict "${dossier_crops[@]}" \
|
||||
| tee /tmp/idle-dossier-crop.txt || true
|
||||
else
|
||||
echo "NOTE: no hud-*-dossier.png yet (need mod build with in-engine crop)" >&2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue