몬스터 체력바 시스템 ( HP UI와 월드 오브젝트 연동 )
목차
1. 시스템 요구 사항
몬스터는 자신의 체력 상태를 플레이어에게 즉시 전달해야 하며, 이 정보는 월드 공간 상에서 몬스터 머리 위 UI 형태로 제공되어야 한다.
체력바는 전투 가독성을 해치지 않도록 항상 노출되지 않고, 플레이어와의 거리 조건을 만족할 때만 활성화되어야 한다.
또한 카메라 시점이 변해도 체력바는 읽기 쉬운 방향을 유지해야 하므로, 매 프레임 플레이어 시점(또는 카메라 시점)에 맞춰 정면을 유지하는 빌보드 처리가 필요하다.
체력 수치의 소유자는 몬스터이며, 체력바는 표현 계층(View)로서 체력 비율을 시각화만 해야 한다.
따라서 데미지 계산/HP 감소/사망 판정과 UI fillAmount 갱신/표시 정책이 섞이지 않도록, 데이터 소유와 표현 책임을 분리한 구조가 요구된다.
2. 설계 목표
- 몬스터가 체력 데이터(curHp/maxHp)를 소유하고, 체력바는 fillAmount를 이용하여 표현만 수행
- 체력바를 월드 공간 UI 프리팹으로 런타임 생성해 몬스터에 종속
- 플레이어 거리 조건으로 체력바 활성/비활성 제어
- 빌보드 처리로 플레이어 시점에서 항상 읽히는 방향을 유지
- 피격 시 체력 감소 후 즉시 UI 갱신(데이터 변경 → UI 반영 순서 보장)
3. 흐름도
[몬스터 스폰/Start]
↓
Monster.CreateHealthBar(y)
↓
MonsterHpBar.SetTarget(monster) + 초기 fillAmount=1
↓
(매 프레임) MonsterHpBar.Update() : 빌보드 정렬 + 타겟 유효성 검사
↓
(전투) Monster.GetDamage()
↓
curHp 감소 → Monster.UpdateHpBar()
↓
MonsterHpBar.UpdateHealthBar(curHp/maxHp)
↓
(거리 조건) Monster.HpBarActive(dist)로 SetActive 제어
↓
(사망/파괴) target null → MonsterHpBar가 자기 자신 Destroy(gameObject)
이 시스템은 몬스터가 체력바를 생성하고 갱신 트리거를 호출하지만, UI의 실제 표현은 MonsterHpBar가 담당하는 구조다.
즉 데이터 소유와 표현을 분리한 상태로, 런타임 생성/거리 정책/빌보딩/갱신 순서가 하나의 파이프라인으로 이어진다.
여기서 체력바 관련 클래스는 MonsterHpBar, Monster, Monster의 자식 클래스, 총 3개의 클래스가 존재한다.
MonsterHpBar 클래스는 표현 계층(View)이다.
이 클래스는 체력 계산을 하지 않으며, 체력 감소를 결정하지 않는다.
오직 다음 세 가지를 담당한다.
- Image.fillAmount를 통해 체력 비율을 시각적으로 표시
- SetTarget 함수로 어떤 몬스터를 바라봐야 하는지 참조만 보관
- Update 함수에서 빌보드 처리(플레이어 방향 회전)
즉, MonsterHpBar는 데이터를 받아서 화면에 보여주는 역할만 한다.
Monster 클래스는 데이터 소유자이자, 상태 관리 계층이다.
이 클래스는 다음을 담당한다.
- 체력 데이터(curHp, maxHp) 소유
- 데미지 처리(GetDamage)
- 체력 감소 시 체력바 업데이트 호출(UpdateHpBar)
- 체력바 생성(CreateHealthBar)
- 거리 기반 체력바 활성/비활성 제어(HpBarActive)
여기서 중요한 점은, Monster는 체력바를 직접 조작하지 않는다는 것이다.
Monster는 fillAmount를 직접 조작하지 않는다.
단지 체력 비율을 계산하여 체력바에 전달한다.
즉, 체력 계산은 Monster 클래스가 책임을 지고, 체력 표현은 MonsterHpBar 클래스가 책임진다.
Monster의 자식 클래스는 행동 계층이다.
중요한 점은 자식 클래스 역시 체력바 구조를 건드리지 않는다는 것이다.
이 클래스들은 다음을 담당한다.
- Start에서 체력바 생성 (CreateHealthBar)
- Update에서 체력바 활성화/비활성화 담당
체력 계산과 체력 변화 처리는 Monster가 담당한다.
체력 수치를 화면에 그리는 표현은 MonsterHpBar가 담당한다.
자식 클래스(Mushroom)는 플레이어 거리/상태에 따라 체력바를 보여줄지 숨길지 같은 표시 정책을 결정해 활성 상태를 제어한다.
4. 구현
4.1. 체력바 생성
// Monster.cs
public void CreateHealthBar(float y)
{
hpBar = Instantiate(Resources.Load<GameObject>("Prefabs/MonsterHpBar"), transform.position, Quaternion.identity);
hpBar.transform.SetParent(this.transform, false);
hpBar.gameObject.SetActive(false);
hpBar.transform.localPosition = new Vector3(0, y, 0);
hpBar.transform.localScale = new Vector3(0.005f, 0.005f, 0.005f);
hpBar.GetComponent<MonsterHpBar>().SetTarget(this);
}// Monster의 자식 클래스
protected virtual void Start()
{
...
CreateHealthBar(1.5f);
}CreateHealthBar 함수는 몬스터가 월드 UI(체력바)를 런타임에 생성하는 진입점이다.
Instantiate는 Unity에서 프리팹을 실제 오브젝트로 복제하는 API이며, 스폰된 몬스터마다 독립된 UI 인스턴스를 갖게 되어 개체 수가 늘어나도 동일한 규칙으로 동작한다는 장점이 있다.
Resources.Load는 런타임 경로 기반 로딩 방식이다.
문자열 의존성이 단점이지만, 체력바 프리팹이 단일이며 생성 시점이 몬스터 초기화에 한정되어 있어 구현 단순성을 우선했다.
대규모 프로젝트에서는 Addressables나 사전 캐싱이 더 적합하지만, 이 프로젝트에서는 HPBar 프리팹이 단일이며 생성 시점도 몬스터 초기화 구간에 한정되기 때문에, 구현 단순성이 더 큰 가치라고 판단한 선택이다.
체력바를 SetParent(this.transform, false)로 몬스터의 자식으로 붙인 이유는 이동 동기화를 자동으로 얻기 위해서다.
부모-자식 트랜스폼 관계에서 부모가 움직이면 자식도 함께 움직이므로, 매 프레임 체력바 위치를 추적하는 별도 코드가 필요 없다.
여기서 worldPositionStays를 false로 둔 것은 월드 좌표를 고정하는 대신 로컬 기준으로 정렬하겠다는 의미이며, 뒤에서 localPosition으로 머리 위 오프셋(y)을 안정적으로 적용하기 위한 선택이다.
즉, 몬스터의 머리 위에 둔다는 요구사항을 가장 단순하고 안정적으로 만족시키는 방식이 ‘자식 결합 + 로컬 오프셋’이다.
hpBar.SetActive(false)를 생성 직후 적용하여 비활성화 시켰다.
이는 거리 조건에 의해 보이는 UI라는 정책을 일관되게 유지하기 위함이다.
생성된 직후부터 화면에 튀어나오면 전투/탐색 UX가 깨질 수 있으므로, 기본 상태는 비활성화로 두고, 이후 HpBarActive에서만 활성화/비활성화 를 결정한다.
마지막으로 SetTarget(this)는 데이터 소유자(몬스터)를 표현자(체력바)에 연결하는 지점이며, 이 한 줄로 UI가 누구의 체력을 표시해야 하는지가 확정된다.
이때 UI는 체력 계산을 하지 않고, 단지 target 참조를 보관하고 fillAmount를 갱신하는 역할만 수행한다.
4.2. 타겟 설정
// MonsterHpBar.cs
public void SetTarget(Monster target)
{
this.target = target;
hpBarUI.fillAmount = 1.0f;
}// Monster.cs
public void CreateHealthBar(float y)
{
...
hpBar.GetComponent<MonsterHpBar>().SetTarget(this);
}SetTarget 함수는 체력바가 어떤 몬스터를 표시해야 하는지를 확정한다.
SetTarget 함수는 CreateHealthBar 함수 안에서 호출되어, 체력바가 생성될 때 실행된다.
여기서 fillAmount를 1로 초기화하는 이유는 생성 직후 체력 100% 표시를 보장하기 위해서다.
Image.fillAmount는 Unity UI에서 0~1 범위를 갖는 표준 게이지 표현 방식이며, 1은 꽉 찬 상태, 0은 비어 있는 상태를 의미한다.
즉, 체력바는 수치를 직접 출력하는 것이 아니라, 비율(healthPercent)을 fillAmount로 시각화한다.
4.3. 체력바 갱신
// MonsterHpBar.cs
public void UpdateHpBarUI(float hpPercent)
{
hpBarUI.fillAmount = hpPercent;
}// Monster.cs
public void UpdateHpBar()
{
hpBar.GetComponent<MonsterHpBar>().UpdateHpBarUI(curHp / maxHp);
}
public void GetDamage(float f)
{
curHp -= f;
if (curHp > 0)
{
UpdateHpBar();
state = State.DAMAGED;
}
else if (curHp <= 0)
{
state = State.DIE;
if (hpBar) hpBar.GetComponent<MonsterHpBar>().UpdateHpBarUI(0); ;
}
}UpdateHpBarUI(float healthPercent) 함수는 표현 계층의 유일한 갱신 진입점이다.
MonsterHpBar가 외부로부터 전달받은 체력 비율을 실제 UI에 반영한다.
이 함수는 단순히 Image.fillAmount에 값을 대입하는 역할처럼 보이지만, 구조적으로는 표현 계층을 보호하는 캡슐화 경계다.
만약 Monster가 hpBar.fillAmount를 직접 조작한다면, 전투 계층이 UI 세부 구현에 의존하게 되어 계층 간 결합도가 상승하게 된다.
그러나 현재 구조에서는 Monster가 비율 값만 전달하고, 실제 표현 방식(fillAmount를 쓸지, Slider를 쓸지, 숫자 텍스트를 병행할지)은 MonsterHpBar 내부에서 결정된다.
이는 C#에서의 캡슐화 개념을 그대로 적용한 것으로, View의 구현 세부 사항을 외부에 노출하지 않는 설계다.
UpdateHpBar 함수는 데이터 계층에서 표현 계층으로 넘어가는 브리지 함수이다.
Monster 클래스 내부에서 체력 데이터가 변경되었을 때 표현 계층으로 갱신 요청을 보낸다.
이 함수는 curHp / maxHp로 현재 체력 비율을 계산한 뒤, MonsterHpBar의 UpdateHpBarUI 함수를 호출한다.
여기서 중요한 점은 Monster가 직접 Image.fillAmount를 조작하지 않는다는 것이다.
Monster는 체력 데이터를 소유하고 계산하지만, 표현 방식은 알지 못한다.
따라서 이 함수는 데이터 계층에서 표현 계층으로 넘어가는 유일한 통로 역할을 한다.
이 경계가 존재하기 때문에 체력 계산 로직이 변경되거나, 체력바 표현 방식이 바뀌어도 서로 최소 수정으로 유지될 수 있다.
이 구조는 Unity UI 구조에서 흔히 발생하는 ‘전투 코드가 UI를 직접 참조하는 강결합’을 방지하기 위한 의도적 설계다.
GetDamage 함수는 전투 처리 함수이다.
몬스터가 피격되었을 때 호출된다.
이 함수는 전달받은 데미지 값을 curHp에서 차감하고, 상태를 DAMAGED 또는 DIE로 전이시킨다.
체력 감소 이후에는 반드시 UpdateHpBar 함수를 호출하여 변경된 체력 비율이 즉시 UI에 반영되도록 한다.
여기서 핵심은 체력 감소가 먼저 이루어지고, 그 결과를 UI에 전달한다는 순서를 유지하는 것이다.
이를 통해 전투 판정과 시각적 피드백이 동일한 프레임 흐름 안에서 일관되게 유지된다.
이 구간은 전투 데이터 변경이 UI로 반영되는 경로를 정의한다.
핵심은 반드시 curHp를 먼저 감소시키고, 그 다음 UpdateHpBar로 UI를 갱신해야 한다는 점이다.
UI가 맞기 전 체력을 잠깐이라도 보여주면 플레이어는 피격 피드백을 지연으로 체감하고, 특히 빠른 타격이 반복되는 상황에서 가독성이 무너진다.
그래서 GetDamage에서 먼저 curHp를 갱신하고, 즉시 curHp/maxHp 비율을 계산해 MonsterHpBar에 전달한다.
여기서 Monster가 하는 계산은 전투 규칙 계산이 아니라 표현을 위한 비율 산출이며, 실제 UI의 표시 방식(fillAmount)은 MonsterHpBar가 소유한다.
이 분리는 체력 수치가 바뀌거나, 체력바 표현이 바뀌어도 서로를 최소 수정으로 유지시키는 실용적인 결합도 조절이다.
UpdateHealthBar는 단순 대입처럼 보이지만, View가 수행해야 할 책임을 정확히 담고 있다.
View는 체력 상태를 변경하지 않고, 전달받은 percent를 곧바로 UI에 반영한다.
이 구조 덕분에 체력바가 숫자 텍스트로 바뀌거나, 슬라이더로 바뀌어도 Monster 쪽의 전투 로직은 그대로 유지되고, View만 교체하면 된다.
* 결과

4.4. 빌보드 처리와 생명주기 안정
// MonsterHpBar.cs
Transform player;
public Image hpBarUI;
private Monster target;
private void Awake()
{
player = GameObject.FindWithTag("Player").transform;
}
private void Start()
{
transform.gameObject.SetActive(false);
}
void Update()
{
if (target == null)
{
Destroy(gameObject);
return;
}
if (player != null)
{
Vector3 lookPosition = player.position;
lookPosition.y = transform.position.y;
transform.LookAt(lookPosition);
}
}빌보드는 월드 공간 UI를 항상 읽히는 방향으로 정렬하기 위한 처리이며, 시점은 프레임마다 변하므로 Update에서 수행하는 것이 가장 단순하고 안정적이다.
Update는 Unity의 프레임 루프에서 매 프레임 호출되며, 입력/카메라/캐릭터 이동과 동일한 타이밍으로 갱신되기 때문에 UI 방향도 자연스럽게 동기화된다.
반대로 FixedUpdate는 물리 틱 기반이라 시점 변화와 어긋날 수 있어 UI 가독성 측면에서 불리하다.
여기서 LookAt은 현재 위치에서 특정 위치를 바라보도록 회전을 계산하는 Unity Transform API이며, 내부적으로 방향 벡터를 기반으로 회전(Quaternion)을 생성해 적용한다.
다만 플레이어가 점프하거나 지형 높낮이가 달라지면 UI가 위아래로 과도하게 기울 수 있으므로, lookPosition.y를 체력바 자신의 y로 고정해 수평 회전만 수행하도록 제한했다.
이 한 줄이 없으면 체력바가 하늘/땅을 향해 눕는 상황이 발생할 수 있고, 월드 UI 가독성이 크게 떨어진다.
또한 이 Update에는 생명주기 안전성이 포함된다.
target이 null이면 체력바 프리팹 전체를 Destroy(gameObject)로 제거하고 즉시 return한다.
이렇게 해야 컴포넌트만 파괴하거나 잔존 오브젝트가 남는 문제를 방지할 수 있고, 이미 파괴된 참조를 다음 프레임에 접근하면서 발생하는 MissingReference 계열 예외도 차단된다.
체력바는 자신의 표시 대상이 사라졌는지 스스로 검사하고 제거함으로써, Monster 쪽에서 UI 정리 책임을 갖지 않도록 했다.
이로써 소유자는 데이터만 관리하고, 표현 오브젝트는 자신의 생명주기를 스스로 정리하는 구조가 된다.
player Transform을 Awake에서 태그로 찾는 이유는, 빌보딩을 위해 바라볼 기준점이 필요하기 때문이다.
Transform은 위치·회전·스케일 정보를 담는 컴포넌트이며, LookAt 연산에 필요하다.
GameObject.FindWithTag는 편리하지만 탐색 비용이 있으므로, 매 프레임 찾지 않고 Awake에서 1회만 수행해 캐싱한다.
다만 플레이어가 씬 전환으로 교체되는 구조라면 이 참조가 깨질 수 있으므로, 그 경우에는 PlayerController 싱글톤 참조나 이벤트 기반 재바인딩이 개선 방향이 된다.
현재 시스템은 구조 단순성과 안정적인 1회 캐싱을 우선한 선택이다.
Start에서 체력바를 비활성화하는 이유는, 전투가 시작되지 않은 상황에서 UI가 화면을 점유하지 않도록 하기 위함이다.
4.5. 거리 기반 활성화
// Monster.cs
public void HpBarActive(float? distance)
{
hpBar.SetActive(distance <= 15f);
}// Monster의 자식 클래스
Transform target;
float distanceToPlayer;
protected virtual void Update()
{
...
distanceToPlayer = Vector3.Distance(target.position, transform.position);
HpBarActive(distToPlayer);
...
}체력바를 항상 켜두지 않고 거리 기반으로 활성화하는 것은 가독성과 비용을 동시에 관리하기 위한 정책이다.
월드 UI는 몬스터 수가 늘어날수록 화면을 빠르게 혼잡하게 만들 수 있고, 불필요하게 많은 UI가 활성화된 상태는 드로우/업데이트 비용을 증가시킨다.
그래서 플레이어가 일정 거리 안에 들어왔을 때만 hpBar를 SetActive(true)로 활성화 한다.
SetActive는 GameObject 전체 활성 상태를 바꾸는 Unity API이며, 비활성화되면 해당 오브젝트의 Update와 렌더링이 중단되기 때문에 '숨김 + 비용 절감' 효과가 동시에 발생한다.
이 정책을 Monster 쪽에서 관리하는 이유는 표시 조건(거리)은 데이터/상태를 아는 쪽(몬스터/AI)에서 결정하고, MonsterHpBar는 그저 표시만 한다는 책임 분리를 유지하기 위해서다.
만약 체력바 내부에서 거리 계산을 하도록 만들면, View가 도메인 로직을 알게 되어 계층 침범이 발생한다.
거리 계산 책임은 자식 클래스에 있다.
target 변수는 몬스터의 공격 대상의 위치를 저장하는 Vector3 변수다.
Vector3는 3차원 공간에서 위치를 나타낸다.
타겟의 위치와 몬스터의 위치 간의 유클리드 거리를 계산하여 distanceToPlayer 변수에 저장한다.
이들은 매 프레임마다 호출되는 Update에서 계산하고 저장하여 실시간으로 거리를 구한다.
여기서 Vector3.Distance함수는 매개변수 2개 위치 간의 유클리드 거리를 float형으로 반환하는 함수이다.

Disatance 함수는 이 수식을 사용하여 두 점 사이의 유클리드(직선) 거리를 계산한다.
플레이어와의 거리에 따라 실시간으로 체력 바가 활성화 비활성화 되어야 한다.
부모 클래스 'Monster'에 있는 HpBarActive에 현재 플레이어와의 거리를 실시간으로 매개변수로 전달하여 호출한다.
* 결과

5. 개발 의도
이 체력바 시스템은 단순히 HP를 표시하는 기능이 아니라, 월드 UI에서 가장 흔하게 생기는 문제(가독성, 시점 변화, 다수 개체 확장, 오브젝트 정리)를 최소한의 구조로 안정적으로 해결하는 것을 목표로 했다.
몬스터가 체력 데이터를 소유하고, 체력바는 fillAmount로 시각화만 수행하도록 분리함으로써 전투/AI 로직이 커져도 UI가 흔들리지 않게 만들었다.
체력바를 몬스터 자식으로 두어 위치 동기화를 자동으로 확보하고, Update 기반 빌보딩으로 시점 변화에서도 읽히는 방향을 유지했으며, 거리 기반 활성화로 화면 혼잡과 비용을 제어했다.
또한 타겟이 사라졌을 때 프리팹 전체를 제거하는 정리 로직을 통해 런타임 예외와 누수를 방지하도록 설계했다.
결과적으로 이 구조는 몬스터 종류가 늘어나도 동일한 생성/갱신 규칙으로 재사용 가능하고, 이후 AI 상태머신이나 피격/사망 연출이 확장되어도 UI 계층을 안정적으로 유지하는 기반이 된다.
