尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++植物大战僵尸:从零搭建游戏模型与代码实战

C++植物大战僵尸:从零搭建游戏模型与代码实战 简介这份资源是面向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::durationdouble(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::vectorPosition positions_; std::vectorHealth healths_; std::vectorAttack attacks_; std::vectorProducer producers_; std::vectorMovement movements_; std::vectorRender renders_; std::vectorbool alive_; std::vectorEntity freeList_; Entity nextId_ 1; };这里用「平行数组」而不是「实体到组件的 map」是因为遍历时缓存友好。freeList_回收被销毁的 ID避免 ID 无限增长。alive_用vectorbool虽然有点争议但这里只是标记位够用。注意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_castfloat(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_castfloat(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_castfloat(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初始化为 0timer也是 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::arrayPlantDef, 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减半其他不动跑起来看是否生效。如果生效说明数据驱动到位了。我自己的习惯是每加一个新单位先只改表改完跑一遍确认逻辑层没被污染。这套骨架我前后重写过三次最后一次才想明白模型稳了代码只是填空。希望帮到你。本文还有配套的精品资源点击获取
返回列表