C++/QT 贪吃蛇小游戏 界面设计

C++/QT 贪吃蛇小游戏 界面设计

前言:本文所写的贪吃蛇是笔者初学QT练手的小项目,做出来的界面较为粗糙。由于很久没有接触C++,程序中类封装的不是很规范。写这篇文章,权当是记录生活了,手动狗头。还有就是写的比较啰嗦,建议跳跃性阅读。文章末尾附代码文件下载链接

概述:一张16格x16格地图,初始长度为2格,宽度为1格的Snake,地图中随机合法位置刷新食物;

游戏规则:

  • 玩家使用上下左右(或者WSAD)控制Snake的前进方向;

  • Snake成功吃到食物后长度加1;

  • 吃到自己身体或撞到地图边界死亡;


设计思路

关于Size:地图为16格x16格,实际编程窗口中取:1格=30x30像素,即地图尺寸为480x480像素

关于地图:使用QT绘制组件QPainter绘制480x480的浅灰色填充矩形

定义MyWin类(继承QWidget基类),用于创建窗口以及按钮、标签、画布等子控件,包含以下变量及成员:

  • MyWin(),默认构造函数
  • ~Mywin(),默认析构函数
  • void paintEvent(QPaintEvent*),绘图事件重写
  • void keyPressEvent(QKeyEvent* event),键盘监听事件重写
  • QTimer* timer,定时器,核心部件,可以定时发出信号,完成程序界面刷新动作
  • Snake* m_snake,生成Snake类对象,即初始化一条蛇,根据对象的信息在地图中进行绘制
  • QLabel*,QPushButton*等子控件,用于界面功能设计

定义Snake类,用于生成一条蛇,包含以下变量及成员:

  • Vector<Vector> snake_node变量,存储Snake身体结点;例如:[[2,2],[2,3],[3,3]…]

  • char direction变量,Snake当前方向,‘U’==Up,‘D’=Down,‘L’=Left,‘R’=Right

  • int head_x,head_y,蛇头坐标

  • int score,游戏得分,成功吃下一个食物,得分加 1

  • int snake_length,蛇身长度

  • moveSnake()函数,判断移动是否合法,并相应改变Snake的身体结点坐标,

  • Vector mapFlag(256,1),定义一个二维数组mapFlag,大小为16x16,与地图相对应。遍历数组,将Snake身体所处的结点标为 0,当蛇头移动至被标记为 0 的结点,即吃到自己身体,游戏结束。

    同时,将食物结点标为2,当蛇头移动至标为 2 的结点时,即吃到食物。

定义Food类,用于生成食物,包含以下变量及成员:

编写Food类,写到一半发现,Food类中变量成员太少了,单独开.h .cpp文件简直是可耻的浪费,于是干脆把变量成员都塞到Snake类里了,不建议这样写,因为不利于阅读以及后期维护(我就默默偷懒了)

  • int food_x,food_y,食物坐标
  • void newFood(),生成新的食物

第一步 界面设计

  1. 创建顶级窗口:
    • 实例化继承于QWidget的MyWin类对象,生成一个800x600空窗口,标题设置为[贪吃蛇]
  2. 往顶级窗口中添加内容:
    • 重写绘画事件paintEvent,使用QPainter控件在顶级窗口中绘制480x480的浅灰色填充矩形,起点为(80,60),地图绘制完成。
    • 调用QLabel,QPushButton等控件,设置字体,控件位置,文本内容(按钮的点击响应事件暂时放一边,先设计好外观),完成以上步骤后界面如下

第二步 蛇的绘制&食物的绘制

  1. 蛇的绘制:

    • 蛇身体的绘制,Snake类中变量vector<vector> snake_node存储的是蛇身体结点的坐标,数组初始化为[ [0,1], [1,1] ]。绘制第一个结点[0,1]时,要清楚地图尺寸为16x16格,1格为30像素

      要想绘制结点[0,1]即图中绿色部分,首先需要获得绿色区域左上角的坐标,根据映射关系可以得到,左上角横坐标 = 80 + 30 * 0;纵坐标 = 60 + 30 * 1;(80,60分别是顶级窗口左上角到地图左上角的横纵距离,计算时要代入),所以绿色区域左上角的像素坐标为(80, 90),使用以下语句即可填充此区域:

      painterSnakeBody.drawRoundedRect(80, 90, single_size, single_size, 10, 10)
      

      painterSnakeBody是绘画控件对象;drawRoundedRect是画圆角矩形函数;80,90为矩形左上角坐标;single_size为30,是每1格的像素长度;最后两个参数10表示矩形圆角的弯曲程度。

      遍历snake_node数组,并按上述步骤操作,即可画出蛇的所有身体。

    • 蛇头的绘制,绘制步骤相同,但蛇身结点使用黑色绘制,蛇头使用红色绘制

    • 蛇眼睛的绘制,眼睛的绘制区域与蛇头区域相同,不同的是眼睛的部位需要视情况而定,将头结点那格区域(30x30像素)放大如下:

      如图所示:1,2,3,4分别为眼睛可以放置的位置,当蛇向上移动时,即蛇头向上,眼睛的绘制区域应该为1,2;蛇头向右时,眼睛绘制区域为2,4;蛇头向下时,眼睛绘制区域为3,4;蛇头向左时,眼睛绘制区域为1,3;根据不同情况,在相应位置绘制直径为3的白色圆形,具体坐标的映射计算就不再赘述,代码如下:

      painterSnakeEye.drawEllipse(site_x, site_y, 6, 6);
      
  2. 食物的绘制:

    • 食物的绘制,与身体绘制方式一致,颜色选用绿色。完成以上步骤效果如下(蛇初始长度为2):

第三步 地图刷新机制

  1. 创建定时器对象:

    定时器QTimer介绍:QT中的一个控件,初始化时调用timer->start( T )函数,T可以传入单位为毫秒的参数,比如1000,则定时器timer的功能就是每1000ms发出一个超时信号,不断循环这个操作,直到程序退出或对象调用stop()函数(笔者对定时器了解不深,这段概括可能不够严谨,具体事项请阅读QT官方说明文档)

    QTimer* timer = new QTimer(this);
    

    设置超时周期:

    timer->start(1000);
    
  2. 信号与槽

    • 将每次定时器发出的超时信号作为信号函数,以此触发槽函数。在槽函数中,即新的周期开始时,我们需要更新绘图事件,按照新周期当前snake_node结点坐标和食物坐标重新绘制蛇与食物,这样便可以达到蛇在移动的视觉效果;我们还需要更新界面上游戏得分标签子控件。

      (void)connect(timer, &QTimer::timeout, [=](){
      
      ​	update();
      
      ​    label_score_2->setText(QString::number(m_snake->score));
      
        });
      

      上述代码中,connect()函数功能是将信号与槽连接起来,在QT早期版本,connect不支持Lambda表达式的使用,所以传入参数形式如:connect(信号发出者,信号,信号接收者,槽);在新版QT中,加入了Lambda表达式的使用,所以connect的传入参数中可以直接编写槽函数,并且可以省略信号接收者参数。针对上述代码,我们逐句分析理解:

      • 参数timer是定时器,即信号发出者;
      • timeout表示超时信号,加上作用域QTimer,表示为定时器发出的超时信号,记得开头要加上取地址符,参数需要传入的是函数地址;
      • [=](){}是lambda表达式格式,[]是lambda引出符,编辑器会根据引出符来判断接下来的代码是否为lambda表达式,[]中传入的=符号表示值传递方式为捕捉父作用域的变量(说实话笔者不是太理解,改天花时间认真学习一下)
      • update()功能是再次调用绘图事件,重新绘画蛇及食物
      • label_score_2是界面上用来显示游戏得分的一个标签;setText()函数可以设置标签文本内容;QString::number可以将整型转换为QString类型,也就是QT中的字符串;m_snake是在窗口类MyWin下实例化Snake类的一个对象,即Snake *m_snake = new Snake;score是当前对象拥有的属性,即当前游戏得分;

      所以这段代码的意思就是:定时器发出超时信号,执行绘图事件,更新地图,游戏得分标签更新。

  3. 蛇的移动

    • 仅仅是每周期执行绘图事件,刷新地图是没有办法让蛇移动起来的。要知道,之前定义的snake_node数组中存放着蛇的全部结点坐标,绘图事件是根据snake_node数组来绘制蛇的,因此只有让snake_node数组发生变化并每周期绘制一次才能达到蛇移动的效果,因此可以理解移动蛇实际上是对snake_node数组进行操作。状态A移动至状态B后snake_node数组的变化,如下图所示:

      每次蛇的移动都可以看作为,snake_node数组删除第一对坐标[0,1],并添加新坐标[2,1],代码如下:

      snake_node.erase(snake_node.begin());
      snake_node.push_back([2,1]);
      

      .erase()函数功能即删除数组成员,.begin()是返回数组第一位成员的迭代器,组合起来便是删除第一队结点坐标[0,1];

      .push_bakc()是向数组最后添加一位成员;实际上push_back()不支持上述表达式中的语法,这样写是为了更加容易理解,编程时需要先定义一个一维数组temp_node,将2,1塞进一维数组后,再执行push_back(temp_node)将[2,1]添加进去,代码如下:

      vector<int> temp_node = { 2,1 };
      snake_node.push_back(temp_node);
      

      每周期开始时执行这样的数组操作,再调用绘图事件绘制,刷新地图和标签,实现效果如下:

第四步 键盘控制移动

  1. 加入键盘监听事件

    • 为了实现程序运行过程中能够实时循环接收键盘输入的内容,我们重写QT键盘监听事件,先在窗口类MyWin中声明,再在类外实现,键盘事件实现代码如下:

      void MyWin::keyPressEvent(QKeyEvent* event)
      {
        switch (event->key())
        {
        // 键盘的方向键'上'或大小写'w/W'
        case Qt::Key_Up:
        case 'w':
        case 'W':
        if (m_snake->direction == 'D') break;
        m_snake->direction = 'U';
        break;
          
        // 键盘的方向键'下'或大小写's/S'
        case Qt::Key_Down:
        case 's':
        case 'S':
        if (m_snake->direction == 'U') break;
        m_snake->direction = 'D';
        break;
          
        // ······省略左右操作
          
        default:
        break;
        }
      }
      

      上述代码中,void MyWin::keyPressEvent(QKeyEvent* event)即键盘事件函数的实现,注意在keyPressEvent前要加上作用域MyWin;

      event->key()即键盘监听事件程序从键盘获取的内容,比如我们按下键盘上的字母’T’,那么event->key()的值就是’T’(至于函数这个实时不断获取功能是怎样实现的,笔者也是比较好奇,有机会可以学习一下)。

      综上,当我们在键盘上按下了字母’W’,即希望蛇向上移动,代码进入Switch第一段分支语句,这里我们需要做个判断,如果当前蛇的方向是向下的(Down),那么我们执行向上操作显然是非法的(总不能让蛇原地转头吧),因此操作非法时,直接break,退出这一次键盘监听程序,忽略本次操作;若合法,将对象m_snake的方向赋值为上’U’(Up),然后退出这一次键盘监听程序。m_snake->direction即对象m_snake拥有的属性direction,direction的值即当前蛇的朝向。

      我们总共需要4段分支语句,分别判断输入的上下左右,这里代码比较长,且逻辑重复性高,就不贴出来了,只贴了上下判断的分支语句。

  2. 控制蛇的移动

    • 现在我们已经可以接收键盘输入的内容,要做的就是编写移动函数moveSnake(),实现根据键盘输入的内容而为snake_node数组添加相应的结点,函数实现如下:

      int Snake::moveSnake()
      {
        // 根据方向移动蛇头
        if (direction == 'U') head_y--;
        else if (direction == 'D') head_y++;
        else if (direction == 'L') head_x--;
        else head_x++;
      
        // 若移动不合法,吃到自己或撞墙
        int head_site = 16 * head_y + head_x;
        if(head_x < 0 || head_x>15 || head_y < 0 || head_y>15 || mapFlag[head_site]==0) 	{
          return 0;
        }
      
        // 将蛇尾对应标志地图置为1,蛇头置为0
        int temp_site = snake_node[0][1] * 16 + snake_node[0][0];
        mapFlag[temp_site] = 1;
        mapFlag[head_site] = 0;
      
        // 将蛇头加入数组,并删除蛇尾
        vector<int> temp_node = { head_x,head_y };
        snake_node.push_back(temp_node);
        snake_node.erase(snake_node.begin());
        return 1;
      }
      

      上述代码第一段,根据当前蛇的朝向来改变头结点的坐标,示例如下:

      若当前头结点坐标为[3, 3],且蛇当前朝向上边,所以下一周期头结点的位置即为[3, 3 - 1];

      若当前头结点坐标为[3, 3],且蛇当前朝向下边,所以下一周期头结点的位置即为[3, 3 + 1];

      以此类推,可以得到蛇头下一周期的坐标。

      代码第二段,如果第一段计算出的坐标超出了16x16,或者在标志地图上对应的标志为0(表示该坐标处有蛇的身体),就说明蛇撞到了墙或者吃到了自己,直接返回0,表示蛇已经死亡。

      代码第三段,将蛇尾的区域对应的标志地图赋值为1,表明该区域已经没有蛇身体了,已经是空白区域了;再将蛇头即将进入的区域赋值为0,表示蛇已经进入该区域了;

      代码第四段,即snake_node数组操作,删除第一位,数组末尾添加一位,并返回1,表示移动成功;

      至此,我们就实现了控制蛇移动的功能,效果如下:

第五步 食物生成&吃掉食物

食物的生成相对较于简单,只需要注意几点:1)食物的生成应当是随机的;2)一次只能生成一颗食物,在已有食物被吃掉之前不能触发生成函数;3)食物需要生成在地图中合法位置,不允许生成在蛇的身体上。

  1. 随机生成机制

    • 一开始的想法很简单,地图是16格x16格,所以先在[0, 15]区间内取个随机数当作 X,再次取个数当作 Y,这样不就有了一个随机坐标,当作食物坐标就行了。如果随机取的坐标不合法,就舍去重新再随机一个。写到一半突然想起来,这样的效率太低了,到最后蛇的身体快占满地图的时候,这时想要随机取出一个合法的坐标,概率太低了。所以想了想换成了下面的生成机制。

      地图16x16总共256格合法位置可以生成食物,当地图中有了蛇的身体,合法区域会随之减少,例如现在蛇的长度为50格长,那么还剩下206格合法位置可以刷新食物,因此在[0, 205]区间内随机取值,无论随机数是多少,必然得到的都是一块合法的区域

  2. 食物生成代码

    void Snake::newFood()
    {
      // 食物随机刷新机制如下:
      // 标志地图为256大小的数组,地图中空白区域对应数组中的标志为0,蛇身区域为1,食物为2
      // 每次将数组大小256减去蛇身长度,得到余留空白区域的大小,以此大小为范围取随机数
      // 例如蛇身长度为250,说明余留空白区域大小为6,随机数只需在[0,5]范围内取值即可
      // 例如随机数取值为4,遍历标志地图数组,找到第4个空白区域,并刷新食物
      srand((unsigned)time(NULL));
      int site_food = rand() % (256 - snake_length);
      int i = 0;
      for (; i < 256; i++) {
        if (mapFlag[i] == 1) {
          site_food--;
          if (site_food == -1) break;
        }
      }
      food_x = i % 16;
      food_y = i / 16;
      mapFlag[i] = 2;
    }
    

    找到合法位置后,将该位置对应的标志地图赋值为2,表示该位置是食物;并对食物的坐标进行修改。

  3. 吃掉食物

    • 与移动到空白区域不同,蛇头移动到食物区域时,对蛇的moveSnake()函数要进行部分修改。当蛇吃到食物时,进行以下操作:

      • 蛇的长度snake_length加 1;
      • 游戏得分score加 1;
      • 在snake_node数组末尾加上下一组坐标,但并不需要删除数组的第一组坐标,因为蛇吃完食物后身体长度加 1,实际尾巴并没有发生移动;
    • 在moveSnake()函数中增加以下代码:

      // 若遇见食物,进行吃食物操作
        if (mapFlag[head_site] == 2) {
          mapFlag[head_site] = 0;
          vector<int> temp_node = { head_x,head_y };
          snake_node.push_back(temp_node);
          snake_length++;
          score++;
          return 2;
        }
      

      返回值为 2,表示这次移动吃到了食物,告诉对象m_snake调用生成新食物函数newFood();

      实现以上功能,效果如下:

拓展1 调节速度机制

至此,贪吃蛇简单的核心功能已经全部完成,剩下的就是些修修补补的工作。

实现贪吃蛇速度调节功能,其实很简单,之前我们定义的定时器使用函数timer->start(T)时,传入的参数是T=1000,即1000ms刷新一次,如果我们将T改为500,即1s内刷新两次,也就是蛇1s内移动两格,速度就是之前的两倍。因此要想实现多级速度调节,我们只需要将 ( T ) 改为 ( 1000 / game_speed ),game_speed数据类型为整形,取值为[ 1, 2, 4 ,8 ],因此现在传入的参数可以是[ 1000, 500, 250, 125 ],对应着四种速度。

再设计两个按钮,分别对应着 ’ - ’ 和 ’ + ’ ;按下减速按钮时,将game_speed的值翻倍,并重新调用start()函数,当按下加速按钮时,将game_speed的值除2,并调用start()函数;对应减速按钮的点击事件代码如下:

// 按钮事件:减速按钮
  (void)connect(button_speed_sub, &QPushButton::clicked, [=]() {
    if (game_speed > 1)
    {
      game_speed = game_speed / 2;
      // 显示当前速度的标签
      label_cur_speed_2->setText("x" + QString::number(game_speed));
      
      timer->start(1000 / game_speed);
    }
    });

我这里设置的game_speed的最小值为1,最大值为8,所以会有上下限的判断;当然也有其他很多思路,比如给T设定一个初始值,每次按下加速按钮时,T就加上200;每次按下加速按钮时,T就减去200(注意判断小于0);

效果展示:

拓展2 游戏暂停

相同的,实现暂停游戏也很简单。用到定时器中的一个函数timer->stop(),即定时器停止函数;

这边的逻辑部分需要注意一下,当按下”暂停游戏“按钮时,游戏暂停,并且”暂停游戏“字样应当改为”继续游戏“;相反,当按钮”继续游戏“时,字样也应当发生改变;要实现单个按钮状态的切换,我在网上没有找到好的方法,智能曲线救国,在程序中加了个标志位:

// 0:表示游戏正在运行 1: 表示游戏正在暂停
	int pause_flag;

因此,暂停按钮的点击事件部分可以这样写:

  // 游戏暂停按钮响应事件
  (void)connect(button_game_pause, &QPushButton::clicked, [=]() {
    // 根据暂停标志得知当前游戏状态
    // 从而切换该按钮的状态
    switch (pause_flag)
    {
    case 0:
      timer->stop();
      pause_flag = 1;
      button_game_pause->setText(QString::fromLocal8Bit("继续游戏"));
      break;
      
    case 1:
      timer->start(1000 / game_speed);
      pause_flag = 0;
      button_game_pause->setText(QString::fromLocal8Bit("暂停游戏"));
      break;
      
    default:
      break;
    }   
    });

效果展示:

拓展3 重新开始

在原窗口实现重新开始功能的思路如下:

  1. 重新实例化一个对象,即m_snake = new Snake;
  2. 初始化各项变量,例如游戏速度,暂停标志,游戏得分标签等等

实现代码如下:

  // 重新开始按钮响应事件
  (void)connect(button_restart, &QPushButton::clicked, [=]() {
    // 重新实例化
    m_snake = new Snake;
    game_speed = 1;
    pause_flag = 0;
    
	// 速度标签重置
    label_cur_speed_2->setText("x" + QString::number(game_speed));
    // 得分标签重置
    label_score_2->setText(QString::number(m_snake->score));
    // 暂停标志重置
    pause_flag = 0;
    // 将暂停按钮重置
    button_game_pause->setText(QString::fromLocal8Bit("暂停游戏"));
    // 重新绘制
    update();
    // 启用定时器
    timer->start(1000 / game_speed);
    });

效果展示:

拓展4 非法输入过滤

在调试程序的时候,发现了一个非常有趣的问题,如下:

不知道大家有没有看清楚蛇是怎么死的,它原地掉头把自己咬到了(可以看到蛇死的时候,眼睛是向左边的)。当时出现这个bug的时候很懵逼,明明写了程序判断,当蛇朝向右边时,向左移动是非法操作,是不执行任何操作的,那为什么会出现这个问题呢?

其实,在那个周期内,我迅速的按了 ’ W ’ ,’ A ’ 两个键,即先向上再向左;这时程序先判断,发现向上移动合法,于是将蛇的方向改为了向上 ’ U ',还没等到下一周期,蛇头还未发生移动(蛇每周期移动一格),这时程序又接收到了向左的操作,因为当前方向已经改成了向上,所以此时向左也是合法操作,于是又将蛇的朝向改为了向左,等于在一周期内发生了两次转向操作,并且都是合法的,于是到了下一周期,执行moveSnake函数的时候,蛇头直接咬到了自己。

问题的缘由弄清楚,那么怎么避免单周期内多次操作呢?

我的做法是:为每个周期加个标志,即定义int step_count = 1;比如定时器第一次循环的时候,step_count的值为 1,可以认为当前周期叫做 ’ 1 ’ 周期,第二次循环的时候,step_count的值加 1,所以第二周期可以叫做 ’ 2 ’ 周期,以此类推,每周期开始时将step_count的值加 1,表示为该周期的标识;

再添加一个标志位step_key_input,当键盘输入时,将当前周期的标识赋值给step_key_input,下次键盘输入时,判断step_key_input是否等于step_count,如果等于说明在这个周期内,已经有过一个合法输入了,本次操作不执行。

举个例子:就类似上面出错的情景,在定时器循环的第3个周期,step_count = 3,蛇朝向左边,这时我们输入向上操作,于是step_key_input = 3,当前朝向被修改为向上;紧接着,我们又输入了向左操作,由于此时的step_count = step_key_input = 3,所以本次向左操作被视为非法操作,故不执行。在当前周期内,只允许1个合法操作,这样就可以避免上述bug的发生。

关于这个问题,肯定有很多不同的解决思路,上述方法只能说是解决问题,算不上是最优方案,还是要多思考,多学习。最后附一张菜鸡截图:

附代码文件:

笔者环境是VS2019社区版+QT5(注意:QT4可能不兼容,特别是connecct函数那部分),与在QT中直接编译可能会有些不同,代码应该是没有问题的;建议无论是使用VS还是QT,先创建QT界面项目,然后用下面的文件将项目中的替换掉,否则可能无法运行。

C++/QT 贪吃蛇简陋小游戏
百度网盘链接(提取码:aciq)

评论6
请先登录 后发表评论~
©️2021 CSDN 皮肤主题: 深蓝海洋 设计师:CSDN官方博客 返回首页

打赏作者

wapitier

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值