高级语言程序设计2-2 C++大作业
1. 作业题目
- 2048小游戏:通过上下左右键合并数字块,合并得出2048时,游戏胜利;当上下左右操作均无法移动任一数字块,游戏失败。
2. 开发软件
- 系统环境: Windows 10
- 开发环境: Qt Creator 4.3.1
- 编译器环境: MinGW 5.3.0 32bit (C/C++)
- Qt依赖版本: Qt 5.9.0
3. 课题要求
- 学生自选题目,使用C++语言完成一个图形化的小程序。
- 图形化平台不限,可以是MFC、QT等。
- 程序内容主题不限,可以是小游戏、小工具等。
4. 主要流程
4.1 绘制游戏窗口:
4.1.1 QWidget:
- Widget是Qt中创建用户界面的主要元素,它可以显示数据和状态信息,接收用户输入,并为其他应该组合在一起的Widget提供一个容器(可以堆叠盛放其他的Widgets)。最外层的Widget称为Window。
GameBoard::GameBoard(QWidget *parent) : // 由Qwidget继承来的组件
QWidget(parent)
- 在创建游戏面板时,我们在文件初始创建时就可以选中继承对象QWidget
4.1.2 QLayout:
- QLayout提供了便捷的页面布局方法:
- QHBoxLayout将Widget按水平排列,从左到右。
- QVBoxLayout将Widget按垂直排列,从上到下。
- QGridLayout在二维网格中布局Widget。Widget可以占用多个单元格。
// 设置主窗口垂直布局
mainLayout = new QVBoxLayout(); // QLayout将Widget呈现垂直排列
setLayout(mainLayout);
// 创建游戏面板为网格状布局
boardLayout = new QGridLayout(); // QLayout将Widget呈现二维网格排列
4.1.3 QLabel:
- QLabel用于显示文本或图像。不提供用户交互功能。
- QLabel可以使用类似
CSS
的样式设置,通过方法setStyleSheet
启用样式设置
auto cell = new QLabel();
cell->setText("2");
cell->setAlignment(Qt::AlignCenter);
cell->setStyleSheet("QLabel { background: rgb(238,228,218); color: rgb(119,110,101); font: bold; border-radius: 10px; font: 40pt; }");
- 上面代码将cell定义为一个QLabel,设置块中文本为2,居中,同时设置了QLabel样式,对颜色、背景和字体进行进一步调整。
4.1.4 显示分数:
- 在游戏面板下方,显示分数score,且右对齐。
// 加入score显示模块
score = new QLabel(QString("SCORE: %1").arg(0)); // 用QLabel显示分数score
score->setStyleSheet("QLabel { color: rgb(235,224,214); font: 16pt; }"); // 设置样式
score->setFixedHeight(50); // 高度自适应
mainLayout->insertWidget(1, score, 0, Qt::AlignRight); // 在主窗口中插入分数模块
4.1.5 绘制4*4网格布局:
- 4*4的生成采用循环语句。由于游戏中需要对数字块进行多次绘制并具备初始化功能,所以抽象为
drawBoard()
函数代替。
void GameBoard::drawBoard()
{
delete boardLayout;
boardLayout = new QGridLayout();
for (int i = 0; i < NCells; ++i) {
for (int j = 0; j < NCells; ++j) {
delete cells[i][j];
cells[i][j] = new Cell(game.board[i][j]);
boardLayout->addWidget(cells[i][j], i, j);
cells[i][j]->draw();
}
}
mainLayout->insertLayout(0, boardLayout);
}
4.2 绘制数字块:
- 数字块可以命名为Cell,是QLabel类型的,可以显示文本并设置样式
- 在文件创建时,可以设置为继承QLabel
cell
类声明了draw()
成员函数,用于设置网格的样式。
Cell::Cell(int v): value(v)
{
setAlignment(Qt::AlignCenter);
}
void Cell::draw() //draw()函数可以按照cell不同的值来设置样式
{
setText(QString::number(value));
auto style = QString("Cell { background: %1; color: %2; font: bold; border-radius: 10px; font: 40pt; }");
switch (value) {
case 2: {
setStyleSheet(style.arg("rgb(238,228,218)").arg("rgb(119,110,101)"));
break;
}
case 4: {
setStyleSheet(style.arg("rgb(237,224,200)").arg("rgb(119,110,101)"));
break;
}
case 8: {
setStyleSheet(style.arg("rgb(242,177,121)").arg("rgb(255,255,255)"));
break;
}
case 16: {
setStyleSheet(style.arg("rgb(245,150,100)").arg("rgb(255,255,255)"));
break;
}
case 32: {
setStyleSheet(style.arg("rgb(245,125,95)").arg("rgb(255,255,255)"));
break;
}
case 64: {
setStyleSheet(style.arg("rgb(245,95,60)").arg("rgb(255,255,255)"));
break;
}
case 128: {
setStyleSheet(style.arg("rgb(237,207,114)").arg("rgb(255,255,255)"));
break;
}
case 256: {
QGraphicsDropShadowEffect *dse = new QGraphicsDropShadowEffect();
dse->setColor(Qt::yellow);
dse->setBlurRadius(20);
dse->setOffset(-1);
setGraphicsEffect(dse);
setStyleSheet(style.arg("rgb(237,204,97)").arg("rgb(255,255,255)"));
break;
}
case 512: {
QGraphicsDropShadowEffect *dse = new QGraphicsDropShadowEffect();
dse->setColor(Qt::yellow);
dse->setBlurRadius(30);
dse->setOffset(-1);
setGraphicsEffect(dse);
setStyleSheet(style.arg("rgb(237,204,97)").arg("rgb(255,255,255)"));
break;
}
case 1024: {
QGraphicsDropShadowEffect *dse = new QGraphicsDropShadowEffect();
dse->setColor(Qt::yellow);
dse->setBlurRadius(40);
dse->setOffset(-1);
setGraphicsEffect(dse);
setStyleSheet(style.arg("rgb(237,204,97)").arg("rgb(255,255,255)"));
break;
}
case 2048: {
QGraphicsDropShadowEffect *dse = new QGraphicsDropShadowEffect();
dse->setColor(Qt::yellow);
dse->setBlurRadius(50);
dse->setOffset(-1);
setGraphicsEffect(dse);
setStyleSheet(style.arg("rgb(237,204,97)").arg("rgb(255,255,255)"));
break;
}
default: {
setText("");
setStyleSheet("Cell { background: rgb(204,192,179); border-radius: 10px; }");
}
}
-
在设置
Cell
样式时,还使用了QString
来构建字符串。可以使用arg()重载函数来访问值。取得Label的值后,根据值的不同设置不同样式。 -
可以在
main.cpp
中设置随机数srand(time(NULL));
,使得每次打开应用都不一样。
4.3 编写游戏逻辑:
4.3.1 获取键盘输入:
- 通过
keyPressEvent(QKeyEvent *event)
可以获取键盘输入,通过event->key()
取得键盘输入值,之后通过switch语句跳转到对应的move操作。
switch (event->key()) {
case Qt::Key_Up:
game.move(false, false);
break;
case Qt::Key_Down:
game.move(false, true);
break;
case Qt::Key_Left:
game.move(true, false);
break;
case Qt::Key_Right:
game.move(true, true);
break;
}
4.3.2 生成新数字块:
- 首先需要获取当前棋盘状态下棋盘的空位。使用
getEmptyPos()
获取:
// game.cpp
Pos Game::getEmptyPos()
{
int i,j;
do {
i = rand() % NCells;
j = rand() % NCells;
} while (board[i][j]);
return {i, j};
}
- 之后在用户输入操作时,如果判断当前棋盘没有全部占满,则在空位上生成一个新的数字块。
// gameboard.cpp
if (!game.isfull()) {
auto pos = game.getEmptyPos();
game.board[pos[0]][pos[1]] = 2;
}
score->setText(QString("SCORE: %1").arg(game.total_score));
drawBoard();
4.3.3 移动、相加操作:
move()
是游戏操作的核心函数,在game.cpp
中编写,功能上分为两个部分:移动和相加,使用Vector容器实现。
void Game::move(bool horizonal, bool reverse)
{
static int board_prev[NCells][NCells];
memcpy(board_prev, board, sizeof board); //backup
using Vec = std::vector<int> ;
auto squeezeVec = [&] (Vec v, bool reverse) -> Vec { // 定义一个相加函数(上、左正方向,下、右需要先翻转再计算再反转回来)
if (reverse) v = Vec(v.rbegin(), v.rend()); //翻转向量
Vec ans; size_t first = 0;
while (first+1 < v.size()) {
if (v[first] == v[first+1]) {
if(v[first] * 2 == 2048) wonGame = 1;
total_score += v[first] * 2;
ans.push_back(v[first] * 2), first += 2;
}
else ans.push_back(v[first]), first++;
}
if (first+1 == v.size()) ans.push_back(v[first]);
while (ans.size() < NCells) ans.push_back(0);
return reverse ? Vec(ans.rbegin(), ans.rend()) : ans;
};
auto getVal = [&] (size_t i, size_t j) -> int& { // 执行上下操作时,将棋盘逆时针旋转90度,之后当作向左或向右处理
return horizonal ? board[i][j] : board[j][i];
};
for (size_t i=0; i < NCells; ++i) {
Vec v;
for (size_t j=0; j < NCells; ++j) {
if (getVal(i, j)) v.push_back(getVal(i, j));
}
auto ans = squeezeVec(v, reverse);
for (size_t j=0; j < NCells; ++j) {
getVal(i, j) = ans[j];
}
}
changed = !std::equal(*board, *board + NCells * NCells, *board_prev );
}
- 每当输入一次操作时,都需要遍历整个棋盘进行处理。由于C++中数组的特性,将行定义一个向量vector容器进行处理是较为合适的方法。考虑到上下左右四种操作的复杂性,我们使用
horizaonal
和reverse
两个变量来统一四种操作,使其均转化为行向量进行处理(简而言之就是转棋盘) - 移动操作需要对棋盘中的各个数字块之间的空格按照用户指定的方向进行消去,首先以左为头,以右为尾定义棋盘行为
v
向量,将非0的数字块重新按照顺序排列加入v
向量。以向左操作为基准方向:向上操作需要将纵向元素映射到横向向量中,即将棋盘逆时针旋转90度;向右操作需要倒序加入向量,计算后再倒序输出;向下操作需要先逆时针90度旋转棋盘,再倒序加入向量,计算后倒序输出。 - 相加操作通过vector的特性实现:如果前一项与后一项相等,则将乘2的结果存入结果棋盘
ans
向量。如果前后不等,则直接加入ans
向量。相加后,在vector末尾补齐0作为空格。
4.3.4 游戏胜利/失败条件:
- 2048游戏的胜利条件是:当棋盘中出现2048时,游戏胜利。为此,可以在相加操作中加入flag变量
wonGame
,当相加结果等于2048时,wonGame为真,弹出胜利界面gameWinWindow
GameWinWindow::GameWinWindow(QWidget *parent) :
QWidget(parent)
{
setStyleSheet("GameWinWindow { background: rgb(237,224,200); }");
setFixedSize(425,205);
QVBoxLayout *layout = new QVBoxLayout(this);
// game over label
QLabel* gamewin = new QLabel("Game Win!", this);
gamewin->setStyleSheet("QLabel { color: rgb(119,110,101); font: 40pt; font: bold;} ");
// reset button
reset = new ResetButton(this);
reset->setFixedHeight(50);
reset->setFixedWidth(100);
// add game win label to window
layout->insertWidget(0,gamewin,0,Qt::AlignCenter);
// add reset button to window
layout->insertWidget(1,reset,0,Qt::AlignCenter);
}
ResetButton* GameWinWindow::getResetBtn() const
{
return reset;
}
- 2048游戏的失败条件是:当棋盘全部占满且在任意方向进行移动操作时,棋盘均没有改变。实现上使用四个flag变量监测用户输入某个操作时,棋盘是否有变动。如果有变动,则全部四个flag置假,如果无变动,则判断当前是否棋盘已满,满则对应操作的flag位置真,当四个flag位均为真时,判断为无法继续游戏,弹出游戏失败窗口
gameOverWindow
GameWinWindow::GameWinWindow(QWidget *parent) :
QWidget(parent)
{
setStyleSheet("GameWinWindow { background: rgb(237,224,200); }");
setFixedSize(425,205);
QVBoxLayout *layout = new QVBoxLayout(this);
// game over label
QLabel* gamewin = new QLabel("Game Win!", this);
gamewin->setStyleSheet("QLabel { color: rgb(119,110,101); font: 40pt; font: bold;} ");
// reset button
reset = new ResetButton(this);
reset->setFixedHeight(50);
reset->setFixedWidth(100);
// add game win label to window
layout->insertWidget(0,gamewin,0,Qt::AlignCenter);
// add reset button to window
layout->insertWidget(1,reset,0,Qt::AlignCenter);
}
ResetButton* GameWinWindow::getResetBtn() const
{
return reset;
}
- 无论游戏胜利或失败,均设计了游戏重开的按钮,使用QLabel和mousePressEvent实现。
ResetButton::ResetButton( QWidget* parent) : QLabel(parent)
{
setText("Try again!");
setAlignment(Qt::AlignCenter);
setStyleSheet("ResetButton { background-color: rgb(143,122,102); border-radius: 10px; font: bold; color: white; }");
}
void ResetButton::mousePressEvent(QMouseEvent* event)
{
emit clicked();
}
- 在gameboard上将resetbutton和resetgame的逻辑功能绑定
// gameboard.cpp
GameBoard::GameBoard(QWidget *parent) :
QWidget(parent)
{
// 连接游戏失败窗口
connect(gameOverWindow.getResetBtn(), SIGNAL(clicked()), this, SLOT(resetGameOver()));
// 连接游戏成功窗口
connect(gameWinWindow.getResetBtn(), SIGNAL(clicked()), this, SLOT(resetGameWin()));
}
// game.cpp
void Game::resetGame()
{
memset(board, 0, sizeof board);
board[rand()%NCells][rand()%NCells] = 2;
auto pos = getEmptyPos();
board[pos[0]][pos[1]] = 2;
total_score = 0; changed = true;
}
5. 单元测试:
5.1 绘制游戏窗口:
- 初始QWidget绘制效果如下(仅包含一个窗口和一个文字QLabel):
- 在下方加入score后效果如下:
- 设置循环后,绘制4*4网格如下:
5.2 绘制数字块:
- 为之前创建的4*4网格上,进行样式整理,结果如下:
5.3 游戏逻辑展示:
- 游戏过程:
- 游戏胜利:
- 游戏失败:
6. 体会与感悟
- 以前完成其他作业时使用过
html+css+js
的组合,也用过python+tkinker
的组合,但本次大作业是我第一次使用c++编写图形化界面。不仅解锁了新的技术栈,也第一次制作了一个简单的小游戏,收获颇丰。代码参考了上海交通大学的c++程序设计课程的实验指导教程,自己额外加入了一些其他界面。
完整代码下载地址:C++大作业基于C++实现的2048小游戏