Map Module 0.2.5: icon culling, EOTD, gear menu icons, font fix
Icon culling hides car icons when zoomed out past a configurable threshold (default 40%), keeping only locomotive icons visible. Locos scale up automatically when culling is active (configurable boost). An option also hides MU trailing units. All managed by the new MapIconCuller class with smooth fade transitions. EOTD (End-of-Train Device): a blinking red dot at the rear of each consist. Shown by default when car icons are hidden. Dot size and visibility conditions are configurable. Implemented in EotdSystem as a programmatically generated circle texture to avoid asset dependencies. Added an Icons submenu to the gear menu containing all icon and EOTD settings. Controls grey out to reflect dependencies: culling sub-settings (MU hide, loco boost, EOTD) grey out when culling is off; EOTD dot size and hide-when-visible grey out when EOTD is off. Changes round-trip via UICmd events, persist to PopoutSettings, and call MapIconCuller.Refresh(). Native state is seeded from C# on window init via RRPOPOUT_SetIconCullingState. Settings completeness: added Right-click recenters toggle and Map BG opacity slider to the UMM settings panel (were only accessible from the gear menu). Replaced four FindObjectOfType<MapWindow> calls with PanelFinder.GetMapWindow(). Fixed AV false-positive on release zips: ImGui's built-in font blob contained the substring "UPX", matching the heuristic for UPX-packed binaries. Added IMGUI_DISABLE_DEFAULT_FONT to strip the blob; ProggyClean.ttf is now shipped as a separate file alongside the DLL and loaded at runtime instead.
This commit is contained in:
parent
c8918851aa
commit
edf6a8a14e
20 changed files with 823 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",
|
||||
|
|
|
|||
14
README.md
14
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,16 @@ 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
|
||||
|
||||
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.
|
||||
|
||||
<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