SCXML 数独
演示如何在 SCXML 中使用单独的 javascript 文件。
运行示例
要运行来自 Qt Creator打开Welcome 模式,然后从Examples 中选择示例。更多信息,请参阅Qt Creator: 教程:构建并运行。
数独功能
我们的数独包含以下功能:
- 游戏开始和结束时,数独会进入
idle
状态。在该状态下,玩家可以查看上一局游戏是否成功结束。然后,状态机分别进入idle
状态的两个子状态之一:solved
或unsolved
。在idle
状态下,玩家还可以选择要解决的数独网格。网格将被禁用,用户交互将被忽略。 - 玩家点击Start 按钮后,数独游戏就会进入
playing
状态,用户可以在棋盘上进行互动。 - 当游戏处于
playing
状态,玩家点击Stop 按钮后,游戏结束并进入unsolved
子状态的idle
状态。如果玩家成功解出当前谜题,游戏自动结束,并进入idle
状态的子状态solved
,表示游戏成功。 - 棋盘由 81 个按钮组成,按 9x9 的网格排列。在游戏过程中,带有初始值的按钮会一直处于禁用状态。玩家只能与初始值为空的按钮互动。每点击一次按钮,其数值就增加一次。
- 在游戏过程中,Undo 按钮,方便玩家使用。
SCXML 部分:内部逻辑描述
sudoku.scxml文件描述了数独游戏所处状态的内部结构,定义了状态之间的转换,并在转换发生时触发相应的脚本函数。它还通过发送事件、监听即将发生的事件并对其做出反应来与图形用户界面部分进行通信。
我们使用 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 单元的二维数组,包含初始数独数字。数值为 0 表示单元格初始为空。 |
currentState | 保存当前游戏的状态。它与initState 变量类似,初始时包含相同的内容。不过,当玩家开始在空单元格中输入数字时,该变量会相应更新,而initState 变量则保持不变。 |
undoStack | 保存棋手移动的历史记录。它是最后一次移动的单元格坐标列表。游戏过程中每次新的修改都会在列表中添加一对 x 和 y 坐标。 |
上述变量与sudoku.js
文件中定义的脚本辅助函数共享:
<script src="sudoku.js"/>
在进行转换或对图形用户界面发送的事件做出反应时,我们会调用其中定义的一些函数。
前面提到的所有可能状态都定义在根状态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
变量。
每当我们要通知图形用户界面网格内容已更改,图形用户界面应根据传递的值进行更新时,就会在内部引发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
状态下,我们会对图形用户界面发送的可能事件做出反应: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 个列表都没有问题时,网格就正确解出了,我们将返回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()
函数会删除历史记录中的最后一次移动(如果有的话),并递减这次移动的坐标所描述的单元格的当前值。
当玩家按下Stop 按钮时,playing
状态也将为图形用户界面发送的stop
事件做好准备。在这种情况下,我们只需激活idle
状态即可。
此外,我们还会拦截内部发送的solved
事件,并在这种情况下激活solved
状态。
C++ 部分:构建图形用户界面
应用程序的 C++ 部分由一个MainWindow
类组成,该类构建图形用户界面并将其与 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 XML 从sudoku.scxml
文件自动生成的状态机类。该类还包含指向某些图形用户界面元素的指针。
MainWindow::MainWindow(QScxmlStateMachine *machine, QWidget *parent) : QWidget(parent), m_machine(machine) {
MainWindow
类的构造函数实例化应用程序的图形用户界面部分,并存储指向所传递状态机的指针。它还初始化 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
事件。
随后,我们在网格中添加了一些水平线和垂直线,以便将按钮组合成 3x3 的方框。
connect(m_startButton, &QAbstractButton::clicked, this, [this]() { if (m_machine->isActive("playing")) m_machine->submitEvent("stop"); else m_machine->submitEvent("start"); });
我们创建Start / Stop 按钮,并将其点击信号连接到一个 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
关键字下的变体映射中,作为 int 变体列表,然后我们会向状态机提交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
状态时发出的信号,并相应地更新图形用户界面的某些部分。我们还会连接到状态机的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 类。我们启动状态机、显示主窗口并执行应用程序。
© 2025 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.