十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++塔防游戏实战:从王国保卫战源码拆解主循环、寻路与对象池

C++塔防游戏实战:从王国保卫战源码拆解主循环、寻路与对象池 简介一款基于C开发的模仿王国保卫战的塔防游戏源码面向计算机、自动化等专业的大学生及从业者适合作为期末课程设计、课程大作业或毕业设计项目也可作为个人项目直接使用。资源以完整工程形式打包共336个文件其中175个PNG图片为游戏界面与角色贴图62个H头文件与60个CPP源文件构成核心逻辑层23个WAV音效提供战斗反馈另含plist配置与TTF字体等压缩包大小44.78MB目录结构清晰便于按模块检索与二次开发。代码涵盖炮塔攻击、怪物AI、玩家操控、HUD界面显示、关卡切换StageTwo/StageThree等核心玩法模块并实现ResultMenu、UpLayer等菜单与逻辑层整体架构完整运行验证通过。已有566人学习使用具有较高的学习借鉴价值既能帮助理解游戏开发流程与C面向对象设计思想也能为课程设计或毕业设计提供可直接改造的完整工程基础。1. 用 C 复刻王国保卫战判断一个塔防项目值不值得读的门道拿到“基于 c 开发的模仿王国保卫战游戏源码”的大学生项目第一反应往往是“不就抄了个塔防”。但真把源码摊开判断标准就变了游戏循环稳不稳、敌人寻路是否写死、塔的伤害判定按格子还是按碰撞、存档与波次耦合紧不紧。这几点才是能交作业的 c 小游戏和能写进简历的 c 游戏项目的分水岭。受众很直接还没碰过完整项目的本科生、想拿游戏方向作品找工作的同学、被课程设计逼着交塔防的人。王国保卫战是经典路线塔防——敌人沿固定路径行进玩家在路边造塔拦截。机制朴素正好练 C 的对象模型、容器选型与帧循环也适合拿来试水 VSCode 配 C/C 环境的完整构建流程。下文顺着这类源码最常见的组织方式从主循环拆到寻路、从渲染绕到碰撞最后补调试和内存优化。建议照思路自己复刻而不是把 zip 里的代码原样跑一遍就完事。2. C 塔防的主循环和对象模型先把地基打对决定一份王国保卫战模仿源码能不能改得动的是主循环和对象模型。主循环决定帧率波动时游戏体验是否一致对象模型决定你加一个新单位时要改几个文件。大多数大学生项目在这两层上都赶得比较急循环写成“每帧裸奔 update”类设计则是把敌人、塔、投射物全部塞进一个巨大的 Game 类。它能跑但后续动起来很痛。2.1 固定时间步长为什么塔防不能“每帧一更”很多 C 新手写游戏循环是while (window.isOpen())里直接update()。这在 60Hz 的机器上没问题一旦换到 144Hz 高刷屏敌人移动速度和塔的攻击频率会整体变快反过来低配机器掉帧时敌人又会表现出瞬移。这就是帧率耦合。塔防的战斗判定密集波次间隔和塔冷却都按秒算不用固定步长不同机器上同一关的难度完全不同。常见解法是固定时间步长加累积器这也是这类源码里最值得模仿的一段// 固定时间步长的主循环逻辑以 60Hz 刷新 constexpr float TICK_RATE 1.0f / 60.0f; float accumulator 0.0f; auto lastTime std::chrono::steady_clock::now(); while (running) { auto now std::chrono::steady_clock::now(); float dt std::chrono::durationfloat(now - lastTime).count(); lastTime now; // 单帧耗时超过 0.25 秒时丢弃余量防止“螺旋死亡” if (dt 0.25f) dt 0.25f; accumulator dt; while (accumulator TICK_RATE) { handleInput(); // 读取鼠标建造、快捷键、暂停 update(TICK_RATE); // 战斗逻辑严格按固定步长走 accumulator - TICK_RATE; } render(accumulator / TICK_RATE); // 余量比例传给渲染做插值 }这里值得注意的参数是TICK_RATE。取 1/60 而不是 1/30是为了让塔的攻击冷却和敌人生成间隔能细化到 16 毫秒以内取 1/120 没有必要塔防逻辑里几乎没有需要 8 毫秒精度的判定。dt上限 0.25 的作用是防止程序切窗口或断点命中后恢复时把一大段时间全塞进循环导致瞬间生成一大批兵。渲染时传入accumulator / TICK_RATE是让画面在两个逻辑帧之间做位置插值属于视觉平滑不影响逻辑结果。2.2 实体对象模型继承树怎么拆容器怎么选王国保卫战的实体不多敌人、塔、投射物、飘字、地面装饰。大学生源码最常见的组织方式是让所有实体继承一个GameObject基类里面放坐标和虚函数update/draw。这个设计在敌人只有两种时是干净的但一旦出现“飞行敌人不走路点”“炮塔有转向动画”这类差异虚函数会越堆越胖。我一般建议先把共用字段拆出来不急着上继承。一个敌人可以简化为这样class Enemy { public: Enemy(std::vectorVec2 const path, int hp, float speed) : m_path(path), m_hp(hp), m_speed(speed) {} void update(float dt); Vec2 position() const { return m_position; } size_t pathIndex() const { return m_pathIndex; } size_t pathSize() const { return m_path.size(); } void takeDamage(int dmg) { m_hp - dmg; } bool isDead() const { return m_hp 0; } bool isReachedEnd() const { return m_pathIndex m_path.size(); } private: std::vectorVec2 const m_path; // 引用同一份路点不复制 size_t m_pathIndex 0; Vec2 m_position; // 当前世界坐标 float m_speed; int m_hp; };引用成员m_path是一个取舍。它让所有敌人共享关卡的路点数组省内存代价是Enemy对象不方便按值拷贝赋值。实战里敌人不会复制所以这个代价可接受。反而常见的问题是拷贝路径把m_path存成std::vectorVec2后100 个敌人就有 100 份完整路点数组属于典型的“功能正常但浪费内存”。另一个需要注意的点是引用生命周期路径数组必须活得比所有敌人都久关卡卸载时要先销毁敌人。实体表实体典型职责常见错误Enemy沿路径前进、受伤、掉落金币把路径拷贝进每个实例Tower索敌、冷却、升级、产生投射物升级判断写在渲染里Projectile追踪目标、命中判伤目标已删除仍持有裸指针GameState金币、生命、波次索引把波次逻辑塞进 Enemy 更新2.3 迭代删除vector 里删敌人的安全写法塔防一帧里可能有几十个敌人同时死亡删除操作如果写在塔的攻击函数里会立刻毁掉正在遍历容器的迭代器。初学者最容易撞的崩溃就是这么来的。可参考的做法是把删除挪到update的一个阶段先标记再统一移除// 先标记不直接删 for (Enemy e : m_enemies) { if (e.isDead() || e.isReachedEnd()) { e.markForRemoval(); } } // 再统一从 vector 中移除 m_enemies.erase( std::remove_if(m_enemies.begin(), m_enemies.end(), [](Enemy const e) { return e.shouldRemove(); }), m_enemies.end());remove_if会把要删除的元素搬到容器尾部返回新逻辑结尾的迭代器erase再真正销毁尾部这些元素。整个过程中循环遍历只在读取阶段进行不会出现迭代器失效。这也是为什么在中等规模塔防里不用急着上std::list。vector 的连续内存对缓存友好几百个敌人每帧遍历一趟性能压力远小于链表的散列节点。只有当敌人数量上万才值得考虑内存池或分块存储而这个场景在王国保卫战仿写项目里基本不存在。提示markForRemoval和shouldRemove不要用同一个函数名逻辑上一个是写一个是读混在一起容易在批量删除时误判状态。3. 寻路、波次和塔的索敌逻辑王国保卫战玩法核心怎么落地王国保卫战是固定路线塔防敌人从出发点沿预设路点走进终点。这个特征定义了整套玩法的核心代码寻路不需要 A*只需要路径点遍历波次由数据表驱动而不是 if 脚本塔的索敌则是在“最近、最远、最强”之间做选择。三块代码都不难但每块都有一个容易被问穿的点。3.1 路径点寻路固定路线塔防不需要 A*“固定路线”意味着关卡加载时就把路点顺序写好敌人只需要知道自己当前正走向第几个点。这种设计下寻路模块退化成“路径点列表 索引”不需要网格也不需要开放寻路算法。理由很简单如果玩家的塔不能改变地形就永远不会出现“路被堵住要重新寻路”的需求。只有塔能修路障或者敌人需要避开临时障碍时A* 才是必要的。敌人的移动更新通常长这样bool Enemy::update(float dt) { if (m_pathIndex m_path.size()) return false; // 到达终点扣命逻辑交给 GameState Vec2 target m_path[m_pathIndex]; Vec2 delta target - m_position; float dist std::sqrt(delta.x * delta.x delta.y * delta.y); float step m_speed * dt; if (dist step) { // 剩余距离不够一步直接落到路点上 m_position target; m_pathIndex; } else { // 分量归一化后按步长累加保证移动速度恒定 m_position.x (delta.x / dist) * step; m_position.y (delta.y / dist) * step; } return true; }两个分支必须分开处理。dist step时不直接落点敌人会永远差一点到不了路点产生来回抖动dist step时不归一化敌人速度会随着接近路点而变慢。实际调试时把speed调成 0 再跑一帧看m_position是否精确停在路点上是个有效的验证手段。如果路点坐标来自地图编辑器还要注意相邻路点间距不能太近否则dist接近 0 时除以它会出现浮点异常。3.2 塔索敌Closest 和 Furthest 是两套指标塔的攻击逻辑核心是“从当前所有敌人里选一个目标对它发射投射物”。选法一般有三种代码差别只在一行比较条件Enemy* Tower::pickTarget(GameState state) { Enemy* best nullptr; float bestValue -1.0f; for (Enemy e : state.enemies()) { float d distance(m_position, e.position()); if (d m_range) continue; // 射程外直接跳过 // 按“路径进度”选出离终点最近的敌人 float progress static_castfloat(e.pathIndex()) / static_castfloat(e.pathSize()); if (progress bestValue) { bestValue progress; best e; } } return best; }这里比较的是路径进度而非直线距离。很多初学者会想当然写distance(m_position, e.position())然后取最小值结果是“离塔最近的敌人”被优先攻击而不是“离终点最近的敌人”。对王国保卫战这类漏怪即扣血的游戏拦截离终点近的敌人比清理塔边的敌人更重要所以路径进度比直线距离更接近设计意图。三种选法对比索敌策略比较量适合场景注意点Closest到塔的欧氏距离泛用防守清掉近处的兵敌人路过塔侧时会浪费火力FurthestpathIndex / pathSize防漏怪到终点需要沿途路点密度一致Strongestm_hp 最大优先击杀精英兵boss 会被小怪挡在射程外实际项目里弓塔爱用 Closest炮塔用 Furthest精英怪则让冰塔优先减速。所以给Tower加一个TargetMode枚举比写死某一种策略更通用也方便在关卡编辑器里给每座塔单独设置。3.3 波次生成用数据表代替一堆 if如果波次逻辑写成“到第 10 秒出一波”“到第 20 秒再出一波”代码会很快腐烂。更可维护的做法是把每次波次定义成数据struct WaveConfig { int enemyType; // 0 步兵, 1 弓手, 2 精英 int count; float interval; // 两个敌人之间的间隔秒 float delay; // 波次开始前的停顿秒 }; class WaveSystem { public: explicit WaveSystem(std::vectorWaveConfig waves) : m_waves(std::move(waves)) {} void start(int index) { if (index 0 || index (int)m_waves.size()) return; m_waveIndex index; m_spawned 0; m_elapsed 0.0f; } void update(float dt) { WaveConfig const w m_waves[m_waveIndex]; m_elapsed dt; while (m_spawned w.count m_elapsed w.delay m_spawned * w.interval) { spawnEnemy(w.enemyType); m_spawned; } } bool finished() const { return m_waveIndex (int)m_waves.size() - 1 m_spawned m_waves.back().count; } private: std::vectorWaveConfig m_waves; int m_waveIndex 0; int m_spawned 0; float m_elapsed 0.0f; };while条件是这份代码的要点。w.delay m_spawned * w.interval计算的是第m_spawned个敌人的预定出场时刻只要累计时间越过阈值就生成一个。好处是不需要给每个敌人单独维护计时器改interval就能整体调整波次密度改count就是批量加兵。把一组WaveConfig放在数组里关卡难度调整只动数据不动逻辑。注意finished()只在最后一路兵也生成完毕后才返回 true如果你要在下一波敌人清空后才进入结算还要再加一个“场内无存活敌人”的判断条件别让finished()承担过多语义。4. 碰撞与渲染C 塔防 Demo 从逻辑坐标到屏幕像素的转换在塔防源码里碰撞检测和渲染经常被当成两个独立部分但在固定路线塔防中它们共享一个核心问题把世界坐标里的实体判定映射到屏幕上的视觉结果。射程是圆形、敌人占地是矩形、投射物命中是圆对矩形这三个形状的处理方式直接决定手感。4.1 射程判定用圆命中判定用 AABB塔的攻击范围天然是圆。与之相关的代码最常犯的毛病是每次都开根号std::sqrt(dx*dx dy*dy)。在几百个敌人、几十座塔的规模下这点开销微不足道但写成平方比较除了省去开根更重要的是把“比较距离”和“计算距离”从语义上分开以后如果要改成曼哈顿距离直接改函数体即可。// 塔射程圆判定比较距离平方 bool inRange(Vec2 towerPos, float range, Vec2 enemyPos) { float dx towerPos.x - enemyPos.x; float dy towerPos.y - enemyPos.y; return dx * dx dy * dy range * range; } // 投射物命中AABB 相交适合精灵贴图不是正圆的情况 bool overlapAABB(Vec2 aMin, Vec2 aMax, Vec2 bMin, Vec2 bMax) { return aMin.x bMax.x aMax.x bMin.x aMin.y bMax.y aMax.y bMin.y; }AABB 的四个比较本质上是“两个矩形在 X 轴和 Y 轴上的投影都有交集”。投射物是飞行体用 AABB 而不是像素级碰撞会让真实命中范围比精灵表面稍大一圈对塔防这种以清怪为目的的游戏大一圈反而形成宽容判定手感更好。如果做的是近战塔攻击判定还要在onAttack回调里再确认一次敌人是否仍在攻击范围内避免投射物飞行途中目标已经跑出射程。两种判定方式对比判定方式计算量适用场景注意点圆形距离两次乘法、一次比较射程、范围伤害圆形范围在斜坡上与实际视觉有偏差AABB 矩形四次比较投射物、近战拍击旋转过的矩形不能直接用需要先旋转坐标点到线段距离较重敌人贴墙走、技能横扫塔防逻辑里一般不常用4.2 渲染只读逻辑别把升级判断写进 draw大学生源码里最普遍的一个坏味道是把状态修改放进绘制函数。典型代码长这样// 反面示例渲染里偷偷升级帧率一变行为就变 void drawTowers(sf::RenderWindow win) { for (auto t : m_towers) { if (m_gold t.upgradeCost()) { // 判断出现在渲染路径 m_gold - t.upgradeCost(); // 修改状态 t.upgrade(); } t.draw(win); } }这能跑但你在遍历渲染的同时改m_gold和t等于把游戏逻辑绑定到 GPU 的刷新频率。锁帧以后没问题去掉垂直同步后逻辑就可能每帧被调用好几轮。正确做法是逻辑和渲染彻底分开void GameState::update(float dt, Tower t) { if (m_gold t.upgradeCost() t.currentLevel() t.maxLevel()) { m_gold - t.upgradeCost(); // 所有状态修改只发生在 update t.upgrade(); } } void GameState::render(sf::RenderWindow win) const { for (auto const t : m_towers) { t.draw(win); // 渲染只读取状态不做修改 } }const修饰符在这里是纪律不是摆设。render 接收const容器强制编译器帮你拦截修改操作。如果源码里 render 非要加一个非 const 参数那就是在告诉你设计有循环耦合尽早拆。4.3 世界坐标与屏幕坐标一个小的正交相机就够如果关卡比窗口大就要处理相机。王国的关卡通常远超一屏一个简单的正交相机足够struct Camera { Vec2 center; // 相机中心的世界坐标 float zoom; // 缩放塔防通常固定 1.0f Vec2 worldToScreen(Vec2 w) const { return { (w.x - center.x) * zoom screenWidth / 2.0f, (w.y - center.y) * zoom screenHeight / 2.0f }; } bool isVisible(Vec2 w, Vec2 halfSize) const { Vec2 s worldToScreen(w); return s.x halfSize.x 0 s.x - halfSize.x screenWidth s.y halfSize.y 0 s.y - halfSize.y screenHeight; } };这里有两个细节。其一worldToScreen只管绘制坐标映射游戏逻辑坐标不要跟着改。敌人的位置应该始终存世界坐标所有逻辑计算都用世界坐标只有绘制前调用转换。其二isVisible的剔除判断在绘制前做一次即可避免对屏幕外的敌人继续做后面的 AABB 相交计算。塔防场景深度不大这种程度的剔除已经够用不需要视锥剔除。5. 让这份 C 塔防源码经得起拷问调试、内存与热路径验证5.1 把看不见的状态画出来塔防最容易出问题的位置在寻路和索敌。不看数值光看跳帧很难定位。给自己留一个调试开关按 F2 切换渲染判定区域// 调试开关放在 GameState 里 bool debugDraw false; void render(sf::RenderWindow win) { // 原有的敌人、塔、投射物渲染 if (debugDraw) { for (auto const t : m_towers) { drawCircleOutline(win, t.position(), t.range()); } for (auto const e : m_enemies) { if (e.pathIndex() e.pathSize()) { drawLine(win, e.position(), path[e.pathIndex()]); } } } }把塔的射程画成圆圈把敌人到下一路点的线段画出来一眼就能看出塔是不是选了范围外的目标、敌人有没有越界。这个技巧成本极低却能把“为什么不打这个怪”变成可见记录。注意画圈和画线要在高峰调试时用独立开关控制别和正式渲染混在同一个if里否则多人接手时容易误开。5.2 用 chrono 量出真正的热路径不要靠肉眼盯帧率。在update和render的入口出口各打时间戳用std::chrono::steady_clock测量单帧各阶段耗时比猜可靠得多auto start std::chrono::steady_clock::now(); update(TICK_RATE); auto updateEnd std::chrono::steady_clock::now(); float updateMs std::chrono::durationfloat, std::milli(updateEnd - start).count(); maxUpdateMs std::max(maxUpdateMs, updateMs);如果 update 单帧超过 8~10 毫秒优先查逻辑阶段。常见原因有三个每帧对敌人按距离排序时重复排序、vector里按值传大对象、以及每个敌人都访问磁盘资源。如果 render 阶段高优先怀疑 draw 调用次数过多。塔防里一个常见误用是每帧对每个敌人单独设置纹理再画一次纹理切换是很大的开销正确做法是把同种纹理的精灵先收集到一批再统一提交。5.3 投射物对象池别在战斗高峰期一帧一 new投射物是塔防里生命周期最短的对象飞出塔、碰到敌人或到射程尽头就销毁。如果每帧new/delete高频战斗时会积累大量堆碎片表现就是游戏越玩越卡。给投射物做一个简单对象池class ProjectilePool { public: Projectile* acquire() { Projectile* p nullptr; if (m_free.empty()) { m_free.push_back(std::make_uniqueProjectile()); } // 把空闲对象的裸指针取出交给活跃列表管理所有权 p m_free.back().release(); m_free.pop_back(); m_active.push_back(std::unique_ptrProjectile(p)); return p; } void release(Projectile* p) { // 从 m_active 中找到对应项重置状态后放回 m_free } private: std::vectorstd::unique_ptrProjectile m_active; std::vectorstd::unique_ptrProjectile m_free; };对象池的收益不是省掉new的花销现代 C 的malloc已经很快真正价值在于降低分配频率后堆碎片减少、缓存局部性改善以及“释放投射物”变成一次状态重置而不是内存释放。要注意给池子设上限比如 200 个超出后直接复用最早退役的投射物避免波次无限叠加时池子跟着膨胀。验证方法也很直接开一局满波次持续刷兵在任务管理器里盯内存曲线稳定不增长说明池子工作正常若还在缓慢上升优先查release是否漏了重置状态别急着怀疑池子本身。本文还有配套的精品资源点击获取
返回列表