Java潜艇大战:面向对象设计与Swing游戏开发实战
发布时间:2026/10/1 4:12:47来源:尧图网络
简介本资源是一套完整的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 ThreadEDT统一调度但游戏每秒需 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监听全局按键并维护一个MapKeyCode, Boolean状态表public class InputManager implements KeyListener { private static final InputManager INSTANCE new InputManager(); private final MapInteger, 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 ListTorpedo 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/sintorpedoes列表是对象池Object Pool防止频繁new Torpedo()触发 GCreset()方法必须清空鱼雷的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 ListEnemyShip generateWave() { ListEnemyShip 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, x20, y10); Area area new Area(new Rectangle2D.Double(x, y, 40, 20)); area.transform(at); return area; }性能优化用空间分区Grid Partition预筛——将游戏区域划分为 50×50 像素网格只检测同一格或相邻格的对象// GridPartition.java private final MapPoint, ListGameObject 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 ListGameObject getNearbyObjects(GameObject obj) { Point center getCell(obj.getBounds().getCenterX(), obj.getBounds().getCenterY()); ListGameObject 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); ListGameObject 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 (IteratorTorpedo it torpedoes.iterator(); it.hasNext();) { Torpedo t it.next(); t.update(); if (!t.isAlive()) { it.remove(); // 安全移除 torpedoPool.add(t); // 归还到池 } }对象池本身用StackTorpedo实现 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。解决方案是预加载所有音效到MapString, Clip缓存public class SoundManager { private static final MapString, 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 爆炸粒子系统用ArrayListParticle实现低成本特效不依赖第三方库用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.base50 enemy.health.perLevel10 torpedo.damage25 sonar.range150public 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 工程师的职业本能。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网