简介:面向 Processing 初学者与视觉交互开发者的类 FlappyBird 小游戏源码,适合借助一个可运行项目理解绘图基础、动画循环、碰撞检测、键盘输入、随机管道生成、计分与游戏状态管理等核心环节,也可作为课程示例或课程设计参考。压缩包共 12 个文件、约 2.48MB,主体为 4 个 pde 源码文件,并配有 png 图像、mp3/wav 音效与 properties 配置,覆盖代码、素材与项目设置,便于直接运行、拆解和二次练习。已有 1041 人学习,代码按小鸟、管道、通用函数等职责拆分,体现面向对象组织方式;结合图像与声音资源可呈现完整游戏反馈,帮助读者梳理从素材加载、对象更新到游戏逻辑串联的实现路径,并对主循环、模块协作、项目结构和资源管理形成直观认识,积累调试、排错、扩展与模块化改写思路。
1. 一只按空格就扑腾的小鸟:Processing 版类 FlappyBird 是怎么跑起来的
第一次把这份 Processing 写的类 FlappyBird 小游戏拖进编辑器时,我以为会看到满屏写死的坐标数字。运行之后三秒,一个方块小鸟开始自由下坠,按一下空格它往上蹿一截,撞到绿色管道就黑屏重来——就这一下,我把它留下了。它没有物理引擎,也没有实体组件框架,全部逻辑压在几个 .pde 文件里:setup 做一次初始化,draw 每秒跑六十遍,管道丢进 ArrayList 往里塞往外删,碰撞判定就是一对方框比四条边。正因为结构简单,它才适合当骨架用:想搞懂 Processing 画布刷新和对象管理的,能一口气读完;想给课程作业交个能跑的交互 Demo 的,换几张图就成;想把 2D 跳跃玩法改成自己的东西(换角色、加道具、改难度曲线)的,接入点全写在明面上。下面按「怎么跑起来、逻辑长什么样、哪里容易翻车、怎么改」的顺序往下拆。
2. 工程骨架与主循环:setup/draw 双阶段加三态状态机
2.1 从单文件 .pde 拆成多文件草图
Processing 的草图(sketch)默认只有一个主 .pde 文件,所有代码堆在一起。写小 Demo 没问题,一旦超过三百行,找bird.y在哪儿改都得靠搜索。这类游戏我一般会拆成下面这个结构,Processing 会把同目录下所有 .pde 文件按文件名字母序合并成一个隐式的PApplet子类,所以拆完不需要写任何 import 或者继承关系:
FlappyClone/ ├── FlappyClone.pde // 主草图:setup / draw / 全局状态与输入 ├── Bird.pde // 小鸟实体:位置、速度、跳跃、旋转 ├── PipePair.pde // 一对上下管道 ├── PipeManager.pde // 管道池:生成、回收、碰撞查询 ├── ScoreBoard.pde // 分数与最高分的读写 ├── GameState.pde // 状态枚举 └── data/ ├── bird.png ├── pipe_up.png ├── pipe_down.png ├── bg.png ├── jump.wav └── hit.wav这张目录表里有两个 Processing 特有的点值得先记住。第一,data/目录是草图的资源根,loadImage("bird.png")不需要写data/bird.png,写了反而找不到;第二,文件名大小写在 Linux 和 macOS 上是敏感的,Bird.png和bird.png是两份东西,跨平台拷来拷去时这一条能省掉半小时排查。
提示:如果拆完文件后编辑器提示「重复定义」,通常是把同一个类拆到了两个 .pde 里,或者某个函数名和 Processing 内置函数撞了(比如自己写了个
size())。
2.2 setup 做一次,draw 每秒做六十次
Processing 的执行模型非常干脆:程序启动时调用一次setup(),然后以目标帧率反复调用draw(),直到窗口关闭。所有「持续变化」的东西都必须放在draw()里或者由它驱动,所有「只做一次」的初始化都放setup()。主草图我一般写成这样:
// FlappyClone.pde —— 全局状态与主循环 GameState state = GameState.READY; Bird bird; PipeManager pipes; ScoreBoard score; PImage bgImg; void setup() { size(480, 720); // 竖屏比例,接近手机端手感 frameRate(60); // 只是"目标上限",实际帧率看机器 smooth(2); // 抗锯齿等级 2~8,低配机器降到 1 bgImg = loadImage("bg.png"); reset(); // 初始化和"重开"共用同一段逻辑 } void reset() { bird = new Bird(width * 0.3, height * 0.45); pipes = new PipeManager(width, height); score = new ScoreBoard(); state = GameState.READY; } void draw() { float dt = 1.0 / max(frameRate, 1); // 上一帧的真实耗时,单位秒 image(bgImg, 0, 0, width, height); // 先铺背景,等于清屏 if (state != GameState.DEAD) { // 死亡后画面冻结,不再推进世界 pipes.update(dt, bird.pos.x); bird.update(dt); } pipes.display(); bird.display(); score.display(); switch (state) { case READY: drawReadyTip(); break; case PLAYING: break; case DEAD: drawGameOver(); break; } }这段代码里最关键的一行是float dt = 1.0 / max(frameRate, 1)。frameRate是 Processing 的内置变量,返回的是上一帧实际耗时折算出的帧率,不是你在setup()里设的那个目标值。用它算出的dt让「每秒移动多少像素」这件事和机器性能解耦:跑到 60 帧就是每帧 0.0167 秒,掉到 30 帧就是 0.0333 秒,物体在一秒内走过的总距离是一样的。max(frameRate, 1)是防零除的保护,某些机器启动瞬间frameRate会返回 0,不做保护会直接得到 Infinity 然后坐标全变 NaN,画面上小鸟凭空消失,这种玄学问题查起来最费劲。
reset()单独抽出来是有意的。这类游戏最常见的低级 bug 就是「重开之后分数没清零」或者「重开之后管道还停在原来的位置」,根源都是把初始化写在setup()里,而setup()一辈子只跑一次。把初始化和重置合成一个函数,重开时直接再调一遍,状态就干净了。
2.3 三态状态机:READY / PLAYING / DEAD
游戏逻辑再简单,也得有个明确的阶段划分,否则就会出现「还没开始管道就在动」或者「死了以后小鸟还在掉」这种别扭感。用一个枚举把三个状态固定下来:
// GameState.pde public enum GameState { READY, // 等待第一次按键,世界静止 PLAYING, // 正常游戏 DEAD // 撞管或落地,显示结算 }状态切换的入口集中在输入回调里,这样「什么状态下按键有什么用」一眼能看全:
void keyPressed() { if (key != ' ') return; switch (state) { case READY: state = GameState.PLAYING; bird.jump(); // 起跳和开局在同一个动作里完成 break; case PLAYING: bird.jump(); break; case DEAD: if (millis() - deathTime > 600) reset(); // 600ms 冷却防误触 break; } }三个状态各自对应不同的draw行为:READY下管道不规则推进,只画一个「按空格开始」的提示;PLAYING下世界正常滚动;DEAD下停止更新但继续绘制最后一帧,让玩家看清自己是怎么死的。millis() - deathTime > 600这个冷却窗口是人给的后悔药——玩家撞管那一下手指多半还按在空格上,不给冷却会瞬间重开,看起来像游戏闪了一下,体验极差。
mousePressed()里可以原样复制一份keyPressed()的逻辑,这样桌面端用鼠标、移动端用触摸都能玩,改造成本几乎为零。
3. 物理与手感调参:重力、跳跃冲量与帧率无关的位移
3.1 不要写 y += g,要写 v += g * dt
新手写跳跃游戏最爱写的一行是y = y + speed; speed = speed + 0.5;。这样写能跑,但它有三个隐患:速度单位和距离单位混在一起、重力大小和帧率绑定、跳跃力度和重力不成体系没法单独调。正确的写法是把它拆成两个量——速度(像素/秒)和加速度(像素/秒²),每一帧先更新速度,再用速度更新位置:
// Bird.pde class Bird { PVector pos; float velY = 0; float gravity = 1800; // 像素/秒²,向下为正 float jumpSpeed = -520; // 像素/秒,向上为负 float maxFall = 900; // 终端速度,防止高速穿模 float rotation = 0; float r = 16; // 碰撞半径,hitbox 用 Bird(float x, float y) { pos = new PVector(x, y); } void jump() { velY = jumpSpeed; // 覆盖,不是累加 rotation = -25; } void update(float dt) { velY += gravity * dt; // 先积分速度 velY = constrain(velY, -1200, maxFall); // 再夹住上下限 pos.y += velY * dt; // 最后积分位置 // 旋转跟着速度走:上升抬头、下坠低头 rotation = lerp(rotation, map(velY, jumpSpeed, maxFall, -25, 70), 0.15); } void display() { pushMatrix(); translate(pos.x, pos.y); rotate(radians(rotation)); imageMode(CENTER); image(birdImg, 0, 0, 34, 24); popMatrix(); } }逐段说清楚。velY += gravity * dt是显式欧拉积分,精度一般但对这种手感的游戏完全够,而且比 Verlet 之类好调试。constrain(velY, -1200, maxFall)里的maxFall是终端速度,没有它的话,小鸟从屏幕顶端掉到地面那一下单帧位移会超过管道宽度,碰撞检测采样的那一刻它可能已经在管道另一侧了,看起来就是「穿模」——这是 2D 游戏里最典型的血泪经验之一。map(velY, jumpSpeed, maxFall, -25, 70)把速度线性映射成角度,配一个 0.15 的lerp做插值,旋转就不会跳变。
jump()里velY = jumpSpeed是赋值而不是velY += jumpSpeed。改成累加的话,玩家手速一快连点三下,速度会叠到 -1560,小鸟像坐了火箭直接飞出屏幕顶端,然后被constrain的上限 -1200 拽住,整个手感就崩了。
3.2 手感其实就三个参数在打架
调这类游戏的手感,本质是调三组数的平衡。我把它们列出来,改的时候一次只动一个:
| 参数 | 起始值 | 调大的效果 | 调小的效果 |
|---|---|---|---|
gravity | 1800 | 下坠更快,容错时间变短,难度飙升 | 飘,像在月球上,节奏拖沓 |
jumpSpeed | -520 | 蹿得高,容易撞上管,操作变笨重 | 蹿不动,连点次数增加,累手 |
maxFall | 900 | 掉得更狠,入管时容易来不及拉 | 下落拖泥带水,像有空气阻力 |
pipe.gap | 150 | 缺口大,通关容易,适合新手期 | 缺口小,必须精准,30 分后开始难受 |
一个经验比例是|jumpSpeed| / gravity ≈ 0.28 ~ 0.32,也就是起跳后大约 0.29 秒到达最高点,再花 0.29 秒回到起跳高度。小于 0.25 会感觉跳跃很「硬」,大于 0.35 会感觉控制不住。这个比例不是什么标准,是反复试出来的起手值,改完必须自己玩二十把。
3.3 键盘是事件,不是电平
Processing 里keyPressed()是回调函数,keyPressed(没有括号)是布尔变量。这两者混用是新手翻车的重灾区:在draw()里写if (keyPressed) bird.jump();,按住空格不放,每秒会触发六十次 jump,小鸟直接钉在屏幕顶端。正确做法是用回调做「一次性事件」,再加一个极短的输入缓冲:
boolean jumpQueued = false; void keyPressed() { if (key == ' ') jumpQueued = true; // 只记账,不直接改物理 } void draw() { if (jumpQueued && state == GameState.PLAYING) { bird.jump(); } jumpQueued = false; // 每帧末尾清空,不积压 // ...其余更新与绘制 }为什么绕这一圈而不是直接在keyPressed()里调bird.jump()?因为按键回调的触发时机和draw()的帧边界不同步,一帧内可能来两次按键,也可能一次没有。先记账再在帧边界统一消费,能保证物理状态只在帧边界被修改,后面加录像回放或者联网同步时这一点尤其重要。jumpQueued = false放在帧末而不是帧初,是为了让同一帧内到达的多次按键只生效一次——按住空格不放自然就不会连跳。
3.4 屏幕边界别用魔法数字
小鸟飞出屏幕顶端要不要判定死亡?这类游戏普遍的处理是:撞到顶部不判死,但夹住坐标;撞到底部判死。写成:
void checkBoundary() { if (pos.y - r < 0) { // 顶到天花板 pos.y = r; velY = 0; // 顶住,不反弹 } if (pos.y + r > height) { // 落地 pos.y = height - r; triggerDeath(); } }height是 Processing 内置变量,永远等于size()设的高,不用自己存一份。所有边界判断都该用它,而不是写720——一旦你想改成 640 高的小窗,写死的数字会漏改,症状是「画面下半截没判定」或者「提前死了」。
4. 管道系统与碰撞检测:ArrayList 的增删与 AABB 判定
4.1 一对管道就是一个对象,回收靠逆序删除
管道的本质是「一对上下分开的矩形,中间留一个缺口」。把它封装成一个类,位置只需要记一个 x,缺口中心记一个 y,剩下的靠缺口高度算出来:
// PipePair.pde class PipePair { float x; float gapY; // 缺口中心线的 y float gap; // 缺口高度 float w = 70; boolean scored = false; // 是否已经计过分 PipePair(float x, float gapY, float gap) { this.x = x; this.gapY = gapY; this.gap = gap; } void update(float dt, float speed) { x -= speed * dt; // 向左滚动 } boolean offScreen() { return x + w < 0; // 完全移出左边界才回收 } }管理器的职责只有三件:按节奏生成、每帧推进、出界就删。这里有个必须讲清楚的点——删除必须逆序遍历:
// PipeManager.pde class PipeManager { ArrayList<PipePair> list = new ArrayList<PipePair>(); float timer = 0; float interval = 1.6; // 生成间隔,秒 float speed = 150; // 滚动速度,像素/秒 float gap = 150; int scoreRef = 0; void update(float dt, float birdX) { timer += dt; if (timer >= interval) { timer = 0; // 缺口中心留出上下各 80px 的安全区,避免贴边生成 float gy = random(gap / 2 + 80, height - gap / 2 - 80); list.add(new PipePair(width + 20, gy, gap)); } for (int i = list.size() - 1; i >= 0; i--) { // 逆序! PipePair p = list.get(i); p.update(dt, speed); if (p.offScreen()) list.remove(i); } } }正序遍历边删边会漏元素:删掉索引 0 之后,原来索引 1 的元素挪到了 0,而循环变量已经加到 1,它就被跳过了。这次只是少回收一根管道,问题不明显;如果管道对象里还挂着粒子特效或者音效引用,漏回收就是妥妥的内存泄漏。逆序删除从末尾往前扫,删过的位置不会被再次访问,一次遍历搞定。
random(gap / 2 + 80, height - gap / 2 - 80)里的 80 是安全边距。不留边距的话,缺口中心可能生成在gap/2的位置,上管高度接近 0,视觉上像一根孤零零的短柱,玩家会以为是渲染 bug。
4.2 碰撞用 AABB,不用圆形距离
管道是矩形,小鸟是近似圆的方块,所以碰撞判定用轴对齐包围盒(AABB)最合适。四个条件全部满足才算相交:
// 通用 AABB 相交判定 boolean rectRect(float ax, float ay, float aw, float ah, float bx, float by, float bw, float bh) { return ax < bx + bw && // A 左边在 B 右边之内 ax + aw > bx && // A 右边在 B 左边之外 ay < by + bh && // A 上边在 B 下边之内 ay + ah > by; // A 下边在 B 上边之外 } // 小鸟 vs 一对管道 boolean hitPipe(Bird b, PipePair p) { float bx = b.pos.x - b.r; float by = b.pos.y - b.r; float bw = b.r * 2; float bh = b.r * 2; // 上管:从屏幕顶 0 到缺口上沿 if (rectRect(bx, by, bw, bh, p.x, 0, p.w, p.gapY - p.gap / 2)) return true; // 下管:从缺口下沿到屏幕底 if (rectRect(bx, by, bw, bh, p.x, p.gapY + p.gap / 2, p.w, height - (p.gapY + p.gap / 2))) return true; return false; }b.r = 16但图片画出来是 34×24,也就是碰撞盒比贴图小了一圈。这是故意的,不是 bug。像素风格的贴图边缘往往带一两个像素的透明留白,按贴图原尺寸判定会出现「明明看着没碰到就死了」,玩家会骂人。一般做法是让 hitbox 比视觉尺寸小 4~6 像素,手感立刻变宽容。调试阶段可以在display()里额外画一个noFill(); stroke(255,0,0); rect(bx, by, bw, bh);把碰撞盒显形,确认它确实比贴图小。
不要用「圆心距离小于半径」那套圆形碰撞。管道是瘦长的矩形,圆形判定会让小鸟在缺口上下沿的位置出现「看起来还有缝,实际上已经算撞了」的误判,比矩形难看很多。
4.3 计分只认「整根越过」,靠标记去重
计分逻辑看着简单,写错了会出现分数乱跳。最稳的判定是:管道的右边缘越过小鸟的中心线,且这根管道还没被记过:
void checkScore(Bird b) { for (PipePair p : list) { if (!p.scored && p.x + p.w < b.pos.x) { p.scored = true; score.add(1); } } }p.scored这个标记是必须的。没有它,p.x + p.w < b.pos.x在管道继续左移的过程中每一帧都成立,分数会以每秒六十次的速度飙升到几千。换成「小鸟 x 落在管道 x 和 x+w 之间」也不行,那个区间会持续若干帧,同样会重复加分,还得配一个「是否在区间内」的状态位,反而更绕。
4.4 难度曲线:越往后越挤,但要有天花板
一个恒定难度的游戏玩到第三十根管道就无聊了,所以参数得跟着分数走。用norm把分数压到 0~1,再线性插值三组参数:
void applyDifficulty(int scoreValue) { float t = constrain(scoreValue / 30.0, 0, 1); // 30 分封顶 interval = lerp(1.60, 1.00, t); // 生成间隔 1.6s -> 1.0s speed = lerp(150, 265, t); // 滚动速度 150 -> 265 gap = lerp(150, 118, t); // 缺口高度 150 -> 118 }constrain(..., 0, 1)是天花板,防止三十五分之后继续变难直到不可通关。lerp是 Processing 内置的线性插值。三个参数同时变会让难度曲线变得很陡,如果只想动一个,建议先只缩interval,它对体感的影响最直接,也最容易调回舒适区。
5. 避坑与排查:帧率、路径、判定这三处最容易翻车
5.1 换台电脑管道突然变慢,位移只有一半
现象:在自己机器上跑得好好的,拷到另一台笔记本上,管道滚动速度肉眼可见变慢,跳跃高度也变低了,代码一行没改。
原因:位移写成了「每帧固定像素」,比如x -= 2.5;或者pos.y += velY;没有乘dt。这种写法把「每秒移动多少」偷偷绑定到了帧率上,60 帧的机器一秒走 150 像素,30 帧的机器一秒只能走 75 像素。窗口一变大、抗锯齿一开高,帧率掉下去,整个游戏的节奏就跟着变了。
解决:所有位移、速度积分统一乘dt,并且dt用实测帧率算而不是写死1.0/60。写死也不是不行,代价是帧率掉下来时游戏会变成慢动作——画面上六十帧渲染,逻辑只推进了三十次,看起来像是所有东西都变慢了。如果要做可复现的回放,反而应该写死固定步长,那是另一个取舍。
5.2 图片全白或者直接空指针,路径明明是对的
现象:loadImage("bird.png")抛NullPointerException,或者画面上只有一个白色方块,控制台没有任何报错。
原因:两个高频点。一是图片放在草图根目录而不是data/子目录里,Processing 的资源根就是data/,写loadImage("data/bird.png")反而找不到;二是文件名大小写不一致,Bird.PNG在 macOS 默认文件系统上能读到,拷到区分大小写的环境就挂了,这类问题跨平台协作时必然爆发一次。
解决:图片统一放data/,扩展名统一小写,代码里只写文件名。排查时在setup()里加一句println(sketchPath(""));把资源根打印出来,再println(new File(sketchPath("data/bird.png")).exists());,一行就能定位到底是路径问题还是文件问题,比瞎猜快得多。
5.3 明明还有缝就判死了,玩家骂判定是黑匣子
现象:小鸟从缺口中间穿过去,视觉上四周都有余量,却触发了死亡。
原因:碰撞盒用的是贴图原始尺寸,而像素图边缘有透明留白;或者碰撞盒的坐标系搞错了,imageMode(CENTER)之后还用左上角坐标去算 AABB,整体偏移了半个贴图。
解决:把 hitbox 独立成r或者w/h字段,不跟贴图尺寸挂钩,让它比视觉小一圈。然后在绘制阶段加一个调试开关,把碰撞盒用红色描边画出来,肉眼比对一次就知道偏在哪。我一般会留一个boolean DEBUG_HITBOX = false;的全局开关,发布前关掉,调手感时随时打开。
5.4 音效第一次响,第二次就没声音了
现象:撞击音效只在第一次死亡时播放,之后再也听不见,也不报错。
原因:SoundFile对象没被复用,或者在draw()里每帧new SoundFile(...),对象被反复创建。更常见的是同一个SoundFile还在播放时又调了一次play(),某些采样率的文件在这种重叠调用下会静默失败。
解决:音效对象在setup()里一次性加载成全局变量,播放前先判断if (!hitSound.isPlaying()) hitSound.play();。加载大量音效时注意内存,一个几秒的 wav 解压后可能占好几兆,音效文件别贪多。
5.5 重开一局,分数和管道还停在上把
现象:死亡后按空格重开,画面刷新了但分数还是上一把的,或者第一根管道出现在屏幕中间。
原因:状态切回READY了,但ArrayList没有清空、分数没有归零——reset()里只重建了小鸟对象。
解决:把所有需要归零的东西集中到一个reset()里,宁可写重复也别分散。检查方法是:在reset()末尾打印一次list.size()和score.value,正常应该永远是 0 和 0。
6. 二次开发:把写死的数字抽出来,给新玩法留接口
拿到这份代码之后最容易做的改造是换皮,但换皮两天就腻了,真正有用的是把结构改成能塞新东西的样子。第一步是把散落在各个类里的魔法数字集中成一个配置对象,我一般写在Config.pde里,用static final加static可变字段混着用:
// Config.pde class Config { static final int W = 480, H = 720; // 窗口尺寸 static final float GRAVITY = 1800; static final float JUMP = -520; static final float MAX_FALL = 900; static float pipeSpeed = 150; // 难度曲线会改,所以不是 final static float pipeGap = 150; static float pipeInterval = 1.6; static final boolean DEBUG_HITBOX = false; }抽出来之后,难度曲线改参数只动一处,做「简单/普通/困难」三个模式也只是切换几组初值。
第二步是给新元素留接入点。想加金币、加速道具、移动管道,思路是一样的:新建一个interface Scrollable,只要求实现update(float dt)、display()、offScreen()三个方法,然后把PipeManager里的ArrayList<PipePair>泛化成ArrayList<Scrollable>。移动的奖励物、会上下浮动的管道、飘过的云,全部塞进同一个列表,for循环里统一 update 和回收,新增一种元素的成本从「改三个地方」降到「写一个类」。
第三步是验证。改完手感之后别靠感觉,打开DEBUG_HITBOX,在draw()末尾叠一行实时数据:当前帧率、velY、当前难度系数t、管道数量。跑三把,看看管道数量是不是稳定在 3~5 根之间波动——如果这个数字一直往上涨,说明逆序删除被改坏了;如果一直是 1,说明生成间隔和移动速度不匹配。这个数据面板比任何日志都好使。
从那以后我每次动这类游戏的手感参数,都强制先把碰撞盒显形、把帧率和实体数量打到屏幕上跑三遍,肉眼确认没问题才关掉调试开关——因为手感这种东西一旦只凭感觉调,改到最后往往自己都分不清是变好了还是变累了。希望这份拆解帮到你,改出自己的那一版。
本文还有配套的精品资源,点击获取