简介:这份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语言也能这么有意思。
本文还有配套的精品资源,点击获取