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:
parent
16d118c4a9
commit
5187c03ebe
18 changed files with 3069 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
389
src/Modules/QuickActions/ConsistActions.cs
Normal file
389
src/Modules/QuickActions/ConsistActions.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/Modules/QuickActions/ConsistSlotHover.cs
Normal file
10
src/Modules/QuickActions/ConsistSlotHover.cs
Normal 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);
|
||||||
|
}
|
||||||
240
src/Modules/QuickActions/ContextMenuPatch.cs
Normal file
240
src/Modules/QuickActions/ContextMenuPatch.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
275
src/Modules/QuickActions/EndGearActions.cs
Normal file
275
src/Modules/QuickActions/EndGearActions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1027
src/Modules/QuickActions/EndGearOverlay.cs
Normal file
1027
src/Modules/QuickActions/EndGearOverlay.cs
Normal file
File diff suppressed because it is too large
Load diff
BIN
src/Modules/QuickActions/Icons/consist.png
Normal file
BIN
src/Modules/QuickActions/Icons/consist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9 KiB |
126
src/Modules/QuickActions/Icons/make_consist_icon.py
Normal file
126
src/Modules/QuickActions/Icons/make_consist_icon.py
Normal 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)
|
||||||
BIN
src/Modules/QuickActions/Icons/set-lead.png
Normal file
BIN
src/Modules/QuickActions/Icons/set-lead.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
162
src/Modules/QuickActions/MuConsistAction.cs
Normal file
162
src/Modules/QuickActions/MuConsistAction.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
400
src/Modules/QuickActions/PieCenterHud.cs
Normal file
400
src/Modules/QuickActions/PieCenterHud.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Modules/QuickActions/QuickActionsModule.cs
Normal file
79
src/Modules/QuickActions/QuickActionsModule.cs
Normal 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
|
||||||
|
{
|
||||||
|
}
|
||||||
34
src/Modules/QuickActions/QuickActionsSettings.cs
Normal file
34
src/Modules/QuickActions/QuickActionsSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
87
src/Modules/QuickActions/QuickActionsSettingsUI.cs
Normal file
87
src/Modules/QuickActions/QuickActionsSettingsUI.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Modules/QuickActions/SetLeadIconFacing.cs
Normal file
55
src/Modules/QuickActions/SetLeadIconFacing.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/Modules/QuickActions/TrainReadout.cs
Normal file
169
src/Modules/QuickActions/TrainReadout.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue