小恐龙 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、speed |
| Dino | 恐龙实体:位置、速度、跳跃状态、动画帧 | x、y、vy、onGround |
| Obstacle | 障碍物:类型、位置、尺寸、是否已计分 | x、y、w、h、type、passed |
| ObstacleManager | 障碍物生成与回收:控制间距、随机规则 | obstacles、spawnTimer |
| Ground | 地面滚动:偏移量、速度 | offset、speed |
| Score | 计分与难度曲线 | score、distance |
Game 持有 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 };人眼对于“擦边撞上”的容忍度极低,对于“看起来擦边但实际躲过”的容忍度却很高。稍微缩小碰撞区域,玩家会觉得“好险但真的好爽”,正反馈很强。如果碰撞框和视觉完全一致,任何一次近距离擦边都会触发死亡,体感是这游戏判定太严、不讲道理。这个经验在格斗游戏、平台跳跃游戏里其实通用。
除了缩小 hitbox,3.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++ -std=c++17 -Wall -O2 src/*.cpp -o dino如果打开 SDL2 渲染,需要额外链接 SDL2 库,仓库里用宏RENDER_SDL控制编译分支:
g++ -std=c++17 -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 )注意:编译时必须开启 C++17 或更高标准,因为代码里用了
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 的最小值 | 加 clamp,y < 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 的架构当起点,先让它跑起来,再往里面加你自己的东西。等你把第一只翼龙加进去的时候,就会理解我为什么反复强调分层了。