☰
C++与SFML实战:从零开发俯视角射击游戏核心机制
2026/10/20 22:29:07 网站建设 项目流程

简介:一份基于C++与SFML、Box2D引擎实现的简易2D俯视角射击游戏项目,核心亮点是“视野阴影”机制,适合想要学习2D游戏循环、光照映射、地图拼装或物理碰撞处理的开发者参考。资源包共76个文件,压缩后约140KB,包含头文件与源码(h/cpp)、精灵图与法线贴图(png)、光照/阴影数据(pgm/ppm)、着色器(frag)以及Makefile等,其中frag着色器用于光照渲染,pgm/ppm用于测试阴影与光照地图,整体目录结构清晰,便于按模块阅读。已有371人学习。项目自带Linux构建说明并附带Windows二进制包,可跨平台快速试玩,同时内部提供normalmaps、spriteMap等美术素材,适合作为课堂作业或独立游戏练手项目,能直观体会视野系统与物理碰撞的集成方式。

1. 先看成果:一个用 C++ 写出来的俯视角射击游戏

打开编译好的程序,一个 1280x720 的窗口弹出来。黑色背景里有一个白色三角形,你按 WASD 控制它移动,鼠标移动决定枪口指向,按住左键就会连续射出小圆子弹。没过几秒,屏幕四边开始冒出一批批彩色的圆形敌人,它们会朝你聚拢,被命中后闪烁一下消失,左上角的分数跟着上涨,血量被碰到就扣一格,直到归零游戏结束。这就是 TopDownShooter,一个用 C++ 和 SFML 从零写出来的自上而下 2D 射击游戏。它特别适合刚学完 C++ 基础、还没写过“完整游戏循环”的开发者,也适合拿来做课程设计或入行游戏开发的第一个练手工程。虽然简单,但玩家控制、鼠标瞄准、自动射击、敌人 AI、碰撞检测、波次生成这些最核心的玩法模块都在,后面我会按“先看架构、再拆代码、然后避坑、最后改造”的顺序一层层讲明白。

2. 技术选型与目录结构:SFML 让 C++ 游戏开发不再从零造轮子

2.1 为什么是 SFML:不写引擎,也能落地窗口、输入和渲染

C++ 标准库是不管窗口、输入事件和图片加载的,做游戏要么选一个轻量图形库,要么自己封装操作系统 API,后者对一个简单射击游戏来说纯属给自己挖坑。市面上 C++ 2D 游戏常选的库有 SFML、SDL、raylib,这个工程选的是 SFML,具体是 SFML 2.6 + CMake + C++17 的组合。原因一句话就够:SFML 的 API 比 SDL 友好,sprite、text、shape 都是一等公民,不需要手动管理底层图形上下文,写“移动、朝向、渲染”这类游戏原语时心智负担小,而且中文资料和示例都很全,遇到问题搜起来快。

SFML 分成 system、window、graphics、audio 四个核心模块,这个项目只用了前三个。system 负责时钟、向量和字符串工具;window 负责窗口创建、事件轮询和 OpenGL 上下文;graphics 承担精灵、形状、文本和视图渲染。正因为模块足够薄,我们可以把“游戏自己的 Player、Bullet、Enemy 类”和“底层渲染”干净分离,后面二次开发时不需要碰库本身。

如果你在自己的环境复现,常见的安装方式是 macOS 上brew install sfml,Ubuntu 上sudo apt install libsfml-dev,Windows 则是下载 SFML 官方预编译包,解压后把路径交给 CMake。窗口创建部分通常是这样的:

sf::RenderWindow window(sf::VideoMode(1280, 720), "TopDownShooter"); window.setVerticalSyncEnabled(true);

这样就把游戏窗口固定下来。VideoMode的两个参数是宽和高,后面敌人从四边生成、子弹越界回收都依赖这个窗口尺寸,改分辨率时这几个常量和 Spawner 的边界判断要一起改。

2.2 目录结构:模块按游戏对象拆分,一个类管一件事

拿到工程后先别急着编译,扫一眼目录结构会省很多事。典型的组织方式不是把所有代码塞进一个 main.cpp,而是按“游戏对象 / 逻辑层 / 入口”拆开:

TopDownShooter/ ├─ CMakeLists.txt # 构建配置,找 SFML、指定 C++17 标准 ├─ src/ │ ├─ main.cpp # 入口,创建 Game 实例并调用 run() │ ├─ Game.h / Game.cpp # 窗口、主循环、对象容器、碰撞仲裁 │ ├─ Player.h / Player.cpp # 玩家位置、速度、朝向、血量 │ ├─ Bullet.h / Bullet.cpp # 子弹位置、速度、存活标记 │ ├─ Enemy.h / Enemy.cpp # 敌人位置、追击 AI、血量 │ ├─ Spawner.h / Spawner.cpp# 波次计时、敌人刷出逻辑 │ └─ Utility.h # 向量归一化、两点距离等小工具 └─ assets/ └─ textures/ # 预留贴图目录,跑起来先用纯色形状

这个结构的好处是职责单一:改玩家行为完全不影响 Spawner;给 Bullet 加属性只需要碰它自己的类。Game 持有std::vector<Bullet>和std::vector<Enemy>,每帧依次调用它们的update(dt)和draw(window),对象之间的交互在 Game 里统一仲裁,不互相依赖。

assets/textures在初始版本里可以是空的,因为跑通逻辑用的是sf::RectangleShape和sf::CircleShape画纯色图形,不需要外部美术资源。这么设计是为了先保证程序能编译能跑,省掉“找不到贴图路径”这种最常见翻车点;后面换成贴图时,只要在对应类的draw里填上纹理和精灵即可,不影响移動和碰撞代码。

2.3 主循环与帧同步:速度必须乘上 deltaTime

游戏循环是所有游戏的心脏。TopDownShooter 的 Game::run() 大概长这样:

void Game::run() { sf::Clock clock; while (window.isOpen()) { float dt = clock.restart().asSeconds(); // 两帧之间真实流逝的秒数 if (dt > 0.05f) dt = 0.05f; // 掉帧兜底,防止大跳后碰撞穿透 processEvents(); // 处理关闭、键盘、鼠标事件 update(dt); // 更新所有游戏对象 render(); // 清屏、绘制、翻转缓冲 } }

这里最容易被新手忽略的是dt。如果不乘它,直接在 update 里写player.move(speed),那同一份代码在 60Hz 显示器上敌人慢得像散步,在 165Hz 显示器上快得像开挂。乘上 dt 后,所有移动量单位都变成“每秒移动多少像素”,刷新率再高也稳定。dt上限 0.05 秒的兜底也很关键:场景里子弹很多时,如果某一帧卡到 100ms,子弹单帧位移过大就可能直接穿过敌人身体,这种“透明墙”现象多半就是这么来的。

update 里只改状态,不画东西;render 里只画东西,不改状态。更新与渲染分离这个习惯,能帮你在调试时快速定位是“算错了”还是“画错了”,后面所有逻辑排查都依赖这个划分。

3. 核心代码拆解:玩家移动、子弹碰撞与敌人 AI 的实现细节

3.1 玩家移动与鼠标瞄准:向量归一化不能忽略零向量

玩家模块负责两件事:键盘移动和鼠标朝向。先看移动部分:

void Player::update(float dt) { sf::Vector2f moveDir(0, 0); if (sf::Keyboard::isKeyPressed(sf::Keyboard::W)) moveDir.y -= 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::S)) moveDir.y += 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::A)) moveDir.x -= 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::D)) moveDir.x += 1.f; // 斜着走时速度不能是 sqrt(2)*speed,先归一化 if (length(moveDir) > 0.f) { moveDir = normalize(moveDir); position += moveDir * speed * dt; // speed 是每秒像素数 } // 鼠标位置来自窗口坐标系,这里转成世界坐标后算出朝向 sf::Vector2f aimVec = mousePos - position; if (length(aimVec) > 0.f) { rotation = atan2f(aimVec.y, aimVec.x); } }

moveDir把四个键盘状态映射成一个方向向量,四个 if 是 2D 游戏最直觉的写法。归一化保证了移动速度恒定:同时按 W 和 D 时,原始向量长度是 1.414,如果直接拿它乘速度,玩家斜着走会比直线快四成,手感会很怪。mousePos由 Game 在事件回调里更新,使用的是window.mapPixelToCoords(sf::Mouse::getPosition(window)),这步不能省——窗口有标题栏或被拖动时,原始鼠标坐标和游戏世界坐标是有偏移的。

atan2f返回的是弧度,之后渲染三角形精灵时要用sprite.setRotation(rotation * 180.f / M_PI)转成角度。注意 sf::Sprite 默认的正方向是朝上的,而 atan2 算出来的 0 弧度是朝右的,所以美术素材要么做成朝右,要么给旋转角加 90 度,这是一个典型的“看起来转了但方向不对”的问题点。

参数说明:speed 是 float,单位像素/秒,200~300 比较合适;dt 是 Game 传进来的帧时间。这个函数每帧被调用,不关心是 30Hz 还是 120Hz,只关心过去了几分之一秒。

3.2 子弹发射与碰撞检测:射击间隔模拟射速

子弹是这个游戏交互频率最高的模块。先定义轻量的子弹结构体,再放进容器统一管理:

struct Bullet { sf::Vector2f position; sf::Vector2f velocity; float radius; bool alive; };

Game 里的生成和推进逻辑如下:

void Game::shoot(const sf::Vector2f& from, const sf::Vector2f& aimDir) { sf::Vector2f normDir = normalize(aimDir); Bullet b; b.position = from + normDir * 20.f; // 从玩家中心前方一点出来,避免自撞 b.velocity = normDir * bulletSpeed; // bulletSpeed 默认 600 像素/秒 b.radius = 4.f; b.alive = true; bullets.push_back(b); } void Game::update(float dt) { shootTimer -= dt; if (sf::Mouse::isButtonPressed(sf::Mouse::Left) && shootTimer <= 0.f) { shoot(player.position, aimDir); shootTimer = 0.15f; // 每秒约 6.7 发 } for (auto& b : bullets) { if (!b.alive) continue; b.position += b.velocity * dt; // 越界回收:只标记死亡,不立刻删除 if (b.position.x < -20 || b.position.x > 1300 || b.position.y < -20 || b.position.y > 740) b.alive = false; } detectCollisions(); }

子弹没有做成多态类,而是用轻量结构体放进 vector,这是小游戏里常见的取舍:不需要复杂的继承体系,数据紧凑还方便遍历。shootTimer是射速控制,所有射击游戏都得有它,否则按住鼠标会变成每帧打一发,帧率高射速就快,手感全无。子弹出生点偏移 20 像素是为了避免子弹刚生成就和玩家自己的碰撞圆重叠。

越界判定的边界值比窗口尺寸略大 20 像素,目的是让子弹完全滑出屏幕再回收,视觉上不会突然消失。这里只把alive置 false,清理再用 erase-remove 惯用法,避免在循环中删元素导致迭代器失效。

碰撞检测使用圆-圆距离,简单够用:

void Game::detectCollisions() { for (auto& b : bullets) { if (!b.alive) continue; for (auto& e : enemies) { if (!e.alive) continue; float dist = length(e.position - b.position); if (dist < e.radius + b.radius) { b.alive = false; e.hp -= 1; if (e.hp <= 0) { e.alive = false; score += 10; } break; // 本帧内一颗子弹只命中一个敌人 } } } bullets.erase( std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b){ return !b.alive; }), bullets.end()); enemies.erase( std::remove_if(enemies.begin(), enemies.end(), [](const Enemy& e){ return !e.alive; }), enemies.end()); }

双层 for 循环复杂度是 O(N*M),对几十颗子弹和几十个敌人完全够。e.radius + b.radius是碰撞半径和,比如敌人半径 22、子弹半径 4,视觉上碰撞发生在边缘接触,手感不苛刻。break保证同一发子弹在当前帧不会连续穿多个重叠敌人;而alive标记会随这轮循环往后传递,下一帧 update 里if (!b.alive) continue会跳过它,所以实际上实现了“一弹最多命中一个敌人”。清理时std::remove_if将死亡对象换到尾部,再统一 erase,比循环里 erase 安全得多,那个写法崩溃概率极高,属于常见翻车点。

3.3 敌人 AI 与波次生成:让怪物看起来“有脑子”

敌人的移动逻辑不复杂,但要让它们不挤成一坨,还需要一点随机偏移。单个敌人的 update 如下:

void Enemy::update(float dt, const sf::Vector2f& targetPos) { sf::Vector2f toTarget = targetPos - position; if (length(toTarget) < 5.f) return; // 已经贴脸,等待碰撞扣血 sf::Vector2f dir = normalize(toTarget); position += dir * speed * dt; // 用正弦偏移模拟摇摆走位,避免所有敌人都走同一条直线 position.y += sin(timeSinceSpawn * 2.5f) * 8.f * dt; }

这三点值得说。一是直接朝玩家位置追,不绕障碍,因为当前地图是空场,没有墙,所以“直线追目标”就是正确 AI;将来加了障碍物才需要寻路。二是正弦摆动不是为了好看,而是打破同步:敌人从四边刷出后,如果不加偏移,它们会在同一帧排成直线,子弹一穿多,难度瞬间失衡。timeSinceSpawn在生成时初始化为 0,每帧累加 dt,用于取 sin 的值。

波次生成放在 Spawner 里,每 3 秒一波,敌人数量线性增长:

void Spawner::update(float dt) { waveTimer += dt; if (waveTimer >= waveInterval) { // 默认 3 秒一波 waveTimer = 0.f; waveCount++; int count = 2 + waveCount; // 第 1 波 3 个,第 2 波 4 个,递增 for (int i = 0; i < count; i++) { Enemy e; e.position = randomEdgePos(); // 在屏幕四边随机选一个点 e.speed = 40.f + waveCount * 6.f; // 每波快一点,上限封在 120 e.hp = 1 + waveCount / 5; // 每 5 波敌人变肉 enemies.push_back(e); } } }

waveTimer累加而不是归零后再计,保证波次间隔严格等于 waveInterval。敌人数量用线性增长,第 1 波 3 个、第 10 波 12 个,新手不会瞬间被围死,中后期又有压力。randomEdgePos是 Utility 里的一个工具函数,随机选屏幕四条边上的一个坐标;它的边界值要和窗口尺寸保持同一套常量,否则可能出现敌人卡在屏幕边缘外一层的情况。

Spawner 和 Enemy 是解耦的:Spawner 只负责“什么时候刷、刷多少个、初始属性多少”,不关心敌人内部如何移动;Enemy 每帧自己朝玩家追。这样以后做 Boss 波时,只需要替换生成逻辑里的 Enemy 为新的子类或新结构体,不影响现有代码。

4. 常见问题排查:从编译报错到碰撞失效的五个坑

这一章我把实际下载、编译、调这个项目时最容易遇到的麻烦按阶段整理出来。每条都按“现象 → 原因 → 解决”展开。

4.1 编译阶段:找不到库与链接顺序

坑 1:CMake 找不到 SFML

现象:CMake 报Could NOT find SFML (missing: SFML_Graphics SFML_Window SFML_System),明明已经安装了 SFML 还是报错,Windows 下面最常见。

原因:SFML 的 Windows 版本是绿色解压包,没有写入系统注册表,CMake 默认搜索路径覆盖不到。Linux 通过包管理器安装时路径在 /usr 下能被搜到,Windows 必须显式告诉 CMake “SFML 在哪”。

解决:在 CMakeLists.txt 顶部加一个缓存路径,或者命令行指定:

cmake -DCMAKE_PREFIX_PATH="D:/lib/SFML-2.6.2" ..

同时确认 SFML 库位数和编译器一致:用 32 位 SFML 配 64 位 MinGW,链接错误会非常古怪,属于浪费两小时的玄学问题。我在项目里额外加了一句:

set(SFML_DIR "D:/lib/SFML-2.6.2/lib/cmake/SFML" CACHE PATH "SFML cmake config dir")

它不是万能钥匙,但至少让你在搜索失败时第一时间想到路径,而不是怀疑库坏了重装三遍。

坑 2:链接阶段几百个未定义引用

现象:编译顺利通过,链接时报undefined reference to sf::Sprite::setTexture(...)一类的一大串错误。

原因:静态库链接顺序敏感。比如写成target_link_libraries(项目 sfml-graphics sfml-window sfml-system),链接器从左往右扫描,Graphics 引用了 Window 里的符号,但 Window 已经在 Graphics 之前被扫完,符号就丢了。旧版 SFML 手动拼-lsfml-graphics -lsfml-window时非常容易触发。

解决:不要手拼目标名,用 find_package 提供的导入目标:

target_link_libraries(TopDownShooter PRIVATE SFML::Graphics SFML::Window SFML::System)

导入目标自带依赖关系,顺序由 CMake 内部处理,从此再也不用关心链接顺序。

4.2 运行阶段:帧率与中文编码

坑 3:同一份代码在 165Hz 屏幕上快了一倍

现象:办公室和家里跑同一份代码,游戏速度肉眼可见不同,高刷新率屏上玩家狂飙,低刷新率屏上像慢动作。

原因:早期版本没做 dt 修正,每帧移动固定像素,比如position.x += 5.f,帧率越高单位时间移动次数越多。

解决:所有运动逻辑改成position += direction * speed * dt,并让 Game 循环用clock.restart().asSeconds()取真实时间。如果已经这样做还是不同步,检查帧率控制:常见做法是只开垂直同步:

window.setVerticalSyncEnabled(true);

不支持垂直同步的环境再补window.setFramerateLimit(60);。注意两个都开时系统会取两者的最低限制,反而让帧率在临界点抖动,dt 不稳定。

坑 4:HUD 的中文显示成方框

现象:用 sf::Text 显示"得分: " + std::to_string(score),中文全部变成方块或问号。

原因:sf::Text 要能显示字符需要对应的字体文件,并且字符编码要和字体匹配。如果你用 UTF-8 源码但加载的系统字体不包含中文字形,就会缺字。锅在字体和编码,不在 SFML 本身。

解决:最省事的是 UI 先用英文,写成"Score: "。如果必须中文,显式加载一个中文字体:

sf::Font font; font.loadFromFile("assets/fonts/msyh.ttf"); // 注意使用英文路径 text.setFont(font); text.setString(sf::String::fromUtf8("得分"));

同时让源文件保持 UTF-8 无 BOM 编码,MSVC 编译时建议加/utf-8编译选项。这个坑不解决,你会在调字体上花掉比写逻辑更多的时间。

4.3 逻辑阶段:高速子弹的碰撞失效

坑 5:低帧率时子弹穿过敌人

现象:敌人很密集或系统卡顿的瞬间,子弹明明飞过敌人中心,却没有任何命中反馈。

原因:碰撞检测是在每个帧采样点判断的,如果两帧之间子弹移动距离大于敌人的直径,那子弹在采样时刻分别位于敌人两侧,物理上“穿过”了敌人。比如子弹速度 600 像素/秒,帧率只有 10Hz 时单帧位移 60 像素,而敌人直径约 44 像素,就出现了检测真空。

解决:给 dt 加上限是最快的止血方案:

if (dt > 0.05f) dt = 0.05f;

这让单帧位移永远小于子弹速度乘以 0.05,也就是 30 像素,小于最小碰撞直径的一半。如果将来给游戏加了高速武器,子弹速度超过 1000 像素/秒,建议再做扫掠检测:用小步进把一帧拆成 5~10 段逐段检测,或者手动计算子弹移动线段与圆心之间的距离。两个方法都不复杂,但能在高射速、低帧率的组合下保证手感稳定。

5. 二次开发思路:从加一把新武器到动态难度曲线

想让这个 Demo 变成你自己的,改动最划算的入口是武器系统。现在 Game 里只有一种子弹,射速固定、伤害固定,扩成多武器非常自然。

先在 Bullet.h 旁边定义一个武器配置:

enum class WeaponType { Pistol, Shotgun, RapidFire }; struct WeaponConfig { float fireInterval; // 两发之间间隔秒数 float bulletSpeed; // 子弹速度像素/秒 int bulletCount; // 一次射出的子弹数 float spreadAngle; // 扩散角,单位度 };

然后在 Player 里维护一个映射:

std::map<WeaponType, WeaponConfig> weapons = { { WeaponType::Pistol, { 0.15f, 600.f, 1, 0.f } }, { WeaponType::Shotgun, { 0.60f, 550.f, 5, 15.f } }, { WeaponType::RapidFire, { 0.06f, 800.f, 1, 4.f } } };

射击函数改成按配置生成多颗子弹,每颗方向在基准方向上加一个偏移:

for (int i = 0; i < cfg.bulletCount; i++) { float angle = baseAngle + degreesToRadians( (i - (cfg.bulletCount - 1) / 2.f) * cfg.spreadAngle); spawnBullet(from, angle, cfg.bulletSpeed); }

这么做的好处是所有改动集中在 shoot 和配置数据里,Spawner、Enemy 完全不用动。验证改动时,我一般按 F3 打印当前子弹数和存活敌人数,按 F4 强制结算当前波,用来快速测试不同射速和扩散参数带来的平衡感,不用开断点就能看到结果。散弹枪伤害过高就调大 spreadAngle、调低 bulletSpeed,一切在配置里改,不改逻辑。

从那以后我每次做游戏 Demo,都强制把“输入、移动、碰撞、渲染”拆成独立函数,所有可调参数集中到一个 Config 结构里,然后重新跑一遍完整循环确认帧同步没有被破坏。这次从 TopDownShooter 拆出来的这一整套开发习惯,我直接搬进了下一个项目。希望你能在这个工程跑起来后,也体会到亲手搭一套完整游戏循环的痛快——希望帮到你。

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

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

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

立即咨询