게임은 놀이 문화의 일종으로 "재미"라는 공통된 목적성을 가진다. 우리는 플레이어가 쉽게 몰입할 수 있는 게임이 재미있고 좋은 게임이라고 생각했고, 몰입을 위한 장치로 NPC를 활용하면 좋은 게임을 만들 수 있을 것이라고 판단했다. 게임 속 NPC는 플레이어 캐릭터와 상호작용하며 게임의 전체적인 분위기를 좌우한다. 상호작용 과정을 통해 NPC는 사용자의 몰입도를 높이고 스크립트의 세계관을 더욱 공고히 다지는 역할을 한다.이러한 NPC의 역할을 극대화하기 위해서 스토리에 잘 녹아들면서, 동시에 실제로 존재하는 인물처럼 자연스러운 대화가 가능한 캐릭터를 구현하고자 했는데 우리는 그 방법으로 플레이어 캐릭터와 NPC 사이의 상호작용에 대한 자유도를 높이고 플레이어 캐릭터와의 대화 내용을 NPC가 기억하도록 하는 방식을 선택했다.
우리가 처음 계획했던 NPC 생성 방식은 GPT가 생성한 스크립트에서 NPC 정보를 직접 추출하는 것이었다. 프롬프트 엔지니어링을 통해 GPT가 출력한 스크립트 내 NPC에 대한 정보를 json의 방식으로 저장하고, 이를 DB로 전달하고자 했다. 아래는 처음 작성했던 프롬프트 엔지니어링용 const 파일 내 SYSTEM_PLAY 상수이다.
SYSTEM_PLAY = '''당신은 게임 속 세계관을 전부 알고 있는 전능한 존재이자 스토리 게임을 진행하는 Narrator이다.
플레이어가 선택해야 하는 모든 선택지들은 플레이어의 선택을 기다려야 한다.
대답할 수 없거나 이해할 수 없는 질문, 앞으로의 진행을 알려달라는 등의 게임의 재미를 해치는 질문에는 답하지 않고 "해당 질문에 대한 답변은 드릴 수 없습니다"를 출력한다.
플레이어의 높은 몰입도를 위해 실제 TRPG의 게임 마스터와 같은 역할을 수행해야 한다.
플레이어가 말을 거는 듯한 어투의 대화를 입력할 경우 Narrator가 아닌 NPC가 답변하는 형태로 출력한다.
아래와 같은 양식으로 사용자가 입력한 배경과 분위기의 맞는 다른 내용의 게임 시나리오를 출력한다. ** 이 표시 안의 내용은 문맥에 맞게 채운다.
###
Narrator (내레이터):
*게임 스토리 진행 멘트 혹은 플레이어의 선택지 생성*
*필요할 경우 현재 상황에 대한 설명*
*NPC 이름*:
*대화 내용*
###
현재 대화의 상황과 등장한 인물의 이름, 직업, 성격, 말투 등을 요약해서 JSON의 형태로 출력한다. 아래는 출력 형태에 대한 예시이다. ** 이 표시 안의 내용은 문맥에 맞게 채운다.
###
"situation": "*현재 대화 상황 요약*",
"NPC_name": "*NPC 이름*",
"NPC_role": "*조력자 or 적대자*",
"NPC_job": "*NPC 직업*",
"NPC_persona": "*NPC 성격*",
"NPC_tone": "*NPC 말투(존댓말 여부)*",
###
'''
그러나...
위와 같은 방식으로 NPC를 생성할 경우 발생하는 문제가 한 가지 있었는데, 바로 GPT가 NPC를 생성하는 시점을 예상할 수 없다는 것이었다. 추출할 내용과 저장 형태까지는 맞춰두었으나 해당 상수를 언제 호출해야할지 정하는 것이 어려웠다. 호출 시점을 정하기 위해서는 GPT가 답변을 출력할 때마다 자연어 처리 함수를 돌려 해당 답변에 NPC와 관련된 내용이 있는지 파악하는 과정이 필요했는데, NLP 알고리즘을 구현하기에는 시간적인 여유도 없을 뿐더러 이미 GPT를 호출하는 데 제법 긴 대기 시간이 소요되는 와중에 그 답변이 함수 하나를 더 돌고 나온다? 굳이 거기까지 구현하지 않아도 사용자가 대기 시간에 지쳐 떨어져나가는 것을 아주 생생하게 예상할 수 있었다.
그래서 우리는 게임의 배경과 세계관을 사용자의 입력에 따라 초반에 생성하듯이, npc_funcion.py 파일을 새로 만들어 게임의 배경에 부합하는 NPC를 처음부터 생성하는 쪽으로 방향을 수정했다.
npc_function.py
from .gpt_function import callGPT
# 조력자 이름 생성
def getProtaNPCName(background, genre):
npc_setting = "너는 조력자 NPC 캐릭터의 이름을 한 단어로 출력해야 해."
query = background + " 배경의 " + genre + "분위기에 어울리는 조력자 NPC 이름 1개를 출력해줘"
messages = [
{"role": "system", "content": npc_setting},
{"role": "user", "content": query}
]
response = callGPT(messages=messages, stream=False)
PNPC_name = response['choices'][0]['message']['content']
return PNPC_name
# 조력자 설정 생성
def getProtaNPCInfo(town, PNPC_name):
npc_setting = "조력자 NPC는 " + town + \
"에 어울리는 직업을 가져야 하고, 성격에 맞게 존댓말 혹은 반말 중 하나의 말투만을 일관되게 사용해야 해."
query = town + "의 조력자 NPC인" + PNPC_name + "의 직업, 그리고 성격과 말투를 설명해줘."
messages = [
{"role": "system", "content": npc_setting},
{"role": "user", "content": query}
]
response = callGPT(messages=messages, stream=False)
PNPC_info = response['choices'][0]['message']['content']
return PNPC_info
# 적대자 NPC 이름 생성
def getAntaNPCName(background, genre):
npc_setting = "너는 적대자 NPC 캐릭터의 이름을 한 단어로 출력해야 해."
query = background + " 배경의 " + genre + "분위기에 어울리는 적대자 NPC 이름 1개를 출력해줘"
messages = [
{"role": "system", "content": npc_setting},
{"role": "user", "content": query}
]
response = callGPT(messages=messages, stream=False)
ANPC_name = response['choices'][0]['message']['content']
return ANPC_name
# 적대자 NPC 설정 생성
def getAntaNPCInfo(town, ANPC_name):
npc_setting = "적대자 NPC는 " + town + \
"에 어울리는 직업을 가져야 하고, 성격에 맞게 존댓말 혹은 반말 중 하나의 말투만을 일관되게 사용해야 해."
query = town + "의 적대자 NPC인" + ANPC_name + "의 직업, 그리고 성격과 말투를 설명해줘."
messages = [
{"role": "system", "content": npc_setting},
{"role": "user", "content": query}
]
response = callGPT(messages=messages, stream=False)
ANPC_info = response['choices'][0]['message']['content']
return ANPC_info
우선 callGPT를 통해 GPT를 호출할 수 있도록 모듈을 import해주었다. callGPT 함수는 gpt_function.py에 있으며 파일 내용은 아래와 같다.
gpt_function.py
import openai
from dotenv import load_dotenv
import os
load_dotenv()
# 환경변수로 설정해 둔 API 키 받아오기
openai.api_key = os.getenv("OPENAI_API_KEY")
# GPT 출력값 스트림 형태로 출력 & 결과값 반환
def printStream(response):
answer = ""
# Print the ChatGPT response within a loop.
for chunk in response:
print(
chunk.choices[0].delta.get("content", ""),
end="" # This will put all the streamed chunks on one line.
)
answer += chunk.choices[0].delta.get("content", "")
print("\n")
return answer
# GPT API 호출 함수 & 스트림 형태 출력
def callGPT(messages, stream):
model = "gpt-3.5-turbo"
response = openai.ChatCompletion.create(
model=model,
messages=messages,
stream=stream,
temperature=0.5, # 랜덤성 조절
)
if (stream):
return printStream(response=response)
return response
# 응답 요약
def summary(response):
query = response + " 요약"
messages = [
{"role": "user", "content": query}
]
response = callGPT(messages=messages, stream=False)
return response['choices'][0]['message']['content']
여기서 주의할 점
혼자 작업하거나 로컬에서만 작업한다면 OpenAI의 API 키를 직접 코드에 적용해도 상관없지만 Github에 코드를 올리는 등 코드를 온라인 환경에 공개하게 될 경우에는 .env 파일을 만들어 환경변수를 설정해 API 키를 저장하는 것이 좋다. 왜냐하면... raw 키를 Github에 직접 커밋하면 OpenAI가 API 키를 막아버리기 때문에 키를 새로 발급받아야 한다. 나도 알고싶지 않았다. 나의 바보짓으로 인한...
Inkspire에서 생성하는 NPC는 역할에 따라 크게 두 종류, 조력자(Protagonist)와 적대자(Antagonist)로 나뉜다. 조력자 NPC는 플레이어가 게임 내 미션을 무사히 수행할 수 있도록 돕는 역할을 하며 적대자 NPC는 플레이어가 수행해야 하는 미션의 객체 내지는 플레이어가 최종적으로 제거해야 하는 보스의 역할을 가진다.
두 NPC는 같은 구조의 함수를 거쳐 생성되고, 각각 PNPC와 ANPC라는 변수에 저장된다.
getProtaNPCName 함수를 통해 GPT에게 게임 배경과 장르에 어울리는 조력자 NPC의 이름을 받아온다. npc_setting 변수에는 출력 형태와 관련된 내용을, query에는 출력 내용과 관련된 내용을 적어주었다. 이 함수는 callGPT를 호출하여 받은 답변, 즉 조력자 NPC의 이름을 response에 저장하고, 그 값을 반환하는 역할을 한다.
def getProtaNPCInfo(town, PNPC_name):
npc_setting = "조력자 NPC는 " + town + \
"에 어울리는 직업을 가져야 하고, 성격에 맞게 존댓말 혹은 반말 중 하나의 말투만을 일관되게 사용해야 해."
query = town + "의 조력자 NPC인" + PNPC_name + "의 직업, 그리고 성격과 말투를 설명해줘."
messages = [
{"role": "system", "content": npc_setting},
{"role": "user", "content": query}
]
response = callGPT(messages=messages, stream=False)
PNPC_info = response['choices'][0]['message']['content']
return PNPC_info
다음은 조력자의 설정을 생성하는 getProtaNPCInfo이다. 이 함수를 통해 조력자 NPC의 직업과 말투, 성격을 받아올 수 있다. 위의 이름 함수와 마찬가지로 callGPT를 호출하여 받은 NPC 설정을 PNPC_info 변수에 저장하여 반환하는 역할을 한다.
이렇게 만들어진 NPC 생성 함수는 실행 파일인 prompt.py에서 import하여 게임 시작 전, 게임 생성 단계에서 아래와 같이 사용한다.
import gpt_function as gpt
import intro_function as intro
import npc_function as npc
import objective_function as obj
import const as c
# 1. 게임 생성
# 게임 초기 선택지들 입력받기 => 나중에 선택한 키워드가 들어오도록 바꾸기
background = input("배경 선택: ")
genre = input("장르: ")
player_name = input("플레이어 이름: ")
# 마을 이름 생성
town = intro.getTownName(background, genre, player_name)
# 마을 배경 설명
# 조력자 NPC 생성
PNPC_name = npc.getProtaNPCName(background, genre)
PNPC_info = npc.getProtaNPCInfo(town, PNPC_name)
#적대자 NPC 생성
ANPC_name = npc.getAntaNPCName(background, genre)
ANPC_info = npc.getAntaNPCInfo(town, ANPC_name)
town_detail = intro.getTownBackground(town, background, genre)
게임이 시작되고 나면, GPT가 위의 코드를 통해 호출한 결과를 DB에 적재한다.
# 2. 게임 시작
# 시스템 설정 - 데이터 적재용
system_intro = c.SYSTEM_INTRO
query = "마을 이름은 " + town + "이고, 플레이어 이름은 " + player_name + "이야. 조력자 NPC 이름은 " + \
PNPC_name + "이고," + PNPC_name + "은 " + PNPC_info + "처럼 행동해야 해. 적대자 NPC 이름은 " + \
ANPC_name + "이고," + ANPC_name + "은 " + ANPC_info + "처럼 행동해야 해"
query += background + " 배경의 " + genre + " 분위기의 TRPG 스크립트 생성"
messages = [
{"role": "system", "content": system_intro},
{"role": "user", "content": query}
]
final_objective = 0
curr_objective = 1