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,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