using System; using System.Collections; using System.Collections.Generic; using Game.Scripts.Runtime.Abstractions; using Game.Scripts.Runtime.Data; using Game.Scripts.Runtime.Pooling; using Game.Scripts.Runtime.Navigation; using MegaKoop.Game; using UnityEngine; using UnityEngine.AI; namespace Game.Scripts.Runtime.Spawning { [DisallowMultipleComponent] public class EnemySpawner : MonoBehaviour { [Header("Scene References")] [SerializeField] private Transform player; [SerializeField] private ObjectPooler pooler; [Header("Player Resolution")] [SerializeField] private bool autoAssignPlayer = true; [SerializeField] private string fallbackPlayerTag = "Player"; [SerializeField] private bool fallbackToMainCamera = true; [Header("Data"), Tooltip("Fallback config when Configure() is not called at runtime.")] [SerializeField] private SpawnerConfig config; [SerializeField] private SpawnTable defaultSpawnTable; [Header("Overrides")] [SerializeField] private LayerMask groundMask = Physics.DefaultRaycastLayers; [SerializeField] private LayerMask obstacleMask = Physics.DefaultRaycastLayers; [SerializeField] private bool useLineOfSight = true; [SerializeField] private float maxHeightDelta = 4f; [SerializeField] private int navMeshAreaMask = -1; [SerializeField] private List spawnRuleBehaviours = new(); [Header("Debug Logging")] [SerializeField] private bool logLifecycle; [SerializeField] private bool logSpawnAttempts; [SerializeField] private bool logSpawnFailures; public event Action OnEnemySpawned; public event Action OnBossSpawned; public bool IsRunning => _spawnLoop != null; public int CurrentWaveIndex { get; private set; } public IReadOnlyList RecentSamples => _recentSamples; public SpawnerConfig ActiveConfig => _runtimeConfig ?? config; public SpawnTable ActiveTable => _runtimeTable ?? defaultSpawnTable; public Transform PlayerTransform => player; public int ActiveEnemyCount => _activeInstances.Count; public ObjectPooler Pool => pooler; private readonly HashSet _activeInstances = new(); private readonly Dictionary _instanceToDefinition = new(); private readonly Dictionary _spawnedThisWave = new(); private readonly List _recentSamples = new(); private readonly List _rules = new(); private SpawnTable _runtimeTable; private SpawnerConfig _runtimeConfig; private Coroutine _spawnLoop; private System.Random _rng; private IClock _clock; private float _localElapsed; private float _nextSpawnTimestamp; private IEnemyFactory _factory; private bool _warnedMissingPlayer; private bool _warnedMissingNavMesh; private void OnValidate() { CacheRules(); } private void Awake() { CacheRules(); if (pooler == null) { pooler = GetComponent(); } _factory = pooler; EnsurePlayerReference(); } private void OnEnable() { EnsurePlayerReference(); if (pooler != null) { pooler.InstanceDespawned += HandleInstanceDespawned; } } private void OnDisable() { if (pooler != null) { pooler.InstanceDespawned -= HandleInstanceDespawned; } if (_spawnLoop != null) { StopCoroutine(_spawnLoop); _spawnLoop = null; } } public void Configure(SpawnTable table, SpawnerConfig overrideConfig) { if (table != null) { _runtimeTable = table; } if (overrideConfig != null) { _runtimeConfig = overrideConfig; } ResetRng(); } public void SetClock(IClock clock) { _clock = clock; } public void SetPlayer(Transform playerTransform) { player = playerTransform; if (player != null) { _warnedMissingPlayer = false; } } public void Begin() { if (_spawnLoop != null) { return; } _runtimeConfig ??= config; _runtimeTable ??= defaultSpawnTable; _factory ??= pooler; if (_runtimeTable == null || _runtimeConfig == null || _factory == null) { LogLifecycle($"Cannot begin – missing refs (table:{_runtimeTable != null}, config:{_runtimeConfig != null}, player:{player != null}, factory:{_factory != null})"); Debug.LogWarning("EnemySpawner is missing configuration or references and cannot begin.", this); return; } if (!RequirePlayerReference()) { LogLifecycle("Cannot begin – missing player reference"); return; } ResetRng(); PrewarmPools(); _activeInstances.Clear(); _instanceToDefinition.Clear(); _spawnedThisWave.Clear(); _localElapsed = 0f; _nextSpawnTimestamp = 0f; _spawnLoop = StartCoroutine(SpawnLoop()); LogLifecycle($"Begin – wave {CurrentWaveIndex}, pool prewarm complete, maxConcurrent={_runtimeConfig.MaxConcurrent}"); } public void End() { if (_spawnLoop == null) { return; } StopCoroutine(_spawnLoop); _spawnLoop = null; LogLifecycle($"End – cleaned up coroutine, active={_activeInstances.Count}"); } public void SetWaveIndex(int wave) { if (CurrentWaveIndex == wave) { return; } CurrentWaveIndex = Mathf.Max(0, wave); _spawnedThisWave.Clear(); LogLifecycle($"Wave index set to {CurrentWaveIndex}"); } public bool TrySpawn(EnemyDefinition definition, Vector3? positionOverride = null) { return TrySpawnInternal(definition, positionOverride, null); } internal bool TrySpawnWithRadius(EnemyDefinition definition, float radiusOverride) { return TrySpawnInternal(definition, null, radiusOverride); } public bool TrySampleSpawnPosition(out Vector3 position, float? radiusOverride = null) { return TrySampleSpawnPoint(null, out position, radiusOverride); } private IEnumerator SpawnLoop() { while (true) { if (_clock == null) { _localElapsed += Time.deltaTime; } else { _localElapsed = _clock.Elapsed; } if (_localElapsed >= _nextSpawnTimestamp) { ExecuteSpawnTick(); var interval = GetDynamicInterval(); _nextSpawnTimestamp = _localElapsed + interval; } yield return null; } } private void ExecuteSpawnTick() { if (_runtimeConfig == null || _runtimeTable == null) { return; } if (_activeInstances.Count >= _runtimeConfig.MaxConcurrent) { return; } var attempts = Mathf.Clamp(_runtimeConfig.MaxConcurrent - _activeInstances.Count, 1, 4); for (var i = 0; i < attempts; i++) { var def = PickDefinition(); if (def == null) { break; } if (!TrySpawnInternal(def, null, null)) { break; } if (_activeInstances.Count >= _runtimeConfig.MaxConcurrent) { break; } } } private EnemyDefinition PickDefinition() { _runtimeTable ??= defaultSpawnTable; if (_runtimeTable == null) { return null; } EnsureRng(); var candidates = new List(); foreach (var entry in _runtimeTable.EnumerateEligibleEntries(CurrentWaveIndex)) { var currentCount = _spawnedThisWave.TryGetValue(entry.Def, out var count) ? count : 0; if (!entry.IsUnderCap(currentCount)) { continue; } candidates.Add(entry); } if (candidates.Count == 0) { return null; } var totalWeight = 0; foreach (var entry in candidates) { totalWeight += Mathf.Max(1, entry.Weight); } var roll = _rng.Next(totalWeight); foreach (var entry in candidates) { var weight = Mathf.Max(1, entry.Weight); if (roll < weight) { return entry.Def; } roll -= weight; } return candidates[^1].Def; } private bool TrySpawnInternal(EnemyDefinition definition, Vector3? positionOverride, float? radiusOverride) { _runtimeConfig ??= config; if (definition == null || _factory == null || _runtimeConfig == null) { LogSpawnFailure($"TrySpawnInternal aborted – definition:{definition != null}, factory:{_factory != null}, config:{_runtimeConfig != null}"); return false; } EnsureRng(); if (_activeInstances.Count >= _runtimeConfig.MaxConcurrent) { LogSpawnFailure($"Spawn blocked – MaxConcurrent reached ({_activeInstances.Count}/{_runtimeConfig.MaxConcurrent})"); return false; } var hasPosition = positionOverride.HasValue; var candidatePosition = positionOverride.GetValueOrDefault(); var definitionName = definition != null ? definition.name : ""; if (!hasPosition) { if (!TrySampleSpawnPoint(definition, out candidatePosition, radiusOverride)) { LogSpawnFailure($"Failed to sample spawn point for {definitionName} (wave {CurrentWaveIndex})"); return false; } } var spawned = _factory.Spawn(definition, candidatePosition, Quaternion.identity); if (spawned == null) { LogSpawnFailure($"Pool returned null for {definitionName}"); return false; } _activeInstances.Add(spawned); _instanceToDefinition[spawned] = definition; _spawnedThisWave.TryGetValue(definition, out var count); _spawnedThisWave[definition] = count + 1; TrackInstance(spawned); OnEnemySpawned?.Invoke(spawned, definition); LogSpawn($"Spawned {definition.name} at {candidatePosition} | active={_activeInstances.Count}/{_runtimeConfig.MaxConcurrent}"); if (definition.IsBoss) { OnBossSpawned?.Invoke(spawned, definition); } return true; } private void TrackInstance(GameObject instance) { var handle = instance.GetComponent(); if (handle == null) { return; } handle.Despawned -= HandlePooledDespawn; handle.Despawned += HandlePooledDespawn; } private bool TrySampleSpawnPoint(EnemyDefinition definition, out Vector3 position, float? radiusOverride) { position = Vector3.zero; if (!RequirePlayerReference()) { return false; } var cfg = _runtimeConfig ?? config; if (cfg == null) { return false; } EnsureRng(); float minRadius; float maxRadius; if (radiusOverride.HasValue) { minRadius = Mathf.Max(0.1f, radiusOverride.Value); maxRadius = minRadius; } else { minRadius = Mathf.Max(0.1f, cfg.MinSpawnRadius); maxRadius = Mathf.Max(minRadius + 0.1f, cfg.MaxSpawnRadius); } var attempts = Mathf.Max(1, cfg.MaxPlacementAttempts); var navMask = navMeshAreaMask != 0 ? navMeshAreaMask : cfg.NavMeshAreaMask; if (navMask != 0 && !NavMeshRuntimeUtility.HasNavMeshData()) { navMask = 0; if (!_warnedMissingNavMesh) { Debug.LogWarning($"[{name}] No baked NavMesh detected. Falling back to ground-only placement.", this); _warnedMissingNavMesh = true; } } var los = cfg.UseLineOfSight; var heightTolerance = cfg.MaxHeightDelta > 0f ? cfg.MaxHeightDelta : maxHeightDelta; var groundMaskValue = cfg.GroundMask.value == 0 ? (groundMask.value == 0 ? Physics.DefaultRaycastLayers : groundMask.value) : cfg.GroundMask.value; var obstacleMaskValue = cfg.ObstacleMask.value == 0 ? (obstacleMask.value == 0 ? Physics.DefaultRaycastLayers : obstacleMask.value) : cfg.ObstacleMask.value; for (var i = 0; i < attempts; i++) { var angle = (float)(_rng.NextDouble() * Mathf.PI * 2.0); var t = Mathf.Sqrt((float)_rng.NextDouble()); var radius = Mathf.Approximately(minRadius, maxRadius) ? minRadius : Mathf.Lerp(minRadius, maxRadius, t); var offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius; var sampleOrigin = player.position + offset + Vector3.up * cfg.GroundRaycastHeight; if (!Physics.Raycast(sampleOrigin, Vector3.down, out var hit, cfg.GroundRaycastHeight * 2f, groundMaskValue, QueryTriggerInteraction.Ignore)) { RecordSample(sampleOrigin, false, "NoGround"); continue; } var point = hit.point; if (Mathf.Abs(point.y - player.position.y) > heightTolerance) { RecordSample(point, false, "Height"); continue; } if (los || useLineOfSight) { var origin = player.position + Vector3.up * 1.6f; var target = point + Vector3.up * 0.5f; if (Physics.Linecast(origin, target, obstacleMaskValue, QueryTriggerInteraction.Ignore)) { RecordSample(point, false, "LOS"); continue; } } if (navMask != 0) { if (!NavMesh.SamplePosition(point, out var navHit, 1.5f, navMask)) { RecordSample(point, false, "NavMesh"); continue; } point = navHit.position; } if (!EvaluateRules(definition, point)) { RecordSample(point, false, "Rule"); continue; } position = point; RecordSample(point, true, "OK"); return true; } LogSpawnFailure($"SamplePoint failed after {attempts} attempts. Radius {minRadius}-{maxRadius}, navMask={navMask}, player={player?.name}"); return false; } private bool EvaluateRules(EnemyDefinition definition, Vector3 candidate) { if (_rules.Count == 0) { return true; } foreach (var rule in _rules) { if (rule == null) { continue; } if (!rule.CanSpawn(definition, candidate)) { return false; } } return true; } private float GetDynamicInterval() { var interval = _runtimeConfig?.SpawnInterval ?? 1f; var curve = _runtimeConfig?.SpawnRateOverTime; if (curve == null) { return Mathf.Max(0.05f, interval); } var multiplier = Mathf.Max(0.01f, curve.Evaluate(_localElapsed)); return Mathf.Max(0.05f, interval / multiplier); } private void PrewarmPools() { if (_runtimeTable == null || _runtimeConfig == null || pooler == null) { return; } foreach (var entry in _runtimeTable.Entries) { if (entry?.Def == null) { continue; } pooler.SetHardCap(entry.Def, _runtimeConfig.PoolHardCapPerType); pooler.Prewarm(entry.Def, _runtimeConfig.PrewarmPerType); } } private void ResetRng() { if (_runtimeConfig != null && _runtimeConfig.Seed.HasValue) { _rng = new System.Random(_runtimeConfig.Seed.Value); return; } _rng = new System.Random(Environment.TickCount); } private void EnsureRng() { if (_rng == null) { _rng = new System.Random(Environment.TickCount); } } private void HandleInstanceDespawned(GameObject instance, EnemyDefinition definition) { if (instance == null) { return; } _activeInstances.Remove(instance); _instanceToDefinition.Remove(instance); } private void HandlePooledDespawn(PooledInstance instance) { HandleInstanceDespawned(instance?.gameObject, instance?.Definition); } private void CacheRules() { _rules.Clear(); foreach (var behaviour in spawnRuleBehaviours) { if (behaviour is ISpawnRule rule) { _rules.Add(rule); } } } private void RecordSample(Vector3 position, bool accepted, string reason) { var limit = Mathf.Max(0, _runtimeConfig?.GizmoSampleCount ?? config?.GizmoSampleCount ?? 0); if (limit <= 0) { return; } _recentSamples.Add(new SpawnSampleRecord(position, accepted, reason)); while (_recentSamples.Count > limit) { _recentSamples.RemoveAt(0); } } public readonly struct SpawnSampleRecord { public readonly Vector3 Position; public readonly bool Accepted; public readonly string Reason; public SpawnSampleRecord(Vector3 position, bool accepted, string reason) { Position = position; Accepted = accepted; Reason = reason; } } private void LogLifecycle(string message) { if (logLifecycle) { Debug.Log($"[{name}] {message}", this); } } private void LogSpawn(string message) { if (logSpawnAttempts) { Debug.Log($"[{name}] {message}", this); } } private void LogSpawnFailure(string message) { if (logSpawnFailures) { Debug.LogWarning($"[{name}] {message}", this); } } private bool RequirePlayerReference() { if (EnsurePlayerReference()) { return true; } if (!_warnedMissingPlayer) { Debug.LogWarning($"[{name}] EnemySpawner could not locate a player Transform. Assign one manually or enable auto assignment.", this); _warnedMissingPlayer = true; } return false; } private bool EnsurePlayerReference() { if (player != null) { return true; } if (autoAssignPlayer) { var tagged = TryFindPlayerByTag(fallbackPlayerTag); if (tagged != null) { player = tagged; return true; } var controller = FindObjectOfType(); if (controller != null) { player = controller.transform; return true; } if (fallbackToMainCamera && Camera.main != null) { player = Camera.main.transform; return true; } } return false; } private static Transform TryFindPlayerByTag(string tag) { if (string.IsNullOrEmpty(tag)) { return null; } try { var taggedObject = GameObject.FindGameObjectWithTag(tag); return taggedObject != null ? taggedObject.transform : null; } catch (UnityException) { return null; } } } }