코드 리팩토링

재사용성 및 확장성을 고려하여 코드 전반을 리팩토링함
This commit is contained in:
2026-01-21 01:45:15 +09:00
parent b4ac8f600f
commit db5db4b106
45 changed files with 2775 additions and 248 deletions

View File

@@ -1,24 +1,21 @@
using UnityEngine;
using System;
public class Core : MonoBehaviour, IDamageable
/// <summary>
/// Core structure that players must defend.
/// Uses HealthComponent for health management.
/// </summary>
[RequireComponent(typeof(HealthComponent))]
public class Core : MonoBehaviour
{
[SerializeField] private float maxHealth = 100f;
[SerializeField] private float currentHealth = 100f;
private float CurrentHealth;
private HealthComponent _health;
// 체력이 변경될 때 UI 등에 알리기 위한 이벤트 (Observer 패턴)
public static event Action<float> OnHealthChanged;
public static event Action OnCoreDestroyed;
void Awake() => currentHealth = maxHealth;
public void TakeDamage(float amount)
void Awake()
{
currentHealth -= amount;
OnHealthChanged?.Invoke(currentHealth / maxHealth);
if (currentHealth <= 0)
OnCoreDestroyed?.Invoke();
_health = GetComponent<HealthComponent>();
}
}
/// <summary>
/// Get the health component for direct access.
/// </summary>
public HealthComponent Health => _health;
}

View File

@@ -1,19 +1,42 @@
using UnityEngine;
using UnityEngine.SceneManagement; // 씬 재시작용
using UnityEngine.SceneManagement;
/// <summary>
/// Manages game state and responds to core destruction.
/// </summary>
public class GameManager : MonoBehaviour
{
private bool _isGameOver = false;
[SerializeField] private Core coreReference;
private void OnEnable()
private bool _isGameOver = false;
private HealthComponent _coreHealth;
private void Start()
{
// Core의 파괴 이벤트를 구독
Core.OnCoreDestroyed += GameOver;
// Find Core if not assigned
if (coreReference == null)
{
coreReference = FindFirstObjectByType<Core>();
}
// Subscribe to core's health component
if (coreReference != null)
{
_coreHealth = coreReference.Health;
if (_coreHealth != null)
{
_coreHealth.OnDeath += GameOver;
}
}
}
private void OnDisable()
private void OnDestroy()
{
Core.OnCoreDestroyed -= GameOver;
// Unsubscribe to prevent memory leaks
if (_coreHealth != null)
{
_coreHealth.OnDeath -= GameOver;
}
}
private void GameOver()
@@ -23,14 +46,13 @@ public class GameManager : MonoBehaviour
_isGameOver = true;
Debug.Log("Game Over! Core has been destroyed.");
// 여기에 패배 UI 표시 로직 등을 넣습니다.
// 예: 3초 후 게임 재시작
// Show defeat UI here
// Example: restart game after 3 seconds
Invoke(nameof(RestartGame), 3f);
}
private void RestartGame()
{
// 현재 활성화된 씬을 다시 로드
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}
}

View File

@@ -1,34 +1,51 @@
using UnityEngine;
using System;
using UnityEngine.AI;
public class Gate : MonoBehaviour, IDamageable
/// <summary>
/// Gate structure that can be destroyed.
/// Uses HealthComponent for health management.
/// </summary>
[RequireComponent(typeof(HealthComponent))]
public class Gate : MonoBehaviour
{
[SerializeField] private float maxHealth = 50f;
[SerializeField] private float currentHealth = 50f;
private float CurrentHealth;
private HealthComponent _health;
private NavMeshObstacle _obstacle;
// 체력이 변경될 때 UI 등에 알리기 위한 이벤트 (Observer 패턴)
public static event Action<float> OnHealthChanged;
public static event Action OnGateDestroyed;
void Awake() => currentHealth = maxHealth;
public void TakeDamage(float amount)
void Awake()
{
currentHealth -= amount;
OnHealthChanged?.Invoke(currentHealth / maxHealth);
_health = GetComponent<HealthComponent>();
_obstacle = GetComponent<NavMeshObstacle>();
if (currentHealth <= 0)
// Subscribe to health component events
_health.OnDeath += HandleDeath;
}
void OnDestroy()
{
if (_health != null)
{
gameObject.SetActive(false);
var obstacle = GetComponent<UnityEngine.AI.NavMeshObstacle>();
if(obstacle != null)
{
obstacle.carving = false;
obstacle.enabled = false;
}
OnGateDestroyed?.Invoke();
Destroy(gameObject, 0.1f);
_health.OnDeath -= HandleDeath;
}
}
}
private void HandleDeath()
{
// Disable the gate visually
gameObject.SetActive(false);
// Disable NavMesh carving so enemies can pass through
if (_obstacle != null)
{
_obstacle.carving = false;
_obstacle.enabled = false;
}
// Destroy the object after a short delay
Destroy(gameObject, 0.1f);
}
/// <summary>
/// Get the health component for direct access.
/// </summary>
public HealthComponent Health => _health;
}

View File

@@ -0,0 +1,384 @@
using System;
using UnityEngine;
using Unity.Netcode;
/// <summary>
/// Reusable health component that can be attached to any entity.
/// Supports both networked (multiplayer) and local-only usage.
/// Implements IDamageable for compatibility with existing damage systems.
/// </summary>
public class HealthComponent : NetworkBehaviour, IDamageable
{
[Header("Health Settings")]
[SerializeField] private float maxHealth = 100f;
[SerializeField] private bool destroyOnDeath = false;
[SerializeField] private float destroyDelay = 0f;
// Network-synced health for multiplayer
private NetworkVariable<float> _networkHealth = new NetworkVariable<float>(
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Local health for non-networked entities or single-player
private float _localHealth;
// Track if we're in a networked context
private bool _isNetworked;
#region Events
/// <summary>
/// Fired when health changes. Parameters: (currentHealth, maxHealth)
/// </summary>
public event Action<float, float> OnHealthChanged;
/// <summary>
/// Fired when damage is taken. Parameter: DamageInfo
/// </summary>
public event Action<DamageInfo> OnDamaged;
/// <summary>
/// Fired when healed. Parameter: healAmount
/// </summary>
public event Action<float> OnHealed;
/// <summary>
/// Fired when health reaches zero.
/// </summary>
public event Action OnDeath;
/// <summary>
/// Fired when health is restored from zero (revived).
/// </summary>
public event Action OnRevived;
#endregion
#region IDamageable Implementation
public float CurrentHealth => _isNetworked ? _networkHealth.Value : _localHealth;
public float MaxHealth => maxHealth;
public bool IsAlive => CurrentHealth > 0;
#endregion
#region Unity Lifecycle
private void Awake()
{
_localHealth = maxHealth;
}
public override void OnNetworkSpawn()
{
_isNetworked = true;
if (IsServer)
{
_networkHealth.Value = maxHealth;
}
_networkHealth.OnValueChanged += HandleNetworkHealthChanged;
}
public override void OnNetworkDespawn()
{
_networkHealth.OnValueChanged -= HandleNetworkHealthChanged;
}
#endregion
#region Damage Methods
/// <summary>
/// Simple damage method (backwards compatible).
/// </summary>
public void TakeDamage(float amount)
{
TakeDamage(new DamageInfo(amount));
}
/// <summary>
/// Enhanced damage method with full context.
/// </summary>
public void TakeDamage(DamageInfo damageInfo)
{
if (!IsAlive) return;
if (damageInfo.Amount <= 0) return;
if (_isNetworked)
{
// In networked mode, request damage through server
ApplyDamageServerRpc(damageInfo.Amount, (int)damageInfo.Type);
}
else
{
// Local mode - apply directly
ApplyDamageLocal(damageInfo);
}
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void ApplyDamageServerRpc(float amount, int damageType)
{
if (!IsAlive) return;
float newHealth = Mathf.Max(0, _networkHealth.Value - amount);
_networkHealth.Value = newHealth;
// Notify all clients about the damage
NotifyDamageClientRpc(amount, damageType);
if (newHealth <= 0)
{
HandleDeathServer();
}
}
[ClientRpc]
private void NotifyDamageClientRpc(float amount, int damageType)
{
OnDamaged?.Invoke(new DamageInfo(amount, (DamageType)damageType));
}
private void ApplyDamageLocal(DamageInfo info)
{
float previousHealth = _localHealth;
_localHealth = Mathf.Max(0, _localHealth - info.Amount);
OnHealthChanged?.Invoke(_localHealth, maxHealth);
OnDamaged?.Invoke(info);
if (_localHealth <= 0 && previousHealth > 0)
{
HandleDeathLocal();
}
}
#endregion
#region Healing Methods
/// <summary>
/// Heal the entity by a specified amount.
/// </summary>
public void Heal(float amount)
{
if (amount <= 0) return;
if (_isNetworked)
{
HealServerRpc(amount);
}
else
{
HealLocal(amount);
}
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void HealServerRpc(float amount)
{
bool wasAlive = _networkHealth.Value > 0;
float newHealth = Mathf.Min(maxHealth, _networkHealth.Value + amount);
_networkHealth.Value = newHealth;
NotifyHealClientRpc(amount, !wasAlive && newHealth > 0);
}
[ClientRpc]
private void NotifyHealClientRpc(float amount, bool revived)
{
OnHealed?.Invoke(amount);
if (revived)
{
OnRevived?.Invoke();
}
}
private void HealLocal(float amount)
{
bool wasAlive = _localHealth > 0;
_localHealth = Mathf.Min(maxHealth, _localHealth + amount);
OnHealthChanged?.Invoke(_localHealth, maxHealth);
OnHealed?.Invoke(amount);
if (!wasAlive && _localHealth > 0)
{
OnRevived?.Invoke();
}
}
/// <summary>
/// Fully restore health to maximum.
/// </summary>
public void HealToFull()
{
Heal(maxHealth - CurrentHealth);
}
#endregion
#region Health Modification
/// <summary>
/// Set the maximum health value.
/// </summary>
/// <param name="newMax">New maximum health</param>
/// <param name="healToMax">If true, also sets current health to the new max</param>
public void SetMaxHealth(float newMax, bool healToMax = false)
{
maxHealth = Mathf.Max(1, newMax);
if (healToMax)
{
if (_isNetworked && IsServer)
{
_networkHealth.Value = maxHealth;
}
else if (!_isNetworked)
{
_localHealth = maxHealth;
OnHealthChanged?.Invoke(_localHealth, maxHealth);
}
}
else
{
// Clamp current health to new max
if (_isNetworked && IsServer)
{
_networkHealth.Value = Mathf.Min(_networkHealth.Value, maxHealth);
}
else if (!_isNetworked)
{
_localHealth = Mathf.Min(_localHealth, maxHealth);
OnHealthChanged?.Invoke(_localHealth, maxHealth);
}
}
}
/// <summary>
/// Directly set the current health (use with caution).
/// </summary>
public void SetHealth(float health)
{
float newHealth = Mathf.Clamp(health, 0, maxHealth);
if (_isNetworked && IsServer)
{
_networkHealth.Value = newHealth;
}
else if (!_isNetworked)
{
_localHealth = newHealth;
OnHealthChanged?.Invoke(_localHealth, maxHealth);
}
}
#endregion
#region Death Handling
private void HandleDeathServer()
{
NotifyDeathClientRpc();
if (destroyOnDeath)
{
if (destroyDelay > 0)
{
// Schedule despawn
Invoke(nameof(DespawnObject), destroyDelay);
}
else
{
DespawnObject();
}
}
}
private void DespawnObject()
{
if (TryGetComponent<NetworkObject>(out var netObj))
{
netObj.Despawn();
}
}
[ClientRpc]
private void NotifyDeathClientRpc()
{
OnDeath?.Invoke();
}
private void HandleDeathLocal()
{
OnDeath?.Invoke();
if (destroyOnDeath)
{
if (destroyDelay > 0)
{
Destroy(gameObject, destroyDelay);
}
else
{
Destroy(gameObject);
}
}
}
#endregion
#region Network Health Change Handler
private void HandleNetworkHealthChanged(float previousValue, float newValue)
{
OnHealthChanged?.Invoke(newValue, maxHealth);
// Check for death on clients (server handles it in ApplyDamageServerRpc)
if (previousValue > 0 && newValue <= 0 && !IsServer)
{
// Death event already sent via ClientRpc, just update local state
}
}
#endregion
#region Utility Methods
/// <summary>
/// Get health as a normalized value (0-1).
/// </summary>
public float GetHealthNormalized()
{
return maxHealth > 0 ? CurrentHealth / maxHealth : 0f;
}
/// <summary>
/// Check if health is below a certain percentage.
/// </summary>
public bool IsHealthBelow(float percentage)
{
return GetHealthNormalized() < percentage;
}
/// <summary>
/// Check if at full health.
/// </summary>
public bool IsAtFullHealth()
{
return CurrentHealth >= maxHealth;
}
/// <summary>
/// Kill the entity instantly.
/// </summary>
public void Kill()
{
TakeDamage(new DamageInfo(CurrentHealth + 1, DamageType.True));
}
#endregion
}

View File

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

View File

@@ -1,5 +1,141 @@
// IDamageable.cs
using UnityEngine;
/// <summary>
/// Types of damage that can be dealt.
/// Used for damage resistance/vulnerability systems.
/// </summary>
public enum DamageType
{
Physical,
Magical,
Mining,
Environmental,
True // Ignores resistances
}
/// <summary>
/// Contains all information about a damage event.
/// </summary>
[System.Serializable]
public struct DamageInfo
{
/// <summary>
/// Amount of damage to deal.
/// </summary>
public float Amount;
/// <summary>
/// Type of damage being dealt.
/// </summary>
public DamageType Type;
/// <summary>
/// The GameObject that caused the damage (can be null).
/// </summary>
public GameObject Source;
/// <summary>
/// World position where the damage was applied.
/// </summary>
public Vector3 HitPoint;
/// <summary>
/// Direction the damage came from (for knockback, effects, etc.).
/// </summary>
public Vector3 HitDirection;
/// <summary>
/// Create a simple damage info with just an amount.
/// </summary>
public DamageInfo(float amount)
{
Amount = amount;
Type = DamageType.Physical;
Source = null;
HitPoint = Vector3.zero;
HitDirection = Vector3.zero;
}
/// <summary>
/// Create damage info with amount and type.
/// </summary>
public DamageInfo(float amount, DamageType type)
{
Amount = amount;
Type = type;
Source = null;
HitPoint = Vector3.zero;
HitDirection = Vector3.zero;
}
/// <summary>
/// Create full damage info with all parameters.
/// </summary>
public DamageInfo(float amount, DamageType type, GameObject source,
Vector3 hitPoint = default, Vector3 hitDirection = default)
{
Amount = amount;
Type = type;
Source = source;
HitPoint = hitPoint;
HitDirection = hitDirection;
}
/// <summary>
/// Create mining damage.
/// </summary>
public static DamageInfo Mining(float amount, GameObject source = null)
{
return new DamageInfo(amount, DamageType.Mining, source);
}
/// <summary>
/// Create physical damage with source and direction.
/// </summary>
public static DamageInfo Physical(float amount, GameObject source, Vector3 hitPoint, Vector3 direction)
{
return new DamageInfo(amount, DamageType.Physical, source, hitPoint, direction);
}
}
/// <summary>
/// Interface for any object that can take damage.
/// </summary>
public interface IDamageable
{
/// <summary>
/// Simple damage method for backwards compatibility.
/// </summary>
/// <param name="amount">Amount of damage to deal</param>
void TakeDamage(float amount);
}
/// <summary>
/// Enhanced damage method with full context.
/// Default implementation calls the simple TakeDamage for backwards compatibility.
/// </summary>
/// <param name="damageInfo">Full damage information</param>
void TakeDamage(DamageInfo damageInfo)
{
TakeDamage(damageInfo.Amount);
}
/// <summary>
/// Current health value.
/// </summary>
float CurrentHealth { get; }
/// <summary>
/// Maximum health value.
/// </summary>
float MaxHealth { get; }
/// <summary>
/// Whether this object is still alive.
/// </summary>
bool IsAlive { get; }
/// <summary>
/// Health as a percentage (0-1).
/// </summary>
float HealthPercent => MaxHealth > 0 ? CurrentHealth / MaxHealth : 0f;
}

View File

@@ -1,23 +1,118 @@
using UnityEngine;
/// <summary>
/// ScriptableObject defining item properties.
/// Implements IUsableItem and IEquippableItem for extensibility.
/// </summary>
[CreateAssetMenu(fileName = "New Item", menuName = "Inventory/Item")]
public class ItemData : ScriptableObject
public class ItemData : ScriptableObject, IUsableItem, IEquippableItem
{
[Header("Basic Info")]
public int itemID;
public string itemName;
public Sprite icon;
public float weight; // 아이템 개당 무게
public int maxStack = 99; // 최대 중첩 개수
public Sprite icon;
[TextArea] public string description;
[Header("Stack & Weight")]
public float weight = 1f;
public int maxStack = 99;
[Header("Visual Source")]
public GameObject originalBlockPrefab; // 이제 이것만 있으면 됩니다!
[Tooltip("Original prefab for dropped item visuals")]
public GameObject originalBlockPrefab;
[Header("Tool Settings")]
public bool isTool; // 도구 여부
public PlayerActionData toolAction; // 이 도구를 들었을 때 나갈 액션 (예: MiningActionData)
[Header("Item Behavior")]
[Tooltip("Defines what happens when the item is used")]
public ItemBehavior behavior;
[Header("Visual Settings")]
public GameObject toolPrefab; // 캐릭터 손에 스폰될 3D 프리팹
public Vector3 equipPositionOffset; // 손 위치 미세 조정
public Vector3 equipRotationOffset; // 손 회전 미세 조정
}
[Header("Equipment Settings")]
[Tooltip("Whether this item can be equipped (shows in hand)")]
public bool isEquippable;
[Tooltip("Prefab spawned in player's hand when equipped")]
public GameObject equipmentPrefab;
public Vector3 equipPositionOffset;
public Vector3 equipRotationOffset;
[Tooltip("Name of the transform to attach equipment to")]
public string attachmentPointName = "ToolAnchor";
#region IUsableItem Implementation
public bool IsConsumable => behavior != null && behavior.IsConsumable;
public bool CanUse(GameObject user, GameObject target)
{
return behavior != null && behavior.CanUse(user, target);
}
public void Use(GameObject user, GameObject target)
{
if (behavior != null)
{
behavior.Use(user, target);
}
}
public ActionDescriptor GetUseAction()
{
return behavior?.GetActionDescriptor();
}
#endregion
#region IEquippableItem Implementation
public GameObject GetEquipmentPrefab() => equipmentPrefab;
public Vector3 GetPositionOffset() => equipPositionOffset;
public Vector3 GetRotationOffset() => equipRotationOffset;
public string GetAttachmentPointName() => attachmentPointName;
public Transform FindAttachmentPoint(GameObject user)
{
if (string.IsNullOrEmpty(attachmentPointName))
return user.transform;
var transforms = user.GetComponentsInChildren<Transform>();
foreach (var t in transforms)
{
if (t.name == attachmentPointName)
return t;
}
return user.transform;
}
#endregion
#region Utility Properties
/// <summary>
/// Check if this item has any usable behavior.
/// </summary>
public bool HasBehavior => behavior != null;
/// <summary>
/// Check if this item can be equipped.
/// </summary>
public bool CanBeEquipped => isEquippable && equipmentPrefab != null;
#endregion
#region Utility Methods
/// <summary>
/// Get display name for UI.
/// </summary>
public string GetDisplayName() => string.IsNullOrEmpty(itemName) ? name : itemName;
/// <summary>
/// Get description for UI.
/// </summary>
public string GetDescription() => description;
/// <summary>
/// Calculate total weight for a stack.
/// </summary>
public float GetStackWeight(int count) => weight * count;
#endregion
}