1786 lines
61 KiB
C#
1786 lines
61 KiB
C#
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; }
|
||
/// <summary>Coin units from matchmaker <c>rc_prize</c>; used for in-game prize UI.</summary>
|
||
public static int MatchRcPrizeCoins { get; set; }
|
||
/// <summary>Participation CC amount shown on game over. Actual credit is decided by the winner PATCH.</summary>
|
||
public const int ParticipationCcAmount = 100;
|
||
/// <summary>Server-reported: red was still connected when the match ended (eligible for participation CC).</summary>
|
||
public static bool RedFinishedMatch { get; private set; } = true;
|
||
/// <summary>Server-reported: blue was still connected when the match ended (eligible for participation CC).</summary>
|
||
public static bool BlueFinishedMatch { get; private set; } = true;
|
||
/// <summary>Local player stayed connected through match end and should see +CC on game over.</summary>
|
||
public static bool LocalPlayerEarnedParticipationCc =>
|
||
MyTeam == Team.Blue ? BlueFinishedMatch : RedFinishedMatch;
|
||
/// <summary>Set by dedicated server startup (e.g. CupidConnector parsing <c>-matchId</c>). PATCH is skipped if id ≤ 0 or secret is empty.</summary>
|
||
public static int DedicatedMatchId { get; private set; }
|
||
/// <summary>Sent as <c>X-Dedicated-Server-Secret</c>. From <c>-dedicatedSecret</c> arg or <c>DEDICATED_SERVER_SECRET</c> env.</summary>
|
||
public static string DedicatedMatchSecret { get; private set; } = "";
|
||
/// <summary>Base URL for internal match API (no trailing slash), default <c>http://127.0.0.1:2612</c>.</summary>
|
||
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}");
|
||
}
|
||
|
||
/// <summary>Outcome of <see cref="RequestDedicatedRematch"/> (POST <c>/internal/rematch</c>).</summary>
|
||
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 ?? "";
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dedicated server only: POST <c>/internal/rematch</c> with <c>X-Dedicated-Server-Secret</c>.
|
||
/// Blocks until the request finishes (same pattern as <see cref="ReportDedicatedMatchRoomClosed"/>).
|
||
/// On HTTP 200, returns the same JSON envelope as a successful matchmaker GET room fill: <see cref="MatchmadeResponse"/>.
|
||
/// </summary>
|
||
/// <param name="userRedId"><c>users.id</c> for red.</param>
|
||
/// <param name="userBlueId"><c>users.id</c> for blue; must differ from red.</param>
|
||
/// <param name="entryFee">RC stake per player (non-negative).</param>
|
||
/// <param name="secret">Overrides <see cref="DedicatedMatchSecret"/> when non-empty.</param>
|
||
/// <param name="internalApiBaseUrl">Overrides <see cref="DedicatedInternalApiBase"/> when non-empty.</param>
|
||
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<MatchmadeResponse>(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<DedicatedRematchErrorEnvelope>(json);
|
||
if (err != null && !string.IsNullOrEmpty(err.error))
|
||
return err.error;
|
||
}
|
||
catch
|
||
{
|
||
/* ignore */
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/// <summary>
|
||
/// If entry fees were collected and no winner was reported yet, settle via blocking <c>/winner</c>
|
||
/// (remaining player or last-disconnect-wins) before room close / quit.
|
||
/// </summary>
|
||
public static void SettleWinnerIfNeededBeforeShutdown()
|
||
{
|
||
if (instance == null)
|
||
return;
|
||
instance.SettleWinnerIfNeededBeforeShutdownInstance();
|
||
}
|
||
|
||
/// <summary>Sets match <c>status</c> to <c>-1</c> (room closed). Settles winner first when escrow is open. Synchronous for <c>Application.Quit</c>.</summary>
|
||
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<Team> _disconnectOrder = new List<Team>();
|
||
int _consecutiveSkipsRed;
|
||
int _consecutiveSkipsBlue;
|
||
|
||
public static Action<Team> 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<Puck> pucks = new List<Puck>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Blends from <paramref name="centerMultiplier"/> at the pitch center to <paramref name="boundaryMultiplier"/>
|
||
/// at the boundary (and beyond), using the max normalized axis distance inside the field rectangle.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Server-only: called from <see cref="NetPlayer.CmdSetTeam"/> after <c>myTeam</c> is set.</summary>
|
||
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<NetPlayer>(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)");
|
||
}
|
||
}
|
||
|
||
/// <summary>Server-only: Mirror disconnect — track order and start forfeit grace after collect + kickoff.</summary>
|
||
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]);
|
||
}
|
||
|
||
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<DedicatedJoinPatchResponse>(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)
|
||
{
|
||
if (matchId <= 0 || string.IsNullOrEmpty(secret))
|
||
yield break;
|
||
|
||
string jsonBody = DedicatedMatschInternalApi.BuildWinnerPatchJson(winnerLower, redConnected, blueConnected);
|
||
using (var req = DedicatedMatschInternalApi.BuildWinnerRequest(baseUrl, matchId, secret, jsonBody))
|
||
{
|
||
yield return req.SendWebRequest();
|
||
DedicatedMatschInternalApi.LogWinnerPatchResult(req);
|
||
}
|
||
}
|
||
|
||
void ReportDedicatedMatchGameOver(Team winningTeam, bool redConnected, bool blueConnected)
|
||
{
|
||
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));
|
||
}
|
||
|
||
IEnumerator CoReportDedicatedMatchGameOver(Team winningTeam, bool redConnected, bool blueConnected)
|
||
{
|
||
yield return DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}");
|
||
string winner = winningTeam == Team.Red ? "red" : "blue";
|
||
yield return DedicatedMatchPatchWinner(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, winner, redConnected, blueConnected);
|
||
}
|
||
|
||
/// <summary>Score or forfeit end: latch game over, report winner if escrowed, notify clients.</summary>
|
||
void EndMatch(Team winningTeam)
|
||
{
|
||
if (!isServer || isGameEnded)
|
||
return;
|
||
|
||
isGameEnded = true;
|
||
ReplayRecorder.StopAndFlush();
|
||
bool redConnected = IsSideConnectedForParticipation(Team.Red);
|
||
bool blueConnected = IsSideConnectedForParticipation(Team.Blue);
|
||
Logger.Log($"Dedicated match: end winner={winningTeam} red_connected={redConnected} blue_connected={blueConnected}");
|
||
ApplyParticipationCcFlags(redConnected, blueConnected);
|
||
ReportDedicatedMatchGameOver(winningTeam, redConnected, blueConnected);
|
||
RpcGameOver(winningTeam, redConnected, blueConnected);
|
||
gameOver(winningTeam);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Server-side: participation CC is only for players still connected at settle.
|
||
/// Voluntary leave clears the connected flag before <see cref="EndMatch"/>.
|
||
/// </summary>
|
||
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<NetPlayer>(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)
|
||
{
|
||
RedFinishedMatch = redConnected;
|
||
BlueFinishedMatch = blueConnected;
|
||
}
|
||
|
||
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);
|
||
return;
|
||
}
|
||
|
||
if (!_blueConnected && _redConnected && _blueForfeitDeadline > 0f && now >= _blueForfeitDeadline)
|
||
{
|
||
Logger.Log("Dedicated match: blue forfeit grace expired — red wins");
|
||
EndMatch(Team.Red);
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
Logger.Log("Dedicated match: blocking winner settle before shutdown → " + winningTeam
|
||
+ " red_connected=" + redConnected + " blue_connected=" + blueConnected);
|
||
|
||
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);
|
||
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<Ball>();
|
||
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<Puck>();
|
||
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<Rigidbody2D>().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<NetPlayer>();
|
||
#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);
|
||
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);
|
||
}
|
||
|
||
/// <summary>Shown when this team starts a turn after already skipping once.</summary>
|
||
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);
|
||
}else if(redScore >= 3){
|
||
EndMatch(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, bool redConnected, bool blueConnected){
|
||
ApplyParticipationCcFlags(redConnected, blueConnected);
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Called when the local client spawns into a match. If three <see cref="NetPlayer"/> instances exist
|
||
/// (matchmaker sent an extra client into a full room), disconnect and return to the main menu.
|
||
/// </summary>
|
||
public void OnLocalPlayerJoinedMatch()
|
||
{
|
||
if (!isClient || _thirdPlayerLeaveTriggered)
|
||
return;
|
||
StartCoroutine(CoLeaveIfThirdPlayer());
|
||
}
|
||
|
||
/// <summary>
|
||
/// Voluntary leave: forfeit this player immediately so the opponent wins, then return to the menu.
|
||
/// </summary>
|
||
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<NetPlayer>();
|
||
if (fromIdentity != null)
|
||
{
|
||
leavingTeam = fromIdentity.myTeam;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
foreach (var player in FindObjectsByType<NetPlayer>(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);
|
||
}
|
||
|
||
IEnumerator CoLeaveIfThirdPlayer()
|
||
{
|
||
const int maxFrames = 30;
|
||
for (int i = 0; i < maxFrames; i++)
|
||
{
|
||
yield return null;
|
||
if (FindObjectsByType<NetPlayer>(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;
|
||
|
||
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<PlayerL10Response>(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<Ball>().Reset(resetDuration);
|
||
if(!kickoff){
|
||
foreach(Puck puck in pucks){
|
||
puck.Reset(null, resetDuration);
|
||
}
|
||
}
|
||
|
||
yield return new WaitForSeconds(resetDuration);
|
||
|
||
freezeInput=false;
|
||
}
|
||
|
||
|
||
|
||
|
||
}
|
||
|
||
/// <summary>Headless dedicated server → matchmaker internal PATCH. Runs only where started (server-side coroutines).</summary>
|
||
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)
|
||
{
|
||
return JsonUtility.ToJson(new DedicatedWinnerPatchRequest
|
||
{
|
||
winner = winnerLower,
|
||
red_connected = redConnected,
|
||
blue_connected = blueConnected
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>Handles <c>PATCH /internal/match/:id/winner</c>: 200 parses economy; 409 idempotent (already paid).</summary>
|
||
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<DedicatedWinnerPatchResponse>(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);
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
[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 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;
|
||
}
|