PlayerStatus 기반 스탯 데이터 설계

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. ScriptableObject 기반 데이터 모델 설계

       4.2. 계산형 프로퍼티 구조

       4.3. GSP 감소 공식의 의도

       4.4. 포인트와 증가량의 역할 분리

       4.5. ATK 증가량만 setter를 허용한 이유

       4.6. 맵 해금 상태

5. 개발 의도

1. 시스템 요구 사항

플레이어는 체력(HP), 배터리(BT), 이동속도(SP), 공격력(ATK), 채집속도(GSP)와 같은 핵심 스탯을 가진다.

이 스탯들은 단순한 저장 값이 아니라, '초기값 + 강화 포인트 × 포인트당 증가량' 이라는 명확한 계산 규칙에 따라 산출되어야 한다.

게임 진행 중 강화 포인트가 변경되면, 별도의 재계산 호출 없이 다음 스탯 조회 시점에 즉시 반영되어야 한다.

또한 숲, 사막, 동굴과 같은 맵 해금 상태는 플레이어의 장기 진행 상태에 해당하므로, 외부 시스템이 안정적으로 참조할 수 있는 형태로 함께 관리되어야 한다.

이때 중요한 점은 PlayerStatus가 현재 체력이나 현재 배터리 같은 런타임에서 지속적으로 변하는 상태를 직접 보관하지 않는다는 것이다.

PlayerStatus는 오직 스탯이 어떻게 계산되는지에 대한 규칙과, 플레이어의 영구 상태만을 제공한다.

현재 체력처럼 실시간으로 감소하거나 증가하는 값은 별도의 런타임 관리 계층에서 담당해야 한다.

따라서 스탯 계산 규칙은 여러 클래스에 흩어지지 않고, 단일 데이터 모델에서 일관되게 유지되어야 하며, 외부 시스템은 이 모델을 참조하는 구조가 필요하다.

2. 설계  목표

- 스탯 계산 규칙을 PlayerStatus 단일 데이터 모델로 고정

- 초기값, 포인트, 증가량을 명확히 분리하여 밸런싱 수정 지점 명확화

- 스탯은 요청 시 계산되는 구조로 설계하여 항상 최신값 반환

- 플레이어의 영구 진행 상태(맵 해금 등)를 동일 모델에서 통합 관리

- 외부 시스템이 안전하게 접근할 수 있도록 프로퍼티 기반 인터페이스 제공

- 데이터 기반 밸런싱 수정이 가능하도록 ScriptableObject 활용

3. 흐름도

[외부 시스템: StatusManager / UI / 컨트롤러]

         │ (HpPoint, AtkPoint 등 변경)

         ▼

      [PlayerStatus]

  ┌───────────┐

      init + point × inc

       HP / BT / SP ...  

  └───────────┘

         │ (프로퍼티 반환)

         ▼

[계산된 최신 스탯 값 사용]

이 구조에서 의존성 방향은 항상 외부 → PlayerStatus로 향한다.

PlayerStatus는 외부 시스템을 참조하지 않는다.

즉, 데이터 모델은 독립적이며, 외부 시스템이 이를 읽고 사용하는 단방향 의존 구조를 가진다.

외부에서 강화 포인트를 변경하면, 이후 PlayerStatus의 프로퍼티를 조회하는 모든 시스템은 항상 동일한 계산식을 통해 산출된 최신 값을 받게 된다.

계산 규칙은 단 한 곳에 존재한다.

4. 구현

4.1. ScriptableObject 기반 데이터 모델 설계
[CreateAssetMenu(fileName = "PlayerStatus", menuName = "Player/Status")]
public class PlayerStatus : ScriptableObject
{
    public const int maxPoint = 5;
    ...
}

PlayerStatus를 MonoBehaviour가 아니라 ScriptableObject로 설계한 이유는, 이 클래스가 씬 오브젝트의 생명주기를 따르는 동작 컴포넌트가 아니라 데이터와 규칙을 표현하는 모델이기 때문이다.

MonoBehaviour는 Update나 FixedUpdate처럼 프레임 루프에 종속되는 구조를 갖는다.

반면 스탯 계산 공식은 프레임마다 실행될 필요가 없다.

ScriptableObject는 씬과 독립적인 데이터 자산으로 존재하며, 여러 시스템이 동일 데이터를 공유하기에 적합하다.

또한 CreateAssetMenu 속성을 통해 에디터에서 PlayerStatus 에셋을 직접 생성할 수 있다.

이는 밸런싱 수치를 코드 수정 없이 인스펙터에서 조정할 수 있게 해준다.

데이터 기반 설계를 통해 기획 변경에 대한 대응 속도를 높이는 것이 목적이다.

단점은 ScriptableObject가 에셋 형태로 존재하기 때문에 런타임에서 직접 수정할 경우 원본 데이터가 오염될 수 있다는 점이다.

이 문제는 런타임 인스턴스 전략으로 해결하며, 해당 내용은 StatusManager 게시글에서 설명한다.

maxPoint를 const로 둔 것은 강화 상한이 게임 규칙의 일부이기 때문이다.

const는 컴파일 타임 상수로 변경 가능성이 없음을 명확히 한다.

만약 강화 상한이 기획적으로 자주 변경될 가능성이 있다면 SerializeField로 분리하는 것이 더 적합하지만, 본 설계는 강화 상한을 규칙으로 고정하는 전제를 따른다.

4.2. 계산형 프로퍼티 구조
public float HP
{
    get
    {
        return initHp + hpPoint * hpIncrease;
    }
}

// BT, SP, ATK 모두 동일한 구조

HP, BT, SP, ATK 프로퍼티는 값을 저장해 두는 구조가 아니라, 접근 시점마다 계산하여 반환하는 구조다.

이 설계는 스탯 변경이 발생했을 때 별도의 재계산 로직을 호출할 필요가 없도록 만든다.

C# 프로퍼티는 외부에서 필드처럼 보이지만 내부에서 로직을 실행할 수 있는 문법이다.

외부 시스템은 status.HP처럼 단순하게 접근하지만, 내부에서는 항상 동일한 공식이 적용된다.

계산 규칙은 오직 PlayerStatus 내부에만 존재한다.

이 방식의 장점은 규칙의 단일화와 일관성이다.

어느 시스템이 호출하더라도 동일한 계산 결과를 얻는다.

단점은 getter 호출 시마다 계산이 수행된다는 점이다.

하지만 현재 수식은 단순 산술 연산이므로 성능 부담은 거의 없다.

만약 향후 성장 공식이 곡선 기반 계산이나 테이블 조회 기반으로 확장된다면, 캐싱 전략이나 이벤트 기반 갱신 구조로 전환하는 개선 여지도 있다.

현재 구조는 단순성과 안정성을 우선한 선택이다.

4.3. GSP 감소 공식의 의도
public float GSP
{
    get
    {
        gsp = initGsp - gspPoint * gspIncrease;
        return gsp;
    }
}

GSP는 다른 스탯과 달리 초기값에서 감소하는 공식이다.

이는 채집속도를 값이 클수록 빠르다가 아니라 채집에 걸리는 시간 관점으로 모델링했기 때문이다.

값이 작을수록 더 빠른 채집을 의미한다.

네이밍은 Speed이지만 실제 모델은 Time에 가깝다.

따라서 향후 혼동을 줄이기 위해 변수명을 GatheringTime 등으로 변경하는 개선 여지도 있다.

현재는 내부 의미를 문서로 명확히 정의하는 방식을 선택했다.

4.4. 포인트와 증가량의 역할 분리
// HP, BT, SP, ATK, GSP 모두 동일한 구조
public int HpPoint
{ 
    get { return hpPoint; } 
    set { hpPoint = value; } 
}

// BT, SP, GSP 모두 동일한 구조
public float HpIncrease
{
    get{ return hpIncrease; }
}

포인트는 플레이 중 강화 시스템이나 치트 기능에 의해 변경되는 값이므로 setter를 열어 두었다.

반면 증가량은 밸런싱 파라미터이기 때문에 읽기 전용으로 두었다.

이는 플레이 중 변경되는 상태와 설계 파라미터를 구분하기 위한 경계다.

이렇게 역할을 분리하면 외부 로직이 실수로 증가량 자체를 변경해 밸런스를 무너뜨리는 사고를 방지할 수 있다.

4.5. ATK 증가량만 setter를 허용한 이유
public float AtkIncrease
{
    get { return atkIncrease; }
    set { atkIncrease = value; }
}

AtkIncrease만 setter가 열려 있는 이유는 치트 기능에서 공격력 증가량을 임시로 변경하기 때문이다.

이는 규칙 파라미터를 런타임에서 변조하는 예외적 케이스를 허용한 설계다.

다만 이 구조는 규칙 변경과 플레이어 상태 변경이 동일 모델에 공존하게 만들므로, 일반 게임 로직에서는 증가량을 변경하지 않도록 사용 경계를 명확히 해야 한다.

치트 기능과 같은 특수 상황에서만 허용되는 설계라는 점을 문서로 명확히 하는 것이 중요하다.

4.6. 맵 해금 상태
// 숲, 사막, 동굴 해금 상태
public bool IsForestUnLock { get; set; } 
public bool IsDesertUnLock { get; set; } 
public bool IsCaveUnLock { get; set; }

맵 해금 상태는 스탯 계산과 직접 연관되지는 않지만, 플레이어의 영구 진행 상태라는 점에서 동일 데이터 모델에 통합했다.

자동 구현 프로퍼티를 사용한 이유는 이 값이 연산 지점이 아니라 단순 상태 저장 목적이기 때문이다.

명시적 backing field를 둘 필요가 없으며, 자동 구현 프로퍼티는 컴파일러가 내부 필드를 생성한다.

이 방식은 데이터 보관이라는 의도를 가장 간결하게 표현한다.

추가 로직이 없다는 사실이 코드 구조에서 드러나며, 향후 접근 제한이 필요할 경우 get/set 접근 제어만 조정하면 된다.

5. 개발 의도

이 설계의 핵심은 상태(State)와 규칙(Rule)의 구조적 분리다.

PlayerStatus는 현재 체력이나 배터리를 관리하지 않는다.

대신 플레이어의 스펙이 어떻게 계산되어야 하는지를 정의하는 규칙 모델이다.

스탯은 저장된 값이 아니라 계산된 결과다.

계산 공식은 단일 모델에 집중되어 있고, 외부 시스템은 이를 참조만 한다.

이 구조는 계산 로직의 중복을 방지하고, 밸런싱 변경 시 수정 지점을 명확히 한다.

또한 ScriptableObject 기반 데이터 모델을 통해 프리셋 확장, 저장 시스템 연계, 밸런싱 수정까지 고려한 구조를 유지했다.

다음 게시글에서는 이 PlayerStatus를 런타임에서 안전하게 인스턴스화하고, 현재 HP/BT처럼 변하는 상태를 어떻게 관리하는지(StatusManager)를 통해 상태 관리 계층을 설명한다.