本页内容

SCXML 数独

演示了如何在 SCXML 中使用独立的 JavaScript 文件。

运行示例

您可以从以下位置运行该示例:

数独功能

数独示例的屏幕截图

我们的数独包含以下功能:

  • 游戏开始时及结束时,数独将进入“idle ”状态。在此状态下,玩家可以查看上一局游戏是否成功结束。随后,状态机将处于“idle ”状态的两个子状态之一:分别是“solved ”或“unsolved ”。在“idle ”状态下,玩家还可以选择想要解题的数独网格。 此时数独网格处于禁用状态,系统将忽略用户的任何交互操作。
  • 玩家点击“Start ”按钮后,数独进入“playing ”状态,并准备好接受用户在棋盘上的交互。
  • 当游戏处于“playing ”状态时,如果玩家点击“Stop ”按钮,游戏结束并进入“idle ”状态的子状态“unsolved ”。如果玩家成功解开了当前谜题,游戏将自动结束并进入“idle ”状态的子状态“solved ”,以表示成功。
  • 游戏棋盘由 81 个按钮组成,排列成 9×9 的网格。具有初始值的按钮在游戏过程中保持禁用状态。玩家只能与初始为空的按钮进行交互。每次点击按钮,其数值将增加 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存储当前游戏的初始状态。它是一个9×9的二维数组,其中各单元格包含数独的初始数字。值为零表示该单元格初始为空。
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 按钮开始游戏时,也会触发此事件。 当玩家完成当前游戏时,我们不希望触发该事件,因为我们仍希望显示上一局游戏中的已填满数独网格。因此,该事件由三个不同的上下文触发,并在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 变量。

每当我们需要通知 GUI 网格内容已发生变化,且 GUI 应根据传入的值进行自我更新时,系统内部就会触发update 事件。该事件在game 状态的另一个无目标过渡中被捕获:

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

我们发送外部事件updateGUI ,该事件在C++ 代码中被拦截。updateGUI 事件附带额外数据,这些数据在<param> 元素中指定。我们传递两个参数,可通过currentState 和initState 名称从外部访问。传递给它们的实际值分别等于数据模型中的currentState 和initState 变量,这些变量由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部分发出的事件:start 和setup 。每当接收到start 事件,我们仅会切换至playing 状态。当接收到setup 事件时,我们预期GUI部分已向我们发送了待求解的新网格。 网格的新初始状态应通过_event.data 的initState 字段传递。我们将传递的值赋给数据模型中定义的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 、undo 和stop 。

当玩家点击某个已启用的数独格子时,会触发tap 事件。该事件预计包含指定格子坐标的附加数据,这些坐标会通过_event.data 中的x 和y 字段传递进来。首先,我们通过调用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]);
}

该函数会将传入网格单元格的值加1,并将新操作添加到撤销堆栈历史记录中。

在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() 的主循环被调用九次。在每次迭代中,我们会构建三个数字列表,分别代表网格中的一行、一列和一个方格,并为它们调用isOK() 。当所有27个列表均通过验证时,即表示网格已正确求解,此时我们返回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() 函数会从历史记录中移除最后一步(如有),并将由该步操作坐标所描述的单元格的当前值减1。

playing 状态还准备好响应GUI在玩家点击Stop 按钮时发出的stop 事件。此时,我们只需激活idle 状态即可。

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

C++ 部分:构建图形用户界面

应用程序的 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 ` 信号连接到一个 lambda 表达式,该表达式会向状态机提交 `tap ` 事件,并传递按钮的坐标。

随后,我们在网格中添加一些水平线和垂直线,以便将按钮分组为3×3的方格。

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

我们创建Start / Stop 按钮,并将它的clicked信号连接到一个lambda表达式,该表达式会根据状态机是否处于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 键下的位置,并向状态机提交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) {
        ...
    });

随后,我们订阅了状态机进入或离开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

© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.