C++植物大战僵尸练手:固定步长主循环与塔防架构拆解
简介:面向有一定C++基础、希望理解塔防游戏实现过程的学习者,这份资源包围绕植物大战僵尸的C++复刻展开,提供可编译运行的工程与核心源码,用于降低游戏循环、对象建模和资源加载等入门门槛。包内共109个文件,以cpp源码、sln与vcxproj工程配置、exe可执行程序为主,另有mp3音效、金币与阳光等图片素材,以及编译生成的pdb、obj、tlog等中间文件,压缩包约15.82MB,工程结构相对完整,便于直接打开调试。已有5883人学习下载,具备一定参考热度。读者可对照实体类派生植物与僵尸的面向对象设计,梳理主循环、碰撞检测、数据结构与资源管理的落地方式,并借助现成工程复现运行效果、排查编译链接问题,适合作为课程设计或游戏开发入门的实践样本。
1. 从一份 C++ 植物大战僵尸代码说起:它到底能练到什么
不少人第一次看到这个资源标题,会以为“模型”指的是美术资源,下载下来才发现真正值钱的是那套用 C++ 写出来的对象模型——植物、僵尸、子弹、阳光各自是一个类,靠一个固定步长的主循环推动,草坪是 5×9 的网格,波次是一张数据表。这份 C++ 游戏开发的练手工程,把植物大战僵尸经典游戏里最核心的塔防循环完整实现了:种植物、收阳光、卡牌冷却、僵尸分拨推进、子弹命中扣血、坚果被啃、樱桃炸弹清一片、最后判胜负。它适合已经学完 C++ 语法、能写类和容器,但从没独立写过一个完整游戏循环的人;也适合想拿一个“小而全”的项目练架构拆分的人。图形层通常是 EasyX 或 SDL2 这类轻量库,不依赖商业引擎,整个工程能在一台普通开发机上编译通过。
2. 工程骨架与主循环:一帧是怎么转起来的
拿到源码先别急着翻植物逻辑,先看它怎么组织文件、怎么跑循环。这两件事决定后面所有代码往哪儿塞。
2.1 目录结构与模块边界
一份写得清楚的工程,目录本身就在告诉你作者的思路。常见的组织方式是这样:
| 目录 / 文件 | 职责 |
|---|---|
src/main.cpp | 入口,只负责初始化窗口和跑主循环 |
src/core/Game.cpp | 全局状态机,管开始、暂停、胜负 |
src/core/Board.cpp | 草坪网格,负责格子占用与查询 |
include/Entity.h | 实体基类,植物、僵尸、子弹的公共父类 |
src/entity/plant/ | 豌豆、向日葵、坚果、樱桃各一个 cpp |
src/entity/zombie/ | 僵尸状态机与不同耐久类型 |
src/entity/bullet/ | 子弹飞行与命中判定 |
src/ui/ | 卡牌栏、阳光计数、关卡进度条 |
src/data/WaveTable.cpp | 波次数据表,纯配置 |
assets/ | 贴图、音效、字体 |
这里最值得学的是三条边界:数据(波次表、植物属性)和逻辑(实体行为)分开;逻辑(update)和表现(render)分开;场景管理(Game)和场地规则(Board)分开。我见过太多课程作业把所有东西堆进一个main.cpp,两千行以后想加个植物都下不去手。分好这三层,后面加内容就只是往对应目录里添文件。
2.2 实体基类:把植物、僵尸、子弹塞进同一个容器
只要你的游戏里存在“一堆需要每帧更新、会死、会互相碰撞的东西”,就该有一个统一基类。这个项目的做法很朴素:
// include/Entity.h #pragma once #include <memory> struct Vec2 { float x = 0.f, y = 0.f; }; // 所有可更新对象的基类:植物、僵尸、子弹、飘字都挂在同一条继承链上 class Entity { public: virtual ~Entity() = default; virtual void update(float dt) = 0; // 只改状态,绝不碰绘图 virtual void render() const = 0; // 只读状态,绝不改逻辑 bool alive() const { return hp_ > 0 && !removed_; } void markRemoved() { removed_ = true; } Vec2 pos; // 左上角坐标(像素),不是中心点 Vec2 size; // 碰撞盒尺寸 int hp = 1; int faction = 0; // 0=植物 1=僵尸 2=子弹,用来过滤敌对关系 protected: bool removed_ = false; // 标记删除,真正 erase 交给管理器 };update和render严格分离,是这套代码里最不能破的规矩。植物开火、僵尸掉血、阳光下落都只发生在update里;render拿到什么状态就画什么,不做任何判断以外的副作用。pos存左上角而不是中心点,是因为贴图绘制接口通常以左上角为锚点,省掉一次换算;代价是所有距离判定都要记得用pos + size/2算中心,这一点在后面讲碰撞时会再提一次。faction字段很便宜,但它替你省掉了一堆dynamic_cast——子弹只查faction == 1的目标就够了。
配套的实体管理器负责生命周期:
// include/EntityManager.h class EntityManager { public: template <class T, class... Args> T* spawn(Args&&... args) { auto e = std::make_unique<T>(std::forward<Args>(args)...); T* raw = e.get(); // 返回裸指针,方便当场设置属性 entities_.push_back(std::move(e)); return raw; // 只借用,不转移所有权 } void updateAll(float dt) { for (auto& e : entities_) if (e->alive()) e->update(dt); // 延迟删除:先标记,循环结束后统一清理 entities_.erase( std::remove_if(entities_.begin(), entities_.end(), [](const std::unique_ptr<Entity>& e) { return !e->alive(); }), entities_.end()); } private: std::vector<std::unique_ptr<Entity>> entities_; };这段的关键在“延迟删除”四个字。子弹命中后如果当场erase,正在跑的for循环里迭代器立刻失效,表现就是偶尔崩、偶尔穿透,属于典型的运行期玄学问题。改成“先markRemoved,循环外统一remove_if”,问题一次消失。spawn返回的裸指针只允许在当帧内使用,用来设置刚生成实体的行号、速度这类参数,不要把它存进别的对象里长期持有。
2.3 主循环:固定步长比什么都重要
塔防游戏的判定非常依赖时间。用可变dt直接乘速度,帧率一抖,僵尸就会一步跨过一整格,或者豌豆从僵尸身上穿过去。
// src/main.cpp int main() { Game game; game.init(); const float FIXED_DT = 1.0f / 60.0f; // 逻辑固定 60Hz float accumulator = 0.f; ULONGLONG last = GetTickCount64(); while (!game.shouldQuit()) { ULONGLONG now = GetTickCount64(); float frame = (now - last) / 1000.0f; last = now; if (frame > 0.25f) frame = 0.25f; // 拖窗口/断点回来时别让游戏瞬移 accumulator += frame; while (accumulator >= FIXED_DT) { // 追帧:一帧内可能跑多次逻辑 game.update(FIXED_DT); accumulator -= FIXED_DT; } game.render(); // 渲染只做一次 Sleep(1); // 让出 CPU,不然风扇会叫 } return 0; }FIXED_DT = 1/60意味着不管你的机器是 60 帧还是 144 帧,逻辑一秒永远推进 60 次,豌豆每秒飞行距离、僵尸每秒走多少像素、坚果能扛多少秒,全都可算。frame > 0.25f那行是个小保险:调试断点停下来两分钟再继续,不夹住的话accumulator会攒出上百次update,游戏直接瞬移。accumulator这个写法叫固定步长加追帧,代码不到十行,但它决定了后面所有数值调参有没有意义。
2.4 编译与运行:三种打开方式
工程一般提供 CMake 配置,也有现成的 VS 工程文件,习惯哪种用哪种。
# 方式一:CMake(Windows + MSVC,EasyX 版本) cmake -S . -B build -G "Visual Studio 17 2022" -A x64 cmake --build build --config Release ./build/bin/pvz.exe# CMakeLists.txt 里跟图形库相关的部分 cmake_minimum_required(VERSION 3.16) project(pvz_cpp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) file(GLOB_RECURSE SRC ${CMAKE_SOURCE_DIR}/src/*.cpp) add_executable(pvz ${SRC}) target_include_directories(pvz PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/third_party/graphics/include) # EasyX 没有官方 CMake 包,直接把 lib 链进来; # EasyXw.lib 对应 Unicode 字符集,EasyXa.lib 对应多字节,二者必须和项目设置一致 if (MSVC) target_link_libraries(pvz PRIVATE ${CMAKE_SOURCE_DIR}/third_party/graphics/lib/EasyXw.lib) endif() # 把资源目录拷到输出目录,避免 exe 找不到贴图 add_custom_command(TARGET pvz POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets $<TARGET_FILE_DIR:pvz>/assets)如果是 SDL2 版本,把find_package(SDL2 REQUIRED)和SDL2::SDL2 SDL2_image::SDL2_image换上去就行,业务代码不用动——这也是把渲染封装成render()虚函数的好处。POST_BUILD那段拷贝别省,很多人第一次运行黑屏就是因为资源路径问题,下一章排查部分会细说。
3. 战场规则:网格坐标、阳光经济与波次刷怪
骨架搭好之后,塔防的“规则”才是真正决定手感的部分。这一层基本都是纯数据加纯计算,跟图形库无关。
3.1 草坪网格:像素和格子来回换
植物大战僵尸的核心抽象就一句话:这是一块 5 行 9 列的棋盘,所有实体都活在格子上,只是画出来是像素。
// src/core/Board.h constexpr int ROWS = 5; constexpr int COLS = 9; constexpr float CELL_W = 80.f; constexpr float CELL_H = 100.f; constexpr float LAWN_X = 260.f; // 草坪左上角在窗口里的像素位置 constexpr float LAWN_Y = 80.f; // 格子 -> 像素(左上角) inline Vec2 cellToPixel(int row, int col) { return { LAWN_X + col * CELL_W, LAWN_Y + row * CELL_H }; } // 像素 -> 格子,越界返回 false inline bool pixelToCell(Vec2 p, int& row, int& col) { col = int((p.x - LAWN_X) / CELL_W); row = int((p.y - LAWN_Y) / CELL_H); return row >= 0 && row < ROWS && col >= 0 && col < COLS; }int()强转在这里正好等价于向下取整,因为分子是正数时它就是floor。但鼠标点在草坪左边(p.x < LAWN_X)时分子为负,强转结果仍是负数或零,所以后面那个范围判断不能省——省了就会出现在草坪外“隔空种植物”的怪事。CELL_W/CELL_H不是正方形,因为原版美术素材本身就是 80×100 左右的比例,用正方形格子会让僵尸脚底和格子对不齐,看着很别扭。
场地还需要一层占用表:
// 每个格子存一个植物指针,nullptr 表示空 Plant* grid_[ROWS][COLS] = {}; bool Board::place(int row, int col, Plant* p) { if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return false; if (grid_[row][col]) return false; // 格子已占用 grid_[row][col] = p; return true; } // 僵尸在一行里找"当前格及左侧一格"有没有植物 Plant* Board::plantAt(int row, float zombieLeftX) { for (int c = 0; c < COLS; ++c) { Plant* p = grid_[row][c]; if (!p || !p->alive()) continue; float right = p->pos.x + p->size.x; if (zombieLeftX < right && zombieLeftX > p->pos.x - CELL_W) return p; } return nullptr; }plantAt里的容差(p->pos.x - CELL_W)是必要的。僵尸的碰撞盒比格子窄,走到格子边缘时视觉上已经贴上植物了,如果要求严格重叠才触发啃食,就会出现“僵尸站在坚果身上继续往前走”的诡异画面。
3.2 阳光经济与卡牌冷却
阳光是整个游戏的节奏阀门,写得糙一点整局体验就崩了。
// src/core/SunManager.h class SunManager { public: void update(float dt) { spawnTimer_ -= dt; if (spawnTimer_ <= 0.f) { spawnTimer_ = 8.0f; // 每 8 秒天上掉一个 spawnNaturalSun(); } for (auto& s : suns_) { s.y += 40.f * dt; // 下落速度 s.life -= dt; // 没点会消失 if (!s.collected && s.life <= 0.f) s.dead = true; } suns_.erase(std::remove_if(suns_.begin(), suns_.end(), [](const Sun& s) { return s.dead; }), suns_.end()); } bool spend(int cost) { if (sun_ < cost) return false; // 先判再扣,不能反过来 sun_ -= cost; return true; } int sun() const { return sun_; } private: int sun_ = 50; // 开局阳光,和原版一致 float spawnTimer_ = 5.f; // 第一个阳光稍微早一点 std::vector<Sun> suns_; };spend里“先判再扣”这个顺序看着废话,但它和放置逻辑的顺序才是真正的坑:正确写法是先spend(cost)成功、再board.place()成功,两个都成功才落子;如果反过来先放植物再扣阳光,一旦阳光不够就会出现“植物种下去了但阳光没扣”的白嫖漏洞。卡牌冷却同理,冷却计时放在update里走,点击时只查cdLeft <= 0:
struct PlantCard { PlantType type; int cost; // 阳光价格 float cooldown; // 冷却秒数 float cdLeft = 0.f; bool ready() const { return cdLeft <= 0.f; } void tick(float dt) { if (cdLeft > 0.f) cdLeft -= dt; } };冷却值本身是数值设计:豌豆 7.5 秒、向日葵 7.5 秒、坚果 20 秒、樱桃炸弹 50 秒,这几个数直接决定了开局前 30 秒你能不能守住。想改难度,优先动这里,而不是动僵尸血量。
3.3 波次表:把节奏写成数据,别写成一堆 if
关卡推进最忌讳写成“时间到了就刷一波”的硬编码。用数据表,调关卡不用重编译逻辑:
// src/data/WaveTable.cpp struct WaveEntry { float at; // 本关开始后第几秒 ZombieType type; int row; // 0~4,-1 表示随机行 }; static const std::vector<WaveEntry> kLevel1 = { { 6.f, ZombieType::Normal, 2 }, { 14.f, ZombieType::Normal, 0 }, { 14.f, ZombieType::Normal, 4 }, { 24.f, ZombieType::Normal, 1 }, { 30.f, ZombieType::Cone, 3 }, { 38.f, ZombieType::Normal, 0 }, { 38.f, ZombieType::Normal, 2 }, { 46.f, ZombieType::Cone, 3 }, { 55.f, ZombieType::Bucket, 2 }, // 精英波 };推进逻辑只需要一个游标:
void WaveController::update(float dt) { elapsed_ += dt; while (cursor_ < table_.size() && table_[cursor_].at <= elapsed_) { const auto& e = table_[cursor_]; int row = (e.row < 0) ? randInt(0, ROWS - 1) : e.row; spawner_.spawn(e.type, row); ++cursor_; } // 全部刷完且场上没僵尸 -> 通关 if (cursor_ >= table_.size() && spawner_.aliveCount() == 0) game_.setState(GameState::Win); }while而不是if,是为了防止卡顿后一帧内该刷三个僵尸只刷出一个。行号硬编码成具体数字,好处是作者能精确控制“哪一行压力大”,玩家必须在那行补防御;全部改成随机行看着更公平,实际体验会变成纯运气。真要随机,也建议限制在“相邻两行”里随机,节奏感会好很多。
3.4 状态机:胜负判定只在一个地方发生
enum class GameState { Ready, Playing, Paused, Win, Lose };Game::update按状态分发:Playing才推进Board、WaveController、SunManager;胜负检查放在一次update的最后统一做,绝不在僵尸死亡回调或者植物被啃光的瞬间直接切状态。理由很简单——你没法保证那一刻没有别的逻辑还在跑。我见过在Zombie::die()里直接game.setState(Lose)的写法,结果一帧内死了三只僵尸,状态被切了三次,结算界面闪了一下就没了。统一出口,只有一处能改状态,这类问题就不会出现。
4. 战斗判定:子弹飞行、碰撞盒与僵尸状态机
规则层只管“能不能种、该不该刷”,真正的手感来自这一层。
4.1 子弹与碰撞:AABB 就够了
塔防不需要物理引擎,轴对齐包围盒(AABB)完全够用。
// include/Math.h struct AABB { float x, y, w, h; }; inline AABB boundsOf(const Entity& e) { return { e.pos.x, e.pos.y, e.size.x, e.size.y }; } inline bool overlap(const AABB& a, const AABB& b) { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; }豌豆的update只做三件事:往前飞、查同行、命中就死。
// src/entity/bullet/Pea.cpp void Pea::update(float dt) { pos.x += speed * dt; // 只往右飞,速度约 320 px/s if (pos.x > SCREEN_W) { markRemoved(); return; } // 同一行才判定,但 y 方向给 20px 容差,照顾矮体型僵尸 AABB me = boundsOf(*this); for (Zombie* z : board_->zombiesInRow(row_)) { if (!z->alive()) continue; AABB zb = boundsOf(*z); zb.y += 20.f; zb.h -= 20.f; // 收缩判定盒,让命中点落在身体上 if (overlap(me, zb)) { z->damage(damage_); markRemoved(); spawnHitEffect(pos); // 命中特效,纯表现,不影响逻辑 return; } } }zombiesInRow返回的是同行的僵尸列表,行内数量最多也就十来个,线性遍历完全没问题,不用上空间划分。zb.y += 20.f; zb.h -= 20.f;这两行是在收缩僵尸的判定盒——原始贴图里僵尸头部有空白,不收缩的话豌豆会“打空气”却算命中,视觉上很怪。子弹速度 320 px/s 配合 60Hz 固定步长,每帧移动约 5.3 像素,远小于僵尸 40 像素左右的宽度,所以不会出现穿透。如果哪天你把速度调到 2000,就必须改成射线检测,这是固定步长的物理上限。
4.2 僵尸状态机:走路、啃食、死亡
僵尸看着复杂,其实只有三个状态,用enum加switch写最清楚。
// src/entity/zombie/Zombie.cpp void Zombie::update(float dt) { switch (state_) { case ZombieState::Walk: { Plant* p = board_->plantAt(row_, pos.x); if (p) { state_ = ZombieState::Eat; target_ = p; } else { pos.x -= speed_ * dt; // 普通僵尸约 20 px/s animTimer_ += dt; // 走路动画计时 if (pos.x + size.x < HOUSE_LINE_X) game_->onZombieBreach(this); // 触发失败,只上报不自处理 } break; } case ZombieState::Eat: if (!target_ || !target_->alive()) { state_ = ZombieState::Walk; target_ = nullptr; break; } eatTimer_ -= dt; if (eatTimer_ <= 0.f) { eatTimer_ = 1.0f; // 每 1 秒咬一口 target_->hp -= 100; // 每口 100 点 } break; case ZombieState::Die: fadeTimer_ -= dt; if (fadeTimer_ <= 0.f) markRemoved(); break; } }啃食按“每秒一口 100 点”而不是“每帧扣一点”,这个设计很关键。坚果墙 4000 血,算下来正好扛 40 秒;如果改成按帧扣,帧率一变耐久就跟着变,调数值完全失去意义。HOUSE_LINE_X用的是pos.x + size.x(右边界)而不是pos.x,因为僵尸是朝左走的,视觉上“头”在右边,用左边界判定会出现僵尸半个身子进屋了还没输的情况。onZombieBreach只上报给Game,自己不改全局状态——回到上一节说的单向出口。
不同耐久类型只是数值不同,共用同一套逻辑:
| 类型 | 血量 | 速度 | 说明 |
|---|---|---|---|
| 普通僵尸 | 200 | 20 px/s | 一颗豌豆 20 伤害,打 10 下 |
| 路障僵尸 | 560 | 20 px/s | 路障掉落后退化成普通 |
| 铁桶僵尸 | 1300 | 18 px/s | 明显更慢,给玩家反应时间 |
路障和铁桶的“退化”实现方式通常是给僵尸加一个armorHp_,伤害先扣护甲再扣本体,护甲归零时换一套贴图。这样比写三个类干净得多。
4.3 植物行为:四种典型实现
豌豆射手是最简单的周期性开火:
void Peashooter::update(float dt) { fireTimer_ -= dt; if (fireTimer_ <= 0.f && board_->hasZombieInRow(row_, pos.x)) { fireTimer_ = 1.4f; // 攻击间隔 board_->spawnPea(row_, pos.x + size.x, damage_); // 从嘴的位置出弹 } }hasZombieInRow里带了pos.x参数,意思是只打自己右边(前方)的僵尸——少了这个判断,豌豆会往左打已经走过去的僵尸,属于一眼就能看出来的低级错误。
向日葵是产阳光,逻辑类似但产物不同:
void Sunflower::update(float dt) { produceTimer_ -= dt; if (produceTimer_ <= 0.f) { produceTimer_ = 12.f; // 每 12 秒产 25 阳光 sunMgr_->spawnAt(pos.x + size.x * 0.5f, pos.y); } }樱桃炸弹是一次性范围伤害,用一次就标记删除:
void CherryBomb::explode() { // 以自身为中心 3x3 格范围 AABB blast{ pos.x - CELL_W, pos.y - CELL_H, CELL_W * 3, CELL_H * 3 }; for (Zombie* z : board_->zombiesIn(blast)) z->damage(1800); // 秒杀普通和路障,铁桶也扛不住 markRemoved(); }zombiesIn(AABB)就是把同行查询换成矩形范围查询,实现还是线性遍历全部僵尸,几十个对象的量级完全不用优化。伤害值 1800 是照着“能秒掉铁桶(1300)”定的,这个数字不是随便写的,是反推出来的。
5. 避坑与常见问题排查:一跑就崩的五个地方
这套代码逻辑本身不复杂,真正消耗时间的是环境和时序问题。下面五条基本覆盖了新手会撞到的绝大多数情况。
5.1 编译与资源加载
现象一:双击 exe 直接闪退,日志里写图片加载失败。原因是资源路径用了"..\\assets\\pea.png"这种相对路径。VS 里按 F5 调试时工作目录是项目目录,双击 exe 时工作目录却是 exe 所在目录,两处不一致,路径自然对不上。解决办法是把资源路径统一锚定到 exe 所在目录:启动时先取GetModuleFileName拿到 exe 路径,再拼assets/;同时用 CMake 的POST_BUILD把assets整个拷到输出目录,让两种启动方式看到的世界一样。
现象二:链接报一堆LNK2019 无法解析的外部符号。大概率是图形库版本和项目字符集没对上。EasyX 分EasyXw.lib(Unicode)和EasyXa.lib(多字节)两个版本,项目属性里字符集设成“使用 Unicode 字符集”就必须链 w 版,设成“多字节”就链 a 版。还有一种情况是 Debug 配置链了 Release 的库,或者附加库目录没配、只写了#pragma comment(lib, ...)却找不到文件。统一字符集设置、确认库目录、Debug/Release 分开配置,三件事做完基本就好。
5.2 运行时逻辑
现象三:子弹明明打中了却穿过去,或者偶尔崩在遍历里。两个原因叠在一起:一是在遍历实体的for循环里直接erase命中的子弹,迭代器失效;二是主循环用了可变dt,卡一帧子弹就跳过了僵尸的碰撞盒。前者改成markRemoved加循环外统一remove_if,后者改固定步长1/60,两个都改完就稳定了。
现象四:僵尸走到最左边,有时候判负有时候不判。问题出在判定线上。pos.x是左上角,僵尸朝左走,“进屋”的视觉临界点其实在左边界。如果代码里有的地方用pos.x、有的地方用pos.x + size.x,就会出现判定不一致。统一成“碰撞盒左边界小于HOUSE_LINE_X时判负”,并且只触发一次(加个bool breached_标志),别每帧都往Game上报。
现象五:玩两三分钟后越来越卡,帧数一路掉。最常见的是实体泄漏。检查三处:命中特效、飘字、尸体动画有没有markRemoved;波次刷出来的僵尸死亡后有没有真正从容器移除;std::vector是不是只push_back从来不清。排查手段很简单,在渲染角上打个entities_.size()的实时数字,正常应该稳定在几十,如果只涨不跌,泄漏源立刻定位。
提示:这五个问题里有四个都能靠“固定步长 + 延迟删除 + 单一状态出口”这三条规矩提前规避,剩下的那个是路径问题,加个资源加载失败就弹窗提示的检查即可。
6. 二次开发:从能跑到能改
这套代码真正的价值不在“复刻得像不像”,而在它给你留了哪些入口。我的建议是先做一件事:把植物属性从代码里挪出去。现在豌豆的伤害、攻速、价格散落在Peashooter.cpp和卡牌表两处,加一个新植物要改三个文件,很容易漏。抽一个PlantConfig结构体加一张静态表,所有数值集中在一处,改平衡就是改一行数字。
加新植物的完整路径通常是:继承Plant实现update,在PlantType枚举里加一项,往属性表和卡牌表各加一条,assets/里放贴图,Board::place那边不用动。整个过程半小时以内,如果超过这个时间,说明前面分层没做好,先回头拆。
关卡数据值得单独说。波次表现在是 C++ 静态数组,编译期就固定了。改成运行时加载 JSON 之后,调难度可以不开 IDE:
{ "level": 2, "waves": [ { "at": 5.0, "type": "normal", "row": 1 }, { "at": 5.0, "type": "normal", "row": 3 }, { "at": 18.0, "type": "cone", "row": 2 }, { "at": 30.0, "type": "bucket", "row": -1 } ] }加载器用任意一个轻量 JSON 库都行,几十行代码。改完之后你可以一边跑一边改数值,热重载都不用做,重启一次两秒钟,比在代码里翻来翻去快得多。
验证改动有没有翻车,我有两个固定习惯。一是加一个--speed 4启动参数,把FIXED_DT的累积速度乘 4,一局三分钟压到 45 秒跑完,快速确认后期波次不会因为数值问题卡死;二是在窗口标题栏实时打印state / sun / wave / entity count四个数字,任何异常第一眼就能看见,比打断点快。
| 改造点 | 涉及文件 | 大致工作量 |
|---|---|---|
| 加一种植物 | PlantType枚举、属性表、卡牌表、entity/plant/ | 半小时 |
| 加一种僵尸 | ZombieType、血量表、贴图 | 二十分钟 |
| 关卡数据外置 | WaveTable.cpp换成加载器 | 半天 |
| 加入音效 | 封装一个AudioMgr,在关键事件处调用 | 两小时 |
从那以后我每次接这种“看着简单”的小游戏工程,都会先把固定步长主循环和实体删除策略这两块单独检查一遍,再动任何玩法逻辑。这两个地方不稳,后面加的每一个功能都是在流沙上盖楼。希望这份拆解能帮你少走几步弯路。
本文还有配套的精品资源,点击获取