• 植物大战僵尸,是一个非常经典的小游戏,初学者从零开始,开发一个自己的植物大战僵尸,还是非常值得期待的!可以作为自己的课设,也可以用来快速提升自己的项目开发能力。

项目效果(详细视频教程-下载素材-点这里)

说明:因为完整动图提交后提示违规,所以这里仅截图示意。如果需要演示视频,在评论中回复即可。

项目准备

  • 安装Visual Studio的任意版本(推荐VS2019社区版、VS2022社区版)

  • 安装easyx图形库(官网下载地址)

  • 领取项目素材(回复“植物大战僵尸”,即可领取)

创建项目

使用VS创建项目,使用空项目模板:

导入素材:在项目目录下,创建res文件夹,把解压后的素材拷贝到res目录下。

实现游戏初始场景

代码如下(需要逐行代码视频讲解,可回复“代码讲解“)。

#include #include #include "tools.h"#include #pragma comment(lib, "winmm.lib")#define WIN_WIDTH900#define WIN_HEIGHT600enum { WAN_DOU, XIANG_RI_KUI, ZHI_WU_COUT };IMAGE imgBg;IMAGE imgBar;IMAGE imgCards[ZHI_WU_COUT];IMAGE* imgZhiWu[ZHI_WU_COUT][20];int curZhiWu;int curX, curY; //当前选中植物在移动过程中的坐标struct zhiWu {int type; // >=10:没有植物int frameIndex;};struct zhiWumap[3][9];int sunshine;int sunshineTable[ZHI_WU_COUT] = { 100, 50 };void gameInit() {loadimage(&imgBg, "res/bg.jpg");loadimage(&imgBar, "res/bar.png");sunshine = 150;curZhiWu = 0;memset(imgZhiWu, 0, sizeof(imgZhiWu));memset(map, 0, sizeof(map));char name[64];for (int i = 0; i < ZHI_WU_COUT; i++) {sprintf_s(name, sizeof(name), "res/Cards/card_%d.png", i + 1);loadimage(&imgCards[i], name);for (int j = 0; j getwidth() == 0) {delete imgZhiWu[i][j];imgZhiWu[i][j] = NULL;}}}initgraph(WIN_WIDTH, WIN_HEIGHT, 1);// 设置字体:LOGFONT f;gettextstyle(&f); // 获取当前字体设置f.lfHeight = 30;// 设置字体高度为 48f.lfWidth = 15;strcpy(f.lfFaceName, "Segoe UI Black");f.lfQuality = ANTIALIASED_QUALITY;// 设置输出效果为抗锯齿settextstyle(&f); // 设置字体样式setbkmode(TRANSPARENT);setcolor(BLACK);mciSendString("play res/bg.mp3 repeat", 0, 0, 0);}void updateWindow() {BeginBatchDraw();putimage(0, 0, &imgBg);putimagePNG(250, 0, &imgBar);for (int i = 0; i  0) {// 绘制正在移动的植物IMAGE* img = imgZhiWu[curZhiWu - 1][0];putimagePNG(curX - img->getwidth() * 0.5, curY - img->getheight() * 0.5, img);}for (int i = 0; i < 3; i++) {for (int j = 0; j  0) {int x = 260 + j * 81.6;// (msg.x - 260) / 81.6;int y = 180 + i * 103.6 + 14; // (msg.y - 210) / 103.6;int zhiWuIndex = map[i][j].type;int frameIndex = map[i][j].frameIndex;putimagePNG(x, y, imgZhiWu[zhiWuIndex - 1][frameIndex]);}}}char scoreText[8];sprintf_s(scoreText, sizeof(scoreText), "%d", sunshine);outtextxy(282 - 10 + 4, 50 + 15 + 2, scoreText);EndBatchDraw();}void userClick() {ExMessage msg;static int status = 0;if (peekmessage(&msg)) {if (msg.message == WM_LBUTTONDOWN) {if (msg.x > 338 && msg.x 6 && msg.y  260 && msg.y  180 && msg.y = sunshineTable[curZhiWu - 1]) {sunshine -= sunshineTable[curZhiWu - 1];int col = (msg.x - 260) / 81.6;int row = (msg.y - 210) / 103.6;printf("[%d,%d]\n", row, col);if (map[row][col].type == 0) {map[row][col].type = curZhiWu;map[row][col].frameIndex = 0;}}}status = 0;curZhiWu = 0;}}}void updateGame() {for (int i = 0; i < 3; i++) {for (int j = 0; j  0) {map[i][j].frameIndex++;if (imgZhiWu[map[i][j].type - 1][map[i][j].frameIndex] == NULL) {map[i][j].frameIndex = 0;}}}}}int main(void) {gameInit();int timer = 0;bool flag = true;while (1) {userClick();timer += getDelay();if (timer > 20) {timer = 0;flag = true;}if (flag) {flag = false;updateWindow();updateGame();}}return 0;}

添加启动菜单

创建菜单界面,代码如下:

void startUI() {IMAGE imgBg, imgMenu1, imgMenu2;loadimage(&imgBg, "res/menu.png");loadimage(&imgMenu1, "res/menu1.png");loadimage(&imgMenu2, "res/menu2.png");int flag = 0;while (1) {BeginBatchDraw();putimage(0, 0, &imgBg);putimagePNG(474, 75, flag " />在main函数中调用菜单,代码如下:

int main(void) {gameInit();startUI();int timer = 0;bool flag = true;while (1) {userClick();timer += getDelay();if (timer > 20) {timer = 0;flag = true;}if (flag) {flag = false;updateWindow();updateGame();}}return 0;}

生产阳光

熟悉植物大战僵尸的同学都知道,种植植物才能消灭僵尸,但是种植植物,需要先具备一定数量的阳光值。初始的阳光值很小。有两种方式生成阳光:第一种,随机降落少量的阳光;第二种,通过种植向日葵,让向日葵自动生产阳光。我们先实现第一种方式。

定义一个结构体,来表示阳光球。因为阳光是以旋转的方式运动的,所以定义一个图片帧数组,通过循环播放图片帧来实现旋转效果。

IMAGE imgSunshineBall[29]; struct sunshineBall { int x, y;int frameIndex;bool used;int destY;int timer = 0;};struct sunshineBall balls[10];

在gameInit函数中,初始化阳光帧数组。

memset(balls, 0, sizeof(balls));for (int i = 0; i < 29; i++) {sprintf_s(name, sizeof(name), "res/sunshine/%d.png", i + 1);loadimage(&imgSunshineBall[i], name);}

创建阳光,代码如下。

void createSunshine() {int ballMax = sizeof(balls) / sizeof(balls[0]);static int frameCount = 0;static int fre = 400;frameCount++;if (frameCount >= fre) {fre = 200 + rand() % 200;frameCount = 0;int i;for (i = 0; i = ballMax) return;balls[i].used = true;balls[i].frameIndex = 0;balls[i].x = 260 + rand() % (905 - 260);balls[i].y = 60;balls[i].destY = 180 + (rand() % 4) * 90 + 20;balls[i].timer = 0;}}

修改阳光的位置和帧序号,代码如下。

void updateSunshine() {int ballMax = sizeof(balls) / sizeof(balls[0]);for (int i = 0; i = balls[i].destY) {balls[i].timer++;if (balls[i].timer > 100) balls[i].used = false;}}}}

在updateGame函数中调用以上两个函数 ,以创建阳光并更新阳光的状态。

createSunshine();updateSunshine();

在updateWindow函数中,渲染阳光。

for (int i = 0; i < 10; i++) {if (balls[i].used) {putimagePNG(balls[i].x, balls[i].y, &imgSunshineBall[balls[i].frameIndex]);}}

收集阳光

当“阳光球”出现的时候,用户点击阳光球,就可以“收集”这个阳光,当前总的阳光值就会增加25点。在原版的植物大战僵尸游戏中,阳光球被收集后,会慢慢移动到顶部的“工具栏”的左侧。这个阳光球的“移动过程”,我们后续再实现。

定义一个全局变量,表示当前总的阳光值。

int sunshine;

在初始化gameInit中,设置一个初始值。

sunshine = 150;

创建收集阳光的函数,如下:

void collectSunshine(ExMessage* msg) {int count = sizeof(balls) / sizeof(balls[0]);int w = imgSunshineBall[0].getwidth();int h = imgSunshineBall[0].getheight();for (int i = 0; i x > x && msg->x y > y && msg->y < y + h) {balls[i].used = false;sunshine += 25;mciSendString("play res/sunshine.mp3", 0, 0, 0);}}}}

在用户点击处理中,调用收集阳光的函数。

#include #pragma comment(lib, "winmm.lib")void userClick() {ExMessage msg;static int status = 0;if (peekmessage(&msg)) {if (msg.message == WM_LBUTTONDOWN) {if (msg.x > 338 && msg.x < 338 + 65 * ZHI_WU_COUNT && msg.y < 96) {int index = (msg.x - 338) / 65;status = 1;curZhiWu = index + 1;} else {collectSunshine(&msg);}}// ...... }}

显示当前总的阳光值

在gameInit初始化中,设置字体。

LOGFONT f;gettextstyle(&f); // 获取当前字体设置f.lfHeight = 30;// 设置字体高度为 48f.lfWidth = 15;strcpy(f.lfFaceName, "Segoe UI Black");f.lfQuality = ANTIALIASED_QUALITY;// 设置输出效果为抗锯齿settextstyle(&f); // 设置字体样式setbkmode(TRANSPARENT);setcolor(BLACK);

在updateWindow中绘制阳光值。

char scoreText[8];sprintf_s(scoreText, sizeof(scoreText), "%d", sunshine);outtextxy(276, 67, scoreText);

创建僵尸

创建僵尸的数据模型。这里一共创建了10个僵尸,这10个僵尸全部被消灭后,这个关卡就胜利了。

struct zm {int x, y; int frameIndex;bool used;int speed;};struct zm zms[10];IMAGE imgZM[22];

僵尸数组,以及僵尸序列帧图片数组,在gameInit函数中进行初始化,如下。注意:把僵尸的素材图片保存到src/zm目录下。)

memset(zms, 0, sizeof(zms));srand(time(NULL));for (int i = 0; i < 22; i++) {sprintf_s(name, sizeof(name), "res/zm/%d.png", i + 1);loadimage(&imgZM[i], name);}

创建僵尸,代码如下:

void createZM() {static int zmFre = 500;static int count = 0;count++;if (count > zmFre) {zmFre = rand() % 200 + 300;count = 0;int i;int zmMax = sizeof(zms) / sizeof(zms[0]);for (i = 0; i < zmMax && zms[i].used; i++);if (i < zmMax) {zms[i].used = true;zms[i].x = WIN_WIDTH;zms[i].y = 180 + (1 + rand() % 3) * 100 - 8;zms[i].speed = 1;}}}

更新僵尸的数据(僵尸的图片帧序号、僵尸的位置),代码如下:

void updateZM() {int zmMax = sizeof(zms) / sizeof(zms[0]);static int count1 = 0;count1++;if (count1 > 2) {count1 = 0;for (int i = 0; i x  4) {count2 = 0;for (int i = 0; i < zmMax; i++) {if (zms[i].used) {zms[i].frameIndex = (zms[i].frameIndex + 1) % 22;}}}}

在updateGame函数中,创建僵尸并更新僵尸数据,如下:

createZM();updateZM();

创建绘制僵尸的接口, 如下:

void drawZM() {int zmCount = sizeof(zms) / sizeof(zms[0]);for (int i = 0; i getheight();putimagePNG(x, y, img);}}}

在updateWindow函数中,绘制僵尸,如下:

drawZM();

实现阳光球的飞跃

现在的实现效果是,阳光被点击后,阳光球直接消失了!而原版的植物大战僵尸中,阳光被点击后,阳光会自动飞向左上角的位置,飞到终点后,阳光值才增加25点。我们的实现方式是,阳光球每次飞跃4个点,直到飞到终点,如下图:

给阳光的结构体添加两个成员,表示飞跃过程中的偏移量:

struct sunshineBall { int x, y;int frameIndex;bool used;int destY;int timer;//添加以下两个成员float xOff;float yOff;};

在阳光被创建时,把变异量设置为0, 如下:

void createSunshine() {int ballMax = sizeof(balls) / sizeof(balls[0]);static int frameCount = 0;static int fre = 200;frameCount++;if (frameCount >= fre) {//...略balls[i].xOff = 0;balls[i].yOff = 0;}}

阳光被点击后,马上修改阳光球的xoff和yoff:

#include void collectSunshine(ExMessage* msg) {int count = sizeof(balls) / sizeof(balls[0]);int w = imgSunshineBall[0].getwidth();int h = imgSunshineBall[0].getheight();for (int i = 0; i x > x && msg->x y >y && msg->y < y + h) {balls[i].used = false;sunshine += 25;mciSendString("play res/sunshine.mp3", 0, 0, 0);// 设置初始偏移量float destX = 262;float destY = 0;float angle = atan((y - destY) / (x - destX));balls[i].xOff = 4 * cos(angle);balls[i].yOff = 4 * sin(angle);}}}}

在阳光飞跃过程中更新阳光的位置,如下:(注意是在飞跃过程中,不断计算偏移量,效果更好。)

void updateSunshine() {int ballMax = sizeof(balls) / sizeof(balls[0]);for (int i = 0; i < ballMax; i++) {if (balls[i].used) { //略...}else if (balls[i].xOff) {float destX = 263;float destY = 0;float angle = atan((balls[i].y - destY) / (balls[i].x - destX));balls[i].xOff = 4 * cos(angle);balls[i].yOff = 4 * sin(angle);balls[i].x -= balls[i].xOff;balls[i].y -= balls[i].yOff;if (balls[i].y < 0 || balls[i].x < 262) {balls[i].xOff = 0;balls[i].yOff = 0;sunshine += 25; }}}}

删除原来被点击后,立即更新阳光值的代码。

//sunshine += 25;

修改渲染阳光的判断条件,如下:

for (int i = 0; i < ballMax; i++) {if (balls[i].used || balls[i].xOff) { //添加这个条件IMAGE* img = &imgSunshineBall[balls[i].frameIndex];putimagePNG(balls[i].x, balls[i].y, img);}}

此时已经能够实现阳光的飞跃了,但是飞跃动作太慢了,后期我们再优化。

发射豌豆

僵尸靠近时,已经种植的植物豌豆就会自动发射“子弹”,我们先为子弹定义数据类型,如下:

struct bullet {int x, y;int row;bool used;int speed;};struct bullet bullets[30];IMAGE imgBulletNormal;

在gameInit函数中,初始化“豌豆子弹池”和子弹的图片,如下:

loadimage(&imgBulletNormal, "res/bullets/bullet_normal.png");memset(bullets, 0, sizeof(bullets));

在僵尸结构体中,添加成员row, 表示该僵尸所在的“行”,方便后续的判断。也可以不加,直接根据僵尸的y坐标来计算。

struct zm {int x, y; int frameIndex;bool used;int speed;int row; //0..2};

在createZM函数中,创建僵尸的时候,设置row成员的值,如下:

......if (i < zmMax) {zms[i].used = true;zms[i].x = WIN_WIDTH;zms[i].row = rand() % 3; // 0..2;zms[i].y = 172 + (1 + zms[i].row) * 100;zms[i].speed = 1;}......

创建shoot函数,实现豌豆发射子弹,如下:

void shoot() {int zmCount = sizeof(zms) / sizeof(zms[0]);int directions[3] = { 0 }; int dangerX = WIN_WIDTH - imgZM[0].getwidth();for (int i = 0; i < zmCount; i++) {if (zms[i].used && zms[i].x < dangerX) {directions[zms[i].row] = 1;}}for (int i = 0; i < 3; i++) {for (int j = 0; j  20) {count = 0;int k;int maxCount = sizeof(bullets) / sizeof(bullets[0]);for (k = 0; k < maxCount && bullets[k].used; k++);if (k getwidth()-10;bullets[k].y = zwY + 5;}}}}}}

更新子弹的位置,如下:

void updateBullets() {int countMax = sizeof(bullets) / sizeof(bullets[0]);for (int i = 0; i  WIN_WIDTH) {bullets[i].used = false;}}}}

在updateGame函数中,发射子弹并更新子弹的位置,如下:

shoot();updateBullets();

在updateWindow中绘制子弹,如下:

int bulletMax = sizeof(bullets) / sizeof(bullets[0]);for (int i = 0; i < bulletMax; i++) {if (bullets[i].used) {putimagePNG(bullets[i].x, bullets[i].y, &imgBulletNormal);}}

子弹和僵尸的碰撞

子弹碰到僵尸之后,子弹会“爆炸”,同时僵尸会“掉血”。我们先给僵尸添加血量成员。

struct zm { //略...int blood;};

并在创建僵尸的时候,把血量初始化为100,如下:

//...zms[i].speed = 1;zms[i].blood = 100;

子弹在碰到僵尸之后才会爆炸,并显示爆炸图片:

所以,我们在子弹的结构体中添加两个成员,分别表示当前是否已经爆炸,以及爆炸的帧图片序号,如下:

struct bullet {//...bool blast;int frameIndex; };IMAGE imgBulletBlast[4];

在gameInit函数中对子弹帧图片数组,进行初始化,如下:

loadimage(&imgBulletBlast[3], "res/bullets/bullet_blast.png");for (int i = 0; i < 3; i++) {float k = (i + 1) * 0.2;loadimage(&imgBulletBlast[i], "res/bullets/bullet_blast.png", imgBulletBlast[3].getwidth()*k,imgBulletBlast[3].getheight()*k, true);}

在发射子弹shoot函数中,对子弹的blast和帧序号frameIndex进行初始化,如下:

bullets[k].row = i;bullets[k].speed = 4;bullets[k].used = true;bullets[k].blast = false;bullets[k].blastTime = 0;

在更新子弹的updateBullets函数中,更新子弹爆炸的帧序号,如下:

bullets[i].x += bullets[i].speed;if (bullets[i].x > WIN_WIDTH) {bullets[i].used = false;}if (bullets[i].blast) {bullets[i].blastTime++;if (bullets[i].blastTime >= 4) {bullets[i].used = false;}}

进行碰撞检测,检查子弹和僵尸是否发生碰撞,如下:

void collisionCheck() {int bCount = sizeof(bullets) / sizeof(bullets[0]);int zCount = sizeof(zms) / sizeof(zms[0]);for (int i = 0; i < bCount; i++) {if (bullets[i].used == false || bullets[i].blast)continue;for (int k = 0; k  x1 && bullets[i].x < x2) {zms[i].blood -= 20;bullets[i].blast = true;bullets[i].speed = 0;}}}}

在updateGame函数中,调用碰撞检测函数,如下:

collisionCheck();

渲染子弹的爆炸效果,如下:

int bulletMax = sizeof(bullets) / sizeof(bullets[0]);for (int i = 0; i getwidth() / 2;int y = bullets[i].y + 12 - img->getheight() / 2;putimagePNG(x, y, img);/*bullets[i].used = false;*/}else {putimagePNG(bullets[i].x, bullets[i].y, &imgBulletNormal);}}}

僵尸死亡

僵尸被豌豆子弹击中后,会“掉血”,血量掉光了,就直接KO了,同时变成一堆“黑沙”。

给僵尸结构体添加dead成员,表示是否已经死亡,另外添加一个图片帧数组,用来表示变成成黑沙的过程。

struct zm {......bool dead;};IMAGE imgZmDead[20];

在gameInit中对这个图片帧数组进行初始化。

for (int i = 0; i < 20; i++) {sprintf_s(name, sizeof(name), "res/zm_dead/%d.png", i + 1);loadimage(&imgZmDead[i], name);}

在碰撞检测中对僵尸的血量做检测,如果血量降到0,就设置为死亡状态。如下:

void collisionCheck() {int bCount = sizeof(bullets) / sizeof(bullets[0]);int zCount = sizeof(zms) / sizeof(zms[0]);for (int i = 0; i < bCount; i++) {if (bullets[i].used == false || bullets[i].blast)continue;for (int k = 0; k  x1 && bullets[i].x < x2) {zms[k].blood -= 20;bullets[i].blast = true;bullets[i].speed = 0;//对血量进行检测if (zms[k].blood <= 0) {zms[k].dead = true;zms[k].speed = 0;zms[k].frameIndex = 0;}break;}}}}

僵尸死亡后,在updateZM中,更新僵尸的状态(变成黑沙发)。如下:

static int count2 = 0;count2++;if (count2 > 4) {count2 = 0;for (int i = 0; i = 20) {zms[i].used = false;}}else {zms[i].frameIndex = (zms[i].frameIndex + 1) % 22;}}}}

绘制僵尸的黑沙状态,如下:

void drawZM() {int zmCount = sizeof(zms) / sizeof(zms[0]);for (int i = 0; i 后续的内容,点击这里看完整的实现