简介:面向C语言初学者与游戏开发入门者的Flappy Bird像素鸟完整源码,基于easyX图形库实现,可用于学习图形绘制、双缓冲防闪屏、飞行物理模拟、管道碰撞检测及游戏状态管理等核心环节。压缩包共49个文件,约17.09MB,包含cpp与h源码、jpg游戏素材、mp3音效、sln与vcxproj工程文件以及exe、pdb等编译调试产物,便于直接打开工程运行和对照调试。已有1395人学习下载。源码结构清晰、注释较完整,读者可从中梳理主循环、资源加载、分数与结束判定等模块的实现思路,并借助工程配置理解easyX项目的组织方式,适合作为课程设计、自学练手和游戏编程入门的实践案例。
1. 从一份“能跑起来的 C 语言小游戏”说起
课程设计交差、C 语言结课作业、想碰一碰图形编程又不想学引擎,这三个场景撞在一起时,最省事的路径往往是一份完整的 C 语言 + EasyX 像素鸟(Flappy Bird)源码。它只有几个.c/.h文件加一个res图片目录,不依赖任何游戏引擎,双击 exe 就是一个能玩完整一局的窗口:小鸟受重力下坠,按键或点击抬升,管道横向滚动,撞管或落地立刻结算,分数实时刷新。适合的人群也很明确:刚学完指针和结构体、想找一个“代码量不超过两千行、但五脏俱全”的参照物的人;以及需要在本机快速改出一个能演示的小作品、拿去答辩或者当作业交的人。反过来说,如果你想要的是跨平台、要发布到应用商店的东西,这份代码不合适,它的舒适区就是 Windows 桌面上的教学与练手。
2. 项目骨架与技术选型:结构体、状态机和 EasyX 三件套
2.1 目录结构与文件职责
拿到源码包先别急着编译,把目录摊开看一眼,心里对“逻辑写在哪、资源放在哪”有个数,后面改代码才不会到处翻。这类项目的目录一般长下面这样,文件不多,但边界分得很清楚:
FlappyBird/ ├── src/ │ ├── main.c # 入口:初始化窗口、装载资源、驱动主循环 │ ├── game.c # 核心逻辑:物理更新、管道生成、碰撞判定、计分 │ ├── render.c # 渲染:背景、小鸟、管道、地面、分数文字 │ └── game.h # 全部结构体定义、宏常量、函数声明 ├── res/ │ ├── bg.png # 背景图,建议 480x720 │ ├── bird0.png # 小鸟三帧,翅膀上中下 │ ├── bird1.png │ ├── bird2.png │ ├── pipe.png # 单根管道,上下翻转复用 │ └── ground.png # 地面滚动条 └── FlappyBird.sln # 或者 CodeBlocks / Dev-C++ 工程文件game.h是整个项目的“总参数表”,所有魔法数字都从这儿出。这种写法的好处是调手感时只改一个头文件,不用在三个.c里搜索0.45这种数字。血泪经验是:很多同学的版本把手感和资源路径写死在函数中间,改一次跳跃力度要翻五个文件,最后干脆不调了,成品手感一塌糊涂。
.c和.h分离也不是为了好看。逻辑和渲染拆开之后,你后面想换一套贴图、或者把 2D 画布换成别的输出方式,改动面会被限制在render.c一个文件里,game.c一行都不用碰。这是这类小项目里最值钱的一条工程习惯。
2.2 环境搭建:编译器与图形库怎么配
EasyX 是 Windows 下的一个轻量图形库,本质是对 GDI 的封装,头文件就叫graphics.h,API 风格非常“教学友好”:开窗口一行、贴图一行、画文字一行。它只支持 MSVC 系编译器(Visual Studio 各版本)以及部分 MinGW 配置,用纯 gcc 命令行直接编是编不过的。配置步骤大致是:
- 装 Visual Studio(社区版足够),安装时勾选“使用 C++ 的桌面开发”。
- 从 EasyX 官网下载对应 VS 版本的安装包,双击安装,它会把
graphics.h、easyx.h和.lib塞进 VS 的头文件与库目录。 - 新建“空项目”,把
src下的文件加进去,确认工程属性里字符集选的是“使用 Unicode 字符集”(默认就是)。 - 编译前把工作目录设成项目根目录,因为代码里的资源路径是
res/bg.png这种相对路径。
一个最小的验证程序长这样,先确认环境通了再谈游戏:
#include <graphics.h> int main(void) { // 480x720 是竖屏像素鸟最常用的画布尺寸 initgraph(480, 720); setbkcolor(WHITE); // 设置背景色 cleardevice(); // 用背景色刷一遍 settextcolor(BLACK); settextstyle(24, 0, _T("Consolas")); outtextxy(120, 340, _T("EasyX ready")); _getch(); // 等一个按键,防止窗口一闪而过 closegraph(); // 释放图形窗口资源 return 0; }initgraph负责创建窗口并把绘图目标绑定到这块画布上;cleardevice把整块画布刷成当前背景色,否则窗口里是随机的内存残留;closegraph一定要和initgraph配对,不规范退出会让下次启动偶尔出现窗口创建失败的怪现象。这二十行能正常出画面,说明头文件路径、库链接、字符集三件事都对了,后面再出错就是业务代码的问题,排查范围直接缩小一半。
2.3 窗口坐标系与全局常量表
EasyX 的坐标系和大多数 2D 画布一致:左上角是(0, 0),x 向右增大,y 向下增大。这一点必须记牢,因为“向上飞”在代码里是 y 减小、速度取负值,很多人第一次写重力时把符号写反,小鸟一按空格就往下扎。
下面这张表是这类项目里最该被抽成宏的常量,也是你以后调手感唯一需要动的地方:
| 常量名 | 典型值 | 作用 | 调大之后的效果 |
|---|---|---|---|
WIN_W/WIN_H | 480 / 720 | 画布尺寸 | 视野变宽,管道相对变慢 |
GROUND_H | 120 | 地面条高度 | 可飞行区域变窄,难度上升 |
GRAVITY | 0.45 | 每帧重力加速度 | 下坠更快,容错变低 |
JUMP_V | -7.2 | 跳跃瞬时速度 | 抬升更高,需要配合重力一起调 |
MAX_FALL | 12.0 | 下落速度上限 | 防止高速穿模的关键参数 |
PIPE_W | 70 | 管道宽度 | 越宽越难穿 |
PIPE_GAP | 150 | 上下管缺口高度 | 越小越难,低于 110 基本不可玩 |
PIPE_SPEED | 3.0 | 管道每帧左移像素 | 整体节奏变快 |
PIPE_INTERVAL | 90 | 每隔多少帧生成一组 | 管道间距,影响反应时间 |
MAX_FALL这个参数最容易被忽略,也最容易翻车。如果不限制下落速度,一局玩到后面小鸟的vy会累积到二三十,一帧跨过 20 多个像素,而管道宽度只有 70,就会出现“明明没碰到却判定穿过去了”的玄学现象——本质是离散采样漏检。把速度钳在一个小于管道宽度的量级上,问题自然消失。这也是为什么表里它单独占一行。
3. 渲染层拆解:双缓冲、贴图与帧率
3.1 双缓冲:把“擦”和“画”合成一次
新手写图形程序最容易遇到的第一个视觉问题是闪烁。原因很朴素:如果每帧先cleardevice()把整块画布刷白,再一个个putimage贴上去,屏幕会真实地经历“全白 → 半成品 → 完整画面”三个阶段,人眼在 60 帧下也能捕捉到那一瞬间的白。解决办法就是双缓冲——所有绘制先画到内存里的一张隐藏图上,画完一次性推到屏幕。
EasyX 把这件事封装成了三个函数,用起来几乎零成本:
int main(void) { initgraph(WIN_W, WIN_H); BeginBatchDraw(); // 开启批量绘制:后续绘制都落在后备缓冲 while (g_running) { cleardevice(); // 只在后备缓冲里刷背景 RenderBackground(); RenderPipes(); RenderBird(); RenderGround(); RenderScore(); FlushBatchDraw(); // 关键:把后备缓冲整块送到屏幕,只闪这一次 Sleep(FRAME_MS); } EndBatchDraw(); // 关闭批量绘制,恢复即时绘制模式 closegraph(); return 0; }BeginBatchDraw之后的每一次绘图调用都不再直接作用于屏幕,而是写进一块和窗口等大的内存位图;FlushBatchDraw才是真正的一次性拷贝。这三个函数必须成对出现,漏掉EndBatchDraw在多数情况下不会崩,但如果后面还有别的绘图代码,行为会变得不可预期。
需要注意一个细节:FlushBatchDraw要放在一帧所有绘制动作之后、Sleep之前。有人把它写在循环开头,等于把上一帧的画面又推了一遍,画面会稳定地慢一帧,操作手感上就是“我按了键,鸟过了 16 毫秒才动”,这种延迟在高分段是致命的。
3.2 贴图加载与透明通道
资源加载集中放在main开头,一次性全部读进IMAGE对象,绝不在主循环里反复loadimage。后者是硬盘 IO,一帧读六张图,帧率会直接塌到个位数。
typedef struct { IMAGE bg; IMAGE bird[3]; // 三帧扇翅动画 IMAGE pipe; IMAGE ground; } Assets; static int LoadAssets(Assets *a) { // loadimage 返回空表示失败,路径写错时最典型的表现就是这里返回空 if (!loadimage(&a->bg, _T("res/bg.png"))) return -1; if (!loadimage(&a->pipe, _T("res/pipe.png"))) return -1; if (!loadimage(&a->ground, _T("res/ground.png"))) return -1; if (!loadimage(&a->bird[0], _T("res/bird0.png"))) return -1; if (!loadimage(&a->bird[1], _T("res/bird1.png"))) return -1; if (!loadimage(&a->bird[2], _T("res/bird2.png"))) return -1; return 0; }loadimage的第二个参数是宽字符字符串,所以要用_T()包一层;工程字符集设成 Unicode 时直接写"res/bg.png"会编译报错,这是新手第一个高频卡点。返回值判空非常重要,路径错一个字母、少一层目录,函数都不会崩,只是悄悄返回失败,然后你会在屏幕上看到一片黑或者纯色块,误以为是渲染逻辑写错了。
透明通道方面,EasyX 从 2019 版开始原生支持 PNG 的 alpha 通道,putimage贴上去就是透明的,不需要手动调TransparentBlt。但前提是你的 PNG 确实是带透明背景的——如果美术给的图是白底 JPG 改后缀得来的,贴上去就是一个白方块,这种“看起来像代码 bug、实际是素材问题”的情况,排查时优先怀疑资源本身。
小鸟的三帧动画不需要状态机,用帧计数器取模就行:
// 每 6 帧切一次图,60fps 下大约是每秒 10 次扇翅,视觉上最自然 int frame = (g_frameCount / 6) % 3; putimage((int)bird.x, (int)bird.y, &assets.bird[frame]);刚起飞时扇得快、下落时扇得慢,这种细节靠改除数就能实现,比如下落状态用/ 4,上升状态用/ 8,代码只多一个三目运算符,观感提升却很明显。
3.3 帧率控制与时间步长
游戏循环里最容易埋雷的地方是“速度写死成每帧多少像素”。一旦换了台刷新率不同的机器,或者某帧因为切窗口卡了一下,整个游戏的节奏就变了。这类项目通常有两条路线:
第一条是固定步长,简单粗暴,也是绝大多数教学源码采用的方案:
#define FRAME_MS 16 // 约 62.5 fps while (g_running) { DWORD t0 = GetTickCount(); HandleInput(); UpdateGame(); RenderGame(); FlushBatchDraw(); DWORD cost = GetTickCount() - t0; // 本帧实际耗时 if (cost < FRAME_MS) { Sleep(FRAME_MS - cost); // 补足剩余时间,稳定节奏 } }GetTickCount返回毫秒级时间戳,精度够用;用cost减去再Sleep的做法叫“帧率补偿”,它保证每帧至少间隔 16 毫秒,逻辑更新次数因此稳定。第二条路线是可变时间步长,把dt乘进所有速度里,物理更精确,但碰撞检测会变复杂,还要处理“一帧跨太久直接穿过管道”的隧道效应。教学项目里,第一种方案的性价比高得多,只要记住一条:所有速度、重力都是“每帧”的量纲,改帧率就得同步改这些数。
Sleep(16)直接写在循环末尾是另一种常见写法,但它不考虑渲染耗时,实际帧率会掉到 45 左右,手感偏钝。加上补偿之后,帧率稳定在 60 上下,跳跃的节奏感才对得上。这种差异单看代码看不出来,跑起来一对比就非常明显。
4. 主循环与物理:重力、跳跃和管道碰撞怎么落地
4.1 一个枚举管住三种状态
游戏状态机是这类项目里最值得学的设计。像素鸟只有三个状态:准备(等待第一次点击)、游戏中、结束。用枚举加switch组织,主循环会干净得不像话,也不会出现“游戏结束了小鸟还在往下掉”这种逻辑混乱。
typedef enum { STATE_READY = 0, // 开局等待,小鸟原地小幅上下浮动 STATE_PLAY, // 正式游戏中,物理生效 STATE_OVER // 结算,等待重开 } GameState; typedef struct { GameState state; Bird bird; Pipe pipes[MAX_PIPE]; int score; int bestScore; int frameCount; } GameContext;GameContext把一局游戏需要的所有东西打包成一个结构体,主循环里只有一个ctx传来传去。这个习惯的好处是重开一局变得极其简单——把结构体清零、重新初始化几个字段就行,不需要满世界找全局变量挨个复位。很多同学写的版本重开之后分数还在、管道位置错乱,根因就是状态散落在十几个全局变量里,复位漏了几个。
状态迁移的入口统一放在输入处理里:
static void HandleInput(GameContext *ctx) { // 空格或鼠标左键都算一次“拍翅膀” int pressed = (GetAsyncKeyState(VK_SPACE) & 0x8000) || peekmessage(NULL, EX_MOUSE); if (!pressed) return; switch (ctx->state) { case STATE_READY: ctx->state = STATE_PLAY; ctx->bird.vy = JUMP_V; // 第一次点击直接给一个上冲 break; case STATE_PLAY: ctx->bird.vy = JUMP_V; // 游戏中每次点击重置纵向速度 break; case STATE_OVER: ResetGame(ctx); // 任意键重开 break; } }GetAsyncKeyState配合0x8000掩码判断“当前是否按下”,适合连续检测;peekmessage是 EasyX 的消息接口,用来不阻塞地取鼠标事件,返回非零表示有消息。两者取或,键盘鼠标都能触发。这里有个细节:跳跃不是“给一个向上的加速度”,而是“把纵向速度直接重置为JUMP_V”。这个设计决定了手感——不管你之前掉得多快,一拍翅膀立刻回升到固定速度,这正是像素鸟那种干脆利落的手感来源。改成累加的话,连按会越飞越高,玩起来就飘了。
4.2 重力与跳跃的数值手感
物理更新函数是整个项目里最短、也最需要反复调的一段:
static void BirdUpdate(Bird *b, float dt) { b->vy += GRAVITY * dt; // 重力持续累积 if (b->vy > MAX_FALL) { b->vy = MAX_FALL; // 钳制下落速度,防穿模 } b->y += b->vy * dt; // 撞天花板:贴边并清零速度,不要让鸟飞出画面 if (b->y < 0) { b->y = 0; b->vy = 0; } // 落地判定交给调用方处理,这里只做位置钳制 float floorY = WIN_H - GROUND_H - b->h; if (b->y > floorY) { b->y = floorY; } }三个参数之间的关系决定了难度:GRAVITY越大,两次点击之间掉得越快,容错窗口越窄;JUMP_V的绝对值越大,单次抬升越高,但太高会让玩家失去节奏感;MAX_FALL是安全阀,前面已经说过它的作用。
我一般按这个顺序调:先把MAX_FALL定在 12 左右,保证单帧位移小于管道宽度的一半;再把GRAVITY从 0.4 开始往上试,0.45 到 0.5 之间是比较舒服的区间;最后调JUMP_V,标准是“从屏幕中线起跳,能飞到接近顶部但不碰天花板”。这三步下来,绝大多数人上手五分钟就能过十根管道。
dt参数在固定步长方案里通常恒等于 1.0,留着它是为了以后想换可变步长时不用改函数签名。这种“先留接口”的做法在小项目里不算过度设计,改造成本几乎为零,收益是后期不用大改。
4.3 管道生成、回收与 AABB 碰撞
管道用定长数组循环复用,不用malloc。原因是这类游戏的管道数量上限明确,屏幕上最多同时存在三组,用对象池可以彻底避开内存碎片和忘记free的问题。
#define MAX_PIPE 4 static void SpawnPipe(GameContext *ctx) { for (int i = 0; i < MAX_PIPE; ++i) { if (!ctx->pipes[i].alive) { ctx->pipes[i].alive = 1; ctx->pipes[i].x = (float)WIN_W; // 缺口中心在 [180, WIN_H - GROUND_H - 180] 之间随机 ctx->pipes[i].gapY = 180 + rand() % (WIN_H - GROUND_H - 360); ctx->pipes[i].scored = 0; return; } } } static void PipeUpdate(GameContext *ctx, float dt) { for (int i = 0; i < MAX_PIPE; ++i) { Pipe *p = &ctx->pipes[i]; if (!p->alive) continue; p->x -= PIPE_SPEED * dt; // 完全移出屏幕左侧就回收,等下一轮复用 if (p->x + PIPE_W < 0) { p->alive = 0; continue; } // 通过缺口且未计分,加一分 if (!p->scored && p->x + PIPE_W < ctx->bird.x) { p->scored = 1; ctx->score++; } } }gapY的随机范围要留边距:上下各留 180 像素,保证缺口不会贴到天花板或地面。如果不留,玩家会遇到“物理上不可能穿过”的管道,这种关卡设计事故在自制版本里非常常见。
碰撞检测用最朴素的 AABB(轴对齐包围盒)。像素鸟的所有元素都是矩形,没必要上更复杂的算法:
static int RectHit(const RECT *a, const RECT *b) { return a->left < b->right && a->right > b->left && a->top < b->bottom && a->bottom > b->top; } static int CheckCollision(const GameContext *ctx) { const Bird *b = &ctx->bird; RECT rb = { (LONG)b->x, (LONG)b->y, (LONG)(b->x + b->w), (LONG)(b->y + b->h) }; // 落地即死 if (b->y + b->h >= WIN_H - GROUND_H) return 1; for (int i = 0; i < MAX_PIPE; ++i) { const Pipe *p = &ctx->pipes[i]; if (!p->alive) continue; RECT top = { (LONG)p->x, 0, (LONG)(p->x + PIPE_W), (LONG)(p->gapY - PIPE_GAP / 2) }; RECT bot = { (LONG)p->x, (LONG)(p->gapY + PIPE_GAP / 2), (LONG)(p->x + PIPE_W), WIN_H - GROUND_H }; if (RectHit(&rb, &top) || RectHit(&rb, &bot)) return 1; } return 0; }RECT是 Windows 自带的结构体,left/top/right/bottom四个字段,正好对应 AABB 的两组边界。这里有个容易被忽略的坑:鸟的碰撞盒应该比贴图小一圈,通常左右各缩 4 到 6 像素,上下各缩 3 到 5 像素。因为美术图里的鸟有翅膀和尾巴的空白区域,用整张贴图做判定会让玩家觉得“明明还有缝却死了”,这是手感投诉的第一大来源。做法是在Bird结构体里单独存hitX/hitY/hitW/hitH,和渲染用的坐标解耦。
4.4 计分、结算与最高分记忆
计分逻辑写在PipeUpdate里,判定条件是“管道右边缘越过鸟的左边缘”。这个时机比“鸟穿过缺口中心”更早触发,玩家感知到的加分时刻和视觉更贴合。
结算画面需要显示本局分数和历史最高分。最高分没必要上数据库,一个文本文件就够:
static int LoadBest(void) { FILE *fp = fopen("best.dat", "r"); if (!fp) return 0; // 首次运行没有文件,返回 0 int v = 0; fscanf(fp, "%d", &v); fclose(fp); return v; } static void SaveBest(int score) { FILE *fp = fopen("best.dat", "w"); if (!fp) return; fprintf(fp, "%d", score); fclose(fp); }fopen返回空指针必须判,第一次运行的时候best.dat根本不存在,不判就会直接崩在启动阶段——这个 bug 在自己机器上永远复现不了,交到老师电脑上第一次打开就闪退,属于典型的“演示事故”。
5. 避坑排查:编译、贴图、闪烁和跨机部署
5.1 链接期报错:一堆 LNK2019 无法解析的外部符号
现象:代码语法检查全过,一编译就是十几条LNK2019,符号名全是initgraph、putimage、BeginBatchDraw之类。
原因基本只有两种:EasyX 没装成功,或者工程位数和库位数对不上。EasyX 的库文件分 32 位和 64 位,VS 工程默认是 x64,如果你装的是老版本的 32 位库,链接器就找不到对应的.lib。
解决顺序是:先看工程属性里的平台是 Win32 还是 x64,再去 EasyX 安装目录确认EasyXa.lib(32 位)和EasyXw.lib(64 位)是否都在;然后把平台切到和库匹配的那一边,重新生成。还有一种低频情况是手动改过附加依赖项,多写了一个不存在的库名,把附加依赖项清空重来即可。
5.2 运行起来一片黑:图片全没加载上
现象:窗口能开,背景是黑的或者纯色,小鸟和管道完全不显示,程序也不崩。
原因九成是资源路径。VS 调试时的“工作目录”默认是工程目录(.vcxproj所在位置),而很多人把res放在了解决方案目录,两者差一层。另外loadimage加载失败是静默的,不抛异常也不打日志,所以从表面看就是“什么都没画”。
解决办法很直接:在LoadAssets里给每一张图都加上失败返回和提示,跑一次就知道是哪张、哪个路径:
if (!loadimage(&a->bg, _T("res/bg.png"))) { // 用 MessageBox 而不是 printf,图形程序没有控制台 MessageBox(GetHWnd(), _T("背景图加载失败,检查 res/bg.png"), _T("资源错误"), MB_OK); return -1; }GetHWnd()拿到当前图形窗口的句柄,MessageBox弹窗能确保你一定看得到。定位到问题之后,把工作目录设成项目根目录,或者把路径改成绝对路径验证一次,再改回相对路径。
5.3 画面抖、鸟有残影:双缓冲和清屏的配合问题
现象:管道滚动时边缘发虚,小鸟身后拖一条淡淡的影子,鼠标移动时更明显。
原因是没有开批量绘制,或者开了但清屏位置不对。如果cleardevice()写在FlushBatchDraw()之后,等于这一帧根本没擦干净,上一帧的内容会叠加在新画面上,残影就出来了。
正确的顺序永远是:清屏 → 画背景 → 画管道 → 画小鸟 → 画地面 → 画分数 →FlushBatchDraw。另外要注意setbkmode(TRANSPARENT),画文字时如果不设这个,文字底下会带一个背景色方块,压在游戏画面上非常突兀。这个属性设一次就行,放在初始化里。
5.4 换台机器就白屏、速度忽快忽慢
现象:自己电脑上跑得好好的,拷到别人机器上要么打不开,要么小鸟掉得飞快。
打不开多半是缺运行库。VS 编译出来的 exe 依赖 MSVC 运行库,对方没装就会提示找不到 dll。解决方式是在工程属性里把运行库改成“多线程 (/MT)”,静态链接进去,exe 体积大几百 KB,但拷哪都能跑。这个选项在“C/C++ → 代码生成 → 运行库”里,Debug 和 Release 都要改。
速度异常则是帧率补偿没写,直接用了固定Sleep(16)或者干脆没Sleep,机器性能不同循环跑的次数就不同,物理更新的频率跟着变。补上前面那段GetTickCount的耗时计算,问题自然消失。这种“在我这儿是好的”类 bug,最坑的地方在于毫无报错信息,只能靠代码结构本身来预防。
6. 二次开发:难度曲线、状态扩展和验证手段
源码能跑通只是起点,真正能拿得出手的是你改过之后的东西。改之前建议先明确一件事:这个项目最值钱的不是画面,而是那套“常量 + 状态机 + 对象池”的结构,所有的扩展都应该顺着这套结构走,而不是另起炉灶。
6.1 难度曲线:三个旋钮和一个公式
最直接的改造是让游戏越玩越难。可调的旋钮就三个:PIPE_SPEED、PIPE_GAP、PIPE_INTERVAL。不要写成阶跃式的“过 10 分就变难”,玩家会明显感觉到突兀。用连续插值更自然:
// 分数越高,速度和缺口都往难度方向线性靠拢,但设一个下限防止不可玩 static void ApplyDifficulty(GameContext *ctx) { float t = ctx->score / 30.0f; // 30 分达到最大难度 if (t > 1.0f) t = 1.0f; ctx->pipeSpeed = 3.0f + 2.5f * t; // 3.0 -> 5.5 ctx->pipeGap = 150 - 40 * t; // 150 -> 110,110 是下限 ctx->pipeInter = 90 - 20 * t; // 90 -> 70,管道更密 }要注意pipeGap的绝对值不能低于小鸟高度加 40 像素左右,否则物理上根本穿不过去。这个下限不是拍脑袋,是拿鸟的高度和单帧最大位移算出来的:单帧最多移动 12 像素,缺口必须比鸟高出一倍以上,玩家才有反应余地。改完这三个参数之后,务必自己连玩二十局,看看 30 分以后是不是变成了纯粹的背板游戏。
6.2 加一个状态位:从单局到无限续命
想加“三条命”或者“无敌帧”,不要在外面套if,直接在状态机里加一个字段:
| 字段 | 类型 | 含义 | 用途 |
|---|---|---|---|
lives | int | 剩余生命 | 撞管时减一,为 0 转STATE_OVER |
invincible | int | 无敌剩余帧数 | 大于 0 时跳过碰撞判定的伤害分支 |
shakeFrame | int | 屏幕震动剩余帧 | 受伤时给一个视觉反馈 |
碰撞函数改成返回“是否受伤”而不是“是否结束”,状态迁移交给主循环判断。这样做的好处是以后加“护盾”“复活”之类的东西,全都在同一个地方扩展,不会散落到渲染和输入里去。渲染层只需要在invincible > 0时让小鸟闪烁,用帧计数取模控制显隐,三行代码。
6.3 验证方法:帧日志和单步回放
改完之后怎么确认没改坏?靠肉眼看是不够的,尤其是物理参数调整之后,很多问题只在高分段才暴露。我一般会加一个轻量的调试日志:每帧把bird.y、bird.vy、score、最近的管道x打到文件里,出问题时回看那几帧的数据。
#ifdef _DEBUG static void DumpFrame(const GameContext *ctx) { FILE *fp = fopen("frame.log", "a"); if (!fp) return; fprintf(fp, "%d,%d,%.2f,%.2f,%.2f\n", ctx->frameCount, ctx->score, ctx->bird.y, ctx->bird.vy, ctx->pipeSpeed); fclose(fp); } #endif用#ifdef _DEBUG包起来,发布版不带这段开销。日志别每帧都fopen/fclose,量大了会明显拖慢帧率,正确做法是开局打开一次、结束关闭,这里为了代码短先这么写,实际项目里我会改成静态文件句柄。拿到日志之后用表格软件画一条vy的曲线,就能直观看到某次跳跃的加速度是否异常,比盯着屏幕猜高效得多。
还有一个更笨但更可靠的验证:把pipeSpeed临时改成 0.5,游戏变成慢动作,逐帧观察鸟和管道的相对位置,碰撞盒大小是否合适一眼就能看出来。调完再改回去。
这套代码我前后改过好几版,最深刻的一次教训是:调完手感参数觉得差不多了,直接打包发出去,结果对方机器上是 144Hz 屏幕,帧率补偿那段的Sleep粒度不够,实际跑了七十多帧,整个游戏的节奏比我这快了两成,直接被吐槽“你这游戏怎么这么快”。从那以后我每次动循环和参数,都强制在两台不同刷新率的机器上各跑一遍,同时把帧日志拉出来对比实际帧数,确认稳定了才敢说改完了。
希望这份拆解能帮你少走几个弯路。
本文还有配套的精品资源,点击获取