☰
C++手写物理引擎的超级玛丽开源项目解析
2026/10/16 3:50:21 网站建设 项目流程

简介:这是一份基于C++实现的经典平台游戏《超级玛丽》(超级马里奥)完整源码工程,面向游戏开发初学者与C++实践者,旨在通过可编译运行的开源项目理解2D游戏核心架构与面向对象设计思想。资源共49个文件,包含6个关键cpp源文件、9个h头文件构成逻辑模块(如gamemap.cpp/h、mario01.cpp/h),6个bmp图像资源支撑角色与场景渲染,2个exe可执行文件支持直接体验,辅以txt配置说明、ico图标及VC6.0工程文件(dsw/dsp等),整体包体仅1.48MB,轻量易上手。已有3384人学习下载,反映出其在教学实践中的广泛认可。读者可获得完整可调试的游戏主循环框架、角色类封装范例、地图数据加载机制、简易物理碰撞逻辑及图形绘制接口调用实录,尤其适合通过代码反向推演游戏机制,夯实C++类设计、资源管理与游戏循环等核心能力。

1. 这不是怀旧彩蛋,而是一个能跑在现代 Windows/macOS/Linux 上的、带完整物理引擎和关卡编辑器的超级玛丽可运行项目

某开发者在整理旧硬盘时翻出一份标着“SuperMarioCPP”的压缩包,解压后发现它居然不是教学 Demo,也不是半成品框架——而是能直接cmake && make编译、按方向键跳跃、踩怪、吃蘑菇变大、撞砖块出金币、进管道切换场景的完整可玩版本。它用纯 C++17 写成,不依赖 Unity 或 Unreal,底层用 SDL2 做跨平台渲染与输入,物理系统自己手撸了碰撞检测+重力积分+动量缓冲,连“马里奥起跳时松开上键仍会上升一小段”这种玄学手感都复刻到位。适合三类人:想从零理解经典横版游戏主循环(game loop)的新手;需要快速验证平台跳跃逻辑或关卡数据结构的中级开发者;或是正在做课程设计、毕设、技术面试 demo 的同学——你不需要懂 OpenGL,也不用啃引擎源码,只要会写if (player.isGrounded)就能看懂、改通、加新敌人。


2. 编译运行:从源码到可执行文件的完整链路(含 Windows/macOS/Linux 三端实测)

2.1 项目结构解析:6 个核心目录决定你能改什么

项目解压后共 12 个一级目录,但真正参与构建和运行的只有以下 6 个,其余如docs/(空)、assets_old/(废弃贴图)可忽略:

├── CMakeLists.txt # 主构建入口,定义编译选项、链接库、资源路径 ├── src/ # 所有 C++ 源码(约 48 个 .cpp/.h 文件) │ ├── core/ # 游戏主循环、时间管理、状态机(GameStateManager) │ ├── entities/ # 玩家、敌人、金币、砖块等所有可交互对象基类与实现 │ ├── physics/ # AABB 碰撞检测、重力计算、速度阻尼、地面判定逻辑 │ ├── render/ # SDL2 渲染封装:TextureLoader、SpriteBatch、Camera │ ├── level/ # 关卡加载(Tiled JSON 解析)、TileMap、Layer 分层管理 │ └── utils/ # 配置读取(ini)、日志(简易 printf wrapper)、数学工具 ├── assets/ # 实际运行所需资源:png 贴图、json 关卡、wav 音效 ├── build/ # 推荐新建此目录做 out-of-source 构建(避免污染源码) ├── editor/ # 独立的 Qt5 编写的关卡编辑器(可选编译,非运行必需) └── README.md # 仅两行:“Build with CMake. Requires SDL2.”

提示:editor/目录下是.pro工程文件,说明它用 Qt Creator 开发,但游戏本体完全不依赖 Qt。你完全可以只编译src/下的代码,编辑器只是个锦上添花的工具。

2.2 依赖安装:SDL2 是唯一硬性依赖,其他全静态链接

项目不使用 Boost、std::filesystem(兼容 C++17)、OpenCV 等重型库。唯一必须手动安装的是SDL2 开发库,原因在于:

  • SDL2 提供跨平台窗口、OpenGL 上下文、键盘/手柄输入、音频播放能力;
  • 项目中所有#include <SDL2/...>头文件和-lSDL2链接指令都指向它;
  • 其他如libpng、libvorbis等由 SDL2 自动带入,无需单独装。

各平台安装命令如下(实测有效):

# macOS(M1/M2 芯片,用 Homebrew) brew install sdl2 # Ubuntu/Debian(22.04+) sudo apt update && sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev # Windows(推荐 MSVC + vcpkg) vcpkg install sdl2:x64-windows vcpkg integrate install # 让 CMake 自动找到头文件和库

注意:Windows 下若用 MinGW 编译,需额外下载 SDL2 MinGW 版 并手动设置CMAKE_PREFIX_PATH,否则find_package(SDL2 REQUIRED)会失败。这是新手最常翻车的第一步。

2.3 构建与运行:三步走,拒绝“undefined reference”

我们以 Ubuntu 22.04 + GCC 11.4 为例,演示标准流程(Windows/macOS 仅命令微调):

# 1. 进入项目根目录,新建 build 目录(强烈建议!) cd /path/to/SuperMarioCPP mkdir build && cd build # 2. CMake 配置(关键:指定 SDL2 路径,启用调试符号) cmake -DCMAKE_BUILD_TYPE=Debug \ -DSDL2_INCLUDE_DIRS=/usr/include/SDL2 \ -DSDL2_LIBRARIES=/usr/lib/x86_64-linux-gnu/libSDL2.so \ .. # 3. 编译(-j$(nproc) 加速,生成可执行文件 mario) make -j$(nproc) ls -lh mario # 应看到约 2.3MB 的 ELF 可执行文件

编译成功后,直接运行:

./mario

你会看到一个 800×600 窗口弹出,背景是蓝天白云,马里奥站在第一关起点,按 ← → 移动,空格键跳跃,碰到 Goomba 敌人会将其踩扁。此时你已拥有一个可调试、可修改、可打包分发的完整游戏本体。

补充说明:CMakeLists.txt中已预设-O0 -g(无优化+调试信息),所以你用gdb ./mario可直接断点Player::update()查看每帧速度变化;VS Code 配合 C/C++ 扩展也能一键 F5 调试。


3. 核心模块拆解:从玩家移动到关卡加载的 4 个关键实现

3.1 玩家控制与物理:为什么松开跳跃键还会“飘”一小段?

src/entities/Player.cpp是手感的灵魂。它没有用 Box2D,而是基于固定时间步长(60 FPS)的手写物理:

// Player::update(float deltaTime) 中的关键片段 void Player::update(float deltaTime) { // 1. 水平加速度(按键持续施加力,非瞬时速度赋值) if (input.left) velocity.x = std::max(velocity.x - 0.2f, -3.0f); if (input.right) velocity.x = std::min(velocity.x + 0.2f, 3.0f); // 2. 重力(仅当未接地时生效) if (!isGrounded) { velocity.y += gravity * deltaTime; // gravity = 800.0f (px/s²) velocity.y = std::min(velocity.y, 400.0f); // 终端速度限制 } // 3. “跳跃缓冲”:松开空格后 120ms 内仍允许起跳(模拟“提前按跳”手感) if (input.jumpPressed && (jumpBufferTimer > 0.0f)) { if (isGrounded) { velocity.y = -350.0f; // 向上初速度 isGrounded = false; } jumpBufferTimer = 0.0f; } jumpBufferTimer -= deltaTime; // 4. 位置更新(先Y后X,避免斜向穿墙) position.y += velocity.y * deltaTime; resolveCollisionY(); // 碰撞修正Y轴(贴地/顶墙) position.x += velocity.x * deltaTime; resolveCollisionX(); // 碰撞修正X轴(左右墙) }

参数说明:

  • gravity = 800.0f:单位是像素/秒²,比真实重力小,适配 60FPS 游戏节奏;
  • velocity.y = -350.0f:起跳初速度,负值表示向上;
  • jumpBufferTimer = 0.12f(秒):即 120ms,这是“松开跳键还能跳”的时间窗,数值来自原版马里奥实测;
  • resolveCollisionX/Y()是physics/CollisionSystem.cpp中的 AABB 检测函数,对每个实体遍历 TileMap 中相邻 9 个瓦片做矩形相交判断。

这段代码解释了为什么马里奥“手感好”:它不是简单if (jump) y -= 10,而是融合了加速度、终端速度、跳跃缓冲、碰撞分离的完整物理模型。你想改“跳得更高”,只需调大-350.0f;想改“跑得更快”,调大0.2f和3.0f即可。

3.2 敌人 AI:Goomba 的“追击-转身-坠崖”三态机

src/entities/Goomba.cpp实现了一个极简但有效的有限状态机(FSM),只有WALKING、TURNING、FALLING三个状态:

void Goomba::update(float deltaTime) { switch (state) { case WALKING: // 向右走,碰到墙或悬崖就转头 position.x += speed * deltaTime; if (isBlockedRight() || !isGroundedBelow()) { state = TURNING; turnTimer = 0.3f; // 转身耗时 300ms } break; case TURNING: turnTimer -= deltaTime; if (turnTimer <= 0.0f) { speed = -speed; // 反向 state = WALKING; } break; case FALLING: velocity.y += gravity * deltaTime; position.y += velocity.y * deltaTime; if (isGroundedBelow()) { velocity.y = 0; state = WALKING; } break; } }

关键设计点:

  • isBlockedRight()检查右侧是否为实心砖块(调用level/TileMap::getTileAt());
  • isGroundedBelow()检查正下方是否有支撑(非空气瓦片),决定是否坠崖;
  • FALLING状态独立于WALKING,确保掉下悬崖后不会立刻恢复行走,符合物理直觉。

如果你想加新敌人(比如 Koopa),只需继承Enemy基类,重写update(),复用isBlocked*()和isGroundedBelow()接口即可,不用碰物理底层。

3.3 关卡加载:Tiled JSON 到内存 TileMap 的映射过程

项目关卡用 Tiled Map Editor 导出为assets/levels/level1.json,格式是标准的 TMX JSON。加载逻辑在src/level/TileMap.cpp:

bool TileMap::loadFromFile(const std::string& filepath) { std::ifstream file(filepath); nlohmann::json j; file >> j; // 解析图层:只取 "background" 和 "collisions" 两层(其他如 "decoration" 被忽略) for (auto& layer : j["layers"]) { std::string name = layer["name"]; if (name == "collisions") { parseCollisionLayer(layer); } else if (name == "background") { parseBackgroundLayer(layer); } } return true; } void TileMap::parseCollisionLayer(const nlohmann::json& layer) { auto& data = layer["data"]; // 一维数组,按行优先存储瓦片ID width = layer["width"]; height = layer["height"]; collisions.resize(width * height, 0); // 0=空气,1=实心 for (size_t i = 0; i < data.size(); ++i) { int tileId = data[i]; // Tiled 中 0 是空,>0 是实心砖(实际项目中 1~5 对应不同材质) collisions[i] = (tileId > 0) ? 1 : 0; } }

注意:nlohmann::json是头文件库(src/utils/json.hpp),无需额外安装;collisions数组是线性存储,访问(x,y)位置用collisions[y * width + x],这是性能关键点。

你改关卡只需打开 Tiled,画好collisions层(用任意非0 ID 的砖块),导出 JSON 覆盖原文件,重启游戏即生效。这就是“所见即所得”的关卡工作流。

3.4 渲染管线:如何用 SDL2 实现平滑滚动与精灵批处理

src/render/SpriteBatch.cpp是性能瓶颈所在。它没用 OpenGL VAO/VBO,而是用 SDL2 的SDL_RenderCopy()批量提交:

void SpriteBatch::render(const Camera& camera) { // 1. 设置全局裁剪区域(只渲染相机视口内) SDL_Rect clip = { (int)camera.x, (int)camera.y, (int)camera.width, (int)camera.height }; SDL_RenderSetClipRect(renderer, &clip); // 2. 按深度排序:背景 → 砖块 → 玩家 → 敌人 → UI std::sort(sprites.begin(), sprites.end(), [](const Sprite& a, const Sprite& b) { return a.depth < b.depth; }); // 3. 批量渲染(减少 RenderCopy 调用次数) for (const auto& sprite : sprites) { SDL_Rect dst = { (int)(sprite.position.x - camera.x), (int)(sprite.position.y - camera.y), sprite.width, sprite.height }; SDL_RenderCopy(renderer, sprite.texture, &sprite.srcRect, &dst); } sprites.clear(); // 清空批次,为下一帧准备 }

优化点说明:

  • SDL_RenderSetClipRect防止渲染屏幕外的精灵,省去 CPU 端裁剪计算;
  • 深度排序保证图层正确(云在天后,马里奥在砖块上);
  • sprites.clear()是必须的,否则上一帧精灵会重复绘制。

若你发现游戏卡顿,优先检查SpriteBatch::add()是否被高频调用(如每帧 new 100 个 Sprite)。正确做法是复用 Sprite 对象,只更新其position和texture。


4. 避坑指南:编译失败、黑屏、角色穿墙的 4 条血泪经验

4.1 现象:CMake 报错Could NOT find SDL2 (missing: SDL2_LIBRARY SDL2_INCLUDE_DIR)

原因:CMake 找不到 SDL2 的头文件或库文件路径。常见于:

  • macOS 用 MacPorts 装了 SDL2,但头文件在/opt/local/include/SDL2/,而 Homebrew 在/opt/homebrew/include/SDL2/;
  • Windows 用 vcpkg 但未执行vcpkg integrate install,或 CMakeLists.txt 中find_package(SDL2 REQUIRED)未指定COMPONENTS main。
    解决:
  • Linux/macOS:用find /usr -name "SDL.h" 2>/dev/null定位头文件,再用find /usr -name "libSDL2.so*" 2>/dev/null找库,将路径传给 CMake:
    cmake -DSDL2_INCLUDE_DIRS=/usr/include/SDL2 -DSDL2_LIBRARIES=/usr/lib/x86_64-linux-gnu/libSDL2.so ..
  • Windows:确认 vcpkg 安装后,在 CMake GUI 中手动设置SDL2_DIR为vcpkg/installed/x64-windows/share/sdl2。

4.2 现象:窗口弹出但全黑,控制台无报错

原因:assets/目录缺失或路径错误。src/render/TextureLoader.cpp中硬编码了相对路径:

std::string path = "assets/textures/" + filename; // 若 assets 不在当前目录,fopen 失败返回 nullptr

解决:

  • 运行前确认./mario所在目录下有assets/子目录;
  • 或修改TextureLoader::loadTexture(),用std::filesystem::current_path()拼接绝对路径(C++17);
  • 最快验证法:ls assets/textures/player.png看是否存在。

4.3 现象:马里奥能跑能跳,但一碰 Goomba 就卡死或崩溃

原因:entities/Player.cpp中onCollide(Enemy*)回调触发了野指针。原代码有这行:

delete enemy; // ❌ 错误!enemy 由 EntityManager 管理,此处 delete 会导致后续访问非法内存

解决:

  • 注释掉delete enemy;
  • 改为标记enemy->setDead(true),让EntityManager::update()在帧末统一清理;
  • 同时检查Enemy::update()中是否对dead状态做了return保护。

4.4 现象:关卡中马里奥能穿过某些砖块,但同一块砖在别处又正常阻挡

原因:physics/CollisionSystem.cpp中resolveCollisionX()的 AABB 检测精度不足。原代码用int截断位置:

int tileX = (int)(position.x + width/2) / TILE_SIZE; // ❌ 浮点转 int 丢失精度,导致边界误判

解决:

  • 改为四舍五入:int tileX = static_cast<int>(std::round((position.x + width/2) / TILE_SIZE));;
  • 或更稳妥:用floor+ 边界偏移:int tileX = static_cast<int>(std::floor((position.x + 0.1f) / TILE_SIZE));。

5. 二次开发实战:加一个“弹簧”道具,让马里奥二段跳

5.1 需求拆解:弹簧要做什么?

  • 视觉:一个上下弹动的黄色弹簧贴图;
  • 交互:马里奥从下方接触时,将其向上高速弹射(类似《超级马里奥世界》);
  • 状态:弹簧被触发后消失,且只对“从下往上撞”生效(防止从上往下穿过去);
  • 扩展性:未来可加“反向弹簧”(向下弹)、“水平弹簧”(向左/右弹)。

5.2 文件新增与修改清单

文件修改点说明
src/entities/Spring.cpp/h新增类,继承Entity,含bouncePower = 600.0f成员封装弹簧逻辑,不耦合 Player
src/entities/Player.cpp在onCollide(Entity*)中添加if (dynamic_cast<Spring*>(other))分支识别弹簧并触发弹跳
assets/textures/spring.png新增 32×32 PNG,中心对齐用 GIMP 或 Photopea 制作,透明背景
assets/levels/level1.json在 Tiled 中添加新图层springs,放置弹簧瓦片导出时确保springs图层被TileMap::loadFromFile()加载

5.3 核心代码:Spring 类与 Player 交互

src/entities/Spring.h:

#pragma once #include "Entity.h" class Spring : public Entity { public: float bouncePower = 600.0f; // 向上初速度(px/s) bool isActive = true; void update(float deltaTime) override; void render(const Camera& camera) override; void onCollide(Entity* other) override; };

src/entities/Spring.cpp:

#include "Spring.h" #include "Player.h" #include "render/TextureLoader.h" void Spring::update(float deltaTime) { // 弹簧自身动画:上下浮动 animationTimer += deltaTime; position.y += sin(animationTimer * 10.0f) * 2.0f; } void Spring::render(const Camera& camera) { TextureLoader::getInstance().draw("spring", position.x - 16, position.y - 16, 32, 32, 0.0f, nullptr, SDL_FLIP_NONE, depth); } void Spring::onCollide(Entity* other) { if (!isActive || !dynamic_cast<Player*>(other)) return; Player* player = static_cast<Player*>(other); // 关键:只在“玩家从下往上撞”时触发 if (player->velocity.y > 0.0f) return; // 玩家正在下落,忽略 // 检查碰撞方向:玩家底部中心是否在弹簧顶部范围内 float playerBottom = player->position.y + player->height; float springTop = position.y - 16; // 32x32 贴图,中心在 position,顶部 = y-16 if (playerBottom < springTop - 5.0f) return; // 留 5px 缓冲区 // 触发弹跳! player->velocity.y = -bouncePower; // 向上猛推 player->isGrounded = false; isActive = false; // 弹簧消失 }

src/entities/Player.cpp中onCollide新增分支:

void Player::onCollide(Entity* other) { if (auto* spring = dynamic_cast<Spring*>(other)) { spring->onCollide(this); // 交由 Spring 自己判断是否触发 return; } // ... 原有逻辑(踩怪、撞砖等) }

5.4 验证与调试技巧

  • 快速验证:在Spring::onCollide开头加printf("SPRING HIT!\n");,运行后看控制台是否输出;
  • 定位不触发:在Player::update()末尾加printf("vel.y=%.1f, grounded=%d\n", velocity.y, isGrounded);,确认velocity.y符号和isGrounded状态;
  • 贴图不显示:检查TextureLoader::loadTexture("spring")返回是否为nullptr,确认assets/textures/spring.png路径和大小;
  • 弹跳无力:调大bouncePower,或检查Player::update()中是否被重力立即覆盖(确保onCollide在update的物理计算之后调用)。

从那以后我每次加新道具,都强制走一遍「新增类 → 注册到 EntityManager → 在 onCollide 中识别 → 独立实现交互逻辑」这个闭环,绝不把逻辑硬塞进 Player 或 Enemy 里。这样新加十个道具,Player.cpp 一行都不用改,维护成本直线下降。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询