Big stupid refactor of spawning almost done

This commit is contained in:
Michal Pikulski
2025-12-17 18:51:33 +01:00
parent 7a598c302c
commit 867399c838
32 changed files with 1450 additions and 403 deletions

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Collections.Generic;
using AppleHills.Core.Settings;
using Minigames.Airplane.Data;
using Minigames.StatueDressup.Data;
using UnityEngine;
@@ -344,11 +345,18 @@ namespace Core.Settings
float PositiveNegativeRatio { get; } // 0-1, where 1 = all positive, 0 = all negative
float SpawnDistanceAhead { get; }
float GroundSpawnInterval { get; }
float RecencyPenaltyDuration { get; } // Time penalty for recently-spawned prefabs
// Ground Snapping
int GroundLayer { get; }
float MaxGroundRaycastDistance { get; }
float DefaultObjectYOffset { get; }
float FallbackYPosition { get; } // Y position when SnapToGround fails OR when using SpecifiedY mode (universal fallback)
float GroundSpawnY { get; }
// Default Obstacle Positioning (used when prefab has no PrefabSpawnEntryComponent)
SpawnPositionMode DefaultObstaclePositionMode { get; }
float DefaultObstacleRandomYMin { get; } // Min Y when DefaultObstaclePositionMode = RandomRange
float DefaultObstacleRandomYMax { get; } // Max Y when DefaultObstaclePositionMode = RandomRange
// Debug
bool ShowDebugLogs { get; }

View File

@@ -72,6 +72,16 @@ namespace Minigames.Airplane.Core
[Tooltip("Transform marker in scene where dynamic spawning begins (uses X position)")]
[SerializeField] private Transform dynamicSpawnThresholdMarker;
[Header("Spawn Organization")]
[Tooltip("Parent transform for spawned obstacles (organization)")]
[SerializeField] private Transform obstacleContainer;
[Tooltip("Parent transform for spawned ground tiles (organization)")]
[SerializeField] private Transform groundContainer;
[Tooltip("Parent transform for spawned parallax elements (organization)")]
[SerializeField] private Transform parallaxContainer;
[Header("Spawn Parents")]
[Tooltip("Parent transform for spawned target (optional)")]
[SerializeField] private Transform targetParent;
@@ -242,6 +252,14 @@ namespace Minigames.Airplane.Core
if (groundSpawner != null)
{
groundSpawner.PreSpawn(preSpawnStartX, preSpawnEndX);
// Force physics system to update so ground colliders are available for raycasting
Physics2D.SyncTransforms();
if (showDebugLogs)
{
Logging.Debug("[AirplaneSpawnManager] Physics synced after ground spawn");
}
}
// 2. Spawn parallax background (if assigned)
@@ -250,7 +268,7 @@ namespace Minigames.Airplane.Core
parallaxSpawner.PreSpawn(preSpawnStartX, preSpawnEndX);
}
// 3. Spawn obstacles (after ground exists)
// 3. Spawn obstacles (after ground exists and physics updated)
if (obstacleSpawner != null)
{
obstacleSpawner.PreSpawn(preSpawnStartX, preSpawnEndX);
@@ -279,33 +297,10 @@ namespace Minigames.Airplane.Core
_isSpawningActive = true;
_gameTime = 0f;
// Start tracking in spawners
float startX = _lastSpawnedX;
if (obstacleSpawner != null)
{
obstacleSpawner.StartTracking(planeTransform, startX);
}
if (groundSpawner != null)
{
groundSpawner.StartTracking(planeTransform, startX);
}
if (parallaxSpawner != null)
{
parallaxSpawner.StartTracking(planeTransform, startX);
}
// Start UI tracking with calculated target position
if (targetDisplayUI != null)
{
targetDisplayUI.StartTracking(planeTransform);
if (showDebugLogs)
{
Logging.Debug("[AirplaneSpawnManager] UI tracking started");
}
}
if (showDebugLogs)
@@ -322,22 +317,6 @@ namespace Minigames.Airplane.Core
_isSpawningActive = false;
_planeTransform = null;
// Stop spawners
if (obstacleSpawner != null)
{
obstacleSpawner.StopTracking();
}
if (groundSpawner != null)
{
groundSpawner.StopTracking();
}
if (parallaxSpawner != null)
{
parallaxSpawner.StopTracking();
}
// Stop UI tracking
if (targetDisplayUI != null)
{
@@ -481,22 +460,25 @@ namespace Minigames.Airplane.Core
{
if (obstacleSpawner != null)
{
obstacleSpawner.Initialize();
var initParams = new SpawnInitParameters(obstacleContainer, _settings);
obstacleSpawner.Initialize(initParams);
}
if (groundSpawner != null)
{
groundSpawner.Initialize();
var initParams = new SpawnInitParameters(groundContainer, _settings);
groundSpawner.Initialize(initParams);
}
if (parallaxSpawner != null)
{
parallaxSpawner.Initialize();
var initParams = new SpawnInitParameters(parallaxContainer, _settings);
parallaxSpawner.Initialize(initParams);
}
if (showDebugLogs)
{
Logging.Debug("[AirplaneSpawnManager] All spawners initialized");
Logging.Debug("[AirplaneSpawnManager] All spawners initialized - distance-based spawning ready");
}
}
@@ -520,7 +502,7 @@ namespace Minigames.Airplane.Core
private void UpdateDynamicSpawning()
{
if (_planeTransform == null) return;
if (_planeTransform == null || !_isSpawningActive) return;
float planeX = _planeTransform.position.x;
@@ -535,21 +517,36 @@ namespace Minigames.Airplane.Core
if (shouldSpawnNewContent)
{
float spawnAheadDistance = _settings.SpawnDistanceAhead;
// Centralized distance checking - orchestrator decides when to spawn
// Obstacles - check if next spawn point is within ahead distance
if (obstacleSpawner != null)
{
obstacleSpawner.UpdateSpawning(spawnAheadDistance);
float nextObstacleX = obstacleSpawner.GetNextSpawnX();
if (nextObstacleX <= planeX + _settings.SpawnDistanceAhead)
{
obstacleSpawner.SpawnNext();
}
}
// Ground - spawn further ahead (2x distance)
if (groundSpawner != null)
{
groundSpawner.UpdateSpawning(spawnAheadDistance * 2f); // Ground spawns further ahead
float nextGroundX = groundSpawner.GetNextSpawnX();
if (nextGroundX <= planeX + (_settings.SpawnDistanceAhead * 2f))
{
groundSpawner.SpawnNext();
}
}
// Parallax - spawn at 1.5x distance
if (parallaxSpawner != null)
{
parallaxSpawner.UpdateSpawning(spawnAheadDistance * 1.5f);
float nextParallaxX = parallaxSpawner.GetNextSpawnX();
if (nextParallaxX <= planeX + (_settings.SpawnDistanceAhead * 1.5f))
{
parallaxSpawner.SpawnNext();
}
}
}
}
@@ -719,9 +716,9 @@ namespace Minigames.Airplane.Core
}
else
{
// No ground found - use default
Logging.Warning($"[SpawnManager] No ground found for target at X={xPosition:F2} (raycast from Y=20 for {_settings.MaxGroundRaycastDistance} units), using default Y={_settings.DefaultObjectYOffset}");
return _settings.DefaultObjectYOffset;
// No ground found - use fallback
Logging.Warning($"[SpawnManager] No ground found for target at X={xPosition:F2} (raycast from Y=20 for {_settings.MaxGroundRaycastDistance} units), using fallback Y={_settings.FallbackYPosition}");
return _settings.FallbackYPosition;
}
}
@@ -853,7 +850,7 @@ namespace Minigames.Airplane.Core
return groundY + bounds.extents.y;
}
return _settings.DefaultObjectYOffset;
return _settings.FallbackYPosition;
}
/// <summary>

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using Core;
using Core.Lifecycle;
using Core.Settings;
using Minigames.Airplane.Data;
using UnityEngine;
@@ -9,7 +10,9 @@ namespace Minigames.Airplane.Core.Spawning
/// <summary>
/// Base class for distance-based spawners in the airplane minigame.
/// Handles pool management, time-based unlocking, distance tracking, and recency diversity.
/// All spawn parameters (distances, recency, positioning, debug) are configured in AirplaneSettings.
/// Derived classes implement specific spawn logic via SpawnFromPool.
/// Orchestrator (AirplaneSpawnManager) handles distance checking and calls SpawnNext when needed.
/// </summary>
public abstract class BaseDistanceSpawner : ManagedBehaviour
{
@@ -22,35 +25,16 @@ namespace Minigames.Airplane.Core.Spawning
[Tooltip("Spawn pools ordered by difficulty/progression")]
[SerializeField] protected SpawnPoolConfig[] pools;
[Header("Global Spawn Parameters")]
[Tooltip("Minimum distance between spawns (used when poolMode = Together or as fallback)")]
[SerializeField] protected float globalMinDistance = 5f;
[Tooltip("Maximum distance between spawns (used when poolMode = Together or as fallback)")]
[SerializeField] protected float globalMaxDistance = 15f;
[Header("Spawn References")]
[Tooltip("Transform marking spawn position (typically off-screen right)")]
[SerializeField] protected Transform spawnPoint;
[Tooltip("Optional parent for spawned objects (organization)")]
[SerializeField] protected Transform spawnContainer;
[Header("Recency Tracking")]
[Tooltip("Time penalty (seconds) applied to recently-used prefabs for diversity")]
[SerializeField] protected float recencyPenaltyDuration = 10f;
[Header("Debug")]
[SerializeField] protected bool showDebugLogs;
#endregion
#region State
// Shared references (set by orchestrator via SpawnInitParameters)
protected Transform spawnContainer;
protected IAirplaneSettings settings;
// Tracking
protected Transform PlaneTransform;
protected float GameTime;
protected bool IsSpawning;
protected float LastSpawnedX;
// Pool state
@@ -68,10 +52,15 @@ namespace Minigames.Airplane.Core.Spawning
#region Initialization
/// <summary>
/// Initialize the spawner. Call this before starting spawning.
/// Initialize the spawner with shared configuration. Call this before starting spawning.
/// </summary>
public virtual void Initialize()
/// <param name="initParams">Initialization parameters containing shared references</param>
public virtual void Initialize(SpawnInitParameters initParams)
{
// Store shared references
spawnContainer = initParams.SpawnContainer;
settings = initParams.Settings;
ValidateConfiguration();
// Unlock pool 0 immediately
@@ -79,7 +68,7 @@ namespace Minigames.Airplane.Core.Spawning
{
UnlockedPoolIndices.Add(0);
if (showDebugLogs)
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Initialized with pool 0 unlocked");
}
@@ -88,9 +77,12 @@ namespace Minigames.Airplane.Core.Spawning
// Initialize exclusive mode spawn positions if needed
if (poolMode == SpawnPoolMode.Exclusive)
{
for (int i = 0; i < pools.Length; i++)
if (pools != null)
{
PoolNextSpawnX[i] = 0f;
for (int i = 0; i < pools.Length; i++)
{
PoolNextSpawnX[i] = 0f;
}
}
}
}
@@ -103,11 +95,6 @@ namespace Minigames.Airplane.Core.Spawning
return;
}
if (spawnPoint == null)
{
Logging.Warning($"[{GetType().Name}] Spawn point not assigned!");
}
// Validate pool unlock times are monotonically increasing
for (int i = 1; i < pools.Length; i++)
{
@@ -122,51 +109,6 @@ namespace Minigames.Airplane.Core.Spawning
#region Public API
/// <summary>
/// Start tracking the plane and enable spawning.
/// </summary>
public void StartTracking(Transform planeTransform, float startX)
{
PlaneTransform = planeTransform;
IsSpawning = true;
GameTime = 0f;
LastSpawnedX = startX;
// Initialize next spawn position
if (poolMode == SpawnPoolMode.Together)
{
NextSpawnX = startX + Random.Range(globalMinDistance, globalMaxDistance);
}
else // Exclusive
{
foreach (int poolIndex in UnlockedPoolIndices)
{
float minDist = pools[poolIndex].GetMinDistance(globalMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(globalMaxDistance);
PoolNextSpawnX[poolIndex] = startX + Random.Range(minDist, maxDist);
}
}
if (showDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Started tracking from X={startX}");
}
}
/// <summary>
/// Stop spawning and tracking.
/// </summary>
public void StopTracking()
{
IsSpawning = false;
PlaneTransform = null;
if (showDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Stopped tracking");
}
}
/// <summary>
/// Update game time and check for pool unlocks.
/// Call this every frame from the orchestrator.
@@ -177,6 +119,58 @@ namespace Minigames.Airplane.Core.Spawning
CheckPoolUnlocks();
}
/// <summary>
/// Spawn the next object at the predetermined NextSpawnX position.
/// Updates LastSpawnedX and calculates new NextSpawnX.
/// Called by orchestrator when distance check determines spawning is needed.
/// </summary>
public virtual void SpawnNext()
{
if (poolMode == SpawnPoolMode.Together)
{
SpawnAtPosition(NextSpawnX);
LastSpawnedX = NextSpawnX;
NextSpawnX = LastSpawnedX + Random.Range(settings.ObjectSpawnMinDistance, settings.ObjectSpawnMaxDistance);
}
else // Exclusive mode - spawn from each pool independently
{
foreach (int poolIndex in UnlockedPoolIndices)
{
if (poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs) continue;
if (!PoolNextSpawnX.ContainsKey(poolIndex)) continue;
SpawnFromPool(poolIndex, PoolNextSpawnX[poolIndex]);
float minDist = pools[poolIndex].GetMinDistance(settings.ObjectSpawnMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(settings.ObjectSpawnMaxDistance);
PoolNextSpawnX[poolIndex] += Random.Range(minDist, maxDist);
LastSpawnedX = Mathf.Max(LastSpawnedX, PoolNextSpawnX[poolIndex]);
}
}
}
/// <summary>
/// Get the X position where the next spawn will occur.
/// Used by orchestrator for distance checking.
/// </summary>
public float GetNextSpawnX()
{
if (poolMode == SpawnPoolMode.Together)
{
return NextSpawnX;
}
else // Exclusive - return the closest spawn point across all pools
{
float closest = float.MaxValue;
foreach (var kvp in PoolNextSpawnX)
{
if (kvp.Value < closest)
closest = kvp.Value;
}
return closest;
}
}
/// <summary>
/// Pre-spawn objects from start to end X position.
/// </summary>
@@ -194,26 +188,6 @@ namespace Minigames.Airplane.Core.Spawning
LastSpawnedX = endX;
}
/// <summary>
/// Update spawning based on plane position.
/// Call this every frame from the orchestrator.
/// </summary>
public virtual void UpdateSpawning(float spawnAheadDistance)
{
if (!IsSpawning || PlaneTransform == null) return;
float planeX = PlaneTransform.position.x;
if (poolMode == SpawnPoolMode.Together)
{
UpdateSpawningTogether(planeX, spawnAheadDistance);
}
else
{
UpdateSpawningExclusive(planeX, spawnAheadDistance);
}
}
/// <summary>
/// Clean up all spawned objects.
/// </summary>
@@ -231,7 +205,7 @@ namespace Minigames.Airplane.Core.Spawning
UnlockedPoolIndices.Clear();
UnlockedPoolIndices.Add(0); // Re-add pool 0
if (showDebugLogs)
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Cleanup complete");
}
@@ -252,14 +226,15 @@ namespace Minigames.Airplane.Core.Spawning
UnlockedPoolIndices.Add(i);
// If exclusive mode, initialize spawn position for this pool
if (poolMode == SpawnPoolMode.Exclusive && PlaneTransform != null)
if (poolMode == SpawnPoolMode.Exclusive)
{
float minDist = pools[i].GetMinDistance(globalMinDistance);
float maxDist = pools[i].GetMaxDistance(globalMaxDistance);
PoolNextSpawnX[i] = PlaneTransform.position.x + Random.Range(minDist, maxDist);
float minDist = pools[i].GetMinDistance(settings.ObjectSpawnMinDistance);
float maxDist = pools[i].GetMaxDistance(settings.ObjectSpawnMaxDistance);
// Initialize at last spawned position plus random distance
PoolNextSpawnX[i] = LastSpawnedX + Random.Range(minDist, maxDist);
}
if (showDebugLogs)
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Unlocked pool {i} '{pools[i].description}' at time {GameTime:F2}s");
}
@@ -267,7 +242,7 @@ namespace Minigames.Airplane.Core.Spawning
}
}
protected GameObject SelectPrefabFromPools(out int selectedPoolIndex)
protected virtual GameObject SelectPrefabFromPools(out int selectedPoolIndex)
{
selectedPoolIndex = -1;
@@ -294,9 +269,9 @@ namespace Minigames.Airplane.Core.Spawning
if (LastUsedTimes.TryGetValue(prefab, out float lastUsedTime))
{
float timeSinceUse = GameTime - lastUsedTime;
if (timeSinceUse < recencyPenaltyDuration)
if (timeSinceUse < settings.RecencyPenaltyDuration)
{
weight = timeSinceUse / recencyPenaltyDuration; // 0 to 1 linear recovery
weight = timeSinceUse / settings.RecencyPenaltyDuration; // 0 to 1 linear recovery
}
}
@@ -340,29 +315,17 @@ namespace Minigames.Airplane.Core.Spawning
protected virtual void PreSpawnTogether(float startX, float endX)
{
float currentX = startX + Random.Range(globalMinDistance, globalMaxDistance);
float currentX = startX + Random.Range(settings.ObjectSpawnMinDistance, settings.ObjectSpawnMaxDistance);
while (currentX <= endX)
{
SpawnAtPosition(currentX);
currentX += Random.Range(globalMinDistance, globalMaxDistance);
currentX += Random.Range(settings.ObjectSpawnMinDistance, settings.ObjectSpawnMaxDistance);
}
NextSpawnX = currentX;
}
protected virtual void UpdateSpawningTogether(float planeX, float spawnAheadDistance)
{
float spawnTriggerX = LastSpawnedX + spawnAheadDistance;
if (planeX >= spawnTriggerX && planeX >= NextSpawnX)
{
SpawnAtPosition(NextSpawnX);
LastSpawnedX = NextSpawnX;
NextSpawnX += Random.Range(globalMinDistance, globalMaxDistance);
}
}
#endregion
#region Exclusive Mode Spawning
@@ -373,8 +336,8 @@ namespace Minigames.Airplane.Core.Spawning
{
if (poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs) continue;
float minDist = pools[poolIndex].GetMinDistance(globalMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(globalMaxDistance);
float minDist = pools[poolIndex].GetMinDistance(settings.ObjectSpawnMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(settings.ObjectSpawnMaxDistance);
float currentX = startX + Random.Range(minDist, maxDist);
while (currentX <= endX)
@@ -387,49 +350,24 @@ namespace Minigames.Airplane.Core.Spawning
}
}
protected virtual void UpdateSpawningExclusive(float planeX, float spawnAheadDistance)
{
foreach (int poolIndex in UnlockedPoolIndices)
{
if (poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs) continue;
if (!PoolNextSpawnX.ContainsKey(poolIndex)) continue;
float nextX = PoolNextSpawnX[poolIndex];
float spawnTargetX = planeX + spawnAheadDistance;
if (nextX <= spawnTargetX)
{
SpawnFromPool(poolIndex, nextX);
float minDist = pools[poolIndex].GetMinDistance(globalMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(globalMaxDistance);
PoolNextSpawnX[poolIndex] = nextX + Random.Range(minDist, maxDist);
LastSpawnedX = Mathf.Max(LastSpawnedX, nextX);
}
}
}
#endregion
#region Spawning
protected void SpawnAtPosition(float xPosition)
{
int selectedPoolIndex;
GameObject prefab = SelectPrefabFromPools(out selectedPoolIndex);
if (prefab == null) return;
SpawnFromPool(selectedPoolIndex, xPosition);
GameObject prefab = SelectPrefabFromPools(out int poolIndex);
if (prefab != null && poolIndex >= 0)
{
SpawnFromPool(poolIndex, xPosition);
}
}
/// <summary>
/// Spawn a specific prefab from a pool at the given X position.
/// Override this in derived classes to implement specific spawn logic.
/// Derived classes must implement this to define spawn behavior.
/// </summary>
protected virtual void SpawnFromPool(int poolIndex, float xPosition)
{
// Derived classes implement specific spawn logic
}
protected abstract void SpawnFromPool(int poolIndex, float xPosition);
protected GameObject InstantiatePrefab(GameObject prefab, Vector3 position)
{
@@ -444,6 +382,95 @@ namespace Minigames.Airplane.Core.Spawning
}
#endregion
#region Object Positioning
/// <summary>
/// Position an object based on the specified spawn mode.
/// Used by spawners to apply Y positioning after instantiation.
/// </summary>
protected void PositionObject(GameObject obj, float xPosition, SpawnPositionMode mode,
float specifiedY, float randomYMin, float randomYMax)
{
if (obj == null) return;
float targetY;
switch (mode)
{
case SpawnPositionMode.SnapToGround:
targetY = SnapToGround(obj, xPosition);
break;
case SpawnPositionMode.SpecifiedY:
targetY = specifiedY;
break;
case SpawnPositionMode.RandomRange:
targetY = Random.Range(randomYMin, randomYMax);
break;
default:
targetY = 0f;
break;
}
Vector3 newPosition = obj.transform.position;
newPosition.y = targetY;
obj.transform.position = newPosition;
}
/// <summary>
/// Snap an object to the ground using raycast.
/// Positions object so its bottom bounds touch the ground surface.
/// </summary>
protected float SnapToGround(GameObject obj, float xPosition)
{
// Raycast from high up to ensure we're above the ground
Vector2 rayOrigin = new Vector2(xPosition, settings.MaxGroundRaycastDistance);
int layerMask = 1 << settings.GroundLayer;
RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.down, settings.MaxGroundRaycastDistance + 100f, layerMask);
if (hit.collider != null)
{
float groundY = hit.point.y;
Bounds bounds = GetObjectBounds(obj);
float finalY = groundY + bounds.extents.y;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] SnapToGround: X={xPosition:F2}, Ground={groundY:F2}, Final={finalY:F2}");
}
return finalY;
}
if (settings.ShowDebugLogs)
{
Logging.Warning($"[{GetType().Name}] SnapToGround FAILED at X={xPosition:F2} - no ground found! Using fallback Y={settings.FallbackYPosition:F2}");
}
return settings.FallbackYPosition;
}
/// <summary>
/// Get the bounds of an object from its Renderer or Collider.
/// </summary>
protected Bounds GetObjectBounds(GameObject obj)
{
Renderer objRenderer = obj.GetComponentInChildren<Renderer>();
if (objRenderer != null) return objRenderer.bounds;
Collider2D objCollider2D = obj.GetComponentInChildren<Collider2D>();
if (objCollider2D != null) return objCollider2D.bounds;
Collider objCollider3D = obj.GetComponentInChildren<Collider>();
if (objCollider3D != null) return objCollider3D.bounds;
return new Bounds(obj.transform.position, Vector3.one);
}
#endregion
}
}

View File

@@ -1,3 +1,3 @@
fileFormatVersion: 2
guid: 387858903c2c44e0ab007cb2ac886343
timeCreated: 1765965907
guid: f65e211e40b247ffb0ac920be5a9ce53
timeCreated: 1765993195

View File

@@ -1,4 +1,6 @@
using Core;
using Core.Settings;
using Minigames.Airplane.Data;
using UnityEngine;
namespace Minigames.Airplane.Core.Spawning
@@ -6,6 +8,7 @@ namespace Minigames.Airplane.Core.Spawning
/// <summary>
/// Spawns ground tiles at fixed intervals.
/// Inherits distance-based spawning from BaseDistanceSpawner.
/// Uses IAirplaneSettings.GroundSpawnInterval for consistent tile spacing.
/// </summary>
public class GroundDistanceSpawner : BaseDistanceSpawner
{
@@ -13,6 +16,49 @@ namespace Minigames.Airplane.Core.Spawning
[Tooltip("Y position at which to spawn ground tiles")]
[SerializeField] private float groundSpawnY = -18f;
private float _groundSpawnInterval;
public override void Initialize(SpawnInitParameters initParams)
{
base.Initialize(initParams);
// Get fixed ground spawn interval from settings
_groundSpawnInterval = initParams.Settings.GroundSpawnInterval;
// Force Together mode for ground spawning
poolMode = SpawnPoolMode.Together;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[GroundDistanceSpawner] Using fixed interval: {_groundSpawnInterval}f from settings");
}
}
// Override to use fixed intervals instead of random distances
protected override void PreSpawnTogether(float startX, float endX)
{
float currentX = startX;
while (currentX <= endX)
{
SpawnAtPosition(currentX);
currentX += _groundSpawnInterval; // FIXED interval, not random
}
NextSpawnX = currentX;
}
/// <summary>
/// Override SpawnNext to use fixed ground intervals instead of random distances.
/// Called by orchestrator when distance check determines spawning is needed.
/// </summary>
public override void SpawnNext()
{
SpawnAtPosition(NextSpawnX);
LastSpawnedX = NextSpawnX;
NextSpawnX += _groundSpawnInterval; // FIXED interval, not random
}
protected override void SpawnFromPool(int poolIndex, float xPosition)
{
if (poolIndex < 0 || poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs)
@@ -22,15 +68,15 @@ namespace Minigames.Airplane.Core.Spawning
GameObject tilePrefab = pools[poolIndex].prefabs[Random.Range(0, pools[poolIndex].prefabs.Length)];
if (tilePrefab == null) return;
// Calculate spawn position
Vector3 spawnPosition = new Vector3(xPosition, groundSpawnY, 0f);
// Calculate spawn position using settings
Vector3 spawnPosition = new Vector3(xPosition, settings.GroundSpawnY, 0f);
// Instantiate
GameObject instance = InstantiatePrefab(tilePrefab, spawnPosition);
if (showDebugLogs)
if (settings.ShowDebugLogs)
{
Logging.Debug($"[GroundDistanceSpawner] Spawned ground tile at X={xPosition:F2}, Y={groundSpawnY:F2}");
Logging.Debug($"[GroundDistanceSpawner] Spawned ground tile at X={xPosition:F2}, Y={settings.GroundSpawnY:F2}");
}
}
}

View File

@@ -1,71 +1,183 @@
using Core;
using Minigames.Airplane.Data;
using Minigames.Airplane.Interactive;
using System.Collections.Generic;
using UnityEngine;
namespace Minigames.Airplane.Core.Spawning
{
/// <summary>
/// Spawns obstacle objects (positive and negative) with weighted ratio management.
/// Uses exactly 2 fixed pools: Pool 0 = Positive, Pool 1 = Negative.
/// Inherits distance-based spawning from BaseDistanceSpawner.
/// </summary>
public class ObstacleDistanceSpawner : BaseDistanceSpawner
{
[Header("Obstacle-Specific")]
[Tooltip("Ratio of positive to negative objects (0 = all negative, 1 = all positive)")]
[Range(0f, 1f)]
[SerializeField] private float positiveNegativeRatio = 0.5f;
[Tooltip("Array indices that should be treated as positive objects")]
[SerializeField] private int[] positivePoolIndices;
[Tooltip("Array indices that should be treated as negative objects")]
[SerializeField] private int[] negativePoolIndices;
[Header("Positioning")]
[SerializeField] private int groundLayer;
[SerializeField] private float maxGroundRaycastDistance = 50f;
[SerializeField] private float defaultObjectYOffset;
private int _positiveSpawnCount;
private int _negativeSpawnCount;
public override void Initialize()
public override void Initialize(SpawnInitParameters initParams)
{
base.Initialize();
// TEMPORARY: Unconditional log to verify this is being called
Logging.Debug("[ObstacleDistanceSpawner] Initialize() called - spawner is active!");
// Validate exactly 2 pools
if (pools == null || pools.Length != 2)
{
Logging.Error("[ObstacleDistanceSpawner] Must have exactly 2 pools (Positive, Negative)!");
}
base.Initialize(initParams);
_positiveSpawnCount = 0;
_negativeSpawnCount = 0;
}
/// <summary>
/// Override base prefab selection to implement positive/negative ratio management.
/// Uses actual spawn counts to maintain target ratio over time.
/// </summary>
protected override GameObject SelectPrefabFromPools(out int selectedPoolIndex)
{
selectedPoolIndex = -1;
// Determine which pool (positive or negative) based on ratio
int targetPoolIndex = DeterminePoolIndexByRatio();
if (targetPoolIndex < 0 || targetPoolIndex >= pools.Length || !pools[targetPoolIndex].HasPrefabs)
{
if (settings.ShowDebugLogs)
{
Logging.Warning($"[ObstacleDistanceSpawner] Target pool {targetPoolIndex} has no prefabs!");
}
return null;
}
// Get all prefabs from the target pool
List<GameObject> availablePrefabs = new List<GameObject>();
List<float> prefabWeights = new List<float>();
foreach (GameObject prefab in pools[targetPoolIndex].prefabs)
{
if (prefab == null) continue;
availablePrefabs.Add(prefab);
// Calculate weight based on recency tracking
float weight = 1f;
if (LastUsedTimes.TryGetValue(prefab, out float lastUsedTime))
{
float timeSinceUse = GameTime - lastUsedTime;
if (timeSinceUse < settings.RecencyPenaltyDuration)
{
weight = timeSinceUse / settings.RecencyPenaltyDuration; // 0 to 1 linear recovery
}
}
prefabWeights.Add(weight);
}
if (availablePrefabs.Count == 0) return null;
// Select using weighted random (respects recency)
int selectedIndex = WeightedRandom(prefabWeights);
GameObject selectedPrefab = availablePrefabs[selectedIndex];
selectedPoolIndex = targetPoolIndex;
// Update recency tracking
LastUsedTimes[selectedPrefab] = GameTime;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ObstacleDistanceSpawner] Selected {(targetPoolIndex == 0 ? "positive" : "negative")} prefab from pool {targetPoolIndex}");
}
return selectedPrefab;
}
/// <summary>
/// Determines which pool (positive or negative) to spawn from based on target ratio.
/// Uses actual spawn counts to push toward target ratio over time.
/// </summary>
private int DeterminePoolIndexByRatio()
{
int totalSpawned = _positiveSpawnCount + _negativeSpawnCount;
float targetPositiveRatio = settings.PositiveNegativeRatio;
bool shouldSpawnPositive;
// First spawn - use ratio as pure probability
if (totalSpawned == 0)
{
shouldSpawnPositive = Random.value < targetPositiveRatio;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ObstacleDistanceSpawner] First spawn - ratio {targetPositiveRatio:P0} → {(shouldSpawnPositive ? "positive" : "negative")}");
}
}
else
{
// Calculate current ratio vs target
float currentPositiveRatio = (float)_positiveSpawnCount / totalSpawned;
float difference = targetPositiveRatio - currentPositiveRatio;
// Adjust probability based on how far we are from target
// If difference > 0: we need more positives, increase positive chance
// If difference < 0: we need more negatives, decrease positive chance
float adjustedProbability = Mathf.Clamp01(targetPositiveRatio + difference);
shouldSpawnPositive = Random.value < adjustedProbability;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ObstacleDistanceSpawner] Ratio tracking: {_positiveSpawnCount}pos/{_negativeSpawnCount}neg ({currentPositiveRatio:P0} current vs {targetPositiveRatio:P0} target) → adjusted probability {adjustedProbability:P0} → {(shouldSpawnPositive ? "positive" : "negative")}");
}
}
return shouldSpawnPositive ? 0 : 1;
}
protected override void SpawnFromPool(int poolIndex, float xPosition)
{
if (poolIndex < 0 || poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs)
if (poolIndex < 0 || poolIndex >= 2 || !pools[poolIndex].HasPrefabs)
return;
// Determine if this should spawn positive or negative
bool isPositivePool = System.Array.IndexOf(positivePoolIndices, poolIndex) >= 0;
bool isNegativePool = System.Array.IndexOf(negativePoolIndices, poolIndex) >= 0;
// If pool is not specifically marked, use weighted random
bool spawnPositive = isPositivePool || (!isNegativePool && ShouldSpawnPositive());
// Pool index directly determines positive/negative
// Pool 0 = Positive, Pool 1 = Negative
bool spawnPositive = (poolIndex == 0);
// Select random prefab from pool
GameObject prefab = pools[poolIndex].prefabs[Random.Range(0, pools[poolIndex].prefabs.Length)];
if (prefab == null) return;
// Get spawn position from spawnPoint or use default
float spawnY = spawnPoint != null ? spawnPoint.position.y : 0f;
Vector3 tempPosition = new Vector3(xPosition, spawnY, 0f);
// Temporary spawn position (Y will be adjusted by PositionObject)
Vector3 tempPosition = new Vector3(xPosition, 0f, 0f);
// Instantiate
GameObject instance = InstantiatePrefab(prefab, tempPosition);
// Try to get spawn entry for positioning
// Try to get spawn entry for positioning (per-prefab override)
var spawnEntry = prefab.GetComponent<PrefabSpawnEntryComponent>();
if (spawnEntry != null)
{
// Use per-prefab configuration
PositionObject(instance, xPosition, spawnEntry.spawnPositionMode,
spawnEntry.specifiedY, spawnEntry.randomYMin, spawnEntry.randomYMax);
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ObstacleDistanceSpawner] Using per-prefab positioning: {spawnEntry.spawnPositionMode}");
}
}
else
{
// Fall back to global default configuration from settings
PositionObject(instance, xPosition, settings.DefaultObstaclePositionMode,
settings.FallbackYPosition, settings.DefaultObstacleRandomYMin, settings.DefaultObstacleRandomYMax);
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ObstacleDistanceSpawner] Using default positioning from settings: {settings.DefaultObstaclePositionMode}");
}
}
// Initialize if implements interface
@@ -81,100 +193,12 @@ namespace Minigames.Airplane.Core.Spawning
else
_negativeSpawnCount++;
if (showDebugLogs)
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ObstacleDistanceSpawner] Spawned {(spawnPositive ? "positive" : "negative")} at X={xPosition:F2} from pool {poolIndex}");
}
}
private bool ShouldSpawnPositive()
{
int totalSpawned = _positiveSpawnCount + _negativeSpawnCount;
// First few spawns - use pure random
if (totalSpawned < 5)
{
return Random.value <= positiveNegativeRatio;
}
// Calculate current ratio and adjust
float currentRatio = totalSpawned > 0 ? (float)_positiveSpawnCount / totalSpawned : 0.5f;
float targetRatio = positiveNegativeRatio;
float adjustedProbability;
if (currentRatio < targetRatio)
{
adjustedProbability = Mathf.Lerp(targetRatio, 1f, (targetRatio - currentRatio) * 2f);
}
else
{
adjustedProbability = Mathf.Lerp(0f, targetRatio, 1f - (currentRatio - targetRatio) * 2f);
}
return Random.value <= adjustedProbability;
}
private void PositionObject(GameObject obj, float xPosition, SpawnPositionMode mode,
float specifiedY, float randomYMin, float randomYMax)
{
if (obj == null) return;
float targetY;
switch (mode)
{
case SpawnPositionMode.SnapToGround:
targetY = SnapToGround(obj, xPosition);
break;
case SpawnPositionMode.SpecifiedY:
targetY = specifiedY;
break;
case SpawnPositionMode.RandomRange:
targetY = Random.Range(randomYMin, randomYMax);
break;
default:
targetY = 0f;
break;
}
Vector3 newPosition = obj.transform.position;
newPosition.y = targetY;
obj.transform.position = newPosition;
}
private float SnapToGround(GameObject obj, float xPosition)
{
Vector2 rayOrigin = new Vector2(xPosition, 0f);
int layerMask = 1 << groundLayer;
RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.down, maxGroundRaycastDistance, layerMask);
if (hit.collider != null)
{
float groundY = hit.point.y;
Bounds bounds = GetObjectBounds(obj);
return groundY + bounds.extents.y;
}
return defaultObjectYOffset;
}
private Bounds GetObjectBounds(GameObject obj)
{
Renderer objRenderer = obj.GetComponentInChildren<Renderer>();
if (objRenderer != null) return objRenderer.bounds;
Collider2D objCollider2D = obj.GetComponentInChildren<Collider2D>();
if (objCollider2D != null) return objCollider2D.bounds;
Collider objCollider3D = obj.GetComponentInChildren<Collider>();
if (objCollider3D != null) return objCollider3D.bounds;
return new Bounds(obj.transform.position, Vector3.one);
}
public override void Cleanup()
{
base.Cleanup();
@@ -182,18 +206,5 @@ namespace Minigames.Airplane.Core.Spawning
_negativeSpawnCount = 0;
}
}
/// <summary>
/// Helper component to store spawn entry data on prefabs.
/// Attach this to prefabs that need specific positioning.
/// </summary>
public class PrefabSpawnEntryComponent : MonoBehaviour
{
public SpawnPositionMode spawnPositionMode = SpawnPositionMode.SnapToGround;
public float specifiedY;
public float randomYMin = -5f;
public float randomYMax = 5f;
}
}

View File

@@ -24,7 +24,7 @@ namespace Minigames.Airplane.Core.Spawning
private Transform _currentCameraTransform;
public override void Initialize()
public override void Initialize(SpawnInitParameters initParams)
{
// Force exclusive mode
poolMode = SpawnPoolMode.Exclusive;
@@ -35,7 +35,7 @@ namespace Minigames.Airplane.Core.Spawning
Logging.Error("[ParallaxBackgroundSpawner] Must have exactly 3 pools (Background, Middle, Foreground)!");
}
base.Initialize();
base.Initialize(initParams);
// Subscribe to camera changes
if (cameraManager != null)
@@ -76,7 +76,7 @@ namespace Minigames.Airplane.Core.Spawning
}
}
if (showDebugLogs)
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ParallaxBackgroundSpawner] Camera changed to {newState}, updated parallax elements");
}
@@ -91,9 +91,8 @@ namespace Minigames.Airplane.Core.Spawning
GameObject prefab = pools[poolIndex].prefabs[Random.Range(0, pools[poolIndex].prefabs.Length)];
if (prefab == null) return;
// Get spawn Y from spawnPoint or default
float spawnY = spawnPoint != null ? spawnPoint.position.y : 0f;
Vector3 spawnPosition = new Vector3(xPosition, spawnY, 0f);
// Spawn at temporary position (Y will be adjusted by PositionObject if component exists)
Vector3 spawnPosition = new Vector3(xPosition, 0f, 0f);
// Instantiate
GameObject instance = InstantiatePrefab(prefab, spawnPosition);
@@ -115,7 +114,16 @@ namespace Minigames.Airplane.Core.Spawning
// Set sort layer on sprite renderer
SetSortLayer(instance, layer);
if (showDebugLogs)
// Try to get spawn entry for Y positioning (same logic as obstacles)
var spawnEntry = prefab.GetComponent<PrefabSpawnEntryComponent>();
if (spawnEntry != null)
{
PositionObject(instance, xPosition, spawnEntry.spawnPositionMode,
spawnEntry.specifiedY, spawnEntry.randomYMin, spawnEntry.randomYMax);
}
// Otherwise stays at Y=0
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ParallaxBackgroundSpawner] Spawned {layer} element at X={xPosition:F2}");
}
@@ -136,7 +144,7 @@ namespace Minigames.Airplane.Core.Spawning
spriteRenderer.sortingLayerName = sortLayerName;
if (showDebugLogs)
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ParallaxBackgroundSpawner] Set sort layer '{sortLayerName}' for {layer}");
}

View File

@@ -0,0 +1,27 @@
using Minigames.Airplane.Data;
using UnityEngine;
namespace Minigames.Airplane.Data
{
/// <summary>
/// Component to store spawn positioning data on prefabs.
/// Attach this to obstacle prefabs that need specific positioning behavior.
/// If not present, spawner will use default positioning from AirplaneSettings.
/// </summary>
[AddComponentMenu("Airplane/Prefab Spawn Entry")]
public class PrefabSpawnEntryComponent : MonoBehaviour
{
[Tooltip("How to position this object vertically")]
public SpawnPositionMode spawnPositionMode = SpawnPositionMode.SnapToGround;
[Tooltip("Y position to use (when mode is SpecifiedY)")]
public float specifiedY;
[Tooltip("Min Y for random range (when mode is RandomRange)")]
public float randomYMin = -5f;
[Tooltip("Max Y for random range (when mode is RandomRange)")]
public float randomYMax = 5f;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 90239fb003214b4087d0717f6f417161
timeCreated: 1765990367

View File

@@ -0,0 +1,32 @@
using Core.Settings;
using UnityEngine;
namespace Minigames.Airplane.Data
{
/// <summary>
/// Parameters passed from AirplaneSpawnManager to spawners during initialization.
/// Encapsulates shared references and configuration.
/// </summary>
public class SpawnInitParameters
{
/// <summary>
/// Parent transform for spawned objects (organization)
/// </summary>
public Transform SpawnContainer { get; set; }
/// <summary>
/// Settings reference for spawn configuration
/// </summary>
public IAirplaneSettings Settings { get; set; }
/// <summary>
/// Create spawn initialization parameters
/// </summary>
public SpawnInitParameters(Transform spawnContainer, IAirplaneSettings settings)
{
SpawnContainer = spawnContainer;
Settings = settings;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: eb788e2f6d204a5fb1b2ae79ed38e7c2
timeCreated: 1765972065

View File

@@ -107,6 +107,9 @@ namespace Minigames.Airplane.Settings
[Tooltip("Distance interval for ground tile spawning")]
[SerializeField] private float groundSpawnInterval = 5f;
[Tooltip("Time penalty (seconds) applied to recently-spawned prefabs for diversity")]
[SerializeField] private float recencyPenaltyDuration = 10f;
[Header("Ground Snapping")]
[Tooltip("Layer for ground detection (objects will snap to this)")]
[Layer]
@@ -115,8 +118,24 @@ namespace Minigames.Airplane.Settings
[Tooltip("Maximum distance to raycast for ground")]
[SerializeField] private float maxGroundRaycastDistance = 50f;
[Tooltip("Default Y offset for objects if no ground found")]
[SerializeField] private float defaultObjectYOffset = 0f;
[Tooltip("Fallback Y position (used when SnapToGround fails OR when using SpecifiedY positioning mode)")]
[SerializeField] private float fallbackYPosition = 0f;
[Tooltip("Y position at which to spawn ground tiles")]
[SerializeField] private float groundSpawnY = -18f;
[Header("Default Obstacle Positioning")]
[Tooltip("Default mode for obstacle positioning")]
[SerializeField] private Data.SpawnPositionMode defaultObstaclePositionMode = Data.SpawnPositionMode.SnapToGround;
[Tooltip("Default Y position for obstacles (if specified)")]
[SerializeField] private float defaultObstacleSpecifiedY = -10f;
[Tooltip("Minimum random Y position for obstacles (if random range used)")]
[SerializeField] private float defaultObstacleRandomYMin = -5;
[Tooltip("Maximum random Y position for obstacles (if random range used)")]
[SerializeField] private float defaultObstacleRandomYMax = 5;
[Header("Debug")]
[Tooltip("Show debug logs in console")]
@@ -155,9 +174,14 @@ namespace Minigames.Airplane.Settings
public float PositiveNegativeRatio => positiveNegativeRatio;
public float SpawnDistanceAhead => spawnDistanceAhead;
public float GroundSpawnInterval => groundSpawnInterval;
public float RecencyPenaltyDuration => recencyPenaltyDuration;
public int GroundLayer => groundLayer;
public float MaxGroundRaycastDistance => maxGroundRaycastDistance;
public float DefaultObjectYOffset => defaultObjectYOffset;
public float FallbackYPosition => fallbackYPosition;
public float GroundSpawnY => groundSpawnY;
public Data.SpawnPositionMode DefaultObstaclePositionMode => defaultObstaclePositionMode;
public float DefaultObstacleRandomYMin => defaultObstacleRandomYMin;
public float DefaultObstacleRandomYMax => defaultObstacleRandomYMax;
public bool ShowDebugLogs => showDebugLogs;
#endregion