using System; using System.Collections; using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.Networking; using UnityEngine.Serialization; using TMPro; using Mirror; public class GameManager : NetworkBehaviour { public static Team MyTeam { get; set; } public static MatchmadePlayer RedPlayer { get; set; } public static MatchmadePlayer BluePlayer { get; set; } /// Coin units from matchmaker rc_prize; used for in-game prize UI. public static int MatchRcPrizeCoins { get; set; } /// Set by dedicated server startup (e.g. CupidConnector parsing -matchId). PATCH is skipped if id ≤ 0 or secret is empty. public static int DedicatedMatchId { get; private set; } /// Sent as X-Dedicated-Server-Secret. From -dedicatedSecret arg or DEDICATED_SERVER_SECRET env. public static string DedicatedMatchSecret { get; private set; } = ""; /// Base URL for internal match API (no trailing slash), default http://127.0.0.1:2612. public static string DedicatedInternalApiBase { get; private set; } = LoginManager.AuthBaseUrl; public static void ConfigureDedicatedMatchReporting(int matchId, string secret=null, string internalApiBaseUrl = null) { DedicatedMatchId = matchId; DedicatedMatchSecret = secret ?? "38516e39d3406225b7c3015a66e5d25c54e9b2024c0755212122bdcea3e3a328"; if (!string.IsNullOrWhiteSpace(internalApiBaseUrl)) DedicatedInternalApiBase = internalApiBaseUrl.TrimEnd('/'); Logger.Log($"Configured dedicated match reporting for match id {matchId} with secret {DedicatedMatchSecret} and internal api base {DedicatedInternalApiBase}"); } /// Outcome of (POST /internal/rematch). public readonly struct DedicatedRematchResult { public readonly bool Success; public readonly long HttpStatusCode; public readonly MatchmadeResponse Response; public readonly string ErrorMessage; public DedicatedRematchResult(bool success, long httpStatusCode, MatchmadeResponse response, string errorMessage) { Success = success; HttpStatusCode = httpStatusCode; Response = response; ErrorMessage = errorMessage ?? ""; } } /// /// Dedicated server only: POST /internal/rematch with X-Dedicated-Server-Secret. /// Blocks until the request finishes (same pattern as ). /// On HTTP 200, returns the same JSON envelope as a successful matchmaker GET room fill: . /// /// users.id for red. /// users.id for blue; must differ from red. /// RC stake per player (non-negative). /// Overrides when non-empty. /// Overrides when non-empty. public static DedicatedRematchResult RequestDedicatedRematch(int userRedId, int userBlueId, int entryFee, string secret = null, string internalApiBaseUrl = null) { string s = string.IsNullOrEmpty(secret) ? DedicatedMatchSecret : secret; string baseUrl = string.IsNullOrEmpty(internalApiBaseUrl) ? DedicatedInternalApiBase : internalApiBaseUrl.TrimEnd('/'); if (string.IsNullOrEmpty(s)) return new DedicatedRematchResult(false, 0, null, "Missing dedicated server secret"); if (string.IsNullOrEmpty(baseUrl)) return new DedicatedRematchResult(false, 0, null, "Missing internal API base URL"); string json = JsonUtility.ToJson(new DedicatedRematchRequestBody { user_red_id = userRedId, user_blue_id = userBlueId, entry_fee = entryFee }); using (var req = DedicatedMatschInternalApi.BuildRematchRequest(baseUrl, s, json)) { var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(16); string text = req.downloadHandler != null ? req.downloadHandler.text : ""; long code = req.responseCode; if (req.result != UnityWebRequest.Result.Success && req.result != UnityWebRequest.Result.ProtocolError) { string netErr = string.IsNullOrEmpty(req.error) ? "Network error" : req.error; Logger.Log("Dedicated rematch POST failed (transport): " + netErr + " " + req.url); return new DedicatedRematchResult(false, code, null, netErr); } if (code == 200) { try { var env = JsonUtility.FromJson(text); if (env != null && env.ok) return new DedicatedRematchResult(true, code, env, ""); string apiErr = TryParseRematchErrorMessage(text); Logger.Log("Dedicated rematch POST 200 but ok=false or parse issue: " + text); return new DedicatedRematchResult(false, code, env, string.IsNullOrEmpty(apiErr) ? "Unexpected response" : apiErr); } catch (Exception e) { Logger.Log("Dedicated rematch POST: could not parse success body: " + e.Message + " body=" + text); return new DedicatedRematchResult(false, code, null, e.Message); } } string err = TryParseRematchErrorMessage(text); if (string.IsNullOrEmpty(err)) err = string.IsNullOrEmpty(text) ? "HTTP " + code : text; Logger.Log("Dedicated rematch POST failed: " + code + " " + err); return new DedicatedRematchResult(false, code, null, err); } } static string TryParseRematchErrorMessage(string json) { if (string.IsNullOrEmpty(json)) return ""; try { var err = JsonUtility.FromJson(json); if (err != null && !string.IsNullOrEmpty(err.error)) return err.error; } catch { /* ignore */ } return ""; } /// Sets match status to -1 (room closed). Safe to call from shutdown paths; runs synchronously so it completes before Application.Quit. public static void ReportDedicatedMatchRoomClosed() { if (_dedicatedRoomClosedReported || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret)) return; _dedicatedRoomClosedReported = true; if (DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret)) return; using (var req = DedicatedMatschInternalApi.BuildRequest(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":-1}")) { var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(16); DedicatedMatschInternalApi.LogIfFailed(req); } } static bool _dedicatedRoomClosedReported; static string Iso8601UtcNow() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", System.Globalization.CultureInfo.InvariantCulture); bool _dedicatedReportedRedJoin; bool _dedicatedReportedBlueJoin; bool _dedicatedReportedGameOver; public static Action OnTeamChanged; public static bool isMoving{ get{ return instance.IsMoving(); } } [SyncVar] public bool m_isMoving =false; [SyncVar(hook = nameof(OnGameStartedChanged))] public bool gameStarted = false; [SyncVar] public bool isGameEnded = false; void OnGameStartedChanged(bool oldStarted, bool newStarted){ if(newStarted){ GameCanvas.instance.HideWaitingForOpponentPanel(); if (AudioManager.instance != null) AudioManager.instance.SetCrowdNoiseType(CrowdNoiseType.Normal); }else{ GameCanvas.instance.ShowWaitingForOpponentPanel(); } } [SyncVar(hook = nameof(OnSelectedTeamChanged))] public Team SelectedTeam = Team.Red; void OnSelectedTeamChanged(Team oldTeam, Team newTeam){ Logger.Log($"Selected team changed from {oldTeam} to {newTeam}"); OnTeamChanged?.Invoke(newTeam); if (!isClient || !gameStarted || oldTeam == newTeam) return; if (AudioManager.instance != null) AudioManager.instance.PlayRefereeWhistle(); } public static bool NoPuckSelected { get{ return instance.selectedPuck == null; } } public int turnTimer = 10; [SyncVar] public float turnTimerCounter; public Rigidbody2D ball; public float puckForce = 20f; public AnimationCurve puckForceCurve = AnimationCurve.Linear(0,3,1,1); public float ballMoveTime = 3f; public float puckDragClampMax = 5f; public float puckDragMinClamp = 1f; [Header("Field bounds")] [Tooltip("Pitch width (X). Length (Y) is fieldSize × 2. Origin-centered; used for boundary-based camera zoom while dragging.")] public float fieldSize = 10f; [Tooltip("Exponent on distance-to-boundary (0–1). >1 = subtle at center, ramps up mostly near the boundary; 1 = linear.")] [Min(0.01f)] public float cameraStretchBoundaryExponent = 2.5f; [SyncVar(hook = nameof(OnScoreChanged))] public int redScore=0; [SyncVar(hook = nameof(OnScoreChanged))] public int blueScore=0; [Header("Effects")] public ParticleSystem[] redGoalEffects; public ParticleSystem[] blueGoalEffects; [Header("UI")] public TMP_Text redScoreText; public TMP_Text blueScoreText; public CanvasGroupUtils blueTimerGroup; [FormerlySerializedAs("timerGroup")] public CanvasGroupUtils redTimerGroup; [Header("Timer Flash Settings")] [Tooltip("Minimum flash speed when timer is at 5 seconds")] public float minFlashSpeed = 10f; [Tooltip("Maximum flash speed when timer is at 0 seconds")] public float maxFlashSpeed = 20f; [Header("Misc")] public List pucks = new List(); public float curPuckPullForce; [SyncVar] public int hitCounter = 0; public static GameManager instance; public float puckMass{ get{ if(pucks.Count > 0){return pucks[0].rb.mass;}else{return 1f;} }set{ foreach(Puck puck in pucks){ puck.rb.mass = value; } } } public float puckDrag{ get{ if(pucks.Count > 0){return pucks[0].rb.linearDamping;}else{return 0f;} }set{ foreach(Puck puck in pucks){ puck.rb.linearDamping = value; } } } void Awake() { instance = this; } /// /// Blends from at the pitch center to /// at the boundary (and beyond), using the max normalized axis distance inside the field rectangle. /// public float GetCameraStretchDistanceMultiplier(Vector3 puckWorldPosition, float centerMultiplier = 0.3f, float boundaryMultiplier = 1f) { if (fieldSize <= Mathf.Epsilon) return boundaryMultiplier; float halfWidth = fieldSize * 0.5f; float halfHeight = fieldSize; float nx = Mathf.Abs(puckWorldPosition.x) / halfWidth; float ny = Mathf.Abs(puckWorldPosition.y) / halfHeight; float t = Mathf.Clamp01(Mathf.Max(nx, ny)); float shaped = Mathf.Pow(t, cameraStretchBoundaryExponent); return Mathf.Lerp(centerMultiplier, boundaryMultiplier, shaped); } void OnDrawGizmos() { if (fieldSize <= Mathf.Epsilon) return; float halfWidth = fieldSize * 0.5f; float halfHeight = fieldSize; Vector3 c = Vector3.zero; Vector3 right = new Vector3(halfWidth, 0f, 0f); Vector3 up = new Vector3(0f, halfHeight, 0f); Vector3 a = c - right - up; Vector3 b = c + right - up; Vector3 d = c + right + up; Vector3 e = c - right + up; Color prev = Gizmos.color; Gizmos.color = new Color(1f, 0.92f, 0.016f, 0.85f); Gizmos.DrawLine(a, b); Gizmos.DrawLine(b, d); Gizmos.DrawLine(d, e); Gizmos.DrawLine(e, a); Gizmos.color = prev; } /// Server-only: called from after myTeam is set. public void OnMatchPlayerTeamAssigned(Team team) { if (!isServer || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret)) return; int n = FindObjectsByType(FindObjectsSortMode.None).Length; string ts = Iso8601UtcNow(); if (team == Team.Red && !_dedicatedReportedRedJoin) { _dedicatedReportedRedJoin = true; int status = n >= 2 ? 2 : 1; string json = "{\"red_joined_at\":\"" + ts + "\",\"status\":" + status + "}"; StartCoroutine(DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json)); // StartCoroutine(DedicatedMatchInternalApi.Patch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json)); } else if (team == Team.Blue && !_dedicatedReportedBlueJoin) { _dedicatedReportedBlueJoin = true; int status = n >= 2 ? 2 : 1; string json = "{\"blue_joined_at\":\"" + ts + "\",\"status\":" + status + "}"; StartCoroutine(DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json)); } } IEnumerator DedicatedMatchPatch(string baseUrl, int matchId, string secret, string json){ if (matchId <= 0 || string.IsNullOrEmpty(secret)) yield break; using (var req = DedicatedMatschInternalApi.BuildRequest(baseUrl, matchId, secret, json)) { yield return req.SendWebRequest(); DedicatedMatschInternalApi.LogIfFailed(req); } } public IEnumerator DedicatedMatchPatchWinner(string baseUrl, int matchId, string secret, string winnerLower) { if (matchId <= 0 || string.IsNullOrEmpty(secret)) yield break; string jsonBody = "{\"winner\":\"" + winnerLower + "\"}"; using (var req = DedicatedMatschInternalApi.BuildWinnerRequest(baseUrl, matchId, secret, jsonBody)) { yield return req.SendWebRequest(); DedicatedMatschInternalApi.LogWinnerPatchResult(req); } } void ReportDedicatedMatchGameOver(Team winningTeam) { if (!isServer || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret) || _dedicatedReportedGameOver) return; _dedicatedReportedGameOver = true; StartCoroutine(CoReportDedicatedMatchGameOver(winningTeam)); } IEnumerator CoReportDedicatedMatchGameOver(Team winningTeam) { yield return DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}"); string winner = winningTeam == Team.Red ? "red" : "blue"; yield return DedicatedMatchPatchWinner(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, winner); } void Start() { GameEvents.OnSelectedPuckChanged?.Invoke(null); #if UNITY_EDITOR #else Application.targetFrameRate = isServer ? 30 : 100; #endif } public void RegisterPuck(Puck puck){ pucks.Add(puck); } public void DisposePuck(Puck puck){ pucks.Remove(puck); } [SyncVar(hook = nameof(OnSelectedPuckChanged))] [SerializeField]Puck selectedPuck; void OnSelectedPuckChanged(Puck oldPuck, Puck newPuck){ // Logger.Log($"Selected puck changed from {oldPuck} to {newPuck}"); GameEvents.OnSelectedPuckChanged?.Invoke(newPuck); if (newPuck == null && isClient && CameraEffects.instance != null){ CameraEffects.instance.ReleaseStretchFactor(); } } public static Puck SelectedPuck{ get{ return instance != null ? instance.selectedPuck : null; } set{ if (instance == null) return; instance.SetSelectedPuck(value); } } void SetSelectedPuck(Puck puck) { selectedPuck = puck; GameEvents.OnSelectedPuckChanged?.Invoke(puck); } public Puck GetClosestPuck(Vector2 position){ if(IsMoving()){return null;} //Do not do anything if shits moving // Convert screen position to world position // Vector3 worldPosition = Camera.main.ScreenToWorldPoint(new Vector3(position.x, position.y, Camera.main.nearClipPlane)); // Find all pucks in the scene Puck[] pucks = FindObjectsOfType(); float minDistance = float.MaxValue; Puck closestPuck = null; foreach (Puck puck in pucks) { if (puck == null) continue; if(puck.team != SelectedTeam){continue;} float dist = Vector2.Distance(position, puck.transform.position); if (dist < minDistance) { minDistance = dist; closestPuck = puck; } } return closestPuck; } Coroutine coroutinePostLaunch; public void OnPointerUp(Vector2 direction) { if(selectedPuck == null){ return; } if(direction.magnitude < puckDragMinClamp){ selectedPuck = null; GameEvents.OnSelectedPuckChanged?.Invoke(null); return; } if(coroutinePostLaunch != null){ StopCoroutine(coroutinePostLaunch); } coroutinePostLaunch = StartCoroutine(CoroutinePostLaunch()); curPuckPullForce = direction.magnitude; float force = puckForce * puckForceCurve.Evaluate(curPuckPullForce); Logger.Log($"force = {puckForce} * {puckForceCurve.Evaluate(curPuckPullForce)} = {force}"); Logger.Log($"launching puck at {direction} with {force * -direction} force"); selectedPuck.GetComponent().AddForce(-direction * force, ForceMode2D.Impulse); hitCounter++; selectedPuck = null; GameEvents.OnSelectedPuckChanged?.Invoke(null); SwitchTeams(); } void SwitchTeams(){ if(!isServer){ Debug.LogWarning("SwitchTeams called on client, skipping"); return; } StartCoroutine(CoroutineSwitchTeams()); } bool switchingTeams=false; IEnumerator CoroutineSwitchTeams(){ switchingTeams=true; for(int i=0; i < 3; i++){ yield return null; } while(m_isMoving){ yield return null; } turnTimerCounter = 0; // Clear any active drag/selection before changing turns. // This prevents stale selected-puck visuals when the timer expires mid-hold. if (selectedPuck != null){ SetSelectedPuck(null); } SelectedTeam = SelectedTeam == Team.Red ? Team.Blue : Team.Red; OnTeamChanged?.Invoke(SelectedTeam); switchingTeams=false; } IEnumerator CoroutinePostLaunch(){ float t = 0; while ( t < 1){ t += Time.deltaTime / ballMoveTime; ball.linearDamping = Mathf.Lerp(0, 10, t); yield return null; } } bool IsMoving(){ if(freezeInput){return true;} float minMagnitude = 0.01f; foreach(Puck puck in pucks){ if(puck.rb.linearVelocity.magnitude > minMagnitude){ return true; } } if(ball.linearVelocity.magnitude > minMagnitude){ return true; } return false; } void Update() { if(isServer){ m_isMoving=IsMoving(); HandleTurnTimer(); if(!gameStarted){ NetPlayer[] players = FindObjectsOfType(); #if UNITY_EDITOR if(Input.GetKeyDown(KeyCode.Space)){ gameStarted = true; GameCanvas.instance.HideWaitingForOpponentPanel(); Logger.Log("Game started On Server, EDITOR ONLY, DEBUG ONLY"); } #endif if (players.Length == 2) { gameStarted = true; GameCanvas.instance.HideWaitingForOpponentPanel(); Logger.Log("Game started On Server"); } } } UpdateLocalTurnTimerBeep(); UpdateTimerColor(); } void UpdateLocalTurnTimerBeep() { if (!isClient || NetPlayer.localPlayer == null) return; if (!gameStarted || SelectedTeam != NetPlayer.localPlayer.myTeam) { _lastTurnTimerBeepFloor = -1; return; } if (m_isMoving || turnTimerCounter >= turnTimer) return; float remaining = turnTimer - turnTimerCounter; int floorSec = Mathf.FloorToInt(remaining); if (floorSec != _lastTurnTimerBeepFloor) { if (_lastTurnTimerBeepFloor >= 0 && floorSec < _lastTurnTimerBeepFloor && AudioManager.instance != null) AudioManager.instance.PlayTimerBeep(); _lastTurnTimerBeepFloor = floorSec; } } void HandleTurnTimer(){ if(turnTimerCounter < turnTimer){ if(!isMoving && !switchingTeams && gameStarted){ turnTimerCounter += Time.deltaTime; } }else{ SwitchTeams(); turnTimerCounter = 0; } } float flashPhase = 0f; float lastUpdateTime = 0f; float lastRemainingTime = 10f; int _lastTurnTimerBeepFloor = -1; void UpdateTimerColor(){ float remainingTime = turnTimer - turnTimerCounter; // Detect timer reset: if remainingTime jumped from low to high, reset phase if(remainingTime > lastRemainingTime + 2f){ flashPhase = 0f; lastUpdateTime = 0f; } lastRemainingTime = remainingTime; CanvasGroupUtils activeTimerGroup = SelectedTeam == Team.Red ? redTimerGroup : blueTimerGroup; CanvasGroupUtils inactiveTimerGroup = SelectedTeam == Team.Red ? blueTimerGroup : redTimerGroup; if(inactiveTimerGroup != null){ inactiveTimerGroup.overrideColor = Color.white; } if(activeTimerGroup == null){ if(remainingTime >= 5f || !gameStarted){ flashPhase = 0f; lastUpdateTime = 0f; } return; } if(remainingTime < 5f && gameStarted){ // Calculate intensity based on how close timer is to 0 // Intensity increases from 0 to 1 as timer goes from 5 to 0 float intensity = 1f - (remainingTime / 5f); // Create flashing effect using sine wave // Flash speed increases as timer approaches 0 float flashSpeed = Mathf.Lerp(minFlashSpeed, maxFlashSpeed, intensity); // Update phase based on delta time and current flash speed // This ensures smooth flashing that resets with the timer if(lastUpdateTime > 0f){ float deltaTime = Time.time - lastUpdateTime; flashPhase += flashSpeed * deltaTime; } else { // First frame in warning zone, initialize phase flashPhase = 0f; } lastUpdateTime = Time.time; float flashValue = (Mathf.Sin(flashPhase) + 1f) * 0.5f; // 0 to 1 // Combine intensity with flash for final red intensity // As timer approaches 0, intensity increases and flash becomes more prominent float finalRedIntensity = intensity * (0.3f + flashValue * 0.7f); // Range: intensity*0.3 to intensity*1 // Lerp between white and red based on final intensity, keeping alpha at 1 Color white = Color.white; Color red = Color.red; activeTimerGroup.overrideColor = Color.Lerp(white, red, finalRedIntensity); }else{ // Reset phase when timer is above 5 seconds or game hasn't started flashPhase = 0f; lastUpdateTime = 0f; activeTimerGroup.overrideColor = Color.white; } } Team GetOpposingTeam(Team team){ return team == Team.Red ? Team.Blue : Team.Red; } void SetKickoffTeam(Team kickoffTeam){ turnTimerCounter = 0; SelectedTeam = kickoffTeam; OnTeamChanged?.Invoke(SelectedTeam); } public void OnGoal(Team team){ if(!isServer){ Debug.LogWarning("OnGoal called on client, skipping"); return;} if(!CanProcessGoal){ return; } RpcPlayGoalScoredSfx(); freezeInput=true; Team concedingTeam = team; if(hitCounter == 1){ //kickoff goal Logger.Log("Kickoff goal"); SetKickoffTeam(concedingTeam); StartCoroutine(CoroutineOnGoal(team,true));//true = Kickoff goal, reset only the ball hitCounter = 0; return; } if(team == Team.Blue){ blueScore++; blueScoreText.text = blueScore.ToString(); PlayGoalEffects(Team.Blue); Logger.Log($"Blue goal scored, blue score is now {blueScore}"); }else{ redScore++; redScoreText.text = redScore.ToString(); PlayGoalEffects(Team.Red); Logger.Log($"Red goal scored, red score is now {redScore}"); } SetKickoffTeam(concedingTeam); if(blueScore >= 3){ isGameEnded = true; ReportDedicatedMatchGameOver(Team.Blue); RpcGameOver(Team.Blue); gameOver(Team.Blue); }else if(redScore >= 3){ isGameEnded = true; ReportDedicatedMatchGameOver(Team.Red); RpcGameOver(Team.Red); gameOver(Team.Red); }else{ StartCoroutine(CoroutineOnGoal(team)); } hitCounter = 0; } void PlayGoalEffects(Team team){ if(isServer){ m_PlayGoalEffects(team); RpcPlayGoalEffects(team); }else{ CmdPlayGoalEffects(team); } } [Command] void CmdPlayGoalEffects(Team team){ m_PlayGoalEffects(team); RpcPlayGoalEffects(team); } [ClientRpc] void RpcPlayGoalEffects(Team team){ m_PlayGoalEffects(team); } void m_PlayGoalEffects(Team team){ if(team == Team.Blue){ foreach(ParticleSystem effect in blueGoalEffects){ effect.Play(); } }else{ foreach(ParticleSystem effect in redGoalEffects){ effect.Play(); } } } [ClientRpc] void RpcPlayGoalScoredSfx() { if (AudioManager.instance != null) AudioManager.instance.PlayGoalScoredSfxSequence(); } [ClientRpc] void RpcGameOver(Team team){ gameOver(team); } void gameOver(Team team){ StartCoroutine(CoroutineGameOver(team)); } IEnumerator CoroutineGameOver(Team team){ yield return new WaitForSeconds(2f); yield return CoroutineFetchBothPlayersL10(); // StopClient(); LevelLoadManager.instance.SetupGameOver(team,redScore,blueScore); GameOverCanvas.instance.Show(team, MyTeam, redScore, blueScore); // LevelLoadManager.LoadLevel("MainMenu"); } bool _thirdPlayerLeaveTriggered; /// /// Called when the local client spawns into a match. If three instances exist /// (matchmaker sent an extra client into a full room), disconnect and return to the main menu. /// public void OnLocalPlayerJoinedMatch() { if (!isClient || _thirdPlayerLeaveTriggered) return; StartCoroutine(CoLeaveIfThirdPlayer()); } IEnumerator CoLeaveIfThirdPlayer() { const int maxFrames = 30; for (int i = 0; i < maxFrames; i++) { yield return null; if (FindObjectsByType(FindObjectsSortMode.None).Length >= 3) { _thirdPlayerLeaveTriggered = true; Logger.Log("Third player in a 2-player match (matchmaker bug); leaving to main menu."); if (LevelLoadManager.instance != null) LevelLoadManager.instance.Leave(); else LevelLoadManager.LoadLevel("MainMenu"); yield break; } } } public void StopClient(){ try{ NetworkManager.singleton.StopClient(); }catch(Exception e){ Logger.Log("Error stopping client: " + e.Message); } } IEnumerator CoroutineFetchBothPlayersL10() { var my = LevelLoadManager.myPlayer; var opp = LevelLoadManager.opponentPlayer; if (my == null || opp == null) yield break; string baseUrl = (DedicatedInternalApiBase ?? "").TrimEnd('/'); if (string.IsNullOrEmpty(baseUrl)) yield break; if (my.UserId > 0){ yield return CoFetchPlayerL10Apply(baseUrl, my); } if (opp.UserId > 0 && opp.UserId != my.UserId){ yield return CoFetchPlayerL10Apply(baseUrl, opp); } LevelLoadManager.myPlayer = my; LevelLoadManager.opponentPlayer = opp; } static IEnumerator CoFetchPlayerL10Apply(string baseUrl, MatchmadePlayer player) { string url = baseUrl + "/players/" + player.UserId + "/l10"; using (var req = UnityWebRequest.Get(url)) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Logger.Log("Player L10 fetch failed: " + req.error + " " + url); yield break; } var resp = JsonUtility.FromJson(req.downloadHandler.text); if (resp != null && resp.ok) { player.l10_wins = resp.l10_wins; player.l10_losses = resp.l10_losses; } } } [Serializable] class PlayerL10Response { public bool ok; public int player_id; public int l10_wins; public int l10_losses; } bool freezeInput = false; public bool CanProcessGoal => isServer && !freezeInput && !isGameEnded; IEnumerator CoroutineOnGoal(Team team, bool kickoff = false){ if(coroutinePostLaunch!=null){ StopCoroutine(coroutinePostLaunch); } float t=0; while(t < 1){ t +=Time.deltaTime * 2f; ball.linearDamping = Mathf.Lerp(0, 100, t); yield return null; } yield return new WaitForSeconds(2f); Reset(); } void OnScoreChanged(int oldScore, int newScore){ Logger.Log($"Score changed from {oldScore} to {newScore}"); redScoreText.text = redScore.ToString(); blueScoreText.text = blueScore.ToString(); } public void Reset(bool kickoff = false) { StartCoroutine(CoroutineReset(kickoff)); } IEnumerator CoroutineReset(bool kickoff = false){ float resetDuration = 0.5f; if(!kickoff){ foreach(Puck puck in pucks){ puck.Reset(null, resetDuration); } } ball.GetComponent().Reset(resetDuration); yield return new WaitForSeconds(resetDuration); freezeInput=false; } } /// Headless dedicated server → matchmaker internal PATCH. Runs only where started (server-side coroutines). static class DedicatedMatschInternalApi { public static UnityWebRequest BuildRequest(string baseUrl, int matchId, string secret, string jsonBody) { string url = baseUrl.TrimEnd('/') + "/internal/match/" + matchId; return BuildPatchJson(url, secret, jsonBody); } public static UnityWebRequest BuildWinnerRequest(string baseUrl, int matchId, string secret, string jsonBody) { string url = baseUrl.TrimEnd('/') + "/internal/match/" + matchId + "/winner"; return BuildPatchJson(url, secret, jsonBody); } public static UnityWebRequest BuildRematchRequest(string baseUrl, string secret, string jsonBody) { string url = baseUrl.TrimEnd('/') + "/internal/rematch"; byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody); var req = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST) { uploadHandler = new UploadHandlerRaw(bodyRaw), downloadHandler = new DownloadHandlerBuffer() }; req.SetRequestHeader("Content-Type", "application/json"); req.SetRequestHeader("X-Dedicated-Server-Secret", secret); return req; } static UnityWebRequest BuildPatchJson(string url, string secret, string jsonBody) { byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody); var req = new UnityWebRequest(url, "PATCH") { uploadHandler = new UploadHandlerRaw(bodyRaw), downloadHandler = new DownloadHandlerBuffer() }; req.SetRequestHeader("Content-Type", "application/json"); req.SetRequestHeader("X-Dedicated-Server-Secret", secret); return req; } public static void LogIfFailed(UnityWebRequest req) { if (req.result == UnityWebRequest.Result.Success){ Logger.Log("Dedicated match PATCH success: " + req.responseCode + " " + (req.downloadHandler != null ? req.downloadHandler.text : "")); }else{ Logger.Log("Dedicated match PATCH failed: " + req.responseCode + " " + req.error + " " + (req.downloadHandler != null ? req.downloadHandler.text : "")); Logger.Log("More details : " + req.url + " " + req.method + " " + req.uploadHandler.data + " " + req.downloadHandler.text); } } /// Handles PATCH /internal/match/:id/winner: 200 parses economy; 409 idempotent (already paid). public static void LogWinnerPatchResult(UnityWebRequest req) { long code = req.responseCode; string body = req.downloadHandler != null ? req.downloadHandler.text : ""; if (code == 200 && req.result == UnityWebRequest.Result.Success) { try { var parsed = JsonUtility.FromJson(body); if (parsed != null && parsed.ok && parsed.economy != null) GameManager.MatchRcPrizeCoins = parsed.economy.rc_prize; } catch (Exception e) { Logger.Log("Dedicated winner PATCH: could not parse economy: " + e.Message + " body=" + body); } Logger.Log("Dedicated match winner PATCH success: " + code + " " + body); return; } if (code == 409) { Logger.Log("Dedicated match winner PATCH: winner already recorded (409 idempotent), no second payout. " + body); return; } Logger.Log("Dedicated match winner PATCH failed: " + code + " " + req.error + " " + body); Logger.Log("More details : " + req.url + " " + req.method + " " + body); } } [Serializable] class DedicatedWinnerPatchEconomy { public int entry_fee_rc; public int rc_prize; public int participant_cc; } [Serializable] class DedicatedWinnerPatchResponse { public bool ok; public int id; public int winner_id; public DedicatedWinnerPatchEconomy economy; } [Serializable] class DedicatedRematchRequestBody { public int user_red_id; public int user_blue_id; public int entry_fee; } [Serializable] class DedicatedRematchErrorEnvelope { public bool ok; public string error; }