写Chrome小恐龙的C版本很多人第一反应是这玩意儿有什么好写的。但我真动手做过一遍之后发现这只像素恐龙几乎是2D游戏开发的最小完备样本主循环、帧率控制、精灵动画、物理模拟、碰撞检测、随机生成、分数持久化这七个核心模块它全占了代码量又小到一个周末就能啃完。对刚学完C语法、想找个东西练手的人来说它比写几千行控制台贪吃蛇有用得多——你会在里面第一次真切地碰到指针到底在指谁坐标是int还是float为什么帧率一高恐龙就飞起来这些只有做项目才会暴露的问题。这篇实录就把我从零搭出可玩版本的全过程拆开讲包括库的选型理由、跳跃参数怎么推、碰撞判定盒留多少余量、以及几个让我卡了半天才想明白的坑。1. 为什么值得用C手撸一只像素恐龙1.1 这个小游戏到底练到了哪些真本事别看屏幕上只有一只恐龙在跑它的代码骨架和很多商业2D游戏是同一套。主循环负责每帧按顺序处理输入、更新逻辑、渲染画面这是所有实时程序的心脏固定时间步长决定物理计算和真实时间怎么对齐做不好就会出现电脑越快游戏越快的经典事故精灵动画让你理解一张大图怎么按坐标切成帧序列物理模拟就是重力加速度那几行公式碰撞检测是判定盒之间的矩形相交判断随机生成涉及障碍物间距和难度曲线分数持久化则是文件读写和容错。这七块你只要真正写过一遍再看Unity或UE的对应模块心里会有底——你会发现引擎帮你封装的本质上还是这些最朴素的逻辑。选它还有个现实原因反馈快。写控制台程序输出是一堆字符很难直观感受手感这种东西。而恐龙游戏里跳跃高度高了还是低了、障碍物是不是太密、帧率掉不掉眼睛一看就知道。这种即时反馈对新手建立直觉特别重要。我那会儿调跳跃参数前后改了二十多版如果是别的项目可能早烦了但看着恐龙一下一下蹦改一次立刻能看出差别反而越调越有劲。另外这个项目天然覆盖了C里最容易出问题的一批知识点结构体和类的取舍、std::vector动态数组的增删、对象生命周期、const和引用传参、文件流ifstream/ofstream。这些不是孤立的知识点而是被一个真实需求串起来的。你为了管理一堆障碍物去用vector为了写最高分去用文件流学完立刻就用比背八股扎实得多。1.2 图形库怎么选EasyX、SDL2还是RaylibC本身没有画图能力标准库只会往控制台吐字符所以第一件事是挑一个图形库。我最初在三个方案里纠结最后把对比列成了表你按自己的情况对号入座就行。方案上手难度跨平台资源加载适合场景EasyX极低仅Windows直接贴图纯练手、快速出效果SDL2中等全平台需配SDL_image想学底层、后续做正经项目Raylib低全平台内置一行搞定想省事又要跨平台我最后选了EasyX理由是它跟Visual Studio集成得最顺头文件一行#include graphics.h就能开始画图不需要折腾额外的库文件路径。对第一次做图形项目的人来说把精力花在游戏逻辑上比花在环境配置上值。代价是它只能在Windows下跑但这只恐龙本来就是随手练手跨平台不是刚需。如果你用的是VS Code而不是Visual StudioEasyX的配置会稍微绕一点需要手动指定lib和include路径——这个我放在第4节的实操里细说。至于不要一上来就选SDL2不是因为它不好而是它对新手的环境配置门槛偏高很多人兴冲冲装了半天库最后卡在链接错误上就放弃了还没写一行游戏逻辑就先被劝退太不划算。等你能把EasyX版跑通、理解了图形渲染的基本套路再迁移到SDL2或Raylib会顺很多因为要改的主要是那几个绘图API的名字游戏逻辑几乎原样搬。2. 先把游戏逻辑想清楚再敲代码2.1 主循环与固定时间步长别让物理跟帧率绑死新手最容易犯的错就是把物理计算写成每帧固定加多少。比如跳跃写y - 12;重力写y 0.6;看起来跑得好好的但只要换台刷新率不同的机器或者后台开了个占资源的程序导致帧率掉下来恐龙的手感立刻就变了。问题的根源在于帧不是时间单位。你这台机器每秒60帧那台可能90帧同样的每帧减12前者一秒位移720像素后者一秒位移1080像素速度完全不一样。正确的做法是引入时间步长delta time所有和速度、加速度相关的量都乘上它。我用的结构是这样的double lastTime getNow(); double accumulator 0.0; const double FIXED_DT 1.0 / 60.0; // 物理固定60Hz while (!gameOver) { double now getNow(); double frameTime now - lastTime; lastTime now; if (frameTime 0.25) frameTime 0.25; // 防止切窗口回来一次性追帧爆炸 accumulator frameTime; while (accumulator FIXED_DT) { update(FIXED_DT); // 物理和逻辑永远按1/60秒算 accumulator - FIXED_DT; } render(); // 渲染按当前实际帧率来 }这套写法叫固定时间步长的累加器模式好处是物理永远以1/60秒的粒度推进无论你的渲染跑到120帧还是30帧恐龙跳跃的高度和耗时都一模一样。那个frameTime 0.25的判断也很关键——有次我切出去刷了会儿网页再回来游戏瞬间快进了好几秒恐龙直接撞死就是因为欠下的时间一次性被追算。加了这个钳制之后切窗口再回来最多只补算0.25秒手感就自然多了。注意getNow()不要用time()那种秒级精度的函数它的分辨率太粗会导致delta time经常是0。Windows下用std::chrono::steady_clock换算成秒即可精度到微秒级够用。2.2 游戏状态机等待、奔跑、结束三段式游戏不是一直在跑的它有明确的阶段开局等待输入、奔跑中、撞死结束。如果不用状态机代码会变成一堆if (游戏开始 !游戏结束 恐龙没跳)的嵌套判断越写越乱。我用一个枚举把状态理清楚enum class GameState { Ready, // 等待按下跳跃键 Running, // 正常游戏 Over // 撞到障碍物 }; GameState state GameState::Ready;有了它update函数的逻辑就变得非常干净void update(double dt) { switch (state) { case GameState::Ready: // 只做原地踏步动画等玩家按键 break; case GameState::Running: updateDino(dt); updateObstacles(dt); checkCollision(); break; case GameState::Over: // 停止一切运动显示重开提示 break; } }状态机还有个隐性好处它逼你思考状态之间的转换条件。Ready到Running的触发是玩家按下跳跃Running到Over是碰撞命中Over到Ready是再次按键重开。这三个转换我当初漏想了重开后要重置哪些数据结果第一次做完重开时恐龙还停在原地、障碍物还在屏幕上、分数没清零看起来像没重开一样。补一个resetGame()函数把所有状态显式归零才彻底解决。3. 跳跃物理与碰撞检测的参数推导3.1 重力、初速度与跳跃高度两个参数定手感跳跃手感就两个参数说了算起跳初速度和重力加速度。前者决定蹦多高后者决定回落多快。这里有个初中物理公式直接能用忽略空气阻力跳跃最大高度h v² / (2g)滞空总时间t 2v / g。我一开始拍脑袋设v 12、g 0.6单位是像素每帧那么最大高度 h 12² / (2 × 0.6) 144 / 1.2 120像素滞空时间 t 2 × 12 / 0.6 40帧约0.67秒恐龙本身大概46像素高障碍物高度在30到50像素之间。跳起120像素意味着最高点远超障碍物滞空0.67秒意味着你有足够时间跨过障碍物手感比较宽裕。这个高度后来我调低过一次因为跳太高会让玩家感觉飘改成v 11、g 0.7之后高度约86像素滞空约31帧落地更干脆反而更接近原版那种利落的感觉。这里没有标准答案全靠自己试但公式给了你调整的方向想跳得高就加v想落得快就加g。跳跃的代码只需在按下按键时给一个向上的初速度剩下交给重力struct Dino { float x 80, y GROUND_Y; float vy 0; bool onGround true; }; const float GRAVITY 0.7f; const float JUMP_V -11.0f; void jump(Dino d) { if (d.onGround) { d.vy JUMP_V; // 负值表示向上屏幕坐标y向下 d.onGround false; } } void updateDino(Dino d, double dt) { d.vy GRAVITY; d.y d.vy; if (d.y GROUND_Y) { // 落地判定 d.y GROUND_Y; d.vy 0; d.onGround true; } }心得屏幕坐标系y轴是向下的所以向上跳要用负速度。我第一次写的时候忘了这一点结果恐龙一按就往下钻钻进地底还不回来——因为我的落地判定写的是y GROUND_Y方向反了。坐标方向一定要先在纸上画一遍再动笔。3.2 AABB判定盒与容错边距判定要比看起来小一圈碰撞检测我用的是最经典的AABB轴对齐矩形包围盒两个矩形只要水平方向和垂直方向都发生重叠就算碰撞。struct Rect { float x, y, w, h; }; bool overlap(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; }原理很朴素但实战里有个必须做的处理判定盒要比视觉图形小一圈。原版小恐龙的手感之所以好是因为它的碰撞判定盒明显小于恐龙贴图本身四周留了大约3到5像素的容错。如果不做这个收缩玩家会遇到明明擦边过去了却判定撞死的情况非常影响体验。我给恐龙和障碍物各定义一个独立的判定矩形缩进量写在常量里方便调const float HITBOX_PADDING 4.0f; Rect dinoBox(const Dino d) { return { d.x HITBOX_PADDING, d.y HITBOX_PADDING, DINO_W - HITBOX_PADDING * 2, DINO_H - HITBOX_PADDING * 2 }; }那个4像素我是来回试出来的太小了还是容易误判太大了会出现看着撞上了却没死的诡异情况玩家会觉得游戏坏了。同一个数值从两个方向逼近最后落在的手感区间才是对的。3.3 障碍物间距的数学约束别让两个障碍物凑成死局这是最容易翻车的地方。障碍物是随机生成的但随机不等于随便。如果你完全随机化间距迟早会出现两个障碍物挨得太近玩家跳过一个后立刻撞上第二个这不是难度这是不讲理。约束的推导其实很简单。一次跳跃水平能跨过的距离是滞空帧数 × 水平速度。用前面的参数滞空31帧、速度假设8像素每帧那就是248像素。要让玩家跳过一个障碍后还有反应时间跳过下一个两个障碍之间的间距gap至少要满足gap 滞空距离 恐龙宽度 反应缓冲我实际写的生成逻辑是这样的float spawnGap(float speed) { float minGap speed * AIR_FRAMES DINO_W 40.0f; // 硬性下限 float extra 60.0f rand() % 180; // 随机浮动 return minGap extra; }speed * AIR_FRAMES就是这一跳能飞多远后面加上恐龙宽度和40像素的反应缓冲保证永远有解。在此基础上再叠一层60到240像素的随机浮动让节奏不那么机械。这套约束的好处是难度提升靠加速而不是靠把间距压死。速度越快minGap自动跟着变大游戏依然可玩只是反应窗口变窄——这才是合理的难度曲线。4. 完整实现从窗口初始化到跑起来4.1 环境搭建与工程结构用Visual Studio的话EasyX官网下载安装包它会自动把库塞进VS的目录新建空项目直接用。如果你跟我一样用VS Code就得手动配置把EasyX的头文件目录加进includePath把对应的.lib加进链接参数。以MinGW为例tasks.json里的编译命令大致是这样g main.cpp -o dino.exe -IE:/easyx/include -LE:/easyx/lib -lgraphics -lgdi32 -luuid -lole32那几个附加的-l库别漏EasyX底层用到了Windows的图形和COM接口漏掉就会出现一堆undefined reference to的链接错误。工程结构我保持最简单一个main.cpp加上assets资源文件夹dino/ ├── main.cpp ├── assets/ │ ├── dino.png │ └── ground.png └── highscore.txt (运行时自动生成)单文件的好处是调试方便改完直接编译。等代码超过七八百行再拆成game.h、render.cpp这样的模块也不迟。新手别一上来就追求工程化先让东西跑起来结构是随着复杂度自然长出来的。4.2 精灵图切分与动画帧原版恐龙的素材是一张横向排列的精灵图奔跑动画有好几帧并排放在一张Png里。渲染时不能整张贴得按帧宽切出当前该显示的那一帧const int FRAME_W 44; const int FRAME_H 47; void drawDino(const Dino d, int frameIndex) { // 从精灵图里取第frameIndex帧贴到目标位置 putimage_alphablend(nullptr, dinoSheet, d.x, d.y, FRAME_W, FRAME_H, frameIndex * FRAME_W, 0, FRAME_W, FRAME_H); }动画帧的切换不要用每帧切一次那样帧率一变动画速度就乱。正确做法是按时间累积攒够比如0.1秒才切下一帧。double animTimer 0; int currentFrame 0; const double FRAME_INTERVAL 0.1; void updateAnim(double dt) { animTimer dt; if (animTimer FRAME_INTERVAL) { animTimer - FRAME_INTERVAL; currentFrame (currentFrame 1) % RUN_FRAME_COUNT; } }这里用-而不是直接清零是为了避免累积误差——如果一帧耗时0.12秒多出来的0.02秒应该留到下一轮直接清零的话动画会整体偏慢。4.3 输入响应、跳跃预输入与土狼时间按键检测有个细节别用当前是否按下要用这一帧是否刚按下。如果按当前状态判断玩家按住空格不放恐龙就会一落地立刻又跳变成无限连跳。我记录按键的前一帧状态做上升沿检测bool jumpPressed false; // 本帧是否产生了一次按下事件 void updateInput(bool keyDown) { static bool lastKey false; jumpPressed keyDown !lastKey; // 只在从松开变按下的瞬间为true lastKey keyDown; }更进一步手感优化里有两个几乎必加的机制加了之后跟手程度立竿见影跳跃预输入Jump Buffer玩家在落地前一点点就按了跳如果直接丢弃这次输入会感觉我明明按了却没跳。做法是把按键事件记下来给一个约0.1秒的有效期落地时如果缓冲还有效就立刻起跳。土狼时间Coyote Time玩家跑出平台边缘后的一小段时间内仍然允许起跳虽然这个游戏是平地但如果做平台跳跃就非常重要。原理是记录离地后经过的时间在阈值内仍然判定为可跳。double jumpBufferTimer 0; const double JUMP_BUFFER 0.1; void handleJump(Dino d, double dt) { if (jumpPressed) jumpBufferTimer JUMP_BUFFER; else jumpBufferTimer - dt; if (jumpBufferTimer 0 d.onGround) { jump(d); jumpBufferTimer 0; } }这两个机制加起来不到十行代码但对操作的宽容度提升巨大。很多新手游戏玩起来别扭缺的往往不是画面而是这种几十行的细节打磨。4.4 分数、加速与最高分持久化分数用累计时间换算跑得越久分越高。同时分数驱动速度形成难度曲线score 0.1; float currentSpeed() { float s 8.0f score * 0.002f; return std::min(s, 16.0f); // 封顶别让速度快到没法玩 }那个封顶值16是必需的。不封顶的话跑个几分钟速度会快到人类反应不过来游戏就只剩必死一种结局。封顶之后游戏会落在一个固定的极限难度上考验的是耐力而不是不可能的操作。最高分要存文件重开程序也不丢。写的时候注意文件可能不存在第一次运行时房间是空的ifstream打开失败是正常的不能让它报错int loadHighScore() { std::ifstream in(highscore.txt); int hs 0; if (in.is_open()) { // 文件不存在时直接返回0 in hs; } return hs; } void saveHighScore(int hs) { std::ofstream out(highscore.txt); if (out.is_open()) { out hs; } }坑点分数用浮点累加会越来越长显示的时候要取整(int)score保存也存整数。我一开始直接把浮点写进文件结果读出来是123.45600000001看起来非常业余。5. 调试现场那些让我卡了半天的坑5.1 编译与链接类报错做C项目报错有相当大的比例跟你的逻辑无关纯粹是环境问题。先把常见几类整理出来遇到别慌报错关键词真实原因处理方向undefined reference toxxx库没链上检查-l参数是否遗漏cannot open source file graphics.h头文件路径没配补includePath或-Ierror: microsoft visual c 14.0 is required缺VC运行库/编译组件装对应版本的构建工具LNK2019 无法解析的外部符号函数声明了没实现检查是否忘记写函数体第三行那个microsoft visual c 14.0 is required其实经常出现在Python装某些带C扩展的包时本质是机器上缺了微软的C编译组件。解决办法是装官方提供的构建工具集注意勾选C相关组件装完重启终端再试。它跟EasyX本身没关系但很多人搜C环境配置时会撞见这个提示别被绕晕。还有一种特别隐蔽的代码改对了但运行的是旧exe。VS Code有时编译失败但没注意直接点了运行跑的还是上一次的产物于是你对着一个改了没反应的bug查半天。养成看编译输出有没有error的习惯比什么都省时间。5.2 画面闪烁、掉帧与手感受阻画面闪烁是新手用EasyX最常遇到的。原因是每画一个元素就直接刷新到屏幕上画恐龙时屏上是半成品画障碍物时又刷一次视觉上就是一顿闪。解决办法是用双缓冲所有绘制先在后台画好最后一次性推上去。BeginBatchDraw(); // 开始批量绘制此间的绘图不直接显示 // ... 画背景、地面、障碍物、恐龙、分数 ... FlushBatchDraw(); // 一次性把整帧推给屏幕 EndBatchDraw();加了这一对函数闪烁立刻消失。这背后的思想其实和现代游戏引擎的帧缓冲完全一致先组装好完整一帧再呈现绝不让用户看到半成品。掉帧则通常出在每帧重复做了没必要的事。比如我一开始每帧都从磁盘读精灵图那叫一个卡。改成程序启动时加载一次、常驻内存、退出时释放帧率立刻回到满格。这个教训很直白IO操作只做一次别塞进主循环。还有个手感问题是跳跃不跟手。查下来发现按键检测写在了渲染之后导致输入延迟了一整帧。把updateInput()挪到主循环最前面、物理更新之前操作立刻变得干脆。顺序这件事看着不起眼但**输入→逻辑→渲染这个顺序是铁律**颠倒了就要付出延迟的代价。5.3 随机数、数组越界与内存问题随机数不随机第一次运行发现障碍物间距每次都一样查出来是忘了调seed。C的rand()默认种子固定不播种的话每次程序启动序列都一样。在游戏开始时调用一次srand((unsigned)time(nullptr))就行。或者更现代一点用std::mt19937配std::uniform_int_distribution质量更好。数组越界障碍物我用std::vectorObstacle存每帧遍历、移除跑出屏幕的。有次为了省事变遍历边erase结果迭代器失效程序随机崩溃或者画出诡异的东西。正确姿势是用erase配合remove_if或者倒序遍历删除for (int i (int)obstacles.size() - 1; i 0; --i) { if (obstacles[i].x obstacles[i].w 0) { obstacles.erase(obstacles.begin() i); } }倒序遍历的好处是删除当前元素不会影响还没访问到的下标。这个技巧在游戏循环里用得非常频繁值得记牢。内存问题如果用了new创建对象一定要配对delete。但说实话现代C里做这种小游戏用std::vector和栈对象就够了压根不需要手动new。我早期为了练习指针到处new最后内存泄漏排查到崩溃后来全换成值语义代码更短也更安全。指针是工具不是目的能不用裸指针就不用。6. 玩法扩展与性能优化的几个方向6.1 昼夜切换与视觉反馈原版小恐龙跑到一定分数会切到夜间模式颜色反转。这个效果实现起来比想象的简单本质就是维护一个主题配色结构到阈值时切换struct Theme { COLORREF bg, fg; }; Theme day { WHITE, BLACK }; Theme night { RGB(30,30,30), RGB(200,200,200) }; Theme current day; if (((int)score / 700) % 2 1) current night;所有绘制都用current.fg和current.bg切主题只需改这一个变量全屏颜色跟着变。这种把视觉参数集中管理的做法等你以后要加皮肤、加主题时会感谢当初的自己。除了颜色我还加了落地粒子、分数闪烁、撞死时的短暂冻结帧这几个小效果加起来游戏的完成度瞬间就不一样了。6.2 从单文件到工程化下一步该练什么当你的单文件版本能顺畅运行就可以考虑往下走一步。我建议的扩展顺序是这样的先把渲染和逻辑分离拆出render和update两个模块为以后换图形库留后路再把游戏对象抽象成基类恐龙和各种障碍物都继承一个GameObject各实现自己的更新和绘制这样加新障碍物会飞的翼龙、地面上的仙人掌组合时不用改主循环最后引入简单的资源管理器统一加载和缓存贴图、音效避免到处写文件路径。这套演进路线走完你手里就不只是一个恐龙游戏而是一个可以往里面塞任意2D玩法的小框架。回头看从最初那个连坐标方向都搞反的版本到能跑能跳、有昼夜有音效的完整版本中间隔的不是什么高深技术而是一遍遍调参数、一次次debug攒下来的手感。我个人在实际操作中的体会是这类小项目的价值不在于最终成品多惊艳而在于它逼你把那些平时好像懂了的知识真正落地。指针怎么传、容器怎么删、时间怎么算、输入延迟从哪来——这些问题你不亲手踩一遍看多少教程都只是隔靴搔痒。