483 lines
19 KiB
C#
483 lines
19 KiB
C#
using UnityEngine;
|
|
using Core;
|
|
using Core.Settings;
|
|
using Core.Lifecycle;
|
|
using AppleHillsCamera;
|
|
using System.Text;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Minigames.BirdPooper
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<GameObject> _allObstacles;
|
|
private Dictionary<GameObject, int> _obstacleToGlobalIndex;
|
|
private float[] _lastUsedTimes;
|
|
|
|
/// <summary>
|
|
/// Initializes the obstacle spawner by loading settings, validating references, and building obstacle pools.
|
|
/// Should be called once before spawning begins.
|
|
/// </summary>
|
|
private void Initialize()
|
|
{
|
|
// Load settings
|
|
_settings = GameManager.GetSettingsObject<IBirdPooperSettings>();
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a master list of all obstacles across all pools and creates index mappings for recency tracking.
|
|
/// </summary>
|
|
private void BuildMasterObstacleList()
|
|
{
|
|
_allObstacles = new List<GameObject>();
|
|
_obstacleToGlobalIndex = new Dictionary<GameObject, int>();
|
|
|
|
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})");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<GameObject> availableObstacles = new List<GameObject>();
|
|
List<int> availableGlobalIndices = new List<int>();
|
|
|
|
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<Obstacle>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Internal method that handles the actual spawning startup logic.
|
|
/// Sets initial state, computes first interval, and spawns the first obstacle.
|
|
/// </summary>
|
|
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)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stop spawning obstacles.
|
|
/// </summary>
|
|
public void StopSpawning()
|
|
{
|
|
_isSpawning = false;
|
|
Debug.Log("[ObstacleSpawner] Stopped spawning");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if spawner is currently active.
|
|
/// </summary>
|
|
public bool IsSpawning => _isSpawning;
|
|
|
|
#if UNITY_EDITOR
|
|
/// <summary>
|
|
/// Draw gizmos in editor to visualize spawn/despawn points.
|
|
/// </summary>
|
|
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
|
|
}
|
|
}
|
|
|