简介这是一份面向C初学者与课程设计需求者的控制台版植物大战僵尸游戏源码编号100013171适合用来练习面向对象编程、状态机与STL容器综合应用。项目以状态机实时响应用户输入通过多线程并行避免阻塞其他功能植物与僵尸均采用继承体系实现代码复用并以虚函数重写各自特殊行为子弹、地块对象则借助STL容器统一管理便于遍历、增删与移除。游戏主循环遵循draw、getkey、update的经典结构逻辑清晰便于二次开发与调试。压缩包共39个文件约337KB包含9个cpp与9个h源码文件、16张png与2张jpeg图片资源、1个可执行exe、1份README说明及LICENSE源码与素材组织完整。目前已有1062人学习下载可作为课程设计参考、C练手项目或状态机与继承多态的实战范例帮助读者快速理解游戏主循环与对象管理思路。1. 从零手搓植物大战僵尸C 小游戏到底能带你走多远很多人第一次动念头用 C 写游戏脑子里蹦出来的就是植物大战僵尸。原因很实在玩法规则清晰、格子逻辑天然适合练手、素材网上能扒到而且做出来能跑能玩比控制台里打印九九乘法表有成就感得多。但真动手就会发现这事的难点根本不在“C 语法会不会”而在于你能不能把一堆散装知识点——随机数、定时器、碰撞检测、状态机、资源管理——捏成一个能持续运行几分钟不崩的程序。我见过太多人卡在“僵尸走到向日葵面前不攻击”或者“豌豆飞出去不消失”这种地方一卡就是好几天。这篇东西就是把我自己踩过的路重新铺一遍从环境配置到核心循环从格子坐标到僵尸寻路每一步都给能直接抄的代码和参数说明。适合已经学过 C 基础语法、想找个完整项目把知识串起来的人也适合做过控制台小游戏、想试试图形界面但不想一上来就啃虚幻引擎的开发者。读完你至少能拿到一个可编译、可扩展的骨架剩下的关卡设计、植物种类、僵尸波次都是在这个骨架上加肉的事。2. 环境与框架选型为什么我不推荐一上来就上引擎2.1 图形库的三条路EasyX、SFML、SDL2 怎么选用 C 做植物大战僵尸第一个岔路口就是图形库。网上搜“C 小游戏”十篇有八篇用 EasyX因为它确实简单一个initgraph开窗口putimage贴图getmessage收消息半天就能让向日葵出现在屏幕上。但 EasyX 的问题也很明显——它本质是封装了 Windows GDI只支持 Windows而且对透明通道、图层混合、音频播放的支持相当有限。你做到后面想加个僵尸被冰冻的蓝色叠加效果或者想播个背景音乐就会开始难受。SFML 和 SDL2 是更正规的选择。SFML 的 C 接口更“现代”面向对象程度高sf::Sprite、sf::Texture、sf::Clock这些类用起来很顺手文档也清晰。SDL2 更底层C 风格接口但跨平台支持极好很多商业独立游戏都用它。如果你只是想在 Windows 上快速出个能跑的东西EasyX 够用如果你想顺便学点正经的游戏开发概念或者以后可能换 Mac/Linux直接上 SFML。我个人的习惯是教学演示用 EasyX自己写项目用 SFML。注意SFML 2.6 和 3.0 的 API 有差异网上很多教程是 2.x 的装的时候看清楚版本。新手建议先用 2.6.x资料多踩坑少。2.2 VS Code 配置 C/C 环境三个文件别写错不管你选哪个图形库VS Code 都是目前最顺手的编辑器。但它的 C 环境配置对新手确实不太友好核心就是三个文件c_cpp_properties.json、tasks.json、launch.json。很多人编译报错“找不到头文件”九成是c_cpp_properties.json里的includePath没写对。// .vscode/c_cpp_properties.json { configurations: [ { name: Win32, includePath: [ ${workspaceFolder}/**, C:/SFML-2.6.1/include // 换成你的 SFML 实际路径 ], defines: [_DEBUG, UNICODE, _UNICODE], compilerPath: C:/mingw64/bin/g.exe, // 换成你的编译器路径 cStandard: c17, cppStandard: c17, intelliSenseMode: windows-gcc-x64 } ], version: 4 }includePath告诉 VS Code 的智能提示去哪里找头文件compilerPath告诉它用哪个编译器。这两个不对代码里就会满屏红波浪线但实际编译可能没问题——那是 IntelliSense 在闹脾气。// .vscode/tasks.json { version: 2.0.0, tasks: [ { label: build, type: shell, command: g, args: [ -g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe, -I, C:/SFML-2.6.1/include, -L, C:/SFML-2.6.1/lib, -lsfml-graphics, -lsfml-window, -lsfml-system ], group: { kind: build, isDefault: true } } ] }tasks.json是编译任务。-I指定头文件目录-L指定库文件目录-l指定要链接的库。SFML 需要链接 graphics、window、system 三个库顺序不能乱因为库之间有依赖关系。// .vscode/launch.json { version: 0.2.0, configurations: [ { name: C Debug, type: cppdbg, request: launch, program: ${fileDirname}/${fileBasenameNoExtension}.exe, args: [], stopAtEntry: false, cwd: ${fileDirname}, environment: [], externalConsole: false, MIMode: gdb, miDebuggerPath: C:/mingw64/bin/gdb.exe, preLaunchTask: build } ] }launch.json管调试。preLaunchTask填build意思是按 F5 调试之前先执行编译任务。miDebuggerPath指向 gdb 的路径写错了就进不了断点。提示如果你用的是 Microsoft Visual C Redistributable 相关的运行库问题那通常是运行 exe 时缺 DLL。MinGW 编译的程序依赖libstdc-6.dll、libgcc_s_seh-1.dll这些要么把 MinGW 的 bin 目录加到系统 PATH要么编译时加-static静态链接。2.3 项目目录结构别把所有代码塞进一个 main.cpp新手最容易犯的错就是所有代码写在一个文件里。一开始可能就两三百行感觉还行等加到僵尸、子弹、阳光、卡片、关卡文件直奔两千行改一个 bug 要滚半天鼠标。我一般会这样分pvz/ ├── assets/ # 图片、音频、字体 │ ├── images/ │ ├── sounds/ │ └── fonts/ ├── include/ # 头文件 │ ├── Game.h │ ├── Plant.h │ ├── Zombie.h │ ├── Bullet.h │ └── Grid.h ├── src/ # 源文件 │ ├── main.cpp │ ├── Game.cpp │ ├── Plant.cpp │ ├── Zombie.cpp │ ├── Bullet.cpp │ └── Grid.cpp └── CMakeLists.txt # 如果用 CMake 构建Game类管主循环和状态Plant、Zombie、Bullet各自管自己的更新和绘制Grid管格子坐标转换。这样每个文件两三百行改起来清爽。编译的时候用g src/*.cpp -o pvz.exe ...一把编或者写个简单的 Makefile。3. 核心循环与格子坐标把“种植物”这件事说透3.1 游戏主循环固定时间步长为什么比 deltaTime 更稳游戏主循环是所有逻辑的发动机。最简单的写法是while (window.isOpen())里处理事件、更新、绘制。但更新逻辑如果直接用每帧的deltaTime会遇到一个玄学问题帧率波动时僵尸移动速度会变。比如你设定僵尸每秒走 20 像素60 帧时每帧走 0.33 像素30 帧时每帧走 0.67 像素看起来没问题但碰撞检测的精度会变子弹可能“穿透”僵尸。我一般用固定时间步长逻辑更新固定按 1/60 秒走渲染帧率随显示器刷新率走。// Game.cpp 核心循环 #include SFML/Graphics.hpp const float FIXED_DT 1.0f / 60.0f; // 逻辑更新固定步长 const float MAX_FRAME_TIME 0.25f; // 防止卡顿后逻辑爆炸 void Game::run() { sf::Clock clock; float accumulator 0.0f; while (window.isOpen()) { float frameTime clock.restart().asSeconds(); if (frameTime MAX_FRAME_TIME) frameTime MAX_FRAME_TIME; accumulator frameTime; // 事件处理 sf::Event event; while (window.pollEvent(event)) { if (event.type sf::Event::Closed) window.close(); handleInput(event); } // 固定步长更新逻辑 while (accumulator FIXED_DT) { update(FIXED_DT); // 所有移动、碰撞、计时都在这 accumulator - FIXED_DT; } // 渲染 window.clear(); render(); window.display(); } }FIXED_DT是逻辑更新的固定间隔accumulator累积真实流逝的时间够一个步长就更新一次。MAX_FRAME_TIME是保险丝如果窗口被拖动或者系统卡了一下frameTime可能突然变成好几秒不加限制的话while循环会疯狂执行几千次update游戏直接卡死。这个坑我踩过调试的时候窗口一拖就崩找了半天才发现是这里。3.2 格子坐标转换鼠标点下去怎么知道种哪一格植物大战僵尸的草坪是 5 行 9 列。每格宽 80 像素、高 100 像素草坪左上角在屏幕坐标(260, 100)左右不同素材包不一样自己量。鼠标点击时要把屏幕坐标转成格子行列。// Grid.h struct Grid { static const int ROWS 5; static const int COLS 9; static const int CELL_W 80; static const int CELL_H 100; static const int OFFSET_X 260; // 草坪左上角 x static const int OFFSET_Y 100; // 草坪左上角 y // 屏幕坐标 - 格子坐标返回 {-1,-1} 表示不在草坪内 static sf::Vector2i screenToGrid(int sx, int sy) { int col (sx - OFFSET_X) / CELL_W; int row (sy - OFFSET_Y) / CELL_H; if (col 0 || col COLS || row 0 || row ROWS) return {-1, -1}; return {col, row}; } // 格子坐标 - 屏幕坐标格子中心 static sf::Vector2f gridToScreen(int col, int row) { return { OFFSET_X col * CELL_W CELL_W / 2.0f, OFFSET_Y row * CELL_H CELL_H / 2.0f }; } };screenToGrid里用的是整数除法负数除法在 C 里是向零取整所以(sx - OFFSET_X)为负时col会是 0 而不是 -1必须靠后面的边界检查兜住。这个细节不注意鼠标点到草坪左边外面也会被当成第 0 列。gridToScreen返回格子中心方便把植物、僵尸的精灵居中放置。僵尸从右边出来时x 坐标从OFFSET_X COLS * CELL_W开始往左走走到OFFSET_X就算进屋。3.3 植物放置与阳光扣除一个容易写反的判断点击卡片、再点草坪、扣阳光、放植物这个流程的逻辑顺序很重要。我见过有人先扣阳光再检查格子是否为空结果格子被占了阳光也扣了玩家直接骂人。正确顺序是检查卡片是否选中 → 检查格子是否为空 → 检查阳光是否足够 → 扣除阳光 → 放置植物 → 取消卡片选中。// Game.cpp 处理鼠标点击 void Game::handleClick(int mouseX, int mouseY) { // 先检查是否点了卡片 int cardIndex getClickedCard(mouseX, mouseY); if (cardIndex 0) { selectedCard cardIndex; return; } // 再检查是否点了草坪 sf::Vector2i grid Grid::screenToGrid(mouseX, mouseY); if (grid.x 0) return; // 没点草坪 if (selectedCard 0) return; // 没选卡片 // 格子已被占用 if (plants[grid.y][grid.x] ! nullptr) return; // 阳光够不够 int cost cardCosts[selectedCard]; if (sun cost) return; // 扣阳光、放植物 sun - cost; plants[grid.y][grid.x] createPlant(selectedCard, grid.x, grid.y); selectedCard -1; // 取消选中 }plants是个二维数组plants[row][col]存植物指针空着就是nullptr。注意行列顺序grid.y是行grid.x是列写反了植物就种到隔壁去了。这个 bug 很隐蔽因为画面看起来“差不多对”但僵尸走到面前时攻击判定会错位。4. 僵尸、子弹与随机数让战斗逻辑跑起来4.1 僵尸寻路不是 A*是“一条路走到黑”植物大战僵尸的僵尸不需要寻路算法。它们从右边固定行出现直线往左走遇到植物就停下来啃。所以僵尸的状态机很简单WALKING、EATING、DYING。// Zombie.h enum class ZombieState { WALKING, EATING, DYING }; class Zombie { public: float x, y; int row; float speed 20.0f; // 像素/秒 float hp 100.0f; float eatDamage 50.0f; // 每秒啃食伤害 ZombieState state ZombieState::WALKING; Plant* target nullptr; // 正在啃的植物 void update(float dt, std::vectorPlant* plants) { if (state ZombieState::DYING) return; // 检查当前行有没有植物在攻击范围内 Plant* front findFrontPlant(plants); if (front) { state ZombieState::EATING; target front; front-hp - eatDamage * dt; if (front-hp 0) { removePlant(front); state ZombieState::WALKING; target nullptr; } } else { state ZombieState::WALKING; x - speed * dt; } if (hp 0) state ZombieState::DYING; } private: Plant* findFrontPlant(std::vectorPlant* plants) { for (auto* p : plants) { if (p-row row p-hp 0) { // 僵尸的嘴在 x 位置植物中心在 p-x if (x - p-x 30.0f x - p-x -10.0f) return p; } } return nullptr; } };findFrontPlant里的30.0f和-10.0f是啃食判定范围。僵尸的 x 是身体中心植物中心在格子中心两者距离小于 30 像素时开始啃。这个值要根据你的素材尺寸调太大僵尸会“隔空啃”太小僵尸会走到植物身上才停。4.2 子弹碰撞矩形检测比圆形快但要注意顺序豌豆子弹的碰撞检测我一般用sf::FloatRect的intersects。子弹小、僵尸大矩形检测足够而且比圆形距离检测快。// Bullet.cpp void Bullet::update(float dt, std::vectorZombie* zombies) { x speed * dt; // 向右飞 sf::FloatRect bulletRect(x - 5, y - 5, 10, 10); for (auto* z : zombies) { if (z-state ZombieState::DYING) continue; sf::FloatRect zombieRect(z-x - 20, z-y - 40, 40, 80); if (bulletRect.intersects(zombieRect)) { z-hp - damage; alive false; // 子弹消失 break; // 一颗子弹只打一个僵尸 } } if (x 900) alive false; // 飞出屏幕 }break很重要。不加的话一颗子弹会穿透一排僵尸每个都扣血。alive标记子弹是否存活主循环里遍历子弹数组alive false的就从数组里移除。注意遍历zombies时如果僵尸在别处被移除迭代器可能失效。我一般用std::vectorZombie*存指针移除时只标记state DYING统一在帧末清理避免遍历中删除。4.3 C 随机数别再用 rand() 了僵尸掉落的阳光、卡片冷却、僵尸出现的间隔都需要随机数。很多老教程用rand() % n但rand()的质量很差而且取模会引入偏差。C11 之后应该用random。#include random // 全局随机数引擎只初始化一次 std::mt19937 rng(std::random_device{}()); // 生成 [min, max] 的整数 int randomInt(int min, int max) { std::uniform_int_distributionint dist(min, max); return dist(rng); } // 生成 [min, max) 的浮点数 float randomFloat(float min, float max) { std::uniform_real_distributionfloat dist(min, max); return dist(rng); } // 使用示例僵尸出现间隔 3~8 秒 float spawnInterval randomFloat(3.0f, 8.0f); // 使用示例阳光掉落位置在草坪内随机 int sunCol randomInt(0, Grid::COLS - 1); int sunRow randomInt(0, Grid::ROWS - 1);std::mt19937是梅森旋转算法周期长、分布均匀。std::random_device用来播种保证每次运行结果不同。如果你调试时想复现某个随机序列把random_device换成固定种子比如std::mt19937 rng(42)就行。uniform_int_distribution的区间是闭区间[min, max]uniform_real_distribution是左闭右开[min, max)。这个差异在写“随机掉落 0 到 4 列”时要注意用randomInt(0, 4)才能取到第 4 列。5. 避坑与排查那些让我熬夜的崩溃和玄学5.1 现象程序运行几秒后闪退无报错原因最常见的是访问了空指针。比如plants[row][col]是nullptr但代码里直接plants[row][col]-hp - damage。僵尸啃食时如果findFrontPlant返回了nullptr后面又没检查就用了。解决所有从容器里取出来的指针用之前先判空。或者用std::optional、智能指针。调试时在崩溃前加日志打印当前行、列、指针地址。5.2 现象僵尸走到植物面前不攻击直接穿过去原因findFrontPlant的判定范围写错了。僵尸的 x 是身体中心植物的 x 是格子中心如果判定条件写成x - p-x 30当僵尸在植物右边时x - p-x是正数没问题但当僵尸走到植物左边x - p-x变成负数条件依然成立僵尸会“回头”啃。更常见的是范围太小僵尸一步跨过了判定区间。解决判定范围用绝对值并且每帧都检查。std::abs(x - p-x) 30.0f。如果僵尸速度是 20 像素/秒60 帧下每帧移动 0.33 像素不会跳过 30 像素的区间。但如果速度调到 200每帧移动 3.3 像素就要把范围放大到至少 10 像素以上。5.3 现象子弹打中僵尸但僵尸不掉血原因碰撞检测的矩形坐标算错了。sf::FloatRect的构造函数是(left, top, width, height)left和top是左上角不是中心。如果按中心坐标传矩形会偏到右下角去。解决子弹矩形用(x - w/2, y - h/2, w, h)僵尸矩形同理。或者直接用sf::Sprite的getGlobalBounds()但要注意精灵的原点设置。5.4 现象VS Code 里代码全是红波浪线但能编译原因IntelliSense 的includePath没配好或者compilerPath指向的编译器版本和实际用的不一致。解决按第 2 章的c_cpp_properties.json检查路径。如果用了 SFML确保includePath里有 SFML 的 include 目录。改完文件后按CtrlShiftP运行C/C: Reset IntelliSense Database。5.5 现象程序在别人电脑上打不开提示缺 DLL原因MinGW 动态链接的程序依赖libstdc-6.dll、libgcc_s_seh-1.dll、libwinpthread-1.dll。别人电脑没装 MinGW 就缺这些。解决编译时加-static静态链接或者把需要的 DLL 和 exe 放一起。SFML 也有对应的 DLLsfml-graphics-2.dll这些要么静态链接要么一起打包。6. 进阶技巧用状态机和对象池把项目撑到 5000 行不崩写到后面你会发现最烦的不是加新植物而是加完之后老功能莫名其妙坏了。我自己的血泪经验是早点上状态机和对象池后面能省下大量后悔药。状态机管僵尸和植物的行为。僵尸的WALKING、EATING、DYING只是开始后面加铁桶僵尸、撑杆僵尸、舞王僵尸每个都有特殊状态。用switch或者多态都行关键是状态切换要集中在一个地方别散落在各个函数里。// 用函数表代替 switch加新状态不用改 update class Zombie { using StateFunc void(*)(Zombie, float); static std::unordered_mapZombieState, StateFunc stateTable; void update(float dt) { auto it stateTable.find(state); if (it ! stateTable.end()) it-second(*this, dt); } };对象池管子弹和阳光。子弹每帧可能生成几十个、销毁几十个频繁new/delete会造成内存碎片帧率会抖。对象池预先分配一大块内存用的时候取一个不用的时候还回去。templatetypename T, int N class ObjectPool { std::arrayT, N pool; std::arraybool, N used; public: T* acquire() { for (int i 0; i N; i) { if (!used[i]) { used[i] true; return pool[i]; } } return nullptr; // 池满了 } void release(T* obj) { int idx obj - pool[0]; used[idx] false; } };N取 256 或 512够用了。acquire返回nullptr时说明池满了这时候要么扩容要么丢弃最老的子弹。我一般选择丢弃因为屏幕上同时几百颗子弹本来就不正常。验证方法很简单在update里加个计数器每帧打印活跃子弹数、僵尸数、植物数。正常运行时这些数字应该在一个范围内波动如果持续增长说明有对象没被回收内存泄漏了。这个习惯帮我提前发现过好几次僵尸死亡后没从数组里移除的问题。最后说个我自己的习惯每加一个新功能先写一个最小的测试场景。比如加寒冰射手先只放一个寒冰射手和一个普通僵尸看减速效果对不对再放进正式关卡。直接在大关卡里调变量太多出了问题根本不知道是哪行代码的锅。希望帮到你。本文还有配套的精品资源点击获取