QT项目之推箱子
1. 项目概述
- 项目背景:推箱子游戏(Sokoban)是一款简单但经典的益智游戏,具有一定的逻辑和策略性。玩家需要控制角色推箱子,将所有的箱子推到黑洞里面,作为奖励当前位置便会出现一个西瓜作为对玩家的奖励。我的项目实现了关卡选择、关卡重置(按R重置关卡)、音效、背景音乐等功能,并提供了一个简洁的 UI 设计。
- 目的:展示C++及Qt开发经验,同时提高自己的编程和项目管理能力。
- 技术栈:Qt框架、C++语言、面向对象编程、图形界面开发。
界面展示:
2. 项目结构
项目结构如下:
Widget
类:主窗口类,负责游戏的整体布局、事件处理等。
class Widget : public QWidget
{Q_OBJECTpublic:explicit Widget(QWidget *parent = nullptr);virtual void paintEvent(QPaintEvent* event);//绘图事件函数virtual void keyPressEvent(QKeyEvent* event);//键盘按下事件void Collision(int _dRow,int _dCol);void RestartGame();void PlayGame();void ChooseLevel();void PlaySoundEffect(const QString &soundFile);~Widget();private:Ui::Widget *ui;GameMap* mPMap;//执行绘画操作QPainter* mMapPainter;//角色Role* mRole;//游戏更新定时器 解决paintEvent不定时更新问题QTimer* mTimer;QMediaPlayer *backgroundMusicPlayer; // 背景音乐播放器
};
GameMap
类:地图类,管理地图数据和绘制。
class GameMap : public QObject
{Q_OBJECT
public:explicit GameMap(QObject *parent = nullptr);~GameMap();bool InitByFile(QString fileName);void Clear();void Paint(QPainter* _p,QPoint _Pos);bool CheckWinCondition();int mRow;int mCol;int** mPArr;//用于开辟二维数组 2D地图元素signals:public slots:
};
Role
类:角色类,管理角色的位置和移动。
class Role : public QObject
{Q_OBJECT
public:explicit Role(QObject *parent = nullptr);//对应在地图的映射行列int mRow;int mCol;int startRow=1;int startCol=1;//画图位置//人在二维数组里的x,y和显示的x,y坐标轴是反过来的QPoint mPaintPos;//人物图片QImage mImg;void Move(int _dRow,int _dCol);//移动函数void Paint(QPainter* _p,QPoint _pos);//自己的绘制函数void ResetPosition();signals:public slots:
};
- 音效系统:使用
QMediaPlayer
播放背景音乐和音效。
QT += multimedia
QT += multimediawidgets
3.功能设计
1. 基本游戏功能
-
关卡选择:玩家可以选择不同的关卡来体验不同难度的游戏。关卡文件使用文本文件保存,每个文件描述一个独立的地图布局。
-
实现:使用
QFileDialog
提供文件选择界面,通过InitByFile
函数加载关卡文件中的地图信息。
if(!mPMap->InitByFile("./Map/lv1.txt")){QMessageBox::warning(this,"警告","文件打开失败");}
// 使用选择的关卡文件加载地图if (!mPMap->InitByFile(fileName)) {QMessageBox::warning(this, "警告", "关卡文件打开失败");} else {mRole->ResetPosition(); // 重置角色位置update();}
-
角色移动:玩家可以使用方向键(WASD 或箭头键)来控制角色移动,推动箱子到达目标点。
-
实现:通过
keyPressEvent
捕获按键事件,根据按下的方向键调用Collision
函数进行碰撞检测和移动操作。
void Widget::keyPressEvent(QKeyEvent* event)
{PlaySoundEffect("qrc:/new/prefix1/something/man.mp3");switch (event->key()){case Qt::Key_W:case Qt::Key_Up:{//碰撞检测函数Collision(-1,0);break;}case Qt::Key_S:case Qt::Key_Down:{//碰撞检测函数Collision(1,0);break;}case Qt::Key_A:case Qt::Key_Left:{//碰撞检测函数Collision(0,-1);break;}case Qt::Key_D:case Qt::Key_Right:{//碰撞检测函数Collision(0,1);break;}case Qt::Key_R:{RestartGame();return;}}// 检查是否胜利if (mPMap->CheckWinCondition()){QMessageBox::information(this, "胜利", "恭喜你!你已经完成了关卡!");RestartGame(); // 可以选择重新开始游戏或关闭游戏}//可以再增加想要的功能
}
-
箱子碰撞和推动:当角色遇到箱子时,会判断箱子前方的格子是否为空地或目标点,如果是则推动箱子,否则角色无法移动。
-
实现:在
Collision
函数中判断角色前方是否有箱子,若有,再进一步判断箱子前方是否为可推动区域(道路或目标点),然后根据条件修改地图数据。
void Widget::Collision(int _dRow,int _dCol)
{//判断位置定义int newRow = mRole->mRow + _dRow;int newCol = mRole->mCol + _dCol;if(mPMap->mPArr[newRow][newCol] == Wall)//判断前方是墙{return;}else if(mPMap->mPArr[newRow][newCol] == Box)//判断前方是箱子{//判断箱子前方if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Road){//改变地图元素mPMap->mPArr[newRow+_dRow][newCol + _dCol] = Box;//箱子前方变成箱子mPMap->mPArr[newRow][newCol] = Road;}else if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Point){//改变地图mPMap->mPArr[newRow+_dRow][newCol + _dCol] = InPoint;//箱子前方变成洞mPMap->mPArr[newRow][newCol] = Road;}else{return;//无法推动箱子}}else if(mPMap->mPArr[newRow][newCol] == InPoint)//前方是填好的洞{//判断目标点前方if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Road){//改变地图元素mPMap->mPArr[newRow+_dRow][newCol + _dCol] = Box;//目标点前方变成箱子mPMap->mPArr[newRow][newCol] = Point;}else if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Point){//改变地图元素mPMap->mPArr[newRow+_dRow][newCol + _dCol] = InPoint;//箱子进点mPMap->mPArr[newRow][newCol] = Point;}else{return;//无法推动箱子}}//否则移动mRole->Move(_dRow,_dCol);//qDebug() << "人物绘制位置:" << mRole->mPaintPos;
}
-
胜利条件检测:当所有箱子都被推到目标点时,判定玩家通关并弹出胜利提示。
-
实现:使用
CheckWinCondition
函数遍历地图数据,检查所有箱子是否都在目标点位置上。如果满足条件,触发胜利提示。
bool GameMap::CheckWinCondition()
{//qDebug() << "Checking win condition..."; // 调试信息,确保函数被调用for (int i = 0; i < mRow; ++i){for (int j = 0; j < mCol; ++j){// 如果地图中存在一个箱子(Box)if (mPArr[i][j] == Box && mPArr[i][j] != InPoint){//qDebug() << "Box at (" << i << "," << j << ") not in point!";return false; // 如果有箱子没有到达目标点,返回false}}}//qDebug() << "All boxes are in the right places!";return true; // 所有箱子都到了目标位置
}
2. 用户体验设计
-
游戏界面布局:界面包含一个固定的窗口大小,设计简洁且易于理解,包括地图区域、角色、箱子、背景和控制按钮。布局简洁直观,让玩家专注于游戏。
- 实现:使用 Qt Designer 设置窗口布局,地图和角色元素绘制在画布上。按钮使用
QPushButton
组件创建,并通过布局管理器设置位置。
- 实现:使用 Qt Designer 设置窗口布局,地图和角色元素绘制在画布上。按钮使用
//初始化地图元素mPMap = new GameMap(this);mMapPainter = new QPainter(this);//创建画家mRole = new Role(this);
-
按钮设计:游戏界面包含“Play”(开始游戏)、“Choose Level”(选择关卡)、“Close”(关闭游戏)等按钮,方便玩家操作。
- 实现:通过
QPushButton
创建按钮,并使用connect
函数连接到相应的槽函数。例如,点击“Choose Level”按钮后,会打开文件选择对话框让玩家选择关卡文件。
- 实现:通过
//开始PlayQPushButton *pushbutten_play= new QPushButton("Play",this);pushbutten_play->setParent(this);pushbutten_play->move(40,60);connect(pushbutten_play,&QPushButton::clicked,this,&Widget::PlayGame);//结束closeQPushButton *pushButtonClose = new QPushButton("Close", this);pushButtonClose->move(40, 140); // 设置位置,避免和其他按钮重叠connect(pushButtonClose, &QPushButton::clicked, this, &Widget::close);// 选择关卡按钮QPushButton *pushButtonChooseLevel = new QPushButton("Choose Level", this);pushButtonChooseLevel->move(40, 100);connect(pushButtonChooseLevel, &QPushButton::clicked, this, &Widget::ChooseLevel);
-
窗口大小的固定:窗口固定在 1024x768 的尺寸,以确保界面布局和比例不随窗口大小的改变而变形。
- 实现:使用
setFixedSize
固定窗口大小。
- 实现:使用
// 设置窗口图标setWindowIcon(QIcon(":/new/prefix1/something/favicon.ico"));setFixedSize(1024,768);//固定窗口大小
-
背景音乐:游戏内置背景音乐,播放轻松的循环音乐,为玩家提供沉浸式的游戏体验。
- 实现:使用
QMediaPlayer
播放背景音乐,并设置循环播放功能。每当音乐播放结束时,通过mediaStatusChanged
信号重新启动播放,实现无缝循环。
- 实现:使用
// 初始化背景音乐播放器backgroundMusicPlayer = new QMediaPlayer(this);backgroundMusicPlayer->setMedia(QUrl("qrc:/new/prefix1/something/summer.mp3"));backgroundMusicPlayer->setVolume(50); // 设置音量 (0-100)backgroundMusicPlayer->play(); // 开始播放背景音乐
-
音效:当玩家进行特定操作时(如开始游戏、推动箱子)播放音效,增加游戏的趣味性。
- 实现:在
PlaySoundEffect
函数中使用QMediaPlayer
播放音效,操作完成后自动释放资源,避免内存泄漏。
- 实现:在
void Widget::PlaySoundEffect(const QString &soundFile)
{QMediaPlayer *soundEffect = new QMediaPlayer(this);soundEffect->setMedia(QUrl(soundFile));soundEffect->setVolume(100);soundEffect->play();// 在音效播放结束后删除对象,避免内存泄漏connect(soundEffect, &QMediaPlayer::stateChanged, soundEffect, &QMediaPlayer::deleteLater);
}
3. 扩展功能
-
关卡文件解析和加载:每个关卡文件包含地图的布局信息,包括角色、箱子、墙壁和目标点的位置。文件格式可以是简单的文本文件,通过特定字符代表地图元素。
- 实现:在
InitByFile
函数中读取文件数据,解析各字符表示的内容,并将数据存储到地图数组中。例如,'1'
表示墙,'2'
表示箱子,'3'
表示可以行走的路等。
- 实现:在
bool GameMap::InitByFile(QString fileName)
{QFile file(fileName);//创建文件对象if(!file.open(QIODevice::ReadOnly)){return false;//打开失败}//读取所有内容QByteArray arrAll = file.readAll();arrAll.replace("\r\n","\n");//将 "\r\n" 替换成 “\n”QList<QByteArray> lineList = arrAll.split('\n');//以“\n” 分割子串mRow = lineList.size();//确定行mPArr = new int*[mRow];for(int i = 0;i < mRow;i++){QList<QByteArray> colList = lineList[i].split(',');mCol = colList.size();//确定列mPArr[i] = new int[mCol];//开辟列for(int j = 0;j < mCol;j++)//遍历列{mPArr[i][j] = colList[j].toInt();}}return true;
}
注意:这里使用了QByteArray类,通过替换,分割的方式获取了文件中的所需数据(立大功)
-
音效实现和循环播放:使用
QMediaPlayer
实现背景音乐和音效的播放,通过检测音频状态来判断是否循环播放。- 实现:
QMediaPlayer
的stateChanged
信号用于播放单次音效,在音效结束后自动释放资源。背景音乐则通过mediaStatusChanged
信号在播放结束时重新播放,实现无缝循环。
- 实现:
connect(backgroundMusicPlayer, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus status) {if (status == QMediaPlayer::EndOfMedia) {backgroundMusicPlayer->play(); // 重新开始播放}
3. 关键代码解析
在这一部分,我深入分析推箱子游戏的核心模块,包括地图加载、角色和箱子的碰撞检测、以及背景音乐和音效的实现。这些模块是游戏的核心功能,直接影响游戏体验和功能实现。
1. 地图加载
地图加载功能通过读取关卡文件,将字符表示的地图信息转换为游戏界面中的元素。关卡文件通常使用文本格式,文件中的每个字符代表不同的地图元素(例如,墙、箱子、目标点等),并以特定规则呈现。
enum MapElement
{Road,//可以行走的位置Wall,//墙Box,//箱子Point,//未填充的洞InPoint//填充的洞
};
还需要将对应属性与图片联系在一起。
由于我使用了枚举类型enum,所以文件中的数字0到4分别代表了不同的地图元素。
-
实现思路:
- 打开指定路径的文件,读取每行的字符,逐行解析。(QByteArray)
- 根据字符类型将地图元素分配到二维数组
mPArr
中。
2. 角色和箱子的碰撞检测
碰撞检测模块控制角色的移动并处理箱子的推动逻辑。角色在每次按键时都要判断前方是否有障碍物(墙或箱子),若有箱子,还需判断箱子前方的空位,以决定是否能成功推动。
void Widget::Collision(int _dRow,int _dCol)
{//判断位置定义int newRow = mRole->mRow + _dRow;int newCol = mRole->mCol + _dCol;if(mPMap->mPArr[newRow][newCol] == Wall)//判断前方是墙{return;}else if(mPMap->mPArr[newRow][newCol] == Box)//判断前方是箱子{//判断箱子前方if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Road){//改变地图元素mPMap->mPArr[newRow+_dRow][newCol + _dCol] = Box;//箱子前方变成箱子mPMap->mPArr[newRow][newCol] = Road;}else if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Point){//改变地图mPMap->mPArr[newRow+_dRow][newCol + _dCol] = InPoint;//箱子前方变成洞mPMap->mPArr[newRow][newCol] = Road;}else{return;//无法推动箱子}}else if(mPMap->mPArr[newRow][newCol] == InPoint)//前方是填好的洞{//判断目标点前方if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Road){//改变地图元素mPMap->mPArr[newRow+_dRow][newCol + _dCol] = Box;//目标点前方变成箱子mPMap->mPArr[newRow][newCol] = Point;}else if(mPMap->mPArr[newRow+_dRow][newCol + _dCol] == Point){//改变地图元素mPMap->mPArr[newRow+_dRow][newCol + _dCol] = InPoint;//箱子进点mPMap->mPArr[newRow][newCol] = Point;}else{return;//无法推动箱子}}//否则移动mRole->Move(_dRow,_dCol);//qDebug() << "人物绘制位置:" << mRole->mPaintPos;
}
-
实现思路:
- 根据按键方向(
_dRow
和_dCol
)计算角色前方的格子位置。 - 判断角色前方是否为墙,若为墙则停止移动。
- 若前方为箱子,再判断箱子前方的格子是否为空地或目标点,若符合条件则推动箱子,更新地图信息。
- 最后,角色移动到新的位置。
- 根据按键方向(
这个功能中用了许多的判断语句
3. 背景音乐和音效
在游戏中添加背景音乐和音效可以提升沉浸感。背景音乐在游戏开始时播放,音效在角色推箱子或胜利时播放,背景音乐实现循环播放。
// 初始化背景音乐播放器backgroundMusicPlayer = new QMediaPlayer(this);backgroundMusicPlayer->setMedia(QUrl("qrc:/new/prefix1/something/summer.mp3"));backgroundMusicPlayer->setVolume(50); // 设置音量 (0-100)backgroundMusicPlayer->play(); // 开始播放背景音乐connect(backgroundMusicPlayer, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus status) {if (status == QMediaPlayer::EndOfMedia) {backgroundMusicPlayer->play(); // 重新开始播放}});
实现思路:
- 使用
QMediaPlayer
加载背景音乐文件并设置音量,然后在游戏开始时播放。 - 在背景音乐播放结束时,通过
mediaStatusChanged
信号检测状态并重新播放,以实现循环效果。 PlaySoundEffect
函数创建短暂的音效播放器,在播放结束后自动释放内存,避免资源浪费。
4. 遇到的挑战与解决方案
在推箱子游戏的开发过程中,遇到了多个技术上的难题。通过逐步分析问题、调试代码、查阅文档并采用适当的解决方案,我成功克服了这些困难。这不仅提高了代码的稳定性,也优化了用户体验。以下是开发过程中遇到的主要挑战和相应的解决方案。
1. QMediaPlayer 无法实现循环播放
问题:
在使用 QMediaPlayer
添加背景音乐时,发现 QMediaPlayer
本身不支持直接设置循环播放。音乐播放到结束后会自动停止,这不符合游戏中需要背景音乐持续播放的需求。
解决方案:
通过连接 mediaStatusChanged
信号来检测播放状态,当检测到状态为 EndOfMedia
时,将播放重新开始。这样可以实现背景音乐的循环播放。注:(引用音乐的地址前一定要加qrc)
效果:
此解决方案使背景音乐可以在每次播放结束后自动重新播放,成功实现了循环效果,避免了因音乐停止而导致的沉浸感丧失。
2.GameMap::CheckWinCondition()无法被调用
问题:
在使用 GameMap::CheckWinCondition() 判断是否胜利时,发现无论怎样玩游戏,游戏最终都没有游戏胜利。无法给定游戏胜利,这不符合游戏中需要结果的需求。
解决方案:确保 CheckWinCondition
的调用位置,在 keyPressEvent
函数中,CheckWinCondition
被放置在 mMapPainter->end()
之后,导致绘图已经结束但仍继续检查胜利条件。
效果:可以在游戏场上所有箱子都在洞中时给出胜利弹窗,并重新开始游戏。
// 检查是否胜利if (mPMap->CheckWinCondition()){QMessageBox::information(this, "胜利", "恭喜你!你已经完成了关卡!");RestartGame(); // 可以选择重新开始游戏或关闭游戏}
5. 未来优化方向
在完成当前功能的基础上,我已经为未来的优化和扩展制定了一些计划。通过引入新功能和优化用户体验,我希望能够进一步提升游戏的完整度和可玩性,并为用户带来更好的交互体验。以下是一些关键的优化方向:
1. 实现保存和读取进度功能
为提升玩家体验,计划加入游戏的存档功能。玩家可以随时保存当前的游戏进度,下次打开时可以继续上次的关卡与状态。这不仅提高了游戏的实用性,也能让玩家在休闲时间随时体验游戏。
实现思路:
- 设计一个简单的保存机制,将玩家的关卡进度、角色位置、箱子位置等信息保存到本地文件。
- 当游戏启动时,检查是否存在存档文件,如有则读取并恢复进度。
- 可以进一步扩展以支持多用户的进度存档,适应更广泛的用户需求。
2. 提高 UI 设计的美观度
为了让游戏更加吸引人,计划优化游戏的界面设计,使其更符合现代化的视觉风格。具体可以通过引入更精致的图标、动画和音效特效,让用户在操作时获得更好的反馈,从而增加沉浸感。
优化思路:
- 为游戏按钮、地图元素和角色设计更高质量的图标,增强画面的视觉效果。
- 在推动箱子、胜利时加入简单的动画效果,例如淡入淡出、缩放等,以提升交互体验。
- 增加更多贴合场景的音效,例如胜利时的欢呼声或角色移动时的步伐声,丰富游戏氛围。
6. 个人收获
在开发这个推箱子游戏项目的过程中,我不仅学到了技术层面的知识,也积累了宝贵的开发经验,对软件开发有了更深入的理解。以下是一些具体的收获:
1. 加深了对 C++ 编程的理解
通过本项目的开发,我对 C++ 的面向对象编程、内存管理、以及指针和引用的用法有了更深入的掌握。尤其是在构建游戏核心逻辑时,我意识到数据结构的设计和内存的高效使用对游戏的性能有着至关重要的影响。C++ 强大的性能优势让我能够构建出一个响应流畅的游戏逻辑,这也让我对 C++ 在实际项目中的应用充满信心。
2. 掌握了 Qt 框架的核心功能
本项目使用了 Qt 作为主要的开发框架。在开发过程中,我学习并使用了 Qt 的界面布局、事件系统、图像绘制、音效播放等功能,逐步理解了 Qt 在 GUI 开发中的强大优势。通过尝试不同的控件和功能模块,我不仅学会了如何搭建用户友好的界面,还掌握了如何处理 Qt 中的信号与槽机制,让各个组件高效协作。
3. 提升了解决问题的思维方式
在开发过程中,我遇到了一些挑战,如背景音乐无法循环播放、音效资源重复加载导致的内存泄漏等问题。在查阅资料和实验调试中,我学会了分析问题的本质,从文档、开发者社区等多种资源中找到最佳解决方案。这些过程让我对开发中的“调试思维”有了更深刻的理解,也让我体会到面对问题时保持耐心和冷静的重要性。
4. 强化了项目开发的系统化思维
通过开发这个项目,我进一步认识到系统化开发的意义,从前期的功能设计到代码实现、再到功能调试与优化,每一个环节都至关重要。这次项目开发让我对项目管理的每一步都有了实战经验,对未来更复杂项目的规划和实现有了宝贵的积累。