diff --git a/Info.json b/Info.json index 8acd767..73c9a46 100644 --- a/Info.json +++ b/Info.json @@ -2,7 +2,7 @@ "Id": "S3", "DisplayName": "S³ - Seton's Special Sauce", "Author": "seton", - "Version": "0.2.4", + "Version": "0.2.5", "ManagerVersion": "0.27.0", "GameVersionPoint": "0", "AssemblyName": "S3.dll", diff --git a/README.md b/README.md index 78c46c6..f2f2dd6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ Replaces the base-game map with a floating in-game overlay (toggle with **M** or ### Settings -Configure everything from the S³ UMM settings page: hotkey, drag-to-cancel follow behavior, right-click to recenter on player, theme, three independent opacity controls (Window, Map Elements, Map Background), the detach button, and a full per-element custom color editor. +Configure everything from the S³ UMM settings page: hotkey, drag-to-cancel follow behavior, right-click to recenter on player, theme, three independent opacity controls (Window, Map Elements, Map Background), icon culling and EOTD settings, the detach button, and a full per-element custom color editor. + +Most settings are also reachable directly from the gear menu on the map toolbar without opening UMM. ![Map Module settings panel](img/map/map_settings_umm.png) @@ -64,6 +66,16 @@ Rotate the map manually with the rotation control, or lock it to your player cam +### Icon Controls + +When zoomed out past a configurable threshold (default 40%), car icons are hidden and only locomotive icons remain. Locomotives scale up automatically so they stay easy to spot. A separate option hides MU trailing units, keeping just the lead locomotive per consist. A blinking red EOTD dot marks the rear of each consist, giving you a quick sense of train length without cluttering the map with car icons. + +All of these options are available from the gear menu Icons submenu on the map toolbar, or from the S³ UMM settings page. + + + +![Icon controls settings](img/map/map_icons_config.png) + ### Popout Window Pop the map into a detached native OS window. Drag it to any monitor, resize it freely, and pin it always-on-top via the window's right-click title bar menu. Re-attach it back into the game overlay at any time from the settings panel without losing your position, zoom, or rotation. diff --git a/dist/build-common.ps1 b/dist/build-common.ps1 index e5ff4f7..60adffe 100644 --- a/dist/build-common.ps1 +++ b/dist/build-common.ps1 @@ -84,5 +84,11 @@ function New-ModLayout { Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force if ($NativeDll) { Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force + # Ship ProggyClean.ttf alongside the native DLL so IMGUI_DISABLE_DEFAULT_FONT + # can load it at runtime without the base85 blob in the binary. + $fontSrc = Join-Path $RepoRoot "native\fonts\ProggyClean.ttf" + if (Test-Path $fontSrc) { + Copy-Item $fontSrc (Join-Path $DestModDir "ProggyClean.ttf") -Force + } } } diff --git a/img/map/map_icons_config.png b/img/map/map_icons_config.png new file mode 100644 index 0000000..f41ba0d Binary files /dev/null and b/img/map/map_icons_config.png differ diff --git a/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4 b/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4 new file mode 100644 index 0000000..984ee21 Binary files /dev/null and b/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4 differ diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 01fdf80..41427c8 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -31,6 +31,11 @@ target_include_directories(imgui PUBLIC # imgui_impl_dx11.cpp calls D3DCompile at runtime to build its own shaders. target_link_libraries(imgui PUBLIC d3d11 d3dcompiler) +# Remove the built-in ProggyClean base85 blob. That blob incidentally contains +# the 3-char string "UPX" which triggers AV false-positives on every release zip. +# We ship ProggyClean.ttf as a standalone file and load it at runtime instead. +target_compile_definitions(imgui PUBLIC IMGUI_DISABLE_DEFAULT_FONT) + # Suppress MSVC warnings inside third-party ImGui source. if(MSVC) target_compile_options(imgui PRIVATE /W0) diff --git a/native/fonts/ProggyClean.ttf b/native/fonts/ProggyClean.ttf new file mode 100644 index 0000000..0270cdf Binary files /dev/null and b/native/fonts/ProggyClean.ttf differ diff --git a/native/include/shared_types.h b/native/include/shared_types.h index 6f87834..8fca52e 100644 --- a/native/include/shared_types.h +++ b/native/include/shared_types.h @@ -41,6 +41,14 @@ enum UICmd : int32_t { TogglePanDisablesFollow = 20, // toggle whether panning cancels follow mode ToggleRightClickRecenter = 21, // toggle whether right-clicking recenters on player SetMapBgAlpha = 22, // set map camera clear-colour opacity; y = [0.0, 1.0] + // Icon culling + EOTD (gear menu Icons submenu) + ToggleIconCulling = 23, // toggle car-icon culling + SetCullThreshold = 24, // set cull zoom threshold; y = [0.3, 1.0] + ToggleHideMuLocos = 25, // toggle hiding MU trailing units + SetLocoBoostedScale = 26, // set loco icon boost when culling; y = [1.0, 3.0] + ToggleEotd = 27, // toggle EOTD dot visibility + ToggleEotdOnlyWhenCulled = 28, // toggle hiding EOTD when car icons are visible + SetEotdSizeScale = 29, // set EOTD dot size; y = [1.0, 10.0] }; // Bit indices for ME bool settings packed into PopoutWindow::imMEFlags. diff --git a/native/src/d3d11_renderer.cpp b/native/src/d3d11_renderer.cpp index e7e625f..78f09bc 100644 --- a/native/src/d3d11_renderer.cpp +++ b/native/src/d3d11_renderer.cpp @@ -276,9 +276,29 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) { { std::lock_guard lk(g_themeMutex); if (g_theme.accA == 0.f) g_theme = kS3DarkTheme; } ApplyThemeToStyle(g_theme); - // Fonts: default (ASCII) + merge the ⚙ gear glyph from Segoe UI Symbol. - // If seguisym.ttf is absent the button renders a box — fully functional fallback. - io.Fonts->AddFontDefault(); + // Locate ProggyClean.ttf relative to this DLL. IMGUI_DISABLE_DEFAULT_FONT strips + // the built-in base85 blob from the binary (that blob incidentally contains "UPX", + // which AV scanners mistake for a UPX-packed executable). We ship the TTF file + // alongside the DLL so end users see identical visual output. + { + char fontPath[MAX_PATH] = {}; + HMODULE self = GetModuleHandleA("S3Native.dll"); + if (self && GetModuleFileNameA(self, fontPath, MAX_PATH)) { + char* slash = strrchr(fontPath, '\\'); + if (slash) { *++slash = '\0'; strcat_s(fontPath, "ProggyClean.ttf"); } + } + // Fallback chain: mod dir → Consolas → Lucida Console. + // At least one must succeed or ImGui renders blank glyphs (no crash). + static const char* kFallbacks[] = { + "C:\\Windows\\Fonts\\consola.ttf", + "C:\\Windows\\Fonts\\lucon.ttf", + nullptr + }; + if (!fontPath[0] || !io.Fonts->AddFontFromFileTTF(fontPath, 13.f)) { + for (const char** fb = kFallbacks; *fb; ++fb) + if (io.Fonts->AddFontFromFileTTF(*fb, 13.f)) break; + } + } { ImFontConfig fc; fc.MergeMode = true; @@ -651,6 +671,69 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h, ImGui::Separator(); + if (ImGui::BeginMenu("Icons")) { + bool cullOn = win->imIconCullingEnabled.load(); + if (ImGui::MenuItem("Hide cars when zoomed out", nullptr, cullOn)) { + win->imIconCullingEnabled.store(!cullOn); + pushCmd(UICmd::ToggleIconCulling); + } + + ImGui::BeginDisabled(!cullOn); + + // Display as percentage (0.40 → 40%); store as fraction. + float threshPct = win->imCullThreshold.load() * 100.f; + ImGui::SetNextItemWidth(100.f); + if (ImGui::SliderFloat("Cull threshold##icons", &threshPct, 30.f, 100.f, "%.0f%%")) + win->imCullThreshold.store(threshPct / 100.f); + if (ImGui::IsItemDeactivatedAfterEdit()) + pushCmd(UICmd::SetCullThreshold, win->imCullThreshold.load()); + + ImGui::Separator(); + + bool hideMU = win->imHideMuLocos.load(); + if (ImGui::MenuItem("Hide MU trailing units", nullptr, hideMU)) { + win->imHideMuLocos.store(!hideMU); + pushCmd(UICmd::ToggleHideMuLocos); + } + + float boost = win->imLocoBoostedScale.load(); + ImGui::SetNextItemWidth(100.f); + if (ImGui::SliderFloat("Loco icon boost##icons", &boost, 1.0f, 3.0f, "%.1fx")) + win->imLocoBoostedScale.store(boost); + if (ImGui::IsItemDeactivatedAfterEdit()) + pushCmd(UICmd::SetLocoBoostedScale, win->imLocoBoostedScale.load()); + + ImGui::Separator(); + + bool eotdOn = win->imEotdEnabled.load(); + if (ImGui::MenuItem("Show EOTD", nullptr, eotdOn)) { + win->imEotdEnabled.store(!eotdOn); + pushCmd(UICmd::ToggleEotd); + } + + ImGui::BeginDisabled(!eotdOn); + + bool eotdWC = win->imEotdOnlyWhenCulled.load(); + if (ImGui::MenuItem("Hide EOTD when icons visible", nullptr, eotdWC)) { + win->imEotdOnlyWhenCulled.store(!eotdWC); + pushCmd(UICmd::ToggleEotdOnlyWhenCulled); + } + + float eotdSz = win->imEotdSizeScale.load(); + ImGui::SetNextItemWidth(100.f); + if (ImGui::SliderFloat("EOTD dot size##icons", &eotdSz, 1.0f, 10.0f, "%.1f")) + win->imEotdSizeScale.store(eotdSz); + if (ImGui::IsItemDeactivatedAfterEdit()) + pushCmd(UICmd::SetEotdSizeScale, win->imEotdSizeScale.load()); + + ImGui::EndDisabled(); // !eotdOn + ImGui::EndDisabled(); // !cullOn + + ImGui::EndMenu(); + } + + ImGui::Separator(); + // Helper lambdas for ME setting commands (y = index, delta = value) auto pushMEBool = [&](int idx, bool val) { InputEvent ev{}; ev.type = UICommand; diff --git a/native/src/exports.cpp b/native/src/exports.cpp index b37738f..efc377a 100644 --- a/native/src/exports.cpp +++ b/native/src/exports.cpp @@ -234,6 +234,24 @@ void RRPOPOUT_SetRightClickRecenter(int windowHandle, bool active) { if (win) win->imRightClickRecenter.store(active); } +// Seed all icon-culling and EOTD settings for the gear menu Icons submenu. +// Called on window/overlay init and whenever C# settings change. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetIconCullingState(int windowHandle, + bool cullEnabled, float cullThresh, bool hideMU, float locoBoost, + bool eotdEnabled, bool eotdOnlyWhenCulled, float eotdSize) +{ + PopoutWindow* win = GetPopoutWindow(windowHandle); + if (!win) return; + win->imIconCullingEnabled.store(cullEnabled); + win->imCullThreshold.store(cullThresh); + win->imHideMuLocos.store(hideMU); + win->imLocoBoostedScale.store(locoBoost); + win->imEotdEnabled.store(eotdEnabled); + win->imEotdOnlyWhenCulled.store(eotdOnlyWhenCulled); + win->imEotdSizeScale.store(eotdSize); +} + // Seed the map camera clear-colour opacity slider (shared global, affects both surfaces). extern "C" __declspec(dllexport) void RRPOPOUT_SetOverlayMapBgAlpha(float alpha) { diff --git a/native/src/popout_window.h b/native/src/popout_window.h index 36c63b0..e572b34 100644 --- a/native/src/popout_window.h +++ b/native/src/popout_window.h @@ -82,6 +82,17 @@ struct PopoutWindow { std::atomic imPanDisablesFollow {true}; std::atomic imRightClickRecenter {true}; + // ----------------------------------------------------------------------- + // Icon culling + EOTD settings (C# → render thread via RRPOPOUT_SetIconCullingState) + // ----------------------------------------------------------------------- + std::atomic imIconCullingEnabled {true}; + std::atomic imCullThreshold {0.40f}; + std::atomic imHideMuLocos {true}; + std::atomic imLocoBoostedScale {1.5f}; + std::atomic imEotdEnabled {true}; + std::atomic imEotdOnlyWhenCulled {true}; + std::atomic imEotdSizeScale {8.0f}; + // ----------------------------------------------------------------------- // MapEnhancer settings state (C# → render thread via RRPOPOUT_SetMEState) // Bit layout of imMEFlags matches MEBoolBit enum in shared_types.h. diff --git a/src/Core/Ui/UiService.cs b/src/Core/Ui/UiService.cs index 25e8ecd..c5dc9b9 100644 --- a/src/Core/Ui/UiService.cs +++ b/src/Core/Ui/UiService.cs @@ -469,7 +469,7 @@ internal sealed class UiHost : MonoBehaviour switch ((UICmd)(int)e.x) { case UICmd.Recenter: - UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); + PanelFinder.GetMapWindow()?.ClickLocateMe(); break; case UICmd.FollowMode: MapEnhancerBridge.ToggleFollowMode(); break; case UICmd.FollowPlayerCam: MapEnhancerBridge.FollowPlayerCamera(); break; @@ -568,6 +568,34 @@ internal sealed class UiHost : MonoBehaviour _mapCamera.backgroundColor = bgc2; } break; + case UICmd.ToggleIconCulling: + PopoutModule.Settings.iconCullingEnabled = !PopoutModule.Settings.iconCullingEnabled; + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.SetCullThreshold: + PopoutModule.Settings.iconCullingThreshold = Mathf.Clamp(e.y, 0.3f, 1.0f); + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.ToggleHideMuLocos: + PopoutModule.Settings.hideMuLocos = !PopoutModule.Settings.hideMuLocos; + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.SetLocoBoostedScale: + PopoutModule.Settings.locoBoostedScale = Mathf.Clamp(e.y, 1.0f, 3.0f); + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.ToggleEotd: + PopoutModule.Settings.eotdEnabled = !PopoutModule.Settings.eotdEnabled; + PopoutModule.Persist(); + break; + case UICmd.ToggleEotdOnlyWhenCulled: + PopoutModule.Settings.eotdOnlyWhenCulled = !PopoutModule.Settings.eotdOnlyWhenCulled; + PopoutModule.Persist(); + break; + case UICmd.SetEotdSizeScale: + PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f); + PopoutModule.Persist(); + break; } } @@ -583,7 +611,7 @@ internal sealed class UiHost : MonoBehaviour { case InputEventType.RButtonUp: if (PopoutModule.Settings.rightClickRecenter) - UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); + PanelFinder.GetMapWindow()?.ClickLocateMe(); break; case InputEventType.MouseWheel: @@ -702,6 +730,7 @@ internal sealed class UiHost : MonoBehaviour Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false); Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow); Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter); + SeedIconCullingState(_overlayHandle); Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha); var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme); bgc.a *= PopoutModule.Settings.overlayMapBgAlpha; @@ -763,4 +792,12 @@ internal sealed class UiHost : MonoBehaviour _drag = false; _mapActive = false; } + + private static void SeedIconCullingState(int handle) + { + var s = PopoutModule.Settings; + Native.RRPOPOUT_SetIconCullingState(handle, + s.iconCullingEnabled, s.iconCullingThreshold, s.hideMuLocos, s.locoBoostedScale, + s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale); + } } diff --git a/src/Modules/Popout/DetachedPanel.cs b/src/Modules/Popout/DetachedPanel.cs index 93549db..d2aaeca 100644 --- a/src/Modules/Popout/DetachedPanel.cs +++ b/src/Modules/Popout/DetachedPanel.cs @@ -3,7 +3,6 @@ using System.Runtime.InteropServices; using S3.Core; // Log using UI.Map; using UnityEngine; -using UnityEngine.Rendering; namespace S3.Modules.Popout { @@ -74,6 +73,7 @@ namespace S3.Modules.Popout { Native.RRPOPOUT_SetMapEnhancerCaps(_windowHandle, MapEnhancerBridge.HasTurntable, MapEnhancerBridge.IsCommunity); Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow); Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter); + SeedIconCullingState(_windowHandle); Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha); var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme); bgc.a *= PopoutModule.Settings.overlayMapBgAlpha; @@ -121,10 +121,6 @@ namespace S3.Modules.Popout { _mapCamera.rect = new Rect(0f, 0f, 1f, 1f); _mapCamera.aspect = (float)w / h; - var inGameRT = PanelFinder.GetMapRenderTexture(); - if (inGameRT != null && inGameRT != _ownRT) - Graphics.Blit(_ownRT, inGameRT); - Native.RRPOPOUT_SetFrameTexture(_windowHandle, _ownRT.GetNativeTexturePtr(), 0f, 1f, 1f, 0f); GL.IssuePluginEvent(_renderEventFunc, _windowHandle); @@ -351,13 +347,13 @@ namespace S3.Modules.Popout { case InputEventType.RButtonUp: if (PopoutModule.Settings.rightClickRecenter) - UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); + PanelFinder.GetMapWindow()?.ClickLocateMe(); break; case InputEventType.UICommand: switch ((UICmd)(int)e.x) { case UICmd.Recenter: - UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); + PanelFinder.GetMapWindow()?.ClickLocateMe(); break; case UICmd.FollowMode: MapEnhancerBridge.ToggleFollowMode(); @@ -453,9 +449,45 @@ namespace S3.Modules.Popout { _mapCamera.backgroundColor = bgc2; } break; + case UICmd.ToggleIconCulling: + PopoutModule.Settings.iconCullingEnabled = !PopoutModule.Settings.iconCullingEnabled; + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.SetCullThreshold: + PopoutModule.Settings.iconCullingThreshold = Mathf.Clamp(e.y, 0.3f, 1.0f); + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.ToggleHideMuLocos: + PopoutModule.Settings.hideMuLocos = !PopoutModule.Settings.hideMuLocos; + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.SetLocoBoostedScale: + PopoutModule.Settings.locoBoostedScale = Mathf.Clamp(e.y, 1.0f, 3.0f); + PopoutModule.Persist(); MapIconCuller.Refresh(); + break; + case UICmd.ToggleEotd: + PopoutModule.Settings.eotdEnabled = !PopoutModule.Settings.eotdEnabled; + PopoutModule.Persist(); + break; + case UICmd.ToggleEotdOnlyWhenCulled: + PopoutModule.Settings.eotdOnlyWhenCulled = !PopoutModule.Settings.eotdOnlyWhenCulled; + PopoutModule.Persist(); + break; + case UICmd.SetEotdSizeScale: + PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f); + PopoutModule.Persist(); + break; } break; } } + + private static void SeedIconCullingState(int handle) + { + var s = PopoutModule.Settings; + Native.RRPOPOUT_SetIconCullingState(handle, + s.iconCullingEnabled, s.iconCullingThreshold, s.hideMuLocos, s.locoBoostedScale, + s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale); + } } } diff --git a/src/Modules/Popout/EotdSystem.cs b/src/Modules/Popout/EotdSystem.cs new file mode 100644 index 0000000..ed0c457 --- /dev/null +++ b/src/Modules/Popout/EotdSystem.cs @@ -0,0 +1,261 @@ +using System.Collections.Generic; +using HarmonyLib; +using Helpers; +using Model; +using Track; +using UI.Map; +using UnityEngine; +using UnityEngine.UI; + +namespace S3.Modules.Popout; + +// Places a blinking red dot on the map at the rear end of each active train consist. +// The dot is a cloned car MapIcon (red Image, no label) parented to a DontDestroyOnLoad +// holder — NOT to the car's own MapIcon Canvas — so culling the car icons never hides it. +// +// Consists are rebuilt every kRebuildInterval seconds. A consist qualifies when it has a +// lead locomotive (no loco on A-end) with at least one car trailing on the A-end chain. +// +// EnumerateCoupled(A) from the lead loco walks FORWARD through the consist (lead → rear), +// ending at the last car. EnumerateCoupled(B) walks in reverse (rear → lead). We always +// use the A-end chain and take the final element as the EOTD placement car. +internal static class EotdSystem +{ + private static GameObject? _holder; + private static readonly Dictionary _markers = new(); + private static float _rebuildTimer; + private const float kRebuildInterval = 3f; + + public static void Install() + { + if (_holder != null) return; + _holder = new GameObject("S3.EOTD.Holder"); + Object.DontDestroyOnLoad(_holder); + _rebuildTimer = 0f; + } + + public static void Uninstall() + { + ClearAll(); + if (_holder != null) { Object.Destroy(_holder); _holder = null; } + } + + public static void Tick(float dt) + { + if (_holder == null) return; + if (!PopoutModule.Settings.eotdEnabled) { ClearAll(); return; } + _rebuildTimer -= dt; + if (_rebuildTimer <= 0f) { _rebuildTimer = kRebuildInterval; Rebuild(); } + } + + // --------------------------------------------------------------------------- + + private static void Rebuild() + { + if (_holder == null) return; + + var tc = TrainController.Shared; + if (tc == null) { ClearAll(); return; } + + // Identify the current set of rear cars (one per consist with a lead loco). + var rearCars = new HashSet(); + foreach (Car car in tc.Cars) + { + if (!car.IsLocomotive || !MapIconCuller.IsLeadLoco(car)) continue; + var rear = FindRearCar(car); + if (rear != null) rearCars.Add(rear); + } + + // Remove markers whose rear car is no longer valid. + var stale = new List(); + foreach (var kv in _markers) + { + if (!rearCars.Contains(kv.Key)) + { + if (kv.Value != null) Object.Destroy(kv.Value.gameObject); + stale.Add(kv.Key); + } + } + foreach (var k in stale) _markers.Remove(k); + + int mapLayer = GetMapLayer(tc); + + // Create markers for newly discovered rear cars. + foreach (Car rear in rearCars) + { + if (_markers.ContainsKey(rear)) continue; + + var template = GetAnyCarIcon(tc); + if (template == null) continue; + + var go = Object.Instantiate(template.gameObject, _holder!.transform); + go.name = "S3_EOTD_Marker"; + SetLayerRecursive(go, mapLayer); + + // Strip all children (car-body shape, text labels). + var children = new System.Collections.Generic.List(); + foreach (Transform child in go.transform) children.Add(child); + foreach (var child in children) Object.Destroy(child.gameObject); + + // Destroy MapIcon so MapBuilder.SetZoom() never overrides our manually-set + // localScale. MapIcon.OnDisable() removes us from _mapIcons cleanly. + Object.Destroy(go.GetComponent()); + + // sizeDelta=(1,1) — scale is set each frame in EotdMarker.Update() as a + // fraction of the map camera's orthographicSize, giving a stable ~2% + // screen-height dot at any zoom level. + var dotGo = new GameObject("dot"); + dotGo.layer = mapLayer; + dotGo.transform.SetParent(go.transform, false); + var img = dotGo.AddComponent(); + img.color = Color.red; + img.sprite = MakeCircleSprite(32); + img.type = Image.Type.Simple; + var dotRt = dotGo.GetComponent(); + dotRt.sizeDelta = new Vector2(1f, 1f); + dotRt.anchoredPosition = Vector2.zero; + + var marker = go.AddComponent(); + marker.Init(rear, img); + _markers[rear] = marker; + } + } + + private static void ClearAll() + { + foreach (var kv in _markers) + if (kv.Value != null) Object.Destroy(kv.Value.gameObject); + _markers.Clear(); + } + + // EnumerateCoupled(A) starts at the lead loco and walks forward through the consist. + // The last element is the rear car. Returns null if nothing is coupled (solo loco). + private static Car? FindRearCar(Car leadLoco) + { + Car last = leadLoco; + try + { + foreach (Car c in leadLoco.EnumerateCoupled(Car.LogicalEnd.A)) + last = c; + } + catch { } + return last == leadLoco ? null : last; + } + + private static MapIcon? GetAnyCarIcon(TrainController tc) + { + foreach (Car car in tc.Cars) + { + if (car.IsLocomotive) continue; + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon != null) return icon; + } + foreach (Car car in tc.Cars) + { + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon != null) return icon; + } + return null; + } + + private static int GetMapLayer(TrainController tc) + { + foreach (Car car in tc.Cars) + { + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon != null) return icon.gameObject.layer; + } + return LayerMask.NameToLayer("Map"); + } + + private static void SetLayerRecursive(GameObject go, int layer) + { + go.layer = layer; + foreach (Transform child in go.transform) + SetLayerRecursive(child.gameObject, layer); + } + + // Generates a white anti-aliased circle sprite at runtime. + // Used instead of Resources.GetBuiltinResource("UI/Skin/Knob.psd") which + // is not available in stripped Unity builds. + private static Sprite MakeCircleSprite(int radius) + { + int size = radius * 2; + var tex = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false); + tex.filterMode = FilterMode.Bilinear; + var pixels = new Color32[size * size]; + float c = radius - 0.5f; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + float dist = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c)); + byte a = (byte)(Mathf.Clamp01(radius - dist) * 255f); + pixels[y * size + x] = new Color32(255, 255, 255, a); + } + tex.SetPixels32(pixels); + tex.Apply(); + return Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f)); + } +} + +// Attached to each EOTD marker GameObject. Tracks the rear car's world position each +// frame and animates a 1.2 Hz sine-wave pulse on the red Image alpha. +internal class EotdMarker : MonoBehaviour +{ + private Car? _car; + private Image? _img; + private Canvas? _canvas; + + public void Init(Car rearCar, Image dot) + { + _car = rearCar; + _img = dot; + _canvas = GetComponent(); + } + + private void Update() + { + if (_car == null) { Destroy(gameObject); return; } + + var s = PopoutModule.Settings; + + // Optionally hide the dot when car icons are visible (zoom inside cull threshold). + bool show = true; + if (s.eotdOnlyWhenCulled && s.iconCullingEnabled) + { + var mb2 = MapBuilder.Shared; + if (mb2 != null) + { + float zoom = Traverse.Create(mb2).Property("NormalizedScale").Value; + show = zoom >= s.iconCullingThreshold; + } + } + + if (_canvas != null) _canvas.enabled = show; + if (!show) return; + + try + { + var worldPos = WorldTransformer.GameToWorld(_car.GetCenterPosition(Graph.Shared)); + worldPos.y += 3200f; + // Euler(-90, 0, 0) lays the canvas flat under the overhead map camera. + transform.SetPositionAndRotation(worldPos, Quaternion.Euler(-90f, 0f, 0f)); + + // Scale = orthographicSize * (eotdSizeScale * 0.002). + // eotdSizeScale ranges 1–10; at 3 (default) and orthoSize ~4000 this gives + // a dot ~24 world units ≈ about 3-4× the track-line width. + var mb = MapBuilder.Shared; + if (mb?.mapCamera != null) + transform.localScale = Vector3.one * (mb.mapCamera.orthographicSize * s.eotdSizeScale * 0.002f); + } + catch { } + + if (_img != null) + { + float t = (Mathf.Sin(Time.time * Mathf.PI * 2.4f) + 1f) * 0.5f; + var colour = _img.color; + colour.a = t; + _img.color = colour; + } + } +} diff --git a/src/Modules/Popout/MapIconCuller.cs b/src/Modules/Popout/MapIconCuller.cs new file mode 100644 index 0000000..63da1dd --- /dev/null +++ b/src/Modules/Popout/MapIconCuller.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using HarmonyLib; +using Model; +using UI.Map; +using UnityEngine; +using UnityEngine.UI; + +namespace S3.Modules.Popout; + +// Harmony patches on MapBuilder.UpdateForZoom (postfix) and MapBuilder.Add (postfix) +// that cull non-locomotive car icons when the map is zoomed far out, and optionally +// boost visible loco icon scale so they remain readable. +// +// Two patches are needed: +// UpdateForZoom postfix — handles the steady-state case (zoom scroll events). +// Add postfix — catches icons registered AFTER UpdateForZoom runs during a +// map rebuild, closing the 1-frame flash window. +// +// Fade: CanvasGroup.alpha is lerped to 0/1 each frame via TickFade(). The canvas is +// only disabled once alpha reaches 0 so the GPU stops work at the same moment the +// visual disappears. Canvas.enabled is re-set to true before fading in. +// +// Access notes (live game DLL): +// MapBuilder.NormalizedScale / IconScale — private, accessed via Traverse +// Car.MapIcon — protected field, accessed via Traverse +// Car.LogicalEnd — nested enum inside Car class +internal static class MapIconCuller +{ + private static Harmony? _harmony; + + // Target alpha for fading icons. Populated by SetVisible; drained by TickFade. + private static readonly Dictionary _fadeTargets = new(); + private const float kFadeSpeed = 3f; // alpha units/second → full fade in ~0.33 s + + public static void Install() + { + if (_harmony != null) return; + _harmony = new Harmony("S3.popout.culler"); + _harmony.CreateClassProcessor(typeof(MapBuilder_UpdateForZoom_Patch)).Patch(); + _harmony.CreateClassProcessor(typeof(MapBuilder_Add_Patch)).Patch(); + } + + public static void Uninstall() + { + if (_harmony == null) return; + RestoreAll(); + _harmony.UnpatchAll("S3.popout.culler"); + _harmony = null; + _fadeTargets.Clear(); + } + + public static void Refresh() + { + var mb = MapBuilder.Shared; + if (mb != null) Apply(mb); + } + + // Called each frame by PopoutModule.Tick() to drive icon fade animations. + public static void TickFade(float dt) + { + if (_fadeTargets.Count == 0) return; + + var done = new List(); + foreach (var kv in _fadeTargets) + { + var icon = kv.Key; + float target = kv.Value; + + if (icon == null) { done.Add(icon); continue; } + + var cg = icon.GetComponent(); + if (cg == null) { done.Add(icon); continue; } + + float next = Mathf.MoveTowards(cg.alpha, target, kFadeSpeed * dt); + cg.alpha = next; + + if (Mathf.Approximately(next, target)) + { + if (target < 0.5f) + icon.GetComponent().enabled = false; + done.Add(icon); + } + } + foreach (var icon in done) _fadeTargets.Remove(icon); + } + + // --------------------------------------------------------------------------- + + internal static void Apply(MapBuilder mb) + { + var s = PopoutModule.Settings; + if (!s.iconCullingEnabled) { RestoreAll(); return; } + + float zoom = Traverse.Create(mb).Property("NormalizedScale").Value; + bool culling = zoom >= s.iconCullingThreshold; + float iconScale = Traverse.Create(mb).Property("IconScale").Value; + + var tc = TrainController.Shared; + if (tc == null) return; + + foreach (Car car in tc.Cars) + { + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon == null) continue; + + if (car.IsLocomotive) + { + bool show = !culling || !s.hideMuLocos || IsLeadLoco(car); + SetVisible(icon, show); + if (show && culling) + icon.SetZoom(iconScale * s.locoBoostedScale); + } + else + { + SetVisible(icon, !culling); + } + } + } + + private static void RestoreAll() + { + var tc = TrainController.Shared; + if (tc == null) return; + foreach (Car car in tc.Cars) + { + var icon = Traverse.Create(car).Field("MapIcon").Value; + if (icon != null) SetVisible(icon, true); + } + } + + private static void SetVisible(MapIcon icon, bool visible) + { + var canvas = icon.GetComponent(); + if (canvas == null) return; + + var cg = icon.GetComponent(); + if (cg == null) cg = icon.gameObject.AddComponent(); + + if (visible) + { + canvas.enabled = true; // must re-enable before fading in + _fadeTargets[icon] = 1f; + } + else if (canvas.enabled) + { + // Only start a fade-out if the canvas is currently rendering. + _fadeTargets[icon] = 0f; + } + } + + // A loco is the lead of its consist when nothing (or a non-loco) is on its A-end. + internal static bool IsLeadLoco(Car loco) + { + try + { + var atA = loco.CoupledTo(Car.LogicalEnd.A); + return atA == null || !atA.IsLocomotive; + } + catch + { + return true; + } + } + + // --------------------------------------------------------------------------- + + [HarmonyPatch(typeof(MapBuilder), "UpdateForZoom")] + private static class MapBuilder_UpdateForZoom_Patch + { + private static void Postfix(MapBuilder __instance) => Apply(__instance); + } + + // Catches icons added to _mapIcons AFTER UpdateForZoom runs during a map rebuild. + // Car MapIcons are parented to car.transform; non-car icons (junctions, stations) + // have no Car component on their parent — those are left alone. + [HarmonyPatch(typeof(MapBuilder), nameof(MapBuilder.Add))] + private static class MapBuilder_Add_Patch + { + private static void Postfix(MapIcon icon) + { + var s = PopoutModule.Settings; + if (!s.iconCullingEnabled) return; + + var mb = MapBuilder.Shared; + if (mb == null) return; + + float zoom = Traverse.Create(mb).Property("NormalizedScale").Value; + if (zoom < s.iconCullingThreshold) return; + + // S3 EOTD clones and junction/station icons have no Car on their parent. + var car = icon.transform.parent?.GetComponent(); + if (car == null) return; + + float iconScale = Traverse.Create(mb).Property("IconScale").Value; + + if (car.IsLocomotive) + { + bool show = !s.hideMuLocos || IsLeadLoco(car); + SetVisible(icon, show); + if (show) icon.SetZoom(iconScale * s.locoBoostedScale); + } + else + { + SetVisible(icon, false); + } + } + } +} diff --git a/src/Modules/Popout/NativeInterop.cs b/src/Modules/Popout/NativeInterop.cs index 096593f..1342889 100644 --- a/src/Modules/Popout/NativeInterop.cs +++ b/src/Modules/Popout/NativeInterop.cs @@ -46,6 +46,14 @@ namespace S3.Modules.Popout { TogglePanDisablesFollow = 20, // toggle whether panning cancels follow mode ToggleRightClickRecenter = 21, // toggle whether right-clicking recenters on player SetMapBgAlpha = 22, // set map camera clear-colour opacity; y = [0.0, 1.0] + // Icon culling + EOTD (gear menu Icons submenu) + ToggleIconCulling = 23, + SetCullThreshold = 24, // y = [0.3, 1.0] + ToggleHideMuLocos = 25, + SetLocoBoostedScale = 26, // y = [1.0, 3.0] + ToggleEotd = 27, + ToggleEotdOnlyWhenCulled = 28, + SetEotdSizeScale = 29, // y = [1.0, 10.0] } // Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes). @@ -225,6 +233,12 @@ namespace S3.Modules.Popout { [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern void RRPOPOUT_SetRightClickRecenter(int windowHandle, bool active); + // Seed all icon-culling and EOTD state for the gear menu Icons submenu. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetIconCullingState(int windowHandle, + bool cullEnabled, float cullThresh, bool hideMU, float locoBoost, + bool eotdEnabled, bool eotdOnlyWhenCulled, float eotdSize); + // Seed the map camera clear-colour opacity slider (shared global, overlay + popout). [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern void RRPOPOUT_SetOverlayMapBgAlpha(float alpha); diff --git a/src/Modules/Popout/PanelFinder.cs b/src/Modules/Popout/PanelFinder.cs index 661beda..6e82e97 100644 --- a/src/Modules/Popout/PanelFinder.cs +++ b/src/Modules/Popout/PanelFinder.cs @@ -16,9 +16,16 @@ namespace S3.Modules.Popout { internal static class PanelFinder { + private static MapWindow? _cachedMapWindow; + // Returns the live MapWindow MonoBehaviour, or null if the map isn't loaded. - public static MapWindow? GetMapWindow() => - Object.FindObjectOfType(); + // Cached after first find; Unity's == null catches destroyed instances and + // triggers a fresh search on the next call. + public static MapWindow? GetMapWindow() { + if (_cachedMapWindow == null) + _cachedMapWindow = Object.FindObjectOfType(); + return _cachedMapWindow; + } // Returns the RenderTexture the map camera renders to each frame. public static RenderTexture? GetMapRenderTexture() { diff --git a/src/Modules/Popout/PopoutModule.cs b/src/Modules/Popout/PopoutModule.cs index 7e9a60f..2902fc3 100644 --- a/src/Modules/Popout/PopoutModule.cs +++ b/src/Modules/Popout/PopoutModule.cs @@ -81,6 +81,9 @@ public sealed class PopoutModule : IModule return; } + MapIconCuller.Install(); + EotdSystem.Install(); + _host = new GameObject("S3.Popout.Host"); Object.DontDestroyOnLoad(_host); _host.AddComponent(); @@ -92,6 +95,8 @@ public sealed class PopoutModule : IModule if (_activePanel != null) CloseActivePanel("[popout] Module disabled."); _pendingOpen = false; _pendingOverlayOpen = false; + MapIconCuller.Uninstall(); + EotdSystem.Uninstall(); if (_host != null) { Object.Destroy(_host); _host = null; } } @@ -171,6 +176,10 @@ public sealed class PopoutModule : IModule _pendingRestoreWindow = null; } + float dt = UnityEngine.Time.deltaTime; + MapIconCuller.TickFade(dt); + EotdSystem.Tick(dt); + if (_hotkey.Down()) Toggle(); diff --git a/src/Modules/Popout/PopoutSettings.cs b/src/Modules/Popout/PopoutSettings.cs index 9b3770a..1d61dc3 100644 --- a/src/Modules/Popout/PopoutSettings.cs +++ b/src/Modules/Popout/PopoutSettings.cs @@ -38,6 +38,22 @@ public class PopoutSettings // When true, right-clicking anywhere on the map image recenters on the player. public bool rightClickRecenter = true; + // --- Icon culling --- + // When the map is zoomed out past iconCullingThreshold (NormalizedScale 0=close, 1=far), + // non-locomotive car icons are hidden to reduce Canvas draw calls. + public bool iconCullingEnabled = true; + public float iconCullingThreshold = 0.40f; + // When culling is active, hide MU trailing units (locos with another loco on their A-end). + public bool hideMuLocos = true; + // Scale multiplier applied to visible loco icons when culling is active. + public float locoBoostedScale = 1.5f; + // Show a blinking red dot on the map at the rear end of each train (EOTD). + public bool eotdEnabled = true; + // Hide the EOTD when car icons are visible (i.e. zoom is inside the cull threshold). + public bool eotdOnlyWhenCulled = true; + // Dot size: 1 (tiny) to 10 (large). Applied as orthographicSize * value * 0.002. + public float eotdSizeScale = 8f; + // Custom theme colors — edited live in the Settings color picker. // Initialized to S3 Dark so first-launch looks reasonable before the user tunes it. public MapThemeData customTheme = new MapThemeData { diff --git a/src/Modules/Popout/PopoutSettingsUI.cs b/src/Modules/Popout/PopoutSettingsUI.cs index 662cd35..35dd93d 100644 --- a/src/Modules/Popout/PopoutSettingsUI.cs +++ b/src/Modules/Popout/PopoutSettingsUI.cs @@ -76,6 +76,83 @@ static class PopoutSettingsUI bool pan = GUILayout.Toggle(s.panDisablesFollow, " Dragging the map cancels follow mode"); if (pan != s.panDisablesFollow) { s.panDisablesFollow = pan; PopoutModule.Persist(); } + bool rcr = GUILayout.Toggle(s.rightClickRecenter, " Right-click on map recenters on player"); + if (rcr != s.rightClickRecenter) { s.rightClickRecenter = rcr; PopoutModule.Persist(); } + + GUILayout.Space(8f); + + // ── Icon culling ───────────────────────────────────────────────────────── + bool cullOn = GUILayout.Toggle(s.iconCullingEnabled, " Hide car icons when zoomed out (keep locomotives)"); + if (cullOn != s.iconCullingEnabled) + { + s.iconCullingEnabled = cullOn; + PopoutModule.Persist(); + MapIconCuller.Refresh(); + } + + if (s.iconCullingEnabled) + { + GUILayout.BeginHorizontal(); + GUILayout.Space(20f); + GUILayout.Label("Cull at zoom-out:", GUILayout.Width(115f)); + float newThresh = GUILayout.HorizontalSlider(s.iconCullingThreshold, 0.3f, 1.0f, GUILayout.Width(160f)); + GUILayout.Label($"{s.iconCullingThreshold:P0}", GUILayout.Width(44f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(newThresh - s.iconCullingThreshold) > 0.005f) + { + s.iconCullingThreshold = newThresh; + PopoutModule.Persist(); + MapIconCuller.Refresh(); + } + + GUILayout.BeginHorizontal(); + GUILayout.Space(20f); + bool hideMU = GUILayout.Toggle(s.hideMuLocos, " Hide MU trailing units (show lead loco only)"); + GUILayout.EndHorizontal(); + if (hideMU != s.hideMuLocos) + { + s.hideMuLocos = hideMU; + PopoutModule.Persist(); + MapIconCuller.Refresh(); + } + + GUILayout.BeginHorizontal(); + GUILayout.Space(20f); + GUILayout.Label("Loco icon boost:", GUILayout.Width(115f)); + float newBoost = GUILayout.HorizontalSlider(s.locoBoostedScale, 1.0f, 3.0f, GUILayout.Width(160f)); + GUILayout.Label($"{s.locoBoostedScale:F1}x", GUILayout.Width(36f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(newBoost - s.locoBoostedScale) > 0.05f) + { + s.locoBoostedScale = newBoost; + PopoutModule.Persist(); + MapIconCuller.Refresh(); + } + + GUILayout.BeginHorizontal(); + GUILayout.Space(20f); + bool eotd = GUILayout.Toggle(s.eotdEnabled, " Show EOTD (blinking red dot at rear of each train)"); + GUILayout.EndHorizontal(); + if (eotd != s.eotdEnabled) { s.eotdEnabled = eotd; PopoutModule.Persist(); } + + if (s.eotdEnabled) + { + GUILayout.BeginHorizontal(); + GUILayout.Space(40f); + bool onlyWhenCulled = GUILayout.Toggle(s.eotdOnlyWhenCulled, " Hide when car icons are visible"); + GUILayout.EndHorizontal(); + if (onlyWhenCulled != s.eotdOnlyWhenCulled) { s.eotdOnlyWhenCulled = onlyWhenCulled; PopoutModule.Persist(); } + + GUILayout.BeginHorizontal(); + GUILayout.Space(40f); + GUILayout.Label("Dot size:", GUILayout.Width(72f)); + float newSz = GUILayout.HorizontalSlider(s.eotdSizeScale, 1f, 10f, GUILayout.Width(160f)); + GUILayout.Label($"{s.eotdSizeScale:F1}", GUILayout.Width(36f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(newSz - s.eotdSizeScale) > 0.05f) { s.eotdSizeScale = newSz; PopoutModule.Persist(); } + } + } + GUILayout.Space(8f); // ── Theme preset quick-switch ──────────────────────────────────────────── @@ -93,8 +170,9 @@ static class PopoutSettingsUI GUILayout.Space(4f); // ── Overlay opacity ────────────────────────────────────────────────────── - OpacityRow("Window opacity:", ref s.overlayAlpha, 0.1f, 1.0f, Native.RRPOPOUT_SetOverlayAlpha); - OpacityRow("Map opacity:", ref s.overlayMapAlpha, 0.0f, 1.0f, Native.RRPOPOUT_SetOverlayMapAlpha); + OpacityRow("Window opacity:", ref s.overlayAlpha, 0.1f, 1.0f, Native.RRPOPOUT_SetOverlayAlpha); + OpacityRow("Map opacity:", ref s.overlayMapAlpha, 0.0f, 1.0f, Native.RRPOPOUT_SetOverlayMapAlpha); + OpacityRow("Map BG opacity:", ref s.overlayMapBgAlpha, 0.0f, 1.0f, Native.RRPOPOUT_SetOverlayMapBgAlpha); GUILayout.Space(8f);