☰
纯C++植物大战僵尸塔防模型:游戏循环与数据驱动拆解
2026/10/29 5:15:14 网站建设 项目流程

简介:面向具备 C++ 语法基础、对游戏开发有兴趣的初学者与课程设计学习者,这份植物大战僵尸模型及代码以经典塔防玩法为样本,帮助读者理解面向对象抽象、游戏主循环、碰撞检测、资源加载与基础数据结构在小型项目中的落地方式。压缩包共 109 个文件,约 15.82MB,主要包括 cpp 源码、vcxproj 与 sln 工程配置、exe 可执行文件,以及 pdb、obj、ilk 等编译中间文件,另含 mp3 音效和金币、阳光等图像素材,便于还原目录结构、核对构建环境并排查常见编译问题。已有 5883 人学习下载。读者可对照源码观察植物、僵尸、场地等对象的类划分,梳理主循环如何驱动状态更新与画面渲染,结合工程配置和资源文件理解一个经典塔防小游戏的代码组织方式,适合作为自学练手、课程设计和游戏开发入门的参考案例。

1. 从“卡在第二关”说起:这套 C++ 植物大战僵尸模型能拿来做什么

有个朋友带学生做课设,选了植物大战僵尸这个题,兴冲冲装了个引擎,结果三天都耗在环境配置上,一行游戏逻辑没写。后来换成这套纯 C++ 的植物大战僵尸模型,情况立刻顺了:没有引擎、没有编辑器,就是几十个头文件和实现文件,加上一个主循环,把阳光经济、格子种植、豌豆射击、僵尸按行推进、波次刷怪这些塔防最核心的机制用 C++ 从头摆了一遍。它不适合拿去做商业项目,美术和音效基本是占位符;但特别适合想搞明白“一帧里到底发生了什么”的人——学生做完能把每行代码讲清楚,写过两年业务代码想转游戏方向的人,也能借它把游戏循环、实体管理、数据驱动这几件基本功补上。下面按能跑、能读、能改的顺序拆。

2. 工程骨架与技术选型:不用引擎,纯 C++ 怎么撑起一个塔防循环

这套代码最容易让人误会的地方,是它看着“简陋”,但分层其实做得比不少培训班项目干净。整个工程只依赖标准库,渲染层是可插拔的:想看图就链 SDL2,想快速跑逻辑就直接用控制台字符画格子。逻辑层完全不认识“像素”这两个字,只认行列号和秒。

2.1 目录结构:逻辑、渲染、数据三层切开

先看目录,理解了分层,后面读代码就不会乱:

pvz-cpp/ ├── CMakeLists.txt ├── include/ │ ├── core/ # 纯逻辑:World、Entity、Plant、Zombie、Bullet │ ├── render/ # IRenderer.h,渲染只暴露一个接口 │ └── data/ # PlantTable.h、WaveTable.h,数值全在表里 ├── src/ │ ├── main.cpp # 只做三件事:解析参数、造对象、进循环 │ ├── core/ # World.cpp、GameLoop.cpp、SunManager.cpp │ ├── render/ # SdlRenderer.cpp、ConsoleRenderer.cpp │ └── data/ ├── assets/ │ ├── sprites/ # 贴图,控制台版直接忽略 │ └── config/ # 数值表,便于策划改而不重编 └── tests/ # headless 逻辑测试,不开窗口也能跑

core目录下的任何文件都不允许#include渲染相关头文件,这条约束是整套架构的地基。好处很直接:写平衡性测试时把渲染层换成空实现,一次跑几百局,几秒钟出结果;以后想把控制台换成 SDL2 甚至换成别的框架,只改src/render一个目录,core一行不动。我一般在新项目里也会先立这条规矩,因为一旦让逻辑层偷偷拿到了窗口句柄和纹理指针,后面想拆就难了。

2.2 核心实体模型:一切场上单位都是 Entity

塔防里会出现植物、僵尸、子弹、阳光、割草机,种类不少,但共同点很多:都有唯一标识、都在某个格子上、都有血量(阳光可以理解为 1 点血,捡到就消失)、都要每帧更新。所以工程里抽了一个Entity基类:

// include/core/Entity.h #pragma once #include <cstdint> enum class Camp { Plant, Zombie, Bullet, Pickup }; struct GridPos { int row = -1; // 0..4,从上到下 int col = -1; // 0..8,从左到右 bool valid() const { return row >= 0 && col >= 0; } }; class Entity { public: Entity(uint32_t id, Camp camp, GridPos pos, float hp) : id_(id), camp_(camp), pos_(pos), hp_(hp) {} virtual ~Entity() = default; // 存指针容器,必须有虚析构 virtual void update(float dt) = 0; // 固定步长推进 virtual bool alive() const { return hp_ > 0.f; } void takeDamage(float dmg) { hp_ -= dmg; } uint32_t id() const { return id_; } Camp camp() const { return camp_; } const GridPos& pos() const { return pos_; } protected: uint32_t id_; Camp camp_; GridPos pos_; float hp_; };

几个参数值得单独说。id_由World里一个单调递增的计数器发放,作用不只是“唯一”——做回放、做日志比对、做确定性测试时,靠它排序能保证每次跑出来的顺序一致,不依赖容器地址。camp_是阵营标记,碰撞检测第一层过滤就用它,避免子弹去打阳光。GridPos用格子坐标而不是像素坐标,是因为塔防的所有判定——能不能种、僵尸走到哪、豌豆打不打得到——本质都是格子语义,像素坐标只在渲染时由col * 80 + offset换算出来。这一层如果混了像素,后面调数值会非常痛苦。

2.3 主循环与固定步长:别让帧率决定僵尸速度

新手写游戏循环最常见的写法是while (running) { update(); render(); },在自己的机器上跑得好好的,换台高刷屏的笔记本立刻出事:僵尸走得飞快,豌豆穿过僵尸不判定。原因是每帧的dt不一样,逻辑直接吃了这个浮动的dt。这套模型的循环用的是固定步长加累加器:

// src/core/GameLoop.cpp constexpr float kStep = 1.0f / 60.0f; // 逻辑固定 60Hz constexpr float kMaxFrame = 0.25f; // 单帧最大补偿,防止卡顿后狂追 void GameLoop::run() { auto last = Clock::now(); float acc = 0.f; while (running_) { float frame = std::chrono::duration<float>(Clock::now() - last).count(); last = Clock::now(); frame = std::min(frame, kMaxFrame); // 断点暂停回来不会瞬间跑几百步 acc += frame; input_.poll(); // 输入每帧采一次 while (acc >= kStep) { world_.update(kStep); // 逻辑只吃固定 dt acc -= kStep; } renderer_.draw(world_, acc / kStep); // 插值系数留给渲染层 } }

kStep定成 1/60 秒,是因为这套数值表里的射击间隔、产阳光间隔都是按 60 帧手调的,换成 1/120 就得整表重调。kMaxFrame这个 clamp 是血泪经验:调试时打个断点停十秒,回来之后acc攒了十秒的量,内层 while 会一口气跑六百步,画面直接跳到六百帧之后,看起来就像“程序抽风”。把单帧补偿封顶在 0.25 秒,最多补 15 步,卡顿感可控。最后那个acc / kStep是留给渲染的插值系数,让画面在两次逻辑更新之间做平滑,不插值的话 60Hz 逻辑在高刷屏上会有轻微顿感,属于锦上添花,不影响判定。

3. 核心模块拆解:阳光、植物、僵尸、子弹在一帧里的完整链路

骨架清楚之后,真正决定“像不像那个游戏”的是四个模块:阳光怎么产、植物怎么按表行动、僵尸怎么成波地压过来、子弹怎么判定命中。这四块串起来,就是一帧里发生的全部事情。

3.1 阳光经济:整局节奏的节拍器

塔防的难度曲线,本质是阳光收入和僵尸强度两条线的赛跑。所以阳光管理不能散落在各处,工程里单独抽了一个SunManager:

// include/core/SunManager.h class SunManager { public: void update(float dt) { timer_ += dt; if (timer_ >= spawnInterval_) { // 默认 8 秒一颗天降阳光 timer_ -= spawnInterval_; pending_.push_back(SunToken{ randomCol(), 0.f, 25 }); // 落点列、纵坐标、面值 } for (auto& s : pending_) s.y += 60.f * dt; // 下落速度 60 像素/秒 } bool spend(int cost) { if (sun_ < cost) return false; // 不够就直接拒绝,不做事后回滚 sun_ -= cost; return true; } void collect(int amount) { sun_ = std::min(sun_ + amount, kSunCap); } private: int sun_ = 50; // 开局阳光 float timer_ = 0.f; float spawnInterval_ = 8.0f; std::vector<SunToken> pending_; static constexpr int kSunCap = 9990; // 防止溢出和界面撑破 };

这里有两个设计选择值得说。spend用“先查后扣、失败不落地”的写法,而不是先扣再退,是因为种植动作要跟格子占用、冷却检查串在一起,任何一步失败都得整体回滚,写成事务式最省心。kSunCap上限看着无关紧要,但我见过有人没收住,玩家挂机十分钟阳光数字溢出成负数,种植判定直接全线崩溃,一个上限值省掉一次通宵排查。开局 50、向日葵 25、豌豆 100 这些数字不要写死在逻辑里,工程的assets/config下有对应数值表,改表不用重编。

3.2 植物行为表:用数据驱动干掉一长串 if-else

植物种类一多,最容易写崩的地方是Plant::update里堆满switch (type),加一种植物要改五个地方,改漏一个就出玄学 bug。这套代码的做法是把“植物是什么”和“植物怎么动”分开:静态属性进表,行为按枚举分发。

// include/data/PlantTable.h enum class Effect { Shooter, Producer, Wall, Instant, Slow }; struct PlantDef { PlantType type; const char* name; int cost; // 阳光消耗 float hp; float plantCd; // 种植冷却(秒) float actionCd; // 行为间隔:射击 / 产阳光 Effect effect; float param; // 按 effect 解释:伤害 / 产量 / 减速系数 }; inline constexpr PlantDef kPlants[] = { {PlantType::Peashooter, "pea", 100, 300.f, 7.5f, 1.4f, Effect::Shooter, 20.f}, {PlantType::Sunflower, "sun", 50, 300.f, 7.5f, 24.f, Effect::Producer, 25.f}, {PlantType::Wallnut, "wall", 50, 4000.f, 30.0f, 0.f, Effect::Wall, 0.f}, {PlantType::SnowPea, "ice", 175, 300.f, 7.5f, 1.4f, Effect::Slow, 20.f}, }; inline const PlantDef& defOf(PlantType t) { for (const auto& d : kPlants) if (d.type == t) return d; return kPlants[0]; // 兜底,防止配错类型直接崩 }

参数的含义要按effect分开读:Shooter的param是单发伤害,Producer的param是一次产出的阳光数,Slow的param是伤害、减速系数另放一个字段更清晰,这里为了表结构紧凑合在了一起。真正执行时只写一份分发逻辑,actionCd到了就按effect走对应分支,新增植物只要往表里加一行、再在分发处补一个case,火线射手、双发射手这类变体甚至连case都不用加。

提示:表里的PlantType枚举顺序要和资源加载的贴图索引对齐,否则会出现“种豌豆长出来一棵向日葵”的经典事故。

3.3 僵尸波次:让难度是爬坡而不是跳崖

僵尸刷新如果只写“每隔 N 秒随便来一只”,玩起来会非常平。工程里用一张波次表控制节奏,前几波给玩家攒阳光的时间,后面收紧间隔、抬高血量:

// src/core/WaveManager.cpp struct WaveDef { int count; float interval; float hpScale; }; static constexpr WaveDef kWaves[] = { { 1, 20.f, 1.00f}, // 第 1 波:1 只,20 秒后下一波 { 3, 18.f, 1.00f}, { 5, 16.f, 1.15f}, { 8, 14.f, 1.30f}, {12, 10.f, 1.50f}, // 第 5 波开始明显加压 }; void WaveManager::update(float dt, World& world) { if (waveIndex_ >= std::size(kWaves)) return; timer_ += dt; const auto& w = kWaves[waveIndex_]; if (spawned_ < w.count && timer_ >= spawnGap_) { timer_ = 0.f; ++spawned_; int row = rng_.nextInt(0, kRows - 1); // 固定种子可复现 world.spawnZombie(row, kGridRight + 1, w.hpScale); } if (spawned_ >= w.count) { // 本波刷完,进入下一波倒计时 spawned_ = 0; ++waveIndex_; spawnGap_ = (waveIndex_ < std::size(kWaves)) ? kWaves[waveIndex_].interval : 0.f; } }

hpScale是乘在僵尸基础血量上的系数,比“每波加 50 点血”更好调,因为不同僵尸基础血量差很多。rng_用自己实现的线性同余或std::mt19937都行,关键是种子可控——测试时固定种子,同样的操作每次跑出同样的局面,出问题能复现,这一点比随机性本身重要得多。行号随机取值时要注意别连续把五只都刷在同一行,工程里做了个简单的“最近两行不重复”约束,否则玩家会遇到某一行瞬间被压穿、其他行空着的极端局面。

3.4 子弹与行判定:塔防的碰撞其实只有一维

很多人一听碰撞就想到 AABB、想到四叉树,塔防用不上。豌豆只在它那一行沿 x 轴前进,僵尸也只在那一行,判定退化成一维区间比较,几十行代码搞定,还不会穿透:

// src/core/World.cpp void World::updateBullets(float dt) { for (auto& b : bullets_) { b.x += b.speed * dt; // 像素坐标,沿行向右 if (b.x > kGridRight) { b.dead = true; continue; } for (auto& z : zombies_) { if (z.pos().row != b.row) continue; // 第一层:行过滤 if (b.x >= z.x() && b.x <= z.x() + kZombieWidth) { z.takeDamage(b.damage); if (b.slow > 0.f) z.applySlow(b.slow, 2.5f); b.dead = true; // 一颗子弹只打一个目标 break; } } } // 统一清理,绝不在遍历中 erase bullets_.erase(std::remove_if(bullets_.begin(), bullets_.end(), [](const Bullet& b) { return b.dead; }), bullets_.end()); }

kZombieWidth是僵尸碰撞盒宽度(像素),比贴图宽度略窄一点,手感更好,玩家不会觉得“明明没碰到怎么就死了”。b.dead标记加末尾统一erase-remove,是这套代码里最该学的一个习惯:在for循环里直接erase会让迭代器失效,程序可能不崩,但会随机漏掉一只僵尸,表现成“僵尸突然加速穿过去”,这种 bug 找起来极其折磨。子弹和僵尸的移除都走同一套模式,逻辑层因此干净很多。

4. 编译与运行:从 clone 到看见第一只僵尸走过来的最短路径

工程能读,还得能跑。这一章是照着做就能出画面的部分,顺便说说跑起来之后怎么观察它是不是正常。

4.1 环境与编译:CMake 一把梭

需要的东西不多:C++17 编译器(GCC 9+ / Clang 10+ / MSVC 2019+ 都行),CMake 3.16 以上;想看图形界面再装 SDL2,不装也能用控制台版跑逻辑。

cmake_minimum_required(VERSION 3.16) project(pvz_model CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(pvz src/main.cpp src/core/World.cpp src/core/GameLoop.cpp src/core/SunManager.cpp src/core/WaveManager.cpp src/render/ConsoleRenderer.cpp # 默认控制台版,零依赖 ) target_include_directories(pvz PRIVATE include) find_package(SDL2 QUIET) if (SDL2_FOUND) target_sources(pvz PRIVATE src/render/SdlRenderer.cpp) target_link_libraries(pvz PRIVATE SDL2::SDL2) target_compile_definitions(pvz PRIVATE USE_SDL2) endif()

编译和运行:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j ./build/pvz # 有 SDL2 时自动走图形版 ./build/pvz --headless --seed 42 --time 180 # 无窗口跑 180 秒逻辑,打日志

-DCMAKE_BUILD_TYPE=Release别省。Debug 模式下这套代码的僵尸和子弹遍历会慢一个数量级,波次一多就掉帧,你会误以为是逻辑写崩了,其实是没开优化。--headless参数是这套工程很实用的一个开关,跑平衡测试、跑回归全靠它。

4.2 资源与配置:九成“跑不起来”栽在这里

图形版启动后黑屏或者直接退出,最常见的不是代码问题,是资源路径。程序默认从可执行文件所在目录往上找assets/,而很多人是从项目根目录直接执行./build/pvz,工作目录不对,贴图加载全失败。

// src/render/SdlRenderer.cpp bool SdlRenderer::init() { if (SDL_Init(SDL_INIT_VIDEO) != 0) { // 初始化失败必须先报错 std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); return false; } window_ = SDL_CreateWindow("pvz-model", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, kCols * kCellW, kRows * kCellH, SDL_WINDOW_SHOWN); // assets 目录用程序参数传,不要写死相对路径 if (!loadTextures(assetRoot_)) { std::fprintf(stderr, "textures not found under: %s\n", assetRoot_.c_str()); return false; } return true; }

assetRoot_由main从命令行参数或环境变量读进来,默认值是"./assets"。窗口尺寸直接用kCols * kCellW、kRows * kCellH算出来,5 行 9 列、格子 80×100,得到 720×500,跟逻辑层的格子数天然对齐,改行列数窗口自动跟着变,不用手动调魔数。这条“所有尺寸从格子数推导”的习惯,能省掉大量“贴图位置偏了半个格子”的对齐调试。

4.3 调试手段:让它在出问题时留下证据

这套代码里最值钱的调试设施是一个几行的日志宏,配合固定种子:

// include/core/Log.h #define LOGF(world, ...) \ do { \ std::printf("[t=%7.2f f=%u] ", (world).time(), (world).frame()); \ std::printf(__VA_ARGS__); \ std::printf("\n"); \ } while (0) // 用法:种植、受击、死亡、刷怪处各打一条 LOGF(world_, "plant type=%d row=%d col=%d sun=%d", (int)type, row, col, sun_.amount());

带上t(游戏内秒数)和f(帧序号)两个维度,出问题时能立刻判断是“时间到了没触发”还是“帧里漏了”。想复现某个诡异局面,把种子和操作日志记下来,--seed跑回去,同样的僵尸、同样的行号,一次就能对上。另一个实用技巧是把kStep临时改小到 1/10 秒,肉眼慢放看僵尸怎么穿过防线的,比盯着代码空想快得多,改完记得改回来。

5. 排查清单:五个让塔防跑不起来或跑歪了的典型翻车点

下面这几条基本覆盖了这个项目里 90% 的求助,按“编译期”和“运行期”分两组,每条都按现象、原因、解决写清楚。

5.1 编译期与链接期:程序还没跑就先倒下

第一条:undefined reference to 'World::update(float)',或者 SDL 相关符号全红。

现象:CMake 配置阶段正常,编译到链接阶段报一堆未定义符号,改动代码后时好时坏。原因通常是新增的.cpp没加进add_executable,CMake 根本不知道要编译它;SDL 那部分则是find_package没找到库,USE_SDL2宏没定义,但SdlRenderer.cpp里又写了 SDL 的调用。解决办法是把源文件用file(GLOB ...)或者target_sources集中管理,别手写清单——手写迟早漏;SDL 相关的代码全部用#ifdef USE_SDL2包住,或者干脆只在SDL2_FOUND的分支里添加源文件,这样没装 SDL 的机器也能编过控制台版。

第二条:程序启动即退出,控制台一行textures not found,或者干脆什么都不打印。

现象:双击可执行文件闪退,命令行跑也不报错。原因是资源路径和工作目录不匹配,加上init()失败后main里没检查返回值,直接往下跑,渲染层拿着空指针解引用。解决分两步:一是所有init()类函数都返回bool,main里逐层检查,失败就打印具体路径退出;二是资源根目录做成可配置项,默认值用std::filesystem::current_path()拼出来打印一遍,肉眼一看就知道找的是哪。

5.2 运行期:能跑起来但行为不对

第三条:僵尸速度时快时慢,高刷屏上明显更快,豌豆经常穿过僵尸不判定。

现象:同一份代码,台式机上正常,笔记本上僵尸像开了加速。原因就是逻辑层直接吃了可变帧间隔,update(world, deltaTime)每帧传的dt不一样,移动和判定都按帧走。解决办法是回到 2.3 节的固定步长加累加器,逻辑只接受kStep,物理推进和碰撞判定都在这个固定步长里做。顺手把kMaxFrame的 clamp 加上,否则打个断点回来会看到僵尸瞬移。

第四条:某一波结束后僵尸数量不对,或者有僵尸卡在格子上不动。

现象:明明打死了,屏幕上还剩一只;或者某只僵尸停在原地不掉血。最常见的原因是在遍历容器时erase,迭代器失效导致跳过一个元素,或者跳过之后僵尸的row被清成了-1。解决方法是所有删除一律走“标记dead+ 末尾erase-remove”模式,World::update的末尾统一清理三类容器(僵尸、子弹、植物)。另外在GridPos上留个valid(),任何写坐标的地方都断言一下,越界立刻暴露,别让它跑到后面才炸。

第五条:跑十几分钟内存一直涨,退出时崩在析构里。

现象:长时间运行内存曲线一路上扬;或者关窗口时程序崩溃,调试器停在某个析构函数。原因基本是两个:用裸new往std::vector<Plant*>里塞对象,忘了delete;以及Entity基类没写虚析构,通过基类指针删除派生对象时析构不完整。解决办法很直接——容器一律用std::vector<std::unique_ptr<Entity>>,所有权明确,erase自动释放;基类析构必须virtual,这条是 C++ 多态的铁律,没有例外。改完再跑一次 headless 长测,用工具看一眼内存曲线是平的,才算真的修好。

注意:这五条里,第三条和第四条最容易伪装成“玄学”,因为它们不报错、只是概率性表现异常。固定种子加日志是唯一靠谱的排查手段,凭感觉改代码只会越改越乱。

6. 二次开发:加一株新植物、一只能破防的僵尸,并验证它没把平衡搞崩

真正想拿这套代码练手的人,第一步基本都是“加个新植物试试”。流程比想象中短,因为数值和行为已经分开,加一株“三线射手”只需要动三处:枚举加一项、表里加一行、分发处补一个分支。

// 1) include/core/PlantType.h 里加枚举 enum class PlantType { Peashooter, Sunflower, Wallnut, SnowPea, Threepeater /* 新增 */ }; // 2) include/data/PlantTable.h 的表里加一行 {PlantType::Threepeater, "three", 325, 300.f, 7.5f, 1.4f, Effect::Shooter, 20.f}, // 3) 射击分支里按 type 决定一次打几行 case Effect::Shooter: { int rows[3] = {0, 0, 0}; int n = 1; if (def.type == PlantType::Threepeater) { // 本行 + 上下各一行 rows[0] = plant.pos().row - 1; rows[1] = plant.pos().row; rows[2] = plant.pos().row + 1; n = 3; } else { rows[0] = plant.pos().row; } for (int i = 0; i < n; ++i) { if (rows[i] < 0 || rows[i] >= kRows) continue; // 边界行只打两路 world.spawnBullet(rows[i], plant.x() + 40.f, def.param, 0.f); } break; }

这里的关键不是代码量,而是边界处理:三线射手种在最上面或最下面一行时,只有两路能打,直接continue跳过越界行,绝不能拿rows[i]去做下标访问容器的at(),否则又是一个越界崩溃。加僵尸则是反过来的思路——僵尸的行为集中在Zombie::update的位移和啃食逻辑里,加一只“跳过高坚果”的僵尸,需要给它加一个canBypass标记,并在啃食判定处提前返回,血量、速度、伤害都进僵尸表,跟植物表保持同样的结构。

改完必须验证,不然很容易出现“新植物太强,第三波就无压力”的情况。我的做法是固定种子跑批量对战:

for s in $(seq 1 50); do ./build/pvz --headless --seed $s --time 300 --auto-plant sunflower > "log_$s.txt" done grep -h "wave_end" log_*.txt | awk '{sum+=$4; n++} END {print "平均剩余阳光:", sum/n}'

用 50 个不同种子各跑 300 秒,脚本按固定策略种植,统计每波结束时的阳光存量和剩余防线行数。加新植物前后各跑一遍,如果平均阳光存量暴涨 50% 以上,说明这株太便宜或者太强,回去调cost和actionCd;如果某一波的防线剩余行数从平均 5 掉到 2,说明加压过头。这套流程我一般写成脚本挂在每次改动后面,几秒钟出结果,比手动玩十局靠谱得多,也比凭直觉调数值靠谱得多。

从那以后我每次往表里加一行新植物,都会先跑一遍这批种子,再打开图形版亲手玩一局确认手感——数据和手感必须两条腿走路,缺一个都会翻车。文章里贴的是这套工程最关键的几段骨架,完整的头文件、波次表、渲染层和那个 headless 测试脚本都在包里,拿去改个数值、加两株植物,一晚上就能玩出自己的版本。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询