刚把一个用 C++ 写的小游戏从零做到能流畅跑起来的版本,就是“球球飞车”。这个小项目的核心其实不难,一个小球在起伏路面上高速滚动,玩家用键盘控制它左右移动,躲避障碍物、收集能量块,跑得越远分数越高。但真正上手写代码之后会发现,里面涉及物理模拟、碰撞检测、游戏循环、状态管理这些游戏开发的基础问题,每一个都值得认真处理。这篇内容适合正在学 C++、想做点小项目练手的人参考,也适合想做游戏却不知道第一步怎么迈的人。我把完整的实现思路、关键代码、踩坑记录都整理在下面了。
1. 项目定位与设计思路
1.1 玩法原型与交互设计
做之前先想清楚“这个游戏玩什么”。球球飞车的原型其实来自我最早期玩过的那种“躲障碍滚球”小游戏,但我不打算直接抄,而是把玩法做了点调整:游戏采用侧视角,画面里有一条起伏不停的路面,玩家控制一个带纹理的小球在路面上左右移动。小球会因为重力、坡度和速度变化自动滚动,玩家只需要管左右方向,不用管加减速,这种设计让上手门槛很低,同时保留了高速状态下的紧张感。
交互方式很简单:方向键左右控制小球横向移动,空格键负责游戏开始和暂停。核心目标是“跑得更远”,所以我没有设计终点,而是一段无限生成的路面,随着时间推移,障碍物变多、路面起伏加剧、球的整体速度也会慢慢提高。
这个原型定下来之后,后续所有技术实现都有了明确边界:路面是可以动态生成的地形,小球是带质量的刚体,障碍物是固定摆放的碰撞体,分数则由里程换算。明确了这些,写代码才不会乱。
1.2 技术栈选型的纠结过程
最开始我有几个方案。第一个是用控制台字符画直接渲染,方案很简单,但画面表现力几乎没有,连“球在滚动”这种最基础的视觉反馈都做不出来。第二个是用某图形库直接画图,优点是上手快,API 对新手很友好,但问题是不跨平台,写出来的东西只能在某个系统上玩。第三个是用一个轻量级多媒体库配合扩展组件做渲染和音频,前期要多写几行初始化代码,但跨平台、稳定,并且渲染 API 是标准方式,后面想做复杂效果不会卡住。
我最后选了第三个方案,理由有几个:
| 对比项 | 字符画控制台 | 简易图形库 | 轻量级多媒体库 |
|---|---|---|---|
| 上手难度 | 极低 | 低 | 中等 |
| 跨平台能力 | 有 | 差 | 好 |
| 渲染能力 | 极弱 | 中等 | 强 |
| 音频支持 | 无 | 需额外想办法 | 自带扩展 |
| 后续扩展空间 | 几乎没有 | 受限 | 灵活 |
如果你只是交一个期末作业,用简易图形库确实更快,2 小时就能见效果;但如果你想把游戏做成一个系列项目,或者后面想发布到别的平台试试水,那跨平台方案毫无疑问是更合适的选择。我身边有一位同期做游戏的朋友,他一开始用简易图形库写了个飞机大战,后来想移植到 Linux 环境,结果整个渲染层全部重写,相当于推倒重来。我听完这个教训,果断在一开始就选择了跨平台方案。
开发环境方面,我用的是标准的 C++17,配了一个文本编辑器加命令行编译工具链,没有用大型 IDE,因为项目规模不算大,命令行编译速度反而更快,改完代码 3 秒就能出可执行文件。当然,如果你习惯 IDE,用 Visual Studio 或者别的 C++ IDE 也是一样的,核心代码完全通用。
1.3 游戏循环与整体状态架构
很多新手写游戏,习惯把逻辑全塞进一个 while 循环里,结果发现窗口刷新、物理更新、按键响应、弹窗逻辑全搅在一起。这个项目从一开始我就把结构拆成了两块:一块是“系统刷新层”,负责处理窗口事件、物理更新、渲染调度;另一块是“游戏状态层”,负责根据当前状态决定调用哪些逻辑。
游戏状态我分了四种:初始化状态、运行状态、暂停状态、结束状态。每个状态用一个枚举值表示,主循环里拿到状态后走不同的处理分支。比如运行状态会处理障碍物生成、分数累加、碰撞判定,暂停状态则只处理键盘监听和画面覆盖层刷新,结束状态会弹出“重新开始”和“退出”两个选项。
这样设计的直接好处是,后面加菜单界面、加设置页、加教程关卡都容易得多,不需要改动主循环结构。实际写下来,这个状态机模式是这个项目里性价比最高的架构决策之一,强烈建议你不管做什么小游戏,第一件事先把状态机设计好。
2. 环境搭建与最小游戏原型
2.1 初始化窗口与渲染配置
确定了技术选型后,第一件事是把一个空白窗口跑起来。这里有一个常见坑:很多人一上来就写游戏逻辑,结果连窗口都没法正常关闭,或者一调整窗口大小画面就花掉。所以我建议第一步只做三件事:初始化图形环境、创建一个窗口、创建渲染器。
我用的是 C++ 与一个跨平台图形库的组合,具体实现代码大致如下:
#include <SDL2/SDL.h> const int SCREEN_WIDTH = 800; const int SCREEN_HEIGHT = 600; int main(int argc, char* argv[]) { if (SDL_Init(SDL_INIT_VIDEO) < 0) { SDL_Log("初始化失败: %s", SDL_GetError()); return -1; } SDL_Window* window = SDL_CreateWindow( "球球飞车", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SCREEN_WIDTH, SCREEN_HEIGHT, SDL_WINDOW_SHOWN ); if (!window) { SDL_Log("窗口创建失败: %s", SDL_GetError()); SDL_Quit(); return -1; } SDL_Renderer* renderer = SDL_CreateRenderer( window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC ); if (!renderer) { SDL_Log("渲染器创建失败: %s", SDL_GetError()); SDL_DestroyWindow(window); SDL_Quit(); return -1; } // 主循环暂时留空 SDL_Event event; bool running = true; while (running) { while (SDL_PollEvent(&event)) { if (event.type == SDL_QUIT) running = false; } SDL_SetRenderDrawColor(renderer, 32, 32, 32, 255); SDL_RenderClear(renderer); // 画面绘制会放在这里 SDL_RenderPresent(renderer); } SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); return 0; }注意几个细节:渲染器创建时我用了一个标志让刷新和显示器的垂直同步绑定,这样可以避免画面撕裂,也顺便把帧率锁在了显示器的刷新率附近,比如 60 帧。窗口尺寸我直接用了常量定义,800x600 这个分辨率对这类小游戏来说很合适,不需要高分辨率贴图,运行压力也小。
2.2 绘制小球与起伏路面
窗口跑起来以后,最兴奋的一步就是把小球画出来了。我先把路面设计成一系列顶点组成的折线,顶点之间用线段连接,球是一个填充圆形。这样不仅看起来干净,物理判定也方便,因为后面检测碰撞时,只需要对球心到每一段线段做距离计算就行。
路面顶点我用一个标准向量保存,每一帧都会根据游戏速度向左平移。生成新路面的规则很直接:预生成一段路面点,当路面最左侧的点超出屏幕时,把后面的点顺移上来,同时在右侧补一个新随机高度的顶点。
绘制部分的核心代码:
struct Vec2 { float x, y; Vec2(float xv = 0, float yv = 0) : x(xv), y(yv) {} }; std::vector<Vec2> roadPoints; SDL_FillCircle(renderer, ball.x, ball.y, ballRadius); // 封装好的填充圆函数 void DrawRoad(SDL_Renderer* renderer, const std::vector<Vec2>& points) { for (size_t i = 0; i + 1 < points.size(); ++i) { SDL_RenderDrawLine(renderer, (int)points[i].x, (int)points[i].y, (int)points[i + 1].x, (int)points[i + 1].y); } }路面起伏高度我用了伪随机数加平滑插值生成,避免出现太突兀的尖角,不然小球滚上去会表现得很假。像素风或者线条风其实都是很好看的风格,没必要一上来就做复杂材质。对于新手来说,把图形画成简单几何体样式,既降低了美术成本,又显得干净利落。
2.3 让球动起来:输入与基础移动
球画出来了,路面有了,接下来就要让球响应键盘。传统控制台程序里,按键判断是阻塞式的,但图形游戏里每一帧都在循环,我们要做的是检测“当前是否有按键按下”,而不是“等一个按键事件”。SDL 里可以通过查询键盘状态来完成。
const Uint8* keyState = SDL_GetKeyboardState(nullptr); if (keyState[SDL_SCANCODE_LEFT]) ball.x -= moveSpeed * deltaTime; if (keyState[SDL_SCANCODE_RIGHT]) ball.x += moveSpeed * deltaTime;这里的 deltaTime 是这一帧和上一帧之间的时间差,单位是秒。新手最容易在这里翻车:如果不乘 deltaTime,在 60 帧显示器上球会跑得飞快,而换到 120 帧高刷显示器后球会快得像瞬移,游戏根本没法定难度。我一开始也踩过这个坑,直接导致球速在不同电脑上完全不一致。改用 deltaTime 之后,无论帧率怎么变,球的移动速度都恒定。
为了让小球滚起来更符合“飞车”的感觉,我还给球加了一个虚拟角速度:当球左右移动时,表面纹理也跟着滚动,这纯粹是视觉欺骗,但玩家一眼看过去就会觉得“球是真的在滚动”。这个效果只需要维护一个角度值,每帧根据移动方向和速度增量累加即可,成本几乎为零,但观感提升很大。
3. 核心机制拆解与物理建模
3.1 球体滚动与速度约束
小球在路面上滚动的物理,一开始我试图完整模拟刚体动力学,做法过头了,代码复杂不说,小球还老是从路面上弹飞或陷进去。后来我调整思路,做了一套“简化物理模型”:只需要满足几个基础规律,玩家视觉和手感就足够真实。
基础规律有三条:
- 下坡时球自然加速,上坡时球自然减速
- 球在平整路面滚动时不突然加速或减速
- 球不会穿透路面
实现上,每帧我需要计算球所在位置的路面斜率。如果当前位置的路面是倾斜的,就把重力沿坡面方向分解,得到沿坡面的加速度,叠加到球的速度上。公式是:沿坡面的加速度 = 重力加速度 * sin(路面倾角)。
一个更简洁的处理办法:不需要显式计算倾角,直接用相邻两个路面顶点的坐标差就能算方向向量。我把这段逻辑封装成一个“更新球场位置”的函数,里面做了三件事:
- 根据当前位置找到所属的那段路面线段
- 算出坡面单位方向向量
- 把重力分量叠加到速度向量上,并更新球心坐标
需要特别留意的是,如果球速度过快,一帧内可能跨过一段很短的线段,导致找不到正确的支撑点。这个现象的术语叫“隧道效应”,对应到游戏里就是高速时球偶尔会穿模。解决办法不复杂:限制单帧最大移动距离,如果超过路段长度,就把一帧拆成多个子步更新。
3.2 碰撞检测:圆与线段的正确姿势
碰撞检测是整个游戏里最容易被写崩的部分,我第一版代码只检测了球心到线段的垂直距离是不是小于半径,结果经常出现球卡进路面的情况。原因很经典:球心虽然离线段很近,但球可能已经越过线段端点,这时用直线距离公式算出来的值会偏小,从而产生误判。
正确的做法是把圆和线段的碰撞分成两步:先找到球心在线段上的投影点,然后判断投影点是否在线段范围内。如果在线段范围内,就求点到线段的垂直距离;如果不在范围内,就求球心到最近端点的距离。无论哪种情况,只要距离小于球半径,都要做位置修正和速度方向反弹。
bool CircleSegmentCollision(Vec2 center, float radius, Vec2 segA, Vec2 segB, Vec2& normal, float& penetration) { Vec2 ab = segB - segA; Vec2 ac = center - segA; float len2 = ab.x * ab.x + ab.y * ab.y; float t = (ac.x * ab.x + ac.y * ab.y) / len2; t = std::max(0.0f, std::min(1.0f, t)); Vec2 closest = segA + ab * t; Vec2 delta = center - closest; float dist2 = delta.x * delta.x + delta.y * delta.y; if (dist2 > radius * radius) return false; float dist = std::sqrt(dist2); if (dist > 0.0001f) { normal = delta / dist; penetration = radius - dist; } else { normal = Vec2(0, -1); penetration = radius; } return true; }做完之后,我把整个路面顶点都遍历一遍,对每条线段执行这个检测函数。顶点数量不算多,最多几十个点,所以性能一点不吃紧。这里最不容易想到的坑是:当球心刚好落在线段延长线上时,计算出来的距离会是 0,除零会导致程序崩溃。所以我特意加了一个保护:距离过小时给一个默认法线方向。
障碍物部分,我没有用复杂的多边形碰撞,而是把所有障碍物简化成 AABB 碰撞盒。圆和矩形的碰撞检测公式并不复杂,先找到球心在矩形里最近的边上的点,然后算距离,判断是否重叠。这种简化方式让游戏核心逻辑清晰很多。
3.3 难度曲线与障碍物生成规则
球球飞车没有关卡编辑器,难度完全依靠程序动态生成。我定的规则是:每过固定时间间隔,在路面右侧视线外生成一个障碍物,生成位置在一定范围内随机。但如果没有约束,随机很容易形成一条无法通过的“死路”,玩家就会觉得这游戏不公平。
为了解决这个问题,我用了两套约束逻辑。第一是“通道约束”:障碍物不会在屏幕上同时超过两个,且相邻障碍物之间的横向间距总是留出至少能让球通过的缝隙。这个缝隙宽度等于球直径再加上一个安全余量。第二是“速度联动”:生成障碍物的时间间隔会和当前游戏速度挂钩。速度越快,生成间隔越短、障碍物越密集。
分数量化标准是这样的:每滚动一段固定距离记分,同时每成功躲过一个障碍物额外加分。距离分是基础分,额外分是奖励分,两者分开累计,在结算界面分别展示。这样玩家既能感受到“跑得远就有分”,又清楚“躲障碍才是上分关键”。
因为生成规则和路面顶点生成是同一个模块,我封装了一个“RoadGenerator”类,把路面生成、障碍物生成、间距计算都放在一起,避免主循环里散落一堆全局变量,代码可读性高了不少。
4. 实操过程与关键模块实现
4.1 游戏主循环与固定时间步长
很多游戏写久了会越写越卡,一个很重要的原因是物理更新频率和渲染帧率没有解耦。比如屏幕刷新率是 60Hz,但物理逻辑可能一帧内要跑很多次,如果主循环里直接每帧执行一次物理更新,物理精度就和显示器刷新率绑定在一起了。换到高刷屏,游戏难度直接变化。
我采用的是固定时间步长方案。思路是:物理更新固定按每步 1/60 秒来推进,渲染则按实际帧率来执行。每帧开始,先通过系统时钟算出这一帧的累积时间,放进累加器,只要累加器大于等于固定步长,就循环执行物理更新,直到累加器剩余时间不足一步为止。
const float fixedDelta = 1.0f / 60.0f; float accumulator = 0.0f; Uint32 lastTick = SDL_GetTicks(); while (running) { Uint32 currentTick = SDL_GetTicks(); float frameTime = (currentTick - lastTick) / 1000.0f; lastTick = currentTick; if (frameTime > 0.25f) frameTime = 0.25f; // 防止切后台后疯狂补帧 accumulator += frameTime; while (accumulator >= fixedDelta) { UpdateGame(fixedDelta); accumulator -= fixedDelta; } Render(renderer); }frameTime 最大不超过 0.25 秒这行代码是重点。如果没有它,当你从后台切回游戏时,系统可能累计了一秒多的帧时间,物理循环会疯狂补跑几十次更新,小球瞬间就飞出去了。加了这个钳位后,切回窗口最多只补 15 步,体验稳定很多。
4.2 游戏状态切换与 UI 层
状态机的实现我放在了单独一个 GameState 枚举里,每次切换到另一个状态时,会执行对应的进入回调。比如从运行状态切到暂停状态时,我要记录当前分数,暂停背景做成半透明遮罩;从暂停恢复回运行状态时,需要恢复计时。
enum class GameState { Menu, Running, Paused, GameOver }; GameState currentState = GameState::Menu; void ChangeState(GameState next) { currentState = next; // 每个状态进入时的特殊初始化和资源加载动作 switch (currentState) { case GameState::Menu: ResetGame(); break; case GameState::Running: StartTimer(); break; case GameState::Paused: PauseTimer(); break; case GameState::GameOver: StopTimer(); break; } }菜单界面我做得比较克制,就是一个居中标题、一行操作提示、一个闪烁的“按空格开始”,没有做复杂的按钮跳转,因为核心玩法才是重点。UI 绘制和游戏场景绘制我放在同一个渲染流程里,但分开成两个函数,场景绘制先执行,UI 绘制后执行,这样 UI 永远能在最上层。
体验细节上也做了些打磨:在菜单状态时,背景不是静止的,而是让场景以慢速滚动的方式播放,这样玩家第一眼就明白“游戏里的球和路面是什么样的”。这个细节成本很低,但能让整个游戏看起来完成度高很多。
4.3 碰撞反馈与音频实现
没有声音反馈的躲避游戏会显得很“木”,所以我引入了音频扩展组件来播放三种简单的音效:得分音效、碰撞音效、游戏结束音效。音频初始化比图形初始化还要多一点步骤,需要打开音频设备并加载音频文件。
我用程序内置短音效的方式,以避免外部文件丢失导致黑屏运行的问题。因为这些音效都是短促的单音效果,用生成波形的方式直接合成,而不需要加载 wav 文件。具体做法是在初始化时生成一组正弦波加衰减包络,做成脉冲音。虽然音色很简单,但低成本的方案换来了稳定运行的回报。
音频初始化的代码坑也不少,最常见的问题是:初始化失败后程序直接退出,但玩家根本不知道发生了什么。所以我加了一行日志输出,初始化失败也不会导致整个游戏崩溃,只是静音运行。这个处理策略适用于所有资源加载环节:资源缺失时游戏降级运行,而不是崩溃退出。
碰撞发生时,除了播放音效,我还会触发一个短暂的减速效果:球的速度立刻乘以一个小于 1 的系数,同时画面四周短暂出现红色描边,提示玩家受伤了。这个视觉+听觉的双重反馈很重要,玩家能清楚地判断自己的操作是否失误,不然经常会出现“我明明躲开了为什么死了”的疑惑。
5. 调试心得与问题速查
5.1 典型发行版 Bug 与排查手册
我把开发过程中遇到的典型问题整理成了一张速查表,每个问题都是我实际踩过的,不是凭空推测:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 球速在不同电脑上差异极大 | 没有使用 deltaTime | 物理更新全部用固定时间步长 |
| 球穿过路面掉出屏幕 | 单帧移动距离超过线段长度 | 限制最大帧时间或拆分子步 |
| 球卡在障碍物边缘抖动 | 碰撞修正后没有退出环路 | 修正位置后立即标记处理完成 |
| 切后台回来后游戏直接结束 | 帧时间累加过大导致物理补帧疯狂 | 对单帧最大时间做钳位 |
| 画面闪烁严重 | 没有使用双缓冲渲染 | 创建渲染器时开启垂直同步 |
| 障碍物偶尔出现必死绝路 | 随机生成没有通道约束 | 生成后检测缝隙宽度,不足则重掷 |
排查问题时,我的习惯是先加日志后改代码。尤其在碰撞检测这种逻辑里,把球的位置、法线方向、穿透深度用日志打出来,观察几秒,马上就能发现是数值问题还是逻辑问题。不要靠猜,日志是调试最快的路径。
5.2 性能优化与渲染瓶颈分析
球球飞车的场景很简单,渲染压力不大,但我在开发过程中还是做了几项优化,因为担心后续加功能时卡顿。最基础的优化是减少绘制调用次数。SDL 的绘制函数每调用一次都有固定开销,我最初的绘制代码里,路面每个线段调用一次画线函数,一次帧就要调用几十次,还好。但后来我为了给路面加描边效果,每段又加了三条平行的线,性能直接打对折。优化方式是把屏幕上的道路顶点缓存成静态数组,每帧统一计算一次,状态不变就不重复画。
另一个被很多人忽略的优化点是内存分配。无限生成路面时最容易想到的做法是持续 push_back 新顶点,旧顶点不断清理,这种频繁分配释放会让程序越跑越慢。我的做法是使用固定大小的顶点数组,加一个循环指针,超过范围就覆盖旧数据。说白了就是一个简单的环形缓冲区,障碍物和粒子效果也用了同样的对象池思路。
性能实测下来,800x600 窗口,150 个路面顶点,60 个障碍物上限,单帧渲染耗时在 2 毫秒以内,比刷新间隔 16 毫秒少得多,完全不用担心性能问题。当然如果你要加多层背景、动态光影,那就要上更复杂的渲染方案了,但那是另一个层级的问题。
5.3 手感打磨的一些细节
游戏发布前我花了很多时间调手感,很多人把“手感好”神秘化了,其实拆开来看就几件事:响应速度、视觉反馈、容错空间。
第一,按键响应要快。我用的是键盘状态查询,每一帧都在检测按键,而不是依赖系统按键事件,这样延迟极低,玩家按下立刻就有反应。第二,视觉反馈要明显。球左右移动时,球体本身的旋转滚动画、路面的轻微倾斜变化、移动时产生的细微风线粒子,都让玩家知道自己的操作正在生效。第三,容错空间要设计合理。障碍物的碰撞范围比视觉范围小了一圈,比如视觉上看起来碰到的位置,实际碰撞判断会稍微宽容一些。这算是很多商业游戏都用的“隐藏容错”技巧,能明显减少玩家的挫败感,但不会让游戏变得没有难度。
我调的最终版本是:碰撞盒宽高比视觉尺寸小 15% 左右,配合音效和红框反馈,玩家既觉得刺激,又不会觉得判定苛刻。
6. 打包分发与后续扩展建议
6.1 可执行文件与运行环境打包
游戏开发完,最后一步是把程序发给朋友玩。这里有个新手经常犯的错:把源码发给别人,让人家自己装环境自己编译,结果朋友电脑上没有依赖库,根本跑不起来,最后不了了之。正确做法是打包成可执行文件,把需要的动态库一起放进同一个目录。
我用的是命令行交叉编译方式,生成可执行文件后,复制几个必要的动态库文件和资源文件到同目录,整体压缩成一个 zip 包。打包逻辑很简单,但需要注意:动态库和可执行文件必须放在同一层,不能放在子文件夹里,否则加载不到。我一开始踩过这个坑,朋友双击后屏幕一片漆黑,排查才发现是加载不到图形库的接口函数。
如果要做安装包,那又是另一个话题,但球球飞车这种小体量项目,绿色解压版反而是最好的分发方式,玩家下载解压就能玩,没有安装步骤,传播成本最低。
6.2 后续玩法扩展的方向
球球飞车的框架做出来之后,扩展方向其实很多。第一个方向是增加道具系统:路面随机生成加速道具、防护罩、多倍积分道具。设计上只需要在障碍物生成器里加一种新的实体类型,然后在碰撞检测里做分支处理即可,不用动核心物理逻辑。
第二个方向是增加关卡模式:把无限跑酷改成有终点的关卡,每个关卡设定目标分数,玩家在限定时间内跑完固定里程才能过关。这样游戏就从“挑战分数”变成了“通关解谜”,玩法目标感更强。
第三个方向是 BOSS 模式:一个巨大的障碍物从远处逐步靠近,玩家需要在有限的横向空间里反复躲闪,持续一段时间才能击破它。这种模式对核心渲染模块几乎没有改动,大部分工作量集中在新的 UI 表现和碰撞规则上。
根据我个人的实际开发体会,这个项目最值得学习的不是某个具体算法,而是“先把最小闭环跑通,再逐步加玩法”的开发节奏。很多人上手做游戏,一开始就想把画面、音效、道具全做完,结果三个月还在画主菜单。球球飞车这个项目的开发顺序非常典型:窗口、小球、路面、物理、碰撞、障碍、状态机、音效、打包。每一步都可以独立验证,每一步完成之后游戏都可玩,这种渐进式开发带来的成就感,是支撑你把一个项目做完最重要的动力。
最后分享一个小技巧:如果你也想做类似的小游戏,不需要一上来就把物理引擎搞得很复杂,先用最简模型让游戏跑起来,手感不对再逐项调整参数和规则。我最初那版球球飞车的物理代码,加起来还不到一百行,但已经能玩。后来所有的优化和扩展,都是在那一百行基础上增量修改的。先把“能玩”做出来,再来讨论“好玩”和“好看到什么程度”,这才是做小游戏最靠谱的路线。