chore: Assets 디렉토리 구조 정리 및 네이밍 컨벤션 적용
- Assets/_Game/ 하위로 게임 에셋 통합 - External/ 패키지 벤더별 분류 (Synty, Animations, UI) - 에셋 네이밍 컨벤션 확립 및 적용 (Data_Skill_, Data_SkillEffect_, Prefab_, Anim_, Model_, BT_ 등) - pre-commit hook으로 네이밍 컨벤션 자동 검사 추가 - RESTRUCTURE_CHECKLIST.md 작성 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
250
Assets/_Game/Scripts/Enemy/BossArea.cs
Normal file
250
Assets/_Game/Scripts/Enemy/BossArea.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.UI;
|
||||
using Colosseum.Player;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 영역 트리거.
|
||||
/// 플레이어가 이 영역에 진입하면 연결된 보스의 체력바 UI를 표시합니다.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider))]
|
||||
public class BossArea : MonoBehaviour
|
||||
{
|
||||
[Header("Boss Reference")]
|
||||
[Tooltip("이 영역에 연결된 보스")]
|
||||
[SerializeField] private BossEnemy boss;
|
||||
|
||||
[Header("UI Settings")]
|
||||
[Tooltip("보스 체력바 UI (없으면 씬에서 자동 검색)")]
|
||||
[SerializeField] private BossHealthBarUI bossHealthBarUI;
|
||||
|
||||
[Header("Trigger Settings")]
|
||||
[Tooltip("플레이어 퇴장 시 UI 숨김 여부")]
|
||||
[SerializeField] private bool hideOnExit = false;
|
||||
|
||||
[Tooltip("영역 진입 시 한 번만 표시")]
|
||||
[SerializeField] private bool showOnceOnly = false;
|
||||
|
||||
// 이벤트
|
||||
/// <summary>
|
||||
/// 플레이어 진입 시 호출
|
||||
/// </summary>
|
||||
public event Action OnPlayerEnter;
|
||||
|
||||
/// <summary>
|
||||
/// 플레이어 퇴장 시 호출
|
||||
/// </summary>
|
||||
public event Action OnPlayerExit;
|
||||
|
||||
// 상태
|
||||
private bool hasShownUI = false;
|
||||
private bool isPlayerInArea = false;
|
||||
private Collider triggerCollider;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugMode = false;
|
||||
|
||||
/// <summary>
|
||||
/// 연결된 보스
|
||||
/// </summary>
|
||||
public BossEnemy Boss => boss;
|
||||
|
||||
/// <summary>
|
||||
/// 플레이어가 영역 내에 있는지 여부
|
||||
/// </summary>
|
||||
public bool IsPlayerInArea => isPlayerInArea;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// Collider 설정 확인
|
||||
triggerCollider = GetComponent<Collider>();
|
||||
if (triggerCollider != null && !triggerCollider.isTrigger)
|
||||
{
|
||||
Debug.LogWarning($"[BossArea] {name}: Collider가 Trigger가 아닙니다. 자동으로 Trigger로 설정합니다.");
|
||||
triggerCollider.isTrigger = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// BossHealthBarUI 자동 검색
|
||||
if (bossHealthBarUI == null)
|
||||
{
|
||||
bossHealthBarUI = FindFirstObjectByType<BossHealthBarUI>();
|
||||
if (bossHealthBarUI == null)
|
||||
{
|
||||
Debug.LogWarning($"[BossArea] {name}: BossHealthBarUI를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 보스 참조 확인
|
||||
if (boss == null)
|
||||
{
|
||||
Debug.LogWarning($"[BossArea] {name}: 연결된 보스가 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
// 이미 표시했고 한 번만 표시 설정이면 무시
|
||||
if (showOnceOnly && hasShownUI)
|
||||
return;
|
||||
|
||||
// 플레이어 확인 (태그 또는 컴포넌트)
|
||||
if (!IsPlayer(other, out var playerController))
|
||||
return;
|
||||
|
||||
isPlayerInArea = true;
|
||||
ShowBossHealthBar();
|
||||
|
||||
OnPlayerEnter?.Invoke();
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[BossArea] {name}: 플레이어 진입 - 보스: {boss?.name ?? "없음"}");
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
// 플레이어 확인
|
||||
if (!IsPlayer(other, out var playerController))
|
||||
return;
|
||||
|
||||
isPlayerInArea = false;
|
||||
|
||||
if (hideOnExit)
|
||||
{
|
||||
HideBossHealthBar();
|
||||
}
|
||||
|
||||
OnPlayerExit?.Invoke();
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[BossArea] {name}: 플레이어 퇴장");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 체력바 표시
|
||||
/// </summary>
|
||||
public void ShowBossHealthBar()
|
||||
{
|
||||
if (boss == null || bossHealthBarUI == null)
|
||||
return;
|
||||
|
||||
// BossHealthBarUI에 보스 설정
|
||||
bossHealthBarUI.SetBoss(boss);
|
||||
hasShownUI = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 체력바 숨김
|
||||
/// </summary>
|
||||
public void HideBossHealthBar()
|
||||
{
|
||||
if (bossHealthBarUI == null)
|
||||
return;
|
||||
|
||||
bossHealthBarUI.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 플레이어 여부 확인
|
||||
/// </summary>
|
||||
private bool IsPlayer(Collider other, out PlayerNetworkController playerController)
|
||||
{
|
||||
playerController = null;
|
||||
|
||||
// 1. 태그로 확인
|
||||
if (other.CompareTag("Player"))
|
||||
{
|
||||
playerController = other.GetComponent<PlayerNetworkController>();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 컴포넌트로 확인
|
||||
playerController = other.GetComponent<PlayerNetworkController>();
|
||||
if (playerController != null)
|
||||
return true;
|
||||
|
||||
// 3. 부모에서 검색
|
||||
playerController = other.GetComponentInParent<PlayerNetworkController>();
|
||||
return playerController != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 수동 설정
|
||||
/// </summary>
|
||||
public void SetBoss(BossEnemy newBoss)
|
||||
{
|
||||
boss = newBoss;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UI 수동 설정
|
||||
/// </summary>
|
||||
public void SetHealthBarUI(BossHealthBarUI ui)
|
||||
{
|
||||
bossHealthBarUI = ui;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 상태 초기화 (재진입 허용)
|
||||
/// </summary>
|
||||
public void ResetState()
|
||||
{
|
||||
hasShownUI = false;
|
||||
isPlayerInArea = false;
|
||||
}
|
||||
|
||||
#region Debug Gizmos
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!debugMode)
|
||||
return;
|
||||
|
||||
// 영역 시각화
|
||||
Gizmos.color = new Color(1f, 0.5f, 0f, 0.3f); // 주황색 반투명
|
||||
|
||||
var col = GetComponent<Collider>();
|
||||
if (col is BoxCollider boxCol)
|
||||
{
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.DrawCube(boxCol.center, boxCol.size);
|
||||
}
|
||||
else if (col is SphereCollider sphereCol)
|
||||
{
|
||||
Gizmos.DrawSphere(transform.position + sphereCol.center, sphereCol.radius);
|
||||
}
|
||||
else if (col is CapsuleCollider capsuleCol)
|
||||
{
|
||||
// 캡슐은 구+실린더로 근접 표현
|
||||
Gizmos.DrawWireSphere(transform.position + capsuleCol.center, capsuleCol.radius);
|
||||
}
|
||||
|
||||
// 보스 연결 표시
|
||||
if (boss != null)
|
||||
{
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawLine(transform.position, boss.transform.position);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// 선택 시 더 명확하게 표시
|
||||
Gizmos.color = new Color(1f, 0.3f, 0f, 0.5f);
|
||||
|
||||
var col = GetComponent<Collider>();
|
||||
if (col is BoxCollider boxCol)
|
||||
{
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.DrawCube(boxCol.center, boxCol.size);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/BossArea.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/BossArea.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1735054b0ca6d674b99668aeb74ba273
|
||||
315
Assets/_Game/Scripts/Enemy/BossEnemy.cs
Normal file
315
Assets/_Game/Scripts/Enemy/BossEnemy.cs
Normal file
@@ -0,0 +1,315 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
using Unity.Behavior;
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 캐릭터. 페이즈 시스템과 동적 AI 전환을 지원합니다.
|
||||
/// Unity Behavior 패키지를 사용하여 Behavior Tree 기반 AI를 구현합니다.
|
||||
/// </summary>
|
||||
|
||||
public class BossEnemy : EnemyBase
|
||||
{
|
||||
[Header("Boss Settings")]
|
||||
[Tooltip("보스 페이즈 데이터 목록 (순서대로 전환)")]
|
||||
[SerializeField] private List<BossPhaseData> 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<string, bool> customConditions = new Dictionary<string, bool>();
|
||||
|
||||
// 이벤트
|
||||
public event System.Action<int> OnPhaseChanged; // phaseIndex
|
||||
public event System.Action<float> OnPhaseTransitionStart; // transitionDuration
|
||||
public event System.Action OnPhaseTransitionEnd;
|
||||
// 정적 이벤트 (UI 자동 연결용)
|
||||
/// <summary>
|
||||
/// 보스 스폰 시 발생하는 정적 이벤트
|
||||
/// </summary>
|
||||
public static event System.Action<BossEnemy> OnBossSpawned;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 활성화된 보스 (Scene에 하나만 존재한다고 가정)
|
||||
/// </summary>
|
||||
public static BossEnemy ActiveBoss { get; private set; }
|
||||
|
||||
// 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<BehaviorGraphAgent>();
|
||||
if (behaviorAgent == null)
|
||||
{
|
||||
behaviorAgent = gameObject.AddComponent<BehaviorGraphAgent>();
|
||||
}
|
||||
|
||||
// 초기 AI 설정
|
||||
if (IsServer && initialBehaviorGraph != null)
|
||||
{
|
||||
behaviorAgent.Graph = initialBehaviorGraph;
|
||||
}
|
||||
|
||||
// 정적 이벤트 발생 (UI 자동 연결용)
|
||||
ActiveBoss = this;
|
||||
OnBossSpawned?.Invoke(this);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[Boss] Boss spawned: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 조건 확인
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 시작
|
||||
/// </summary>
|
||||
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})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 연출 재생
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 적용 (무적 상태 고려)
|
||||
/// </summary>
|
||||
public override float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
if (isInvincible)
|
||||
return 0f;
|
||||
|
||||
return base.TakeDamage(damage, source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커스텀 조건 설정
|
||||
/// </summary>
|
||||
public void SetCustomCondition(string conditionId, bool value)
|
||||
{
|
||||
customConditions[conditionId] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커스텀 조건 확인
|
||||
/// </summary>
|
||||
public bool CheckCustomCondition(string conditionId)
|
||||
{
|
||||
return customConditions.TryGetValue(conditionId, out bool value) && value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 수동으로 페이즈 전환
|
||||
/// </summary>
|
||||
public void ForcePhaseTransition(int phaseIndex)
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
|
||||
if (phaseIndex >= 0 && phaseIndex < phases.Count && phaseIndex != currentPhaseIndex)
|
||||
{
|
||||
StartPhaseTransition(phaseIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 페이즈 재시작
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
// AI 완전 중단 (순서 중요: enabled=false를 먼저 호출하여 Update() 차단)
|
||||
if (behaviorAgent != null)
|
||||
{
|
||||
behaviorAgent.enabled = false; // 가장 먼저: Update() 호출 방지
|
||||
behaviorAgent.End();
|
||||
behaviorAgent.Graph = null;
|
||||
}
|
||||
behaviorAgent = null;
|
||||
|
||||
base.HandleDeath();
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/BossEnemy.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/BossEnemy.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a49d1cf004a0c944be905fe6fabf936
|
||||
123
Assets/_Game/Scripts/Enemy/BossPhaseData.cs
Normal file
123
Assets/_Game/Scripts/Enemy/BossPhaseData.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Behavior;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 페이즈 전환 조건 타입
|
||||
/// </summary>
|
||||
public enum PhaseTransitionType
|
||||
{
|
||||
HealthPercent, // 체력 비율 기반
|
||||
TimeElapsed, // 시간 경과
|
||||
CustomCondition, // 커스텀 조건 (코드에서 설정)
|
||||
Manual, // 수동 전환
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 페이즈 데이터. 각 페이즈의 AI, 조건, 보상을 정의합니다.
|
||||
/// </summary>
|
||||
[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<BlackboardVariableOverride> 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<BlackboardVariableOverride> BlackboardOverrides => blackboardOverrides;
|
||||
public AnimationClip PhaseStartAnimation => phaseStartAnimation;
|
||||
public GameObject PhaseTransitionEffect => phaseTransitionEffect;
|
||||
|
||||
/// <summary>
|
||||
/// 전환 조건 충족 여부 확인
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blackboard 변수 오버라이드 정보
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blackboard 변수 타입
|
||||
/// </summary>
|
||||
public enum BlackboardVariableType
|
||||
{
|
||||
Float,
|
||||
Int,
|
||||
Bool,
|
||||
String,
|
||||
GameObject,
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/BossPhaseData.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/BossPhaseData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e9dd028b74b2124895ac9673115a9b9
|
||||
126
Assets/_Game/Scripts/Enemy/EnemyAnimationController.cs
Normal file
126
Assets/_Game/Scripts/Enemy/EnemyAnimationController.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 적 캐릭터 애니메이션 컨트롤러
|
||||
/// NavMeshAgent 속도에 따라 Idle/Move 애니메이션 제어
|
||||
/// </summary>
|
||||
public class EnemyAnimationController : NetworkBehaviour
|
||||
{
|
||||
[Header("Animation Parameters")]
|
||||
[SerializeField] private string speedParam = "Speed";
|
||||
[SerializeField] private string attackTriggerParam = "Attack";
|
||||
[SerializeField] private string skillTriggerParam = "Skill";
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float speedSmoothTime = 0.1f;
|
||||
[SerializeField] private float stopThreshold = 0.05f;
|
||||
|
||||
private Animator animator;
|
||||
private UnityEngine.AI.NavMeshAgent navMeshAgent;
|
||||
private EnemyBase enemyBase;
|
||||
private float currentSpeed;
|
||||
private float speedVelocity;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
animator = GetComponent<Animator>();
|
||||
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
enemyBase = GetComponent<EnemyBase>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!IsServer)
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
UpdateAnimationParameters();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 파라미터 업데이트
|
||||
/// </summary>
|
||||
private void UpdateAnimationParameters()
|
||||
{
|
||||
// 사망 상태에서는 애니메이션 파라미터 업데이트 중단
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
if (animator == null || navMeshAgent == null)
|
||||
return;
|
||||
|
||||
// NavMeshAgent의 속도를 기반으로 타겟 속도 계산
|
||||
float targetSpeed = navMeshAgent.velocity.magnitude;
|
||||
|
||||
// 정지 임계값 처리
|
||||
if (targetSpeed < stopThreshold)
|
||||
targetSpeed = 0f;
|
||||
|
||||
// 부드러운 속도 변화
|
||||
currentSpeed = Mathf.SmoothDamp(currentSpeed, targetSpeed, ref speedVelocity, speedSmoothTime);
|
||||
|
||||
// 애니메이터 파라미터 설정
|
||||
animator.SetFloat(speedParam, currentSpeed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 공격 애니메이션 트리거 (외부에서 호출)
|
||||
/// </summary>
|
||||
public void PlayAttack()
|
||||
{
|
||||
if (!IsServer || animator == null)
|
||||
return;
|
||||
|
||||
// 사망 상태에서는 공격 애니메이션 재생하지 않음
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
animator.SetTrigger(attackTriggerParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 애니메이션 트리거 (외부에서 호출)
|
||||
/// </summary>
|
||||
public void PlaySkill()
|
||||
{
|
||||
if (!IsServer || animator == null)
|
||||
return;
|
||||
|
||||
// 사망 상태에서는 스킬 애니메이션 재생하지 않음
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
animator.SetTrigger(skillTriggerParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 애니메이션 트리거 재생
|
||||
/// </summary>
|
||||
/// <param name="triggerName">트리거 파라미터 이름</param>
|
||||
public void PlayTrigger(string triggerName)
|
||||
{
|
||||
if (!IsServer || animator == null)
|
||||
return;
|
||||
|
||||
// 사망 상태에서는 일반 애니메이션 재생하지 않음
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
animator.SetTrigger(triggerName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 애니메이션 속도
|
||||
/// </summary>
|
||||
public float CurrentSpeed => currentSpeed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5443105bfa8e570438bb5dc62c58aca9
|
||||
227
Assets/_Game/Scripts/Enemy/EnemyBase.cs
Normal file
227
Assets/_Game/Scripts/Enemy/EnemyBase.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Combat;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 적 캐릭터 기본 클래스.
|
||||
/// 네트워크 동기화, 스탯 관리, 대미지 처리를 담당합니다.
|
||||
/// </summary>
|
||||
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<float> currentHealth = new NetworkVariable<float>(100f);
|
||||
protected NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
|
||||
protected NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
|
||||
|
||||
// 이벤트
|
||||
public event Action<float, float> OnHealthChanged; // currentHealth, maxHealth
|
||||
public event Action<float> 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<CharacterStats>();
|
||||
if (animator == null)
|
||||
animator = GetComponentInChildren<Animator>();
|
||||
if (navMeshAgent == null)
|
||||
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
|
||||
// 서버에서 초기화
|
||||
if (IsServer)
|
||||
{
|
||||
InitializeStats();
|
||||
}
|
||||
|
||||
// 클라이언트에서 체력 변화 감지
|
||||
currentHealth.OnValueChanged += OnHealthChangedInternal;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
currentHealth.OnValueChanged -= OnHealthChangedInternal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스탯 초기화 (서버에서만 실행)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 적용 (서버에서 실행)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 피드백 (애니메이션, 이펙트)
|
||||
/// </summary>
|
||||
protected virtual void OnTakeDamageFeedback(float damage, object source)
|
||||
{
|
||||
if (animator != null)
|
||||
{
|
||||
animator.SetTrigger("Hit");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 체력 회복 (서버에서 실행)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사망 애니메이션 재생 (모든 클라이언트에서 실행)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Everyone)]
|
||||
private void PlayDeathAnimationRpc()
|
||||
{
|
||||
if (animator != null)
|
||||
{
|
||||
// EnemyAnimationController 비활성화 (더 이상 애니메이션 제어하지 않음)
|
||||
var animController = GetComponent<EnemyAnimationController>();
|
||||
if (animController != null)
|
||||
{
|
||||
animController.enabled = false;
|
||||
}
|
||||
|
||||
// 모든 트리거 리셋
|
||||
animator.ResetTrigger("Attack");
|
||||
animator.ResetTrigger("Skill");
|
||||
animator.ResetTrigger("Hit");
|
||||
animator.ResetTrigger("Jump");
|
||||
animator.ResetTrigger("Land");
|
||||
animator.ResetTrigger("Die");
|
||||
|
||||
// 즉시 Die 상태로 전환 (다른 애니메이션 중단)
|
||||
animator.Play("Die", 0, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사망 처리 (서버에서 실행)
|
||||
/// </summary>
|
||||
protected virtual void HandleDeath()
|
||||
{
|
||||
isDead.Value = true;
|
||||
|
||||
// 실행 중인 스킬 즉시 취소
|
||||
var skillController = GetComponent<Colosseum.Skills.SkillController>();
|
||||
if (skillController != null)
|
||||
{
|
||||
skillController.CancelSkill();
|
||||
}
|
||||
|
||||
// 모든 클라이언트에서 사망 애니메이션 재생
|
||||
PlayDeathAnimationRpc();
|
||||
|
||||
if (navMeshAgent != null)
|
||||
{
|
||||
navMeshAgent.isStopped = true;
|
||||
}
|
||||
|
||||
OnDeath?.Invoke();
|
||||
|
||||
Debug.Log($"[Enemy] {name} died!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 리스폰
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/EnemyBase.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/EnemyBase.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d928c3a8adf0b424886395e6864ce010
|
||||
68
Assets/_Game/Scripts/Enemy/EnemyData.cs
Normal file
68
Assets/_Game/Scripts/Enemy/EnemyData.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using UnityEngine;
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 적 캐릭터 데이터. 기본 스탯과 보상을 정의합니다.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// CharacterStats에 기본 스탯 적용
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/EnemyData.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/EnemyData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ecdc2379b078b246a0bd5c0fb58e346
|
||||
Reference in New Issue
Block a user