简介:这是一份基于EasyX图形库与标准C语言实现的《植物大战僵尸》简化版游戏源码,专为计算机类专业学生课程设计、大作业及项目入门实践打造,适用于计科、人工智能、物联网等方向的学习者。资源包共2000个文件,含1017张PNG格式游戏素材图、551个GIF动画帧、178首MP3背景音效、166个OGG音效文件,以及6个核心CPP源文件(如main.cpp、tools.cpp)、4个头文件(.h)和完整VS工程配置(.sln、.vcxproj等),整体147.37MB,结构规范,便于理解图形渲染、事件响应与游戏逻辑分层。已有258人学习下载,代码经实机验证可稳定运行,附带可执行文件PVZ.exe,支持开箱即用。读者可直接编译调试,深入掌握EasyX绘图接口、双缓冲机制、定时器控制与资源管理方法;亦可基于现有框架拓展新植物、关卡或AI逻辑,是C语言图形编程与游戏开发实践的优质教学范例。
1. 为什么用 EasyX 写植物大战僵尸,比直接上 SDL 或 OpenGL 更适合作课设?
这不是一个“复刻正版 PVE”的工程,而是一次面向大二、大三学生的图形编程能力闭环训练:从零画出阳光、豌豆射手、僵尸行走动画,到实现碰撞检测、资源管理、状态机切换——全程不碰 Win32 SDK 的消息循环黑盒,不被 DirectX 初始化失败卡死,也不用为跨平台编译折腾 CMakeLists。EasyX 就是 Windows 下 C 语言图形编程的「减速带」:它把CreateWindow、GetMessage、BitBlt这些底层毛刺全包掉,只留initgraph()、putimage()、delay_ms()三个接口就能跑通一帧逻辑。我带过三届课程设计,凡是选 SDL 的组,70% 卡在SDL_Init(SDL_INIT_VIDEO)返回 -1;而用 EasyX 的组,95% 能在第三天跑出第一个会动的僵尸。它不解决高性能渲染问题,但完美解决「学生能不能在两周内看到自己写的代码动起来」这个核心诉求。如果你要交一份能演示、能讲清逻辑、能答辩时现场改参数调行为的课设,EasyX + C 是当前 Windows 环境下最稳的组合——不是因为它多先进,而是因为它把所有「非教学目标」的干扰项,一刀切掉了。
2. 搭建可运行环境:从零配置 VS + EasyX 开发链
2.1 安装 EasyX 并验证图形初始化是否成功
EasyX 不是库文件,而是一套头文件 + 静态链接库 + IDE 插件的集成包。必须使用 Visual Studio 2015 及以上版本(推荐 VS2019),且安装时需勾选「使用 C++ 的桌面开发」工作负载。下载 EasyX 官方安装包(注意:仅支持 Windows,无 macOS/Linux 版本),运行后自动注入 VS 工程模板。安装完成后,在 VS 中新建「EasyX 图形程序」项目(不是空的 Win32 控制台),它会自动生成含#include <easyx.h>和initgraph(640, 480)的骨架。
提示:若编译报错
LNK2019: 无法解析的外部符号 _main,说明你新建的是「控制台应用程序」而非「EasyX 图形程序」。EasyX 项目入口是WinMain,不是main,类型不匹配会导致链接失败。
验证是否装好,只需运行以下最小可执行代码:
#include <easyx.h> #include <conio.h> int main() { initgraph(640, 480); // 创建 640×480 像素窗口 setbkcolor(RGB(100, 180, 100)); // 背景设为草地绿 cleardevice(); // 刷屏 outtextxy(100, 100, "EasyX 初始化成功!"); _getch(); // 等待按键 closegraph(); // 关闭图形窗口 return 0; }✅ 成功表现:弹出窗口,显示绿色背景和白色文字;按任意键退出。
❌ 失败表现:黑窗一闪而过 / 报错initgraph failed/ 编译不过。此时请检查:① 是否以管理员身份运行 VS(部分旧版 EasyX 需要);② 项目属性 → 配置属性 → 常规 → 字符集 → 设为「使用多字节字符集」(不是 Unicode);③ 项目属性 → 链接器 → 输入 → 附加依赖项中是否含graphics.lib(EasyX 安装后自动添加,手动检查更稳妥)。
2.2 解压源码包后的目录结构与关键文件定位
你拿到的.zip包解压后,典型结构如下(实际以你下载包为准,但逻辑一致):
PvZ_Simple/ ├── PvZ_Simple.c ← 主程序入口,含 game_loop() 和 draw_scene() ├── res/ ← 资源目录(必须存在!) │ ├── sun.bmp ← 阳光图标(24×24,24位真彩 BMP) │ ├── pea.bmp ← 豌豆子弹(16×16) │ ├── zombie.bmp ← 僵尸贴图(64×96,含透明通道) │ └── plant_shooter.bmp ← 豌豆射手(80×100) ├── include/ │ └── game_struct.h ← 定义 struct plant、struct zombie、struct bullet └── README.txt ← 作者写的简要说明(常含编译提示)⚠️ 注意:EasyX不支持 PNG/JPG,所有图片必须是.bmp格式,且推荐用24位色深、无压缩 BMP(Photoshop 导出时选「BMP 24位」,GIMP 选「Windows BMP,24位」)。若你替换资源时用了 PNG,loadimage()会静默失败(返回空指针),后续putimage()崩溃——这是课设中最常见的「玄学崩溃」源头。
2.3 编译前必做的三处路径与编码修正
EasyX 默认读取资源路径是相对当前可执行文件(.exe)所在目录,而非源码目录。VS 默认输出路径是x64/Debug/,因此你必须把整个res/文件夹复制到该目录下(即PvZ_Simple/x64/Debug/res/),否则loadimage(NULL, "./res/sun.bmp")会找不到文件。
其次,中文路径或文件名会导致loadimage()返回 NULL(EasyX 内部用 ANSI 编码读取路径)。务必保证:
- 所有
.bmp文件名是英文(如zombie.bmp,不能叫僵尸.bmp); res/文件夹名是英文;- VS 项目保存路径不含中文(如
D:\code\PvZ_Simple\可,D:\我的代码\植物大战僵尸\不可)。
最后,VS 默认源文件编码是 UTF-8 with BOM,而 EasyX 的outtextxy()在中文 Windows 下要求 GBK 编码才能正确显示中文。解决方案(二选一):
- ✅ 推荐:在 VS 中右键
.c文件 →「高级保存选项」→ 编码选「GB2312」→ 保存; - ⚠️ 替代:用
settextstyle()+outtextxy()显示英文(如"Sun: 50"),避免中文显示问题。
3. 核心游戏逻辑拆解:从静态画面到完整帧循环
3.1 游戏主循环:while (!kbhit())为什么比while (1)更安全?
课设源码中常见主循环写法:
initgraph(1000, 600); while (!kbhit()) { // 检测键盘是否有按键按下 cleardevice(); draw_background(); update_plants(); update_zombies(); update_bullets(); draw_all(); delay_ms(33); // 约 30 FPS } closegraph();这里kbhit()是关键——它不阻塞,只查询当前缓冲区是否有按键,没有就立刻返回 0,继续下一帧。对比while (1)+getch()会卡住等待输入,导致动画冻结。而kbhit()+getch()组合才是响应式设计:
if (kbhit()) { char key = getch(); if (key == 'q' || key == 'Q') break; // 按 Q 退出 if (key == 's' || key == 'S') spawn_sun(); // 按 S 生成阳光 }✅ 优势:帧率稳定、输入不丢帧、调试时可随时按 Q 退出,不怕程序卡死需强制结束。
3.2 植物与僵尸的状态机设计:用枚举+计时器替代布尔开关
很多初学者用bool is_alive、bool is_shooting控制对象行为,结果出现「僵尸死了还在走」「射手没冷却完就又发豆」。正确做法是定义状态枚举,并绑定生命周期计时器:
// game_struct.h 中定义 typedef enum { STATE_IDLE, STATE_WALKING, STATE_EATING, STATE_DYING } zombie_state_t; typedef struct { int x, y; // 世界坐标(像素) zombie_state_t state; // 当前状态 int hp; // 生命值 int walk_timer; // 步行计时器(每帧减1,到0才移动一步) int die_timer; // 死亡动画计时器(用于播放死亡帧) } zombie_t;在update_zombies()中:
for (int i = 0; i < MAX_ZOMBIES; i++) { if (!zombies[i].hp) continue; // 已死亡,跳过更新 switch (zombies[i].state) { case STATE_WALKING: if (--zombies[i].walk_timer <= 0) { zombies[i].x -= 1; // 向左走1像素 zombies[i].walk_timer = 5; // 每5帧走1步,控制速度 } break; case STATE_EATING: // 检测是否与植物重叠,若重叠则扣植物HP break; case STATE_DYING: if (--zombies[i].die_timer <= 0) { zombies[i].hp = 0; // 动画播完,标记死亡 } break; } }💡 血泪经验:walk_timer初始值决定僵尸移动快慢(值越小越快),die_timer决定死亡动画持续帧数。这两个参数比直接改x -= 2更易调、更可控,是课设答辩时老师最爱问的「如何调节难度」的答案来源。
3.3 碰撞检测的两种落地方案:矩形包围盒 vs 像素级检测
EasyX 不提供物理引擎,碰撞全靠手算。课设级别必须用 AABB(Axis-Aligned Bounding Box)矩形检测,即判断两个矩形是否相交:
// 判断子弹与僵尸是否碰撞 bool rect_overlap(int x1, int y1, int w1, int h1, int x2, int y2, int w2, int h2) { return !(x1 + w1 <= x2 || x2 + w2 <= x1 || y1 + h1 <= y2 || y2 + h2 <= y1); } // 使用示例 if (rect_overlap(bullet.x, bullet.y, 16, 16, zombie.x, zombie.y, 64, 96)) { zombie.hp -= 10; bullet.active = false; // 子弹消失 }⚠️ 注意:bullet.x/y是子弹左上角坐标,zombie.x/y是僵尸左上角坐标,宽高必须传入实际贴图尺寸(不是结构体里存的缩放值)。若你发现「明明没碰到却触发碰撞」,大概率是僵尸贴图含大量透明边距,导致包围盒远大于视觉区域——此时应手动裁剪w2/h2(如传40, 70代替64, 96)。
❌ 像素级检测(逐点比对getpixel())理论上更准,但 EasyX 的getpixel()在双缓冲下极慢,10 个僵尸 × 10 颗子弹 × 每帧遍历 1000 像素 = 直接卡死。课设中禁止使用。
4. 避坑指南:课设高频翻车现场与后悔药
4.1 现象:程序运行后窗口空白,或只闪一下就关闭
原因:initgraph()失败后未检查返回值,后续putimage()对空指针操作导致崩溃;或cleardevice()被误删,画面残留不刷新。
解决:在initgraph()后加断言:
if (initgraph(1000, 600) == NULL) { printf("EasyX 初始化失败!请检查:\n1. VS 是否以管理员运行\n2. 项目字符集是否为多字节\n3. graphics.lib 是否链接"); _getch(); return -1; }4.2 现象:僵尸不动、植物不发豆,但控制台无报错
原因:delay_ms(33)时间过短,导致帧率过高,walk_timer/shoot_timer计时器减得太快,逻辑未生效就被重置。
解决:将delay_ms()改为Sleep(33)(需#include <windows.h>),或统一用delay_ms(50)降低帧率至 20 FPS,让计时器有足够时间归零。课设不必追求 60 FPS,稳定可调才是王道。
4.3 现象:加载图片后putimage()显示一片黑或马赛克
原因:BMP 文件不是 24 位真彩,或是带 Alpha 通道的 32 位 BMP(EasyX 不支持);或图片尺寸超过 EasyX 默认限制(默认最大 2048×2048,但某些显卡驱动会更低)。
解决:用 IrfanView 或 XnConvert 批量转换:打开 BMP → 「文件」→「另存为」→ 格式选 BMP → 「选项」中勾选「24 位色深」→ 取消「RLE 压缩」→ 保存。再用画图打开确认「属性」中「颜色」显示「24 位位图」。
4.4 现象:按方向键种植物,但植物总出现在鼠标点击位置偏移处
原因:EasyX 的GetMousePos()获取的是客户区坐标,而putimage()的(x,y)是相对于窗口左上角的绝对坐标。若窗口有标题栏、边框,客户区原点(0,0)实际在窗口内部,导致偏移。
解决:不用GetMousePos(),改用GetAsyncKeyState()捕获键盘 + 固定格子坐标。例如:
// 定义 5×9 的种植网格(草坪共 5 行,每行 9 列) #define GRID_W 100 #define GRID_H 120 if (GetAsyncKeyState('1') & 0x8000) { // 按 1 键种向日葵 int col = (mouse_x - 200) / GRID_W; // 200 是草坪起始 X int row = (mouse_y - 100) / GRID_H; // 100 是草坪起始 Y if (col >= 0 && col < 9 && row >= 0 && row < 5) { plant_grid[row][col] = PLANT_SUNFLOWER; } }4.5 现象:多个僵尸重叠时,只打中一个,其余穿模而过
原因:碰撞检测逻辑写在for循环内,但bullet.active = false后未break,导致同一颗子弹继续检测后续僵尸。
解决:检测命中后立即break,或用标志位:
bool hit = false; for (int i = 0; i < MAX_ZOMBIES && !hit; i++) { if (zombies[i].hp > 0 && rect_overlap(...)) { zombies[i].hp -= 10; hit = true; bullet.active = false; } }5. 课设进阶技巧:三招让答辩老师眼前一亮
5.1 用双缓冲消除闪烁:BeginBatchDraw()/EndBatchDraw()必须成对出现
默认 EasyX 是单缓冲:每调一次putimage()就刷一次屏,10 次绘制就会闪 10 次。解决方法是开启双缓冲——先在内存中画完整帧,再一次性贴到屏幕:
initgraph(1000, 600); BeginBatchDraw(); // 开启双缓冲 while (!kbhit()) { cleardevice(); // 清除内存缓冲区(不是屏幕!) draw_background(); for (int i = 0; i < MAX_PLANTS; i++) { if (plants[i].active) { putimage(plants[i].x, plants[i].y, &plants[i].img); } } // ... 其他绘制 FlushBatchDraw(); // 把内存缓冲区内容刷到屏幕 } EndBatchDraw(); // 关闭双缓冲 closegraph();✅ 效果:动画丝滑无闪烁,即使 20 个对象同时运动也无频闪。老师用手机录屏演示时,画面质量明显提升。
注意:
FlushBatchDraw()必须在while循环内,且不能漏写;BeginBatchDraw()只需调一次,在while外。
5.2 实现简易音效:用PlaySound()播放 WAV,避开复杂音频库
EasyX 本身不带音频,但 Windows API 的PlaySound()极简可用。准备shoot.wav、boom.wav等 16bit/44.1kHz 单声道 WAV 文件(用 Audacity 导出),放入res/目录:
#include <mmsystem.h> // 需在项目属性 → 链接器 → 输入 → 附加依赖项中加 winmm.lib // 播放音效(异步,不阻塞主线程) PlaySound(TEXT("./res/shoot.wav"), NULL, SND_FILENAME | SND_ASYNC | SND_NODEFAULT); // 若需重复播放同一音效(如僵尸吃植物),加 SND_LOOP 标志并用 SND_PURGE 停止💡 技巧:WAV 文件体积要小(<200KB),否则PlaySound()加载慢。课设中 0.3 秒的豌豆发射音效足矣,不必追求高品质。
5.3 添加难度调节开关:用全局变量+键盘快捷键实时修改
课设答辩常被问「怎么调难度?」。硬编码zombie_speed = 2太 Low,应该做成运行时可调:
// 全局变量(定义在 .c 文件顶部) int g_zombie_speed = 3; // 初始速度 int g_sun_value = 25; // 阳光价值 int g_pea_damage = 10; // 豌豆伤害 // 在主循环中监听调节键 if (GetAsyncKeyState(VK_UP) & 0x8000) g_zombie_speed++; // ↑ 加速 if (GetAsyncKeyState(VK_DOWN) & 0x8000) g_zombie_speed--; // ↓ 减速 if (g_zombie_speed < 1) g_zombie_speed = 1; // 在 draw_ui() 中显示当前参数 char buf[64]; sprintf(buf, "Speed:%d Sun:%d Damage:%d", g_zombie_speed, g_sun_value, g_pea_damage); outtextxy(10, 10, buf);这样答辩时,你可以当场按方向键演示「把僵尸调快两倍,看学生手忙脚乱」,比讲一百遍「我们用了状态机」更有说服力。
6. 从课设到作品:我的三个落地习惯与一条教训
我带过的课设项目里,最终能留下长期维护、甚至被学弟复用的,都有一个共同点:不追求功能堆砌,而专注「可解释性」与「可干预性」。比如,我把所有魔法数字(64、96、33)全部替换成宏定义:
#define ZOMBIE_WIDTH 64 #define ZOMBIE_HEIGHT 96 #define FRAME_DELAY_MS 33 #define SUN_VALUE 25这样答辩时,老师问「僵尸宽度为什么是 64?」,我能立刻打开game_struct.h指着注释说:「因为原始贴图是 64×96,这里定义后,所有rect_overlap()和putimage()都自动同步,改一处全更新。」——这比说「我查了资料」有力得多。
第二个习惯是日志轻量化。我不用printf打满屏,而是在关键分支加条件日志:
#ifdef DEBUG_MODE printf("Zombie[%d] state=%d, x=%d, hp=%d\n", i, z.state, z.x, z.hp); #endif编译时加/D DEBUG_MODE宏即可开启,交作业前删掉宏定义,零日志。既方便自己调试,又不污染交付包。
第三个习惯是资源校验前置。在main()开头加一段资源加载检查:
IMAGE img; if (loadimage(&img, "./res/zombie.bmp") == NULL) { MessageBox(NULL, "缺少 res/zombie.bmp!", "资源错误", MB_ICONERROR); return -1; }所有图片都校验一遍。课设答辩现场最怕「老师换台电脑运行,直接白屏」,这段代码就是你的「后悔药」。
最后是一条血泪教训:永远不要在putimage()里传入未初始化的IMAGE变量。EasyX 的IMAGE是结构体,未loadimage()前其内部指针为野指针,putimage()会直接访问非法内存。我见过太多同学把IMAGE plant_img;声明在全局,却忘了在main()里loadimage(&plant_img, "..."),结果程序在自己电脑上好好的,一拷给老师就崩——因为不同机器内存布局不同,野指针指向的位置运气不一样。
所以我的铁律是:每个IMAGE变量声明后,下一行必须是loadimage(),且必须检查返回值。宁可多写三行,不省这一句。
希望帮到你。
本文还有配套的精品资源,点击获取