2025-11-20 15:16:57 +00:00
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
using Core;
|
|
|
|
|
|
using Core.Settings;
|
|
|
|
|
|
using Core.Lifecycle;
|
|
|
|
|
|
using AppleHillsCamera;
|
2025-12-15 02:08:01 +01:00
|
|
|
|
using System.Text;
|
2025-11-20 15:16:57 +00:00
|
|
|
|
|
|
|
|
|
|
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).
|
|
|
|
|
|
/// </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;
|
|
|
|
|
|
|
|
|
|
|
|
[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;
|
|
|
|
|
|
|
|
|
|
|
|
[Header("Obstacle Prefabs")]
|
|
|
|
|
|
[Tooltip("Array of obstacle prefabs to spawn randomly")]
|
|
|
|
|
|
[SerializeField] private GameObject[] obstaclePrefabs;
|
|
|
|
|
|
|
2025-12-14 20:55:37 +01:00
|
|
|
|
[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;
|
|
|
|
|
|
|
2025-12-15 02:08:01 +01:00
|
|
|
|
[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;
|
|
|
|
|
|
|
2025-11-20 15:16:57 +00:00
|
|
|
|
private IBirdPooperSettings settings;
|
|
|
|
|
|
private float spawnTimer;
|
|
|
|
|
|
private bool isSpawning;
|
2025-12-14 20:55:37 +01:00
|
|
|
|
private float _currentSpawnInterval = 1f;
|
|
|
|
|
|
|
|
|
|
|
|
// difficulty tracking
|
|
|
|
|
|
private float _elapsedTime = 0f;
|
2025-11-20 15:16:57 +00:00
|
|
|
|
|
2025-12-15 02:08:01 +01:00
|
|
|
|
// recency tracking
|
|
|
|
|
|
private float[] _lastUsedTimes;
|
|
|
|
|
|
|
2025-11-20 15:16:57 +00:00
|
|
|
|
internal override void OnManagedAwake()
|
|
|
|
|
|
{
|
|
|
|
|
|
base.OnManagedAwake();
|
|
|
|
|
|
|
|
|
|
|
|
// Load settings
|
|
|
|
|
|
settings = GameManager.GetSettingsObject<IBirdPooperSettings>();
|
|
|
|
|
|
if (settings == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogError("[ObstacleSpawner] BirdPooperSettings not found!");
|
2025-12-14 20:55:37 +01:00
|
|
|
|
// continue — we now use min/max interval fields instead of relying on settings
|
2025-11-20 15:16:57 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate 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 (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.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cameraAdapter == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[ObstacleSpawner] CameraScreenAdapter not assigned. EdgeAnchor will attempt to auto-find camera.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 20:55:37 +01:00
|
|
|
|
// Validate interval range
|
|
|
|
|
|
if (minSpawnInterval < 0f) minSpawnInterval = 0f;
|
|
|
|
|
|
if (maxSpawnInterval < 0f) maxSpawnInterval = 0f;
|
|
|
|
|
|
if (minSpawnInterval > maxSpawnInterval)
|
|
|
|
|
|
{
|
|
|
|
|
|
float tmp = minSpawnInterval;
|
|
|
|
|
|
minSpawnInterval = maxSpawnInterval;
|
|
|
|
|
|
maxSpawnInterval = tmp;
|
|
|
|
|
|
Debug.LogWarning("[ObstacleSpawner] minSpawnInterval was greater than maxSpawnInterval. Values were swapped.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clamp ramp duration
|
|
|
|
|
|
if (difficultyRampDuration < 0.01f) difficultyRampDuration = 0.01f;
|
|
|
|
|
|
|
2025-12-15 02:08:01 +01:00
|
|
|
|
// Clamp recency
|
|
|
|
|
|
if (recentDecayDuration < 0.01f) recentDecayDuration = 0.01f;
|
|
|
|
|
|
if (minRecentWeight < 0f) minRecentWeight = 0f;
|
|
|
|
|
|
if (minRecentWeight > 1f) minRecentWeight = 1f;
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
2025-11-20 15:16:57 +00:00
|
|
|
|
Debug.Log("[ObstacleSpawner] Initialized successfully");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private void Update()
|
|
|
|
|
|
{
|
2025-12-14 20:55:37 +01:00
|
|
|
|
if (!isSpawning || spawnPoint == null) return;
|
2025-11-20 15:16:57 +00:00
|
|
|
|
|
|
|
|
|
|
spawnTimer += Time.deltaTime;
|
2025-12-14 20:55:37 +01:00
|
|
|
|
_elapsedTime += Time.deltaTime;
|
2025-11-20 15:16:57 +00:00
|
|
|
|
|
2025-12-14 20:55:37 +01:00
|
|
|
|
if (spawnTimer >= _currentSpawnInterval)
|
2025-11-20 15:16:57 +00:00
|
|
|
|
{
|
|
|
|
|
|
SpawnObstacle();
|
|
|
|
|
|
spawnTimer = 0f;
|
2025-12-14 20:55:37 +01:00
|
|
|
|
|
|
|
|
|
|
// pick next interval based on difficulty ramp
|
|
|
|
|
|
float t = Mathf.Clamp01(_elapsedTime / difficultyRampDuration);
|
|
|
|
|
|
float difficulty = 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);
|
|
|
|
|
|
|
|
|
|
|
|
// apply small jitter
|
|
|
|
|
|
if (intervalJitter > 0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
float jitter = Random.Range(-intervalJitter, 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})");
|
2025-11-20 15:16:57 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Spawn a random obstacle at the spawn point position (Y = 0).
|
2025-12-15 02:08:01 +01:00
|
|
|
|
/// Uses timestamp/decay weighting so prefabs used recently are less likely.
|
2025-11-20 15:16:57 +00:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
private void SpawnObstacle()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (obstaclePrefabs == null || obstaclePrefabs.Length == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[ObstacleSpawner] No obstacle prefabs to spawn!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (despawnPoint == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[ObstacleSpawner] Cannot spawn obstacle without despawn point reference!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 02:08:01 +01:00
|
|
|
|
int count = obstaclePrefabs.Length;
|
|
|
|
|
|
|
|
|
|
|
|
// Defensive: ensure _lastUsedTimes is initialized and matches prefab count
|
|
|
|
|
|
if (_lastUsedTimes == null || _lastUsedTimes.Length != count)
|
|
|
|
|
|
{
|
|
|
|
|
|
_lastUsedTimes = new float[count];
|
|
|
|
|
|
float initTime = Time.time - recentDecayDuration - 1f;
|
|
|
|
|
|
for (int i = 0; i < count; i++) _lastUsedTimes[i] = initTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// compute weights based on recency (newer = lower weight)
|
|
|
|
|
|
float[] weights = new float[count];
|
|
|
|
|
|
float now = Time.time;
|
|
|
|
|
|
for (int i = 0; i < 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// compute probabilities for logging
|
|
|
|
|
|
float totalW = 0f;
|
|
|
|
|
|
for (int i = 0; i < 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++)
|
|
|
|
|
|
{
|
|
|
|
|
|
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(", ");
|
|
|
|
|
|
}
|
|
|
|
|
|
Debug.Log(sb.ToString());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int chosenIndex = WeightedPickIndex(weights);
|
|
|
|
|
|
GameObject selectedPrefab = obstaclePrefabs[chosenIndex];
|
|
|
|
|
|
|
|
|
|
|
|
// record usage timestamp
|
|
|
|
|
|
_lastUsedTimes[chosenIndex] = Time.time;
|
2025-11-20 15:16:57 +00:00
|
|
|
|
|
|
|
|
|
|
// Spawn at spawn point position with Y = 0
|
|
|
|
|
|
Vector3 spawnPosition = new Vector3(spawnPoint.position.x, 0f, 0f);
|
|
|
|
|
|
GameObject obstacleObj = Instantiate(selectedPrefab, spawnPosition, Quaternion.identity);
|
|
|
|
|
|
|
|
|
|
|
|
// 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 obstacle '{selectedPrefab.name}' at position {spawnPosition}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 02:08:01 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 15:16:57 +00:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Start spawning obstacles.
|
|
|
|
|
|
/// Spawns the first obstacle immediately, then continues with interval-based spawning.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public void StartSpawning()
|
|
|
|
|
|
{
|
|
|
|
|
|
isSpawning = true;
|
|
|
|
|
|
spawnTimer = 0f;
|
2025-12-14 20:55:37 +01:00
|
|
|
|
_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)
|
|
|
|
|
|
{
|
|
|
|
|
|
float jitter = Random.Range(-intervalJitter, 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})");
|
|
|
|
|
|
|
2025-11-20 15:16:57 +00:00
|
|
|
|
// Spawn the first obstacle immediately
|
|
|
|
|
|
SpawnObstacle();
|
2025-12-14 20:55:37 +01:00
|
|
|
|
|
2025-11-20 15:16:57 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|