worldbox-observer-mod/IdleSpectator/ChronicleHud.cs

594 lines
20 KiB
C#

using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Compact bottom-right World Memory card: age chapters + lore landmarks (F9).
/// Sized in canvas units (same scaler as the dossier) to stay clear of top-right.
/// </summary>
public static class ChronicleHud
{
// Reference units on CanvasMain (~285x420). Keep modest so high-res scale is not a wall.
private const float PanelWidth = 220f;
private const float PanelHeight = 124f;
private const float ScreenInset = 12f;
private const float BottomClearance = 64f;
private const int MaxVisibleRows = 20;
private const float RowHeightMin = 24f;
private const float ChapterHeight = 18f;
private const float KindIconSize = 12f;
private const float TitleIconSize = 14f;
private static GameObject _root;
private static RectTransform _rootRt;
private static RectTransform _content;
private static Text _titleText;
private static Image _titleIcon;
private static readonly List<GameObject> Rows = new List<GameObject>();
private static bool _visible;
private static int _lastCount = -1;
private static string _lastAgeId = "";
public static bool Visible => _visible && _root != null && _root.activeSelf;
public static bool IsReady => _root != null;
public static void EnsureBuilt()
{
DestroyOrphanHuds();
if (_root != null)
{
ApplyRootPlacement();
return;
}
if (!Config.game_loaded && CanvasMain.instance == null)
{
return;
}
Canvas canvas = HudCanvas.Resolve();
if (canvas == null)
{
return;
}
_root = new GameObject("IdleSpectatorWorldMemoryHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
_root.transform.SetParent(canvas.transform, false);
_rootRt = _root.GetComponent<RectTransform>();
ApplyRootPlacement();
Image bg = _root.GetComponent<Image>();
bg.raycastTarget = true;
HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.88f));
CanvasGroup group = _root.GetComponent<CanvasGroup>();
group.blocksRaycasts = true;
group.interactable = true;
Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 8);
RectTransform f9Rt = f9.GetComponent<RectTransform>();
f9Rt.anchorMin = new Vector2(1f, 1f);
f9Rt.anchorMax = new Vector2(1f, 1f);
f9Rt.pivot = new Vector2(1f, 1f);
f9Rt.sizeDelta = new Vector2(24f, 14f);
f9Rt.anchoredPosition = new Vector2(-6f, -4f);
f9.alignment = TextAnchor.MiddleRight;
f9.color = new Color(0.55f, 0.58f, 0.62f, 1f);
f9.raycastTarget = false;
_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(14f, -14f);
HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks") ?? HudIcons.FromUiIcon("iconWar"));
_titleText = HudCanvas.MakeText(_root.transform, "Title", "World Memory", 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.offsetMin = new Vector2(24f, -24f);
titleRt.offsetMax = new Vector2(-28f, -4f);
_titleText.alignment = TextAnchor.MiddleLeft;
_titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f);
_titleText.fontStyle = FontStyle.Bold;
_titleText.raycastTarget = false;
_titleText.horizontalOverflow = HorizontalWrapMode.Overflow;
GameObject scrollGo = new GameObject(
"Scroll",
typeof(RectTransform),
typeof(Image),
typeof(ScrollRect),
typeof(RectMask2D));
scrollGo.transform.SetParent(_root.transform, false);
RectTransform scrollRt = scrollGo.GetComponent<RectTransform>();
scrollRt.anchorMin = new Vector2(0f, 0f);
scrollRt.anchorMax = new Vector2(1f, 1f);
scrollRt.offsetMin = new Vector2(5f, 5f);
scrollRt.offsetMax = new Vector2(-5f, -26f);
Image scrollBg = scrollGo.GetComponent<Image>();
scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
scrollBg.raycastTarget = true;
GameObject contentGo = new GameObject(
"Content",
typeof(RectTransform),
typeof(VerticalLayoutGroup),
typeof(ContentSizeFitter));
contentGo.transform.SetParent(scrollGo.transform, false);
_content = contentGo.GetComponent<RectTransform>();
_content.anchorMin = new Vector2(0f, 1f);
_content.anchorMax = new Vector2(1f, 1f);
_content.pivot = new Vector2(0.5f, 1f);
_content.anchoredPosition = Vector2.zero;
_content.sizeDelta = new Vector2(0f, 0f);
VerticalLayoutGroup layout = contentGo.GetComponent<VerticalLayoutGroup>();
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = true;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
layout.spacing = 2f;
layout.padding = new RectOffset(0, 0, 0, 0);
ContentSizeFitter fitter = contentGo.GetComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
ScrollRect scroll = scrollGo.GetComponent<ScrollRect>();
scroll.content = _content;
scroll.horizontal = false;
scroll.vertical = true;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.viewport = scrollRt;
_root.SetActive(false);
_visible = false;
LogService.LogInfo("[IdleSpectator] World Memory HUD ready (F9, compact)");
}
private static void DestroyOrphanHuds()
{
// Hot-reload / rename can leave old full-screen-looking cards in the canvas.
try
{
Canvas canvas = HudCanvas.Resolve();
if (canvas == null)
{
return;
}
for (int i = canvas.transform.childCount - 1; i >= 0; i--)
{
Transform child = canvas.transform.GetChild(i);
if (child == null)
{
continue;
}
string n = child.name;
if (n != "IdleSpectatorChronicleHud" && n != "IdleSpectatorWorldMemoryHud")
{
continue;
}
if (_root != null && child.gameObject == _root)
{
continue;
}
Object.Destroy(child.gameObject);
}
}
catch
{
// ignore
}
}
private static void ApplyRootPlacement()
{
if (_root == null)
{
return;
}
if (_rootRt == null)
{
_rootRt = _root.GetComponent<RectTransform>();
}
if (_rootRt == null)
{
return;
}
// Bottom-right, same canvas units as the dossier so PlayerConfig UI size scales both.
_rootRt.anchorMin = new Vector2(1f, 0f);
_rootRt.anchorMax = new Vector2(1f, 0f);
_rootRt.pivot = new Vector2(1f, 0f);
_rootRt.sizeDelta = new Vector2(PanelWidth, PanelHeight);
_rootRt.anchoredPosition = new Vector2(-ScreenInset, BottomClearance);
_rootRt.localScale = Vector3.one;
}
public static bool IsPointerOverPanel()
{
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
{
return false;
}
return RectTransformUtility.RectangleContainsScreenPoint(_rootRt, Input.mousePosition, null);
}
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);
}
pixelRect = Rect.MinMaxRect(xMin, yMin, xMax, yMax);
return pixelRect.width > 8f && pixelRect.height > 8f;
}
public static void SetVisible(bool visible)
{
EnsureBuilt();
if (_root == null)
{
return;
}
ApplyRootPlacement();
_visible = visible;
_root.SetActive(visible);
if (visible)
{
Rebuild(force: true);
}
}
public static void Toggle()
{
SetVisible(!Visible);
}
public static void UpdateLive()
{
if (!Visible)
{
return;
}
ApplyRootPlacement();
int count = Chronicle.MemoryCount;
string ageId = Chronicle.CurrentAgeId;
if (count != _lastCount || ageId != _lastAgeId)
{
Rebuild(force: true);
}
}
public static void Rebuild(bool force = false)
{
EnsureBuilt();
if (_content == null || (!force && !Visible))
{
return;
}
ApplyRootPlacement();
ClearRows();
IReadOnlyList<ChronicleEntry> entries = Chronicle.SnapshotMemory();
_lastCount = entries.Count;
_lastAgeId = Chronicle.CurrentAgeId;
string ageTitle = Chronicle.CurrentAgeName;
if (_titleText != null)
{
_titleText.text = string.IsNullOrEmpty(ageTitle)
? "World Memory"
: "World Memory · " + ageTitle;
}
if (entries.Count == 0)
{
AddEmpty("No landmarks yet");
return;
}
List<ChronicleEntry> display = BuildDisplayOrder(entries);
int shown = 0;
for (int i = 0; i < display.Count; i++)
{
ChronicleEntry entry = display[i];
if (entry == null)
{
continue;
}
GameObject row = entry.Kind == ChronicleKind.AgeChapter
? BuildChapterRow(entry)
: BuildClickableRow(entry, shown);
row.transform.SetParent(_content, false);
Rows.Add(row);
shown++;
if (shown >= MaxVisibleRows)
{
break;
}
}
}
private static List<ChronicleEntry> BuildDisplayOrder(IReadOnlyList<ChronicleEntry> chronological)
{
var ageOrder = new List<string>();
var chapters = new Dictionary<string, ChronicleEntry>();
var byAge = new Dictionary<string, List<ChronicleEntry>>();
for (int i = 0; i < chronological.Count; i++)
{
ChronicleEntry e = chronological[i];
if (e == null)
{
continue;
}
string ageId = string.IsNullOrEmpty(e.AgeId) ? "unknown" : e.AgeId;
if (e.Kind == ChronicleKind.AgeChapter)
{
chapters[ageId] = e;
if (!ageOrder.Contains(ageId))
{
ageOrder.Add(ageId);
}
continue;
}
if (!byAge.TryGetValue(ageId, out List<ChronicleEntry> list))
{
list = new List<ChronicleEntry>();
byAge[ageId] = list;
if (!ageOrder.Contains(ageId))
{
ageOrder.Add(ageId);
}
}
list.Add(e);
}
var result = new List<ChronicleEntry>();
for (int a = ageOrder.Count - 1; a >= 0; a--)
{
string ageId = ageOrder[a];
bool newestAge = a == ageOrder.Count - 1;
if (chapters.TryGetValue(ageId, out ChronicleEntry chapter))
{
result.Add(chapter);
}
else if (byAge.ContainsKey(ageId))
{
string name = byAge[ageId].Count > 0 ? byAge[ageId][0].AgeName : ageId;
if (string.IsNullOrEmpty(name))
{
name = ageId;
}
result.Add(new ChronicleEntry
{
Kind = ChronicleKind.AgeChapter,
AgeId = ageId,
AgeName = name,
Line = name,
LoreLine = name
});
}
if (!byAge.TryGetValue(ageId, out List<ChronicleEntry> landmarks) || landmarks.Count == 0)
{
continue;
}
int cap = newestAge ? MaxVisibleRows : 2;
int take = Mathf.Min(cap, landmarks.Count);
for (int i = landmarks.Count - 1; i >= landmarks.Count - take; i--)
{
result.Add(landmarks[i]);
}
}
return result;
}
private static void AddEmpty(string text)
{
GameObject empty = BuildStaticRow(text);
empty.transform.SetParent(_content, false);
Rows.Add(empty);
}
private static void ClearRows()
{
foreach (GameObject row in Rows)
{
if (row != null)
{
Object.Destroy(row);
}
}
Rows.Clear();
}
private static GameObject BuildChapterRow(ChronicleEntry entry)
{
string title = !string.IsNullOrEmpty(entry.AgeName) ? entry.AgeName : entry.HudLine;
GameObject go = new GameObject("Chapter", typeof(RectTransform), typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 14f, ChapterHeight);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = ChapterHeight;
le.preferredHeight = ChapterHeight;
Text label = HudCanvas.MakeText(go.transform, "Text", "— " + title + " —", 8);
Stretch(label.GetComponent<RectTransform>(), 2f);
label.alignment = TextAnchor.MiddleCenter;
label.color = new Color(0.7f, 0.74f, 0.82f, 1f);
label.fontStyle = FontStyle.Bold;
label.raycastTarget = false;
return go;
}
private static GameObject BuildStaticRow(string text)
{
GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = RowHeightMin;
le.preferredHeight = RowHeightMin;
Text label = HudCanvas.MakeText(go.transform, "Text", text, 8);
Stretch(label.GetComponent<RectTransform>(), 3f);
label.alignment = TextAnchor.MiddleLeft;
label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f);
label.resizeTextForBestFit = false;
label.horizontalOverflow = HorizontalWrapMode.Wrap;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.raycastTarget = false;
return go;
}
private static GameObject BuildClickableRow(ChronicleEntry entry, int index)
{
GameObject go = new GameObject(
$"Row_{index}",
typeof(RectTransform),
typeof(Image),
typeof(Button),
typeof(LayoutElement),
typeof(ContentSizeFitter));
RectTransform rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin);
LayoutElement le = go.GetComponent<LayoutElement>();
le.minHeight = RowHeightMin;
le.preferredHeight = -1f;
ContentSizeFitter rowFit = go.GetComponent<ContentSizeFitter>();
rowFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
rowFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
Image bg = go.GetComponent<Image>();
bg.color = entry.IsLegend
? new Color(0.95f, 0.85f, 0.45f, 0.06f)
: new Color(1f, 1f, 1f, 0.03f);
bg.raycastTarget = true;
Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize);
RectTransform kindRt = kindIcon.GetComponent<RectTransform>();
kindRt.anchorMin = new Vector2(0f, 1f);
kindRt.anchorMax = new Vector2(0f, 1f);
kindRt.pivot = new Vector2(0.5f, 1f);
kindRt.anchoredPosition = new Vector2(9f, -5f);
kindIcon.raycastTarget = false;
HudIcons.Apply(kindIcon, HudIcons.ForChronicleKind(entry.Kind));
Text label = HudCanvas.MakeText(go.transform, "Text", entry.DisplayLineRich, 8);
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = new Vector2(0f, 0f);
labelRt.anchorMax = new Vector2(1f, 1f);
labelRt.offsetMin = new Vector2(18f, 2f);
labelRt.offsetMax = new Vector2(-4f, -2f);
label.alignment = TextAnchor.UpperLeft;
label.color = ColorForKind(entry.Kind);
label.supportRichText = true;
label.resizeTextForBestFit = false;
label.horizontalOverflow = HorizontalWrapMode.Wrap;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.raycastTarget = false;
ContentSizeFitter textFit = label.gameObject.AddComponent<ContentSizeFitter>();
textFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
textFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
Button button = go.GetComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.2f, 1.2f, 1.25f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
ChronicleEntry captured = entry;
button.onClick.AddListener(new UnityAction(() =>
{
bool ok = Chronicle.JumpTo(captured);
LogService.LogInfo($"[IdleSpectator][MEMORY] jump ok={ok} line={captured.DisplayLine}");
}));
return go;
}
private static Color ColorForKind(ChronicleKind kind)
{
switch (kind)
{
case ChronicleKind.Death:
return new Color(0.78f, 0.72f, 0.78f, 1f);
case ChronicleKind.Kill:
return new Color(0.95f, 0.58f, 0.48f, 1f);
case ChronicleKind.Lover:
return new Color(0.95f, 0.58f, 0.78f, 1f);
case ChronicleKind.Friend:
return new Color(0.58f, 0.85f, 0.95f, 1f);
case ChronicleKind.World:
return new Color(0.95f, 0.85f, 0.48f, 1f);
case ChronicleKind.AgeChapter:
return new Color(0.7f, 0.74f, 0.82f, 1f);
default:
return new Color(0.88f, 0.88f, 0.9f, 1f);
}
}
private static void Stretch(RectTransform rt, float pad)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(pad, 1f);
rt.offsetMax = new Vector2(-pad, -1f);
}
}