From 8f7dd412c5f833fec6bebf1e1e93c6e74fd54e5f Mon Sep 17 00:00:00 2001 From: dal4segno Date: Wed, 11 Mar 2026 17:51:06 +0900 Subject: [PATCH] =?UTF-8?q?[Enemy]=20=EC=A0=81=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeonggu/oh-my-opencode) Co-authored-by: Sisyphus --- Assets/Scripts/Enemy.meta | 8 + Assets/Scripts/Enemy/BossPhaseData.cs | 123 +++++++++++++ Assets/Scripts/Enemy/BossPhaseData.cs.meta | 2 + Assets/Scripts/Enemy/EnemyBase.cs | 202 +++++++++++++++++++++ Assets/Scripts/Enemy/EnemyBase.cs.meta | 2 + Assets/Scripts/Enemy/EnemyData.cs | 68 +++++++ Assets/Scripts/Enemy/EnemyData.cs.meta | 2 + Assets/Scripts/Enemy/IDamageable.cs | 39 ++++ Assets/Scripts/Enemy/IDamageable.cs.meta | 2 + 9 files changed, 448 insertions(+) create mode 100644 Assets/Scripts/Enemy.meta create mode 100644 Assets/Scripts/Enemy/BossPhaseData.cs create mode 100644 Assets/Scripts/Enemy/BossPhaseData.cs.meta create mode 100644 Assets/Scripts/Enemy/EnemyBase.cs create mode 100644 Assets/Scripts/Enemy/EnemyBase.cs.meta create mode 100644 Assets/Scripts/Enemy/EnemyData.cs create mode 100644 Assets/Scripts/Enemy/EnemyData.cs.meta create mode 100644 Assets/Scripts/Enemy/IDamageable.cs create mode 100644 Assets/Scripts/Enemy/IDamageable.cs.meta diff --git a/Assets/Scripts/Enemy.meta b/Assets/Scripts/Enemy.meta new file mode 100644 index 00000000..e73fdeed --- /dev/null +++ b/Assets/Scripts/Enemy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8f6dc132c1fce114da1ae74c46fd57dd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Enemy/BossPhaseData.cs b/Assets/Scripts/Enemy/BossPhaseData.cs new file mode 100644 index 00000000..790940d8 --- /dev/null +++ b/Assets/Scripts/Enemy/BossPhaseData.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Unity.Behavior; + +namespace Colosseum.Enemy +{ + /// + /// 보스 페이즈 전환 조건 타입 + /// + public enum PhaseTransitionType + { + HealthPercent, // 체력 비율 기반 + TimeElapsed, // 시간 경과 + CustomCondition, // 커스텀 조건 (코드에서 설정) + Manual, // 수동 전환 + } + + /// + /// 보스 페이즈 데이터. 각 페이즈의 AI, 조건, 보상을 정의합니다. + /// + [CreateAssetMenu(fileName = "NewBossPhase", menuName = "Colosseum/Boss Phase")] + public class BossPhaseData : ScriptableObject + { + [Header("페이즈 정보")] + [SerializeField] private string phaseName = "Phase 1"; + [TextArea(1, 3)] + [SerializeField] private string description; + + [Header("전환 조건")] + [SerializeField] private PhaseTransitionType transitionType = PhaseTransitionType.HealthPercent; + + [Tooltip("체력 비율 기반 전환 시, 이 비율 이하에서 페이즈 전환")] + [Range(0f, 1f)] [SerializeField] private float healthPercentThreshold = 0.7f; + + [Tooltip("시간 기반 전환 시, 경과 시간 (초)")] + [Min(0f)] [SerializeField] private float timeThreshold = 60f; + + [Tooltip("커스텀 조건 ID (코드에서 사용)")] + [SerializeField] private string customConditionId; + + [Header("AI 설정")] + [Tooltip("이 페이즈에서 사용할 Behavior Graph")] + [SerializeField] private BehaviorGraph behaviorGraph; + + [Tooltip("페이즈 전환 시 Blackboard 변수 오버라이드")] + [SerializeField] private List blackboardOverrides = new(); + + [Header("페이즈 효과")] + [Tooltip("페이즈 시작 시 재생할 애니메이션")] + [SerializeField] private AnimationClip phaseStartAnimation; + + [Tooltip("페이즈 전환 효과 (이펙트, 사운드 등)")] + [SerializeField] private GameObject phaseTransitionEffect; + + // Properties + public string PhaseName => phaseName; + public string Description => description; + public PhaseTransitionType TransitionType => transitionType; + public float HealthPercentThreshold => healthPercentThreshold; + public float TimeThreshold => timeThreshold; + public string CustomConditionId => customConditionId; + public BehaviorGraph BehaviorGraph => behaviorGraph; + public IReadOnlyList BlackboardOverrides => blackboardOverrides; + public AnimationClip PhaseStartAnimation => phaseStartAnimation; + public GameObject PhaseTransitionEffect => phaseTransitionEffect; + + /// + /// 전환 조건 충족 여부 확인 + /// + public bool CheckTransitionCondition(BossEnemy boss, float elapsedTime) + { + return transitionType switch + { + PhaseTransitionType.HealthPercent => boss.CurrentHealth / boss.MaxHealth <= healthPercentThreshold, + PhaseTransitionType.TimeElapsed => elapsedTime >= timeThreshold, + PhaseTransitionType.CustomCondition => boss.CheckCustomCondition(customConditionId), + PhaseTransitionType.Manual => false, + _ => false, + }; + } + } + + /// + /// Blackboard 변수 오버라이드 정보 + /// + [Serializable] + public class BlackboardVariableOverride + { + [Tooltip("변수 이름")] + [SerializeField] private string variableName; + + [Tooltip("변수 타입")] + [SerializeField] private BlackboardVariableType variableType = BlackboardVariableType.Float; + + [Tooltip("설정할 값")] + [SerializeField] private float floatValue; + [SerializeField] private int intValue; + [SerializeField] private bool boolValue; + [SerializeField] private string stringValue; + [SerializeField] private GameObject gameObjectValue; + + public string VariableName => variableName; + public BlackboardVariableType VariableType => variableType; + public float FloatValue => floatValue; + public int IntValue => intValue; + public bool BoolValue => boolValue; + public string StringValue => stringValue; + public GameObject GameObjectValue => gameObjectValue; + } + + /// + /// Blackboard 변수 타입 + /// + public enum BlackboardVariableType + { + Float, + Int, + Bool, + String, + GameObject, + } +} diff --git a/Assets/Scripts/Enemy/BossPhaseData.cs.meta b/Assets/Scripts/Enemy/BossPhaseData.cs.meta new file mode 100644 index 00000000..3df701f8 --- /dev/null +++ b/Assets/Scripts/Enemy/BossPhaseData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0e9dd028b74b2124895ac9673115a9b9 \ No newline at end of file diff --git a/Assets/Scripts/Enemy/EnemyBase.cs b/Assets/Scripts/Enemy/EnemyBase.cs new file mode 100644 index 00000000..03b504d3 --- /dev/null +++ b/Assets/Scripts/Enemy/EnemyBase.cs @@ -0,0 +1,202 @@ +using System; +using UnityEngine; +using Unity.Netcode; +using Colosseum.Stats; +using Colosseum.Combat; + +namespace Colosseum.Enemy +{ + /// + /// 적 캐릭터 기본 클래스. + /// 네트워크 동기화, 스탯 관리, 대미지 처리를 담당합니다. + /// + public class EnemyBase : NetworkBehaviour, IDamageable + { + [Header("References")] + [Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")] + [SerializeField] protected CharacterStats characterStats; + [Tooltip("Animator 컴포넌트")] + [SerializeField] protected Animator animator; + [Tooltip("NavMeshAgent 또는 이동 컴포넌트")] + [SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent; + + [Header("Data")] + [SerializeField] protected EnemyData enemyData; + + // 네트워크 동기화 변수 + protected NetworkVariable currentHealth = new NetworkVariable(100f); + protected NetworkVariable currentMana = new NetworkVariable(50f); + protected NetworkVariable isDead = new NetworkVariable(false); + + // 이벤트 + public event Action OnHealthChanged; // currentHealth, maxHealth + public event Action OnDamageTaken; // damage + public event Action OnDeath; + + // Properties + public float CurrentHealth => currentHealth.Value; + public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f; + public float CurrentMana => currentMana.Value; + public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f; + public bool IsDead => isDead.Value; + public CharacterStats Stats => characterStats; + public EnemyData Data => enemyData; + public Animator Animator => animator; + + public override void OnNetworkSpawn() + { + // 컴포넌트 참조 확인 + if (characterStats == null) + characterStats = GetComponent(); + if (animator == null) + animator = GetComponentInChildren(); + if (navMeshAgent == null) + navMeshAgent = GetComponent(); + + // 서버에서 초기화 + if (IsServer) + { + InitializeStats(); + } + + // 클라이언트에서 체력 변화 감지 + currentHealth.OnValueChanged += OnHealthChangedInternal; + } + + public override void OnNetworkDespawn() + { + currentHealth.OnValueChanged -= OnHealthChangedInternal; + } + + /// + /// 스탯 초기화 (서버에서만 실행) + /// + protected virtual void InitializeStats() + { + if (enemyData != null && characterStats != null) + { + enemyData.ApplyBaseStats(characterStats); + } + + // NavMeshAgent 속도 설정 + if (navMeshAgent != null && enemyData != null) + { + navMeshAgent.speed = enemyData.MoveSpeed; + navMeshAgent.angularSpeed = enemyData.RotationSpeed; + } + + currentHealth.Value = MaxHealth; + currentMana.Value = MaxMana; + isDead.Value = false; + } + + + + + + + + + + + + /// + /// 대미지 적용 (서버에서 실행) + /// + public virtual float TakeDamage(float damage, object source = null) + { + if (!IsServer || isDead.Value) + return 0f; + + float actualDamage = Mathf.Min(damage, currentHealth.Value); + currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); + + OnDamageTaken?.Invoke(actualDamage); + + // 대미지 피드백 (애니메이션, 이펙트 등) + OnTakeDamageFeedback(actualDamage, source); + + if (currentHealth.Value <= 0f) + { + HandleDeath(); + } + + return actualDamage; + } + + /// + /// 대미지 피드백 (애니메이션, 이펙트) + /// + protected virtual void OnTakeDamageFeedback(float damage, object source) + { + if (animator != null) + { + animator.SetTrigger("Hit"); + } + } + + /// + /// 체력 회복 (서버에서 실행) + /// + public virtual float Heal(float amount) + { + if (!IsServer || isDead.Value) + return 0f; + + float oldHealth = currentHealth.Value; + currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount); + float actualHeal = currentHealth.Value - oldHealth; + + return actualHeal; + } + + /// + /// 사망 처리 (서버에서 실행) + /// + protected virtual void HandleDeath() + { + isDead.Value = true; + + if (animator != null) + { + animator.SetTrigger("Die"); + } + + if (navMeshAgent != null) + { + navMeshAgent.isStopped = true; + } + + OnDeath?.Invoke(); + + Debug.Log($"[Enemy] {name} died!"); + } + + /// + /// 리스폰 + /// + public virtual void Respawn() + { + if (!IsServer) return; + + isDead.Value = false; + InitializeStats(); + + if (navMeshAgent != null) + { + navMeshAgent.isStopped = false; + } + + if (animator != null) + { + animator.Rebind(); + } + } + + // 체력 변화 이벤트 전파 + private void OnHealthChangedInternal(float oldValue, float newValue) + { + OnHealthChanged?.Invoke(newValue, MaxHealth); + } + } +} diff --git a/Assets/Scripts/Enemy/EnemyBase.cs.meta b/Assets/Scripts/Enemy/EnemyBase.cs.meta new file mode 100644 index 00000000..b363635a --- /dev/null +++ b/Assets/Scripts/Enemy/EnemyBase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d928c3a8adf0b424886395e6864ce010 \ No newline at end of file diff --git a/Assets/Scripts/Enemy/EnemyData.cs b/Assets/Scripts/Enemy/EnemyData.cs new file mode 100644 index 00000000..32ebaaa4 --- /dev/null +++ b/Assets/Scripts/Enemy/EnemyData.cs @@ -0,0 +1,68 @@ +using UnityEngine; +using Colosseum.Stats; + +namespace Colosseum.Enemy +{ + /// + /// 적 캐릭터 데이터. 기본 스탯과 보상을 정의합니다. + /// + [CreateAssetMenu(fileName = "NewEnemyData", menuName = "Colosseum/Enemy Data")] + public class EnemyData : ScriptableObject + { + [Header("기본 정보")] + [SerializeField] private string enemyName; + [TextArea(2, 4)] + [SerializeField] private string description; + [SerializeField] private Sprite icon; + + [Header("기본 스탯")] + [Min(1f)] [SerializeField] private float baseStrength = 10f; + [Min(1f)] [SerializeField] private float baseDexterity = 10f; + [Min(1f)] [SerializeField] private float baseIntelligence = 10f; + [Min(1f)] [SerializeField] private float baseVitality = 10f; + [Min(1f)] [SerializeField] private float baseWisdom = 10f; + [Min(1f)] [SerializeField] private float baseSpirit = 10f; + + [Header("이동")] + [Min(0f)] [SerializeField] private float moveSpeed = 3f; + [Min(0f)] [SerializeField] private float rotationSpeed = 10f; + + [Header("전투")] + [Min(0f)] [SerializeField] private float aggroRange = 10f; + [Min(0f)] [SerializeField] private float attackRange = 2f; + [Min(0f)] [SerializeField] private float attackCooldown = 1f; + + // Properties + public string EnemyName => enemyName; + public string Description => description; + public Sprite Icon => icon; + + public float BaseStrength => baseStrength; + public float BaseDexterity => baseDexterity; + public float BaseIntelligence => baseIntelligence; + public float BaseVitality => baseVitality; + public float BaseWisdom => baseWisdom; + public float BaseSpirit => baseSpirit; + + public float MoveSpeed => moveSpeed; + public float RotationSpeed => rotationSpeed; + public float AggroRange => aggroRange; + public float AttackRange => attackRange; + public float AttackCooldown => attackCooldown; + + /// + /// CharacterStats에 기본 스탯 적용 + /// + public void ApplyBaseStats(CharacterStats stats) + { + if (stats == null) return; + + stats.Strength.BaseValue = baseStrength; + stats.Dexterity.BaseValue = baseDexterity; + stats.Intelligence.BaseValue = baseIntelligence; + stats.Vitality.BaseValue = baseVitality; + stats.Wisdom.BaseValue = baseWisdom; + stats.Spirit.BaseValue = baseSpirit; + } + } +} diff --git a/Assets/Scripts/Enemy/EnemyData.cs.meta b/Assets/Scripts/Enemy/EnemyData.cs.meta new file mode 100644 index 00000000..ee869933 --- /dev/null +++ b/Assets/Scripts/Enemy/EnemyData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1ecdc2379b078b246a0bd5c0fb58e346 \ No newline at end of file diff --git a/Assets/Scripts/Enemy/IDamageable.cs b/Assets/Scripts/Enemy/IDamageable.cs new file mode 100644 index 00000000..da575b58 --- /dev/null +++ b/Assets/Scripts/Enemy/IDamageable.cs @@ -0,0 +1,39 @@ +namespace Colosseum.Combat +{ + /// + /// 대미지를 받을 수 있는 엔티티를 위한 인터페이스. + /// 플레이어, 적, 보스 등이 구현합니다. + /// + public interface IDamageable + { + /// + /// 현재 체력 + /// + float CurrentHealth { get; } + + /// + /// 최대 체력 + /// + float MaxHealth { get; } + + /// + /// 사망 여부 + /// + bool IsDead { get; } + + /// + /// 대미지 적용 + /// + /// 적용할 대미지량 + /// 대미지 출처 (선택) + /// 실제로 적용된 대미지량 + float TakeDamage(float damage, object source = null); + + /// + /// 체력 회복 + /// + /// 회복량 + /// 실제로 회복된 양 + float Heal(float amount); + } +} diff --git a/Assets/Scripts/Enemy/IDamageable.cs.meta b/Assets/Scripts/Enemy/IDamageable.cs.meta new file mode 100644 index 00000000..a0621fe8 --- /dev/null +++ b/Assets/Scripts/Enemy/IDamageable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 33a0f0f245adbf64791b38c182c48062 \ No newline at end of file