using System; using System.Collections; using System.Collections.Generic; using TMPro; using Unity.Netcode; using UnityEngine; public struct PlayerScore : INetworkSerializable, System.IEquatable { public ulong ClientId; public int Score; public bool Equals(PlayerScore _other) { return ClientId == _other.ClientId && Score == _other.Score; } public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter { serializer.SerializeValue(ref ClientId); serializer.SerializeValue(ref Score); } } public class RoundManager : NetworkBehaviour { public static RoundManager Current { get; private set; } [SerializeField] private float roundStartDelay = 3f; [SerializeField] private Transform[] spawnPoints; private NetworkVariable roundDuration = new NetworkVariable(180f); private NetworkVariable roundTimeRemaining = new NetworkVariable(0f); private NetworkVariable currentRound = new NetworkVariable(0); private NetworkVariable isRoundActive = new NetworkVariable(false); private List alivePlayers = new List(); // Точки спавна, назначенные игрокам в этом раунде (параллельно alivePlayers). // Нужны, чтобы дождаться, пока серверная позиция догонит телепорт, перед стартом записи. private List spawnAssignments = new List(); private NetworkList playerScores = new NetworkList(); // pitDefenders[i] = ClientId игрока, которому принадлежит яма с pitIndex == i private ulong[] pitDefenders; private const ulong NoWinner = ulong.MaxValue; public event Action TimerChanged; public event Action RoundChanged; public event Action ScoresChanged; public override void OnNetworkSpawn() { currentRound.OnValueChanged += OnRoundChanged; isRoundActive.OnValueChanged += OnRoundStateChanged; roundTimeRemaining.OnValueChanged += OnRoundTimeChanged; playerScores.OnListChanged += OnScoresChanged; if (IsServer) { currentRound.Value = 0; StartCoroutine(StartRoundWithDelay()); } } private void Update() { // Рестарт матча по R — любой игрок, запрос уходит на сервер if (Input.GetKeyDown(KeyCode.R)) RequestRestartServerRpc(); if (!IsServer) return; if (!IsRoundActive) return; roundTimeRemaining.Value = Mathf.Max(0f, roundTimeRemaining.Value - Time.deltaTime); if (roundTimeRemaining.Value <= 0f) { StartCoroutine(EndRound(NoWinner)); } } void Awake() { Current = this; } public void OnPlayerEnteredPit(int _pitIndex, ulong _enteringClientId) { if (!IsServer) return; if (!isRoundActive.Value) return; if (pitDefenders == null) return; if (_pitIndex < 0 || _pitIndex >= pitDefenders.Length) return; // Своя яма — не считается, побеждают только за попадание в ЧУЖУЮ яму. // Для клона сюда приходит ClientId его ОРИГИНАЛА (см. PitZone) — правило работает так же. if (_enteringClientId == pitDefenders[_pitIndex]) { Debug.Log($"[ROUND] pit{_pitIndex} own pit by {_enteringClientId} — no score"); return; } Debug.Log($"[ROUND] WIN pit{_pitIndex} by client={_enteringClientId} (defender={pitDefenders[_pitIndex]}) round={currentRound.Value} timeLeft={roundTimeRemaining.Value:F1}"); AddScore(_enteringClientId); StartCoroutine(EndRound(_enteringClientId)); } private void AddScore(ulong _clientId) { for (int _i = 0; _i < playerScores.Count; _i++) { if (playerScores[_i].ClientId != _clientId) continue; playerScores[_i] = new PlayerScore { ClientId = _clientId, Score = playerScores[_i].Score + 1 }; return; } } public void OnPlayerDied(PlayerHealth _playerHealth) { // if (!IsServer) return; // alivePlayers.Remove(_playerHealth); // if (alivePlayers.Count <= 1) // { // StartCoroutine(EndRound()); // } } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestRestartServerRpc() { RestartMatch(); } // Полный рестарт: стоп текущего раунда и клонов, очистка архива и счёта, раунд с нуля private void RestartMatch() { if (!IsServer) return; StopAllCoroutines(); if (GhostRoundManager.Current != null) { GhostRoundManager.Current.ServerEndRound(); GhostRoundManager.Current.ClearArchive(); } for (int _i = 0; _i < playerScores.Count; _i++) { playerScores[_i] = new PlayerScore { ClientId = playerScores[_i].ClientId, Score = 0 }; } isRoundActive.Value = false; roundTimeRemaining.Value = 0f; currentRound.Value = 0; StartCoroutine(StartRoundWithDelay()); } public void RegisterPlayer(ulong _clientId) { if (!IsServer) return; foreach (var _s in playerScores) if (_s.ClientId == _clientId) return; playerScores.Add(new PlayerScore { ClientId = _clientId, Score = 0 }); } private IEnumerator EndRound(ulong _winnerClientId) { Debug.Log($"[ROUND] EndRound round={currentRound.Value} winner={_winnerClientId} timeLeft={roundTimeRemaining.Value:F1}"); isRoundActive.Value = false; // Сразу: стоп клонов + запись раунда в архив. // Обязательно ДО WaitForSeconds — иначе клоны бегают и стреляют на экране победы. if (GhostRoundManager.Current != null) GhostRoundManager.Current.ServerEndRound(); string _winner = _winnerClientId == NoWinner ? "No winner (time up)" : $"Player {_winnerClientId} wins!"; RoundEndedClientRPC(_winner); yield return new WaitForSeconds(roundStartDelay); currentRound.Value++; StartCoroutine(StartRoundWithDelay()); } private void RespawnAllPlayers() { if (!IsServer) return; if (spawnPoints == null || spawnPoints.Length == 0) return; PlayerHealth[] _found = FindObjectsByType(FindObjectsSortMode.None); // Только живые игроки: у клона тоже есть PlayerHealth, // но ему нельзя давать яму и спавн-поинт List _players = new List(); foreach (PlayerHealth _p in _found) { if (_p.GetComponent() != null) continue; _players.Add(_p); } // Детерминированный порядок раздачи ям: клон слепо повторяет прошлый раунд, // значит "чья яма где" должно совпадать из раунда в раунд. // FindObjectsByType порядок не гарантирует — сортируем по ClientId. _players.Sort((_a, _b) => _a.OwnerClientId.CompareTo(_b.OwnerClientId)); alivePlayers.Clear(); spawnAssignments.Clear(); // Одна яма на игрока: игрок с индексом _q защищает яму pitIndex == _q pitDefenders = new ulong[_players.Count]; for (int _q = 0; _q < _players.Count; _q++) { ulong _clientId = _players[_q].OwnerClientId; RegisterPlayer(_clientId); pitDefenders[_q] = _clientId; Vector3 _spawnPosition = spawnPoints[_q % spawnPoints.Length].position; // Диагностика: спавн-поинт не должен быть внутри триггера ямы, // иначе клон заспавнится в яме и мгновенно "победит". Collider[] _overlaps = Physics.OverlapSphere(_spawnPosition, 0.5f, ~0, QueryTriggerInteraction.Collide); foreach (Collider _c in _overlaps) if (_c.GetComponentInParent() != null) Debug.LogError($"[ROUND] SPAWN {_q} at {_spawnPosition} INSIDE PIT '{_c.name}' для client={_clientId} — это причина мгновенной победы клона!"); _players[_q].Respawn(_spawnPosition); alivePlayers.Add(_players[_q]); spawnAssignments.Add(_spawnPosition); } } // Ждём, пока серверная позиция каждого игрока догонит телепорт на спавн. // Иначе первый сэмпл записи (positions[0]) окажется там, где игрок умер (в яме), // и клон следующего раунда заспавнится прямо в яме → мгновенная ложная победа. private IEnumerator WaitForPlayersAtSpawn() { float _timeout = Time.time + 3f; float _start = Time.time; while (Time.time < _timeout) { bool _allAtSpawn = true; for (int _i = 0; _i < alivePlayers.Count; _i++) { if (alivePlayers[_i] == null) continue; float _dist = Vector3.Distance(alivePlayers[_i].transform.position, spawnAssignments[_i]); if (_dist > 1.5f) { _allAtSpawn = false; break; } } if (_allAtSpawn) { Debug.Log($"[ROUND] players at spawn after {Time.time - _start:F2}s"); yield break; } yield return null; } Debug.LogWarning($"[ROUND] WaitForPlayersAtSpawn TIMEOUT after 3s — positions may still be stale:"); for (int _i = 0; _i < alivePlayers.Count; _i++) if (alivePlayers[_i] != null) Debug.LogWarning($"[ROUND] player {alivePlayers[_i].OwnerClientId} pos={alivePlayers[_i].transform.position} target={spawnAssignments[_i]} dist={Vector3.Distance(alivePlayers[_i].transform.position, spawnAssignments[_i]):F2}"); } private IEnumerator StartRoundWithDelay() { yield return new WaitForSeconds(1f); yield return new WaitUntil(() => CountRealPlayers() >= 2); RespawnAllPlayers(); // КРИТИЧНО: ждём, пока серверная позиция игроков реально станет спавном, // прежде чем начинать запись — иначе клоны будут спавниться в яме (см. WaitForPlayersAtSpawn). yield return WaitForPlayersAtSpawn(); // Спавним клонов прошлых раундов и включаем запись у живых игроков if (GhostRoundManager.Current != null) GhostRoundManager.Current.ServerStartRound(); roundTimeRemaining.Value = roundDuration.Value; isRoundActive.Value = true; Debug.Log($"[ROUND] START round={currentRound.Value} players={alivePlayers.Count}"); RoundStartedClientRPC(currentRound.Value); } // Считаем только настоящих игроков — у клонов тоже есть PlayerHealth private int CountRealPlayers() { PlayerHealth[] _found = FindObjectsByType(FindObjectsSortMode.None); int _count = 0; foreach (PlayerHealth _p in _found) if (_p.GetComponent() == null) _count++; return _count; } [ClientRpc] private void RoundStartedClientRPC(int _round) { Debug.Log($"Round {_round} started"); } [ClientRpc] private void RoundEndedClientRPC(string _winnerText) { Debug.Log($"Round ended: {_winnerText}"); } public bool IsRoundActive => isRoundActive.Value; public int CurrentRound => currentRound.Value; public float RoundTimeRemaining => roundTimeRemaining.Value; public int GetScore(ulong _clientId) { foreach (PlayerScore _s in playerScores) if (_s.ClientId == _clientId) return _s.Score; return 0; } // Для 2 игроков: счёт первого игрока, чей ClientId не равен локальному public int GetEnemyScore(ulong _localClientId) { foreach (PlayerScore _s in playerScores) if (_s.ClientId != _localClientId) return _s.Score; return 0; } public override void OnNetworkDespawn() { if (IsServer && Current == this) Current = null; currentRound.OnValueChanged -= OnRoundChanged; isRoundActive.OnValueChanged -= OnRoundStateChanged; roundTimeRemaining.OnValueChanged -= OnRoundTimeChanged; playerScores.OnListChanged -= OnScoresChanged; } private void OnScoresChanged(NetworkListEvent _event) { ScoresChanged?.Invoke(); } private void OnRoundTimeChanged(float _previousValue, float _newValue) { TimerChanged?.Invoke(_newValue); } private void OnRoundChanged(int _previousValue, int _newValue) { RoundChanged?.Invoke(_newValue); } private void OnRoundStateChanged(bool _previousValue, bool _newValue) { } }