这是一篇练手项目的实盘记录。花了一周多的时间,把小时候在红白机上玩得停不下来的坦克大战,用纯 Java 从零写到了 v3.0。期间踩了无数坑,也把集合框架、多线程、GUI 事件分发这些以前觉得“会了”的东西重新啃了一遍。这篇文章把整个项目的设计思路、关键代码、调优过程和最终遇到的问题都整理出来,给同样想用 Java 练手仿写经典游戏的朋友一个参考。
1. 内容整体设计与思路拆解
1.1 为什么选择坦克大战作为 Java 练手项目
很多 Java 学习者走到某个阶段都会陷入同一个困境:语法看完了,教程跟完了,但真让自己独立写点东西,打开 IDE 却不知道该从哪一行代码开始。我之前就处于这个状态。HashMap、Lambda、Stream 这些 API 背得滚瓜烂熟,但拿一个完整需求过来,脑子里全是碎片。
这种情况下,找一个“麻雀虽小五脏俱全”的项目去练,比继续刷教程要有效得多。坦克大战就是一个非常合适的对象。它规则透明到不用读说明文档就能玩:玩家控制我方坦克,消灭敌方坦克,守住基地。但真正实现起来,你需要用到 Java 里的这些核心能力:
- 面向对象设计:坦克、子弹、墙体、爆炸特效、道具,天然适合用类层次结构去建模。
- GUI 编程:Swing/AWT 的窗口绘制、事件监听、双缓冲绘制。
- 多线程:游戏主循环、敌方 AI 定时行动、子弹飞行、爆炸动画,全都是并发的。
- 集合框架:存放游戏内所有对象的容器管理,涉及遍历时的性能问题。
- 数学与坐标运算:方向向量、碰撞检测、边界处理。
一个项目能把 Java 学习路线中最容易被“纸面化”的部分全部串起来。做完之后你会发现,再去看那些 Java 面试八股文里关于集合、多线程、JVM 相关的话题,讨论的角度完全不一样了,因为你是真遇到过问题才去查的资料,而不是死记硬背。
1.2 v3.0 的核心升级目标
坦克大战这个项目我从 v1.0 一路重构到 v3.0。如果只是画几个方块在屏幕上移动,那这个项目不到一天就能写完,但那样做没有任何学习价值,充其量是照着教程敲了一遍。v3.0 我给自己定了几个明确的目标,每一项都对应一个或几个 Java 技术难点:
| 版本 | 核心功能 | 技术侧重点 |
|---|---|---|
| v1.0 | 画出坦克并移动、发射子弹 | 基础 Swing 绘制、键盘监听 |
| v2.0 | 加入墙体、敌方坦克、碰撞检测 | 面向对象重构、矩形碰撞算法 |
| v3.0 | 多线程任务调度、关卡系统、游戏状态管理、存档与音效 | 并发模型、状态机、IO 数据持久化 |
v3.0 要解决的核心痛点有三个。一是游戏运行时的统一调度问题——子弹要飞、坦克要动、敌方 AI 要决策、爆炸动画要播放,如果全部在同一个线程里顺序执行,逻辑会极其混乱;二是线程安全问题——多个线程同时修改游戏对象集合,稍不注意就抛 ConcurrentModificationException;三是代码结构问题——前两个版本里的“上帝类”越来越大,每一步修改都胆战心惊,必须彻底重构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 游戏对象的抽象类设计
坦克大战里所有出现在屏幕上的东西,都有一个共同特点:有坐标、有大小、可以被绘制出来、有些还能移动。如果为每一个对象单独写一组坐标和绘制代码,不仅重复,而且后期加新物体(比如道具、新的敌方坦克种类)时要改一堆地方。
所以我设计了一个 GameObject 抽象类作为所有游戏对象的基类:
java复制public abstract class GameObject {
protected int x;
protected int y;
protected int width;
protected int height;
protected Direction direction;
protected boolean alive = true;
protected Image image;
public GameObject(int x, int y, int width, int height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public abstract void update();
public void draw(Graphics g) {
if (image != null) {
g.drawImage(image, x, y, null);
}
}
public Rectangle getBounds() {
return new Rectangle(x, y, width, height);
}
public boolean isAlive() {
return alive;
}
}
Tank 继承 GameObject,增加血量、速度、方向、阵营这些坦克特有属性。Bullet、Wall、Explosion 也各继承自 GameObject。这样设计的好处是,游戏中所有对象的统一管理变得异常简单。游戏主循环只需要维护一个 List<GameObject>,每一帧通过多态调用所有对象的 update() 和 draw() 就行。
在实际编码中要特别注意坐标的基准点选择。我最初写的时候把 x、y 当成坦克左上角坐标,后来计算子弹生成位置、碰撞边界时总觉得别扭。后来统一改成中心点坐标,并在 draw() 里绘制时换算成左上角,这样旋转方向、计算相对位置都直观很多。这个细节看着不起眼,但到后续做方向朝向、双人模式时,中心点坐标系能省下大量心智负担。
2.2 碰撞检测的矩形相交算法
游戏开发绕不开碰撞检测。完整的大厂级碰撞检测方案可以很复杂,但坦克大战这个项目里所有对象都是轴对齐的矩形,用最简单的矩形相交判定就完全足够。Java 标准库的 Rectangle 类直接提供了 intersects() 方法,可以拿来就用。
最开始我是这么写的:每个对象每帧都和场景里的其他对象做一次 getBounds().intersects() 判断。坦克大战的经典地图是 13×13 的格子布局,每个格子里可能是空地、砖墙、钢墙、河流等。所有物体加起来最多几十个,两两相交判断的时间复杂度是 O(n²),在这么小的规模下完全没有性能压力,而且代码写起来非常直观。
但这里有一个关键细节:碰撞后如何处理位置。如果只顾检测碰撞然后置为死亡,会出现坦克“陷进墙里”或子弹“穿墙”的现象。原因是对象在上一帧的位置和这一帧的位置之间有一个运动跨度,如果移动速度太快,可能直接跨过了障碍物的矩形。
我的处理方式是把移动拆成 X 轴和 Y 轴分别计算。比如坦克同时按下了上和左,先尝试在 Y 方向移动,如果新位置和墙体相交,就回退到移动前的位置,并把 y 设置为墙体边缘紧贴的值;同理再处理 X 方向。这种“分轴处理”的策略看着朴素,但在 2D 格子地图里非常管用,手感上也比整体位置计算后直接回退要顺滑得多。
地图上的墙体用二维数组存储,每个格子有固定的尺寸。判断坦克当前是否与墙体相交时,不需要遍历所有墙体对象,只需要根据坦克占用了哪几个格子去查二维数组里那几个格子是否有墙体即可。这是一个非常重要的性能优化思路——碰撞检测有时并不是算法问题,而是数据结构选型问题。
2.3 地图系统与游戏状态机
地图是整个游戏的地基。经典坦克大战地图是 13×13 的格子,每个格子有对应的地形类型。我用了 int[][] 二维数组来存储地图数据,0 表示空地,1 表示砖墙,2 表示钢墙,3 表示河流,4 表示草丛,5 表示基地(老巢)。
java复制public class Map {
public static final int ROWS = 13;
public static final int COLS = 13;
public static final int CELL_SIZE = 32;
private final int[][] grid = new int[ROWS][COLS];
public Map(int level) {
loadLevel(level);
}
private void loadLevel(int level) {
// 从关卡配置文件中读取二维数组并填充到 grid
}
public int getCell(int row, int col) {
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) {
return -1;
}
return grid[row][col];
}
public void setCell(int row, int col, int type) {
grid[row][col] = type;
}
}
用二维数组存储地图最直观的价值是“数据和渲染分离”。你要调整某个关卡的布局,改配置文件里的二维数组就行,游戏代码完全不用动。我做了一个简单的关卡文件,用数字组成的 13 行文本表示地图,加载时逐行解析。
游戏状态管理是 v3.0 才真正做对的。之前版本的逻辑是各种 boolean 标志满天飞,比如 isRunning、isPaused、isGameOver,几个标志之间还能互相打架。v3.0 我用了一个枚举状态机:
java复制public enum GameState {
MENU, // 主菜单
PLAYING, // 游戏中
PAUSED, // 暂停
GAME_OVER, // 游戏结束
LEVEL_CLEAR // 关卡通过
}
游戏主循环只根据当前状态执行对应的逻辑。比如在 PAUSED 状态下,不更新任何游戏对象,但依然重绘画面(因为要显示“暂停”两个字);在 GAME_OVER 状态下,停止敌方 AI 线程,等待玩家按键返回主菜单。状态机的引入让主循环的逻辑变得非常清爽,也不会出现“游戏已经结束但子弹还在飞”这种逻辑漏洞。
3. 实操过程与核心环节实现
3.1 玩家坦克的控制与基于集合的按键监听实现
玩家操控是游戏的灵魂。Swing 里监听键盘主要靠 KeyListener 接口,但直接用它在游戏循环里有坑。keyPressed 和 keyReleased 是由事件分发线程(EDT)回调的,如果你的游戏逻辑在另一个线程里跑,就必须想办法把按键状态安全地传递过去。
我的方案是维护一个 Set<Integer> 存储当前所有被按下的键码。keyPressed 时往集合里添加键码,keyReleased 时移除。游戏主循环里只需要查询集合里是否包含某个键码,就可以判断当前是否应该向左移动或向右旋转。
java复制public class Keyboard implements KeyListener {
private final Set<Integer> pressedKeys = ConcurrentHashMap.newKeySet();
@Override
public void keyPressed(KeyEvent e) {
pressedKeys.add(e.getKeyCode());
}
@Override
public void keyReleased(KeyEvent e) {
pressedKeys.remove(e.getKeyCode());
}
@Override
public void keyTyped(KeyEvent e) {
// 游戏中不处理字符输入
}
public boolean isKeyDown(int keyCode) {
return pressedKeys.contains(keyCode);
}
}
为什么要用 ConcurrentHashMap.newKeySet() 而不是 HashSet?因为 pressedKeys 一边被事件分发线程写入,另一边被游戏线程读取,涉及跨线程访问,普通 HashSet 在极端情况下(按键频率很高时)可能因为内部结构被并发修改而产生问题。ConcurrentHashMap.newKeySet() 是线程安全的,不会出现 ConcurrentModificationException。这个坑我在 v2.0 时踩过,画面表现就是“按一下方向键,坦克随机抽风走一段”。
按键状态的逻辑还有一个细节值得提醒:处理玩家移动时,不要直接用 if ... else if ... 去判断四方向,因为玩家完全可能同时按下“上”和“右”。正确做法是先判断出合成方向,再分别处理 X/Y 轴的位移增量。坦克大战里的坦克只有四个朝向,不会斜着移动,但我仍然把“先收集所有有效按键、再综合决策”这个模式保留了下来,为以后扩展坦克种类留了余地。
3.2 敌方坦克 AI 的线程调度策略
敌方坦克行为的核心是一个定时决策的 AI 循环。每过一段随机时间,敌方坦克会做出三个可能的决策之一:继续直行、转向、发射子弹。在 v3.0 中,我为每一个存活的敌方坦克启动了一个独立的 AI 调度任务,但有节流控制,不让它们太频繁地改变行为,否则敌方的移动看起来就像布朗运动,玩家一点游戏体验都没有。
实现时我用了一个 ScheduledExecutorService 来调度任务,每隔 100 到 300 毫秒随机执行一次决策动作:
java复制private void aiLoop(EnemyTank tank) {
scheduler.scheduleWithFixedDelay(() -> {
if (game.getState() != GameState.PLAYING || !tank.isAlive()) {
return;
}
// 以一定概率转向或发射
int action = random.nextInt(10);
if (action < 4) {
tank.setDirection(randomDirection());
} else if (action < 6) {
tank.fire();
} else {
tank.setDirection(tank.getDirection());
}
}, 0, 150 + random.nextInt(150), TimeUnit.MILLISECONDS);
}
为什么用调度线程池而不是在游戏主循环里直接处理 AI?分开的好处是逻辑解耦——就算游戏主循环在某一帧因为资源加载卡顿,敌方 AI 的决策节奏也不会被拖乱。而且每个坦克一个任务,天然支持未来增加不同智能水平的坦克类型。
当然,调度线程池要小心一个坑:敌人坦克被消灭后,任务还在继续跑。所以每次任务里都要先判断坦克的 alive 状态和当前游戏状态,不满足条件就直接返回,不执行任何变更逻辑。这种“空转”任务虽然有点浪费,但在这个量级下完全可接受,图的是代码清晰。
3.3 子弹、爆炸动画与音效的实现细节
子弹是全场最多的动态物体(尤其是敌方坦克无限制发射时),每一发子弹的生命周期包括生成、飞行、碰撞判定、消失。子弹移动不能靠 while 循环,而是每一帧在主循环中根据方向移动固定步长。
子弹飞行有个典型问题:速度太快时会穿透薄墙。经典坦克大战中子弹速度并不快,在 60 FPS 下每帧移动 6~8 像素,墙厚是 32 像素,理论上不会穿模。但为了稳妥起见,我在移动子弹时做了“步进检测”——把一次移动拆成若干个小步,每小步都做一次碰撞检测,只要任一小步碰撞了,就立即触发消失逻辑。
java复制public void update() {
int steps = 4;
int dx = 0;
int dy = 0;
switch (direction) {
case UP -> dy = -speed;
case DOWN -> dy = speed;
case LEFT -> dx = -speed;
case RIGHT -> dx = speed;
}
for (int i = 0; i < steps; i++) {
x += dx / steps;
y += dy / steps;
if (checkCollision()) {
alive = false;
break;
}
}
}
爆炸动画当时也想得简单了。最初我用一个定时器在爆炸后 500 毫秒后直接把对象删掉,但特效的闪现过程没有任何中间帧,体验很差。后来改成用 Explosion 对象持有 6 帧爆炸图片序列,在每次主循环更新时切换到下一帧,全部播完后再把 alive 置为 false。
音效部分用的是 Java 内置的 javax.sound.sampled 包,播放 wav 格式的音效文件。按我实测的经验,游戏循环里不要直接循环播放音频,否则声音会和画面帧数互相影响,出现卡顿感。正确做法是每次发射只创建一个短的 Clip 播放一次性音效,背景音乐单独用一个循环播放线程。这里有一个容易踩的坑:有些 wav 文件采样率、位数设置特殊,Java 的 AudioSystem 解析不好会直接抛异常,开发时最好统一转换成 44.1kHz 16bit 的常见格式。
4. 常见问题与排查技巧实录
4.1 高频踩坑问题速览
整个开发过程我记录了很多报错和异常,挑几个有代表性的整理成一张表,基本都是 Java 初学者和中级开发最容易遇到的情况:
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
运行时抛出 ConcurrentModificationException |
多个线程同时对同一个 ArrayList<GameObject> 进行增删和遍历 |
使用 CopyOnWriteArrayList 或加 synchronized 锁,推荐前者 |
| 编译告警“源发行版 17 需要目标发行版 17” | IDE 中项目字节码版本和编译器版本不一致 | 检查 Maven/Gradle 配置里 maven.compiler.source 和 target,统一为 17 或项目实际使用的 JDK 版本 |
| 画面闪烁强烈 | 直接在 paint() 里绘制多张图片,没有使用双缓冲 |
继承 JPanel 并重写 paintComponent(),通过 BufferedImage 做离屏绘制再整体贴出 |
启动后报 OutOfMemoryError: insufficient memory |
加载大量高清图片素材且未释放引用,或 JVM 启动堆内存过小 | 压缩图片资源;及时移除不用的对象引用;必要时通过 -Xmx 调大堆内存 |
| 按键反应迟钝、像有延迟 | 游戏主循环用 Thread.sleep(50) 但每次处理耗时超过预期,帧率不稳定 |
用 System.nanoTime() 计算每帧实际间隔,动态调整补帧 |
| IDE 中 Lombok 注解不生效 | 项目用了 Lombok 但编译器和 IDE 的 annotation processing 没打开,或版本不兼容 | 卸载 Lombok 依赖,手写 getter/setter;或检查 IDE 的 annotation processing 设置 |
| 坦克移动时卡墙角 | X 轴和 Y 轴碰撞一起判断,互相牵制导致位移被取消 | 改成 X/Y 分轴移动判断,先横后竖或先竖后横 |
4.2 双缓冲绘制与 FPS 稳定性调优
Swing 的绘制机制天生就有闪烁问题。如果你直接在 paint() 方法里画背景、画墙体、画坦克、画子弹,每一帧都会先把画面清空成背景色,再逐层往上画,人眼很容易看到闪烁。
解决闪屏的标准姿势是双缓冲。手动实现的方式是创建一个与画布大小一致的 BufferedImage,在内存里把整帧内容全部画好,最后一次性把整张图直接写入屏幕:
java复制@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
// 开启抗锯齿
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
// 绘制内存画布
Graphics2D buffer = offScreenImage.createGraphics();
drawBackground(buffer);
drawGameObjects(buffer);
drawUI(buffer);
// 一次拷贝,避免闪烁
g.drawImage(offScreenImage, 0, 0, this);
buffer.dispose();
}
FPS 稳定性问题我记得很清楚。v2.0 时主循环直接 Thread.sleep(20)(目标 50 FPS),但在对象多的时候,一帧的处理耗时超过 20ms,实际帧率直接掉到 30 以下,而且波动很大。v3.0 改用动态时间步:
java复制while (running) {
long start = System.nanoTime();
update();
repaint();
long elapsed = System.nanoTime() - start;
long sleepTime = FRAME_TARGET_NS - elapsed;
if (sleepTime > 0) {
TimeUnit.NANOSECONDS.sleep(sleepTime);
}
}
用这种方式保证每帧耗时尽量贴近期望帧间隔,而不是固定睡固定时长。游戏逻辑中的移动速度也要跟实际帧间隔挂钩,否则在不同配置的机器上游戏快慢不一样。我定义了一个速度常量(单位:像素/秒),每帧根据实际帧间隔时间乘以速度来计算位移量,这样游戏在 60Hz 显示器和高刷屏上的体验保持一致。
4.3 jstack 与日志定位线程卡死问题
开发过程中最让人头疼的 Bug 是“游戏开场后画面偶尔冻结,但 Windows 没死,就是窗口不动”。这种问题如果放在没有工具的阶段,只能一行一行看代码猜。后来我学会了用 JDK 自带的 jstack 抓线程快照,这才定位到问题。
具体流程是在程序卡死时,打开命令行执行 jps -l 查到 Java 进程 PID,再用 jstack PID > dump.txt 导出线程快照。打开文件后就看到了根因:游戏主线程在 Thread.sleep() 时一直持有对象锁,而绘制线程等待同一把锁,最终互相等死。
这个问题的本质是锁的粒度没控制好。我原本为了保证集合安全,在 update() 和 draw() 方法上加了 synchronized,结果这两个方法本身不是原子操作,互相嵌套调用时就会出现死锁。后来我把锁的范围缩小到“只是对共享集合进行增删时的局部锁”,并且保证加锁顺序一致。如果能回到过去,我会更早意识到一个原则:并发编程里最重要的不是知道怎么加锁,而是知道不应该给什么加锁。
4.4 内存泄漏排查:从 OutOfMemoryError 到引用清理
游戏因为图片素材加载过多,运行几关之后就开始越跑越慢,最终在某个激烈的对战场景直接抛 OutOfMemoryError: insufficient memory。当时第一反应是素材太大,但压缩图片之后问题依然存在。
后来打开 jvisualvm 看堆内存曲线,发现频繁创建 ImageIcon 对象后堆占用不断上升,GC 后也不回落。原因找到了:游戏在重新开始关卡时,把所有墙体和坦克图片重新加载了一遍,但是旧的 Image 对象还被某个全局静态集合引用着,无法被垃圾回收。这就是教科书里说的“隐性内存泄漏”,引用自己没有及时清理。
解决方案分两步走。第一步,增加一个 ResourceManager 负责图片的加载和缓存,同一张图片全局只创建一次,用 Map<String, Image> 做资源池;第二步,每一关结束或游戏重启时,清空所有游戏对象的集合引用,让对象树完全断开。这样内存占用稳稳控制在 200MB 以内。
这个经历让我意识到一个重要的开发习惯:每次往集合里加对象时,都要问自己一句——什么时候删除?如果答不上来,这里可能就会变成内存泄漏点。
5. 从 v3.0 到未来的思考与个人体会
做到这一步,游戏已经可以流畅运行,敌我双方都有完整的战斗表现,关卡可以切换,死了能重来。但在整个工程收尾的时候,我回过头去看 v1.0 时候自己写的那几百行代码,反而有些新的体会。
刚写 v1.0 的时候,我追求的是“实现”:坦克能在屏幕上跑,能发射子弹,敌人会朝我冲过来,就觉得自己已经完成了任务。到 v2.0 开始有了“抽象”的意识,会把坦克、子弹、墙体拆成独立的类,但类和类之间的关系还比较混乱。到了 v3.0,我才真正开始理解“设计”二字的含义——不是把所有东西都做成类就是面向对象,而是要让每个类有清晰单一的职责,让类与类之间的协作关系足够清晰,让修改一个功能时不需要连带改三个其他类。
这个项目里最值得做对的一件事,是对线程模型的重视。v3.0 采用了非常典型的多线程协作模式:EDT 处理用户输入,Swing 定时重绘,独立线程跑游戏主循环和 AI 调度。虽然单个坦克大战游戏并不复杂,但线程之间的同步、共享数据的保护、任务的调度策略,都是真实的大型应用每天早上都会遇到的问题。做完这个项目后,再回过头去理解“并发编程”相关的内容,感觉完全不一样了。
做完 v3.0 之后,我觉得这个项目还有继续深挖的空间。如果你在这个基础上继续迭代,可以往这几个方向走:
- 关卡编辑器:可视化设计地图,配合 JSON 序列化存储,用到了 IO 与设计模式。
- 局域网双人对战:走 Socket/Netty 同步双方状态,用到了网络编程和协议设计。
- 存档系统:把玩家进度、军衔、解锁的坦克保存到本地数据库,用到了 JDBC 与持久层设计。
- 智能敌方 AI:引入有限状态机和路径寻路算法,敌方坦克不再乱撞墙,而是有策略地围攻玩家基地。
如果你正在学 Java,处于“能看懂教程但不会写项目”的阶段,我建议你把坦克大战这个项目从头到尾认真写一遍。别急着看网上的成品代码,先自己设计,卡住再查资料,再回来改。这个过程比你看十个视频教程都有用。真正把代码敲进 IDE 里,把它跑起来,让它出错,再自己把它修好,这些经验才是你自己的。
