玩 Flappy Bird 的难点不是”要不要点”,而是”点了之后还活不活得下去”。这其实是一个混合系统(hybrid system)问题:鸟的纵向运动是连续动力学(重力 + 速度脉冲),管道是离散出现的几何障碍。本文不谈强化学习,而是用逆向可达集分析 + 区间算术,算出每帧”还存在活路”的高度带,并据此给出一个带保证的操控策略。
这套方法的价值在于:它给的是逻辑保证,不是训练出来的经验概率。RL 会说”这个状态我 99.7% 能活”,可达性分析会说”这个状态无论你怎么点,都活不到 3 秒后”——后者才是能写进安全规范的结论。
一、把游戏写成混合自动机
连续状态 x = (y, v)(高度、竖直速度)。离散事件是管道间隙序列。
单帧的演化规则(固定步长 dt):
控制 u = flap: v' = vJump (拍翅,速度直接重置)
控制 u = fall: v' = clamp(v + g·dt) (自由下落,速度受限)
两种情况的位移: y' = y + v'·dt
用 y 向下为正的屏幕坐标,vJump 取负值(向上),g 取正值(向下)。这就是一个标准的两模态混合自动机:模态由 u 决定,守卫条件是速度的上下限。
安全问题定义为:存在一个操控序列,使鸟在所有管道到来时都不碰撞。这比”存在最优路径”要强:我们要的不是最短通关,而是”确认有活路”。
二、逆向可达集:从终点往前算”还有救的 y”
正向算”我可能在哪”(前向可达集)会爆炸:状态连续、分支逐帧翻倍,几百帧后集合退化成整个屏幕。真正有用的是逆向可达集:
SafeY(t) = { y : ∃u,使 y' = y + v'·dt 落在 SafeY(t+1) 内,且 y' 本身不撞管道 }
把分支显式化。两个控制分支对应的 y 到 y’ 映射:
- flap 分支:
v' = vJump与当前 v 无关,所以y' = y + vJump·dt,即y = y' - vJump·dt。这是一个纯平移。 - fall 分支:
v' = clamp(v+g·dt),而 v 是状态的一部分(可取[vMin, vMax]内任意值),所以y' = y + v'·dt中 v’ 可取[vMin+g·dt, vMax+g·dt]。于是y' ∈ y + [Δmin, Δmax],反向看就是y ∈ SafeY(t+1) ⊕ [-Δmax, -Δmin]—— 一个区间膨胀(Minkowski 和)。
两个分支的结果取并集,再与本帧”可站区”(全体高度减去被管道占据的高度)取交集。交集只会让集合变小,这就是逆向分析不会发散的根本原因。
三、区间算术:把”不确定性”写成会膨胀的区间
区间运算规则朴素但极重要:
[a,b] ⊕ [c,d] = [a+c, b+d] (Minkowski 和 = 平移/膨胀)
[a,b] ∩ [c,d] = [max(a,c), min(b,d)] (交集,可能为空)
[a,b] ∪ [c,d] = 排序合并(可能产生多段,用列表表示)
[a,b] \ [c,d] = 差集(管道占位)
关键的工程纪律是:所有取整必须向外。因为浮点误差会让真实安全状态被判成”不安全”——这比保守地多保留一些状态危险得多。安全分析里,所有保守都必须朝”更大危害”方向。
四、为什么不用”单点模拟”
一个诱人的简化是:既然 v 可以自由选,那就把 fall 分支当成一个固定位移。这是错的。
v' = clamp(v+g) 中的 v 是历史积累下来的,不是自由变量。如果你假设”v 总是某个典型值”,得出的安全带会覆盖不到极端情况(比如连续拍翅后立刻松手,v 从 -12 开始下落)。用区间 [vMin+g, vMax+g] 包住全部可能,虽然保守,但保证正确。
同样的道理,bird 半径不能省。把鸟当质点会让安全带变宽 2r,在 140px 的间隙上这直接导致明显的假安全率。
五、Java 完整实现
flappy-reachability/
├── src/main/java/com/example/flappy/
│ ├── HybridModel.java // 连续动力学与离散化参数
│ ├── Intervals.java // 区间算术(并/交/差/膨胀,全部向外取整)
│ ├── PipeSchedule.java // 管道序列(确定性种子)
│ ├── ReachabilitySet.java // 逆向可达集递推 + 时间剖面
│ ├── SafeController.java // 基于安全集的前瞻控制器
│ └── FlappyDemo.java // 主流程与统计
└── pom.xml
5.1 混合模型
package com.example.flappy;
/** 混合自动机的连续部分:y 位置、v 速度,按固定 dt 离散演化(y 向下为正) */
public class HybridModel {
public final double dt; // 每帧时长
public final double gravity; // 每帧加速度(向下为正)
public final double vJump; // 拍翅赋予的速度(负 = 向上)
public final double vMin, vMax;
public final double yMin, yMax;
public final double birdR; // 鸟的碰撞半径
public HybridModel() {
this.dt = 1.0;
this.gravity = 0.42;
this.vJump = -7.0;
this.vMin = -12.0;
this.vMax = 8.0;
this.yMin = 0.0;
this.yMax = 480.0;
this.birdR = 10.0;
}
/** 给定当前速度与控制,返回下一帧速度 */
public double nextVelocity(double v, boolean flap) {
double nv = flap ? vJump : v + gravity * dt;
return clamp(nv, vMin, vMax);
}
/** 该速度对应的位移量 */
public double deltaY(double v) { return v * dt; }
/** 鸟可活动的高度范围(保证整个碰撞包络都在屏幕内) */
public double bodyMin() { return yMin + birdR; }
public double bodyMax() { return yMax - birdR; }
public double clamp(double x, double lo, double hi) {
return Math.max(lo, Math.min(hi, x));
}
}
5.2 区间算术
package com.example.flappy;
import java.util.*;
/**
* 一维区间算术。所有运算按 EPS 向外膨胀,保证结果永远"过分保守":
* 宁可变宽,也不产生假安全。
*/
public class Intervals {
public static final double EPS = 0.5;
public static class Interval {
public final double lo, hi;
private Interval(double lo, double hi) { this.lo = lo; this.hi = hi; }
/** 工厂方法:向外膨胀 EPS(所有"算出来的"区间都用这个) */
public static Interval of(double lo, double hi) {
return new Interval(Math.min(lo, hi) - EPS, Math.max(lo, hi) + EPS);
}
/** 工厂方法:不膨胀(用于本身就确定的边界,如屏幕边界、管道边界) */
public static Interval exact(double lo, double hi) {
return new Interval(Math.min(lo, hi), Math.max(lo, hi));
}
public double width() { return hi - lo; }
/** 区间膨胀(Minkowski 和):[a,b] ⊕ [c,d] = [a+c, b+d] */
public Interval inflate(double c, double d) {
return Interval.of(lo + c, hi + d);
}
public Interval shift(double d) { return inflate(d, d); }
public Interval intersect(Interval o) {
double l = Math.max(lo, o.lo), h = Math.min(hi, o.hi);
return l <= h ? Interval.exact(l, h) : null;
}
@Override public String toString() {
return String.format("[%.1f,%.1f]", lo, hi);
}
}
/** 多段区间的并集:排序后合并相邻段 */
public static List<Interval> union(List<Interval> a, List<Interval> b) {
List<Interval> all = new ArrayList<>(a);
all.addAll(b);
all.removeIf(Objects::isNull);
all.sort(Comparator.comparingDouble(i -> i.lo));
List<Interval> merged = new ArrayList<>();
for (Interval i : all) {
if (merged.isEmpty()) { merged.add(i); continue; }
Interval last = merged.get(merged.size() - 1);
if (i.lo <= last.hi) {
merged.set(merged.size() - 1, Interval.exact(last.lo, Math.max(last.hi, i.hi)));
} else {
merged.add(i);
}
}
return merged;
}
/** 差集 a \ b,可能把一个段切成两段 */
public static List<Interval> difference(List<Interval> a, List<Interval> b) {
List<Interval> cur = new ArrayList<>(a);
for (Interval hole : b) {
List<Interval> next = new ArrayList<>();
for (Interval seg : cur) {
if (hole.hi <= seg.lo || hole.lo >= seg.hi) { next.add(seg); continue; }
if (hole.lo > seg.lo) next.add(Interval.exact(seg.lo, hole.lo));
if (hole.hi < seg.hi) next.add(Interval.exact(hole.hi, seg.hi));
}
cur = next;
}
return cur;
}
}
5.3 管道序列
package com.example.flappy;
import java.util.*;
/** 第 i 段间隙在 arriveAt(i) 到来,其下沿由种子决定的伪随机序列给出 */
public class PipeSchedule {
public final double gapEvery = 88.0; // 相邻管道间隔帧数
public final double gapHeight = 140.0; // 间隙高度
public final double pipeWidth = 60.0;
public final int count;
private final double[] gapLo;
private final double[] arrival;
public PipeSchedule(long seed, int count) {
this.count = count;
Random rnd = new Random(seed);
gapLo = new double[count];
arrival = new double[count];
for (int i = 0; i < count; i++) {
gapLo[i] = 60 + rnd.nextDouble() * 200; // 下沿在 [60, 260]
arrival[i] = 200 + i * gapEvery;
}
}
public double gapLow(int i) { return gapLo[i]; }
public double gapHigh(int i) { return gapLo[i] + gapHeight; }
public double arriveAt(int i) { return arrival[i]; }
/**
* 时刻 t 被管道占据的高度区间(按"鸟心位置不可取"来表达,已扣掉鸟半径)。
* 下侧管体:y ≤ gapLo - r;上侧管体:y ≥ gapHi + r。
*/
public List<Intervals.Interval> occupied(double t, HybridModel m) {
List<Intervals.Interval> occ = new ArrayList<>();
for (int i = 0; i < count; i++) {
double a = arrival[i], b = arrival[i] + pipeWidth;
if (t < a - m.birdR || t > b + m.birdR) continue; // 鸟未进入管道宽度
occ.add(Intervals.Interval.exact(Double.NEGATIVE_INFINITY, gapLo[i] - m.birdR));
occ.add(Intervals.Interval.exact(gapLo[i] + gapHeight + m.birdR, Double.POSITIVE_INFINITY));
}
return occ;
}
}
5.4 逆向可达集递推
package com.example.flappy;
import java.util.*;
/**
* 逆向可达性:
* SafeY(t) = ( shiftFlap(SafeY(t+1)) ∪ inflateFall(SafeY(t+1)) ) ∩ allowed(t)
* shiftFlap 对应"拍翅"分支的纯平移;
* inflateFall 对应"下落"分支的区间膨胀(v 可取值区间导致)。
*/
public class ReachabilitySet {
private final HybridModel m;
private final PipeSchedule pipes;
private final List<List<Intervals.Interval>> safeByFrame = new ArrayList<>();
private final int horizon;
public ReachabilitySet(HybridModel m, PipeSchedule pipes, int horizon) {
this.m = m;
this.pipes = pipes;
this.horizon = horizon;
compute();
}
private void compute() {
// 终局:无管道威胁,任何合法 y 都安全
List<Intervals.Interval> next = new ArrayList<>();
next.add(Intervals.Interval.exact(m.bodyMin(), m.bodyMax()));
for (int t = horizon; t >= 0; t--) {
// flap 分支:y' = y + vJump·dt => y = y' - vJump·dt
double flapShift = -m.deltaY(m.vJump);
List<Intervals.Interval> fromFlap = new ArrayList<>();
for (Intervals.Interval s : next) fromFlap.add(s.shift(flapShift));
// fall 分支:v' ∈ [vMin+g·dt, vMax+g·dt],y' = y + v'·dt
double vLo = m.clamp(m.vMin + m.gravity * m.dt, m.vMin, m.vMax);
double vHi = m.clamp(m.vMax + m.gravity * m.dt, m.vMin, m.vMax);
double dLo = m.deltaY(vLo), dHi = m.deltaY(vHi);
List<Intervals.Interval> fromFall = new ArrayList<>();
for (Intervals.Interval s : next) fromFall.add(s.inflate(-dHi, -dLo));
List<Intervals.Interval> merged = Intervals.union(fromFlap, fromFall);
// 与本帧可站区取交:全体合法高度 - 管道占位
List<Intervals.Interval> allowed = new ArrayList<>();
allowed.add(Intervals.Interval.exact(m.bodyMin(), m.bodyMax()));
allowed = Intervals.difference(allowed, pipes.occupied(t, m));
List<Intervals.Interval> safeT = new ArrayList<>();
for (Intervals.Interval a : allowed) {
for (Intervals.Interval s : merged) {
Intervals.Interval x = a.intersect(s);
if (x != null && x.hi > x.lo) safeT.add(x);
}
}
safeByFrame.add(0, Intervals.union(safeT, Collections.emptyList()));
next = safeByFrame.get(0);
}
}
public List<Intervals.Interval> safeAt(int t) {
if (t < 0 || t >= safeByFrame.size()) return Collections.emptyList();
return safeByFrame.get(t);
}
/** 整段碰撞包络是否都落在某段安全带内 */
public boolean isSafe(int t, double y) {
double lo = y - m.birdR, hi = y + m.birdR;
for (Intervals.Interval seg : safeAt(t)) {
if (seg.lo <= lo && seg.hi >= hi) return true;
}
return false;
}
/** 到安全带边界的余量:越大越安全 */
public double margin(int t, double y) {
double best = Double.NEGATIVE_INFINITY;
for (Intervals.Interval seg : safeAt(t)) {
if (y < seg.lo || y > seg.hi) continue;
best = Math.max(best, Math.min(y - seg.lo, seg.hi - y));
}
return best;
}
public void printProfile(int step) {
System.out.println("帧 安全高度带(width 表示带的总宽度)");
for (int t = 0; t <= horizon; t += step) {
List<Intervals.Interval> s = safeAt(t);
if (s.isEmpty()) { System.out.printf("%-5d (死区:无活路)%n", t); continue; }
StringBuilder sb = new StringBuilder();
double w = 0;
for (Intervals.Interval i : s) { sb.append(i).append(' '); w += i.width(); }
System.out.printf("%-5d %s 宽=%.1f%n", t, sb, w);
}
}
public int horizon() { return horizon; }
}
5.5 前瞻控制器
package com.example.flappy;
import java.util.*;
/**
* 基于安全集的控制器:
* 两个分支都逐个试算下一帧的 y,选"下一帧仍在安全带内、且余量更大"的那个;
* 若都安全,则偏向让速度更接近 0 的选择(避免极端速度积累)。
*/
public class SafeController {
private final HybridModel m;
private final ReachabilitySet reach;
public int deadZoneHits = 0;
public SafeController(HybridModel m, ReachabilitySet reach) {
this.m = m;
this.reach = reach;
}
public boolean decide(int t, double y, double v) {
boolean bestFlap = false;
double bestScore = Double.NEGATIVE_INFINITY;
boolean anyFeasible = false;
for (boolean flap : new boolean[]{true, false}) {
double nv = m.nextVelocity(v, flap);
double ny = y + m.deltaY(nv);
if (ny < m.bodyMin() || ny > m.bodyMax()) continue; // 直接撞地/出顶
if (!reach.isSafe(t + 1, ny)) continue; // 下一帧不在安全带内
anyFeasible = true;
// 评分:安全带余量 - 速度偏离惩罚(偏好温和的速度)
double score = reach.margin(t + 1, ny) - 0.5 * Math.abs(nv);
if (score > bestScore) { bestScore = score; bestFlap = flap; }
}
if (!anyFeasible) {
deadZoneHits++;
// 死区:退化为"离地面更近就拍翅"的兜底策略
return y > (m.yMin + m.yMax) / 2;
}
return bestFlap;
}
}
5.6 主流程
package com.example.flappy;
public class FlappyDemo {
public static void main(String[] args) {
HybridModel model = new HybridModel();
int horizon = 900;
int deaths = 0, clears = 0;
double totalAlive = 0;
for (long seed = 1; seed <= 20; seed++) {
PipeSchedule pipes = new PipeSchedule(seed, 10);
ReachabilitySet reach = new ReachabilitySet(model, pipes, horizon);
SafeController ctrl = new SafeController(model, reach);
double y = 240, v = 0;
int alive = 0;
boolean dead = false;
for (int t = 0; t < horizon; t++) {
boolean flap = ctrl.decide(t, y, v);
v = model.nextVelocity(v, flap);
y = y + model.deltaY(v);
if (y - model.birdR < model.yMin || y + model.birdR > model.yMax) { dead = true; break; }
if (collide(t, y, pipes, model)) { dead = true; break; }
alive++;
}
totalAlive += alive;
if (dead) deaths++; else clears++;
System.out.printf("seed=%2d 存活 %4d 帧 %s 死区触发 %d 次%n",
seed, alive, dead ? "✗ 坠毁" : "✓ 通关", ctrl.deadZoneHits);
}
System.out.printf("%n平均存活 %.1f 帧,通关 %d/20,坠毁 %d/20%n",
totalAlive / 20.0, clears, deaths);
// 打印一条安全带剖面
PipeSchedule p0 = new PipeSchedule(1, 10);
new ReachabilitySet(model, p0, 400).printProfile(40);
}
static boolean collide(int t, double y, PipeSchedule pipes, HybridModel m) {
for (int i = 0; i < pipes.count; i++) {
double a = pipes.arriveAt(i), b = a + pipes.pipeWidth;
if (t < a - m.birdR || t > b + m.birdR) continue;
if (y - m.birdR < pipes.gapLow(i)) return true; // 撞下侧管体
if (y + m.birdR > pipes.gapLow(i) + pipes.gapHeight) return true; // 撞上侧管体
}
return false;
}
}
六、区间算术 vs 位图:怎么选
| 维度 | 区间列表 | BitSet 位图 |
|---|---|---|
| 精度控制 | 需 EPS 向外膨胀,会保守地变宽 | 分辨率固定,无膨胀 |
| 单帧计算 | O(段数 × 段数),段数稀疏时很快 | O(高度格 / 64),位并行 |
| 表达多段 | 天然支持 | 天然支持 |
| 适用场景 | 连续动力学、管道稀疏 | 高度分辨率固定、需要极快迭代 |
实践中常做混合:先用区间列表粗筛(快速判定整帧死区),再用位图精算。本文为了讲清原理只用区间法。
七、为什么”逆向”比”正向”有用
很多教程教前向可达集:从当前状态往前推,算出”可能到达的所有状态”。这条路在 Flappy Bird 上会失败:
- 前向集合会随帧数指数增宽(每帧一个分支,集合宽度单调膨胀),几百帧后退化成整个屏幕。
- 即使不爆炸,它只告诉你”可能在哪”,不告诉你”哪里还有活路”。
逆向可达集的集合收敛而不发散:每往前推一步都是”并集再取交”,交集负责收缩。这是可达性分析在实时系统、自动驾驶安全验证里被广泛使用的根本原因。
八、三个工程坑
- EPS 必须向外。向内取整(
lo+EPS, hi-EPS)会漏掉真实安全状态,得出”这个状态必死”的假结论。安全分析里所有保守都必须朝”更大危害”方向。 - fall 分支必须用速度区间而非单点。因为 v 是历史积累的状态量,不是自由变量。本文用
[vMin+g, vMax+g]包住全部可能——保守但正确。 - 不要省掉鸟半径。把鸟当质点会让安全带变宽 2r,在 140px 间隙上直接带来明显的假安全率。
九、小结
Flappy Bird 看起来是个”手速游戏”,本质上是个混合系统的可达安全决策问题。用区间算术表达不确定性、用逆向可达集算出每帧的安全带、用前瞻回溯把安全带变成操控序列——这套流程不依赖训练和大量试错,结论带逻辑保证。想更进一步,可把本模型换成带守卫条件的显式混合自动机,接入 SpaceEx / Flow* 这类验证工具,就能处理更复杂的连续控制系统。