C++小游戏开发实战:玩转主循环、物理碰撞与镜头跟随
发布时间:2026/10/17 22:22:46 锦皓数字建站

把 C 当主力语言之后我做过不少小项目其中最能拿得出手、也最常拿出来反复打磨的就是这个 C 小游戏-球球飞车。它说白了就是让一颗小球在一条像赛道一样的跑道上滚动、加速、跳跃从起点一路冲到终点。玩法听起来简单但真做起来从主循环、物理、碰撞到镜头跟随整条流程走完你会对 C 工程结构有完全不一样的理解。这篇内容不是完整项目的逐行源码我也不会让你复刻我的美术资源。我更想做的是把做这个项目时最关键的设计取舍、代码骨架、以及踩过的坑完整梳理一遍。如果你刚学完 C 语法或者已经能写控制台程序但想更进一步这篇文章应该能帮上忙。别的先不说从玩法定位聊起。1. 球球飞车的玩法定位与技术框架1.1 我先想的不是代码而是玩法“球球飞车”这个名字没有固定模板每个人理解都不一样。我给它的定义是玩家操控一个球在由折线段组成的赛道上行驶球会滚动、会跳、会在坡道上自然加速或减速目标是用尽量短的时间从起点到达终点。之所以选球不选车是因为球的物理模型对新手极其友好一个半径加一个圆心位置就能表达全部碰撞形状不存在车体的朝向角、悬挂组件、轮胎抓地力这些复杂概念。做球的时候你只需要关注速度、重力、碰撞和摩擦这些刚好是 2D 游戏物理的四大核心。我建议第一次做这个项目不要加敌人、不要加计分只做“起点—赛道—终点”这个闭环。赛道做成侧视角能看到球在地形上起伏、跳跃最容易验证物理逻辑是否正确。等基础跑通了再去加道具、加时间排行榜。1.2 技术选型标准 C 加轻量绘图库做 2D 小游戏完全没必要上重型引擎一个轻量级绘图库就够。我的理由很简单这个项目的核心难点不在渲染而在物理和关卡逻辑。渲染只需要“画一个圆、画一条线段、显示文字”这三板斧其他全部可以用标准 C 完成。绘图库只负责开窗口、读键盘、画几何形状至于选哪一套我的观点是手边顺手就好不建议为了这个项目专门去配一套重型多媒体栈。窗口初始化和游戏逻辑要严格分开窗口代码只做桥接真正复杂的东西全放在游戏侧。代码骨架大致长这样struct GameContext { BallState ball; Track track; Camera camera; InputState input; double accumulator 0.0; bool running true; };整个游戏的核心差不多就是这四样东西一个球、一条赛道、一个镜头、一份输入状态。窗口层负责把外设事件写进 InputState渲染层负责根据 GameContext 画东西中间的物理更新不依赖任何窗口细节。这样结构清晰后续加功能也不会乱。2. 引擎底盘主循环、时间步长与输入采样2.1 固定时间步长的必要性最开始我写主循环是这么写的每帧处理事件、更新一次物理、渲染一次画面。屏幕是 60Hz 的时候一切正常但后来拿到一台高刷新率设备上试小球速度明显变快再模拟一次卡顿掉帧小球又会突然飞出去一大截。原因很简单每次更新都吃满一帧时间物理步长跟着帧率走了。正确做法是固定时间步长。思路是这样的每帧拿到真实时间差 dt累加到一个 accumulator 里每次消耗 1/60 秒去更新一次物理剩下的时间留到下一帧继续积累。const double STEP 1.0 / 60.0; while (running) { double frameTime getFrameDeltaTime(); accumulator frameTime; while (accumulator STEP) { update(STEP); // 每次步长固定结果可预测 accumulator - STEP; } render(); }这里有个容易忽略的细节update 内部所有时间相关量都以 STEP 为基准不要混用真实的 frameTime否则固定步长等于白做。固定步长的好处是同一段游戏逻辑在不同刷新率的设备上表现完全一致调试的时候也能复现问题。2.2 输入采样独立于物理更新固定步长会带来一个副作用一帧真实时间里可能跑两次 update也可能一次都不跑。如果 update 内部直接去读键盘状态会出现两次更新读到不同输入的矛盾物理表现就不稳定。我的解决办法是建立一个 InputState 结构体在每次事件回调时更新键位布尔值update 内部只读取这个结构体。struct InputState { bool left false; bool right false; bool up false; bool down false; bool jump false; };这样有个很好的额外好处如果玩家在极短时间按下又松开跳跃键事件轮询不会漏掉它。只要物理更新读取时是 true那一帧就会触发跳跃输入不会丢。2.3 BallState用数据把球描述清楚球的状态最好集中放在一个结构体里方便调试、方便重置、方便以后序列化存档struct BallState { float x 0.0f, y 0.0f; // 圆心坐标 float vx 0.0f, vy 0.0f; // 速度 float radius 14.0f; // 球半径 float angle 0.0f; // 表面滚动角度 float angularVel 0.0f; // 角速度用于滚动动画 bool onGround false; // 是否着地 };radius 我单独写成变量而不是常量是因为后面如果要加“变大变小”之类的道具直接改这个字段就行。angle 和 angularVel 其实不影响碰撞只用于让球看起来真的在滚动而不是平移这个小细节很值得保留。3. 让球“有手感”速度、推力与滚动摩擦模型3.1 先把平面上的运动学算明白球在赛道上的运动可以分解为水平方向的推力与阻力以及垂直方向的重力。水平方向加速度大概是这样a F / m - μ * g其中 F 是引擎推力m 是质量μ 是滚动阻力系数g 是重力加速度。做 2D 游戏时我习惯直接把像素当作长度单位重力取 900 像素/秒平方这样球在屏幕上的速度和观感是匹配的。一次简单的水平物理更新长这个样子void updateGround(BallState b, float dt) { float thrust 0.0f; if (inputState.up) thrust 320.0f; if (inputState.down) thrust - 160.0f; b.vx thrust * dt / mass; // 阻力方向与速度方向相反这里按简化分支处理 float friction 0.35f * 900.0f * dt; if (b.vx 0.0f) { b.vx - friction; if (b.vx 0.0f) b.vx 0.0f; } else if (b.vx 0.0f) { b.vx friction; if (b.vx 0.0f) b.vx 0.0f; } }为什么给倒车用的推力比前进小这是为了做“倒车速度更慢”的驾驶感玩家不会因为误按反向键就瞬间往反方向飞去。阻力方向处理是很容易写错的地方实际项目里速度正负号要仔细判断不然球会在速度接近零时来回抖动。3.2 漂移和转向不是靠旋转是靠变速度方向很多第一次做“球车”的同学会踩一个坑按左右键直接改 vx结果球像在太空里平移完全没有车感。问题是这样的做法直接改变了合速度大小左右方向一按球要么加速要么减速一点“滚动”的感觉都没有。真正的做法是保持合速度大小基本不变只改变速度方向float speed length(ball.vx, ball.vy); float angle atan2(ball.vy, ball.vx); if (inputState.left) angle - turnRate * dt; if (inputState.right) angle turnRate * dt; ball.vx cos(angle) * speed; ball.vy sin(angle) * speed;这里 turnRate 需要限制我一般用 2.0 弧度/秒左右。想再有手感一点可以把 targetAngle 和当前 angle 做插值用一阶滞后让转向缓慢变化而不是直接赋值。做完这步之后球在连续弯道上会有明显的“过弯”而不是“瞬移”观感立刻上一个档次。3.3 参数调优的顺序和默认值手感是个很主观的东西但我总结出一套调试顺序先定加速度再定阻力最后定转向。顺序反了会一直互相拖累因为你分不清是推力不够还是阻力太大。下面给一版我调完的默认参数跑起来比较稳参数默认值调大后的表现调小后的表现质量1.0手感沉重加减速迟钝手感轻飘容易失控前向推力320起步迅猛操控难度上升起步肉拖节奏倒车推力160倒车太快影响走位倒车太慢脱困困难滚动阻力系数0.35滑行距离短停车快像滑冰难以急停重力900 px/s²球压地面跳跃低漂浮感强落点难判断最大转向角速度2.0 rad/s转向灵敏容易过度转向笨重弯道吃亏反弹弹性0.3撞地弹得高节奏乱几乎不弹像撞墙我习惯把所有参数集中到一个配置结构体里在游戏里用按键加减并实时显示效果比改一行代码重新编译一次快太多。屏幕上显示速度、FPS、当前参数和 onGround 状态能让调参过程像做实验一样有反馈。4. 赛道构建与碰撞系统多边形路段和法线反弹4.1 赛道看起来是画面其实是一串顶点赛道本质是一个顶点数组每两个相邻顶点组成一个线段。障碍物同理也用线段表示。为什么不用图片碰撞因为线段碰撞可以用数学精确求解内存占用小还能直接绘制调试线。struct Segment { Vec2 a, b; }; struct Track { std::vectorVec2 points; // 赛道地面折线 std::vectorSegment colliders; // 障碍物线段 };注意赛道点不要一次性只给几十个。我一般每隔 20 到 40 像素放一个顶点这样曲线看起来足够平滑碰撞检测也足够精细。太稀疏的顶点会让球经过凸起时“哐哐”颠簸太密的顶点又浪费内存这个密度在 2D 侧视角赛道里比较合适。4.2 点-线段距离碰撞检测的核心函数球和线段的碰撞本质是求球心到线段的最近点然后比较这个距离和球半径。求最近点用的是投影法Vec2 closestPointOnSegment(const Vec2 p, const Segment s) { Vec2 ab s.b - s.a; float len2 dot(ab, ab); float t dot(p - s.a, ab) / len2; t clamp(t, 0.0f, 1.0f); return s.a ab * t; }拿到最近点之后距离就是 length(p - closest)。如果小于半径说明球穿过了赛道表面。这里要注意 clamp 那一步否则最近点会跑到线段延长线上去碰撞位置就不对了。碰撞后的处理顺序很关键先推位置再改速度。// 先沿法线把球推出赛道 Vec2 normal normalize(p - closest); ball.x closest.x normal.x * radius; ball.y closest.y normal.y * radius; // 再计算反弹速度 float velAlongNormal dot(ballVel, normal); if (velAlongNormal 0.0f) { ballVel - (1.0f restitution) * velAlongNormal * normal; }先推位置是为了防止球嵌入几何体。如果顺序反了先改速度再推位置球在某些尖锐顶角处可能会反复穿越产生高频抖动。这属于典型的“碰撞后处理抖动”靠调摩擦系数是救不回来的只能靠调整顺序解决。4.3 onGround 与坡道重力分解球在弯道上“飞起来”还是“贴地跑”由 onGround 标志控制。判定方法很简单上一帧与线段发生碰撞且碰撞法线方向在“上方”范围内就认为着地否则是空中状态。跳跃只能在 onGround 为 true 时触发这样防止玩家在空中无限连跳。另一个容易翻车的地方是坡道。很多简化实现把摩擦当成水平方向的恒定值结果 60 度陡坡上球上坡和下坡表现完全离谱。正确做法是把重力沿坡道切线方向和法线方向分解。假设坡道与水平面夹角为 θ那么重力沿坡道切线方向的分量是 g * sin(θ)沿法线方向的分量是 g * cos(θ)。下坡时切线分量是加速动力上坡时切线分量是额外阻力。如果忽略这个分解球下坡不会越来越快上坡也不会越来越慢整个坡度感就废掉了。举个例子30 度坡的 sin 值是 0.5意味着重力的一半会变成切线方向的加速力或阻力这个数值绝对不能忽略。处理完坡道分解之后球在起伏赛道上的表现才真正有了“滚动”的质感。5. 镜头跟随与关卡呈现5.1 死区跟随镜头别贴球贴得太紧如果镜头直接跟随球心画面会显得特别飘因为球速变化很快镜头永远在追玩家看着就晕。我用的是“死区”算法以屏幕中心为基准设定一个矩形区域只要球还在这个区域里镜头就不动球超出区域边界时镜头才把球拉回边缘。float edgeL camera.x - 120.0f; float edgeR camera.x 120.0f; float edgeT camera.y - 80.0f; float edgeB camera.y 80.0f; if (ball.x edgeL) camera.x - edgeL - ball.x; if (ball.x edgeR) camera.x ball.x - edgeR; // 垂直方向同理死区的横宽竖窄是有意为之跑酷类赛道的横向速度远大于纵向移动给球更长的横向预览距离会比较舒服垂直方向则要控制一下免得镜头上下晃动。工整来说死区宽 240、高 160 是不错的起点具体可以按自己屏幕比例微调。5.2 视差背景一层的伪立体就够背景我只做了两层远景山体和近景装饰。实现视差只用一句话背景层的坐标乘一个系数再绘制越远系数越小。比如远景系数 0.2近景系数 0.5滚动时两层速度不同空间感立刻出来了。这里有个性能坑背景如果需要大量重复元素千万不要每帧循环绘制几十个形状。正确的做法是预先把整幅背景合成到一张大纹理上每帧只做一次贴图调用。做 C 小游戏时轻量图形库的绘制调用往往比较贵减少调用次数比什么都有效。5.3 渲染插值让画面跟上物理帧固定步长更新到 60Hz但屏幕可能是 144Hz直接渲染 BallState 会导致画面看起来一跳一跳因为物理更新频率和屏幕刷新频率对不上。解决办法是保存上一物理帧的状态和当前物理帧状态渲染时按累积器的剩余比例插值。float alpha accumulator / STEP; float renderX prevBall.x (ball.x - prevBall.x) * alpha; float renderY prevBall.y (ball.y - prevBall.y) * alpha;这样渲染层和物理层完全解耦显示永远平滑同时物理逻辑不受帧率影响。顺便说一下发光、阴影这类效果都是后来可以加的先保证最基础的平滑显示比什么特效都实在。6. 调试与性能实战从“能跑”到“好玩”6.1 把调试几何画出来这是我做这个项目时最受益的一点。项目里做一个“调试模式”开关开启后直接把线段、碰撞点、法向量、球的包围圆全部画到屏幕上。法向量可视化之后很多穿墙问题一眼就能看出根因法线方向写反了、线段方向反了、碰撞点计算用了旧的球心位置。平时这些问题只表现为“偶尔掉出赛道”很难定位可视化后全都暴露了。另外屏幕上常驻显示球速、FPS、onGround、累积器大小这些信息。累积器信息特别重要如果它持续变大说明物理更新跟不上真实帧率游戏在“慢动作”运行这是性能问题出现的第一个信号。6.2 高速小球的防透穿处理点-线段碰撞有个经典缺陷球速度越快单位时间位移越大很容易直接跨过细线段从一侧穿到另一侧而不触发碰撞。我的处理办法是“子步进”如果一帧的速度足够大就把物理步长再切成几个子步每个子步做一次碰撞检测。int subSteps 1; float speed length(ball.vx, ball.vy); if (speed * STEP radius * 2.0f) { subSteps 4; } float subDt STEP / subSteps; for (int i 0; i subSteps; i) { update(subDt); handleCollisions(); }这个方案虽然朴素但对“球球飞车”这种赛道细长、常有大跳台的项目完全够用。更高端的做法是做连续碰撞检测对当前帧速度扫出一个扫掠体再和几何体做求交不过对 2D 小游戏来说多数情况下子步进就能稳定解决了。6.3 对象管理少一点 new多一点复用主循环里不要频繁创建 vector、string更不要每帧 new 对象。一帧要跑很多次 update如果每次都动态分配临时变量很快就会看到明显卡顿。我的习惯是所有容器在加载关卡时一次性分配好update 里只修改数值。遇到需要临时效果实例的情况我会用简单的对象池一个空闲列表一个活跃列表需要时从空闲列表取用完放回。这样游戏跑起来内存稳定不会出现卡顿和碎片化增长。记住性能问题从来不是渲染层独有物理层里的每一次分配都有代价。做到现在这个 C 小游戏-球球飞车已经能在自定义赛道上稳定跑完全程了。我最大的体会是小游戏项目最难的不是某一个具体算法而是把一堆模块拼起来时仍然保持整体可控。主循环固定时间步长、物理与渲染解耦、调试几何可视化这三件事建议在动手第一天就搭好后期再补会很痛苦。最后再分享一个小技巧贴图和音效永远最后加先用纯色图形把小球和赛道跑起来等玩法参数都调通了再套美术资源。这样你不会被渲染细节绑住手脚反而能更快找到这个游戏真正好玩的地方。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。