☰
开源小恐龙跑酷游戏实战:C++与SDL2架构设计及手感调优
2026/10/17 18:36:34 网站建设 项目流程

做“小恐龙”这种横版跑酷游戏,听起来确实不算什么大工程,但真正动手把它从“能跑起来”打磨到“手感顺滑、节奏紧凑、还能开源放出去给别人玩”,中间踩过的坑比想象中多得多。这个 3.0 版本是我把之前的代码全部推倒重写之后的成果,核心玩法还是那套:一只小恐龙不停往前跑,玩家按空格跳跃躲过高矮不一的仙人掌,但整个工程已经从最初几十分钟拼出来的“玩具”,变成了一个结构清楚、参数可调、注释到位的开源项目。如果你正打算用 C++ 写一个 2D 轻度休闲游戏,或者想拿一个完整的开源 Demo 来学习游戏循环、碰撞检测、状态管理这些基本功,这个项目的代码结构和实现思路都会很有参考价值。

先啰嗦一句:别觉得小恐龙玩法简单就不值得研究。操作只有一个键,逻辑看似一目了然,但恰恰是这种极简规则,对帧率稳定度、碰撞判定的宽容度、难度曲线的平滑度要求非常高。任何一个环节做得不够干净,玩家的反馈都会非常直接——“跳了怎么没躲过去”“怎么突然加速了”“按键怎么有延迟”。这些问题背后,才是游戏编程里真正通用的那部分知识。

1. 项目概述与整体设计思路

1.1 为什么做到第三版才敢开源

第一版的时候,整个游戏全部堆在main.cpp里,变量乱飞,障碍物的生成用的是最简单的固定间隔,碰撞检测直接拿整个图片矩形去算,导致小恐龙明明已经“看起来躲开了”,却还是被判死。第二版补上了双缓冲渲染和基础的动画帧切换,手感好了一些,但代码组织还是比较乱,想加个音效得绕半天。

到了 3.0,我给自己定了三个目标:一是代码必须模块化,游戏主循环、实体对象、资源管理、UI 状态全部拆开;二是所有影响手感的参数,比如重力、跳跃速度、障碍物间隔、速度增量,都要集中在一个地方可调;三是必须能顺利打包成独立可执行文件,让朋友下载后双击就能玩,而不是还得装一堆配置环境。这三点做完,项目才真正具备开源分享的价值。

所以这个项目与其说是一个成品游戏,不如说是一个“结构样板 + 玩法 Demo”。它适合的读者也很明确:已经会 C++ 基础语法,想看看一个完整的小游戏工程长什么样的人;或者想做类似跑酷游戏,想参考碰撞检测和难度曲线怎么处理的人。如果你希望看到那种酷炫的 3D 大作实现,这个项目大概率会让你失望,但如果你想踏踏实实搞清楚 2D 游戏最小闭环是怎么运转的,它很够用。

1.2 技术选型:为什么是 SDL2 而不是轮子自绘

3.0 用的是 C++11 标准配合 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::vector<Obstacle>存当前屏幕内所有障碍,移出屏幕左侧就删掉,避免容器无限膨胀。

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.md

config.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会垂直同步等待显示器刷新,如果不开 VSync,SDL 渲染器默认可能直接跑满几百帧,逻辑更新步长不变,但画面撕裂感严重。解决办法是创建渲染器时加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 下来,从改第一个参数开始,就能感受到游戏“手感”真实地被你的调整牵着走,这种感觉远比玩一百个小游戏来得实在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询