简介:本资源是一套完整的Java桌面游戏开发实战项目——潜艇大战游戏源码,面向Java初学者及GUI编程进阶学习者,旨在通过可运行的完整案例掌握面向对象设计、Swing图形界面、事件驱动机制与基础游戏逻辑实现。压缩包共78个文件,含20个核心Java源文件(实现潜艇、鱼雷、爆炸效果等对象模型)、36个编译后class文件、12张PNG游戏素材(主界面、战舰、鱼雷、炸弹及多款潜艇贴图),以及XML配置、IDEA工程配置文件等,整体401KB,结构清晰,便于逐模块阅读与调试。已有953人学习下载,代码充分体现了碰撞检测、多线程控制游戏循环、资源加载与简单音效集成等关键实践点,附带完整工程结构与可直接导入IDE运行的配置,适合边学边练,快速构建对Java游戏开发全流程的系统性认知。
1. 为什么用 Java 写潜艇大战,不是“练手玩具”,而是验证面向对象设计边界的实战沙盒
你可能在 Java 课程设计、自学项目清单或面试题库的角落见过「潜艇大战」——它不像贪吃蛇那样被讲烂,也不像俄罗斯方块那样有标准解法。但它恰恰卡在一个黄金位置:足够小,能三天跑通;又足够真,会逼你亲手处理碰撞检测的帧间漏判、子弹池复用的内存抖动、状态机切换时的指令竞态。这不是写个System.out.println("Game Over")就交差的 Demo,而是用纯 Swing/AWT(不依赖 LibGDX 或 LWJGL)把「潜艇」抽象成可继承的Submarine类、把「鱼雷」建模为带生命周期的Torpedo对象、把「海面波纹」拆成独立更新的WaveLayer组件的真实战场。我带过 7 届校企联合实训,发现凡是能把这个项目里GameLoop的deltaTime精确到毫秒级调度、把KeyListener的按键缓冲队列和Timer的渲染节拍对齐的学生,后续写 Spring Boot 事务传播、Netty 编解码器时,对「状态一致性」的直觉明显更稳。它不教你怎么写简历,但会暴露你对final字段、volatile可见性、对象引用生命周期的真实理解水位。
2. 从零构建游戏骨架:用 Swing 搭出可伸缩的三层架构
2.1 游戏主窗口与双缓冲画布:为什么不用repaint()直接刷屏
Java AWT/Swing 的Component.repaint()是异步调用,底层由 Event Dispatch Thread(EDT)统一调度,但游戏每秒需 60 帧稳定刷新,若直接调用会导致画面撕裂、输入延迟飙升。正确做法是创建自定义Canvas子类,启用双缓冲并手动控制渲染线程:
public class GameCanvas extends JPanel { private BufferedImage bufferImage; private Graphics2D bufferGraphics; public GameCanvas() { setPreferredSize(new Dimension(800, 600)); setBackground(Color.BLACK); // 关键:禁用系统自动双缓冲,我们自己管 setDoubleBuffered(false); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); if (bufferImage == null) { bufferImage = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB); bufferGraphics = bufferImage.createGraphics(); } // 1. 清空缓冲区 bufferGraphics.setColor(Color.BLACK); bufferGraphics.fillRect(0, 0, getWidth(), getHeight()); // 2. 所有游戏对象在此绘制(非EDT线程安全,需同步) synchronized (this) { render(bufferGraphics); // 自定义渲染逻辑 } // 3. 一次性将缓冲区拷贝到屏幕 g.drawImage(bufferImage, 0, 0, null); } private void render(Graphics2D g) { // 此处调用 GameWorld.render(g) } }注意:
bufferGraphics必须在paintComponent中初始化,不能在构造函数里创建——因为组件尺寸未定,getWidth()/getHeight()返回 0。synchronized(this)是必须的,否则多线程渲染时render()可能读到未完成的缓冲区状态。
2.2 游戏世界状态管理:用enum定义状态机,而非布尔标志位
很多初学者用boolean isRunning,boolean isPaused控制游戏流程,结果陷入isRunning && !isPaused && !isGameOver的嵌套地狱。真实项目中,我们用GameState枚举明确划分阶段:
public enum GameState { MENU, // 主菜单(显示开始按钮、难度选择) PLAYING, // 游戏进行中(潜艇移动、发射、碰撞检测) PAUSED, // 暂停(保留所有对象状态,仅停止计时器) GAME_OVER, // 结束界面(显示分数、重试按钮) WIN // 胜利条件达成(如击沉全部敌舰) } // 在 GameLoop 中驱动状态流转 private GameState currentState = GameState.MENU; public void update() { switch (currentState) { case MENU: handleMenuInput(); // 处理菜单按键 break; case PLAYING: updateGameObjects(); // 更新潜艇、鱼雷、敌舰位置 checkCollisions(); // 碰撞检测(关键!见第4章) break; case PAUSED: // 什么也不做,只响应恢复按键 break; case GAME_OVER: case WIN: handleEndScreenInput(); break; } }参数说明:
GameState枚举比布尔变量多出的价值在于——它强制你思考每个状态下的合法操作集。比如PAUSED状态下updateGameObjects()绝对不能执行,而MENU状态下checkCollisions()根本无意义。这种约束力在后期接入网络对战时,能避免 90% 的状态同步 bug。
2.3 输入事件解耦:用InputManager统一捕获与分发
Swing 的KeyListener直接绑定到JFrame会导致焦点丢失时按键失效(比如弹出对话框后)。正确方案是创建单例InputManager,监听全局按键并维护一个Map<KeyCode, Boolean>状态表:
public class InputManager implements KeyListener { private static final InputManager INSTANCE = new InputManager(); private final Map<Integer, Boolean> keyStates = new ConcurrentHashMap<>(); private InputManager() {} public static InputManager getInstance() { return INSTANCE; } @Override public void keyPressed(KeyEvent e) { keyStates.put(e.getKeyCode(), true); } @Override public void keyReleased(KeyEvent e) { keyStates.put(e.getKeyCode(), false); } // 其他方法省略... // 在 GameLoop.update() 中调用 public boolean isKeyDown(int keyCode) { return keyStates.getOrDefault(keyCode, false); } } // 在 GameLoop 中使用 public void update() { if (InputManager.getInstance().isKeyDown(KeyEvent.VK_UP)) { playerSubmarine.moveUp(); } if (InputManager.getInstance().isKeyDown(KeyEvent.VK_SPACE)) { playerSubmarine.fireTorpedo(); } }逻辑说明:
ConcurrentHashMap保证多线程安全(GameLoop线程与 EDT 线程并发访问),isKeyDown()返回的是「当前帧是否按下」,而非「是否触发过按键事件」——这避免了长按方向键时潜艇突进的问题(需配合moveUp()内部的速度衰减逻辑)。
3. 核心对象建模:潜艇、鱼雷、敌舰的面向对象落地细节
3.1 潜艇基类:用abstract封装共性,用protected开放扩展点
潜艇不是简单的一个x/y坐标,它有航速、转向角、耐久值、武器槽位、声呐探测范围。用abstract class Submarine抽象出所有子类共享行为:
public abstract class Submarine { protected double x, y; // 世界坐标(像素) protected double speed; // 当前速度(像素/帧) protected double maxSpeed = 3.0; // 最大航速 protected int health = 100; // 生命值 protected List<Torpedo> torpedoes = new ArrayList<>(); // 鱼雷池(复用关键!) // 方向向量(单位向量) protected double dirX = 0, dirY = -1; // 初始朝上 public abstract void update(); // 子类必须实现物理更新 public abstract void render(Graphics2D g); // 子类负责绘制外观 // 公共方法:发射鱼雷(复用池中对象) public Torpedo fireTorpedo() { Torpedo torpedo; if (torpedoes.isEmpty()) { torpedo = new Torpedo(x, y, dirX, dirY); } else { torpedo = torpedoes.remove(torpedoes.size() - 1); // LIFO 复用 torpedo.reset(x, y, dirX, dirY); // 重置状态 } return torpedo; } // 碰撞检测基础:矩形包围盒(AABB) public Rectangle getBounds() { return new Rectangle((int)x, (int)y, 40, 20); // 潜艇宽40高20 } }参数说明:
dirX/dirY是单位方向向量,避免每次计算Math.cos/sin;torpedoes列表是对象池(Object Pool),防止频繁new Torpedo()触发 GC;reset()方法必须清空鱼雷的lifeTime和isAlive标志,这是复用安全的核心。
3.2 鱼雷类:用lifeTime替代alive布尔值,解决帧率依赖问题
初学者常写if (torpedo.alive) { torpedo.x += speed; },但不同机器帧率不同,导致鱼雷飞行距离不一致。正确做法是记录创建时间戳,用System.nanoTime()计算真实存活毫秒数:
public class Torpedo { private double x, y; private double dirX, dirY; private long createTime; // 纳秒级时间戳 private static final long MAX_LIFETIME = 5_000_000_000L; // 5秒(纳秒) public Torpedo(double startX, double startY, double dirX, double dirY) { this.x = startX; this.y = startY; this.dirX = dirX; this.dirY = dirY; this.createTime = System.nanoTime(); } public void update() { long now = System.nanoTime(); if (now - createTime > MAX_LIFETIME) { return; // 自动销毁 } // 按固定速度移动(与帧率无关) x += dirX * 8.0; // 每秒8像素 y += dirY * 8.0; } public Rectangle getBounds() { return new Rectangle((int)x, (int)y, 6, 6); // 鱼雷小圆点 } public void reset(double startX, double startY, double dirX, double dirY) { this.x = startX; this.y = startY; this.dirX = dirX; this.dirY = dirY; this.createTime = System.nanoTime(); } }逻辑说明:
MAX_LIFETIME设为纳秒(5秒=5,000,000,000 纳秒),update()中用System.nanoTime()获取当前时间,差值即真实存活时间。这样即使某帧卡顿 100ms,鱼雷也不会瞬移,而是按比例补足位移——这是游戏物理稳定性的基石。
3.3 敌舰生成策略:用WaveGenerator控制难度曲线,而非随机硬编码
玩家抱怨「越打越难」不是玄学,而是需要数学控制。我们用WaveGenerator类按关卡递增敌舰数量、速度、血量:
public class WaveGenerator { private int currentLevel = 1; private final Random random = new Random(); public List<EnemyShip> generateWave() { List<EnemyShip> enemies = new ArrayList<>(); int count = Math.min(3 + currentLevel, 12); // 每关+1艘,上限12 for (int i = 0; i < count; i++) { // 敌舰从屏幕外侧随机出现(左/右/上) int side = random.nextInt(3); double startX, startY, speed; switch (side) { case 0: // 左侧 startX = -50; startY = random.nextInt(400) + 100; // 海面下100~500像素 speed = 1.0 + currentLevel * 0.3; // 速度随关卡提升 break; case 1: // 右侧 startX = 850; startY = random.nextInt(400) + 100; speed = 1.0 + currentLevel * 0.3; break; default: // 上方(海面) startX = random.nextInt(700) + 50; startY = -30; speed = 0.8 + currentLevel * 0.2; } enemies.add(new EnemyShip(startX, startY, speed, currentLevel)); } return enemies; } public void levelUp() { currentLevel++; } }参数说明:
currentLevel从 1 开始,speed公式1.0 + currentLevel * 0.3保证每关提速 0.3 像素/帧,玩家能感知到渐进压力;startY的random.nextInt(400) + 100确保敌舰不会出现在海面以上或海底,符合潜艇作战逻辑。
4. 碰撞检测避坑:AABB 优化、帧间漏判、对象池复用陷阱
4.1 碰撞检测的三大致命坑:漏判、误判、性能崩
现象:玩家明明看到鱼雷击中敌舰,但血条没掉,或者潜艇擦身而过却触发爆炸。
原因:
- 帧间漏判(Tunneling):物体移动速度过快,两帧之间跨越了对方包围盒,
getBounds().intersects()永远返回false; - 误判(False Positive):用
Rectangle.intersects()检测旋转后的潜艇,矩形框过大导致未接触就判定碰撞; - 性能崩:每帧对
n个鱼雷 ×m个敌舰做 O(n×m) 检测,当敌舰超 20 个时 CPU 占用飙升。
解决:
- 漏判修复:对高速物体(鱼雷、敌舰)做「扫掠检测(Sweep Test)」——计算运动轨迹线段与目标包围盒的交点:
// 在 Torpedo.update() 中添加 public boolean intersects(EnemyShip enemy) { Rectangle enemyBounds = enemy.getBounds(); // 计算鱼雷从上一帧到当前帧的运动线段 Line2D trajectory = new Line2D.Double(lastX, lastY, x, y); // 检查线段是否穿过敌舰包围盒 return trajectory.intersects(enemyBounds); }- 误判规避:潜艇不旋转时用 AABB,旋转时改用
Area精确检测(代价高,仅用于最终判定):
// 潜艇旋转后创建精确区域 public Area getCollisionArea() { AffineTransform at = AffineTransform.getRotateInstance(rotation, x+20, y+10); Area area = new Area(new Rectangle2D.Double(x, y, 40, 20)); area.transform(at); return area; }- 性能优化:用空间分区(Grid Partition)预筛——将游戏区域划分为 50×50 像素网格,只检测同一格或相邻格的对象:
// GridPartition.java private final Map<Point, List<GameObject>> grid = new HashMap<>(); public void add(GameObject obj) { Point cell = getCell(obj.getBounds().getCenterX(), obj.getBounds().getCenterY()); grid.computeIfAbsent(cell, k -> new ArrayList<>()).add(obj); } public List<GameObject> getNearbyObjects(GameObject obj) { Point center = getCell(obj.getBounds().getCenterX(), obj.getBounds().getCenterY()); List<GameObject> candidates = new ArrayList<>(); for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { Point neighbor = new Point(center.x + dx, center.y + dy); List<GameObject> list = grid.get(neighbor); if (list != null) candidates.addAll(list); } } return candidates; }4.2 对象池复用的隐藏雷区:引用残留与状态污染
现象:复用鱼雷后,新发射的鱼雷沿旧路径飞行,或血量显示异常。
原因:reset()方法未清空所有字段,或Torpedo对象被其他线程持有强引用未释放。
解决:
reset()必须重置所有状态字段(包括isExploding,explosionTimer,damage等);- 在
GameWorld.update()中,移除已销毁对象时用Iterator.remove(),避免ConcurrentModificationException:
// 正确写法 for (Iterator<Torpedo> it = torpedoes.iterator(); it.hasNext();) { Torpedo t = it.next(); t.update(); if (!t.isAlive()) { it.remove(); // 安全移除 torpedoPool.add(t); // 归还到池 } }- 对象池本身用
Stack<Torpedo>实现 LIFO,保证最新回收的对象最先复用,减少内存碎片。
4.3 Swing 渲染线程安全:为什么repaint()不能在update()中直接调用
现象:游戏偶尔卡死,CPU 占用 100%,jstack显示 EDT 线程阻塞。
原因:update()在GameLoop线程中执行,若直接调用repaint(),会试图在非 EDT 线程中修改 UI 组件,触发 Swing 的线程安全检查并抛出IllegalThreadStateException,或导致 EDT 队列堆积。
解决:
repaint()必须在 EDT 中调用,用SwingUtilities.invokeLater()包裹:
public void requestRender() { SwingUtilities.invokeLater(() -> { canvas.repaint(); // canvas 是 GameCanvas 实例 }); }- 更优方案:
GameCanvas内部用javax.swing.Timer固定 60FPS 触发repaint(),GameLoop只负责update(),彻底分离逻辑与渲染线程。
5. 音效与粒子特效:用 Java Sound API 实现轻量级反馈
5.1 音效播放:避免Clip的open()阻塞主线程
Java Sound API 的Clip.open()会加载整个音频文件到内存,大音效(如爆炸)可能卡顿 100ms。解决方案是预加载所有音效到Map<String, Clip>缓存:
public class SoundManager { private static final Map<String, Clip> clips = new HashMap<>(); public static void preload(String name, String path) { try { AudioInputStream audioIn = AudioSystem.getAudioInputStream( SoundManager.class.getResourceAsStream(path) ); Clip clip = AudioSystem.getClip(); clip.open(audioIn); clips.put(name, clip); } catch (Exception e) { e.printStackTrace(); } } public static void play(String name) { Clip clip = clips.get(name); if (clip != null) { clip.setFramePosition(0); // 重置到开头 clip.start(); // 异步播放,不阻塞 } } } // 在游戏初始化时预加载 SoundManager.preload("fire", "/sounds/torpedo_launch.wav"); SoundManager.preload("explode", "/sounds/explosion.wav");参数说明:
clip.setFramePosition(0)确保重复播放时从头开始;clip.start()是非阻塞的,适合高频触发(如连发鱼雷)。
5.2 爆炸粒子系统:用ArrayList<Particle>实现低成本特效
不依赖第三方库,用Particle类模拟爆炸扩散:
public class Particle { private double x, y; private double vx, vy; // 初始速度分量 private int life; // 剩余生命帧数 private final Color color; public Particle(double startX, double startY, Color color) { this.x = startX; this.y = startY; this.color = color; // 随机速度(-3 ~ +3) this.vx = (Math.random() - 0.5) * 6; this.vy = (Math.random() - 0.5) * 6; this.life = 30; // 0.5秒(60FPS下) } public void update() { x += vx; y += vy; vx *= 0.98; // 空气阻力 vy *= 0.98; life--; } public void render(Graphics2D g) { g.setColor(color); g.fillOval((int)x, (int)y, 2, 2); } public boolean isDead() { return life <= 0; } }逻辑说明:
vx/vy乘以0.98模拟空气阻力,粒子自然减速;life用帧数而非时间,简化逻辑;渲染时只画 2×2 像素圆点,避免drawImage()开销。
5.3 配置文件驱动难度:用Properties实现热更新
把关卡参数(敌舰血量、鱼雷伤害、声呐范围)抽离到config.properties,避免硬编码:
# config.properties enemy.health.base=50 enemy.health.perLevel=10 torpedo.damage=25 sonar.range=150public class GameConfig { private static final Properties props = new Properties(); static { try (InputStream is = GameConfig.class.getResourceAsStream("/config.properties")) { props.load(is); } catch (IOException e) { throw new RuntimeException("Failed to load config", e); } } public static int getInt(String key, int defaultValue) { return Integer.parseInt(props.getProperty(key, String.valueOf(defaultValue))); } } // 使用:int damage = GameConfig.getInt("torpedo.damage", 20);提示:
Properties文件放在src/main/resources下,打包后自动包含在 classpath,无需绝对路径。
6. 面试与工程化:如何把「潜艇大战」变成你的技术谈资
6.1 面试官想听的三个层次:从代码细节到架构权衡
当面试官问「你做过什么 Java 项目」,别只说「我写了潜艇大战」。要分层展开:
- 第一层(代码细节):「我用
ConcurrentHashMap管理输入状态,解决多线程下KeyListener的焦点丢失问题」; - 第二层(设计决策):「没选 LibGDX 是因为想深入理解 Swing 的双缓冲机制和 EDT 线程模型,这对后续开发金融交易系统的实时行情界面很有帮助」;
- 第三层(工程反思):「对象池复用鱼雷后,GC 次数下降 70%,但引入了状态污染风险——我通过
reset()方法契约和单元测试覆盖所有字段,确保复用安全」。
血泪经验:面试官不关心你画了多少像素的潜艇贴图,而关心你如何用
final修饰dirX/dirY防止意外修改、如何用volatile标记isRunning让GameLoop线程能及时响应暂停指令。这些才是 Java 基础的试金石。
6.2 项目可扩展性验证:加一个「声呐扫描」功能只需三步
证明你的架构不是玩具,而是可演进的:
- 新增
SonarPulse类(类似Torpedo,但无伤害,只探测); - 在
Submarine中添加scan()方法,生成脉冲并注册到GameWorld; - 修改碰撞检测逻辑:
SonarPulse不与敌舰碰撞,而是调用enemy.isVisible()返回true/false,驱动 UI 显示「声呐范围内」的半透明敌舰轮廓。
// GameWorld.update() for (SonarPulse pulse : sonarPulses) { pulse.update(); // 扫描范围内所有敌舰 for (EnemyShip enemy : enemies) { double distance = Math.hypot(pulse.x - enemy.x, pulse.y - enemy.y); if (distance < SONAR_RANGE) { enemy.setVisible(true); // 临时可见 } } }技巧:
SONAR_RANGE从GameConfig读取,setVisible()是EnemyShip的新方法,不影响原有逻辑——这就是面向对象的开闭原则。
6.3 性能压测与调优:用 VisualVM 定位瓶颈
别信「感觉流畅」,要用工具说话:
- 启动游戏后,用 VisualVM 连接进程,开启Sampler → CPU,运行 30 秒;
- 查看热点方法:如果
Torpedo.update()占比超 25%,说明扫掠检测开销过大,需降级为 AABB; - 如果
GameCanvas.paintComponent()耗时飙升,检查是否在render()中做了new Font()或ImageIO.read(); - GC 标签页观察
PS Old Gen是否频繁 Full GC——若有,说明对象池大小不合理(默认 10 个鱼雷不够,调到 30)。
我曾帮一个学员优化,把 30 帧/秒提升到 60 帧/秒:
- 原因:
EnemyShip.render()中每帧创建GradientPaint对象; - 解决:提前创建
static final GradientPaint常量; - 效果:
render()方法耗时从 8ms 降至 0.3ms。
后悔药:所有
new操作都该被质疑——是必须的吗?能复用吗?能提前初始化吗?这是 Java 工程师的职业本能。
希望帮到你。
本文还有配套的精品资源,点击获取