C++ 2D射击游戏源码解析:从Entity基类到碰撞检测的实战指南 简介这是一份面向C游戏开发初学者与2D射击游戏爱好者的开源项目源码实现了一个带视野机制的自上而下射击玩法适合用来学习游戏循环、碰撞检测与光影渲染等核心模块。压缩包共76个文件约140KB以14个cpp源文件与15个h头文件构成主体逻辑涵盖玩家、敌人、子弹管理、地图生成、生命条与小地图等模块另有21个png精灵贴图、8个pgm与4个ppm地图及光照数据、4个frag着色器文件配合Makefile可直接在Linux下编译运行。项目已有373人学习下载代码结构分层清晰头文件与实现分离并附带Windows可执行二进制便于读者对照源码理解视野遮挡、法线贴图与阴影渲染的实现思路是研究小型2D射击游戏架构的实用参考。1. 从一份“能跑起来”的 C 射击游戏源码说起很多 C 初学者学完语法后卡在同一个地方知道类、继承、虚函数、STL 容器怎么写但真给一个完整项目连从哪个文件开始读都不知道。这份 TopDownShooter 源码就是冲着这个痛点来的——一个自上而下的 2D 射击游戏玩家在俯视视角下移动、瞄准、开火敌人从边缘刷出并追击命中判定、血量、得分、游戏结束一整套流程都跑得通。它不追求画面炫技胜在结构干净、依赖少适合想拿 C 练手做小游戏、或者想找一个能二次开发的原型骨架的开发者。下面按“它是什么、怎么跑、核心怎么实现、怎么改”的顺序拆开讲中间会穿插我实际编译时踩过的坑。2. 工程结构与运行环境从零把项目跑起来2.1 目录布局与模块职责这类 2D 射击 Demo 的工程结构通常不会太复杂常见做法是按“引擎层 / 游戏层 / 资源层”三分。拿到源码后先别急着编译花五分钟把目录扫一遍后面读代码会顺很多。典型的布局是这样TopDownShooter/ ├── src/ │ ├── main.cpp # 入口初始化窗口与主循环 │ ├── Game.h / Game.cpp # 游戏状态机管理实体与关卡 │ ├── Player.h/.cpp # 玩家移动、瞄准、开火 │ ├── Enemy.h/.cpp # 敌人寻路、追击、攻击 │ ├── Bullet.h/.cpp # 子弹生命周期与碰撞 │ ├── Entity.h # 实体基类位置/速度/血量 │ └── Utils.h # 向量运算、随机数、计时 ├── assets/ │ ├── textures/ # 玩家、敌人、子弹贴图 │ └── sounds/ # 射击、命中音效 ├── CMakeLists.txt # 构建脚本 └── README.mdEntity是所有可动对象的基类玩家、敌人、子弹都从它派生共享位置、速度、包围盒和血量字段。Game持有实体容器并驱动更新循环main.cpp只负责窗口和事件泵。这种分层的好处是加一种新敌人只需要继承Enemy或Entity不用动主循环。读代码的顺序建议是main.cpp→Game.cpp→Entity.h→Player/Enemy/Bullet先看骨架再看血肉。2.2 依赖与构建CMake 一把梭项目正文没写具体依赖但一个 2D 射击游戏绕不开窗口、输入和渲染。这个场景下合格开发最可能用的组合是 SFML 或 SDL2两者都是跨平台、文档全、上手快。以 SFML 为例CMakeLists.txt的核心几行大概长这样cmake_minimum_required(VERSION 3.16) project(TopDownShooter CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 查找 SFMLgraphics 依赖 windowwindow 依赖 system find_package(SFML 2.5 COMPONENTS graphics window system REQUIRED) file(GLOB SOURCES src/*.cpp) add_executable(TopDownShooter ${SOURCES}) target_link_libraries(TopDownShooter PRIVATE sfml-graphics sfml-window sfml-system)CMAKE_CXX_STANDARD 17是因为代码里大概率用了std::optional、结构化绑定或auto推导find_package的COMPONENTS顺序不能乱graphics 依赖 windowwindow 依赖 system缺一个链接就报未定义符号。file(GLOB ...)省事但有个副作用新增.cpp后必须重新跑一次 CMake 配置否则新文件不进构建这是新手最容易翻车的地方之一。构建命令分平台Linux/macOS 下mkdir build cd build cmake .. cmake --build . -j4 ./TopDownShooterWindows 下如果用 MinGW把cmake ..换成cmake -G MinGW Makefiles ..用 Visual Studio 则直接cmake -G Visual Studio 17 2022 ..再打开生成的.sln。SFML 在 Windows 上还要把sfml-graphics-2.dll等动态库拷到可执行文件同目录否则双击闪退、命令行报“找不到 xxx.dll”。这一步没有报错信息提示只能靠经验我第一次跑的时候就卡在这。2.3 主循环与帧率控制游戏能跑起来之后第一个要看的文件是main.cpp因为主循环决定了整个程序的节奏。典型写法#include SFML/Graphics.hpp #include Game.h int main() { sf::RenderWindow window(sf::VideoMode(1280, 720), TopDownShooter); window.setFramerateLimit(60); // 锁 60 帧避免 CPU 空转 Game game(window); // Game 持有实体容器和状态 sf::Clock clock; while (window.isOpen()) { sf::Event event; while (window.pollEvent(event)) { if (event.type sf::Event::Closed) window.close(); } float dt clock.restart().asSeconds(); // 帧间隔秒 game.update(dt); // 逻辑更新 window.clear(); game.render(window); // 渲染 window.display(); } return 0; }关键在dtdelta time。所有移动、冷却、子弹飞行都必须乘dt否则游戏速度会绑死在帧率上60 帧时正常144 帧时敌人快一倍。setFramerateLimit(60)是双保险但逻辑层仍要按dt算这是血泪经验。clock.restart()返回上一帧到现在的时长并重置计时器比手动记时间戳干净。pollEvent必须在每帧开头清空事件队列漏了会导致窗口拖不动、按键无响应。3. 核心模块拆解实体、碰撞与游戏状态机3.1 Entity 基类与多态更新整个游戏的扩展性都压在Entity这一层。它定义所有可动对象的公共契约子类只覆写自己关心的行为// Entity.h #pragma once #include SFML/Graphics.hpp class Entity { public: Entity(const sf::Vector2f pos, float hp) : position_(pos), health_(hp), alive_(true) {} virtual ~Entity() default; // 纯虚子类必须实现自己的更新与绘制 virtual void update(float dt) 0; virtual void draw(sf::RenderWindow window) 0; bool isAlive() const { return alive_; } void damage(float amount) { health_ - amount; if (health_ 0.f) alive_ false; } sf::Vector2f position_; float health_; bool alive_; };virtual ~Entity() default不能省否则通过基类指针delete派生对象时析构不完整贴图和音效资源泄漏。update(float dt)和draw做成纯虚Game就能用一个std::vectorstd::unique_ptrEntity统一驱动所有对象新增类型不用改循环。damage里做存活判定比在每个子类里各写一遍靠谱。注意position_放在 public 是为了让碰撞检测直接读工程小可以接受规模大了应该收成 protected 加 getter这是后话。3.2 玩家移动与瞄准归一化向量是重点玩家模块最容易写错的是斜向移动速度。直接position direction * speed * dt斜着走会比直着走快约 41%因为向量长度是 √2。正确做法是先归一化// Player.cpp 片段 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; // 长度不为 0 才归一化否则除零 float len std::sqrt(dir.x * dir.x dir.y * dir.y); if (len 0.f) { dir / len; // 单位向量斜向不再加速 position_ dir * moveSpeed_ * dt; } // 鼠标位置换算成世界坐标得到瞄准方向 sf::Vector2f mouse window_.mapPixelToCoords(sf::Mouse::getPosition(window_)); aimDir_ mouse - position_; float aimLen std::sqrt(aimDir_.x * aimDir_.x aimDir_.y * aimDir_.y); if (aimLen 0.f) aimDir_ / aimLen; // 开火冷却按 dt 递减避免按住鼠标连发过快 fireCooldown_ - dt; if (sf::Mouse::isButtonPressed(sf::Mouse::Left) fireCooldown_ 0.f) { shoot(); fireCooldown_ 0.15f; // 每秒约 6.6 发 } }mapPixelToCoords把屏幕像素坐标转成世界坐标如果以后加了摄像机滚动这一步不能省否则瞄准会偏。fireCooldown_用dt递减而不是用帧计数帧率变化时射速才稳定。shoot()里生成子弹时初始位置要沿aimDir_偏移一个枪口距离不然子弹从角色中心出生视觉上像从身体里冒出来。3.3 碰撞检测与子弹生命周期2D 俯视射击用圆形碰撞最省事比 AABB 更贴合角色和子弹的视觉形状。核心是一个距离平方比较避免开方// Game.cpp 片段子弹与敌人碰撞 void Game::resolveCollisions() { for (auto bullet : bullets_) { if (!bullet-isAlive()) continue; for (auto enemy : enemies_) { if (!enemy-isAlive()) continue; sf::Vector2f diff bullet-position_ - enemy-position_; float distSq diff.x * diff.x diff.y * diff.y; float radiusSum bullet-radius_ enemy-radius_; if (distSq radiusSum * radiusSum) { // 命中 enemy-damage(bullet-damage_); bullet-alive_ false; // 子弹消失 score_ 10; break; // 一颗子弹只打一个敌人 } } } // 统一清理死亡对象避免遍历时删除导致迭代器失效 bullets_.erase(std::remove_if(bullets_.begin(), bullets_.end(), [](const auto b) { return !b-isAlive(); }), bullets_.end()); enemies_.erase(std::remove_if(enemies_.begin(), enemies_.end(), [](const auto e) { return !e-isAlive(); }), enemies_.end()); }两个细节值得说。第一碰撞判定用distSq radiusSum * radiusSum全程不调sqrt几百个对象时性能差别肉眼可见。第二删除对象必须用erase-remove惯用法绝不能在for循环里直接erase否则迭代器失效程序随机崩溃——这个 bug 隐蔽性极强可能跑十分钟才炸一次。子弹飞出屏幕后也要标记死亡否则容器无限增长内存和帧率一起崩。3.4 敌人 AI 与刷怪节奏敌人逻辑简单但要有压迫感朝玩家直线追击进入攻击距离后按冷却造成伤害。刷怪用计时器控制波次// Enemy.cpp 片段 void Enemy::update(float dt, const sf::Vector2f playerPos) { sf::Vector2f toPlayer playerPos - position_; float dist std::sqrt(toPlayer.x * toPlayer.x toPlayer.y * toPlayer.y); if (dist attackRange_) { toPlayer / dist; // 归一化后追击 position_ toPlayer * speed_ * dt; } else { attackCooldown_ - dt; if (attackCooldown_ 0.f) { // 通过回调或事件通知 Game 扣玩家血 onAttack_(damage_); attackCooldown_ 1.0f; // 每秒攻击一次 } } }onAttack_用std::functionvoid(float)注入敌人不需要知道玩家对象在哪只负责发信号耦合度低。刷怪在Game::update里用累加器spawnTimer_ dt; if (spawnTimer_ interval_) { spawn(); spawnTimer_ 0; }interval_随存活时间递减难度曲线就出来了。敌人从屏幕四边随机位置生成生成点要在可视区外一点避免“凭空出现”的突兀感。4. 避坑与排查编译、运行、逻辑三层问题4.1 链接报错找不到 SFML 符号现象是编译通过、链接阶段一堆undefined reference to sf::...。原因通常是find_package找到了头文件但没链上库或者 Debug/Release 库混用。解决确认target_link_libraries里三个组件齐全且顺序为 graphics→window→systemWindows 下检查 CMake 的CMAKE_BUILD_TYPE与 SFML 库版本-d后缀是 Debug 库是否匹配混用必炸。4.2 程序启动闪退、无任何输出现象是双击 exe 窗口一闪而过。原因多为动态库缺失或资源路径错误。解决在命令行里运行看报错Windows 把 SFML 的 dll 拷到 exe 同目录资源加载用相对路径时工作目录取决于启动方式IDE 里跑和双击跑的工作目录可能不同稳妥做法是用std::filesystem::path基于可执行文件位置拼资源路径。4.3 帧率越高游戏越快现象是换台高刷显示器后敌人速度翻倍。原因就是移动没乘dt或者dt取了固定值。解决所有位移、冷却、计时统一乘dtdt用clock.restart().asSeconds()实时取再加个上限dt std::min(dt, 0.05f)防止窗口拖动卡顿后dt突增导致实体瞬移穿墙。4.4 删除实体后随机崩溃现象是玩一会儿程序挂掉调用栈指向容器遍历。原因是在遍历vector时直接erase后续迭代器全部失效。解决标记死亡 循环外统一erase-remove或者改用std::list并小心处理迭代器。前者更推荐缓存友好。4.5 子弹穿墙或打不中高速敌人现象是子弹速度快时直接穿过敌人。原因是离散碰撞检测按帧采样一帧位移超过目标直径就漏检。解决子弹速度别设太离谱或者对子弹做线段与圆的相交检测用上一帧位置到当前位置的线段这是标准补救方案代码量不大但效果立竿见影。5. 二次开发从 Demo 到可玩原型的改造路径拿到这份源码后最值钱的不是它现在能玩而是它的骨架能往哪扩。我一般按“先加内容、再改手感、最后补系统”的顺序动刀每一步都能独立验证不至于改崩了找不到原因。第一步加内容。新增武器类型最省事的做法是把Bullet的伤害、速度、半径抽成配置结构体玩家持有当前武器配置切换武器只换配置不换类。敌人同理给Enemy加一个EnemyType枚举和对应的速度/血量/贴图映射表刷怪时按权重随机。这样加三五种敌人和武器代码增量很小。第二步改手感。射击游戏的手感八成来自反馈命中时敌人闪白、屏幕轻微震动、音效延迟低于 50ms。屏幕震动可以在渲染前给window套一个随机偏移的sf::View持续 0.1 秒衰减命中闪白用sf::Sprite::setColor(sf::Color::White)配合计时器还原。这些都不改核心逻辑只在渲染层加风险低、收益大。第三步补系统。得分、血量、波次这些状态现在散在Game里规模一大就乱。可以抽一个GameState结构体集中管理配合简单的观察者模式让 UI 层订阅变化。存档更简单把GameState序列化成 JSON引入一个 header-only 的 JSON 库即可退出时写文件、启动时读几十行搞定。验证方法上我习惯给核心逻辑写几个不依赖窗口的单元测试向量归一化、圆形碰撞判定、伤害扣减这些纯函数抽出来后用断言跑一遍比每次开游戏手测快得多。改完一轮跑一次回归问题当场暴露。有个教训我记了很久早期我改碰撞逻辑时图省事直接在原函数里加分支结果子弹和敌人、敌人和玩家两套判定互相污染调了一晚上才发现是共用了一个临时变量。从那以后我每次动碰撞相关代码都强制先把判定抽成独立纯函数、单独验证再接回主循环。希望这份拆解能帮你少走几步弯路源码跑起来之后剩下的就是你自己往里填东西了。本文还有配套的精品资源点击获取