using UnityEngine; using Core; using Core.Settings; using Core.Lifecycle; using AppleHillsCamera; using System.Text; using System.Collections.Generic; namespace Minigames.BirdPooper { /// /// Spawns obstacles at regular intervals for Bird Pooper minigame. /// Uses Transform references for spawn and despawn positions instead of hardcoded values. /// All obstacles are spawned at Y = 0 (prefabs should be authored accordingly). /// Supports dynamic difficulty pools that unlock over time. /// public class ObstacleSpawner : ManagedBehaviour { [Header("Spawn Configuration")] [Tooltip("Transform marking where obstacles spawn (off-screen right)")] [SerializeField] private Transform spawnPoint; [Tooltip("Transform marking where obstacles despawn (off-screen left)")] [SerializeField] private Transform despawnPoint; [Tooltip("Optional parent transform for spawned obstacles (for scene organization)")] [SerializeField] private Transform obstacleContainer; [Header("EdgeAnchor References")] [Tooltip("ScreenReferenceMarker to pass to spawned obstacles")] [SerializeField] private ScreenReferenceMarker referenceMarker; [Tooltip("CameraScreenAdapter to pass to spawned obstacles")] [SerializeField] private CameraScreenAdapter cameraAdapter; private IBirdPooperSettings _settings; private ObstacleSpawnConfig _spawnConfig; private float _spawnTimer; private bool _isSpawning; private float _currentSpawnInterval = 1f; // Difficulty tracking private float _elapsedTime; // Master obstacle list for recency tracking private List _allObstacles; private Dictionary _obstacleToGlobalIndex; private float[] _lastUsedTimes; /// /// Initializes the obstacle spawner by loading settings, validating references, and building obstacle pools. /// Should be called once before spawning begins. /// private void Initialize() { // Load settings _settings = GameManager.GetSettingsObject(); if (_settings == null) { Debug.LogError("[ObstacleSpawner] BirdPooperSettings not found! Cannot initialize."); return; } _spawnConfig = _settings.ObstacleSpawnConfiguration; if (_spawnConfig == null) { Debug.LogError("[ObstacleSpawner] ObstacleSpawnConfiguration not found in settings! Cannot initialize."); return; } // Validate spawn configuration _spawnConfig.Validate(); // Validate scene references if (spawnPoint == null) { Debug.LogError("[ObstacleSpawner] Spawn Point not assigned! Please assign a Transform in the Inspector."); } if (despawnPoint == null) { Debug.LogError("[ObstacleSpawner] Despawn Point not assigned! Please assign a Transform in the Inspector."); } if (referenceMarker == null) { Debug.LogError("[ObstacleSpawner] ScreenReferenceMarker not assigned! Obstacles need this for EdgeAnchor positioning."); } if (cameraAdapter == null) { Debug.LogWarning("[ObstacleSpawner] CameraScreenAdapter not assigned. EdgeAnchor will attempt to auto-find camera."); } // Build master obstacle list from all pools BuildMasterObstacleList(); Debug.Log("[ObstacleSpawner] Initialized successfully with pool-based difficulty scaling"); } /// /// Builds a master list of all obstacles across all pools and creates index mappings for recency tracking. /// private void BuildMasterObstacleList() { _allObstacles = new List(); _obstacleToGlobalIndex = new Dictionary(); if (_spawnConfig.obstaclePools == null || _spawnConfig.obstaclePools.Length == 0) { Debug.LogError("[ObstacleSpawner] No obstacle pools defined in configuration!"); return; } int globalIndex = 0; for (int poolIdx = 0; poolIdx < _spawnConfig.obstaclePools.Length; poolIdx++) { ObstaclePool pool = _spawnConfig.obstaclePools[poolIdx]; if (pool == null || pool.obstacles == null || pool.obstacles.Length == 0) { Debug.LogWarning($"[ObstacleSpawner] Pool[{poolIdx}] is empty or null!"); continue; } foreach (GameObject prefab in pool.obstacles) { if (prefab == null) { Debug.LogWarning($"[ObstacleSpawner] Null prefab found in pool[{poolIdx}]"); continue; } // Allow duplicates - same prefab can appear in multiple pools if (!_obstacleToGlobalIndex.ContainsKey(prefab)) { _obstacleToGlobalIndex[prefab] = globalIndex; _allObstacles.Add(prefab); globalIndex++; } } } // Initialize recency tracking int totalObstacles = _allObstacles.Count; _lastUsedTimes = new float[totalObstacles]; float initTime = Time.time - _spawnConfig.recentDecayDuration - 1f; for (int i = 0; i < totalObstacles; i++) { _lastUsedTimes[i] = initTime; } // Log pool configuration StringBuilder sb = new StringBuilder(); sb.AppendLine($"[ObstacleSpawner] Loaded {_spawnConfig.obstaclePools.Length} obstacle pools with {totalObstacles} unique obstacles:"); for (int i = 0; i < _spawnConfig.obstaclePools.Length; i++) { ObstaclePool pool = _spawnConfig.obstaclePools[i]; int obstacleCount = pool != null && pool.obstacles != null ? pool.obstacles.Length : 0; float unlockTime = (i == 0) ? 0f : (_spawnConfig.poolUnlockTimes != null && i - 1 < _spawnConfig.poolUnlockTimes.Length ? _spawnConfig.poolUnlockTimes[i - 1] : -1f); sb.AppendLine($" Pool[{i}]: {obstacleCount} obstacles, unlocks at {unlockTime}s"); } Debug.Log(sb.ToString()); } private void Update() { if (!_isSpawning || spawnPoint == null || _spawnConfig == null) return; _spawnTimer += Time.deltaTime; _elapsedTime += Time.deltaTime; if (_spawnTimer >= _currentSpawnInterval) { SpawnObstacle(); _spawnTimer = 0f; // Pick next interval based on difficulty ramp float t = Mathf.Clamp01(_elapsedTime / _spawnConfig.difficultyRampDuration); float difficulty = _spawnConfig.difficultyCurve.Evaluate(t); // 0..1 // Map difficulty to interval: difficulty 0 -> max interval (easy), 1 -> min interval (hard) float baseInterval = Mathf.Lerp(_spawnConfig.maxSpawnInterval, _spawnConfig.minSpawnInterval, difficulty); // Apply small jitter if (_spawnConfig.intervalJitter > 0f) { float jitter = Random.Range(-_spawnConfig.intervalJitter, _spawnConfig.intervalJitter); _currentSpawnInterval = Mathf.Max(0f, baseInterval * (1f + jitter)); } else { _currentSpawnInterval = baseInterval; } // Log the computed interval and difficulty for debugging Debug.Log($"[ObstacleSpawner] Next spawn interval: {_currentSpawnInterval:F2}s (difficulty {difficulty:F2})"); } } /// /// Spawn a random obstacle from currently unlocked pools at the spawn point position (Y = 0). /// Uses timestamp/decay weighting so prefabs used recently are less likely. /// private void SpawnObstacle() { if (_spawnConfig == null || _spawnConfig.obstaclePools == null || _spawnConfig.obstaclePools.Length == 0) { Debug.LogWarning("[ObstacleSpawner] No obstacle pools configured!"); return; } if (despawnPoint == null) { Debug.LogWarning("[ObstacleSpawner] Cannot spawn obstacle without despawn point reference!"); return; } // Determine which pools are currently unlocked based on elapsed time int unlockedPoolCount = 1; // Pool[0] is always unlocked if (_spawnConfig.poolUnlockTimes != null) { for (int i = 0; i < _spawnConfig.poolUnlockTimes.Length; i++) { if (_elapsedTime >= _spawnConfig.poolUnlockTimes[i]) { unlockedPoolCount = i + 2; // +2 because we're unlocking pool[i+1] } else { break; // Times should be in order, so stop when we hit a future unlock } } } // Clamp to available pools unlockedPoolCount = Mathf.Min(unlockedPoolCount, _spawnConfig.obstaclePools.Length); // Build list of available obstacles from unlocked pools List availableObstacles = new List(); List availableGlobalIndices = new List(); for (int poolIdx = 0; poolIdx < unlockedPoolCount; poolIdx++) { ObstaclePool pool = _spawnConfig.obstaclePools[poolIdx]; if (pool == null || pool.obstacles == null) continue; foreach (GameObject prefab in pool.obstacles) { if (prefab == null) continue; // Add to available list (duplicates allowed if same prefab is in multiple pools) availableObstacles.Add(prefab); // Look up global index for recency tracking if (_obstacleToGlobalIndex.TryGetValue(prefab, out int globalIdx)) { availableGlobalIndices.Add(globalIdx); } else { Debug.LogWarning($"[ObstacleSpawner] Prefab '{prefab.name}' not found in global index!"); availableGlobalIndices.Add(-1); // Invalid index } } } if (availableObstacles.Count == 0) { Debug.LogWarning($"[ObstacleSpawner] No obstacles available in unlocked pools (0..{unlockedPoolCount-1})"); return; } // Compute weights based on recency float[] weights = new float[availableObstacles.Count]; float now = Time.time; for (int i = 0; i < availableObstacles.Count; i++) { int globalIdx = availableGlobalIndices[i]; if (globalIdx < 0 || globalIdx >= _lastUsedTimes.Length) { weights[i] = 1f; // Default weight for invalid indices continue; } float age = now - _lastUsedTimes[globalIdx]; float normalized = Mathf.Clamp01(age / _spawnConfig.recentDecayDuration); // 0 = just used, 1 = fully recovered float weight = Mathf.Max(_spawnConfig.minRecentWeight, normalized); weights[i] = weight; } // Compute and log probabilities for debugging float totalW = 0f; for (int i = 0; i < availableObstacles.Count; i++) { totalW += Mathf.Max(0f, weights[i]); } if (totalW > 0f) { StringBuilder sb = new StringBuilder(); sb.Append($"[ObstacleSpawner] Spawning from pools 0-{unlockedPoolCount-1}. Probabilities: "); for (int i = 0; i < availableObstacles.Count; i++) { float p = weights[i] / totalW; string prefabName = availableObstacles[i] != null ? availableObstacles[i].name : i.ToString(); sb.AppendFormat("{0}:{1:P1}", prefabName, p); if (i < availableObstacles.Count - 1) sb.Append(", "); } Debug.Log(sb.ToString()); } // Select obstacle using weighted random int chosenIndex = WeightedPickIndex(weights); GameObject selectedPrefab = availableObstacles[chosenIndex]; int selectedGlobalIndex = availableGlobalIndices[chosenIndex]; // Record usage timestamp for recency tracking if (selectedGlobalIndex >= 0 && selectedGlobalIndex < _lastUsedTimes.Length) { _lastUsedTimes[selectedGlobalIndex] = Time.time; } // Determine which pool this obstacle came from (for logging) int sourcePool = -1; for (int poolIdx = 0; poolIdx < unlockedPoolCount; poolIdx++) { ObstaclePool pool = _spawnConfig.obstaclePools[poolIdx]; if (pool != null && pool.obstacles != null && System.Array.IndexOf(pool.obstacles, selectedPrefab) >= 0) { sourcePool = poolIdx; break; } } // Spawn at spawn point position with Y = 0 Vector3 spawnPosition = new Vector3(spawnPoint.position.x, 0f, 0f); GameObject obstacleObj = Instantiate(selectedPrefab, spawnPosition, Quaternion.identity); // Parent to container if provided if (obstacleContainer != null) { obstacleObj.transform.SetParent(obstacleContainer, true); } // Initialize obstacle with despawn X position and EdgeAnchor references Obstacle obstacle = obstacleObj.GetComponent(); if (obstacle != null) { obstacle.Initialize(despawnPoint.position.x, referenceMarker, cameraAdapter); } else { Debug.LogError($"[ObstacleSpawner] Spawned prefab '{selectedPrefab.name}' does not have Obstacle component!"); Destroy(obstacleObj); } Debug.Log($"[ObstacleSpawner] Spawned '{selectedPrefab.name}' from pool[{sourcePool}] at {spawnPosition}"); } private int WeightedPickIndex(float[] weights) { int n = weights.Length; float total = 0f; for (int i = 0; i < n; i++) { if (weights[i] > 0f) total += weights[i]; } if (total <= 0f) { return Random.Range(0, n); } float r = Random.value * total; float acc = 0f; for (int i = 0; i < n; i++) { acc += Mathf.Max(0f, weights[i]); if (r <= acc) return i; } return n - 1; } /// /// Start spawning obstacles. /// Initializes the spawner if not already initialized, then begins spawning logic. /// Spawns the first obstacle immediately, then continues with interval-based spawning. /// public void StartSpawning() { // Initialize if not already done if (_spawnConfig == null) { Initialize(); } // Ensure initialization was successful if (_spawnConfig == null) { Debug.LogError("[ObstacleSpawner] Cannot start spawning - initialization failed!"); return; } // Begin the spawning process BeginSpawningObstacles(); } /// /// Internal method that handles the actual spawning startup logic. /// Sets initial state, computes first interval, and spawns the first obstacle. /// private void BeginSpawningObstacles() { _isSpawning = true; _spawnTimer = 0f; _elapsedTime = 0f; // Choose initial interval based on difficulty (at time 0) float initialDifficulty = _spawnConfig.difficultyCurve.Evaluate(0f); float initialBase = Mathf.Lerp(_spawnConfig.maxSpawnInterval, _spawnConfig.minSpawnInterval, initialDifficulty); if (_spawnConfig.intervalJitter > 0f) { float jitter = Random.Range(-_spawnConfig.intervalJitter, _spawnConfig.intervalJitter); _currentSpawnInterval = Mathf.Max(0f, initialBase * (1f + jitter)); } else { _currentSpawnInterval = initialBase; } // Log the initial computed interval Debug.Log($"[ObstacleSpawner] Initial spawn interval: {_currentSpawnInterval:F2}s (difficulty {initialDifficulty:F2})"); // Spawn the first obstacle immediately SpawnObstacle(); Debug.Log("[ObstacleSpawner] Started spawning (first obstacle spawned immediately)"); } /// /// Stop spawning obstacles. /// public void StopSpawning() { _isSpawning = false; Debug.Log("[ObstacleSpawner] Stopped spawning"); } /// /// Check if spawner is currently active. /// public bool IsSpawning => _isSpawning; #if UNITY_EDITOR /// /// Draw gizmos in editor to visualize spawn/despawn points. /// private void OnDrawGizmos() { if (spawnPoint != null) { Gizmos.color = Color.green; Gizmos.DrawLine(spawnPoint.position + Vector3.up * 10f, spawnPoint.position + Vector3.down * 10f); Gizmos.DrawWireSphere(spawnPoint.position, 0.5f); } if (despawnPoint != null) { Gizmos.color = Color.red; Gizmos.DrawLine(despawnPoint.position + Vector3.up * 10f, despawnPoint.position + Vector3.down * 10f); Gizmos.DrawWireSphere(despawnPoint.position, 0.5f); } } #endif } }