做“小恐龙”这种横版跑酷游戏听起来确实不算什么大工程但真正动手把它从“能跑起来”打磨到“手感顺滑、节奏紧凑、还能开源放出去给别人玩”中间踩过的坑比想象中多得多。这个 3.0 版本是我把之前的代码全部推倒重写之后的成果核心玩法还是那套一只小恐龙不停往前跑玩家按空格跳跃躲过高矮不一的仙人掌但整个工程已经从最初几十分钟拼出来的“玩具”变成了一个结构清楚、参数可调、注释到位的开源项目。如果你正打算用 C 写一个 2D 轻度休闲游戏或者想拿一个完整的开源 Demo 来学习游戏循环、碰撞检测、状态管理这些基本功这个项目的代码结构和实现思路都会很有参考价值。先啰嗦一句别觉得小恐龙玩法简单就不值得研究。操作只有一个键逻辑看似一目了然但恰恰是这种极简规则对帧率稳定度、碰撞判定的宽容度、难度曲线的平滑度要求非常高。任何一个环节做得不够干净玩家的反馈都会非常直接——“跳了怎么没躲过去”“怎么突然加速了”“按键怎么有延迟”。这些问题背后才是游戏编程里真正通用的那部分知识。1. 项目概述与整体设计思路1.1 为什么做到第三版才敢开源第一版的时候整个游戏全部堆在main.cpp里变量乱飞障碍物的生成用的是最简单的固定间隔碰撞检测直接拿整个图片矩形去算导致小恐龙明明已经“看起来躲开了”却还是被判死。第二版补上了双缓冲渲染和基础的动画帧切换手感好了一些但代码组织还是比较乱想加个音效得绕半天。到了 3.0我给自己定了三个目标一是代码必须模块化游戏主循环、实体对象、资源管理、UI 状态全部拆开二是所有影响手感的参数比如重力、跳跃速度、障碍物间隔、速度增量都要集中在一个地方可调三是必须能顺利打包成独立可执行文件让朋友下载后双击就能玩而不是还得装一堆配置环境。这三点做完项目才真正具备开源分享的价值。所以这个项目与其说是一个成品游戏不如说是一个“结构样板 玩法 Demo”。它适合的读者也很明确已经会 C 基础语法想看看一个完整的小游戏工程长什么样的人或者想做类似跑酷游戏想参考碰撞检测和难度曲线怎么处理的人。如果你希望看到那种酷炫的 3D 大作实现这个项目大概率会让你失望但如果你想踏踏实实搞清楚 2D 游戏最小闭环是怎么运转的它很够用。1.2 技术选型为什么是 SDL2 而不是轮子自绘3.0 用的是 C11 标准配合 SDL2 图形库音频部分用了 SDL_mixer。选这套组合的原因很实际SDL2 在 2D 渲染上足够底层又不会像某些大引擎那样把渲染细节藏得严严实实方便理解“每一帧到底发生了什么”。用 Windows 自带 GDI 画图虽然零依赖但做动画帧切换和缩放会把效率问题放大用 DirectX 或 OpenGL 裸写渲染管线对于这个小项目来说又太折腾。SDL2 的SDL_Renderer是一个很舒服的中间层——它提供了纹理、复制渲染、简单几何绘制内部则尽量复用当前平台的硬件加速能力。我自己的开发环境是 Windows用 Visual Studio 编译SDL2 直接下载开发库配置好 include 和 lib 就能跑。后来换到 Linux 环境测试也只需要把链接库名从SDL2.lib换成对应的动态库版本工程文件略作调整就编译通过了。另外一个关键点SDL2 对“窗口创建—事件循环—渲染呈现”这套流程的封装非常固定游戏主循环的标准骨架几乎就是照着它的模式写的。这能帮我们强制梳理出一个干净的主循环结构后面扩展起来也清晰。1.3 架构设计状态机驱动的四层结构3.0 的项目没有用特别高深的设计模式但是严格分成了四个层次引擎层负责初始化 SDL创建窗口控制主循环和 FPS。资源层负责加载图片、音效并用一个简单的缓存机制避免重复加载。逻辑层只处理游戏状态比如菜单、奔跑、死亡、暂停以及小恐龙的位置、速度、碰撞结果。表现层把逻辑层的状态显示到屏幕上包括背景滚动、恐龙动画帧切换、得分文字渲染。分层之后最大的体会是改东西不怕了。比如想给 UI 加个震动效果只需要在表现层做文章不用去动物理参数想让小恐龙下落速度更沉一点就只改逻辑层里的重力常量。界面层和逻辑层之间通过一个游戏状态结构体来通信状态结构体里放着当前分数、速度、恐龙 y 坐标、障碍物列表这些数据。游戏整体是一个状态机INIT初始化资源MENU等待玩家按空格开始RUNNING进行主玩法循环DEAD播放死亡动画并显示“按 R 重新开始”PAUSE在游戏运行时按 P 进入。状态机带来的直接好处是——不会出现“游戏已经死了但背景还在滚”这种逻辑打架的问题。enum class GameState { MENU, RUNNING, DEAD, PAUSE };主循环的每一帧都先处理输入事件然后根据当前状态驱动逻辑更新再渲染画面最后把 FPS 限制到目标帧率。这就是跑酷游戏最稳定的主循环骨架。2. 核心模块拆解与实现要点2.1 游戏主循环固定时间步长有多重要小恐龙玩法的操作反馈极其敏感如果游戏逻辑更新的间隔不稳定玩家第一感受就是“这游戏好飘”。3.0 版本里我用了经典的“固定时间步长 渲染插值”思路逻辑更新固定以 1/60 秒为一步渲染则每帧都执行。这样做的好处是哪怕屏幕刷新率是 75Hz 或 120Hz小恐龙跳跃的抛物线也不会因为渲染帧率的变化而失真。整个主循环的逻辑大致是这样的while (!quit) { Uint32 frameStart SDL_GetTicks(); handleEvents(); while (accumulator stepMs) { updateGame(stepMs / 1000.0f); accumulator - stepMs; } renderGame(); Uint32 frameTime SDL_GetTicks() - frameStart; if (frameTime frameDelay) { SDL_Delay(frameDelay - frameTime); } accumulator frameTime; }核心思路是用accumulator累积真实经过的时间每攒够 16.67 毫秒就执行一次逻辑刷新。这样游戏速度就不依赖具体机器的渲染快慢。注意别把updateGame写成每帧都调用并传一帧的 deltaTime那样在高刷新率显示器上跳跃弧线就会变得扁平。踩过这个坑后我对“固定步长”这三个字的理解完全不同了。2.2 玩家控制跳跃物理与手感调优跳跃手感是跑酷游戏的生命线。3.0 版本里我用了非常朴素的半物理模拟——小恐龙只受垂直方向的重力作用水平方向速度恒定等于游戏当前地面滚动速度。按下空格键瞬间给恐龙一个向上的初始速度之后每帧让它受重力减速。公式就是典型的位移法。重力加速度我给的750.0f单位是像素/秒²跳跃初速度-390.0f负值表示向上。这样算下来小恐龙从起跳到最高点的时间大约是390 / 750 ≈ 0.52秒最高点高度大约是390 * 390 / (2 * 750) ≈ 101像素。地面高度定在窗口底部往上 120 像素的地方所以这个跳跃高度能稳稳越过常见的仙人掌障碍——仙人掌我给的高度基本控制在 35 到 80 像素之间。参数不是乱拍的都是先算个大概再进游戏微调。另一个影响手感的关键是“跳跃缓冲”和“按键抬起灵敏度”。我在实际测试中发现玩家往往会比视觉判断稍早一点点按空格如果那一瞬间恐龙还没落地按键会被直接吞掉造成“明明按了却没跳”的挫败感。所以 3.0 里加了个窗口期机制跳跃按键发生后的 80 毫秒内如果小恐龙接触地面就自动补发一次跳跃指令。这个缓冲逻辑非常小但体验提升极其明显。void Player::onJumpPressed() { jumpBufferTimer 0.08f; // 跳跃缓冲窗口 } void Player::update(float dt) { if (jumpBufferTimer 0.0f) { jumpBufferTimer - dt; if (isOnGround jumpBufferTimer 0.0f) { velocityY -jumpSpeed; isOnGround false; jumpBufferTimer 0.0f; } } // 按下空格提前松开时削减上升速度实现可变跳跃高度 if (!isOnGround velocityY 0 !jumpKeyHeld) { velocityY gravity * 1.5f * dt; } velocityY gravity * dt; posY velocityY * dt; }“可变跳跃高度”也参考了主流平台跳跃游戏的做法按住空格跳得更高轻点就跳得矮。实现逻辑很简单就是当恐龙上升途中玩家松开空格时把重力临时加重 1.5 倍让它快速回落。这个小细节让快速连跳和跨越高障碍都变得有操作区分度。2.3 障碍物生成随机性里的难度曲线障碍物如果生成得太随机玩家会觉得不公平如果太固定又会觉得无聊。3.0 采用了一种“分段难度曲线”加“随机间隔”的方案。游戏速度会随着存活时间缓慢增加但障碍物的生成间隔并不是简单等比缩短而是分阶段降低前 20 秒内最小间隔相对宽松给玩家适应时间20 到 60 秒间隔逐渐变短仙人掌的高度种类变多60 秒以后开始出现成对障碍偶尔会有窄间距的双障碍逼迫玩家精确操作。生成障碍时我维护一个nextSpawnX坐标点每次生成后根据当前难度重新算下一个生成位置。随机数用std::mt19937避免老式rand的低位分布不均问题。仙人掌有几种尺寸高度越低尺寸越大高障碍物一般单发矮障碍物容易连着来。void ObstacleSystem::spawnNext(float speed) { Obstacle obs; int type distType(mt); float h heights[type]; obs.x nextSpawnX; obs.y groundY - h; obs.w widths[type]; obs.h h; obstacles.push_back(obs); float gap baseGap randGap(mt); gap - speed * gapReduceFactor survivalTime * gapTimeFactor; if (gap minGap) gap minGap; nextSpawnX gap; }它们统一在ObstacleSystem里维护用一个std::vectorObstacle存当前屏幕内所有障碍移出屏幕左侧就删掉避免容器无限膨胀。2.4 碰撞检测AABB 判定不要直接套图碰撞检测我一开始图省事直接用 SDL_Rect 的相交判断函数。但小恐龙图片是长方形图片里恐龙身体四周有不少空白像素直接整个矩形参与判定会被“空气墙”坑死。3.0 版本里我在恐龙和障碍物各自维护一个“逻辑碰撞盒”比图片尺寸每边缩进几个像素——恐龙左右各缩进 8 像素顶部缩进 10 像素底部缩进 2 像素障碍物左右缩进 6 像素。这样看起来小恐龙的尾巴扫过仙人掌尖头但实际上不会误判死。碰撞判定本身就是一个 AABB 重叠判断bool checkAABB(const SDL_Rect a, const SDL_Rect b) { if (a.x a.w b.x || b.x b.w a.x) return false; if (a.y a.h b.y || b.y b.h a.y) return false; return true; }更讲究一点的做法是小恐龙下落速度很快时帧与帧之间位移可能超过障碍物厚度出现穿透。虽然在小恐龙这种游戏里下落速度看起来不至于太快但为了保险我在update里做了“碰撞扫掠”检测——把上一帧位置和当前位置连成一条竖直线按步长做两次 AABB 检测。代价很低效果却让人安心许多。2.5 渲染与音效动画帧切换和资源缓存小恐龙有 6 帧跑动动画、2 帧跳跃动画、1 帧死亡动画。我不想用复杂的动画状态机所以用一个简单的结构体记录当前动画名称、当前帧索引和累积时间每次更新时根据状态切换动画。因为是像素风图片每帧图片大小都是固定的 88x94 像素左右加载时我都统一转化成 SDL_Texture切换回非常干净。音效方面SDL_mixer 加载了一个跳跃音效和一个碰触音效。音乐没有做因为跑酷游戏的背景音乐一旦跟节奏对不上反而容易干扰操作判断。跳跃音效比较短脆在onJumpPressed时调用Mix_PlayChannel(-1, chunkJump, 0)。为了省去重复加载图片的麻烦资源层里做了一个极简的哈希缓存SDL_Texture* loadTexture(const std::string path) { auto it textureCache.find(path); if (it ! textureCache.end()) return it-second; SDL_Texture* tex IMG_LoadTexture(renderer, path.c_str()); textureCache[path] tex; return tex; }当时觉得这是小题大做但实际从菜单到运行要加载的图片有二十多张如果每次状态切换都重新读文件会明显卡一下。缓存之后游戏内所有画面切帧都感觉很跟手。3. 实操复现过程与工程结构3.1 初始化与资源加载流程初始化没什么玄学但要养成“任一关键环节失败就明确报错退出”的习惯。我写了InitContext结构体统一存放窗口指针、渲染器指针和分辨率。整个初始化流程是初始化 SDL 视频子系统 → 创建窗口 → 创建渲染器 → 初始化 SDL_image → 初始化 SDL_mixer → 加载所有图片和音效。顺序不能乱因为后面的步骤依赖前面的对象。bool initGame(InitContext ctx) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) 0) return false; ctx.window SDL_CreateWindow(Dino Run 3.0, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 960, 360, 0); ctx.renderer SDL_CreateRenderer(ctx.window, -1, SDL_RENDERER_ACCELERATED); if (!ctx.renderer) return false; IMG_Init(IMG_INIT_PNG); Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048); loadAllResources(ctx); return true; }窗口分辨率取 960x360是考虑到小恐龙经典的窄长条场景感跑动时玩家视野足够看到右侧即将出现的障碍物。太高太宽反而会让注意力分散。3.2 渲染层的分层绘制与背景滚动渲染顺序决定了视觉效果正确与否。3.0 的绘制顺序是天空背景 → 地面纹理 → 背景装饰物远处的云和山→ 障碍物 → 小恐龙 → UI 文字。背景滚动做的是视差效果滚动速度是地面速度的 0.4 倍这样远处景物“移动得慢”近处地面“移动得快”营造出简单的层次感。实现视差滚动时最直接的做法是维护两个偏移量bgOffset和groundOffset每次更新时按各自速度增加绘制时用(offset % textureWidth) - textureWidth来平铺。这里有个细节算出偏移后要在贴图宽度范围内循环绘出至少两段才能铺满整个窗口否则窗口右边会露出底色空隙。SDL_Rect src {0, 0, groundTexW, groundTexH}; for (int x - (groundOffset % groundTexW); x SCREEN_W; x groundTexW) { SDL_Rect dst {x, groundY, groundTexW, groundTexH}; SDL_RenderCopy(renderer, groundTex, src, dst); }刚开始我直接在地面 y 坐标从窗口底部往上硬编码结果在别人电脑上运行时因为窗口高度不同地面要么悬空要么出界。后来把所有与坐标相关的常量统一封装到GameConfig结构体里并提供了 computeLayout 函数根据实际窗口尺寸计算地面高度、玩家初始位置等才算彻底解决问题。3.3 工程文件结构一览开源项目的工程结构尽量保持“新人友好”3.0 的目录分得比较扁平Dino3.0/ ├── src/ │ ├── main.cpp │ ├── Game.cpp / Game.h │ ├── Player.cpp / Player.h │ ├── ObstacleSystem.cpp / ObstacleSystem.h │ ├── ResourceManager.cpp / ResourceManager.h │ ├── UI.cpp / UI.h │ └── config.h ├── assets/ │ ├── images/ (恐龙帧、仙人掌、地面、背景) │ └── sounds/ (jump.wav, dead.wav) ├── CMakeLists.txt └── README.mdconfig.h里集中放了全局常量比如重力、跳跃初速度、玩家尺寸、地面高度、最小障碍间距、音效音量等。我强烈建议你把这个文件当成“调参面板”用——改完参数重新编译进游戏跑十秒感受一下手感比去代码里翻变量名有效率得多。比如跳跃初速度这个值我陆续试过 -360 到 -420 的区间最后定在 -390偏上一点会感觉恐龙“轻飘飘”偏低一点又觉得跳不上去。3.4 编译运行与调试技巧CMakeLists.txt 里最核心的就是让 CMake 能找到 SDL2 和 SDL2_image、SDL2_mixer 的库。源码里通过find_package(SDL2 REQUIRED)找库再把头文件目录和链接库加进去。cmake_minimum_required(VERSION 3.10) project(Dino3) set(CMAKE_CXX_STANDARD 11) find_package(SDL2 REQUIRED) find_package(SDL2_image REQUIRED) find_package(SDL2_mixer REQUIRED) add_executable(Dino3 src/main.cpp src/Game.cpp src/Player.cpp src/ObstacleSystem.cpp src/ResourceManager.cpp src/UI.cpp ) target_link_libraries(Dino3 SDL2::SDL2 SDL2::SDL2_image SDL2::SDL2_mixer )Windows 下如果用 Visual Studio直接把 SDL2 的 include 和 lib 路径加到项目设置里就行。有个很容易踩的坑把 SDL2.dll、SDL2_image.dll、SDL2_mixer.dll 三个文件跟 exe 放一起不然双击直接报“找不到 DLL”。这个在 README 里我特意提醒了。调试时最有用的招数是画碰撞盒。我在一个 debug 开关打开的情况下把每个实体的 AABB 用SDL_RenderDrawRect画成亮色框观察跳越障碍时恐龙碰撞盒和仙人掌碰撞盒的实际距离。肉眼盯着看几轮一下子就明白了哪些参数给得太过宽松。另一个很实用的技巧是在窗口标题栏实时显示 FPS 和当前速度倍率std::string title Dino3 fps std::to_string((int)fps) speed std::to_string(gameSpeed); SDL_SetWindowTitle(ctx.window, title.c_str());这样不用额外写日志跑动过程随时瞄一眼标题栏就能感知帧率和难度推进是否正常。4. 常见问题与排查技巧实录4.1 帧率忽高忽低跳跃手感完全不对这是最典型的问题。我之前曾把逻辑更新放在渲染循环里用dt自己乘重力60Hz 显示器上还好一到 144Hz 笔记本上小恐龙跳得又高又轻完全变了味道。排查思路是先把物理逻辑更新调整成固定步长再把渲染频率单独控制情况立刻恢复正常。另外一个隐性问题SDL_RenderPresent会垂直同步等待显示器刷新如果不开 VSyncSDL 渲染器默认可能直接跑满几百帧逻辑更新步长不变但画面撕裂感严重。解决办法是创建渲染器时加SDL_RENDERER_PRESENTVSYNC标志或者手动用SDL_Delay限帧。两者都试过后我选择手动限帧因为 VSync 在不同显示器上的表现不完全一致手动限帧更可控。4.2 明明没碰到怪物却判定死亡这个 90% 是碰撞盒设置问题。图片里的透明区域太大或者恐龙图片比实际内容大了一圈。排查方式前面讲过了——打开 debug 绘制把每个碰撞盒画出来肉眼对比玩家感受的“角色范围”和逻辑范围。3.0 里我最终把恐龙碰撞盒缩进参数调到左 8、右 8、上 10、下 2仙人掌缩进参数是左右各 6顶部不用缩——仙人掌本来就是尖的。还有一个情况是“落下时穿透障碍物”。下落速度太快且碰撞检测频率不足时会出现一帧在障碍物上方、下一帧已经穿到障碍物下方但两次判断都没有重叠。解决方案就是前面说的碰撞扫掠或者在下一帧位置和当前位置之间做两个等距点的插值检测。对于小恐龙这种小尺寸物体分成两步检测已经足够。4.3 资源路径和跨平台路径分隔符问题一开始我在代码里写assets/images/dino_run_1.png这样的相对路径在 Windows 下跑没问题但工程放到别的电脑上工作目录一变就加载不到资源。后来统一改成基于可执行文件所在目录来找assets文件夹先获取 exe 路径再往上回退一级作为basePath拼接资源路径。跨平台还有路径分隔符差异——Windows 用反斜杠Linux 用斜杠。我的解决方案很简单代码里常量统一用正斜杠SDL 自己的文件 API 在 Windows 上也能正确解析。如果你的代码里用ifstream之类的标准库读文件就要注意std::filesystem的跨平台用法了。这个问题不高深但非常经常踩。4.4 长时间游玩后内存不断上涨因为资源缓存是一个哈希表如果只在启动时加载一次理论上不会持续增长。真正的问题出现在障碍物容器以前图省事障碍物离开屏幕后只标记隐藏不删除结果跑个几分钟容器里积累了大量元素每帧还要遍历它们检查碰撞和渲染帧率自然下降。3.0 的做法是障碍物真正移出屏幕左侧后立刻erase并且用“对象池”复用的方式减少频繁构造析构的开销。对象池思路不复杂准备一个空闲列表需要生成障碍物时从池里取一个已分配对象重置坐标销毁时标记为空闲。对小恐龙这种障碍物密集的场景对象池很值得用。实测同样跑五分钟内存占用稳定在一个固定水平不再继续爬升。4.5 开源发布前的工程整理清单既然标题写了开源就一定会有人 clone 下来自己编译。这个环节最容易被忽略。我自己的发布清单是所有图片、音效的命名改为英文且无空格否则跨平台可能出现文件名解析问题。README 里写清楚依赖库的版本、编译步骤、运行说明。至少让一个从没配过 SDL2 的人按着步骤操作能跑起来。LICENSE 文件必须存在。我用的 MIT 协议最省心。工程里不要留绝对路径、个人电脑特有配置。如果你在本地跑得好好的但 clone 到干净环境编译报一堆链接错误大概率是 CMake 没找到 SDL2。建议把find_package(SDL2 REQUIRED)换成带搜索路径提示的版本并在 README 里说明如何给 CMake 指定 SDL2 的安装位置或者直接提供预编译的 Windows 可执行包。我个人在实际开发里还有一个很深刻的体会小恐龙游戏的“难点”从来不在于特效或者画面华丽程度而是把最基础的循环、碰撞、资源管理这些地基打牢。地基稳了后续想加计分排行、角色换肤、飞行怪物、动态掉落等等都是顺着现有框架往里面添东西的事。3.0 开源后有不少人提过新功能建议但我的建议始终是——先把现有的跳跃手感调到自己舒服再谈别的。这个项目最大的价值不是它有多好玩而是你能在很短的时间里把一套完整的小游戏工程从浏览器玩到亲手改出专属版。代码本身不藏着掖着参数全写在 config 里注释也写得很直白。你 clone 下来从改第一个参数开始就能感受到游戏“手感”真实地被你的调整牵着走这种感觉远比玩一百个小游戏来得实在。