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:
2026-03-16 19:08:27 +09:00
parent 309bf5f48b
commit c265f980db
17251 changed files with 2630777 additions and 206 deletions

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1735054b0ca6d674b99668aeb74ba273

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a49d1cf004a0c944be905fe6fabf936

View 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,
}
}

View File

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

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5443105bfa8e570438bb5dc62c58aca9

View 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);
}
}
}

View File

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

View 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;
}
}
}

View File

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