0706 자작 노벨 스크립터
260706
어제 언급했던,
몇 년 전부터 만들던
노벨 스크립터를 소개해 보겠다.
가장 처음 만들었던 스크립터는
고도 엔진을 썼었다.
지금으로부터 약 4년 정도 전이다.
gdscript로 작성했었고,
대사 출력 / 선택지 띄우기와
스탠딩, 배경 전환 (트랜지션) 및
세이브 / 로드 시스템이 구현되어 있었다.
기억나는 건
if / elif 블록이 4번정도 겹쳐있는,
읽기 매우 힘든 코드다.
그때도 세이브 로드 시스템을 만들기 위해서
글로벌 인덱스와 선택지들을 저장하던 건 생각난다.
아마 대사 출력(percent_visible 바꾸는거)은 쉽게 구현했는데,
저장 하고 불러오는 게 내 실력으로는 구현하기가 힘들었다.
이때는 텍스트 파일을 읽고 실행하긴 하는데
파싱이라고 부르기 애매한 정도였다.
(각 줄에 대해 split 떡칠하고 대충 캐스팅)
그 후, 본격적으로 게임 엔진 개발을 배워나갔다.
고도 엔진의 구조를 많이 빌려와서
씬과 오브젝트(노드)로 이루어진
UI 시스템도 이때 구현하기 시작했다.
특히 스크립트 인터프리터 만드는 법도 배워서,
간단한 재귀 하향 파서로
나만의 문법을 만들 수 있게 되었다.
이때는 코드 퀄리티보다
동작하면 그만이지 하는 마인드로 쓰다 보니까,
freetype 규격에 맞지도 않는 텍스트 렌더링을
배칭도 없이, 한 글자당 콜 1개로 하긴 했었다.
이게 3년정도 전이다.
아무튼 본론으로 돌아와서.
자작 스크립터의 가장 본질적인 것만 얘기해 보겠다.
대사 출력이나 선택지 띄우는 거 말고,
가장 중요한 세이브 & 로드 시스템이다.
그걸 설명하려면 내 스크립트가 어떻게 컴파일되는지 알아야 한다.
다음은 스크립트랑, 컴파일된 결과물이다.
label main {
s "안녕하세요."
s "대사를 출력하고 있습니다."
setvar "a" "10"
if "a == 10" {
s "a의 값은 10입니다."
}
else {
s "a의 값은 10이 아닙니다."
}
s "선택지를 띄워보겠습니다."
choice {
case "선택지 A" {
s "선택지 A를 골랐습니다."
}
case "선택지 B" {
s "선택지 B를 골랐습니다."
}
}
s "게임을 종료합니다."
}
컴파일된 결과물:
Label "main":
cmd_say( id="s", content="안녕하세요.")
cmd_say( id="s", content="대사를 출력하고 있습니다.")
cmd_setvar( name="a", value="10")
cmd_if(
expr_n_offsets = [
(expr = "a == 10", offset = 1)
],
else_exists = true,
to_else_offset = 3,
to_last_index_offset = 4,
)
cmd_say( id="s", content="a의 값은 10입니다.")
cmd_addptr(3)
cmd_say( id="s", content="a의 값은 10이 아닙니다.")
cmd_addptr(1)
cmd_say( id="s", content="선택지를 띄워보겠습니다.")
cmd_choice(
choices_n_offsets = [
(choice = "선택지 A", offset = 1),
(choice = "선택지 B", offset = 3)
]
)
cmd_say( id="s", content="선택지 A를 골랐습니다.")
cmd_addptr(3)
cmd_say( id="s", content="선택지 B를 골랐습니다.")
cmd_addptr(1)
cmd_say( id="s", content = "게임을 종료합니다.")
cmd_end()
내가 강조하고 싶은 건,
조건문과 choice 문이 어떻게
'분해' 되는지 이다.
그 두 개의 statement는,
"커맨드 블록 + addptr 1개"
을 연달아 늘어놓은 것과,
"특정 블록으로 점프하려면 몇 오프셋이 필요한지" 를 가지고 있는
그 statement 자체 커맨드 1개로 이루진다.
예를 들어 보면,
저기 위의 if 문을 인터프리팅한다고 하면 -
expr_n_offset 를 하나씩 순회하면서 expr가 참이 된다면
해당 오프셋으로 점프(말이 점프지, 그냥 index += offset) 하게 되고 -
블록을 전부 실행한 다음에
add_ptr() 커맨드를 만나서?
자동으로 '나머지 블록을 스킵' 하고
실질적으로 스크립트 내에서
if 문 다음에 실행될 커맨드가 실행되게 한다.
물론 expr 가 참이 되지 않는다면?
- else가 있으면 -> to_else_offset 만큼 점프.
- else가 없으면 -> to_last_index_offset 만큼 점프.
이런 식으로 작동하게 된다.
choice문도 마찬가지로.
아무튼 위처럼 구현을 하게 되면,
if문 블록이나 choice문 블록이
아무리 몇 단이 겹쳐 있어도??
"하나의 레이블 덩어리" 로 만들 수 있다.
(내가 처음에 '1개 레이블 덩어리' 에 집착한 이유는,
나중에 바이트코드로 컴파일해서 압축 & 암호화하기 좋기 때문이다.)
세이브 & 로드 시스템의 핵심은
세이브 파일 안에
target_index : size_ttarget_choices : list[size_t]
이 두 개의 정보가 들어가 있다는 것에서 시작한다.
'게임을 저장한다' 라는 신호가 들어왔을 떄,
스크립터는 현재 게임 진행하면서
-
"대사 출력 커맨드 시작 시"
-
"대사 이어서 출력 커맨드 시작 시"
-
"선택지 띄우고, 유저가 누른 후"
이 세 가지 조건이 하나라도 만족할 경우
1씩 누적해왔던
global_idx, global_choices_list 를 파일에 저장한다.
'게임을 로드한다' 라는 신호가 들어오면,
target_index 와 target_choices를 로드하고 -
스크립터는
- 현재 global_idx 와 global_choices_list 를 초기화.
- 현재 scope 초기화.
- 처음부터 다시, push_scope (MAIN_LABEL) 해준다.
- 이후 while 문으로, global_idx < target_index 일 때까지 계속 "간략한 진행" 을 해준다.
- 만약 진행하다 choice문을 만나면, target_choices 큐에서 front를 뽑아서 그걸 끼워넣는다.
- 간략한 진행이란, cmd_say / cmd_choice 같은 커맨드를 실행하고 "유저의 입력을 기다리지 않는" 걸 말한다.
이러면
플레이어가 어떤 복잡한 선택을 했느냐에 상관없이 -
저장한 그 순간을 '인덱스 1개' 와 '선택지 인덱스 리스트'
두 개의 정보로 로딩할 수 있는 것이다.