Added better GPS handling, Improved test mode, Fixed naming of players not being broadcast until a new HelloClient, Fixed lobby restarts, Fixed task handling
This commit is contained in:
@@ -2,29 +2,29 @@ C/C++ Structured Logb
|
||||
`
|
||||
^E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\additional_project_files.txtC
|
||||
A
|
||||
?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint Øÿ‚ØÜ3› ýêôÓÜ3_
|
||||
?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint ËÀ°…Ý3› ýêôÓÜ3_
|
||||
]
|
||||
[E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\android_gradle_build.json Øÿ‚ØÜ3Þ
|
||||
[E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\android_gradle_build.json ËÀ°…Ý3Þ
|
||||
…ëôÓÜ3d
|
||||
b
|
||||
b
|
||||
`E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\android_gradle_build_mini.json ËÀ°…Ý3È ÃëôÓÜ3Q
|
||||
O
|
||||
O
|
||||
ME:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build.ninja ËÀ°…Ý3Ü¡ ÜéôÓÜ3U
|
||||
S
|
||||
S
|
||||
QE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build.ninja.txt ËÀ°…Ý3Z
|
||||
X
|
||||
X
|
||||
VE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\build_file_index.txt ËÀ°…Ý3ß ËëôÓÜ3[
|
||||
Y
|
||||
Y
|
||||
WE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\compile_commands.json ËÀ°…Ý3£U ÚéôÓÜ3_
|
||||
]
|
||||
]
|
||||
[E:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\compile_commands.json.bin ËÀ°…Ý3 Ä ÚéôÓÜ3e
|
||||
c
|
||||
c
|
||||
aE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\metadata_generation_command.txt ËÀ°…Ý3
|
||||
ß ÊëôÓÜ3X
|
||||
V
|
||||
V
|
||||
TE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\prefab_config.json ËÀ°…Ý3ã ÊëôÓÜ3]
|
||||
[
|
||||
[
|
||||
YE:\Code\GeoSus\GeoSusGame\.utmp\RelWithDebInfo\6b10225s\arm64-v8a\symbol_folder_index.txt ËÀ°…Ý3Š ËëôÓÜ3l
|
||||
j
|
||||
j
|
||||
hE:\Code\GeoSus\GeoSusGame\Library\Bee\Android\Prj\IL2CPP\Gradle\unityLibrary\src\main\cpp\CMakeLists.txt ËÀ°…Ý3
|
||||
|
||||
|
||||
@@ -533,6 +533,11 @@ public class GameClient : IDisposable
|
||||
|
||||
public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500, GameSettingsOverrides? settings = null)
|
||||
{
|
||||
// DisplayName is sent on every CreateLobby/JoinLobby so the server
|
||||
// picks up the live nickname (typed into the input field after the
|
||||
// ClientHello handshake fired). Without this the server uses the
|
||||
// ClientHello-time name, which is the GameManager prefab default
|
||||
// for any user who immediately created/joined a lobby.
|
||||
Send(new CreateLobby
|
||||
{
|
||||
PlayAreaCenter = center,
|
||||
@@ -540,7 +545,8 @@ public class GameClient : IDisposable
|
||||
ImpostorCount = impostorCount,
|
||||
TaskCount = taskCount,
|
||||
Password = password,
|
||||
Settings = settings
|
||||
Settings = settings,
|
||||
DisplayName = DisplayName
|
||||
});
|
||||
}
|
||||
|
||||
@@ -549,7 +555,8 @@ public class GameClient : IDisposable
|
||||
Send(new JoinLobby
|
||||
{
|
||||
JoinCode = joinCode.ToUpperInvariant(),
|
||||
Password = password
|
||||
Password = password,
|
||||
DisplayName = DisplayName
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,16 @@ public class CreateLobby : Message
|
||||
/// </summary>
|
||||
[JsonProperty("settings")]
|
||||
public GameSettingsOverrides? Settings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Live host display name from the nickname input field at
|
||||
/// the moment of CreateLobby. ClientHello-time name is stale because
|
||||
/// the handshake fires before the user has typed anything; this lets
|
||||
/// the server pick up the freshly-typed name without a separate
|
||||
/// rename round-trip.
|
||||
/// </summary>
|
||||
[JsonProperty("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
public class CreateLobbyResponse : Message
|
||||
@@ -228,6 +238,13 @@ public class JoinLobby : Message
|
||||
|
||||
[JsonProperty("password")]
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Live joiner display name from the nickname input field
|
||||
/// at the moment of Join. See CreateLobby.DisplayName for rationale.
|
||||
/// </summary>
|
||||
[JsonProperty("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
public class JoinLobbyResponse : Message
|
||||
|
||||
@@ -66,10 +66,39 @@ public class GameManager : MonoBehaviour
|
||||
|
||||
[Header("Debug")]
|
||||
public bool testMode = false;
|
||||
private GameClient _secondClient;
|
||||
private GameClient _thirdClient;
|
||||
private GameManager_Network _secondNetwork;
|
||||
private GameManager_Network _thirdNetwork;
|
||||
/// <summary>
|
||||
/// When true, draw a small GPS status banner across the top of every
|
||||
/// screen. Useful for diagnosing why CreateLobby is blocked or why a
|
||||
/// joiner's position isn't updating - failures otherwise only show up
|
||||
/// in logcat which most users can't reach. Toggle off for release.
|
||||
/// </summary>
|
||||
public bool showGPSDebugOverlay = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of in-process test client bots to spawn alongside the host
|
||||
/// when testMode is on. Each gets its own GameClient + Network and
|
||||
/// joins the host's lobby automatically. Bots are switchable via
|
||||
/// number keys 1..N (host = 0). Default 3 keeps memory reasonable;
|
||||
/// bump for stress-testing voting / sabotage flows.
|
||||
/// </summary>
|
||||
public int testClientCount = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Per-bot network + display-name + sim-position state. The active slot
|
||||
/// (host = 0, bots = 1..N) gets WASD on the next tick.
|
||||
/// </summary>
|
||||
private class TestBot
|
||||
{
|
||||
public GameClient Client;
|
||||
public GameManager_Network Network;
|
||||
public string DisplayName;
|
||||
public GeoSus.Client.Position SimPosition;
|
||||
public bool Joined;
|
||||
public float LastSendTime;
|
||||
}
|
||||
private System.Collections.Generic.List<TestBot> _testBots = new System.Collections.Generic.List<TestBot>();
|
||||
/// <summary>Slot 0 = host (real player), 1..N = test bot index.</summary>
|
||||
private int _activeClientSlot = 0;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
@@ -80,11 +109,71 @@ public class GameManager : MonoBehaviour
|
||||
}
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
|
||||
// Keep the screen on while the player is in the app. A geographic
|
||||
// social-deduction game asks the user to walk around for 5-15 minutes
|
||||
// staring at the map; default Android sleep timeout (15-60s) blacks
|
||||
// the screen out mid-round, drops GPS updates, and requires the
|
||||
// player to re-unlock the phone. Two layers of belt-and-suspenders:
|
||||
// (1) Unity's Screen.sleepTimeout, which works on most devices and
|
||||
// is one line, but is overridden by some MIUI/EMUI ROMs.
|
||||
// (2) Android FLAG_KEEP_SCREEN_ON on the activity window, harder for
|
||||
// OEM ROMs to override and the standard pattern for navigation/maps
|
||||
// apps. Wrapped in #if UNITY_ANDROID so editor/iOS skip it.
|
||||
Screen.sleepTimeout = SleepTimeout.NeverSleep;
|
||||
AcquireAndroidWakelock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set FLAG_KEEP_SCREEN_ON on the Unity activity's window. This is the
|
||||
/// standard navigation/maps-app pattern and survives ROM-level overrides
|
||||
/// of Unity's Screen.sleepTimeout. No-op on non-Android platforms.
|
||||
/// </summary>
|
||||
private static void AcquireAndroidWakelock()
|
||||
{
|
||||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||||
try
|
||||
{
|
||||
using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||||
using (var activity = player.GetStatic<AndroidJavaObject>("currentActivity"))
|
||||
{
|
||||
// addFlags must run on the UI thread. Capture activity into a
|
||||
// local for the closure - AndroidJavaObject can be reused.
|
||||
var act = activity;
|
||||
act.Call("runOnUiThread", new AndroidJavaRunnable(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var window = act.Call<AndroidJavaObject>("getWindow"))
|
||||
{
|
||||
// WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||
const int FLAG_KEEP_SCREEN_ON = 0x00000080;
|
||||
window.Call("addFlags", FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[Wakelock] addFlags failed: " + ex.Message);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[Wakelock] Android JNI bridge failed: " + ex.Message);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (string.IsNullOrEmpty(displayName))
|
||||
// The prefab default in SampleScene.unity is "Hrac" (Czech for
|
||||
// "player"). Treat it as equivalent to "no name set" so users who
|
||||
// never customize their name don't all show up identically. This
|
||||
// override only fires at startup; users who explicitly type "Hrac"
|
||||
// into the nickname field will still send "Hrac" via the live
|
||||
// DisplayName payload field.
|
||||
if (string.IsNullOrEmpty(displayName) || displayName == "Hrac")
|
||||
displayName = PlayerPrefs.GetString("PlayerName", GenerateUsername());
|
||||
|
||||
gameClient = new GameClient(GenerateUUID(), displayName);
|
||||
@@ -96,12 +185,18 @@ public class GameManager : MonoBehaviour
|
||||
|
||||
if (testMode)
|
||||
{
|
||||
_secondClient = new GameClient(GenerateUUID(), GenerateUsername());
|
||||
_secondNetwork = new GameManager_Network(_secondClient, null);
|
||||
_thirdClient = new GameClient(GenerateUUID(), GenerateUsername());
|
||||
_thirdNetwork = new GameManager_Network(_thirdClient, null);
|
||||
_secondNetwork.OpenConnection();
|
||||
_thirdNetwork.OpenConnection();
|
||||
int n = Mathf.Max(0, testClientCount);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var bot = new TestBot
|
||||
{
|
||||
DisplayName = "TestBot" + (i + 1),
|
||||
};
|
||||
bot.Client = new GameClient(GenerateUUID(), bot.DisplayName);
|
||||
bot.Network = new GameManager_Network(bot.Client, null);
|
||||
bot.Network.OpenConnection();
|
||||
_testBots.Add(bot);
|
||||
}
|
||||
}
|
||||
|
||||
networkSubsystem.OpenConnection();
|
||||
@@ -118,14 +213,103 @@ public class GameManager : MonoBehaviour
|
||||
SceneManager.LoadScene(firstMenuScene, LoadSceneMode.Single);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws a GPS status banner across the top of every screen. We use OnGUI
|
||||
/// rather than a uGUI Canvas element because OnGUI works without any
|
||||
/// scene wiring - we want this visible from the very first frame, on
|
||||
/// every screen, even if the lobby canvas hasn't been bound yet. This is
|
||||
/// a debug overlay; toggle showGPSDebugOverlay off for release builds.
|
||||
/// </summary>
|
||||
private void OnGUI()
|
||||
{
|
||||
if (!showGPSDebugOverlay) return;
|
||||
if (inputSubsystem == null) return;
|
||||
|
||||
var diag = inputSubsystem.GpsDiagnostic;
|
||||
var label = "GPS: " + diag;
|
||||
|
||||
// Scale font size to screen so it's legible on phones (HDPI) and
|
||||
// editor (lower DPI) alike. Phones tend to have ~400dpi; the
|
||||
// editor game view runs at ~100dpi.
|
||||
int fontSize = Mathf.Max(14, Screen.width / 50);
|
||||
|
||||
var style = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
fontSize = fontSize,
|
||||
fontStyle = FontStyle.Bold,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
wordWrap = false,
|
||||
normal = { textColor = Color.white }
|
||||
};
|
||||
|
||||
// Width covers most of the screen so longer error strings don't get
|
||||
// clipped. Height auto-fits the chosen font size.
|
||||
float pad = fontSize * 0.5f;
|
||||
float bannerH = fontSize * 2f;
|
||||
var rect = new Rect(pad, pad, Screen.width - pad * 2, bannerH);
|
||||
|
||||
// Translucent black background for legibility against the map.
|
||||
var prevColor = GUI.color;
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.65f);
|
||||
GUI.Box(rect, GUIContent.none);
|
||||
GUI.color = prevColor;
|
||||
|
||||
// Indent the label inside the box.
|
||||
var textRect = new Rect(rect.x + pad, rect.y, rect.width - pad * 2, rect.height);
|
||||
GUI.Label(textRect, label, style);
|
||||
|
||||
// Second row: position-source picker (tap to cycle) + active client
|
||||
// indicator (testMode only). Both are diagnostic; the source picker
|
||||
// is the recovery path when one backend silently fails on a phone.
|
||||
float row2Y = rect.y + bannerH + pad * 0.5f;
|
||||
var btnStyle = new GUIStyle(GUI.skin.button)
|
||||
{
|
||||
fontSize = Mathf.Max(12, fontSize - 2),
|
||||
fontStyle = FontStyle.Bold,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
};
|
||||
|
||||
// Source button: shows current source name + invites tap.
|
||||
string sourceLabel = "Source: " + inputSubsystem.CurrentSourceName + " [tap to cycle]";
|
||||
// Width sized to the text so the touch area matches the label.
|
||||
Vector2 sourceSize = btnStyle.CalcSize(new GUIContent(sourceLabel));
|
||||
float sourceW = Mathf.Min(Screen.width - pad * 2, sourceSize.x + pad * 2);
|
||||
var sourceRect = new Rect(pad, row2Y, sourceW, bannerH);
|
||||
if (GUI.Button(sourceRect, sourceLabel, btnStyle))
|
||||
{
|
||||
inputSubsystem.CycleNextPositionSource();
|
||||
}
|
||||
|
||||
// Active-client indicator (only when we have test bots).
|
||||
if (testMode && _testBots.Count > 0)
|
||||
{
|
||||
string slot = _activeClientSlot == 0 ? "Host" : ("Bot " + _activeClientSlot);
|
||||
string indicator = $"WASD: {slot} (0..{_testBots.Count} to switch)";
|
||||
var indStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
fontSize = Mathf.Max(12, fontSize - 2),
|
||||
fontStyle = FontStyle.Bold,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
normal = { textColor = new Color(0.9f, 1f, 0.4f) },
|
||||
};
|
||||
Vector2 indSize = indStyle.CalcSize(new GUIContent(indicator));
|
||||
var indRect = new Rect(sourceRect.xMax + pad, row2Y, indSize.x + pad * 2, bannerH);
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.65f);
|
||||
GUI.Box(indRect, GUIContent.none);
|
||||
GUI.color = prevColor;
|
||||
GUI.Label(new Rect(indRect.x + pad, indRect.y, indRect.width, indRect.height), indicator, indStyle);
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Tick the SDK dispatcher so callbacks fire on main thread
|
||||
gameClient?.Update();
|
||||
if (testMode)
|
||||
{
|
||||
_secondClient?.Update();
|
||||
_thirdClient?.Update();
|
||||
for (int i = 0; i < _testBots.Count; i++)
|
||||
_testBots[i].Client?.Update();
|
||||
HandleTestBotInput();
|
||||
}
|
||||
|
||||
if (gameClient?.CurrentLobbyState != null)
|
||||
@@ -137,6 +321,102 @@ public class GameManager : MonoBehaviour
|
||||
UpdateKillCooldown();
|
||||
|
||||
inputSubsystem?.positionCheck();
|
||||
|
||||
if (testMode) StepActiveTestBot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number-key handling for slot switching. 0 = host, 1..N = test bot N.
|
||||
/// Suppress host WASD when a non-host bot is active so the host capsule
|
||||
/// doesn't drift while the user is moving a bot. Only fires when
|
||||
/// testMode is on; release builds never see this path.
|
||||
/// </summary>
|
||||
private void HandleTestBotInput()
|
||||
{
|
||||
// 0 = host. 1..9 = bots (capped by Unity KeyCode.Alpha9).
|
||||
if (Input.GetKeyDown(KeyCode.Alpha0)) _activeClientSlot = 0;
|
||||
for (int i = 1; i <= 9 && i <= _testBots.Count; i++)
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Alpha0 + i)) _activeClientSlot = i;
|
||||
}
|
||||
|
||||
// Tell the host's input subsystem to ignore WASD when a bot is active.
|
||||
if (inputSubsystem != null)
|
||||
inputSubsystem.SuppressWasd = (_activeClientSlot != 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the active slot is a bot, step its sim position from WASD axes
|
||||
/// and send to the server. Idle bots get a periodic keep-alive so their
|
||||
/// avatars don't time out.
|
||||
/// </summary>
|
||||
private void StepActiveTestBot()
|
||||
{
|
||||
if (_testBots.Count == 0) return;
|
||||
var state = gameClient?.CurrentLobbyState;
|
||||
if (state == null || state.MapData == null) return;
|
||||
|
||||
// Lazy-init each bot's sim position to the lobby's map center on
|
||||
// first lobby state. Until the bot has joined a lobby it can't
|
||||
// send position updates.
|
||||
for (int i = 0; i < _testBots.Count; i++)
|
||||
{
|
||||
var bot = _testBots[i];
|
||||
if (!bot.Joined) continue;
|
||||
if (bot.SimPosition.Lat == 0 && bot.SimPosition.Lon == 0)
|
||||
{
|
||||
// Spawn each bot in a small ring around the map center so
|
||||
// they don't all stack on top of each other on frame one.
|
||||
double offsetLat = 0.00003 * Mathf.Cos(i * Mathf.PI * 2f / Mathf.Max(1, _testBots.Count));
|
||||
double offsetLon = 0.00003 * Mathf.Sin(i * Mathf.PI * 2f / Mathf.Max(1, _testBots.Count));
|
||||
bot.SimPosition = new GeoSus.Client.Position(
|
||||
state.MapData.Center.Lat + offsetLat,
|
||||
state.MapData.Center.Lon + offsetLon);
|
||||
bot.Client.UpdatePosition(bot.SimPosition);
|
||||
bot.LastSendTime = Time.time;
|
||||
}
|
||||
}
|
||||
|
||||
// WASD only drives the active bot.
|
||||
if (_activeClientSlot >= 1 && _activeClientSlot <= _testBots.Count)
|
||||
{
|
||||
var bot = _testBots[_activeClientSlot - 1];
|
||||
if (bot.Joined)
|
||||
{
|
||||
float dx = Input.GetAxis("Horizontal");
|
||||
float dy = Input.GetAxis("Vertical");
|
||||
const double speed = 0.00001;
|
||||
bool moved = Mathf.Abs(dx) > 0.001f || Mathf.Abs(dy) > 0.001f;
|
||||
if (moved)
|
||||
{
|
||||
bot.SimPosition = new GeoSus.Client.Position(
|
||||
bot.SimPosition.Lat + dy * speed,
|
||||
bot.SimPosition.Lon + dx * speed);
|
||||
}
|
||||
// Send on movement OR on keep-alive cadence so the server
|
||||
// doesn't drop our presence.
|
||||
bool dueKeepAlive = (Time.time - bot.LastSendTime) >= 1.0f;
|
||||
if (moved || dueKeepAlive)
|
||||
{
|
||||
bot.Client.UpdatePosition(bot.SimPosition);
|
||||
bot.LastSendTime = Time.time;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No bot is active. All bots get keep-alive only.
|
||||
for (int i = 0; i < _testBots.Count; i++)
|
||||
{
|
||||
var bot = _testBots[i];
|
||||
if (!bot.Joined) continue;
|
||||
if ((Time.time - bot.LastSendTime) >= 1.0f)
|
||||
{
|
||||
bot.Client.UpdatePosition(bot.SimPosition);
|
||||
bot.LastSendTime = Time.time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -326,9 +606,38 @@ public class GameManager : MonoBehaviour
|
||||
return "Player" + UnityEngine.Random.Range(1000, 9999).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull the nickname input field's current text into displayName +
|
||||
/// gameClient.DisplayName + PlayerPrefs before sending a network
|
||||
/// action. Defensive against any TMP_InputField / soft-keyboard race
|
||||
/// where the user types and immediately taps a button: onValueChanged
|
||||
/// normally fires before the click handler in the same frame, but
|
||||
/// some Android keyboards batch text events oddly. Call this at the
|
||||
/// top of any Create/Join/Rename flow. No-op if the input field
|
||||
/// doesn't exist in the current scene.
|
||||
/// </summary>
|
||||
private void CommitNicknameFromInput()
|
||||
{
|
||||
var nameGO = GameObject.Find("name");
|
||||
if (nameGO == null) return;
|
||||
var field = nameGO.GetComponent<TMPro.TMP_InputField>();
|
||||
if (field == null) return;
|
||||
// Force the InputField to flush any pending soft-keyboard text.
|
||||
// ForceLabelUpdate() is harmless if there's nothing pending.
|
||||
field.ForceLabelUpdate();
|
||||
string typed = (field.text ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(typed)) return;
|
||||
if (typed == displayName) return; // already in sync, skip the writes
|
||||
displayName = typed;
|
||||
if (gameClient != null) gameClient.DisplayName = typed;
|
||||
PlayerPrefs.SetString("PlayerName", typed);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
// Called by HostLobbyUI
|
||||
public void CreateLobbyButton()
|
||||
{
|
||||
CommitNicknameFromInput();
|
||||
// Refuse to create a lobby without a real GPS fix. The previous
|
||||
// behavior of silently using a hardcoded Czechia fallback meant the
|
||||
// game always started at the same place no matter where the host was,
|
||||
@@ -340,9 +649,12 @@ public class GameManager : MonoBehaviour
|
||||
// testMode bypasses the GPS gate entirely so debug runs still work.
|
||||
if (!testMode)
|
||||
{
|
||||
Debug.LogWarning("[GameManager] CreateLobby blocked: no GPS fix yet. " +
|
||||
"Make sure location permission is granted and you have signal.");
|
||||
uiSubsystem?.ShowToast("Waiting for GPS fix... grant location permission and try again.");
|
||||
// Surface the actual GPS state in both logs and the toast
|
||||
// instead of the generic "Waiting for GPS fix..." that hides
|
||||
// permission/timeout/device-disabled distinctions.
|
||||
string diag = inputSubsystem?.GpsDiagnostic ?? "no input subsystem";
|
||||
Debug.LogWarning("[GameManager] CreateLobby blocked. " + diag);
|
||||
uiSubsystem?.ShowToast("Cannot create lobby. " + diag);
|
||||
inputSubsystem?.EnsureGPSStarted();
|
||||
return;
|
||||
}
|
||||
@@ -358,6 +670,7 @@ public class GameManager : MonoBehaviour
|
||||
// Called by JoinLobbyUI with the code from the input field
|
||||
public void JoinLobbyButton(string code)
|
||||
{
|
||||
CommitNicknameFromInput();
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
networkSubsystem.JoinLobby(code);
|
||||
else
|
||||
@@ -377,12 +690,14 @@ public class GameManager : MonoBehaviour
|
||||
void OnApplicationQuit()
|
||||
{
|
||||
gameClient?.Disconnect();
|
||||
_secondClient?.Disconnect();
|
||||
_thirdClient?.Disconnect();
|
||||
for (int i = 0; i < _testBots.Count; i++)
|
||||
_testBots[i].Client?.Disconnect();
|
||||
}
|
||||
|
||||
IEnumerator ConnectTestClients()
|
||||
{
|
||||
if (_testBots.Count == 0) yield break;
|
||||
|
||||
// Wait until host lobby code exists
|
||||
float wait = 0f;
|
||||
while ((gameClient?.CurrentLobbyState == null || string.IsNullOrEmpty(gameClient.CurrentLobbyState.JoinCode)) && wait < 20f)
|
||||
@@ -394,26 +709,46 @@ public class GameManager : MonoBehaviour
|
||||
var joinCode = gameClient?.CurrentLobbyState?.JoinCode;
|
||||
if (string.IsNullOrEmpty(joinCode))
|
||||
{
|
||||
Debug.LogWarning("[TestMode] Could not join test clients: join code not available.");
|
||||
Debug.LogWarning("[TestMode] Could not join test bots: join code not available.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Wait until helper clients are connected and handshake-complete
|
||||
// Wait until every bot's client has finished its TCP handshake.
|
||||
// IsReady flips once ClientHello + ClientHelloAck round-trip.
|
||||
wait = 0f;
|
||||
while (((_secondClient == null || !_secondClient.IsReady) || (_thirdClient == null || !_thirdClient.IsReady)) && wait < 20f)
|
||||
bool allReady;
|
||||
do
|
||||
{
|
||||
wait += 0.25f;
|
||||
yield return new WaitForSeconds(0.25f);
|
||||
allReady = true;
|
||||
for (int i = 0; i < _testBots.Count; i++)
|
||||
{
|
||||
if (_testBots[i].Client == null || !_testBots[i].Client.IsReady)
|
||||
{
|
||||
allReady = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allReady)
|
||||
{
|
||||
wait += 0.25f;
|
||||
yield return new WaitForSeconds(0.25f);
|
||||
}
|
||||
} while (!allReady && wait < 20f);
|
||||
|
||||
if (!allReady)
|
||||
{
|
||||
Debug.LogWarning("[TestMode] Some test bots not ready, joining the ready ones only.");
|
||||
}
|
||||
|
||||
if (_secondClient == null || _thirdClient == null || !_secondClient.IsReady || !_thirdClient.IsReady)
|
||||
for (int i = 0; i < _testBots.Count; i++)
|
||||
{
|
||||
Debug.LogWarning("[TestMode] Helper clients are not ready, skipping auto-join.");
|
||||
yield break;
|
||||
var bot = _testBots[i];
|
||||
if (bot.Client != null && bot.Client.IsReady)
|
||||
{
|
||||
bot.Network?.JoinLobby(joinCode);
|
||||
bot.Joined = true;
|
||||
}
|
||||
}
|
||||
|
||||
_secondNetwork?.JoinLobby(joinCode);
|
||||
_thirdNetwork?.JoinLobby(joinCode);
|
||||
Debug.Log($"[TestMode] Helper clients joined lobby with code {joinCode}.");
|
||||
Debug.Log($"[TestMode] {_testBots.Count} bot(s) joined lobby with code {joinCode}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine;
|
||||
using GeoSus.Client;
|
||||
using System;
|
||||
using System.Collections;
|
||||
@@ -16,6 +16,292 @@ namespace Subsystems
|
||||
Running,
|
||||
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 Position ToLocal(this Position position, Position center)
|
||||
@@ -51,6 +337,18 @@ namespace Subsystems
|
||||
private GameObject _player;
|
||||
private bool _testMode;
|
||||
|
||||
// PlayerPrefs key for the user's chosen position source. Persists
|
||||
// across app restarts so a user who flipped to UnityInput because
|
||||
// their phone hated the JNI path doesn't have to flip again every
|
||||
// launch.
|
||||
private const string PrefsSourceKey = "PositionSource_v1";
|
||||
private PositionSource _currentSource = PositionSource.Auto;
|
||||
|
||||
// When the multi-client editor test mode picks a non-host bot as
|
||||
// active, we need the host's WASD path to NOT also move. Set true
|
||||
// by GameManager when active slot != 0.
|
||||
public bool SuppressWasd = false;
|
||||
|
||||
private GPSState _GPSState = GPSState.Uninitialized;
|
||||
private float _speed = 0.00001f;
|
||||
private Position _mapCenter;
|
||||
@@ -61,9 +359,125 @@ namespace Subsystems
|
||||
private float _lastPositionSendTime;
|
||||
private const float _positionKeepAliveSeconds = 1.0f;
|
||||
|
||||
// Diagnostic state. We capture *why* GPS init failed so the UI can
|
||||
// surface it to the user without requiring logcat. Older Android
|
||||
// phones (Mi 9T, A20e) hit silent failure modes that are impossible
|
||||
// to distinguish from "still warming up" without this.
|
||||
private string _lastGpsError = "";
|
||||
private float _gpsInitStartTime = -1f;
|
||||
// Bump from the original 20s. Cold-start GPS on older Android can
|
||||
// easily exceed 20s indoors or under cloud cover - by the time the
|
||||
// user notices nothing is happening, we've already given up.
|
||||
private const int _gpsInitTimeoutSeconds = 60;
|
||||
|
||||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||||
// JNI-backed location provider, used for Auto/GpsOnly/NetworkOnly.
|
||||
// UnityInput uses Input.location instead and leaves this null.
|
||||
private AndroidLocationProvider _androidProvider;
|
||||
#endif
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_gameClient = gameClient;
|
||||
@@ -73,12 +487,85 @@ namespace Subsystems
|
||||
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
|
||||
@@ -88,10 +575,19 @@ namespace Subsystems
|
||||
/// </summary>
|
||||
public void EnsureGPSStarted()
|
||||
{
|
||||
if (_testMode) return;
|
||||
if (_currentSource == PositionSource.EditorWasd) return;
|
||||
if (_coroutineHost == null) return;
|
||||
// Allow tapping "Create Lobby" again (or any caller of this
|
||||
// method) to retry from Failed up to _maxGpsRetries times.
|
||||
if (_GPSState == GPSState.Uninitialized)
|
||||
{
|
||||
_coroutineHost.StartCoroutine(InitiallizeGPS());
|
||||
}
|
||||
else if (_GPSState == GPSState.Failed && _gpsRetryCount < _maxGpsRetries)
|
||||
{
|
||||
_gpsRetryCount++;
|
||||
_coroutineHost.StartCoroutine(InitiallizeGPS());
|
||||
}
|
||||
}
|
||||
public void positionCheck()
|
||||
{
|
||||
@@ -101,7 +597,7 @@ namespace Subsystems
|
||||
|
||||
try
|
||||
{
|
||||
if (_testMode)
|
||||
if (_currentSource == PositionSource.EditorWasd)
|
||||
{
|
||||
if (_currentPosition == new Position(0, 0))
|
||||
{
|
||||
@@ -114,7 +610,10 @@ namespace Subsystems
|
||||
_lastSentPosition = _currentPosition;
|
||||
}
|
||||
|
||||
TestPlayerPosition();
|
||||
if (!SuppressWasd)
|
||||
TestPlayerPosition();
|
||||
else
|
||||
TrySendCurrentPosition(); // keep-alive only
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,18 +693,16 @@ namespace Subsystems
|
||||
{
|
||||
double x = Input.GetAxis("Horizontal");
|
||||
double y = Input.GetAxis("Vertical");
|
||||
Debug.Log($"Input: {x}, {y}");
|
||||
_currentPosition = new Position( _lastSentPosition.Lat + y * _speed, _lastSentPosition.Lon + x * _speed);
|
||||
Debug.Log($"Current Position: {_currentPosition.Lat}, {_currentPosition.Lon}");
|
||||
var localCurrent = _currentPosition.ToLocalVector3(_mapCenter);
|
||||
Debug.Log($"Local Current Position: {localCurrent}");
|
||||
var heading = CalculateHeading(_lastSentPosition.ToLocalVector3(_mapCenter), localCurrent);
|
||||
if (heading != null)
|
||||
{
|
||||
Debug.Log($"Heading: {heading}");
|
||||
_player.transform.rotation = Quaternion.Euler(0, (float)heading, 0);
|
||||
if (_player != null)
|
||||
_player.transform.rotation = Quaternion.Euler(0, (float)heading, 0);
|
||||
}
|
||||
_player.transform.position = localCurrent;
|
||||
if (_player != null)
|
||||
_player.transform.position = localCurrent;
|
||||
try
|
||||
{
|
||||
TrySendCurrentPosition();
|
||||
@@ -228,9 +725,13 @@ namespace Subsystems
|
||||
IEnumerator InitiallizeGPS()
|
||||
{
|
||||
_GPSState = GPSState.Initializing;
|
||||
_gpsInitStartTime = Time.time;
|
||||
_lastGpsError = "";
|
||||
|
||||
#if UNITY_ANDROID
|
||||
// Request fine location permission if not already granted
|
||||
// Request fine location permission if not already granted.
|
||||
// On Android 12+ a "precise" toggle exists separately from coarse,
|
||||
// but Unity's FineLocation request covers both for our purposes.
|
||||
if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation))
|
||||
{
|
||||
UnityEngine.Android.Permission.RequestUserPermission(UnityEngine.Android.Permission.FineLocation);
|
||||
@@ -241,42 +742,133 @@ namespace Subsystems
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
waited += 0.5f;
|
||||
}
|
||||
if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation))
|
||||
{
|
||||
_lastGpsError = "Permission denied (fine location)";
|
||||
Debug.LogError("[GPS] " + _lastGpsError);
|
||||
_GPSState = GPSState.Failed;
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||||
// Choose subscription scope based on selected source. UnityInput
|
||||
// skips JNI entirely and falls through to the Input.location path
|
||||
// below (the same path iOS / editor use).
|
||||
if (_currentSource == PositionSource.Auto ||
|
||||
_currentSource == PositionSource.GpsOnly ||
|
||||
_currentSource == PositionSource.NetworkOnly)
|
||||
{
|
||||
bool useGps = (_currentSource != PositionSource.NetworkOnly);
|
||||
bool useNetwork = (_currentSource != PositionSource.GpsOnly);
|
||||
|
||||
if (_androidProvider != null)
|
||||
{
|
||||
_androidProvider.Shutdown();
|
||||
_androidProvider = null;
|
||||
}
|
||||
_androidProvider = new AndroidLocationProvider();
|
||||
if (!_androidProvider.Initialize(out var initError, useGps, useNetwork))
|
||||
{
|
||||
_lastGpsError = "Native LocationManager failed: " + initError;
|
||||
Debug.LogError("[GPS] " + _lastGpsError);
|
||||
_androidProvider = null;
|
||||
_GPSState = GPSState.Failed;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Fast-fail if neither subscribed provider is enabled at OS
|
||||
// level. Waiting 60s for fixes from disabled providers is
|
||||
// pointless - tell the user immediately what's wrong.
|
||||
bool anyUsableEnabled =
|
||||
(useGps && _androidProvider.GpsProviderEnabled) ||
|
||||
(useNetwork && _androidProvider.NetworkProviderEnabled);
|
||||
if (!anyUsableEnabled)
|
||||
{
|
||||
string which = useGps && useNetwork ? "gps + network"
|
||||
: useGps ? "gps"
|
||||
: "network";
|
||||
_lastGpsError = $"{which} provider DISABLED at OS level. Open Settings > Location and switch it ON. Or tap [Source] to try a different backend.";
|
||||
Debug.LogError("[GPS] " + _lastGpsError);
|
||||
_androidProvider.Shutdown();
|
||||
_androidProvider = null;
|
||||
_GPSState = GPSState.Failed;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Wait for the first fix (cached or live).
|
||||
int maxWaitJni = _gpsInitTimeoutSeconds;
|
||||
while (!_androidProvider.HasFix && maxWaitJni > 0)
|
||||
{
|
||||
yield return new WaitForSeconds(1);
|
||||
maxWaitJni--;
|
||||
}
|
||||
|
||||
if (!_androidProvider.HasFix)
|
||||
{
|
||||
string enabled = _androidProvider.EnabledProvidersList ?? "";
|
||||
string gpsState = _androidProvider.GpsProviderEnabled ? "ON" : "OFF";
|
||||
string netState = _androidProvider.NetworkProviderEnabled ? "ON" : "OFF";
|
||||
string lastKnown = $"lastKnown[gps={(_androidProvider.GpsLastKnownExists ? "yes" : "no")}, net={(_androidProvider.NetworkLastKnownExists ? "yes" : "no")}]";
|
||||
|
||||
_lastGpsError = $"Timeout {_gpsInitTimeoutSeconds}s on {_currentSource}. enabled=[{enabled}] gps={gpsState} net={netState} {lastKnown}. Try [Source] cycle to switch backends.";
|
||||
Debug.LogError("[GPS] " + _lastGpsError);
|
||||
_androidProvider.Shutdown();
|
||||
_androidProvider = null;
|
||||
_GPSState = GPSState.Failed;
|
||||
yield break;
|
||||
}
|
||||
|
||||
_currentPosition = new Position(_androidProvider.Lat, _androidProvider.Lon);
|
||||
_GPSState = GPSState.Running;
|
||||
_gpsRetryCount = 0;
|
||||
_coroutineHost.StartCoroutine(AndroidGPSService());
|
||||
yield break;
|
||||
}
|
||||
|
||||
// _currentSource == UnityInput on Android: fall through to the
|
||||
// Input.location path below. This is the recovery path for
|
||||
// newer Android phones where JNI's streaming-callbacks don't
|
||||
// fire (MIUI/HyperOS background restrictions, approximate-vs-
|
||||
// precise permission, minDistance gating on stationary phones).
|
||||
#endif
|
||||
|
||||
// iOS / editor / non-Android / Android-with-UnityInput-source:
|
||||
// use Unity's Input.location.
|
||||
if (!Input.location.isEnabledByUser)
|
||||
{
|
||||
Debug.LogError("Location not enabled on device or app does not have permission to access location");
|
||||
_lastGpsError = "Location services not enabled by user";
|
||||
Debug.LogError("[GPS] " + _lastGpsError);
|
||||
_GPSState = GPSState.Failed;
|
||||
yield break;
|
||||
}
|
||||
// Starts the location service.
|
||||
|
||||
float desiredAccuracyInMeters = 5f;
|
||||
float updateDistanceInMeters = 1f;
|
||||
|
||||
Input.location.Start(desiredAccuracyInMeters, updateDistanceInMeters);
|
||||
|
||||
// Waits until the location service initializes
|
||||
int maxWait = 20;
|
||||
int maxWait = _gpsInitTimeoutSeconds;
|
||||
while (Input.location.status == LocationServiceStatus.Initializing && maxWait > 0)
|
||||
{
|
||||
yield return new WaitForSeconds(1);
|
||||
maxWait--;
|
||||
}
|
||||
|
||||
// If the service didn't initialize in 20 seconds this cancels location service use.
|
||||
if (maxWait < 1)
|
||||
{
|
||||
_lastGpsError = $"Timed out after {_gpsInitTimeoutSeconds}s waiting for first fix (try moving outdoors, or tap [Source] to try a different backend)";
|
||||
Debug.LogError("[GPS] " + _lastGpsError);
|
||||
_GPSState = GPSState.Failed;
|
||||
Debug.LogError("Timed out");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (Input.location.status == LocationServiceStatus.Failed)
|
||||
{
|
||||
_lastGpsError = "Unity Input.location reported Failed status";
|
||||
Debug.LogError("[GPS] " + _lastGpsError);
|
||||
_GPSState = GPSState.Failed;
|
||||
Debug.LogError("Unable to determine device location");
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -284,14 +876,43 @@ namespace Subsystems
|
||||
_gpsRetryCount = 0;
|
||||
_coroutineHost.StartCoroutine(GPSService());
|
||||
}
|
||||
|
||||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||||
/// <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;
|
||||
Debug.LogError("Unable to determine device location");
|
||||
yield break;
|
||||
}
|
||||
|
||||
|
||||
@@ -702,6 +702,20 @@ namespace Subsystems{
|
||||
_bodyMarkers.Clear();
|
||||
}
|
||||
|
||||
// ── Player avatar sizing ────────────────────────────────────────────
|
||||
// The default Unity capsule primitive is 2m tall in local space. The
|
||||
// map camera defaults to 150m orthographic-ish height (see
|
||||
// MapCameraController), so anything smaller than ~3m world-size is a
|
||||
// pixel on screen. Original code used scale=0.4 (~0.8m capsule) which
|
||||
// was invisible. Markers (POIs/tasks/bodies) are 8m pillars; players
|
||||
// need to be visibly distinct from those AND from each other. The
|
||||
// local player gets a halo light + larger scale so the user can find
|
||||
// themselves on the map at a glance.
|
||||
private const float kLocalPlayerScale = 4f; // ~8m capsule (matches marker height)
|
||||
private const float kRemotePlayerScale = 2f; // ~4m capsule (smaller than markers)
|
||||
private const float kLocalPlayerHaloRange = 18f;
|
||||
private const float kLocalPlayerHaloIntensity = 2.5f;
|
||||
|
||||
public void UpdatePlayerAvatars(Dictionary<string, PlayerPositionInfo> positions, string myUuid)
|
||||
{
|
||||
if (_mapCenterPoint == null) return;
|
||||
@@ -715,20 +729,47 @@ namespace Subsystems{
|
||||
{
|
||||
string uuid = kvp.Key;
|
||||
var info = kvp.Value;
|
||||
bool isLocal = uuid == myUuid;
|
||||
if (!_playerAvatars.TryGetValue(uuid, out var go) || go == null)
|
||||
{
|
||||
go = GameObject.CreatePrimitive(PrimitiveType.Capsule);
|
||||
go.name = $"Player_{uuid.Substring(0, Mathf.Min(8, uuid.Length))}";
|
||||
go.transform.parent = _mapCenterPoint?.transform;
|
||||
go.transform.localScale = Vector3.one * 0.4f;
|
||||
// Strip the auto-collider - avatars are visual only and the
|
||||
// collider would interact with the map's MeshColliders.
|
||||
var col = go.GetComponent<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;
|
||||
}
|
||||
go.transform.position = info.Position.ToLocalVector3(_centerPosition) + Vector3.up * 1f;
|
||||
|
||||
// Lift the avatar so the bottom of the capsule sits roughly at
|
||||
// ground level despite the larger scale. Capsule's local pivot
|
||||
// is at center, height = 2 * localScale.y world units, so we
|
||||
// raise by half the local height.
|
||||
float halfHeight = (isLocal ? kLocalPlayerScale : kRemotePlayerScale);
|
||||
go.transform.position = info.Position.ToLocalVector3(_centerPosition)
|
||||
+ Vector3.up * halfHeight;
|
||||
|
||||
var mr = go.GetComponent<MeshRenderer>();
|
||||
if (mr)
|
||||
{
|
||||
if (uuid == myUuid) mr.material.color = Color.green;
|
||||
if (isLocal) mr.material.color = new Color(0.30f, 1.00f, 0.55f);
|
||||
else if (info.State == GeoSus.Client.PlayerState.Dead) mr.material.color = Color.grey;
|
||||
else mr.material.color = Color.white;
|
||||
}
|
||||
|
||||
@@ -487,6 +487,15 @@ namespace Subsystems
|
||||
{
|
||||
State.Phase = GamePhase.Lobby;
|
||||
_manager?.uiSubsystem?.HideMeetingPanel();
|
||||
// Bodies survive the scene reload because the marker GameObjects are
|
||||
// parented under MapCenterPoint (which lives in the persistent
|
||||
// Client.unity scene). Without this clear, returning to lobby and
|
||||
// starting a new round leaves stale corpses on the map of the new
|
||||
// round. Server already cleared its `_bodies` set in
|
||||
// ProcessReturnToLobby; this is the client-side mirror that was
|
||||
// missing in HandleVotingClosed's symmetry.
|
||||
_manager?.mapSubsystem?.ClearBodyMarkers();
|
||||
_manager?.mapSubsystem?.ClearSabotageMarkers();
|
||||
// Unified lobby: regardless of role, return to create.unity.
|
||||
SceneManager.LoadScene("create", LoadSceneMode.Single);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace Subsystems
|
||||
private List<TaskEntry> _tasks = new List<TaskEntry>();
|
||||
private bool _minigameOpen;
|
||||
private string _loadedMinigameScene;
|
||||
private Camera _hostCameraSuspended;
|
||||
private GameObject _hostInGameHudHidden;
|
||||
|
||||
// Proximity state (checked every frame in UpdateProximity)
|
||||
public GeoSus.Client.GameTask NearbyTask { get; private set; }
|
||||
@@ -190,12 +192,31 @@ namespace Subsystems
|
||||
// Inform server that task started
|
||||
_gameClient.Send(new TaskStart { TaskId = entry.ServerTask.TaskId });
|
||||
|
||||
// Disable the host scene's main camera while the minigame is up.
|
||||
// With both cameras enabled the minigame's UI/3D content would
|
||||
// fight the host's map camera for screen space, and what gets
|
||||
// drawn depends on Camera.depth which isn't guaranteed across
|
||||
// scenes. Restored in FinishMinigame.
|
||||
_hostCameraSuspended = Camera.main;
|
||||
if (_hostCameraSuspended != null) _hostCameraSuspended.enabled = false;
|
||||
|
||||
// Hide the persistent InGame HUD canvas (if present). It lives
|
||||
// in Client.unity and renders Screen Space - Overlay so it would
|
||||
// otherwise stack on top of the minigame's UI regardless of
|
||||
// which scene is active. SetActive(false) is reversible.
|
||||
_hostInGameHudHidden = GameObject.Find("InGame");
|
||||
if (_hostInGameHudHidden != null && _hostInGameHudHidden.activeSelf)
|
||||
_hostInGameHudHidden.SetActive(false);
|
||||
else
|
||||
_hostInGameHudHidden = null; // nothing to restore
|
||||
|
||||
var op = SceneManager.LoadSceneAsync(entry.MinigameScene, LoadSceneMode.Additive);
|
||||
if (op == null)
|
||||
{
|
||||
Debug.LogError($"[Tasks] LoadSceneAsync returned null for '{entry.MinigameScene}'.");
|
||||
GameManager.Instance?.uiSubsystem?.ShowToast(
|
||||
$"Task scene failed to load: {entry.MinigameScene}");
|
||||
if (_hostCameraSuspended != null) { _hostCameraSuspended.enabled = true; _hostCameraSuspended = null; }
|
||||
_minigameOpen = false;
|
||||
yield break;
|
||||
}
|
||||
@@ -203,8 +224,34 @@ namespace Subsystems
|
||||
|
||||
_loadedMinigameScene = entry.MinigameScene;
|
||||
|
||||
// Find the ITask component in the newly loaded scene
|
||||
// CRITICAL: switch the active scene to the loaded minigame.
|
||||
// LoadSceneMode.Additive stacks scenes without changing which one
|
||||
// is "active" - and an inactive scene's RenderSettings, ambient
|
||||
// light, and skybox don't drive rendering. The host (Client.unity)
|
||||
// remains active and its lighting context still applies, which
|
||||
// is the root cause of "task opens to white screen": the
|
||||
// minigame's content loads but its visuals don't take over.
|
||||
// Without SetActiveScene, even minigames that ARE wired up
|
||||
// correctly render against the host's lighting and look broken.
|
||||
Scene scene = SceneManager.GetSceneByName(entry.MinigameScene);
|
||||
if (scene.IsValid()) SceneManager.SetActiveScene(scene);
|
||||
|
||||
// Diagnostic: count cameras / canvases / lights in the loaded
|
||||
// scene. If the white screen persists after this fix, the
|
||||
// numbers tell us whether the scene is missing rendering bits
|
||||
// (camera=0, canvas=0) or if the issue is elsewhere.
|
||||
int camCount = 0, canvasCount = 0, lightCount = 0;
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
{
|
||||
camCount += root.GetComponentsInChildren<Camera>(true).Length;
|
||||
canvasCount += root.GetComponentsInChildren<Canvas>(true).Length;
|
||||
lightCount += root.GetComponentsInChildren<Light>(true).Length;
|
||||
}
|
||||
Debug.Log($"[Tasks] Loaded '{entry.MinigameScene}': cameras={camCount}, " +
|
||||
$"canvases={canvasCount}, lights={lightCount}, " +
|
||||
$"activeScene={SceneManager.GetActiveScene().name}");
|
||||
|
||||
// Find the ITask component in the newly loaded scene
|
||||
ITask taskComponent = null;
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
{
|
||||
@@ -214,7 +261,9 @@ namespace Subsystems
|
||||
|
||||
if (taskComponent == null)
|
||||
{
|
||||
Debug.LogWarning($"[Tasks] No ITask found in '{entry.MinigameScene}'. Auto-completing.");
|
||||
Debug.LogWarning($"[Tasks] No ITask found in '{entry.MinigameScene}'. " +
|
||||
$"Either the minigame's controller script isn't attached to a GameObject in the scene, " +
|
||||
$"or the script doesn't implement ITask. Auto-completing.");
|
||||
yield return FinishMinigame(entry, true);
|
||||
yield break;
|
||||
}
|
||||
@@ -246,14 +295,33 @@ namespace Subsystems
|
||||
Debug.Log($"[Tasks] Task '{entry.ServerTask.Name}' exited without completion.");
|
||||
}
|
||||
|
||||
// Unload minigame scene
|
||||
// Unload minigame scene. Switch the active scene back to the
|
||||
// host BEFORE the unload so we don't end up with no active
|
||||
// scene mid-frame (Unity will complain and lighting flickers).
|
||||
if (!string.IsNullOrEmpty(_loadedMinigameScene))
|
||||
{
|
||||
var hostScene = SceneManager.GetSceneByName("Client");
|
||||
if (hostScene.IsValid()) SceneManager.SetActiveScene(hostScene);
|
||||
|
||||
var unload = SceneManager.UnloadSceneAsync(_loadedMinigameScene);
|
||||
yield return unload;
|
||||
_loadedMinigameScene = null;
|
||||
}
|
||||
|
||||
// Re-enable the host camera that was suspended during the minigame.
|
||||
if (_hostCameraSuspended != null)
|
||||
{
|
||||
_hostCameraSuspended.enabled = true;
|
||||
_hostCameraSuspended = null;
|
||||
}
|
||||
|
||||
// Re-show the InGame HUD canvas hidden at minigame entry.
|
||||
if (_hostInGameHudHidden != null)
|
||||
{
|
||||
_hostInGameHudHidden.SetActive(true);
|
||||
_hostInGameHudHidden = null;
|
||||
}
|
||||
|
||||
_minigameOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user