enemy spawner

This commit is contained in:
2025-10-26 14:17:31 +01:00
parent 20d3b46834
commit 40a62b5b5a
2102 changed files with 1255290 additions and 70 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 61c1ac42a9e4eef6faf3c89f37f23e5c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
namespace Game.Scripts.Runtime.Abstractions
{
public interface IClock
{
float Elapsed { get; }
bool IsRunning { get; }
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 571bdb1b643aa3ca9a685d437bfa198e

View File

@@ -0,0 +1,11 @@
using Game.Scripts.Runtime.Data;
using UnityEngine;
namespace Game.Scripts.Runtime.Abstractions
{
public interface IEnemyFactory
{
GameObject Spawn(EnemyDefinition definition, Vector3 position, Quaternion rotation);
void Despawn(GameObject instance);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9cd9bf4b38832adc09d08d55b0cf1503

View File

@@ -0,0 +1,9 @@
namespace Game.Scripts.Runtime.Abstractions
{
public interface IPool<TKey, TValue>
{
void Prewarm(TKey key, int count);
TValue Get(TKey key);
void Release(TKey key, TValue value);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d1a3c285bb7769484ba53b11d2d4dbea

View File

@@ -0,0 +1,10 @@
using Game.Scripts.Runtime.Data;
using UnityEngine;
namespace Game.Scripts.Runtime.Abstractions
{
public interface ISpawnRule
{
bool CanSpawn(EnemyDefinition definition, Vector3 position);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 53678779ec0e460ac88698b25fc991f6

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: df6848b33d3716cbb91acccd380719f7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Game.Scripts.Runtime.Data
{
[CreateAssetMenu(menuName = "Game/Spawning/Boss Schedule", fileName = "BossSchedule")]
public class BossSchedule : ScriptableObject
{
[Serializable]
public class BossEvent
{
[Min(0f)] public float TimeSinceStart;
public EnemyDefinition Boss;
public int Count = 1;
[SerializeField] private bool useSpawnRadiusOverride;
[SerializeField] private float spawnRadiusOverride = 10f;
public bool HasSpawnRadiusOverride => useSpawnRadiusOverride;
public float SpawnRadiusOverride => spawnRadiusOverride;
}
[SerializeField] private List<BossEvent> events = new();
public IReadOnlyList<BossEvent> Events => events;
private void OnValidate()
{
events.Sort((a, b) => a.TimeSinceStart.CompareTo(b.TimeSinceStart));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e2ad306ddab577d19b1e0e6bbb6fea9

View File

@@ -0,0 +1,23 @@
using UnityEngine;
namespace Game.Scripts.Runtime.Data
{
[CreateAssetMenu(menuName = "Game/Spawning/Enemy Definition", fileName = "EnemyDefinition")]
public class EnemyDefinition : ScriptableObject
{
[Tooltip("Unique identifier leveraged by pooling and save systems. Defaults to asset name when left blank.")]
[SerializeField] private string id;
[Tooltip("Prefab that represents this enemy. Should include required behaviours and visuals.")]
public GameObject Prefab;
public bool IsBoss;
public float BaseHP = 10f;
public float MoveSpeed = 3.5f;
public float Damage = 1f;
public Vector3 PrefabPivotOffset = Vector3.zero;
public string[] Tags = System.Array.Empty<string>();
public string Id => string.IsNullOrEmpty(id) ? name : id;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b0e84710d3119d60af5d5373005e029

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Game.Scripts.Runtime.Data
{
[CreateAssetMenu(menuName = "Game/Spawning/Spawn Table", fileName = "SpawnTable")]
public class SpawnTable : ScriptableObject
{
[Serializable]
public class Entry
{
public EnemyDefinition Def;
[Min(0)] public int Weight = 1;
public int MinWave = 0;
public int MaxWave = int.MaxValue;
[Tooltip("Optional cap per wave. Values <= 0 mean unlimited.")]
public int Cap = -1;
public bool IsWaveValid(int wave) => wave >= MinWave && wave <= MaxWave;
public bool IsUnderCap(int currentCount)
{
if (Cap <= 0)
{
return true;
}
return currentCount < Cap;
}
}
[SerializeField] private List<Entry> entries = new();
private readonly List<Entry> _eligibleBuffer = new();
public IReadOnlyList<Entry> Entries => entries;
public EnemyDefinition Pick(int wave, System.Random rng)
{
if (entries == null || entries.Count == 0)
{
return null;
}
_eligibleBuffer.Clear();
foreach (var entry in entries)
{
if (entry?.Def == null || entry.Weight <= 0)
{
continue;
}
if (entry.IsWaveValid(wave))
{
_eligibleBuffer.Add(entry);
}
}
if (_eligibleBuffer.Count == 0)
{
return null;
}
var totalWeight = 0;
foreach (var entry in _eligibleBuffer)
{
totalWeight += Mathf.Max(1, entry.Weight);
}
var roll = rng.Next(totalWeight);
foreach (var entry in _eligibleBuffer)
{
var weight = Mathf.Max(1, entry.Weight);
if (roll < weight)
{
return entry.Def;
}
roll -= weight;
}
return _eligibleBuffer[^1].Def;
}
public IEnumerable<Entry> EnumerateEligibleEntries(int wave)
{
if (entries == null)
{
yield break;
}
foreach (var entry in entries)
{
if (entry?.Def == null || entry.Weight <= 0)
{
continue;
}
if (entry.IsWaveValid(wave))
{
yield return entry;
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8fd6e160d6ab3dfecbc59a0685007a97

View File

@@ -0,0 +1,44 @@
using UnityEngine;
namespace Game.Scripts.Runtime.Data
{
[CreateAssetMenu(menuName = "Game/Spawning/Spawner Config", fileName = "SpawnerConfig")]
public class SpawnerConfig : ScriptableObject
{
[Header("Radii")]
public float MinSpawnRadius = 8f;
public float MaxSpawnRadius = 24f;
[Header("Timing")]
public float SpawnInterval = 2f;
public AnimationCurve SpawnRateOverTime = AnimationCurve.Linear(0f, 1f, 1f, 1f);
[Header("Limits")]
public int MaxConcurrent = 30;
public int MaxPlacementAttempts = 6;
public int PrewarmPerType = 4;
public int PoolHardCapPerType = 60;
[Header("Placement")]
public float GroundRaycastHeight = 5f;
public float MaxHeightDelta = 4f;
public bool UseLineOfSight = false;
[Header("Layers")]
public LayerMask GroundMask = Physics.DefaultRaycastLayers;
public LayerMask ObstacleMask = Physics.DefaultRaycastLayers;
[Header("NavMesh")]
public int NavMeshAreaMask = -1;
[Header("RNG")]
[SerializeField] private bool useSeed;
[SerializeField] private int seedValue = 0;
[Header("Debug")]
public bool DrawGizmos = false;
public int GizmoSampleCount = 16;
public int? Seed => useSeed ? seedValue : (int?)null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4af5312c5abd9f725a626ceb6f7f8ae5

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 933d32c8200115ef88df534d69281d59
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,272 @@
using System;
using System.Collections;
using Game.Scripts.Runtime.Abstractions;
using Game.Scripts.Runtime.Data;
using Game.Scripts.Runtime.Spawning;
using UnityEngine;
namespace Game.Scripts.Runtime.Game
{
[DisallowMultipleComponent]
public class GameController : MonoBehaviour, IClock
{
[SerializeField] private EnemySpawner spawner;
[SerializeField] private BossSchedule bossSchedule;
[SerializeField] private SpawnerConfig defaultSpawnerConfig;
[SerializeField, Min(1f)] private float waveDurationSeconds = 30f;
[SerializeField] private bool autoStartOnEnable;
[SerializeField] private bool logStateChanges;
public float Elapsed => _elapsed;
public bool IsRunning => _isRunning && !_isPaused;
public int CurrentWaveIndex => _currentWaveIndex;
public event Action OnGameStarted;
public event Action OnGamePaused;
public event Action OnGameResumed;
public event Action OnGameStopped;
public event Action<int> OnWaveStarted;
public event Action<int> OnWaveCompleted;
public event Action<EnemyDefinition, int> OnBossSpawned;
private Coroutine _loop;
private float _elapsed;
private bool _isRunning;
private bool _isPaused;
private int _currentWaveIndex = -1;
private int _bossCursor;
private void OnEnable()
{
if (autoStartOnEnable)
{
StartGame();
}
}
private void OnDisable()
{
StopGame();
}
public void StartGame()
{
if (_isRunning)
{
return;
}
_elapsed = 0f;
_isRunning = true;
_isPaused = false;
_currentWaveIndex = -1;
_bossCursor = 0;
ConfigureSpawner();
spawner?.SetClock(this);
spawner?.Begin();
UpdateWaveState();
_loop = StartCoroutine(GameLoop());
OnGameStarted?.Invoke();
Log($"Game started waveDuration={waveDurationSeconds}s");
}
public void PauseGame()
{
if (!_isRunning || _isPaused)
{
return;
}
_isPaused = true;
OnGamePaused?.Invoke();
Log("Game paused");
}
public void ResumeGame()
{
if (!_isRunning || !_isPaused)
{
return;
}
_isPaused = false;
OnGameResumed?.Invoke();
Log("Game resumed");
}
public void StopGame()
{
if (!_isRunning)
{
return;
}
_isRunning = false;
_isPaused = false;
if (_loop != null)
{
StopCoroutine(_loop);
_loop = null;
}
spawner?.End();
if (_currentWaveIndex >= 0)
{
OnWaveCompleted?.Invoke(_currentWaveIndex);
}
OnGameStopped?.Invoke();
_currentWaveIndex = -1;
_elapsed = 0f;
_bossCursor = 0;
Log("Game stopped and state reset");
}
private IEnumerator GameLoop()
{
while (_isRunning)
{
if (!_isPaused)
{
_elapsed += Time.deltaTime;
UpdateWaveState();
TickBossSchedule();
}
yield return null;
}
}
private void ConfigureSpawner()
{
if (spawner == null)
{
return;
}
if (defaultSpawnerConfig != null)
{
spawner.Configure(null, defaultSpawnerConfig);
}
PrewarmBosses();
}
private void PrewarmBosses()
{
if (spawner?.Pool == null || bossSchedule == null || bossSchedule.Events == null)
{
return;
}
var cfg = spawner.ActiveConfig ?? defaultSpawnerConfig;
foreach (var evt in bossSchedule.Events)
{
if (evt?.Boss == null)
{
continue;
}
var cap = cfg?.PoolHardCapPerType ?? 64;
var prewarm = cfg?.PrewarmPerType ?? 1;
spawner.Pool.SetHardCap(evt.Boss, cap);
spawner.Pool.Prewarm(evt.Boss, prewarm);
}
}
private void UpdateWaveState()
{
var safeDuration = Mathf.Max(1f, waveDurationSeconds);
var computedWave = Mathf.Max(0, Mathf.FloorToInt(_elapsed / safeDuration));
if (_currentWaveIndex < 0)
{
_currentWaveIndex = computedWave;
spawner?.SetWaveIndex(_currentWaveIndex);
OnWaveStarted?.Invoke(_currentWaveIndex);
Log($"Wave {_currentWaveIndex} started (initial)");
return;
}
if (computedWave != _currentWaveIndex)
{
var previous = _currentWaveIndex;
_currentWaveIndex = computedWave;
OnWaveCompleted?.Invoke(previous);
spawner?.SetWaveIndex(_currentWaveIndex);
OnWaveStarted?.Invoke(_currentWaveIndex);
Log($"Wave advanced: {previous} -> {_currentWaveIndex}");
}
}
private void TickBossSchedule()
{
if (bossSchedule == null || bossSchedule.Events == null || bossSchedule.Events.Count == 0)
{
return;
}
while (_bossCursor < bossSchedule.Events.Count)
{
var evt = bossSchedule.Events[_bossCursor];
if (evt == null || evt.Boss == null)
{
_bossCursor++;
continue;
}
if (_elapsed < evt.TimeSinceStart)
{
break;
}
var spawned = SpawnBossWave(evt);
if (spawned > 0)
{
OnBossSpawned?.Invoke(evt.Boss, spawned);
Log($"Boss spawn triggered for {evt.Boss.name} x{spawned}");
}
_bossCursor++;
}
}
private int SpawnBossWave(BossSchedule.BossEvent evt)
{
if (spawner == null)
{
return 0;
}
var total = Mathf.Max(1, evt.Count);
var success = 0;
for (var i = 0; i < total; i++)
{
var radius = evt.HasSpawnRadiusOverride ? evt.SpawnRadiusOverride : (float?)null;
var spawned = radius.HasValue
? spawner.TrySpawnWithRadius(evt.Boss, radius.Value)
: spawner.TrySpawn(evt.Boss);
if (spawned)
{
success++;
}
}
return success;
}
private void Log(string message)
{
if (logStateChanges)
{
Debug.Log($"[{name}] {message}", this);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e0139f703f0ee517daa8cfa061c70231

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 46d196a10a132337990b704f03710c79
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,47 @@
using System;
using UnityEngine.AI;
namespace Game.Scripts.Runtime.Navigation
{
/// <summary>
/// Lightweight cache around NavMesh availability checks so gameplay code
/// can gracefully degrade when a bake is not present.
/// </summary>
public static class NavMeshRuntimeUtility
{
private static bool? _cachedHasNavMesh;
/// <summary>
/// Returns true if there is any baked NavMesh data loaded at runtime.
/// Result is cached until <see cref="InvalidateCache"/> is called.
/// </summary>
public static bool HasNavMeshData()
{
if (_cachedHasNavMesh.HasValue)
{
return _cachedHasNavMesh.Value;
}
try
{
var triangulation = NavMesh.CalculateTriangulation();
var hasData = triangulation.vertices != null && triangulation.vertices.Length > 0;
_cachedHasNavMesh = hasData;
return hasData;
}
catch (Exception)
{
_cachedHasNavMesh = false;
return false;
}
}
/// <summary>
/// Clears the cached NavMesh availability state. Call this after baking at runtime.
/// </summary>
public static void InvalidateCache()
{
_cachedHasNavMesh = null;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 19ab015c2ac2d472aa3d70eedc029e97

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d3e4f9dd504704b52a63505b81c25d5a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using Game.Scripts.Runtime.Abstractions;
using Game.Scripts.Runtime.Data;
using UnityEngine;
namespace Game.Scripts.Runtime.Pooling
{
[DefaultExecutionOrder(-300)]
[DisallowMultipleComponent]
public class ObjectPooler : MonoBehaviour, IPool<EnemyDefinition, GameObject>, IEnemyFactory
{
[SerializeField] private Transform poolRoot;
[SerializeField] private bool markAsDontDestroyOnLoad = true;
[SerializeField] private int defaultHardCap = 64;
private readonly Dictionary<string, PoolBucket> _pools = new();
private readonly Dictionary<GameObject, PoolItem> _lookup = new();
public static ObjectPooler SharedInstance { get; private set; }
public event Action<GameObject, EnemyDefinition> InstanceSpawned;
public event Action<GameObject, EnemyDefinition> InstanceDespawned;
private void Awake()
{
EnsureRoot();
if (markAsDontDestroyOnLoad && Application.isPlaying)
{
DontDestroyOnLoad(gameObject);
}
if (SharedInstance == null)
{
SharedInstance = this;
}
}
public void Prewarm(EnemyDefinition definition, int count)
{
if (definition == null || definition.Prefab == null || count <= 0)
{
return;
}
var key = GetKey(definition);
var bucket = GetOrCreateBucket(key);
bucket.HardCap = bucket.HardCap <= 0 ? defaultHardCap : bucket.HardCap;
var remaining = Mathf.Max(0, Math.Min(count, bucket.HardCap) - bucket.TotalCount);
for (var i = 0; i < remaining; i++)
{
var instance = CreateInstance(definition, key);
if (instance == null)
{
return;
}
bucket.Available.Enqueue(instance);
}
}
public void SetHardCap(EnemyDefinition definition, int hardCap)
{
if (definition == null)
{
return;
}
var key = GetKey(definition);
var bucket = GetOrCreateBucket(key);
bucket.HardCap = Mathf.Max(1, hardCap);
}
public GameObject Spawn(EnemyDefinition definition, Vector3 position, Quaternion rotation)
{
if (definition == null || definition.Prefab == null)
{
Debug.LogWarning("Cannot spawn without a valid definition or prefab.", this);
return null;
}
var key = GetKey(definition);
var bucket = GetOrCreateBucket(key);
bucket.HardCap = bucket.HardCap <= 0 ? defaultHardCap : bucket.HardCap;
if (bucket.Available.Count == 0 && bucket.TotalCount >= bucket.HardCap)
{
Debug.LogWarning($"Pool for {key} reached hard cap of {bucket.HardCap}", this);
return null;
}
if (bucket.Available.Count == 0)
{
var instance = CreateInstance(definition, key);
if (instance != null)
{
bucket.Available.Enqueue(instance);
}
}
if (bucket.Available.Count == 0)
{
return null;
}
var go = bucket.Available.Dequeue();
if (!_lookup.TryGetValue(go, out var item))
{
return null;
}
bucket.Active.Add(go);
go.transform.SetParent(null, true);
go.transform.SetPositionAndRotation(position + definition.PrefabPivotOffset, rotation);
go.SetActive(true);
InstanceSpawned?.Invoke(go, definition);
return go;
}
public void Despawn(GameObject instance)
{
if (instance == null)
{
return;
}
if (!_lookup.TryGetValue(instance, out var item))
{
Destroy(instance);
return;
}
var bucket = GetOrCreateBucket(item.Key);
bucket.Active.Remove(instance);
instance.SetActive(false);
instance.transform.SetParent(poolRoot, false);
bucket.Available.Enqueue(instance);
item.Handle?.NotifyDespawned();
InstanceDespawned?.Invoke(instance, item.Definition);
}
void IPool<EnemyDefinition, GameObject>.Prewarm(EnemyDefinition key, int count) => Prewarm(key, count);
GameObject IPool<EnemyDefinition, GameObject>.Get(EnemyDefinition key) => Spawn(key, Vector3.zero, Quaternion.identity);
void IPool<EnemyDefinition, GameObject>.Release(EnemyDefinition key, GameObject value) => Despawn(value);
GameObject IEnemyFactory.Spawn(EnemyDefinition definition, Vector3 position, Quaternion rotation) => Spawn(definition, position, rotation);
void IEnemyFactory.Despawn(GameObject instance) => Despawn(instance);
private PoolBucket GetOrCreateBucket(string key)
{
if (!_pools.TryGetValue(key, out var bucket))
{
bucket = new PoolBucket { HardCap = defaultHardCap };
_pools.Add(key, bucket);
}
return bucket;
}
private GameObject CreateInstance(EnemyDefinition definition, string key)
{
if (definition == null || definition.Prefab == null)
{
return null;
}
EnsureRoot();
var go = Instantiate(definition.Prefab, poolRoot);
go.name = $"{definition.Prefab.name}_Pooled";
go.SetActive(false);
var handle = go.GetComponent<PooledInstance>();
if (handle == null)
{
handle = go.AddComponent<PooledInstance>();
}
handle.Initialize(this, key, definition);
_lookup[go] = new PoolItem(definition, key, handle);
return go;
}
private static string GetKey(EnemyDefinition definition)
{
if (definition == null)
{
return string.Empty;
}
return string.IsNullOrEmpty(definition.Id) ? definition.name : definition.Id;
}
private sealed class PoolBucket
{
public readonly Queue<GameObject> Available = new();
public readonly HashSet<GameObject> Active = new();
public int HardCap;
public int TotalCount => Available.Count + Active.Count;
}
private sealed class PoolItem
{
public readonly EnemyDefinition Definition;
public readonly string Key;
public readonly PooledInstance Handle;
public PoolItem(EnemyDefinition definition, string key, PooledInstance handle)
{
Definition = definition;
Key = key;
Handle = handle;
}
}
private void EnsureRoot()
{
if (poolRoot != null)
{
return;
}
var root = new GameObject("[Pool]");
root.transform.SetParent(transform, false);
poolRoot = root.transform;
}
}
[DisallowMultipleComponent]
public sealed class PooledInstance : MonoBehaviour
{
private ObjectPooler _pool;
private string _key;
private EnemyDefinition _definition;
public event Action<PooledInstance> Despawned;
internal void Initialize(ObjectPooler pool, string key, EnemyDefinition definition)
{
_pool = pool;
_key = key;
_definition = definition;
}
public void ReturnToPool()
{
_pool?.Despawn(gameObject);
}
internal void NotifyDespawned()
{
Despawned?.Invoke(this);
}
public EnemyDefinition Definition => _definition;
public string Key => _key;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9307c12832cd4aad3b75e864616073a4

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1da2bcb2f4ea277dd9c4173e391e4811
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0dcdb636426092128b5b358481d4dee7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,66 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
namespace Game.Scripts.Runtime.Spawning.Editor
{
[CustomEditor(typeof(EnemySpawner))]
public class EnemySpawnerGizmos : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
using (new EditorGUI.DisabledScope(!Application.isPlaying))
{
if (GUILayout.Button("Force Spawn Test Enemy"))
{
var spawner = (EnemySpawner)target;
var table = spawner.ActiveTable;
if (table != null)
{
var def = table.Pick(spawner.CurrentWaveIndex, new System.Random());
if (def != null)
{
spawner.TrySpawn(def);
}
}
}
}
}
private void OnSceneGUI()
{
var spawner = (EnemySpawner)target;
var config = spawner.ActiveConfig;
var player = spawner.PlayerTransform;
if (config == null || player == null || !config.DrawGizmos)
{
return;
}
Handles.color = new Color(1f, 0.8f, 0f, 0.7f);
Handles.DrawWireDisc(player.position, Vector3.up, config.MinSpawnRadius);
Handles.color = new Color(1f, 0f, 0f, 0.7f);
Handles.DrawWireDisc(player.position, Vector3.up, config.MaxSpawnRadius);
var samples = spawner.RecentSamples;
if (samples != null)
{
foreach (var sample in samples)
{
Handles.color = sample.Accepted ? Color.green : Color.magenta;
Handles.SphereHandleCap(0, sample.Position, Quaternion.identity, 0.35f, EventType.Repaint);
}
}
Handles.BeginGUI();
GUILayout.BeginArea(new Rect(12f, 12f, 200f, 70f), GUI.skin.box);
GUILayout.Label("Spawner Debug", EditorStyles.boldLabel);
GUILayout.Label($"Active: {spawner.ActiveEnemyCount}/{config.MaxConcurrent}");
GUILayout.EndArea();
Handles.EndGUI();
}
}
}
#endif

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9439d534025ac2a28a9250ffd9e349be

View File

@@ -0,0 +1,710 @@
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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d7f3cf4476796bb0095ba2dde7810079