using UnityEngine; using UnityEngine.AI; using System.Collections; using System.Collections.Generic; /// /// Spawn zone classification for cash bags. /// Contested zones are between team vaults (center of map) — forces team encounters. /// public enum SpawnZone { Safe, // Near a vault — low risk, low reward Neutral, // Between center and vaults — medium risk Contested // Center of map between vaults — high risk, high reward } /// /// CashBagSpawner — Wave-based spawning of cash bags with object pooling. /// Uses zone-weighted spawning to force team encounters in contested areas. /// public class CashBagSpawner : MonoBehaviour { [Header("Cash Bag Settings")] [SerializeField] private GameObject cashBagPrefab; [SerializeField] private float cashPerBag = 100f; [Header("Spawn Limits")] [SerializeField] private int maxBagCount = 15; [SerializeField] private float initialSpawnPercent = 0.60f; // 60% on start [Header("Wave Settings")] [SerializeField] private float waveInterval = 30f; [SerializeField] private int bagsPerWave = 3; [Header("Spawn Area")] [SerializeField] private Transform[] spawnPoints; [SerializeField] private bool useRandomSpawning = false; [SerializeField] private Vector3 spawnAreaCenter = Vector3.zero; [SerializeField] private Vector3 spawnAreaSize = new Vector3(20f, 0f, 20f); [SerializeField] private float minSpawnDistance = 5f; // Min distance between bags [SerializeField] private float playerAvoidDistance = 5f; // Distance from players [SerializeField] private float vaultAvoidDistance = 6f; // Distance from vaults [Header("Zone-Weighted Spawning")] [Tooltip("Chance to spawn in contested center zone (forces team encounters)")] [SerializeField, Range(0f, 1f)] private float contestedZoneWeight = 0.60f; [Tooltip("Chance to spawn in neutral middle zone")] [SerializeField, Range(0f, 1f)] private float neutralZoneWeight = 0.30f; [Tooltip("Chance to spawn in safe zone near vaults")] [SerializeField, Range(0f, 1f)] private float safeZoneWeight = 0.10f; [Tooltip("Value multiplier for contested zone bags")] [SerializeField] private float contestedValueMultiplier = 2.0f; [Tooltip("Value multiplier for neutral zone bags")] [SerializeField] private float neutralValueMultiplier = 1.0f; [Tooltip("Value multiplier for safe zone bags")] [SerializeField] private float safeValueMultiplier = 0.5f; [Header("Debug")] [SerializeField] private bool showSpawnGizmos = true; // ─── Runtime ───────────────────────────────────────────── private ObjectPool _pool; private readonly List _activeBags = new List(); private readonly HashSet _occupiedSpawnPoints = new HashSet(); // tracks which spawn points have an active bag private readonly Dictionary _spawnPointCooldowns = new Dictionary(); // prevents same-point reuse private const float SPAWN_POINT_COOLDOWN = 8f; // seconds before a freed point can be reused private int _totalSpawned; private bool _isSpawning; private Coroutine _waveCoroutine; // ─── Zone Classification ───────────────────────────────── private readonly List _contestedPoints = new List(); private readonly List _neutralPoints = new List(); private readonly List _safePoints = new List(); private bool _zonesClassified; private Vector3 _mapCenter; // midpoint between vaults — the contested zone center public int ActiveBagCount => _activeBags.Count; public int TotalSpawned => _totalSpawned; // ─── Lifecycle ─────────────────────────────────────────── private void Start() { if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected) { enabled = false; return; } // Find spawn points by tag if none assigned if ((spawnPoints == null || spawnPoints.Length == 0) && !useRandomSpawning) { GameObject[] spawnPointObjects = GameObject.FindGameObjectsWithTag("CashBagSpawn"); if (spawnPointObjects.Length > 0) { spawnPoints = new Transform[spawnPointObjects.Length]; for (int i = 0; i < spawnPointObjects.Length; i++) spawnPoints[i] = spawnPointObjects[i].transform; } else { useRandomSpawning = true; } } // Initialize object pool (pre-warm up to maxBagCount and parent under this transform) if (cashBagPrefab != null) { _pool = new ObjectPool(cashBagPrefab, initialSize: maxBagCount, maxSize: maxBagCount, parent: transform); } // Classify spawn points into zones for weighted spawning ClassifySpawnPoints(); StartSpawning(); } private void OnEnable() { GameEvents.OnBagDespawned += OnBagDespawned; // NOTE: We intentionally do NOT subscribe to OnBagCollected here. // A bag being picked up is NOT the same as a bag leaving the field — // the carrier is still running around with it. We only remove from // _activeBags when the bag is truly gone (despawned/deposited). } private void OnDisable() { GameEvents.OnBagDespawned -= OnBagDespawned; } // ─── Spawning ──────────────────────────────────────────── public void StartSpawning() { if (_isSpawning) return; _isSpawning = true; int initialCount = Mathf.RoundToInt(maxBagCount * initialSpawnPercent); for (int i = 0; i < initialCount; i++) SpawnBag(); GameEvents.FireActiveBagCountChanged(_activeBags.Count); GameEvents.FirePopupMessage("CASH DROPPED!", 1.5f); // Start wave timer _waveCoroutine = StartCoroutine(WaveSpawnRoutine()); } private IEnumerator WaveSpawnRoutine() { while (_isSpawning) { yield return new WaitForSeconds(waveInterval); if (!_isSpawning) break; // Clean up null references _activeBags.RemoveAll(b => b == null || !b.gameObject.activeInHierarchy); int canSpawn = maxBagCount - _activeBags.Count; int toSpawn = Mathf.Min(bagsPerWave, canSpawn); if (toSpawn <= 0) continue; for (int i = 0; i < toSpawn; i++) { SpawnBag(); yield return new WaitForSeconds(0.15f); // Stagger } GameEvents.FireActiveBagCountChanged(_activeBags.Count); GameEvents.FirePopupMessage($"+{toSpawn} CASH!", 1f); } } private void SpawnBag() { if (cashBagPrefab == null) return; // Determine spawn zone for value multiplier SpawnZone zone = _zonesClassified ? PickWeightedZone() : SpawnZone.Neutral; Vector3 spawnPos = _zonesClassified ? GetZoneSpawnPosition(zone) : GetValidSpawnPosition(); if (spawnPos == Vector3.zero && !useRandomSpawning) return; // Snap to NavMesh if (NavMesh.SamplePosition(spawnPos, out NavMeshHit navHit, 5f, NavMesh.AllAreas)) spawnPos = navHit.position + Vector3.up * 0.7f; // Use pool if available, fallback to Instantiate GameObject bagObj; if (_pool != null) { bagObj = _pool.Get(spawnPos, Quaternion.identity); if (bagObj == null) { bagObj = Instantiate(cashBagPrefab, spawnPos, Quaternion.identity); } } else { bagObj = Instantiate(cashBagPrefab, spawnPos, Quaternion.identity); } CashBag bag = bagObj.GetComponent(); if (bag == null) bag = bagObj.AddComponent(); // Apply zone-based value multiplier — contested bags are worth MORE float zoneMultiplier = GetZoneValueMultiplier(zone); bag.SetCashValue(cashPerBag * zoneMultiplier); _activeBags.Add(bag); _totalSpawned++; // Ensure the bag gets a minimap marker if (bag.GetComponent() == null) bag.gameObject.AddComponent(); GameEvents.FireBagSpawned(bag); } // ─── Zone Classification ───────────────────────────────── /// /// Classifies each spawn point as Contested, Neutral, or Safe based on /// its distance from the map center (midpoint between team vaults). /// This ensures bags spawn more often in areas where teams will clash. /// private void ClassifySpawnPoints() { if (spawnPoints == null || spawnPoints.Length == 0 || useRandomSpawning) { _zonesClassified = false; return; } _contestedPoints.Clear(); _neutralPoints.Clear(); _safePoints.Clear(); // Find vault positions to determine map layout var vaults = EntityRegistry.GetAll(); if (vaults == null || vaults.Count < 2) { // Can't classify without vaults — treat all as neutral for (int i = 0; i < spawnPoints.Length; i++) if (spawnPoints[i] != null) _neutralPoints.Add(i); _zonesClassified = true; _mapCenter = spawnAreaCenter; DevLog.Log("[CashBagSpawner] <2 vaults found, all spawn points classified as Neutral."); return; } // Calculate map center (midpoint between vaults — XZ plane) Vector3 v0 = vaults[0].transform.position; Vector3 v1 = vaults[1].transform.position; _mapCenter = (v0 + v1) * 0.5f; _mapCenter.y = 0f; // Normalize to ground // Find the maximum distance from center to any spawn point (for normalization) float maxDist = 0f; for (int i = 0; i < spawnPoints.Length; i++) { if (spawnPoints[i] == null) continue; Vector3 flatPos = spawnPoints[i].position; flatPos.y = 0f; float d = Vector3.Distance(flatPos, _mapCenter); if (d > maxDist) maxDist = d; } if (maxDist < 1f) maxDist = 1f; // Prevent division by zero // Classify: inner 40% = contested, 40-70% = neutral, outer 70%+ = safe for (int i = 0; i < spawnPoints.Length; i++) { if (spawnPoints[i] == null) continue; Vector3 flatPos = spawnPoints[i].position; flatPos.y = 0f; float normalizedDist = Vector3.Distance(flatPos, _mapCenter) / maxDist; if (normalizedDist < 0.40f) _contestedPoints.Add(i); else if (normalizedDist < 0.70f) _neutralPoints.Add(i); else _safePoints.Add(i); } _zonesClassified = true; DevLog.Log($"[CashBagSpawner] Zone classification: " + $"Contested={_contestedPoints.Count}, " + $"Neutral={_neutralPoints.Count}, " + $"Safe={_safePoints.Count}"); } /// /// Selects a spawn zone based on weighted random distribution. /// 60% contested, 30% neutral, 10% safe by default. /// Falls back to any available zone if the chosen zone is empty. /// private SpawnZone PickWeightedZone() { float r = Random.value; float cumulative = 0f; cumulative += contestedZoneWeight; if (r < cumulative && _contestedPoints.Count > 0) return SpawnZone.Contested; cumulative += neutralZoneWeight; if (r < cumulative && _neutralPoints.Count > 0) return SpawnZone.Neutral; if (_safePoints.Count > 0) return SpawnZone.Safe; // Fallback: pick whichever zone has points if (_contestedPoints.Count > 0) return SpawnZone.Contested; if (_neutralPoints.Count > 0) return SpawnZone.Neutral; if (_safePoints.Count > 0) return SpawnZone.Safe; return SpawnZone.Neutral; // absolute fallback } /// Returns the value multiplier for a given spawn zone. private float GetZoneValueMultiplier(SpawnZone zone) { switch (zone) { case SpawnZone.Contested: return contestedValueMultiplier; case SpawnZone.Neutral: return neutralValueMultiplier; case SpawnZone.Safe: return safeValueMultiplier; default: return 1f; } } /// Returns the spawn point list for a given zone. private List GetZonePoints(SpawnZone zone) { switch (zone) { case SpawnZone.Contested: return _contestedPoints; case SpawnZone.Neutral: return _neutralPoints; case SpawnZone.Safe: return _safePoints; default: return _neutralPoints; } } // ─── Position Validation ───────────────────────────────── private Vector3 GetValidSpawnPosition() { return useRandomSpawning ? GetRandomSpawnPosition() : GetSpawnPointPosition(); } /// /// Zone-aware spawn position selection. Picks from the specified zone's spawn points, /// falling back to other zones if all points in the chosen zone are occupied. /// private Vector3 GetZoneSpawnPosition(SpawnZone preferredZone) { if (!_zonesClassified || useRandomSpawning) return GetValidSpawnPosition(); // Try preferred zone first Vector3 pos = GetSpawnPointFromZone(GetZonePoints(preferredZone)); if (pos != Vector3.zero) return pos; // Fallback: try other zones (contested > neutral > safe priority) if (preferredZone != SpawnZone.Contested) { pos = GetSpawnPointFromZone(_contestedPoints); if (pos != Vector3.zero) return pos; } if (preferredZone != SpawnZone.Neutral) { pos = GetSpawnPointFromZone(_neutralPoints); if (pos != Vector3.zero) return pos; } if (preferredZone != SpawnZone.Safe) { pos = GetSpawnPointFromZone(_safePoints); if (pos != Vector3.zero) return pos; } // Absolute fallback: original unfiltered method return GetSpawnPointPosition(); } /// /// Picks a valid spawn position from a specific set of spawn point indices. /// private Vector3 GetSpawnPointFromZone(List zoneIndices) { if (zoneIndices == null || zoneIndices.Count == 0) return Vector3.zero; // Clean up expired cooldowns List expired = null; foreach (var kvp in _spawnPointCooldowns) { if (Time.time >= kvp.Value) { if (expired == null) expired = new List(4); expired.Add(kvp.Key); } } if (expired != null) foreach (int idx in expired) _spawnPointCooldowns.Remove(idx); // Shuffle to randomize within zone List shuffled = new List(zoneIndices); for (int i = shuffled.Count - 1; i > 0; i--) { int j = Random.Range(0, i + 1); (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); } // Pick first unoccupied, valid, non-cooldown point foreach (int idx in shuffled) { if (idx < 0 || idx >= spawnPoints.Length || spawnPoints[idx] == null) continue; if (_occupiedSpawnPoints.Contains(idx)) continue; if (_spawnPointCooldowns.ContainsKey(idx)) continue; if (IsPositionValid(spawnPoints[idx].position)) { _occupiedSpawnPoints.Add(idx); return spawnPoints[idx].position; } } // Fallback: allow cooldown points in this zone foreach (int idx in shuffled) { if (idx < 0 || idx >= spawnPoints.Length || spawnPoints[idx] == null) continue; if (_occupiedSpawnPoints.Contains(idx)) continue; if (IsPositionValid(spawnPoints[idx].position)) { _occupiedSpawnPoints.Add(idx); return spawnPoints[idx].position; } } return Vector3.zero; } private Vector3 GetSpawnPointPosition() { if (spawnPoints == null || spawnPoints.Length == 0) return Vector3.zero; // Clean up expired cooldowns List expired = null; foreach (var kvp in _spawnPointCooldowns) { if (Time.time >= kvp.Value) { if (expired == null) expired = new List(4); expired.Add(kvp.Key); } } if (expired != null) foreach (int idx in expired) _spawnPointCooldowns.Remove(idx); // Build a list of indices, then shuffle to randomize List indices = new List(spawnPoints.Length); for (int i = 0; i < spawnPoints.Length; i++) indices.Add(i); for (int i = indices.Count - 1; i > 0; i--) { int j = Random.Range(0, i + 1); (indices[i], indices[j]) = (indices[j], indices[i]); } // Pick the first unoccupied, valid, non-cooldown spawn point foreach (int idx in indices) { if (spawnPoints[idx] == null) continue; if (_occupiedSpawnPoints.Contains(idx)) continue; // skip occupied if (_spawnPointCooldowns.ContainsKey(idx)) continue; // skip recently used if (IsPositionValid(spawnPoints[idx].position)) { _occupiedSpawnPoints.Add(idx); return spawnPoints[idx].position; } } // Fallback: allow cooldown points if nothing else available foreach (int idx in indices) { if (spawnPoints[idx] == null) continue; if (_occupiedSpawnPoints.Contains(idx)) continue; if (IsPositionValid(spawnPoints[idx].position)) { _occupiedSpawnPoints.Add(idx); return spawnPoints[idx].position; } } return Vector3.zero; } private Vector3 GetRandomSpawnPosition() { for (int i = 0; i < 30; i++) { Vector3 randomPos = spawnAreaCenter + new Vector3( Random.Range(-spawnAreaSize.x / 2, spawnAreaSize.x / 2), 0.5f, Random.Range(-spawnAreaSize.z / 2, spawnAreaSize.z / 2) ); if (IsPositionValid(randomPos)) return randomPos; } return spawnAreaCenter + new Vector3(Random.Range(-5f, 5f), 0.5f, Random.Range(-5f, 5f)); } private bool IsPositionValid(Vector3 pos) { // Check distance from active bags (using EntityRegistry — zero alloc) var bags = EntityRegistry.GetAll(); for (int i = 0; i < bags.Count; i++) { if (bags[i] != null && Vector3.Distance(pos, bags[i].transform.position) < minSpawnDistance) return false; } // Check distance from all characters (using TeamMember static registry) foreach (TeamMember member in TeamMember.AllMembers) { if (member != null && Vector3.Distance(pos, member.transform.position) < playerAvoidDistance) return false; } // Check distance from vaults var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] != null && Vector3.Distance(pos, vaults[i].transform.position) < vaultAvoidDistance) return false; } return true; } // ─── Event Handlers ────────────────────────────────────── private void OnBagDespawned(CashBag bag) { _activeBags.Remove(bag); ReleaseSpawnPoint(bag); GameEvents.FireActiveBagCountChanged(_activeBags.Count); // Immediately spawn a replacement at a DIFFERENT position // so the field always has bags and they rotate across spawn points if (_isSpawning && _activeBags.Count < maxBagCount) { StartCoroutine(DelayedRespawn(0.5f)); } } /// Respawn a bag after a short delay at a different position. private IEnumerator DelayedRespawn(float delay) { yield return new WaitForSeconds(delay); if (!_isSpawning) yield break; // Clean up null references _activeBags.RemoveAll(b => b == null || !b.gameObject.activeInHierarchy); if (_activeBags.Count < maxBagCount) { SpawnBag(); GameEvents.FireActiveBagCountChanged(_activeBags.Count); } } /// Called when a bag is collected (legacy API for compatibility). public void OnBagCollected(CashBag bag) { // Legacy API: only remove if bag is truly gone (inactive). // Don't remove carried bags — they're still in play. if (bag != null && !bag.gameObject.activeInHierarchy) { if (_activeBags.Contains(bag)) _activeBags.Remove(bag); ReleaseSpawnPoint(bag); } } /// /// Free the spawn point closest to a removed bag so it can be reused. /// Applies a cooldown so the same point isn't immediately reused. /// private void ReleaseSpawnPoint(CashBag bag) { if (bag == null || spawnPoints == null) return; float bestDist = float.MaxValue; int bestIdx = -1; foreach (int idx in _occupiedSpawnPoints) { if (idx < 0 || idx >= spawnPoints.Length || spawnPoints[idx] == null) continue; float dist = Vector3.Distance(bag.SpawnPosition, spawnPoints[idx].position); if (dist < bestDist) { bestDist = dist; bestIdx = idx; } } // Only release if the bag was reasonably close to the spawn point if (bestIdx >= 0 && bestDist < minSpawnDistance * 2f) { _occupiedSpawnPoints.Remove(bestIdx); // Add cooldown so next spawn goes to a DIFFERENT position _spawnPointCooldowns[bestIdx] = Time.time + SPAWN_POINT_COOLDOWN; } } public void StopSpawning() { _isSpawning = false; if (_waveCoroutine != null) StopCoroutine(_waveCoroutine); } private void OnDestroy() { StopSpawning(); } private void OnDrawGizmos() { if (!showSpawnGizmos) return; Gizmos.color = new Color(0, 1, 0, 0.3f); Gizmos.DrawCube(spawnAreaCenter, spawnAreaSize); Gizmos.color = Color.green; Gizmos.DrawWireCube(spawnAreaCenter, spawnAreaSize); if (spawnPoints != null) { for (int i = 0; i < spawnPoints.Length; i++) { if (spawnPoints[i] == null) continue; // Color by zone classification if (_zonesClassified) { if (_contestedPoints.Contains(i)) Gizmos.color = Color.red; // Contested = RED (danger zone) else if (_neutralPoints.Contains(i)) Gizmos.color = Color.yellow; // Neutral = YELLOW else if (_safePoints.Contains(i)) Gizmos.color = Color.green; // Safe = GREEN else Gizmos.color = Color.white; } else { Gizmos.color = Color.yellow; } Gizmos.DrawSphere(spawnPoints[i].position, 0.5f); } // Draw map center (contested zone center) if (_zonesClassified) { Gizmos.color = new Color(1f, 0f, 0f, 0.5f); Gizmos.DrawWireSphere(_mapCenter, 3f); } } } }