railroader-setons-special-s.../src/Modules/Popout/DetachedPanel.cs
seton dd1af2ec30 v0.2.3 - MapEnhancer settings panel, opacity controls, right-click recenter
Extends the existing MapEnhancer gear menu integration with a full settings
panel covering the options added by the community fork: marker visibility
toggles, scale sliders for flares, junctions, track lines, and crossings,
and bulk switch reset buttons.

Also adds pan-cancels-follow and right-click to recenter on player toggles,
three independent opacity controls (Window, Map Elements, Map Background),
a per-element hover minimum of 50% for the window chrome so controls stay
reachable at low opacity, and fixes to make the compass and toolbar correctly
respond to their respective sliders. Both surfaces (in-game overlay and OS
popout) maintain parity on all new settings.
2026-06-19 08:32:05 -04:00

460 lines
21 KiB
C#

using System;
using System.Runtime.InteropServices;
using S3.Core; // Log
using UI.Map;
using UnityEngine;
using UnityEngine.Rendering;
namespace S3.Modules.Popout {
// Manages one detached map popout window.
internal class DetachedPanel {
public bool IsAlive => _windowHandle > 0;
private static IntPtr _renderEventFunc = IntPtr.Zero;
private int _windowHandle;
private Camera? _mapCamera;
private RenderTexture? _ownRT;
private RenderTexture? _savedTargetTexture;
private Rect _savedRect;
private float _savedAspect;
// Drag / click state
private bool _isDragging;
private bool _didDrag;
private float _dragStartX, _dragStartY;
private float _camStartX, _camStartZ;
private const float kClickThreshold = 0.02f;
private const float kZoomFactor = 0.9f;
private const float kZoomMin = 100f;
private const float kZoomMax = 10000f;
// GetAsyncKeyState works regardless of which window has focus.
[DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey);
private bool _prevHotkeyDown;
public bool CloseRequested { get; private set; }
// Status bar: only push to native when the string changes.
private string _lastStatusText = "";
// Menu list refresh — locos every 3 s, locations once.
private float _listTimer = 3f; // trigger immediately on first Update
private bool _locationsSent = false;
// Map rotation
private float _mapRotationDeg = 0f;
private float _mapCamEulerX = 0f;
private float _mapCamEulerZ = 0f;
private bool _mapSyncPlayer = false;
// Follow player position (independent of MapEnhancer)
private bool _followPlayer = false;
private bool _meDirty = false;
private const int kMaxInputEvents = 64;
private static readonly InputEvent[] s_eventBuffer = new InputEvent[kMaxInputEvents];
public DetachedPanel(string title) {
if (_renderEventFunc == IntPtr.Zero)
_renderEventFunc = Native.RRPOPOUT_GetRenderEventFunc();
_mapCamera = MapBuilder.Shared?.mapCamera;
_windowHandle = Native.RRPOPOUT_CreateWindow(title, 900, 700);
if (_windowHandle == 0 || _mapCamera == null) return;
_mapCamEulerX = _mapCamera.transform.eulerAngles.x;
_mapCamEulerZ = _mapCamera.transform.eulerAngles.z;
Native.RRPOPOUT_SetMapEnhancerInstalled(_windowHandle, MapEnhancerBridge.IsInstalled);
Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow);
Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter);
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
_mapCamera.backgroundColor = bgc;
if (MapEnhancerBridge.IsInstalled) PushMEState();
// Native loaded window_state.ini before showing the window (position/size/topmost
// already applied). Read back the C#-side state and apply it here.
Native.RRPOPOUT_GetPersistedState(_windowHandle,
out int followPlayer, out int syncRotation,
out float mapRotation, out float mapZoom);
// Preserve the game's original RT dimensions exactly so Camera.pixelWidth and
// Camera.pixelHeight are identical to the in-game map. Icon scripts that scale
// based on pixelWidth/Height will then produce the same sizes as in-game.
// We still set camera.aspect = window ratio so the correct FOV fills the window;
// the RT-vs-window aspect mismatch is cancelled by the camera's horizontal FOV
// expansion and the native blit's corresponding horizontal stretch.
_savedTargetTexture = _mapCamera.targetTexture;
_savedRect = _mapCamera.rect;
_savedAspect = _mapCamera.aspect;
Log.Info($"[popout] mapCam RT={((_mapCamera.targetTexture != null) ? $"{_mapCamera.targetTexture.width}x{_mapCamera.targetTexture.height}" : "null(screen)")} screen={Screen.width}x{Screen.height} rect={_mapCamera.rect} aspect={_mapCamera.aspect:F3}");
_mapCamera.orthographicSize = Mathf.Clamp(mapZoom, 100f, 10000f);
if (mapRotation != 0f) ApplyMapRotation(mapRotation);
if (syncRotation != 0) { _mapSyncPlayer = true; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, true); }
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();
}
public void Update() {
if (_windowHandle == 0 || _mapCamera == null || _ownRT == null) return;
Native.RRPOPOUT_GetWindowSize(_windowHandle, out int w, out int h);
if (w == 0 && h == 0) { _windowHandle = 0; return; }
// RT dimensions are fixed at the game's original map RT size — never resized.
// Only aspect changes as the window is resized, so the camera renders the
// correct FOV for the current window shape.
_mapCamera.enabled = true;
_mapCamera.targetTexture = _ownRT;
_mapCamera.rect = new Rect(0f, 0f, 1f, 1f);
_mapCamera.aspect = (float)w / h;
var inGameRT = PanelFinder.GetMapRenderTexture();
if (inGameRT != null && inGameRT != _ownRT)
Graphics.Blit(_ownRT, inGameRT);
Native.RRPOPOUT_SetFrameTexture(_windowHandle,
_ownRT.GetNativeTexturePtr(), 0f, 1f, 1f, 0f);
GL.IssuePluginEvent(_renderEventFunc, _windowHandle);
// --- Status bar ---
UpdateStatusText();
// --- Menu list refresh ---
_listTimer += Time.deltaTime;
if (_listTimer >= 3f) {
_listTimer = 0f;
PushLocoList();
if (!_locationsSent) { PushLocationList(); _locationsSent = true; }
}
// --- Follow player position ---
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(_windowHandle, false);
}
}
// --- Map rotation sync ---
if (_mapSyncPlayer) {
if (Camera.main != null)
ApplyMapRotation(Camera.main.transform.eulerAngles.y);
else {
_mapSyncPlayer = false;
Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false);
}
}
// --- Input ---
int count = Native.RRPOPOUT_PollInputEvents(_windowHandle, s_eventBuffer, kMaxInputEvents);
for (int i = 0; i < count; i++)
ForwardInputEvent(s_eventBuffer[i]);
if (_meDirty) { PushMEState(); _meDirty = false; }
// Hotkey mirror
bool hotkeyNow = IsHotkeyDown();
if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true;
_prevHotkeyDown = hotkeyNow;
}
// ---------------------------------------------------------------------------
private void PushMEState() {
if (!MapEnhancerBridge.IsInstalled) return;
Native.RRPOPOUT_SetMEState(_windowHandle,
MapEnhancerBridge.GetMEBoolFlags(),
MapEnhancerBridge.GetMEFloat(0),
MapEnhancerBridge.GetMEFloat(1),
MapEnhancerBridge.GetMEFloat(2),
MapEnhancerBridge.GetMEFloat(3));
}
private void ApplyMapRotation(float deg) {
_mapRotationDeg = ((deg % 360f) + 360f) % 360f;
if (_mapCamera != null)
_mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, _mapRotationDeg, _mapCamEulerZ);
Native.RRPOPOUT_SetMapRotation(_windowHandle, _mapRotationDeg);
}
// ---------------------------------------------------------------------------
private void UpdateStatusText() {
string status = PanelFinder.BuildStatusText(_mapCamera!);
if (status == _lastStatusText) return;
Native.RRPOPOUT_SetStatusText(_windowHandle, status);
Native.RRPOPOUT_SetMapZoom(_windowHandle, _mapCamera!.orthographicSize);
_lastStatusText = status;
}
private void PushLocoList() {
try {
var names = MapEnhancerBridge.GetLocoNames();
Native.RRPOPOUT_SetLocoList(_windowHandle,
names.Length > 0 ? string.Join("\n", names) : "");
} catch { }
}
private void PushLocationList() {
try {
var names = MapEnhancerBridge.GetLocationNames();
Native.RRPOPOUT_SetLocationList(_windowHandle,
names.Length > 0 ? string.Join("\n", names) : "");
} catch { }
}
// Returns true while the configured hotkey combination is held.
private static bool IsHotkeyDown() {
var kb = PopoutModule.Hotkey;
bool wantShift = (kb.modifiers & 1) != 0;
bool wantCtrl = (kb.modifiers & 2) != 0;
bool wantAlt = (kb.modifiers & 4) != 0;
if (IsDown(0x10) != wantShift) return false;
if (IsDown(0x11) != wantCtrl ) return false;
if (IsDown(0x12) != wantAlt ) return false;
return IsDown(ToVirtualKey(kb.keyCode));
}
private static bool IsDown(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0;
private static int ToVirtualKey(KeyCode k) {
int c = (int)k;
if (c >= 97 && c <= 122) return c - 32;
if (c >= 282 && c <= 296) return 0x70 + (c - 282);
return c;
}
public void Destroy() {
if (_mapCamera != null) {
_mapCamera.targetTexture = _savedTargetTexture;
_mapCamera.rect = _savedRect;
_mapCamera.aspect = _savedAspect;
_mapCamera.transform.rotation = Quaternion.Euler(_mapCamEulerX, 0f, _mapCamEulerZ);
_mapCamera = null;
}
if (_ownRT != null) {
// 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;
}
if (_windowHandle != 0) {
Native.RRPOPOUT_DestroyWindow(_windowHandle);
_windowHandle = 0;
}
}
// ---------------------------------------------------------------------------
private void CreateOwnRT() {
if (_ownRT != null) { _ownRT.Release(); UnityEngine.Object.Destroy(_ownRT); }
// Use the game's original RT dimensions so Camera.pixelWidth AND pixelHeight
// are identical to the in-game map. camera.aspect is updated each frame in
// Update() to match the current window shape.
int rtW = _savedTargetTexture != null ? _savedTargetTexture.width : Mathf.Min(Screen.width, 1920);
int rtH = _savedTargetTexture != null ? _savedTargetTexture.height : Mathf.Min(Screen.height, 1080);
_ownRT = new RenderTexture(rtW, rtH, 24, RenderTextureFormat.ARGB32) { name = "RRPopout_MapRT" };
_ownRT.Create();
if (_mapCamera != null) {
_mapCamera.targetTexture = _ownRT;
_mapCamera.rect = new Rect(0f, 0f, 1f, 1f);
}
Canvas.ForceUpdateCanvases();
// Apply the correct icon scale for the current zoom now that the map renders
// into our RT. MapBuilder.UpdateForZoom() recomputes MapBuilder.IconScale from
// the camera's orthographic size and rescales every map icon, matching how the
// game refreshes the map after a zoom.
PanelFinder.UpdateMapForZoom();
}
private void ForwardInputEvent(in InputEvent e) {
var cam = _mapCamera;
if (cam == null) return;
var viewportPos = new Vector2(e.x, 1f - e.y);
switch ((InputEventType)e.type) {
case InputEventType.MouseWheel:
cam.orthographicSize = Mathf.Clamp(
cam.orthographicSize * Mathf.Pow(kZoomFactor, e.delta),
kZoomMin, kZoomMax);
// The game rescales every map icon (locos, junctions, stations, flares)
// inside MapBuilder.UpdateForZoom(), which is normally driven by
// MapWindow.OnZoom(). Because we set orthographicSize directly, we must
// trigger it ourselves — otherwise icons keep their previous pixel size
// as you zoom in/out. This is the same call MapEnhancer makes after it
// adjusts the orthographic size.
PanelFinder.UpdateMapForZoom();
break;
case InputEventType.LButtonDown:
_isDragging = true;
_didDrag = false;
_dragStartX = e.x; _dragStartY = e.y;
_camStartX = cam.transform.position.x;
_camStartZ = cam.transform.position.z;
// Dragging cancels position follow (same behaviour as Google Maps grab-to-pan)
if (_followPlayer) {
_followPlayer = false;
Native.RRPOPOUT_SetFollowPlayer(_windowHandle, false);
}
break;
case InputEventType.LButtonUp:
if (!_didDrag)
PanelFinder.GetMapDrag()?.OnClick?.Invoke(viewportPos);
_isDragging = false;
_didDrag = false;
break;
case InputEventType.MouseMove:
if (!_isDragging) break;
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;
// Screen-space drag scaled to world units
float dxScreen = (e.x - _dragStartX) * orthoSize * 2f * aspect;
float dzScreen = (e.y - _dragStartY) * orthoSize * 2f;
// Rotate screen drag into world space using camera orientation.
// For top-down camera: right and up are horizontal (Y≈0) so no projection needed.
// Formula preserves original sign conventions at rotDeg=0.
Vector3 worldDelta = -cam.transform.right * dxScreen + cam.transform.up * dzScreen;
cam.transform.position = new Vector3(
_camStartX + worldDelta.x, cam.transform.position.y, _camStartZ + worldDelta.z);
break;
case InputEventType.RButtonUp:
if (PopoutModule.Settings.rightClickRecenter)
UnityEngine.Object.FindObjectOfType<MapWindow>()?.ClickLocateMe();
break;
case InputEventType.UICommand:
switch ((UICmd)(int)e.x) {
case UICmd.Recenter:
UnityEngine.Object.FindObjectOfType<MapWindow>()?.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(_windowHandle, false);
ApplyMapRotation(e.y);
break;
case UICmd.RotateReset:
_mapSyncPlayer = false;
Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false);
ApplyMapRotation(0f);
break;
case UICmd.RotateSyncPlayer:
_mapSyncPlayer = !_mapSyncPlayer;
Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, _mapSyncPlayer);
break;
case UICmd.ToggleFollowPlayer:
_followPlayer = !_followPlayer;
// Cancel any ME follow mode to avoid conflicting camera updates
if (_followPlayer && MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode)
MapEnhancerBridge.ToggleFollowMode();
Native.RRPOPOUT_SetFollowPlayer(_windowHandle, _followPlayer);
break;
case UICmd.Close:
CloseRequested = true;
break;
case UICmd.SetTheme:
MapThemes.Apply((MapTheme)(int)e.y);
if (_mapCamera != null) {
var c = MapThemes.GetMapBgColor((MapTheme)(int)e.y);
c.a *= PopoutModule.Settings.overlayMapBgAlpha;
_mapCamera.backgroundColor = c;
}
break;
case UICmd.SetAlpha:
float alpha = Mathf.Clamp(e.y, 0.1f, 1.0f);
PopoutModule.Settings.overlayAlpha = alpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayAlpha(alpha);
break;
case UICmd.SetMapAlpha:
float mapAlpha = Mathf.Clamp(e.y, 0.0f, 1.0f);
PopoutModule.Settings.overlayMapAlpha = mapAlpha;
PopoutModule.Persist();
Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha);
break;
case UICmd.SetMEBool:
MapEnhancerBridge.SetMEBool((int)e.y, e.delta != 0f);
if ((int)e.y == 6) _locationsSent = false; // EnableModdedSpawnPoints — re-push location list
_meDirty = true;
break;
case UICmd.SetMEFloat:
MapEnhancerBridge.SetMEFloat((int)e.y, e.delta);
_meDirty = true;
break;
case UICmd.MEResetSwitchesNormal:
MapEnhancerBridge.ResetAllSwitchesToNormal();
break;
case UICmd.MEResetSwitchesThrown:
MapEnhancerBridge.ResetAllSwitchesToThrown();
break;
case UICmd.TogglePanDisablesFollow:
PopoutModule.Settings.panDisablesFollow = !PopoutModule.Settings.panDisablesFollow;
PopoutModule.Persist();
Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow);
break;
case UICmd.ToggleRightClickRecenter:
PopoutModule.Settings.rightClickRecenter = !PopoutModule.Settings.rightClickRecenter;
PopoutModule.Persist();
Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter);
break;
case UICmd.SetMapBgAlpha:
float bgAlpha = Mathf.Clamp(e.y, 0f, 1f);
PopoutModule.Settings.overlayMapBgAlpha = bgAlpha;
PopoutModule.Persist();
if (_mapCamera != null) {
var bgc2 = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc2.a *= bgAlpha;
_mapCamera.backgroundColor = bgc2;
}
break;
}
break;
}
}
}
}