using UnityEngine; using GeoSus.Client; using Subsystems; using System.Collections; using System; using TMPro; using UnityEngine.SceneManagement; public class GameManager : MonoBehaviour { // Singleton public static GameManager Instance { get; private set; } [Header("Subsystems")] public GameManager_Network networkSubsystem; public GameManager_UI uiSubsystem; public GameManager_Map mapSubsystem; public GameManager_Input inputSubsystem; public GameManager_Tasks taskSubsystem; public GameClient gameClient; [Header("Player Info")] public string displayName; [Header("Scene Management")] [SerializeField] public string firstMenuScene = "main menu asi idk lol"; [Header("UI Elements (Client.unity)")] // Canvas names in Client.unity — found at runtime in OnSceneLoaded private const string CanvasNameJoinCreate = "LobbySelector"; private const string CanvasNameInLobby = "InLobby"; private const string CanvasNameLoading = "LoadingScreen"; private const string CanvasNameGame = "InGame"; [Header("Map")] // MapCenterPoint and Player are in Client.unity — wired at runtime in OnSceneLoaded. // buildingSettings/pathwaySettings/areaSettings must be assigned in SampleScene Inspector. public BuildingSettings buildingSettings; public PathwaySettings pathwaySettings; public AreaSettings areaSettings; [Header("Lobby Settings")] public double pendingRadius = 500; public int pendingImpostorCount = 1; public int pendingTaskCount = 5; /// /// P13b/c: full settings overrides accumulated by HostLobbyUI before the /// host taps "Create". Null = host didn't change anything beyond the three /// flat fields above; server falls through to its current defaults for /// every field. Each field is independently nullable so the host can /// opt into changing only what they care about. /// public GameSettingsOverrides pendingSettings; [Header("Task Minigames (round-robin)")] // Names MUST match the scene file names in Assets/Scenes (case-sensitive) // and each one MUST be enabled in EditorBuildSettings, or LoadSceneAsync // will silently fail and the task button will appear dead. [SerializeField] public string[] minigameScenes = { "MiniGame-Kabely V10", "MiniGame-insertkeys", "MiniGame-FlappyBird", "MiniGame-ThrowInHole", "MiniGame-Satelit" }; [Header("Debug")] public bool testMode = false; /// /// When true, draw a small GPS status banner across the top of every /// screen. Useful for diagnosing why CreateLobby is blocked or why a /// joiner's position isn't updating - failures otherwise only show up /// in logcat which most users can't reach. Toggle off for release. /// public bool showGPSDebugOverlay = true; /// /// Number of in-process test client bots to spawn alongside the host /// when testMode is on. Each gets its own GameClient + Network and /// joins the host's lobby automatically. Bots are switchable via /// number keys 1..N (host = 0). Default 3 keeps memory reasonable; /// bump for stress-testing voting / sabotage flows. /// public int testClientCount = 3; /// /// Per-bot network + display-name + sim-position state. The active slot /// (host = 0, bots = 1..N) gets WASD on the next tick. /// private class TestBot { public GameClient Client; public GameManager_Network Network; public string DisplayName; public GeoSus.Client.Position SimPosition; public bool Joined; public float LastSendTime; } private System.Collections.Generic.List _testBots = new System.Collections.Generic.List(); /// Slot 0 = host (real player), 1..N = test bot index. private int _activeClientSlot = 0; void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); // Keep the screen on while the player is in the app. A geographic // social-deduction game asks the user to walk around for 5-15 minutes // staring at the map; default Android sleep timeout (15-60s) blacks // the screen out mid-round, drops GPS updates, and requires the // player to re-unlock the phone. Two layers of belt-and-suspenders: // (1) Unity's Screen.sleepTimeout, which works on most devices and // is one line, but is overridden by some MIUI/EMUI ROMs. // (2) Android FLAG_KEEP_SCREEN_ON on the activity window, harder for // OEM ROMs to override and the standard pattern for navigation/maps // apps. Wrapped in #if UNITY_ANDROID so editor/iOS skip it. Screen.sleepTimeout = SleepTimeout.NeverSleep; AcquireAndroidWakelock(); } /// /// Set FLAG_KEEP_SCREEN_ON on the Unity activity's window. This is the /// standard navigation/maps-app pattern and survives ROM-level overrides /// of Unity's Screen.sleepTimeout. No-op on non-Android platforms. /// private static void AcquireAndroidWakelock() { #if UNITY_ANDROID && !UNITY_EDITOR try { using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = player.GetStatic("currentActivity")) { // addFlags must run on the UI thread. Capture activity into a // local for the closure - AndroidJavaObject can be reused. var act = activity; act.Call("runOnUiThread", new AndroidJavaRunnable(() => { try { using (var window = act.Call("getWindow")) { // WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON const int FLAG_KEEP_SCREEN_ON = 0x00000080; window.Call("addFlags", FLAG_KEEP_SCREEN_ON); } } catch (System.Exception ex) { Debug.LogWarning("[Wakelock] addFlags failed: " + ex.Message); } })); } } catch (System.Exception ex) { Debug.LogWarning("[Wakelock] Android JNI bridge failed: " + ex.Message); } #endif } void Start() { // The prefab default in SampleScene.unity is "Hrac" (Czech for // "player"). Treat it as equivalent to "no name set" so users who // never customize their name don't all show up identically. This // override only fires at startup; users who explicitly type "Hrac" // into the nickname field will still send "Hrac" via the live // DisplayName payload field. if (string.IsNullOrEmpty(displayName) || displayName == "Hrac") displayName = PlayerPrefs.GetString("PlayerName", GenerateUsername()); gameClient = new GameClient(GenerateUUID(), displayName); networkSubsystem = new GameManager_Network(gameClient, this); mapSubsystem = new GameManager_Map(gameClient, null, buildingSettings, pathwaySettings, areaSettings); uiSubsystem = new GameManager_UI(gameClient); inputSubsystem = new GameManager_Input(gameClient, null, testMode); taskSubsystem = new GameManager_Tasks(gameClient, minigameScenes, this); if (testMode) { int n = Mathf.Max(0, testClientCount); for (int i = 0; i < n; i++) { var bot = new TestBot { DisplayName = "TestBot" + (i + 1), }; bot.Client = new GameClient(GenerateUUID(), bot.DisplayName); bot.Network = new GameManager_Network(bot.Client, null); bot.Network.OpenConnection(); _testBots.Add(bot); } } networkSubsystem.OpenConnection(); // Start GPS immediately at app launch. Acquiring a fix on a cold // device can take 5-30 seconds; if we wait until CreateLobby is // pressed, the lobby will be seeded with bad coords. Starting here // means the user's normal navigation through the menus gives the // GPS subsystem time to settle. inputSubsystem?.EnsureGPSStarted(); // Load main menu after GameManager is ready if (!string.IsNullOrEmpty(firstMenuScene)) SceneManager.LoadScene(firstMenuScene, LoadSceneMode.Single); } /// /// Draws a GPS status banner across the top of every screen. We use OnGUI /// rather than a uGUI Canvas element because OnGUI works without any /// scene wiring - we want this visible from the very first frame, on /// every screen, even if the lobby canvas hasn't been bound yet. This is /// a debug overlay; toggle showGPSDebugOverlay off for release builds. /// private void OnGUI() { if (!showGPSDebugOverlay) return; if (inputSubsystem == null) return; var diag = inputSubsystem.GpsDiagnostic; var label = "GPS: " + diag; // Scale font size to screen so it's legible on phones (HDPI) and // editor (lower DPI) alike. Phones tend to have ~400dpi; the // editor game view runs at ~100dpi. int fontSize = Mathf.Max(14, Screen.width / 50); var style = new GUIStyle(GUI.skin.label) { fontSize = fontSize, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false, normal = { textColor = Color.white } }; // Width covers most of the screen so longer error strings don't get // clipped. Height auto-fits the chosen font size. float pad = fontSize * 0.5f; float bannerH = fontSize * 2f; var rect = new Rect(pad, pad, Screen.width - pad * 2, bannerH); // Translucent black background for legibility against the map. var prevColor = GUI.color; GUI.color = new Color(0f, 0f, 0f, 0.65f); GUI.Box(rect, GUIContent.none); GUI.color = prevColor; // Indent the label inside the box. var textRect = new Rect(rect.x + pad, rect.y, rect.width - pad * 2, rect.height); GUI.Label(textRect, label, style); // Second row: position-source picker (tap to cycle) + active client // indicator (testMode only). Both are diagnostic; the source picker // is the recovery path when one backend silently fails on a phone. float row2Y = rect.y + bannerH + pad * 0.5f; var btnStyle = new GUIStyle(GUI.skin.button) { fontSize = Mathf.Max(12, fontSize - 2), fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, }; // Source button: shows current source name + invites tap. string sourceLabel = "Source: " + inputSubsystem.CurrentSourceName + " [tap to cycle]"; // Width sized to the text so the touch area matches the label. Vector2 sourceSize = btnStyle.CalcSize(new GUIContent(sourceLabel)); float sourceW = Mathf.Min(Screen.width - pad * 2, sourceSize.x + pad * 2); var sourceRect = new Rect(pad, row2Y, sourceW, bannerH); if (GUI.Button(sourceRect, sourceLabel, btnStyle)) { inputSubsystem.CycleNextPositionSource(); } // Active-client indicator (only when we have test bots). if (testMode && _testBots.Count > 0) { string slot = _activeClientSlot == 0 ? "Host" : ("Bot " + _activeClientSlot); string indicator = $"WASD: {slot} (0..{_testBots.Count} to switch)"; var indStyle = new GUIStyle(GUI.skin.label) { fontSize = Mathf.Max(12, fontSize - 2), fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, normal = { textColor = new Color(0.9f, 1f, 0.4f) }, }; Vector2 indSize = indStyle.CalcSize(new GUIContent(indicator)); var indRect = new Rect(sourceRect.xMax + pad, row2Y, indSize.x + pad * 2, bannerH); GUI.color = new Color(0f, 0f, 0f, 0.65f); GUI.Box(indRect, GUIContent.none); GUI.color = prevColor; GUI.Label(new Rect(indRect.x + pad, indRect.y, indRect.width, indRect.height), indicator, indStyle); } } private void Update() { // Tick the SDK dispatcher so callbacks fire on main thread gameClient?.Update(); if (testMode) { for (int i = 0; i < _testBots.Count; i++) _testBots[i].Client?.Update(); HandleTestBotInput(); } if (gameClient?.CurrentLobbyState != null) { uiSubsystem?.UpdateLobbyUI(); taskSubsystem?.UpdateProximity(); } if (gameClient?.MyRole == PlayerRole.Impostor) UpdateKillCooldown(); inputSubsystem?.positionCheck(); if (testMode) StepActiveTestBot(); } /// /// Number-key handling for slot switching. 0 = host, 1..N = test bot N. /// Suppress host WASD when a non-host bot is active so the host capsule /// doesn't drift while the user is moving a bot. Only fires when /// testMode is on; release builds never see this path. /// private void HandleTestBotInput() { // 0 = host. 1..9 = bots (capped by Unity KeyCode.Alpha9). if (Input.GetKeyDown(KeyCode.Alpha0)) _activeClientSlot = 0; for (int i = 1; i <= 9 && i <= _testBots.Count; i++) { if (Input.GetKeyDown(KeyCode.Alpha0 + i)) _activeClientSlot = i; } // Tell the host's input subsystem to ignore WASD when a bot is active. if (inputSubsystem != null) inputSubsystem.SuppressWasd = (_activeClientSlot != 0); } /// /// If the active slot is a bot, step its sim position from WASD axes /// and send to the server. Idle bots get a periodic keep-alive so their /// avatars don't time out. /// private void StepActiveTestBot() { if (_testBots.Count == 0) return; var state = gameClient?.CurrentLobbyState; if (state == null || state.MapData == null) return; // Lazy-init each bot's sim position to the lobby's map center on // first lobby state. Until the bot has joined a lobby it can't // send position updates. for (int i = 0; i < _testBots.Count; i++) { var bot = _testBots[i]; if (!bot.Joined) continue; if (bot.SimPosition.Lat == 0 && bot.SimPosition.Lon == 0) { // Spawn each bot in a small ring around the map center so // they don't all stack on top of each other on frame one. double offsetLat = 0.00003 * Mathf.Cos(i * Mathf.PI * 2f / Mathf.Max(1, _testBots.Count)); double offsetLon = 0.00003 * Mathf.Sin(i * Mathf.PI * 2f / Mathf.Max(1, _testBots.Count)); bot.SimPosition = new GeoSus.Client.Position( state.MapData.Center.Lat + offsetLat, state.MapData.Center.Lon + offsetLon); bot.Client.UpdatePosition(bot.SimPosition); bot.LastSendTime = Time.time; } } // WASD only drives the active bot. if (_activeClientSlot >= 1 && _activeClientSlot <= _testBots.Count) { var bot = _testBots[_activeClientSlot - 1]; if (bot.Joined) { float dx = Input.GetAxis("Horizontal"); float dy = Input.GetAxis("Vertical"); const double speed = 0.00001; bool moved = Mathf.Abs(dx) > 0.001f || Mathf.Abs(dy) > 0.001f; if (moved) { bot.SimPosition = new GeoSus.Client.Position( bot.SimPosition.Lat + dy * speed, bot.SimPosition.Lon + dx * speed); } // Send on movement OR on keep-alive cadence so the server // doesn't drop our presence. bool dueKeepAlive = (Time.time - bot.LastSendTime) >= 1.0f; if (moved || dueKeepAlive) { bot.Client.UpdatePosition(bot.SimPosition); bot.LastSendTime = Time.time; } } } else { // No bot is active. All bots get keep-alive only. for (int i = 0; i < _testBots.Count; i++) { var bot = _testBots[i]; if (!bot.Joined) continue; if ((Time.time - bot.LastSendTime) >= 1.0f) { bot.Client.UpdatePosition(bot.SimPosition); bot.LastSendTime = Time.time; } } } } void OnEnable() { SceneManager.sceneLoaded += OnSceneLoaded; } void OnDisable() { SceneManager.sceneLoaded -= OnSceneLoaded; } /// /// After Client.unity loads, re-bind all canvas/HUD references because /// those GameObjects don't exist in the Art menu scenes. /// private void OnSceneLoaded(Scene scene, LoadSceneMode mode) { if (scene.name == "Client") { var roots = scene.GetRootGameObjects(); // Find a root or deep GameObject by name in the loaded scene GameObject FindGO(string n) { foreach (var go in roots) { if (go.name == n) return go; var found = go.transform.Find(n); if (found != null) return found.gameObject; } return null; } Canvas FindCanvas(string n) { var go = FindGO(n); return go != null ? go.GetComponent() : null; } // ── Build HUD BEFORE BindClientScene so FindTMP/Find can locate new elements ── var inGameGO = FindGO("InGame"); if (inGameGO != null) { var builder = inGameGO.GetComponent() ?? inGameGO.AddComponent(); builder.BuildNow(); } // ── Wire canvases (after HUD is built) ── // Apply our standard CanvasScaler (1080x1920 reference, match=0.5) // to every canvas in the scene before binding so layouts scale // identically across phones and tablets without per-device tweaks. var cJoin = FindCanvas(CanvasNameJoinCreate); var cLobby = FindCanvas(CanvasNameInLobby); var cLoad = FindCanvas(CanvasNameLoading); var cGame = FindCanvas(CanvasNameGame); InGameHUDBuilder.ConfigureCanvasScaler(cJoin); InGameHUDBuilder.ConfigureCanvasScaler(cLobby); InGameHUDBuilder.ConfigureCanvasScaler(cLoad); InGameHUDBuilder.ConfigureCanvasScaler(cGame); uiSubsystem?.BindClientScene(cJoin, cLobby, cLoad, cGame); // ── Wire map center point and player capsule ── var mapCenter = FindGO("MapCenterPoint"); var player = FindGO("Capsule"); mapSubsystem?.SetMapCenterPoint(mapCenter); inputSubsystem?.SetPlayerObject(player); // ── Attach camera controller to Main Camera ── var mainCamGO = FindGO("Main Camera"); if (mainCamGO != null) { var camCtrl = mainCamGO.GetComponent() ?? mainCamGO.AddComponent(); camCtrl.SetTarget(player); } // If MapDataReady arrived before Client scene finished loading, // this will build the map now that scene references are valid. networkSubsystem?.OnClientSceneReady(); } else if (scene.name == "create" || scene.name == "join loading") { // Lobby scene just loaded — ensure LobbyDisplayUI refreshes once // its Start() has run and registered itself (happens before Update). uiSubsystem?.NotifyLobbyChanged(); } } private float _killCooldownSeconds = 0f; private const float KillCooldownDuration = 20f; private void UpdateKillCooldown() { if (_killCooldownSeconds > 0) { _killCooldownSeconds -= Time.deltaTime; // Mirror into GameState so UI reads from the single source of truth if (networkSubsystem?.State != null) networkSubsystem.State.KillCooldownRemaining = _killCooldownSeconds; uiSubsystem?.SetKillCooldownText($"Kill: {Mathf.CeilToInt(_killCooldownSeconds)}s"); } else { _killCooldownSeconds = 0f; if (networkSubsystem?.State != null) networkSubsystem.State.KillCooldownRemaining = 0; uiSubsystem?.SetKillCooldownText(""); } } /// /// Called by the ActionButton. Routes to kill / report / emergency / use-task /// depending on current proximity state. /// public void PerformAction() { if (uiSubsystem == null || uiSubsystem.IsPlayerDead) return; bool isImpostor = gameClient?.MyRole == PlayerRole.Impostor; // P13b: pull per-lobby distances from the server-snapshotted settings // instead of hardcoding 5m for every check. ?? fallback keeps the // pre-P13b behavior on old server builds that don't ship settings. var settings = networkSubsystem?.State?.Settings; double reportDist = settings?.ReportDistanceM ?? 5.0; double emergencyDist = settings?.EmergencyMeetingCallRadiusM ?? 5.0; double killDist = settings?.KillDistanceM ?? 5.0; // 1. Nearby task → USE var nearbyTask = taskSubsystem?.NearbyTask; if (nearbyTask != null && !isImpostor) { taskSubsystem.TriggerNearbyTask(); return; } // 2. Nearby body → REPORT if (!uiSubsystem.IsCommsBlackout) { var nearbyBody = gameClient?.FindNearbyBody(reportDist); if (nearbyBody != null) { gameClient.ReportBody(nearbyBody.BodyId); return; } // 3. Near map centre → EMERGENCY if (gameClient?.CurrentLobbyState?.MapData != null) { double distToCenter = gameClient.MyPosition.DistanceTo(gameClient.CurrentLobbyState.MapData.Center); if (distToCenter <= emergencyDist) { gameClient.CallEmergencyMeeting(); return; } } } // 4. Impostor kill if (isImpostor && _killCooldownSeconds <= 0) { var targetUuid = gameClient?.FindNearbyPlayer(killDist); if (!string.IsNullOrEmpty(targetUuid)) { gameClient.Kill(targetUuid); _killCooldownSeconds = KillCooldownDuration; } } } /// Called by Impostor sabotage buttons. public void StartSabotage(int typeIndex) { gameClient?.Send(new GeoSus.Client.StartSabotage { SabotageType = (SabotageType)typeIndex }); } /// Called by the meeting vote buttons. Pass null to skip. public void CastVote(string targetUuid) { gameClient?.Vote(targetUuid); } protected string GenerateUUID() { return System.Guid.NewGuid().ToString(); } protected string GenerateUsername() { return "Player" + UnityEngine.Random.Range(1000, 9999).ToString(); } /// /// Pull the nickname input field's current text into displayName + /// gameClient.DisplayName + PlayerPrefs before sending a network /// action. Defensive against any TMP_InputField / soft-keyboard race /// where the user types and immediately taps a button: onValueChanged /// normally fires before the click handler in the same frame, but /// some Android keyboards batch text events oddly. Call this at the /// top of any Create/Join/Rename flow. No-op if the input field /// doesn't exist in the current scene. /// private void CommitNicknameFromInput() { var nameGO = GameObject.Find("name"); if (nameGO == null) return; var field = nameGO.GetComponent(); if (field == null) return; // Force the InputField to flush any pending soft-keyboard text. // ForceLabelUpdate() is harmless if there's nothing pending. field.ForceLabelUpdate(); string typed = (field.text ?? "").Trim(); if (string.IsNullOrEmpty(typed)) return; if (typed == displayName) return; // already in sync, skip the writes displayName = typed; if (gameClient != null) gameClient.DisplayName = typed; PlayerPrefs.SetString("PlayerName", typed); PlayerPrefs.Save(); } // Called by HostLobbyUI public void CreateLobbyButton() { CommitNicknameFromInput(); // Refuse to create a lobby without a real GPS fix. The previous // behavior of silently using a hardcoded Czechia fallback meant the // game always started at the same place no matter where the host was, // and the player capsule would spawn miles away in coordinate space // because they're at their real GPS while the map was built around // the fallback. Both bugs share this single gate. if (inputSubsystem?.LastKnownPosition == null) { // testMode bypasses the GPS gate entirely so debug runs still work. if (!testMode) { // Surface the actual GPS state in both logs and the toast // instead of the generic "Waiting for GPS fix..." that hides // permission/timeout/device-disabled distinctions. string diag = inputSubsystem?.GpsDiagnostic ?? "no input subsystem"; Debug.LogWarning("[GameManager] CreateLobby blocked. " + diag); uiSubsystem?.ShowToast("Cannot create lobby. " + diag); inputSubsystem?.EnsureGPSStarted(); return; } } var pos = inputSubsystem?.LastKnownPosition; double lat = pos?.Lat ?? 0; double lon = pos?.Lon ?? 0; networkSubsystem.CreateLobby(lat, lon, pendingRadius, pendingImpostorCount, pendingTaskCount, pendingSettings); if (testMode) StartCoroutine(ConnectTestClients()); } // Called by JoinLobbyUI with the code from the input field public void JoinLobbyButton(string code) { CommitNicknameFromInput(); if (!string.IsNullOrEmpty(code)) networkSubsystem.JoinLobby(code); else Debug.LogWarning("Join code is empty!"); } public void LeaveLobbyButton() { networkSubsystem.LeaveLobby(); } public void StartGameButton() { networkSubsystem.StartGame(); } void OnApplicationQuit() { gameClient?.Disconnect(); for (int i = 0; i < _testBots.Count; i++) _testBots[i].Client?.Disconnect(); } IEnumerator ConnectTestClients() { if (_testBots.Count == 0) yield break; // Wait until host lobby code exists float wait = 0f; while ((gameClient?.CurrentLobbyState == null || string.IsNullOrEmpty(gameClient.CurrentLobbyState.JoinCode)) && wait < 20f) { wait += 0.25f; yield return new WaitForSeconds(0.25f); } var joinCode = gameClient?.CurrentLobbyState?.JoinCode; if (string.IsNullOrEmpty(joinCode)) { Debug.LogWarning("[TestMode] Could not join test bots: join code not available."); yield break; } // Wait until every bot's client has finished its TCP handshake. // IsReady flips once ClientHello + ClientHelloAck round-trip. wait = 0f; bool allReady; do { allReady = true; for (int i = 0; i < _testBots.Count; i++) { if (_testBots[i].Client == null || !_testBots[i].Client.IsReady) { allReady = false; break; } } if (!allReady) { wait += 0.25f; yield return new WaitForSeconds(0.25f); } } while (!allReady && wait < 20f); if (!allReady) { Debug.LogWarning("[TestMode] Some test bots not ready, joining the ready ones only."); } for (int i = 0; i < _testBots.Count; i++) { var bot = _testBots[i]; if (bot.Client != null && bot.Client.IsReady) { bot.Network?.JoinLobby(joinCode); bot.Joined = true; } } Debug.Log($"[TestMode] {_testBots.Count} bot(s) joined lobby with code {joinCode}."); } }