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,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