ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

简介:这是一份面向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 环境准备与依赖安装

在动手编译前,先把工具链备齐:一个支持 C++17 的编译器(GCC 9+、Clang 10+ 或 MSVC 2019+)、CMake 3.16 以上、以及源码依赖的那个 2D 图形库。Linux 下用包管理器装库最省事,以常见发行版为例:

# 安装编译工具与 CMake sudo apt install build-essential cmake # 安装 2D 图形库开发包(库名以源码依赖为准) sudo apt install libsfml-dev

Windows 下我一般推荐用 vcpkg 或 MSYS2 管理依赖,避免手动配库路径的血泪经验——图形库的.lib、.dll、头文件路径三者对不上,是新手最常见的翻车点。macOS 用 Homebrew 装对应库即可。装完库后,验证一下头文件能被找到:随便写个引入该库头文件的小文件编译一下,能过就说明环境通了。

3.2 编译与首次运行

依赖就位后,标准流程是“建构建目录 → 生成 → 编译 → 运行”,四步分开做,出错时好定位:

# 在源码根目录执行 mkdir build && cd build # 生成构建文件,-DCMAKE_BUILD_TYPE 控制优化级别 cmake .. -DCMAKE_BUILD_TYPE=Release # 编译,-j 后面跟并行任务数,按 CPU 核心数填 cmake --build . -j 8 # 运行产物(名字以 CMakeLists 里 add_executable 的定义为准) ./TopDownShooter

-DCMAKE_BUILD_TYPE=Release会开优化,游戏跑起来帧率更稳;调试阶段可以换成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); }

dt(delta time)是这段代码的灵魂。如果直接写position += dir * speed,帧率 60 和帧率 30 下角色速度会差一倍,手感完全变样。归一化那几行同样关键:不归一化的话,同时按 W+D 斜着走,速度是直线的 1.414 倍,玩家会本能地“斜着跑更快”,这是很多手写移动的经典 bug。瞄准则用鼠标坐标减去玩家坐标得到方向向量,开火时沿该方向生成子弹,角度用atan2换算即可。

4.2 敌人 AI 与生成节奏

敌人逻辑简单到近乎粗暴:每帧朝玩家当前位置移动,接触玩家造成伤害。这种“直线追击”在少量敌人时体验尚可,敌人一多就会挤成一坨。生成节奏通常由一个计时器控制,每隔固定秒数在屏幕边缘随机位置刷一个:

// Game::update 中的敌人生成片段 spawnTimer += dt; if (spawnTimer >= spawnInterval) { spawnTimer = 0.f; // 在窗口四边随机取点,保证敌人从屏幕外进入 float x = static_cast<float>(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_if+erase的惯用法统一清理;如果在双层循环里直接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_if+erase在每帧末尾清理,并检查所有markDead()的调用点是否都有对应的清理路径。遍历中直接删除还会引发崩溃,属于同一类问题的两种表现。

5.5 碰撞判定时灵时不灵:更新顺序与坐标不同步

现象是子弹明明碰到敌人却不掉血,或敌人已经死了还在追人。原因是同一帧内先做了碰撞检测、后更新位置,用的是上一帧的旧坐标;或者敌人死亡标记设置了但追击逻辑没检查。解决办法是固定更新顺序为“移动 → 碰撞 → 清理”,并在敌人update开头判断isDead()直接返回。顺序一旦定下来就写进注释,后面加新实体照此办理。

6. 二次开发:加武器、加敌人类型与难度曲线

拿到能跑的源码后,最有价值的改造是“加一种武器”和“加一种敌人”,这两件事能把实体管理、工厂式创建、参数配置三条线全部走一遍。加武器时,我会在子弹类里加一个damage、speed、cooldown的参数组,玩家开火处根据当前武器索引取对应参数,而不是复制一份子弹类——复制会让碰撞和清理逻辑出现两份,后面改一处漏一处。加敌人类型同理,用枚举或配置表区分血量、速度、半径,生成时按权重随机,难度曲线就落在权重表上,调数值不用碰逻辑代码。

验证改造是否成功,我习惯用三个检查点:新武器伤害是否只结算一次(防穿透多判)、新敌人死亡后是否被正确清理(防内存增长)、高帧率下速度是否仍然一致(防漏乘 dt)。这三个点覆盖了前面踩过的大部分坑。改完跑一局,看帧率曲线和内存曲线是否平稳,比肉眼观察靠谱。

有个习惯我保持了很久:每次动实体容器或碰撞逻辑,都先在纸上把“这一帧的执行顺序”写一遍,再对照代码确认。早期我不信这个邪,觉得顺序无所谓,结果被“子弹穿过敌人”和“迭代器失效崩溃”轮流教育过。从那以后我每次改主循环或容器清理,都强制走一遍顺序推演再编译。希望这份拆解能帮你少走几段弯路,把这份 C++ 射击源码真正跑起来、改出自己的版本。

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

返回列表