Implement Fort Fight minigame (#75)

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Reviewed-on: #75
This commit is contained in:
2025-12-04 01:18:29 +00:00
parent bb8d600af2
commit e60d516e7e
127 changed files with 21544 additions and 128 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 39b2d9cda7ea6d745a490a155fc6f9ca
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fccff300fcb6488419e3871d8f59fb95
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,113 @@
using System.Collections;
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Core;
using Minigames.FortFight.Data;
using UnityEngine;
namespace Minigames.FortFight.AI
{
/// <summary>
/// AI controller for the PigMan opponent.
/// Phase 1: Stubbed implementation - just simulates taking a turn.
/// Phase 4: Full implementation with trajectory calculation and target selection.
/// </summary>
public class FortFightAIController : ManagedBehaviour
{
[Header("AI Settings (Stubbed)")]
[SerializeField] private float aiThinkTime = 1.5f; // Time AI "thinks" before acting
private TurnManager turnManager;
private bool isThinking = false;
#region Initialization
/// <summary>
/// Initialize the AI controller
/// </summary>
public void Initialize()
{
// Get reference to turn manager via singleton
turnManager = TurnManager.Instance;
if (turnManager == null)
{
Logging.Error("[FortFightAIController] TurnManager not found!");
return;
}
// Subscribe to turn events
turnManager.OnTurnStarted += OnTurnStarted;
Logging.Debug("[FortFightAIController] AI initialized");
}
#endregion
#region Turn Handling
/// <summary>
/// Called when a new turn starts
/// </summary>
private void OnTurnStarted(PlayerData currentPlayer, TurnState turnState)
{
// Only act if it's AI's turn
if (turnState == TurnState.AITurn && !isThinking)
{
StartCoroutine(ExecuteAITurn());
}
}
/// <summary>
/// Execute the AI's turn (stubbed for Phase 1)
/// </summary>
private IEnumerator ExecuteAITurn()
{
isThinking = true;
Logging.Debug($"[FortFightAIController] AI is thinking... (for {aiThinkTime}s)");
// Simulate AI "thinking"
yield return new WaitForSeconds(aiThinkTime);
// STUBBED: Perform AI action
Logging.Debug("[FortFightAIController] AI takes action! (STUBBED - no actual projectile fired yet)");
// TODO Phase 4: AI should trigger its slingshot to fire projectile here
// Turn will automatically advance when AI's projectile settles (via ProjectileTurnAction)
// Do NOT manually call EndTurn() - it's now private and automatic
// NOTE: For now, AI turn will hang until Phase 4 AI projectile system is implemented
// To test without AI, use TwoPlayer mode
isThinking = false;
Logging.Warning("[FortFightAIController] AI turn stubbed - Phase 4 needed for AI projectile firing");
}
#endregion
#region Cleanup
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
if (turnManager != null)
{
turnManager.OnTurnStarted -= OnTurnStarted;
}
}
#endregion
#region Future Implementation (Phase 4)
// TODO Phase 4: Implement ballistic trajectory calculation
// TODO Phase 4: Implement target selection logic
// TODO Phase 4: Implement shot deviation system
// TODO Phase 4: Implement ammunition selection
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d82cbd88db94eec4ba7c19ef60b9fbbc

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 461e23829a7d28547bfabd54136aff7b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,329 @@
using System;
using System.Collections.Generic;
using AppleHills.Core.Settings;
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Data;
using UnityEngine;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Manages ammunition selection and cooldowns for Fort Fight.
/// Tracks which ammo types are available and handles cooldown timers.
/// Singleton pattern for easy access throughout the game.
/// </summary>
public class AmmunitionManager : ManagedBehaviour
{
#region Singleton
private static AmmunitionManager _instance;
public static AmmunitionManager Instance
{
get
{
if (_instance == null)
{
_instance = FindFirstObjectByType<AmmunitionManager>();
}
return _instance;
}
}
#endregion
#region Constants
private const int MaxPlayers = 2; // Support 2 players (indices 0 and 1)
#endregion
#region Inspector Properties
[Header("Configuration")]
[Tooltip("Default projectile type selected at game start")]
[SerializeField] private ProjectileType defaultProjectileType = ProjectileType.Toaster;
[Header("Debug")]
[SerializeField] private bool showDebugLogs = true;
#endregion
#region Settings
private IFortFightSettings cachedSettings;
private IFortFightSettings CachedSettings
{
get
{
if (cachedSettings == null)
{
cachedSettings = GameManager.GetSettingsObject<IFortFightSettings>();
}
return cachedSettings;
}
}
private List<ProjectileConfig> AvailableConfigs
{
get
{
var settings = CachedSettings;
return settings != null ? new List<ProjectileConfig>(settings.ProjectileConfigs) : new List<ProjectileConfig>();
}
}
#endregion
#region Events
/// <summary>
/// Fired when ammo selection changes. Parameters: (ProjectileType selectedType, int playerIndex)
/// </summary>
public event Action<ProjectileType, int> OnAmmoSelected;
/// <summary>
/// Fired when ammo is used and enters cooldown. Parameters: (ProjectileType type, int cooldownTurns)
/// </summary>
public event Action<ProjectileType, int> OnAmmoCooldownStarted;
/// <summary>
/// Fired when ammo cooldown completes. Parameters: (ProjectileType type)
/// </summary>
public event Action<ProjectileType> OnAmmoCooldownCompleted;
#endregion
#region State
// Per-player ammunition state (encapsulates cooldowns, selection, usage)
private Dictionary<int, PlayerAmmoState> playerStates = new Dictionary<int, PlayerAmmoState>();
#endregion
#region Lifecycle
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Register singleton
if (_instance != null && _instance != this)
{
Logging.Warning("[AmmunitionManager] Multiple instances detected! Destroying duplicate.");
Destroy(gameObject);
return;
}
_instance = this;
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Initialize player states
var configs = AvailableConfigs;
for (int playerIndex = 0; playerIndex < MaxPlayers; playerIndex++)
{
// Create player state with default ammo
playerStates[playerIndex] = new PlayerAmmoState(playerIndex, defaultProjectileType);
// Initialize cooldowns for all projectile types
foreach (var config in configs)
{
playerStates[playerIndex].InitializeCooldown(config.projectileType);
}
// Select default ammo (triggers event)
SelectAmmo(defaultProjectileType, playerIndex);
}
}
/// <summary>
/// Called when a player's turn ends - decrements that player's cooldowns by 1 turn.
/// Should be called by TurnManager on OnTurnEnded event.
/// </summary>
public void DecrementCooldowns(int playerIndex)
{
if (!playerStates.ContainsKey(playerIndex))
{
Logging.Warning($"[AmmunitionManager] Player {playerIndex} state not found!");
return;
}
// Decrement cooldowns and get completed types
List<ProjectileType> completedCooldowns = playerStates[playerIndex].DecrementCooldowns();
// Fire events for completed cooldowns
var settings = CachedSettings;
foreach (var type in completedCooldowns)
{
var config = settings?.GetProjectileConfig(type);
if (config != null)
{
if (showDebugLogs) Logging.Debug($"[AmmunitionManager] Player {playerIndex}: {config.displayName} cooldown completed");
OnAmmoCooldownCompleted?.Invoke(type);
}
}
}
#endregion
#region Ammo Selection
/// <summary>
/// Select ammunition type for a specific player (if available - not on cooldown)
/// </summary>
public bool SelectAmmo(ProjectileType type, int playerIndex)
{
if (!playerStates.ContainsKey(playerIndex))
{
Logging.Warning($"[AmmunitionManager] Player {playerIndex} state not found!");
return false;
}
var settings = CachedSettings;
var config = settings?.GetProjectileConfig(type);
if (config == null)
{
Logging.Warning($"[AmmunitionManager] Projectile type {type} not found in settings");
return false;
}
if (!IsAmmoAvailable(type, playerIndex))
{
if (showDebugLogs) Logging.Debug($"[AmmunitionManager] Player {playerIndex}: {config.displayName} is on cooldown");
return false;
}
playerStates[playerIndex].SelectedAmmo = type;
if (showDebugLogs) Logging.Debug($"[AmmunitionManager] Player {playerIndex} selected: {config.displayName}");
OnAmmoSelected?.Invoke(type, playerIndex);
return true;
}
/// <summary>
/// Get currently selected projectile type for a specific player
/// </summary>
public ProjectileType GetSelectedAmmoType(int playerIndex)
{
if (playerStates.ContainsKey(playerIndex))
{
return playerStates[playerIndex].SelectedAmmo;
}
return defaultProjectileType;
}
/// <summary>
/// Get currently selected projectile config for a specific player
/// </summary>
public ProjectileConfig GetSelectedAmmoConfig(int playerIndex)
{
var type = GetSelectedAmmoType(playerIndex);
return CachedSettings?.GetProjectileConfig(type);
}
/// <summary>
/// Check if ammunition type is available for a specific player (not on cooldown)
/// </summary>
public bool IsAmmoAvailable(ProjectileType type, int playerIndex)
{
if (!playerStates.ContainsKey(playerIndex))
{
return false;
}
return playerStates[playerIndex].IsAmmoAvailable(type);
}
/// <summary>
/// Get remaining cooldown turns for ammo for a specific player
/// </summary>
public int GetCooldownRemaining(ProjectileType type, int playerIndex)
{
if (!playerStates.ContainsKey(playerIndex))
{
return 0;
}
return playerStates[playerIndex].GetCooldown(type);
}
#endregion
#region Ammo Usage
/// <summary>
/// Use specific ammo for a specific player (trigger turn-based cooldown)
/// </summary>
public void UseAmmo(ProjectileType type, int playerIndex)
{
if (!playerStates.ContainsKey(playerIndex))
{
Logging.Warning($"[AmmunitionManager] Player {playerIndex} state not found!");
return;
}
var settings = CachedSettings;
var config = settings?.GetProjectileConfig(type);
if (config == null)
{
Logging.Warning($"[AmmunitionManager] Projectile type {type} not found in settings");
return;
}
// Set cooldown and record usage
playerStates[playerIndex].SetCooldown(type, config.cooldownTurns);
playerStates[playerIndex].RecordUsage(type);
if (showDebugLogs) Logging.Debug($"[AmmunitionManager] Player {playerIndex}: {config.displayName} used - cooldown: {config.cooldownTurns} turns");
OnAmmoCooldownStarted?.Invoke(type, config.cooldownTurns);
}
#endregion
#region Public API
/// <summary>
/// Reset all cooldowns for all players
/// </summary>
public void ResetAllCooldowns()
{
var configs = AvailableConfigs;
foreach (var playerState in playerStates.Values)
{
foreach (var config in configs)
{
playerState.SetCooldown(config.projectileType, 0);
}
}
if (showDebugLogs) Logging.Debug("[AmmunitionManager] All cooldowns reset for all players");
}
/// <summary>
/// Get all available projectile types from settings
/// </summary>
public List<ProjectileType> GetAvailableProjectileTypes()
{
var types = new List<ProjectileType>();
var configs = AvailableConfigs;
foreach (var config in configs)
{
types.Add(config.projectileType);
}
return types;
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4701ebc78fda468e9d8f3cf7fa7ee9f3
timeCreated: 1764682641

View File

@@ -0,0 +1,294 @@
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Data;
using Unity.Cinemachine;
using UnityEngine;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Manages camera states and transitions for the Fort Fight minigame.
/// Subscribes to turn events and switches camera views accordingly.
/// Uses Cinemachine for smooth camera blending.
/// Singleton pattern for easy access.
/// </summary>
public class CameraController : ManagedBehaviour
{
#region Singleton
private static CameraController _instance;
public static CameraController Instance
{
get
{
if (_instance == null)
{
_instance = FindFirstObjectByType<CameraController>();
}
return _instance;
}
}
#endregion
#region Inspector References
[Header("Cinemachine Cameras")]
[Tooltip("Virtual camera showing wide battlefield view (both forts)")]
[SerializeField] private CinemachineCamera wideViewCamera;
[Tooltip("Player One's dedicated camera (position this in the scene for Player 1's view)")]
[SerializeField] private CinemachineCamera playerOneCamera;
[Tooltip("Player Two's dedicated camera (position this in the scene for Player 2's view)")]
[SerializeField] private CinemachineCamera playerTwoCamera;
[Tooltip("Camera that follows projectiles in flight (should have CinemachineFollow component)")]
[SerializeField] private CinemachineCamera projectileCamera;
// Note: TurnManager accessed via singleton
#endregion
#region Public Properties
public CinemachineCamera WideViewCamera => wideViewCamera;
public CinemachineCamera PlayerOneCamera => playerOneCamera;
public CinemachineCamera PlayerTwoCamera => playerTwoCamera;
public CinemachineCamera ProjectileCamera => projectileCamera;
#endregion
#region Lifecycle
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Register singleton
if (_instance != null && _instance != this)
{
Logging.Warning("[CameraController] Multiple instances detected! Destroying duplicate.");
Destroy(gameObject);
return;
}
_instance = this;
// Validate references
if (wideViewCamera == null)
{
Logging.Error("[CameraController] Wide view camera not assigned!");
}
if (playerOneCamera == null)
{
Logging.Error("[CameraController] Player One camera not assigned!");
}
if (playerTwoCamera == null)
{
Logging.Error("[CameraController] Player Two camera not assigned!");
}
if (projectileCamera == null)
{
Logging.Warning("[CameraController] Projectile camera not assigned - projectiles won't be followed!");
}
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Subscribe to turn events via singleton
if (TurnManager.Instance != null)
{
TurnManager.Instance.OnTurnStarted += HandleTurnStarted;
TurnManager.Instance.OnTurnEnded += HandleTurnEnded;
Logging.Debug("[CameraController] Subscribed to turn events");
}
// Start in wide view
ShowWideView();
}
internal override void OnManagedDestroy()
{
if (_instance == this)
{
_instance = null;
}
base.OnManagedDestroy();
// Unsubscribe from events
if (TurnManager.Instance != null)
{
TurnManager.Instance.OnTurnStarted -= HandleTurnStarted;
TurnManager.Instance.OnTurnEnded -= HandleTurnEnded;
}
}
#endregion
#region Event Handlers
/// <summary>
/// Called when a player's turn starts - activate their dedicated camera
/// </summary>
private void HandleTurnStarted(PlayerData player, TurnState turnState)
{
Logging.Debug($"[CameraController] Turn started for {player.PlayerName} (Index: {player.PlayerIndex}, State: {turnState})");
// If transitioning, show wide view
if (turnState == TurnState.TransitioningTurn)
{
ActivateCamera(wideViewCamera);
return;
}
// Activate the appropriate player camera based on player index
if (player.PlayerIndex == 0)
{
// Player One's turn
ActivateCamera(playerOneCamera);
}
else if (player.PlayerIndex == 1)
{
// Player Two's turn
ActivateCamera(playerTwoCamera);
}
else
{
Logging.Warning($"[CameraController] Unknown player index: {player.PlayerIndex}, defaulting to wide view");
ActivateCamera(wideViewCamera);
}
}
/// <summary>
/// Called when a player's turn ends - camera switches handled by turn state changes
/// </summary>
private void HandleTurnEnded(PlayerData player)
{
Logging.Debug($"[CameraController] Turn ended for {player.PlayerName}");
// Camera switching happens via OnTurnStarted when state changes to TransitioningTurn
}
/// <summary>
/// Activate a specific camera by setting its priority highest
/// </summary>
private void ActivateCamera(CinemachineCamera camera)
{
if (camera == null) return;
// Set all cameras to low priority
if (wideViewCamera != null) wideViewCamera.Priority.Value = 10;
if (playerOneCamera != null) playerOneCamera.Priority.Value = 10;
if (playerTwoCamera != null) playerTwoCamera.Priority.Value = 10;
if (projectileCamera != null) projectileCamera.Priority.Value = 10;
// Set target camera to high priority
camera.Priority.Value = 20;
Logging.Debug($"[CameraController] Activated camera: {camera.gameObject.name}");
}
#endregion
#region Projectile Tracking
/// <summary>
/// Start following a projectile with the projectile camera.
/// Called when a projectile is launched.
/// </summary>
public void StartFollowingProjectile(Transform projectileTransform)
{
if (projectileCamera == null)
{
Logging.Warning("[CameraController] Cannot follow projectile - projectile camera not assigned!");
return;
}
if (projectileTransform == null)
{
Logging.Warning("[CameraController] Cannot follow null projectile transform!");
return;
}
// Verify CinemachineFollow component exists (optional check)
var followComponent = projectileCamera.GetComponent<CinemachineFollow>();
if (followComponent == null)
{
Logging.Error("[CameraController] Projectile camera missing CinemachineFollow component!");
return;
}
// Set the follow target on the CinemachineCamera's Target property
projectileCamera.Target.TrackingTarget = projectileTransform;
// Activate the projectile camera
ActivateCamera(projectileCamera);
Logging.Debug($"[CameraController] Now following projectile: {projectileTransform.gameObject.name}");
}
/// <summary>
/// Stop following the projectile and return to wide view.
/// Called when projectile has settled.
/// </summary>
public void StopFollowingProjectile()
{
if (projectileCamera == null) return;
// Clear the follow target on the CinemachineCamera's Target property
projectileCamera.Target.TrackingTarget = null;
// Return to wide view
ActivateCamera(wideViewCamera);
Logging.Debug("[CameraController] Stopped following projectile, returned to wide view");
}
#endregion
#region Public API
/// <summary>
/// Manually switch to wide view (useful for game start/end)
/// </summary>
public void ShowWideView()
{
ActivateCamera(wideViewCamera);
}
/// <summary>
/// Manually switch to a specific player's camera
/// </summary>
public void ShowPlayerCamera(int playerIndex)
{
if (playerIndex == 0)
{
ActivateCamera(playerOneCamera);
}
else if (playerIndex == 1)
{
ActivateCamera(playerTwoCamera);
}
}
#endregion
#region Editor Helpers
#if UNITY_EDITOR
private void OnValidate()
{
}
#endif
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: aa30fcfc16ed44d59edd73fd0224d03c
timeCreated: 1764674161

View File

@@ -0,0 +1,386 @@
using System;
using Core;
using UnityEngine;
using Core.Lifecycle;
using Minigames.FortFight.AI;
using Minigames.FortFight.Data;
using Minigames.FortFight.UI;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Main game manager for Fort Fight minigame.
/// Orchestrates game flow, mode selection, and coordinates between systems.
/// Singleton pattern for easy access.
/// </summary>
public class FortFightGameManager : ManagedBehaviour
{
#region Singleton
private static FortFightGameManager _instance;
public static FortFightGameManager Instance => _instance;
#endregion
#region Inspector References
[Header("Core Systems")]
[SerializeField] private FortFightAIController aiController;
[Header("UI References")]
[SerializeField] private ModeSelectionPage modeSelectionPage;
[SerializeField] private GameplayPage gameplayPage;
[SerializeField] private UI.GameOverUI gameOverUI;
// Note: TurnManager and FortManager accessed via singletons
#endregion
#region Events
/// <summary>
/// Fired when game mode is selected and game is starting
/// </summary>
public event Action<FortFightGameMode> OnGameModeSelected;
/// <summary>
/// Fired when the game actually starts (after mode selection)
/// </summary>
public event Action OnGameStarted;
/// <summary>
/// Fired when game ends
/// </summary>
public event Action OnGameEnded;
#endregion
#region State
private FortFightGameMode currentGameMode;
private PlayerData playerOne;
private PlayerData playerTwo;
private bool isGameActive = false;
private float gameStartTime = 0f;
public FortFightGameMode CurrentGameMode => currentGameMode;
public bool IsGameActive => isGameActive;
/// <summary>
/// Get elapsed game time in seconds since game started
/// </summary>
public float ElapsedGameTime
{
get
{
if (!isGameActive) return 0f;
return Time.time - gameStartTime;
}
}
#endregion
#region Lifecycle
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Set singleton
if (_instance != null && _instance != this)
{
Logging.Warning("[FortFightGameManager] Multiple instances detected! Destroying duplicate.");
Destroy(gameObject);
return;
}
_instance = this;
// Validate references
ValidateReferences();
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Show mode selection page
ShowModeSelection();
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe from fort defeated events
var fortManager = FortManager.Instance;
if (fortManager != null)
{
if (fortManager.PlayerFort != null)
{
fortManager.PlayerFort.OnFortDefeated -= OnFortDefeated;
}
if (fortManager.EnemyFort != null)
{
fortManager.EnemyFort.OnFortDefeated -= OnFortDefeated;
}
}
// Clear events
OnGameModeSelected = null;
OnGameStarted = null;
OnGameEnded = null;
}
#endregion
#region Validation
private void ValidateReferences()
{
if (aiController == null)
{
Logging.Warning("[FortFightGameManager] AIController reference not assigned! AI mode will not work.");
}
if (modeSelectionPage == null)
{
Logging.Error("[FortFightGameManager] ModeSelectionPage reference not assigned!");
}
if (gameplayPage == null)
{
Logging.Error("[FortFightGameManager] GameplayPage reference not assigned!");
}
if (gameOverUI == null)
{
Logging.Error("[FortFightGameManager] GameOverUI reference not assigned!");
}
}
#endregion
#region Game Flow
/// <summary>
/// Show the mode selection screen
/// </summary>
private void ShowModeSelection()
{
if (modeSelectionPage != null)
{
modeSelectionPage.gameObject.SetActive(true);
modeSelectionPage.TransitionIn();
Logging.Debug("[FortFightGameManager] Showing mode selection page");
}
// Hide other UI pages
if (gameplayPage != null)
{
gameplayPage.gameObject.SetActive(false);
}
if (gameOverUI != null)
{
gameOverUI.Hide();
}
}
/// <summary>
/// Called when player selects a game mode
/// </summary>
public void SelectGameMode(FortFightGameMode mode)
{
currentGameMode = mode;
Logging.Debug($"[FortFightGameManager] Game mode selected: {mode}");
OnGameModeSelected?.Invoke(mode);
// Initialize players based on mode
InitializePlayers();
// Transition to gameplay
StartGame();
}
/// <summary>
/// Initialize player data based on selected game mode
/// </summary>
private void InitializePlayers()
{
playerOne = new PlayerData("Player", false, 0);
if (currentGameMode == FortFightGameMode.SinglePlayer)
{
playerTwo = new PlayerData("PigMan AI", true, 1);
}
else
{
playerTwo = new PlayerData("Player 2", false, 1);
}
Logging.Debug($"[FortFightGameManager] Players initialized - P1: {playerOne.PlayerName}, P2: {playerTwo.PlayerName}");
// Spawn forts for both players via singleton
if (FortManager.Instance != null)
{
FortManager.Instance.SpawnForts();
Logging.Debug("[FortFightGameManager] Forts spawned for both players");
}
else
{
Logging.Warning("[FortFightGameManager] FortManager not found! Forts will not spawn.");
}
}
/// <summary>
/// Start the game
/// </summary>
private void StartGame()
{
// Hide mode selection, show gameplay
if (modeSelectionPage != null)
{
modeSelectionPage.TransitionOut();
}
if (gameplayPage != null)
{
gameplayPage.gameObject.SetActive(true);
gameplayPage.TransitionIn();
}
// Initialize turn manager via singleton
if (TurnManager.Instance != null)
{
TurnManager.Instance.Initialize(playerOne, playerTwo);
TurnManager.Instance.StartGame();
}
// Initialize AI if in single player mode
if (currentGameMode == FortFightGameMode.SinglePlayer && aiController != null)
{
aiController.Initialize();
}
// Subscribe to fort defeated events (may need to wait for forts to spawn)
StartCoroutine(SubscribeToFortEventsWhenReady());
isGameActive = true;
gameStartTime = Time.time; // Start tracking elapsed time
OnGameStarted?.Invoke();
Logging.Debug("[FortFightGameManager] Game started!");
}
/// <summary>
/// Wait for forts to be spawned and ready, then subscribe to their defeat events
/// </summary>
private System.Collections.IEnumerator SubscribeToFortEventsWhenReady()
{
Logging.Debug("[FortFightGameManager] Waiting for forts to be ready...");
var fortManager = FortManager.Instance;
if (fortManager == null)
{
Logging.Error("[FortFightGameManager] FortManager not found! Cannot subscribe to fort events.");
yield break;
}
// Wait up to 5 seconds for forts to spawn
float timeout = 5f;
float elapsed = 0f;
while ((fortManager.PlayerFort == null || fortManager.EnemyFort == null) && elapsed < timeout)
{
yield return new WaitForSeconds(0.1f);
elapsed += 0.1f;
}
if (fortManager.PlayerFort == null || fortManager.EnemyFort == null)
{
Logging.Error($"[FortFightGameManager] Forts not ready after {timeout}s! PlayerFort: {fortManager.PlayerFort != null}, EnemyFort: {fortManager.EnemyFort != null}");
yield break;
}
// Subscribe to both forts
Logging.Debug($"[FortFightGameManager] Forts ready! Subscribing to defeat events...");
fortManager.PlayerFort.OnFortDefeated += OnFortDefeated;
fortManager.EnemyFort.OnFortDefeated += OnFortDefeated;
Logging.Debug($"[FortFightGameManager] Successfully subscribed to fort defeat events: PlayerFort={fortManager.PlayerFort.FortName}, EnemyFort={fortManager.EnemyFort.FortName}");
}
/// <summary>
/// Called when any fort is defeated
/// </summary>
private void OnFortDefeated()
{
Logging.Debug("[FortFightGameManager] Fort defeated, ending game...");
EndGame();
}
/// <summary>
/// End the game and show game over UI
/// </summary>
public void EndGame()
{
if (!isGameActive)
{
Logging.Warning("[FortFightGameManager] EndGame called but game is not active");
return;
}
isGameActive = false;
// Stop turn manager
if (TurnManager.Instance != null)
{
TurnManager.Instance.SetGameOver();
}
// Manage UI transitions
ShowGameOver();
OnGameEnded?.Invoke();
Logging.Debug("[FortFightGameManager] Game ended");
}
/// <summary>
/// Show game over UI and hide gameplay UI
/// </summary>
private void ShowGameOver()
{
// Hide gameplay page
if (gameplayPage != null)
{
gameplayPage.gameObject.SetActive(false);
}
// Show game over UI
if (gameOverUI != null)
{
gameOverUI.Show();
}
else
{
Logging.Error("[FortFightGameManager] Cannot show game over UI - reference not assigned!");
}
// Switch camera to wide view
var cameraController = CameraController.Instance;
if (cameraController != null)
{
cameraController.ShowWideView();
}
Logging.Debug("[FortFightGameManager] Game over UI shown");
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 517ef0a4f14e16f42987a95684371b73

View File

@@ -0,0 +1,248 @@
using System.Collections.Generic;
using System.Linq;
using AppleHills.Core.Settings;
using Minigames.FortFight.Data;
using Minigames.FortFight.Settings;
using UnityEngine;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Settings for the Fort Fight minigame.
/// Contains all configurable gameplay values for balancing.
/// </summary>
[CreateAssetMenu(fileName = "FortFightSettings", menuName = "AppleHills/Settings/Fort Fight", order = 8)]
public class FortFightSettings : BaseSettings, IFortFightSettings
{
[Header("Block Material Configurations")]
[Tooltip("HP and mass configurations for each material type")]
[SerializeField] private List<BlockMaterialConfig> materialConfigs = new List<BlockMaterialConfig>
{
new BlockMaterialConfig { material = BlockMaterial.Cardboard, baseHp = 20f, baseMass = 0.5f },
new BlockMaterialConfig { material = BlockMaterial.Metal, baseHp = 50f, baseMass = 2f },
new BlockMaterialConfig { material = BlockMaterial.Glass, baseHp = 15f, baseMass = 0.8f }
};
[Header("Block Size Configurations")]
[Tooltip("HP and mass multipliers for each size type")]
[SerializeField] private List<BlockSizeConfig> sizeConfigs = new List<BlockSizeConfig>
{
new BlockSizeConfig { size = BlockSize.Small, hpMultiplier = 1f, massMultiplier = 0.5f },
new BlockSizeConfig { size = BlockSize.Medium, hpMultiplier = 1.5f, massMultiplier = 1f },
new BlockSizeConfig { size = BlockSize.Large, hpMultiplier = 2f, massMultiplier = 2f }
};
[Header("Weak Point Settings")]
[Tooltip("Radius of explosion effect from weak points")]
[SerializeField] private float weakPointExplosionRadius = 2.5f;
[Tooltip("Base damage dealt by weak point explosion")]
[SerializeField] private float weakPointExplosionDamage = 50f;
[Tooltip("Force applied to blocks hit by weak point explosion")]
[SerializeField] private float weakPointExplosionForce = 300f;
[Header("Fort Configuration")]
[Tooltip("HP percentage threshold for fort defeat (0.3 = 30%)")]
[SerializeField] private float fortDefeatThreshold = 0.3f;
[Header("Physics Settings")]
[Tooltip("Gravity scale for fort blocks (1.0 = normal Unity gravity)")]
[SerializeField] private float blockGravityScale = 1f;
[Tooltip("Gravity scale for projectiles (1.0 = normal Unity gravity)")]
[SerializeField] private float projectileGravityScale = 1f;
[Header("Turn & Projectile Timing")]
[Tooltip("Time to wait after projectile stops moving before ending turn")]
[SerializeField] private float projectileSettleDelay = 2.5f;
[Tooltip("Additional delay during turn transition with wide view camera")]
[SerializeField] private float turnTransitionDelay = 1.5f;
[Header("BASE Projectile Configurations")]
[Tooltip("All available projectile types and their base configurations (damage, mass, cooldown)")]
[SerializeField] private List<ProjectileConfig> projectileConfigs = new List<ProjectileConfig>();
[Header("Projectile Ability - Vacuum Cleaner")]
[Tooltip("Constant sliding velocity in meters per second")]
[SerializeField] private float vacuumSlideSpeed = 10f;
[Tooltip("Number of blocks to destroy while sliding")]
[SerializeField] private int vacuumDestroyBlockCount = 3;
[Tooltip("Damage dealt to blocks while sliding (high value for instant destruction)")]
[SerializeField] private float vacuumBlockDamage = 999f;
[Header("Projectile Ability - Trash Bag")]
[Tooltip("Number of trash pieces to spawn on impact")]
[SerializeField] private int trashBagPieceCount = 8;
[Tooltip("Force applied to each trash piece")]
[SerializeField] private float trashBagPieceForce = 10f;
[Tooltip("Spread cone angle for trash pieces (degrees)")]
[SerializeField] private float trashBagSpreadAngle = 60f;
[Tooltip("Damage each trash piece deals on collision with blocks")]
[SerializeField] private float trashPieceDamage = 5f;
[Tooltip("How long trash pieces persist before auto-cleanup (seconds)")]
[SerializeField] private float trashPieceLifetime = 5f;
[Header("Projectile Ability - Ceiling Fan")]
[Tooltip("Delay before tap-to-drop becomes available (seconds)")]
[SerializeField] private float ceilingFanActivationDelay = 0.5f;
[Tooltip("Brief pause before ceiling fan starts dropping (seconds)")]
[SerializeField] private float ceilingFanDropDelay = 0.2f;
[Tooltip("Downward velocity when dropping (m/s)")]
[SerializeField] private float ceilingFanDropSpeed = 20f;
[Header("Slingshot Settings")]
[Tooltip("Base launch force multiplier - higher values = projectiles fly farther")]
[SerializeField] private float baseLaunchForce = 20f;
[Tooltip("Minimum force multiplier (0-1, e.g. 0.1 = 10% of max force required to launch)")]
[Range(0f, 1f)]
[SerializeField] private float minForceMultiplier = 0.1f;
[Tooltip("Maximum force multiplier (0-1, e.g. 1.0 = 100% at max drag distance)")]
[Range(0f, 2f)]
[SerializeField] private float maxForceMultiplier = 1f;
[Tooltip("How long to keep trajectory visible after launching (seconds)")]
[SerializeField] private float trajectoryLockDuration = 2f;
[Header("Physics Layers")]
[Tooltip("Layer for fort blocks - projectiles will collide with these (Default: Layer 8 'FortBlock')")]
[AppleHills.Core.Settings.Layer]
[SerializeField] private int fortBlockLayer = 8;
[Tooltip("Layer for projectiles - for filtering projectile-to-projectile collisions (Default: Layer 9 'Projectile')")]
[AppleHills.Core.Settings.Layer]
[SerializeField] private int projectileLayer = 9;
[Header("Visual Settings")]
[Tooltip("Color tint applied to damaged blocks")]
[SerializeField] private Color damageColorTint = new Color(0.5f, 0.5f, 0.5f);
#region IFortFightSettings Implementation
public List<BlockMaterialConfig> MaterialConfigs => materialConfigs;
public List<BlockSizeConfig> SizeConfigs => sizeConfigs;
public float WeakPointExplosionRadius => weakPointExplosionRadius;
public float WeakPointExplosionDamage => weakPointExplosionDamage;
public float WeakPointExplosionForce => weakPointExplosionForce;
public float FortDefeatThreshold => fortDefeatThreshold;
public float PhysicsGravityScale => blockGravityScale; // Kept for backwards compatibility
public float BlockGravityScale => blockGravityScale;
public float ProjectileGravityScale => projectileGravityScale;
public float ProjectileSettleDelay => projectileSettleDelay;
public float TurnTransitionDelay => turnTransitionDelay;
public int FortBlockLayer => fortBlockLayer;
public int ProjectileLayer => projectileLayer;
public Color DamageColorTint => damageColorTint;
public float BaseLaunchForce => baseLaunchForce;
public float MinForceMultiplier => minForceMultiplier;
public float MaxForceMultiplier => maxForceMultiplier;
public float TrajectoryLockDuration => trajectoryLockDuration;
public float VacuumSlideSpeed => vacuumSlideSpeed;
public int VacuumDestroyBlockCount => vacuumDestroyBlockCount;
public float VacuumBlockDamage => vacuumBlockDamage;
public int TrashBagPieceCount => trashBagPieceCount;
public float TrashBagPieceForce => trashBagPieceForce;
public float TrashBagSpreadAngle => trashBagSpreadAngle;
public float TrashPieceDamage => trashPieceDamage;
public float TrashPieceLifetime => trashPieceLifetime;
public float CeilingFanActivationDelay => ceilingFanActivationDelay;
public float CeilingFanDropDelay => ceilingFanDropDelay;
public float CeilingFanDropSpeed => ceilingFanDropSpeed;
public IReadOnlyList<ProjectileConfig> ProjectileConfigs => projectileConfigs;
/// <summary>
/// Get projectile configuration by type
/// </summary>
public ProjectileConfig GetProjectileConfig(ProjectileType type)
{
foreach (var config in projectileConfigs)
{
if (config.projectileType == type)
{
return config;
}
}
return null;
}
/// <summary>
/// Get projectile configuration by ID string
/// </summary>
public ProjectileConfig GetProjectileConfigById(string projectileId)
{
foreach (var config in projectileConfigs)
{
if (config.projectileId == projectileId)
{
return config;
}
}
return null;
}
public BlockMaterialConfig GetMaterialConfig(BlockMaterial material)
{
return materialConfigs.FirstOrDefault(c => c.material == material);
}
public BlockSizeConfig GetSizeConfig(BlockSize size)
{
return sizeConfigs.FirstOrDefault(c => c.size == size);
}
#endregion
#region Validation
private void OnValidate()
{
// Validate projectile configs
foreach (var config in projectileConfigs)
{
config?.Validate();
}
// Ensure defeat threshold is between 0 and 1
fortDefeatThreshold = Mathf.Clamp01(fortDefeatThreshold);
// Ensure all materials are configured
foreach (BlockMaterial material in System.Enum.GetValues(typeof(BlockMaterial)))
{
if (!materialConfigs.Any(c => c.material == material))
{
Debug.LogWarning($"[FortFightSettings] Missing configuration for material: {material}");
}
}
// Ensure all sizes are configured
foreach (BlockSize size in System.Enum.GetValues(typeof(BlockSize)))
{
if (!sizeConfigs.Any(c => c.size == size))
{
Debug.LogWarning($"[FortFightSettings] Missing configuration for size: {size}");
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: eaaa527529c5438f80d27ff95c7c7930
timeCreated: 1764669847

View File

@@ -0,0 +1,294 @@
using System;
using Core;
using Core.Lifecycle;
using UnityEngine;
using Minigames.FortFight.Data;
using Minigames.FortFight.Fort;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Manages fort prefab spawning and references during gameplay.
/// Singleton pattern for easy access to fort references.
/// </summary>
public class FortManager : ManagedBehaviour
{
#region Singleton
private static FortManager _instance;
public static FortManager Instance
{
get
{
if (_instance == null)
{
_instance = FindFirstObjectByType<FortManager>();
}
return _instance;
}
}
#endregion
#region Inspector Properties
[Header("Fort Prefabs")]
[SerializeField] private GameObject[] premadeFortPrefabs;
[Tooltip("Leave empty to spawn random forts. Assign specific prefabs for testing.")]
[SerializeField] private GameObject debugPlayerFortPrefab;
[SerializeField] private GameObject debugEnemyFortPrefab;
[Header("Spawn Points")]
[SerializeField] private Transform playerSpawnPoint;
[SerializeField] private Transform enemySpawnPoint;
[Header("Settings")]
[SerializeField] private bool useDebugForts = false;
#endregion
#region Events
/// <summary>
/// Fired when player fort is spawned
/// </summary>
public event Action<FortController> OnPlayerFortSpawned;
/// <summary>
/// Fired when enemy fort is spawned
/// </summary>
public event Action<FortController> OnEnemyFortSpawned;
#endregion
#region Properties
public FortController PlayerFort { get; private set; }
public FortController EnemyFort { get; private set; }
#endregion
#region Lifecycle
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Register singleton
if (_instance != null && _instance != this)
{
Logging.Warning("[FortManager] Multiple instances detected! Destroying duplicate.");
Destroy(gameObject);
return;
}
_instance = this;
// Validate spawn points
if (playerSpawnPoint == null)
{
Logging.Error("[FortManager] Player spawn point not assigned!");
}
if (enemySpawnPoint == null)
{
Logging.Error("[FortManager] Enemy spawn point not assigned!");
}
// Validate fort prefabs
if (premadeFortPrefabs == null || premadeFortPrefabs.Length == 0)
{
Logging.Warning("[FortManager] No premade fort prefabs assigned! Add at least one fort prefab.");
}
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
OnPlayerFortSpawned = null;
OnEnemyFortSpawned = null;
}
#endregion
#region Spawning
/// <summary>
/// Spawn forts for both player and enemy at game start
/// </summary>
public void SpawnForts()
{
Logging.Debug("[FortManager] Spawning forts for both players");
// Spawn player fort
if (useDebugForts && debugPlayerFortPrefab != null)
{
PlayerFort = SpawnFort(debugPlayerFortPrefab, playerSpawnPoint, "Player Fort");
}
else
{
PlayerFort = SpawnRandomFort(playerSpawnPoint, "Player Fort");
}
if (PlayerFort != null)
{
OnPlayerFortSpawned?.Invoke(PlayerFort);
}
// Spawn enemy fort
if (useDebugForts && debugEnemyFortPrefab != null)
{
EnemyFort = SpawnFort(debugEnemyFortPrefab, enemySpawnPoint, "Enemy Fort");
}
else
{
EnemyFort = SpawnRandomFort(enemySpawnPoint, "Enemy Fort");
}
if (EnemyFort != null)
{
OnEnemyFortSpawned?.Invoke(EnemyFort);
}
}
/// <summary>
/// Spawn a random fort from the premade prefabs array
/// </summary>
public FortController SpawnRandomFort(Transform spawnPoint, string overrideName = null)
{
if (premadeFortPrefabs == null || premadeFortPrefabs.Length == 0)
{
Logging.Error("[FortManager] Cannot spawn random fort - no prefabs available!");
return null;
}
GameObject randomPrefab = premadeFortPrefabs[UnityEngine.Random.Range(0, premadeFortPrefabs.Length)];
return SpawnFort(randomPrefab, spawnPoint, overrideName);
}
/// <summary>
/// Spawn a specific fort prefab
/// </summary>
public FortController SpawnFort(GameObject fortPrefab, Transform spawnPoint, string overrideName = null)
{
if (fortPrefab == null)
{
Logging.Error("[FortManager] Cannot spawn fort - prefab is null!");
return null;
}
if (spawnPoint == null)
{
Logging.Error("[FortManager] Cannot spawn fort - spawn point is null!");
return null;
}
// Instantiate fort
GameObject fortInstance = Instantiate(fortPrefab, spawnPoint.position, Quaternion.identity, spawnPoint);
fortInstance.name = overrideName ?? fortPrefab.name;
// Get FortController
FortController controller = fortInstance.GetComponent<FortController>();
if (controller == null)
{
Logging.Error($"[FortManager] Fort prefab {fortPrefab.name} is missing FortController component!");
Destroy(fortInstance);
return null;
}
// Fort will self-initialize in Start() and register with this manager
Logging.Debug($"[FortManager] Spawned fort: {controller.FortName} at {spawnPoint.name} (will self-initialize)");
return controller;
}
#endregion
#region Fort Registration
/// <summary>
/// Called by FortController when it finishes initialization.
/// Determines if player/enemy fort and fires appropriate events.
/// </summary>
public void RegisterFort(FortController fort)
{
if (fort == null)
{
Logging.Error("[FortManager] Cannot register null fort!");
return;
}
// Determine if this is player or enemy fort by checking which spawn point it's under
bool isPlayerFort = fort.transform.IsChildOf(playerSpawnPoint);
bool isEnemyFort = fort.transform.IsChildOf(enemySpawnPoint);
if (isPlayerFort)
{
PlayerFort = fort;
Logging.Debug($"[FortManager] Registered PLAYER fort: {fort.FortName}");
OnPlayerFortSpawned?.Invoke(fort);
}
else if (isEnemyFort)
{
EnemyFort = fort;
Logging.Debug($"[FortManager] Registered ENEMY fort: {fort.FortName}");
OnEnemyFortSpawned?.Invoke(fort);
}
else
{
Logging.Warning($"[FortManager] Fort {fort.FortName} is not under player or enemy spawn point! Cannot determine fort type.");
}
}
#endregion
#region Cleanup
/// <summary>
/// Destroy all spawned forts (for game restart)
/// </summary>
public void ClearForts()
{
Logging.Debug("[FortManager] Clearing all forts");
if (PlayerFort != null)
{
Destroy(PlayerFort.gameObject);
PlayerFort = null;
}
if (EnemyFort != null)
{
Destroy(EnemyFort.gameObject);
EnemyFort = null;
}
}
#endregion
#region Queries
/// <summary>
/// Get fort for a specific player
/// </summary>
public FortController GetFortForPlayer(PlayerData player)
{
if (player == null) return null;
return player.PlayerIndex == 0 ? PlayerFort : EnemyFort;
}
/// <summary>
/// Get opponent's fort
/// </summary>
public FortController GetOpponentFort(PlayerData player)
{
if (player == null) return null;
return player.PlayerIndex == 0 ? EnemyFort : PlayerFort;
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 47c585eb15414e8f802a6e31cbc6f501
timeCreated: 1764592116

View File

@@ -0,0 +1,166 @@
using AppleHills.Core.Settings;
using Core;
using Minigames.FortFight.Data;
using Minigames.FortFight.Projectiles;
using UnityEngine;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Turn action for projectile launching.
/// Enables slingshot, waits for player to launch, waits for projectile to complete.
/// </summary>
public class ProjectileTurnAction
{
private SlingshotController slingshot;
private AmmunitionManager ammoManager;
private CameraController cameraController;
private int playerIndex;
private ProjectileBase activeProjectile;
private bool launchComplete = false;
private bool projectileSettled = false;
private float settleTimer = 0f;
private IFortFightSettings _cachedSettings;
private IFortFightSettings CachedSettings
{
get
{
if (_cachedSettings == null)
{
_cachedSettings = GameManager.GetSettingsObject<IFortFightSettings>();
}
return _cachedSettings;
}
}
public bool IsComplete => projectileSettled;
public ProjectileTurnAction(SlingshotController slingshot, AmmunitionManager ammoManager, CameraController cameraController, int playerIndex)
{
this.slingshot = slingshot;
this.ammoManager = ammoManager;
this.cameraController = cameraController;
this.playerIndex = playerIndex;
}
/// <summary>
/// Execute the turn action - enable slingshot and wait for launch
/// </summary>
public void Execute()
{
Logging.Debug("[ProjectileTurnAction] Executing - enabling slingshot");
// Enable slingshot
if (slingshot != null)
{
slingshot.Enable();
// Subscribe to launch event
slingshot.OnProjectileLaunched += HandleProjectileLaunched;
}
}
/// <summary>
/// Update the action (check if projectile has settled)
/// </summary>
public void Update()
{
if (!launchComplete) return;
// Check if projectile is destroyed or stopped
if (activeProjectile == null)
{
// Projectile destroyed - start settle timer
if (settleTimer == 0f)
{
Logging.Debug("[ProjectileTurnAction] Projectile destroyed - starting settle timer");
}
settleTimer += Time.deltaTime;
float settleDelay = CachedSettings?.ProjectileSettleDelay ?? 2.5f;
if (settleTimer >= settleDelay)
{
projectileSettled = true;
// Stop camera tracking when projectile settles
if (cameraController != null)
{
cameraController.StopFollowingProjectile();
}
Logging.Debug("[ProjectileTurnAction] Turn action complete");
}
}
else
{
// Check if projectile has stopped moving
Rigidbody2D rb = activeProjectile.GetComponent<Rigidbody2D>();
if (rb != null && rb.linearVelocity.magnitude < 0.5f)
{
settleTimer += Time.deltaTime;
float settleDelay = CachedSettings?.ProjectileSettleDelay ?? 2.5f;
if (settleTimer >= settleDelay)
{
projectileSettled = true;
// Stop camera tracking when projectile settles
if (cameraController != null)
{
cameraController.StopFollowingProjectile();
}
Logging.Debug("[ProjectileTurnAction] Projectile settled - turn action complete");
}
}
else
{
// Reset settle timer if still moving
settleTimer = 0f;
}
}
}
/// <summary>
/// Cancel the action (disable slingshot)
/// </summary>
public void Cancel()
{
if (slingshot != null)
{
slingshot.Disable();
slingshot.OnProjectileLaunched -= HandleProjectileLaunched;
}
}
private void HandleProjectileLaunched(ProjectileBase projectile)
{
Logging.Debug($"[ProjectileTurnAction] Projectile launched: {projectile.gameObject.name}");
launchComplete = true;
activeProjectile = projectile;
// Disable slingshot after launch
if (slingshot != null)
{
slingshot.Disable();
}
// Trigger cooldown for used ammo for this player
if (ammoManager != null)
{
ProjectileType usedAmmoType = ammoManager.GetSelectedAmmoType(playerIndex);
ammoManager.UseAmmo(usedAmmoType, playerIndex);
}
// Start camera tracking the projectile
if (cameraController != null && projectile != null)
{
cameraController.StartFollowingProjectile(projectile.transform);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ac54e08365f94dbd91d2bace2b5964a6
timeCreated: 1764682659

View File

@@ -0,0 +1,328 @@
using System;
using AppleHills.Core.Settings;
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Data;
using Minigames.FortFight.Projectiles;
using UnityEngine;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Controls slingshot aiming and projectile launching.
/// Angry Birds-style drag-to-aim mechanic with trajectory preview.
/// Implements ITouchInputConsumer for InputManager integration.
/// </summary>
public class SlingshotController : ManagedBehaviour, ITouchInputConsumer
{
#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")]
[Tooltip("Trajectory preview component")]
[SerializeField] private TrajectoryPreview trajectoryPreview;
[Header("Debug")]
[SerializeField] private bool showDebugLogs = true;
#endregion
#region Settings
private IFortFightSettings cachedSettings;
private IFortFightSettings CachedSettings
{
get
{
if (cachedSettings == null)
{
cachedSettings = GameManager.GetSettingsObject<IFortFightSettings>();
}
return cachedSettings;
}
}
private float MaxForce => CachedSettings?.BaseLaunchForce ?? 20f;
#endregion
#region Events
/// <summary>
/// Fired when projectile is launched. Parameters: (ProjectileBase projectile)
/// </summary>
public event Action<ProjectileBase> OnProjectileLaunched;
#endregion
#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;
#endregion
#region Lifecycle
internal override void OnManagedAwake()
{
base.OnManagedAwake();
if (projectileSpawnPoint == null)
{
projectileSpawnPoint = transform;
}
if (trajectoryPreview == null)
{
trajectoryPreview = GetComponent<TrajectoryPreview>();
}
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Hide trajectory by default
if (trajectoryPreview != null)
{
trajectoryPreview.Hide();
}
}
#endregion
#region ITouchInputConsumer Implementation
public void OnTap(Vector2 worldPosition)
{
// Slingshot 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
private void StartDrag(Vector2 worldPosition)
{
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}");
}
private void UpdateDrag(Vector2 currentWorldPosition)
{
// 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)
{
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);
}
}
private void EndDrag(Vector2 currentWorldPosition)
{
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}");
}
}
#endregion
#region Projectile Management
/// <summary>
/// Set the current ammunition type
/// </summary>
public void SetAmmo(ProjectileConfig ammoConfig)
{
currentAmmo = ammoConfig;
if (showDebugLogs) Logging.Debug($"[SlingshotController] Ammo set to: {ammoConfig?.displayName ?? "null"}");
}
/// <summary>
/// Launch a projectile with calculated force and direction
/// </summary>
private void LaunchProjectile(Vector2 direction, float force)
{
if (currentAmmo == null || currentAmmo.prefab == null)
{
Logging.Error("[SlingshotController] Cannot launch - no ammo or prefab!");
return;
}
// Spawn projectile
GameObject projectileObj = Instantiate(currentAmmo.prefab, projectileSpawnPoint.position, Quaternion.identity);
activeProjectile = projectileObj.GetComponent<Projectiles.ProjectileBase>();
if (activeProjectile == null)
{
Logging.Error($"[SlingshotController] Projectile prefab {currentAmmo.prefab.name} missing ProjectileBase component!");
Destroy(projectileObj);
return;
}
// Initialize projectile with its type (loads damage and mass from settings)
activeProjectile.Initialize(currentAmmo.projectileType);
// Launch it
activeProjectile.Launch(direction, force);
// Lock trajectory to show the shot path
if (trajectoryPreview != null)
{
float lockDuration = CachedSettings?.TrajectoryLockDuration ?? 2f;
trajectoryPreview.LockTrajectory(lockDuration);
}
if (showDebugLogs) Logging.Debug($"[SlingshotController] Launched {currentAmmo?.displayName ?? "projectile"} with force {force}");
// Fire event
OnProjectileLaunched?.Invoke(activeProjectile);
}
/// <summary>
/// Get currently active projectile (in flight)
/// </summary>
public ProjectileBase GetActiveProjectile()
{
return activeProjectile;
}
#endregion
#region Enable/Disable
/// <summary>
/// Enable slingshot (allow aiming/launching)
/// </summary>
public void Enable()
{
IsEnabled = true;
if (showDebugLogs) Logging.Debug("[SlingshotController] Enabled");
}
/// <summary>
/// Disable slingshot (prevent aiming/launching)
/// </summary>
public void Disable()
{
IsEnabled = false;
isDragging = false;
if (trajectoryPreview != null)
{
trajectoryPreview.Hide();
}
if (showDebugLogs) Logging.Debug("[SlingshotController] Disabled");
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fc81b72132764f09a0ba180c90b432cf
timeCreated: 1764682598

View File

@@ -0,0 +1,153 @@
using Core;
using UnityEngine;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Displays trajectory prediction line for projectile launches.
/// Shows dotted line preview of projectile arc.
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class TrajectoryPreview : MonoBehaviour
{
[Header("Trajectory Settings")]
[Tooltip("Number of points to simulate (physics steps)")]
[SerializeField] private int simulationSteps = 50;
[Header("Visual")]
[Tooltip("Color of trajectory line")]
[SerializeField] private Color lineColor = Color.yellow;
[Tooltip("Width of trajectory line")]
[SerializeField] private float lineWidth = 0.1f;
private LineRenderer lineRenderer;
private bool isLocked = false;
private float lockTimer = 0f;
private float lockDuration = 0f;
private void Awake()
{
lineRenderer = GetComponent<LineRenderer>();
// Configure line renderer
if (lineRenderer != null)
{
lineRenderer.startWidth = lineWidth;
lineRenderer.endWidth = lineWidth;
lineRenderer.startColor = lineColor;
lineRenderer.endColor = lineColor;
lineRenderer.positionCount = simulationSteps;
lineRenderer.enabled = false;
}
}
private void Update()
{
if (isLocked)
{
lockTimer += Time.deltaTime;
if (lockTimer >= lockDuration)
{
isLocked = false;
Hide();
}
}
}
/// <summary>
/// Show the trajectory preview
/// </summary>
public void Show()
{
if (lineRenderer != null)
{
lineRenderer.enabled = true;
}
}
/// <summary>
/// Hide the trajectory preview (unless locked)
/// </summary>
public void Hide()
{
// Don't hide if trajectory is locked
if (isLocked)
return;
if (lineRenderer != null)
{
lineRenderer.enabled = false;
}
}
/// <summary>
/// Lock the current trajectory display for a duration
/// </summary>
public void LockTrajectory(float duration)
{
isLocked = true;
lockTimer = 0f;
lockDuration = duration;
// Ensure line is visible
if (lineRenderer != null)
{
lineRenderer.enabled = true;
}
}
/// <summary>
/// Update the trajectory preview with new parameters.
/// Uses Physics.fixedDeltaTime for accurate simulation matching Unity's physics.
/// </summary>
/// <param name="startPosition">Starting position of trajectory</param>
/// <param name="direction">Launch direction (normalized)</param>
/// <param name="force">Launch force (impulse)</param>
/// <param name="mass">Projectile mass</param>
public void UpdateTrajectory(Vector2 startPosition, Vector2 direction, float force, float mass = 1f)
{
if (lineRenderer == null) return;
// Calculate initial velocity: impulse force F gives velocity v = F/m
Vector2 startVelocity = (direction * force) / mass;
// Get gravity with projectile gravity scale from settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
float gravityScale = settings?.ProjectileGravityScale ?? 1f;
Vector2 gravity = new Vector2(Physics2D.gravity.x, Physics2D.gravity.y) * gravityScale;
// Simulate trajectory using Unity's physics time step
Vector3[] points = new Vector3[simulationSteps];
Vector2 pos = startPosition;
Vector2 vel = startVelocity;
for (int i = 0; i < simulationSteps; i++)
{
// Set current position
points[i] = new Vector3(pos.x, pos.y, 0);
// Update velocity (gravity applied over fixedDeltaTime)
vel = vel + gravity * Time.fixedDeltaTime;
// Update position (velocity applied over fixedDeltaTime)
pos = pos + vel * Time.fixedDeltaTime;
// Optional: Stop if hits ground (y < threshold)
if (pos.y < -10f)
{
// Fill remaining points at ground level
for (int j = i + 1; j < simulationSteps; j++)
{
points[j] = new Vector3(pos.x, -10f, 0);
}
break;
}
}
lineRenderer.positionCount = simulationSteps;
lineRenderer.SetPositions(points);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b1e26667c6d4415f8dc51e4a58ba9479
timeCreated: 1764682615

View File

@@ -0,0 +1,372 @@
using System;
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Data;
using UnityEngine;
namespace Minigames.FortFight.Core
{
/// <summary>
/// Manages turn order and turn state for Fort Fight minigame.
/// Handles transitions between Player One, Player Two/AI turns.
/// Manages turn actions and input delegation.
/// Singleton pattern for easy access to turn state.
/// </summary>
public class TurnManager : ManagedBehaviour
{
#region Singleton
private static TurnManager _instance;
public static TurnManager Instance
{
get
{
if (_instance == null)
{
_instance = FindFirstObjectByType<TurnManager>();
}
return _instance;
}
}
#endregion
#region Inspector References
[Header("Slingshot Controllers")]
[Tooltip("Slingshot for Player One")]
[SerializeField] private SlingshotController playerOneSlingshotController;
[Tooltip("Slingshot for Player Two")]
[SerializeField] private SlingshotController playerTwoSlingshotController;
[Header("Systems")]
[Tooltip("Camera controller for projectile tracking")]
[SerializeField] private CameraController cameraController;
// Note: AmmunitionManager accessed via singleton (AmmunitionManager.Instance)
#endregion
#region Events
/// <summary>
/// Fired when a new turn begins. Parameters: (PlayerData currentPlayer, TurnState turnState)
/// </summary>
public event Action<PlayerData, TurnState> OnTurnStarted;
/// <summary>
/// Fired when the current turn ends. Parameters: (PlayerData playerWhoFinished)
/// </summary>
public event Action<PlayerData> OnTurnEnded;
/// <summary>
/// Fired when transitioning between turns
/// </summary>
public event Action OnTurnTransitioning;
#endregion
#region State
private TurnState currentTurnState = TurnState.PlayerOneTurn;
private PlayerData playerOne;
private PlayerData playerTwo;
private PlayerData currentPlayer;
private int turnCount = 0;
// Turn action management
private ProjectileTurnAction currentTurnAction;
private bool isTransitioning = false;
private float transitionTimer = 0f;
public TurnState CurrentTurnState => currentTurnState;
public PlayerData CurrentPlayer => currentPlayer;
public int TurnCount => turnCount;
#endregion
#region Initialization
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Register singleton
if (_instance != null && _instance != this)
{
Logging.Warning("[TurnManager] Multiple instances detected! Destroying duplicate.");
Destroy(gameObject);
return;
}
_instance = this;
// Validate references
if (playerOneSlingshotController == null)
{
Logging.Error("[TurnManager] Player One slingshot controller not assigned!");
}
if (playerTwoSlingshotController == null)
{
Logging.Error("[TurnManager] Player Two slingshot controller not assigned!");
}
if (cameraController == null)
{
Logging.Warning("[TurnManager] Camera controller not assigned - projectiles won't be followed by camera!");
}
// Disable both slingshots initially
if (playerOneSlingshotController != null) playerOneSlingshotController.Disable();
if (playerTwoSlingshotController != null) playerTwoSlingshotController.Disable();
}
/// <summary>
/// Initialize the turn manager with player data
/// </summary>
public void Initialize(PlayerData pPlayerOne, PlayerData pPlayerTwo)
{
this.playerOne = pPlayerOne;
this.playerTwo = pPlayerTwo;
Logging.Debug($"[TurnManager] Initialized with P1: {pPlayerOne.PlayerName} (AI: {pPlayerOne.IsAI}), P2: {pPlayerTwo.PlayerName} (AI: {pPlayerTwo.IsAI})");
}
/// <summary>
/// Start the first turn
/// </summary>
public void StartGame()
{
turnCount = 0;
currentTurnState = TurnState.PlayerOneTurn;
currentPlayer = playerOne;
// Set initial input mode to UI
if (Input.InputManager.Instance != null)
{
Input.InputManager.Instance.SetInputMode(Input.InputMode.UI);
}
Logging.Debug($"[TurnManager] Game started. First turn: {currentPlayer.PlayerName}");
OnTurnStarted?.Invoke(currentPlayer, currentTurnState);
// Start turn action for first player
StartTurnAction();
}
#endregion
#region Lifecycle
private void Update()
{
// Update current turn action
if (currentTurnAction != null && !isTransitioning)
{
currentTurnAction.Update();
// Check if action is complete
if (currentTurnAction.IsComplete)
{
EndTurnAction();
}
}
// Handle transition timing
if (isTransitioning)
{
transitionTimer += Time.deltaTime;
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
float transitionDelay = settings?.TurnTransitionDelay ?? 1.5f;
if (transitionTimer >= transitionDelay)
{
CompleteTransition();
}
}
}
#endregion
#region Turn Management
/// <summary>
/// Start turn action for current player (projectile launch)
/// </summary>
private void StartTurnAction()
{
// Get the appropriate slingshot for current player
SlingshotController activeSlingshot = GetSlingshotForPlayer(currentPlayer);
if (activeSlingshot == null)
{
Logging.Error($"[TurnManager] No slingshot found for {currentPlayer.PlayerName}!");
return;
}
// Create and execute turn action with player index
currentTurnAction = new ProjectileTurnAction(activeSlingshot, AmmunitionManager.Instance, cameraController, currentPlayer.PlayerIndex);
// Set current ammo on slingshot for this player
if (AmmunitionManager.Instance != null)
{
ProjectileConfig currentAmmo = AmmunitionManager.Instance.GetSelectedAmmoConfig(currentPlayer.PlayerIndex);
if (currentAmmo != null)
{
activeSlingshot.SetAmmo(currentAmmo);
}
}
// Execute the action (enables slingshot)
currentTurnAction.Execute();
// Register slingshot as input consumer and switch to Game mode
if (Input.InputManager.Instance != null)
{
Input.InputManager.Instance.RegisterOverrideConsumer(activeSlingshot);
Input.InputManager.Instance.SetInputMode(Input.InputMode.Game);
}
Logging.Debug($"[TurnManager] Started turn action for {currentPlayer.PlayerName}");
}
/// <summary>
/// End current turn action (projectile has settled)
/// </summary>
private void EndTurnAction()
{
Logging.Debug($"[TurnManager] Ending turn action for {currentPlayer.PlayerName}");
// Get active slingshot and unregister from input
SlingshotController activeSlingshot = GetSlingshotForPlayer(currentPlayer);
if (activeSlingshot != null && Input.InputManager.Instance != null)
{
Input.InputManager.Instance.UnregisterOverrideConsumer(activeSlingshot);
}
// Restore UI input mode
if (Input.InputManager.Instance != null)
{
Input.InputManager.Instance.SetInputMode(Input.InputMode.UI);
}
// Clear turn action
currentTurnAction = null;
// End the turn
EndTurn();
}
/// <summary>
/// End the current turn and begin transition to next player
/// </summary>
private void EndTurn()
{
if (currentTurnState == TurnState.GameOver)
{
Logging.Warning("[TurnManager] Cannot end turn - game is over");
return;
}
Logging.Debug($"[TurnManager] Turn ended for {currentPlayer.PlayerName}");
OnTurnEnded?.Invoke(currentPlayer);
// Decrement ammunition cooldowns for this player
if (AmmunitionManager.Instance != null)
{
AmmunitionManager.Instance.DecrementCooldowns(currentPlayer.PlayerIndex);
}
// Enter transition state (triggers wide view camera via OnTurnStarted)
currentTurnState = TurnState.TransitioningTurn;
OnTurnTransitioning?.Invoke();
OnTurnStarted?.Invoke(currentPlayer, currentTurnState); // Fire for camera switch to wide view
// Start transition timer
isTransitioning = true;
transitionTimer = 0f;
}
/// <summary>
/// Complete transition and advance to next player
/// </summary>
private void CompleteTransition()
{
isTransitioning = false;
transitionTimer = 0f;
AdvanceToNextPlayer();
}
/// <summary>
/// Advance to the next player's turn (called after transition delay)
/// </summary>
private void AdvanceToNextPlayer()
{
turnCount++;
// Switch players
if (currentPlayer == playerOne)
{
currentPlayer = playerTwo;
currentTurnState = playerTwo.IsAI ? TurnState.AITurn : TurnState.PlayerTwoTurn;
}
else
{
currentPlayer = playerOne;
currentTurnState = TurnState.PlayerOneTurn;
}
Logging.Debug($"[TurnManager] Advanced to turn {turnCount}. Current player: {currentPlayer.PlayerName} (State: {currentTurnState})");
OnTurnStarted?.Invoke(currentPlayer, currentTurnState);
// Start turn action for next player
StartTurnAction();
}
/// <summary>
/// Get the slingshot controller for a specific player
/// </summary>
private SlingshotController GetSlingshotForPlayer(PlayerData player)
{
if (player == playerOne)
{
return playerOneSlingshotController;
}
else if (player == playerTwo)
{
return playerTwoSlingshotController;
}
return null;
}
/// <summary>
/// Force game over state
/// </summary>
public void SetGameOver()
{
currentTurnState = TurnState.GameOver;
Logging.Debug("[TurnManager] Game over state set");
}
#endregion
#region Cleanup
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Clear all event listeners
OnTurnStarted = null;
OnTurnEnded = null;
OnTurnTransitioning = null;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c0b22cad084c0ba44b522474269c2c4b

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 08a9c8eba88cf1148bcf11e305a91051
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,55 @@
namespace Minigames.FortFight.Data
{
/// <summary>
/// Game mode for Fort Fight minigame
/// </summary>
public enum FortFightGameMode
{
SinglePlayer, // Player vs AI
TwoPlayer // Player vs Player
}
/// <summary>
/// Current turn state in the game
/// </summary>
public enum TurnState
{
PlayerOneTurn,
PlayerTwoTurn,
AITurn,
TransitioningTurn, // Transitioning between turns (projectile in flight, waiting for settle)
GameOver
}
/// <summary>
/// Material types for fort blocks
/// </summary>
public enum BlockMaterial
{
Cardboard,
Metal,
Glass
}
/// <summary>
/// Size categories for fort blocks
/// </summary>
public enum BlockSize
{
Small,
Medium,
Large
}
/// <summary>
/// Types of projectiles available
/// </summary>
public enum ProjectileType
{
Toaster, // Standard physics projectile
Vacuum, // Heavy, rolls on floor
CeilingFan, // Drops straight down
TrashBag // Explodes on impact
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9891698193c757344bc2f3f26730248a

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
namespace Minigames.FortFight.Data
{
/// <summary>
/// Encapsulates all ammunition state for a single player.
/// Tracks cooldowns, selection, and usage history per player.
/// </summary>
[Serializable]
public class PlayerAmmoState
{
#region Properties
public int PlayerIndex { get; private set; }
public ProjectileType SelectedAmmo { get; set; }
#endregion
#region State
// Cooldowns per projectile type (turns remaining)
private Dictionary<ProjectileType, int> cooldowns;
// Optional: Track usage for statistics/analytics
private Dictionary<ProjectileType, int> usageCount;
private ProjectileType lastUsedProjectile;
#endregion
#region Constructor
public PlayerAmmoState(int playerIndex, ProjectileType defaultAmmo)
{
PlayerIndex = playerIndex;
SelectedAmmo = defaultAmmo;
cooldowns = new Dictionary<ProjectileType, int>();
usageCount = new Dictionary<ProjectileType, int>();
lastUsedProjectile = defaultAmmo;
}
#endregion
#region Cooldown Management
/// <summary>
/// Initialize cooldown for a specific projectile type.
/// </summary>
public void InitializeCooldown(ProjectileType type)
{
if (!cooldowns.ContainsKey(type))
{
cooldowns[type] = 0;
}
}
/// <summary>
/// Set cooldown for a specific projectile type.
/// </summary>
public void SetCooldown(ProjectileType type, int turns)
{
cooldowns[type] = turns;
}
/// <summary>
/// Get remaining cooldown turns for a projectile type.
/// </summary>
public int GetCooldown(ProjectileType type)
{
return cooldowns.ContainsKey(type) ? cooldowns[type] : 0;
}
/// <summary>
/// Check if projectile type is available (not on cooldown).
/// </summary>
public bool IsAmmoAvailable(ProjectileType type)
{
return GetCooldown(type) == 0;
}
/// <summary>
/// Decrement all cooldowns by 1 turn.
/// Returns list of projectile types that completed cooldown this turn.
/// </summary>
public List<ProjectileType> DecrementCooldowns()
{
List<ProjectileType> completedCooldowns = new List<ProjectileType>();
List<ProjectileType> types = new List<ProjectileType>(cooldowns.Keys);
foreach (var type in types)
{
if (cooldowns[type] > 0)
{
cooldowns[type]--;
if (cooldowns[type] == 0)
{
completedCooldowns.Add(type);
}
}
}
return completedCooldowns;
}
#endregion
#region Usage Tracking
/// <summary>
/// Record that a projectile type was used.
/// </summary>
public void RecordUsage(ProjectileType type)
{
lastUsedProjectile = type;
if (!usageCount.ContainsKey(type))
{
usageCount[type] = 0;
}
usageCount[type]++;
}
/// <summary>
/// Get usage count for a projectile type.
/// </summary>
public int GetUsageCount(ProjectileType type)
{
return usageCount.ContainsKey(type) ? usageCount[type] : 0;
}
/// <summary>
/// Get the last projectile type used by this player.
/// </summary>
public ProjectileType LastUsedProjectile => lastUsedProjectile;
/// <summary>
/// Get total number of projectiles used by this player.
/// </summary>
public int TotalUsageCount
{
get
{
int total = 0;
foreach (var count in usageCount.Values)
{
total += count;
}
return total;
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fd7545bfc92d4096b53954bab9884b15
timeCreated: 1764797211

View File

@@ -0,0 +1,23 @@
using System;
namespace Minigames.FortFight.Data
{
/// <summary>
/// Represents a player in the Fort Fight minigame
/// </summary>
[Serializable]
public class PlayerData
{
public string PlayerName;
public bool IsAI;
public int PlayerIndex; // 0 for Player One, 1 for Player Two/AI
public PlayerData(string name, bool isAI, int playerIndex)
{
PlayerName = name;
IsAI = isAI;
PlayerIndex = playerIndex;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f310a90c43a9b3049b875c84f2d9043a

View File

@@ -0,0 +1,94 @@
using System;
using UnityEngine;
namespace Minigames.FortFight.Data
{
/// <summary>
/// Configuration data for a projectile type.
/// Stored centrally in FortFightSettings instead of individual ScriptableObject assets.
/// </summary>
[Serializable]
public class ProjectileConfig
{
[Header("Identity")]
[Tooltip("Type of projectile this config represents")]
public ProjectileType projectileType;
[Tooltip("Unique string identifier (auto-generated from type)")]
public string projectileId;
[Header("Prefab")]
[Tooltip("Prefab for this projectile (should have ProjectileBase component)")]
public GameObject prefab;
[Header("Ammunition System")]
[Tooltip("Cooldown in turns after use")]
public int cooldownTurns = 2;
[Header("UI")]
[Tooltip("Icon sprite for ammunition UI")]
public Sprite icon;
[Tooltip("Display name for this projectile type")]
public string displayName;
[Tooltip("Description of projectile behavior")]
[TextArea(2, 4)]
public string description;
[Header("Combat Stats")]
[Tooltip("Damage dealt on impact")]
public float damage = 20f;
[Header("Physics")]
[Tooltip("Mass for physics simulation (affects trajectory and force)")]
public float mass = 1f;
/// <summary>
/// Get the ProjectileBase component from the prefab
/// </summary>
public Projectiles.ProjectileBase GetProjectileComponent()
{
if (prefab == null) return null;
return prefab.GetComponent<Projectiles.ProjectileBase>();
}
/// <summary>
/// Get damage value from config
/// </summary>
public float GetDamage()
{
return damage;
}
/// <summary>
/// Get mass value from config
/// </summary>
public float GetMass()
{
return mass;
}
/// <summary>
/// Validate and auto-generate projectileId from type
/// </summary>
public void Validate()
{
if (string.IsNullOrEmpty(projectileId))
{
projectileId = GenerateIdFromType(projectileType);
}
if (string.IsNullOrEmpty(displayName))
{
displayName = projectileType.ToString();
}
}
private string GenerateIdFromType(ProjectileType type)
{
return type.ToString().ToLower();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9d60235e77c7456380c10f9c145750bf
timeCreated: 1764778577

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a5434add8e2e43cabd0ce4283636ca83
timeCreated: 1764591745

View File

@@ -0,0 +1,414 @@
using System;
using Core;
using Core.Lifecycle;
using UnityEngine;
using Minigames.FortFight.Data;
namespace Minigames.FortFight.Fort
{
/// <summary>
/// Individual fort block with HP, material properties, and physics.
/// Component attached to each block GameObject in a fort prefab.
/// </summary>
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D))]
public class FortBlock : ManagedBehaviour
{
#region Inspector Properties
[Header("Block Configuration")]
[SerializeField] private BlockMaterial material = BlockMaterial.Cardboard;
[SerializeField] private BlockSize size = BlockSize.Medium;
[SerializeField] private bool isWeakPoint = false;
[Tooltip("Fixed HP value for this block (default: 10)")]
[SerializeField] private float blockHp = 10f;
[Header("Weak Point Settings (if applicable)")]
[Tooltip("Visual indicator shown in editor/game for weak points")]
[SerializeField] private GameObject weakPointVisualIndicator;
[Tooltip("Visual explosion effect prefab")]
[SerializeField] private GameObject explosionEffectPrefab;
[Header("Visual Feedback")]
[SerializeField] private SpriteRenderer spriteRenderer;
#endregion
#region Events
/// <summary>
/// Fired when this block is destroyed. Parameters: (FortBlock block, float damageTaken)
/// </summary>
public event Action<FortBlock, float> OnBlockDestroyed;
/// <summary>
/// Fired when this block takes damage. Parameters: (float currentHP, float maxHP)
/// </summary>
public event Action<float, float> OnBlockDamaged;
#endregion
#region Properties
public BlockMaterial Material => material;
public BlockSize Size => size;
public bool IsWeakPoint => isWeakPoint;
public float CurrentHp => currentHp;
public float MaxHp => maxHp;
public float HpPercentage => maxHp > 0 ? (currentHp / maxHp) * 100f : 0f;
#endregion
#region Private State
private float maxHp;
private float currentHp;
private FortController parentFort;
private Rigidbody2D rb2D;
private Collider2D blockCollider;
private bool isDestroyed = false;
// Cached settings
private AppleHills.Core.Settings.IFortFightSettings _cachedSettings;
private AppleHills.Core.Settings.IFortFightSettings CachedSettings
{
get
{
if (_cachedSettings == null)
{
_cachedSettings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
}
return _cachedSettings;
}
}
#endregion
#region Lifecycle
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe events
OnBlockDestroyed = null;
OnBlockDamaged = null;
}
#endregion
#region Initialization
/// <summary>
/// Initialize this block. Called explicitly by parent FortController.
/// DO NOT call from Awake/Start - parent controls initialization timing.
/// </summary>
public void Initialize()
{
// Automatically assign block to correct layer from settings
var settings = CachedSettings;
if (settings != null && settings.FortBlockLayer >= 0 && gameObject.layer != settings.FortBlockLayer)
{
gameObject.layer = settings.FortBlockLayer;
Logging.Debug($"[FortBlock] Assigned {gameObject.name} to layer {LayerMask.LayerToName(settings.FortBlockLayer)}");
}
// Cache components
rb2D = GetComponent<Rigidbody2D>();
blockCollider = GetComponent<Collider2D>();
if (spriteRenderer == null)
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
if (isDestroyed)
{
Logging.Warning($"[FortBlock] Cannot initialize destroyed block {gameObject.name}");
return;
}
// Calculate HP based on material and size
CalculateHp();
// Configure physics properties
ConfigurePhysics();
// Show/hide weak point indicator
if (weakPointVisualIndicator != null)
{
weakPointVisualIndicator.SetActive(isWeakPoint);
}
Logging.Debug($"[FortBlock] {gameObject.name} initialized: {material} {size}, HP: {maxHp}");
}
#endregion
#region HP Calculation
private void CalculateHp()
{
// Use fixed block HP value (default 10)
maxHp = blockHp;
currentHp = maxHp;
Logging.Debug($"[FortBlock] {gameObject.name} initialized: {material} {size}, HP: {maxHp}");
}
#endregion
#region Physics Configuration
private void ConfigurePhysics()
{
if (rb2D == null) return;
// Get material config
var materialConfig = CachedSettings.GetMaterialConfig(material);
float baseMass = materialConfig?.baseMass ?? 1f;
// Get size config
var sizeConfig = CachedSettings.GetSizeConfig(size);
float sizeMultiplier = sizeConfig?.massMultiplier ?? 1f;
rb2D.mass = baseMass * sizeMultiplier;
rb2D.gravityScale = CachedSettings.PhysicsGravityScale;
rb2D.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
}
#endregion
#region Damage System
/// <summary>
/// Apply damage to this block
/// </summary>
public void TakeDamage(float damage)
{
if (isDestroyed) return;
currentHp -= damage;
currentHp = Mathf.Max(0f, currentHp);
Logging.Debug($"[FortBlock] {gameObject.name} took {damage} damage. HP: {currentHp}/{maxHp} ({HpPercentage:F1}%)");
OnBlockDamaged?.Invoke(currentHp, maxHp);
// Visual feedback
UpdateVisualDamage();
// Check if destroyed
if (currentHp <= 0f)
{
DestroyBlock();
}
}
private void UpdateVisualDamage()
{
if (spriteRenderer == null) return;
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
Color targetColor = settings?.DamageColorTint ?? new Color(0.5f, 0.5f, 0.5f);
// Darken sprite based on damage
float damagePercent = 1f - (currentHp / maxHp);
Color damageColor = Color.Lerp(Color.white, targetColor, damagePercent);
spriteRenderer.color = damageColor;
}
#endregion
#region Destruction
private void DestroyBlock()
{
if (isDestroyed) return;
isDestroyed = true;
Logging.Debug($"[FortBlock] {gameObject.name} destroyed! Weak point: {isWeakPoint}");
// Trigger explosion if weak point
if (isWeakPoint)
{
TriggerWeakPointExplosion();
}
// Notify listeners
OnBlockDestroyed?.Invoke(this, maxHp);
// Spawn destruction effects (placeholder)
SpawnDestructionEffect();
// Destroy GameObject
Destroy(gameObject);
}
private void TriggerWeakPointExplosion()
{
float explosionRadius = CachedSettings.WeakPointExplosionRadius;
float explosionDamage = CachedSettings.WeakPointExplosionDamage;
float explosionForce = CachedSettings.WeakPointExplosionForce;
Logging.Debug($"[FortBlock] ========================================");
Logging.Debug($"[FortBlock] 💥 WEAK POINT EXPLOSION TRIGGERED!");
Logging.Debug($"[FortBlock] Position: {transform.position}");
Logging.Debug($"[FortBlock] Explosion Radius: {explosionRadius}");
Logging.Debug($"[FortBlock] Explosion Damage: {explosionDamage}");
Logging.Debug($"[FortBlock] Explosion Force: {explosionForce}");
Logging.Debug($"[FortBlock] ========================================");
// Spawn explosion effect
if (explosionEffectPrefab != null)
{
Logging.Debug($"[FortBlock] Spawning explosion effect prefab");
GameObject explosion = Instantiate(explosionEffectPrefab, transform.position, Quaternion.identity);
// Dynamically determine cleanup time from particle system
float lifetime = GetEffectLifetime(explosion);
Destroy(explosion, lifetime);
}
else
{
Logging.Debug($"[FortBlock] No explosion effect prefab (visual only)");
}
// Find nearby blocks and damage them
Collider2D[] nearbyColliders = Physics2D.OverlapCircleAll(transform.position, explosionRadius);
Logging.Debug($"[FortBlock] Physics2D.OverlapCircleAll found {nearbyColliders.Length} colliders");
if (nearbyColliders.Length <= 1)
{
Logging.Warning($"[FortBlock] ⚠️ Only found {nearbyColliders.Length} colliders! Other blocks need BoxCollider2D with 'Is Trigger' UNCHECKED");
}
int blocksHit = 0;
foreach (Collider2D col in nearbyColliders)
{
if (col.gameObject == gameObject)
{
Logging.Debug($"[FortBlock] Skipping self");
continue; // Skip self
}
Logging.Debug($"[FortBlock] Checking collider: {col.gameObject.name}");
FortBlock nearbyBlock = col.GetComponent<FortBlock>();
if (nearbyBlock != null && !nearbyBlock.isDestroyed)
{
Vector2 explosionCenter = transform.position;
float distance = Vector2.Distance(explosionCenter, nearbyBlock.transform.position);
// Calculate damage with falloff
float damageFalloff = 1f - (distance / explosionRadius);
float actualDamage = explosionDamage * damageFalloff;
// Apply damage
nearbyBlock.TakeDamage(actualDamage);
// Apply explosion force (2D equivalent of AddExplosionForce)
Rigidbody2D nearbyRb = nearbyBlock.GetComponent<Rigidbody2D>();
if (nearbyRb != null)
{
ApplyExplosionForce2D(nearbyRb, explosionForce, explosionCenter, explosionRadius);
Logging.Debug($"[FortBlock] ✓ HIT: {nearbyBlock.gameObject.name} - Damage: {actualDamage:F1}, Force applied from center");
}
else
{
Logging.Debug($"[FortBlock] ✓ HIT: {nearbyBlock.gameObject.name} - Damage: {actualDamage:F1} (no Rigidbody2D)");
}
blocksHit++;
}
else if (nearbyBlock == null)
{
Logging.Debug($"[FortBlock] × MISS: {col.gameObject.name} has no FortBlock component");
}
else
{
Logging.Debug($"[FortBlock] × SKIP: {col.gameObject.name} already destroyed");
}
}
Logging.Debug($"[FortBlock] Explosion complete. Damaged {blocksHit} blocks");
Logging.Debug($"[FortBlock] ========================================");
// TODO: Add screen shake effect
// TODO: Play explosion sound via AudioManager
}
/// <summary>
/// Apply explosion force to a Rigidbody2D (2D equivalent of Rigidbody.AddExplosionForce).
/// Force decreases with distance from explosion center.
/// </summary>
private void ApplyExplosionForce2D(Rigidbody2D rb, float force, Vector2 center, float radius)
{
Vector2 direction = (rb.position - center);
float distance = direction.magnitude;
if (distance == 0f) return; // Avoid division by zero
// Normalize direction
direction /= distance;
// Calculate force with linear falloff (like Unity's 3D AddExplosionForce)
float forceMagnitude = force * (1f - (distance / radius));
// Apply force as impulse
rb.AddForce(direction * forceMagnitude, ForceMode2D.Impulse);
}
private void SpawnDestructionEffect()
{
// Placeholder for destruction particles
// TODO: Create material-specific destruction effects
Logging.Debug($"[FortBlock] Spawning destruction effect for {material} block");
}
/// <summary>
/// Get the lifetime of an effect by reading particle system StartLifetime.
/// Falls back to 3 seconds if no particle system found.
/// </summary>
private float GetEffectLifetime(GameObject effect)
{
// Try to read from ParticleSystem
ParticleSystem ps = effect.GetComponent<ParticleSystem>();
if (ps != null)
{
return ps.main.startLifetime.constantMax + 0.5f; // Add small buffer
}
// Try to read from child particle systems
ParticleSystem childPs = effect.GetComponentInChildren<ParticleSystem>();
if (childPs != null)
{
return childPs.main.startLifetime.constantMax + 0.5f;
}
// Fallback for non-particle effects
return 3f;
}
#endregion
#region Debug Helpers
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (isWeakPoint)
{
// Draw explosion radius in editor using settings
float radius = AppleHills.SettingsAccess.GetWeakPointExplosionRadius();
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, radius);
}
}
#endif
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ace8ce8bea324389a9955e63081ccff7
timeCreated: 1764591745

View File

@@ -0,0 +1,379 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Core;
using Core.Lifecycle;
using UnityEngine;
using AppleHills.Core.Settings;
namespace Minigames.FortFight.Fort
{
/// <summary>
/// Root component of fort prefabs. Manages collection of child FortBlocks and tracks total HP.
/// </summary>
public class FortController : ManagedBehaviour
{
#region Inspector Properties
[Header("Fort Configuration")]
[SerializeField] private string fortName = "Unnamed Fort";
[Header("Debug")]
[SerializeField] private bool showDebugInfo = true;
#endregion
#region Events
/// <summary>
/// Fired when fort takes damage. Parameters: (float damage, float hpPercentage)
/// </summary>
public event Action<float, float> OnFortDamaged;
/// <summary>
/// Fired when fort is defeated (HP < 30%)
/// </summary>
public event Action OnFortDefeated;
/// <summary>
/// Fired when a block is destroyed. Parameters: (FortBlock block)
/// </summary>
public event Action<FortBlock> OnBlockDestroyed;
#endregion
#region Properties
public string FortName => fortName;
public float MaxFortHp => maxFortHp;
public float CurrentFortHp => currentFortHp;
public float HpPercentage => maxFortHp > 0 ? (currentFortHp / maxFortHp) * 100f : 0f;
public int TotalBlockCount => blocks.Count;
public int InitialBlockCount => initialBlockCount;
public bool IsDefeated { get; private set; }
// Aliases for consistency
public float MaxHp => maxFortHp;
public float CurrentHp => currentFortHp;
#endregion
#region Private State
private List<FortBlock> blocks = new List<FortBlock>();
private float maxFortHp = 0f;
private float currentFortHp = 0f;
private int initialBlockCount = 0;
private bool isInitialized = false;
// Cached settings
private IFortFightSettings _cachedSettings;
private IFortFightSettings CachedSettings
{
get
{
if (_cachedSettings == null)
{
_cachedSettings = GameManager.GetSettingsObject<IFortFightSettings>();
}
return _cachedSettings;
}
}
#endregion
#region Lifecycle
internal override void OnManagedStart()
{
base.OnManagedStart();
// Self-initialize: discover blocks, register with manager
InitializeFort();
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe from all block events
foreach (FortBlock block in blocks)
{
if (block != null)
{
block.OnBlockDestroyed -= HandleBlockDestroyed;
block.OnBlockDamaged -= HandleBlockDamaged;
}
}
blocks.Clear();
// Clear events
OnFortDamaged = null;
OnFortDefeated = null;
OnBlockDestroyed = null;
}
#endregion
#region Initialization
/// <summary>
/// Initialize fort: discover child blocks, calculate HP, register with manager.
/// Called automatically in Start() - no external calls needed.
/// </summary>
private void InitializeFort()
{
if (isInitialized)
{
Logging.Warning($"[FortController] {fortName} already initialized!");
return;
}
Logging.Debug($"[FortController] {fortName} - Starting self-initialization");
// Step 1: Discover and register child blocks
DiscoverAndRegisterBlocks();
// Step 2: Register with central manager
RegisterWithManager();
isInitialized = true;
Logging.Debug($"[FortController] {fortName} - Initialization complete");
}
/// <summary>
/// Discover, initialize, and register all child blocks.
/// This ensures deterministic initialization order.
/// </summary>
private void DiscoverAndRegisterBlocks()
{
FortBlock[] childBlocks = GetComponentsInChildren<FortBlock>();
if (childBlocks.Length == 0)
{
Logging.Error($"[FortController] {fortName} has no blocks!");
return;
}
if (childBlocks.Length > 10)
{
Logging.Warning($"[FortController] {fortName} has {childBlocks.Length} blocks (max 10 recommended)");
}
Logging.Debug($"[FortController] {fortName} - Discovered {childBlocks.Length} blocks, initializing...");
// Step 1: Initialize each block (calculate HP, configure physics)
foreach (FortBlock block in childBlocks)
{
block.Initialize();
}
// Step 2: Register each block (subscribe to events, track HP)
foreach (FortBlock block in childBlocks)
{
RegisterBlock(block);
}
// Step 3: Initialize current HP to match max HP (sum of all blocks)
currentFortHp = maxFortHp;
initialBlockCount = blocks.Count;
Logging.Debug($"[FortController] {fortName} - Initialized and registered {blocks.Count} blocks, Total HP: {maxFortHp:F0}");
}
/// <summary>
/// Register this fort with the central FortManager.
/// Manager determines if player/enemy and handles UI binding.
/// </summary>
private void RegisterWithManager()
{
Core.FortManager manager = Core.FortManager.Instance;
if (manager == null)
{
Logging.Error($"[FortController] {fortName} - FortManager not found! Cannot complete initialization.");
return;
}
manager.RegisterFort(this);
Logging.Debug($"[FortController] {fortName} - Registered with FortManager");
}
#endregion
#region Block Management
/// <summary>
/// Register a block with this fort controller. Called by FortBlock on start.
/// </summary>
public void RegisterBlock(FortBlock block)
{
if (block == null) return;
if (!blocks.Contains(block))
{
blocks.Add(block);
// Only add to max HP, current HP will be calculated once at end of initialization
maxFortHp += block.MaxHp;
// Subscribe to block events
block.OnBlockDestroyed += HandleBlockDestroyed;
block.OnBlockDamaged += HandleBlockDamaged;
if (showDebugInfo)
{
Logging.Debug($"[FortController] Registered block: {block.gameObject.name} ({block.Material} {block.Size}, HP: {block.MaxHp})");
}
}
}
/// <summary>
/// Get all blocks marked as weak points
/// </summary>
public List<FortBlock> GetWeakPoints()
{
return blocks.Where(b => b != null && b.IsWeakPoint).ToList();
}
/// <summary>
/// Get all remaining blocks
/// </summary>
public List<FortBlock> GetAllBlocks()
{
return new List<FortBlock>(blocks);
}
/// <summary>
/// Get a random block (for AI targeting)
/// </summary>
public FortBlock GetRandomBlock()
{
if (blocks.Count == 0) return null;
return blocks[UnityEngine.Random.Range(0, blocks.Count)];
}
#endregion
#region Event Handlers
private void HandleBlockDestroyed(FortBlock block, float blockMaxHp)
{
if (block == null) return;
Logging.Debug($"[FortController] {fortName} - Block destroyed: {block.gameObject.name}");
// Remove from list
blocks.Remove(block);
// Recalculate HP by summing all remaining blocks (consistent calculation method)
RecalculateFortHp();
// Notify listeners
OnBlockDestroyed?.Invoke(block);
OnFortDamaged?.Invoke(blockMaxHp, HpPercentage);
// Check defeat condition
CheckDefeatCondition();
if (showDebugInfo)
{
Logging.Debug($"[FortController] {fortName} - HP: {currentFortHp:F0}/{maxFortHp:F0} ({HpPercentage:F1}%), Blocks: {blocks.Count}/{initialBlockCount}");
}
}
private void HandleBlockDamaged(float currentBlockHp, float maxBlockHp)
{
// Block damaged but not destroyed
Logging.Debug($"[FortController] {fortName} - Block damaged! CurrentBlockHP: {currentBlockHp}/{maxBlockHp}");
// Recalculate current fort HP based on all block HP
RecalculateFortHp();
// Notify UI to update
int listenerCount = OnFortDamaged?.GetInvocationList()?.Length ?? 0;
Logging.Debug($"[FortController] {fortName} - Firing OnFortDamaged event. HP: {HpPercentage:F1}%. Listeners: {listenerCount}");
OnFortDamaged?.Invoke(0f, HpPercentage);
// Check defeat condition after damage
CheckDefeatCondition();
}
/// <summary>
/// Recalculate total fort HP by summing all block HP
/// </summary>
private void RecalculateFortHp()
{
currentFortHp = 0f;
foreach (var block in blocks)
{
if (block != null)
{
currentFortHp += block.CurrentHp;
}
}
if (showDebugInfo)
{
Logging.Debug($"[FortController] {fortName} - HP recalculated: {currentFortHp:F0}/{maxFortHp:F0} ({HpPercentage:F1}%)");
}
}
#endregion
#region Defeat Condition
private void CheckDefeatCondition()
{
if (IsDefeated)
{
Logging.Debug($"[FortController] {fortName} - Already defeated, skipping check");
return;
}
float defeatThreshold = CachedSettings?.FortDefeatThreshold ?? 0.3f;
float defeatThresholdPercent = defeatThreshold * 100f;
Logging.Debug($"[FortController] {fortName} - Checking defeat: HP={currentFortHp:F1}/{maxFortHp:F1} ({HpPercentage:F1}%) vs threshold={defeatThresholdPercent:F1}%");
// Defeat if HP at or below threshold
if (HpPercentage <= defeatThresholdPercent)
{
IsDefeated = true;
int listeners = OnFortDefeated?.GetInvocationList()?.Length ?? 0;
Logging.Debug($"[FortController] {fortName} DEFEATED! Final HP: {HpPercentage:F1}% (threshold: {defeatThresholdPercent:F1}%). Firing event to {listeners} listeners...");
OnFortDefeated?.Invoke();
Logging.Debug($"[FortController] {fortName} - OnFortDefeated event fired");
}
else
{
Logging.Debug($"[FortController] {fortName} - Not defeated yet ({HpPercentage:F1}% >= {defeatThresholdPercent:F1}%)");
}
}
#endregion
#region Debug Helpers
private void OnGUI()
{
if (!showDebugInfo || !Application.isPlaying) return;
// Display fort HP in scene view (for testing)
Vector3 screenPos = Camera.main.WorldToScreenPoint(transform.position + Vector3.up * 2f);
if (screenPos.z > 0)
{
GUI.color = IsDefeated ? Color.red : Color.white;
GUI.Label(new Rect(screenPos.x - 50, Screen.height - screenPos.y, 100, 30),
$"{fortName}\nHP: {HpPercentage:F0}%");
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 05031222348c421ab564757f52f24952
timeCreated: 1764591745

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2f2ab5d80875486aa447f9c4afcbdec1
timeCreated: 1764682302

View File

@@ -0,0 +1,183 @@
using System.Collections;
using Core;
using UnityEngine;
namespace Minigames.FortFight.Projectiles
{
/// <summary>
/// Ceiling Fan projectile - drops straight down when player taps screen.
/// Implements ITouchInputConsumer to capture tap input mid-flight.
/// </summary>
public class CeilingFanProjectile : ProjectileBase, ITouchInputConsumer
{
[Header("Ceiling Fan Specific")]
[Tooltip("Visual indicator showing drop is available (arrow down)")]
[SerializeField] private GameObject indicator;
private bool isDropping = false;
private bool inputEnabled = false;
public override void Launch(Vector2 direction, float force)
{
base.Launch(direction, force);
// Hide indicator initially
if (indicator != null)
{
indicator.SetActive(false);
}
// Start activation delay coroutine
StartCoroutine(ActivationDelayCoroutine());
}
private IEnumerator ActivationDelayCoroutine()
{
// Get activation delay from settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
float activationDelay = settings?.CeilingFanActivationDelay ?? 0.5f;
// Wait for delay
yield return new WaitForSeconds(activationDelay);
// Enable input and show indicator (if not already dropped)
if (!isDropping && !AbilityActivated)
{
inputEnabled = true;
if (indicator != null)
{
indicator.SetActive(true);
}
// Register with InputManager to capture tap-to-drop
if (Input.InputManager.Instance != null)
{
Input.InputManager.Instance.RegisterOverrideConsumer(this);
Logging.Debug("[CeilingFanProjectile] Tap-to-drop now available");
}
}
}
public override void ActivateAbility()
{
base.ActivateAbility();
if (AbilityActivated)
{
Logging.Debug("[CeilingFanProjectile] Ability activated - dropping straight down");
StartCoroutine(DropCoroutine());
}
}
private IEnumerator DropCoroutine()
{
isDropping = true;
// Stop all velocity
if (rb2D != null)
{
rb2D.linearVelocity = Vector2.zero;
rb2D.angularVelocity = 0f;
}
// Get drop configuration from settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
float dropDelay = settings?.CeilingFanDropDelay ?? 0.2f;
float dropSpeed = settings?.CeilingFanDropSpeed ?? 20f;
// Wait brief moment
yield return new WaitForSeconds(dropDelay);
// Drop straight down
if (rb2D != null)
{
rb2D.linearVelocity = Vector2.down * dropSpeed;
Logging.Debug($"[CeilingFanProjectile] Dropping with velocity: {rb2D.linearVelocity}");
}
}
protected override void OnHit(Collision2D collision)
{
// Spawn impact effect only if dropped (not on normal arc hit)
if (isDropping)
{
SpawnImpactEffect(collision.contacts[0].point);
}
// Deal damage to blocks
var block = collision.gameObject.GetComponent<Fort.FortBlock>();
if (block != null)
{
block.TakeDamage(Damage);
Logging.Debug($"[CeilingFanProjectile] Dealt {Damage} damage to {block.gameObject.name}");
}
// Destroy projectile
DestroyProjectile();
}
#region ITouchInputConsumer Implementation
public void OnTap(Vector2 worldPosition)
{
// Only respond if input is enabled
if (inputEnabled && !AbilityActivated && !isDropping)
{
Logging.Debug("[CeilingFanProjectile] Tap detected - activating drop");
// Hide indicator
if (indicator != null)
{
indicator.SetActive(false);
}
ActivateAbility();
// Unregister immediately after tap
UnregisterFromInput();
}
}
public void OnHoldStart(Vector2 worldPosition)
{
// Not used for ceiling fan
}
public void OnHoldMove(Vector2 worldPosition)
{
// Not used for ceiling fan
}
public void OnHoldEnd(Vector2 worldPosition)
{
// Not used for ceiling fan
}
#endregion
private void UnregisterFromInput()
{
inputEnabled = false;
if (indicator != null)
{
indicator.SetActive(false);
}
if (Input.InputManager.Instance != null)
{
Input.InputManager.Instance.UnregisterOverrideConsumer(this);
Logging.Debug("[CeilingFanProjectile] Unregistered from input");
}
}
protected override void DestroyProjectile()
{
// Make sure we unregister when destroyed
UnregisterFromInput();
base.DestroyProjectile();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e10ba9bd4bcd40da87ecb3efe5b78467
timeCreated: 1764682337

View File

@@ -0,0 +1,381 @@
using System;
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Fort;
using UnityEngine;
namespace Minigames.FortFight.Projectiles
{
/// <summary>
/// Base class for all projectile types in Fort Fight.
/// Handles physics, collision, and basic damage dealing.
/// Subclasses override ActivateAbility() and OnHit() for unique behaviors.
/// </summary>
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D))]
public abstract class ProjectileBase : ManagedBehaviour
{
#region Inspector Properties
[Header("Visuals")]
[Tooltip("Sprite renderer for projectile")]
[SerializeField] protected SpriteRenderer spriteRenderer;
[Header("Effects")]
[Tooltip("Particle effect on impact (optional)")]
[SerializeField] protected GameObject impactEffectPrefab;
#endregion
#region Events
/// <summary>
/// Fired when projectile is launched. Parameters: (ProjectileBase projectile)
/// </summary>
public event Action<ProjectileBase> OnLaunched;
/// <summary>
/// Fired when projectile hits something. Parameters: (ProjectileBase projectile, Collider2D hit)
/// </summary>
public event Action<ProjectileBase, Collider2D> OnImpact;
/// <summary>
/// Fired when projectile is destroyed. Parameters: (ProjectileBase projectile)
/// </summary>
public event Action<ProjectileBase> OnDestroyed;
#endregion
#region Properties
public float Damage { get; protected set; }
public float Mass { get; protected set; }
public Data.ProjectileType ProjectileType { get; protected set; }
public bool IsLaunched { get; protected set; }
public bool AbilityActivated { get; protected set; }
public Vector2 LaunchDirection { get; protected set; }
public float LaunchForce { get; protected set; }
#endregion
#region Timeout
private const float ProjectileTimeout = 10f; // Destroy projectile after 10 seconds if stuck/off-map
private Coroutine timeoutCoroutine;
#endregion
#region Components
protected Rigidbody2D rb2D;
protected Collider2D projectileCollider;
#endregion
#region Lifecycle
/// <summary>
/// Initialize the projectile with its type and load stats from settings.
/// Must be called after instantiation, before Launch.
/// </summary>
public void Initialize(Data.ProjectileType projectileType)
{
ProjectileType = projectileType;
// Load damage and mass from settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
if (settings != null)
{
var config = settings.GetProjectileConfig(projectileType);
if (config != null)
{
Damage = config.damage;
Mass = config.mass;
// Update rigidbody mass if already initialized
if (rb2D != null)
{
rb2D.mass = Mass;
}
Logging.Debug($"[ProjectileBase] Initialized {projectileType} - Damage: {Damage}, Mass: {Mass}");
}
else
{
Logging.Warning($"[ProjectileBase] No config found for {projectileType}, using defaults");
Damage = 20f;
Mass = 1f;
}
}
else
{
Logging.Warning($"[ProjectileBase] Settings not found, using default damage and mass");
Damage = 20f;
Mass = 1f;
}
}
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Automatically assign projectile to correct layer from settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
if (settings != null && settings.ProjectileLayer >= 0 && gameObject.layer != settings.ProjectileLayer)
{
gameObject.layer = settings.ProjectileLayer;
Logging.Debug($"[ProjectileBase] Assigned {gameObject.name} to layer {LayerMask.LayerToName(settings.ProjectileLayer)}");
}
// Cache components
rb2D = GetComponent<Rigidbody2D>();
projectileCollider = GetComponent<Collider2D>();
if (spriteRenderer == null)
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
// Configure rigidbody (mass will be set by Initialize if called, otherwise use defaults)
if (rb2D != null)
{
// If Initialize hasn't been called yet, use default mass
if (Mass == 0f)
{
Mass = 1f;
Damage = 20f;
}
rb2D.mass = Mass;
rb2D.gravityScale = settings?.ProjectileGravityScale ?? 1f;
rb2D.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
}
}
#endregion
#region Launch
/// <summary>
/// Launch the projectile with given direction and force.
/// Called by SlingshotController.
/// </summary>
public virtual void Launch(Vector2 direction, float force)
{
if (IsLaunched)
{
Logging.Warning($"[ProjectileBase] {gameObject.name} already launched!");
return;
}
LaunchDirection = direction.normalized;
LaunchForce = force;
IsLaunched = true;
// Apply physics impulse
if (rb2D != null)
{
rb2D.AddForce(LaunchDirection * LaunchForce, ForceMode2D.Impulse);
// Debug: Log actual mass and resulting velocity for trajectory verification
float actualMass = rb2D.mass;
float expectedVelocity = LaunchForce / actualMass;
Logging.Debug($"[Projectile] Launched {gameObject.name} - Force: {LaunchForce:F2}, Mass: {actualMass:F2}, Expected Velocity: {expectedVelocity:F2}, Dir: {LaunchDirection}");
// After physics applies, log actual velocity (next frame would show it, but we log expectation)
// Note: Actual velocity will be set by Unity physics engine as: velocity = impulse / mass
}
// Fire event
OnLaunched?.Invoke(this);
// Start timeout - destroy projectile after configured time if it hasn't been destroyed
StartTimeoutTimer();
}
#endregion
#region Timeout
/// <summary>
/// Start timeout timer. Projectile will auto-destroy after timeout to prevent stuck/lost projectiles.
/// </summary>
private void StartTimeoutTimer()
{
if (timeoutCoroutine != null)
{
StopCoroutine(timeoutCoroutine);
}
timeoutCoroutine = StartCoroutine(TimeoutCoroutine());
}
/// <summary>
/// Timeout coroutine - destroys projectile after configured time
/// </summary>
private System.Collections.IEnumerator TimeoutCoroutine()
{
yield return new WaitForSeconds(ProjectileTimeout);
// Only destroy if still exists (might have been destroyed by collision already)
if (this != null && gameObject != null)
{
Logging.Debug($"[ProjectileBase] {gameObject.name} timed out after {ProjectileTimeout}s, destroying...");
DestroyProjectile();
}
}
#endregion
#region Ability
/// <summary>
/// Activate projectile's special ability (mid-flight).
/// Override in subclasses for unique behaviors.
/// Called when player taps screen during flight.
/// </summary>
public virtual void ActivateAbility()
{
if (!IsLaunched)
{
Logging.Warning($"[ProjectileBase] Cannot activate ability - projectile not launched yet!");
return;
}
if (AbilityActivated)
{
Logging.Warning($"[ProjectileBase] Ability already activated!");
return;
}
AbilityActivated = true;
Logging.Debug($"[ProjectileBase] {gameObject.name} ability activated");
// Subclasses override this for special behavior
}
#endregion
#region Collision
private void OnCollisionEnter2D(Collision2D collision)
{
if (!IsLaunched) return;
Logging.Debug($"[ProjectileBase] {gameObject.name} hit {collision.gameObject.name}");
// Fire impact event
OnImpact?.Invoke(this, collision.collider);
// Delegate to subclass - they handle everything (damage, effects, destruction)
OnHit(collision);
}
/// <summary>
/// Called when projectile hits something.
/// Override in subclasses to implement full projectile behavior.
/// Default implementation: Deal damage to blocks and destroy projectile.
/// Subclasses should call DestroyProjectile() when they want to be destroyed.
/// </summary>
/// <param name="collision">Collision data including contact points and normals</param>
protected virtual void OnHit(Collision2D collision)
{
// Default behavior: Deal damage to blocks and destroy
FortBlock block = collision.gameObject.GetComponent<FortBlock>();
if (block != null)
{
block.TakeDamage(Damage);
Logging.Debug($"[ProjectileBase] Dealt {Damage} damage to {block.gameObject.name}");
}
// Default: Destroy on hit
DestroyProjectile();
}
#endregion
#region Effects
/// <summary>
/// Spawn impact particle effect
/// </summary>
protected void SpawnImpactEffect(Vector2 position)
{
if (impactEffectPrefab != null)
{
GameObject effect = Instantiate(impactEffectPrefab, position, Quaternion.identity);
// Dynamically determine cleanup time from particle system
float lifetime = GetEffectLifetime(effect);
Destroy(effect, lifetime);
}
}
/// <summary>
/// Get the lifetime of an effect by reading particle system StartLifetime.
/// Falls back to 2 seconds if no particle system found.
/// </summary>
private float GetEffectLifetime(GameObject effect)
{
// Try to read from ParticleSystem
ParticleSystem ps = effect.GetComponent<ParticleSystem>();
if (ps != null)
{
return ps.main.startLifetime.constantMax + 0.5f; // Add small buffer
}
// Try to read from child particle systems
ParticleSystem childPs = effect.GetComponentInChildren<ParticleSystem>();
if (childPs != null)
{
return childPs.main.startLifetime.constantMax + 0.5f;
}
// Fallback for non-particle effects (sprites, etc.)
return 2f;
}
#endregion
#region Destruction
/// <summary>
/// Destroy the projectile.
/// Can be overridden by subclasses for delayed destruction.
/// </summary>
protected virtual void DestroyProjectile()
{
Logging.Debug($"[ProjectileBase] Destroying {gameObject.name}");
// Stop timeout coroutine if running
if (timeoutCoroutine != null)
{
StopCoroutine(timeoutCoroutine);
timeoutCoroutine = null;
}
// Fire destroyed event
OnDestroyed?.Invoke(this);
// Destroy GameObject
Destroy(gameObject);
}
#endregion
#region Debug
private void OnDrawGizmos()
{
if (IsLaunched && Application.isPlaying)
{
// Draw launch direction
Gizmos.color = Color.yellow;
Gizmos.DrawLine(transform.position, transform.position + (Vector3)(LaunchDirection * 2f));
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 70f37c48406847cdabd5589910220fdf
timeCreated: 1764682302

View File

@@ -0,0 +1,39 @@
using Core;
using UnityEngine;
namespace Minigames.FortFight.Projectiles
{
/// <summary>
/// Standard projectile - no special ability.
/// Moderate damage, standard physics arc.
/// </summary>
public class ToasterProjectile : ProjectileBase
{
// Toaster is the basic projectile - uses base class behavior
// No special ability needed
protected override void OnHit(Collision2D collision)
{
// Spawn impact effect
SpawnImpactEffect(collision.contacts[0].point);
// Deal damage to blocks
var block = collision.gameObject.GetComponent<Fort.FortBlock>();
if (block != null)
{
block.TakeDamage(Damage);
Logging.Debug($"[ToasterProjectile] Dealt {Damage} damage to {block.gameObject.name}");
}
// Destroy projectile
DestroyProjectile();
}
public override void ActivateAbility()
{
// Toaster has no special ability
Logging.Debug("[ToasterProjectile] Toaster has no special ability");
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6ecf658b5965496abda845de1a28e227
timeCreated: 1764682312

View File

@@ -0,0 +1,107 @@
using Core;
using UnityEngine;
namespace Minigames.FortFight.Projectiles
{
/// <summary>
/// Trash Bag projectile - splits into multiple smaller pieces on impact.
/// Deals AOE damage in a forward cone.
/// </summary>
public class TrashBagProjectile : ProjectileBase
{
[Header("Trash Bag Specific")]
[Tooltip("Prefab for individual trash pieces (small debris)")]
[SerializeField] private GameObject trashPiecePrefab;
protected override void OnHit(Collision2D collision)
{
// Deal initial damage from trash bag itself
var block = collision.gameObject.GetComponent<Fort.FortBlock>();
if (block != null)
{
block.TakeDamage(Damage);
Logging.Debug($"[TrashBagProjectile] Dealt {Damage} damage to {block.gameObject.name}");
}
// Get settings for trash pieces
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
int pieceCount = settings?.TrashBagPieceCount ?? 8;
Logging.Debug($"[TrashBagProjectile] Splitting into {pieceCount} pieces");
// Get contact normal and impact point
Vector2 hitNormal = collision.contacts[0].normal;
Vector2 impactPoint = collision.contacts[0].point;
// Spawn trash pieces (NOT parented, so they persist as debris)
SpawnTrashPieces(impactPoint, hitNormal);
// Destroy trash bag after spawning pieces
DestroyProjectile();
}
/// <summary>
/// Spawn multiple trash pieces in a cone away from the hit surface.
/// Uses hit normal + projectile momentum for realistic splash effect.
/// </summary>
private void SpawnTrashPieces(Vector2 impactPoint, Vector2 hitNormal)
{
if (trashPiecePrefab == null)
{
Logging.Warning("[TrashBagProjectile] No trash piece prefab assigned!");
return;
}
// Get settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
int pieceCount = settings?.TrashBagPieceCount ?? 8;
float pieceForce = settings?.TrashBagPieceForce ?? 10f;
float spreadAngle = settings?.TrashBagSpreadAngle ?? 60f;
// Calculate projectile's incoming direction (momentum)
Vector2 incomingDirection = rb2D.linearVelocity.normalized;
if (incomingDirection == Vector2.zero)
{
incomingDirection = LaunchDirection;
}
// Calculate reflection direction from hit normal
// This creates a bounce-like effect
Vector2 reflectDirection = Vector2.Reflect(incomingDirection, hitNormal);
// Blend between reflection and pure normal for more variety
// 70% normal (splash away from surface) + 30% reflection (maintain some momentum direction)
Vector2 baseDirection = (hitNormal * 0.7f + reflectDirection * 0.3f).normalized;
// Spawn pieces in a cone around the base direction
for (int i = 0; i < pieceCount; i++)
{
// Calculate angle offset for this piece within the spread cone
float t = pieceCount > 1 ? i / (float)(pieceCount - 1) : 0.5f;
float angleOffset = Mathf.Lerp(-spreadAngle / 2f, spreadAngle / 2f, t);
float angleRadians = Mathf.Atan2(baseDirection.y, baseDirection.x) + angleOffset * Mathf.Deg2Rad;
Vector2 pieceDirection = new Vector2(Mathf.Cos(angleRadians), Mathf.Sin(angleRadians));
// Spawn trash piece slightly offset from impact point
Vector2 spawnOffset = pieceDirection * 0.2f; // Small offset to prevent clipping
GameObject piece = Instantiate(trashPiecePrefab, (Vector2)impactPoint + spawnOffset, Quaternion.identity);
// Setup trash piece physics
Rigidbody2D pieceRb = piece.GetComponent<Rigidbody2D>();
if (pieceRb != null)
{
// Apply force with some randomness for more natural spread
float randomForce = pieceForce * Random.Range(0.8f, 1.2f);
pieceRb.AddForce(pieceDirection * randomForce, ForceMode2D.Impulse);
// Add some random spin
pieceRb.AddTorque(Random.Range(-100f, 100f));
}
Logging.Debug($"[TrashBagProjectile] Spawned trash piece {i} in direction {pieceDirection}");
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b0996e59b91e48f8a542ab9294b11a74
timeCreated: 1764682467

View File

@@ -0,0 +1,84 @@
using Core;
using UnityEngine;
namespace Minigames.FortFight.Projectiles
{
/// <summary>
/// Component for individual trash pieces spawned by TrashBagProjectile.
/// Deals pre-configured damage on collision with blocks, spawns impact effect, then auto-cleans up after timeout.
/// </summary>
public class TrashPiece : MonoBehaviour
{
[Header("Visual Effects")]
[Tooltip("Impact effect prefab spawned on block collision")]
[SerializeField] private GameObject impactEffectPrefab;
private float damage;
private bool hasHit = false;
private void Start()
{
// Get configuration from settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
damage = settings?.TrashPieceDamage ?? 5f;
float lifetime = settings?.TrashPieceLifetime ?? 5f;
// Auto-cleanup after configured timeout
Destroy(gameObject, lifetime);
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (hasHit) return;
// Check if hit a fort block
var block = collision.gameObject.GetComponent<Fort.FortBlock>();
if (block != null)
{
hasHit = true;
// Deal damage
block.TakeDamage(damage);
Logging.Debug($"[TrashPiece] Dealt {damage} damage to {block.gameObject.name}");
// Spawn impact effect at collision point
if (impactEffectPrefab != null && collision.contacts.Length > 0)
{
Vector2 impactPoint = collision.contacts[0].point;
GameObject effect = Instantiate(impactEffectPrefab, impactPoint, Quaternion.identity);
// Dynamically determine cleanup time from particle system
float lifetime = GetEffectLifetime(effect);
Destroy(effect, lifetime);
}
// Destroy trash piece immediately after dealing damage
Destroy(gameObject);
}
}
/// <summary>
/// Get the lifetime of an effect by reading particle system StartLifetime.
/// Falls back to 2 seconds if no particle system found.
/// </summary>
private float GetEffectLifetime(GameObject effect)
{
// Try to read from ParticleSystem
ParticleSystem ps = effect.GetComponent<ParticleSystem>();
if (ps != null)
{
return ps.main.startLifetime.constantMax + 0.5f; // Add small buffer
}
// Try to read from child particle systems
ParticleSystem childPs = effect.GetComponentInChildren<ParticleSystem>();
if (childPs != null)
{
return childPs.main.startLifetime.constantMax + 0.5f;
}
// Fallback for non-particle effects
return 2f;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 516daf2ce7384aaa94fd5e0f7a3cf078
timeCreated: 1764756385

View File

@@ -0,0 +1,105 @@
using Core;
using UnityEngine;
namespace Minigames.FortFight.Projectiles
{
/// <summary>
/// Vacuum projectile - high mass, slides along ground after landing.
/// On floor/block impact: applies constant force to the right and destroys blocks.
/// </summary>
public class VacuumProjectile : ProjectileBase
{
private bool isSliding = false;
private int blocksDestroyed = 0;
private int maxBlocksToDestroy = 3;
private Vector2 slideDirection;
protected override void OnHit(Collision2D collision)
{
// If already sliding, count block destruction
if (isSliding)
{
var block = collision.gameObject.GetComponent<Fort.FortBlock>();
if (block != null)
{
// Spawn impact effect on each block hit
SpawnImpactEffect(collision.contacts[0].point);
// Get damage from settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
float blockDamage = settings?.VacuumBlockDamage ?? 999f;
// Deal high damage to destroy block instantly
block.TakeDamage(blockDamage);
blocksDestroyed++;
Logging.Debug($"[VacuumProjectile] Destroyed block {blocksDestroyed}/{maxBlocksToDestroy}");
if (blocksDestroyed >= maxBlocksToDestroy)
{
Logging.Debug("[VacuumProjectile] Destroyed max blocks - stopping");
DestroyProjectile();
}
}
// Don't destroy - keep sliding
return;
}
// First hit - spawn impact effect and start sliding
SpawnImpactEffect(collision.contacts[0].point);
Logging.Debug("[VacuumProjectile] Hit surface - starting slide");
StartSliding();
// Don't destroy - keep sliding!
}
/// <summary>
/// Start sliding behavior after hitting surface
/// </summary>
private void StartSliding()
{
if (isSliding) return;
isSliding = true;
// Get settings
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
if (settings != null)
{
maxBlocksToDestroy = settings.VacuumDestroyBlockCount;
}
// Determine slide direction based on horizontal velocity (preserve launch direction)
if (rb2D != null)
{
slideDirection = rb2D.linearVelocity.x >= 0 ? Vector2.right : Vector2.left;
rb2D.gravityScale = 0f;
rb2D.linearVelocity = Vector2.zero; // Stop all momentum
Logging.Debug($"[VacuumProjectile] Started sliding in direction: {slideDirection}");
}
}
private void FixedUpdate()
{
if (isSliding && rb2D != null)
{
// Set constant velocity in slide direction
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
float slideSpeed = settings?.VacuumSlideSpeed ?? 10f;
rb2D.linearVelocity = slideDirection * slideSpeed;
}
}
/// <summary>
/// Clean up when destroyed
/// </summary>
protected override void DestroyProjectile()
{
isSliding = false;
base.DestroyProjectile();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: bb85d181808c411b8bd1335aa7d35257
timeCreated: 1764682326

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 90c9b2a87d284c5ba24cce60865c31a4
timeCreated: 1764669813

View File

@@ -0,0 +1,39 @@
using System;
using Minigames.FortFight.Data;
using UnityEngine;
namespace Minigames.FortFight.Settings
{
/// <summary>
/// Configuration for a specific block material (Cardboard, Metal, Glass).
/// </summary>
[Serializable]
public class BlockMaterialConfig
{
[Tooltip("The material type this configuration applies to")]
public BlockMaterial material;
[Tooltip("Base HP for this material before size multiplier")]
public float baseHp;
[Tooltip("Base mass for physics simulation before size multiplier")]
public float baseMass;
}
/// <summary>
/// Configuration for a specific block size (Small, Medium, Large).
/// </summary>
[Serializable]
public class BlockSizeConfig
{
[Tooltip("The size type this configuration applies to")]
public BlockSize size;
[Tooltip("HP multiplier applied to base material HP")]
public float hpMultiplier;
[Tooltip("Mass multiplier applied to base material mass")]
public float massMultiplier;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 731e8965ca0149d79420ee0b15a4e94f
timeCreated: 1764669813

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d97440ab83b1f824e81ff627610e13d0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,252 @@
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Core;
using Minigames.FortFight.Data;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Minigames.FortFight.UI
{
/// <summary>
/// Generic reusable ammunition button that displays projectile data.
/// Shows icon, availability state, cooldown progress, and turns remaining.
/// </summary>
public class AmmoButton : ManagedBehaviour
{
#region Inspector References
[Header("UI Components")]
[Tooltip("Icon image for the projectile")]
[SerializeField] private Image iconImage;
[Tooltip("Background overlay that greys out the entire button when on cooldown")]
[SerializeField] private Image cooldownBackgroundImage;
[Tooltip("Radial fill overlay for cooldown visualization")]
[SerializeField] private Image cooldownFillImage;
[Tooltip("Text displaying turns remaining")]
[SerializeField] private TextMeshProUGUI turnsRemainingText;
[Tooltip("Button component")]
[SerializeField] private Button button;
[Tooltip("Visual indicator for selected state (optional border/glow)")]
[SerializeField] private GameObject selectedIndicator;
#endregion
#region State
private ProjectileConfig projectileConfig;
private AmmunitionManager ammunitionManager;
private SlingshotController slingshotController;
private int playerIndex;
private bool isSelected;
#endregion
#region Initialization
/// <summary>
/// Initialize the button with projectile config and system references.
/// Call this after instantiation to configure the button.
/// </summary>
public void Initialize(ProjectileConfig config, AmmunitionManager ammoManager, SlingshotController slingshot, int playerIdx)
{
projectileConfig = config;
ammunitionManager = ammoManager;
slingshotController = slingshot;
playerIndex = playerIdx;
// Setup UI from projectile config
if (iconImage != null && config.icon != null)
{
iconImage.sprite = config.icon;
}
// Setup cooldown background (hidden by default)
if (cooldownBackgroundImage != null)
{
cooldownBackgroundImage.gameObject.SetActive(false);
}
// Setup cooldown fill (hidden by default)
if (cooldownFillImage != null)
{
cooldownFillImage.fillAmount = 0f;
cooldownFillImage.type = Image.Type.Filled;
cooldownFillImage.fillMethod = Image.FillMethod.Radial360;
cooldownFillImage.fillOrigin = (int)Image.Origin360.Top;
cooldownFillImage.gameObject.SetActive(false);
}
// Setup turns text (hidden by default)
if (turnsRemainingText != null)
{
turnsRemainingText.gameObject.SetActive(false);
}
// Setup button
if (button != null)
{
button.onClick.AddListener(OnButtonClicked);
}
// Subscribe to ammunition events
if (ammunitionManager != null)
{
ammunitionManager.OnAmmoSelected += HandleAmmoSelected;
ammunitionManager.OnAmmoCooldownStarted += HandleCooldownStarted;
ammunitionManager.OnAmmoCooldownCompleted += HandleCooldownCompleted;
}
// Initial update
UpdateVisuals();
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe from events
if (ammunitionManager != null)
{
ammunitionManager.OnAmmoSelected -= HandleAmmoSelected;
ammunitionManager.OnAmmoCooldownStarted -= HandleCooldownStarted;
ammunitionManager.OnAmmoCooldownCompleted -= HandleCooldownCompleted;
}
// Remove button listener
if (button != null)
{
button.onClick.RemoveListener(OnButtonClicked);
}
}
#endregion
#region Update
private void Update()
{
// Update visuals every frame
UpdateVisuals();
}
/// <summary>
/// Update all visual elements based on current state
/// </summary>
private void UpdateVisuals()
{
if (projectileConfig == null || ammunitionManager == null) return;
// Get current cooldown state for this player
int turnsRemaining = ammunitionManager.GetCooldownRemaining(projectileConfig.projectileType, playerIndex);
bool isAvailable = ammunitionManager.IsAmmoAvailable(projectileConfig.projectileType, playerIndex);
bool onCooldown = turnsRemaining > 0;
// Show/hide cooldown background overlay
if (cooldownBackgroundImage != null)
{
cooldownBackgroundImage.gameObject.SetActive(onCooldown);
}
// Update cooldown fill (0 = no fill, 1 = full fill)
if (cooldownFillImage != null)
{
if (onCooldown && projectileConfig.cooldownTurns > 0)
{
float fillAmount = (float)turnsRemaining / projectileConfig.cooldownTurns;
cooldownFillImage.fillAmount = fillAmount;
cooldownFillImage.gameObject.SetActive(true);
}
else
{
cooldownFillImage.gameObject.SetActive(false);
}
}
// Update turns remaining text
if (turnsRemainingText != null)
{
if (onCooldown)
{
turnsRemainingText.text = turnsRemaining.ToString();
turnsRemainingText.gameObject.SetActive(true);
}
else
{
turnsRemainingText.gameObject.SetActive(false);
}
}
// Update button interactability
if (button != null)
{
button.interactable = isAvailable;
}
// Update selected indicator
if (selectedIndicator != null)
{
selectedIndicator.SetActive(isSelected);
}
}
#endregion
#region Button Click
/// <summary>
/// Called when button is clicked - selects this ammo type
/// </summary>
private void OnButtonClicked()
{
if (projectileConfig == null || ammunitionManager == null) return;
// Try to select this ammo type for this player
bool selected = ammunitionManager.SelectAmmo(projectileConfig.projectileType, playerIndex);
if (selected && slingshotController != null)
{
// Update slingshot with new ammo config
slingshotController.SetAmmo(projectileConfig);
Logging.Debug($"[AmmoButton] Player {playerIndex} selected {projectileConfig.displayName}");
}
}
#endregion
#region Event Handlers
private void HandleAmmoSelected(ProjectileType selectedType, int selectedPlayerIndex)
{
// Only update if this event is for our player
if (selectedPlayerIndex != playerIndex)
return;
// Update selected state - check if this is our player's current selection
isSelected = (selectedType == projectileConfig.projectileType);
}
private void HandleCooldownStarted(ProjectileType type, int cooldownTurns)
{
// Visual update handled in UpdateVisuals()
}
private void HandleCooldownCompleted(ProjectileType type)
{
// Visual update handled in UpdateVisuals()
if (type == projectileConfig.projectileType)
{
Logging.Debug($"[AmmoButton] {projectileConfig.displayName} ready!");
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d18726bad651464fbc4e49f8c95c0c37
timeCreated: 1764770308

View File

@@ -0,0 +1,157 @@
using Core;
using Core.Lifecycle;
using Minigames.FortFight.Core;
using Minigames.FortFight.Data;
using UnityEngine;
namespace Minigames.FortFight.UI
{
/// <summary>
/// Manages ammunition UI panel for a specific player.
/// Shows/hides based on turn state and initializes buttons with player context.
/// </summary>
public class AmmunitionPanel : ManagedBehaviour
{
#region Inspector References
[Header("Player Configuration")]
[Tooltip("Which player this panel belongs to (0 = Player 1, 1 = Player 2)")]
[SerializeField] private int playerIndex = 0;
[Header("References")]
[Tooltip("This player's slingshot controller")]
[SerializeField] private SlingshotController slingshotController;
// Note: AmmunitionManager and TurnManager accessed via singletons
[Header("UI")]
[Tooltip("Array of ammo buttons in this panel")]
[SerializeField] private AmmoButton[] ammoButtons;
[Tooltip("Root GameObject to show/hide entire panel")]
[SerializeField] private GameObject panelRoot;
#endregion
#region Initialization
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Validate references
if (slingshotController == null)
{
Logging.Error($"[AmmunitionPanel] Player {playerIndex}: Slingshot controller not assigned!");
}
if (ammoButtons == null || ammoButtons.Length == 0)
{
Logging.Warning($"[AmmunitionPanel] Player {playerIndex}: No ammo buttons assigned!");
}
// Use assigned panelRoot or fall back to this GameObject
if (panelRoot == null)
{
panelRoot = gameObject;
}
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Initialize ammo buttons with player context
InitializeButtons();
// Subscribe to turn events via singleton
if (TurnManager.Instance != null)
{
TurnManager.Instance.OnTurnStarted += HandleTurnStarted;
}
// Start hidden
SetPanelVisibility(false);
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe from events
if (TurnManager.Instance != null)
{
TurnManager.Instance.OnTurnStarted -= HandleTurnStarted;
}
}
/// <summary>
/// Initialize all ammo buttons with player-specific configuration
/// </summary>
private void InitializeButtons()
{
if (AmmunitionManager.Instance == null || slingshotController == null || ammoButtons == null)
{
return;
}
// Get available projectile types from settings
var availableTypes = AmmunitionManager.Instance.GetAvailableProjectileTypes();
var settings = GameManager.GetSettingsObject<AppleHills.Core.Settings.IFortFightSettings>();
if (settings == null)
{
Logging.Error($"[AmmunitionPanel] Player {playerIndex}: Could not get FortFightSettings!");
return;
}
for (int i = 0; i < ammoButtons.Length && i < availableTypes.Count; i++)
{
if (ammoButtons[i] != null)
{
var config = settings.GetProjectileConfig(availableTypes[i]);
if (config != null)
{
ammoButtons[i].Initialize(config, AmmunitionManager.Instance, slingshotController, playerIndex);
Logging.Debug($"[AmmunitionPanel] Player {playerIndex}: Initialized button for {config.displayName}");
}
}
}
}
#endregion
#region Turn Events
/// <summary>
/// Called when any player's turn starts - show/hide panel accordingly
/// </summary>
private void HandleTurnStarted(PlayerData player, TurnState turnState)
{
// Only show panel when it's this player's turn (not during transitions)
bool shouldShow = player.PlayerIndex == playerIndex &&
(turnState == TurnState.PlayerOneTurn || turnState == TurnState.PlayerTwoTurn);
SetPanelVisibility(shouldShow);
if (shouldShow)
{
Logging.Debug($"[AmmunitionPanel] Showing panel for Player {playerIndex}");
}
}
/// <summary>
/// Show or hide the entire panel
/// </summary>
private void SetPanelVisibility(bool visible)
{
if (panelRoot != null)
{
panelRoot.SetActive(visible);
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1963617e4d104c199c3a66d671b8d8a2
timeCreated: 1764771029

View File

@@ -0,0 +1,263 @@
using Core;
using Core.Lifecycle;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Minigames.FortFight.Fort;
namespace Minigames.FortFight.UI
{
/// <summary>
/// Displays HP bar and percentage for a fort
/// </summary>
public class FortHealthUI : ManagedBehaviour
{
#region Inspector Properties
[Header("UI References")]
[SerializeField] private Slider hpSlider;
[SerializeField] private TextMeshProUGUI hpPercentageText;
[SerializeField] private TextMeshProUGUI fortNameText;
[Header("Visual Feedback")]
[SerializeField] private Image fillImage;
[SerializeField] private Color healthyColor = Color.green;
[SerializeField] private Color damagedColor = Color.yellow;
[SerializeField] private Color criticalColor = Color.red;
[Header("Auto-Binding (for Dynamic Spawning)")]
[SerializeField] private bool autoBindToFort = true;
[SerializeField] private bool isPlayerFort = true;
[Tooltip("Leave empty to auto-find")]
[SerializeField] private Core.FortManager fortManager;
[Header("Debug Display")]
[Tooltip("Show numerical HP values (current/max)")]
[SerializeField] private bool debugDisplay = false;
[Tooltip("Text field to display 'current/max' HP values")]
[SerializeField] private TextMeshProUGUI debugHpText;
#endregion
#region Private State
private FortController trackedFort;
#endregion
#region Lifecycle
internal override void OnManagedStart()
{
base.OnManagedStart();
Logging.Debug($"[FortHealthUI] OnManagedStart - autoBindToFort: {autoBindToFort}, isPlayerFort: {isPlayerFort}");
// Auto-bind to dynamically spawned forts
if (autoBindToFort)
{
SetupAutoBinding();
}
else
{
Logging.Warning($"[FortHealthUI] Auto-bind disabled! HP UI will not update.");
}
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe from fort events
if (trackedFort != null)
{
trackedFort.OnFortDamaged -= OnFortDamaged;
}
// Unsubscribe from fort manager
if (fortManager != null)
{
fortManager.OnPlayerFortSpawned -= OnFortSpawned;
fortManager.OnEnemyFortSpawned -= OnFortSpawned;
}
}
#endregion
#region Auto-Binding
private void SetupAutoBinding()
{
Logging.Debug($"[FortHealthUI] SetupAutoBinding called for {(isPlayerFort ? "PLAYER" : "ENEMY")} fort");
// Get FortManager via singleton
fortManager = Core.FortManager.Instance;
if (fortManager == null)
{
Logging.Error($"[FortHealthUI] CRITICAL: FortManager.Instance is NULL! HP UI will not work.");
return;
}
Logging.Debug($"[FortHealthUI] FortManager found. Checking if fort already spawned...");
// Check if fort already spawned (missed the spawn event)
FortController existingFort = isPlayerFort ? fortManager.PlayerFort : fortManager.EnemyFort;
if (existingFort != null)
{
Logging.Debug($"[FortHealthUI] Fort already exists, binding immediately: {existingFort.FortName}");
BindToFort(existingFort);
return;
}
Logging.Debug($"[FortHealthUI] Fort not spawned yet. Subscribing to spawn events...");
// Subscribe to appropriate spawn event for future spawn
if (isPlayerFort)
{
fortManager.OnPlayerFortSpawned += OnFortSpawned;
Logging.Debug($"[FortHealthUI] ✅ Subscribed to OnPlayerFortSpawned event");
}
else
{
fortManager.OnEnemyFortSpawned += OnFortSpawned;
Logging.Debug($"[FortHealthUI] ✅ Subscribed to OnEnemyFortSpawned event");
}
}
private void OnFortSpawned(FortController fort)
{
Logging.Debug($"[FortHealthUI] 🎯 OnFortSpawned event received! Fort: {fort?.FortName ?? "NULL"}");
if (fort == null)
{
Logging.Error($"[FortHealthUI] Fort is NULL in spawn callback!");
return;
}
BindToFort(fort);
}
#endregion
#region Setup
/// <summary>
/// Bind this UI to a specific fort
/// </summary>
public void BindToFort(FortController fort)
{
if (fort == null)
{
Logging.Warning("[FortHealthUI] Cannot bind to null fort!");
return;
}
// Unsubscribe from previous fort
if (trackedFort != null)
{
trackedFort.OnFortDamaged -= OnFortDamaged;
}
trackedFort = fort;
// Subscribe to fort events
trackedFort.OnFortDamaged += OnFortDamaged;
// Initialize UI
if (fortNameText != null)
{
fortNameText.text = fort.FortName;
}
UpdateDisplay();
Logging.Debug($"[FortHealthUI] Bound to fort: {fort.FortName}. Event subscription successful.");
}
#endregion
#region Event Handlers
private void OnFortDamaged(float damage, float hpPercentage)
{
Logging.Debug($"[FortHealthUI] OnFortDamaged received! Damage: {damage}, HP%: {hpPercentage:F1}%, Fort: {trackedFort?.FortName}");
UpdateDisplay();
}
#endregion
#region Display Update
private void UpdateDisplay()
{
if (trackedFort == null)
{
Logging.Warning("[FortHealthUI] UpdateDisplay called but trackedFort is null!");
return;
}
float hpPercent = trackedFort.HpPercentage;
Logging.Debug($"[FortHealthUI] UpdateDisplay - Fort: {trackedFort.FortName}, HP: {hpPercent:F1}%");
// Update slider
if (hpSlider != null)
{
hpSlider.value = hpPercent / 100f;
Logging.Debug($"[FortHealthUI] Slider updated to {hpSlider.value:F2}");
}
else
{
Logging.Warning("[FortHealthUI] hpSlider is null!");
}
// Update text
if (hpPercentageText != null)
{
hpPercentageText.text = $"{hpPercent:F0}%";
Logging.Debug($"[FortHealthUI] Text updated to {hpPercentageText.text}");
}
else
{
Logging.Warning("[FortHealthUI] hpPercentageText is null!");
}
// Update debug HP display (current/max)
if (debugHpText != null)
{
if (debugDisplay)
{
debugHpText.gameObject.SetActive(true);
float currentHp = trackedFort.CurrentHp;
float maxHp = trackedFort.MaxHp;
debugHpText.text = $"{currentHp:F0}/{maxHp:F0}";
}
else
{
debugHpText.gameObject.SetActive(false);
}
}
// Update color based on HP
if (fillImage != null)
{
if (hpPercent > 60f)
{
fillImage.color = healthyColor;
}
else if (hpPercent > 30f)
{
fillImage.color = damagedColor;
}
else
{
fillImage.color = criticalColor;
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ed9e5253aa2a40c9a9028767466456b1
timeCreated: 1764592117

View File

@@ -0,0 +1,234 @@
using Core;
using Core.Lifecycle;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Minigames.FortFight.UI
{
/// <summary>
/// Game Over UI - displays when a fort is defeated.
/// Shows match time, winner, and restart button.
/// </summary>
public class GameOverUI : ManagedBehaviour
{
#region Inspector Properties
[Header("UI References")]
[Tooltip("Root GameObject to show/hide the entire UI")]
[SerializeField] private GameObject rootPanel;
[Tooltip("Text showing elapsed time")]
[SerializeField] private TextMeshProUGUI elapsedTimeText;
[Tooltip("Text showing winner")]
[SerializeField] private TextMeshProUGUI winnerText;
[Tooltip("Restart button")]
[SerializeField] private Button restartButton;
[Header("Optional Visuals")]
[Tooltip("Optional canvas group for fade-in")]
[SerializeField] private CanvasGroup canvasGroup;
#endregion
#region Lifecycle
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Validate references
if (rootPanel == null)
{
Logging.Error("[GameOverUI] Root panel not assigned!");
}
if (elapsedTimeText == null)
{
Logging.Warning("[GameOverUI] Elapsed time text not assigned!");
}
if (winnerText == null)
{
Logging.Warning("[GameOverUI] Winner text not assigned!");
}
if (restartButton == null)
{
Logging.Error("[GameOverUI] Restart button not assigned!");
}
// Setup button listener
if (restartButton != null)
{
restartButton.onClick.AddListener(OnRestartClicked);
}
// Ensure canvas group exists for fade
if (canvasGroup == null && rootPanel != null)
{
canvasGroup = rootPanel.GetComponent<CanvasGroup>();
}
// Start hidden
Hide();
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Remove button listener
if (restartButton != null)
{
restartButton.onClick.RemoveListener(OnRestartClicked);
}
}
#endregion
#region Event Handlers
private void OnRestartClicked()
{
Logging.Debug("[GameOverUI] Restart button clicked, reloading scene...");
RestartGame();
}
#endregion
#region Display
/// <summary>
/// Show the game over UI with match results
/// Called by FortFightGameManager when game ends
/// </summary>
public void Show()
{
if (rootPanel != null)
{
rootPanel.SetActive(true);
}
// Get game manager for elapsed time
var gameManager = Core.FortFightGameManager.Instance;
if (gameManager != null)
{
float elapsedTime = gameManager.ElapsedGameTime;
UpdateElapsedTime(elapsedTime);
// Determine winner
DetermineWinner();
}
// Optional: Fade in
if (canvasGroup != null)
{
canvasGroup.alpha = 0f;
StartCoroutine(FadeIn());
}
Logging.Debug("[GameOverUI] Game over UI shown");
}
/// <summary>
/// Hide the game over UI
/// </summary>
public void Hide()
{
if (rootPanel != null)
{
rootPanel.SetActive(false);
}
}
/// <summary>
/// Update the elapsed time display
/// </summary>
private void UpdateElapsedTime(float seconds)
{
if (elapsedTimeText == null) return;
// Format as MM:SS
int minutes = Mathf.FloorToInt(seconds / 60f);
int secs = Mathf.FloorToInt(seconds % 60f);
elapsedTimeText.text = $"{minutes:00}:{secs:00}";
}
/// <summary>
/// Determine and display the winner
/// </summary>
private void DetermineWinner()
{
if (winnerText == null) return;
var fortManager = Core.FortManager.Instance;
if (fortManager == null) return;
bool playerDefeated = fortManager.PlayerFort?.IsDefeated ?? false;
bool enemyDefeated = fortManager.EnemyFort?.IsDefeated ?? false;
if (playerDefeated && enemyDefeated)
{
winnerText.text = "DRAW!";
}
else if (playerDefeated)
{
winnerText.text = "PLAYER TWO WINS!";
}
else if (enemyDefeated)
{
winnerText.text = "PLAYER ONE WINS!";
}
else
{
winnerText.text = "GAME OVER";
}
}
/// <summary>
/// Fade in the UI over time
/// </summary>
private System.Collections.IEnumerator FadeIn()
{
float duration = 0.5f;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
if (canvasGroup != null)
{
canvasGroup.alpha = Mathf.Lerp(0f, 1f, elapsed / duration);
}
yield return null;
}
if (canvasGroup != null)
{
canvasGroup.alpha = 1f;
}
}
#endregion
#region Restart
/// <summary>
/// Restart the game by reloading the current scene
/// </summary>
private void RestartGame()
{
// Use Unity's SceneManager to reload current scene
string currentScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name;
Logging.Debug($"[GameOverUI] Reloading scene: {currentScene}");
UnityEngine.SceneManagement.SceneManager.LoadScene(currentScene);
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ffb13ad5109340eba06f9c02082ece94
timeCreated: 1764806274

View File

@@ -0,0 +1,204 @@
using Core;
using UnityEngine;
using TMPro;
using UI.Core;
using Minigames.FortFight.Core;
using Minigames.FortFight.Data;
using Pixelplacement;
namespace Minigames.FortFight.UI
{
/// <summary>
/// Main gameplay UI page for Fort Fight minigame.
/// Displays turn info. Turn actions now handled via slingshot input system.
/// </summary>
public class GameplayPage : UIPage
{
[Header("UI Elements")]
[SerializeField] private TextMeshProUGUI turnIndicatorText;
[SerializeField] private TextMeshProUGUI currentPlayerText;
[Header("Optional Visual Elements")]
[SerializeField] private CanvasGroup canvasGroup;
[SerializeField] private GameObject playerActionPanel;
[SerializeField] private GameObject aiTurnPanel;
private TurnManager turnManager;
#region Initialization
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Validate references
ValidateReferences();
// Note: takeActionButton is no longer used - turns handled via slingshot input
// Set up canvas group
if (canvasGroup == null)
{
canvasGroup = GetComponent<CanvasGroup>();
}
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Get turn manager reference via singleton
turnManager = TurnManager.Instance;
if (turnManager != null)
{
turnManager.OnTurnStarted += OnTurnStarted;
turnManager.OnTurnEnded += OnTurnEnded;
}
else
{
Logging.Error("[GameplayPage] TurnManager not found!");
}
}
private void ValidateReferences()
{
if (turnIndicatorText == null)
{
Logging.Warning("[GameplayPage] Turn indicator text not assigned!");
}
if (currentPlayerText == null)
{
Logging.Warning("[GameplayPage] Current player text not assigned!");
}
// Note: takeActionButton and actionButtonText are no longer used
// Turns are now handled automatically via slingshot input system
}
#endregion
#region Turn Events
/// <summary>
/// Called when a new turn starts
/// </summary>
private void OnTurnStarted(PlayerData currentPlayer, TurnState turnState)
{
Logging.Debug($"[GameplayPage] Turn started - Player: {currentPlayer.PlayerName}, State: {turnState}");
UpdateTurnUI(currentPlayer, turnState);
}
/// <summary>
/// Called when the current turn ends
/// </summary>
private void OnTurnEnded(PlayerData player)
{
Logging.Debug($"[GameplayPage] Turn ended for {player.PlayerName}");
}
#endregion
#region UI Updates
/// <summary>
/// Update the UI to reflect current turn state
/// </summary>
private void UpdateTurnUI(PlayerData currentPlayer, TurnState turnState)
{
// Update turn counter
if (turnIndicatorText != null && turnManager != null)
{
turnIndicatorText.text = $"Turn {turnManager.TurnCount + 1}";
}
// Update current player display
if (currentPlayerText != null)
{
currentPlayerText.text = $"{currentPlayer.PlayerName}'s Turn";
}
// Show/hide appropriate panels based on turn state
if (turnState == TurnState.AITurn)
{
// AI turn - hide player controls
if (playerActionPanel != null)
{
playerActionPanel.SetActive(false);
}
if (aiTurnPanel != null)
{
aiTurnPanel.SetActive(true);
}
}
else if (turnState == TurnState.PlayerOneTurn || turnState == TurnState.PlayerTwoTurn)
{
// Player turn - show controls
if (playerActionPanel != null)
{
playerActionPanel.SetActive(true);
}
if (aiTurnPanel != null)
{
aiTurnPanel.SetActive(false);
}
}
}
#endregion
#region Transitions
protected override void DoTransitionIn(System.Action onComplete)
{
// Simple fade in if canvas group is available
if (canvasGroup != null)
{
canvasGroup.alpha = 0f;
Tween.CanvasGroupAlpha(canvasGroup, 1f, transitionDuration, 0f, Tween.EaseOut,
completeCallback: () => onComplete?.Invoke());
}
else
{
onComplete?.Invoke();
}
}
protected override void DoTransitionOut(System.Action onComplete)
{
// Simple fade out if canvas group is available
if (canvasGroup != null)
{
Tween.CanvasGroupAlpha(canvasGroup, 0f, transitionDuration, 0f, Tween.EaseIn,
completeCallback: () => onComplete?.Invoke());
}
else
{
onComplete?.Invoke();
}
}
#endregion
#region Cleanup
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe from turn manager events
if (turnManager != null)
{
turnManager.OnTurnStarted -= OnTurnStarted;
turnManager.OnTurnEnded -= OnTurnEnded;
}
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e22e2729514a5ee40bb2d4c23fbeb75f

View File

@@ -0,0 +1,150 @@
using Core;
using UnityEngine;
using UnityEngine.UI;
using UI.Core;
using Minigames.FortFight.Core;
using Minigames.FortFight.Data;
using Pixelplacement;
namespace Minigames.FortFight.UI
{
/// <summary>
/// UI page for selecting single-player or two-player mode
/// </summary>
public class ModeSelectionPage : UIPage
{
[Header("Mode Selection Buttons")]
[SerializeField] private Button singlePlayerButton;
[SerializeField] private Button twoPlayerButton;
[Header("Optional Visual Elements")]
[SerializeField] private GameObject titleText;
[SerializeField] private CanvasGroup canvasGroup;
#region Initialization
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Validate button references
if (singlePlayerButton == null)
{
Logging.Error("[ModeSelectionPage] Single player button not assigned!");
}
else
{
singlePlayerButton.onClick.AddListener(OnSinglePlayerSelected);
}
if (twoPlayerButton == null)
{
Logging.Error("[ModeSelectionPage] Two player button not assigned!");
}
else
{
twoPlayerButton.onClick.AddListener(OnTwoPlayerSelected);
}
// Set up canvas group if available
if (canvasGroup == null)
{
canvasGroup = GetComponent<CanvasGroup>();
}
}
#endregion
#region Button Callbacks
/// <summary>
/// Called when single player button is clicked
/// </summary>
private void OnSinglePlayerSelected()
{
Logging.Debug("[ModeSelectionPage] Single player mode selected");
if (FortFightGameManager.Instance != null)
{
FortFightGameManager.Instance.SelectGameMode(FortFightGameMode.SinglePlayer);
}
else
{
Logging.Error("[ModeSelectionPage] FortFightGameManager instance not found!");
}
}
/// <summary>
/// Called when two player button is clicked
/// </summary>
private void OnTwoPlayerSelected()
{
Logging.Debug("[ModeSelectionPage] Two player mode selected");
if (FortFightGameManager.Instance != null)
{
FortFightGameManager.Instance.SelectGameMode(FortFightGameMode.TwoPlayer);
}
else
{
Logging.Error("[ModeSelectionPage] FortFightGameManager instance not found!");
}
}
#endregion
#region Transitions
protected override void DoTransitionIn(System.Action onComplete)
{
// Simple fade in if canvas group is available
if (canvasGroup != null)
{
canvasGroup.alpha = 0f;
Tween.CanvasGroupAlpha(canvasGroup, 1f, transitionDuration, 0f, Tween.EaseOut,
completeCallback: () => onComplete?.Invoke());
}
else
{
onComplete?.Invoke();
}
}
protected override void DoTransitionOut(System.Action onComplete)
{
// Simple fade out if canvas group is available
if (canvasGroup != null)
{
Tween.CanvasGroupAlpha(canvasGroup, 0f, transitionDuration, 0f, Tween.EaseIn,
completeCallback: () => onComplete?.Invoke());
}
else
{
onComplete?.Invoke();
}
}
#endregion
#region Cleanup
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Unsubscribe from button events
if (singlePlayerButton != null)
{
singlePlayerButton.onClick.RemoveListener(OnSinglePlayerSelected);
}
if (twoPlayerButton != null)
{
twoPlayerButton.onClick.RemoveListener(OnTwoPlayerSelected);
}
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c1ab5687204db54eaa4ea7f812b3c06