railroader-setons-special-s.../src/Modules/CarCards/CardUi.cs
seton cc552a0246 CarCards: fanned consist dock with notes, actions, and WQ cut dividers
Shows the selected cut as a fan of cards with waybill, notes, and
couple/handbrake/locate actions. WaypointQueue cuts appear as dividers
when that mod is present.
2026-09-11 15:19:23 -04:00

520 lines
19 KiB
C#

using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace S3.Modules.CarCards;
static class CardUi
{
public const float Round = 8f;
static Sprite? _white;
static Sprite? _round;
static Sprite? _pin;
static Sprite? _dots;
static Sprite? _x;
static Sprite? _couple;
static Sprite? _brake;
static Sprite? _locate;
static Sprite? _cut;
static Sprite? _drop;
static Sprite? _pickup;
static TMP_FontAsset? _tmp;
static Font? _uiFont;
public static Sprite White()
{
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);
_white.name = "S3CardWhite";
return _white;
}
public static Sprite RoundSprite()
{
if (_round != null) return _round;
// 8 UI-unit corners. uGUI slice size is border * canvasRefPPU / spritePPU
// (canvas reference PPU is 100), so sprite PPU must be aa * 100 to keep
// the radius at 8 while the texture is supersampled.
const int radiusUi = 8;
const int aa = 8;
const int r = radiusUi * aa;
const int s = r * 2 + 32;
const float ppu = aa * 100f;
var tex = new Texture2D(s, s, TextureFormat.RGBA32, false)
{
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
hideFlags = HideFlags.HideAndDontSave,
};
var px = new Color32[s * s];
for (int y = 0; y < s; y++)
{
for (int x = 0; x < s; x++)
{
float a = CoverRound(x + 0.5f, y + 0.5f, s, r);
byte b = (byte)Mathf.Clamp(Mathf.RoundToInt(a * 255f), 0, 255);
px[y * s + x] = new Color32(255, 255, 255, b);
}
}
tex.SetPixels32(px);
tex.Apply(false, true);
_round = Sprite.Create(
tex, new Rect(0f, 0f, s, s), new Vector2(0.5f, 0.5f),
ppu, 0, SpriteMeshType.FullRect, new Vector4(r, r, r, r));
_round.name = "S3Round8";
return _round;
}
static float CoverRound(float x, float y, int s, int r)
{
float dx = x < r ? r - x : (x > s - r ? x - (s - r) : 0f);
float dy = y < r ? r - y : (y > s - r ? y - (s - r) : 0f);
if (dx == 0f || dy == 0f) return 1f;
float d = Mathf.Sqrt(dx * dx + dy * dy);
return Mathf.Clamp01(r + 0.5f - d);
}
public static Sprite PinSprite()
{
if (_pin != null) return _pin;
_pin = MakeGlyph(32, (tex, s) =>
{
FillCircle(tex, s, 16, 12, 7, Color.white);
FillCircle(tex, s, 16, 12, 3, new Color(0, 0, 0, 0));
FillTri(tex, s, 16, 16, 10, 28, 22, 16, Color.white);
});
_pin.name = "S3Pin";
return _pin;
}
public static Sprite DotsSprite()
{
if (_dots != null) return _dots;
_dots = MakeGlyph(32, (tex, s) =>
{
FillCircle(tex, s, 16, 8, 3, Color.white);
FillCircle(tex, s, 16, 16, 3, Color.white);
FillCircle(tex, s, 16, 24, 3, Color.white);
});
_dots.name = "S3Dots";
return _dots;
}
public static Sprite XSprite()
{
if (_x != null) return _x;
_x = MakeGlyph(32, (tex, s) =>
{
StrokeLine(tex, s, 8, 8, 24, 24, 2.2f, Color.white);
StrokeLine(tex, s, 24, 8, 8, 24, 2.2f, Color.white);
});
_x.name = "S3X";
return _x;
}
public static Sprite CoupleSprite()
{
if (_couple != null) return _couple;
_couple = MakeGlyph(32, (tex, s) =>
{
StrokeLine(tex, s, 6, 10, 14, 10, 2.2f, Color.white);
StrokeLine(tex, s, 14, 10, 14, 22, 2.2f, Color.white);
StrokeLine(tex, s, 14, 22, 6, 22, 2.2f, Color.white);
StrokeLine(tex, s, 26, 10, 18, 10, 2.2f, Color.white);
StrokeLine(tex, s, 18, 10, 18, 22, 2.2f, Color.white);
StrokeLine(tex, s, 18, 22, 26, 22, 2.2f, Color.white);
});
_couple.name = "S3Couple";
return _couple;
}
public static Sprite BrakeSprite()
{
if (_brake != null) return _brake;
_brake = MakeGlyph(32, (tex, s) =>
{
FillCircle(tex, s, 16, 14, 9, Color.white);
FillCircle(tex, s, 16, 14, 5, new Color(0, 0, 0, 0));
StrokeLine(tex, s, 16, 14, 16, 28, 2.2f, Color.white);
});
_brake.name = "S3Brake";
return _brake;
}
public static Sprite LocateSprite()
{
if (_locate != null) return _locate;
_locate = MakeGlyph(32, (tex, s) =>
{
FillCircle(tex, s, 16, 16, 8, Color.white);
FillCircle(tex, s, 16, 16, 4, new Color(0, 0, 0, 0));
StrokeLine(tex, s, 16, 4, 16, 10, 2f, Color.white);
StrokeLine(tex, s, 16, 22, 16, 28, 2f, Color.white);
StrokeLine(tex, s, 4, 16, 10, 16, 2f, Color.white);
StrokeLine(tex, s, 22, 16, 28, 16, 2f, Color.white);
});
_locate.name = "S3Locate";
return _locate;
}
public static Sprite CutSprite()
{
if (_cut != null) return _cut;
_cut = MakeGlyph(64, (px, s) =>
{
StrokeCar(px, s, 4f, 18f, 22f, 28f, 2.6f);
StrokeCar(px, s, 38f, 18f, 22f, 28f, 2.6f);
});
_cut.name = "S3Cut";
return _cut;
}
public static Sprite DropSprite()
{
if (_drop != null) return _drop;
_drop = MakeGlyph(64, (px, s) =>
{
StrokeCar(px, s, 16f, 8f, 32f, 24f, 2.6f);
StrokeLine(px, s, 32f, 36f, 32f, 50f, 2.8f, Color.white);
FillTri(px, s, 32f, 58f, 21f, 46f, 43f, 46f, Color.white);
});
_drop.name = "S3Drop";
return _drop;
}
public static Sprite PickupSprite()
{
if (_pickup != null) return _pickup;
_pickup = MakeGlyph(64, (px, s) =>
{
StrokeCar(px, s, 16f, 32f, 32f, 24f, 2.6f);
StrokeLine(px, s, 32f, 28f, 32f, 14f, 2.8f, Color.white);
FillTri(px, s, 32f, 6f, 21f, 18f, 43f, 18f, Color.white);
});
_pickup.name = "S3Pickup";
return _pickup;
}
static void StrokeCar(Color[] px, int s, float x, float y, float w, float h, float thick)
{
StrokeLine(px, s, x, y, x + w, y, thick, Color.white);
StrokeLine(px, s, x + w, y, x + w, y + h, thick, Color.white);
StrokeLine(px, s, x + w, y + h, x, y + h, thick, Color.white);
StrokeLine(px, s, x, y + h, x, y, thick, Color.white);
float wy = y + 4f;
FillCircle(px, s, x + w * 0.28f, wy, 3.2f, Color.white);
FillCircle(px, s, x + w * 0.72f, wy, 3.2f, Color.white);
}
public static void TintButton(Button btn, bool on, Color onColor)
{
if (btn == null) return;
var img = btn.targetGraphic as Image;
if (img == null) return;
img.color = on ? onColor : new Color(0.22f, 0.22f, 0.24f, 1f);
}
static Sprite MakeGlyph(int s, System.Action<Color[], int> draw)
{
var tex = new Texture2D(s, s, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Bilinear,
hideFlags = HideFlags.HideAndDontSave,
};
var px = new Color[s * s];
draw(px, s);
tex.SetPixels(px);
tex.Apply(false, true);
return Sprite.Create(tex, new Rect(0f, 0f, s, s), new Vector2(0.5f, 0.5f), 100f);
}
static void FillCircle(Color[] px, int s, float cx, float cy, float r, Color c)
{
for (int y = 0; y < s; y++)
for (int x = 0; x < s; x++)
{
float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
float a = Mathf.Clamp01(r + 0.5f - d);
if (a <= 0f) continue;
int i = y * s + x;
if (c.a <= 0.01f) px[i] = Color.clear;
else
{
Color dcol = c;
dcol.a *= a;
px[i] = Blend(px[i], dcol);
}
}
}
static void FillTri(Color[] px, int s, float x1, float y1, float x2, float y2, float x3, float y3, Color c)
{
for (int y = 0; y < s; y++)
for (int x = 0; x < s; x++)
{
float pxp = x + 0.5f, pyp = y + 0.5f;
if (!InTri(pxp, pyp, x1, y1, x2, y2, x3, y3)) continue;
px[y * s + x] = Blend(px[y * s + x], c);
}
}
static bool InTri(float px, float py, float x1, float y1, float x2, float y2, float x3, float y3)
{
float d1 = Sign(px, py, x1, y1, x2, y2);
float d2 = Sign(px, py, x2, y2, x3, y3);
float d3 = Sign(px, py, x3, y3, x1, y1);
bool hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
bool hasPos = d1 > 0 || d2 > 0 || d3 > 0;
return !(hasNeg && hasPos);
}
static float Sign(float px, float py, float x1, float y1, float x2, float y2) =>
(px - x2) * (y1 - y2) - (x1 - x2) * (py - y2);
static void StrokeLine(Color[] px, int s, float x0, float y0, float x1, float y1, float thick, Color c)
{
for (int y = 0; y < s; y++)
for (int x = 0; x < s; x++)
{
float d = DistToSeg(x + 0.5f, y + 0.5f, x0, y0, x1, y1);
float a = Mathf.Clamp01(thick + 0.5f - d);
if (a <= 0f) continue;
Color dcol = c;
dcol.a *= a;
px[y * s + x] = Blend(px[y * s + x], dcol);
}
}
static float DistToSeg(float px, float py, float x0, float y0, float x1, float y1)
{
float dx = x1 - x0, dy = y1 - y0;
float l2 = dx * dx + dy * dy;
if (l2 < 0.0001f) return Vector2.Distance(new Vector2(px, py), new Vector2(x0, y0));
float t = Mathf.Clamp01(((px - x0) * dx + (py - y0) * dy) / l2);
return Vector2.Distance(new Vector2(px, py), new Vector2(x0 + t * dx, y0 + t * dy));
}
static Color Blend(Color under, Color over)
{
float a = over.a + under.a * (1f - over.a);
if (a < 0.0001f) return Color.clear;
return new Color(
(over.r * over.a + under.r * under.a * (1f - over.a)) / a,
(over.g * over.a + under.g * under.a * (1f - over.a)) / a,
(over.b * over.a + under.b * under.a * (1f - over.a)) / a,
a);
}
public static TMP_FontAsset? TmpFont()
{
if (_tmp != null) return _tmp;
try { _tmp = TMP_Settings.defaultFontAsset; } catch { }
if (_tmp == null)
{
var all = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (all != null && all.Length > 0) _tmp = all[0];
}
return _tmp;
}
public static Font UiFont()
{
if (_uiFont != null) return _uiFont;
_uiFont = Resources.GetBuiltinResource<Font>("Arial.ttf");
return _uiFont;
}
public static RectTransform Rt(GameObject go) => (RectTransform)go.transform;
public static Image MakeImage(RectTransform parent, string name, Color color, bool raycast, bool round = true)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
var rt = Rt(go);
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>();
if (round)
{
img.sprite = RoundSprite();
img.type = Image.Type.Sliced;
img.pixelsPerUnitMultiplier = 1f;
}
else
{
img.sprite = White();
img.type = Image.Type.Simple;
}
img.color = color;
img.raycastTarget = raycast;
return img;
}
public static Image MakeIcon(RectTransform parent, string name, Sprite sprite, Color color, bool raycast)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
var rt = Rt(go);
rt.SetParent(parent, false);
var img = go.GetComponent<Image>();
img.sprite = sprite;
img.type = Image.Type.Simple;
img.preserveAspect = true;
img.color = color;
img.raycastTarget = raycast;
return img;
}
public static TextMeshProUGUI Tmp(
RectTransform parent, string name, float size, Color color,
FontStyles style = FontStyles.Normal, TextAlignmentOptions align = TextAlignmentOptions.TopLeft)
{
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
var rt = Rt(go);
rt.SetParent(parent, false);
var tmp = go.GetComponent<TextMeshProUGUI>();
tmp.fontSize = size;
tmp.color = color;
tmp.fontStyle = style;
tmp.alignment = align;
tmp.raycastTarget = false;
tmp.textWrappingMode = TextWrappingModes.Normal;
tmp.overflowMode = TextOverflowModes.Truncate;
var font = TmpFont();
if (font != null) tmp.font = font;
return tmp;
}
public static Button Button(RectTransform parent, string name, string label, Vector2 size)
{
var img = MakeImage(parent, name, new Color(0.22f, 0.22f, 0.24f, 1f), raycast: true);
img.rectTransform.sizeDelta = size;
var btn = img.gameObject.AddComponent<Button>();
btn.targetGraphic = img;
var colors = btn.colors;
colors.highlightedColor = new Color(0.32f, 0.32f, 0.34f, 1f);
colors.pressedColor = new Color(0.16f, 0.16f, 0.18f, 1f);
btn.colors = colors;
if (!string.IsNullOrEmpty(label))
{
var text = Tmp(img.rectTransform, "Label", 12f, Color.white, FontStyles.Normal, TextAlignmentOptions.Center);
Stretch(text.rectTransform, 4f);
text.text = label;
text.raycastTarget = false;
}
return btn;
}
public static Button IconButton(RectTransform parent, string name, Sprite icon, Vector2 size)
{
var btn = Button(parent, name, "", size);
var img = MakeIcon(btn.GetComponent<RectTransform>(), "Icon", icon, Color.white, raycast: false);
img.rectTransform.anchorMin = img.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
img.rectTransform.sizeDelta = new Vector2(size.x * 0.55f, size.y * 0.55f);
return btn;
}
public static Toggle Toggle(RectTransform parent, string name, string label)
{
var img = MakeImage(parent, name, new Color(0.18f, 0.18f, 0.2f, 1f), raycast: true);
var tog = img.gameObject.AddComponent<Toggle>();
tog.targetGraphic = img;
var check = MakeImage(img.rectTransform, "Check", new Color(0.45f, 0.72f, 0.42f, 1f), raycast: false);
check.rectTransform.anchorMin = new Vector2(0f, 0.5f);
check.rectTransform.anchorMax = new Vector2(0f, 0.5f);
check.rectTransform.pivot = new Vector2(0f, 0.5f);
check.rectTransform.anchoredPosition = new Vector2(6f, 0f);
check.rectTransform.sizeDelta = new Vector2(12f, 12f);
tog.graphic = check;
var text = Tmp(img.rectTransform, "Label", 12f, Color.white, FontStyles.Normal, TextAlignmentOptions.MidlineLeft);
text.rectTransform.anchorMin = new Vector2(0f, 0f);
text.rectTransform.anchorMax = new Vector2(1f, 1f);
text.rectTransform.offsetMin = new Vector2(22f, 0f);
text.rectTransform.offsetMax = Vector2.zero;
text.text = label;
return tog;
}
public static InputField NotesField(RectTransform parent, Vector2 size)
{
var img = MakeImage(parent, "Notes", new Color(0.97f, 0.94f, 0.86f, 1f), raycast: true);
img.rectTransform.sizeDelta = size;
var field = img.gameObject.AddComponent<InputField>();
var textGo = new GameObject("Text", typeof(RectTransform), typeof(Text));
var textRt = Rt(textGo);
textRt.SetParent(img.rectTransform, false);
Stretch(textRt, 4f);
var text = textGo.GetComponent<Text>();
text.font = UiFont();
text.fontSize = 11;
text.color = new Color(0.22f, 0.18f, 0.14f, 1f);
text.supportRichText = false;
text.alignment = TextAnchor.UpperLeft;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Truncate;
field.textComponent = text;
field.lineType = InputField.LineType.MultiLineNewline;
field.customCaretColor = true;
field.caretColor = new Color(0.2f, 0.15f, 0.1f, 1f);
field.placeholder = null;
return field;
}
public static Image AddShadow(RectTransform host)
{
var sh = MakeImage(host, "Shadow", new Color(0f, 0f, 0f, 0.42f), raycast: false);
Stretch(sh.rectTransform, 0f);
sh.rectTransform.anchoredPosition = new Vector2(3f, -3f);
sh.rectTransform.SetAsFirstSibling();
return sh;
}
public static Image AddResizeGrip(RectTransform panel, System.Action? onEnd, float minW, float minH)
{
var grip = MakeImage(panel, "Resize", new Color(1f, 1f, 1f, 0.28f), raycast: true);
grip.rectTransform.anchorMin = grip.rectTransform.anchorMax = new Vector2(1f, 0f);
grip.rectTransform.pivot = new Vector2(1f, 0f);
grip.rectTransform.anchoredPosition = new Vector2(-3f, 3f);
grip.rectTransform.sizeDelta = new Vector2(14f, 14f);
grip.gameObject.AddComponent<PanelResize>().Bind(panel, onEnd, minW, minH);
grip.transform.SetAsLastSibling();
return grip;
}
public static Scrollbar HScroll(RectTransform parent)
{
var bg = MakeImage(parent, "HScroll", new Color(0.12f, 0.12f, 0.14f, 0.7f), raycast: true);
var handle = MakeImage(bg.rectTransform, "Handle", new Color(0.55f, 0.55f, 0.58f, 0.95f), raycast: true);
Stretch(handle.rectTransform, 2f);
var sb = bg.gameObject.AddComponent<Scrollbar>();
sb.handleRect = handle.rectTransform;
sb.targetGraphic = handle;
sb.direction = Scrollbar.Direction.LeftToRight;
sb.transition = Selectable.Transition.ColorTint;
return sb;
}
public static void Stretch(RectTransform rt, float pad)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(pad, pad);
rt.offsetMax = new Vector2(-pad, -pad);
rt.pivot = new Vector2(0.5f, 0.5f);
}
public static void BottomLeft(RectTransform rt, Vector2 pos, Vector2 size)
{
rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f);
rt.pivot = new Vector2(0f, 0f);
rt.anchoredPosition = pos;
rt.sizeDelta = size;
}
public static Color BandInk(Color band)
{
float lum = band.r * 0.3f + band.g * 0.59f + band.b * 0.11f;
return lum > 0.55f ? new Color(0.16f, 0.14f, 0.12f, 1f) : Color.white;
}
}