ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C语言实战:基于easyX图形库从零实现Flappy Bird小游戏

C语言实战:基于easyX图形库从零实现Flappy Bird小游戏 简介这份C语言结合easyX图形库实现的Flappy Bird像素鸟小游戏面向具备C语言基础、希望入门游戏开发的初学者展示了完整游戏循环与图形界面设计。压缩包共49个文件体积约17MB既包含cpp源文件与头文件也有jpg图片素材、mp3背景音乐、可直接运行的exe以及pdb调试信息便于对照代码与运行效果学习。核心采用双缓冲绘图技术有效避免画面闪烁同时涵盖重力模拟、管道移动、碰撞检测和游戏状态管理等关键逻辑注释详尽结构清晰。项目包含完整VS工程文件既可直接编译运行也支持断点调试逐行分析通过修改参数还可调整重力、管道间距等难度设置。已有1391人学习使用对理解C语言底层绘图与游戏开发流程很有帮助。 提到flappy bird估计不少人都还记得当年那个“根本停不下来”的绿色小鸟。最近在折腾C语言教学时我发现用easyX把这个游戏从零写一遍比单纯刷几道题更能让学生理解循环、结构体、数组和函数到底怎么配合。这个项目没有用任何第三方游戏引擎核心代码就两百多行跑起来就是一个能玩、能计分、能判断死活的像素鸟。无论你是刚学完C语言基础还是在找C语言小游戏源码参考这篇都能给你一个能直接照着敲的样板。1. 项目整体设计与开发环境1.1 easyX是什么为什么拿它做小游戏easyX是一个Windows平台下的C/C图形绘制库早期做图形学实验的人可能都听过graphics.h但那套东西基本只活在Turbo C那个年代早就不方便用了。easyX在Visual Studio下装好以后就是一个头文件的事几个关键API就能撑起一个游戏界面initgraph开窗口、solidcircle画圆、solidrectangle画方、outtextxy写文字。对C语言初学者来说这种“所见即所得”的反馈比黑框框里printf一串坐标强太多了。安装方面必须提醒一句easyX官方目前只支持Visual Studio系列的编译器。如果你还在用Dev-C会遇到下载了库也装不上的情况这个主要是版本兼容问题建议直接换VS Community或者找老版本的easyX适配包。后面源码里用到了宽字符和TCHAR宏在VS下编译最省心换到老版Dev-C还得改不少地方。1.2 三种游戏状态与完整流程别看flappy bird玩法简单内部逻辑至少分成三种状态菜单MENU、游戏中PLAYING、结束OVER。三种状态之间靠空格键切换这是整个程序的骨架。MENU状态下画面只绘制背景、小鸟和提示文字按空格进入PLAYING。PLAYING状态下重力、管道移动、碰撞检测全部开启这是游戏的主循环逻辑。OVER状态下画面定格在死亡瞬间显示当前分数再按空格会重置所有数据回到可玩状态。用枚举或者常量记录状态每帧在switch里判断当前该干什么比到处散落if判断要清晰得多。这个小套路几乎所有小游戏都能通用养成习惯后写别的项目也顺手。1.3 每帧都在干什么游戏循环框架游戏的本质是一个永不停止的循环每帧做的事情可以概括成四步读取输入、更新游戏数据、根据数据绘制画面、控制帧率。这里最重要的认知是“游戏里变化的是数据画面只是数据的可视化”。小鸟的位置、管道的间距、当前的分数这些都是程序里的变量。每次循环先把这些变量算一遍再统一画一遍。理解了这一点再看代码就不会一头雾水。后续的源码拆解基本就是围绕这个循环来展开的。2. 核心机制原理解析2.1 重力与跳跃一个简单的“小球竖抛”模型小鸟的飞行动作在物理上就是一个竖直方向的抛体运动。用变量vy表示垂直速度每帧给vy加上重力加速度GRAVITY再用位置加上vy算出新的y坐标。按一下空格就相当于瞬间给小鸟一个向上的初速度。这个模型在游戏里几乎是通用标配理解起来也不难小鸟的自由落体过程不是一下子掉下来而是每帧加速一点点跳跃也不是直接瞬移而是给一个反方向的速度去抵消重力。具体数值不需要和现实物理对标只要手感舒服就行。我调下来比较顺手的参数是GRAVITY在0.3到0.5之间跳跃初速度在-6到-7之间。单位都是“像素/帧”和“像素/帧/帧”因为在60帧率下看久了反而觉得像素作单位最直观。2.2 管道生成定时器加循环数组管道不能一开始全画好那样既占内存也没意义。常规做法是维护一个spawnTimer倒计时每帧减1减到0就生成一组新管道然后重置计数。管道本身用结构体数组存数组大小固定比如MAX_PIPES为8。生成时在数组里找一个active为0的空位把x设为窗口右边界随机给一个gapY把active置1。每帧所有active的管道x坐标向左移动固定速度视觉上就是管道在不断靠近小鸟。这里有一个随机范围的坑gapY不能随便取上下管道都要留出足够空间让小鸟飞过去。我的做法是把gapY限制在距离窗口顶部60像素和距离地面60像素之间这样不管怎么随机玩家都有一条能钻过去的通道。2.3 碰撞检测圆与矩形的相交判断flappy bird的碰撞检测不需要做成像素级通常把小鸟抽象成一个圆把管道看成上下两个矩形。圆和矩形求交有一个经典方法先找到矩形区域内离圆心最近的那个点然后计算这个点和圆心的距离如果距离小于半径就说明撞上了。这个算法的好处是计算量极小而且在视觉上比直接拿矩形套矩形更宽容玩起来不会出现“明明没碰到却死掉”的憋屈感。实现的时候封装成一个helper函数比如circleHitRect传入圆心坐标、半径和矩形的左上角、宽高返回一个布尔值。上下管道分别调用一次加上地面判定整个碰撞系统就齐了。2.4 计分逻辑越过管道就加分计分的触发点不是碰到管道而是x坐标的交叉。当某一根管道的右边缘已经越过小鸟的左边缘也就是管道x加上管道宽度小于小鸟x时说明小鸟成功穿过了它分数加1。为了防止同一根管道反复加分结构体里加一个passed标志加过分就置1后面直接跳过。这个逻辑看起来简单但如果不加passed标记就会出现在一根管道旁边反复进场出场疯狂加分的bug。很多初版源码都栽在这里值得留意。3. 可运行源码拆解3.1 全局变量与数据结构定义先定义需要用到的常量和结构体。这里用的都是基础类型没有引入复杂的概念方便直接对照着敲。#include graphics.h #include conio.h #include stdlib.h #include time.h #include stdbool.h #include windows.h #define WIDTH 500 #define HEIGHT 650 #define GROUND_Y 550 #define BIRD_X 120 #define BIRD_R 15 #define GRAVITY 0.35 #define JUMP_VY -6.5 #define PIPE_WIDTH 66 #define PIPE_GAP 155 #define PIPE_SPEED 2.2 #define MAX_PIPES 8 #define PIPE_INTERVAL 90 typedef enum { MENU, PLAYING, OVER } GameState; typedef struct { bool active; float x; int gapY; bool passed; } Pipe; GameState state; float birdY, birdVy; int score; int spawnTimer; Pipe pipes[MAX_PIPES]; bool lastSpacePressed;birdY和birdVy用float是因为重力累加需要小数精度绘制时强转int即可。管道x也用float这样每帧加PIPE_SPEED不会因为整数截断导致速度不均匀。3.2 绘制与初始化绘图部分我分成几个小函数drawSky负责天空和地面drawPipe负责管道drawBird负责小鸟drawUI负责文字和分数。void drawSky() { setbkcolor(RGB(135, 206, 235)); cleardevice(); setfillcolor(RGB(222, 214, 148)); solidrectangle(0, GROUND_Y, WIDTH, HEIGHT); setcolor(RGB(140, 110, 60)); for (int x 0; x WIDTH; x 40) { line(x, GROUND_Y, x - 20, HEIGHT); } } void drawPipe(Pipe p) { int topH p.gapY - PIPE_GAP / 2; int bottomY p.gapY PIPE_GAP / 2; setfillcolor(RGB(73, 190, 70)); solidrectangle((int)p.x, 0, (int)p.x PIPE_WIDTH, topH); solidrectangle((int)p.x, bottomY, (int)p.x PIPE_WIDTH, GROUND_Y); setlinecolor(RGB(43, 128, 43)); setlinestyle(PS_SOLID, 2); rectangle((int)p.x, 0, (int)p.x PIPE_WIDTH, topH); rectangle((int)p.x, bottomY, (int)p.x PIPE_WIDTH, GROUND_Y); } void drawBird() { setfillcolor(RGB(255, 220, 60)); solidcircle(BIRD_X, (int)birdY, BIRD_R); setfillcolor(WHITE); solidcircle(BIRD_X 7, (int)birdY - 5, 5); setfillcolor(BLACK); solidcircle(BIRD_X 9, (int)birdY - 5, 2); setfillcolor(RGB(255, 140, 0)); solidrectangle(BIRD_X 10, (int)birdY 3, BIRD_X 20, (int)birdY 8); }管道画完之后加一个深绿色外框视觉上会精致不少不会像两根纯色柱子那么呆板。地面斜线纯属装饰不画也不影响功能。初始化部分写一个resetGame函数负责把游戏数据恢复到开局状态void resetGame() { birdY HEIGHT / 2; birdVy 0; for (int i 0; i MAX_PIPES; i) { pipes[i].active false; } spawnTimer PIPE_INTERVAL; score 0; state PLAYING; }3.3 输入与更新逻辑输入处理最核心的一点是避免长按空格导致连续跳跃或连续重置。我记录上一次空格键状态只有当“当前按下且上一帧没按下”时才触发动作这就是常说的上升沿触发。void handleInput() { bool curSpace (GetAsyncKeyState(VK_SPACE) 0x8000) ! 0; if (curSpace !lastSpacePressed) { if (state MENU) { state PLAYING; } else if (state PLAYING) { birdVy JUMP_VY; } else if (state OVER) { resetGame(); } } lastSpacePressed curSpace; }更新逻辑只有在PLAYING状态才执行否则游戏画面不会继续变化void update() { if (state ! PLAYING) return; birdVy GRAVITY; birdY birdVy; if (birdY - BIRD_R 0) { birdY BIRD_R; birdVy 0; } if (birdY BIRD_R GROUND_Y) { birdY GROUND_Y - BIRD_R; state OVER; return; } for (int i 0; i MAX_PIPES; i) { if (pipes[i].active) { pipes[i].x - PIPE_SPEED; } } spawnTimer--; if (spawnTimer 0) { spawnPipe(); spawnTimer PIPE_INTERVAL; } if (hitPipe()) { state OVER; return; } for (int i 0; i MAX_PIPES; i) { if (pipes[i].active !pipes[i].passed pipes[i].x PIPE_WIDTH BIRD_X) { score; pipes[i].passed true; } } }生成管道的函数和碰撞检测函数是这样配合的void spawnPipe() { for (int i 0; i MAX_PIPES; i) { if (!pipes[i].active) { pipes[i].active true; pipes[i].x WIDTH; int minCenter 60 PIPE_GAP / 2; int range GROUND_Y - 120 - PIPE_GAP; pipes[i].gapY minCenter rand() % range; pipes[i].passed false; return; } } } bool circleHitRect(float cx, float cy, float r, int rx, int ry, int rw, int rh) { float nearX cx rx ? rx : (cx rx rw ? rx rw : cx); float nearY cy ry ? ry : (cy ry rh ? ry rh : cy); float dx cx - nearX; float dy cy - nearY; return dx * dx dy * dy r * r; } bool hitPipe() { for (int i 0; i MAX_PIPES; i) { if (!pipes[i].active) continue; int topH pipes[i].gapY - PIPE_GAP / 2; int bottomY pipes[i].gapY PIPE_GAP / 2; if (circleHitRect(BIRD_X, birdY, BIRD_R, (int)pipes[i].x, 0, PIPE_WIDTH, topH)) return true; if (circleHitRect(BIRD_X, birdY, BIRD_R, (int)pipes[i].x, bottomY, PIPE_WIDTH, GROUND_Y - bottomY)) return true; } return false; }circleHitRect里的nearX和nearY就是“矩形上离圆心最近的点”这个点在矩形范围内所以对比不会越界。逻辑上不用判断圆心是否在矩形内部因为当圆心在矩形内部时最近点就是圆心本身距离为0一定判定碰撞。3.4 main函数与游戏循环主函数把前面所有模块串起来一个while循环搞定int main() { srand((unsigned)time(NULL)); initgraph(WIDTH, HEIGHT); resetGame(); state MENU; lastSpacePressed false; while (true) { handleInput(); update(); drawSky(); for (int i 0; i MAX_PIPES; i) { if (pipes[i].active) drawPipe(pipes[i]); } drawBird(); TCHAR text[64]; _stprintf_s(text, _T(Score: %d), score); outtextxy(10, 10, text); if (state MENU) { outtextxy(WIDTH / 2 - 70, HEIGHT / 2 - 40, _T(Press SPACE to Start)); } else if (state OVER) { outtextxy(WIDTH / 2 - 70, HEIGHT / 2 - 40, _T(Game Over)); } FlushBatchDraw(); Sleep(16); } closegraph(); return 0; }这里有三个细节需要特别说明。第一drawSky里开的是普通绘图模式但整个游戏推荐在main开始加上BeginBatchDraw()在FlushBatchDraw()时统一输出这样能避免画面撕裂和闪烁。第二Sleep(16)对应的理论帧率是60帧左右这个速度下重力参数的手感刚刚好。第三窗口关闭时while循环里没有退出条件直接关闭窗口进程结束即可closegraph写不写得到无所谓。4. 踩坑记录与手感调优4.1 画面闪烁第一次写完运行时画面会闪得像老式电影一样尤其是管道密集的时候整个屏幕都在跳。原因很简单每画一个图形系统就立刻把内容同步到窗口一次多个图形反复刷新自然就闪了。解决方案就是双缓冲。在进入循环前调用BeginBatchDraw()把后续所有绘图操作都写在缓冲区里循环末尾再调用FlushBatchDraw()一次性把整帧画面推到屏幕上。这个操作对性能的影响不大但对视觉体验的提升是巨大的是所有easyX游戏项目的标配。4.2 按键手感刚开始我用_kbhit加getch来检测空格结果发现按一次空格经常触发两次跳跃。原因是getch拿到的是字符码而空格长按的时候缓冲区里会积累很多个空格导致小鸟连续往上窜手感极其难受。后来改成GetAsyncKeyState直接读取物理键盘状态配合上升沿判断才彻底解决。这个方案还有一个额外好处不需要聚焦窗口也能读到按键不会出现点了一下窗口焦点丢失后游戏失灵的怪问题。4.3 碰撞边界如果你用鸟的外接矩形去做碰撞检测游戏难度会明显增加因为四个角并没有被鸟身体覆盖但矩形判定会认为那里也算碰撞。改成圆形判定之后哪怕小鸟斜着掠过管道边角只要圆心距离够远就不会误杀。还有一个容易被忽略的细节是PIPE_GAP和BIRD_R的比例。我实测下来BIRD_R为15时PIPE_GAP低于130就会让玩家产生很强的压榨感低于120几乎无法通关。而PIPE_GAP大于180又会让游戏失去难度所以155到170之间是比较舒适的区间。整个项目的“手感”其实就是GRAVITY、JUMP_VY、PIPE_GAP三个参数共同决定的改任何一个都能让游戏难度天翻地覆。4.4 后续还能加什么这个版本已经能完整游玩但如果想做扩展有几个方向性价比很高。一是用loadimage加载像素鸟贴图替换掉现在的圆形绘制视觉上直接变成真正的“像素鸟”。二是把最高分存到本地文件进入游戏时读取结束游戏时更新这就用到了C语言文件读写。三是加上死亡音效和击打音效调PlaySound加上一句就行。四是加一个新手引导开局前几帧代码里自动跳一下提示玩家空格在哪里。这些扩展都不需要动游戏的底层框架只要在现有数据结构上加点字段或者在循环里多挂几个判断就行。st_table运行时的核心问题在于节点数组随着插入量增大检索效率会下降但实际场景里我们的HashMap长度往往保持在几千以内完全没必要过度设计。我最满意的是这个结构足够通用不管是存配置项、做词频统计还是给网络协议做会话映射都能直接拿过去改改就用不用推倒重来。这个项目的代码量不大却把游戏循环、物理模拟、碰撞检测、状态管理这些基本功全过了一遍。我给学生讲的时候总会强调一点把GRAVITY、JUMP_VY、PIPE_GAP这三个值换着改一改玩起来完全是不同难度、不同手感的东西这就是参数驱动玩法的最好例子。在黑色控制台之外看到自己写的小鸟飞起来的那一刻很多人才第一次觉得学C语言也能这么有意思。本文还有配套的精品资源点击获取
返回列表