XR 플밍 - 6. 콘솔 프로젝트 [Project Exit] - 1일차 (4/8)

이형원·2025년 4월 8일
0

XR플밍

목록 보기
37/215

콘솔 프로젝트 1일차의 내용을 담아보고자 한다.

1. 초안의 구체화

0일차에서 정리한 내용에서 좀 더 구체적으로 내용을 정리해보고자 한다.

1.1 스토리라인 틀

이야기를 만드는 것도 좋아하고 나름 상상력도 풍부하다고 생각했지만, 막상 맨땅에 갑자기 이야기를 만들자니 많이 난감하기도 했다. 우선은 작은 규모의 게임에 맞는 짧고도 인상적인 게임을 만드는 것을 목표로 한다.

  • 간략한 스토리 라인

    인물은 플레이어인 P군과 NPC인 N양이 같이 탈출하는 이야기로 한다.
    (탈출하는 곳, 탈출 방법 등의 구체적인 방식은 미구현이며,)
    (NPC는 지금 단계에선 한 명만 있는 것으로 함)

1.2 우선적으로 개발해야 하는 내용

개발 내용에 대해서 우선 순위를 정해야겠다는 생각을 했다.
따라서 아래와 같은 개발 순서를 정하기로 했다.

  1. 우선은 맵과 플레이어 구현을 하고, 엔딩 씬에 도달이 되는 부분부터 구현하기로 함.
    아무리 개발을 열심히 해도 엔딩이 완성되지 않은 게임은 미완성 느낌이 날 테니까
  2. 엔딩 도달 지점까지 만든 후에 추가적인 디테일을 만든다
    2.1 아이템 기능 구현
    2.2 NPC 기능 구현
    2.3 맵 - 미로 만들기 기능을 넣어보고 싶긴 한데 가능하면 넣기
    2.4 추가적인 디테일 - 아이템이 반짝이는 것처럼 깜빡이는 효과라던가 구현해보기

이와 같은 내용을 바탕으로 개발을 시작하고자 한다.

2. 플레이어의 구현

0일차에서 맵 출력까지는 해냈으니, 이제는 플레이어를 구현하여 움직이게 할 수 있는지 개발해 보는 차례이다.

플레이어가 가져야 할 기능은 아래와 같다.

  1. 플레이어는 출력되어야 한다. (P 모양의 초록색 글자로 출력되게 설정)
  2. 플레이어는 이동이 가능해야 한다.
  3. 플레이어는 체력과 정신력 스텟을 가져야 한다.
  4. 플레이어는 맵 내 물체와 상호작용이 가능해야 한다.

4번의 기능만 나중에 구현하기로 하고, 우선 플레이어의 기능을 구현했다.

public class Player
{
	// 플레이어의 체력 스탯
    private int playerHP = 5;
    public int PlayerHP { get { return playerHP; } }

	// 플레이어의 정신력 스탯
    private int playerMental = 5;
    public int PlayerMental { get { return playerMental; } }

	// 플레이어 위치
    public Vector2 position;
    // 플레이어가 참고할 맵 구조
    public bool[,] map;

	// 플레이어 출력
    public void Print()
    {
        Console.SetCursorPosition(position.x, position.y);
        Console.ForegroundColor = ConsoleColor.Green;
        Console.Write('P');
        Console.ResetColor();
    }

	// 플레이어 이동
    public void Move(ConsoleKey input)
    {
        Vector2 targetPos = position;
        
        switch (input)
        {
            case ConsoleKey.UpArrow:
                targetPos.y--;
                break;
            case ConsoleKey.DownArrow:
                targetPos.y++;
                break;
            case ConsoleKey.LeftArrow:
                targetPos.x--;
                break;
            case ConsoleKey.RightArrow:
                targetPos.x++;
                break;
        }
        if (map[targetPos.y, targetPos.x] == true)	// 타겟위치가 이동 가능한 위치이면 이동
        {
            position = targetPos;
        }

    }
}

이와 같이 작성하고서 출력을 확인하였다.

플레이어의 이동이 정상 작동하는 것을 확인했고, 처음 출력된 텍스트는 한 번만 출력되고 이후 자유롭게 이동 가능한 것을 확인했다.

3. 맵 이동 구현

다음으로 맵 이동을 구현해 보려고 한다. 맵 이동 구현을 위해 아래와 같은 작업을 진행하기로 했다.

  1. 우선, 앞으로 맵에 추가적으로 출력해야 할 것이 다음과 같은 것이 있다.

    이동을 위한 입구, 아이템, NPC, 이벤트 발생 좌표(이것을 보이게 할 지 말지는 고민중이다)

    이것들을 구현하기 위한 인터페이스로 IInteractable를 만든다.

  2. IInteractable을 상속할 GameObject를 만들고, 이 GameObject의 하위 자식 클래스로 Place를 만든다.

  3. Place와 상호작용했을 시에 다음 맵으로 이동 가능한 기능을 구현한다.

우선은 인터페이스에서는 플레이어와의 상호작용을 구현하기 위해서 아래와 같이 간단한 내용을 작성했다.

public interface IInteractable
{
    public void Interact(Player player);
}

이 다음으로 GameObject를 만들어야 하는데, GameObject가 가져야 하는 기능은 다음과 같다.

  1. GameObject는 맵에 출력되어야 한다. 이때, 맵에 출력되는 문자 모양, 색깔, 위치 등의 정보를 변경하여 입력할 수 있도록 해야 한다.
  2. GameObject는 IInteract를 상속받았으므로, 플레이어와의 상호작용을 구현해야 한다.

이때, GameObject는 그 오브젝트가 무엇이냐에 따라서 상호작용의 내용이 달라질 것이므로, 이를 추상 클래스 형태로 구현한다. 따라서 아래와 같이 GameObject를 작성하였다.

public abstract class GameObject : IInteractable
{
    public ConsoleColor color;	// 색깔
    public char symbol;			// 표시 글자
    public Vector2 position;	// 위치

	// 생성자
    public GameObject(ConsoleColor color, char symbol, Vector2 position)
    {
        this.color = color;
        this.symbol = symbol;
        this.position = position;
    }

	// 출력
    public void Print()
    {
        Console.SetCursorPosition(position.x, position.y);
        Console.ForegroundColor = color;
        Console.Write(symbol);
        Console.ResetColor();
    }

	// 상호작용 추상함수
    public abstract void Interact(Player player);
}

이 다음으로 이 GameObject를 상속할 하위 자식 클래스로 Place를 만들어야 한다.
Place가 가져야 할 기능은 아래와 같다.

  1. Place는 출력되어야 한다. (이는 부모 클래스의 GameObject로 구현되어 있으니, 추가로 구현할 필요성은 없음)
  2. Place는 기본 색상을 파란색으로 설정한다. 또한 상호작용 기능으로서 맵 이동 기능을 구현해야 하므로, Scene에 대한 입력을 추가로 받는다.

따라서 아래와 같이 작성하였다.

public class Place : GameObject
{
    private string scene;	// 씬에 대한 추가 입력
    
    // 생성자
    public Place(string scene, char symbol, Vector2 position) 
        : base(ConsoleColor.Blue, symbol, position)
    {
        this.scene = scene;
    }
    
    // 상호작용 - 맵 이동
    public override void Interact(Player player)
    {
        Game.ChangeScene(scene);
    }
}

이와 같이 구현하고 SecretRoomScene1에 해당 포탈을 구현하였다.

public class SecretRoomScene1 : BaseScene
{
    ...


	// 게임 오브젝트를 리스트로 선언
    private List<GameObject> gameObjects;

	public SecretRoomScene1()
	{
	...
    
        gameObjects = new List<GameObject>();
        gameObjects.Add(new Place("SecretR2", '▥', new Vector2(38, 6)));
    }
    public override void Render()
    {
        PrintMap();
        
        // 게임 오브젝트 출력
        foreach (GameObject go in gameObjects)
        {
            go.Print();
        }
        Game.Player.Print();         
        
        StartText();
        
    }
    
...

	// 캐릭터와 위치가 겹칠 시 상호작용하도록 구현
    public override void Result()
    {
        foreach(GameObject go in gameObjects)
        {
            if(Game.Player.position == go.position && input != ConsoleKey.X)
            {
                go.Interact(Game.Player);
            }
        }
    }
    ...
}

3.1 맵 이동은 했으나 문제 발생 상황


맵 이동은 정상적으로 확인이 되지만 위 상황에서와 같이 위 키를 눌렀을 때 이동이 되지 않는 현상을 확인했다. 왜 문제가 발생하는지 가만 보니, 두 번째 맵에 구현되어 있는 벽이 첫 번째 맵에서 출력되는 현상을 확인했다.

  • 두 번째 맵 테스트용
mapData = new string[]
{
    "########################################",
    "#                                      #",
    "#                                      #",
    "#        ####                          #",
    "#                                      #",
    "#                            ####      #",
    "#                                      #",
    "#                   ####               #",
    "#                                      #",
    "#                                      #",
    "#                                      #",
    "########################################"
};

이와 같은 문제가 발생하는 이유는 아무래도 Start 단계에서 Dictionary로 맵을 Add하는 상황에서 맵이 겹치면서 발생하는 문제로 보인다. 이를 방지하기 위해선 맵을 이동할 때마다 해당 맵의 내용을 반영하는 부분이 필요하다고 판단했다.

이를 위해서 우선 부모클래스인 BaseScene에 새로운 함수를 만들도록 한다.

public abstract class BaseScene
{
...

    public virtual void Enter() { }
}

이때 추상함수가 아니라 가상함수로 구현하기로 했다. 왜냐하면 프롤로그 씬과 같이 Enter와 Exit이 따로 필요가 없는 함수가 있기도 하고, 필요한 씬 부분에서만 구현하기 위해서이다.
이와 같이 설정하고, 게임의 ChangeScene에서 해당 내용을 반영한다.

public static void ChangeScene(string sceneName)
{
    prevScene = curScene.name; // 이전 씬의 무엇이냐에 따라서 위치가 달라질 수 있으므로 변수 추가

    curScene = sceneDic[sceneName];
    curScene.Enter();
}

이와 같이 설정하고, 각각의 맵에서 Enter를 정의해주고, 플레이어의 좌표와 맵을 초기화 해주는 작업을 넣으면 된다.

// SecretRoomScene1에서
...

public override void Enter()
{
    if (Game.prevScene == "Prologue")
    {
        Game.Player.position = new Vector2(1, 2);
    }
    else if (Game.prevScene == "SecretR2")
    {                
        Game.Player.position = new Vector2(38, 6);
    }
    Game.Player.map = map;
}
...

// SecretRoomScene2에서
...

public override void Enter()
{
    if (Game.prevScene == "SecretR1")
    {
        Game.Player.position = new Vector2(38, 6);                
    }
    else if (Game.prevScene == "SecretR3")
    {
        Game.Player.position = new Vector2(5, 2);
    }
    Game.Player.map = map;
}

이와 같은 방식으로 맵 이동이 정상작동하는 것을 확인하였다.

4. 인벤토리와 아이템

4.1 인벤토리와 아이템의 구현

GameObject 클래스를 만들었고, 이 클래스를 이용하여 Place 자식클래스를 구현하였다. 하지만 이것 외에도 구현할 것이 하나 더 있다. 우선은 GameObject를 상속하는 아이템 클래스와 인벤토리 클래스를 만들어보자.

// 인벤토리 구현
public class Inventory
{
	// 아이템 배열 선언
    public Item[] items;

	// 인벤토리의 칸 수는 아이템을 넣을 수 있는 배열 4칸으로 선언(인벤토리 4칸)
    public Inventory()
    {
        items = new Item[4];            
    }

	// 아이템은 인벤토리 내 빈 공간에 들어가도록 설정
    public void Add(Item item)
    {
        for(int i = 0; i < items.Length; i++)
        {
            if(items[i] == null)
            {
                items[i] = item;
                lastAcievedIndex = i;
                itemAchieved = true;
                break;
            }
        }            
    }

	// 아이템 사용
    public void Use(int index)
    {
        items[index].Use();
    }        
    
    // 아이템 버리기(소멸X)
    public void Drop(int index)
    {
        items[index] = null;            
    }          
}

// 아이템 구현
public abstract class Item : GameObject
{
	// 아이템 이름
    public string name;
    // 아이템 설명
    public string description;

	// 필드에서의 아이템의 기본 색깔은 노란색, 'I' 문자로 표기한다.
    public Item(Vector2 position)
        : base(ConsoleColor.Yellow, 'I', position)
    {        
    }
    
    // 아이템과의 상호작용으로, 아이템을 플레이어의 인벤토리에 넣는다.
    public override void Interact(Player player)
    {
        player.inventory.Add(this);
    }

    public abstract void Use();  
}

인벤토리는 배열로 4칸으로 만들어, 최대 4개의 아이템을 들고 다닐 수 있도록 구현하였다.
앞으로 구현해야 할 아이템이 더 있겠지만, 우선은 간단하게 체력 회복 기능한 아이템인 붕대를 만들어 보았다.

public class Bandage : Item
{
    public Bandage(Vector2 position)
        : base(position)
    {
        name = "붕대";
        description = "체력을 2 회복합니다";
    }

    public override void Use()
    {
        Game.Player.HPHeal(2);
    }
}

// 체력 회복을 구현하기 위해 플레이어 클래스에 추가로 함수 구현

...

public void HPHeal(int amount)
{
    curPlayerHP += amount;
    if(curPlayerHP > MaxPlayerHP)
    {
        curPlayerHP = MaxPlayerHP;
    }
}
...

이제 사용할 수 있는 아이템도 만들었다. 다만 아이템 사용 기능 구현은 조금 이따 하도록 하고, 인벤토리에 아이템이 제대로 들어가는지 먼저 확인해보도록 하자.
인벤토리의 내용물 유무를 간단하게 표시할 수 있는 함수를 구현하여 다음과 같이 게임 속에서 구현되도록 하였다.

public void BrieflyPrint()
{
    Console.SetCursorPosition(0, 12);
    Console.Write("인벤토리 : ");
    for (int i = 0; i < items.Length; i++)
    {                
        if (items[i] != null)
        {
            Console.Write(" ■ ");
        }
        else
        {
            Console.Write(" □ ");
        }
    }
} 

인벤토리가 차 있으면 색깔이 칠해진 네모, 안 차 있으면 빈 네모가 출력되도록 하였다.

위와 같이 아이템을 두 개 먹었을 때 두 개만 찬 것으로 출력되는 것을 확인할 수 있다.
다만 아이템의 경우 먹었으면 사라지도록 출력해야 하기 때문에, 아이템을 먹었으면 사라지도록 bool 변수를 만들기로 했다.
GameObject에 bool 변수 - isOnce 를 만들어, 장소의 경우에는 일회성이 아니고 아이템의 경우에는 일회성으로 출력되도록 만들었다.

public abstract class GameObject : IInteractable
{
    public ConsoleColor color;
    public char symbol;
    public Vector2 position;
    public bool isOnce;		// 일회성인지 여부 판단

    public GameObject(ConsoleColor color, char symbol, Vector2 position, bool isOnce)
    {
        this.color = color;
        this.symbol = symbol;
        this.position = position;
        this.isOnce = isOnce;
    }
...


// 해당 변수를 사용하여 Result 출력에서 GameObject 리스트에서 일회성 아이템은 상호작용 후 사라지도록 구현

public override void Result()
{
    foreach (GameObject go in gameObjects)
    {
        if (Game.Player.position == go.position && input != ConsoleKey.X)
        {
            go.Interact(Game.Player);
            if (go.isOnce == true)
            {
                gameObjects.Remove(go);
            }
            break;
        }
    }
...

4.2 아이템을 먹었다고 출력하기

아이템을 먹었을 때 아무런 멘트도 출력되지 않으니, 무엇을 먹었는지 확인할 길이 없었다. 이걸 위해서 아이템을 먹었다는 출력을 만들 필요성을 느꼈다.
다만 이와 같은 경우 콘솔 프로젝트 특성상 애를 많이 먹었다.

public override void Result()
{
    foreach (GameObject go in gameObjects)
    {
        if (Game.Player.position == go.position && input != ConsoleKey.X)
        {
            go.Interact(Game.Player);
            // 아이템을 먹었을 때 해당 아이템 위치 소멸
            if (go.isOnce == true)
			{
    			gameObjects.Remove(go);
			}
            // 아이템을 먹은 후 출력하면 되겠지?
        }
    }
    
...

처음엔 이와 같이 생각했지만, 계속 출력되자마자 지워지는 현상이 일어나길래 확인해보니, Result 단계에서 바로 Render단계로 넘어가면서 지워지기 때문인 것으로 확인했다.
그러면 Render 단계에서 텍스트가 출력되도록 해야 할 것이다. 이를 위해선 추가로 아이템을 먹은 여부를 판단할 bool 변수를 만들어야겠다고 생각했다. 인벤토리에 private bool itemAchieved 함수를 만든 후, 다음과 같이 변수가 참 거짓을 구현하도록 했다.

public void Add(Item item)
{
    for(int i = 0; i < items.Length; i++)
    {
        if(items[i] == null)
        {
            items[i] = item;
            itemAchieved = true;	// 아이템을 획득했으면 참으로 바뀐다.
            break;
        }        
    }            
}
...
public void PrintAchievedItem()
{
    if (itemAchieved)
    {
        Console.SetCursorPosition(0, 13);
        Console.Write($"{items}를 획득했습니다."); // 아이템을 획득했다고 출력한 뒤에 거짓으로 바뀐다.
        itemAchieved = false;
    }            
}

이와 같이 세팅하고 아이템의 획득 여부 출력을 확인했다.

아이템을 획득한 게 출력되기는 한데, 이름이 이상하게 출력된다. 그래서 획득한 아이템인 붕대의 아이템 명을 출력하려 했는데, 아무리 여러 방식으로 참조해보려고 해도 안 된다.

items.name으로 하려는 의도였는데, 아이템 클래스의 내용이나 붕대 내용이나 아무리 수정해봐도 안 되서 결국 강사님한테 조언을 구해봤다.

생각보다 쉬운 문제였단 걸 질문하고 나서 알게 되었다.
items에서 바로 이름을 불러올 수 없는 이유는, 내가 애초에 인벤토리에 선언된 items 자체가 Item 클래스를 배열로 선언한 함수였기 때문이다.
따라서 인덱스가 있어야지 해당 아이템의 이름을 불러올 수 있는데, 인덱스도 없이 아이템 이름을 불러오려고 해서 발생한 문제였다. 그러면 획득한 아이템의 인덱스를 어떻게 찾아올까. 이걸 위해서 인덱스를 가져올 변수를 하나 추가로 선언해야 했다.

private int lastAcievedIndex; // 제일 최근에 획득한 아이템의 인덱스를 저장할 변수
...
public void Add(Item item)
{
	for(int i = 0; i < items.Length; i++)
	{
		if(items[i] == null)
		{
             items[i] = item;
             lastAcievedIndex = i;	// 아이템을 먹었을 때 해당 아이템의 인덱스를 저장
             itemAchieved = true;
             break;
        }
...

public void PrintAchievedItem()
{
    if (itemAchieved)
    {
        Console.SetCursorPosition(0, 13);
        Console.Write($"{items[lastAcievedIndex].name}를 획득했습니다.");
        itemAchieved = false;
    }            
}

이와 같이 작성하고서 출력해 보았다.

이로서 아이템을 획득하는 일회성 출력까지 완성하였다.

4.3 인벤토리가 가득 찼을 때?

인벤토리 테스트를 하면서 추가적인 문제를 발견했다. 아이템이 가득 찼을 때의 내용을 나중에 구현할 생각이기도 했는데, 테스트를 진행하다 보니 인벤토리가 가득 찬 상태에서 먹은 아이템은 먹지 않은 걸로 처리되고 그대로 사라지는 것.
물론 현재 맵 구상 상태에서는 인벤토리가 그렇게 넉넉치 않을 정도로 아이템을 많이 배치할 생각은 없지만, 만에 하나 아이템이 먹지도 못한 채 소멸하는 현상이 생기는 건 큰 문제다.

이 부분도 실제로 구현해 보려고 하니까 상당히 난감했다.

  1. 첫 번째 해결 시도 - 맵의 Result 단계에서 조건 확인하기?
public override void Result()
{
    foreach (GameObject go in gameObjects)
    {
        if (Game.Player.position == go.position && input != ConsoleKey.X)
        {
            go.Interact(Game.Player);
            if (go.isOnce == true)
            {
                gameObjects.Remove(go);
            }
            break;
        }
    }
...

여기서 go.Interact(Game.Player); 가 작동하면 아이템 획득 프로세스가 진행되는데, 여기에서 조건을 걸어버리려는 시도를 했다. 하지만, 저 프로세스에서 작동하는 것이 아이템 뿐만이 아니라 Place 클래스도 있기 때문에 여기서 잘못 조건을 걸었다간 Place 클래스가 먹통이 되는 사태가 벌어질 것 같았다.
따라서 다른 방법을 생각해야 했다.

  1. 두 번째 해결 시도 - 인벤토리에서 획득 못하게 시도
public void Add(Item item)
{
    for(int i = 0; i < items.Length; i++)
    {
        if(items[i] == null)
        {
            items[i] = item;
            lastAcievedIndex = i;
            itemAchieved = true;
            break;
        }
        if(items[items.Length-1] != null)	// 인벤토리가 꽉 찼을 시
        {
            itemAchieved = false;
            break;
        }
    }            
}

우선은 인벤토리가 꽉 찼을 시 아이템 획득이 되지 않도록 설정하긴 했으나, 문제는 여기에서 뭘 건들여봐도 go.Interact 함수 진행 후 Remove를 막을 방법이 없다는 것이었다. Item에 선언된 isOnce 변수는 수정할 수가 없는(해서는 안 되는) 변수이기 때문에, 무언가 조건을 더 걸어야 한다고는 생각했다.

하지만 itemAchieved 변수는 private으로 설정되어 있고, 이걸 public으로 바꾸기에는 리스크가 컸다. 새로 변수를 만들어서 조건을 걸어야하나 생각했을 때 문득 다음과 같은 방법을 생각해냈다.

private bool itemAchieved;

// getter - setter, 이 간단한 방법을 왜 바로 못 떠올렸을까
public bool ItemAchieved { get { return itemAchieved; } }	

코드를 쭉 살펴 보니, 아이템 획득 여부를 잘만 활용하면 인벤토리가 가득 찼을 때 아이템을 획득하지 못하면서 아이템이 사라지지 않도록 구현할 수 있을 것 같았다. 위와 같이 조건을 걸고 맵의 Result 부분에 조건을 추가했다.

public override void Result()
{
    foreach (GameObject go in gameObjects)
    {
        if (Game.Player.position == go.position && input != ConsoleKey.X)
        {
            go.Interact(Game.Player);
            // go가 아이템이면서 아이템을 획득했을 경우 -> 획득하지 못하면 안 사라짐
            if (go.isOnce == true && Game.Player.inventory.ItemAchieved)
            {
                gameObjects.Remove(go);
            }
            break;
        }
    }

이와 같이 구현하고 아이템이 사라지지 않는 것을 확인하였다.

  • 도전과제 : 인벤토리가 가득 찼을 때, 인벤토리가 가득 찼다는 출력을 하고 싶었는데, 이건 너무도 구현이 어려웠다. (정확히는 할 수는 있는데 변수와 함수를 자꾸 추가해야 할 것 같아서 당장은 구현하지 않기로 했다.)
    만약 작업하는데 시간이 남는다면 이 부분도 한 번 구현해보도록 하자.

5. NPC 구현

NPC는 GameObject인가 아닌가? 이 부분에 대해 깊게 생각하지 않고 무작정 GameObject로 만들려다가 가만 생각해보니, 짚고 넘어갈 필요성이 있어 보였다.

(무작정 게임 오브젝트로 구현하려 했다가 이건 아니다 싶어서 지우는 장면이다.)

결론만 따지자면 NPC는 GameObject가 아니라고 판단했다. 따라서 IInteractable를 상속하는 다른 객체로 분리하여 새로운 클래스를 만들었다.

public abstract class NPC : IInteractable
{
    public ConsoleColor color;
    public char symbol;
    public Vector2 position;
    
    // 이름
    public string name;
    // 호감도
    protected int likeablity;

	// NPC는 색깔, 글자심볼, 위치를 가진다.
    public NPC(ConsoleColor color, char symbol, Vector2 position)
    {
        this.color = color;
        this.symbol = symbol;
        this.position = position;
    }

    public void Print()
    {
        Console.SetCursorPosition(position.x, position.y);
        Console.ForegroundColor = color;
        Console.Write(symbol);
        Console.ResetColor();
    }

	// 플레이어와의 상호작용을 구현
    public abstract void Interact(Player player);
	
    // NPC는 대화할 수 있는 기능을 가진다.
    public abstract void Talk();
}

사실은 지금 단계에서 만들 NPC는 한 명 밖에 없는 계획이지만, 그래도 나중에 확장성 등을 생각하여 초기에 설정한 캐릭터 NPC - N양을 자식클래스로 구현하였다.

public class Ms_N : NPC
{    
    public int Likeablity { get { return likeablity; } }
    public Ms_N(Vector2 position)
        : base(ConsoleColor.DarkYellow, 'N', position)
    {
        name = "N양";
        likeablity = 0;
        isTalking = false;
    }

    public override void Interact(Player player)
    {
    	Talk();
    }

    public override void Talk()
    {
        Console.SetCursorPosition(0, 14);
        Util.XKeyText("안녕하세요");
        Util.XKeyText("당신은 누구신가요?");
        Util.XKeyText("제 이름은 N양이라고 해요");
        Console.WriteLine("대화를 종료하려면 X키 외의 아무 키를 누르세요.");
        
        isTalking = false;
    }
}

우선은 이와 같이 구현을 하고 플레이어와 상호작용을 시켜야 하니, 맵에서 NPC를 따로 리스트로 선언하고, 출력 순서 때문에 맵이 덜 출력되는 현상도 발견되어 순서 조정을 좀 했다.
(절차 지향보다는 확실히 편한데, 객체 지향이라고 순서가 안 중요한 것은 아니다 기억하자)

protected string[] mapData;
protected bool[,] map;

protected List<GameObject> gameObjects;
protected List<NPC> npcList;

private ConsoleKey input;

public override void Render()
{
    PrintMap();		// 1. 맵 출력
    foreach (GameObject go in gameObjects)
    {
        go.Print();		// 2. 게임 오브젝트 출력
    }
    foreach (NPC npc in npcList)
    {
        npc.Print();	// 3. NPC 출력
        // 여기다가 Talk를 출력하면 되겠지?
    }
    Game.Player.Print();	// 4. 플레이어 출력
    Game.Player.inventory.BrieflyPrint();	// 5. 인벤토리 출력 (여기까지가 필수 출력)        
    Game.Player.inventory.PrintAchievedItem();	// 6. 아이템 획득여부 출력 (조건부)
    StartText();	// 7. 초기 출력 텍스트는 순서가 그다지 중요하지 않음    
}

처음엔 저기다가 같이 출력하려고 했는데, 저기다 출력하니 움직이는 내내 npc 대화가 출력되는 문제가 발생했다.
우선은 대화 출력 조건문을 만들고, Render 순서상 NPC의 대화는 뒤에 출력되도록 하였다.

  • 대화 출력의 조건을 위해선, 우선 NPC 좌표 기준으로 플레이어가 상하좌우 위치에 있을 때 대화를 할 수 있도록 설정했다. 이걸 조건문으로 쓰면 무려 4개의 조건이 필요하기 때문에, 대화가 가능한지 여부를 판단하는 bool 반환 함수를 아래와 같이 NPC에 선언했다.
// 대화하고 있는지를 판단할 bool 변수 추가
public bool isTalking;

...

public bool IsInteractable()
{
	// 플레이어의 왼쪽 / 플레이어의 오른쪽 / 플레이어의 위쪽 / 플레이어의 아래쪽 중 하나에 있을 경우
    if ((Game.Player.position.x - 1 == position.x && Game.Player.position.y == position.y)
        ||(Game.Player.position.x + 1 == position.x && Game.Player.position.y == position.y)
        ||(Game.Player.position.x == position.x && Game.Player.position.y - 1 == position.y)
        ||(Game.Player.position.x == position.x && Game.Player.position.y + 1 == position.y))
    {
        return true;
    }
    return false;
}

이와 같이 4가지 조건식을 간단하게 IsInteractable() 로 구현하고, 다음과 같이 진행되도록 하였다

  1. 맵의 Result에서 플레이어가 상호작용 가능한 상태일 경우, isTalking으로 상호작용 가능 여부를 기록한다.
  2. 맵의 Render 부분으로 돌아가서, 상호작용 여부에 추가로 X키를 눌러야지 대화가 가능하도록 설정.
  3. 대화가 가능한 상태이면 NPC와의 Interact가 활성화되고 대화가 출력된다.
// 맵의 결과 출력 부분
// 1. NPC와 상호작용 가능한지 확인
 public override void Result()
 {
     ...
     
     foreach (NPC npc in npcList)	// NPC와 상호작용이 가능하면 true, 아니면 false
	{
    	npc.isTalking = npc.IsInteractable()? true : false;
	}
}
 
 
// 맵의 Render 부분
// 2. NPC와 상호작용 가능한 상태인지 확인한 후, Render 부분으로 돌아가서,
// 상호작용 가능한지 여부와, X키를 눌렀는지 여부를 같이 확인한다.
...
StartText();
foreach (NPC npc in npcList)
{
    if (npc.isTalking == true && input == ConsoleKey.X)
    {
        npc.Interact(Game.Player);
    }
}



// Ms_N NPC의 Interact 기능
// 3. Interact가 작동하면, Talk() 가 작동하고 대화가 출력이 된다.
...
public override void Interact(Player player)
{
    Talk();
}

public override void Talk()
{
    Console.SetCursorPosition(0, 14);
    Util.XKeyText("안녕하세요");
    Util.XKeyText("당신은 누구신가요?");
    Util.XKeyText("제 이름은 N양이라고 해요");
    Console.WriteLine("대화를 종료하려면 X키 외의 아무 키를 누르세요.");
    
    isTalking = false;
}

또한 대화 출력의 경우, 전에 만들어 놓았던 Util 기능을 이용하여 대화 중 이동으로 갑작스레 대화가 종료되는 부자연스러운 종료 방법을 막았다.

  • 도전 과제 : NPC를 만들면서 겪고 있는 도전 과제는 총 두 가지다.
  1. 원래는 플레이어 근처에 다가가서 대화 가능 상태일 경우 대화 기능을 출력하려고 했다. 하지만 이게 생각보다 조건을 많이 꼬아야 해서 구현이 어려웠다. 가능하다면 구현해 보자.
  2. NPC를 상하좌우 위치에서 상호작용 가능하게 구현한 게, 플레이어와 겹치는 작용이 부자연스러워서 그랬다. 하지만 NPC위에 올라가지 못하게 하는 것은 아직 구현하지 못했다.
    (맵을 구현한 특성상 이게 쉽지가 않다.)
    정 안되면 NPC 좌표를 일일히 false 처리할 방법을 써야겠지만, 가능하다면 NPC 좌표를 바로 bool 처리할 수 있는지 방법을 찾아보자.

6. 마무리

오늘의 개발 일지는 여기서 마무리하고, 2일차 TODO 리스트를 미리 기록해 놓자.

  1. 인벤토리 Open 기능 및 아이템 사용 기능 구현
  2. 탈출을 위한 아이템의 추가, 함정 등 새로운 오브젝트 및 맵 구성
  3. 본격적인 스토리 라인 구축
  4. 호감도 시스템 및 체력/정신력에 따른 엔딩 시스템 구현
profile
게임 만들러 코딩 공부중

0개의 댓글