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. |
|
||||
| Profiler | Unified in-game performance overlay with hitch attribution captures. Console: `/rpf overlay`, `/s3bench` |
|
||||
| Misc Tweaks | Small QoL: cancellable autoload of the most recent save from the main menu. |
|
||||
| Quick Actions | Extra outer-ring couple/air/cut actions and a consist hover wheel on the rolling-stock pie menu. |
|
||||
|
||||
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
|
||||
|
||||
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.MiscTweaks.MiscTweaksModule());
|
||||
_registry.Register(new Modules.Popout.PopoutModule());
|
||||
_registry.Register(new Modules.QuickActions.QuickActionsModule());
|
||||
|
||||
_registry.EnableConfigured();
|
||||
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>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Modules\QuickActions\Icons\consist.png">
|
||||
<LogicalName>S3.QuickActions.consist.png</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Reference in a new issue