C++ 游戏开发实战:从零实现球球飞车的物理模拟与碰撞检测
发布时间:2026/10/17 22:32:51 锦皓数字建站

刚把一个用 C 写的小游戏从零做到能流畅跑起来的版本就是“球球飞车”。这个小项目的核心其实不难一个小球在起伏路面上高速滚动玩家用键盘控制它左右移动躲避障碍物、收集能量块跑得越远分数越高。但真正上手写代码之后会发现里面涉及物理模拟、碰撞检测、游戏循环、状态管理这些游戏开发的基础问题每一个都值得认真处理。这篇内容适合正在学 C、想做点小项目练手的人参考也适合想做游戏却不知道第一步怎么迈的人。我把完整的实现思路、关键代码、踩坑记录都整理在下面了。1. 项目定位与设计思路1.1 玩法原型与交互设计做之前先想清楚“这个游戏玩什么”。球球飞车的原型其实来自我最早期玩过的那种“躲障碍滚球”小游戏但我不打算直接抄而是把玩法做了点调整游戏采用侧视角画面里有一条起伏不停的路面玩家控制一个带纹理的小球在路面上左右移动。小球会因为重力、坡度和速度变化自动滚动玩家只需要管左右方向不用管加减速这种设计让上手门槛很低同时保留了高速状态下的紧张感。交互方式很简单方向键左右控制小球横向移动空格键负责游戏开始和暂停。核心目标是“跑得更远”所以我没有设计终点而是一段无限生成的路面随着时间推移障碍物变多、路面起伏加剧、球的整体速度也会慢慢提高。这个原型定下来之后后续所有技术实现都有了明确边界路面是可以动态生成的地形小球是带质量的刚体障碍物是固定摆放的碰撞体分数则由里程换算。明确了这些写代码才不会乱。1.2 技术栈选型的纠结过程最开始我有几个方案。第一个是用控制台字符画直接渲染方案很简单但画面表现力几乎没有连“球在滚动”这种最基础的视觉反馈都做不出来。第二个是用某图形库直接画图优点是上手快API 对新手很友好但问题是不跨平台写出来的东西只能在某个系统上玩。第三个是用一个轻量级多媒体库配合扩展组件做渲染和音频前期要多写几行初始化代码但跨平台、稳定并且渲染 API 是标准方式后面想做复杂效果不会卡住。我最后选了第三个方案理由有几个对比项字符画控制台简易图形库轻量级多媒体库上手难度极低低中等跨平台能力有差好渲染能力极弱中等强音频支持无需额外想办法自带扩展后续扩展空间几乎没有受限灵活如果你只是交一个期末作业用简易图形库确实更快2 小时就能见效果但如果你想把游戏做成一个系列项目或者后面想发布到别的平台试试水那跨平台方案毫无疑问是更合适的选择。我身边有一位同期做游戏的朋友他一开始用简易图形库写了个飞机大战后来想移植到 Linux 环境结果整个渲染层全部重写相当于推倒重来。我听完这个教训果断在一开始就选择了跨平台方案。开发环境方面我用的是标准的 C17配了一个文本编辑器加命令行编译工具链没有用大型 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::vectorVec2 roadPoints; SDL_FillCircle(renderer, ball.x, ball.y, ballRadius); // 封装好的填充圆函数 void DrawRoad(SDL_Renderer* renderer, const std::vectorVec2 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 表现和碰撞规则上。根据我个人的实际开发体会这个项目最值得学习的不是某个具体算法而是“先把最小闭环跑通再逐步加玩法”的开发节奏。很多人上手做游戏一开始就想把画面、音效、道具全做完结果三个月还在画主菜单。球球飞车这个项目的开发顺序非常典型窗口、小球、路面、物理、碰撞、障碍、状态机、音效、打包。每一步都可以独立验证每一步完成之后游戏都可玩这种渐进式开发带来的成就感是支撑你把一个项目做完最重要的动力。最后分享一个小技巧如果你也想做类似的小游戏不需要一上来就把物理引擎搞得很复杂先用最简模型让游戏跑起来手感不对再逐项调整参数和规则。我最初那版球球飞车的物理代码加起来还不到一百行但已经能玩。后来所有的优化和扩展都是在那一百行基础上增量修改的。先把“能玩”做出来再来讨论“好玩”和“好看到什么程度”这才是做小游戏最靠谱的路线。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。