diff --git a/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity b/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity
index 3fa7828d..e20e43f2 100644
--- a/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity
+++ b/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity
@@ -2457,6 +2457,37 @@ MonoBehaviour:
trajectoryPreview: {fileID: 1309397783}
showDebugLogs: 0
airplanePrefab: {fileID: 2043346932243838886, guid: 582ed0c37f4ec6c4e930ddabea174eca, type: 3}
+--- !u!1 &1405572708
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1405572709}
+ m_Layer: 0
+ m_Name: SpawnContainer
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &1405572709
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1405572708}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 1784207402}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1001 &1414189130
PrefabInstance:
m_ObjectHideFlags: 0
@@ -3702,6 +3733,7 @@ GameObject:
m_Component:
- component: {fileID: 1784207402}
- component: {fileID: 1784207403}
+ - component: {fileID: 1784207404}
m_Layer: 0
m_Name: SpawnManager
m_TagString: Untagged
@@ -3724,6 +3756,7 @@ Transform:
m_Children:
- {fileID: 1219431443}
- {fileID: 1287102785}
+ - {fileID: 1405572709}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1784207403
@@ -3738,6 +3771,9 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 70f14ee4b04b46b793ec2652fd2ca7b9, type: 3}
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::Minigames.Airplane.Core.AirplaneSpawnManager
+ obstacleSpawner: {fileID: 0}
+ groundSpawner: {fileID: 0}
+ parallaxSpawner: {fileID: 0}
targetPrefabs:
- targetKey: bob_target
prefab: {fileID: 3207629437433571205, guid: 9f4bb48933059e543b60ac782d2140d8, type: 3}
@@ -3751,42 +3787,37 @@ MonoBehaviour:
specifiedY: 0
randomYMin: -5
randomYMax: 5
- positiveObjectPrefabs:
- - prefab: {fileID: 497267990420767357, guid: dc32284941c693c4f9e422f4197ca61e, type: 3}
- spawnPositionMode: 2
- specifiedY: 0
- randomYMin: 3
- randomYMax: 40
- - prefab: {fileID: 1917678391913987792, guid: 989121c9099e41e469824ddeaf0e34a5, type: 3}
- spawnPositionMode: 0
- specifiedY: 0
- randomYMin: 5
- randomYMax: 20
- - prefab: {fileID: 7032677151789119314, guid: 7dc33e43acead834ba6a231b67cfd2d9, type: 3}
- spawnPositionMode: 2
- specifiedY: 0
- randomYMin: 3
- randomYMax: 40
- negativeObjectPrefabs:
- - prefab: {fileID: 1186710456879913970, guid: 006b956651124704dbae5bd4faab3152, type: 3}
- spawnPositionMode: 2
- specifiedY: 0
- randomYMin: 3
- randomYMax: 40
- - prefab: {fileID: 2434350760695575337, guid: f3188909ff4e845499a5cbfd0ae93101, type: 3}
- spawnPositionMode: 2
- specifiedY: 0
- randomYMin: 3
- randomYMax: 40
- groundTilePrefabs:
- - {fileID: 5175967588203935335, guid: a9b4569fcc08080479d99b9c3bcee089, type: 3}
targetDisplayUI: {fileID: 1520040329}
launchController: {fileID: 1309397785}
dynamicSpawnThresholdMarker: {fileID: 1653173574}
- spawnedObjectsParent: {fileID: 1219431443}
- groundTilesParent: {fileID: 1287102785}
- groundSpawnY: -5
+ targetParent: {fileID: 1405572709}
showDebugLogs: 0
+--- !u!114 &1784207404
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1784207401}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 0a550c256a3d4d91a17adf6d0338f022, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: AppleHillsScripts::Minigames.Airplane.Core.Spawning.ObstacleDistanceSpawner
+ poolMode: 0
+ pools: []
+ globalMinDistance: 5
+ globalMaxDistance: 15
+ spawnPoint: {fileID: 0}
+ spawnContainer: {fileID: 0}
+ recencyPenaltyDuration: 10
+ showDebugLogs: 0
+ positiveNegativeRatio: 0.5
+ positivePoolIndices:
+ negativePoolIndices:
+ groundLayer: 0
+ maxGroundRaycastDistance: 50
+ defaultObjectYOffset: 0
--- !u!1 &1810521056
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs
index 97138e3d..791b2efb 100644
--- a/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs
@@ -71,6 +71,35 @@ namespace Minigames.Airplane.Core
#endregion
+ #region Camera Tracking API
+
+ ///
+ /// Get the transform of the currently active camera.
+ /// Used by parallax elements to track camera movement.
+ ///
+ public Transform GetActiveCameraTransform()
+ {
+ var activeCamera = GetCamera(CurrentState);
+ return activeCamera != null ? activeCamera.transform : null;
+ }
+
+ ///
+ /// Override to fire additional state changed events.
+ ///
+ public override void SwitchToState(AirplaneCameraState newState)
+ {
+ AirplaneCameraState oldState = CurrentState;
+ base.SwitchToState(newState);
+
+ // Base class already fires OnStateChanged event, we just add logging
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[AirplaneCameraManager] Camera state changed: {oldState} -> {newState}");
+ }
+ }
+
+ #endregion
+
#region Flight Camera Follow
///
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs
index c05c04ca..e1a4f12a 100644
--- a/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs
@@ -459,6 +459,8 @@ namespace Minigames.Airplane.Core
if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] Introducing {_currentPerson.PersonName}...");
+ if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] Checking camera state... Current: {(cameraManager != null ? cameraManager.CurrentState.ToString() : "NULL")}");
+
// Blend to next person camera if not already there
// (Person shuffle flow already blends to NextPerson, so might already be there)
if (cameraManager != null && cameraManager.CurrentState != AirplaneCameraState.NextPerson)
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs
index 31ad5395..11208938 100644
--- a/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs
@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
-using AppleHills.Core.Settings;
using Core;
using Core.Lifecycle;
using Core.Settings;
+using Minigames.Airplane.Core.Spawning;
using Minigames.Airplane.Data;
using Minigames.Airplane.UI;
using UnityEngine;
@@ -12,8 +12,9 @@ using Random = UnityEngine.Random;
namespace Minigames.Airplane.Core
{
///
- /// Manages dynamic spawning of targets, positive/negative objects, and ground tiles
- /// as the airplane moves through the level.
+ /// Orchestrates spawning for the airplane minigame.
+ /// Manages target spawning and coordinates specialized spawners (obstacles, ground, parallax).
+ /// Tracks game time and propagates to spawners for pool unlocking.
///
public class AirplaneSpawnManager : ManagedBehaviour
{
@@ -32,7 +33,7 @@ namespace Minigames.Airplane.Core
public SpawnPositionMode spawnPositionMode = SpawnPositionMode.SnapToGround;
[Tooltip("Y position to use (SpecifiedY mode)")]
- public float specifiedY = 0f;
+ public float specifiedY;
[Tooltip("Min Y for random range (RandomRange mode)")]
public float randomYMin = -5f;
@@ -45,19 +46,20 @@ namespace Minigames.Airplane.Core
#region Inspector References
- [Header("Prefab References")]
+ [Header("Spawner References")]
+ [Tooltip("Obstacle spawner (positive/negative objects)")]
+ [SerializeField] private ObstacleDistanceSpawner obstacleSpawner;
+
+ [Tooltip("Ground tile spawner")]
+ [SerializeField] private GroundDistanceSpawner groundSpawner;
+
+ [Tooltip("Parallax background spawner (optional)")]
+ [SerializeField] private ParallaxBackgroundSpawner parallaxSpawner;
+
+ [Header("Target Configuration")]
[Tooltip("Dictionary of target prefabs (key = target name)")]
[SerializeField] private TargetPrefabEntry[] targetPrefabs;
- [Tooltip("Array of positive object prefabs with spawn configuration")]
- [SerializeField] private PrefabSpawnEntry[] positiveObjectPrefabs;
-
- [Tooltip("Array of negative object prefabs with spawn configuration")]
- [SerializeField] private PrefabSpawnEntry[] negativeObjectPrefabs;
-
- [Tooltip("Array of ground tile prefabs")]
- [SerializeField] private GameObject[] groundTilePrefabs;
-
[Header("UI")]
[Tooltip("Target display UI component")]
[SerializeField] private TargetDisplayUI targetDisplayUI;
@@ -67,19 +69,12 @@ namespace Minigames.Airplane.Core
[SerializeField] private AirplaneLaunchController launchController;
[Header("Spawn Threshold")]
- [Tooltip("Transform marker in scene where dynamic spawning begins (uses X position). If null, uses fallback from settings.")]
+ [Tooltip("Transform marker in scene where dynamic spawning begins (uses X position)")]
[SerializeField] private Transform dynamicSpawnThresholdMarker;
[Header("Spawn Parents")]
- [Tooltip("Parent transform for spawned objects (optional, for organization)")]
- [SerializeField] private Transform spawnedObjectsParent;
-
- [Tooltip("Parent transform for ground tiles (optional)")]
- [SerializeField] private Transform groundTilesParent;
-
- [Header("Ground Settings")]
- [Tooltip("Y position at which to spawn ground tiles")]
- [SerializeField] private float groundSpawnY = -18f;
+ [Tooltip("Parent transform for spawned target (optional)")]
+ [SerializeField] private Transform targetParent;
[Header("Debug")]
[SerializeField] private bool showDebugLogs;
@@ -101,15 +96,10 @@ namespace Minigames.Airplane.Core
// Plane tracking
private Transform _planeTransform;
private bool _isSpawningActive;
+ private float _gameTime;
// Spawning positions (distance-based)
- private float _lastSpawnedX; // Tracks the furthest forward X position that has been pre-spawned
- private float _nextObjectSpawnX;
- private float _nextGroundSpawnX;
-
- // Spawn statistics (for weighted ratio adjustment)
- private int _positiveSpawnCount;
- private int _negativeSpawnCount;
+ private float _lastSpawnedX;
// Adaptive spawn distance (persistent across retries)
private float _furthestReachedX;
@@ -135,42 +125,23 @@ namespace Minigames.Airplane.Core
// Validate references
ValidateReferences();
+
+ // Initialize all spawners
+ InitializeSpawners();
}
private void Update()
{
if (!_isSpawningActive || _planeTransform == null) return;
- float planeX = _planeTransform.position.x;
+ float deltaTime = Time.deltaTime;
+ _gameTime += deltaTime;
- // Track furthest X position reached
- if (planeX > _furthestReachedX)
- {
- _furthestReachedX = planeX;
- }
+ // Update spawner game times for pool unlocking
+ UpdateSpawnerTimes(deltaTime);
- // Check if plane has reached the point where we need to continue spawning beyond pre-spawned content
- // Only spawn new content if plane is beyond previous furthest point (for retries)
- bool shouldSpawnNewContent = !_isRetryAttempt || planeX > (_furthestReachedX - _settings.SpawnDistanceAhead);
-
- if (shouldSpawnNewContent)
- {
- // Continue spawning objects when plane approaches the last spawned position
- float spawnTriggerX = _lastSpawnedX + _settings.SpawnDistanceAhead;
- if (planeX >= spawnTriggerX && planeX >= _nextObjectSpawnX)
- {
- SpawnRandomObject();
- ScheduleNextObjectSpawn(planeX);
- }
-
- // Continue spawning ground tiles ahead of plane
- float groundSpawnTargetX = planeX + GetGroundSpawnAheadDistance();
- while (_nextGroundSpawnX < groundSpawnTargetX)
- {
- SpawnGroundTile();
- _nextGroundSpawnX += _settings.GroundSpawnInterval;
- }
- }
+ // Update dynamic spawning
+ UpdateDynamicSpawning();
}
#endregion
@@ -180,7 +151,6 @@ namespace Minigames.Airplane.Core
///
/// Initialize the spawn system for a new game.
/// Determines target spawn position and sets up UI, but doesn't spawn target yet.
- /// Target will spawn when plane gets within spawn distance.
///
/// Key of the target to spawn
/// True if this is a retry attempt (keeps existing spawned objects and target position)
@@ -189,13 +159,12 @@ namespace Minigames.Airplane.Core
_currentTargetKey = targetKey;
_isSpawningActive = false;
_isRetryAttempt = isRetry;
+ _gameTime = 0f;
// Only reset target and spawn state if NOT a retry
if (!isRetry)
{
_hasSpawnedTarget = false;
- _positiveSpawnCount = 0;
- _negativeSpawnCount = 0;
_furthestReachedX = 0f;
// Determine NEW target spawn distance
@@ -204,7 +173,7 @@ namespace Minigames.Airplane.Core
if (showDebugLogs)
{
- Logging.Debug($"[SpawnManager] Initialized NEW turn for target '{targetKey}' at distance {_targetDistance:F2}");
+ Logging.Debug($"[AirplaneSpawnManager] Initialized NEW turn for target '{targetKey}' at distance {_targetDistance:F2}");
}
}
else
@@ -212,14 +181,14 @@ namespace Minigames.Airplane.Core
// Retry: Keep existing target position and spawned objects
if (showDebugLogs)
{
- Logging.Debug($"[SpawnManager] Initialized RETRY for target '{targetKey}' at distance {_targetDistance:F2}, furthest reached: {_furthestReachedX:F2}");
+ Logging.Debug($"[AirplaneSpawnManager] Initialized RETRY for target '{targetKey}' at distance {_targetDistance:F2}, furthest reached: {_furthestReachedX:F2}");
}
}
// Find target entry and extract icon WITHOUT spawning
if (!_targetPrefabDict.TryGetValue(_currentTargetKey, out _currentTargetEntry))
{
- Logging.Error($"[SpawnManager] Target prefab not found for key '{_currentTargetKey}'!");
+ Logging.Error($"[AirplaneSpawnManager] Target prefab not found for key '{_currentTargetKey}'!");
return;
}
@@ -241,7 +210,7 @@ namespace Minigames.Airplane.Core
{
if (_targetPrefabToSpawn == null)
{
- Logging.Error("[SpawnManager] Cannot pre-spawn - target prefab not initialized! Call InitializeForGame first.");
+ Logging.Error("[AirplaneSpawnManager] Cannot pre-spawn - target prefab not initialized! Call InitializeForGame first.");
return;
}
@@ -249,14 +218,14 @@ namespace Minigames.Airplane.Core
{
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Target already spawned, skipping pre-spawn (retry scenario)");
+ Logging.Debug("[AirplaneSpawnManager] Target already spawned, skipping pre-spawn (retry scenario)");
}
return;
}
if (dynamicSpawnThresholdMarker == null)
{
- Logging.Error("[SpawnManager] Cannot pre-spawn - dynamicSpawnThresholdMarker not assigned!");
+ Logging.Error("[AirplaneSpawnManager] Cannot pre-spawn - dynamicSpawnThresholdMarker not assigned!");
return;
}
@@ -266,57 +235,37 @@ namespace Minigames.Airplane.Core
if (showDebugLogs)
{
- Logging.Debug($"[SpawnManager] Pre-spawning level from X={preSpawnStartX:F2} (threshold) to X={preSpawnEndX:F2} (target={_targetDistance:F2} + buffer={_settings.PreSpawnBeyondTargetDistance:F2})");
+ Logging.Debug($"[AirplaneSpawnManager] Pre-spawning level from X={preSpawnStartX:F2} to X={preSpawnEndX:F2}");
}
- // 1. Spawn ground tiles FIRST across entire range (so target can raycast)
- float currentGroundX = preSpawnStartX;
- while (currentGroundX <= preSpawnEndX)
+ // 1. Spawn ground tiles FIRST (so objects can raycast to them)
+ if (groundSpawner != null)
{
- SpawnGroundTileAt(currentGroundX);
- currentGroundX += _settings.GroundSpawnInterval;
+ groundSpawner.PreSpawn(preSpawnStartX, preSpawnEndX);
}
- // Set next ground spawn position beyond pre-spawn range
- _nextGroundSpawnX = preSpawnEndX + _settings.GroundSpawnInterval;
-
- if (showDebugLogs)
+ // 2. Spawn parallax background (if assigned)
+ if (parallaxSpawner != null)
{
- Logging.Debug($"[SpawnManager] Ground tiles spawned, now spawning objects");
+ parallaxSpawner.PreSpawn(preSpawnStartX, preSpawnEndX);
}
- // 2. Spawn objects across entire range (skipping near target)
- float currentObjectX = preSpawnStartX + Random.Range(_settings.ObjectSpawnMinDistance, _settings.ObjectSpawnMaxDistance);
-
- while (currentObjectX <= preSpawnEndX)
+ // 3. Spawn obstacles (after ground exists)
+ if (obstacleSpawner != null)
{
- // Spawn object at this position
- SpawnRandomObjectAt(currentObjectX);
-
- // Move to next spawn position (forward)
- float spawnDistance = Random.Range(_settings.ObjectSpawnMinDistance, _settings.ObjectSpawnMaxDistance);
- currentObjectX += spawnDistance;
+ obstacleSpawner.PreSpawn(preSpawnStartX, preSpawnEndX);
}
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Objects spawned, now spawning target with raycast");
- }
-
- // 3. Spawn the target LAST (after ground exists for proper raycasting)
+ // 4. Spawn the target LAST
SpawnTarget();
_hasSpawnedTarget = true;
- // 4. Store the furthest forward pre-spawn position as _lastSpawnedX
+ // 5. Store the furthest forward pre-spawn position
_lastSpawnedX = preSpawnEndX;
- // 5. Schedule next object spawn beyond the pre-spawn range
- _nextObjectSpawnX = preSpawnEndX + Random.Range(_settings.ObjectSpawnMinDistance, _settings.ObjectSpawnMaxDistance);
-
if (showDebugLogs)
{
- Logging.Debug($"[SpawnManager] Pre-spawn complete! Last spawned X={_lastSpawnedX:F2}, next object at X={_nextObjectSpawnX:F2}");
- Logging.Debug($"[SpawnManager] Spawned {_positiveSpawnCount} positive and {_negativeSpawnCount} negative objects");
+ Logging.Debug($"[AirplaneSpawnManager] Pre-spawn complete! Last spawned X={_lastSpawnedX:F2}");
}
}
@@ -328,9 +277,25 @@ namespace Minigames.Airplane.Core
{
_planeTransform = planeTransform;
_isSpawningActive = true;
+ _gameTime = 0f;
- // Initialize ground spawning position ahead of plane
- _nextGroundSpawnX = _planeTransform.position.x + GetGroundSpawnAheadDistance();
+ // 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)
@@ -339,13 +304,13 @@ namespace Minigames.Airplane.Core
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] UI tracking started, distance updates will show");
+ Logging.Debug("[AirplaneSpawnManager] UI tracking started");
}
}
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Started tracking airplane");
+ Logging.Debug("[AirplaneSpawnManager] Started tracking airplane");
}
}
@@ -357,6 +322,22 @@ 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)
{
@@ -365,31 +346,28 @@ namespace Minigames.Airplane.Core
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Stopped tracking");
+ Logging.Debug("[AirplaneSpawnManager] Stopped tracking");
}
}
///
/// Show the target display UI.
- /// Call when entering aiming state.
///
public void ShowTargetUI()
{
if (targetDisplayUI != null)
{
- // Update distance and show UI (refreshes distance from launch point if plane not launched yet)
targetDisplayUI.UpdateAndShow();
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Target UI shown with updated distance");
+ Logging.Debug("[AirplaneSpawnManager] Target UI shown");
}
}
}
///
/// Hide the target display UI.
- /// Call when target is successfully hit.
///
public void HideTargetUI()
{
@@ -399,55 +377,52 @@ namespace Minigames.Airplane.Core
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Target UI hidden");
+ Logging.Debug("[AirplaneSpawnManager] Target UI hidden");
}
}
}
///
- /// Clean up all spawned objects (call on successful shot or game restart).
- /// Destroys all spawned content including target, objects, and ground tiles.
+ /// Clean up all spawned objects.
///
public void CleanupSpawnedObjects()
{
- if (spawnedObjectsParent != null)
+ // Cleanup spawners
+ if (obstacleSpawner != null)
{
- foreach (Transform child in spawnedObjectsParent)
- {
- Destroy(child.gameObject);
- }
+ obstacleSpawner.Cleanup();
}
- if (groundTilesParent != null)
+ if (groundSpawner != null)
{
- foreach (Transform child in groundTilesParent)
- {
- Destroy(child.gameObject);
- }
+ groundSpawner.Cleanup();
}
+ if (parallaxSpawner != null)
+ {
+ parallaxSpawner.Cleanup();
+ }
+
+ // Cleanup target
if (_spawnedTarget != null)
{
Destroy(_spawnedTarget);
_spawnedTarget = null;
}
- // Reset all spawn state
+ // Reset state
_hasSpawnedTarget = false;
_furthestReachedX = 0f;
- _positiveSpawnCount = 0;
- _negativeSpawnCount = 0;
+ _gameTime = 0f;
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Full cleanup completed (success or game restart)");
+ Logging.Debug("[AirplaneSpawnManager] Full cleanup completed");
}
}
///
/// Clean up level based on shot result.
- /// Success: Full cleanup (destroys all spawned objects).
- /// Failure: Reset for retry (keeps spawned objects, resets tracking).
///
public void CleanupLevel(bool success)
{
@@ -457,7 +432,7 @@ namespace Minigames.Airplane.Core
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Level cleanup: SUCCESS - destroyed all objects");
+ Logging.Debug("[AirplaneSpawnManager] Level cleanup: SUCCESS");
}
}
else
@@ -466,23 +441,19 @@ namespace Minigames.Airplane.Core
if (showDebugLogs)
{
- Logging.Debug("[SpawnManager] Level cleanup: FAILURE - kept objects for retry");
+ Logging.Debug("[AirplaneSpawnManager] Level cleanup: FAILURE - kept objects for retry");
}
}
}
///
- /// Reset tracking state for retry attempt (keeps spawned objects).
- /// Call this when player fails and will retry the same shot.
+ /// Reset tracking state for retry attempt.
///
public void ResetForRetry()
{
- // Don't destroy anything - keep all spawned objects and target
- // Just reset the tracking state so spawning can continue if plane goes further
-
if (showDebugLogs)
{
- Logging.Debug($"[SpawnManager] Reset for retry (keeping spawned objects, furthest reached: {_furthestReachedX:F2})");
+ Logging.Debug($"[AirplaneSpawnManager] Reset for retry (furthest reached: {_furthestReachedX:F2})");
}
}
@@ -496,7 +467,6 @@ namespace Minigames.Airplane.Core
///
/// Get the spawned target's transform for camera tracking.
- /// Returns null if target hasn't been spawned yet.
///
public Transform GetSpawnedTargetTransform()
{
@@ -505,6 +475,87 @@ namespace Minigames.Airplane.Core
#endregion
+ #region Spawner Management
+
+ private void InitializeSpawners()
+ {
+ if (obstacleSpawner != null)
+ {
+ obstacleSpawner.Initialize();
+ }
+
+ if (groundSpawner != null)
+ {
+ groundSpawner.Initialize();
+ }
+
+ if (parallaxSpawner != null)
+ {
+ parallaxSpawner.Initialize();
+ }
+
+ if (showDebugLogs)
+ {
+ Logging.Debug("[AirplaneSpawnManager] All spawners initialized");
+ }
+ }
+
+ private void UpdateSpawnerTimes(float deltaTime)
+ {
+ if (obstacleSpawner != null)
+ {
+ obstacleSpawner.UpdateGameTime(deltaTime);
+ }
+
+ if (groundSpawner != null)
+ {
+ groundSpawner.UpdateGameTime(deltaTime);
+ }
+
+ if (parallaxSpawner != null)
+ {
+ parallaxSpawner.UpdateGameTime(deltaTime);
+ }
+ }
+
+ private void UpdateDynamicSpawning()
+ {
+ if (_planeTransform == null) return;
+
+ float planeX = _planeTransform.position.x;
+
+ // Track furthest X position reached
+ if (planeX > _furthestReachedX)
+ {
+ _furthestReachedX = planeX;
+ }
+
+ // Check if plane should trigger new spawning
+ bool shouldSpawnNewContent = !_isRetryAttempt || planeX > (_furthestReachedX - _settings.SpawnDistanceAhead);
+
+ if (shouldSpawnNewContent)
+ {
+ float spawnAheadDistance = _settings.SpawnDistanceAhead;
+
+ if (obstacleSpawner != null)
+ {
+ obstacleSpawner.UpdateSpawning(spawnAheadDistance);
+ }
+
+ if (groundSpawner != null)
+ {
+ groundSpawner.UpdateSpawning(spawnAheadDistance * 2f); // Ground spawns further ahead
+ }
+
+ if (parallaxSpawner != null)
+ {
+ parallaxSpawner.UpdateSpawning(spawnAheadDistance * 1.5f);
+ }
+ }
+ }
+
+ #endregion
+
#region Initialization
///
@@ -556,37 +607,37 @@ namespace Minigames.Airplane.Core
{
if (_settings == null)
{
- Logging.Error("[SpawnManager] Could not load IAirplaneSettings!");
+ Logging.Error("[AirplaneSpawnManager] Could not load IAirplaneSettings!");
}
- if (positiveObjectPrefabs == null || positiveObjectPrefabs.Length == 0)
+ if (obstacleSpawner == null)
{
- Logging.Warning("[SpawnManager] No positive object prefabs assigned!");
+ Logging.Warning("[AirplaneSpawnManager] Obstacle spawner not assigned!");
}
- if (negativeObjectPrefabs == null || negativeObjectPrefabs.Length == 0)
+ if (groundSpawner == null)
{
- Logging.Warning("[SpawnManager] No negative object prefabs assigned!");
+ Logging.Warning("[AirplaneSpawnManager] Ground spawner not assigned!");
}
- if (groundTilePrefabs == null || groundTilePrefabs.Length == 0)
+ if (parallaxSpawner == null)
{
- Logging.Warning("[SpawnManager] No ground tile prefabs assigned!");
+ Logging.Warning("[AirplaneSpawnManager] Parallax spawner not assigned (optional)");
}
if (targetDisplayUI == null)
{
- Logging.Warning("[SpawnManager] Target display UI not assigned!");
+ Logging.Warning("[AirplaneSpawnManager] Target display UI not assigned!");
}
if (launchController == null)
{
- Logging.Warning("[SpawnManager] Launch controller not assigned! Distance calculation will use world origin.");
+ Logging.Warning("[AirplaneSpawnManager] Launch controller not assigned!");
}
if (dynamicSpawnThresholdMarker == null)
{
- Logging.Warning("[SpawnManager] Dynamic spawn threshold marker not assigned! Pre-spawn will fail.");
+ Logging.Warning("[AirplaneSpawnManager] Dynamic spawn threshold marker not assigned!");
}
}
@@ -615,9 +666,9 @@ namespace Minigames.Airplane.Core
// Spawn target at determined position
_spawnedTarget = Instantiate(_currentTargetEntry.prefab, _targetSpawnPosition, Quaternion.identity);
- if (spawnedObjectsParent != null)
+ if (targetParent != null)
{
- _spawnedTarget.transform.SetParent(spawnedObjectsParent);
+ _spawnedTarget.transform.SetParent(targetParent);
}
// Position target using configured spawn mode (will refine Y if needed)
@@ -747,289 +798,12 @@ namespace Minigames.Airplane.Core
#endregion
- #region Dynamic Spawning
-
- ///
- /// Get the distance ahead to spawn ground (2x object spawn distance).
- ///
- private float GetGroundSpawnAheadDistance()
- {
- return _settings.SpawnDistanceAhead * 2f;
- }
-
- ///
- /// Schedule the next object spawn based on random distance from current position.
- ///
- /// Current X position (usually plane's X or last spawn X)
- private void ScheduleNextObjectSpawn(float currentX)
- {
- float spawnDistance = Random.Range(_settings.ObjectSpawnMinDistance, _settings.ObjectSpawnMaxDistance);
- _nextObjectSpawnX = currentX + spawnDistance;
-
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Next object scheduled at X={_nextObjectSpawnX:F2} (distance: {spawnDistance:F2} from {currentX:F2})");
- }
- }
-
- ///
- /// Spawn a random positive or negative object.
- /// Uses weighted randomness to maintain target ratio.
- /// Avoids spawning near target position to prevent obscuring it.
- /// Objects spawn at look-ahead distance to ensure they're off-screen.
- ///
- private void SpawnRandomObject()
- {
- if (_planeTransform == null) return;
-
- // Spawn at look-ahead distance from plane's current position
- // This ensures objects always spawn off-screen
- float spawnX = _planeTransform.position.x + _settings.SpawnDistanceAhead;
-
- // Check if spawn position is too close to target (avoid obscuring it)
- float distanceToTarget = Mathf.Abs(spawnX - _targetSpawnPosition.x);
- float targetClearanceZone = 10f; // Don't spawn within 10 units of target
-
- if (distanceToTarget < targetClearanceZone)
- {
- // Too close to target, skip this spawn and schedule the next one
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Skipped object spawn at X={spawnX:F2} (too close to target at X={_targetSpawnPosition.x:F2})");
- }
- // Schedule next spawn further ahead
- ScheduleNextObjectSpawn(spawnX);
- return;
- }
-
- // Determine if spawning positive or negative based on weighted ratio
- bool spawnPositive = ShouldSpawnPositive();
-
- PrefabSpawnEntry entryToSpawn = null;
-
- if (spawnPositive)
- {
- if (positiveObjectPrefabs != null && positiveObjectPrefabs.Length > 0)
- {
- entryToSpawn = positiveObjectPrefabs[UnityEngine.Random.Range(0, positiveObjectPrefabs.Length)];
- _positiveSpawnCount++;
- }
- }
- else
- {
- if (negativeObjectPrefabs != null && negativeObjectPrefabs.Length > 0)
- {
- entryToSpawn = negativeObjectPrefabs[UnityEngine.Random.Range(0, negativeObjectPrefabs.Length)];
- _negativeSpawnCount++;
- }
- }
-
- if (entryToSpawn == null || entryToSpawn.prefab == null) return;
-
-
- // Spawn object at temporary position
- Vector3 tempPosition = new Vector3(spawnX, 0f, 0f);
- GameObject spawnedObject = Instantiate(entryToSpawn.prefab, tempPosition, Quaternion.identity);
-
- if (spawnedObjectsParent != null)
- {
- spawnedObject.transform.SetParent(spawnedObjectsParent);
- }
-
- // Position object using entry's spawn configuration
- PositionObject(spawnedObject, spawnX,
- entryToSpawn.spawnPositionMode,
- entryToSpawn.specifiedY,
- entryToSpawn.randomYMin,
- entryToSpawn.randomYMax);
-
- // Initialize components that need post-spawn setup
- var initializable = spawnedObject.GetComponent();
- if (initializable != null)
- {
- initializable.Initialize();
- }
-
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Spawned {(spawnPositive ? "positive" : "negative")} object at {spawnedObject.transform.position}");
- }
- }
-
- ///
- /// Determine if next spawn should be positive based on weighted ratio.
- /// Adjusts to maintain target positive/negative ratio.
- ///
- private bool ShouldSpawnPositive()
- {
- int totalSpawned = _positiveSpawnCount + _negativeSpawnCount;
-
- // First few spawns - use pure random based on ratio
- if (totalSpawned < 5)
- {
- return UnityEngine.Random.value <= _settings.PositiveNegativeRatio;
- }
-
- // Calculate current ratio
- float currentRatio = totalSpawned > 0 ? (float)_positiveSpawnCount / totalSpawned : 0.5f;
- float targetRatio = _settings.PositiveNegativeRatio;
-
- // If we're below target ratio, heavily favor positive
- // If we're above target ratio, heavily favor negative
- float adjustedProbability;
- if (currentRatio < targetRatio)
- {
- // Need more positive - increase probability
- adjustedProbability = Mathf.Lerp(targetRatio, 1f, (targetRatio - currentRatio) * 2f);
- }
- else
- {
- // Need more negative - decrease probability
- adjustedProbability = Mathf.Lerp(0f, targetRatio, 1f - (currentRatio - targetRatio) * 2f);
- }
-
- return UnityEngine.Random.value <= adjustedProbability;
- }
-
- ///
- /// Spawn a ground tile at the next ground spawn position.
- ///
- private void SpawnGroundTile()
- {
- if (groundTilePrefabs == null || groundTilePrefabs.Length == 0) return;
-
- // Pick random ground tile
- GameObject tilePrefab = groundTilePrefabs[Random.Range(0, groundTilePrefabs.Length)];
-
- // Calculate spawn position using configured Y
- Vector3 spawnPosition = new Vector3(_nextGroundSpawnX, groundSpawnY, 0f);
-
- // Spawn tile
- GameObject spawnedTile = Instantiate(tilePrefab, spawnPosition, Quaternion.identity);
-
- if (groundTilesParent != null)
- {
- spawnedTile.transform.SetParent(groundTilesParent);
- }
-
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Spawned ground tile at ({_nextGroundSpawnX:F2}, {groundSpawnY:F2})");
- }
- }
-
- ///
- /// Spawn a ground tile at a specific X position (used for pre-spawn).
- ///
- private void SpawnGroundTileAt(float xPosition)
- {
- if (groundTilePrefabs == null || groundTilePrefabs.Length == 0) return;
-
- // Pick random ground tile
- GameObject tilePrefab = groundTilePrefabs[Random.Range(0, groundTilePrefabs.Length)];
-
- // Calculate spawn position using configured Y
- Vector3 spawnPosition = new Vector3(xPosition, groundSpawnY, 0f);
-
- // Spawn tile
- GameObject spawnedTile = Instantiate(tilePrefab, spawnPosition, Quaternion.identity);
-
- if (groundTilesParent != null)
- {
- spawnedTile.transform.SetParent(groundTilesParent);
- }
-
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Pre-spawned ground tile at ({xPosition:F2}, {groundSpawnY:F2})");
- }
- }
-
- ///
- /// Spawn a random positive or negative object at a specific X position (used for pre-spawn).
- ///
- private void SpawnRandomObjectAt(float xPosition)
- {
- // Check if spawn position is too close to target (avoid obscuring it)
- float distanceToTarget = Mathf.Abs(xPosition - _targetSpawnPosition.x);
- float targetClearanceZone = 10f; // Don't spawn within 10 units of target
-
- if (distanceToTarget < targetClearanceZone)
- {
- // Too close to target, skip this spawn
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Skipped pre-spawn at X={xPosition:F2} (too close to target at X={_targetSpawnPosition.x:F2})");
- }
- return;
- }
-
- // Determine if spawning positive or negative based on weighted ratio
- bool spawnPositive = ShouldSpawnPositive();
-
- PrefabSpawnEntry entryToSpawn = null;
-
- if (spawnPositive)
- {
- if (positiveObjectPrefabs != null && positiveObjectPrefabs.Length > 0)
- {
- entryToSpawn = positiveObjectPrefabs[Random.Range(0, positiveObjectPrefabs.Length)];
- _positiveSpawnCount++;
- }
- }
- else
- {
- if (negativeObjectPrefabs != null && negativeObjectPrefabs.Length > 0)
- {
- entryToSpawn = negativeObjectPrefabs[Random.Range(0, negativeObjectPrefabs.Length)];
- _negativeSpawnCount++;
- }
- }
-
- if (entryToSpawn == null || entryToSpawn.prefab == null) return;
-
- // Spawn object at temporary position
- Vector3 tempPosition = new Vector3(xPosition, 0f, 0f);
- GameObject spawnedObject = Instantiate(entryToSpawn.prefab, tempPosition, Quaternion.identity);
-
- if (spawnedObjectsParent != null)
- {
- spawnedObject.transform.SetParent(spawnedObjectsParent);
- }
-
- // Position object using entry's spawn configuration
- PositionObject(spawnedObject, xPosition,
- entryToSpawn.spawnPositionMode,
- entryToSpawn.specifiedY,
- entryToSpawn.randomYMin,
- entryToSpawn.randomYMax);
-
- // Initialize components that need post-spawn setup
- var initializable = spawnedObject.GetComponent();
- if (initializable != null)
- {
- initializable.Initialize();
- }
-
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Pre-spawned {(spawnPositive ? "positive" : "negative")} object at {spawnedObject.transform.position}");
- }
- }
-
- #endregion
-
- #region Object Positioning
+ #region Object Positioning Helpers
///
/// Position an object based on the specified spawn mode.
+ /// Used for target positioning.
///
- /// Object to position
- /// X position for the object
- /// Spawn position mode to use
- /// Y value for SpecifiedY mode
- /// Min Y for RandomRange mode
- /// Max Y for RandomRange mode
private void PositionObject(GameObject obj, float xPosition, SpawnPositionMode mode,
float specifiedY, float randomYMin, float randomYMax)
{
@@ -1045,22 +819,14 @@ namespace Minigames.Airplane.Core
case SpawnPositionMode.SpecifiedY:
targetY = specifiedY;
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Positioned object at specified Y={targetY:F2}");
- }
break;
case SpawnPositionMode.RandomRange:
targetY = Random.Range(randomYMin, randomYMax);
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Positioned object at random Y={targetY:F2} (range: {randomYMin:F2} to {randomYMax:F2})");
- }
break;
default:
- Logging.Error($"[SpawnManager] Unknown spawn position mode: {mode}");
+ Logging.Error($"[AirplaneSpawnManager] Unknown spawn position mode: {mode}");
targetY = 0f;
break;
}
@@ -1073,83 +839,37 @@ namespace Minigames.Airplane.Core
///
/// Snap an object to the ground using raycast.
- /// Positions object so its bottom bounds touch the ground.
///
- /// Object to snap to ground
- /// X position to raycast from
- /// The Y position where object was snapped
private float SnapToGround(GameObject obj, float xPosition)
{
- // Start raycast from high Y position
Vector2 rayOrigin = new Vector2(xPosition, 0.0f);
-
- // Raycast downward to find ground (convert layer to layer mask)
int layerMask = 1 << _settings.GroundLayer;
- RaycastHit2D hit = Physics2D.Raycast(
- rayOrigin,
- Vector2.down,
- _settings.MaxGroundRaycastDistance,
- layerMask
- );
-
- float targetY;
+ RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.down, _settings.MaxGroundRaycastDistance, layerMask);
if (hit.collider != null)
{
- // Found ground - calculate Y position
float groundY = hit.point.y;
-
- // Get object bounds
Bounds bounds = GetObjectBounds(obj);
- float boundsBottomOffset = bounds.extents.y; // Half height
-
- // Position object so bottom touches ground
- targetY = groundY + boundsBottomOffset;
-
- if (showDebugLogs)
- {
- Logging.Debug($"[SpawnManager] Snapped object to ground at Y={targetY:F2} (ground at {groundY:F2})");
- }
- }
- else
- {
- // No ground found - use default offset
- targetY = _settings.DefaultObjectYOffset;
-
- Logging.Warning($"[SpawnManager] No ground found at X={xPosition}, using default Y={targetY}");
+ return groundY + bounds.extents.y;
}
- return targetY;
+ return _settings.DefaultObjectYOffset;
}
///
- /// Get the bounds of an object from its Renderer or Collider.
+ /// Get the bounds of an object.
///
private Bounds GetObjectBounds(GameObject obj)
{
- // Try Renderer first
Renderer objRenderer = obj.GetComponentInChildren();
- if (objRenderer != null)
- {
- return objRenderer.bounds;
- }
+ if (objRenderer != null) return objRenderer.bounds;
- // Try Collider2D
Collider2D objCollider2D = obj.GetComponentInChildren();
- if (objCollider2D != null)
- {
- return objCollider2D.bounds;
- }
+ if (objCollider2D != null) return objCollider2D.bounds;
- // Try Collider3D
Collider objCollider3D = obj.GetComponentInChildren();
- if (objCollider3D != null)
- {
- return objCollider3D.bounds;
- }
+ if (objCollider3D != null) return objCollider3D.bounds;
- // Fallback - create minimal bounds
- Logging.Warning($"[SpawnManager] No Renderer or Collider found on {obj.name}, using default bounds");
return new Bounds(obj.transform.position, Vector3.one);
}
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning.meta b/Assets/Scripts/Minigames/Airplane/Core/Spawning.meta
new file mode 100644
index 00000000..fdfcac7a
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5dc5cd57fce342659690bcf1c3411048
+timeCreated: 1765965907
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/BaseDistanceSpawner.cs b/Assets/Scripts/Minigames/Airplane/Core/Spawning/BaseDistanceSpawner.cs
new file mode 100644
index 00000000..6329e71d
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/BaseDistanceSpawner.cs
@@ -0,0 +1,449 @@
+using System.Collections.Generic;
+using Core;
+using Core.Lifecycle;
+using Minigames.Airplane.Data;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core.Spawning
+{
+ ///
+ /// Base class for distance-based spawners in the airplane minigame.
+ /// Handles pool management, time-based unlocking, distance tracking, and recency diversity.
+ /// Derived classes implement specific spawn logic via SpawnFromPool.
+ ///
+ public abstract class BaseDistanceSpawner : ManagedBehaviour
+ {
+ #region Inspector Fields
+
+ [Header("Pool Configuration")]
+ [Tooltip("How pools are combined: Together = shared spawn stream, Exclusive = independent spawning")]
+ [SerializeField] protected SpawnPoolMode poolMode = SpawnPoolMode.Together;
+
+ [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
+
+ // Tracking
+ protected Transform PlaneTransform;
+ protected float GameTime;
+ protected bool IsSpawning;
+ protected float LastSpawnedX;
+
+ // Pool state
+ protected List UnlockedPoolIndices = new List();
+ protected Dictionary LastUsedTimes = new Dictionary();
+
+ // Together mode state
+ protected float NextSpawnX;
+
+ // Exclusive mode state
+ protected Dictionary PoolNextSpawnX = new Dictionary();
+
+ #endregion
+
+ #region Initialization
+
+ ///
+ /// Initialize the spawner. Call this before starting spawning.
+ ///
+ public virtual void Initialize()
+ {
+ ValidateConfiguration();
+
+ // Unlock pool 0 immediately
+ if (pools != null && pools.Length > 0)
+ {
+ UnlockedPoolIndices.Add(0);
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[{GetType().Name}] Initialized with pool 0 unlocked");
+ }
+ }
+
+ // Initialize exclusive mode spawn positions if needed
+ if (poolMode == SpawnPoolMode.Exclusive)
+ {
+ for (int i = 0; i < pools.Length; i++)
+ {
+ PoolNextSpawnX[i] = 0f;
+ }
+ }
+ }
+
+ protected virtual void ValidateConfiguration()
+ {
+ if (pools == null || pools.Length == 0)
+ {
+ Logging.Error($"[{GetType().Name}] No pools configured!");
+ 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++)
+ {
+ if (pools[i].unlockTime < pools[i - 1].unlockTime)
+ {
+ Logging.Warning($"[{GetType().Name}] Pool {i} unlock time ({pools[i].unlockTime}s) is earlier than pool {i-1} ({pools[i-1].unlockTime}s)!");
+ }
+ }
+ }
+
+ #endregion
+
+ #region Public API
+
+ ///
+ /// Start tracking the plane and enable spawning.
+ ///
+ 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}");
+ }
+ }
+
+ ///
+ /// Stop spawning and tracking.
+ ///
+ public void StopTracking()
+ {
+ IsSpawning = false;
+ PlaneTransform = null;
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[{GetType().Name}] Stopped tracking");
+ }
+ }
+
+ ///
+ /// Update game time and check for pool unlocks.
+ /// Call this every frame from the orchestrator.
+ ///
+ public void UpdateGameTime(float deltaTime)
+ {
+ GameTime += deltaTime;
+ CheckPoolUnlocks();
+ }
+
+ ///
+ /// Pre-spawn objects from start to end X position.
+ ///
+ public virtual void PreSpawn(float startX, float endX)
+ {
+ if (poolMode == SpawnPoolMode.Together)
+ {
+ PreSpawnTogether(startX, endX);
+ }
+ else
+ {
+ PreSpawnExclusive(startX, endX);
+ }
+
+ LastSpawnedX = endX;
+ }
+
+ ///
+ /// Update spawning based on plane position.
+ /// Call this every frame from the orchestrator.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Clean up all spawned objects.
+ ///
+ public virtual void Cleanup()
+ {
+ if (spawnContainer != null)
+ {
+ foreach (Transform child in spawnContainer)
+ {
+ Destroy(child.gameObject);
+ }
+ }
+
+ LastUsedTimes.Clear();
+ UnlockedPoolIndices.Clear();
+ UnlockedPoolIndices.Add(0); // Re-add pool 0
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[{GetType().Name}] Cleanup complete");
+ }
+ }
+
+ #endregion
+
+ #region Pool Management
+
+ protected void CheckPoolUnlocks()
+ {
+ for (int i = 1; i < pools.Length; i++)
+ {
+ if (UnlockedPoolIndices.Contains(i)) continue;
+
+ if (GameTime >= pools[i].unlockTime)
+ {
+ UnlockedPoolIndices.Add(i);
+
+ // If exclusive mode, initialize spawn position for this pool
+ if (poolMode == SpawnPoolMode.Exclusive && PlaneTransform != null)
+ {
+ float minDist = pools[i].GetMinDistance(globalMinDistance);
+ float maxDist = pools[i].GetMaxDistance(globalMaxDistance);
+ PoolNextSpawnX[i] = PlaneTransform.position.x + Random.Range(minDist, maxDist);
+ }
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[{GetType().Name}] Unlocked pool {i} '{pools[i].description}' at time {GameTime:F2}s");
+ }
+ }
+ }
+ }
+
+ protected GameObject SelectPrefabFromPools(out int selectedPoolIndex)
+ {
+ selectedPoolIndex = -1;
+
+ if (UnlockedPoolIndices.Count == 0) return null;
+
+ // Build weighted list of all available prefabs
+ List availablePrefabs = new List();
+ List prefabPoolIndices = new List();
+ List prefabWeights = new List();
+
+ foreach (int poolIndex in UnlockedPoolIndices)
+ {
+ if (poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs) continue;
+
+ foreach (GameObject prefab in pools[poolIndex].prefabs)
+ {
+ if (prefab == null) continue;
+
+ availablePrefabs.Add(prefab);
+ prefabPoolIndices.Add(poolIndex);
+
+ // Calculate weight based on recency
+ float weight = 1f;
+ if (LastUsedTimes.TryGetValue(prefab, out float lastUsedTime))
+ {
+ float timeSinceUse = GameTime - lastUsedTime;
+ if (timeSinceUse < recencyPenaltyDuration)
+ {
+ weight = timeSinceUse / recencyPenaltyDuration; // 0 to 1 linear recovery
+ }
+ }
+
+ prefabWeights.Add(weight);
+ }
+ }
+
+ if (availablePrefabs.Count == 0) return null;
+
+ // Select using weighted random
+ int selectedIndex = WeightedRandom(prefabWeights);
+ GameObject selectedPrefab = availablePrefabs[selectedIndex];
+ selectedPoolIndex = prefabPoolIndices[selectedIndex];
+
+ // Update recency
+ LastUsedTimes[selectedPrefab] = GameTime;
+
+ return selectedPrefab;
+ }
+
+ protected int WeightedRandom(List weights)
+ {
+ float totalWeight = 0f;
+ foreach (float w in weights) totalWeight += w;
+
+ float randomValue = Random.Range(0f, totalWeight);
+ float cumulative = 0f;
+
+ for (int i = 0; i < weights.Count; i++)
+ {
+ cumulative += weights[i];
+ if (randomValue <= cumulative) return i;
+ }
+
+ return weights.Count - 1;
+ }
+
+ #endregion
+
+ #region Together Mode Spawning
+
+ protected virtual void PreSpawnTogether(float startX, float endX)
+ {
+ float currentX = startX + Random.Range(globalMinDistance, globalMaxDistance);
+
+ while (currentX <= endX)
+ {
+ SpawnAtPosition(currentX);
+ currentX += Random.Range(globalMinDistance, globalMaxDistance);
+ }
+
+ 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
+
+ protected virtual void PreSpawnExclusive(float startX, float endX)
+ {
+ foreach (int poolIndex in UnlockedPoolIndices)
+ {
+ if (poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs) continue;
+
+ float minDist = pools[poolIndex].GetMinDistance(globalMinDistance);
+ float maxDist = pools[poolIndex].GetMaxDistance(globalMaxDistance);
+ float currentX = startX + Random.Range(minDist, maxDist);
+
+ while (currentX <= endX)
+ {
+ SpawnFromPool(poolIndex, currentX);
+ currentX += Random.Range(minDist, maxDist);
+ }
+
+ PoolNextSpawnX[poolIndex] = currentX;
+ }
+ }
+
+ 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);
+ }
+
+ ///
+ /// Spawn a specific prefab from a pool at the given X position.
+ /// Override this in derived classes to implement specific spawn logic.
+ ///
+ protected virtual void SpawnFromPool(int poolIndex, float xPosition)
+ {
+ // Derived classes implement specific spawn logic
+ }
+
+ protected GameObject InstantiatePrefab(GameObject prefab, Vector3 position)
+ {
+ GameObject instance = Instantiate(prefab, position, Quaternion.identity);
+
+ if (spawnContainer != null)
+ {
+ instance.transform.SetParent(spawnContainer);
+ }
+
+ return instance;
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/BaseDistanceSpawner.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/Spawning/BaseDistanceSpawner.cs.meta
new file mode 100644
index 00000000..66436a89
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/BaseDistanceSpawner.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 387858903c2c44e0ab007cb2ac886343
+timeCreated: 1765965907
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/GroundDistanceSpawner.cs b/Assets/Scripts/Minigames/Airplane/Core/Spawning/GroundDistanceSpawner.cs
new file mode 100644
index 00000000..e257bf83
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/GroundDistanceSpawner.cs
@@ -0,0 +1,38 @@
+using Core;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core.Spawning
+{
+ ///
+ /// Spawns ground tiles at fixed intervals.
+ /// Inherits distance-based spawning from BaseDistanceSpawner.
+ ///
+ public class GroundDistanceSpawner : BaseDistanceSpawner
+ {
+ [Header("Ground-Specific")]
+ [Tooltip("Y position at which to spawn ground tiles")]
+ [SerializeField] private float groundSpawnY = -18f;
+
+ protected override void SpawnFromPool(int poolIndex, float xPosition)
+ {
+ if (poolIndex < 0 || poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs)
+ return;
+
+ // Select random tile from pool
+ 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);
+
+ // Instantiate
+ GameObject instance = InstantiatePrefab(tilePrefab, spawnPosition);
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[GroundDistanceSpawner] Spawned ground tile at X={xPosition:F2}, Y={groundSpawnY:F2}");
+ }
+ }
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/GroundDistanceSpawner.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/Spawning/GroundDistanceSpawner.cs.meta
new file mode 100644
index 00000000..7329e382
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/GroundDistanceSpawner.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 76db01f1871c4b9ea66fb981b01b3ee1
+timeCreated: 1765965959
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/ObstacleDistanceSpawner.cs b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ObstacleDistanceSpawner.cs
new file mode 100644
index 00000000..0e3dfa3e
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ObstacleDistanceSpawner.cs
@@ -0,0 +1,199 @@
+using Core;
+using Minigames.Airplane.Data;
+using Minigames.Airplane.Interactive;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core.Spawning
+{
+ ///
+ /// Spawns obstacle objects (positive and negative) with weighted ratio management.
+ /// Inherits distance-based spawning from BaseDistanceSpawner.
+ ///
+ 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()
+ {
+ base.Initialize();
+ _positiveSpawnCount = 0;
+ _negativeSpawnCount = 0;
+ }
+
+ protected override void SpawnFromPool(int poolIndex, float xPosition)
+ {
+ if (poolIndex < 0 || poolIndex >= pools.Length || !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());
+
+ // 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);
+
+ // Instantiate
+ GameObject instance = InstantiatePrefab(prefab, tempPosition);
+
+ // Try to get spawn entry for positioning
+ var spawnEntry = prefab.GetComponent();
+ if (spawnEntry != null)
+ {
+ PositionObject(instance, xPosition, spawnEntry.spawnPositionMode,
+ spawnEntry.specifiedY, spawnEntry.randomYMin, spawnEntry.randomYMax);
+ }
+
+ // Initialize if implements interface
+ var initializable = instance.GetComponent();
+ if (initializable != null)
+ {
+ initializable.Initialize();
+ }
+
+ // Track counts
+ if (spawnPositive)
+ _positiveSpawnCount++;
+ else
+ _negativeSpawnCount++;
+
+ if (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();
+ if (objRenderer != null) return objRenderer.bounds;
+
+ Collider2D objCollider2D = obj.GetComponentInChildren();
+ if (objCollider2D != null) return objCollider2D.bounds;
+
+ Collider objCollider3D = obj.GetComponentInChildren();
+ if (objCollider3D != null) return objCollider3D.bounds;
+
+ return new Bounds(obj.transform.position, Vector3.one);
+ }
+
+ public override void Cleanup()
+ {
+ base.Cleanup();
+ _positiveSpawnCount = 0;
+ _negativeSpawnCount = 0;
+ }
+ }
+
+ ///
+ /// Helper component to store spawn entry data on prefabs.
+ /// Attach this to prefabs that need specific positioning.
+ ///
+ public class PrefabSpawnEntryComponent : MonoBehaviour
+ {
+ public SpawnPositionMode spawnPositionMode = SpawnPositionMode.SnapToGround;
+ public float specifiedY;
+ public float randomYMin = -5f;
+ public float randomYMax = 5f;
+ }
+}
+
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/ObstacleDistanceSpawner.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ObstacleDistanceSpawner.cs.meta
new file mode 100644
index 00000000..93854f40
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ObstacleDistanceSpawner.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 0a550c256a3d4d91a17adf6d0338f022
+timeCreated: 1765965959
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/ParallaxBackgroundSpawner.cs b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ParallaxBackgroundSpawner.cs
new file mode 100644
index 00000000..dca88d21
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ParallaxBackgroundSpawner.cs
@@ -0,0 +1,146 @@
+using Core;
+using Minigames.Airplane.Data;
+using Minigames.Airplane.Interactive;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core.Spawning
+{
+ ///
+ /// Spawns parallax background elements across 3 fixed layers (Background/Middle/Foreground).
+ /// Always operates in Exclusive pool mode.
+ /// Integrates with AirplaneCameraManager to provide camera reference to parallax elements.
+ ///
+ public class ParallaxBackgroundSpawner : BaseDistanceSpawner
+ {
+ [Header("Parallax-Specific")]
+ [Tooltip("Sort layer names for each parallax layer")]
+ [SerializeField] private string backgroundSortLayer = "Background";
+ [SerializeField] private string middleSortLayer = "Midground";
+ [SerializeField] private string foregroundSortLayer = "Foreground";
+
+ [Header("Camera Integration")]
+ [Tooltip("Camera manager for tracking active camera")]
+ [SerializeField] private AirplaneCameraManager cameraManager;
+
+ private Transform _currentCameraTransform;
+
+ public override void Initialize()
+ {
+ // Force exclusive mode
+ poolMode = SpawnPoolMode.Exclusive;
+
+ // Validate exactly 3 pools
+ if (pools == null || pools.Length != 3)
+ {
+ Logging.Error("[ParallaxBackgroundSpawner] Must have exactly 3 pools (Background, Middle, Foreground)!");
+ }
+
+ base.Initialize();
+
+ // Subscribe to camera changes
+ if (cameraManager != null)
+ {
+ cameraManager.OnStateChanged += HandleCameraChanged;
+ _currentCameraTransform = cameraManager.GetActiveCameraTransform();
+ }
+ else
+ {
+ Logging.Warning("[ParallaxBackgroundSpawner] Camera manager not assigned! Parallax elements won't track camera.");
+ }
+ }
+
+ private void OnDestroy()
+ {
+ if (cameraManager != null)
+ {
+ cameraManager.OnStateChanged -= HandleCameraChanged;
+ }
+ }
+
+ private void HandleCameraChanged(AirplaneCameraState oldState, AirplaneCameraState newState)
+ {
+ if (cameraManager == null) return;
+
+ _currentCameraTransform = cameraManager.GetActiveCameraTransform();
+
+ // Update all existing parallax elements with new camera
+ if (spawnContainer != null)
+ {
+ foreach (Transform child in spawnContainer)
+ {
+ var parallaxElement = child.GetComponent();
+ if (parallaxElement != null)
+ {
+ parallaxElement.SetCameraTransform(_currentCameraTransform);
+ }
+ }
+ }
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[ParallaxBackgroundSpawner] Camera changed to {newState}, updated parallax elements");
+ }
+ }
+
+ protected override void SpawnFromPool(int poolIndex, float xPosition)
+ {
+ if (poolIndex < 0 || poolIndex >= 3 || !pools[poolIndex].HasPrefabs)
+ return;
+
+ // Select random prefab from pool
+ 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);
+
+ // Instantiate
+ GameObject instance = InstantiatePrefab(prefab, spawnPosition);
+
+ // Determine parallax layer based on pool index
+ ParallaxLayer layer = (ParallaxLayer)poolIndex;
+
+ // Get or add ParallaxElement component
+ ParallaxElement parallaxElement = instance.GetComponent();
+ if (parallaxElement == null)
+ {
+ parallaxElement = instance.AddComponent();
+ }
+
+ // Configure parallax element
+ parallaxElement.SetLayer(layer);
+ parallaxElement.SetCameraTransform(_currentCameraTransform);
+
+ // Set sort layer on sprite renderer
+ SetSortLayer(instance, layer);
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[ParallaxBackgroundSpawner] Spawned {layer} element at X={xPosition:F2}");
+ }
+ }
+
+ private void SetSortLayer(GameObject obj, ParallaxLayer layer)
+ {
+ SpriteRenderer spriteRenderer = obj.GetComponentInChildren();
+ if (spriteRenderer == null) return;
+
+ string sortLayerName = layer switch
+ {
+ ParallaxLayer.Background => backgroundSortLayer,
+ ParallaxLayer.Middle => middleSortLayer,
+ ParallaxLayer.Foreground => foregroundSortLayer,
+ _ => "Default"
+ };
+
+ spriteRenderer.sortingLayerName = sortLayerName;
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[ParallaxBackgroundSpawner] Set sort layer '{sortLayerName}' for {layer}");
+ }
+ }
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/Spawning/ParallaxBackgroundSpawner.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ParallaxBackgroundSpawner.cs.meta
new file mode 100644
index 00000000..b5b1bbdd
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/Spawning/ParallaxBackgroundSpawner.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: b126f2d189024140ac0bc6fb14155c7f
+timeCreated: 1765965959
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Data/ParallaxLayer.cs b/Assets/Scripts/Minigames/Airplane/Data/ParallaxLayer.cs
new file mode 100644
index 00000000..7f44515e
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/ParallaxLayer.cs
@@ -0,0 +1,24 @@
+namespace Minigames.Airplane.Data
+{
+ ///
+ /// Defines parallax layer depth for background elements.
+ ///
+ public enum ParallaxLayer
+ {
+ ///
+ /// Furthest back layer, moves slowest (lowest parallax factor).
+ ///
+ Background,
+
+ ///
+ /// Middle layer, moves at medium speed.
+ ///
+ Middle,
+
+ ///
+ /// Closest layer, moves fastest (highest parallax factor).
+ ///
+ Foreground
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Data/ParallaxLayer.cs.meta b/Assets/Scripts/Minigames/Airplane/Data/ParallaxLayer.cs.meta
new file mode 100644
index 00000000..28f744ec
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/ParallaxLayer.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 30cd4bc1743c44089146678153542aae
+timeCreated: 1765965854
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolConfig.cs b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolConfig.cs
new file mode 100644
index 00000000..2995b0a1
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolConfig.cs
@@ -0,0 +1,45 @@
+using System;
+using UnityEngine;
+
+namespace Minigames.Airplane.Data
+{
+ ///
+ /// Configuration for a single spawn pool.
+ /// Contains prefabs, unlock timing, and optional spawn parameter overrides.
+ ///
+ [Serializable]
+ public class SpawnPoolConfig
+ {
+ [Tooltip("Prefabs that can spawn from this pool")]
+ public GameObject[] prefabs;
+
+ [Tooltip("Time (seconds from game start) when this pool becomes available. 0 = available immediately.")]
+ public float unlockTime = 0f;
+
+ [Tooltip("Description for this pool (editor reference only)")]
+ public string description = "Pool";
+
+ [Header("Spawn Parameter Overrides (0 = use global)")]
+ [Tooltip("Override minimum spawn distance for this pool (0 = use global)")]
+ public float overrideMinDistance = 0f;
+
+ [Tooltip("Override maximum spawn distance for this pool (0 = use global)")]
+ public float overrideMaxDistance = 0f;
+
+ ///
+ /// Check if this pool has valid prefabs assigned.
+ ///
+ public bool HasPrefabs => prefabs != null && prefabs.Length > 0;
+
+ ///
+ /// Get effective minimum distance (uses override if non-zero, otherwise uses global).
+ ///
+ public float GetMinDistance(float globalMin) => overrideMinDistance > 0f ? overrideMinDistance : globalMin;
+
+ ///
+ /// Get effective maximum distance (uses override if non-zero, otherwise uses global).
+ ///
+ public float GetMaxDistance(float globalMax) => overrideMaxDistance > 0f ? overrideMaxDistance : globalMax;
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolConfig.cs.meta b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolConfig.cs.meta
new file mode 100644
index 00000000..db59ad4c
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolConfig.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 51d29acb7cdd48819588acd1401456a9
+timeCreated: 1765965854
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolMode.cs b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolMode.cs
new file mode 100644
index 00000000..26bb273f
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolMode.cs
@@ -0,0 +1,21 @@
+namespace Minigames.Airplane.Data
+{
+ ///
+ /// Defines how multiple spawn pools are combined.
+ ///
+ public enum SpawnPoolMode
+ {
+ ///
+ /// All unlocked pools contribute to a single shared spawn stream.
+ /// Objects spawn at regular intervals considering all available pools.
+ ///
+ Together,
+
+ ///
+ /// Each pool spawns independently with its own timing.
+ /// Multiple objects can spawn simultaneously from different pools.
+ ///
+ Exclusive
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolMode.cs.meta b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolMode.cs.meta
new file mode 100644
index 00000000..fd060b97
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/SpawnPoolMode.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5206332ad0e54ed4b20df47663202967
+timeCreated: 1765965854
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Interactive/ParallaxElement.cs b/Assets/Scripts/Minigames/Airplane/Interactive/ParallaxElement.cs
new file mode 100644
index 00000000..959638f6
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Interactive/ParallaxElement.cs
@@ -0,0 +1,150 @@
+using Core;
+using Minigames.Airplane.Data;
+using UnityEngine;
+
+namespace Minigames.Airplane.Interactive
+{
+ ///
+ /// Parallax element that adjusts position based on camera movement.
+ /// Creates depth illusion by moving at different speeds for different layers.
+ /// Continuously tracks active camera for seamless transitions.
+ ///
+ public class ParallaxElement : MonoBehaviour
+ {
+ [Header("Layer Configuration")]
+ [Tooltip("Which parallax layer this element belongs to")]
+ [SerializeField] private ParallaxLayer layer = ParallaxLayer.Background;
+
+ [Header("Parallax Settings")]
+ [Tooltip("Global parallax strength multiplier (0 = no parallax, 1 = full)")]
+ [SerializeField] private float globalStrength = 1f;
+
+ [Tooltip("Per-layer parallax factors (Background/Middle/Foreground)")]
+ [SerializeField] private float backgroundFactor = 0.3f;
+ [SerializeField] private float middleFactor = 0.6f;
+ [SerializeField] private float foregroundFactor = 0.9f;
+
+ [Header("Debug")]
+ [SerializeField] private bool showDebugLogs;
+
+ private Transform _cameraTransform;
+ private Vector3 _startPosition;
+ private float _startCameraX;
+ private bool _isInitialized;
+
+ private void Awake()
+ {
+ // Ensure correct sort layer
+ EnsureSortLayer();
+ }
+
+ private void Start()
+ {
+ _startPosition = transform.position;
+
+ if (_cameraTransform != null)
+ {
+ _startCameraX = _cameraTransform.position.x;
+ _isInitialized = true;
+ }
+ }
+
+ private void Update()
+ {
+ if (!_isInitialized || _cameraTransform == null) return;
+
+ ApplyParallax();
+ }
+
+ ///
+ /// Set the parallax layer for this element.
+ ///
+ public void SetLayer(ParallaxLayer newLayer)
+ {
+ layer = newLayer;
+ EnsureSortLayer();
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[ParallaxElement] Layer set to {layer}");
+ }
+ }
+
+ ///
+ /// Set the camera transform to track.
+ /// Call this when camera changes.
+ ///
+ public void SetCameraTransform(Transform cameraTransform)
+ {
+ if (cameraTransform == null) return;
+
+ // If camera changed, recalculate base position
+ if (_cameraTransform != null && _cameraTransform != cameraTransform)
+ {
+ // Smooth transition: current world position becomes new start position
+ _startPosition = transform.position;
+ }
+
+ _cameraTransform = cameraTransform;
+ _startCameraX = _cameraTransform.position.x;
+ _isInitialized = true;
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[ParallaxElement] Camera set, starting camera X={_startCameraX:F2}");
+ }
+ }
+
+ private void ApplyParallax()
+ {
+ // Calculate camera displacement from start
+ float cameraDisplacement = _cameraTransform.position.x - _startCameraX;
+
+ // Get layer-specific parallax factor
+ float layerFactor = GetLayerFactor();
+
+ // Calculate parallax offset (reduced displacement based on layer depth)
+ float parallaxOffset = cameraDisplacement * layerFactor * globalStrength;
+
+ // Apply offset to start position
+ Vector3 newPosition = _startPosition;
+ newPosition.x += parallaxOffset;
+ transform.position = newPosition;
+ }
+
+ private float GetLayerFactor()
+ {
+ return layer switch
+ {
+ ParallaxLayer.Background => backgroundFactor,
+ ParallaxLayer.Middle => middleFactor,
+ ParallaxLayer.Foreground => foregroundFactor,
+ _ => 1f
+ };
+ }
+
+ private void EnsureSortLayer()
+ {
+ SpriteRenderer spriteRenderer = GetComponentInChildren();
+ if (spriteRenderer == null) return;
+
+ // Sort layer is set by spawner, this is just a validation/fallback
+ string expectedLayer = layer switch
+ {
+ ParallaxLayer.Background => "Background",
+ ParallaxLayer.Middle => "Midground",
+ ParallaxLayer.Foreground => "Foreground",
+ _ => "Default"
+ };
+
+ if (spriteRenderer.sortingLayerName != expectedLayer)
+ {
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[ParallaxElement] Adjusting sort layer to '{expectedLayer}' for {layer}");
+ }
+ }
+ }
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Interactive/ParallaxElement.cs.meta b/Assets/Scripts/Minigames/Airplane/Interactive/ParallaxElement.cs.meta
new file mode 100644
index 00000000..0eff2f41
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Interactive/ParallaxElement.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: b32c96c5365743d3a1a5c849ba340b6e
+timeCreated: 1765965978
\ No newline at end of file