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 时,植物死亡
调整植物和僵尸结构体,以及增加变量
/* 僵尸相关结构和变量 */
#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,加载图片至内存
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);
}
游戏更新窗口接口,渲染图片至输出窗口
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
/* 更新游戏属性的接口 */
void updateGame()
{
updatePlantsPic();
createSunshine();
updateSunshine();
createZombie();
updateZombie();
shoot();
updateBullets();
collsionCheck();
eatPlants();
}
/* 移除死亡的植物 */
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; //僵尸解除吃植物状态
}
}
}
}
}
}
}
最后更新僵尸状态,在这里进行帧处理
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 有差异,想保留原随机阳光球逻辑,所以这里是做了兼容处理逻辑,具体实现如下
向日葵结构体增加变量
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;
实现向日葵生产阳光的接口
需要注意的是在收集向日葵生产太阳球时,需要重置贝塞尔曲线
/* 实现向日葵生产太阳球 */
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;
}
}
}
}
}
/* 重置贝塞尔曲线坐标值 */
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());
}
}
在更新游戏属性的接口中调用
/* 更新游戏属性的接口 */
void updateGame()
{
updatePlantsPic();
createSunshine();
produceSunShine();
updateSunshine();
createZombie();
updateZombie();
shoot();
updateBullets();
collsionCheck();
eatPlants();
}
其次,在种植向日葵的时候需要进行新增成员的初始化
/* 种植植物接口, 主要释放草格子内存, 二维指针数组对应位置,指向初始化的植物 */
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;
}
}
在更新阳光球接口,添加新增更新向日葵生产阳光球帧的逻辑
/* 更新随机阳光球接口, 主要更新随机阳光球的图片帧和处理飞跃状态时的 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
}
}
}
}
在收集随机阳光接口中添加上收集向日葵生产的日光 新增逻辑
/* 收集随机阳光接口 */
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 接口中渲染一下向日葵生产的阳光即可
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
十五 片头僵尸展示
优化片头效果,实现函数如下,开局会先展示路边的僵尸
/* 展示界面的僵尸相关变量 */
#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);
}
}
在主函数中调用
效果展示
游戏开场会缓慢的移动窗口至马路边,停顿观察路边僵尸(僵尸会一摇一摇的抖动),然后游戏镜头会再缓慢移动至原界面
十六 植物栏滑动
在上述游戏界面拉回主界面过程中,植物菜单栏会缓慢滑动出现,具体实现如下
/* 植物栏滑动 */
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);
}
在主函数中调用
效果展示
在上述开场游戏界面拉回主界面过程中,植物菜单栏会缓慢滑动出现
十六 判断游戏结束
相关结构和变量
/* 游戏输赢相关的结构和变量 */
enum { GAMEING, WIN, FAIL };
#define INGAME_ZOMBIE_NUM 15
int killZombies = 0;
int gameStatus = GAMEING;
创建僵尸接口时判断杀死的僵尸是否满足该局僵尸的数目了,如果是则不再创建
/* 创建僵尸接口, 主要用于初始化僵尸 */
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
/* 判断游戏输赢 */
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 里,校验僵尸和窗口右端的距离即可
② 卡牌太阳值不够不能选取
如果阳光值不够选取植物,则渲染为灰色,阳光值不够不能种植该植物;且植物有冷却时间,在冷却时间内植物不能种植
/* 游戏体验优化, 阳光值不足或植物冷却时不能种植 */
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]; //植物卡片状态数组
更新植物卡牌状态函数代码
/* 更新植物卡牌状态 */
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;
}
}
}
修改植物栏滑动逻辑
/* 植物栏滑动 */
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);
}
种植植物时记得扣除太阳值和重置冷却
/* 种植植物接口, 主要释放草格子内存, 二维指针数组对应位置,指向初始化的植物 */
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
/* 更新植物图片帧接口, 主要用于实现植物摇摆 */
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 函数
效果展示
如果阳光值不够选取植物,则渲染为灰色,阳光值不够不能种植该植物;且植物有冷却时间,在冷却时间内植物不能种植
③ 添加各种音乐
加上音效
初始背景音乐
/* 游戏开始前的菜单界面 */
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 接口中,添加如下代码
if (createZombies == 1) PlaySound("res/audio/zombiescoming.wav", NULL, SND_FILENAME | SND_ASYNC); //异步播放音效
选取植物背景音乐
种植物音乐,种到不合适地方的音乐
豌豆射击的音乐
花了两块大洋买了原曲,支持一下(其实是为了游戏背景曲,哈哈)
遗留问题
音频播放同时播放两个音频,可以实现功能就是没用到其它音频库,导致游戏试玩时当有大量音频需要加载播放时,会稍有卡顿,待有空找个 Win 音频三方库优化一下吧
全部源代码和资源文件待后续把项目上传