발전하는 춘배
[원고, C++/OOP] 로그라이크 게임 만들어보기 3 - Map 구현하기 2 : Tile, JSON, nlohmann/json, Entity 배치 본문
[원고, C++/OOP] 로그라이크 게임 만들어보기 3 - Map 구현하기 2 : Tile, JSON, nlohmann/json, Entity 배치
춘배0 2026. 2. 8. 16:53앞서 정의한 Map과 World의 책임 정의를 염두해 두고 Map 구현을 끝내보자.
Map:
- 타일 정보 보관 (벽, 땅, 나무)
- 특정 좌표가 막혔는지
- 특정 좌표에 엔티티가 있는지
- 화면 출력
World:
- 맵 생성
- 맵 초기화 (벽, 땅 채우기)
- 플레이어 배치
- 몬스터 배치
- 맵 전환
1. 맵을 Tile 형태로 저장
지금 맵은 int형 이중벡터 형식인데 이는 직관적이지 않다.
map을 std::vector<std::vector<Tile>> tiles; 형태로 구현하려고 한다.
Tile은 아직은 딱히 class로 만들 이유를 못 느껴서 struct로 만든다.
struct Tile {
TileType type;
TileProperty props;
};
struct TileProperty {
char symbol;
bool walkable;
};
타일의 속성으로는 일단 출력 심볼이랑 플레이어가 다닐 수 있는지 없는지 여부.
굳이 Tile이랑 Property를 분리해서 두 개의 struct로 만들었어야했냐 하면 그런 건 아니긴 한데 props는 중복이 가능하지만 type은 TileType enum class로써 중복이 불가능하다는 점에서 key:value 느낌으로 분리해봤다.
2. 타일 속성 파일로 저장 (JSON)
그리고 사실 타일들은 게임 중에 동적으로 속성이 바뀌거나 하진 않을 거라서 각 타일별로 속성들을 파일에 저장해서 게임 시작 때 로드하는 느낌으로 해보고 싶었다.
이를 관리할 TileDatabase 클래스를 만든다.
class TileDatabase {
private:
static std::unordered_map<TileType, Tile> tileDatabase;
public:
static void loadFromFile(const std::string& path);
static TileType tileTypeFromString(const std::string& s);
static const Tile findTileByTileType(TileType type);
};
그 타일 속성들은 JSON형식으로 저장해보기로 한다.
{
"Empty": { "walkable": false, "symbol": " " },
"Floor": { "walkable": true, "symbol": "." },
"Wall": { "walkable": false, "symbol": "#" }
}
JSON 라이브러리는 GPT형님한테 추천받은 nlohmann/json 사용한다.
https://github.com/nlohmann/json/releases
여기서 json.hpp 받아서 include/nlohmann에 넣어놓고 #include <nlohmann/json.hpp>로 쓰면 된다.
include폴더는 이번에 새로 만들었기 때문에 컴파일 옵션에 -Iinclude 넣어준다.
이걸 이용해서 TileDatabase API들을 구현해준다.
그리고 데이터 loading을 어디서 구현해야할 지 고민이었는데 일단 구현은 TildDatabase에서 하고, main에서는 "언제"로딩할지 결정해서 로딩해오는 식으로 하기로 했다.
gpt 표현을 빌리면..
main은 “언제”만 알고
“어떻게”는 몰라야 한다
테스트:
int main() {
TileDatabase::loadFromFile("data/tile_def.json");
std::cout << "Tile definitions loaded successfully.\n";
std::cout << "TileType::EMPTY: " << TileDatabase::findTileByTileType(TileType::EMPTY).type << "\n";
return 0;
}
결과:
Tile definitions loaded successfully.
TileType::EMPTY: EMPTY
성공적으로 map의 내부 구현을 바꿨다.
3. 맵 생성 로직
맵 생성은 World가 하기로 했었다. 일단 벽으로 둘러쌓인 고정된 맵을 만들고, 나중에 랜덤 요소를 추가하기로 한다.
void World::populateMap(const std::string& id) {
Map& map = *maps[id];
map.fill(TileType::Floor);
map.makeBorderWalls();
}
테스트:
World world;
world.populateMap("test", 30, 10);
world.changeMap("test");
std::cout << "Map created successfully.\n";
world.getCurrentMap()->print();
결과:
Map created successfully.
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
사실 많은 시행착오가 있었다. 일단 출력이 안 돼서 보니까 Map() 생성자에
Map::Map(int width, int height) : width(width), height(height), tiles(width, std::vector<Tile>()) {}
이렇게 되어 있었는데 당연히 2차원 벡터 생성이 똑바로 안 되서 일단 문제였던 것이고..
좀 더 생각을 해보니 tiles(width, std::vector<Tile>(height))보다는 tiles(height, std::vector<Tile>(width)) 이게 맞는 표현일 터였고..
이걸 바꾸다보니 갑자기 좌표때문에 너무 헷갈려서 싹 바꿔버렸다.
접근은 tiles[y][x]; 이런 식으로 하고..
반복문은
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
std::cout << tiles[y][x] << " ";
}
std::cout << '\n';
}
이런 식으로 돌리기로 했다. 은근 좌표 표현이 헷갈리는구나
4. 엔티티 배치
이제 맵에 플레이어랑 적을 올려 보자.
일단 간단하게 (1,1)에 플레이어, (28,8)에 적을 둬 본다.
나중엔 맵마다 스폰 구역을 지정해서(ex. 입구) 해당 타일에서 뭔가 스폰되도록 하려 하는데 일단 대충 해본다.
엔티티 배치는 World의 책임이었다.
World에서 해당 위치에 엔티티를 스폰할 수 있는지 체크하고 가능하다면 배치한다. (체크를 위해 Map의 데이터를 사용하지만 판단은 World에서 한다)
그리고 맵 출력 함수도 수정해서 엔티티가 그 자리에 있다면 타일 심볼 대신 엔티티 심볼을 출력하게 해야하는데 문제가 있다.
맵은 엔티티의 위치를 직접 알지 않는다. 엔티티의 속성에 x좌표와 y좌표가 있을 뿐이다. 그럼 맵이 어떤 좌표 x,y에 어떤 엔티티가 있는 지 확인하려면 어떡해야할까?
1. World가 맵별로 스폰되어있는 엔티티 정보를 담고 있다. unordered_map<string, vector<Entity>> entities;
2. 현재 맵에서 이 벡터를 탐색하여 엔티티 위치를 확인한다.
3. 맵 출력도 이제 Map이 하지 않는다. 기존의 Map::print()를 없애고 World::drawMap()으로 대체한다.
이렇게 하는 이유는 다음과 같다.
- 일단 이제 출력해야할 대상이 Map뿐아니라 Entity까지로 확장되었으므로, Map에서 이를 모두 그리려고 시도하는 건 명백한 맵의 월권이다.
- 아직 뭐 최적화는 생각하지 않을 거기 때문에 벡터로 쭉 탐색해도 괜찮다.
- 1번이 좀 문젠데, world가 maps를 담고 있는 방식 unordered_map<string, unique_ptr<Map>> maps; 이거랑 key값은 같고 value가 두 개로 나눠진다는 점에서 개선의 여지가 있다. 즉 맵의 id string에 대하여 Map과 Entity를 묶은 세트를 저장할 수도 있는데 일단 아직 프로그램의 볼륨이 그렇게까지 크지 않고 (솔직히 귀찮아서) 나중으로 미루기로 한다.
이를 적용해서 수정해본다.
class World {
protected:
std::unordered_map<std::string, std::unique_ptr<Map>> maps;
std::unordered_map<std::string, std::vector<Entity>> entities;
std::string currentMapId;
Map *currentMap;
public:
World();
// GETTERS
Map *getCurrentMap();
// Methods
// Creates a empty new map with the given id and dimensions
void createMapBase(const std::string &id, int width, int height);
void spawnPlayer();
void spawnMonsters();
void populateMap(const std::string &id, int w, int h);
Entity* getEntityAt(int x, int y);
void changeMap(const std::string &id);
void drawCurrentMap();
};
테스트:
World world;
world.populateMap("test", 30, 10);
world.changeMap("test");
std::cout << "Map created successfully.\n";
world.drawCurrentMap();
결과:
Map created successfully.
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# @ . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . $ #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
굿.
하다보니 Map의 권한이 많이 줄어들고 대부분 World로 갔다.
- Map은 엔티티를 전혀 모른다
- World가 엔티티를 소유한다
- 출력은 Map이 아니라 World가 한다
GPT 표현을 빌리면 이렇다.
Map은 순수한 데이터(타일들의 집합)
World가 조합(rendering orchestration)
무튼 마지막으로 맵 바꾸는 것까지 테스트를 해봤다.
World world;
world.populateMap("test", 30, 10);
world.changeMap("test");
std::cout << "Map created successfully.\n";
world.drawCurrentMap();
world.populateMap("test2", 8, 8);
world.changeMap("test2");
std::cout << "Map created successfully.\n";
world.drawCurrentMap();
world.changeMap("test");
std::cout << "Map changed successfully.\n";
world.drawCurrentMap();
Map created successfully.
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# @ . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . $ #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
Map created successfully.
# # # # # # # #
# @ . . . . . #
# . . . . . . #
# . . . . . . #
# . . . . . . #
# . . . . . . #
# . . . . . . #
# # # # # # # #
Map changed successfully.
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# @ . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . . . . . . . . . . . . . . $ #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
오늘은 여기까지.
다음엔 플레이어를 이동시켜보자.
