引言
打地鼠(Whac-A-Mole)看似简单——地鼠随机冒出,玩家快速敲击——但其背后隐藏着丰富的概率建模与决策优化问题。本文从泊松过程(Poisson Process)出发,对地鼠的出现规律进行数学建模,并推导在有限反应时间下的最优敲击策略。读者将看到,即使是这款童年游戏,也能成为理解随机过程与最优停时理论的绝佳载体。
核心算法思想
1. 泊松过程建模
假设每个洞口独立地以泊松过程产生地鼠,速率为 $\lambda$(单位时间内平均冒出次数)。那么在时间区间 $[0, t]$ 内,该洞口冒出 $k$ 只地鼠的概率为:
$$P(N(t)=k) = \frac{(\lambda t)^k e^{-\lambda t}}{k!}$$
相邻两只地鼠的等待时间服从指数分布:$T \sim Exp(\lambda)$,均值为 $1/\lambda$。
2. 反应窗口与命中概率
玩家从看到地鼠到挥锤命中存在一个反应时间 $R$(通常为 200-400ms)。地鼠在洞口停留的时间为 $S$(通常 1-2 秒)。只有当玩家在地鼠消失前完成反应并命中,才算得分。因此,对于一只在时刻 $t_0$ 出现的地鼠,玩家的有效命中窗口为 $[t_0, t_0 + S]$,而玩家的反应+动作耗时为 $R$。
3. 最优反应窗口决策
当多个洞口同时有地鼠时,玩家面临选择。最优策略基于以下价值函数:
$$V_i = p_i \cdot (score_i + futureValue_i) – c$$
其中 $p_i$ 为命中洞口 $i$ 的概率(取决于距离、地鼠剩余停留时间),$c$ 为切换目标的认知代价。贪心策略选择当前 $V_i$ 最大的洞口;更高级的策略则通过动态规划计算多步期望收益。
4. 反应时间分布拟合
通过记录大量玩家的反应时间数据,可以用对数正态分布或伽马分布拟合:
$$R \sim LogNormal(\mu, \sigma^2)$$
据此可以计算不同难度下的预期命中率,并动态调整地鼠出现速率 $\lambda$ 以维持恒定的挑战水平(动态难度调整,DDA)。
Java 实现
import java.util.*;
/**
* 打地鼠 AI/模拟器 — 泊松过程建模 + 最优反应窗口决策
* 功能:
* 1. 按泊松过程生成地鼠出现事件
* 2. 模拟玩家反应时间分布
* 3. 计算最优敲击顺序(贪心/动态规划)
* 4. 动态难度调整(DDA)
*/
public class WhacAMoleSimulator {
// 游戏配置
private final int rows;
private final int cols;
private final double baseLambda; // 基础泊松速率(每秒每只地鼠)
private final double stayTime; // 地鼠停留时间(秒)
private final Random rand;
// 玩家模型
private final double reactionMean; // 平均反应时间(秒)
private final double reactionStd; // 反应时间标准差
// 动态难度
private double currentLambda;
private double targetHitRate = 0.75; // 目标命中率
public WhacAMoleSimulator(int rows, int cols, double lambda, double stay,
double reactMean, double reactStd) {
this.rows = rows;
this.cols = cols;
this.baseLambda = lambda;
this.stayTime = stay;
this.reactionMean = reactMean;
this.reactionStd = reactStd;
this.currentLambda = lambda;
this.rand = new Random();
}
/**
* 泊松过程生成:在时间段 [0, duration] 内生成所有地鼠出现事件
* 使用指数分布采样等待时间
*/
public List<MoleEvent> generateEvents(double duration) {
List<MoleEvent> events = new ArrayList<>();
int holes = rows * cols;
for (int h = 0; h < holes; h++) {
double t = 0;
while (t < duration) {
// 指数分布:等待时间 ~ Exp(lambda)
double wait = -Math.log(1 - rand.nextDouble()) / currentLambda;
t += wait;
if (t < duration) {
events.add(new MoleEvent(h / cols, h % cols, t));
}
}
}
events.sort(Comparator.comparingDouble(e -> e.appearTime));
return events;
}
/**
* 模拟玩家反应时间:从对数正态分布采样
*/
public double sampleReactionTime() {
// 对数正态分布参数转换
double sigma2 = Math.log(1 + (reactionStd * reactionStd) / (reactionMean * reactionMean));
double mu = Math.log(reactionMean) - sigma2 / 2;
double z = rand.nextGaussian();
return Math.exp(mu + Math.sqrt(sigma2) * z);
}
/**
* 贪心最优策略:在每一时刻选择预期收益最高的地鼠
* @param events 所有地鼠出现事件
* @return 实际命中事件列表
*/
public List<MoleEvent> simulateGreedyPlayer(List<MoleEvent> events) {
List<MoleEvent> hits = new ArrayList<>();
double currentTime = 0;
// 活跃地鼠:已出现且未消失
PriorityQueue<MoleEvent> active = new PriorityQueue<>(
Comparator.comparingDouble(e -> e.deadline)
);
int idx = 0;
while (idx < events.size() || !active.isEmpty()) {
// 加入新出现的地鼠
while (idx < events.size() && events.get(idx).appearTime <= currentTime) {
MoleEvent e = events.get(idx);
e.deadline = e.appearTime + stayTime;
active.offer(e);
idx++;
}
if (active.isEmpty()) {
// 跳到下一个事件
currentTime = events.get(idx).appearTime;
continue;
}
// 贪心选择:找价值最高的活跃地鼠
MoleEvent best = selectBest(active, currentTime);
if (best == null) {
// 所有地鼠都来不及打了,跳到下一个
currentTime = events.get(idx).appearTime;
active.removeIf(e -> e.deadline < currentTime);
continue;
}
double react = sampleReactionTime();
double hitTime = currentTime + react;
if (hitTime <= best.deadline) {
best.hitTime = hitTime;
best.hit = true;
hits.add(best);
currentTime = hitTime;
} else {
// 反应太慢, missed
best.missed = true;
}
active.remove(best);
// 移除已超时的地鼠
active.removeIf(e -> e.deadline < currentTime);
}
return hits;
}
/**
* 选择当前最优地鼠
* 价值 = 基础分 / (距离惩罚 + 剩余时间倒数)
* 距离惩罚:玩家当前位置到地鼠洞口的欧氏距离对应的移动时间
*/
private MoleEvent selectBest(PriorityQueue<MoleEvent> active, double now) {
MoleEvent best = null;
double bestValue = -1;
// 假设玩家当前在中心位置 (rows/2, cols/2)
double pr = rows / 2.0, pc = cols / 2.0;
for (MoleEvent e : active) {
double remaining = e.deadline - now;
if (remaining <= 0) continue;
double dist = Math.sqrt((e.r - pr) * (e.r - pr) + (e.c - pc) * (e.c - pc));
double moveTime = dist * 0.1; // 每格移动耗时 0.1s
double value = 1.0 / (moveTime + 0.5 / remaining + 0.1);
if (value > bestValue) {
bestValue = value;
best = e;
pr = e.r; pc = e.c; // 更新假设位置(贪心移动)
}
}
return best;
}
/**
* 动态难度调整(DDA)
* 根据最近 N 局的命中率调整 lambda
*/
public void adjustDifficulty(List<Double> recentHitRates) {
if (recentHitRates.isEmpty()) return;
double avgHit = recentHitRates.stream().mapToDouble(Double::doubleValue).average().orElse(0);
double error = avgHit - targetHitRate;
// PID 控制器简化版:比例调整
double kp = 0.5;
currentLambda = baseLambda * (1 - kp * error);
currentLambda = Math.max(0.1, Math.min(currentLambda, baseLambda * 3));
}
/**
* 计算泊松过程的理论命中率上界
* 使用 Erlang B 公式思想近似
*/
public double theoreticalMaxHitRate(List<MoleEvent> events, double duration) {
int totalMoles = events.size();
if (totalMoles == 0) return 0;
double avgReact = reactionMean;
// 理论上限:总有效窗口时间 / 总反应耗时
double totalWindow = totalMoles * stayTime;
double totalReact = totalMoles * avgReact;
return Math.min(1.0, totalWindow / (totalReact + totalWindow * 0.5));
}
// ============== 地鼠事件类 ==============
public static class MoleEvent {
int r, c; // 洞口坐标
double appearTime; // 出现时刻
double deadline; // 消失时刻
double hitTime; // 命中时刻
boolean hit; // 是否命中
boolean missed; // 是否错过
MoleEvent(int r, int c, double t) {
this.r = r; this.c = c; this.appearTime = t;
}
}
// ============== 演示入口 ==============
public static void main(String[] args) {
// 3x3 洞口,基础速率 0.5/s,停留 1.5s,反应均值 0.3s,标准差 0.1s
WhacAMoleSimulator sim = new WhacAMoleSimulator(3, 3, 0.5, 1.5, 0.3, 0.1);
double duration = 30.0; // 30秒游戏
List<MoleEvent> events = sim.generateEvents(duration);
System.out.println("总地鼠数:" + events.size());
List<MoleEvent> hits = sim.simulateGreedyPlayer(events);
long hitCount = hits.stream().filter(e -> e.hit).count();
long missedCount = events.size() - hitCount;
System.out.println("命中:" + hitCount + ",错过:" + missedCount);
System.out.println("命中率:" + String.format("%.2f%%", 100.0 * hitCount / events.size()));
System.out.println("理论命中率上界:" + String.format("%.2f%%",
100.0 * sim.theoreticalMaxHitRate(events, duration)));
// DDA 演示
List<Double> recent = new ArrayList<>();
for (int i = 0; i < 5; i++) {
List<MoleEvent> ev = sim.generateEvents(duration);
List<MoleEvent> hi = sim.simulateGreedyPlayer(ev);
double rate = hi.size() / (double) ev.size();
recent.add(rate);
}
sim.adjustDifficulty(recent);
System.out.println("调整后的 lambda:" + String.format("%.3f", sim.currentLambda));
}
}
复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 泊松事件生成 | $O(H \cdot \lambda \cdot T)$ | $O(H \cdot \lambda \cdot T)$ |
| 贪心模拟 | $O(E \log E)$ | $O(E)$ |
| 动态难度调整 | $O(N)$ | $O(N)$ |
其中 $H$ 为洞口数,$T$ 为游戏时长,$E$ 为事件总数,$N$ 为历史局数。
总结
泊松过程为打地鼠的地鼠出现规律提供了严谨的数学描述,指数分布的等待时间让游戏节奏天然具有不可预测性。贪心最优策略在每个决策点最大化即时期望收益,而动态难度调整则确保了玩家始终处于”心流通道”——既不会因过于简单而厌倦,也不会因过于困难而挫败。读者可以在此基础上继续扩展:引入多玩家竞争模型、设计基于强化学习的自适应 AI 对手、或将泊松过程升级为非齐次泊松过程(Non-homogeneous Poisson Process),让地鼠出现速率随时间动态变化,制造更丰富的波峰波谷体验。