引言
打地鼠是一款考验反应速度与决策的经典街机游戏。地鼠随机从洞中探出,玩家需在有限时间内击打尽可能多的地鼠。本文将用Java实现一个智能化的打地鼠AI,核心在于将地鼠出现序列建模为带时间窗口的序列决策问题,使用动态规划求解最优击打序列,并结合贪心策略处理实时约束。
问题建模
假设游戏区域有 $n$ 个地鼠洞,地鼠按时间戳出现,每个地鼠具有:
– 出现位置 $(x, y)$
– 出现时间 $t_{appear}$
– 停留时长 $duration$
– 分值 $score$
玩家移动锤子需要时间,锤子在时间 $t$ 可以击打位置 $(x, y)$ 的地鼠,当且仅当 $t_{appear} \le t \le t_{appear} + duration$。
目标:在总时间 $T$ 内,选择一组可执行的击打动作,使得总分最大。
动态规划最优解
将每个地鼠视为一个”任务”,其时间窗口为 $[t_{appear}, t_{appear} + duration]$。假设玩家在地鼠 $i$ 和地鼠 $j$ 之间移动需要耗时 $dist(i, j) / v$,其中 $v$ 为移动速度。
定义 $dp[i]$ 为以击打地鼠 $i$ 作为最后一个动作时能获得的最大分数。状态转移:
$$
dp[i] = score_i + \max_{j: t_j + move(j,i) \le t_i^{deadline}} { dp[j] }
$$
若无可行前驱,则 $dp[i] = score_i$。最终答案为 $\max_i dp[i]$。
Java实现
地鼠实体类
public class Mole {
int id;
int x, y; // 位置坐标
int appearTime; // 出现时间(毫秒)
int duration; // 停留时长
int score; // 击打得分
int deadline; // 最晚可击打时间 = appearTime + duration
public Mole(int id, int x, int y, int appearTime, int duration, int score) {
this.id = id;
this.x = x;
this.y = y;
this.appearTime = appearTime;
this.duration = duration;
this.score = score;
this.deadline = appearTime + duration;
}
// 计算移动耗时(毫秒),假设速度为每单位距离1ms
public int moveTimeTo(Mole other) {
return Math.abs(this.x - other.x) + Math.abs(this.y - other.y);
}
@Override
public String toString() {
return String.format("Mole[%d]@(%d,%d) t=%d~%d score=%d", id, x, y, appearTime, deadline, score);
}
}
动态规划求解器
import java.util.*;
public class MoleDPStrategy {
private List<Mole> moles;
private int startX, startY; // 锤子初始位置
public MoleDPStrategy(List<Mole> moles, int startX, int startY) {
this.moles = new ArrayList<>(moles);
this.startX = startX;
this.startY = startY;
// 按最晚击打时间排序
this.moles.sort(Comparator.comparingInt(m -> m.deadline));
}
/**
* 动态规划求解最优击打序列
* dp[i] = 以第i个地鼠结尾的最大得分
*/
public Result solve() {
int n = moles.size();
if (n == 0) return new Result(0, new ArrayList<>());
double[] dp = new double[n];
int[] prev = new int[n];
Arrays.fill(prev, -1);
for (int i = 0; i < n; i++) {
Mole curr = moles.get(i);
// 从起点直接击打
int distFromStart = Math.abs(curr.x - startX) + Math.abs(curr.y - startY);
if (distFromStart <= curr.deadline) {
dp[i] = curr.score;
} else {
dp[i] = Double.NEGATIVE_INFINITY;
}
// 尝试从之前的某个地鼠转移过来
for (int j = 0; j < i; j++) {
Mole prevMole = moles.get(j);
int moveTime = prevMole.moveTimeTo(curr);
int arrivalTime = prevMole.appearTime + moveTime; // 简化为从出现时刻出发
// 更精确:需要知道击打j的实际时间,这里假设在deadline前到达j并立即出发
int departureTime = Math.min(prevMole.deadline, prevMole.appearTime + 100); // 简化为出现后100ms击打
arrivalTime = departureTime + moveTime;
if (arrivalTime <= curr.deadline && dp[j] > Double.NEGATIVE_INFINITY) {
if (dp[j] + curr.score > dp[i]) {
dp[i] = dp[j] + curr.score;
prev[i] = j;
}
}
}
}
// 找最大值
double maxScore = Double.NEGATIVE_INFINITY;
int bestIdx = -1;
for (int i = 0; i < n; i++) {
if (dp[i] > maxScore) {
maxScore = dp[i];
bestIdx = i;
}
}
// 回溯路径
List<Mole> sequence = new ArrayList<>();
int idx = bestIdx;
while (idx != -1) {
sequence.add(moles.get(idx));
idx = prev[idx];
}
Collections.reverse(sequence);
return new Result((int) maxScore, sequence);
}
/**
* 贪心策略:每次击打当前可到达且得分效率最高的地鼠
*/
public Result greedySolve() {
List<Mole> remaining = new ArrayList<>(moles);
List<Mole> sequence = new ArrayList<>();
int currX = startX, currY = startY;
int currTime = 0;
int totalScore = 0;
while (!remaining.isEmpty()) {
Mole best = null;
double bestEfficiency = Double.NEGATIVE_INFINITY;
for (Mole m : remaining) {
int dist = Math.abs(m.x - currX) + Math.abs(m.y - currY);
int arrival = currTime + dist;
if (arrival <= m.deadline) {
// 效率 = 得分 / (移动时间 + 等待时间)
double efficiency = (double) m.score / (dist + Math.max(0, m.appearTime - currTime) + 1);
if (efficiency > bestEfficiency) {
bestEfficiency = efficiency;
best = m;
}
}
}
if (best == null) break;
int dist = Math.abs(best.x - currX) + Math.abs(best.y - currY);
currTime = Math.max(currTime + dist, best.appearTime);
currX = best.x;
currY = best.y;
totalScore += best.score;
sequence.add(best);
remaining.remove(best);
}
return new Result(totalScore, sequence);
}
public static class Result {
public int totalScore;
public List<Mole> sequence;
public Result(int totalScore, List<Mole> sequence) {
this.totalScore = totalScore;
this.sequence = sequence;
}
}
}
游戏引擎与可视化
public class WhackAMoleGame {
private static final int GRID_SIZE = 5;
private static final int GAME_TIME = 30000; // 30秒
private List<Mole> moles;
private Random random;
public WhackAMoleGame() {
this.moles = new ArrayList<>();
this.random = new Random();
}
/**
* 生成随机地鼠序列
*/
public void generateMoles(int count) {
moles.clear();
for (int i = 0; i < count; i++) {
int x = random.nextInt(GRID_SIZE);
int y = random.nextInt(GRID_SIZE);
int appear = random.nextInt(GAME_TIME - 2000);
int duration = 1000 + random.nextInt(2000);
int score = 10 + random.nextInt(50);
moles.add(new Mole(i, x, y, appear, duration, score));
}
}
public void runAIComparison() {
generateMoles(20);
System.out.println("=== 地鼠序列 ===");
for (Mole m : moles) {
System.out.println(m);
}
// DP策略
MoleDPStrategy dpStrategy = new MoleDPStrategy(moles, 0, 0);
MoleDPStrategy.Result dpResult = dpStrategy.solve();
System.out.println("\n=== DP最优策略 ===");
System.out.println("总得分: " + dpResult.totalScore);
System.out.println("击打序列:");
for (Mole m : dpResult.sequence) {
System.out.println(" " + m);
}
// 贪心策略
MoleDPStrategy.Result greedyResult = dpStrategy.greedySolve();
System.out.println("\n=== 贪心策略 ===");
System.out.println("总得分: " + greedyResult.totalScore);
System.out.println("击打序列:");
for (Mole m : greedyResult.sequence) {
System.out.println(" " + m);
}
System.out.println("\n得分提升: " + (dpResult.totalScore - greedyResult.totalScore));
}
public static void main(String[] args) {
WhackAMoleGame game = new WhackAMoleGame();
game.runAIComparison();
}
}
带时间精度的状态DP(进阶)
public class MoleStateDP {
/**
* 状态压缩DP:将时间离散化为事件点
* 对于小规模地鼠(n <= 15),可用位掩码表示已击打集合
*/
public static Result bitmaskDP(List<Mole> moles, int startX, int startY) {
int n = moles.size();
if (n > 15) throw new IllegalArgumentException("地鼠数量过多,不适合状态压缩");
double[] dp = new double[1 << n];
int[] lastPos = new int[1 << n]; // 记录最后击打的地鼠
Arrays.fill(lastPos, -1);
// 预处理:计算从起点到每个地鼠的时间
int[] startTime = new int[n];
for (int i = 0; i < n; i++) {
Mole m = moles.get(i);
startTime[i] = Math.abs(m.x - startX) + Math.abs(m.y - startY);
}
double bestScore = 0;
int bestMask = 0;
for (int mask = 0; mask < (1 << n); mask++) {
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) != 0) continue;
int prevMask = mask;
int prevIdx = lastPos[prevMask];
int arrivalTime;
if (prevIdx == -1) {
arrivalTime = startTime[i];
} else {
Mole prevMole = moles.get(prevIdx);
arrivalTime = /* 需要记录实际击打时间,此处简化 */ 0;
}
// 简化为检查可行性并更新得分
// 实际实现需要维护每个状态的当前时间和位置
}
}
return new Result((int) bestScore, new ArrayList<>());
}
}
复杂度分析
- 动态规划:时间 $O(n^2)$,空间 $O(n)$,其中 $n$ 为地鼠数量。需要对地鼠按截止时间排序。
- 状态压缩DP:时间 $O(n \cdot 2^n)$,适用于 $n \le 20$ 的小规模场景,可求得全局最优。
- 贪心策略:时间 $O(n^2)$,空间 $O(n)$,不保证最优但实时性更好。
总结
本文将打地鼠游戏建模为带时间窗口的序列决策问题,通过动态规划求解最优击打序列。DP策略在离线场景下能求得最优解,而贪心策略更适合在线实时决策。两种策略的结合可以在实际游戏中兼顾最优性与响应速度。