简介:这是一款基于C++实现的轻量级自上而下2D射击游戏开源项目,面向图形编程初学者与SFML/Box2D框架学习者,帮助理解游戏主循环、物理碰撞、视野阴影渲染、角色控制及地图管理等核心机制。资源包共76个文件,含14个C++源码(如player.cpp、light.cpp)、15个头文件(含contactListener.h、shadow.h等模块化设计)、21张PNG纹理与精灵图、8张PGM/PPM格式光照贴图及法线图,以及Makefile构建脚本和完整README说明,整体仅140KB,结构清晰、依赖明确。已有369人学习下载,适合在Linux环境下快速编译运行(Ubuntu一键安装依赖),亦提供Windows预编译二进制文件(兼容32/64位系统),省去环境配置门槛。读者可完整掌握从地图生成、光照计算、子弹管理到UI迷你地图的全链路实现逻辑,并复用其模块化架构进行二次开发。
1. TopDownShooter:不是“玩具Demo”,而是一个能跑通完整射击逻辑、支持热重载武器配置、带碰撞反馈和状态机的可扩展2D射击骨架
上周帮某高校实验室调试一个学生做的课程设计,他们用Unity写了个“自上而下射击游戏”,结果子弹穿墙、角色卡墙角、换枪后伤害不生效——最后发现根本没实现碰撞分层和状态同步。而这个TopDownShooter项目,恰恰是用纯C++(无引擎)从零搭起的一套可验证、可打断、可热插拔的射击系统:它用SDL2做渲染与输入,Box2D做物理,但关键不在“用了什么”,而在“怎么组织”——角色移动是独立于输入帧的固定步进,射击逻辑走事件驱动而非轮询,弹道计算封装成BulletFactory可替换接口,连UI血条更新都通过观察者模式解耦。适合两类人:想吃透2D射击底层逻辑的C++新手(代码全在单个src/目录下,无宏地狱),以及需要快速集成射击模块到自有框架的老手(核心逻辑不依赖SDL,只靠几组回调函数就能剥离)。它不炫技,但每一行都在回答“子弹打中敌人那一刻,到底发生了什么”。
2. 技术栈与工程结构:SDL2 + Box2D + CMake,但真正值钱的是那套“状态-事件-响应”三层架构
2.1 为什么选SDL2而不是SFML或Raylib?——轻量可控才是硬需求
这个项目没用任何游戏引擎,甚至刻意避开抽象层过厚的库。SDL2被选中的核心原因就一条:输入事件能精确到帧,且窗口事件、键盘、鼠标、游戏手柄全部走同一套事件循环。比如玩家按住W键移动时,项目里不是用SDL_GetKeyboardState()轮询,而是监听SDL_KEYDOWN/SDL_KEYUP事件,并把按键状态存入InputManager::keyState位图中——这样当你要加“连击判定”或“长按蓄力”时,时间戳和状态变更点都是确定的。更关键的是,SDL2的SDL_PollEvent()返回的是SDL_Event联合体,项目里专门定义了EventType枚举映射:
// src/core/InputManager.h enum class EventType { KEY_DOWN, KEY_UP, MOUSE_BUTTON_DOWN, MOUSE_BUTTON_UP, MOUSE_MOTION, WINDOW_RESIZED }; struct InputEvent { EventType type; int keyCode; // SDL_SCANCODE_* 值 int mouseX, mouseY; uint32_t timestamp; // SDL_GetTicks() };提示:别直接用
SDL_SCANCODE_W硬编码!项目里所有键位都通过KeyBinding.h统一管理,比如KeyBinding::MOVE_UP = SDL_SCANCODE_W,改键位只需改头文件,不碰逻辑。
2.2 Box2D不是拿来“做物理”的,而是当“碰撞仲裁器”用
很多人一看到Box2D就想到刚体、关节、力场——但在这个项目里,Box2D只干一件事:告诉“谁碰了谁”,且保证不漏判、不误判。所有实体(Player、Enemy、Bullet、Obstacle)都对应一个b2Body*,但它们的b2BodyType全是b2_kinematicBody(运动学刚体),完全不参与物理模拟。为什么?因为角色移动由PlayerController手动调用SetTransform()控制,子弹轨迹由BulletSystem按固定速度SetLinearVelocity()推进——Box2D只负责在每帧末尾调用b2World::QueryAABB()检测碰撞对,并触发预注册的ContactListener回调:
// src/physics/ContactListener.cpp void ContactListener::BeginContact(b2Contact* contact) { void* fixtureA = contact->GetFixtureA()->GetUserData().pointer; void* fixtureB = contact->GetFixtureB()->GetUserData().pointer; // 用户数据存的是EntityID(uint32_t),不是指针!避免悬空 if (fixtureA && fixtureB) { EntityID idA = *static_cast<uint32_t*>(fixtureA); EntityID idB = *static_cast<uint32_t*>(fixtureB); CollisionEvent event{idA, idB, true}; // true = 开始接触 EventSystem::getInstance().publish(event); } }注意这里没做任何dynamicBody的力计算,也没设摩擦/密度——Box2D在这里就是个高精度的“碰撞开关”,把物理引擎降维成事件总线。这是项目最聪明的设计选择:既利用了Box2D的成熟碰撞检测,又规避了它复杂的物理更新逻辑。
2.3 CMakeLists.txt不是生成构建文件的,而是定义“可替换模块”的契约
整个项目的CMake结构暴露了它的扩展意图。根目录CMakeLists.txt只做三件事:
add_subdirectory(src)加载主逻辑;add_subdirectory(third_party)管理SDL2/Box2D等外部依赖;- 最关键的是第3条:
option(ENABLE_WEAPON_EDITOR "Enable in-game weapon config editor" OFF)
这个开关控制是否编译src/editor/WeaponEditor.cpp——它是个基于ImGui的实时配置面板,能动态修改子弹速度、散射角、冷却时间,并立即生效。但它的存在不污染核心逻辑:WeaponConfig结构体定义在src/data/WeaponConfig.h,所有武器参数都序列化为JSON,WeaponFactory只认这个结构体,不管你是从文件读、网络拉、还是编辑器改。这种“配置即数据、逻辑即接口”的思路,让二次开发时你可以轻松换成YAML解析器,或者对接自己的配置中心。
3. 核心模块拆解:从玩家按下空格键,到敌人播放受击动画的完整链路
3.1 输入处理:InputManager如何把“按空格”变成“发射事件”
整个射击链路的起点不是Player::shoot(),而是InputManager捕获的SDL_KEYDOWN事件。项目采用两级输入抽象:
- 硬件层:
SDL_PollEvent()捕获原始事件,转为InputEvent存入队列; - 逻辑层:
InputSystem::update()每帧消费队列,根据当前游戏状态(如是否在菜单、是否被眩晕)决定是否转发。
关键代码在InputSystem.cpp:
// src/systems/InputSystem.cpp void InputSystem::update(float deltaTime) { InputEvent event; while (InputManager::pollEvent(event)) { // 状态过滤:若玩家被眩晕,则丢弃所有输入事件 if (playerState == PlayerState::STUNNED) continue; switch (event.type) { case EventType::KEY_DOWN: if (event.keyCode == KeyBinding::SHOOT) { // 构造射击事件,含瞄准方向(由鼠标位置计算) glm::vec2 aimDir = calculateAimDirection(event.mouseX, event.mouseY); ShootEvent shootEvent{aimDir, playerEntityId}; EventSystem::getInstance().publish(shootEvent); } break; // 其他按键... } } }注意calculateAimDirection()的实现:它用glm::normalize(mousePos - playerPos)算出世界坐标系下的方向向量,不是屏幕像素差。这意味着即使你缩放窗口或切换分辨率,瞄准依然精准——因为所有坐标运算都在世界空间(world space)完成,SDL2只提供原始像素坐标,转换逻辑全在项目内。
3.2 子弹生成:BulletFactory如何用策略模式支持激光、霰弹、追踪弹
BulletFactory是项目最体现OOP思想的模块。它不直接new Bullet,而是根据WeaponConfig类型创建不同策略:
// src/factories/BulletFactory.h class BulletFactory { public: static std::unique_ptr<Bullet> createBullet( const WeaponConfig& config, EntityID ownerID, const glm::vec2& position, const glm::vec2& direction ); private: static std::unique_ptr<Bullet> createLaser(const WeaponConfig&, ...); static std::unique_ptr<Bullet> createShotgun(const WeaponConfig&, ...); static std::unique_ptr<Bullet> createHoming(const WeaponConfig&, ...); };以霰弹为例,createShotgun()会生成5发子弹,每发方向在基础方向上叠加±5°随机偏移,并设置独立的生命周期(lifespan = 2.0f)和碰撞掩码(只与ENEMY_LAYER碰撞):
// src/bullets/ShotgunBullet.cpp void ShotgunBullet::update(float deltaTime) { // 每帧按固定速度前进 position += velocity * deltaTime; // 生命周期倒计时 lifespan -= deltaTime; if (lifespan <= 0.0f) { markForRemoval(); // 触发销毁流程 return; } // Box2D碰撞检测已由PhysicsSystem统一处理,此处只管逻辑 if (isCollidingWithEnemy()) { applyDamage(); markForRemoval(); } }注意:
markForRemoval()不是立刻delete,而是把实体ID加入EntityManager::toRemoveQueue,等本帧所有系统更新完再批量清理——这是避免迭代器失效的标准做法。
3.3 碰撞响应:CollisionSystem如何把“子弹碰敌人”翻译成“掉血+音效+粒子”
CollisionSystem是事件驱动的典型应用。它订阅CollisionEvent,但不做具体处理,只分发子事件:
// src/systems/CollisionSystem.cpp void CollisionSystem::onCollision(const CollisionEvent& event) { auto& entityA = EntityManager::getInstance().getEntity(event.idA); auto& entityB = EntityManager::getInstance().getEntity(event.idB); // 判断碰撞类型:子弹打敌人?敌人打玩家?子弹打墙? if (entityA.hasComponent<DamageComponent>() && entityB.hasComponent<HealthComponent>()) { // A是攻击方,B是受击方 DamageEvent damageEvent{ event.idA, event.idB, entityA.getComponent<DamageComponent>().damageValue }; EventSystem::getInstance().publish(damageEvent); } else if (/* 其他组合 */) { /* ... */ } }真正的“掉血”逻辑在HealthSystem里:
// src/systems/HealthSystem.cpp void HealthSystem::onDamage(const DamageEvent& event) { auto& target = EntityManager::getInstance().getEntity(event.targetId); auto& health = target.getComponent<HealthComponent>(); health.currentHP -= event.damage; if (health.currentHP <= 0) { // 播放死亡音效(通过AudioSystem) AudioSystem::getInstance().playSound("enemy_death"); // 触发死亡事件,供其他系统响应 EventSystem::getInstance().publish(DeathEvent{event.targetId}); } else { // 播放受击音效 + 屏幕震动 AudioSystem::getInstance().playSound("hit"); CameraSystem::getInstance().shake(0.1f, 0.05f); // 振幅0.1,持续0.05秒 } }这种“碰撞→事件→响应”的三级解耦,让你改一个功能不用动三个文件:想换受击音效?只改AudioSystem;想加无敌帧?在HealthComponent里加invincibilityTimer;想让敌人受伤时掉落金币?监听DamageEvent再发DropItemEvent即可。
4. 运行与调试:从零编译到实机验证的完整路径(含Windows/macOS/Linux三平台实测)
4.1 编译前必做三件事:依赖安装、CMake配置、资源路径校验
项目依赖明确写在README.md里,但新手常踩的坑是忽略版本兼容性。实测有效组合如下:
| 组件 | 推荐版本 | 验证平台 | 关键说明 |
|---|---|---|---|
| SDL2 | 2.28.5 | 全平台 | macOS需brew install sdl2后执行export SDL2_DIR=/opt/homebrew/lib/cmake/SDL2 |
| Box2D | 2.4.1 | 全平台 | Linux用户注意:Ubuntu 22.04源里的box2d-dev是2.3.1,必须手动编译2.4.1 |
| CMake | ≥3.16 | 全平台 | Windows用VS2019生成器时,务必勾选“Use Ninja”而非“Visual Studio 16 2019” |
编译命令(以Linux/macOS为例):
# 1. 创建构建目录(严禁在源码目录下cmake!) mkdir build && cd build # 2. 配置CMake(关键:指定Box2D路径,否则找不到b2World) cmake .. \ -DCMAKE_BUILD_TYPE=Release \ -DBox2D_DIR=/path/to/box2d/lib/cmake/Box2D \ # Linux/macOS路径示例 -DSDL2_DIR=/usr/local/lib/cmake/SDL2 # 或brew安装路径 # 3. 编译(-j$(nproc)加速,但内存不足时建议-j2) make -j$(nproc)提示:Windows用户若用MSVC,
CMakeLists.txt里已预设-D_CRT_SECURE_NO_WARNINGS,但若报LNK2019: unresolved external symbol __imp__SDL_Init,一定是SDL2.lib路径没加进链接器——检查CMakeCache.txt里SDL2_LIBRARY:FILEPATH是否指向正确的.lib文件。
4.2 启动失败的五大高频原因及定位方法
现象1:窗口一闪而逝,控制台无输出
原因:资源路径错误,assets/textures/player.png加载失败,TextureManager::load()抛出异常未捕获。
解决:在main.cpp开头加SDL_LogSetAllPriority(SDL_LOG_PRIORITY_DEBUG),重新运行,看日志里TextureManager: failed to load assets/textures/player.png。正确路径是build/assets/,不是src/assets/——CMake会自动复制资源到build目录。
现象2:编译通过但运行报Segmentation fault (core dumped)
原因:Box2D版本不匹配。2.3.x的b2World::Step()签名是(float32, int32, int32),而2.4.x改为(float32, int32, int32, bool),项目代码用的是2.4.x版。
解决:grep -r "Step(" src/确认调用处,若参数只有3个,说明Box2D头文件版本太低,删掉系统包,手动编译Box2D 2.4.1。
现象3:玩家能移动但无法射击,鼠标瞄准无反应
原因:SDL2窗口焦点丢失。项目默认创建窗口时设SDL_WINDOW_INPUT_FOCUS,但某些Linux桌面环境(如Wayland)不支持。
解决:在src/core/WindowManager.cpp里找到SDL_CreateWindow()调用,把flags从SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE改为SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_INPUT_GRABBED,强制捕获鼠标。
现象4:子弹飞出去但不触发碰撞,敌人血条不动
原因:Box2D的b2World::QueryAABB()范围设置过小。项目默认查询半径是200.0f,若地图超大(如4000x3000),子弹超出范围就不检测。
解决:打开src/physics/PhysicsSystem.cpp,找到queryAABB调用,把b2AABB的lowerBound和upperBound扩大为playerPos ± 500.0f。
现象5:启用ENABLE_WEAPON_EDITOR后编译失败,提示ImGui::Begin()未定义
原因:ImGui子模块未初始化。项目用git submodule管理ImGui,但git clone默认不拉取子模块。
解决:在项目根目录执行git submodule update --init --recursive,再重新cmake。
5. 二次开发实战:给敌人加“护盾”机制——从需求分析到上线验证的全流程
5.1 需求拆解:护盾要满足什么才算“可维护”
接到需求:“敌人被击中时,先消耗护盾值,护盾归零后再扣血”。表面看只是加个字段,但真正在项目里落地,必须考虑四点:
- 数据可配置:护盾值、充能速度、最大值不能硬编码,得进
EnemyConfig.json; - 状态可观察:护盾变化要触发事件,让UI显示护盾条、音效系统播放充能音;
- 逻辑可复用:不能只写敌人,未来Boss、玩家护甲也要用同一套;
- 性能可接受:每帧检查护盾值不能引入额外遍历,得融入现有
HealthSystem。
结论:护盾不是新组件,而是HealthComponent的增强版——我们把它拆成ShieldComponent(护盾专属)+HealthComponent(生命值专属),两者共用一套事件机制。
5.2 代码改造四步法:组件→系统→事件→配置
第一步:新增ShieldComponent(src/components/ShieldComponent.h)
// src/components/ShieldComponent.h struct ShieldComponent { float currentShield = 0.0f; float maxShield = 100.0f; float rechargeRate = 5.0f; // 每秒回多少 float rechargeDelay = 1.0f; // 受击后延迟1秒才开始回 float lastHitTime = 0.0f; // 序列化支持(用于JSON加载) NLOHMANN_DEFINE_TYPE_INTRUSIVE(ShieldComponent, currentShield, maxShield, rechargeRate, rechargeDelay) };注意:
NLOHMANN_DEFINE_TYPE_INTRUSIVE是nlohmann/json库的宏,项目已集成,无需额外include。
第二步:改造HealthSystem,注入护盾逻辑(src/systems/HealthSystem.cpp)
// 在onDamage()函数内插入: void HealthSystem::onDamage(const DamageEvent& event) { auto& target = EntityManager::getInstance().getEntity(event.targetId); // 优先处理护盾 if (target.hasComponent<ShieldComponent>()) { auto& shield = target.getComponent<ShieldComponent>(); shield.lastHitTime = SDL_GetTicks() / 1000.0f; // 记录受击时间 if (shield.currentShield > 0) { shield.currentShield = std::max(0.0f, shield.currentShield - event.damage); // 护盾被击穿,触发护盾破碎事件 if (shield.currentShield <= 0) { EventSystem::getInstance().publish(ShieldBrokenEvent{event.targetId}); } return; // 护盾吸收伤害,不扣血 } } // 护盾不存在或已耗尽,走原有扣血逻辑 auto& health = target.getComponent<HealthComponent>(); health.currentHP -= event.damage; // ... 后续死亡逻辑 }第三步:新增ShieldSystem处理自动充能(src/systems/ShieldSystem.cpp)
// src/systems/ShieldSystem.cpp void ShieldSystem::update(float deltaTime) { auto view = EntityManager::getInstance().view<ShieldComponent>(); float now = SDL_GetTicks() / 1000.0f; for (auto entity : view) { auto& shield = view.get<ShieldComponent>(entity); // 检查是否在充能延迟期内 if (now - shield.lastHitTime < shield.rechargeDelay) continue; // 充能:每帧增加rechargeRate * deltaTime shield.currentShield = std::min(shield.maxShield, shield.currentShield + shield.rechargeRate * deltaTime); } } // 注册到主循环(src/main.cpp) int main(int argc, char* argv[]) { // ... 初始化代码 ShieldSystem shieldSystem; // ... 在game loop里 shieldSystem.update(deltaTime); }第四步:配置文件升级(assets/configs/enemy_001.json)
{ "type": "enemy", "health": 50, "shield": { "maxShield": 30, "rechargeRate": 2.5, "rechargeDelay": 0.5 }, "speed": 80 }EnemyLoader在创建敌人时,会自动检查shield字段是否存在,存在则添加ShieldComponent。
5.3 验证技巧:用“时间切片快照”定位护盾逻辑是否生效
改完代码别急着跑,先用SDL2的SDL_GetTicks()打时间戳快照:
// 在ShieldSystem::update()开头加 static uint32_t lastLog = 0; if (SDL_GetTicks() - lastLog > 1000) { // 每秒打印一次 lastLog = SDL_GetTicks(); SDL_Log("Shield status: current=%.1f, max=%.1f", shield.currentShield, shield.maxShield); }然后启动游戏,用编辑器把敌人护盾设为100,站在原地看日志:
- 未受击时:
current=100.0→current=102.5(充能生效) - 被击中瞬间:
current=102.5→current=72.5(扣减正确) - 1秒后:
current=72.5→current=75.0(延迟结束,开始充能)
这种“时间切片”比断点调试更直观——护盾逻辑本质是时间敏感的,用时间戳验证比看变量值更可靠。
从那以后我每次加状态机类功能(眩晕、冰冻、燃烧),都强制走一遍“时间戳快照验证”:在状态变更点、持续效果点、恢复点各打一个log,用真实时间轴对齐逻辑预期。这招帮我避开了80%的“以为改对了,其实没生效”的玄学翻车。希望帮到你。
本文还有配套的精品资源,点击获取