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.在芬兰和/或其他国家的商标。所有其他商标均为各自所有者的财产。