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` |
|
| 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. |
|
| 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. |
|
| 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.
|
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
|
## Migrating from the standalone mods
|
||||||
|
|
||||||
S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and
|
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.MiscTweaks.MiscTweaksModule());
|
||||||
_registry.Register(new Modules.Popout.PopoutModule());
|
_registry.Register(new Modules.Popout.PopoutModule());
|
||||||
_registry.Register(new Modules.QuickActions.QuickActionsModule());
|
_registry.Register(new Modules.QuickActions.QuickActionsModule());
|
||||||
|
_registry.Register(new Modules.CarCards.CarCardsModule());
|
||||||
|
|
||||||
_registry.EnableConfigured();
|
_registry.EnableConfigured();
|
||||||
ModConflicts.CheckAtLoad();
|
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