Time-based difficulty scaling with object pools and bird pooper restart improvements to the minigame

This commit is contained in:
Michal Pikulski
2025-12-16 23:21:10 +01:00
parent 0ff3fbbc70
commit 6133caec53
14 changed files with 994 additions and 280 deletions

View File

@@ -4,6 +4,7 @@ using Core.Settings;
using Core.Lifecycle;
using AppleHillsCamera;
using System.Text;
using System.Collections.Generic;
namespace Minigames.BirdPooper
{
@@ -11,6 +12,7 @@ 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.
/// </summary>
public class ObstacleSpawner : ManagedBehaviour
{
@@ -20,6 +22,9 @@ namespace Minigames.BirdPooper
[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")]
@@ -28,55 +33,45 @@ namespace Minigames.BirdPooper
[Tooltip("CameraScreenAdapter to pass to spawned obstacles")]
[SerializeField] private CameraScreenAdapter cameraAdapter;
[Header("Obstacle Prefabs")]
[Tooltip("Array of obstacle prefabs to spawn randomly")]
[SerializeField] private GameObject[] obstaclePrefabs;
[Header("Spawn Timing")]
[Tooltip("Minimum interval between spawns (seconds)")]
[SerializeField] private float minSpawnInterval = 1f;
[Tooltip("Maximum interval between spawns (seconds)")]
[SerializeField] private float maxSpawnInterval = 2f;
[Header("Difficulty")]
[Tooltip("Time in seconds for difficulty to ramp from 0 to 1")]
[SerializeField] private float difficultyRampDuration = 60f;
[Tooltip("Curve mapping normalized time (0..1) to difficulty (0..1). Default is linear.")]
[SerializeField] private AnimationCurve difficultyCurve = AnimationCurve.Linear(0f, 0f, 1f, 1f);
[Tooltip("Maximum jitter applied to computed interval (fractional). 0.1 = +/-10% jitter")]
[SerializeField] private float intervalJitter = 0.05f;
[Header("Recency / Diversity")]
[Tooltip("Time in seconds it takes for a recently-used prefab to recover back to full weight")]
[SerializeField] private float recentDecayDuration = 10f;
[Tooltip("Minimum weight (0..1) applied to a just-used prefab so it can still appear occasionally")]
[Range(0f, 1f)]
[SerializeField] private float minRecentWeight = 0.05f;
private IBirdPooperSettings settings;
private float spawnTimer;
private bool isSpawning;
private IBirdPooperSettings _settings;
private ObstacleSpawnConfig _spawnConfig;
private float _spawnTimer;
private bool _isSpawning;
private float _currentSpawnInterval = 1f;
// difficulty tracking
private float _elapsedTime = 0f;
// Difficulty tracking
private float _elapsedTime;
// recency tracking
// Master obstacle list for recency tracking
private List<GameObject> _allObstacles;
private Dictionary<GameObject, int> _obstacleToGlobalIndex;
private float[] _lastUsedTimes;
internal override void OnManagedAwake()
/// <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()
{
base.OnManagedAwake();
// Load settings
settings = GameManager.GetSettingsObject<IBirdPooperSettings>();
if (settings == null)
_settings = GameManager.GetSettingsObject<IBirdPooperSettings>();
if (_settings == null)
{
Debug.LogError("[ObstacleSpawner] BirdPooperSettings not found!");
// continue — we now use min/max interval fields instead of relying on settings
Debug.LogError("[ObstacleSpawner] BirdPooperSettings not found! Cannot initialize.");
return;
}
// Validate references
_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.");
@@ -87,11 +82,6 @@ namespace Minigames.BirdPooper
Debug.LogError("[ObstacleSpawner] Despawn Point not assigned! Please assign a Transform in the Inspector.");
}
if (obstaclePrefabs == null || obstaclePrefabs.Length == 0)
{
Debug.LogError("[ObstacleSpawner] No obstacle prefabs assigned! Please assign at least one prefab in the Inspector.");
}
if (referenceMarker == null)
{
Debug.LogError("[ObstacleSpawner] ScreenReferenceMarker not assigned! Obstacles need this for EdgeAnchor positioning.");
@@ -102,58 +92,100 @@ namespace Minigames.BirdPooper
Debug.LogWarning("[ObstacleSpawner] CameraScreenAdapter not assigned. EdgeAnchor will attempt to auto-find camera.");
}
// Validate interval range
if (minSpawnInterval < 0f) minSpawnInterval = 0f;
if (maxSpawnInterval < 0f) maxSpawnInterval = 0f;
if (minSpawnInterval > maxSpawnInterval)
// 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)
{
float tmp = minSpawnInterval;
minSpawnInterval = maxSpawnInterval;
maxSpawnInterval = tmp;
Debug.LogWarning("[ObstacleSpawner] minSpawnInterval was greater than maxSpawnInterval. Values were swapped.");
Debug.LogError("[ObstacleSpawner] No obstacle pools defined in configuration!");
return;
}
// Clamp ramp duration
if (difficultyRampDuration < 0.01f) difficultyRampDuration = 0.01f;
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;
}
// Clamp recency
if (recentDecayDuration < 0.01f) recentDecayDuration = 0.01f;
if (minRecentWeight < 0f) minRecentWeight = 0f;
if (minRecentWeight > 1f) minRecentWeight = 1f;
foreach (GameObject prefab in pool.obstacles)
{
if (prefab == null)
{
Debug.LogWarning($"[ObstacleSpawner] Null prefab found in pool[{poolIdx}]");
continue;
}
// Initialize last-used timestamps so prefabs start available (set to sufficiently negative so they appear with full weight)
int n = obstaclePrefabs != null ? obstaclePrefabs.Length : 0;
_lastUsedTimes = new float[n];
float initTime = -recentDecayDuration - 1f;
for (int i = 0; i < n; i++) _lastUsedTimes[i] = initTime;
// Allow duplicates - same prefab can appear in multiple pools
if (!_obstacleToGlobalIndex.ContainsKey(prefab))
{
_obstacleToGlobalIndex[prefab] = globalIndex;
_allObstacles.Add(prefab);
globalIndex++;
}
}
}
Debug.Log("[ObstacleSpawner] Initialized successfully");
// 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) return;
if (!_isSpawning || spawnPoint == null || _spawnConfig == null) return;
spawnTimer += Time.deltaTime;
_spawnTimer += Time.deltaTime;
_elapsedTime += Time.deltaTime;
if (spawnTimer >= _currentSpawnInterval)
if (_spawnTimer >= _currentSpawnInterval)
{
SpawnObstacle();
spawnTimer = 0f;
_spawnTimer = 0f;
// pick next interval based on difficulty ramp
float t = Mathf.Clamp01(_elapsedTime / difficultyRampDuration);
float difficulty = difficultyCurve.Evaluate(t); // 0..1
// 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(maxSpawnInterval, minSpawnInterval, difficulty);
// 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 (intervalJitter > 0f)
// Apply small jitter
if (_spawnConfig.intervalJitter > 0f)
{
float jitter = Random.Range(-intervalJitter, intervalJitter);
float jitter = Random.Range(-_spawnConfig.intervalJitter, _spawnConfig.intervalJitter);
_currentSpawnInterval = Mathf.Max(0f, baseInterval * (1f + jitter));
}
else
@@ -167,14 +199,14 @@ namespace Minigames.BirdPooper
}
/// <summary>
/// Spawn a random obstacle at the spawn point position (Y = 0).
/// 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 (obstaclePrefabs == null || obstaclePrefabs.Length == 0)
if (_spawnConfig == null || _spawnConfig.obstaclePools == null || _spawnConfig.obstaclePools.Length == 0)
{
Debug.LogWarning("[ObstacleSpawner] No obstacle prefabs to spawn!");
Debug.LogWarning("[ObstacleSpawner] No obstacle pools configured!");
return;
}
@@ -184,53 +216,133 @@ namespace Minigames.BirdPooper
return;
}
int count = obstaclePrefabs.Length;
// Defensive: ensure _lastUsedTimes is initialized and matches prefab count
if (_lastUsedTimes == null || _lastUsedTimes.Length != count)
// Determine which pools are currently unlocked based on elapsed time
int unlockedPoolCount = 1; // Pool[0] is always unlocked
if (_spawnConfig.poolUnlockTimes != null)
{
_lastUsedTimes = new float[count];
float initTime = Time.time - recentDecayDuration - 1f;
for (int i = 0; i < count; i++) _lastUsedTimes[i] = initTime;
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
}
}
}
// compute weights based on recency (newer = lower weight)
float[] weights = new float[count];
// 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 < count; i++)
for (int i = 0; i < availableObstacles.Count; i++)
{
float age = now - _lastUsedTimes[i];
float normalized = Mathf.Clamp01(age / recentDecayDuration); // 0 = just used, 1 = fully recovered
float weight = Mathf.Max(minRecentWeight, normalized); // ensure minimum probability
weights[i] = weight; // base weight = 1.0, could be extended to per-prefab weights
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 probabilities for logging
// Compute and log probabilities for debugging
float totalW = 0f;
for (int i = 0; i < count; i++) totalW += Mathf.Max(0f, weights[i]);
for (int i = 0; i < availableObstacles.Count; i++)
{
totalW += Mathf.Max(0f, weights[i]);
}
if (totalW > 0f)
{
var sb = new StringBuilder();
sb.Append("[ObstacleSpawner] Prefab pick probabilities: ");
for (int i = 0; i < count; i++)
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 name = obstaclePrefabs[i] != null ? obstaclePrefabs[i].name : i.ToString();
sb.AppendFormat("{0}:{1:P1}", name, p);
if (i < count - 1) sb.Append(", ");
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 = obstaclePrefabs[chosenIndex];
GameObject selectedPrefab = availableObstacles[chosenIndex];
int selectedGlobalIndex = availableGlobalIndices[chosenIndex];
// record usage timestamp
_lastUsedTimes[chosenIndex] = Time.time;
// 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>();
@@ -244,7 +356,7 @@ namespace Minigames.BirdPooper
Destroy(obstacleObj);
}
Debug.Log($"[ObstacleSpawner] Spawned obstacle '{selectedPrefab.name}' at position {spawnPosition}");
Debug.Log($"[ObstacleSpawner] Spawned '{selectedPrefab.name}' from pool[{sourcePool}] at {spawnPosition}");
}
private int WeightedPickIndex(float[] weights)
@@ -275,20 +387,45 @@ namespace Minigames.BirdPooper
/// <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()
{
isSpawning = true;
spawnTimer = 0f;
// 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 = difficultyCurve.Evaluate(0f);
float initialBase = Mathf.Lerp(maxSpawnInterval, minSpawnInterval, initialDifficulty);
if (intervalJitter > 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(-intervalJitter, intervalJitter);
float jitter = Random.Range(-_spawnConfig.intervalJitter, _spawnConfig.intervalJitter);
_currentSpawnInterval = Mathf.Max(0f, initialBase * (1f + jitter));
}
else
@@ -310,14 +447,14 @@ namespace Minigames.BirdPooper
/// </summary>
public void StopSpawning()
{
isSpawning = false;
_isSpawning = false;
Debug.Log("[ObstacleSpawner] Stopped spawning");
}
/// <summary>
/// Check if spawner is currently active.
/// </summary>
public bool IsSpawning => isSpawning;
public bool IsSpawning => _isSpawning;
#if UNITY_EDITOR
/// <summary>