☰
用C语言和EasyX实现植物大战僵尸课设:从结构体到碰撞检测全解析
2026/9/28 13:21:24 网站建设 项目流程

简介:这是一份基于EasyX与C语言开发的植物大战僵尸简易游戏源码,适合计算机相关专业的学生用作课程设计、大作业或初期项目演示,也适合对游戏开发感兴趣的初学者学习入门。压缩包共收录2000个文件,容量约147.37MB,主要包含png与gif图片动画素材、mp3与ogg音频文件,以及cpp、h源文件和VS工程配置,素材与代码分离清晰,便于替换和扩展。项目代码完整、功能经过验证,运行稳定可靠,可直接编译体验经典游戏的种植、射击与僵尸出场等核心玩法。同时,源码结构简洁,注释到位,具备较好的二次开发空间,已有257人学习浏览,对于希望快速完成课设或提升C语言实践能力的读者很有参考价值。

1. 为什么用 C 语言写《植物大战僵尸》反而是课设里最能打的方案

大学课程设计一旦规定只能用 C 语言、不允许接 Unity 或 Python,很多人的第一反应是做个图书管理系统、贪吃蛇或通讯录。但如果你把这个题目换成“基于 EasyX 和 C 语言的植物大战僵尸简易游戏源码(可作课设)”,答辩现场的观感会完全不同:玩法完整、交互直观、逻辑复杂度适中,而且能同时把“结构体数组”“碰撞检测”“状态机”“定时器”这些 C 语言核心考点串成一条线。EasyX 图形库负责把控制台窗口升级成可交互的游戏界面,背后的业务逻辑仍然是纯 C,没有 C++ 的 class,也没有 STL 容器,完全贴合教学大纲要求。这篇文章我按自己实际做课设的思路,把从框架拆分、坐标映射、碰撞检测到最终打磨成源码包的完整路径讲一遍。

2. 动手之前先拆局:把游戏拆成 C 语言看得懂的结构体与状态机

2.1 EasyX 到底解决了 C 语言游戏的哪块短板:先跑通一个可关闭的窗口

C 语言自带的标准库只能做控制台交互,stdout 里全是黑底白字。想让游戏有地图、有精灵、有鼠标点击,就得选一个合适的图形方案。常见的选项有 RayLib、SDL、EasyX。SDL 和 RayLib 功能强,但需要额外配置链接库、处理跨平台编译,课设这种场景很容易在环境配置上消耗大量时间。EasyX 走的是 Windows GDI 路线,安装后直接包一层graphics.h头文件,和 Visual Studio 无缝集成,对新手的友好程度远高于前两者。

先写一个最小窗口程序,确认环境没问题:

#include <graphics.h> #include <conio.h> int main() { initgraph(800, 600); // 创建 800x600 绘图窗口 setbkcolor(RGB(46, 46, 46)); // 深灰色背景,方便后面盖图 cleardevice(); // 用当前背景色清空画布 outtextxy(200, 280, "EasyX window is ready"); getch(); // 等待按键,防止窗口一闪而过 closegraph(); // 关掉绘图窗口 return 0; }

initgraph有两个参数,第一个是窗口宽度,第二个是高度,单位是像素。cleardevice负责在每一帧开始时把画布恢复成背景色,否则上一帧的图形会残留形成拖影。getch在这里的作用是挂起进程,让窗口保持住。如果编译时提示找不到graphics.h,基本可以断定是 EasyX 没有正确安装到当前 VS 版本目录下,需要在项目属性里把“平台”切换成 x86 或 x64,再在 EasyX 官网下载对应安装包补齐。

2.2 用结构体把植物、僵尸、子弹建模成“对象”

EasyX 只是画图和收鼠标消息的工具,游戏的数据核心仍然要用 C 语言的结构体来承载。很多初学者会倾向于用两个二维数组直接记录格子状态,但二维数组只能表达“有植物”或“没植物”,表达不了植物的血量、冷却时间、攻击频率这些动态属性。所以更合适的做法是定义三个结构体,分别对应植物、僵尸、子弹。

#define MAX_PLANTS 45 // 5 行 x 9 列 #define MAX_ZOMBIES 30 // 同一屏最多 30 只僵尸 #define MAX_BULLETS 100 typedef struct { int active; // 1=该槽位正被使用,0=空槽 int row, col; // 格子坐标,row 0~4,col 0~8 int type; // 0=向日葵,1=豌豆射手,2=坚果墙 int hp; // 当前生命值 int cd; // 攻击或产阳光的冷却倒计时 } Plant; typedef struct { int active; int x, y; // 像素坐标,用于移动和碰撞 int row; // 所在行,决定和哪一排植物交互 int hp; int speed; // 每帧移动像素数 int state; // 0=行走,1=啃植物,2=死亡 } Zombie; typedef struct { int active; int x, y; int row; // 子弹所在行 } Bullet; Plant plants[MAX_PLANTS]; Zombie zombies[MAX_ZOMBIES]; Bullet bullets[MAX_BULLETS];

active是整个游戏对象管理的关键。C 语言里数组长度固定,不能在运行期随意增删,所以采用“标记法”:新增对象时遍历数组找到第一个active == 0的槽位,写入数据并置 1;对象死亡时把active置 0 即可,后面的元素无需搬动。相比使用链表的方案,这种写法省的不仅是编码量,更重要的是避免了频繁 malloc/free 造成的内存碎片,对课设这种长时间运行的演示程序更稳。如果你在答辩时被问到“数组越界怎么办”,可以说“所有遍历都会先检查 active 标志,保证不会访问到未初始化的内存”。

2.3 主循环与帧率控制:Sleep 不是玄学,是同步基线

游戏的本质是一个无限循环:收集输入、更新状态、绘制画面、等待一小段时间,然后进入下一帧。这里的Sleep(16)不是可有可无的延迟,而是把帧率限制在约 60 FPS 的同步手段。少了它,循环会以 CPU 允许的最快速度空转,部分机器会直接跑到几千帧,导致逻辑更新过快,子弹一帧飞出屏幕。

int main() { initgraph(800, 600); initGame(); // 初始化植物、僵尸数组和全局变量 BeginBatchDraw(); // 开启双缓冲批量绘制 while (1) { handleInput(); // 处理鼠标点击 updateGame(); // 更新所有对象的逻辑 drawGame(); // 绘制植物、僵尸、子弹到后台缓冲 FlushBatchDraw();// 把后台缓冲一次性刷新到窗口 Sleep(16); } EndBatchDraw(); closegraph(); return 0; }

把逻辑更新和绘制分开写是保持代码清爽的关键。如果把碰撞检测、阳光生成、僵尸移动全部塞进一个while循环里,后面调试的时候会非常痛苦。这个结构也适合在答辩时讲:handleInput对应交互层,updateGame对应业务逻辑层,drawGame对应表现层,层次清晰,老师想追问哪个函数都能快速定位。

3. 把玩法落成代码:种植、阳光、碰撞与对象回收

3.1 种植判定:从鼠标像素坐标反向映射到 5x9 网格

窗口宽度为 800,高度为 600,地图区域是 9 列 x 5 行。每格宽 80、高 100,则地图总宽度为 720,总高度为 500。为了让地图居中,左边界GRID_LEFT = (800 - 720) / 2 = 40,上边界GRID_TOP = (600 - 500) / 2 = 50。

鼠标点击的坐标是相对于窗口左上角的像素坐标,要转化为“第几行第几列”,只需要做一步减法再整除格子尺寸。

#define GRID_LEFT 40 #define GRID_TOP 50 #define CELL_WIDTH 80 #define CELL_HEIGHT 100 #define ROWS 5 #define COLS 9 int getGridRow(int mouseY) { if (mouseY < GRID_TOP || mouseY >= GRID_TOP + ROWS * CELL_HEIGHT) return -1; // 点击在地图外,通常是顶部阳光栏或底部按钮 return (mouseY - GRID_TOP) / CELL_HEIGHT; } int getGridCol(int mouseX) { if (mouseX < GRID_LEFT || mouseX >= GRID_LEFT + COLS * CELL_WIDTH) return -1; return (mouseX - GRID_LEFT) / CELL_WIDTH; }

返回值 -1 表示“无效格子”,调用侧需要先判断是否为 -1 再执行种植逻辑。这里最容易踩的坑是忘记减去GRID_LEFT和GRID_TOP,直接用mouseX / CELL_WIDTH,结果就是整个地图往右下方偏移了 40 和 50 个像素,鼠标点选的格子和实际落点错位。窗口分辨率改变时,GRID_LEFT和GRID_TOP也要跟着重算,所以最好写成宏定义,而不是散落在代码里的魔法数字。

3.2 阳光的产生与收集:点击即拾取,不追求像素级命中

阳光是游戏里的资源货币。我一般设计两种来源:一种是向日葵每 10 秒在自身附近产生一个阳光,另一种是场景中每隔 8 到 12 秒随机掉落一个阳光。两者都靠一个全局计时器驱动,每帧递减,减到 0 时重置并生成新阳光。

typedef struct { int active; int x, y; int timer; // 阳光存留时间,倒计时为 0 时消失 } Sunshine; Sunshine suns[20]; int globalSunTimer; void updateSunshine() { if (globalSunTimer <= 0) { globalSunTimer = rand() % 5 + 8; // 8~12 秒周期性生成 for (int i = 0; i < 20; i++) { if (!suns[i].active) { suns[i].active = 1; suns[i].x = GRID_LEFT + (rand() % (COLS * CELL_WIDTH)); suns[i].y = GRID_TOP + (rand() % (ROWS * CELL_HEIGHT)); suns[i].timer = 120; // 约 2 秒后消失 break; } } } globalSunTimer--; }

鼠标点击收集阳光时,不需要精确判断点击是否落在阳光图片的像素范围内。因为用户是快速连点的,如果判定太严格,很容易出现“明明点到阳光却没收走”的迟滞感。常见的做法是计算鼠标位置和阳光坐标的曼哈顿距离,小于 40 像素就算拾取成功。距离判断只涉及加减法和绝对值,性能开销几乎为零,体验却比矩形碰撞更顺滑。

3.3 僵尸移动与子弹命中的碰撞检测逻辑:AABB 矩形相交

EasyX 的绘图函数putimage和solidrectangle在绘图时只会给出精灵的位置和宽高。如果要判断子弹是否打中僵尸,不需要做像素级的透明通道碰撞检测,那是图像算法课的内容了,在 C 语言课设里做只会徒增 bug。用 AABB(轴对齐包围盒)相交就足够了。

int isCollide(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { if (ax > bx + bw) return 0; if (bx > ax + aw) return 0; if (ay > by + bh) return 0; if (by > ay + ah) return 0; return 1; } void updateBullets() { for (int i = 0; i < MAX_BULLETS; i++) { if (!bullets[i].active) continue; bullets[i].x += 6; // 子弹速度,每帧向右移动 6 像素 if (bullets[i].x > 800) { bullets[i].active = 0; continue; } for (int j = 0; j < MAX_ZOMBIES; j++) { if (!zombies[j].active) continue; if (zombies[j].row != bullets[i].row) continue; if (isCollide(bullets[i].x, bullets[i].y, 14, 14, zombies[j].x, zombies[j].y, 60, 90)) { zombies[j].hp -= 20; bullets[i].active = 0; break; } } } }

两个对象在发生碰撞前,先判断row是否一致,可以提前过滤掉大量无关比较。僵尸的碰撞盒我按宽 60、高 90 设置,比实际图片略小一圈,这样做对玩家更友好,因为子弹贴近僵尸侧身时不会被“空气墙”挡住。碰到边界要主动把子弹置为 inactive,否则子弹飞出屏幕后仍然占用一个数组槽位,长期运行会积累越来越多的无效对象。

4. 渲染与贴图:EasyX 批量绘制和双缓冲的取舍

4.1 没有美术素材时,如何让 UI 不显得粗糙:几何色块加文本标注

很多同学下载植物大战僵尸素材包,图片动辄几百张,但导入工程后才发现路径混乱、格式不兼容、加载失败。其实课设演示阶段不需要完整的高清素材,用 EasyX 内置的几何绘图函数加少量贴图完全可以支撑起观感。

void drawPlant(int index) { Plant *p = &plants[index]; int x = GRID_LEFT + p->col * CELL_WIDTH; int y = GRID_TOP + p->row * CELL_HEIGHT; if (p->type == 0) { setfillcolor(RGB(255, 200, 0)); // 向日葵:黄色块 solidcircle(x + 40, y + 40, 30); outtextxy(x + 15, y + 45, "SUN"); // 绘制血量条 } else if (p->type == 1) { setfillcolor(RGB(0, 200, 0)); // 豌豆射手:绿色块 solidrectangle(x + 10, y + 20, x + 60, y + 60); outtextxy(x + 20, y + 40, "SHOOT"); } }

这里没有使用任何外部图片资源,而是在每个格子中心绘制圆形或矩形,再叠加outtextxy写出“SUN”“SHOOT”这类辅助文字。纯几何图形的好处是完全不依赖素材包,无论换哪台电脑都能稳定编译运行。如果确实想贴 PNG 图片,用loadimage加载后配合putimage绘制,注意把图片路径用相对路径写,或者干脆放到工程目录下的res文件夹里,避免答辩换机器后因绝对路径失效而黑屏。

4.2 双缓冲图形绘制流程:为什么画面会闪,怎么压下去

如果不做任何缓冲,直接在一帧内调用大量绘图函数,窗口会出现明显闪烁或撕裂,原因是绘制过程中绘图内容被分多次刷新到屏幕,用户的肉眼能捕捉到“半成品”画面。EasyX 提供的解决方案是批处理模式。

BeginBatchDraw(); // 进入批处理模式,所有绘图操作先写入内存缓冲 while (1) { cleardevice(); drawGrid(); // 画草坪格 drawPlants(); // 画植物 drawZombies(); // 画僵尸 drawBullets(); // 画子弹 drawSunshine(); // 画阳光 drawUI(); // 画阳光数量、冷却条 FlushBatchDraw(); // 把完整的一帧一次性刷新到窗口 Sleep(16); } EndBatchDraw();

cleardevice必须在批处理模式下使用,否则每一帧开始时的清屏操作也会造成闪烁。绘制顺序是从底层到顶层:先是草坪背景,然后是植物、僵尸、子弹,最后是阳光数量等 UI 层。如果反过来先画 UI 再画草坪,整个界面会被背景盖掉一层。FlushBatchDraw 的调用时机也很关键,它应该在所有绘制完成后再执行,避免在 update 过程中被用户看到中间状态。

5. 课设验收避坑指南:编译、内存与演示翻车的 5 个瞬间

5.1 现象:#include <graphics.h>直接报错,找不到文件

原因:EasyX 安装包没有和当前 Visual Studio 版本匹配,或者安装时选了 32 位库而工程编译的是 x64 目标,头文件路径自然匹配不上。

解决:从 EasyX 官网重新下载最新安装包,关闭 Visual Studio 后运行,它会自动检测已安装的 VS 版本并写入头文件和库文件路径。如果多个版本共存,先确认当前工程使用的 IDE 版本和位数。这个坑最容易在答辩前一天爆发,临时换机器更容易触发,所以安装完第一时间编译最小窗口程序,确认跑通再继续写业务代码。

5.2 现象:豌豆射手每隔一帧发射一颗子弹,弹幕密得像机关枪

原因:主循环每帧都会调用updateGame,而创建子弹的代码没有限制频率,等于每 16 毫秒就生成一颗。视觉效果就是火力全开,游戏难度瞬间崩塌。

解决:给植物增加冷却计数器。每次触发发射时把cd初始化为 30,主循环中每帧将cd减 1,只有cd == 0时才允许重新生成子弹。这里的 30 对应约 30 帧,也就是 0.5 秒一发。

void updatePlants() { for (int i = 0; i < MAX_PLANTS; i++) { if (!plants[i].active) continue; if (plants[i].cd > 0) { plants[i].cd--; continue; } // 朝向该行最近的一只僵尸发射 if (hasZombieInRow(plants[i].row)) { createBullet(plants[i].row, GRID_LEFT + plants[i].col * CELL_WIDTH + 40, GRID_TOP + plants[i].row * CELL_HEIGHT + 30); plants[i].cd = 30; } } }

5.3 现象:僵尸死亡后,后面的僵尸全部离奇消失,或者数组顺序错乱

原因:遍历数组删除僵尸时直接把后面的元素整体前移,导致循环索引 i 跳过了原本相邻的元素,部分僵尸被漏检或者重复处理。

解决:采用标记删除,只把active置为 0,不移动内存。后续新增僵尸时,用第一个空的槽位覆盖写入。这样遍历逻辑始终稳定,不会因为删除操作破坏数组的连续性。

5.4 现象:窗口内出现半截图形、残影,画面被切割成两部分

原因:没有调用BeginBatchDraw和FlushBatchDraw,绘图操作直接作用于窗口显存,每一次putimage或solidrectangle都被独立刷新到屏幕上。

解决:在进入主循环前调用BeginBatchDraw,循环末尾调用FlushBatchDraw。这两行代码是所有图形游戏的地基。如果已经用了批处理仍然闪烁,检查是否漏了cleardevice,因为上一帧的图形会残留在下一帧的背景上。

5.5 现象:Windows 11 高分屏下画面变模糊,鼠标怎么点都对不上

原因:系统默认开启了显示缩放(125% 或 150%),EasyX 窗口被系统拉伸放大,逻辑像素和物理像素不一致,鼠标的屏幕坐标和绘图坐标出现映射偏差。

解决:在程序入口处调用SetProcessDPIAware(),或者在 Visual Studio 的项目属性里把“清单文件”中的 DPI 感知设置为 true。另一个后备方案是把窗口固定为 800x600,并在答辩前把系统缩放临时调回 100%。课设现场如果用教室的投影仪,分辨率普遍是 1024x768 或更高,这个问题往往等到演示时才暴露,提前处理最稳妥。

6. 课设答辩的进阶收尾:把三个不显眼的细节做成加分项

答辩时老师看的不只是游戏跑起来没跑起来,更多是问“这里的对象怎么管理”“碰撞怎么判断”“如果场景再大你会怎么优化”。所以你至少要在最终源码里埋三个能展开讲的细节。

第一个是种植预览光标。鼠标移动到草坪上方时,在对应的格子上画一个半透明绿色矩形;移动到已有植物的格子上时画为红色。这个效果只需要在handleInput里再调一次getGridRow和getGridCol,然后在drawGame后绘制一个描边矩形即可。移植到手写代码上成本极低,但答辩现场看起来非常像“完整产品”。

第二个是音效。用mciSendString播放 wav 文件,只需要一行指令:

#include <windows.h> #pragma comment(lib, "winmm.lib") void playPlantSound() { mciSendString("play res/plant.wav", NULL, 0, NULL); }

没有音效素材就用Beep(880, 50)临时顶替,调用 Windows 内置蜂鸣器,也能让演示不那么干涩。

第三个是本地存档。把最高分写进score.txt,启动时读取:

FILE *fp = fopen("score.txt", "r"); if (fp) { fscanf(fp, "%d", &highestScore); fclose(fp); }

答辩时可以很自然地说一句“这是利用标准文件 I/O 保存的持久化记录”,数据结构的分数立刻拉满。

我在自己版本的课设里保留了植物类型切换、僵尸波次生成和简易存档功能,演示时不慌,提问也不怕深挖。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询