Unity에서 Ink를 사용하여 인터랙티브 스토리 시스템을 구현하는 기술 문서입니다.
1. https://github.com/inkle/ink-unity-integration/releases
2. 최신 .unitypackage 다운로드
3. Unity 프로젝트에 임포트
설치 후 .ink 파일을 Unity에 추가하면 자동으로 .json으로 컴파일됩니다.
Assets/
├── Ink/ # Ink 플러그인
├── Stories/ # .ink 파일들
│ ├── Chapter1.ink
│ └── Chapter2.ink
└── Scripts/
└── DialogueManager.cs
Knot: 큰 섹션 단위 (씬, 챕터)
Stitch: Knot 내부의 하위 섹션
=== chapter_one ===
Chapter 1 시작
-> meet_character
=== meet_character ===
= first_encounter
"처음 만났을 때의 대화"
= second_encounter
"두 번째 만났을 때의 대화"
-> END
=== start ===
게임 시작
-> next_scene
=== next_scene ===
다음 씬
-> start // 다른 Knot으로 이동
-> next_scene.subsection // 특정 Stitch로 이동
=== dialogue ===
"무엇을 하시겠습니까?"
* [대화하기]
"대화를 시작합니다."
-> talk_scene
* [조사하기]
"주변을 조사합니다."
-> investigate_scene
- "선택 후 공통으로 실행되는 부분"
-> END
선택지 타입:
* - 한 번만 표시되는 선택지 (once-only)+ - 반복해서 표시되는 선택지 (sticky)선택지 텍스트 제어:
* "안녕하세요[."]라고 말했다.
// 선택지에는: "안녕하세요."
// 출력될 때: "안녕하세요라고 말했다."
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{player_health > 50:
"건강 상태가 양호합니다."
- player_health > 20:
"조금 지쳤습니다."
- else:
"위험한 상태입니다."
}
=== function add_item(item_name) ===
// 함수 내용
~ return
=== function calculate_damage(base_damage) ===
~ return base_damage * 2
=== combat ===
~ temp damage = calculate_damage(10)
데미지: {damage}
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}");
}
}
}
}
// 특정 Knot으로 이동
story.ChoosePathString("chapter_two");
// 특정 Stitch로 이동
story.ChoosePathString("chapter_two.boss_fight");
// 이동 후 계속 진행
ContinueStory();
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();
}
}
Ink:
VAR has_sword = false
VAR player_level = 1
=== encounter ===
적과 마주쳤습니다.
* {has_sword} [검으로 공격]
검을 휘둘렀습니다!
* {player_level >= 5} [마법 공격]
강력한 마법을 시전했습니다!
* [도망친다]
재빨리 도망쳤습니다.
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;
}
}
}
}
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;
}
}
}
}
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}");
}
}
Ink에서 C# 함수를 호출할 수 있는 강력한 기능입니다.
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;
});
}
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);
});
}
Ink 에디터에서 테스트할 때 C# 바인딩이 없어도 에러가 나지 않도록 Fallback 함수를 정의할 수 있습니다.
Ink:
EXTERNAL PlaySound(soundName)
// Fallback: Unity에서 바인딩되지 않았을 때 실행
=== function PlaySound(soundName) ===
// 테스트용 - 실제로는 아무것도 하지 않음
~ return
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;
변수 값이 변경될 때마다 자동으로 호출되는 콜백을 등록할 수 있습니다.
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 자동 호출됨
// 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}번째 방문)
}
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("게임 저장 완료");
}
}
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) => {
// ...
});
}
}
중요: Story를 다시 생성한 후 반드시 아래 순서를 따라야 합니다:
void LoadGame()
{
// 1. Story 생성
story = new Story(inkJSON.text);
// 2. External Functions 바인딩
BindExternalFunctions();
// 3. 저장된 상태 로드
story.state.LoadJson(savedState);
// 4. 대화 진행
ContinueStory();
}
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();
}
}
여러 개의 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);
});
}
}
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;
}
}