From c6d2ca4e5ca012108e9655e2990c6f74e454568d Mon Sep 17 00:00:00 2001 From: Michal Pikulski Date: Wed, 17 Dec 2025 00:55:47 +0100 Subject: [PATCH] Valentine Notes Dlivery game flow changes and improvements. --- .../Airplane/AirplaneGameBlends.asset | 22 + .../MiniGames/ValentineNoteDelivery.unity | 209 ++++--- .../Scripts/Cinematics/LevelIntroDirector.cs | 59 +- .../Common/Camera/CameraStateManager.cs | 163 +++++ .../Core/Settings/SettingsInterfaces.cs | 3 + .../Airplane/Core/AirplaneCameraManager.cs | 24 + .../Airplane/Core/AirplaneGameManager.cs | 575 ++++++++++++------ .../Airplane/Core/AirplaneSpawnManager.cs | 367 ++++++++--- .../Minigames/Airplane/Core/PersonQueue.cs | 69 +-- .../Airplane/Data/AirplaneCameraState.cs | 3 +- .../Airplane/Settings/AirplaneSettings.cs | 13 +- 11 files changed, 1095 insertions(+), 412 deletions(-) diff --git a/Assets/Prefabs/Minigames/Airplane/AirplaneGameBlends.asset b/Assets/Prefabs/Minigames/Airplane/AirplaneGameBlends.asset index f203d504..c01400cc 100644 --- a/Assets/Prefabs/Minigames/Airplane/AirplaneGameBlends.asset +++ b/Assets/Prefabs/Minigames/Airplane/AirplaneGameBlends.asset @@ -24,3 +24,25 @@ MonoBehaviour: m_PreInfinity: 0 m_PostInfinity: 0 m_RotationOrder: 0 + - From: '**ANY CAMERA**' + To: TargetCamera + Blend: + Style: 6 + Time: 3 + CustomCurve: + serializedVersion: 2 + m_Curve: [] + m_PreInfinity: 0 + m_PostInfinity: 0 + m_RotationOrder: 0 + - From: TargetCamera + To: '**ANY CAMERA**' + Blend: + Style: 1 + Time: 1 + CustomCurve: + serializedVersion: 2 + m_Curve: [] + m_PreInfinity: 0 + m_PostInfinity: 0 + m_RotationOrder: 0 diff --git a/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity b/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity index 87445600..3fa7828d 100644 --- a/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity +++ b/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity @@ -788,8 +788,11 @@ MonoBehaviour: camera: {fileID: 761345710} - state: 3 camera: {fileID: 263322553} + - state: 4 + camera: {fileID: 1842736650} inactivePriority: 10 activePriority: 20 + cinemachineBrain: {fileID: 1810521058} showDebugLogs: 0 --- !u!4 &597044887 Transform: @@ -3948,7 +3951,7 @@ Transform: m_GameObject: {fileID: 1810521056} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 12.800001, z: -10} + m_LocalPosition: {x: -18.9, y: 9.100001, z: -10} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] @@ -4136,6 +4139,101 @@ SpriteRenderer: m_SpriteTileMode: 0 m_WasSpriteAssigned: 1 m_SpriteSortPoint: 0 +--- !u!1 &1842736649 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1842736651} + - component: {fileID: 1842736650} + - component: {fileID: 1842736652} + m_Layer: 0 + m_Name: TargetCamera + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1842736650 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1842736649} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f9dfa5b682dcd46bda6128250e975f58, type: 3} + m_Name: + m_EditorClassIdentifier: + Priority: + Enabled: 0 + m_Value: 0 + OutputChannel: 1 + StandbyUpdate: 2 + m_StreamingVersion: 20241001 + m_LegacyPriority: 0 + Target: + TrackingTarget: {fileID: 0} + LookAtTarget: {fileID: 0} + CustomLookAtTarget: 0 + Lens: + FieldOfView: 60 + OrthographicSize: 20 + NearClipPlane: 0.3 + FarClipPlane: 1000 + Dutch: 0 + ModeOverride: 0 + PhysicalProperties: + GateFit: 2 + SensorSize: {x: 21.946, y: 16.002} + LensShift: {x: 0, y: 0} + FocusDistance: 10 + Iso: 200 + ShutterSpeed: 0.005 + Aperture: 16 + BladeCount: 5 + Curvature: {x: 2, y: 11} + BarrelClipping: 0.25 + Anamorphism: 0 + BlendHint: 0 +--- !u!4 &1842736651 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1842736649} + serializedVersion: 2 + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -9.6, y: 12.900002, z: -10} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1842736652 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1842736649} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b617507da6d07e749b7efdb34e1173e1, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Cinemachine::Unity.Cinemachine.CinemachineFollow + TrackerSettings: + BindingMode: 4 + PositionDamping: {x: 1, y: 1, z: 1} + AngularDampingMode: 0 + RotationDamping: {x: 1, y: 1, z: 1} + QuaternionDamping: 1 + FollowOffset: {x: 0, y: 0, z: -10} --- !u!1 &1897459173 GameObject: m_ObjectHideFlags: 0 @@ -4895,6 +4993,37 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &2041920295 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2041920296} + m_Layer: 0 + m_Name: GameObject + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2041920296 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2041920295} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 16.8, y: 12.6, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &2049960991 GameObject: m_ObjectHideFlags: 0 @@ -5024,81 +5153,6 @@ CanvasRenderer: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2090212067} m_CullTransparentMesh: 1 ---- !u!1 &2103114174 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 2103114178} - - component: {fileID: 2103114177} - m_Layer: 0 - m_Name: CinemachineCamera - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &2103114177 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2103114174} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: f9dfa5b682dcd46bda6128250e975f58, type: 3} - m_Name: - m_EditorClassIdentifier: - Priority: - Enabled: 0 - m_Value: 0 - OutputChannel: 1 - StandbyUpdate: 2 - m_StreamingVersion: 20241001 - m_LegacyPriority: 0 - Target: - TrackingTarget: {fileID: 0} - LookAtTarget: {fileID: 0} - CustomLookAtTarget: 0 - Lens: - FieldOfView: 60 - OrthographicSize: 15 - NearClipPlane: 0.3 - FarClipPlane: 1000 - Dutch: 0 - ModeOverride: 0 - PhysicalProperties: - GateFit: 2 - SensorSize: {x: 21.946, y: 16.002} - LensShift: {x: 0, y: 0} - FocusDistance: 10 - Iso: 200 - ShutterSpeed: 0.005 - Aperture: 16 - BladeCount: 5 - Curvature: {x: 2, y: 11} - BarrelClipping: 0.25 - Anamorphism: 0 - BlendHint: 0 ---- !u!4 &2103114178 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2103114174} - serializedVersion: 2 - m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 0, y: 12.800001, z: -10} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &2111947703 GameObject: m_ObjectHideFlags: 0 @@ -5578,7 +5632,6 @@ SceneRoots: m_ObjectHideFlags: 0 m_Roots: - {fileID: 1810521061} - - {fileID: 2103114178} - {fileID: 742382011} - {fileID: 2125208580} - {fileID: 1239857187} @@ -5596,8 +5649,10 @@ SceneRoots: - {fileID: 595230075} - {fileID: 2041347574} - {fileID: 761345711} + - {fileID: 1842736651} - {fileID: 263322554} - {fileID: 1017291799} - {fileID: 1064542409} - {fileID: 1701327424} + - {fileID: 2041920296} - {fileID: 1710395163} diff --git a/Assets/Scripts/Cinematics/LevelIntroDirector.cs b/Assets/Scripts/Cinematics/LevelIntroDirector.cs index 7a9a07f6..1224ddbe 100644 --- a/Assets/Scripts/Cinematics/LevelIntroDirector.cs +++ b/Assets/Scripts/Cinematics/LevelIntroDirector.cs @@ -1,41 +1,42 @@ -using UnityEngine; -using Core; using Core.Lifecycle; -using UnityEngine.Playables; using Input; -using Unity.Cinemachine; +using UnityEngine; +using UnityEngine.Playables; -public class LevelIntroDirector : ManagedBehaviour +namespace Cinematics { - public bool playOnSceneReady; - - [HideInInspector] - public PlayableDirector introPlayableDirector; - - internal override void OnSceneReady() + public class LevelIntroDirector : ManagedBehaviour { - base.OnSceneReady(); - if (playOnSceneReady) + public bool playOnSceneReady; + + [HideInInspector] + public PlayableDirector introPlayableDirector; + + internal override void OnSceneReady() { - introPlayableDirector = GetComponent(); - introPlayableDirector.stopped += IntroTimelineStopped; - PlayIntroTimeline(); + base.OnSceneReady(); + if (playOnSceneReady) + { + introPlayableDirector = GetComponent(); + introPlayableDirector.stopped += IntroTimelineStopped; + PlayIntroTimeline(); + } + else { gameObject.SetActive(false); } } - else { gameObject.SetActive(false); } - } - private void IntroTimelineStopped(PlayableDirector director) - { - InputManager.Instance.SetInputMode(InputMode.Game); - introPlayableDirector.stopped -= IntroTimelineStopped; - gameObject.SetActive(false); + private void IntroTimelineStopped(PlayableDirector director) + { + InputManager.Instance.SetInputMode(InputMode.Game); + introPlayableDirector.stopped -= IntroTimelineStopped; + gameObject.SetActive(false); - } + } - public void PlayIntroTimeline() - { - introPlayableDirector.Play(); - InputManager.Instance.SetInputMode(InputMode.InputDisabled); - } + public void PlayIntroTimeline() + { + introPlayableDirector.Play(); + InputManager.Instance.SetInputMode(InputMode.InputDisabled); + } + } } diff --git a/Assets/Scripts/Common/Camera/CameraStateManager.cs b/Assets/Scripts/Common/Camera/CameraStateManager.cs index 4a437a71..32a9bed8 100644 --- a/Assets/Scripts/Common/Camera/CameraStateManager.cs +++ b/Assets/Scripts/Common/Camera/CameraStateManager.cs @@ -49,6 +49,10 @@ namespace Common.Camera [Tooltip("Priority for the active camera")] [SerializeField] protected int activePriority = 20; + [Header("Cinemachine Brain")] + [Tooltip("CinemachineBrain for blend detection (auto-finds if null)")] + [SerializeField] protected CinemachineBrain cinemachineBrain; + [Header("Debug")] [SerializeField] protected bool showDebugLogs = false; @@ -60,6 +64,11 @@ namespace Common.Camera private TState _currentState; private bool _isInitialized = false; + // Event-driven blend tracking + private CinemachineCamera _pendingBlendTarget; + private bool _isBlendComplete; + private Action _pendingBlendCallback; + public TState CurrentState => _currentState; #endregion @@ -71,6 +80,11 @@ namespace Common.Camera /// public event Action OnStateChanged; + /// + /// Fired when camera blend completes after state switch + /// + public event Action OnBlendComplete; + #endregion #region Lifecycle @@ -84,6 +98,17 @@ namespace Common.Camera { base.OnManagedAwake(); + // Auto-find CinemachineBrain if not assigned + if (cinemachineBrain == null) + { + cinemachineBrain = UnityEngine.Camera.main?.GetComponent(); + + if (cinemachineBrain == null && showDebugLogs) + { + Logging.Warning($"[{GetType().Name}] CinemachineBrain not found. Blend tracking will be unavailable."); + } + } + // Initialize cameras from Inspector mappings InitializeCameraMap(); @@ -91,6 +116,35 @@ namespace Common.Camera ValidateCameras(); } + /// + /// Subscribe to Cinemachine events on enable + /// + private void OnEnable() + { + // Subscribe to Cinemachine global events + CinemachineCore.BlendFinishedEvent.AddListener(OnBlendFinished); + CinemachineCore.CameraActivatedEvent.AddListener(OnCameraActivated); + + if (showDebugLogs) + Logging.Debug($"[{GetType().Name}] Subscribed to Cinemachine events"); + } + + /// + /// Unsubscribe from Cinemachine events on disable + /// + private void OnDisable() + { + // Unsubscribe from Cinemachine events to prevent memory leaks + CinemachineCore.BlendFinishedEvent.RemoveListener(OnBlendFinished); + CinemachineCore.CameraActivatedEvent.RemoveListener(OnCameraActivated); + + // Clear any pending callbacks + _pendingBlendCallback = null; + + if (showDebugLogs) + Logging.Debug($"[{GetType().Name}] Unsubscribed from Cinemachine events"); + } + #endregion #region Initialization @@ -229,6 +283,115 @@ namespace Common.Camera return _cameraMap.ContainsKey(state); } + /// + /// Blend to a state and wait asynchronously (coroutine). + /// Yields until Cinemachine blend event fires. Event-driven, no polling. + /// Use this when you need to wait for the blend to complete before continuing. + /// + public System.Collections.IEnumerator BlendToStateAsync(TState newState) + { + // Reset completion flag + _isBlendComplete = false; + + // Set pending target camera + if (!_cameraMap.TryGetValue(newState, out _pendingBlendTarget)) + { + Logging.Error($"[{GetType().Name}] No camera for state {newState}"); + yield break; + } + + // Switch camera state (triggers blend) + SwitchToState(newState); + + // Fallback: if no brain, complete immediately + if (cinemachineBrain == null) + { + if (showDebugLogs) + Logging.Warning($"[{GetType().Name}] No brain, completing blend immediately"); + yield break; + } + + // Wait for event to fire (event handlers set _isBlendComplete = true) + yield return new WaitUntil(() => _isBlendComplete); + + if (showDebugLogs) + Logging.Debug($"[{GetType().Name}] Blend to {newState} completed (event-driven)"); + } + + /// + /// Blend to a state with callback invoked on completion. + /// Callback fires when Cinemachine blend event fires. Event-driven, no polling. + /// Use this when you want to perform an action after the blend completes. + /// + public void BlendToState(TState newState, Action onComplete) + { + // Set pending target camera + if (!_cameraMap.TryGetValue(newState, out _pendingBlendTarget)) + { + Logging.Error($"[{GetType().Name}] No camera for state {newState}"); + onComplete?.Invoke(); // Still invoke to prevent hanging + return; + } + + // Store callback + _pendingBlendCallback = onComplete; + + // Switch camera state (triggers blend) + SwitchToState(newState); + + // Fallback: if no brain, invoke callback immediately + if (cinemachineBrain == null) + { + if (showDebugLogs) + Logging.Warning($"[{GetType().Name}] No brain, invoking callback immediately"); + CompleteBlend(); + } + + // Event handlers will invoke callback when blend finishes + } + + #endregion + + #region Event Handlers + + /// + /// Called when Cinemachine finishes a blend (non-zero length blends) + /// + private void OnBlendFinished(ICinemachineMixer mixer, ICinemachineCamera cam) + { + // Filter: only respond to blends from our brain to our expected camera + if (mixer == cinemachineBrain && cam == _pendingBlendTarget) + { + CompleteBlend(); + } + } + + /// + /// Called when Cinemachine activates a camera (handles instant cuts) + /// + private void OnCameraActivated(ICinemachineCamera.ActivationEventParams evt) + { + // Filter: only respond to cuts from our brain to our expected camera + if (evt.Origin == cinemachineBrain && evt.IncomingCamera == _pendingBlendTarget && evt.IsCut) + { + CompleteBlend(); + } + } + + /// + /// Mark blend as complete and fire callbacks + /// + private void CompleteBlend() + { + _isBlendComplete = true; + _pendingBlendCallback?.Invoke(); + _pendingBlendCallback = null; + OnBlendComplete?.Invoke(); + + if (showDebugLogs) + Logging.Debug($"[{GetType().Name}] Blend completed, callbacks invoked"); + } + #endregion #region Validation diff --git a/Assets/Scripts/Core/Settings/SettingsInterfaces.cs b/Assets/Scripts/Core/Settings/SettingsInterfaces.cs index 41361788..a4f90f61 100644 --- a/Assets/Scripts/Core/Settings/SettingsInterfaces.cs +++ b/Assets/Scripts/Core/Settings/SettingsInterfaces.cs @@ -332,8 +332,11 @@ namespace Core.Settings float IntroDuration { get; } float PersonIntroDuration { get; } float EvaluationDuration { get; } + float TargetFlybyLingerDuration { get; } + float TargetFlybyCameraBlendTime { get; } // Spawn System + float PreSpawnBeyondTargetDistance { get; } float TargetMinDistance { get; } float TargetMaxDistance { get; } float ObjectSpawnMinDistance { get; } // Min distance between spawned objects diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs index cda8eff0..97138e3d 100644 --- a/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs +++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs @@ -114,6 +114,30 @@ namespace Minigames.Airplane.Core if (showDebugLogs) Logging.Debug("[AirplaneCameraManager] Stopped following airplane"); } + /// + /// Set the target flyby camera to track the target's position + /// + public void SetTargetFlybyTracking(Transform targetTransform) + { + var flybyCamera = GetCamera(AirplaneCameraState.TargetFlyby); + if (flybyCamera == null) + { + Logging.Warning("[AirplaneCameraManager] Cannot set flyby tracking - TargetFlyby camera not assigned!"); + return; + } + + if (targetTransform == null) + { + Logging.Warning("[AirplaneCameraManager] Cannot set flyby tracking - target transform is null!"); + return; + } + + // Set the tracking target on the flyby camera + flybyCamera.Target.TrackingTarget = targetTransform; + + if (showDebugLogs) Logging.Debug($"[AirplaneCameraManager] TargetFlyby camera now tracking: {targetTransform.gameObject.name}"); + } + #endregion } } diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs index a1d12df3..c05c04ca 100644 --- a/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs +++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs @@ -66,7 +66,7 @@ namespace Minigames.Airplane.Core #region State - private AirplaneGameState _currentState = AirplaneGameState.AirplaneSelection; + private AirplaneGameState _currentState = AirplaneGameState.Intro; private Person _currentPerson; private Person _previousPerson; private AirplaneController _currentAirplane; @@ -74,7 +74,7 @@ namespace Minigames.Airplane.Core private int _successCount; private int _failCount; private int _totalTurns; - private AirplaneAbilityType _selectedAirplaneType; + private IAirplaneSettings _cachedSettings; public AirplaneGameState CurrentState => _currentState; public Person CurrentPerson => _currentPerson; @@ -98,6 +98,13 @@ namespace Minigames.Airplane.Core } _instance = this; + // Cache settings for performance + _cachedSettings = GameManager.GetSettingsObject(); + if (_cachedSettings == null) + { + Logging.Error("[AirplaneGameManager] Failed to load IAirplaneSettings!"); + } + // Validate references ValidateReferences(); } @@ -216,74 +223,43 @@ namespace Minigames.Airplane.Core { if (showDebugLogs) Logging.Debug("[AirplaneGameManager] ===== GAME STARTING ====="); - // Start with intro camera blend, THEN show selection UI StartCoroutine(IntroSequence()); } + #endregion + + #region Turn Type Helpers + /// - /// Airplane selection sequence: show selection UI, wait for player choice - /// Called AFTER intro camera blend + /// Check if this is the first turn of the game /// - private IEnumerator AirplaneSelectionSequence() + private bool IsFirstTurn() { - ChangeState(AirplaneGameState.AirplaneSelection); - - if (showDebugLogs) Logging.Debug("[AirplaneGameManager] === AIRPLANE SELECTION STARTING ==="); - - // Show selection UI - if (selectionUI != null) - { - if (showDebugLogs) - { - Logging.Debug($"[AirplaneGameManager] SelectionUI found! GameObject: {selectionUI.gameObject.name}, Active: {selectionUI.gameObject.activeSelf}"); - } - - selectionUI.Show(); - - if (showDebugLogs) - { - Logging.Debug($"[AirplaneGameManager] Called selectionUI.Show(). GameObject now active: {selectionUI.gameObject.activeSelf}"); - } - - // Wait for player to select and confirm - yield return new WaitUntil(() => selectionUI.HasSelectedType); - - _selectedAirplaneType = selectionUI.GetSelectedType(); - selectionUI.Hide(); - - if (showDebugLogs) - { - Logging.Debug($"[AirplaneGameManager] Selected airplane: {_selectedAirplaneType}"); - } - } - else - { - Logging.Warning("[AirplaneGameManager] ⚠️ selectionUI is NULL! Cannot show selection UI. Check Inspector."); - Logging.Warning("[AirplaneGameManager] Using default airplane type from settings as fallback."); - - // Fallback: use default type from settings - var settings = GameManager.GetSettingsObject(); - if (settings != null) - { - _selectedAirplaneType = settings.DefaultAirplaneType; - - if (showDebugLogs) - { - Logging.Debug($"[AirplaneGameManager] No selection UI, using default: {_selectedAirplaneType}"); - } - } - else - { - _selectedAirplaneType = AirplaneAbilityType.Jet; // Ultimate fallback - } - } - - // Continue with hellos after selection - yield return StartCoroutine(IntroHellosSequence()); + return _totalTurns == 0; } /// - /// Intro sequence: blend to intro camera, THEN show airplane selection + /// Check if this is a new person (different from previous) + /// + private bool IsNewPerson() + { + return _previousPerson != _currentPerson; + } + + /// + /// Check if this is a retry turn (same person as previous) + /// + private bool IsRetryTurn() + { + return !IsNewPerson() && _previousPerson != null; + } + + #endregion + + #region Flow: Intro Sequence + + /// + /// Initial intro sequence: blend to intro camera, introduce all people (stub), then start new person flow /// private IEnumerator IntroSequence() { @@ -291,116 +267,50 @@ namespace Minigames.Airplane.Core if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Playing intro sequence..."); - // 1. Blend to intro camera + // 1. Blend to intro camera and wait for blend to complete if (cameraManager != null) { - cameraManager.SwitchToState(AirplaneCameraState.Intro); - yield return new WaitForSeconds(0.5f); // Camera blend time + yield return StartCoroutine(cameraManager.BlendToStateAsync(AirplaneCameraState.Intro)); } - if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Intro camera ready. Now showing airplane selection..."); + // 2. Introduce all people (stub for now) + yield return StartCoroutine(IntroduceAllPeople()); - // 2. Show airplane selection UI and wait for player choice - yield return StartCoroutine(AirplaneSelectionSequence()); + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Intro complete, moving to first person..."); + + // 3. Start first person flow + yield return StartCoroutine(ExecuteNewPersonFlow()); } /// - /// Hellos sequence: all people greet, then blend to aiming camera - /// Called AFTER airplane selection is complete + /// Stub: All people say hello animation. Empty for now. /// - private IEnumerator IntroHellosSequence() + private IEnumerator IntroduceAllPeople() { - if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Starting hellos sequence..."); - - // 1. Iterate over each person and allow them to say their hellos - if (personQueue != null && personQueue.HasMorePeople()) - { - if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Introducing all people..."); - - // Get all people from queue without removing them - var allPeople = personQueue.GetAllPeople(); - foreach (var person in allPeople) - { - if (person != null) - { - // Wait for each person's greeting to complete - yield return StartCoroutine(person.OnHello()); - } - } - - if (showDebugLogs) Logging.Debug("[AirplaneGameManager] All introductions complete"); - } - - // 2. Blend to aiming camera (first person's turn will start) - if (cameraManager != null) - { - cameraManager.SwitchToState(AirplaneCameraState.Aiming); - yield return new WaitForSeconds(0.5f); // Camera blend time - } - - if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Intro complete"); - - // Move to first person's turn - StartCoroutine(SetupNextPerson()); + // TODO: All people say hello animation + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] (Stub) All people introduction"); + yield return null; } + #endregion + + #region Flow: New Person Flow + /// - /// Setup the next person's turn + /// New Person Flow: Executed for first person or after successful hit. + /// Steps: Pop person → Introduce → Prepare level → Flyby → Select airplane → Aim → Shoot /// - private IEnumerator SetupNextPerson() + private IEnumerator ExecuteNewPersonFlow() { // Check if there are more people if (personQueue == null || !personQueue.HasMorePeople()) { if (showDebugLogs) Logging.Debug("[AirplaneGameManager] No more people, ending game"); - StartCoroutine(GameOver()); + yield return StartCoroutine(GameOver()); yield break; } - ChangeState(AirplaneGameState.NextPerson); - - // If this is NOT the first turn, handle post-shot reaction - if (_currentPerson != null) - { - // Switch to next person camera for reaction/transition - if (cameraManager != null) - { - cameraManager.SwitchToState(AirplaneCameraState.NextPerson); - - // Wait for camera blend to complete before cleanup and reaction - yield return new WaitForSeconds(0.5f); // Camera blend time - } - - // NOW cleanup spawned objects after camera has blended (camera shows scene before cleanup) - if (spawnManager != null) - { - if (_lastShotHit) - { - // Success: Full cleanup - destroy all spawned objects and target - spawnManager.CleanupSpawnedObjects(); - - if (showDebugLogs) - { - Logging.Debug("[AirplaneGameManager] Cleaned up spawned objects after successful shot"); - } - } - else - { - // Failure: Keep spawned objects for retry, just reset tracking state - spawnManager.ResetForRetry(); - - if (showDebugLogs) - { - Logging.Debug("[AirplaneGameManager] Kept spawned objects for retry after miss"); - } - } - } - - // Handle the previous person's reaction (celebrate/disappointment), removal (if hit), and shuffle - yield return StartCoroutine(personQueue.HandlePostShotReaction(_lastShotHit)); - } - - // Get the next person (now at front of queue after potential removal) + // Pop next person from queue _previousPerson = _currentPerson; _currentPerson = personQueue.PopNextPerson(); _totalTurns++; @@ -411,76 +321,308 @@ namespace Minigames.Airplane.Core yield break; } - // Check if this is a NEW person (different from previous) or a retry (same person) - bool isNewPerson = _previousPerson != _currentPerson; - - if (showDebugLogs) + if (showDebugLogs) { - string turnType = isNewPerson ? "NEW PERSON" : "RETRY"; - Logging.Debug($"[AirplaneGameManager] === Turn {_totalTurns}: {_currentPerson.PersonName} ({turnType}) ===" + + Logging.Debug($"[AirplaneGameManager] === NEW PERSON FLOW: Turn {_totalTurns}: {_currentPerson.PersonName} ===" + $"\n Target: {_currentPerson.TargetName}"); } OnPersonStartTurn?.Invoke(_currentPerson); - // Only introduce if this is a NEW person - if (isNewPerson && _previousPerson != null) - { - // Switching to a new person (after success) - they say hello - yield return StartCoroutine(personQueue.IntroduceNextPerson()); - } - else if (_previousPerson == null) - { - // First turn - they already said hello during intro, just brief camera pause - if (cameraManager != null) - { - cameraManager.SwitchToState(AirplaneCameraState.NextPerson); - yield return new WaitForSeconds(0.5f); - } - } - // else: Same person retry (after failure) - skip introduction, go straight to aiming + // 1. Introduce this person + yield return StartCoroutine(IntroducePerson()); - // Initialize spawn manager for this person's target + // 2. Prepare level (spawn objects, target) + yield return StartCoroutine(PrepareLevel()); + + // 3. Execute target flyby + yield return StartCoroutine(ExecuteTargetFlyby()); + + // 4. Select airplane + AirplaneAbilityType selectedType = AirplaneAbilityType.None; + yield return StartCoroutine(SelectAirplane((type) => selectedType = type)); + + // 5. Enter aiming state with selected airplane + EnterAimingState(selectedType); + + // Flow continues to shot → evaluation → routing + } + + #endregion + + #region Flow: Repeat Shot Flow + + /// + /// Repeat Shot Flow: Executed when same person retries after missing. + /// Steps: Brief camera → React (disappointed) → Select airplane → Aim → Shoot + /// + private IEnumerator ExecuteRepeatShotFlow() + { + if (showDebugLogs) + { + Logging.Debug($"[AirplaneGameManager] === REPEAT SHOT FLOW: Turn {_totalTurns}: {_currentPerson.PersonName} (RETRY) ===" + + $"\n Target: {_currentPerson.TargetName}"); + } + + _totalTurns++; + OnPersonStartTurn?.Invoke(_currentPerson); + + // 1. Switch to next person camera briefly and wait for blend + if (cameraManager != null) + { + yield return StartCoroutine(cameraManager.BlendToStateAsync(AirplaneCameraState.NextPerson)); + } + + // 2. Play person reaction (disappointment) + yield return StartCoroutine(PlayPersonReaction(false)); + + // 3. Reset spawn manager for retry (keeps spawned objects) if (spawnManager != null) { - // Pass retry flag: true if same person, false if new person - bool isRetry = !isNewPerson; - spawnManager.InitializeForGame(_currentPerson.TargetName, isRetry); + spawnManager.ResetForRetry(); + + if (showDebugLogs) + { + Logging.Debug("[AirplaneGameManager] Kept spawned objects for retry"); + } } - // Queue done - continue game flow + // 4. Select airplane (new choice for this shot) + AirplaneAbilityType selectedType = AirplaneAbilityType.None; + yield return StartCoroutine(SelectAirplane((type) => selectedType = type)); + + // 5. Enter aiming state with selected airplane + EnterAimingState(selectedType); + + // Flow continues to shot → evaluation → routing + } + + #endregion + + #region Flow: Person Shuffle Flow + + /// + /// Person Shuffle Flow: Executed after successful hit. + /// Steps: Camera → React (celebrate) → Advance queue → Cleanup → Next person or game over + /// + private IEnumerator ExecutePersonShuffleFlow() + { + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] === PERSON SHUFFLE FLOW ==="); + + // 1. Switch to next person camera and wait for blend + if (cameraManager != null) + { + yield return StartCoroutine(cameraManager.BlendToStateAsync(AirplaneCameraState.NextPerson)); + } + + // 2. Play person reaction (celebration) + yield return StartCoroutine(PlayPersonReaction(true)); + + // 3. Advance queue (remove person, shuffle) + if (personQueue != null) + { + yield return StartCoroutine(personQueue.AdvanceQueue(true)); + } + + // 4. Cleanup level (destroy all spawned objects) + if (spawnManager != null) + { + spawnManager.CleanupLevel(true); + + if (showDebugLogs) + { + Logging.Debug("[AirplaneGameManager] Cleaned up spawned objects after success"); + } + } + + // 5. Check if more people, then continue to new person flow or game over + if (personQueue != null && personQueue.HasMorePeople()) + { + yield return StartCoroutine(ExecuteNewPersonFlow()); + } + else + { + yield return StartCoroutine(GameOver()); + } + } + + #endregion + + #region Phase Methods + + /// + /// Phase: Introduce current person + /// + private IEnumerator IntroducePerson() + { + ChangeState(AirplaneGameState.NextPerson); + + if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] Introducing {_currentPerson.PersonName}..."); + + // 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) + { + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Blending to NextPerson camera..."); + yield return StartCoroutine(cameraManager.BlendToStateAsync(AirplaneCameraState.NextPerson)); + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Blend to NextPerson complete"); + } + else if (showDebugLogs) + { + Logging.Debug("[AirplaneGameManager] Already on NextPerson camera, skipping blend"); + } + + // Person says hello + if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] Calling OnHello for {_currentPerson.PersonName}..."); + yield return StartCoroutine(_currentPerson.OnHello()); + if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] OnHello complete for {_currentPerson.PersonName}"); + + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Introduction complete"); + } + + /// + /// Phase: Prepare level (spawn ground, objects, target) + /// + private IEnumerator PrepareLevel() + { + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Preparing level..."); + + if (spawnManager == null) + { + Logging.Error("[AirplaneGameManager] Cannot prepare level - spawn manager not assigned!"); + yield break; + } + + // Initialize spawn manager with new target + spawnManager.InitializeForGame(_currentPerson.TargetName, isRetry: false); + + // Pre-spawn entire level + spawnManager.PreSpawnLevelToTarget(); + + // Set flyby camera tracking on spawned target + if (cameraManager != null) + { + Transform targetTransform = spawnManager.GetSpawnedTargetTransform(); + if (targetTransform != null) + { + cameraManager.SetTargetFlybyTracking(targetTransform); + + if (showDebugLogs) + { + Logging.Debug("[AirplaneGameManager] Target camera now tracking spawned target"); + } + } + } + + // Set expected target for validator if (targetValidator != null) { targetValidator.SetExpectedTarget(_currentPerson.TargetName); } - // Enter aiming state - EnterAimingState(); + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Level preparation complete"); + + yield return null; } /// - /// Enter aiming state - player can aim and launch + /// Phase: Select airplane type (shows UI, waits for player choice) + /// Blends to Aiming camera if not already there (needed for repeat shot flow) /// - private void EnterAimingState() + private IEnumerator SelectAirplane(Action onSelected) + { + ChangeState(AirplaneGameState.AirplaneSelection); + + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] === AIRPLANE SELECTION ==="); + + // Blend to aiming camera if not already there + // (New person flow: already on Aiming from flyby; Repeat shot flow: on NextPerson, needs blend) + if (cameraManager != null && cameraManager.CurrentState != AirplaneCameraState.Aiming) + { + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Blending to Aiming camera..."); + yield return StartCoroutine(cameraManager.BlendToStateAsync(AirplaneCameraState.Aiming)); + } + else if (showDebugLogs) + { + Logging.Debug("[AirplaneGameManager] Already on Aiming camera, skipping blend"); + } + + AirplaneAbilityType selectedType = AirplaneAbilityType.None; + + // Show selection UI + if (selectionUI != null) + { + selectionUI.Show(); + + // Wait for player to select and confirm + yield return new WaitUntil(() => selectionUI.HasSelectedType); + + selectedType = selectionUI.GetSelectedType(); + selectionUI.Hide(); + + if (showDebugLogs) + { + Logging.Debug($"[AirplaneGameManager] Selected airplane: {selectedType}"); + } + } + else + { + Logging.Warning("[AirplaneGameManager] SelectionUI not assigned! Using default airplane type."); + + // Fallback: use default type from settings + if (_cachedSettings != null) + { + selectedType = _cachedSettings.DefaultAirplaneType; + } + else + { + selectedType = AirplaneAbilityType.Jet; // Ultimate fallback + } + + if (showDebugLogs) + { + Logging.Debug($"[AirplaneGameManager] No selection UI, using default: {selectedType}"); + } + } + + onSelected?.Invoke(selectedType); + } + + /// + /// Phase: Play person reaction animation + /// + private IEnumerator PlayPersonReaction(bool success) + { + if (personQueue == null || _currentPerson == null) + { + yield break; + } + + if (showDebugLogs) + { + Logging.Debug($"[AirplaneGameManager] Playing {_currentPerson.PersonName} reaction (success={success})"); + } + + yield return StartCoroutine(personQueue.PlayPersonReaction(success)); + } + /// + /// Enter aiming state - player can aim and launch with selected airplane type + /// Camera should already be on Aiming state from SelectAirplane phase + /// + private void EnterAimingState(AirplaneAbilityType selectedType) { ChangeState(AirplaneGameState.Aiming); if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Ready to aim and launch!"); - // Switch to aiming camera - if (cameraManager != null) - { - cameraManager.SwitchToState(AirplaneCameraState.Aiming); - } - // Spawn airplane at slingshot with selected type - if (launchController != null && _selectedAirplaneType != AirplaneAbilityType.None) + if (launchController != null && selectedType != AirplaneAbilityType.None) { - launchController.SetAirplaneType(_selectedAirplaneType); + launchController.SetAirplaneType(selectedType); if (showDebugLogs) { - Logging.Debug($"[AirplaneGameManager] Spawned airplane at slingshot: {_selectedAirplaneType}"); + Logging.Debug($"[AirplaneGameManager] Spawned airplane at slingshot: {selectedType}"); } } @@ -497,6 +639,45 @@ namespace Minigames.Airplane.Core } } + #endregion + + #region Flyby Sequence + /// + /// Execute cinematic flyby of the target location. + /// Switches to TargetFlyby camera, waits for blend, lingers, then returns to Aiming camera. + /// + private IEnumerator ExecuteTargetFlyby() + { + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Starting target flyby sequence..."); + + if (cameraManager == null) + { + Logging.Warning("[AirplaneGameManager] Cannot execute flyby - camera manager not assigned!"); + yield break; + } + + if (_cachedSettings == null) + { + Logging.Warning("[AirplaneGameManager] Cannot execute flyby - settings not available!"); + yield break; + } + + // 1. Blend to TargetFlyby camera and wait for blend to complete + yield return StartCoroutine(cameraManager.BlendToStateAsync(AirplaneCameraState.TargetFlyby)); + + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Blend to target camera complete, lingering..."); + + // 2. Linger on target + yield return new WaitForSeconds(_cachedSettings.TargetFlybyLingerDuration); + + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Linger complete, blending back to aiming..."); + + // 3. Blend back to Aiming camera and wait for blend to complete + yield return StartCoroutine(cameraManager.BlendToStateAsync(AirplaneCameraState.Aiming)); + + if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Target flyby sequence complete!"); + } + #endregion #region Event Handlers @@ -700,11 +881,17 @@ namespace Minigames.Airplane.Core launchController.ClearActiveAirplane(); } - // NOTE: Spawned objects cleanup moved to SetupNextPerson() to happen AFTER camera blend - // This ensures camera shows the scene before cleanup and person reaction - - // Move to next person - StartCoroutine(SetupNextPerson()); + // Route to appropriate flow based on result + if (success) + { + // Success: Go to person shuffle flow + StartCoroutine(ExecutePersonShuffleFlow()); + } + else + { + // Failure: Go to repeat shot flow + StartCoroutine(ExecuteRepeatShotFlow()); + } } /// diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs index c23c09fc..31ad5395 100644 --- a/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs +++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneSpawnManager.cs @@ -69,7 +69,7 @@ namespace Minigames.Airplane.Core [Header("Spawn Threshold")] [Tooltip("Transform marker in scene where dynamic spawning begins (uses X position). If null, uses fallback from settings.")] [SerializeField] private Transform dynamicSpawnThresholdMarker; - + [Header("Spawn Parents")] [Tooltip("Parent transform for spawned objects (optional, for organization)")] [SerializeField] private Transform spawnedObjectsParent; @@ -101,9 +101,9 @@ namespace Minigames.Airplane.Core // Plane tracking private Transform _planeTransform; private bool _isSpawningActive; - private bool _hasPassedThreshold; // Spawning positions (distance-based) + private float _lastSpawnedX; // Tracks the furthest forward X position that has been pre-spawned private float _nextObjectSpawnX; private float _nextGroundSpawnX; @@ -139,7 +139,6 @@ namespace Minigames.Airplane.Core private void Update() { - if (!_isSpawningActive || _planeTransform == null) return; float planeX = _planeTransform.position.x; @@ -150,58 +149,26 @@ namespace Minigames.Airplane.Core _furthestReachedX = planeX; } - // Check if target should be spawned (when plane gets within spawn distance) - if (!_hasSpawnedTarget && _targetPrefabToSpawn != null) + // 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) { - float distanceToTarget = _targetSpawnPosition.x - planeX; - if (distanceToTarget <= _settings.SpawnDistanceAhead) + // Continue spawning objects when plane approaches the last spawned position + float spawnTriggerX = _lastSpawnedX + _settings.SpawnDistanceAhead; + if (planeX >= spawnTriggerX && planeX >= _nextObjectSpawnX) { - SpawnTarget(); - _hasSpawnedTarget = true; - - if (showDebugLogs) - { - Logging.Debug($"[SpawnManager] Target spawned at distance {distanceToTarget:F2} from plane"); - } + SpawnRandomObject(); + ScheduleNextObjectSpawn(planeX); } - } - - // Check if plane has crossed threshold - float threshold = dynamicSpawnThresholdMarker.position.x; - - if (!_hasPassedThreshold && planeX >= threshold) - { - _hasPassedThreshold = true; - InitializeDynamicSpawning(); - if (showDebugLogs) + // Continue spawning ground tiles ahead of plane + float groundSpawnTargetX = planeX + GetGroundSpawnAheadDistance(); + while (_nextGroundSpawnX < groundSpawnTargetX) { - Logging.Debug($"[SpawnManager] Plane crossed threshold at X={planeX:F2}"); - } - } - - // If past threshold, handle spawning (only if we're going further than before) - if (_hasPassedThreshold) - { - // Only spawn new content if plane is beyond previous furthest point (for retries) - bool shouldSpawnNewContent = !_isRetryAttempt || planeX > (_furthestReachedX - _settings.SpawnDistanceAhead); - - if (shouldSpawnNewContent) - { - // Spawn objects when plane reaches spawn position - if (planeX >= _nextObjectSpawnX) - { - SpawnRandomObject(); - ScheduleNextObjectSpawn(planeX); - } - - // Spawn ground tiles ahead of plane - float groundSpawnTargetX = planeX + GetGroundSpawnAheadDistance(); - while (_nextGroundSpawnX < groundSpawnTargetX) - { - SpawnGroundTile(); - _nextGroundSpawnX += _settings.GroundSpawnInterval; - } + SpawnGroundTile(); + _nextGroundSpawnX += _settings.GroundSpawnInterval; } } } @@ -221,7 +188,6 @@ namespace Minigames.Airplane.Core { _currentTargetKey = targetKey; _isSpawningActive = false; - _hasPassedThreshold = false; _isRetryAttempt = isRetry; // Only reset target and spawn state if NOT a retry @@ -266,6 +232,94 @@ namespace Minigames.Airplane.Core SetupTargetUI(); } + /// + /// Pre-spawn the entire level from threshold marker to target + buffer. + /// Spawns range: (threshold_x, target_x + preSpawnBeyondTargetDistance) + /// Call this after InitializeForGame() for new turns (not retries). + /// + public void PreSpawnLevelToTarget() + { + if (_targetPrefabToSpawn == null) + { + Logging.Error("[SpawnManager] Cannot pre-spawn - target prefab not initialized! Call InitializeForGame first."); + return; + } + + if (_hasSpawnedTarget) + { + if (showDebugLogs) + { + Logging.Debug("[SpawnManager] Target already spawned, skipping pre-spawn (retry scenario)"); + } + return; + } + + if (dynamicSpawnThresholdMarker == null) + { + Logging.Error("[SpawnManager] Cannot pre-spawn - dynamicSpawnThresholdMarker not assigned!"); + return; + } + + // Get pre-spawn range: from threshold to target + buffer + float preSpawnStartX = dynamicSpawnThresholdMarker.position.x; + float preSpawnEndX = _targetDistance + _settings.PreSpawnBeyondTargetDistance; + + if (showDebugLogs) + { + Logging.Debug($"[SpawnManager] Pre-spawning level from X={preSpawnStartX:F2} (threshold) to X={preSpawnEndX:F2} (target={_targetDistance:F2} + buffer={_settings.PreSpawnBeyondTargetDistance:F2})"); + } + + // 1. Spawn ground tiles FIRST across entire range (so target can raycast) + float currentGroundX = preSpawnStartX; + while (currentGroundX <= preSpawnEndX) + { + SpawnGroundTileAt(currentGroundX); + currentGroundX += _settings.GroundSpawnInterval; + } + + // Set next ground spawn position beyond pre-spawn range + _nextGroundSpawnX = preSpawnEndX + _settings.GroundSpawnInterval; + + if (showDebugLogs) + { + Logging.Debug($"[SpawnManager] Ground tiles spawned, now spawning objects"); + } + + // 2. Spawn objects across entire range (skipping near target) + float currentObjectX = preSpawnStartX + Random.Range(_settings.ObjectSpawnMinDistance, _settings.ObjectSpawnMaxDistance); + + while (currentObjectX <= preSpawnEndX) + { + // Spawn object at this position + SpawnRandomObjectAt(currentObjectX); + + // Move to next spawn position (forward) + float spawnDistance = Random.Range(_settings.ObjectSpawnMinDistance, _settings.ObjectSpawnMaxDistance); + currentObjectX += spawnDistance; + } + + if (showDebugLogs) + { + Logging.Debug($"[SpawnManager] Objects spawned, now spawning target with raycast"); + } + + // 3. Spawn the target LAST (after ground exists for proper raycasting) + SpawnTarget(); + _hasSpawnedTarget = true; + + // 4. Store the furthest forward pre-spawn position as _lastSpawnedX + _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"); + } + } + /// /// Start tracking the airplane and enable spawning. /// @@ -380,7 +434,6 @@ namespace Minigames.Airplane.Core // Reset all spawn state _hasSpawnedTarget = false; - _hasPassedThreshold = false; _furthestReachedX = 0f; _positiveSpawnCount = 0; _negativeSpawnCount = 0; @@ -391,6 +444,33 @@ namespace Minigames.Airplane.Core } } + /// + /// 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) + { + if (success) + { + CleanupSpawnedObjects(); + + if (showDebugLogs) + { + Logging.Debug("[SpawnManager] Level cleanup: SUCCESS - destroyed all objects"); + } + } + else + { + ResetForRetry(); + + if (showDebugLogs) + { + Logging.Debug("[SpawnManager] 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. @@ -414,6 +494,15 @@ namespace Minigames.Airplane.Core return (_targetSpawnPosition, _targetDistance, _targetIconSprite); } + /// + /// Get the spawned target's transform for camera tracking. + /// Returns null if target hasn't been spawned yet. + /// + public Transform GetSpawnedTargetTransform() + { + return _spawnedTarget != null ? _spawnedTarget.transform : null; + } + #endregion #region Initialization @@ -494,6 +583,11 @@ namespace Minigames.Airplane.Core { Logging.Warning("[SpawnManager] Launch controller not assigned! Distance calculation will use world origin."); } + + if (dynamicSpawnThresholdMarker == null) + { + Logging.Warning("[SpawnManager] Dynamic spawn threshold marker not assigned! Pre-spawn will fail."); + } } #endregion @@ -501,7 +595,8 @@ namespace Minigames.Airplane.Core #region Target Spawning /// - /// Spawn the target at the predetermined position. + /// Spawn the target at the predetermined X position. + /// Y coordinate is determined via raycast from high position to find ground. /// private void SpawnTarget() { @@ -511,7 +606,13 @@ namespace Minigames.Airplane.Core return; } - // Spawn target at initial position + // Determine Y position via raycast from high position (Y=20) + float targetY = DetermineTargetYPosition(_targetDistance); + + // Update target spawn position with correct Y + _targetSpawnPosition = new Vector3(_targetDistance, targetY, 0f); + + // Spawn target at determined position _spawnedTarget = Instantiate(_currentTargetEntry.prefab, _targetSpawnPosition, Quaternion.identity); if (spawnedObjectsParent != null) @@ -519,14 +620,14 @@ namespace Minigames.Airplane.Core _spawnedTarget.transform.SetParent(spawnedObjectsParent); } - // Position target using configured spawn mode - PositionObject(_spawnedTarget, _targetSpawnPosition.x, + // Position target using configured spawn mode (will refine Y if needed) + PositionObject(_spawnedTarget, _targetDistance, _currentTargetEntry.spawnPositionMode, _currentTargetEntry.specifiedY, _currentTargetEntry.randomYMin, _currentTargetEntry.randomYMax); - // Update target spawn position to actual positioned location + // Update target spawn position to actual final positioned location _targetSpawnPosition = _spawnedTarget.transform.position; // Extract sprite for UI icon @@ -538,6 +639,41 @@ namespace Minigames.Airplane.Core } } + /// + /// Determine target Y position by raycasting downward from high position (Y=20). + /// Ensures target spawns touching the ground surface. + /// + private float DetermineTargetYPosition(float xPosition) + { + // Start raycast from high Y position (20 units up) + Vector2 rayOrigin = new Vector2(xPosition, 20f); + + // Raycast downward to find ground + int layerMask = 1 << _settings.GroundLayer; + RaycastHit2D hit = Physics2D.Raycast( + rayOrigin, + Vector2.down, + _settings.MaxGroundRaycastDistance, + layerMask + ); + + if (hit.collider != null) + { + // Found ground - return ground Y position + if (showDebugLogs) + { + Logging.Debug($"[SpawnManager] Target Y determined via raycast: ground at {hit.point.y:F2}"); + } + return hit.point.y; + } + else + { + // No ground found - use default + Logging.Warning($"[SpawnManager] No ground found for target at X={xPosition:F2} (raycast from Y=20 for {_settings.MaxGroundRaycastDistance} units), using default Y={_settings.DefaultObjectYOffset}"); + return _settings.DefaultObjectYOffset; + } + } + /// /// Extract sprite from target prefab for UI display (without instantiation). /// Finds first SpriteRenderer in prefab or children. @@ -613,24 +749,6 @@ namespace Minigames.Airplane.Core #region Dynamic Spawning - /// - /// Initialize dynamic spawning when threshold is crossed. - /// - private void InitializeDynamicSpawning() - { - // Schedule first spawn trigger from current plane position - // Actual spawning will happen at look-ahead distance - if (_planeTransform != null) - { - ScheduleNextObjectSpawn(_planeTransform.position.x); - - if (showDebugLogs) - { - Logging.Debug($"[SpawnManager] Dynamic spawning initialized, first spawn trigger at planeX={_nextObjectSpawnX:F2}"); - } - } - } - /// /// Get the distance ahead to spawn ground (2x object spawn distance). /// @@ -800,6 +918,105 @@ namespace Minigames.Airplane.Core } } + /// + /// 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 diff --git a/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs b/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs index c135cf8e..39a4846d 100644 --- a/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs +++ b/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs @@ -212,16 +212,15 @@ namespace Minigames.Airplane.Core } /// - /// Handle post-shot reaction for current person (who just shot). - /// If successful: celebrate, remove from queue, shuffle remaining people. - /// If failed: show disappointment, stay in queue. - /// Awaitable - game flow waits for reactions and animations to complete. + /// Play person reaction animation only (celebration or disappointment). + /// Does not modify queue - use AdvanceQueue() separately for queue management. + /// Awaitable - game flow waits for animation to complete. /// - public IEnumerator HandlePostShotReaction(bool targetHit) + public IEnumerator PlayPersonReaction(bool success) { if (peopleInQueue.Count == 0) { - Logging.Warning("[PersonQueue] HandlePostShotReaction called but queue is empty!"); + Logging.Warning("[PersonQueue] PlayPersonReaction called but queue is empty!"); yield break; } @@ -229,57 +228,59 @@ namespace Minigames.Airplane.Core Person currentPerson = peopleInQueue[0]; if (showDebugLogs) - Logging.Debug($"[PersonQueue] Post-shot reaction for {currentPerson.PersonName} (Hit: {targetHit})"); + Logging.Debug($"[PersonQueue] Playing reaction for {currentPerson.PersonName} (success={success})"); // Call person's reaction based on result - if (targetHit) + if (success) { // Success reaction yield return StartCoroutine(currentPerson.OnTargetHit()); - - if (showDebugLogs) Logging.Debug("[PersonQueue] Success! Removing person and shuffling queue..."); - - // Remember the first person's position BEFORE removing them - Vector3 firstPersonPosition = currentPerson.PersonTransform.position; - - // Remove successful person from queue (they're no longer in peopleInQueue) - RemoveCurrentPerson(); - - // Shuffle remaining people forward to fill the first person's spot - yield return StartCoroutine(ShuffleToPosition(firstPersonPosition)); } else { // Failure reaction yield return StartCoroutine(currentPerson.OnTargetMissed()); - - if (showDebugLogs) Logging.Debug("[PersonQueue] Failed - person stays in queue"); - // On failure, don't remove or shuffle, person gets another turn } - if (showDebugLogs) Logging.Debug("[PersonQueue] Post-shot reaction complete"); + if (showDebugLogs) Logging.Debug("[PersonQueue] Reaction complete"); } /// - /// Introduce the next person (at front of queue) for their turn. - /// Awaitable - game flow waits for introduction to complete. + /// Advance the queue after a shot result. + /// If success: Remove current person and shuffle remaining people forward. + /// If failure: Do nothing (person stays in queue for retry). + /// Awaitable - game flow waits for removal and shuffle animations to complete. /// - public IEnumerator IntroduceNextPerson() + public IEnumerator AdvanceQueue(bool success) { if (peopleInQueue.Count == 0) { - Logging.Warning("[PersonQueue] IntroduceNextPerson called but queue is empty!"); + Logging.Warning("[PersonQueue] AdvanceQueue called but queue is empty!"); yield break; } - Person nextPerson = peopleInQueue[0]; + Person currentPerson = peopleInQueue[0]; - if (showDebugLogs) Logging.Debug($"[PersonQueue] Introducing next person: {nextPerson.PersonName}"); - - // Call person's hello sequence - yield return StartCoroutine(nextPerson.OnHello()); - - if (showDebugLogs) Logging.Debug("[PersonQueue] Introduction complete"); + if (success) + { + if (showDebugLogs) Logging.Debug($"[PersonQueue] Advancing queue - removing {currentPerson.PersonName} and shuffling"); + + // Remember the first person's position BEFORE removing them + Vector3 firstPersonPosition = currentPerson.PersonTransform.position; + + // Remove successful person from queue + RemoveCurrentPerson(); + + // Shuffle remaining people forward to fill the first person's spot + yield return StartCoroutine(ShuffleToPosition(firstPersonPosition)); + + if (showDebugLogs) Logging.Debug("[PersonQueue] Queue advance complete"); + } + else + { + if (showDebugLogs) Logging.Debug($"[PersonQueue] Failed - {currentPerson.PersonName} stays in queue for retry"); + // On failure, don't remove or shuffle, person gets another turn + } } /// diff --git a/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs b/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs index 3068533f..042583e1 100644 --- a/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs +++ b/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs @@ -8,7 +8,8 @@ namespace Minigames.Airplane.Data Intro, // Intro sequence camera NextPerson, // Camera focusing on the next person Aiming, // Camera for aiming the airplane - Flight // Camera following the airplane in flight + Flight, // Camera following the airplane in flight + TargetFlyby // Camera showing the target location (cinematic flyby) } } diff --git a/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs b/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs index 5fe8b4f3..aa57e44d 100644 --- a/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs +++ b/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs @@ -75,9 +75,15 @@ namespace Minigames.Airplane.Settings [Tooltip("Duration of result evaluation (seconds)")] [SerializeField] private float evaluationDuration = 1f; + [Tooltip("Duration to linger on target during flyby (seconds)")] + [SerializeField] private float targetFlybyLingerDuration = 1.5f; + + [Tooltip("Fallback blend time if CinemachineBrain blend detection unavailable (seconds)")] + [SerializeField] private float targetFlybyCameraBlendTime = 1f; + [Header("Spawn System")] - [Tooltip("Transform marker in scene where dynamic spawning begins (uses X position). If null, uses fallback distance.")] - [SerializeField] private Transform dynamicSpawnThresholdMarker; + [Tooltip("Distance beyond target to pre-spawn (extends the pre-spawn range)")] + [SerializeField] private float preSpawnBeyondTargetDistance = 30f; [Tooltip("Minimum random distance for target spawn")] [SerializeField] private float targetMinDistance = 30f; @@ -139,6 +145,9 @@ namespace Minigames.Airplane.Settings public float IntroDuration => introDuration; public float PersonIntroDuration => personIntroDuration; public float EvaluationDuration => evaluationDuration; + public float TargetFlybyLingerDuration => targetFlybyLingerDuration; + public float TargetFlybyCameraBlendTime => targetFlybyCameraBlendTime; + public float PreSpawnBeyondTargetDistance => preSpawnBeyondTargetDistance; public float TargetMinDistance => targetMinDistance; public float TargetMaxDistance => targetMaxDistance; public float ObjectSpawnMinDistance => objectSpawnMinDistance;