IndustryTags: in-world business, track, and yard callouts

Live industry/track/yard labels with cached and staggered updates so
the old 10-12 ms/frame cost near busy areas is gone. /s3ind dump
writes a session dump.
This commit is contained in:
Seton Carmichael 2026-09-11 15:19:24 -04:00
parent cc552a0246
commit 48c9b97029
9 changed files with 4723 additions and 0 deletions

View file

@ -24,6 +24,7 @@ I originally planned on releasing individual mods, but considering my workflow o
| 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. |
| Industry Tags | In-world business, track, and yard callouts with live industry data. |
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.
@ -223,6 +224,14 @@ Optional WaypointQueue cut dividers when that mod is installed.
---
## Industry Tags
Floating in-world callouts for businesses, per-track badges, and yard codes,
read from live industry data so mod maps are included. Double-click centers
the strategy camera. Hover highlights the related tracks. Console: `/s3ind dump`.
---
## Migrating from the standalone mods
S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and

View file

@ -38,6 +38,7 @@ public static class Main
_registry.Register(new Modules.Popout.PopoutModule());
_registry.Register(new Modules.QuickActions.QuickActionsModule());
_registry.Register(new Modules.CarCards.CarCardsModule());
_registry.Register(new Modules.IndustryTags.IndustryTagsModule());
_registry.EnableConfigured();
ModConflicts.CheckAtLoad();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,816 @@
using System.Collections.Generic;
using HarmonyLib;
using Helpers;
using Model;
using Model.Ops;
using Track;
using UI;
using UI.Menu;
using UI.Tags;
using UnityEngine;
namespace S3.Modules.IndustryTags;
sealed class IndustryTagOverlay : MonoBehaviour
{
public static bool PointerOver { get; internal set; }
readonly List<BusinessCluster> _clusters = new();
readonly List<TrackSpot> _tracks = new();
readonly Dictionary<string, IndustryTagView> _views = new();
readonly Dictionary<string, IndustryTagView> _trackViews = new();
readonly List<TrackSpot> _slideSpots = new();
readonly List<IndustryTagView> _slideViews = new();
readonly List<Rect> _slideRects = new();
readonly List<BusinessCluster> _nextClusters = new();
readonly List<TrackSpot> _nextTracks = new();
readonly HashSet<string> _seen = new();
readonly HashSet<string> _seenTracks = new();
readonly List<string> _drop = new();
float[] _slideDeltaT = System.Array.Empty<float>();
float[] _slideDeltaLift = System.Array.Empty<float>();
bool[] _slideColliding = System.Array.Empty<bool>();
bool[] _slideNear = System.Array.Empty<bool>();
float _rebuildAt;
float _trackRebuildAt;
float _refreshAt;
float _slideAt;
float _pointerAt;
float _clickAt;
bool _clustersInitialized;
bool _tracksInitialized;
bool _collisionDirty = true;
int _lastSlideCount = -1;
int _lastScreenWidth;
int _lastScreenHeight;
Vector3 _lastSlideCameraPos;
Quaternion _lastSlideCameraRot;
bool _haveSlideCameraPose;
object? _playSession;
bool _haveCatalogSettings;
bool _catalogShowTracks;
bool _catalogShowYards;
float _catalogMergeDistance;
IndustryTagView? _clickView;
string _highlightKey = "";
string? _highlightToken;
static PersistentLoader? _loader;
static GameObject? _loadingScreen;
void Update()
{
if (!InPlay())
{
HideImmediate();
return;
}
object? playSession = TrainController.Shared;
if (!ReferenceEquals(_playSession, playSession))
{
ResetForPlaySession(playSession);
}
Camera? cam = PlayCamera();
bool want = WantVisible();
if (!want)
{
FadeAll(cam);
TickPointer(null);
return;
}
float now = Time.unscaledTime;
var s = IndustryTagsModule.Settings;
if (!_haveCatalogSettings
|| _catalogShowTracks != s.showTrackBadges
|| _catalogShowYards != s.showYardTags)
{
_haveCatalogSettings = true;
_catalogShowTracks = s.showTrackBadges;
_catalogShowYards = s.showYardTags;
_tracksInitialized = false;
_trackRebuildAt = 0f;
}
if (!Nearly(_catalogMergeDistance, s.mergeDistance))
{
_catalogMergeDistance = s.mergeDistance;
_clustersInitialized = false;
_rebuildAt = 0f;
}
bool rebuiltIndustries = false;
bool rebuiltTracks = false;
if (!_clustersInitialized || now >= _rebuildAt)
{
_rebuildAt = now + 30f;
try
{
IndustryCatalog.Rebuild(_nextClusters, s.mergeDistance);
_clusters.Clear();
_clusters.AddRange(_nextClusters);
_clustersInitialized = true;
rebuiltIndustries = true;
if (!_tracksInitialized)
_trackRebuildAt = now + 0.05f;
}
catch (System.Exception ex) { S3.Core.Log.Error($"[industrytags] rebuild: {ex.Message}"); }
}
if (!rebuiltIndustries && (!_tracksInitialized || now >= _trackRebuildAt))
{
_trackRebuildAt = now + 30f;
try
{
_nextTracks.Clear();
if (s.showTrackBadges)
IndustryCatalog.RebuildTracks(_nextTracks);
if (s.showYardTags)
IndustryCatalog.RebuildYards(_nextTracks);
_tracks.Clear();
_tracks.AddRange(_nextTracks);
_tracksInitialized = true;
rebuiltTracks = true;
}
catch (System.Exception ex) { S3.Core.Log.Error($"[industrytags] tracks: {ex.Message}"); }
}
int detailBudget = 0;
if (now >= _refreshAt)
{
_refreshAt = now + 0.04f;
detailBudget = 1;
}
int createBudget = detailBudget;
Vector3 camGame = Vector3.zero;
bool haveCam = TryCameraGame(cam, out camGame);
float maxDist = Mathf.Max(20f, s.maxDrawDistance);
float trackDist = Mathf.Max(20f, s.trackMaxDrawDistance);
bool hideIndustryNearTracks = s.showTrackBadges && s.hideIndustryWhenTracksVisible;
_seen.Clear();
foreach (BusinessCluster cluster in _clusters)
{
if (string.IsNullOrEmpty(cluster.Key)) continue;
_seen.Add(cluster.Key);
bool inRange = true;
float distMul = 1f;
if (haveCam)
{
float dist = Vector3.Distance(camGame, cluster.GameCentroid);
inRange = dist <= maxDist;
distMul = DistMul(dist, maxDist);
if (hideIndustryNearTracks && dist <= trackDist)
inRange = false;
}
bool created = false;
if (!_views.TryGetValue(cluster.Key, out IndustryTagView? view) || view == null)
{
if (!inRange || createBudget <= 0) continue;
createBudget--;
var go = new GameObject("IndustryTag " + cluster.Name);
go.transform.SetParent(transform, false);
view = go.AddComponent<IndustryTagView>();
_views[cluster.Key] = view;
created = true;
}
if (!inRange)
{
view.SetDistanceMul(distMul);
view.SetWanted(false);
continue;
}
if (created || rebuiltIndustries)
FillSpanIds(view, cluster.Components);
if (created || (detailBudget > 0 && now >= view.DetailRefreshAt))
{
Color color = IndustryCatalog.TagColor(cluster.Area);
if (view.Bind(cluster.Name, IndustryCatalog.BuildDetails(cluster, s), color))
_collisionDirty = true;
view.DetailRefreshAt = NextDetailRefresh(now, cluster.Key);
detailBudget--;
}
Vector3 world = cluster.GameCentroid.GameToWorld();
view.GamePos = cluster.GameCentroid;
if (view.SetWorld(world))
_collisionDirty = true;
view.SetDistanceMul(distMul);
view.SetWanted(true);
}
_seenTracks.Clear();
_slideSpots.Clear();
_slideViews.Clear();
bool drawTrackLayer = s.showTrackBadges || s.showYardTags;
if (drawTrackLayer)
{
foreach (TrackSpot spot in _tracks)
{
if (string.IsNullOrEmpty(spot.Key)) continue;
bool wantThis = spot.Yard ? s.showYardTags : s.showTrackBadges;
if (!wantThis) continue;
_seenTracks.Add(spot.Key);
bool inRange = true;
float distMul = 1f;
if (haveCam)
{
float dist = Vector3.Distance(camGame, spot.GameCentroid);
inRange = dist <= trackDist;
distMul = DistMul(dist, trackDist);
}
bool created = false;
if (!_trackViews.TryGetValue(spot.Key, out IndustryTagView? view) || view == null)
{
if (!inRange || createBudget <= 0) continue;
createBudget--;
var go = new GameObject((spot.Yard ? "YardTag " : "TrackBadge ") + spot.Label);
go.transform.SetParent(transform, false);
view = go.AddComponent<IndustryTagView>();
view.TrackBadge = true;
view.Yard = spot.Yard;
_trackViews[spot.Key] = view;
created = true;
}
view.Yard = spot.Yard;
if (!inRange)
{
view.TrackT = 0.5f;
view.HeightLift = 0f;
view.SetDistanceMul(distMul);
view.SetWanted(false);
continue;
}
if (created || rebuiltTracks)
{
view.SpanIds.Clear();
view.SpanIds.AddRange(spot.SpanIds);
view.HighlightKey = string.Join("|", view.SpanIds);
}
if (created || (detailBudget > 0 && now >= view.DetailRefreshAt))
{
Color color = IndustryCatalog.TagColor(spot.Area);
if (view.Bind(IndustryCatalog.TrackTitle(spot), IndustryCatalog.BuildTrackDetails(spot, s), color, trackBadge: true))
_collisionDirty = true;
view.DetailRefreshAt = NextDetailRefresh(now, spot.Key);
detailBudget--;
}
Vector3 game = spot.GameCentroid;
if (spot.PathLength >= 8f)
{
view.TrackT = IndustryCatalog.ClampPathT(spot, view.TrackT);
game = IndustryCatalog.PointOnPath(spot, view.TrackT);
}
Vector3 world = game.GameToWorld();
view.GamePos = game;
if (view.SetWorld(world))
_collisionDirty = true;
view.SetDistanceMul(distMul);
view.SetWanted(true);
_slideSpots.Add(spot);
_slideViews.Add(view);
}
}
SlideTrackBadges(cam, now);
TickAppearances(cam);
TickPointerBounded(cam, now);
if (rebuiltIndustries && _views.Count > _seen.Count)
{
_drop.Clear();
foreach (var kv in _views)
{
if (_seen.Contains(kv.Key)) continue;
_drop.Add(kv.Key);
if (kv.Value != null) Destroy(kv.Value.gameObject);
}
foreach (string key in _drop)
_views.Remove(key);
}
if ((rebuiltTracks && _trackViews.Count > _seenTracks.Count) || !drawTrackLayer)
{
_drop.Clear();
foreach (var kv in _trackViews)
{
if (drawTrackLayer && _seenTracks.Contains(kv.Key)) continue;
_drop.Add(kv.Key);
if (kv.Value != null) Destroy(kv.Value.gameObject);
}
foreach (string key in _drop)
_trackViews.Remove(key);
}
}
static float NextDetailRefresh(float now, string key)
{
unchecked
{
int hash = 17;
for (int i = 0; i < key.Length; i++)
hash = hash * 31 + key[i];
float phase = (hash & 255) / 255f;
return now + 0.85f + phase * 0.25f;
}
}
void TickAppearances(Camera? cam)
{
int index = 0;
int frame = Time.frameCount;
foreach (var kv in _views)
{
IndustryTagView? view = kv.Value;
if (view == null || !view.gameObject.activeSelf) continue;
view.TickAppearance(cam, ((frame + index++) & 3) == 0);
}
foreach (var kv in _trackViews)
{
IndustryTagView? view = kv.Value;
if (view == null || !view.gameObject.activeSelf) continue;
view.TickAppearance(cam, ((frame + index++) & 3) == 0);
}
}
void TickPointerBounded(Camera? cam, float now)
{
bool click = Input.GetMouseButtonDown(0);
if (!click && now < _pointerAt) return;
_pointerAt = now + 0.04f;
TickPointer(cam);
}
void SlideTrackBadges(Camera? cam, float now)
{
int n = _slideSpots.Count;
if (cam == null || n == 0) return;
bool cameraChanged = !_haveSlideCameraPose
|| (cam.transform.position - _lastSlideCameraPos).sqrMagnitude > 0.0025f
|| Quaternion.Angle(cam.transform.rotation, _lastSlideCameraRot) > 0.1f
|| Screen.width != _lastScreenWidth
|| Screen.height != _lastScreenHeight;
bool listChanged = n != _lastSlideCount;
if (now < _slideAt || (!cameraChanged && !listChanged && !_collisionDirty))
return;
_slideAt = now + 0.1f;
_haveSlideCameraPose = true;
_lastSlideCameraPos = cam.transform.position;
_lastSlideCameraRot = cam.transform.rotation;
_lastScreenWidth = Screen.width;
_lastScreenHeight = Screen.height;
_lastSlideCount = n;
_collisionDirty = false;
if (n == 1)
{
EaseHome(_slideSpots[0], _slideViews[0]);
ApplySlidePositions();
return;
}
const float collidePad = 14f;
const float clearPad = 42f;
EnsureSlideCapacity(n);
for (int round = 0; round < 3; round++)
{
if (!MeasureSlideRects(cam)) return;
System.Array.Clear(_slideDeltaT, 0, n);
System.Array.Clear(_slideDeltaLift, 0, n);
System.Array.Clear(_slideColliding, 0, n);
for (int i = 0; i < n; i++)
{
for (int j = i + 1; j < n; j++)
{
if (!RectOverlap(_slideRects[i], _slideRects[j], collidePad, out float ox, out float oy))
continue;
_slideColliding[i] = true;
_slideColliding[j] = true;
PushPair(cam, i, j, Mathf.Min(ox, oy), _slideDeltaT, _slideDeltaLift);
}
}
bool moved = false;
for (int i = 0; i < n; i++)
{
if (!_slideColliding[i]) continue;
float nextT = IndustryCatalog.ClampPathT(_slideSpots[i], _slideViews[i].TrackT + _slideDeltaT[i]);
float nextL = Mathf.Clamp(_slideViews[i].HeightLift + _slideDeltaLift[i], 0f, 220f);
if (Mathf.Abs(nextT - _slideViews[i].TrackT) > 0.00015f
|| Mathf.Abs(nextL - _slideViews[i].HeightLift) > 0.05f)
moved = true;
_slideViews[i].TrackT = nextT;
_slideViews[i].HeightLift = nextL;
}
if (!moved) break;
ApplySlidePositions();
}
if (!MeasureSlideRects(cam)) return;
System.Array.Clear(_slideNear, 0, n);
for (int i = 0; i < n; i++)
{
for (int j = i + 1; j < n; j++)
{
if (!RectOverlap(_slideRects[i], _slideRects[j], clearPad, out _, out _))
continue;
_slideNear[i] = true;
_slideNear[j] = true;
}
}
for (int i = 0; i < n; i++)
{
if (_slideNear[i]) continue;
EaseHome(_slideSpots[i], _slideViews[i]);
}
ApplySlidePositions();
}
void EnsureSlideCapacity(int n)
{
if (_slideDeltaT.Length >= n) return;
int size = Mathf.NextPowerOfTwo(Mathf.Max(4, n));
_slideDeltaT = new float[size];
_slideDeltaLift = new float[size];
_slideColliding = new bool[size];
_slideNear = new bool[size];
}
void PushPair(Camera cam, int i, int j, float pen, float[] dT, float[] dL)
{
TrackSpot spotI = _slideSpots[i];
TrackSpot spotJ = _slideSpots[j];
IndustryTagView viewI = _slideViews[i];
IndustryTagView viewJ = _slideViews[j];
float tI = viewI.TrackT;
float tJ = viewJ.TrackT;
float dist0 = Vector2.Distance(PathScreen(cam, spotI, tI), PathScreen(cam, spotJ, tJ));
float step = Mathf.Clamp(PixelsToPathT(cam, spotI, tI, Mathf.Max(12f, pen * 0.4f)), 0.05f, 0.14f);
int bestSI = 0;
int bestSJ = 0;
float bestDist = dist0;
for (int s = 0; s < 4; s++)
{
int si = s == 0 || s == 2 ? 1 : -1;
int sj = s == 0 || s == 3 ? -1 : 1;
float d = Vector2.Distance(
PathScreen(cam, spotI, tI + si * step),
PathScreen(cam, spotJ, tJ + sj * step));
if (d <= bestDist + 0.75f) continue;
bestDist = d;
bestSI = si;
bestSJ = sj;
}
float gain = bestDist - dist0;
bool useHeight = gain < 16f;
float slideShare = gain < 5f ? 0f : (useHeight ? 0.18f : 1f);
if (slideShare > 0f && (bestSI != 0 || bestSJ != 0))
{
dT[i] += bestSI * PixelsToPathT(cam, spotI, tI, pen * slideShare * 0.32f);
dT[j] += bestSJ * PixelsToPathT(cam, spotJ, tJ, pen * slideShare * 0.32f);
}
if (useHeight)
{
Vector3 worldI = viewI.GamePos.GameToWorld();
Vector3 worldJ = viewJ.GamePos.GameToWorld();
float distI = (cam.transform.position - worldI).sqrMagnitude;
float distJ = (cam.transform.position - worldJ).sqrMagnitude;
float lift = YOffsetForPixels(cam, distI <= distJ ? worldJ : worldI, pen * 0.42f);
if (distI <= distJ)
{
dL[j] += lift;
dL[i] -= lift * 0.4f;
}
else
{
dL[i] += lift;
dL[j] -= lift * 0.4f;
}
}
else
{
dL[i] -= viewI.HeightLift * 0.45f;
dL[j] -= viewJ.HeightLift * 0.45f;
}
}
static Vector2 PathScreen(Camera cam, TrackSpot spot, float t)
{
t = IndustryCatalog.ClampPathT(spot, t);
Vector3 s = cam.WorldToScreenPoint(IndustryCatalog.PointOnPath(spot, t).GameToWorld());
return new Vector2(s.x, s.y);
}
static void EaseHome(TrackSpot spot, IndustryTagView view)
{
float k = 1f - Mathf.Exp(-5.5f * Time.unscaledDeltaTime);
float homeT = IndustryCatalog.ClampPathT(spot, 0.5f);
view.TrackT = Mathf.Lerp(view.TrackT, homeT, k);
if (Mathf.Abs(view.TrackT - homeT) < 0.003f)
view.TrackT = homeT;
view.HeightLift = Mathf.Lerp(view.HeightLift, 0f, k);
if (view.HeightLift < 0.25f)
view.HeightLift = 0f;
}
bool MeasureSlideRects(Camera cam)
{
int n = _slideViews.Count;
_slideRects.Clear();
for (int i = 0; i < n; i++)
{
if (!_slideViews[i].TryScreenRect(cam, out Rect rect))
return false;
_slideRects.Add(rect);
}
return _slideRects.Count == n;
}
static bool RectOverlap(Rect a, Rect b, float pad, out float ox, out float oy)
{
ox = Mathf.Min(a.xMax + pad, b.xMax + pad) - Mathf.Max(a.xMin - pad, b.xMin - pad);
oy = Mathf.Min(a.yMax + pad, b.yMax + pad) - Mathf.Max(a.yMin - pad, b.yMin - pad);
return ox > 0f && oy > 0f;
}
void ApplySlidePositions()
{
for (int i = 0; i < _slideSpots.Count; i++)
{
Vector3 game = IndustryCatalog.PointOnPath(_slideSpots[i], _slideViews[i].TrackT);
_slideViews[i].GamePos = game;
_slideViews[i].SetWorld(game.GameToWorld());
}
}
static float YOffsetForPixels(Camera cam, Vector3 world, float pixels)
{
Vector3 a = cam.WorldToScreenPoint(world);
Vector3 b = cam.WorldToScreenPoint(world + Vector3.up);
if (a.z <= 0f || b.z <= 0f)
return Mathf.Clamp(pixels * 0.2f, 8f, 80f);
float py = Mathf.Abs(b.y - a.y);
if (py < 0.5f) py = 0.5f;
return Mathf.Clamp(pixels / py, 8f, 90f);
}
static float PixelsToPathT(Camera cam, TrackSpot spot, float t, float pixels)
{
float sample = spot.PathLength > 1f ? Mathf.Clamp(4f / spot.PathLength, 0.015f, 0.08f) : 0.04f;
Vector3 a = IndustryCatalog.PointOnPath(spot, Mathf.Clamp01(t - sample)).GameToWorld();
Vector3 b = IndustryCatalog.PointOnPath(spot, Mathf.Clamp01(t + sample)).GameToWorld();
Vector3 sa = cam.WorldToScreenPoint(a);
Vector3 sb = cam.WorldToScreenPoint(b);
float px = Vector2.Distance(new Vector2(sa.x, sa.y), new Vector2(sb.x, sb.y));
float perT = px / (sample * 2f);
if (perT < 8f) perT = 8f;
return Mathf.Clamp(pixels / perT, 0.004f, 0.18f);
}
static void FillSpanIds(IndustryTagView view, List<IndustryComponent> components)
{
view.SpanIds.Clear();
foreach (IndustryComponent ic in components)
{
if (ic?.trackSpans == null) continue;
foreach (TrackSpan span in ic.trackSpans)
{
if (span == null || string.IsNullOrEmpty(span.id)) continue;
if (!view.SpanIds.Contains(span.id))
view.SpanIds.Add(span.id);
}
}
view.HighlightKey = string.Join("|", view.SpanIds);
}
void TickPointer(Camera? cam)
{
PointerOver = false;
if (cam == null)
{
SetHighlight(null);
return;
}
Vector3 mouse = Input.mousePosition;
IndustryTagView? best = null;
float bestD = 99999f;
foreach (IndustryTagView? view in EnumerateViews())
{
if (view == null || !view.gameObject.activeSelf) continue;
if (!view.TryScreenHit(cam, mouse, out float d)) continue;
if (d >= bestD) continue;
bestD = d;
best = view;
}
PointerOver = best != null;
SetHighlight(best);
if (best == null || !Input.GetMouseButtonDown(0)) return;
float now = Time.unscaledTime;
if (_clickView == best && now - _clickAt <= 0.4f)
{
best.JumpCamera();
_clickView = null;
}
else
{
_clickView = best;
_clickAt = now;
}
}
IEnumerable<IndustryTagView> EnumerateViews()
{
foreach (var kv in _views)
if (kv.Value != null) yield return kv.Value;
foreach (var kv in _trackViews)
if (kv.Value != null) yield return kv.Value;
}
void SetHighlight(IndustryTagView? view)
{
string key = "";
if (view != null)
key = view.HighlightKey;
if (key == _highlightKey) return;
ClearHighlight();
_highlightKey = key;
if (key.Length == 0 || view == null) return;
try
{
var ctrl = SegmentIndicatorController.Shared;
if (ctrl != null)
_highlightToken = ctrl.Add(view.SpanIds);
}
catch { }
}
void ClearHighlight()
{
if (!string.IsNullOrEmpty(_highlightToken))
{
try { SegmentIndicatorController.Shared?.Remove(_highlightToken); }
catch { }
}
_highlightToken = null;
_highlightKey = "";
}
void ResetForPlaySession(object? session)
{
ClearHighlight();
_playSession = session;
foreach (var kv in _views)
if (kv.Value != null) Destroy(kv.Value.gameObject);
foreach (var kv in _trackViews)
if (kv.Value != null) Destroy(kv.Value.gameObject);
_views.Clear();
_trackViews.Clear();
_clusters.Clear();
_tracks.Clear();
_clustersInitialized = false;
_tracksInitialized = false;
_haveCatalogSettings = false;
_haveSlideCameraPose = false;
_lastSlideCount = -1;
_collisionDirty = true;
_rebuildAt = 0f;
_trackRebuildAt = 0f;
_refreshAt = 0f;
}
void OnDestroy()
{
ClearHighlight();
foreach (var kv in _views)
if (kv.Value != null) Destroy(kv.Value.gameObject);
_views.Clear();
foreach (var kv in _trackViews)
if (kv.Value != null) Destroy(kv.Value.gameObject);
_trackViews.Clear();
_clusters.Clear();
_tracks.Clear();
}
static bool WantVisible()
{
var s = IndustryTagsModule.Settings;
if (s.alwaysOn) return true;
if (!s.followTabTags) return false;
try
{
TagController? tags = TagController.Shared;
if (tags != null) return tags.TagsVisible;
}
catch { }
return false;
}
void HideImmediate()
{
ClearHighlight();
PointerOver = false;
foreach (var kv in _views)
kv.Value?.HideImmediate();
foreach (var kv in _trackViews)
kv.Value?.HideImmediate();
}
void FadeAll(Camera? cam)
{
foreach (var kv in _views)
kv.Value?.SetWanted(false);
foreach (var kv in _trackViews)
kv.Value?.SetWanted(false);
foreach (var kv in _views)
if (kv.Value != null && kv.Value.gameObject.activeSelf)
kv.Value.TickAppearance(cam, true);
foreach (var kv in _trackViews)
if (kv.Value != null && kv.Value.gameObject.activeSelf)
kv.Value.TickAppearance(cam, true);
}
static Camera? PlayCamera()
{
Camera? cam = Camera.main;
if (cam != null) return cam;
try
{
Camera? found = null;
if (MainCameraHelper.TryGetIfNeeded(ref found) && found != null)
return found;
}
catch { }
return null;
}
static bool TryCameraGame(Camera? cam, out Vector3 gamePos)
{
gamePos = Vector3.zero;
try
{
if (CameraSelector.shared != null)
{
gamePos = CameraSelector.shared.CurrentCameraPosition;
return true;
}
}
catch { }
if (cam == null) return false;
try
{
gamePos = cam.transform.GamePosition();
return true;
}
catch { return false; }
}
static float DistMul(float dist, float maxDist)
{
if (maxDist < 1f) maxDist = 1f;
if (dist >= maxDist) return 0f;
float start = maxDist * 0.85f;
if (dist <= start) return 1f;
return Mathf.InverseLerp(maxDist, start, dist);
}
static bool Nearly(float a, float b) => Mathf.Abs(a - b) <= 0.0001f;
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 = Object.FindObjectOfType<PersistentLoader>();
if (_loader == null) return false;
_loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue<GameObject>();
return _loadingScreen != null && _loadingScreen.activeInHierarchy;
}
catch { return false; }
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,575 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using HarmonyLib;
using Model;
using Model.Ops;
using S3.Core;
using Track;
using UI.Console;
using UnityEngine;
namespace S3.Modules.IndustryTags;
[HarmonyPatch(typeof(ConsoleCommandHandler))]
[HarmonyPatch("_HandleSlashCommand")]
static class IndustryTagsDumpCommandPatch
{
static bool Prefix(string[] comps, ref string __result)
{
if (comps.Length == 0 || !string.Equals(comps[0], "/s3ind", StringComparison.OrdinalIgnoreCase))
return true;
__result = IndustryTagsDumpCommand.Handle(comps);
return false;
}
}
static class IndustryTagsDumpCommand
{
internal static string Handle(string[] comps)
{
if (comps.Length >= 2)
{
string sub = comps[1].ToLowerInvariant();
if (sub == "help") return Usage();
if (sub == "yards") return DumpYards();
if (sub != "dump") return $"Unknown subcommand '{comps[1]}'. {Usage()}";
}
string filter = "";
if (comps.Length >= 3)
filter = string.Join(" ", comps.Skip(2)).Trim();
return Dump(filter);
}
static string Usage() =>
"Usage: /s3ind dump [name] or /s3ind yards (writes Mods/S3/*.txt)";
static string DumpYards()
{
if (TrainController.Shared == null)
return "Not in a game.";
var sb = new StringBuilder();
sb.AppendLine($"=== S3 yard dump {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===");
sb.AppendLine();
TrackSegment[]? all = null;
try { all = UnityEngine.Object.FindObjectsOfType<TrackSegment>(); }
catch (Exception e)
{
return Finish(sb.AppendLine("FindObjectsOfType failed: " + e.Message), "yard-dump.txt");
}
int yardN = 0;
if (all != null)
{
sb.AppendLine("-- Style.Yard segments --");
foreach (TrackSegment seg in all.OrderBy(s => s != null ? s.id : "", StringComparer.OrdinalIgnoreCase))
{
if (seg == null || seg.style != TrackSegment.Style.Yard) continue;
yardN++;
float len = 0f;
try { len = seg.GetLength(); } catch { }
string goName = "";
try { goName = seg.gameObject != null ? seg.gameObject.name : ""; } catch { }
string parent = "";
try
{
parent = seg.transform != null && seg.transform.parent != null
? seg.transform.parent.name
: "";
}
catch { }
sb.AppendLine(
$" {seg.id} name={goName} parent={parent} group={seg.groupId} " +
$"len={len:F1}m/{len * 3.28084f:F0}ft avail={seg.Available} groupOn={seg.GroupEnabled}");
}
sb.AppendLine($"({yardN} yard segments)");
sb.AppendLine();
}
var spots = new List<TrackSpot>();
try { IndustryCatalog.RebuildTracks(spots); }
catch (Exception e) { sb.AppendLine("RebuildTracks: " + e.Message); }
int industrySpots = spots.Count;
try { IndustryCatalog.RebuildYards(spots); }
catch (Exception e) { sb.AppendLine("RebuildYards: " + e.Message); }
int tagged = 0;
sb.AppendLine($"-- clustered yard tags (industry spots excluded={industrySpots}) --");
foreach (TrackSpot spot in spots)
{
if (!spot.Yard) continue;
tagged++;
int cars = Mathf.Max(0, Mathf.FloorToInt(spot.PathLength / 15.24f));
int ft = Mathf.Max(0, Mathf.RoundToInt(spot.PathLength * 3.28084f));
string area = "";
try { area = spot.Area != null ? spot.Area.name : ""; } catch { }
sb.AppendLine(
$" {spot.Label} {ft}ft {cars} cars area={area} pathPts={spot.Path.Count} key={spot.Key}");
}
sb.AppendLine($"({tagged} yard tags with a BY-style code)");
if (yardN > 0 && tagged == 0)
sb.AppendLine("No labels matched letter+digit codes (BY1). Check segment names above.");
return Finish(sb, "yard-dump.txt");
}
static string Dump(string filter)
{
var ops = OpsController.Shared;
var tc = TrainController.Shared;
if (ops == null || tc == null)
return "Not in a game.";
var sb = new StringBuilder();
sb.AppendLine($"=== S3 industry dump {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===");
if (!string.IsNullOrEmpty(filter))
sb.AppendLine($"filter: {filter}");
sb.AppendLine();
Car? selected = null;
try { selected = tc.SelectedCar; } catch { }
if (selected != null)
DumpCar(sb, ops, selected, "SELECTED CAR");
Industry[]? industries = null;
try { industries = ops.AllIndustries; } catch { }
if (industries == null || industries.Length == 0)
return Finish(sb.AppendLine("No industries."));
var componentToIndustry = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (Industry industry in industries)
{
if (industry == null) continue;
try
{
foreach (IndustryComponent ic in industry.Components)
{
if (ic == null || string.IsNullOrEmpty(ic.Identifier)) continue;
componentToIndustry[ic.Identifier] = industry.identifier;
}
}
catch { }
}
var inbound = new Dictionary<string, List<Car>>();
var outbound = new Dictionary<string, List<Car>>();
foreach (Car car in tc.Cars)
{
if (car == null) continue;
Waybill? wb = car.Waybill;
if (!wb.HasValue) continue;
Waybill w = wb.Value;
if (w.Completed) continue;
string? destId = TryIndustryId(componentToIndustry, w.Destination.Identifier);
string? originId = w.Origin.HasValue
? TryIndustryId(componentToIndustry, w.Origin.Value.Identifier)
: null;
if (!string.IsNullOrEmpty(destId))
Add(inbound, destId, car);
if (!string.IsNullOrEmpty(originId) && originId != destId)
Add(outbound, originId, car);
}
sb.AppendLine("-- summary (inbound / CarsAtPosition / span.Contains / stopped / outbound) --");
int dumped = 0;
foreach (Industry industry in industries.OrderBy(i => i != null ? i.name : "", StringComparer.OrdinalIgnoreCase))
{
if (industry == null || string.IsNullOrEmpty(industry.identifier)) continue;
if (!Matches(industry, filter)) continue;
List<Car> atPos = CarsAtPosition(ops, industry, stoppedOnly: false);
List<Car> stopped = CarsAtPosition(ops, industry, stoppedOnly: true);
int onSpan = 0;
inbound.TryGetValue(industry.identifier, out var inCars);
outbound.TryGetValue(industry.identifier, out var outCars);
if (inCars != null)
{
foreach (Car car in inCars)
{
if (CarOnIndustrySpans(car, industry)) onSpan++;
}
}
bool interesting = string.IsNullOrEmpty(filter)
|| (inCars != null && inCars.Count > 0)
|| atPos.Count > 0
|| (outCars != null && outCars.Count > 0)
|| (selected != null && CarTouches(selected, industry, componentToIndustry));
if (string.IsNullOrEmpty(filter) && !interesting)
continue;
sb.AppendLine(
$"{industry.name} id={industry.identifier} " +
$"in={inCars?.Count ?? 0} atPos={atPos.Count} span={onSpan} stopped={stopped.Count} out={outCars?.Count ?? 0}");
dumped++;
}
sb.AppendLine($"({dumped} industries listed)");
sb.AppendLine();
foreach (Industry industry in industries.OrderBy(i => i != null ? i.name : "", StringComparer.OrdinalIgnoreCase))
{
if (industry == null) continue;
if (!Matches(industry, filter)) continue;
if (string.IsNullOrEmpty(filter))
{
inbound.TryGetValue(industry.identifier, out var inCars0);
outbound.TryGetValue(industry.identifier, out var outCars0);
if ((inCars0 == null || inCars0.Count == 0) && (outCars0 == null || outCars0.Count == 0))
continue;
}
DumpIndustry(sb, ops, industry, componentToIndustry,
inbound.TryGetValue(industry.identifier, out var inList) ? inList : null,
outbound.TryGetValue(industry.identifier, out var outList) ? outList : null);
}
return Finish(sb);
}
static void DumpIndustry(
StringBuilder sb,
OpsController ops,
Industry industry,
Dictionary<string, string> componentToIndustry,
List<Car>? inbound,
List<Car>? outbound)
{
sb.AppendLine($"== {industry.name} ({industry.identifier}) ==");
try
{
foreach (IndustryComponent ic in industry.Components)
{
if (ic == null) continue;
DumpComponent(sb, ops, ic);
}
}
catch (Exception e)
{
sb.AppendLine($" components error: {e.Message}");
}
var atPos = new HashSet<Car>(CarsAtPosition(ops, industry, stoppedOnly: false));
if (inbound != null && inbound.Count > 0)
{
sb.AppendLine(" inbound waybills:");
foreach (Car car in inbound)
DumpCarLine(sb, ops, industry, car, atPos, dest: true);
}
if (outbound != null && outbound.Count > 0)
{
sb.AppendLine(" outbound waybills:");
foreach (Car car in outbound)
DumpCarLine(sb, ops, industry, car, atPos, dest: false);
}
foreach (Car car in atPos)
{
if (inbound != null && inbound.Contains(car)) continue;
sb.AppendLine($" extra atPos (no inbound dest): {CarLabel(car)} vel={Vel(car)} type={Safe(car.CarType)}");
DumpWaybillShort(sb, car, componentToIndustry);
}
sb.AppendLine();
}
static void DumpComponent(StringBuilder sb, OpsController ops, IndustryComponent ic)
{
string type = ic.GetType().Name;
int spanN = ic.trackSpans != null ? ic.trackSpans.Length : 0;
string vis = "?";
try { vis = ic.IsVisible ? "vis" : "hidden"; } catch { }
string filter = "";
try { filter = ic.carTypeFilter != null ? ic.carTypeFilter.ToString() : ""; } catch { }
sb.AppendLine($" [{type}] {ic.DisplayName} id={ic.Identifier} {vis} spans={spanN} filter={filter}");
if (ic.trackSpans != null)
{
foreach (TrackSpan span in ic.trackSpans)
{
if (span == null)
{
sb.AppendLine(" span=null");
continue;
}
string segs = "";
try
{
var list = span.GetSegments();
if (list != null)
segs = string.Join(",", list.Select(s => s != null ? s.id : "?"));
}
catch { segs = "err"; }
bool valid = false;
try { valid = span.IsValid; } catch { }
sb.AppendLine($" span {span.id} valid={valid} segs=[{segs}]");
}
}
try
{
var cars = new List<Car>();
foreach (Car c in ops.CarsAtPosition(ic))
if (c != null) cars.Add(c);
sb.AppendLine($" CarsAtPosition={cars.Count}" +
(cars.Count == 0 ? "" : " " + string.Join(", ", cars.Select(CarLabel))));
}
catch (Exception e)
{
sb.AppendLine($" CarsAtPosition error: {e.Message}");
}
}
static void DumpCarLine(StringBuilder sb, OpsController ops, Industry industry, Car car, HashSet<Car> atPos, bool dest)
{
bool span = CarOnIndustrySpans(car, industry);
bool pos = atPos.Contains(car);
string destId = "";
string originId = "";
try
{
Waybill? wb = car.Waybill;
if (wb.HasValue)
{
destId = wb.Value.Destination.Identifier;
if (wb.Value.Origin.HasValue)
originId = wb.Value.Origin.Value.Identifier;
}
}
catch { }
sb.AppendLine(
$" {CarLabel(car)} type={Safe(car.CarType)} vel={Vel(car)} " +
$"spanContains={span} atPos={pos} dest={destId} origin={originId}");
DumpLocation(sb, car);
try
{
if (ops.TryGetDestinationInfo(car, out var destName, out var isAt, out _, out var destPos))
sb.AppendLine($" TryGetDestinationInfo name={destName} isAt={isAt} destId={destPos.Identifier}");
}
catch (Exception e)
{
sb.AppendLine($" TryGetDestinationInfo error: {e.Message}");
}
try
{
OpsCarPosition? here = ops.PositionForCar(car);
sb.AppendLine(here.HasValue
? $" PositionForCar {here.Value.DisplayName}/{here.Value.Identifier}"
: " PositionForCar null");
}
catch (Exception e)
{
sb.AppendLine($" PositionForCar error: {e.Message}");
}
}
static void DumpCar(StringBuilder sb, OpsController ops, Car car, string heading)
{
sb.AppendLine($"-- {heading}: {CarLabel(car)} --");
sb.AppendLine($" type={Safe(car.CarType)} vel={Vel(car)}");
DumpWaybillShort(sb, car, null);
DumpLocation(sb, car);
try
{
if (ops.TryGetDestinationInfo(car, out var destName, out var isAt, out var destWorld, out var destPos))
sb.AppendLine($" destInfo {destName} isAt={isAt} id={destPos.Identifier} world={destWorld}");
}
catch (Exception e) { sb.AppendLine($" destInfo error: {e.Message}"); }
try
{
OpsCarPosition? here = ops.PositionForCar(car);
sb.AppendLine(here.HasValue
? $" PositionForCar {here.Value.DisplayName}/{here.Value.Identifier}"
: " PositionForCar null");
}
catch (Exception e) { sb.AppendLine($" PositionForCar error: {e.Message}"); }
sb.AppendLine();
}
static void DumpWaybillShort(StringBuilder sb, Car car, Dictionary<string, string>? map)
{
try
{
Waybill? wb = car.Waybill;
if (!wb.HasValue)
{
sb.AppendLine(" waybill=none");
return;
}
Waybill w = wb.Value;
string destInd = map != null ? TryIndustryId(map, w.Destination.Identifier) ?? "-" : "";
string originInd = "";
if (map != null && w.Origin.HasValue)
originInd = TryIndustryId(map, w.Origin.Value.Identifier) ?? "-";
sb.AppendLine(
$" waybill completed={w.Completed} dest={w.Destination.Identifier} ({destInd}) " +
$"origin={(w.Origin.HasValue ? w.Origin.Value.Identifier : "none")} ({originInd}) tag={w.Tag}");
}
catch (Exception e)
{
sb.AppendLine($" waybill error: {e.Message}");
}
}
static void DumpLocation(StringBuilder sb, Car car)
{
try
{
Location opsLoc = car.OpsLocation;
Location a = car.LocationA;
Location b = car.LocationB;
sb.AppendLine($" loc ops={Loc(opsLoc)} A={Loc(a)} B={Loc(b)} world={car.transform.position}");
}
catch (Exception e)
{
sb.AppendLine($" loc error: {e.Message}");
}
}
static string Loc(Location loc)
{
try
{
if (loc.segment == null) return "null";
return $"{loc.segment.id}@{loc.distance:F1}";
}
catch { return "?"; }
}
static List<Car> CarsAtPosition(OpsController ops, Industry industry, bool stoppedOnly)
{
var list = new List<Car>();
var seen = new HashSet<Car>();
try
{
foreach (IndustryComponent ic in industry.Components)
{
if (ic == null || ic.trackSpans == null || ic.trackSpans.Length == 0) continue;
foreach (Car car in ops.CarsAtPosition(ic))
{
if (car == null || !seen.Add(car)) continue;
if (stoppedOnly && Mathf.Abs(car.velocity) > 0.05f) continue;
list.Add(car);
}
}
}
catch { }
return list;
}
static bool CarOnIndustrySpans(Car car, Industry industry)
{
try
{
Location opsLoc = car.OpsLocation;
Location a = car.LocationA;
Location b = car.LocationB;
Vector3 world = car.transform.position;
foreach (IndustryComponent ic in industry.Components)
{
if (ic?.trackSpans == null) continue;
foreach (TrackSpan span in ic.trackSpans)
{
if (span == null) continue;
try
{
if (!span.IsValid) continue;
if (span.Contains(opsLoc) || span.Contains(a) || span.Contains(b))
return true;
if (span.Contains(world, 4f))
return true;
}
catch { }
}
}
}
catch { }
return false;
}
static bool CarTouches(Car car, Industry industry, Dictionary<string, string> map)
{
try
{
Waybill? wb = car.Waybill;
if (!wb.HasValue) return false;
string? dest = TryIndustryId(map, wb.Value.Destination.Identifier);
if (dest == industry.identifier) return true;
if (wb.Value.Origin.HasValue)
{
string? origin = TryIndustryId(map, wb.Value.Origin.Value.Identifier);
if (origin == industry.identifier) return true;
}
}
catch { }
return false;
}
static string? TryIndustryId(Dictionary<string, string> map, string? componentId)
{
if (string.IsNullOrEmpty(componentId)) return null;
return map.TryGetValue(componentId, out var id) ? id : null;
}
static void Add(Dictionary<string, List<Car>> dict, string key, Car car)
{
if (!dict.TryGetValue(key, out var list))
dict[key] = list = new List<Car>();
list.Add(car);
}
static bool Matches(Industry industry, string filter)
{
if (string.IsNullOrEmpty(filter)) return true;
try
{
if (industry.name != null && industry.name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
if (industry.identifier != null && industry.identifier.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
catch { }
return false;
}
static string CarLabel(Car car)
{
try { return $"{car.DisplayName} ({car.id})"; }
catch { return car != null ? car.id : "?"; }
}
static string Vel(Car car)
{
try { return car.velocity.ToString("F2"); }
catch { return "?"; }
}
static string Safe(string? s) => string.IsNullOrEmpty(s) ? "-" : s;
static string Finish(StringBuilder sb, string fileName = "industry-dump.txt")
{
string text = sb.ToString();
try { Log.Info("[s3ind]\n" + text); }
catch { }
string path = "(not written)";
try
{
string dir = Main.ModEntry.Path;
path = Path.Combine(dir, fileName);
File.WriteAllText(path, text);
}
catch (Exception e)
{
return text + $"\nFailed to write file: {e.Message}";
}
return $"Wrote {path}\n(also in the S3 log)";
}
}

View file

@ -0,0 +1,93 @@
using System;
using HarmonyLib;
using S3.Core;
using UI;
using UnityEngine;
namespace S3.Modules.IndustryTags;
public sealed class IndustryTagsModule : IModule
{
private const string SettingsFile = "S3.industrytags.json";
public static IndustryTagsSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
public IndustryTagsModule()
{
Settings = SettingsStore.Load<IndustryTagsSettings>(SettingsFile);
if (Settings.poseRev < 2)
{
Settings.heightOffset = 80f;
Settings.tagScale = 0.4f;
Settings.maxDrawDistance = 850f;
if (Settings.opacity < 0.05f) Settings.opacity = 0.9f;
Settings.poseRev = 2;
SettingsStore.Save(SettingsFile, Settings);
}
if (Settings.poseRev < 3)
{
Settings.hideIndustryWhenTracksVisible = true;
Settings.poseRev = 3;
SettingsStore.Save(SettingsFile, Settings);
}
bool saveTracks = false;
if (Settings.trackScale < 0.08f) { Settings.trackScale = 0.55f; saveTracks = true; }
if (Settings.trackHeightOffset < 1f) { Settings.trackHeightOffset = 24f; saveTracks = true; }
if (Settings.trackOpacity < 0.05f) { Settings.trackOpacity = 0.9f; saveTracks = true; }
if (Settings.trackMaxDrawDistance < 20f) { Settings.trackMaxDrawDistance = 650f; saveTracks = true; }
if (Settings.titleFontSize < 6f) { Settings.titleFontSize = 14f; saveTracks = true; }
if (Settings.trackTitleFontSize < 6f) { Settings.trackTitleFontSize = 12f; saveTracks = true; }
if (saveTracks) SettingsStore.Save(SettingsFile, Settings);
}
public string Id => "industrytags";
public string DisplayName => "Industry Tags";
public string Description =>
"In-world callouts for businesses, industry tracks, and yard numbers. " +
"Built from live map data, so modded maps work.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
_harmony = new Harmony("S3.industrytags");
try { _harmony.CreateClassProcessor(typeof(IndustryTagsMouseOverUiPatch)).Patch(); }
catch (Exception e) { Log.Error($"[industrytags] patch failed: {e.Message}"); }
try { _harmony.CreateClassProcessor(typeof(IndustryTagsDumpCommandPatch)).Patch(); }
catch (Exception e) { Log.Error($"[industrytags] dump command failed: {e.Message}"); }
_hostGo = new GameObject("[S3] IndustryTagsHost");
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
_hostGo.AddComponent<IndustryTagOverlay>();
}
public void OnDisable()
{
_harmony?.UnpatchAll("S3.industrytags");
_harmony = null;
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
IndustryTagOverlay.PointerOver = false;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => IndustryTagsSettingsUI.Draw();
}
[HarmonyPatch(typeof(GameInput), nameof(GameInput.IsMouseOverUI))]
static class IndustryTagsMouseOverUiPatch
{
static void Postfix(ref bool __result)
{
if (IndustryTagOverlay.PointerOver) __result = true;
}
}

View file

@ -0,0 +1,40 @@
using System;
namespace S3.Modules.IndustryTags;
[Serializable]
public class IndustryTagsSettings
{
public bool enabled = false;
public bool followTabTags = true;
public bool alwaysOn = false;
public float mergeDistance = 250f;
public float maxDrawDistance = 850f;
public float heightOffset = 80f;
public float tagScale = 0.4f;
public float opacity = 0.9f;
public float titleFontSize = 14f;
public bool showTrackBadges = true;
public bool hideIndustryWhenTracksVisible = true;
public float trackHeightOffset = 24f;
public float trackScale = 0.55f;
public float trackOpacity = 0.9f;
public float trackTitleFontSize = 12f;
public float trackMaxDrawDistance = 650f;
public bool showYardTags = true;
public bool showYardFeet = false;
public bool showYardCarLengths = false;
// Bumped when pose defaults change so old JSON is retuned once.
public int poseRev = 3;
public bool showNeeds = true;
public bool showOutputs = true;
public bool showPerformance = true;
public bool showStallReason = true;
public bool showCarCounts = true;
}

View file

@ -0,0 +1,93 @@
using UnityEngine;
namespace S3.Modules.IndustryTags;
static class IndustryTagsSettingsUI
{
public static void Draw()
{
var s = IndustryTagsModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Industry Tags</b> - in-world labels for businesses, tracks, and yards");
GUILayout.Space(4f);
GUILayout.Label(
" Groups each company's tracks and puts one callout at the midpoint.\n" +
" Reads live industry data, so modded maps work without extra setup.\n" +
" Double-click a tag to center the strategy camera on that business.\n" +
" Track badges sit over each loader/unloader; hover highlights that track.\n" +
" Yard tags are smaller codes (BY1) on Style.Yard sidings, not industry spots.\n" +
" Car counts: \u2192 still coming, loading-unloading / ready to pick up, rolling outbound \u2192.\n" +
" Console: /s3ind dump [name] /s3ind yards",
GUI.skin.label);
GUILayout.Space(10f);
GUILayout.Label("<b>Visibility</b>");
GUILayout.Space(4f);
changed |= Toggle(ref s.followTabTags, " Show with car tags (Tab)");
changed |= Toggle(ref s.alwaysOn, " Always show (ignores Tab)");
GUILayout.Space(10f);
GUILayout.Label("<b>Contents</b>");
GUILayout.Space(4f);
changed |= Toggle(ref s.showNeeds, " Needs (inbound cargo and storage)");
changed |= Toggle(ref s.showOutputs, " Making (outbound cargo and storage)");
changed |= Toggle(ref s.showPerformance, " Contract performance");
changed |= Toggle(ref s.showStallReason, " Stall reason (Needs steel, no contract, etc.)");
changed |= Toggle(ref s.showCarCounts, " Car counts (\u2192 inbound loading/ready outbound \u2192)");
GUILayout.Space(10f);
GUILayout.Label("<b>Placement</b>");
GUILayout.Space(4f);
changed |= Slider("Height", ref s.heightOffset, 10f, 200f, "0");
changed |= Slider("Size", ref s.tagScale, 0.1f, 1.5f, "0.00");
changed |= Slider("Title size", ref s.titleFontSize, 8f, 28f, "0");
changed |= Slider("Opacity", ref s.opacity, 0.15f, 1f, "0.00");
changed |= Slider("Merge distance", ref s.mergeDistance, 50f, 800f, "0");
changed |= Slider("Draw distance", ref s.maxDrawDistance, 100f, 2000f, "0");
GUILayout.Space(10f);
GUILayout.Label("<b>Track badges</b>");
GUILayout.Space(4f);
changed |= Toggle(ref s.showTrackBadges, " Show a smaller badge over each industry track");
changed |= Toggle(ref s.hideIndustryWhenTracksVisible, " Hide the business tag when you are close enough to see track badges");
changed |= Slider("Track height", ref s.trackHeightOffset, 5f, 80f, "0");
changed |= Slider("Track size", ref s.trackScale, 0.1f, 1.5f, "0.00");
changed |= Slider("Track title size", ref s.trackTitleFontSize, 8f, 28f, "0");
changed |= Slider("Track opacity", ref s.trackOpacity, 0.15f, 1f, "0.00");
changed |= Slider("Track draw distance", ref s.trackMaxDrawDistance, 80f, 2000f, "0");
GUILayout.Space(10f);
GUILayout.Label("<b>Yard tags</b>");
GUILayout.Space(4f);
changed |= Toggle(ref s.showYardTags, " Show compact codes on yard tracks (BY1)");
changed |= Toggle(ref s.showYardFeet, " Show how many feet fit");
changed |= Toggle(ref s.showYardCarLengths, " Show how many 50 ft cars fit");
GUILayout.EndVertical();
if (changed)
IndustryTagsModule.Persist();
}
static bool Toggle(ref bool field, string label)
{
bool next = GUILayout.Toggle(field, label);
if (next == field) return false;
field = next;
return true;
}
static bool Slider(string label, ref float field, float min, float max, string fmt)
{
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(120f));
float nv = GUILayout.HorizontalSlider(field, min, max, GUILayout.Width(180f));
GUILayout.Label(field.ToString(fmt), GUILayout.Width(48f));
GUILayout.EndHorizontal();
if (Mathf.Abs(nv - field) <= 0.01f) return false;
field = nv;
return true;
}
}