Replace the bare-bones stock map with a full Dear ImGui in-game overlay (M key). The overlay shares the same toolbar and compass as the OS popout window: Follow, Pop Out, Gear, rotation compass. Pop Out button is now in the bottom toolbar where it is actually visible. Pressing it (or F10) closes the overlay, waits 0.5 s for the camera to release, then opens the OS popout window. Closing the popout restores the overlay if it was running when the launch was triggered. Pressing M while the popout is open closes the popout and reopens the overlay. Harmony patches intercept MapWindow.Toggle and Show so base-game "Show on map" links and the map hotkey both route through the S3 overlay. MapBypass lets internal S3 calls through without triggering the patch recursively. Camera ownership is enforced in LateUpdate so no base-game script can reset targetTexture before the camera renders. Object.Destroy replaces Release() on both RT teardown paths so the D3D address stays live until end-of-frame and cannot be recycled into a new RT mid-frame.
261 lines
9 KiB
C#
261 lines
9 KiB
C#
using S3.Core;
|
|
using S3.Core.Ui;
|
|
using UI.Common;
|
|
using UI.Map;
|
|
using UnityEngine;
|
|
using UnityModManagerNet;
|
|
|
|
namespace S3.Modules.Popout;
|
|
|
|
/// <summary>
|
|
/// 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 S3Native.dll on enable, then spawns a <see cref="PopoutHost"/>
|
|
/// MonoBehaviour that drives the per-frame map button, hotkey, and panel lifecycle.
|
|
/// </summary>
|
|
public sealed class PopoutModule : IModule
|
|
{
|
|
private const string SettingsFile = "S3.popout.json";
|
|
|
|
public static PopoutSettings Settings { get; private set; } = new();
|
|
|
|
// Persistent KeyBinding instance (not rebuilt per frame) so .Down() edge detection works.
|
|
private static KeyBinding _hotkey = new();
|
|
public static KeyBinding Hotkey => _hotkey;
|
|
|
|
private static DetachedPanel? _activePanel;
|
|
private static Window? _hiddenWindow;
|
|
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()
|
|
{
|
|
Settings = SettingsStore.Load<PopoutSettings>(SettingsFile);
|
|
RebuildHotkey();
|
|
}
|
|
|
|
public string Id => "popout";
|
|
public string DisplayName => "Map Popout";
|
|
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 " +
|
|
"on the map window. Optional MapEnhancer integration for follow modes.";
|
|
|
|
public bool Enabled
|
|
{
|
|
get => Settings.enabled;
|
|
set => Settings.enabled = value;
|
|
}
|
|
|
|
public void OnEnable()
|
|
{
|
|
if (!NativeLoader.EnsureLoaded())
|
|
{
|
|
Log.Error("[popout] native load failed — module will not run this session.");
|
|
return;
|
|
}
|
|
|
|
_host = new GameObject("S3.Popout.Host");
|
|
Object.DontDestroyOnLoad(_host);
|
|
_host.AddComponent<PopoutHost>();
|
|
}
|
|
|
|
public void OnDisable()
|
|
{
|
|
// Reserved for live toggling. When wired, destroy the active panel + host here.
|
|
if (_activePanel != null) CloseActivePanel("[popout] Module disabled.");
|
|
_pendingOpen = false;
|
|
_pendingOverlayOpen = false;
|
|
if (_host != null) { Object.Destroy(_host); _host = null; }
|
|
}
|
|
|
|
public void SaveSettings() => Persist();
|
|
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
|
|
|
|
public void DrawSettings() => PopoutSettingsUI.Draw();
|
|
|
|
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<PopoutHost>();
|
|
}
|
|
|
|
_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();
|
|
|
|
_activePanel?.Update();
|
|
|
|
if (_activePanel != null && (!_activePanel.IsAlive || _activePanel.CloseRequested))
|
|
{
|
|
CloseActivePanel("[popout] Map popout closed.");
|
|
}
|
|
}
|
|
|
|
internal static void Toggle()
|
|
{
|
|
if (_activePanel != null)
|
|
{
|
|
CloseActivePanel("[popout] Map re-attached.");
|
|
return;
|
|
}
|
|
|
|
// 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.");
|
|
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();
|
|
if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect)
|
|
{
|
|
_savedWindowScale = rect.localScale;
|
|
rect.localScale = Vector3.zero;
|
|
}
|
|
|
|
_activePanel = new DetachedPanel("Railroader Map");
|
|
if (!_activePanel.IsAlive)
|
|
{
|
|
Log.Error("[popout] RRPOPOUT_CreateWindow returned 0.");
|
|
_activePanel = null;
|
|
RestoreInGameWindow();
|
|
return;
|
|
}
|
|
|
|
Log.Info("[popout] Map detached.");
|
|
}
|
|
|
|
private static void RestoreInGameWindow()
|
|
{
|
|
if (_hiddenWindow == null) return;
|
|
|
|
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;
|
|
}
|
|
}
|