Files
GeoSusGame/Assets/GameManager/GameManager.cs
2026-05-17 20:26:49 +02:00

756 lines
29 KiB
C#

using UnityEngine;
using GeoSus.Client;
using Subsystems;
using System.Collections;
using System;
using TMPro;
using UnityEngine.SceneManagement;
public class GameManager : MonoBehaviour
{
// Singleton
public static GameManager Instance { get; private set; }
[Header("Subsystems")]
public GameManager_Network networkSubsystem;
public GameManager_UI uiSubsystem;
public GameManager_Map mapSubsystem;
public GameManager_Input inputSubsystem;
public GameManager_Tasks taskSubsystem;
public GameClient gameClient;
[Header("Player Info")]
public string displayName;
[Header("Scene Management")]
[SerializeField] public string firstMenuScene = "main menu asi idk lol";
[Header("UI Elements (Client.unity)")]
// Canvas names in Client.unity — found at runtime in OnSceneLoaded
private const string CanvasNameJoinCreate = "LobbySelector";
private const string CanvasNameInLobby = "InLobby";
private const string CanvasNameLoading = "LoadingScreen";
private const string CanvasNameGame = "InGame";
[Header("Map")]
// MapCenterPoint and Player are in Client.unity — wired at runtime in OnSceneLoaded.
// buildingSettings/pathwaySettings/areaSettings must be assigned in SampleScene Inspector.
public BuildingSettings buildingSettings;
public PathwaySettings pathwaySettings;
public AreaSettings areaSettings;
[Header("Lobby Settings")]
public double pendingRadius = 500;
public int pendingImpostorCount = 1;
public int pendingTaskCount = 5;
/// <summary>
/// P13b/c: full settings overrides accumulated by HostLobbyUI before the
/// host taps "Create". Null = host didn't change anything beyond the three
/// flat fields above; server falls through to its current defaults for
/// every field. Each field is independently nullable so the host can
/// opt into changing only what they care about.
/// </summary>
public GameSettingsOverrides pendingSettings;
[Header("Task Minigames (round-robin)")]
// Names MUST match the scene file names in Assets/Scenes (case-sensitive)
// and each one MUST be enabled in EditorBuildSettings, or LoadSceneAsync
// will silently fail and the task button will appear dead.
[SerializeField] public string[] minigameScenes = {
"MiniGame-Kabely V10",
"MiniGame-insertkeys",
"MiniGame-FlappyBird",
"MiniGame-ThrowInHole",
"MiniGame-Satelit"
// Add minigame scene name here
};
[Header("Debug")]
public bool testMode = false;
/// <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()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
// Keep the screen on while the player is in the app. A geographic
// social-deduction game asks the user to walk around for 5-15 minutes
// staring at the map; default Android sleep timeout (15-60s) blacks
// the screen out mid-round, drops GPS updates, and requires the
// player to re-unlock the phone. Two layers of belt-and-suspenders:
// (1) Unity's Screen.sleepTimeout, which works on most devices and
// is one line, but is overridden by some MIUI/EMUI ROMs.
// (2) Android FLAG_KEEP_SCREEN_ON on the activity window, harder for
// OEM ROMs to override and the standard pattern for navigation/maps
// apps. Wrapped in #if UNITY_ANDROID so editor/iOS skip it.
Screen.sleepTimeout = SleepTimeout.NeverSleep;
AcquireAndroidWakelock();
}
/// <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()
{
// The prefab default in SampleScene.unity is "Hrac" (Czech for
// "player"). Treat it as equivalent to "no name set" so users who
// never customize their name don't all show up identically. This
// override only fires at startup; users who explicitly type "Hrac"
// into the nickname field will still send "Hrac" via the live
// DisplayName payload field.
if (string.IsNullOrEmpty(displayName) || displayName == "Hrac")
displayName = PlayerPrefs.GetString("PlayerName", GenerateUsername());
gameClient = new GameClient(GenerateUUID(), displayName);
networkSubsystem = new GameManager_Network(gameClient, this);
mapSubsystem = new GameManager_Map(gameClient, null, buildingSettings, pathwaySettings, areaSettings);
uiSubsystem = new GameManager_UI(gameClient);
inputSubsystem = new GameManager_Input(gameClient, null, testMode);
taskSubsystem = new GameManager_Tasks(gameClient, minigameScenes, this);
if (testMode)
{
int n = Mathf.Max(0, testClientCount);
for (int i = 0; i < n; i++)
{
var bot = new TestBot
{
DisplayName = "TestBot" + (i + 1),
};
bot.Client = new GameClient(GenerateUUID(), bot.DisplayName);
bot.Network = new GameManager_Network(bot.Client, null);
bot.Network.OpenConnection();
_testBots.Add(bot);
}
}
networkSubsystem.OpenConnection();
// Start GPS immediately at app launch. Acquiring a fix on a cold
// device can take 5-30 seconds; if we wait until CreateLobby is
// pressed, the lobby will be seeded with bad coords. Starting here
// means the user's normal navigation through the menus gives the
// GPS subsystem time to settle.
inputSubsystem?.EnsureGPSStarted();
// Load main menu after GameManager is ready
if (!string.IsNullOrEmpty(firstMenuScene))
SceneManager.LoadScene(firstMenuScene, LoadSceneMode.Single);
}
/// <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)
{
for (int i = 0; i < _testBots.Count; i++)
_testBots[i].Client?.Update();
HandleTestBotInput();
}
if (gameClient?.CurrentLobbyState != null)
{
uiSubsystem?.UpdateLobbyUI();
taskSubsystem?.UpdateProximity();
}
if (gameClient?.MyRole == PlayerRole.Impostor)
UpdateKillCooldown();
inputSubsystem?.positionCheck();
if (testMode) StepActiveTestBot();
}
/// <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;
}
}
}
}
void OnEnable()
{
SceneManager.sceneLoaded += OnSceneLoaded;
}
void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
/// <summary>
/// After Client.unity loads, re-bind all canvas/HUD references because
/// those GameObjects don't exist in the Art menu scenes.
/// </summary>
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (scene.name == "Client")
{
var roots = scene.GetRootGameObjects();
// Find a root or deep GameObject by name in the loaded scene
GameObject FindGO(string n) {
foreach (var go in roots) {
if (go.name == n) return go;
var found = go.transform.Find(n);
if (found != null) return found.gameObject;
}
return null;
}
Canvas FindCanvas(string n) {
var go = FindGO(n);
return go != null ? go.GetComponent<Canvas>() : null;
}
// ── Build HUD BEFORE BindClientScene so FindTMP/Find can locate new elements ──
var inGameGO = FindGO("InGame");
if (inGameGO != null)
{
var builder = inGameGO.GetComponent<InGameHUDBuilder>()
?? inGameGO.AddComponent<InGameHUDBuilder>();
builder.BuildNow();
}
// ── Wire canvases (after HUD is built) ──
// Apply our standard CanvasScaler (1080x1920 reference, match=0.5)
// to every canvas in the scene before binding so layouts scale
// identically across phones and tablets without per-device tweaks.
var cJoin = FindCanvas(CanvasNameJoinCreate);
var cLobby = FindCanvas(CanvasNameInLobby);
var cLoad = FindCanvas(CanvasNameLoading);
var cGame = FindCanvas(CanvasNameGame);
InGameHUDBuilder.ConfigureCanvasScaler(cJoin);
InGameHUDBuilder.ConfigureCanvasScaler(cLobby);
InGameHUDBuilder.ConfigureCanvasScaler(cLoad);
InGameHUDBuilder.ConfigureCanvasScaler(cGame);
uiSubsystem?.BindClientScene(cJoin, cLobby, cLoad, cGame);
// ── Wire map center point and player capsule ──
var mapCenter = FindGO("MapCenterPoint");
var player = FindGO("Capsule");
mapSubsystem?.SetMapCenterPoint(mapCenter);
inputSubsystem?.SetPlayerObject(player);
// ── Attach camera controller to Main Camera ──
var mainCamGO = FindGO("Main Camera");
if (mainCamGO != null)
{
var camCtrl = mainCamGO.GetComponent<MapCameraController>()
?? mainCamGO.AddComponent<MapCameraController>();
camCtrl.SetTarget(player);
}
// If MapDataReady arrived before Client scene finished loading,
// this will build the map now that scene references are valid.
networkSubsystem?.OnClientSceneReady();
}
else if (scene.name == "create" || scene.name == "join loading")
{
// Lobby scene just loaded — ensure LobbyDisplayUI refreshes once
// its Start() has run and registered itself (happens before Update).
uiSubsystem?.NotifyLobbyChanged();
}
}
private float _killCooldownSeconds = 0f;
private const float KillCooldownDuration = 20f;
private void UpdateKillCooldown()
{
if (_killCooldownSeconds > 0)
{
_killCooldownSeconds -= Time.deltaTime;
// Mirror into GameState so UI reads from the single source of truth
if (networkSubsystem?.State != null)
networkSubsystem.State.KillCooldownRemaining = _killCooldownSeconds;
uiSubsystem?.SetKillCooldownText($"Kill: {Mathf.CeilToInt(_killCooldownSeconds)}s");
}
else
{
_killCooldownSeconds = 0f;
if (networkSubsystem?.State != null)
networkSubsystem.State.KillCooldownRemaining = 0;
uiSubsystem?.SetKillCooldownText("");
}
}
/// <summary>
/// Called by the ActionButton. Routes to kill / report / emergency / use-task
/// depending on current proximity state.
/// </summary>
public void PerformAction()
{
if (uiSubsystem == null || uiSubsystem.IsPlayerDead) return;
bool isImpostor = gameClient?.MyRole == PlayerRole.Impostor;
// P13b: pull per-lobby distances from the server-snapshotted settings
// instead of hardcoding 5m for every check. ?? fallback keeps the
// pre-P13b behavior on old server builds that don't ship settings.
var settings = networkSubsystem?.State?.Settings;
double reportDist = settings?.ReportDistanceM ?? 5.0;
double emergencyDist = settings?.EmergencyMeetingCallRadiusM ?? 5.0;
double killDist = settings?.KillDistanceM ?? 5.0;
// 1. Nearby task → USE
var nearbyTask = taskSubsystem?.NearbyTask;
if (nearbyTask != null && !isImpostor)
{
taskSubsystem.TriggerNearbyTask();
return;
}
// 2. Nearby body → REPORT
if (!uiSubsystem.IsCommsBlackout)
{
var nearbyBody = gameClient?.FindNearbyBody(reportDist);
if (nearbyBody != null)
{
gameClient.ReportBody(nearbyBody.BodyId);
return;
}
// 3. Near map centre → EMERGENCY
if (gameClient?.CurrentLobbyState?.MapData != null)
{
double distToCenter = gameClient.MyPosition.DistanceTo(gameClient.CurrentLobbyState.MapData.Center);
if (distToCenter <= emergencyDist)
{
gameClient.CallEmergencyMeeting();
return;
}
}
}
// 4. Impostor kill
if (isImpostor && _killCooldownSeconds <= 0)
{
var targetUuid = gameClient?.FindNearbyPlayer(killDist);
if (!string.IsNullOrEmpty(targetUuid))
{
gameClient.Kill(targetUuid);
_killCooldownSeconds = KillCooldownDuration;
}
}
}
/// <summary>Called by Impostor sabotage buttons.</summary>
public void StartSabotage(int typeIndex)
{
gameClient?.Send(new GeoSus.Client.StartSabotage { SabotageType = (SabotageType)typeIndex });
}
/// <summary>Called by the meeting vote buttons. Pass null to skip.</summary>
public void CastVote(string targetUuid)
{
gameClient?.Vote(targetUuid);
}
protected string GenerateUUID()
{
return System.Guid.NewGuid().ToString();
}
protected string GenerateUsername()
{
return "Player" + UnityEngine.Random.Range(1000, 9999).ToString();
}
/// <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,
// and the player capsule would spawn miles away in coordinate space
// because they're at their real GPS while the map was built around
// the fallback. Both bugs share this single gate.
if (inputSubsystem?.LastKnownPosition == null)
{
// testMode bypasses the GPS gate entirely so debug runs still work.
if (!testMode)
{
// Surface the actual GPS state in both logs and the toast
// instead of the generic "Waiting for GPS fix..." that hides
// permission/timeout/device-disabled distinctions.
string diag = inputSubsystem?.GpsDiagnostic ?? "no input subsystem";
Debug.LogWarning("[GameManager] CreateLobby blocked. " + diag);
uiSubsystem?.ShowToast("Cannot create lobby. " + diag);
inputSubsystem?.EnsureGPSStarted();
return;
}
}
var pos = inputSubsystem?.LastKnownPosition;
double lat = pos?.Lat ?? 0;
double lon = pos?.Lon ?? 0;
networkSubsystem.CreateLobby(lat, lon, pendingRadius, pendingImpostorCount, pendingTaskCount, pendingSettings);
if (testMode) StartCoroutine(ConnectTestClients());
}
// Called by JoinLobbyUI with the code from the input field
public void JoinLobbyButton(string code)
{
CommitNicknameFromInput();
if (!string.IsNullOrEmpty(code))
networkSubsystem.JoinLobby(code);
else
Debug.LogWarning("Join code is empty!");
}
public void LeaveLobbyButton()
{
networkSubsystem.LeaveLobby();
}
public void StartGameButton()
{
networkSubsystem.StartGame();
}
void OnApplicationQuit()
{
gameClient?.Disconnect();
for (int i = 0; i < _testBots.Count; i++)
_testBots[i].Client?.Disconnect();
}
IEnumerator ConnectTestClients()
{
if (_testBots.Count == 0) yield break;
// Wait until host lobby code exists
float wait = 0f;
while ((gameClient?.CurrentLobbyState == null || string.IsNullOrEmpty(gameClient.CurrentLobbyState.JoinCode)) && wait < 20f)
{
wait += 0.25f;
yield return new WaitForSeconds(0.25f);
}
var joinCode = gameClient?.CurrentLobbyState?.JoinCode;
if (string.IsNullOrEmpty(joinCode))
{
Debug.LogWarning("[TestMode] Could not join test bots: join code not available.");
yield break;
}
// Wait until every bot's client has finished its TCP handshake.
// IsReady flips once ClientHello + ClientHelloAck round-trip.
wait = 0f;
bool allReady;
do
{
allReady = true;
for (int i = 0; i < _testBots.Count; i++)
{
if (_testBots[i].Client == null || !_testBots[i].Client.IsReady)
{
allReady = false;
break;
}
}
if (!allReady)
{
wait += 0.25f;
yield return new WaitForSeconds(0.25f);
}
} while (!allReady && wait < 20f);
if (!allReady)
{
Debug.LogWarning("[TestMode] Some test bots not ready, joining the ready ones only.");
}
for (int i = 0; i < _testBots.Count; i++)
{
var bot = _testBots[i];
if (bot.Client != null && bot.Client.IsReady)
{
bot.Network?.JoinLobby(joinCode);
bot.Joined = true;
}
}
Debug.Log($"[TestMode] {_testBots.Count} bot(s) joined lobby with code {joinCode}.");
}
}