植物大战僵尸 PVZ Qt版

前排提示:这是我花了两个星期写的QT版PVZ程序,时间比较紧,因此许多地方可能有不足,请见谅!在GitHub的代码没有注释,因为。。。即使有注释你也未必能看得懂 ,所以阅读代码前先看这篇文章。

源代码请见:GitHub-PVZ

一、初步设计

1. 框架

本程序分为两个部分:菜单界面采用QWidget实现,有一个父窗口,通过信号槽机制调用各种子窗口;游戏界面使用QGraphicsScene框架实现,里面包含view和各种图形项item

本程序采用面向对象程序设计思路,创建基类植物Plant、僵尸Zombie、卡牌Cards,在这基础上派生出不同的类,如豌豆射手、路障僵尸等。创建一个Map类,用以记录植物的位置,以及是否可以种植。

2. 界面

首先,整个窗口界面是900X600的大小,并且大小不可调。

菜单界面依然是植物大战僵尸的那个味道,放置熟悉的菜单背景,在右侧放置游戏开始按钮,玩家点击后程序会展示僵尸的一封信,再次点击即可进入游戏界面。

游戏界面也依然是植物大战僵尸的那个味道,几乎差不多。最上方是卡牌选择框,在选择框左侧显示阳光点数。左上角是返回菜单和游戏暂停按钮,在左侧摆放割草机,右侧出僵尸,正中一大片区域放植物,最下方展示游戏目标。

游戏开始,依然是植物大战僵尸的味道。玩家选择卡牌,在地图中央点击放置植物;若想铲除植物,则需点击右上方的铲子进行铲除操作。

3. 图形项Item

不可点击的图形项:基于QGraphicsItemPlantZombie包含以下函数,注意,前两个是必须要实现的:

boundingRect()  // Item的绘制边界
paint()  // Item的绘制
collidesWithItem()  //Item的碰撞判定
advance()  // Item的动作状态

因为每种植物和僵尸的动作特点不同,实际编写程序的时候,是需要重写这四个函数的。

可点击的图形项:例如基于QGraphicsItemCardsSun(阳光点数)包含以下函数:

boundingRect()  // Item的绘制边界
paint()  // Item的绘制
collidesWithItem()  //Item的碰撞判定
advance()  // Item的动作状态
mousePressEvent() // 鼠标动作

想实现各种Item的动画效果,就要将定时器Timer绑定到QGraphicsSceneadvance函数。同时,游戏生成僵尸的函数也需要绑定到advance函数。

好,下面进入正题。

二、详细设计

下面来看看几个主要的类:

1. 地图Map

Map类是整个程序的核心部位,是重中之重,Map主要完成了以下几件事情:

  • 界定地图边界
  • 计算并存储植物的位置
  • 接收玩家的点击事件(尤其是卡牌被点击后)
  • 生成太阳

我们来看看Map的变量:

static Cards *PreparedPlant; //记录玩家点击了哪张卡牌
static Plant *PlantMap[9][5]; //记录植物的位置,类型是Plant,一般情况下为nullptr

地图是如何响应玩家的点击事件的呢?正常情况下,玩家点击地图是没有反应的;但是如果玩家先前点击卡牌购买植物,再点击地图,那么就必须做出反应了:

    if(PreparedPlant && event->button()==Qt::LeftButton)
    {
        Shovel::isMovePlant = 0;
        int i = ((int)event->scenePos().x() - 250) / 80;
        int j = ((int)event->scenePos().y() - 100) / 90;
        if(i == 9) --i;  if(j == 5) --j;
        qDebug()<< i << j; //以上都是计算准备种植的位置
        if(!PlantMap[i][j]) // 如果这个位置没被其他植物占用
        {
            QPointF plantPos(290+80*i, 145+90*j);
            switch(PreparedPlant->No)//根据卡牌序号,生成新的植物对象
            {
            case 0: PlantMap[i][j] = new Sunflower(plantPos); break;
            case 1: PlantMap[i][j] = new Peashooter(plantPos); break;
           ........................
            default: break;
            }
            PlantMap[i][j]->setZValue(j);
            scene()->addItem(PlantMap[i][j]);
            PreparedPlant->isPlanted = 1; //告诉卡牌我已经被种植了,意味着可以开始你的冷却动画了
            PreparedPlant->counter = 0; //开始进行冷却等待
            PreparedPlant->StartMode = 0;
            PreparedPlant->sunTotal -= PreparedPlant->sunNeed;
            PreparedPlant = nullptr; //记录准备植物的变量清空,等待下一次卡牌被点击时被赋予新的值
            QApplication::restoreOverrideCursor(); //光标出栈,恢复光标形状
        }    
    }

其实铲子的功能也是同样的原理,这里不说了。

2. 卡牌Cards

Cards主要完成的任务有:

  • 显示卡牌的各种状态
  • 判断并接收玩家的点击事件,并改变光标形状
  • 存储玩家的阳光点数

基类Cards包含以下变量:

  int No, sunNeed;// 植物编号;植物所需的阳光点数
  int counter; // 计时器,
  int coolTime; // 冷却时间
  QString name; // 植物名字
  bool isPlanted, StartMode; // 记录是否被种植;记录是否是游戏开始,这个是干嘛的后面再说
  static int sunTotal; // 玩家现有的阳光点数,静态成员变量
  

我们再来看看几个函数:

Cards::Cards()
{
    counter = 0; StartMode = 1; //这里的初始化很重要!要记住,待会要说
     isPlanted = 0; 
    setCursor(Qt::PointingHandCursor);
    setZValue(0); //设置放置顺序
}

这个函数实现了卡牌绘制和冷却效果:

void Cards::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
    Q_UNUSED(option);
    Q_UNUSED(widget);
    QImage image(":/graphics/Cards/card_" + name.toLower() + ".png");
    painter->drawImage(boundingRect(), image); //画出卡牌
    if(sunTotal < sunNeed) //情况一:当阳光点数不足以购买植物时,画禁止点击的矩形
    {
        painter->setBrush(QColor(128, 128, 0, 200));
        painter->drawRect(boundingRect());
    }
    if(counter < coolTime && !StartMode && isPlanted) //情况二:未到冷却时间,画冷却效果
    // 注意,游戏开始时不需要冷却效果,如果没有StartMode来判断游戏是否开始,那游戏一开始就会跑冷却效果,就没法购买植物了;同时,要真的种了植物才能跑冷却,如果没有种不需要冷却效果
    {
        painter->setBrush(QColor(0, 0, 0, 200));
        painter->drawRect(QRectF(425+60*No, 10, 50, 70*(1-qreal(counter)/coolTime)));
    }
}

这个函数实现了鼠标点击事件:

void Cards::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
    if(event->button()==Qt::LeftButton) //如果卡牌被点击
    { //如果阳光足够,且已达到冷却时间
        if(sunTotal >= sunNeed && (counter == coolTime || StartMode)){
            QCursor cursor(QPixmap(":/graphics/Plants/" + name + "/0.gif"));//改变鼠标光标为对应植物光标
            QApplication::setOverrideCursor(cursor);
            Map::PreparedPlant = this;//告诉地图,我接下来可能要种植这株植物了
        }
    }
}

最后,派生类继承了这些函数,并初始化自己的变量,例如:

sunflower::sunflower()
{
    name = "SunFlower"; coolTime = 227; No = 0;
    sunNeed = 50; tip = "向日葵(50)";
}

3. 植物Plant

基类Plant包含以下变量:

enum { Type = UserType + 1}; //植物类型记作1
 int HP; //血量
 int ATK; //攻击力
 int posX, posY; //位置
 QString name; //植物名字
 QMovie *movie; //动画

基类Plant包含以下函数(前三个之前说过,不说了):

 QRectF boundingRect() const;
 void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget);
 void advance(int phase);
 int type() const; // 返回类型,这个作用就是,
 // 是植物的就返回植物类型(记作1),是僵尸的就返回僵尸类型(记作2),以此类推

为什么没有collidesWithItem()?因为大部分植物的确不需要碰撞检测,一般碰撞发出者是僵尸。比如向日葵、豌豆射手是不需要碰撞检测的,但是樱桃炸弹、土豆地雷这些是需要的,因为它是碰撞发出者。

以下两个成员函数位于基类,凡是植物,没有特殊要求的,都默认执行这两个函数:

QRectF Plant::boundingRect() const
{
    return QRectF(180, 0, 64, 70);
}

void Plant::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
    Q_UNUSED(option);
    Q_UNUSED(widget);
    painter->drawImage(boundingRect(), movie->currentImage());
    painter->drawRect (boundingRect());
}

下面来说具体植物(派生类)大致是怎么实现的:

豌豆射手Peashooter

这些都是每种植物都有的基本成员,后面的植物不再赘述:

成员函数:
Peashooter(QPointF point);
~Peashooter();
void advance(int phase);

成员变量:
 Pea *pea; // 生成豌豆
 int peaCounter, peaCreateTime; // 第一个有计时器的功能,第二个是豌豆生成间隔

再来看看是怎么实现这三个函数的:

//构造函数
Peashooter::Peashooter(QPointF point) // 该形参是告诉豌豆射手的位置在哪
{
    HP = 300; peaCounter = 0; peaCreateTime = 42; // 各种初始化,不解释
    name = "Peashooter";
    movie = new QMovie(":/graphics/Plants/" + name + "/1.gif"); // 播动画
    movie->start();
    posX = point.x()-32; // 位置调整,因为鼠标点击种植的位置与植物的位置有偏差
    posY = point.y()-35;
    setPos(posX-180, posY); // 算好位置,放植物
}

//析构函数
Peashooter::~Peashooter()
{
    Map::PlantMap[(posX-250)/80][(posY-100)/90] = nullptr; //这里是告诉Map,我要走了,麻烦你把我这个位置设置成空指针
    if(movie) //删除动画,释放内存
        delete movie;
}

豌豆射手的advance函数是用来发射豌豆的:

void Peashooter::advance(int phase)
{
    if(!phase)
        return;
    update();
    if(peaCounter < peaCreateTime) //没到发射时间,计时器继续工作
        ++peaCounter;
    if(peaCounter == peaCreateTime) //到时间了,该发射豌豆了
    {
        pea = new Pea(QPointF(posX+50, posY));
        scene()->addItem(pea);
        peaCounter = 0;
    }
    if(HP <= 0)
        delete this;
}

寒冰射手、双重射手与豌豆射手类似,请见具体代码,这里不再赘述。注意,寒冰射手的豌豆具有减慢效果,它的豌豆可以调整僵尸的速度因子:

zombie->speedFactor = 20; //僵尸速度将降到原来的20%
樱桃炸弹CherryBomb

樱桃炸弹是少有的几个需要碰撞检测的植物,它的碰撞检测collidesWithItem() 需要重写。除此之外,advance函数也与其他植物不同:

void Cherrybomb::advance(int phase)
{
    if(!phase)
        return;
    update();
    if(!atkStatus && counter == prepareTime) //如果已经完成准备工作
    {
        delete movie;
        movie = new QMovie(":/graphics/Plants/" + name + "/Boom.gif");
        movie->start();
        atkStatus = 1;
    }
    if(atkStatus) //如果处于攻击状态
    {
        QList<QGraphicsItem *> items = collidingItems(); //开始监测周围僵尸
        foreach(QGraphicsItem *item, items)
        {
            Zombie *zombie = qgraphicsitem_cast<Zombie *>(item);
            zombie->HP -= ATK;
            if(zombie->HP <= 0)
                zombie->setStatus = -1; //僵尸直接死亡
        }
        if(movie->currentFrameNumber() == movie->frameCount() - 1)
           delete this; //如果爆炸动画播完,删除植物
    }
    else //如果还在准备状态
        counter++;
    if(HP <= 0)
       delete this;
}

注意,土豆地雷与樱桃炸弹非常类似,只不过爆炸范围没有后者强,因此两者代码是基本相同的,这里不再赘述。

4. 僵尸Zombie

僵尸的动画比植物要复杂,随着HP的不同,僵尸的动画也会随之变化,因此僵尸的实现有些复杂。下面是僵尸的成员变量:

enum { Type = UserType + 2}; //僵尸类型是2
int HP, crHP, ATK, setStatus; 
// 总血量; 临界血量; 攻击力; 设置状态,这个待会会详细解释
int speedFactor; // 速度调整因子
int nowStatus // 僵尸现在的状态,初始值为1
int snowCounter; // 缓慢效果计时器
qreal speed; // 僵尸行进速度
bool isHead; // 用来记录僵尸有没有头
int posX, posY; // 僵尸初始位置
QString name; // 僵尸名字
QMovie *movie, *head; // 播放动画,前者播放身体动画,后者是头掉落的动画

下面是僵尸的成员函数:

  QRectF boundingRect() const override;
  void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
  void advance(int phase);
  bool collidesWithItem(const QGraphicsItem *other, Qt::ItemSelectionMode mode) const override;
  int type() const;
  void bodyMovie(QString pic); //这两个函数都是用来播放动画
  void headMovie(QString pic);

这里着重介绍一下advance函数,在advance函数中,分为三个部分:

  • 检查状态
  • 设置状态
  • 确定状态

为了更好地控制僵尸的动作状态,我们引入了一个状态系统,用状态码来标识每种僵尸的每个动作,见下表:

状态码普通僵尸足球/路障/铁桶僵尸报纸僵尸
-1爆炸死亡爆炸死亡爆炸死亡
0正常死亡正常死亡正常死亡
1有头走路有装饰物走路有报纸走路
2有头攻击有装饰物攻击有报纸攻击
3无头走路无装饰物走路无报纸走路
4无头攻击无装饰物攻击无报纸攻击
5无头走路无头走路
6无头攻击无头攻击

如上表所见,每个状态码都有一个相对应的动作。那么我们是怎么确定僵尸要做哪个动作?见下面的足球僵尸例子,你可以对应上表参照:

 QList <QGraphicsItem *> items = collidingItems();
    if(setStatus != -1) //如果爆炸死亡的,就直接-1,不管HP了
    {
     if(HP >= ornHP){           // HP=270-350 有头盔
        setStatus = 1;
        if(!items.isEmpty()){   // 如果碰到植物
            setStatus = 2;
            plant = qgraphicsitem_cast<Plant *> (items[qrand() % items.size()]);
        }
     }
     else if(HP >= crHP){  //HP=70-270 无头盔
        setStatus = 3;
        if(!items.isEmpty()){   // 如果碰到植物
            setStatus = 4;
            plant = qgraphicsitem_cast<Plant *> (items[qrand() % items.size()]);
        }
     }
     else if(HP > 0){        // HP=0-70 到达临界值不再有攻击力,HP匀速减少
        HP--;
        setStatus = 5;
        if(!items.isEmpty()){
            setStatus = 6;   // 如果碰到植物
            plant = qgraphicsitem_cast<Plant *> (items[qrand() % items.size()]);
        }
    }
    else                    // <0 死亡
        setStatus = 0;
    }

这是僵尸的advance函数的第一个部分:确定(检查)状态。僵尸初始化时,都默认是状态码1。每次调用该函数时,都要由这部分代码检查状态。

下面是第二个部分,这部分是用于设置状态,设置完成后,播放相对应的动画:

switch(setStatus)
    {
    case -1:
        speedFactor = 100;
        if(setStatus != nowStatus)
           bodyMovie(":/graphics/Zombies/" + name + "/BoomDie.gif");
        break;
    case 0:
        speedFactor = 100;
       if(setStatus != nowStatus)
       {
          bodyMovie(":/graphics/Zombies/" + name + "/Die.gif");
          if(isHead) //如果死的时候有头,麻烦把掉落头部的动画也播放一下
              headMovie(":/graphics/Zombies/Zombie/ZombieHead.gif");
       }
       break;
    case 1:
       if(setStatus != nowStatus)
         bodyMovie(":/graphics/Zombies/" + name + "/" + name + ".gif");
       setX(x() - speed * speedFactor / 100);
       break;
    case 2:
       if(setStatus != nowStatus)
            bodyMovie(":/graphics/Zombies/" + name + "/" + name + "Attack.gif");
       plant->HP -= ATK * speedFactor / 100;
       break;
    case 3:
       if(setStatus != nowStatus)
            bodyMovie(":/graphics/Zombies/" + name + "/" + name + "OrnLost.gif");
       setX(x() - speed * speedFactor / 100);
       break;
    case 4:
        if(setStatus != nowStatus)
            bodyMovie(":/graphics/Zombies/" + name + "/" + name + "OrnLostAttack.gif");
        plant->HP -= ATK * speedFactor / 100;
       break;
    case 5:
       if(setStatus != nowStatus)
       {
          bodyMovie(":/graphics/Zombies/" + name + "/LostHead.gif");
          if(isHead)
              headMovie(":/graphics/Zombies/Zombie/ZombieHead.gif");
       }
       setX(x() - speed * speedFactor / 100);
       break;
    case 6:
       if(setStatus != nowStatus)
       {
            bodyMovie(":/graphics/Zombies/" + name + "/LostHeadAttack.gif");
            if(isHead)
                headMovie(":/graphics/Zombies/Zombie/ZombieHead.gif");
       }
        break;
    default:
        break;
    }

设置完状态后,进入第三个操作:nowStatus = setStatus;,这是告诉僵尸,你的这个状态已经被确定了。如果下一次检查还是这个状态,那么将不会设置状态,除非下一次检查发现状态变了。这就是if(setStatus != nowStatus)的作用。

僵尸的实现基本上都是一致的,都是重复造轮子,就不多说了。

下面着重讲述我几个遇到的问题。

三、问题与解决

其实很多问题都可以通过阅读Qt官方文档就能解决,整个编写过程还算顺利。只不过,由于一开始没有用对框架,导致前期浪费了时间,所以完工的比较仓促。

1. 关于游戏暂停

之前没有使用QGraphicsScene框架写代码的时候就被这个问题困住,不过后来更换了框架之后,想到可以将所有动画绑定到定时器上,我只要来个timer->stop()就可以马上停止了。

2. 关于鼠标光标

点击卡牌,鼠标光标变成植物形状。之后左击地图,表示放置植物,光标恢复;右击地图,表示取消放置植物,光标恢复。因为整个窗口都需要光标显示为植物形状,因此只好使用QApplication::setOverrideCursor(cursor);,后面用了QApplication::restoreOverrideCursor();光标恢复以后,所有子部件的光标都变成同一个光标了。

上网查了一下,才知道QApplication::restoreOverrideCursor ()是撤销最近一次的setOverrideCursor()。如果setOverrideCursor()已经被调用两次,调用restoreOverrideCursor()会激活第一个光标设置。第二次调用这个函数会恢复初始窗口部件的光标。可以这么理解,QApplication::setOverrideCursor(cursor);是光标入栈,而restoreOverrideCursor()是光标出栈。查了查我写的程序,发现确实有两个setOverrideCursor(),而只有一个restoreOverrideCursor(),这就是问题所在了。

3. 关于坐标系统

许多物体的位置和碰撞检测都需要慢慢调试,因为窗口、view、scene和每个item都有自己的坐标系统,必须对这些要熟悉。最好加上qDebug慢慢调试。

4. 关于内存泄露

这个我有些体会。每个对象消亡后都要delete指针,释放内存。在经历许多次程序崩溃后终于明白,每一次delete之后指针并不会指向空指针,而是成为了野指针。最安全的做法是delete以后将其指向nullptr。空指针也是可以被delete的。

5. 关于游戏音乐

我将素材的音乐转换成WAV格式,但是QSound依然无法播放文件,这是因为比特率太大的缘故。想要播放mp3格式的文件应该用QMediaPlayer。由于时间比较紧,我没有实现这部分功能,但是经过测试,此方法可行。

6. 两个item的叠加顺序不正确

比如,按照逻辑,第二行的僵尸应该是遮住第一行的僵尸。所以,我又去查了官方文档。。。原来有这两个函数:setZValuestackBefore。默认的Z值是0,具有同样的Z值的item会按照插入的顺序来入栈。也就是说,GraphicsView会优先根据item的Z值决定item的层次,Z大的在上面,Z小的在下面,只有当Z值相同的情况下才会去理会stackBefore函数。这个函数在Z相同的情况下,允许你决定哪个在上哪个在下。

  • 5
    点赞
  • 61
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
【资源说明】 课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip 课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip 课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip 课程设计基于QtC++ 框架编写的简易植物大战僵尸游戏源码.zip 【备注】 1、该资源内项目代码都经过测试运行成功,功能ok的情况下才上传的,请放心下载使用! 2、本项目适合计算机相关专业(如计科、人工智能、通信工程、自动化、电子信息等)的在校学生、老师或者企业员工下载使用,也适合小白学习进阶,当然也可作为毕设项目、课程设计、作业、项目初期立项演示等。 3、如果基础还行,也可在此代码基础上进行修改,以实现其他功能,也可直接用于毕设、课设、作业等。 欢迎下载,沟通交流,互相学习,共同进步!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值