简介:这份资源是面向C++初学者与游戏开发爱好者的植物大战僵尸模拟项目源码包,以面向对象编程为主线,帮助读者在实战中理解类与对象、继承多态、数据结构与算法、事件处理、状态机、碰撞检测及随机数生成等核心知识点。项目围绕Plant与Zombie等类的设计展开,涵盖豌豆射手、寒冰射手、普通僵尸、铁桶僵尸等角色行为,并涉及游戏地图表示、僵尸行动顺序管理、图形界面与错误调试等内容,适合作为课程设计或自学练手素材。压缩包为rar格式,共109个文件,约27.74MB,包含cpp源码、vcxproj与sln工程文件、exe可执行程序、pdb与idb调试符号、obj中间文件、mp3音效及json配置等,工程结构完整,可直接用Visual Studio打开编译运行。目前已有1709人学习下载,便于读者对照源码梳理游戏循环与模块划分,快速上手C++游戏逻辑开发。
1. 从零搭一个 C++ 植物大战僵尸:为什么我劝你先别急着写渲染
很多人第一次搜「c++的植物大战僵尸模型以及代码c++编写」,脑子里想的是直接开干:窗口一开,贴图一铺,僵尸一走,豌豆一射,收工。真动手才发现,卡住你的从来不是画面,而是模型——阳光怎么结算、格子怎么占位、僵尸和植物怎么判定碰撞、波次怎么推进。这些逻辑一旦没设计好,后面加一个「寒冰射手减速」都能把代码改到崩。我见过太多人用 C++ 写小游戏,最后死在全局变量互相打架上。
这篇笔记讲的就是这套模型怎么立起来,以及配套的 C++ 代码怎么落地。适合两类人:刚学完 C++ 基础、想拿一个完整项目练手的;以及写过一点小游戏、但逻辑一复杂就失控的。全程不依赖任何游戏引擎,纯 C++ 标准库加一个轻量图形库就能跑,重点放在「模型设计」和「代码组织」上,画面只是验证手段。读完你能拿到一套可扩展的骨架,而不是一堆只能跑一次的散代码。
2. 先把游戏模型拆成四层:实体、网格、时间、规则
2.1 为什么用「实体 + 组件」而不是继承树
新手最容易踩的坑,是给每个东西建一个类:PeaShooter、Sunflower、WallNut、NormalZombie、ConeZombie……然后发现「寒冰射手」既要像豌豆射手又要带减速,继承关系立刻变成一团乱麻。C++ 的多继承能救一时,但虚函数表一多,调试时你连对象到底是谁都看不清。
我一般用「实体 + 组件」的简化版:实体只是一个 ID,组件是纯数据,系统负责处理逻辑。植物大战僵尸里真正需要区分的维度其实很少——位置、血量、攻击、生产、移动、状态。把这些做成组件,组合出不同单位,比继承树好维护得多。
// entity.h #pragma once #include <cstdint> using Entity = std::uint32_t; constexpr Entity kInvalidEntity = 0; // 组件:只放数据,不放逻辑 struct Position { int row; int col; float x; float y; }; struct Health { int hp; int maxHp; }; struct Attack { int damage; float cooldown; float timer; }; struct Producer { int sunPerCycle; float interval; float timer; }; struct Movement { float speed; bool slowed; float slowTimer; }; struct Render { int spriteId; };这段代码的关键在于:Entity只是个整数,组件是 POD 风格的结构体,系统遍历时按需组合。kInvalidEntity用 0 表示空,避免到处判空指针。参数上,cooldown和timer分开存,是为了让「攻击间隔」和「当前冷却」解耦,改数值时不会互相污染。slowed和slowTimer放在 Movement 里,是因为减速只影响移动,不该污染 Attack。
2.2 网格模型:5 行 9 列背后的坐标换算
植物大战僵尸的草坪是 5 行 9 列,这个网格是整个游戏的地基。所有放置、碰撞、索敌都基于格子,而不是像素。很多人一上来就用像素坐标做判定,结果僵尸走到两格之间时,豌豆打不中,或者植物被种到缝里。
正确做法是:逻辑层永远用(row, col),渲染层才换算成像素。换算公式取决于你的格子尺寸,常见的是 80x100 像素一格。
// grid.h #pragma once #include "entity.h" constexpr int kRows = 5; constexpr int kCols = 9; constexpr int kCellW = 80; constexpr int kCellH = 100; constexpr int kGridOriginX = 40; // 草坪左上角在窗口中的偏移 constexpr int kGridOriginY = 80; inline float CellToPixelX(int col) { return kGridOriginX + col * kCellW + kCellW * 0.5f; } inline float CellToPixelY(int row) { return kGridOriginY + row * kCellH + kCellH * 0.5f; } inline bool InBounds(int row, int col) { return row >= 0 && row < kRows && col >= 0 && col < kCols; }kGridOriginX/Y是草坪在窗口里的起点,调这个值就能整体挪动草坪。CellToPixelX返回格子中心,方便贴图居中。InBounds是后面所有放置和索敌的第一道防线,任何越界访问都会在这里被拦下。注意这里用inline而不是宏,类型安全,调试时也能断点。
2.3 时间模型:固定步长还是可变 delta
游戏循环里最容易出玄学的地方就是时间。用可变 delta 看似平滑,但僵尸速度一快,可能一帧跨过一整格,碰撞直接漏掉。植物大战僵尸这种格子游戏,我强烈建议固定步长:逻辑每秒跑 60 次,每次 delta 固定 1/60 秒,渲染可以插值。
// game_loop.cpp #include <chrono> #include <thread> constexpr double kFixedDt = 1.0 / 60.0; void RunLoop(GameState& state) { using clock = std::chrono::steady_clock; auto last = clock::now(); double accumulator = 0.0; while (state.running) { auto now = clock::now(); double frame = std::chrono::duration<double>(now - last).count(); last = now; if (frame > 0.25) frame = 0.25; // 防止卡顿后追帧爆炸 accumulator += frame; while (accumulator >= kFixedDt) { UpdateLogic(state, kFixedDt); // 所有逻辑用固定 dt accumulator -= kFixedDt; } Render(state, accumulator / kFixedDt); // 插值系数 std::this_thread::sleep_for(std::chrono::milliseconds(1)); } }kFixedDt是逻辑步长,改它等于改游戏速度,但一般不动。frame > 0.25那行是后悔药:窗口被拖动或系统卡顿时,delta 会突然很大,不截断的话 accumulator 会疯狂追帧,游戏直接瞬移。Render拿到的是 0 到 1 的插值系数,让画面在两次逻辑之间平滑过渡。这套结构跑起来,僵尸速度再快也不会穿模。
2.4 规则模型:阳光、冷却、波次怎么串起来
阳光是资源,冷却限制节奏,波次控制压力。这三者如果各写各的,后期调平衡会痛不欲生。我的做法是集中到一个RuleConfig里,所有数值可配。
// rules.h #pragma once struct RuleConfig { int startSun = 50; int sunValue = 25; float sunFallInterval = 10.0f; float plantCooldown = 7.5f; float firstWaveDelay = 20.0f; float waveInterval = 25.0f; int zombiesPerWaveBase = 2; int zombiesPerWaveGrowth = 1; }; struct GameState { int sun = 0; float waveTimer = 0.0f; int waveIndex = 0; bool running = true; RuleConfig rules; };startSun和sunValue决定开局节奏,plantCooldown是种植后的全局冷却,waveInterval控制波次间隔。zombiesPerWaveBase加waveIndex * growth就是每波僵尸数,改这两个值就能调难度曲线。把这些从逻辑里抽出来,你调平衡时只改一个文件,不用满项目搜魔法数字。
3. 用 C++ 把模型跑起来:实体管理、系统调度与碰撞判定
3.1 实体管理器:一个数组加空闲列表就够了
实体管理不需要什么高大上的架构。一个std::vector存组件,一个空闲列表回收 ID,性能足够跑几千个实体。
// entity_manager.h #pragma once #include <vector> #include <unordered_map> #include "entity.h" class EntityManager { public: Entity Create() { Entity e; if (!freeList_.empty()) { e = freeList_.back(); freeList_.pop_back(); } else { e = nextId_++; positions_.emplace_back(); healths_.emplace_back(); attacks_.emplace_back(); producers_.emplace_back(); movements_.emplace_back(); renders_.emplace_back(); alive_.push_back(false); } alive_[e] = true; return e; } void Destroy(Entity e) { if (e >= alive_.size() || !alive_[e]) return; alive_[e] = false; freeList_.push_back(e); } bool IsAlive(Entity e) const { return e < alive_.size() && alive_[e]; } Position& Pos(Entity e) { return positions_[e]; } Health& Hp(Entity e) { return healths_[e]; } Attack& Atk(Entity e) { return attacks_[e]; } Producer& Prod(Entity e) { return producers_[e]; } Movement& Mov(Entity e) { return movements_[e]; } Render& Ren(Entity e) { return renders_[e]; } private: std::vector<Position> positions_; std::vector<Health> healths_; std::vector<Attack> attacks_; std::vector<Producer> producers_; std::vector<Movement> movements_; std::vector<Render> renders_; std::vector<bool> alive_; std::vector<Entity> freeList_; Entity nextId_ = 1; };这里用「平行数组」而不是「实体到组件的 map」,是因为遍历时缓存友好。freeList_回收被销毁的 ID,避免 ID 无限增长。alive_用vector<bool>虽然有点争议,但这里只是标记位,够用。注意Create里新实体是emplace_back到每个数组,保证索引对齐。Destroy只标记不删除,真正清理可以放到帧末统一做。
3.2 系统调度:每帧按顺序跑哪些逻辑
系统就是函数,按固定顺序处理组件。顺序错了会出现「僵尸先移动再被豌豆打」这种时序 bug。
// systems.cpp #include "systems.h" #include <cmath> void UpdateLogic(GameState& state, double dt) { UpdateSunSpawn(state, dt); UpdateProducers(state, dt); UpdateAttacks(state, dt); UpdateMovements(state, dt); UpdateCollisions(state, dt); UpdateWave(state, dt); CleanupDead(state); } void UpdateProducers(GameState& state, double dt) { for (Entity e = 1; e < state.em.AliveCount(); ++e) { if (!state.em.IsAlive(e)) continue; auto& p = state.em.Prod(e); if (p.interval <= 0.0f) continue; p.timer += static_cast<float>(dt); if (p.timer >= p.interval) { p.timer -= p.interval; state.sun += p.sunPerCycle; } } }顺序上,先产阳光再结算攻击,然后移动,最后碰撞和波次。UpdateProducers里p.timer -= p.interval而不是清零,是为了保留溢出时间,避免长期运行后生产变慢。AliveCount是实体管理器暴露的上界,遍历时跳过死亡实体。所有系统都接收dt,保证和固定步长一致。
3.3 碰撞判定:格子索敌比像素距离更稳
植物打僵尸,本质是「同一行、僵尸在植物右侧、且在攻击范围内」。用格子判定,比算像素距离简单且不会漏。
// combat.cpp #include "combat.h" Entity FindTargetInRow(GameState& state, int row, int col, int range) { Entity best = kInvalidEntity; int bestCol = kCols + 1; for (Entity e = 1; e < state.em.AliveCount(); ++e) { if (!state.em.IsAlive(e)) continue; auto& pos = state.em.Pos(e); auto& mov = state.em.Mov(e); if (pos.row != row) continue; if (mov.speed <= 0.0f) continue; // 不是僵尸 if (pos.col < col) continue; // 在植物左侧 if (pos.col > col + range) continue; // 超出射程 if (pos.col < bestCol) { bestCol = pos.col; best = e; } } return best; }range是射程,按格子数算,普通豌豆给 9 就能打全屏。mov.speed <= 0用来区分僵尸和植物,因为只有僵尸有移动组件。选bestCol最小的,保证优先打最近的。这个函数每帧对每个攻击型植物调一次,5x9 的规模下性能完全不是问题。
3.4 波次推进:用状态机而不是一堆 if
波次逻辑写成if (timer > x) spawn...很快就会失控。用一个简单的状态机:准备、出怪、等待、下一波。
// wave.cpp enum class WavePhase { Idle, Spawning, Waiting }; void UpdateWave(GameState& state, double dt) { static WavePhase phase = WavePhase::Idle; static int remaining = 0; static float spawnTimer = 0.0f; state.waveTimer += static_cast<float>(dt); switch (phase) { case WavePhase::Idle: if (state.waveTimer >= state.rules.firstWaveDelay) { state.waveTimer = 0.0f; remaining = state.rules.zombiesPerWaveBase + state.waveIndex * state.rules.zombiesPerWaveGrowth; phase = WavePhase::Spawning; } break; case WavePhase::Spawning: spawnTimer += static_cast<float>(dt); if (spawnTimer >= 1.5f && remaining > 0) { spawnTimer = 0.0f; SpawnZombie(state, RandomRow()); --remaining; } if (remaining <= 0) { phase = WavePhase::Waiting; state.waveTimer = 0.0f; } break; case WavePhase::Waiting: if (state.waveTimer >= state.rules.waveInterval) { ++state.waveIndex; state.waveTimer = 0.0f; phase = WavePhase::Idle; } break; } }WavePhase把「什么时候出怪、出多少、什么时候下一波」拆清楚。spawnTimer控制出怪间隔,1.5 秒一只,避免一波全挤出来。RandomRow用std::mt19937加std::uniform_int_distribution,别用rand(),分布不均且线程不安全。状态机的好处是加「大波僵尸」时只需在 Spawning 里改 remaining 的计算。
4. 避坑与排查:那些让我重写三遍的细节
4.1 僵尸走到最左边没判负,游戏卡死
现象:僵尸走到第 0 列还在往左,画面不动,也不结束。 原因:只写了移动逻辑,没写「到达终点」的判定。 解决:在UpdateMovements里加if (pos.col < 0) { state.running = false; },或者触发割草机。割草机本质是一个一次性实体,放在每行最左,僵尸碰到就销毁该行所有僵尸。
4.2 豌豆穿过僵尸不打,或者打到了后面的
现象:豌豆和僵尸擦肩而过,或者越过最近的打远处的。 原因:碰撞用像素距离且没排序,或者豌豆移动速度太快,一帧跨过僵尸。 解决:豌豆也用格子判定,记录targetCol,每帧检查是否到达。或者用连续碰撞检测:记录上一帧位置,判断线段是否跨过僵尸所在格。
4.3 阳光点不到,或者点了没反应
现象:鼠标点阳光,阳光不消失,阳光数不涨。 原因:阳光的点击区域用的是贴图左上角,而渲染是居中;或者点击检测在逻辑更新之后,阳光已经飘走。 解决:点击检测统一用格子中心加半径,和渲染对齐。输入处理放在逻辑更新之前,保证同一帧内状态一致。
4.4 植物种下去不攻击,冷却却转了
现象:豌豆射手种下后一直不发射,但种植冷却正常走。 原因:Attack组件的cooldown初始化为 0,timer也是 0,判定条件写成了timer >= cooldown直接成立但没重置,或者FindTargetInRow的range传了 0。 解决:初始化时给cooldown一个正值,timer设为cooldown让它立即能打第一发。检查range参数,普通植物至少给 9。
4.5 窗口关闭后进程还在,任务管理器里一堆
现象:点关闭按钮,窗口没了,但进程残留。 原因:图形库的事件循环没处理Quit事件,或者running标志没同步。 解决:在事件处理里显式设置state.running = false,并确保主循环每帧检查。如果用了多线程,记得 join 或 detach,别让线程悬空。
5. 进阶技巧:用数据驱动让加新植物不用改代码
写到后面你会发现,每加一个植物就要改一堆if,这不对劲。我的习惯是把植物和僵尸的数值抽成表,用 ID 索引,逻辑只认组件不认具体类型。
// plant_db.h #pragma once #include <array> #include <string> struct PlantDef { std::string name; int cost; int hp; int damage; float attackCooldown; int sunPerCycle; float produceInterval; int spriteId; }; inline const std::array<PlantDef, 4> kPlantDB = {{ {"Peashooter", 100, 300, 20, 1.4f, 0, 0.0f, 1}, {"Sunflower", 50, 300, 0, 0.0f, 25, 24.0f, 2}, {"WallNut", 50, 4000, 0, 0.0f, 0, 0.0f, 3}, {"SnowPea", 175, 300, 20, 1.4f, 0, 0.0f, 4}, }}; Entity SpawnPlant(GameState& state, int plantId, int row, int col) { const auto& def = kPlantDB[plantId]; Entity e = state.em.Create(); state.em.Pos(e) = {row, col, CellToPixelX(col), CellToPixelY(row)}; state.em.Hp(e) = {def.hp, def.hp}; state.em.Atk(e) = {def.damage, def.attackCooldown, def.attackCooldown}; state.em.Prod(e) = {def.sunPerCycle, def.produceInterval, 0.0f}; state.em.Mov(e) = {0.0f, false, 0.0f}; state.em.Ren(e) = {def.spriteId}; return e; }kPlantDB是编译期常量表,加植物只需加一行。SpawnPlant根据 ID 填组件,逻辑系统完全不关心这是豌豆还是寒冰。寒冰的减速效果可以在UpdateAttacks里根据spriteId或额外加一个SlowEffect组件实现。这样改数值不用重编译逻辑,调平衡效率翻倍。
验证方法很简单:加一个「双发射手」,只需在表里加一行,attackCooldown减半,其他不动,跑起来看是否生效。如果生效,说明数据驱动到位了。我自己的习惯是每加一个新单位,先只改表,改完跑一遍,确认逻辑层没被污染。这套骨架我前后重写过三次,最后一次才想明白:模型稳了,代码只是填空。希望帮到你。
本文还有配套的精品资源,点击获取