711 lines
22 KiB
C#
711 lines
22 KiB
C#
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<MonoBehaviour> spawnRuleBehaviours = new();
|
||
[Header("Debug Logging")]
|
||
[SerializeField] private bool logLifecycle;
|
||
[SerializeField] private bool logSpawnAttempts;
|
||
[SerializeField] private bool logSpawnFailures;
|
||
|
||
public event Action<GameObject, EnemyDefinition> OnEnemySpawned;
|
||
public event Action<GameObject, EnemyDefinition> OnBossSpawned;
|
||
|
||
public bool IsRunning => _spawnLoop != null;
|
||
public int CurrentWaveIndex { get; private set; }
|
||
|
||
public IReadOnlyList<SpawnSampleRecord> 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<GameObject> _activeInstances = new();
|
||
private readonly Dictionary<GameObject, EnemyDefinition> _instanceToDefinition = new();
|
||
private readonly Dictionary<EnemyDefinition, int> _spawnedThisWave = new();
|
||
private readonly List<SpawnSampleRecord> _recentSamples = new();
|
||
private readonly List<ISpawnRule> _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<ObjectPooler>();
|
||
}
|
||
|
||
_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<SpawnTable.Entry>();
|
||
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 : "<null>";
|
||
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<PooledInstance>();
|
||
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<ThirdPersonCharacterController>();
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|