C 实现植物大战僵尸(四)

C 实现植物大战僵尸(四)

音频稍卡顿问题,用了 SFML 三方库已优化解决

安装 SFML

资源下载 https://www.sfml-dev.org/download/sfml/2.6.2/

C 实现植物大战僵尸,完结撒花(还有个音频稍卡顿的性能问题,待有空优化解决)。目前基本的功能模块已经搭建好了,感兴趣的友友可自行尝试编写后续游戏内容

因为 C 站不能上传动图,所以游戏实际效果可看后续文章更新,插一条试玩视频(https://live.csdn.net/v/441805)

后面项目全部源代码会上传至 C 站(https://gitcode.com/qq_44868502/PlantsAndZombiesBattle),

音频图片等因为 C 站上传文件大小的原因,导致没法上传了,需要的可在文章下方留言

十三 实现僵尸吃植物

实现和原 UP 有差异,僵尸捕获植物感觉很奇怪,不如设计成植物同样有血量,当植物血量为 0 时,植物死亡

调整植物和僵尸结构体,以及增加变量

c++ 复制代码
/* 僵尸相关结构和变量 */
#define MAX_ZOMBIE_NUM 10
#define MAX_ZOMBIE_DEAD_PIC_NUM 10
#define MAX_ZOMBIE_EAT_PIC_NUM 21
#define MAX_ZOMBIE_PIC_NUM 22
typedef struct Zombie {
    int x;              //当前 X 轴坐标
    int y;              //当前 Y 轴坐标
    int frameId;        //当前图片帧编号
    int speed;          //僵尸移动的速度
    int row;            //僵尸所在行
    int blood;          //默认僵尸血条为 100
    bool isDead;        //僵尸是否死亡
    bool isEating;      //僵尸是否在吃植物, 这些状态改用枚举更好, 待优化
    bool used;          //是否在使用
} Zombie;
Zombie zombies[MAX_ZOMBIE_NUM];
IMAGE imgZombies[MAX_ZOMBIE_PIC_NUM];
IMAGE imgDeadZombies[MAX_ZOMBIE_DEAD_PIC_NUM];
IMAGE imgZombiesEat[MAX_ZOMBIE_EAT_PIC_NUM];


/* 植物相关结构和变量 */
typedef struct Plant // 植物结构体
{
    int type;     //植物类型, -1 表示草地
    int frameId;  //表示植物摆动帧
    int blood;    //植物血量
} Plant;

游戏初始化接口 gameInit,加载图片至内存

c++ 复制代码
for (int i = 0; i < MAX_ZOMBIE_EAT_PIC_NUM; ++i) //加载僵尸吃植物图片
{
    sprintf(name, "res/zm_eat/0/%d.png", i + 1);
    loadimage(&imgZombiesEat[i], name);
}

游戏更新窗口接口,渲染图片至输出窗口

c++ 复制代码
for (int i = 0; i < MAX_ZOMBIE_NUM; ++i) //渲染僵尸
{
    if (zombies[i].used) 
    {
        if (zombies[i].isDead) putimagePNG(zombies[i].x, zombies[i].y + 30, &imgDeadZombies[zombies[i].frameId]);
        else if (zombies[i].isEating) putimagePNG(zombies[i].x, zombies[i].y + 30, &imgZombiesEat[zombies[i].frameId]);
        else putimagePNG(zombies[i].x, zombies[i].y + 30, &imgZombies[zombies[i].frameId]);
    }
}

更新游戏属性的接口,增加 eatPlants

c++ 复制代码
/* 更新游戏属性的接口 */
void updateGame() 
{
    updatePlantsPic();
    createSunshine();
    updateSunshine();
    createZombie();
    updateZombie();
    shoot();
    updateBullets();
    collsionCheck();
    eatPlants();
}
c++ 复制代码
/* 移除死亡的植物 */
Plant* plantDeath(Plant* plant)
{
    assert(plant);
    if (plant->type == PEA)  //释放对应种植植物内存
        free((PeaShooter*)plant);
    else if (plant->type == SUNFLOWER)
        free((SunFlower*)plant);

    Grass* grassPtr = (Grass*)calloc(1, sizeof(Grass)); //重置为草地
    assert(grassPtr);
    grassPtr->plant.type = -1;
    return (Plant*)grassPtr;
}

/* 僵尸吃植物接口 */
void eatPlants()
{
    PeaShooter* peaShooter = NULL;
    int row = 0, plantX = 0, zombieCurrX = 0;
    for (int i = 0; i < MAX_ZOMBIE_NUM; ++i) //遍历是否存在僵尸
    {
        if (zombies[i].used && !zombies[i].isDead) //僵尸正在使用中, 且存活
        {
            row = zombies[i].row;
            for (int j = 0; j < GRASS_GRID_COL; ++j) //遍历当前行是否存在植物
            {
                if (plants[row][j]->type >= PEA) 
                {
                    plantX = GRASS_LEFT_MARGIN + j * GRASS_GRID_WIDTH + 5; 
                    zombieCurrX = zombies[i].x + 80;
                    if (zombieCurrX > plantX + 10 && zombieCurrX < plantX + 60) //当僵尸已经到达植物附近
                    {
                        zombies[i].isEating = true;
                        plants[row][j]->blood -= 1; //植物扣血
                        if (plants[row][j]->blood <= 0) //植物被杀死
                        {
                            plants[row][j] = plantDeath(plants[row][j]); //移除死亡的植物
                            zombies[i].frameId = 0;
                            zombies[i].isEating = false; //僵尸解除吃植物状态
                        }
                    }
                }
            }
        }
    }
}

最后更新僵尸状态,在这里进行帧处理

c++ 复制代码
void updateZombie() 
{
    static int CallCnt = 0; //延缓函数调用次数
    if (++CallCnt < 3) return;
    CallCnt = 0;

    for (int i = 0; i < MAX_ZOMBIE_NUM; ++i)
    {
        if (zombies[i].used)
        {
            if (zombies[i].isDead)
            {
                if (++zombies[i].frameId >= MAX_ZOMBIE_DEAD_PIC_NUM) //僵尸死亡则更换死亡帧
                    zombies[i].used = false; //重置僵尸状态
            }
            else if (zombies[i].isEating)
            {
                zombies[i].frameId = ++zombies[i].frameId % MAX_ZOMBIE_EAT_PIC_NUM; //僵尸更换图片帧
            }
            else
            {
                zombies[i].x -= zombies[i].speed; //僵尸行走
                zombies[i].frameId = ++zombies[i].frameId % MAX_ZOMBIE_PIC_NUM; //僵尸更换图片帧
            }

            if (zombies[i].x < 170) //目前先这样写待优化
            {
                printf("GAME OVER !");
                MessageBox(NULL, "over", "over", 0);
                exit(0);
            }
        }
    }
}

效果展示

僵尸会对一条道路上的植物进行啃食,在啃食期间会正常受到豌豆射手的攻击,啃食结束后,植物死亡

十四 向日葵生成阳光

实现和原 UP 有差异,想保留原随机阳光球逻辑,所以这里是做了兼容处理逻辑,具体实现如下

向日葵结构体增加变量

c++ 复制代码
enum SUN_SHINE_STATUS { UNUSED, PRODUCE, GROUND, COLLECT };

/* 向日葵结构体 */
typedef struct SunFlower
{
    Plant plant;
    /* 这里也可以使用数组, 一个向日葵有多个阳光球成员*/
    SunShineBall sunShine; //向日葵生产的阳光球
    int timeInterval;             //向日葵生产阳光的计时器
    int status;       //向日葵生产的阳光球状态
    float t; //贝塞尔曲线时间点
    float speed; //阳光球移动速度
    vector2 p1, p2, p3, p4; //贝塞尔曲线位置点
    vector2 pCurr; //当前阳光球的位置
} SunFlower;

实现向日葵生产阳光的接口

需要注意的是在收集向日葵生产太阳球时,需要重置贝塞尔曲线

c++ 复制代码
/* 实现向日葵生产太阳球 */
void produceSunShine()
{
    SunFlower* sunFlower = NULL;
    for (int i = 0; i < GRASS_GRID_ROW; ++i) //遍历二维指针数组
    {
        for (int j = 0; j < GRASS_GRID_COL; ++j)
        {
            if (plants[i][j]->type == SUNFLOWER)
            {
                sunFlower = (SunFlower*)plants[i][j];
                switch (sunFlower->status)
                {
                    case COLLECT:
                        sunFlower->t += sunFlower->speed; //设置贝塞尔曲线开始时间
                        sunFlower->pCurr = sunFlower->p1 +
                            sunFlower->t * (sunFlower->p4 - sunFlower->p1); //构建贝塞尔曲线
                        if (sunFlower->t > 1) 
                        { 
                            sunShineVal += 25;
                            sunFlower->status = UNUSED;
                            resetVecotrVal(sunFlower, i, j);
                        }
                        break;
                    case GROUND:
                        if (--sunFlower->timeInterval <= 0) //超时则阳光消失
                        {
                            sunFlower->status = UNUSED; //重置状态
                            sunFlower->timeInterval = MAX_TIME_INTERVAL * (4 + rand() % 5);
                        }
                        break;
                    case PRODUCE:
                        sunFlower->t += sunFlower->speed; //设置贝塞尔曲线开始时间
                        sunFlower->pCurr = calcBezierPoint(sunFlower->t,
                            sunFlower->p1, sunFlower->p2, sunFlower->p3, sunFlower->p4); //构建贝塞尔曲线
                        if (sunFlower->t > 1)
                        {
                            sunFlower->t = 0;
                            sunFlower->status = GROUND;
                            sunFlower->timeInterval = MAX_TIME_INTERVAL * (4 + rand() % 5);
                        }
                        break;
                    case UNUSED:
                        if (--sunFlower->timeInterval <= 0)
                        {
                            sunFlower->status = PRODUCE;
                            sunFlower->timeInterval = MAX_TIME_INTERVAL * (4 + rand() % 5);
                        }
                        break;
                    default:
                        printf("ERROR");
                        break;
                }
            }
        }
    }
}
c++ 复制代码
/* 重置贝塞尔曲线坐标值 */
void resetVecotrVal(SunFlower* sunFlower, int x, int y)
{
    assert(sunFlower);
    if (sunFlower->status == COLLECT)
    {
        sunFlower->p1 = sunFlower->pCurr;
        sunFlower->p4 = vector2(262, 0);
        sunFlower->t = 0;
        const float distance = dis(sunFlower->p1 - sunFlower->p4);
        sunFlower->speed = 1.0 / (distance / 16.0);
    }
    else if (sunFlower->status == UNUSED)
    {
        const int distance = (50 + rand() % 50); //只往右抛即可
        const int currPlantX = GRASS_LEFT_MARGIN + y * GRASS_GRID_WIDTH + 5;
        const int currPlantY = GRASS_TOP_MARGIN + x * GRASS_GRID_HIGHT + 10;
        sunFlower->t = 0;
        sunFlower->timeInterval = MAX_TIME_INTERVAL * (4 + rand() % 5);
        sunFlower->speed = 0.05;
        sunFlower->p1 = vector2(currPlantX, currPlantY);
        sunFlower->p2 = vector2(sunFlower->p1.x + distance * 0.3, sunFlower->p1.y - 100);
        sunFlower->p3 = vector2(sunFlower->p1.x + distance * 0.7, sunFlower->p1.y - 100);
        sunFlower->p4 = vector2(currPlantX + distance, currPlantY +
            imgPlant[SUNFLOWER][0]->getheight() - imgSunShineBall[0].getheight());
    }
}

在更新游戏属性的接口中调用

c++ 复制代码
/* 更新游戏属性的接口 */
void updateGame() 
{
    updatePlantsPic();
    createSunshine();
    produceSunShine();
    updateSunshine();
    createZombie();
    updateZombie();
    shoot();
    updateBullets();
    collsionCheck();
    eatPlants();
}

其次,在种植向日葵的时候需要进行新增成员的初始化

c++ 复制代码
/* 种植植物接口, 主要释放草格子内存, 二维指针数组对应位置,指向初始化的植物 */
Plant* growPlants(Plant* plant, int type, int x, int y)
{
    assert(plant);
    free((Grass*)plant); //释放该位置草格子内存
    if (type == PEA) //根据类型初始化 PeaShooter
    {
        PeaShooter* peaShooter = (PeaShooter*)calloc(1, sizeof(PeaShooter)); //calloc 函数替代 malloc, 省略 memset
        assert(peaShooter);
        peaShooter->shootSpeed = DEFAULT_SHOOT_TIME; //豌豆射击速度, 或者叫豌豆发射子弹的时间间隔, -1 表示可发射子弹
        peaShooter->plant.blood = 100;
        return (Plant*)peaShooter;
    }
    else if (type == SUNFLOWER) //根据类型初始化 SunFlower
    {
        SunFlower* sunFlower = (SunFlower*)calloc(1, sizeof(SunFlower));
        assert(sunFlower);
        sunFlower->plant.type = 1;
        sunFlower->plant.blood = 100;
        sunFlower->timeInterval = MAX_TIME_INTERVAL * (4 + rand() % 5); //增加游戏随机性

        /* 初始化贝塞尔曲线 */
        const int distance = (50 + rand() % 50); //只往右抛即可
        const int currPlantX = GRASS_LEFT_MARGIN + y * GRASS_GRID_WIDTH + 5;
        const int currPlantY = GRASS_TOP_MARGIN + x * GRASS_GRID_HIGHT + 10;
        sunFlower->t = 0;
        sunFlower->speed = 0.05;
        sunFlower->p1 = vector2(currPlantX, currPlantY);
        sunFlower->p2 = vector2(sunFlower->p1.x + distance * 0.3, sunFlower->p1.y - 100);
        sunFlower->p3 = vector2(sunFlower->p1.x + distance * 0.7, sunFlower->p1.y - 100);
        sunFlower->p4 = vector2(currPlantX + distance, currPlantY +
            imgPlant[SUNFLOWER][0]->getheight() - imgSunShineBall[0].getheight());

        return (Plant*)sunFlower;
    }
}

在更新阳光球接口,添加新增更新向日葵生产阳光球帧的逻辑

c++ 复制代码
/* 更新随机阳光球接口, 主要更新随机阳光球的图片帧和处理飞跃状态时的 X Y 轴偏移 */
void updateSunshine()
{
    for (int i = 0; i < MAX_BALLS_NUM; ++i) 
    {
        if (balls[i].used)
        {
            if (balls[i].y < balls[i].destination)
                balls[i].y += 2; //每次移动两个像素
            else //当阳光下落至目标位置时, 停止移动
            {
                if (balls[i].timer < MAX_TIME_INTERVAL) ++balls[i].timer;
                else balls[i].used = false;
            }
            balls[i].frameId = ++balls[i].frameId % SUM_SHINE_PIC_NUM; //修改当前图片帧编号, 并在到达 SUM_SHINE_PIC_NUM 时重置图片帧为 0
        }
        else if (balls[i].xOffset) //阳光球处于飞跃状态
        {
            if (balls[i].y > 0 && balls[i].x > 262)
            {
                const float angle = atan((float)(balls[i].y - 0) / (float)(balls[i].x - 262)); //不断调整阳光球的位置坐标
                balls[i].xOffset = 16 * cos(angle);
                balls[i].yOffset = 16 * sin(angle);
                balls[i].x -= balls[i].xOffset;
                balls[i].y -= balls[i].yOffset;
            }
            else
            {
                balls[i].xOffset = 0;  //阳光球飞至计分器位置, 则将 xOffset 置 0, 且加上 25 积分
                balls[i].yOffset = 0;
                sunShineVal += 25;
            }
        }
    }

    /* 更新向日葵生产的日光 */
    SunFlower* sunFlower = NULL;
    for (int i = 0; i < GRASS_GRID_ROW; ++i) //遍历二维指针数组
    {
        for (int j = 0; j < GRASS_GRID_COL; ++j)
        {
            if (plants[i][j]->type == SUNFLOWER)
            {
                sunFlower = (SunFlower*)plants[i][j];
                if (sunFlower->status == GROUND || sunFlower->status == PRODUCE)
                    sunFlower->sunShine.frameId = ++sunFlower->sunShine.frameId % SUM_SHINE_PIC_NUM; //修改当前图片帧编号, 并在到达 SUM_SHINE_PIC_NUM 时重置图片帧为 0 
            }
        }
    }
}

在收集随机阳光接口中添加上收集向日葵生产的日光 新增逻辑

c++ 复制代码
/* 收集随机阳光接口 */
void collectSunShine(ExMessage* msg)
{
    IMAGE* imgSunShine = NULL;
    for (int i = 0; i < MAX_BALLS_NUM; ++i) //遍历阳光球
    {
        if (balls[i].used) //阳光球在使用中
        {
            imgSunShine = &imgSunShineBall[balls[i].frameId]; //找到对应的阳光球图片
            if (msg->x > balls[i].x && msg->x < balls[i].x + imgSunShine->getwidth()
                && msg->y > balls[i].y && msg->y < balls[i].y + imgSunShine->getheight()) //判断鼠标移动的位置是否处于当前阳光球的位置
            {
                PlaySound("res/audio/sunshine.wav", NULL, SND_FILENAME | SND_ASYNC); //异步播放收集阳光球音效
                balls[i].used = false;  //将阳光球状态更改为未使用 (飞跃状态, 因为 xOffset 赋值了)
                const float angle = atan((float)(balls[i].y - 0) / (float)(balls[i].x - 262)); //使用正切函数
                balls[i].xOffset = 16 * cos(angle); //计算 X 轴偏移
                balls[i].yOffset = 16 * sin(angle); //计算 Y 轴偏移
            }
        }
    }

    /* 收集向日葵生产的日光 */
    SunFlower* sunFlower = NULL;
    for (int i = 0; i < GRASS_GRID_ROW; ++i) //遍历二维指针数组
    {
        for (int j = 0; j < GRASS_GRID_COL; ++j)
        {
            if (plants[i][j]->type == SUNFLOWER)
            {
                sunFlower = (SunFlower*)plants[i][j];
                imgSunShine = &imgSunShineBall[sunFlower->sunShine.frameId]; //找到对应的阳光球图片
                if (sunFlower->status == GROUND) 
                {
                    if (msg->x > sunFlower->pCurr.x && msg->x < sunFlower->pCurr.x + imgSunShine->getwidth()
                        && msg->y > sunFlower->pCurr.y && msg->y < sunFlower->pCurr.y + imgSunShine->getheight()) //判断鼠标移动的位置是否处于当前阳光球的位置
                    {
                        PlaySound("res/audio/sunshine.wav", NULL, SND_FILENAME | SND_ASYNC); //异步播放收集阳光球音效
                        sunFlower->status = COLLECT;
                        resetVecotrVal(sunFlower, i, j); //更改曲线坐标
                    }
                }
            }
        }
    }
}

最后只需要在 updateWindow 接口中渲染一下向日葵生产的阳光即可

c++ 复制代码
SunFlower* sunFlower = NULL;
for (int i = 0; i < GRASS_GRID_ROW; ++i) //渲染向日葵阳光
{
    for (int j = 0; j < GRASS_GRID_COL; ++j)
    {
        if (plants[i][j]->type == SUNFLOWER)
        {
            sunFlower = ((SunFlower*)plants[i][j]);
            if (sunFlower->status > UNUSED)
            {
                putimagePNG(sunFlower->pCurr.x, sunFlower->pCurr.y,
                    &imgSunShineBall[sunFlower->sunShine.frameId]);
            }
        }      
    }
}

效果展示

向日葵可以生产阳光,生产阳光球后会以类似抛物线的形式(贝塞尔曲线)随机掉落在右一格的位置。鼠标移动至阳光球处,阳光将会被收集,阳光值增加 25

十五 片头僵尸展示

优化片头效果,实现函数如下,开局会先展示路边的僵尸

c++ 复制代码
/* 展示界面的僵尸相关变量 */
#define VIEW_ZOMBIE_NUM 9
#define VIEW_ZOMBIE_PIC_NUM 11
IMAGE imgViewZombies[VIEW_ZOMBIE_PIC_NUM];

/* 游戏开始前展示僵尸 */
void viewScence()
{
    int Xmin = WIN_WIDTH - imgBg.getwidth(); //-500
    vector2 zombieVec[VIEW_ZOMBIE_NUM] = {   //展示场景中, 僵尸初始位置
        {550,80},{530,160},{630,170},{530,200},{515,270},
        {565,370},{605,340},{705,280},{690,340}
    };

    int frameIndexArr[VIEW_ZOMBIE_NUM];
    for (int i = 0; i < VIEW_ZOMBIE_NUM; ++i)
        frameIndexArr[i] = rand() % VIEW_ZOMBIE_PIC_NUM;

    int cycleNum = 0; //利用循环计数, 解决僵尸抖动过快
    for (int x = 0; x >= Xmin; x -= 2) //缓慢移动展示僵尸
    {
        BeginBatchDraw(); //双缓冲解决闪屏
        
        putimage(x, 0, &imgBg);
        ++cycleNum; //当循环十次后, 更换每只僵尸的帧图片
        for (int i = 0; i < VIEW_ZOMBIE_NUM; ++i) //循环僵尸个数
        {
            putimagePNG(zombieVec[i].x - Xmin + x, zombieVec[i].y, &imgViewZombies[frameIndexArr[i]]); //渲染僵尸图片
            if (cycleNum > 2)
                frameIndexArr[i] = (++frameIndexArr[i]) % VIEW_ZOMBIE_PIC_NUM; //更换帧图
        }

        if (cycleNum > 2) cycleNum = 0; //重置循环计数
        EndBatchDraw();
        Sleep(5);
    }

    //停留 3 S 展示
    for (int k = 0; k < MAX_TIME_INTERVAL / 2; ++k)
    {
        BeginBatchDraw(); //双缓冲解决闪屏

        putimage(Xmin, 0, &imgBg); //相当于把图片向左移动 500 个像素
        for (int i = 0; i < VIEW_ZOMBIE_NUM; ++i) //循环僵尸个数
        {
            putimagePNG(zombieVec[i].x, zombieVec[i].y, &imgViewZombies[frameIndexArr[i]]); //渲染僵尸图片
            frameIndexArr[i] = (++frameIndexArr[i]) % VIEW_ZOMBIE_PIC_NUM; //更换帧图
        }
        EndBatchDraw();
        Sleep(30);
    }

    //移动回主界面
    cycleNum = 0;
    for (int x = Xmin; x <= 0; x += 2)
    {
        BeginBatchDraw(); //双缓冲解决闪屏

        putimage(x, 0, &imgBg);
        ++cycleNum; //当循环十次后, 更换每只僵尸的帧图片
        for (int i = 0; i < VIEW_ZOMBIE_NUM; ++i) //循环僵尸个数
        {
            if (zombieVec[i].x - Xmin + x > 0)
            {
                putimagePNG(zombieVec[i].x - Xmin + x, zombieVec[i].y, &imgViewZombies[frameIndexArr[i]]); //渲染僵尸图片
                if (cycleNum > 2)
                    frameIndexArr[i] = (++frameIndexArr[i]) % VIEW_ZOMBIE_PIC_NUM; //更换帧图
            }
        }

        if (cycleNum > 2) cycleNum = 0; //重置循环计数
        EndBatchDraw();
        Sleep(5);
    }
}

在主函数中调用

效果展示

游戏开场会缓慢的移动窗口至马路边,停顿观察路边僵尸(僵尸会一摇一摇的抖动),然后游戏镜头会再缓慢移动至原界面

十六 植物栏滑动

在上述游戏界面拉回主界面过程中,植物菜单栏会缓慢滑动出现,具体实现如下

c++ 复制代码
/* 植物栏滑动 */
void barsDown()
{
    int imgBarHeight = imgBar.getheight();
    for (int i = -imgBarHeight; i <= 6; ++i) //这里因为微调了植物卡片位置为 6
    {
        BeginBatchDraw();
        putimage(0, 0, &imgBg);             //渲染地图
        if (i <= 0) putimagePNG(250, i, &imgBar); //但植物栏的位置为 0
        else putimagePNG(250, 0, &imgBar); //渲染植物栏
        
        for (int j = 0; j < PLANT_CNT; ++j) //遍历植物卡牌
            putimage(PIC_LEFT_MARGIN + j * PIC_WIDTH, i, &imgCards[j]); //渲染植物卡牌    

        EndBatchDraw();
        Sleep(10);
    }
    Sleep(1000);
}

在主函数中调用

效果展示

在上述开场游戏界面拉回主界面过程中,植物菜单栏会缓慢滑动出现

十六 判断游戏结束

相关结构和变量

c++ 复制代码
/* 游戏输赢相关的结构和变量 */
enum { GAMEING, WIN, FAIL };
#define INGAME_ZOMBIE_NUM 15
int killZombies = 0;
int gameStatus = GAMEING;

创建僵尸接口时判断杀死的僵尸是否满足该局僵尸的数目了,如果是则不再创建

c++ 复制代码
/* 创建僵尸接口, 主要用于初始化僵尸 */
void createZombie()
{
    if (killZombies >= INGAME_ZOMBIE_NUM) return;
    static int zombieCallCnt = 0; //延缓函数调用次数并增加些随机性
    static int randZombieCallCnt = 500;
    if (zombieCallCnt++ < randZombieCallCnt) return;
    randZombieCallCnt = 300 + rand() % 200;
    zombieCallCnt = 0;

    for (int i = 0; i < MAX_ZOMBIE_NUM;  ++i) //找一个未在界面的僵尸初始化
    {
        if (!zombies[i].used)
        {
            zombies[i].row = rand() % GRASS_GRID_ROW; //僵尸出现在第几行(从 0 开始)
            zombies[i].x = WIN_WIDTH;
            zombies[i].y = zombies[i].row * GRASS_GRID_HIGHT; //出现在草地的任意一格上
            zombies[i].frameId = 0;
            zombies[i].speed = 1;  //僵尸的移动速度
            zombies[i].blood = 100; //默认僵尸血条为 100
            zombies[i].isDead = false; //僵尸存活
            zombies[i].isEating = false;
            zombies[i].used = true;
            break; //结束循环
        }
    }
}

在原子弹和僵尸碰撞接口 collsionCheck 中 ,若杀死僵尸数大于或等于该局游戏僵尸数目,则改变游戏状态

原更新僵尸接口中,若僵尸已移动至最左端,则游戏失败

最后在 main 函数中调用检验游戏状态的函数,即可判断游戏输赢

checkGameOver 会用到 在线 MP3 音频转 WAV

c++ 复制代码
/* 判断游戏输赢 */
IMAGE imgGameOver; //工具栏图片
bool checkGameOver()
{
    if (gameStatus == WIN)
    {
        Sleep(500);
        PlaySound("res/audio/win.wav", NULL, SND_FILENAME | SND_ASYNC); //异步播放音效
        loadimage(0, "res/gameWin.png");
        return true;
    }
    else if (gameStatus == FAIL)
    {
        Sleep(500);
        PlaySound("res/audio/lose.wav", NULL, SND_FILENAME | SND_ASYNC); //异步播放音效
        loadimage(&imgGameOver, "res/gameFail.png");
        putimagePNG(300, 140, &imgGameOver);
        return true;
    }
    return false;
}


/* 主函数 */
int main()
{
    gameInit(); //不能把 startUI 放在 gameInit 前, gameInit 包含了创建游戏图形窗口
    startUI();
    viewScence();
    barsDown();
    updateWindow(); //窗口视图展示

    int timer = 0; //用以计时 20 毫秒更新一次
    while (1)
    {
        userClick(); //监听窗口鼠标事件
        timer += getDelay();

        if (timer > 20)
        {
            updateWindow(); //更新窗口视图
            updateGame(); //更新游戏动画帧
            if (checkGameOver()) break; //判断游戏输赢
            timer = 0;
        }
    }

    destroyPlants(); //释放内存
    system("pause");
    return 0;
}

效果展示

一些游戏体验优化

① 豌豆不能太提前射击僵尸

在射击接口 shoot 里,校验僵尸和窗口右端的距离即可

② 卡牌太阳值不够不能选取

如果阳光值不够选取植物,则渲染为灰色,阳光值不够不能种植该植物;且植物有冷却时间,在冷却时间内植物不能种植

c++ 复制代码
/* 游戏体验优化, 阳光值不足或植物冷却时不能种植 */
IMAGE imgBlackCards[PLANT_CNT]; //植物不能种植卡片
IMAGE imgFreezeCards[PLANT_CNT]; //植物冷却卡片
#define PEA_FREEZE_TIME 500
#define SUMFLOWER_FREEZE_TIME 200
static int peaPlantInterval = 500;
static int sumFlowerPlantInterval = 200;

enum PLANT_CARD_STATUS { BRIGHT, GREY, FREEZE };
int plantCardStatus[PLANT_CNT]; //植物卡片状态数组

更新植物卡牌状态函数代码

c++ 复制代码
/* 更新植物卡牌状态 */
void updatePlantCardStatus()
{
    for (int i = 0; i < PLANT_CNT; ++i) //判断植物卡牌状态
    {
        if (i == PEA)
        {
            if (sunShineVal < 100)                  //阳光值不够
                plantCardStatus[i] = GREY;          //卡片灰色
            else if (sunShineVal >= 100 && peaPlantInterval < PEA_FREEZE_TIME) //阳光值够但在冷却时间内
                plantCardStatus[i] = FREEZE;        //卡片冻结
            else
                plantCardStatus[i] = BRIGHT;        //卡片原色
        }
        else if (i == SUNFLOWER)
        {
            if (sunShineVal < 50)
                plantCardStatus[i] = GREY;
            else if (sunShineVal >= 50 && sumFlowerPlantInterval < SUMFLOWER_FREEZE_TIME)
                plantCardStatus[i] = FREEZE;
            else
                plantCardStatus[i] = BRIGHT;
        }
    }
}

修改植物栏滑动逻辑

c++ 复制代码
/* 植物栏滑动 */
void barsDown()
{
    int imgBarHeight = imgBar.getheight();
    updatePlantCardStatus();
    for (int i = -imgBarHeight; i <= 6; ++i) //这里因为微调了植物卡片位置为 6
    {
        BeginBatchDraw();
        putimage(0, 0, &imgBg);             //渲染地图
        if (i <= 0) putimagePNG(250, i, &imgBar); //但植物栏的位置为 0
        else putimagePNG(250, 0, &imgBar); //渲染植物栏
        
        for (int j = 0; j < PLANT_CNT; ++j) //遍历植物卡牌
        {
            if (plantCardStatus[j] == BRIGHT)
                putimage(PIC_LEFT_MARGIN + j * PIC_WIDTH, i, &imgCards[j]);  //渲染植物卡牌
            else if (plantCardStatus[j] == GREY)
                putimage(PIC_LEFT_MARGIN + j * PIC_WIDTH, i, &imgBlackCards[j]);
            else
                putimage(PIC_LEFT_MARGIN + j * PIC_WIDTH, i, &imgFreezeCards[j]);
        }  

        EndBatchDraw();
        Sleep(10);
    }
    Sleep(1000);
}

种植植物时记得扣除太阳值和重置冷却

c++ 复制代码
/* 种植植物接口, 主要释放草格子内存, 二维指针数组对应位置,指向初始化的植物 */
Plant* growPlants(Plant* plant, int type, int x, int y)
{
    assert(plant);
    free((Grass*)plant); //释放该位置草格子内存
    if (type == PEA) //根据类型初始化 PeaShooter
    {
        PeaShooter* peaShooter = (PeaShooter*)calloc(1, sizeof(PeaShooter)); //calloc 函数替代 malloc, 省略 memset
        assert(peaShooter);
        peaShooter->shootSpeed = DEFAULT_SHOOT_TIME; //豌豆射击速度, 或者叫豌豆发射子弹的时间间隔, -1 表示可发射子弹
        peaShooter->plant.blood = 100;

        //扣除太阳值和重置冷却
        sunShineVal -= 100;
        peaPlantInterval = 0;
        updatePlantCardStatus();
        return (Plant*)peaShooter;
    }
    else if (type == SUNFLOWER) //根据类型初始化 SunFlower
    {
        SunFlower* sunFlower = (SunFlower*)calloc(1, sizeof(SunFlower));
        assert(sunFlower);
        sunFlower->plant.type = 1;
        sunFlower->plant.blood = 100;
        sunFlower->timeInterval = MAX_TIME_INTERVAL * (4 + rand() % 5);

        /* 初始化贝塞尔曲线 */
        const int distance = (50 + rand() % 50); //只往右抛即可
        const int currPlantX = GRASS_LEFT_MARGIN + y * GRASS_GRID_WIDTH + 5;
        const int currPlantY = GRASS_TOP_MARGIN + x * GRASS_GRID_HIGHT + 10;
        sunFlower->t = 0;
        sunFlower->speed = 0.05;
        sunFlower->p1 = vector2(currPlantX, currPlantY);
        sunFlower->p2 = vector2(sunFlower->p1.x + distance * 0.3, sunFlower->p1.y - 100);
        sunFlower->p3 = vector2(sunFlower->p1.x + distance * 0.7, sunFlower->p1.y - 100);
        sunFlower->p4 = vector2(currPlantX + distance, currPlantY +
            imgPlant[SUNFLOWER][0]->getheight() - imgSunShineBall[0].getheight());

        sunShineVal -= 50;  //扣除太阳值和重置冷却
        sumFlowerPlantInterval = 0;
        updatePlantCardStatus();
        return (Plant*)sunFlower;
    }
}

原 updatePlantsPic 接口中更新 peaPlantInterval 和 sumFlowerPlantInterval

c++ 复制代码
/* 更新植物图片帧接口, 主要用于实现植物摇摆 */
void updatePlantsPic()
{
    ++peaPlantInterval;
    ++sumFlowerPlantInterval;
    updatePlantCardStatus();

    for (int i = 0; i < GRASS_GRID_ROW; ++i) //遍历二维指针数组
    {
        for (int j = 0; j < GRASS_GRID_COL; ++j)
        {
            if (plants[i][j]->type >= PEA && //找到非草地的植物
                imgPlant[plants[i][j]->type][++plants[i][j]->frameId] == NULL) //将植物图片增加一, 判断是否到达图片帧末尾            
                    plants[i][j]->frameId = 0; //重置图片帧为零
        }
    }
}

最后修改渲染卡片窗口的 updateWindow 函数

效果展示

如果阳光值不够选取植物,则渲染为灰色,阳光值不够不能种植该植物;且植物有冷却时间,在冷却时间内植物不能种植

③ 添加各种音乐

加上音效

初始背景音乐

c++ 复制代码
/* 游戏开始前的菜单界面 */
void startUI()
{
    IMAGE imageBg, imgMenu1, imgMenu2;
    loadimage(&imageBg, "res/menu.png");
    loadimage(&imgMenu1, "res/menu1.png");
    loadimage(&imgMenu2, "res/menu2.png");
    PlaySound("res/audio/bg.wav", NULL, SND_FILENAME | SND_ASYNC); //异步播放音效

    bool mouseStatus = false; //0 表示鼠标未移动至开始游戏位置
    while (1) 
    {
        BeginBatchDraw(); //双缓冲解决闪屏
        putimage(0, 0, &imageBg);
        putimagePNG(UI_LEFT_MARGIN, UI_TOP_MARGIN, mouseStatus ? &imgMenu2 : &imgMenu1); //根据鼠标是否移动至游戏开始位置, 显示不同的图片

        ExMessage msg;
        if (peekmessage(&msg)) //监听鼠标事件
        {
            if (msg.x > UI_LEFT_MARGIN && msg.x < UI_LEFT_MARGIN + UI_WIDTH
                && msg.y > UI_TOP_MARGIN && msg.y < UI_TOP_MARGIN + UI_HIGHT) //当鼠标移动至开始游戏位置, 界面高亮
            {
                putimagePNG(UI_LEFT_MARGIN, UI_TOP_MARGIN, &imgMenu2);
                mouseStatus = true; //表示鼠标移动至开始游戏位置, 如果一直不移动鼠标则一直高亮

                if (msg.message == WM_LBUTTONDOWN) //当鼠标点击时, 进入游戏
                {
                    PlaySound(0, 0, SND_FILENAME);
                    EndBatchDraw();
                    return; //结束函数
                }
            }
            else mouseStatus = false; //当鼠标未移动至开始游戏位置, 界面不高亮
        }
        EndBatchDraw();
    }
}

片头背景音乐

僵尸来了背景音乐

在 createZombie 接口中,添加如下代码

c++ 复制代码
if (createZombies == 1) PlaySound("res/audio/zombiescoming.wav", NULL, SND_FILENAME | SND_ASYNC); //异步播放音效

选取植物背景音乐

种植物音乐,种到不合适地方的音乐

豌豆射击的音乐

花了两块大洋买了原曲,支持一下(其实是为了游戏背景曲,哈哈)

遗留问题

音频播放同时播放两个音频,可以实现功能就是没用到其它音频库,导致游戏试玩时当有大量音频需要加载播放时,会稍有卡顿,待有空找个 Win 音频三方库优化一下吧

全部源代码和资源文件待后续把项目上传

相关推荐
史不了2 分钟前
静态交叉编译rust程序
开发语言·后端·rust
ad钙奶长高高6 分钟前
【C语言】扫雷游戏详解
c语言
读研的武19 分钟前
DashGo零基础入门 纯Python的管理系统搭建
开发语言·python
Andy38 分钟前
Python基础语法4
开发语言·python
但要及时清醒43 分钟前
ArrayList和LinkedList
java·开发语言
孚亭1 小时前
Swift添加字体到项目中
开发语言·ios·swift
hweiyu001 小时前
Go、DevOps运维开发实战(视频教程)
开发语言·golang·运维开发
mm-q29152227291 小时前
Python+Requests零基础系统掌握接口自动化测试
开发语言·python
星星火柴9362 小时前
笔记 | C++面向对象高级开发
开发语言·c++·笔记·学习