简介:这是一份基于C++实现的经典平台游戏《超级玛丽》(超级马里奥)开源源码包,面向游戏开发初学者与C++实践者,旨在通过完整可运行项目理解2D游戏核心架构与面向对象设计思想。资源共49个文件,包含6个cpp与9个h头文件构成主体逻辑(如gamemap.cpp、mario01.h),6个bmp图像资源支撑角色与场景渲染,2个exe可执行文件支持即点即玩,辅以txt关卡配置、ico图标及VC6.0工程文件(dsw/dsp等),整体压缩包仅1.48MB,轻量易解压学习。已有3384人下载学习,是少有的结构清晰、模块分明的C++游戏教学级项目:主循环驱动、角色类封装、地图数据加载、简易物理碰撞与输入响应机制均完整呈现,尤其适合动手调试理解游戏帧更新、精灵动画控制与资源管理流程。
1. 这不是怀旧彩蛋,而是一套可编译、可调试、可二次开发的 Super Mario Bros 完整游戏逻辑骨架
某天我帮一个刚学完 C++ 类和继承的同学看毕设——他想做个横版跳跃游戏,但卡在“角色怎么同时处理碰撞、动画、输入、重力”上。我顺手扔给他一个 GitHub 上标着 “Super Mario Bros C++ Source Code” 的仓库,他本地cmake && make后直接跑出了熟悉的绿水管、红蘑菇和跳跃音效。更关键的是:他能单步进Player::update()看 Mario 怎么算下一帧位置,能在CollisionSystem.cpp里临时注释掉砖块检测,让 Mario 穿墙飞出去——这不是 ROM 模拟器,也不是 Unity Asset Store 里的黑匣子插件,而是一份用现代 C++17 写就、模块清晰、无第三方游戏引擎依赖(仅依赖 SFML 2.6)、从零构建物理与状态机的真实项目源码。它适合三类人:C++ 初学者练手面向对象设计;游戏开发入门者理解横版平台游戏核心循环;或者你正需要一个轻量级、可嵌入、带完整碰撞/动画/关卡数据驱动的游戏内核做原型验证。别被“超级玛丽”名字骗了——它没打包任何任天堂素材,所有图形资源都是开发者重绘的占位图,法律边界清晰,拿来即改即用。
2. 编译运行:从空目录到 Mario 跳起来,只需 5 分钟(含踩坑时间)
2.1 环境准备:SFML 是唯一硬依赖,版本锁死是血泪经验
这个项目不依赖 Unreal、Unity 或 Godot,它用 SFML(Simple and Fast Multimedia Library)做窗口、渲染、音频和输入。关键点:必须用 SFML 2.6.x,不是 2.5 也不是 2.7。我第一次用 Ubuntu 22.04 自带的libsfgl-dev(实际是 SFML 2.5.1)编译,链接时疯狂报undefined reference to sf::Clock::restart()——查了半小时才发现restart()是 2.6 新增接口。
正确做法(Linux/macOS):
# Ubuntu/Debian(推荐源码编译,避免包管理器版本滞后) wget https://www.sfml-dev.org/files/SFML-2.6.1-sources.zip unzip SFML-2.6.1-sources.zip && cd SFML-2.6.1 mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release -DSFML_BUILD_AUDIO=ON .. make -j$(nproc) && sudo make install # macOS(Homebrew 用户注意:brew install sfml 默认装 2.6+,但需确认) brew install sfml pkg-config --modversion sfml-system # 输出应为 2.6.xWindows 用户直接去 SFML 官网下载 2.6.1 SDK ,解压后把lib/和include/路径配进你的 CMakeLists.txt(后面会讲)。
提示:项目根目录下
CMakeLists.txt第 12 行写着find_package(sfml 2.6 REQUIRED COMPONENTS system window graphics audio),这就是版本锁死的证据——别试图降级绕过。
2.2 构建流程:CMake 是唯一入口,拒绝 Makefile 手动维护
项目结构干净,没有隐藏的.pro或.sln文件,纯 CMake 驱动:
super-mario-cpp/ ├── CMakeLists.txt # 顶层配置:定义项目名、C++标准、SFML查找、可执行文件 ├── src/ │ ├── main.cpp # 游戏入口:创建RenderWindow,启动主循环 │ ├── core/ # 核心框架:GameLoop、TimeStep、ResourceManager │ ├── entities/ # 实体基类:Entity(含position/velocity)、Player、Enemy、Item │ ├── systems/ # 独立系统:RenderSystem、PhysicsSystem、CollisionSystem │ ├── assets/ # 资源加载:TextureManager、SoundManager、TileMapLoader │ └── levels/ # 关卡数据:level1.tmx(Tiled 导出的 JSON 格式地图) └── resources/ # 原始素材:png/sound/wav 文件(全部可替换)构建命令(Linux/macOS):
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release \ -DSFML_INCLUDE_DIR=/usr/local/include/SFML \ # Linux 自编译路径 -DSFML_LIBRARY_DIR=/usr/local/lib make -j4 ./super-mario # 启动!Windows 用户在 CMake GUI 中设置SFML_INCLUDE_DIR为D:/SFML-2.6.1/include,SFML_LIBRARY_DIR为D:/SFML-2.6.1/lib,生成 Visual Studio 解决方案后编译即可。
2.3 运行时行为:窗口、输入、帧率全可控,不是黑盒
启动后你会看到一个 800x600 窗口,Mario 站在第一关起点。此时:
- 输入映射:
A/D或方向键左右移动,W或↑跳跃,Space二次跳(代码在Player.cpp的handleInput()); - 帧率锁定:
core/GameLoop.cpp中targetFps = 60,deltaTime精确到微秒,render()和update()分离调用; - 调试开关:按
F1切换碰撞框显示(绿色矩形),F2显示坐标和速度(DebugOverlay.cpp),这对调物理参数极其关键; - 关卡切换:碰到旗杆或死亡后自动重载
levels/level1.tmx,支持多关卡扩展(只需新增.tmx并修改LevelManager.cpp)。
注意:首次运行若报
Failed to load texture: resources/player.png,说明resources/目录没放在可执行文件同级路径——这是新手最高频错误。CMake 默认输出build/super-mario,所以resources/必须放在build/目录下,或修改assets/TextureManager.cpp中basePath为绝对路径。
3. 核心模块拆解:一个跳跃请求如何触发完整状态链路
3.1 Player 类:状态机驱动的实体,不是一堆 if-else
src/entities/Player.h定义了 Mario 的生命线。它继承自Entity,但核心是enum class State和State m_state成员:
// src/entities/Player.h enum class PlayerState { IDLE, RUNNING, JUMPING, FALLING, DEAD, INVINCIBLE // 踩敌人后短暂无敌 }; class Player : public Entity { private: PlayerState m_state; float m_jumpVelocity = -18.0f; // 负值表示向上 float m_maxFallSpeed = 24.0f; bool m_isGrounded = false; sf::Clock m_invincibilityTimer; public: void update(float deltaTime) override; void handleInput(const InputManager& input); void setState(PlayerState newState); void onCollisionWith(Enemy& enemy); };update()方法就是状态流转中枢:
// src/entities/Player.cpp void Player::update(float deltaTime) { // 1. 状态专属逻辑 switch (m_state) { case PlayerState::JUMPING: if (m_isGrounded) { // 离地瞬间 m_velocity.y = m_jumpVelocity; setState(PlayerState::FALLING); } break; case PlayerState::FALLING: m_velocity.y = std::min(m_velocity.y + 600.0f * deltaTime, m_maxFallSpeed); // 重力加速度 600 break; case PlayerState::INVINCIBLE: if (m_invincibilityTimer.getElapsedTime().asSeconds() > 2.0f) { setState(PlayerState::IDLE); m_invincibilityTimer.restart(); } break; } // 2. 通用物理:位置 = 位置 + 速度 * 时间 m_position += m_velocity * deltaTime; // 3. 边界检查:不能跳出屏幕左/右,但允许从顶部掉出(死亡判定) if (m_position.x < 0) m_position.x = 0; if (m_position.x > 750) m_position.x = 750; // 关卡宽度限制 }关键设计点:
m_velocity.y在JUMPING状态不直接赋值,而是在FALLING状态中由重力持续累加——这保证了“跳得高”和“下落快”的物理感;setState()不是简单赋值,它会触发onEnterState()回调(如JUMPING → PLAY_JUMP_SOUND);INVINCIBLE状态用sf::Clock而非int countdown,避免帧率波动导致无敌时间不准。
3.2 CollisionSystem:分离式碰撞检测,支持多种形状
碰撞不是写在Player::update()里的硬编码 if,而是独立系统systems/CollisionSystem.cpp统一调度:
// systems/CollisionSystem.cpp void CollisionSystem::checkAllCollisions() { // Step 1: Player vs Tiles(基于 TileMap 的 AABB 检测) auto playerBounds = m_player->getGlobalBounds(); for (const auto& tile : m_tileMap->getSolidTiles()) { if (playerBounds.intersects(tile.bounds)) { resolveCollision(*m_player, tile); } } // Step 2: Player vs Enemies(圆形检测,更精准) for (auto& enemy : m_enemies) { float dx = m_player->getPosition().x - enemy.getPosition().x; float dy = m_player->getPosition().y - enemy.getPosition().y; float distance = std::sqrt(dx*dx + dy*dy); if (distance < 24.0f) { // 半径 24px handlePlayerEnemyCollision(*m_player, enemy); } } } void CollisionSystem::resolveCollision(Entity& entity, const Tile& tile) { // AABB 分离轴定理:计算最小穿透深度,沿法线方向弹回 sf::Vector2f overlap = calculateOverlap(entity.getGlobalBounds(), tile.bounds); if (std::abs(overlap.x) > std::abs(overlap.y)) { // X 方向碰撞:阻止水平移动 entity.setPosition(entity.getPosition().x - overlap.x, entity.getPosition().y); entity.setVelocityX(0); // 摩擦力归零 } else { // Y 方向碰撞:标记接地或反弹 if (overlap.y > 0) { // 从上往下撞(落地) entity.setPosition(entity.getPosition().x, tile.bounds.top - entity.getSize().y); entity.setVelocityY(0); m_player->setGrounded(true); } else { // 从下往上撞(顶砖块) entity.setPosition(entity.getPosition().x, tile.bounds.top + tile.bounds.height); entity.setVelocityY(0); } } }为什么这样设计?
TileMap提供getSolidTiles()接口,让关卡数据与碰撞逻辑解耦;calculateOverlap()返回sf::Vector2f,后续可扩展为斜坡、弹簧等复杂地形;handlePlayerEnemyCollision()里根据m_player->getState()判断:若为INVINCIBLE则忽略,若为IDLE/RUNNING则触发死亡,若为FALLING且enemy.isAlive()则播放踩踏音效并设置enemy.die()—— 这就是“踩蘑菇”的完整逻辑链。
3.3 LevelManager:Tiled 地图驱动,JSON 解析即关卡
关卡不是写死在代码里,而是用 Tiled Map Editor 编辑的levels/level1.tmx(实际是 JSON 格式)。assets/TileMapLoader.cpp负责解析:
// assets/TileMapLoader.cpp bool TileMapLoader::loadFromFile(const std::string& filename, TileMap& map) { std::ifstream file(filename); nlohmann::json j; file >> j; map.width = j["width"]; map.height = j["height"]; map.tileWidth = j["tilewidth"]; map.tileHeight = j["tileheight"]; // 解析图层:0=背景,1=碰撞层("collision"),2=实体层("entities") for (const auto& layer : j["layers"]) { if (layer["name"] == "collision") { loadCollisionLayer(layer, map); } else if (layer["name"] == "entities") { loadEntityLayer(layer, map); } } return true; } void TileMapLoader::loadCollisionLayer(const nlohmann::json& layer, TileMap& map) { const auto& data = layer["data"]; for (size_t i = 0; i < data.size(); ++i) { int gid = data[i]; if (gid != 0) { // gid=0 表示空白瓦片 int x = i % map.width; int y = i / map.width; sf::FloatRect bounds(x * map.tileWidth, y * map.tileHeight, map.tileWidth, map.tileHeight); map.solidTiles.push_back({bounds, gid}); } } }实操价值:
- 你用 Tiled 新建地图,画一个红色矩形图层命名为
"collision",导出为 JSON,替换levels/level1.tmx,重启游戏——新碰撞区域立刻生效; loadEntityLayer()会读取"type": "goomba"或"type": "coin"的对象,动态创建Goomba或Coin实例并加入m_enemies容器;- 所有瓦片 ID(
gid)对应resources/tiles.png中的索引,换贴图无需改代码。
4. 避坑指南:那些让我重启三次编译器的致命细节
4.1 现象:undefined reference to vtable for Player
原因:Player类声明了虚函数(如update()继承自Entity::update()),但忘记定义Player::~Player()析构函数,或定义了却没加override关键字,导致虚表未生成。
解决:在Player.h中添加virtual ~Player() override = default;,并在Player.cpp中确保update()函数签名与基类完全一致(包括const修饰符)。
4.2 现象:Mario 跳跃高度随帧率剧烈波动,60fps 时跳 3 格,30fps 时跳 6 格
原因:update()中用了m_velocity.y += gravity而非m_velocity.y += gravity * deltaTime。当deltaTime为 1/30 秒时,加速度累积量翻倍。
解决:检查Player::update()和PhysicsSystem::update()中所有物理量更新,必须乘deltaTime。本项目已修正,但如果你合并了其他分支代码,务必 grep"gravity +"。
4.3 现象:按住A键不放,Mario 加速到飞出屏幕
原因:Player::handleInput()中对input.isKeyPressed(Key::A)使用了isKeyPressed(每帧触发一次),而非isKeyHeld(持续触发)。正确逻辑应是:按键按下时设m_velocity.x = -moveSpeed,松开时设m_velocity.x = 0。
解决:修改handleInput(),用input.isKeyHeld(Key::A)控制速度,用input.wasKeyPressed(Key::W)控制跳跃触发——这是平台游戏输入响应的黄金法则。
4.4 现象:make报错fatal error: SFML/Graphics.hpp: No such file or directory
原因:CMake 找到了 SFML 库(.so/.dll),但没找到头文件(.hpp)。常见于 Windows 用户只设置了SFML_LIBRARY_DIR却漏设SFML_INCLUDE_DIR。
解决:在CMakeLists.txt中确认find_package(sfml REQUIRED)后,显式添加include_directories(${SFML_INCLUDE_DIR});或使用 CMake GUI 重新配置,确保两个路径都指向 SFML SDK 的include/和lib/。
4.5 现象:F1 显示碰撞框,但 Mario 穿过砖块无反应
原因:CollisionSystem::checkAllCollisions()调用时机错误。本项目中它必须在Player::update()之后、RenderSystem::render()之前调用。如果误放在update()前,则检测的是上一帧位置。
解决:检查core/GameLoop.cpp中updateSystems(deltaTime)的调用顺序,确保m_collisionSystem.update(deltaTime)在m_player->update(deltaTime)之后。
5. 二次开发实战:给 Mario 加个“冲刺”技能,30 分钟搞定
5.1 需求分析:冲刺不是加速,而是状态叠加与能量管理
我们不希望冲刺变成“永远更快”,而是:
- 按住
Shift键蓄力 0.5 秒,松开后向前高速突进 1 秒; - 冲刺中无敌(忽略敌人碰撞),但撞墙会停止;
- 每次冲刺消耗 1 点“能量”,能量上限 3,静止时每秒恢复 1 点;
- 冲刺结束自动切回
RUNNING状态。
这需要新增:状态枚举、能量变量、输入检测逻辑、独立更新分支。
5.2 代码改造四步走
Step 1:扩展 PlayerState 和成员变量
// src/entities/Player.h enum class PlayerState { IDLE, RUNNING, JUMPING, FALLING, DEAD, INVINCIBLE, SPRINTING // 新增 }; class Player : public Entity { private: // ... 原有成员 int m_sprintEnergy = 3; // 当前能量 int m_maxSprintEnergy = 3; sf::Clock m_sprintTimer; // 记录冲刺持续时间 sf::Clock m_sprintChargeTimer; // 记录蓄力时间 bool m_isCharging = false; // 是否正在蓄力 float m_sprintSpeed = 400.0f; // 冲刺速度(原 runSpeed=200) };Step 2:修改 handleInput() 增加蓄力逻辑
// src/entities/Player.cpp void Player::handleInput(const InputManager& input) { // ... 原有移动/跳跃逻辑 // 新增:冲刺蓄力 if (input.isKeyHeld(Key::LShift) && m_state == PlayerState::RUNNING && m_sprintEnergy > 0) { m_isCharging = true; m_sprintChargeTimer.restart(); // 从按下 Shift 开始计时 } else if (m_isCharging && input.wasKeyReleased(Key::LShift)) { // 松开 Shift 且蓄力达标,触发冲刺 if (m_sprintChargeTimer.getElapsedTime().asSeconds() >= 0.5f) { m_sprintEnergy--; m_velocity.x = m_facingRight ? m_sprintSpeed : -m_sprintSpeed; setState(PlayerState::SPRINTING); m_sprintTimer.restart(); } m_isCharging = false; } }Step 3:在 update() 中添加 SPRINTING 状态专属逻辑
void Player::update(float deltaTime) { switch (m_state) { // ... 原有 case case PlayerState::SPRINTING: // 冲刺中无敌:跳过敌人碰撞检测(CollisionSystem 会检查 m_state) if (m_sprintTimer.getElapsedTime().asSeconds() >= 1.0f) { // 冲刺结束,减速回正常速度 m_velocity.x *= 0.8f; // 惯性衰减 if (std::abs(m_velocity.x) < 50.0f) { m_velocity.x = 0; setState(PlayerState::RUNNING); } } break; } // 能量恢复:静止时每秒 +1 if (std::abs(m_velocity.x) < 10.0f && std::abs(m_velocity.y) < 10.0f) { if (m_sprintEnergy < m_maxSprintEnergy) { static sf::Clock recoveryClock; if (recoveryClock.getElapsedTime().asSeconds() >= 1.0f) { m_sprintEnergy++; recoveryClock.restart(); } } } }Step 4:修改 CollisionSystem 跳过冲刺中的敌人碰撞
// systems/CollisionSystem.cpp void CollisionSystem::checkAllCollisions() { // ... Player vs Tiles 检测(保留) // Player vs Enemies:仅当非冲刺状态才检测 if (m_player->getState() != PlayerState::SPRINTING) { for (auto& enemy : m_enemies) { // ... 原有距离检测 handlePlayerEnemyCollision(*m_player, enemy); } } }5.3 验证与调试技巧
- 可视化能量条:在
DebugOverlay.cpp的draw()中添加:sf::RectangleShape energyBar(sf::Vector2f(100, 10)); energyBar.setFillColor(sf::Color::Green); energyBar.setPosition(10, 40); energyBar.setScale(static_cast<float>(m_player->getSprintEnergy()) / 3.0f, 1.0f); window.draw(energyBar); - 日志追踪:在
setState(PlayerState::SPRINTING)前加std::cout << "SPRINT START! Energy: " << m_sprintEnergy << "\n";,确认触发时机; - 参数调优:
m_sprintSpeed初始设 300,测试发现太慢,调到 400;m_sprintTimer从 0.8 秒改为 1.0 秒,避免突进距离过短; - 边界测试:在冲刺中按
W跳跃,观察是否保持水平速度(应保持);冲刺撞墙后m_velocity.x是否清零(resolveCollision()中已处理)。
从那以后我每次给平台游戏角色加新能力,都强制走一遍这个流程:先定义状态枚举,再加成员变量,然后在handleInput()埋触发点,在update()写状态逻辑,最后在CollisionSystem做交互裁决。这套模式让我在三个不同项目里复用了同一套状态机骨架,没再为“技能打断”“状态覆盖”“输入延迟”掉过头发。希望帮到你。
本文还有配套的精品资源,点击获取