Add Camera controls and GUI improvements to ClientControlGUI (#561)

* fix key binds and NoClip logic

* Add camera controls to GUI
This commit is contained in:
ZypherusKhan authored and GitHub committed 2026-04-07 18:56:31 +02:00
1 parent ace39ee51c
commit 4a45ac7841
2 files changed
+123 -90

No files matched your search

+114 -79
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using EHR.Modules;
using EHR.Patches;
using UnityEngine;
@@ -23,14 +22,19 @@ public class ClientControlGUI : MonoBehaviour
private Vector2 _dragOffset; // cursor offset from window corner when drag started
private bool _windowInitialized;
// Scale helpers - everything is relative to a 1080px-wide reference screen.
// On PC the UI is scaled down to 60% but on Android we keep it at full size for better readability.
// Zoom slider state - tracks Camera.main.orthographicSize so the slider doesn't reset when the user zooms with the scroll wheel or pinch gesture
// Range matches Zoom.cs: 3.0 (default) to 18.0 (max out)
private float _zoomValue = 3.0f;
// Scale helpers - everything is relative to a 1080px-wide reference screen
// On PC the UI is scaled down to 60% but on Android we keep it at full size for better readability
private static float PS => OperatingSystem.IsAndroid() ? 1.0f : 0.5f; // PS: platform scale multiplier (Android full size, PC smaller)
private static float S => Screen.width / 1080f * PS; // S: global scale factor
private static int FS => Mathf.Max(12, Mathf.RoundToInt(21f * S)); // FS: font size
private static float BH => 66f * S; // BH: button height
private static float BW => 340f * S; // BW: button width
private static float P => 10f * S; // P: padding
private static int ChipFS => Mathf.Max(10, FS - 4); // Shortcut chip font size
// Scrollbar column width - kept intentionally small so it never causes horizontal overflow
private static float SBW => 22f * S;
@@ -39,48 +43,34 @@ public class ClientControlGUI : MonoBehaviour
private float _lastS = -1f;
private string _lastScene = "";
// GUIStyle holds font, colors, and the background texture for each widget type.
// GUIStyle holds font, colors, and the background texture for each widget type
private GUIStyle _sAction, _sHost, _sDanger, _sSection, _sToggle, _sWindow, _sTitleBar, _sDragHint;
// All textures created go here so we can destroy them properly on scene change
private readonly List<Texture2D> _textures = [];
private Action<Scene, LoadSceneMode> _sceneLoadedHandler;
// Credit: Xtracube for pointing out add_sceneLoaded workaround.
// Subscribes to scene load events so we can rebuild styles after transitions.
// Credit: Xtracube for pointing out the add_sceneLoaded workaround
private void Awake()
{
Instance = this;
_sceneLoadedHandler = OnSceneLoaded;
SceneManager.add_sceneLoaded(_sceneLoadedHandler);
SceneManager.add_sceneLoaded((Action<Scene, LoadSceneMode>)OnSceneLoaded);
Logger.Info("ClientControlGUI initialised", "ClientControlGUI");
}
// Unsubscribes the scene load handler and cleans up textures when the component is removed.
private void OnDestroy()
{
if (_sceneLoadedHandler != null)
SceneManager.remove_sceneLoaded(_sceneLoadedHandler);
DestroyTextures();
SceneManager.remove_sceneLoaded((Action<Scene, LoadSceneMode>)OnSceneLoaded);
}
// Called by Unity whenever a scene loads (e.g. lobby -> game).
// Scene transitions destroy GPU-side texture data, so we reset the scale tracker to force a full style rebuild on the next OnGUI call.
// Called by Unity whenever a scene loads (e.g. lobby -> game)
// Resetting _lastS forces a full style rebuild on the next OnGUI call
// Textures use HideFlags.HideAndDontSave so Unity manages their lifetime automatically
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
_lastS = -1f;
DestroyTextures();
NullStyles();
}
// Destroys all tracked textures and nulls out every GUIStyle.
// Nulling the styles causes StylesValid() to return false, which triggers RebuildStyles() on the next OnGUI call.
private void DestroyTextures()
// Nulls out every GUIStyle so StylesValid() returns false and RebuildStyles() is triggered next frame
private void NullStyles()
{
foreach (var t in _textures)
if (t) Destroy(t);
_textures.Clear();
_sAction = _sHost = _sDanger = _sSection = _sToggle = _sWindow = _sTitleBar = _sDragHint = null;
}
@@ -94,11 +84,11 @@ public class ClientControlGUI : MonoBehaviour
_windowInitialized = true;
}
// Creates all GUIStyles. Called on first draw and after every scene change.
// Creates all GUIStyles. Called on first draw and after every scene change
private void RebuildStyles()
{
_lastS = S;
DestroyTextures();
NullStyles();
int toggleSize = Mathf.Max(1, Mathf.RoundToInt(48f * S));
int toggleRadius = toggleSize / 4; // gentler radius for the toggle square
@@ -170,9 +160,7 @@ public class ClientControlGUI : MonoBehaviour
);
}
// Builds a button GUIStyle with three states: normal, hovered, pressed.
// The background texture is a rounded rectangle drawn pixel-by-pixel.
// Uses a higher-res texture and larger radius for visibly smooth corners.
// Creates a button GUIStyle with normal, hover, and pressed states using a high-res, pixel-drawn rounded rectangle for smooth corners.
private GUIStyle MakeBtn(Color normal, Color hover, Color active)
{
int w = Mathf.Max(1, Mathf.RoundToInt(BW));
@@ -185,6 +173,7 @@ public class ClientControlGUI : MonoBehaviour
fontStyle = FontStyle.Bold,
alignment = TextAnchor.MiddleCenter,
wordWrap = true,
richText = true,
normal = { background = T(w, h, r, normal, Lift(normal, 0.10f)), textColor = Color.white },
hover = { background = T(w, h, r, hover, Lift(hover, 0.10f)), textColor = Color.white },
active = { background = T(w, h, r, active, Lift(active, 0.06f)), textColor = Color.white }
@@ -195,16 +184,15 @@ public class ClientControlGUI : MonoBehaviour
private static Color Lift(Color c, float v) =>
new(Mathf.Clamp01(c.r + v), Mathf.Clamp01(c.g + v), Mathf.Clamp01(c.b + v), 1f);
// Creates a rounded-rect texture and registers it for cleanup
private Texture2D T(int w, int h, int r, Color fill, Color edge)
// Creates a rounded-rect texture with HideAndDontSave so Unity handles cleanup automatically
private static Texture2D T(int w, int h, int r, Color fill, Color edge)
{
var tex = RoundedTex(w, h, r, fill, edge);
_textures.Add(tex);
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
// Draws a texture where corners are rounded using per-pixel distance checks.
// 'fill' is the interior color, 'edge' is used for a subtle 1px anti-aliased rim.
// Draws a rounded-corner texture with per-pixel distance checks, using 'fill' for interior and 'edge' for a subtle 1px anti-aliased rim
private static Texture2D RoundedTex(int w, int h, int radius, Color fill, Color edge)
{
w = Mathf.Max(1, w);
@@ -230,8 +218,7 @@ public class ClientControlGUI : MonoBehaviour
return tex;
}
// Returns 0 (transparent) if the pixel is outside a corner arc, 1 if inside, or a
// fractional value for the single-pixel anti-aliased transition on the edge.
// Returns 0 if outside (transparent), 1 if inside, or a small blend value for the smooth edge
private static float CornerAlpha(int px, int py, int w, int h, int r)
{
// Find which corner circle center this pixel belongs to, if any
@@ -248,8 +235,8 @@ public class ClientControlGUI : MonoBehaviour
return r + 0.5f - d; // on the edge - fractional for smoothing
}
// Returns false if any style or its background texture is missing.
// This happens after a scene load destroys the textures.
// Returns false if any style or its background texture is missing
// This happens after a scene load, triggering RebuildStyles() on the next OnGUI call
private bool StylesValid() =>
_sAction != null
&& _sAction.normal.background
@@ -262,13 +249,13 @@ public class ClientControlGUI : MonoBehaviour
&& _sWindow != null
&& _sWindow.normal.background;
// OnGUI is called by Unity every frame (and sometimes multiple times per frame).
// OnGUI is called by Unity every frame (and sometimes multiple times per frame)
private void OnGUI()
{
if (!HudManager.InstanceExists) return;
if (!_windowInitialized) InitWindowRect();
// Rebuild styles if the screen scale changed or a scene transition wiped the textures
// Rebuild styles if the screen scale changed or a scene transition wiped the styles
string scene = SceneManager.GetActiveScene().name;
if (Math.Abs(_lastS - S) > 0.01f || scene != _lastScene || !StylesValid())
{
@@ -281,8 +268,7 @@ public class ClientControlGUI : MonoBehaviour
if (IsOpen) DrawWindow();
}
// Draws the small square button that opens/closes the panel.
// Fades to 10% opacity during gameplay
// Draws the small toggle button for the panel; fades to 10% opacity during gameplay
private void DrawToggle()
{
float size = 48f * S;
@@ -296,7 +282,7 @@ public class ClientControlGUI : MonoBehaviour
}
else
{
// Bottom-left center: horizontally centred on the left quarter of the screen,
// Bottom-left center: horizontally centred on the left quarter of the screen
x = Screen.width * 0.3f - size * 0.5f;
y = Screen.height - size - 10f * S;
}
@@ -312,8 +298,7 @@ public class ClientControlGUI : MonoBehaviour
if (fadeOut) GUI.color = prev;
}
// Handles dragging the window by its title bar area.
// ImGUI doesn't have built-in window dragging, so we do it manually with mouse events.
// Handles dragging the window by the title bar using mouse or touch input
private void HandleDrag()
{
if (!IsOpen) return;
@@ -372,11 +357,9 @@ public class ClientControlGUI : MonoBehaviour
float scrollH = _windowRect.height - titleH - P;
float visibleW = _windowRect.width - P * 2f;
// outerRect: the visible scroll area on screen
// innerRect: the full content area (taller than outerRect when there are enough buttons)
// Content width is set to outerRect.width - SBW - 1
// Keeping it strictly less than outerRect.width guarantees Unity never calculates horizontal overflow, which is what
// causes the horizontal scrollbar to appear regardless of the alwaysShowHorizontal flag.
// outerRect: the visible scroll area on screen
// innerRect: the full content area (taller than outerRect when there are enough buttons)
// Content width is set slightly less than outerRect.width to prevent horizontal overflow and hide the horizontal scrollbar
float contentW = visibleW - SBW - 1f;
var outerRect = new Rect(_windowRect.x + P, scrollY, visibleW, scrollH);
var innerRect = new Rect(0, 0, contentW, _contentH);
@@ -384,8 +367,7 @@ public class ClientControlGUI : MonoBehaviour
GUI.skin.verticalScrollbar.fixedWidth = SBW;
GUI.skin.verticalScrollbarThumb.fixedWidth = SBW;
// Both horizontal flags false, so no horizontal scrollbar.
// Vertical scrollbar appears automatically when _contentH > scrollH.
// Horizontal scroll is disabled; vertical scroll appears when _contentH > scrollH
_scroll = GUI.BeginScrollView(outerRect, _scroll, innerRect, false, false);
float y = P * 0.5f;
DrawButtons(ref y, contentW);
@@ -393,9 +375,14 @@ public class ClientControlGUI : MonoBehaviour
GUI.EndScrollView();
}
// Draws all buttons. Only shows buttons relevant to the current game state.
// 'y' is passed by ref so each button advances the cursor downward automatically.
// 'w' is the available width, passed in from DrawWindow to match innerRect exactly.
// Formats a button label with an optional shortcut chip below the text; hidden on Android (no keyboard); uses IMGUI rich text (richText = true)
private string L(string label, string shortcut = null)
{
if (OperatingSystem.IsAndroid() || shortcut == null) return label;
return $"{label}\n<color=#7a9cbf><size={ChipFS}>{shortcut}</size></color>";
}
// Draws buttons based on game state; 'y' moves down per button; 'w' is the available width from DrawWindow
private void DrawButtons(ref float y, float w)
{
bool amHost = AmongUsClient.Instance && AmongUsClient.Instance.AmHost;
@@ -410,37 +397,37 @@ public class ClientControlGUI : MonoBehaviour
Section(ref y, "General");
Btn(ref y, "Dump Log", _sAction, () =>
Btn(ref y, L("Dump Log", "CTRL + F1"), _sAction, () =>
{
Logger.Info("Log dumped", "ClientControlGUI");
Utils.DumpLog();
});
Btn(ref y, "Reload Translations", _sAction, () =>
Btn(ref y, L("Reload Translations", "F5 + T"), _sAction, () =>
{
Logger.Info("Reloading Custom Translation File", "ClientControlGUI");
LoadLangs();
Logger.SendInGame("Reloaded Custom Translation File");
});
Btn(ref y, "Export Translations", _sAction, () =>
Btn(ref y, L("Export Translations", "F5 + X"), _sAction, () =>
{
Logger.Info("Exported Custom Translation File", "ClientControlGUI");
ExportCustomTranslation();
Logger.SendInGame("Exported Custom Translation File");
});
if (!notJoined)
Btn(ref y, "Copy Settings", _sAction, Utils.CopyCurrentSettings);
Btn(ref y, L("Copy Settings", "ALT + C"), _sAction, Utils.CopyCurrentSettings);
Btn(ref y, "Fix Button Positions", _sAction, () =>
Btn(ref y, L("Fix Button Positions", "ALT + ENTER"), _sAction, () =>
LateTask.New(SetResolutionManager.Postfix, 0.01f, "Fix Button Position")
);
if (inGame || inMeeting)
Btn(ref y, "Fix Blackscreen", _sAction, () =>
Btn(ref y, L("Fix Blackscreen", "SHIFT + CTRL + X"), _sAction, () =>
ExileController.Instance?.ReEnableGameplay()
);
if (inGame && (canMove || inMeeting))
Btn(ref y, InGameRoleInfoMenu.Showing ? "Hide Role Info" : "Show Role Info", _sAction, () =>
Btn(ref y, L(InGameRoleInfoMenu.Showing ? "Hide Role Info" : "Show Role Info", "F1 (hold)"), _sAction, () =>
{
if (InGameRoleInfoMenu.Showing)
InGameRoleInfoMenu.Hide();
@@ -451,12 +438,51 @@ public class ClientControlGUI : MonoBehaviour
}
});
if (inLobby || (inGame && !inMeeting && canMove && (!localAlive || GameStates.IsFreePlay || DebugModeManager.AmDebugger)))
{
Section(ref y, "Camera");
// Sync slider to actual camera value so external changes (scroll wheel, touch pinch) are reflected
var cam = Camera.main;
if (cam) _zoomValue = cam.orthographicSize;
float newZoom = Slider(ref y, $"Zoom {_zoomValue:F1}x", _zoomValue, 3.0f, 18.0f, w);
if (Mathf.Abs(newZoom - _zoomValue) > 0.01f)
{
_zoomValue = newZoom;
Zoom.SetZoomSize(reset: false);
if (cam) cam.orthographicSize = _zoomValue;
if (HudManager.InstanceExists) HudManager.Instance.UICamera.orthographicSize = _zoomValue;
}
if (GUI.Button(new Rect(0, y, w, BH), "Reset Zoom", _sAction))
{
Zoom.SetZoomSize(reset: true);
_zoomValue = 3.0f;
}
y += BH + P * 0.7f;
bool canNoclip = PlayerControl.LocalPlayer
&& PlayerControl.LocalPlayer.CanMove
&& (!AmongUsClient.Instance.IsGameStarted || !GameStates.IsOnlineGame);
if (canNoclip)
{
// Reads live state every frame for correct label/colour; lambda also reads it on click to avoid stale values
bool noclipOn = ControllerManagerUpdatePatch.NoClipEnabled;
Btn(ref y, noclipOn ? "No-clip: ON" : "No-clip: OFF", noclipOn ? _sHost : _sAction, () =>
{
ControllerManagerUpdatePatch.NoClipEnabled = !ControllerManagerUpdatePatch.NoClipEnabled;
});
}
}
if (inLobby)
{
Section(ref y, "Lobby");
if (amHost && !countdown)
Btn(ref y, "Start Game", _sHost, () =>
Btn(ref y, L("Start Game", "ENTER"), _sHost, () =>
{
if (GameStartManager.InstanceExists)
{
@@ -467,12 +493,12 @@ public class ClientControlGUI : MonoBehaviour
if (amHost && countdown)
{
Btn(ref y, "Start Immediately", _sHost, () =>
Btn(ref y, L("Start Immediately", "SHIFT"), _sHost, () =>
{
Logger.Info("Starting game immediately via ClientControlGUI", "ClientControlGUI");
GameStartManager.Instance.countDownTimer = 0;
});
Btn(ref y, "Cancel Countdown", _sDanger, () =>
Btn(ref y, L("Cancel Countdown", "C"), _sDanger, () =>
{
GameStartManager.Instance.ResetStartState();
Logger.SendInGame(GetString("CancelStartCountDown"));
@@ -481,17 +507,17 @@ public class ClientControlGUI : MonoBehaviour
if (amHost)
{
Btn(ref y, "Show Active Settings", _sHost, () =>
Btn(ref y, L("Show Active Settings", "CTRL + N"), _sHost, () =>
{
Main.IsChatCommand = true;
Utils.ShowActiveSettings();
});
Btn(ref y, "Show Settings Help", _sHost, () =>
Btn(ref y, L("Show Settings Help", "CTRL + SHIFT + N"), _sHost, () =>
{
Main.IsChatCommand = true;
Utils.ShowActiveSettingsHelp();
});
Btn(ref y, "Reset All Options", _sDanger, () =>
Btn(ref y, L("Reset All Options", "CTRL + SHIFT + DEL"), _sDanger, () =>
Prompt.Show(GetString("Promt.ResetAllOptions"), () =>
{
foreach (var opt in OptionItem.AllOptions)
@@ -508,7 +534,7 @@ public class ClientControlGUI : MonoBehaviour
Section(ref y, "In Game");
if (amHost && localAlive)
Btn(ref y, "Kill Self", _sDanger, () =>
Btn(ref y, L("Kill Self", "SHIFT + ENTER + E"), _sDanger, () =>
{
var state = Main.PlayerStates[PlayerControl.LocalPlayer.PlayerId];
state.deathReason = PlayerState.DeathReason.etc;
@@ -527,23 +553,23 @@ public class ClientControlGUI : MonoBehaviour
Section(ref y, "Host Controls");
if (!inMeeting)
Btn(ref y, "Call Meeting", _sHost, () =>
Btn(ref y, L("Call Meeting", "SHIFT + ENTER + M"), _sHost, () =>
PlayerControl.LocalPlayer.NoCheckStartMeeting(null, true)
);
else
Btn(ref y, "End Meeting", _sHost, () =>
Btn(ref y, L("End Meeting", "SHIFT + ENTER + M"), _sHost, () =>
{
MeetingHudRpcClosePatch.AllowClose = true;
MeetingHud.Instance.RpcClose();
});
Btn(ref y, "Open Your Chat", _sHost, () =>
Btn(ref y, L("Open Your Chat", "SHIFT + ENTER + C"), _sHost, () =>
HudManager.Instance.Chat.SetVisible(true)
);
Btn(ref y, "Open Chat for All", _sHost, Utils.SetChatVisibleForAll);
Btn(ref y, L("Open Chat for All", "CTRL + SHIFT + ENTER + C"), _sHost, Utils.SetChatVisibleForAll);
if (noGameEnd)
Btn(ref y, "Force Game End", _sDanger, () =>
Btn(ref y, L("Force Game End", "SHIFT + ENTER + L"), _sDanger, () =>
{
CustomWinnerHolder.ResetAndSetWinner(CustomWinner.Draw);
GameEndChecker.CheckCustomEndCriteria();
@@ -561,7 +587,7 @@ public class ClientControlGUI : MonoBehaviour
sy += BH * 0.52f + P * 0.4f;
}
// Draws a button and advances the cursor. try/catch prevents one bad action from crashing the GUI.
// Draws a button and moves the cursor; try/catch stops one bad action from crashing the GUI
void Btn(ref float by, string label, GUIStyle style, Action action)
{
if (GUI.Button(new Rect(0, by, w, BH), label, style))
@@ -571,6 +597,15 @@ public class ClientControlGUI : MonoBehaviour
}
by += BH + P * 0.7f;
}
}
// Draws a labeled horizontal slider and returns the new value
float Slider(ref float sy, string label, float value, float min, float max, float sw)
{
GUI.Label(new Rect(0, sy, sw, BH * 0.45f), label, _sSection);
sy += BH * 0.48f;
float result = GUI.HorizontalSlider(new Rect(0, sy, sw, BH * 0.52f), value, min, max);
sy += BH * 0.52f + P * 0.7f;
return result;
}
}
}
+9 -11
View File
@@ -15,6 +15,7 @@ namespace EHR;
[HarmonyPatch(typeof(ControllerManager), nameof(ControllerManager.Update))]
internal static class ControllerManagerUpdatePatch
{
public static bool NoClipEnabled;
private static readonly (int, int)[] Resolutions = [(480, 270), (640, 360), (800, 450), (1280, 720), (1600, 900), (1920, 1080)];
private static int ResolutionIndex;
@@ -29,24 +30,21 @@ internal static class ControllerManagerUpdatePatch
{
try
{
if (ClientControlGUI.Instance && (Input.GetKeyDown(KeyCode.Insert) || KeysDown(KeyCode.LeftControl, KeyCode.Tilde) || KeysDown(KeyCode.RightControl, KeyCode.Tilde)))
if (ClientControlGUI.Instance &&
(Input.GetKeyDown(KeyCode.Delete) ||
KeysDown(KeyCode.LeftControl, KeyCode.BackQuote) ||
KeysDown(KeyCode.RightControl, KeyCode.BackQuote)))
ClientControlGUI.Instance.IsOpen = !ClientControlGUI.Instance.IsOpen;
if (HudManager.InstanceExists)
{
if (PlayerControl.LocalPlayer)
{
if (Input.GetKeyDown(KeyCode.LeftControl))
{
if ((!AmongUsClient.Instance.IsGameStarted || !GameStates.IsOnlineGame) && PlayerControl.LocalPlayer.CanMove)
PlayerControl.LocalPlayer.Collider.offset = new(0f, 127f);
}
bool shouldNoclip =
NoClipEnabled || (Input.GetKey(KeyCode.LeftControl) &&
(!AmongUsClient.Instance.IsGameStarted || !GameStates.IsOnlineGame) && PlayerControl.LocalPlayer.CanMove);
if (Math.Abs(PlayerControl.LocalPlayer.Collider.offset.y - 127f) < 0.1f)
{
if (!Input.GetKey(KeyCode.LeftControl) || (AmongUsClient.Instance.IsGameStarted && GameStates.IsOnlineGame))
PlayerControl.LocalPlayer.Collider.offset = new(0f, -0.3636f);
}
PlayerControl.LocalPlayer.Collider.offset = shouldNoclip ? new Vector2(0f, 127f) : new Vector2(0f, -0.3636f);
}
if (GameStates.IsLobby && (!HudManager.Instance.Chat || !HudManager.Instance.Chat.IsOpenOrOpening))