using System; using Unity.Netcode; using UnityEngine; using UnityEngine.Events; using UnityEngine.Serialization; [DisallowMultipleComponent] [RequireComponent(typeof(NetworkObject))] public sealed class MapController : NetworkBehaviour { [Header("Main References")] [SerializeField] private MapCrosshairController crosshair; [SerializeField] private MapCoordinateSystem coordinateSystem; [SerializeField] private MapPlayer mapPlayer; [SerializeField] private RoundGameLoopController roundGameLoop; [SerializeField] private NetworkBaseHealth baseHealth; [Header("Target System")] [SerializeField] private MapTargetController targetController; [SerializeField] private Transform targetParent; [SerializeField, Min(0.02f)] private float simulationStep = 0.1f; [Header("Radar System")] [SerializeField] private MapRadarController radarController; [FormerlySerializedAs("targetPrefab")] [SerializeField] private MapTarget radarMarkPrefab; [Header("Crosshair Objects")] [SerializeField] private Transform relayX; [SerializeField] private Transform relayY; [SerializeField] private Transform crosshairVisual; [Header("Pseudo Shot")] [SerializeField, Min(0.01f)] private float hitRadius = 0.65f; [SerializeField, Min(1)] private int shotDamage = 1; [SerializeField] private MissileType pseudoMissileType = MissileType.Standard; [SerializeField] private bool ignoreMissileTypeForPrototype = true; [Header("Network Events")] [SerializeField] private UnityEvent waveStarted; [SerializeField] private UnityEvent shotResolved; [SerializeField] private UnityEvent allTargetsResolved; [SerializeField] private UnityEvent allTargetsDestroyed; private readonly NetworkVariable crosshairCoordinates = new( new Vector2(5f, 5f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable currentWave = new( 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable radarReadyAt = new( 0d, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable lastShotResult = new( default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable resolvedSequence = new( 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable destroyedSequence = new( 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkList activeTargets = new(); private readonly NetworkList radarMarks = new(); private float simulationAccumulator; private int nextTargetId = 1; private int initialTargetCount; private int escapedTargetCount; private bool waveIsActive; private bool roundEventsBound; public MapCoordinateSystem CoordinateSystem => coordinateSystem; public Transform CrosshairVisual => crosshairVisual; public Vector2 CrosshairCoordinates => crosshairCoordinates.Value; public int CurrentWave => currentWave.Value; public int ActiveTargetCount => activeTargets.Count; public bool WaveIsActive => waveIsActive; public double RadarCooldownRemaining => IsSpawned && NetworkManager != null ? Math.Max(0d, radarReadyAt.Value - NetworkManager.ServerTime.Time) : 0d; public event Action WaveStarted; public event Action ShotResolved; public event Action AllTargetsResolved; public event Action AllTargetsDestroyed; private void Awake() => ConfigurePresentation(); private void OnValidate() { simulationStep = Mathf.Max(0.02f, simulationStep); hitRadius = Mathf.Max(0.01f, hitRadius); shotDamage = Mathf.Max(1, shotDamage); } public override void OnNetworkSpawn() { activeTargets.OnListChanged += HandleTargetListChanged; radarMarks.OnListChanged += HandleRadarListChanged; crosshairCoordinates.OnValueChanged += HandleCrosshairChanged; currentWave.OnValueChanged += HandleWaveChanged; radarReadyAt.OnValueChanged += HandleRadarReadyAtChanged; lastShotResult.OnValueChanged += HandleShotResultChanged; resolvedSequence.OnValueChanged += HandleResolvedSequenceChanged; destroyedSequence.OnValueChanged += HandleDestroyedSequenceChanged; ConfigurePresentation(); BindRoundEvents(); ApplyPresentation(); if (IsServer && roundGameLoop != null && roundGameLoop.Phase == RoundPhase.WorkInProgress && currentWave.Value == 0) StartNextWaveOnServer(); } public override void OnNetworkDespawn() { activeTargets.OnListChanged -= HandleTargetListChanged; radarMarks.OnListChanged -= HandleRadarListChanged; crosshairCoordinates.OnValueChanged -= HandleCrosshairChanged; currentWave.OnValueChanged -= HandleWaveChanged; radarReadyAt.OnValueChanged -= HandleRadarReadyAtChanged; lastShotResult.OnValueChanged -= HandleShotResultChanged; resolvedSequence.OnValueChanged -= HandleResolvedSequenceChanged; destroyedSequence.OnValueChanged -= HandleDestroyedSequenceChanged; UnbindRoundEvents(); } private void OnDestroy() { activeTargets.Dispose(); radarMarks.Dispose(); } private void Update() { if (!roundEventsBound) BindRoundEvents(); if (radarController != null) radarController.SetCooldownRemaining((float)RadarCooldownRemaining); if (!IsServer || !IsSpawned || !waveIsActive) return; simulationAccumulator += Time.deltaTime; while (simulationAccumulator >= simulationStep) { simulationAccumulator -= simulationStep; SimulateTargets(simulationStep); } } public void SetHorizontalNormalized(float value) { Vector2 next = crosshairCoordinates.Value; next.x = Mathf.Clamp01(value) * MapCoordinateSystem.CoordinateMaximum; RequestCrosshairCoordinates(next); } public void SetVerticalNormalized(float value) { Vector2 next = crosshairCoordinates.Value; next.y = Mathf.Clamp01(value) * MapCoordinateSystem.CoordinateMaximum; RequestCrosshairCoordinates(next); } public void SetHorizontalAxis(float value) => SetHorizontalNormalized(Mathf.InverseLerp(-1f, 1f, value)); public void SetVerticalAxis(float value) => SetVerticalNormalized(Mathf.InverseLerp(-1f, 1f, value)); public Vector2 GetCrosshairCoordinates() => crosshairCoordinates.Value; public void RequestPseudoFire() { if (!IsSpawned) return; if (IsServer) ResolvePseudoShotOnServer(); else RequestPseudoFireRpc(); } public void RequestRadarScan() { if (!IsSpawned) return; if (IsServer) ScanRadarOnServer(); else RequestRadarScanRpc(); } public bool ActivateRadar() { if (!IsSpawned || RadarCooldownRemaining > 0d) return false; RequestRadarScan(); return true; } public void CreateAndPlaceTargets() => StartNextWave(); public bool StartNextWave() { if (!IsSpawned) return false; if (IsServer) return StartNextWaveOnServer(); RequestStartNextWaveRpc(); return true; } public bool TryGetNearestTarget(out MapTarget target, out float distance) { if (crosshair != null) return crosshair.TryGetNearestTarget(out target, out distance); target = null; distance = -1f; return false; } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void SetCrosshairCoordinatesRpc(Vector2 coordinates) => SetCrosshairCoordinatesOnServer(coordinates); [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestPseudoFireRpc() => ResolvePseudoShotOnServer(); [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestRadarScanRpc() => ScanRadarOnServer(); [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestStartNextWaveRpc() => StartNextWaveOnServer(); private void RequestCrosshairCoordinates(Vector2 coordinates) { if (!IsSpawned) return; if (IsServer) SetCrosshairCoordinatesOnServer(coordinates); else SetCrosshairCoordinatesRpc(coordinates); } private void SetCrosshairCoordinatesOnServer(Vector2 coordinates) { if (!IsServer) return; crosshairCoordinates.Value = coordinateSystem != null ? coordinateSystem.ClampCoordinates(coordinates) : new Vector2( Mathf.Clamp(coordinates.x, 0f, MapCoordinateSystem.CoordinateMaximum), Mathf.Clamp(coordinates.y, 0f, MapCoordinateSystem.CoordinateMaximum)); } private bool StartNextWaveOnServer() { if (!IsServer || targetController == null || !targetController.HasValidConfiguration()) { Debug.LogWarning("[Map] Cannot start target wave: map or target configs are incomplete.", this); return false; } activeTargets.Clear(); radarMarks.Clear(); currentWave.Value++; escapedTargetCount = 0; initialTargetCount = 0; waveIsActive = true; simulationAccumulator = 0f; for (int index = 0; index < targetController.TargetsPerWave; index++) { int configIndex = targetController.GetRandomValidConfigIndex(); TargetConfig config = targetController.GetConfig(configIndex); if (config == null) continue; Vector2 coordinates = new( UnityEngine.Random.Range(0f, MapCoordinateSystem.CoordinateMaximum), UnityEngine.Random.Range(0f, MapCoordinateSystem.CoordinateMaximum)); activeTargets.Add(new MapTargetNetworkState( nextTargetId++, configIndex, coordinates, config.Health, config.TimeBeforeAttack)); initialTargetCount++; } Debug.Log($"[Map] Wave {currentWave.Value} started with {initialTargetCount} targets.", this); if (initialTargetCount == 0) CompleteWaveOnServer(); return true; } private void SimulateTargets(float deltaTime) { for (int index = activeTargets.Count - 1; index >= 0; index--) { MapTargetNetworkState state = activeTargets[index]; TargetConfig config = targetController.GetConfig(state.ConfigIndex); if (config == null) { ResolveEscapedTarget(index, state, null); continue; } bool isAirTarget = config.TargetPrefab is AirTarget; if (isAirTarget && mapPlayer != null && config.MovementSpeed > 0f) { Vector2 destination = mapPlayer.MapCoordinates; state.Coordinates = Vector2.MoveTowards( state.Coordinates, destination, config.MovementSpeed * deltaTime); if (Vector2.Distance(state.Coordinates, destination) <= config.StoppingDistance) { ResolveEscapedTarget(index, state, config); continue; } } else { state.AttackTimeRemaining = Mathf.Max( 0f, state.AttackTimeRemaining - deltaTime); if (state.AttackTimeRemaining <= 0f) { ResolveEscapedTarget(index, state, config); continue; } } activeTargets[index] = state; } TryCompleteWaveOnServer(); } private void ResolveEscapedTarget(int index, MapTargetNetworkState target, TargetConfig config) { int damage = config != null ? config.BaseDamage : 1; escapedTargetCount++; activeTargets.RemoveAt(index); if (baseHealth != null) baseHealth.ApplyDamage(damage); Debug.Log($"[Map] Target {target.TargetId} reached the base. Base damage: {damage}.", this); } private void ResolvePseudoShotOnServer() { if (!IsServer || !waveIsActive) { PublishShotResult(false, -1, -1f); return; } int nearestIndex = -1; float nearestDistance = float.PositiveInfinity; for (int index = 0; index < activeTargets.Count; index++) { float distance = Vector2.Distance( crosshairCoordinates.Value, activeTargets[index].Coordinates); if (distance >= nearestDistance) continue; nearestDistance = distance; nearestIndex = index; } if (nearestIndex >= 0 && nearestDistance <= hitRadius) { MapTargetNetworkState target = activeTargets[nearestIndex]; TargetConfig config = targetController.GetConfig(target.ConfigIndex); bool missileMatches = ignoreMissileTypeForPrototype || config == null || config.RequiredMissileType == MissileType.None || config.RequiredMissileType == pseudoMissileType; if (missileMatches) { target.Health -= shotDamage; if (target.Health <= 0) activeTargets.RemoveAt(nearestIndex); else activeTargets[nearestIndex] = target; PublishShotResult(true, target.TargetId, nearestDistance); Debug.Log($"[Map] HIT target {target.TargetId} at {nearestDistance:0.00} map units.", this); TryCompleteWaveOnServer(); return; } } int missedTargetId = nearestIndex >= 0 ? activeTargets[nearestIndex].TargetId : -1; PublishShotResult(false, missedTargetId, nearestDistance); float printedDistance = float.IsPositiveInfinity(nearestDistance) ? -1f : nearestDistance; Debug.Log($"[Map] MISS at {crosshairCoordinates.Value}. Nearest distance: {printedDistance:0.00}.", this); } private void PublishShotResult(bool hit, int targetId, float distance) { lastShotResult.Value = new MapShotResultState( lastShotResult.Value.Sequence + 1, hit, targetId, crosshairCoordinates.Value, float.IsPositiveInfinity(distance) ? -1f : distance); } private void ScanRadarOnServer() { if (!IsServer || radarController == null || NetworkManager == null || NetworkManager.ServerTime.Time < radarReadyAt.Value) return; radarMarks.Clear(); foreach (MapTargetNetworkState target in activeTargets) radarMarks.Add(target.Coordinates); radarReadyAt.Value = NetworkManager.ServerTime.Time + radarController.Cooldown; Debug.Log($"[Map] Radar scan found {radarMarks.Count} targets.", this); } private void TryCompleteWaveOnServer() { if (waveIsActive && activeTargets.Count == 0) CompleteWaveOnServer(); } private void CompleteWaveOnServer() { waveIsActive = false; resolvedSequence.Value++; if (initialTargetCount > 0 && escapedTargetCount == 0) destroyedSequence.Value++; Debug.Log( $"[Map] Wave {currentWave.Value} resolved. " + $"Destroyed: {Mathf.Max(0, initialTargetCount - escapedTargetCount)}, " + $"escaped: {escapedTargetCount}.", this); } private void ConfigurePresentation() { mapPlayer?.Configure(coordinateSystem); targetController?.Configure(coordinateSystem, targetParent, mapPlayer); radarController?.Configure(coordinateSystem, radarMarkPrefab); crosshair?.Configure( coordinateSystem, relayX, relayY, crosshairVisual, targetController); } private void ApplyPresentation() { crosshair?.SetCoordinates(crosshairCoordinates.Value); targetController?.ApplyNetworkTargets(CreateTargetSnapshot(), currentWave.Value); radarController?.ApplyRadarMarks(CreateRadarSnapshot()); if (radarController != null) radarController.SetCooldownRemaining((float)RadarCooldownRemaining); } private void BindRoundEvents() { if (roundEventsBound) return; if (roundGameLoop == null) roundGameLoop = FindFirstObjectByType(); if (roundGameLoop == null || !roundGameLoop.IsSpawned) return; roundGameLoop.PhaseChanged += HandleRoundPhaseChanged; roundEventsBound = true; } private void UnbindRoundEvents() { if (!roundEventsBound || roundGameLoop == null) return; roundGameLoop.PhaseChanged -= HandleRoundPhaseChanged; roundEventsBound = false; } private void HandleRoundPhaseChanged(RoundPhase phase) { if (IsServer && phase == RoundPhase.WorkInProgress && !waveIsActive) StartNextWaveOnServer(); } private void HandleTargetListChanged(NetworkListEvent _) => targetController?.ApplyNetworkTargets(CreateTargetSnapshot(), currentWave.Value); private void HandleRadarListChanged(NetworkListEvent _) => radarController?.ApplyRadarMarks(CreateRadarSnapshot()); private void HandleCrosshairChanged(Vector2 _, Vector2 current) => crosshair?.SetCoordinates(current); private void HandleWaveChanged(int _, int current) { targetController?.ApplyNetworkTargets(CreateTargetSnapshot(), current); WaveStarted?.Invoke(current); waveStarted?.Invoke(current); } private void HandleRadarReadyAtChanged(double _, double __) { if (radarController != null) radarController.SetCooldownRemaining((float)RadarCooldownRemaining); } private void HandleShotResultChanged(MapShotResultState previous, MapShotResultState current) { if (current.Sequence == 0 || current.Sequence == previous.Sequence) return; ShotResolved?.Invoke(current); shotResolved?.Invoke(current.Hit); } private void HandleResolvedSequenceChanged(uint previous, uint current) { if (current == previous) return; AllTargetsResolved?.Invoke(); allTargetsResolved?.Invoke(); } private void HandleDestroyedSequenceChanged(uint previous, uint current) { if (current == previous) return; AllTargetsDestroyed?.Invoke(); allTargetsDestroyed?.Invoke(); } private MapTargetNetworkState[] CreateTargetSnapshot() { MapTargetNetworkState[] snapshot = new MapTargetNetworkState[activeTargets.Count]; for (int index = 0; index < snapshot.Length; index++) snapshot[index] = activeTargets[index]; return snapshot; } private Vector2[] CreateRadarSnapshot() { Vector2[] snapshot = new Vector2[radarMarks.Count]; for (int index = 0; index < snapshot.Length; index++) snapshot[index] = radarMarks[index]; return snapshot; } }