From 48c9b97029f700ea9d01621425ce7a7d1efba968 Mon Sep 17 00:00:00 2001 From: seton Date: Fri, 11 Sep 2026 15:19:24 -0400 Subject: [PATCH] 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. --- README.md | 9 + src/Main.cs | 1 + src/Modules/IndustryTags/IndustryCatalog.cs | 1647 +++++++++++++++++ .../IndustryTags/IndustryTagOverlay.cs | 816 ++++++++ src/Modules/IndustryTags/IndustryTagView.cs | 1449 +++++++++++++++ .../IndustryTags/IndustryTagsDumpCommand.cs | 575 ++++++ .../IndustryTags/IndustryTagsModule.cs | 93 + .../IndustryTags/IndustryTagsSettings.cs | 40 + .../IndustryTags/IndustryTagsSettingsUI.cs | 93 + 9 files changed, 4723 insertions(+) create mode 100644 src/Modules/IndustryTags/IndustryCatalog.cs create mode 100644 src/Modules/IndustryTags/IndustryTagOverlay.cs create mode 100644 src/Modules/IndustryTags/IndustryTagView.cs create mode 100644 src/Modules/IndustryTags/IndustryTagsDumpCommand.cs create mode 100644 src/Modules/IndustryTags/IndustryTagsModule.cs create mode 100644 src/Modules/IndustryTags/IndustryTagsSettings.cs create mode 100644 src/Modules/IndustryTags/IndustryTagsSettingsUI.cs diff --git a/README.md b/README.md index 47595ec..fbd49a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Main.cs b/src/Main.cs index e0a91cc..51533a2 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -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(); diff --git a/src/Modules/IndustryTags/IndustryCatalog.cs b/src/Modules/IndustryTags/IndustryCatalog.cs new file mode 100644 index 0000000..dd0267b --- /dev/null +++ b/src/Modules/IndustryTags/IndustryCatalog.cs @@ -0,0 +1,1647 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Game; +using Model; +using Model.Definition.Data; +using Model.Ops; +using Model.Ops.Definition; +using Track; +using UnityEngine; + +namespace S3.Modules.IndustryTags; + +sealed class BusinessCluster +{ + public string Key = ""; + public string Name = ""; + public Industry? Industry; + public Area? Area; + public Vector3 GameCentroid; + public readonly List Components = new(); +} + +sealed class TrackSpot +{ + public string Key = ""; + public string Label = ""; + public Industry? Industry; + public Area? Area; + public Vector3 GameCentroid; + public readonly List Components = new(); + public readonly List SpanIds = new(); + public readonly List Spans = new(); + public readonly List Path = new(); + public float PathLength; + public bool Yard; +} + +static class IndustryCatalog +{ + public static void Rebuild(List dest, float mergeDistance) + { + dest.Clear(); + var ops = OpsController.Shared; + if (ops == null) return; + + var handled = new HashSet(); + Industry[]? industries = null; + try { industries = ops.AllIndustries; } + catch { return; } + if (industries == null) return; + + foreach (Industry industry in industries) + { + if (industry == null || industry.ProgressionDisabled) continue; + var anchors = CollectAnchors(industry.Components, handled); + if (anchors.Count == 0) continue; + ClusterAnchors(dest, industry, industry.name, anchors, mergeDistance); + } + + IndustryComponent[]? leftover = null; + try { leftover = UnityEngine.Object.FindObjectsOfType(); } + catch { leftover = null; } + if (leftover == null) return; + + var orphans = new Dictionary>(); + foreach (IndustryComponent ic in leftover) + { + if (ic == null || handled.Contains(ic)) continue; + if (!IsPlaceable(ic)) continue; + string name = ParseBusinessName(ic.DisplayName); + if (string.IsNullOrEmpty(name)) continue; + Vector3 pos = SpanCentroid(ic); + if (!orphans.TryGetValue(name, out var list)) + orphans[name] = list = new(); + list.Add((ic, pos)); + handled.Add(ic); + } + + foreach (var kv in orphans) + ClusterAnchors(dest, null, kv.Key, kv.Value, mergeDistance); + } + + public static void RebuildTracks(List dest) + { + dest.Clear(); + var ops = OpsController.Shared; + if (ops == null) return; + Industry[]? industries = null; + try { industries = ops.AllIndustries; } + catch { return; } + if (industries == null) return; + + foreach (Industry industry in industries) + { + if (industry == null || industry.ProgressionDisabled) continue; + var groups = new Dictionary(); + try + { + foreach (IndustryComponent ic in industry.Components) + { + if (ic == null || !IsPlaceable(ic)) continue; + TrackSpan[]? spans = ic.trackSpans; + if (spans == null || spans.Length == 0) continue; + string[] names = ExpandSpanNames(ic); + for (int si = 0; si < spans.Length; si++) + { + TrackSpan span = spans[si]; + if (span == null) continue; + string spanId = span.id; + if (string.IsNullOrEmpty(spanId)) + spanId = (ic.Identifier ?? "") + "#" + si; + if (string.IsNullOrEmpty(spanId)) continue; + if (!groups.TryGetValue(spanId, out var spot)) + { + string rawName = si < names.Length ? names[si] : ic.DisplayName; + Vector3 pos = SpanCenter(span, ic); + spot = new TrackSpot + { + Industry = industry, + GameCentroid = pos, + Area = FindArea(industry, pos), + Label = ParseTrackLabel(rawName, industry.name), + Key = industry.identifier + "|" + spanId, + }; + spot.SpanIds.Add(spanId); + spot.Spans.Add(span); + groups[spanId] = spot; + } + else + { + if (!spot.SpanIds.Contains(spanId)) + spot.SpanIds.Add(spanId); + if (!spot.Spans.Contains(span)) + spot.Spans.Add(span); + } + if (!spot.Components.Contains(ic)) + spot.Components.Add(ic); + } + } + } + catch { } + foreach (var kv in groups) + { + FillTrackPath(kv.Value); + dest.Add(kv.Value); + } + } + } + + public static void RebuildYards(List dest) + { + var usedSegs = new HashSet(StringComparer.Ordinal); + CollectIndustrySegmentIds(usedSegs); + foreach (TrackSpot spot in dest) + { + foreach (TrackSpan span in spot.Spans) + { + if (span == null) continue; + try + { + foreach (TrackSegment seg in span.GetSegments()) + { + if (seg != null && !string.IsNullOrEmpty(seg.id)) + usedSegs.Add(seg.id); + } + } + catch { } + } + } + + TrackSegment[]? all = null; + try { all = UnityEngine.Object.FindObjectsOfType(); } + catch { return; } + if (all == null) return; + + var yardSegs = new List(); + foreach (TrackSegment seg in all) + { + if (seg == null || seg.style != TrackSegment.Style.Yard) continue; + if (seg.IsInvisible) continue; + try { if (!seg.Available || !seg.GroupEnabled) continue; } catch { } + if (!string.IsNullOrEmpty(seg.id) && usedSegs.Contains(seg.id)) continue; + yardSegs.Add(seg); + } + if (yardSegs.Count == 0) return; + + var byNode = new Dictionary>(StringComparer.Ordinal); + foreach (TrackSegment seg in yardSegs) + { + AddYardNode(byNode, seg.a, seg); + AddYardNode(byNode, seg.b, seg); + } + + var used = new HashSet(); + foreach (TrackSegment start in yardSegs) + { + if (used.Contains(start)) continue; + var chain = WalkYardChain(start, byNode, used); + if (chain.Count == 0) continue; + string? label = YardLabel(chain); + if (string.IsNullOrEmpty(label)) continue; + var path = new List(); + float meters = SampleYardPath(chain, path); + if (path.Count < 2) continue; + Vector3 mid = path[path.Count / 2]; + var spot = new TrackSpot + { + Yard = true, + Industry = null, + GameCentroid = mid, + Area = FindArea(null, mid), + Label = label, + Key = "yard|" + YardKey(chain), + }; + spot.Path.AddRange(path); + spot.PathLength = meters > 0.01f ? meters : PolylineLength(path); + dest.Add(spot); + } + } + + static void CollectIndustrySegmentIds(HashSet dest) + { + var ops = OpsController.Shared; + if (ops?.AllIndustries == null) return; + foreach (Industry industry in ops.AllIndustries) + { + if (industry?.Components == null) continue; + foreach (IndustryComponent ic in industry.Components) + { + if (ic?.trackSpans == null) continue; + foreach (TrackSpan span in ic.trackSpans) + { + if (span == null) continue; + try + { + foreach (TrackSegment seg in span.GetSegments()) + { + if (seg != null && !string.IsNullOrEmpty(seg.id)) + dest.Add(seg.id); + } + } + catch { } + } + } + } + } + + static void AddYardNode(Dictionary> byNode, TrackNode? node, TrackSegment seg) + { + if (node == null || string.IsNullOrEmpty(node.id)) return; + if (!byNode.TryGetValue(node.id, out var list)) + byNode[node.id] = list = new List(); + if (!list.Contains(seg)) list.Add(seg); + } + + static List WalkYardChain( + TrackSegment start, + Dictionary> byNode, + HashSet used) + { + var left = new List(); + var right = new List(); + used.Add(start); + GrowYard(start, start.a, byNode, used, left); + GrowYard(start, start.b, byNode, used, right); + var chain = new List(left.Count + 1 + right.Count); + for (int i = left.Count - 1; i >= 0; i--) + chain.Add(left[i]); + chain.Add(start); + chain.AddRange(right); + return chain; + } + + static void GrowYard( + TrackSegment from, + TrackNode? node, + Dictionary> byNode, + HashSet used, + List into) + { + TrackSegment prev = from; + TrackNode? cur = node; + while (cur != null && !string.IsNullOrEmpty(cur.id)) + { + if (!byNode.TryGetValue(cur.id, out var list) || list.Count != 2) + return; + TrackSegment? next = null; + foreach (TrackSegment s in list) + { + if (s != prev) { next = s; break; } + } + if (next == null || used.Contains(next)) return; + into.Add(next); + used.Add(next); + TrackNode? other = next.GetOtherNode(cur); + prev = next; + cur = other; + } + } + + static string YardKey(List chain) + { + var ids = new List(); + foreach (TrackSegment s in chain) + { + if (s != null && !string.IsNullOrEmpty(s.id)) + ids.Add(s.id); + } + ids.Sort(StringComparer.Ordinal); + return ids.Count > 0 ? string.Join(".", ids) : "anon"; + } + + static string? YardLabel(List chain) + { + foreach (TrackSegment seg in chain) + { + if (seg == null) continue; + string? code = YardCodeFromName(seg.gameObject != null ? seg.gameObject.name : null) + ?? YardCodeFromName(seg.id) + ?? YardCodeFromName(seg.groupId); + if (seg.transform != null && seg.transform.parent != null) + code ??= YardCodeFromName(seg.transform.parent.name); + if (!string.IsNullOrEmpty(code)) return code; + } + return null; + } + + static string? YardCodeFromName(string? raw) + { + if (string.IsNullOrEmpty(raw)) return null; + string s = raw.Trim(); + int cut = s.IndexOf('('); + if (cut > 0) s = s.Substring(0, cut).Trim(); + if (LooksLikeTrackCode(s)) return s.ToUpperInvariant(); + var parts = s.Split(new[] { ' ', '_', '-' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return null; + string last = parts[parts.Length - 1]; + if (LooksLikeTrackCode(last)) return last.ToUpperInvariant(); + return null; + } + + static float SampleYardPath(List chain, List path) + { + float total = 0f; + TrackNode? at = null; + if (chain.Count >= 2) + { + TrackSegment a = chain[0]; + TrackSegment b = chain[1]; + if (a.a == b.a || a.a == b.b) at = a.b; + else at = a.a; + } + else if (chain.Count == 1) + at = chain[0].a; + + foreach (TrackSegment seg in chain) + { + if (seg == null) continue; + float len = 0f; + try { len = seg.GetLength(); } + catch { continue; } + total += len; + TrackSegment.End from = TrackSegment.End.A; + if (at != null) + { + try { from = seg.EndForNode(at); } + catch { from = TrackSegment.End.A; } + } + int samples = Mathf.Max(2, Mathf.RoundToInt(len / 12f) + 1); + for (int i = 0; i < samples; i++) + { + float d = len * (i / (float)(samples - 1)); + try + { + seg.GetPositionRotationAtDistance(d, from, PositionAccuracy.Standard, out Vector3 pos, out _); + if (path.Count == 0 || (pos - path[path.Count - 1]).sqrMagnitude > 0.04f) + path.Add(pos); + } + catch { } + } + try { at = seg.GetOtherNode(at != null ? at : seg.a); } + catch { at = null; } + } + return total; + } + + public static Vector3 PointOnPath(TrackSpot spot, float t) + { + if (spot.Path.Count == 0) return spot.GameCentroid; + if (spot.Path.Count == 1) return spot.Path[0]; + float len = spot.PathLength; + if (len < 0.01f) return spot.Path[spot.Path.Count / 2]; + float target = Mathf.Clamp01(t) * len; + float acc = 0f; + for (int i = 0; i < spot.Path.Count - 1; i++) + { + Vector3 a = spot.Path[i]; + Vector3 b = spot.Path[i + 1]; + float d = Vector3.Distance(a, b); + if (acc + d >= target || i == spot.Path.Count - 2) + { + float u = d > 0.0001f ? (target - acc) / d : 0f; + return Vector3.LerpUnclamped(a, b, Mathf.Clamp01(u)); + } + acc += d; + } + return spot.Path[spot.Path.Count - 1]; + } + + public static float ClampPathT(TrackSpot spot, float t) + { + if (spot.PathLength < 8f) return 0.5f; + float m = Mathf.Clamp(8f / spot.PathLength, 0.06f, 0.42f); + return Mathf.Clamp(t, m, 1f - m); + } + + static void FillTrackPath(TrackSpot spot) + { + spot.Path.Clear(); + spot.PathLength = 0f; + var seen = new HashSet(); + var chains = new List>(); + IEnumerable spans = spot.Spans.Count > 0 + ? spot.Spans + : EnumerateSpotSpans(spot); + foreach (TrackSpan span in spans) + { + if (span == null) continue; + string id = span.id ?? ""; + if (spot.SpanIds.Count > 0 && id.Length > 0 && !spot.SpanIds.Contains(id)) + continue; + if (id.Length > 0 && !seen.Add(id)) continue; + List? chain = CopySpanPoints(span); + if (chain != null && chain.Count >= 2) + chains.Add(chain); + } + if (chains.Count == 0) return; + List path = LongestJoinedPath(chains); + if (path.Count < 2) return; + spot.Path.AddRange(path); + spot.PathLength = PolylineLength(path); + spot.GameCentroid = PointOnPath(spot, 0.5f); + } + + static List? CopySpanPoints(TrackSpan span) + { + IReadOnlyCollection? pts = null; + try { pts = span.GetPoints(); } + catch { return null; } + if (pts == null || pts.Count < 2) return null; + var chain = new List(pts.Count); + Vector3 prev = default; + bool have = false; + foreach (Vector3 p in pts) + { + if (have && (p - prev).sqrMagnitude < 0.05f) continue; + chain.Add(p); + prev = p; + have = true; + } + return chain.Count >= 2 ? chain : null; + } + + static List LongestJoinedPath(List> chains) + { + int best = 0; + float bestLen = PolylineLength(chains[0]); + for (int i = 1; i < chains.Count; i++) + { + float len = PolylineLength(chains[i]); + if (len <= bestLen) continue; + best = i; + bestLen = len; + } + var path = new List(chains[best]); + var used = new bool[chains.Count]; + used[best] = true; + bool grew = true; + while (grew) + { + grew = false; + for (int i = 0; i < chains.Count; i++) + { + if (used[i]) continue; + if (!TryJoinPolyline(path, chains[i])) continue; + used[i] = true; + grew = true; + } + } + return path; + } + + static bool TryJoinPolyline(List path, List chain) + { + if (path.Count == 0 || chain.Count == 0) return false; + Vector3 ps = path[0]; + Vector3 pe = path[path.Count - 1]; + Vector3 cs = chain[0]; + Vector3 ce = chain[chain.Count - 1]; + const float maxSqr = 16f; + float peCs = (pe - cs).sqrMagnitude; + float peCe = (pe - ce).sqrMagnitude; + float psCs = (ps - cs).sqrMagnitude; + float psCe = (ps - ce).sqrMagnitude; + float min = Mathf.Min(Mathf.Min(peCs, peCe), Mathf.Min(psCs, psCe)); + if (min > maxSqr) return false; + if (min == peCs) + AppendSkipFirst(path, chain, reverse: false); + else if (min == peCe) + AppendSkipFirst(path, chain, reverse: true); + else if (min == psCe) + { + path.Reverse(); + AppendSkipFirst(path, chain, reverse: false); + } + else + { + path.Reverse(); + AppendSkipFirst(path, chain, reverse: true); + } + return true; + } + + static void AppendSkipFirst(List path, List chain, bool reverse) + { + if (reverse) + { + for (int i = chain.Count - 2; i >= 0; i--) + path.Add(chain[i]); + } + else + { + for (int i = 1; i < chain.Count; i++) + path.Add(chain[i]); + } + } + + static float PolylineLength(List pts) + { + float len = 0f; + for (int i = 0; i < pts.Count - 1; i++) + len += Vector3.Distance(pts[i], pts[i + 1]); + return len; + } + + public static string TrackTitle(TrackSpot spot) + { + string track = string.IsNullOrEmpty(spot.Label) ? (spot.Yard ? "Yard" : "Track") : spot.Label; + if (spot.Yard) return track; + string? name = null; + try { name = spot.Industry != null ? spot.Industry.name : null; } + catch { } + string abbr = AbbreviateIndustry(name); + if (string.IsNullOrEmpty(abbr)) return track; + return abbr + " - " + track; + } + + static string AbbreviateIndustry(string? name) + { + if (string.IsNullOrEmpty(name)) return ""; + var initials = new List(); + var words = new List(); + foreach (string raw in name.Split(new[] { ' ', '-', '/', '&' }, StringSplitOptions.RemoveEmptyEntries)) + { + string w = raw.Trim(); + if (w.Length == 0) continue; + if (IsSkippedIndustryWord(w)) continue; + if (!char.IsLetter(w[0])) continue; + words.Add(w); + initials.Add(char.ToUpperInvariant(w[0])); + } + if (words.Count == 0) + { + var letters = new List(); + foreach (char c in name) + { + if (!char.IsLetter(c)) continue; + letters.Add(char.ToUpperInvariant(c)); + if (letters.Count >= 3) break; + } + return letters.Count > 0 ? new string(letters.ToArray()) : ""; + } + if (words.Count == 1) + { + string w = words[0]; + int n = Mathf.Min(3, w.Length); + var buf = new char[n]; + for (int i = 0; i < n; i++) + buf[i] = char.ToUpperInvariant(w[i]); + return new string(buf); + } + if (initials.Count > 4) + return string.Concat(initials[0], initials[1], initials[initials.Count - 2], initials[initials.Count - 1]); + return new string(initials.ToArray()); + } + + static bool IsSkippedIndustryWord(string w) + { + return w.Equals("a", StringComparison.OrdinalIgnoreCase) + || w.Equals("an", StringComparison.OrdinalIgnoreCase) + || w.Equals("the", StringComparison.OrdinalIgnoreCase) + || w.Equals("of", StringComparison.OrdinalIgnoreCase) + || w.Equals("and", StringComparison.OrdinalIgnoreCase) + || w.Equals("for", StringComparison.OrdinalIgnoreCase) + || w.Equals("at", StringComparison.OrdinalIgnoreCase) + || w.Equals("to", StringComparison.OrdinalIgnoreCase) + || w.Equals("in", StringComparison.OrdinalIgnoreCase) + || w.Equals("on", StringComparison.OrdinalIgnoreCase) + || w.Equals("by", StringComparison.OrdinalIgnoreCase); + } + + public readonly struct TagDetails + { + public readonly string Counts; + public readonly int? ContractTier; + public readonly float Performance; + public readonly string Body; + + public TagDetails(string counts, int? contractTier, float performance, string body) + { + Counts = counts; + ContractTier = contractTier; + Performance = performance; + Body = body; + } + } + + public static TagDetails BuildDetails(BusinessCluster cluster, IndustryTagsSettings s) + { + string counts = ""; + int? tier = null; + float performance = 0f; + var sb = new StringBuilder(); + + Industry? industry = cluster.Industry; + if (s.showCarCounts && industry != null) + { + try + { + var (inbound, working, ready, outbound) = CountCars(industry); + counts = FormatCounts(inbound, working, ready, outbound); + } + catch { } + } + + if (s.showPerformance && industry != null && industry.usesContract) + { + try + { + Contract? contract = industry.Contract; + if (contract.HasValue) + { + tier = contract.Value.Tier; + IReadOnlyDictionary? hist = industry.PerformanceHistory; + if (hist != null && hist.Count > 0) + performance = Mathf.Clamp01(hist.OrderByDescending(kv => kv.Key).First().Value); + } + } + catch { } + } + + if (s.showStallReason && industry != null) + { + string stall = StallReason(industry); + if (!string.IsNullOrEmpty(stall)) + sb.Append(stall); + } + + if (industry != null && (s.showNeeds || s.showOutputs)) + { + try + { + var seenLoads = new HashSet(StringComparer.Ordinal); + foreach (var (ic, ctx) in industry.EnumerateComponentContexts(0f)) + { + if (ic == null || ic.ProgressionDisabled) continue; + if (IsSkippedType(ic) || IsUtilityName(ic.DisplayName)) continue; + AppendInventory(sb, ic, ctx, s, seenLoads); + } + foreach (var (ic, ctx) in industry.EnumerateComponentContexts(0f)) + { + if (ic == null || ic.ProgressionDisabled) continue; + if (IsSkippedType(ic) || IsUtilityName(ic.DisplayName)) continue; + if (!IncludeComponentFields(ic, s)) continue; + foreach (IndustryComponent.PanelField field in ic.PanelFields(ctx)) + { + if (string.IsNullOrEmpty(field.Text)) continue; + if (!string.IsNullOrEmpty(field.Label) && seenLoads.Contains(field.Label)) + continue; + if (sb.Length > 0 && sb[sb.Length - 1] != '\n') sb.Append('\n'); + if (!string.IsNullOrEmpty(field.Label) && + !string.Equals(field.Label, field.Text, StringComparison.Ordinal)) + sb.Append(field.Label).Append(" "); + sb.Append(field.Text); + } + } + } + catch { } + } + + return new TagDetails(counts, tier, performance, sb.ToString().TrimEnd()); + } + + public static TagDetails BuildTrackDetails(TrackSpot spot, IndustryTagsSettings s) + { + if (spot.Yard) + return new TagDetails("", null, 0f, YardCapacityLine(spot, s)); + EnsureCarCounts(); + string counts = ""; + if (s.showCarCounts) + { + var (inbound, working, ready, outbound) = CountTrackCars(spot); + counts = FormatCounts(inbound, working, ready, outbound); + } + return new TagDetails(counts, null, 0f, ""); + } + + static string YardCapacityLine(TrackSpot spot, IndustryTagsSettings s) + { + if (!s.showYardFeet && !s.showYardCarLengths) return ""; + float meters = spot.PathLength; + if (meters < 0.5f) return ""; + var parts = new List(); + if (s.showYardFeet) + parts.Add(Mathf.Max(0, Mathf.RoundToInt(meters * 3.28084f)) + " ft"); + if (s.showYardCarLengths) + parts.Add(Mathf.Max(0, Mathf.FloorToInt(meters / 15.24f)) + " cars"); + return string.Join(" · ", parts); + } + + static (int inbound, int working, int ready, int outbound) CountTrackCars(TrackSpot spot) + { + var here = CarsOnSpot(spot); + int working = 0, ready = 0; + foreach (Car car in here) + { + OnSiteKind kind = ClassifyOnTrack(car, spot); + if (kind == OnSiteKind.Working) working++; + else if (kind == OnSiteKind.Ready) ready++; + } + + var destIds = new HashSet(StringComparer.Ordinal); + foreach (IndustryComponent ic in spot.Components) + { + if (ic == null || string.IsNullOrEmpty(ic.Identifier)) continue; + destIds.Add(ic.Identifier); + } + if (destIds.Count == 0) return (0, working, ready, 0); + + int inbound = 0, outbound = 0; + var tc = TrainController.Shared; + if (tc == null) return (0, working, ready, 0); + foreach (Car car in tc.Cars) + { + if (car == null || car is BaseLocomotive) continue; + if (here.Contains(car)) continue; + Waybill? wb = car.Waybill; + if (!wb.HasValue) continue; + Waybill w = wb.Value; + if (w.Completed) continue; + string destComp = w.Destination.Identifier; + string? originComp = w.Origin.HasValue ? w.Origin.Value.Identifier : null; + if (CarOnSiblingSpan(car, spot)) continue; + if (!string.IsNullOrEmpty(destComp) && destIds.Contains(destComp)) + inbound++; + if (!string.IsNullOrEmpty(originComp) && destIds.Contains(originComp) && originComp != destComp) + outbound++; + } + return (inbound, working, ready, outbound); + } + + static HashSet CarsOnSpot(TrackSpot spot) + { + var set = new HashSet(); + var ops = OpsController.Shared; + if (ops == null) return set; + foreach (IndustryComponent ic in spot.Components) + { + if (ic == null || !HasWorkSpans(ic)) continue; + try + { + foreach (Car car in ops.CarsAtPosition(ic)) + { + if (car == null || car is BaseLocomotive) continue; + if (spot.Spans.Count > 0 && !CarOnSpans(car, spot.Spans)) continue; + set.Add(car); + } + } + catch { } + } + return set; + } + + static OnSiteKind ClassifyOnTrack(Car car, TrackSpot spot) + { + string? destComp = null; + string? originComp = null; + bool completed = false; + try + { + Waybill? wb = car.Waybill; + if (wb.HasValue) + { + Waybill w = wb.Value; + destComp = w.Destination.Identifier; + if (w.Origin.HasValue) originComp = w.Origin.Value.Identifier; + completed = w.Completed; + } + } + catch { } + + bool destHere = SpotHasId(spot, destComp); + bool originHere = SpotHasId(spot, originComp); + + // Billed to somewhere else: interchange, another track, another industry. + if (!string.IsNullOrEmpty(destComp) && !destHere && !completed) + { + // Finished here (loaded out, empties ordered away) and waiting for pickup. + if (originHere) return OnSiteKind.Ready; + // Overflow sitting on the wrong track — inbound for the dest track, not ours. + return OnSiteKind.Skip; + } + + if (destHere) + { + IndustryComponent? destIc = ComponentById(spot, destComp); + if (destIc != null) return ClassifyAgainstComponent(car, destIc); + } + + return ClassifyAgainstSpotWork(car, spot); + } + + static OnSiteKind ClassifyAgainstComponent(Car car, IndustryComponent ic) + { + if (!CarTypeMatches(ic, car)) return OnSiteKind.Skip; + if (ic is IndustryUnloader) + return IsEmpty(car) ? OnSiteKind.Ready : OnSiteKind.Working; + if (ic is IndustryLoaderBase loader) + return IsDoneLoading(car, loader) ? OnSiteKind.Ready : OnSiteKind.Working; + return ClassifyAgainstSpotWorkForOne(car, ic); + } + + static OnSiteKind ClassifyAgainstSpotWork(Car car, TrackSpot spot) + { + bool empty = IsEmpty(car); + IndustryUnloader? matchingUnloader = null; + IndustryLoaderBase? matchingLoader = null; + bool anyLoader = false; + bool typeOk = false; + foreach (IndustryComponent ic in spot.Components) + { + if (ic == null || !CarTypeMatches(ic, car)) continue; + typeOk = true; + if (ic is IndustryUnloader unloader) + { + if (!empty && LoadMatches(car, unloader.load)) + matchingUnloader = unloader; + } + else if (ic is IndustryLoaderBase loader) + { + anyLoader = true; + if (empty || LoadMatches(car, loader.load)) + matchingLoader = loader; + } + } + if (!typeOk) return OnSiteKind.Skip; + if (matchingUnloader != null) return OnSiteKind.Working; + if (matchingLoader != null) + return IsDoneLoading(car, matchingLoader) ? OnSiteKind.Ready : OnSiteKind.Working; + if (anyLoader && !empty) return OnSiteKind.Ready; + if (anyLoader) return OnSiteKind.Working; + return OnSiteKind.Ready; + } + + static OnSiteKind ClassifyAgainstSpotWorkForOne(Car car, IndustryComponent ic) + { + if (ic is IndustryUnloader) return IsEmpty(car) ? OnSiteKind.Ready : OnSiteKind.Working; + if (ic is IndustryLoaderBase loader) + return IsDoneLoading(car, loader) ? OnSiteKind.Ready : OnSiteKind.Working; + return OnSiteKind.Ready; + } + + static bool SpotHasId(TrackSpot spot, string? id) + { + if (string.IsNullOrEmpty(id)) return false; + foreach (IndustryComponent ic in spot.Components) + { + if (ic != null && ic.Identifier == id) return true; + } + return false; + } + + static IndustryComponent? ComponentById(TrackSpot spot, string? id) + { + if (string.IsNullOrEmpty(id)) return null; + foreach (IndustryComponent ic in spot.Components) + { + if (ic != null && ic.Identifier == id) return ic; + } + return null; + } + + static bool CarTypeMatches(IndustryComponent ic, Car car) + { + try + { + if (ic.carTypeFilter != null && !ic.carTypeFilter.IsEmpty && + !ic.carTypeFilter.Matches(car.CarType)) + return false; + } + catch { } + return true; + } + + static bool IsEmpty(Car car) + { + try { return car.IsLoadEmpty(); } + catch { return true; } + } + + static bool LoadMatches(Car car, Load? load) + { + if (load == null || string.IsNullOrEmpty(load.id)) return false; + try + { + CarLoadInfo? info = car.GetLoadInfo(load.id, out _); + return info.HasValue && info.Value.Quantity > 0.001f; + } + catch { return false; } + } + + static bool IsDoneLoading(Car car, IndustryLoaderBase loader) + { + if (IsEmpty(car)) return false; + Load? load = loader.load; + if (load == null) return true; + if (!LoadMatches(car, load)) return false; + try + { + var (qty, cap) = car.QuantityCapacityOfLoad(load); + return cap > 0.001f && qty >= cap - 0.001f; + } + catch { return false; } + } + + static string FormatCounts(int inbound, int working, int ready, int outbound) => + inbound + "\u00A0\u00A0" + working + "/" + ready + "\u00A0\u00A0" + outbound; + + static void AppendInventory( + StringBuilder sb, IndustryComponent ic, IndustryContext ctx, + IndustryTagsSettings s, HashSet seenLoads) + { + Load? load = null; + float max = 0f; + if (ic is IndustryUnloader unloader && unloader.load != null) + { + if (!s.showNeeds) return; + load = unloader.load; + max = unloader.maxStorage; + } + else if (ic is IndustryLoaderBase loader && loader.load != null) + { + if (!s.showOutputs) return; + load = loader.load; + max = loader.maxStorage; + } + else + return; + + string key = !string.IsNullOrEmpty(load.id) ? load.id : load.description; + if (string.IsNullOrEmpty(key) || !seenLoads.Add(key)) return; + if (!string.IsNullOrEmpty(load.description)) + seenLoads.Add(load.description); + + float qty = 0f; + try { qty = ctx.QuantityInStorage(load); } + catch { } + try + { + float mul = ic.Industry != null ? ic.Industry.GetContractMultiplier() : 0f; + if (mul > 0.001f) max *= mul; + } + catch { } + if (max < 0.001f) max = 1f; + + if (sb.Length > 0 && sb[sb.Length - 1] != '\n') sb.Append('\n'); + sb.Append(TextSprites.PiePercent(qty, max)).Append(' '); + if (load.units == LoadUnits.Pounds && qty > 1000f) + sb.Append(Mathf.RoundToInt(qty / 2000f)).Append(" T"); + else if (load.units == LoadUnits.Pounds) + sb.Append(Mathf.RoundToInt(qty)).Append(" lb"); + else if (load.units == LoadUnits.Gallons) + sb.Append(Mathf.RoundToInt(qty)).Append(" gal"); + else + sb.Append(Mathf.RoundToInt(qty)); + if (!string.IsNullOrEmpty(load.description)) + sb.Append(' ').Append(load.description); + } + + static float _countsRefreshAt; + static object? _countsSession; + static readonly Dictionary _counts = new(); + static readonly Dictionary _compCounts = new(); + + static (int inbound, int working, int ready, int outbound) CountCars(Industry industry) + { + EnsureCarCounts(); + if (industry == null || string.IsNullOrEmpty(industry.identifier)) + return (0, 0, 0, 0); + return _counts.TryGetValue(industry.identifier, out var n) ? n : (0, 0, 0, 0); + } + + static void EnsureCarCounts() + { + float now = Time.unscaledTime; + object? session = TrainController.Shared; + if (ReferenceEquals(_countsSession, session) && now < _countsRefreshAt) return; + _countsSession = session; + _countsRefreshAt = now + 0.5f; + _counts.Clear(); + _compCounts.Clear(); + + var ops = OpsController.Shared; + var tc = TrainController.Shared; + if (ops == null || tc == null) return; + + Industry[]? industries = null; + try { industries = ops.AllIndustries; } + catch { return; } + if (industries == null) return; + + var componentToIndustry = new Dictionary(); + foreach (Industry industry in industries) + { + if (industry == null || string.IsNullOrEmpty(industry.identifier)) continue; + _counts[industry.identifier] = (0, 0, 0, 0); + try + { + foreach (IndustryComponent ic in industry.Components) + { + if (ic == null || string.IsNullOrEmpty(ic.Identifier)) continue; + componentToIndustry[ic.Identifier] = industry.identifier; + _compCounts[ic.Identifier] = (0, 0, 0, 0); + } + } + catch { } + } + + var spotted = new HashSet(); + foreach (Industry industry in industries) + { + if (industry == null || string.IsNullOrEmpty(industry.identifier)) continue; + CountOnSite(ops, industry, componentToIndustry, spotted); + } + + foreach (Car car in tc.Cars) + { + if (car == null || car is BaseLocomotive) continue; + if (spotted.Contains(car)) 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) && _counts.TryGetValue(destId, out var destN)) + _counts[destId] = (destN.inbound + 1, destN.working, destN.ready, destN.outbound); + try + { + string destComp = w.Destination.Identifier; + if (!string.IsNullOrEmpty(destComp) && _compCounts.TryGetValue(destComp, out var destC)) + _compCounts[destComp] = (destC.inbound + 1, destC.working, destC.ready, destC.outbound); + } + catch { } + + if (!string.IsNullOrEmpty(originId) && originId != destId && _counts.TryGetValue(originId, out var originN)) + _counts[originId] = (originN.inbound, originN.working, originN.ready, originN.outbound + 1); + try + { + if (w.Origin.HasValue) + { + string originComp = w.Origin.Value.Identifier; + if (!string.IsNullOrEmpty(originComp) && originId != destId && + _compCounts.TryGetValue(originComp, out var originC)) + _compCounts[originComp] = (originC.inbound, originC.working, originC.ready, originC.outbound + 1); + } + } + catch { } + } + } + + static void CountOnSite( + OpsController ops, Industry industry, + Dictionary componentToIndustry, HashSet spotted) + { + try + { + foreach (IndustryComponent ic in industry.Components) + { + if (ic == null || !HasWorkSpans(ic)) continue; + List here; + try + { + here = new List(); + foreach (Car car in ops.CarsAtPosition(ic)) + if (car != null) here.Add(car); + } + catch { continue; } + + foreach (Car car in here) + { + if (car is BaseLocomotive) continue; + OnSiteKind kind = ClassifyOnSite(car, ic, industry.identifier, componentToIndustry); + if (kind == OnSiteKind.Skip) continue; + + if (!string.IsNullOrEmpty(ic.Identifier) && _compCounts.TryGetValue(ic.Identifier, out var cn)) + { + if (kind == OnSiteKind.Working) + _compCounts[ic.Identifier] = (cn.inbound, cn.working + 1, cn.ready, cn.outbound); + else + _compCounts[ic.Identifier] = (cn.inbound, cn.working, cn.ready + 1, cn.outbound); + } + + if (!spotted.Add(car)) continue; + if (!_counts.TryGetValue(industry.identifier, out var n)) continue; + if (kind == OnSiteKind.Working) + _counts[industry.identifier] = (n.inbound, n.working + 1, n.ready, n.outbound); + else + _counts[industry.identifier] = (n.inbound, n.working, n.ready + 1, n.outbound); + } + } + } + catch { } + } + + enum OnSiteKind { Skip, Working, Ready } + + static OnSiteKind ClassifyOnSite( + Car car, IndustryComponent ic, string industryId, + Dictionary componentToIndustry) + { + try + { + Waybill? wb = car.Waybill; + string? destComp = null; + string? originComp = null; + if (wb.HasValue) + { + Waybill w = wb.Value; + destComp = w.Destination.Identifier; + if (w.Origin.HasValue) originComp = w.Origin.Value.Identifier; + } + + string? destId = TryIndustryId(componentToIndustry, destComp); + string? originId = TryIndustryId(componentToIndustry, originComp); + bool destHere = destId == industryId; + bool originHere = originId == industryId; + + Load? load = ic is IndustryUnloader unloader ? unloader.load + : ic is IndustryLoaderBase loader ? loader.load + : null; + + try + { + if (ic.carTypeFilter != null && !ic.carTypeFilter.IsEmpty && + !ic.carTypeFilter.Matches(car.CarType)) + return OnSiteKind.Skip; + } + catch { } + + bool empty = true; + try { empty = car.IsLoadEmpty(); } + catch { } + + bool fullOfTarget = false; + if (load != null) + { + try + { + var (qty, cap) = car.QuantityCapacityOfLoad(load); + fullOfTarget = cap > 0.001f && qty >= cap - 0.001f; + } + catch { } + } + + if (ic is IndustryUnloader) + { + if (!empty) return OnSiteKind.Working; + return OnSiteKind.Ready; + } + + if (ic is IndustryLoaderBase) + { + if (empty || !fullOfTarget) return OnSiteKind.Working; + return OnSiteKind.Ready; + } + + if (!empty && destHere) return OnSiteKind.Working; + if (originHere && !destHere) return OnSiteKind.Ready; + if (empty && destHere) return OnSiteKind.Ready; + return OnSiteKind.Skip; + } + catch { return OnSiteKind.Skip; } + } + + static string? TryIndustryId(Dictionary map, string? componentId) + { + if (string.IsNullOrEmpty(componentId)) return null; + return map.TryGetValue(componentId, out var id) ? id : null; + } + + static bool HasWorkSpans(IndustryComponent ic) + { + if (IsSkippedType(ic) || IsUtilityName(ic.DisplayName)) return false; + return ic.trackSpans != null && ic.trackSpans.Length > 0; + } + + static string StallReason(Industry industry) + { + try + { + var warnings = industry.Storage?.Warnings; + if (warnings != null) + { + foreach (var kv in warnings) + { + if (string.IsNullOrEmpty(kv.Value)) continue; + return FormatStall(kv.Value); + } + } + } + catch { } + + try + { + if (industry.usesContract && !industry.HasActiveContract(TimeWeather.Now)) + return "Needs contract"; + } + catch { } + + try + { + if (industry.GetContractMultiplier() < 0.001f) + return ""; + var missing = new List(); + foreach (var (ic, ctx) in industry.EnumerateComponentContexts(0f)) + { + if (ic is not FormulaicIndustryComponent formula) continue; + if (formula.inputTerms == null) continue; + foreach (var term in formula.inputTerms) + { + if (term?.load == null) continue; + if (ctx.QuantityInStorage(term.load) > 0.001f) continue; + string desc = term.load.description; + if (!string.IsNullOrEmpty(desc) && !missing.Contains(desc)) + missing.Add(desc); + } + } + if (missing.Count > 0) + return "Needs " + LowerRest(string.Join(", ", missing)); + } + catch { } + + return ""; + } + + static string FormatStall(string warning) + { + const string prefix = "Production Stopped: "; + if (warning.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase)) + return "Needs " + LowerRest(warning.Substring(prefix.Length).Trim()); + return warning; + } + + static string LowerRest(string text) + { + if (string.IsNullOrEmpty(text)) return text; + return char.ToLowerInvariant(text[0]) + text.Substring(1); + } + + static bool IncludeComponentFields(IndustryComponent ic, IndustryTagsSettings s) + { + if (ic is IndustryUnloader) return s.showNeeds; + if (ic is IndustryLoaderBase) return s.showOutputs; + return s.showNeeds || s.showOutputs; + } + + static List<(IndustryComponent ic, Vector3 pos)> CollectAnchors( + IEnumerable components, HashSet handled) + { + var anchors = new List<(IndustryComponent ic, Vector3 pos)>(); + foreach (IndustryComponent ic in components) + { + if (ic == null) continue; + handled.Add(ic); + if (!IsPlaceable(ic)) continue; + Vector3 pos = SpanCentroid(ic); + anchors.Add((ic, pos)); + } + return anchors; + } + + static void ClusterAnchors( + List dest, Industry? industry, string name, + List<(IndustryComponent ic, Vector3 pos)> anchors, float mergeDistance) + { + if (anchors.Count == 0) return; + var assigned = new bool[anchors.Count]; + int clusterIx = 0; + for (int i = 0; i < anchors.Count; i++) + { + if (assigned[i]) continue; + var members = new List<(IndustryComponent ic, Vector3 pos)> { anchors[i] }; + assigned[i] = true; + bool added; + do + { + added = false; + for (int j = i + 1; j < anchors.Count; j++) + { + if (assigned[j]) continue; + foreach (var m in members) + { + if (Vector3.Distance(anchors[j].pos, m.pos) < mergeDistance) + { + members.Add(anchors[j]); + assigned[j] = true; + added = true; + break; + } + } + } + } while (added); + + Vector3 sum = Vector3.zero; + var cluster = new BusinessCluster + { + Industry = industry, + Name = string.IsNullOrEmpty(name) ? "Industry" : name, + }; + var seen = new HashSet(); + foreach (var (ic, pos) in members) + { + sum += pos; + if (seen.Add(ic)) + cluster.Components.Add(ic); + } + cluster.GameCentroid = sum / members.Count; + cluster.Area = FindArea(industry, cluster.GameCentroid); + string id = industry != null ? industry.identifier : "orphan:" + cluster.Name; + cluster.Key = id + "#" + clusterIx; + dest.Add(cluster); + clusterIx++; + } + } + + static Area? FindArea(Industry? industry, Vector3 gamePos) + { + try + { + if (industry != null) + { + var parent = industry.GetComponentInParent(); + if (IsTownArea(parent)) return parent; + } + + var ops = OpsController.Shared; + if (ops != null && industry != null) + { + try + { + foreach (IndustryComponent ic in industry.Components) + { + if (ic == null) continue; + Area? via = ops.AreaForCarPosition(ic); + if (IsTownArea(via)) return via; + } + } + catch { } + + Area? best = null; + int bestCount = int.MaxValue; + foreach (Area area in ops.Areas) + { + if (!IsTownArea(area) || area.Industries == null) continue; + int n = 0; + bool owns = false; + foreach (Industry ind in area.Industries) + { + n++; + if (ind == industry) owns = true; + } + if (!owns) continue; + if (n < bestCount) + { + bestCount = n; + best = area; + } + } + if (best != null) return best; + } + + if (ops != null) + { + Area? closest = ops.ClosestAreaForGamePosition(gamePos); + if (IsTownArea(closest)) return closest; + } + } + catch { } + return null; + } + + static bool IsTownArea(Area? area) + { + if (area == null) return false; + if (string.Equals(area.identifier, "legos-global-industries", StringComparison.OrdinalIgnoreCase)) + return false; + return true; + } + + public static Color TagColor(Area? area) + { + if (area == null) return new Color(0.55f, 0.55f, 0.52f, 1f); + Color c = area.tagColor; + if (c.a < 0.05f || c.maxColorComponent < 0.05f) + return new Color(0.55f, 0.55f, 0.52f, 1f); + c.a = 1f; + return c; + } + + static Vector3 SpanCentroid(IndustryComponent ic) + { + Vector3 sum = Vector3.zero; + int n = 0; + TrackSpan[] spans = ic.trackSpans; + if (spans != null) + { + foreach (TrackSpan span in spans) + { + if (span == null) continue; + try + { + sum += span.GetCenterPoint(); + n++; + } + catch { } + } + } + if (n > 0) return sum / n; + return ic.CenterPoint; + } + + static bool IsPlaceable(IndustryComponent ic) + { + try + { + if (!ic.IsVisible) return false; + } + catch { return false; } + if (IsSkippedType(ic)) return false; + if (IsUtilityName(ic.DisplayName)) return false; + return ic.trackSpans != null && ic.trackSpans.Length > 0; + } + + static bool IsSkippedType(IndustryComponent ic) + { + return ic is ProgressionIndustryComponent + || ic is RepairTrack + || ic is Interchange + || ic is InterchangedIndustryLoader; + } + + static bool IsUtilityName(string name) + { + if (string.IsNullOrEmpty(name)) return false; + if (name.IndexOf(" Interchange", System.StringComparison.OrdinalIgnoreCase) >= 0 || + name.StartsWith("Interchange", System.StringComparison.OrdinalIgnoreCase)) + return true; + if (name.EndsWith(" Repair Track", System.StringComparison.OrdinalIgnoreCase) || + name.EndsWith(" Repair", System.StringComparison.OrdinalIgnoreCase)) + return true; + if (name.EndsWith(" Diesel Stand", System.StringComparison.OrdinalIgnoreCase) || + name.EndsWith(" Diesel", System.StringComparison.OrdinalIgnoreCase)) + return true; + if (name.EndsWith(" Coal Loader", System.StringComparison.OrdinalIgnoreCase) || + name.EndsWith(" Coaling Tower", System.StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + static string[] ExpandSpanNames(IndustryComponent ic) + { + string display = ic.DisplayName ?? ""; + TrackSpan[]? spans = ic.trackSpans; + if (spans == null || spans.Length <= 1) return new[] { display }; + string[] parts = display.Split('/'); + if (parts.Length != spans.Length) return new[] { display }; + string first = parts[0].Trim(); + int sp = first.LastIndexOf(' '); + string prefix = sp >= 0 ? first.Substring(0, sp + 1) : ""; + var result = new string[parts.Length]; + result[0] = first; + for (int i = 1; i < parts.Length; i++) + result[i] = prefix + parts[i].Trim(); + return result; + } + + static Vector3 SpanCenter(TrackSpan span, IndustryComponent ic) + { + try + { + Vector3 p = span.GetCenterPoint(); + if (p.sqrMagnitude > 0.01f) return p; + } + catch { } + return SpanCentroid(ic); + } + + static IEnumerable EnumerateSpotSpans(TrackSpot spot) + { + var seen = new HashSet(); + foreach (IndustryComponent ic in spot.Components) + { + TrackSpan[]? spans = ic != null ? ic.trackSpans : null; + if (spans == null) continue; + foreach (TrackSpan span in spans) + { + if (span == null) continue; + string id = span.id ?? ""; + if (id.Length > 0 && !seen.Add(id)) continue; + yield return span; + } + } + } + + static bool CarOnSpans(Car car, List spans) + { + Location loc; + try { loc = car.LocationA; } + catch { return false; } + foreach (TrackSpan span in spans) + { + if (span == null) continue; + try + { + if (span.Contains(loc)) return true; + } + catch { } + } + return false; + } + + static bool CarOnSiblingSpan(Car car, TrackSpot spot) + { + foreach (IndustryComponent ic in spot.Components) + { + TrackSpan[]? spans = ic != null ? ic.trackSpans : null; + if (spans == null) continue; + foreach (TrackSpan span in spans) + { + if (span == null) continue; + string id = span.id ?? ""; + if (id.Length > 0 && spot.SpanIds.Contains(id)) continue; + try + { + if (span.Contains(car.LocationA)) return true; + } + catch { } + } + } + return false; + } + + static string ParseTrackLabel(string displayName, string? businessName) + { + if (string.IsNullOrEmpty(displayName)) return "Track"; + string rest = displayName.Trim(); + if (!string.IsNullOrEmpty(businessName) && + rest.StartsWith(businessName, StringComparison.OrdinalIgnoreCase)) + { + rest = rest.Substring(businessName.Length).Trim(); + if (rest.StartsWith("-")) rest = rest.Substring(1).Trim(); + } + if (!string.IsNullOrEmpty(rest) && rest.Length <= 14) + return rest; + + int sp = displayName.LastIndexOf(' '); + if (sp >= 0) + { + string last = displayName.Substring(sp + 1); + if (LooksLikeTrackCode(last)) return last; + } + return string.IsNullOrEmpty(rest) ? displayName : rest; + } + + static bool LooksLikeTrackCode(string s) + { + if (string.IsNullOrEmpty(s) || s.Length > 8) return false; + int i = 0; + while (i < s.Length && char.IsLetter(s[i])) i++; + if (i == 0 || i > 4) return false; + int digits = 0; + while (i < s.Length && char.IsDigit(s[i])) { i++; digits++; } + if (digits == 0) return false; + if (i < s.Length && s[i] == '-') + { + i++; + if (i >= s.Length) return false; + while (i < s.Length && char.IsLetterOrDigit(s[i])) i++; + } + return i == s.Length; + } + + static string ParseBusinessName(string displayName) + { + if (string.IsNullOrEmpty(displayName)) return ""; + string name = displayName; + if (name.Contains('/')) + { + string first = name.Split('/')[0].Trim(); + int sp = first.LastIndexOf(' '); + name = sp >= 0 ? first.Substring(0, sp) : first; + } + int slash = name.IndexOf('/'); + if (slash >= 0) name = name.Substring(0, slash).Trim(); + int lastSp = name.LastIndexOf(' '); + if (lastSp >= 0 && IsTrackCode(name.Substring(lastSp + 1))) + return name.Substring(0, lastSp); + return name; + } + + static bool IsTrackCode(string s) + { + if (s.Length == 0 || s.Length > 4) return false; + int i = 0; + while (i < s.Length && char.IsLetter(s[i])) i++; + if (i == 0 || i > 3) return false; + while (i < s.Length && char.IsDigit(s[i])) i++; + return i == s.Length; + } +} diff --git a/src/Modules/IndustryTags/IndustryTagOverlay.cs b/src/Modules/IndustryTags/IndustryTagOverlay.cs new file mode 100644 index 0000000..f00cf07 --- /dev/null +++ b/src/Modules/IndustryTags/IndustryTagOverlay.cs @@ -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 _clusters = new(); + readonly List _tracks = new(); + readonly Dictionary _views = new(); + readonly Dictionary _trackViews = new(); + readonly List _slideSpots = new(); + readonly List _slideViews = new(); + readonly List _slideRects = new(); + readonly List _nextClusters = new(); + readonly List _nextTracks = new(); + readonly HashSet _seen = new(); + readonly HashSet _seenTracks = new(); + readonly List _drop = new(); + float[] _slideDeltaT = System.Array.Empty(); + float[] _slideDeltaLift = System.Array.Empty(); + bool[] _slideColliding = System.Array.Empty(); + bool[] _slideNear = System.Array.Empty(); + 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(); + _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(); + 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 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 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(); + if (_loader == null) return false; + _loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue(); + return _loadingScreen != null && _loadingScreen.activeInHierarchy; + } + catch { return false; } + } +} diff --git a/src/Modules/IndustryTags/IndustryTagView.cs b/src/Modules/IndustryTags/IndustryTagView.cs new file mode 100644 index 0000000..b761890 --- /dev/null +++ b/src/Modules/IndustryTags/IndustryTagView.cs @@ -0,0 +1,1449 @@ +using HarmonyLib; +using Helpers; +using Model; +using Model.Ops; +using System.Collections.Generic; +using TMPro; +using UI; +using UI.Tags; +using UnityEngine; +using UnityEngine.UI; + +namespace S3.Modules.IndustryTags; + +sealed class IndustryTagView : MonoBehaviour +{ + TagCallout? _callout; + Canvas? _calloutCanvas; + Canvas? _fallbackCanvas; + RectTransform? _fallbackRt; + TextMeshProUGUI? _body; + CanvasGroup? _group; + TMP_Text? _countsLabel; + RectTransform? _countsRow; + Image? _inArrow; + Image? _outArrow; + RectTransform? _contractRow; + TMP_Text? _tierLabel; + TMP_Text? _stripeTitle; + Image? _stripe; + RectTransform? _panel; + Image? _panelBg; + Image? _pointerImg; + bool _owned; + Image? _donutTrack; + Image? _donutFill; + Color _color = Color.gray; + float _yOffset = 80f; + float _scale = 0.4f; + float _opacity = 0.9f; + float _fallbackAfter; + Camera? _boundCamera; + Vector3 _world; + bool _hasWorld; + bool _poseCached; + bool _poseTrackBadge; + bool _poseYard; + float _poseTitleSize = -1f; + bool _layoutDirty = true; + bool _tintDirty = true; + float _lastAlpha = -1f; + string _bodySource = ""; + static TagCallout? _prefab; + static TMP_FontAsset? _font; + static TMP_SpriteAsset? _sprites; + + public Vector3 GamePos; + public bool TrackBadge; + public bool Yard; + public float TrackT = 0.5f; + public float HeightLift; + public float DetailRefreshAt; + public readonly List SpanIds = new(); + public string HighlightKey = ""; + public float Scale => _scale; + public bool IsHoverable => _vis * _edge * _distMul > 0.25f; + + float _vis; + float _visTarget; + float _edge = 1f; + float _distMul = 1f; + float _distMulTarget = 1f; + readonly Vector3[] _corners = new Vector3[4]; + + public Vector3 ClickWorld + { + get + { + if (_callout != null && _callout.canvasRectTransform != null) + return _callout.canvasRectTransform.position; + if (_fallbackRt != null) + return _fallbackRt.position; + return transform.position + Vector3.up * _yOffset; + } + } + + public bool TryScreenHit(Camera cam, Vector3 mouse, out float dist) + { + dist = 9999f; + Vector3 sp = cam.WorldToScreenPoint(ClickWorld); + if (sp.z <= 0f) return false; + dist = Vector2.Distance((Vector2)mouse, (Vector2)sp); + if (!IsHoverable) return false; + float radius = 40f + 120f * Mathf.Clamp(_scale, 0.1f, 2f); + return dist <= radius; + } + + public bool TryScreenRect(Camera cam, out Rect rect) + { + rect = default; + RectTransform? rt = FadeRect(); + if (rt == null) return false; + try { rt.GetWorldCorners(_corners); } + catch { return false; } + float minX = 99999f, minY = 99999f, maxX = -99999f, maxY = -99999f; + for (int i = 0; i < 4; i++) + { + Vector3 s = cam.WorldToScreenPoint(_corners[i]); + if (s.z <= 0f) return false; + if (s.x < minX) minX = s.x; + if (s.y < minY) minY = s.y; + if (s.x > maxX) maxX = s.x; + if (s.y > maxY) maxY = s.y; + } + rect = Rect.MinMaxRect(minX, minY, maxX, maxY); + return rect.width > 2f && rect.height > 2f; + } + + public void JumpCamera() + { + try + { + if (CameraSelector.shared != null) + CameraSelector.shared.ZoomToPoint(GamePos); + } + catch { } + } + + public bool Bind(string title, IndustryCatalog.TagDetails details, Color color, bool trackBadge = false) + { + bool kindChanged = TrackBadge != trackBadge; + TrackBadge = trackBadge; + EnsureVisual(); + EnsureDetailRows(); + if (_panel == null) return false; + if (kindChanged) + { + _poseCached = false; + _layoutDirty = true; + } + bool changed = Apply(title, details, color); + ApplyPose(); + return changed || kindChanged; + } + + public bool SetWorld(Vector3 world) + { + EnsureVisual(); + ApplyPose(); + if (_hasWorld && (world - _world).sqrMagnitude <= 0.000001f) + return false; + _hasWorld = true; + _world = world; + transform.position = world; + if (_callout != null) + _callout.SetPosition(world, immediate: true); + else + BillboardFallback(); + return true; + } + + void ApplyPose() + { + var s = IndustryTagsModule.Settings; + float nextY; + float nextScale; + float nextOpacity; + float titleSize; + if (TrackBadge) + { + nextY = Mathf.Clamp(s.trackHeightOffset, 1f, 250f) + Mathf.Clamp(HeightLift, 0f, 220f); + nextScale = Mathf.Clamp(s.trackScale, 0.08f, 2f); + nextOpacity = Mathf.Clamp(s.trackOpacity, 0.05f, 1f); + titleSize = Mathf.Clamp(s.trackTitleFontSize, 8f, 28f); + } + else + { + nextY = Mathf.Clamp(s.heightOffset, 1f, 250f); + nextScale = Mathf.Clamp(s.tagScale, 0.08f, 2f); + nextOpacity = Mathf.Clamp(s.opacity, 0.05f, 1f); + titleSize = Mathf.Clamp(s.titleFontSize, 8f, 28f); + } + bool poseChanged = !_poseCached + || _poseTrackBadge != TrackBadge + || _poseYard != Yard + || !Nearly(_yOffset, nextY) + || !Nearly(_scale, nextScale) + || !Nearly(_opacity, nextOpacity); + bool layoutChanged = !_poseCached + || _poseTrackBadge != TrackBadge + || _poseYard != Yard + || !Nearly(_poseTitleSize, titleSize); + _poseCached = true; + _poseTrackBadge = TrackBadge; + _poseYard = Yard; + _poseTitleSize = titleSize; + _yOffset = nextY; + _scale = nextScale; + _opacity = nextOpacity; + if (layoutChanged) _layoutDirty = true; + if (_callout != null) + { + if (poseChanged) + { + _callout.yOffset = _yOffset; + _callout.canvasScale = _scale; + } + } + FlushVisualChanges(); + } + + public void SetWanted(bool wanted) + { + _visTarget = wanted ? 1f : 0f; + if (wanted && !gameObject.activeSelf) + { + gameObject.SetActive(true); + RefreshCalloutCamera(); + } + } + + public void SetDistanceMul(float mul) + { + _distMulTarget = Mathf.Clamp01(mul); + } + + public void HideImmediate() + { + _vis = 0f; + _visTarget = 0f; + _edge = 1f; + _distMul = 1f; + _distMulTarget = 1f; + if (gameObject.activeSelf) + gameObject.SetActive(false); + } + + public void TickAppearance(Camera? cam, bool sampleEdge) + { + float dt = Time.unscaledDeltaTime; + if (cam != null && gameObject.activeSelf) + RefreshCalloutCamera(cam); + if (cam != null && sampleEdge) + { + float edgeTarget = EdgeAlpha(cam); + _edge = Mathf.MoveTowards(_edge, edgeTarget, 8f * dt); + } + _distMul = Mathf.MoveTowards(_distMul, _distMulTarget, 4f * dt); + float speed = _visTarget >= _vis ? 4.5f : 3.5f; + _vis = Mathf.MoveTowards(_vis, _visTarget, speed * dt); + float alpha = _opacity * _vis * _edge * _distMul; + if (_group != null && !Nearly(_lastAlpha, alpha, 0.0005f)) + { + _group.alpha = alpha; + _lastAlpha = alpha; + } + bool wantActive = _visTarget > 0f || _vis > 0.001f; + if (gameObject.activeSelf != wantActive) + { + gameObject.SetActive(wantActive); + if (wantActive) RefreshCalloutCamera(cam); + } + } + + void RefreshCalloutCamera(Camera? cam = null) + { + if (cam == null) + { + cam = Camera.main; + } + if (cam == null) + { + try { MainCameraHelper.TryGetIfNeeded(ref cam); } + catch { } + } + if (_boundCamera == cam) return; + _boundCamera = cam; + if (_callout != null) + { + try { Traverse.Create(_callout).Field("_camera").SetValue(cam); } + catch { } + if (_calloutCanvas != null) _calloutCanvas.worldCamera = cam; + } + if (_fallbackCanvas != null) + _fallbackCanvas.worldCamera = cam; + } + + float EdgeAlpha(Camera cam) + { + RectTransform? rt = FadeRect(); + if (rt == null) + return PointEdge(cam, ClickWorld, 0.12f); + + try { rt.GetWorldCorners(_corners); } + catch { return PointEdge(cam, ClickWorld, 0.12f); } + + float minX = 1f, minY = 1f, maxX = 0f, maxY = 0f; + int behind = 0; + int used = 0; + for (int i = 0; i < 4; i++) + { + Vector3 v = cam.WorldToViewportPoint(_corners[i]); + if (v.z <= 0f) + { + behind++; + continue; + } + used++; + if (v.x < minX) minX = v.x; + if (v.y < minY) minY = v.y; + if (v.x > maxX) maxX = v.x; + if (v.y > maxY) maxY = v.y; + } + if (behind >= 3 || used == 0) return 0f; + if (behind > 0) return PointEdge(cam, ClickWorld, 0.12f); + + float w = maxX - minX; + float h = maxY - minY; + if (w < 0.0001f || h < 0.0001f) return PointEdge(cam, ClickWorld, 0.12f); + + float visW = Mathf.Max(0f, Mathf.Min(maxX, 1f) - Mathf.Max(minX, 0f)); + float visH = Mathf.Max(0f, Mathf.Min(maxY, 1f) - Mathf.Max(minY, 0f)); + float visible = (visW * visH) / (w * h); + float clipped = 1f - Mathf.Clamp01(visible); + if (clipped <= 0.30f) return 1f; + return Mathf.InverseLerp(1f, 0.30f, clipped); + } + + RectTransform? FadeRect() + { + if (_panel != null) return _panel; + if (_callout != null && _callout.callout != null) + { + RectTransform? panel = _callout.callout.RectTransform; + if (panel == null) + panel = _callout.callout.GetComponent(); + if (panel != null) return panel; + } + if (_callout != null) return _callout.canvasRectTransform; + return _fallbackRt; + } + + static float PointEdge(Camera cam, Vector3 world, float pad) + { + Vector3 v = cam.WorldToViewportPoint(world); + if (v.z <= 0f) return 0f; + float dx = 0f; + if (v.x < pad) dx = (pad - v.x) / pad; + else if (v.x > 1f - pad) dx = (v.x - (1f - pad)) / pad; + float dy = 0f; + if (v.y < pad) dy = (pad - v.y) / pad; + else if (v.y > 1f - pad) dy = (v.y - (1f - pad)) / pad; + float t = Mathf.Max(dx, dy); + if (t <= 0f) return 1f; + return Mathf.Clamp01(1f - t); + } + + public void SetShown(bool shown) + { + SetWanted(shown); + } + + void LateUpdate() + { + if (_callout == null && _fallbackRt != null && gameObject.activeSelf) + BillboardFallback(); + } + + void EnsureVisual() + { + if (_callout != null || _fallbackCanvas != null) return; + if (TryAttachPrefab()) return; + if (_fallbackAfter <= 0f) _fallbackAfter = Time.unscaledTime + 3f; + if (Time.unscaledTime >= _fallbackAfter) + BuildFallback(); + } + + bool TryAttachPrefab() + { + TagCallout? prefab = Prefab(); + if (prefab == null) return false; + try + { + _callout = Object.Instantiate(prefab, transform); + _callout.gameObject.SetActive(true); + _callout.yOffset = _yOffset; + if (_callout.locationIndicatorHoverArea != null) + { + _callout.locationIndicatorHoverArea.enabled = false; + _callout.locationIndicatorHoverArea.spanIds?.Clear(); + _callout.locationIndicatorHoverArea.descriptors?.Clear(); + } + foreach (var ray in _callout.GetComponentsInChildren(true)) + ray.enabled = false; + foreach (var g in _callout.GetComponentsInChildren(true)) + g.raycastTarget = false; + CacheTmpAssets(_callout); + Canvas? canvas = null; + if (_callout.canvasRectTransform != null) + canvas = _callout.canvasRectTransform.GetComponent(); + if (canvas == null) + canvas = _callout.GetComponentInChildren(true); + _calloutCanvas = canvas; + GameObject groupHost = canvas != null ? canvas.gameObject : _callout.gameObject; + _group = groupHost.GetComponent(); + if (_group == null) + _group = groupHost.AddComponent(); + _group.alpha = 0f; + _group.blocksRaycasts = false; + _group.interactable = false; + BuildOwnedPanel(); + _boundCamera = null; + return true; + } + catch + { + if (_callout != null) + { + Object.Destroy(_callout.gameObject); + _callout = null; + } + return false; + } + } + + void BuildFallback() + { + var canvasGo = new GameObject("Canvas", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler)); + _fallbackRt = (RectTransform)canvasGo.transform; + _fallbackRt.SetParent(transform, false); + _fallbackRt.sizeDelta = new Vector2(240f, 90f); + _fallbackRt.pivot = new Vector2(0.5f, 0f); + _fallbackCanvas = canvasGo.GetComponent(); + _fallbackCanvas.renderMode = RenderMode.WorldSpace; + _fallbackCanvas.worldCamera = Camera.main; + _fallbackCanvas.overrideSorting = true; + _fallbackCanvas.sortingOrder = 80; + canvasGo.GetComponent().dynamicPixelsPerUnit = 10f; + _group = canvasGo.GetComponent(); + if (_group == null) + _group = canvasGo.AddComponent(); + _group.alpha = 0f; + _group.blocksRaycasts = false; + _group.interactable = false; + BuildOwnedPanel(); + } + + bool Apply(string title, IndustryCatalog.TagDetails details, Color color) + { + BuildOwnedPanel(); + if (_panel == null) return false; + bool changed = false; + string safeTitle = string.IsNullOrEmpty(title) ? "Industry" : title.NoParse(); + changed |= SetText(_stripeTitle, safeTitle); + changed |= ApplyDetailRows(details); + if (_color != color) + { + _color = color; + _tintDirty = true; + changed = true; + } + if (changed) _layoutDirty = true; + return changed; + } + + void FlushVisualChanges() + { + if (_tintDirty) + { + ApplyTint(); + _tintDirty = false; + } + if (_layoutDirty) + { + LayoutOwnedPanel(); + _layoutDirty = false; + } + } + + void ApplyTint() + { + Color c = _color; + c.a = 1f; + if (_stripe != null) _stripe.color = c; + ColorTitle(); + Color ink = new Color(0.92f, 0.90f, 0.84f, 1f); + if (_inArrow != null) _inArrow.color = ink; + if (_outArrow != null) _outArrow.color = ink; + TintDonut(c); + } + + void ColorTitle() + { + if (_stripeTitle == null) return; + _stripeTitle.color = ContrastOn(_color); + } + + static Color ContrastOn(Color bg) + { + float lum = 0.299f * bg.r + 0.587f * bg.g + 0.114f * bg.b; + return lum > 0.55f + ? new Color(0.12f, 0.11f, 0.10f, 1f) + : Color.white; + } + + void EnsureDetailRows() + { + BuildOwnedPanel(); + } + + void BuildOwnedPanel() + { + if (_owned && _panel != null) return; + + RectTransform? host = null; + if (_callout != null && _callout.canvasRectTransform != null) + host = _callout.canvasRectTransform; + else if (_fallbackRt != null) + host = _fallbackRt; + if (host == null) return; + + CacheHostFont(); + HideVanillaCallout(); + + _panel = NewRt(host, "S3Panel"); + _panel.anchorMin = new Vector2(0.5f, 0f); + _panel.anchorMax = new Vector2(0.5f, 0f); + _panel.pivot = new Vector2(0.5f, 0f); + _panel.anchoredPosition = Vector2.zero; + + _panelBg = MakeImage(_panel, "Bg", new Color(0.10f, 0.10f, 0.09f, 0.92f)); + _panelBg.sprite = PanelSprite(); + _panelBg.type = Image.Type.Sliced; + _panelBg.fillCenter = true; + + _stripe = MakeImage(_panel, "Stripe", _color); + _stripe.sprite = HeaderSprite(); + _stripe.type = Image.Type.Sliced; + _stripe.fillCenter = true; + + _stripeTitle = MakeTmp(_stripe.rectTransform, "Title", 14f, FontStyles.Bold, TextAlignmentOptions.Center); + _stripeTitle.enableWordWrapping = false; + _stripeTitle.overflowMode = TextOverflowModes.Overflow; + + _countsRow = NewRt(_panel, "CountsRow"); + var countsLayout = _countsRow.gameObject.AddComponent(); + countsLayout.spacing = 4f; + countsLayout.childAlignment = TextAnchor.MiddleCenter; + countsLayout.childForceExpandWidth = false; + countsLayout.childForceExpandHeight = false; + countsLayout.childControlWidth = true; + countsLayout.childControlHeight = true; + Color arrowC = new Color(0.92f, 0.90f, 0.84f, 1f); + _inArrow = MakeCountArrow(_countsRow, "InArrow", arrowC); + _countsLabel = MakeTmp(_countsRow, "Counts", 12f, FontStyles.Normal, TextAlignmentOptions.Midline); + _countsLabel.enableWordWrapping = false; + _countsLabel.overflowMode = TextOverflowModes.Overflow; + var countsLe = _countsLabel.gameObject.AddComponent(); + countsLe.flexibleWidth = 0f; + _outArrow = MakeCountArrow(_countsRow, "OutArrow", arrowC); + + if (!TrackBadge) + _contractRow = MakeContractRow(_panel, 12f); + + _body = MakeTmp(_panel, "Body", 12f, FontStyles.Normal, TextAlignmentOptions.TopLeft); + _body.overflowMode = TextOverflowModes.Overflow; + _body.color = new Color(0.86f, 0.84f, 0.78f, 1f); + + _pointerImg = MakeImage(_panel, "Pointer", new Color(0.10f, 0.10f, 0.09f, 0.92f)); + _pointerImg.sprite = PointerSprite(); + _pointerImg.preserveAspect = false; + _pointerImg.type = Image.Type.Simple; + + FaceLikeVanilla(); + _owned = true; + _poseCached = false; + _layoutDirty = true; + _tintDirty = true; + } + + void CacheHostFont() + { + PickFont(); + if (_callout != null) CacheTmpAssets(_callout); + } + + void HideVanillaCallout() + { + if (_callout == null) return; + foreach (Graphic g in _callout.GetComponentsInChildren(true)) + { + if (g == null) continue; + if (_panel != null && g.transform.IsChildOf(_panel)) continue; + g.enabled = false; + } + foreach (var mask in _callout.GetComponentsInChildren(true)) + mask.enabled = false; + foreach (var mask in _callout.GetComponentsInChildren(true)) + mask.enabled = false; + Callout? callout = _callout.callout; + if (callout == null) return; + foreach (var lg in callout.GetComponents()) + lg.enabled = false; + var csf = callout.GetComponent(); + if (csf != null) csf.enabled = false; + } + + void FaceLikeVanilla() + { + if (_panel == null) return; + Transform? src = _callout != null && _callout.callout != null + ? _callout.callout.transform + : null; + if (src != null && src != _panel && src != _panel.parent) + { + Vector3 ls = src.localScale; + bool alreadyFlipped = ls.x < 0f + || Mathf.Abs(Mathf.DeltaAngle(src.localEulerAngles.y, 180f)) < 8f; + _panel.localRotation = src.localRotation; + _panel.localScale = new Vector3(ls.x < 0f ? -1f : 1f, ls.y < 0f ? -1f : 1f, 1f); + if (!alreadyFlipped) + _panel.localEulerAngles = new Vector3(0f, 180f, 0f); + return; + } + _panel.localEulerAngles = new Vector3(0f, 180f, 0f); + } + + void LayoutOwnedPanel() + { + if (!_owned || _panel == null) return; + + var s = IndustryTagsModule.Settings; + float titleSize = TrackBadge + ? Mathf.Clamp(s.trackTitleFontSize, 8f, 28f) + : Mathf.Clamp(s.titleFontSize, 8f, 28f); + float bodySize = Mathf.Max(10f, titleSize - 2f); + float padX = Yard ? 6f : TrackBadge ? 8f : 10f; + float padY = Yard ? 4f : TrackBadge ? 5f : 6f; + float gap = 3f; + float pointerH = Yard ? 8f : TrackBadge ? 10f : 12f; + + if (_stripeTitle != null) + { + _stripeTitle.enableAutoSizing = false; + _stripeTitle.fontSize = titleSize; + _stripeTitle.alignment = TextAlignmentOptions.Center; + _stripeTitle.enableWordWrapping = false; + _stripeTitle.ForceMeshUpdate(); + ColorTitle(); + } + if (_countsLabel != null) + { + _countsLabel.fontSize = bodySize; + _countsLabel.alignment = TextAlignmentOptions.Midline; + _countsLabel.ForceMeshUpdate(); + var le = _countsLabel.GetComponent(); + if (le != null) + { + le.preferredWidth = Mathf.Max(8f, _countsLabel.preferredWidth); + le.preferredHeight = Mathf.Max(bodySize, _countsLabel.preferredHeight); + } + } + float arrowH = _countsLabel != null + ? Mathf.Max(bodySize, _countsLabel.preferredHeight) + : Mathf.Max(bodySize + 2f, 14f); + SizeCountArrow(_inArrow, arrowH); + SizeCountArrow(_outArrow, arrowH); + + float titleW = _stripeTitle != null ? _stripeTitle.preferredWidth : 40f; + float titleH = _stripeTitle != null + ? Mathf.Max(titleSize, _stripeTitle.preferredHeight) + : titleSize; + float stripeH = titleH + padY; + + float countsW = 0f, countsH = 0f; + if (_countsRow != null && _countsRow.gameObject.activeSelf) + { + countsW = (_countsLabel != null ? _countsLabel.preferredWidth : 0f) + bodySize * 2.6f + 12f; + countsH = Mathf.Max(bodySize + 2f, 16f); + } + + float contractW = 0f, contractH = 0f; + if (_contractRow != null && _contractRow.gameObject.activeSelf) + { + contractW = 170f; + contractH = 36f; + } + + float innerW = titleW + padX * 2f; + if (countsW + padX * 2f > innerW) innerW = countsW + padX * 2f; + if (contractW + padX * 2f > innerW) innerW = contractW + padX * 2f; + innerW = Mathf.Clamp(innerW, Yard ? 44f : TrackBadge ? 86f : 130f, 440f); + + float bodyH = 0f; + if (_body != null && _body.gameObject.activeSelf && !string.IsNullOrEmpty(_body.text)) + { + _body.fontSize = bodySize; + _body.alignment = Yard ? TextAlignmentOptions.Center : TextAlignmentOptions.TopLeft; + if (!Yard) + FitInventoryLines(_body, innerW - padX * 2f); + _body.enableWordWrapping = false; + _body.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, innerW - padX * 2f); + _body.ForceMeshUpdate(); + bodyH = Mathf.Max(bodySize, _body.preferredHeight); + if (Yard && _body.preferredWidth + padX * 2f > innerW) + innerW = Mathf.Min(440f, _body.preferredWidth + padX * 2f); + } + else if (_body != null) + _body.gameObject.SetActive(false); + + float y = stripeH; + float boxH = stripeH; + if (countsH > 0f) { y += gap; boxH = y + countsH; y = boxH; } + if (contractH > 0f) { y += gap; boxH = y + contractH; y = boxH; } + if (bodyH > 0f) { y += gap; boxH = y + bodyH; y = boxH; } + boxH += padY; + float totalH = boxH + pointerH; + _panel.sizeDelta = new Vector2(innerW, totalH); + + if (_panelBg != null) + { + var bg = _panelBg.rectTransform; + bg.anchorMin = Vector2.zero; + bg.anchorMax = Vector2.one; + bg.offsetMin = new Vector2(0f, pointerH); + bg.offsetMax = Vector2.zero; + } + + if (_stripe != null) + { + var st = _stripe.rectTransform; + st.anchorMin = new Vector2(0f, 1f); + st.anchorMax = new Vector2(1f, 1f); + st.pivot = new Vector2(0.5f, 1f); + st.anchoredPosition = Vector2.zero; + st.sizeDelta = new Vector2(0f, stripeH); + } + if (_stripeTitle != null) + { + var tr = _stripeTitle.rectTransform; + tr.anchorMin = Vector2.zero; + tr.anchorMax = Vector2.one; + tr.offsetMin = new Vector2(padX, 2f); + tr.offsetMax = new Vector2(-padX, -2f); + } + + float top = stripeH + gap; + PlaceRow(_countsRow, countsH > 0f, ref top, innerW, padX, countsH, gap); + PlaceRow(_contractRow, contractH > 0f, ref top, innerW, padX, contractH, gap); + PlaceRow(_body != null ? _body.rectTransform : null, bodyH > 0f, ref top, innerW, padX, bodyH, gap); + + if (_pointerImg != null) + { + _pointerImg.enabled = true; + _pointerImg.color = new Color(0.10f, 0.10f, 0.09f, 0.92f); + var pr = _pointerImg.rectTransform; + pr.SetAsLastSibling(); + pr.anchorMin = new Vector2(0.5f, 0f); + pr.anchorMax = new Vector2(0.5f, 0f); + pr.pivot = new Vector2(0.5f, 1f); + pr.anchoredPosition = new Vector2(0f, pointerH); + pr.sizeDelta = new Vector2(pointerH * 1.6f, pointerH); + } + + if (_callout != null && _callout.canvasRectTransform != null) + _callout.canvasRectTransform.sizeDelta = _panel.sizeDelta; + else if (_fallbackRt != null) + _fallbackRt.sizeDelta = _panel.sizeDelta; + + FaceLikeVanilla(); + } + + static void PlaceRow(RectTransform? rt, bool show, ref float top, float innerW, float padX, float height, float gap) + { + if (rt == null) return; + rt.gameObject.SetActive(show); + if (!show) return; + rt.anchorMin = new Vector2(0.5f, 1f); + rt.anchorMax = new Vector2(0.5f, 1f); + rt.pivot = new Vector2(0.5f, 1f); + rt.anchoredPosition = new Vector2(0f, -top); + rt.sizeDelta = new Vector2(Mathf.Max(8f, innerW - padX * 2f), height); + top += height + gap; + } + + static void FitInventoryLines(TMP_Text tmp, float maxWidth) + { + if (tmp == null || string.IsNullOrEmpty(tmp.text) || maxWidth < 8f) return; + string[] lines = tmp.text.Split('\n'); + bool changed = false; + for (int i = 0; i < lines.Length; i++) + { + string fitted = FitLineToWidth(tmp, lines[i], maxWidth); + if (fitted != lines[i]) + { + lines[i] = fitted; + changed = true; + } + } + if (changed) + tmp.text = string.Join("\n", lines); + } + + static string FitLineToWidth(TMP_Text tmp, string line, float maxWidth) + { + if (string.IsNullOrEmpty(line)) return line; + if (LineWidth(tmp, line) <= maxWidth) return line; + + string[] words = line.Split(' '); + for (int keep = 8; keep >= 4; keep--) + { + string trial = AbbreviateWords(words, keep); + if (LineWidth(tmp, trial) <= maxWidth) + return trial; + } + + string cut = line; + while (cut.Length > 4 && LineWidth(tmp, cut + "\u2026") > maxWidth) + cut = cut.Substring(0, cut.Length - 1); + return cut + "\u2026"; + } + + static string AbbreviateWords(string[] words, int keep) + { + var parts = new string[words.Length]; + for (int i = 0; i < words.Length; i++) + { + string w = words[i]; + if (CanShortenWord(w) && w.Length > keep) + parts[i] = w.Substring(0, keep) + "."; + else + parts[i] = w; + } + return string.Join(" ", parts); + } + + static bool CanShortenWord(string w) + { + if (string.IsNullOrEmpty(w) || w.Length < 6) return false; + if (w[0] == '<') return false; + if (char.IsDigit(w[0])) return false; + if (w == "lb" || w == "gal") return false; + for (int i = 0; i < w.Length; i++) + { + char c = w[i]; + if (!char.IsLetter(c) && c != '-' && c != '\'') + return false; + } + return true; + } + + static float LineWidth(TMP_Text tmp, string line) + { + try + { + Vector2 size = tmp.GetPreferredValues(line, 4000f, 0f); + return size.x; + } + catch + { + tmp.text = line; + tmp.ForceMeshUpdate(); + return tmp.preferredWidth; + } + } + + void TintDonut(Color c) + { + if (_donutFill != null) + _donutFill.color = c; + if (_donutTrack != null) + _donutTrack.color = new Color(c.r, c.g, c.b, 0.28f); + } + + bool ApplyDetailRows(IndustryCatalog.TagDetails details) + { + bool changed = false; + if (_countsRow != null) + { + bool show = !string.IsNullOrEmpty(details.Counts); + changed |= SetActive(_countsRow.gameObject, show); + if (show && _countsLabel != null) + { + changed |= SetText(_countsLabel, details.Counts); + } + } + + if (_contractRow != null) + { + bool show = !TrackBadge && details.ContractTier.HasValue; + changed |= SetActive(_contractRow.gameObject, show); + if (show) + { + if (_tierLabel != null) + changed |= SetText(_tierLabel, details.ContractTier.Value.ToString()); + if (_donutFill != null) + { + float fill = Mathf.Clamp01(details.Performance); + if (!Nearly(_donutFill.fillAmount, fill)) + { + _donutFill.fillAmount = fill; + changed = true; + } + } + } + } + + if (_body != null) + { + bool show = !string.IsNullOrEmpty(details.Body); + changed |= SetActive(_body.gameObject, show); + if (show && _bodySource != details.Body) + { + _bodySource = details.Body; + _body.text = details.Body; + changed = true; + } + else if (!show && _bodySource.Length > 0) + { + _bodySource = ""; + changed = true; + } + } + return changed; + } + + static bool SetText(TMP_Text? label, string? text) + { + if (label == null) return false; + string next = text ?? ""; + if (label.text == next) return false; + label.text = next; + return true; + } + + static bool SetActive(GameObject go, bool active) + { + if (go.activeSelf == active) return false; + go.SetActive(active); + return true; + } + + static bool Nearly(float a, float b, float epsilon = 0.0001f) => + Mathf.Abs(a - b) <= epsilon; + + RectTransform MakeContractRow(RectTransform parent, float fontSize) + { + var go = new GameObject("Contract", typeof(RectTransform), typeof(HorizontalLayoutGroup), typeof(LayoutElement)); + var rt = (RectTransform)go.transform; + rt.SetParent(parent, false); + var h = go.GetComponent(); + h.spacing = 6f; + h.childAlignment = TextAnchor.MiddleCenter; + h.childForceExpandWidth = false; + h.childForceExpandHeight = false; + h.childControlWidth = false; + h.childControlHeight = false; + var rowLe = go.GetComponent(); + rowLe.flexibleWidth = 1f; + rowLe.minHeight = 36f; + rowLe.preferredHeight = 36f; + + var prefix = MakeTmp(rt, "Prefix", fontSize, FontStyles.Normal, TextAlignmentOptions.MidlineRight); + prefix.text = "Contract: Tier"; + prefix.enableWordWrapping = false; + prefix.overflowMode = TextOverflowModes.Overflow; + var prefixRt = prefix.rectTransform; + prefixRt.sizeDelta = new Vector2(108f, 24f); + var prefixLe = prefix.gameObject.AddComponent(); + prefixLe.preferredWidth = 108f; + prefixLe.preferredHeight = 24f; + prefixLe.minWidth = 90f; + + var donut = new GameObject("Donut", typeof(RectTransform), typeof(LayoutElement)); + var donutRt = (RectTransform)donut.transform; + donutRt.SetParent(rt, false); + donutRt.sizeDelta = new Vector2(36f, 36f); + var donutLe = donut.GetComponent(); + donutLe.minWidth = 36f; + donutLe.minHeight = 36f; + donutLe.preferredWidth = 36f; + donutLe.preferredHeight = 36f; + donutLe.flexibleWidth = 0f; + + Sprite ring = RingSprite(); + _donutTrack = MakeImage(donutRt, "Track", new Color(0.73f, 0.70f, 0.62f, 0.28f)); + Stretch(_donutTrack.rectTransform, 0f); + _donutTrack.sprite = ring; + _donutTrack.preserveAspect = true; + _donutTrack.type = Image.Type.Simple; + + _donutFill = MakeImage(donutRt, "Fill", Color.white); + Stretch(_donutFill.rectTransform, 0f); + _donutFill.sprite = ring; + _donutFill.preserveAspect = true; + _donutFill.type = Image.Type.Filled; + _donutFill.fillMethod = Image.FillMethod.Radial360; + _donutFill.fillOrigin = (int)Image.Origin360.Top; + _donutFill.fillClockwise = true; + _donutFill.fillAmount = 0f; + + var tier = MakeTmp(donutRt, "Tier", Mathf.Max(10f, fontSize - 3f), FontStyles.Bold, TextAlignmentOptions.Center); + Stretch(tier.rectTransform, 0f); + tier.enableWordWrapping = false; + tier.overflowMode = TextOverflowModes.Overflow; + _tierLabel = tier; + return rt; + } + + static void StyleFrom(TMP_Text? dst, TMP_Text src) + { + if (dst == null || src == null) return; + if (src.font != null) dst.font = src.font; + dst.fontSize = src.fontSize; + dst.color = src.color; + if (src.spriteAsset != null) dst.spriteAsset = src.spriteAsset; + dst.raycastTarget = false; + } + + static Sprite? _ringSprite; + + static Sprite RingSprite() + { + if (_ringSprite != null) return _ringSprite; + const int size = 64; + var tex = new Texture2D(size, size, TextureFormat.ARGB32, false); + tex.wrapMode = TextureWrapMode.Clamp; + tex.filterMode = FilterMode.Bilinear; + tex.hideFlags = HideFlags.HideAndDontSave; + float cx = (size - 1) * 0.5f; + float cy = (size - 1) * 0.5f; + float outer = size * 0.48f; + float inner = size * 0.24f; + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + float dx = x - cx; + float dy = y - cy; + float d = Mathf.Sqrt(dx * dx + dy * dy); + float outerA = Mathf.Clamp01(outer - d + 0.75f); + float innerA = Mathf.Clamp01(d - inner + 0.75f); + float a = Mathf.Min(outerA, innerA); + tex.SetPixel(x, y, new Color(1f, 1f, 1f, a)); + } + } + tex.Apply(false, false); + _ringSprite = Sprite.Create(tex, new Rect(0f, 0f, size, size), new Vector2(0.5f, 0.5f), size); + _ringSprite.hideFlags = HideFlags.HideAndDontSave; + return _ringSprite; + } + + void BillboardFallback() + { + Camera? cam = Camera.main; + if (cam == null || _fallbackRt == null) return; + float y = Quaternion.LookRotation(cam.transform.position - _fallbackRt.position).eulerAngles.y; + _fallbackRt.rotation = Quaternion.Euler(0f, y, 0f); + _fallbackRt.localPosition = Vector3.up * _yOffset; + _fallbackRt.localScale = Vector3.one * (0.03f * _scale); + if (_fallbackCanvas != null && _fallbackCanvas.worldCamera == null) + _fallbackCanvas.worldCamera = cam; + } + + static TagCallout? Prefab() + { + if (_prefab != null) return _prefab; + try + { + TagController? tc = TagController.Shared; + if (tc == null) return null; + _prefab = Traverse.Create(tc).Field("tagCalloutPrefab").GetValue(); + } + catch { _prefab = null; } + return _prefab; + } + + static void CacheTmpAssets(TagCallout tag) + { + if (_sprites != null) return; + var labels = tag.GetComponentsInChildren(true); + foreach (var t in labels) + { + if (t == null) continue; + if (_sprites == null && t.spriteAsset != null) _sprites = t.spriteAsset; + } + } + + static TMP_FontAsset? PickFont() + { + if (_font != null) return _font; + TMP_FontAsset? sans = null; + TMP_FontAsset? fallback = null; + try + { + var loaded = Resources.FindObjectsOfTypeAll(); + if (loaded != null) + { + foreach (TMP_FontAsset f in loaded) + { + if (f == null) continue; + string n = f.name ?? ""; + if (LooksSerif(n)) continue; + fallback ??= f; + if (LooksSans(n)) + { + sans = f; + break; + } + } + } + } + catch { } + if (sans == null) + { + try + { + foreach (TMP_Text t in Object.FindObjectsOfType()) + { + if (t == null || t.font == null) continue; + if (t.GetComponentInParent() != null) continue; + string n = t.font.name ?? ""; + if (LooksSerif(n)) continue; + fallback ??= t.font; + if (LooksSans(n)) + { + sans = t.font; + break; + } + } + } + catch { } + } + if (sans == null) + { + try { sans = TMP_Settings.defaultFontAsset; } + catch { } + } + _font = sans ?? fallback; + return _font; + } + + static bool LooksSerif(string name) + { + string n = name.ToLowerInvariant(); + if (n.Contains("sans")) return false; + return n.Contains("serif") + || n.Contains("times") + || n.Contains("garamond") + || n.Contains("georgia") + || n.Contains("imprint") + || n.Contains("callout") + || n.Contains("tagfont"); + } + + static bool LooksSans(string name) + { + string n = name.ToLowerInvariant(); + return n.Contains("sans") + || n.Contains("roboto") + || n.Contains("liberation") + || n.Contains("noto") + || n.Contains("inter") + || n.Contains("arial") + || n.Contains("segoe") + || n.Contains("hud"); + } + + static RectTransform NewRt(RectTransform parent, string name) + { + var go = new GameObject(name, typeof(RectTransform)); + var rt = (RectTransform)go.transform; + rt.SetParent(parent, false); + return rt; + } + + static Sprite? _panelSprite; + static Sprite? _headerSprite; + static Sprite? _pointerSprite; + static Sprite? _arrowSprite; + + static Sprite PanelSprite() + { + if (_panelSprite != null) return _panelSprite; + _panelSprite = MakeRoundSprite(6, topOnly: false); + _panelSprite.name = "S3TagPanel"; + return _panelSprite; + } + + static Sprite HeaderSprite() + { + if (_headerSprite != null) return _headerSprite; + _headerSprite = MakeRoundSprite(6, topOnly: true); + _headerSprite.name = "S3TagHeader"; + return _headerSprite; + } + + static Sprite MakeRoundSprite(int radiusUi, bool topOnly) + { + const int aa = 8; + int r = radiusUi * aa; + int s = r * 2 + 32; + 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, topOnly); + 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); + var border = topOnly + ? new Vector4(r, 1f, r, r) + : new Vector4(r, r, r, r); + var sprite = Sprite.Create( + tex, new Rect(0f, 0f, s, s), new Vector2(0.5f, 0.5f), + ppu, 0, SpriteMeshType.FullRect, border); + sprite.hideFlags = HideFlags.HideAndDontSave; + return sprite; + } + + static float CoverRound(float x, float y, int s, int r, bool topOnly) + { + float dx = x < r ? r - x : (x > s - r ? x - (s - r) : 0f); + float dy; + if (topOnly) + dy = y > s - r ? y - (s - r) : 0f; + else + 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); + } + + static Sprite PointerSprite() + { + if (_pointerSprite != null) return _pointerSprite; + const int w = 32; + const int h = 24; + var tex = new Texture2D(w, h, TextureFormat.RGBA32, false) + { + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave, + }; + var px = new Color32[w * h]; + float x0 = 0f, x1 = w - 1f, xmid = (w - 1) * 0.5f; + float y0 = h - 1f, y1 = 0f; + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + float t = (y0 - y) / Mathf.Max(0.001f, y0 - y1); + t = Mathf.Clamp01(t); + float half = Mathf.Lerp((x1 - x0) * 0.5f, 0.35f, t); + float a = Mathf.Clamp01(half - Mathf.Abs(x - xmid) + 0.85f); + byte b = (byte)Mathf.Clamp(Mathf.RoundToInt(a * 255f), 0, 255); + px[y * w + x] = new Color32(255, 255, 255, b); + } + } + tex.SetPixels32(px); + tex.Apply(false, true); + _pointerSprite = Sprite.Create(tex, new Rect(0f, 0f, w, h), new Vector2(0.5f, 0.5f), 24f); + _pointerSprite.hideFlags = HideFlags.HideAndDontSave; + _pointerSprite.name = "S3TagPointer"; + return _pointerSprite; + } + + static Sprite ArrowSprite() + { + if (_arrowSprite != null) return _arrowSprite; + const int w = 40; + const int h = 20; + var tex = new Texture2D(w, h, TextureFormat.RGBA32, false) + { + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave, + }; + var px = new Color32[w * h]; + float cy = (h - 1) * 0.5f; + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + float a = CoverArrow(x + 0.5f, y + 0.5f, w, h, cy); + byte b = (byte)Mathf.Clamp(Mathf.RoundToInt(a * 255f), 0, 255); + px[y * w + x] = new Color32(255, 255, 255, b); + } + } + tex.SetPixels32(px); + tex.Apply(false, true); + _arrowSprite = Sprite.Create(tex, new Rect(0f, 0f, w, h), new Vector2(0.5f, 0.5f), 20f); + _arrowSprite.hideFlags = HideFlags.HideAndDontSave; + _arrowSprite.name = "S3CountArrow"; + return _arrowSprite; + } + + static float CoverArrow(float x, float y, int w, int h, float cy) + { + float shaft = CoverRect(x, y, 1.5f, cy - 1.85f, w * 0.62f, cy + 1.85f); + float head = CoverTri(x, y, w * 0.48f, cy - h * 0.42f, w * 0.48f, cy + h * 0.42f, w - 1.2f, cy); + return Mathf.Max(shaft, head); + } + + static float CoverRect(float x, float y, float x0, float y0, float x1, float y1) + { + float dx = x < x0 ? x0 - x : (x > x1 ? x - x1 : 0f); + float dy = y < y0 ? y0 - y : (y > y1 ? y - y1 : 0f); + if (dx == 0f && dy == 0f) return 1f; + return Mathf.Clamp01(1.05f - Mathf.Sqrt(dx * dx + dy * dy)); + } + + static float CoverTri(float px, float py, float ax, float ay, float bx, float by, float cx, float cy) + { + float d = DistPointToTri(px, py, ax, ay, bx, by, cx, cy); + return Mathf.Clamp01(0.85f - d); + } + + static float DistPointToTri(float px, float py, float ax, float ay, float bx, float by, float cx, float cy) + { + float d1 = Cross2(bx - ax, by - ay, px - ax, py - ay); + float d2 = Cross2(cx - bx, cy - by, px - bx, py - by); + float d3 = Cross2(ax - cx, ay - cy, px - cx, py - cy); + bool hasNeg = d1 < 0f || d2 < 0f || d3 < 0f; + bool hasPos = d1 > 0f || d2 > 0f || d3 > 0f; + if (!(hasNeg && hasPos)) + return -1f; + return Mathf.Min( + DistPointToSeg(px, py, ax, ay, bx, by), + DistPointToSeg(px, py, bx, by, cx, cy), + DistPointToSeg(px, py, cx, cy, ax, ay)); + } + + static float Cross2(float ax, float ay, float bx, float by) => ax * by - ay * bx; + + static float DistPointToSeg(float px, float py, float ax, float ay, float bx, float by) + { + float vx = bx - ax, vy = by - ay; + float wx = px - ax, wy = py - ay; + float c1 = vx * wx + vy * wy; + if (c1 <= 0f) return Mathf.Sqrt(wx * wx + wy * wy); + float c2 = vx * vx + vy * vy; + if (c2 <= c1) + { + float dx = px - bx, dy = py - by; + return Mathf.Sqrt(dx * dx + dy * dy); + } + float t = c1 / c2; + float dx2 = px - (ax + t * vx), dy2 = py - (ay + t * vy); + return Mathf.Sqrt(dx2 * dx2 + dy2 * dy2); + } + + static Image MakeCountArrow(RectTransform parent, string name, Color color) + { + Image img = MakeImage(parent, name, color); + img.sprite = ArrowSprite(); + img.preserveAspect = true; + img.type = Image.Type.Simple; + img.rectTransform.sizeDelta = new Vector2(16f, 10f); + var le = img.gameObject.AddComponent(); + le.preferredWidth = 16f; + le.preferredHeight = 10f; + le.minWidth = 14f; + le.minHeight = 8f; + le.flexibleWidth = 0f; + le.flexibleHeight = 0f; + return img; + } + + static void SizeCountArrow(Image? img, float lineH) + { + if (img == null) return; + float h = Mathf.Clamp(lineH, 10f, 22f); + float w = h * 1.65f; + var le = img.GetComponent(); + if (le != null) + { + le.preferredWidth = w; + le.preferredHeight = h; + le.minWidth = w; + le.minHeight = h; + } + img.rectTransform.sizeDelta = new Vector2(w, h); + } + + static Image MakeImage(RectTransform parent, string name, Color color) + { + var go = new GameObject(name, typeof(RectTransform), typeof(Image)); + var rt = (RectTransform)go.transform; + rt.SetParent(parent, false); + var img = go.GetComponent(); + img.color = color; + img.raycastTarget = false; + return img; + } + + static TextMeshProUGUI MakeTmp(RectTransform parent, string name, float size, FontStyles style, TextAlignmentOptions align) + { + var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI)); + var rt = (RectTransform)go.transform; + rt.SetParent(parent, false); + var tmp = go.GetComponent(); + PickFont(); + tmp.fontSize = size; + tmp.color = new Color(0.92f, 0.90f, 0.84f, 1f); + tmp.fontStyle = style; + tmp.alignment = align; + tmp.raycastTarget = false; + tmp.overflowMode = TextOverflowModes.Overflow; + if (_font != null) tmp.font = _font; + else + { + try + { + var def = TMP_Settings.defaultFontAsset; + if (def != null) tmp.font = def; + } + catch { } + } + if (_sprites != null) tmp.spriteAsset = _sprites; + return tmp; + } + + static void Stretch(RectTransform rt, float pad) + { + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.offsetMin = new Vector2(pad, pad); + rt.offsetMax = new Vector2(-pad, -pad); + } + + static void Stretch(RectTransform rt, Vector2 minPad, Vector2 maxPad) + { + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.offsetMin = minPad; + rt.offsetMax = new Vector2(-maxPad.x, -maxPad.y); + } +} diff --git a/src/Modules/IndustryTags/IndustryTagsDumpCommand.cs b/src/Modules/IndustryTags/IndustryTagsDumpCommand.cs new file mode 100644 index 0000000..400b30b --- /dev/null +++ b/src/Modules/IndustryTags/IndustryTagsDumpCommand.cs @@ -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(); } + 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(); + 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(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>(); + var outbound = new Dictionary>(); + 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 atPos = CarsAtPosition(ops, industry, stoppedOnly: false); + List 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 componentToIndustry, + List? inbound, + List? 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(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(); + 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 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? 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 CarsAtPosition(OpsController ops, Industry industry, bool stoppedOnly) + { + var list = new List(); + var seen = new HashSet(); + 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 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 map, string? componentId) + { + if (string.IsNullOrEmpty(componentId)) return null; + return map.TryGetValue(componentId, out var id) ? id : null; + } + + static void Add(Dictionary> dict, string key, Car car) + { + if (!dict.TryGetValue(key, out var list)) + dict[key] = list = new List(); + 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)"; + } +} diff --git a/src/Modules/IndustryTags/IndustryTagsModule.cs b/src/Modules/IndustryTags/IndustryTagsModule.cs new file mode 100644 index 0000000..62d35f5 --- /dev/null +++ b/src/Modules/IndustryTags/IndustryTagsModule.cs @@ -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(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(); + } + + 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; + } +} diff --git a/src/Modules/IndustryTags/IndustryTagsSettings.cs b/src/Modules/IndustryTags/IndustryTagsSettings.cs new file mode 100644 index 0000000..18b8c9a --- /dev/null +++ b/src/Modules/IndustryTags/IndustryTagsSettings.cs @@ -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; +} diff --git a/src/Modules/IndustryTags/IndustryTagsSettingsUI.cs b/src/Modules/IndustryTags/IndustryTagsSettingsUI.cs new file mode 100644 index 0000000..86bacbd --- /dev/null +++ b/src/Modules/IndustryTags/IndustryTagsSettingsUI.cs @@ -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("Industry Tags - 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("Visibility"); + 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("Contents"); + 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("Placement"); + 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("Track badges"); + 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("Yard tags"); + 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; + } +}