Java Swing实现大鱼吃小鱼游戏:从零到一的完整开发指南

发布时间:2026/8/7 6:41:36
Java Swing实现大鱼吃小鱼游戏:从零到一的完整开发指南
1. 项目概述与核心价值最近在整理一些经典的小游戏项目发现《大鱼吃小鱼》这个玩法虽然简单但用来理解游戏开发的核心循环和面向对象设计思想再合适不过了。它不像大型游戏那样有复杂的系统和美术资源却能完整地体现一个游戏从逻辑设计、对象交互到渲染绘制的全过程。对于Java初学者或者想从应用开发转向游戏开发的朋友来说这是一个绝佳的练手项目。你不需要任何游戏引擎只用Java的Swing或JavaFX这样的GUI库就能亲手实现一个可玩、可扩展的游戏。这个项目能帮你搞明白游戏世界里的对象鱼是怎么“活”起来的它们之间如何发生“吃”与“被吃”的交互游戏状态如分数、生命又是如何随着交互而动态变化的今天我就把自己实现这个项目的完整思路、关键代码以及踩过的那些坑毫无保留地分享出来。无论你是想完成一个课程设计还是单纯对游戏逻辑感兴趣这篇文章都能给你一套可以直接“抄作业”的方案。2. 游戏整体设计与核心思路拆解2.1 游戏规则与核心循环定义《大鱼吃小鱼》的核心规则非常直观玩家控制一条鱼通常是小鱼起步在一个有边界的海域里游动。海域中会随机生成其他鱼类这些鱼有不同的大小。玩家的鱼只能吃掉比自己体型小的鱼每吃掉一条玩家的鱼就会长大一些同时获得分数。如果玩家的鱼尝试去吃比自己大的鱼或者被更大的鱼撞到则会损失生命值或直接游戏结束。这个简单的规则背后隐藏着一个标准的游戏循环Game Loop模型。游戏循环是任何游戏的心脏它通常包含四个阶段1. 处理输入监听键盘或鼠标事件、2. 更新游戏状态根据输入和规则计算所有对象的新位置、状态、3. 碰撞检测判断对象之间是否发生交互、4. 渲染绘制将最新的游戏状态画到屏幕上。我们的Java实现也将严格遵循这个循环。在Swing中我们可以使用一个javax.swing.Timer来以固定的时间间隔例如每秒60帧驱动这个循环确保游戏的流畅性。2.2 面向对象的核心类设计用Java实现首要任务就是将游戏中的实体抽象成类。这是面向对象思想最直接的体现。我设计了以下几个核心类Fish类这是所有鱼的基类。它应该包含哪些属性位置x, y坐标、大小一个代表体型的半径或宽度/高度、速度在x和y方向上的分量、颜色、以及一个代表其“分数值”的属性被吃掉后玩家能得多少分。方法则包括移动move、绘制自己draw、以及一个判断能否吃掉另一条鱼的canEat(Fish other)方法。这个canEat方法就是规则的核心通常是比较双方的大小属性。PlayerFish类继承自Fish。它除了拥有普通鱼的属性还需要额外响应键盘输入。我会为它重写move方法使其移动方向由键盘如WASD或方向键控制而不是像AI鱼那样随机或按固定模式移动。AIFish类同样继承自Fish。代表游戏中自动生成的、由电脑控制的鱼。它们的移动逻辑可以是简单的直线运动、碰到边界反弹或者更高级一点的朝着某个点缓慢移动。我们需要一个机制来管理这些AI鱼的生成和销毁。GamePanel类继承自JPanel这是游戏的主画布。它负责持有所有鱼的实例一个ListFish。实现游戏主循环在Timer的actionPerformed中调用更新和重绘。重写paintComponent(Graphics g)方法在这里遍历所有鱼并调用它们的draw方法。处理键盘事件监听将按键状态传递给PlayerFish。GameMain类包含main方法负责创建JFrame窗口将GamePanel添加进去并启动游戏。设计心得在初期不要把Fish类的属性设计得太复杂。先让鱼能显示、能移动把核心循环跑通。canEat和碰撞检测的逻辑可以先用简单的矩形或圆形边界框实现后期再优化。记住一个原则先跑起来再做好最后做优化。2.3 关键技术选型与考量为什么选择Swing而不是JavaFX或其他对于这样一个2D小型演示项目Swing完全够用且是Java标准库的一部分无需额外依赖环境配置最简单。它的Graphics绘图API对于绘制圆形、矩形、图像精灵Sprite已经足够。Timer和事件监听机制也能很好地满足游戏循环和输入处理的需求。如果未来你想让游戏效果更炫酷可以考虑引入缓冲图像BufferedImage来减少闪烁或者使用VolatileImage进行硬件加速但对于初版Graphics直接绘制是最快上手的方案。3. 核心细节解析与实操要点3.1 鱼的移动与边界处理移动的本质是每一帧根据速度更新位置坐标。在Fish的move方法里就是x speedX; y speedY;。但这里有两个关键点速度的向量表示速度不应该只是一个标量值。我们使用speedX和speedY两个分量这样可以方便地表示任意方向上的移动。如果需要让鱼朝着鼠标或某个点移动就需要计算方向向量并归一化然后乘以速度大小。边界碰撞与反弹这是让游戏世界感觉真实的重要一环。在move方法中更新坐标后要立即进行边界判断。public void move() { x speedX; y speedY; // 假设游戏面板宽度为panelWidth高度为panelHeight鱼自身半径为size if (x - size 0 || x size panelWidth) { speedX -speedX; // 水平方向反弹 // 防止鱼卡在边界进行位置修正 x Math.max(size, Math.min(panelWidth - size, x)); } if (y - size 0 || y size panelHeight) { speedY -speedY; // 垂直方向反弹 y Math.max(size, Math.min(panelHeight - size, y)); } }对于玩家控制的鱼我们通常不希望它反弹而是停在边界。所以可以在PlayerFish的move方法中重写边界处理逻辑改为x Math.max(size, Math.min(panelWidth - size, x));即简单地钳制在边界内。3.2 碰撞检测的实现方案碰撞检测是游戏中的性能热点之一尤其是对象数量多的时候。我们分步实现第一步简单边界框检测。对于圆形鱼检测两个圆是否相交是最直观的。计算两条鱼圆心之间的距离如果小于它们半径之和则认为发生碰撞。public boolean isCollidingWith(Fish other) { double distance Math.sqrt(Math.pow(this.x - other.x, 2) Math.pow(this.y - other.y, 2)); return distance (this.size other.size); }这里使用了开方运算在对象很多时可能成为性能瓶颈。一个常见的优化是比较距离的平方避免开方double dx this.x - other.x; double dy this.y - other.y; double distanceSquared dx*dx dy*dy; double radiusSum this.size other.size; return distanceSquared radiusSum * radiusSum;第二步区分碰撞类型。碰撞发生后我们需要判断是“吃”还是“被吃”。这就在Fish类中设计canEat方法public boolean canEat(Fish other) { // 一个简单的规则当前鱼的体型至少是对方鱼的1.2倍时才能吃掉对方 // 这个比例系数可以调整用于控制游戏难度 return this.size other.size * 1.2; }在游戏主循环的碰撞检测阶段当检测到玩家鱼和AI鱼碰撞时就调用playerFish.canEat(aiFish)来判断结果。第三步优化策略当鱼数量增多时。如果屏幕上有几十上百条鱼两两检测O(n²)复杂度会非常卡顿。此时需要引入空间划分算法如**四叉树Quadtree或网格Grid**法。简单来说就是把屏幕分成一个个小格子每条鱼根据其位置归属到某个格子。检测碰撞时只需要检测同一个格子或相邻格子里的鱼大大减少了检测对数。对于我们的练手项目如果AI鱼数量控制在20条以内两两检测在60帧下是完全可行的。但了解这个优化思路对日后做更复杂的游戏很有帮助。3.3 游戏状态管理与AI鱼生成游戏需要管理几个核心状态分数Score、生命值Life或游戏是否结束Game Over。这些状态可以放在GamePanel类中作为成员变量。AI鱼的生成是一个典型的生产者-消费者模型虽然这里没有严格的队列。我们可以在GamePanel中设置一个定时器或直接在游戏循环里用一个计数器来控制private void generateAIFish() { if (aiFishList.size() MAX_AI_FISH_COUNT) { if (Math.random() SPAWN_PROBABILITY_PER_FRAME) { // 每帧有一定的生成概率 int size MIN_AI_SIZE (int)(Math.random() * (MAX_AI_SIZE - MIN_AI_SIZE)); int speed ... // 根据大小可能成反比大鱼慢小鱼快 AIFish newFish new AIFish(randomX, randomY, size, speed, color); aiFishList.add(newFish); } } }同时在更新所有鱼的状态后需要遍历AI鱼列表移除那些已经被吃掉isEaten标志为真或者游出边界太远如果设计了此类规则的鱼防止内存泄漏。4. 实操过程与核心代码实现4.1 项目环境搭建与窗口创建首先确保你有一个Java开发环境JDK 8或以上。创建一个新的Java项目我习惯用Maven或Gradle管理但纯Java项目也完全没问题。核心代码只需要JDK。创建GameMain.java作为入口import javax.swing.*; public class GameMain { public static void main(String[] args) { SwingUtilities.invokeLater(() - { JFrame frame new JFrame(大鱼吃小鱼 - Java版); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); // 固定窗口大小简化坐标计算 GamePanel gamePanel new GamePanel(); frame.add(gamePanel); frame.pack(); // 使窗口适应面板大小 frame.setLocationRelativeTo(null); // 居中显示 frame.setVisible(true); gamePanel.startGame(); // 启动游戏循环 }); } }GamePanel的构造函数里我们需要设置面板的 preferred size并添加键盘监听器。4.2 游戏主循环与绘制逻辑这是GamePanel类的核心部分。我们使用一个javax.swing.Timer来驱动循环。public class GamePanel extends JPanel implements ActionListener { private Timer gameTimer; private PlayerFish player; private ListAIFish aiFishes; private int score 0; private boolean gameRunning true; private static final int FPS 60; private static final int DELAY 1000 / FPS; // 每帧延迟毫秒数 public GamePanel() { setPreferredSize(new Dimension(800, 600)); setBackground(new Color(135, 206, 235)); // 浅蓝色背景模拟海水 setFocusable(true); // 必须设置为true才能接收键盘事件 addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { player.keyPressed(e); } Override public void keyReleased(KeyEvent e) { player.keyReleased(e); } }); player new PlayerFish(400, 300, 20, 5); // 初始位置大小速度 aiFishes new ArrayList(); gameTimer new Timer(DELAY, this); } public void startGame() { gameTimer.start(); } Override public void actionPerformed(ActionEvent e) { if (!gameRunning) return; // 1. 更新状态 updateGame(); // 2. 重绘屏幕 repaint(); } private void updateGame() { player.move(); for (AIFish fish : aiFishes) { fish.move(); } generateAIFish(); checkCollisions(); // 其他状态更新如分数显示等 } Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; // 开启抗锯齿让绘制的图形边缘更平滑 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); player.draw(g2d); for (AIFish fish : aiFishes) { fish.draw(g2d); } // 绘制游戏状态信息分数、生命 drawGameInfo(g2d); } // ... 其他方法如 generateAIFish, checkCollisions, drawGameInfo }关键提示repaint()方法并不会立即执行绘制而是向Swing系统提交一个重绘请求。真正的绘制发生在事件分发线程EDT上。因此在actionPerformed中不要进行耗时操作否则会阻塞EDT导致界面卡顿。所有状态更新和计算都应尽快完成。4.3 玩家控制与AI鱼行为实现PlayerFish的关键在于将键盘输入转换为移动方向。我通常使用一个布尔数组来记录按键状态而不是在keyPressed事件中直接修改位置。public class PlayerFish extends Fish { private boolean upPressed, downPressed, leftPressed, rightPressed; public void keyPressed(KeyEvent e) { int key e.getKeyCode(); switch (key) { case KeyEvent.VK_W: case KeyEvent.VK_UP: upPressed true; break; case KeyEvent.VK_S: case KeyEvent.VK_DOWN: downPressed true; break; case KeyEvent.VK_A: case KeyEvent.VK_LEFT: leftPressed true; break; case KeyEvent.VK_D: case KeyEvent.VK_RIGHT: rightPressed true; break; } updateSpeedFromKeys(); } public void keyReleased(KeyEvent e) { // 类似keyPressed将对应方向设置为false updateSpeedFromKeys(); } private void updateSpeedFromKeys() { // 根据按键状态组合计算速度向量 int speedX 0, speedY 0; if (upPressed) speedY - baseSpeed; if (downPressed) speedY baseSpeed; if (leftPressed) speedX - baseSpeed; if (rightPressed) speedX baseSpeed; // 如果同时按了对角线方向速度会更快。为了保持各方向速度一致需要进行归一化。 if (speedX ! 0 speedY ! 0) { // 保持斜向移动速度与单向一致 double length Math.sqrt(speedX * speedX speedY * speedY); this.speedX (int)(baseSpeed * (speedX / length)); this.speedY (int)(baseSpeed * (speedY / length)); } else { this.speedX speedX; this.speedY speedY; } } Override public void move() { // 先调用父类或自己的位置更新 super.move(); // 玩家鱼的边界处理是钳制而不是反弹 this.x Math.max(size, Math.min(panelWidth - size, this.x)); this.y Math.max(size, Math.min(panelHeight - size, this.y)); } }对于AIFish我们可以实现一些简单的行为模式。比如最简单的“随机移动”模式每过一段时间或者碰到边界后随机生成一个新的速度方向。public class AIFish extends Fish { private int behaviorTimer 0; private static final int BEHAVIOR_INTERVAL 60; // 每60帧约1秒可能改变一次行为 Override public void move() { super.move(); // 先执行基础的移动和边界反弹 behaviorTimer; if (behaviorTimer BEHAVIOR_INTERVAL) { behaviorTimer 0; // 有一定概率改变方向 if (Math.random() 0.3) { // 随机生成一个方向角并转换为速度分量 double angle Math.random() * 2 * Math.PI; this.speedX (int)(speed * Math.cos(angle)); this.speedY (int)(speed * Math.sin(angle)); } } } }你可以扩展这个行为模式比如让小鱼更灵活速度变化快大鱼更笨重速度变化慢或者让AI鱼有一个很小的概率向玩家鱼方向移动模拟“捕食”或“逃避”行为增加游戏趣味性。4.4 碰撞响应与游戏逻辑整合在GamePanel的checkCollisions()方法中我们整合碰撞检测与游戏规则。private void checkCollisions() { // 检测玩家与所有AI鱼的碰撞 IteratorAIFish iterator aiFishes.iterator(); while (iterator.hasNext()) { AIFish aiFish iterator.next(); if (player.isCollidingWith(aiFish)) { if (player.canEat(aiFish)) { // 玩家吃掉AI鱼 player.grow(aiFish.getNutrition()); // grow方法增加玩家鱼大小 score aiFish.getScoreValue(); iterator.remove(); // 安全地从列表中移除被吃的鱼 // 可以在这里播放一个音效或粒子效果如果实现的话 } else if (aiFish.canEat(player)) { // AI鱼吃掉玩家或对玩家造成伤害 player.takeDamage(); if (player.getLife() 0) { gameOver(); } // 注意通常不会立即移除玩家鱼而是让它闪烁无敌状态或缩小 } // 如果体型相当可能发生碰撞但互不吃掉可以设计为弹开 } } // 可选检测AI鱼之间的相互碰撞让它们也会互相吞食使游戏世界更真实 for (int i 0; i aiFishes.size(); i) { for (int j i 1; j aiFishes.size(); j) { AIFish fish1 aiFishes.get(i); AIFish fish2 aiFishes.get(j); if (fish1.isCollidingWith(fish2)) { // 判断谁吃谁并更新列表 // 注意在遍历时修改列表要小心可以使用待删除列表或迭代器 } } } }grow和takeDamage方法会修改玩家鱼的状态并可能触发视觉反馈比如体型变化、颜色闪烁。游戏结束gameOver()方法会停止计时器并显示“Game Over”文字或界面。5. 性能优化与体验提升技巧5.1 渲染优化双缓冲与图像绘制直接使用Graphics在paintComponent中绘制当对象较多时可能会出现闪烁。这是因为Swing的绘制过程是先清除背景擦除上一帧再绘制当前帧。在清除和绘制的间隙用户可能会看到短暂的空白形成闪烁。解决方案是双缓冲Double Buffering。原理是在内存中创建一个离屏图像BufferedImage先将所有内容画到这个图像上然后在paintComponent中一次性将这个图像绘制到屏幕组件上。Swing的JPanel默认是启用双缓冲的通过setDoubleBuffered(true)但有时自定义绘制仍需优化。我们可以手动实现一个更可控的双缓冲public class GamePanel extends JPanel { private BufferedImage bufferImage; private Graphics2D bufferGraphics; Override protected void paintComponent(Graphics g) { super.paintComponent(g); if (bufferImage null || bufferImage.getWidth() ! getWidth() || bufferImage.getHeight() ! getHeight()) { // 如果缓冲区不存在或大小不匹配则创建新的 bufferImage new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB); bufferGraphics bufferImage.createGraphics(); // 为缓冲区Graphics设置抗锯齿 bufferGraphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); } // 1. 清空缓冲区用背景色填充 bufferGraphics.setColor(getBackground()); bufferGraphics.fillRect(0, 0, getWidth(), getHeight()); // 2. 将所有游戏对象绘制到缓冲区 player.draw(bufferGraphics); for (AIFish fish : aiFishes) { fish.draw(bufferGraphics); } drawGameInfo(bufferGraphics); // 3. 将缓冲区图像一次性绘制到屏幕 g.drawImage(bufferImage, 0, 0, null); } }这种方式几乎能完全消除闪烁。另一个优化点是使用图像精灵Sprite代替纯色绘制。与其用fillOval画一个圆不如为不同大小的鱼预先加载好图片。这会让游戏看起来更精美。你可以创建一个ImageResources类来集中加载和管理图片资源。5.2 游戏难度与成长曲线设计一个有趣的游戏需要有合理的难度曲线。在我们的项目中可以通过以下几个参数动态调整难度AI鱼生成速率和大小分布随着玩家分数增加可以生成更大比例的、体型接近玩家的大鱼减少小鱼的生成概率。玩家成长与速度关系一个常见的设定是玩家鱼变得越大移动速度会相应变慢更笨重。这增加了后期躲避大鱼的难度。你可以在PlayerFish的grow方法中修改其baseSpeed。特殊道具或事件比如定时生成一个“加速泡泡”吃了之后短时间内速度爆发或者“缩小药水”让玩家暂时变小以穿过狭窄区域或躲避追杀。这些都能极大丰富游戏玩法。5.3 音效与视觉反馈虽然核心是逻辑但适当的反馈能极大提升游戏体验。Swing中播放音效可以使用javax.sound.sampled.Clip。准备一些简短的WAV文件如吃鱼声、碰撞声、游戏结束声在相应事件触发时播放。public class SoundManager { private Clip eatSound; // ... 其他音效 public SoundManager() { // 加载音频文件 try { AudioInputStream ais AudioSystem.getAudioInputStream(new File(eat.wav)); eatSound AudioSystem.getClip(); eatSound.open(ais); } catch (Exception e) { e.printStackTrace(); // 音效加载失败不应影响主游戏可以静默处理 } } public void playEatSound() { if (eatSound ! null) { eatSound.setFramePosition(0); // 从头开始播放 eatSound.start(); } } }视觉反馈方面除了改变大小还可以在碰撞时让玩家鱼短暂闪烁通过交替绘制原色和透明色来实现或者在被吃掉时在AI鱼的位置绘制一个简单的粒子扩散效果用多个逐渐放大并淡出的小圆表示。6. 常见问题与排查技巧实录6.1 游戏卡顿与帧率不稳定问题描述游戏运行不流畅感觉一卡一卡的尤其是在鱼数量多的时候。排查与解决检查游戏循环耗时在updateGame方法开始和结束处记录时间戳计算耗时。如果一帧的更新逻辑超过了你设定的帧间隔如16.6ms for 60FPS就会掉帧。使用System.nanoTime()进行高精度测量。优化碰撞检测这是最常见的性能瓶颈。首先确保你没有进行不必要的碰撞检测比如已经移出屏幕的鱼。其次实现前面提到的空间划分优化。一个简单的网格法就能带来巨大提升。将屏幕划分为10x10的网格每帧更新每条鱼所在的网格只检测同一网格及相邻网格内的鱼。审视绘制代码在paintComponent中避免创建新的Color、Font等对象。应该将这些对象作为类的成员变量在初始化时创建好。另外确保只绘制可见区域内的对象视口裁剪。使用性能分析工具如果问题复杂可以使用JVisualVM随JDK提供进行CPU采样看看时间到底花在哪里了。6.2 键盘响应延迟或粘键问题描述按下键盘后鱼反应慢半拍或者松开键后鱼还在移动。排查与解决确保面板获得焦点JPanel必须setFocusable(true)并且在窗口激活时面板需要获得焦点。可以在GamePanel构造函数末尾调用requestFocusInWindow()。使用键位状态数组正如我之前在PlayerFish中实现的使用布尔数组记录按键状态在独立的updateSpeedFromKeys方法中计算速度。这比在keyPressed事件中直接修改位置更平滑也能更好地处理多个键同时按下的情况。Timer的延迟问题javax.swing.Timer的触发精度依赖于系统事件队列在负载高时可能不精确。对于要求更高的动作游戏可以考虑使用游戏循环专用计时模式如固定时间步长Fixed Timestep循环但这在Java Swing中实现稍复杂。对于本项目Timer通常足够。6.3 碰撞检测不准确或“穿模”问题描述两条鱼看起来没有接触却判定为碰撞或者高速移动时鱼直接穿过了对方。排查与解决检查碰撞检测频率如果鱼的移动速度每帧位移大于其自身半径就可能从对方“头上”飞过去中间帧没有检测到碰撞。解决方法是进行连续碰撞检测CCD。一个简化的实现是不仅检测当前帧的位置还检测从上一帧到这一帧的移动线段是否与对方鱼的圆形区域相交。这对于高速移动的物体是必要的。验证碰撞判定公式仔细检查isCollidingWith方法中的距离计算确保没有符号错误或类型转换问题比如int和double混用导致精度丢失。绘制调试信息在开发阶段可以在每条鱼周围绘制其碰撞边界如一个圆圈。这样能直观地看到碰撞框是否和视觉表现匹配。确认size属性代表的是半径还是直径在绘制和碰撞计算中要统一。6.4 内存泄漏AI鱼数量无限增长问题描述游戏运行一段时间后越来越卡最终可能内存溢出。排查与解决确保及时移除对象在checkCollisions和updateGame方法中被吃掉的鱼、游出边界外的鱼必须从aiFishes列表中移除。使用Iterator.remove()是安全的做法。限制AI鱼总数设置一个合理的MAX_AI_FISH_COUNT并在generateAIFish方法中检查当前数量。检查资源释放如果使用了自定义的图像资源在游戏结束或对象销毁时考虑将其引用置为null帮助垃圾回收。对于Clip音效播放完毕后可以调用clip.close()。6.5 游戏平衡性调整问题描述游戏太难或太简单玩家很快失去兴趣。解决思路将游戏中的关键参数设计为可配置的甚至做成一个简单的配置文件。例如PLAYER_BASE_SPEEDPLAYER_GROWTH_FACTOR每吃一条鱼长大多少AI_SPAWN_RATEAI_SIZE_MIN/MAXEAT_SIZE_RATIO能吃对方所需的大小比例 在游戏运行时可以创建一个调试面板用JSlider来实时调整这些参数快速找到最佳的游戏体验点。这是游戏设计中的“数据驱动”思维非常实用。