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; // 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()?.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; } }