독수파출소 개발일지 #5 – 대화 시스템, 실제 경찰 경험을 게임에 녹이다

독수파출소를 만들기로 했을 때 제일 자신 있었던 것은 경험에서 나오는 스토리였다.

그래픽, 맵 크기는 전문 게임 개발자들을 못 따라가더라도 높은 현장감과 좋은 서사로 경쟁력을 갖추고 싶었다.

파출소에서 제일 먼저 배운 것 중 하나는 말하는 방식이었다.

이걸 게임으로 만들고 싶었다.


기존 경찰 게임의 대화는 왜 아쉬운가

This Is the Police는 훌륭한 게임이지만 대화 자체는 단순하다. 선택지를 고르면 결과가 나온다. 어떤 말을 왜 하는지보다, 어떤 결과를 원하는지가 중심이다.

디스코 엘리시움은 반대다. 대화가 너무 깊고 복잡해서 경찰 현장의 긴박함보다는 철학적 탐구에 가깝다.

독수파출소는 그 사이 어딘가를 노렸다. 실제 경찰 현장의 느낌, 그 안의 판단 압박.


실제 경찰 대화에서 가져온 4가지 메카닉

1. 진술 파헤치기 – 대부분 처음엔 거짓말을 한다

현장에 가면 대부분 처음부터 사실을 말하지 않는다. 가정폭력 신고 현장에서 문을 열어준 남성은 “가벼운 부부싸움이었다”고 한다. 피해자는 옆에 서서 고개를 끄덕인다.

게임에서 이걸 구현하기 위해 대화를 여러 겹으로 설계했다. NPC의 첫 번째 대사는 표면이다. 플레이어의 탐색에 따라 숨겨진 선택지가 열린다.

[주변을 살펴본다]  → 아이의 표정이 굳어있음 발견

[아이에게 말을 건다] → 아이가 말을 꺼내기 시작함

[피해자에게 다시 묻는다] → 진술이 바뀜

DialogueData ScriptableObject에 lines 배열을 둔 이유가 이거다. 대화 한 건이 여러 층으로 이어질 수 있게.

[CreateAssetMenu(fileName = "NewDialogue",
                 menuName = "DoksuPolice/Dialogue Data")]
public class DialogueData : ScriptableObject
{
    public DialogueLine[] lines;  // 여러 층의 대화
}

다만 플레이어가 주변을 직접 탐색하고 단서를 통해 진실을 추리하는 구조를 만들고 싶은 욕심도 있어서 추후 구상해볼 예정이다.

2. 어조 시스템 – 같은 말도 어떻게 하는지에 따라 다르다

“음주 측정 협조해주세요”라는 말을 단호하게 할 수도 있고, 부드럽게 할 수도 있다. 상대방의 상태에 따라 어떤 어조가 맞는지가 달라진다.

선택지에 내용뿐 아니라 어조를 붙였다. 같은 의도의 말이라도 다르게 표현된다.

[단호하게] "음주 측정 협조해주세요"  카리스마 2 필요
[부드럽게] "잠깐 협조해 주실 수 있을까요?"
[압박하듯] "지금 측정 거부하시는 겁니까?"

카리스마가 높은 경찰은 더 다양한 어조를 선택할 수 있다. 경험이 없으면 무난한 선택지만 보인다.

3. 기억 – 동네 사람들(NPC)이 나를 기억한다

독수파출소 단골손님 참새아파트 502호 할머니. 세 번 친절하게 응대했더니 다음 신고 때 “000경관님이라 믿어요”라며 먼저 진술하신다.

독수역 편의점 알바. 첫 번째 무전취식 사건을 잘 처리하지 못했더니 두 번째 신고 때 믿지 못하는 눈빛을 보낸다.

이건 평판 시스템과 연결되지만, 단순한 수치 변동이 아니라 특정 NPC와의 관계다. 지금은 글로벌 평판 수치로 구현돼 있지만, 나중에 NPC마다 개별 관계 수치를 붙일 계획이다.

4. 경청 – 더 들을 것인가, 지금 처리할 것인가

파출소에서 선배한테 들은 말 중에 기억에 남는 게 있다. “신고자 말을 끝까지 들어주는 게 제일 빠른 거야.” 처음엔 무슨 말인지 몰랐다. 빨리 처리하려면 빨리 말하고 빨리 끝내야 하는 거 아닌가.

근데 현장에서 보면 달랐다. 흥분한 사람, 억울한 사람을 말로 제압하려 할수록 더 길어졌다. 그냥 듣고 있으면 스스로 말을 털어놓고 가라앉았다. 정보도 더 많이 나왔다.

게임에서는 이걸 시간 압박과 엮을 계획이다. Papers Please처럼 대화 중에도 시간은 계속 흐른다. NPC가 말을 이어가는 도중에 플레이어는 선택해야 한다.

신고자/피해자가 사건 경위를 설명하는 중...  

[계속 듣는다] → 더 많은 정보, 시간 소모, 신고자의 감사
[내 할 말을 한다] → 빠른 처리, 정보 손실 가능, 신고자에게 상처

빨리 처리하고 다음 신고를 받을 것인가, 아니면 시간을 써서 더 많은 걸 얻어낼 것인가. 정답이 없는 선택이다. 신고가 많은 날 저녁에는 하나하나 끝까지 들어줄 여유가 없다. 그게 실제 지역경찰의 딜레마이기도 하다.


기술 구현 – DialogueManager

대화 시스템의 중심은 DialogueManager다. 대화 시작, 선택지 표시, 능력치 조건 체크, 평판 수치 변동까지 전부 여기서 처리한다.

public class DialogueManager : MonoBehaviour
{
    [Header("UI References")]
    public GameObject    dialoguePanel;
    public TextMeshProUGUI nameText;
    public TextMeshProUGUI dialogueText;
    public GameObject    choiceButton1;
    public GameObject    choiceButton2;
    public TextMeshProUGUI choiceText1;
    public TextMeshProUGUI choiceText2;

    [Header("Systems")]
    public ReputationManager reputationManager;
    public PlayerStats       playerStats;

    private bool        _isDialogueActive;
    private DialogueLine _currentLine;

    public void StartDialogue(DialogueData data)
    {
        if (data.lines.Length == 0) return;

        _isDialogueActive = true;
        _currentLine      = data.lines[0];

        dialoguePanel.SetActive(true);
        nameText.text     = _currentLine.speakerName;
        dialogueText.text = _currentLine.dialogue;

        SetupChoice(choiceButton1, choiceText1,
            _currentLine.choice1,
            _currentLine.choice1RequiredStat,
            _currentLine.choice1RequiredLevel, "1. ");

        SetupChoice(choiceButton2, choiceText2,
            _currentLine.choice2,
            _currentLine.choice2RequiredStat,
            _currentLine.choice2RequiredLevel, "2. ");

        Time.timeScale = 0f;  // 대화 중 게임 일시정지
    }
    // ...
}

선택지마다 능력치 조건을 체크하는 SetupChoice가 핵심이다.

void SetupChoice(GameObject button, TextMeshProUGUI text,
    string choiceLabel, StatType requiredStat,
    int requiredLevel, string prefix)
{
    button.SetActive(true);

    bool unlocked = playerStats.CheckStat(requiredStat, requiredLevel);
    button.GetComponent<Button>().interactable = unlocked;

    text.text = unlocked
        ? prefix + choiceLabel
        : prefix + choiceLabel
          + $" [{playerStats.GetStatKorName(requiredStat)}"
          + $" {requiredLevel} 필요]";
}

조건을 충족하지 못하면 버튼이 비활성화되고 “카리스마 3 필요”가 붙는다. 충족하면 깨끗하게 선택지만 나온다.

선택지를 고르면 평판 수치가 바뀐다.

public void OnChoice1()
{
    reputationManager.ChangeCitizenTrust(_currentLine.choice1_citizenTrust);
    reputationManager.ChangeTeamReputation(_currentLine.choice1_teamRep);
    reputationManager.ChangeLocalIntel(_currentLine.choice1_localIntel);
    ShowResult(_currentLine.result1);
}

이 수치들은 코드에 박혀있는 게 아니라 DialogueData ScriptableObject Inspector에서 신고마다 직접 설정한다. 소음 신고에서 피해자 편을 들면 시민 신뢰도 +3, 가해자 쪽 -2. 팀 지원 없이 혼자 처리하면 팀내 신망 -1. 이런 식으로.


대화창이 안 닫히던 버그

만들면서 황당한 버그가 하나 있었다. 대화 결과가 나오고 1.5초가 지나도 창이 닫히지 않는 거였다.

원인은 Time.timeScale = 0f였다. 대화 중에 게임을 일시정지했는데, Invoke는 게임 시간 기준으로 작동해서 timeScale이 0이면 영원히 실행되지 않는다.

해결은 WaitForSecondsRealtime을 쓰는 코루틴으로 교체하는 거였다.

// 이렇게 하면 안 됨 — timeScale 0이면 영원히 안 닫힘
Invoke("EndDialogue", 1.5f);

// 이렇게 해야 함 — 실제 시간 기준
IEnumerator EndDialogueAfterDelay(float delay)
{
    yield return new WaitForSecondsRealtime(delay);
    EndDialogue();
}

WaitForSecondsWaitForSecondsRealtime의 차이. 이런 걸 직접 부딪혀서 배우게 된다.


지금 구조와 앞으로

현재 대화 시스템의 구조는 이렇다.

NPCDialogue (NPC에 부착)
└─ DialogueData (ScriptableObject)
├─ DialogueLine[]
│ ├─ 대사 / 선택지 / 결과
│ ├─ 능력치 조건 (StatType enum + requiredLevel)
│ └─ 평판 변동값 (citizenTrust / teamRep / localIntel)
└─ DialogueManager가 읽어서 화면에 표시

앞으로 추가할 것들이 있다. 진술 모순 감지, NPC별 개별 관계 수치, 대화 선택에 따른 스토리 분기. 지금은 기초만 잡힌 상태고 살이 붙어야 한다.

다음 글에서는 이 시스템 위에 실제 맵이 올라가는 과정, Kenney 에셋으로 도시를 조립한 이야기를 할 예정이다.

댓글 달기

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

위로 스크롤