SCXML 数独
演示了如何使用与 SCXML 分离的 JavaScript 文件。
运行示例
要从 Qt Creator 运行示例,请打开 欢迎 模式并从 示例 中选择示例。有关更多信息,请访问 构建和运行示例。
数独特性

我们的数独包含以下特性
- 初始状态以及在游戏结束时,数独进入
空闲状态。在该状态下,玩家可以看到他们的上一局游戏是否成功完成。状态机处于空闲状态的两个子状态之一:分别是解决或未解决。在空闲状态中,玩家还可以选择他们想要解决的数独网格。网格被禁用,用户交互被忽略。 - 玩家点击 开始 按钮后,数独进入
游戏状态,并准备好在棋盘上进行用户交互。 - 当游戏处于
游戏状态且玩家点击 停止 按钮时,游戏结束并进入空闲状态的未解决子状态。如果玩家已经成功解决了当前谜题,游戏将自动结束并进入空闲状态的解决子状态,表示成功。 - 棋盘由 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>元素内部。我们传递两个参数,它们可以通过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]);
}此函数增加传递网格单元格的值,并将新的移动添加到撤销栈的历史记录中。
在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个列表都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状态提交相应的stop或start事件。
我们创建一个标签来显示网格是否已解决,以及一个撤销按钮,该按钮会在点击时提交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和GUI类MainWindow。我们启动状态机,显示主窗口,并执行应用程序。
© 2024 The Qt Company Ltd. 本文件中包含的文档贡献者是各自所有者的版权。提供的文档根据自由软件基金会发布的GNU自由文档许可协议版本1.3的条款提供。Qt及其相应的标志是The Qt Company Ltd.在芬兰和/或其他国家的商标。所有其他商标均为各自所有者的财产。