
简介面向Processing初学者与视觉编程爱好者的类FlappyBird小游戏源码包适合用来练习绘图、动画循环、碰撞检测、键盘输入与面向对象组织。项目将小鸟、管道和通用逻辑拆分为独立模块配合图片与音效素材较完整地呈现角色运动、随机障碍生成、得分判定与游戏状态切换的实现思路读者可在其基础上调整参数、替换素材或扩展玩法借此理解小型交互游戏从绘制到逻辑闭环的基本结构也可作为课堂示例拆解。压缩包共12个文件以4个pde源码为主另有3张png图像、3个mp3与1个wav音频及1个properties配置文件整体约2.48MB源码、素材与项目设置相对齐全。已有1041人学习适合Processing入门者自学练手也便于教师布置图形交互作业时参考。1. 用 Processing 复刻 FlappyBird难的不是画鸟用 Processing 写一个类 FlappyBird 的横版躲避小游戏网上能搜到的版本大多两百行上下图能跑、鸟能飞可真拿去给人玩十分钟内必然被问三句话我明明没碰到管子怎么就死了重开一局分数怎么还挂着上一局的为什么在我机器上特别飘、换台电脑又变得特别难这份源码包正面处理的就是这三件事——重力、跳跃、管道、碰撞、状态机各自拆成互不越界的几块参数集中在文件头部改一个数字就能改手感。适合两类人刚学完 setup/draw 想找个完整小项目练手的新手以及课程设计要做横版躲避 Demo、需要一份能讲清每行代码为什么这么写的骨架的从业者。下面按“先把物理立住、再让管道可复用、最后把状态和参数收干净”的顺序拆。2. 主循环与重力把“手感”拆成四个可调参数手感这东西听起来玄学落到代码里其实就是四个数字重力多大、点击给多大冲量、最大下落速度多少、点击有没有冷却。四个数字定死之前先得把数据结构和主循环摆对否则后面每加一个功能都在还债。2.1 为什么用 PVector 存速度而不是一个 float vyProcessing 的坐标系原点在左上角y 轴向下为正所以“向上跳”必须给负值——这个符号是最容易写反的地方。我一般把方向直接编进常量名里JUMP_IMPULSE写成 -7.6 并在旁边注释“负值向上”比事后查 bug 便宜得多。用PVector而不是三个 float理由有三条一是位置和速度天然成对重置时pos.set(...)、vel.set(0,0)一行解决二是以后想加横向风、管道推速、震屏偏移直接pos.add(wind)就行不用再补一堆变量三是PVector是可变对象pos.add(vel)原地修改、不产生新对象在每帧都跑的 draw 里不会给 GC 添麻烦。唯一要小心的是别写成pos pos.add(vel)这种看着像“产生新向量”的写法容易让后面接手的人误判语义。// 坐标原点在左上角y 轴向下为正向上跳必须给负值 final float GRAVITY 0.42; // 每帧60fps 基准叠加到速度上的加速度 final float JUMP_IMPULSE -7.6; // 点击瞬间的垂直速度负值 向上 final float MAX_FALL_SPEED 11.0; // 终端速度防止掉帧时一帧穿过管道 final float BIRD_X 120; // 鸟的横坐标固定世界向左滚动 PVector pos; PVector vel; float gravity; // 用变量而不是直接用常量方便第 6 章在线调参 void setup() { size(480, 720); frameRate(60); gravity GRAVITY; resetBird(); } void resetBird() { pos new PVector(BIRD_X, height * 0.42); vel new PVector(0, 0); }逻辑说明pos只存位置vel只存速度重力永远不直接改pos而是先进入vel、再由vel进入pos。这个“加速度→速度→位置”的两级积分是所有运动的基础骨架顺序写反先加位置再加加速度会让鸟在低帧率下出现明显的台阶感。resetBird()单独抽出来是为第 4 章的重开逻辑留接口避免重开时到处复制两行初始化。参数说明GRAVITY决定松手后的“沉”度0.35 以下像月球漫步0.6 以上基本救不回来JUMP_IMPULSE决定一次点击抬多高绝对值越大越“弹”MAX_FALL_SPEED是安全阀后面碰撞那章会用到它BIRD_X固定不动因为滚动的是世界不是鸟这一点跟很多横版卷轴的做法一致能让碰撞判定只在一个方向上变化。提示frameRate(60)只是“请求”不是承诺。窗口被遮挡、图片过大、后台开了别的程序实际帧率都会掉这就是下一节要处理的问题。2.2 归一化系数与固定步长两种让手感不随帧率漂移的写法如果直接写vel.y GRAVITY; pos.y vel.y;在 60fps 下一切正常可一旦实际帧率掉到 30同样的重力每秒只累加 30 次鸟的下落明显变慢玩家会觉得“这游戏今天特别简单”。这种随设备漂移的手感是最难复现的 bug因为它在你机器上永远不出现。写法 A 是系数归一化一行搞定void draw() { float k 60.0 / max(1.0, frameRate); // 以 60fps 为基准的缩放系数 k constrain(k, 0.5, 3.0); // 卡顿太久时不至于一帧跳出去 vel.y gravity * k; vel.y min(vel.y, MAX_FALL_SPEED); // 终端速度 pos.y vel.y * k; }逻辑说明k的物理含义是“这一帧相当于几个标准帧”帧率越低 k 越大重力累加得越多。constrain那行是后悔药——窗口被最小化再还原时frameRate可能瞬间变成一个很小的值k 会变成几十鸟一帧就飞出屏幕夹到 3 倍以内基本能兜住。参数说明这种写法有个理论缺陷重力和位移同时乘 k速度项实际被放大了 k² 量级的误差帧率波动大时轨迹会有细微差别。对 FlappyBird 这种只有竖直一维运动的场景完全够用但如果后面要加抛物线弹道、要算落点就得换写法 B。写法 B 是固定步长累加器把物理推进和渲染彻底解耦final float FIXED_DT 1.0 / 60.0; // 物理固定按 1/60 秒推进 float accumulator 0; int lastMillis 0; void draw() { int now millis(); float frameTime (now - lastMillis) / 1000.0; lastMillis now; frameTime min(frameTime, 0.25); // 防止窗口挂起后一次性补几百帧 accumulator frameTime; while (accumulator FIXED_DT) { stepPhysics(FIXED_DT); accumulator - FIXED_DT; } render(); // 渲染每帧只做一次直接用 pos 画 } void stepPhysics(float dt) { vel.y GRAVITY * 60 * dt; // GRAVITY 按“每帧”定义这里换算成每秒 vel.y min(vel.y, MAX_FALL_SPEED); pos.y vel.y * 60 * dt; }逻辑说明不管渲染帧率是多少物理永远以 1/60 秒为单位推进一帧内可能要跑 0 次高刷屏也可能跑 3 次卡顿但每一步的数值完全一致所以同一串点击时间戳在任何机器上都能复现出同一条轨迹。min(frameTime, 0.25)是必须的否则断点调试回来那一帧会一次性补上几十步物理鸟直接穿模。参数说明FIXED_DT不建议小于 1/120否则一步的位移只有零点几像素属于浪费也不建议大于 1/45否则快速下落时单步位移过大配合第 3 章的碰撞会漏判。方案代码量轨迹一致性适用场景系数归一化3 行一般低帧率下有偏差单机小 Demo、只想快速跑通固定步长20 行强同输入必同轨迹要做回放、要做自动测试、要交付2.3 跳跃冲量、终端速度与连点冷却跳跃有两种写法差别比想象中大。vel.y JUMP_IMPULSE是硬置每次点击后鸟的上升轨迹完全一致玩家能形成肌肉记忆vel.y JUMP_IMPULSE是叠加连点会累加速度鸟能一路窜到屏幕顶端。早期我图省事用了叠加结果玩家全程狂点就能过管道形同虚设这是最典型的一次翻车。硬置还要配一个冷却否则鼠标事件和键盘事件在同一帧里都触发时等于白按一次int lastJumpMillis -1000; final int JUMP_COOLDOWN 90; // 毫秒防止同帧多输入事件叠加 void doJump() { if (millis() - lastJumpMillis JUMP_COOLDOWN) return; lastJumpMillis millis(); vel.y JUMP_IMPULSE; // 硬置不是 } void keyPressed() { if (key ) doJump(); } void mousePressed() { doJump(); }逻辑说明冷却窗口取 90ms 左右比人类最快连点间隔约 100ms稍短正常操作不会觉得卡但能把同一帧的重复事件吃掉。lastJumpMillis初值给 -1000 而不是 0是为了让游戏开始时的第一次点击不被冷却挡住。再补两个视觉细节。第一是终端速度它除了防止穿模还让鸟在长时间不下落时有个稳定姿态不会越掉越快变成自由落体。第二是倾斜角用速度映射旋转玩家的操作反馈立刻直观// 把速度映射成机身角度上升时抬头下落时低头 float angle map(vel.y, JUMP_IMPULSE, MAX_FALL_SPEED, -0.42, 1.45); angle constrain(angle, -0.42, 1.45); // -0.42rad≈-24°1.45rad≈83°这两个数字-24° 和 83°是试出来的抬得太高像在倒着飞低得不够又看不出俯冲。它们不影响任何逻辑纯粹是让判定和视觉对得上的心理暗示——玩家觉得“鸟头朝下冲进管子”比“鸟平平地贴上去”更容易接受死亡。3. 管道生成与碰撞对象池、开口公式和一个收缩系数管道是这类游戏里唯一会持续增长的东西写法不对就会出现“越玩越卡”和“明明没碰到却死了”两大顽疾。前者是内存问题后者是数据不同源问题两个都得从数据结构层面解决。3.1 用对象池代替 remove/add最直觉的写法是每帧pipes.add(new Pipe())、出屏后pipes.remove(0)。ArrayList的remove(0)会把后面所有元素整体搬移一位add在容量不足时还要扩容复制管道保持在屏幕上 3~4 根、每 2 秒轮换一次看着不频繁但配合每帧的遍历和第 4 章的碰撞实测每过七八根管子就有一次可感知的顿挫。改成固定长度数组加环形复用就干净了class Pipe { float x; // 管道左边缘的世界坐标 float gapY; // 开口中心的纵坐标 float gapH; // 开口总高度 boolean scored; // 是否已计分见 4.2 } final int PIPE_COUNT 4; Pipe[] pipes new Pipe[PIPE_COUNT]; float pipeW 78; // 管道宽度 float gapH 180; // 开口高度 float spacing 240; // 相邻两根管道的水平间距 void initPipes() { for (int i 0; i PIPE_COUNT; i) { pipes[i] new Pipe(); pipes[i].x width 120 i * spacing; pipes[i].gapY random(gapH * 0.6, height - gapH * 0.6); pipes[i].gapH gapH; pipes[i].scored false; } } void updatePipes(float dx) { for (Pipe p : pipes) { p.x - dx; // 世界向左滚动 if (p.x pipeW 0) { // 完全出屏才回收 p.x PIPE_COUNT * spacing; // 接到队尾而不是 new 一个 p.gapY random(p.gapH * 0.6, height - p.gapH * 0.6); p.scored false; } } }逻辑说明回收时用p.x PIPE_COUNT * spacing而不是p.x width这是整个对象池能稳住的关键。如果重置到width一旦某帧因为卡顿导致dx变大队尾的管道会和刚回收的管道挤在一起甚至重叠加上“一整轮的间距”之后队列的相对位置永远均匀间距不会因为帧率波动而漂移。参数说明PIPE_COUNT取 4 是算出来的屏幕宽度 480 除以间距 240 约等于 2再多留两根在屏幕右侧待命保证任何时刻都有管子接上。pipeW取 78 是视觉宽度碰撞时用的是另一套收缩后的判定框两者刻意不同源但共用x和gapY——只要绘制和判定读的是同一份gapY/gapH就不会出现画一套算一套的穿模。3.2 开口高度与水平间距难度曲线的三个旋钮真正决定难度的是三个量gapH开口高度、spacing水平间距、dx每帧滚动像素。前两个是空间后一个是时间三者可以换算成一个玩家能感知的指标——反应时间从管道进入屏幕右缘到抵达鸟的位置可用的秒数 ≈ (width - BIRD_X) / (dx * 60)按 480 宽、鸟在 120、dx 取 2.2 计算大约是 2.7 秒当 dx 涨到 3.4只剩 1.76 秒。低于 0.8 秒基本就是背板而不是反应不适合作为起始难度。这个换算看着简单但它把“我调了速度好像变难了”变成“反应窗口从 2.7 秒压到 1.8 秒”调参时心里有数。难度递增用一个 0~1 的进度量做线性插值30 分封顶void updateDifficulty() { float d constrain(score / 30.0, 0, 1); // 0~130 分后不再变难 gapH lerp(190, 140, d); // 开口 190 - 140 scrollSpeed lerp(2.2, 3.4, d); // 每帧像素 spacing lerp(250, 210, d); }逻辑说明lerp的三个参数是起点、终点、进度写起来比手算start (end - start) * d清楚。注意这里只改“下一根被回收的管道”的参数已经在屏幕上的管道保持原样——如果每帧都去改所有管道的gapH玩家会看到管子开口像呼吸一样收放那才是真正的玄学。参数说明gapH的下限 140 是硬边界再低就会出现“鸟的判定框加上管道宽度已经不允许任何操作余量”的情况理论上还能过实际全靠运气。spacing的下限 210 是为了保证两根管子不会同时在鸟的位置附近形成夹击。3.3 碰撞AABB 加一个 0.75 收缩系数碰撞用轴对齐包围盒AABB就够了不要去做像素级检测。像素级不但慢而且会把贴图上的描边、阴影算进去玩家看到的是“我贴着边过去了”判定却认为撞上这种不公平感比直接撞死更劝退。// 鸟的判定框比贴图小 25%这是让玩家觉得“公平”的关键 final float HIT_SCALE 0.75; boolean hitPipe(Pipe p) { float halfW 24 * 0.5 * HIT_SCALE; float halfH 24 * 0.5 * HIT_SCALE; // 横向不相交直接返回省掉后面的纵向判断 if (pos.x halfW p.x || pos.x - halfW p.x pipeW) return false; float topBottom p.gapY - p.gapH * 0.5; // 上管的底边 float botTop p.gapY p.gapH * 0.5; // 下管的顶边 if (pos.y - halfH topBottom) return true; // 撞上管 if (pos.y halfH botTop) return true; // 撞下管 return false; }逻辑说明先做横向粗筛再做纵向判断是因为横向不相交的概率远高于纵向提前返回能省掉大部分计算。上管的绘制高度就是topBottom下管从botTop画到height判定和绘制共用这两个变量所以只要gapY和gapH不被别的逻辑偷偷改掉就永远不会出现“看着没碰到却死了”。参数说明HIT_SCALE是本项目里最值得单独拿出来讲的一个数。取 1.0 时判定框和贴图完全重合玩家会觉得“擦边就死”取 0.75 相当于允许鸟的像素边缘压进管道 3 像素左右视觉上有惊无险操作反馈立刻变好。低于 0.6 就反过来了看着撞进去却没死信任感崩得比撞死还快。注意判定框收缩只缩鸟不要缩管道。缩管道会让开口看起来比实际大玩家对距离的判断会持续失准。4. 状态机与计分READY / PLAYING / GAME_OVER 三态怎么切把状态和计分单拎一章是因为这类小游戏的 bug 有七成出在这里重开不清零、一次过管加两分、死亡后还在跑物理。用布尔量拼状态是万恶之源。4.1 用 enum 管状态别用三个 boolean 拼boolean playing、boolean gameOver、boolean started三个变量一起用必然出现playing gameOver同时为真的中间态。我见过最典型的一次是重开时忘了清gameOver鸟在 READY 界面就开始自由落体玩家还没反应过来已经死了。enum加switch强制每次只走一个分支这类 bug 从结构上就不存在。enum GameState { READY, PLAYING, GAME_OVER } GameState state GameState.READY; void update() { switch (state) { case READY: // 待机时鸟做上下浮动不施加重力 pos.y height * 0.42 sin(millis() / 300.0) * 8; break; case PLAYING: stepPhysics(FIXED_DT); updatePipes(scrollSpeed); updateDifficulty(); if (hitAnyPipe() || pos.y height - 12) { state GameState.GAME_OVER; } checkScore(); break; case GAME_OVER: // 只落不飞掉到接近底部就停住 if (pos.y height - 20) { stepPhysics(FIXED_DT); } break; } }逻辑说明READY 状态下完全不碰vel鸟的位置每帧由正弦函数直接给这样玩家看到的是一只悬停的鸟而不是一只正在下坠的鸟。PLAYING 分支里先算物理再算管道再判碰撞顺序不能反——先判碰撞再移动位置会出现判定用的是上一帧坐标的情况表现为“穿过去半根才死”。GAME_OVER 分支保留物理但不接受输入鸟会继续下坠一段再停住这个细节让死亡有重量感。参数说明height - 12是落地判定的阈值取 12 是鸟贴图半高配合height - 20的停止线让鸟落在地上而不是陷进去。millis() / 300.0里的 300 是浮动周期改小变快、改大变得更慢。4.2 计分用 scored 标志位不要用距离阈值见过不少实现写成if (abs(pos.x - p.x) 5) score;这行代码在管道每帧移动 2~3 像素的情况下会漏判一帧跨过去了或重复判阈值太大连续两帧都满足。正确做法是给每根管道一个“是否已计分”的布尔位条件写成“鸟已经越过管道的右边缘”天然幂等。void checkScore() { for (Pipe p : pipes) { if (!p.scored pos.x p.x pipeW) { p.scored true; // 置位保证一根管道只计一次 score; } } }逻辑说明pos.x p.x pipeW是“整只鸟含判定框都过了管道右边缘”的意思比用中心点判断更符合直觉。p.scored在 3.1 的回收逻辑里被重置为false所以同一根管道被复用后还能再计一次分整套流程闭环。参数说明score建议单独维护不要复用frameCount或任何全局计数。分数一旦和帧数绑在一起重开就会出问题——frameCount是自 sketch 启动以来的累计值永远不会归零。4.3 重开一局要重置的清单重开是这类游戏最容易留残影的地方。我一般写一个resetAll()作为唯一入口把所有会变的东西列成清单一条一条对着写而不是散落在keyPressed里。void resetAll() { score 0; scrollSpeed 2.2; gapH 190; spacing 250; resetBird(); // pos / vel initPipes(); // 管道的 x / gapY / gapH / scored lastJumpMillis -1000; stateStartFrame frameCount; state GameState.READY; }逻辑说明清单式重置的好处是新增字段时一眼能看出“这里要补一行”。管道必须走initPipes()而不是只清scored因为上一局的管道位置会留在屏幕中间重开后鸟直接撞上去。参数说明stateStartFrame是自己记的帧号用途见下。注意frameCount是自 sketch 启动以来的全局帧数重开不会清零。任何“每 30 帧闪一次”的动画都要写成frameCount - stateStartFrame直接用frameCount % 30会在重开后表现得不规律。5. 五条血泪踩坑从“能跑”到“能玩”之间隔着什么下面这五条是照着别人代码改或者自己从头写都会撞上的前三条偏画面与性能后两条偏逻辑判定。每条按“现象→原因→解决”说能对号入座就不用重走一遍。5.1 画面与性能类第一条越玩越卡七八根管子之后明显顿一下。现象是前面几根很顺过了十来根开始每隔一两秒卡一帧帧率显示从 60 掉到 45 又弹回来。原因是每帧在draw()里new Pipe()或者new PVector()管道轮换时对象不断创建又丢弃垃圾回收周期被打断表现为规律性的顿挫。解决是把管道改成 3.1 的固定数组复用PVector只在resetBird()里创建一次后续全部用set()和add()原地修改。改完再测帧率曲线会变成一条直线。第二条第一帧图片黑块或者鸟身后拖着一条残影。现象是启动瞬间鸟的位置是一块黑色矩形或者飞行时背后留下一串半透明残像。原因有两个一是loadImage()写在draw()里第一帧图片还没解码完就画了二是draw()开头漏了background()上一帧没被覆盖。解决是把所有loadImage()挪进setup()并检查返回值是否为 null同时在draw()第一行无条件调用background()。如果确实需要拖影效果用半透明矩形叠一层别指望不清屏。第三条判定框和看到的鸟对不上明明擦过去了却说撞上。现象是视觉上鸟和管道还有一两个像素的间隙游戏却判定死亡或者反过来。原因通常是渲染用了P2D或做了scale()缩放而碰撞判定还在原始坐标系里算。解决是把碰撞计算统一放在世界坐标里做需要缩放或旋转时用pushMatrix()/popMatrix()把变换包起来画完就弹栈绝不让变换泄漏到判定逻辑里。5.2 逻辑与输入类第四条鸟从管道中间穿过去没触发死亡。现象是偶尔一次下落特别快鸟直接出现在管道另一侧判定完全没反应。原因是掉帧导致单帧位移超过了判定框高度——vel.y达到 20 以上时一帧就能移动 20 像素而鸟的判定框高度只有 18 像素中间那一小段被整帧跳过了。解决分两层先把MAX_FALL_SPEED压到 11 以内保证单帧位移小于判定框高度再在stepPhysics里做子步进位移大于半高就拆成多次小步每步都判一次碰撞。第五条一次过管加了两分或者重开一局分数接着上一局算。现象有两种但根子是同一个。前者是计分条件写成了“鸟在管道范围内”这个条件连续多帧为真每帧都加一次分后者是score没进resetAll()。解决是把计分改成 4.2 的标志位写法条件收紧到“越过右边缘”并把score 0明确写进重置清单。顺手把scored也一起重置否则复用的管道会带着上一局的计分状态。还有一个容易被忽略的输入问题想实现“按住鼠标连跳”的人会发现按住不动鸟不飞。mousePressed()函数是按下瞬间触发一次按住不会重复调用这是符合预期的要连跳得自己判断mousePressed这个布尔变量并在draw()里按冷却节奏触发doJump()。这个坑跟判定无关但很多人第一次做都会卡在这儿。6. 把参数拧成一张表调手感、验证与一份可复现的自检流程前面所有常量都是“我觉得合适”的值真正交付前必须按目标设备调一轮。我的习惯是把可调参数从final常量挪到普通成员变量用一个调试面板实时改边玩边调比改一次重跑一次快十倍。参数起始值调整步长观察什么gravity0.42±0.03松手后下坠的“沉”度太快会来不及点JUMP_IMPULSE-7.6±0.3一次点击抬升的高度太小会被管道憋死MAX_FALL_SPEED11±1掉帧时会不会穿管超过判定框半高就危险gapH190→140±10新手第一次能不能过140 是硬下限spacing250→210±10两根管之间的喘息时间scrollSpeed2.2→3.4±0.1整体节奏3.4 时反应窗口约 1.8 秒// 调试期用方向键实时改参数改完按 r 重开验证 void keyPressed() { if (key CODED keyCode UP) gravity 0.03; if (key CODED keyCode DOWN) gravity - 0.03; if (key CODED keyCode LEFT) JUMP_IMPULSE 0.3; // 注意负值加是变弱 if (key CODED keyCode RIGHT) JUMP_IMPULSE - 0.3; if (key r) resetAll(); if (key p) saveFrame(frames/frame-####.png); // 逐帧存图做轨迹比对 doJump(); // 空格也走跳跃 }这段代码里JUMP_IMPULSE的方向最容易搞混它是负值0.3实际是让跳跃变弱。调试面板一定要在屏幕上把当前数值打印出来text画在左上角否则调着调着就不知道自己在哪个值上了。调完之后要验证不能靠“我感觉变难了”。我用的办法是固定随机种子加脚本化点击randomSeed(42)保证每局管道布局一致然后用一个计数器每隔固定帧数调一次doJump()看能不能稳定通过前 10 根管道。调参前后各跑一遍通过根数的变化就是可比的基线。这套流程不玄学比反复手动试玩靠谱得多也方便把“这一版手感更好”这种主观判断变成“同样的自动操作多过了三根管”的客观记录。从那以后我每次交这类小游戏之前都会先固定随机种子跑一遍 30 根管道的自动点击回放再手动玩三局两边的结论要是对不上八成就是判定框和绘制不同源去查gapY有没有被别的地方改过基本一查一个准。希望帮到你。本文还有配套的精品资源点击获取