보스 몬스터 확장 설계 (상속 기반 AI 확장과 스킬 시스템 통합)
목차
1. 시스템 요구 사항
보스 몬스터는 일반 몬스터가 이미 갖고 있는 FSM(순찰, 추적, 공격, 복귀, 피격, 사망)의 상태 구조를 그대로 유지한 채로, 보스 전용 기능(스킬, 페이즈 전환, 자동 회복, 강화된 사망 정리)을 덧붙일 수 있어야 한다.
즉, 보스 전투를 구현한다고 해서 CHASE/ATTACK/RETURN 같은 기존 상태의 전이 규칙이나 NavMeshAgent 기반 이동 규칙을 새로 만들거나 뒤엎지 않고, 부모가 제공하는 상태 소비 루프 위에 확장 로직만 얹는다.
보스는 체력 비율이 특정 구간(60% 이하, 30% 이하)에 진입했을 때 스킬이 단계적으로 활성화되며, 각 스킬은 쿨타임을 가지고 준비 상태가 되었을 때만 사용 가능해야 한다.
또한 보스가 시작 위치로 복귀해 안전한 구간(IDLE)에 들어간 경우 일정 주기로 체력을 회복하지만, 플레이어를 인지하거나 피격이 발생하면 회복은 즉시 중단되어 전투 긴장감을 유지해야 한다.
마지막으로 보스의 사망 처리에서는 일반 몬스터보다 더 많은 실행 흐름(코루틴, NavMesh 경로, 애니메이터 트리거, 스킬 트리거 등)이 동시에 얽혀 있기 때문에, 사망 시점에서 잔여 실행을 완전히 정리하여 애니메이션 꼬임, 경로 잔존, 중복 호출 같은 런타임 불안정이 발생하지 않도록 해야 한다.
2. 설계 목표
- Mushroom을 상속하여 기존 FSM과 이동/전이 규칙을 재사용
- virtual/override로 필요한 지점만 확장하고, 상태 구조 자체는 변경하지 않기
- 체력 구간 기반 스킬 활성화(페이즈)와 쿨타임 기반 실행 제어 결합
- ATTACK 상태에서 '스킬 우선 시도 → 실패 시 기본 공격' 폴백 구조로 공격을 확장
- RETURN 완료 후 IDLE 진입 시 자동 회복 시작, 전투 진입/피격 시 회복 즉시 중단
- 사망 시, 코루틴, 경로, 애니메이터 트리거, 스킬 트리거까지 포함한 완전 정리로 안정성 확보
3. 흐름도
MushroomBoss (Mushroom 상속)
↓
Update() : 쿨타임 갱신 → base.Update()로 FSM 소비
↓
FSM이 ATTACK 상태로 진입
↓
UpdateAttack() override
├ base.UpdateAttack() 실행 (거리/영역 조건 전이 유지)
└ TryUseSkill() 시도
├ 성공: 스킬 애니메이션 Trigger
└ 실패: 기본 공격 Trigger
↓
애니메이션 이벤트 시점에서 데미지 적용(스킬별 판정 방식)
---
RETURN 상태(시작 위치 복귀)
↓
base.UpdateReturn()에서 도착 판정 → IDLE 전이
↓
(RETURN → IDLE 전이 순간 감지)
StartRecovery()로 회복 코루틴 시작
↓
5초마다 체력 회복 + HP UI 갱신
↓
플레이어 인지 또는 피격
StopRecovery()로 회복 즉시 중단
이 흐름의 핵심은 보스가 새로운 상태 머신을 만드는 것이 아니라, 부모가 이미 검증한 상태 전이 구조를 그대로 신뢰하고 base 호출로 유지한 뒤, 그 위에서 스킬 · 회복 · 사망 정리 같은 보스 기능을 실행 계층으로만 확장한다는 점이다.
특히 전투 중 전이(영역 초과, 인지 범위 이탈, 공격 거리 이탈)는 전투 안정성에 직접 영향을 주기 때문에, 보스가 이를 재구현하지 않고 부모 로직을 먼저 호출해 전이 규칙을 일관되게 유지하도록 설계하였다.
4. 구현
4.1. 상속 기반 확장 구조
public class MushroomBoss : MushroomMushroomBoss가 Mushroom을 상속받는 것은, 이미 구축된 FSM 실행 루프와 NavMeshAgent 기반 이동/전이 규칙을 재사용하기 위한 결정이다.
특히 Mushroom의 상태 함수들이 protected virtual로 설계되어 있기 때문에, 보스는 상태 구조(어떤 상태가 존재하는지, 어떤 조건에서 전이되는지)를 갈아엎지 않고도, 특정 상태에서의 추가 행동만 override로 끼워 넣을 수 있다.
이 방식은 기능 추가를 위해 상태 머신을 복제하거나 분기형 코드로 다시 쓰는 비용을 줄이고, 부모의 버그 수정/개선이 보스에도 동일하게 반영되는 장점이 있다.
반면 상속 기반 확장은 부모 구현에 대한 의존이 생기므로, 어떤 책임을 부모에 남기고 어떤 책임을 자식에서 가져올지를 명확히 분리해야 하는데, 이 코드는 전이 규칙은 부모, 보스 전용 기능은 자식이라는 경계를 유지하는 쪽으로 설계되어 있다.
4.2. Start · Update 확장
protected override void Start()
{
base.Start();
CreateHealthBar(2.5f);
}
protected override void Update()
{
if (isDie) return;
UpdateCooltime();
base.Update();
}Start에서 base.Start()를 먼저 호출하는 이유는, 부모가 수행하는 초기화(체력 초기화, 초기 state 설정, 순찰 시작, 체력바 생성 등)가 보스에게도 동일하게 적용되어야 하기 때문이다.
보스는 기본 몬스터와 같은 생애 주기 시작 규칙을 공유해야 하며, 그 위에서 체력바의 위치만 보스 키에 맞게 2.5f로 조정한다.
여기서 중요한 점은 부모가 만든 체력바를 다시 만드는 것이 아니라, 보스 체형에 맞춰 표현 계층의 배치만 조정한다는 점이다.
이로써 체력바 시스템의 책임 분리(몬스터는 데이터, HPBar는 표현)는 그대로 유지된다.
Update에서는 UpdateCooltime()을 먼저 호출하고 이후 base.Update()를 호출한다.
Update를 쓰는 이유는 쿨타임과 FSM 소비가 모두 프레임 단위로 즉시 반응해야 하는 로직이기 때문이다.
플레이어의 위치 변화, 공격 거리 진입, 영역 이탈 같은 조건은 매 프레임 재평가되어야 자연스럽고, 스킬 쿨타임 UI/로직도 프레임 진행과 함께 감소하는 형태가 일반적이다.
다만 쿨타임은 물리 시뮬레이션이 아니라 게임 로직 시간이므로 FixedUpdate에 둘 이유가 없고, base.Update가 이미 Update 기반으로 상태 머신을 소비하는 구조이기 때문에 동일한 루프에서 통합되는 것이 일관적이다.
또한 if (isDie) return;을 최상단에 두어 사망 이후에는 쿨타임 갱신이나 FSM 실행이 이어지지 않도록 차단함으로써, 사망 이후 잔여 로직이 살아서 발생하는 예외 상황을 예방한다.
4.3. 순찰 · 복귀 상태 확장 (회복 연동)
protected override void UpdateIdle(float dist)
{
if (dist <= chaseDist) StopRecovery();
base.UpdateIdle(dist);
}
protected override void UpdateReturn()
{
// 부모 로직 실행 (도착 체크)
bool wasReturning = state == State.RETURN;
base.UpdateReturn();
// 방금 도착해서 IDLE로 바뀌었다면 회복 시작
if (wasReturning && state == State.IDLE)
{
StartRecovery();
}
}UpdateIdle에서 dist가 chaseDist 이내라면 StopRecovery()를 호출하는 것은 전투 진입 조건이 만족되는 순간 회복을 즉시 끊는다는 정책을 코드로 고정하기 위함이다.
회복이 유지된 채로 CHASE/ATTACK이 진행되면 보스가 맞으면서도 체력이 차는 부자연스러운 체감이 발생할 수 있고, 전투 난이도도 의도치 않게 흔들릴 수 있다.
그 다음 base.UpdateIdle(dist)를 호출함으로써, IDLE 상태의 본래 전이 규칙(대기 타이머, 순찰 재개, CHASE 전환 등)은 부모의 책임으로 유지된다.
이 순서는 보스 확장 로직(회복 중단)이 부모 로직에 의해 덮이거나 지연되지 않도록 하는 의미도 가진다.
UpdateReturn에서는 wasReturning으로 이번 프레임이 RETURN에서 시작했는지를 기억해두고, base.UpdateReturn() 호출 이후에 state가 IDLE로 바뀌었는지를 확인한다.
이렇게 구현한 이유는 복귀 도착 판정과 상태 전이(RETURN → IDLE)가 부모 로직 안에서 수행되기 때문이다.
보스가 도착 판정을 재구현하면 NavMeshAgent의 pathPending/remainingDistance/stoppingDistance 같은 판정 규칙이 중복되고, 부모와 보스 사이에서 도착 조건이 달라져 상태 떨림이 생길 수 있다.
따라서 보스는 도착 판정과 전이는 부모에게 위임하고, 전이가 발생한 직후의 추가 행동(회복 시작)만 안전하게 후처리로 연결한다.
이 구조가 상속 확장의 핵심인 '규칙 유지 + 기능 주입'을 가장 깔끔하게 만족한다.
4.4. 자동 회복 시스템
private Coroutine recoveryCoroutine;
void StartRecovery()
{
if (recoveryCoroutine == null && curHp < maxHp)
{
recoveryCoroutine = StartCoroutine(RecoveryHpRoutine());
}
}
void StopRecovery()
{
if (recoveryCoroutine != null)
{
StopCoroutine(recoveryCoroutine);
recoveryCoroutine = null;
}
}
IEnumerator RecoveryHpRoutine()
{
while (curHp < maxHp)
{
yield return new WaitForSeconds(5f);
curHp = Mathf.Min(curHp + 10f, maxHp); // 5초당 10씩 회복
UpdateHpBar();
}
recoveryCoroutine = null;
}
회복은 Coroutine으로 구현되어 있는데, 이는 5초마다 회복이라는 요구사항이 프레임마다 타이머를 직접 깎는 방식보다 의도가 더 명확하고, 코드 구조가 시간 흐름을 그대로 드러내기 때문이다.
Unity의 Coroutine은 메인 스레드에서 실행되며, yield return new WaitForSeconds(5f)는 지정한 시간만큼 흐른 후 다음 루프를 진행하게 해준다.
장점은 Update에 회복 타이머 변수를 추가해 상태마다 타이머를 관리하는 복잡도가 줄어들고, 회복 로직이 독립된 실행 흐름으로 분리되어 읽기 쉽다는 점이다.
단점은 코루틴이 중복 실행되거나 중단 타이밍이 불명확해지면 예상치 못한 회복이 겹칠 수 있다는 점인데, 이를 막기 위해 recoveryCoroutine 참조를 보관하고 null 여부로 중복 실행을 차단한다.
또한 회복 중단은 StopCoroutine(recoveryCoroutine)로 즉시 처리하며, 참조를 null로 비워 다음 회복 재시작 조건을 명확히 만든다.
Mathf.Min을 사용하는 이유는 회복으로 인해 maxHp를 초과하는 오버힐을 구조적으로 차단하기 위함이다.
이렇게 하면 체력 데이터가 항상 [0, maxHp] 범위에 머물러 이후 스킬 페이즈 조건(0.6, 0.3) 같은 비율 기반 로직도 예측 가능한 상태를 유지한다.
그리고 UpdateHpBar()를 회복 직후 호출하는 것은, 보스의 체력 데이터 변화가 UI에 즉시 반영되어야 플레이어가 복귀하면 회복한다는 패턴을 학습하고 전투를 전략적으로 대응할 수 있기 때문이다.
4.5. 스킬 확장 구조
public bool skillFlip; // 스킬 번갈아 쓰기용
ublic BossSkill[] skills;
protected override void UpdateAttack(float dist, float distFromStart)
{
if (isDie || state == State.DIE) return;
base.UpdateAttack(dist, distFromStart);
// 스킬 사용 시도, 실패 시 기본 공격 애니메이션
if (!TryUseSkill())
{
anime.SetTrigger("Attack");
}
}
private bool TryUseSkill()
{
// 현재 체력 상황에 따라 사용 가능한 스킬 활성화
if (curHp <= maxHp * 0.6f) skills[0].useAble = true;
if (curHp <= maxHp * 0.3f) skills[1].useAble = true;
int idx = skillFlip ? 1 : 0;
// 선택된 스킬이 준비되지 않았다면 반대쪽 스킬 체크
if (!skills[idx].IsReady() || !skills[idx].useAble)
{
idx = 1 - idx;
}
if (skills[idx].useAble && skills[idx].IsReady())
{
skills[idx].UseSkill();
anime.SetTrigger(skills[idx].skillName);
skillFlip = !skillFlip; // 다음엔 다른 스킬 시도
return true;
}
return false;
}UpdateAttack에서 base.UpdateAttack()을 먼저 호출하는 것은 보스가 공격 상태에서도 부모가 정의한 전이 우선순위를 그대로 따라야 하기 때문이다.
예를 들어 distFromStart가 maxChaseDist를 넘었거나, dist가 chaseDist를 넘어 인지 범위를 벗어난 경우에는 공격보다 복귀가 우선되어야 하고, dist가 attackDist를 벗어나면 다시 CHASE로 전환되어야 한다.
이 전이 규칙은 전투 안정성을 좌우하므로, 보스가 공격 로직을 확장하더라도 전이 규칙은 부모가 먼저 처리하도록 보장한다.
그런 다음 TryUseSkill()을 호출하여 가능하면 스킬, 아니면 기본 공격으로 실행을 확장한다.
이 구조는 공격을 교체하는 것이 아니라, 기존 공격 체계에 스킬 레이어를 덧붙이는 방식이며, 스킬이 실패해도 반드시 기본 공격으로 폴백되므로 ATTACK 상태에서 아무 행동도 하지 않는 공백이 발생하지 않는다.
TryUseSkill에서는 체력 비율 기반으로 useAble을 켜는 방식으로 페이즈를 표현한다.
이 방식은 보스가 맞을수록 패턴이 늘어난다는 전투 체감을 만들기 쉽고, 조건이 코드에 명확히 드러나 디버깅도 쉬운 장점이 있다.
다만 배열 인덱스에 직접 의존하므로 스킬 수가 늘어나면 일반화가 필요해질 수 있는데, 현재는 2스킬 구조에 맞춰 의도적으로 단순화된 형태다.
IsReady()는 쿨타임이 끝났는지 확인하는 함수이고, UseSkill()은 쿨타임을 재설정하는 책임을 스킬 객체가 가지게 하여 보스는 스킬 선택과 트리거만 담당하고, 스킬의 쿨타임 내부 상태는 스킬이 책임진다는 책임 분리를 유지한다.
마지막으로 skillFlip은 특정 스킬만 반복하는 패턴이 나오지 않도록 시도 순서를 번갈아 주는 토글이며, 선택 스킬이 준비되지 않았을 때 반대 스킬을 확인하는 로직은 가능한 행동을 최대화하기 위한 보정이다.
4.6. 쿨타임 갱신
void UpdateCooltime()
{
foreach (var s in skills)
if (s.useAble) s.UpdateCooldown();
}쿨타임은 시간에 따라 감소하는 값이므로 매 프레임 갱신이 자연스럽다.
Update에서 수행하는 이유는 FSM이 Update 기반으로 상태를 소비하고 있고, 스킬 사용 가능 여부가 공격 상태에서 즉시 판단되어야 하기 때문이다.
FixedUpdate는 물리 틱 기준이라 프레임과 다르게 움직일 수 있고, 쿨타임 로직은 물리 정확성이 아니라 플레이 감각의 일관성이 중요하므로 Update가 더 적합하다.
또한 useAble이 false인 스킬은 아직 페이즈가 열리지 않은 상태이므로 갱신 대상에서 제외함으로써 불필요한 연산과 상태 변경을 줄이고 열린 스킬만 관리한다.
4.7 애니메이션 이벤트 기반 판정
void UpdateCooltime()
{
foreach (var s in skills)
if (s.useAble) s.UpdateCooldown();
}
public void UseJumpSkill()
{
if (isDie) return;
if (Vector3.Distance(transform.position, target.position) <= 3f)
StatusManager.instance.CurrentHp -= skills[0].damage;
}
public void UseKickSkill()
{
if (isDie) return;
if (isAtk)
StatusManager.instance.CurrentHp -= skills[1].damage;
}보스 스킬 데미지는 Update에서 즉시 적용하지 않고 애니메이션 이벤트에서 적용된다.
Unity의 애니메이션 이벤트는 특정 애니메이션 프레임에 함수 호출을 연결할 수 있기 때문에, 때리는 모션이 실제로 맞는 순간과 데미지가 들어가는 순간을 일치시키는 데 유리하다고 생각했다.
Jump 스킬은 거리 기반 판정을 사용해 착지/충격파 같은 느낌을 간단히 표현하고, Kick 스킬은 Trigger 기반 판정을 재사용하여 근접 범위에서만 유효하도록 만든다.
두 판정 방식이 섞여 있어도 공통 원칙은 동일한데, 데미지 적용은 항상 애니메이션 이벤트 타이밍에만 일어나므로 연출과 판정의 동기화가 유지되고, 플레이어 입장에서는 보이는 동작에 맞춰 피해를 받는다는 납득 가능한 피드백이 생긴다.
4.8. 피격 및 사망
protected override void UpdateDamaged()
{
StopRecovery(); // 맞으면 회복 중단
anime.SetTrigger("Damage");
state = (curHp <= 0) ? State.DIE : State.CHASE;
}
protected override void Die()
{
if (isDie) return;
isDie = true;
state = State.DIE;
StopAllCoroutines();
StopRecovery();
agent.isStopped = true;
agent.ResetPath();
// 기존 트리거 제거
anime.ResetTrigger("Attack");
anime.ResetTrigger("Damage");
foreach (var s in skills)
anime.ResetTrigger(s.skillName);
Animation(false, false, false, false);
anime.CrossFade("Die", 0.1f);
spawnMonster.instance.KillBoss(monsterName);
Destroy(gameObject, 2f);
}피격 시 StopRecovery()를 먼저 호출하는 것은, 공격 받으면 회복이 끊긴다는 전투 규칙을 가장 강한 우선순위로 적용하기 위함이다.
그리고 보스는 피격 이후 CHASE로 즉시 복귀하도록 재정의되어 있는데, 이는 보스가 피격으로 인해 전투 템포가 끊기지 않고 지속적으로 압박을 유지하도록 만든 선택이다.
사망 처리에서는 일반 몬스터보다 정리 단계가 강화되어 있다.
StopAllCoroutines()는 보스가 실행 중일 수 있는 모든 코루틴(회복뿐 아니라 다른 코루틴이 추가될 가능성 포함)을 한 번에 종료하여 잔여 실행이 사망 이후에도 계속되는 문제를 방지한다.
NavMeshAgent에 대해 ResetPath()를 호출하는 것은 사망 후에도 경로가 남아 이동 상태가 미묘하게 유지되거나, 다른 상태 전이가 남는 상황을 차단하기 위한 조치다.
Animator 트리거는 이벤트성 상태라 잔여 트리거가 남으면 죽는 도중에 공격/피격 트리거가 섞여 애니메이션이 꼬일 수 있는데, ResetTrigger로 이를 선제적으로 제거하고 CrossFade("Die", 0.1f)로 사망 애니메이션을 강제로 우선 적용한다.
마지막으로 보스 스폰 시스템에 사망을 통지하고 일정 시간 후 파괴하는 구조는 보스 사망 연출 시간을 확보하면서, 시스템적으로는 사망 이벤트를 즉시 반영한다는 목적을 동시에 만족한다.
5. 개발 의도
이 보스 몬스터 설계의 핵심은 기존 FSM을 교체하지 않고 확장한다는 원칙을 끝까지 유지하는 것이다.
보스는 새로운 상태 머신을 만들거나 기존 전이 규칙을 분기 코드로 복제하지 않고, base.Update와 base.UpdateAttack/base.UpdateReturn을 통해 부모가 가진 전이 우선순위와 이동 안정성을 그대로 재사용한다.
그 위에서 보스다운 전투 경험을 만들기 위해 체력 구간 기반 페이즈 스킬과 쿨타임 제어를 결합했고, 공격 상태에서는 스킬을 우선 시도하되 실패하면 기본 공격으로 폴백하여 전투 리듬이 끊기지 않게 했다.
복귀 후 자동 회복은 코루틴으로 시간 흐름을 독립시켜 구현 의도를 명확히 했고, 전투 진입이나 피격 시 즉시 중단되도록 가드를 두어 전투 긴장감을 유지했다.
마지막으로 보스 사망은 실행 흐름이 복잡해질수록 오류 가능성이 커지는 지점이므로, 코루틴, NavMesh 경로, 애니메이터 트리거를 모두 정리하는 완전 종료를 설계해 런타임 안정성을 확보했다.
결과적으로 이 구조는 기존 아키텍처를 재사용하면서도 보스 전투에 필요한 복잡한 패턴을 실행 계층 확장만으로 통합한 사례이며, 기능 추가가 구조 변경으로 번지지 않도록 경계를 유지한 설계라고 정리할 수 있다.
