diff --git a/Assets/Scenes/MiniGames/FortFight.unity b/Assets/Scenes/MiniGames/FortFight.unity
index 324ce617..ed953a92 100644
--- a/Assets/Scenes/MiniGames/FortFight.unity
+++ b/Assets/Scenes/MiniGames/FortFight.unity
@@ -2874,9 +2874,11 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: fc81b72132764f09a0ba180c90b432cf, type: 3}
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::Minigames.FortFight.Core.SlingshotController
- maxDragDistance: 5
- projectileSpawnPoint: {fileID: 1668202570}
- trajectoryPreview: {fileID: 0}
+ maxDragDistanceOverride: 0
+ maxForceOverride: 0
+ launchAnchor: {fileID: 1668202570}
+ showDebugLogs: 1
+ trajectoryPreview: {fileID: 841922115}
--- !u!1 &846792101
GameObject:
m_ObjectHideFlags: 0
@@ -4525,9 +4527,11 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: fc81b72132764f09a0ba180c90b432cf, type: 3}
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::Minigames.FortFight.Core.SlingshotController
- maxDragDistance: 5
- projectileSpawnPoint: {fileID: 497509525}
- trajectoryPreview: {fileID: 0}
+ maxDragDistanceOverride: 0
+ maxForceOverride: 0
+ launchAnchor: {fileID: 497509525}
+ showDebugLogs: 1
+ trajectoryPreview: {fileID: 1460473368}
--- !u!1 &1543340062
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity b/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity
index bddd4be5..655ca311 100644
--- a/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity
+++ b/Assets/Scenes/MiniGames/ValentineNoteDelivery.unity
@@ -119,85 +119,6 @@ NavMeshSettings:
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
---- !u!1 &580848252
-GameObject:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- serializedVersion: 6
- m_Component:
- - component: {fileID: 580848255}
- - component: {fileID: 580848254}
- - component: {fileID: 580848253}
- m_Layer: 0
- m_Name: EventSystem
- m_TagString: Untagged
- m_Icon: {fileID: 0}
- m_NavMeshLayer: 0
- m_StaticEditorFlags: 0
- m_IsActive: 1
---- !u!114 &580848253
-MonoBehaviour:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- m_GameObject: {fileID: 580848252}
- m_Enabled: 1
- m_EditorHideFlags: 0
- m_Script: {fileID: 11500000, guid: 01614664b831546d2ae94a42149d80ac, type: 3}
- m_Name:
- m_EditorClassIdentifier:
- m_SendPointerHoverToParent: 1
- m_MoveRepeatDelay: 0.5
- m_MoveRepeatRate: 0.1
- m_XRTrackingOrigin: {fileID: 0}
- m_ActionsAsset: {fileID: -944628639613478452, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_PointAction: {fileID: -1654692200621890270, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_MoveAction: {fileID: -8784545083839296357, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_SubmitAction: {fileID: 392368643174621059, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_CancelAction: {fileID: 7727032971491509709, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_LeftClickAction: {fileID: 3001919216989983466, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_MiddleClickAction: {fileID: -2185481485913320682, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_RightClickAction: {fileID: -4090225696740746782, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_ScrollWheelAction: {fileID: 6240969308177333660, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_TrackedDevicePositionAction: {fileID: 6564999863303420839, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_TrackedDeviceOrientationAction: {fileID: 7970375526676320489, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3}
- m_DeselectOnBackgroundClick: 1
- m_PointerBehavior: 0
- m_CursorLockBehavior: 0
- m_ScrollDeltaPerTick: 6
---- !u!114 &580848254
-MonoBehaviour:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- m_GameObject: {fileID: 580848252}
- m_Enabled: 1
- m_EditorHideFlags: 0
- m_Script: {fileID: 11500000, guid: 76c392e42b5098c458856cdf6ecaaaa1, type: 3}
- m_Name:
- m_EditorClassIdentifier:
- m_FirstSelected: {fileID: 0}
- m_sendNavigationEvents: 1
- m_DragThreshold: 10
---- !u!4 &580848255
-Transform:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- m_GameObject: {fileID: 580848252}
- 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: 0}
- m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1810521056
GameObject:
m_ObjectHideFlags: 0
@@ -362,7 +283,7 @@ Transform:
m_GameObject: {fileID: 1810521056}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: -34.3, y: -36.3, z: -10}
+ m_LocalPosition: {x: 0, y: 0, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -378,8 +299,6 @@ GameObject:
m_Component:
- component: {fileID: 2103114178}
- component: {fileID: 2103114177}
- - component: {fileID: 2103114176}
- - component: {fileID: 2103114175}
m_Layer: 0
m_Name: CinemachineCamera
m_TagString: Untagged
@@ -387,45 +306,6 @@ GameObject:
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
---- !u!114 &2103114175
-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: f453f694addf4275988fac205bc91968, type: 3}
- m_Name:
- m_EditorClassIdentifier:
- BoundingShape2D: {fileID: 0}
- Damping: 3
- SlowingDistance: 20
- OversizeWindow:
- Enabled: 0
- MaxWindowSize: 0
- Padding: 0
- m_LegacyMaxWindowSize: -2
---- !u!114 &2103114176
-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: b617507da6d07e749b7efdb34e1173e1, type: 3}
- m_Name:
- m_EditorClassIdentifier:
- TrackerSettings:
- BindingMode: 4
- PositionDamping: {x: 2, y: 0.5, z: 1}
- AngularDampingMode: 0
- RotationDamping: {x: 1, y: 1, z: 1}
- QuaternionDamping: 1
- FollowOffset: {x: 0, y: 0, z: -10}
--- !u!114 &2103114177
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -478,7 +358,7 @@ Transform:
m_GameObject: {fileID: 2103114174}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: -34.3, y: -36.3, z: -10}
+ m_LocalPosition: {x: 0, y: 0, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -489,5 +369,4 @@ SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 1810521061}
- - {fileID: 580848255}
- {fileID: 2103114178}
diff --git a/Assets/Scripts/Common.meta b/Assets/Scripts/Common.meta
new file mode 100644
index 00000000..9163167c
--- /dev/null
+++ b/Assets/Scripts/Common.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 70833f6496d94acab58cfe981c757d2d
+timeCreated: 1764851204
\ No newline at end of file
diff --git a/Assets/Scripts/Common/Camera.meta b/Assets/Scripts/Common/Camera.meta
new file mode 100644
index 00000000..c9babf97
--- /dev/null
+++ b/Assets/Scripts/Common/Camera.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 44c4b5c8fcd54d1887fb05ca65a9bb20
+timeCreated: 1764851223
\ No newline at end of file
diff --git a/Assets/Scripts/Common/Camera/CameraStateManager.cs b/Assets/Scripts/Common/Camera/CameraStateManager.cs
new file mode 100644
index 00000000..dc6e39cd
--- /dev/null
+++ b/Assets/Scripts/Common/Camera/CameraStateManager.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using Core;
+using Core.Lifecycle;
+using Unity.Cinemachine;
+using UnityEngine;
+
+namespace Common.Camera
+{
+ ///
+ /// Generic state-based camera controller using Cinemachine.
+ /// Manages camera transitions by setting priorities on virtual cameras.
+ /// Type parameter TState must be an enum representing camera states.
+ ///
+ public abstract class CameraStateManager : ManagedBehaviour where TState : Enum
+ {
+ #region Configuration
+
+ [Header("Camera Priority Settings")]
+ [Tooltip("Priority for inactive cameras")]
+ [SerializeField] protected int inactivePriority = 10;
+
+ [Tooltip("Priority for the active camera")]
+ [SerializeField] protected int activePriority = 20;
+
+ [Header("Debug")]
+ [SerializeField] protected bool showDebugLogs = false;
+
+ #endregion
+
+ #region State
+
+ private Dictionary _cameraMap = new Dictionary();
+ private TState _currentState;
+ private bool _isInitialized = false;
+
+ public TState CurrentState => _currentState;
+
+ #endregion
+
+ #region Events
+
+ ///
+ /// Fired when camera state changes. Parameters: (TState oldState, TState newState)
+ ///
+ public event Action OnStateChanged;
+
+ #endregion
+
+ #region Initialization
+
+ ///
+ /// Register a camera for a specific state.
+ /// Call this in subclass OnManagedAwake to set up the camera map.
+ ///
+ protected void RegisterCamera(TState state, CinemachineCamera pCamera)
+ {
+ if (pCamera == null)
+ {
+ Logging.Warning($"[{GetType().Name}] Attempted to register null camera for state {state}");
+ return;
+ }
+
+ if (_cameraMap.ContainsKey(state))
+ {
+ Logging.Warning($"[{GetType().Name}] Camera for state {state} already registered, overwriting");
+ }
+
+ _cameraMap[state] = pCamera;
+
+ // Set all cameras to inactive priority initially
+ pCamera.Priority.Value = inactivePriority;
+
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Registered camera '{pCamera.gameObject.name}' for state {state}");
+ }
+
+ ///
+ /// Finalize initialization after all cameras are registered.
+ /// Call this at the end of subclass OnManagedAwake.
+ ///
+ protected void FinalizeInitialization()
+ {
+ _isInitialized = true;
+
+ if (_cameraMap.Count == 0)
+ {
+ Logging.Warning($"[{GetType().Name}] No cameras registered!");
+ }
+
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Initialized with {_cameraMap.Count} cameras");
+ }
+
+ #endregion
+
+ #region State Management
+
+ ///
+ /// Switch to a specific camera state
+ ///
+ public virtual void SwitchToState(TState newState)
+ {
+ if (!_isInitialized)
+ {
+ Logging.Error($"[{GetType().Name}] Cannot switch state - not initialized!");
+ return;
+ }
+
+ if (!_cameraMap.ContainsKey(newState))
+ {
+ Logging.Error($"[{GetType().Name}] No camera registered for state {newState}!");
+ return;
+ }
+
+ TState oldState = _currentState;
+ _currentState = newState;
+
+ // Set all cameras to inactive priority
+ foreach (var kvp in _cameraMap)
+ {
+ kvp.Value.Priority.Value = inactivePriority;
+ }
+
+ // Set target camera to active priority
+ _cameraMap[newState].Priority.Value = activePriority;
+
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Switched from {oldState} to {newState} (camera: {_cameraMap[newState].gameObject.name})");
+
+ OnStateChanged?.Invoke(oldState, newState);
+ }
+
+ ///
+ /// Get the camera for a specific state
+ ///
+ public CinemachineCamera GetCamera(TState state)
+ {
+ if (_cameraMap.TryGetValue(state, out CinemachineCamera pCamera))
+ {
+ return pCamera;
+ }
+
+ Logging.Warning($"[{GetType().Name}] No camera found for state {state}");
+ return null;
+ }
+
+ ///
+ /// Check if a camera is registered for a state
+ ///
+ public bool HasCamera(TState state)
+ {
+ return _cameraMap.ContainsKey(state);
+ }
+
+ #endregion
+
+ #region Validation
+
+ ///
+ /// Validate that all required states have cameras registered.
+ /// Subclasses can override to add custom validation.
+ ///
+ protected virtual void ValidateCameras()
+ {
+ // Subclasses should implement specific validation
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Common/Camera/CameraStateManager.cs.meta b/Assets/Scripts/Common/Camera/CameraStateManager.cs.meta
new file mode 100644
index 00000000..c0633017
--- /dev/null
+++ b/Assets/Scripts/Common/Camera/CameraStateManager.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: c4fc438e61b94c529f7d1e8fe9fb70fa
+timeCreated: 1764851223
\ No newline at end of file
diff --git a/Assets/Scripts/Common/Input.meta b/Assets/Scripts/Common/Input.meta
new file mode 100644
index 00000000..327b6514
--- /dev/null
+++ b/Assets/Scripts/Common/Input.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 35838202f1ac4fa4b606b0582fa4e439
+timeCreated: 1764851204
\ No newline at end of file
diff --git a/Assets/Scripts/Common/Input/DragLaunchController.cs b/Assets/Scripts/Common/Input/DragLaunchController.cs
new file mode 100644
index 00000000..95b2554f
--- /dev/null
+++ b/Assets/Scripts/Common/Input/DragLaunchController.cs
@@ -0,0 +1,373 @@
+using System;
+using Core;
+using Core.Lifecycle;
+using Input;
+using UnityEngine;
+
+namespace Common.Input
+{
+ ///
+ /// Base class for drag-to-launch mechanics (Angry Birds style).
+ /// Provides core drag logic, force calculation, and input handling.
+ /// Uses SlingshotConfig for all settings - fully configuration-driven.
+ /// Subclasses implement visual feedback and specific launch behavior.
+ ///
+ public abstract class DragLaunchController : ManagedBehaviour, ITouchInputConsumer
+ {
+ #region Events
+
+ ///
+ /// Fired when drag starts. Parameters: (Vector2 startPosition)
+ ///
+ public event Action OnDragStart;
+
+ ///
+ /// Fired during drag update. Parameters: (Vector2 currentPosition, Vector2 direction, float force)
+ ///
+ public event Action OnDragUpdate;
+
+ ///
+ /// Fired when drag ends. Parameters: (Vector2 endPosition, Vector2 direction, float force)
+ ///
+ public event Action OnDragEnd;
+
+ ///
+ /// Fired when launch occurs. Parameters: (Vector2 direction, float force)
+ ///
+ public event Action OnLaunch;
+
+ #endregion
+
+ #region Settings
+
+ private SlingshotConfig _config;
+
+ protected SlingshotConfig Config
+ {
+ get
+ {
+ if (_config == null)
+ {
+ _config = GetSlingshotConfig();
+ }
+ return _config;
+ }
+ }
+
+ ///
+ /// Subclasses implement to return their slingshot configuration
+ /// from their specific settings object
+ ///
+ protected abstract SlingshotConfig GetSlingshotConfig();
+
+ ///
+ /// Subclasses implement to return the projectile prefab that will be launched.
+ /// Used for reading Rigidbody2D properties (mass, gravityScale) for trajectory calculations.
+ ///
+ protected abstract GameObject GetProjectilePrefab();
+
+ #endregion
+
+ #region Inspector Properties
+
+ [Header("Launch Settings Overrides (leave 0 to use config)")]
+ [Tooltip("Override max drag distance (0 = use config)")]
+ [SerializeField] protected float maxDragDistanceOverride = 0f;
+
+ [Tooltip("Override max force (0 = use config)")]
+ [SerializeField] protected float maxForceOverride = 0f;
+
+ [Header("References")]
+ [Tooltip("Launch anchor point (spawn/slingshot position)")]
+ [SerializeField] protected Transform launchAnchor;
+
+ [Header("Debug")]
+ [SerializeField] protected bool showDebugLogs;
+
+ #endregion
+
+ #region Computed Properties
+
+ protected float MaxDragDistance => maxDragDistanceOverride > 0 ? maxDragDistanceOverride : Config?.maxDragDistance ?? 5f;
+ protected float MaxForce => maxForceOverride > 0 ? maxForceOverride : Config?.baseLaunchForce ?? 20f;
+
+ #endregion
+
+ #region State
+
+ private bool _isDragging;
+ private Vector2 _dragStartPosition;
+ private bool _isEnabled = false;
+ private bool _isRegistered = false;
+
+ public bool IsDragging => _isDragging;
+ public bool IsEnabled => _isEnabled;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ if (launchAnchor == null)
+ {
+ launchAnchor = transform;
+ }
+ }
+
+ #endregion
+
+ #region Enable/Disable
+
+ ///
+ /// Enable the launch controller and register with InputManager
+ ///
+ public virtual void Enable()
+ {
+ _isEnabled = true;
+
+ // Register with InputManager as override consumer
+ if (InputManager.Instance != null && !_isRegistered)
+ {
+ InputManager.Instance.RegisterOverrideConsumer(this);
+ _isRegistered = true;
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Registered with InputManager");
+ }
+
+ // Show preview visuals
+ ShowPreview();
+
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Enabled");
+ }
+
+ ///
+ /// Disable the launch controller and unregister from InputManager
+ ///
+ public virtual void Disable()
+ {
+ _isEnabled = false;
+ _isDragging = false;
+
+ // Unregister from InputManager
+ if (InputManager.Instance != null && _isRegistered)
+ {
+ InputManager.Instance.UnregisterOverrideConsumer(this);
+ _isRegistered = false;
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Unregistered from InputManager");
+ }
+
+ // Hide preview visuals
+ HidePreview();
+
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Disabled");
+ }
+
+ #endregion
+
+ #region ITouchInputConsumer Implementation
+
+ public void OnTap(Vector2 worldPosition)
+ {
+ // Drag-to-launch uses hold/drag, not tap
+ }
+
+ public void OnHoldStart(Vector2 worldPosition)
+ {
+ if (!_isEnabled) return;
+ StartDrag(worldPosition);
+ }
+
+ public void OnHoldMove(Vector2 worldPosition)
+ {
+ if (!_isEnabled || !_isDragging) return;
+ UpdateDrag(worldPosition);
+ }
+
+ public void OnHoldEnd(Vector2 worldPosition)
+ {
+ if (!_isEnabled || !_isDragging) return;
+ EndDrag(worldPosition);
+ }
+
+ #endregion
+
+ #region Drag Handling
+
+ ///
+ /// Start drag operation
+ ///
+ protected virtual void StartDrag(Vector2 worldPosition)
+ {
+ _isDragging = true;
+ // Use launch anchor as the reference point (like Angry Birds)
+ _dragStartPosition = launchAnchor.position;
+
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Started drag at {worldPosition}, anchor at {_dragStartPosition}");
+
+ OnDragStart?.Invoke(worldPosition);
+ }
+
+ ///
+ /// Update drag operation
+ ///
+ protected virtual void UpdateDrag(Vector2 currentWorldPosition)
+ {
+ // Calculate drag vector from anchor to current drag position
+ // Pull back (away from anchor) = launch forward (toward anchor direction)
+ Vector2 dragVector = _dragStartPosition - currentWorldPosition;
+
+ // Calculate force and direction
+ float dragDistance = dragVector.magnitude;
+ float dragRatio = Mathf.Clamp01(dragDistance / MaxDragDistance);
+
+ // Use config to calculate force with multipliers
+ float force = Config?.CalculateForce(dragDistance, dragRatio) ?? (dragRatio * MaxForce);
+ Vector2 direction = dragVector.normalized;
+ float mass = GetProjectileMass();
+
+ // Warn if mass is zero or invalid
+ if (mass <= 0f && showDebugLogs)
+ {
+ Logging.Warning($"[{GetType().Name}] Projectile mass is {mass}! Trajectory calculation will be inaccurate. Override GetProjectileMass().");
+ }
+
+ // Update visuals with mass parameter
+ UpdateVisuals(currentWorldPosition, direction, force, dragDistance, mass);
+
+ OnDragUpdate?.Invoke(currentWorldPosition, direction, force);
+ }
+
+ ///
+ /// End drag operation and potentially launch
+ ///
+ protected virtual void EndDrag(Vector2 currentWorldPosition)
+ {
+ _isDragging = false;
+
+ // Hide preview
+ HidePreview();
+
+ // Calculate final launch parameters
+ Vector2 dragVector = _dragStartPosition - currentWorldPosition;
+ float dragDistance = dragVector.magnitude;
+ float dragRatio = Mathf.Clamp01(dragDistance / MaxDragDistance);
+
+ // Use config to calculate force with multipliers
+ float force = Config?.CalculateForce(dragDistance, dragRatio) ?? (dragRatio * MaxForce);
+ Vector2 direction = dragVector.normalized;
+
+ // Get minimum force from config
+ float minForce = Config?.GetMinForce() ?? (MaxForce * 0.1f);
+
+ if (showDebugLogs)
+ Logging.Debug($"[{GetType().Name}] Drag ended - Force: {force:F2}, Min: {minForce:F2}, Distance: {dragDistance:F2}");
+
+ OnDragEnd?.Invoke(currentWorldPosition, direction, force);
+
+ // Launch if force exceeds minimum
+ if (force >= minForce)
+ {
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Launching with force {force:F2}");
+ PerformLaunch(direction, force);
+ OnLaunch?.Invoke(direction, force);
+ }
+ else
+ {
+ if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Drag too short - force {force:F2} < min {minForce:F2}");
+ }
+ }
+
+ #endregion
+
+ #region Abstract Methods - Subclass Implementation
+
+ ///
+ /// Update visual feedback during drag (trajectory preview, rubber band, etc.)
+ ///
+ protected abstract void UpdateVisuals(Vector2 currentPosition, Vector2 direction, float force, float dragDistance, float mass);
+
+ ///
+ /// Show preview visuals when controller is enabled
+ ///
+ protected abstract void ShowPreview();
+
+ ///
+ /// Hide preview visuals when controller is disabled
+ ///
+ protected abstract void HidePreview();
+
+ ///
+ /// Perform the actual launch (spawn projectile/airplane, apply force, etc.)
+ ///
+ protected abstract void PerformLaunch(Vector2 direction, float force);
+
+ #endregion
+
+ #region Virtual Methods - Optional Override
+
+ ///
+ /// Get projectile mass for trajectory calculation.
+ /// Reads from the prefab's Rigidbody2D component.
+ /// Subclasses can override for custom behavior (e.g., if mass changes dynamically).
+ ///
+ protected virtual float GetProjectileMass()
+ {
+ GameObject prefab = GetProjectilePrefab();
+ if (prefab == null)
+ {
+ if (showDebugLogs)
+ Logging.Warning($"[{GetType().Name}] GetProjectilePrefab() returned null!");
+ return 0f;
+ }
+
+ var rb = prefab.GetComponent();
+ if (rb == null)
+ {
+ if (showDebugLogs)
+ Logging.Warning($"[{GetType().Name}] Projectile prefab '{prefab.name}' has no Rigidbody2D!");
+ return 0f;
+ }
+
+ return rb.mass;
+ }
+
+ ///
+ /// Get gravity value for trajectory calculation.
+ /// Uses Physics2D.gravity.magnitude * prefab's Rigidbody2D gravityScale.
+ ///
+ protected virtual float GetGravity()
+ {
+ GameObject prefab = GetProjectilePrefab();
+ if (prefab == null)
+ {
+ // Fallback to project gravity
+ return Physics2D.gravity.magnitude;
+ }
+
+ var rb = prefab.GetComponent();
+ float gravityScale = rb != null ? rb.gravityScale : 1f;
+
+ return Physics2D.gravity.magnitude * gravityScale;
+ }
+
+ #endregion
+
+ #region Cleanup
+
+ internal override void OnManagedDestroy()
+ {
+ base.OnManagedDestroy();
+
+ // Ensure we unregister from InputManager
+ if (_isRegistered && InputManager.Instance != null)
+ {
+ InputManager.Instance.UnregisterOverrideConsumer(this);
+ }
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Common/Input/DragLaunchController.cs.meta b/Assets/Scripts/Common/Input/DragLaunchController.cs.meta
new file mode 100644
index 00000000..e09d590c
--- /dev/null
+++ b/Assets/Scripts/Common/Input/DragLaunchController.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 44e042d1338149f6bb8adf6129e1c6c2
+timeCreated: 1764851204
\ No newline at end of file
diff --git a/Assets/Scripts/Common/Input/SlingshotConfig.cs b/Assets/Scripts/Common/Input/SlingshotConfig.cs
new file mode 100644
index 00000000..d69854d2
--- /dev/null
+++ b/Assets/Scripts/Common/Input/SlingshotConfig.cs
@@ -0,0 +1,59 @@
+using System;
+using UnityEngine;
+
+namespace Common.Input
+{
+ ///
+ /// Configuration for slingshot launch mechanics.
+ /// Can be embedded in any minigame settings that use drag-to-launch.
+ ///
+ [Serializable]
+ public class SlingshotConfig
+ {
+ [Header("Drag & Force Settings")]
+ [Tooltip("Distance to reach max force")]
+ public float maxDragDistance = 5f;
+
+ [Tooltip("Base force value")]
+ public float baseLaunchForce = 20f;
+
+ [Tooltip("Minimum threshold (0-1)")]
+ [Range(0f, 1f)]
+ public float minForceMultiplier = 0.1f;
+
+ [Tooltip("Maximum cap (0-2, usually 1)")]
+ [Range(0f, 2f)]
+ public float maxForceMultiplier = 1f;
+
+ [Header("Trajectory Settings")]
+ [Tooltip("Number of preview points")]
+ public int trajectoryPoints = 50;
+
+ [Tooltip("Time between points")]
+ public float trajectoryTimeStep = 0.1f;
+
+ [Tooltip("Show trajectory after launch (seconds, 0 = no lock)")]
+ public float trajectoryLockDuration = 2f;
+
+ [Header("Input")]
+ [Tooltip("Auto-register with InputManager on Enable()")]
+ public bool autoRegisterInput = true;
+
+ ///
+ /// Calculate force from drag parameters using configured multipliers
+ ///
+ public float CalculateForce(float dragDistance, float dragRatio)
+ {
+ return dragRatio * maxForceMultiplier * baseLaunchForce;
+ }
+
+ ///
+ /// Calculate minimum force threshold
+ ///
+ public float GetMinForce()
+ {
+ return baseLaunchForce * minForceMultiplier;
+ }
+ }
+}
+
diff --git a/Assets/Scripts/Common/Input/SlingshotConfig.cs.meta b/Assets/Scripts/Common/Input/SlingshotConfig.cs.meta
new file mode 100644
index 00000000..5fa8c7d5
--- /dev/null
+++ b/Assets/Scripts/Common/Input/SlingshotConfig.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: be4f5d5fd7084425a7bf28a1fadf125e
+timeCreated: 1764854225
\ No newline at end of file
diff --git a/Assets/Scripts/Core/GameManager.cs b/Assets/Scripts/Core/GameManager.cs
index e238829f..545e3370 100644
--- a/Assets/Scripts/Core/GameManager.cs
+++ b/Assets/Scripts/Core/GameManager.cs
@@ -5,6 +5,7 @@ using AppleHills.Core.Settings;
using Core.Lifecycle;
using Core.Settings;
using Input;
+using Minigames.Airplane.Settings;
using Minigames.FortFight.Core;
using UnityEngine;
@@ -175,6 +176,7 @@ namespace Core
var birdPooperSettings = SettingsProvider.Instance.LoadSettingsSynchronous();
var statueDressupSettings = SettingsProvider.Instance.LoadSettingsSynchronous();
var fortFightSettings = SettingsProvider.Instance.LoadSettingsSynchronous();
+ var airplaneSettings = SettingsProvider.Instance.LoadSettingsSynchronous();
// Register settings with service locator
@@ -257,11 +259,21 @@ namespace Core
{
Debug.LogError("Failed to load FortFightSettings");
}
+
+ if (airplaneSettings != null)
+ {
+ ServiceLocator.Register(airplaneSettings);
+ Logging.Debug("AirplaneSettings registered successfully");
+ }
+ else
+ {
+ Debug.LogError("Failed to load AirplaneSettings");
+ }
// Log success
_settingsLoaded = playerSettings != null && interactionSettings != null && minigameSettings != null
&& cardSystemSettings != null && birdPooperSettings != null && statueDressupSettings != null
- && fortFightSettings != null;
+ && fortFightSettings != null && sortingGameSettings != null && airplaneSettings != null;
if (_settingsLoaded)
{
Logging.Debug("All settings loaded and registered with ServiceLocator");
diff --git a/Assets/Scripts/Core/Settings/SettingsInterfaces.cs b/Assets/Scripts/Core/Settings/SettingsInterfaces.cs
index 1f3084f2..4b38f2da 100644
--- a/Assets/Scripts/Core/Settings/SettingsInterfaces.cs
+++ b/Assets/Scripts/Core/Settings/SettingsInterfaces.cs
@@ -219,6 +219,9 @@ namespace AppleHills.Core.Settings
///
public interface IFortFightSettings
{
+ // Slingshot Configuration
+ Common.Input.SlingshotConfig SlingshotSettings { get; }
+
// Block configurations
System.Collections.Generic.List MaterialConfigs { get; }
System.Collections.Generic.List SizeConfigs { get; }
@@ -279,4 +282,29 @@ namespace AppleHills.Core.Settings
Minigames.FortFight.Settings.BlockMaterialConfig GetMaterialConfig(Minigames.FortFight.Data.BlockMaterial material);
Minigames.FortFight.Settings.BlockSizeConfig GetSizeConfig(Minigames.FortFight.Data.BlockSize size);
}
+
+ ///
+ /// Interface for Airplane minigame settings
+ ///
+ public interface IAirplaneSettings
+ {
+ // Slingshot Configuration
+ Common.Input.SlingshotConfig SlingshotSettings { get; }
+
+ // Flight Settings
+ float AirplaneMass { get; }
+ float MaxFlightTime { get; }
+
+ // Camera Settings
+ float CameraFollowSmoothing { get; }
+ float FlightCameraZoom { get; }
+
+ // Timing
+ float IntroDuration { get; }
+ float PersonIntroDuration { get; }
+ float EvaluationDuration { get; }
+
+ // Debug
+ bool ShowDebugLogs { get; }
+ }
}
diff --git a/Assets/Scripts/Input/InputManager.cs b/Assets/Scripts/Input/InputManager.cs
index ec1f7903..93580401 100644
--- a/Assets/Scripts/Input/InputManager.cs
+++ b/Assets/Scripts/Input/InputManager.cs
@@ -15,7 +15,7 @@ namespace Input
UI,
GameAndUI,
InputDisabled
- }
+ }
///
/// Handles input events and dispatches them to the appropriate ITouchInputConsumer.
diff --git a/Assets/Scripts/Minigames/Airplane.meta b/Assets/Scripts/Minigames/Airplane.meta
new file mode 100644
index 00000000..5553dbda
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 4cdcfc21e5ec473dafc45f1ae16624b2
+timeCreated: 1764851234
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core.meta b/Assets/Scripts/Minigames/Airplane/Core.meta
new file mode 100644
index 00000000..fc37aad0
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 48e6932cbd9645bfac8add678e705033
+timeCreated: 1764851249
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs
new file mode 100644
index 00000000..d48dba7f
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs
@@ -0,0 +1,160 @@
+using Common.Camera;
+using Core;
+using Minigames.Airplane.Data;
+using Unity.Cinemachine;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core
+{
+ ///
+ /// Manages camera states for the airplane minigame.
+ /// Handles transitions between Intro, NextPerson, Aiming, and Flight cameras.
+ /// Flight camera includes follow functionality for tracking airplanes.
+ ///
+ public class AirplaneCameraManager : CameraStateManager
+ {
+ #region Singleton
+
+ private static AirplaneCameraManager _instance;
+ public static AirplaneCameraManager Instance => _instance;
+
+ #endregion
+
+ #region Inspector References
+
+ [Header("Cinemachine Cameras")]
+ [Tooltip("Camera for intro sequence")]
+ [SerializeField] private CinemachineCamera introCamera;
+
+ [Tooltip("Camera for showing the next person")]
+ [SerializeField] private CinemachineCamera nextPersonCamera;
+
+ [Tooltip("Camera for aiming view")]
+ [SerializeField] private CinemachineCamera aimingCamera;
+
+ [Tooltip("Camera that follows the airplane (should have CinemachineFollow)")]
+ [SerializeField] private CinemachineCamera flightCamera;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ // Set singleton
+ if (_instance != null && _instance != this)
+ {
+ Logging.Warning("[AirplaneCameraManager] Multiple instances detected! Destroying duplicate.");
+ Destroy(gameObject);
+ return;
+ }
+ _instance = this;
+
+ // Register cameras
+ RegisterCamera(AirplaneCameraState.Intro, introCamera);
+ RegisterCamera(AirplaneCameraState.NextPerson, nextPersonCamera);
+ RegisterCamera(AirplaneCameraState.Aiming, aimingCamera);
+ RegisterCamera(AirplaneCameraState.Flight, flightCamera);
+
+ // Finalize initialization
+ FinalizeInitialization();
+
+ // Validate
+ ValidateCameras();
+ }
+
+ internal override void OnManagedDestroy()
+ {
+ base.OnManagedDestroy();
+
+ if (_instance == this)
+ {
+ _instance = null;
+ }
+ }
+
+ #endregion
+
+ #region Validation
+
+ protected override void ValidateCameras()
+ {
+ if (introCamera == null)
+ {
+ Logging.Error("[AirplaneCameraManager] Intro camera not assigned!");
+ }
+
+ if (nextPersonCamera == null)
+ {
+ Logging.Error("[AirplaneCameraManager] Next person camera not assigned!");
+ }
+
+ if (aimingCamera == null)
+ {
+ Logging.Error("[AirplaneCameraManager] Aiming camera not assigned!");
+ }
+
+ if (flightCamera == null)
+ {
+ Logging.Error("[AirplaneCameraManager] Flight camera not assigned!");
+ }
+ else
+ {
+ // Verify flight camera has follow component
+ var followComponent = flightCamera.GetComponent();
+ if (followComponent == null)
+ {
+ Logging.Warning("[AirplaneCameraManager] Flight camera missing CinemachineFollow component!");
+ }
+ }
+ }
+
+ #endregion
+
+ #region Flight Camera Follow
+
+ ///
+ /// Start following an airplane with the flight camera
+ ///
+ public void StartFollowingAirplane(Transform airplaneTransform)
+ {
+ if (flightCamera == null)
+ {
+ Logging.Warning("[AirplaneCameraManager] Cannot follow airplane - flight camera not assigned!");
+ return;
+ }
+
+ if (airplaneTransform == null)
+ {
+ Logging.Warning("[AirplaneCameraManager] Cannot follow null airplane transform!");
+ return;
+ }
+
+ // Set the follow target on the flight camera
+ flightCamera.Target.TrackingTarget = airplaneTransform;
+
+ // Switch to flight camera
+ SwitchToState(AirplaneCameraState.Flight);
+
+ if (showDebugLogs) Logging.Debug($"[AirplaneCameraManager] Now following airplane: {airplaneTransform.gameObject.name}");
+ }
+
+ ///
+ /// Stop following the airplane and clear the target
+ ///
+ public void StopFollowingAirplane()
+ {
+ if (flightCamera == null) return;
+
+ // Clear the follow target
+ flightCamera.Target.TrackingTarget = null;
+
+ if (showDebugLogs) Logging.Debug("[AirplaneCameraManager] Stopped following airplane");
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs.meta
new file mode 100644
index 00000000..bcc7d549
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneCameraManager.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 34b856742e12475793b85a0a3019d67b
+timeCreated: 1764851249
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneController.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneController.cs
new file mode 100644
index 00000000..6d04d750
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneController.cs
@@ -0,0 +1,281 @@
+using System;
+using System.Collections;
+using Core;
+using Core.Lifecycle;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core
+{
+ ///
+ /// Controls airplane movement using calculated (non-physics-based) flight.
+ /// Uses Rigidbody2D for velocity application but not for simulation.
+ /// Follows an arc trajectory based on launch parameters.
+ ///
+ [RequireComponent(typeof(Rigidbody2D), typeof(Collider2D))]
+ public class AirplaneController : ManagedBehaviour
+ {
+ #region Events
+
+ ///
+ /// Fired when airplane is launched. Parameters: (AirplaneController airplane)
+ ///
+ public event Action OnLaunched;
+
+ ///
+ /// Fired when airplane lands/stops. Parameters: (AirplaneController airplane)
+ ///
+ public event Action OnLanded;
+
+ ///
+ /// Fired when airplane hits a target. Parameters: (AirplaneController airplane, string targetName)
+ ///
+ public event Action OnTargetHit;
+
+ ///
+ /// Fired when airplane times out. Parameters: (AirplaneController airplane)
+ ///
+ public event Action OnTimeout;
+
+ #endregion
+
+ #region Inspector Properties
+
+ [Header("Flight Settings")]
+ [Tooltip("Gravity multiplier for arc calculation")]
+ [SerializeField] private float gravity = 9.81f;
+
+ [Tooltip("Mass of the airplane")]
+ [SerializeField] private float mass = 1f;
+
+ [Tooltip("Maximum flight time before timeout (seconds)")]
+ [SerializeField] private float maxFlightTime = 10f;
+
+ [Header("Visual")]
+ [Tooltip("Should airplane rotate to face velocity direction?")]
+ [SerializeField] private bool rotateToVelocity = true;
+
+ [Header("Debug")]
+ [SerializeField] private bool showDebugLogs = false;
+
+ #endregion
+
+ #region State
+
+ private Rigidbody2D rb2D;
+ private Collider2D airplaneCollider;
+ private Vector2 currentVelocity;
+ private bool isFlying = false;
+ private float flightTimer = 0f;
+ private string lastHitTarget = null;
+
+ public bool IsFlying => isFlying;
+ public Vector2 CurrentVelocity => currentVelocity;
+ public string LastHitTarget => lastHitTarget;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ // Cache components
+ rb2D = GetComponent();
+ airplaneCollider = GetComponent();
+
+ // Configure Rigidbody2D
+ if (rb2D != null)
+ {
+ rb2D.isKinematic = true; // Not physics-simulated
+ rb2D.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
+ }
+
+ // Configure Collider2D as trigger
+ if (airplaneCollider != null)
+ {
+ airplaneCollider.isTrigger = true;
+ }
+ }
+
+ #endregion
+
+ #region Launch
+
+ ///
+ /// Launch the airplane with calculated velocity
+ ///
+ public void Launch(Vector2 direction, float force)
+ {
+ if (isFlying)
+ {
+ Logging.Warning($"[AirplaneController] {gameObject.name} already flying!");
+ return;
+ }
+
+ // Calculate initial velocity from force and mass
+ float initialSpeed = force / mass;
+ currentVelocity = direction.normalized * initialSpeed;
+
+ isFlying = true;
+ flightTimer = 0f;
+ lastHitTarget = null;
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[AirplaneController] Launched - Force: {force:F2}, Mass: {mass:F2}, " +
+ $"Initial Speed: {initialSpeed:F2}, Direction: {direction}");
+ }
+
+ OnLaunched?.Invoke(this);
+
+ // Start flight update
+ StartCoroutine(FlightUpdateCoroutine());
+ }
+
+ #endregion
+
+ #region Flight Update
+
+ ///
+ /// Update airplane flight physics each frame
+ ///
+ private IEnumerator FlightUpdateCoroutine()
+ {
+ while (isFlying)
+ {
+ float deltaTime = Time.fixedDeltaTime;
+
+ // Apply gravity to velocity
+ currentVelocity.y -= gravity * deltaTime;
+
+ // Apply velocity to rigidbody
+ if (rb2D != null)
+ {
+ rb2D.linearVelocity = currentVelocity;
+ }
+
+ // Rotate to face velocity direction
+ if (rotateToVelocity && currentVelocity.magnitude > 0.1f)
+ {
+ float angle = Mathf.Atan2(currentVelocity.y, currentVelocity.x) * Mathf.Rad2Deg;
+ transform.rotation = Quaternion.Euler(0, 0, angle);
+ }
+
+ // Update flight timer
+ flightTimer += deltaTime;
+
+ // Check for timeout
+ if (flightTimer >= maxFlightTime)
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneController] Flight timeout reached");
+ HandleTimeout();
+ yield break;
+ }
+
+ // Check if airplane has landed (velocity near zero or hit ground)
+ if (currentVelocity.y < -0.1f && transform.position.y < -10f) // Below screen
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneController] Airplane went off screen");
+ HandleLanding();
+ yield break;
+ }
+
+ yield return new WaitForFixedUpdate();
+ }
+ }
+
+ #endregion
+
+ #region Collision Detection
+
+ ///
+ /// Detect trigger collisions with targets
+ ///
+ private void OnTriggerEnter2D(Collider2D other)
+ {
+ if (!isFlying) return;
+
+ // Check if it's a target
+ var target = other.GetComponent();
+ if (target != null)
+ {
+ lastHitTarget = target.TargetName;
+
+ if (showDebugLogs) Logging.Debug($"[AirplaneController] Hit target: {lastHitTarget}");
+
+ OnTargetHit?.Invoke(this, lastHitTarget);
+
+ // Land after hitting target
+ HandleLanding();
+ }
+ }
+
+ #endregion
+
+ #region Landing and Timeout
+
+ ///
+ /// Handle airplane landing
+ ///
+ private void HandleLanding()
+ {
+ if (!isFlying) return;
+
+ isFlying = false;
+ currentVelocity = Vector2.zero;
+
+ if (rb2D != null)
+ {
+ rb2D.linearVelocity = Vector2.zero;
+ }
+
+ if (showDebugLogs) Logging.Debug("[AirplaneController] Airplane landed");
+
+ OnLanded?.Invoke(this);
+ }
+
+ ///
+ /// Handle airplane timeout
+ ///
+ private void HandleTimeout()
+ {
+ if (!isFlying) return;
+
+ isFlying = false;
+ currentVelocity = Vector2.zero;
+
+ if (rb2D != null)
+ {
+ rb2D.linearVelocity = Vector2.zero;
+ }
+
+ if (showDebugLogs) Logging.Debug("[AirplaneController] Airplane timed out");
+
+ OnTimeout?.Invoke(this);
+ }
+
+ ///
+ /// Public method to force stop the airplane
+ ///
+ public void ForceStop()
+ {
+ HandleLanding();
+ }
+
+ #endregion
+
+ #region Cleanup
+
+ internal override void OnManagedDestroy()
+ {
+ base.OnManagedDestroy();
+
+ // Stop any coroutines
+ StopAllCoroutines();
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneController.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/AirplaneController.cs.meta
new file mode 100644
index 00000000..8460dff2
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneController.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 0cdaac23e969495d8c0deeaf236c259e
+timeCreated: 1764851277
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs
new file mode 100644
index 00000000..46b41dee
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs
@@ -0,0 +1,553 @@
+using System;
+using System.Collections;
+using Core;
+using Core.Lifecycle;
+using Minigames.Airplane.Data;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core
+{
+ ///
+ /// Main game manager for the airplane minigame.
+ /// Orchestrates game flow through state machine with distinct phases:
+ /// Intro -> NextPerson -> Aiming -> Flying -> Evaluating -> (repeat or GameOver)
+ ///
+ public class AirplaneGameManager : ManagedBehaviour
+ {
+ #region Singleton
+
+ private static AirplaneGameManager _instance;
+ public static AirplaneGameManager Instance => _instance;
+
+ #endregion
+
+ #region Inspector References
+
+ [Header("Core Systems")]
+ [SerializeField] private PersonQueue personQueue;
+ [SerializeField] private AirplaneCameraManager cameraManager;
+ [SerializeField] private AirplaneLaunchController launchController;
+ [SerializeField] private AirplaneTargetValidator targetValidator;
+
+ [Header("Targets")]
+ [Tooltip("All targets in the scene (for highlighting)")]
+ [SerializeField] private Targets.AirplaneTarget[] allTargets;
+
+ [Header("Debug")]
+ [SerializeField] private bool showDebugLogs = true;
+
+ #endregion
+
+ #region Events
+
+ ///
+ /// Fired when game state changes. Parameters: (AirplaneGameState oldState, AirplaneGameState newState)
+ ///
+ public event Action OnStateChanged;
+
+ ///
+ /// Fired when a person starts their turn. Parameters: (PersonData person)
+ ///
+ public event Action OnPersonStartTurn;
+
+ ///
+ /// Fired when a person finishes their turn. Parameters: (PersonData person, bool success)
+ ///
+ public event Action OnPersonFinishTurn;
+
+ ///
+ /// Fired when game completes
+ ///
+ public event Action OnGameComplete;
+
+ #endregion
+
+ #region State
+
+ private AirplaneGameState _currentState = AirplaneGameState.Intro;
+ private PersonData _currentPerson;
+ private AirplaneController _currentAirplane;
+ private int _successCount;
+ private int _failCount;
+ private int _totalTurns;
+
+ public AirplaneGameState CurrentState => _currentState;
+ public PersonData CurrentPerson => _currentPerson;
+ public int SuccessCount => _successCount;
+ public int FailCount => _failCount;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ // Set singleton
+ if (_instance != null && _instance != this)
+ {
+ Logging.Warning("[AirplaneGameManager] Multiple instances detected! Destroying duplicate.");
+ Destroy(gameObject);
+ return;
+ }
+ _instance = this;
+
+ // Validate references
+ ValidateReferences();
+ }
+
+ internal override void OnManagedStart()
+ {
+ base.OnManagedStart();
+
+ // Subscribe to events
+ if (launchController != null)
+ {
+ launchController.OnAirplaneLaunched += HandleAirplaneLaunched;
+ }
+
+ if (targetValidator != null)
+ {
+ targetValidator.OnCorrectTargetHit += HandleCorrectTargetHit;
+ targetValidator.OnWrongTargetHit += HandleWrongTargetHit;
+ targetValidator.OnMissedAllTargets += HandleMissedTargets;
+ }
+
+ // Start the game
+ StartGame();
+ }
+
+ internal override void OnManagedDestroy()
+ {
+ base.OnManagedDestroy();
+
+ // Unsubscribe from events
+ if (launchController != null)
+ {
+ launchController.OnAirplaneLaunched -= HandleAirplaneLaunched;
+ }
+
+ if (targetValidator != null)
+ {
+ targetValidator.OnCorrectTargetHit -= HandleCorrectTargetHit;
+ targetValidator.OnWrongTargetHit -= HandleWrongTargetHit;
+ targetValidator.OnMissedAllTargets -= HandleMissedTargets;
+ }
+
+ if (_instance == this)
+ {
+ _instance = null;
+ }
+ }
+
+ #endregion
+
+ #region Validation
+
+ private void ValidateReferences()
+ {
+ if (personQueue == null)
+ {
+ Logging.Error("[AirplaneGameManager] PersonQueue not assigned!");
+ }
+
+ if (cameraManager == null)
+ {
+ Logging.Error("[AirplaneGameManager] AirplaneCameraManager not assigned!");
+ }
+
+ if (launchController == null)
+ {
+ Logging.Error("[AirplaneGameManager] AirplaneLaunchController not assigned!");
+ }
+
+ if (targetValidator == null)
+ {
+ Logging.Error("[AirplaneGameManager] AirplaneTargetValidator not assigned!");
+ }
+
+ if (allTargets == null || allTargets.Length == 0)
+ {
+ Logging.Warning("[AirplaneGameManager] No targets assigned!");
+ }
+ }
+
+ #endregion
+
+ #region State Management
+
+ private void ChangeState(AirplaneGameState newState)
+ {
+ AirplaneGameState oldState = _currentState;
+ _currentState = newState;
+
+ if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] State: {oldState} -> {newState}");
+
+ OnStateChanged?.Invoke(oldState, newState);
+ }
+
+ #endregion
+
+ #region Game Flow
+
+ ///
+ /// Start the game
+ ///
+ public void StartGame()
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] ===== GAME STARTING =====");
+
+ ChangeState(AirplaneGameState.Intro);
+ StartCoroutine(IntroSequence());
+ }
+
+ ///
+ /// Intro sequence (stub for MVP)
+ ///
+ private IEnumerator IntroSequence()
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Playing intro sequence...");
+
+ // Switch to intro camera
+ if (cameraManager != null)
+ {
+ cameraManager.SwitchToState(AirplaneCameraState.Intro);
+ }
+
+ // Wait for intro duration (stub)
+ yield return new WaitForSeconds(1f);
+
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Intro complete");
+
+ // Move to first person
+ StartCoroutine(SetupNextPerson());
+ }
+
+ ///
+ /// Setup the next person's turn
+ ///
+ private IEnumerator SetupNextPerson()
+ {
+ // Check if there are more people
+ if (personQueue == null || !personQueue.HasMorePeople())
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] No more people, ending game");
+ StartCoroutine(GameOver());
+ yield break;
+ }
+
+ ChangeState(AirplaneGameState.NextPerson);
+
+ // Pop next person
+ _currentPerson = personQueue.PopNextPerson();
+ _totalTurns++;
+
+ if (_currentPerson == null)
+ {
+ Logging.Error("[AirplaneGameManager] Failed to get next person!");
+ yield break;
+ }
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[AirplaneGameManager] === Turn {_totalTurns}: {_currentPerson.personName} ===" +
+ $"\n Target: {_currentPerson.targetName}");
+ }
+
+ OnPersonStartTurn?.Invoke(_currentPerson);
+
+ // Switch to next person camera
+ if (cameraManager != null)
+ {
+ cameraManager.SwitchToState(AirplaneCameraState.NextPerson);
+ }
+
+ // Wait for person introduction (stub)
+ yield return new WaitForSeconds(1f);
+
+ // Set expected target
+ if (targetValidator != null)
+ {
+ targetValidator.SetExpectedTarget(_currentPerson.targetName);
+ }
+
+ // Highlight the target
+ HighlightTarget(_currentPerson.targetName);
+
+ // Enter aiming state
+ EnterAimingState();
+ }
+
+ ///
+ /// Enter aiming state - player can aim and launch
+ ///
+ private void EnterAimingState()
+ {
+ ChangeState(AirplaneGameState.Aiming);
+
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Ready to aim and launch!");
+
+ // Switch to aiming camera
+ if (cameraManager != null)
+ {
+ cameraManager.SwitchToState(AirplaneCameraState.Aiming);
+ }
+
+ // Enable launch controller
+ if (launchController != null)
+ {
+ launchController.Enable();
+ }
+ }
+
+ #endregion
+
+ #region Event Handlers
+
+ ///
+ /// Handle airplane launched event
+ ///
+ private void HandleAirplaneLaunched(AirplaneController airplane)
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Airplane launched!");
+
+ _currentAirplane = airplane;
+
+ // Disable launch controller
+ if (launchController != null)
+ {
+ launchController.Disable();
+ }
+
+ ChangeState(AirplaneGameState.Flying);
+
+ // Start following airplane with camera
+ if (cameraManager != null)
+ {
+ cameraManager.StartFollowingAirplane(airplane.transform);
+ }
+
+ // Subscribe to airplane events
+ airplane.OnTargetHit += HandleAirplaneHitTarget;
+ airplane.OnLanded += HandleAirplaneLanded;
+ airplane.OnTimeout += HandleAirplaneTimeout;
+ }
+
+ ///
+ /// Handle airplane hitting a target
+ ///
+ private void HandleAirplaneHitTarget(AirplaneController airplane, string targetName)
+ {
+ if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] Airplane hit target: {targetName}");
+
+ // Validate the hit
+ if (targetValidator != null)
+ {
+ targetValidator.ValidateHit(targetName);
+ }
+ }
+
+ ///
+ /// Handle airplane landing
+ ///
+ private void HandleAirplaneLanded(AirplaneController airplane)
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Airplane landed");
+
+ // If no target was hit, count as miss
+ if (targetValidator != null && !targetValidator.HasValidated)
+ {
+ targetValidator.HandleMiss();
+ }
+
+ // Evaluate result
+ StartCoroutine(EvaluateResult());
+ }
+
+ ///
+ /// Handle airplane timeout
+ ///
+ private void HandleAirplaneTimeout(AirplaneController airplane)
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Airplane timed out");
+
+ // Count as miss
+ if (targetValidator != null && !targetValidator.HasValidated)
+ {
+ targetValidator.HandleMiss();
+ }
+
+ // Evaluate result
+ StartCoroutine(EvaluateResult());
+ }
+
+ ///
+ /// Handle correct target hit
+ ///
+ private void HandleCorrectTargetHit(string targetName)
+ {
+ _successCount++;
+ if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] ✓ SUCCESS! Hit correct target: {targetName}");
+ }
+
+ ///
+ /// Handle wrong target hit
+ ///
+ private void HandleWrongTargetHit(string expectedTarget, string actualTarget)
+ {
+ _failCount++;
+ if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] ✗ FAIL! Expected: {expectedTarget}, Hit: {actualTarget}");
+ }
+
+ ///
+ /// Handle missed all targets
+ ///
+ private void HandleMissedTargets()
+ {
+ _failCount++;
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] ✗ MISS! Didn't hit any target");
+ }
+
+ #endregion
+
+ #region Evaluation and Cleanup
+
+ ///
+ /// Evaluate the result of the turn
+ ///
+ private IEnumerator EvaluateResult()
+ {
+ ChangeState(AirplaneGameState.Evaluating);
+
+ // Stop following airplane
+ if (cameraManager != null)
+ {
+ cameraManager.StopFollowingAirplane();
+ }
+
+ // Determine success/failure
+ bool success = targetValidator != null &&
+ targetValidator.HasValidated &&
+ _currentAirplane != null &&
+ !string.IsNullOrEmpty(_currentAirplane.LastHitTarget) &&
+ targetValidator.IsExpectedTarget(_currentAirplane.LastHitTarget);
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[AirplaneGameManager] Turn result: {(success ? "SUCCESS" : "FAILURE")}" +
+ $"\n Score: {_successCount} / {_totalTurns}");
+ }
+
+ OnPersonFinishTurn?.Invoke(_currentPerson, success);
+
+ // Wait for evaluation display (stub)
+ yield return new WaitForSeconds(1f);
+
+ // Clean up airplane
+ if (_currentAirplane != null)
+ {
+ Destroy(_currentAirplane.gameObject);
+ _currentAirplane = null;
+ }
+
+ // Clear launch controller reference
+ if (launchController != null)
+ {
+ launchController.ClearActiveAirplane();
+ }
+
+ // Clear target highlighting
+ ClearAllTargetHighlights();
+
+ // Move to next person
+ StartCoroutine(SetupNextPerson());
+ }
+
+ ///
+ /// Game over - no more people
+ ///
+ private IEnumerator GameOver()
+ {
+ ChangeState(AirplaneGameState.GameOver);
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[AirplaneGameManager] ===== GAME OVER =====" +
+ $"\n Total Turns: {_totalTurns}" +
+ $"\n Success: {_successCount}" +
+ $"\n Failures: {_failCount}" +
+ $"\n Success Rate: {(_totalTurns > 0 ? (_successCount * 100f / _totalTurns) : 0):F1}%");
+ }
+
+ OnGameComplete?.Invoke();
+
+ // Stub: Show game over UI
+ yield return new WaitForSeconds(2f);
+
+ if (showDebugLogs) Logging.Debug("[AirplaneGameManager] Game complete");
+ }
+
+ #endregion
+
+ #region Target Management
+
+ ///
+ /// Highlight a specific target by name
+ ///
+ private void HighlightTarget(string targetName)
+ {
+ if (allTargets == null) return;
+
+ foreach (var target in allTargets)
+ {
+ if (target != null)
+ {
+ bool isActive = string.Equals(target.TargetName, targetName, StringComparison.OrdinalIgnoreCase);
+ target.SetAsActiveTarget(isActive);
+ }
+ }
+
+ if (showDebugLogs) Logging.Debug($"[AirplaneGameManager] Highlighted target: {targetName}");
+ }
+
+ ///
+ /// Clear all target highlights
+ ///
+ private void ClearAllTargetHighlights()
+ {
+ if (allTargets == null) return;
+
+ foreach (var target in allTargets)
+ {
+ if (target != null)
+ {
+ target.SetAsActiveTarget(false);
+ }
+ }
+ }
+
+ #endregion
+
+ #region Public Query Methods
+
+ ///
+ /// Get current game statistics
+ ///
+ public (int total, int success, int fail) GetStatistics()
+ {
+ return (_totalTurns, _successCount, _failCount);
+ }
+
+ ///
+ /// Check if game is active
+ ///
+ public bool IsGameActive()
+ {
+ return _currentState != AirplaneGameState.Intro && _currentState != AirplaneGameState.GameOver;
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs.meta
new file mode 100644
index 00000000..732e5a27
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneGameManager.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: fd2c6d27dee546479b16d0dfd8c3b2ee
+timeCreated: 1764851399
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneLaunchController.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneLaunchController.cs
new file mode 100644
index 00000000..57ad3647
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneLaunchController.cs
@@ -0,0 +1,252 @@
+using System;
+using AppleHills.Core.Settings;
+using Common.Input;
+using Core;
+using Core.Lifecycle;
+using Minigames.Airplane.Data;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core
+{
+ ///
+ /// Launch controller for the airplane minigame.
+ /// Extends DragLaunchController with airplane-specific behavior.
+ /// Spawns and launches airplanes on release.
+ ///
+ public class AirplaneLaunchController : DragLaunchController
+ {
+ #region Events
+
+ ///
+ /// Fired when airplane is launched. Parameters: (AirplaneController airplane)
+ ///
+ public event Action OnAirplaneLaunched;
+
+ #endregion
+
+ #region Settings
+
+ protected override SlingshotConfig GetSlingshotConfig()
+ {
+ return GameManager.GetSettingsObject()?.SlingshotSettings;
+ }
+
+ protected override GameObject GetProjectilePrefab()
+ {
+ return airplanePrefab;
+ }
+
+ #endregion
+
+ #region Inspector Properties
+
+ [Header("Airplane Setup")]
+ [Tooltip("Airplane prefab to spawn")]
+ [SerializeField] private GameObject airplanePrefab;
+
+ [Header("Visual Feedback")]
+ [Tooltip("Line renderer for trajectory preview (optional)")]
+ [SerializeField] private LineRenderer trajectoryLine;
+
+ [Tooltip("Visual indicator for launch anchor (optional)")]
+ [SerializeField] private GameObject anchorVisual;
+
+ #endregion
+
+ #region State
+
+ private AirplaneController _activeAirplane;
+
+ public AirplaneController ActiveAirplane => _activeAirplane;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ // Validate airplane prefab
+ if (airplanePrefab == null)
+ {
+ Logging.Error("[AirplaneLaunchController] Airplane prefab not assigned!");
+ }
+ else
+ {
+ // Verify airplane has AirplaneController
+ if (airplanePrefab.GetComponent() == null)
+ {
+ Logging.Error("[AirplaneLaunchController] Airplane prefab missing AirplaneController component!");
+ }
+ }
+
+ // Setup trajectory line
+ if (trajectoryLine != null)
+ {
+ trajectoryLine.enabled = false;
+ }
+
+ // Hide anchor visual initially
+ if (anchorVisual != null)
+ {
+ anchorVisual.SetActive(false);
+ }
+ }
+
+ #endregion
+
+ #region Visual Feedback
+
+ protected override void ShowPreview()
+ {
+ // Show anchor visual
+ if (anchorVisual != null)
+ {
+ anchorVisual.SetActive(true);
+ }
+
+ // Show trajectory line (will be updated during drag)
+ if (trajectoryLine != null)
+ {
+ trajectoryLine.enabled = false; // Only show during drag
+ }
+
+ if (showDebugLogs) Logging.Debug("[AirplaneLaunchController] Preview shown");
+ }
+
+ protected override void HidePreview()
+ {
+ // Hide anchor visual
+ if (anchorVisual != null)
+ {
+ anchorVisual.SetActive(false);
+ }
+
+ // Hide trajectory line
+ if (trajectoryLine != null)
+ {
+ trajectoryLine.enabled = false;
+ }
+
+ if (showDebugLogs) Logging.Debug("[AirplaneLaunchController] Preview hidden");
+ }
+
+ protected override void UpdateVisuals(Vector2 currentPosition, Vector2 direction, float force, float dragDistance, float mass)
+ {
+ // Show trajectory line during drag
+ if (trajectoryLine != null && trajectoryLine.enabled == false && dragDistance > 0.1f)
+ {
+ trajectoryLine.enabled = true;
+ }
+
+ // Update trajectory preview
+ if (trajectoryLine != null && trajectoryLine.enabled)
+ {
+ UpdateTrajectoryPreview(direction, force, mass);
+ }
+ }
+
+ ///
+ /// Update the trajectory preview line
+ ///
+ private void UpdateTrajectoryPreview(Vector2 direction, float force, float mass)
+ {
+ if (trajectoryLine == null) return;
+
+ var config = Config;
+ if (config == null) return;
+
+ if (mass <= 0f)
+ {
+ if (showDebugLogs) Logging.Warning("[AirplaneLaunchController] Cannot calculate trajectory with zero mass!");
+ return;
+ }
+
+ Vector2 startPos = launchAnchor.position;
+ float initialSpeed = force / mass;
+ Vector2 velocity = direction * initialSpeed;
+
+ // Get gravity from prefab's Rigidbody2D (Physics2D.gravity.magnitude * rb.gravityScale)
+ float gravity = GetGravity();
+
+ trajectoryLine.positionCount = config.trajectoryPoints;
+
+ // Calculate trajectory points using config values
+ for (int i = 0; i < config.trajectoryPoints; i++)
+ {
+ float time = i * config.trajectoryTimeStep;
+
+ // Calculate position at this time
+ float x = startPos.x + velocity.x * time;
+ float y = startPos.y + velocity.y * time - 0.5f * gravity * time * time;
+
+ trajectoryLine.SetPosition(i, new Vector3(x, y, 0));
+ }
+ }
+
+ #endregion
+
+ #region Launch
+
+ protected override void PerformLaunch(Vector2 direction, float force)
+ {
+ if (airplanePrefab == null)
+ {
+ Logging.Error("[AirplaneLaunchController] Cannot launch - airplane prefab not assigned!");
+ return;
+ }
+
+ // Spawn airplane at launch anchor
+ GameObject airplaneObj = Instantiate(airplanePrefab, launchAnchor.position, Quaternion.identity);
+ _activeAirplane = airplaneObj.GetComponent();
+
+ if (_activeAirplane == null)
+ {
+ Logging.Error("[AirplaneLaunchController] Spawned airplane missing AirplaneController!");
+ Destroy(airplaneObj);
+ return;
+ }
+
+ // Launch the airplane
+ _activeAirplane.Launch(direction, force);
+
+ // Hide trajectory preview
+ if (trajectoryLine != null)
+ {
+ trajectoryLine.enabled = false;
+ }
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[AirplaneLaunchController] Launched airplane with force {force:F2}, direction {direction}");
+ }
+
+ // Fire event
+ OnAirplaneLaunched?.Invoke(_activeAirplane);
+ }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Get reference to the currently active airplane (if any)
+ ///
+ public AirplaneController GetActiveAirplane()
+ {
+ return _activeAirplane;
+ }
+
+ ///
+ /// Clear reference to active airplane (called after airplane is destroyed)
+ ///
+ public void ClearActiveAirplane()
+ {
+ _activeAirplane = null;
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneLaunchController.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/AirplaneLaunchController.cs.meta
new file mode 100644
index 00000000..3b329113
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneLaunchController.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: a819923cb68240d494bdcf6d5ecf6b9b
+timeCreated: 1764851349
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneTargetValidator.cs b/Assets/Scripts/Minigames/Airplane/Core/AirplaneTargetValidator.cs
new file mode 100644
index 00000000..7f51d478
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneTargetValidator.cs
@@ -0,0 +1,186 @@
+using System;
+using Core;
+using Core.Lifecycle;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core
+{
+ ///
+ /// Validates whether the airplane hit the correct target.
+ /// Singleton for easy access throughout the minigame.
+ ///
+ public class AirplaneTargetValidator : ManagedBehaviour
+ {
+ #region Singleton
+
+ private static AirplaneTargetValidator _instance;
+ public static AirplaneTargetValidator Instance => _instance;
+
+ #endregion
+
+ #region Events
+
+ ///
+ /// Fired when correct target is hit. Parameters: (string targetName)
+ ///
+ public event Action OnCorrectTargetHit;
+
+ ///
+ /// Fired when wrong target is hit. Parameters: (string expectedTarget, string actualTarget)
+ ///
+ public event Action OnWrongTargetHit;
+
+ ///
+ /// Fired when no target is hit
+ ///
+ public event Action OnMissedAllTargets;
+
+ #endregion
+
+ #region State
+
+ private string _expectedTargetName = null;
+ private bool _hasValidatedCurrentShot = false;
+
+ public string ExpectedTargetName => _expectedTargetName;
+
+ #endregion
+
+ #region Configuration
+
+ [Header("Debug")]
+ [SerializeField] private bool showDebugLogs = false;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ // Set singleton
+ if (_instance != null && _instance != this)
+ {
+ Logging.Warning("[AirplaneTargetValidator] Multiple instances detected! Destroying duplicate.");
+ Destroy(gameObject);
+ return;
+ }
+ _instance = this;
+ }
+
+ internal override void OnManagedDestroy()
+ {
+ base.OnManagedDestroy();
+
+ if (_instance == this)
+ {
+ _instance = null;
+ }
+ }
+
+ #endregion
+
+ #region Target Setting
+
+ ///
+ /// Set the expected target for the current shot
+ ///
+ public void SetExpectedTarget(string targetName)
+ {
+ _expectedTargetName = targetName;
+ _hasValidatedCurrentShot = false;
+
+ if (showDebugLogs) Logging.Debug($"[AirplaneTargetValidator] Expected target set to: {targetName}");
+ }
+
+ ///
+ /// Clear the expected target
+ ///
+ public void ClearExpectedTarget()
+ {
+ _expectedTargetName = null;
+ _hasValidatedCurrentShot = false;
+
+ if (showDebugLogs) Logging.Debug("[AirplaneTargetValidator] Expected target cleared");
+ }
+
+ #endregion
+
+ #region Validation
+
+ ///
+ /// Validate if the hit target matches the expected target
+ ///
+ public bool ValidateHit(string hitTargetName)
+ {
+ // Prevent multiple validations for the same shot
+ if (_hasValidatedCurrentShot)
+ {
+ if (showDebugLogs) Logging.Debug("[AirplaneTargetValidator] Already validated this shot");
+ return false;
+ }
+
+ _hasValidatedCurrentShot = true;
+
+ if (string.IsNullOrEmpty(_expectedTargetName))
+ {
+ Logging.Warning("[AirplaneTargetValidator] No expected target set!");
+ return false;
+ }
+
+ bool isCorrect = string.Equals(hitTargetName, _expectedTargetName, StringComparison.OrdinalIgnoreCase);
+
+ if (isCorrect)
+ {
+ if (showDebugLogs) Logging.Debug($"[AirplaneTargetValidator] ✓ Correct! Hit target: {hitTargetName}");
+ OnCorrectTargetHit?.Invoke(hitTargetName);
+ }
+ else
+ {
+ if (showDebugLogs) Logging.Debug($"[AirplaneTargetValidator] ✗ Wrong! Expected: {_expectedTargetName}, Hit: {hitTargetName}");
+ OnWrongTargetHit?.Invoke(_expectedTargetName, hitTargetName);
+ }
+
+ return isCorrect;
+ }
+
+ ///
+ /// Handle case where airplane didn't hit any target
+ ///
+ public void HandleMiss()
+ {
+ // Prevent multiple validations for the same shot
+ if (_hasValidatedCurrentShot)
+ {
+ return;
+ }
+
+ _hasValidatedCurrentShot = true;
+
+ if (showDebugLogs) Logging.Debug($"[AirplaneTargetValidator] ✗ Missed! Expected target: {_expectedTargetName}");
+
+ OnMissedAllTargets?.Invoke();
+ }
+
+ #endregion
+
+ #region Query Methods
+
+ ///
+ /// Check if a target name matches the expected target
+ ///
+ public bool IsExpectedTarget(string targetName)
+ {
+ return string.Equals(targetName, _expectedTargetName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Check if validation has been done for current shot
+ ///
+ public bool HasValidated => _hasValidatedCurrentShot;
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/AirplaneTargetValidator.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/AirplaneTargetValidator.cs.meta
new file mode 100644
index 00000000..9eb19f7d
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/AirplaneTargetValidator.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 3cf7815b220240e090fb5cba4fc7414f
+timeCreated: 1764851309
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs b/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs
new file mode 100644
index 00000000..0b6b2999
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs
@@ -0,0 +1,197 @@
+using System.Collections.Generic;
+using Core;
+using Core.Lifecycle;
+using Minigames.Airplane.Data;
+using UnityEngine;
+
+namespace Minigames.Airplane.Core
+{
+ ///
+ /// Manages the queue of people waiting to launch airplanes.
+ /// Provides methods to pop the next person and track remaining people.
+ ///
+ public class PersonQueue : ManagedBehaviour
+ {
+ #region Inspector Properties
+
+ [Header("Person Setup")]
+ [Tooltip("List of people in the queue (order matters)")]
+ [SerializeField] private List peopleInQueue = new List();
+
+ [Header("Debug")]
+ [SerializeField] private bool showDebugLogs = false;
+
+ #endregion
+
+ #region State
+
+ private int _currentTurnNumber = 1;
+
+ public int TotalPeople => peopleInQueue.Count;
+ public int RemainingPeople => peopleInQueue.Count;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ ValidateQueue();
+ }
+
+ internal override void OnManagedStart()
+ {
+ base.OnManagedStart();
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[PersonQueue] Initialized with {TotalPeople} people");
+ foreach (var person in peopleInQueue)
+ {
+ Logging.Debug($" - {person.personName} -> Target: {person.targetName}");
+ }
+ }
+ }
+
+ #endregion
+
+ #region Validation
+
+ ///
+ /// Validate the queue setup
+ ///
+ private void ValidateQueue()
+ {
+ if (peopleInQueue.Count == 0)
+ {
+ Logging.Warning("[PersonQueue] No people in queue! Add people in the inspector.");
+ return;
+ }
+
+ // Check for missing data
+ for (int i = 0; i < peopleInQueue.Count; i++)
+ {
+ var person = peopleInQueue[i];
+
+ if (string.IsNullOrEmpty(person.personName))
+ {
+ Logging.Warning($"[PersonQueue] Person at index {i} has no name!");
+ }
+
+ if (string.IsNullOrEmpty(person.targetName))
+ {
+ Logging.Warning($"[PersonQueue] Person '{person.personName}' at index {i} has no target assigned!");
+ }
+
+ if (person.personTransform == null)
+ {
+ Logging.Warning($"[PersonQueue] Person '{person.personName}' at index {i} has no transform reference!");
+ }
+ }
+ }
+
+ #endregion
+
+ #region Queue Management
+
+ ///
+ /// Check if there are more people in the queue
+ ///
+ public bool HasMorePeople()
+ {
+ return peopleInQueue.Count > 0;
+ }
+
+ ///
+ /// Get the next person without removing them from the queue
+ ///
+ public PersonData PeekNextPerson()
+ {
+ if (peopleInQueue.Count == 0)
+ {
+ if (showDebugLogs) Logging.Debug("[PersonQueue] Queue is empty!");
+ return null;
+ }
+
+ return peopleInQueue[0];
+ }
+
+ ///
+ /// Pop the next person from the queue
+ ///
+ public PersonData PopNextPerson()
+ {
+ if (peopleInQueue.Count == 0)
+ {
+ if (showDebugLogs) Logging.Debug("[PersonQueue] Queue is empty!");
+ return null;
+ }
+
+ // Get first person
+ PersonData nextPerson = peopleInQueue[0];
+
+ // Assign turn number
+ nextPerson.turnNumber = _currentTurnNumber;
+ _currentTurnNumber++;
+
+ // Remove from queue
+ peopleInQueue.RemoveAt(0);
+
+ if (showDebugLogs)
+ {
+ Logging.Debug($"[PersonQueue] Popped person: {nextPerson.personName} (Turn {nextPerson.turnNumber}), " +
+ $"Remaining: {RemainingPeople}");
+ }
+
+ return nextPerson;
+ }
+
+ ///
+ /// Reset the queue (for testing or replay)
+ ///
+ public void ResetQueue(List newQueue)
+ {
+ peopleInQueue.Clear();
+ peopleInQueue.AddRange(newQueue);
+ _currentTurnNumber = 1;
+
+ if (showDebugLogs) Logging.Debug($"[PersonQueue] Reset queue with {TotalPeople} people");
+ }
+
+ ///
+ /// Clear the queue
+ ///
+ public void Clear()
+ {
+ peopleInQueue.Clear();
+ _currentTurnNumber = 1;
+
+ if (showDebugLogs) Logging.Debug("[PersonQueue] Queue cleared");
+ }
+
+ #endregion
+
+ #region Query Methods
+
+ ///
+ /// Get count of people still in queue
+ ///
+ public int GetRemainingCount()
+ {
+ return peopleInQueue.Count;
+ }
+
+ ///
+ /// Get the current turn number
+ ///
+ public int GetCurrentTurnNumber()
+ {
+ return _currentTurnNumber;
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs.meta b/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs.meta
new file mode 100644
index 00000000..7a9cf704
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Core/PersonQueue.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 77964ec3bd5848a6b947ed4ac9b0ee3f
+timeCreated: 1764851326
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Data.meta b/Assets/Scripts/Minigames/Airplane/Data.meta
new file mode 100644
index 00000000..41610835
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 4d58653664484f58be14ab8089e22ce3
+timeCreated: 1764851234
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs b/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs
new file mode 100644
index 00000000..3068533f
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs
@@ -0,0 +1,14 @@
+namespace Minigames.Airplane.Data
+{
+ ///
+ /// Camera states for the airplane minigame
+ ///
+ public enum AirplaneCameraState
+ {
+ Intro, // Intro sequence camera
+ NextPerson, // Camera focusing on the next person
+ Aiming, // Camera for aiming the airplane
+ Flight // Camera following the airplane in flight
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs.meta b/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs.meta
new file mode 100644
index 00000000..f0d441d3
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/AirplaneCameraState.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: f5b6a3623e7040be9dfeac6ee8e195cf
+timeCreated: 1764851235
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Data/AirplaneGameState.cs b/Assets/Scripts/Minigames/Airplane/Data/AirplaneGameState.cs
new file mode 100644
index 00000000..da80a4cb
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/AirplaneGameState.cs
@@ -0,0 +1,16 @@
+namespace Minigames.Airplane.Data
+{
+ ///
+ /// Game states for the airplane minigame
+ ///
+ public enum AirplaneGameState
+ {
+ Intro, // Intro sequence
+ NextPerson, // Introducing the next person
+ Aiming, // Player is aiming the airplane
+ Flying, // Airplane is in flight
+ Evaluating, // Evaluating the result of the flight
+ GameOver // All people have had their turn
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Data/AirplaneGameState.cs.meta b/Assets/Scripts/Minigames/Airplane/Data/AirplaneGameState.cs.meta
new file mode 100644
index 00000000..aa89a34f
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/AirplaneGameState.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 59636bd1dbca4575b431820510da201f
+timeCreated: 1764851235
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Data/PersonData.cs b/Assets/Scripts/Minigames/Airplane/Data/PersonData.cs
new file mode 100644
index 00000000..2aafc422
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/PersonData.cs
@@ -0,0 +1,52 @@
+using UnityEngine;
+
+namespace Minigames.Airplane.Data
+{
+ ///
+ /// Data for a person participating in the airplane minigame.
+ /// Contains their name, target assignment, and scene reference.
+ ///
+ [System.Serializable]
+ public class PersonData
+ {
+ [Tooltip("Name of the person")]
+ public string personName;
+
+ [Tooltip("Target name they need to hit")]
+ public string targetName;
+
+ [Tooltip("Transform reference to the person in the scene")]
+ public Transform personTransform;
+
+ [Tooltip("Turn number (assigned at runtime)")]
+ public int turnNumber;
+
+ ///
+ /// Constructor for creating person data
+ ///
+ public PersonData(string name, string target, Transform transform, int turn = 0)
+ {
+ personName = name;
+ targetName = target;
+ personTransform = transform;
+ turnNumber = turn;
+ }
+
+ ///
+ /// Default constructor for serialization
+ ///
+ public PersonData()
+ {
+ personName = "Unknown";
+ targetName = "Unknown";
+ personTransform = null;
+ turnNumber = 0;
+ }
+
+ public override string ToString()
+ {
+ return $"Person: {personName}, Target: {targetName}, Turn: {turnNumber}";
+ }
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Data/PersonData.cs.meta b/Assets/Scripts/Minigames/Airplane/Data/PersonData.cs.meta
new file mode 100644
index 00000000..6f2eb0b3
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Data/PersonData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: b9a03de5cfa64dadaf6c53b8f3935d3e
+timeCreated: 1764851235
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Settings.meta b/Assets/Scripts/Minigames/Airplane/Settings.meta
new file mode 100644
index 00000000..8676bb48
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Settings.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 81b8f6aeeaf946cea5f5338a9127ae74
+timeCreated: 1764851415
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs b/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs
new file mode 100644
index 00000000..56a7c0c6
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs
@@ -0,0 +1,70 @@
+using AppleHills.Core.Settings;
+using Common.Input;
+using UnityEngine;
+
+namespace Minigames.Airplane.Settings
+{
+ ///
+ /// Settings for the airplane minigame.
+ /// Create via Assets > Create > AppleHills > Settings > Airplane
+ ///
+ [CreateAssetMenu(fileName = "AirplaneSettings", menuName = "AppleHills/Settings/Airplane", order = 9)]
+ public class AirplaneSettings : BaseSettings, IAirplaneSettings
+ {
+ [Header("Slingshot Configuration")]
+ [SerializeField] private SlingshotConfig slingshotSettings = new SlingshotConfig
+ {
+ maxDragDistance = 5f,
+ baseLaunchForce = 20f,
+ minForceMultiplier = 0.1f,
+ maxForceMultiplier = 1f,
+ trajectoryPoints = 20,
+ trajectoryTimeStep = 0.1f,
+ trajectoryLockDuration = 0f, // No locking for airplane
+ autoRegisterInput = true // Direct registration
+ };
+
+ [Header("Flight Settings")]
+ [Tooltip("Mass of the airplane")]
+ [SerializeField] private float airplaneMass = 1f;
+
+ [Tooltip("Maximum flight time before timeout (seconds)")]
+ [SerializeField] private float maxFlightTime = 10f;
+
+ [Header("Camera Settings")]
+ [Tooltip("Camera follow smoothness (higher = smoother but more lag)")]
+ [SerializeField] private float cameraFollowSmoothing = 5f;
+
+ [Tooltip("Camera zoom level during flight")]
+ [SerializeField] private float flightCameraZoom = 5f;
+
+ [Header("Timing")]
+ [Tooltip("Duration of intro sequence (seconds)")]
+ [SerializeField] private float introDuration = 1f;
+
+ [Tooltip("Duration of person introduction (seconds)")]
+ [SerializeField] private float personIntroDuration = 1f;
+
+ [Tooltip("Duration of result evaluation (seconds)")]
+ [SerializeField] private float evaluationDuration = 1f;
+
+ [Header("Debug")]
+ [Tooltip("Show debug logs in console")]
+ [SerializeField] private bool showDebugLogs;
+
+ #region IAirplaneSettings Implementation
+
+ public SlingshotConfig SlingshotSettings => slingshotSettings;
+ public float AirplaneMass => airplaneMass;
+ public float MaxFlightTime => maxFlightTime;
+ public float CameraFollowSmoothing => cameraFollowSmoothing;
+ public float FlightCameraZoom => flightCameraZoom;
+ public float IntroDuration => introDuration;
+ public float PersonIntroDuration => personIntroDuration;
+ public float EvaluationDuration => evaluationDuration;
+ public bool ShowDebugLogs => showDebugLogs;
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs.meta b/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs.meta
new file mode 100644
index 00000000..80964634
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Settings/AirplaneSettings.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 1c277e2fec3d42e2b3b0bed1b8a33beb
+timeCreated: 1764851415
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Targets.meta b/Assets/Scripts/Minigames/Airplane/Targets.meta
new file mode 100644
index 00000000..927293c5
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Targets.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: bef822469ac14cedad520c7d8f01562a
+timeCreated: 1764851291
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/Airplane/Targets/AirplaneTarget.cs b/Assets/Scripts/Minigames/Airplane/Targets/AirplaneTarget.cs
new file mode 100644
index 00000000..376d72b1
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Targets/AirplaneTarget.cs
@@ -0,0 +1,151 @@
+using System;
+using Core;
+using Core.Lifecycle;
+using UnityEngine;
+
+namespace Minigames.Airplane.Targets
+{
+ ///
+ /// Represents a target in the airplane minigame.
+ /// Detects airplane collisions and can be highlighted when active.
+ ///
+ [RequireComponent(typeof(Collider2D))]
+ public class AirplaneTarget : ManagedBehaviour
+ {
+ #region Events
+
+ ///
+ /// Fired when this target is hit. Parameters: (AirplaneTarget target, GameObject airplane)
+ ///
+ public event Action OnTargetHit;
+
+ #endregion
+
+ #region Inspector Properties
+
+ [Header("Target Configuration")]
+ [Tooltip("Name of this target (for validation)")]
+ [SerializeField] private string targetName = "Target";
+
+ [Header("Visual Feedback")]
+ [Tooltip("Sprite renderer for visual feedback (optional)")]
+ [SerializeField] private SpriteRenderer spriteRenderer;
+
+ [Tooltip("Color when target is active")]
+ [SerializeField] private Color activeColor = Color.yellow;
+
+ [Tooltip("Color when target is inactive")]
+ [SerializeField] private Color inactiveColor = Color.white;
+
+ [Header("Debug")]
+ [SerializeField] private bool showDebugLogs = false;
+
+ #endregion
+
+ #region Properties
+
+ public string TargetName => targetName;
+
+ private bool _isActive = false;
+ public bool IsActive => _isActive;
+
+ #endregion
+
+ #region State
+
+ private Collider2D _targetCollider;
+ private Color _originalColor;
+
+ #endregion
+
+ #region Lifecycle
+
+ internal override void OnManagedAwake()
+ {
+ base.OnManagedAwake();
+
+ // Cache components
+ _targetCollider = GetComponent();
+
+ // Configure collider as trigger
+ if (_targetCollider != null)
+ {
+ _targetCollider.isTrigger = true;
+ }
+
+ // Cache sprite renderer if not assigned
+ if (spriteRenderer == null)
+ {
+ spriteRenderer = GetComponent();
+ }
+
+ // Store original color
+ if (spriteRenderer != null)
+ {
+ _originalColor = spriteRenderer.color;
+ }
+ }
+
+ internal override void OnManagedStart()
+ {
+ base.OnManagedStart();
+
+ // Start as inactive
+ SetAsActiveTarget(false);
+ }
+
+ #endregion
+
+ #region Active State
+
+ ///
+ /// Set this target as active (highlighted) or inactive
+ ///
+ public void SetAsActiveTarget(bool active)
+ {
+ _isActive = active;
+
+ // Update visual feedback
+ if (spriteRenderer != null)
+ {
+ spriteRenderer.color = active ? activeColor : inactiveColor;
+ }
+
+ if (showDebugLogs) Logging.Debug($"[AirplaneTarget] {targetName} set to {(active ? "active" : "inactive")}");
+ }
+
+ #endregion
+
+ #region Collision Detection
+
+ ///
+ /// Detect when airplane enters trigger
+ ///
+ private void OnTriggerEnter2D(Collider2D other)
+ {
+ // Check if it's an airplane
+ var airplane = other.GetComponent();
+ if (airplane != null)
+ {
+ if (showDebugLogs) Logging.Debug($"[AirplaneTarget] {targetName} hit by airplane: {other.gameObject.name}");
+
+ OnTargetHit?.Invoke(this, other.gameObject);
+ }
+ }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Reset target to original state
+ ///
+ public void Reset()
+ {
+ SetAsActiveTarget(false);
+ }
+
+ #endregion
+ }
+}
+
diff --git a/Assets/Scripts/Minigames/Airplane/Targets/AirplaneTarget.cs.meta b/Assets/Scripts/Minigames/Airplane/Targets/AirplaneTarget.cs.meta
new file mode 100644
index 00000000..87f94ebb
--- /dev/null
+++ b/Assets/Scripts/Minigames/Airplane/Targets/AirplaneTarget.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 53e3dae13bb14c109a038bb5a84bd941
+timeCreated: 1764851291
\ No newline at end of file
diff --git a/Assets/Scripts/Minigames/FortFight/Core/FortFightSettings.cs b/Assets/Scripts/Minigames/FortFight/Core/FortFightSettings.cs
index acece465..f9eb1fb8 100644
--- a/Assets/Scripts/Minigames/FortFight/Core/FortFightSettings.cs
+++ b/Assets/Scripts/Minigames/FortFight/Core/FortFightSettings.cs
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using AppleHills.Core.Settings;
+using Common.Input;
using Minigames.FortFight.Data;
using Minigames.FortFight.Settings;
using UnityEngine;
@@ -14,6 +15,19 @@ namespace Minigames.FortFight.Core
[CreateAssetMenu(fileName = "FortFightSettings", menuName = "AppleHills/Settings/Fort Fight", order = 8)]
public class FortFightSettings : BaseSettings, IFortFightSettings
{
+ [Header("Slingshot Configuration")]
+ [SerializeField] private SlingshotConfig slingshotSettings = new SlingshotConfig
+ {
+ maxDragDistance = 5f,
+ baseLaunchForce = 20f,
+ minForceMultiplier = 0.1f,
+ maxForceMultiplier = 1f,
+ trajectoryPoints = 50,
+ trajectoryTimeStep = 0.1f,
+ trajectoryLockDuration = 2f,
+ autoRegisterInput = false // TurnManager handles registration
+ };
+
[Header("Block Material Configurations")]
[Tooltip("HP and mass configurations for each material type")]
[SerializeField] private List materialConfigs = new List
@@ -142,6 +156,8 @@ namespace Minigames.FortFight.Core
#region IFortFightSettings Implementation
+ public SlingshotConfig SlingshotSettings => slingshotSettings;
+
public List MaterialConfigs => materialConfigs;
public List SizeConfigs => sizeConfigs;
diff --git a/Assets/Scripts/Minigames/FortFight/Core/SlingshotController.cs b/Assets/Scripts/Minigames/FortFight/Core/SlingshotController.cs
index d50599a6..ad8c03a4 100644
--- a/Assets/Scripts/Minigames/FortFight/Core/SlingshotController.cs
+++ b/Assets/Scripts/Minigames/FortFight/Core/SlingshotController.cs
@@ -1,7 +1,7 @@
using System;
using AppleHills.Core.Settings;
+using Common.Input;
using Core;
-using Core.Lifecycle;
using Minigames.FortFight.Data;
using Minigames.FortFight.Projectiles;
using UnityEngine;
@@ -9,22 +9,14 @@ using UnityEngine;
namespace Minigames.FortFight.Core
{
///
- /// Controls slingshot aiming and projectile launching.
- /// Angry Birds-style drag-to-aim mechanic with trajectory preview.
- /// Implements ITouchInputConsumer for InputManager integration.
+ /// Controls slingshot aiming and projectile launching for FortFight.
+ /// Extends DragLaunchController with FortFight-specific ammo management and trajectory preview.
///
- public class SlingshotController : ManagedBehaviour, ITouchInputConsumer
+ public class SlingshotController : DragLaunchController
{
#region Inspector Properties
- [Header("Launch Settings")]
- [Tooltip("Drag distance to reach max force")]
- [SerializeField] private float maxDragDistance = 5f;
-
- [Tooltip("Spawn point for projectiles")]
- [SerializeField] private Transform projectileSpawnPoint;
-
- [Header("References")]
+ [Header("FortFight Specific")]
[Tooltip("Trajectory preview component")]
[SerializeField] private TrajectoryPreview trajectoryPreview;
@@ -58,8 +50,15 @@ namespace Minigames.FortFight.Core
}
}
- private float MaxForce => CachedSettings?.BaseLaunchForce ?? 20f;
- private bool ShowDebugLogs => CachedDevSettings?.SlingshotShowDebugLogs ?? false;
+ protected override SlingshotConfig GetSlingshotConfig()
+ {
+ return CachedSettings?.SlingshotSettings;
+ }
+
+ protected override GameObject GetProjectilePrefab()
+ {
+ return _currentAmmo?.prefab;
+ }
#endregion
@@ -74,13 +73,10 @@ namespace Minigames.FortFight.Core
#region State
- private bool _isDragging;
- private Vector2 _dragStartPosition;
private ProjectileConfig _currentAmmo;
private ProjectileBase _activeProjectile;
- public bool IsDragging => _isDragging;
- public bool IsEnabled { get; private set; } = true;
+ public ProjectileBase ActiveProjectile => _activeProjectile;
#endregion
@@ -90,15 +86,15 @@ namespace Minigames.FortFight.Core
{
base.OnManagedAwake();
- if (projectileSpawnPoint == null)
- {
- projectileSpawnPoint = transform;
- }
+ // Base class handles launchAnchor (previously projectileSpawnPoint)
if (trajectoryPreview == null)
{
trajectoryPreview = GetComponent();
}
+
+ // Set debug logging from developer settings
+ showDebugLogs = CachedDevSettings?.SlingshotShowDebugLogs ?? false;
}
internal override void OnManagedStart()
@@ -114,132 +110,51 @@ namespace Minigames.FortFight.Core
#endregion
- #region ITouchInputConsumer Implementation
+ #region Override Methods
- public void OnTap(Vector2 worldPosition)
+ protected override float GetProjectileMass()
{
- // Slingshot uses hold/drag, not tap
+ return _currentAmmo?.GetMass() ?? base.GetProjectileMass();
}
- public void OnHoldStart(Vector2 worldPosition)
+ protected override void StartDrag(Vector2 worldPosition)
{
- if (!IsEnabled) return;
- StartDrag(worldPosition);
- }
-
- public void OnHoldMove(Vector2 worldPosition)
- {
- if (!IsEnabled || !_isDragging) return;
- UpdateDrag(worldPosition);
- }
-
- public void OnHoldEnd(Vector2 worldPosition)
- {
- if (!IsEnabled || !_isDragging) return;
- EndDrag(worldPosition);
+ // Check ammo before starting drag
+ if (_currentAmmo == null)
+ {
+ if (showDebugLogs) Logging.Warning("[SlingshotController] No ammo selected!");
+ return;
+ }
+
+ base.StartDrag(worldPosition);
}
#endregion
- #region Drag Handling
+ #region Abstract Method Implementations
- private void StartDrag(Vector2 worldPosition)
+ protected override void ShowPreview()
{
- if (_currentAmmo == null)
- {
- if (ShowDebugLogs) Logging.Warning("[SlingshotController] No ammo selected!");
- return;
- }
-
- _isDragging = true;
- // Use the projectile spawn point as the anchor, not the touch position
- // This makes it work like Angry Birds - pull back from slingshot to launch forward
- _dragStartPosition = projectileSpawnPoint.position;
-
- // Show trajectory preview
- if (trajectoryPreview != null)
- {
- trajectoryPreview.Show();
- }
-
- if (ShowDebugLogs) Logging.Debug($"[SlingshotController] Started drag at {worldPosition}, anchor at spawn point {_dragStartPosition}");
+ trajectoryPreview?.Show();
}
- private void UpdateDrag(Vector2 currentWorldPosition)
+ protected override void HidePreview()
{
- // Calculate drag vector from spawn point to current drag position
- // Pull back (away from spawn) = launch forward (toward spawn direction)
- Vector2 dragVector = _dragStartPosition - currentWorldPosition;
-
- // Calculate force and direction
- float dragDistance = dragVector.magnitude;
- float dragRatio = Mathf.Clamp01(dragDistance / maxDragDistance);
-
- // Apply configurable max force multiplier
- float maxMultiplier = CachedSettings?.MaxForceMultiplier ?? 1f;
- float forceMultiplier = dragRatio * maxMultiplier;
- float force = forceMultiplier * MaxForce;
-
- Vector2 direction = dragVector.normalized;
-
- // Update trajectory preview with projectile mass
- if (trajectoryPreview != null && _currentAmmo != null)
+ trajectoryPreview?.Hide();
+ }
+
+ protected override void UpdateVisuals(Vector2 currentPosition, Vector2 direction,
+ float force, float dragDistance, float mass)
+ {
+ if (trajectoryPreview != null)
{
- Vector2 worldStartPos = projectileSpawnPoint.position;
- float mass = _currentAmmo.GetMass();
-
- // Debug: Log trajectory calculation (uncomment for debugging)
- // if (showDebugLogs && Time.frameCount % 30 == 0) // Log every 30 frames to avoid spam
- // {
- // Logging.Debug($"[Slingshot] Preview - Force: {force:F2}, Mass: {mass:F2}, Velocity: {force/mass:F2}, Dir: {direction}");
- // }
-
- trajectoryPreview.UpdateTrajectory(worldStartPos, direction, force, mass);
+ trajectoryPreview.UpdateTrajectory(launchAnchor.position, direction, force, mass);
}
}
- private void EndDrag(Vector2 currentWorldPosition)
+ protected override void PerformLaunch(Vector2 direction, float force)
{
- _isDragging = false;
-
- // Hide trajectory
- if (trajectoryPreview != null)
- {
- trajectoryPreview.Hide();
- }
-
- // Calculate final launch parameters from spawn point to final drag position
- Vector2 dragVector = _dragStartPosition - currentWorldPosition;
- float dragDistance = dragVector.magnitude;
- float dragRatio = Mathf.Clamp01(dragDistance / maxDragDistance);
-
- // Apply configurable max force multiplier
- float maxMultiplier = CachedSettings?.MaxForceMultiplier ?? 1f;
- float forceMultiplier = dragRatio * maxMultiplier;
- float force = forceMultiplier * MaxForce;
-
- Vector2 direction = dragVector.normalized;
-
- // Check against configurable minimum force threshold
- float minMultiplier = CachedSettings?.MinForceMultiplier ?? 0.1f;
- float minForce = minMultiplier * MaxForce;
-
- // Launch projectile if force exceeds minimum
- if (force >= minForce)
- {
- if (ShowDebugLogs && _currentAmmo != null)
- {
- float mass = _currentAmmo.GetMass();
- float velocity = force / mass;
- Logging.Debug($"[Slingshot] Launch - Force: {force:F2}, Mass: {mass:F2}, Velocity: {velocity:F2}, Dir: {direction}");
- }
-
- LaunchProjectile(direction, force);
- }
- else
- {
- if (ShowDebugLogs) Logging.Debug($"[SlingshotController] Drag too short - force {force:F2} < min {minForce:F2}");
- }
+ LaunchProjectile(direction, force);
}
#endregion
@@ -252,7 +167,7 @@ namespace Minigames.FortFight.Core
public void SetAmmo(ProjectileConfig ammoConfig)
{
_currentAmmo = ammoConfig;
- if (ShowDebugLogs) Logging.Debug($"[SlingshotController] Ammo set to: {ammoConfig?.displayName ?? "null"}");
+ if (showDebugLogs) Logging.Debug($"[SlingshotController] Ammo set to: {ammoConfig?.displayName ?? "null"}");
}
///
@@ -266,8 +181,8 @@ namespace Minigames.FortFight.Core
return;
}
- // Spawn projectile
- GameObject projectileObj = Instantiate(_currentAmmo.prefab, projectileSpawnPoint.position, Quaternion.identity);
+ // Spawn projectile at launch anchor
+ GameObject projectileObj = Instantiate(_currentAmmo.prefab, launchAnchor.position, Quaternion.identity);
_activeProjectile = projectileObj.GetComponent();
if (_activeProjectile == null)
@@ -290,7 +205,7 @@ namespace Minigames.FortFight.Core
trajectoryPreview.LockTrajectory(lockDuration);
}
- if (ShowDebugLogs) Logging.Debug($"[SlingshotController] Launched {_currentAmmo?.displayName ?? "projectile"} with force {force}");
+ if (showDebugLogs) Logging.Debug($"[SlingshotController] Launched {_currentAmmo?.displayName ?? "projectile"} with force {force}");
// Fire event
OnProjectileLaunched?.Invoke(_activeProjectile);
@@ -324,7 +239,7 @@ namespace Minigames.FortFight.Core
float speed = velocity.magnitude;
float force = mass * speed;
- if (ShowDebugLogs)
+ if (showDebugLogs)
{
Logging.Debug($"[Slingshot] LaunchWithVelocity - Velocity: {velocity}, Mass: {mass:F2}, Force: {force:F2}");
}
@@ -341,34 +256,7 @@ namespace Minigames.FortFight.Core
}
#endregion
-
- #region Enable/Disable
- ///
- /// Enable slingshot (allow aiming/launching)
- ///
- public void Enable()
- {
- IsEnabled = true;
- if (ShowDebugLogs) Logging.Debug("[SlingshotController] Enabled");
- }
-
- ///
- /// Disable slingshot (prevent aiming/launching)
- ///
- public void Disable()
- {
- IsEnabled = false;
- _isDragging = false;
-
- if (trajectoryPreview != null)
- {
- trajectoryPreview.Hide();
- }
-
- if (ShowDebugLogs) Logging.Debug("[SlingshotController] Disabled");
- }
-
- #endregion
+ // Note: Enable/Disable methods now handled by base DragLaunchController class
}
}
diff --git a/Assets/Settings/FortFightSettings.asset b/Assets/Settings/FortFightSettings.asset
index 1ee88337..dd3e15f9 100644
--- a/Assets/Settings/FortFightSettings.asset
+++ b/Assets/Settings/FortFightSettings.asset
@@ -12,6 +12,17 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: eaaa527529c5438f80d27ff95c7c7930, type: 3}
m_Name: FortFightSettings
m_EditorClassIdentifier: AppleHillsScripts::Minigames.FortFight.Core.FortFightSettings
+ slingshotSettings:
+ maxDragDistance: 5
+ baseLaunchForce: 125
+ minForceMultiplier: 0.1
+ maxForceMultiplier: 1
+ trajectoryPoints: 50
+ trajectoryTimeStep: 0.1
+ trajectoryLockDuration: 2
+ gravity: 9.81
+ defaultProjectileMass: 1
+ autoRegisterInput: 0
materialConfigs:
- material: 0
baseHp: 20