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