From 207f997254c17cfbe50257049882eb84be8d0d05 Mon Sep 17 00:00:00 2001 From: Bandwidth Date: Mon, 27 Apr 2026 23:18:13 +0200 Subject: [PATCH] Added better GPS handling, Improved test mode, Fixed naming of players not being broadcast until a new HelloClient, Fixed lobby restarts, Fixed task handling --- .../arm64-v8a/configure_fingerprint.bin | 26 +- Assets/ClientSDK/GameClient.cs | 13 +- Assets/ClientSDK/Protocol.cs | 21 +- Assets/GameManager/GameManager.cs | 399 ++++++++++- Assets/GameManager/GameManager_Input.cs | 663 +++++++++++++++++- Assets/GameManager/GameManager_Map.cs | 47 +- Assets/GameManager/GameManager_Network.cs | 9 + Assets/GameManager/GameManager_Tasks.cs | 74 +- 8 files changed, 1175 insertions(+), 77 deletions(-) diff --git a/.utmp/RelWithDebInfo/6b10225s/arm64-v8a/configure_fingerprint.bin b/.utmp/RelWithDebInfo/6b10225s/arm64-v8a/configure_fingerprint.bin index fc5ff59..88df226 100644 --- a/.utmp/RelWithDebInfo/6b10225s/arm64-v8a/configure_fingerprint.bin +++ b/.utmp/RelWithDebInfo/6b10225s/arm64-v8a/configure_fingerprint.bin @@ -2,29 +2,29 @@ C/C++ Structured Logb ` ^E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\additional_project_files.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  Øÿ‚ØÜ3› ýêôÓÜ3_ +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  ËÀ°…Ý3› ýêôÓÜ3_ ] -[E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\android_gradle_build.json  Øÿ‚ØÜ3Þ …ëôÓÜ3d +[E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\android_gradle_build.json  ËÀ°…Ý3Þ …ëôÓÜ3d b -`E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\android_gradle_build_mini.json  Øÿ‚ØÜ3È ÃëôÓÜ3Q +`E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\android_gradle_build_mini.json  ËÀ°…Ý3È ÃëôÓÜ3Q O -ME:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build.ninja  Øÿ‚ØÜ3Ü¡ ÜéôÓÜ3U +ME:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build.ninja  ËÀ°…Ý3Ü¡ ÜéôÓÜ3U S -QE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build.ninja.txt  Øÿ‚ØÜ3Z +QE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build.ninja.txt  ËÀ°…Ý3Z X -VE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build_file_index.txt  Øÿ‚ØÜ3ß ËëôÓÜ3[ +VE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build_file_index.txt  ËÀ°…Ý3ß ËëôÓÜ3[ Y -WE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\compile_commands.json  Ùÿ‚ØÜ3£U ÚéôÓÜ3_ +WE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\compile_commands.json  ËÀ°…Ý3£U ÚéôÓÜ3_ ] -[E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\compile_commands.json.bin  Ùÿ‚ØÜ3 Ä ÚéôÓÜ3e +[E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\compile_commands.json.bin  ËÀ°…Ý3 Ä ÚéôÓÜ3e c -aE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\metadata_generation_command.txt  Ùÿ‚ØÜ3 +aE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\metadata_generation_command.txt  ËÀ°…Ý3 ß ÊëôÓÜ3X V -TE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\prefab_config.json  Ùÿ‚ØÜ3 ã ÊëôÓÜ3] +TE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\prefab_config.json  ËÀ°…Ý3 ã ÊëôÓÜ3] [ -YE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\symbol_folder_index.txt  Ùÿ‚ØÜ3 Š ËëôÓÜ3l +YE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\symbol_folder_index.txt  ËÀ°…Ý3 Š ËëôÓÜ3l j -hE:\Code\GeoSus\GeoSusGame\Library\Bee\Android\Prj\IL2CPP\Gradle\unityLibrary\src\main\cpp\CMakeLists.txt  Ùÿ‚ØÜ3  W û”ìÓÜ3y +hE:\Code\GeoSus\GeoSusGame\Library\Bee\Android\Prj\IL2CPP\Gradle\unityLibrary\src\main\cpp\CMakeLists.txt  ËÀ°…Ý3  W û”ìÓÜ3y w -uE:\Code\GeoSus\GeoSusGame\Library\Bee\Android\Prj\IL2CPP\Gradle\unityLibrary\src\main\cpp\GameActivity\CMakeLists.txt  Ùÿ‚ØÜ3û ó”ìÓÜ3 \ No newline at end of file +uE:\Code\GeoSus\GeoSusGame\Library\Bee\Android\Prj\IL2CPP\Gradle\unityLibrary\src\main\cpp\GameActivity\CMakeLists.txt  ËÀ°…Ý3û ó”ìÓÜ3 \ No newline at end of file diff --git a/Assets/ClientSDK/GameClient.cs b/Assets/ClientSDK/GameClient.cs index 00d9331..4f63cc6 100644 --- a/Assets/ClientSDK/GameClient.cs +++ b/Assets/ClientSDK/GameClient.cs @@ -533,6 +533,11 @@ public class GameClient : IDisposable public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500, GameSettingsOverrides? settings = null) { + // DisplayName is sent on every CreateLobby/JoinLobby so the server + // picks up the live nickname (typed into the input field after the + // ClientHello handshake fired). Without this the server uses the + // ClientHello-time name, which is the GameManager prefab default + // for any user who immediately created/joined a lobby. Send(new CreateLobby { PlayAreaCenter = center, @@ -540,16 +545,18 @@ public class GameClient : IDisposable ImpostorCount = impostorCount, TaskCount = taskCount, Password = password, - Settings = settings + Settings = settings, + DisplayName = DisplayName }); } - + public void JoinLobby(string joinCode, string? password = null) { Send(new JoinLobby { JoinCode = joinCode.ToUpperInvariant(), - Password = password + Password = password, + DisplayName = DisplayName }); } diff --git a/Assets/ClientSDK/Protocol.cs b/Assets/ClientSDK/Protocol.cs index 1f3b302..ae6478f 100644 --- a/Assets/ClientSDK/Protocol.cs +++ b/Assets/ClientSDK/Protocol.cs @@ -197,6 +197,16 @@ public class CreateLobby : Message /// [JsonProperty("settings")] public GameSettingsOverrides? Settings { get; set; } + + /// + /// Optional. Live host display name from the nickname input field at + /// the moment of CreateLobby. ClientHello-time name is stale because + /// the handshake fires before the user has typed anything; this lets + /// the server pick up the freshly-typed name without a separate + /// rename round-trip. + /// + [JsonProperty("displayName")] + public string? DisplayName { get; set; } } public class CreateLobbyResponse : Message @@ -222,12 +232,19 @@ public class CreateLobbyResponse : Message public class JoinLobby : Message { public override string Type => "JoinLobby"; - + [JsonProperty("joinCode")] public string JoinCode { get; set; } = ""; - + [JsonProperty("password")] public string? Password { get; set; } + + /// + /// Optional. Live joiner display name from the nickname input field + /// at the moment of Join. See CreateLobby.DisplayName for rationale. + /// + [JsonProperty("displayName")] + public string? DisplayName { get; set; } } public class JoinLobbyResponse : Message diff --git a/Assets/GameManager/GameManager.cs b/Assets/GameManager/GameManager.cs index d42e23c..a4540ff 100644 --- a/Assets/GameManager/GameManager.cs +++ b/Assets/GameManager/GameManager.cs @@ -66,10 +66,39 @@ public class GameManager : MonoBehaviour [Header("Debug")] public bool testMode = false; - private GameClient _secondClient; - private GameClient _thirdClient; - private GameManager_Network _secondNetwork; - private GameManager_Network _thirdNetwork; + /// + /// 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() { @@ -80,11 +109,71 @@ public class GameManager : MonoBehaviour } 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() { - if (string.IsNullOrEmpty(displayName)) + // 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); @@ -96,12 +185,18 @@ public class GameManager : MonoBehaviour if (testMode) { - _secondClient = new GameClient(GenerateUUID(), GenerateUsername()); - _secondNetwork = new GameManager_Network(_secondClient, null); - _thirdClient = new GameClient(GenerateUUID(), GenerateUsername()); - _thirdNetwork = new GameManager_Network(_thirdClient, null); - _secondNetwork.OpenConnection(); - _thirdNetwork.OpenConnection(); + 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(); @@ -118,14 +213,103 @@ public class GameManager : MonoBehaviour 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) { - _secondClient?.Update(); - _thirdClient?.Update(); + for (int i = 0; i < _testBots.Count; i++) + _testBots[i].Client?.Update(); + HandleTestBotInput(); } if (gameClient?.CurrentLobbyState != null) @@ -137,6 +321,102 @@ public class GameManager : MonoBehaviour 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; + } + } + } } @@ -326,9 +606,38 @@ public class GameManager : MonoBehaviour 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, @@ -340,9 +649,12 @@ public class GameManager : MonoBehaviour // testMode bypasses the GPS gate entirely so debug runs still work. if (!testMode) { - Debug.LogWarning("[GameManager] CreateLobby blocked: no GPS fix yet. " + - "Make sure location permission is granted and you have signal."); - uiSubsystem?.ShowToast("Waiting for GPS fix... grant location permission and try again."); + // 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; } @@ -358,6 +670,7 @@ public class GameManager : MonoBehaviour // Called by JoinLobbyUI with the code from the input field public void JoinLobbyButton(string code) { + CommitNicknameFromInput(); if (!string.IsNullOrEmpty(code)) networkSubsystem.JoinLobby(code); else @@ -377,12 +690,14 @@ public class GameManager : MonoBehaviour void OnApplicationQuit() { gameClient?.Disconnect(); - _secondClient?.Disconnect(); - _thirdClient?.Disconnect(); + for (int i = 0; i < _testBots.Count; i++) + _testBots[i].Client?.Disconnect(); } - IEnumerator ConnectTestClients() - { + 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) @@ -394,26 +709,46 @@ public class GameManager : MonoBehaviour var joinCode = gameClient?.CurrentLobbyState?.JoinCode; if (string.IsNullOrEmpty(joinCode)) { - Debug.LogWarning("[TestMode] Could not join test clients: join code not available."); + Debug.LogWarning("[TestMode] Could not join test bots: join code not available."); yield break; } - // Wait until helper clients are connected and handshake-complete + // Wait until every bot's client has finished its TCP handshake. + // IsReady flips once ClientHello + ClientHelloAck round-trip. wait = 0f; - while (((_secondClient == null || !_secondClient.IsReady) || (_thirdClient == null || !_thirdClient.IsReady)) && wait < 20f) + bool allReady; + do { - wait += 0.25f; - yield return new WaitForSeconds(0.25f); + 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."); } - if (_secondClient == null || _thirdClient == null || !_secondClient.IsReady || !_thirdClient.IsReady) + for (int i = 0; i < _testBots.Count; i++) { - Debug.LogWarning("[TestMode] Helper clients are not ready, skipping auto-join."); - yield break; + var bot = _testBots[i]; + if (bot.Client != null && bot.Client.IsReady) + { + bot.Network?.JoinLobby(joinCode); + bot.Joined = true; + } } - - _secondNetwork?.JoinLobby(joinCode); - _thirdNetwork?.JoinLobby(joinCode); - Debug.Log($"[TestMode] Helper clients joined lobby with code {joinCode}."); + Debug.Log($"[TestMode] {_testBots.Count} bot(s) joined lobby with code {joinCode}."); } } diff --git a/Assets/GameManager/GameManager_Input.cs b/Assets/GameManager/GameManager_Input.cs index c845931..533198d 100644 --- a/Assets/GameManager/GameManager_Input.cs +++ b/Assets/GameManager/GameManager_Input.cs @@ -1,4 +1,4 @@ -using UnityEngine; +using UnityEngine; using GeoSus.Client; using System; using System.Collections; @@ -16,6 +16,292 @@ namespace Subsystems Running, Failed } + + /// + /// Position source backend. Selectable at runtime via the GPS overlay + /// "Source" button so the user can recover when one path misbehaves on + /// their phone: + /// Auto - JNI: subscribe to gps + network, pick most recent fix. + /// GpsOnly - JNI: subscribe to gps only (network's frequent indoor + /// fixes don't drown out the slower-but-precise gps fix). + /// NetworkOnly - JNI: subscribe to network only (cell tower / WiFi). + /// Useful indoors when no satellite lock is possible. + /// UnityInput - Unity's Input.location wrapper. Verified to hang on + /// Mi 9T / A20e (which is why JNI exists), but works on + /// newer Android where the JNI streaming-callbacks path + /// silently doesn't fire (MIUI/HyperOS battery saver, + /// approximate-vs-precise permission split, minDistance + /// gating on stationary phones). + /// EditorWasd - WASD-driven simulated position. Available regardless + /// of testMode flag so desktop builds and editor sessions + /// can navigate the map without real GPS. + /// + public enum PositionSource + { + Auto, + GpsOnly, + NetworkOnly, + UnityInput, + EditorWasd, + } + +#if UNITY_ANDROID && !UNITY_EDITOR + /// + /// Bridges android.location.LocationListener to managed code. The method + /// names here must match Java's LocationListener interface exactly so + /// AndroidJavaProxy's reflection dispatcher can find them. + /// + internal class AndroidLocationProxy : AndroidJavaProxy + { + public AndroidLocationProvider Owner { get; set; } + public AndroidLocationProxy() : base("android.location.LocationListener") { } + + // Called by Android each time a new fix arrives from the registered provider. + public void onLocationChanged(AndroidJavaObject location) + { + try + { + if (location == null) return; + double lat = location.Call("getLatitude"); + double lon = location.Call("getLongitude"); + long t = location.Call("getTime"); + string provider = ""; + try { provider = location.Call("getProvider"); } catch { } + // Streaming callbacks are LIVE (never cached). The cached path + // calls UpdateLocation directly with isCached=true. + Owner?.UpdateLocation(lat, lon, t, provider, isCached: false); + } + catch (Exception ex) + { + Debug.LogWarning("[GPS-JNI] onLocationChanged failed: " + ex.Message); + } + } + + // Required by the LocationListener interface even if we don't use them. + // Missing methods cause java.lang.AbstractMethodError at runtime. + public void onStatusChanged(string provider, int status, AndroidJavaObject extras) { } + public void onProviderEnabled(string provider) { } + public void onProviderDisabled(string provider) { } + } + + /// + /// Direct wrapper around android.location.LocationManager via JNI, used as + /// a replacement for Unity's Input.location on Android when the user picks + /// Auto/GpsOnly/NetworkOnly. Subscribed providers are configurable so the + /// position-source picker can rewire live without restart. + /// + internal class AndroidLocationProvider + { + private AndroidJavaObject _activity; + private AndroidJavaObject _locationManager; + private AndroidLocationProxy _gpsListener; + private AndroidLocationProxy _networkListener; + private double _lat, _lon; + private long _lastTimeMillis; + private long _lastLiveTimeMillis; // Time of most recent NON-cached fix. + private bool _hasFix; + private bool _hasLiveFix; // True once any streaming callback fired. + private string _activeProvider = ""; + + // Captured at Initialize() so the diagnostic can report + // "GPS provider DISABLED, only network enabled" etc. + private bool _gpsProviderEnabled; + private bool _networkProviderEnabled; + private bool _gpsLastKnownExists; + private bool _networkLastKnownExists; + private string _enabledProvidersList = ""; + + // Subscription scope - set in Initialize, used in Shutdown to know + // which listeners we registered. + private bool _subscribedGps; + private bool _subscribedNetwork; + + public bool HasFix => _hasFix; + public bool HasLiveFix => _hasLiveFix; + public long LastLiveTimeMillis => _lastLiveTimeMillis; + public long LastTimeMillis => _lastTimeMillis; + public double Lat => _lat; + public double Lon => _lon; + public string ActiveProvider => _activeProvider; + public bool GpsProviderEnabled => _gpsProviderEnabled; + public bool NetworkProviderEnabled => _networkProviderEnabled; + public bool GpsLastKnownExists => _gpsLastKnownExists; + public bool NetworkLastKnownExists => _networkLastKnownExists; + public string EnabledProvidersList => _enabledProvidersList; + public bool SubscribedGps => _subscribedGps; + public bool SubscribedNetwork => _subscribedNetwork; + + public bool Initialize(out string error, bool useGps, bool useNetwork) + { + error = ""; + try + { + using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + { + _activity = unityPlayer.GetStatic("currentActivity"); + } + if (_activity == null) { error = "no current activity"; return false; } + + _locationManager = _activity.Call("getSystemService", "location"); + if (_locationManager == null) { error = "getSystemService(\"location\") returned null"; return false; } + + // Capture provider enable state up front so the diagnostic + // can distinguish "provider disabled at OS level" from + // "provider enabled but produced no fix yet". + _gpsProviderEnabled = SafeIsProviderEnabled("gps"); + _networkProviderEnabled = SafeIsProviderEnabled("network"); + _enabledProvidersList = SafeGetEnabledProviders(); + + Debug.Log($"[GPS-JNI] init useGps={useGps} useNetwork={useNetwork} gps enabled={_gpsProviderEnabled} network enabled={_networkProviderEnabled} all enabled=[{_enabledProvidersList}]"); + + // Try cached last-known fixes from the providers we're about + // to subscribe to. If the OS already knows where we are + // (e.g. from another app that recently used GPS), we get a + // fix at zero cost and zero wait time. Tagged isCached so + // the diagnostic can mark them and we know we still need + // to wait for a streaming callback. + if (useNetwork) TryLastKnown("network", out _networkLastKnownExists); + if (useGps) TryLastKnown("gps", out _gpsLastKnownExists); + + _subscribedGps = useGps; + _subscribedNetwork = useNetwork; + + if (useGps) _gpsListener = new AndroidLocationProxy { Owner = this }; + if (useNetwork) _networkListener = new AndroidLocationProxy { Owner = this }; + + // requestLocationUpdates must be called on a thread with a + // Looper. Use the Activity's UI thread, which always has one. + // minTime=1000ms, minDistance=0f - we want updates on every + // fix the OS produces. Previously this was 1f which gated + // out updates from a stationary phone (MIUI/newer Android + // are stricter about this and that's the suspected cause of + // "via gps (cached)" sticking forever). + _activity.Call("runOnUiThread", new AndroidJavaRunnable(() => + { + if (useGps) + { + try { _locationManager.Call("requestLocationUpdates", "gps", 1000L, 0f, _gpsListener); } + catch (Exception ex) { Debug.LogWarning("[GPS-JNI] gps subscribe failed: " + ex.Message); } + } + if (useNetwork) + { + try { _locationManager.Call("requestLocationUpdates", "network", 1000L, 0f, _networkListener); } + catch (Exception ex) { Debug.LogWarning("[GPS-JNI] network subscribe failed: " + ex.Message); } + } + })); + + return true; + } + catch (Exception ex) + { + error = "JNI init exception: " + ex.Message; + return false; + } + } + + void TryLastKnown(string provider, out bool nonNullReturned) + { + nonNullReturned = false; + try + { + var loc = _locationManager.Call("getLastKnownLocation", provider); + if (loc != null) + { + nonNullReturned = true; + double lat = loc.Call("getLatitude"); + double lon = loc.Call("getLongitude"); + long t = loc.Call("getTime"); + UpdateLocation(lat, lon, t, provider, isCached: true); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[GPS-JNI] getLastKnownLocation({provider}) failed: " + ex.Message); + } + } + + bool SafeIsProviderEnabled(string provider) + { + try + { + return _locationManager.Call("isProviderEnabled", provider); + } + catch (Exception ex) + { + Debug.LogWarning($"[GPS-JNI] isProviderEnabled({provider}) failed: " + ex.Message); + return false; + } + } + + // Build a comma-separated list of currently-enabled providers via + // LocationManager.getProviders(true). We iterate the returned + // java.util.List by index because AndroidJavaObject does not + // implement IEnumerable. + string SafeGetEnabledProviders() + { + try + { + var list = _locationManager.Call("getProviders", true); + if (list == null) return ""; + int size = list.Call("size"); + var parts = new System.Text.StringBuilder(); + for (int i = 0; i < size; i++) + { + var name = list.Call("get", i); + if (i > 0) parts.Append(","); + parts.Append(name); + } + return parts.ToString(); + } + catch (Exception ex) + { + Debug.LogWarning("[GPS-JNI] getProviders failed: " + ex.Message); + return ""; + } + } + + public void UpdateLocation(double lat, double lon, long timeMillis, string provider, bool isCached) + { + // Ignore older fixes if a newer one is already in hand. This lets + // both gps + network listeners feed us without ping-ponging + // between stale and fresh data. + if (timeMillis < _lastTimeMillis) return; + _lat = lat; + _lon = lon; + _lastTimeMillis = timeMillis; + // Active-provider name carries cached/live state in the diagnostic + // banner so the user can see at a glance whether streaming has + // kicked in or we're still on the initial cached snapshot. + _activeProvider = (provider ?? "") + (isCached ? " (cached)" : ""); + _hasFix = true; + if (!isCached) + { + _hasLiveFix = true; + _lastLiveTimeMillis = timeMillis; + } + } + + public void Shutdown() + { + try + { + if (_locationManager != null) + { + if (_gpsListener != null) _locationManager.Call("removeUpdates", _gpsListener); + if (_networkListener != null) _locationManager.Call("removeUpdates", _networkListener); + } + } + catch (Exception ex) + { + Debug.LogWarning("[GPS-JNI] Shutdown failed: " + ex.Message); + } + _gpsListener = null; + _networkListener = null; + _locationManager = null; + _activity = null; + } + } +#endif public static class PositonExtensions { public static Position ToLocal(this Position position, Position center) @@ -26,7 +312,7 @@ namespace Subsystems double metersPerDegreeLon = 111320.0 * Math.Cos(center.Lat * Math.PI / 180.0); float x = (float)(lonDiff * metersPerDegreeLon); float z = (float)(latDiff * metersPerDegreeLat); - return new Position(z, x); + return new Position(z, x); } public static Vector3 ToLocalVector3(this Position position, Position center) { @@ -51,6 +337,18 @@ namespace Subsystems private GameObject _player; private bool _testMode; + // PlayerPrefs key for the user's chosen position source. Persists + // across app restarts so a user who flipped to UnityInput because + // their phone hated the JNI path doesn't have to flip again every + // launch. + private const string PrefsSourceKey = "PositionSource_v1"; + private PositionSource _currentSource = PositionSource.Auto; + + // When the multi-client editor test mode picks a non-host bot as + // active, we need the host's WASD path to NOT also move. Set true + // by GameManager when active slot != 0. + public bool SuppressWasd = false; + private GPSState _GPSState = GPSState.Uninitialized; private float _speed = 0.00001f; private Position _mapCenter; @@ -61,9 +359,125 @@ namespace Subsystems private float _lastPositionSendTime; private const float _positionKeepAliveSeconds = 1.0f; + // Diagnostic state. We capture *why* GPS init failed so the UI can + // surface it to the user without requiring logcat. Older Android + // phones (Mi 9T, A20e) hit silent failure modes that are impossible + // to distinguish from "still warming up" without this. + private string _lastGpsError = ""; + private float _gpsInitStartTime = -1f; + // Bump from the original 20s. Cold-start GPS on older Android can + // easily exceed 20s indoors or under cloud cover - by the time the + // user notices nothing is happening, we've already given up. + private const int _gpsInitTimeoutSeconds = 60; + +#if UNITY_ANDROID && !UNITY_EDITOR + // JNI-backed location provider, used for Auto/GpsOnly/NetworkOnly. + // UnityInput uses Input.location instead and leaves this null. + private AndroidLocationProvider _androidProvider; +#endif + /// Last known GPS position (for CreateLobby centre point) public Position? LastKnownPosition => _currentPosition.Lat != 0 || _currentPosition.Lon != 0 ? _currentPosition : (Position?)null; + /// Current GPS state machine value (debug/diagnostic). + public string GpsStateName => _GPSState.ToString(); + + /// Last GPS error reason captured during init (empty if none). + public string LastGpsError => _lastGpsError ?? ""; + + /// Retry count out of max (debug/diagnostic). + public string GpsRetryProgress => $"{_gpsRetryCount}/{_maxGpsRetries}"; + + /// Currently selected position source (for UI cycle button). + public PositionSource CurrentSource => _currentSource; + + /// Display name for the current source (for UI label). + public string CurrentSourceName + { + get + { + switch (_currentSource) + { + case PositionSource.Auto: return "Auto (GPS+Net)"; + case PositionSource.GpsOnly: return "GPS only"; + case PositionSource.NetworkOnly: return "Network only"; + case PositionSource.UnityInput: return "Unity Input"; + case PositionSource.EditorWasd: return "WASD"; + default: return _currentSource.ToString(); + } + } + } + + /// + /// Human-readable one-line GPS status for on-screen overlay. Designed + /// to be visible without ADB so users can self-diagnose permission + /// vs. timeout vs. device-disabled vs. running-but-no-fix-yet. + /// + public string GpsDiagnostic + { + get + { + if (_currentSource == PositionSource.EditorWasd) + { + if (_currentPosition.Lat == 0 && _currentPosition.Lon == 0) + return "WASD: waiting for map center"; + return $"WASD lat={_currentPosition.Lat:F5} lon={_currentPosition.Lon:F5}"; + } + + switch (_GPSState) + { + case GPSState.Uninitialized: + return "Uninitialized (will start on first lobby action)"; + case GPSState.Initializing: + { + float elapsed = _gpsInitStartTime >= 0 ? Time.time - _gpsInitStartTime : 0; + string providers = ""; +#if UNITY_ANDROID && !UNITY_EDITOR + if (_androidProvider != null && !string.IsNullOrEmpty(_androidProvider.EnabledProvidersList)) + providers = $" providers=[{_androidProvider.EnabledProvidersList}]"; +#endif + return $"Initializing ({elapsed:F1}s / max {_gpsInitTimeoutSeconds}s){providers}"; + } + case GPSState.Running: + { + string suffix = ""; +#if UNITY_ANDROID && !UNITY_EDITOR + if (_androidProvider != null) + { + string p = _androidProvider.ActiveProvider; + if (!string.IsNullOrEmpty(p)) suffix = " via " + p; + // Show how stale the most recent fix is (ms-level + // resolution) so "stuck on cached" is obvious at + // a glance: "via gps (cached) [no live, 47s old]". + if (!_androidProvider.HasLiveFix) + { + long now = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + long ageMs = now - _androidProvider.LastTimeMillis; + if (_androidProvider.LastTimeMillis > 0 && ageMs > 0) + suffix += $" [no live, {ageMs / 1000}s old]"; + else + suffix += " [no live]"; + } + else + { + long now = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + long ageMs = now - _androidProvider.LastLiveTimeMillis; + if (ageMs > 5000) suffix += $" [live {ageMs / 1000}s old]"; + } + } +#endif + if (_currentPosition.Lat == 0 && _currentPosition.Lon == 0) + return "Running but no fix yet (waiting for satellites)" + suffix; + return $"Running lat={_currentPosition.Lat:F5} lon={_currentPosition.Lon:F5}" + suffix; + } + case GPSState.Failed: + return $"Failed: {(_lastGpsError ?? "unknown")} (retries {GpsRetryProgress})"; + default: + return "?"; + } + } + } + public GameManager_Input(GameClient gameClient, GameObject player, bool testMode) { _gameClient = gameClient; @@ -73,12 +487,85 @@ namespace Subsystems var hostGO = new UnityEngine.GameObject("_CoroutineHost"); UnityEngine.Object.DontDestroyOnLoad(hostGO); _coroutineHost = hostGO.AddComponent(); + + // Restore the user's last picked source. Default depends on + // platform: editor defaults to EditorWasd (no GPS hardware in + // editor anyway); device defaults to Auto. + string saved = PlayerPrefs.GetString(PrefsSourceKey, ""); + if (!string.IsNullOrEmpty(saved) && Enum.TryParse(saved, out PositionSource parsed)) + { + _currentSource = parsed; + } + else + { +#if UNITY_EDITOR + _currentSource = PositionSource.EditorWasd; +#else + _currentSource = PositionSource.Auto; +#endif + } + + // Legacy testMode flag forces EditorWasd. New code paths should + // use SwitchPositionSource(EditorWasd) instead, but we keep the + // old behavior for backward compatibility with the inspector flag. + if (_testMode) _currentSource = PositionSource.EditorWasd; } /// Called from OnSceneLoaded when Client.unity loads so the /// Player capsule (which lives in Client.unity) can be wired at runtime. public void SetPlayerObject(GameObject player) { _player = player; } + /// + /// Switch the active position source backend live. Tears down the + /// current backend's listeners (JNI proxies, Input.location), resets + /// the state machine, and kicks off init for the new source. Persists + /// the choice to PlayerPrefs. + /// + public void SwitchPositionSource(PositionSource newSource) + { + if (_currentSource == newSource) return; + Debug.Log($"[GPS] SwitchPositionSource {_currentSource} -> {newSource}"); + + // Tear down whatever's running. + ShutdownCurrentBackend(); + + _currentSource = newSource; + PlayerPrefs.SetString(PrefsSourceKey, newSource.ToString()); + PlayerPrefs.Save(); + + _GPSState = GPSState.Uninitialized; + _gpsRetryCount = 0; + _lastGpsError = ""; + _gpsInitStartTime = -1f; + // Don't clear _currentPosition - the user has presumably been + // playing somewhere. Map markers/avatar position can stay until + // the next fix arrives from the new source. + + EnsureGPSStarted(); + } + + /// Cycle through the available sources for tap-to-cycle UI. + public void CycleNextPositionSource() + { + var values = (PositionSource[])Enum.GetValues(typeof(PositionSource)); + int idx = Array.IndexOf(values, _currentSource); + var next = values[(idx + 1) % values.Length]; + SwitchPositionSource(next); + } + + private void ShutdownCurrentBackend() + { +#if UNITY_ANDROID && !UNITY_EDITOR + if (_androidProvider != null) + { + _androidProvider.Shutdown(); + _androidProvider = null; + } +#endif + // Stop Unity Input.location too, in case it was running. + try { Input.location.Stop(); } catch { } + } + /// /// Kick off GPS initialization if it hasn't started yet. Safe to call /// repeatedly. Hosts must call this from the lobby setup screen so @@ -88,10 +575,19 @@ namespace Subsystems /// public void EnsureGPSStarted() { - if (_testMode) return; + if (_currentSource == PositionSource.EditorWasd) return; if (_coroutineHost == null) return; + // Allow tapping "Create Lobby" again (or any caller of this + // method) to retry from Failed up to _maxGpsRetries times. if (_GPSState == GPSState.Uninitialized) + { _coroutineHost.StartCoroutine(InitiallizeGPS()); + } + else if (_GPSState == GPSState.Failed && _gpsRetryCount < _maxGpsRetries) + { + _gpsRetryCount++; + _coroutineHost.StartCoroutine(InitiallizeGPS()); + } } public void positionCheck() { @@ -101,7 +597,7 @@ namespace Subsystems try { - if (_testMode) + if (_currentSource == PositionSource.EditorWasd) { if (_currentPosition == new Position(0, 0)) { @@ -114,7 +610,10 @@ namespace Subsystems _lastSentPosition = _currentPosition; } - TestPlayerPosition(); + if (!SuppressWasd) + TestPlayerPosition(); + else + TrySendCurrentPosition(); // keep-alive only } else { @@ -194,18 +693,16 @@ namespace Subsystems { double x = Input.GetAxis("Horizontal"); double y = Input.GetAxis("Vertical"); - Debug.Log($"Input: {x}, {y}"); _currentPosition = new Position( _lastSentPosition.Lat + y * _speed, _lastSentPosition.Lon + x * _speed); - Debug.Log($"Current Position: {_currentPosition.Lat}, {_currentPosition.Lon}"); var localCurrent = _currentPosition.ToLocalVector3(_mapCenter); - Debug.Log($"Local Current Position: {localCurrent}"); var heading = CalculateHeading(_lastSentPosition.ToLocalVector3(_mapCenter), localCurrent); if (heading != null) { - Debug.Log($"Heading: {heading}"); - _player.transform.rotation = Quaternion.Euler(0, (float)heading, 0); + if (_player != null) + _player.transform.rotation = Quaternion.Euler(0, (float)heading, 0); } - _player.transform.position = localCurrent; + if (_player != null) + _player.transform.position = localCurrent; try { TrySendCurrentPosition(); @@ -228,9 +725,13 @@ namespace Subsystems IEnumerator InitiallizeGPS() { _GPSState = GPSState.Initializing; + _gpsInitStartTime = Time.time; + _lastGpsError = ""; #if UNITY_ANDROID - // Request fine location permission if not already granted + // Request fine location permission if not already granted. + // On Android 12+ a "precise" toggle exists separately from coarse, + // but Unity's FineLocation request covers both for our purposes. if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation)) { UnityEngine.Android.Permission.RequestUserPermission(UnityEngine.Android.Permission.FineLocation); @@ -241,42 +742,133 @@ namespace Subsystems yield return new WaitForSeconds(0.5f); waited += 0.5f; } + if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation)) + { + _lastGpsError = "Permission denied (fine location)"; + Debug.LogError("[GPS] " + _lastGpsError); + _GPSState = GPSState.Failed; + yield break; + } } #endif +#if UNITY_ANDROID && !UNITY_EDITOR + // Choose subscription scope based on selected source. UnityInput + // skips JNI entirely and falls through to the Input.location path + // below (the same path iOS / editor use). + if (_currentSource == PositionSource.Auto || + _currentSource == PositionSource.GpsOnly || + _currentSource == PositionSource.NetworkOnly) + { + bool useGps = (_currentSource != PositionSource.NetworkOnly); + bool useNetwork = (_currentSource != PositionSource.GpsOnly); + + if (_androidProvider != null) + { + _androidProvider.Shutdown(); + _androidProvider = null; + } + _androidProvider = new AndroidLocationProvider(); + if (!_androidProvider.Initialize(out var initError, useGps, useNetwork)) + { + _lastGpsError = "Native LocationManager failed: " + initError; + Debug.LogError("[GPS] " + _lastGpsError); + _androidProvider = null; + _GPSState = GPSState.Failed; + yield break; + } + + // Fast-fail if neither subscribed provider is enabled at OS + // level. Waiting 60s for fixes from disabled providers is + // pointless - tell the user immediately what's wrong. + bool anyUsableEnabled = + (useGps && _androidProvider.GpsProviderEnabled) || + (useNetwork && _androidProvider.NetworkProviderEnabled); + if (!anyUsableEnabled) + { + string which = useGps && useNetwork ? "gps + network" + : useGps ? "gps" + : "network"; + _lastGpsError = $"{which} provider DISABLED at OS level. Open Settings > Location and switch it ON. Or tap [Source] to try a different backend."; + Debug.LogError("[GPS] " + _lastGpsError); + _androidProvider.Shutdown(); + _androidProvider = null; + _GPSState = GPSState.Failed; + yield break; + } + + // Wait for the first fix (cached or live). + int maxWaitJni = _gpsInitTimeoutSeconds; + while (!_androidProvider.HasFix && maxWaitJni > 0) + { + yield return new WaitForSeconds(1); + maxWaitJni--; + } + + if (!_androidProvider.HasFix) + { + string enabled = _androidProvider.EnabledProvidersList ?? ""; + string gpsState = _androidProvider.GpsProviderEnabled ? "ON" : "OFF"; + string netState = _androidProvider.NetworkProviderEnabled ? "ON" : "OFF"; + string lastKnown = $"lastKnown[gps={(_androidProvider.GpsLastKnownExists ? "yes" : "no")}, net={(_androidProvider.NetworkLastKnownExists ? "yes" : "no")}]"; + + _lastGpsError = $"Timeout {_gpsInitTimeoutSeconds}s on {_currentSource}. enabled=[{enabled}] gps={gpsState} net={netState} {lastKnown}. Try [Source] cycle to switch backends."; + Debug.LogError("[GPS] " + _lastGpsError); + _androidProvider.Shutdown(); + _androidProvider = null; + _GPSState = GPSState.Failed; + yield break; + } + + _currentPosition = new Position(_androidProvider.Lat, _androidProvider.Lon); + _GPSState = GPSState.Running; + _gpsRetryCount = 0; + _coroutineHost.StartCoroutine(AndroidGPSService()); + yield break; + } + + // _currentSource == UnityInput on Android: fall through to the + // Input.location path below. This is the recovery path for + // newer Android phones where JNI's streaming-callbacks don't + // fire (MIUI/HyperOS background restrictions, approximate-vs- + // precise permission, minDistance gating on stationary phones). +#endif + + // iOS / editor / non-Android / Android-with-UnityInput-source: + // use Unity's Input.location. if (!Input.location.isEnabledByUser) { - Debug.LogError("Location not enabled on device or app does not have permission to access location"); + _lastGpsError = "Location services not enabled by user"; + Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; yield break; } - // Starts the location service. float desiredAccuracyInMeters = 5f; float updateDistanceInMeters = 1f; Input.location.Start(desiredAccuracyInMeters, updateDistanceInMeters); - // Waits until the location service initializes - int maxWait = 20; + int maxWait = _gpsInitTimeoutSeconds; while (Input.location.status == LocationServiceStatus.Initializing && maxWait > 0) { yield return new WaitForSeconds(1); maxWait--; } - // If the service didn't initialize in 20 seconds this cancels location service use. if (maxWait < 1) { + _lastGpsError = $"Timed out after {_gpsInitTimeoutSeconds}s waiting for first fix (try moving outdoors, or tap [Source] to try a different backend)"; + Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; - Debug.LogError("Timed out"); yield break; } if (Input.location.status == LocationServiceStatus.Failed) { + _lastGpsError = "Unity Input.location reported Failed status"; + Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; - Debug.LogError("Unable to determine device location"); yield break; } @@ -284,14 +876,43 @@ namespace Subsystems _gpsRetryCount = 0; _coroutineHost.StartCoroutine(GPSService()); } + +#if UNITY_ANDROID && !UNITY_EDITOR + /// + /// Mirrors the JNI provider's most recent fix into _currentPosition + /// every 0.5s so the rest of the game (which polls _currentPosition + /// indirectly via LastKnownPosition / TrySendCurrentPosition) keeps + /// working unchanged. Replaces GPSService on Android. + /// + IEnumerator AndroidGPSService() + { + while (_GPSState == GPSState.Running && _androidProvider != null) + { + if (_androidProvider.HasFix) + { + _currentPosition = new Position(_androidProvider.Lat, _androidProvider.Lon); + } + yield return new WaitForSeconds(0.5f); + } + + // Loop ended (state != Running or provider disposed). Clean up + // listeners so we don't leak across retries. + if (_androidProvider != null) + { + _androidProvider.Shutdown(); + _androidProvider = null; + } + } +#endif IEnumerator GPSService() { while (_GPSState == GPSState.Running) { if (Input.location.status == LocationServiceStatus.Failed) { + _lastGpsError = "Location service died after init (provider stopped)"; + Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; - Debug.LogError("Unable to determine device location"); yield break; } @@ -302,4 +923,4 @@ namespace Subsystems } } } -} \ No newline at end of file +} diff --git a/Assets/GameManager/GameManager_Map.cs b/Assets/GameManager/GameManager_Map.cs index 1099cdf..c9c1acc 100644 --- a/Assets/GameManager/GameManager_Map.cs +++ b/Assets/GameManager/GameManager_Map.cs @@ -702,6 +702,20 @@ namespace Subsystems{ _bodyMarkers.Clear(); } + // ── Player avatar sizing ──────────────────────────────────────────── + // The default Unity capsule primitive is 2m tall in local space. The + // map camera defaults to 150m orthographic-ish height (see + // MapCameraController), so anything smaller than ~3m world-size is a + // pixel on screen. Original code used scale=0.4 (~0.8m capsule) which + // was invisible. Markers (POIs/tasks/bodies) are 8m pillars; players + // need to be visibly distinct from those AND from each other. The + // local player gets a halo light + larger scale so the user can find + // themselves on the map at a glance. + private const float kLocalPlayerScale = 4f; // ~8m capsule (matches marker height) + private const float kRemotePlayerScale = 2f; // ~4m capsule (smaller than markers) + private const float kLocalPlayerHaloRange = 18f; + private const float kLocalPlayerHaloIntensity = 2.5f; + public void UpdatePlayerAvatars(Dictionary positions, string myUuid) { if (_mapCenterPoint == null) return; @@ -715,20 +729,47 @@ namespace Subsystems{ { string uuid = kvp.Key; var info = kvp.Value; + bool isLocal = uuid == myUuid; if (!_playerAvatars.TryGetValue(uuid, out var go) || go == null) { go = GameObject.CreatePrimitive(PrimitiveType.Capsule); go.name = $"Player_{uuid.Substring(0, Mathf.Min(8, uuid.Length))}"; go.transform.parent = _mapCenterPoint?.transform; - go.transform.localScale = Vector3.one * 0.4f; + // Strip the auto-collider - avatars are visual only and the + // collider would interact with the map's MeshColliders. + var col = go.GetComponent(); + if (col != null) UnityEngine.Object.Destroy(col); + + float scale = isLocal ? kLocalPlayerScale : kRemotePlayerScale; + go.transform.localScale = Vector3.one * scale; + + if (isLocal) + { + // Halo light around the local player so the user can + // find themselves at a glance even at the widest zoom. + // Range/intensity tuned so it reads as "this is me" + // without bleeding far enough to drown POI markers. + var halo = go.AddComponent(); + halo.color = new Color(0.30f, 1.00f, 0.55f); // matches green capsule color + halo.intensity = kLocalPlayerHaloIntensity; + halo.range = kLocalPlayerHaloRange; + } + _playerAvatars[uuid] = go; } - go.transform.position = info.Position.ToLocalVector3(_centerPosition) + Vector3.up * 1f; + + // Lift the avatar so the bottom of the capsule sits roughly at + // ground level despite the larger scale. Capsule's local pivot + // is at center, height = 2 * localScale.y world units, so we + // raise by half the local height. + float halfHeight = (isLocal ? kLocalPlayerScale : kRemotePlayerScale); + go.transform.position = info.Position.ToLocalVector3(_centerPosition) + + Vector3.up * halfHeight; var mr = go.GetComponent(); if (mr) { - if (uuid == myUuid) mr.material.color = Color.green; + if (isLocal) mr.material.color = new Color(0.30f, 1.00f, 0.55f); else if (info.State == GeoSus.Client.PlayerState.Dead) mr.material.color = Color.grey; else mr.material.color = Color.white; } diff --git a/Assets/GameManager/GameManager_Network.cs b/Assets/GameManager/GameManager_Network.cs index ab85b1a..53bedac 100644 --- a/Assets/GameManager/GameManager_Network.cs +++ b/Assets/GameManager/GameManager_Network.cs @@ -487,6 +487,15 @@ namespace Subsystems { State.Phase = GamePhase.Lobby; _manager?.uiSubsystem?.HideMeetingPanel(); + // Bodies survive the scene reload because the marker GameObjects are + // parented under MapCenterPoint (which lives in the persistent + // Client.unity scene). Without this clear, returning to lobby and + // starting a new round leaves stale corpses on the map of the new + // round. Server already cleared its `_bodies` set in + // ProcessReturnToLobby; this is the client-side mirror that was + // missing in HandleVotingClosed's symmetry. + _manager?.mapSubsystem?.ClearBodyMarkers(); + _manager?.mapSubsystem?.ClearSabotageMarkers(); // Unified lobby: regardless of role, return to create.unity. SceneManager.LoadScene("create", LoadSceneMode.Single); } diff --git a/Assets/GameManager/GameManager_Tasks.cs b/Assets/GameManager/GameManager_Tasks.cs index 0b2845a..d646f6e 100644 --- a/Assets/GameManager/GameManager_Tasks.cs +++ b/Assets/GameManager/GameManager_Tasks.cs @@ -25,6 +25,8 @@ namespace Subsystems private List _tasks = new List(); private bool _minigameOpen; private string _loadedMinigameScene; + private Camera _hostCameraSuspended; + private GameObject _hostInGameHudHidden; // Proximity state (checked every frame in UpdateProximity) public GeoSus.Client.GameTask NearbyTask { get; private set; } @@ -190,12 +192,31 @@ namespace Subsystems // Inform server that task started _gameClient.Send(new TaskStart { TaskId = entry.ServerTask.TaskId }); + // Disable the host scene's main camera while the minigame is up. + // With both cameras enabled the minigame's UI/3D content would + // fight the host's map camera for screen space, and what gets + // drawn depends on Camera.depth which isn't guaranteed across + // scenes. Restored in FinishMinigame. + _hostCameraSuspended = Camera.main; + if (_hostCameraSuspended != null) _hostCameraSuspended.enabled = false; + + // Hide the persistent InGame HUD canvas (if present). It lives + // in Client.unity and renders Screen Space - Overlay so it would + // otherwise stack on top of the minigame's UI regardless of + // which scene is active. SetActive(false) is reversible. + _hostInGameHudHidden = GameObject.Find("InGame"); + if (_hostInGameHudHidden != null && _hostInGameHudHidden.activeSelf) + _hostInGameHudHidden.SetActive(false); + else + _hostInGameHudHidden = null; // nothing to restore + var op = SceneManager.LoadSceneAsync(entry.MinigameScene, LoadSceneMode.Additive); if (op == null) { Debug.LogError($"[Tasks] LoadSceneAsync returned null for '{entry.MinigameScene}'."); GameManager.Instance?.uiSubsystem?.ShowToast( $"Task scene failed to load: {entry.MinigameScene}"); + if (_hostCameraSuspended != null) { _hostCameraSuspended.enabled = true; _hostCameraSuspended = null; } _minigameOpen = false; yield break; } @@ -203,8 +224,34 @@ namespace Subsystems _loadedMinigameScene = entry.MinigameScene; - // Find the ITask component in the newly loaded scene + // CRITICAL: switch the active scene to the loaded minigame. + // LoadSceneMode.Additive stacks scenes without changing which one + // is "active" - and an inactive scene's RenderSettings, ambient + // light, and skybox don't drive rendering. The host (Client.unity) + // remains active and its lighting context still applies, which + // is the root cause of "task opens to white screen": the + // minigame's content loads but its visuals don't take over. + // Without SetActiveScene, even minigames that ARE wired up + // correctly render against the host's lighting and look broken. Scene scene = SceneManager.GetSceneByName(entry.MinigameScene); + if (scene.IsValid()) SceneManager.SetActiveScene(scene); + + // Diagnostic: count cameras / canvases / lights in the loaded + // scene. If the white screen persists after this fix, the + // numbers tell us whether the scene is missing rendering bits + // (camera=0, canvas=0) or if the issue is elsewhere. + int camCount = 0, canvasCount = 0, lightCount = 0; + foreach (var root in scene.GetRootGameObjects()) + { + camCount += root.GetComponentsInChildren(true).Length; + canvasCount += root.GetComponentsInChildren(true).Length; + lightCount += root.GetComponentsInChildren(true).Length; + } + Debug.Log($"[Tasks] Loaded '{entry.MinigameScene}': cameras={camCount}, " + + $"canvases={canvasCount}, lights={lightCount}, " + + $"activeScene={SceneManager.GetActiveScene().name}"); + + // Find the ITask component in the newly loaded scene ITask taskComponent = null; foreach (var root in scene.GetRootGameObjects()) { @@ -214,7 +261,9 @@ namespace Subsystems if (taskComponent == null) { - Debug.LogWarning($"[Tasks] No ITask found in '{entry.MinigameScene}'. Auto-completing."); + Debug.LogWarning($"[Tasks] No ITask found in '{entry.MinigameScene}'. " + + $"Either the minigame's controller script isn't attached to a GameObject in the scene, " + + $"or the script doesn't implement ITask. Auto-completing."); yield return FinishMinigame(entry, true); yield break; } @@ -246,14 +295,33 @@ namespace Subsystems Debug.Log($"[Tasks] Task '{entry.ServerTask.Name}' exited without completion."); } - // Unload minigame scene + // Unload minigame scene. Switch the active scene back to the + // host BEFORE the unload so we don't end up with no active + // scene mid-frame (Unity will complain and lighting flickers). if (!string.IsNullOrEmpty(_loadedMinigameScene)) { + var hostScene = SceneManager.GetSceneByName("Client"); + if (hostScene.IsValid()) SceneManager.SetActiveScene(hostScene); + var unload = SceneManager.UnloadSceneAsync(_loadedMinigameScene); yield return unload; _loadedMinigameScene = null; } + // Re-enable the host camera that was suspended during the minigame. + if (_hostCameraSuspended != null) + { + _hostCameraSuspended.enabled = true; + _hostCameraSuspended = null; + } + + // Re-show the InGame HUD canvas hidden at minigame entry. + if (_hostInGameHudHidden != null) + { + _hostInGameHudHidden.SetActive(true); + _hostInGameHudHidden = null; + } + _minigameOpen = false; } }