Files
megakoop/Game/Scripts/Runtime/Spawning/EnemySpawner.cs
2025-10-27 12:37:18 +01:00

765 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 MegaKoop.Game.Networking;
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 bool _authorityOverrideSet;
private bool _authorityOverrideValue = true;
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;
if (HasAuthority())
{
_spawnLoop = StartCoroutine(SpawnLoop());
LogLifecycle($"Begin wave {CurrentWaveIndex}, pool prewarm complete, maxConcurrent={_runtimeConfig.MaxConcurrent}");
}
else
{
_spawnLoop = null;
LogLifecycle($"Begin follower mode (no spawn loop), wave {CurrentWaveIndex}, 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 (!HasAuthority())
{
yield return null;
continue;
}
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);
}
}
internal void SetAuthorityOverride(bool hasAuthority)
{
_authorityOverrideSet = true;
_authorityOverrideValue = hasAuthority;
if (!HasAuthority() && _spawnLoop != null)
{
StopCoroutine(_spawnLoop);
_spawnLoop = null;
}
else if (HasAuthority() && _spawnLoop == null && _runtimeConfig != null && _runtimeTable != null)
{
_spawnLoop = StartCoroutine(SpawnLoop());
}
}
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;
}
}
private bool HasAuthority()
{
if (_authorityOverrideSet)
{
return _authorityOverrideValue;
}
var manager = SteamCoopNetworkManager.Instance;
if (manager == null)
{
return true;
}
if (!manager.IsConnected)
{
return true;
}
return manager.IsHost;
}
}
}