100、C++项目实战:完整游戏项目(平台跳跃游戏)
终于到了第100章。说实话,走到这一步挺不容易的。前面99个实战项目,我们从基础语法、数据结构、网络编程、图形界面一路打怪升级,现在终于要做一个真正完整的游戏了。
平台跳跃游戏,听起来是不是有点小激动?我当年第一次用C++写完一个能跑能跳的小人时,那种成就感比拿到什么证书都强。今天我们就来拆解这个项目的核心架构,让你也能亲手做出来。
项目整体架构
一个完整的平台跳跃游戏,说白了就是三个核心模块:游戏循环、物理引擎、渲染系统。这三者缺一不可。
我个人习惯先把架构图画出来,再动手写代码。这样心里有谱,不会写着写着就迷路了。
游戏循环:一切的核心
游戏循环就是整个游戏的「心脏」。它不停地做三件事:读输入 → 更新状态 → 渲染画面。每循环一次就是一帧。
我记得刚开始做游戏时,直接把循环写成了 while(true),结果CPU直接飙到100%。后来才明白,必须控制帧率。
class GameEngine {
public:
void run() {
const int TARGET_FPS = 60;
const int FRAME_DELAY = 1000 / TARGET_FPS;
Uint32 frameStart;
int frameTime;
while (isRunning) {
frameStart = SDL_GetTicks();
handleInput(); // 处理键盘/鼠标
update(); // 更新物理和逻辑
render(); // 绘制画面
frameTime = SDL_GetTicks() - frameStart;
if (frameTime < FRAME_DELAY) {
SDL_Delay(FRAME_DELAY - frameTime); // 控制帧率
}
}
}
private:
bool isRunning = true;
};
SDL_Delay 控制帧率是最简单的方法。但如果你追求更平滑的动画,可以考虑「固定时间步长」方案,把物理更新和渲染分开。
物理引擎:让角色动起来
物理引擎是平台跳跃游戏的灵魂。说白了,就是让角色感觉「有重量」——跳起来会下落,碰到平台会停住。
核心就三个东西:重力、速度、碰撞。
重力与跳跃
重力其实很简单:每帧给角色的垂直速度加一个固定值。跳跃就是瞬间给一个向上的初速度。
class Player {
public:
float x, y; // 位置
float vx, vy; // 速度
bool isOnGround; // 是否在地面
void applyGravity(float gravity, float deltaTime) {
if (!isOnGround) {
vy += gravity * deltaTime; // 重力加速度
}
}
void jump(float jumpForce) {
if (isOnGround) {
vy = -jumpForce; // 向上跳,所以是负值
isOnGround = false;
}
}
void update(float deltaTime) {
x += vx * deltaTime;
y += vy * deltaTime;
}
};
deltaTime 忘了乘进去。结果游戏在60帧时跑得正常,换到144帧显示器上,角色直接飞出去了。帧率无关的物理更新,这是基本功。
碰撞检测
平台跳跃的碰撞检测,最常用的是 AABB(轴对齐包围盒)。说白了就是判断两个矩形有没有重叠。
struct AABB {
float x, y; // 左上角
float w, h; // 宽高
bool intersects(const AABB& other) const {
return x < other.x + other.w &&
x + w > other.x &&
y < other.y + other.h &&
y + h > other.y;
}
};
嗯,这里要注意:碰撞检测不能只做「是否重叠」的判断。你想想看,如果角色每帧移动5像素,而平台只有4像素厚,角色可能直接穿过去。这就是传说中的「隧道效应」。
我建议的做法是:分轴检测。先沿X轴移动并检测,再沿Y轴移动并检测。这样能有效避免穿透。
渲染系统:让画面动起来
渲染系统负责把游戏世界画到屏幕上。我用的是 SDL2,轻量又好用。
核心思路是:双缓冲 + 纹理缓存。双缓冲避免画面闪烁,纹理缓存减少重复加载。
class Renderer {
public:
bool init(const char* title, int width, int height) {
window = SDL_CreateWindow(title,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
width, height, SDL_WINDOW_SHOWN);
renderer = SDL_CreateRenderer(window, -1,
SDL_RENDERER_ACCELERATED);
return window && renderer;
}
void clear() {
SDL_SetRenderDrawColor(renderer, 135, 206, 235, 255); // 天蓝色背景
SDL_RenderClear(renderer);
}
void drawTexture(SDL_Texture* tex, int x, int y, int w, int h) {
SDL_Rect dst = {x, y, w, h};
SDL_RenderCopy(renderer, tex, nullptr, &dst);
}
void present() {
SDL_RenderPresent(renderer); // 交换缓冲区
}
private:
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
};
游戏实体管理
一个平台跳跃游戏里,有玩家、敌人、平台、金币、陷阱……这些东西怎么管理?
我习惯用 组件模式。每个实体是一个容器,挂载不同的组件(位置组件、渲染组件、物理组件)。这样灵活,也好扩展。
class Entity {
public:
int id;
std::unordered_map<std::string, Component*> components;
template<typename T>
T* getComponent(const std::string& name) {
auto it = components.find(name);
if (it != components.end()) {
return dynamic_cast<T*>(it->second);
}
return nullptr;
}
void addComponent(const std::string& name, Component* comp) {
components[name] = comp;
}
};
// 使用示例
Entity player;
player.addComponent("transform", new TransformComponent(100, 300));
player.addComponent("physics", new PhysicsComponent());
player.addComponent("sprite", new SpriteComponent("player.png"));
关卡设计:从文件加载
硬编码关卡?别闹了。我建议用 JSON 或 CSV 文件来定义关卡。这样改关卡不用重新编译代码。
| 文件格式 | 优点 | 缺点 |
|---|---|---|
| CSV(二维数组) | 简单直观,适合瓦片地图 | 表达能力有限 |
| JSON | 结构清晰,支持嵌套 | 需要解析库 |
| 自定义二进制 | 加载快,体积小 | 调试困难 |
我个人推荐先用 JSON,等游戏做大了再考虑二进制。用 nlohmann/json 这个库,几行代码就能解析。
// 关卡 JSON 示例
{
"width": 20,
"height": 15,
"tileSize": 32,
"playerStart": {"x": 1, "y": 10},
"platforms": [
{"x": 2, "y": 12, "w": 5, "h": 1},
{"x": 8, "y": 10, "w": 3, "h": 1},
{"x": 14, "y": 8, "w": 4, "h": 1}
],
"enemies": [
{"x": 6, "y": 11, "type": "slime"},
{"x": 12, "y": 9, "type": "bat"}
],
"coins": [
{"x": 4, "y": 11},
{"x": 10, "y": 9}
]
}
声音与音效
没有声音的游戏,就像没有调料的菜。SDL2 的音频库 SDL_mixer 可以轻松播放音效和背景音乐。
// 初始化音频
Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048);
// 加载音效
Mix_Chunk* jumpSound = Mix_LoadWAV("jump.wav");
Mix_Chunk* coinSound = Mix_LoadWAV("coin.wav");
// 播放音效
Mix_PlayChannel(-1, jumpSound, 0); // 播放一次
// 加载背景音乐
Mix_Music* bgm = Mix_LoadMUS("background.ogg");
Mix_PlayMusic(bgm, -1); // 循环播放
调试与性能优化
游戏开发中,调试是家常便饭。我常用的几个技巧:
- 显示 FPS:在窗口标题或角落显示帧率,一眼就能看出性能问题。
- 碰撞盒可视化:把 AABB 边框画出来,方便调试碰撞逻辑。
- 日志系统:记录关键事件(玩家死亡、金币收集),方便排查 bug。
void Game::toggleDebug() {
showDebug = !showDebug;
}
void Game::renderDebug() {
if (!showDebug) return;
// 显示 FPS
std::string fpsText = "FPS: " + std::to_string(currentFPS);
renderText(fpsText, 10, 10);
// 绘制碰撞盒
for (auto& entity : entities) {
auto* phys = entity->getComponent<PhysicsComponent>("physics");
if (phys) {
SDL_SetRenderDrawColor(renderer, 255, 0, 0, 128);
SDL_RenderDrawRect(renderer, &phys->getAABB());
}
}
}
打包与发布
游戏写完了,怎么给别人玩?你不能让人家先装个 Visual Studio 再编译吧。
我建议用 CMake + CPack 来打包。把所有依赖(SDL2、SDL_image、SDL_mixer 等)的 DLL 都放到同一个目录下,然后打包成 ZIP 或安装包。
# CMakeLists.txt 片段
install(TARGETS MyPlatformGame DESTINATION bin)
install(DIRECTORY assets/ DESTINATION assets)
install(FILES
${SDL2_DLL}
${SDL2_IMAGE_DLL}
${SDL2_MIXER_DLL}
DESTINATION bin
)
# 打包命令
# cpack -G ZIP # 生成 ZIP 包
# cpack -G NSIS # 生成 Windows 安装包
SDL2.dll 放进去,结果发给朋友后,他双击游戏直接报错「找不到 DLL」。从那以后,我每次打包都会用 Dependency Walker 检查一遍依赖。
总结
平台跳跃游戏,说难不难,说简单也不简单。核心就是:游戏循环驱动、物理引擎模拟、渲染系统输出。把这三点吃透了,你就能做出一个能跑能跳、有碰撞有收集的完整游戏。
我做了这么多年 C++ 项目,最深的体会是:别怕动手。代码写出来,跑起来,哪怕有 bug,那也是进步。你想想看,第一个平台跳跃游戏《大金刚》才多少行代码?现在的你,完全有能力做出更好的。
好了,拿起键盘,开始写你的第一个游戏循环吧。