독수파출소 개발일지 #4 – 능력치와 평판, 선택에 무게를 더하다

112 신고 시스템을 만들고 나서 한 가지가 마음에 걸렸다.

선택지를 고르는데 큰 의미가 없었다. 어떤 걸 골라도 결과 대사가 나오고 끝. 내가 어떤 경찰인지가 판단에 영향을 주지 않는다면, 그게 무슨 시뮬레이션일까.

그래서 다음 작업은 능력치와 평판 시스템이었다.


능력치 – 나는 어떤 경찰인가

게임 시작 전, 플레이어는 3포인트를 5가지 능력치에 자유롭게 배분한다.

능력치역할
카리스마대화·설득 선택지 활성화.
언어로 제압하는 경찰
물리 제압 성공률.
저항하는 용의자를 제압할 수 있는 경찰
속도도보 추격 성공률.
실종 골든타임을 지킬 수 있는 경찰
운전차량 추격 성공률.
긴급 상황에서 기동력 있는 경찰
사격흉기난동·강력범죄 제압 성공률.
권총·테이저건을 쓸 수 있는 경찰

최대치는 5점. 포인트는 30일, 60일 간격의 복무 기간이 쌓여야 하나씩 더 받는다. 성장은 느리지만 꾸준할 것이다.

PlayerStats 스크립트가 이 모든 걸 관리한다.

public class PlayerStats : MonoBehaviour
{
    [Header("능력치 (최대 5)")]
    public int charisma  = 0;
    public int strength  = 0;
    public int speed     = 0;
    public int driving   = 0;
    public int shooting  = 0;

    [Header("분배 포인트")]
    public int availablePoints = 3;

    private const int MAX_STAT = 5;

    public bool SpendPoint(StatType stat)
    {
        if (availablePoints <= 0) return false;

        switch (stat)
        {
            case StatType.Charisma:
                if (charisma >= MAX_STAT) return false;
                charisma++; break;
            case StatType.Strength:
                if (strength >= MAX_STAT) return false;
                strength++; break;
            case StatType.Speed:
                if (speed >= MAX_STAT) return false;
                speed++; break;
            case StatType.Driving:
                if (driving >= MAX_STAT) return false;
                driving++; break;
            case StatType.Shooting:
                if (shooting >= MAX_STAT) return false;
                shooting++; break;
        }
        availablePoints--;
        return true;
    }

    public int GetStat(StatType stat)
    {
        switch (stat)
        {
            case StatType.Charisma:  return charisma;
            case StatType.Strength:  return strength;
            case StatType.Speed:     return speed;
            case StatType.Driving:   return driving;
            case StatType.Shooting:  return shooting;
            default: return 0;
        }
    }
}

enum – 오타를 원천 차단하다

처음엔 능력치를 문자열로 비교했다. "charisma"라고 써야 하는데 "Charisma"로 쓰면 조건 체크가 실패해서 선택지가 항상 잠겨버리는 버그였다. 에러 메시지도 없고, 원인 찾기가 너무 힘들었다.

그래서 enum으로 바꿨다.

public enum StatType
{
    None,
    Charisma,
    Strength,
    Speed,
    Driving,
    Shooting
}

이렇게 하면 Inspector에서 드롭다운으로 선택하게 돼서 오타가 원천 차단된다. 이걸 알고 나서 “처음부터 이렇게 했어야 했다”는 생각이 들었다. 개발에서 타입 안전성이 왜 중요한지 몸으로 배운 순간이었다.


선택지가 잠긴다는 것의 의미

능력치가 낮으면 선택지가 회색으로 잠긴다.

가정폭력 현장. 가해자가 문 앞을 막고 서 있다.

1. [말로 설득한다]  카리스마 3 필요  ← 잠김
2. [몸으로 밀고 들어간다] 힘 2 필요 ← 잠김
3. [지원을 요청한다] ← 누구나 가능

DialogueManager에서 선택지를 표시할 때 이 조건을 체크한다.

void SetupChoices(DialogueLine line)
{
    // 선택지 1 능력치 조건 체크
    bool choice1Unlocked = CheckStat(
        line.choice1RequiredStat,
        line.choice1RequiredLevel
    );

    choiceButton1.interactable = choice1Unlocked;

    string lockText = choice1Unlocked
        ? ""
        : $" [{line.choice1RequiredStat} {line.choice1RequiredLevel} 필요]";

    choiceText1.text = line.choice1 + lockText;

    // 선택지 2도 동일
    bool choice2Unlocked = CheckStat(
        line.choice2RequiredStat,
        line.choice2RequiredLevel
    );
    choiceButton2.interactable = choice2Unlocked;

    string lockText2 = choice2Unlocked
        ? ""
        : $" [{line.choice2RequiredStat} {line.choice2RequiredLevel} 필요]";

    choiceText2.text = line.choice2 + lockText2;
}

bool CheckStat(StatType stat, int requiredLevel)
{
    if (stat == StatType.None || requiredLevel <= 0) return true;
    return playerStats.GetStat(stat) >= requiredLevel;
}

처음에는 의욕만으로 해결하지 못 하는 상황이 있다. 경험과 실력이 없으면 선택지 자체가 없다.

추후에는 능력치를 요구하는 대화를 많이 추가할 계획이다.


능력치 분배 UI

게임 시작 시 능력치를 배분하는 화면을 만들었다. 확인 버튼을 누르면 화면이 닫히고 1일차가 시작된다.

public class StatDistributeUI : MonoBehaviour
{
    public GameObject statPanel;
    public TextMeshProUGUI remainPointsText;
    public TextMeshProUGUI charismaText;
    // ... 나머지 텍스트들

    public PlayerStats playerStats;
    public DayManager dayManager;

    void Start()
    {
        statPanel.SetActive(true);
        Time.timeScale = 0f;   // 게임 일시정지
        UpdateUI();
    }

    void AddPoint(StatType stat)
    {
        if (playerStats.SpendPoint(stat))
            UpdateUI();
    }

    void UpdateUI()
    {
        remainPointsText.text = "남은 포인트 : " + playerStats.availablePoints;
        charismaText.text     = "카리스마 : "    + playerStats.charisma;
        // ...
    }

    public void OnConfirm()
    {
        statPanel.SetActive(false);
        Time.timeScale = 1f;
        dayManager.StartDay();  // 1일차 시작
    }
}

만들면서 작은 문제들이 계속 나왔다. 포인트를 다 썼는데 + 버튼이 계속 눌리는 문제는 SpendPoint에서 availablePoints <= 0이면 false를 반환하도록 막아서 해결했다.

그리고 Time.timeScale = 0f로 일시정지한 상태에서도 신고가 계속 들어오는 문제가 있었다. WaitForSecondsRealtime은 timeScale에 영향을 받지 않기 때문이었다. DayManager에서 StartDay()를 분배 완료 후에만 호출하도록 타이밍을 조정해서 해결했다.

기본적인 기능은 구현했고, 추후 분배 초기화 버튼 등 추가적인 기능을 구현할 계획이다.


평판 – 행동이 쌓여서 세계가 바뀐다

능력치가 “내가 어떤 경찰인가”라면, 평판은 “이 동네가 나를 어떻게 보는가”다.

평판은 세 가지 트랙으로 구성했다.

시민 신뢰도 (기본값 +5, 범위 -50~+50) 주민이 먼저 제보를 해오는지, 묵비권을 행사하는지가 달라진다. 신뢰도가 낮으면 허위 신고가 늘어나고 민원이 폭주한다.

팀내 신망 (기본값 0) 팀원이 위기 상황에 지원을 와주는지, 지원 요청을 무시하는지. 팀내 신망이 낮으면 진짜 힘든 신고에서 혼자 버텨야 한다.

지역 정보망 (기본값 -10) 상습 신고자, 위험 인물을 사전에 파악하고 있는지. -10에서 시작하는 이유가 있다. 신입 경위는 아직 이 동네를 모른다. 순찰을 돌고 사람들을 만나면서 조금씩 올라간다.

public class ReputationManager : MonoBehaviour
{
    [Header("평판 수치 (-50 ~ +50)")]
    public int citizenTrust   =   5;  // 시민 신뢰도
    public int teamReputation =   0;  // 팀내 신망
    public int localIntel     = -10;  // 지역 정보망

    private const int MIN_REP = -50;
    private const int MAX_REP =  50;

    public void ChangeCitizenTrust(int amount)
    {
        citizenTrust = Mathf.Clamp(citizenTrust + amount, MIN_REP, MAX_REP);
    }
    public void ChangeTeamReputation(int amount)
    {
        teamReputation = Mathf.Clamp(teamReputation + amount, MIN_REP, MAX_REP);
    }
    public void ChangeLocalIntel(int amount)
    {
        localIntel = Mathf.Clamp(localIntel + amount, MIN_REP, MAX_REP);
    }
}

선택지 하나를 고를 때마다 수치가 바뀐다. 층간소음 민원에서 피해자 편만 들면 가해자 쪽 신뢰도가 떨어진다. 팀원 의견을 무시하고 혼자 처리하면 팀내 신망이 깎인다. 빠른 처리가 항상 정답이 아니다.

선택지 결과마다 세 수치 중 어느 것이 얼마나 바뀌는지를 DialogueData ScriptableObject에 직접 설정한다.

[System.Serializable]
public class DialogueLine
{
    public string speakerName;
    [TextArea(2, 4)]
    public string dialogue;
    public string choice1;
    public string choice2;
    public string result1;
    public string result2;

    [Header("선택지 1 조건")]
    public StatType choice1RequiredStat;
    public int      choice1RequiredLevel;

    [Header("선택지 1 평판 변동")]
    public int choice1_citizenTrust;
    public int choice1_teamRep;
    public int choice1_localIntel;

    [Header("선택지 2 조건")]
    public StatType choice2RequiredStat;
    public int      choice2RequiredLevel;

    [Header("선택지 2 평판 변동")]
    public int choice2_citizenTrust;
    public int choice2_teamRep;
    public int choice2_localIntel;
}

코드를 건드리지 않고 Inspector에서 신고마다 다른 평판 변동값을 설정할 수 있다. ScriptableObject의 진가가 여기서 나온다.


연결 구조 – 두 시스템이 만나는 지점

능력치와 평판은 독립적으로 작동하지만, 현장에서 만난다.

예를 들어 흉기난동 신고. 카리스마가 높으면 대화로 제압을 시도하는 선택지가 열린다. 성공하면 시민 신뢰도 +10. 실패하면 팀내 신망 -5, 지역 정보망 -3.

힘이 높으면 물리 제압을 선택할 수 있다. 성공하면 빠르게 정리되지만 시민 신뢰도에는 별 영향이 없다. 지원 요청을 선택하면 팀내 신망 +3이 붙는다. 혼자 해결하지 않고 팀을 신뢰했다는 평가다.

이 연결 구조를 만들고 나서, 조금이나마 게임 같은 모습을 할 수 있었다.

아무 버튼이나 누르는 게 아니라, 내가 어떤 경찰이 되고 싶은지를 고민할 수 있게 되었다.


스크립트 연결 구조

Player
├─ PlayerStats (능력치 5종, availablePoints 관리)
└─ PlayerInteraction → DialogueManager

DialogueManager
├─ PlayerStats 체크 → 선택지 잠금/해제
└─ ReputationManager → 선택 결과에 따라 수치 변동

StatDistributeUI → PlayerStats, DayManager
HUDManager → PlayerStats, ReputationManager (매 프레임 갱신)

다음 글에서는 게임의 핵심인 대화시스템과 다양한 상호작용에 대해 이야기할 예정이다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤