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:
parent
5187c03ebe
commit
cc552a0246
16 changed files with 3964 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
59
src/Modules/CarCards/CarCardsModule.cs
Normal file
59
src/Modules/CarCards/CarCardsModule.cs
Normal 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();
|
||||
}
|
||||
1270
src/Modules/CarCards/CarCardsOverlay.cs
Normal file
1270
src/Modules/CarCards/CarCardsOverlay.cs
Normal file
File diff suppressed because it is too large
Load diff
71
src/Modules/CarCards/CarCardsSettings.cs
Normal file
71
src/Modules/CarCards/CarCardsSettings.cs
Normal 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,
|
||||
}
|
||||
153
src/Modules/CarCards/CarCardsSettingsUI.cs
Normal file
153
src/Modules/CarCards/CarCardsSettingsUI.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
78
src/Modules/CarCards/CardClick.cs
Normal file
78
src/Modules/CarCards/CardClick.cs
Normal 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;
|
||||
}
|
||||
155
src/Modules/CarCards/CardNotes.cs
Normal file
155
src/Modules/CarCards/CardNotes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
520
src/Modules/CarCards/CardUi.cs
Normal file
520
src/Modules/CarCards/CardUi.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
239
src/Modules/CarCards/CardViewModel.cs
Normal file
239
src/Modules/CarCards/CardViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
89
src/Modules/CarCards/CardViewOrder.cs
Normal file
89
src/Modules/CarCards/CardViewOrder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
276
src/Modules/CarCards/CardWidget.cs
Normal file
276
src/Modules/CarCards/CardWidget.cs
Normal 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);
|
||||
}
|
||||
111
src/Modules/CarCards/ConsistBinder.cs
Normal file
111
src/Modules/CarCards/ConsistBinder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
127
src/Modules/CarCards/DividerWidget.cs
Normal file
127
src/Modules/CarCards/DividerWidget.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
113
src/Modules/CarCards/FanLayout.cs
Normal file
113
src/Modules/CarCards/FanLayout.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
693
src/Modules/CarCards/WaypointCutSim.cs
Normal file
693
src/Modules/CarCards/WaypointCutSim.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue