SCXML 数独

演示了如何使用与 SCXML 分离的 JavaScript 文件。

运行示例

要从 Qt Creator 运行示例,请打开 欢迎 模式并从 示例 中选择示例。有关更多信息,请访问 构建和运行示例

数独特性

Screenshot of the Sudoku example

我们的数独包含以下特性

  • 初始状态以及在游戏结束时,数独进入 空闲 状态。在该状态下,玩家可以看到他们的上一局游戏是否成功完成。状态机处于 空闲 状态的两个子状态之一:分别是 解决未解决。在 空闲 状态中,玩家还可以选择他们想要解决的数独网格。网格被禁用,用户交互被忽略。
  • 玩家点击 开始 按钮后,数独进入 游戏 状态,并准备好在棋盘上进行用户交互。
  • 当游戏处于 游戏 状态且玩家点击 停止 按钮时,游戏结束并进入 空闲 状态的 未解决 子状态。如果玩家已经成功解决了当前谜题,游戏将自动结束并进入 空闲 状态的 解决 子状态,表示成功。
  • 棋盘由 81 个按钮组成,排列成 9x9 的网格。在游戏过程中,有初始值的按钮保持禁用。玩家只能与初始为空的按钮交互。每次点击按钮都会将其值增加 1。
  • 在游戏过程中,玩家可以方便地使用 撤销 按钮。

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包含当前游戏初始状态的子九宫格数组。其值为零表示单元格初始为空。
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事件。每当玩家更改当前数独网格或按下开始按钮开始游戏时,都会引发此事件。我们不希望在完成当前游戏时发送它,因为我们仍然想显示上次游戏中的填有空格的网格。因此,该事件从三个不同的环境中引发,并在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()函数将initState分配给currentState变量,并清除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名称从外部访问。传递给它们的实际值分别等于数模型中指定的currentStateinitState变量,这些变量由expr属性指定。

        <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部分可能发送的两个事件做出反应: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发送的事件做出反应:tapundostop

当玩家按下一个启用的数独单元格时,会发送tap事件。此事件应包含附加数据,指定单元格的坐标,这些坐标通过_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()辅助函数。此函数接受数字列表,如果传递的列表包含唯一的数字且没有数字等于零(这意味着没有空单元格),则返回trueisSolved()的主循环被调用九次。在每一次迭代中,我们构建表示一行、一列和网格方格的数字列表,并对它们调用isOK()。当所有27个列表都fine时,网格被正确解决,我们返回true

回到我们的SCXML文件,如果isSolved()返回true,我们内部触发solved事件。对于正确的移动,最后的指令是触发update事件,因为我们需要通知GUI关于网格的变化。

        <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状态也准备好对GUI发送的stop事件做出反应,当玩家按下按钮时。在这种情况下,我们只激活idle状态。

此外,我们拦截内部发送的solved事件,并在此情况下激活solved状态。

C++部分:构建GUI

应用程序C++部分由一个MainWindow类组成,该类构建GUI并将其与SCXML部分连接起来。该类在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类保存对QScxmlStateMachine *m_machine的指针,这是一个由Qt自动从sudoku.scxml文件生成的状态机类。它还保存了一些GUI元素的指针。

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

MainWindow类的构造函数实例化了应用程序的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事件提交给状态机并传递按钮坐标的lambda表达式。

之后,我们在网格中添加了一些水平和垂直线,以便将按钮分组到3x3的方框中。

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

我们创建了按钮,并将其点击信号连接到lambda表达式,该表达式根据机器是否处于playing状态提交相应的stopstart事件。

我们创建一个标签来显示网格是否已解决,以及一个撤销按钮,该按钮会在点击时提交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键下的变体映射中,作为一个整数变体的列表的列表,并提交setup事件给状态机,传递网格的内容。最初,我们从列表中读取第一个可用的网格,并将其直接传递给数独状态机作为初始网格。

    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) {
        ...
    });

后来,我们连接到在机器进入或离开playingsolved状态时发出的信号,并相应地更新一些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和GUI类MainWindow。我们启动状态机,显示主窗口,并执行应用程序。

示例项目 @ code.qt.io

© 2024 The Qt Company Ltd. 本文件中包含的文档贡献者是各自所有者的版权。提供的文档根据自由软件基金会发布的GNU自由文档许可协议版本1.3的条款提供。Qt及其相应的标志是The Qt Company Ltd.在芬兰和/或其他国家的商标。所有其他商标均为各自所有者的财产。