Added UI and more
This commit is contained in:
@@ -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:
|
||||
+10
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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:
|
||||
Reference in New Issue
Block a user