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; }
/// Clears match roster/team statics that survive scene unload (logout).
public static void ClearMatchSession()
{
MyTeam = default;
RedPlayer = null;
BluePlayer = null;
MatchRcPrizeCoins = 0;
RedFinishedMatch = true;
BlueFinishedMatch = true;
MatchWinningTeam = default;
MatchEndedByForfeit = false;
}
/// Participation CC amount shown on game over. Actual credit is decided by the winner PATCH.
public const int ParticipationCcAmount = 100;
/// Server-reported: red was still connected when the match ended. Not inferred from winner.
public static bool RedFinishedMatch { get; private set; } = true;
/// Server-reported: blue was still connected when the match ended. Not inferred from winner.
public static bool BlueFinishedMatch { get; private set; } = true;
/// Winning side at settle. Used with for game-over CC display.
public static Team MatchWinningTeam { get; private set; }
/// True for leave, disconnect, last-disconnect, and skip/auto-forfeit. Not shown in player UI.
public static bool MatchEndedByForfeit { get; private set; }
/// Local player should see +CC on game over: connected, and not the forfeit loser.
public static bool LocalPlayerEarnedParticipationCc
{
get
{
bool connected = MyTeam == Team.Blue ? BlueFinishedMatch : RedFinishedMatch;
if (!connected)
return false;
if (!MatchEndedByForfeit)
return true;
return MyTeam == MatchWinningTeam;
}
}
/// 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;
if (string.IsNullOrEmpty(secret))
secret = Environment.GetEnvironmentVariable("DEDICATED_SERVER_SECRET");
DedicatedMatchSecret = string.IsNullOrEmpty(secret)
? "38516e39d3406225b7c3015a66e5d25c54e9b2024c0755212122bdcea3e3a328"
: secret;
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 "";
}
///
/// If entry fees were collected and no winner was reported yet, settle via blocking /winner
/// (remaining player or last-disconnect-wins) before room close / quit.
///
public static void SettleWinnerIfNeededBeforeShutdown()
{
if (instance == null)
return;
instance.SettleWinnerIfNeededBeforeShutdownInstance();
}
/// Sets match status to -1 (room closed). Settles winner first when escrow is open. Synchronous for Application.Quit.
public static void ReportDedicatedMatchRoomClosed()
{
SettleWinnerIfNeededBeforeShutdown();
if (_dedicatedRoomClosedReported || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
return;
_dedicatedRoomClosedReported = true;
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);
const float DisconnectForfeitGraceSeconds = 15f;
const int ConsecutiveSkipsToForfeit = 2;
bool _dedicatedReportedRedJoin;
bool _dedicatedReportedBlueJoin;
bool _entriesCollected;
bool _collectFailed;
bool _winnerSettled;
bool _replayRecordingStarted;
bool _redConnected;
bool _blueConnected;
float _redForfeitDeadline = -1f;
float _blueForfeitDeadline = -1f;
readonly List _disconnectOrder = new List();
int _consecutiveSkipsRed;
int _consecutiveSkipsBlue;
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;
public float puckSelectMaxDistance = 4f;
[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)
{
Logger.Log($"Dedicated join: team assigned {team} isServer={isServer} matchId={DedicatedMatchId} secretEmpty={string.IsNullOrEmpty(DedicatedMatchSecret)} redJoin={_dedicatedReportedRedJoin} blueJoin={_dedicatedReportedBlueJoin} collected={_entriesCollected}");
if (!isServer)
return;
if (team == Team.Red)
{
_redConnected = true;
_redForfeitDeadline = -1f;
}
else if (team == Team.Blue)
{
_blueConnected = true;
_blueForfeitDeadline = -1f;
}
if (DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
{
Logger.Log($"Dedicated join: skipping PATCH (matchId={DedicatedMatchId}, secretEmpty={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;
bool collectAttempt = _dedicatedReportedRedJoin && _dedicatedReportedBlueJoin;
string json = "{\"red_joined_at\":\"" + ts + "\",\"status\":" + status + "}";
Logger.Log($"Dedicated join: starting red PATCH collectAttempt={collectAttempt} players={n} body={json}");
StartCoroutine(CoDedicatedMatchJoinPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json, collectAttempt));
}
else if (team == Team.Blue && !_dedicatedReportedBlueJoin)
{
_dedicatedReportedBlueJoin = true;
int status = n >= 2 ? 2 : 1;
bool collectAttempt = _dedicatedReportedRedJoin && _dedicatedReportedBlueJoin;
string json = "{\"blue_joined_at\":\"" + ts + "\",\"status\":" + status + "}";
Logger.Log($"Dedicated join: starting blue PATCH collectAttempt={collectAttempt} players={n} body={json}");
StartCoroutine(CoDedicatedMatchJoinPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json, collectAttempt));
}
else
{
Logger.Log($"Dedicated join: no new PATCH for {team} (already reported or unknown team)");
}
}
/// Server-only: Mirror disconnect — track order and start forfeit grace after collect + kickoff.
public void OnDedicatedMatchPlayerDisconnected(Team team)
{
if (!isServer)
return;
if (team != Team.Red && team != Team.Blue)
return;
if (team == Team.Red)
{
if (!_redConnected)
return;
_redConnected = false;
_redForfeitDeadline = Time.realtimeSinceStartup + DisconnectForfeitGraceSeconds;
}
else
{
if (!_blueConnected)
return;
_blueConnected = false;
_blueForfeitDeadline = Time.realtimeSinceStartup + DisconnectForfeitGraceSeconds;
}
_disconnectOrder.Add(team);
Logger.Log($"Dedicated match: {team} disconnected (order count={_disconnectOrder.Count})");
if (!_entriesCollected || _winnerSettled || !gameStarted)
return;
// Both gone → last player who disconnected wins immediately.
if (!_redConnected && !_blueConnected && _disconnectOrder.Count > 0)
EndMatch(_disconnectOrder[_disconnectOrder.Count - 1], forfeit: true);
}
IEnumerator CoDedicatedMatchJoinPatch(string baseUrl, int matchId, string secret, string json, bool collectAttempt)
{
if (matchId <= 0 || string.IsNullOrEmpty(secret))
{
Logger.Log($"Dedicated join PATCH: abort before send (matchId={matchId}, secretEmpty={string.IsNullOrEmpty(secret)})");
yield break;
}
using (var req = DedicatedMatschInternalApi.BuildRequest(baseUrl, matchId, secret, json))
{
Logger.Log("Dedicated join PATCH BEFORE: url=" + req.url
+ " method=" + req.method
+ " collectAttempt=" + collectAttempt
+ " body=" + json
+ " base=" + baseUrl);
yield return req.SendWebRequest();
long code = req.responseCode;
string body = req.downloadHandler != null ? req.downloadHandler.text : "";
Logger.Log("Dedicated join PATCH AFTER: result=" + req.result
+ " http=" + code
+ " error=" + (req.error ?? "")
+ " collectAttempt=" + collectAttempt
+ " body=" + body);
if (code >= 200 && code < 300)
{
DedicatedMatschInternalApi.LogIfFailed(req);
try
{
var parsed = JsonUtility.FromJson(body);
bool collected = parsed != null && (parsed.entries_collected || parsed.already_collected);
Logger.Log("Dedicated join PATCH parsed: ok=" + (parsed != null && parsed.ok)
+ " id=" + (parsed != null ? parsed.id : 0)
+ " entries_collected=" + (parsed != null && parsed.entries_collected)
+ " already_collected=" + (parsed != null && parsed.already_collected)
+ " escrow_user_id=" + (parsed != null ? parsed.escrow_user_id : 0)
+ " willSetCollected=" + collected);
if (collected)
{
_entriesCollected = true;
Logger.Log("Dedicated match: entry fees collected into escrow"
+ (parsed.already_collected ? " (already_collected)" : ""));
}
else
{
Logger.Log("Dedicated join PATCH: 2xx but entries not collected yet (waiting for other join or unexpected body)");
}
}
catch (Exception e)
{
Logger.Log("Dedicated join PATCH: could not parse body: " + e.Message + " body=" + body);
}
yield break;
}
DedicatedMatschInternalApi.LogIfFailed(req);
// Collect runs on the join that sets both timestamps; non-2xx then is fatal.
if (collectAttempt)
{
_collectFailed = true;
Logger.Log("Dedicated match: collect failed (HTTP " + code + ") — aborting kickoff, no winner call");
AbortMatchCollectFailed();
}
else
{
Logger.Log("Dedicated join PATCH: non-2xx on first join (http=" + code + ") — not aborting collect yet");
}
}
}
bool _abortLeaveStarted;
void AbortMatchCollectFailed()
{
RpcAbortMatch("Match cancelled", "The match could not start. Please try again.");
StartCoroutine(CoDisconnectAfterAbortRpc());
}
[ClientRpc]
void RpcAbortMatch(string title, string message)
{
ShowMatchAbortMessage(title, message);
// Host keeps the server up so the Rpc can reach the other client first.
if (!NetworkServer.active)
LeaveToMenuAfterAbort();
}
IEnumerator CoDisconnectAfterAbortRpc()
{
yield return new WaitForSecondsRealtime(0.5f);
if (NetworkServer.active)
NetworkServer.DisconnectAll();
if (isClient)
LeaveToMenuAfterAbort();
}
void NotifyAndLeaveMatch(string title, string message)
{
ShowMatchAbortMessage(title, message);
LeaveToMenuAfterAbort();
}
void ShowMatchAbortMessage(string title, string message)
{
NetManager.MarkIntentionalClientDisconnect();
if (CupidLobby.instance != null)
CupidLobby.instance.Cancel();
LoginManager.ClearMatchYourTeam();
MessageBoxDialog.Show(title, message);
}
void LeaveToMenuAfterAbort()
{
if (_abortLeaveStarted)
return;
_abortLeaveStarted = true;
if (LevelLoadManager.instance != null)
LevelLoadManager.instance.Leave();
else
LevelLoadManager.LoadLevel("MainMenu");
}
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, bool redConnected, bool blueConnected, int redScore, int blueScore, bool forfeit)
{
if (matchId <= 0 || string.IsNullOrEmpty(secret))
yield break;
string jsonBody = DedicatedMatschInternalApi.BuildWinnerPatchJson(winnerLower, redConnected, blueConnected, redScore, blueScore, forfeit);
using (var req = DedicatedMatschInternalApi.BuildWinnerRequest(baseUrl, matchId, secret, jsonBody))
{
yield return req.SendWebRequest();
DedicatedMatschInternalApi.LogWinnerPatchResult(req);
}
}
void ReportDedicatedMatchGameOver(Team winningTeam, bool redConnected, bool blueConnected, bool forfeit)
{
if (!isServer || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret) || _winnerSettled)
return;
if (!_entriesCollected)
{
Logger.Log("Dedicated match: skipping winner PATCH (entries not collected)");
return;
}
_winnerSettled = true;
StartCoroutine(CoReportDedicatedMatchGameOver(winningTeam, redConnected, blueConnected, forfeit));
}
IEnumerator CoReportDedicatedMatchGameOver(Team winningTeam, bool redConnected, bool blueConnected, bool forfeit)
{
yield return DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}");
string winner = winningTeam == Team.Red ? "red" : "blue";
yield return DedicatedMatchPatchWinner(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, winner, redConnected, blueConnected, redScore, blueScore, forfeit);
}
/// Score or forfeit end: latch game over, report winner if escrowed, notify clients.
void EndMatch(Team winningTeam, bool forfeit)
{
if (!isServer || isGameEnded)
return;
isGameEnded = true;
ReplayRecorder.StopAndFlush();
if (GameTutorialManager.tutorialModeEnabled)
GameTutorialManager.RecordCpuL10Result(winningTeam != MyTeam);
bool redConnected = IsSideConnectedForParticipation(Team.Red);
bool blueConnected = IsSideConnectedForParticipation(Team.Blue);
Logger.Log($"Dedicated match: end winner={winningTeam} red_connected={redConnected} blue_connected={blueConnected} red_score={redScore} blue_score={blueScore} forfeit={forfeit}");
ApplyParticipationCcFlags(redConnected, blueConnected, winningTeam, forfeit);
ReportDedicatedMatchGameOver(winningTeam, redConnected, blueConnected, forfeit);
RpcGameOver(winningTeam, redConnected, blueConnected, forfeit);
gameOver(winningTeam);
}
///
/// Server-side: participation CC is only for players still connected at settle.
/// Voluntary leave clears the connected flag before .
///
bool IsSideConnectedForParticipation(Team team)
{
if (team == Team.Red)
{
if (!_redConnected)
return false;
}
else if (team == Team.Blue)
{
if (!_blueConnected)
return false;
}
else
{
return false;
}
foreach (var player in FindObjectsByType(FindObjectsSortMode.None))
{
if (player == null || player.myTeam != team)
continue;
if (player.connectionToClient != null)
return player.connectionToClient.isReady;
// Listen-server host has no connectionToClient.
if (player.isLocalPlayer)
return true;
}
return false;
}
static void ApplyParticipationCcFlags(bool redConnected, bool blueConnected, Team winningTeam, bool forfeit)
{
RedFinishedMatch = redConnected;
BlueFinishedMatch = blueConnected;
MatchWinningTeam = winningTeam;
MatchEndedByForfeit = forfeit;
}
void HandleDisconnectForfeitTimers()
{
if (!_entriesCollected || _winnerSettled || !gameStarted || isGameEnded)
return;
float now = Time.realtimeSinceStartup;
if (!_redConnected && _blueConnected && _redForfeitDeadline > 0f && now >= _redForfeitDeadline)
{
Logger.Log("Dedicated match: red forfeit grace expired — blue wins");
EndMatch(Team.Blue, forfeit: true);
return;
}
if (!_blueConnected && _redConnected && _blueForfeitDeadline > 0f && now >= _blueForfeitDeadline)
{
Logger.Log("Dedicated match: blue forfeit grace expired — red wins");
EndMatch(Team.Red, forfeit: true);
return;
}
if (!_redConnected && !_blueConnected
&& _redForfeitDeadline > 0f && now >= _redForfeitDeadline
&& _blueForfeitDeadline > 0f && now >= _blueForfeitDeadline
&& _disconnectOrder.Count > 0)
{
Team winner = _disconnectOrder[_disconnectOrder.Count - 1];
Logger.Log("Dedicated match: both forfeit graces expired — last disconnect wins: " + winner);
EndMatch(winner, forfeit: true);
}
}
Team ResolveForfeitWinnerForShutdown()
{
if (_redConnected && !_blueConnected)
return Team.Red;
if (_blueConnected && !_redConnected)
return Team.Blue;
if (_disconnectOrder.Count > 0)
return _disconnectOrder[_disconnectOrder.Count - 1];
Logger.Log("Dedicated match: shutdown settle with no disconnect order — defaulting to red");
return Team.Red;
}
void SettleWinnerIfNeededBeforeShutdownInstance()
{
if (!_entriesCollected || _winnerSettled)
return;
if (DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
return;
Team winningTeam = ResolveForfeitWinnerForShutdown();
bool redConnected = IsSideConnectedForParticipation(Team.Red);
bool blueConnected = IsSideConnectedForParticipation(Team.Blue);
_winnerSettled = true;
isGameEnded = true;
ApplyParticipationCcFlags(redConnected, blueConnected, winningTeam, forfeit: true);
Logger.Log("Dedicated match: blocking winner settle before shutdown → " + winningTeam
+ " red_connected=" + redConnected + " blue_connected=" + blueConnected
+ " red_score=" + redScore + " blue_score=" + blueScore + " forfeit=true");
using (var statusReq = DedicatedMatschInternalApi.BuildRequest(
DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}"))
{
var op = statusReq.SendWebRequest();
while (!op.isDone)
System.Threading.Thread.Sleep(16);
DedicatedMatschInternalApi.LogIfFailed(statusReq);
}
string winner = winningTeam == Team.Red ? "red" : "blue";
string jsonBody = DedicatedMatschInternalApi.BuildWinnerPatchJson(winner, redConnected, blueConnected, redScore, blueScore, forfeit: true);
using (var winReq = DedicatedMatschInternalApi.BuildWinnerRequest(
DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, jsonBody))
{
var op = winReq.SendWebRequest();
while (!op.isDone)
System.Threading.Thread.Sleep(16);
DedicatedMatschInternalApi.LogWinnerPatchResult(winReq);
}
}
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);
}
int resetCollisionLockCount;
public bool AreResetCollisionsDisabled => resetCollisionLockCount > 0;
public void PushResetCollisionLock()
{
resetCollisionLockCount++;
if (resetCollisionLockCount == 1)
SetPlayCollisionsEnabled(false);
}
public void PopResetCollisionLock()
{
resetCollisionLockCount = Mathf.Max(0, resetCollisionLockCount - 1);
if (resetCollisionLockCount == 0)
SetPlayCollisionsEnabled(true);
}
void SetPlayCollisionsEnabled(bool enabled)
{
foreach (Puck puck in pucks)
{
if (puck != null)
puck.SetCollisionsEnabled(enabled);
}
if (ball != null)
{
Ball ballComp = ball.GetComponent();
if (ballComp != null)
ballComp.SetCollisionsEnabled(enabled);
}
}
[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 && dist <= puckSelectMaxDistance)
{
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);
if (isServer)
{
bool wasWarned = GetConsecutiveSkips(SelectedTeam) > 0;
ClearConsecutiveSkips(SelectedTeam);
if (wasWarned)
RpcHideSkipForfeitWarning(SelectedTeam);
}
SwitchTeams();
}
public 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);
MaybeWarnIfAtRiskOfSkipForfeit(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){
if(GameTutorialManager.tutorialModeEnabled){
gameStarted = true;
GameCanvas.instance.HideWaitingForOpponentPanel();
Logger.Log("Game started On localhost, tutorial mode enabled");
}
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)
{
// Dedicated matches: wait until API collected entry fees into escrow.
if (DedicatedMatchId > 0)
{
if (_entriesCollected && !_collectFailed)
{
gameStarted = true;
GameCanvas.instance.HideWaitingForOpponentPanel();
Logger.Log("Game started On Server (entries collected)");
}
else if (Time.frameCount % 300 == 0)
{
Logger.Log("Dedicated kickoff waiting: players=2 collected=" + _entriesCollected
+ " collectFailed=" + _collectFailed
+ " redJoin=" + _dedicatedReportedRedJoin
+ " blueJoin=" + _dedicatedReportedBlueJoin
+ " matchId=" + DedicatedMatchId);
}
}
else
{
gameStarted = true;
GameCanvas.instance.HideWaitingForOpponentPanel();
Logger.Log("Game started On Server");
}
}
}
HandleDisconnectForfeitTimers();
// Host practice (tutorial) and dedicated matches both record once NetworkServer is ready.
// Retry until StartRecording succeeds — StartHost can lag one frame behind gameStarted.
if (gameStarted && !_replayRecordingStarted && NetworkServer.active)
{
if (ReplayRecorder.StartRecording(DedicatedMatchId, this))
_replayRecordingStarted = true;
}
}
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;
}
}
bool turnTimerPaused;
public void SetTurnTimerPaused(bool paused)
{
if (!isServer)
{
CmdSetTurnTimerPaused(paused);
return;
}
turnTimerPaused = paused;
}
[Command(requiresAuthority = false)]
void CmdSetTurnTimerPaused(bool paused)
{
turnTimerPaused = paused;
}
void HandleTurnTimer(){
if (turnTimerPaused || isGameEnded)
return;
if(turnTimerCounter < turnTimer){
if(!isMoving && !switchingTeams && gameStarted){
turnTimerCounter += Time.deltaTime;
}
}else{
OnTurnTimerExpired();
turnTimerCounter = 0;
}
}
void OnTurnTimerExpired()
{
if (!isServer || !gameStarted || isGameEnded)
return;
if (GameTutorialManager.tutorialModeEnabled)
{
SwitchTeams();
return;
}
Team skippingTeam = SelectedTeam;
int skips = IncrementConsecutiveSkips(skippingTeam);
Logger.Log($"Match: {skippingTeam} skipped turn ({skips}/{ConsecutiveSkipsToForfeit})");
if (skips >= ConsecutiveSkipsToForfeit)
{
Team winner = GetOpposingTeam(skippingTeam);
Logger.Log($"Match: {skippingTeam} forfeited for skipping two consecutive turns — {winner} wins");
RpcHideSkipForfeitWarning(skippingTeam);
EndMatch(winner, forfeit: true);
return;
}
SwitchTeams();
}
int GetConsecutiveSkips(Team team)
{
return team == Team.Red ? _consecutiveSkipsRed : _consecutiveSkipsBlue;
}
int IncrementConsecutiveSkips(Team team)
{
if (team == Team.Red)
return ++_consecutiveSkipsRed;
return ++_consecutiveSkipsBlue;
}
void ClearConsecutiveSkips(Team team)
{
if (team == Team.Red)
_consecutiveSkipsRed = 0;
else
_consecutiveSkipsBlue = 0;
}
void MaybeWarnIfAtRiskOfSkipForfeit(Team team)
{
if (!isServer || !gameStarted || isGameEnded)
return;
if (GameTutorialManager.tutorialModeEnabled)
return;
if (GetConsecutiveSkips(team) <= 0)
return;
RpcShowSkipForfeitWarning(team);
}
[ClientRpc]
void RpcShowSkipForfeitWarning(Team team)
{
ShowSkipForfeitWarning(team);
}
[ClientRpc]
void RpcHideSkipForfeitWarning(Team team)
{
HideSkipForfeitWarning(team);
}
/// Shown when this team starts a turn after already skipping once.
public void ShowSkipForfeitWarning(Team team)
{
if (NetPlayer.localPlayer != null && NetPlayer.localPlayer.myTeam != team)
return;
if (GameCanvas.instance == null)
return;
GameCanvas.instance.ShowKickWarning();
}
public void HideSkipForfeitWarning(Team team)
{
if (NetPlayer.localPlayer != null && NetPlayer.localPlayer.myTeam != team)
return;
if (GameCanvas.instance == null)
return;
GameCanvas.instance.HideKickWarning();
}
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);
MaybeWarnIfAtRiskOfSkipForfeit(SelectedTeam);
}
public void OnGoal(Team team){
if(!isServer){
Debug.LogWarning("OnGoal called on client, skipping");
return;}
if(!CanProcessGoal){
return;
}
RpcPlayGoalScoredSfx();
freezeInput=true;
CancelInGoalPuckResets();
Team concedingTeam = team;
if(hitCounter == 1){
//kickoff goal
Logger.Log("Kickoff goal");
ReplayRecorder.RecordGoal(concedingTeam, redScore, blueScore, kickoff: true);
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}");
}
ReplayRecorder.RecordGoal(concedingTeam, redScore, blueScore, kickoff: false);
SetKickoffTeam(concedingTeam);
if(blueScore >= 3){
EndMatch(Team.Blue, forfeit: false);
}else if(redScore >= 3){
EndMatch(Team.Red, forfeit: false);
}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, bool redConnected, bool blueConnected, bool forfeit){
ApplyParticipationCcFlags(redConnected, blueConnected, team, forfeit);
gameOver(team);
}
void gameOver(Team team){
StartCoroutine(CoroutineGameOver(team));
}
IEnumerator CoroutineGameOver(Team team){
if (GameCanvas.instance != null)
yield return GameCanvas.instance.ShowRandomPostGamePanelThenHide();
else
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;
bool _leaveRequested;
///
/// 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());
}
///
/// Voluntary leave: forfeit this player immediately so the opponent wins, then return to the menu.
///
public void Leave()
{
if (_leaveRequested)
return;
_leaveRequested = true;
bool shouldForfeit = gameStarted && !isGameEnded;
if (shouldForfeit)
{
if (isServer)
ForfeitLeavingTeam(ResolveLocalLeavingTeam());
else
CmdForfeit();
StartCoroutine(CoLeaveAfterForfeitSent());
return;
}
DoLeaveToMenu();
}
IEnumerator CoLeaveAfterForfeitSent()
{
// Let the forfeit Command / game-over RPC flush before tearing down the connection.
yield return null;
yield return new WaitForSecondsRealtime(0.1f);
DoLeaveToMenu();
}
void DoLeaveToMenu()
{
if (LevelLoadManager.instance != null)
LevelLoadManager.instance.Leave();
}
[Command(requiresAuthority = false)]
void CmdForfeit(NetworkConnectionToClient sender = null)
{
Team leavingTeam;
if (!TryResolveLeavingTeam(sender, out leavingTeam))
{
Logger.Log("Forfeit: could not resolve leaving team from sender");
return;
}
ForfeitLeavingTeam(leavingTeam);
}
bool TryResolveLeavingTeam(NetworkConnectionToClient sender, out Team leavingTeam)
{
leavingTeam = Team.Red;
if (sender != null)
{
if (sender.identity != null)
{
var fromIdentity = sender.identity.GetComponent();
if (fromIdentity != null)
{
leavingTeam = fromIdentity.myTeam;
return true;
}
}
foreach (var player in FindObjectsByType(FindObjectsSortMode.None))
{
if (player != null && player.connectionToClient == sender)
{
leavingTeam = player.myTeam;
return true;
}
}
}
if (isServer && isClient)
{
leavingTeam = ResolveLocalLeavingTeam();
return leavingTeam == Team.Red || leavingTeam == Team.Blue;
}
return false;
}
Team ResolveLocalLeavingTeam()
{
if (NetPlayer.localPlayer != null)
return NetPlayer.localPlayer.myTeam;
return MyTeam;
}
void ForfeitLeavingTeam(Team leavingTeam)
{
if (!isServer || isGameEnded)
return;
if (!gameStarted)
return;
if (leavingTeam != Team.Red && leavingTeam != Team.Blue)
return;
if (leavingTeam == Team.Red)
_redConnected = false;
else if (leavingTeam == Team.Blue)
_blueConnected = false;
Team winner = GetOpposingTeam(leavingTeam);
Logger.Log($"Match: {leavingTeam} forfeited by leave — {winner} wins");
EndMatch(winner, forfeit: true);
}
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.");
NotifyAndLeaveMatch("Unable to join", "This match is already full.");
yield break;
}
}
}
public void StopClient(){
try{
NetManager.StopNetworkingForSceneChange();
}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;
if (GameTutorialManager.tutorialModeEnabled)
GameTutorialManager.ApplyCpuL10(opp);
string baseUrl = (DedicatedInternalApiBase ?? "").TrimEnd('/');
if (string.IsNullOrEmpty(baseUrl))
{
LevelLoadManager.myPlayer = my;
LevelLoadManager.opponentPlayer = opp;
yield break;
}
if (my.UserId > 0){
yield return CoFetchPlayerL10Apply(baseUrl, my);
}
if (!GameTutorialManager.tutorialModeEnabled && 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;
void CancelInGoalPuckResets()
{
foreach (Puck puck in pucks)
{
if (puck != null)
puck.CancelInGoalReset();
}
}
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){
ReplayRecorder.RecordReset(kickoff);
float resetDuration = 0.5f;
ball.GetComponent().Reset(resetDuration);
if(!kickoff){
foreach(Puck puck in pucks){
puck.Reset(null, 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 string BuildWinnerPatchJson(string winnerLower, bool redConnected, bool blueConnected, int redScore, int blueScore, bool forfeit)
{
return JsonUtility.ToJson(new DedicatedWinnerPatchRequest
{
winner = winnerLower,
red_connected = redConnected,
blue_connected = blueConnected,
red_score = redScore,
blue_score = blueScore,
forfeit = forfeit
});
}
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;
Logger.Log("Dedicated winner PATCH economy: rc_prize=" + parsed.economy.rc_prize
+ " participant_cc=" + parsed.economy.participant_cc
+ " cc_awarded_red=" + parsed.economy.cc_awarded_red
+ " cc_awarded_blue=" + parsed.economy.cc_awarded_blue
+ " mmr_delta=" + parsed.mmr_delta
+ " forfeit=" + parsed.forfeit
+ " already_settled=" + parsed.already_settled);
}
}
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 DedicatedJoinPatchResponse
{
public bool ok;
public int id;
public bool entries_collected;
public bool already_collected;
public int escrow_user_id;
}
[Serializable]
class DedicatedWinnerPatchRequest
{
public string winner;
public bool red_connected;
public bool blue_connected;
public int red_score;
public int blue_score;
public bool forfeit;
}
[Serializable]
class DedicatedWinnerPatchEconomy
{
public int entry_fee_rc;
public int rc_prize;
public int participant_cc;
public bool cc_awarded_red;
public bool cc_awarded_blue;
}
[Serializable]
class DedicatedWinnerPatchResponse
{
public bool ok;
public int id;
public int winner_id;
public bool already_settled;
public DedicatedWinnerPatchEconomy economy;
public int mmr_delta;
public bool forfeit;
}
[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;
}