资讯动态

C++实战:手写2D射击游戏源码解析与二次开发指南

发布时间:2026/10/28 5:25:08 来源:尧图企业网站定制
简介这是一份面向C游戏开发初学者与2D射击游戏爱好者的开源项目源码实现了一个带视野机制的自上而下射击玩法适合用来学习游戏循环、碰撞检测与光影渲染等核心模块。压缩包共76个文件以14个cpp源文件与15个h头文件构成主体逻辑涵盖玩家、敌人、子弹管理、地图生成、生命条与小地图等模块另有21个png精灵贴图、8个pgm与4个ppm地图及光照贴图、4个frag着色器文件配合Makefile与说明文档完整呈现了从资源到构建的工程结构整体约140KB。项目已在Linux下通过SFML与Box2D完成构建验证并附带Windows可执行二进制方便不同平台读者直接运行体验。目前已有373人学习下载读者可从中获取视野遮挡、法线贴图光照、碰撞监听等实现思路并借助清晰的目录划分快速定位源码适合作为小型2D游戏开发的参考范例。1. 从一份能跑起来的 C 射击游戏源码说起很多人学 C 卡在“语法都会、项目不会”这一步指针、容器、类继承背得滚瓜烂熟真给一个完整工程却不知道从哪下手。这份 TopDownShooter 源码正好填这个坑——它是一个自上而下的 2D 射击游戏玩家操控角色在俯视视角下移动、瞄准、开火敌人从四周刷出并追击命中判定、血量、得分、游戏结束一整套循环都在里面。技术栈是纯 C 加一个轻量图形库没有引擎黑匣子逻辑全摊在源码里。适合两类人想找一个能编译运行、能改能调的 C 练手项目的初学者以及想拆解“游戏主循环 实体管理 碰撞检测”这套骨架的熟手。下面按“能干什么 → 怎么跑 → 怎么改”的顺序把这份源码拆开讲。2. 项目定位与技术选型为什么用 C 手写而不是上引擎2.1 这个项目到底解决什么问题市面上的 2D 射击教程大多基于成熟引擎拖几个组件、挂个脚本就能跑但学习者对“一帧里到底发生了什么”毫无概念。TopDownShooter 的价值在于把游戏最核心的那条链路完整暴露出来输入采集 → 状态更新 → 碰撞检测 → 渲染输出四步全部由手写代码完成。玩家移动靠键盘状态位射击靠鼠标位置换算角度敌人 AI 是最朴素的“朝玩家坐标直线逼近”子弹与敌人的命中用圆形/矩形包围盒判定。这些实现单独拎出来都不复杂但拼成一个能持续运行、能重开、能计分的闭环就是一次完整的工程训练。从代码组织看它刻意避开了“一个 main 函数写到底”的写法而是按职责拆成玩家、敌人、子弹、游戏状态几个实体主循环只负责调度。这种结构对二次开发非常友好——想加一种新敌人复制敌人类改参数即可想换武器在子弹生成处加分支就行。对拿源码想跑起来的人来说它没有复杂的资源管线贴图音频都是最简形式编译依赖少是少数“下载完半小时内能看到画面”的 C 游戏项目。2.2 技术栈与依赖的取舍项目用 C 编写图形层依赖一个轻量 2D 库常见做法是 SFML 或 SDL2 这类源码里通过头文件引入窗口、渲染、输入模块。选它而不是重型引擎理由很实际编译快、API 直白、事件循环和绘制调用一眼能看懂不会出现“引擎帮你做了但你看不见”的断层。构建方式通常是 CMake源码根目录下会有CMakeLists.txt声明可执行目标、源文件列表和链接库。一个典型的构建脚本长这样我按常见写法还原核心部分cmake_minimum_required(VERSION 3.16) project(TopDownShooter LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 收集所有源文件新增 .cpp 后无需手动改这里 file(GLOB SOURCES src/*.cpp) add_executable(TopDownShooter ${SOURCES}) # 链接 2D 图形库具体库名以源码实际依赖为准 find_package(SFML 2.5 COMPONENTS graphics window system REQUIRED) target_link_libraries(TopDownShooter PRIVATE sfml-graphics sfml-window sfml-system)这段脚本的关键点有三个CMAKE_CXX_STANDARD 17决定了能用哪些语言特性结构化绑定、std::optional之类如果源码里用了较新的写法而这里没设对编译会直接报错file(GLOB ...)让新增源文件自动纳入编译省去每次改列表的麻烦但要注意 GLOB 在文件增删后需要重新跑一次 CMake 配置target_link_libraries里的库名必须和本机安装的图形库版本匹配链接失败十有八九是这里名字对不上或路径没找到。提示不同图形库的组件名不一样SFML 是sfml-graphics这种带前缀的写法SDL2 则是SDL2::SDL2这类 target 名。拿到源码先看CMakeLists.txt里find_package和target_link_libraries两行别凭记忆改。2.3 目录结构先认清每个文件夹的职责拿到一份陌生源码我习惯先扫目录再读代码。这个项目的结构大致如下文件名以实际源码为准职责划分是通用的TopDownShooter/ ├── CMakeLists.txt # 构建入口 ├── src/ │ ├── main.cpp # 程序入口创建窗口、跑主循环 │ ├── Game.cpp/.h # 游戏状态机菜单、进行中、结束 │ ├── Player.cpp/.h # 玩家移动、瞄准、开火、血量 │ ├── Enemy.cpp/.h # 敌人生成、追击、受击 │ ├── Bullet.cpp/.h # 子弹飞行、生命周期、命中 │ └── Utils.h # 向量运算、随机数等工具 └── assets/ # 贴图、字体等资源main.cpp只做三件事初始化窗口、构造Game对象、进入循环。真正的游戏逻辑全在Game类里它持有玩家、敌人容器、子弹容器每帧按固定顺序调用各实体的update()和draw()。这种“入口极薄、逻辑集中”的布局好处是读代码时不用在十几个文件间跳来跳去坏处是Game.cpp会偏长——二次开发时如果功能膨胀第一件事就是把它按系统再拆。3. 跑起来从零编译到看见第一个敌人3.1 环境准备与依赖安装在动手编译前先把工具链备齐一个支持 C17 的编译器GCC 9、Clang 10 或 MSVC 2019、CMake 3.16 以上、以及源码依赖的那个 2D 图形库。Linux 下用包管理器装库最省事以常见发行版为例# 安装编译工具与 CMake sudo apt install build-essential cmake # 安装 2D 图形库开发包库名以源码依赖为准 sudo apt install libsfml-devWindows 下我一般推荐用 vcpkg 或 MSYS2 管理依赖避免手动配库路径的血泪经验——图形库的.lib、.dll、头文件路径三者对不上是新手最常见的翻车点。macOS 用 Homebrew 装对应库即可。装完库后验证一下头文件能被找到随便写个引入该库头文件的小文件编译一下能过就说明环境通了。3.2 编译与首次运行依赖就位后标准流程是“建构建目录 → 生成 → 编译 → 运行”四步分开做出错时好定位# 在源码根目录执行 mkdir build cd build # 生成构建文件-DCMAKE_BUILD_TYPE 控制优化级别 cmake .. -DCMAKE_BUILD_TYPERelease # 编译-j 后面跟并行任务数按 CPU 核心数填 cmake --build . -j 8 # 运行产物名字以 CMakeLists 里 add_executable 的定义为准 ./TopDownShooter-DCMAKE_BUILD_TYPERelease会开优化游戏跑起来帧率更稳调试阶段可以换成Debug配合断点看变量。-j 8是并行编译机器核心少就调小否则内存吃紧反而更慢。运行后如果窗口一闪而过或直接崩溃先看终端有没有打印资源加载失败的路径——游戏启动时读不到贴图或字体是最常见的“黑屏”原因。注意资源路径是相对路径还是绝对路径直接决定“在 build 目录里运行”能不能找到assets/。如果源码里写的是assets/xxx.png而你在build/里执行程序就会找不到文件。解决办法是把assets拷到可执行文件同级目录或在代码里改成基于可执行文件路径拼接。3.3 主循环一帧里到底发生了什么游戏能跑起来之后第一件该读的代码是主循环。它是整个项目的节拍器所有实体都挂在这条时间线上// main.cpp 核心结构按常见写法还原 int main() { sf::RenderWindow window(sf::VideoMode(1280, 720), TopDownShooter); window.setFramerateLimit(60); // 锁 60 帧避免空转吃满 CPU Game game(window); // 游戏状态与实体都归 Game 管 while (window.isOpen()) { sf::Event event; while (window.pollEvent(event)) { if (event.type sf::Event::Closed) window.close(); game.handleInput(event); // 事件先交给 Game 处理 } game.update(); // 更新移动、AI、碰撞、生成 window.clear(); game.draw(window); // 渲染按图层顺序画 window.display(); } return 0; }这段代码里setFramerateLimit(60)是新手最容易忽略的一行——不锁帧的话循环会疯狂空转CPU 占用飙到 100%笔记本风扇立刻起飞。pollEvent内层循环负责把这一帧积压的所有事件关闭、按键、鼠标取干净漏掉事件会导致输入延迟或窗口关不掉。update()和draw()分离是游戏循环的基本纪律更新阶段只改状态不画渲染阶段只读状态不改两者混在一起后面加暂停、加回放会非常痛苦。update()内部的调用顺序也有讲究常见做法是“先处理输入产生的意图 → 再更新玩家 → 更新敌人 → 更新子弹 → 统一做碰撞检测 → 清理死亡对象”。顺序错了会出现“子弹穿过敌人不判定”或“敌人已经死了还在追人”这类玄学问题本质是同一帧内状态被读了旧值。4. 核心模块拆解玩家、敌人、子弹与碰撞4.1 玩家控制键盘状态位与鼠标瞄准玩家模块要解决两件事八方向移动和朝鼠标方向射击。移动不用每帧读按键事件而是维护一个按键状态表在update里根据状态累加速度向量// Player::update —— 根据按键状态计算移动 void Player::update(float dt) { sf::Vector2f dir(0.f, 0.f); if (sf::Keyboard::isKeyPressed(sf::Keyboard::W)) dir.y - 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::S)) dir.y 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::A)) dir.x - 1.f; if (sf::Keyboard::isKeyPressed(sf::Keyboard::D)) dir.x 1.f; // 归一化斜向移动不能比直线快 if (dir.x ! 0.f || dir.y ! 0.f) { float len std::sqrt(dir.x * dir.x dir.y * dir.y); dir / len; } position dir * speed * dt; // dt 为帧间隔保证不同帧率速度一致 sprite.setPosition(position); }dtdelta time是这段代码的灵魂。如果直接写position dir * speed帧率 60 和帧率 30 下角色速度会差一倍手感完全变样。归一化那几行同样关键不归一化的话同时按 WD 斜着走速度是直线的 1.414 倍玩家会本能地“斜着跑更快”这是很多手写移动的经典 bug。瞄准则用鼠标坐标减去玩家坐标得到方向向量开火时沿该方向生成子弹角度用atan2换算即可。4.2 敌人 AI 与生成节奏敌人逻辑简单到近乎粗暴每帧朝玩家当前位置移动接触玩家造成伤害。这种“直线追击”在少量敌人时体验尚可敌人一多就会挤成一坨。生成节奏通常由一个计时器控制每隔固定秒数在屏幕边缘随机位置刷一个// Game::update 中的敌人生成片段 spawnTimer dt; if (spawnTimer spawnInterval) { spawnTimer 0.f; // 在窗口四边随机取点保证敌人从屏幕外进入 float x static_castfloat(rand() % windowWidth); float y (rand() % 2 0) ? -32.f : windowHeight 32.f; enemies.emplace_back(sf::Vector2f(x, y), player.getPosition()); }spawnInterval是难度曲线的直接抓手想调难度先动它-32.f和windowHeight 32.f让敌人从可视区外进场避免“凭空出现在屏幕中间”的突兀感。敌人构造时传入玩家位置作为初始追击目标之后每帧重新取玩家坐标才能实现持续追踪。这里有个容易踩的坑如果敌人持有的是玩家位置的拷贝而非每帧查询玩家一移动敌人就会追着旧坐标跑看起来像“AI 傻了”。4.3 子弹生命周期与碰撞检测子弹是短生命周期对象生成后沿固定方向飞行飞出屏幕或命中敌人即销毁。碰撞检测用最朴素的包围盒/圆形距离判定每帧对所有子弹和所有敌人做一次两两检测// 碰撞检测子弹 vs 敌人 for (auto bullet : bullets) { for (auto enemy : enemies) { // 用圆形半径之和做粗略判定够快也够用 sf::Vector2f diff bullet.getPosition() - enemy.getPosition(); float distSq diff.x * diff.x diff.y * diff.y; float radiusSum bullet.getRadius() enemy.getRadius(); if (distSq radiusSum * radiusSum) { bullet.markDead(); enemy.takeDamage(bullet.getDamage()); break; // 一颗子弹只命中一个敌人 } } } // 统一清理死亡对象避免遍历中删除导致迭代器失效 bullets.erase(std::remove_if(bullets.begin(), bullets.end(), [](const Bullet b) { return b.isDead(); }), bullets.end());这段代码有两个工程要点。第一用距离平方比较而不是开方省掉每帧成百上千次sqrt是性能上的常规操作。第二删除对象必须延后到遍历结束用remove_iferase的惯用法统一清理如果在双层循环里直接erase迭代器立刻失效程序崩溃或行为诡异这是 C 容器操作的头号翻车点。break保证一颗子弹只结算一次伤害否则穿透多个重叠敌人会瞬间打出多倍伤害。4.4 游戏状态机菜单、进行中、结束一个完整游戏不能只有“进行中”一种状态。Game类里通常用一个枚举区分菜单、游戏中、游戏结束update和draw根据当前状态分流enum class GameState { Menu, Playing, GameOver }; void Game::update(float dt) { switch (state) { case GameState::Menu: if (startPressed()) { reset(); state GameState::Playing; } break; case GameState::Playing: updateEntities(dt); if (player.isDead()) state GameState::GameOver; break; case GameState::GameOver: if (restartPressed()) { reset(); state GameState::Playing; } break; } }状态机的价值在于“重开”这件事变得干净reset()统一清空敌人、子弹、重置玩家血量和分数而不是在游戏结束分支里零散地手动清。没有状态机的话重开逻辑会散落在各处加一个“暂停”状态就得改遍所有分支。这个模式在二次开发时几乎是必留的骨架。5. 避坑与排查编译、运行、逻辑三层问题5.1 链接失败库找到了但符号对不上现象是编译通过、链接阶段报一堆undefined reference to ...。原因通常是find_package找到了库的头文件但target_link_libraries里漏了某个组件或者 Debug/Release 版本的库混用。解决方法是先确认CMakeLists.txt里链接的组件是否覆盖了源码实际用到的模块窗口、图形、系统三件套缺一不可再检查构建类型是否和库的构建类型一致。Windows 下还要注意 32/64 位匹配装错位数会报符号找不到。5.2 黑屏或闪退资源路径与工作目录现象是窗口打开后一片黑或程序启动即退出。原因九成是资源加载失败——贴图、字体用了相对路径而运行目录不是源码根目录。解决方法是打印每次加载的路径和返回值确认文件真实存在或者把assets目录拷到可执行文件旁边。我一般会在资源加载处加一句失败日志比事后猜路径高效得多。5.3 帧率越高跑得越快忘了乘 dt现象是在高刷屏上角色移动飞快换台机器又变慢。原因是移动、子弹飞行、计时器全部按“每帧固定增量”写没有乘帧间隔dt。解决办法是把所有与时间相关的量改成速度 * dt计时器改成累加dt。改完后不同帧率下手感一致这是游戏循环的基本纪律漏一处就会露馅。5.4 敌人越打越多、内存上涨死亡对象没清理现象是玩几分钟后帧率下降、内存持续增长。原因是子弹或敌人标记了死亡却没有从容器里移除或者移除方式错误导致容器只增不减。解决方法是统一用remove_iferase在每帧末尾清理并检查所有markDead()的调用点是否都有对应的清理路径。遍历中直接删除还会引发崩溃属于同一类问题的两种表现。5.5 碰撞判定时灵时不灵更新顺序与坐标不同步现象是子弹明明碰到敌人却不掉血或敌人已经死了还在追人。原因是同一帧内先做了碰撞检测、后更新位置用的是上一帧的旧坐标或者敌人死亡标记设置了但追击逻辑没检查。解决办法是固定更新顺序为“移动 → 碰撞 → 清理”并在敌人update开头判断isDead()直接返回。顺序一旦定下来就写进注释后面加新实体照此办理。6. 二次开发加武器、加敌人类型与难度曲线拿到能跑的源码后最有价值的改造是“加一种武器”和“加一种敌人”这两件事能把实体管理、工厂式创建、参数配置三条线全部走一遍。加武器时我会在子弹类里加一个damage、speed、cooldown的参数组玩家开火处根据当前武器索引取对应参数而不是复制一份子弹类——复制会让碰撞和清理逻辑出现两份后面改一处漏一处。加敌人类型同理用枚举或配置表区分血量、速度、半径生成时按权重随机难度曲线就落在权重表上调数值不用碰逻辑代码。验证改造是否成功我习惯用三个检查点新武器伤害是否只结算一次防穿透多判、新敌人死亡后是否被正确清理防内存增长、高帧率下速度是否仍然一致防漏乘 dt。这三个点覆盖了前面踩过的大部分坑。改完跑一局看帧率曲线和内存曲线是否平稳比肉眼观察靠谱。有个习惯我保持了很久每次动实体容器或碰撞逻辑都先在纸上把“这一帧的执行顺序”写一遍再对照代码确认。早期我不信这个邪觉得顺序无所谓结果被“子弹穿过敌人”和“迭代器失效崩溃”轮流教育过。从那以后我每次改主循环或容器清理都强制走一遍顺序推演再编译。希望这份拆解能帮你少走几段弯路把这份 C 射击源码真正跑起来、改出自己的版本。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑