Compare commits
2 commits
c8918851aa
...
2343f05b18
| Author | SHA1 | Date | |
|---|---|---|---|
| 2343f05b18 | |||
| edf6a8a14e |
20 changed files with 825 additions and 18 deletions
|
|
@ -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",
|
||||
|
|
|
|||
16
README.md
16
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.
|
||||
|
||||

|
||||
|
||||
|
|
@ -64,6 +66,18 @@ Rotate the map manually with the rotation control, or lock it to your player cam
|
|||
|
||||
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/map_rotation/rotate_manual_and_player_locked.mp4" controls></video>
|
||||
|
||||
### Icon Controls
|
||||
|
||||
Each car icon on the map is a live Canvas element. On a large session with many cars visible, the rendering cost adds up - testing showed a **3-12 FPS improvement** when zoomed out over a busy portion of the map with culling enabled.
|
||||
|
||||
When zoomed out past a configurable threshold (default 40%), car icons fade out 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 so you can still gauge train length at a glance.
|
||||
|
||||
All of these options are available from the gear menu Icons submenu on the map toolbar, or from the S³ UMM settings page.
|
||||
|
||||
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4" controls></video>
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
|
|
|||
6
dist/build-common.ps1
vendored
6
dist/build-common.ps1
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
img/map/map_icons_config.png
Normal file
BIN
img/map/map_icons_config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
BIN
img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4
Normal file
BIN
img/map/map_optimizations/hiding_cars_and_eotd_demo.mp4
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
BIN
native/fonts/ProggyClean.ttf
Normal file
BIN
native/fonts/ProggyClean.ttf
Normal file
Binary file not shown.
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -276,9 +276,29 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) {
|
|||
{ std::lock_guard<std::mutex> 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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,17 @@ struct PopoutWindow {
|
|||
std::atomic<bool> imPanDisablesFollow {true};
|
||||
std::atomic<bool> imRightClickRecenter {true};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Icon culling + EOTD settings (C# → render thread via RRPOPOUT_SetIconCullingState)
|
||||
// -----------------------------------------------------------------------
|
||||
std::atomic<bool> imIconCullingEnabled {true};
|
||||
std::atomic<float> imCullThreshold {0.40f};
|
||||
std::atomic<bool> imHideMuLocos {true};
|
||||
std::atomic<float> imLocoBoostedScale {1.5f};
|
||||
std::atomic<bool> imEotdEnabled {true};
|
||||
std::atomic<bool> imEotdOnlyWhenCulled {true};
|
||||
std::atomic<float> imEotdSizeScale {8.0f};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MapEnhancer settings state (C# → render thread via RRPOPOUT_SetMEState)
|
||||
// Bit layout of imMEFlags matches MEBoolBit enum in shared_types.h.
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ internal sealed class UiHost : MonoBehaviour
|
|||
switch ((UICmd)(int)e.x)
|
||||
{
|
||||
case UICmd.Recenter:
|
||||
UnityEngine.Object.FindObjectOfType<MapWindow>()?.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<MapWindow>()?.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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MapWindow>()?.ClickLocateMe();
|
||||
PanelFinder.GetMapWindow()?.ClickLocateMe();
|
||||
break;
|
||||
|
||||
case InputEventType.UICommand:
|
||||
switch ((UICmd)(int)e.x) {
|
||||
case UICmd.Recenter:
|
||||
UnityEngine.Object.FindObjectOfType<MapWindow>()?.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
261
src/Modules/Popout/EotdSystem.cs
Normal file
261
src/Modules/Popout/EotdSystem.cs
Normal file
|
|
@ -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<Car, EotdMarker> _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<Car>();
|
||||
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<Car>();
|
||||
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<Transform>();
|
||||
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<MapIcon>());
|
||||
|
||||
// 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<Image>();
|
||||
img.color = Color.red;
|
||||
img.sprite = MakeCircleSprite(32);
|
||||
img.type = Image.Type.Simple;
|
||||
var dotRt = dotGo.GetComponent<RectTransform>();
|
||||
dotRt.sizeDelta = new Vector2(1f, 1f);
|
||||
dotRt.anchoredPosition = Vector2.zero;
|
||||
|
||||
var marker = go.AddComponent<EotdMarker>();
|
||||
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>("MapIcon").Value;
|
||||
if (icon != null) return icon;
|
||||
}
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
var icon = Traverse.Create(car).Field<MapIcon>("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>("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<Sprite>("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<Canvas>();
|
||||
}
|
||||
|
||||
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<float>("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;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
src/Modules/Popout/MapIconCuller.cs
Normal file
208
src/Modules/Popout/MapIconCuller.cs
Normal file
|
|
@ -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<MapIcon, float> _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<MapIcon>();
|
||||
foreach (var kv in _fadeTargets)
|
||||
{
|
||||
var icon = kv.Key;
|
||||
float target = kv.Value;
|
||||
|
||||
if (icon == null) { done.Add(icon); continue; }
|
||||
|
||||
var cg = icon.GetComponent<CanvasGroup>();
|
||||
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<Canvas>().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<float>("NormalizedScale").Value;
|
||||
bool culling = zoom >= s.iconCullingThreshold;
|
||||
float iconScale = Traverse.Create(mb).Property<float>("IconScale").Value;
|
||||
|
||||
var tc = TrainController.Shared;
|
||||
if (tc == null) return;
|
||||
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
var icon = Traverse.Create(car).Field<MapIcon>("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>("MapIcon").Value;
|
||||
if (icon != null) SetVisible(icon, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetVisible(MapIcon icon, bool visible)
|
||||
{
|
||||
var canvas = icon.GetComponent<Canvas>();
|
||||
if (canvas == null) return;
|
||||
|
||||
var cg = icon.GetComponent<CanvasGroup>();
|
||||
if (cg == null) cg = icon.gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
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<float>("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<Car>();
|
||||
if (car == null) return;
|
||||
|
||||
float iconScale = Traverse.Create(mb).Property<float>("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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<MapWindow>();
|
||||
// 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<MapWindow>();
|
||||
return _cachedMapWindow;
|
||||
}
|
||||
|
||||
// Returns the RenderTexture the map camera renders to each frame.
|
||||
public static RenderTexture? GetMapRenderTexture() {
|
||||
|
|
|
|||
|
|
@ -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<PopoutHost>();
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue