Unity + Ink 통합 가이드

REIN·2025년 10월 7일

Unity 관련 정보들

목록 보기
2/3

Unity에서 Ink를 사용하여 인터랙티브 스토리 시스템을 구현하는 기술 문서입니다.

목차

  1. 환경 설정
  2. Ink 핵심 개념
  3. Unity에서 Story 실행
  4. 선택지 처리
  5. 태그 시스템
  6. External Functions
  7. 변수 관리
  8. 저장/로드
  9. 실전 패턴

1. 환경 설정

1.1 Ink Unity Integration 설치

1. https://github.com/inkle/ink-unity-integration/releases
2. 최신 .unitypackage 다운로드
3. Unity 프로젝트에 임포트

설치 후 .ink 파일을 Unity에 추가하면 자동으로 .json으로 컴파일됩니다.

1.2 기본 프로젝트 구조

Assets/
  ├── Ink/              # Ink 플러그인
  ├── Stories/          # .ink 파일들
  │   ├── Chapter1.ink
  │   └── Chapter2.ink
  └── Scripts/
      └── DialogueManager.cs

2. Ink 핵심 개념

2.1 Knot과 Stitch

Knot: 큰 섹션 단위 (씬, 챕터)
Stitch: Knot 내부의 하위 섹션

=== chapter_one ===
Chapter 1 시작
-> meet_character

=== meet_character ===
= first_encounter
"처음 만났을 때의 대화"

= second_encounter
"두 번째 만났을 때의 대화"

-> END

2.2 Divert (흐름 제어)

=== start ===
게임 시작
-> next_scene

=== next_scene ===
다음 씬
-> start                    // 다른 Knot으로 이동
-> next_scene.subsection    // 특정 Stitch로 이동

2.3 선택지

=== dialogue ===
"무엇을 하시겠습니까?"

* [대화하기]
  "대화를 시작합니다."
  -> talk_scene

* [조사하기]
  "주변을 조사합니다."
  -> investigate_scene

- "선택 후 공통으로 실행되는 부분"
-> END

선택지 타입:

  • * - 한 번만 표시되는 선택지 (once-only)
  • + - 반복해서 표시되는 선택지 (sticky)

선택지 텍스트 제어:

* "안녕하세요[."]라고 말했다.
  // 선택지에는: "안녕하세요."
  // 출력될 때: "안녕하세요라고 말했다."

2.4 변수

VAR player_health = 100
VAR has_key = false
VAR player_name = "Unknown"

=== game_start ===
현재 체력: {player_health}

* [문을 연다]
  {has_key:
    ~ player_health -= 10
    문을 열었습니다.
  - else:
    열쇠가 필요합니다.
  }

변수 타입:

  • int - 정수
  • float - 소수
  • string - 문자열
  • bool - true/false

2.5 조건문

{player_health > 50:
  "건강 상태가 양호합니다."
- player_health > 20:
  "조금 지쳤습니다."
- else:
  "위험한 상태입니다."
}

2.6 함수

=== function add_item(item_name) ===
// 함수 내용
~ return

=== function calculate_damage(base_damage) ===
~ return base_damage * 2

=== combat ===
~ temp damage = calculate_damage(10)
데미지: {damage}

3. Unity에서 Story 실행

3.1 기본 Story 로드

using Ink.Runtime;
using UnityEngine;

public class DialogueManager : MonoBehaviour
{
    [SerializeField] private TextAsset inkJSON;
    private Story story;

    void Start()
    {
        // Story 생성
        story = new Story(inkJSON.text);
        
        // 에러 핸들러 등록
        story.onError += OnStoryError;
        
        // 대화 시작
        ContinueStory();
    }

    void OnStoryError(string message, Ink.ErrorType type)
    {
        if (type == Ink.ErrorType.Warning)
            Debug.LogWarning(message);
        else
            Debug.LogError(message);
    }

    void ContinueStory()
    {
        // canContinue가 true인 동안 텍스트 출력
        while (story.canContinue)
        {
            string text = story.Continue();
            Debug.Log(text);
        }
        
        // 선택지 확인
        ShowChoices();
    }

    void ShowChoices()
    {
        if (story.currentChoices.Count > 0)
        {
            foreach (Choice choice in story.currentChoices)
            {
                Debug.Log($"- {choice.text}");
            }
        }
    }
}

3.2 Knot/Stitch로 점프

// 특정 Knot으로 이동
story.ChoosePathString("chapter_two");

// 특정 Stitch로 이동
story.ChoosePathString("chapter_two.boss_fight");

// 이동 후 계속 진행
ContinueStory();

4. 선택지 처리

4.1 선택지 표시 및 선택

public class DialogueManager : MonoBehaviour
{
    private Story story;
    private List<GameObject> choiceButtons = new List<GameObject>();
    
    [SerializeField] private GameObject choiceButtonPrefab;
    [SerializeField] private Transform choiceContainer;

    void ShowChoices()
    {
        // 기존 버튼 제거
        ClearChoiceButtons();
        
        // 선택지가 있는 경우
        if (story.currentChoices.Count > 0)
        {
            for (int i = 0; i < story.currentChoices.Count; i++)
            {
                Choice choice = story.currentChoices[i];
                CreateChoiceButton(choice, i);
            }
        }
    }

    void CreateChoiceButton(Choice choice, int index)
    {
        GameObject button = Instantiate(choiceButtonPrefab, choiceContainer);
        button.GetComponentInChildren<Text>().text = choice.text;
        
        button.GetComponent<Button>().onClick.AddListener(() => {
            OnChoiceSelected(index);
        });
        
        choiceButtons.Add(button);
    }

    void OnChoiceSelected(int choiceIndex)
    {
        story.ChooseChoiceIndex(choiceIndex);
        ContinueStory();
    }

    void ClearChoiceButtons()
    {
        foreach (GameObject button in choiceButtons)
        {
            Destroy(button);
        }
        choiceButtons.Clear();
    }
}

4.2 조건부 선택지

Ink:

VAR has_sword = false
VAR player_level = 1

=== encounter ===
적과 마주쳤습니다.

* {has_sword} [검으로 공격]
  검을 휘둘렀습니다!

* {player_level >= 5} [마법 공격]
  강력한 마법을 시전했습니다!

* [도망친다]
  재빨리 도망쳤습니다.

5. 태그 시스템

5.1 라인 태그

Ink:

"안녕하세요." # speaker:npc_merchant # emotion:happy
"어서오세요!" # speaker:npc_merchant # emotion:excited # audio:welcome.mp3

C# 파싱:

void ContinueStory()
{
    while (story.canContinue)
    {
        string text = story.Continue();
        List<string> tags = story.currentTags;
        
        // 태그 파싱
        ParseTags(tags);
        
        // 텍스트 출력
        DisplayText(text);
    }
}

void ParseTags(List<string> tags)
{
    foreach (string tag in tags)
    {
        string[] parts = tag.Split(':');
        
        if (parts.Length == 2)
        {
            string key = parts[0].Trim();
            string value = parts[1].Trim();
            
            switch (key)
            {
                case "speaker":
                    SetCurrentSpeaker(value);
                    break;
                case "emotion":
                    SetCharacterEmotion(value);
                    break;
                case "audio":
                    PlayAudioClip(value);
                    break;
            }
        }
    }
}

5.2 Knot 태그

Ink:

=== dungeon_entrance ===
# location:dungeon
# bgm:dark_ambient.mp3
# lighting:dim

던전 입구에 도착했습니다.

C# 미리 읽기:

void PrepareScene(string knotName)
{
    List<string> knotTags = story.TagsForContentAtPath(knotName);
    
    foreach (string tag in knotTags)
    {
        string[] parts = tag.Split(':');
        if (parts.Length == 2)
        {
            string key = parts[0].Trim();
            string value = parts[1].Trim();
            
            switch (key)
            {
                case "location":
                    LoadLocation(value);
                    break;
                case "bgm":
                    ChangeBGM(value);
                    break;
                case "lighting":
                    SetLighting(value);
                    break;
            }
        }
    }
}

5.3 Global 태그

Ink:

# title:My Game
# author:Developer Name
# version:1.0

=== start ===
...

C# 읽기:

void Start()
{
    story = new Story(inkJSON.text);
    
    List<string> globalTags = story.globalTags;
    foreach (string tag in globalTags)
    {
        Debug.Log($"Global Tag: {tag}");
    }
}

6. External Functions

Ink에서 C# 함수를 호출할 수 있는 강력한 기능입니다.

6.1 기본 사용법

Ink:

EXTERNAL PlaySound(soundName)
EXTERNAL GiveItem(itemName, quantity)
EXTERNAL GetPlayerGold()

=== shop ===
"어서오세요!"
~ PlaySound("bell")

* [검을 산다 (100골드)]
  {GetPlayerGold() >= 100:
    ~ GiveItem("sword", 1)
    검을 구매했습니다!
  - else:
    골드가 부족합니다.
  }

C# 바인딩:

void Start()
{
    story = new Story(inkJSON.text);
    BindExternalFunctions();
}

void BindExternalFunctions()
{
    // 반환값 없는 함수
    story.BindExternalFunction("PlaySound", (string soundName) => {
        AudioManager.Instance.PlaySound(soundName);
    });

    // 반환값 없는 함수 (매개변수 여러 개)
    story.BindExternalFunction("GiveItem", (string itemName, int quantity) => {
        Inventory.Instance.AddItem(itemName, quantity);
    });

    // 반환값 있는 함수
    story.BindExternalFunction("GetPlayerGold", () => {
        return PlayerData.Instance.Gold;
    });
}

6.2 게임 상태 변경 예제

Ink:

EXTERNAL ChangeGameState(stateName)
EXTERNAL SpawnEnemy(enemyType, count)
EXTERNAL IsQuestComplete(questId)

=== boss_encounter ===
보스가 나타났습니다!
~ ChangeGameState("Combat")
~ SpawnEnemy("dragon", 1)

* [전투 시작]
  -> combat_start

=== quest_check ===
{IsQuestComplete(1):
  퀘스트를 완료했습니다!
- else:
  아직 퀘스트가 진행 중입니다.
}

C# 구현:

void BindExternalFunctions()
{
    story.BindExternalFunction("ChangeGameState", (string stateName) => {
        GameManager.Instance.ChangeState(stateName);
    });

    story.BindExternalFunction("SpawnEnemy", (string enemyType, int count) => {
        EnemySpawner.Instance.SpawnEnemies(enemyType, count);
    });

    story.BindExternalFunction("IsQuestComplete", (int questId) => {
        return QuestManager.Instance.IsQuestComplete(questId);
    });
}

6.3 Fallback 함수 (테스트용)

Ink 에디터에서 테스트할 때 C# 바인딩이 없어도 에러가 나지 않도록 Fallback 함수를 정의할 수 있습니다.

Ink:

EXTERNAL PlaySound(soundName)

// Fallback: Unity에서 바인딩되지 않았을 때 실행
=== function PlaySound(soundName) ===
// 테스트용 - 실제로는 아무것도 하지 않음
~ return

7. 변수 관리

7.1 변수 읽기/쓰기

Ink:

VAR player_health = 100
VAR player_name = "Hero"
VAR quest_completed = false

C# 접근:

// 읽기
int health = (int)story.variablesState["player_health"];
string name = (string)story.variablesState["player_name"];
bool questDone = (bool)story.variablesState["quest_completed"];

// 쓰기
story.variablesState["player_health"] = 80;
story.variablesState["player_name"] = "Knight";
story.variablesState["quest_completed"] = true;

7.2 변수 Observer

변수 값이 변경될 때마다 자동으로 호출되는 콜백을 등록할 수 있습니다.

void Start()
{
    story = new Story(inkJSON.text);
    
    // 변수 감시 시작
    story.ObserveVariable("player_health", OnHealthChanged);
    story.ObserveVariable("player_gold", OnGoldChanged);
}

void OnHealthChanged(string variableName, object newValue)
{
    int health = (int)newValue;
    UIManager.Instance.UpdateHealthBar(health);
}

void OnGoldChanged(string variableName, object newValue)
{
    int gold = (int)newValue;
    UIManager.Instance.UpdateGoldDisplay(gold);
}

Ink에서 변수가 바뀔 때 자동으로 UI 업데이트:

VAR player_health = 100

=== combat ===
적의 공격!
~ player_health -= 20
// OnHealthChanged 자동 호출됨

7.3 방문 횟수 (Visit Count)

// Knot/Stitch 방문 횟수 확인
int visitCount = story.state.VisitCountAtPathString("shop.greeting");

if (visitCount == 0)
{
    Debug.Log("처음 방문");
}
else
{
    Debug.Log($"{visitCount}번째 방문");
}

Ink에서도 사용 가능:

=== shop ===
= greeting
{greeting == 1:
  처음 오셨군요!
- greeting == 2:
  다시 오셨네요.
- else:
  단골이시군요! ({greeting}번째 방문)
}

8. 저장/로드

8.1 저장

public class SaveManager : MonoBehaviour
{
    private Story story;
    
    public void SaveGame()
    {
        // Story 상태를 JSON으로 변환
        string storyState = story.state.ToJson();
        
        // 저장 (PlayerPrefs 예시)
        PlayerPrefs.SetString("StoryState", storyState);
        PlayerPrefs.Save();
        
        Debug.Log("게임 저장 완료");
    }
}

8.2 로드

public class SaveManager : MonoBehaviour
{
    [SerializeField] private TextAsset inkJSON;
    private Story story;
    
    public void LoadGame()
    {
        // Story 새로 생성
        story = new Story(inkJSON.text);
        
        // External Functions 바인딩
        BindExternalFunctions();
        
        // 저장된 상태 불러오기
        string savedState = PlayerPrefs.GetString("StoryState", "");
        
        if (!string.IsNullOrEmpty(savedState))
        {
            story.state.LoadJson(savedState);
            Debug.Log("게임 로드 완료");
        }
        else
        {
            Debug.Log("저장된 데이터가 없습니다");
        }
    }
    
    void BindExternalFunctions()
    {
        // External Functions 재바인딩 필요
        story.BindExternalFunction("PlaySound", (string sound) => {
            // ...
        });
    }
}

8.3 주의사항

중요: Story를 다시 생성한 후 반드시 아래 순서를 따라야 합니다:

void LoadGame()
{
    // 1. Story 생성
    story = new Story(inkJSON.text);
    
    // 2. External Functions 바인딩
    BindExternalFunctions();
    
    // 3. 저장된 상태 로드
    story.state.LoadJson(savedState);
    
    // 4. 대화 진행
    ContinueStory();
}

9. 실전 패턴

9.1 DialogueManager 전체 구조

using Ink.Runtime;
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;

public class DialogueManager : MonoBehaviour
{
    [Header("Ink")]
    [SerializeField] private TextAsset inkJSON;
    private Story story;
    
    [Header("UI")]
    [SerializeField] private GameObject dialoguePanel;
    [SerializeField] private Text dialogueText;
    [SerializeField] private GameObject choiceButtonPrefab;
    [SerializeField] private Transform choiceContainer;
    
    private List<GameObject> currentChoices = new List<GameObject>();
    
    void Start()
    {
        InitializeStory();
    }
    
    void InitializeStory()
    {
        story = new Story(inkJSON.text);
        story.onError += OnStoryError;
        BindExternalFunctions();
    }
    
    void OnStoryError(string message, Ink.ErrorType type)
    {
        if (type == Ink.ErrorType.Warning)
            Debug.LogWarning($"Ink Warning: {message}");
        else
            Debug.LogError($"Ink Error: {message}");
    }
    
    void BindExternalFunctions()
    {
        story.BindExternalFunction("PlaySound", (string soundName) => {
            // 사운드 재생 로직
        });
        
        story.BindExternalFunction("ChangeScene", (string sceneName) => {
            // 씬 전환 로직
        });
    }
    
    public void StartDialogue()
    {
        dialoguePanel.SetActive(true);
        ContinueStory();
    }
    
    void ContinueStory()
    {
        // 텍스트 출력
        while (story.canContinue)
        {
            string text = story.Continue();
            ProcessTags(story.currentTags);
            dialogueText.text = text.Trim();
        }
        
        // 선택지 표시
        DisplayChoices();
    }
    
    void ProcessTags(List<string> tags)
    {
        foreach (string tag in tags)
        {
            string[] parts = tag.Split(':');
            if (parts.Length == 2)
            {
                string key = parts[0].Trim();
                string value = parts[1].Trim();
                
                // 태그 처리 로직
                HandleTag(key, value);
            }
        }
    }
    
    void HandleTag(string key, string value)
    {
        switch (key)
        {
            case "speaker":
                // 화자 변경
                break;
            case "emotion":
                // 표정 변경
                break;
            case "audio":
                // 오디오 재생
                break;
        }
    }
    
    void DisplayChoices()
    {
        ClearChoices();
        
        foreach (Choice choice in story.currentChoices)
        {
            GameObject choiceButton = Instantiate(choiceButtonPrefab, choiceContainer);
            choiceButton.GetComponentInChildren<Text>().text = choice.text;
            
            int choiceIndex = choice.index;
            choiceButton.GetComponent<Button>().onClick.AddListener(() => {
                OnChoiceSelected(choiceIndex);
            });
            
            currentChoices.Add(choiceButton);
        }
        
        if (story.currentChoices.Count == 0)
        {
            // 대화 종료
            EndDialogue();
        }
    }
    
    void OnChoiceSelected(int choiceIndex)
    {
        story.ChooseChoiceIndex(choiceIndex);
        ContinueStory();
    }
    
    void ClearChoices()
    {
        foreach (GameObject choice in currentChoices)
        {
            Destroy(choice);
        }
        currentChoices.Clear();
    }
    
    void EndDialogue()
    {
        dialoguePanel.SetActive(false);
    }
    
    public void JumpToKnot(string knotName)
    {
        story.ChoosePathString(knotName);
        ContinueStory();
    }
}

9.2 멀티 파일 관리

여러 개의 Ink 파일을 사용하는 경우:

public class StoryManager : MonoBehaviour
{
    [SerializeField] private TextAsset[] chapterFiles;
    private Story currentStory;
    private int currentChapter = 0;
    
    public void LoadChapter(int chapterIndex)
    {
        if (chapterIndex < 0 || chapterIndex >= chapterFiles.Length)
            return;
        
        currentChapter = chapterIndex;
        currentStory = new Story(chapterFiles[chapterIndex].text);
        BindExternalFunctions();
        
        Debug.Log($"Chapter {chapterIndex + 1} 로드됨");
    }
    
    void BindExternalFunctions()
    {
        currentStory.BindExternalFunction("NextChapter", () => {
            LoadChapter(currentChapter + 1);
        });
    }
}

9.3 조건부 대화 시작

public class NPCInteraction : MonoBehaviour
{
    [SerializeField] private DialogueManager dialogueManager;
    [SerializeField] private string npcKnotName;
    
    void OnInteract()
    {
        // 조건 확인
        if (IsFirstMeeting())
        {
            dialogueManager.JumpToKnot($"{npcKnotName}.first_meeting");
        }
        else
        {
            dialogueManager.JumpToKnot($"{npcKnotName}.regular_greeting");
        }
        
        dialogueManager.StartDialogue();
    }
    
    bool IsFirstMeeting()
    {
        // 방문 횟수 또는 플레이어 데이터로 판단
        return PlayerPrefs.GetInt($"met_{npcKnotName}", 0) == 0;
    }
}

참고 자료

profile
RL Researcher, Video Game Developer

0개의 댓글