railroader-setons-special-s.../src/Modules/QuickActions/PieCenterHud.cs
seton 5187c03ebe QuickActions: outer-ring cut/couple actions and consist hover wheel
Adds end-gear couple/air/cut on the vanilla pie, plus a consist wheel
for set-lead, brakes, bleed, air, and idle without opening extra windows.
2026-09-11 15:19:23 -04:00

400 lines
15 KiB
C#

using System.Collections.Generic;
using HarmonyLib;
using TMPro;
using UI.ContextMenu;
using UnityEngine;
using UnityEngine.UI;
using GameContextMenu = UI.ContextMenu.ContextMenu;
namespace S3.Modules.QuickActions;
/// <summary>
/// Replaces the factory reporting-mark hole with a panel matching the secondary
/// wedges, plus a rim gauge: weight fills the arc, colored tickers mark max TE,
/// current TE, and grade need.
/// </summary>
sealed class PieCenterHud
{
static readonly Color Panel = new Color(0.07f, 0.07f, 0.065f, 0.97f);
static readonly Color Fg = new Color(0xca / 255f, 0xc4 / 255f, 0xb8 / 255f, 1f);
static readonly Color FgDim = new Color(0xca / 255f, 0xc4 / 255f, 0xb8 / 255f, 0.88f);
static readonly Color Border = new Color(0xcd / 255f, 0xb9 / 255f, 0x93 / 255f, 1f);
static readonly Color Track = new Color(0.16f, 0.14f, 0.11f, 0.96f);
static readonly Color WeightFill = new Color(1f, 0.92f, 0.70f, 0.88f);
static readonly Color MaxTe = new Color(1f, 0.85f, 0.22f, 1f);
static readonly Color CurrentTe = new Color(0.15f, 0.92f, 1f, 1f);
static readonly Color HereTe = new Color(1f, 0.55f, 0.12f, 1f);
static readonly Color NeedTe = new Color(1f, 0.32f, 0.18f, 1f);
const float BorderPx = 2.4f;
const float GaugeThick = 7f;
const float TickInward = 12f;
const float TickWidth = 3.4f;
// Min at 8 o'clock (left). Values climb clockwise around the rim.
// WedgeImage itself only sweeps CCW, so the track mesh starts at the max end.
const float GaugeMin = 250f;
const float GaugeRange = 320f;
static Sprite? _white;
static Sprite? _circle;
readonly RectTransform _gaugeRoot;
readonly WedgeImage _weight;
readonly RectTransform _tickMax;
readonly RectTransform _tickCurrent;
readonly RectTransform _tickHere;
readonly RectTransform _tickNeed;
readonly Image _tickMaxImg;
readonly Image _tickCurrentImg;
readonly Image _tickHereImg;
readonly Image _tickNeedImg;
readonly TMP_Text _road;
readonly TMP_Text _stats;
readonly TMP_Text _preview;
readonly float _holeR;
readonly float _discR;
readonly float _fontRoad;
readonly float _fontBody;
readonly List<Graphic> _hiddenFactory = new();
PieCenterHud(
RectTransform gaugeRoot, WedgeImage weight,
RectTransform tickMax, RectTransform tickCurrent, RectTransform tickHere, RectTransform tickNeed,
Image tickMaxImg, Image tickCurrentImg, Image tickHereImg, Image tickNeedImg,
TMP_Text road, TMP_Text stats, TMP_Text preview,
float holeR, float discR, float fontRoad, float fontBody,
List<Graphic> hiddenFactory)
{
_gaugeRoot = gaugeRoot;
_weight = weight;
_tickMax = tickMax;
_tickCurrent = tickCurrent;
_tickHere = tickHere;
_tickNeed = tickNeed;
_tickMaxImg = tickMaxImg;
_tickCurrentImg = tickCurrentImg;
_tickHereImg = tickHereImg;
_tickNeedImg = tickNeedImg;
_road = road;
_stats = stats;
_preview = preview;
_holeR = holeR;
_discR = discR;
_fontRoad = fontRoad;
_fontBody = fontBody;
_hiddenFactory = hiddenFactory;
}
public static PieCenterHud? TryCreate(GameContextMenu menu, Transform overlay, float pieRadius, float innerFrac)
{
try
{
var t = Traverse.Create(menu);
var centerRt = t.Field<RectTransform>("centerRectTransform").Value;
var src = t.Field<TMP_Text>("centerLabel").Value;
if (src == null) return null;
float srcSize = src.fontSize > 1f ? src.fontSize : 14f;
float fontRoad = Mathf.Clamp(srcSize, 13f, 16f);
float fontBody = Mathf.Clamp(srcSize * 0.88f, 11.5f, 14f);
var font = src.font;
Material? mat = src.fontSharedMaterial;
var hidden = new List<Graphic>();
HideFactory(centerRt, src, hidden);
float holeR = Mathf.Max(36f, pieRadius * Mathf.Clamp(innerFrac, 0.35f, 0.7f));
float discR = Mathf.Max(28f, holeR - BorderPx - GaugeThick);
var root = new GameObject("S3_CenterHud", typeof(RectTransform), typeof(LayoutElement));
var rt = (RectTransform)root.transform;
rt.SetParent(overlay, false);
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
rt.localPosition = Vector3.zero;
rt.sizeDelta = new Vector2(holeR * 2f, holeR * 2f);
root.GetComponent<LayoutElement>().ignoreLayout = true;
rt.SetAsFirstSibling();
var disc = MakeImage(rt, "Disc", CircleSprite(), Panel, raycast: false);
disc.rectTransform.sizeDelta = new Vector2(discR * 2f, discR * 2f);
var gaugeGo = new GameObject("Gauge", typeof(RectTransform));
var gaugeRt = (RectTransform)gaugeGo.transform;
gaugeRt.SetParent(rt, false);
gaugeRt.anchorMin = gaugeRt.anchorMax = new Vector2(0.5f, 0.5f);
gaugeRt.pivot = new Vector2(0.5f, 0.5f);
gaugeRt.localPosition = Vector3.zero;
gaugeRt.sizeDelta = new Vector2(holeR * 2f, holeR * 2f);
float borderInner = (holeR - BorderPx) / holeR;
float gaugeInner = (holeR - BorderPx - GaugeThick) / holeR;
MakeWedge(gaugeRt, "Border", Border, borderInner, 0f, 360f);
MakeWedge(gaugeRt, "Track", Track, gaugeInner, TrackStart(), GaugeRange);
var weight = MakeWedge(gaugeRt, "Weight", WeightFill, gaugeInner, TrackStart(), 1f);
float tickLen = GaugeThick + TickInward;
var tickMax = MakeTick(gaugeRt, "TickMax", MaxTe, TickWidth, tickLen, out Image tickMaxImg);
var tickCur = MakeTick(gaugeRt, "TickCurrent", CurrentTe, TickWidth, tickLen, out Image tickCurImg);
var tickHere = MakeTick(gaugeRt, "TickHere", HereTe, TickWidth, tickLen, out Image tickHereImg);
var tickNeed = MakeTick(gaugeRt, "TickNeed", NeedTe, TickWidth, tickLen, out Image tickNeedImg);
float textW = discR * 1.62f;
var road = MakeLabel(rt, "Road", font, mat, fontRoad, Fg, src.fontStyle, textW, fontRoad + 6f);
var stats = MakeLabel(rt, "Stats", font, mat, fontBody, FgDim, FontStyles.Normal, textW, fontBody * 2.6f);
var preview = MakeLabel(rt, "Preview", font, mat, fontBody, Fg, FontStyles.Normal, textW, fontBody * 2.8f);
stats.gameObject.SetActive(false);
preview.gameObject.SetActive(false);
return new PieCenterHud(
gaugeRt, weight,
tickMax, tickCur, tickHere, tickNeed,
tickMaxImg, tickCurImg, tickHereImg, tickNeedImg,
road, stats, preview,
holeR, discR, fontRoad, fontBody, hidden);
}
catch
{
return null;
}
}
public void RestoreFactory()
{
for (int i = 0; i < _hiddenFactory.Count; i++)
{
if (_hiddenFactory[i] != null)
_hiddenFactory[i].enabled = true;
}
_hiddenFactory.Clear();
}
void KeepFactoryHidden()
{
for (int i = 0; i < _hiddenFactory.Count; i++)
{
if (_hiddenFactory[i] != null && _hiddenFactory[i].enabled)
_hiddenFactory[i].enabled = false;
}
}
public void Tick(string road, string? preview, TrainReadout.Snapshot? stats, bool showStats, bool showGauge)
{
KeepFactoryHidden();
bool hintOnly = !string.IsNullOrEmpty(preview);
_road.gameObject.SetActive(!hintOnly);
if (!hintOnly)
{
_road.text = road;
_road.fontSize = _fontRoad;
FitLabel(_road, road, _fontRoad, 1);
}
bool statsOn = !hintOnly && showStats && stats.HasValue;
_stats.gameObject.SetActive(statsOn);
if (statsOn)
FitLabel(_stats, stats!.Value.StatsBlock(), _fontBody, 2);
_preview.gameObject.SetActive(hintOnly);
if (hintOnly)
FitLabel(_preview, preview!, _fontBody, 3);
if (hintOnly)
_preview.rectTransform.localPosition = Vector3.zero;
else
StackText(statsOn, previewOn: false);
bool gaugeOn = !hintOnly && showGauge && stats.HasValue;
_gaugeRoot.gameObject.SetActive(gaugeOn);
if (!gaugeOn) return;
var s = stats.Value;
float scale = Mathf.Max(1f, s.RatedTeLbf, s.CurrentTeLbf, s.HereLbf, s.NeedLbf, s.WeightMarkLbf);
SetArc(_weight, s.WeightMarkLbf / scale);
PlaceTick(_tickMax, s.RatedTeLbf / scale);
PlaceTick(_tickCurrent, s.CurrentTeLbf / scale);
PlaceTick(_tickHere, s.HereLbf / scale);
bool route = s.HasWaypoint && s.NeedLbf > 0.5f;
_tickNeed.gameObject.SetActive(route);
if (route)
PlaceTick(_tickNeed, s.NeedLbf / scale);
_tickMaxImg.color = MaxTe;
_tickCurrentImg.color = CurrentTe;
_tickHereImg.color = HereTe;
_tickNeedImg.color = NeedTe;
}
void StackText(bool statsOn, bool previewOn)
{
float roadH = _road.rectTransform.sizeDelta.y;
float statsH = statsOn ? _stats.rectTransform.sizeDelta.y : 0f;
float prevH = previewOn ? _preview.rectTransform.sizeDelta.y : 0f;
float gap = 5f;
float total = roadH + (statsOn ? gap + statsH : 0f) + (previewOn ? gap + prevH : 0f);
float y = total * 0.5f - roadH * 0.5f;
_road.rectTransform.localPosition = new Vector3(0f, y, 0f);
y -= roadH * 0.5f;
if (statsOn)
{
y -= gap + statsH * 0.5f;
_stats.rectTransform.localPosition = new Vector3(0f, y, 0f);
y -= statsH * 0.5f;
}
if (previewOn)
{
y -= gap + prevH * 0.5f;
_preview.rectTransform.localPosition = new Vector3(0f, y, 0f);
}
}
void FitLabel(TMP_Text tmp, string text, float size, int maxLines)
{
tmp.enableAutoSizing = false;
tmp.fontSize = size;
tmp.text = text;
tmp.lineSpacing = 2f;
float maxW = _discR * 1.62f;
tmp.ForceMeshUpdate();
Vector2 pref = tmp.GetPreferredValues(text, maxW, size * (maxLines * 1.35f + 0.4f));
float h = Mathf.Clamp(pref.y, size * 1.15f, size * maxLines * 1.4f);
float w = Mathf.Min(maxW, Mathf.Max(24f, pref.x));
tmp.rectTransform.sizeDelta = new Vector2(w, h);
}
static float TrackStart() => Mathf.Repeat(GaugeMin - GaugeRange, 360f);
static float ClockwiseDeg(float t) => Mathf.Repeat(GaugeMin - Mathf.Clamp01(t) * GaugeRange, 360f);
void SetArc(WedgeImage wedge, float t)
{
float span = Mathf.Clamp01(t) * GaugeRange;
wedge.startAngle = ClockwiseDeg(t);
wedge.angleRange = span;
wedge.SetVerticesDirty();
}
void PlaceTick(RectTransform tick, float t)
{
float deg = ClockwiseDeg(t);
float rad = deg * Mathf.Deg2Rad;
float r = _holeR - BorderPx;
tick.localPosition = new Vector3(Mathf.Cos(rad) * r, Mathf.Sin(rad) * r, 0f);
tick.localRotation = Quaternion.Euler(0f, 0f, deg - 90f);
tick.gameObject.SetActive(true);
}
static void HideFactory(RectTransform? centerRt, TMP_Text src, List<Graphic> hidden)
{
void Hide(Graphic g)
{
if (g == null || !g.enabled) return;
g.enabled = false;
hidden.Add(g);
}
Hide(src);
if (centerRt == null) return;
foreach (var g in centerRt.GetComponentsInChildren<Graphic>(true))
Hide(g);
}
static TMP_Text MakeLabel(
RectTransform parent, string name, TMP_FontAsset? font, Material? mat,
float size, Color color, FontStyles style, float w, float h)
{
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
rt.sizeDelta = new Vector2(w, h);
var tmp = go.GetComponent<TextMeshProUGUI>();
tmp.enableAutoSizing = false;
tmp.fontSize = size;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = color;
tmp.fontStyle = style;
tmp.raycastTarget = false;
tmp.overflowMode = TextOverflowModes.Truncate;
tmp.textWrappingMode = TextWrappingModes.Normal;
tmp.lineSpacing = 2f;
if (font != null) tmp.font = font;
if (mat != null) tmp.fontSharedMaterial = mat;
return tmp;
}
static Image MakeImage(RectTransform parent, string name, Sprite sprite, Color color, bool raycast)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
var img = go.GetComponent<Image>();
img.sprite = sprite;
img.color = color;
img.raycastTarget = raycast;
img.preserveAspect = true;
return img;
}
static WedgeImage MakeWedge(RectTransform parent, string name, Color color, float innerFrac, float start, float range)
{
var go = new GameObject(name, typeof(RectTransform), typeof(WedgeImage));
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
rt.sizeDelta = parent.sizeDelta;
var w = go.GetComponent<WedgeImage>();
w.sprite = WhiteSprite();
w.color = color;
w.raycastTarget = false;
w.innerRadius = innerFrac;
w.startAngle = start;
w.angleRange = range;
return w;
}
static RectTransform MakeTick(RectTransform parent, string name, Color color, float width, float length, out Image img)
{
img = MakeImage(parent, name, WhiteSprite(), color, raycast: false);
img.preserveAspect = false;
var rt = img.rectTransform;
rt.pivot = new Vector2(0.5f, 1f);
rt.sizeDelta = new Vector2(width, length);
return rt;
}
static Sprite WhiteSprite()
{
if (_white != null) return _white;
var tex = Texture2D.whiteTexture;
_white = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 4f);
return _white;
}
static Sprite CircleSprite()
{
if (_circle != null) return _circle;
const int radius = 16;
int size = radius * 2;
var tex = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false);
tex.filterMode = FilterMode.Bilinear;
var pixels = new Color32[size * size];
float c = radius - 0.5f;
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
float dist = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c));
byte a = (byte)(Mathf.Clamp01(radius - dist) * 255f);
pixels[y * size + x] = new Color32(255, 255, 255, a);
}
tex.SetPixels32(pixels);
tex.Apply(updateMipmaps: false, makeNoLongerReadable: true);
_circle = Sprite.Create(tex, new Rect(0f, 0f, size, size), new Vector2(0.5f, 0.5f), 100f);
return _circle;
}
}