From d035c9a9c50ab8ef06880a8869e7790f5efd35b1 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Wed, 11 Mar 2026 17:51:27 +0900 Subject: [PATCH] =?UTF-8?q?[Enemy]=20=EB=B3=B4=EC=8A=A4=20=EC=A0=81=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/Editor/BossEnemyEditor.cs | 314 ++++++++++++++++++ Assets/Scripts/Editor/BossEnemyEditor.cs.meta | 2 + Assets/Scripts/Enemy/BossEnemy.cs | 291 ++++++++++++++++ Assets/Scripts/Enemy/BossEnemy.cs.meta | 2 + 4 files changed, 609 insertions(+) create mode 100644 Assets/Scripts/Editor/BossEnemyEditor.cs create mode 100644 Assets/Scripts/Editor/BossEnemyEditor.cs.meta create mode 100644 Assets/Scripts/Enemy/BossEnemy.cs create mode 100644 Assets/Scripts/Enemy/BossEnemy.cs.meta diff --git a/Assets/Scripts/Editor/BossEnemyEditor.cs b/Assets/Scripts/Editor/BossEnemyEditor.cs new file mode 100644 index 00000000..42b8e458 --- /dev/null +++ b/Assets/Scripts/Editor/BossEnemyEditor.cs @@ -0,0 +1,314 @@ +#if UNITY_EDITOR +using UnityEngine; +using UnityEditor; +using Colosseum.Enemy; + +namespace Colosseum.Editor +{ + /// + /// BossEnemy 커스텀 인스펙터. + /// 페이즈 정보, HP, 상태를 시각적으로 표시합니다. + /// + [CustomEditor(typeof(BossEnemy))] + public class BossEnemyEditor : UnityEditor.Editor + { + private BossEnemy boss; + private bool showPhaseDetails = true; + private bool showDebugTools = true; + private int selectedPhaseIndex = 0; + + private void OnEnable() + { + boss = (BossEnemy)target; + } + + public override void OnInspectorGUI() + { + // 기본 인스펙터 그리기 + DrawDefaultInspector(); + + if (!Application.isPlaying) + { + EditorGUILayout.HelpBox("런타임 디버그 정보는 플레이 모드에서만 표시됩니다.", MessageType.Info); + return; + } + + EditorGUILayout.Space(10); + + // 상태 요약 + DrawStatusSummary(); + + EditorGUILayout.Space(10); + + // 페이즈 정보 + DrawPhaseInfo(); + + EditorGUILayout.Space(10); + + // 디버그 도구 + DrawDebugTools(); + } + + /// + /// 상태 요약 표시 + /// + private void DrawStatusSummary() + { + EditorGUILayout.LabelField("상태 요약", EditorStyles.boldLabel); + EditorGUI.indentLevel++; + + // HP 바 + float hpPercent = boss.MaxHealth > 0 ? boss.CurrentHealth / boss.MaxHealth : 0f; + DrawProgressBar("HP", hpPercent, GetHealthColor(hpPercent), $"{boss.CurrentHealth:F0} / {boss.MaxHealth:F0}"); + + // 상태 정보 + EditorGUILayout.LabelField("현재 페이즈", $"{boss.CurrentPhaseIndex + 1} / {boss.TotalPhases}"); + EditorGUILayout.LabelField("상태", GetStatusText()); + + if (boss.CurrentPhase != null) + { + EditorGUILayout.LabelField("페이즈명", boss.CurrentPhase.PhaseName); + } + + EditorGUI.indentLevel--; + } + + /// + /// 페이즈 상세 정보 표시 + /// + private void DrawPhaseInfo() + { + showPhaseDetails = EditorGUILayout.Foldout(showPhaseDetails, "페이즈 상세 정보", true); + + if (!showPhaseDetails) + return; + + EditorGUI.indentLevel++; + + var phasesProp = serializedObject.FindProperty("phases"); + if (phasesProp == null || phasesProp.arraySize == 0) + { + EditorGUILayout.HelpBox("등록된 페이즈가 없습니다.", MessageType.Warning); + EditorGUI.indentLevel--; + return; + } + + for (int i = 0; i < phasesProp.arraySize; i++) + { + var phaseProp = phasesProp.GetArrayElementAtIndex(i); + var phase = phaseProp.objectReferenceValue as BossPhaseData; + + if (phase == null) + continue; + + bool isCurrentPhase = i == boss.CurrentPhaseIndex; + bool isCompleted = i < boss.CurrentPhaseIndex; + + // 페이즈 헤더 + GUIStyle phaseStyle = new GUIStyle(EditorStyles.foldout); + if (isCurrentPhase) + phaseStyle.fontStyle = FontStyle.Bold; + + EditorGUILayout.BeginHorizontal(); + + // 상태 아이콘 + string statusIcon = isCurrentPhase ? "▶" : (isCompleted ? "✓" : "○"); + GUIContent phaseLabel = new GUIContent($"{statusIcon} Phase {i + 1}: {phase.PhaseName}"); + + EditorGUILayout.LabelField(phaseLabel, GUILayout.Width(200)); + + // 전환 조건 + EditorGUILayout.LabelField($"[{phase.TransitionType}]", EditorStyles.miniLabel, GUILayout.Width(100)); + + EditorGUILayout.EndHorizontal(); + + if (isCurrentPhase) + { + EditorGUI.indentLevel++; + EditorGUILayout.LabelField($"전환 조건: {GetTransitionConditionText(phase)}"); + EditorGUILayout.LabelField($"경과 시간: {boss.PhaseElapsedTime:F1}초"); + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(2); + } + + EditorGUI.indentLevel--; + } + + /// + /// 디버그 도구 표시 + /// + private void DrawDebugTools() + { + showDebugTools = EditorGUILayout.Foldout(showDebugTools, "디버그 도구", true); + + if (!showDebugTools) + return; + + EditorGUI.indentLevel++; + + EditorGUILayout.HelpBox("이 도구는 서버에서만 작동합니다.", MessageType.Info); + + // 페이즈 강제 전환 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("페이즈 강제 전환", GUILayout.Width(120)); + selectedPhaseIndex = EditorGUILayout.IntSlider(selectedPhaseIndex, 0, Mathf.Max(0, boss.TotalPhases - 1)); + if (GUILayout.Button("전환", GUILayout.Width(60))) + { + if (Application.isPlaying) + { + boss.ForcePhaseTransition(selectedPhaseIndex); + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 현재 페이즈 재시작 + if (GUILayout.Button("현재 페이즈 재시작")) + { + if (Application.isPlaying) + { + boss.RestartCurrentPhase(); + } + } + + EditorGUILayout.Space(5); + + // HP 조작 + EditorGUILayout.LabelField("HP 조작", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("HP 10%")) + { + SetBossHP(0.1f); + } + if (GUILayout.Button("HP 30%")) + { + SetBossHP(0.3f); + } + if (GUILayout.Button("HP 50%")) + { + SetBossHP(0.5f); + } + if (GUILayout.Button("HP 100%")) + { + SetBossHP(1f); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 커스텀 조건 + EditorGUILayout.LabelField("커스텀 조건 설정", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("조건 ID:", GUILayout.Width(60)); + string conditionId = EditorGUILayout.TextField("Enraged"); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("활성화")) + { + boss.SetCustomCondition(conditionId, true); + } + if (GUILayout.Button("비활성화")) + { + boss.SetCustomCondition(conditionId, false); + } + EditorGUILayout.EndHorizontal(); + + EditorGUI.indentLevel--; + } + + /// + /// HP 설정 (서버에서만) + /// + private void SetBossHP(float percent) + { + if (!Application.isPlaying) + return; + + float targetHP = boss.MaxHealth * percent; + float damage = boss.CurrentHealth - targetHP; + + if (damage > 0) + { + boss.TakeDamage(damage); + } + else if (damage < 0) + { + boss.Heal(-damage); + } + } + + /// + /// 진행 바 그리기 + /// + private void DrawProgressBar(string label, float value, Color color, string text = "") + { + Rect rect = EditorGUILayout.GetControlRect(); + rect.height = 20f; + + // 레이블 + Rect labelRect = new Rect(rect.x, rect.y, 60, rect.height); + EditorGUI.LabelField(labelRect, label); + + // 바 + Rect barRect = new Rect(rect.x + 65, rect.y, rect.width - 65, rect.height); + EditorGUI.DrawRect(barRect, new Color(0.2f, 0.2f, 0.2f)); + + Rect fillRect = new Rect(barRect.x, barRect.y, barRect.width * Mathf.Clamp01(value), barRect.height); + EditorGUI.DrawRect(fillRect, color); + + // 텍스트 + if (!string.IsNullOrEmpty(text)) + { + GUIStyle centeredStyle = new GUIStyle(EditorStyles.label) + { + alignment = TextAnchor.MiddleCenter + }; + EditorGUI.LabelField(barRect, text, centeredStyle); + } + } + + /// + /// HP 비율에 따른 색상 반환 + /// + private Color GetHealthColor(float percent) + { + if (percent > 0.6f) + return new Color(0.2f, 0.8f, 0.2f); // 녹색 + if (percent > 0.3f) + return new Color(0.9f, 0.7f, 0.1f); // 노란색 + return new Color(0.9f, 0.2f, 0.2f); // 빨간색 + } + + /// + /// 상태 텍스트 반환 + /// + private string GetStatusText() + { + if (boss.IsDead) + return "사망"; + if (boss.IsTransitioning) + return "페이즈 전환 중"; + return "활성"; + } + + /// + /// 전환 조건 텍스트 반환 + /// + private string GetTransitionConditionText(BossPhaseData phase) + { + return phase.TransitionType switch + { + PhaseTransitionType.HealthPercent => $"HP ≤ {phase.HealthPercentThreshold * 100:F0}%", + PhaseTransitionType.TimeElapsed => $"시간 ≥ {phase.TimeThreshold:F0}초", + PhaseTransitionType.CustomCondition => $"조건: {phase.CustomConditionId}", + PhaseTransitionType.Manual => "수동 전환", + _ => "알 수 없음" + }; + } + } +} +#endif diff --git a/Assets/Scripts/Editor/BossEnemyEditor.cs.meta b/Assets/Scripts/Editor/BossEnemyEditor.cs.meta new file mode 100644 index 00000000..e309321d --- /dev/null +++ b/Assets/Scripts/Editor/BossEnemyEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 867ffff975b9a7a4694783f4a5ee1c6e \ No newline at end of file diff --git a/Assets/Scripts/Enemy/BossEnemy.cs b/Assets/Scripts/Enemy/BossEnemy.cs new file mode 100644 index 00000000..4a5431d8 --- /dev/null +++ b/Assets/Scripts/Enemy/BossEnemy.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Unity.Netcode; +using Unity.Behavior; +using Colosseum.Stats; + +namespace Colosseum.Enemy +{ + /// + /// 보스 캐릭터. 페이즈 시스템과 동적 AI 전환을 지원합니다. + /// Unity Behavior 패키지를 사용하여 Behavior Tree 기반 AI를 구현합니다. + /// + public class BossEnemy : EnemyBase + { + [Header("Boss Settings")] + [Tooltip("보스 페이즈 데이터 목록 (순서대로 전환)")] + [SerializeField] private List phases = new(); + + [Tooltip("초기 Behavior Graph")] + [SerializeField] private BehaviorGraph initialBehaviorGraph; + + [Header("Phase Settings")] + [Tooltip("페이즈 전환 시 무적 시간")] + [Min(0f)] [SerializeField] private float phaseTransitionInvincibilityTime = 2f; + + [Tooltip("페이즈 전환 연출 시간")] + [Min(0f)] [SerializeField] private float phaseTransitionDuration = 3f; + + [Header("Debug")] + [SerializeField] private bool debugMode = true; + + // 컴포넌트 + private BehaviorGraphAgent behaviorAgent; + + // 페이즈 상태 + private int currentPhaseIndex = 0; + private bool isTransitioning = false; + private float phaseStartTime; + private float phaseElapsedTime; + private bool isInvincible = false; + + // 커스텀 조건 딕셔너리 + private Dictionary customConditions = new Dictionary(); + + // 이벤트 + public event System.Action OnPhaseChanged; // phaseIndex + public event System.Action OnPhaseTransitionStart; // transitionDuration + public event System.Action OnPhaseTransitionEnd; + + // Properties + public int CurrentPhaseIndex => currentPhaseIndex; + public BossPhaseData CurrentPhase => phases.Count > currentPhaseIndex ? phases[currentPhaseIndex] : null; + public int TotalPhases => phases.Count; + public bool IsTransitioning => isTransitioning; + public float PhaseElapsedTime => phaseElapsedTime; + + public override void OnNetworkSpawn() + { + base.OnNetworkSpawn(); + + // BehaviorGraphAgent 컴포넌트 확인/추가 + behaviorAgent = GetComponent(); + if (behaviorAgent == null) + { + behaviorAgent = gameObject.AddComponent(); + } + + // 초기 AI 설정 + if (IsServer && initialBehaviorGraph != null) + { + behaviorAgent.Graph = initialBehaviorGraph; + } + } + + protected override void InitializeStats() + { + base.InitializeStats(); + phaseStartTime = Time.time; + phaseElapsedTime = 0f; + currentPhaseIndex = 0; + isTransitioning = false; + isInvincible = false; + customConditions.Clear(); + } + + private void Update() + { + if (!IsServer || IsDead || isTransitioning) + return; + + phaseElapsedTime = Time.time - phaseStartTime; + + // 다음 페이즈 전환 조건 확인 + CheckPhaseTransition(); + } + + /// + /// 페이즈 전환 조건 확인 + /// + private void CheckPhaseTransition() + { + int nextPhaseIndex = currentPhaseIndex + 1; + if (nextPhaseIndex >= phases.Count) + return; + + BossPhaseData nextPhase = phases[nextPhaseIndex]; + if (nextPhase == null) + return; + + if (nextPhase.CheckTransitionCondition(this, phaseElapsedTime)) + { + StartPhaseTransition(nextPhaseIndex); + } + } + + /// + /// 페이즈 전환 시작 + /// + private void StartPhaseTransition(int newPhaseIndex) + { + if (newPhaseIndex >= phases.Count || isTransitioning) + return; + + isTransitioning = true; + isInvincible = true; + + StartCoroutine(PhaseTransitionCoroutine(newPhaseIndex)); + } + + private System.Collections.IEnumerator PhaseTransitionCoroutine(int newPhaseIndex) + { + BossPhaseData newPhase = phases[newPhaseIndex]; + + // 전환 이벤트 + OnPhaseTransitionStart?.Invoke(phaseTransitionDuration); + + // 전환 연출 + yield return PlayPhaseTransitionEffect(newPhase); + + // AI 그래프 교체 + if (newPhase.BehaviorGraph != null && behaviorAgent != null) + { + behaviorAgent.End(); + behaviorAgent.Graph = newPhase.BehaviorGraph; + } + + // 페이즈 전환 완료 + currentPhaseIndex = newPhaseIndex; + phaseStartTime = Time.time; + phaseElapsedTime = 0f; + + // 무적 해제 + yield return new WaitForSeconds(phaseTransitionInvincibilityTime); + isInvincible = false; + isTransitioning = false; + + OnPhaseTransitionEnd?.Invoke(); + OnPhaseChanged?.Invoke(currentPhaseIndex); + + if (debugMode) + { + Debug.Log($"[Boss] Phase transition: {currentPhaseIndex} ({newPhase.PhaseName})"); + } + } + + /// + /// 페이즈 전환 연출 재생 + /// + private System.Collections.IEnumerator PlayPhaseTransitionEffect(BossPhaseData newPhase) + { + // 애니메이션 재생 + if (animator != null && newPhase.PhaseStartAnimation != null) + { + animator.Play(newPhase.PhaseStartAnimation.name); + } + + // 이펙트 생성 + if (newPhase.PhaseTransitionEffect != null) + { + var effect = Instantiate(newPhase.PhaseTransitionEffect, transform.position, transform.rotation); + Destroy(effect, phaseTransitionDuration); + } + + // 전환 시간 대기 + yield return new WaitForSeconds(phaseTransitionDuration); + } + + /// + /// 대미지 적용 (무적 상태 고려) + /// + public override float TakeDamage(float damage, object source = null) + { + if (isInvincible) + return 0f; + + return base.TakeDamage(damage, source); + } + + /// + /// 커스텀 조건 설정 + /// + public void SetCustomCondition(string conditionId, bool value) + { + customConditions[conditionId] = value; + } + + /// + /// 커스텀 조건 확인 + /// + public bool CheckCustomCondition(string conditionId) + { + return customConditions.TryGetValue(conditionId, out bool value) && value; + } + + /// + /// 수동으로 페이즈 전환 + /// + public void ForcePhaseTransition(int phaseIndex) + { + if (!IsServer) + return; + + if (phaseIndex >= 0 && phaseIndex < phases.Count && phaseIndex != currentPhaseIndex) + { + StartPhaseTransition(phaseIndex); + } + } + + /// + /// 현재 페이즈 재시작 + /// + public void RestartCurrentPhase() + { + if (!IsServer) + return; + + phaseStartTime = Time.time; + phaseElapsedTime = 0f; + + if (behaviorAgent != null) + { + behaviorAgent.Restart(); + } + } + + protected override void HandleDeath() + { + // 마지막 페이즈에서만 사망 처리 + if (currentPhaseIndex < phases.Count - 1 && !isTransitioning) + { + // 아직 페이즈가 남아있으면 강제로 다음 페이즈로 + StartPhaseTransition(currentPhaseIndex + 1); + return; + } + + base.HandleDeath(); + + // AI 정지 + if (behaviorAgent != null) + { + behaviorAgent.End(); + } + } + + #region Debug + + private void OnDrawGizmosSelected() + { + if (!debugMode) + return; + + // 현재 페이즈 정보 표시 + #if UNITY_EDITOR + if (phases != null && currentPhaseIndex < phases.Count) + { + var phase = phases[currentPhaseIndex]; + if (phase != null) + { + UnityEditor.Handles.Label( + transform.position + Vector3.up * 3f, + $"Phase {currentPhaseIndex + 1}/{phases.Count}\n{phase.PhaseName}" + ); + } + } + #endif + } + + #endregion + } +} diff --git a/Assets/Scripts/Enemy/BossEnemy.cs.meta b/Assets/Scripts/Enemy/BossEnemy.cs.meta new file mode 100644 index 00000000..bfae6985 --- /dev/null +++ b/Assets/Scripts/Enemy/BossEnemy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a49d1cf004a0c944be905fe6fabf936 \ No newline at end of file