diff --git a/Assets/GameManager/GameManager_Input.cs b/Assets/GameManager/GameManager_Input.cs
index ef51873..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)
{
@@ -50,97 +336,376 @@ namespace Subsystems
private Position _lastSentPosition;
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;
- private CoroutineHost _coroutineHost = new CoroutineHost();
+ private CoroutineHost _coroutineHost;
+
+ private int _gpsRetryCount = 0;
+ private const int _maxGpsRetries = 5;
+ 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;
_player = player;
_testMode = testMode;
+ // CoroutineHost needs a MonoBehaviour on a real GameObject
+ 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
+ /// that by the time they click "Create Lobby" we have a real GPS
+ /// fix to use as the play-area center, instead of falling back to
+ /// the hardcoded coordinates.
+ ///
+ public void EnsureGPSStarted()
+ {
+ 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()
{
+ var state = _gameClient?.CurrentLobbyState;
+ if (state == null || state.Phase != GamePhase.Playing)
+ return;
+
try
{
- if (_gameClient.CurrentLobbyState.Phase == GamePhase.Playing)
+ if (_currentSource == PositionSource.EditorWasd)
{
- if (_testMode)
+ if (_currentPosition == new Position(0, 0))
{
+ if (state.MapData == null)
+ return;
- if (_currentPosition == null || _currentPosition == new Position(0, 0))
- {
- //Init blok
- _currentPosition = _gameClient.CurrentLobbyState.MapData.Center;
- _mapCenter = _gameClient.CurrentLobbyState.MapData.Center;
- _lastSentPosition = _currentPosition;
- }
+ //Init blok
+ _currentPosition = state.MapData.Center;
+ _mapCenter = state.MapData.Center;
+ _lastSentPosition = _currentPosition;
+ }
+ if (!SuppressWasd)
TestPlayerPosition();
+ else
+ TrySendCurrentPosition(); // keep-alive only
+ }
+ else
+ {
+ if (_GPSState == GPSState.Uninitialized)
+ {
+ _coroutineHost.StartCoroutine(InitiallizeGPS());
+ return;
+ }
+ else if (_GPSState == GPSState.Initializing)
+ {
+ return;
+ }
+ else if (_GPSState == GPSState.Running)
+ {
+ EnsureMapCenter();
+ TrySendCurrentPosition();
}
else
{
- if (_GPSState == GPSState.Uninitialized)
+ Debug.Log("GPS failed, trying again...");
+ if (_gpsRetryCount < _maxGpsRetries)
{
- _coroutineHost.StartCoroutine(InitiallizeGPS());
- return;
- }
- else if (_GPSState == GPSState.Initializing)
- {
- return;
- }
- else if (_GPSState == GPSState.Running)
- {
- try
- {
- if (_currentPosition != _lastSentPosition)
- {
- _gameClient.UpdatePosition(_currentPosition);
- _lastSentPosition = _currentPosition;
- _player.transform.position = _currentPosition.ToLocalVector3(_mapCenter);
- _player.transform.rotation = Quaternion.Euler(0, (float)CalculateHeading(_lastSentPosition.ToLocalVector3(_mapCenter), _currentPosition.ToLocalVector3(_mapCenter)), 0);
- }
- }
- catch (Exception ex)
- {
- Debug.Log(ex);
- }
+ _gpsRetryCount++;
+ _GPSState = GPSState.Uninitialized;
}
else
{
- Debug.Log("GPS failed, trying again...");
- _GPSState = GPSState.Uninitialized;
+ Debug.LogWarning("GPS unavailable after max retries. Using last known position.");
+ // Keep _GPSState = Failed so we stop retrying
}
}
}
}
- catch (NullReferenceException ex) { Debug.Log(ex); }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[Input] positionCheck failed: {ex.Message}");
+ }
}
+
+ private void EnsureMapCenter()
+ {
+ if (_mapCenter.Lat != 0 || _mapCenter.Lon != 0)
+ return;
+
+ var md = _gameClient?.CurrentLobbyState?.MapData;
+ if (md != null)
+ _mapCenter = md.Center;
+ }
+
+ private void TrySendCurrentPosition()
+ {
+ bool moved = _currentPosition != _lastSentPosition;
+ bool keepAliveDue = (Time.time - _lastPositionSendTime) >= _positionKeepAliveSeconds;
+ if (!moved && !keepAliveDue)
+ return;
+
+ var previous = _lastSentPosition;
+ _gameClient.UpdatePosition(_currentPosition);
+ _lastSentPosition = _currentPosition;
+ _lastPositionSendTime = Time.time;
+
+ if (_player == null || (_mapCenter.Lat == 0 && _mapCenter.Lon == 0))
+ return;
+
+ var localCurrent = _currentPosition.ToLocalVector3(_mapCenter);
+ _player.transform.position = localCurrent;
+
+ if (previous.Lat == 0 && previous.Lon == 0)
+ return;
+
+ var heading = CalculateHeading(previous.ToLocalVector3(_mapCenter), localCurrent);
+ if (heading.HasValue)
+ _player.transform.rotation = Quaternion.Euler(0, (float)heading.Value, 0);
+ }
+
private void TestPlayerPosition()
{
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
{
- if (_currentPosition != _lastSentPosition)
- {
- _gameClient.UpdatePosition(_currentPosition);
- _lastSentPosition = _currentPosition;
- }
+ TrySendCurrentPosition();
}
catch
{
@@ -150,105 +715,212 @@ namespace Subsystems
}
private double? CalculateHeading(Vector3 first, Vector3 second)
{
- double? heading = null;
- if ((first - second).magnitude == 0)
- {
- return null;
- }
- else if (first.x == second.x && first.z < second.z)
- {
- return 0;
- }
- else if (first.x == second.x && first.z > second.z)
- {
- return 180;
- }
- else if (first.x > second.x && first.z == second.z)
- {
- return 270;
- }
- else if (first.x < second.x && first.z == second.z)
- {
- return 90;
- }
- else if (first.x < second.x && first.z < second.z)
- {
- heading = Math.Asin((second.z - first.z) / first.DistanceTo(second));
- return (heading * 180) / Math.PI;
- }
- else if (first.x < second.x && first.z > second.z)
- {
- heading = Math.Asin((second.z - first.z) / first.DistanceTo(second));
- return (heading * 180) / Math.PI + 180;
- }
- else if (first.x > second.x && first.z < second.z)
- {
- heading = Math.Asin((second.z - first.z) / first.DistanceTo(second));
- return (heading * 180) / Math.PI - 90;
- }
- else if (first.x > second.x && first.z > second.z)
- {
- heading = Math.Asin((second.z - first.z) / first.DistanceTo(second));
- return (heading * 180) / Math.PI - 90;
- }
- else
- {
- return heading;
- }
+ if ((first - second).magnitude < 0.0001f) return null;
+ float dx = second.x - first.x;
+ float dz = second.z - first.z;
+ float heading = Mathf.Atan2(dx, dz) * Mathf.Rad2Deg;
+ if (heading < 0) heading += 360f;
+ return heading;
}
IEnumerator InitiallizeGPS()
{
_GPSState = GPSState.Initializing;
+ _gpsInitStartTime = Time.time;
+ _lastGpsError = "";
+
+#if UNITY_ANDROID
+ // 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);
+ // Wait up to 10 seconds for user to respond to the permission dialog
+ float waited = 0f;
+ while (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation) && waited < 10f)
+ {
+ 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 = 10f;
- float updateDistanceInMeters = 10f;
+ 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;
}
- _GPSState = GPSState.Running;
- yield return _coroutineHost.StartCoroutine(GPSService());
- }
- IEnumerator GPSService()
- {
- // Check if the user has location service enabled.
-
- // If the connection failed this cancels location service use.
if (Input.location.status == LocationServiceStatus.Failed)
{
- Debug.LogError("Unable to determine device location");
+ _lastGpsError = "Unity Input.location reported Failed status";
+ Debug.LogError("[GPS] " + _lastGpsError);
+ _GPSState = GPSState.Failed;
yield break;
}
- else
+
+ _GPSState = GPSState.Running;
+ _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 the connection succeeded, this retrieves the device's current location and displays it in the Console window.
- _currentPosition = new Position(Input.location.lastData.latitude, Input.location.lastData.longitude);
- Debug.Log("Location: " + Input.location.lastData.latitude + " " + Input.location.lastData.longitude + " " + Input.location.lastData.altitude + " " + Input.location.lastData.horizontalAccuracy + " " + Input.location.lastData.timestamp);
- yield return new WaitForSeconds(5f);
+ if (_androidProvider.HasFix)
+ {
+ _currentPosition = new Position(_androidProvider.Lat, _androidProvider.Lon);
+ }
+ yield return new WaitForSeconds(0.5f);
}
- // Stops the location service if there is no need to query location updates continuously.
- yield return _coroutineHost.StartCoroutine(GPSService());
+ // 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;
+ yield break;
+ }
+
+ // Keep current GPS position fresh; sending is throttled in positionCheck().
+ var data = Input.location.lastData;
+ _currentPosition = new Position(data.latitude, data.longitude);
+ yield return new WaitForSeconds(0.5f);
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/Materials.meta b/Assets/Materials.meta
index c832773..266e4ff 100644
--- a/Assets/Materials.meta
+++ b/Assets/Materials.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 579723ff3377eaf4a9e50b2d20bbb490
+guid: 2230bf768ecb84610af77bea6cdd7074
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Assets/New Material.mat.meta b/Assets/New Material.mat.meta
index f1a4f93..a14f623 100644
--- a/Assets/New Material.mat.meta
+++ b/Assets/New Material.mat.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 14e7660f277f5414ab8b483f12ca6e3a
+guid: 7142a58f80866aba3ae4ffeb79d5f67c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
diff --git a/Assets/_Recovery.meta b/Assets/_Recovery.meta
index 62ed08f..ee2850e 100644
--- a/Assets/_Recovery.meta
+++ b/Assets/_Recovery.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: d8e0b736547e8cf4f98d5f7832d39867
+guid: b9c8f9e5bbf063b4fb1152966129a495
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Assets/host a join.meta b/Assets/host a join.meta
index 041d4a6..157039b 100644
--- a/Assets/host a join.meta
+++ b/Assets/host a join.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 6279204888b22964e9ddee5def64f698
+guid: 0f6331bfef5b3a00cb6f84141e60f9c4
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Assets/main menu.meta b/Assets/main menu.meta
index 7b5bd5d..2e2d688 100644
--- a/Assets/main menu.meta
+++ b/Assets/main menu.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 73f88b8c97995af4eab9d0126a697831
+guid: 43d195e24415843378eb1b53e9e1da18
folderAsset: yes
DefaultImporter:
externalObjects: {}