StatusManager 기반 런타임 상태 관리 시스템
목차
1. 시스템 요구 사항
PlayerStatus는 플레이어의 스탯 계산 규칙과 맵 해금 여부 같은 영구 데이터를 보관하는 모델이다.
그러나 실제 게임 플레이에서는 현재 체력(CurrentHp), 현재 배터리(CurrentBt)처럼 실시간으로 변화하는 상태가 필요하다.
배터리는 시간이 지나면 감소해야 하고, 플레이어가 집 내부에 있을 경우 회복되어야 한다.
배터리가 0이 되면 체력이 감소하는 상태 전이가 발생해야 하며, 해금되지 않은 맵에 진입하면 패널티로 체력이 일정 주기마다 감소해야 한다.
체력이 0이 되면 사망 처리는 단 한 번만 실행되어야 한다.
또한 치트 기능을 통해 체력 무적, 배터리 무제한, 공격력 증가 같은 상태 덮어쓰기가 가능해야 한다.
이때 ScriptableObject 원본 에셋은 절대 오염되면 안 된다.
따라서 계산 규칙 모델(PlayerStatus)과 런타임 상태 관리자(StatusManager)는 분리되어야 하며, StatusManager는 시간 기반 상태 변화, 상태 전이, 사망 처리, 치트 로직을 통합적으로 관리해야 한다.
2. 설계 목표
- PlayerStatus(규칙 모델)와 StatusManager(상태 관리자) 책임 분리
- CurrentHp/CurrentBt를 프로퍼티로 감싸 clamp 처리
- 시간 지연 로직은 Coroutine으로 처리
- 사망 처리 단일 진입점 보장
- 코루틴 중복 실행 및 중지 안정성 확보
3. 흐름도
PlayerStatus (규칙 모델)
- HP/BT 계산식
- 맵 해금 상태
│
▼
StatusManager (런타임 상태 관리자)
- CurrentHp / CurrentBt 관리
- FixedUpdate: 상태 감시
- Coroutine: 시간 기반 변화
│
├─ 배터리 감소
├─ 배터리 0 → 체력 감소
├─ 집 내부 → 배터리 회복
├─ 잠금 맵 → 체력 패널티
└─ 체력 0 → 사망 처리
StatusManager는 현재 상태의 중심 계층이다.
PlayerStatus는 계산 결과를 제공하고, StatusManager는 그 결과를 기준으로 실시간 상태를 관리한다.
4. 구현
4.1 싱글톤과 런타임 생존 범위
public static StatusManager instance;
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(this.gameObject);
}
else Destroy(this.gameObject);
}StatusManager는 '현재 체력/현재 배터리' 처럼 플레이 도중 계속 변하는 런타임 상태를 관리한다.
이 값들은 특정 씬에 종속되면 안 되고, '타이틀 → 게임 → 마을' 같은 씬 전환이 일어나도 동일한 플레이어의 상태로 이어져야 한다.
그래서 Awake에서 싱글톤 인스턴스를 확정하고 DontDestroyOnLoad로 오브젝트의 생존 범위를 씬 밖으로 확장한다.
DontDestroyOnLoad는 Unity가 씬 로딩 시 오브젝트를 파괴하는 기본 동작을 예외 처리하는 API이며, 상태 관리자 같은 전역 서비스 성격의 오브젝트에 적합하다.
다만 DontDestroyOnLoad는 잘못 쓰면 씬을 바꿀 때마다 객체가 중복 생성되는 문제가 생기기 때문에, instance가 이미 존재하면 자신을 Destroy하는 가드를 반드시 같이 둔다.
여기서 싱글톤은 편하게 접근하려고가 아니라, 플레이어 상태가 단 하나의 진실 원천이어야 한다는 설계 요구를 강제하기 위한 안전장치다.
만약 StatusManager가 2개 생기면 CurrentHp/CurrentBt가 서로 다른 인스턴스에서 갱신되어, UI는 A를 보고 실제 로직은 B를 보는 식의 치명적인 불일치가 발생할 수 있다.
이 구조는 그런 불일치를 구조적으로 막는다.
4.2. PlayerStatus 런타임 인스턴스화
[SerializeField] PlayerStatus playerStatus;
private void Awake()
{
...
playerStatus = ScriptableObject.Instantiate(
Resources.Load<PlayerStatus>("Prefabs/PlayerData/PlayerStatus"));
}PlayerStatus는 ScriptableObject 기반의 규칙/영구 데이터 모델이고, 에디터에서 밸런싱 값을 조정하는 원본 에셋이다.
문제는 StatusManager가 치트 기능 등으로 playerStatus 내부 값을 변경할 수 있다는 점이다.
ScriptableObject 에셋을 런타임에서 직접 수정하면, 플레이 중 데이터가 원본에 덮여 씌워지는 에셋 오염 위험이 생긴다.
이를 방지하기 위해 Awake에서 Resources.Load로 원본 에셋을 로드한 뒤 ScriptableObject.Instantiate로 복제본을 만들고, 이후 게임은 복제본만 사용한다.
Instantiate는 Unity에서 ScriptableObject를 메모리 상의 별도 인스턴스로 복사하는 메서드이고, 이 인스턴스는 플레이어 전용 런타임 데이터가 된다.
Resources.Load를 선택한 이유는 구현 단순성과 명확성 때문이다. 경로 문자열만으로 에셋을 로드할 수 있어서 빠르게 시스템을 구성할 수 있다.
대신 Resources 폴더 의존성이 생기고, 대규모 프로젝트에서 로딩/빌드 관리가 어려워질 수 있다는 단점이 있으므로, 프로젝트가 커지면 Addressables 같은 자산 관리 체계로 전환하는 것이 더 적합하다.
하지만 현재 목표가 원본 오염을 막는 런타임 분리를 명확히 보여주는 것이라면 이 구조는 의도가 직관적으로 드러난다.
4.3. 최대치와 현재치의 분리
public float MaxHp => playerStatus.HP;
public float MaxBt => playerStatus.BT;MaxHp는 직접 계산하지 않고 PlayerStatus의 계산형 프로퍼티를 참조한다.
이는 최대치 계산 규칙이 StatusManager에 존재하지 않도록 하기 위함이다.
규칙은 모델이 소유하고, 관리자는 결과만 사용한다.
public float CurrentHp
{
get { return currentHp; }
set
{
currentHp = value;
if (currentHp > MaxHp) currentHp = MaxHp;
if (currentHp < 0) currentHp = 0;
}
}
public float CurrentBt
{
get { return currentBt; }
set
{
currentBt = value;
if (currentBt > MaxBt) currentBt = MaxBt;
if (currentBt < 0) currentBt = 0;
}
}StatusManager는 현재치를 직접 보관하지만 최대치 계산 규칙은 소유하지 않는다.
MaxHp, MaxBt가 playerStatus.HP, playerStatus.BT를 그대로 참조하는 이유는 최대치 산출 규칙이 StatusManager로 흩어지지 않게 하기 위함이다.
규칙은 PlayerStatus가 갖고, StatusManager는 그 규칙의 계산 결과만 사용하는 역할에 머문다.
이렇게 하면 강화 포인트나 증가량 조정이 생겨도 최대치 계산을 고치는 곳은 PlayerStatus 하나로 고정된다.
반면 CurrentHp/CurrentBt는 실시간으로 변하는 상태이므로 별도 필드로 들고 있고, setter에서 clamp 처리를 수행해 값의 유효 범위를 강제한다.
이 clamp는 단순 방어 코드가 아니라 상태 무결성을 보장하는 장치다.
외부 로직이 어떤 이유로든 CurrentHp를 음수로 만들거나 MaxHp보다 크게 만들려고 해도, 프로퍼티 setter가 이를 자동으로 보정해 시스템 전체가 깨지지 않도록 만든다.
C# 프로퍼티를 사용하면 외부에서는 필드처럼 단순하게 접근하면서도 내부에서는 제한, 검증, 보정 같은 규칙을 은닉할 수 있어서 런타임 상태 관리에 적합하다.
4.4. FixedUpdate를 상태 감시 계층으로 사용
public bool die = false;
private void FixedUpdate()
{
MapLockMinusHP();
if (currentHp > 0)
{
if (!PlayerController.instance.isHouse)
{
if (currentBt > 0) StartCoroutine("MinusCurrentBt");
else
{
StopCoroutine("MinusCurrentBt");
StartCoroutine("MinusCurrentHp");
}
}
else
{
if (currentBt < MaxBt) StartCoroutine("PlusCurrentBt");
else StopCoroutine("PlusCurrentBt");
}
}
else
{
if (!die)
{
die = true;
StopCoroutine("MinusCurrentHp");
Inven.instance.RemoveAll();
UiManager_.instance.Die();
}
}
}StatusManager의 FixedUpdate는 수치를 직접 줄이는 곳이 아니라 어떤 전이가 필요한지 판단하는 감시자로 설계되어 있다.
여기서 FixedUpdate를 선택한 이유는 이 시스템이 플레이어 이동/환경 상태(isHouse, isFlying, 맵 위치 등)와 함께 상태 전이를 다루기 때문이다.
Update를 써도 구현은 가능하지만, FixedUpdate는 일정한 물리 틱 기반으로 실행되므로 ‘조건 감시 → 전이 트리거’가 보다 안정적인 리듬으로 유지된다.
중요한 건 FixedUpdate 안에서 Wait 기반 시간을 직접 계산하지 않는다는 점이다.
시간 지연이 필요한 감소/회복은 Coroutine으로 위임하고, FixedUpdate는 코루틴을 시작/중지하거나 사망 같은 단발성 전이를 트리거하는 역할만 수행한다.
이 방식은 매 프레임 누적 델타타임으로 감소 같은 구현보다 의도가 명확하고, 상태 전이 조건을 한눈에 읽기 좋다.
if (!die)
{
die = true;
StopCoroutine("MinusCurrentHp");
Inven.instance.RemoveAll();
UiManager_.instance.Die();
}또한 die 플래그는 체력이 0 이하가 된 이후에도 FixedUpdate가 계속 호출되는 Unity 구조에서 사망 처리가 반복 실행되는 것을 막는다.
즉 die는 단순 bool이 아니라 사망 전이의 단일 실행을 보장하는 잠금 장치이며, StopCoroutine과 인벤 삭제, UI 호출 같은 파괴적 연산이 중복 실행되지 않게 만든다.
4.5. Coroutine을 이용한 시간 기반 변화
IEnumerator MinusCurrentBt()
{
if (!minusBattery)
{
minusBattery = true;
yield return new WaitForSecondsRealtime(1f);
if (!PlayerController.instance.isFlying) currentBt--;
else currentBt -= 20;
if (currentBt < 0) currentBt = 0;
minusBattery = false;
}
}MinusCurrentBt 코루틴은 배터리 감소라는 시간 기반 변화를 표현한다.
Coroutine은 Unity에서 별도 스레드를 만드는 방식이 아니라, 메인 루프에서 yield 지점 기준으로 실행을 나누어 이어가는 협력적 비동기 구조다.
yield return WaitForSecondsRealtime은 timeScale의 영향을 받지 않기 때문에, 게임이 일시정지 되거나 슬로우 상태여도 배터리 변화가 동일한 실제 시간 기준으로 진행된다.
여기서 Realtime을 쓴 이유는 이 시스템이 게임 시간이 아니라 현실 시간 기준으로 자원 소모를 유지해야 한다는 설계를 반영한 선택이다.
반대로 일시정지 중에는 자원 소모를 멈추고 싶다면 WaitForSeconds를 사용하거나 timeScale 정책을 바꾸는 것이 더 적합하다.
minusBattery 플래그는 FixedUpdate가 매 틱 StartCoroutine을 호출할 수 있다는 구조적 특성 때문에 필요하다.
플래그가 없으면 같은 코루틴이 여러 번 시작되어 배터리가 의도보다 빠르게 감소하거나, 중복 실행이 누적되는 문제가 생긴다.
플래그는 코루틴을 한 번 실행 중이면 다음 호출은 무시하도록 만들어, 감시 루프와 시간 루프를 안전하게 분리한다.
isFlying 조건에서 감소량을 달리하는 분기는 행동 상태가 자원 소모율을 바꾸는 게임 디자인을 코드로 표현한 부분이며, 결과적으로 배터리 시스템이 단순 타이머가 아니라 플레이어 행동에 반응하는 자원 시스템으로 동작하게 만든다.
4.6. 체력 감소 코루틴
IEnumerator MinusCurrentHp()
{
if (!minusHp)
{
minusHp = true;
yield return new WaitForSecondsRealtime(1.0f);
currentHp -= 5;
if(currentHp < 0) currentHp = 0;
minusHp = false;
}
}MinusCurrentHp는 배터리가 0이 되었을 때 체력이 일정 주기로 감소하는 후속 전이를 구현한다.
이 코루틴이 FixedUpdate에서 직접 호출되지 않고 배터리 0 조건을 통해 트리거되는 이유는 상태 전이를 단계적으로 분리하기 위함이다.
배터리가 남아 있을 때는 배터리 감소만 일어나고, 배터리가 고갈되었을 때만 체력 감소로 전이된다.
이런 전이는 게임 시스템 관점에서 자원(배터리)이 1차 보호막이고 체력이 2차 생존 자원이라는 의미를 가진다.
minusHp 플래그는 minusBattery와 동일한 이유로 중복 실행을 막는다.
또한 체력 감소 역시 clamp로 인해 0 이하로 내려가지 않기 때문에, 사망 전이 조건이 현재 체력 0이라는 하나의 기준으로 수렴한다.
4.7. 배터리 회복 코루틴
IEnumerator PlusCurrentBt()
{
if (!plusBattery)
{
plusBattery = true;
yield return new WaitForSecondsRealtime(1.0f);
currentBt+= 10;
if(currentBt > MaxBt) currentBt = MaxBt;
plusBattery = false;
}
}PlusCurrentBt는 집 내부일 때 배터리가 회복되는 흐름을 담당한다.
집이라는 공간 조건은 PlayerController의 isHouse로 제공되고, StatusManager는 그 조건을 감시해 회복 코루틴을 트리거한다.
회복 역시 시간 기반이기 때문에 Coroutine으로 분리했고, plusBattery 플래그로 중복 실행을 방지한다.
회복량이 10으로 설정된 것은 '집 내부 = 빠른 회복'이라는 디자인을 코드에 반영한 것이고, MaxBt 초과를 막는 보정은 상태 무결성을 유지하기 위한 장치다.
이 회복 로직이 별도의 함수로 분리되어 있기 때문에, 향후 회복 속도를 지역별로 다르게 하거나 아이템/스킬에 따라 회복량을 변경하는 확장도 수월해진다.
4.8. 맵 잠금 패널티
void MapLockMinusHP()
{
if ((PlayerController.instance.isForest && !playerStatus.IsForestUnLock)
|| (PlayerController.instance.isDesert && !playerStatus.IsDesertUnLock)
|| (PlayerController.instance.isCave && !playerStatus.IsCaveUnLock))
StartCoroutine(MapLockMinusCurrentHP());
else StopCoroutine(MapLockMinusCurrentHP());
}MapLockMinusHP는 플레이어가 해금되지 않은 지역에 진입했는지를 감지하고, 조건이 만족되면 MapLockMinusCurrentHP 코루틴을 실행한다.
여기서 핵심은 잠금 여부 판단과 패널티 실행을 분리한 것이다.
잠금 상태는 PlayerStatus의 영구 데이터(IsForestUnLock 등)가 소유하고, StatusManager는 그 데이터를 조회해 런타임 패널티를 실행한다.
이렇게 하면 해금 규칙이 바뀌어도 판단 기준은 PlayerStatus에 남고, 패널티의 실행 방식만 StatusManager에서 조정할 수 있다.
StopCoroutine을 사용해 조건이 해제되면 패널티 루프를 끊는 구조인데, 이 방식은 단순하지만 코루틴 문자열 기반 호출은 리팩토링에서 이름 변경에 취약할 수 있다는 단점이 있다.
그럼에도 현재 목적이 조건 기반으로 패널티를 켰다 껐다를 명확히 보여주는 것이므로 구조 자체는 이해하기 쉽다.
더 안전하게 하려면 코루틴 핸들을 저장해 StopCoroutine(handle) 방식으로 바꾸는 개선 여지도 있다.
minusHpMap 플래그는 다른 코루틴들과 동일하게 중복 실행 방지를 위한 안전장치이며, 특히 맵 잠금 패널티는 조건이 유지되는 동안 지속적으로 발생해야 하므로 1초 주기라는 실행 템포를 안정적으로 유지하는 역할을 한다.
4.9. 맵 잠금 패널티 코루틴
IEnumerator MapLockMinusCurrentHP()
{
if (!minusHpMap)
{
minusHpMap = true;
currentHp -= 10;
if (currentHp < 0) currentHp = 0;
yield return new WaitForSecondsRealtime(1.0f);
minusHpMap = false;
}
}MapLockMinusCurrentHP는 해금되지 않은 지역에 진입했을 때 발생하는 지속 패널티를 구현하는 코루틴이다.
이 코루틴은 단순히 체력을 감소시키는 함수가 아니라, 규칙 모델(PlayerStatus)과 런타임 상태 관리자(StatusManager)가 만나는 지점에 해당한다.
해금 여부 자체는 PlayerStatus가 소유한다.
즉, 어떤 맵이 잠금 상태인가라는 규칙은 데이터 모델의 책임이다.
반면 잠금 상태일 때 어떤 벌칙을 줄 것인가는 게임 플레이의 실행 로직이므로 StatusManager가 담당한다.
MapLockMinusHP는 이 둘을 연결하는 조건 감시자이며, MapLockMinusCurrentHP는 실제 실행 루프를 담당한다.
이 코루틴은 먼저 minusHpMap 플래그를 통해 중복 실행을 방지한다.
FixedUpdate는 매 틱 호출되기 때문에 조건이 유지되는 동안 StartCoroutine이 반복 호출될 수 있다.
보호 장치가 없다면 체력 감소가 중첩 실행되어 의도보다 빠르게 진행될 위험이 있다.
플래그는 현재 패널티 루프가 실행 중인가를 나타내는 잠금 장치다.
체력 감소는 yield 이전에 수행된다.
이는 조건을 만족하는 순간 즉시 패널티를 체감하게 하기 위함이다.
이후 WaitForSecondsRealtime을 통해 1초 간격의 템포를 유지한다.
Realtime을 사용한 이유는 배터리 감소 로직과 동일하게, 게임의 timeScale 변화와 무관하게 패널티가 유지되어야 한다는 설계 판단 때문이다.
만약 일시정지 시 패널티를 멈추는 것이 의도라면 WaitForSeconds가 더 적합하다.
이 코루틴의 중요한 의미는 규칙과 처벌의 분리다.
PlayerStatus는 ‘잠금 여부’를 판단하지만,
StatusManager는 ‘잠금 상태일 때 어떤 게임 플레이 결과를 낼 것인가’를 결정한다.
이 구조 덕분에 향후 패널티 수치를 조정하거나, 단순 체력 감소 대신 상태 이상 효과를 주는 방식으로 변경하더라도, PlayerStatus는 수정할 필요가 없다.
즉, 데이터 모델과 실행 로직이 느슨하게 결합되어 있다.
다만 현재 구현은 StopCoroutine(MapLockMinusCurrentHP()) 방식으로 제어되고 있으며, 이는 코루틴 인스턴스를 명시적으로 관리하지 않는 단순한 접근이다.
대규모 프로젝트에서는 Coroutine 핸들을 저장하여 정확한 인스턴스를 중지하는 방식이 더 안전하다.
현재 구조는 설계 의도를 명확히 보여주는 데 초점을 둔 구현이다.
5. 개발 의도
이 시스템의 핵심은 규칙과 상태의 분리다.
PlayerStatus는 계산 규칙과 영구 상태만을 제공하고, StatusManager는 실시간으로 변화하는 상태를 관리한다.
시간 기반 변화는 Coroutine으로 명확히 표현했고, FixedUpdate는 상태 감시 및 전이 트리거로만 사용하여 역할을 분리했다.
ScriptableObject를 런타임에서 복제하여 사용함으로써 데이터 오염을 방지했고, 프로퍼티 기반 clamp 처리로 상태 무결성을 확보했다.
이 구조는 확장 시 상태 머신 패턴으로 발전시킬 수 있으며, 현재 규모에서는 가독성과 안정성을 모두 확보하는 설계다.
