SCXML 数独

SCXMLで別のjavascriptファイルを使う方法を示します。

例の実行

Qt Creator からサンプルを実行するには、Welcome モードを開き、Examples からサンプルを選択します。詳細については、Building and Running an Example を参照してください。

数独の機能

Screenshot of the Sudoku example

私たちの数独には以下の機能があります:

  • ゲーム開始時およびゲーム終了時、数独はidle の状態になります。この状態で、プレイヤーは前回のゲームが正常に終了したかどうかを見ることができます。その後、ステートマシンはidle 状態の子状態であるsolved またはunsolved のいずれかになる。idle 状態では、プレイヤーは解きたい数独グリッドを選択することもできる。グリッドは無効化され、ユーザのインタラクションは無視される。
  • プレイヤーがStart ボタンをクリックすると、数独はplaying の状態になり、ボード上でユーザーとのインタラクションができるようになります。
  • ゲームがplaying の状態で、プレーヤーがStop ボタンをクリックすると、ゲームは終了し、unsolved の子ステートidle に入ります。プレーヤーが現在のパズルをうまく解いた場合、ゲームは自動的に終了し、成功を示すidle ステートの子ステートsolved に入る。
  • 盤面は9x9のグリッドに並べられた81個のボタンで構成されている。初期値が与えられたボタンは、ゲーム中も無効のままである。プレイヤーは、最初は空のボタンとしか対話できない。ボタンをクリックするごとに、その値は1ずつ増加する。
  • ゲーム中、プレイヤーの便宜のためにUndo

SCXMLパート:内部ロジックの説明

sudoku.scxmlファイルは、数独ゲームの状態の内部構造を記述し、状態間の遷移を定義し、遷移が起こったときに適切なスクリプト関数をトリガーします。また、イベントを送信したり、次のイベントをリスニングしてそれらに反応することで、GUIパートと通信します。

ECMAScriptのデータモデルを使用する:

<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0"
       name="Sudoku" datamodel="ecmascript">

以下の変数を宣言する:

    <datamodel>
        <data id="initState"/>
        <data id="currentState" expr="[[]]"/>
        <data id="undoStack"/>
    </datamodel>
変数説明
initState現在のゲームの初期状態を保持する。これは9x9のセルの2次元配列で、初期の数独の数字が格納されている。ゼロの値は、セルが初期状態では空であることを意味する。
currentState現在のゲームの状態を保持する。initState 変数と似ており、初期状態では同じ内容が格納されている。しかし、プレイヤーが空のセルに数字を入力し始めると、この変数はそれに応じて更新されるが、initState 変数は変更されない。
undoStackプレイヤーの手の履歴を保持する。これは最後にタッチされたセルの座標のリストである。ゲーム中に新しい変更を加えるたびに、xとyの座標のペアがこのリストに追加される。

上記の変数は、sudoku.js ファイルで定義されたスクリプトヘルパー関数と共有される:

    <script src="sudoku.js"/>

そこで定義された関数のいくつかを、トランジション時やGUIから送られるイベントに反応して呼び出します。

前述のすべての可能なステートは、ルート・ステートgame に定義されています。

    <state id="game">
        <onentry>
            <raise event="restart"/>
        </onentry>
        <state id="idle">
            ...
            <state id="unsolved"/>
            <state id="solved"/>
        </state>
        <state id="playing">
            ...
        </state>
        ...
    </state>

数独の例が開始されると、ステートマシンはgame 状態に入り、アプリケーションが終了するまでこの状態に留まります。この状態に入ると、内部的にrestart イベントが発生します。このイベントは、プレイヤーが現在の数独グリッドを変更したときや、Start ボタンを押してゲームを開始したときにも発生します。最後のゲームプレイで埋められたグリッドを表示したいので、現在のゲームが終了したときにはこのイベントを送らないようにします。したがって、このイベントは3つの異なるコンテキストから発生し、game ステートのターゲットなしのトランジションで一度だけ内部的に捕捉される:

        <transition event="restart">
            <script>
                restart();
            </script>
            <raise event="update"/>
        </transition>

restart イベントをキャッチすると、sudoku.js ファイルで定義されているヘルパーrestart() スクリプト メソッドを呼び出し、update イベントを追加で内部的に発生させます。

function restart() {
    for (var i = 0; i < initState.length; i++)
        currentState[i] = initState[i].slice();
    undoStack = [];
}

restart() 関数は、currentState 変数にinitState を代入し、undoStack 変数をクリアする。

update イベントは、グリッドの内容が変更され、渡された値に従ってGUIが更新されることをGUIに通知したいときに、内部的に発生します。このイベントは、game ステートの別のターゲットなし遷移でキャッチされます:

        <transition event="update">
            <send event="updateGUI">
                <param name="currentState" expr="currentState"/>
                <param name="initState" expr="initState"/>
            </send>
        </transition>

外部イベントupdateGUI を送信し、C++コードでインターセプトします。updateGUI イベントは、<param> 要素の内部で指定された追加データを備えています。currentStateinitState の名前を通して外部からアクセスできる2つのパラメータを渡します。実際に渡される値は、expr 属性で指定されたデータモデルの変数currentStateinitState にそれぞれ等しくなります。

        <state id="idle">
            <transition event="start" target="playing"/>
            <transition event="setup" target="unsolved">
                <assign location="initState" expr="_event.data.initState"/>
                <raise event="restart"/>
            </transition>
            <state id="unsolved"/>
            <state id="solved"/>
        </state>

idle 状態のとき、GUI パートから送信される 2 つのイベントstartsetup に反応します。start イベントを受信するたびに、playing 状態に遷移します。setup イベントを受け取ると、GUI 部分が解くべき新しいグリッドを送ってきたと判断します。グリッドの新しい初期状態は、_event.datainitState フィールドを通して渡されることが期待されます。渡された値をデータモデルで定義されたinitState 変数に代入し、グリッドのコンテンツを再起動します。

        <state id="playing">
            <onentry>
                <raise event="restart"/>
            </onentry>
            <transition event="tap">
                <if cond="isValidPosition()">
                    <script>
                        calculateCurrentState();
                    </script>
                    <if cond="isSolved()">
                        <raise event="solved"/>
                    </if>
                    <raise event="update"/>
                </if>
            </transition>
            ...
        </state>

playing の状態に入るたびに、グリッドの内容をリセットします。なぜなら、前のゲームプレイの内容をまだ表示している可能性があるからです。playing 状態では、GUI から送られる可能性のあるイベントに反応します:tap undostop

tap イベントは、プレイヤーが有効化された数独マスの1つを押したときに送られます。このイベントにはセルの座標を指定する追加データが含まれることが期待され、このデータは_event.dataxy フィールドを通して渡される。まず、isValidPosition() スクリプト関数を呼び出して、渡された座標が有効かどうかをチェックする:

function isValidPosition() {
    var x = _event.data.x;
    var y = _event.data.y;
    if (x < 0 || x >= initState.length)
        return false;
    if (y < 0 || y >= initState.length)
        return false;
    if (initState[x][y] !== 0)
        return false;
    return true;
}

座標が負でなく、グリッドより大きくないことを確認する。さらに、座標が最初は空白のセルを指しているかどうかをチェックする。

渡された座標が正しいことを確認したら、calculateCurrentState() スクリプト関数を呼び出します:

function calculateCurrentState() {
    if (isValidPosition() === false)
        return;
    var x = _event.data.x;
    var y = _event.data.y;
    var currentValue = currentState[x][y];
    if (currentValue === initState.length)
        currentValue = 0;
    else
        currentValue += 1;
    currentState[x][y] = currentValue;
    undoStack.push([x, y]);
}

この関数は渡されたグリッドのセルの値をインクリメントし、新しい移動をアンドゥスタックの履歴に追加します。

calculateCurrentState() 関数の実行が終わった直後に、isSolved() スクリプト関数を呼び出して、グリッドがすでに解かれているかどうかをチェックします:

function isOK(numbers) {
    var temp = [];
    for (var i = 0; i < numbers.length; i++) {
        var currentValue = numbers[i];
        if (currentValue === 0)
            return false;
        if (temp.indexOf(currentValue) >= 0)
            return false;
        temp.push(currentValue);
    }
    return true;
}

function isSolved() {
    for (var i = 0; i < currentState.length; i++) {
        if (!isOK(currentState[i]))
            return false;

        var column = [];
        var square = [];
        for (var j = 0; j < currentState[i].length; j++) {
            column.push(currentState[j][i]);
            square.push(currentState[Math.floor(i / 3) * 3 + Math.floor(j / 3)]
                                    [i % 3 * 3 + j % 3]);
        }

        if (!isOK(column))
            return false;
        if (!isOK(square))
            return false;
    }
    return true;
}

isSolved() 関数は、グリッドが正しく解かれていればtrue を返します。各行、各列、各 3x3 正方形をチェックする必要があるので、isOK() ヘルパー関数を定義します。この関数は数字のリストを受け取り、渡されたリストに一意な数字が含まれていて、数字がゼロに等しい、つまり空のセルがない場合にtrue を返します。isSolved() のメイン・ループは9回呼び出される。すべての繰り返しで、グリッドの行、列、正方形を表す3つの数値リストを作成し、isOK() 。27個のリストすべてに問題がなければ、グリッドは正しく解かれ、true を返す。

SCXML ファイルに戻り、isSolved()true を返した場合、内部的にsolved イベントを発生させます。グリッドの変更をGUIに通知する必要があるため、正しく移動した場合の最後の命令はupdate イベントを発生させることです。

        <state id="playing">
            ...
            <transition event="undo">
                <script>
                    undo();
                </script>
                <raise event="update"/>
            </transition>
            <transition event="stop" target="idle"/>
            <transition event="solved" target="solved"/>
        </state>

playing 状態のとき、GUI から送られるundo イベントにも反応します。この場合、undo() スクリプト関数を呼び出し、GUI に更新の必要性を通知します。

function undo() {
    if (!undoStack.length)
        return;

    var lastMove = undoStack.pop();
    var x = lastMove[0];
    var y = lastMove[1];
    var currentValue = currentState[x][y];
    if (currentValue === 0)
        currentValue = initState.length;
    else
        currentValue -= 1;
    currentState[x][y] = currentValue;
}

undo() 関数は履歴から最後の手を削除し(もしあれば)、この手から取得した座標で記述されたセルの現在値をデクリメントします。

playing の状態は、プレイヤーがStop ボタンを押したときに GUI から送られるstop イベントにも対応しています。この場合、単純にidle

さらに、solved イベントをインターセプトし、solved ステートをアクティブにします。

C++パート:GUIの構築

アプリケーションの C++ 部分は GUI を構築し、SCXML 部分と接着するMainWindow クラスで構成されます。このクラスはmainwindow.h で宣言されています。

class MainWindow : public QWidget
{
    Q_OBJECT

public:
    explicit MainWindow(QScxmlStateMachine *machine, QWidget *parent = nullptr);

private:
    QScxmlStateMachine *m_machine = nullptr;
    QList<QList<QToolButton *>> m_buttons;
    QToolButton *m_startButton = nullptr;
    QToolButton *m_undoButton = nullptr;
    QLabel *m_label = nullptr;
    QComboBox *m_chooser = nullptr;
};

MainWindow クラスはsudoku.scxml ファイルから Qt が自動的に生成したステートマシンクラスであるQScxmlStateMachine *m_machine へのポインタを保持します。また、いくつかのGUI要素へのポインタも保持しています。

MainWindow::MainWindow(QScxmlStateMachine *machine, QWidget *parent) :
    QWidget(parent),
    m_machine(machine)
{

MainWindow クラスのコンストラクタは、アプリケーションのGUI部分をインスタンス化し、渡されたステートマシンへのポインタを格納します。また、GUI部分を初期化し、GUI部分とステートマシンの通信インタフェースを接続することで、GUI部分とステートマシンを接着します。

            connect(button, &QToolButton::clicked, this, [this, i, j]() {
                QVariantMap data;
                data.insert(u"x"_s, i);
                data.insert(u"y"_s, j);
                m_machine->submitEvent("tap", data);
            });

まず、81個のボタンを作成し、そのclicked シグナルを、ボタンの座標を渡すステート・マシンにtap イベントをサブミットするラムダ式に接続する。

その後、ボタンを3x3のボックスにグループ化するために、グリッドに水平線と垂直線を追加する。

    connect(m_startButton, &QAbstractButton::clicked, this, [this]() {
        if (m_machine->isActive("playing"))
            m_machine->submitEvent("stop");
        else
            m_machine->submitEvent("start");
    });

Start / Stop ボタンを作成し、そのクリックされたシグナルをラムダ式に接続する。このラムダ式は、マシンがplaying の状態にあるかどうかに応じて、それぞれstop またはstart イベントを送信する。

グリッドが解けているかどうかを知らせるラベルと、クリックされるたびにundo イベントを送信するUndo ボタンを作成する。

    connect(m_undoButton, &QAbstractButton::clicked, this, [this]() {
        m_machine->submitEvent("undo");
    });

そして、解くべきグリッドの名前を入れるコンボボックスを作成する。これらのグリッドは、アプリケーションのコンパイル済みリソースの:/data ディレクトリから読み込まれます。

    connect(m_chooser, &QComboBox::currentIndexChanged, this, [this](int index) {
        const QString sudokuFile = m_chooser->itemData(index).toString();
        const QVariantMap initValues = readSudoku(sudokuFile);
        m_machine->submitEvent("setup", initValues);
    });

    const QVariantMap initValues = readSudoku(
                m_chooser->itemData(0).toString());
    m_machine->setInitialValues(initValues);

プレイヤーがコンボボックスのグリッドを変更するたびに、initValues キー下の variant map にグリッドの内容を int variant のリストとして格納し、setup イベントをグリッドの内容を渡すステートマシンに送信します。最初は、リストから利用可能な最初のグリッドを読み出し、初期グリッドとして直接 sudoku ステートマシンに渡します。

    m_machine->connectToState("playing", [this] (bool playing) {
        ...
    });

    m_machine->connectToState("solved", [this](bool solved) {
        if (solved)
            m_label->setText(tr("SOLVED !!!"));
        else
            m_label->setText(tr("unsolved"));
    });

    m_machine->connectToEvent("updateGUI", [this](const QScxmlEvent &event) {
        ...
    });

その後、マシンがplaying またはsolved のステートに入ったり出たりするたびに送信されるシグナルに接続し、それに応じていくつかのGUIパーツを更新します。また、ステートマシンのupdateGUI イベントに接続し、渡されたセルの状態に応じてすべてのボタンの値を更新する。

#include "mainwindow.h"
#include "sudoku.h"

#include <QtWidgets/qapplication.h>

int main(int argc, char **argv)
{
    QApplication app(argc, argv);

    Sudoku machine;
    MainWindow mainWindow(&machine);

    machine.start();
    mainWindow.show();
    return app.exec();
}

main.cpp ファイルのmain() 関数で、app アプリケーション・オブジェクト、Sudoku ステート・マシン、MainWindow GUI クラスをインスタンス化します。ステート・マシンを起動し、メイン・ウィンドウを表示し、アプリケーションを実行する。

プロジェクト例 @ code.qt.io

©2024 The Qt Company Ltd. 本書に含まれる文書の著作権は、それぞれの所有者に帰属します。 本書で提供されるドキュメントは、Free Software Foundation が発行したGNU Free Documentation License version 1.3に基づいてライセンスされています。 Qtおよびそれぞれのロゴは、フィンランドおよびその他の国におけるThe Qt Company Ltd.の 商標です。その他すべての商標は、それぞれの所有者に帰属します。