diff --git a/Info.json b/Info.json index 134b362..43f8f7e 100644 --- a/Info.json +++ b/Info.json @@ -2,7 +2,7 @@ "Id": "S3", "DisplayName": "S³ - Seton's Special Sauce", "Author": "seton", - "Version": "0.2.0", + "Version": "0.2.1", "ManagerVersion": "0.27.0", "GameVersionPoint": "0", "AssemblyName": "S3.dll", diff --git a/dist/build-common.ps1 b/dist/build-common.ps1 index 094ff0e..e5ff4f7 100644 --- a/dist/build-common.ps1 +++ b/dist/build-common.ps1 @@ -37,31 +37,52 @@ function Invoke-NativeBuild { return $null } - Write-Host "=== Building native (RRPopout.dll, $Configuration) ===" -ForegroundColor Cyan + Write-Host "=== Building native (S3Native.dll, $Configuration) ===" -ForegroundColor Cyan $buildDir = Join-Path $RepoRoot "native\build" if (-not (Test-Path $buildDir)) { cmake -B $buildDir -A x64 (Join-Path $RepoRoot "native") | Out-Host } cmake --build $buildDir --config $Configuration | Out-Host if ($LASTEXITCODE -ne 0) { throw "Native build failed." } # MSVC multi-config generators place the DLL in a per-config subfolder. - $dll = Join-Path $buildDir "bin\$Configuration\RRPopout.dll" - if (-not (Test-Path $dll)) { $dll = Join-Path $buildDir "bin\RRPopout.dll" } # single-config fallback - if (-not (Test-Path $dll)) { throw "RRPopout.dll not found under $buildDir\bin" } + $dll = Join-Path $buildDir "bin\$Configuration\S3Native.dll" + if (-not (Test-Path $dll)) { $dll = Join-Path $buildDir "bin\S3Native.dll" } # single-config fallback + if (-not (Test-Path $dll)) { throw "S3Native.dll not found under $buildDir\bin" } return $dll } # Assembles the Mods\S3 layout into $DestModDir (the S3 folder itself). +# +# Overwrites in place rather than wiping the folder. A full wipe is dangerous: if +# the game is running, a loaded DLL is locked, so the wipe deletes Info.json and +# the managed DLL, then aborts on the locked native DLL — leaving the mod with no +# manifest, so it silently vanishes from the loader. Overwriting in place keeps +# the last-good install if a copy fails, and preserves the user's settings files. function New-ModLayout { param([Parameter(Mandatory)][string]$DestModDir, [Parameter(Mandatory)][string]$ManagedDll, [string]$NativeDll = $null) - if (Test-Path $DestModDir) { Remove-Item $DestModDir -Recurse -Force } New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null - Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force + # Fail fast with a clear message if a DLL we're about to overwrite is locked + # (game still running) — before touching anything. + foreach ($name in @("S3.dll", "S3Native.dll")) { + $path = Join-Path $DestModDir $name + if (Test-Path $path) { + try { [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None').Close() } + catch { throw "$name is locked - close Railroader before installing." } + } + } + + # Drop stale artifacts from older builds (the pre-rename native DLL, ngen + # caches) so they don't linger, but leave Info.json and settings intact. + $stale = Join-Path $DestModDir "RRPopout.dll" + if (Test-Path $stale) { Remove-Item $stale -Force } + Get-ChildItem $DestModDir -Filter "*.cache" -ErrorAction SilentlyContinue | Remove-Item -Force + Copy-Item (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force + Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force if ($NativeDll) { - Copy-Item $NativeDll (Join-Path $DestModDir "RRPopout.dll") -Force + Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force } } diff --git a/dist/build-release.ps1 b/dist/build-release.ps1 index 43b31e2..053465e 100644 --- a/dist/build-release.ps1 +++ b/dist/build-release.ps1 @@ -3,7 +3,7 @@ # Produces dist\SetonsSpecialSauce-.zip with the layout: # S3/Info.json # S3/S3.dll -# S3/RRPopout.dll (when the native module is present) +# S3/S3Native.dll (when the native module is present) param( [string]$Configuration = "Release" diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 33f0928..01fdf80 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.21) -project(RRPopout CXX) +project(S3Native CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -37,21 +37,21 @@ if(MSVC) endif() # --------------------------------------------------------------------------- -# RRPopout native plugin DLL +# S3Native native plugin DLL # --------------------------------------------------------------------------- -add_library(RRPopout SHARED +add_library(S3Native SHARED src/dllmain.cpp src/exports.cpp src/popout_windows.cpp src/d3d11_renderer.cpp ) -target_include_directories(RRPopout PRIVATE +target_include_directories(S3Native PRIVATE include external ) -target_link_libraries(RRPopout PRIVATE +target_link_libraries(S3Native PRIVATE imgui d3d11 dxgi @@ -60,13 +60,13 @@ target_link_libraries(RRPopout PRIVATE shell32 # ExtractIconExW (EXE icon loading) ) -set_target_properties(RRPopout PROPERTIES - OUTPUT_NAME "RRPopout" +set_target_properties(S3Native PROPERTIES + OUTPUT_NAME "S3Native" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" ) if(MSVC) - target_compile_options(RRPopout PRIVATE /W3 /WX- /GR- /EHsc) - target_compile_options(RRPopout PRIVATE $<$:/Zi>) - target_link_options(RRPopout PRIVATE $<$:/DEBUG /OPT:REF /OPT:ICF>) + target_compile_options(S3Native PRIVATE /W3 /WX- /GR- /EHsc) + target_compile_options(S3Native PRIVATE $<$:/Zi>) + target_link_options(S3Native PRIVATE $<$:/DEBUG /OPT:REF /OPT:ICF>) endif() diff --git a/native/include/shared_types.h b/native/include/shared_types.h index 8e570b3..0137846 100644 --- a/native/include/shared_types.h +++ b/native/include/shared_types.h @@ -1,7 +1,7 @@ #pragma once #include -// Shared between RRPopout.dll (C++) and RRPopout.Managed.dll (C#). +// Shared between S3Native.dll (C++) and S3.dll (C#). // The C# struct in NativeInterop.cs MUST match this layout exactly. enum InputEventType : int32_t { @@ -28,6 +28,7 @@ enum UICmd : int32_t { RotateReset = 8, // reset map rotation to 0 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 }; #pragma pack(push, 4) diff --git a/native/src/d3d11_renderer.cpp b/native/src/d3d11_renderer.cpp index 25e3ba8..bcd8025 100644 --- a/native/src/d3d11_renderer.cpp +++ b/native/src/d3d11_renderer.cpp @@ -9,6 +9,7 @@ #include #include "d3d11_renderer.h" #include "popout_window.h" +#include "popout_windows.h" #include "../include/shared_types.h" // Dear ImGui + D3D11 backend @@ -76,6 +77,23 @@ static DXGI_FORMAT TypedFormat(DXGI_FORMAT f) { } } +// Non-sRGB (linear) render-target view format for a backbuffer, so ImGui's writes +// are NOT sRGB-encoded by the GPU (which brightens). Returns UNKNOWN for formats +// where no such fix is needed/possible (e.g. float HDR has no encode anyway). +static DXGI_FORMAT UnormRtvFormat(DXGI_FORMAT f) { + switch (f) { + case DXGI_FORMAT_R8G8B8A8_TYPELESS: + case DXGI_FORMAT_R8G8B8A8_UNORM: + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: return DXGI_FORMAT_R8G8B8A8_UNORM; + case DXGI_FORMAT_B8G8R8A8_TYPELESS: + case DXGI_FORMAT_B8G8R8A8_UNORM: + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_R10G10B10A2_TYPELESS: + case DXGI_FORMAT_R10G10B10A2_UNORM: return DXGI_FORMAT_R10G10B10A2_UNORM; + default: return DXGI_FORMAT_UNKNOWN; + } +} + // --------------------------------------------------------------------------- // D3D11 state save/restore — prevents corrupting Unity's render state // --------------------------------------------------------------------------- @@ -147,6 +165,9 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) { ImGuiStyle& style = ImGui::GetStyle(); style.WindowRounding = 0.f; style.WindowBorderSize = 0.f; + // Allow the 22px toolbar to be exactly 22px. The default 32px minimum makes it + // overhang the bottom of the in-game map window as a stray dark strip. + style.WindowMinSize = ImVec2(4.f, 4.f); style.FrameRounding = 3.f; style.GrabRounding = 3.f; style.ScrollbarRounding = 3.f; @@ -286,6 +307,260 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win) { if (win->swapChain) { win->swapChain->Release(); win->swapChain = nullptr; } } +// --------------------------------------------------------------------------- +// Shared map UI chrome (compass rose + bottom toolbar), drawn for window `win` +// at logical size w x h. Pure ImGui: reads win-> state atomics and pushes +// UICommands onto win->inputQueue. The caller owns the frame lifecycle — it must +// set up io, call ImGui_ImplDX11_NewFrame()/ImGui::NewFrame() before this, and +// ImGui::Render() after. Used by the popout (Renderer_Present) and, in time, by +// the in-game overlay so the map UI is authored once and rendered on any surface. +// --------------------------------------------------------------------------- +// origin = top-left screen position of the map region; w,h = its size. For the +// popout the region is the whole window (origin 0,0); for the in-game overlay it +// is the floating window's map image rect, so the chrome tracks that window. +// 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) { + ImGuiIO& io = ImGui::GetIO(); + + // Shared command helper — usable from any window in this frame + auto pushCmd = [&](UICmd cmd, float idx = 0.f) { + InputEvent ev{}; ev.type = UICommand; + ev.x = static_cast(cmd); ev.y = idx; + win->inputQueue.push(ev); + }; + + const float kBarH = 22.f; + + // ── Compass overlay (floating in the bottom-right of the map area) ── + const float kCR = 36.f; // compass circle radius + const float kCWin = kCR * 2.f + 20.f; // window size (leaves room for N label) + const float kCPad = 10.f; // margin from window edge + + if (h > kBarH + kCWin + kCPad * 2.f) { // skip if window too small + ImGui::SetNextWindowPos( + ImVec2(origin.x + w - kCWin - kCPad, origin.y + h - kBarH - kCWin - kCPad), + ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(kCWin, kCWin)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::Begin("##compass_overlay", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground); + + ImGui::SetCursorPos(ImVec2(0.f, 0.f)); + ImGui::InvisibleButton("##compassHit", ImVec2(kCWin, kCWin)); + bool cHov = ImGui::IsItemHovered(); + bool cAct = ImGui::IsItemActive(); + + // Drag to rotate: angle from compass centre → new bearing + if (cAct) { + ImVec2 wp = ImGui::GetWindowPos(); + ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; + float ddx = io.MousePos.x - cc.x, ddy = io.MousePos.y - cc.y; + if (ddx*ddx + ddy*ddy > 9.f) { + float ang = atan2f(ddx, -ddy) * (180.f / 3.14159265f); + if (ang < 0.f) ang += 360.f; + win->imMapRotationDeg.store(ang); + win->imMapSyncPlayer.store(false); + pushCmd(UICmd::SetRotation, ang); + } + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + win->imMapRotationDeg.store(0.f); + win->imMapSyncPlayer.store(false); + pushCmd(UICmd::RotateReset); + } + + // Draw compass rose + { + float rotDeg = win->imMapRotationDeg.load(); + float rotRad = rotDeg * (3.14159265f / 180.f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 wp = ImGui::GetWindowPos(); + ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; + + // 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); + + // Tick marks: 8 directions — cardinals longer, inter-cardinals shorter + for (int i = 0; i < 8; ++i) { + float a = (i * 45.f - rotDeg) * (3.14159265f / 180.f); + float ox = sinf(a), oy = -cosf(a); + bool card = (i % 2 == 0); + 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 ? 1.5f : 1.f); + } + + // Needle: diamond split red (N) / white (S) + float nx = sinf(rotRad), ny = -cosf(rotRad); + float bx = -ny * 7.f, by = nx * 7.f; // perpendicular half-width + 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->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)); + + // "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"); + } + + if (cHov) + ImGui::SetTooltip("Rotation: %.0f\xc2\xb0\nDrag to rotate | Right-click to reset", + win->imMapRotationDeg.load()); + + ImGui::End(); + ImGui::PopStyleVar(2); + } + + // ── Toolbar strip ──────────────────────────────────────────────────── + // Leave the bottom-right corner clear for the ImGui resize grip when in-game. + const float kGripReserve = reserveResizeGrip ? 18.f : 0.f; + ImGui::SetNextWindowPos(ImVec2(origin.x, origin.y + (float)h - kBarH)); + ImGui::SetNextWindowSize(ImVec2((float)w - kGripReserve, kBarH)); + ImGui::SetNextWindowBgAlpha(1.f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 2.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 2.f)); + ImGui::Begin("##toolbar", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoFocusOnAppearing); + + // Status label (left) + { + char buf[256]; + { std::lock_guard lk(win->imStatusMutex); strncpy_s(buf, win->imStatusText, _TRUNCATE); } + ImGui::TextUnformatted(buf); + } + + const float kBtnH = kBarH - 4.f; + 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. + const ImVec4 kBtnBlue = ImGui::GetStyleColorVec4(ImGuiCol_TitleBgActive); + const ImVec4 kBtnBlueActive = ImVec4(0.26f, 0.45f, 0.72f, 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); + if (ImGui::Button(">>##popout", ImVec2(kBtnW, kBtnH))) + pushCmd(UICmd::PopOut); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Open in popout window"); + } + + // ◎ Follow-player position toggle + ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW); + { + bool fOn = win->imFollowPlayer.load(); + ImGui::PushStyleColor(ImGuiCol_Button, fOn ? kBtnBlueActive : kBtnBlue); + if (ImGui::Button("\xe2\x97\x8e##follow", ImVec2(kBtnW, kBtnH))) + pushCmd(UICmd::ToggleFollowPlayer); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(fOn ? "Following player position\nClick to stop" + : "Follow player position"); + } + + // ⚙ Settings gear + ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW); + ImGui::PushStyleColor(ImGuiCol_Button, kBtnBlue); + if (ImGui::Button("\xe2\x9a\x99##cfg", ImVec2(kBtnW, kBtnH))) + ImGui::OpenPopup("##settings"); + ImGui::PopStyleColor(); + + if (ImGui::BeginPopup("##settings")) { + if (ImGui::MenuItem("Center on player")) + pushCmd(UICmd::Recenter); + + // Sync rotation — checkmark shows live state + if (ImGui::MenuItem("Sync rotation to player camera", nullptr, + win->imMapSyncPlayer.load())) + pushCmd(UICmd::RotateSyncPlayer); + + ImGui::Separator(); + + bool meOn = win->imMapEnhancerInstalled.load(); + if (meOn) { + if (ImGui::BeginMenu("Map Enhancer")) { + if (ImGui::MenuItem("Toggle follow mode")) + pushCmd(UICmd::FollowMode); + ImGui::Separator(); + if (ImGui::BeginMenu("Follow")) { + if (ImGui::MenuItem("Player camera")) + pushCmd(UICmd::FollowPlayerCam); + if (ImGui::MenuItem("Selected loco")) + pushCmd(UICmd::FollowSelectedLoco); + if (ImGui::MenuItem("Stop following")) + pushCmd(UICmd::FollowMode); + { + std::lock_guard lk(win->imLocoMutex); + if (!win->imLocoList.empty()) { + ImGui::Separator(); + for (int i = 0; i < (int)win->imLocoList.size(); ++i) + if (ImGui::MenuItem(win->imLocoList[i].label)) + pushCmd(UICmd::FollowLoco, (float)i); + } + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Jump to location")) { + std::lock_guard lk(win->imLocationMutex); + if (win->imLocationList.empty()) + ImGui::TextDisabled("(loading...)"); + else + for (int i = 0; i < (int)win->imLocationList.size(); ++i) + if (ImGui::MenuItem(win->imLocationList[i].label)) + pushCmd(UICmd::JumpToLocation, (float)i); + ImGui::EndMenu(); + } + ImGui::EndMenu(); + } + } else { + if (ImGui::BeginMenu("Jump to location")) { + std::lock_guard lk(win->imLocationMutex); + if (win->imLocationList.empty()) + ImGui::TextDisabled("(loading...)"); + else + for (int i = 0; i < (int)win->imLocationList.size(); ++i) + if (ImGui::MenuItem(win->imLocationList[i].label)) + pushCmd(UICmd::JumpToLocation, (float)i); + ImGui::EndMenu(); + } + } + + ImGui::EndPopup(); + } + + ImGui::PopStyleVar(3); + ImGui::End(); +} + void Renderer_Present(PopoutWindow* win) { if (!win || win->resizing.load()) return; void* rawTex = win->pendingTexture.load(); @@ -367,220 +642,7 @@ void Renderer_Present(PopoutWindow* win) { ImGui_ImplDX11_NewFrame(); ImGui::NewFrame(); - // Shared command helper — usable from any window in this frame - auto pushCmd = [&](UICmd cmd, float idx = 0.f) { - InputEvent ev{}; ev.type = UICommand; - ev.x = static_cast(cmd); ev.y = idx; - win->inputQueue.push(ev); - }; - - const float kBarH = 22.f; - - // ── Compass overlay (floating in the bottom-right of the map area) ── - const float kCR = 36.f; // compass circle radius - const float kCWin = kCR * 2.f + 20.f; // window size (leaves room for N label) - const float kCPad = 10.f; // margin from window edge - - if (h > kBarH + kCWin + kCPad * 2.f) { // skip if window too small - ImGui::SetNextWindowPos( - ImVec2(w - kCWin - kCPad, h - kBarH - kCWin - kCPad), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(kCWin, kCWin)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); - ImGui::Begin("##compass_overlay", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBackground); - - ImGui::SetCursorPos(ImVec2(0.f, 0.f)); - ImGui::InvisibleButton("##compassHit", ImVec2(kCWin, kCWin)); - bool cHov = ImGui::IsItemHovered(); - bool cAct = ImGui::IsItemActive(); - - // Drag to rotate: angle from compass centre → new bearing - if (cAct) { - ImVec2 wp = ImGui::GetWindowPos(); - ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; - float ddx = io.MousePos.x - cc.x, ddy = io.MousePos.y - cc.y; - if (ddx*ddx + ddy*ddy > 9.f) { - float ang = atan2f(ddx, -ddy) * (180.f / 3.14159265f); - if (ang < 0.f) ang += 360.f; - win->imMapRotationDeg.store(ang); - win->imMapSyncPlayer.store(false); - pushCmd(UICmd::SetRotation, ang); - } - } - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - win->imMapRotationDeg.store(0.f); - win->imMapSyncPlayer.store(false); - pushCmd(UICmd::RotateReset); - } - - // Draw compass rose - { - float rotDeg = win->imMapRotationDeg.load(); - float rotRad = rotDeg * (3.14159265f / 180.f); - ImDrawList* dl = ImGui::GetWindowDrawList(); - ImVec2 wp = ImGui::GetWindowPos(); - ImVec2 cc = { wp.x + kCWin * 0.5f, wp.y + kCWin * 0.5f }; - - // 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); - - // Tick marks: 8 directions — cardinals longer, inter-cardinals shorter - for (int i = 0; i < 8; ++i) { - float a = (i * 45.f - rotDeg) * (3.14159265f / 180.f); - float ox = sinf(a), oy = -cosf(a); - bool card = (i % 2 == 0); - 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 ? 1.5f : 1.f); - } - - // Needle: diamond split red (N) / white (S) - float nx = sinf(rotRad), ny = -cosf(rotRad); - float bx = -ny * 7.f, by = nx * 7.f; // perpendicular half-width - 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->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)); - - // "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"); - } - - if (cHov) - ImGui::SetTooltip("Rotation: %.0f\xc2\xb0\nDrag to rotate | Right-click to reset", - win->imMapRotationDeg.load()); - - ImGui::End(); - ImGui::PopStyleVar(2); - } - - // ── Toolbar strip ──────────────────────────────────────────────────── - ImGui::SetNextWindowPos(ImVec2(0.f, (float)h - kBarH)); - ImGui::SetNextWindowSize(ImVec2((float)w, kBarH)); - ImGui::SetNextWindowBgAlpha(1.f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 2.f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 2.f)); - ImGui::Begin("##toolbar", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoFocusOnAppearing); - - // Status label (left) - { - char buf[256]; - { std::lock_guard lk(win->imStatusMutex); strncpy_s(buf, win->imStatusText, _TRUNCATE); } - ImGui::TextUnformatted(buf); - } - - const float kBtnH = kBarH - 4.f; - const float kBtnW = 22.f; - const float kGap = 4.f; - - // ◎ Follow-player position toggle - ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW - kGap - kBtnW); - { - bool fOn = win->imFollowPlayer.load(); - if (fOn) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.52f, 0.18f, 1.f)); - if (ImGui::Button("\xe2\x97\x8e##follow", ImVec2(kBtnW, kBtnH))) - pushCmd(UICmd::ToggleFollowPlayer); - if (fOn) ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip(fOn ? "Following player position\nClick to stop" - : "Follow player position"); - } - - // ⚙ Settings gear - ImGui::SameLine(ImGui::GetContentRegionMax().x - kBtnW); - if (ImGui::Button("\xe2\x9a\x99##cfg", ImVec2(kBtnW, kBtnH))) - ImGui::OpenPopup("##settings"); - - if (ImGui::BeginPopup("##settings")) { - if (ImGui::MenuItem("Center on player")) - pushCmd(UICmd::Recenter); - - // Sync rotation — checkmark shows live state - if (ImGui::MenuItem("Sync rotation to player camera", nullptr, - win->imMapSyncPlayer.load())) - pushCmd(UICmd::RotateSyncPlayer); - - ImGui::Separator(); - - bool meOn = win->imMapEnhancerInstalled.load(); - if (meOn) { - if (ImGui::BeginMenu("Map Enhancer")) { - if (ImGui::MenuItem("Toggle follow mode")) - pushCmd(UICmd::FollowMode); - ImGui::Separator(); - if (ImGui::BeginMenu("Follow")) { - if (ImGui::MenuItem("Player camera")) - pushCmd(UICmd::FollowPlayerCam); - if (ImGui::MenuItem("Selected loco")) - pushCmd(UICmd::FollowSelectedLoco); - if (ImGui::MenuItem("Stop following")) - pushCmd(UICmd::FollowMode); - { - std::lock_guard lk(win->imLocoMutex); - if (!win->imLocoList.empty()) { - ImGui::Separator(); - for (int i = 0; i < (int)win->imLocoList.size(); ++i) - if (ImGui::MenuItem(win->imLocoList[i].label)) - pushCmd(UICmd::FollowLoco, (float)i); - } - } - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Jump to location")) { - std::lock_guard lk(win->imLocationMutex); - if (win->imLocationList.empty()) - ImGui::TextDisabled("(loading...)"); - else - for (int i = 0; i < (int)win->imLocationList.size(); ++i) - if (ImGui::MenuItem(win->imLocationList[i].label)) - pushCmd(UICmd::JumpToLocation, (float)i); - ImGui::EndMenu(); - } - ImGui::EndMenu(); - } - } else { - if (ImGui::BeginMenu("Jump to location")) { - std::lock_guard lk(win->imLocationMutex); - if (win->imLocationList.empty()) - ImGui::TextDisabled("(loading...)"); - else - for (int i = 0; i < (int)win->imLocationList.size(); ++i) - if (ImGui::MenuItem(win->imLocationList[i].label)) - pushCmd(UICmd::JumpToLocation, (float)i); - ImGui::EndMenu(); - } - } - - ImGui::EndPopup(); - } - - ImGui::PopStyleVar(3); - ImGui::End(); + BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h, /*reserveResizeGrip=*/false, /*showPopOutBtn=*/false); ImGui::Render(); ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); @@ -592,3 +654,226 @@ void Renderer_Present(PopoutWindow* win) { // Restore Unity's render state saved.Restore(g_context); } + +// =========================================================================== +// In-game overlay (spike) +// =========================================================================== +// Renders an ImGui test window into Unity's currently bound backbuffer (no +// swapchain, no Present) to prove the on-top render path. Reuses the SAME ImGui +// context + DX11 backend the popout uses (set up once in InitImGui): the popout +// and overlay each do a complete NewFrame/Render inside their own plugin event +// on the render thread, so sequential reuse of one context is safe. A second +// context tripped the DX11 backend's per-context init in release builds. +// --------------------------------------------------------------------------- +static std::atomic g_ovDeviceTex {nullptr}; +static std::atomic g_ovW {0.f}, g_ovH {0.f}; +static std::atomic g_ovMouseX {-1.f}, g_ovMouseY {-1.f}; +static std::atomic g_ovLButton {false}, g_ovRButton {false}; +static std::atomic g_ovWheelRaw {0}; +static std::atomic g_ovWantMouse {false}; +static std::atomic g_ovVisible {false}; + +// Map texture to show in the in-game window (set from C# each frame) + UV rect. +static std::atomic g_ovMapTex {nullptr}; +static std::atomic g_ovMapU0 {0.f}, g_ovMapV0 {1.f}; +static std::atomic g_ovMapU1 {1.f}, g_ovMapV1 {0.f}; +// SRV over the map texture for ImGui::Image. Rebuilt each frame; must outlive +// RenderDrawData, so it lives at file scope rather than as a frame-local. +static ComPtr g_ovMapSRV; + +// Size (px) of the map image region this frame. C# reads it back to set the map +// camera's aspect to the window shape, so the map fills the window (no letterbox). +static std::atomic g_ovViewW {0.f}, g_ovViewH {0.f}; +void Overlay_GetMapView(float* outW, float* outH) { + if (outW) *outW = g_ovViewW.load(); + if (outH) *outH = g_ovViewH.load(); +} + +// 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; +int Overlay_PollInput(InputEvent* out, int maxEvents) { return g_ovInput.drain(out, maxEvents); } + +void Overlay_SetDeviceTexture(void* texturePtr) { g_ovDeviceTex.store(texturePtr); } +void Overlay_SetVisible(bool visible) { g_ovVisible.store(visible); } +bool Overlay_WantsMouse() { return g_ovWantMouse.load(); } + +void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1) { + g_ovMapTex.store(texturePtr); + g_ovMapU0.store(u0); g_ovMapV0.store(v0); + g_ovMapU1.store(u1); g_ovMapV1.store(v1); +} + +void Overlay_SetInput(float displayW, float displayH, + float mouseX, float mouseY, + bool lButton, bool rButton, int wheel) { + g_ovW.store(displayW); g_ovH.store(displayH); + g_ovMouseX.store(mouseX); g_ovMouseY.store(mouseY); + g_ovLButton.store(lButton); g_ovRButton.store(rButton); + if (wheel) g_ovWheelRaw.fetch_add(wheel); +} + +void Renderer_PresentOverlay() { + if (!g_ovVisible.load()) return; + + // Lazy device init — same path the popout uses, but from a throwaway texture + // since the overlay has no map RT of its own. Renderer_OnDeviceInit also runs + // InitImGui (shared context + DX11 backend), so g_imguiInited is set here too. + if (!g_device) { + void* t = g_ovDeviceTex.load(); + if (!t) return; + auto* tex = static_cast(t); + ID3D11Device* dev = nullptr; + tex->GetDevice(&dev); + if (!dev) return; + Renderer_OnDeviceInit(dev); + dev->Release(); + } + if (!g_device || !g_context || !g_imguiInited) return; + + float w = g_ovW.load(), h = g_ovH.load(); + if (w <= 0.f || h <= 0.f) return; + + // Save Unity's render state — we draw into its currently bound RTV and leave + // it bound (no OMSetRenderTargets), so ImGui composites onto the backbuffer. + D3D11StateBlock saved; + saved.Capture(g_context); + + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(w, h); + io.MousePos = ImVec2(g_ovMouseX.load(), g_ovMouseY.load()); + io.MouseDown[0] = g_ovLButton.load(); + io.MouseDown[1] = g_ovRButton.load(); + io.MouseWheel = (float)g_ovWheelRaw.exchange(0) / WHEEL_DELTA; + + ImGui_ImplDX11_NewFrame(); + ImGui::NewFrame(); + + g_ovMapSRV.Reset(); // release last frame's view before building this frame's + + // Captured inside the window, used to position the shared chrome (toolbar + + // compass) over the map image after the window's End(). + ImVec2 mapScreenPos(0.f, 0.f), mapSize(0.f, 0.f); + bool mapShown = false; + + // Shared UI-state holder (status, lists, rotation) + command queue for chrome. + PopoutWindow* stateWin = GetPopoutWindow(GetOrCreateOverlayStateHandle()); + + // 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. + 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)) { + void* mapTex = g_ovMapTex.load(); + ImVec2 avail = ImGui::GetContentRegionAvail(); + + if (mapTex && avail.x > 4.f && avail.y > 4.f) { + auto* tex = static_cast(mapTex); + D3D11_TEXTURE2D_DESC td; tex->GetDesc(&td); + + D3D11_SHADER_RESOURCE_VIEW_DESC sd = {}; + sd.Format = TypedFormat(td.Format); + sd.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + sd.Texture2D.MostDetailedMip = 0; + sd.Texture2D.MipLevels = 1; + if (SUCCEEDED(g_device->CreateShaderResourceView(tex, &sd, &g_ovMapSRV))) { + // Fill the whole content region (no letterbox). C# reads this size + // back and sets camera.aspect to match, so the map renders the right + // slice to fill the window instead of leaving bars top/bottom. + ImVec2 sz = avail; + g_ovViewW.store(sz.x); + g_ovViewH.store(sz.y); + + // Transparent hit area over the map for drag/zoom. Queue events in + // normalized [0,1] image space (top-left origin) so C# can forward + // them to the map camera with the same math the popout uses. + ImVec2 imgPos = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##mapHit", sz, ImGuiButtonFlags_MouseButtonLeft); + bool hov = ImGui::IsItemHovered(); + ImVec2 nrm = { (io.MousePos.x - imgPos.x) / sz.x, + (io.MousePos.y - imgPos.y) / sz.y }; + auto pushOv = [&](int type, float d) { + InputEvent e{}; e.type = type; e.x = nrm.x; e.y = nrm.y; e.delta = d; + g_ovInput.push(e); + }; + if (ImGui::IsItemActivated()) pushOv(LButtonDown, 0.f); + if (ImGui::IsItemActive() && + (io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f)) + pushOv(MouseMove, 0.f); + if (ImGui::IsItemDeactivated()) pushOv(LButtonUp, 0.f); + if (hov && io.MouseWheel != 0.f) pushOv(MouseWheel, io.MouseWheel); + + // Draw the map over the same rect the hit-button occupies. + 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); + + // Visible resize-grip indicator. ImGui draws its own grip during + // Begin() — behind our opaque map image, so invisible. We draw one + // on top in the corner only while the window is focused (the dark + // unfocused version was more distracting than no grip at all). + if (ImGui::IsWindowFocused()) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 br(imgPos.x + sz.x, imgPos.y + sz.y); + const float gripSz = 16.f; + ImU32 col = ImGui::GetColorU32(ImGuiCol_TitleBgActive, 1.0f); + dl->AddTriangleFilled(ImVec2(br.x, br.y), + ImVec2(br.x - gripSz, br.y), + ImVec2(br.x, br.y - gripSz), col); + } + + mapScreenPos = imgPos; mapSize = sz; mapShown = true; + } + } else { + ImGui::TextWrapped("Load a save and open the in-game map, then it will " + "appear here. (F10 toggles this window.)"); + } + } + ImGui::End(); + ImGui::PopStyleVar(); // WindowPadding + + // 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); + + ImGui::Render(); + + // Bind a linear (non-sRGB) view of Unity's backbuffer so the GPU does not + // sRGB-encode our writes (which washes the map/UI out). This makes the overlay + // match the popout, which renders into its own UNORM swapchain. Falls back to + // Unity's bound RTV if a UNORM view can't be created (no regression). + ComPtr linearRtv; + if (saved.rtv[0]) { + ComPtr res; + saved.rtv[0]->GetResource(&res); + ComPtr bb; + if (res && SUCCEEDED(res.As(&bb))) { + D3D11_TEXTURE2D_DESC bbDesc; bb->GetDesc(&bbDesc); + DXGI_FORMAT linear = UnormRtvFormat(bbDesc.Format); + if (linear != DXGI_FORMAT_UNKNOWN) { + D3D11_RENDER_TARGET_VIEW_DESC rd = {}; + rd.Format = linear; + rd.ViewDimension = (bbDesc.SampleDesc.Count > 1) + ? D3D11_RTV_DIMENSION_TEXTURE2DMS : D3D11_RTV_DIMENSION_TEXTURE2D; + g_device->CreateRenderTargetView(bb.Get(), &rd, &linearRtv); + } + } + } + if (linearRtv) { + ID3D11RenderTargetView* rtv = linearRtv.Get(); + g_context->OMSetRenderTargets(1, &rtv, nullptr); + } + + ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); + g_ovWantMouse.store(io.WantCaptureMouse); + + // Restore Unity's render state + saved.Restore(g_context); +} diff --git a/native/src/d3d11_renderer.h b/native/src/d3d11_renderer.h index 63e94bc..f611cd3 100644 --- a/native/src/d3d11_renderer.h +++ b/native/src/d3d11_renderer.h @@ -13,3 +13,41 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win); // Blits pendingTexture into the swapchain and calls Present. Render thread only. void Renderer_Present(PopoutWindow* win); + +// --------------------------------------------------------------------------- +// In-game overlay (spike): draw a Dear ImGui frame directly into whatever +// render target Unity has bound (the final backbuffer when issued after +// WaitForEndOfFrame). No swapchain, no Present — composites on top of the game. +// All functions below run on Unity's render thread except the *Set* setters, +// which are called from the C# main thread and only touch atomics. +// --------------------------------------------------------------------------- + +// Stash a texture whose owning device we lazily init from (mirrors the popout's +// tex->GetDevice() path) when UnityPluginLoad never fired. Call once from C#. +void Overlay_SetDeviceTexture(void* texturePtr); + +// Per-frame input snapshot from C# (top-left origin, pixels). wheel in WHEEL_DELTA units. +void Overlay_SetInput(float displayW, float displayH, + float mouseX, float mouseY, + bool lButton, bool rButton, int wheel); + +// The Unity map render texture to display inside the in-game ImGui window, plus +// the UV sub-rect (V flipped: v0=1,v1=0 for a Unity RT). Pass nullptr to clear. +void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1); + +// Drains queued in-game map input (image drag/zoom) for C# to forward to the map +// camera. Returns the number of events written (<= maxEvents). +int Overlay_PollInput(InputEvent* out, int maxEvents); + +// Size (px) of the map image region last frame, so C# can set the map camera's +// aspect to the window shape (map fills the window, no letterbox bars). +void Overlay_GetMapView(float* outW, float* outH); + +// Show/hide the overlay UI. +void Overlay_SetVisible(bool visible); + +// True when ImGui wants the mouse (hovering a widget) — C# uses this to swallow input. +bool Overlay_WantsMouse(); + +// Render the overlay into the currently bound RTV. Render thread only. +void Renderer_PresentOverlay(); diff --git a/native/src/exports.cpp b/native/src/exports.cpp index d62eca6..4b55bee 100644 --- a/native/src/exports.cpp +++ b/native/src/exports.cpp @@ -43,7 +43,12 @@ UnityPluginUnload() { // --------------------------------------------------------------------------- // Render event callback — called by Unity on the render thread // --------------------------------------------------------------------------- +// Sentinel event id for the in-game overlay. Window handles start at 1 and grow +// by one (see CreatePopoutWindow), so this high value can never collide. +static const int kOverlayEventId = 0x05E70001; + static void UNITY_INTERFACE_API OnRenderEvent(int eventId) { + if (eventId == kOverlayEventId) { Renderer_PresentOverlay(); return; } PopoutWindow* win = GetPopoutWindow(eventId); if (win) Renderer_Present(win); } @@ -76,6 +81,63 @@ void RRPOPOUT_GetWindowSize(int windowHandle, int* outWidth, int* outHeight) { GetPopoutWindowSize(windowHandle, outWidth, outHeight); } +// --------------------------------------------------------------------------- +// In-game overlay (spike) — exports +// --------------------------------------------------------------------------- + +// Returns the event id to pass to GL.IssuePluginEvent for the overlay render. +extern "C" __declspec(dllexport) +int RRPOPOUT_GetOverlayEventId() { return kOverlayEventId; } + +// One-time: hand native a texture so it can lazily acquire Unity's D3D device. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayDeviceTexture(void* texturePtr) { + Overlay_SetDeviceTexture(texturePtr); +} + +// Per-frame input snapshot (top-left origin, pixels). +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayInput(float displayW, float displayH, + float mouseX, float mouseY, + int lButton, int rButton, int wheel) { + Overlay_SetInput(displayW, displayH, mouseX, mouseY, + lButton != 0, rButton != 0, wheel); +} + +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayVisible(int visible) { Overlay_SetVisible(visible != 0); } + +// Map render texture to show in the in-game window (+ UV rect; V flipped for a +// Unity RT). Call each frame before issuing the overlay render event. +extern "C" __declspec(dllexport) +void RRPOPOUT_SetOverlayMapTexture(void* texturePtr, + float u0, float v0, float u1, float v1) { + Overlay_SetMapTexture(texturePtr, u0, v0, u1, v1); +} + +extern "C" __declspec(dllexport) +int RRPOPOUT_OverlayWantsMouse() { return Overlay_WantsMouse() ? 1 : 0; } + +// Drain queued in-game map input (drag/zoom over the map image) for C# to apply. +extern "C" __declspec(dllexport) +int RRPOPOUT_PollOverlayInput(InputEvent* outEvents, int maxEvents) { + return Overlay_PollInput(outEvents, maxEvents); +} + +// Read back the map image region size so C# can match the camera aspect to it. +extern "C" __declspec(dllexport) +void RRPOPOUT_GetOverlayMapView(float* outW, float* outH) { + Overlay_GetMapView(outW, outH); +} + +// Handle of the in-game overlay's shared UI-state holder. C# pushes status text, +// loco/location lists, rotation, follow flags, etc. to this handle with the same +// RRPOPOUT_Set* / RRPOPOUT_PollInputEvents functions the popout uses. +extern "C" __declspec(dllexport) +int RRPOPOUT_GetOverlayWindowHandle() { + return GetOrCreateOverlayStateHandle(); +} + // Poll input events — mouse-type events are suppressed while ImGui has capture // (e.g. cursor is over the toolbar) to prevent accidental map pan/zoom. extern "C" __declspec(dllexport) diff --git a/native/src/popout_window.h b/native/src/popout_window.h index 1149553..1ee8359 100644 --- a/native/src/popout_window.h +++ b/native/src/popout_window.h @@ -83,7 +83,9 @@ struct PopoutWindow { std::atomic lastWinW {900}, lastWinH {700}; PopoutWindow() { - strncpy_s(imStatusText, sizeof(imStatusText), - "Railroader \xe2\x80\x94 Map", _TRUNCATE); // UTF-8 em-dash + // Status starts empty (filled live with zoom/coords). Avoids a default label + // that would be redundant with the window title bar and would render any + // non-ASCII glyph as '?' in ImGui's default font. + imStatusText[0] = '\0'; } }; diff --git a/native/src/popout_windows.cpp b/native/src/popout_windows.cpp index 64b4351..110ad26 100644 --- a/native/src/popout_windows.cpp +++ b/native/src/popout_windows.cpp @@ -15,15 +15,18 @@ #include "d3d11_renderer.h" // --------------------------------------------------------------------------- -// Persistent state — saved to /RRPopout/window_state.ini on close, -// loaded and applied in MessagePumpThread before ShowWindow. +// Persistent state — saved to /Mods/S3/popout_window.ini on close, +// loaded and applied in MessagePumpThread before ShowWindow. Deliberately NOT +// /RRPopout — that is the old standalone PopOut's folder and may still +// be present, so we keep our state in our own mod folder (which always exists) +// to avoid reading its stale config. // --------------------------------------------------------------------------- static std::wstring GetStatePath() { wchar_t buf[MAX_PATH] = {}; GetModuleFileNameW(nullptr, buf, MAX_PATH); // Railroader.exe full path wchar_t* sep = wcsrchr(buf, L'\\'); if (sep) sep[1] = L'\0'; // trim to directory (keep slash) - return std::wstring(buf) + L"RRPopout\\config.ini"; + return std::wstring(buf) + L"Mods\\S3\\popout_window.ini"; } static void SaveWindowState(HWND hwnd, PopoutWindow* win) { @@ -76,8 +79,8 @@ static std::unordered_map g_windows; static std::mutex g_windowsMutex; static std::atomic g_nextHandle {1}; -static const wchar_t* kWindowClass = L"RRPopout_FloatWindow"; -static const wchar_t* kContentClass = L"RRPopout_Content"; +static const wchar_t* kWindowClass = L"S3Native_FloatWindow"; +static const wchar_t* kContentClass = L"S3Native_Content"; static bool g_classRegistered = false; static bool g_contentClassRegistered = false; @@ -477,6 +480,31 @@ PopoutWindow* GetPopoutWindow(int handle) { return (it != g_windows.end()) ? it->second : nullptr; } +// Registers a PopoutWindow with no OS window or message pump — a pure UI-state +// holder. Safe to query/update via the same handle-based exports as real windows. +int CreateHeadlessWindow() { + int handle = g_nextHandle.fetch_add(1); + auto* win = new PopoutWindow(); + win->alive.store(true); + { std::lock_guard lock(g_windowsMutex); g_windows[handle] = win; } + return handle; +} + +// Lazily creates the single shared state holder for the in-game overlay and +// returns its handle. Callable from both the render thread and the main thread. +static std::atomic g_overlayStateHandle {0}; +static std::mutex g_overlayStateMutex; +int GetOrCreateOverlayStateHandle() { + int h = g_overlayStateHandle.load(); + if (h != 0) return h; + std::lock_guard lk(g_overlayStateMutex); + h = g_overlayStateHandle.load(); + if (h != 0) return h; + h = CreateHeadlessWindow(); + g_overlayStateHandle.store(h); + return h; +} + void GetPopoutWindowSize(int handle, int* w, int* h) { PopoutWindow* win = GetPopoutWindow(handle); if (win && win->alive.load()) { *w = win->width.load(); *h = win->height.load(); } diff --git a/native/src/popout_windows.h b/native/src/popout_windows.h index dae789d..c97cf83 100644 --- a/native/src/popout_windows.h +++ b/native/src/popout_windows.h @@ -9,3 +9,9 @@ void DestroyPopoutWindow(int handle); PopoutWindow* GetPopoutWindow(int handle); void GetPopoutWindowSize(int handle, int* w, int* h); int PollPopoutInputEvents(int handle, InputEvent* outEvents, int maxEvents); + +// A registered PopoutWindow with no OS window/thread — used as a shared UI-state +// holder for the in-game overlay so it can reuse all the popout's state exports +// (status text, loco/location lists, rotation, follow, etc.) and BuildMapUI. +int CreateHeadlessWindow(); +int GetOrCreateOverlayStateHandle(); diff --git a/src/Core/Ui/UiService.cs b/src/Core/Ui/UiService.cs new file mode 100644 index 0000000..03ca2f9 --- /dev/null +++ b/src/Core/Ui/UiService.cs @@ -0,0 +1,640 @@ +using System.Collections; +using HarmonyLib; +using S3.Modules.Popout; // NativeLoader + Native + PanelFinder + MapEnhancerBridge +using UI; // GameInput +using UI.Common; // Window +using UI.Map; // MapBuilder, MapWindow +using UnityEngine; + +namespace S3.Core.Ui; + +/// +/// Always-on core service that hosts Dear ImGui inside the game window, +/// reusing the same native Win32 + D3D11 + ImGui engine that powers the map popout. +/// Renders the map into a floating in-game window (F10 to toggle), with full chrome +/// (toolbar, compass, follow/jump menus) matching the OS popout surface. Also +/// intercepts the base-game map-open hotkey and "Show on map" links so our overlay +/// opens instead of the stock map panel. +/// +public static class UiService +{ + private static GameObject? _host; + private static UiHost? _uiHost; + + // Set to true before calling MapWindow.Show/Toggle internally so the intercept + // patches let those calls through without redirecting to the overlay. + internal static bool MapBypass { get; set; } + + // True while the in-game overlay is visible. Used by PopoutModule to track + // whether it should restore the overlay when the popout is later closed. + internal static bool IsOverlayVisible => _uiHost?.IsVisible ?? false; + + /// Spawn the persistent host. Idempotent; called once from Main. + public static void Install() + { + if (_host != null) return; + + var harmony = new Harmony("S3.ui"); + harmony.CreateClassProcessor(typeof(GameInput_IsMouseOverUI_Patch)).Patch(); + harmony.CreateClassProcessor(typeof(GameInput_IsMouseOverGameWindow_Patch)).Patch(); + harmony.CreateClassProcessor(typeof(MapWindow_Toggle_Patch)).Patch(); + harmony.CreateClassProcessor(typeof(MapWindow_Show_Patch)).Patch(); + harmony.CreateClassProcessor(typeof(MapWindow_ShowPos_Patch)).Patch(); + + _host = new GameObject("S3.UiService"); + Object.DontDestroyOnLoad(_host); + _uiHost = _host.AddComponent(); + } + + // Close the overlay if visible. Called by PopoutModule when it opens the popout + // (they share the one map camera — only one surface can own it at a time). + internal static void CloseOverlay() => _uiHost?.HideIfVisible(); + + // Open the overlay if hidden. Called by the map-open interceptor. + internal static void OpenOverlay() => _uiHost?.ShowIfHidden(); + + // Toggle the overlay. Called by the map hotkey interceptor. + internal static void ToggleOverlay() => _uiHost?.ToggleVisible(); +} + +// While the cursor is over our in-game overlay, tell the game the mouse is "over UI" +// and "not over the game window" so its camera pan/zoom and world-click input are +// suppressed (Railroader reads mouse input raw and gates it through these checks). +[HarmonyPatch(typeof(GameInput), nameof(GameInput.IsMouseOverUI))] +internal static class GameInput_IsMouseOverUI_Patch +{ + private static void Postfix(ref bool __result) + { + if (UiHost.MouseOverOverlay) __result = true; + } +} + +[HarmonyPatch(typeof(GameInput), nameof(GameInput.IsMouseOverGameWindow))] +internal static class GameInput_IsMouseOverGameWindow_Patch +{ + private static void Postfix(ref bool __result) + { + if (UiHost.MouseOverOverlay) __result = false; + } +} + +// Intercept the map hotkey (GameInput calls Toggle() when M / the bound key is pressed). +// Open our overlay instead of the stock map panel. +[HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Toggle))] +internal static class MapWindow_Toggle_Patch +{ + private static bool Prefix() + { + if (UiService.MapBypass) return true; + UiService.ToggleOverlay(); + return false; + } +} + +// Intercept no-arg Show (safety net — game currently only calls Toggle or Show(Vector3)). +[HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Show), new System.Type[] { })] +internal static class MapWindow_Show_Patch +{ + private static bool Prefix() + { + if (UiService.MapBypass) return true; + UiService.OpenOverlay(); + return false; + } +} + +// Intercept "Show on map" links (EngineRosterRow, EmployeesPanelBuilder). +// Open our overlay and jump the map camera to the requested position. +[HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Show), new System.Type[] { typeof(Vector3) })] +internal static class MapWindow_ShowPos_Patch +{ + private static bool Prefix(Vector3 gamePosition) + { + if (UiService.MapBypass) return true; + UiService.OpenOverlay(); + MapBuilder.Shared?.SetMapCenter(gamePosition); + return false; + } +} + +/// +/// Per-frame driver for the in-game overlay. Lives on a DontDestroyOnLoad object +/// so it survives scene loads. Toggles visibility on F10 and, while visible, +/// pumps an input snapshot to native and issues the overlay render event at end +/// of frame so ImGui composites over the finished game image. +/// +internal sealed class UiHost : MonoBehaviour +{ + private System.IntPtr _renderFunc = System.IntPtr.Zero; + private int _eventId; + private int _overlayHandle; + private bool _nativeReady; + private bool _visible; + + // Throwaway texture: its only job is to let native call tex->GetDevice() and + // acquire Unity's D3D11 device (UnityPluginLoad never fires for a manually + // LoadLibrary'd plugin). Kept referenced so the GC never collects it. + private Texture2D? _deviceTex; + + // In-game map drag/zoom forwarding (events polled from native each frame). + private const int kMaxInput = 64; + private static readonly InputEvent[] s_inputBuf = new InputEvent[kMaxInput]; + private bool _drag; + private bool _didDrag; + private float _dragStartX, _dragStartY, _camStartX, _camStartZ; + private const float kClickThreshold = 0.02f; + + // True while the overlay is visible and the cursor is over an ImGui window. Read + // by the GameInput Harmony patches to suppress the game's world-mouse input. + internal static bool MouseOverOverlay { get; private set; } + + // Map camera takeover (mirrors the popout's DetachedPanel). While the overlay is + // up we render the map camera into our own RT and collapse the base game map + // window, so the map works without the base map open and without its full-screen + // UI cost. Restored when the overlay is hidden. + private Camera? _mapCamera; + private RenderTexture? _ownRT; + private RenderTexture? _savedTarget; + private Rect _savedRect; + private float _savedAspect; + private Window? _hiddenWindow; + private Vector3 _savedWinScale; + private bool _mapWasOpen; + private bool _mapActive; + + // Chrome state mirrored to the shared handle (status, rotation, follow modes) and + // commands forwarded back from the toolbar/compass. Mirrors DetachedPanel. + private float _mapRotationDeg; + private float _mapCamEulerX, _mapCamEulerZ; + private bool _mapSyncPlayer; + private bool _followPlayer; + private float _listTimer; + private bool _locationsSent; + private string _lastStatus = ""; + + private void Start() + { + if (!NativeLoader.EnsureLoaded()) + { + Log.Error("[ui] native load failed - in-game overlay unavailable this session."); + enabled = false; + return; + } + + _renderFunc = Native.RRPOPOUT_GetRenderEventFunc(); + _eventId = Native.RRPOPOUT_GetOverlayEventId(); + + _deviceTex = new Texture2D(4, 4, TextureFormat.RGBA32, false); + _deviceTex.Apply(false, false); + Native.RRPOPOUT_SetOverlayDeviceTexture(_deviceTex.GetNativeTexturePtr()); + + // Shared UI-state holder for the in-game map chrome (toolbar + compass). + // Created up front so native can render the chrome; state/commands are + // pushed/polled through this handle. + _overlayHandle = Native.RRPOPOUT_GetOverlayWindowHandle(); + + _nativeReady = true; + StartCoroutine(RenderLoop()); + Log.Info("[ui] in-game overlay ready - press F10 to toggle."); + } + + internal bool IsVisible => _visible; + + private void Update() + { + if (!_nativeReady) return; + + // Suppress game world-mouse input only while the cursor is actually over our + // window, so the rest of the screen stays interactive while the map floats. + MouseOverOverlay = _visible && Native.RRPOPOUT_OverlayWantsMouse() == 1; + + // F10: switch the in-game map to the OS popout window. Only fires while the + // in-game overlay is active (no-op if neither map surface is open). + if (_visible && Input.GetKeyDown(KeyCode.F10)) + PopoutModule.ScheduleExternalLaunch(); + } + + private void LateUpdate() + { + // The base game may reset mapCamera.targetTexture or enabled in its own scripts. + // Re-assert our RT and enabled state here — LateUpdate runs immediately before + // camera rendering, so this wins over anything the game set in Update/LateUpdate + // earlier in the same frame. + if (_mapActive && _mapCamera != null && _ownRT != null) + { + _mapCamera.enabled = true; + _mapCamera.targetTexture = _ownRT; + } + } + + // ----- Called by UiService static methods (map-intercept + mutual exclusion) ----- + + internal void HideIfVisible() + { + if (!_nativeReady || !_visible) return; + _visible = false; + Native.RRPOPOUT_SetOverlayVisible(0); + MouseOverOverlay = false; + DeactivateMap(); + Log.Info("[ui] overlay closed (external request)."); + } + + internal void ShowIfHidden() + { + if (!_nativeReady || _visible) return; + // Claim visible now so any re-entrant call from PopoutModule.RestoreInGameWindow + // -> UiService.OpenOverlay -> ShowIfHidden sees us as already open and no-ops. + _visible = true; + Native.RRPOPOUT_SetOverlayVisible(1); + if (PopoutModule.IsDetached) + { + // M pressed while popout owns the camera: close popout first, + // then fall through to ActivateMap below. + PopoutModule.Toggle(); + } + ActivateMap(); + Log.Info("[ui] overlay opened."); + } + + internal void ToggleVisible() + { + if (_visible) HideIfVisible(); + else ShowIfHidden(); + } + + // --------------------------------------------------------------------------------- + + private IEnumerator RenderLoop() + { + var endOfFrame = new WaitForEndOfFrame(); + while (true) + { + yield return endOfFrame; + if (!_visible) continue; + + // Drive our own map camera render into our RT and hand it to native. + // If the map isn't ready yet (no save), keep retrying so it appears as + // soon as it becomes available. UV V is flipped (v0=1, v1=0) to convert + // Unity's bottom-up RT to D3D top-down. + if (!_mapActive) ActivateMap(); + + if (_mapActive && _mapCamera != null && _ownRT != null) + { + // Match the camera aspect to the overlay window's content region so + // the map fills it (no letterbox). Falls back to the RT aspect until + // native has reported a region size. + Native.RRPOPOUT_GetOverlayMapView(out float viewW, out float viewH); + float aspect = (viewW > 0f && viewH > 0f) + ? viewW / viewH + : (float)_ownRT.width / _ownRT.height; + + _mapCamera.enabled = true; + _mapCamera.targetTexture = _ownRT; + _mapCamera.rect = new Rect(0f, 0f, 1f, 1f); + _mapCamera.aspect = aspect; + Native.RRPOPOUT_SetOverlayMapTexture( + _ownRT.GetNativeTexturePtr(), 0f, 1f, 1f, 0f); + } + else + { + Native.RRPOPOUT_SetOverlayMapTexture(System.IntPtr.Zero, 0f, 1f, 1f, 0f); + } + + // Unity mouse origin is bottom-left; ImGui wants top-left. + Vector3 mp = Input.mousePosition; + float mouseY = Screen.height - mp.y; + int wheel = (int)(Input.mouseScrollDelta.y * 120f); // WHEEL_DELTA units + + Native.RRPOPOUT_SetOverlayInput( + Screen.width, Screen.height, + mp.x, mouseY, + Input.GetMouseButton(0) ? 1 : 0, + Input.GetMouseButton(1) ? 1 : 0, + wheel); + + // After WaitForEndOfFrame the final backbuffer is bound, so the native + // callback draws ImGui on top of the completed game frame. + GL.IssuePluginEvent(_renderFunc, _eventId); + + // Apply any drag/zoom the user did over the map image. We forward to the + // live map camera, so the in-game map and our mirror move together. + int n = Native.RRPOPOUT_PollOverlayInput(s_inputBuf, kMaxInput); + for (int i = 0; i < n; i++) + ForwardToMap(s_inputBuf[i]); + + // Chrome: push live status/lists, apply follow/sync, run toolbar commands. + if (_mapActive && _mapCamera != null) + { + UpdateMapStatus(); + RefreshMenuLists(); + ApplyFollowAndSync(); + int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput); + for (int i = 0; i < cn; i++) + ForwardCommand(s_inputBuf[i]); + } + } + } + + // Dragging cancels follow modes so the pan takes over (grab-to-pan). + private void CancelFollowForPan() + { + if (_followPlayer) + { + _followPlayer = false; + Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false); + } + if (MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode) + MapEnhancerBridge.ToggleFollowMode(); + } + + // Rotates the map camera to `deg` and pushes the angle back so the compass needle + // reflects it. Mirrors DetachedPanel.ApplyMapRotation. + private void ApplyMapRotation(float deg) + { + _mapRotationDeg = ((deg % 360f) + 360f) % 360f; + if (_mapCamera != null) + _mapCamera.transform.rotation = + Quaternion.Euler(_mapCamEulerX, _mapRotationDeg, _mapCamEulerZ); + Native.RRPOPOUT_SetMapRotation(_overlayHandle, _mapRotationDeg); + } + + // Pushes the toolbar status line (zoom + map/cam coordinates) when it changes. + private void UpdateMapStatus() + { + string status = PanelFinder.BuildStatusText(_mapCamera!); + if (status == _lastStatus) return; + Native.RRPOPOUT_SetStatusText(_overlayHandle, status); + Native.RRPOPOUT_SetMapZoom(_overlayHandle, _mapCamera!.orthographicSize); + _lastStatus = status; + } + + // Refreshes the Follow/Jump menus: locos every 3 s, locations once. + private void RefreshMenuLists() + { + _listTimer += Time.deltaTime; + if (_listTimer < 3f) return; + _listTimer = 0f; + + try + { + var locos = MapEnhancerBridge.GetLocoNames(); + Native.RRPOPOUT_SetLocoList(_overlayHandle, + locos.Length > 0 ? string.Join("\n", locos) : ""); + } + catch { } + + if (!_locationsSent) + { + try + { + var locs = MapEnhancerBridge.GetLocationNames(); + Native.RRPOPOUT_SetLocationList(_overlayHandle, + locs.Length > 0 ? string.Join("\n", locs) : ""); + _locationsSent = true; + } + catch { } + } + } + + // Drives continuous follow-player-position and sync-rotation-to-player modes. + private void ApplyFollowAndSync() + { + if (_followPlayer) + { + if (Camera.main != null) + { + var p = _mapCamera!.transform.position; + p.x = Camera.main.transform.position.x; + p.z = Camera.main.transform.position.z; + _mapCamera.transform.position = p; + } + else { _followPlayer = false; Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false); } + } + + if (_mapSyncPlayer) + { + if (Camera.main != null) ApplyMapRotation(Camera.main.transform.eulerAngles.y); + else { _mapSyncPlayer = false; Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false); } + } + } + + // Routes a toolbar/compass command (pushed by BuildMapUI into the state handle's + // queue) to the live map. Mirrors DetachedPanel.ForwardInputEvent's UICommand arm. + private void ForwardCommand(in InputEvent e) + { + if ((InputEventType)e.type != InputEventType.UICommand) return; + switch ((UICmd)(int)e.x) + { + case UICmd.Recenter: + UnityEngine.Object.FindObjectOfType()?.ClickLocateMe(); + break; + case UICmd.FollowMode: MapEnhancerBridge.ToggleFollowMode(); break; + case UICmd.FollowPlayerCam: MapEnhancerBridge.FollowPlayerCamera(); break; + case UICmd.FollowSelectedLoco: MapEnhancerBridge.FollowSelectedLoco(); break; + case UICmd.FollowLoco: MapEnhancerBridge.FollowLoco((int)e.y); break; + case UICmd.JumpToLocation: MapEnhancerBridge.JumpToLocation((int)e.y); break; + + case UICmd.SetRotation: + _mapSyncPlayer = false; + Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false); + ApplyMapRotation(e.y); + break; + case UICmd.RotateReset: + _mapSyncPlayer = false; + Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false); + ApplyMapRotation(0f); + break; + case UICmd.RotateSyncPlayer: + _mapSyncPlayer = !_mapSyncPlayer; + Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, _mapSyncPlayer); + break; + case UICmd.ToggleFollowPlayer: + _followPlayer = !_followPlayer; + if (_followPlayer && MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode) + MapEnhancerBridge.ToggleFollowMode(); + Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, _followPlayer); + break; + + case UICmd.PopOut: + // Hand the map off to the OS popout window. ScheduleExternalLaunch + // captures our visible=true state, closes the overlay (DeactivateMap), + // and starts a 0.5 s timer before DetachedPanel takes the camera, + // ensuring the RT is fully torn down before the next owner grabs it. + PopoutModule.ScheduleExternalLaunch(); + break; + } + } + + // Applies one map-image input event to the live map camera. Mirrors the pan/zoom + // math the popout uses (DetachedPanel.ForwardInputEvent). Events arrive in + // normalized [0,1] image space, top-left origin. + private void ForwardToMap(in InputEvent e) + { + var cam = MapBuilder.Shared?.mapCamera; + if (cam == null) return; + + switch ((InputEventType)e.type) + { + case InputEventType.MouseWheel: + // Lower floor than the popout's 100 so you can zoom in close like the + // base game map; UpdateForZoom rescales icons to match. + cam.orthographicSize = Mathf.Clamp( + cam.orthographicSize * Mathf.Pow(0.9f, e.delta), 25f, 10000f); + PanelFinder.UpdateMapForZoom(); + break; + + case InputEventType.LButtonDown: + _drag = true; + _didDrag = false; + _dragStartX = e.x; _dragStartY = e.y; + _camStartX = cam.transform.position.x; + _camStartZ = cam.transform.position.z; + break; + + case InputEventType.LButtonUp: + // A click (no meaningful drag) is forwarded to the map's click handler + // so switches, signals, etc. respond just like in the base game map. + if (_drag && !_didDrag) + PanelFinder.GetMapDrag()?.OnClick?.Invoke(new Vector2(e.x, 1f - e.y)); + _drag = false; + break; + + case InputEventType.MouseMove: + if (!_drag) break; + if (!_didDrag && + (Mathf.Abs(e.x - _dragStartX) > kClickThreshold || + Mathf.Abs(e.y - _dragStartY) > kClickThreshold)) + { + _didDrag = true; // first frame of a real drag + if (PopoutModule.Settings.panDisablesFollow) + CancelFollowForPan(); + } + if (!_didDrag) break; + float ortho = cam.orthographicSize; + float aspect = cam.aspect > 0f ? cam.aspect : 1.7f; + // Screen-space drag (normalized) scaled to world units, then rotated + // into world space by the camera's orientation (handles map rotation). + float dx = (e.x - _dragStartX) * ortho * 2f * aspect; + float dz = (e.y - _dragStartY) * ortho * 2f; + Vector3 wd = -cam.transform.right * dx + cam.transform.up * dz; + cam.transform.position = new Vector3( + _camStartX + wd.x, cam.transform.position.y, _camStartZ + wd.z); + break; + } + } + + // Takes over the map camera: collapse the base game map window and redirect the + // camera into our own RT. Cheap no-op until a save is loaded, so it is safe to + // retry every frame. Skipped while the popout owns the camera (mutually exclusive). + private void ActivateMap() + { + if (_mapActive) return; + if (MapBuilder.Shared?.mapCamera == null) return; // no save yet; retry later + if (PopoutModule.IsDetached) return; // popout owns the map camera + + try + { + var winUI = PanelFinder.GetMapWindowUI(); + _mapWasOpen = winUI != null && winUI.IsShown; + + UiService.MapBypass = true; + MapWindow.Show(); + UiService.MapBypass = false; + if (!PanelFinder.IsMapReady()) return; // not ready yet; retry next frame + + _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) + { + _savedWinScale = rect.localScale; + rect.localScale = Vector3.zero; + } + + _savedTarget = _mapCamera.targetTexture; + _savedRect = _mapCamera.rect; + _savedAspect = _mapCamera.aspect; + + int rtW = _savedTarget != null ? _savedTarget.width : Mathf.Min(Screen.width, 1920); + int rtH = _savedTarget != null ? _savedTarget.height : Mathf.Min(Screen.height, 1080); + _ownRT = new RenderTexture(rtW, rtH, 24, RenderTextureFormat.ARGB32) { name = "S3_InGameMapRT" }; + _ownRT.Create(); + _mapCamera.enabled = true; // counteract SetVisible(false) from prior map close + _mapCamera.targetTexture = _ownRT; + _mapCamera.rect = new Rect(0f, 0f, 1f, 1f); + + Canvas.ForceUpdateCanvases(); + PanelFinder.UpdateMapForZoom(); + + // Chrome: reset rotation/follow, seed native with MapEnhancer status. + _mapCamEulerX = _mapCamera.transform.eulerAngles.x; + _mapCamEulerZ = _mapCamera.transform.eulerAngles.z; + _mapRotationDeg = 0f; + _mapSyncPlayer = false; + _followPlayer = false; + _listTimer = 3f; // refresh lists on the first frame + _locationsSent = false; + _lastStatus = ""; + Native.RRPOPOUT_SetMapEnhancerInstalled(_overlayHandle, MapEnhancerBridge.IsInstalled); + Native.RRPOPOUT_SetMapRotation(_overlayHandle, 0f); + Native.RRPOPOUT_SetMapSyncPlayer(_overlayHandle, false); + Native.RRPOPOUT_SetFollowPlayer(_overlayHandle, false); + + _mapActive = true; + Log.Info("[ui] in-game map activated."); + } + catch (System.Exception ex) + { + UiService.MapBypass = false; // ensure bypass is cleared if we threw mid-call + Log.Error($"[ui] map activation failed: {ex.Message}"); + } + } + + // Restores the map camera and the base game map window to their pre-overlay state. + private void DeactivateMap() + { + Native.RRPOPOUT_SetOverlayMapTexture(System.IntPtr.Zero, 0f, 1f, 1f, 0f); + + if (_mapCamera != null) + { + _mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, 0f, _mapCamEulerZ); + _mapCamera.targetTexture = _savedTarget; + _mapCamera.rect = _savedRect; + _mapCamera.aspect = _savedAspect; + _mapCamera = null; + } + + if (_ownRT != null) + { + // Do not call Release() before Destroy: if the new owner creates a RT in the + // same frame, Unity's pool may return the same D3D handle, and the end-of-frame + // Destroy would then free it from under the new owner. Let Destroy handle both + // GPU teardown and C# cleanup atomically at end-of-frame. + Object.Destroy(_ownRT); + _ownRT = null; + } + + if (_hiddenWindow != null) + { + if (!_mapWasOpen) + { + // Close the window while localScale is still zero (invisible) to prevent + // a 1-frame flash before the window hides itself. + UiService.MapBypass = true; + 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; + _hiddenWindow = null; + } + + _drag = false; + _mapActive = false; + } +} diff --git a/src/Main.cs b/src/Main.cs index 63a415f..280db42 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -1,4 +1,5 @@ using S3.Core; +using S3.Core.Ui; using UnityModManagerNet; namespace S3; @@ -33,6 +34,10 @@ public static class Main _registry.EnableConfigured(); ModConflicts.CheckAtLoad(); + // Always-on core UI service: hosts Dear ImGui inside the game, intercepts + // the base-game map hotkey, and enforces popout<->in-game mutual exclusion. + UiService.Install(); + modEntry.OnGUI = _ => SettingsPanel.Draw(_registry); modEntry.OnSaveGUI = _ => _registry.SaveAll(); diff --git a/src/Modules/Popout/DetachedPanel.cs b/src/Modules/Popout/DetachedPanel.cs index 5b86374..c71e106 100644 --- a/src/Modules/Popout/DetachedPanel.cs +++ b/src/Modules/Popout/DetachedPanel.cs @@ -1,6 +1,5 @@ using System; using System.Runtime.InteropServices; -using Helpers; // WorldTransformer (in Assembly-CSharp) using S3.Core; // Log using UI.Map; using UnityEngine; @@ -96,6 +95,7 @@ namespace S3.Modules.Popout { if (followPlayer != 0) { _followPlayer = true; Native.RRPOPOUT_SetFollowPlayer(_windowHandle, true); } CreateOwnRT(); + _mapCamera.enabled = true; // SetVisible(false) may have disabled it; re-enable now _prevHotkeyDown = IsHotkeyDown(); } @@ -176,25 +176,7 @@ namespace S3.Modules.Popout { // --------------------------------------------------------------------------- private void UpdateStatusText() { - string follow = MapEnhancerBridge.IsInstalled - ? (MapEnhancerBridge.FollowMode ? " · Following" : "") - : ""; - - string mapCoord = ""; - string camCoord = ""; - try { - // Map camera position → game coordinates. - var mapPt = WorldTransformer.WorldToGame(_mapCamera!.transform.position); - mapCoord = $" · Map ({(int)mapPt.x}, {(int)mapPt.z})"; - - // Player/main camera position → game coordinates. - if (Camera.main != null) { - var camPt = WorldTransformer.WorldToGame(Camera.main.transform.position); - camCoord = $" · Cam ({(int)camPt.x}, {(int)camPt.z})"; - } - } catch { /* WorldTransformer not yet ready — silently omit coords */ } - - string status = $"Zoom: {(int)_mapCamera!.orthographicSize}{follow}{mapCoord}{camCoord}"; + string status = PanelFinder.BuildStatusText(_mapCamera!); if (status == _lastStatusText) return; Native.RRPOPOUT_SetStatusText(_windowHandle, status); Native.RRPOPOUT_SetMapZoom(_windowHandle, _mapCamera!.orthographicSize); @@ -247,7 +229,9 @@ namespace S3.Modules.Popout { _mapCamera = null; } if (_ownRT != null) { - _ownRT.Release(); + // Skip Release() — let Destroy handle GPU teardown so the address stays + // live until end-of-frame. This prevents the pool from reissuing the same + // D3D handle to whoever creates a new RT in the same Update call. UnityEngine.Object.Destroy(_ownRT); _ownRT = null; } @@ -323,9 +307,15 @@ namespace S3.Modules.Popout { case InputEventType.MouseMove: if (!_isDragging) break; - if (Mathf.Abs(e.x - _dragStartX) > kClickThreshold || - Mathf.Abs(e.y - _dragStartY) > kClickThreshold) + if (!_didDrag && + (Mathf.Abs(e.x - _dragStartX) > kClickThreshold || + Mathf.Abs(e.y - _dragStartY) > kClickThreshold)) + { _didDrag = true; + if (PopoutModule.Settings.panDisablesFollow && + MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode) + MapEnhancerBridge.ToggleFollowMode(); + } if (!_didDrag) break; float orthoSize = cam.orthographicSize; float aspect = cam.aspect > 0f ? cam.aspect : 1.7f; diff --git a/src/Modules/Popout/NativeInterop.cs b/src/Modules/Popout/NativeInterop.cs index d8d2274..cc89a38 100644 --- a/src/Modules/Popout/NativeInterop.cs +++ b/src/Modules/Popout/NativeInterop.cs @@ -33,13 +33,16 @@ namespace S3.Modules.Popout { RotateReset = 8, // reset map rotation to 0 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 } internal static class Native { - // RRPopout.dll lives in the mod folder (Mods/S3) and is loaded by full path + // S3Native.dll lives in the mod folder (Mods/S3) and is loaded by full path // via NativeLoader before any of these are called; once loaded, [DllImport] // binds to it by name. (Pure-UMM install — no game-root copy, no winhttp proxy.) - private const string Dll = "RRPopout"; + // Named S3Native (not RRPopout) so it can never collide with a leftover copy + // of the old standalone PopOut's RRPopout.dll still injected in someone's game. + private const string Dll = "S3Native"; // Create a floating window. Returns a handle (> 0) or 0 on failure. // Native loads saved position/size from its state file; width/height are the first-run defaults. @@ -115,5 +118,56 @@ namespace S3.Modules.Popout { public static extern void RRPOPOUT_GetPersistedState(int windowHandle, out int followPlayer, out int syncRotation, out float mapRotation, out float mapZoom); + + // ------------------------------------------------------------------- + // In-game overlay. Shares the native engine's shared ImGui context + // and D3D11 device; renders into Unity's bound backbuffer after frame. + // ------------------------------------------------------------------- + + // Event id to pass to GL.IssuePluginEvent to drive the overlay render. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_GetOverlayEventId(); + + // One-time: hand native a texture so it can acquire Unity's D3D device. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayDeviceTexture(IntPtr texturePtr); + + // Per-frame input snapshot (top-left origin, pixels; wheel in WHEEL_DELTA units). + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayInput( + float displayW, float displayH, + float mouseX, float mouseY, + int lButton, int rButton, int wheel); + + // Show or hide the overlay. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayVisible(int visible); + + // Map render texture to display inside the in-game overlay window, plus the + // UV sub-rect (V flipped for a Unity RT: v0=1, v1=0). IntPtr.Zero clears it. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_SetOverlayMapTexture( + IntPtr texturePtr, float u0, float v0, float u1, float v1); + + // 1 when ImGui wants the mouse this frame (hovering a widget). + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_OverlayWantsMouse(); + + // Drains queued in-game map input (drag/zoom over the map image). Events are + // in normalized [0,1] image space (top-left origin); forward to the map camera. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_PollOverlayInput( + [Out] InputEvent[] outEvents, int maxEvents); + + // Reads the map image region size (px) so C# can set camera.aspect to match, + // filling the window with no letterbox bars. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern void RRPOPOUT_GetOverlayMapView(out float outW, out float outH); + + // Handle of the in-game overlay's shared UI-state holder. Push state to it + // (status/loco/location/rotation/follow) and poll it for toolbar commands + // with the same handle-based functions the popout uses. + [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] + public static extern int RRPOPOUT_GetOverlayWindowHandle(); } } diff --git a/src/Modules/Popout/NativeLoader.cs b/src/Modules/Popout/NativeLoader.cs index 52d64ff..c5dc30d 100644 --- a/src/Modules/Popout/NativeLoader.cs +++ b/src/Modules/Popout/NativeLoader.cs @@ -6,11 +6,11 @@ using S3.Core; namespace S3.Modules.Popout; /// -/// Preloads the native RRPopout.dll from the mod folder before any P/Invoke. +/// Preloads the native S3Native.dll from the mod folder before any P/Invoke. /// /// S³ is a pure-UMM install: there is no winhttp proxy and Unity never calls /// UnityPluginLoad for our plugin. By loading the DLL ourselves via its full path, -/// every [DllImport("RRPopout")] in then binds to it by name. +/// every [DllImport("S3Native")] in then binds to it by name. /// The native renderer lazily initializes its D3D device from the supplied texture, /// so the missing UnityPluginLoad is fine — that lazy path becomes the normal one. /// @@ -25,7 +25,7 @@ internal static class NativeLoader { if (_loaded) return true; - string dll = Path.Combine(Main.ModEntry.Path, "RRPopout.dll"); + string dll = Path.Combine(Main.ModEntry.Path, "S3Native.dll"); if (!File.Exists(dll)) { Log.Error($"[popout] native DLL not found: {dll}"); @@ -40,7 +40,7 @@ internal static class NativeLoader } _loaded = true; - Log.Info($"[popout] native RRPopout.dll loaded from {dll}"); + Log.Info($"[popout] native S3Native.dll loaded from {dll}"); return true; } } diff --git a/src/Modules/Popout/PanelFinder.cs b/src/Modules/Popout/PanelFinder.cs index 3dbf739..661beda 100644 --- a/src/Modules/Popout/PanelFinder.cs +++ b/src/Modules/Popout/PanelFinder.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using Helpers; using UI.Common; using UI.Map; using UnityEngine; @@ -55,5 +56,23 @@ namespace S3.Modules.Popout { if (mb != null) Traverse.Create(mb).Method("UpdateForZoom").GetValue(); } + + // Builds the toolbar status string shared by the popout and the in-game overlay. + // ASCII separators only — ImGui's default font has no non-ASCII glyphs. + public static string BuildStatusText(Camera mapCamera) { + string follow = MapEnhancerBridge.IsInstalled + ? (MapEnhancerBridge.FollowMode ? " | Following" : "") + : ""; + string mapCoord = "", camCoord = ""; + try { + var mapPt = WorldTransformer.WorldToGame(mapCamera.transform.position); + mapCoord = $" | Map ({(int)mapPt.x}, {(int)mapPt.z})"; + if (Camera.main != null) { + var camPt = WorldTransformer.WorldToGame(Camera.main.transform.position); + camCoord = $" | Cam ({(int)camPt.x}, {(int)camPt.z})"; + } + } catch { /* WorldTransformer not ready yet — omit coords */ } + return $"Zoom: {(int)mapCamera.orthographicSize}{follow}{mapCoord}{camCoord}"; + } } } diff --git a/src/Modules/Popout/PopoutModule.cs b/src/Modules/Popout/PopoutModule.cs index 549670e..e3c10b2 100644 --- a/src/Modules/Popout/PopoutModule.cs +++ b/src/Modules/Popout/PopoutModule.cs @@ -1,4 +1,5 @@ using S3.Core; +using S3.Core.Ui; using UI.Common; using UI.Map; using UnityEngine; @@ -10,7 +11,7 @@ namespace S3.Modules.Popout; /// S³ module that detaches the in-game map into a resizable native OS window /// (rendered by the shared Win32 + D3D11 + Dear ImGui native engine). /// -/// Loads the native RRPopout.dll on enable, then spawns a +/// Loads the native S3Native.dll on enable, then spawns a /// MonoBehaviour that drives the per-frame map button, hotkey, and panel lifecycle. /// public sealed class PopoutModule : IModule @@ -25,10 +26,25 @@ public sealed class PopoutModule : IModule private static DetachedPanel? _activePanel; private static Window? _hiddenWindow; - private static Vector2 _savedWindowSize; + private static Vector3 _savedWindowScale; private static bool _mapWasOpen; private static GameObject? _host; + // Delayed-open state: after the in-game overlay closes we wait before handing the + // camera to DetachedPanel, giving Unity a full half-second to tear down the RT. + private static bool _pendingOpen; + private static float _pendingTimer; + + // True when the in-game overlay was active at the moment the popout was triggered. + // Restored when the popout closes so the user gets their overlay back automatically. + private static bool _inGameWasOpen; + + // Short delay before reopening the overlay after a popout closes. + // Gives Unity time to finish its end-of-frame Destroy for the popout's RT so the + // D3D address is fully gone before UiHost.ActivateMap allocates a new one. + private static bool _pendingOverlayOpen; + private static float _pendingOverlayTimer; + public static bool IsDetached => _activePanel != null; public PopoutModule() @@ -66,7 +82,9 @@ public sealed class PopoutModule : IModule public void OnDisable() { // Reserved for live toggling. When wired, destroy the active panel + host here. - if (_activePanel != null) { _activePanel.Destroy(); _activePanel = null; RestoreInGameWindow(); } + if (_activePanel != null) CloseActivePanel("[popout] Module disabled."); + _pendingOpen = false; + _pendingOverlayOpen = false; if (_host != null) { Object.Destroy(_host); _host = null; } } @@ -78,12 +96,57 @@ public sealed class PopoutModule : IModule internal static void RebuildHotkey() => _hotkey = new KeyBinding { modifiers = (byte)Settings.hotkeyModifiers, keyCode = (KeyCode)Settings.hotkeyKeyCode }; + // Called by UiService (Pop Out toolbar button or F10) to transition the map from + // the in-game overlay to the OS popout window. Captures the overlay state BEFORE + // closing so the overlay can be restored when the popout is later dismissed. + internal static void ScheduleExternalLaunch() + { + if (_activePanel != null || _pendingOpen) return; // already detached or pending + + if (_host == null) + { + if (!NativeLoader.EnsureLoaded()) return; + _host = new GameObject("S3.Popout.Host"); + Object.DontDestroyOnLoad(_host); + _host.AddComponent(); + } + + _inGameWasOpen = UiService.IsOverlayVisible; // capture before closing + UiService.CloseOverlay(); // release camera + RT now + _pendingOpen = true; + _pendingTimer = 0.5f; + } + // Called each frame by PopoutHost. internal static void Tick() { MapWindowButton.TryInstall(); MapWindowButton.UpdateLabel(_activePanel != null); + // Delayed open: wait for the overlay's RT to fully tear down before DetachedPanel + // grabs the camera. + if (_pendingOpen) + { + _pendingTimer -= UnityEngine.Time.deltaTime; + if (_pendingTimer <= 0f) + { + _pendingOpen = false; + DoOpenPopout(); + } + } + + // Delayed overlay restore: wait one frame for the popout's RT Destroy to process + // at end-of-frame before UiHost.ActivateMap allocates a new RT. + if (_pendingOverlayOpen) + { + _pendingOverlayTimer -= UnityEngine.Time.deltaTime; + if (_pendingOverlayTimer <= 0f) + { + _pendingOverlayOpen = false; + UiService.OpenOverlay(); + } + } + if (_hotkey.Down()) Toggle(); @@ -91,10 +154,7 @@ public sealed class PopoutModule : IModule if (_activePanel != null && (!_activePanel.IsAlive || _activePanel.CloseRequested)) { - _activePanel.Destroy(); - _activePanel = null; - RestoreInGameWindow(); - Log.Info("[popout] Map popout closed."); + CloseActivePanel("[popout] Map popout closed."); } } @@ -102,35 +162,72 @@ public sealed class PopoutModule : IModule { if (_activePanel != null) { - _activePanel.Destroy(); - _activePanel = null; - RestoreInGameWindow(); - Log.Info("[popout] Map re-attached."); + CloseActivePanel("[popout] Map re-attached."); return; } - // Remember whether the map was already open — if we opened it ourselves, we - // close it again when the popout closes. + // If the in-game overlay owns the camera, schedule a delayed open so the RT + // has time to fully tear down before DetachedPanel grabs the camera. + if (UiService.IsOverlayVisible) + { + _inGameWasOpen = true; + UiService.CloseOverlay(); + _pendingOpen = true; + _pendingTimer = 0.5f; + return; + } + + DoOpenPopout(); + } + + // Closes the active popout panel and optionally restores the in-game overlay. + private static void CloseActivePanel(string logMsg) + { + _activePanel!.Destroy(); + _activePanel = null; + bool wasInGame = _inGameWasOpen; + _inGameWasOpen = false; + RestoreInGameWindow(); + if (wasInGame) + { + // Delay overlay reopen by ~0.1 s so Unity's end-of-frame Destroy for the + // popout's RT finishes before UiHost.ActivateMap creates a new one. Without + // this, the pool can reissue the same D3D address and the deferred Destroy + // then invalidates the brand-new RT from under ActivateMap. + _pendingOverlayOpen = true; + _pendingOverlayTimer = 0.1f; + } + Log.Info(logMsg); + } + + // Creates the DetachedPanel after the camera is free. + private static void DoOpenPopout() + { + // Remember whether the map was already open so we can close it again on teardown. Window? win = PanelFinder.GetMapWindowUI(); _mapWasOpen = win != null && win.IsShown; + UiService.MapBypass = true; MapWindow.Show(); + UiService.MapBypass = false; if (!PanelFinder.IsMapReady()) { - Log.Warn("[popout] Map not ready — ensure a save is loaded."); + Log.Warn("[popout] Map not ready - ensure a save is loaded."); return; } - // Collapse the in-game panel to zero size (invisible, all components active). + // 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(); if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect) { - _savedWindowSize = rect.sizeDelta; - rect.sizeDelta = Vector2.zero; + _savedWindowScale = rect.localScale; + rect.localScale = Vector3.zero; } - _activePanel = new DetachedPanel("Railroader — Map"); + _activePanel = new DetachedPanel("Railroader Map"); if (!_activePanel.IsAlive) { Log.Error("[popout] RRPOPOUT_CreateWindow returned 0."); @@ -146,14 +243,18 @@ public sealed class PopoutModule : IModule { if (_hiddenWindow == null) return; - if (_hiddenWindow.transform is RectTransform rect) - rect.sizeDelta = _savedWindowSize; - - // If the map wasn't open before the popout, close it logically. Toggle() is - // used instead of SetActive(false) — SetActive can make MapWindow unfindable - // by FindObjectOfType on the next open. if (!_mapWasOpen) + { + // Close the window while localScale is still zero (invisible) to prevent a + // 1-frame flash. Toggle() is used instead of SetActive(false) so MapWindow + // remains findable by FindObjectOfType on the next open. + UiService.MapBypass = true; 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; _hiddenWindow = null; } diff --git a/src/Modules/Popout/PopoutSettings.cs b/src/Modules/Popout/PopoutSettings.cs index 731e11d..4681067 100644 --- a/src/Modules/Popout/PopoutSettings.cs +++ b/src/Modules/Popout/PopoutSettings.cs @@ -14,4 +14,8 @@ public class PopoutSettings // UMM modifier bitmask: 1=Shift, 2=Ctrl, 4=Alt. Default: F9, no modifier. public int hotkeyModifiers = 0; public int hotkeyKeyCode = (int)KeyCode.F9; + + // 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; } diff --git a/src/Modules/Popout/PopoutSettingsUI.cs b/src/Modules/Popout/PopoutSettingsUI.cs index 11aaca1..e2a224a 100644 --- a/src/Modules/Popout/PopoutSettingsUI.cs +++ b/src/Modules/Popout/PopoutSettingsUI.cs @@ -41,6 +41,12 @@ static class PopoutSettingsUI GUILayout.Space(8f); + // ── Behaviour ─────────────────────────────────────────────────────────── + bool pan = GUILayout.Toggle(s.panDisablesFollow, " Dragging the map cancels follow mode"); + if (pan != s.panDisablesFollow) { s.panDisablesFollow = pan; PopoutModule.Persist(); } + + 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)))