0. 들어가기에 앞서
어제자의 작업에서 해결해야 할 부분이 무엇이 남았는지 확인해보자.
- 죽음 엔딩(데드 엔딩) 출력 오류 수정
- 필요가 없어진 3번 맵을 아예 버릴지? 아니면 뭔가 더 구현할 수 있을지
- NPC가 아이템을 주는 이벤트 구현하기
- 이벤트가 진행된 후 이벤트에서 사용된 아이템 인벤토리에서 삭제하기
- 이벤트가 진행된 후 획득하지 않은 특정 아이템 제거하기
위와 같은 과제 해결을 우선으로 하고, 가능하면 현실적으로 기한 내에 만들 수 있는 기능을 추가하도록 하자. (괜히 마지막 날에도 새로운 기능을 넣으려고 했다가 게임 터진 채로 마무리하면 낭패다.)
해당 과제 중 얼마나 해결했는지, 완성도를 얼마나 높였는지에 대해 서술하고자 한다.
위에서 적어놨던 1 과 2의 경우에는 결국 결정의 문제였기 때문에, 쉽게 해결할 수 있었다.
데드 엔딩의 경우 기존에 GameOver 함수로 출력하는 대신에 ChangeScene으로 DeadScene으로 만들어 엔딩 출력 방식을 통일하는 걸로 마무리했다. 또한 필요 없어진 3번 맵을 아예 버리고 2번 맵에서의 출구로 엔딩을 가도록 했다.
NPC C에서만 분기점을 두고 엔딩은 동일하게 출력되는 부분에 변화가 필요하다고 생각했다. 이를 위해서 할 수 있는 가장 간단한 방법이 Game에 엔딩 분기점을 결정할 static 변수를 넣는 것이라고 생각했다.
// 엔딩 루트를 결정하는 변수
private static int endingNum = 0;
public static int EndingNum { get { return endingNum; } }
public static void EndingBranch(int num)
{
endingNum = num;
}
이와 같이 엔딩 루트를 저장하도록 설정하여 C군과 대화하는 과정에서 엔딩 분기가 달라지게 된다.
엔딩은 기존에 3가지 엔딩을 만들었으니, 최종 엔딩 씬에서 switch문으로 처리하여 다른 결과 텍스트가 출력되도록 하였다.
public override void Render()
{
Console.Clear();
int endingNumber = Game.EndingNum;
switch (endingNumber)
{
case 1: Ending1(); break; // 엔딩 1에 해당하는 텍스트를 출력
case 2: Ending2(); break; // 엔딩 2에 해당하는 텍스트를 출력
default: Ending3(); break; // 엔딩 3 혹은 해당 NPC를 무시하고 지나갔을 경우 출력
}
...
마지막으로 해당 조건이 성립하는 부분을 C군과의 텍스트 중에 삽입한다.
private void Ending1()
{
(엔딩 1 내용)
Game.EndingBranch(1);
eventHappened = true;
talkLog.Dequeue();
}
private void Ending2_3()
{
(엔딩 2 내용)
Game.EndingBranch(2);
talkLog.Dequeue();
isTalking = false;
}
private void Ending3()
{
(엔딩 3 내용)
Game.EndingBranch(3);
talkLog.Dequeue();
talkLog.Dequeue();
talkLog.Dequeue();
talkLog.Dequeue();
talkLog.Dequeue();
isTalking = false;
}
이와 같이 하여 실질적인 게임의 엔딩 루트를 구현하는 데 성공했다.
지금까지 NPC와의 대화를 X키로만 가능하게 해서 계속 작업하고 있었지만, 대화를 X키로 종료하고 다음 대화로 넘어가는 문제가 계속 발생하고 있었다. 이걸 방지하기 위해서 일부러 대화 종료 키를 Z로 만드는 등의 방법을 쓰기도 했었지만, 실질적으로 문제가 해결된 것은 아니었다.
(분명 어딘가에서 문제를 일으키고 있던 것인데, 문제를 쉽사리 찾을 수 없었다.)
처음엔 그저 콘솔 프로젝트 상의 구조의 한계, 라고 생각했었는데, 오늘 작업에서 코드를 전체적으로 살펴보니 코드 순서를 이상하게 해 놓은 부분을 발견했다.
우선 기존에 출력했던 방식이다
public override void Render()
{
foreach (NPC npc in npcList)
{
if (npc.isTalking == true && input == ConsoleKey.X)
{
npc.Interact(Game.Player);
}
}
}
public override void Update()
{
Game.Player.Action(input);
// NPC와 상호작용 가능한 영역에 진입했는지 확인하고,
// 대화 가능 여부를 결정 - 해당 위치에서 X키를 누르면 대화 시작
foreach (NPC npc in npcList)
{
npc.isTalking = npc.IsInteractable() ? true : false;
}
}
기존에는 위와 같이 출력 과정을 Render로 넣고, Update문에서 대화 가능여부를 판정하는 내용을 넣었다. 논리적으로는 문제가 없는 것 같았는데, 가만 생각해 보니, 대화의 출력문이 Render에 나오면 안 되었다.
대화의 출력문은, 내가 X키를 누른 순간에 나오는 '결과'에 해당하기 때문에, Render가 아니라 Result에 위치시켜야 한다는 것이다. (왜냐하면, 저 출력문 자체가 또 다시 입력을 받는 구조로 되어 있기 때문에, Render에 위치시켜버리면, Input까지 두 번 입력을 받는 셈이 되기 때문이다.)
이런 사실을 깨닫고 코드를 다시 정리한 다음 테스트를 해 보니, 드디어 고질적인 문제가 해결된 것을 확인할 수 있었다.
이게 바로 오류가 생겼을 때 임시방편으로 막으면 안 되는 이유라는 걸 몸소 느껴버렸다.
정말로 해결하지 못하는 문제가 발견했을 때, 이런 식으로 차분하게 문제를 살펴보는 태도를 가져야 겠다.
처음에는 이것의 구현이 생각보다 어려울 것이라고 생각해서 뒤로 미룬 과제였지만, 놀랍게도 이걸 해결해보자 했을 때 그냥 몇 분만에 뚝딱 완성해 버렸다. 이미 인벤토리에 구현되어 있는 함수를 이용하면 된다.
// 버리는 기능의 경우
// 이미 for문으로 아이템을 검사하면서 돌리고 있었으므로,
// 그냥 해당 인벤토리 칸을 비워버리면 된다
for (int i = 0; i < Game.Player.inventory.items.Length; i++)
{
// 인벤토리에 칼이 있을 경우 1번 엔딩
if (Game.Player.inventory.items[i] != null && Game.Player.inventory.items[i].name == "칼")
{
Util.XKeyText("당신을 칼을 들어 남자를 뒤에서 공격했습니다.");
Util.XKeyText("N양이 당신을 채 막기도 전에 일어난 일입니다.");
Console.WriteLine();
Util.NPC_NText("꺄아아아악!");
Console.WriteLine();
Util.XKeyText("N양은 당신이 저지른 짓을 보고 충격을 받아 주저앉습니다.");
Util.XKeyText("다른 방법을 택할 수는 없었을까요?");
Util.XKeyText("당신은 피 묻은 칼을 바닥에 버렸고,");
Util.XKeyText("무심하게 N양을 바라봅니다.");
// 칼 아이템을 버렸으니 인벤토리에서 제거
Game.Player.inventory.items[i] = null;
Console.WriteLine();
Util.XKeyText("대화를 종료하려면 X키를 누르세요.");
Game.EndingBranch(1);
eventHappened = true;
talkLog.Dequeue();
isTalking = false;
break;
}
}
// 아이템 획득의 경우는 살짝 더 해야 할 작업이 있다.
// N양이 주는 아이템은 붕대
// 플레이어의 인벤토리에 공간이 있을 경우 아이템을 지급
// 인벤토리 자리가 없을 경우 다음 대화로 넘어가지 않고
// 아이템을 받을 때까지 현재 대화를 반복
Bandage bandage = new Bandage(Game.Player.position);
for (int i = 0; i < Game.Player.inventory.items.Length; i++)
{
if (Game.Player.inventory.items[i] == null)
{
Game.Player.inventory.Add(bandage);
Util.XKeyText("당신은 그녀에게 붕대를 받고서 나아갈 준비를 합니다.");
// 아이템을 수령하면 다음 대화로 넘어감
talkLog.Dequeue();
break;
}
if (Game.Player.inventory.items[Game.Player.inventory.items.Length - 1] != null)
{
Util.NPC_NText("들고 있는 게 너무 많은 것 같은데,");
Util.NPC_NText("주머니에 든 걸 비워주고 다시 말을 걸어줘.");
break;
}
}
이와 같이 임시로 붕대 클래스를 생성해주고, 아이템 위치는 플레이어의 위치로 설정했다. 또한 기능을 구현하는 와중에 인벤토리가 가득 찼을 경우, 해당 대화에서 바로 다음 대화로 넘어가지 않고 아이템을 주기에 들고 있는 것이 많다는 출력도 하게끔 했다.
초장에 적어둔 다섯 개의 문제 중 결과적으로 해결하지 못한 문제가 하나 있었다.
5 번째의 획득하지 않은 필드의 아이템을 사라지게 하는 것. 이건 생각보다 많이 어려운 문제였다.
인벤토리에 있는 아이템은 애초에 배열을 호출해서 제거하면 되지만, 필드에 있는 아이템은 필드맵에서 리스트로 호출되어서 출력되는 원리라서, 직접적으로 제거할 방법이 없었다.
열심히 머리를 쥐어짜보면서 맵 출력 부분에 다운 캐스팅을 해 보는 시도를 해 봤다.
public override void Render()
{
PrintMap();
foreach (GameObject go in gameObjects)
{
go.Print();
if(go is knife)
{
knife knife = (knife)go;
if(Game.KnifeCollected)
{
gameObjects.Remove(knife);
}
}
}
foreach (NPC npc in npcList)
{
npc.Print();
}
Game.Player.Print();
Game.Player.inventory.BrieflyPrint();
Game.Player.inventory.PrintAchievedItem();
StartText();
}
그런데 이렇게 해서 출력해 봤더니, 오류가 터졌다.
Unhandled exception. System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
해당 오류가 뭔지 검색해보자.
foreach 문 내부에서 add나 remove와 같이 Collection을 수정할 경우 발생을 한다.
저 코드에서 내가 칼을 remove로 제거하려 했기 때문에 발생한 오류라는 것을 알게 되었다.
그러면 해결할 수 있는 방법은 foreach에서 for문으로 바꾸거나, 해당 리스트를 애초에 Static으로 선언해서 바꿔야 하는 등의 코드 수정이 필요한 상황이었다.
기한이 있는 프로젝트이다 보니 이 코드를 수정할 여력이 없었다. 이 부분은 그래서, 제출 상황에서는 반영하지 않고 나중에 따로 고쳐 보기로 했다.
콘솔 프로젝트를 하면서 무언가를 구현한다는 게 생각보다 쉽지 않은 일이라는 걸 느꼈다. 하지만 기능을 하나씩 완성해 나가면서 게임의 완성도가 올라가고, 결과적으로 하나의 게임을 완성했다. 비록 완성도가 아주 높은 게임도 아니고, 3일만에 게임 하나를 완성시키기에는 시간이 많이 촉박했기에 겨우 게임의 구실 정도 하는 수준으로 구사했다.
하지만 이렇게 게임의 기능을 생각해 보고, 구현할 수 있는 기능을 완성해 나가는 과정이 상당히 많은 경험치가 되었다는 느낌을 받았다. 지금까지 배웠던 개념을 어떻게 적용하는 지도, 만들 게임 마다 아주 다르게 사용할 것이고 기능의 구현 방법 또한 다양하게 나올 수 있다는 것을 체감할 수 있었다.
프로젝트 내내 힘들었지만 정말 유익한 경험이었다고 생각한다. 앞으로도 이런 문제 해결에 대한 고민을 멈추지 않고 더 좋은 게임을 개발해 보려고 한다.