Qt 로 만들자: 숫자 퍼즐

어릴 적 해본 게임 중에 숫자 번호판을 번호 순대로 맞추는 숫자 퍼즐이 있었다. 꽤 단순하면서도 다양한 형태로 발전시킬 수 있는 게임이다. 이번에는 숫자 퍼즐을 구현해 보자.

1. 요구 사항

  • n * n 퍼즐 조각판에 숫자를 마구잡이로 섞는다
  • 조각은 빈 칸으로만 움직일 수 있다
  • 왼쪽 위에서부터 오른쪽 아래까지 순서대로 조각판을 나열한다

2. 구현

2.1 프로젝트 생성

  • 프로젝트 이름 : Puzzle
  • 메인 클래스 이름 : Puzzle
  • 메인 클래스 유형 : QMainWindow

2.2 헤더

우선, 헤더를 보자.

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
class Puzzle : public QMainWindow
{
    Q_OBJECT

public:
    Puzzle(QWidget *parent = 0);
    ~Puzzle();

protected:
    void keyPressEvent(QKeyEvent *e);

private:
    /// 퍼즐 세로 줄 갯수
    static const int PuzzleRows = 3;
    /// 패줄 가로 줄 갯수
    static const int PuzzleCols = 3;
    /// 퍼줄 조각 갯수
    static const int PuzzlePieces = PuzzleRows * PuzzleCols;

    QList<QPushButton *> _pieces;   ///< 퍼즐 조각 리스트
    QMap<intint> _puzzleMap;      ///< 퍼즐 조각 위치 맵
    int _emptyPiece;                ///< 빈 칸 위치
    bool _shuffle;                  ///< 섞는 중
    int _tryCount;                  ///< 이동 횟수

    void initMenus();
    void initWidgets();
    void initConnections();

    int upPiece(int from);
    int downPiece(int from);
    int leftPiece(int from);
    int rightPiece(int from);
    void moveUp();
    void moveDown();
    void moveLeft();
    void moveRight();
    void movePiece(int from);
    void checkResult();

private slots:
    void newPuzzle();
    void pieceClicked();
};


이번에 새롭게 등장한 것은 10 번째 줄의 keyPressEvent() 멤버 함수와 21번째 줄의 QMap 자료형이다. keyPressEvent() 는 위젯에 입력한 키보드 입력을 처리하기 위해 호출되는 멤버 함수이다. 그리고 QMap 은 key 와 value 를 연결하는 컨테이너 클래스이다.

2.3 생성자와 소멸자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Puzzle::Puzzle(QWidget *parent)
    : QMainWindow(parent)
    , _emptyPiece(0)
    , _shuffle(false)
    , _tryCount(0)
{
    initMenus();        // 메뉴 초기화
    initWidgets();      // 위젯 초기화
    initConnections();  // 시그널과 슬롯 연결

    newPuzzle();    // 새 퍼즐

}

Puzzle::~Puzzle()
{

}


예전과 크게 달라진 것은 없지만, initMenus() 가 추가되었다. 이는 메뉴를 초기화하기 위한 함수이다.

2.4 initMenuts()

메뉴를 초기화 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Puzzle::initMenus()
{
      // "파일" 메뉴 새성
    QMenu *fileMenu = new QMenu(tr("파일(&F)"));
    // "새 퍼즐" 항목 추가
    fileMenu->addAction(tr("새 퍼즐(&N)"), this, SLOT(newPuzzle()),
                        QKeySequence(QKeySequence::New));
    // 구분줄 추가
    fileMenu->addSeparator();
    // "끝내기" 항목 추가
    fileMenu->addAction(tr("끝내기(&x)"), qApp, SLOT(quit()),
                        QKeySequence(tr("Ctrl+Q")));

    // 메뉴바에 "파일" 메뉴 추가
    menuBar()->addMenu(fileMenu);
}


4 번째 줄의 QMenu 클래스는 메뉴를 만들기 위한 클래스이다.

6 번째 줄의 QMenu::addAction() 은 메뉴에 원하는 항목을 추가하는 함수이다. 이 때, QMenu::addAction() 은 항목의 이름은 물론이고, 해당 항목이 선택되었을 때, 호출될 슬롯과 해당 항목을 직접 호출할 수 있는 키조합도 지정할 수 있다. QMenu::addAction() 의 오버로딩된 다른 함수들도 있으니, 도움말을 확인하자.

QKeySequence() 는 키조합을 생성하는 함수인데, QKeySequence::New 는 표준 키조합으로 새로운 문서를 생성한다든지 할 때 쓰인다. 이런 표준 키조합이 제공되지 않을 때에는 12 번째 줄에서 처럼 직접 키조합을 지정할 수 있다. 이 때, 대소문자는 상관이 없다. 그리고 tr() 로 묶여 있는 것으로 알 수 있듯이, 다른 언어로 번역할 수도 있다. 만일 직접 키코드로 지정하고 싶다면, Qt::Key 와 Qt::Modifer 를 쓸 수 있다. 12 번째 줄의 QKeySequence(tr("Ctrl+Q")) 는 QKeySequence(Qt::CTRL + Qt::Key_Q) 와 동등하다. 하지만, 되도록이면 이렇게 키코드를 직접 지정하는 것은 피하는 게 좋다.

결국, 우선 순위는 다음과 같다.

  1. 표준 키조합 쓰기
  2. tr() 문자열로 지정하기
  3. 키코드 직접 지정하기

9 번째 줄의 QMenu::addSeparator() 는 구분줄을 메뉴에 추가한다.

11 번째 줄의 SLOT(quit())qApp 의 슬롯이다. 이 슬롯을 호출하면 프로그램이 종료된다.

15 번째 줄의 menuBar() 는 QMainWindow 의 멤버 함수로 메인 윈도우 상단의 메뉴바에 대한 포인터를 돌려준다. 따라서 상단 메뉴바에 "파일" 메뉴를 추가한다.

2.5 initWidgets()

위젯들을 초기화한다.

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
void Puzzle::initWidgets()
{
    // 퍼즐 조각 생성
    while (_pieces.count() < PuzzlePieces)
    {
        QPushButton *piece = new QPushButton;
        piece->setFocusPolicy(Qt::NoFocus); // 입력 촛점을 받지 않음

        _pieces.append(piece);  // 생성된 푸시 버튼을 리스트에 추가
    }

    int row = 0, col = 0;

    QGridLayout *gridLayout = new QGridLayout;  // 그리드 레이아웃 생성

    Q_FOREACH (QPushButton *piece, _pieces)
    {
        // 그리드 레이아웃으로 퍼즐 조각 배치
        gridLayout->addWidget(piece, row, col, Qt::AlignCenter);

        // PuzzleCols 단위로 줄바꿈
        col = (col + 1) % PuzzleCols;
        row += col == 0;
    }

    setCentralWidget(new QWidget);          // 센트럴 위젯 생성
    centralWidget()->setLayout(gridLayout); // 센트럴 위젯 레이아웃 설정
}


7 번째 줄의 QWidget::setFocusPolicy() 는 위젯의 입력 촛점 정책을 지정하는 함수이다. Qt::NoFocus 는 입력 촛점을 받지 않는다는 뜻이다. 만약 만들어진 버튼이 입력 촛점을 받는다면, 키보드 입력이 센트럴 위젯으로 전달되지 않아, 키보드 입력을 처리할 수 없다.

14 번째 QGridLayout 은 위젯을 모눈 형태로 배치한다. 다음은 QGridLayout 의 예이다.

출처: Qt 도음말(QGridLayout)


분홍색 선은 모눈의 경계를 나타내는 가상의 선이다. 위 QGridLayout 은 3행 5열이다.

19 번째 줄의 QGridLayout::addWidget() 은 QGridLayout 에 위젯을 추가하는 함수인데, 행과 열 그리고 정렬 상태를 지정할 수 있다. 행과 열은 0 에서부터 시작한다. 다른 오버로딩된 함수들도 있으니 도움말을 확인하자.

2.6 initConnections()

위젯의 시그널과 슬롯을 연결한다.

1
2
3
4
5
6
void Puzzle::initConnections()
{
    // 모든 퍼즐 조각의 시그널을 하나의 슬롯에 연결
    Q_FOREACH (QPushButton *piece, _pieces)
        connect(piece, SIGNAL(clicked(bool)), this, SLOT(pieceClicked()));
}



5 번째 줄의 QObject::connect() 를 보면, 모든 시그널을 하나의 슬롯에 연결하고 있다. 하나의 시그널이 하나의 슬롯에만 연결될 필요는 없다. 여러 개의 시그널이 하나의 슬롯에 연결될 수도 있고, 하나의 시그널이 여러개의 슬롯에 연결될 수도 있다. 그리고 시그널은 슬롯 뿐만 아니라 또다른 시그널에도 연결될 수 있다.

2.7 newPuzzle()

새로운 퍼즐을 준비한다. 퍼즐을 섞고, 조각에 숫자를 나타낸다.

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
void Puzzle::newPuzzle()
{
    // 밀리초로 나타낸 현재 시간을 난수 씨앗으로 설정
    qsrand(QTime::currentTime().msecsSinceStartOfDay());

    // 퍼즐 조각 위치 맵을 초기화
    _puzzleMap.clear();
    while (_puzzleMap.count() < PuzzlePieces)
        _puzzleMap.insert(_puzzleMap.count(), _puzzleMap.count());

    // 빈칸은 첫번째 칸
    _emptyPiece = 0;

    _shuffle = true;
    // 퍼즐 조각의 5 배를 섞는다
    for (int i = 0; i < PuzzlePieces * 5; ++i)
    {
        // 네 방향으로 임의로 움직인다
        switch (qrand() * 4 / RAND_MAX)
        {
        case 0:
            moveUp();
            break;

        case 1:
            moveDown();
            break;

        case 2:
            moveLeft();
            break;

        case 3:
        default:
            moveRight();
            break;
        }
    }
    _shuffle = false;

    QMapIterator<intint> it(_puzzleMap);
    while (it.hasNext())
    {
        it.next();

        QString text(it.value() == 0 ? QString(" ") :
                                       QString::number(it.value()));

        // 위치를 문자열로 바꾸어 표현
        _pieces.at(it.key())->setText(text);
    }
}


9 번째 줄의 QMap::insert() 는 주어진 key 와 value 를 맵에 추가한다. 이 때, 같은 key 가 이미 있다면, 옛 key 는 새로운 key 로 대체된다. 하나의 key 가 여러 개의 value 를 가질 수 있는 컨테이너가 필요하다면 QMultiMap 클래스를 쓰자.

14~39 번째 줄에서 퍼즐 조각을 마구잡이로 섞는다. 굳이 이렇게 하는 이유는 퍼즐 조각을 직접 지정하게 되면, 풀리지 않는 퍼즐이 만들어질 수도 있기 때문이다.

41 번째 줄의 QMapIterator 는 QMap 을 자바 스타일로 순환하기 위한 클래스이다. 42 번째 줄의 QMapIterator::hasNext() 는 항목이 하나라도 남아 있으면 참을 돌려준다. 44 번째 줄의 QMapIterator::next() 는 다음 항목으로 이동하고, 그 항목을 돌려준다.

46 번째 줄의 QMapIterator::value() 는 해당 맵의 value 를 돌려주고, 50 번째 줄의 QMapIIterator::key() 는 해당 맵의 key 를 돌려준다.

2.8 pieceClicked()

퍼즐 조각을 클릭했을 때 호출된다. 주변에 빈 칸이 있으면 퍼즐 조각을 움직인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Puzzle::pieceClicked()
{
    // 시그널을 보낸 위젯의 위치를 얻는다
    int piece = _pieces.indexOf(qobject_cast<QPushButton *>(sender()));

    // 빈 칸이면 할 일 없다
    if (piece == _emptyPiece)
        return;

    // 윗쪽이 비었으면, 위로 옮김
    if (upPiece(piece) == _emptyPiece)
        moveUp();
    // 아랫쪽이 비었으면 아래로 옮김
    else if (downPiece(piece) == _emptyPiece)
        moveDown();
    // 왼쪽이 비었으면 왼쪽으로 옮김
    else if (leftPiece(piece) == _emptyPiece)
        moveLeft();
    // 오른쪽이 비었으면 오른쪽으로 옮
    else if (rightPiece(piece) == _emptyPiece)
        moveRight();
}


4 번째 줄의 QObject::sender() 함수를 주목하자. 앞서 initConnections() 에서 우리는 여러 위젯의 시그널을 하나의 슬롯에 연결했다. 따라서 이 슬롯이 호출될 때, 어떤 위젯의 시그널 때문에 호출됐는지 알 수 없다. 이를 해결하기 위한 것이 QObject::sender() 함수이다. OOP 원칙에는 어긋나지만, 꽤나 유용한 함수이다.

qobject_cast 는 QObject 객체의 유형을 바꿔준다. QObject::sender() 는 QObject * 를 돌려주므로, QPushButton * 으로 바꿔줄 필요가 있다.

2.9 upPiece(), downPiece(), leftPiece(), rightPiece()

주어진 위치에서 위/아래/왼쪽/오른쪽 방향에 있는 퍼즐 조각의 위치를 돌려준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int Puzzle::upPiece(int from)
{
    return qMax(from - PuzzleCols, from % PuzzleCols);
}

int Puzzle::downPiece(int from)
{
    return qMin(from + PuzzleCols,
                PuzzlePieces - PuzzleCols + (from % PuzzleCols));
}

int Puzzle::leftPiece(int from)
{
    return (from % PuzzleCols) == 0 ? from : from - 1;
}

int Puzzle::rightPiece(int from)
{
    return (from % PuzzleCols) == PuzzleCols - 1 ? from : from + 1;
}


qMax()qMin() 은 각각 둘 중의 큰 값과 작은 값을 돌려준다.

2.10 moveUp(), moveDown(), moveLeft(), moveRight()

퍼즐 조각을 위/아래/왼쪽/오른쪽으로 이동시킨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void Puzzle::moveUp()
{
    movePiece(downPiece(_emptyPiece));
}

void Puzzle::moveDown()
{
    movePiece(upPiece(_emptyPiece));
}

void Puzzle::moveLeft()
{
    movePiece(rightPiece(_emptyPiece));
}

void Puzzle::moveRight()
{
    movePiece(leftPiece(_emptyPiece));
}


2.11 movePiece()

퍼즐 조각을 이동시키고, 성공 여부를 확인한다.

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
void Puzzle::movePiece(int from)
{
    // 움직이려는 조각이 빈 칸이 아니면
    if (from != _emptyPiece)
    {
        int to = _emptyPiece;

        // 움직이려는 조각과 빈칸을 바꿈
        _puzzleMap.insert(to, _puzzleMap.value(from));
        _puzzleMap.insert(from, 0);

        QPushButton *toPiece = _pieces.at(to);
        QPushButton *fromPiece = _pieces.at(from);

        toPiece->setText(fromPiece->text());
        fromPiece->setText(" ");

        // 빈 칸을 옮김
        _emptyPiece = from;

        // 섞고 있지 않으면
        if (!_shuffle)
        {
            // 움직인 횟수 증가
            ++_tryCount;

            // 결과 확인
            checkResult();
        }
    }
}


2.12 checkResult()

퍼즐이 완성되었는지 확인한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Puzzle::checkResult()
{
    bool result = true;

    QMapIterator<intint> it(_puzzleMap);

    while (it.hasNext())
    {
        it.next();

        // 마지막 조각은 빈 칸(0)
        if (it.key() + 1 != it.value()
                && ((it.key() + 1 != PuzzlePieces) || it.value() != 0))
        {
            result = false;
        }
    }

    if (result)
        QMessageBox::information(this, qApp->applicationName(),
                                 tr("%1 번의 이동만에 성공하였습니다!!!")
                                    .arg(_tryCount));
}


12~13 번째 줄에서 실제로 완성되었는지 확인한다. 숫자는 1부터 시작하므로, 항상 value() 는 key() 보다 1 크다. 하지만, 빈칸은 마지막에 위치하므로, 마지막 칸의 value() 는 0 이다.

2.13 keyProcessEvent()

위젯에 전달된 키보드 입력을  처리한다. 방향키에 따라 퍼즐 조각을 이동시킨다.

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
void Puzzle::keyPressEvent(QKeyEvent *e)
{
    switch (e->key())
    {
    case Qt::Key_Up:    // 윗쪽 방향 키면
        moveUp();       // 위로 옮김
        break;

    case Qt::Key_Down:  // 아랫쪽 방향 키면
        moveDown();     // 아래로 옮김
        break;

    case Qt::Key_Left:  // 윗쪽 방향 키면
        moveLeft();     // 왼쪽으로 옮김
        break;

    case Qt::Key_Right: // 오른쪽 방향 키면
        moveRight();    // 오른쪽으로 옮김
        break;

    default:            // 나머지 이벤트는
        e->ignore();    // 부모 위젯에 전달
        return;
    }

    e->accept();    // 처리했음
}


3번째 줄의 QKeyEvent::key() 는 전달된 이벤트의 키코드를 돌려준다. 키코드는 Qt::Key 를 보자.

22 번째 줄의 QEvent::ignore() 는 전달된 위젯에서 이벤트가 처리되지 않았다는 것을 뜻한다. 이렇게 되면 이 이벤트는 부모 위젯으로 전달된다.

26 번째 줄의 QEvent::accept() 는 이벤트가 처리되었다는 것을 뜻한다. 따라서 이 이벤트는 더이상 전파되지 않는다.

3. 마무리하면서...

숫자 퍼즐을 만들면서 메뉴와 키보드 입력 처리에 대해 간단히 알아보았다. Qt 도움말을 활용하면 더욱 풍부한 설명을 볼 수 있으니, 도움말은 꼭 챙기자.

다음은 숫자 퍼즐의 실행 모습이다.


전체 소스는 여기에서 확인하자.





댓글

이 블로그의 인기 게시물

토렌트: < 왕좌의 게임 > 시즌 1 ~ 시즌 8 완결편 마그넷

토렌트: NGC < 코스모스 > 우리말 더빙 전편(1편~13편) 마그넷

토렌트: < 스타워즈 > Ep.1 ~ Ep.6 마그넷