C++小游戏开发实战:固定时间步长、碰撞检测与状态机设计

发布时间:2026/10/17 19:59:29
C++小游戏开发实战:固定时间步长、碰撞检测与状态机设计 小恐龙 3.0 是我用 C 写的一个开源跑酷小游戏核心玩法一句话就能讲完一只小恐龙自动往前跑玩家按空格跳跃躲开仙人掌和翼龙障碍跑得越远分数越高、速度越快。这个项目我从 1.0 的“十几行字符画”一路迭代到 3.0 的“工程化小游戏”最大的收获不是游戏本身而是把“会写代码”和“能把代码组织成别人也好懂的结构”之间那条沟摸清楚了。这篇文章不讲引擎不讲花哨的图像特效只讲真实项目里那些能直接迁移到任何 2D 小游戏上的东西固定时间步长的主循环、跳跃物理参数怎么算出来、AABB 碰撞检测为什么必须留误差、刷怪怎么做到“随机且有底线”。如果你正在学 C想找一个能完整跑起来、又能真正练到面向对象和状态机设计的项目这篇可以边看边抄如果你已经有编程基础只是好奇这种小游戏内部到底怎么组织可以直接从第 2 节开始看。1. 整体架构设计先想清楚再写代码1.1 为什么 3.0 要把“游戏逻辑”和“画面”彻底分开1.0 版本只有两个文件一个 main.cpp 和一个头文件。当时所有逻辑都写在主函数里读输入、改恐龙位置、画字符、判断碰撞顺序执行。代码短的时候看起来很流畅但一旦想加“翅膀扇动动画”“计分板”“暂停功能”就得小心翼翼地在每个 if 出口里打补丁改一处崩三处。2.0 我做了一次面向对象重构把恐龙、障碍物、地面分别抽象成类代码好读了一些但问题依然明显碰撞检测里直接调用绘制函数游戏逻辑和终端输出绑得死死的想换渲染方式几乎等于重写。到 3.0 动手前我给自己定了几条硬性规则。第一核心逻辑模块Game、Dino、Obstacle、ObstacleManager、Score不得包含任何具体图形库相关的代码它们只维护状态、执行更新第二渲染层通过接口从核心模块读取状态怎么画、用什么画完全由渲染层自己决定第三所有调节手感的数据重力、跳跃速度、障碍生成间隔、速度上限全部集中到一个配置结构体里不允许散落在代码各处。这样做的好处很现实我在开发过程中经常需要“盯逻辑”而不是“盯画面”。开着碰撞框看恐龙和障碍物的实际碰撞区域是否吻合这时候渲染层只是调试工具渲染层从字符画换成 SDL2 矩形甚至 Qt 贴图都只需要写一个几十行的适配层。一个两千行的小项目如果一开始就分层后面每次改动的心智负担会小非常多。1.2 工程里的核心类与职责划分3.0 的类结构并不复杂但每个类都只做一件事。我用一张表把它们列出来方便你对着源码看类名职责关键成员Game整体流程状态切换、主循环调度、输入分发gameState、score、speedDino恐龙实体位置、速度、跳跃状态、动画帧x、y、vy、onGroundObstacle障碍物类型、位置、尺寸、是否已计分x、y、w、h、type、passedObstacleManager障碍物生成与回收控制间距、随机规则obstacles、spawnTimerGround地面滚动偏移量、速度offset、speedScore计分与难度曲线score、distanceGame 持有 Dino、ObstacleManager、Ground、Score 的对象每一帧依次调用它们的 update并负责把键盘输入映射成动作。Dino 只管理自身物理状态ObstacleManager 管容器、生成与回收Ground 只处理偏移Score 根据距离和存活时间累加分数再根据分数调整全局速度。更新顺序也是固定死的Dino 先动接着 ObstacleManager 刷新障碍然后 Ground 滚动最后做碰撞检测和计分。这个顺序有个讲究——必须先让角色和障碍都完成本帧位移再检查碰撞否则会出现“上一帧还没碰、下一帧已经穿模”的经典时序错误。位移永远先于判定这是 2.0 踩过坑之后才固定下来的。1.3 状态机一个 enum 解决所有流程问题初学项目最常见的毛病是用一堆 bool 变量控制游戏流程isPlaying、isOver、isStart、isPause 全塞在一起到一个界面要判断四五个条件。3.0 直接引了一个三位状态机START、RUNNING、GAMEOVER。enum class GameState { START, RUNNING, GAMEOVER }; class Game { GameState state GameState::START; public: void setState(GameState s) { state s; onStateChanged(); // 统一处理进入状态的逻辑 } };状态机的三个用途都发挥得很彻底。一是输入分流START 状态按空格才开始RUNNING 状态按空格跳跃GAMEOVER 状态按空格重置重来。二是更新分流只有 RUNNING 状态才会跑物理和碰撞GAME OVER 时实体可以继续做简单的“倒地动画”但不再计分。三是渲染分流不同状态显示不同界面提示。这个设计让流程逻辑平铺直叙不需要嵌套 if 判断各种布尔组合后来加“暂停”状态只花了二十分钟。2. 核心玩法实现每一个细节都是可以算的2.1 固定时间步长主循环小游戏最隐蔽的坑藏在主循环里。很多新手写while (running) { update(); render(); }物理直接用系统时间差去算这在帧率稳定的机器上没问题可一旦切到性能低的机器帧率掉到 30 以下恐龙的下落速度会肉眼可见地变慢因为每帧的真实时间变长了。更麻烦的是碰撞检测和速度相关的逻辑会随帧率漂移同一段代码在不同机器上体验完全不同。3.0 用固定时间步长加累加器解决这个问题const float dt 1.0f / 60.0f; // 逻辑以 60 帧固定步长运行 float accumulator 0.0f; float lastTime getCurrentTime(); while (running) { float now getCurrentTime(); float frameTime now - lastTime; lastTime now; // 防止一次卡顿导致累加器爆炸上限设为 0.25 秒 accumulator std::min(frameTime, 0.25f); while (accumulator dt) { update(dt); accumulator - dt; } render(); }固定步长的意思是无论真实屏幕刷新率是多少游戏逻辑永远以 1/60 秒为单位推进。帧率高就多跑几次逻辑帧率低就少跑但每一次逻辑步进的物理量完全一致。这样手感不会漂移碰撞判定也稳定。上限 0.25 秒是为了防止一次大卡顿之后陷入“追帧死循环”也可以称为螺旋死亡这个保护必须加。2.2 恐龙跳跃重力、初速度与跳跃高度的计算跳跃物理是整个游戏手感的核心代码其实只有几行void Dino::update(float dt) { if (!onGround) { vy GRAVITY * dt; y vy * dt; } if (y height groundY) { y groundY - height; vy 0.0f; onGround true; } } void Dino::jump() { if (onGround) { vy -JUMP_VELOCITY; onGround false; } }关键是参数怎么定。纯物理公式跳跃最高点高度 h v² / (2g)空中总时间 t 2v / g。我实测下来屏幕纵向大约 360 像素恐龙身高 40 像素障碍物最高 60 像素。要让跳跃有足够高的余量又不会飘我选的参数是重力 GRAVITY 1800 像素/秒²跳跃初速度 JUMP_VELOCITY 680 像素/秒代入公式最高点 h 680² / (2 × 1800) ≈ 128 像素空中时间 t 2 × 680 / 1800 ≈ 0.76 秒。这个高度能把 60 像素的障碍轻松甩过去还留了 30% 容错0.76 秒的滞空时间给玩家足够的反应窗口来调整起跳位置。如果重力太小或者跳速太大会造成角色在天上飘很久反而难操作。速度快到一定程度后玩家的落点判断会非常困难宁可把跳跃做“脆”一点。落地逻辑也有讲究。判断落地用的是y height groundY而不是y groundY因为恐龙的身高必须考虑进去。落地时把 vy 清零防止反复进入“落地-弹起”的抖动状态这一点很容易忽略。2.3 障碍物刷新的“公平性”规则随机刷怪看起来简单spawnTimer - dt; if (timer 0) spawn();就完了。但纯随机会带来两个致命问题连刷导致玩家必死或者长时间不刷导致无聊。3.0 的 ObstacleManager 在生成时机上做了三重约束。第一生成间隔与当前速度联动。基础间隔区间是 0.6 到 1.5 秒但速度越快最小间隔越大。因为速度快时同样一秒内障碍物移动距离更远玩家需要更大的空档才能安全通过。我用了一个经验公式minGap dinoWidth / speed 0.35f意思是一个恐龙身位能通过的时间加上 0.35 秒的反应余量。第二公平性检测。每次生成前检查屏幕上最右侧障碍物的右边缘位置计算它到达屏幕右沿还要多久float timeToRightEdge (screenWidth - lastObstacleRight) / speed; if (timeToRightEdge 0.9f) { return; // 太近了这帧不生成 }这条规则保证了任何障碍物出现时玩家至少有 0.9 秒的预判时间不会出现“凭空刷在脸上”的必死局面。我觉得这是整个游戏可玩性最重要的保障。第三同点不刷双障碍。仙人掌这一类生成判定如果和翼龙类的判定重叠会造成两个障碍叠在一个坐标上视觉上像一堵墙。ObstacleManager 里有数组记录当前全部障碍每次生成前查询新障碍的碰撞区域是否与已有障碍重叠重叠就换一个位置或放弃本次生成。2.4 地面滚动与视差效果地面滚动用的是“偏移量取模”的经典做法。地面纹理宽度固定比如 600 像素每次更新让偏移量累加速度 × 时间超出宽度就减掉groundOffset speed * dt; if (groundOffset groundWidth) { groundOffset - groundWidth; }绘制时画两个副本第一个从-groundOffset开始第二个从-groundOffset groundWidth开始。这样看起来地面一直在向左滚动实际上只是同一个图案在循环移动。这个技巧比每次重新生成一段地面节省得多代码也稳定。背景视差同理把远处的山丘偏移速度设为地面的 0.5 倍就能有简单的层次感成本几乎为零。3. 3.0 版本的关键升级复盘3.1 从“魔法数字”到配置驱动1.0 时代一个跳跃高度 350 会散在三四个文件里想调手感得全局搜索。3.0 把所有可调参数集中到一个 Config 结构体并且支持从配置文件加载struct Config { float gravity{1800.0f}; float jumpVelocity{680.0f}; float baseSpeed{320.0f}; float maxSpeed{900.0f}; float speedUpPerScore{0.05f}; float spawnMinGap{0.6f}; float spawnMaxGap{1.5f}; int hitboxShrinkX{4}; int hitboxShrinkY{2}; };这样做最大的好处是测试时调手感不用改代码。把障碍生成间隔从 0.6 改成 0.5重启就能对比效果。等玩了几十局确定了参数再固化到默认配置里。如果项目后续做版本更新配置驱动还能让用户自己定制难度衍生玩法空间大很多。3.2 碰撞检测可见的图不能全信3.0 的碰撞用的是 AABB也就是轴对齐包围盒。两个矩形的碰撞判断非常简单bool collideRect(const Rect a, const Rect b) { return a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y; }但真正的坑不在算法而在 hitbox 尺寸。游戏角色绘制出来视觉上恐龙的头上有尖角、尾巴有翘起如果把整个贴图的矩形区域当成碰撞框玩家会频繁死于“明明没碰到却撞了”的情况。3.0 的做法是视觉框向内缩几像素恐龙和障碍的碰撞盒都缩 2 到 4 像素Rect dinoBox { dino.x config.hitboxShrinkX, dino.y config.hitboxShrinkY, dino.w - 2 * config.hitboxShrinkX, dino.h - 2 * config.hitboxShrinkY };人眼对于“擦边撞上”的容忍度极低对于“看起来擦边但实际躲过”的容忍度却很高。稍微缩小碰撞区域玩家会觉得“好险但真的好爽”正反馈很强。如果碰撞框和视觉完全一致任何一次近距离擦边都会触发死亡体感是这游戏判定太严、不讲道理。这个经验在格斗游戏、平台跳跃游戏里其实通用。除了缩小 hitbox3.0 还在调试模式里把碰撞框可视化按 F1 就能看到红色矩形和视觉画面叠在一起。让设计者直观检查碰撞框与画面是否匹配这是排查诡异碰撞问题最快的方法。3.3 难度曲线与计分系统计分系统在 3.0 里承担了两件事给玩家正反馈以及驱动难度曲线。计分由距离和时间共同决定采用如下方式score speed * dt * 0.1f; // 按距离给分 if (obstaclePassed) score 15.0f; // 成功跳过障碍加分 speed config.baseSpeed score * config.speedUpPerScore; speed std::min(speed, config.maxSpeed);这样设计会让玩家感受到“跑得远一定有分跳障碍有额外奖励”并且分数越高速度越快。但速度必须设置上限我试过把 maxSpeed 去掉游戏跑到三百多分时恐龙前进速度快到人眼根本反应不过来玩家不是被难倒而是被劝退。合理的曲线是前 60 秒内速度从 320 快速涨到接近上限之后保持稳定让玩家有“我变强了但游戏没有失控”的感觉。3.4 游戏流程与重置逻辑2.0 有一个让我崩溃的 bug重开一局后恐龙还带着上一局的 vy障碍物数组清了一半计分器从旧分数继续涨。原因是没有把重置逻辑做成统一入口三个类分别在不同地方初始化。3.0 里 Game 提供了一个resetGame()方法内部依次调用 Dino::reset()、ObstacleManager::clear()、Score::reset()、Ground::reset()void Game::resetGame() { dino.reset(); obstacles.clear(); score.reset(); ground.reset(); accumulator 0.0f; setState(GameState::RUNNING); }注意 accumulator 也必须清零否则残留的时间累加会让新一局的第一帧“跳一大步”。这个细节很隐蔽是我在一台卡顿的机器上连续重开时发现的。后来我写了一个自动化测试脚本模拟连续重开十局、每局跑 5 秒确保任何状态下重置都不会出问题。4. 编译运行与核心代码走读4.1 环境与构建方式3.0 源码不依赖重型引擎渲染层默认是控制台字符画因此零外部依赖。直接用编译器编译整个 src 目录即可g -stdc17 -Wall -O2 src/*.cpp -o dino如果打开 SDL2 渲染需要额外链接 SDL2 库仓库里用宏RENDER_SDL控制编译分支g -stdc17 -O2 -DRENDER_SDL src/*.cpp -lSDL2 -o dino同时提供 CMakeLists.txt方便习惯 CMake 的开发者cmake_minimum_required(VERSION 3.10) project(dino3) set(CMAKE_CXX_STANDARD 17) add_executable(dino3 src/main.cpp src/Game.cpp src/Dino.cpp src/Obstacle.cpp src/ObstacleManager.cpp src/Ground.cpp src/Score.cpp )注意编译时必须开启 C17 或更高标准因为代码里用了std::min配合初始化列表等语法老标准会报错。4.2 主循环与关键函数走读Game::run() 是整个程序的骨架源码核心部分是这样的void Game::run() { while (running) { processInput(); while (accumulator dt) { update(dt); accumulator - dt; } render(); } } void Game::update(float dt) { if (state ! GameState::RUNNING) return; dino.update(dt); obstacles.update(dt, speed); ground.update(dt, speed); checkCollisions(); score.update(dt, speed); }update的顺序就是之前说的固定顺序。checkCollisions遍历所有障碍比对恐龙 hitbox 和每个障碍 hitbox只要有一个碰撞游戏立即进入 GAMEOVER 状态void Game::checkCollisions() { if (state ! GameState::RUNNING) return; Rect dinoBox dino.getHitbox(); for (auto obs : obstacles.getList()) { if (collideRect(dinoBox, obs.getHitbox())) { setState(GameState::GAMEOVER); return; } } }GAMEOVER 之后玩家按空格会触发 resetGame()整个闭环干净利落。4.3 输入处理与跳跃缓冲键盘输入映射比较简单空格跳跃。但 3.0 额外实现了一个“跳跃缓冲”功能让手感跟手很多。原理是如果玩家在恐龙落地前的一小段时间内按了空格系统会把这次跳跃请求暂存等恐龙一落地立即执行跳跃。float jumpBufferTimer 0.0f; void Game::processInput() { if (keyPressed(KEY_SPACE)) { if (state GameState::START || state GameState::GAMEOVER) { resetGame(); return; } if (state GameState::RUNNING) { jumpBufferTimer 0.12f; // 100~120 毫秒的缓冲窗口 } } } void Game::update(float dt) { // ... if (jumpBufferTimer 0.0f) { jumpBufferTimer - dt; if (dino.isOnGround()) { dino.jump(); jumpBufferTimer 0.0f; } } }这个设计的背后逻辑是玩家的操作意图往往比角色的可操作帧早出现纯粹按“当前是否在地面”来判断跳跃会让玩家觉得自己按了没反应。加上 120 毫秒缓冲很多失败就变成成功体感提升非常明显强烈推荐在平台跳跃游戏里加这个机制。4.4 渲染层的适配接口因为核心逻辑完全不感知渲染3.0 提供了一个 Renderer 接口。控制台渲染器读 Game 里各实体的坐标画出字符图案SDL2 渲染器则画矩形贴图。切换渲染器只需要在 main.cpp 里换一行#ifdef RENDER_SDL SdlRenderer renderer; #else ConsoleRenderer renderer; #endif Game game(renderer); game.run();每次扩展渲染表现比如给恐龙加奔跑动画帧也只需要在渲染器里根据 Dino 的 animationFrame 字段切换字符或贴图完全不需要动逻辑层。5. 常见问题与排查实录5.1 高频问题速查表写这个项目期间我自己和身边试玩的朋友踩了不少坑。我把高频问题整理成一张速查表你照着排查能省很多时间现象可能原因处理方法不同电脑上恐龙下落速度不同物理直接用了真实帧差没有固定步长改用累加器 固定 dt障碍物偶尔突然出现在屏幕近处生成逻辑没考虑当前速度和最小安全距离加入生成前的公平性检测看起来碰到障碍却穿过去了hitbox 比视觉框小太多用调试模式绘制碰撞框对齐视觉恐龙跳出画面上边界没有限制 y 的最小值加 clampy 0 时强制归零游戏跑一段时间越来越卡障碍物数组只新增不清理出屏对象统一移除或放入对象池重开一局后分数或障碍残留重置逻辑不完整统一走 resetGame() 做全量重置按空格跳跃偶尔没反应没有跳跃缓冲加入 120 毫秒缓冲窗口5.2 我的调试三板斧第一个是可视化碰撞框。我在代码里留了一个宏DEBUG_HITBOX开起来会在每个实体位置绘制带颜色的矩形。调试时开着它跑立刻就能看出碰撞框和视觉是否匹配也能看到障碍之间有没有意外重叠。我敢说90% 的“神秘碰撞 bug”靠这一招就能定位。第二个是慢动作模式。按 F2 把全局时间缩放系数设为 0.3让整个游戏慢放观察碰撞瞬间的判定是否准确按 F3 恢复两倍速专门检查在高速度下生成逻辑和计分逻辑有没有错乱。这个功能只影响 dt不影响任何游戏状态实现起来非常便宜调试价值却巨大。第三个是把数据打到日志。我在 Score 和 ObstacleManager 里加了按秒记录速度、生成间隔、分数的日志接口。曾经遇到一个“玩到两分钟后手感突然不对劲”的反馈我就是靠回放日志发现速度线性增长的斜率设得太高导致中后期速度超了玩家反应极限。没有日志的话这种问题很难凭空定位。5.3 手感调优的三个心得第一跳跃高度必须大于最大障碍高度加一个恐龙身位的余量。按前面的参数最大障碍 60 像素恐龙高 40 像素跳跃高度 128 像素完全够用。如果换算下来达不到这个余量玩家会频繁因为预判偏差撞上障碍。第二落地瞬间不要急着判死。我在 Dino 的落地逻辑里加了一个“刚落地 0.05 秒内不计摩擦”的容忍窗口作用是消除因为细微时间误差导致的视觉弹跳问题。虽然这个窗口很小但对观感有质的改善。第三用“最小可达成功率”来检验参数。我每次调完手感会连续开十局要求自己至少八局能通过第一波障碍。做不到就继续微调参数直到这个指标稳定达标。手感这种玄学最终还是要靠量化指标兜底。6. 开源发布与后续扩展6.1 把项目推出来之前要做的整理开源不是把代码往仓库一丢就完事。3.0 公开之前我做了三件事。第一写 README内容包含一句话介绍、运行截图或演示动图、编译方法、操作说明、目录结构、配置项说明、许可证七要素。截图或演示动图特别重要很多人在仓库列表里只看一眼截图就决定要不要点进来没有截图的游戏项目先天吃亏。第二选许可证。我选 MIT简单友好别人拿去改、拿去商用都不会有法律顾虑。如果你希望衍生项目也必须开源可以改用 GPL。许可证是法律层面的东西建议直接复制官方模板文本不要自己发挥。第三清理代码里的机器相关硬编码。比如日志输出到/home/某用户/dino.log这种路径必须改成可配置项或者使用相对路径调试模式下打开的额外线程、调试端口也要默认关闭。还有一个容易忽略的点是换行符统一为 LF避免不同平台贡献代码时出现 diff 混乱。6.2 仓库目录结构建议一个清晰的目录结构能让别人在十秒内找到关键文件。3.0 的目录布局是这样dino3/ ├── include/ # 公共头文件 ├── src/ # 核心源码 ├── config.ini # 可调参数 ├── CMakeLists.txt ├── README.md └── LICENSE源码内部Game.cpp、Dino.cpp 等文件按类拆分命名清晰。合一文件派和拆分派争吵不休但在开源项目里拆分更容易让贡献者快速定位“改哪个文件”。2000 行代码拆成六个文件平均每个三百多行阅读压力小很多。6.3 玩法还能怎么扩展3.0 只是骨架扩展空间非常大我给几个实际验证过的方向。加新障碍类型。目前只有地面仙人掌可以加入飞行翼龙翼龙有高、中、低三档高度对应玩家按空格跳过或者等待经过。这一步需要给 Obstacle 增加飞行高度字段和动画帧工作量不大但对玩法丰富度提升很明显。做双人模式。左右分屏控制两套恐龙和障碍物各自计分互不干扰。因为在 3.0 架构里逻辑和渲染已分离实现双人只需要复制一套 Game 核心再提供两个输入通道。我实测过核心改动大约三百行。加本地存档。把最高分写到本地文件重开游戏后读取展示。这一点对留存率很重要毕竟玩家需要一个再来一局的理由。存档格式可以用简单文本或者 JSON配合配置系统一起做很顺手。换皮改色。把恐龙换成忍者仙人掌换成障碍柱地面滚动图案换掉就是一款全新主题的游戏了。3.0 的渲染层接口决定换皮成本极低如果你想让项目更有辨识度优先从这里入手。最后说说我自己的体会。这个 3.0 项目最值钱的一课不是学会了某个语法或者算法而是明白了一个朴素又容易被忽视的道理小项目也要把“逻辑”和“表现”分开。因为这个决定后面每次加功能、调手感、开源给别人看都轻松了一大截。如果你也想复刻或者改造这个小恐龙建议直接拿 3.0 的架构当起点先让它跑起来再往里面加你自己的东西。等你把第一只翼龙加进去的时候就会理解我为什么反复强调分层了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询