基于C语言的植物大战僵尸小游戏:核心代码与避坑指南
简介这是一份基于C语言实现的植物大战僵尸小游戏课程设计资源面向高校计算机专业学生适合作为C语言期末项目、课程设计或入门游戏编程的参考源码与二次开发基础。包内提供完整设计报告Word文档与全套C/C源码代码按include与src目录清晰分层头文件与实现文件分别封装地图、游戏逻辑、键盘监听、屏幕绘制等核心模块并附带Makefile与CMakeLists构建脚本便于快速编译运行。资源共30个文件除核心cpp、h源码外还有10张png游戏素材图包括向日葵、豌豆射手、樱桃炸弹、坚果墙与多种僵尸贴图、md说明文档及license文件压缩包仅174KB小巧且结构完整。已有2123人学习下载对想从零理解游戏循环、模块化编程以及需要撰写课程设计报告和准备答辩的同学都有较好的参考价值。1. 基于 C 语言的植物大战僵尸小游戏一个 zip 包把「学 C」变成「做游戏」「基于 C 语言设计的植物大战僵尸小游戏.zip」这个标题是很多人在 C 语言课设、期末练手、寒假自学阶段反复搜过的资源名。它说的不是某个商业游戏的源码而是学生向、课程向的 C 语言控制台小游戏实现不调用游戏引擎不用 C 的类体系只靠结构体、数组、循环、指针和标准输入输出把「种植物、产阳光、射豌豆、僵尸进攻、判定胜负」这套玩法在本地跑起来。这类 zip 真正值得重视的地方在于它把 C 语言最核心的语法点全部压进一个可编译、可交互的状态机里比单独刷题更容易看出自己到底会不会写代码。适合刚学完指针和结构体的学生也适合想回炉 C 语言的从业者拿来热身。2. 先立骨架再写玩法植物、僵尸、子弹的数据结构与状态机写 C 语言小游戏最容易犯的第一个错误就是一上来写画面、写按键、写「种下向日葵」。等代码写到几百行主函数变成一坨 if 嵌套改一个功能牵动全局最后只能推翻重来。我的习惯是先确定数据怎么放再确定状态怎么跳最后才碰具体玩法。这个顺序对 C 语言尤其重要因为 C 没有类和对象兜底结构体数组一旦定错后面改起来全是体力活。2.1 地图用二维数组、植物和僵尸用结构体数组先把容量定死常见做法是把地图抽象成一个二维数组 map[ROW][COL]行数通常取 5列数取 9。这个尺寸不是随意定的它对应经典玩法里「五条路、每路九个格子」的布局对控制台小游戏来说一个字符格放一株植物刚好铺满常见终端尺寸。格子的值用 -1 表示空地0 到 N 表示种了第几号植物初始化时全填 -1。然后是植物的数据。植物数量少最多也就 5×945 株所以用一维结构体数组就能装下全部在场的植物每个结构体记录类型、血量、所在格子和射击冷却typedef struct { int type; // 植物编号0向日葵 1豌豆射手 2坚果 int hp; // 当前血量 int x, y; // 像素坐标用于绘制和碰撞 int cooldown; // 距下一次产阳光/发射的帧计数 int alive; } Plant; #define MAX_PLANT 45 // 5行 x 9列最多同时存在 45 株 #define ROW 5 #define COL 9这里我把数组长度直接定死成 45而不是用 malloc 按需生成。理由很简单地图格子数就是上限没有植物会跑到格子外面。定长数组在遍历时不需要担心释放问题逻辑也透明。真正需要小心的是「种植物前先确认这格为空」否则同一格会出现两株植物叠着绘制的情况。僵尸和植物不同僵尸是动态实体会不断向左移动同一行可能同时存在多个僵尸。常见设计是用一维结构体数组 zombieList[MAX_ZOMBIE]每一项带行号 line、像素 x 坐标、血量、速度、状态。为什么不塞进二维数组因为二维数组存不住「移动中」的对象——你总不能要求僵尸每走一步就换一个数组下标那样动画会变成一格一格跳。移动中的实体用一维数组携带像素坐标固定格子的植物用二维地图承载两者分工不同。typedef struct { int line; // 僵尸所在行0~4 float x; // 像素横坐标小于等于 0 表示进了房子 int hp; int speed; // 每帧向左移动像素数 int state; // 0行走 1啃咬 2死亡 int attackTimer; // 啃咬间隔计时 int alive; } Zombie;这里有个新手容易忽略的设计点行动速度和攻击速度要分开。僵尸在行进时speed 决定每帧向左位移多少一旦前方有植物挡住僵尸应该停下进入啃咬状态speed 置为 0而不是继续往前推。如果你只有一个「速度」字段就会出现僵尸穿到植物背后再回头啃的奇异画面——这个问题在避坑章节还会展开。2.2 用枚举状态机管理菜单、战斗、结算让主循环不再是一坨 if把游戏过程拆成状态是这个项目最容易出彩的地方。最常见的做法是定义一个枚举把界面分成主菜单、战斗、失败结算、胜利结算四态typedef enum { GAME_MENU, // 主菜单按回车开始 GAME_PLAY, // 游戏进行中 GAME_OVER, // 失败结算 GAME_WIN // 胜利结算 } GameState;游戏只有一个主循环循环里根据当前状态调用不同的 update 和 render 函数。C 语言的 switch-case 语法天然适合做这件事GameState state GAME_MENU; while (running) { switch (state) { case GAME_MENU: handleMenuInput(state); renderMenu(); break; case GAME_PLAY: updateGame(); renderGame(); break; case GAME_OVER: case GAME_WIN: handleEndInput(state); renderEnd(state); break; } Sleep(50); // 控制帧率让画面约 20 帧每秒 }状态机的价值在写代码时就能体会菜单界面不需要处理子弹和碰撞战斗界面不需要等待玩家按回车每个界面只处理自己该处理的事。还有一个实际好处——它让「重新开局」变得非常清晰把 state 从 GAME_WIN 改成 GAME_PLAY 时顺便调用一次 initGame() 把所有数组清零比「跳转回游戏内部某一行」要安全得多。需要注意「暂停」怎么处理。我见过不少代码把暂停也塞进状态机结果发现暂停后战斗数据全被切走恢复时还得重新初始化。更顺手的做法是暂停只是 GAME_PLAY 状态内部的一个标志战斗的 update 函数开头先查它为真就直接返回。这样暂停时画面冻结但状态机本身不用多绕一圈。对于课设答辩状态机是特别好讲的部分别人问你「程序怎么组织」你能回答「四个状态、一个主循环、每个状态各司其职」这句话就已经把框架说清楚了。这比说「我写了好多个 if」体面得多。2.3 像素坐标与网格坐标分开换算碰撞判定才不靠玄学控制台绘制的植物大战僵尸小游戏本质上是在一张像素画布上摆矩形。常见做法是每个格子逻辑上有格子坐标 (row, col)绘制时有像素坐标 (x, y)两者靠换算公式关联pixelX col * CELL_WpixelY row * CELL_H。这个换算必须写成一两个函数而不是在每个地方重复写乘法。#define CELL_W 60 #define CELL_H 48 void gridToPixel(int row, int col, int *x, int *y) { *x col * CELL_W; *y row * CELL_H; } void pixelToGrid(int x, int y, int *row, int *col) { *row y / CELL_H; *col x / CELL_W; }真正的难点在于「坐标到底指物体的左上角还是中心点」。绘制植物时通常把坐标当左上角而碰撞判断时如果拿左上角去做矩形相交会出现子弹要打到植物头顶上才判定命中的感觉。统一的做法是绘制矩形用 (x, y, xw, yh)碰撞判断也用这四个边界保证两套逻辑用的是同一个盒子。植物绘制在格子里时y 取 rowCELL_Hx 取 colCELL_W只要显示盒和碰撞盒保持一致就没必要纠结中心点。僵尸的碰撞盒比格子小一圈通常更自然。如果僵尸占满整格子弹才飞到格边就会判定命中玩家会觉得「子弹在空地上爆炸」。常见做法是僵尸碰撞盒取约 70% 宽度例如 x 坐标加 8 像素、再取 52 像素宽作为判定范围。这个常数建议定义成宏 ZOMBIE_BOX_OFFSET将来调整手感只改一处不要在碰撞函数里写裸数字。有了坐标系统和碰撞盒的概念后面的子弹命中判定就不需要靠「两个坐标比较接近」这种玄学写判断而是明确调用一个矩形相交函数。这也是这类项目代码好坏的分水岭看碰撞函数里有没有一堆不清楚含义的裸数字基本能判断作者有没有认真设计过。3. 核心玩法代码分段按键读取、子弹对象池与碰撞函数骨架定好之后接下来是最容易出现「代码写完了但跑起来不是那个味道」的地方按键、子弹和碰撞。这一章我按实际开发顺序把三个最核心的模块拆开讲。3.1 非阻塞读键Windows 的 _kbhit 和 Linux 的 termios 两条路很多控制台游戏用 getch() 读按键但 getch 在没按键时会死等导致游戏画面卡住。主菜单里死等没问题战斗画面却必须持续刷新动画。最可靠且最容易讲清楚的方案游戏循环里先检测是否有输入有才去读键。Windows 下用 conio.h 提供的 _kbhit#include conio.h int readKeyIfAny() { if (!_kbhit()) { return -1; // 无按键 } return _getch(); // 有按键取一个字符 }主循环每帧调用这个函数拿到有效按键再交给 handleKeyint key readKeyIfAny(); if (key ! -1) { handleKey(key, state); }但 conio.h 是 Windows 专属。Linux 版源码通常用 termios 设置终端进入非规范模式让输入不排队直接到程序手里#include termios.h #include unistd.h void setNonCanonicalMode(int enable) { static struct termios oldt, newt; if (enable) { tcgetattr(STDIN_FILENO, oldt); newt oldt; newt.c_lflag ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, newt); } else { tcsetattr(STDIN_FILENO, TCSANOW, oldt); } }这个函数的作用是让终端把每次按键立即交给程序而不是等按下回车。程序退出前记得调用 setNonCanonicalMode(0) 恢复原状否则终端会停留在不回显、不换行的模式非常难受。也有的 Linux 版本直接用 ncurses 库处理输入输出那就不需要手写 termios但编译时要加 -lncurses。按键处理有个细节方向键在 getch 体系里会返回两个字节先返回 224 或 0 表示「这是扩展键」再返回具体键码。只读第一段会导致方向键失灵这个在避坑章节再展开。3.2 用子弹对象池复用固定数组避免每颗豌豆都 malloc每发射一颗豌豆就 malloc 一次是新手代码里常见但危险的写法。子弹生命周期短游戏循环里高频 malloc/free 会产生内存碎片更糟的是忘记释放战斗越久内存占用越高。最常见也更稳妥的做法是「对象池」用一个定长 Bullet 数组每颗子弹带 alive 标志发射时找空位飞出屏幕或命中后把 alive 置 0 当作回收。#define MAX_BULLET 200 typedef struct { float x, y; int line; // 子弹所在行 int speed; // 每帧移动像素数 int alive; } Bullet; Bullet bullets[MAX_BULLET]; void fireBullet(int line, float startX, float startY) { for (int i 0; i MAX_BULLET; i) { if (!bullets[i].alive) { bullets[i].x startX; bullets[i].y startY; bullets[i].line line; bullets[i].speed 4; bullets[i].alive 1; break; } } }把创建和更新分开每帧调用一次 updateBulletsvoid updateBullets() { for (int i 0; i MAX_BULLET; i) { if (!bullets[i].alive) continue; bullets[i].x - bullets[i].speed; // 豌豆向左飞 if (bullets[i].x 0) { bullets[i].alive 0; // 飞出屏幕即回收 } } }为什么 MAX_BULLET 取 200极限情况估算每行每秒发射 2 发5 行同时开火豌豆从生成到飞出屏幕约 3 秒同时存在的子弹最多约 30 颗200 已经留足余量。这种「用空间换实现简单」的取舍在 C 语言面向过程风格里非常典型数组上限可控、没有泄漏、逻辑透明答辩时也说得清楚。单一风险是数组满了以后新的豌豆无处可发实际玩起来几乎不会触发真触发了也不难扩展。3.3 碰撞判断顺序子弹打僵尸、僵尸啃植物、僵尸进房子碰撞逻辑是这个游戏的核心规则判断顺序错了会看到奇观子弹先穿过僵尸僵尸再推平植物房子还没被吃掉就闪胜利。常见规则是子弹命中僵尸子弹消失、僵尸掉血僵尸撞上所在列的植物后停下进入啃咬状态僵尸 x 坐标小于等于 0 且前方已无阻挡时房子失守。常见做法是把碰撞拆成三个小函数按固定顺序调用。第一段是子弹打僵尸void checkBulletHitZombie() { for (int i 0; i MAX_BULLET; i) { if (!bullets[i].alive) continue; for (int j 0; j MAX_ZOMBIE; j) { if (!zombies[j].alive) continue; if (bullets[i].line zombies[j].line rectIntersect( bullets[i].x, bullets[i].y, BULLET_W, BULLET_H, zombies[j].x, zombies[j].y, ZOMBIE_W, ZOMBIE_H)) { bullets[i].alive 0; zombies[j].hp - 20; break; // 一颗子弹只打一个僵尸 } } } }rectIntersect 是标准 AABB 碰撞判断建议写成独立公共函数int rectIntersect(float x1, float y1, float w1, float h1, float x2, float y2, float w2, float h2) { return x1 x2 w2 x1 w1 x2 y1 y2 h2 y1 h1 y2; }这里有一个容易混淆的点矩形碰撞不是「两个中心点距离小于半径」而是「两个矩形在 x 轴和 y 轴上的投影都重叠」。把公式拆开就能讲明白——x1 x2w2 且 x1w1 x2 同时成立意味着横向投影有交集y 轴同理。我把每帧更新顺序固定为updateZombies 推僵尸位置和状态updateBullets 让子弹飞行checkBulletHitZombie 判命中checkZombieEatPlant 判啃咬最后 checkGameResult 判胜负。顺序一致才能保证结果稳定。子弹命中判断放在僵尸啃咬之前否则会出现僵尸已经倒地的动画子弹还在空中飞半天。这个细节看似不起眼却是熟手代码和新手代码最明显的区别之一。4. 把源码编译跑起来GCC 命令、Windows 代码页与项目目录结构拿到 zip 是一回事能在自己电脑上编译运行是另一回事。大多数拿到这类项目的人首先遇到的根本不是游戏逻辑而是编译环境。这一章讲三条最常用的路线。4.1 一条 gcc 命令编译多文件工程makefile 注意用 Tab 缩进在 Linux 或 Windows 的 MinGW 环境下最常见的做法是把所有源文件一次性交给 gcc而不是只编译 main.c。文件多的时候用通配符即可gcc -Wall -Wextra -stdc99 main.c game.c ui.c -o pvz_game-Wall -Wextra 让编译器把未使用变量、类型不匹配等潜在问题以警告形式显示。对新手来说警告不是噪音而是免费的代码审查。如果代码用了数学函数编译命令要加 -lm如果用了 ncurses要加 -lncurses。看到 undefined reference 报错时十有八九是漏了 -l 链接项。想反复改代码重编makefile 能省很多事。常见骨架CC gcc CFLAGS -Wall -Wextra -stdc99 OBJS main.o game.o ui.o TARGET pvz_game $(TARGET): $(OBJS) $(CC) $(OBJS) -o $(TARGET) main.o: main.c game.h ui.h game.o: game.c game.h ui.o: ui.c ui.h clean: rm -f *.o $(TARGET)makefile 的换行必须是 Tab 缩进不能用四个空格代替。报错只显示 missing separator定位方向却不在 C 代码上这属于低级但高发的问题。另一个容易踩的是 Windows 下 MinGW 的 make 不一定叫 make有的环境里是 mingw32-make命令对不上时会误以为 makefile 写错了。4.2 在 Windows 控制台显示中文代码页 936 与 65001 的抉择源码文件用 UTF-8 保存而 Windows 控制台默认用 GBK代码页 936显示printf 里的中文就会全部变成乱码。常见解决办法有两种在 main 开头调用 SetConsoleOutputCP(CP_UTF8) 切换控制台代码页或者把源文件另存为 ANSI 编码。我一般推荐第一种因为它不改源文件编码跨平台复用更友好。#include windows.h int main(void) { SetConsoleOutputCP(CP_UTF8); // ... 游戏初始化 }如果你拿到的是 Linux 版源码想放到 Windows 编译会发现文件里没有 windows.h 也没有 conio.h那就要做小幅兼容把按键读取替换为 conio 版本或者用宽字符输出。代码里的 system(cls) 在 Windows 下正常在 Linux 下无效反过来也一样。这些适配不难但它往往决定了「这个 zip 能不能在自己的电脑上跑起来」这个第一道坎。4.3 解压后先检查头文件与资源目录再决定怎么编译一个结构完整的项目压缩包内部通常分成几类入口 main.c、业务逻辑 game.c/game.h、界面渲染 ui.c/ui.h、说明文件。打开压缩包后建议先做两件事搜索所有 #include确认引用的每个自定义头文件都在目录里找到 main 函数入口确认它是控制台程序而非图形窗口程序。如果有 .sln 或 .vcxproj说明是针对 Windows IDE 打的包如果只有 makefile则是以 GCC 为主。「为什么我解压后打开 .c 文件直接编译报错」通常不是代码坏了而是头文件缺失或跨平台依赖问题。排查方式用文本编辑器全局搜 #include把每个系统外头的文件名在目录里找一遍。如果引用了 windows.h 而你正站在 Linux 终端编译失败就是必然结果这不是玄学是平台差异。有些代码包是纯数组加源码不需要外部素材有些版本引用了图片、音频资源压缩包通常带有 assets 或 res 文件夹运行时 exe 必须和资源文件夹保持相对路径。我建议把解压目录当作工作目录在终端里从该目录启动 exe而不是双击快捷方式避免找不到资源的诡异问题。这种问题在控制台版本里不常见但一旦发生现象往往是不分系统地闪退。5. 避坑清单双击闪退到僵尸穿墙的 5 个高频问题这类项目的坑非常集中大部分问题不用看完整代码就能定位。我按实际排查顺序列五个高频问题每条按「现象 → 原因 → 解决」展开。5.1 双击 exe 秒退连主菜单都看不见现象在文件管理器里双击编译好的 exe屏幕上窗口一闪而过根本来不及看画面。原因程序运行完毕正常退出控制台窗口被系统直接关闭。多数情况下是 main 函数里没有等待按键的代码游戏跑完或出错直接 return 了。解决在 main 的 return 前加暂停常见做法是 getchar() 或 system(pause)。但更值得查的是程序是否在初始化阶段崩溃——如果 main 一开始就访问了空指针窗口同样会秒退。建议先在终端里运行 exe让报错信息留在屏幕上而不是双击后猜问题。5.2 豌豆明明打在僵尸身上却穿过去现象子弹飞行轨迹和僵尸在同一直线上肉眼看着明显撞上了但没有命中反馈僵尸照常前进。原因绝大多数情况是坐标体系混乱。子弹和僵尸的 line 字段一致但像素 x 坐标不是同一个参考点比如子弹存中心点、僵尸存左上角或者碰撞盒和绘制盒尺寸不一致绘制时占了整格碰撞时判定范围只有一小块。解决统一矩形坐标约定绘制和碰撞都用同一个 (x, y, w, h) 四个值。如果两个矩形来自同一个坐标系仍然不命中就在碰撞函数里临时打印两个矩形的位置跑一帧就能看出偏差。5.3 游戏越打越卡内存占用只涨不降现象前期流畅玩到后期僵尸和子弹一多画面帧率明显下降任务管理器里进程内存持续增长。原因用了 malloc 动态创建僵尸或子弹删除时只把对象从链表里移除没有真正 free或者遍历时用下标删除元素索引没有回退导致后面的对象被跳过产生越积越多的残留数据。解决换成预分配数组加 alive 标志这是最省心的做法。如果项目确实用了链表删节点时必须先保存下一个节点指针再 free 当前节点并在循环结束后补充检查。Linux 上可以用 valgrind 检查泄漏位置Windows 下没有这么直接的体感工具所以从一开始就避免频繁分配是更好的策略。5.4 界面中文全是乱码现象植物名、提示语、按钮文字全部显示成乱码英文和数字正常。原因源码文件是 UTF-8 编码Windows 控制台默认代码页 936GBK两边各按各的编码读结果就是乱码。解决在 main 开头调用 SetConsoleOutputCP(CP_UTF8)或者把源码另存为 ANSI 编码再重新编译。还有一个小习惯如果项目里同时存在多个 .c 文件保持所有文件的编码一致不要一个 UTF-8 一个 GBK否则符号也会出问题。5.5 方向键按下变成两个字符按键总是失灵现象按方向键时界面没反应或者屏幕多出奇怪字符按其他键又正常。原因方向键在 getch 体系里返回两个字节第一个是 224 或 0 表示扩展键第二个才是具体键码。代码只读了一个字节自然得不到完整方向信息。解决封装一个 readKey 函数检测到扩展键前缀后连续读两次再统一转换成方向常量int readKey() { int c _getch(); if (c 224 || c 0) { return _getch(); // 第二次读到方向键码 } return c; // 普通字符 }这里有个容易忽略的细节如果代码在菜单界面和战斗界面都调 getch暂停时又调用了一次 getch可能会把上一次的残留按键吃掉。建议所有按键读取都走同一个 readKey 函数不要到处直接调 getch。6. 从「能跑」到「能答辩」存档、音效和模块拆分三个进阶改动程序能跑只是起点真正让人觉得「这项目做得完整」的通常不是玩法多酷而是几个一直没处理的小事。我建议按下面三个方向做进阶改动。第一加一个二进制存档。把植物数组、僵尸数组、当前分数、当前波次写进一个结构体用 fwrite 一次性存盘typedef struct { Plant plants[MAX_PLANT]; Zombie zombies[MAX_ZOMBIE]; int score; int wave; } SaveData; void saveGame(const char *path) { FILE *fp fopen(path, wb); SaveData data {0}; // 把当前游戏数据填入 data fwrite(data, sizeof(SaveData), 1, fp); fclose(fp); }这个功能在答辩时特别好讲它展示了结构体、指针、文件操作三个知识点的结合也解决了「玩到一半关掉就重来」的体验问题。读取存档时直接用 fread 读回结构体再重新初始化状态机即可。第二加一个简单的音效。Windows 下可以用 Beep 函数按频率播放提示音比如向日葵产出时响一声短音僵尸死亡时响低音。Linux 下没有 Beep 标准接口可以切换为终端响铃或干脆用文本提示代替。这个功能虽然不起眼但它把游戏从「画面程序」变成了「多感官体验」答辩演示时不冷场。第三把代码按职责拆成两个文件。main.c 只放主循环和状态机game.c 放所有游戏逻辑更新ui.c 放绘制输出。头文件只暴露出必要接口比如 initGame、updateGame、renderGame。我今天给出的所有代码实际上已经按这个结构组织你只需要把段落分文件即可。拆完以后代码的可读性提升非常明显出问题时一眼就能定位到文件。以前我自己练手时把所有逻辑塞进 main 函数里写到六百行以后改一个变量名都得小心翼翼。后来把 update 和 render 拆开把每个功能函数控制在几十行以内才算真正体会到模块化的价值。这类项目最大的收获不是说「我会写植物大战僵尸」了而是你终于知道一段 C 程序是怎么从零长成一个完整可交付的产品的。按这个思路把一个 zip 里的示例改造成自己的版本比下载下来跑通一遍有用得多。希望这些整理能帮到你少走几步当年的弯路。本文还有配套的精品资源点击获取