Post

[우아한테크코스] 프리코스 오픈 미션 - 체스 게임 만들기

[우아한테크코스] 프리코스 오픈 미션 - 체스 게임 만들기

Intro

이번 프리코스의 미션은 오픈 미션으로 여러 선택지 중 하나를 골라 해결하는 유형의 미션이었다. 나는 난이도는 평범하나 평소에 잘 사용하지 않는 개발 도구나 언어로 문제를 해결한다.를 미션 주제로 잡았고 C++을 활용한 CLI 체스 프로그램을 구현하는 것을 목표로 잡았다. 체스라는 게임 자체를 좋아해서 한 때 체스닷컴에서 많이 플레이를 하기도 했고, Stockfish라는 체스 엔진의 분석 결과를 실시간으로 받아보는 프로젝트도 해봤던터라 좋아하는 분야인 체스를 주제로 잡았다. 언어의 경우 C++을 선택했는데 아래 이유들로 적합하다고 판단했다.

  1. 한 번도 사용해 본 적은 없는데 배워보고 싶다.
  2. 최적화에 초점을 맞춰 개발해보고 싶다.
  3. 객체 지향 언어로 개발해보고 싶다.

먼저 1번의 경우 최근 알고리즘 공부를 하다 Java로는 아예 해결이 불가능한 문제가 백준에 존재한다는 것을 알게 됐다. 메모리 최적화 알고리즘 관련 문제였는데 JVM이 올라가는 것만으로 메모리 제한을 초과해서 아예 통과가 안된다고 했다. Java가 PS에 불리한 것은 들어봤는데 이런 문제를 보니 PS에 조금 진심인 나도 결국은 C++로 PS를 할 날이 오겠구나 생각이 들었다. 어차피 언젠가 배워야하는데 오픈 미션이라는 좋은 기회에 사용해보면 좋을 것이라는 생각이 들었다.

2번의 경우 C++이 시간, 공간 모두 확실히 빠르고 효율적인 언어라고 생각했다. Stockfish의 경우도 성능 때문에 C++로 개발이 된 것으로 알고 있고, 다른 체스 엔진들도 성능 때문에 대부분 C++로 개발된 것으로 알고 있다. 내 CLI 체스 게임이 그정도 성능이 필요한 것은 아니지만 향후 엔진 개발이 붙을 경우 호환성도 좋을 것이고 단순히 게임 로직만 구현하는 것은 약간 아쉬워서 최적화에 초점을 맞추려다보니 자연스럽게 C++이 떠올랐다. 체스 보드의 크기가 마침 딱 64칸이니 비트 연산을 활용해서 극한의 최적화를 해보고 싶었다.

3번의 경우 Java 밖에 할 줄 모르는 나는 객체 지향적인 사고를 그대로 이어서 개발을 하고 싶었다. 체스의 경우 생각보다 게임 규칙이 복잡해서 잘 설계하는게 중요할 것으로 판단했는데 C의 경우 절차 지향 언어라서 이런 복잡성을 잘 풀어낼 수 있을까 고민이 됐고 OOP가 되는 C++이 좀 더 적합할 것으로 보였다.


WoowaHanChess

프로젝트명은 크게 고민하지 않고 WoowaHanChess로 지었다. 우아한테크코스를 위한 느낌은 주고 싶어서 저렇게 했는데 나름 나쁘지 않은 것 같다. C++ 개발을 위해 일주일간 언어 공부에 몰입했다.


기물 정보를 어떻게 다룰까?

체스는 흑과 백 두 색상의 플레이어가 있고, 폰, 나이트, 비숍, 룩, 퀸, 킹 6가지 기물이 있어서 총 12가지 기물 조합이 나온다. 이 기물에 대한 데이터를 어떻게 다룰지가 향후 프로젝트의 복잡도를 결정하는 중요한 사항이라는 생각이 들었고 많은 고민을 들여 설계했다.

먼저 조합이 12가지이니 1바이트로 다룰 수 있겠다고 생각했고 앞의 4비트는 색상 정보를, 뒤의 4비트는 기물 종류 정보를 담았다. 이러면 비트 연산을 통해 색상과 기물 종류 정보를 빠르게 확인할 수 있을 것 같았고 아래와 같이 Piece.hpp 헤더 파일을 작성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#pragma once
#include <cstdint>

/*
 * 1바이트 CCCCTTTT 형태로 기물 정보 저장
 *
 * CCCC = 0000 -> WHITE
 * CCCC = 0001 -> BLACK
 * CCCC = 0100 -> NONE
 *
 * TTTT = 0000 -> PAWN
 * TTTT = 0001 -> KNIGHT
 * TTTT = 0010 -> BISHOP
 * TTTT = 0011 -> ROOK
 * TTTT = 0100 -> QUEEN
 * TTTT = 0101 -> KING
 * TTTT = 1000 -> NONE
 */
enum Piece : int8_t {
  WP = 0b0000'0000,
  WN = 0b0000'0001,
  WB = 0b0000'0010,
  WR = 0b0000'0011,
  WQ = 0b0000'0100,
  WK = 0b0000'0101,
  BP = 0b0001'0000,
  BN = 0b0001'0001,
  BB = 0b0001'0010,
  BR = 0b0001'0011,
  BQ = 0b0001'0100,
  BK = 0b0001'0101,
  NONE = 0b0100'1000
};

constexpr int8_t BIT_MASK = 0b1111; // 4비트 단위 청크
constexpr int8_t COLOR_SHIFT = 4;   // 청크 이동

constexpr bool isNone(Piece p) { return p == NONE; }

// NONE은 쓰레기 값 반환, 실제 기물이면 색상 인덱스 반환
constexpr int8_t colorIdx(Piece p) { return (p >> COLOR_SHIFT) & BIT_MASK; }

// NONE은 쓰레기 값 반환, 실제 기물이면 타입 인덱스 반환
constexpr int8_t typeIdx(Piece p) { return p & BIT_MASK; }

색상과 기물 종류 정보는 향후 비트보드라는 곳에 활용하기 위해 저렇게 추출하는 함수를 작성해줬는데 NONE에 대한 체크를 isNone 함수에서 수행하고 인덱스 함수는 NONE 여부를 체크하지 않도록 구현했다. 딱히 무분별하게 호출될 일도 없거니와 미약한 예외처리 생략을 통한 성능 개선이 있지 않을까 싶은데 팀 개발에서는 지양해야하는 코드 같기는 하다. 기물 종류는 4비트짜리 청크와의 & 연산으로, 색상은 비트 시프트 이후 & 연산으로 추출할 수 있고 추출한 값이 바로 인덱스로 들어가도록 0부터 단조 증가하게 구성했다.


체스 보드에는 어떤 정보가 필요할까?

기물 정보를 위와 같이 정의한 후 체스 보드를 설계했다. 먼저 간단하게 필요한 필드들과 체스 보드 생성 및 출력 정도만 구현하는 것을 1차 목표로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#pragma once
#include "Piece.hpp"
#include <cstdint>

constexpr uint8_t CR_WK = 1 << 0; // WHITE KING SIDE CASTLING
constexpr uint8_t CR_WQ = 1 << 1; // WHITE QUEEN SIDE CASTLING
constexpr uint8_t CR_BK = 1 << 2; // BLACK KING SIDE CASTLING
constexpr uint8_t CR_BQ = 1 << 3; // BLACK QUEEN SIDE CASTLING

class Board {
private:
  Piece mailbox_[64];      // 보드 위 각 칸의 Piece 정보
  uint64_t pieceBB_[2][6]; // [COLOR][TYPE] 비트보드
  uint64_t colorBB_[2];    // [COLOR] 비트보드
  uint64_t occupiedBB_;    // 전체 비트보드

  bool whiteToMove_;      // 현재 플레이어 턴
  uint8_t castlingRight_; // 캐슬링 여부 캐싱
  int8_t epSquare_;       // 앙파상이 가능한 칸 캐싱

  Board();

  void initStandardPosition(); // 기본 체스 게임 생성
  void initBitboards();        // 현재 포지션을 기반으로 비트보드 초기화

public:
  static Board createStandard(); // 기본 체스 게임을 생성하는 정적 팩터리 메서드

  void printBoard() const; // 현재 보드 출력

  ~Board() = default;
};

체스보드의 경우 꽤 많은 정보가 필요한데 먼저 비트보드라는 것이 핵심이다. 비트보드는 8 * 8 크기로 총 64칸을 갖는 체스 보드에 대해 64비트 정수 타입 변수로 해당 보드를 표현하는 방법이다. 이를 통해 특정 칸의 상태를 0 또는 1로 표현할 수 있다. 이 비트보드를 활용하면 아까 기물의 색상과 종류를 반환하는 함수와 합쳐서 2 * 6 크기의 배열로 체스 보드 위 모든 기물의 위치를 표시할 수 있다. 이를 나타내는 필드가 2차원 배열 pieceBB_로 색상과 종류에 해당하는 인덱스에는 해당 기물의 위치가 비트로 표시되어있다. 이것만으로 모든 기물의 위치를 나타내는데는 충분하지만 좀 더 최적화를 위해 각 색상별 비트보드(colorBB_)와 전체 비트보드(occupiedBB_)를 두어서 이후 기물 이동 등에서 탐색 과정의 오버헤드를 줄이는 선택을 했다. 24바이트의 추가 투자로 2차원 배열을 반복 순회하지 않아도 돼서 더 나은 선택지로 보였다.

mailbox_의 경우 각 칸에 어떤 기물이 있는지 나타내는 배열로 특정 칸의 기물을 빠르게 확인할 수 있는 캐시 역할을 한다. 64바이트를 추가 투자하지만 $O(1)$ 로 조회 성능을 높일 수 있다. 이후 체스 보드를 출력하는 과정에서도 요긴하게 사용돼서 반드시 필요하다고 생각한다.

whiteToMove_는 현재 어떤 플레이어의 턴인지 castlingRight_는 캐슬링이 가능한 상태를, epSquare_는 앙파상이 가능한 칸을 따로 저장하는 역할을 한다. 추후 체스 보드와 체스 게임을 분리하면 턴 여부는 체스 게임에서 관리하도록 뺄 수도 있을 것 같고, 캐슬링 여부는 기물의 위치만으로는 알 수 없어서 반드시 따로 저장해야 한다. 앙파상 역시 기물 위치로는 추측할 수 없어서 따로 변수로 관리해야 한다.

체스 보드의 생성은 생성자 대신 정적 팩터리 메서드를 통한 생성 방식을 선택했는데 이는 흔히 아는 체스 게임이 아닌 변종 체스들도 제공할 수 있는 호환성을 위해 이렇게 설계했다. 예를 들면 폰을 제외한 기물의 위치가 랜덤으로 정해지는 체스960 게임도 지원하게 될 수 있기에 정적 팩터리 메서드로 게임 유형에 대한 힌트를 제공하는 것이 좋을 것으로 판단했다.

Board.cpp는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#include "Board.hpp"
#include <cstring>
#include <iostream>

using namespace std;

Board::Board() {}

Board Board::createStandard() {
  Board b;
  b.initStandardPosition();
  b.initBitboards();
  return b;
}

void Board::initStandardPosition() {
  static const Piece init[64] = {
      WR,   WN,   WB,   WQ,   WK,   WB,   WN,   WR,   WP,   WP,   WP,
      WP,   WP,   WP,   WP,   WP,   NONE, NONE, NONE, NONE, NONE, NONE,
      NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE,
      NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE, NONE,
      NONE, NONE, NONE, NONE, BP,   BP,   BP,   BP,   BP,   BP,   BP,
      BP,   BR,   BN,   BB,   BQ,   BK,   BB,   BN,   BR};

  memcpy(mailbox_, init, sizeof(init));

  whiteToMove_ = true;
  castlingRight_ = CR_WK | CR_WQ | CR_BK | CR_BQ;
  epSquare_ = -1;
}

void Board::initBitboards() {
  for (int c = 0; c < 2; c++) {
    for (int t = 0; t < 6; t++) {
      pieceBB_[c][t] = 0;
    }
    colorBB_[c] = 0;
  }
  occupiedBB_ = 0;

  for (int sq = 0; sq < 64; sq++) {
    Piece p = mailbox_[sq];
    if (isNone(p)) {
      continue;
    }

    int8_t c = colorIdx(p);
    int8_t t = typeIdx(p);
    uint64_t bit = 1ULL << sq;

    pieceBB_[c][t] |= bit;
    colorBB_[c] |= bit;
    occupiedBB_ |= bit;
  }
}

static const char *pieceToStr(Piece p) {
  switch (p) {
  case WP:
    return "WP";
  case WN:
    return "WN";
  case WB:
    return "WB";
  case WR:
    return "WR";
  case WQ:
    return "WQ";
  case WK:
    return "WK";
  case BP:
    return "BP";
  case BN:
    return "BN";
  case BB:
    return "BB";
  case BR:
    return "BR";
  case BQ:
    return "BQ";
  case BK:
    return "BK";
  default:
    return "  ";
  }
}

void Board::printBoard() const {
  cout << "  +----+----+----+----+----+----+----+----+\n";
  for (int rank = 7; rank >= 0; rank--) {
    cout << (rank + 1) << " |";
    for (int file = 0; file < 8; file++) {
      int sq = rank * 8 + file;
      Piece p = mailbox_[sq];
      cout << " " << pieceToStr(p) << " |";
    }
    cout << "\n  +----+----+----+----+----+----+----+----+\n";
  }
  cout << "    a    b    c    d    e    f    g    h\n\n";
}

createStandard를 통해 체스 게임을 생성하면 initStandardPosition을 통해 기물을 배치하고 기본 정보를 초기화한 후, initBitboards를 통해 비트보드를 초기화하는 방식으로 구현했다. 체스 보드의 초기화 단계가 두 단계로 나뉘어 있고 역할과 책임 분리가 약간은 애매해 보였는데 변종 체스의 경우도 비트보드 초기화는 동일하게 진행될테니 함수로 따로 추출하는게 맞다고 생각했다. std::memcpy를 통한 고속 복사로 빠르게 필드를 초기화 했고 기타 필드도 비트 연산 기반으로 초기화해서 최적화를 수행했다. 출력의 경우 일단 CLI에서 최대한 이뻐보이게 했다.


결과

저렇게 작성한 후 main.cpp를 통해 체스 보드를 생성하고 출력해보니 아래와 같이 나름 이쁘게 잘 출력됐다.

1
2
3
4
5
6
7
8
#include "Board.hpp"

int main() {
  Board b = Board::createStandard();
  b.printBoard();

  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  +----+----+----+----+----+----+----+----+
8 | BR | BN | BB | BQ | BK | BB | BN | BR |
  +----+----+----+----+----+----+----+----+
7 | BP | BP | BP | BP | BP | BP | BP | BP |
  +----+----+----+----+----+----+----+----+
6 |    |    |    |    |    |    |    |    |
  +----+----+----+----+----+----+----+----+
5 |    |    |    |    |    |    |    |    |
  +----+----+----+----+----+----+----+----+
4 |    |    |    |    |    |    |    |    |
  +----+----+----+----+----+----+----+----+
3 |    |    |    |    |    |    |    |    |
  +----+----+----+----+----+----+----+----+
2 | WP | WP | WP | WP | WP | WP | WP | WP |
  +----+----+----+----+----+----+----+----+
1 | WR | WN | WB | WQ | WK | WB | WN | WR |
  +----+----+----+----+----+----+----+----+
    a    b    c    d    e    f    g    h

다음에는 체스 게임의 핵심인 기본적인 기물 이동, 공격 등에 대한 구현을 진행할 예정이다.


This post is licensed under CC BY 4.0 by the author.