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.
This commit is contained in:
Seton Carmichael 2026-09-11 15:19:23 -04:00
parent 5187c03ebe
commit cc552a0246
16 changed files with 3964 additions and 0 deletions

View file

@ -23,6 +23,7 @@ I originally planned on releasing individual mods, but considering my workflow o
| Profiler | Unified in-game performance overlay with hitch attribution captures. Console: `/rpf overlay`, `/s3bench` |
| Misc Tweaks | Small QoL: cancellable autoload of the most recent save from the main menu. |
| Quick Actions | Extra outer-ring couple/air/cut actions and a consist hover wheel on the rolling-stock pie menu. |
| Car Cards | Fanned consist dock for the selected cut, with waybill, notes, and couple/handbrake/locate actions. |
All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow.
@ -214,6 +215,14 @@ handbrakes, bleed, air, idle, select loco). Cut can optionally apply a handbrake
---
## Car Cards
A Monopoly-style fanned card dock for the coupled cut around your selected car.
Color bands, waybill info, per-car notes, and couple/handbrake/locate actions.
Optional WaypointQueue cut dividers when that mod is installed.
---
## Migrating from the standalone mods
S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and

View file

@ -37,6 +37,7 @@ public static class Main
_registry.Register(new Modules.MiscTweaks.MiscTweaksModule());
_registry.Register(new Modules.Popout.PopoutModule());
_registry.Register(new Modules.QuickActions.QuickActionsModule());
_registry.Register(new Modules.CarCards.CarCardsModule());
_registry.EnableConfigured();
ModConflicts.CheckAtLoad();

View file

@ -0,0 +1,59 @@
using System;
using HarmonyLib;
using S3.Core;
using UnityEngine;
namespace S3.Modules.CarCards;
public sealed class CarCardsModule : IModule
{
private const string SettingsFile = "S3.carcards.json";
public static CarCardsSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
public CarCardsModule() => Settings = SettingsStore.Load<CarCardsSettings>(SettingsFile);
public string Id => "carcards";
public string DisplayName => "Car Cards";
public string Description =>
"A fanned handful of monopoly-style cards for the coupled cut you are working. " +
"Color bands, waybill, notes, and on-card couple / handbrake / locate. Off by default.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
CardNotes.Load();
_harmony = new Harmony("S3.carcards");
foreach (Type t in new[] { typeof(CarCardsMouseOverUiPatch) })
{
try { _harmony.CreateClassProcessor(t).Patch(); }
catch (Exception e) { Log.Error($"[carcards] patch {t.Name} failed: {e.Message}"); }
}
_hostGo = new GameObject("[S3] CarCardsHost");
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
_hostGo.AddComponent<CarCardsOverlay>();
}
public void OnDisable()
{
CardNotes.Flush();
_harmony?.UnpatchAll("S3.carcards");
_harmony = null;
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => CarCardsSettingsUI.Draw();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,71 @@
using System;
namespace S3.Modules.CarCards;
[Serializable]
public class CarCardsSettings
{
public bool enabled = false;
public bool visible = true;
public int hotkeyKeyCode = 0;
public int hotkeyModifiers = 0;
// 0 owner, 1 dest, 2 origin, 3 type, 4 paint, 5 mark
public int colorMode = 1;
public float overlap = 0.55f;
// Fraction of card width neighbors still cover when a card is lifted.
public float hoverCover = 0.125f;
public bool revealOnHover = true;
public bool revealOnClick = true;
// 0 select, 1 follow, 2 inspector
public int clickAction = 0;
public bool pinned;
public string pinCarId = "";
public float windowX = -1f;
public float windowY = -1f;
public float windowW = 920f;
public float windowH = 144f;
public bool cardsBehindTitle = true;
public bool matchViewOrder = true;
public bool freezeOrderAtDistance = true;
public float viewOrderFreezeDistance = 1500f;
public bool matchMapRotation = false;
public bool showWaypointCuts = true;
// 0 follow, 1 map, 2 both
public int locateMode = 0;
// Parallel arrays (SettingsStore cannot nest custom classes).
public string[] undockedIds = Array.Empty<string>();
public float[] undockedX = Array.Empty<float>();
public float[] undockedY = Array.Empty<float>();
}
public enum CardColorMode
{
Owner = 0,
Destination = 1,
Origin = 2,
Type = 3,
Paint = 4,
Mark = 5,
}
public enum CardClickAction
{
Select = 0,
Follow = 1,
Inspector = 2,
}
public enum CardLocateMode
{
Follow = 0,
Map = 1,
Both = 2,
}

View file

@ -0,0 +1,153 @@
using UnityEngine;
namespace S3.Modules.CarCards;
static class CarCardsSettingsUI
{
internal static bool Capturing;
static readonly string[] ColorNames =
{
"Owner", "Destination", "Origin", "Type", "Paint", "Mark",
};
static readonly string[] ClickNames =
{
"Select", "Follow", "Inspector",
};
static readonly string[] LocateNames =
{
"Follow", "Show on map", "Both",
};
public static void Draw()
{
var s = CarCardsModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Car Cards</b> - fanned consist dock over the game");
GUILayout.Space(4f);
GUILayout.Label(
" Bound to the coupled cut of the selected car, or a pinned consist.\n" +
" Title bar sits under the cards. Drag a card out to keep it on screen.\n" +
" Enable the module, restart, then pick a hotkey (or leave the overlay on).",
GUI.skin.label);
GUILayout.Space(10f);
GUILayout.Label("<b>Hotkey</b>");
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
GUILayout.Label("Toggle overlay:", GUILayout.Width(110f));
if (Capturing)
{
GUILayout.Label("Press a key… (Esc to cancel)");
Event e = Event.current;
if (e.type == EventType.KeyDown)
{
if (e.keyCode != KeyCode.Escape && e.keyCode != KeyCode.None)
{
s.hotkeyKeyCode = (int)e.keyCode;
s.hotkeyModifiers = (e.shift ? 1 : 0) | (e.control ? 2 : 0) | (e.alt ? 4 : 0);
changed = true;
}
Capturing = false;
e.Use();
}
}
else
{
if (GUILayout.Button($"{HotkeyLabel(s)} (click to change)", GUILayout.Width(240f)))
Capturing = true;
if (s.hotkeyKeyCode != 0 && GUILayout.Button("Clear", GUILayout.Width(60f)))
{
s.hotkeyKeyCode = 0;
s.hotkeyModifiers = 0;
changed = true;
}
}
GUILayout.EndHorizontal();
GUILayout.Space(10f);
GUILayout.Label("<b>Display</b>");
GUILayout.Space(4f);
bool vis = GUILayout.Toggle(s.visible, " Show overlay when the module is enabled");
if (vis != s.visible) { s.visible = vis; changed = true; }
bool match = GUILayout.Toggle(s.matchViewOrder, " Match view order (left card is the leftmost car on screen)");
if (match != s.matchViewOrder) { s.matchViewOrder = match; changed = true; }
bool freeze = GUILayout.Toggle(s.freezeOrderAtDistance, " Freeze to lead-left when far (same as the on-screen consist, no orbit flip)");
if (freeze != s.freezeOrderAtDistance) { s.freezeOrderAtDistance = freeze; changed = true; }
if (s.freezeOrderAtDistance)
{
GUILayout.BeginHorizontal();
GUILayout.Label($" Distance: {s.viewOrderFreezeDistance:F0}", GUILayout.Width(130f));
float dist = GUILayout.HorizontalSlider(s.viewOrderFreezeDistance, 200f, 5000f, GUILayout.Width(220f));
GUILayout.EndHorizontal();
if (Mathf.Abs(dist - s.viewOrderFreezeDistance) > 1f) { s.viewOrderFreezeDistance = dist; changed = true; }
}
bool mapRot = GUILayout.Toggle(s.matchMapRotation, " Sync to map rotation (when the map overlay or popout is open)");
if (mapRot != s.matchMapRotation) { s.matchMapRotation = mapRot; changed = true; }
bool cuts = GUILayout.Toggle(s.showWaypointCuts, " Show waypoint cuts in the fan (needs WaypointQueue)");
if (cuts != s.showWaypointCuts) { s.showWaypointCuts = cuts; changed = true; }
GUILayout.BeginHorizontal();
GUILayout.Label("Color band:", GUILayout.Width(110f));
int color = GUILayout.Toolbar(s.colorMode, ColorNames, GUILayout.Width(420f));
GUILayout.EndHorizontal();
if (color != s.colorMode) { s.colorMode = color; changed = true; }
GUILayout.BeginHorizontal();
GUILayout.Label("Click a card:", GUILayout.Width(110f));
int click = GUILayout.Toolbar(s.clickAction, ClickNames, GUILayout.Width(240f));
GUILayout.EndHorizontal();
if (click != s.clickAction) { s.clickAction = click; changed = true; }
GUILayout.BeginHorizontal();
GUILayout.Label("Locate button:", GUILayout.Width(110f));
int loc = GUILayout.Toolbar(s.locateMode, LocateNames, GUILayout.Width(280f));
GUILayout.EndHorizontal();
if (loc != s.locateMode) { s.locateMode = loc; changed = true; }
GUILayout.Space(10f);
GUILayout.Label("<b>Fan</b>");
GUILayout.Space(4f);
bool hover = GUILayout.Toggle(s.revealOnHover, " Lift card on hover");
if (hover != s.revealOnHover) { s.revealOnHover = hover; changed = true; }
bool clk = GUILayout.Toggle(s.revealOnClick, " Keep card lifted after click");
if (clk != s.revealOnClick) { s.revealOnClick = clk; changed = true; }
GUILayout.BeginHorizontal();
GUILayout.Label($"Overlap: {s.overlap * 100f:F0}%", GUILayout.Width(110f));
float ov = GUILayout.HorizontalSlider(s.overlap, 0f, 0.9f, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(ov - s.overlap) > 0.01f) { s.overlap = ov; changed = true; }
GUILayout.BeginHorizontal();
GUILayout.Label($"Lifted cover: {s.hoverCover * 100f:F0}%", GUILayout.Width(110f));
float hc = GUILayout.HorizontalSlider(s.hoverCover, 0f, 0.4f, GUILayout.Width(200f));
GUILayout.EndHorizontal();
GUILayout.Label(" How much neighbors still sit on a lifted card. 12% is about 1/8 width.", GUI.skin.label);
if (Mathf.Abs(hc - s.hoverCover) > 0.005f) { s.hoverCover = hc; changed = true; }
GUILayout.EndVertical();
if (changed)
CarCardsModule.Persist();
}
internal static string HotkeyLabel(CarCardsSettings s)
{
if (s.hotkeyKeyCode == 0)
return "Not set";
string prefix = "";
if ((s.hotkeyModifiers & 2) != 0) prefix += "Ctrl+";
if ((s.hotkeyModifiers & 1) != 0) prefix += "Shift+";
if ((s.hotkeyModifiers & 4) != 0) prefix += "Alt+";
return prefix + (KeyCode)s.hotkeyKeyCode;
}
}

View file

@ -0,0 +1,78 @@
using Model;
using S3.Modules.Popout;
using Track;
using UI.CarInspector;
using UnityEngine;
namespace S3.Modules.CarCards;
static class CardClick
{
public static void Apply(Car car)
{
if (car == null) return;
try
{
switch ((CardClickAction)CarCardsModule.Settings.clickAction)
{
case CardClickAction.Follow:
CameraSelector.shared?.FollowCar(car);
break;
case CardClickAction.Inspector:
CarInspector.Show(car);
break;
default:
if (TrainController.Shared != null)
TrainController.Shared.SelectedCar = car;
break;
}
}
catch { }
}
public static void Locate(Car car)
{
if (car == null) return;
var mode = (CardLocateMode)CarCardsModule.Settings.locateMode;
try
{
if (mode == CardLocateMode.Follow || mode == CardLocateMode.Both)
CameraSelector.shared?.FollowCar(car);
if (mode == CardLocateMode.Map || mode == CardLocateMode.Both)
MapEnhancerBridge.JumpToCar(car);
}
catch { }
}
public static void JumpToWaypoint(WaypointDivider d)
{
if (d == null || !d.HasPosition) return;
try
{
CameraSelector.shared?.JumpToPoint(
d.Position,
d.Rotation,
CameraSelector.CameraIdentifier.Strategy);
}
catch { }
}
public static Car.LogicalEnd ScreenLeftEnd(Car car)
{
try
{
Camera? cam = CardViewOrder.ActiveCamera();
var graph = Graph.Shared;
if (cam == null || graph == null) return Car.LogicalEnd.A;
Vector3 a = graph.GetPosition(car.WheelBoundsA);
Vector3 b = graph.GetPosition(car.WheelBoundsB);
return cam.WorldToScreenPoint(a).x <= cam.WorldToScreenPoint(b).x
? Car.LogicalEnd.A
: Car.LogicalEnd.B;
}
catch { return Car.LogicalEnd.A; }
}
public static Car.LogicalEnd Other(Car.LogicalEnd end) =>
end == Car.LogicalEnd.A ? Car.LogicalEnd.B : Car.LogicalEnd.A;
}

View file

@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using Model;
using S3.Core;
using UnityEngine;
namespace S3.Modules.CarCards;
/// <summary>
/// Per-car notes. Prefers a KV key on the car so notes ride the save; never
/// references KeyValue.Runtime at compile time (reflection + Harmony Traverse).
/// Sidecar JSON is the fallback if KV get/set fails.
/// </summary>
static class CardNotes
{
public const string KvKey = "s3.card.notes";
const string SidecarFile = "S3.carcards.notes.json";
static readonly Dictionary<string, string> _sidecar = new();
static MethodInfo? _getItem;
static MethodInfo? _setItem;
static MethodInfo? _valueString;
static PropertyInfo? _stringValue;
static PropertyInfo? _valueType;
static bool _resolved;
static bool _kvBroken;
static float _flushAt;
static bool _dirty;
[Serializable]
class FileShape
{
public string[] ids = Array.Empty<string>();
public string[] texts = Array.Empty<string>();
}
public static void Load()
{
_sidecar.Clear();
var file = SettingsStore.Load<FileShape>(SidecarFile);
if (file.ids == null || file.texts == null) return;
int n = Math.Min(file.ids.Length, file.texts.Length);
for (int i = 0; i < n; i++)
{
if (string.IsNullOrEmpty(file.ids[i])) continue;
_sidecar[file.ids[i]] = file.texts[i] ?? "";
}
}
public static void Flush()
{
if (!_dirty) return;
var ids = new string[_sidecar.Count];
var texts = new string[_sidecar.Count];
int i = 0;
foreach (var kv in _sidecar)
{
ids[i] = kv.Key;
texts[i] = kv.Value ?? "";
i++;
}
SettingsStore.Save(SidecarFile, new FileShape { ids = ids, texts = texts });
_dirty = false;
_flushAt = 0f;
}
public static void Tick()
{
if (_dirty && _flushAt > 0f && Time.unscaledTime >= _flushAt)
Flush();
}
public static string Get(Car car)
{
if (car == null) return "";
if (!_kvBroken)
{
try
{
string? fromKv = ReadKv(car);
if (!string.IsNullOrEmpty(fromKv))
return fromKv;
}
catch (Exception e)
{
_kvBroken = true;
Log.Warn($"[carcards] notes KV read failed, using sidecar: {e.Message}");
}
}
return _sidecar.TryGetValue(car.id, out string text) ? text : "";
}
public static void Set(Car car, string text)
{
if (car == null) return;
text ??= "";
_sidecar[car.id] = text;
_dirty = true;
_flushAt = Time.unscaledTime + 0.6f;
if (_kvBroken) return;
try
{
WriteKv(car, text);
}
catch (Exception e)
{
_kvBroken = true;
Log.Warn($"[carcards] notes KV write failed, sidecar only: {e.Message}");
}
}
static string? ReadKv(Car car)
{
object? kvo = AccessTools.Field(typeof(Car), "KeyValueObject")?.GetValue(car);
if (kvo == null) return null;
Resolve(kvo);
if (_getItem == null) return null;
object? val = _getItem.Invoke(kvo, new object[] { KvKey });
if (val == null) return null;
object? kind = _valueType?.GetValue(val);
if (kind != null && string.Equals(kind.ToString(), "Null", StringComparison.Ordinal))
return null;
return _stringValue?.GetValue(val) as string;
}
static void WriteKv(Car car, string text)
{
object? kvo = AccessTools.Field(typeof(Car), "KeyValueObject")?.GetValue(car);
if (kvo == null) return;
Resolve(kvo);
if (_setItem == null || _valueString == null) return;
object val = _valueString.Invoke(null, new object[] { text })!;
_setItem.Invoke(kvo, new object[] { KvKey, val });
}
static void Resolve(object kvo)
{
if (_resolved) return;
Type t = kvo.GetType();
Type? valueType = t.Assembly.GetType("KeyValue.Runtime.Value");
_getItem = t.GetMethod("get_Item", new[] { typeof(string) });
_setItem = valueType != null
? t.GetMethod("set_Item", new[] { typeof(string), valueType })
: null;
if (valueType != null)
{
_valueString = valueType.GetMethod("String", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(string) }, null);
_stringValue = valueType.GetProperty("StringValue");
_valueType = valueType.GetProperty("Type");
}
_resolved = true;
}
}

View file

@ -0,0 +1,520 @@
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;
}
}

View file

@ -0,0 +1,239 @@
using System;
using System.Reflection;
using HarmonyLib;
using Model;
using Model.Definition;
using Model.Ops;
using S3.Modules.QuickActions;
using UnityEngine;
namespace S3.Modules.CarCards;
sealed class CardViewModel
{
public Car Car = null!;
public string Id = "";
public string Mark = "";
public string TypeLine = "";
public string Specs = "";
public string Load = "";
public string Waybill = "";
public string LocoExtra = "";
public string Notes = "";
public Color Band = new(0.45f, 0.45f, 0.48f);
public bool Owned;
static MethodInfo? _getLoadInfo;
static bool _loadResolved;
public static CardViewModel From(Car car, CardColorMode mode)
{
var vm = new CardViewModel { Car = car, Id = car.id };
try { vm.Mark = string.IsNullOrEmpty(car.DisplayName) ? car.id : car.DisplayName; }
catch { vm.Mark = car.id; }
string type = "";
try { type = car.CarType; } catch { }
string arch = "";
try { arch = car.Archetype.DisplayName(); } catch { arch = car.Archetype.ToString(); }
vm.TypeLine = string.IsNullOrEmpty(type) ? arch : $"{type} {arch}";
float ft = car.carLength * 3.28084f;
float tons = car.Weight / 2000f;
vm.Specs = $"{ft:0} ft {tons:0.0} T";
try { vm.Owned = car.IsOwnedByPlayer; } catch { vm.Owned = false; }
vm.Load = ReadLoad(car);
FillWaybill(car, vm);
FillLoco(car, vm);
vm.Notes = CardNotes.Get(car);
vm.Band = BandColor(car, vm, mode);
return vm;
}
static void FillLoco(Car car, CardViewModel vm)
{
if (car is not BaseLocomotive loco) return;
string rated = TrainReadout.Snapshot.FormatTe(loco.RatedTractiveEffort);
string cur = TrainReadout.Snapshot.FormatTe(Mathf.Abs(loco.TractiveEffort));
string fuel = "No fuel";
try { fuel = loco.HasFuel ? "Fueled" : "No fuel"; } catch { }
vm.LocoExtra = $"{rated} rated {cur} now\n{fuel}";
}
static void FillWaybill(Car car, CardViewModel vm)
{
object? raw = null;
try { raw = Traverse.Create(car).Property("Waybill").GetValue(); }
catch { return; }
raw = UnwrapNullable(raw);
if (raw == null) return;
Type t = raw.GetType();
object? origin = UnwrapNullable(t.GetField("Origin")?.GetValue(raw));
object? dest = UnwrapNullable(t.GetField("Destination")?.GetValue(raw));
string originName = PosName(origin);
string destName = PosName(dest);
int pay = 0;
try { pay = (int)(t.GetField("PaymentOnArrival")?.GetValue(raw) ?? 0); }
catch { }
if (string.IsNullOrEmpty(originName) && string.IsNullOrEmpty(destName))
{
vm.Waybill = "";
return;
}
string route = string.IsNullOrEmpty(originName) ? destName : $"{originName} → {destName}";
vm.Waybill = pay > 0 ? $"{route}\n${pay}" : route;
}
static string PosName(object? pos)
{
if (pos == null) return "";
try
{
object? n = pos.GetType().GetField("DisplayName")?.GetValue(pos);
return n as string ?? "";
}
catch { return ""; }
}
static Color BandColor(Car car, CardViewModel vm, CardColorMode mode)
{
return mode switch
{
CardColorMode.Owner => vm.Owned
? new Color(0.28f, 0.50f, 0.74f)
: new Color(0.48f, 0.48f, 0.50f),
CardColorMode.Destination => IndustryColor(car, dest: true),
CardColorMode.Origin => IndustryColor(car, dest: false),
CardColorMode.Type => TypeColor(car),
CardColorMode.Paint => PaintColor(car),
CardColorMode.Mark => MarkColor(vm.Mark),
_ => new Color(0.45f, 0.45f, 0.48f),
};
}
static Color TypeColor(Car car)
{
try
{
return car.Archetype switch
{
CarArchetype.LocomotiveDiesel => new Color(0.82f, 0.68f, 0.18f),
CarArchetype.LocomotiveSteam => new Color(0.55f, 0.38f, 0.22f),
CarArchetype.Boxcar => new Color(0.78f, 0.42f, 0.18f),
CarArchetype.Flat => new Color(0.45f, 0.62f, 0.38f),
CarArchetype.Tank => new Color(0.22f, 0.55f, 0.62f),
CarArchetype.HopperOpen => new Color(0.42f, 0.42f, 0.45f),
CarArchetype.Caboose => new Color(0.72f, 0.22f, 0.22f),
CarArchetype.Tender => new Color(0.35f, 0.32f, 0.30f),
CarArchetype.Gondola => new Color(0.62f, 0.32f, 0.48f),
CarArchetype.Coach => new Color(0.55f, 0.42f, 0.72f),
CarArchetype.Baggage => new Color(0.38f, 0.45f, 0.62f),
_ => new Color(0.50f, 0.50f, 0.52f),
};
}
catch { return new Color(0.50f, 0.50f, 0.52f); }
}
static Color MarkColor(string mark)
{
int h = mark.GetHashCode();
float hue = Mathf.Abs(h % 360) / 360f;
return Color.HSVToRGB(hue, 0.55f, 0.72f);
}
static Color PaintColor(Car car)
{
try
{
foreach (var mb in car.GetComponentsInChildren<MonoBehaviour>(true))
{
if (mb == null || mb.GetType().Name != "CarColorController") continue;
object? scheme = Traverse.Create(mb).Property("Scheme").GetValue();
if (scheme == null) break;
string? hex = Traverse.Create(scheme).Field("BaseHex").GetValue() as string;
if (string.IsNullOrEmpty(hex)) break;
if (!hex.StartsWith("#")) hex = "#" + hex;
if (ColorUtility.TryParseHtmlString(hex, out Color c))
return c;
}
}
catch { }
return new Color(0.45f, 0.45f, 0.48f);
}
static Color IndustryColor(Car car, bool dest)
{
object? raw = null;
try { raw = Traverse.Create(car).Property("Waybill").GetValue(); }
catch { return new Color(0.45f, 0.45f, 0.48f); }
raw = UnwrapNullable(raw);
if (raw == null) return new Color(0.45f, 0.45f, 0.48f);
string field = dest ? "Destination" : "Origin";
object? pos = UnwrapNullable(raw.GetType().GetField(field)?.GetValue(raw));
if (pos == null) return new Color(0.45f, 0.45f, 0.48f);
try
{
var ops = OpsController.Shared;
if (ops == null) return new Color(0.45f, 0.45f, 0.48f);
object? area = typeof(OpsController).GetMethod("AreaForCarPosition")?.Invoke(ops, new[] { pos });
if (area == null) return new Color(0.45f, 0.45f, 0.48f);
object? col = area.GetType().GetField("tagColor")?.GetValue(area);
if (col is Color c) return c;
}
catch { }
return new Color(0.45f, 0.45f, 0.48f);
}
static string ReadLoad(Car car)
{
try
{
if (!_loadResolved)
{
Type? ext = typeof(Car).Assembly.GetType("Model.Ops.CarExtensions");
_getLoadInfo = ext?.GetMethod("GetLoadInfo", new[] { typeof(Car), typeof(int) });
_loadResolved = true;
}
if (_getLoadInfo == null) return LoadFromWeight(car);
object? boxed = _getLoadInfo.Invoke(null, new object[] { car, 0 });
boxed = UnwrapNullable(boxed);
if (boxed == null) return LoadFromWeight(car);
string? id = boxed.GetType().GetField("LoadId")?.GetValue(boxed) as string;
object? qtyObj = boxed.GetType().GetField("Quantity")?.GetValue(boxed);
float qty = qtyObj is float f ? f : 0f;
if (string.IsNullOrEmpty(id) || qty <= 0.001f) return "Empty";
return $"{id} {qty:0.#}";
}
catch
{
return LoadFromWeight(car);
}
}
static string LoadFromWeight(Car car)
{
try
{
int empty = car.Definition.WeightEmpty;
float extra = car.Weight - empty;
if (extra > 200f) return "Loaded";
}
catch { }
return "";
}
static object? UnwrapNullable(object? raw)
{
if (raw == null) return null;
Type t = raw.GetType();
if (!t.IsGenericType || t.GetGenericTypeDefinition() != typeof(Nullable<>))
return raw;
object? has = t.GetProperty("HasValue")?.GetValue(raw);
if (has is not true) return null;
return t.GetProperty("Value")?.GetValue(raw);
}
}

View file

@ -0,0 +1,89 @@
using System.Collections.Generic;
using HarmonyLib;
using Model;
using S3.Core.Ui;
using S3.Modules.Popout;
using UI.Map;
using UnityEngine;
namespace S3.Modules.CarCards;
static class CardViewOrder
{
public static Camera? ActiveCamera()
{
if (CarCardsModule.Settings.matchMapRotation && TryMapCamera(out Camera map))
return map;
return Camera.main;
}
public static bool TryMapCamera(out Camera cam)
{
cam = null!;
try
{
if (!UiService.IsOverlayVisible && !PopoutModule.IsDetached)
return false;
cam = MapBuilder.Shared?.mapCamera!;
return cam != null;
}
catch
{
cam = null!;
return false;
}
}
public static bool IsFar(Camera cam, List<Car> cars, float freezeAt, ref bool frozen)
{
float dist = MinDistance(cam, cars);
if (dist < 0f) return frozen;
float band = Mathf.Max(80f, freezeAt * 0.1f);
if (frozen)
{
if (dist < freezeAt - band) frozen = false;
}
else if (dist >= freezeAt)
frozen = true;
return frozen;
}
public static void OrderLeadLeft(List<Car> cars)
{
int lead = LeadIndex(cars);
if (lead < 0) return;
if (lead * 2 >= cars.Count)
cars.Reverse();
}
static int LeadIndex(List<Car> cars)
{
int firstLoco = -1;
for (int i = 0; i < cars.Count; i++)
{
if (cars[i] is not BaseLocomotive loco) continue;
if (firstLoco < 0) firstLoco = i;
bool mu = false;
try { mu = Traverse.Create(loco).Property<bool>("IsMuEnabled").Value; }
catch { }
if (!mu) return i;
}
return firstLoco;
}
static float MinDistance(Camera cam, List<Car> cars)
{
float best = float.MaxValue;
Vector3 p = cam.transform.position;
for (int i = 0; i < cars.Count; i++)
{
var car = cars[i];
if (car == null) continue;
Transform body = car.BodyTransform != null ? car.BodyTransform : car.transform;
if (body == null) continue;
float d = Vector3.Distance(p, body.position);
if (d < best) best = d;
}
return best < float.MaxValue ? best : -1f;
}
}

View file

@ -0,0 +1,276 @@
using Model;
using S3.Modules.QuickActions;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace S3.Modules.CarCards;
sealed class CardWidget : MonoBehaviour,
IPointerClickHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
static readonly Color Cream = new(0.93f, 0.90f, 0.82f, 0.97f);
static readonly Color Ink = new(0.16f, 0.14f, 0.12f, 1f);
static readonly Color Border = new(0.12f, 0.10f, 0.08f, 0.95f);
static readonly Color MadeUp = new(0.28f, 0.52f, 0.32f, 1f);
static readonly Color BrakeOn = new(0.72f, 0.32f, 0.22f, 1f);
public CardViewModel? Model { get; private set; }
public int Index;
public bool Undocked { get; private set; }
public bool Dragging { get; private set; }
Image _shadow = null!;
Image _band = null!;
TextMeshProUGUI _mark = null!;
TextMeshProUGUI _body = null!;
InputField? _notes;
Button _left = null!;
Button _brake = null!;
Button _locate = null!;
Button _right = null!;
CarCardsOverlay? _owner;
bool _didDrag;
Vector3 _dragWorldOffset;
public static CardWidget Create(RectTransform parent, CarCardsOverlay owner)
{
var root = new GameObject("Card", typeof(RectTransform));
var rt = (RectTransform)root.transform;
rt.SetParent(parent, false);
rt.sizeDelta = new Vector2(FanLayout.CardW, FanLayout.CardH);
var w = root.AddComponent<CardWidget>();
w._owner = owner;
w._shadow = CardUi.AddShadow(rt);
var border = CardUi.MakeImage(rt, "Border", Border, raycast: false);
CardUi.Stretch(border.rectTransform, 0f);
var face = CardUi.MakeImage(rt, "Face", Cream, raycast: true);
CardUi.Stretch(face.rectTransform, 2f);
face.gameObject.AddComponent<Mask>().showMaskGraphic = true;
w._band = CardUi.MakeImage(face.rectTransform, "Band", Color.gray, raycast: false, round: false);
var bandRt = w._band.rectTransform;
bandRt.anchorMin = new Vector2(0f, 1f);
bandRt.anchorMax = new Vector2(1f, 1f);
bandRt.pivot = new Vector2(0.5f, 1f);
bandRt.anchoredPosition = Vector2.zero;
bandRt.sizeDelta = new Vector2(0f, FanLayout.BandH);
w._mark = CardUi.Tmp(bandRt, "Mark", 12f, Color.white, FontStyles.Bold, TextAlignmentOptions.MidlineLeft);
w._mark.rectTransform.anchorMin = Vector2.zero;
w._mark.rectTransform.anchorMax = Vector2.one;
w._mark.rectTransform.offsetMin = new Vector2(6f, 0f);
w._mark.rectTransform.offsetMax = new Vector2(-6f, 0f);
w._body = CardUi.Tmp(face.rectTransform, "Body", 11f, Ink, FontStyles.Normal, TextAlignmentOptions.TopLeft);
w._body.rectTransform.anchorMin = new Vector2(0f, 0f);
w._body.rectTransform.anchorMax = new Vector2(1f, 1f);
w._body.rectTransform.offsetMin = new Vector2(8f, FanLayout.NotesH + FanLayout.ActionsH + 8f);
w._body.rectTransform.offsetMax = new Vector2(-8f, -(FanLayout.BandH + 4f));
w._body.color = Ink;
w._notes = CardUi.NotesField(face.rectTransform, new Vector2(0f, FanLayout.NotesH));
NotesDragRelay.Attach(w._notes.gameObject, w);
var nrt = (RectTransform)w._notes.transform;
nrt.anchorMin = new Vector2(0f, 0f);
nrt.anchorMax = new Vector2(1f, 0f);
nrt.pivot = new Vector2(0.5f, 0f);
nrt.anchoredPosition = new Vector2(0f, FanLayout.ActionsH + 6f);
nrt.sizeDelta = new Vector2(-12f, FanLayout.NotesH);
w._notes.onValueChanged.AddListener(t =>
{
if (w.Model?.Car == null) return;
CardNotes.Set(w.Model.Car, t);
w.Model.Notes = t;
});
float by = 4f;
float bw = 28f;
w._left = ActionBtn(face.rectTransform, "L", CardUi.CoupleSprite(), 8f, by, bw);
w._brake = ActionBtn(face.rectTransform, "Brake", CardUi.BrakeSprite(), 40f, by, bw);
w._locate = ActionBtn(face.rectTransform, "Go", CardUi.LocateSprite(), 72f, by, bw);
w._right = ActionBtn(face.rectTransform, "R", CardUi.CoupleSprite(), 104f, by, bw);
w._left.onClick.AddListener(() => w.OnCouple(left: true));
w._right.onClick.AddListener(() => w.OnCouple(left: false));
w._brake.onClick.AddListener(w.OnBrake);
w._locate.onClick.AddListener(w.OnLocate);
return w;
}
static Button ActionBtn(RectTransform parent, string name, Sprite icon, float x, float y, float size)
{
var btn = CardUi.IconButton(parent, name, icon, new Vector2(size, size));
var rt = btn.GetComponent<RectTransform>();
rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f);
rt.pivot = new Vector2(0f, 0f);
rt.anchoredPosition = new Vector2(x, y);
return btn;
}
public void Bind(CardViewModel vm, int index)
{
Model = vm;
Index = index;
gameObject.SetActive(true);
_band.color = vm.Band;
_mark.color = CardUi.BandInk(vm.Band);
_mark.text = vm.Mark;
_body.text = BodyText(vm);
name = "Card_" + vm.Mark;
if (_notes != null && !_notes.isFocused && _notes.text != (vm.Notes ?? ""))
_notes.text = vm.Notes ?? "";
RefreshActions();
}
public void RefreshActions()
{
var car = Model?.Car;
if (car == null) return;
var left = CardClick.ScreenLeftEnd(car);
var right = CardClick.Other(left);
CardUi.TintButton(_left, EndGearActions.IsMadeUp(car, left), MadeUp);
CardUi.TintButton(_right, EndGearActions.IsMadeUp(car, right), MadeUp);
bool brake = false;
try { brake = car.air != null && car.air.handbrakeApplied; } catch { }
CardUi.TintButton(_brake, brake, BrakeOn);
_left.interactable = EndGearActions.CanToggleJoint(car, left);
_right.interactable = EndGearActions.CanToggleJoint(car, right);
}
public void SetUndocked(bool undocked) => Undocked = undocked;
public void SetLifted(bool lifted)
{
if (_shadow == null) return;
_shadow.color = lifted ? new Color(0f, 0f, 0f, 0.55f) : new Color(0f, 0f, 0f, 0.42f);
_shadow.rectTransform.anchoredPosition = lifted ? new Vector2(5f, -6f) : new Vector2(3f, -3f);
}
public void Hide()
{
Model = null;
gameObject.SetActive(false);
}
public void OnPointerClick(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left) return;
if (_didDrag) return;
if (Undocked && eventData.clickCount >= 2)
{
_owner?.Redock(this, snap: false);
return;
}
if (Model != null) _owner?.NotifyClick(this);
}
public void OnBeginDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left) return;
if (_notes != null && _notes.isFocused)
_notes.DeactivateInputField();
_didDrag = false;
Dragging = true;
var rt = (RectTransform)transform;
Camera? cam = eventData.pressEventCamera;
if (!RectTransformUtility.ScreenPointToWorldPointInRectangle(rt, eventData.position, cam, out Vector3 world))
world = rt.position;
_dragWorldOffset = rt.position - world;
}
public void OnDrag(PointerEventData eventData)
{
if (!Dragging) return;
if (!_didDrag && eventData.delta.sqrMagnitude > 1f)
_didDrag = true;
if (!Undocked && _didDrag)
_owner?.BeginUndock(this);
var rt = (RectTransform)transform;
var parent = rt.parent as RectTransform;
if (parent == null) return;
Camera? cam = eventData.pressEventCamera;
if (!RectTransformUtility.ScreenPointToWorldPointInRectangle(parent, eventData.position, cam, out Vector3 world))
return;
rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f);
rt.pivot = new Vector2(0f, 0f);
rt.position = world + _dragWorldOffset;
}
public void OnEndDrag(PointerEventData eventData)
{
bool was = Dragging;
Dragging = false;
if (!was) return;
if (Undocked)
_owner?.EndUndockDrag(this, eventData.position);
}
void OnCouple(bool left)
{
var car = Model?.Car;
if (car == null) return;
var end = CardClick.ScreenLeftEnd(car);
if (!left) end = CardClick.Other(end);
EndGearActions.ToggleJoint(car, end);
RefreshActions();
}
void OnBrake()
{
var car = Model?.Car;
if (car == null) return;
try
{
bool on = car.air != null && car.air.handbrakeApplied;
car.SetHandbrake(!on);
}
catch { }
RefreshActions();
}
void OnLocate()
{
if (Model?.Car != null) CardClick.Locate(Model.Car);
}
static string BodyText(CardViewModel vm)
{
var sb = new System.Text.StringBuilder();
sb.Append(vm.TypeLine);
sb.Append('\n').Append(vm.Specs);
if (!string.IsNullOrEmpty(vm.Load))
sb.Append('\n').Append(vm.Load);
if (!string.IsNullOrEmpty(vm.Waybill))
sb.Append('\n').Append(vm.Waybill);
if (!string.IsNullOrEmpty(vm.LocoExtra))
sb.Append('\n').Append(vm.LocoExtra);
return sb.ToString();
}
}
sealed class NotesDragRelay : MonoBehaviour,
IInitializePotentialDragHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
CardWidget _owner = null!;
public static void Attach(GameObject notes, CardWidget owner)
{
var relay = notes.AddComponent<NotesDragRelay>();
relay._owner = owner;
}
public void OnInitializePotentialDrag(PointerEventData eventData)
{
eventData.useDragThreshold = true;
}
public void OnBeginDrag(PointerEventData eventData) => _owner.OnBeginDrag(eventData);
public void OnDrag(PointerEventData eventData) => _owner.OnDrag(eventData);
public void OnEndDrag(PointerEventData eventData) => _owner.OnEndDrag(eventData);
}

View file

@ -0,0 +1,111 @@
using System.Collections.Generic;
using Model;
using UnityEngine;
namespace S3.Modules.CarCards;
sealed class ConsistBinder
{
public readonly struct Option
{
public readonly string Key;
public readonly string Label;
public readonly Car Anchor;
public Option(string key, string label, Car anchor)
{
Key = key;
Label = label;
Anchor = anchor;
}
}
public Car? Anchor { get; private set; }
public List<Car> Resolve()
{
var list = new List<Car>();
var s = CarCardsModule.Settings;
Car? seed = null;
if (s.pinned && !string.IsNullOrEmpty(s.pinCarId))
seed = Find(s.pinCarId);
if (seed == null)
{
try { seed = TrainController.Shared?.SelectedCar; }
catch { seed = null; }
}
Anchor = seed;
if (seed == null) return list;
try
{
foreach (Car c in seed.EnumerateCoupled())
if (c != null) list.Add(c);
}
catch { /* partial consist */ }
if (list.Count == 0) list.Add(seed);
return list;
}
public void PinTo(Car car)
{
var s = CarCardsModule.Settings;
s.pinned = true;
s.pinCarId = car.id;
CarCardsModule.Persist();
}
public void FollowSelection()
{
var s = CarCardsModule.Settings;
s.pinned = false;
s.pinCarId = "";
CarCardsModule.Persist();
}
public static List<Option> OwnedConsists()
{
var result = new List<Option>();
var seen = new HashSet<string>();
TrainController? tc = null;
try { tc = TrainController.Shared; }
catch { return result; }
if (tc?.Cars == null) return result;
foreach (Car c in tc.Cars)
{
if (c is not BaseLocomotive) continue;
bool owned = false;
try { owned = c.IsOwnedByPlayer; }
catch { continue; }
if (!owned) continue;
var cars = new List<Car>();
try
{
foreach (Car x in c.EnumerateCoupled())
if (x != null) cars.Add(x);
}
catch { }
if (cars.Count == 0) cars.Add(c);
string key = cars[0].id;
if (!seen.Add(key)) continue;
string mark = string.IsNullOrEmpty(c.DisplayName) ? c.id : c.DisplayName;
result.Add(new Option(c.id, $"{mark} · {cars.Count} cars", c));
}
return result;
}
public static Car? Find(string id)
{
if (string.IsNullOrEmpty(id)) return null;
try
{
var tc = TrainController.Shared;
if (tc?.Cars == null) return null;
foreach (Car c in tc.Cars)
if (c != null && c.id == id) return c;
}
catch { }
return null;
}
}

View file

@ -0,0 +1,127 @@
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace S3.Modules.CarCards;
sealed class DividerWidget : MonoBehaviour, IPointerClickHandler
{
static readonly Color Cream = new(0.93f, 0.90f, 0.82f, 0.97f);
static readonly Color Ink = new(0.16f, 0.14f, 0.12f, 1f);
static readonly Color Border = new(0.12f, 0.10f, 0.08f, 0.95f);
public WaypointDivider? Model { get; private set; }
Image _shadow = null!;
Image _stripe = null!;
TextMeshProUGUI _num = null!;
Image _icon = null!;
TextMeshProUGUI _head = null!;
TextMeshProUGUI _body = null!;
public static DividerWidget Create(RectTransform parent)
{
var root = new GameObject("Divider", typeof(RectTransform));
var rt = (RectTransform)root.transform;
rt.SetParent(parent, false);
rt.sizeDelta = new Vector2(FanLayout.DividerW, FanLayout.CardH);
var w = root.AddComponent<DividerWidget>();
w._shadow = CardUi.AddShadow(rt);
var border = CardUi.MakeImage(rt, "Border", Border, raycast: false);
CardUi.Stretch(border.rectTransform, 0f);
var face = CardUi.MakeImage(rt, "Face", Cream, raycast: true);
CardUi.Stretch(face.rectTransform, 2f);
face.gameObject.AddComponent<Mask>().showMaskGraphic = true;
var faceRt = face.rectTransform;
w._stripe = CardUi.MakeImage(faceRt, "Stripe", Color.gray, raycast: false, round: false);
var srt = w._stripe.rectTransform;
srt.anchorMin = new Vector2(0f, 0f);
srt.anchorMax = new Vector2(0f, 1f);
srt.pivot = new Vector2(0f, 0.5f);
srt.anchoredPosition = Vector2.zero;
srt.sizeDelta = new Vector2(FanLayout.StripeW, 0f);
w._num = CardUi.Tmp(srt, "Num", 20f, Color.white, FontStyles.Bold, TextAlignmentOptions.Center);
var nrt = w._num.rectTransform;
nrt.anchorMin = new Vector2(0f, 1f);
nrt.anchorMax = new Vector2(1f, 1f);
nrt.pivot = new Vector2(0.5f, 1f);
nrt.anchoredPosition = new Vector2(0f, -8f);
nrt.sizeDelta = new Vector2(0f, 32f);
w._num.enableAutoSizing = true;
w._num.fontSizeMin = 11f;
w._num.fontSizeMax = 20f;
w._num.overflowMode = TextOverflowModes.Overflow;
var icon = CardUi.MakeIcon(srt, "Icon", CardUi.CutSprite(), Color.white, raycast: false);
icon.rectTransform.anchorMin = icon.rectTransform.anchorMax = new Vector2(0.5f, 1f);
icon.rectTransform.pivot = new Vector2(0.5f, 1f);
icon.rectTransform.anchoredPosition = new Vector2(0f, -42f);
icon.rectTransform.sizeDelta = new Vector2(22f, 22f);
w._icon = icon;
w._head = CardUi.Tmp(faceRt, "Head", 13f, Ink, FontStyles.Bold, TextAlignmentOptions.TopLeft);
var hrt = w._head.rectTransform;
hrt.anchorMin = new Vector2(0f, 1f);
hrt.anchorMax = new Vector2(1f, 1f);
hrt.pivot = new Vector2(0f, 1f);
hrt.anchoredPosition = new Vector2(FanLayout.StripeW + 8f, -8f);
hrt.sizeDelta = new Vector2(-(FanLayout.StripeW + 16f), 36f);
w._body = CardUi.Tmp(faceRt, "Body", 11f, Ink, FontStyles.Normal, TextAlignmentOptions.TopLeft);
var brt = w._body.rectTransform;
brt.anchorMin = Vector2.zero;
brt.anchorMax = Vector2.one;
brt.offsetMin = new Vector2(FanLayout.StripeW + 8f, 8f);
brt.offsetMax = new Vector2(-8f, -44f);
w._body.color = Ink;
return w;
}
public void Bind(WaypointDivider d)
{
Model = d;
gameObject.SetActive(true);
name = "Div_" + d.Number;
_stripe.color = d.Color;
Color ink = CardUi.BandInk(d.Color);
_num.color = ink;
_num.text = d.Number.ToString();
_icon.sprite = SpriteFor(d.Action);
_icon.color = ink;
_head.text = d.Headline ?? "";
_body.text = d.Detail ?? "";
}
public void SetLifted(bool lifted)
{
if (_shadow == null) return;
_shadow.color = lifted ? new Color(0f, 0f, 0f, 0.55f) : new Color(0f, 0f, 0f, 0.42f);
_shadow.rectTransform.anchoredPosition = lifted ? new Vector2(5f, -6f) : new Vector2(3f, -3f);
}
public void OnPointerClick(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left) return;
if (Model != null) CardClick.JumpToWaypoint(Model);
}
public void Hide()
{
Model = null;
gameObject.SetActive(false);
}
static Sprite SpriteFor(WaypointAction action) => action switch
{
WaypointAction.Pickup => CardUi.PickupSprite(),
WaypointAction.Drop => CardUi.DropSprite(),
WaypointAction.Couple => CardUi.CoupleSprite(),
_ => CardUi.CutSprite(),
};
}

View file

@ -0,0 +1,113 @@
using System.Collections.Generic;
using UnityEngine;
namespace S3.Modules.CarCards;
static class FanLayout
{
public const float CardW = 140f;
public const float CardH = 236f;
public const float DividerW = CardW;
public const float BandH = 28f;
public const float NotesH = 40f;
public const float ActionsH = 26f;
public const float TitleH = 36f;
public const float WellMul = 3f;
public const float PeekFrac = 1f / 3f;
public const float HoverSpread = 36f;
public const float AnimSpeed = 14f;
public const float Pad = 8f;
public const float ScrollH = 12f;
public const float StripeW = 36f;
public static float DockH => TitleH * (1f + WellMul);
public static float PeekHidden => CardH * (1f - PeekFrac);
public static float Step(float overlap) =>
StepFor(CardW, overlap);
public static float StepFor(float width, float overlap) =>
Mathf.Max(18f, width * (1f - Mathf.Clamp01(overlap)));
/// <summary>
/// How far neighbors slide so a lifted card is only covered by hoverCover of CardW.
/// Same pixel amount on both sides.
/// </summary>
public static float Parting(float overlap)
{
float restCover = Mathf.Max(0f, CardW - Step(overlap));
float want = Mathf.Clamp(CarCardsModule.Settings.hoverCover, 0f, 0.5f) * CardW;
return Mathf.Max(0f, restCover - want);
}
public static float Span(int count, float overlap, int hover, int insertAt)
{
if (count <= 0) return Pad * 2f;
float step = Step(overlap);
float span = Pad + CardW + step * Mathf.Max(0, count - 1);
if (insertAt >= 0) span += HoverSpread;
if (hover >= 0) span += Parting(overlap) * 2f;
return span + Pad;
}
public static float XAt(IReadOnlyList<float> widths, int index, int hoverSlot, float overlap)
{
float x = Pad;
for (int i = 0; i < index; i++)
x += StepFor(widths[i], overlap);
if (hoverSlot >= 0 && index != hoverSlot)
{
float part = Parting(overlap);
if (index < hoverSlot) x -= part;
else x += part;
}
return x;
}
public static float RestExclusiveRight(IReadOnlyList<float> widths, int index, float overlap)
{
if (widths == null || index < 0 || index >= widths.Count)
return Pad;
if (index + 1 < widths.Count)
return XAt(widths, index + 1, -1, overlap);
return XAt(widths, index, -1, overlap) + widths[index];
}
public static float SpanOf(IReadOnlyList<float> widths, int hoverSlot, float overlap)
{
if (widths == null || widths.Count == 0) return Pad * 2f;
int last = widths.Count - 1;
float left = XAt(widths, 0, hoverSlot, overlap);
float right = XAt(widths, last, hoverSlot, overlap) + widths[last];
return right - Mathf.Min(left, 0f) + Pad;
}
public static float SlotY(int slot, int hoverSlot, bool canLift) =>
canLift && slot == hoverSlot && hoverSlot >= 0 ? Pad : -PeekHidden;
/// <summary>Bottom-left in fan space: y=0 is the top of title+scrollbar chrome.</summary>
public static Vector2 DockedPos(
int index, int count, int hover, int insertAt, float overlap)
{
float step = Step(overlap);
float part = hover >= 0 ? Parting(overlap) : 0f;
float x = Pad;
for (int i = 0; i < index; i++)
{
x += step;
if (i == insertAt) x += HoverSpread;
}
if (index == insertAt)
x += HoverSpread * 0.5f;
if (hover >= 0 && index != hover)
{
if (index < hover) x -= part;
else x += part;
}
float y = -PeekHidden;
if (index == hover)
y = Pad;
return new Vector2(x, y);
}
}

View file

@ -0,0 +1,693 @@
using System;
using System.Collections.Generic;
using HarmonyLib;
using Model;
using Model.Definition;
using Model.Ops;
using S3.Modules.Popout;
using Track;
using UnityEngine;
namespace S3.Modules.CarCards;
enum WaypointAction
{
Cut,
Drop,
Pickup,
Couple,
}
sealed class WaypointDivider
{
public int Number;
public Color Color;
public WaypointAction Action;
public string LeftId = "";
public string RightId = "";
/// <summary>
/// When true, LeftId is the end car and RightId is the next car inward.
/// The divider sits on the outer face of LeftId, away from RightId.
/// </summary>
public bool Outer;
public string WaypointId = "";
public bool HasPosition;
public Vector3 Position;
public Quaternion Rotation = Quaternion.identity;
public string Headline = "";
public string Detail = "";
}
static class WaypointCutSim
{
const float NearCoupleM = 250f;
public static void Fill(IReadOnlyList<CardViewModel> cards, List<WaypointDivider> into, out string signature)
{
into.Clear();
signature = "";
if (cards == null || cards.Count == 0) return;
if (!CarCardsModule.Settings.showWaypointCuts) return;
if (!WaypointQueueBridge.IsInstalled) return;
BaseLocomotive? loco = LocoWithQueue(cards);
if (loco == null) return;
if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0)
return;
Color color = MapWaypointSystem.ColorForLoco(loco.id);
var present = new HashSet<string>(StringComparer.Ordinal);
string seedId = "";
for (int i = 0; i < cards.Count; i++)
{
string id = cards[i].Id;
if (string.IsNullOrEmpty(id)) continue;
present.Add(id);
if (seedId.Length == 0) seedId = id;
}
if (present.Count == 0) return;
// Couple-walk order (A-end to B-end), not the camera fan. View reverse
// only affects where dividers are drawn, not which cars they sit between.
var remaining = IdsOf(seedId);
remaining.RemoveAll(id => !present.Contains(id));
if (remaining.Count == 0)
{
remaining = new List<string>(present.Count);
for (int i = 0; i < cards.Count; i++)
{
string id = cards[i].Id;
if (!string.IsNullOrEmpty(id))
remaining.Add(id);
}
}
var parked = new List<ParkedCut>();
var sig = new System.Text.StringBuilder(loco.id);
sig.Append(':').Append(snaps.Count);
if (remaining.Count > 0)
sig.Append(':').Append(remaining[0]).Append('-').Append(remaining[remaining.Count - 1]);
for (int w = 0; w < snaps.Count; w++)
{
var wp = snaps[w];
sig.Append('|').Append(wp.Number)
.Append(':').Append(wp.CouplingSearchMode)
.Append(':').Append(wp.UncouplingMode)
.Append(':').Append(wp.NumberOfCarsToCut)
.Append(':').Append(wp.CoupleToCarId);
if (!Apply(wp, remaining, parked, present, into, color))
break;
}
signature = sig.ToString();
}
static bool Apply(
WaypointQueueBridge.WqWaypointSnap wp,
List<string> remaining,
List<ParkedCut> parked,
HashSet<string> present,
List<WaypointDivider> into,
Color color)
{
if (remaining.Count == 0) return false;
bool coupling = WaypointQueueBridge.Coupling(wp);
string coupleId = CoupleId(wp);
if (coupling && string.IsNullOrEmpty(coupleId)
&& string.Equals(wp.CouplingSearchMode, "Nearest", StringComparison.OrdinalIgnoreCase))
{
if (!TryResolveNearest(wp, remaining, parked, out coupleId))
return false;
}
if (coupling && string.IsNullOrEmpty(coupleId)
&& string.Equals(wp.CouplingSearchMode, "SpecificCar", StringComparison.OrdinalIgnoreCase))
coupleId = wp.CouplingSearchResultCarId ?? "";
if (Is(wp.UncouplingMode, "ByDestinationArea")
|| Is(wp.UncouplingMode, "ByDestinationIndustry")
|| Is(wp.UncouplingMode, "ByDestinationTrack"))
return true;
if (coupling && wp.Pickup && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0)
{
if (string.IsNullOrEmpty(coupleId)) return false;
bool onTrain = remaining.Contains(coupleId);
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
return false;
if (!onTrain)
AddEndDivider(into, wp, color, WaypointAction.Pickup, remaining, atStart);
if (!TryMergeForeign(remaining, coupleId, atStart, wp.NumberOfCarsToCut, parked, wp))
return false;
return true;
}
if (coupling && wp.Dropoff && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0)
{
if (string.IsNullOrEmpty(coupleId)) return false;
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
return false;
if (!TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart: atStart, parked, wp, into, color, WaypointAction.Drop, present))
return remaining.Count > 0;
return true;
}
if (coupling && Is(wp.UncouplingMode, "BySpecificCar"))
return true;
if (coupling)
{
if (string.IsNullOrEmpty(coupleId)) return false;
bool onTrain = remaining.Contains(coupleId);
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
return false;
if (!onTrain)
AddEndDivider(into, wp, color, WaypointAction.Couple, remaining, atStart);
if (!TryMergeForeign(remaining, coupleId, atStart, keep: -1, parked, wp))
return false;
return true;
}
if (Is(wp.UncouplingMode, "AllExceptLocomotives"))
{
CutAllExceptLocos(remaining, parked, wp, into, color, present);
return remaining.Count > 0;
}
if (Is(wp.UncouplingMode, "ByCount"))
{
bool fromStart = !TakeFromLast(remaining, wp);
TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart, parked, wp, into, color, WaypointAction.Cut, present);
return remaining.Count > 0;
}
if (Is(wp.UncouplingMode, "BySpecificCar"))
{
string spec = wp.UncouplingSearchResultCarId ?? "";
if (string.IsNullOrEmpty(spec)) return true;
int idx = remaining.IndexOf(spec);
if (idx < 0) return true;
bool fromStart = !TakeFromLast(remaining, wp);
int n = fromStart ? idx + 1 : remaining.Count - idx;
TryCutCount(remaining, n, fromStart, parked, wp, into, color, WaypointAction.Cut, present);
return remaining.Count > 0;
}
return true;
}
static string CoupleId(WaypointQueueBridge.WqWaypointSnap wp)
{
if (!string.IsNullOrEmpty(wp.CoupleToCarId)) return wp.CoupleToCarId;
if (!string.IsNullOrEmpty(wp.CouplingSearchResultCarId)) return wp.CouplingSearchResultCarId;
return "";
}
static bool TryCutCount(
List<string> remaining,
int n,
bool fromStart,
List<ParkedCut> parked,
WaypointQueueBridge.WqWaypointSnap wp,
List<WaypointDivider> into,
Color color,
WaypointAction action,
HashSet<string> present)
{
if (n <= 0 || n >= remaining.Count) return false;
string left;
string right;
List<string> cut;
if (fromStart)
{
left = remaining[n - 1];
right = remaining[n];
cut = remaining.GetRange(0, n);
remaining.RemoveRange(0, n);
}
else
{
int keep = remaining.Count - n;
left = remaining[keep - 1];
right = remaining[keep];
cut = remaining.GetRange(keep, n);
remaining.RemoveRange(keep, n);
}
Park(parked, cut, wp);
if (present.Contains(left) || present.Contains(right))
{
into.Add(DividerFrom(wp, color, action, left, right));
}
return true;
}
static void CutAllExceptLocos(
List<string> remaining,
List<ParkedCut> parked,
WaypointQueueBridge.WqWaypointSnap wp,
List<WaypointDivider> into,
Color color,
HashSet<string> present)
{
var keep = new List<string>();
var cut = new List<string>();
for (int i = 0; i < remaining.Count; i++)
{
if (IsLocoType(remaining[i])) keep.Add(remaining[i]);
else cut.Add(remaining[i]);
}
if (cut.Count == 0) return;
for (int i = 0; i < remaining.Count - 1; i++)
{
bool a = IsLocoType(remaining[i]);
bool b = IsLocoType(remaining[i + 1]);
if (a == b) continue;
string left = remaining[i];
string right = remaining[i + 1];
if (!present.Contains(left) && !present.Contains(right)) continue;
into.Add(DividerFrom(wp, color, WaypointAction.Cut, left, right));
}
Park(parked, cut, wp);
remaining.Clear();
remaining.AddRange(keep);
}
static void AddEndDivider(
List<WaypointDivider> into,
WaypointQueueBridge.WqWaypointSnap wp,
Color color,
WaypointAction action,
List<string> remaining,
bool atStart)
{
if (remaining.Count == 0) return;
string end;
string inward;
if (atStart)
{
end = remaining[0];
inward = remaining.Count > 1 ? remaining[1] : "";
}
else
{
end = remaining[remaining.Count - 1];
inward = remaining.Count > 1 ? remaining[remaining.Count - 2] : "";
}
var d = DividerFrom(wp, color, action, end, inward);
d.Outer = true;
into.Add(d);
}
static WaypointDivider DividerFrom(
WaypointQueueBridge.WqWaypointSnap wp,
Color color,
WaypointAction action,
string leftId,
string rightId)
{
return new WaypointDivider
{
Number = wp.Number,
Color = color,
Action = action,
LeftId = leftId,
RightId = rightId,
WaypointId = wp.Id ?? "",
HasPosition = wp.HasPosition,
Position = wp.Position,
Rotation = wp.Rotation,
Headline = Headline(action, wp),
Detail = DetailLines(wp, action),
};
}
static string Headline(WaypointAction action, WaypointQueueBridge.WqWaypointSnap wp)
{
int n = wp.NumberOfCarsToCut;
return action switch
{
WaypointAction.Pickup => n > 0 ? $"Pickup {n}" : "Pickup",
WaypointAction.Drop => n > 0 ? $"Drop {n}" : "Drop",
WaypointAction.Couple => "Couple",
_ => n > 0 ? $"Cut {n}" : "Cut",
};
}
static string DetailLines(WaypointQueueBridge.WqWaypointSnap wp, WaypointAction action)
{
var lines = new List<string>();
if (!string.IsNullOrEmpty(wp.Name))
lines.Add(wp.Name);
string place = PlaceName(wp);
if (!string.IsNullOrEmpty(place))
lines.Add(place);
string couple = CarMark(wp.CoupleToCarId);
if (string.IsNullOrEmpty(couple))
couple = CarMark(wp.CouplingSearchResultCarId);
if (!string.IsNullOrEmpty(couple) && action != WaypointAction.Cut)
lines.Add("To " + couple);
if (action == WaypointAction.Cut && wp.NumberOfCarsToCut > 0)
lines.Add(wp.CountFromNearest ? "Nearest end" : "Furthest end");
if (wp.WillWait)
lines.Add(wp.WaitMinutes > 0 ? $"Wait {wp.WaitMinutes} min" : "Wait");
if (wp.WillRefuel)
lines.Add(string.IsNullOrEmpty(wp.RefuelLoad) ? "Refuel" : "Refuel " + wp.RefuelLoad);
if (!string.IsNullOrEmpty(wp.Notes))
lines.Add(wp.Notes);
return string.Join("\n", lines);
}
static string PlaceName(WaypointQueueBridge.WqWaypointSnap wp)
{
if (!string.IsNullOrEmpty(wp.AreaName))
return wp.AreaName;
if (!wp.HasPosition) return "";
try
{
var ops = OpsController.Shared;
if (ops == null) return "";
Area? area = ops.ClosestAreaForGamePosition(wp.Position);
return area != null ? area.name : "";
}
catch { return ""; }
}
static string CarMark(string id)
{
if (string.IsNullOrEmpty(id)) return "";
Car? c = ConsistBinder.Find(id);
if (c == null) return "";
try
{
return string.IsNullOrEmpty(c.DisplayName) ? id : c.DisplayName;
}
catch { return id; }
}
static bool TryCoupleEnd(
List<string> remaining,
WaypointQueueBridge.WqWaypointSnap wp,
string coupleId,
out string endId,
out bool atStart)
{
endId = "";
atStart = false;
if (remaining.Count == 0) return false;
int already = remaining.IndexOf(coupleId);
if (already >= 0)
{
atStart = already * 2 < remaining.Count;
endId = coupleId;
return true;
}
Vector3 hint = wp.HasPosition ? wp.Position : default;
if (TryGamePosId(coupleId, out Vector3 couplePos))
hint = couplePos;
else if (!wp.HasPosition)
return false;
atStart = !NearIsLast(remaining, hint);
endId = atStart ? remaining[0] : remaining[remaining.Count - 1];
return true;
}
static bool TryMergeForeign(
List<string> remaining,
string coupleId,
bool atStart,
int keep,
List<ParkedCut> parked,
WaypointQueueBridge.WqWaypointSnap wp)
{
if (remaining.Contains(coupleId)) return true;
var foreign = IdsOf(coupleId);
if (foreign.Count == 0) return false;
foreign.RemoveAll(remaining.Contains);
int ci = foreign.IndexOf(coupleId);
if (ci < 0)
{
foreign.Insert(0, coupleId);
ci = 0;
}
if (ci != 0 && ci != foreign.Count - 1)
return false;
if (ci == 0 && atStart) foreign.Reverse();
if (ci != 0 && !atStart) foreign.Reverse();
if (keep >= 0 && keep < foreign.Count)
{
List<string> extra;
List<string> take;
if (atStart)
{
int drop = foreign.Count - keep;
extra = foreign.GetRange(0, drop);
take = foreign.GetRange(drop, keep);
}
else
{
take = foreign.GetRange(0, keep);
extra = foreign.GetRange(keep, foreign.Count - keep);
}
if (extra.Count > 0) Park(parked, extra, wp);
foreign = take;
}
if (atStart) remaining.InsertRange(0, foreign);
else remaining.AddRange(foreign);
ForgetParked(parked, foreign);
return true;
}
static bool TryResolveNearest(
WaypointQueueBridge.WqWaypointSnap wp,
List<string> remaining,
List<ParkedCut> parked,
out string coupleId)
{
coupleId = "";
if (!wp.HasPosition) return false;
Vector3 wpPos = wp.Position;
var remainingSet = new HashSet<string>(remaining);
string bestParked = "";
float bestParkedD = NearCoupleM;
for (int i = 0; i < parked.Count; i++)
{
var group = parked[i];
if (!TryLiveGroup(group.Ids, remainingSet, out List<string> live, out float dist, wpPos))
continue;
if (dist >= bestParkedD) continue;
bestParkedD = dist;
bestParked = ClosestId(live, wpPos);
}
if (!string.IsNullOrEmpty(bestParked))
{
coupleId = bestParked;
return true;
}
if (TryPollNearby(wpPos, remainingSet, out string pollId))
{
coupleId = pollId;
return true;
}
return false;
}
static bool TryLiveGroup(
List<string> ids,
HashSet<string> remaining,
out List<string> live,
out float dist,
Vector3 wpPos)
{
live = new List<string>();
dist = float.MaxValue;
string seed = "";
for (int i = 0; i < ids.Count; i++)
{
if (remaining.Contains(ids[i])) continue;
if (ConsistBinder.Find(ids[i]) == null) continue;
seed = ids[i];
break;
}
if (string.IsNullOrEmpty(seed)) return false;
live = IdsOf(seed);
if (live.Count == 0) return false;
bool overlap = false;
for (int i = 0; i < ids.Count; i++)
{
if (live.Contains(ids[i])) { overlap = true; break; }
}
if (!overlap) return false;
string closest = ClosestId(live, wpPos);
if (!TryGamePosId(closest, out Vector3 p)) return false;
dist = Vector3.Distance(p, wpPos);
return dist < NearCoupleM;
}
static bool TryPollNearby(Vector3 wpPos, HashSet<string> remaining, out string id)
{
id = "";
TrainController? tc = null;
try { tc = TrainController.Shared; } catch { }
if (tc?.Cars == null) return false;
float best = NearCoupleM;
foreach (Car c in tc.Cars)
{
if (c == null || remaining.Contains(c.id)) continue;
bool a = false, b = false;
try { a = c[Car.LogicalEnd.A].IsCoupled; } catch { }
try { b = c[Car.LogicalEnd.B].IsCoupled; } catch { }
if (a && b) continue;
if (!TryGamePos(c, out Vector3 p)) continue;
float d = Vector3.Distance(p, wpPos);
if (d >= best) continue;
best = d;
id = c.id;
}
return !string.IsNullOrEmpty(id);
}
static void Park(List<ParkedCut> parked, List<string> ids, WaypointQueueBridge.WqWaypointSnap wp)
{
if (ids == null || ids.Count == 0) return;
var copy = new List<string>(ids);
Vector3 pos = wp.HasPosition ? wp.Position : default;
if (!wp.HasPosition)
{
for (int i = 0; i < copy.Count; i++)
{
if (TryGamePosId(copy[i], out pos)) break;
}
}
parked.Add(new ParkedCut { Ids = copy, Pos = pos });
}
static void ForgetParked(List<ParkedCut> parked, List<string> taken)
{
if (taken.Count == 0) return;
var set = new HashSet<string>(taken);
for (int i = parked.Count - 1; i >= 0; i--)
{
parked[i].Ids.RemoveAll(set.Contains);
if (parked[i].Ids.Count == 0) parked.RemoveAt(i);
}
}
static bool TakeFromLast(List<string> remaining, WaypointQueueBridge.WqWaypointSnap wp)
{
bool nearLast = NearIsLast(remaining, wp.HasPosition ? wp.Position : default);
return wp.CountFromNearest ? nearLast : !nearLast;
}
static bool NearIsLast(List<string> remaining, Vector3 pos)
{
if (remaining.Count < 2) return true;
if (!TryGamePosId(remaining[0], out Vector3 a)) return true;
if (!TryGamePosId(remaining[remaining.Count - 1], out Vector3 b)) return true;
return Vector3.Distance(b, pos) <= Vector3.Distance(a, pos);
}
static List<string> IdsOf(string seedId)
{
var ids = new List<string>();
Car? seed = ConsistBinder.Find(seedId);
if (seed == null) return ids;
try
{
foreach (Car c in seed.EnumerateCoupled())
if (c != null) ids.Add(c.id);
}
catch { }
if (ids.Count == 0) ids.Add(seedId);
return ids;
}
static string ClosestId(List<string> ids, Vector3 pos)
{
string best = ids[0];
float bestD = float.MaxValue;
for (int i = 0; i < ids.Count; i++)
{
if (!TryGamePosId(ids[i], out Vector3 p)) continue;
float d = Vector3.Distance(p, pos);
if (d >= bestD) continue;
bestD = d;
best = ids[i];
}
return best;
}
static bool TryGamePosId(string id, out Vector3 pos)
{
pos = default;
Car? c = ConsistBinder.Find(id);
return c != null && TryGamePos(c, out pos);
}
static bool TryGamePos(Car car, out Vector3 pos)
{
pos = default;
try
{
if (car == null || Graph.Shared == null) return false;
pos = Graph.Shared.GetPosition(car.WheelBoundsA);
return true;
}
catch { return false; }
}
static bool IsLocoType(string id)
{
Car? c = ConsistBinder.Find(id);
if (c == null) return false;
try
{
if (c.IsLocomotive) return true;
var a = c.Archetype;
return a == CarArchetype.LocomotiveDiesel
|| a == CarArchetype.LocomotiveSteam
|| a == CarArchetype.Tender;
}
catch { return c is BaseLocomotive; }
}
static BaseLocomotive? LocoWithQueue(IReadOnlyList<CardViewModel> cards)
{
BaseLocomotive? lead = null;
BaseLocomotive? withQ = null;
for (int i = 0; i < cards.Count; i++)
{
if (cards[i].Car is not BaseLocomotive loco) continue;
lead ??= loco;
if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0)
continue;
bool mu = false;
try { mu = Traverse.Create(loco).Property<bool>("IsMuEnabled").Value; }
catch { }
if (!mu) return loco;
withQ ??= loco;
}
return withQ ?? lead;
}
static bool Is(string value, string name) =>
string.Equals(value, name, StringComparison.OrdinalIgnoreCase);
sealed class ParkedCut
{
public List<string> Ids = new();
public Vector3 Pos;
}
}