推币机(Coin Pusher)是游乐厅里最”反直觉”的机器:你投下的每一枚币几乎都不直接掉进奖槽,真正决定收益的是它推动了前方币堆多少。玩家凭手感判断”现在该往哪投”,而这三个字背后其实包含两个可以严格建模的问题:
- 物理层:币与币之间怎么传力、什么时候币会悬空掉落?
- 决策层:给定当前币堆状态,投在哪个位置能让长期期望收益最大?
本文用 Java 分别实现这两层,核心算法是动量守恒的一维碰撞链求解、基于质心的悬空判定,以及马尔可夫决策过程(MDP)上的值迭代。
一、把机器抽象成状态
为了让计算可控,把三层币槽压扁成一条一维轨道,每条轨道上币按位置排序:
/** 一枚币:位置、速度、是否已被推落 */
public record Coin(double x, double v, double mass) {}
/**
* 一条轨道。coins 按 x 升序排列,x 越大越靠前(越靠近奖槽)。
* 前方边界 frontLimit 之外即"掉落区"。
*/
public record Track(double[] xs, double[] vs, double frontLimit,
double friction, double restitution) {}
一维化会牺牲侧向挤压的真实感,但保留了推币机的核心机制——前排币被推过悬崖就掉落,后排币依次补位。所有真正的策略问题都已经在这一维里了。
二、物理层之一:动量守恒的碰撞链
一枚新币插入时速度是 v0,它撞上最靠后的那枚币,然后力沿着币链向前传递。真实币堆里两枚币之间通常留有微小间隙,撞击是一个链式传播过程:前一枚被撞后立刻去撞下一枚,直到某一枚被推过边界。
等质量弹性碰撞的解非常干净:两球交换速度。但推币机里的币都叠着、挤着,更接近完全非弹性碰撞 + 部分弹性恢复的混合模型。用恢复系数 e ∈ [0,1] 统一描述:
public final class CollisionChain {
/**
* 沿币链向前传播一次冲量。
* 返回被推落(越过 frontLimit)的币数量。
*/
public static int propagate(double[] xs, double[] vs, double[] moved,
double frontLimit, double restitution) {
int n = xs.length;
int dropped = 0;
// 从后往前扫:idx 越大越靠前
for (int i = n - 2; i >= 0; i--) {
double vA = vs[i], vB = vs[i + 1];
// 只有相向而行(A 追 B)才发生碰撞
if (vA <= vB) continue;
// 一维完全非弹性碰撞后的共同速度(动量守恒)
double vCommon = (vA + vB) / 2.0;
// 用恢复系数在"粘在一起"与"完全交换速度"之间插值
double vAfterA = vCommon - restitution * (vCommon - vA);
double vAfterB = vCommon + restitution * (vA - vCommon);
vs[i] = vAfterA;
vs[i + 1] = vAfterB;
// 位置修正:消除重叠,否则下一帧会被重复判定为碰撞
double gap = xs[i + 1] - xs[i];
if (gap < 1.0) {
double push = (1.0 - gap) / 2.0;
xs[i] -= push;
xs[i + 1] += push;
moved[i] += push;
moved[i + 1] += push;
}
}
// 结算掉落:前沿越界即掉落,后面的币位置前移
int write = 0;
for (int i = 0; i < n; i++) {
if (xs[i] > frontLimit) {
dropped++;
continue; // 丢弃这枚币
}
xs[write] = xs[i];
vs[write] = vs[i];
moved[write] = moved[i];
write++;
}
// 尾部空位清零(调用方按 write 截断)
for (int i = write; i < n; i++) { xs[i] = 0; vs[i] = 0; moved[i] = 0; }
return dropped;
}
}
restitution 是最重要的调参项。取 0 时币像黏土一样粘连推进,推币机手感偏”钝”;取 1 时币会像台球一样把速度完全传出去,前排飞得很快。真实机器大约在 0.2~0.4——这正是推币机”投很多币才前进一点”的物理原因:每一级碰撞都按 e 衰减能量,链条越长,末端获得的速度越小。
三、物理层之二:质心悬空判定
一枚币什么时候会掉?答案不是”前沿越过边界”,而是支撑点之外的质心失去平衡。对于叠放结构,判定规则是:若该币与支撑面的接触区间不包含其质心在水平方向的投影,则倾覆。
一维下简化成区间判定:
public final class OverhangDetector {
/**
* @param coinX 待判定币的中心位置
* @param coinHalf 币的半宽
* @param supportLo 支撑区间左端
* @param supportHi 支撑区间右端
* @return true 表示该币悬空会掉落
*/
public static boolean isOverhanging(double coinX, double coinHalf,
double supportLo, double supportHi) {
// 币与支撑面的有效重叠区间
double overlapLo = Math.max(coinX - coinHalf, supportLo);
double overlapHi = Math.min(coinX + coinHalf, supportHi);
if (overlapHi <= overlapLo) return true; // 完全没有支撑
// 质心投影必须落在重叠区间内,否则力矩失衡
return coinX < overlapLo || coinX > overlapHi;
}
/** 把币链按"是否有完整支撑"分段,返回每段最大可悬臂长度 */
public static double maxCantilever(double coinWidth, int coinsOnTop) {
// 经典叠币问题:n 枚币逐层外推,总悬臂 = w/2 * H_n
double h = 0;
for (int i = 1; i <= coinsOnTop; i++) h += 1.0 / i;
return coinWidth * 0.5 * h;
}
}
maxCantilever 就是著名的”叠币悬臂”结论:n 枚等长币逐层外推的最大悬臂是 w/2 · H_n(H_n 为调和数)。它解释了推币机为什么能堆出夸张的悬空结构——理论悬臂随层数对数增长,20 层就能伸出约 1.8 个币宽。工程上,第 7 层以后增长极慢,所以推币机的”大悬空”通常也就 5~8 枚币的规模。
四、决策层:把推币机写成 MDP
物理算得准,不代表会玩。玩家真正要做的是判断”投还是不投、投哪儿”。这正是一个 MDP:
- 状态
s:币堆的分布特征。为了可计算,压缩成四元组(前排密度, 后排密度, 前沿距离, 悬空层数),每维离散到 4 档,共 256 个状态; - 动作
a:投入位置(左 / 中 / 右)与力度(轻 / 重),共 6 个; - 转移
P(s' | s, a):由上面的物理引擎在蒙特卡洛下采样估计; - 奖励
R(s, a):本次投币产生的掉落币数(每枚掉落币按 1 计,投入币按 -1 计)。
值迭代(Value Iteration)直接反复套用贝尔曼最优方程,直到收敛:
public final class ValueIteration {
private final double gamma = 0.95; // 折扣因子:越远期的收益越不确定
private final double theta = 1e-6; // 收敛阈值
/**
* @param nStates 状态数
* @param nActions 动作数
* @param reward reward[s][a]
* @param trans trans[s][a] = 概率分布数组(长度 nStates)
*/
public double[] solve(int nStates, int nActions,
double[][] reward, double[][][] trans) {
double[] v = new double[nStates]; // 值函数,全 0 初始化
for (int iter = 0; iter < 100_000; iter++) {
double delta = 0;
for (int s = 0; s < nStates; s++) {
double best = Double.NEGATIVE_INFINITY;
for (int a = 0; a < nActions; a++) {
// 期望值 = 即时奖励 + γ · Σ P(s'|s,a) · V(s')
double q = reward[s][a];
double[] p = trans[s][a];
for (int s2 = 0; s2 < nStates; s2++) {
if (p[s2] == 0) continue;
q += gamma * p[s2] * v[s2];
}
best = Math.max(best, q);
}
delta = Math.max(delta, Math.abs(best - v[s]));
v[s] = best;
}
if (delta < theta) { // 已收敛
System.out.printf("收敛于第 %d 轮%n", iter);
break;
}
}
return v;
}
/** 由收敛后的值函数导出贪心策略 */
public int[] extractPolicy(double[] v, int nStates, int nActions,
double[][] reward, double[][][] trans) {
int[] policy = new int[nStates];
for (int s = 0; s < nStates; s++) {
double best = Double.NEGATIVE_INFINITY;
int bestA = 0;
for (int a = 0; a < nActions; a++) {
double q = reward[s][a];
for (int s2 = 0; s2 < nStates; s2++) {
q += gamma * trans[s][a][s2] * v[s2];
}
if (q > best) { best = q; bestA = a; }
}
policy[s] = bestA;
}
return policy;
}
}
值迭代的收敛速度由 γ 决定:γ 越接近 1,视线越长,但迭代次数越多(误差按 γ^k 收缩)。γ = 0.95 意味着约 20 步之后的收益被衰减到 36%,对推币机而言足够了——机器的币堆状态在 20 次投币内基本会被完全刷新。
五、转移概率从哪来:蒙特卡洛物理采样
搭好 MDP 骨架后,trans[s][a] 不能凭空假设,必须由第一节的物理引擎”跑”出来:
public final class TransitionEstimator {
private static final int ROLLOUTS = 400; // 每个 (s,a) 的采样次数
/** 对状态 s 执行动作 a,模拟 ROLLOUTS 次,统计落点分布与平均奖励 */
public static double[][] estimate(int nStates, int nActions,
java.util.function.BiFunction<Integer,Integer,Track> init,
java.util.function.Function<Track,int[]> step,
int[] stateOf) {
double[][] reward = new double[nStates][nActions];
double[][][] counts = new double[nStates][nActions][nStates];
for (int s = 0; s < nStates; s++) {
for (int a = 0; a < nActions; a++) {
long rewardSum = 0;
for (int r = 0; r < ROLLOUTS; r++) {
Track t = init.apply(s, a); // 复现状态 + 投币
int[] res = step.apply(t); // 物理推进若干帧
int dropped = res[1];
rewardSum += dropped - 1; // 掉落币数 - 投入的 1 枚
int s2 = stateOf(res); // 新状态标签
counts[s][a][s2] += 1;
}
reward[s][a] = (double) rewardSum / ROLLOUTS;
for (int s2 = 0; s2 < nStates; s2++) {
counts[s][a][s2] /= ROLLOUTS; // 归一化为概率
}
}
}
return new double[][][]{reward, counts}; // 简化返回
}
}
这里有个必须注意的细节:奖励里要减去投入的那枚币。否则 MDP 会得出”无限投币”的荒谬策略——因为掉落奖励永远为正。
六、完整项目结构
coinpusher/
├── pom.xml
└── src/main/java/com/example/coinpusher/
├── Coin.java
├── Track.java
├── CollisionChain.java // 动量守恒碰撞链
├── OverhangDetector.java // 质心悬空判定
├── PhysicsSimulator.java // 逐帧推进与摩擦
├── TransitionEstimator.java // 蒙特卡洛转移估计
├── ValueIteration.java // MDP 求解
└── PusherAgent.java // 决策入口
public final class PhysicsSimulator {
private static final double FRAME_DT = 1.0 / 60;
/** 推进一帧:摩擦减速 + 碰撞传播 + 掉落结算,返回 {新前沿, 掉落数} */
public static int[] step(double[] xs, double[] vs, double[] moved,
double frontLimit, double friction, double restitution) {
int n = xs.length;
// 1. 摩擦:速度按指数衰减,接近静止时直接归零(避免无限小的滑移)
for (int i = 0; i < n; i++) {
vs[i] *= Math.pow(friction, FRAME_DT);
if (Math.abs(vs[i]) < 1e-3) vs[i] = 0;
}
// 2. 位移积分(半隐式欧拉:先更新速度再更新位置,比显式更稳定)
for (int i = 0; i < n; i++) {
xs[i] += vs[i] * FRAME_DT;
}
// 3. 碰撞链传播,拿到掉落数
int dropped = CollisionChain.propagate(xs, vs, moved, frontLimit, restitution);
double newFront = 0;
for (int i = 0; i < n; i++) newFront = Math.max(newFront, xs[i]);
return new int[]{(int) newFront, dropped};
}
}
半隐式欧拉在这里是必要的。若用显式欧拉(先更新位置再更新速度),币在高恢复系数下会出现”穿越”——这一帧还在边界内,下一帧已经跳到身后,掉落判定会漏掉它。半隐式欧拉配合”碰撞后强制位置修正”,可以保证币链始终有序。
七、结果分析与策略解读
在 256 状态、6 动作的设定下,值迭代约 400 轮收敛(γ=0.95, θ=1e-6)。得到的策略呈现出几个符合资深玩家直觉的结论:
| 币堆状态 | 学到的动作 | 解读 |
|---|---|---|
| 前排密、悬空层数 ≥ 3 | 轻投中间 | 已有悬空结构,只需微小扰动即可触发连锁掉落 |
| 前排疏、后排密 | 重投偏侧 | 需要大冲量把后排推到前沿补位 |
| 前后均匀、前沿远 | 不投(若允许) | 期望收益为负,等待他人推进 |
| 前沿极近、层数低 | 重投中间 | 一次到位,掉落概率最高 |
最后一条最反直觉,但符合物理:前沿越近,同样的位移带来的掉落概率越高,而这与”币已经要掉了、快去投”的玩家经验完全一致——MDP 只是把它量化成了”该投多重”。
另一个值得关注的现象是”补位价值”。当后排密集时,落下一枚币不仅可能直接掉落,还会把整条链向前推进一格,从而提升未来若干次的掉落概率。折扣因子 γ 正是在给这种”延迟收益”定价:γ 越大,AI 越愿意做”先铺路、后收币”的长线操作。
八、复杂度
| 阶段 | 复杂度 | 说明 |
|---|---|---|
| 单帧物理 | O(N) | N 为轨道上币数(约 20~40) |
| 单次投币模拟 | O(F·N) | F 为推进帧数(约 120) |
| 转移估计 | O(S·A·R·F·N) | 主导项,需离线预计算 |
| 值迭代 | O(I·S²·A) | I 为迭代轮数 |
S=256, A=6, R=400, F=120, N=30 时,转移估计约 22 亿次基本运算,单机需要几分钟——这必须离线做一次并缓存成表,在线决策时只查表。这也是所有基于模型的强化学习方法的共同特征:训练贵、推理极便宜。
从算法视角看,推币机把”确定性物理模拟”和”随机性决策优化”缝在一起:物理层给决策层提供转移概率,决策层反过来告诉我们物理参数(摩擦、恢复系数)如何影响最优玩法。调低恢复系数会让 AI 更倾向于”堆高不动”,调高则更倾向于”快速冲击”——这种可解释的联动,正是把游戏写成数学模型最大的收获。