QuickActions: outer-ring cut/couple actions and consist hover wheel

Adds end-gear couple/air/cut on the vanilla pie, plus a consist wheel
for set-lead, brakes, bleed, air, and idle without opening extra windows.
This commit is contained in:
Seton Carmichael 2026-09-11 15:19:23 -04:00
parent 16d118c4a9
commit 5187c03ebe
18 changed files with 3069 additions and 0 deletions

View file

@ -22,6 +22,7 @@ I originally planned on releasing individual mods, but considering my workflow o
| Base Game Performance | Smooths Unity's incremental garbage collector and Nature Renderer grass streaming to reduce camera-motion hitches without lowering visual quality. | | Base Game Performance | Smooths Unity's incremental garbage collector and Nature Renderer grass streaming to reduce camera-motion hitches without lowering visual quality. |
| 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. |
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.
@ -205,6 +206,14 @@ Any key cancels. Enable it from the S³ settings page.
--- ---
## Quick Actions
Extends the rolling-stock radial menu with outer-ring end actions (couple, air,
angle cock, cut) and a hover Consist wheel for train-wide operations (set lead,
handbrakes, bleed, air, idle, select loco). Cut can optionally apply a handbrake.
---
## 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

View file

@ -36,6 +36,7 @@ public static class Main
_registry.Register(new Modules.Profiler.ProfilerModule()); _registry.Register(new Modules.Profiler.ProfilerModule());
_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.EnableConfigured(); _registry.EnableConfigured();
ModConflicts.CheckAtLoad(); ModConflicts.CheckAtLoad();

View file

@ -0,0 +1,389 @@
using System.Collections.Generic;
using Game.Messages;
using Game.State;
using HarmonyLib;
using Model;
using S3.Core;
namespace S3.Modules.QuickActions;
static class ConsistActions
{
public enum Kind
{
SetLead,
ReleaseHandbrakes,
ApplyHandbrakes,
BleedAll,
OpenCocks,
CloseCocks,
ConnectAir,
IdleBail,
SelectLead,
SelectLoco,
}
public static IReadOnlyList<(Kind kind, string label)> VisibleKinds(Car clicked)
{
var s = QuickActionsModule.Settings;
var lead = new List<(Kind, string)>(3);
var body = new List<(Kind, string)>(8);
if (s.consistSetLead && ShouldShow(Kind.SetLead, clicked))
lead.Add((Kind.SetLead, "Set\nLead"));
if (s.consistSelectLead && ShouldShow(Kind.SelectLead, clicked))
lead.Add((Kind.SelectLead, "Select\nLead"));
if (s.consistSelectLoco && ShouldShow(Kind.SelectLoco, clicked))
lead.Add((Kind.SelectLoco, "Select\nLoco"));
if (s.consistReleaseHandbrakes && ShouldShow(Kind.ReleaseHandbrakes, clicked))
body.Add((Kind.ReleaseHandbrakes, "Release All\nHandbrakes"));
if (s.consistApplyHandbrakes && ShouldShow(Kind.ApplyHandbrakes, clicked))
body.Add((Kind.ApplyHandbrakes, "Apply All\nHandbrakes"));
if (s.consistBleedAll && ShouldShow(Kind.BleedAll, clicked))
body.Add((Kind.BleedAll, "Bleed All"));
if (s.consistOpenCocks && ShouldShow(Kind.OpenCocks, clicked))
body.Add((Kind.OpenCocks, "Open All\nAnglecocks"));
if (s.consistCloseCocks && ShouldShow(Kind.CloseCocks, clicked))
body.Add((Kind.CloseCocks, "Close All\nAnglecocks"));
if (s.consistConnectAir && ShouldShow(Kind.ConnectAir, clicked))
body.Add((Kind.ConnectAir, "Attach All\nHoses"));
if (s.consistIdleBail && ShouldShow(Kind.IdleBail, clicked))
body.Add((Kind.IdleBail, "Idle and\nBail All"));
body.InsertRange(body.Count / 2, lead);
return body;
}
public static bool AnyVisible(Car clicked) => VisibleKinds(clicked).Count > 0;
static bool ShouldShow(Kind kind, Car clicked)
{
return kind switch
{
Kind.SetLead => clicked is BaseLocomotive && CountLocos(clicked) >= 2,
Kind.SelectLead => TryLead(clicked, out BaseLocomotive lead) && lead != clicked,
Kind.SelectLoco => TryOnlyLoco(clicked, out BaseLocomotive loco) && loco != clicked,
Kind.IdleBail => CountLocos(clicked) >= 1,
Kind.ConnectAir => CountAirJoints(clicked, QuickActionsModule.Settings.consistConnectAirExcludeLocos) > 0,
Kind.ReleaseHandbrakes => CountAffected(kind, clicked) > 0,
Kind.ApplyHandbrakes => CountAffected(kind, clicked) > 0,
Kind.BleedAll => CountAffected(kind, clicked) > 0,
Kind.OpenCocks => CountAffected(kind, clicked) > 0,
Kind.CloseCocks => CountAffected(kind, clicked) > 0,
_ => false,
};
}
public static bool CanRun(Kind kind, Car clicked)
{
return kind switch
{
Kind.SetLead => clicked is BaseLocomotive && CountLocos(clicked) >= 2,
Kind.IdleBail => CountLocos(clicked) >= 1,
Kind.SelectLead => TryLead(clicked, out BaseLocomotive lead) && lead != clicked,
Kind.SelectLoco => TryOnlyLoco(clicked, out BaseLocomotive loco) && loco != clicked,
_ => true,
};
}
public static void Run(Kind kind, Car clicked)
{
if (!CanRun(kind, clicked)) return;
switch (kind)
{
case Kind.SetLead:
MuConsistAction.Run(clicked);
break;
case Kind.ReleaseHandbrakes:
ForEachCar(clicked, QuickActionsModule.Settings.consistReleaseHandbrakesExcludeLocos,
c => c.SetHandbrake(false));
break;
case Kind.ApplyHandbrakes:
ForEachCar(clicked, QuickActionsModule.Settings.consistApplyHandbrakesExcludeLocos,
c => c.SetHandbrake(true));
break;
case Kind.BleedAll:
ForEachCar(clicked, QuickActionsModule.Settings.consistBleedAllExcludeLocos, c =>
{
if (c.SupportsBleed())
c.SetBleed();
});
break;
case Kind.OpenCocks:
SetAllCocks(clicked, 1f, QuickActionsModule.Settings.consistOpenCocksExcludeLocos);
break;
case Kind.CloseCocks:
SetAllCocks(clicked, 0f, QuickActionsModule.Settings.consistCloseCocksExcludeLocos);
break;
case Kind.ConnectAir:
ConnectAllAir(clicked, QuickActionsModule.Settings.consistConnectAirExcludeLocos);
break;
case Kind.IdleBail:
IdleBail(clicked);
break;
case Kind.SelectLead:
if (TryLead(clicked, out BaseLocomotive lead))
Select(lead);
break;
case Kind.SelectLoco:
if (TryOnlyLoco(clicked, out BaseLocomotive loco))
Select(loco);
break;
}
}
public static string Preview(Kind kind, Car clicked)
{
int n = CountAffected(kind, clicked);
return kind switch
{
Kind.SetLead => "Set this locomotive as lead",
Kind.ReleaseHandbrakes => $"Release handbrake on {n}",
Kind.ApplyHandbrakes => $"Apply handbrake on {n}",
Kind.BleedAll => $"Bleed {n}",
Kind.OpenCocks => "",
Kind.CloseCocks => "",
Kind.ConnectAir => "",
Kind.IdleBail => $"Idle and bail {n}",
Kind.SelectLead => TryLead(clicked, out BaseLocomotive lead)
? $"Select {lead.DisplayName}"
: "",
Kind.SelectLoco => TryOnlyLoco(clicked, out BaseLocomotive loco)
? $"Select {loco.DisplayName}"
: "",
_ => "",
};
}
public static int CountAffected(Kind kind, Car clicked)
{
var s = QuickActionsModule.Settings;
return kind switch
{
Kind.ReleaseHandbrakes => CountCars(clicked, s.consistReleaseHandbrakesExcludeLocos),
Kind.ApplyHandbrakes => CountCars(clicked, s.consistApplyHandbrakesExcludeLocos),
Kind.BleedAll => CountCars(clicked, s.consistBleedAllExcludeLocos, c => c.SupportsBleed()),
Kind.OpenCocks => CountCars(clicked, s.consistOpenCocksExcludeLocos),
Kind.CloseCocks => CountCars(clicked, s.consistCloseCocksExcludeLocos),
Kind.ConnectAir => CountAirJoints(clicked, s.consistConnectAirExcludeLocos),
Kind.IdleBail => CountLocos(clicked),
Kind.SetLead => 1,
Kind.SelectLead => 1,
Kind.SelectLoco => 1,
_ => 0,
};
}
static int CountCars(Car origin, bool excludeLocos, System.Func<Car, bool>? pred = null)
{
int n = 0;
try
{
foreach (Car c in origin.EnumerateCoupled())
{
if (excludeLocos && c is BaseLocomotive) continue;
if (pred != null && !pred(c)) continue;
n++;
}
}
catch { /* counted what we could */ }
return n;
}
static int CountAirJoints(Car origin, bool excludeLocos)
{
int n = 0;
Car? prev = null;
try
{
foreach (Car c in origin.EnumerateCoupled())
{
if (prev != null && WouldConnect(prev, c, excludeLocos))
n++;
prev = c;
}
}
catch { /* counted what we could */ }
return n;
}
static bool WouldConnect(Car a, Car b, bool excludeLocos)
{
if (excludeLocos && (a is BaseLocomotive || b is BaseLocomotive))
return false;
Car.LogicalEnd? joint = null;
if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.A, out Car nA, out _) && nA == b)
joint = Car.LogicalEnd.A;
else if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.B, out Car nB, out _) && nB == b)
joint = Car.LogicalEnd.B;
if (joint == null) return false;
return a[joint.Value].IsCoupled && !a[joint.Value].IsAirConnected;
}
static int CountLocos(Car origin)
{
int n = 0;
try
{
foreach (Car c in origin.EnumerateCoupled())
{
if (c is BaseLocomotive)
n++;
}
}
catch { /* counted what we could */ }
return n;
}
static List<BaseLocomotive> CollectLocos(Car origin)
{
var list = new List<BaseLocomotive>();
try
{
foreach (Car c in origin.EnumerateCoupled())
{
if (c is BaseLocomotive loco)
list.Add(loco);
}
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] loco walk failed: {e.Message}");
}
return list;
}
static bool TryLead(Car origin, out BaseLocomotive lead)
{
lead = null!;
var locos = CollectLocos(origin);
if (locos.Count < 2) return false;
foreach (BaseLocomotive loco in locos)
{
if (!IsMuOn(loco))
{
lead = loco;
return true;
}
}
lead = locos[0];
return true;
}
static bool TryOnlyLoco(Car origin, out BaseLocomotive loco)
{
loco = null!;
var locos = CollectLocos(origin);
if (locos.Count != 1) return false;
loco = locos[0];
return true;
}
static bool IsMuOn(BaseLocomotive loco)
{
try
{
return Traverse.Create(loco).Property<bool>("IsMuEnabled").Value;
}
catch
{
return false;
}
}
static void Select(Car car)
{
try
{
if (TrainController.Shared != null)
TrainController.Shared.SelectedCar = car;
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] select {car.DisplayName} failed: {e.Message}");
}
}
static void ForEachCar(Car origin, bool excludeLocos, System.Action<Car> act)
{
try
{
foreach (Car c in origin.EnumerateCoupled())
{
if (excludeLocos && c is BaseLocomotive) continue;
try { act(c); }
catch (System.Exception e)
{
Log.Warn($"[quickactions] consist action failed on {c.DisplayName}: {e.Message}");
}
}
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] EnumerateCoupled failed: {e.Message}");
}
}
static void SetAllCocks(Car origin, float value, bool excludeLocos)
{
ForEachCar(origin, excludeLocos, c =>
{
c.ApplyEndGearChange(Car.LogicalEnd.A, Car.EndGearStateKey.Anglecock, value);
c.ApplyEndGearChange(Car.LogicalEnd.B, Car.EndGearStateKey.Anglecock, value);
});
}
static void ConnectAllAir(Car origin, bool excludeLocos)
{
Car? prev = null;
try
{
foreach (Car c in origin.EnumerateCoupled())
{
if (prev != null)
TryConnectPair(prev, c, excludeLocos);
prev = c;
}
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] connect-air walk failed: {e.Message}");
}
}
static void TryConnectPair(Car a, Car b, bool excludeLocos)
{
if (excludeLocos && (a is BaseLocomotive || b is BaseLocomotive))
return;
Car.LogicalEnd? joint = null;
if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.A, out Car nA, out _) && nA == b)
joint = Car.LogicalEnd.A;
else if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.B, out Car nB, out _) && nB == b)
joint = Car.LogicalEnd.B;
if (joint == null) return;
if (!a[joint.Value].IsCoupled || a[joint.Value].IsAirConnected) return;
try
{
var msg = new SetGladhandsConnected(a.id, b.id, true);
if (!StateManager.CheckAuthorizedToSendMessage(msg)) return;
StateManager.ApplyLocal(msg);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] connect air {a.DisplayName}/{b.DisplayName}: {e.Message}");
}
}
static void IdleBail(Car origin)
{
ForEachCar(origin, excludeLocos: false, c =>
{
if (c is not BaseLocomotive loco) return;
loco.SendPropertyChange(PropertyChange.Control.Throttle, 0f);
if (loco.ControlHelper != null)
loco.ControlHelper.BailOff();
else
loco.SendPropertyChange(PropertyChange.Control.LocomotiveBrake, -0.1f);
});
}
}

View file

@ -0,0 +1,10 @@
using UnityEngine;
using UnityEngine.EventSystems;
namespace S3.Modules.QuickActions;
sealed class ConsistSlotHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
public void OnPointerEnter(PointerEventData eventData) => EndGearOverlay.NotifyConsistHover(true);
public void OnPointerExit(PointerEventData eventData) => EndGearOverlay.NotifyConsistHover(false);
}

View file

@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Game.Messages;
using Game.State;
using HarmonyLib;
using Model;
using RollingStock;
using UI;
using UI.ContextMenu;
using UnityEngine;
using UnityEngine.UI;
using GameContextMenu = UI.ContextMenu.ContextMenu;
namespace S3.Modules.QuickActions;
[HarmonyPatch(typeof(CarPickable), "HandleShowContextMenu")]
static class CarPickableContextMenuPatch
{
static void Prefix(Car car) => ContextMenuActions.Stash(car);
}
[HarmonyPatch(typeof(GameContextMenu), nameof(GameContextMenu.Show))]
static class ContextMenuShowPatch
{
static void Prefix(GameContextMenu __instance) => ContextMenuActions.InjectIfNeeded(__instance);
static void Postfix(GameContextMenu __instance) =>
EndGearOverlay.Attach(__instance, ContextMenuActions.MenuCar);
}
// Vanilla sizes wedges by quadrant home-angle. Extra items bunch and desync
// hitboxes. After that pass, space every item evenly, clockwise from 12 o'clock
// — the same direction GetItemExtentAngles / WedgeImage already assume.
[HarmonyPatch(typeof(GameContextMenu), "BuildItemAngles")]
static class ContextMenuEvenLayoutPatch
{
static void Postfix(GameContextMenu __instance)
{
var t = Traverse.Create(__instance);
var quadrants = t.Field<List<List<ContextMenuItem>>>("_quadrants").Value;
var itemAngles = t.Field<Dictionary<(ContextMenuQuadrant quadrant, int index), float>>("_itemAngles").Value;
if (quadrants == null || itemAngles == null) return;
var keys = new List<(ContextMenuQuadrant quadrant, int index)>();
for (int q = 0; q < quadrants.Count; q++)
{
List<ContextMenuItem> list = quadrants[q];
for (int i = 0; i < list.Count; i++)
keys.Add(((ContextMenuQuadrant)q, i));
}
if (keys.Count == 0) return;
float step = 360f / keys.Count;
itemAngles.Clear();
for (int i = 0; i < keys.Count; i++)
{
// Walk order is clockwise (decreasing angle). Keep (0, 360] so
// WedgeImage.IsRaycastLocationValid does not see a negative start.
float ang = 90f - i * step;
if (ang <= 0f) ang += 360f;
itemAngles[keys[i]] = ang;
}
}
}
// LerpAngle can still hand SetAngle a negative start when a slice crosses 0°.
[HarmonyPatch(typeof(ContextMenuItem), nameof(ContextMenuItem.SetAngle))]
static class ContextMenuItemSetAnglePatch
{
static void Postfix(ContextMenuItem __instance)
{
if (__instance.wedgeImage == null) return;
__instance.wedgeImage.startAngle = Mathf.Repeat(__instance.wedgeImage.startAngle, 360f);
}
}
[HarmonyPatch]
static class ContextMenuHidePatch
{
static IEnumerable<System.Reflection.MethodBase> TargetMethods()
{
foreach (var m in typeof(GameContextMenu).GetMethods(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
if (m.Name == "Hide")
yield return m;
}
}
static void Prefix()
{
EndGearOverlay.Detach();
ContextMenuActions.ClearMenuCar();
}
}
static class ContextMenuActions
{
static Car? _pending;
static Car? _menuCar;
static Sprite? _consistSprite;
static bool _consistSpriteTried;
public static Car? MenuCar => _menuCar;
public static ContextMenuItem? ConsistItem { get; private set; }
public static void Stash(Car car)
{
_pending = car;
_menuCar = car;
ConsistItem = null;
}
public static void ClearMenuCar()
{
_menuCar = null;
ConsistItem = null;
}
public static void InjectIfNeeded(GameContextMenu menu)
{
Car? car = _pending;
_pending = null;
if (car == null) return;
if (!ShouldOfferConsist(car)) return;
Sprite? custom = ConsistSprite();
Sprite sprite = custom ?? SpriteName.Select.Sprite();
menu.AddButton(
ContextMenuQuadrant.Unused1,
"Consist",
sprite,
() => { });
try
{
var quadrants = Traverse.Create(menu).Field<List<List<ContextMenuItem>>>("_quadrants").Value;
List<ContextMenuItem>? ours = quadrants?[(int)ContextMenuQuadrant.Unused1];
ContextMenuItem? item = ours is { Count: > 0 } ? ours[ours.Count - 1] : null;
if (item == null) return;
// Keep the pie open; consist work lives on the satellite wheel.
item.OnClick = () => { };
ConsistItem = item;
var hover = item.gameObject.GetComponent<ConsistSlotHover>()
?? item.gameObject.AddComponent<ConsistSlotHover>();
hover.enabled = true;
if (item.image == null) return;
// Glyph is already #cdb993; white tint lets that color through.
item.image.color = Color.white;
if (custom == null) return;
item.image.preserveAspect = true;
item.image.raycastTarget = false;
var ignore = item.image.gameObject.GetComponent<LayoutElement>()
?? item.image.gameObject.AddComponent<LayoutElement>();
ignore.ignoreLayout = true;
var facing = item.image.gameObject.GetComponent<SetLeadIconFacing>()
?? item.image.gameObject.AddComponent<SetLeadIconFacing>();
facing.Car = car;
facing.Image = item.image;
facing.FlipFacing = false;
facing.TargetSize = new Vector2(40f, 40f);
}
catch (Exception e)
{
Core.Log.Warn($"[quickactions] consist icon polish failed: {e.Message}");
}
}
static Sprite? ConsistSprite()
{
if (_consistSpriteTried) return _consistSprite;
_consistSpriteTried = true;
try
{
byte[]? png = ReadEmbeddedPng() ?? ReadLoosePng();
if (png == null || png.Length == 0)
{
Core.Log.Warn("[quickactions] consist.png not found; using Select.");
return null;
}
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: true);
if (!ImageConversion.LoadImage(tex, png, markNonReadable: true))
{
Core.Log.Warn("[quickactions] consist.png failed to decode.");
return null;
}
tex.filterMode = FilterMode.Trilinear;
tex.wrapMode = TextureWrapMode.Clamp;
tex.anisoLevel = 2;
tex.mipMapBias = 0.15f;
_consistSprite = Sprite.Create(
tex,
new Rect(0f, 0f, tex.width, tex.height),
new Vector2(0.5f, 0.5f),
100f);
}
catch (Exception e)
{
Core.Log.Warn($"[quickactions] consist sprite load failed: {e.Message}");
}
return _consistSprite;
}
static byte[]? ReadEmbeddedPng()
{
using Stream? stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("S3.QuickActions.consist.png");
if (stream == null) return null;
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
static byte[]? ReadLoosePng()
{
try
{
string path = Path.Combine(Main.ModEntry.Path, "consist.png");
return File.Exists(path) ? File.ReadAllBytes(path) : null;
}
catch
{
return null;
}
}
static bool ShouldOfferConsist(Car car)
{
return ConsistActions.AnyVisible(car);
}
}

View file

@ -0,0 +1,275 @@
using System.Collections.Generic;
using Game.Messages;
using Game.State;
using Model;
using S3.Core;
namespace S3.Modules.QuickActions;
static class EndGearActions
{
public static bool TryNeighbor(Car car, Car.LogicalEnd end, out Car other, out Car.LogicalEnd otherEnd)
{
otherEnd = end == Car.LogicalEnd.A ? Car.LogicalEnd.B : Car.LogicalEnd.A;
other = null!;
try
{
return car.TryGetAdjacentCar(end, out other) && other != null;
}
catch
{
other = null!;
return false;
}
}
public static bool CanDisconnectAll(Car car, Car.LogicalEnd end)
{
return car[end].IsCoupled;
}
public static bool CanToggleCouple(Car car, Car.LogicalEnd end)
{
if (car[end].IsCoupled) return true;
return TryNeighbor(car, end, out _, out _);
}
public static bool CanToggleAir(Car car, Car.LogicalEnd end)
{
if (car[end].IsAirConnected) return true;
return car[end].IsCoupled && TryNeighbor(car, end, out _, out _);
}
public static void ToggleCouple(Car car, Car.LogicalEnd end)
{
bool couple = !car[end].IsCoupled;
bool hasNeighbor = TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd);
if (couple && !hasNeighbor) return;
try
{
car.ApplyEndGearChange(end, Car.EndGearStateKey.IsCoupled, couple);
if (hasNeighbor)
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.IsCoupled, couple);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] couple toggle failed: {e.Message}");
}
}
public static void ToggleAir(Car car, Car.LogicalEnd end)
{
bool connect = !car[end].IsAirConnected;
if (connect && !car[end].IsCoupled) return;
Car? other = null;
try { other = car.CoupledTo(end) ?? car.AirConnectedTo(end); }
catch { /* fall through */ }
if (other == null && !TryNeighbor(car, end, out other, out _))
return;
if (other == null) return;
try
{
var msg = new SetGladhandsConnected(car.id, other.id, connect);
if (!StateManager.CheckAuthorizedToSendMessage(msg)) return;
StateManager.ApplyLocal(msg);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] air toggle failed: {e.Message}");
}
}
public static void ToggleCock(Car car, Car.LogicalEnd end)
{
float next = car[end].IsAnglecockOpen ? 0f : 1f;
try
{
car.ApplyEndGearChange(end, Car.EndGearStateKey.Anglecock, next);
if (car[end].IsCoupled && TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd))
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.Anglecock, next);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] anglecock toggle failed: {e.Message}");
}
}
public static string Preview(Car car, Car.LogicalEnd end, HoverSlot slot)
{
return slot switch
{
HoverSlot.Cut => CutPreview(CountCut(car, end)),
_ => "",
};
}
static string CutPreview(int n)
{
if (n <= 0) return "";
return $"Cut out {n} Cars";
}
public static int CountCut(Car car, Car.LogicalEnd end)
{
if (!TryNeighbor(car, end, out Car other, out _))
return 0;
return CollectAway(other, car).Count;
}
public static void DisconnectAll(Car car, Car.LogicalEnd end)
{
if (!TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd))
return;
List<Car> detached = CollectAway(other, car);
var s = QuickActionsModule.Settings;
try
{
car.ApplyEndGearChange(end, Car.EndGearStateKey.Anglecock, 0f);
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.Anglecock, 0f);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] drop-all anglecock failed: {e.Message}");
}
if (car[end].IsAirConnected)
{
try
{
var msg = new SetGladhandsConnected(car.id, other.id, false);
if (StateManager.CheckAuthorizedToSendMessage(msg))
StateManager.ApplyLocal(msg);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] drop-all air failed: {e.Message}");
}
}
try
{
car.ApplyEndGearChange(end, Car.EndGearStateKey.IsCoupled, false);
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.IsCoupled, false);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] drop-all couple failed: {e.Message}");
}
if (!s.dropHandbrakeOnCut) return;
foreach (Car c in detached)
{
if (s.dropHandbrakeExcludeLocos && c is BaseLocomotive) continue;
try { c.SetHandbrake(true); }
catch (System.Exception e)
{
Log.Warn($"[quickactions] drop-all handbrake {c.DisplayName}: {e.Message}");
}
}
}
public static bool IsMadeUp(Car car, Car.LogicalEnd end)
{
try
{
var g = car[end];
return g.IsCoupled && g.IsAirConnected && g.IsAnglecockOpen;
}
catch { return false; }
}
public static bool CanToggleJoint(Car car, Car.LogicalEnd end)
{
try
{
if (car[end].IsCoupled) return true;
return TryNeighbor(car, end, out _, out _);
}
catch { return false; }
}
public static void ToggleJoint(Car car, Car.LogicalEnd end)
{
if (IsMadeUp(car, end)) DisconnectAll(car, end);
else MakeUp(car, end);
}
public static void MakeUp(Car car, Car.LogicalEnd end)
{
bool hasNeighbor = TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd);
if (!hasNeighbor)
{
try { if (!car[end].IsCoupled) return; }
catch { return; }
}
try
{
if (!car[end].IsCoupled)
{
car.ApplyEndGearChange(end, Car.EndGearStateKey.IsCoupled, true);
if (hasNeighbor)
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.IsCoupled, true);
}
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] make-up couple failed: {e.Message}");
}
Car? airOther = null;
try { airOther = car.CoupledTo(end) ?? other; }
catch { airOther = other; }
if (airOther != null)
{
try
{
if (!car[end].IsAirConnected)
{
var msg = new SetGladhandsConnected(car.id, airOther.id, true);
if (StateManager.CheckAuthorizedToSendMessage(msg))
StateManager.ApplyLocal(msg);
}
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] make-up air failed: {e.Message}");
}
}
try
{
car.ApplyEndGearChange(end, Car.EndGearStateKey.Anglecock, 1f);
if (hasNeighbor)
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.Anglecock, 1f);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] make-up Anglecock failed: {e.Message}");
}
}
static List<Car> CollectAway(Car start, Car blocked)
{
var list = new List<Car>();
var seen = new HashSet<Car> { blocked };
var stack = new Stack<Car>();
stack.Push(start);
while (stack.Count > 0)
{
Car c = stack.Pop();
if (!seen.Add(c)) continue;
list.Add(c);
if (TryNeighbor(c, Car.LogicalEnd.A, out Car a, out _) && !seen.Contains(a))
stack.Push(a);
if (TryNeighbor(c, Car.LogicalEnd.B, out Car b, out _) && !seen.Contains(b))
stack.Push(b);
}
return list;
}
}

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -0,0 +1,126 @@
"""Square consist glyph: curved track with boxcars. Factory icon cream #cdb993."""
import math
from PIL import Image, ImageDraw, ImageFilter
OUT = r"D:\Seton\Documents\Projects\Railroader\SetonsSpecialSauce\src\Modules\QuickActions\Icons\consist.png"
CREAM = (0xCD, 0xB9, 0x93, 255)
SRC = 256
def lerp(a, b, t):
return a + (b - a) * t
def curve(t):
# S-curve from lower-left to upper-right so the glyph fills a square.
p0 = (36.0, 220.0)
p1 = (52.0, 20.0)
p2 = (204.0, 236.0)
p3 = (220.0, 36.0)
u = 1.0 - t
x = u**3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t**3 * p3[0]
y = u**3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t**3 * p3[1]
return x, y
def deriv(t):
dt = 0.002
x0, y0 = curve(max(0.0, t - dt))
x1, y1 = curve(min(1.0, t + dt))
dx, dy = x1 - x0, y1 - y0
l = math.hypot(dx, dy) or 1.0
return dx / l, dy / l
def normal(t):
tx, ty = deriv(t)
return -ty, tx
def polyline(draw, offset, width, t0=0.0, t1=1.0, n=140):
pts = []
for i in range(n + 1):
t = lerp(t0, t1, i / n)
x, y = curve(t)
nx, ny = normal(t)
pts.append((x + nx * offset, y + ny * offset))
draw.line(pts, fill=CREAM, width=width, joint="curve")
r = width * 0.5
draw.ellipse((pts[0][0] - r, pts[0][1] - r, pts[0][0] + r, pts[0][1] + r), fill=CREAM)
draw.ellipse((pts[-1][0] - r, pts[-1][1] - r, pts[-1][0] + r, pts[-1][1] + r), fill=CREAM)
def rotated_rect(cx, cy, w, h, ang):
ca, sa = math.cos(ang), math.sin(ang)
hw, hh = w * 0.5, h * 0.5
local = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
return [(cx + x * ca - y * sa, cy + x * sa + y * ca) for x, y in local]
def outline_poly(draw, pts, width):
closed = pts + [pts[0]]
draw.line(closed, fill=CREAM, width=width, joint="curve")
r = width * 0.48
for x, y in pts:
draw.ellipse((x - r, y - r, x + r, y + r), fill=CREAM)
im = Image.new("RGBA", (SRC, SRC), (0, 0, 0, 0))
d = ImageDraw.Draw(im)
gauge = 11.0
rail_w = 10
box_stroke = 9
# Track a bit longer than the cut
polyline(d, -gauge, rail_w, t0=0.0, t1=1.0)
polyline(d, gauge, rail_w, t0=0.0, t1=1.0)
# A few ties so it reads as track at pie size; skip the dense ladder.
for i in range(7):
t = lerp(0.08, 0.92, i / 6)
x, y = curve(t)
nx, ny = normal(t)
span = gauge + 5
d.line(
(x - nx * span, y - ny * span, x + nx * span, y + ny * span),
fill=CREAM,
width=7,
)
car_len, car_wid = 44.0, 32.0
ts = (0.18, 0.39, 0.61, 0.82)
for t in ts:
x, y = curve(t)
tx, ty = deriv(t)
ang = math.atan2(ty, tx)
pts = rotated_rect(x, y, car_len, car_wid, ang)
outline_poly(d, pts, box_stroke)
# Crop to ink and fit into 128 with even padding
px = im.load()
minx, miny, maxx, maxy = SRC, SRC, 0, 0
for y in range(SRC):
for x in range(SRC):
if px[x, y][3] > 16:
if x < minx: minx = x
if y < miny: miny = y
if x > maxx: maxx = x
if y > maxy: maxy = y
pad = 8
minx = max(0, minx - pad)
miny = max(0, miny - pad)
maxx = min(SRC - 1, maxx + pad)
maxy = min(SRC - 1, maxy + pad)
crop = im.crop((minx, miny, maxx + 1, maxy + 1))
cw, ch = crop.size
side = 128
scale = min((side - 8) / cw, (side - 8) / ch)
nw, nh = max(1, int(round(cw * scale))), max(1, int(round(ch * scale)))
fitted = crop.resize((nw, nh), Image.Resampling.LANCZOS)
out = Image.new("RGBA", (side, side), (0, 0, 0, 0))
out.paste(fitted, ((side - nw) // 2, (side - nh) // 2), fitted)
out = out.filter(ImageFilter.UnsharpMask(radius=1.0, percent=100, threshold=2))
out.save(OUT, "PNG")
print("crop", cw, "x", ch, "fitted", nw, "x", nh)

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,162 @@
using System.Collections;
using System.Collections.Generic;
using Game.Messages;
using Game.State;
using Model;
using S3.Core;
using UnityEngine;
namespace S3.Modules.QuickActions;
static class MuConsistAction
{
public static void Run(Car clicked)
{
if (clicked is not BaseLocomotive lead)
return;
List<BaseLocomotive> locos;
try
{
locos = CollectLocos(lead);
}
catch (System.Exception e)
{
Log.Error($"[quickactions] Set Lead walk failed: {e}");
return;
}
if (locos.Count < 2)
{
Log.Info("[quickactions] Set Lead: no other locomotives in this cut.");
return;
}
var host = QuickActionsModule.Host;
if (host == null)
{
ApplyImmediate(lead, locos);
return;
}
host.StopAllCoroutines();
host.StartCoroutine(ApplyRoutine(lead, locos));
}
static List<BaseLocomotive> CollectLocos(BaseLocomotive origin)
{
var locos = new List<BaseLocomotive>();
foreach (Car c in origin.EnumerateCoupled())
{
if (c is BaseLocomotive loco)
locos.Add(loco);
}
return locos;
}
static IEnumerator ApplyRoutine(BaseLocomotive lead, List<BaseLocomotive> locos)
{
// AE on a trailer will turn MU back off. Drop those AEs first and let
// OffDuty land before we touch MU / Cut Out.
DisableTrailerAutoEngineers(lead, locos);
yield return null;
// Always cycle trailers off first so DPU-mod / large-consist glitches unstick,
// even when MU/Cut Out already read as on.
foreach (BaseLocomotive loco in locos)
{
if (loco == lead) continue;
SetBool(loco, PropertyChange.Control.Mu, false);
SetBool(loco, PropertyChange.Control.CutOut, false);
}
SetLead(lead);
IdleAll(locos);
yield return null;
foreach (BaseLocomotive loco in locos)
{
if (loco == lead) continue;
SetBool(loco, PropertyChange.Control.CutOut, true);
SetBool(loco, PropertyChange.Control.Mu, true);
}
Log.Info($"[quickactions] Set Lead: {locos.Count - 1} trailer(s) MU'd to {lead.DisplayName}.");
}
static void ApplyImmediate(BaseLocomotive lead, List<BaseLocomotive> locos)
{
DisableTrailerAutoEngineers(lead, locos);
foreach (BaseLocomotive loco in locos)
{
if (loco == lead) continue;
SetBool(loco, PropertyChange.Control.Mu, false);
SetBool(loco, PropertyChange.Control.CutOut, false);
SetBool(loco, PropertyChange.Control.CutOut, true);
SetBool(loco, PropertyChange.Control.Mu, true);
}
SetLead(lead);
IdleAll(locos);
Log.Info($"[quickactions] Set Lead (no host): {locos.Count - 1} trailer(s) MU'd to {lead.DisplayName}.");
}
static void DisableTrailerAutoEngineers(BaseLocomotive lead, List<BaseLocomotive> locos)
{
foreach (BaseLocomotive loco in locos)
{
if (loco == lead) continue;
DisableAutoEngineer(loco);
}
}
static void DisableAutoEngineer(BaseLocomotive loco)
{
try
{
var cmd = new AutoEngineerCommand(loco.id, AutoEngineerMode.Off, true, 0, null, null, null);
StateManager.ApplyLocal(cmd);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] disable AE failed on {loco.DisplayName}: {e.Message}");
}
}
static void SetLead(BaseLocomotive lead)
{
SetBool(lead, PropertyChange.Control.Mu, false);
SetBool(lead, PropertyChange.Control.CutOut, false);
}
static void IdleAll(List<BaseLocomotive> locos)
{
foreach (BaseLocomotive loco in locos)
{
try
{
loco.SendPropertyChange(PropertyChange.Control.Throttle, 0f);
if (loco.ControlHelper != null)
loco.ControlHelper.BailOff();
else
loco.SendPropertyChange(PropertyChange.Control.LocomotiveBrake, -0.1f);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] idle/bail failed on {loco.DisplayName}: {e.Message}");
}
}
}
static void SetBool(BaseLocomotive loco, PropertyChange.Control control, bool value)
{
try
{
loco.SendPropertyChange(control, value);
}
catch (System.Exception e)
{
Log.Warn($"[quickactions] {control}={value} failed on {loco.DisplayName}: {e.Message}");
}
}
}

View file

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

View file

@ -0,0 +1,79 @@
using System;
using HarmonyLib;
using S3.Core;
using UnityEngine;
namespace S3.Modules.QuickActions;
public sealed class QuickActionsModule : IModule
{
private const string SettingsFile = "S3.quickactions.json";
public static QuickActionsSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
internal static QuickActionsHost? Host { get; private set; }
private static readonly Type[] PatchTypes =
{
typeof(CarPickableContextMenuPatch),
typeof(ContextMenuEvenLayoutPatch),
typeof(ContextMenuItemSetAnglePatch),
typeof(ContextMenuShowPatch),
typeof(ContextMenuHidePatch),
};
public QuickActionsModule() => Settings = SettingsStore.Load<QuickActionsSettings>(SettingsFile);
public string Id => "quickactions";
public string DisplayName => "Quick Actions";
public string Description =>
"Outer-ring Coupler / Air Line / Anglecock / Cut on rolling stock, plus a Consist hover wheel " +
"on any car (Set Lead, handbrakes, bleed, anglecocks, air, idle, select lead/loco).";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
_harmony = new Harmony("S3.quickactions");
foreach (Type t in PatchTypes)
{
try
{
_harmony.CreateClassProcessor(t).Patch();
}
catch (Exception e)
{
Log.Error($"[quickactions] patch {t.Name} failed: {e.Message}");
}
}
_hostGo = new GameObject("[S3] QuickActionsHost");
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
Host = _hostGo.AddComponent<QuickActionsHost>();
}
public void OnDisable()
{
_harmony?.UnpatchAll("S3.quickactions");
_harmony = null;
EndGearOverlay.Detach();
Host = 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() => QuickActionsSettingsUI.Draw();
}
public sealed class QuickActionsHost : MonoBehaviour
{
}

View file

@ -0,0 +1,34 @@
using System;
namespace S3.Modules.QuickActions;
[Serializable]
public class QuickActionsSettings
{
public bool enabled = false;
public bool dropHandbrakeOnCut = false;
public bool dropHandbrakeExcludeLocos = true;
public bool consistSetLead = true;
public bool consistReleaseHandbrakes = true;
public bool consistReleaseHandbrakesExcludeLocos = false;
public bool consistApplyHandbrakes = true;
public bool consistApplyHandbrakesExcludeLocos = true;
public bool consistBleedAll = true;
public bool consistBleedAllExcludeLocos = true;
public bool consistOpenCocks = true;
public bool consistOpenCocksExcludeLocos = false;
public bool consistCloseCocks = true;
public bool consistCloseCocksExcludeLocos = false;
public bool consistConnectAir = true;
public bool consistConnectAirExcludeLocos = false;
public bool consistIdleBail = true;
public bool consistSelectLead = true;
public bool consistSelectLoco = true;
public bool centerActionPreview = false;
public bool centerTrainStats = false;
public bool centerHudAlways = false;
public bool centerWaypointBar = false;
}

View file

@ -0,0 +1,87 @@
using UnityEngine;
namespace S3.Modules.QuickActions;
static class QuickActionsSettingsUI
{
public static void Draw()
{
var s = QuickActionsModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Quick Actions</b> - extra items on the rolling-stock radial menu");
GUILayout.Space(4f);
GUILayout.Label(
" Couple / Uncouple, Attach / Detach, and Open / Close Anglecock sit on an outer ring at each end.\n" +
" Cut closes anglecocks, detaches the hose, and uncouples that joint.\n" +
" Hover Consist on any car for train-wide actions that apply to this cut.",
GUI.skin.label);
GUILayout.Space(10f);
GUILayout.Label("<b>Cut</b>");
changed |= Toggle(ref s.dropHandbrakeOnCut,
" After Cut, apply handbrakes on the cut that left your train");
if (s.dropHandbrakeOnCut)
{
GUILayout.BeginHorizontal();
GUILayout.Space(18f);
changed |= Toggle(ref s.dropHandbrakeExcludeLocos, " Exclude locomotives");
GUILayout.EndHorizontal();
}
GUILayout.Space(10f);
GUILayout.Label("<b>Consist wheel</b> (hover Consist; each action only appears when it can do something)");
GUILayout.Space(4f);
changed |= Toggle(ref s.consistSetLead, " Set Lead (clicked locomotive in a cut with two or more locomotives)");
changed |= ActionRow("Release all handbrakes", ref s.consistReleaseHandbrakes, ref s.consistReleaseHandbrakesExcludeLocos);
changed |= ActionRow("Apply all handbrakes", ref s.consistApplyHandbrakes, ref s.consistApplyHandbrakesExcludeLocos);
changed |= ActionRow("Bleed all", ref s.consistBleedAll, ref s.consistBleedAllExcludeLocos);
changed |= ActionRow("Open all anglecocks", ref s.consistOpenCocks, ref s.consistOpenCocksExcludeLocos);
changed |= ActionRow("Close all anglecocks", ref s.consistCloseCocks, ref s.consistCloseCocksExcludeLocos);
changed |= ActionRow("Attach all hoses", ref s.consistConnectAir, ref s.consistConnectAirExcludeLocos);
changed |= Toggle(ref s.consistIdleBail, " Idle throttle and bail independents (when the cut has a locomotive)");
changed |= Toggle(ref s.consistSelectLead, " Select Lead (when you are not already on the lead of a multi-loco cut)");
changed |= Toggle(ref s.consistSelectLoco, " Select Loco (when you clicked a car and the cut has exactly one locomotive)");
GUILayout.Space(10f);
GUILayout.Label("<b>Center readout</b> (replaces the reporting-mark hole; all extras off by default)");
GUILayout.Space(4f);
changed |= Toggle(ref s.centerActionPreview, " Hover preview (counts and which locomotive; skipped when the button already says it)");
changed |= Toggle(ref s.centerTrainStats, " Train length and weight");
changed |= Toggle(ref s.centerWaypointBar, " Power gauge (weight fills the ring; gold = max TE, cyan = current, orange = this grade, red = waypoint grade)");
if (s.centerTrainStats || s.centerWaypointBar)
{
GUILayout.BeginHorizontal();
GUILayout.Space(18f);
changed |= Toggle(ref s.centerHudAlways, " Show even when not hovering a button");
GUILayout.EndHorizontal();
}
GUILayout.EndVertical();
if (changed)
QuickActionsModule.Persist();
}
static bool ActionRow(string label, ref bool enabled, ref bool excludeLocos)
{
bool changed = Toggle(ref enabled, " " + label);
if (enabled)
{
GUILayout.BeginHorizontal();
GUILayout.Space(18f);
changed |= Toggle(ref excludeLocos, " Exclude locomotives");
GUILayout.EndHorizontal();
}
return changed;
}
static bool Toggle(ref bool field, string label)
{
bool next = GUILayout.Toggle(field, label);
if (next == field) return false;
field = next;
return true;
}
}

View file

@ -0,0 +1,55 @@
using Model;
using UnityEngine;
using UnityEngine.UI;
namespace S3.Modules.QuickActions;
/// <summary>
/// Screen-space heading of a car vs the camera: +1 nose-right, -1 nose-left.
/// Same test the couple/air hints will use to park on opposite sides of the pie.
/// When the pie is opened from the map, <see cref="CameraOverride"/> is the map
/// camera so arrows follow map rotation instead of the player view.
/// </summary>
static class CarScreenFacing
{
internal static Camera? CameraOverride;
public static Camera? Active() => CameraOverride != null ? CameraOverride : Camera.main;
public static float Sign(Car? car)
{
if (car == null) return 1f;
Camera? cam = Active();
Transform? body = car.BodyTransform != null ? car.BodyTransform : car.transform;
if (cam == null || body == null) return 1f;
return Vector3.Dot(cam.transform.right, body.forward) >= 0f ? 1f : -1f;
}
}
/// <summary>
/// Keeps the Consist glyph sized in the pie slot. Vanilla hover/click LeanTweens
/// localScale to 1.1 / 1.3 / 1.0; we preserve that magnitude.
/// </summary>
sealed class SetLeadIconFacing : MonoBehaviour
{
public Car? Car;
public Image? Image;
public Vector2 TargetSize = new Vector2(40f, 40f);
public bool FlipFacing = false;
void LateUpdate()
{
if (Image == null) return;
RectTransform rt = Image.rectTransform;
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
Image.preserveAspect = true;
Image.raycastTarget = false;
rt.sizeDelta = TargetSize;
float mag = Mathf.Abs(rt.localScale.y);
if (mag < 0.01f) mag = 1f;
float sign = FlipFacing ? CarScreenFacing.Sign(Car) : 1f;
rt.localScale = new Vector3(sign * mag, mag, mag);
}
}

View file

@ -0,0 +1,169 @@
using System.Collections.Generic;
using Game.Messages;
using HarmonyLib;
using Model;
using Model.AI;
using Track.Search;
using UnityEngine;
namespace S3.Modules.QuickActions;
static class TrainReadout
{
public readonly struct Snapshot
{
public readonly int Cars;
public readonly float LengthFt;
public readonly float Tons;
public readonly float RatedTeLbf;
public readonly float CurrentTeLbf;
public readonly float HereLbf;
public readonly float NeedLbf;
public readonly float WeightMarkLbf;
public readonly bool HasWaypoint;
public readonly bool CanMakeIt;
public Snapshot(
int cars, float lengthFt, float tons,
float ratedTeLbf, float currentTeLbf, float hereLbf, float needLbf, float weightMarkLbf,
bool hasWaypoint, bool canMakeIt)
{
Cars = cars;
LengthFt = lengthFt;
Tons = tons;
RatedTeLbf = ratedTeLbf;
CurrentTeLbf = currentTeLbf;
HereLbf = hereLbf;
NeedLbf = needLbf;
WeightMarkLbf = weightMarkLbf;
HasWaypoint = hasWaypoint;
CanMakeIt = canMakeIt;
}
public string StatsBlock()
{
return $"{LengthFt:0} ft\n{Tons:0} T";
}
public static string FormatTe(float lbf)
{
if (lbf >= 1000f) return $"{lbf / 1000f:0.0}k lbf";
return $"{lbf:0} lbf";
}
}
public static Snapshot Measure(Car origin)
{
int cars = 0;
float meters = 0f;
float pounds = 0f;
float rated = 0f;
float current = 0f;
float gravity = 0f;
BaseLocomotive? loco = null;
try
{
foreach (Car c in origin.EnumerateCoupled())
{
cars++;
meters += c.carLength;
pounds += c.Weight;
gravity += c.GravityForce;
if (c is BaseLocomotive l)
{
rated += l.RatedTractiveEffort;
current += Mathf.Abs(l.TractiveEffort);
loco ??= l;
}
}
}
catch { /* measured what we could */ }
float tons = pounds / 2000f;
float weightMark = tons * 20f;
float here = Mathf.Abs(gravity);
float need = 0f;
bool hasWp = false;
if (TryWaypointNeed(origin, loco, tons, out float routeNeed, out bool wp) && wp)
{
hasWp = true;
need = routeNeed;
}
bool can = rated + 0.5f >= Mathf.Max(here, need);
return new Snapshot(
cars, meters * 3.28084f, tons,
rated, current, here, need, weightMark,
hasWp, can);
}
static bool TryWaypointNeed(Car origin, BaseLocomotive? first, float tons, out float need, out bool hasWaypoint)
{
need = 0f;
hasWaypoint = false;
try
{
foreach (Car c in origin.EnumerateCoupled())
{
if (c is not BaseLocomotive l) continue;
var planner = l.AutoEngineerPlanner;
if (planner == null) continue;
object? raw = Traverse.Create(planner).Field("_orders").GetValue();
if (raw is not Orders orders) continue;
if (orders.Mode != AutoEngineerMode.Waypoint || !orders.Waypoint.HasValue)
continue;
hasWaypoint = true;
if (TryRouteGrade(planner, out float gradePct))
need = tons * 20f * Mathf.Max(0f, gradePct);
else
need = Mathf.Abs(first != null ? SumGravity(origin) : 0f);
return true;
}
}
catch { /* no waypoint */ }
return false;
}
static float SumGravity(Car origin)
{
float n = 0f;
try
{
foreach (Car c in origin.EnumerateCoupled())
n += c.GravityForce;
}
catch { /* */ }
return n;
}
static bool TryRouteGrade(AutoEngineerPlanner planner, out float maxAdversePct)
{
maxAdversePct = 0f;
try
{
object? raw = Traverse.Create(planner).Field("_route").GetValue();
if (raw is not List<RouteSearch.Step> route || route.Count < 2)
return false;
Vector3 prev = route[0].Position;
for (int i = 1; i < route.Count; i++)
{
Vector3 p = route[i].Position;
Vector3 d = p - prev;
float horiz = new Vector2(d.x, d.z).magnitude;
if (horiz < 0.5f)
{
prev = p;
continue;
}
float pct = d.y / horiz * 100f;
if (pct > maxAdversePct) maxAdversePct = pct;
prev = p;
}
return true;
}
catch
{
return false;
}
}
}

View file

@ -119,4 +119,10 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Modules\QuickActions\Icons\consist.png">
<LogicalName>S3.QuickActions.consist.png</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project> </Project>