발전하는 춘배

[원고, C++/OOP] 로그라이크 게임 만들어보기 4 - 플레이어 이동 : 클래스의 권한은 어디까지인가, 플레이어의 소유권은 무슨 클래스에 주어야 하는가 따위의 고민 본문

.원고

[원고, C++/OOP] 로그라이크 게임 만들어보기 4 - 플레이어 이동 : 클래스의 권한은 어디까지인가, 플레이어의 소유권은 무슨 클래스에 주어야 하는가 따위의 고민

춘배0 2026. 2. 12. 22:22

1. 플레이어 이동 함수 호출에 대한 고민

플레이어 이동을 구현해보자.

큰 틀은 이렇다.

1) 플레이어는 자신을 이동시키는 API를 갖고있다. 입력은 변화시킬 x와 y이며 자기 자신의 좌표를 그만큼 업데이트한다. 2) 생각해보면 플레이어뿐 아니라 다른 적(Entity)들도 이동은 가능하므로 이 API는 Entity라는 클래스에 넣는다. 일단 플레이어와 적 말고 다른 엔티티는 구현하지 않을 것이므로 따로 MovingEntity같은 클래스는 만들지 않는다.

3) 이 API는 Game이라는 클래스에서 호출할 것이다. 이렇게 만들면 지금은 콘솔 input기반으로 moving API가 호출되겠지만, 나중에 UI 등 다른 input으로 변경할 때 호출되도록 변경하기 용이할 것이다.

 

여기서 이런 고민이 생겼다. map의 범위를 벗어나는지, 이동할 타일이 walkable한지 아닌지 체크를 어디에서 체크해야 할까?

이번뿐 아니라 계속 이런 게 가장 어려운 것 같다. 하나의 기능을 구현하는 데 그 과정에서 어떤 클래스가 어떤 클래스를 호출하거나 의존해야 하고, 그 클래스가 "어디까지의 권한"을 가져야 하는지가 고민스럽다. 지금은 GPT한테 물어보면서 조언을 구하고 있는데, 깨달은 바가 있다면 그 클래스가 '알고 있는 것'의 범위를 명확하게 이해하는 게 해결을 위한 좋은 접근법인 것 같다는 것이다.

이 경우에서는

Entity가 알고 있는 것은 '자신의 좌표',

Map이 알고  있는 것은 '타일의 정보',

World가 알고 있는 것은 '맵과 엔티티'이므로 이거에 맞춰 생각해본다.

1. Entity는 단순히 자신의 좌표를 업데이트한다.

void Entity::move(int x, int y) {
   this->x += x;
   this->y += y;
}

2. World는 엔티티의 이동이 가능한지 판단해서 이동시킨다. 이때 Map의 타일 정보를 받아와서 가능한지 체크한다.

void World::tryMoveEntity(Entity *entity, int x, int y) {
  if (entity == nullptr) {
    return;
  }

  int newX = entity->getX() + x;
  int newY = entity->getY() + y;

  if (currentMap->getTile(newX, newY).props.walkable) {
    entity->move(x, y);
  }
}

3. Game에서는

world.tryMoveEntity(player, 1, 0);

과 같이 호출한다. 지금은 콘솔 입력을 받아 호출하지만, 나중에 뭐 UI 이벤트로 호출을 할 수도 있게 될 것이다. 호출 방법을 바꾸더라도호출하는 함수는 항상 world.tryMoveEntity(player, 1, 0);로 고정이다. 즉 확장성이 좋다.

 

2. Game 클래스, 플레이어 객체의 소유권

좋다. 이제 플레이어 이동을 위한 모든 준비가 끝났고, 실제로 이동시켜본다.

지금까지 테스트는 main에서 그냥 했었는데 이젠 실제로 Game 클래스를 만들어보자.

class Game {
private:
  World* world;
  Entity *player;

public:
  Game();
  ~Game();
  void run();
};

간단하다.

Main에서 Game을 생성하면 world와 player가 할당되어야 한다. world와 player는 게임당 고유한 하나임을 기억하자.

그래서 생성자와 소멸자를 이렇게 짜본다.

Game::Game() : world(new World), player(new Entity(1, 1, '@', 30, 10)) {}
Game::~Game() {
  delete world;
  delete player;
  world = nullptr;
  player = nullptr;
}

예전에 지정했던 World의 entity 벡터를 unique_ptr 벡터로 바꿔야한다.

std::unordered_map<std::string, std::vector<Entity>> entities; 이거였는데

std::unordered_map<std::string, std::vector<std::unique_ptr<Entity>>> entities; 이걸로 바꿔준다.

왜냐하면,

전자에서는 여기 벡터에 저장되는 엔티티는 플레이어의 복사본이기 때문에 Game의 플레이어는 좌표를 바꾸지만 World의 entities[]의 플레이어의 좌표는 (1,1)에서 변하지 않는 문제가 생긴다.

후자로 바꿔주면, 엔티티는 힙에 생성되고, 벡터는 포인터만 저장해서 벡터가 재할당되어도 가리키는 엔티티는 힙의 그 자리에 계속 있다.

 

일단 이건 이거고, 사실 나의 시도는 구조부터 잘못되었다.

플레이어는 Game이 갖는 게 아니라, World가 가지는 게 맞다는 GPT형님의 충고가 있었다.

내 딴에는 플레이어는 게임에서 고유하므로 Game이 소유하면 되지 않을까 싶었던 건데, GPT형님의 표현은 이러하다.

 

플레이어는 어디에 존재하나?

  • 메뉴에 존재하나? ❌
  • 입력 시스템에 존재하나? ❌
  • 월드 안에 존재하나? ✅

플레이어는 세계 안에 있는 객체다.

즉:

플레이어는 “게임의 구성 요소”가 아니라
“월드의 구성 요소”다.

 

오호라. 이렇게 보니 또 이 말이 맞다. 아직 내 게임에는 메뉴도 입력 시스템도(아직은) 없지만, 만약 이런 걸 추가한다면, Game 클래스에 추가하게 될 것이다. 그렇다면 그 때 player를 Game이 관리하는 건 맞지 않다. 왜냐하면 계층적으로 봤을 때, 플레이어는 메뉴와 입력 시스템과 월드와 동등하다고 볼 수 없고, 플레이어는 월드 안에 존재하는 것이기 때문이다.

 

더불어 아래 설명도 인상깊게 읽어서 같이 남겨본다.

ㅇㅋ 이제 player는 World에서 생성하고 관리해야된다는 걸 알았기 때문에 할 일을 해보자.

이를 위해 World가 player 객체를 추적하도록 멤버변수와 getter를 만들어준다.

class World {
protected:
  std::unique_ptr<Entity> player;
  ...
public:
  Entity* getPlayer();
  ...
}

플레이어를 스폰할 때 이 player를 업데이트해준다.

Entity* World::spawnPlayer() {
  player = std::make_unique<Entity>(1, 1, '@', 30, 10);
  return player.get();
}

즉 Entity 벡터에 플레이어를 추가하는 게 아니라 따로 직접 관리한다.

그래서 맵 프린트 함수도 고쳐준다.

void World::drawCurrentMap() {
  for (int y = 0; y < currentMap->getHeight(); y++) {
    for (int x = 0; x < currentMap->getWidth(); x++) {
      if (player && player->getX() == x && player->getY() == y) {
        std::cout << player->getSymbol() << " ";
      } else {
        Entity *entity = getEntityAt(x, y);
        if (entity != nullptr) {
          std::cout << entity->getSymbol() << " ";
        } else
          std::cout << currentMap->getTile(x, y) << " ";
      }
      std::cout << '\n';
    }
  }
}

이제 게임의 run함수를 만들면

void Game::run() {
  world->populateMap("map1", 15, 10);
  Entity* player= world->getPlayer();
  bool running = true;
  while (running) {
    world->drawCurrentMap();
    std::string command;
    std::cout << "Enter command: ";
    std::cin >> command;
    switch (command[0]) {
    case 'q':
      running = false;
      break;
    case 's':
      world->tryMoveEntity(player, 0, 1);
      break;
    case 'd':
      world->tryMoveEntity(player, 1, 0);
      break;
    case 'w':
      world->tryMoveEntity(player, 0, -1);
      break;
    case 'a':
      world->tryMoveEntity(player, -1, 0);
      break;
    default:
      std::cout << "Invalid command.\n";
      break;
    }
  }
}

참고로 플레이어가 만들어지는 시점은 아직까진 popluateMap()에서 spawnPlayer()를 호출하여 만드는데 이건 사실 마음에 들지 않는다. 지금이야 맵이 하나지만 이렇게 하면 맵을 만들 때마다 플레이어가 새로 생겨버려 스탯 연동(아예 다른 객체이므로) 따위의 문제가 발생하기 때문. 근데 일단 지금 하고자하는 건 움직임 구현이므로 참고 넘어간다.

 

테스트:

Enter command: a
# # # # # # # # # # # # # # # 
# @ . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# # # # # # # # # # # # # # # 
Enter command: w
# # # # # # # # # # # # # # # 
# @ . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# # # # # # # # # # # # # # # 
Enter command: d
# # # # # # # # # # # # # # # 
# . @ . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# # # # # # # # # # # # # # # 
Enter command: s
# # # # # # # # # # # # # # # 
# . . . . . . . . . . . . . # 
# . @ . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# . . . . . . . . . . . . . # 
# # # # # # # # # # # # # # #

벽은 walkable하지 않으므로 이동이 안된다. 굿

반응형