v0.2.2 - map module themes, custom colors, in-game overlay

Map module:
- In-game overlay (UiService) with full map chrome matching the popout
- Six themes (S3 Dark, Railroader Classic, Night Mode, Hi-Vis, Retro Terminal, Custom)
- Per-element custom color editor with hex input and live R/G/B/A sliders
- Independent window and map opacity controls
- Theme and opacity changes apply from both the in-game overlay and the popout window
- Renamed module display name from "Map Popout" to "Map Module"

Native engine:
- SnapshotTheme() helper reduces per-frame mutex acquisitions from two to one
- Toolbar button text color auto-contrasts against the button background
- Compass colors driven by theme data

README:
- Logo, screenshots, and videos for all features
- Physics Optimizer before/after profiler comparison
- Correct MapEnhancer link (maintained fork)
This commit is contained in:
Seton Carmichael 2026-06-18 21:30:53 -04:00
parent 914597b717
commit 97eb320e0f
29 changed files with 930 additions and 71 deletions

View file

@ -2,7 +2,7 @@
"Id": "S3",
"DisplayName": "S³ - Seton's Special Sauce",
"Author": "seton",
"Version": "0.2.1",
"Version": "0.2.2",
"ManagerVersion": "0.27.0",
"GameVersionPoint": "0",
"AssemblyName": "S3.dll",

View file

@ -1,5 +1,7 @@
# S³ - Seton's Special Sauce
<p align="center"><img src="S3 Logo - clean.png" width="480" alt="S3 - Seton's Special Sauce"></p>
An umbrella "everything mod" for [Railroader](https://store.steampowered.com/app/1638770/Railroader/),
built on [Unity Mod Manager](https://www.nexusmods.com/site/mods/21).
@ -15,10 +17,90 @@ I originally planned on releasing individual mods, but considering my workflow o
| Module | What it does |
|---|---|
| Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with a profiler overlay and debug car tinting. Console: `/rpf` |
| Map Popout | Detaches the in-game map into a separate resizable window for a second monitor. Optional MapEnhancer integration. |
| Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity controls, map rotation, and optional MapEnhancer integration. |
Both are disabled by default - You must enable them per-module from the S³ settings page, a game restart is required for this to take effect.
More modules will follow - S³ is designed to grow.
Both are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow.
---
## Map Module
Replaces the base-game map with a floating in-game overlay (toggle with **M** or your configured hotkey) and lets you pop it out into a detached resizable OS window for a second monitor. The toolbar, compass, and follow/jump menus are identical in both surfaces.
### Settings
Configure everything from the S³ UMM settings page: hotkey, drag-to-cancel follow behavior, theme, independent window and map opacity, the detach button, and a full per-element custom color editor.
![Map Module settings panel](img/map/map_settings_umm.png)
### Themes
Six themes built in: five named presets plus a **Custom** slot you tune with per-element hex input and live R/G/B/A sliders. Any preset can be copied into Custom as a starting point. Theme changes apply instantly without restarting.
<table>
<tr>
<td align="center"><img src="img/map/map_themes/s3-default.png" alt="S3 Dark"><br><b>S3 Dark</b> (default)</td>
<td align="center"><img src="img/map/map_themes/railroader.png" alt="Railroader Classic"><br><b>Railroader Classic</b></td>
<td align="center"><img src="img/map/map_themes/high-vis.png" alt="High Visibility"><br><b>High Visibility</b></td>
</tr>
<tr>
<td align="center"><img src="img/map/map_themes/red-night.png" alt="Night Mode"><br><b>Night Mode</b></td>
<td align="center"><img src="img/map/map_themes/retro_green.png" alt="Retro Terminal"><br><b>Retro Terminal</b></td>
<td align="center"><i>+ fully customizable</i></td>
</tr>
</table>
### Transparency
The in-game overlay has independent **Window** and **Map** opacity controls. Keep the chrome subtle while the track layout stays crisp, or dial both down to a ghost overlay sitting over the world.
![Overlay transparency with the map visible through the game world](img/map/transparency.png)
### Map Rotation
Rotate the map manually with the rotation control, or lock it to your player camera heading so it always faces the direction you're looking. Switch between free and locked at any time without losing your current view.
<video src="img/map/map_rotation/rotate_manual_and_player_locked.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.
<video src="img/map/map_popout/map_popout_reattach.mp4" controls></video>
![Always on Top option in the popout window title bar menu](img/map/map_popout/always_on_top.png)
### MapEnhancer Integration
If [MapEnhancer](https://github.com/Refizar08/rr-mapenhancer-fix) is installed, the toolbar gear menu unlocks a full **Follow** submenu: follow player camera, follow the selected locomotive, or pick any consist from a live-updated list. Jump to named locations is also available.
![MapEnhancer follow menu with locomotive list](img/map/map_enhancer_follow_mode.png)
---
## Physics Optimizer
Reduces CPU time spent on train physics with two complementary strategies:
- **LOD Fast-Path**: cars beyond a configurable distance switch to dead-reckoning, skipping expensive 3D constraint updates. A staggered resync keeps them accurate without a sudden full-update spike.
- **Auto Freeze**: consists that are far away and nearly stopped skip the Verlet integration tick entirely, replacing the stock freeze heuristic with a tighter distance + speed threshold.
Locomotives can be excluded from either strategy. A console profiler overlay (`/rpf`) and debug car tinting (yellow = frozen, cyan = fast-path, magenta = full) let you verify the LOD and freeze behavior in your session.
### Settings
![Physics Optimizer settings](img/physics_optimizer/umm_settings.png)
### Performance
LOD fast-path and Auto Freeze together cut per-car physics cost from ~28.6 µs to ~10.5 µs (63% reduction) and FixedUpdate time from 6.7 ms to 3.8 ms on the same 156-car scene. Numbers from the built-in profiler overlay:
| Before | After |
|:---:|:---:|
| ![Profiler before, Physics Optimizer OFF](img/physics_optimizer/pofiler_before.png) | ![Profiler after, Physics Optimizer ON](img/physics_optimizer/profiler_after.png) |
| Physics Optimizer **OFF** (6.7 ms/frame) | Physics Optimizer **ON** (3.8 ms/frame) |
---
## Migrating from the standalone mods
@ -28,6 +110,8 @@ Settings do not carry over, so re-configure each module from the S³ settings pa
If these mods are detected you will be prompted to disable them in the S³ settings page.
---
## Building
Requires the .NET SDK (`dotnet`). The native module additionally needs CMake +

BIN
S3 Logo - clean.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

BIN
img/map/transparency.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View file

@ -29,8 +29,30 @@ enum UICmd : int32_t {
RotateSyncPlayer = 9, // toggle sync-rotation-to-player-camera mode
ToggleFollowPlayer = 10, // toggle continuous follow of player world position
PopOut = 11, // (in-game only) close the overlay and open the OS popout
SetTheme = 12, // (in-game only) apply a theme preset; y = preset index
SetAlpha = 13, // (in-game only) set overlay (chrome) alpha; y = [0.1, 1.0]
Close = 14, // (in-game only) hide the overlay (user clicked [X])
SetMapAlpha = 15, // (in-game only) set map image alpha; y = [0.0, 1.0]
};
// Theme data pushed from C# to native. Controls ImGui style colours and the
// compass rose colours drawn via DrawList. Must match MapThemeData in NativeInterop.cs.
#pragma pack(push, 4)
struct MapThemeData {
float wBgR, wBgG, wBgB, wBgA; // window / toolbar background
float accR, accG, accB, accA; // accent (TitleBgActive, buttons, resize grip)
float txtR, txtG, txtB, txtA; // status text and labels
float popR, popG, popB, popA; // settings popup background
float mapR, mapG, mapB, mapA; // map Image() tint (1,1,1,1 = no tint)
float cmpR, cmpG, cmpB, cmpA; // compass background disk
float nNR, nNG, nNB, nNA; // compass needle N-pointing half
float nSR, nSG, nSB, nSA; // compass needle S-pointing half
float mapBgR, mapBgG, mapBgB, mapBgA; // map camera clear colour (alpha unused, kept for alignment)
};
#pragma pack(pop)
static_assert(sizeof(MapThemeData) == 144, "MapThemeData size mismatch — update NativeInterop.cs");
#pragma pack(push, 4)
struct InputEvent {
int32_t type; // InputEventType

View file

@ -1,4 +1,5 @@
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <Windows.h>
#include <d3d11.h>
#include <dxgi.h>
@ -37,6 +38,18 @@ static ComPtr<ID3D11DepthStencilState> g_depthState;
static bool g_imguiInited = false;
// ---------------------------------------------------------------------------
// Theme state — written by Renderer_SetTheme (main thread), applied on the
// render thread at frame start. Mutex protects the struct copy; g_themeChanged
// signals the render thread to call ApplyThemeToStyle before NewFrame.
// ---------------------------------------------------------------------------
static MapThemeData g_theme;
static std::mutex g_themeMutex;
static std::atomic<bool> g_themeChanged {false};
static std::atomic<int> g_currentThemePreset {0};
static std::atomic<float> g_ovAlpha {1.0f}; // chrome (window + toolbar + compass) alpha
static std::atomic<float> g_mapAlpha {1.0f}; // map Image() alpha, independent of chrome
// cbuffer layout — must be 16-byte aligned
struct alignas(16) UVRectCB { float u0, v0, u1, v1; };
@ -149,6 +162,92 @@ struct D3D11StateBlock {
}
};
// ---------------------------------------------------------------------------
// Theme helpers — render-thread only
// ---------------------------------------------------------------------------
// Apply g_theme to ImGui's style. Safe after ImGui::CreateContext(); does not need
// the DX11 backend. Called on the render thread before NewFrame, or from InitImGui.
static void ApplyThemeToStyle(const MapThemeData& t) {
ImGuiStyle& s = ImGui::GetStyle();
auto iv4 = [](float r, float g, float b, float a) { return ImVec4(r, g, b, a); };
auto darken = [](ImVec4 c, float f) {
return ImVec4(c.x*f, c.y*f, c.z*f, c.w);
};
auto brighten = [](ImVec4 c, float f) {
return ImVec4(std::min(1.f, c.x*f), std::min(1.f, c.y*f), std::min(1.f, c.z*f), c.w);
};
ImVec4 bg = iv4(t.wBgR, t.wBgG, t.wBgB, t.wBgA);
ImVec4 acc = iv4(t.accR, t.accG, t.accB, t.accA);
ImVec4 txt = iv4(t.txtR, t.txtG, t.txtB, t.txtA);
ImVec4 pop = iv4(t.popR, t.popG, t.popB, t.popA);
s.Colors[ImGuiCol_WindowBg] = bg;
s.Colors[ImGuiCol_ChildBg] = bg;
s.Colors[ImGuiCol_PopupBg] = pop;
s.Colors[ImGuiCol_Text] = txt;
s.Colors[ImGuiCol_TextDisabled] = darken(txt, 0.5f);
s.Colors[ImGuiCol_TitleBgActive] = acc;
s.Colors[ImGuiCol_TitleBg] = darken(acc, 0.55f);
s.Colors[ImGuiCol_TitleBgCollapsed] = darken(acc, 0.35f);
s.Colors[ImGuiCol_Button] = darken(acc, 0.55f);
s.Colors[ImGuiCol_ButtonHovered] = darken(acc, 0.80f);
s.Colors[ImGuiCol_ButtonActive] = acc;
s.Colors[ImGuiCol_Header] = darken(acc, 0.40f);
s.Colors[ImGuiCol_HeaderHovered] = darken(acc, 0.65f);
s.Colors[ImGuiCol_HeaderActive] = acc;
s.Colors[ImGuiCol_FrameBg] = iv4(t.wBgR+0.07f, t.wBgG+0.07f, t.wBgB+0.07f, t.wBgA);
s.Colors[ImGuiCol_FrameBgHovered] = darken(acc, 0.35f);
s.Colors[ImGuiCol_FrameBgActive] = darken(acc, 0.55f);
s.Colors[ImGuiCol_SliderGrab] = brighten(acc, 1.1f);
s.Colors[ImGuiCol_SliderGrabActive] = brighten(acc, 1.4f);
s.Colors[ImGuiCol_CheckMark] = brighten(acc, 1.2f);
s.Colors[ImGuiCol_ResizeGrip] = iv4(t.accR, t.accG, t.accB, 0.20f);
s.Colors[ImGuiCol_ResizeGripHovered] = iv4(t.accR, t.accG, t.accB, 0.67f);
s.Colors[ImGuiCol_ResizeGripActive] = acc;
s.Colors[ImGuiCol_Separator] = iv4(t.accR, t.accG, t.accB, 0.35f);
s.Colors[ImGuiCol_SeparatorHovered] = darken(acc, 0.7f);
s.Colors[ImGuiCol_SeparatorActive] = acc;
s.Colors[ImGuiCol_MenuBarBg] = darken(bg, 0.85f);
s.Colors[ImGuiCol_ScrollbarBg] = darken(bg, 0.70f);
s.Colors[ImGuiCol_ScrollbarGrab] = darken(acc, 0.55f);
s.Colors[ImGuiCol_ScrollbarGrabHovered] = darken(acc, 0.75f);
s.Colors[ImGuiCol_ScrollbarGrabActive] = acc;
}
// Snapshots g_theme under a single mutex lock, applying a pending style change first
// if g_themeChanged is set. Replaces the previous double-lock pattern (lock to apply,
// then lock again to copy). Call once at the top of each render entry point.
static MapThemeData SnapshotTheme() {
bool changed = g_themeChanged.exchange(false);
std::lock_guard<std::mutex> lk(g_themeMutex);
if (changed) ApplyThemeToStyle(g_theme);
return g_theme;
}
// Default S3 Dark theme — identical to what InitImGui used to hardcode.
static const MapThemeData kS3DarkTheme = {
0.12f, 0.12f, 0.12f, 1.00f, // windowBg
0.26f, 0.59f, 0.98f, 1.00f, // accent (ImGui default TitleBgActive blue)
1.00f, 1.00f, 1.00f, 1.00f, // text
0.08f, 0.08f, 0.08f, 0.94f, // popupBg
1.00f, 1.00f, 1.00f, 1.00f, // mapTint (no tint)
0.086f,0.086f,0.086f,0.784f, // compassBg (IM_COL32(22,22,22,200))
0.804f,0.196f,0.196f,1.00f, // needleN (IM_COL32(205,50,50,255))
0.804f,0.804f,0.804f,0.863f, // needleS (IM_COL32(205,205,205,220))
0.082f,0.122f,0.157f,1.00f, // mapBg camera clear (dark blue-gray)
};
// ---------------------------------------------------------------------------
// ImGui initialisation (call once per D3D11 device)
// ---------------------------------------------------------------------------
@ -160,7 +259,7 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) {
io.IniFilename = nullptr; // no imgui.ini — we're a mod, not a standalone app
io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; // Win32 owns the cursor
// Dark theme base
// Dark theme base — specific colours are overridden by ApplyThemeToStyle below.
ImGui::StyleColorsDark();
ImGuiStyle& style = ImGui::GetStyle();
style.WindowRounding = 0.f;
@ -171,8 +270,10 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) {
style.FrameRounding = 3.f;
style.GrabRounding = 3.f;
style.ScrollbarRounding = 3.f;
// Slightly warmer toolbar background to distinguish from the map
style.Colors[ImGuiCol_WindowBg] = ImVec4(0.12f, 0.12f, 0.12f, 1.f);
// Apply the current theme (or the default S3 Dark if none has been pushed yet).
{ 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.
@ -248,6 +349,13 @@ void Renderer_OnDeviceInit(ID3D11Device* device) {
InitImGui(device, g_context);
}
void Renderer_SetTheme(const MapThemeData* t, int presetIndex) {
if (!t) return;
{ std::lock_guard<std::mutex> lk(g_themeMutex); g_theme = *t; }
g_currentThemePreset.store(presetIndex);
g_themeChanged.store(true);
}
void Renderer_OnDeviceShutdown() {
if (g_imguiInited) {
ImGui_ImplDX11_Shutdown();
@ -321,7 +429,8 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win) {
// reserveResizeGrip: shorten the toolbar so the in-game ImGui window's bottom-right
// resize grip stays grabbable (the popout uses its OS window border instead).
static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
bool reserveResizeGrip, bool showPopOutBtn) {
bool reserveResizeGrip, bool showPopOutBtn,
const MapThemeData& theme) {
ImGuiIO& io = ImGui::GetIO();
// Shared command helper — usable from any window in this frame
@ -383,12 +492,26 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
ImVec2 wp = ImGui::GetWindowPos();
ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f };
// Derive compass colours from theme
auto col32 = [](float r, float g, float b, float a) -> ImU32 {
return IM_COL32((int)(r*255), (int)(g*255), (int)(b*255), (int)(a*255));
};
ImU32 bgCol = cAct ? col32(theme.cmpR*1.6f, theme.cmpG*1.6f, theme.cmpB*1.6f, theme.cmpA)
: cHov ? col32(theme.cmpR*1.3f, theme.cmpG*1.3f, theme.cmpB*1.3f, theme.cmpA)
: col32(theme.cmpR, theme.cmpG, theme.cmpB, theme.cmpA);
ImU32 ringCol = col32(theme.accR*0.5f, theme.accG*0.5f, theme.accB*0.5f, 0.78f);
ImU32 tickCard = col32(theme.txtR*0.67f, theme.txtG*0.67f, theme.txtB*0.67f, 0.86f);
ImU32 tickIter = col32(theme.txtR*0.50f, theme.txtG*0.50f, theme.txtB*0.50f, 0.51f);
ImU32 needleN = col32(theme.nNR, theme.nNG, theme.nNB, theme.nNA);
ImU32 needleS = col32(theme.nSR, theme.nSG, theme.nSB, theme.nSA);
ImU32 labelN = col32(std::min(1.f,theme.nNR*1.1f), std::min(1.f,theme.nNG*1.1f),
std::min(1.f,theme.nNB*1.1f), 1.0f);
ImU32 capOuter = col32(theme.cmpR*0.85f, theme.cmpG*0.85f, theme.cmpB*0.85f, 1.0f);
ImU32 capInner = col32(theme.txtR*0.82f, theme.txtG*0.82f, theme.txtB*0.82f, 1.0f);
// Background disk
dl->AddCircleFilled(cc, kCR + 2.f,
cAct ? IM_COL32(40,40,40,230)
: cHov ? IM_COL32(32,32,32,230)
: IM_COL32(22,22,22,200), 48);
dl->AddCircle(cc, kCR + 2.f, IM_COL32(100,100,100,200), 48, 1.5f);
dl->AddCircleFilled(cc, kCR + 2.f, bgCol, 48);
dl->AddCircle(cc, kCR + 2.f, ringCol, 48, 1.5f);
// Tick marks: 8 directions — cardinals longer, inter-cardinals shorter
for (int i = 0; i < 8; ++i) {
@ -398,30 +521,30 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
float inner = kCR - (card ? 9.f : 5.f);
dl->AddLine({ cc.x + ox*inner, cc.y + oy*inner },
{ cc.x + ox*(kCR - 1.f), cc.y + oy*(kCR - 1.f) },
IM_COL32(170,170,170, card ? 220 : 130),
card ? tickCard : tickIter,
card ? 1.5f : 1.f);
}
// Needle: diamond split red (N) / white (S)
// Needle: diamond split N / S colours from theme
float nx = sinf(rotRad), ny = -cosf(rotRad);
float bx = -ny * 7.f, by = nx * 7.f; // perpendicular half-width
float bx = -ny * 7.f, by = nx * 7.f;
ImVec2 nTip = { cc.x + nx*(kCR-5.f), cc.y + ny*(kCR-5.f) };
ImVec2 sTail = { cc.x - nx*(kCR-5.f)*0.55f, cc.y - ny*(kCR-5.f)*0.55f };
ImVec2 bleft = { cc.x + bx, cc.y + by };
ImVec2 brght = { cc.x - bx, cc.y - by };
dl->AddTriangleFilled(nTip, bleft, brght, IM_COL32(205,50,50,255)); // N red
dl->AddTriangleFilled(sTail, bleft, brght, IM_COL32(205,205,205,220));// S white
dl->AddTriangleFilled(nTip, bleft, brght, needleN);
dl->AddTriangleFilled(sTail, bleft, brght, needleS);
dl->AddTriangle(nTip, bleft, brght, IM_COL32(0,0,0,100), 0.5f);
dl->AddTriangle(sTail, bleft, brght, IM_COL32(0,0,0, 80), 0.5f);
// Centre cap
dl->AddCircleFilled(cc, 5.5f, IM_COL32(25,25,25,255));
dl->AddCircleFilled(cc, 3.5f, IM_COL32(210,210,210,255));
dl->AddCircleFilled(cc, 5.5f, capOuter);
dl->AddCircleFilled(cc, 3.5f, capInner);
// "N" label at needle tip
ImVec2 nPos = { cc.x + nx*(kCR + 4.f) - 4.f, cc.y + ny*(kCR + 4.f) - 7.f };
dl->AddText(ImGui::GetFont(), 14.f, nPos, IM_COL32(220,55,55,255), "N");
dl->AddText(ImGui::GetFont(), 14.f, nPos, labelN, "N");
}
if (cHov)
@ -458,18 +581,29 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
const float kBtnW = 22.f;
const float kGap = 4.f;
// Toolbar buttons use the title-bar / resize-grip blue so the UI reads as one
// theme; the follow toggle brightens to that blue's lit shade when active.
// Toolbar buttons use the accent colour from the current theme. Active (lit)
// state is a 40% brighter version of the accent.
const ImVec4 kBtnBlue = ImGui::GetStyleColorVec4(ImGuiCol_TitleBgActive);
const ImVec4 kBtnBlueActive = ImVec4(0.26f, 0.45f, 0.72f, 1.f);
const ImVec4 kBtnBlueActive = ImVec4(
std::min(1.f, kBtnBlue.x * 1.4f),
std::min(1.f, kBtnBlue.y * 1.4f),
std::min(1.f, kBtnBlue.z * 1.4f), 1.f);
// Luminance-based contrast text: same formula as Railroader's ColorHelper.IsDark()
auto contrastText = [](ImVec4 bg) -> ImVec4 {
float L = 0.299f*bg.x + 0.587f*bg.y + 0.114f*bg.z;
return L > 0.45f ? ImVec4(0.10f, 0.10f, 0.10f, 1.f)
: ImVec4(0.95f, 0.95f, 0.95f, 1.f);
};
// >> Pop Out (in-game overlay only) — switches map to the OS popout window
if (showPopOutBtn) {
ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW - kGap - kBtnW);
ImGui::PushStyleColor(ImGuiCol_Button, kBtnBlue);
ImGui::PushStyleColor(ImGuiCol_Text, contrastText(kBtnBlue));
if (ImGui::Button(">>##popout", ImVec2(kBtnW, kBtnH)))
pushCmd(UICmd::PopOut);
ImGui::PopStyleColor();
ImGui::PopStyleColor(2);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Open in popout window");
}
@ -478,10 +612,12 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW);
{
bool fOn = win->imFollowPlayer.load();
ImGui::PushStyleColor(ImGuiCol_Button, fOn ? kBtnBlueActive : kBtnBlue);
ImVec4 fColor = fOn ? kBtnBlueActive : kBtnBlue;
ImGui::PushStyleColor(ImGuiCol_Button, fColor);
ImGui::PushStyleColor(ImGuiCol_Text, contrastText(fColor));
if (ImGui::Button("\xe2\x97\x8e##follow", ImVec2(kBtnW, kBtnH)))
pushCmd(UICmd::ToggleFollowPlayer);
ImGui::PopStyleColor();
ImGui::PopStyleColor(2);
if (ImGui::IsItemHovered())
ImGui::SetTooltip(fOn ? "Following player position\nClick to stop"
: "Follow player position");
@ -490,9 +626,10 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
// ⚙ Settings gear
ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW);
ImGui::PushStyleColor(ImGuiCol_Button, kBtnBlue);
ImGui::PushStyleColor(ImGuiCol_Text, contrastText(kBtnBlue));
if (ImGui::Button("\xe2\x9a\x99##cfg", ImVec2(kBtnW, kBtnH)))
ImGui::OpenPopup("##settings");
ImGui::PopStyleColor();
ImGui::PopStyleColor(2);
if (ImGui::BeginPopup("##settings")) {
if (ImGui::MenuItem("Center on player"))
@ -554,6 +691,40 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
}
}
ImGui::Separator();
// Theme preset picker
if (ImGui::BeginMenu("Theme")) {
int cur = g_currentThemePreset.load();
if (ImGui::MenuItem("S3 Dark", nullptr, cur == 0)) pushCmd(UICmd::SetTheme, 0.f);
if (ImGui::MenuItem("Railroader Classic", nullptr, cur == 1)) pushCmd(UICmd::SetTheme, 1.f);
if (ImGui::MenuItem("Night Mode", nullptr, cur == 2)) pushCmd(UICmd::SetTheme, 2.f);
if (ImGui::MenuItem("High Visibility", nullptr, cur == 3)) pushCmd(UICmd::SetTheme, 3.f);
if (ImGui::MenuItem("Retro Terminal", nullptr, cur == 4)) pushCmd(UICmd::SetTheme, 4.f);
ImGui::Separator();
if (ImGui::MenuItem("Custom", nullptr, cur == 5)) pushCmd(UICmd::SetTheme, 5.f);
ImGui::EndMenu();
}
// Opacity sliders (in-game overlay only — popout is a native OS window)
if (showPopOutBtn) {
ImGui::Separator();
// Chrome alpha — window frame, toolbar, compass
float alpha = g_ovAlpha.load();
ImGui::SetNextItemWidth(100.f);
if (ImGui::SliderFloat("Window##ov", &alpha, 0.1f, 1.0f, "%.2f"))
g_ovAlpha.store(alpha);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::SetAlpha, g_ovAlpha.load());
// Map image alpha — independent of chrome
float mAlpha = g_mapAlpha.load();
ImGui::SetNextItemWidth(100.f);
if (ImGui::SliderFloat("Map##ov", &mAlpha, 0.0f, 1.0f, "%.2f"))
g_mapAlpha.store(mAlpha);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::SetMapAlpha, g_mapAlpha.load());
}
ImGui::EndPopup();
}
@ -631,6 +802,8 @@ void Renderer_Present(PopoutWindow* win) {
// ImGui overlay — compass rose on map + toolbar strip at bottom
// -----------------------------------------------------------------------
if (g_imguiInited) {
MapThemeData theme = SnapshotTheme();
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2((float)w, (float)h);
io.MousePos = ImVec2(win->imMouseX.load(), win->imMouseY.load());
@ -642,7 +815,8 @@ void Renderer_Present(PopoutWindow* win) {
ImGui_ImplDX11_NewFrame();
ImGui::NewFrame();
BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h, /*reserveResizeGrip=*/false, /*showPopOutBtn=*/false);
BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h,
/*reserveResizeGrip=*/false, /*showPopOutBtn=*/false, theme);
ImGui::Render();
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
@ -689,6 +863,9 @@ void Overlay_GetMapView(float* outW, float* outH) {
if (outH) *outH = g_ovViewH.load();
}
void Overlay_SetAlpha(float alpha) { g_ovAlpha.store(std::max(0.1f, std::min(1.0f, alpha))); }
void Overlay_SetMapAlpha(float alpha) { g_mapAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); }
// Map-image interaction (drag/zoom) queued here for C# to forward to the map
// camera. Same InputEvent contract the popout uses, so C# can share the logic.
static InputQueue g_ovInput;
@ -739,6 +916,8 @@ void Renderer_PresentOverlay() {
D3D11StateBlock saved;
saved.Capture(g_context);
MapThemeData theme = SnapshotTheme();
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2(w, h);
io.MousePos = ImVec2(g_ovMouseX.load(), g_ovMouseY.load());
@ -749,6 +928,15 @@ void Renderer_PresentOverlay() {
ImGui_ImplDX11_NewFrame();
ImGui::NewFrame();
// Chrome (window bg, title bar, toolbar, compass) alpha. Pushed before Begin()
// so all decorations are affected; popped around the map Image() so the map
// image has its own independent alpha, then popped finally after BuildMapUI.
const float ovAlpha = g_ovAlpha.load();
const float mapAlpha = g_mapAlpha.load();
const bool hasAlpha = ovAlpha < 0.999f;
if (hasAlpha)
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ovAlpha);
g_ovMapSRV.Reset(); // release last frame's view before building this frame's
// Captured inside the window, used to position the shared chrome (toolbar +
@ -761,14 +949,22 @@ void Renderer_PresentOverlay() {
// Zero padding so the map fills the window edge-to-edge and the toolbar sits
// flush at the bottom (acting as the border) with no dark frame around it.
// WindowPadding is only read during Begin() so we pop it right after — this
// keeps it off the style stack so we can freely pop/push ovAlpha around Image().
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f));
ImGui::SetNextWindowPos(ImVec2(80.f, 80.f), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(720.f, 480.f), ImGuiCond_FirstUseEver);
// NoBringToFrontOnFocus: keep this window behind the chrome windows (toolbar +
// compass) that BuildMapUI creates after it, so its opaque map image does not
// cover them when the map area is clicked/focused.
if (ImGui::Begin("Railroader Map##s3ingame", nullptr,
ImGuiWindowFlags_NoBringToFrontOnFocus)) {
// p_open = true: ImGui adds an [X] close button to the title bar. When the user
// clicks it ImGui sets windowOpen=false; we push UICmd::Close so C# can call
// HideIfVisible() and DeactivateMap(). NoBringToFrontOnFocus keeps this window
// behind the chrome windows (toolbar + compass) that BuildMapUI creates after it.
bool windowOpen = true;
bool windowVisible = ImGui::Begin("Railroader Map##s3ingame", &windowOpen,
ImGuiWindowFlags_NoBringToFrontOnFocus);
// WindowPadding is read; pop now so ovAlpha is the only active style var.
ImGui::PopStyleVar();
if (windowVisible) {
void* mapTex = g_ovMapTex.load();
ImVec2 avail = ImGui::GetContentRegionAvail();
@ -809,10 +1005,15 @@ void Renderer_PresentOverlay() {
if (hov && io.MouseWheel != 0.f) pushOv(MouseWheel, io.MouseWheel);
// Draw the map over the same rect the hit-button occupies.
// Map alpha is independent of chrome alpha: pop ovAlpha so
// ImGuiStyleVar_Alpha doesn't multiply into the tint, then push back.
ImGui::SetCursorScreenPos(imgPos);
ImVec2 uv0(g_ovMapU0.load(), g_ovMapV0.load());
ImVec2 uv1(g_ovMapU1.load(), g_ovMapV1.load());
ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1);
ImVec4 tint(theme.mapR, theme.mapG, theme.mapB, theme.mapA * mapAlpha);
if (hasAlpha) ImGui::PopStyleVar(); // lift chrome alpha
ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1, tint);
if (hasAlpha) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ovAlpha);
// Visible resize-grip indicator. ImGui draws its own grip during
// Begin() — behind our opaque map image, so invisible. We draw one
@ -836,12 +1037,24 @@ void Renderer_PresentOverlay() {
}
}
ImGui::End();
ImGui::PopStyleVar(); // WindowPadding
// WindowPadding was already popped right after Begin(); nothing extra to pop here.
// If the user clicked [X], push a Close command for C# to process.
if (!windowOpen && stateWin) {
InputEvent ev{}; ev.type = UICommand;
ev.x = static_cast<float>(UICmd::Close);
stateWin->inputQueue.push(ev);
}
// Shared map chrome (toolbar + compass), positioned over the map image so the
// in-game map and the popout present the same UI. Reads the shared state holder.
if (mapShown && stateWin)
BuildMapUI(stateWin, mapScreenPos, mapSize.x, mapSize.y, /*reserveResizeGrip=*/true, /*showPopOutBtn=*/true);
BuildMapUI(stateWin, mapScreenPos, mapSize.x, mapSize.y,
/*reserveResizeGrip=*/true, /*showPopOutBtn=*/true, theme);
// Pop the global alpha AFTER all chrome windows, before Render().
if (hasAlpha)
ImGui::PopStyleVar();
ImGui::Render();

View file

@ -49,5 +49,16 @@ void Overlay_SetVisible(bool visible);
// True when ImGui wants the mouse (hovering a widget) — C# uses this to swallow input.
bool Overlay_WantsMouse();
// Apply a new theme. Thread-safe: stores data then applies on the render thread at
// next frame start (before NewFrame), so ImGui style is never written from main thread.
// presetIndex is echoed back into the gear menu checkmarks.
void Renderer_SetTheme(const MapThemeData* t, int presetIndex);
// Set the in-game overlay's chrome (window + toolbar + compass) alpha [0.1, 1.0].
void Overlay_SetAlpha(float alpha);
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha. Thread-safe via atomic.
void Overlay_SetMapAlpha(float alpha);
// Render the overlay into the currently bound RTV. Render thread only.
void Renderer_PresentOverlay();

View file

@ -269,6 +269,25 @@ void RRPOPOUT_SetStatusText(int windowHandle, const wchar_t* text) {
strncpy_s(win->imStatusText, buf, _TRUNCATE);
}
// Apply a theme preset. data is a pointer to a MapThemeData struct; presetIndex is
// stored for checkmark display in the gear menu. Thread-safe: stores via mutex + atomic.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTheme(const MapThemeData* data, int presetIndex) {
Renderer_SetTheme(data, presetIndex);
}
// Set the in-game overlay's chrome (window + toolbar + compass) alpha [0.1, 1.0].
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayAlpha(float alpha) {
Overlay_SetAlpha(alpha);
}
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetOverlayMapAlpha(float alpha) {
Overlay_SetMapAlpha(alpha);
}
// ---------------------------------------------------------------------------
// Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp)
// ---------------------------------------------------------------------------

View file

@ -194,6 +194,12 @@ internal sealed class UiHost : MonoBehaviour
_overlayHandle = Native.RRPOPOUT_GetOverlayWindowHandle();
_nativeReady = true;
// Apply saved theme and both alpha settings so the overlay opens with the right look.
MapThemes.ApplyFromSettings();
Native.RRPOPOUT_SetOverlayAlpha(PopoutModule.Settings.overlayAlpha);
Native.RRPOPOUT_SetOverlayMapAlpha(PopoutModule.Settings.overlayMapAlpha);
StartCoroutine(RenderLoop());
Log.Info("[ui] in-game overlay ready - press F10 to toggle.");
}
@ -335,6 +341,29 @@ internal sealed class UiHost : MonoBehaviour
}
}
// Hides `win` by zeroing its localScale, saving the original for later restore.
// Safe to call multiple times — skips if _hiddenWindow is already set.
private void PreHideWindow(Window win)
{
if (_hiddenWindow != null) return;
if (win.transform is RectTransform rect)
{
_savedWinScale = rect.localScale;
rect.localScale = Vector3.zero;
}
_hiddenWindow = win;
}
// Restores _hiddenWindow's localScale on the next frame so any close
// animation the game plays runs while the window is still invisible (scale=0),
// then scale is returned to normal once it is fully hidden.
private System.Collections.IEnumerator DeferredScaleRestore(Window win, Vector3 scale)
{
yield return null;
if (win != null && win.transform is RectTransform rect)
rect.localScale = scale;
}
// Dragging cancels follow modes so the pan takes over (grab-to-pan).
private void CancelFollowForPan()
{
@ -462,6 +491,33 @@ internal sealed class UiHost : MonoBehaviour
// ensuring the RT is fully torn down before the next owner grabs it.
PopoutModule.ScheduleExternalLaunch();
break;
case UICmd.SetTheme:
MapThemes.Apply((MapTheme)(int)e.y);
// Also update the map camera background colour immediately so it
// matches the new theme even before the next ActivateMap.
if (_mapCamera != null)
_mapCamera.backgroundColor =
MapThemes.GetMapBgColor((MapTheme)(int)e.y);
break;
case UICmd.SetAlpha:
float alpha = Mathf.Clamp(e.y, 0.1f, 1.0f);
PopoutModule.Settings.overlayAlpha = alpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayAlpha(alpha);
break;
case UICmd.Close:
HideIfVisible();
break;
case UICmd.SetMapAlpha:
float mapAlpha = Mathf.Clamp(e.y, 0.0f, 1.0f);
PopoutModule.Settings.overlayMapAlpha = mapAlpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha);
break;
}
}
@ -537,22 +593,27 @@ internal sealed class UiHost : MonoBehaviour
var winUI = PanelFinder.GetMapWindowUI();
_mapWasOpen = winUI != null && winUI.IsShown;
// Pre-hide the window BEFORE Show() so it never has a frame at full
// scale. Belt 1: catches the common case and prevents flash even if
// IsMapReady() returns false and we bail out early below.
if (winUI != null) PreHideWindow(winUI);
UiService.MapBypass = true;
MapWindow.Show();
UiService.MapBypass = false;
if (!PanelFinder.IsMapReady()) return; // not ready yet; retry next frame
if (!PanelFinder.IsMapReady()) return; // still hidden thanks to pre-hide
_mapCamera = MapBuilder.Shared!.mapCamera;
// Scale the in-game map panel to zero (invisible, still active) so its
// full-screen UI cost AND its chrome (Pop Out button, close X) go away
// while we render the camera ourselves. localScale hides children too,
// unlike sizeDelta which left the corner-anchored buttons visible.
_hiddenWindow = PanelFinder.GetMapWindowUI();
if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect)
// Belt 2: if pre-hide missed the window (it was null before Show) or
// Show() reset the localScale, zero it again now.
winUI = PanelFinder.GetMapWindowUI();
if (winUI != null)
{
_savedWinScale = rect.localScale;
rect.localScale = Vector3.zero;
PreHideWindow(winUI); // no-op if _hiddenWindow already set
if (_hiddenWindow?.transform is RectTransform rect2 && rect2.localScale != Vector3.zero)
rect2.localScale = Vector3.zero;
}
_savedTarget = _mapCamera.targetTexture;
@ -567,6 +628,11 @@ internal sealed class UiHost : MonoBehaviour
_mapCamera.targetTexture = _ownRT;
_mapCamera.rect = new Rect(0f, 0f, 1f, 1f);
// Apply the theme's map camera background colour (visible as empty-space
// colour behind terrain/water). Safe to set anytime after camera takeover.
_mapCamera.backgroundColor =
MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
Canvas.ForceUpdateCanvases();
PanelFinder.UpdateMapForZoom();
@ -628,10 +694,12 @@ internal sealed class UiHost : MonoBehaviour
MapWindow.Toggle();
UiService.MapBypass = false;
}
// Always restore the scale so the next map open starts from normal state.
if (_hiddenWindow.transform is RectTransform rect)
rect.localScale = _savedWinScale;
// Defer the scale restore by one frame so Unity can finish any close animation
// at scale=0 before we put the window back to its normal size.
Window winToRestore = _hiddenWindow;
Vector3 scaleToRestore = _savedWinScale;
_hiddenWindow = null;
StartCoroutine(DeferredScaleRestore(winToRestore, scaleToRestore));
}
_drag = false;

View file

@ -375,6 +375,26 @@ namespace S3.Modules.Popout {
MapEnhancerBridge.ToggleFollowMode();
Native.RRPOPOUT_SetFollowPlayer(_windowHandle, _followPlayer);
break;
case UICmd.Close:
CloseRequested = true;
break;
case UICmd.SetTheme:
MapThemes.Apply((MapTheme)(int)e.y);
if (_mapCamera != null)
_mapCamera.backgroundColor = MapThemes.GetMapBgColor((MapTheme)(int)e.y);
break;
case UICmd.SetAlpha:
float alpha = Mathf.Clamp(e.y, 0.1f, 1.0f);
PopoutModule.Settings.overlayAlpha = alpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayAlpha(alpha);
break;
case UICmd.SetMapAlpha:
float mapAlpha = Mathf.Clamp(e.y, 0.0f, 1.0f);
PopoutModule.Settings.overlayMapAlpha = mapAlpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha);
break;
}
break;
}

View file

@ -0,0 +1,127 @@
using S3.Core;
using UnityEngine;
namespace S3.Modules.Popout;
public enum MapTheme {
S3Dark = 0,
RailroaderClassic = 1,
NightMode = 2,
HighVisibility = 3,
RetroTerminal = 4,
Custom = 5, // uses PopoutModule.Settings.customTheme
}
internal static class MapThemes
{
// ---------------------------------------------------------------------------
// Named preset definitions (indices 0-4).
// Custom (index 5) is stored in PopoutModule.Settings.customTheme instead.
// ---------------------------------------------------------------------------
private static readonly MapThemeData kS3Dark = new() {
wBgR=0.12f, wBgG=0.12f, wBgB=0.12f, wBgA=1.00f,
accR=0.26f, accG=0.59f, accB=0.98f, accA=1.00f,
txtR=1.00f, txtG=1.00f, txtB=1.00f, txtA=1.00f,
popR=0.08f, popG=0.08f, popB=0.08f, popA=0.94f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.086f,cmpG=0.086f,cmpB=0.086f,cmpA=0.784f,
nNR=0.804f, nNG=0.196f, nNB=0.196f, nNA=1.00f,
nSR=0.804f, nSG=0.804f, nSB=0.804f, nSA=0.863f,
mapBgR=0.082f,mapBgG=0.122f,mapBgB=0.157f,mapBgA=1.00f,
};
// Colors sourced from game decompilation (Assembly-CSharp.dll):
// text = RailroaderButton._defaultTextColor (0.851, 0.776, 0.651)
// accent = TextStyles.Yellow (#DBB95C = 0.859, 0.725, 0.361)
// Window/panel backgrounds are in binary Unity prefabs; estimated from in-game visuals.
private static readonly MapThemeData kRailroaderClassic = new() {
wBgR=0.078f,wBgG=0.051f,wBgB=0.020f,wBgA=0.97f,
accR=0.859f,accG=0.725f,accB=0.361f,accA=1.00f,
txtR=0.851f,txtG=0.776f,txtB=0.651f,txtA=1.00f,
popR=0.063f,popG=0.039f,popB=0.016f,popA=0.97f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.094f,cmpG=0.059f,cmpB=0.024f,cmpA=0.88f,
nNR=0.90f, nNG=0.15f, nNB=0.10f, nNA=1.00f,
nSR=0.85f, nSG=0.85f, nSB=0.85f, nSA=0.90f,
mapBgR=0.059f,mapBgG=0.039f,mapBgB=0.016f,mapBgA=1.00f,
};
private static readonly MapThemeData kNightMode = new() {
wBgR=0.05f, wBgG=0.03f, wBgB=0.03f, wBgA=0.90f,
accR=0.65f, accG=0.12f, accB=0.12f, accA=1.00f,
txtR=0.80f, txtG=0.55f, txtB=0.55f, txtA=1.00f,
popR=0.06f, popG=0.03f, popB=0.03f, popA=0.96f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.08f, cmpG=0.04f, cmpB=0.04f, cmpA=0.85f,
nNR=0.90f, nNG=0.15f, nNB=0.15f, nNA=1.00f,
nSR=0.80f, nSG=0.80f, nSB=0.80f, nSA=0.80f,
mapBgR=0.05f,mapBgG=0.02f,mapBgB=0.02f,mapBgA=1.00f,
};
private static readonly MapThemeData kHighVisibility = new() {
wBgR=0.20f, wBgG=0.20f, wBgB=0.22f, wBgA=1.00f,
accR=0.00f, accG=0.72f, accB=0.85f, accA=1.00f,
txtR=1.00f, txtG=1.00f, txtB=1.00f, txtA=1.00f,
popR=0.16f, popG=0.16f, popB=0.18f, popA=0.97f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.16f, cmpG=0.16f, cmpB=0.17f, cmpA=0.88f,
nNR=0.95f, nNG=0.20f, nNB=0.10f, nNA=1.00f,
nSR=0.90f, nSG=0.90f, nSB=0.90f, nSA=1.00f,
mapBgR=0.14f,mapBgG=0.18f,mapBgB=0.20f,mapBgA=1.00f,
};
private static readonly MapThemeData kRetroTerminal = new() {
wBgR=0.03f, wBgG=0.07f, wBgB=0.03f, wBgA=0.96f,
accR=0.00f, accG=0.80f, accB=0.30f, accA=1.00f,
txtR=0.00f, txtG=0.80f, txtB=0.30f, txtA=1.00f,
popR=0.02f, popG=0.05f, popB=0.02f, popA=0.98f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.03f, cmpG=0.08f, cmpB=0.03f, cmpA=0.88f,
nNR=0.95f, nNG=0.20f, nNB=0.10f, nNA=1.00f,
nSR=0.00f, nSG=0.75f, nSB=0.28f, nSA=1.00f,
mapBgR=0.02f,mapBgG=0.05f,mapBgB=0.02f,mapBgA=1.00f,
};
private static readonly MapThemeData[] _presets = {
kS3Dark, kRailroaderClassic, kNightMode, kHighVisibility, kRetroTerminal,
};
// ---------------------------------------------------------------------------
/// <summary>
/// Push <paramref name="preset"/> to native and save the index to settings.
/// For Custom (index 5), reads <see cref="PopoutSettings.customTheme"/>.
/// </summary>
public static void Apply(MapTheme preset)
{
int idx = Mathf.Clamp((int)preset, 0, 5);
MapThemeData data = idx == (int)MapTheme.Custom
? PopoutModule.Settings.customTheme
: _presets[idx];
Native.RRPOPOUT_SetTheme(ref data, idx);
PopoutModule.Settings.mapTheme = idx;
PopoutModule.Persist();
Log.Info($"[map] theme: {preset}");
}
/// <summary>Apply the theme stored in settings (call once after native loads).</summary>
public static void ApplyFromSettings() =>
Apply((MapTheme)Mathf.Clamp(PopoutModule.Settings.mapTheme, 0, 5));
/// <summary>
/// Return the named preset's data (indices 0-4). Used by the settings color
/// editor's "Copy from" buttons to seed the custom theme.
/// </summary>
public static MapThemeData GetPresetData(MapTheme preset) =>
_presets[Mathf.Clamp((int)preset, 0, _presets.Length - 1)];
/// <summary>Return the map camera clear colour for <paramref name="preset"/>.</summary>
public static Color GetMapBgColor(MapTheme preset)
{
MapThemeData d = preset == MapTheme.Custom
? PopoutModule.Settings.customTheme
: GetPresetData(preset);
return new Color(d.mapBgR, d.mapBgG, d.mapBgB, 1f);
}
}

View file

@ -34,6 +34,25 @@ namespace S3.Modules.Popout {
RotateSyncPlayer = 9, // toggle sync-rotation-to-player-camera mode
ToggleFollowPlayer = 10, // toggle continuous follow of player world position
PopOut = 11, // (in-game only) close the overlay and open the OS popout
SetTheme = 12, // (in-game only) apply theme preset; InputEvent.y = preset index
SetAlpha = 13, // (in-game only) set chrome alpha; InputEvent.y = [0.1, 1.0]
Close = 14, // (in-game only) user clicked [X] — hide the overlay
SetMapAlpha = 15, // (in-game only) set map image alpha; InputEvent.y = [0.0, 1.0]
}
// Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes).
[Serializable]
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct MapThemeData {
public float wBgR, wBgG, wBgB, wBgA; // window / toolbar background
public float accR, accG, accB, accA; // accent (TitleBgActive, buttons)
public float txtR, txtG, txtB, txtA; // status text
public float popR, popG, popB, popA; // settings popup background
public float mapR, mapG, mapB, mapA; // map image tint (1,1,1,1 = no tint)
public float cmpR, cmpG, cmpB, cmpA; // compass background disk
public float nNR, nNG, nNB, nNA; // compass needle N half
public float nSR, nSG, nSB, nSA; // compass needle S half
public float mapBgR, mapBgG, mapBgB, mapBgA; // map camera clear colour
}
internal static class Native {
@ -169,5 +188,19 @@ namespace S3.Modules.Popout {
// with the same handle-based functions the popout uses.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern int RRPOPOUT_GetOverlayWindowHandle();
// Apply a theme to the shared ImGui context. Affects both the in-game overlay
// and the popout (they share one context). presetIndex is stored for the gear
// menu checkmarks. Thread-safe: stored on main thread, applied on render thread.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTheme(ref MapThemeData data, int presetIndex);
// Set the in-game overlay's chrome alpha [0.1, 1.0]. Thread-safe via atomic.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayAlpha(float alpha);
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetOverlayMapAlpha(float alpha);
}
}

View file

@ -45,6 +45,13 @@ public sealed class PopoutModule : IModule
private static bool _pendingOverlayOpen;
private static float _pendingOverlayTimer;
// Deferred window scale restore: a 1-frame counter so any Toggle() close animation
// plays at scale=0 (invisible) before we put the panel back to its normal size.
// Static class has no MonoBehaviour, so we tick this down in Tick() instead of a coroutine.
private static Window? _pendingRestoreWindow;
private static Vector3 _pendingRestoreScale;
private static int _pendingRestoreFrames;
public static bool IsDetached => _activePanel != null;
public PopoutModule()
@ -54,7 +61,7 @@ public sealed class PopoutModule : IModule
}
public string Id => "popout";
public string DisplayName => "Map Popout";
public string DisplayName => "Map Module";
public string Description =>
"Detaches the in-game map into a separate resizable window you can put on a " +
"second monitor. Open a save, then press the hotkey or use the Pop Out button " +
@ -147,6 +154,23 @@ public sealed class PopoutModule : IModule
}
}
// While the popout is live, keep the in-game window zeroed every frame.
// MapWindow.Show() starts a Unity animation that can re-set localScale to
// non-zero across subsequent frames — this override wins each tick.
if (_activePanel != null && _hiddenWindow != null &&
_hiddenWindow.transform is RectTransform suppRect && suppRect.localScale != Vector3.zero)
{
suppRect.localScale = Vector3.zero;
}
// Deferred window scale restore (see RestoreInGameWindow).
if (_pendingRestoreFrames > 0 && --_pendingRestoreFrames == 0)
{
if (_pendingRestoreWindow != null && _pendingRestoreWindow.transform is RectTransform r)
r.localScale = _pendingRestoreScale;
_pendingRestoreWindow = null;
}
if (_hotkey.Down())
Toggle();
@ -207,6 +231,15 @@ public sealed class PopoutModule : IModule
Window? win = PanelFinder.GetMapWindowUI();
_mapWasOpen = win != null && win.IsShown;
// Belt 1: pre-hide the window BEFORE Show() so any open/restore animation
// plays at scale=0 and cannot flash through for one frame.
if (win != null && win.transform is RectTransform preRect)
{
_hiddenWindow = win;
_savedWindowScale = preRect.localScale;
preRect.localScale = Vector3.zero;
}
UiService.MapBypass = true;
MapWindow.Show();
UiService.MapBypass = false;
@ -217,15 +250,23 @@ public sealed class PopoutModule : IModule
return;
}
// Scale the in-game panel to zero (invisible, all components active). Using
// localScale (not sizeDelta) also scales away the corner-anchored chrome
// and restores reliably to (1,1,1).
_hiddenWindow = PanelFinder.GetMapWindowUI();
// Belt 2: re-zero in case Show() reset the scale; also captures the window if
// belt 1 missed because the window didn't exist before Show().
// localScale (not sizeDelta) scales away corner-anchored chrome reliably.
win = PanelFinder.GetMapWindowUI();
if (_hiddenWindow == null)
{
_hiddenWindow = win;
if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect)
{
_savedWindowScale = rect.localScale;
rect.localScale = Vector3.zero;
}
}
else if (_hiddenWindow.transform is RectTransform postRect && postRect.localScale != Vector3.zero)
{
postRect.localScale = Vector3.zero;
}
_activePanel = new DetachedPanel("Railroader Map");
if (!_activePanel.IsAlive)
@ -252,10 +293,11 @@ public sealed class PopoutModule : IModule
MapWindow.Toggle();
UiService.MapBypass = false;
}
// Always restore scale so the next map open starts from normal state.
if (_hiddenWindow.transform is RectTransform rect)
rect.localScale = _savedWindowScale;
// Defer scale restore by 1 frame so any Toggle() close animation plays at
// scale=0 before the panel snaps back to its normal size.
_pendingRestoreWindow = _hiddenWindow;
_pendingRestoreScale = _savedWindowScale;
_pendingRestoreFrames = 1;
_hiddenWindow = null;
}
}

View file

@ -6,6 +6,8 @@ namespace S3.Modules.Popout;
// Flat settings block for the Map Popout module (own file: S3.popout.json).
// Hotkey is stored as flat primitives rather than a UMM KeyBinding so JsonUtility
// round-trips it reliably (no nested custom-class serialization).
// customTheme is a [Serializable] struct — JsonUtility inlines value-type structs
// correctly (unlike nested classes, which Mono silently drops).
[Serializable]
public class PopoutSettings
{
@ -18,4 +20,28 @@ public class PopoutSettings
// When true, dragging the map cancels follow modes (MapEnhancer follow +
// follow-player) so the drag takes over — like grab-to-pan in Google Maps.
public bool panDisablesFollow = true;
// Active theme index (0-4 = named preset, 5 = Custom).
// See MapTheme enum in MapThemes.cs.
public int mapTheme = 0;
// In-game overlay chrome (window + toolbar + compass) alpha [0.1, 1.0].
public float overlayAlpha = 1.0f;
// In-game overlay map image alpha [0.0, 1.0]. Independent of chrome alpha.
public float overlayMapAlpha = 1.0f;
// 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 {
wBgR=0.12f, wBgG=0.12f, wBgB=0.12f, wBgA=1.00f,
accR=0.26f, accG=0.59f, accB=0.98f, accA=1.00f,
txtR=1.00f, txtG=1.00f, txtB=1.00f, txtA=1.00f,
popR=0.08f, popG=0.08f, popB=0.08f, popA=0.94f,
mapR=1.00f, mapG=1.00f, mapB=1.00f, mapA=1.00f,
cmpR=0.086f,cmpG=0.086f,cmpB=0.086f,cmpA=0.784f,
nNR=0.804f, nNG=0.196f, nNB=0.196f, nNA=1.00f,
nSR=0.804f, nSG=0.804f, nSB=0.804f, nSA=0.863f,
mapBgR=0.082f,mapBgG=0.122f,mapBgB=0.157f,mapBgA=1.00f,
};
}

View file

@ -1,13 +1,44 @@
using System;
using UnityEngine;
namespace S3.Modules.Popout;
// Renders the Map Popout settings as the body of its foldout: hotkey rebinding,
// detach/re-attach button, and current status.
// Renders the Map Popout settings: hotkey, behaviour toggles, theme preset buttons,
// a live per-element color editor for the Custom theme, and overlay opacity sliders.
static class PopoutSettingsUI
{
private static bool _capturing;
// ---------------------------------------------------------------------------
// Color editor state — one hex string per theme element, kept in sync with
// the sliders. Reset by InitColorEditor when a preset is copied or on first draw.
// ---------------------------------------------------------------------------
private static bool _colorEditorInit;
private static string _hexWBg = "1F1F1F";
private static string _hexAcc = "4296FA";
private static string _hexTxt = "FFFFFF";
private static string _hexPop = "141414";
private static string _hexMap = "FFFFFF";
private static string _hexCmp = "161616";
private static string _hexNN = "CD3232";
private static string _hexNS = "CDCDCD";
private static string _hexMapBg = "151F28";
private static bool _showColorEditor = true; // expanded by default
private static readonly string[] s_themeNames = { "S3 Dark", "Classic", "Night", "Hi-Vis", "Retro", "Custom" };
private static readonly string[] s_presetNames = { "S3 Dark", "Classic", "Night", "Hi-Vis", "Retro" };
private const float kLabelW = 92f;
private const float kThemeW = 68f; // wide enough for "S3 Dark" / "Classic"
private const float kHexW = 60f;
private const float kSwatchW = 20f;
private const float kChanL = 12f;
private const float kSliderW = 52f;
private const float kValW = 26f;
// ---------------------------------------------------------------------------
public static void Draw()
{
PopoutSettings s = PopoutModule.Settings;
@ -47,11 +78,174 @@ static class PopoutSettingsUI
GUILayout.Space(8f);
// ── Status + detach button ──────────────────────────────────────────────
GUILayout.Label(PopoutModule.IsDetached ? "Status: Detached" : "Status: Docked");
if (GUILayout.Button(PopoutModule.IsDetached ? "Re-attach map" : "Detach map", GUILayout.Width(160f)))
PopoutModule.Toggle();
// ── Theme preset quick-switch ────────────────────────────────────────────
GUILayout.BeginHorizontal();
GUILayout.Label("Theme:", GUILayout.Width(kLabelW));
for (int i = 0; i < s_themeNames.Length; i++)
{
GUI.color = s.mapTheme == i ? new Color(0.5f, 0.9f, 1.0f) : Color.white;
if (GUILayout.Button(s_themeNames[i], GUILayout.Width(kThemeW)))
MapThemes.Apply((MapTheme)i);
}
GUI.color = Color.white;
GUILayout.EndHorizontal();
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);
GUILayout.Space(8f);
// ── Status + detach button ──────────────────────────────────────────────
bool det = PopoutModule.IsDetached;
GUILayout.Label(det ? "Status: Detached" : "Status: Docked");
if (GUILayout.Button(det ? "Re-attach map" : "Detach map", GUILayout.Width(160f)))
PopoutModule.Toggle();
GUILayout.Space(8f);
// ── Custom color editor (collapsible) ────────────────────────────────────
string editorHeader = _showColorEditor ? "▲ Custom Colors (click to collapse)" : "▼ Custom Colors (click to expand)";
if (GUILayout.Button(editorHeader, GUILayout.Width(300f)))
_showColorEditor = !_showColorEditor;
if (_showColorEditor)
{
GUILayout.Space(4f);
GUILayout.Label("Hex = RRGGBB (no #). Drag sliders to tune live; changes switch to Custom.");
// "Copy from:" seeds the editor from a named preset and applies it as Custom
GUILayout.BeginHorizontal();
GUILayout.Label("Copy from:", GUILayout.Width(kLabelW));
for (int i = 0; i < s_presetNames.Length; i++)
{
if (GUILayout.Button(s_presetNames[i], GUILayout.Width(kThemeW)))
{
MapThemeData pd = MapThemes.GetPresetData((MapTheme)i);
s.customTheme = pd;
InitColorEditor(ref s.customTheme);
MapThemes.Apply(MapTheme.Custom);
}
}
GUILayout.EndHorizontal();
GUILayout.Space(4f);
if (!_colorEditorInit)
InitColorEditor(ref s.customTheme);
// Each row writes directly through ref to the struct field on the heap-allocated
// PopoutSettings class, so no copy-modify-write boilerplate is needed.
bool anyChanged = false;
anyChanged |= ColorRow("Window BG", ref s.customTheme.wBgR, ref s.customTheme.wBgG, ref s.customTheme.wBgB, ref s.customTheme.wBgA, showAlpha: true, ref _hexWBg);
anyChanged |= ColorRow("Accent", ref s.customTheme.accR, ref s.customTheme.accG, ref s.customTheme.accB, ref s.customTheme.accA, showAlpha: false, ref _hexAcc);
anyChanged |= ColorRow("Text", ref s.customTheme.txtR, ref s.customTheme.txtG, ref s.customTheme.txtB, ref s.customTheme.txtA, showAlpha: false, ref _hexTxt);
anyChanged |= ColorRow("Popup BG", ref s.customTheme.popR, ref s.customTheme.popG, ref s.customTheme.popB, ref s.customTheme.popA, showAlpha: true, ref _hexPop);
anyChanged |= ColorRow("Map Tint", ref s.customTheme.mapR, ref s.customTheme.mapG, ref s.customTheme.mapB, ref s.customTheme.mapA, showAlpha: true, ref _hexMap);
anyChanged |= ColorRow("Compass BG", ref s.customTheme.cmpR, ref s.customTheme.cmpG, ref s.customTheme.cmpB, ref s.customTheme.cmpA, showAlpha: true, ref _hexCmp);
anyChanged |= ColorRow("N Needle", ref s.customTheme.nNR, ref s.customTheme.nNG, ref s.customTheme.nNB, ref s.customTheme.nNA, showAlpha: false, ref _hexNN);
anyChanged |= ColorRow("S Needle", ref s.customTheme.nSR, ref s.customTheme.nSG, ref s.customTheme.nSB, ref s.customTheme.nSA, showAlpha: false, ref _hexNS);
anyChanged |= ColorRow("Map Camera", ref s.customTheme.mapBgR, ref s.customTheme.mapBgG, ref s.customTheme.mapBgB, ref s.customTheme.mapBgA, showAlpha: false, ref _hexMapBg);
if (anyChanged)
MapThemes.Apply(MapTheme.Custom);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private static void OpacityRow(string label, ref float field, float min, float max, Action<float> setNative)
{
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(110f));
float nv = GUILayout.HorizontalSlider(field, min, max, GUILayout.Width(180f));
GUILayout.Label($"{field:P0}", GUILayout.Width(44f));
GUILayout.EndHorizontal();
if (Mathf.Abs(nv - field) > 0.001f)
{
field = nv;
setNative(nv);
PopoutModule.Persist();
}
}
// Syncs all nine hex strings from the given theme data snapshot.
// Call after "Copy from preset" or on first draw.
private static void InitColorEditor(ref MapThemeData t)
{
_hexWBg = ToHex(t.wBgR, t.wBgG, t.wBgB);
_hexAcc = ToHex(t.accR, t.accG, t.accB);
_hexTxt = ToHex(t.txtR, t.txtG, t.txtB);
_hexPop = ToHex(t.popR, t.popG, t.popB);
_hexMap = ToHex(t.mapR, t.mapG, t.mapB);
_hexCmp = ToHex(t.cmpR, t.cmpG, t.cmpB);
_hexNN = ToHex(t.nNR, t.nNG, t.nNB);
_hexNS = ToHex(t.nSR, t.nSG, t.nSB);
_hexMapBg = ToHex(t.mapBgR, t.mapBgG, t.mapBgB);
_colorEditorInit = true;
}
// Renders one theme element row and returns true if any value changed.
// hexStr shows RRGGBB (no #). Typing a valid 6-char hex updates R/G/B;
// moving a slider updates R/G/B and also rebuilds hexStr.
private static bool ColorRow(
string label,
ref float r, ref float g, ref float b, ref float a,
bool showAlpha, ref string hexStr)
{
bool changed = false;
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(kLabelW));
// Hex input
string newHex = GUILayout.TextField(hexStr, 6, GUILayout.Width(kHexW));
if (newHex != hexStr)
{
hexStr = newHex;
if (newHex.Length == 6 && ColorUtility.TryParseHtmlString("#" + newHex, out Color hc))
{
r = hc.r; g = hc.g; b = hc.b;
changed = true;
}
}
// Live color swatch
GUI.color = new Color(r, g, b, 1f);
GUILayout.Box("", GUILayout.Width(kSwatchW), GUILayout.Height(16f));
GUI.color = Color.white;
// R, G, B sliders; rebuild hex if any change
bool rC = ChanSlider("R", ref r);
bool gC = ChanSlider("G", ref g);
bool bC = ChanSlider("B", ref b);
if (rC || gC || bC) { hexStr = ToHex(r, g, b); changed = true; }
// Optional alpha slider (no hex involvement)
if (showAlpha && ChanSlider("A", ref a))
changed = true;
GUILayout.EndHorizontal();
return changed;
}
// Renders a single channel: letter label + slider (0-1) + integer value (0-255).
// Returns true if the value moved more than the movement threshold.
private static bool ChanSlider(string label, ref float v)
{
GUILayout.Label(label, GUILayout.Width(kChanL));
float nv = GUILayout.HorizontalSlider(v, 0f, 1f, GUILayout.Width(kSliderW));
GUILayout.Label(Mathf.RoundToInt(nv * 255).ToString(), GUILayout.Width(kValW));
if (Mathf.Abs(nv - v) > 0.002f) { v = nv; return true; }
return false;
}
private static string ToHex(float r, float g, float b) =>
$"{Mathf.RoundToInt(r * 255):X2}{Mathf.RoundToInt(g * 255):X2}{Mathf.RoundToInt(b * 255):X2}";
private static string HotkeyLabel(PopoutSettings s)
{

View file

@ -10,7 +10,7 @@
<AssemblyName>S3</AssemblyName>
<RootNamespace>S3</RootNamespace>
<ModVersion>0.2.0</ModVersion>
<ModVersion>0.2.2</ModVersion>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>