코드 리팩토링
재사용성 및 확장성을 고려하여 코드 전반을 리팩토링함
This commit is contained in:
@@ -1,33 +1,66 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
|
||||
public class EnemyHealth : MonoBehaviour, IDamageable
|
||||
/// <summary>
|
||||
/// Enemy health handler.
|
||||
/// Uses HealthComponent for health management.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(HealthComponent))]
|
||||
public class EnemyHealth : MonoBehaviour
|
||||
{
|
||||
public float maxHealth = 50f;
|
||||
private float _currentHealth;
|
||||
private HealthComponent _health;
|
||||
|
||||
void Start()
|
||||
/// <summary>
|
||||
/// Event fired when this enemy dies.
|
||||
/// </summary>
|
||||
public event Action OnEnemyDeath;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_currentHealth = maxHealth;
|
||||
_health = GetComponent<HealthComponent>();
|
||||
|
||||
// Subscribe to health component events
|
||||
_health.OnDamaged += HandleDamaged;
|
||||
_health.OnDeath += HandleDeath;
|
||||
}
|
||||
|
||||
public void TakeDamage(float damage)
|
||||
void OnDestroy()
|
||||
{
|
||||
_currentHealth -= damage;
|
||||
Debug.Log($"{gameObject.name} 남은 체력: {_currentHealth}");
|
||||
|
||||
// 데미지 입었을 때 반짝이는 효과를 여기서 호출해도 좋습니다.
|
||||
// 예: GetComponent<EnemyAttack>().StartFlash();
|
||||
|
||||
if (_currentHealth <= 0)
|
||||
if (_health != null)
|
||||
{
|
||||
Die();
|
||||
_health.OnDamaged -= HandleDamaged;
|
||||
_health.OnDeath -= HandleDeath;
|
||||
}
|
||||
}
|
||||
|
||||
private void Die()
|
||||
private void HandleDamaged(DamageInfo info)
|
||||
{
|
||||
Debug.Log($"{gameObject.name} 사망!");
|
||||
// 여기서 파티클 생성이나 점수 추가 등을 처리합니다.
|
||||
Debug.Log($"{gameObject.name} took {info.Amount} damage. Remaining: {_health.CurrentHealth}");
|
||||
}
|
||||
|
||||
private void HandleDeath()
|
||||
{
|
||||
Debug.Log($"{gameObject.name} died!");
|
||||
|
||||
// Fire event for external listeners (score system, spawner, etc.)
|
||||
OnEnemyDeath?.Invoke();
|
||||
|
||||
// Destroy the enemy
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the health component for direct access if needed.
|
||||
/// </summary>
|
||||
public HealthComponent Health => _health;
|
||||
|
||||
/// <summary>
|
||||
/// Get the current health (convenience property).
|
||||
/// </summary>
|
||||
public float CurrentHealth => _health != null ? _health.CurrentHealth : 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Get the max health (convenience property).
|
||||
/// </summary>
|
||||
public float MaxHealth => _health != null ? _health.MaxHealth : 0f;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
384
Assets/Scripts/GameBase/HealthComponent.cs
Normal file
384
Assets/Scripts/GameBase/HealthComponent.cs
Normal 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
|
||||
}
|
||||
2
Assets/Scripts/GameBase/HealthComponent.cs.meta
Normal file
2
Assets/Scripts/GameBase/HealthComponent.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a4b9c07450e6c9c4b8c741b633a2702e
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,172 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Types of interactions available in the game.
|
||||
/// </summary>
|
||||
public enum InteractionType
|
||||
{
|
||||
Generic,
|
||||
Pickup,
|
||||
Use,
|
||||
Talk,
|
||||
Enter,
|
||||
Build,
|
||||
Open,
|
||||
Activate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains preview information about an interaction for UI display.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct InteractionPreview
|
||||
{
|
||||
/// <summary>
|
||||
/// The action verb (e.g., "Pick up", "Enter", "Use").
|
||||
/// </summary>
|
||||
public string ActionVerb;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the target object (e.g., "Iron Ore", "Tunnel").
|
||||
/// </summary>
|
||||
public string TargetName;
|
||||
|
||||
/// <summary>
|
||||
/// Optional icon to display in UI.
|
||||
/// </summary>
|
||||
public Sprite Icon;
|
||||
|
||||
/// <summary>
|
||||
/// The type of interaction.
|
||||
/// </summary>
|
||||
public InteractionType Type;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this interaction requires holding the button.
|
||||
/// </summary>
|
||||
public bool RequiresHold;
|
||||
|
||||
/// <summary>
|
||||
/// Duration to hold if RequiresHold is true.
|
||||
/// </summary>
|
||||
public float HoldDuration;
|
||||
|
||||
/// <summary>
|
||||
/// Input hint to display (e.g., "[F]", "[E]").
|
||||
/// </summary>
|
||||
public string InputHint;
|
||||
|
||||
/// <summary>
|
||||
/// Create a simple interaction preview.
|
||||
/// </summary>
|
||||
public static InteractionPreview Simple(string actionVerb, string targetName,
|
||||
InteractionType type = InteractionType.Generic)
|
||||
{
|
||||
return new InteractionPreview
|
||||
{
|
||||
ActionVerb = actionVerb,
|
||||
TargetName = targetName,
|
||||
Type = type,
|
||||
RequiresHold = false,
|
||||
HoldDuration = 0f,
|
||||
InputHint = "[F]"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a pickup interaction preview.
|
||||
/// </summary>
|
||||
public static InteractionPreview Pickup(string itemName, Sprite icon = null)
|
||||
{
|
||||
return new InteractionPreview
|
||||
{
|
||||
ActionVerb = "Pick up",
|
||||
TargetName = itemName,
|
||||
Icon = icon,
|
||||
Type = InteractionType.Pickup,
|
||||
RequiresHold = false,
|
||||
InputHint = "[F]"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an enter/use interaction preview.
|
||||
/// </summary>
|
||||
public static InteractionPreview Enter(string targetName)
|
||||
{
|
||||
return new InteractionPreview
|
||||
{
|
||||
ActionVerb = "Enter",
|
||||
TargetName = targetName,
|
||||
Type = InteractionType.Enter,
|
||||
RequiresHold = false,
|
||||
InputHint = "[F]"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a hold-to-interact preview.
|
||||
/// </summary>
|
||||
public static InteractionPreview Hold(string actionVerb, string targetName, float duration,
|
||||
InteractionType type = InteractionType.Use)
|
||||
{
|
||||
return new InteractionPreview
|
||||
{
|
||||
ActionVerb = actionVerb,
|
||||
TargetName = targetName,
|
||||
Type = type,
|
||||
RequiresHold = true,
|
||||
HoldDuration = duration,
|
||||
InputHint = "[Hold F]"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the full display string (e.g., "[F] Pick up Iron Ore").
|
||||
/// </summary>
|
||||
public string GetDisplayString()
|
||||
{
|
||||
return $"{InputHint} {ActionVerb} {TargetName}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for objects that can be interacted with by the player.
|
||||
/// </summary>
|
||||
public interface IInteractable
|
||||
{
|
||||
/// <summary>
|
||||
/// Perform the interaction.
|
||||
/// </summary>
|
||||
/// <param name="interactor">The GameObject performing the interaction (usually the player)</param>
|
||||
void Interact(GameObject interactor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get preview information about this interaction for UI display.
|
||||
/// Default implementation returns a generic preview.
|
||||
/// </summary>
|
||||
InteractionPreview GetInteractionPreview()
|
||||
{
|
||||
return InteractionPreview.Simple("Interact", "Object");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if this object can currently be interacted with.
|
||||
/// Default implementation always returns true.
|
||||
/// </summary>
|
||||
/// <param name="interactor">The GameObject attempting to interact</param>
|
||||
/// <returns>True if interaction is possible</returns>
|
||||
bool CanInteract(GameObject interactor)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the world position of this interactable for distance calculations.
|
||||
/// Default implementation returns zero vector (override in implementation).
|
||||
/// </summary>
|
||||
Vector3 GetInteractionPoint()
|
||||
{
|
||||
return Vector3.zero;
|
||||
}
|
||||
}
|
||||
|
||||
8
Assets/Scripts/Items.meta
Normal file
8
Assets/Scripts/Items.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: afa213ad718d66646ba597d72845441d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
63
Assets/Scripts/Items/IUsableItem.cs
Normal file
63
Assets/Scripts/Items/IUsableItem.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for items that can be used/activated by the player.
|
||||
/// </summary>
|
||||
public interface IUsableItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Check if the item can be used.
|
||||
/// </summary>
|
||||
/// <param name="user">The GameObject using the item (player)</param>
|
||||
/// <param name="target">Optional target for the use action</param>
|
||||
/// <returns>True if the item can be used</returns>
|
||||
bool CanUse(GameObject user, GameObject target);
|
||||
|
||||
/// <summary>
|
||||
/// Use the item.
|
||||
/// </summary>
|
||||
/// <param name="user">The GameObject using the item (player)</param>
|
||||
/// <param name="target">Optional target for the use action</param>
|
||||
void Use(GameObject user, GameObject target);
|
||||
|
||||
/// <summary>
|
||||
/// Get the action descriptor for using this item.
|
||||
/// </summary>
|
||||
ActionDescriptor GetUseAction();
|
||||
|
||||
/// <summary>
|
||||
/// Whether using this item consumes it (reduces stack count).
|
||||
/// </summary>
|
||||
bool IsConsumable { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for items that can be equipped on the player.
|
||||
/// </summary>
|
||||
public interface IEquippableItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the equipment prefab to spawn.
|
||||
/// </summary>
|
||||
GameObject GetEquipmentPrefab();
|
||||
|
||||
/// <summary>
|
||||
/// Get position offset for equipment placement.
|
||||
/// </summary>
|
||||
Vector3 GetPositionOffset();
|
||||
|
||||
/// <summary>
|
||||
/// Get rotation offset for equipment placement.
|
||||
/// </summary>
|
||||
Vector3 GetRotationOffset();
|
||||
|
||||
/// <summary>
|
||||
/// Get the name of the attachment point on the character.
|
||||
/// </summary>
|
||||
string GetAttachmentPointName();
|
||||
|
||||
/// <summary>
|
||||
/// Find the attachment point transform on a user.
|
||||
/// </summary>
|
||||
Transform FindAttachmentPoint(GameObject user);
|
||||
}
|
||||
2
Assets/Scripts/Items/IUsableItem.cs.meta
Normal file
2
Assets/Scripts/Items/IUsableItem.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 686008e086e9d2247b33d5828e0efa5f
|
||||
67
Assets/Scripts/Items/ItemBehavior.cs
Normal file
67
Assets/Scripts/Items/ItemBehavior.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for item behaviors. Allows different items to have
|
||||
/// completely different use effects without modifying ItemData.
|
||||
/// This is the Strategy pattern for item actions.
|
||||
/// </summary>
|
||||
public abstract class ItemBehavior : ScriptableObject
|
||||
{
|
||||
[Header("Basic Settings")]
|
||||
[SerializeField] protected string behaviorName = "Use";
|
||||
[SerializeField] protected float duration = 0.5f;
|
||||
[SerializeField] protected string animTrigger = "Use";
|
||||
[SerializeField] protected float animSpeed = 1f;
|
||||
[SerializeField] protected float impactDelay = 0.2f;
|
||||
[SerializeField] protected bool canRepeat = false;
|
||||
|
||||
[Header("Effects")]
|
||||
[SerializeField] protected AudioClip useSound;
|
||||
[SerializeField] protected GameObject useEffect;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this behavior consumes the item when used.
|
||||
/// </summary>
|
||||
public virtual bool IsConsumable => false;
|
||||
|
||||
/// <summary>
|
||||
/// Check if this behavior can be used with the given user and target.
|
||||
/// </summary>
|
||||
/// <param name="user">The player/entity using the item</param>
|
||||
/// <param name="target">Optional target of the use</param>
|
||||
/// <returns>True if the behavior can be executed</returns>
|
||||
public abstract bool CanUse(GameObject user, GameObject target);
|
||||
|
||||
/// <summary>
|
||||
/// Execute the behavior's effect.
|
||||
/// </summary>
|
||||
/// <param name="user">The player/entity using the item</param>
|
||||
/// <param name="target">Optional target of the use</param>
|
||||
public abstract void Use(GameObject user, GameObject target);
|
||||
|
||||
/// <summary>
|
||||
/// Get the action descriptor for this behavior.
|
||||
/// </summary>
|
||||
public virtual ActionDescriptor GetActionDescriptor()
|
||||
{
|
||||
return new ActionDescriptor
|
||||
{
|
||||
ActionName = behaviorName,
|
||||
Duration = duration,
|
||||
AnimTrigger = animTrigger,
|
||||
AnimSpeed = animSpeed,
|
||||
ImpactDelay = impactDelay,
|
||||
CanRepeat = canRepeat,
|
||||
SoundEffect = useSound,
|
||||
ParticleEffect = useEffect
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a description of why the behavior cannot be used.
|
||||
/// </summary>
|
||||
public virtual string GetBlockedReason(GameObject user, GameObject target)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Items/ItemBehavior.cs.meta
Normal file
2
Assets/Scripts/Items/ItemBehavior.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 73a8d5e271a199f4598ae20f5b20a466
|
||||
@@ -1,98 +1,131 @@
|
||||
using NUnit.Framework.Interfaces;
|
||||
using System.Collections;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
public class MineableBlock : NetworkBehaviour
|
||||
/// <summary>
|
||||
/// A block that can be mined by players.
|
||||
/// Uses HealthComponent for health management and implements IDamageable.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(HealthComponent))]
|
||||
public class MineableBlock : NetworkBehaviour, IDamageable
|
||||
{
|
||||
[Header("Block Stats")]
|
||||
[SerializeField] private int maxHp = 100;
|
||||
// [동기화] 모든 플레이어가 동일한 블록 체력을 보게 함
|
||||
private NetworkVariable<int> _currentHp = new NetworkVariable<int>();
|
||||
|
||||
[Header("Drop Settings")]
|
||||
[SerializeField] private ItemData dropItemData;
|
||||
[SerializeField] private GameObject genericDropPrefab; // 여기에 위에서 만든 'GenericDroppedItem' 프리팹을 넣으세요.
|
||||
[SerializeField] private GameObject genericDropPrefab;
|
||||
|
||||
[Header("Visuals")]
|
||||
private Outline _outline;
|
||||
private Vector3 _originalPos;
|
||||
|
||||
[Header("Shake Settings")]
|
||||
[SerializeField] private float shakeDuration = 0.15f; // 흔들리는 시간
|
||||
[SerializeField] private float shakeMagnitude = 0.1f; // 흔들리는 강도
|
||||
[SerializeField] private float shakeDuration = 0.15f;
|
||||
[SerializeField] private float shakeMagnitude = 0.1f;
|
||||
private Coroutine _shakeCoroutine;
|
||||
|
||||
private Color _originalColor; // 본래의 색상을 저장할 변수
|
||||
private Color _originalColor;
|
||||
private float _lastVisibleTime;
|
||||
private const float VisibilityThreshold = 0.25f;
|
||||
|
||||
[Header("Fog Settings")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float darkIntensity = 0.2f; // 안개 속에서 얼마나 어두워질지 (0: 완전 검정, 1: 원본)
|
||||
[SerializeField] private float darkIntensity = 0.2f;
|
||||
private MaterialPropertyBlock _propBlock;
|
||||
|
||||
private NetworkVariable<bool> isDiscovered = new NetworkVariable<bool>(false);
|
||||
private MeshRenderer _renderer;
|
||||
private HealthComponent _health;
|
||||
|
||||
#region IDamageable Implementation
|
||||
|
||||
public float CurrentHealth => _health != null ? _health.CurrentHealth : 0f;
|
||||
public float MaxHealth => _health != null ? _health.MaxHealth : 0f;
|
||||
public bool IsAlive => _health != null && _health.IsAlive;
|
||||
|
||||
public void TakeDamage(float amount)
|
||||
{
|
||||
TakeDamage(new DamageInfo(amount, DamageType.Mining));
|
||||
}
|
||||
|
||||
public void TakeDamage(DamageInfo damageInfo)
|
||||
{
|
||||
if (_health != null)
|
||||
{
|
||||
_health.TakeDamage(damageInfo);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_health = GetComponent<HealthComponent>();
|
||||
_renderer = GetComponentInChildren<MeshRenderer>();
|
||||
_propBlock = new MaterialPropertyBlock();
|
||||
|
||||
// 시작 시에는 보이지 않게 설정
|
||||
// Start hidden
|
||||
if (_renderer != null) _renderer.enabled = false;
|
||||
|
||||
_originalColor = _renderer.sharedMaterial.HasProperty("_BaseColor")
|
||||
if (_renderer != null && _renderer.sharedMaterial != null)
|
||||
{
|
||||
_originalColor = _renderer.sharedMaterial.HasProperty("_BaseColor")
|
||||
? _renderer.sharedMaterial.GetColor("_BaseColor")
|
||||
: _renderer.sharedMaterial.GetColor("_Color");
|
||||
}
|
||||
|
||||
_renderer.enabled = false;
|
||||
|
||||
// 해당 오브젝트 혹은 자식에게서 Outline 컴포넌트를 찾습니다.
|
||||
// Find outline component
|
||||
_outline = GetComponentInChildren<Outline>();
|
||||
_originalPos = transform.localPosition; // 로컬 위치 저장
|
||||
_originalPos = transform.localPosition;
|
||||
|
||||
if (_outline != null)
|
||||
{
|
||||
// 게임 시작 시 하이라이트는 꺼둡니다.
|
||||
_outline.enabled = false;
|
||||
}
|
||||
else
|
||||
|
||||
// Subscribe to health events
|
||||
if (_health != null)
|
||||
{
|
||||
Debug.LogWarning($"{gameObject.name}: QuickOutline 에셋의 Outline 컴포넌트를 찾을 수 없습니다.");
|
||||
_health.OnDamaged += HandleDamaged;
|
||||
_health.OnDeath += HandleDeath;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (_health != null)
|
||||
{
|
||||
_health.OnDamaged -= HandleDamaged;
|
||||
_health.OnDeath -= HandleDeath;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (IsServer)
|
||||
{
|
||||
_currentHp.Value = maxHp;
|
||||
}
|
||||
|
||||
// 데이터가 동기화될 때 비주얼 업데이트
|
||||
// Update visuals when discovered state syncs
|
||||
UpdateVisuals(isDiscovered.Value);
|
||||
|
||||
isDiscovered.OnValueChanged += (oldVal, newVal) => {
|
||||
isDiscovered.OnValueChanged += (oldVal, newVal) =>
|
||||
{
|
||||
if (newVal) UpdateState();
|
||||
};
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
// 1. 이미 발견된 블록인지는 서버 변수(isDiscovered)로 확인
|
||||
// 2. 현재 내 위치가 안개에서 벗어났는지 확인 (매우 단순화된 로직)
|
||||
if (!isDiscovered.Value)
|
||||
// Check if block should be discovered based on player distance
|
||||
if (!isDiscovered.Value && NetworkManager.Singleton != null &&
|
||||
NetworkManager.Singleton.LocalClient != null &&
|
||||
NetworkManager.Singleton.LocalClient.PlayerObject != null)
|
||||
{
|
||||
float dist = Vector3.Distance(transform.position, NetworkManager.Singleton.LocalClient.PlayerObject.transform.position);
|
||||
if (dist < FogOfWarManager.Instance.revealRadius)
|
||||
float dist = Vector3.Distance(transform.position,
|
||||
NetworkManager.Singleton.LocalClient.PlayerObject.transform.position);
|
||||
|
||||
if (FogOfWarManager.Instance != null && dist < FogOfWarManager.Instance.revealRadius)
|
||||
{
|
||||
// 서버에 "나 발견됐어!"라고 보고
|
||||
RequestRevealServerRpc();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 비주얼 업데이트: 발견된 적이 있을 때만 렌더러를 켬
|
||||
// Update renderer visibility
|
||||
if (_renderer != null)
|
||||
{
|
||||
_renderer.enabled = isDiscovered.Value;
|
||||
@@ -109,64 +142,64 @@ public class MineableBlock : NetworkBehaviour
|
||||
|
||||
private void UpdateState()
|
||||
{
|
||||
if (_renderer == null) return;
|
||||
if (_renderer == null || !_renderer.enabled) return;
|
||||
|
||||
// 2. 현재 시야 안에 있는지 판단합니다.
|
||||
bool isCurrentlyVisible = (Time.time - _lastVisibleTime) < VisibilityThreshold;
|
||||
|
||||
// 3. 상태에 따라 색상과 렌더러 상태를 결정합니다.
|
||||
if (_renderer.enabled == false) return;
|
||||
|
||||
_renderer.GetPropertyBlock(_propBlock);
|
||||
// 2. 시야 내에 있으면 원본 색상(_originalColor), 멀어지면 어둡게 만든 색상을 적용합니다.
|
||||
Color targetColor = isCurrentlyVisible ? _originalColor : _originalColor * darkIntensity;
|
||||
_propBlock.SetColor("_BaseColor", targetColor);
|
||||
|
||||
_renderer.SetPropertyBlock(_propBlock);
|
||||
}
|
||||
|
||||
public void RevealBlock() // 서버에서 호출
|
||||
/// <summary>
|
||||
/// Reveal this block (called by server).
|
||||
/// </summary>
|
||||
public void RevealBlock()
|
||||
{
|
||||
if (IsServer && !isDiscovered.Value) isDiscovered.Value = true;
|
||||
if (IsServer && !isDiscovered.Value)
|
||||
{
|
||||
isDiscovered.Value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 플레이어가 주변을 훑을 때 호출해줄 함수
|
||||
/// <summary>
|
||||
/// Update local visibility for fog of war.
|
||||
/// </summary>
|
||||
public void UpdateLocalVisibility()
|
||||
{
|
||||
_lastVisibleTime = Time.time;
|
||||
}
|
||||
|
||||
// 서버에서만 대미지를 처리하도록 제한
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
||||
public void TakeDamageRpc(int damageAmount)
|
||||
private void HandleDamaged(DamageInfo info)
|
||||
{
|
||||
if (_currentHp.Value <= 0) return;
|
||||
// Play hit effect on all clients
|
||||
PlayHitEffectClientRpc();
|
||||
}
|
||||
|
||||
_currentHp.Value -= damageAmount;
|
||||
|
||||
if (_currentHp.Value <= 0)
|
||||
private void HandleDeath()
|
||||
{
|
||||
if (IsServer)
|
||||
{
|
||||
DestroyBlock();
|
||||
DropItem();
|
||||
GetComponent<NetworkObject>().Despawn();
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyBlock()
|
||||
{
|
||||
DropItem();
|
||||
// 2. 서버에서 네트워크 오브젝트 제거 (모든 클라이언트에서 사라짐)
|
||||
GetComponent<NetworkObject>().Despawn();
|
||||
}
|
||||
|
||||
// 하이라이트 상태를 설정하는 공개 메서드
|
||||
/// <summary>
|
||||
/// Set highlight state for targeting feedback.
|
||||
/// </summary>
|
||||
public void SetHighlight(bool isOn)
|
||||
{
|
||||
if (_outline == null) return;
|
||||
|
||||
// 외곽선 컴포넌트 활성화/비활성화
|
||||
_outline.enabled = isOn;
|
||||
if (_outline != null)
|
||||
{
|
||||
_outline.enabled = isOn;
|
||||
}
|
||||
}
|
||||
|
||||
// 서버에서 호출하여 모든 클라이언트에게 흔들림 지시
|
||||
/// <summary>
|
||||
/// Play hit visual effect on all clients.
|
||||
/// </summary>
|
||||
[ClientRpc]
|
||||
public void PlayHitEffectClientRpc()
|
||||
{
|
||||
@@ -181,14 +214,9 @@ public class MineableBlock : NetworkBehaviour
|
||||
{
|
||||
Vector3 randomOffset = Random.insideUnitSphere * shakeMagnitude;
|
||||
transform.localPosition = _originalPos + randomOffset;
|
||||
|
||||
// 좌표가 실제로 바뀌고 있는지 로그 출력
|
||||
// Debug.Log($"현재 좌표: {transform.localPosition}");
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
transform.localPosition = _originalPos;
|
||||
}
|
||||
|
||||
@@ -196,13 +224,11 @@ public class MineableBlock : NetworkBehaviour
|
||||
{
|
||||
if (!IsServer || dropItemData == null || genericDropPrefab == null) return;
|
||||
|
||||
// 원본 블록이 아니라 '범용 컨테이너'를 소환합니다.
|
||||
GameObject dropObj = Instantiate(genericDropPrefab, transform.position + Vector3.up * 0.5f, Quaternion.identity);
|
||||
|
||||
NetworkObject netObj = dropObj.GetComponent<NetworkObject>();
|
||||
netObj.Spawn();
|
||||
|
||||
// 소환된 컨테이너에 "너는 어떤 아이템의 모양을 따라해야 해"라고 알려줍니다.
|
||||
if (dropObj.TryGetComponent<DroppedItem>(out var droppedItem))
|
||||
{
|
||||
droppedItem.Initialize(dropItemData.itemID);
|
||||
@@ -212,7 +238,11 @@ public class MineableBlock : NetworkBehaviour
|
||||
private void UpdateVisuals(bool discovered)
|
||||
{
|
||||
if (_renderer != null) _renderer.enabled = discovered;
|
||||
// 발견되지 않은 블록은 아웃라인도 표시되지 않아야 함
|
||||
if (!discovered && _outline != null) _outline.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the health component for direct access if needed.
|
||||
/// </summary>
|
||||
public HealthComponent Health => _health;
|
||||
}
|
||||
|
||||
20
Assets/Scripts/Player/BehaviorActionData.cs
Normal file
20
Assets/Scripts/Player/BehaviorActionData.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Bridge class that wraps ItemBehavior for use with PlayerActionHandler.
|
||||
/// This allows the new behavior system to work with the existing action handler.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Actions/Behavior Action")]
|
||||
public class BehaviorActionData : PlayerActionData
|
||||
{
|
||||
[HideInInspector]
|
||||
public ItemBehavior behavior;
|
||||
|
||||
public override void ExecuteEffect(GameObject performer, GameObject target)
|
||||
{
|
||||
if (behavior != null)
|
||||
{
|
||||
behavior.Use(performer, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Player/BehaviorActionData.cs.meta
Normal file
2
Assets/Scripts/Player/BehaviorActionData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a373ecb07ad66848923d4a455b6d236
|
||||
50
Assets/Scripts/Player/ConsumableBehavior.cs
Normal file
50
Assets/Scripts/Player/ConsumableBehavior.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Consumable behavior for healing items, food, etc.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Items/Behaviors/Consumable Behavior")]
|
||||
public class ConsumableBehavior : ItemBehavior
|
||||
{
|
||||
[Header("Consumable Settings")]
|
||||
[SerializeField] private float healAmount = 20f;
|
||||
[SerializeField] private float staminaRestore = 0f;
|
||||
|
||||
public override bool IsConsumable => true;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Consume";
|
||||
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Consume";
|
||||
canRepeat = false;
|
||||
}
|
||||
|
||||
public override bool CanUse(GameObject user, GameObject target)
|
||||
{
|
||||
// Can use if user has a health component that isn't at full health
|
||||
var health = user.GetComponent<HealthComponent>();
|
||||
if (health == null) return false;
|
||||
|
||||
// Can use if not at full health (healing) or if it restores stamina
|
||||
return !health.IsAtFullHealth() || staminaRestore > 0;
|
||||
}
|
||||
|
||||
public override void Use(GameObject user, GameObject target)
|
||||
{
|
||||
var health = user.GetComponent<HealthComponent>();
|
||||
if (health != null && healAmount > 0)
|
||||
{
|
||||
health.Heal(healAmount);
|
||||
}
|
||||
|
||||
// Stamina restoration would go here when stamina system is implemented
|
||||
}
|
||||
|
||||
public override string GetBlockedReason(GameObject user, GameObject target)
|
||||
{
|
||||
var health = user.GetComponent<HealthComponent>();
|
||||
if (health == null) return "Cannot use this item";
|
||||
if (health.IsAtFullHealth() && staminaRestore <= 0) return "Already at full health";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Player/ConsumableBehavior.cs.meta
Normal file
2
Assets/Scripts/Player/ConsumableBehavior.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9c4b3ac4b03db34fa97481232baadfe
|
||||
230
Assets/Scripts/Player/EquipmentSlot.cs
Normal file
230
Assets/Scripts/Player/EquipmentSlot.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Types of equipment slots available.
|
||||
/// </summary>
|
||||
public enum EquipmentSlotType
|
||||
{
|
||||
MainHand,
|
||||
OffHand,
|
||||
Head,
|
||||
Body,
|
||||
Back,
|
||||
Accessory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single equipment slot that can hold an equipped item.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class EquipmentSlot
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of this equipment slot.
|
||||
/// </summary>
|
||||
public EquipmentSlotType SlotType;
|
||||
|
||||
/// <summary>
|
||||
/// Transform where equipment is attached.
|
||||
/// </summary>
|
||||
public Transform AttachPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Currently spawned equipment instance.
|
||||
/// </summary>
|
||||
[NonSerialized]
|
||||
public GameObject CurrentEquipment;
|
||||
|
||||
/// <summary>
|
||||
/// Currently equipped item data.
|
||||
/// </summary>
|
||||
[NonSerialized]
|
||||
public ItemData EquippedItem;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when equipment changes.
|
||||
/// </summary>
|
||||
public event Action<EquipmentSlot, ItemData> OnEquipmentChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Equip an item to this slot.
|
||||
/// </summary>
|
||||
/// <param name="item">Item to equip (or null to unequip)</param>
|
||||
public void Equip(ItemData item)
|
||||
{
|
||||
// Remove current equipment
|
||||
Unequip();
|
||||
|
||||
if (item == null) return;
|
||||
|
||||
// Check if item can be equipped
|
||||
if (!item.CanBeEquipped) return;
|
||||
|
||||
EquippedItem = item;
|
||||
|
||||
// Spawn equipment visual
|
||||
var prefab = item.GetEquipmentPrefab();
|
||||
if (prefab != null && AttachPoint != null)
|
||||
{
|
||||
CurrentEquipment = UnityEngine.Object.Instantiate(prefab, AttachPoint);
|
||||
CurrentEquipment.transform.localPosition = item.GetPositionOffset();
|
||||
CurrentEquipment.transform.localRotation = Quaternion.Euler(item.GetRotationOffset());
|
||||
}
|
||||
|
||||
OnEquipmentChanged?.Invoke(this, item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equip using IEquippableItem interface (more generic).
|
||||
/// </summary>
|
||||
/// <param name="equippable">Equippable item</param>
|
||||
/// <param name="user">The user equipping the item</param>
|
||||
public void Equip(IEquippableItem equippable, GameObject user)
|
||||
{
|
||||
Unequip();
|
||||
|
||||
if (equippable == null) return;
|
||||
|
||||
var prefab = equippable.GetEquipmentPrefab();
|
||||
if (prefab == null) return;
|
||||
|
||||
// Find or use the configured attach point
|
||||
Transform attachTo = AttachPoint;
|
||||
if (attachTo == null && user != null)
|
||||
{
|
||||
attachTo = equippable.FindAttachmentPoint(user);
|
||||
}
|
||||
|
||||
if (attachTo != null)
|
||||
{
|
||||
CurrentEquipment = UnityEngine.Object.Instantiate(prefab, attachTo);
|
||||
CurrentEquipment.transform.localPosition = equippable.GetPositionOffset();
|
||||
CurrentEquipment.transform.localRotation = Quaternion.Euler(equippable.GetRotationOffset());
|
||||
}
|
||||
|
||||
OnEquipmentChanged?.Invoke(this, EquippedItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unequip the current item.
|
||||
/// </summary>
|
||||
public void Unequip()
|
||||
{
|
||||
if (CurrentEquipment != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(CurrentEquipment);
|
||||
CurrentEquipment = null;
|
||||
}
|
||||
|
||||
var previousItem = EquippedItem;
|
||||
EquippedItem = null;
|
||||
|
||||
if (previousItem != null)
|
||||
{
|
||||
OnEquipmentChanged?.Invoke(this, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if this slot has equipment.
|
||||
/// </summary>
|
||||
public bool HasEquipment => CurrentEquipment != null || EquippedItem != null;
|
||||
|
||||
/// <summary>
|
||||
/// Check if a specific item can be equipped in this slot.
|
||||
/// </summary>
|
||||
public bool CanEquip(ItemData item)
|
||||
{
|
||||
if (item == null) return true; // Can always "unequip"
|
||||
return item.CanBeEquipped;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages multiple equipment slots for a character.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class EquipmentManager
|
||||
{
|
||||
[SerializeField]
|
||||
private EquipmentSlot[] _slots;
|
||||
|
||||
/// <summary>
|
||||
/// All equipment slots.
|
||||
/// </summary>
|
||||
public EquipmentSlot[] Slots => _slots;
|
||||
|
||||
/// <summary>
|
||||
/// Get a slot by type.
|
||||
/// </summary>
|
||||
public EquipmentSlot GetSlot(EquipmentSlotType type)
|
||||
{
|
||||
if (_slots == null) return null;
|
||||
|
||||
foreach (var slot in _slots)
|
||||
{
|
||||
if (slot.SlotType == type)
|
||||
return slot;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equip an item to the appropriate slot.
|
||||
/// </summary>
|
||||
public bool TryEquip(ItemData item, EquipmentSlotType preferredSlot = EquipmentSlotType.MainHand)
|
||||
{
|
||||
var slot = GetSlot(preferredSlot);
|
||||
if (slot != null && slot.CanEquip(item))
|
||||
{
|
||||
slot.Equip(item);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unequip all slots.
|
||||
/// </summary>
|
||||
public void UnequipAll()
|
||||
{
|
||||
if (_slots == null) return;
|
||||
|
||||
foreach (var slot in _slots)
|
||||
{
|
||||
slot.Unequip();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize slots with attach points found on the character.
|
||||
/// </summary>
|
||||
public void Initialize(GameObject character, params (EquipmentSlotType type, string attachPointName)[] slotConfigs)
|
||||
{
|
||||
_slots = new EquipmentSlot[slotConfigs.Length];
|
||||
|
||||
for (int i = 0; i < slotConfigs.Length; i++)
|
||||
{
|
||||
var config = slotConfigs[i];
|
||||
Transform attachPoint = null;
|
||||
|
||||
// Find attach point
|
||||
var transforms = character.GetComponentsInChildren<Transform>();
|
||||
foreach (var t in transforms)
|
||||
{
|
||||
if (t.name == config.attachPointName)
|
||||
{
|
||||
attachPoint = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_slots[i] = new EquipmentSlot
|
||||
{
|
||||
SlotType = config.type,
|
||||
AttachPoint = attachPoint
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Player/EquipmentSlot.cs.meta
Normal file
2
Assets/Scripts/Player/EquipmentSlot.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2bee3e86fbe00446b94cf38066b8a81
|
||||
@@ -1,13 +1,171 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Requirement for performing an action (item cost, etc.).
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct ActionRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Item ID required (use -1 if no item required).
|
||||
/// </summary>
|
||||
public int ItemID;
|
||||
|
||||
/// <summary>
|
||||
/// Amount of the item required.
|
||||
/// </summary>
|
||||
public int Amount;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the item is consumed when the action is performed.
|
||||
/// </summary>
|
||||
public bool ConsumeOnUse;
|
||||
|
||||
public ActionRequirement(int itemID, int amount, bool consumeOnUse = true)
|
||||
{
|
||||
ItemID = itemID;
|
||||
Amount = amount;
|
||||
ConsumeOnUse = consumeOnUse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No requirement.
|
||||
/// </summary>
|
||||
public static ActionRequirement None => new ActionRequirement(-1, 0, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes an action that can be performed.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public class ActionDescriptor
|
||||
{
|
||||
public float duration = 0.5f;
|
||||
public string animTrigger = "Interact";
|
||||
// 필요하다면 여기에 사운드 이펙트나 파티클 정보를 추가할 수 있습니다.
|
||||
/// <summary>
|
||||
/// Display name of the action.
|
||||
/// </summary>
|
||||
public string ActionName = "Action";
|
||||
|
||||
/// <summary>
|
||||
/// Total duration of the action in seconds.
|
||||
/// </summary>
|
||||
public float Duration = 0.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Animation trigger name.
|
||||
/// </summary>
|
||||
public string AnimTrigger = "Interact";
|
||||
|
||||
/// <summary>
|
||||
/// Animation playback speed multiplier.
|
||||
/// </summary>
|
||||
public float AnimSpeed = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Time within the animation when the effect occurs (for syncing hit with animation).
|
||||
/// </summary>
|
||||
public float ImpactDelay = 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Sound effect to play.
|
||||
/// </summary>
|
||||
public AudioClip SoundEffect;
|
||||
|
||||
/// <summary>
|
||||
/// Particle effect prefab to spawn.
|
||||
/// </summary>
|
||||
public GameObject ParticleEffect;
|
||||
|
||||
/// <summary>
|
||||
/// Stamina cost to perform this action.
|
||||
/// </summary>
|
||||
public float StaminaCost = 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Item requirements for this action.
|
||||
/// </summary>
|
||||
public ActionRequirement[] ItemRequirements;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action can be repeated by holding the button.
|
||||
/// </summary>
|
||||
public bool CanRepeat = false;
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown time before this action can be performed again.
|
||||
/// </summary>
|
||||
public float Cooldown = 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Create a simple action descriptor.
|
||||
/// </summary>
|
||||
public static ActionDescriptor Simple(string name, float duration, string animTrigger = "Interact")
|
||||
{
|
||||
return new ActionDescriptor
|
||||
{
|
||||
ActionName = name,
|
||||
Duration = duration,
|
||||
AnimTrigger = animTrigger
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an action descriptor for repeatable actions (like mining).
|
||||
/// </summary>
|
||||
public static ActionDescriptor Repeatable(string name, float duration, string animTrigger,
|
||||
float impactDelay, float animSpeed = 1f)
|
||||
{
|
||||
return new ActionDescriptor
|
||||
{
|
||||
ActionName = name,
|
||||
Duration = duration,
|
||||
AnimTrigger = animTrigger,
|
||||
AnimSpeed = animSpeed,
|
||||
ImpactDelay = impactDelay,
|
||||
CanRepeat = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 명세를 제공하는 인터페이스
|
||||
/// <summary>
|
||||
/// Interface for objects that can provide action descriptors.
|
||||
/// Implement this to define what actions can be performed on or with an object.
|
||||
/// </summary>
|
||||
public interface IActionProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the primary action descriptor for this provider.
|
||||
/// </summary>
|
||||
ActionDescriptor GetActionDescriptor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all available actions from this provider.
|
||||
/// Default implementation returns only the primary action.
|
||||
/// </summary>
|
||||
IEnumerable<ActionDescriptor> GetAvailableActions()
|
||||
{
|
||||
yield return GetActionDescriptor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a specific action can be performed.
|
||||
/// </summary>
|
||||
/// <param name="performer">The GameObject attempting the action</param>
|
||||
/// <param name="action">The action to check</param>
|
||||
/// <returns>True if the action can be performed</returns>
|
||||
bool CanPerformAction(GameObject performer, ActionDescriptor action)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the reason why an action cannot be performed.
|
||||
/// </summary>
|
||||
/// <param name="performer">The GameObject attempting the action</param>
|
||||
/// <param name="action">The action to check</param>
|
||||
/// <returns>Reason string, or null if action can be performed</returns>
|
||||
string GetActionBlockedReason(GameObject performer, ActionDescriptor action)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ public class MiningActionData : PlayerActionData
|
||||
|
||||
public override void ExecuteEffect(GameObject performer, GameObject target)
|
||||
{
|
||||
if(target == null) return;
|
||||
if (target == null) return;
|
||||
|
||||
if (target.TryGetComponent<MineableBlock>(out var block))
|
||||
// Use IDamageable interface for all damageable objects
|
||||
if (target.TryGetComponent<IDamageable>(out var damageable))
|
||||
{
|
||||
// 서버 RPC 호출은 블록 내부의 로직을 그대로 사용합니다.
|
||||
block.TakeDamageRpc(damage);
|
||||
block.PlayHitEffectClientRpc();
|
||||
damageable.TakeDamage(new DamageInfo(damage, DamageType.Mining, performer));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
Assets/Scripts/Player/MiningBehavior.cs
Normal file
44
Assets/Scripts/Player/MiningBehavior.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Mining behavior for pickaxes and similar tools.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Items/Behaviors/Mining Behavior")]
|
||||
public class MiningBehavior : ItemBehavior
|
||||
{
|
||||
[Header("Mining Settings")]
|
||||
[SerializeField] private int damage = 50;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Set default mining values
|
||||
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Mine";
|
||||
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Attack";
|
||||
canRepeat = true;
|
||||
}
|
||||
|
||||
public override bool CanUse(GameObject user, GameObject target)
|
||||
{
|
||||
// Can always swing, but only deals damage if hitting a mineable block
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void Use(GameObject user, GameObject target)
|
||||
{
|
||||
if (target == null) return;
|
||||
|
||||
// Use IDamageable interface for all damageable objects
|
||||
if (target.TryGetComponent<IDamageable>(out var damageable))
|
||||
{
|
||||
damageable.TakeDamage(new DamageInfo(damage, DamageType.Mining, user));
|
||||
}
|
||||
}
|
||||
|
||||
public override string GetBlockedReason(GameObject user, GameObject target)
|
||||
{
|
||||
if (target == null) return "No target";
|
||||
if (!target.TryGetComponent<IDamageable>(out _))
|
||||
return "Cannot mine this object";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Player/MiningBehavior.cs.meta
Normal file
2
Assets/Scripts/Player/MiningBehavior.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1deeb9de56edff4ca77ddabf9db691a
|
||||
41
Assets/Scripts/Player/PlaceableBehavior.cs
Normal file
41
Assets/Scripts/Player/PlaceableBehavior.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Placeable behavior for building/placing items.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Items/Behaviors/Placeable Behavior")]
|
||||
public class PlaceableBehavior : ItemBehavior
|
||||
{
|
||||
[Header("Placement Settings")]
|
||||
[SerializeField] private GameObject placeablePrefab;
|
||||
[SerializeField] private bool requiresGround = true;
|
||||
[SerializeField] private float placementRange = 5f;
|
||||
|
||||
public override bool IsConsumable => true;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Place";
|
||||
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Place";
|
||||
canRepeat = false;
|
||||
}
|
||||
|
||||
public override bool CanUse(GameObject user, GameObject target)
|
||||
{
|
||||
// Would integrate with BuildManager for placement validation
|
||||
return placeablePrefab != null;
|
||||
}
|
||||
|
||||
public override void Use(GameObject user, GameObject target)
|
||||
{
|
||||
// Actual placement would be handled by BuildManager
|
||||
// This is a placeholder for the behavior pattern
|
||||
Debug.Log($"[PlaceableBehavior] Would place {placeablePrefab?.name}");
|
||||
}
|
||||
|
||||
public override string GetBlockedReason(GameObject user, GameObject target)
|
||||
{
|
||||
if (placeablePrefab == null) return "Invalid placement item";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Player/PlaceableBehavior.cs.meta
Normal file
2
Assets/Scripts/Player/PlaceableBehavior.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4962abe690c6ef47b7ea654ce747200
|
||||
@@ -33,11 +33,11 @@ public class PlayerActionHandler : NetworkBehaviour
|
||||
private IEnumerator InteractionRoutine(ActionDescriptor desc, IInteractable target)
|
||||
{
|
||||
_isBusy = true;
|
||||
if (desc != null) _animator.SetTrigger(desc.animTrigger);
|
||||
if (desc != null) _animator.SetTrigger(desc.AnimTrigger);
|
||||
|
||||
target.Interact(gameObject); // 로직 실행
|
||||
|
||||
yield return new WaitForSeconds(desc?.duration ?? 0.1f);
|
||||
yield return new WaitForSeconds(desc?.Duration ?? 0.1f);
|
||||
_isBusy = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,63 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Handles equipment visuals for the player.
|
||||
/// Uses the new EquipmentSlot system while maintaining backwards compatibility.
|
||||
/// </summary>
|
||||
public class PlayerEquipmentHandler : NetworkBehaviour
|
||||
{
|
||||
[SerializeField] private Transform toolAnchor; // 캐릭터 손의 소켓 위치
|
||||
[Header("Equipment Settings")]
|
||||
[SerializeField] private Transform mainHandAnchor;
|
||||
[SerializeField] private Transform offHandAnchor;
|
||||
|
||||
[Header("Slot Configuration")]
|
||||
[SerializeField] private EquipmentSlot mainHandSlot;
|
||||
|
||||
private PlayerInventory _inventory;
|
||||
private GameObject _currentToolInstance; // 현재 생성된 도구 모델
|
||||
private GameObject _currentToolInstance;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_inventory = GetComponent<PlayerInventory>();
|
||||
|
||||
// Initialize main hand slot if not configured in inspector
|
||||
if (mainHandSlot == null)
|
||||
{
|
||||
mainHandSlot = new EquipmentSlot
|
||||
{
|
||||
SlotType = EquipmentSlotType.MainHand,
|
||||
AttachPoint = mainHandAnchor
|
||||
};
|
||||
}
|
||||
else if (mainHandSlot.AttachPoint == null)
|
||||
{
|
||||
mainHandSlot.AttachPoint = mainHandAnchor;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
// 인벤토리의 슬롯 변경 이벤트 구독
|
||||
// OnSlotChanged는 (이전 값, 새 값) 두 개의 인자를 전달합니다.
|
||||
_inventory.OnSlotChanged += HandleSlotChanged;
|
||||
// Subscribe to inventory slot changes
|
||||
if (_inventory != null)
|
||||
{
|
||||
_inventory.OnSlotChanged += HandleSlotChanged;
|
||||
|
||||
// 게임 시작 시 처음에 들고 있는 아이템 모델 생성
|
||||
UpdateEquippedModel(_inventory.SelectedSlotIndex);
|
||||
// Initialize with current slot
|
||||
UpdateEquippedModel(_inventory.SelectedSlotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
// Unsubscribe to prevent memory leaks
|
||||
if (_inventory != null)
|
||||
{
|
||||
_inventory.OnSlotChanged -= HandleSlotChanged;
|
||||
}
|
||||
|
||||
// Clean up equipment
|
||||
mainHandSlot?.Unequip();
|
||||
}
|
||||
|
||||
private void HandleSlotChanged(int previousValue, int newValue)
|
||||
@@ -29,30 +67,58 @@ public class PlayerEquipmentHandler : NetworkBehaviour
|
||||
|
||||
private void UpdateEquippedModel(int slotIndex)
|
||||
{
|
||||
// 1. 기존 도구가 있다면 파괴
|
||||
if (_currentToolInstance != null)
|
||||
// Get item data for the selected slot
|
||||
ItemData data = _inventory?.GetItemDataInSlot(slotIndex);
|
||||
|
||||
// Use new equipment slot system
|
||||
if (data != null && data.CanBeEquipped)
|
||||
{
|
||||
Destroy(_currentToolInstance);
|
||||
// Use IEquippableItem interface
|
||||
mainHandSlot.Equip(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
mainHandSlot.Unequip();
|
||||
}
|
||||
|
||||
// 2. 현재 선택된 슬롯의 데이터 확인
|
||||
ItemData data = _inventory.GetItemDataInSlot(slotIndex);
|
||||
|
||||
// 3. 도구인 경우에만 모델 생성
|
||||
if (data != null && data.isTool && data.toolPrefab != null)
|
||||
{
|
||||
_currentToolInstance = Instantiate(data.toolPrefab, toolAnchor);
|
||||
|
||||
// ItemData에 설정된 오프셋 적용
|
||||
_currentToolInstance.transform.localPosition = data.equipPositionOffset;
|
||||
_currentToolInstance.transform.localRotation = Quaternion.Euler(data.equipRotationOffset);
|
||||
}
|
||||
// Update legacy reference for any code that might check it
|
||||
_currentToolInstance = mainHandSlot.CurrentEquipment;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
/// <summary>
|
||||
/// Get the currently equipped item data.
|
||||
/// </summary>
|
||||
public ItemData GetEquippedItem()
|
||||
{
|
||||
// 이벤트 구독 해제 (메모리 누수 방지)
|
||||
if (_inventory != null)
|
||||
_inventory.OnSlotChanged -= HandleSlotChanged;
|
||||
return mainHandSlot?.EquippedItem;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the currently equipped tool instance.
|
||||
/// </summary>
|
||||
public GameObject GetCurrentToolInstance()
|
||||
{
|
||||
return mainHandSlot?.CurrentEquipment ?? _currentToolInstance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if player has equipment in main hand.
|
||||
/// </summary>
|
||||
public bool HasMainHandEquipment => mainHandSlot?.HasEquipment ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Force refresh the equipped model.
|
||||
/// </summary>
|
||||
public void RefreshEquipment()
|
||||
{
|
||||
if (_inventory != null)
|
||||
{
|
||||
UpdateEquippedModel(_inventory.SelectedSlotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the main hand equipment slot for advanced usage.
|
||||
/// </summary>
|
||||
public EquipmentSlot MainHandSlot => mainHandSlot;
|
||||
}
|
||||
|
||||
@@ -204,35 +204,50 @@ public class PlayerNetworkController : NetworkBehaviour
|
||||
if (_isGrounded) _velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
|
||||
}
|
||||
|
||||
// 1. 액션 (좌클릭) - 대상이 없어도 나감
|
||||
// PlayerNetworkController.cs 중 일부
|
||||
// 1. Action (Left Click) - executes even without target
|
||||
private void OnActionInput()
|
||||
{
|
||||
if (!IsOwner || _actionHandler.IsBusy) return;
|
||||
|
||||
ItemData selectedItem = _inventory.GetSelectedItemData();
|
||||
if (selectedItem == null) return;
|
||||
|
||||
// 로그 1: 아이템 확인
|
||||
if (selectedItem == null) { Debug.Log("선택된 아이템이 없음"); return; }
|
||||
|
||||
// 로그 2: 도구 여부 및 액션 데이터 확인
|
||||
Debug.Log($"현재 아이템: {selectedItem.itemName}, 도구여부: {selectedItem.isTool}, 액션데이터: {selectedItem.toolAction != null}");
|
||||
|
||||
if (selectedItem.isTool && selectedItem.toolAction != null)
|
||||
// Check if item has behavior (new system)
|
||||
if (selectedItem.behavior != null)
|
||||
{
|
||||
if (_lastHighlightedBlock != null)
|
||||
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
|
||||
|
||||
// Use the new behavior system
|
||||
if (selectedItem.CanUse(gameObject, target))
|
||||
{
|
||||
Debug.Log($"채광 시작: {_lastHighlightedBlock.name}");
|
||||
_actionHandler.PerformAction(selectedItem.toolAction, _lastHighlightedBlock.gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("조준된 블록이 없음 (하이라이트 확인 필요)");
|
||||
_actionHandler.PerformAction(selectedItem.toolAction, null);
|
||||
// Get action descriptor and perform action
|
||||
var actionDesc = selectedItem.GetUseAction();
|
||||
if (actionDesc != null)
|
||||
{
|
||||
_actionHandler.PerformAction(
|
||||
CreateActionDataFromDescriptor(actionDesc, selectedItem.behavior),
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to bridge between new ActionDescriptor and legacy PlayerActionData
|
||||
private PlayerActionData CreateActionDataFromDescriptor(ActionDescriptor desc, ItemBehavior behavior)
|
||||
{
|
||||
// Create a temporary runtime action data
|
||||
var actionData = ScriptableObject.CreateInstance<BehaviorActionData>();
|
||||
actionData.actionName = desc.ActionName;
|
||||
actionData.duration = desc.Duration;
|
||||
actionData.animTrigger = desc.AnimTrigger;
|
||||
actionData.impactDelay = desc.ImpactDelay;
|
||||
actionData.baseSpeed = desc.AnimSpeed;
|
||||
actionData.canRepeat = desc.CanRepeat;
|
||||
actionData.behavior = behavior;
|
||||
return actionData;
|
||||
}
|
||||
|
||||
// 2. 인터랙션 (F키) - 대상이 없으면 아예 시작 안 함
|
||||
private void OnInteractTap()
|
||||
{
|
||||
@@ -250,13 +265,13 @@ public class PlayerNetworkController : NetworkBehaviour
|
||||
{
|
||||
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out var target))
|
||||
{
|
||||
if (target.TryGetComponent<MineableBlock>(out var block))
|
||||
// Use IDamageable interface instead of MineableBlock directly
|
||||
if (target.TryGetComponent<IDamageable>(out var damageable))
|
||||
{
|
||||
// 서버에서 최종 거리 검증 후 대미지 적용
|
||||
// Server-side distance validation before applying damage
|
||||
if (Vector3.Distance(transform.position, target.transform.position) <= attackRange + 1.0f)
|
||||
{
|
||||
block.TakeDamageRpc(miningDamage);
|
||||
block.PlayHitEffectClientRpc();
|
||||
damageable.TakeDamage(new DamageInfo(miningDamage, DamageType.Mining, gameObject));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,10 +502,11 @@ public class PlayerNetworkController : NetworkBehaviour
|
||||
private void HandleContinuousAction()
|
||||
{
|
||||
ItemData selectedItem = _inventory.GetSelectedItemData();
|
||||
if (selectedItem == null || !selectedItem.isTool || selectedItem.toolAction == null) return;
|
||||
if (selectedItem == null || selectedItem.behavior == null) return;
|
||||
|
||||
// [핵심] 반복 가능한 액션일 때만 Update에서 재실행
|
||||
if (selectedItem.toolAction.canRepeat)
|
||||
// Only repeat if action supports it
|
||||
var actionDesc = selectedItem.GetUseAction();
|
||||
if (actionDesc != null && actionDesc.CanRepeat)
|
||||
{
|
||||
TryExecuteAction();
|
||||
}
|
||||
@@ -501,15 +517,23 @@ public class PlayerNetworkController : NetworkBehaviour
|
||||
if (_actionHandler.IsBusy) return;
|
||||
|
||||
ItemData selectedItem = _inventory.GetSelectedItemData();
|
||||
if (selectedItem != null && selectedItem.isTool && selectedItem.toolAction != null)
|
||||
if (selectedItem == null || selectedItem.behavior == null) return;
|
||||
|
||||
var actionDesc = selectedItem.GetUseAction();
|
||||
if (actionDesc == null) return;
|
||||
|
||||
// Skip if non-repeatable action already executed once
|
||||
if (!actionDesc.CanRepeat && _hasExecutedOnce) return;
|
||||
|
||||
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
|
||||
|
||||
if (selectedItem.CanUse(gameObject, target))
|
||||
{
|
||||
// 단발성 액션인데 이미 한 번 실행했다면 스킵
|
||||
if (!selectedItem.toolAction.canRepeat && _hasExecutedOnce) return;
|
||||
|
||||
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
|
||||
_actionHandler.PerformAction(selectedItem.toolAction, target);
|
||||
|
||||
_hasExecutedOnce = true; // 실행 기록 저장
|
||||
_actionHandler.PerformAction(
|
||||
CreateActionDataFromDescriptor(actionDesc, selectedItem.behavior),
|
||||
target
|
||||
);
|
||||
_hasExecutedOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
8
Assets/Scripts/Utilities.meta
Normal file
8
Assets/Scripts/Utilities.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5b5e4be41b488d4cb306af7c3175e94
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
215
Assets/Scripts/Utilities/ActionExecutor.cs
Normal file
215
Assets/Scripts/Utilities/ActionExecutor.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable action execution system with busy state management.
|
||||
/// Replaces repeated coroutine busy-state patterns across the codebase.
|
||||
/// </summary>
|
||||
public class ActionExecutor : MonoBehaviour
|
||||
{
|
||||
public bool IsBusy { get; private set; }
|
||||
|
||||
private Coroutine _currentAction;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when an action starts.
|
||||
/// </summary>
|
||||
public event Action OnActionStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when an action completes.
|
||||
/// </summary>
|
||||
public event Action OnActionCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when an action is cancelled.
|
||||
/// </summary>
|
||||
public event Action OnActionCancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Try to execute an action. Returns false if already busy.
|
||||
/// </summary>
|
||||
/// <param name="request">The action request to execute</param>
|
||||
/// <returns>True if action started, false if busy</returns>
|
||||
public bool TryExecute(ActionRequest request)
|
||||
{
|
||||
if (IsBusy) return false;
|
||||
|
||||
_currentAction = StartCoroutine(ExecuteRoutine(request));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel the current action if one is running.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (_currentAction != null)
|
||||
{
|
||||
StopCoroutine(_currentAction);
|
||||
_currentAction = null;
|
||||
IsBusy = false;
|
||||
OnActionCancelled?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force reset the busy state. Use with caution.
|
||||
/// </summary>
|
||||
public void ForceReset()
|
||||
{
|
||||
if (_currentAction != null)
|
||||
{
|
||||
StopCoroutine(_currentAction);
|
||||
_currentAction = null;
|
||||
}
|
||||
IsBusy = false;
|
||||
}
|
||||
|
||||
private IEnumerator ExecuteRoutine(ActionRequest request)
|
||||
{
|
||||
IsBusy = true;
|
||||
OnActionStarted?.Invoke();
|
||||
|
||||
// Pre-action callback
|
||||
request.OnStart?.Invoke();
|
||||
|
||||
// Animation trigger
|
||||
if (request.Animator != null && !string.IsNullOrEmpty(request.AnimTrigger))
|
||||
{
|
||||
if (request.AnimSpeed > 0)
|
||||
{
|
||||
request.Animator.SetFloat("ActionSpeed", request.AnimSpeed);
|
||||
}
|
||||
request.Animator.SetTrigger(request.AnimTrigger);
|
||||
}
|
||||
|
||||
// Wait for impact point
|
||||
if (request.ImpactDelay > 0)
|
||||
{
|
||||
float adjustedDelay = request.AnimSpeed > 0
|
||||
? request.ImpactDelay / request.AnimSpeed
|
||||
: request.ImpactDelay;
|
||||
yield return new WaitForSeconds(adjustedDelay);
|
||||
}
|
||||
|
||||
// Execute main effect
|
||||
request.OnImpact?.Invoke();
|
||||
|
||||
// Wait for remaining duration
|
||||
float remainingTime = request.TotalDuration - request.ImpactDelay;
|
||||
if (request.AnimSpeed > 0)
|
||||
{
|
||||
remainingTime /= request.AnimSpeed;
|
||||
}
|
||||
|
||||
if (remainingTime > 0)
|
||||
{
|
||||
yield return new WaitForSeconds(remainingTime);
|
||||
}
|
||||
|
||||
// Completion callback
|
||||
request.OnComplete?.Invoke();
|
||||
|
||||
IsBusy = false;
|
||||
_currentAction = null;
|
||||
OnActionCompleted?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for an action to be executed by ActionExecutor.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public struct ActionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Animator to trigger animations on.
|
||||
/// </summary>
|
||||
public Animator Animator;
|
||||
|
||||
/// <summary>
|
||||
/// Animation trigger name.
|
||||
/// </summary>
|
||||
public string AnimTrigger;
|
||||
|
||||
/// <summary>
|
||||
/// Animation playback speed multiplier.
|
||||
/// </summary>
|
||||
public float AnimSpeed;
|
||||
|
||||
/// <summary>
|
||||
/// Time delay before the impact/effect happens (for syncing with animation).
|
||||
/// </summary>
|
||||
public float ImpactDelay;
|
||||
|
||||
/// <summary>
|
||||
/// Total duration of the action.
|
||||
/// </summary>
|
||||
public float TotalDuration;
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when action starts.
|
||||
/// </summary>
|
||||
public Action OnStart;
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked at the impact moment.
|
||||
/// </summary>
|
||||
public Action OnImpact;
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when action completes.
|
||||
/// </summary>
|
||||
public Action OnComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Create a simple action request with just timing.
|
||||
/// </summary>
|
||||
public static ActionRequest Simple(float duration, Action onComplete)
|
||||
{
|
||||
return new ActionRequest
|
||||
{
|
||||
TotalDuration = duration,
|
||||
AnimSpeed = 1f,
|
||||
OnComplete = onComplete
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an animated action request.
|
||||
/// </summary>
|
||||
public static ActionRequest Animated(Animator animator, string trigger, float duration,
|
||||
float impactDelay, Action onImpact, float speed = 1f)
|
||||
{
|
||||
return new ActionRequest
|
||||
{
|
||||
Animator = animator,
|
||||
AnimTrigger = trigger,
|
||||
AnimSpeed = speed,
|
||||
ImpactDelay = impactDelay,
|
||||
TotalDuration = duration,
|
||||
OnImpact = onImpact
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a full action request with all callbacks.
|
||||
/// </summary>
|
||||
public static ActionRequest Full(Animator animator, string trigger, float duration,
|
||||
float impactDelay, float speed, Action onStart, Action onImpact, Action onComplete)
|
||||
{
|
||||
return new ActionRequest
|
||||
{
|
||||
Animator = animator,
|
||||
AnimTrigger = trigger,
|
||||
AnimSpeed = speed,
|
||||
ImpactDelay = impactDelay,
|
||||
TotalDuration = duration,
|
||||
OnStart = onStart,
|
||||
OnImpact = onImpact,
|
||||
OnComplete = onComplete
|
||||
};
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Utilities/ActionExecutor.cs.meta
Normal file
2
Assets/Scripts/Utilities/ActionExecutor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27e07922480ecf44faf5135ad4872531
|
||||
228
Assets/Scripts/Utilities/PhysicsQueryUtility.cs
Normal file
228
Assets/Scripts/Utilities/PhysicsQueryUtility.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for common physics queries.
|
||||
/// Eliminates repeated OverlapSphere patterns across the codebase.
|
||||
/// </summary>
|
||||
public static class PhysicsQueryUtility
|
||||
{
|
||||
// Reusable buffer to avoid allocations (32 should be enough for most cases)
|
||||
private static readonly Collider[] OverlapBuffer = new Collider[32];
|
||||
|
||||
/// <summary>
|
||||
/// Find the closest object implementing interface T within radius.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Interface or component type to search for</typeparam>
|
||||
/// <param name="origin">Center point of the search</param>
|
||||
/// <param name="radius">Search radius</param>
|
||||
/// <param name="layerMask">Layer mask to filter objects</param>
|
||||
/// <returns>Closest object of type T, or null if none found</returns>
|
||||
public static T FindClosest<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
|
||||
{
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
T closest = null;
|
||||
float minDistSqr = float.MaxValue;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
T component = OverlapBuffer[i].GetComponentInParent<T>();
|
||||
if (component != null)
|
||||
{
|
||||
float distSqr = (origin - OverlapBuffer[i].transform.position).sqrMagnitude;
|
||||
if (distSqr < minDistSqr)
|
||||
{
|
||||
minDistSqr = distSqr;
|
||||
closest = component;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the closest object implementing interface T within radius, also returning distance.
|
||||
/// </summary>
|
||||
public static T FindClosest<T>(Vector3 origin, float radius, LayerMask layerMask, out float distance) where T : class
|
||||
{
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
T closest = null;
|
||||
float minDistSqr = float.MaxValue;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
T component = OverlapBuffer[i].GetComponentInParent<T>();
|
||||
if (component != null)
|
||||
{
|
||||
float distSqr = (origin - OverlapBuffer[i].transform.position).sqrMagnitude;
|
||||
if (distSqr < minDistSqr)
|
||||
{
|
||||
minDistSqr = distSqr;
|
||||
closest = component;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distance = closest != null ? Mathf.Sqrt(minDistSqr) : 0f;
|
||||
return closest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute action on all objects of type T within radius.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Interface or component type to search for</typeparam>
|
||||
/// <param name="origin">Center point of the search</param>
|
||||
/// <param name="radius">Search radius</param>
|
||||
/// <param name="layerMask">Layer mask to filter objects</param>
|
||||
/// <param name="action">Action to execute on each found object</param>
|
||||
public static void ForEachInRadius<T>(Vector3 origin, float radius, LayerMask layerMask, Action<T> action) where T : class
|
||||
{
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
// Track processed objects to avoid duplicates (same object hit by multiple colliders)
|
||||
var processed = new HashSet<T>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
T component = OverlapBuffer[i].GetComponentInParent<T>();
|
||||
if (component != null && !processed.Contains(component))
|
||||
{
|
||||
processed.Add(component);
|
||||
action(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute action on all objects of type T within radius, with additional context.
|
||||
/// </summary>
|
||||
public static void ForEachInRadius<T>(Vector3 origin, float radius, LayerMask layerMask,
|
||||
Action<T, Collider> action) where T : class
|
||||
{
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
var processed = new HashSet<T>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
T component = OverlapBuffer[i].GetComponentInParent<T>();
|
||||
if (component != null && !processed.Contains(component))
|
||||
{
|
||||
processed.Add(component);
|
||||
action(component, OverlapBuffer[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all objects of type T within radius.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Interface or component type to search for</typeparam>
|
||||
/// <param name="origin">Center point of the search</param>
|
||||
/// <param name="radius">Search radius</param>
|
||||
/// <param name="layerMask">Layer mask to filter objects</param>
|
||||
/// <returns>List of all objects of type T within radius</returns>
|
||||
public static List<T> FindAllInRadius<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
|
||||
{
|
||||
var results = new List<T>();
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
T component = OverlapBuffer[i].GetComponentInParent<T>();
|
||||
if (component != null && !results.Contains(component))
|
||||
{
|
||||
results.Add(component);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find all objects of type T within radius, sorted by distance (closest first).
|
||||
/// </summary>
|
||||
public static List<T> FindAllInRadiusSorted<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
|
||||
{
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
var resultsWithDistance = new List<(T component, float distSqr)>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
T component = OverlapBuffer[i].GetComponentInParent<T>();
|
||||
if (component != null)
|
||||
{
|
||||
bool alreadyAdded = false;
|
||||
foreach (var item in resultsWithDistance)
|
||||
{
|
||||
if (ReferenceEquals(item.component, component))
|
||||
{
|
||||
alreadyAdded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyAdded)
|
||||
{
|
||||
float distSqr = (origin - OverlapBuffer[i].transform.position).sqrMagnitude;
|
||||
resultsWithDistance.Add((component, distSqr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance
|
||||
resultsWithDistance.Sort((a, b) => a.distSqr.CompareTo(b.distSqr));
|
||||
|
||||
var results = new List<T>(resultsWithDistance.Count);
|
||||
foreach (var item in resultsWithDistance)
|
||||
{
|
||||
results.Add(item.component);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if any object of type T exists within radius.
|
||||
/// </summary>
|
||||
public static bool AnyInRadius<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
|
||||
{
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (OverlapBuffer[i].GetComponentInParent<T>() != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count objects of type T within radius.
|
||||
/// </summary>
|
||||
public static int CountInRadius<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
|
||||
{
|
||||
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
|
||||
|
||||
var processed = new HashSet<T>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
T component = OverlapBuffer[i].GetComponentInParent<T>();
|
||||
if (component != null)
|
||||
{
|
||||
processed.Add(component);
|
||||
}
|
||||
}
|
||||
|
||||
return processed.Count;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Utilities/PhysicsQueryUtility.cs.meta
Normal file
2
Assets/Scripts/Utilities/PhysicsQueryUtility.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e1262d37bb47b7d4ea78d6426675ae9d
|
||||
Reference in New Issue
Block a user