Map module: - In-game overlay (UiService) with full map chrome matching the popout - Six themes (S3 Dark, Railroader Classic, Night Mode, Hi-Vis, Retro Terminal, Custom) - Per-element custom color editor with hex input and live R/G/B/A sliders - Independent window and map opacity controls - Theme and opacity changes apply from both the in-game overlay and the popout window - Renamed module display name from "Map Popout" to "Map Module" Native engine: - SnapshotTheme() helper reduces per-frame mutex acquisitions from two to one - Toolbar button text color auto-contrasts against the button background - Compass colors driven by theme data README: - Logo, screenshots, and videos for all features - Physics Optimizer before/after profiler comparison - Correct MapEnhancer link (maintained fork)
708 lines
29 KiB
C#
708 lines
29 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Always-on core service that hosts Dear ImGui <em>inside</em> 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Spawn the persistent host. Idempotent; called once from Main.</summary>
|
|
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<UiHost>();
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
// Apply saved theme and both alpha settings so the overlay opens with the right look.
|
|
MapThemes.ApplyFromSettings();
|
|
Native.RRPOPOUT_SetOverlayAlpha(PopoutModule.Settings.overlayAlpha);
|
|
Native.RRPOPOUT_SetOverlayMapAlpha(PopoutModule.Settings.overlayMapAlpha);
|
|
|
|
StartCoroutine(RenderLoop());
|
|
Log.Info("[ui] in-game overlay ready - press F10 to toggle.");
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hides `win` by zeroing its localScale, saving the original for later restore.
|
|
// Safe to call multiple times — skips if _hiddenWindow is already set.
|
|
private void PreHideWindow(Window win)
|
|
{
|
|
if (_hiddenWindow != null) return;
|
|
if (win.transform is RectTransform rect)
|
|
{
|
|
_savedWinScale = rect.localScale;
|
|
rect.localScale = Vector3.zero;
|
|
}
|
|
_hiddenWindow = win;
|
|
}
|
|
|
|
// Restores _hiddenWindow's localScale on the next frame so any close
|
|
// animation the game plays runs while the window is still invisible (scale=0),
|
|
// then scale is returned to normal once it is fully hidden.
|
|
private System.Collections.IEnumerator DeferredScaleRestore(Window win, Vector3 scale)
|
|
{
|
|
yield return null;
|
|
if (win != null && win.transform is RectTransform rect)
|
|
rect.localScale = scale;
|
|
}
|
|
|
|
// Dragging cancels follow modes so the pan takes over (grab-to-pan).
|
|
private void CancelFollowForPan()
|
|
{
|
|
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<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(_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;
|
|
|
|
case UICmd.SetTheme:
|
|
MapThemes.Apply((MapTheme)(int)e.y);
|
|
// Also update the map camera background colour immediately so it
|
|
// matches the new theme even before the next ActivateMap.
|
|
if (_mapCamera != null)
|
|
_mapCamera.backgroundColor =
|
|
MapThemes.GetMapBgColor((MapTheme)(int)e.y);
|
|
break;
|
|
|
|
case UICmd.SetAlpha:
|
|
float alpha = Mathf.Clamp(e.y, 0.1f, 1.0f);
|
|
PopoutModule.Settings.overlayAlpha = alpha;
|
|
PopoutModule.Persist();
|
|
Native.RRPOPOUT_SetOverlayAlpha(alpha);
|
|
break;
|
|
|
|
case UICmd.Close:
|
|
HideIfVisible();
|
|
break;
|
|
|
|
case UICmd.SetMapAlpha:
|
|
float mapAlpha = Mathf.Clamp(e.y, 0.0f, 1.0f);
|
|
PopoutModule.Settings.overlayMapAlpha = mapAlpha;
|
|
PopoutModule.Persist();
|
|
Native.RRPOPOUT_SetOverlayMapAlpha(mapAlpha);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Pre-hide the window BEFORE Show() so it never has a frame at full
|
|
// scale. Belt 1: catches the common case and prevents flash even if
|
|
// IsMapReady() returns false and we bail out early below.
|
|
if (winUI != null) PreHideWindow(winUI);
|
|
|
|
UiService.MapBypass = true;
|
|
MapWindow.Show();
|
|
UiService.MapBypass = false;
|
|
|
|
if (!PanelFinder.IsMapReady()) return; // still hidden thanks to pre-hide
|
|
|
|
_mapCamera = MapBuilder.Shared!.mapCamera;
|
|
|
|
// Belt 2: if pre-hide missed the window (it was null before Show) or
|
|
// Show() reset the localScale, zero it again now.
|
|
winUI = PanelFinder.GetMapWindowUI();
|
|
if (winUI != null)
|
|
{
|
|
PreHideWindow(winUI); // no-op if _hiddenWindow already set
|
|
if (_hiddenWindow?.transform is RectTransform rect2 && rect2.localScale != Vector3.zero)
|
|
rect2.localScale = Vector3.zero;
|
|
}
|
|
|
|
_savedTarget = _mapCamera.targetTexture;
|
|
_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);
|
|
|
|
// Apply the theme's map camera background colour (visible as empty-space
|
|
// colour behind terrain/water). Safe to set anytime after camera takeover.
|
|
_mapCamera.backgroundColor =
|
|
MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
|
|
|
|
Canvas.ForceUpdateCanvases();
|
|
PanelFinder.UpdateMapForZoom();
|
|
|
|
// 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;
|
|
}
|
|
// Defer the scale restore by one frame so Unity can finish any close animation
|
|
// at scale=0 before we put the window back to its normal size.
|
|
Window winToRestore = _hiddenWindow;
|
|
Vector3 scaleToRestore = _savedWinScale;
|
|
_hiddenWindow = null;
|
|
StartCoroutine(DeferredScaleRestore(winToRestore, scaleToRestore));
|
|
}
|
|
|
|
_drag = false;
|
|
_mapActive = false;
|
|
}
|
|
}
|