diff --git a/README.md b/README.md index 4c53121..44fd46e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Main.cs b/src/Main.cs index 8aafcde..b9a1913 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -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(); diff --git a/src/Modules/QuickActions/ConsistActions.cs b/src/Modules/QuickActions/ConsistActions.cs new file mode 100644 index 0000000..3c90b67 --- /dev/null +++ b/src/Modules/QuickActions/ConsistActions.cs @@ -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? 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 CollectLocos(Car origin) + { + var list = new List(); + 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("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 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); + }); + } +} diff --git a/src/Modules/QuickActions/ConsistSlotHover.cs b/src/Modules/QuickActions/ConsistSlotHover.cs new file mode 100644 index 0000000..4d2cec8 --- /dev/null +++ b/src/Modules/QuickActions/ConsistSlotHover.cs @@ -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); +} diff --git a/src/Modules/QuickActions/ContextMenuPatch.cs b/src/Modules/QuickActions/ContextMenuPatch.cs new file mode 100644 index 0000000..b3e47b7 --- /dev/null +++ b/src/Modules/QuickActions/ContextMenuPatch.cs @@ -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>>("_quadrants").Value; + var itemAngles = t.Field>("_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 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 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>>("_quadrants").Value; + List? 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() + ?? item.gameObject.AddComponent(); + 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() + ?? item.image.gameObject.AddComponent(); + ignore.ignoreLayout = true; + + var facing = item.image.gameObject.GetComponent() + ?? item.image.gameObject.AddComponent(); + 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); + } +} diff --git a/src/Modules/QuickActions/EndGearActions.cs b/src/Modules/QuickActions/EndGearActions.cs new file mode 100644 index 0000000..27bd894 --- /dev/null +++ b/src/Modules/QuickActions/EndGearActions.cs @@ -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 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 CollectAway(Car start, Car blocked) + { + var list = new List(); + var seen = new HashSet { blocked }; + var stack = new Stack(); + 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; + } +} diff --git a/src/Modules/QuickActions/EndGearOverlay.cs b/src/Modules/QuickActions/EndGearOverlay.cs new file mode 100644 index 0000000..4dde0ed --- /dev/null +++ b/src/Modules/QuickActions/EndGearOverlay.cs @@ -0,0 +1,1027 @@ +using System.Collections.Generic; +using HarmonyLib; +using Helpers; +using Model; +using TMPro; +using Track; +using UI.ContextMenu; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; +using GameContextMenu = UI.ContextMenu.ContextMenu; + +namespace S3.Modules.QuickActions; + +enum HoverSlot +{ + None, + Consist, + Air, + Cock, + Couple, + Cut, +} + +/// +/// Two camera-aligned end hints on an outer ring. Hover expands each into +/// partial fan on that item's bearing, around the main pie. Inner-wheel labels +/// stay visible unless a coupler arrow covers them. All inner labels hide +/// while a secondary wheel is open. +/// +sealed class EndGearOverlay : MonoBehaviour +{ + // Hairline so the secondary stroke sits against the main pie, not inside it. + const float Gap = 1f; + const float RingThickness = 48f; + const float SectorDegrees = 100f; + const float SliceGap = 0f; + const float StrokePx = 1.35f; + const float ExpandSpeed = 14f; + const float CollapseDelay = 0.12f; + const float ConsistSliceDegrees = 32f; + const float ConsistMaxSector = 200f; + + // Sampled from the factory pie: icons, wedge stroke, labels. + static readonly Color Icon = new Color(0xcd / 255f, 0xb9 / 255f, 0x93 / 255f, 1f); + static readonly Color Stroke = new Color(0x76 / 255f, 0x6a / 255f, 0x4d / 255f, 1f); + static readonly Color Label = new Color(0xca / 255f, 0xc4 / 255f, 0xb8 / 255f, 1f); + static readonly Color LabelHover = new Color(0xe4 / 255f, 0xde / 255f, 0xd0 / 255f, 1f); + static readonly Color Panel = new Color(0.07f, 0.07f, 0.065f, 0.97f); + static readonly Color PanelOn = new Color(0.13f, 0.12f, 0.10f, 0.97f); + static readonly Color PanelHover = new Color(0.28f, 0.26f, 0.22f, 0.98f); + static readonly Color PanelBlocked = new Color(0.32f, 0.10f, 0.09f, 0.97f); + static readonly Color PanelBlockedHover = new Color(0.48f, 0.16f, 0.14f, 0.98f); + static readonly Color LabelBlocked = new Color(0x92 / 255f, 0x50 / 255f, 0x44 / 255f, 1f); + + static Sprite? _white; + static Sprite? _chevron; + static EndGearOverlay? _live; + + Car? _car; + GameContextMenu? _menu; + float _pieRadius = 100f; + Side _a = null!; + Side _b = null!; + GameObject? _consistRoot; + WedgeImage? _consistArc; + readonly List _consistBtns = new(); + float _consistExpand; + static bool _consistPointer; + static float _consistCollapseAt = -1f; + readonly List _hintBlockers = new(); + readonly Vector3[] _labelCorners = new Vector3[4]; + readonly Vector3[] _hintCorners = new Vector3[4]; + PieCenterHud? _hud; + Btn? _hovered; + + public static void NotifyConsistHover(bool enter) + { + if (enter) + { + _consistPointer = true; + _consistCollapseAt = -1f; + } + else + { + _consistCollapseAt = Time.unscaledTime + CollapseDelay; + } + } + + public static void Attach(GameContextMenu menu, Car? car) + { + Detach(); + if (menu == null || car == null) return; + + var content = ReadRt(menu, "contentRectTransform"); + if (content == null) return; + + float radius = 100f; + try + { + radius = Traverse.Create(menu).Field("radius").Value; + if (radius < 10f) radius = 100f; + } + catch { radius = 100f; } + + radius = SamplePieOuter(menu, radius); + + Reclamp(content, radius + Gap + RingThickness + 40f); + + var go = new GameObject("S3_EndGearOverlay", typeof(RectTransform), typeof(LayoutElement)); + var rt = (RectTransform)go.transform; + rt.SetParent(content, false); + rt.anchorMin = rt.anchorMax = content.pivot; + rt.pivot = new Vector2(0.5f, 0.5f); + rt.anchoredPosition = Vector2.zero; + rt.localPosition = Vector3.zero; + rt.localRotation = Quaternion.identity; + rt.localScale = Vector3.one; + rt.sizeDelta = Vector2.zero; + go.GetComponent().ignoreLayout = true; + rt.SetAsLastSibling(); + + var overlay = go.AddComponent(); + overlay._car = car; + overlay._menu = menu; + overlay._pieRadius = radius; + overlay._a = overlay.BuildSide(Car.LogicalEnd.A, menu, radius); + overlay._b = overlay.BuildSide(Car.LogicalEnd.B, menu, radius); + overlay.BuildConsistRing(menu); + overlay._hud = PieCenterHud.TryCreate( + menu, overlay.transform, overlay._pieRadius, SamplePieInnerFrac(menu)); + _live = overlay; + _consistPointer = false; + _consistCollapseAt = -1f; + } + + public static void Detach() + { + if (_live != null) + { + _live.RestoreInnerLabels(); + _live._hud?.RestoreFactory(); + } + if (_live != null && _live.gameObject != null) + Destroy(_live.gameObject); + _live = null; + _consistPointer = false; + _consistCollapseAt = -1f; + } + + static void NotifyWedgeHover(Btn? btn, bool enter) + { + if (_live == null) return; + if (enter) + _live._hovered = btn; + else if (_live._hovered == btn) + _live._hovered = null; + } + + static float HoverInnerFrac(float inner, float outer, bool expanded) + { + // Cover the hairline against the main pie. While open, reach a little + // farther inward so leaving the ring toward the hole does not collapse it. + float hitInner = inner - Gap - (expanded ? 10f : 4f); + return Mathf.Max(0.12f, hitInner / outer); + } + + static float SamplePieOuter(GameContextMenu menu, float fallback) + { + try + { + var quadrants = Traverse.Create(menu).Field>>("_quadrants").Value; + if (quadrants == null) return fallback; + foreach (var list in quadrants) + { + foreach (ContextMenuItem item in list) + { + if (item?.wedgeImage == null) continue; + float w = item.wedgeImage.rectTransform.rect.width; + if (w > 20f) + return w * 0.5f; + } + } + } + catch { /* fallback */ } + return fallback; + } + + static float SamplePieInnerFrac(GameContextMenu menu) + { + try + { + var quadrants = Traverse.Create(menu).Field>>("_quadrants").Value; + if (quadrants == null) return 0.5f; + foreach (var list in quadrants) + { + foreach (ContextMenuItem item in list) + { + if (item?.wedgeImage == null) continue; + float f = item.wedgeImage.innerRadius; + if (f > 0.15f && f < 0.9f) + return f; + } + } + } + catch { /* fallback */ } + return 0.5f; + } + + static RectTransform? ReadRt(GameContextMenu menu, string field) + { + try { return Traverse.Create(menu).Field(field).Value; } + catch { return null; } + } + + static void Reclamp(RectTransform content, float margin) + { + try + { + var canvas = content.GetComponentInParent(); + if (canvas == null) return; + var root = canvas.rootCanvas != null ? canvas.rootCanvas : canvas; + Vector2 size = root.renderingDisplaySize; + Vector2 pos = content.anchoredPosition; + pos.x = Mathf.Clamp(pos.x, margin, size.x - margin); + pos.y = Mathf.Clamp(pos.y, margin, size.y - margin); + content.anchoredPosition = pos; + } + catch { /* leave vanilla placement */ } + } + + void LateUpdate() + { + if (_car == null) + { + Detach(); + return; + } + + transform.localPosition = Vector3.zero; + transform.localRotation = Quaternion.identity; + transform.SetAsLastSibling(); + + if (_consistCollapseAt > 0f && Time.unscaledTime >= _consistCollapseAt) + { + _consistPointer = false; + _consistCollapseAt = -1f; + } + + bool consistHover = _consistPointer && _consistRoot != null; + if (consistHover) + { + _a.WantExpand = false; + _b.WantExpand = false; + } + + _consistExpand = Mathf.MoveTowards(_consistExpand, consistHover ? 1f : 0f, ExpandSpeed * Time.unscaledDeltaTime); + bool consistOpen = _consistExpand > 0.2f; + + if (_a.Root != null) _a.Root.SetActive(!consistOpen); + if (_b.Root != null) _b.Root.SetActive(!consistOpen); + if (_consistRoot != null) + { + _consistRoot.SetActive(_consistExpand > 0.15f); + UpdateConsistRing(); + } + + if (!consistOpen && TryAxis(out Vector2 axis)) + { + float inner = _pieRadius + Gap; + float outer = inner + RingThickness; + _a.Place(axis, _car, inner, outer); + _b.Place(-axis, _car, inner, outer); + } + + UpdateInnerLabelVisibility(); + UpdateCenterHud(); + } + + void OnDestroy() + { + RestoreInnerLabels(); + _hud?.RestoreFactory(); + if (_live == this) _live = null; + } + + void UpdateInnerLabelVisibility() + { + bool wheels = (_consistRoot != null && _consistRoot.activeSelf && _consistExpand > 0.25f) + || (_a != null && _a.Root != null && _a.Root.activeSelf && _a.Expanded) + || (_b != null && _b.Root != null && _b.Root.activeSelf && _b.Expanded); + + _hintBlockers.Clear(); + if (!wheels) + { + CollectHintBlockers(_a); + CollectHintBlockers(_b); + } + + try + { + var quadrants = Traverse.Create(_menu).Field>>("_quadrants").Value; + if (quadrants == null) return; + foreach (var list in quadrants) + { + foreach (ContextMenuItem item in list) + { + if (item == null || item.textContainer == null) continue; + var labelRt = item.textContainer; + bool hide = wheels || HitsHint(labelRt); + labelRt.gameObject.SetActive(!hide); + } + } + } + catch { /* leave labels */ } + } + + void CollectHintBlockers(Side side) + { + if (side?.Root == null || !side.Root.activeSelf) return; + if (side.Hint != null && side.Hint.activeSelf) + _hintBlockers.Add((RectTransform)side.Hint.transform); + } + + void UpdateCenterHud() + { + if (_hud == null || _car == null) return; + var s = QuickActionsModule.Settings; + bool hovering = _hovered != null; + string? preview = null; + if (s.centerActionPreview && hovering) + preview = PreviewFor(_hovered!, _car); + + bool persist = s.centerHudAlways; + bool showStats = s.centerTrainStats && (persist || hovering); + bool showGauge = (s.centerWaypointBar || s.centerTrainStats) && (persist || hovering); + TrainReadout.Snapshot? snap = null; + if (showStats || showGauge) + snap = TrainReadout.Measure(_car); + _hud.Tick(_car.DisplayName, preview, snap, showStats, showGauge); + } + + static string PreviewFor(Btn btn, Car car) + { + if (btn.HoverSlot == HoverSlot.Consist) + return ConsistActions.Preview(btn.ConsistKind, car); + return EndGearActions.Preview(car, btn.End, btn.HoverSlot); + } + + bool HitsHint(RectTransform label) + { + label.GetWorldCorners(_labelCorners); + var labelBox = Encapsulate(_labelCorners); + for (int i = 0; i < _hintBlockers.Count; i++) + { + _hintBlockers[i].GetWorldCorners(_hintCorners); + if (labelBox.Overlaps(Encapsulate(_hintCorners))) + return true; + } + return false; + } + + static Rect Encapsulate(Vector3[] corners) + { + float minX = corners[0].x, maxX = corners[0].x; + float minY = corners[0].y, maxY = corners[0].y; + for (int i = 1; i < 4; i++) + { + minX = Mathf.Min(minX, corners[i].x); + maxX = Mathf.Max(maxX, corners[i].x); + minY = Mathf.Min(minY, corners[i].y); + maxY = Mathf.Max(maxY, corners[i].y); + } + return Rect.MinMaxRect(minX, minY, maxX, maxY); + } + + void BuildConsistRing(GameContextMenu menu) + { + if (ContextMenuActions.ConsistItem == null || _car == null) return; + + TMP_FontAsset? font = null; + try + { + var label = Traverse.Create(menu).Field("centerLabel").Value; + font = label != null ? label.font : null; + } + catch { /* TMP default */ } + + var kinds = ConsistActions.VisibleKinds(_car); + if (kinds.Count == 0) return; + + float inner = _pieRadius + Gap; + float outer = inner + RingThickness; + + var root = new GameObject("ConsistRing", typeof(RectTransform), typeof(LayoutElement)); + var rt = (RectTransform)root.transform; + rt.SetParent(transform, 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 = Vector2.zero; + root.GetComponent().ignoreLayout = true; + root.SetActive(false); + _consistRoot = root; + _consistBtns.Clear(); + + var arcGo = new GameObject("Arc", typeof(RectTransform), typeof(WedgeImage), typeof(ConsistSlotHover)); + var arcRt = (RectTransform)arcGo.transform; + arcRt.SetParent(rt, false); + arcRt.anchorMin = arcRt.anchorMax = new Vector2(0.5f, 0.5f); + arcRt.pivot = new Vector2(0.5f, 0.5f); + arcRt.localPosition = Vector3.zero; + arcRt.sizeDelta = new Vector2(outer * 2f, outer * 2f); + var arc = arcGo.GetComponent(); + arc.sprite = WhiteSprite(); + arc.color = new Color(0f, 0f, 0f, 0f); + arc.raycastTarget = true; + arc.innerRadius = HoverInnerFrac(inner, outer, expanded: false); + arc.angleRange = ConsistSliceDegrees; + _consistArc = arc; + + foreach (var (kind, title) in kinds) + { + ConsistActions.Kind captured = kind; + var btn = MakeWedge(rt, title, font, outer, inner / outer, side: null, () => + { + if (_car != null && ConsistActions.CanRun(captured, _car)) + ConsistActions.Run(captured, _car); + CloseMenu(); + }, consistHover: true); + btn.ConsistKind = captured; + btn.HoverSlot = HoverSlot.Consist; + btn.Root.gameObject.SetActive(true); + _consistBtns.Add(btn); + } + } + + void UpdateConsistRing() + { + var item = ContextMenuActions.ConsistItem; + if (item == null || _consistRoot == null) return; + _consistRoot.transform.localPosition = Vector3.zero; + + int n = _consistBtns.Count; + if (n == 0) return; + + Vector3 p = item.transform.localPosition; + float ang = Mathf.Atan2(p.y, p.x) * Mathf.Rad2Deg; + float inner = _pieRadius + Gap; + float outer = inner + RingThickness; + float midR = (inner + outer) * 0.5f; + + float slice = ConsistSliceDegrees; + float sector = n * slice + Mathf.Max(0, n - 1) * SliceGap; + if (sector > ConsistMaxSector) + { + slice = (ConsistMaxSector - Mathf.Max(0, n - 1) * SliceGap) / n; + sector = ConsistMaxSector; + } + + float start = ang - sector * 0.5f; + if (_consistArc != null) + { + _consistArc.startAngle = Mathf.Repeat(start, 360f); + _consistArc.angleRange = sector; + _consistArc.innerRadius = HoverInnerFrac(inner, outer, _consistExpand > 0.25f); + _consistArc.SetVerticesDirty(); + } + + bool show = _consistExpand > 0.25f; + float innerFrac = inner / outer; + for (int i = 0; i < n; i++) + { + ApplySlice(_consistBtns[i], start + i * (slice + SliceGap), slice, midR, innerFrac, show); + if (_car != null) + { + var btn = _consistBtns[i]; + bool ok = ConsistActions.CanRun(btn.ConsistKind, _car); + Paint(btn, btn.Title, on: false, enabled: ok); + } + } + } + + void RestoreInnerLabels() + { + try + { + var quadrants = Traverse.Create(_menu).Field>>("_quadrants").Value; + if (quadrants == null) return; + foreach (var list in quadrants) + { + foreach (ContextMenuItem item in list) + { + if (item?.textContainer != null) + item.textContainer.gameObject.SetActive(true); + } + } + } + catch { /* menu already gone */ } + } + + bool TryAxis(out Vector2 axis) + { + axis = Vector2.zero; + Camera? cam = CarScreenFacing.Active(); + if (cam == null || _car == null) return false; + + Vector3 worldA = EndWorld(_car, Car.LogicalEnd.A); + Vector3 worldB = EndWorld(_car, Car.LogicalEnd.B); + Vector3 delta = worldA - worldB; + Vector2 projected = new Vector2( + Vector3.Dot(delta, cam.transform.right), + Vector3.Dot(delta, cam.transform.up)); + + if (projected.sqrMagnitude < 0.0001f) + { + Transform? body = _car.BodyTransform != null ? _car.BodyTransform : _car.transform; + if (body == null) return false; + projected = new Vector2( + Vector3.Dot(body.right, cam.transform.right), + Vector3.Dot(body.right, cam.transform.up)); + } + + if (projected.sqrMagnitude < 0.0001f) return false; + axis = projected.normalized; + return true; + } + + static Vector3 EndWorld(Car car, Car.LogicalEnd end) + { + try + { + var anglecock = car[end].Anglecock; + if (anglecock != null) + return anglecock.transform.position; + } + catch { /* fall through */ } + + try + { + var loc = end == Car.LogicalEnd.A ? car.LocationA : car.LocationB; + if (Graph.Shared != null) + { + var game = Graph.Shared.GetPosition(loc); + try { return WorldTransformer.GameToWorld(game); } + catch { return game; } + } + } + catch { /* fall through */ } + + Transform? body = car.BodyTransform != null ? car.BodyTransform : car.transform; + float sign = end == Car.LogicalEnd.A ? 1f : -1f; + return body != null ? body.position + body.forward * sign : Vector3.zero; + } + + Side BuildSide(Car.LogicalEnd end, GameContextMenu menu, float pieRadius) + { + var root = new GameObject(end + "Side", typeof(RectTransform), typeof(LayoutElement)); + var rt = (RectTransform)root.transform; + rt.SetParent(transform, 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 = Vector2.zero; + root.GetComponent().ignoreLayout = true; + + float inner = pieRadius + Gap; + float outer = inner + RingThickness; + + var arcGo = new GameObject("Arc", typeof(RectTransform), typeof(WedgeImage), typeof(HoverRelay)); + var arcRt = (RectTransform)arcGo.transform; + arcRt.SetParent(rt, false); + arcRt.anchorMin = arcRt.anchorMax = new Vector2(0.5f, 0.5f); + arcRt.pivot = new Vector2(0.5f, 0.5f); + arcRt.localPosition = Vector3.zero; + arcRt.sizeDelta = new Vector2(outer * 2f, outer * 2f); + var arc = arcGo.GetComponent(); + arc.sprite = WhiteSprite(); + arc.color = new Color(0f, 0f, 0f, 0f); + arc.raycastTarget = true; + arc.innerRadius = HoverInnerFrac(inner, outer, expanded: false); + arc.angleRange = SectorDegrees; + + var hintGo = new GameObject("Hint", typeof(RectTransform), typeof(CanvasGroup), typeof(HoverRelay)); + var hintRt = (RectTransform)hintGo.transform; + hintRt.SetParent(rt, false); + hintRt.anchorMin = hintRt.anchorMax = new Vector2(0.5f, 0.5f); + hintRt.pivot = new Vector2(0.5f, 0.5f); + hintRt.sizeDelta = new Vector2(34f, 28f); + + var borderGo = new GameObject("Border", typeof(RectTransform), typeof(Image)); + var borderRt = (RectTransform)borderGo.transform; + borderRt.SetParent(hintRt, false); + borderRt.anchorMin = borderRt.anchorMax = new Vector2(0.5f, 0.5f); + borderRt.pivot = new Vector2(0.5f, 0.5f); + borderRt.sizeDelta = new Vector2(34f + StrokePx * 2f, 28f + StrokePx * 2f); + var borderImg = borderGo.GetComponent(); + borderImg.sprite = WhiteSprite(); + borderImg.color = Stroke; + borderImg.raycastTarget = false; + + var backGo = new GameObject("Fill", typeof(RectTransform), typeof(Image)); + var backRt = (RectTransform)backGo.transform; + backRt.SetParent(hintRt, false); + backRt.anchorMin = backRt.anchorMax = new Vector2(0.5f, 0.5f); + backRt.pivot = new Vector2(0.5f, 0.5f); + backRt.sizeDelta = new Vector2(34f, 28f); + var backImg = backGo.GetComponent(); + backImg.sprite = WhiteSprite(); + backImg.color = Panel; + backImg.raycastTarget = false; + + var chevGo = new GameObject("Chevron", typeof(RectTransform), typeof(Image)); + var chevRt = (RectTransform)chevGo.transform; + chevRt.SetParent(hintRt, false); + chevRt.anchorMin = chevRt.anchorMax = new Vector2(0.5f, 0.5f); + chevRt.pivot = new Vector2(0.5f, 0.5f); + chevRt.sizeDelta = new Vector2(28f, 22f); + var hintImg = chevGo.GetComponent(); + hintImg.sprite = ChevronSprite(); + hintImg.color = Color.white; + hintImg.raycastTarget = false; + hintImg.preserveAspect = true; + + var hitGo = new GameObject("Hit", typeof(RectTransform), typeof(Image)); + var hitRt = (RectTransform)hitGo.transform; + hitRt.SetParent(hintRt, false); + hitRt.anchorMin = Vector2.zero; + hitRt.anchorMax = Vector2.one; + hitRt.offsetMin = hitRt.offsetMax = Vector2.zero; + var hitImg = hitGo.GetComponent(); + hitImg.sprite = WhiteSprite(); + hitImg.color = new Color(1f, 1f, 1f, 0.01f); + hitImg.raycastTarget = true; + + TMP_FontAsset? font = null; + try + { + var label = Traverse.Create(menu).Field("centerLabel").Value; + font = label != null ? label.font : null; + } + catch { /* TMP default */ } + + var side = new Side + { + End = end, + Root = root, + Arc = arc, + Hint = hintGo, + HintImage = hintImg, + HintGroup = hintGo.GetComponent(), + }; + + arcGo.GetComponent().Side = side; + hintGo.GetComponent().Side = side; + + side.Air = MakeWedge(rt, "Air Line", font, outer, inner / outer, side, () => + { + if (_car != null) EndGearActions.ToggleAir(_car, end); + CloseMenu(); + }); + side.Cock = MakeWedge(rt, "Anglecock", font, outer, inner / outer, side, () => + { + if (_car != null) EndGearActions.ToggleCock(_car, end); + CloseMenu(); + }); + side.Couple = MakeWedge(rt, "Coupler", font, outer, inner / outer, side, () => + { + if (_car != null) EndGearActions.ToggleCouple(_car, end); + CloseMenu(); + }); + side.All = MakeWedge(rt, "Cut", font, outer, inner / outer, side, () => + { + if (_car != null) EndGearActions.DisconnectAll(_car, end); + CloseMenu(); + }); + side.Air.HoverSlot = HoverSlot.Air; + side.Cock.HoverSlot = HoverSlot.Cock; + side.Couple.HoverSlot = HoverSlot.Couple; + side.All.HoverSlot = HoverSlot.Cut; + side.Air.End = end; + side.Cock.End = end; + side.Couple.End = end; + side.All.End = end; + return side; + } + + Btn MakeWedge(RectTransform parent, string title, TMP_FontAsset? font, float outer, float innerFrac, Side? side, UnityEngine.Events.UnityAction onClick, bool consistHover = false) + { + var go = new GameObject(title.Replace('\n', ' '), typeof(RectTransform), typeof(Button)); + if (consistHover) + go.AddComponent(); + else + go.AddComponent(); + var hover = go.AddComponent(); + + 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.localPosition = Vector3.zero; + rt.sizeDelta = new Vector2(outer * 2f, outer * 2f); + go.SetActive(false); + + var stroke = MakeWedgeImage(rt, "Stroke", Stroke, innerFrac, raycast: true); + var wedge = MakeWedgeImage(rt, "Fill", Panel, innerFrac, raycast: true); + + var btn = go.GetComponent