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.
460 lines
21 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|