Qt Quickを使って15パズルを作成したのでメモしておく

今回、Qt Quickを用いて15パズルゲームを作成したので、困ったところなどをメモしておこうかなと思います。

ソースコードは以下になります。

github.com

なぜ作った?

元々大学3年時代、卒業研究でC++を使って実験プログラムを作ることになりました。

学科がプログラミングを専門とする専攻ではなかったため、プログラミングは不慣れでした。

そこで、C++の勉強のために15パズルゲームを作ろうとしました。

ですが、ままならないまま実験プログラムの作成時期になってしまったため放置されていたんですね。

それを最近掘り起こして動くようにした次第になります。

プログラムの解説

ここからは実際のコードについて説明していきます。

👆のソースコードのリンクを開いて対比してみて貰えるといいと思います。

CMake設定

CMakeプロジェクトとして作成しています。

# CMakeLists.txt
add_compile_options(-Wall -O3)

最適化オプションは通常はO2で十分のようですが、イキってO3にしてます。

# CMakeLists.txt
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)

上記の設定をしておくと Qt 周りの設定をよしなにしてくれるようです。

プロジェクト構成

今回はMVVMパターンを意識して実装しました。

プロジェクト構造

modelsには15パズルのロジック処理を、view_modelsには画面操作の処理を、viewには画面定義のqmlファイルが置かれています。

Qt実装

QtはWidgetではなく、Qmlを使うQuickを使用しています。

QmlはJavaScriptベースのUI記述言語で、イベント処理などにJavaScriptを使用できますが、今回はC++の処理をバインドして使っています。

// main.cpp
MainWindow mainWindow;
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("mainWindow", &mainWindow);

まず、main.cppで QQmlApplicationEngine の定義をする時に、ViewModelとなるクラスを紐づけておきます。

この時、 engine の前に mainWindow を宣言しないとアプリケーション終了時に engine より先に mainWindow がメモリから消えて、propertyがnullだという実行時エラーが出るので注意です。

// MainWindow.qml
Column {
  anchors.centerIn: parent
  spacing: 10
  focus: true
  Keys.onLeftPressed: mainWindow.MoveHiddenPanelToLeft()
  Keys.onRightPressed: mainWindow.MoveHiddenPanelToRight()
  Keys.onUpPressed: mainWindow.MoveHiddenPanelToUp()
  Keys.onDownPressed: mainWindow.MoveHiddenPanelToDown()

また、画面定義でのキーボード入力のイベントにはKeysプロパティを使うのですが、これがQtQuick.Itemを継承したコンポーネントじゃないと正しく実装出来ないようです。

Keysプロパティの設定には focustrue にしておく必要があります。

この設定が ApplicationWindow コンポーネントにはないんですね。

// MainWindow.qml
Grid {
  columns: 4
  rows: 4
  spacing: 5
  anchors.horizontalCenter: parent.horizontalCenter

  Repeater {
    model: mainWindow.Panels
    delegate: Rectangle {
      width: 50
      height: 50
      color: modelData == 16 ? "green" : "white"
      border.color: modelData == 16 ? "transparent" : "green"

      Text {
        text: modelData == 16 ? "" : modelData
        font.pixelSize: 36
        color: "black"
        anchors.fill: parent
        verticalAlignment: Text.AlignVCenter
        horizontalAlignment: Text.AlignHCenter
      }
    }
  }
}

パネルの実装には Grid コンポーネントRepeater コンポーネントを組み合わせて実装してます。

条件分岐による表示の切り替えはC++側では難しいのでJavaScriptで処理しています。*1

そして、model プロパティにC++側で用意した Q_PROPERTY 定義のオブジェクトを指定します。

// MainWindow.hpp
Q_PROPERTY(QVariantList Panels READ GetPanels WRITE SetPanels NOTIFY PanelsChanged)

Qmlで扱えるように型は QVariantList というものを使用しています。

// MainWindow.cpp
QVariantList MainWindow::GetPanels() const
{
  QVariantList list;
  for (auto value : _board.GetPanelsAsIntVector())
  {
    list.append(QVariant::fromValue(value));
  }

  return list;
}

内部では Board クラスとしてパネル情報を持っているので、リードシグナルとなる GetPanels メソッド内で変換しています。

ロジック実装

// Board.hpp
std::vector<Panel> board;

内部的には std::vector の一次元配列を用いて実装しています。

Panel クラスというのを定義していますが、過去の名残で数字を持つだけの意味をなさないクラスになってます。

void Board::Initialize()
{
  unsigned seed =
      std::chrono::system_clock::now().time_since_epoch().count();

  std::vector<int> numbers;
  for (int num = 1; num <= SIZE * SIZE; num++)
  {
    numbers.push_back(num);
  }

  auto engine = std::default_random_engine(seed);
  std::shuffle(numbers.begin(), numbers.end(), engine);

  board.clear(); // 再初期化の時のため

  for (int x = 0; x < SIZE; x++)
  {
    for (int y = 0; y < SIZE; y++)
    {
      Panel panel(numbers[x * SIZE + y]);
      board.push_back(panel);
    }
  }
}

ゲーム起動時にパネル情報をランダムに配置するようにしています。

C++には時間をシードとして配列をランダム配置する処理が標準テンプレートライブラリにあるのでそれを用いてシャッフルしてます。

void Board::MovePanel(MoveDirection direction)
{
  auto hidden = std::find_if(board.begin(), board.end(), [](Panel p)
                             { return p.IsHidden(); });
  auto hiddenIndex = std::distance(board.begin(), hidden);

  auto targetIndex = -1;
  switch (direction)
  {
  case MoveDirection::Left:
    targetIndex = hiddenIndex - 1;
    break;
  case MoveDirection::Right:
    targetIndex = hiddenIndex + 1;
    break;
  case MoveDirection::Up:
    targetIndex = hiddenIndex - SIZE;
    break;
  case MoveDirection::Down:
    targetIndex = hiddenIndex + SIZE;
    break;
  default:
    throw std::invalid_argument("無効な方向が引き渡されました。");
  }

  if (targetIndex >= 0 &&                            // 左上端を突き抜けないこと
      targetIndex < SIZE * SIZE &&                   // 右下端を突き抜けないこと
      ((hiddenIndex / SIZE == targetIndex / SIZE) || // 同行に存在すること
       (hiddenIndex % SIZE == targetIndex % SIZE)))  // 同列に存在すること
  {
    std::swap(board[hiddenIndex], board[targetIndex]);
  }
}

パネルの移動は少しだけトリッキーです。

一次元配列であることを意識して先頭と末尾を超えていないことに加えて、同行にあるということはどういうことか、同列にあるということはどういうことかを意識して条件を設定してます。

あとはただの一次元配列なのでスワップするだけです。

そして、この処理を MainWindow.cpp の各シグナルのメソッド内で実行するだけですね。

作ってみた感想

学生の時は本当にプログラミングが苦手でした。

あれから約10年ほどで2週間くらいで完成出来たので自分の成長を感じました。

久しぶりに C++ に触れてプログラミングを学び始めた頃を思い出せたのでよかったです。

今後 Qt をあえて使うことはあまりないかもですが、気が向いたときにまた触れてみたいと思います。

*1:とは言ってもただの三項演算子