简介:一份采用C++语言实现的自上而下2D射击游戏完整源码,基于SFML负责图形窗口与输入,Box2D提供刚体物理与碰撞模拟,实现了玩家与敌人的移动对抗、子弹管理、视野阴影和动态光照等玩法效果。代码按地图生成、光照系统、阴影渲染、碰撞响应、血条UI、小地图扫描等功能模块划分,适合已经掌握C++基础、想了解游戏循环与物理引擎接合方式的开发者阅读,也可作为高校图形学或游戏编程课程的课外参考。压缩包共76个文件,包含14个cpp源文件与15个头文件,另有PNG精灵和法线贴图、frag着色器、PGM及PPM格式的地图与光照数据、Makefile构建脚本和简要说明文档,整体仅140KB。通过源码可学习如何组织游戏对象、用着色器渲染阴影、用Box2D处理碰撞,甚至进行地图与光照自定义扩展。项目体量虽小,但引擎集成、资源分离、地图与光照管线齐全,当前已有371人学习,是一份轻量而典型的2D游戏入门练手资源。
1. 一个用 C++ 写出来的自上而下 2D 射击游戏,到底能练到什么
第一次看到 TopDownShooter 这个名字时,我以为是又一个 "hello world" 级别的 Demo,但把代码拉下来跑了一圈才发现,它把一款双摇杆射击游戏最核心的骨架全塞进了一个 C++ 工程里:窗口循环、玩家移动、鼠标瞄准、子弹飞行、碰撞判定、敌人生成和波次管理,每个模块都是能直接拿出去改着玩的状态。如果你正在学 C++,学完语法和 STL 之后正愁没有项目练手,或者你已经写过几个控制台程序、想看看游戏循环在真实工程里怎么组织,这个项目正好卡在那个"再简单一步就无聊、再复杂一步就劝退"的位置上。它不是那种只有一张地图和几个方块的演示,而是一个能让你清楚看到"输入 -> 更新 -> 渲染 -> 碰撞 -> 生成"整条链路怎么落在代码里的完整小游戏。
2. 技术栈与工程结构:为什么用 SFML,目录怎么摆
2.1 技术选型解析
项目基于 C++ 实现图形渲染与事件处理,视觉呈现和输入读取都交给了一套轻量的媒体库。说实话,在 2D 游戏这个场景里,能做同样事情的库有不少,但 TopDownShooter 选它有几个很实际的理由。第一是 API 足够直观,创建窗口、加载纹理、绘制精灵这些操作几乎和你脑子里想的步骤一一对应,不需要先理解复杂的场景图或组件系统;第二是它原生提供窗口事件和输入轮询,鼠标位置、键盘状态都是开箱即用,这对射击游戏来说是刚需;第三是它跨平台,Windows、macOS 和主流 Linux 发行版都能编,不至于让下载源码的读者卡在环境这关。
这套选型和游戏本身的规模是匹配的。游戏内没有复杂的骨骼动画、没有物理引擎、没有网络同步,如果用重型引擎反而要把大量精力花在绕开引擎的默认行为上。反过来,如果你一上来就想做 3D 或大型 RPG,那这个项目里的技术栈就不太合适了——它更像一个聚焦在"游戏逻辑如何组织"的样板间,渲染层面刻意保持朴素。
2.2 目录结构逐层拆解
整个工程的文件组织走的是"按模块分文件、按类型分文件夹"的路子,拿到手不用猜,就能知道哪个文件管什么事。下面是我在工程里看到的典型布局:
TopDownShooter/ ├── CMakeLists.txt ├── assets/ │ ├── textures/ │ │ ├── player.png │ │ ├── enemy.png │ │ └── bullet.png │ └── fonts/ │ └── pixel.ttf ├── config/ │ └── settings.ini └── src/ ├── main.cpp ├── Game.cpp ├── Game.hpp ├── Player.cpp ├── Player.hpp ├── Enemy.cpp ├── Enemy.hpp ├── Bullet.cpp ├── Bullet.hpp ├── WaveManager.cpp ├── WaveManager.hpp └── CollisionUtil.hppsrc 目录下每个类基本遵循"头文件放声明、源文件放实现"的标准 C++ 实践,而 CollisionUtil.hpp 是纯工具函数集合,不依赖具体游戏对象,可以独立复用。assets 目录只放资源和字体,config 目录单独放配置文件——这样设计的好处是,美术资源和代码逻辑完全解耦,你替换一张贴图或调整一组参数时不需要翻源码。
从编译角度看,CMakeLists.txt 负责把上面的文件组织成一个可执行文件。它做的事情很常规:指定 C++ 标准、查找媒体库的头文件和库文件、把所有 cpp 文件编译链接成目标程序。构建产物和源码分开,不会出现"编译完之后源码目录被 .o 文件塞满"的情况。
3. 核心模块拆解:一个请求从进入到返回的完整链路
3.1 游戏主循环:固定时间步长怎么写
游戏循环是整份资源里最值得先读的一段代码。常见的错误写法是"每帧处理多少就更新多少",这在 60Hz 显示器上好像没问题,但一旦刷新率波动就会出现玩家移动忽快忽慢、子弹穿过敌人这种玄学问题。TopDownShooter 里的做法是固定时间步长,把逻辑更新和渲染完全分开,代码大概长这样:
// Game.cpp 中主循环的核心结构 void Game::run() { const sf::Time step = sf::seconds(1.0f / 60.0f); // 每帧固定1/60秒 sf::Clock clock; sf::Time accumulator = sf::Time::Zero; while (window.isOpen()) { handleEvents(); accumulator += clock.restart(); // 用累加器凑够一个步长才更新一次逻辑 while (accumulator >= step) { update(step.asSeconds()); accumulator -= step; } render(); } }这里的关键是 accumulator 累加器。每次外层循环先算出自上次刷新过去了多久,然后把这个时间累加到一个变量里,只有累加到超过 1/60 秒才执行一次 update。这样即使某次渲染间隔突然变成 1/30 秒,update 也只会执行两次,而不会因为帧率跳变导致物理速度错乱。render 函数则每帧都执行,不做任何逻辑计算,只负责把当前游戏状态画到屏幕上。
参数 1.0f / 60.0f 是可以按游戏类型调整的。比如格斗游戏官方对战平台对帧率同步要求极高,通常锁定 60;而塔防类或回合制游戏可以降到 30,给 CPU 留更多余量。如果你把 step 从 1/60 改成 1/30,所有基于它计算的移动速度都会自动翻倍时间粒度,这时候要同步把速度数值减半才保持原速,这个换算关系建议在修改时直接写进注释里,不然下次自己都容易踩坑。
3.2 玩家移动:速度、方向与 deltaTime
玩家类包含位置、速度、方向这些基本属性。移动逻辑没有用复杂的物理模型,而是在 update 里直接读取键盘状态,累加出目标方向向量,再乘以速度和时间步长。代码很直白:
// Player.cpp 的 update 函数 void Player::update(float dt) { sf::Vector2f direction(0.f, 0.f); if (sf::Keyboard::isKeyPressed(sf::Keyboard::W)) direction.y -= 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::S)) direction.y += 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::A)) direction.x -= 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::D)) direction.x += 1.f; // 防止斜向移动时速度变成 1.414 倍 if (direction.x != 0.f && direction.y != 0.f) direction /= std::sqrt(2.f); position += direction * moveSpeed * dt; sprite.setPosition(position); }移动方向先收集后归一化,这是很多新手最容易忽略的细节:如果不做斜向速度修正,同时按 W 和 D 时玩家移动速度会明显比单纯按 W 快约 41%,手感会发飘。这里除以根号 2 的操作本质上是把二维向量长度压回 1。
moveSpeed 这个参数定义在头文件或配置文件里,单位为"像素每秒"。dt 从外部传入,由主循环提供,这保证了不管渲染帧率怎么变,实际移动距离只由真实流逝时间决定。为了让移动手感更像双摇杆射击游戏,通常会让移动速度在 200 到 350 像素/秒之间,如果你的地图很大或角色很大,可以适当上调到 400 以上,但超过这个值后瞄准会变得费力,敌人高速穿越时玩家反应不过来。
3.3 子弹发射:从鼠标到弹道的完整映射
射击逻辑是射击游戏的灵魂,TopDownShooter 的实现思路很干净:鼠标在屏幕上的位置是玩家视野中心,子弹沿"玩家当前位置 -> 鼠标位置"的方向直线飞行。发射时记录冷却时间,防止按住左键变成机关枪一样无间隔扫射:
// Game.cpp 中处理射击的关键片段 void Game::update(float dt) { shootCooldown -= dt; if (shootCooldown <= 0.f && sf::Mouse::isButtonPressed(sf::Mouse::Left)) { // 把鼠标的屏幕坐标转换成游戏世界坐标 sf::Vector2f mouseWorld = window.mapPixelToCoords(sf::Mouse::getPosition(window)); // 方向向量 = 鼠标位置 - 玩家枪口位置 sf::Vector2f dir = mouseWorld - player.getPosition(); float length = std::sqrt(dir.x * dir.x + dir.y * dir.y); if (length > 0.f) { dir /= length; // 归一化成单位方向 bullets.push_back( Bullet(player.getPosition() + dir * 20.f, dir * bulletSpeed)); } shootCooldown = fireRate; } // ... 其他更新逻辑 }代码里有两处值得细说。第一处是 window.mapPixelToCoords,这个函数是媒体库提供的坐标转换接口,因为鼠标事件返回的是屏幕像素坐标,而游戏里有相机移动或窗口缩放时屏幕坐标不等于世界坐标,不做转换会导致鼠标看似指着敌人、子弹却飞向偏离位置。第二处是 Bullet 构造函数接收 dir * bulletSpeed,这意味着子弹内部存储的是一个带速度的向量,后续每帧更新只需 position += velocity * dt,不需要再重复计算方向。
fireRate 控制射击间隔,单位也是秒。0.2 到 0.25 秒是比较中庸的手感,偏射击感的游戏会设 0.15 左右,追求弹幕效果可以降到 0.05。每次发射时从玩家位置偏移 20 像素是防止子弹一出生就和玩家身体重叠、直接触发碰撞造成自伤,这个偏移量应该大于玩家和子弹的碰撞半径之和,具体值可以在调试时打印碰撞信息来校准。
3.4 子弹与敌人的碰撞判定
碰撞检测用的不是矩形包围盒,而是圆形。原因很简单:圆形判定在四向移动和斜向穿插的场景里更稳定,旋转时不会出现矩形棱角触发虚假碰撞的情况。核心代码如下:
// CollisionUtil.hpp 中的圆形碰撞检测工具 bool circlesOverlap(const sf::Vector2f& centerA, float radiusA, const sf::Vector2f& centerB, float radiusB) { float dx = centerA.x - centerB.x; float dy = centerA.y - centerB.y; float distSquared = dx * dx + dy * dy; float radiusSum = radiusA + radiusB; return distSquared <= radiusSum * radiusSum; }这里做了个很经典的优化:比较距离平方而不是直接开根号求距离。因为开根号是一个相对昂贵的数学运算,而平方后的比较结果完全一致。在高频碰撞循环里,这个优化能省下不少 CPU 时间。两圆相切时判定为碰撞,这是 gaming 里通行的宽容做法,可以掩盖高速移动带来的轻微穿模。
具体到游戏逻辑层,每帧拿每个子弹去遍历所有敌人,一旦命中就把这个子弹标记为待删除,同时敌人血量减少,血量为零时敌人被移除。为了保证遍历时一边删除一边继续遍历不出问题,常见做法是先记录待删除的下标,循环结束后统一处理,而不是在碰撞发生时直接 erase,这个细节在 Enemy 数量多时会直接决定程序崩不崩。
4. 敌人生成、波次管理与游戏状态:让一局游戏转起来
4.1 波次生成器:用延迟和计数控制节奏
WaveManager 单独抽成了一个类,职责是"生成敌人"和"判断换波"。它内部维护着当前波次编号、延迟计时器、每波敌人总数、以及当前场上存活敌人的数量。核心逻辑可以用下面这段伪代码看懂:
// WaveManager.cpp 的 update 与生成逻辑 void WaveManager::update(float dt) { timer += dt; aliveCount = countAliveEnemies(); // 场上敌人清空且已等待间隔时间,进入新一波次 if (aliveCount == 0 && waveTimer >= waitBetweenWaves) { currentWave++; int count = 2 + currentWave * 2; // 波次越高敌人越多 spawnWave(count); waveTimer = 0.f; } } void WaveManager::spawnWave(int count) { for (int i = 0; i < count; i++) { sf::Vector2f spawnPos = randomEdgePosition(); Enemy newEnemy(spawnPos, playerPosition); enemies.push_back(newEnemy); } }这段代码把游戏节奏分成了两段:场上敌人没清完时,游戏处于"战斗"状态;清完后延迟几秒,再进入下一波。2 + currentWave * 2是一个简单的难度曲线,第一波 4 个,第二波 6 个,依次递增。如果你觉得递增太快或太慢,可以改成1 + currentWave或者3 + currentWave * 3,自己试几局就能找到舒服的节奏。
randomEdgePosition 的功能是生成屏幕边缘的随机坐标,保证敌人不会凭空出现在玩家旁边。实现方式是定义四个边上的生成点,用随机数选边和位置,这样玩家能看到敌人从画面边界涌进来,预警时间充足。
4.2 敌人 AI:追踪、移动与死亡
敌人的 AI 在这里刻意保持简单:向玩家当前方向匀速移动,不做绕障碍,不做包抄。这种"僵尸式追踪"对一个入门项目来说够用了,代码只有几行:
// Enemy.cpp 的移动逻辑 void Enemy::update(float dt, const sf::Vector2f& playerPos) { sf::Vector2f dir = playerPos - position; float len = std::sqrt(dir.x * dir.x + dir.y * dir.y); if (len > 0.f) dir /= len; position += dir * speed * dt; sprite.setPosition(position); }所有敌人的移动完全一致,因此战场上会呈现出一群敌人排着队拥挤向玩家的画面。如果你希望敌人之间有区分度,可以给每个敌人的 speed 设置一个小幅随机偏移,例如speed = baseSpeed + random(-10, 10),这样即使 AI 逻辑一样,移动快慢的差异也能让场面更自然。敌人在血量清零后会被标记死亡,由外层逻辑移除对象并增加玩家得分——这个分数可以读取后显示在窗口标题栏或画布上。
4.3 游戏状态与 UI:玩家死亡和重新开局
游戏状态管理负责切换运行中、暂停、游戏结束三种状态。实现上用一个枚举加开关控制,比如在 main 循环里检查状态再决定调用哪部分逻辑。当玩家血量归零时,保存最高分,显示"Game Over"文本,并提供按 R 重新开始的功能。
UI 层用文本组件渲染分数和血量。字体从 assets/fonts 加载,音效如果工程里没有,可以自行补上——媒体库的音频模块直接加载 wav 格式。注意配置里写清楚:UI 坐标系和世界坐标系是两套,绘制文本前需要把视图切换回默认视图,否则地图滚动时 UI 会跟着飘走。很多把这个项目拿去做二次开发的人都在这一步翻过车。
5. 编译、运行与避坑:最常遇到的五个问题排查
5.1 环境与依赖准备
在动手编译之前,先把依赖确认好。这个项目依赖媒体库的图形、窗口和系统模块,在不同系统下的安装命令不一样,CMake 编译时通过find_package查找头文件和库文件。我一般建议先跑一遍 CMake,让它自动探测,如果报find_package失败,那就是依赖没装全。
构建步骤很常规:在项目根目录新建 build 文件夹,进入后执行cmake ..,然后根据系统选择生成 Makefile 项目或 Visual Studio 工程。编译成功后直接运行生成的可执行文件,窗口会自动弹出。如果执行时提示找不到字体文件或纹理文件,多半是工作目录不对,需要把可执行文件的工作目录切到项目根目录,或者把资源路径写成绝对路径。
5.2 帧率导致移动速度忽快忽慢(玄学级新手坑)
现象:同一台机器 60Hz 显示器下跑得好好的,外接 144Hz 显示器后角色跑得飞快,子弹像瞬移。
原因:代码里如果直接用每帧经过的时间作为 dt,帧率越高 dt 越小,速度看起来没变,但整体更新次数变多了,如果不加累计器就会出现速度跟刷新率挂钩。另一种可能是直接把逻辑写在了渲染循环里,导致垂直同步关闭时逻辑执行频率直冲几百。
解决:用固定时间步长 + 累加器,或者每帧计算真实 deltaTime 并传给 update。我前面展示的主循环就做了这个事,如果你的分支代码没有,先把它补上。
5.3 子弹高速穿过敌人(碰撞检测失效)
现象:子弹飞行速度调到 800 像素/秒之后,偶尔出现穿过敌人但没造成伤害。
原因:每帧更新一次位置后做碰撞检测,但上一帧子弹在敌人左边,下一帧瞬间跳到敌人右边,两帧之间完全没有和敌人重叠的坐标点。这种 tunneling 问题在低速时几乎遇不到,速度越大越明显。
解决:要么把碰撞半径人为扩大(简单但会夸大命中范围),要么把一步拆成多次子步进,例如一帧内拆成 4 次,每次只走 1/4 路程并检测碰撞。TopDownShooter 里子弹速度不算太高,多数情况用半径补偿就够。
5.4 纹理闪烁或画面发糊
现象:玩家贴图边缘在移动时不停闪烁,或者旋转后锯齿感特别重。
原因:默认纹理过滤是平滑模式,缩放非整数倍时会产生模糊;闪烁则可能是因为每帧都把精灵绘制在浮点坐标上,渲染管线在做像素对齐时产生抖动。
解决:加载纹理后调用setSmooth(false)强制使用最近邻采样;绘制前把坐标从 float 转成 int,或者启用像素对齐。
5.5 内存管理翻车:new 出来的敌人找不到 delete
现象:游戏运行几分钟后内存肉眼可见地疯涨,最后窗口卡死。
原因:敌人和子弹在动态创建时直接使用裸指针new Enemy(...),对象被移出容器后没有 delete,泄漏积累到系统内存吃满就崩了。
解决:把容器类型从std::vector<Enemy*>换成std::vector<std::unique_ptr<Enemy>>或直接使用std::vector<Enemy>配合移动语义,让对象生命周期自动管理。如果工程里已经用了裸指针,至少也要在 erase 之前手动 delete,并在流程注释里标明"此处必须释放内存"。
6. 二次开发:从改配置到加武器切换,怎么把这份资源变成自己的
当你把上面的代码完整跑通,接下来最大的乐趣就是往里面加东西了。我给这份资源加的第一个功能是武器切换,思路是把目前散落在 Game 类里的射击参数收拢到一个 WeaponConfig 结构体里,然后给玩家对象加一个当前武器序号:
// 扩展思路示例:武器配置与切换 struct WeaponConfig { float fireRate; float bulletSpeed; int damage; std::string bulletTexture; }; const std::vector<WeaponConfig> weaponTable = { {0.25f, 600.f, 1, "bullet.png"}, // 手枪 {0.10f, 450.f, 1, "bullet_small.png"}, // 冲锋枪 {0.40f, 800.f, 3, "bullet_heavy.png"}, // 狙击枪 };切换武器时只需要更新当前武器的索引并重置冷却时间,子弹的颜色和速度都从配置读取。这样做的好处是以后想加第五第六把武器,只要往 weaponTable 里加一行即可,不用动任何逻辑代码。更进一步,你可以把武器表搬进 config/settings.ini 里,实现完全数据驱动。
第二个值得动手的方向是给敌人加行为差异。比如让敌人分成追踪型和远程射击型,远程型在距离玩家小于某个阈值时停止移动并朝玩家开火,这就是一个迷你状态机。代码结构上可以给 Enemy 加一个枚举成员 state,在 update 里 switch 分支处理不同行为,加上一个攻击间隔计时器。
每做一次改动,我建议走一遍"编译 -> 运行 -> 调整参数"的流程链:先确保无警告编译通过,再进游戏试玩 5 分钟,重点观察碰撞手感、子弹速度、难度曲线三个维度。改完代码后最容易忘的就是同步调整配置参数,我自己的习惯是每次改动都先跑一遍cmake --build build,确认没有遗漏头文件依赖或拼写错误,然后再开游戏。 有一次我改完武器切换后忘了把新纹理复制到 assets 目录,结果运行时报错弹窗,排查了半天才发现是资源路径问题。从那以后我每次改完配置都会先检查资源文件是否齐全,再进游戏验证。希望这些经验能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取