Stash paralax work

This commit is contained in:
Michal Pikulski
2025-12-17 12:39:44 +01:00
parent 4ce61ee756
commit 7a598c302c
21 changed files with 1404 additions and 523 deletions

View File

@@ -2457,6 +2457,37 @@ MonoBehaviour:
trajectoryPreview: {fileID: 1309397783} trajectoryPreview: {fileID: 1309397783}
showDebugLogs: 0 showDebugLogs: 0
airplanePrefab: {fileID: 2043346932243838886, guid: 582ed0c37f4ec6c4e930ddabea174eca, type: 3} 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 --- !u!1001 &1414189130
PrefabInstance: PrefabInstance:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -3702,6 +3733,7 @@ GameObject:
m_Component: m_Component:
- component: {fileID: 1784207402} - component: {fileID: 1784207402}
- component: {fileID: 1784207403} - component: {fileID: 1784207403}
- component: {fileID: 1784207404}
m_Layer: 0 m_Layer: 0
m_Name: SpawnManager m_Name: SpawnManager
m_TagString: Untagged m_TagString: Untagged
@@ -3724,6 +3756,7 @@ Transform:
m_Children: m_Children:
- {fileID: 1219431443} - {fileID: 1219431443}
- {fileID: 1287102785} - {fileID: 1287102785}
- {fileID: 1405572709}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1784207403 --- !u!114 &1784207403
@@ -3738,6 +3771,9 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 70f14ee4b04b46b793ec2652fd2ca7b9, type: 3} m_Script: {fileID: 11500000, guid: 70f14ee4b04b46b793ec2652fd2ca7b9, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: AppleHillsScripts::Minigames.Airplane.Core.AirplaneSpawnManager m_EditorClassIdentifier: AppleHillsScripts::Minigames.Airplane.Core.AirplaneSpawnManager
obstacleSpawner: {fileID: 0}
groundSpawner: {fileID: 0}
parallaxSpawner: {fileID: 0}
targetPrefabs: targetPrefabs:
- targetKey: bob_target - targetKey: bob_target
prefab: {fileID: 3207629437433571205, guid: 9f4bb48933059e543b60ac782d2140d8, type: 3} prefab: {fileID: 3207629437433571205, guid: 9f4bb48933059e543b60ac782d2140d8, type: 3}
@@ -3751,42 +3787,37 @@ MonoBehaviour:
specifiedY: 0 specifiedY: 0
randomYMin: -5 randomYMin: -5
randomYMax: 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} targetDisplayUI: {fileID: 1520040329}
launchController: {fileID: 1309397785} launchController: {fileID: 1309397785}
dynamicSpawnThresholdMarker: {fileID: 1653173574} dynamicSpawnThresholdMarker: {fileID: 1653173574}
spawnedObjectsParent: {fileID: 1219431443} targetParent: {fileID: 1405572709}
groundTilesParent: {fileID: 1287102785}
groundSpawnY: -5
showDebugLogs: 0 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 --- !u!1 &1810521056
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

View File

@@ -71,6 +71,35 @@ namespace Minigames.Airplane.Core
#endregion #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 #region Flight Camera Follow
/// <summary> /// <summary>

View File

@@ -459,6 +459,8 @@ namespace Minigames.Airplane.Core
if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] Introducing {_currentPerson.PersonName}..."); 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 // Blend to next person camera if not already there
// (Person shuffle flow already blends to NextPerson, so might already be there) // (Person shuffle flow already blends to NextPerson, so might already be there)
if (cameraManager != null && cameraManager.CurrentState != AirplaneCameraState.NextPerson) if (cameraManager != null && cameraManager.CurrentState != AirplaneCameraState.NextPerson)

View File

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

View File

@@ -0,0 +1,449 @@
using System.Collections.Generic;
using Core;
using Core.Lifecycle;
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.
/// Derived classes implement specific spawn logic via SpawnFromPool.
/// </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;
[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<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. Call this before starting spawning.
/// </summary>
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
/// <summary>
/// Start tracking the plane and enable spawning.
/// </summary>
public void StartTracking(Transform planeTransform, float startX)
{
PlaneTransform = planeTransform;
IsSpawning = true;
GameTime = 0f;
LastSpawnedX = startX;
// Initialize next spawn position
if (poolMode == SpawnPoolMode.Together)
{
NextSpawnX = startX + Random.Range(globalMinDistance, globalMaxDistance);
}
else // Exclusive
{
foreach (int poolIndex in UnlockedPoolIndices)
{
float minDist = pools[poolIndex].GetMinDistance(globalMinDistance);
float maxDist = pools[poolIndex].GetMaxDistance(globalMaxDistance);
PoolNextSpawnX[poolIndex] = startX + Random.Range(minDist, maxDist);
}
}
if (showDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Started tracking from X={startX}");
}
}
/// <summary>
/// Stop spawning and tracking.
/// </summary>
public void StopTracking()
{
IsSpawning = false;
PlaneTransform = null;
if (showDebugLogs)
{
Logging.Debug($"[{GetType().Name}] Stopped tracking");
}
}
/// <summary>
/// Update game time and check for pool unlocks.
/// Call this every frame from the orchestrator.
/// </summary>
public void UpdateGameTime(float deltaTime)
{
GameTime += deltaTime;
CheckPoolUnlocks();
}
/// <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>
/// Update spawning based on plane position.
/// Call this every frame from the orchestrator.
/// </summary>
public virtual void UpdateSpawning(float spawnAheadDistance)
{
if (!IsSpawning || PlaneTransform == null) return;
float planeX = PlaneTransform.position.x;
if (poolMode == SpawnPoolMode.Together)
{
UpdateSpawningTogether(planeX, spawnAheadDistance);
}
else
{
UpdateSpawningExclusive(planeX, spawnAheadDistance);
}
}
/// <summary>
/// Clean up all spawned objects.
/// </summary>
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<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 < 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<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)
{
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);
}
/// <summary>
/// Spawn a specific prefab from a pool at the given X position.
/// Override this in derived classes to implement specific spawn logic.
/// </summary>
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
}
}

View File

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

View File

@@ -0,0 +1,38 @@
using Core;
using UnityEngine;
namespace Minigames.Airplane.Core.Spawning
{
/// <summary>
/// Spawns ground tiles at fixed intervals.
/// Inherits distance-based spawning from BaseDistanceSpawner.
/// </summary>
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}");
}
}
}
}

View File

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

View File

@@ -0,0 +1,199 @@
using Core;
using Minigames.Airplane.Data;
using Minigames.Airplane.Interactive;
using UnityEngine;
namespace Minigames.Airplane.Core.Spawning
{
/// <summary>
/// Spawns obstacle objects (positive and negative) with weighted ratio management.
/// Inherits distance-based spawning from BaseDistanceSpawner.
/// </summary>
public class ObstacleDistanceSpawner : BaseDistanceSpawner
{
[Header("Obstacle-Specific")]
[Tooltip("Ratio of positive to negative objects (0 = all negative, 1 = all positive)")]
[Range(0f, 1f)]
[SerializeField] private float positiveNegativeRatio = 0.5f;
[Tooltip("Array indices that should be treated as positive objects")]
[SerializeField] private int[] positivePoolIndices;
[Tooltip("Array indices that should be treated as negative objects")]
[SerializeField] private int[] negativePoolIndices;
[Header("Positioning")]
[SerializeField] private int groundLayer;
[SerializeField] private float maxGroundRaycastDistance = 50f;
[SerializeField] private float defaultObjectYOffset;
private int _positiveSpawnCount;
private int _negativeSpawnCount;
public override void Initialize()
{
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<PrefabSpawnEntryComponent>();
if (spawnEntry != null)
{
PositionObject(instance, xPosition, spawnEntry.spawnPositionMode,
spawnEntry.specifiedY, spawnEntry.randomYMin, spawnEntry.randomYMax);
}
// Initialize if implements interface
var initializable = instance.GetComponent<ISpawnInitializable>();
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<Renderer>();
if (objRenderer != null) return objRenderer.bounds;
Collider2D objCollider2D = obj.GetComponentInChildren<Collider2D>();
if (objCollider2D != null) return objCollider2D.bounds;
Collider objCollider3D = obj.GetComponentInChildren<Collider>();
if (objCollider3D != null) return objCollider3D.bounds;
return new Bounds(obj.transform.position, Vector3.one);
}
public override void Cleanup()
{
base.Cleanup();
_positiveSpawnCount = 0;
_negativeSpawnCount = 0;
}
}
/// <summary>
/// Helper component to store spawn entry data on prefabs.
/// Attach this to prefabs that need specific positioning.
/// </summary>
public class PrefabSpawnEntryComponent : MonoBehaviour
{
public SpawnPositionMode spawnPositionMode = SpawnPositionMode.SnapToGround;
public float specifiedY;
public float randomYMin = -5f;
public float randomYMax = 5f;
}
}

View File

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

View File

@@ -0,0 +1,146 @@
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 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<ParallaxElement>();
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<ParallaxElement>();
if (parallaxElement == null)
{
parallaxElement = instance.AddComponent<ParallaxElement>();
}
// 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<SpriteRenderer>();
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}");
}
}
}
}

View File

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

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,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,150 @@
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.
/// Continuously tracks active camera for seamless transitions.
/// </summary>
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();
}
/// <summary>
/// Set the parallax layer for this element.
/// </summary>
public void SetLayer(ParallaxLayer newLayer)
{
layer = newLayer;
EnsureSortLayer();
if (showDebugLogs)
{
Logging.Debug($"[ParallaxElement] Layer set to {layer}");
}
}
/// <summary>
/// Set the camera transform to track.
/// Call this when camera changes.
/// </summary>
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<SpriteRenderer>();
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}");
}
}
}
}
}

View File

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