☰
C语言+EasyX实现植物大战僵尸:课设源码与调试指南
2026/10/10 12:19:42 网站建设 项目流程

简介:面向C语言初学者与课程设计场景,这份基于EasyX图形库的植物大战僵尸简易游戏源码,是一套可直接运行、功能验证完备的完整项目。代码涵盖游戏主循环、碰撞检测、植物与僵尸行为逻辑等核心模块,配合大量PNG/GIF图片素材和MP3/OGG音效资源,能够帮助理解小型游戏的程序组织与图形化交互实现,也适合在此基础上做二次功能扩展。压缩包共含2000个文件,大小约147.37MB,以图片素材、音频文件、C++/C源码以及编译好的EXE可执行程序为主,同时包含项目工程配置与说明文档,解压后即可对照源码和运行效果进行学习。当前已有258人学习浏览,常用于C语言课程设计、大作业或初期项目立项演示。项目结构完整,源码与素材分区清晰,便于按模块阅读和调试;若基础较好,还可自行替换图片、音效或增加新玩法,是入门图形化游戏开发的一份实用参考。

1. 基于EasyX和C语言的植物大战僵尸简易游戏源码:课设到底让你交什么

“基于EasyX和C语言的植物大战僵尸简易游戏源码”,在C语言课设题里几乎是每年都会出现的固定选题。EasyX 是 Windows 下的一个轻量图形库,把绘点、画圆、贴图、鼠标消息这些能力直接暴露给 C/C++,于是你不需要游戏引擎,也不需要学 C++ 的类,就能在纯 C 工程里画出一片草坪、种下向日葵、再放几只僵尸进场。它能解决的核心问题很明确:让一份 C 语言大作业具备“图形界面 + 鼠标交互 + 游戏逻辑”三要素,同时又控制在几百行到一千行的规模。适合大一、大二做课程设计的学生,也适合想验证自己 C 语言结构体、数组和状态机功底的入门开发者。读完这一篇,你会知道怎么搭环境、怎么写游戏循环、怎么管植物和僵尸,以及课设验收前最容易翻车的几个地方。

2. 在 Visual Studio 里装好 EasyX:从空项目到画出第一个圆

2.1 为什么不用网页版和 VS Code:先定技术路线

如果你现在搜“简易植物大战僵尸”,最先跳出来的十有八九是一段 HTML+CSS+JavaScript 的网页源码,复制到浏览器里就能玩。这类版本确实轻巧,但它是前端技术栈,不是 C 语言课设的交付物。老师想看到的是你对 C 语言本身的掌握:数组、结构体、函数、文件读写、内存管理,而不是看你在浏览器里调 Canvas API。所以做课设,我一般会直接选 EasyX:它只负责“画”,游戏逻辑全部由你自己用 C 代码写,正好把课设的考察点留下了。

另一个常见岔路是 VS Code。配置“vscode c语言环境配置”本身不难,难的是把 EasyX 库的 include 和 lib 路径接进去,还要写 tasks.json、launch.json,新手在这个环节消耗的时间可能比写代码还多。Visual Studio Community 装好后,直接运行 EasyX 安装包,按当前 VS 版本一键装完,新建控制台项目就能用。我的建议很明确:在 Windows 本机用 Visual Studio,不要在 Linux 虚拟机里折腾 EasyX,图形库接口直接依赖 Windows 的 GDI,换了系统连窗口都开不出来,没有后悔药。

2.2 最小验证:新建项目并画出第一个圆

先走通最小流程,再谈游戏。步骤如下:

  1. 安装 Visual Studio Community,工作负载勾选“使用 C++ 的桌面开发”。
  2. 到 EasyX 官网下载对应版本的安装包,安装时选择你当前的 VS 版本。
  3. 打开 VS,新建“控制台应用”项目。项目模板列表里没有纯 C 项目,选 C++ 模板没关系,之后把源文件改成.c扩展名即可。
  4. 新建一个main.c,粘贴下面的代码并运行。
#include <graphics.h> #include <conio.h> int main() { initgraph(800, 600); /* 图形窗口:宽 800、高 600 像素 */ setbkcolor(WHITE); /* 设置背景色 */ cleardevice(); /* 用背景色刷新整窗 */ setfillcolor(GREEN); /* 设置填充色 */ fillcircle(100, 100, 50); /* 画一个实心圆 */ _getch(); /* 按任意键退出,避免闪退 */ closegraph(); return 0; }

这段代码就是整个课设的地基。initgraph只负责打开绘图窗口,窗口一出现,你就可以调用 EasyX 提供的全部绘图函数;坐标原点在窗口左上角,x 向右、y 向下,和数学坐标系相反,后面摆放植物时心里要有这根弦。cleardevice配合setbkcolor才能把背景刷成指定颜色。_getch是 conio.h 里的函数,它的作用是阻塞等待按键,没有它程序会瞬间执行完、窗口一闪而过,这是新手第一个“灵异现象”的来源。

如果编译时提示“无法打开 graphics.h”,多半是 EasyX 没装到当前 VS 版本上,或者安装后没重启 VS。重装一次再重启,问题基本消失。

2.3 工程怎么拆文件:不要让 main.c 背所有逻辑

课设虽然只要求交源码,但一个几十个函数全塞在 main.c 里的项目,答辩时老师问“僵尸逻辑在哪”,你要在几百行里翻半天。我建议把工程拆成 3 到 5 个文件,每个文件职责单一,代码总量不变,可读性天差地别。

文件职责
main.c初始化窗口、主循环、调用各模块的 update 和 draw
game.h枚举、结构体、全局函数声明、常量定义
plant.c / plant.h植物结构体、种植、阳光与冷却逻辑
zombie.c / zombie.h僵尸波次、生成与移动逻辑
bullet.c / bullet.h子弹发射、移动与碰撞判定

纯 C 项目没有命名空间,用文件前缀给函数命名,比如plant_update、zombie_update,等于手动模拟了一圈命名空间。头文件里只放结构体定义、枚举、函数声明,不要放函数实现,否则多个.c文件包含同一个头文件时,链接阶段会报重复定义错误。顺手在头文件开头加#pragma once,避免头文件被重复包含导致的结构体重定义问题。

还有个编译细节:新建源文件时,VS 默认给.cpp扩展名,你要手动改成.c。.c文件默认按 C 语言编译,但如果遇到for (int i = 0; ...)报 C2143 语法错误,说明编译器还在按 C89 标准处理代码。VS 较新版本支持 C11/C17,但默认不一定开启,可以在项目属性 → C/C++ → 命令行里加上/std:c11,或者干脆把变量声明都放到函数开头,兼容性最好。课设代码量不大,后者更省事。

2.4 字符集与中文乱码:开局先改一个设置

EasyX 的outtextxy用来在窗口上输出字符串,但它对中文字符串的编码很敏感。新建的 VS 项目默认是 Unicode 字符集,你直接写outtextxy(10, 10, "阳光:50"),编译能过,显示出来却可能是乱码。原因是中文字符串字面量在 Unicode 字符集下被当作宽字符处理,而 EasyX 的窄字符版本按当前系统代码页解释,两边对不上。

提前做两个动作能省掉后面一整节排错时间。第一,项目属性 → 配置属性 → 常规 → 字符集,改成“使用多字节字符集”;第二,用 VS 的“文件 → 高级保存选项”把源文件编码保存为“简体中文 GB2312”或者“UTF-8 with BOM”。此后outtextxy输出中文就稳定了。如果你偷懒不想动编码,也有办法:全部用英文字符串,界面上写 “Sun: 50” 而不是“阳光:50”,但课设界面通常希望看到中文,还是建议按上面的步骤一次配好。

3. 游戏循环怎么写:双缓冲、帧率与“不是按帧移动”

3.1 游戏循环 = 输入 + 更新 + 绘制

环境跑通以后,接下来写的是任何一个游戏的核心骨架:游戏循环。无论商业引擎还是课设,游戏本质都是一个无限循环,每帧做三件事:收集输入、更新逻辑、绘制画面。

while (running) { handle_input(); /* 处理鼠标/键盘消息 */ update_game(dt); /* 更新植物、僵尸、子弹、胜负状态 */ draw_game(); /* 绘制草坪、植物、僵尸、子弹和UI */ Sleep(30); /* 限制帧率,约 33 FPS */ }

三件事的顺序不能乱。输入要放在最前面,让本帧的鼠标点击立即影响本帧的更新;更新必须在绘制之前,保证画面反映的是最新状态。如果先绘制再更新,画面永远慢一帧,最典型的现象是你点了种向日葵,但屏幕上延迟一下才出现植物,像网络卡顿一样。更新逻辑也不要写成一坨:plant_update、zombie_update、bullet_update分开调用,每个函数只关心自己的对象数组,出问题也好定位。

3.2 双缓冲防闪屏:BeginBatchDraw 的用法

不做任何处理时,EasyX 每执行一个绘图函数就向屏幕提交一次,画草坪、画植物、画僵尸,一次刷新就是一串闪烁,画面会闪得人眼发花。解决方法是批绘制,把一帧内所有绘制先画在内存缓冲区,结束后一次性提交。

BeginBatchDraw(); while (running) { handle_input(); update_game(dt); cleardevice(); draw_grass(); draw_plants(); draw_zombies(); draw_bullets(); draw_ui(); EndBatchDraw(); /* 提交本帧 */ BeginBatchDraw(); /* 准备下一帧 */ } EndBatchDraw();

注意BeginBatchDraw和EndBatchDraw必须成对出现。常见写法是循环外先执行一次BeginBatchDraw,循环体内绘制完毕后EndBatchDraw,紧接着下一次BeginBatchDraw,循环结束再补一次EndBatchDraw。如果只在循环外调一次,循环内不断EndBatchDraw,第二次执行时会报错或行为异常。判断标准很简单:每一帧从绘制开始到提交结束,边界清晰,就不会闪。

3.3 用 GetTickCount 做差帧计时,别用 Sleep 数帧

新手最容易犯的变速错误是写zombie.x += 2; Sleep(30);,意思是每 30 毫秒移动 2 像素。问题在于Sleep的精度并不可靠,实际睡眠 28 毫秒还是 35 毫秒取决于系统调度,换一台电脑速度就变,同一个电脑开个后台程序也会波动。正确做法是记录每帧真实流逝的时间,用“速度 × 时间”计算位移。

DWORD last = GetTickCount(); while (running) { DWORD now = GetTickCount(); double dt = (now - last) / 1000.0; /* 转成秒 */ last = now; if (dt > 0.1) dt = 0.1; /* 防止断点调试后瞬移 */ zombie_move(dt); Sleep(30); }

GetTickCount返回的是系统启动以来的毫秒数,除以 1000 后dt的单位是秒。此后所有移动逻辑都写成“每秒移动多少像素”,比如僵尸速度 30,就是一秒走 30 像素,dt = 0.033时单帧位移不到 1 像素,视觉上非常平滑。if (dt > 0.1) dt = 0.1这行是血泪经验:调试时命中断点,停了两秒再继续,dt直接变成 2 秒,僵尸会瞬间平移 60 像素,加了钳制就避免了这种“瞬移”假象。

3.4 鼠标输入:用 ExMessage 接住 WM_LBUTTONDOWN

EasyX 对鼠标消息的封装是ExMessage,配合peekmessage使用。很多入门教程会用getmessage,但那是阻塞式等待,程序会卡在等消息上,游戏循环直接停住。游戏里必须用非阻塞的peekmessage,有消息就处理,没消息就继续更新画面。

ExMessage msg; while (peekmessage(&msg, EX_MOUSE)) { if (msg.message == WM_LBUTTONDOWN) { handle_click(msg.x, msg.y); } }

EX_MOUSE表示只接收鼠标类消息,键盘事件需要改成EX_KEY。msg.x和msg.y是鼠标在窗口上的像素坐标,坐标系和绘图一致,原点在左上角。注意这里要判断的是WM_LBUTTONDOWN(按下)而不是WM_LBUTTONUP(抬起),用抬起事件会造成操作“滞后感”,点下去没反应,松开才触发,玩起来很难受。

4. 植物、僵尸与子弹:用结构体数组撑起一局游戏

4.1 结构体定义:C语言基础课的重点全在这里

“结构体 + 数组”是 C 语言课设的核心考点。植物、僵尸、子弹虽然属性不同,但都能用结构体描述,再用固定大小的数组统一管理。为什么不推荐链表?因为植物和僵尸总量有限,一关同时存在的对象最多几十个,固定数组遍历一遍的开销忽略不计;更重要的是,用数组可以避开malloc/free,不需要自己管理内存,更不容易写出悬垂指针。

enum { SUNFLOWER = 0, SHOOTER = 1, WALLNUT = 2 }; typedef struct { int type; /* 植物种类,对应枚举 */ int row, col; /* 格子位置 */ int hp; /* 生命值 */ int cd; /* 攻击/产出阳光冷却,单位毫秒 */ int alive; /* 1 表示存活 */ } Plant; typedef struct { int row; /* 所在行 */ double x; /* 横向位置,用浮点避免累积误差 */ int hp; int speed; /* 移动速度,像素/秒 */ int alive; } Zombie; typedef struct { int row; double x; int damage; int alive; } Bullet;

三个结构体的关键参数再解释一遍。Plant.type对应枚举里的植物种类,row、col是格子坐标,不是像素坐标;绘制时换算成像素是x = col * GRID_W、y = row * GRID_H。Zombie.x用double是因为差帧计时下位移通常是小数,如果存int,每帧向下取整会损失精度,僵尸走得一顿一顿。Bullet只存row和x,因为豌豆子弹沿水平直线飞行,不需要 y 坐标,这是平面游戏的常见简化。alive是标记删除的关键,对象死亡后置 0,遍历时跳过,而不是从数组里搬移元素。

4.2 种植与阳光:点击、冷却和“钱”

种植逻辑是鼠标交互的核心,一个种植操作要过三道关卡:点的是不是合法格子、阳光够不够、格子上有没有植物。全部通过才创建植物。

void handle_click(int mx, int my) { int col = mx / GRID_W; int row = my / GRID_H; if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return; if (sun_count < plant_cost[selected_type]) return; if (find_plant(row, col) != -1) return; add_plant(selected_type, row, col); sun_count -= plant_cost[selected_type]; }

GRID_W和GRID_H是草坪格子的宽高,一般取 80×100 像素,窗口 800×600 就是 10 列 6 行。plant_cost是个数组,下标对应植物枚举,比如plant_cost[SUNFLOWER] = 50、plant_cost[SHOOTER] = 100。find_plant(row, col)遍历植物数组,返回该格植物下标,找不到返回 -1。这套写法把校验全部前置,失败时直接 return,不会出现阳光扣了但植物没种上去的错乱状态。

阳光的来源有两类:向日葵周期性产出,以及天降阳光。向日葵的逻辑是每秒检查累计时间,冷却到了就把sun_count加 50 并重置冷却;天降阳光可以简化为每 15 秒在随机列生成一个阳光点。课设版本不用做“手动点阳光”的动画,阳光点出现后自动累加即可,代码量能省下不少。植物基础参数可以直接按这套配置走:

植物阳光成本生命值行为
向日葵50300每 10 秒产出 50 阳光
豌豆射手100300每 1.4 秒发射一颗 20 伤害的子弹
坚果墙504000无攻击,纯阻挡

4.3 子弹碰撞判定:按行命中与 fabs 距离

子弹的移动和碰撞是本章最容易出“玄学问题”的地方。因为豌豆射手只会朝正右方射击,子弹的 row 固定,碰撞检测就简化成:检查同一行里有没有僵尸,有的话判断子弹和僵尸的水平距离是否够近。

void bullet_update(double dt) { int i, j; for (i = 0; i < BULLET_MAX; i++) { if (!bullet[i].alive) continue; bullet[i].x += BULLET_SPEED * dt; /* 280 像素/秒 */ for (j = 0; j < ZOMBIE_MAX; j++) { if (!zombie[j].alive || zombie[j].row != bullet[i].row) continue; if (fabs(zombie[j].x - bullet[i].x) < COLLIDE_DIST) { zombie[j].hp -= bullet[i].damage; bullet[i].alive = 0; if (zombie[j].hp <= 0) zombie[j].alive = 0; break; } } } }

fabs(zombie[j].x - bullet[i].x)算的是水平距离,COLLIDE_DIST就是这个题目的“碰撞框半径”。取值 25 到 35 像素比较合适:太小了子弹会穿过僵尸身体打不中,太大了子弹离僵尸还剩半个身位就判定命中,看起来像隔空击杀。这段逻辑就是碰撞检测的黑匣子,调参时建议做个可视化调试开关,把判定框画出来,具体做法在第六章讲。如果追求更精确的碰撞,可以改成矩形相交:判断bullet.x > zombie.x - 15 && bullet.x < zombie.x + 15,本质一样,只是把圆判定换成了框判定。

4.4 僵尸波次与胜负:用状态机避免逻辑缠成一团

游戏胜负需要一个全局状态,不要用多个while循环分别处理“游戏进行中”和“游戏结束”,否则输入会卡在某个黑匣子里。用枚举状态机是最清晰的做法。

typedef enum { GAME_RUNNING, GAME_WIN, GAME_LOSE } GameState; GameState state = GAME_RUNNING; int house_hp = 10; /* 僵尸走到最左边,扣一次 */ void update_win_lose(void) { if (zombie_reach_home()) { house_hp--; remove_zombie_at_home(); } if (house_hp <= 0) state = GAME_LOSE; else if (wave_done() && count_alive_zombie() == 0) state = GAME_WIN; }

zombie_reach_home的判定条件是zombie.x < -50,即僵尸走出窗口左边界。wave_done检查所有波次是否都已生成,count_alive_zombie统计场上还没死的僵尸数量,两者同时满足说明通关。主循环里根据state决定绘制游戏画面还是结算画面,结算后按任意键退出。注意house_hp不要用僵尸数组里的某个属性去存,它是整个游戏的全局资源,单独一个全局变量最直观。

5. EasyX 课设版植物大战僵尸:5 个高频坑与排查方法

5.1 窗口一闪而过 / 黑屏无内容

现象:运行程序,图形窗口闪一下就消失,或者窗口是黑的,什么都看不到,像黑匣子一样不知道程序跑没跑。

原因:第一种情况是main函数执行完直接调用了closegraph,窗口被立刻销毁,你根本来不及看到画面。第二种情况是initgraph之后直接开始画,但没有先用cleardevice刷新背景,窗口默认是黑色的,画的元素又少,一眼望去就是全黑。

解决:在closegraph前调用_getch()阻塞等待按键;初始化时先setbkcolor再cleardevice,把背景刷成指定颜色。这两步是 EasyX 程序的标准开头,养成肌肉记忆。

initgraph(800, 600); setbkcolor(WHITE); cleardevice(); /* 初始化游戏数据 */ /* 主循环 */ _getch(); closegraph();

5.2 outtextxy 输出中文乱码

现象:窗口上输出“阳光:50”,显示成一串乱码或问号,英文数字全部正常。

原因:项目字符集是 Unicode,而 EasyX 窄字符版的outtextxy按多字节代码页解释字符串,两边编码不一致。另外,源文件如果以 UTF-8 无 BOM 保存,VS 编译器会误判中文字符串的长度,也会出现乱码。

解决:按 2.4 节的步骤,把项目字符集改成“使用多字节字符集”,并把源文件另存为 GB2312 或 UTF-8 with BOM。改完后重新编译,中文就正常了。这条坑每个用 VS + EasyX 的人几乎都会踩一次,提前改好后面一路顺畅。

5.3 画面闪烁严重、贴图有残影

现象:窗口里有植物和僵尸时,画面明显闪烁;移动鼠标或拖动窗口时,残影一片,像幻灯片。

原因:绘制时没有用批绘制,每个绘图函数都立即提交到屏幕,多次 GDI 操作叠加导致闪烁。还有一种隐蔽情况:有人把loadimage放在绘制函数里,每帧都重复从硬盘加载图片,性能直接崩掉,闪烁加上拖慢。

解决:所有绘制代码放进BeginBatchDraw/EndBatchDraw之间,具体写法见 3.2 节;图片资源只加载一次,存到全局IMAGE变量,绘制时只调用putimage。如果用了图片,记得把图片尺寸控制小一点,别放整张 800×600 的大图当背景还每帧重新加载。

5.4 僵尸速度时快时慢,换台电脑更明显

现象:同一份代码,在自己电脑上僵尸每 5 秒走到左边,在室友电脑上变成 3 秒;断点调试一次回来,僵尸直接瞬移。

原因:移动逻辑写成了x += 2; Sleep(30);,步长按帧固定,但帧率并不固定。Sleep的精度受系统负载影响,不同机器差异很大;断点调试导致两帧间隔长达几秒,步长一累加就瞬移。

解决:用GetTickCount计算真实时间差dt,所有移动写成“速度 × dt”,再对dt做上限钳制(比如 0.1 秒),防止调试后瞬移。这个改法一劳永逸,之后调整速度只需要改speed常量,不用再碰帧循环。

5.5 遍历中删对象导致“灵异”跳跃或数组越界

现象:子弹命中一只僵尸后,后面的僵尸也跟着掉血;或者点击“铲除植物”后,下一格的植物不见了,极端情况下数组越界崩溃。

原因:在for循环里删除对象时,直接用了覆盖移动元素的做法。正向遍历时删掉第 i 个元素,后面元素前移,下一次循环的 i 会跳过紧随其后的那个元素;如果删的是最后一个元素,还可能访问越界。

解决:统一用alive = 0做标记删除。遍历时只改标记,不移动数组元素,所有逻辑判断先从if (!obj[i].alive) continue;开始。清理数组的操作可以放在遍历结束后单独做,但课设版本不清理也没问题,固定大小的数组会越积越满,只要上限设得够大就行。

for (i = 0; i < ZOMBIE_MAX; i++) { if (zombie[i].alive && zombie[i].hp <= 0) zombie[i].alive = 0; }

6. 从能玩到能答辩:碰撞框可视化与三个调试技巧

6.1 把碰撞框画出来,问题自己会现形

碰撞距离 25 像素到底合不合理,光靠眼睛盯着屏幕很难判断,因为图形资源和碰撞框往往不是一回事。一个图形是 60 像素宽的僵尸,碰撞判定可能只有中间一条线,子弹飞过去碰到僵尸的袖子却不算命中,表现就是“打空枪”。我在做课设时养成的第一个调试习惯,是把碰撞框可视化。

#ifdef DEBUG_DRAW rectangle( (int)z.x - 15, (int)z.y - 60, (int)z.x + 15, (int)z.y); #endif

这段代码在僵尸位置周围画一个红色矩形,矩形宽 30 像素,高 60 像素,正好对应碰撞判定的左右边界。开启DEBUG_DRAW宏后,你能直接在屏幕上看到僵尸的判定框,子弹飞过来时有没有穿框一目了然。这个开关留着别删,答辩时给老师演示“我是怎么调试碰撞的”,比空口解释有说服力得多。

6.2 固定随机种子与事件日志

游戏里僵尸的生成往往带随机性,但随机数不固定会导致每次演示都不一样,答辩现场如果第一波僵尸就刷在离房子最近的行,观感会很差。调试阶段用固定随机种子,让每一局都按相同序列生成,逻辑可复现,排查问题也容易。

srand(20240); /* 固定种子,便于复现同一局 */

另一个容易被忽略的细节是事件日志。游戏逻辑复杂后,种植失败、子弹命中、僵尸进家,这些事件不能全靠printf打到控制台,因为 EasyX 的图形窗口可能盖住控制台窗口。我一般会把关键事件写进本地日志文件,调试时开着日志同步观察。

FILE *log_fp = fopen("game.log", "w"); fprintf(log_fp, "[%ld] plant at (%d, %d)\n", GetTickCount(), row, col); fflush(log_fp); /* 及时落盘,程序崩溃也不丢日志 */

fflush很重要。文件缓冲区默认是满了一整块才写盘,程序崩溃时最后几条日志往往会丢,调试关键路径时每写一条就刷一次盘,日志才可靠。

我做课设那年就栽在碰撞框上:豌豆射手的图形画得不小,判定半径调成 5 像素,子弹经常擦着僵尸的脸飞过去,被同学笑称“豌豆射手打空气”。后来把碰撞框画出来,三分钟就定位到是判定半径太小。那之后我做任何小游戏,第一件事永远是先把对象的可见框和碰撞框对齐,再谈玩法。希望这篇内容能帮你在课设周少走这几步弯路。

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

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

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

立即咨询