
简介一款基于QtC框架的简易植物大战僵尸游戏源码包专为Qt初学者、C编程爱好者以及想要入门游戏逻辑开发的学习者准备。项目中通过模块化设计把植物、僵尸、子弹、阳光、铲子等角色封装成独立类借助Qt信号与槽机制处理点击、按键和定时事件直观展示了植物种植、僵尸移动与攻击、射击判定、阳光积累、生命值管理等核心玩法。同时代码中使用QGraphics场景构建游戏画面配合定时器触发帧刷新形成动画并引入多线程辅助运算和资源文件管理方便读者理解游戏主循环与界面渲染的协作方式。压缩包共36个文件包含17个cpp源码、16个头文件、工程配置文件pro以及资源文件qrc等整体仅33KB代码量精炼且结构清晰便于逐模块阅读和二次修改。目前已有199人学习下载很适合边读代码边上手调试借助这套源码掌握Qt项目组织、类继承、事件处理和数据持久化等实用技能为进一步开发复杂游戏打下基础。1. 基于Qt C框架编写的简易植物大战僵尸游戏.zip解压后怎么让它真正跑起来“基于QtC框架编写的简易植物大战僵尸游戏.zip”这类压缩包在课程作业和资源站里出现频率非常高。它的真实形态通常是一个教学骨架QMainWindow 加 QGraphicsScene 搭出场景几株植物、几个僵尸、一套简陋的阳光生成逻辑。能编译但离“能玩”往往差着一整套主循环和数值调优。这篇文章从一线实战角度把这种 zip 的常见结构拆开Qt Widgets 方案为什么适合小体量塔防、主循环怎么驱动、碰撞参数怎么设、资源加载为什么总是翻车、最后怎么外置关卡配置让它变成一局有头有尾的游戏。适合已经写过简单 Qt 界面、想往游戏方向迈一步的 C开发者。2. 用Qt C搭塔防骨架窗口、场景与主循环先立住2.1 选型为什么是Qt Widgets而不是QML这类 zip 项目基本都基于 Qt Widgets 加 QGraphicsScene。理由其实很务实塔防玩法的核心不是精美动画而是大量可交互图元、碰撞判定和状态切换。QGraphicsScene/QGraphicsView 把“图元-场景-视图”三层关系封装好了开发者只需要继承 QGraphicsItem或者直接使用 QGraphicsPixmapItem就能让植物、僵尸、子弹作为独立对象被场景统一管理。信号槽机制也让“点击卡槽→选择植物→点击地块→扣除阳光”这种交互链写起来很直白。相比之下 QML 适合动画密度高、UI 皮肤频繁换的项目但调试链更长而且很多初学者会把业务逻辑直接塞进 JavaScript 函数里后期根本没法维护。所以这类 zip 用 Widgets 不是落后而是给入门者一条最短路径。选型时还要想清楚你的目标是把“能玩”做出来而不是把花架子搭起来。2.2 主循环不要用 Thread 死循环用 QTimer 驱动游戏需要每帧更新位置、检测碰撞。新手最容易踩的坑是开一个 std::thread 或 QThread在里面 while(true) 跑游戏逻辑然后疯狂抛信号去刷新 UI。结果就是 UI 卡顿、信号堆积、线程退出时崩溃。正确做法是用 QTimer 在 UI 线程里驱动一个 tick() 方法频率控制在 30 到 60 FPS 之间。常见做法是 GameController 持有 QTimerinterval 设为 33ms也就是约 30 FPS。这个值对植物大战僵尸类项目足够僵尸移动是匀速直线植物开火是定时触发30 FPS 下完全流畅。如果项目里加了大量粒子和动画可以降到 16ms但 CPU 占用会明显上涨。我一般会把这个值做成常量避免以后改逻辑时到处找魔法数字。// gamecontroller.h 核心成员 class GameController : public QObject { Q_OBJECT public: explicit GameController(QGraphicsScene* scene, QObject* parent nullptr); void startGame(); private slots: void tick(); private: QTimer m_timer; // 游戏主循环定时器 QGraphicsScene* m_scene; // 场景指针不拥有所有权 QListPlant* m_plants; // 场上所有植物 QListZombie* m_zombies; // 场上所有僵尸 int m_frameCount 0; // 累计帧数用于定时事件 };这段代码把主循环的状态量集中在一个控制器里。m_timer 作为成员最省事因为要 connect 到槽函数。m_scene 用裸指针是因为场景由 MainWindow 创建并持有控制器只负责往里加图元、删图元不需要管理它的生命周期。QList 存储对象指针而不是对象本身因为 QGraphicsItem 体系要求对象存活在堆上场景删除图元时通过 deleteLater 来释放。构造函数里把定时器接上逻辑如下GameController::GameController(QGraphicsScene* scene, QObject* parent) : QObject(parent), m_scene(scene) { // 30 FPS每帧间隔 33ms m_timer.setInterval(33); connect(m_timer, QTimer::timeout, this, GameController::tick); } void GameController::startGame() { m_frameCount 0; m_timer.start(); } void GameController::tick() { m_frameCount; // 1. 生成阳光帧数取模避免每帧都判断时间 // 2. 植物开火遍历 m_plants检查冷却和行内是否有僵尸 // 3. 僵尸移动遍历 m_zombies调用 updateMove // 4. 碰撞检测按行分桶避免全场景两两比较 // 5. 清理死亡对象统一 removeItem deleteLater }tick() 里不要做任何 new 操作对象生成统一放到外面按帧计数或按事件触发避免频繁构造析构造成卡顿。setInterval(33) 是目标值实际触发精度依赖系统计时器Windows 平台上一般够用。如果发现不同机器速度差异大可以改用 QElapsedTimer 做帧间隔校准但简易项目用固定间隔最简单。2.3 QGraphicsScene 的对象管理从 QGraphicsItem 到业务类场景里每一个可交互元素都是 QGraphicsItem 的子类但业务逻辑不能全塞进 item 里。我习惯把 item 做成“纯视图加基础状态”真正的技能冷却、血量、攻击力放在对应的业务类 Plant 和 Zombie 里item 通过指针访问游戏数据。设置场景时有一个隐藏坑默认 itemIndexMethod 是 BspTreeIndex它在大场景下能加速碰撞检测但如果场景里图元频繁插入删除BSP 树重建反而拖慢。简易项目图元数量不大可以显式设置为 NoIndex让遍历走简单链表逻辑更可预期也不会因为图元频繁增删而卡顿。// 初始化场景 m_scene new QGraphicsScene(this); m_scene-setSceneRect(0, 0, 900, 600); // 固定游戏区域 m_scene-setItemIndexMethod(QGraphicsScene::NoIndex); // 图元层级背景 0植物 1僵尸 2阳光 3子弹 4 auto* bg new QGraphicsPixmapItem(QPixmap(://images/lawn.png)); bg-setZValue(0); m_scene-addItem(bg);setSceneRect 决定了 QGraphicsView 的滚动范围和点击坐标的基准。900x600 是适合 5 行 9 列草坪布局的尺寸每格约 80x100 像素。zValue 是解决遮挡的唯一手段子弹必须显示在僵尸上面阳光又必须显示在植物上面依靠 addItem 顺序是不可靠的。不要把整个草坪铺成一张大图然后再用坐标换算那样后续种植物判定会很别扭。3. 阳光经济与种植交互把“植物”玩成可调的状态机3.1 阳光的产生、收集与数值平衡阳光是游戏的经济系统实现上分两个部分定时生成阳光实体和点击收集。生成逻辑最保险的是在 tick() 里按累计帧数判定比如每 8 秒生成一个普通阳光每 24 秒额外生成一个从屏幕上方落下的阳光。这里的关键是“用帧数取模代替独立计时器”否则多个 QTimer 会全部挤在主线程里既难调试也不好暂停。阳光实体是 QGraphicsPixmapItem 加一个收集半径。点击判定不要用 mousePressEvent 里精确检测像素因为阳光图标带透明边会让人感觉“点中了却没反应”。我一般会为阳光设置一个比图标略大的 QRectF 命中区域比如图标 40x40就设成 60x60 的透明碰撞框手感会明显变好。收集时判断是否已被点击过防止一次点击重复计分。class SunItem : public QGraphicsPixmapItem { public: SunItem(int value, const QPointF pos, QGraphicsItem* parent nullptr); QRectF boundingRect() const override; void setCollected(bool c) { m_collected c; } bool isCollected() const { return m_collected; } private: int m_value 25; bool m_collected false; }; QRectF SunItem::boundingRect() const { // 比图片大一圈作为点击热区 return QRectF(-30, -30, 60, 60); }m_value 是阳光面额默认 25可以让不同来源的阳光带不同数值比如花盆掉落 50。boundingRect 返回大热区后碰撞检测和鼠标事件都会用这个矩形所以视觉上点旁边的空白也能收集这是故意为之。小技巧收集动画用 QGraphicsItemAnimation或者直接在 tick 里更新位置但简易项目里把阳光 delete 掉再给玩家加分数手感也够。3.2 种植逻辑卡槽选中、冷却与合法地块判定种植交互的状态机不复杂但必须严谨当前选中哪个卡槽、阳光够不够、该卡槽是否在冷却、点击的格子是否为空地。这四件事全部通过后才会生成植物。很多新手把判定散落在鼠标事件里结果点一次触发两株植物或者阳光不够也种下去了。我习惯把种植判定收敛成一个方法 plantAt(row, col)返回 bool。这样无论点击来自鼠标还是未来的触摸输入都走同一条校验链路。地块坐标用行 0 到 4 和列 0 到 8 表示通过点击的 scenePos 减去草坪起点再除以格子宽高得到。注意这里要处理浮点取整的边界点击在格子最右边缘时除法会得到下一列的索引所以要对结果做 clamp。bool GameController::plantAt(int row, int col) { // 边界与占用检查 if (row 0 || row 5 || col 0 || col 9) return false; if (m_grid[row][col] ! nullptr) return false; // 检查当前选中的植物卡槽 const PlantConfig cfg m_cardConfigs[m_selectedCard]; if (m_sun cfg.sunCost) return false; if (m_coolDown[m_selectedCard] 0) return false; // 扣阳光启动冷却 m_sun - cfg.sunCost; m_coolDown[m_selectedCard] cfg.coolDownFrames; auto* plant new Plant(cfg, row, col); plant-setPos(lawnOrigin() QPointF(col * GRID_W, row * GRID_H)); m_scene-addItem(plant); m_grid[row][col] plant; m_plants.append(plant); return true; }m_grid 是 Plant* 的二维数组这是种植判定和碰撞检测里最高效的数据结构。注意植物位置直接用行列换算不要用 item 自身坐标去反推格子否则拖动或动画会污染逻辑坐标。冷却时间我用帧数计coolDownFrames 在 tick() 里统一递减这样暂停游戏时冷却也自动暂停比 QElapsedTimer 少很多边界问题。m_cardConfigs 建议是一个 QVectorPlantConfig每个配置包含 sunCost、shootIntervalFrames、damage、hp、coolDownFrames。3.3 植物开火射速、子弹对象与对象池植物开火不是每帧都发射而是按帧数冷却。以豌豆射手为例通常每 1.4 秒发射一颗在 30 FPS 下就是 42 帧。开火条件还有一个常被忽略的判断这一行右侧必须存在僵尸。如果场上没僵尸也一直发射不仅浪费子弹还会让碰撞检测列表越来越长。在 tick() 里遍历植物时先按行过滤僵尸列表再决定开火。子弹是独立 QGraphicsItem移动速度和攻击力都写在配置里。最经典的新手错误是每发子弹都 new 一个对象飞出场景后 delete。子弹密集时频繁构造析构会让场景卡顿解决方法是对象池预分配一批子弹击中或出界后不是删除而是 setVisible(false) 并放回空闲列表。Bullet* GameController::acquireBullet() { for (auto* b : m_idleBullets) { if (!b-isActive()) return b; } auto* b new Bullet(); b-setZValue(4); m_scene-addItem(b); return b; } void GameController::releaseBullet(Bullet* b) { b-setActive(false); b-setVisible(false); b-stopMove(); }acquireBullet 先查空闲池找不到才 new。实际运行一段后池子会稳定在某个大小不再持续增长。releaseBullet 不删除对象只是停用。这里必须配套一个“回收过期子弹”的动作子弹飞过场景右边界或命中僵尸时调用 releaseBullet而不是直接 delete。对象池有一个坑停用的子弹还挂在场景里虽然不可见但仍会参与事件分发。所以 Bullet 的 boundingRect() 在未激活时要返回空矩形或者直接把 item 移到场景外的角落两种做法都能避免它干扰鼠标拾取。QRectF Bullet::boundingRect() const { if (!m_active) return QRectF(); // 停用时不参与碰撞与拾取 return QRectF(-8, -8, 16, 16); }4. 僵尸生成与移动AI让对手“笨”得刚刚好4.1 生成节奏与波次配置僵尸的生成不能写成“每 10 秒来一只”否则游戏节奏完全不可控。常见做法是维护一个事件表每条记录包含时间点帧数、行号、僵尸类型。tick() 里检查事件表头部凡是帧数小于等于当前帧的全部生成。事件表按僵尸类型还可以带血量倍率用来做难度递增。波次之间要穿插“空窗期”给玩家留种植物和攒阳光的时间。空窗期太短会让玩家挫败感强太长又无聊。一个典型的开局节奏是前 30 秒只有零星普通僵尸第 60 秒开始间隔 15 到 20 秒来一波后期压缩到 5 秒。这些数值我全部写成配置数组不写死在代码里后面调平衡时能省大量时间。struct WaveEvent { int frame; // 从游戏开始计的帧数 int lane; // 行号 0~4 int typeId; // 0普通僵尸 1路障僵尸 2铁桶僵尸 int hpBonus; // 血量加成百分比 }; void GameController::spawnZombies() { while (!m_waveEvents.isEmpty() m_waveEvents.first().frame m_frameCount) { const WaveEvent ev m_waveEvents.takeFirst(); auto* z new Zombie(ev); qreal xPos m_scene-sceneRect().right() 40; // 从右边界外走入 qreal yPos lawnOrigin().y() ev.lane * GRID_H GRID_H / 2; z-setPos(xPos, yPos); m_scene-addItem(z); m_zombies.append(z); } }这样生成逻辑是“数据驱动”的想调难度只改配置不碰逻辑。从右边界外 40 像素生成是为了让僵尸从屏幕外走进来时第一帧就在场景坐标内不会出现“突然出现在边界上”的跳变。yPos 取格子中心而不是左上角能让僵尸的脚部对齐草坪后续碰撞矩形计算更直观。m_waveEvents 需要提前排序如果配置是从 JSON 读入记得按 frame 升序排好。4.2 移动、啃食与攻击判定碰撞矩形怎么定僵尸的移动逻辑是一个“先看路再走路”的过程如果当前位置没有拦路的植物就向左移动碰到了植物就停在植物前面啃食。判断“碰到植物”不能拿整个图片矩形去比因为图片有透明边和锯齿视觉上还没碰上就开始啃手感很怪。正确做法是给僵尸定义一个窄一点的“身体碰撞矩形”只覆盖脚部和躯干核心比如宽 50 像素、高 70 像素。啃食时僵尸每帧对植物造成伤害伤害按秒计再换算成每帧数值。比如普通僵尸啃食 DPS 是 100植物血量 300那么 3 秒啃死。伤害结算要注意帧率波动如果你的主循环是固定 33ms直接用每帧伤害没问题但如果哪天改成可变帧率伤害会变得不确定。简易项目里固定帧率最省心但注释里要写清楚“每帧伤害 每秒伤害 / 30”。void Zombie::updateMove(QGraphicsScene* scene) { // 找这一行内离自己最近的植物 Plant* target findBlockingPlant(scene); if (target) { // 啃食状态原地不动按帧扣血 target-takeDamage(m_dpsPerFrame); setEating(true); return; } setEating(false); qreal step m_speedPerFrame; // 像素/帧比如 12/30≈0.4 setPos(x() - step, y()); }findBlockingPlant 的效率很重要。最粗的写法是遍历 m_plants找相同行且 x 坐标在当前僵尸右侧最近的那一个。因为 m_grid 已经按行列组织了可以直接用僵尸当前行和列索引去查如果 m_grid[zombieLane][plantCol] 非空且该植物 x 坐标大于僵尸 x 坐标它就是阻挡者。千万不要每帧对全场景植物做两两循环那样 30 个植物加 30 个僵尸就是 900 次比较再叠加子弹碰撞帧率会明显下降。4.3 死亡、消失与游戏结束对象清理的三大纪律对象清理是这类项目里最容易出玄学崩溃的地方。三大纪律第一不要在遍历 QList 时直接删除正在被遍历的元素第二删除 QGraphicsItem 必须通过 scene-removeItem再 delete第三被删除对象如果正在响应信号或事件要让出执行栈。常规做法是 tick() 里把所有要删除的对象先收集到一个列表遍历结束后统一清理。僵尸的死亡包括两种情况被子弹打死、走到了最左侧。后者是游戏失败判定条件。失败不能只弹一个 QMessageBox因为弹窗会阻塞主循环导致僵尸瞬间堆积。我习惯用一个 gameOver 标志tick() 里检测到后直接停止定时器并显示结算界面。void GameController::cleanupDeadItems() { for (auto* z : std::as_const(m_zombies)) { if (z-hp() 0 || z-x() sceneLeft()) { if (z-hp() 0) { m_scene-removeItem(z); z-deleteLater(); // 延后释放避免事件处理中崩溃 } else { gameOver(); // 有僵尸走到最左边 return; } } } // 统一从列表移除 m_zombies.erase(std::remove_if(m_zombies.begin(), m_zombies.end(), [](Zombie* z){ return z-hp() 0 || z-x() sceneLeft(); }), m_zombies.end()); }这里 deleteLater 配合 removeItem 是关键。removeItem 让图元从场景的绘制和事件列表里摘除deleteLater 让对象在本轮事件循环结束后再释放避免在 tick() 内部直接 delete 自己正在调用的对象。注意删除后必须从 m_zombies 列表移除否则指针悬空下次遍历就崩。hp() 是浮点还是整数无所谓但所有伤害来源要统一单位别一会按帧伤害一会按秒伤害调平衡时会被自己坑死。僵尸基础参数参考类型血量速度(px/帧30FPS)啃食DPS备注普通僵尸1800.35100基础单位数量多路障僵尸6000.30100血量厚前期压力来源铁桶僵尸12000.25120后期搭配普通僵尸出现速度单位用“像素/帧”最直观便于在固定帧率下推演。如果你想改成秒所有配置和代码都要同步换否则会出现“看起来走得慢但其实撞一下很疼”的数值割裂现象。5. 编译、资源与避坑排查从zip到可执行文件的血泪经验5.1 资源加载与路径qrc 是唯一的后悔药这类 zip 解压后最常见的第一个坑就是图片显示不出来。根因大多是开发者用了相对路径加载图片QPixmap(images/sun.png); // 在 IDE 里能跑双击 exe 就白屏原因很简单相对路径是相对于进程的“当前工作目录”而双击 exe 和从 IDE 运行时的工作目录往往不一致。解决方法不是去猜路径而是把所有图片和音效放进 Qt 资源系统 qrc。qrc 路径以:/开头与工作目录无关永远能找到。RCC qresource prefix/images file aliassun.pngassets/sun.png/file file aliaspea_bullet.pngassets/pea_bullet.png/file /qresource /RCCQPixmap(:/images/sun.png); // 资源文件路径始终有效alias 可以让你在代码里用短名字原始文件放 assets 子目录资源文件不会污染源码目录。注意 qrc 里路径大小写敏感Windows 下不检查大小写但 Linux 下会跨平台项目建议保持全小写文件名。如果在 Qt Designer 里预览正常但程序里空白先清空一遍构建目录再重编因为 qrc 有时不会自动重新编译这是一个老毛病。5.2 中文乱码与源码编码MSVC 和 MinGW 的差异中文用户的项目里控件文案经常变成一堆问号或乱码。通常不是代码逻辑问题而是源码文件保存编码和编译器默认编码不一致。Qt 从 5.0 开始字符串字面量按 UTF-8 处理但 MSVC 的源码解析默认按本地代码页 GBK 识别两者对不上就乱。解决方案很粗暴源码文件一律保存为 UTF-8。MSVC 用户额外在 .pro 文件里加编译选项让编译器把 UTF-8 文件正确转成执行字符集msvc { QMAKE_CXXFLAGS /utf-8 }MinGW 通常不需要这个选项但注意 Qt Creator 右下角切一下文件编码确保是 UTF-8。已经乱掉的字符串改用 QStringLiteral(中文) 可以让 Qt 5 按 UTF-8 直接处理但根本解法是统一编码。QStringLiteral 还有一个好处是避免隐式转换时的运行时开销但别指望它解决所有乱码。5.3 Qt 版本与编译器匹配dll 缺失和 OpenGL 报错下载 Qt 时有几个版本最常见的翻车是装了一个 MSVC 版 Qt却用 MinGW 编译器去编译。能编译但运行时缺少对应的动态库或者报一堆“无法定位程序输入点”。判断方法很简单看 Qt 安装目录里的编译器标记MSVC 版库目录带 msvc 字样MinGW 版带 mingw。你用的编译器必须与 Qt 构建时一致。部署时使用官方自带的 windeployqt 工具它会把需要的 Qt dll 和插件目录拷到 exe 旁边。注意在命令行运行 windeployqt 时要指定完整 exe 路径并确保该 exe 已经在 Release 模式下构建。Debug 版本依赖的 dll 和 Release 不通用混着用会在别人的机器上出现“应用程序无法正常启动 0xc000007b”。# 以 Windows 环境为例 cd build-release C:\Qt\...\bin\windeployqt.exe game.exe还有一个高频玄学问题打开程序后黑屏或报 OpenGL 相关错误。原因通常是目标机器显卡驱动太老或远程桌面环境不支持 OpenGL。可靠的做法是给视图设置软件渲染后端别跟 OpenGL 死磕。代码里在 QApplication 构造前设置环境变量即可。int main(int argc, char* argv[]) { // 必须放在 QApplication 构造之前 qputenv(QT_OPENGL, software); QApplication app(argc, argv); // ... }未配置正确的 dll 会崩溃但配置了软件渲染又可能让粒子性能下降。取舍是简易项目优先保证能跑性能问题后续再通过硬件加速打开。5.4 常见崩溃与排查记录这里列三个最具代表性的踩坑记录现象点击种植某一种植物程序瞬间退出无任何报错提示。 原因Plant 构造里加载了不存在的资源路径QPixmap 为空后 setPixmap 不会崩但随后调用了 pixmap()-width() 这类空指针操作。解决加载资源后先判空if (pixmap.isNull()) qWarning() resource missing;并且把关键资源用一个统一 loader 函数加载。现象游戏运行几分钟后越来越卡内存占用不断上涨。 原因植物和僵尸产生的子弹、阳光对象只 add 进场景从不删除对象池没有实现回收逻辑或者回收后没有从场景里移除。解决按对象池方案做确保每个产生类对象都有明确的回收出口不要依赖 QGraphicsScene 自动清理。现象删除植物后这一格仍然无法种植。 原因删除植物时只 removeItem没有同步把 m_grid[row][col] 置空。解决封装一个 removePlantAt 方法所有删除路径都走它同时在清理死对象阶段做二次校验。把这三条写进项目的 debug 输出里比靠 gdb 一步步看快得多。遇到崩溃先开 Qt Creator 的调试器看调用栈如果崩在 QGraphicsScene::drawItems十有八九是图元生命周期没收干净如果崩在 QString 相关优先怀疑编码或空指针。6. 让游戏真正“能通关”关卡配置驱动一局完整循环最后一章落到进阶把波次和植物数值全部外置成 JSON并让游戏有明确的通关条件。这样你不用每次改平衡都重新编译也给玩家留了修改关卡的空间。一个关卡配置文件可以长这样{ sunStart: 150, plantCards: [ {id: pea, name: 豌豆射手, sunCost: 100, coolDownFrames: 150, shootIntervalFrames: 42, damage: 20} ], waves: [ {frame: 900, lane: 2, type: 0, hpBonus: 0}, {frame: 1200, lane: 1, type: 1, hpBonus: 40} ] }在 GameController 初始化时用 QJsonDocument 读取把数组解析成之前的 WaveEvent 和 PlantConfig。只要保持字段名一致关卡设计和程序就能并行工作你只改 JSON 就能测试不同难度。通关条件设置为所有 wave 事件已触发且场上僵尸数为 0且没有僵尸到达左侧。满足后停止主循环弹一个结算界面显示用了多少秒和剩余阳光。失败条件前面已经讲了是任一行僵尸越过左边界。这两个条件都要在 tick() 的末尾检测并且加上一个“结束后不再处理事件”的标志防止结算界面弹出后还有僵尸继续移动。我自己的习惯是任何塔防类 Qt 项目都先把“每秒伤害 / 每帧伤害”“冷却帧数”“阳光产出间隔”这几个单位和换算关系做成常量表写在项目根目录的备注文件里。每次调数值只动 JSON 或常量绝不改业务代码。等这版风格顺手了再去考虑加子弹轨迹动画、音效池和更多僵尸类型那都是锦上添花。这类项目真正教会你的不是图形学而是“对象生命周期”和“数据驱动”这两个工程习惯。拿到 zip 别急着跑先看 qrc、看配置、看主循环三个位置很快能判断出这个项目值不值得深挖。希望帮到你。本文还有配套的精品资源点击获取