✒️Inkspire: GPT 생성 게임 스크립트 기반 사용자 맞춤형 텍스트 RPG
[기획] Inkspire : NLP와 생성형 AI 기술을 사용한 텍스트 RPG
서문 컴퓨터공학전공에 진입하고 열심히 커리큘럼을 쫓아 이런저런 과제를 청산하다 정신을 차려보니 어느덧 3학년 2학기를 수강하고 있는 빌리. 입학하고 전공을 선택하던 게 꼭 엊그제 같은
billyboo.tistory.com
기획과 개발을 마치고 벌써 프로젝트 마무리 단계에 접어든 Inkspire.
초기 기획을 실제 어플리케이션으로 구현하면서 정말 다양한 문제를 마주했지만, 그중에서 가장 해결하기 까다로웠던 문제를 고르라면 게임 플레이 시스템을 구축하는 과정을 고르고 싶다. 사실상 어플리케이션의 정체성과 게임성을 결정짓는 부분이라 개발 기간 외에 가장 많은 시간을 투자해서 오랫동안 고민했던 것 같다.
우리가 만들고자 한 게임의 요구사항은 크게 다음 세 가지였다.
1️⃣ 사용자가 스토리에 개입하여 인터랙티브하게 진행되는 스크립트일 것
2️⃣ 게임의 전체 줄거리를 개연성있고 일관되게 생성할 것
3️⃣ 단순한 소설 작성이 아닌 목표와 보상 체계가 명확한 게임일 것
그러나 이 세 가지 조건을 모두 만족시키기가 정말 어려웠다. 플레이어의 개입을 늘리면 스토리가 산으로 가고, 인터랙션을 줄이면 게임보다는 소설 창작의 느낌이 강해졌다. 우리는 GPT의 산발적인 스토리 진행을 일관되게 조정하는 방법과, 세 조건 사이의 적절한 절충 지점을 "장소 기반 챕터제"에서 찾았다.
즉, 사용자가 달성해야하는 게임의 목표를 먼저 생성하고, 그에 따라 탐험할 수 있는 장소를 생성하여 게임을 진행하도록 시스템을 구현하였다.
📍목표에 따른 스토리 구조
Inkspire는 총 5개의 장으로 구성된 게임이다.
이 5개의 장을 순서대로 탐험하면서 각 장의 목표를 수행하고, 마지막 장에서 게임의 최종 목표를 달성하면 게임의 엔딩을 볼 수 있다. 각 장의 목표는 최종 목표와 관련된 서브 목표의 개념이고, 전투, 아이템 획득, 단서 획득 등 목표 유형은 랜덤하게 결정된다. Inkspire의 게임 시스템은 여러 스크립트들이 유기적으로 값을 주고받으며 생성되지만 게임 스토리의 전체적인 구조는 ScriptManager.cs 스크립트에서 다루고 있다.
✅ SetScriptInfo()
public async void SetScriptInfo(string char_name, string genre, string time_background, string space_background)
{
this.char_name = char_name;
// 세계관 생성
await script.InitScript(genre, time_background, space_background);
ScriptAPI.script_api.PostScenarioInfo(script, char_name);
// 목표 생성
await goals[Const.CHAPTER-1].InitGoal(time_background, space_background, script.GetWorldDetail(), genre);
await goals[0].InitGoal(time_background, space_background, script.GetWorldDetail(), genre, goals[Const.CHAPTER-1]);
ScriptAPI.script_api.PostGoalInfo(goals[Const.CHAPTER-1], Const.CHAPTER);
ScriptAPI.script_api.PostGoalInfo(goals[0], curr_chapter + 1);
// npc 정보 생성
await pro_npc.InitNpc("P", script.GetWorldDetail(), genre, char_name);
await anta_npc.InitNpc("A", script.GetWorldDetail(), genre, char_name);
// PNPC 장소 초기화
map[0].ANPC_exist = 0;
// 맵 정보 생성
ChooseEventType(); // 14개의 장소 별 이벤트 타입 생성
// PNPC 장소 초기화
map[0].ANPC_exist = 0;
await map[0].CreatePnpcPlace(script, pro_npc);
// script.IntroImageGPT(map[0].place_name, map[0].place_info);
ScriptAPI.script_api.PostMapInfo(map[0], items[0], game_events[0], 0, curr_chapter + 1);
// 일반 장소 초기화
for (int i = 1; i < 4; i++)
{
// 목표 정보 전달
await items[i].InitItem(script, goals[curr_chapter], game_events[i].type);
await map[i].InitPlace(i, script, items[i], game_events[i].type, place_names);
place_names[i] = map[i].place_name;
// 전투 이벤트(잡몹, 적 처치) 혹은 item_type이 null일 경우에는 이벤트 트리거 생성하지 않음
if (items[i].type != ItemType.Mob && items[i].type != ItemType.Monster && items[i].type != ItemType.Null)
{
await game_events[i].CreateEventTrigger(script.GetWorldDetail(), goals[curr_chapter].GetDetail(), place_names[i], items[i].name);
}
ScriptAPI.script_api.PostMapInfo(map[i], items[i], game_events[i], i, curr_chapter + 1);
}
achivement = await script.AchivementGPT();
ScriptAPI.script_api.UpdateAchievement(achivement);
Debug.Log("업적명:" + achivement);
await script.IntroGPT(pro_npc, anta_npc, map[0].place_name, map[0].place_info, this.char_name);
ScriptAPI.script_api.PutIntroInfo(script);
init_script = true;
}
먼저, SetScriptInfo 함수에서 사용자의 입력값을 바탕으로 게임의 세계관과 챕터 별 목표, NPC 정보, 장소 별 이벤트 정보, 업적명 등을 설정하여 게임 스크립트의 기본적인 뼈대를 세팅한다. 이때, 플레이어의 몰입감을 높이고 줄거리에 개연성을 부여하기 위해 마지막 최종 목표를 가장 먼저 생성하고, 나머지 챕터 목표는 최종 목표와 연관성이 있도록 해당 챕터가 시작될 때 GPT에 최종 목표 정보를 프롬프트로 넘겨주었다.
Place.cs의 CreatePlace 함수는 이러한 목표 정보에 따라 장소를 생성하는 함수이다. 이 함수를 통해 게임이 진행될 Map의 장소 이름과 해당 장소에 대한 설명을 생성한다. 테스트 과정에서 장소의 이름을 중복해서 출력하거나 양식을 자유분방하게 출력하는 경우가 많아 파싱을 효율적으로 하기 위해 다음과 같이 형식을 제한하는 것에 집중하여 프롬프트를 조정하였다.
✅ CreatePlace()
public async Task CreatePlace(int idx, Script script, Item item, List<string> place_names)
{
string time_background = script.GetTimeBackground();
string space_background = script.GetSpaceBackground();
string world_detail = script.GetWorldDetail();
string genre = script.GetGenre();
gpt_messages.Clear();
ChatMessage prompt_msg;
prompt_msg = new ChatMessage()
{
Role = "system",
Content = @"당신은 게임 진행에 필요한 장소를 제시한다.
다음은 게임의 배경인 " + time_background + "시대" + space_background + "를 배경으로 하는 세계관에 대한 설명이다." + world_detail
};
if (item.type != ItemType.Null) {
prompt_msg.Content += @"다음은 이 장소에서 발견할 수 있는 아이템에 대한 설명이다.
아이템 이름: " + item.name + @"
아이템 설명: " + item.info;
}
prompt_msg.Content += @"장소는 게임의 배경에 맞추어 플레이어가 흥미롭게 탐색할 수 있는 곳으로 생성된다. 장소 생성 양식은 아래와 같다. 각 줄의 요소는 반드시 모두 포함되어야 하며, 반드시 아래 생성 양식을 따라야 한다. ** 이 표시 안의 내용은 문맥에 맞게 채운다.
ex)
장소명: *장소 이름을 한 단어로 출력*
장소설명: *장소에 대한 설명을 50자 내외로 설명, 어미는 입니다 체로 통일합니다.*";
gpt_messages.Add(prompt_msg);
var query_msg = new ChatMessage()
{
Role = "user",
Content = "와 장소 이름이 중복되지 않도록 진행중인 게임의 " + genre + " 장르와 세계관에 어울리는 장소 생성. 장소 이름은 절대 중복되어서는 안된다."
};
for (int i = 0; i < idx; i++)
{
if (i != 0)
{
query_msg.Content = place_names[i] + ", " + query_msg.Content;
}
else
{
query_msg.Content = place_names[i] + query_msg.Content;
}
}
gpt_messages.Add(query_msg);
StringToPlace("출력 " + await GptManager.gpt.CallGpt(gpt_messages));
}
아래 SetNextChapter는 챕터가 넘어갈 때 호출되는데, 사용자 입력값과 세계관 배경을 같이 전달하여 게임 스토리가 전혀 다른 방향으로 개연성 없이 진행되는 것을 방지하였다. 이 함수가 호출되면 챕터 목표와 이벤트 트리거 등 다음 장을 진행하는데 필요한 요소들이 세팅되어 새로운 장소가 해금된다.
✅ SetNextChapter()
public async void SetNextChapter()
{
string genre = script.GetGenre();
string time_background = script.GetTimeBackground();
string space_background = script.GetSpaceBackground();
PlayScene.play_scene.LoadNextChapUI();
curr_chapter++;
int place_base = curr_chapter * 3 + 1;
await goals[curr_chapter].InitGoal(time_background, space_background, script.GetWorldDetail(), genre, goals[Const.CHAPTER-1]);
ScriptAPI.script_api.PostGoalInfo(goals[curr_chapter], curr_chapter + 1);
for (int i = 0; i < 3; i++)
{
// 목표 정보 전달
await items[place_base + i].InitItem(script, goals[curr_chapter], game_events[place_base + i].type);
await map[place_base + i].InitPlace(place_base + i, script, items[place_base + i], game_events[place_base + i].type, place_names);
place_names[place_base + i] = map[place_base + i].place_name;
// 전투 이벤트(잡몹, 적 처치) 혹은 item_type이 null일 경우에는 이벤트 트리거 생성하지 않음
if (items[place_base + i].type != ItemType.Mob && items[place_base + i].type != ItemType.Monster && items[place_base + i].type != ItemType.Null)
{
await game_events[place_base + i].CreateEventTrigger(script.GetWorldDetail(), goals[curr_chapter].GetDetail(), place_names[place_base + i], items[place_base + i].name);
}
ScriptAPI.script_api.PostMapInfo(map[place_base + i], items[place_base + i], game_events[place_base + i], place_base + i, curr_chapter + 1);
}
PlayScene.play_scene.LoadChapter(curr_chapter, false);
}
📍게임 스토리와 연결되는 장소 Map 시스템
게임에서 발생하는 이벤트 (전투, 다이스 등)와 줄거리를 최대한 자연스럽고 이질적이지 않게 연결하기 위해 장소, 즉, 맵 시스템을 만들었다. Inkspire에서는 아래 구조와 같이 각 장별로 3개의 장소가 생성된다. 플레이어는 지도 모달창을 통해 원하는 장소로 이동할 수 있고, 주어지는 이벤트를 수행하며 스토리를 진행한다.
🗺️ Map 구조
- 장소 0 : 조력자 NPC에게 도움을 받을 수 있는 장소
- Chapter I ~ IV
- 장소 1
- 장소 2
- 장소 3
- Chapter V
- 최종 장소
이렇게 생성되는 장소들에서 플레이어는 자유롭게 세계관을 탐험하고, 최종 목표를 수행하기 위해 각 장에서 주어지는 소목표들을 클리어하며 게임을 진행하게 된다. 플레이어가 다음 장으로 성공적으로 넘어가기 위해서는 소목표, 즉 챕터 목표를 달성해야하는데, 이 목표는 세 장소 중 한 곳에 랜덤으로 배치된다. 챕터 목표가 포함된 장소가 아닌 나머지 두 곳에서는 일반 이벤트가 발생하거나, 특별한 이벤트 없이 플레이어 캐릭터가 자율적으로 행동하며 스토리를 진행할 수 있다. 이렇게 장소 별로 어떤 종류의 이벤트를 발생시킬지는 아래 ChooseEventType() 함수에서 UnityEngine.Random.Range()를 사용하여 랜덤으로 결정된다. 플래그가 1일 경우 목표 이벤트, 0일 경우 일반 이벤트로 생성된다.
✅ ChooseEventType()
// 장소 별 이벤트 타입 설정 (3개 장소마다 목표 이벤트 출현 장소 정하는 로직)
private void ChooseEventType()
{
int i = 1;
while (i < Const.PLACE_COUNT-1)
{
int flag = Random.Range(0, 3);
if (flag == 0)
{
game_events[i].type = 1;
}
else
{
game_events[i].type = 0;
}
if (game_events[i].type == 1)
{
//100
game_events[i + 1].type = 0;
game_events[i + 2].type = 0;
i += 3;
continue;
}
else
{
i++;
game_events[i].type = UnityEngine.Random.Range(0, 2);
if (game_events[i].type == 1)
{
game_events[i + 1].type = 0; //010
}
else
{
game_events[i + 1].type = 1; //001
}
i += 2;
}
}
//최종 목표 장소
if (i == Const.PLACE_COUNT-1)
{
game_events[i].type = 1;
map[i].ANPC_exist = 0;
}
}
마지막으로 게임의 최종 목표 이벤트가 진행되는 마지막 장소는 다른 장들과 다르게 단 하나의 장소만을 생성하기 때문에 함수를 분리하여 구현하였다. SetFinalPlace 함수를 호출하면 앞선 코드와 같은 형식으로 목표 정보에 따라 마지막 최종 장소가 생성된다.
✅ SetFinalPlace()
public async void SetFinalPlace()
{
PlayScene.play_scene.LoadNextChapUI();
curr_chapter++;
int place_base = curr_chapter * 3 + 1;
// 최종 장소 목표 정보 전달
await items[place_base].InitItem(script, goals[curr_chapter], game_events[place_base].type);
await map[place_base].InitPlace(curr_chapter, script, items[place_base], game_events[place_base].type, place_names);
place_names[place_base] = map[place_base].place_name;
if (items[place_base].type != ItemType.Monster)
{
await game_events[place_base].CreateEventTrigger(script.GetWorldDetail(), goals[curr_chapter].GetDetail(), place_names[place_base], items[place_base].name);
}
ScriptAPI.script_api.PostMapInfo(map[place_base], items[place_base], game_events[place_base], place_base, curr_chapter + 1);
PlayScene.play_scene.LoadChapter(curr_chapter, false);
}
위의 코드로 게임을 생성하고 진행한 결과는 다음과 같다.
중세 서양의 판타지 세계관에 어울리는 "신록의 틈새", "신성한 산틈", "생명의 샘물", "신록의 은신처" 라는 장소 이름을 생성하였고 뼈대 구성과 함께 생성된 조력자 NPC 릴리안은 인덱스 0번 장소인 "신록의 은신처"에 방문하면 조우할 수 있다. 목표 이벤트 장소로 이동하면 목표 이벤트가 발생하게 되는데, 이 게임의 경우 "신성한 산틈"이 목표 이벤트가 발생하는 장소였으며 해당 장소에서 게임의 세계관에 어울리는 적 "그리모토스"와 전투를 진행하였음을 확인할 수 있다.
'CSE > Capstone Design' 카테고리의 다른 글
[Python/chatGPT API] NPC 생성 함수 구현 (0) | 2023.11.24 |
---|---|
[기획] Inkspire : NLP와 생성형 AI 기술을 사용한 텍스트 RPG (0) | 2023.11.24 |
[Python/Firebase] 회원가입 기능 구현 (+ 탈퇴 기능) (0) | 2022.12.13 |