Files
2026-06-26 22:41:39 +05:30

686 lines
25 KiB
C#

using UnityEngine;
using UnityEngine.AI;
using System.Collections;
using System.Collections.Generic;
/// <summary>
/// Spawn zone classification for cash bags.
/// Contested zones are between team vaults (center of map) — forces team encounters.
/// </summary>
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
}
/// <summary>
/// CashBagSpawner — Wave-based spawning of cash bags with object pooling.
/// Uses zone-weighted spawning to force team encounters in contested areas.
/// </summary>
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<CashMag> _activeBags = new List<CashMag>();
private readonly HashSet<int> _occupiedSpawnPoints = new HashSet<int>(); // tracks which spawn points have an active bag
private readonly Dictionary<int, float> _spawnPointCooldowns = new Dictionary<int, float>(); // 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<int> _contestedPoints = new List<int>();
private readonly List<int> _neutralPoints = new List<int>();
private readonly List<int> _safePoints = new List<int>();
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);
}
CashMag bag = bagObj.GetComponent<CashMag>();
if (bag == null) bag = bagObj.AddComponent<CashMag>();
// 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<MinimapObjectMarker>() == null)
bag.gameObject.AddComponent<MinimapObjectMarker>();
GameEvents.FireBagSpawned(bag);
}
// ─── Zone Classification ─────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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<TeamVault>.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}");
}
/// <summary>
/// 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.
/// </summary>
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
}
/// <summary>Returns the value multiplier for a given spawn zone.</summary>
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;
}
}
/// <summary>Returns the spawn point list for a given zone.</summary>
private List<int> 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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// Picks a valid spawn position from a specific set of spawn point indices.
/// </summary>
private Vector3 GetSpawnPointFromZone(List<int> zoneIndices)
{
if (zoneIndices == null || zoneIndices.Count == 0)
return Vector3.zero;
// Clean up expired cooldowns
List<int> expired = null;
foreach (var kvp in _spawnPointCooldowns)
{
if (Time.time >= kvp.Value)
{
if (expired == null) expired = new List<int>(4);
expired.Add(kvp.Key);
}
}
if (expired != null)
foreach (int idx in expired)
_spawnPointCooldowns.Remove(idx);
// Shuffle to randomize within zone
List<int> shuffled = new List<int>(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<int> expired = null;
foreach (var kvp in _spawnPointCooldowns)
{
if (Time.time >= kvp.Value)
{
if (expired == null) expired = new List<int>(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<int> indices = new List<int>(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<CashMag>.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<TeamVault>.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(CashMag 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));
}
}
/// <summary>Respawn a bag after a short delay at a different position.</summary>
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);
}
}
/// <summary>Called when a bag is collected (legacy API for compatibility).</summary>
public void OnBagCollected(CashMag 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);
}
}
/// <summary>
/// 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.
/// </summary>
private void ReleaseSpawnPoint(CashMag 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);
}
}
}
}