using Unity.Netcode; using UnityEngine; /// /// Висит на префабе КЛОНА. Воспроизводит запись: двигает трансформ по сэмплам /// и повторяет выстрелы той же логикой, что PlayerShoot. /// Вся логика — на сервере, на клиенты позиция уезжает через серверный NetworkTransform. /// public class GhostPlayer : NetworkBehaviour { [Header("Combat (как у PlayerShoot)")] [SerializeField] private float damageAmount = 10f; [SerializeField] private float range = 100f; [SerializeField] private GameObject hitEffectPrefab; [Header("Visuals")] [Tooltip("Тусклый цвет СВОЕГО клона (клон локального игрока)")] [SerializeField] private Color ownGhostColor = new Color(0f, 0f, 0.5f, 1f); [Tooltip("Тусклый цвет ЧУЖОГО клона (клон соперника)")] [SerializeField] private Color enemyGhostColor = new Color(0.5f, 0f, 0f, 1f); // ClientId оригинального игрока. Синхронизируется на клиентов для окраски // (recording живёт только на сервере, поэтому цвет по нему клиент не узнает). private NetworkVariable ownerId = new NetworkVariable(ulong.MaxValue); private GhostRecording recording; private int shotIdx; private bool finished; private Collider[] ownColliders; private PlayerHealth health; private PlayerShield shield; /// Кому засчитывать вход в яму / фраги — ClientId оригинального игрока. public ulong OriginalOwnerId => ownerId.Value; public override void OnNetworkSpawn() { ownerId.OnValueChanged += OnOwnerChanged; ApplyColor(); } public override void OnNetworkDespawn() { ownerId.OnValueChanged -= OnOwnerChanged; } private void OnOwnerChanged(ulong _previous, ulong _current) => ApplyColor(); // Свой клон — тусклый синий, чужой — тусклый красный (как живые игроки, но тускло) private void ApplyColor() { bool _isMine = NetworkManager.Singleton != null && ownerId.Value == NetworkManager.Singleton.LocalClientId; Color _color = _isMine ? ownGhostColor : enemyGhostColor; foreach (MeshRenderer _renderer in GetComponentsInChildren()) _renderer.material.color = _color; } /// Вызывается на сервере сразу после NetworkObject.Spawn(). public void Init(GhostRecording _recording) { recording = _recording; shotIdx = 0; finished = false; ownerId.Value = _recording.ownerClientId; ownColliders = GetComponentsInChildren(); health = GetComponent(); shield = GetComponent(); // Init(true): мы на сервере. Без этого TakeDamage у клона молча не работает health.Init(true); } /// Тик приходит от GhostRoundManager — общий счётчик для всех клонов. 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]; if (shield != null && _tick < recording.shieldStates.Count) shield.SetShieldState(recording.shieldStates[_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; PlayerHealth _targetHealth = _hit.collider.GetComponentInParent(); if (_targetHealth != null) _targetHealth.TakeDamage((int)damageAmount); SpawnHitEffectClientRpc(_hit.point); } } [ClientRpc] private void SpawnHitEffectClientRpc(Vector3 _hitPosition) { if (hitEffectPrefab != null) Instantiate(hitEffectPrefab, _hitPosition, Quaternion.identity); } }