Added UI and more

This commit is contained in:
2026-07-02 17:04:29 +03:00
parent eca9be620a
commit 8ac38208ae
190 changed files with 555483 additions and 101 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4e3f1256fc3631c42a16edf4e8605ee5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,96 @@
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Висит на префабе КЛОНА. Воспроизводит запись: двигает трансформ по сэмплам
/// и повторяет выстрелы той же логикой, что PlayerShoot.
/// Вся логика — на сервере, на клиенты позиция уезжает через серверный NetworkTransform.
/// </summary>
public class GhostPlayer : NetworkBehaviour
{
[Header("Combat (как у PlayerShoot)")]
[SerializeField] private float damageAmount = 10f;
[SerializeField] private float range = 100f;
[SerializeField] private GameObject hitEffectPrefab;
[Header("Visuals")]
[SerializeField] private Color ghostColor = new Color(0.5f, 0.5f, 0.5f, 1f);
private GhostRecording recording;
private int shotIdx;
private bool finished;
private Collider[] ownColliders;
private PlayerHealth health;
/// <summary> Кому засчитывать вход в яму / фраги — ClientId оригинального игрока. </summary>
public ulong OriginalOwnerId => recording != null ? recording.ownerClientId : ulong.MaxValue;
public override void OnNetworkSpawn()
{
// Красим клона на всех клиентах, чтобы отличался от живых игроков
foreach (MeshRenderer _renderer in GetComponentsInChildren<MeshRenderer>())
_renderer.material.color = ghostColor;
}
/// <summary> Вызывается на сервере сразу после NetworkObject.Spawn(). </summary>
public void Init(GhostRecording _recording)
{
recording = _recording;
shotIdx = 0;
finished = false;
ownColliders = GetComponentsInChildren<Collider>();
health = GetComponent<PlayerHealth>();
// Init(true): мы на сервере. Без этого TakeDamage у клона молча не работает
health.Init(true);
}
/// <summary> Тик приходит от GhostRoundManager — общий счётчик для всех клонов. </summary>
public void Step(int _tick)
{
if (!IsServer || recording == null || finished) return;
// Клона убили — замирает (визуал/коллайдеры выключил PlayerHealth своим ClientRpc)
if (health != null && health.isDead.Value) return;
// Запись кончилась (в том раунде игрок умер/раунд был короче) — исчезаем
if (_tick >= recording.positions.Count)
{
finished = true;
if (NetworkObject.IsSpawned) NetworkObject.Despawn();
return;
}
transform.position = recording.positions[_tick];
transform.rotation = recording.rotations[_tick];
while (shotIdx < recording.shots.Count && recording.shots[shotIdx].tick <= _tick)
{
ReplayShot(recording.shots[shotIdx]);
shotIdx++;
}
}
// Та же логика, что PlayerShoot.ShootServerRpc — стреляет по ТЕКУЩЕМУ миру
private void ReplayShot(GhostShotEvent _shot)
{
if (Physics.Raycast(_shot.origin, _shot.direction, out RaycastHit _hit, range))
{
if (System.Array.Exists(ownColliders, c => c == _hit.collider)) return;
if (_hit.collider.TryGetComponent(out PlayerHealth _targetHealth))
_targetHealth.TakeDamage((int)damageAmount);
SpawnHitEffectClientRpc(_hit.point);
}
}
[ClientRpc]
private void SpawnHitEffectClientRpc(Vector3 _hitPosition)
{
if (hitEffectPrefab != null)
Instantiate(hitEffectPrefab, _hitPosition, Quaternion.identity);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 146391fe826c38c4899349d0747a9804
@@ -0,0 +1,56 @@
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Висит на префабе ИГРОКА (рядом с PlayerMovement, PlayerShoot и т.д.).
/// Работает только на сервере: пишет позицию/поворот каждый FixedUpdate
/// и выстрелы (их досылает PlayerShoot.ShootServerRpc через RecordShot).
/// </summary>
public class GhostRecorder : NetworkBehaviour
{
[SerializeField] private Transform cameraRoot; // тот же, что в PlayerShoot (можно не назначать)
public GhostRecording Recording { get; private set; }
private bool isRecording;
public int CurrentTick => Recording != null ? Recording.positions.Count : 0;
/// <summary> Вызывается GhostRoundManager'ом на старте раунда (сервер). </summary>
public void BeginRecording()
{
if (!IsServer) return;
Recording = new GhostRecording { ownerClientId = OwnerClientId };
isRecording = true;
}
/// <summary> Вызывается GhostRoundManager'ом в конце раунда (сервер). </summary>
public void StopRecording()
{
isRecording = false;
}
private void FixedUpdate()
{
if (!IsServer || !isRecording) return;
// Позиция, которую сервер видит через NetworkTransform игрока
Recording.positions.Add(transform.position);
Recording.rotations.Add(transform.rotation);
Recording.pitches.Add(cameraRoot != null ? cameraRoot.localEulerAngles.x : 0f);
}
/// <summary> Дёргается из PlayerShoot.ShootServerRpc — только на сервере. </summary>
public void RecordShot(Vector3 _origin, Vector3 _direction)
{
if (!IsServer || !isRecording) return;
Recording.shots.Add(new GhostShotEvent
{
tick = CurrentTick,
origin = _origin,
direction = _direction
});
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2a8b1b53c721bfa4dbb74019372784b7
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Запись одного раунда одного игрока. Живёт только на сервере, по сети не гоняется.
/// </summary>
public class GhostRecording
{
public ulong ownerClientId; // чей это призрак (для ям и счёта)
public List<Vector3> positions = new List<Vector3>(); // сэмпл каждый FixedUpdate
public List<Quaternion> rotations = new List<Quaternion>(); // yaw тела
public List<float> pitches = new List<float>(); // наклон камеры (на будущее, для визуала)
public List<GhostShotEvent> shots = new List<GhostShotEvent>();
}
public struct GhostShotEvent
{
public int tick;
public Vector3 origin;
public Vector3 direction;
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9d51c870578fb8943aaa8cd72d17319d
@@ -0,0 +1,115 @@
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Обычный MonoBehaviour (не сетевой) — вся логика чисто серверная.
/// Повесь на тот же объект, что и RoundManager (или на любой объект сцены).
/// Хранит архив записей, спавнит клонов на старте раунда и тикает их в FixedUpdate.
/// </summary>
public class GhostRoundManager : MonoBehaviour
{
public static GhostRoundManager Current { get; private set; }
[SerializeField] private GameObject ghostPrefab; // префаб клона (см. чеклист настройки)
[SerializeField] private int maxGhostsPerPlayer = 3; // сколько прошлых раундов помним на игрока
// ClientId -> записи его прошлых раундов
private readonly Dictionary<ulong, List<GhostRecording>> archive = new Dictionary<ulong, List<GhostRecording>>();
private readonly List<GhostPlayer> activeGhosts = new List<GhostPlayer>();
private int roundTick;
private bool roundRunning;
private bool IsServer => NetworkManager.Singleton != null && NetworkManager.Singleton.IsServer;
private void Awake()
{
Current = this;
}
private void OnDestroy()
{
if (Current == this) Current = null;
}
/// <summary> Вызывается из RoundManager.StartRoundWithDelay после RespawnAllPlayers. </summary>
public void ServerStartRound()
{
if (!IsServer) return;
roundTick = 0;
activeGhosts.Clear();
// Спавним клонов из архива
foreach (KeyValuePair<ulong, List<GhostRecording>> _kvp in archive)
{
foreach (GhostRecording _recording in _kvp.Value)
{
if (_recording.positions.Count == 0) continue;
GameObject _go = Instantiate(ghostPrefab, _recording.positions[0], _recording.rotations[0]);
_go.GetComponent<NetworkObject>().Spawn();
GhostPlayer _ghost = _go.GetComponent<GhostPlayer>();
_ghost.Init(_recording);
activeGhosts.Add(_ghost);
}
}
// Включаем запись у всех живых игроков
foreach (NetworkClient _client in NetworkManager.Singleton.ConnectedClientsList)
{
_client.PlayerObject?.GetComponent<GhostRecorder>()?.BeginRecording();
}
roundRunning = true;
}
private void FixedUpdate()
{
if (!IsServer || !roundRunning) return;
if (RoundManager.Current == null || !RoundManager.Current.IsRoundActive) return;
foreach (GhostPlayer _ghost in activeGhosts)
{
if (_ghost != null && _ghost.IsSpawned)
_ghost.Step(roundTick);
}
roundTick++;
}
/// <summary> Вызывается из RoundManager.EndRound первой строкой (до WaitForSeconds). </summary>
public void ServerEndRound()
{
if (!IsServer || !roundRunning) return;
roundRunning = false;
// Убираем клонов со сцены
foreach (GhostPlayer _ghost in activeGhosts)
{
if (_ghost != null && _ghost.IsSpawned)
_ghost.NetworkObject.Despawn();
}
activeGhosts.Clear();
// Останавливаем запись и складываем раунд в архив
foreach (NetworkClient _client in NetworkManager.Singleton.ConnectedClientsList)
{
GhostRecorder _recorder = _client.PlayerObject?.GetComponent<GhostRecorder>();
if (_recorder == null || _recorder.Recording == null) continue;
_recorder.StopRecording();
if (!archive.TryGetValue(_client.ClientId, out List<GhostRecording> _list))
{
_list = new List<GhostRecording>();
archive[_client.ClientId] = _list;
}
_list.Add(_recorder.Recording);
if (_list.Count > maxGhostsPerPlayer) _list.RemoveAt(0);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01e9370aadea12f40b9790e34c0093b4
@@ -9,6 +9,7 @@ GameObject:
serializedVersion: 6
m_Component:
- component: {fileID: 4744484706230885847}
- component: {fileID: 8811223344556677889}
m_Layer: 6
m_Name: FPV-Hands
m_TagString: Untagged
@@ -37,6 +38,27 @@ Transform:
- {fileID: 6565998452162728716}
m_Father: {fileID: 2142486437948056408}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &8811223344556677889
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 149248327471132257}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7d3e9f21c4a84b0d9e2f1a6b5c8d0e3f, type: 3}
m_Name:
m_EditorClassIdentifier:
swayAmount: 2
swayMax: 6
swaySmooth: 8
bobSpeed: 9
bobAmount: 0.03
bobSmooth: 10
recoilKickback: 0.12
recoilKickUp: 9
recoilDuration: 0.22
--- !u!1 &262780200250670577
GameObject:
m_ObjectHideFlags: 0
@@ -528,7 +550,7 @@ MonoBehaviour:
RotationMaxInterpolationTime: 0.1
ScaleLerpSmoothing: 1
ScaleMaxInterpolationTime: 0.1
AuthorityMode: 0
AuthorityMode: 1
TickSyncChildren: 0
UseUnreliableDeltas: 0
SyncPositionX: 1
@@ -590,7 +612,7 @@ MonoBehaviour:
damageAmount: 10
range: 100
cameraRoot: {fileID: 2142486437948056408}
hitEffectPrefab: {fileID: 2245960613504829429, guid: 1e9db26aa37525e4ab9227927da53137, type: 3}
hitEffectPrefab: {fileID: 5162910912013668194, guid: cf82839c411151342a7b2337bda9fb34, type: 3}
--- !u!136 &5519469453299969230
CapsuleCollider:
m_ObjectHideFlags: 0
@@ -3,16 +3,28 @@ using Unity.Netcode;
public class PitZone : NetworkBehaviour
{
// Индекс ямы. Игрок с таким же индексом — её "хозяин" (защитник).
// Победа засчитывается тому, кто зашёл в ЧУЖУЮ яму (pitIndex != его индекс).
[SerializeField] private int pitIndex;
private void OnTriggerEnter(Collider _other)
{
if (!IsServer) return;
if (RoundManager.Current == null || !RoundManager.Current.IsRoundActive) return;
PlayerHealth _playerHealth = _other.GetComponent<PlayerHealth>();
// Клон — засчитываем вход за его ОРИГИНАЛЬНОГО игрока.
// Проверять надо ДО PlayerHealth: у клона он тоже есть,
// но OwnerClientId клона — это сервер, а не игрок.
GhostPlayer _ghost = _other.GetComponentInParent<GhostPlayer>();
if (_ghost != null)
{
RoundManager.Current.OnPlayerEnteredPit(pitIndex, _ghost.OriginalOwnerId);
return;
}
PlayerHealth _playerHealth = _other.GetComponentInParent<PlayerHealth>();
if (_playerHealth == null) return;
if(!RoundManager.Current.IsRoundActive) return;
RoundManager.Current.OnPlayerFellIntoPit(_playerHealth.OwnerClientId);
RoundManager.Current.OnPlayerEnteredPit(pitIndex, _playerHealth.OwnerClientId);
}
}
}
@@ -174,3 +174,4 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
ShowTopMostFoldoutHeaderGroup: 1
pitIndex: 0
@@ -1,6 +1,6 @@
using UnityEngine;
using Unity.Netcode;
using TMPro;
using Unity.Netcode.Components;
using System;
public class PlayerHealth : NetworkBehaviour
@@ -91,7 +91,27 @@ public class PlayerHealth : NetworkBehaviour
private void RespawnClientRpc(Vector3 _spawnPosition)
{
SetPhysicsAndVisualsActive(true);
transform.position = _spawnPosition;
// Позицию задаёт только владелец — он авторитет над NetworkTransform (Owner authority)
if (!IsOwner) return;
TeleportOwner(_spawnPosition);
}
// Телепорт без интерполяции. CharacterController кэширует свою позицию,
// поэтому его надо выключить на время установки трансформа.
private void TeleportOwner(Vector3 _spawnPosition)
{
CharacterController _controller = GetComponent<CharacterController>();
if (_controller != null) _controller.enabled = false;
NetworkTransform _netTransform = GetComponent<NetworkTransform>();
if (_netTransform != null)
_netTransform.Teleport(_spawnPosition, transform.rotation, transform.localScale);
else
transform.position = _spawnPosition;
if (_controller != null) _controller.enabled = true;
}
private void SetPhysicsAndVisualsActive(bool _isActive)
@@ -101,16 +121,5 @@ public class PlayerHealth : NetworkBehaviour
foreach (MeshRenderer meshRenderer in GetComponentsInChildren<MeshRenderer>())
meshRenderer.enabled = _isActive;
Rigidbody rigidbody = GetComponent<Rigidbody>();
if (rigidbody == null) return;
rigidbody.isKinematic = !_isActive;
if (_isActive)
{
rigidbody.linearVelocity = Vector3.zero;
rigidbody.angularVelocity = Vector3.zero;
}
}
}
@@ -85,9 +85,9 @@ public class PlayerMovement : MonoBehaviour
if (IsMovementBlocked())
{
// Мёртвый стоит — гасим горизонталь, но гравитацию оставляем
// Мёртвый — CharacterController выключен (как коллайдер), двигать нельзя
horizontalVelocity = Vector3.zero;
ApplyGravityOnly();
verticalVelocity = 0f;
return;
}
@@ -96,6 +96,9 @@ public class PlayerMovement : MonoBehaviour
HandleHorizontal();
HandleJumpAndGravity();
// Контроллер могли выключить (смерть) между кадрами — Move на выключенном кинет ошибку
if (!controller.enabled) return;
// Один общий Move за кадр — и горизонталь, и вертикаль
Vector3 velocity = horizontalVelocity + Vector3.up * verticalVelocity;
controller.Move(velocity * Time.deltaTime);
@@ -216,20 +219,4 @@ public class PlayerMovement : MonoBehaviour
verticalVelocity += g * Time.deltaTime;
verticalVelocity = Mathf.Max(verticalVelocity, maxFallSpeed);
}
// Используется когда движение заблокировано (смерть) — только падение
private void ApplyGravityOnly()
{
if (controller == null) return;
if (controller.isGrounded && verticalVelocity < 0f)
verticalVelocity = groundStickForce;
else
{
verticalVelocity += gravity * Time.deltaTime;
verticalVelocity = Mathf.Max(verticalVelocity, maxFallSpeed);
}
controller.Move(Vector3.up * verticalVelocity * Time.deltaTime);
}
}
@@ -10,37 +10,27 @@ public class PlayerShoot : NetworkBehaviour
[SerializeField] GameObject hitEffectPrefab;
private PlayerHealth playerHealth;
private WeaponView weaponView;
private bool isActive = false;
public void Init()
{
isActive = true;
playerHealth = GetComponent<PlayerHealth>();
weaponView = GetComponentInChildren<WeaponView>(true);
}
// public override void OnNetworkSpawn()
// {
// if (IsOwner)
// {
// playerHealth = GetComponent<PlayerHealth>();
// if (playerHealth == null)
// {
// Debug.LogError("PlayerHealth component not found on player");
// }
// }
// }
void Update()
{
if(!isActive) return;
if (!isActive) return;
playerHealth = GetComponent<PlayerHealth>();
if(playerHealth == null || playerHealth.isDead.Value) return;
if (playerHealth == null || playerHealth.isDead.Value) return;
if (Input.GetMouseButtonDown(0))
{
weaponView?.PlayShoot();
ShootServerRpc(cameraRoot.position, cameraRoot.forward);
}
}
@@ -48,6 +38,9 @@ public class PlayerShoot : NetworkBehaviour
[ServerRpc]
private void ShootServerRpc(Vector3 _origin, Vector3 _direction)
{
// Записываем выстрел для клона — ровно то, что пришло в RPC
GetComponent<GhostRecorder>()?.RecordShot(_origin, _direction);
Collider[] ownerColliders = GetComponentsInChildren<Collider>();
if (Physics.Raycast(_origin, _direction, out RaycastHit _hit, range))
@@ -72,4 +65,4 @@ public class PlayerShoot : NetworkBehaviour
// Debug.Log($"Spawning hit effect at {_hitPosition}");
Instantiate(hitEffectPrefab, _hitPosition, Quaternion.identity);
}
}
}
@@ -1,3 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
@@ -37,22 +38,28 @@ public class RoundManager : NetworkBehaviour
private List<PlayerHealth> alivePlayers = new List<PlayerHealth>();
private NetworkList<PlayerScore> playerScores;
private NetworkList<PlayerScore> playerScores = new NetworkList<PlayerScore>();
// pitDefenders[i] = ClientId игрока, которому принадлежит яма с pitIndex == i
private ulong[] pitDefenders;
private const ulong NoWinner = ulong.MaxValue;
private bool isTimerRunning = false;
public event Action<float> TimerChanged;
public event Action<int> RoundChanged;
public event Action ScoresChanged;
public override void OnNetworkSpawn()
{
currentRound.OnValueChanged += OnRoundChanged;
isRoundActive.OnValueChanged += OnRoundStateChanged;
roundTimeRemaining.OnValueChanged += OnRoundTimeChanged;
playerScores.OnListChanged += OnScoresChanged;
// roundTimeRemaining.OnValueChanged += OnTimerChanged;
if (IsServer)
if (IsServer)
{
// StartTimerServerRPC();
currentRound.Value = 0;
StartCoroutine(StartRoundWithDelay());
}
}
@@ -60,47 +67,51 @@ public class RoundManager : NetworkBehaviour
private void Update()
{
if (!IsServer) return;
if(!IsRoundActive) return;
if (!IsRoundActive) return;
roundTimeRemaining.Value = Mathf.Max(0f, roundTimeRemaining.Value - Time.deltaTime);
if(roundTimeRemaining.Value <= 0f)
if (roundTimeRemaining.Value <= 0f)
{
StartCoroutine(EndRound(0ul));
StartCoroutine(EndRound(NoWinner));
}
}
// public void OnTimerChanged(float _previousValue, float _newValue)
void Awake()
{
{
Current = this;
playerScores = new NetworkList<PlayerScore>();
roundTimeRemaining.Value = roundDuration.Value;
}
public void OnPlayerFellIntoPit(ulong _winnerClientId)
public void OnPlayerEnteredPit(int _pitIndex, ulong _enteringClientId)
{
if (!IsServer) return;
if (!isRoundActive.Value) return;
if(!isRoundActive.Value) return;
if (pitDefenders == null) return;
if (_pitIndex < 0 || _pitIndex >= pitDefenders.Length) return;
// Своя яма — не считается, побеждают только за попадание в ЧУЖУЮ яму.
// Для клона сюда приходит ClientId его ОРИГИНАЛА (см. PitZone) — правило работает так же.
if (_enteringClientId == pitDefenders[_pitIndex]) return;
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 = _winnerClientId,
ClientId = _clientId,
Score = playerScores[_i].Score + 1
};
break;
return;
}
StartCoroutine(EndRound(_winnerClientId));
}
public void OnPlayerDied(PlayerHealth _playerHealth)
@@ -118,10 +129,10 @@ public class RoundManager : NetworkBehaviour
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 });
}
@@ -129,7 +140,14 @@ public class RoundManager : NetworkBehaviour
{
isRoundActive.Value = false;
string _winner = _winnerClientId != 0 ? $"Player {_winnerClientId} wins!" : "No winner";
// Сразу: стоп клонов + запись раунда в архив.
// Обязательно ДО WaitForSeconds — иначе клоны бегают и стреляют на экране победы.
if (GhostRoundManager.Current != null)
GhostRoundManager.Current.ServerEndRound();
string _winner = _winnerClientId == NoWinner
? "No winner (time up)"
: $"Player {_winnerClientId} wins!";
RoundEndedClientRPC(_winner);
@@ -143,17 +161,40 @@ public class RoundManager : NetworkBehaviour
private void RespawnAllPlayers()
{
if (!IsServer) return;
if (spawnPoints == null || spawnPoints.Length == 0) return;
var _allPlayers = FindObjectsByType<PlayerHealth>(FindObjectsSortMode.None);
PlayerHealth[] _found = FindObjectsByType<PlayerHealth>(FindObjectsSortMode.None);
// Только живые игроки: у клона тоже есть PlayerHealth,
// но ему нельзя давать яму и спавн-поинт
List<PlayerHealth> _players = new List<PlayerHealth>();
foreach (PlayerHealth _p in _found)
{
if (_p.GetComponent<GhostPlayer>() != null) continue;
_players.Add(_p);
}
// Детерминированный порядок раздачи ям: клон слепо повторяет прошлый раунд,
// значит "чья яма где" должно совпадать из раунда в раунд.
// FindObjectsByType порядок не гарантирует — сортируем по ClientId.
_players.Sort((_a, _b) => _a.OwnerClientId.CompareTo(_b.OwnerClientId));
alivePlayers.Clear();
for (int _q = 0; _q < _allPlayers.Length; _q++)
{
Vector3 _spawnPosition = spawnPoints[_q % spawnPoints.Length].position;
// Одна яма на игрока: игрок с индексом _q защищает яму pitIndex == _q
pitDefenders = new ulong[_players.Count];
_allPlayers[_q].Respawn(_spawnPosition);
alivePlayers.Add(_allPlayers[_q]);
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;
_players[_q].Respawn(_spawnPosition);
alivePlayers.Add(_players[_q]);
}
}
@@ -161,14 +202,30 @@ public class RoundManager : NetworkBehaviour
{
yield return new WaitForSeconds(1f);
yield return new WaitUntil(() =>
FindObjectsByType<PlayerHealth>(FindObjectsSortMode.None).Length >= 2);
yield return new WaitUntil(() => CountRealPlayers() >= 2);
RespawnAllPlayers();
// Спавним клонов прошлых раундов и включаем запись у живых игроков
if (GhostRoundManager.Current != null)
GhostRoundManager.Current.ServerStartRound();
roundTimeRemaining.Value = roundDuration.Value;
isRoundActive.Value = true;
RoundStartedClientRPC(currentRound.Value);
}
// Считаем только настоящих игроков — у клонов тоже есть PlayerHealth
private int CountRealPlayers()
{
PlayerHealth[] _found = FindObjectsByType<PlayerHealth>(FindObjectsSortMode.None);
int _count = 0;
foreach (PlayerHealth _p in _found)
if (_p.GetComponent<GhostPlayer>() == null) _count++;
return _count;
}
[ClientRpc]
private void RoundStartedClientRPC(int _round)
{
@@ -182,6 +239,23 @@ public class RoundManager : NetworkBehaviour
}
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()
{
@@ -190,15 +264,27 @@ public class RoundManager : NetworkBehaviour
currentRound.OnValueChanged -= OnRoundChanged;
isRoundActive.OnValueChanged -= OnRoundStateChanged;
roundTimeRemaining.OnValueChanged -= OnRoundTimeChanged;
playerScores.OnListChanged -= OnScoresChanged;
}
private void OnScoresChanged(NetworkListEvent<PlayerScore> _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)
{
}
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2045e248fcabacf479b7314446530f68
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -4,15 +4,23 @@ public class PlayerHealthUIController : MonoBehaviour
{
[SerializeField] private PlayerHealthView view;
private PlayerHealth playerHealth;
public void Bind(PlayerHealth _playerHealth)
{
_playerHealth.HealthChanged += OnHealthChanged;
playerHealth = _playerHealth;
playerHealth.HealthChanged += OnHealthChanged;
_playerHealth.PushCurrentHealthValue();
playerHealth.PushCurrentHealthValue();
}
public void OnHealthChanged(int _currentHealth, int _maxHealth)
{
view.UpdateHealth(_currentHealth, _maxHealth);
}
private void OnDestroy()
{
playerHealth.HealthChanged -= OnHealthChanged;
}
}
@@ -6,6 +6,7 @@ using TMPro;
public class PlayerUIController : MonoBehaviour
{
[SerializeField] private PlayerHealthUIController playerHealthUIController;
[SerializeField] private RoundManagerUIController roundManagerUIController;
private PlayerFacade playerFacade;
@@ -19,7 +20,7 @@ public class PlayerUIController : MonoBehaviour
playerFacade = _playerFacade;
playerHealthUIController.Bind(playerFacade.PlayerHealth);
roundManagerUIController.Bind(RoundManager.Current, playerFacade.OwnerClientId);
}
}
@@ -0,0 +1,49 @@
using UnityEngine;
public class RoundManagerUIController : MonoBehaviour
{
[SerializeField] private RoundManagerView view;
private RoundManager roundManager;
private ulong localClientId;
public void Bind(RoundManager _roundManager, ulong _localClientId)
{
roundManager = _roundManager;
localClientId = _localClientId;
roundManager.TimerChanged += OnTimerChanged;
roundManager.RoundChanged += OnRoundChanged;
roundManager.ScoresChanged += OnScoresChanged;
// События срабатывают только при изменении — сразу показываем текущее состояние
OnRoundChanged(roundManager.CurrentRound);
OnTimerChanged(roundManager.RoundTimeRemaining);
OnScoresChanged();
}
public void OnTimerChanged(float _timer)
{
view.UpdateTimer(_timer);
}
public void OnRoundChanged(int _round)
{
view.UpdateCurrentRound(_round);
}
public void OnScoresChanged()
{
view.UpdateOwnerScore(roundManager.GetScore(localClientId));
view.UpdateEnemyScore(roundManager.GetEnemyScore(localClientId));
}
private void OnDestroy()
{
if (roundManager == null) return;
roundManager.TimerChanged -= OnTimerChanged;
roundManager.RoundChanged -= OnRoundChanged;
roundManager.ScoresChanged -= OnScoresChanged;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b11f67053d8a873458049261618f5608
@@ -0,0 +1,30 @@
using TMPro;
using UnityEngine;
public class RoundManagerView : MonoBehaviour
{
[SerializeField] private TMP_Text timerText;
[SerializeField] private TMP_Text currentRoundText;
[SerializeField] private TMP_Text ownerScoreText;
[SerializeField] private TMP_Text enemyScoreText;
public void UpdateTimer(float _timer)
{
timerText.text = $"{_timer:F0}";
}
public void UpdateCurrentRound(int _round)
{
currentRoundText.text = $"Round {_round}";
}
public void UpdateOwnerScore(int _score)
{
ownerScoreText.text = _score.ToString();
}
public void UpdateEnemyScore(int _score)
{
enemyScoreText.text = _score.ToString();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 485b4248f7e4e1946bb88c47df8ffcbd
@@ -0,0 +1,120 @@
using UnityEngine;
using DG.Tweening;
/// <summary>
/// Визуальная "живость" рук от первого лица: свэй за движением мыши, покачивание
/// при ходьбе и сочная отдача при выстреле через DOTween.
/// Висит на FPV-Hands (активен только у владельца), поэтому проверки IsOwner не нужны.
/// Всё считается как смещение поверх исходного localPosition/localRotation,
/// а отдача пишется в отдельные поля — свэй/боб и DOTween не конфликтуют за трансформ.
/// </summary>
public class WeaponView : MonoBehaviour
{
[Header("Sway (свэй за мышью)")]
[SerializeField] private float swayAmount = 2f;
[SerializeField] private float swayMax = 6f;
[SerializeField] private float swaySmooth = 8f;
[Header("Bob (покачивание при ходьбе)")]
[SerializeField] private float bobSpeed = 9f;
[SerializeField] private float bobAmount = 0.03f;
[SerializeField] private float bobSmooth = 10f;
[Header("Recoil (отдача при выстреле)")]
[SerializeField] private float recoilKickback = 0.12f; // назад по Z
[SerializeField] private float recoilKickUp = 9f; // поворот ствола вверх (X)
[SerializeField] private float recoilDuration = 0.22f;
private Vector3 originPos;
private Quaternion originRot;
private Vector3 swayRotOffset;
private Vector3 bobPosOffset;
private Vector3 recoilPos;
private Vector3 recoilRot;
private float bobTimer;
private Sequence recoilSequence;
private void Awake()
{
originPos = transform.localPosition;
originRot = transform.localRotation;
}
private void LateUpdate()
{
ApplySway();
ApplyBob();
transform.localPosition = originPos + bobPosOffset + recoilPos;
transform.localRotation = originRot * Quaternion.Euler(swayRotOffset + recoilRot);
}
private void ApplySway()
{
float _mouseX = Input.GetAxis("Mouse X");
float _mouseY = Input.GetAxis("Mouse Y");
// руки "отстают" от поворота камеры
float _targetPitch = Mathf.Clamp(-_mouseY * swayAmount, -swayMax, swayMax);
float _targetYaw = Mathf.Clamp(-_mouseX * swayAmount, -swayMax, swayMax);
Vector3 _target = new Vector3(_targetPitch, _targetYaw, 0f);
swayRotOffset = Vector3.Lerp(swayRotOffset, _target, swaySmooth * Time.deltaTime);
}
private void ApplyBob()
{
float _moveMagnitude = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).magnitude;
Vector3 _target = Vector3.zero;
if (_moveMagnitude > 0.1f)
{
bobTimer += Time.deltaTime * bobSpeed;
float _x = Mathf.Cos(bobTimer) * bobAmount;
float _y = Mathf.Abs(Mathf.Sin(bobTimer)) * bobAmount;
_target = new Vector3(_x, _y, 0f);
}
else
{
bobTimer = 0f;
}
bobPosOffset = Vector3.Lerp(bobPosOffset, _target, bobSmooth * Time.deltaTime);
}
public void PlayShoot()
{
recoilSequence?.Kill();
recoilPos = Vector3.zero;
recoilRot = Vector3.zero;
float _kick = recoilDuration * 0.35f;
float _return = recoilDuration * 0.65f;
recoilSequence = DOTween.Sequence().SetTarget(this);
// Позиция: резкий отскок назад, затем плавный возврат
recoilSequence.Append(DOTween.To(() => recoilPos, _v => recoilPos = _v,
new Vector3(0f, 0f, -recoilKickback), _kick).SetEase(Ease.OutQuad));
recoilSequence.Append(DOTween.To(() => recoilPos, _v => recoilPos = _v,
Vector3.zero, _return).SetEase(Ease.OutBack));
// Поворот: ствол подбрасывает вверх и возвращает — параллельно позиции
recoilSequence.Insert(0f, DOTween.To(() => recoilRot, _v => recoilRot = _v,
new Vector3(-recoilKickUp, 0f, 0f), _kick).SetEase(Ease.OutQuad));
recoilSequence.Insert(_kick, DOTween.To(() => recoilRot, _v => recoilRot = _v,
Vector3.zero, _return).SetEase(Ease.OutBack));
}
private void OnDisable()
{
recoilSequence?.Kill();
recoilPos = Vector3.zero;
recoilRot = Vector3.zero;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7d3e9f21c4a84b0d9e2f1a6b5c8d0e3f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: