using System; using System.Collections; using System.Collections.Generic; using RR.Common.Entities.Props; using RR.Game2D.Core.Managers; using UnityEngine; namespace RR.Game2D.Behaviour { public sealed class EnvironmentalCollectibleSpawner : MonoBehaviour { [Serializable] public struct SpawnEntry { public EnvironmentalCollectibleBase prefab; public int weight; public bool overridePoints; public int pointsOverride; public bool overrideLifetime; [Tooltip("Время жизни в секундах. 0 или меньше — бесконечно.")] public float lifetimeOverride; public int limit; } [Serializable] public struct NamedSpawnEntry { [Tooltip("Имя, по которому триггер выбирает этот entry (например \"leaves\", \"bats\").")] public string key; public SpawnEntry entry; [Tooltip("Мгновенно убрать все чужие объекты при активации.")] public bool hardReplace; } [Header("Spawn Count")] [SerializeField] private int fixedCount = 8; [SerializeField] private int maxSpawnAttempts = 12; [Tooltip("Задержка между спауном каждого объекта (секунды). 0 — все сразу.")] [SerializeField] private Vector2 spawnDelay = new(0.15f, 0.4f); [Header("Spawn Area")] [SerializeField] private BoxCollider2D spawnArea; [SerializeField] private Vector2 areaCenter; [SerializeField] private Vector2 areaSize = new(8f, 4f); [Header("Random Scale & Rotation")] [Tooltip("Минимальный и максимальный uniform-масштаб при спауне.")] [SerializeField] private Vector2 scaleRange = new(0.8f, 1.2f); [Tooltip("Минимальный и максимальный угол поворота по Z (градусы) при спауне.")] [SerializeField] private Vector2 rotationRange = new(0f, 0f); [Header("Player")] [SerializeField] private float minDistanceToPlayer = 1.5f; [SerializeField] private float maxDistanceToPlayer = 25f; private Transform _player; [Header("Prefabs")] [SerializeField] private SpawnEntry[] spawnEntries; [Header("Trigger Entries")] [Tooltip("Именованные SpawnEntry для вызова из триггеров. Используйте SetForcedEntryByName(string) в UnityEvent.")] [SerializeField] private NamedSpawnEntry[] namedEntries; private readonly HashSet _activeCollectibles = new(); private readonly Dictionary _prefabByCollectible = new(); private readonly Dictionary _activeCountByPrefab = new(); private readonly List _refreshBuffer = new(); private GameObject _forcedPrefab; private SpawnEntry? _forcedEntry; private BoxCollider2D _overrideArea; private Coroutine _spawnCoroutine; private Coroutine _gradualReplaceCoroutine; private void Start() { ResolvePlayer(); if (GameManager.Instance != null) GameManager.Instance.PlayerSpawned += OnPlayerSpawned; SubscribeToScoreSystem(); MaintainFixedCount(); } private void OnEnable() { SubscribeToScoreSystem(); } private void OnDisable() { foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles) { if (collectible != null) collectible.Collected -= HandleCollected; } if (GameManager.Instance != null) GameManager.Instance.PlayerSpawned -= OnPlayerSpawned; UnsubscribeFromScoreSystem(); } #region Public API — смена типа спауна /// /// Мягко переключает спаунер на prefab (компонент). Старые объекты доживают. /// public void SetForcedPrefab(EnvironmentalCollectibleBase prefab) { ApplyForced(prefab != null ? prefab.gameObject : null, null, false); } /// /// Жёстко переключает на prefab (компонент). Все «чужие» объекты убираются сразу. /// public void SetForcedPrefabHard(EnvironmentalCollectibleBase prefab) { ApplyForced(prefab != null ? prefab.gameObject : null, null, true); } /// /// Мягко переключает спаунер на prefab (GameObject). /// Удобно вызывать из UnityEvent — просто перетащить prefab в инспекторе. /// public void SetForcedPrefabByObject(GameObject prefabObject) { ApplyForced(prefabObject, null, false); } /// /// Жёстко переключает на prefab (GameObject). Все «чужие» объекты убираются сразу. /// public void SetForcedPrefabByObjectHard(GameObject prefabObject) { ApplyForced(prefabObject, null, true); } /// /// Мягко переключает на prefab по индексу в массиве spawnEntries. /// public void SetForcedPrefabByIndex(int index) { if (!TryGetEntry(index, out SpawnEntry entry)) return; ApplyForced(entry.prefab.gameObject, entry, false); } /// /// Жёстко переключает на prefab по индексу — все «чужие» убираются сразу. /// public void SetForcedPrefabHardByIndex(int index) { if (!TryGetEntry(index, out SpawnEntry entry)) return; ApplyForced(entry.prefab.gameObject, entry, true); } /// /// Переключает спаунер на полностью кастомный SpawnEntry (из кода). /// public void SetForcedEntry(SpawnEntry entry, bool hardReplace = false) { if (entry.prefab == null) return; ApplyForced(entry.prefab.gameObject, entry, hardReplace); } /// /// Переключает спаунер по строковому ключу из массива namedEntries. /// Вызывайте из UnityEvent (EventTrigger) — Unity покажет поле ввода строки. /// public void SetForcedEntryByName(string key) { if (namedEntries == null || string.IsNullOrEmpty(key)) return; for (int i = 0; i < namedEntries.Length; i++) { if (string.Equals(namedEntries[i].key, key, StringComparison.OrdinalIgnoreCase)) { NamedSpawnEntry named = namedEntries[i]; if (named.entry.prefab == null) return; ApplyForced(named.entry.prefab.gameObject, named.entry, named.hardReplace); return; } } Debug.LogWarning($"[{nameof(EnvironmentalCollectibleSpawner)}] Named entry '{key}' not found.", this); } /// /// Мягко переключает тип спауна по ключу из namedEntries. /// Существующие объекты другого типа убираются постепенно (по одному с задержкой) /// и заменяются новыми. /// public void SetEntryByName(string key) { if (namedEntries == null || string.IsNullOrEmpty(key)) return; for (int i = 0; i < namedEntries.Length; i++) { if (string.Equals(namedEntries[i].key, key, StringComparison.OrdinalIgnoreCase)) { NamedSpawnEntry named = namedEntries[i]; if (named.entry.prefab == null) return; _forcedPrefab = named.entry.prefab.gameObject; _forcedEntry = named.entry; StartGradualReplace(); return; } } Debug.LogWarning($"[{nameof(EnvironmentalCollectibleSpawner)}] Named entry '{key}' not found.", this); } /// /// Возвращает спаунер в смешанный режим (все записи из spawnEntries). /// public void ClearForcedPrefab() { _forcedPrefab = null; _forcedEntry = null; MaintainFixedCount(); } /// /// Временно подменяет область спауна (например, из триггер-зоны). /// Передайте null чтобы вернуть оригинальную область. /// public void SetOverrideArea(BoxCollider2D area) { _overrideArea = area; } #endregion private void ApplyForced(GameObject prefabObj, SpawnEntry? entry, bool hardReplace) { _forcedPrefab = prefabObj; _forcedEntry = entry; RefreshActiveCollectibles(); MaintainFixedCount(); } private bool TryGetEntry(int index, out SpawnEntry entry) { entry = default; if (spawnEntries == null || index < 0 || index >= spawnEntries.Length) return false; entry = spawnEntries[index]; return entry.prefab != null; } private void MaintainFixedCount() { if (_spawnCoroutine != null) StopCoroutine(_spawnCoroutine); if (!gameObject.activeInHierarchy) return; _spawnCoroutine = StartCoroutine(SpawnRoutine()); } private IEnumerator SpawnRoutine() { int safety = fixedCount * Mathf.Max(1, maxSpawnAttempts); bool isFirst = true; while (_activeCollectibles.Count < fixedCount && safety > 0) { // Задержка перед каждым спауном (кроме первого — чтобы был хоть один сразу) if (!isFirst && spawnDelay.y > 0f) yield return new WaitForSeconds(UnityEngine.Random.Range(spawnDelay.x, spawnDelay.y)); isFirst = false; if (!TrySpawnOne()) break; safety--; } _spawnCoroutine = null; } private void StartGradualReplace() { if (_gradualReplaceCoroutine != null) StopCoroutine(_gradualReplaceCoroutine); if (!gameObject.activeInHierarchy) return; _gradualReplaceCoroutine = StartCoroutine(GradualReplaceRoutine()); } private IEnumerator GradualReplaceRoutine() { if (_forcedPrefab == null) { _gradualReplaceCoroutine = null; yield break; } string forcedName = _forcedPrefab.name; while (true) { EnvironmentalCollectibleBase toRemove = null; foreach (EnvironmentalCollectibleBase c in _activeCollectibles) { if (c == null) continue; if (_prefabByCollectible.TryGetValue(c, out GameObject p) && p.name != forcedName) { toRemove = c; break; } } if (toRemove == null) break; DespawnCollectible(toRemove); MaintainFixedCount(); if (spawnDelay.y > 0f) yield return new WaitForSeconds(UnityEngine.Random.Range(spawnDelay.x, spawnDelay.y)); else yield return null; } _gradualReplaceCoroutine = null; } private bool TrySpawnOne() { ResolvePlayer(); if (!TryPickSpawnEntry(out SpawnEntry entry)) return false; if (!TryFindSpawnPosition(out Vector3 spawnPosition)) return false; GameObject prefab = entry.prefab.gameObject; GameObject instance = Instantiate(prefab, spawnPosition, Quaternion.identity, transform); if (instance == null) return false; // Случайный масштаб и поворот float scale = UnityEngine.Random.Range(scaleRange.x, scaleRange.y); instance.transform.localScale = Vector3.one * scale; float angle = UnityEngine.Random.Range(rotationRange.x, rotationRange.y); instance.transform.rotation = Quaternion.Euler(0f, 0f, angle); EnvironmentalCollectibleBase collectible = instance.GetComponent(); if (collectible == null) { Debug.LogError($"[{nameof(EnvironmentalCollectibleSpawner)}] Prefab {prefab.name} is missing EnvironmentalCollectibleBase.", prefab); Destroy(instance); return false; } collectible.Collected -= HandleCollected; collectible.Collected += HandleCollected; collectible.InitializeForSpawn( spawnPosition, entry.overridePoints ? entry.pointsOverride : null, entry.overrideLifetime ? entry.lifetimeOverride : null); _activeCollectibles.Add(collectible); _prefabByCollectible[collectible] = prefab; if (_activeCountByPrefab.TryGetValue(prefab, out int currentCount)) _activeCountByPrefab[prefab] = currentCount + 1; else _activeCountByPrefab[prefab] = 1; return true; } private void HandleCollected(EnvironmentalCollectibleBase collectible) { if (collectible == null) return; collectible.Collected -= HandleCollected; if (_prefabByCollectible.TryGetValue(collectible, out GameObject prefab) && _activeCountByPrefab.TryGetValue(prefab, out int currentCount)) { _activeCountByPrefab[prefab] = Mathf.Max(0, currentCount - 1); } _activeCollectibles.Remove(collectible); _prefabByCollectible.Remove(collectible); if (HasReachedSharedCollectibleLimit()) { DespawnCollectiblesBlockedBySharedLimit(); MaintainFixedCount(); return; } MaintainFixedCount(); } private bool TryPickSpawnEntry(out SpawnEntry entry) { // Если задан полный SpawnEntry — используем его напрямую if (_forcedEntry.HasValue) { SpawnEntry forced = _forcedEntry.Value; if (forced.prefab == null) { entry = default; return false; } if (IsBlockedBySharedCollectibleLimit(forced.prefab)) { entry = default; return false; } // Проверяем лимит if (forced.limit > 0 && _activeCountByPrefab.TryGetValue(forced.prefab.gameObject, out int fc) && fc >= forced.limit) { entry = default; return false; } entry = forced; return true; } entry = default; if (spawnEntries == null || spawnEntries.Length == 0) return false; string forcedName = _forcedPrefab != null ? _forcedPrefab.name : null; int totalWeight = 0; for (int i = 0; i < spawnEntries.Length; i++) { SpawnEntry current = spawnEntries[i]; if (current.prefab == null || current.weight <= 0) continue; GameObject prefab = current.prefab.gameObject; if (forcedName != null && prefab.name != forcedName) continue; if (IsBlockedBySharedCollectibleLimit(current.prefab)) continue; if (current.limit > 0 && _activeCountByPrefab.TryGetValue(prefab, out int activeCount) && activeCount >= current.limit) { continue; } totalWeight += current.weight; } // Фильтрация по _forcedPrefab — если prefab не найден в spawnEntries, создаём временный entry if (totalWeight <= 0 && _forcedPrefab != null) { EnvironmentalCollectibleBase comp = _forcedPrefab.GetComponent(); if (comp != null && !IsBlockedBySharedCollectibleLimit(comp)) { entry = new SpawnEntry { prefab = comp, weight = 1 }; return true; } } if (totalWeight <= 0) return false; int pick = UnityEngine.Random.Range(0, totalWeight); for (int i = 0; i < spawnEntries.Length; i++) { SpawnEntry current = spawnEntries[i]; if (current.prefab == null || current.weight <= 0) continue; GameObject prefab = current.prefab.gameObject; if (forcedName != null && prefab.name != forcedName) continue; if (IsBlockedBySharedCollectibleLimit(current.prefab)) continue; if (current.limit > 0 && _activeCountByPrefab.TryGetValue(prefab, out int activeCount) && activeCount >= current.limit) { continue; } pick -= current.weight; if (pick < 0) { entry = current; return true; } } return false; } private bool TryFindSpawnPosition(out Vector3 spawnPosition) { Bounds bounds = GetSpawnBounds(); for (int i = 0; i < maxSpawnAttempts; i++) { float x = UnityEngine.Random.Range(bounds.min.x, bounds.max.x); float y = UnityEngine.Random.Range(bounds.min.y, bounds.max.y); spawnPosition = new Vector3(x, y, 0f); if (_player == null) return true; float distance = Vector2.Distance(_player.position, spawnPosition); if (distance < minDistanceToPlayer) continue; if (maxDistanceToPlayer > 0f && distance > maxDistanceToPlayer) continue; return true; } spawnPosition = transform.position; return false; } private Bounds GetSpawnBounds() { if (_overrideArea != null) return _overrideArea.bounds; if (spawnArea != null) return spawnArea.bounds; Vector3 center = transform.TransformPoint(areaCenter); Vector3 size = new(areaSize.x, areaSize.y, 0f); return new Bounds(center, size); } private void ResolvePlayer() { if (GameManager.Instance?.PlayerObj != null) { _player = GameManager.Instance.PlayerObj.transform; return; } // Fallback на случай, если GameManager ещё не проинициализирован GameObject playerObject = GameObject.FindGameObjectWithTag("Player"); if (playerObject != null) _player = playerObject.transform; } private void OnPlayerSpawned(GameObject playerObj) { _player = playerObj != null ? playerObj.transform : null; } private void RefreshActiveCollectibles() { if (_forcedPrefab == null) return; string forcedName = _forcedPrefab.name; _refreshBuffer.Clear(); foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles) { if (collectible == null) continue; if (_prefabByCollectible.TryGetValue(collectible, out GameObject prefab) && prefab.name != forcedName) _refreshBuffer.Add(collectible); } for (int i = 0; i < _refreshBuffer.Count; i++) { DespawnCollectible(_refreshBuffer[i]); } _refreshBuffer.Clear(); } private void DespawnCollectible(EnvironmentalCollectibleBase collectible) { if (collectible == null) return; collectible.Collected -= HandleCollected; if (_prefabByCollectible.TryGetValue(collectible, out GameObject prefab)) { if (_activeCountByPrefab.TryGetValue(prefab, out int currentCount)) _activeCountByPrefab[prefab] = Mathf.Max(0, currentCount - 1); } _activeCollectibles.Remove(collectible); _prefabByCollectible.Remove(collectible); Destroy(collectible.gameObject); } private void SubscribeToScoreSystem() { if (ScoreSystem.Instance != null) { ScoreSystem.Instance.SharedEnvironmentalCollectiblesChanged -= OnSharedEnvironmentalCollectiblesChanged; ScoreSystem.Instance.SharedEnvironmentalCollectiblesChanged += OnSharedEnvironmentalCollectiblesChanged; } } private void UnsubscribeFromScoreSystem() { if (ScoreSystem.Instance != null) { ScoreSystem.Instance.SharedEnvironmentalCollectiblesChanged -= OnSharedEnvironmentalCollectiblesChanged; } } private void OnSharedEnvironmentalCollectiblesChanged(int collected, int maxCount) { if (maxCount > 0 && collected >= maxCount) { DespawnCollectiblesBlockedBySharedLimit(); } MaintainFixedCount(); } private void DespawnCollectiblesBlockedBySharedLimit() { _refreshBuffer.Clear(); foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles) { if (collectible != null && IsBlockedBySharedCollectibleLimit(collectible)) { _refreshBuffer.Add(collectible); } } for (int i = 0; i < _refreshBuffer.Count; i++) { DespawnCollectible(_refreshBuffer[i]); } _refreshBuffer.Clear(); } private static bool HasReachedSharedCollectibleLimit() { return ScoreSystem.Instance != null && ScoreSystem.Instance.HasReachedSharedEnvironmentalCollectibleLimit; } private static bool IsBlockedBySharedCollectibleLimit(EnvironmentalCollectibleBase collectible) { return collectible != null && collectible.CountsTowardsSharedCollectionLimit && HasReachedSharedCollectibleLimit(); } private void OnDrawGizmosSelected() { Bounds bounds = GetSpawnBounds(); Gizmos.color = new Color(0.15f, 0.8f, 0.25f, 0.35f); Gizmos.DrawCube(bounds.center, bounds.size); Gizmos.color = new Color(0.15f, 0.8f, 0.25f, 1f); Gizmos.DrawWireCube(bounds.center, bounds.size); } } }