Flappy Bird 的规则简单到近乎残酷:点击一下扇翅,不点就下坠,撞到管道就结束。难的地方在于人无法精确算出该在哪个高度点击。人类玩家其实是靠”感觉”——管子近了就早点扇,下落太快就连续点两下。这种”用模糊的定性判断代替精确数值计算”的思路,正是模糊逻辑控制器(Fuzzy Logic Controller)擅长的领域。本文用 Java 实现一个纯规则驱动的 Flappy Bird AI,不训练任何神经网络,也不做搜索,只靠几十条 IF-THEN 规则通过。
一、普通控制的困境:阈值法为什么会抖
最朴素的 AI 写法是设一个阈值:当鸟的下沿低于某个 y 值时扇翅。这个策略会在管道口附近反复抖动——刚好压线时,鸟上一帧在下、下一帧在上,控制器不停开关,实际轨迹是一条锯齿。加大阈值区间可以缓解抖动,但缓解的方式是牺牲精度,鸟会撞在管道上沿。
模糊控制提供的解法是把”低于阈值”这种二值判断,换成”升高的程度有多急”这种连续判断。它由三个部件组成:
- 模糊化:把精确输入映射为若干模糊集合的隶属度(0 到 1 之间)
- 规则推理:用 IF 前提 THEN 结论 的规则库,把输入隶属度合成输出隶属度
- 去模糊化:把输出模糊集合还原成一个精确的扇翅力度
对 Flappy Bird 来说,输入只需要两个:birdY(相对管道间隙的垂直偏移)和 birdDy(当前垂直速度)。输出只有一个:flapStrength(这一帧是否扇翅的强度,取 0 或 1,也可连续化为推力大小)。
二、模糊化:三角形隶属函数
我们为每个输入定义三到五个模糊集合。垂直偏移用”太高、略高、居中、略低、太低”,垂直速度用”急升、缓升、平稳、缓降、急降”。每个集合用三角形隶属函数 μ(x) = max(0, 1 – |x – a| / b) 计算,a 是中心点,b 是半宽。
/**
* 三角形隶属函数:中心 center,半宽 halfWidth
* 隶属度落在 [0, 1],超出支撑区间直接返回 0
*/
public class FuzzySet {
private final String name;
private final double center;
private final double halfWidth;
public FuzzySet(String name, double center, double halfWidth) {
this.name = name;
this.center = center;
this.halfWidth = halfWidth;
}
public double membership(double x) {
double d = Math.abs(x - center);
if (d >= halfWidth) {
return 0.0;
}
return 1.0 - d / halfWidth;
}
public String getName() {
return name;
}
}
一个常被忽视的实现细节是:相邻模糊集合必须重叠。若”居中”的中心是 0、半宽是 0.2,”略低”的中心是 0.2、半宽也是 0.2,那么 x=0.15 时两个集合同时有隶属度(分别是 0.25 和 0.75),控制器输出就是两条规则的加权平均,过渡平滑。如果集合不重叠,在边界处只有一个集合起作用,控制量会跳变——这就是阈值法抖动问题的另一种表现。
三、规则库:用自然语言写控制逻辑
规则库是整个控制器的灵魂。这里的规则不是从数据里学出来的,而是一句句写下来的经验:
- 如果 位置太低 且 下落很快 → 强力扇翅
- 如果 位置太低 且 下落平稳 → 中等扇翅
- 如果 位置居中 且 下落很快 → 中等扇翅
- 如果 位置居中 且 下落平稳 → 不扇翅
- 如果 位置太高 且 上升很快 → 不扇翅(让它自然下落)
- …
关键在于”同时考虑位置与速度”:只看位置会让鸟在高速下坠时来不及补救,只看速度会让鸟在管道上沿附近仍然扇翅撞顶。下面用二维规则表来表达这种交叉判断,这比逐条写 if-else 更易维护。
import java.util.ArrayList;
import java.util.List;
/**
* 二维规则表:行 = 位置(从高到低),列 = 垂直速度(从急降到急升)
* 单元格的值 = 建议扇翅强度(0.0 ~ 1.0)
*/
public class RuleBase {
/** 位置集合:太低 ... 太高(索引 0 为太低) */
public static final FuzzySet[] POS = {
new FuzzySet("太低", 1.0, 0.30),
new FuzzySet("略低", 0.55, 0.28),
new FuzzySet("居中", 0.0, 0.28),
new FuzzySet("略高", -0.55, 0.28),
new FuzzySet("太高", -1.0, 0.30)
};
/** 速度集合:急升 ... 急降(索引 0 为急升,正值表示向上) */
public static final FuzzySet[] VEL = {
new FuzzySet("急升", 1.0, 0.40),
new FuzzySet("缓升", 0.45, 0.32),
new FuzzySet("平稳", 0.0, 0.30),
new FuzzySet("缓降", -0.45, 0.32),
new FuzzySet("急降", -1.0, 0.40)
};
/**
* 规则表:[位置索引][速度索引] -> 扇翅强度
* 位置越大表示越靠下(危险),速度越负表示下坠越快
*/
private static final double[][] TABLE = {
// 急升 缓升 平稳 缓降 急降
{0.15, 0.30, 0.55, 0.85, 1.00}, // 太低
{0.00, 0.15, 0.40, 0.70, 0.95}, // 略低
{0.00, 0.00, 0.15, 0.45, 0.75}, // 居中
{0.00, 0.00, 0.00, 0.15, 0.40}, // 略高
{0.00, 0.00, 0.00, 0.00, 0.20} // 太高
};
/**
* 用 min-max 推理合成输出
* 规则激活强度 = min(位置隶属度, 速度隶属度)
* 每个格子给出一个"强度 - 扇翅力"的贡献,按激活强度加权平均
*/
public double infer(double posValue, double velValue) {
List<double[]> posMem = membershipVector(POS, posValue);
List<double[]> velMem = membershipVector(VEL, velValue);
double numerator = 0.0;
double denominator = 0.0;
for (int i = 0; i < POS.length; i++) {
for (int j = 0; j < VEL.length; j++) {
double w = Math.min(posMem.get(i)[1], velMem.get(j)[1]);
if (w <= 0) {
continue;
}
numerator += w * TABLE[i][j];
denominator += w;
}
}
if (denominator < 1e-9) {
// 输入落在所有集合之外时的兜底:直接按位置回退
return posValue > 0.3 ? 0.6 : 0.0;
}
return numerator / denominator;
}
/** 返回 [索引, 隶属度] 列表,只保留隶属度大于 0 的项 */
private List<double[]> membershipVector(FuzzySet[] sets, double x) {
List<double[]> res = new ArrayList<>();
for (int i = 0; i < sets.length; i++) {
double m = sets[i].membership(x);
if (m > 0) {
res.add(new double[]{i, m});
}
}
return res;
}
}
这张表的设计有一个不变量:越靠危险区(位置偏低、速度下坠),强度越大;在”太高 + 急升”的格子里强度为 0,明确不予干预。把经验固化成一个矩阵后,调参从”改一堆散落的 if 分支”变成”挪动矩阵里的单元格”,可控性强很多。
四、去模糊化与执行:从强度到动作
推理输出是一个连续值,而游戏动作只有”扇翅”与”不扇翅”。最简单的做法是设阈值:强度 > 0.5 就扇翅。但更好的办法是用”冷却”约束——扇翅后一段时间内无论如何不允许再扇,因为游戏本身有推力上限与运动惯性,连续扇翅会造成上升过冲。
/**
* Flappy Bird 游戏环境与模糊控制器主体
*/
public class FlappyFuzzy {
// 物理参数(单位:像素 / 帧)
private static final double GRAVITY = 0.42;
private static final double FLAP_IMPULSE = 7.0;
private static final double DT = 1.0;
private double birdY;
private double birdDy;
private int flapCooldown;
private final RuleBase rules = new RuleBase();
/** 管道间隙中心 y 与开口高度,由外部每帧更新 */
private double gapCenter;
private double gapHeight = 120;
public FlappyFuzzy(double startY) {
this.birdY = startY;
this.birdDy = 0;
}
/**
* 单帧决策 + 物理推进
* @return 本帧是否扇翅
*/
public boolean step(double gapCenterY, double gapHeight) {
this.gapCenter = gapCenterY;
this.gapHeight = gapHeight;
// 归一化输入:位置偏移 = (鸟y - 间隙中心) / 开口半高
// 正值表示鸟偏下(更容易撞下管)
double posNorm = (birdY - gapCenter) / (gapHeight / 2.0);
// 速度归一化:以 FLAP_IMPULSE 为参考尺度,正值向上
double velNorm = -birdDy / FLAP_IMPULSE;
double strength = rules.infer(posNorm, velNorm);
boolean flap = false;
// 冷却约束:扇翅后 6 帧内不再扇,避免过冲
if (flapCooldown > 0) {
flapCooldown--;
} else if (strength > 0.5) {
flap = true;
birdDy = -FLAP_IMPULSE;
flapCooldown = 6;
}
// 物理积分:先加速后位移,顺序影响不大但应保持一致
birdDy += GRAVITY * DT;
birdY += birdDy * DT;
return flap;
}
public double getBirdY() {
return birdY;
}
}
冷却系数 6 帧需要按游戏的实际帧率调整。若每帧 60 fps,6 帧是 100 毫秒,鸟类游戏的正常点击间隔大约就在这个量级。这个参数本质上是把”人手的操作频率上限”编码进控制器,效果比单纯调阈值自然得多。
五、完整可运行模拟:评估存活帧数
要验证控制器好不好,最直接的办法是跑一批随机管道并统计存活帧数。下面的主程序不含渲染,纯计算,适合快速迭代调参:
import java.util.Random;
public class FlappySimulator {
private static final double SCREEN_H = 640;
private static final double GAP_H = 120;
private static final double PIPE_W = 52;
private static final double SPEED = 3.0;
public static void main(String[] args) {
int runs = 200;
long totalFrames = 0;
int survived = 0;
Random rnd = new Random(2026);
for (int r = 0; r < runs; r++) {
int frames = simulate(rnd);
totalFrames += frames;
if (frames > 100000) {
survived++;
}
}
System.out.printf("平均存活帧数 = %.0f%n", (double) totalFrames / runs);
System.out.printf("通过 10 万帧的比例 = %d / %d%n", survived, runs);
}
/** 单局模拟,返回存活帧数;超过上限视为无限存活 */
private static int simulate(Random rnd) {
FlappyFuzzy ai = new FlappyFuzzy(SCREEN_H / 2);
double pipeX = 300;
double gapCenter = SCREEN_H / 2;
// 记录当前管道的间隙中心,鸟通过后切换到下一根
boolean passed = false;
for (int frame = 0; frame < 1_000_000; frame++) {
pipeX -= SPEED;
if (pipeX < -PIPE_W) {
pipeX = 400;
gapCenter = 100 + rnd.nextDouble() * (SCREEN_H - 200);
passed = false;
}
ai.step(gapCenter, GAP_H);
double y = ai.getBirdY();
// 碰撞判定:仅当鸟的水平位置与管道重叠时检查上下边界
double birdX = 120;
boolean overlap = birdX + 17 > pipeX && birdX - 17 < pipeX + PIPE_W;
if (y < 0 || y > SCREEN_H) {
return frame;
}
if (overlap) {
double top = gapCenter - GAP_H / 2;
double bottom = gapCenter + GAP_H / 2;
if (y - 12 < top || y + 12 > bottom) {
return frame;
}
}
}
return Integer.MAX_VALUE;
}
}
若平均存活帧数明显低于人工玩的水准(比如几百帧),通常是两处参数没对齐:一是输入归一化用错了尺度(把 birdDy 直接除以 1 而不是除以 FLAP_IMPULSE),二是物理参数与游戏真实值不一致导致控制器的”经验”失效。这类问题最容易被误判成”规则写错了”,其实根源是标度错误。
六、调参进阶:用爬山法自动整定规则表
手调 25 个格子很费时间。可以把它们当成 25 个连续参数,用简单的爬山法(局部搜索)优化。目标函数是模拟存活帧数,每次随机扰动一个格子,变好就保留:
import java.util.Random;
public class TableTuner {
private static final int ITER = 500;
public static void main(String[] args) {
Random rnd = new Random(7);
double[][] table = RuleBaseTable.snapshot();
double best = evaluate(table, rnd);
System.out.printf("初始适应度 = %.2f%n", best);
for (int it = 0; it < ITER; it++) {
int i = rnd.nextInt(table.length);
int j = rnd.nextInt(table[0].length);
double old = table[i][j];
// 单调性约束:下方格子不能低于上方格子,避免出现反直觉规则
double delta = (rnd.nextDouble() - 0.5) * 0.2;
double cand = clamp01(old + delta);
if (i > 0 && cand < table[i - 1][j]) {
continue;
}
table[i][j] = cand;
double score = evaluate(table, new Random(12345));
if (score >= best) {
best = score;
System.out.printf("iter %d: 表[%d][%d] %.2f -> %.2f, 适应度=%.2f%n",
it, i, j, old, cand, best);
} else {
table[i][j] = old;
}
}
}
private static double clamp01(double v) {
return Math.max(0.0, Math.min(1.0, v));
}
/** 固定随机种子评估,保证不同表可比 */
private static double evaluate(double[][] table, Random rnd) {
long total = 0;
for (int r = 0; r < 40; r++) {
total += FlappySimulatorHook.run(table, rnd);
}
return (double) total / 40;
}
}
两个工程要点:评估必须用固定种子,否则某次”变好”可能只是随机管道的运气;扰动必须加单调性约束,否则优化器会把”太低 + 平稳”的强度调得比”太低 + 急降”更低,规则表就失去物理意义了。
七、与其他 AI 方案的对比
| 方案 | 需要训练 | 可解释性 | 实现成本 | 在本游戏的表现 |
|---|---|---|---|---|
| 阈值法 | 无 | 极高 | 极低 | 差,临界抖动 |
| 模糊逻辑控制 | 无(仅调参) | 高 | 中 | 好,轨迹平滑 |
| Q-Learning | 需大量帧 | 低 | 中 | 好,但状态离散化成本高 |
| DQN / 神经网络 | 需大量帧与算力 | 极低 | 高 | 很好,但依赖训练环境 |
模糊控制的优势在于”零训练成本 + 高可解释性”。当游戏物理参数改变时,神经网络需要重新训练,而模糊控制器只需重新整定规则表,甚至可以直接按新参数缩放输入归一化尺度,规则表原封不动。它的劣势是规则表维度随输入个数指数增长,输入超过四五个之后基本无法手工维护。
八、小结
Flappy Bird 是一个把”手感”极度外显的游戏,而手感恰好是模糊逻辑最擅长形式化的东西。本文用 Java 完成了一个完整闭环:三角形隶属函数做模糊化,5×5 规则矩阵做推理,加权平均去模糊化,冷却约束把连续输出变成可执行动作,最后用爬山法自动整定规则表。最有价值的收获不是某一个参数值,而是那套”归一化输入 → 显式规则矩阵 → 约束性扰动优化”的流程,它可以套用到任何需要平滑控制的实时系统上。