Introduce background spawning with parallax effect (#86)

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Reviewed-on: #86
This commit is contained in:
2025-12-17 22:08:23 +00:00
parent 4ce61ee756
commit b669ea1a55
85 changed files with 6029 additions and 1439 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

@@ -71,6 +71,35 @@ namespace Minigames.Airplane.Core
#endregion
#region Camera Tracking API
/// <summary>
/// Get the transform of the currently active camera.
/// Used by parallax elements to track camera movement.
/// </summary>
public Transform GetActiveCameraTransform()
{
var activeCamera = GetCamera(CurrentState);
return activeCamera != null ? activeCamera.transform : null;
}
/// <summary>
/// Override to fire additional state changed events.
/// </summary>
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
/// <summary>

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5dc5cd57fce342659690bcf1c3411048
timeCreated: 1765965907

View File

@@ -0,0 +1,475 @@
using System.Collections.Generic;
using Core;
using Core.Lifecycle;
using Core.Settings;
using Minigames.Airplane.Data;
using UnityEngine;
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
{
#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;
#endregion
#region State
// Shared references (set by orchestrator via SpawnInitParameters)
protected Transform spawnContainer;
protected IAirplaneSettings settings;
// Tracking
protected float GameTime;
protected float LastSpawnedX;
// Pool state
protected List<int> UnlockedPoolIndices = new List<int>();
protected Dictionary<GameObject, float> LastUsedTimes = new Dictionary<GameObject, float>();
// Together mode state
protected float NextSpawnX;
// Exclusive mode state
protected Dictionary<int, float> PoolNextSpawnX = new Dictionary<int, float>();
#endregion
#region Initialization
/// <summary>
/// Initialize the spawner with shared configuration. Call this before starting spawning.
/// </summary>
/// <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
if (pools != null && pools.Length > 0)
{
UnlockedPoolIndices.Add(0);
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Initialized with pool 0 unlocked");
}
}
// Initialize exclusive mode spawn positions if needed
if (poolMode == SpawnPoolMode.Exclusive)
{
if (pools != null)
{
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;
}
// 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
/// <summary>
/// Update game time and check for pool unlocks.
/// Call this every frame from the orchestrator.
/// </summary>
public void UpdateGameTime(float deltaTime)
{
GameTime += deltaTime;
CheckPoolUnlocks();
}
/// <summary>
/// Update the spawn horizon and spawn objects if needed.
/// Called by orchestrator to push spawn updates.
/// Each spawner independently decides if spawning is needed.
/// </summary>
public virtual void UpdateSpawnHorizon(float horizonX)
{
if (poolMode == SpawnPoolMode.Together)
{
UpdateSpawnHorizonTogether(horizonX);
}
else // Exclusive mode - each pool checks independently
{
UpdateSpawnHorizonExclusive(horizonX);
}
}
private void UpdateSpawnHorizonTogether(float horizonX)
{
// Keep spawning while next spawn point is behind the horizon
while (NextSpawnX < horizonX)
{
SpawnAtPosition(NextSpawnX);
LastSpawnedX = NextSpawnX;
NextSpawnX = LastSpawnedX + Random.Range(settings.ObjectSpawnMinDistance, settings.ObjectSpawnMaxDistance);
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Together mode: Spawned at {LastSpawnedX:F2}, next at {NextSpawnX:F2}");
}
}
}
private void UpdateSpawnHorizonExclusive(float horizonX)
{
// Each pool spawns independently based on its own position
foreach (int poolIndex in UnlockedPoolIndices)
{
if (poolIndex >= pools.Length || !pools[poolIndex].HasPrefabs) continue;
if (!PoolNextSpawnX.ContainsKey(poolIndex)) continue;
float minDist = pools[poolIndex].GetMinDistance(settings.ObjectSpawnMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(settings.ObjectSpawnMaxDistance);
// Keep spawning from this pool while its next spawn is behind horizon
while (PoolNextSpawnX[poolIndex] < horizonX)
{
SpawnFromPool(poolIndex, PoolNextSpawnX[poolIndex]);
PoolNextSpawnX[poolIndex] += Random.Range(minDist, maxDist);
LastSpawnedX = Mathf.Max(LastSpawnedX, PoolNextSpawnX[poolIndex]);
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Exclusive mode: Pool {poolIndex} spawned at {PoolNextSpawnX[poolIndex]:F2}");
}
}
}
}
/// <summary>
/// Pre-spawn objects from start to end X position.
/// </summary>
public virtual void PreSpawn(float startX, float endX)
{
if (poolMode == SpawnPoolMode.Together)
{
PreSpawnTogether(startX, endX);
}
else
{
PreSpawnExclusive(startX, endX);
}
LastSpawnedX = endX;
}
/// <summary>
/// Clean up all spawned objects.
/// </summary>
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 (settings.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)
{
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 (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Unlocked pool {i} '{pools[i].description}' at time {GameTime:F2}s");
}
}
}
}
protected virtual GameObject SelectPrefabFromPools(out int selectedPoolIndex)
{
selectedPoolIndex = -1;
if (UnlockedPoolIndices.Count == 0) return null;
// Build weighted list of all available prefabs
List<GameObject> availablePrefabs = new List<GameObject>();
List<int> prefabPoolIndices = new List<int>();
List<float> prefabWeights = new List<float>();
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 < settings.RecencyPenaltyDuration)
{
weight = timeSinceUse / settings.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<float> 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)
{
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] PreSpawnTogether START: Range X={startX:F2} to X={endX:F2}, Min/Max distance=[{settings.ObjectSpawnMinDistance:F1}-{settings.ObjectSpawnMaxDistance:F1}]");
}
float currentX = startX + Random.Range(settings.ObjectSpawnMinDistance, settings.ObjectSpawnMaxDistance);
int objectsSpawned = 0;
float firstSpawnX = currentX;
while (currentX <= endX)
{
SpawnAtPosition(currentX);
objectsSpawned++;
currentX += Random.Range(settings.ObjectSpawnMinDistance, settings.ObjectSpawnMaxDistance);
}
NextSpawnX = currentX;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] PreSpawnTogether COMPLETE: Spawned {objectsSpawned} objects from X={firstSpawnX:F2} to X={NextSpawnX:F2}");
}
}
#endregion
#region Exclusive Mode Spawning
protected virtual void PreSpawnExclusive(float startX, float endX)
{
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] PreSpawnExclusive START: Range X={startX:F2} to X={endX:F2}, Total pools={pools.Length}");
}
// Pre-spawn only from pools with unlockTime <= 0 (immediate availability)
// Pools with positive unlock times are meant to unlock during gameplay progression
int spawnedPoolCount = 0;
int skippedPoolCount = 0;
for (int poolIndex = 0; poolIndex < pools.Length; poolIndex++)
{
if (!pools[poolIndex].HasPrefabs)
{
if (settings.ShowDebugLogs)
{
Logging.Warning($"[{GetType().Name}] Pool {poolIndex} skipped - no prefabs assigned");
}
skippedPoolCount++;
continue;
}
// Skip pools that unlock later during gameplay
if (pools[poolIndex].unlockTime > 0f)
{
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Pool {poolIndex} '{pools[poolIndex].description}' skipped - unlockTime={pools[poolIndex].unlockTime}s (will unlock during gameplay)");
}
skippedPoolCount++;
continue;
}
float minDist = pools[poolIndex].GetMinDistance(settings.ObjectSpawnMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(settings.ObjectSpawnMaxDistance);
float currentX = startX + Random.Range(minDist, maxDist);
int objectsSpawned = 0;
float firstSpawnX = currentX;
while (currentX <= endX)
{
SpawnFromPool(poolIndex, currentX);
objectsSpawned++;
currentX += Random.Range(minDist, maxDist);
}
PoolNextSpawnX[poolIndex] = currentX;
spawnedPoolCount++;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Pool {poolIndex} '{pools[poolIndex].description}': unlockTime={pools[poolIndex].unlockTime}s, prefabs={pools[poolIndex].prefabs.Length}, distance=[{minDist:F1}-{maxDist:F1}], spawned {objectsSpawned} objects from X={firstSpawnX:F2} to X={PoolNextSpawnX[poolIndex]:F2}");
}
}
// Ensure all pools with unlockTime <= 0 are marked as unlocked for dynamic spawning
for (int i = 0; i < pools.Length; i++)
{
if (pools[i].unlockTime <= 0f && !UnlockedPoolIndices.Contains(i))
{
UnlockedPoolIndices.Add(i);
}
}
if (settings.ShowDebugLogs)
{
Logging.Debug($"[{GetType().Name}] PreSpawnExclusive COMPLETE: {spawnedPoolCount} pools spawned, {skippedPoolCount} pools skipped, {UnlockedPoolIndices.Count} pools unlocked for dynamic spawning");
}
}
#endregion
#region Spawning
protected void SpawnAtPosition(float 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.
/// Derived classes must implement this to define spawn behavior.
/// </summary>
protected abstract void SpawnFromPool(int poolIndex, float xPosition);
protected GameObject InstantiatePrefab(GameObject prefab, Vector3 position)
{
GameObject instance = Instantiate(prefab, position, Quaternion.identity);
if (spawnContainer != null)
{
instance.transform.SetParent(spawnContainer);
}
return instance;
}
#endregion
#region Object Positioning
/// <summary>
/// Position an object based on the specified spawn mode.
/// Delegates to PositioningUtility for all positioning logic.
/// </summary>
protected void PositionObject(GameObject obj, float xPosition, SpawnPositionMode mode,
float specifiedY, float randomYMin, float randomYMax)
{
PositioningUtility.PositionObject(obj, xPosition, mode, specifiedY, randomYMin, randomYMax,
settings, settings.ShowDebugLogs);
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f65e211e40b247ffb0ac920be5a9ce53
timeCreated: 1765993195

View File

@@ -0,0 +1,104 @@
using Core;
using Core.Settings;
using Minigames.Airplane.Data;
using UnityEngine;
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
{
[Header("Ground-Specific")]
[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)
{
if (settings.ShowDebugLogs)
{
Logging.Debug($"[GroundDistanceSpawner] PreSpawnTogether START: Range X={startX:F2} to X={endX:F2}, Fixed interval={_groundSpawnInterval:F1}");
}
float currentX = startX;
int tilesSpawned = 0;
while (currentX <= endX)
{
SpawnAtPosition(currentX);
tilesSpawned++;
currentX += _groundSpawnInterval; // FIXED interval, not random
}
NextSpawnX = currentX;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[GroundDistanceSpawner] PreSpawnTogether COMPLETE: Spawned {tilesSpawned} ground tiles from X={startX:F2} to X={NextSpawnX:F2}");
}
}
/// <summary>
/// Override SpawnNext to use fixed ground intervals instead of random distances.
/// </summary>
public override void UpdateSpawnHorizon(float horizonX)
{
// Keep spawning while next spawn point is behind the horizon
while (NextSpawnX < horizonX)
{
SpawnAtPosition(NextSpawnX);
LastSpawnedX = NextSpawnX;
NextSpawnX += _groundSpawnInterval; // FIXED interval, not random
if (settings.ShowDebugLogs)
{
Logging.Debug($"[GroundDistanceSpawner] Spawned at {LastSpawnedX:F2}, next at {NextSpawnX:F2}");
}
}
}
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 using settings
Vector3 spawnPosition = new Vector3(xPosition, settings.GroundSpawnY, 0f);
// Instantiate
GameObject instance = InstantiatePrefab(tilePrefab, spawnPosition);
if (settings.ShowDebugLogs)
{
Logging.Debug($"[GroundDistanceSpawner] Spawned ground tile at X={xPosition:F2}, Y={settings.GroundSpawnY:F2}");
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 76db01f1871c4b9ea66fb981b01b3ee1
timeCreated: 1765965959

View File

@@ -0,0 +1,210 @@
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
{
private int _positiveSpawnCount;
private int _negativeSpawnCount;
public override void Initialize(SpawnInitParameters initParams)
{
// 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 >= 2 || !pools[poolIndex].HasPrefabs)
return;
// 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;
// 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 (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
var initializable = instance.GetComponent<ISpawnInitializable>();
if (initializable != null)
{
initializable.Initialize();
}
// Track counts
if (spawnPositive)
_positiveSpawnCount++;
else
_negativeSpawnCount++;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ObstacleDistanceSpawner] Spawned {(spawnPositive ? "positive" : "negative")} at X={xPosition:F2} from pool {poolIndex}");
}
}
public override void Cleanup()
{
base.Cleanup();
_positiveSpawnCount = 0;
_negativeSpawnCount = 0;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0a550c256a3d4d91a17adf6d0338f022
timeCreated: 1765965959

View File

@@ -0,0 +1,220 @@
using Core;
using Minigames.Airplane.Data;
using Minigames.Airplane.Interactive;
using UnityEngine;
namespace Minigames.Airplane.Core.Spawning
{
/// <summary>
/// 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.
/// </summary>
public class ParallaxBackgroundSpawner : BaseDistanceSpawner
{
[Header("Parallax-Specific")]
[Tooltip("Sort layer for all parallax elements")]
[SerializeField] private string parallaxSortLayer = "Background";
[Tooltip("Sort order for background layer (furthest back)")]
[SerializeField] private int backgroundSortOrder = -50;
[Tooltip("Sort order increment between layers")]
[SerializeField] private int sortOrderIncrement = 10;
[Header("Parallax Configuration")]
[Tooltip("Global parallax strength multiplier (0 = no effect, 1 = full effect)")]
[SerializeField] private float globalStrength = 1f;
[Tooltip("Background layer movement speed (typically slowest, e.g. 0.3)")]
[SerializeField] private float backgroundSpeed = 0.3f;
[Tooltip("Middle layer movement speed (typically medium, e.g. 0.6)")]
[SerializeField] private float middleSpeed = 0.6f;
[Tooltip("Foreground layer movement speed (typically fastest, e.g. 0.9)")]
[SerializeField] private float foregroundSpeed = 0.9f;
[Header("Camera Integration")]
[Tooltip("Camera manager for tracking active camera")]
[SerializeField] private AirplaneCameraManager cameraManager;
private Transform _currentCameraTransform;
public override void Initialize(SpawnInitParameters initParams)
{
// 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(initParams);
// 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<ParallaxElement>();
if (parallaxElement != null)
{
parallaxElement.UpdateCamera(_currentCameraTransform);
}
}
}
if (settings.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;
// 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);
// Determine parallax layer based on pool index
ParallaxLayer layer = (ParallaxLayer)poolIndex;
// Get or add ParallaxElement component
ParallaxElement parallaxElement = instance.GetComponent<ParallaxElement>();
if (parallaxElement == null)
{
parallaxElement = instance.AddComponent<ParallaxElement>();
}
// Set sort layer on sprite renderer
SetSortLayer(instance, layer);
// Log Y position BEFORE positioning
Vector3 positionBeforePositionObject = instance.transform.position;
// Try to get spawn entry for Y positioning (same logic as obstacles)
var spawnEntry = prefab.GetComponent<PrefabSpawnEntryComponent>();
if (spawnEntry != null)
{
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ParallaxBackgroundSpawner] '{prefab.name}' has PrefabSpawnEntryComponent: mode={spawnEntry.spawnPositionMode}, specifiedY={spawnEntry.specifiedY}, randomRange=[{spawnEntry.randomYMin},{spawnEntry.randomYMax}]");
}
// Use per-prefab configuration
PositionObject(instance, xPosition, spawnEntry.spawnPositionMode,
spawnEntry.specifiedY, spawnEntry.randomYMin, spawnEntry.randomYMax);
}
else
{
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ParallaxBackgroundSpawner] '{prefab.name}' NO component, using global: mode={settings.DefaultObstaclePositionMode}");
}
// Fall back to global default configuration from settings
PositionObject(instance, xPosition, settings.DefaultObstaclePositionMode,
settings.FallbackYPosition, settings.DefaultObstacleRandomYMin, settings.DefaultObstacleRandomYMax);
}
// Log Y position AFTER positioning
Vector3 positionAfterPositionObject = instance.transform.position;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ParallaxBackgroundSpawner] Y positioning: BEFORE={positionBeforePositionObject.y:F2}, AFTER={positionAfterPositionObject.y:F2}, CHANGED={positionAfterPositionObject.y != positionBeforePositionObject.y}");
}
// IMPORTANT: Initialize parallax element AFTER positioning so it captures the correct Y coordinate
// The parallax element caches _startPosition which includes Y coordinate
// If we initialize before positioning, Y=0 gets cached and objects appear at wrong height
float layerSpeed = GetLayerSpeed(layer);
parallaxElement.Initialize(layer, layerSpeed, globalStrength, _currentCameraTransform);
// Verify that Initialize didn't move the object
Vector3 positionAfterInitialize = instance.transform.position;
if (Mathf.Abs(positionAfterInitialize.y - positionAfterPositionObject.y) > 0.01f)
{
Logging.Error($"[ParallaxBackgroundSpawner] BUG: Y position changed during Initialize! Before={positionAfterPositionObject.y:F2}, After={positionAfterInitialize.y:F2}");
}
if (settings.ShowDebugLogs)
{
string positionModeInfo = spawnEntry != null
? $", positionMode={spawnEntry.spawnPositionMode} (per-prefab)"
: $", positionMode={settings.DefaultObstaclePositionMode} (global fallback)";
Logging.Debug($"[ParallaxBackgroundSpawner] FINAL: '{prefab.name}' at WORLD position X={positionAfterInitialize.x:F2}, Y={positionAfterInitialize.y:F2}, Z={positionAfterInitialize.z:F2}{positionModeInfo}");
}
}
private void SetSortLayer(GameObject obj, ParallaxLayer layer)
{
SpriteRenderer spriteRenderer = obj.GetComponentInChildren<SpriteRenderer>();
if (spriteRenderer == null) return;
// All parallax objects use the same sort layer
spriteRenderer.sortingLayerName = parallaxSortLayer;
// Calculate sort order based on layer depth
// Background (0): -50, Middle (1): -40, Foreground (2): -30
int sortOrder = backgroundSortOrder + ((int)layer * sortOrderIncrement);
spriteRenderer.sortingOrder = sortOrder;
if (settings.ShowDebugLogs)
{
Logging.Debug($"[ParallaxBackgroundSpawner] Set '{parallaxSortLayer}' layer with sort order {sortOrder} for {layer}");
}
}
private float GetLayerSpeed(ParallaxLayer layer)
{
return layer switch
{
ParallaxLayer.Background => backgroundSpeed,
ParallaxLayer.Middle => middleSpeed,
ParallaxLayer.Foreground => foregroundSpeed,
_ => 1f
};
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b126f2d189024140ac0bc6fb14155c7f
timeCreated: 1765965959

View File

@@ -0,0 +1,238 @@
using Core;
using Core.Settings;
using Minigames.Airplane.Data;
using UnityEngine;
namespace Minigames.Airplane.Core.Spawning
{
/// <summary>
/// Static utility for object positioning in the airplane minigame.
/// Provides common positioning logic used by spawners and managers.
/// Handles snap-to-ground, specified Y, and random Y positioning.
/// </summary>
public static class PositioningUtility
{
/// <summary>
/// Position an object based on the specified spawn mode.
/// Handles all positioning modes: SnapToGround, SpecifiedY, and RandomRange.
/// </summary>
public static void PositionObject(GameObject obj, float xPosition, SpawnPositionMode mode,
float specifiedY, float randomYMin, float randomYMax, IAirplaneSettings settings, bool showDebugLogs = false)
{
if (obj == null)
{
if (showDebugLogs)
{
Logging.Warning("[PositioningUtility] PositionObject called with null object!");
}
return;
}
// First, set an initial Y position based on mode
float initialY;
switch (mode)
{
case SpawnPositionMode.SnapToGround:
// Start at fallback position, SnapToGround will adjust
initialY = settings.FallbackYPosition;
break;
case SpawnPositionMode.SpecifiedY:
initialY = specifiedY;
break;
case SpawnPositionMode.RandomRange:
initialY = Random.Range(randomYMin, randomYMax);
break;
default:
initialY = 0f;
if (showDebugLogs)
{
Logging.Warning($"[PositioningUtility] Unknown spawn position mode: {mode}");
}
break;
}
// Set initial position
obj.transform.position = new Vector3(xPosition, initialY, 0f);
// For SnapToGround mode, now adjust the object to align with ground
if (mode == SpawnPositionMode.SnapToGround)
{
SnapToGround(obj, xPosition, settings, showDebugLogs);
}
// Draw debug visualization AFTER positioning
if (showDebugLogs)
{
DrawObjectBoundsDebug(obj, mode);
}
}
/// <summary>
/// Snap an object to the ground using raycast.
/// Positions object so its bottom bounds touch the TOP surface of the ground.
/// Uses simple measure-and-adjust approach that works with any pivot configuration.
/// </summary>
public static void SnapToGround(GameObject obj, float xPosition, IAirplaneSettings settings, bool showDebugLogs = false)
{
if (obj == null)
{
if (showDebugLogs)
{
Logging.Warning("[PositioningUtility] SnapToGround called with null object!");
}
return;
}
// Raycast from high up to find the TOP of the ground surface
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)
{
if (showDebugLogs)
{
Logging.Warning($"[PositioningUtility] SnapToGround FAILED at X={xPosition:F2} - no ground found! Object stays at current Y.");
}
return; // Can't snap, leave object where it is
}
// Ground surface is at the raycast hit point (TOP of ground collider)
float groundY = hit.point.y;
// Measure where the object's bottom actually is RIGHT NOW
Bounds bounds = GetObjectBounds(obj);
float currentBottom = bounds.min.y;
// Calculate how far off we are from desired position
float offset = currentBottom - groundY;
// Adjust the object's position to align bottom with ground
Vector3 pos = obj.transform.position;
pos.y -= offset; // Move up or down by the difference
obj.transform.position = pos;
if (showDebugLogs)
{
// Draw raycast (cyan)
Debug.DrawLine(rayOrigin, hit.point, Color.cyan, 5f);
// Draw ground hit point (magenta)
Debug.DrawLine(new Vector3(xPosition - 1f, groundY, 0f),
new Vector3(xPosition + 1f, groundY, 0f), Color.magenta, 5f);
// Draw object bottom after snapping (red)
Bounds finalBounds = GetObjectBounds(obj);
Debug.DrawLine(new Vector3(xPosition - 1f, finalBounds.min.y, 0f),
new Vector3(xPosition + 1f, finalBounds.min.y, 0f), Color.red, 5f);
Logging.Debug($"[PositioningUtility] SnapToGround: X={xPosition:F2}, GroundTop={groundY:F2}, " +
$"BottomWas={currentBottom:F2}, Offset={offset:F2}, FinalY={obj.transform.position.y:F2}");
}
}
/// <summary>
/// Get the bounds of an object from its Renderer or Collider.
/// Searches child objects if no component is found on the root.
/// </summary>
public static Bounds GetObjectBounds(GameObject obj)
{
if (obj == null)
{
return new Bounds(Vector3.zero, Vector3.one);
}
// Try renderer first (most common for sprites)
Renderer objRenderer = obj.GetComponentInChildren<Renderer>();
if (objRenderer != null) return objRenderer.bounds;
// Try 2D collider
Collider2D objCollider2D = obj.GetComponentInChildren<Collider2D>();
if (objCollider2D != null) return objCollider2D.bounds;
// Try 3D collider
Collider objCollider3D = obj.GetComponentInChildren<Collider>();
if (objCollider3D != null) return objCollider3D.bounds;
// Fallback to transform position with unit size
return new Bounds(obj.transform.position, Vector3.one);
}
/// <summary>
/// Draw debug visualization for object bounds after positioning.
/// Shows the actual bounds in world space at the object's final position.
/// </summary>
public static void DrawObjectBoundsDebug(GameObject obj, SpawnPositionMode mode)
{
if (obj == null) return;
Bounds bounds = default;
string sourceType = "none";
// Get bounds from whatever component is available
Renderer objRenderer = obj.GetComponentInChildren<Renderer>();
if (objRenderer != null)
{
bounds = objRenderer.bounds;
sourceType = "Renderer";
}
else
{
Collider2D objCollider2D = obj.GetComponentInChildren<Collider2D>();
if (objCollider2D != null)
{
bounds = objCollider2D.bounds;
sourceType = "Collider2D";
}
else
{
Collider objCollider3D = obj.GetComponentInChildren<Collider>();
if (objCollider3D != null)
{
bounds = objCollider3D.bounds;
sourceType = "Collider3D";
}
}
}
if (sourceType == "none") return;
Vector3 center = bounds.center;
Vector3 extents = bounds.extents;
float height = bounds.size.y;
// Draw bounds box (green)
Debug.DrawLine(new Vector3(center.x - extents.x, center.y - extents.y, center.z),
new Vector3(center.x + extents.x, center.y - extents.y, center.z), Color.green, 5f);
Debug.DrawLine(new Vector3(center.x - extents.x, center.y + extents.y, center.z),
new Vector3(center.x + extents.x, center.y + extents.y, center.z), Color.green, 5f);
Debug.DrawLine(new Vector3(center.x - extents.x, center.y - extents.y, center.z),
new Vector3(center.x - extents.x, center.y + extents.y, center.z), Color.green, 5f);
Debug.DrawLine(new Vector3(center.x + extents.x, center.y - extents.y, center.z),
new Vector3(center.x + extents.x, center.y + extents.y, center.z), Color.green, 5f);
// Draw center point (yellow cross)
Debug.DrawLine(new Vector3(center.x - 1f, center.y, center.z),
new Vector3(center.x + 1f, center.y, center.z), Color.yellow, 5f);
Debug.DrawLine(new Vector3(center.x, center.y - 1f, center.z),
new Vector3(center.x, center.y + 1f, center.z), Color.yellow, 5f);
// Draw bottom edge (red thick line) - where we expect the object to touch ground
Debug.DrawLine(new Vector3(center.x - extents.x, center.y - extents.y, center.z),
new Vector3(center.x + extents.x, center.y - extents.y, center.z), Color.red, 5f);
// Draw top edge (cyan)
Debug.DrawLine(new Vector3(center.x - extents.x, center.y + extents.y, center.z),
new Vector3(center.x + extents.x, center.y + extents.y, center.z), Color.cyan, 5f);
Logging.Debug($"[PositioningUtility] ObjectBounds: {obj.name}, Mode={mode}, Source={sourceType}, " +
$"Height={height:F2}, Center.y={center.y:F2}, Bottom.y={center.y - extents.y:F2}, " +
$"Top.y={center.y + extents.y:F2}, Scale={obj.transform.localScale.y:F2}");
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 369dac4c5d0943329fd0918ea52bdd4b
timeCreated: 1766006091

View File

@@ -0,0 +1,24 @@
namespace Minigames.Airplane.Data
{
/// <summary>
/// Defines parallax layer depth for background elements.
/// </summary>
public enum ParallaxLayer
{
/// <summary>
/// Furthest back layer, moves slowest (lowest parallax factor).
/// </summary>
Background,
/// <summary>
/// Middle layer, moves at medium speed.
/// </summary>
Middle,
/// <summary>
/// Closest layer, moves fastest (highest parallax factor).
/// </summary>
Foreground
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 30cd4bc1743c44089146678153542aae
timeCreated: 1765965854

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

@@ -0,0 +1,45 @@
using System;
using UnityEngine;
namespace Minigames.Airplane.Data
{
/// <summary>
/// Configuration for a single spawn pool.
/// Contains prefabs, unlock timing, and optional spawn parameter overrides.
/// </summary>
[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;
/// <summary>
/// Check if this pool has valid prefabs assigned.
/// </summary>
public bool HasPrefabs => prefabs != null && prefabs.Length > 0;
/// <summary>
/// Get effective minimum distance (uses override if non-zero, otherwise uses global).
/// </summary>
public float GetMinDistance(float globalMin) => overrideMinDistance > 0f ? overrideMinDistance : globalMin;
/// <summary>
/// Get effective maximum distance (uses override if non-zero, otherwise uses global).
/// </summary>
public float GetMaxDistance(float globalMax) => overrideMaxDistance > 0f ? overrideMaxDistance : globalMax;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 51d29acb7cdd48819588acd1401456a9
timeCreated: 1765965854

View File

@@ -0,0 +1,21 @@
namespace Minigames.Airplane.Data
{
/// <summary>
/// Defines how multiple spawn pools are combined.
/// </summary>
public enum SpawnPoolMode
{
/// <summary>
/// All unlocked pools contribute to a single shared spawn stream.
/// Objects spawn at regular intervals considering all available pools.
/// </summary>
Together,
/// <summary>
/// Each pool spawns independently with its own timing.
/// Multiple objects can spawn simultaneously from different pools.
/// </summary>
Exclusive
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5206332ad0e54ed4b20df47663202967
timeCreated: 1765965854

View File

@@ -0,0 +1,104 @@
using Core;
using Minigames.Airplane.Data;
using UnityEngine;
namespace Minigames.Airplane.Interactive
{
/// <summary>
/// Parallax element that adjusts position based on camera movement.
/// Creates depth illusion by moving at different speeds for different layers.
/// Configuration is provided by ParallaxBackgroundSpawner at initialization.
/// IMPORTANT: Always calculates position relative to ORIGINAL spawn position and camera,
/// ensuring deterministic behavior during camera blends and switches.
/// </summary>
public class ParallaxElement : MonoBehaviour
{
// Runtime state (set by spawner via Initialize)
private ParallaxLayer _layer;
private float _layerSpeed;
private float _globalStrength;
// Current camera being tracked
private Transform _cameraTransform;
// ORIGINAL spawn state - NEVER reset after initialization
// This ensures objects return to spawn position when camera returns to spawn position
private Vector3 _originalSpawnPosition;
private float _originalCameraX;
private bool _isInitialized;
private void Update()
{
if (!_isInitialized || _cameraTransform == null) return;
ApplyParallax();
}
/// <summary>
/// Initialize the parallax element with spawner-provided settings.
/// Called by ParallaxBackgroundSpawner when spawning.
/// Sets the ORIGINAL spawn position and camera position - these are never reset.
/// IMPORTANT: Uses Camera.main for tracking to ensure smooth updates during Cinemachine blends.
/// </summary>
public void Initialize(ParallaxLayer layer, float layerSpeed, float globalStrength, Transform cameraTransform)
{
_layer = layer;
_layerSpeed = layerSpeed;
_globalStrength = globalStrength;
// ALWAYS use Camera.main for tracking - this ensures smooth updates during Cinemachine blends
// Virtual cameras don't move during blends, but Camera.main does
_cameraTransform = Camera.main != null ? Camera.main.transform : cameraTransform;
// Store ORIGINAL spawn position - never reset
_originalSpawnPosition = transform.position;
if (_cameraTransform != null)
{
// Store ORIGINAL camera X - never reset
_originalCameraX = _cameraTransform.position.x;
_isInitialized = true;
}
}
/// <summary>
/// Update the camera transform when camera changes.
/// Called by ParallaxBackgroundSpawner when camera switches or blends.
/// ONLY updates the camera reference - does NOT reset start position or camera X.
/// This ensures parallax remains deterministic across camera switches.
/// IMPORTANT: Always uses Camera.main to ensure smooth updates during blends.
/// </summary>
public void UpdateCamera(Transform newCameraTransform)
{
// ALWAYS use Camera.main for tracking - this ensures smooth updates during Cinemachine blends
// We ignore the passed transform and always use Camera.main
Transform mainCamera = Camera.main != null ? Camera.main.transform : newCameraTransform;
if (mainCamera == null) return;
// Simply update the camera reference - do NOT reset positions
// The parallax will continue to calculate relative to original spawn state
_cameraTransform = mainCamera;
_isInitialized = true;
}
private void ApplyParallax()
{
// Calculate camera displacement from ORIGINAL camera position
// This ensures objects return to spawn position when camera returns to spawn position
float cameraDisplacement = _cameraTransform.position.x - _originalCameraX;
// Calculate parallax offset - negative to move opposite direction
// Lower speed = appears further away (moves less)
// Camera moves right (+) → background moves left (-) at reduced speed
float parallaxOffset = -cameraDisplacement * _layerSpeed * _globalStrength;
// Apply offset to ORIGINAL spawn position
Vector3 newPosition = _originalSpawnPosition;
newPosition.x += parallaxOffset;
transform.position = newPosition;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b32c96c5365743d3a1a5c849ba340b6e
timeCreated: 1765965978

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

View File

@@ -117,8 +117,12 @@ namespace Minigames.BirdPooper
/// </summary>
protected virtual void OnValidate()
{
// Skip validation for prefab assets (only run on scene instances)
if (UnityEditor.PrefabUtility.IsPartOfPrefabAsset(this))
return;
// Only run in editor, not during play mode
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode)
if (Application.isPlaying || !Application.isEditor)
return;
EdgeAnchor anchor = GetComponent<EdgeAnchor>();