简介一套基于C实现的植物大战僵尸游戏模型与完整工程代码面向想学游戏开发、理解面向对象编程的C初学者。压缩包约15.82MB共109个文件包含可直接运行的exe、源码cpp、Visual Studio工程文件sln/vcxproj以及obj、pdb、ilk等编译中间产物、tlog构建日志和mp3音频文件既能直接运行体验也可借助调试与日志信息深入读码和排查构建过程。目前CSDN已有5882人学习下载是塔防游戏入门中较热门的参考项目。项目以植物、僵尸等实体类为基础覆盖游戏循环、碰撞检测、数据结构存储与资源管理等多个关键环节并通过工程文件清晰呈现C项目的组织方式。通过分析这些代码读者能直观理解一个简单游戏从设计到构建的全过程为后续独立编写小游戏打下扎实基础。1. 用 C 写植物大战僵尸先别碰画面把“模型”定义清楚很多新手拿到“植物大战僵尸”这种题材第一反应是去找贴图、搭窗口结果卡在 SDL 初始化上。真正能把这个项目写完的人往往是先把对象模型做对草坪是一套坐标系植物是一个有血量、冷却和攻击间隔的类僵尸是一个不断向左移动的状态机。标题里的“模型和代码”说的就是用 C 把这些对象、状态和规则落成一个最小可玩的塔防原型。这篇适合那些找 c小游戏源码 来练手的人也适合想系统走一遍 C 面向对象、STL 容器和状态机的从业者。读懂这一篇你能拿到一套可以平移成 SDL/SFML 渲染的骨架而不是只能跑一次的玩具。2. 实体抽象与类设计用 C 面向对象还原植物、僵尸和子弹把《植物大战僵尸》还原成逻辑模型第一步不是写类而是先划坐标系。常见做法是把草坪看作 5 行 9 列每个格子大约 1.0 个单位长植物的攻击范围、僵尸的移动距离、子弹的命中判定都在这套坐标系里算。为什么不用像素因为像素与贴图、窗口大小耦合换一次画面方案就要改到处。用“格”做逻辑单位渲染时再乘一个tile_size模型基本不用动。提示下面展示的是“逻辑模型”代码不含渲染和输入你可以直接放到控制台程序里编译运行。2.1 基类 Entity 与虚函数为什么多态是这套代码的地基我一般会把植物、僵尸、子弹全部继承自一个Entity基类。基类里保存类型、阵营、位置和生命值并声明一个纯虚函数update(double dt)。这样游戏主循环只需要统一调用update不用关心当前对象具体是什么。// entity.h #pragma once #include memory enum class Team { Plant, Zombie }; enum class EntityType { Plant, Zombie, Bullet, Sun }; struct Position { int row; double col; // 水平坐标单位是“格”允许小数表示移动过程 }; class Entity { public: Entity(EntityType type, Team team, int hp, int row, double col) : type_(type), team_(team), hp_(hp), pos_{row, col} {} virtual ~Entity() default; // 每一帧由 Game 统一调用子类实现自己的行为 virtual void update(double dt) 0; bool alive() const { return hp_ 0; } void take_damage(int damage) { hp_ - damage; } EntityType type() const { return type_; } Team team() const { return team_; } const Position pos() const { return pos_; } void set_col(double col) { pos_.col col; } protected: EntityType type_; Team team_; int hp_; Position pos_; };这段代码里的关键参数是double col。它允许僵尸在6.0、5.7、5.3这种小数位置移动子弹也能平滑地向右推进。很多初学者把坐标写成整数int col结果子弹只能一格格跳碰撞永远对不上。接下来给出三个派生类。这里刻意把攻击逻辑不写在Plant内部是因为植物需要外部告知“当前行有没有僵尸”否则它每帧都要遍历全局实体耦合度太高。// entities.h #include entity.h class Plant : public Entity { public: Plant(int row, double col, int hp, int cost, double attack_interval) : Entity(EntityType::Plant, Team::Plant, hp, row, col), cost_(cost), attack_interval_(attack_interval), attack_timer_(0.0) {} void update(double dt) override { attack_timer_ dt; } bool can_attack() const { return attack_timer_ attack_interval_; } void reset_attack_timer() { attack_timer_ 0.0; } int cost() const { return cost_; } private: int cost_; double attack_interval_; double attack_timer_; }; class Zombie : public Entity { public: Zombie(int row, double col, int hp, double speed) : Entity(EntityType::Zombie, Team::Zombie, hp, row, col), speed_(speed) {} void update(double dt) override { // 不处理状态机真正的状态机放到第六章做 set_col(pos().col - speed_ * dt); } private: double speed_; }; class Bullet : public Entity { public: Bullet(int row, double col, int damage, double speed) : Entity(EntityType::Bullet, Team::Plant, 1, row, col), damage_(damage), speed_(speed) {} void update(double dt) override { set_col(pos().col speed_ * dt); } int damage() const { return damage_; } private: int damage_; double speed_; };解释一下为什么用继承而不是一个大类后续新增植物比如寒冰射手、双发射手只需要继承Plant再重写攻击效果新增僵尸类型也同理。游戏主循环不用因为“植物种类多了一个”而改动这是面向对象建模最直接的收益。反过来如果一上来就用switch(type)写在一个类里每加一种植物就要回去改好几处case这就是后期踩坑的源头。2.2 用 STL 容器管理战场实体vector、unique_ptr 与 remove-erase 惯用法实体定义好了容器选型也很关键。我会用std::vectorstd::unique_ptrEntity管理全部实体而不是用裸指针数组。裸指针数组的典型问题是手动delete容易漏程序闪退时定位不到是哪个对象释放了两次。unique_ptr在实体死亡后由容器统一释放生命周期清晰。// game.h #include vector #include memory #include algorithm class Game { public: void add_entity(std::unique_ptrEntity entity) { // 新实体不能直接 push 进被遍历的容器否则迭代器会乱 pending_.push_back(std::move(entity)); } void update(double dt) { // 1. 先把上一帧暂停的新实体合并进来 flush_pending(); // 2. 更新所有实体 for (auto e : entities_) { if (e-alive()) e-update(dt); } // 3. 处理攻击和碰撞后续章节实现 // 4. 清理死亡实体使用 remove-erase 惯用法 remove_dead(); } private: void flush_pending() { for (auto e : pending_) { entities_.push_back(std::move(e)); } pending_.clear(); } void remove_dead() { entities_.erase( std::remove_if(entities_.begin(), entities_.end(), [](const std::unique_ptrEntity e) { return !e-alive(); }), entities_.end()); } std::vectorstd::unique_ptrEntity entities_; std::vectorstd::unique_ptrEntity pending_; };这段代码里值得背下来的点是remove_if erase。remove_if会把死亡的实体移到容器末尾返回新的逻辑尾部erase真正删除这一段。它避免了你一边遍历一边erase导致的迭代器失效问题。这也是 C STL 里非常经典的一个惯用法。为什么不选std::list因为游戏实体每帧都要遍历list的节点在内存里分散缓存命中率低。vector连续存储几十上百个实体时性能完全够。为什么不直接存vectorEntity因为Entity是抽象基类直接存储会发生对象切片派生类信息全部丢失。所以指针不可回避配合unique_ptr是最稳妥的写法。注意pending_这个临时队列很关键。如果在遍历entities_的过程中直接push_back可能导致内存重新分配正在使用的迭代器全部失效这是新手最容易翻车的地方。3. 游戏循环与发射逻辑从 update 到碰撞检测的最小实现模型定义好只是骨架要让植物和僵尸动起来必须有一个稳定频率的游戏循环。这一章先把主循环写对再解决“植物什么时候开火、子弹什么时候命中”的问题。3.1 固定步长主循环让逻辑在 60 FPS 下稳定推进常见做法是用固定时间步长驱动逻辑而不是每帧直接传一个变化的dt。原因是 Windows 上Sleep精度有限帧间隔忽长忽短如果逻辑直接依赖dt同一棵植物在高刷屏和普通屏上的攻击频率会不一样。#include chrono #include thread class GameLoop { public: static constexpr double kFixedDt 1.0 / 60.0; void run(Game* game) { using clock std::chrono::steady_clock; auto last clock::now(); double accumulator 0.0; while (running_) { auto now clock::now(); double frame_dt std::chrono::durationdouble(now - last).count(); last now; // 防止窗口拖动、调试断点后出现超大帧间隔 if (frame_dt 0.25) frame_dt 0.25; accumulator frame_dt; // 按固定步长推进逻辑 while (accumulator kFixedDt) { game-update(kFixedDt); accumulator - kFixedDt; } game-render(); // 稍微让出CPU否则空转功耗很高 std::this_thread::sleep_for(std::chrono::milliseconds(1)); } } private: bool running_ true; };这里有几个参数你要自己调kFixedDt固定为 1/60意味着逻辑每秒更新 60 次frame_dt 0.25是保护阈值避免一次卡顿导致循环补偿性连跳几十帧。如果只想做策略游戏20 帧逻辑也够用但碰撞检测会变得粗糙所以 60 帧是比较稳的默认值。sleep 1 毫秒是防空转不必精确到帧。3.2 植物开火与子弹命中先索敌再发射最后判碰撞植物开火不能无脑每帧都发射否则子弹满天飞。我把规则拆成三步先找到同一行里“最靠近房子”的僵尸如果存在且攻击冷却结束就生成一颗子弹子弹每帧向右移动并与僵尸做距离判定。// game.cpp 片段 #include entities.h #include cmath void Game::handle_attacks() { // 遍历所有植物 for (auto entity : entities_) { if (entity-type() ! EntityType::Plant) continue; // unique_ptr 转裸指针只读不接管所有权 Plant* plant static_castPlant*(entity.get()); if (!plant-can_attack()) continue; Zombie* target find_first_zombie_in_row(plant-pos().row, plant-pos().col); if (target) { plant-reset_attack_timer(); spawn_bullet(plant-pos().row, plant-pos().col); } } } Zombie* Game::find_first_zombie_in_row(int row, double plant_col) { Zombie* best nullptr; for (auto entity : entities_) { if (entity-type() ! EntityType::Zombie) continue; Zombie* zombie static_castZombie*(entity.get()); if (zombie-pos().row ! row) continue; // 僵尸必须在植物右侧且是所有僵尸中 col 最小的 // 因为 col 越小越接近房子威胁最大 if (zombie-pos().col plant_col) continue; if (!best || zombie-pos().col best-pos().col) { best zombie; } } return best; } void Game::spawn_bullet(int row, double col) { auto bullet std::make_uniqueBullet( row, col 0.5, /*伤害*/ 20, /*速度*/ 8.0); add_entity(std::move(bullet)); } void Game::handle_bullet_collisions() { for (auto entity : entities_) { if (entity-type() ! EntityType::Bullet) continue; Bullet* bullet static_castBullet*(entity.get()); for (auto other : entities_) { if (other-type() ! EntityType::Zombie) continue; Zombie* zombie static_castZombie*(other.get()); if (zombie-pos().row ! bullet-pos().row) continue; // 子弹与僵尸中心的水平距离小于 0.5 格则判定命中 double dist std::fabs(zombie-pos().col - bullet-pos().col); if (dist 0.5) { zombie-take_damage(bullet-damage()); bullet-take_damage(1); break; } } } }这套逻辑的判定参数有几个可以调子弹速度 8.0格/秒意味着飞行 6 格需要 0.75 秒碰撞半径 0.5 格等于一个格子直径的一半。如果你的子弹飞行很快比如 12.0那么碰撞判定的距离阈值要适当放宽否则子弹可能一帧穿模跨过僵尸导致明明打中了却没扣血。我一般会把阈值设为max(0.5, bullet_speed * 1.0 / 60.0)以上。另外注意handle_bullet_collisions是双重循环实体数量超过几百时性能会下降。就这个游戏模型而言场上同时存在几十个实体完全不用优化。如果非要优化可以按行拆桶但那是后话。4. 把玩法补全阳光资源、冷却与波次生成光有对象和子弹还不够原版游戏的核心玩法是“资源管理 塔防节奏”。这一章补上阳光系统和僵尸波次让模型具备可玩性。4.1 阳光系统与冷却计时器先有资源才能布防阳光是玩家唯一可花费的资源。我会单独写一个SunSystem负责定时在世界中生成阳光点玩家点击后获得阳光值。这里最核心的是“下一次生成时间”的随机区间控制。// sun_system.h #include vector #include random struct Sun { double col; int value; bool collected; }; class SunSystem { public: explicit SunSystem(double total_cols) : total_cols_(total_cols), next_sun_time_(3.0) {} void update(double dt) { timer_ dt; if (timer_ next_sun_time_) { spawn_sun(); timer_ 0.0; next_sun_time_ random_interval(8.0, 12.0); } } void collect_suns() { for (auto sun : suns_) { sun.collected true; // 简化处理实际要检测点击坐标 } } private: void spawn_sun() { double col random_interval(0.5, total_cols_ - 0.5); suns_.push_back(Sun{col, 25, false}); } double random_interval(double min, double max) { static std::mt19937 rng(std::random_device{}()); std::uniform_real_distributiondouble dist(min, max); return dist(rng); } double total_cols_; double timer_ 0.0; double next_sun_time_ 0.0; std::vectorSun suns_; };这里有几个参数影响游戏节奏第一朵阳光 3 秒落下之后每隔 8 到 12 秒随机落一朵每朵价值 25 阳光。如果你把间隔调成 4 到 6 秒游戏会过于富裕调到 15 秒以上玩家前期只能干等。用随机区间而不是固定值是为了让每一局的节奏不完全相同这也是塔防游戏“可复玩性”的来源。再看植物的冷却参数。原版里植物放置后有一个短暂冷却我通常用一个PlantCooldown表统一管理避免每种植物的冷却逻辑散落在不同地方。植物阳光成本生命值攻击/生产间隔备注向日葵5030012 秒每 12 秒产 25 阳光豌豆射手1003001.4 秒每发子弹 20 伤害坚果墙504000无无攻击能力这组参数不是官方数值而是我在复刻时按手感调出来的你完全可以把豌豆射手的攻击间隔改成 1.0 秒来降低难度。重点是参数要集中管理我用std::unordered_mapstd::string, PlantConfig保存而不是写一堆if (name sunflower)散弹式判断。4.2 僵尸波次调度用时间轴控制难度曲线很多新手做塔防会在游戏开始时一次性生成所有僵尸结果玩家没时间种植物就被推掉。正确做法是用一个时间轴事件队列游戏开始后按时间点依次生成僵尸越往后节奏越快。// wave_system.h #include vector #include random struct WaveEvent { double time; int row; int hp; double speed; }; std::vectorWaveEvent build_level_waves(int level) { std::vectorWaveEvent events; double t 5.0; // 第 5 秒出第一只 int hp 50 level * 20; double speed 0.2 level * 0.01; int zombie_count 10 level * 2; for (int i 0; i zombie_count; i) { // 行随机但尽量避开第 2 行给玩家留反应空间 int row std::rand() % 5; if (row 2) row (row 1) % 5; events.push_back(WaveEvent{t, row, hp, speed}); // 间隔越来越短形成压迫感 double interval std::max(1.0, 3.0 - i * 0.08); t interval; } return events; }build_level_waves里的参数是难度曲线的核心zombie_count随关卡数线性增长interval从 3 秒逐渐缩到 1 秒。如果你觉得关卡太难可以把interval的起始值改成 4.0想让后期更刺激就改成0.6 - i * 0.02让后期尸潮叠起来。在主循环中Game持有一个wave_index_每次update(dt)检查当前累计时间是否到达下一个WaveEvent.time到了就生成对应僵尸。事件驱动的好处是调整关卡难度不需要改动上层逻辑改数据表即可。5. 避坑与排查VSCode 配置、运行库缺少和数组越界这一章完全是基于我这些年帮人看代码的经验总结。标题里这个“模型和代码”项目看起来简单但真正卡住新人的往往是环境与内存问题。下面四条是我在读者群里看到最多的坑。5.1 现象编译通过运行却提示缺少 MSVCP140.dll有读者用 VSCode 配置 c/c 环境时选了 MinGW 编译器写完代码编译能过但一运行就弹窗“找不到 msvcp140.dll 或 vcruntime140.dll”。原因很简单不是你代码问题而是你用了 MSVC 工具链编译出的程序动态依赖了微软的运行时库而那台机器没装Microsoft Visual C 2015-2022 Redistributable (x64)。解决办法去微软官网下载对应版本的vc_redist.x64.exe并安装。注意32 位程序要装 x86 版本64 位程序装 x64 版本装错照样报错。如果你不希望用户安装运行库可以在项目属性里把运行库改为“静态链接”/MT这样 exe 体积会大很多但不会再依赖 msvcp140.dll。这个坑在“双击运行秒退”的问题里占比极高不要上来就怀疑代码。5.2 现象控制台输出中文变成乱码界面全是“鏍煎瓙”式符号原版植物名字全是中文很多人把源文件保存为 UTF-8又在 Windows 控制台里用printf(豌豆射手)结果输出变成一串乱码。原因是 Windows 控制台默认代码页是 GBKUTF-8 字节流被按 GBK 解码自然显示成“鏍煎瓙”这种形近字。解决方式有两种。第一种是代码里主动设置控制台为 UTF-8#ifdef _WIN32 #include windows.h #endif int main() { #ifdef _WIN32 SetConsoleOutputCP(CP_UTF8); #endif // 你的游戏入口 return 0; }第二种是在源码文件保存时选“UTF-8 with BOM”这样 MSVC 能正确识别文件编码。如果你用 GCC/MinGWBOM 可能反而引发警告所以我在工程里统一走SetConsoleOutputCP。这个坑排查起来很费时间因为编译器不报错。5.3 现象游戏跑着跑着闪退断点定位到网格访问闪退最常见的原因是数组越界。比如你写了一个int grid[5][9]表示 5 行 9 列但访问grid[row][col]时没有判断row和col的范围一旦某个实体坐标变成row5就直接踩到未初始化内存。这跟“c字符串数组初始化”的经典错误一脉相承用char* names[5]时下标从 1 开始数结果访问names[5]越界。解决方式调试期把二维数组换成std::arraystd::arrayint, 9, 5并且用.at(row).at(col)代替[row][col]。at()越界时会抛出异常能立刻定位到是哪一帧、哪一行越界了。我一般在核心逻辑里保留at()只在性能敏感的循环里换成operator[]并且前置断言#include cassert assert(row 0 row kRows); assert(col 0 col kCols);5.4 现象实体数量一多帧率突然断崖下跌这个坑通常不是因为时间复杂度而是因为频繁字符串拼接或拷贝。有人把植物名称用std::string存每帧渲染时再去查表还大量传递临时字符串导致堆分配爆炸。解决办法用enum class PlantType代替字符串作为对象标识只有输出到 UI 时才转成字符串。另一个常见原因是每帧都new新实体且不清理死亡实体vector反复扩容。我一般在一局游戏开头就reserve(256)给容器预留空间。提示如果帧率先稳定 60 帧玩到 3 分钟后骤降到 20 帧多半是死亡实体堆积导致遍历数量暴涨。优先检查remove_dead是否真正执行了。6. 进阶给僵尸加状态机而不是继续堆 if-else模型跑到这一步植物的攻击循环已经能玩但僵尸只会闷头向左走。要让模型更像原版我会给僵尸加一个简单状态机正常行走、啃食植物、死亡。状态机听起来高大上落地方式其实很简单一个枚举加一个switch把行为拆到不同状态分支里。enum class ZombieState { Walking, Eating, Dead }; class Zombie : public Entity { public: // ...构造器省略 void update(double dt) override { switch (state_) { case ZombieState::Dead: return; case ZombieState::Walking: set_col(pos().col - speed_ * dt); break; case ZombieState::Eating: // 啃食动画和伤害由 Game 协作完成 break; } } void set_state(ZombieState state) { state_ state; } ZombieState state() const { return state_; } private: ZombieState state_ ZombieState::Walking; double speed_; };真正的状态切换发生在Game::handle_collisions里如果僵尸当前格子的偏左位置有植物就把状态切到Eating并移除植物血量如果植物死亡或不存在则切回Walking。吃状态的“啃食间隔”可以用一个eat_timer_累计每 0.5 秒啃一次。加状态机之后新增“冰车僵尸”“撑杆跳僵尸”就只需要在枚举里加两个值再补充对应的更新行为不用把整个游戏逻辑翻一遍。我自己的血泪经验是一开始为了省事把所有行为堆在if (子弹碰到坚果 僵尸在啃食) ...这种组合判断里结果每次加新植物都要改三个地方改到后面自己也分不清哪个分支在前。后来哪怕只做三类僵尸也先把状态表画出来再写代码半天就能做完。验证方法很简单让一个僵尸分别遇到高血量植物和低血量植物打印状态切换日志确认没有卡在“死后继续咬人”这类反直觉行为。希望这篇文章能让你把“植物大战僵尸模型和代码”从概念拆成可落地的类、循环和参数。如果你照着写遇到最多的问题一定不是语法而是对象什么时候该创建、什么时候该删除。把这些理清楚换引擎换渲染都只是时间问题。希望帮到你。本文还有配套的精品资源点击获取