conflict partially resolved

This commit is contained in:
2026-05-17 11:31:00 +02:00
parent ab6263cb10
commit 023bddc91b
9 changed files with 1276 additions and 236 deletions

View File

@@ -1,4 +1,4 @@
using UnityEngine; using UnityEngine;
using GeoSus.Client; using GeoSus.Client;
using System; using System;
using System.Collections; using System.Collections;
@@ -16,6 +16,292 @@ namespace Subsystems
Running, Running,
Failed Failed
} }
/// <summary>
/// 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.
/// </summary>
public enum PositionSource
{
Auto,
GpsOnly,
NetworkOnly,
UnityInput,
EditorWasd,
}
#if UNITY_ANDROID && !UNITY_EDITOR
/// <summary>
/// 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.
/// </summary>
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<double>("getLatitude");
double lon = location.Call<double>("getLongitude");
long t = location.Call<long>("getTime");
string provider = "";
try { provider = location.Call<string>("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) { }
}
/// <summary>
/// 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.
/// </summary>
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<AndroidJavaObject>("currentActivity");
}
if (_activity == null) { error = "no current activity"; return false; }
_locationManager = _activity.Call<AndroidJavaObject>("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<AndroidJavaObject>("getLastKnownLocation", provider);
if (loc != null)
{
nonNullReturned = true;
double lat = loc.Call<double>("getLatitude");
double lon = loc.Call<double>("getLongitude");
long t = loc.Call<long>("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<bool>("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<AndroidJavaObject>("getProviders", true);
if (list == null) return "";
int size = list.Call<int>("size");
var parts = new System.Text.StringBuilder();
for (int i = 0; i < size; i++)
{
var name = list.Call<string>("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 class PositonExtensions
{ {
public static Position ToLocal(this Position position, Position center) public static Position ToLocal(this Position position, Position center)
@@ -51,34 +337,283 @@ namespace Subsystems
private GameObject _player; private GameObject _player;
private bool _testMode; 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 GPSState _GPSState = GPSState.Uninitialized;
private float _speed = 0.00001f; private float _speed = 0.00001f;
private Position _mapCenter; 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
/// <summary>Last known GPS position (for CreateLobby centre point)</summary>
public Position? LastKnownPosition => _currentPosition.Lat != 0 || _currentPosition.Lon != 0 ? _currentPosition : (Position?)null;
/// <summary>Current GPS state machine value (debug/diagnostic).</summary>
public string GpsStateName => _GPSState.ToString();
/// <summary>Last GPS error reason captured during init (empty if none).</summary>
public string LastGpsError => _lastGpsError ?? "";
/// <summary>Retry count out of max (debug/diagnostic).</summary>
public string GpsRetryProgress => $"{_gpsRetryCount}/{_maxGpsRetries}";
/// <summary>Currently selected position source (for UI cycle button).</summary>
public PositionSource CurrentSource => _currentSource;
/// <summary>Display name for the current source (for UI label).</summary>
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();
}
}
}
/// <summary>
/// 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.
/// </summary>
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) public GameManager_Input(GameClient gameClient, GameObject player, bool testMode)
{ {
_gameClient = gameClient; _gameClient = gameClient;
_player = player; _player = player;
_testMode = testMode; _testMode = testMode;
// CoroutineHost needs a MonoBehaviour on a real GameObject
var hostGO = new UnityEngine.GameObject("_CoroutineHost");
UnityEngine.Object.DontDestroyOnLoad(hostGO);
_coroutineHost = hostGO.AddComponent<CoroutineHost>();
// 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;
}
/// <summary>Called from OnSceneLoaded when Client.unity loads so the
/// Player capsule (which lives in Client.unity) can be wired at runtime.</summary>
public void SetPlayerObject(GameObject player) { _player = player; }
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Cycle through the available sources for tap-to-cycle UI.</summary>
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 { }
}
/// <summary>
/// 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.
/// </summary>
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() public void positionCheck()
{ {
var state = _gameClient?.CurrentLobbyState;
if (state == null || state.Phase != GamePhase.Playing)
return;
try 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 //Init blok
_currentPosition = _gameClient.CurrentLobbyState.MapData.Center; _currentPosition = state.MapData.Center;
_mapCenter = _gameClient.CurrentLobbyState.MapData.Center; _mapCenter = state.MapData.Center;
_lastSentPosition = _currentPosition; _lastSentPosition = _currentPosition;
} }
if (!SuppressWasd)
TestPlayerPosition(); TestPlayerPosition();
else
TrySendCurrentPosition(); // keep-alive only
} }
else else
{ {
@@ -93,54 +628,84 @@ namespace Subsystems
} }
else if (_GPSState == GPSState.Running) else if (_GPSState == GPSState.Running)
{ {
try EnsureMapCenter();
{ TrySendCurrentPosition();
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);
}
} }
else else
{ {
Debug.Log("GPS failed, trying again..."); Debug.Log("GPS failed, trying again...");
if (_gpsRetryCount < _maxGpsRetries)
{
_gpsRetryCount++;
_GPSState = GPSState.Uninitialized; _GPSState = GPSState.Uninitialized;
} }
else
{
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() private void TestPlayerPosition()
{ {
double x = Input.GetAxis("Horizontal"); double x = Input.GetAxis("Horizontal");
double y = Input.GetAxis("Vertical"); double y = Input.GetAxis("Vertical");
Debug.Log($"Input: {x}, {y}");
_currentPosition = new Position( _lastSentPosition.Lat + y * _speed, _lastSentPosition.Lon + x * _speed); _currentPosition = new Position( _lastSentPosition.Lat + y * _speed, _lastSentPosition.Lon + x * _speed);
Debug.Log($"Current Position: {_currentPosition.Lat}, {_currentPosition.Lon}");
var localCurrent = _currentPosition.ToLocalVector3(_mapCenter); var localCurrent = _currentPosition.ToLocalVector3(_mapCenter);
Debug.Log($"Local Current Position: {localCurrent}");
var heading = CalculateHeading(_lastSentPosition.ToLocalVector3(_mapCenter), localCurrent); var heading = CalculateHeading(_lastSentPosition.ToLocalVector3(_mapCenter), localCurrent);
if (heading != null) if (heading != null)
{ {
Debug.Log($"Heading: {heading}"); if (_player != null)
_player.transform.rotation = Quaternion.Euler(0, (float)heading, 0); _player.transform.rotation = Quaternion.Euler(0, (float)heading, 0);
} }
if (_player != null)
_player.transform.position = localCurrent; _player.transform.position = localCurrent;
try try
{ {
if (_currentPosition != _lastSentPosition) TrySendCurrentPosition();
{
_gameClient.UpdatePosition(_currentPosition);
_lastSentPosition = _currentPosition;
}
} }
catch catch
{ {
@@ -150,105 +715,212 @@ namespace Subsystems
} }
private double? CalculateHeading(Vector3 first, Vector3 second) private double? CalculateHeading(Vector3 first, Vector3 second)
{ {
double? heading = null; if ((first - second).magnitude < 0.0001f) return null;
if ((first - second).magnitude == 0) float dx = second.x - first.x;
{ float dz = second.z - first.z;
return null; float heading = Mathf.Atan2(dx, dz) * Mathf.Rad2Deg;
} if (heading < 0) heading += 360f;
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; return heading;
} }
}
IEnumerator InitiallizeGPS() IEnumerator InitiallizeGPS()
{ {
_GPSState = GPSState.Initializing; _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) 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 desiredAccuracyInMeters = 5f;
float updateDistanceInMeters = 10f; float updateDistanceInMeters = 1f;
Input.location.Start(desiredAccuracyInMeters, updateDistanceInMeters); Input.location.Start(desiredAccuracyInMeters, updateDistanceInMeters);
// Waits until the location service initializes int maxWait = _gpsInitTimeoutSeconds;
int maxWait = 20;
while (Input.location.status == LocationServiceStatus.Initializing && maxWait > 0) while (Input.location.status == LocationServiceStatus.Initializing && maxWait > 0)
{ {
yield return new WaitForSeconds(1); yield return new WaitForSeconds(1);
maxWait--; maxWait--;
} }
// If the service didn't initialize in 20 seconds this cancels location service use.
if (maxWait < 1) 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; _GPSState = GPSState.Failed;
Debug.LogError("Timed out");
yield break; 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) 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; yield break;
} }
else
{ _GPSState = GPSState.Running;
// If the connection succeeded, this retrieves the device's current location and displays it in the Console window. _gpsRetryCount = 0;
_currentPosition = new Position(Input.location.lastData.latitude, Input.location.lastData.longitude); _coroutineHost.StartCoroutine(GPSService());
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);
} }
// Stops the location service if there is no need to query location updates continuously. #if UNITY_ANDROID && !UNITY_EDITOR
yield return _coroutineHost.StartCoroutine(GPSService()); /// <summary>
/// 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.
/// </summary>
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;
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);
}
} }
} }
} }

View File

@@ -3,9 +3,8 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using UnityEditor; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.Localization.Pseudo;
using UnityEngine.UI; using UnityEngine.UI;
@@ -13,8 +12,8 @@ namespace Subsystems{
[System.Serializable] [System.Serializable]
public class BuildingSettings public class BuildingSettings
{ {
public Material ResidentalBuildingsMat; public Material ResidentialBuildingsMat;
public float ResidentalBuildingHeight; public float ResidentialBuildingHeight;
public Material CommercialBuildingsMat; public Material CommercialBuildingsMat;
public float CommercialBuildingHeight; public float CommercialBuildingHeight;
public Material IndustrialBuildingsMat; public Material IndustrialBuildingsMat;
@@ -56,7 +55,6 @@ namespace Subsystems{
public Material GrassMat; public Material GrassMat;
public Material WaterMat; public Material WaterMat;
public Material DefaultMat; public Material DefaultMat;
public Material GlobalSkybox; // Sem v Unity přetáhneš svůj Panoramic materiál
} }
public class GameManager_Map public class GameManager_Map
{ {
@@ -67,6 +65,43 @@ namespace Subsystems{
private PathwaySettings _pathwaySettings; private PathwaySettings _pathwaySettings;
private AreaSettings _areaSettings; private AreaSettings _areaSettings;
private const float _metersPerUnit = 1f; private const float _metersPerUnit = 1f;
// ── Layer Y separation (single source of truth for vertical stacking) ───
// Areas at the bottom, paths above areas, buildings extruded upward from
// their own base, POIs floating well above everything else. Z-fighting
// happens when adjacent geometry shares a Y; these constants keep each
// logical layer at a distinct elevation.
private const float kAreaBaseY = 0.10f;
private const float kPathY = 0.30f;
private const float kBuildingBaseY = 0.50f;
private const float kPoiY = 2.00f;
// Render-queue forcing was tried in P3 to disambiguate same-Y geometry
// but turned out to be the cause of the "blank map in mobile game view,
// fine in scene view" regression: forcing transparent-class shaders
// (default queue 3000+) into the Geometry range (2000-2150) breaks
// their depth-write/blend assumptions on mobile shader paths. The
// editor's scene view masks it because it uses different render paths
// and post-process is off there. Queue forcing removed in P8;
// disambiguation is now via Y-layering + per-area Y-stagger alone,
// which the depth buffer resolves correctly even on weak mobile GPUs.
// ── Marker sizing (top-down camera, units = meters) ─────────────────
// The camera's orthographic size pushes "1 meter" to a small fraction
// of the screen. Markers need to be visibly larger than buildings'
// footprints for instant recognition.
private const float kMarkerHeight = 8f; // pillar height
private const float kMarkerRadius = 3f; // pillar radius (cylinder X/Z)
private const float kMarkerY = 4f; // base Y so pillar centers ~mid-height
private const float kLabelY = 9f; // text label sits above pillar top
private const float kLabelFontSize = 14f; // 3D text size in world units
// Runtime marker collections
private Dictionary<string, GameObject> _taskMarkers = new Dictionary<string, GameObject>();
private Dictionary<string, GameObject> _bodyMarkers = new Dictionary<string, GameObject>();
private Dictionary<string, GameObject> _playerAvatars = new Dictionary<string, GameObject>();
private List<GameObject> _sabotageMarkers = new List<GameObject>();
public GameManager_Map(GameClient gameClient, GameObject mapCenterPoint, BuildingSettings buildingSettings, PathwaySettings pathwaySettings, AreaSettings areaSettings) public GameManager_Map(GameClient gameClient, GameObject mapCenterPoint, BuildingSettings buildingSettings, PathwaySettings pathwaySettings, AreaSettings areaSettings)
{ {
_gameClient = gameClient; _gameClient = gameClient;
@@ -75,8 +110,25 @@ namespace Subsystems{
_pathwaySettings = pathwaySettings; _pathwaySettings = pathwaySettings;
_areaSettings = areaSettings; _areaSettings = areaSettings;
} }
public bool IsSceneReady => _mapCenterPoint != null;
/// <summary>Called from OnSceneLoaded when Client.unity is loaded so the
/// MapCenterPoint (which lives in Client.unity) can be wired at runtime.</summary>
public void SetMapCenterPoint(GameObject go) { _mapCenterPoint = go; }
public void BuildMap() public void BuildMap()
{ {
if (_mapCenterPoint == null)
{
Debug.LogWarning("[Map] BuildMap skipped: MapCenterPoint is not yet bound.");
return;
}
if (_gameClient?.CurrentLobbyState?.MapData == null)
{
Debug.LogWarning("[Map] BuildMap skipped: no MapData in CurrentLobbyState.");
return;
}
ClearChildren(); ClearChildren();
_centerPosition = _gameClient.CurrentLobbyState.MapData.Center; _centerPosition = _gameClient.CurrentLobbyState.MapData.Center;
GameObject buildingsRoot = new GameObject("Buildings"); GameObject buildingsRoot = new GameObject("Buildings");
@@ -85,8 +137,8 @@ namespace Subsystems{
GameObject pathRoot = new GameObject("Pathways"); GameObject pathRoot = new GameObject("Pathways");
pathRoot.transform.parent = _mapCenterPoint.transform; pathRoot.transform.parent = _mapCenterPoint.transform;
//GameObject areaRoot = new GameObject("Areas"); GameObject areaRoot = new GameObject("Areas");
//areaRoot.transform.parent = _mapCenterPoint.transform; areaRoot.transform.parent = _mapCenterPoint.transform;
foreach (var building in _gameClient.CurrentLobbyState.MapData.GetBuildings()) foreach (var building in _gameClient.CurrentLobbyState.MapData.GetBuildings())
{ {
@@ -105,20 +157,137 @@ namespace Subsystems{
GameObject p = BuildPathwayMesh(path); GameObject p = BuildPathwayMesh(path);
p.transform.parent = pathRoot.transform; p.transform.parent = pathRoot.transform;
} }
/*foreach (var area in _gameClient.CurrentLobbyState.MapData.GetAreas()) foreach (var area in _gameClient.CurrentLobbyState.MapData.GetAreas())
{ {
GameObject a = BuildAreaMesh(area); GameObject a = BuildAreaMesh(area);
a.transform.parent = areaRoot.transform; a.transform.parent = areaRoot.transform;
}*/
//TODO: POIs
// NASTAVENÍ SKYBOXU
if (_areaSettings.GlobalSkybox != null)
{
RenderSettings.skybox = _areaSettings.GlobalSkybox;
} }
else
GameObject poiRoot = new GameObject("POIs");
poiRoot.transform.parent = _mapCenterPoint.transform;
int poiCount = 0;
foreach (var poi in _gameClient.CurrentLobbyState.MapData.GetPOIs())
{ {
Debug.LogWarning("Skybox material není přiřazen v AreaSettings!"); GameObject p = BuildPOIMarker(poi);
if (p != null) { p.transform.parent = poiRoot.transform; poiCount++; }
}
// Diagnostic - if the user reports "map missing in game view" but
// the counts here are non-zero, the bug is camera/culling related,
// not a build issue.
int buildings = _gameClient.CurrentLobbyState.MapData.GetBuildings()?.Count ?? 0;
int paths = _gameClient.CurrentLobbyState.MapData.GetPathways()?.Count ?? 0;
int areas = _gameClient.CurrentLobbyState.MapData.GetAreas()?.Count ?? 0;
Debug.Log($"[Map] BuildMap done: {buildings} buildings, {paths} paths, " +
$"{areas} areas, {poiCount} POIs. MapCenterPoint={_mapCenterPoint.name} " +
$"layer={_mapCenterPoint.layer} pos={_mapCenterPoint.transform.position} " +
$"scale={_mapCenterPoint.transform.localScale}");
}
/// <summary>
/// Build a tall, brightly-colored pillar for a Point of Interest with
/// a 3D text label above it (e.g. "FOOD", "SHOP"). The label is laid
/// flat on the XZ plane facing UP so it reads correctly under the
/// orthogonal top-down camera.
/// </summary>
private GameObject BuildPOIMarker(MapPOI poi)
{
if (poi == null) return null;
var color = ColorForPOI(poi.POIType);
string label = LabelForPOI(poi.POIType);
var pos = poi.Location.ToLocalVector3(_centerPosition);
return CreateMarkerWithLabel($"POI_{poi.POIType}_{poi.Id}", pos, color, label);
}
/// <summary>
/// Shared marker builder: tall colored cylinder pillar + 3D text label
/// above it. Used by POIs, tasks, bodies, and sabotage stations so
/// they all share a visual language ("colored pillar with a name").
/// </summary>
private GameObject CreateMarkerWithLabel(string name, Vector3 worldPos, Color color, string label)
{
var go = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
go.name = name;
// Strip the auto-added collider - markers are visual only.
var col = go.GetComponent<Collider>();
if (col != null) UnityEngine.Object.Destroy(col);
go.transform.position = worldPos + Vector3.up * kMarkerY;
// Cylinder's default unit is 2 tall, 1 wide. Scale Y by half of
// kMarkerHeight (built-in is 2 units), X/Z by kMarkerRadius.
go.transform.localScale = new Vector3(kMarkerRadius, kMarkerHeight * 0.5f, kMarkerRadius);
var mr = go.GetComponent<MeshRenderer>();
if (mr != null)
{
// One .material access -> single clone of the primitive's
// default mat. Don't touch renderQueue (P3 regression cause).
var inst = mr.material;
if (inst != null) inst.color = color;
}
// 3D text label - lays flat on top of the pillar facing up.
// Parented to the marker so it follows position changes.
var labelGO = new GameObject("Label");
labelGO.transform.SetParent(go.transform, worldPositionStays: false);
// Local Y offset: pillar's local scale Y is kMarkerHeight/2, but
// the cylinder primitive is 2 units tall in local space, so its
// top is at local +1. Label sits a hair above that.
labelGO.transform.localPosition = new Vector3(0, 1.05f, 0);
// Rotate 90 around X so the text quad's normal points +Y (toward
// the top-down camera). The default TMP forward is +Z.
labelGO.transform.localRotation = Quaternion.Euler(90f, 0f, 0f);
// Compensate for the cylinder's non-uniform parent scale so the
// text size in world units matches kLabelFontSize regardless of
// how the pillar was scaled.
labelGO.transform.localScale = new Vector3(
1f / kMarkerRadius,
1f / (kMarkerHeight * 0.5f),
1f / kMarkerRadius);
var tmp = labelGO.AddComponent<TextMeshPro>();
tmp.text = label;
tmp.fontSize = kLabelFontSize;
tmp.color = Color.white;
tmp.fontStyle = FontStyles.Bold;
tmp.alignment = TextAlignmentOptions.Center;
tmp.outlineColor = Color.black;
tmp.outlineWidth = 0.25f;
// Reasonable bounds so the text mesh isn't auto-clipped.
var rt = tmp.rectTransform;
rt.sizeDelta = new Vector2(20, 4);
return go;
}
private static Color ColorForPOI(MapPOIType type)
{
switch (type)
{
case MapPOIType.FoodDrink: return new Color(1.00f, 0.55f, 0.00f); // orange
case MapPOIType.Shop: return new Color(0.20f, 0.60f, 1.00f); // blue
case MapPOIType.Health: return new Color(0.96f, 0.27f, 0.27f); // red
case MapPOIType.Transport: return new Color(0.85f, 0.85f, 0.20f); // yellow
case MapPOIType.Culture: return new Color(0.65f, 0.30f, 0.95f); // purple
case MapPOIType.Landmark: return new Color(0.95f, 0.85f, 0.40f); // gold
case MapPOIType.Recreation: return new Color(0.30f, 0.85f, 0.30f); // green
default: return new Color(0.75f, 0.75f, 0.80f); // muted grey
}
}
private static string LabelForPOI(MapPOIType type)
{
switch (type)
{
case MapPOIType.FoodDrink: return "FOOD";
case MapPOIType.Shop: return "SHOP";
case MapPOIType.Health: return "HEALTH";
case MapPOIType.Transport: return "TRANSIT";
case MapPOIType.Culture: return "CULTURE";
case MapPOIType.Landmark: return "LANDMARK";
case MapPOIType.Recreation: return "PARK";
default: return "POI";
} }
} }
void ClearChildren() void ClearChildren()
@@ -136,9 +305,12 @@ namespace Subsystems{
{ {
var building = new GameObject($"Building_{b.Name ?? "Unknown"}"); var building = new GameObject($"Building_{b.Name ?? "Unknown"}");
// Výpočet středu budovy // Výpočet středu budovy. Lift the base above kPathY so building
// walls visibly extrude *upward* from above the road/area layer
// instead of starting at ground (which made them clip into paved
// areas that share their footprint).
Vector3 center = CalculatePolygonCenter(b.Outline); Vector3 center = CalculatePolygonCenter(b.Outline);
building.transform.position = center; building.transform.position = center + Vector3.up * kBuildingBaseY;
// Vytvoření mesh pro budovu // Vytvoření mesh pro budovu
MeshFilter meshFilter = building.AddComponent<MeshFilter>(); MeshFilter meshFilter = building.AddComponent<MeshFilter>();
@@ -149,8 +321,8 @@ namespace Subsystems{
switch (b.BuildingType.ToLower()) switch (b.BuildingType.ToLower())
{ {
case "residential": case "residential":
mat = _buildingSettings.ResidentalBuildingsMat; mat = _buildingSettings.ResidentialBuildingsMat;
height = _buildingSettings.ResidentalBuildingHeight; height = _buildingSettings.ResidentialBuildingHeight;
break; break;
case "commercial": case "commercial":
mat = _buildingSettings.CommercialBuildingsMat; mat = _buildingSettings.CommercialBuildingsMat;
@@ -169,8 +341,12 @@ namespace Subsystems{
meshFilter.mesh = mesh; meshFilter.mesh = mesh;
//TODO: material by type //TODO: material by type
// Použijeme barvu podle typu budovy // Použijeme barvu podle typu budovy. Use sharedMaterial to keep
meshRenderer.material = mat; // the project's Material asset reference - no clone, no leak.
// Y-position alone disambiguates building geometry from area/path
// layers; we don't need renderQueue overrides (which broke mobile
// rendering for transparent-class shaders in P3).
meshRenderer.sharedMaterial = mat;
// Přidání collideru pro interakci // Přidání collideru pro interakci
building.AddComponent<MeshCollider>(); building.AddComponent<MeshCollider>();
@@ -229,15 +405,19 @@ namespace Subsystems{
break; break;
} }
line.material = mat; // sharedMaterial avoids the LineRenderer cloning the project's
// shared path Material on every BuildMap call. Queue overrides
// dropped (P3 mobile-render regression cause).
line.sharedMaterial = mat;
line.widthMultiplier = width; line.widthMultiplier = width;
// Nastavení bodů cesty // Nastavení bodů cesty - kPathY sits above all area polygons but
// below building bases, so paths visibly run on top of areas.
line.positionCount = w.Points.Count; line.positionCount = w.Points.Count;
for (int i = 0; i < w.Points.Count; i++) for (int i = 0; i < w.Points.Count; i++)
{ {
Vector3 pos = w.Points[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center); Vector3 pos = w.Points[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center);
pos.y = 0.1f; // Mírně nad zemí pos.y = kPathY;
line.SetPosition(i, pos); line.SetPosition(i, pos);
} }
return path; return path;
@@ -280,13 +460,58 @@ namespace Subsystems{
break; break;
} }
meshRenderer.material = mat; // sharedMaterial: no per-area material clone. Render-queue forcing
// dropped in P8 (caused mobile-render regression). The Y-stagger
// below alone now drives "smaller polygon on top of larger one"
// depth ordering - which is what the depth buffer was always
// designed to do, and works on mobile GPUs with weak precision
// because the stagger spread (0.04 units) is well above any
// reasonable depth-buffer epsilon.
meshRenderer.sharedMaterial = mat;
area.transform.position = new Vector3(0, 0.05f, 0); // Těsně nad zemí // Y stagger: smaller polygons sit a hair higher than larger ones,
// so depth-test draws them on top of bigger area polygons they sit
// inside (e.g. a playground inside a park). Total spread is 0.04
// units - visually invisible but plenty for the depth buffer.
float yStagger = ComputeAreaYStagger(a.Outline);
area.transform.position = new Vector3(0, kAreaBaseY + yStagger, 0);
return area; return area;
} }
//TODO: POIs
/// <summary>
/// Returns a non-negative size proxy used to bucket areas by footprint.
/// Larger polygons return higher numbers; used inversely for queue/Y.
/// </summary>
private float AreaSizeBucket(List<Position> outline)
{
if (outline == null || outline.Count < 3) return 1f;
// Cheap bbox area in lat-lon space scaled by 1e6 - we only need a
// monotonic ordering, not a real geographic area.
double minLat = outline[0].Lat, maxLat = outline[0].Lat;
double minLon = outline[0].Lon, maxLon = outline[0].Lon;
for (int i = 1; i < outline.Count; i++)
{
if (outline[i].Lat < minLat) minLat = outline[i].Lat;
if (outline[i].Lat > maxLat) maxLat = outline[i].Lat;
if (outline[i].Lon < minLon) minLon = outline[i].Lon;
if (outline[i].Lon > maxLon) maxLon = outline[i].Lon;
}
double bbox = (maxLat - minLat) * (maxLon - minLon) * 1e6;
return (float)System.Math.Max(0.001, bbox);
}
/// <summary>
/// Smaller areas get a higher Y so they render on top of any larger
/// area they overlap. Returns a value in [0, 0.04] units.
/// </summary>
private float ComputeAreaYStagger(List<Position> outline)
{
float bucket = AreaSizeBucket(outline);
// Inverse mapping: huge area -> 0, tiny area -> 0.04.
float t = Mathf.Clamp01(1f - bucket / (bucket + 50f));
return t * 0.04f;
}
#endregion #endregion
#region Polygon Utils #region Polygon Utils
private Vector3 CalculatePolygonCenter(List<Position> points) private Vector3 CalculatePolygonCenter(List<Position> points)
@@ -298,19 +523,52 @@ namespace Subsystems{
} }
return center / points.Count; return center / points.Count;
} }
/// <summary>
/// Signed XZ shoelace area for a polygon expressed in local Vector3.
/// Positive = CCW (Unity left-handed Y-up: upward-facing normal),
/// negative = CW (downward-facing normal -> top face invisible from
/// above unless we reverse the winding before triangulating).
/// </summary>
private static float PolygonSignedAreaXZ(List<Vector3> verts)
{
float area = 0f;
int n = verts.Count;
for (int i = 0; i < n; i++)
{
var a = verts[i];
var b = verts[(i + 1) % n];
area += (b.x - a.x) * (a.z + b.z);
}
return area * 0.5f;
}
private Mesh CreateExtrudedPolygonMesh(List<Position> outline, float height) private Mesh CreateExtrudedPolygonMesh(List<Position> outline, float height)
{ {
Mesh mesh = new Mesh(); Mesh mesh = new Mesh();
// Reject degenerates - Recast/Overpass can hand back 1-2 vertex
// outlines on broken ways. Empty mesh -> renderer draws nothing,
// safer than a malformed triangle list.
if (outline == null || outline.Count < 3) return mesh;
// Convert to local space first so we can run a winding check, then
// reverse if needed. Without this, CW outlines from Overpass yield
// downward-facing top normals and the building roof is invisible
// from the top-down map camera.
int vertexCount = outline.Count; int vertexCount = outline.Count;
var localVerts = new List<Vector3>(vertexCount);
Vector3 center = CalculatePolygonCenter(outline);
for (int i = 0; i < vertexCount; i++)
localVerts.Add(outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center);
if (PolygonSignedAreaXZ(localVerts) < 0f)
localVerts.Reverse();
// Vertices - spodní a horní podstava // Vertices - spodní a horní podstava
Vector3[] vertices = new Vector3[vertexCount * 2]; Vector3[] vertices = new Vector3[vertexCount * 2];
Vector3 center = CalculatePolygonCenter(outline);
for (int i = 0; i < vertexCount; i++) for (int i = 0; i < vertexCount; i++)
{ {
Vector3 pos = outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center; Vector3 pos = localVerts[i];
vertices[i] = pos; // Spodní vertices[i] = pos; // Spodní
vertices[i + vertexCount] = pos + Vector3.up * height; // Horní vertices[i + vertexCount] = pos + Vector3.up * height; // Horní
} }
@@ -354,26 +612,31 @@ namespace Subsystems{
{ {
Mesh mesh = new Mesh(); Mesh mesh = new Mesh();
int vertexCount = outline.Count; // Reject degenerates (matches CreateExtrudedPolygonMesh).
Vector3[] vertices = new Vector3[vertexCount]; if (outline == null || outline.Count < 3) return mesh;
Vector3 center = CalculatePolygonCenter(outline);
int vertexCount = outline.Count;
var localVerts = new List<Vector3>(vertexCount);
Vector3 center = CalculatePolygonCenter(outline);
for (int i = 0; i < vertexCount; i++) for (int i = 0; i < vertexCount; i++)
{ localVerts.Add(outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center);
vertices[i] = outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center;
} // Force CCW so RecalculateNormals produces an upward-facing normal.
// CW polygons from Overpass would otherwise render as black voids
// when the top-down camera looks at their back face.
if (PolygonSignedAreaXZ(localVerts) < 0f)
localVerts.Reverse();
Vector3[] vertices = localVerts.ToArray();
// Triangulace - fan pattern // Triangulace - fan pattern
List<int> triangles = new List<int>(); List<int> triangles = new List<int>();
if (vertexCount >= 3)
{
for (int i = 1; i < vertexCount - 1; i++) for (int i = 1; i < vertexCount - 1; i++)
{ {
triangles.Add(0); triangles.Add(0);
triangles.Add(i); triangles.Add(i);
triangles.Add(i + 1); triangles.Add(i + 1);
} }
}
mesh.vertices = vertices; mesh.vertices = vertices;
mesh.triangles = triangles.ToArray(); mesh.triangles = triangles.ToArray();
@@ -382,5 +645,164 @@ namespace Subsystems{
return mesh; return mesh;
} }
#endregion #endregion
#region Markers
public void CreateTaskMarkers(List<GeoSus.Client.GameTask> tasks)
{
if (_mapCenterPoint == null) return;
if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0)
{
var md = _gameClient?.CurrentLobbyState?.MapData;
if (md != null) _centerPosition = md.Center;
}
if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0) return;
var taskColor = new Color(0.20f, 0.95f, 0.55f); // bright green - "GO HERE"
foreach (var task in tasks)
{
if (_taskMarkers.ContainsKey(task.TaskId)) continue;
var pos = task.Location.ToLocalVector3(_centerPosition);
var go = CreateMarkerWithLabel($"Task_{task.TaskId}", pos, taskColor, "TASK");
go.transform.parent = _mapCenterPoint.transform;
// Pulsing point light so the task literally glows on the map.
var light = go.AddComponent<Light>();
light.color = taskColor;
light.intensity = 3f;
light.range = 25f;
_taskMarkers[task.TaskId] = go;
}
}
public void RemoveTaskMarker(string taskId)
{
if (_taskMarkers.TryGetValue(taskId, out var go))
{
UnityEngine.Object.Destroy(go);
_taskMarkers.Remove(taskId);
}
}
public void CreateBodyMarker(string bodyId, Position location)
{
if (_mapCenterPoint == null) return;
if (_bodyMarkers.ContainsKey(bodyId)) return;
var pos = location.ToLocalVector3(_centerPosition);
// Bright red pillar with "BODY" label - players need to see this
// from across the map to call it in.
var go = CreateMarkerWithLabel($"Body_{bodyId}", pos,
new Color(0.96f, 0.18f, 0.18f), "BODY");
go.transform.parent = _mapCenterPoint?.transform;
_bodyMarkers[bodyId] = go;
}
public void ClearBodyMarkers()
{
foreach (var go in _bodyMarkers.Values)
if (go) UnityEngine.Object.Destroy(go);
_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<string, PlayerPositionInfo> positions, string myUuid)
{
if (_mapCenterPoint == null) return;
if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0)
{
var md = _gameClient?.CurrentLobbyState?.MapData;
if (md != null) _centerPosition = md.Center;
}
if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0) return;
foreach (var kvp in positions)
{
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;
// Strip the auto-collider - avatars are visual only and the
// collider would interact with the map's MeshColliders.
var col = go.GetComponent<Collider>();
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<Light>();
halo.color = new Color(0.30f, 1.00f, 0.55f); // matches green capsule color
halo.intensity = kLocalPlayerHaloIntensity;
halo.range = kLocalPlayerHaloRange;
}
_playerAvatars[uuid] = go;
}
// 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<MeshRenderer>();
if (mr)
{
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;
}
}
}
public void CreateSabotageMarkers(List<RepairStationInfo> stations)
{
var color = new Color(1.0f, 0.55f, 0.0f); // strong orange = repair urgency
foreach (var station in stations)
{
var pos = station.Location.ToLocalVector3(_centerPosition);
var go = CreateMarkerWithLabel($"Sabotage_{station.StationId}", pos,
color, "REPAIR");
go.transform.parent = _mapCenterPoint?.transform;
// Repair stations also pulse light so impostors and crew see
// the urgency from across the map.
var light = go.AddComponent<Light>();
light.color = color;
light.intensity = 4f;
light.range = 30f;
_sabotageMarkers.Add(go);
}
}
public void ClearSabotageMarkers()
{
foreach (var go in _sabotageMarkers)
if (go) UnityEngine.Object.Destroy(go);
_sabotageMarkers.Clear();
}
#endregion
} }
} }

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 9f4c3c97db77f7847a963acfa80db83b guid: 1bc3c07f160332843b2a60af3513f7f6
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 3107737185c186849a570faa7cc4e450 guid: 2230bf768ecb84610af77bea6cdd7074
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 6c0969b4b8e20bf459615000428d9af7 guid: 7142a58f80866aba3ae4ffeb79d5f67c
NativeFormatImporter: NativeFormatImporter:
externalObjects: {} externalObjects: {}
mainObjectFileID: 0 mainObjectFileID: 0

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: f88cc6b9941591e408d1d5c8795128dd guid: b9c8f9e5bbf063b4fb1152966129a495
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: c6a2427f795ab6945901be191f666613 guid: 0f6331bfef5b3a00cb6f84141e60f9c4
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 08d4c3d840ece7c49bcf346bfac58503 guid: 43d195e24415843378eb1b53e9e1da18
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}