1099 lines
37 KiB
C#
1099 lines
37 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>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;
|
||
DedicatedMatchSecret = secret ?? "38516e39d3406225b7c3015a66e5d25c54e9b2024c0755212122bdcea3e3a328";
|
||
if (!string.IsNullOrWhiteSpace(internalApiBaseUrl))
|
||
DedicatedInternalApiBase = internalApiBaseUrl.TrimEnd('/');
|
||
|
||
Logger.Log($"Configured dedicated match reporting for match id {matchId} with secret {DedicatedMatchSecret} and internal api base {DedicatedInternalApiBase}");
|
||
}
|
||
|
||
/// <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>Sets match <c>status</c> to <c>-1</c> (room closed). Safe to call from shutdown paths; runs synchronously so it completes before <c>Application.Quit</c>.</summary>
|
||
public static void ReportDedicatedMatchRoomClosed()
|
||
{
|
||
if (_dedicatedRoomClosedReported || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
|
||
return;
|
||
_dedicatedRoomClosedReported = true;
|
||
|
||
if (DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
|
||
return;
|
||
|
||
using (var req = DedicatedMatschInternalApi.BuildRequest(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":-1}"))
|
||
{
|
||
var op = req.SendWebRequest();
|
||
while (!op.isDone)
|
||
System.Threading.Thread.Sleep(16);
|
||
DedicatedMatschInternalApi.LogIfFailed(req);
|
||
}
|
||
}
|
||
|
||
static bool _dedicatedRoomClosedReported;
|
||
static string Iso8601UtcNow() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", System.Globalization.CultureInfo.InvariantCulture);
|
||
|
||
bool _dedicatedReportedRedJoin;
|
||
bool _dedicatedReportedBlueJoin;
|
||
bool _dedicatedReportedGameOver;
|
||
|
||
public static Action<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;
|
||
|
||
[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)
|
||
{
|
||
if (!isServer || DedicatedMatchId <= 0 || 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;
|
||
string json = "{\"red_joined_at\":\"" + ts + "\",\"status\":" + status + "}";
|
||
StartCoroutine(DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json));
|
||
// StartCoroutine(DedicatedMatchInternalApi.Patch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json));
|
||
}
|
||
else if (team == Team.Blue && !_dedicatedReportedBlueJoin)
|
||
{
|
||
_dedicatedReportedBlueJoin = true;
|
||
int status = n >= 2 ? 2 : 1;
|
||
string json = "{\"blue_joined_at\":\"" + ts + "\",\"status\":" + status + "}";
|
||
StartCoroutine(DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json));
|
||
}
|
||
}
|
||
|
||
IEnumerator DedicatedMatchPatch(string baseUrl, int matchId, string secret, string json){
|
||
if (matchId <= 0 || string.IsNullOrEmpty(secret))
|
||
yield break;
|
||
|
||
using (var req = DedicatedMatschInternalApi.BuildRequest(baseUrl, matchId, secret, json))
|
||
{
|
||
yield return req.SendWebRequest();
|
||
DedicatedMatschInternalApi.LogIfFailed(req);
|
||
}
|
||
}
|
||
|
||
public IEnumerator DedicatedMatchPatchWinner(string baseUrl, int matchId, string secret, string winnerLower)
|
||
{
|
||
if (matchId <= 0 || string.IsNullOrEmpty(secret))
|
||
yield break;
|
||
|
||
string jsonBody = "{\"winner\":\"" + winnerLower + "\"}";
|
||
using (var req = DedicatedMatschInternalApi.BuildWinnerRequest(baseUrl, matchId, secret, jsonBody))
|
||
{
|
||
yield return req.SendWebRequest();
|
||
DedicatedMatschInternalApi.LogWinnerPatchResult(req);
|
||
}
|
||
}
|
||
|
||
void ReportDedicatedMatchGameOver(Team winningTeam)
|
||
{
|
||
if (!isServer || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret) || _dedicatedReportedGameOver)
|
||
return;
|
||
_dedicatedReportedGameOver = true;
|
||
StartCoroutine(CoReportDedicatedMatchGameOver(winningTeam));
|
||
}
|
||
|
||
IEnumerator CoReportDedicatedMatchGameOver(Team winningTeam)
|
||
{
|
||
yield return DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}");
|
||
string winner = winningTeam == Team.Red ? "red" : "blue";
|
||
yield return DedicatedMatchPatchWinner(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, winner);
|
||
}
|
||
|
||
|
||
|
||
|
||
void Start()
|
||
{
|
||
GameEvents.OnSelectedPuckChanged?.Invoke(null);
|
||
|
||
#if UNITY_EDITOR
|
||
|
||
#else
|
||
Application.targetFrameRate = isServer ? 30 : 100;
|
||
#endif
|
||
}
|
||
|
||
|
||
public void RegisterPuck(Puck puck){
|
||
pucks.Add(puck);
|
||
}
|
||
|
||
public void DisposePuck(Puck puck){
|
||
pucks.Remove(puck);
|
||
}
|
||
|
||
|
||
|
||
[SyncVar(hook = nameof(OnSelectedPuckChanged))]
|
||
[SerializeField]Puck selectedPuck;
|
||
|
||
void OnSelectedPuckChanged(Puck oldPuck, Puck newPuck){
|
||
// Logger.Log($"Selected puck changed from {oldPuck} to {newPuck}");
|
||
GameEvents.OnSelectedPuckChanged?.Invoke(newPuck);
|
||
if (newPuck == null && isClient && CameraEffects.instance != null){
|
||
CameraEffects.instance.ReleaseStretchFactor();
|
||
}
|
||
}
|
||
public static Puck SelectedPuck{
|
||
get{
|
||
return instance != null ? instance.selectedPuck : null;
|
||
}
|
||
|
||
set{
|
||
if (instance == null)
|
||
return;
|
||
instance.SetSelectedPuck(value);
|
||
}
|
||
}
|
||
void SetSelectedPuck(Puck puck)
|
||
{
|
||
selectedPuck = puck;
|
||
GameEvents.OnSelectedPuckChanged?.Invoke(puck);
|
||
}
|
||
|
||
public Puck GetClosestPuck(Vector2 position){
|
||
if(IsMoving()){return null;} //Do not do anything if shits moving
|
||
|
||
// Convert screen position to world position
|
||
// Vector3 worldPosition = Camera.main.ScreenToWorldPoint(new Vector3(position.x, position.y, Camera.main.nearClipPlane));
|
||
|
||
// Find all pucks in the scene
|
||
Puck[] pucks = FindObjectsOfType<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)
|
||
{
|
||
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);
|
||
|
||
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);
|
||
|
||
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)
|
||
{
|
||
gameStarted = true;
|
||
GameCanvas.instance.HideWaitingForOpponentPanel();
|
||
Logger.Log("Game started On Server");
|
||
}
|
||
}
|
||
}
|
||
|
||
UpdateLocalTurnTimerBeep();
|
||
UpdateTimerColor();
|
||
}
|
||
|
||
void UpdateLocalTurnTimerBeep()
|
||
{
|
||
if (!isClient || NetPlayer.localPlayer == null)
|
||
return;
|
||
|
||
if (!gameStarted || SelectedTeam != NetPlayer.localPlayer.myTeam)
|
||
{
|
||
_lastTurnTimerBeepFloor = -1;
|
||
return;
|
||
}
|
||
|
||
if (m_isMoving || turnTimerCounter >= turnTimer)
|
||
return;
|
||
|
||
float remaining = turnTimer - turnTimerCounter;
|
||
int floorSec = Mathf.FloorToInt(remaining);
|
||
|
||
if (floorSec != _lastTurnTimerBeepFloor)
|
||
{
|
||
if (_lastTurnTimerBeepFloor >= 0 && floorSec < _lastTurnTimerBeepFloor
|
||
&& AudioManager.instance != null)
|
||
AudioManager.instance.PlayTimerBeep();
|
||
|
||
_lastTurnTimerBeepFloor = floorSec;
|
||
}
|
||
}
|
||
|
||
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)
|
||
return;
|
||
|
||
if(turnTimerCounter < turnTimer){
|
||
if(!isMoving && !switchingTeams && gameStarted){
|
||
turnTimerCounter += Time.deltaTime;
|
||
}
|
||
}else{
|
||
SwitchTeams();
|
||
turnTimerCounter = 0;
|
||
}
|
||
}
|
||
|
||
float flashPhase = 0f;
|
||
float lastUpdateTime = 0f;
|
||
float lastRemainingTime = 10f;
|
||
|
||
int _lastTurnTimerBeepFloor = -1;
|
||
|
||
void UpdateTimerColor(){
|
||
float remainingTime = turnTimer - turnTimerCounter;
|
||
|
||
// Detect timer reset: if remainingTime jumped from low to high, reset phase
|
||
if(remainingTime > lastRemainingTime + 2f){
|
||
flashPhase = 0f;
|
||
lastUpdateTime = 0f;
|
||
}
|
||
lastRemainingTime = remainingTime;
|
||
|
||
CanvasGroupUtils activeTimerGroup = SelectedTeam == Team.Red ? redTimerGroup : blueTimerGroup;
|
||
CanvasGroupUtils inactiveTimerGroup = SelectedTeam == Team.Red ? blueTimerGroup : redTimerGroup;
|
||
|
||
if(inactiveTimerGroup != null){
|
||
inactiveTimerGroup.overrideColor = Color.white;
|
||
}
|
||
|
||
if(activeTimerGroup == null){
|
||
if(remainingTime >= 5f || !gameStarted){
|
||
flashPhase = 0f;
|
||
lastUpdateTime = 0f;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if(remainingTime < 5f && gameStarted){
|
||
// Calculate intensity based on how close timer is to 0
|
||
// Intensity increases from 0 to 1 as timer goes from 5 to 0
|
||
float intensity = 1f - (remainingTime / 5f);
|
||
|
||
// Create flashing effect using sine wave
|
||
// Flash speed increases as timer approaches 0
|
||
float flashSpeed = Mathf.Lerp(minFlashSpeed, maxFlashSpeed, intensity);
|
||
|
||
// Update phase based on delta time and current flash speed
|
||
// This ensures smooth flashing that resets with the timer
|
||
if(lastUpdateTime > 0f){
|
||
float deltaTime = Time.time - lastUpdateTime;
|
||
flashPhase += flashSpeed * deltaTime;
|
||
} else {
|
||
// First frame in warning zone, initialize phase
|
||
flashPhase = 0f;
|
||
}
|
||
lastUpdateTime = Time.time;
|
||
|
||
float flashValue = (Mathf.Sin(flashPhase) + 1f) * 0.5f; // 0 to 1
|
||
|
||
// Combine intensity with flash for final red intensity
|
||
// As timer approaches 0, intensity increases and flash becomes more prominent
|
||
float finalRedIntensity = intensity * (0.3f + flashValue * 0.7f); // Range: intensity*0.3 to intensity*1
|
||
|
||
// Lerp between white and red based on final intensity, keeping alpha at 1
|
||
Color white = Color.white;
|
||
Color red = Color.red;
|
||
activeTimerGroup.overrideColor = Color.Lerp(white, red, finalRedIntensity);
|
||
}else{
|
||
// Reset phase when timer is above 5 seconds or game hasn't started
|
||
flashPhase = 0f;
|
||
lastUpdateTime = 0f;
|
||
activeTimerGroup.overrideColor = Color.white;
|
||
}
|
||
}
|
||
|
||
|
||
Team GetOpposingTeam(Team team){
|
||
return team == Team.Red ? Team.Blue : Team.Red;
|
||
}
|
||
|
||
void SetKickoffTeam(Team kickoffTeam){
|
||
turnTimerCounter = 0;
|
||
SelectedTeam = kickoffTeam;
|
||
OnTeamChanged?.Invoke(SelectedTeam);
|
||
}
|
||
|
||
public void OnGoal(Team team){
|
||
if(!isServer){
|
||
Debug.LogWarning("OnGoal called on client, skipping");
|
||
return;}
|
||
if(!CanProcessGoal){
|
||
return;
|
||
}
|
||
|
||
RpcPlayGoalScoredSfx();
|
||
|
||
freezeInput=true;
|
||
Team concedingTeam = team;
|
||
|
||
if(hitCounter == 1){
|
||
//kickoff goal
|
||
Logger.Log("Kickoff goal");
|
||
SetKickoffTeam(concedingTeam);
|
||
StartCoroutine(CoroutineOnGoal(team,true));//true = Kickoff goal, reset only the ball
|
||
hitCounter = 0;
|
||
|
||
return;
|
||
}
|
||
if(team == Team.Blue){
|
||
blueScore++;
|
||
blueScoreText.text = blueScore.ToString();
|
||
PlayGoalEffects(Team.Blue);
|
||
Logger.Log($"Blue goal scored, blue score is now {blueScore}");
|
||
}else{
|
||
redScore++;
|
||
redScoreText.text = redScore.ToString();
|
||
PlayGoalEffects(Team.Red);
|
||
Logger.Log($"Red goal scored, red score is now {redScore}");
|
||
}
|
||
|
||
SetKickoffTeam(concedingTeam);
|
||
|
||
|
||
if(blueScore >= 3){
|
||
isGameEnded = true;
|
||
ReportDedicatedMatchGameOver(Team.Blue);
|
||
RpcGameOver(Team.Blue);
|
||
gameOver(Team.Blue);
|
||
}else if(redScore >= 3){
|
||
isGameEnded = true;
|
||
ReportDedicatedMatchGameOver(Team.Red);
|
||
RpcGameOver(Team.Red);
|
||
gameOver(Team.Red);
|
||
}else{
|
||
StartCoroutine(CoroutineOnGoal(team));
|
||
}
|
||
|
||
hitCounter = 0;
|
||
}
|
||
|
||
void PlayGoalEffects(Team team){
|
||
if(isServer){
|
||
m_PlayGoalEffects(team);
|
||
RpcPlayGoalEffects(team);
|
||
}else{
|
||
CmdPlayGoalEffects(team);
|
||
}
|
||
}
|
||
|
||
[Command]
|
||
void CmdPlayGoalEffects(Team team){
|
||
m_PlayGoalEffects(team);
|
||
RpcPlayGoalEffects(team);
|
||
}
|
||
|
||
[ClientRpc]
|
||
void RpcPlayGoalEffects(Team team){
|
||
m_PlayGoalEffects(team);
|
||
}
|
||
|
||
void m_PlayGoalEffects(Team team){
|
||
if(team == Team.Blue){
|
||
foreach(ParticleSystem effect in blueGoalEffects){
|
||
effect.Play();
|
||
}
|
||
}else{
|
||
foreach(ParticleSystem effect in redGoalEffects){
|
||
effect.Play();
|
||
}
|
||
}
|
||
}
|
||
|
||
[ClientRpc]
|
||
void RpcPlayGoalScoredSfx()
|
||
{
|
||
if (AudioManager.instance != null)
|
||
AudioManager.instance.PlayGoalScoredSfxSequence();
|
||
}
|
||
|
||
[ClientRpc]
|
||
void RpcGameOver(Team team){
|
||
gameOver(team);
|
||
}
|
||
|
||
void gameOver(Team team){
|
||
StartCoroutine(CoroutineGameOver(team));
|
||
}
|
||
|
||
IEnumerator CoroutineGameOver(Team team){
|
||
yield return new WaitForSeconds(2f);
|
||
yield return CoroutineFetchBothPlayersL10();
|
||
|
||
// StopClient();
|
||
|
||
LevelLoadManager.instance.SetupGameOver(team,redScore,blueScore);
|
||
GameOverCanvas.instance.Show(team, MyTeam, redScore, blueScore);
|
||
// LevelLoadManager.LoadLevel("MainMenu");
|
||
}
|
||
|
||
bool _thirdPlayerLeaveTriggered;
|
||
|
||
/// <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());
|
||
}
|
||
|
||
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.");
|
||
if (LevelLoadManager.instance != null)
|
||
LevelLoadManager.instance.Leave();
|
||
else
|
||
LevelLoadManager.LoadLevel("MainMenu");
|
||
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;
|
||
|
||
IEnumerator CoroutineOnGoal(Team team, bool kickoff = false){
|
||
if(coroutinePostLaunch!=null){
|
||
StopCoroutine(coroutinePostLaunch);
|
||
}
|
||
|
||
float t=0;
|
||
while(t < 1){
|
||
t +=Time.deltaTime * 2f;
|
||
ball.linearDamping = Mathf.Lerp(0, 100, t);
|
||
yield return null;
|
||
}
|
||
|
||
yield return new WaitForSeconds(2f);
|
||
|
||
Reset();
|
||
}
|
||
|
||
void OnScoreChanged(int oldScore, int newScore){
|
||
Logger.Log($"Score changed from {oldScore} to {newScore}");
|
||
redScoreText.text = redScore.ToString();
|
||
blueScoreText.text = blueScore.ToString();
|
||
}
|
||
|
||
|
||
public void Reset(bool kickoff = false)
|
||
{
|
||
StartCoroutine(CoroutineReset(kickoff));
|
||
}
|
||
|
||
IEnumerator CoroutineReset(bool kickoff = false){
|
||
float resetDuration = 0.5f;
|
||
if(!kickoff){
|
||
foreach(Puck puck in pucks){
|
||
puck.Reset(null, resetDuration);
|
||
}
|
||
}
|
||
|
||
ball.GetComponent<Ball>().Reset(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 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;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Logger.Log("Dedicated winner PATCH: could not parse economy: " + e.Message + " body=" + body);
|
||
}
|
||
|
||
Logger.Log("Dedicated match winner PATCH success: " + code + " " + body);
|
||
return;
|
||
}
|
||
|
||
if (code == 409)
|
||
{
|
||
Logger.Log("Dedicated match winner PATCH: winner already recorded (409 idempotent), no second payout. " + body);
|
||
return;
|
||
}
|
||
|
||
Logger.Log("Dedicated match winner PATCH failed: " + code + " " + req.error + " " + body);
|
||
Logger.Log("More details : " + req.url + " " + req.method + " " + body);
|
||
}
|
||
}
|
||
|
||
[Serializable]
|
||
class DedicatedWinnerPatchEconomy
|
||
{
|
||
public int entry_fee_rc;
|
||
public int rc_prize;
|
||
public int participant_cc;
|
||
}
|
||
|
||
[Serializable]
|
||
class DedicatedWinnerPatchResponse
|
||
{
|
||
public bool ok;
|
||
public int id;
|
||
public int winner_id;
|
||
public DedicatedWinnerPatchEconomy economy;
|
||
}
|
||
|
||
[Serializable]
|
||
class DedicatedRematchRequestBody
|
||
{
|
||
public int user_red_id;
|
||
public int user_blue_id;
|
||
public int entry_fee;
|
||
}
|
||
|
||
[Serializable]
|
||
class DedicatedRematchErrorEnvelope
|
||
{
|
||
public bool ok;
|
||
public string error;
|
||
}
|