From cc552a0246d6f7002a92e0fa488446dcb93c462f Mon Sep 17 00:00:00 2001 From: seton Date: Fri, 11 Sep 2026 15:19:23 -0400 Subject: [PATCH] CarCards: fanned consist dock with notes, actions, and WQ cut dividers Shows the selected cut as a fan of cards with waybill, notes, and couple/handbrake/locate actions. WaypointQueue cuts appear as dividers when that mod is present. --- README.md | 9 + src/Main.cs | 1 + src/Modules/CarCards/CarCardsModule.cs | 59 + src/Modules/CarCards/CarCardsOverlay.cs | 1270 ++++++++++++++++++++ src/Modules/CarCards/CarCardsSettings.cs | 71 ++ src/Modules/CarCards/CarCardsSettingsUI.cs | 153 +++ src/Modules/CarCards/CardClick.cs | 78 ++ src/Modules/CarCards/CardNotes.cs | 155 +++ src/Modules/CarCards/CardUi.cs | 520 ++++++++ src/Modules/CarCards/CardViewModel.cs | 239 ++++ src/Modules/CarCards/CardViewOrder.cs | 89 ++ src/Modules/CarCards/CardWidget.cs | 276 +++++ src/Modules/CarCards/ConsistBinder.cs | 111 ++ src/Modules/CarCards/DividerWidget.cs | 127 ++ src/Modules/CarCards/FanLayout.cs | 113 ++ src/Modules/CarCards/WaypointCutSim.cs | 693 +++++++++++ 16 files changed, 3964 insertions(+) create mode 100644 src/Modules/CarCards/CarCardsModule.cs create mode 100644 src/Modules/CarCards/CarCardsOverlay.cs create mode 100644 src/Modules/CarCards/CarCardsSettings.cs create mode 100644 src/Modules/CarCards/CarCardsSettingsUI.cs create mode 100644 src/Modules/CarCards/CardClick.cs create mode 100644 src/Modules/CarCards/CardNotes.cs create mode 100644 src/Modules/CarCards/CardUi.cs create mode 100644 src/Modules/CarCards/CardViewModel.cs create mode 100644 src/Modules/CarCards/CardViewOrder.cs create mode 100644 src/Modules/CarCards/CardWidget.cs create mode 100644 src/Modules/CarCards/ConsistBinder.cs create mode 100644 src/Modules/CarCards/DividerWidget.cs create mode 100644 src/Modules/CarCards/FanLayout.cs create mode 100644 src/Modules/CarCards/WaypointCutSim.cs diff --git a/README.md b/README.md index 44fd46e..47595ec 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ I originally planned on releasing individual mods, but considering my workflow o | Profiler | Unified in-game performance overlay with hitch attribution captures. Console: `/rpf overlay`, `/s3bench` | | 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. | +| Car Cards | Fanned consist dock for the selected cut, with waybill, notes, and couple/handbrake/locate actions. | All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow. @@ -214,6 +215,14 @@ handbrakes, bleed, air, idle, select loco). Cut can optionally apply a handbrake --- +## Car Cards + +A Monopoly-style fanned card dock for the coupled cut around your selected car. +Color bands, waybill info, per-car notes, and couple/handbrake/locate actions. +Optional WaypointQueue cut dividers when that mod is installed. + +--- + ## Migrating from the standalone mods S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and diff --git a/src/Main.cs b/src/Main.cs index b9a1913..e0a91cc 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -37,6 +37,7 @@ public static class Main _registry.Register(new Modules.MiscTweaks.MiscTweaksModule()); _registry.Register(new Modules.Popout.PopoutModule()); _registry.Register(new Modules.QuickActions.QuickActionsModule()); + _registry.Register(new Modules.CarCards.CarCardsModule()); _registry.EnableConfigured(); ModConflicts.CheckAtLoad(); diff --git a/src/Modules/CarCards/CarCardsModule.cs b/src/Modules/CarCards/CarCardsModule.cs new file mode 100644 index 0000000..cbe5794 --- /dev/null +++ b/src/Modules/CarCards/CarCardsModule.cs @@ -0,0 +1,59 @@ +using System; +using HarmonyLib; +using S3.Core; +using UnityEngine; + +namespace S3.Modules.CarCards; + +public sealed class CarCardsModule : IModule +{ + private const string SettingsFile = "S3.carcards.json"; + + public static CarCardsSettings Settings { get; private set; } = new(); + + private static Harmony? _harmony; + private static GameObject? _hostGo; + + public CarCardsModule() => Settings = SettingsStore.Load(SettingsFile); + + public string Id => "carcards"; + public string DisplayName => "Car Cards"; + public string Description => + "A fanned handful of monopoly-style cards for the coupled cut you are working. " + + "Color bands, waybill, notes, and on-card couple / handbrake / locate. Off by default."; + + public bool Enabled + { + get => Settings.enabled; + set => Settings.enabled = value; + } + + public void OnEnable() + { + CardNotes.Load(); + _harmony = new Harmony("S3.carcards"); + foreach (Type t in new[] { typeof(CarCardsMouseOverUiPatch) }) + { + try { _harmony.CreateClassProcessor(t).Patch(); } + catch (Exception e) { Log.Error($"[carcards] patch {t.Name} failed: {e.Message}"); } + } + + _hostGo = new GameObject("[S3] CarCardsHost"); + UnityEngine.Object.DontDestroyOnLoad(_hostGo); + _hostGo.AddComponent(); + } + + public void OnDisable() + { + CardNotes.Flush(); + _harmony?.UnpatchAll("S3.carcards"); + _harmony = null; + if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo); + _hostGo = null; + } + + public void SaveSettings() => Persist(); + internal static void Persist() => SettingsStore.Save(SettingsFile, Settings); + + public void DrawSettings() => CarCardsSettingsUI.Draw(); +} diff --git a/src/Modules/CarCards/CarCardsOverlay.cs b/src/Modules/CarCards/CarCardsOverlay.cs new file mode 100644 index 0000000..13344a4 --- /dev/null +++ b/src/Modules/CarCards/CarCardsOverlay.cs @@ -0,0 +1,1270 @@ +using System.Collections.Generic; +using HarmonyLib; +using Model; +using S3.Core; +using TMPro; +using UI; +using UI.Menu; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace S3.Modules.CarCards; + +public sealed class CarCardsOverlay : MonoBehaviour +{ + public static CarCardsOverlay? Instance { get; private set; } + public static bool PointerOver { get; private set; } + + static readonly Color WellBg = new(0.10f, 0.10f, 0.11f, 0.55f); + static readonly Color TitleBg = new(0.07f, 0.07f, 0.09f, 0.96f); + static readonly Color TitleEdge = new(0.34f, 0.34f, 0.38f, 0.95f); + + readonly ConsistBinder _binder = new(); + readonly List _cards = new(); + readonly List _pool = new(); + readonly List _dividers = new(); + readonly List _divPool = new(); + readonly List _slots = new(); + readonly List _visible = new(); + readonly List _visW = new(); + readonly HashSet _undocked = new(); + readonly Dictionary _undockPos = new(); + + Canvas _canvas = null!; + RectTransform _canvasRt = null!; + RectTransform _dock = null!; + RectTransform _well = null!; + RectTransform _title = null!; + RectTransform _clip = null!; + RectTransform _fan = null!; + Scrollbar _hScroll = null!; + RectTransform _dropList = null!; + TextMeshProUGUI _consistLabel = null!; + TextMeshProUGUI _empty = null!; + Toggle _pinToggle = null!; + Button _colorBtn = null!; + + int _hover = -1; // visible slot (cars and dividers) + int _sticky = -1; + int _viewSign = 1; + bool _frozenFar; + CardWidget? _returning; + bool _hotkeyWasDown; + bool _dropOpen; + float _rebuildAt; + float _scrollX; + bool _scrollPush; + static PersistentLoader? _loader; + static GameObject? _loadingScreen; + string _lastConsistKey = ""; + bool _loadedUndocked; + + struct FanSlot + { + public bool IsDivider; + public int Index; + } + + void Awake() + { + Instance = this; + BuildCanvas(); + } + + void OnDestroy() + { + PersistPanel(); + PersistUndocked(); + CardNotes.Flush(); + if (Instance == this) Instance = null; + PointerOver = false; + } + + void Update() + { + CardNotes.Tick(); + bool inPlay = InPlay(); + bool vis = inPlay && CarCardsModule.Settings.visible; + if (_canvas.gameObject.activeSelf != vis) + _canvas.gameObject.SetActive(vis); + if (_canvas.enabled != vis) + _canvas.enabled = vis; + if (!inPlay) + { + PointerOver = false; + return; + } + + TickHotkey(); + if (!CarCardsModule.Settings.visible) + { + PointerOver = false; + return; + } + + RefreshIfNeeded(); + if (Mathf.Abs(_dock.sizeDelta.y - FanLayout.DockH) > 0.5f) + _dock.sizeDelta = new Vector2(_dock.sizeDelta.x, FanLayout.DockH); + ApplyZOrder(); + LayoutFan(); + ClampDockToScreen(); + SamplePointer(); + TickWheel(); + } + + static bool InPlay() + { + try + { + if (TrainController.Shared == null) return false; + if (SceneDescriptor.MainMenu.IsLoaded) return false; + if (!SceneDescriptor.GameUI.IsLoaded) return false; + if (LoadingScreenVisible()) return false; + return true; + } + catch { return false; } + } + + static bool LoadingScreenVisible() + { + try + { + if (_loadingScreen != null) + return _loadingScreen.activeInHierarchy; + if (_loader == null) + _loader = UnityEngine.Object.FindObjectOfType(); + if (_loader == null) return false; + _loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue(); + return _loadingScreen != null && _loadingScreen.activeInHierarchy; + } + catch { return false; } + } + + void TickHotkey() + { + if (CarCardsSettingsUI.Capturing) return; + var s = CarCardsModule.Settings; + if (s.hotkeyKeyCode == 0) return; + bool down = Input.GetKey((KeyCode)s.hotkeyKeyCode); + bool mods = + ((s.hotkeyModifiers & 1) == 0 || Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift)) && + ((s.hotkeyModifiers & 2) == 0 || Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) && + ((s.hotkeyModifiers & 4) == 0 || Input.GetKey(KeyCode.LeftAlt) || Input.GetKey(KeyCode.RightAlt)); + if (down && mods && !_hotkeyWasDown) + { + s.visible = !s.visible; + CarCardsModule.Persist(); + } + _hotkeyWasDown = down && mods; + } + + void LoadUndocked() + { + if (_loadedUndocked) return; + _loadedUndocked = true; + var s = CarCardsModule.Settings; + var ids = s.undockedIds ?? System.Array.Empty(); + var xs = s.undockedX ?? System.Array.Empty(); + var ys = s.undockedY ?? System.Array.Empty(); + for (int i = 0; i < ids.Length; i++) + { + if (string.IsNullOrEmpty(ids[i])) continue; + _undocked.Add(ids[i]); + float x = i < xs.Length ? xs[i] : 80f; + float y = i < ys.Length ? ys[i] : 200f; + _undockPos[ids[i]] = new Vector2(x, y); + } + } + + void RefreshIfNeeded() + { + if (Time.unscaledTime < _rebuildAt) return; + _rebuildAt = Time.unscaledTime + 0.15f; + LoadUndocked(); + + _cards.Clear(); + var mode = (CardColorMode)CarCardsModule.Settings.colorMode; + var cars = _binder.Resolve(); + ApplyViewOrder(cars); + foreach (Car car in cars) + { + try { _cards.Add(CardViewModel.From(car, mode)); } + catch { } + } + + string cutSig = ""; + try { WaypointCutSim.Fill(_cards, _dividers, out cutSig); } + catch { _dividers.Clear(); cutSig = ""; } + RebuildSlots(); + + string key = _binder.Anchor != null + ? _binder.Anchor.id + ":" + _cards.Count + ":" + _viewSign + ":" + cutSig + + ":" + (_cards.Count > 0 ? _cards[0].Id : "") + + ":" + (_cards.Count > 0 ? _cards[_cards.Count - 1].Id : "") + : ""; + if (key != _lastConsistKey) + { + _lastConsistKey = key; + _sticky = -1; + _hover = -1; + } + + PruneMissingUndocked(); + SyncWidgets(); + SyncDividers(); + + _empty.gameObject.SetActive(DockedCount() == 0); + _consistLabel.text = ConsistTitle(); + if (_pinToggle.isOn != CarCardsModule.Settings.pinned) + _pinToggle.SetIsOnWithoutNotify(CarCardsModule.Settings.pinned); + var colorTmp = _colorBtn.GetComponentInChildren(); + if (colorTmp != null) + colorTmp.text = ColorLabel(CarCardsModule.Settings.colorMode); + } + + void ApplyViewOrder(List cars) + { + if (cars.Count < 2) return; + var s = CarCardsModule.Settings; + try + { + if (s.matchMapRotation && CardViewOrder.TryMapCamera(out Camera mapCam)) + { + ApplyScreenOrder(cars, mapCam); + return; + } + + Camera? cam = Camera.main; + if (s.freezeOrderAtDistance && cam != null && + CardViewOrder.IsFar(cam, cars, s.viewOrderFreezeDistance, ref _frozenFar)) + { + CardViewOrder.OrderLeadLeft(cars); + _viewSign = 1; + return; + } + + if (!s.matchViewOrder) return; + if (cam == null) return; + ApplyScreenOrder(cars, cam); + } + catch { } + } + + void ApplyScreenOrder(List cars, Camera cam) + { + if (!TryConsistScreenDelta(cars, cam, out float dx)) + return; + const float dead = 64f; + if (dx < -dead) _viewSign = -1; + else if (dx > dead) _viewSign = 1; + if (_viewSign < 0) cars.Reverse(); + } + + static bool TryConsistScreenDelta(List cars, Camera cam, out float dx) + { + dx = 0f; + int n = cars.Count; + int take = Mathf.Max(1, Mathf.Min(3, n / 2)); + if (!TryBandScreenX(cars, cam, 0, take, out float left)) + return false; + if (!TryBandScreenX(cars, cam, n - take, take, out float right)) + return false; + dx = right - left; + return true; + } + + static bool TryBandScreenX(List cars, Camera cam, int start, int count, out float x) + { + x = 0f; + int hits = 0; + float sum = 0f; + for (int i = 0; i < count; i++) + { + if (!TryScreenX(cars[start + i], cam, out float sx)) + continue; + sum += sx; + hits++; + } + if (hits == 0) return false; + x = sum / hits; + return true; + } + + static bool TryScreenX(Car car, Camera cam, out float x) + { + x = 0f; + if (car == null) return false; + Transform body = car.BodyTransform != null ? car.BodyTransform : car.transform; + Vector3 screen = cam.WorldToScreenPoint(body.position); + if (screen.z < 0.2f) return false; + x = screen.x; + return true; + } + + void PruneMissingUndocked() + { + if (_undocked.Count == 0) return; + var drop = new List(); + foreach (string id in _undocked) + { + if (ConsistBinder.Find(id) == null) + drop.Add(id); + } + foreach (string id in drop) + { + _undocked.Remove(id); + _undockPos.Remove(id); + } + if (drop.Count > 0) PersistUndocked(); + } + + void SyncWidgets() + { + int need = _cards.Count; + while (_pool.Count < need) + _pool.Add(CardWidget.Create(_fan, this)); + + for (int i = 0; i < _cards.Count; i++) + { + string id = _cards[i].Id; + int found = -1; + for (int j = i; j < _pool.Count; j++) + { + if (_pool[j].Model?.Id == id) + { + found = j; + break; + } + } + if (found > i) + { + var swap = _pool[i]; + _pool[i] = _pool[found]; + _pool[found] = swap; + } + } + + for (int i = 0; i < _pool.Count; i++) + { + if (i >= _cards.Count) + { + _pool[i].Hide(); + continue; + } + var vm = _cards[i]; + var w = _pool[i]; + w.Bind(vm, i); + if (w.Dragging || _returning == w) + continue; + bool undock = _undocked.Contains(vm.Id); + w.SetUndocked(undock); + if (undock) + { + PlaceUndocked(w, vm.Id); + } + else if (w.transform.parent != _fan) + { + w.transform.SetParent(_fan, false); + } + } + } + + void SyncDividers() + { + while (_divPool.Count < _dividers.Count) + _divPool.Add(DividerWidget.Create(_fan)); + for (int i = 0; i < _divPool.Count; i++) + { + if (i >= _dividers.Count) + { + _divPool[i].Hide(); + continue; + } + _divPool[i].Bind(_dividers[i]); + if (_divPool[i].transform.parent != _fan) + _divPool[i].transform.SetParent(_fan, false); + } + } + + void RebuildSlots() + { + _slots.Clear(); + if (_cards.Count == 0) return; + + var placed = new List<(int after, int div)>(); + for (int i = 0; i < _dividers.Count; i++) + { + int after = AfterIndex(_dividers[i]); + if (after == int.MinValue) continue; + placed.Add((after, i)); + } + placed.Sort((a, b) => + { + int c = a.after.CompareTo(b.after); + return c != 0 ? c : a.div.CompareTo(b.div); + }); + + int m = 0; + while (m < placed.Count && placed[m].after < 0) + { + _slots.Add(new FanSlot { IsDivider = true, Index = placed[m].div }); + m++; + } + for (int i = 0; i < _cards.Count; i++) + { + _slots.Add(new FanSlot { IsDivider = false, Index = i }); + while (m < placed.Count && placed[m].after == i) + { + _slots.Add(new FanSlot { IsDivider = true, Index = placed[m].div }); + m++; + } + } + while (m < placed.Count) + { + _slots.Add(new FanSlot { IsDivider = true, Index = placed[m].div }); + m++; + } + } + + int AfterIndex(WaypointDivider d) + { + if (d.Outer) + return OuterAfter(d.LeftId, d.RightId); + int a = IndexOfCar(d.LeftId); + int b = IndexOfCar(d.RightId); + if (a >= 0 && b >= 0) + return Mathf.Min(a, b); + if (a >= 0) return a; + if (b >= 0) return b - 1; + return int.MinValue; + } + + int OuterAfter(string endId, string inwardId) + { + int end = IndexOfCar(endId); + if (end < 0) return int.MinValue; + int inn = IndexOfCar(inwardId); + if (inn >= 0 && inn != end) + return inn > end ? end - 1 : end; + int left = end; + int right = _cards.Count - 1 - end; + return left <= right ? end - 1 : end; + } + + int IndexOfCar(string id) + { + if (string.IsNullOrEmpty(id)) return -1; + for (int i = 0; i < _cards.Count; i++) + if (_cards[i].Id == id) return i; + return -1; + } + + void CollectVisible() + { + _visible.Clear(); + _visW.Clear(); + for (int i = 0; i < _slots.Count; i++) + { + var slot = _slots[i]; + if (slot.IsDivider) + { + if (!DividerHasDockedNeighbor(i)) continue; + if (slot.Index < 0 || slot.Index >= _divPool.Count) continue; + _visible.Add(slot); + _visW.Add(FanLayout.DividerW); + } + else + { + if (slot.Index < 0 || slot.Index >= _pool.Count) continue; + var w = _pool[slot.Index]; + if (w == null || w.Undocked || w.Dragging) continue; + _visible.Add(slot); + _visW.Add(FanLayout.CardW); + } + } + } + + bool DividerHasDockedNeighbor(int slotI) + { + bool hasLeft = false, leftDocked = false; + for (int j = slotI - 1; j >= 0; j--) + { + if (_slots[j].IsDivider) continue; + hasLeft = true; + leftDocked = CarDocked(_slots[j].Index); + break; + } + bool hasRight = false, rightDocked = false; + for (int j = slotI + 1; j < _slots.Count; j++) + { + if (_slots[j].IsDivider) continue; + hasRight = true; + rightDocked = CarDocked(_slots[j].Index); + break; + } + if (hasLeft && hasRight) return leftDocked || rightDocked; + if (hasLeft) return leftDocked; + if (hasRight) return rightDocked; + return false; + } + + bool CarDocked(int cardIndex) + { + if (cardIndex < 0 || cardIndex >= _pool.Count) return false; + var w = _pool[cardIndex]; + return w != null && w.gameObject.activeSelf && !w.Undocked && !w.Dragging; + } + + int HoverSlot(int hoverCar) + { + if (hoverCar < 0) return -1; + int ci = 0; + for (int i = 0; i < _visible.Count; i++) + { + if (_visible[i].IsDivider) continue; + if (ci == hoverCar) return i; + ci++; + } + return -1; + } + + RectTransform? SlotRt(FanSlot slot) + { + if (slot.IsDivider) + { + if (slot.Index < 0 || slot.Index >= _divPool.Count) return null; + return (RectTransform)_divPool[slot.Index].transform; + } + if (slot.Index < 0 || slot.Index >= _pool.Count) return null; + return (RectTransform)_pool[slot.Index].transform; + } + + void PlaceUndocked(CardWidget w, string id) + { + var rt = (RectTransform)w.transform; + if (rt.parent != _canvasRt) + rt.SetParent(_canvasRt, false); + rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f); + rt.pivot = new Vector2(0f, 0f); + rt.sizeDelta = new Vector2(FanLayout.CardW, FanLayout.CardH); + if (!w.Dragging && _undockPos.TryGetValue(id, out Vector2 pos)) + rt.anchoredPosition = pos; + rt.SetAsLastSibling(); + } + + int DockedCount() + { + int n = 0; + foreach (var c in _cards) + if (!_undocked.Contains(c.Id)) n++; + return n; + } + + string ConsistTitle() + { + if (_binder.Anchor == null) return "Follow selection"; + string mark = _binder.Anchor.DisplayName; + return CarCardsModule.Settings.pinned ? mark : mark + " (selected)"; + } + + void LayoutFan() + { + var s = CarCardsModule.Settings; + bool busy = AnyDragging() || _returning != null; + CollectVisible(); + int hoverSlot = busy ? -1 : LiftedSlot(); + float dt = Time.unscaledDeltaTime; + float t = 1f - Mathf.Exp(-FanLayout.AnimSpeed * dt); + float tReturn = 1f - Mathf.Exp(-8f * dt); + float overlap = s.overlap; + + for (int i = 0; i < _divPool.Count; i++) + { + bool used = false; + for (int v = 0; v < _visible.Count; v++) + { + if (_visible[v].IsDivider && _visible[v].Index == i) + { + used = true; + break; + } + } + if (_divPool[i].gameObject.activeSelf != used) + _divPool[i].gameObject.SetActive(used); + } + + for (int i = 0; i < _visible.Count; i++) + { + var slot = _visible[i]; + var rt = SlotRt(slot); + if (rt == null) continue; + float y = FanLayout.SlotY(i, hoverSlot, true); + Vector2 tgt = new Vector2(FanLayout.XAt(_visW, i, hoverSlot, overlap) + _scrollX, y); + rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f); + rt.pivot = new Vector2(0f, 0f); + rt.sizeDelta = new Vector2(_visW[i], FanLayout.CardH); + bool returning = !slot.IsDivider && slot.Index < _pool.Count && _returning == _pool[slot.Index]; + rt.anchoredPosition = Vector2.Lerp(rt.anchoredPosition, tgt, returning ? tReturn : t); + if (!slot.IsDivider && slot.Index < _pool.Count) + _pool[slot.Index].SetLifted(i == hoverSlot); + else if (slot.IsDivider && slot.Index < _divPool.Count) + _divPool[slot.Index].SetLifted(i == hoverSlot); + if (returning) + rt.SetAsLastSibling(); + else if (i != hoverSlot) + rt.SetSiblingIndex(i); + if (returning && (rt.anchoredPosition - tgt).sqrMagnitude < 4f) + _returning = null; + } + // Cover-flow draw order: later rest cards sit on top, so the left + // neighbor is already only under the lifted card. Cards to the right + // stay under every later sibling unless we reverse them, which is why + // parting never showed extra face on the right neighbor. + if (hoverSlot >= 0 && hoverSlot < _visible.Count) + { + for (int i = _visible.Count - 1; i > hoverSlot; i--) + SlotRt(_visible[i])?.SetAsLastSibling(); + SlotRt(_visible[hoverSlot])?.SetAsLastSibling(); + } + SyncScrollbar(hoverSlot); + } + + bool AnyDragging() + { + for (int i = 0; i < _pool.Count; i++) + if (_pool[i] != null && _pool[i].Dragging) return true; + return false; + } + + /// Visible fan slot to lift, or -1. is a slot index (cars and dividers). + int LiftedSlot() + { + var s = CarCardsModule.Settings; + if (_sticky >= 0 && _sticky < DockedCount()) + return HoverSlot(_sticky); + if (s.revealOnHover && _hover >= 0 && _hover < _visible.Count) + return _hover; + return -1; + } + + void SamplePointer() + { + Vector2 mouse = Input.mousePosition; + bool overDock = RectTransformUtility.RectangleContainsScreenPoint(_dock, mouse, null); + bool overDrop = _dropOpen && RectTransformUtility.RectangleContainsScreenPoint(_dropList, mouse, null); + bool overCard = false; + if (AnyDragging()) + { + _hover = -1; + PointerOver = overDock || overDrop; + for (int i = 0; i < _pool.Count; i++) + { + if (!_pool[i].gameObject.activeSelf) continue; + if (RectTransformUtility.RectangleContainsScreenPoint((RectTransform)_pool[i].transform, mouse, null)) + overCard = true; + } + PointerOver = overDock || overDrop || overCard; + return; + } + + CollectVisible(); + if (_hover >= _visible.Count) _hover = -1; + + for (int i = 0; i < _visible.Count; i++) + { + var rt = SlotRt(_visible[i]); + if (rt == null || !rt.gameObject.activeSelf) continue; + if (RectTransformUtility.RectangleContainsScreenPoint(rt, mouse, null)) + overCard = true; + } + + Vector3[] dc = new Vector3[4]; + _dock.GetWorldCorners(dc); + float dockLeft = dc[0].x, dockRight = dc[2].x, dockBottom = dc[0].y; + bool inDockX = mouse.x >= dockLeft && mouse.x <= dockRight; + float overlap = CarCardsModule.Settings.overlap; + + if (_hover >= 0 && _hover < _visible.Count) + { + var rt = SlotRt(_visible[_hover]); + if (rt == null || !rt.gameObject.activeSelf) + _hover = -1; + else if (RectTransformUtility.RectangleContainsScreenPoint(rt, mouse, null)) + { + // Stay on this card until the pointer leaves its visual face. + } + else if (inDockX && mouse.y >= dockBottom) + { + Vector3[] c = new Vector3[4]; + rt.GetWorldCorners(c); + float left = c[0].x; + float right = c[2].x; + if (mouse.x < left) + _hover = _hover > 0 ? _hover - 1 : -1; + else if (mouse.x > right) + _hover = _hover + 1 < _visible.Count ? _hover + 1 : -1; + else + _hover = -1; + } + else + { + _hover = -1; + } + } + else if (inDockX && mouse.y >= dockBottom) + { + _hover = PickRestSlot(mouse, overlap); + } + + PointerOver = overDock || overDrop || overCard; + } + + int PickRestSlot(Vector2 mouse, float overlap) + { + int pick = -1; + for (int i = 0; i < _visible.Count; i++) + { + float x0 = FanLayout.XAt(_visW, i, -1, overlap) + _scrollX; + float x1 = FanLayout.RestExclusiveRight(_visW, i, overlap) + _scrollX; + float w = Mathf.Max(1f, x1 - x0); + Vector2 rest = new Vector2(x0, FanLayout.SlotY(i, -1, true)); + if (FanRectContains(rest, mouse, w)) + pick = i; + } + return pick; + } + + bool FanRectContains(Vector2 anchoredBl, Vector2 screen, float width) + { + if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(_fan, screen, null, out Vector2 local)) + return false; + float w = _fan.rect.width; + float h = _fan.rect.height; + float x0 = -w * _fan.pivot.x + anchoredBl.x; + float y0 = -h * _fan.pivot.y + anchoredBl.y; + return local.x >= x0 && local.x <= x0 + width + && local.y >= y0 && local.y <= y0 + FanLayout.CardH; + } + + float ScrollRange(int hoverSlot) + { + float view = Mathf.Max(1f, _dock.rect.width); + float span = FanLayout.SpanOf(_visW, hoverSlot, CarCardsModule.Settings.overlap); + return Mathf.Max(0f, span - view); + } + + void ClampScroll(int hoverSlot) + { + float range = ScrollRange(hoverSlot); + _scrollX = Mathf.Clamp(_scrollX, -range, 0f); + } + + void SyncScrollbar(int hoverSlot) + { + if (_hScroll == null) return; + float range = ScrollRange(hoverSlot); + bool need = range > 1f; + if (_hScroll.gameObject.activeSelf != need) + _hScroll.gameObject.SetActive(need); + if (!need) + { + _scrollX = 0f; + _scrollPush = true; + _hScroll.SetValueWithoutNotify(0f); + _hScroll.size = 1f; + _scrollPush = false; + return; + } + ClampScroll(hoverSlot); + float view = Mathf.Max(1f, _dock.rect.width); + float span = view + range; + _scrollPush = true; + _hScroll.size = Mathf.Clamp01(view / span); + _hScroll.SetValueWithoutNotify(range < 0.01f ? 0f : Mathf.Clamp01(-_scrollX / range)); + _scrollPush = false; + } + + void OnHScroll(float v) + { + if (_scrollPush) return; + CollectVisible(); + int hoverSlot = AnyDragging() || _returning != null ? -1 : LiftedSlot(); + float range = ScrollRange(hoverSlot); + _scrollX = -v * range; + } + + void TickWheel() + { + if (!PointerOver) return; + Vector2 wheel = Input.mouseScrollDelta; + float delta = wheel.x + wheel.y; + if (Mathf.Abs(delta) < 0.01f) return; + _scrollX += delta * 48f; + CollectVisible(); + int hoverSlot = AnyDragging() || _returning != null ? -1 : LiftedSlot(); + ClampScroll(hoverSlot); + SyncScrollbar(hoverSlot); + } + + internal void NotifyClick(CardWidget w) + { + if (w.Model == null) return; + if (!w.Undocked && CarCardsModule.Settings.revealOnClick) + _sticky = DockedIndexOf(w); + CardClick.Apply(w.Model.Car); + } + + int DockedIndexOf(CardWidget w) + { + int di = 0; + for (int i = 0; i < _cards.Count; i++) + { + if (_pool[i].Undocked) continue; + if (_pool[i] == w) return di; + di++; + } + return -1; + } + + internal void BeginUndock(CardWidget w) + { + if (w.Model == null) return; + var rt = (RectTransform)w.transform; + Vector3 world = rt.position; + rt.SetParent(_canvasRt, true); + rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f); + rt.pivot = new Vector2(0f, 0f); + rt.position = world; + w.SetUndocked(true); + _undocked.Add(w.Model.Id); + _returning = null; + _sticky = -1; + _hover = -1; + } + + internal void EndUndockDrag(CardWidget w, Vector2 screen) + { + if (w.Model == null) return; + if (RectTransformUtility.RectangleContainsScreenPoint(_dock, screen, null)) + { + Redock(w, snap: false); + return; + } + var rt = (RectTransform)w.transform; + _undockPos[w.Model.Id] = rt.anchoredPosition; + PersistUndocked(); + } + + internal void Redock(CardWidget w, bool snap) + { + if (w.Model == null) return; + var rt = (RectTransform)w.transform; + Vector3 world = rt.position; + w.SetUndocked(false); + rt.SetParent(_fan, true); + rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f); + rt.pivot = new Vector2(0f, 0f); + rt.position = world; + _undocked.Remove(w.Model.Id); + _undockPos.Remove(w.Model.Id); + _returning = w; + if (snap) + { + CollectVisible(); + int slot = -1; + for (int i = 0; i < _visible.Count; i++) + { + if (!_visible[i].IsDivider && _visible[i].Index < _pool.Count && _pool[_visible[i].Index] == w) + { + slot = i; + break; + } + } + if (slot >= 0) + { + rt.anchoredPosition = new Vector2( + FanLayout.XAt(_visW, slot, -1, CarCardsModule.Settings.overlap) + _scrollX, + FanLayout.SlotY(slot, -1, true)); + } + _returning = null; + } + PersistUndocked(); + } + + void ApplyZOrder() + { + _well.SetAsFirstSibling(); + _clip.SetSiblingIndex(1); + _title.SetSiblingIndex(2); + if (_hScroll != null) + _hScroll.transform.SetSiblingIndex(3); + if (_dropOpen) + _dropList.SetAsLastSibling(); + Transform grip = _dock.Find("Resize"); + if (grip != null) grip.SetAsLastSibling(); + } + + void BuildCanvas() + { + var canvasGo = new GameObject("Canvas", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster)); + canvasGo.transform.SetParent(transform, false); + _canvas = canvasGo.GetComponent(); + _canvas.renderMode = RenderMode.ScreenSpaceOverlay; + _canvas.sortingOrder = 4500; + var scaler = canvasGo.GetComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1920f, 1080f); + scaler.matchWidthOrHeight = 0.5f; + _canvasRt = (RectTransform)canvasGo.transform; + + _dock = BuildDock(_canvasRt); + BuildWell(_dock); + BuildTitle(_dock); + BuildFan(_dock); + _dropList = BuildDropList(_dock); + _dropList.gameObject.SetActive(false); + BuildScroll(_dock); + CardUi.AddResizeGrip(_dock, PersistPanel, 420f, FanLayout.DockH); + ClampDockToScreen(); + } + + RectTransform BuildDock(RectTransform canvas) + { + var go = new GameObject("Dock", typeof(RectTransform)); + var rt = (RectTransform)go.transform; + rt.SetParent(canvas, false); + rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0f); + rt.pivot = new Vector2(0.5f, 0f); + var s = CarCardsModule.Settings; + rt.sizeDelta = new Vector2(Mathf.Max(420f, s.windowW), FanLayout.DockH); + if (s.windowX >= 0f) + { + rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f); + rt.pivot = new Vector2(0f, 0f); + rt.anchoredPosition = new Vector2(s.windowX, s.windowY); + } + else + rt.anchoredPosition = new Vector2(0f, 16f); + return rt; + } + + void BuildWell(RectTransform dock) + { + var img = CardUi.MakeImage(dock, "Well", WellBg, raycast: true); + _well = img.rectTransform; + _well.anchorMin = new Vector2(0f, 0f); + _well.anchorMax = new Vector2(1f, 1f); + _well.offsetMin = Vector2.zero; + _well.offsetMax = Vector2.zero; + _well.pivot = new Vector2(0.5f, 0.5f); + } + + void BuildTitle(RectTransform dock) + { + var img = CardUi.MakeImage(dock, "Title", TitleBg, raycast: true); + _title = img.rectTransform; + _title.anchorMin = new Vector2(0f, 0f); + _title.anchorMax = new Vector2(1f, 0f); + _title.pivot = new Vector2(0.5f, 0f); + _title.anchoredPosition = Vector2.zero; + _title.sizeDelta = new Vector2(0f, FanLayout.TitleH); + img.gameObject.AddComponent().Bind(_dock, PersistPanel); + + var edge = CardUi.MakeImage(_title, "Edge", TitleEdge, raycast: false); + edge.rectTransform.anchorMin = new Vector2(0f, 1f); + edge.rectTransform.anchorMax = new Vector2(1f, 1f); + edge.rectTransform.pivot = new Vector2(0.5f, 1f); + edge.rectTransform.anchoredPosition = Vector2.zero; + edge.rectTransform.sizeDelta = new Vector2(0f, 2f); + + var label = CardUi.Tmp(_title, "Label", 14f, Color.white, FontStyles.Bold, TextAlignmentOptions.MidlineLeft); + label.rectTransform.anchorMin = label.rectTransform.anchorMax = new Vector2(0f, 0.5f); + label.rectTransform.pivot = new Vector2(0f, 0.5f); + label.rectTransform.anchoredPosition = new Vector2(10f, 0f); + label.rectTransform.sizeDelta = new Vector2(100f, 24f); + label.text = "Car Cards"; + + var consist = CardUi.Button(_title, "Consist", "Follow selection", new Vector2(240f, 24f)); + var crt = consist.GetComponent(); + crt.anchorMin = crt.anchorMax = new Vector2(0f, 0.5f); + crt.pivot = new Vector2(0f, 0.5f); + crt.anchoredPosition = new Vector2(104f, 0f); + _consistLabel = consist.GetComponentInChildren(); + consist.onClick.AddListener(ToggleDrop); + + _pinToggle = CardUi.Toggle(_title, "PinConsist", "Pin consist"); + var pinRt = _pinToggle.GetComponent(); + pinRt.anchorMin = pinRt.anchorMax = new Vector2(0f, 0.5f); + pinRt.pivot = new Vector2(0f, 0.5f); + pinRt.anchoredPosition = new Vector2(352f, 0f); + pinRt.sizeDelta = new Vector2(110f, 24f); + _pinToggle.onValueChanged.AddListener(on => + { + if (on && _binder.Anchor != null) _binder.PinTo(_binder.Anchor); + else _binder.FollowSelection(); + }); + + _colorBtn = CardUi.Button(_title, "Color", ColorLabel(CarCardsModule.Settings.colorMode), new Vector2(120f, 24f)); + var colorRt = _colorBtn.GetComponent(); + colorRt.anchorMin = colorRt.anchorMax = new Vector2(1f, 0.5f); + colorRt.pivot = new Vector2(1f, 0.5f); + colorRt.anchoredPosition = new Vector2(-10f, 0f); + _colorBtn.onClick.AddListener(() => + { + var s = CarCardsModule.Settings; + s.colorMode = (s.colorMode + 1) % 6; + CarCardsModule.Persist(); + _rebuildAt = 0f; + }); + } + + void BuildFan(RectTransform dock) + { + var clipGo = new GameObject("Clip", typeof(RectTransform), typeof(RectMask2D)); + _clip = (RectTransform)clipGo.transform; + _clip.SetParent(dock, false); + _clip.anchorMin = Vector2.zero; + _clip.anchorMax = Vector2.one; + _clip.offsetMin = new Vector2(0f, FanLayout.TitleH + FanLayout.ScrollH); + _clip.offsetMax = new Vector2(0f, FanLayout.CardH); + + var go = new GameObject("Fan", typeof(RectTransform)); + _fan = (RectTransform)go.transform; + _fan.SetParent(_clip, false); + _fan.anchorMin = Vector2.zero; + _fan.anchorMax = Vector2.one; + _fan.offsetMin = Vector2.zero; + _fan.offsetMax = Vector2.zero; + + _empty = CardUi.Tmp(_fan, "Empty", 13f, new Color(0.7f, 0.7f, 0.72f), FontStyles.Normal, TextAlignmentOptions.Center); + _empty.rectTransform.anchorMin = new Vector2(0f, 0f); + _empty.rectTransform.anchorMax = new Vector2(1f, 0f); + _empty.rectTransform.pivot = new Vector2(0.5f, 0f); + _empty.rectTransform.anchoredPosition = new Vector2(0f, 8f); + _empty.rectTransform.sizeDelta = new Vector2(-16f, 40f); + _empty.text = "Select a car, or pick a consist from the list."; + } + + void BuildScroll(RectTransform dock) + { + _hScroll = CardUi.HScroll(dock); + var rt = _hScroll.GetComponent(); + rt.anchorMin = new Vector2(0f, 0f); + rt.anchorMax = new Vector2(1f, 0f); + rt.pivot = new Vector2(0.5f, 0f); + rt.anchoredPosition = new Vector2(-8f, FanLayout.TitleH); + rt.sizeDelta = new Vector2(-28f, FanLayout.ScrollH); + var bg = _hScroll.GetComponent(); + if (bg != null) + bg.color = new Color(0f, 0f, 0f, 0f); + _hScroll.onValueChanged.AddListener(OnHScroll); + _hScroll.gameObject.SetActive(false); + } + + RectTransform BuildDropList(RectTransform dock) + { + var img = CardUi.MakeImage(dock, "Drop", new Color(0.14f, 0.14f, 0.16f, 0.98f), raycast: true); + var rt = img.rectTransform; + rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f); + rt.pivot = new Vector2(0f, 0f); + rt.anchoredPosition = new Vector2(104f, FanLayout.TitleH + 4f); + rt.sizeDelta = new Vector2(260f, 40f); + return rt; + } + + void ToggleDrop() + { + _dropOpen = !_dropOpen; + _dropList.gameObject.SetActive(_dropOpen); + if (!_dropOpen) return; + for (int i = _dropList.childCount - 1; i >= 0; i--) + Destroy(_dropList.GetChild(i).gameObject); + + var options = ConsistBinder.OwnedConsists(); + float h = 24f * (options.Count + 1) + 8f; + _dropList.sizeDelta = new Vector2(260f, h); + float y = h - 4f; + AddDropRow("Follow selection", y, () => + { + _binder.FollowSelection(); + _dropOpen = false; + _dropList.gameObject.SetActive(false); + _sticky = -1; + }); + y -= 24f; + foreach (var opt in options) + { + var captured = opt; + AddDropRow(opt.Label, y, () => + { + _binder.PinTo(captured.Anchor); + _dropOpen = false; + _dropList.gameObject.SetActive(false); + _sticky = -1; + }); + y -= 24f; + } + _dropList.SetAsLastSibling(); + } + + void AddDropRow(string label, float y, UnityEngine.Events.UnityAction act) + { + var btn = CardUi.Button(_dropList, label, label, new Vector2(252f, 22f)); + var rt = btn.GetComponent(); + rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0f); + rt.pivot = new Vector2(0.5f, 0.5f); + rt.anchoredPosition = new Vector2(0f, y - 11f); + btn.onClick.AddListener(act); + } + + internal void ClampDockToScreen() + { + if (_dock == null || _canvasRt == null) return; + Vector3[] d = new Vector3[4]; + Vector3[] c = new Vector3[4]; + _dock.GetWorldCorners(d); + _canvasRt.GetWorldCorners(c); + float dx = 0f, dy = 0f; + float dockW = d[2].x - d[0].x; + float dockH = d[1].y - d[0].y; + float viewW = c[2].x - c[0].x; + float viewH = c[1].y - c[0].y; + if (dockW >= viewW) + dx = c[0].x - d[0].x; + else + { + if (d[0].x < c[0].x) dx = c[0].x - d[0].x; + else if (d[2].x > c[2].x) dx = c[2].x - d[2].x; + } + if (dockH >= viewH) + dy = c[0].y - d[0].y; + else + { + if (d[0].y < c[0].y) dy = c[0].y - d[0].y; + else if (d[1].y > c[1].y) dy = c[1].y - d[1].y; + } + if (dx != 0f || dy != 0f) + _dock.position += new Vector3(dx, dy, 0f); + } + + void PersistPanel() + { + if (_dock == null) return; + var s = CarCardsModule.Settings; + s.windowW = _dock.sizeDelta.x; + s.windowH = _dock.sizeDelta.y; + s.windowX = _dock.anchoredPosition.x; + s.windowY = _dock.anchoredPosition.y; + CarCardsModule.Persist(); + } + + void PersistUndocked() + { + var s = CarCardsModule.Settings; + var ids = new List(_undocked); + s.undockedIds = ids.ToArray(); + s.undockedX = new float[ids.Count]; + s.undockedY = new float[ids.Count]; + for (int i = 0; i < ids.Count; i++) + { + if (_undockPos.TryGetValue(ids[i], out Vector2 p)) + { + s.undockedX[i] = p.x; + s.undockedY[i] = p.y; + } + } + CarCardsModule.Persist(); + } + + static string ColorLabel(int mode) => mode switch + { + 0 => "Color: Owner", + 1 => "Color: Dest", + 2 => "Color: Origin", + 3 => "Color: Type", + 4 => "Color: Paint", + 5 => "Color: Mark", + _ => "Color", + }; +} + +sealed class PanelDrag : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler +{ + RectTransform _rt = null!; + System.Action? _onEnd; + + public void Bind(RectTransform rt, System.Action onEnd) + { + _rt = rt; + _onEnd = onEnd; + } + + public void OnBeginDrag(PointerEventData eventData) { } + + public void OnDrag(PointerEventData eventData) + { + var canvas = _rt.GetComponentInParent(); + float scale = canvas != null ? canvas.scaleFactor : 1f; + _rt.anchoredPosition += eventData.delta / scale; + CarCardsOverlay.Instance?.ClampDockToScreen(); + } + + public void OnEndDrag(PointerEventData eventData) + { + CarCardsOverlay.Instance?.ClampDockToScreen(); + _onEnd?.Invoke(); + } +} + +sealed class PanelResize : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler +{ + RectTransform _rt = null!; + System.Action? _onEnd; + float _minW = 180f; + float _minH = 160f; + + public void Bind(RectTransform rt, System.Action? onEnd, float minW = 180f, float minH = 160f) + { + _rt = rt; + _onEnd = onEnd; + _minW = minW; + _minH = minH; + } + + public void OnBeginDrag(PointerEventData eventData) { } + + public void OnDrag(PointerEventData eventData) + { + var canvas = _rt.GetComponentInParent(); + float scale = canvas != null ? canvas.scaleFactor : 1f; + float dx = eventData.delta.x / scale; + float dy = eventData.delta.y / scale; + Vector2 old = _rt.sizeDelta; + Vector2 size = old; + size.x = Mathf.Clamp(old.x + dx, _minW, 2400f); + size.y = _minH; + float dW = size.x - old.x; + float dH = size.y - old.y; + Vector2 pivot = _rt.pivot; + _rt.sizeDelta = size; + float yShift = pivot.y < 0.05f ? 0f : -(1f - pivot.y) * dH; + _rt.anchoredPosition += new Vector2(pivot.x * dW, yShift); + CarCardsOverlay.Instance?.ClampDockToScreen(); + } + + public void OnEndDrag(PointerEventData eventData) + { + CarCardsOverlay.Instance?.ClampDockToScreen(); + _onEnd?.Invoke(); + } +} + +[HarmonyPatch(typeof(GameInput), nameof(GameInput.IsMouseOverUI))] +static class CarCardsMouseOverUiPatch +{ + static void Postfix(ref bool __result) + { + if (CarCardsOverlay.PointerOver) __result = true; + } +} diff --git a/src/Modules/CarCards/CarCardsSettings.cs b/src/Modules/CarCards/CarCardsSettings.cs new file mode 100644 index 0000000..275a635 --- /dev/null +++ b/src/Modules/CarCards/CarCardsSettings.cs @@ -0,0 +1,71 @@ +using System; + +namespace S3.Modules.CarCards; + +[Serializable] +public class CarCardsSettings +{ + public bool enabled = false; + public bool visible = true; + + public int hotkeyKeyCode = 0; + public int hotkeyModifiers = 0; + + // 0 owner, 1 dest, 2 origin, 3 type, 4 paint, 5 mark + public int colorMode = 1; + + public float overlap = 0.55f; + // Fraction of card width neighbors still cover when a card is lifted. + public float hoverCover = 0.125f; + public bool revealOnHover = true; + public bool revealOnClick = true; + + // 0 select, 1 follow, 2 inspector + public int clickAction = 0; + + public bool pinned; + public string pinCarId = ""; + + public float windowX = -1f; + public float windowY = -1f; + public float windowW = 920f; + public float windowH = 144f; + + public bool cardsBehindTitle = true; + public bool matchViewOrder = true; + public bool freezeOrderAtDistance = true; + public float viewOrderFreezeDistance = 1500f; + public bool matchMapRotation = false; + public bool showWaypointCuts = true; + // 0 follow, 1 map, 2 both + public int locateMode = 0; + + // Parallel arrays (SettingsStore cannot nest custom classes). + public string[] undockedIds = Array.Empty(); + public float[] undockedX = Array.Empty(); + public float[] undockedY = Array.Empty(); +} + +public enum CardColorMode +{ + Owner = 0, + Destination = 1, + Origin = 2, + Type = 3, + Paint = 4, + Mark = 5, +} + +public enum CardClickAction +{ + Select = 0, + Follow = 1, + Inspector = 2, +} + +public enum CardLocateMode +{ + Follow = 0, + Map = 1, + Both = 2, +} diff --git a/src/Modules/CarCards/CarCardsSettingsUI.cs b/src/Modules/CarCards/CarCardsSettingsUI.cs new file mode 100644 index 0000000..ee69803 --- /dev/null +++ b/src/Modules/CarCards/CarCardsSettingsUI.cs @@ -0,0 +1,153 @@ +using UnityEngine; + +namespace S3.Modules.CarCards; + +static class CarCardsSettingsUI +{ + internal static bool Capturing; + + static readonly string[] ColorNames = + { + "Owner", "Destination", "Origin", "Type", "Paint", "Mark", + }; + + static readonly string[] ClickNames = + { + "Select", "Follow", "Inspector", + }; + + static readonly string[] LocateNames = + { + "Follow", "Show on map", "Both", + }; + + public static void Draw() + { + var s = CarCardsModule.Settings; + bool changed = false; + + GUILayout.BeginVertical(); + GUILayout.Label("Car Cards - fanned consist dock over the game"); + GUILayout.Space(4f); + GUILayout.Label( + " Bound to the coupled cut of the selected car, or a pinned consist.\n" + + " Title bar sits under the cards. Drag a card out to keep it on screen.\n" + + " Enable the module, restart, then pick a hotkey (or leave the overlay on).", + GUI.skin.label); + + GUILayout.Space(10f); + GUILayout.Label("Hotkey"); + GUILayout.Space(4f); + GUILayout.BeginHorizontal(); + GUILayout.Label("Toggle overlay:", GUILayout.Width(110f)); + if (Capturing) + { + GUILayout.Label("Press a key… (Esc to cancel)"); + Event e = Event.current; + if (e.type == EventType.KeyDown) + { + if (e.keyCode != KeyCode.Escape && e.keyCode != KeyCode.None) + { + s.hotkeyKeyCode = (int)e.keyCode; + s.hotkeyModifiers = (e.shift ? 1 : 0) | (e.control ? 2 : 0) | (e.alt ? 4 : 0); + changed = true; + } + Capturing = false; + e.Use(); + } + } + else + { + if (GUILayout.Button($"{HotkeyLabel(s)} (click to change)", GUILayout.Width(240f))) + Capturing = true; + if (s.hotkeyKeyCode != 0 && GUILayout.Button("Clear", GUILayout.Width(60f))) + { + s.hotkeyKeyCode = 0; + s.hotkeyModifiers = 0; + changed = true; + } + } + GUILayout.EndHorizontal(); + + GUILayout.Space(10f); + GUILayout.Label("Display"); + GUILayout.Space(4f); + bool vis = GUILayout.Toggle(s.visible, " Show overlay when the module is enabled"); + if (vis != s.visible) { s.visible = vis; changed = true; } + + bool match = GUILayout.Toggle(s.matchViewOrder, " Match view order (left card is the leftmost car on screen)"); + if (match != s.matchViewOrder) { s.matchViewOrder = match; changed = true; } + + bool freeze = GUILayout.Toggle(s.freezeOrderAtDistance, " Freeze to lead-left when far (same as the on-screen consist, no orbit flip)"); + if (freeze != s.freezeOrderAtDistance) { s.freezeOrderAtDistance = freeze; changed = true; } + if (s.freezeOrderAtDistance) + { + GUILayout.BeginHorizontal(); + GUILayout.Label($" Distance: {s.viewOrderFreezeDistance:F0}", GUILayout.Width(130f)); + float dist = GUILayout.HorizontalSlider(s.viewOrderFreezeDistance, 200f, 5000f, GUILayout.Width(220f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(dist - s.viewOrderFreezeDistance) > 1f) { s.viewOrderFreezeDistance = dist; changed = true; } + } + + bool mapRot = GUILayout.Toggle(s.matchMapRotation, " Sync to map rotation (when the map overlay or popout is open)"); + if (mapRot != s.matchMapRotation) { s.matchMapRotation = mapRot; changed = true; } + + bool cuts = GUILayout.Toggle(s.showWaypointCuts, " Show waypoint cuts in the fan (needs WaypointQueue)"); + if (cuts != s.showWaypointCuts) { s.showWaypointCuts = cuts; changed = true; } + + GUILayout.BeginHorizontal(); + GUILayout.Label("Color band:", GUILayout.Width(110f)); + int color = GUILayout.Toolbar(s.colorMode, ColorNames, GUILayout.Width(420f)); + GUILayout.EndHorizontal(); + if (color != s.colorMode) { s.colorMode = color; changed = true; } + + GUILayout.BeginHorizontal(); + GUILayout.Label("Click a card:", GUILayout.Width(110f)); + int click = GUILayout.Toolbar(s.clickAction, ClickNames, GUILayout.Width(240f)); + GUILayout.EndHorizontal(); + if (click != s.clickAction) { s.clickAction = click; changed = true; } + + GUILayout.BeginHorizontal(); + GUILayout.Label("Locate button:", GUILayout.Width(110f)); + int loc = GUILayout.Toolbar(s.locateMode, LocateNames, GUILayout.Width(280f)); + GUILayout.EndHorizontal(); + if (loc != s.locateMode) { s.locateMode = loc; changed = true; } + + GUILayout.Space(10f); + GUILayout.Label("Fan"); + GUILayout.Space(4f); + bool hover = GUILayout.Toggle(s.revealOnHover, " Lift card on hover"); + if (hover != s.revealOnHover) { s.revealOnHover = hover; changed = true; } + bool clk = GUILayout.Toggle(s.revealOnClick, " Keep card lifted after click"); + if (clk != s.revealOnClick) { s.revealOnClick = clk; changed = true; } + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Overlap: {s.overlap * 100f:F0}%", GUILayout.Width(110f)); + float ov = GUILayout.HorizontalSlider(s.overlap, 0f, 0.9f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(ov - s.overlap) > 0.01f) { s.overlap = ov; changed = true; } + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Lifted cover: {s.hoverCover * 100f:F0}%", GUILayout.Width(110f)); + float hc = GUILayout.HorizontalSlider(s.hoverCover, 0f, 0.4f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + GUILayout.Label(" How much neighbors still sit on a lifted card. 12% is about 1/8 width.", GUI.skin.label); + if (Mathf.Abs(hc - s.hoverCover) > 0.005f) { s.hoverCover = hc; changed = true; } + + GUILayout.EndVertical(); + + if (changed) + CarCardsModule.Persist(); + } + + internal static string HotkeyLabel(CarCardsSettings s) + { + if (s.hotkeyKeyCode == 0) + return "Not set"; + string prefix = ""; + if ((s.hotkeyModifiers & 2) != 0) prefix += "Ctrl+"; + if ((s.hotkeyModifiers & 1) != 0) prefix += "Shift+"; + if ((s.hotkeyModifiers & 4) != 0) prefix += "Alt+"; + return prefix + (KeyCode)s.hotkeyKeyCode; + } +} diff --git a/src/Modules/CarCards/CardClick.cs b/src/Modules/CarCards/CardClick.cs new file mode 100644 index 0000000..09bf2c2 --- /dev/null +++ b/src/Modules/CarCards/CardClick.cs @@ -0,0 +1,78 @@ +using Model; +using S3.Modules.Popout; +using Track; +using UI.CarInspector; +using UnityEngine; + +namespace S3.Modules.CarCards; + +static class CardClick +{ + public static void Apply(Car car) + { + if (car == null) return; + try + { + switch ((CardClickAction)CarCardsModule.Settings.clickAction) + { + case CardClickAction.Follow: + CameraSelector.shared?.FollowCar(car); + break; + case CardClickAction.Inspector: + CarInspector.Show(car); + break; + default: + if (TrainController.Shared != null) + TrainController.Shared.SelectedCar = car; + break; + } + } + catch { } + } + + public static void Locate(Car car) + { + if (car == null) return; + var mode = (CardLocateMode)CarCardsModule.Settings.locateMode; + try + { + if (mode == CardLocateMode.Follow || mode == CardLocateMode.Both) + CameraSelector.shared?.FollowCar(car); + if (mode == CardLocateMode.Map || mode == CardLocateMode.Both) + MapEnhancerBridge.JumpToCar(car); + } + catch { } + } + + public static void JumpToWaypoint(WaypointDivider d) + { + if (d == null || !d.HasPosition) return; + try + { + CameraSelector.shared?.JumpToPoint( + d.Position, + d.Rotation, + CameraSelector.CameraIdentifier.Strategy); + } + catch { } + } + + public static Car.LogicalEnd ScreenLeftEnd(Car car) + { + try + { + Camera? cam = CardViewOrder.ActiveCamera(); + var graph = Graph.Shared; + if (cam == null || graph == null) return Car.LogicalEnd.A; + Vector3 a = graph.GetPosition(car.WheelBoundsA); + Vector3 b = graph.GetPosition(car.WheelBoundsB); + return cam.WorldToScreenPoint(a).x <= cam.WorldToScreenPoint(b).x + ? Car.LogicalEnd.A + : Car.LogicalEnd.B; + } + catch { return Car.LogicalEnd.A; } + } + + public static Car.LogicalEnd Other(Car.LogicalEnd end) => + end == Car.LogicalEnd.A ? Car.LogicalEnd.B : Car.LogicalEnd.A; +} diff --git a/src/Modules/CarCards/CardNotes.cs b/src/Modules/CarCards/CardNotes.cs new file mode 100644 index 0000000..84f1c89 --- /dev/null +++ b/src/Modules/CarCards/CardNotes.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Model; +using S3.Core; +using UnityEngine; + +namespace S3.Modules.CarCards; + +/// +/// Per-car notes. Prefers a KV key on the car so notes ride the save; never +/// references KeyValue.Runtime at compile time (reflection + Harmony Traverse). +/// Sidecar JSON is the fallback if KV get/set fails. +/// +static class CardNotes +{ + public const string KvKey = "s3.card.notes"; + const string SidecarFile = "S3.carcards.notes.json"; + + static readonly Dictionary _sidecar = new(); + static MethodInfo? _getItem; + static MethodInfo? _setItem; + static MethodInfo? _valueString; + static PropertyInfo? _stringValue; + static PropertyInfo? _valueType; + static bool _resolved; + static bool _kvBroken; + static float _flushAt; + static bool _dirty; + + [Serializable] + class FileShape + { + public string[] ids = Array.Empty(); + public string[] texts = Array.Empty(); + } + + public static void Load() + { + _sidecar.Clear(); + var file = SettingsStore.Load(SidecarFile); + if (file.ids == null || file.texts == null) return; + int n = Math.Min(file.ids.Length, file.texts.Length); + for (int i = 0; i < n; i++) + { + if (string.IsNullOrEmpty(file.ids[i])) continue; + _sidecar[file.ids[i]] = file.texts[i] ?? ""; + } + } + + public static void Flush() + { + if (!_dirty) return; + var ids = new string[_sidecar.Count]; + var texts = new string[_sidecar.Count]; + int i = 0; + foreach (var kv in _sidecar) + { + ids[i] = kv.Key; + texts[i] = kv.Value ?? ""; + i++; + } + SettingsStore.Save(SidecarFile, new FileShape { ids = ids, texts = texts }); + _dirty = false; + _flushAt = 0f; + } + + public static void Tick() + { + if (_dirty && _flushAt > 0f && Time.unscaledTime >= _flushAt) + Flush(); + } + + public static string Get(Car car) + { + if (car == null) return ""; + if (!_kvBroken) + { + try + { + string? fromKv = ReadKv(car); + if (!string.IsNullOrEmpty(fromKv)) + return fromKv; + } + catch (Exception e) + { + _kvBroken = true; + Log.Warn($"[carcards] notes KV read failed, using sidecar: {e.Message}"); + } + } + return _sidecar.TryGetValue(car.id, out string text) ? text : ""; + } + + public static void Set(Car car, string text) + { + if (car == null) return; + text ??= ""; + _sidecar[car.id] = text; + _dirty = true; + _flushAt = Time.unscaledTime + 0.6f; + if (_kvBroken) return; + try + { + WriteKv(car, text); + } + catch (Exception e) + { + _kvBroken = true; + Log.Warn($"[carcards] notes KV write failed, sidecar only: {e.Message}"); + } + } + + static string? ReadKv(Car car) + { + object? kvo = AccessTools.Field(typeof(Car), "KeyValueObject")?.GetValue(car); + if (kvo == null) return null; + Resolve(kvo); + if (_getItem == null) return null; + object? val = _getItem.Invoke(kvo, new object[] { KvKey }); + if (val == null) return null; + object? kind = _valueType?.GetValue(val); + if (kind != null && string.Equals(kind.ToString(), "Null", StringComparison.Ordinal)) + return null; + return _stringValue?.GetValue(val) as string; + } + + static void WriteKv(Car car, string text) + { + object? kvo = AccessTools.Field(typeof(Car), "KeyValueObject")?.GetValue(car); + if (kvo == null) return; + Resolve(kvo); + if (_setItem == null || _valueString == null) return; + object val = _valueString.Invoke(null, new object[] { text })!; + _setItem.Invoke(kvo, new object[] { KvKey, val }); + } + + static void Resolve(object kvo) + { + if (_resolved) return; + Type t = kvo.GetType(); + Type? valueType = t.Assembly.GetType("KeyValue.Runtime.Value"); + _getItem = t.GetMethod("get_Item", new[] { typeof(string) }); + _setItem = valueType != null + ? t.GetMethod("set_Item", new[] { typeof(string), valueType }) + : null; + if (valueType != null) + { + _valueString = valueType.GetMethod("String", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(string) }, null); + _stringValue = valueType.GetProperty("StringValue"); + _valueType = valueType.GetProperty("Type"); + } + _resolved = true; + } +} diff --git a/src/Modules/CarCards/CardUi.cs b/src/Modules/CarCards/CardUi.cs new file mode 100644 index 0000000..3bc4aa6 --- /dev/null +++ b/src/Modules/CarCards/CardUi.cs @@ -0,0 +1,520 @@ +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace S3.Modules.CarCards; + +static class CardUi +{ + public const float Round = 8f; + + static Sprite? _white; + static Sprite? _round; + static Sprite? _pin; + static Sprite? _dots; + static Sprite? _x; + static Sprite? _couple; + static Sprite? _brake; + static Sprite? _locate; + static Sprite? _cut; + static Sprite? _drop; + static Sprite? _pickup; + static TMP_FontAsset? _tmp; + static Font? _uiFont; + + public static Sprite White() + { + if (_white != null) return _white; + var tex = Texture2D.whiteTexture; + _white = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 4f); + _white.name = "S3CardWhite"; + return _white; + } + + public static Sprite RoundSprite() + { + if (_round != null) return _round; + // 8 UI-unit corners. uGUI slice size is border * canvasRefPPU / spritePPU + // (canvas reference PPU is 100), so sprite PPU must be aa * 100 to keep + // the radius at 8 while the texture is supersampled. + const int radiusUi = 8; + const int aa = 8; + const int r = radiusUi * aa; + const int s = r * 2 + 32; + const float ppu = aa * 100f; + var tex = new Texture2D(s, s, TextureFormat.RGBA32, false) + { + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave, + }; + var px = new Color32[s * s]; + for (int y = 0; y < s; y++) + { + for (int x = 0; x < s; x++) + { + float a = CoverRound(x + 0.5f, y + 0.5f, s, r); + byte b = (byte)Mathf.Clamp(Mathf.RoundToInt(a * 255f), 0, 255); + px[y * s + x] = new Color32(255, 255, 255, b); + } + } + tex.SetPixels32(px); + tex.Apply(false, true); + _round = Sprite.Create( + tex, new Rect(0f, 0f, s, s), new Vector2(0.5f, 0.5f), + ppu, 0, SpriteMeshType.FullRect, new Vector4(r, r, r, r)); + _round.name = "S3Round8"; + return _round; + } + + static float CoverRound(float x, float y, int s, int r) + { + float dx = x < r ? r - x : (x > s - r ? x - (s - r) : 0f); + float dy = y < r ? r - y : (y > s - r ? y - (s - r) : 0f); + if (dx == 0f || dy == 0f) return 1f; + float d = Mathf.Sqrt(dx * dx + dy * dy); + return Mathf.Clamp01(r + 0.5f - d); + } + + public static Sprite PinSprite() + { + if (_pin != null) return _pin; + _pin = MakeGlyph(32, (tex, s) => + { + FillCircle(tex, s, 16, 12, 7, Color.white); + FillCircle(tex, s, 16, 12, 3, new Color(0, 0, 0, 0)); + FillTri(tex, s, 16, 16, 10, 28, 22, 16, Color.white); + }); + _pin.name = "S3Pin"; + return _pin; + } + + public static Sprite DotsSprite() + { + if (_dots != null) return _dots; + _dots = MakeGlyph(32, (tex, s) => + { + FillCircle(tex, s, 16, 8, 3, Color.white); + FillCircle(tex, s, 16, 16, 3, Color.white); + FillCircle(tex, s, 16, 24, 3, Color.white); + }); + _dots.name = "S3Dots"; + return _dots; + } + + public static Sprite XSprite() + { + if (_x != null) return _x; + _x = MakeGlyph(32, (tex, s) => + { + StrokeLine(tex, s, 8, 8, 24, 24, 2.2f, Color.white); + StrokeLine(tex, s, 24, 8, 8, 24, 2.2f, Color.white); + }); + _x.name = "S3X"; + return _x; + } + + public static Sprite CoupleSprite() + { + if (_couple != null) return _couple; + _couple = MakeGlyph(32, (tex, s) => + { + StrokeLine(tex, s, 6, 10, 14, 10, 2.2f, Color.white); + StrokeLine(tex, s, 14, 10, 14, 22, 2.2f, Color.white); + StrokeLine(tex, s, 14, 22, 6, 22, 2.2f, Color.white); + StrokeLine(tex, s, 26, 10, 18, 10, 2.2f, Color.white); + StrokeLine(tex, s, 18, 10, 18, 22, 2.2f, Color.white); + StrokeLine(tex, s, 18, 22, 26, 22, 2.2f, Color.white); + }); + _couple.name = "S3Couple"; + return _couple; + } + + public static Sprite BrakeSprite() + { + if (_brake != null) return _brake; + _brake = MakeGlyph(32, (tex, s) => + { + FillCircle(tex, s, 16, 14, 9, Color.white); + FillCircle(tex, s, 16, 14, 5, new Color(0, 0, 0, 0)); + StrokeLine(tex, s, 16, 14, 16, 28, 2.2f, Color.white); + }); + _brake.name = "S3Brake"; + return _brake; + } + + public static Sprite LocateSprite() + { + if (_locate != null) return _locate; + _locate = MakeGlyph(32, (tex, s) => + { + FillCircle(tex, s, 16, 16, 8, Color.white); + FillCircle(tex, s, 16, 16, 4, new Color(0, 0, 0, 0)); + StrokeLine(tex, s, 16, 4, 16, 10, 2f, Color.white); + StrokeLine(tex, s, 16, 22, 16, 28, 2f, Color.white); + StrokeLine(tex, s, 4, 16, 10, 16, 2f, Color.white); + StrokeLine(tex, s, 22, 16, 28, 16, 2f, Color.white); + }); + _locate.name = "S3Locate"; + return _locate; + } + + public static Sprite CutSprite() + { + if (_cut != null) return _cut; + _cut = MakeGlyph(64, (px, s) => + { + StrokeCar(px, s, 4f, 18f, 22f, 28f, 2.6f); + StrokeCar(px, s, 38f, 18f, 22f, 28f, 2.6f); + }); + _cut.name = "S3Cut"; + return _cut; + } + + public static Sprite DropSprite() + { + if (_drop != null) return _drop; + _drop = MakeGlyph(64, (px, s) => + { + StrokeCar(px, s, 16f, 8f, 32f, 24f, 2.6f); + StrokeLine(px, s, 32f, 36f, 32f, 50f, 2.8f, Color.white); + FillTri(px, s, 32f, 58f, 21f, 46f, 43f, 46f, Color.white); + }); + _drop.name = "S3Drop"; + return _drop; + } + + public static Sprite PickupSprite() + { + if (_pickup != null) return _pickup; + _pickup = MakeGlyph(64, (px, s) => + { + StrokeCar(px, s, 16f, 32f, 32f, 24f, 2.6f); + StrokeLine(px, s, 32f, 28f, 32f, 14f, 2.8f, Color.white); + FillTri(px, s, 32f, 6f, 21f, 18f, 43f, 18f, Color.white); + }); + _pickup.name = "S3Pickup"; + return _pickup; + } + + static void StrokeCar(Color[] px, int s, float x, float y, float w, float h, float thick) + { + StrokeLine(px, s, x, y, x + w, y, thick, Color.white); + StrokeLine(px, s, x + w, y, x + w, y + h, thick, Color.white); + StrokeLine(px, s, x + w, y + h, x, y + h, thick, Color.white); + StrokeLine(px, s, x, y + h, x, y, thick, Color.white); + float wy = y + 4f; + FillCircle(px, s, x + w * 0.28f, wy, 3.2f, Color.white); + FillCircle(px, s, x + w * 0.72f, wy, 3.2f, Color.white); + } + + public static void TintButton(Button btn, bool on, Color onColor) + { + if (btn == null) return; + var img = btn.targetGraphic as Image; + if (img == null) return; + img.color = on ? onColor : new Color(0.22f, 0.22f, 0.24f, 1f); + } + + static Sprite MakeGlyph(int s, System.Action draw) + { + var tex = new Texture2D(s, s, TextureFormat.RGBA32, false) + { + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave, + }; + var px = new Color[s * s]; + draw(px, s); + tex.SetPixels(px); + tex.Apply(false, true); + return Sprite.Create(tex, new Rect(0f, 0f, s, s), new Vector2(0.5f, 0.5f), 100f); + } + + static void FillCircle(Color[] px, int s, float cx, float cy, float r, Color c) + { + for (int y = 0; y < s; y++) + for (int x = 0; x < s; x++) + { + float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy)); + float a = Mathf.Clamp01(r + 0.5f - d); + if (a <= 0f) continue; + int i = y * s + x; + if (c.a <= 0.01f) px[i] = Color.clear; + else + { + Color dcol = c; + dcol.a *= a; + px[i] = Blend(px[i], dcol); + } + } + } + + static void FillTri(Color[] px, int s, float x1, float y1, float x2, float y2, float x3, float y3, Color c) + { + for (int y = 0; y < s; y++) + for (int x = 0; x < s; x++) + { + float pxp = x + 0.5f, pyp = y + 0.5f; + if (!InTri(pxp, pyp, x1, y1, x2, y2, x3, y3)) continue; + px[y * s + x] = Blend(px[y * s + x], c); + } + } + + static bool InTri(float px, float py, float x1, float y1, float x2, float y2, float x3, float y3) + { + float d1 = Sign(px, py, x1, y1, x2, y2); + float d2 = Sign(px, py, x2, y2, x3, y3); + float d3 = Sign(px, py, x3, y3, x1, y1); + bool hasNeg = d1 < 0 || d2 < 0 || d3 < 0; + bool hasPos = d1 > 0 || d2 > 0 || d3 > 0; + return !(hasNeg && hasPos); + } + + static float Sign(float px, float py, float x1, float y1, float x2, float y2) => + (px - x2) * (y1 - y2) - (x1 - x2) * (py - y2); + + static void StrokeLine(Color[] px, int s, float x0, float y0, float x1, float y1, float thick, Color c) + { + for (int y = 0; y < s; y++) + for (int x = 0; x < s; x++) + { + float d = DistToSeg(x + 0.5f, y + 0.5f, x0, y0, x1, y1); + float a = Mathf.Clamp01(thick + 0.5f - d); + if (a <= 0f) continue; + Color dcol = c; + dcol.a *= a; + px[y * s + x] = Blend(px[y * s + x], dcol); + } + } + + static float DistToSeg(float px, float py, float x0, float y0, float x1, float y1) + { + float dx = x1 - x0, dy = y1 - y0; + float l2 = dx * dx + dy * dy; + if (l2 < 0.0001f) return Vector2.Distance(new Vector2(px, py), new Vector2(x0, y0)); + float t = Mathf.Clamp01(((px - x0) * dx + (py - y0) * dy) / l2); + return Vector2.Distance(new Vector2(px, py), new Vector2(x0 + t * dx, y0 + t * dy)); + } + + static Color Blend(Color under, Color over) + { + float a = over.a + under.a * (1f - over.a); + if (a < 0.0001f) return Color.clear; + return new Color( + (over.r * over.a + under.r * under.a * (1f - over.a)) / a, + (over.g * over.a + under.g * under.a * (1f - over.a)) / a, + (over.b * over.a + under.b * under.a * (1f - over.a)) / a, + a); + } + + public static TMP_FontAsset? TmpFont() + { + if (_tmp != null) return _tmp; + try { _tmp = TMP_Settings.defaultFontAsset; } catch { } + if (_tmp == null) + { + var all = Resources.FindObjectsOfTypeAll(); + if (all != null && all.Length > 0) _tmp = all[0]; + } + return _tmp; + } + + public static Font UiFont() + { + if (_uiFont != null) return _uiFont; + _uiFont = Resources.GetBuiltinResource("Arial.ttf"); + return _uiFont; + } + + public static RectTransform Rt(GameObject go) => (RectTransform)go.transform; + + public static Image MakeImage(RectTransform parent, string name, Color color, bool raycast, bool round = true) + { + var go = new GameObject(name, typeof(RectTransform), typeof(Image)); + var rt = Rt(go); + rt.SetParent(parent, false); + rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f); + rt.pivot = new Vector2(0.5f, 0.5f); + var img = go.GetComponent(); + if (round) + { + img.sprite = RoundSprite(); + img.type = Image.Type.Sliced; + img.pixelsPerUnitMultiplier = 1f; + } + else + { + img.sprite = White(); + img.type = Image.Type.Simple; + } + img.color = color; + img.raycastTarget = raycast; + return img; + } + + public static Image MakeIcon(RectTransform parent, string name, Sprite sprite, Color color, bool raycast) + { + var go = new GameObject(name, typeof(RectTransform), typeof(Image)); + var rt = Rt(go); + rt.SetParent(parent, false); + var img = go.GetComponent(); + img.sprite = sprite; + img.type = Image.Type.Simple; + img.preserveAspect = true; + img.color = color; + img.raycastTarget = raycast; + return img; + } + + public static TextMeshProUGUI Tmp( + RectTransform parent, string name, float size, Color color, + FontStyles style = FontStyles.Normal, TextAlignmentOptions align = TextAlignmentOptions.TopLeft) + { + var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI)); + var rt = Rt(go); + rt.SetParent(parent, false); + var tmp = go.GetComponent(); + tmp.fontSize = size; + tmp.color = color; + tmp.fontStyle = style; + tmp.alignment = align; + tmp.raycastTarget = false; + tmp.textWrappingMode = TextWrappingModes.Normal; + tmp.overflowMode = TextOverflowModes.Truncate; + var font = TmpFont(); + if (font != null) tmp.font = font; + return tmp; + } + + public static Button Button(RectTransform parent, string name, string label, Vector2 size) + { + var img = MakeImage(parent, name, new Color(0.22f, 0.22f, 0.24f, 1f), raycast: true); + img.rectTransform.sizeDelta = size; + var btn = img.gameObject.AddComponent