资讯动态

C++与SFML实战:从零开发俯视角射击游戏核心机制

发布时间:2026/10/20 22:29:20 来源:尧图企业网站定制
简介一份基于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 C17 的组合。原因一句话就够SFML 的 API 比 SDL 友好sprite、text、shape 都是一等公民不需要手动管理底层图形上下文写“移动、朝向、渲染”这类游戏原语时心智负担小而且中文资料和示例都很全遇到问题搜起来快。SFML 分成 system、window、graphics、audio 四个核心模块这个项目只用了前三个。system 负责时钟、向量和字符串工具window 负责窗口创建、事件轮询和 OpenGL 上下文graphics 承担精灵、形状、文本和视图渲染。正因为模块足够薄我们可以把“游戏自己的 Player、Bullet、Enemy 类”和“底层渲染”干净分离后面二次开发时不需要碰库本身。如果你在自己的环境复现常见的安装方式是 macOS 上brew install sfmlUbuntu 上sudo apt install libsfml-devWindows 则是下载 SFML 官方预编译包解压后把路径交给 CMake。窗口创建部分通常是这样的sf::RenderWindow window(sf::VideoMode(1280, 720), TopDownShooter); window.setVerticalSyncEnabled(true);这样就把游戏窗口固定下来。VideoMode的两个参数是宽和高后面敌人从四边生成、子弹越界回收都依赖这个窗口尺寸改分辨率时这几个常量和 Spawner 的边界判断要一起改。2.2 目录结构模块按游戏对象拆分一个类管一件事拿到工程后先别急着编译扫一眼目录结构会省很多事。典型的组织方式不是把所有代码塞进一个 main.cpp而是按“游戏对象 / 逻辑层 / 入口”拆开TopDownShooter/ ├─ CMakeLists.txt # 构建配置找 SFML、指定 C17 标准 ├─ 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::vectorBullet和std::vectorEnemy每帧依次调用它们的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单位像素/秒200300 比较合适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 编译阶段找不到库与链接顺序坑 1CMake 找不到 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_PATHD:/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 不稳定。坑 4HUD 的中文显示成方框现象用 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 像素/秒建议再做扫掠检测用小步进把一帧拆成 510 段逐段检测或者手动计算子弹移动线段与圆心之间的距离。两个方法都不复杂但能在高射速、低帧率的组合下保证手感稳定。5. 二次开发思路从加一把新武器到动态难度曲线想让这个 Demo 变成你自己的改动最划算的入口是武器系统。现在 Game 里只有一种子弹射速固定、伤害固定扩成多武器非常自然。先在 Bullet.h 旁边定义一个武器配置enum class WeaponType { Pistol, Shotgun, RapidFire }; struct WeaponConfig { float fireInterval; // 两发之间间隔秒数 float bulletSpeed; // 子弹速度像素/秒 int bulletCount; // 一次射出的子弹数 float spreadAngle; // 扩散角单位度 };然后在 Player 里维护一个映射std::mapWeaponType, 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 拆出来的这一整套开发习惯我直接搬进了下一个项目。希望你能在这个工程跑起来后也体会到亲手搭一套完整游戏循环的痛快——希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑