简介:这是一份面向C++初学者与游戏开发入门者的射击游戏实战源码,围绕面向对象编程、图形界面与游戏循环等核心技能展开,适合想通过完整小项目巩固语法、理解游戏架构的开发者参考。压缩包共14个文件,以12个cpp源文件为主,另含1个头文件与1个rar资源包,整体约7.16MB,源码按功能拆分为玩家、敌人、子弹、道具、墙体、时间管理、开始与结算界面等模块,结构清晰便于分块阅读。目前已有100人学习下载。读者可从中掌握敌人随机位置生成、血量显示与死亡判定、加分与道具拾取、键盘控制移动与射击、分数与血量实时显示、结算界面及背景音乐等完整实现思路,并借鉴对象状态管理、随机数生成与事件驱动输入处理等做法,是学习C++游戏编程的实用范例。
1. 从零手搓一个 C++ 射击游戏:随机刷怪、血量结算与道具系统到底怎么落地
很多人第一次用 C++ 写游戏,卡住的地方不是语法,而是“敌人怎么随机出现”“血量怎么显示”“打死了怎么加分”这些看起来零散、实际互相咬合的模块。这个标题讲的正是这样一套完整的小型射击游戏:玩家控制角色移动、射击,敌人从随机位置刷出,带血量条,被打死后消失并加分,场上还会掉落道具,界面实时显示分数和血量。它适合两类人:一类是学过 C++ 基础语法、想找一个能跑通完整闭环的练手项目;另一类是做嵌入式或工具开发,想借游戏循环理解状态管理和实时刷新。我下面按“能复现”的标准拆,不堆概念,直接给结构和参数。
2. 游戏主循环与实体管理:为什么你的敌人一多就卡
2.1 主循环的时间步长决定手感
射击游戏最核心的不是画面,是循环。常见做法是固定时间步长加渲染插值,但小项目里更实用的是“可变步长 + 帧间隔钳制”。帧间隔钳制能防止切窗口回来时敌人瞬移,这是血泪经验:不钳制的话,最小化再恢复,敌人会直接穿过玩家。
// 主循环骨架:dt 为帧间隔,单位秒 const double MAX_DT = 0.05; // 钳制上限,防止卡顿导致穿透 double last = now(); while (running) { double dt = now() - last; last = now(); if (dt > MAX_DT) dt = MAX_DT; // 关键:钳制 handleInput(); update(dt); // 移动、碰撞、刷怪计时 render(); // 绘制玩家、敌人、UI }逻辑说明:update里所有速度都乘dt,这样帧率高低不影响移动距离。参数说明:MAX_DT取 0.05 秒,对应最低 20 帧的模拟精度,低于这个值敌人会显得“跳”。如果你的机器稳定 60 帧,这个值几乎不触发,但它是后悔药。
2.2 实体用数组还是对象池
敌人数量少时,std::vector<Enemy>直接增删没问题。但射击游戏里子弹和敌人频繁创建销毁,vector的删除是 O(n),敌人一多就卡。我一般用“标记死亡 + 延迟清理”,或者固定大小对象池。
struct Enemy { float x, y; int hp, maxHp; bool alive; float speed; }; std::vector<Enemy> enemies; // 更新时只标记,不立即删除 for (auto& e : enemies) { if (e.hp <= 0) { e.alive = false; score += 10; } } // 统一清理,避免遍历时迭代器失效 enemies.erase(std::remove_if(enemies.begin(), enemies.end(), [](const Enemy& e){ return !e.alive; }), enemies.end());逻辑说明:先标记再统一erase,避免在遍历中删除导致迭代器失效,这是新手最常见的翻车点。参数说明:score += 10是击杀加分,后面道具可以做成动态分值。对象池版本就是把alive复用,不真正释放内存,适合敌人上限固定的场景。
2.3 随机位置刷怪:别让敌人刷在玩家脸上
随机位置不是rand() % 宽就完事。要满足两个条件:不重叠、不贴玩家。常见做法是拒绝采样:随机生成候选点,检查与玩家和已有敌人的距离,不合格就重试,最多重试若干次。
bool spawnEnemy(float& outX, float& outY, float playerX, float playerY) { const float MIN_DIST_PLAYER = 150.0f; // 离玩家最小距离 const float MIN_DIST_ENEMY = 40.0f; // 敌人之间最小距离 for (int tries = 0; tries < 20; ++tries) { float x = randRange(0, SCREEN_W); float y = randRange(0, SCREEN_H); if (dist(x, y, playerX, playerY) < MIN_DIST_PLAYER) continue; bool ok = true; for (auto& e : enemies) if (e.alive && dist(x, y, e.x, e.y) < MIN_DIST_ENEMY) { ok = false; break; } if (ok) { outX = x; outY = y; return true; } } return false; // 20 次都失败,本帧不刷 }逻辑说明:拒绝采样保证分布均匀且满足约束,比“先算角度再算半径”更直观。参数说明:MIN_DIST_PLAYER取 150 像素,约等于玩家两倍身位;MIN_DIST_ENEMY取 40 像素防止叠在一起。重试上限 20 次是防止地图太挤时死循环,失败就跳过本次刷怪,不影响主循环。
3. 血量、死亡与分数显示:UI 和逻辑怎么不打架
3.1 血量条用矩形还是贴图
小项目里血量条直接用两个矩形:底条灰色,前条红色,宽度按hp/maxHp比例缩放。这样不用加载资源,改颜色和尺寸都方便。
void drawHealthBar(float x, float y, int hp, int maxHp) { const float W = 40.0f, H = 6.0f; float ratio = (float)hp / maxHp; if (ratio < 0) ratio = 0; drawRect(x, y, W, H, GRAY); // 底 drawRect(x, y, W * ratio, H, RED); // 前 }逻辑说明:ratio必须钳制到 0,否则血量负数时矩形宽度为负,渲染会出玄学问题。参数说明:W和H按敌人尺寸调,一般宽度略小于敌人碰撞盒。如果敌人有受击闪白,可以在hp变化的那一帧把前条颜色临时改成白色。
3.2 死亡结算与加分时机
死亡和加分要分开:血量归零是状态,加分是事件。如果每帧都判断hp<=0就加分,敌人没被清理前会重复加分。正确做法是用alive标记,只在“本帧刚死”时加一次。
for (auto& e : enemies) { if (e.alive && e.hp <= 0) { e.alive = false; score += e.scoreValue; // 只加一次 spawnDrop(e.x, e.y); // 概率掉道具 } }逻辑说明:alive从 true 变 false 的那一帧才结算,之后即使还在数组里也不会重复加分。参数说明:scoreValue可以按敌人类型区分,普通敌人 10 分,精英 50 分。掉落概率建议 20% 到 30%,太高会满地道具,太低玩家没感知。
3.3 分数和血量显示的位置与刷新
UI 文本每帧重绘,不要缓存成纹理再改,小项目里直接画字符串更省事。分数放左上角,血量放左下角或顶部中间,避免和敌人重叠。
void drawUI(int score, int playerHp, int playerMaxHp) { drawText(10, 10, "Score: " + std::to_string(score)); drawHealthBar(10, SCREEN_H - 20, playerHp, playerMaxHp); }逻辑说明:std::to_string每帧调用会有分配开销,但分数变化不频繁,实际影响可忽略。参数说明:文本坐标用固定值,不要跟着玩家走。如果要做“分数跳动”效果,可以在加分时记录一个scoreDisplay做插值,但这是进阶,先跑通再说。
4. 道具系统与射击手感:从能玩到愿意玩
4.1 道具掉落与拾取判定
道具系统最小闭环是:敌人死亡时按概率生成道具,玩家碰到就生效。道具类型先做三种:加血、加射速、加伤害。
enum class DropType { HEAL, RAPID, DAMAGE }; struct Drop { float x, y; DropType type; bool alive; }; void spawnDrop(float x, float y) { if (rand01() > 0.25f) return; // 25% 掉落 Drop d; d.x = x; d.y = y; d.alive = true; int r = rand() % 3; d.type = (DropType)r; drops.push_back(d); } void checkPickup(float px, float py, Player& p) { for (auto& d : drops) { if (!d.alive) continue; if (dist(px, py, d.x, d.y) < 20.0f) { d.alive = false; if (d.type == DropType::HEAL) p.hp = std::min(p.hp + 20, p.maxHp); if (d.type == DropType::RAPID) p.fireCooldown *= 0.7f; if (d.type == DropType::DAMAGE) p.bulletDamage += 1; } } }逻辑说明:拾取用圆形距离判定,简单且够用。参数说明:掉落概率 25%,拾取半径 20 像素,加血 20 点,射速冷却乘 0.7,伤害加 1。注意射速冷却要设下限,否则连发太快会卡输入。
4.2 射击冷却与子弹管理
射击手感取决于冷却和子弹速度。冷却用累加计时,不要用sleep。
struct Player { float x, y; int hp, maxHp; float fireCooldown; float fireTimer; int bulletDamage; }; void tryShoot(Player& p, std::vector<Bullet>& bullets, float dt) { p.fireTimer -= dt; if (p.fireTimer <= 0 && inputShoot()) { Bullet b; b.x = p.x; b.y = p.y; b.vx = 0; b.vy = -600.0f; // 向上 b.damage = p.bulletDamage; b.alive = true; bullets.push_back(b); p.fireTimer = p.fireCooldown; } }逻辑说明:fireTimer每帧减dt,小于等于 0 且按下射击才发射,然后重置为冷却值。参数说明:子弹速度 600 像素/秒,冷却初始 0.25 秒,拾取射速道具后乘 0.7 并钳制最小 0.08 秒。子弹出屏后标记alive=false统一清理。
4.3 碰撞检测:圆和矩形怎么选
玩家、敌人、子弹都用圆形碰撞,代码简单且手感不差。矩形碰撞适合平台跳跃,射击游戏里圆形更自然。
bool circleHit(float x1, float y1, float r1, float x2, float y2, float r2) { float dx = x1 - x2, dy = y1 - y2; float rr = r1 + r2; return dx * dx + dy * dy <= rr * rr; }逻辑说明:用平方距离避免开方,性能更好。参数说明:玩家半径 16,敌人半径 18,子弹半径 4。子弹和敌人碰撞后,子弹alive=false,敌人hp -= bullet.damage。注意先判子弹再判玩家,避免同一帧子弹穿过多个敌人时重复结算。
5. 避坑与排查:随机刷怪、血量显示和道具最容易翻车的 5 个点
5.1 敌人刷在屏幕外或墙里
现象:敌人出现后玩家打不到,或者敌人卡在边界不动。原因:随机范围用了0到SCREEN_W,但敌人中心点贴边时一半在屏幕外。解决:随机范围改成margin到SCREEN_W - margin,margin取敌人半径加 10。如果地图有障碍物,拒绝采样里再加一条“不与障碍物重叠”的检查。
5.2 血量条不更新或显示负数
现象:敌人被打后血条不变,或者血条反向。原因:hp是整数,ratio计算时用了整数除法,或者hp减到负数没钳制。解决:ratio计算前把hp转成float,并且hp = std::max(0, hp)。另外确认绘制血条时用的是当前帧的hp,不是缓存值。
5.3 加分重复或分数不增加
现象:一个敌人死了加了好几次分,或者分数一直不变。原因:加分写在update的每帧判断里,没有alive标记;或者score是局部变量,没传出来。解决:用alive做一次性结算,score作为全局或GameState成员。排查时在加分处打日志,看一帧内触发几次。
5.4 道具拾取后效果叠加异常
现象:吃了多个射速道具后子弹快得看不见,或者加血超过上限。原因:射速冷却没有下限,加血没钳制。解决:fireCooldown = std::max(0.08f, fireCooldown * 0.7f),加血用std::min。另外道具类型如果随机到重复的,效果会叠加,这是设计问题,不是 bug,但要有上限。
5.5 帧率波动导致敌人瞬移
现象:切窗口或卡顿后,敌人直接穿过玩家或子弹穿过敌人。原因:dt没有钳制,一帧走了几秒的距离。解决:主循环里dt = std::min(dt, MAX_DT),MAX_DT取 0.05。如果还有穿透,子弹碰撞用扫掠检测,即判断线段与圆是否相交,而不是只看当前点。
6. 进阶技巧:用状态机和数据驱动把游戏改到能扩展
跑通上面那套后,游戏已经能玩,但代码会开始膨胀。我一般会做两件事:把玩家和敌人的状态抽成状态机,把敌人属性抽成数据表。状态机解决“敌人什么时候追击、什么时候射击”,数据表解决“加一种敌人要改多少代码”。
先看状态机的最小实现。敌人不需要复杂 AI,三个状态够用:巡逻、追击、攻击。用枚举加switch就行,不用上行为树。
enum class EnemyState { PATROL, CHASE, ATTACK }; void updateEnemy(Enemy& e, float dt, float px, float py) { float d = dist(e.x, e.y, px, py); switch (e.state) { case EnemyState::PATROL: if (d < 300.0f) e.state = EnemyState::CHASE; break; case EnemyState::CHASE: moveToward(e, px, py, e.speed * dt); if (d < 120.0f) e.state = EnemyState::ATTACK; if (d > 350.0f) e.state = EnemyState::PATROL; break; case EnemyState::ATTACK: if (e.fireTimer <= 0) { enemyShoot(e, px, py); e.fireTimer = e.fireCooldown; } if (d > 150.0f) e.state = EnemyState::CHASE; break; } e.fireTimer -= dt; }逻辑说明:状态切换用距离阈值,加一点迟滞避免在边界反复横跳。参数说明:追击距离 300,攻击距离 120,脱战距离 350,攻击冷却按敌人类型设 0.8 到 1.5 秒。这个结构下加“逃跑”状态只需要加一个枚举分支。
再看数据驱动。把敌人属性放到一个结构体数组里,刷怪时按权重随机选类型。
struct EnemyConfig { int maxHp; float speed; int scoreValue; float fireCooldown; int weight; // 权重,越大越常见 }; std::vector<EnemyConfig> ENEMY_TYPES = { { 30, 80.0f, 10, 1.2f, 60 }, // 普通 { 80, 50.0f, 30, 0.9f, 30 }, // 坦克 { 20, 140.0f, 20, 0.6f, 10 }, // 快速 }; EnemyConfig pickEnemyType() { int total = 0; for (auto& c : ENEMY_TYPES) total += c.weight; int r = rand() % total; for (auto& c : ENEMY_TYPES) { if (r < c.weight) return c; r -= c.weight; } return ENEMY_TYPES[0]; }逻辑说明:权重随机让不同敌人出现频率可控,加新敌人只改ENEMY_TYPES,不动刷怪逻辑。参数说明:weight总和不必是 100,按比例即可。坦克血量 80、速度 50,快速敌人血量 20、速度 140,分值按难度给。
验证方法很简单:跑一局,看分数增长曲线是否平滑,敌人类型是否按权重出现。我一般会在调试模式里显示当前敌人数量和类型分布,跑 30 秒看比例对不对。如果某种敌人几乎不出现,检查权重是不是太小,或者随机种子是不是固定了。
最后说一个我自己的习惯:每加一个系统,先写“最小可验证版本”,比如道具先只做加血,跑通掉落、拾取、生效三步,再加射速和伤害。这样出问题时能快速定位是掉落没触发、拾取判定错了,还是效果没生效。射击游戏看着简单,但状态一多,黑匣子式的调试最耗时间。希望帮到你。
本文还有配套的精品资源,点击获取