ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java大鱼吃小鱼项目实战:从面向对象到多线程的完整实现

Java大鱼吃小鱼项目实战:从面向对象到多线程的完整实现

简介:这是一份基于Java Swing实现的「大鱼吃小鱼」游戏完整源码包,面向Java初学者与游戏开发入门者,帮助读者通过一个可运行的小项目理解GUI编程、事件监听与动画渲染的完整流程。压缩包共1171个文件,约61.41MB,以715张png、27个gif、20张jpg等图片素材为主,配合5个java源文件、13个class字节码、14个xml配置及若干properties、project工程文件,覆盖游戏窗口搭建、鼠标事件处理、Graphics绘图、定时器重绘与状态管理等核心模块。代码结构清晰、注释详尽,便于对照学习JFrame与JPanel的协作方式,也可在此基础上扩展鱼类种类、障碍物与关卡设计。目前已有1454人学习下载,适合作为课程设计、期末作业或二次开发的参考素材。

1. 从「java大鱼吃小鱼.zip」说起:一个被低估的 Java 练手项目

很多人第一次看到「java大鱼吃小鱼.zip」这个标题,会以为它只是某个 Java 课程里随堂发的一个小游戏压缩包,解压、双击、跑起来、看鱼游两下,然后关掉。但如果你正在补 Java 基础、准备面试、或者想找一个能把面向对象、集合、多线程、Swing 绘图串起来的练手项目,这个包其实是个被严重低估的入口。它天然覆盖了 Java 基础里最容易考、也最容易忘的那几条线:类的继承与多态、ArrayList 与迭代器的增删、Timer 与线程的协作、Graphics2D 的坐标变换。热搜里「java基础」「面向对象编程java」「java学习路线」反复出现,说明大量人卡在「语法都会、项目不会写」这一步,而大鱼吃小鱼正好是那种代码量不大、但每一行都能对应到一个知识点的项目。这篇笔记不假设你手上已经看过源码,只按这个标题最常见的实现方式,把选型、结构、参数、坑和验证方法讲清楚,让你能自己从零复现一版,也能看懂别人给的版本。

2. 拆解大鱼吃小鱼的核心对象模型:为什么先定类再写逻辑

2.1 从「鱼」这个类开始,把继承关系一次定对

绝大多数大鱼吃小鱼翻车,不是翻在绘图,而是翻在类没设计好。常见做法是先抽象一个GameObject基类,放坐标、宽高、速度、存活状态,再让Fish继承它,玩家鱼和敌方鱼再各自继承Fish。这样做的理由很直接:碰撞检测、边界判断、绘制循环都只认基类,后面加新鱼种不用改主循环。下面这段是最小可用的骨架,字段名我按自己习惯来,你可以直接抄。

// 所有可见对象的基类,只放位置、尺寸和存活标记 public abstract class GameObject { protected int x, y; // 左上角坐标,Swing 绘图默认以左上为原点 protected int width, height; // 碰撞盒尺寸,不等于图片实际像素 protected boolean alive = true; public abstract void update(); // 每帧更新位置或状态 public abstract void draw(Graphics2D g); // 每帧绘制自己 // 轴对齐矩形碰撞,够用且比像素级检测快一个量级 public boolean intersects(GameObject other) { return x < other.x + other.width && x + width > other.x && y < other.y + other.height && y + height > other.y; } }

逻辑说明:update()和draw()声明为抽象,是为了强制每个子类实现自己的行为,主循环里只持有List<GameObject>,遍历调用即可。参数说明:x、y用 int 而不是 float,是因为 Swing 的drawImage最终要整数坐标,用 float 反而要在绘制时反复强转;width、height单独存,是为了让碰撞盒可以比图片小一圈,手感更准。这里有个血泪经验:很多人直接把图片宽高当碰撞盒,结果鱼明明没碰到就判定吃掉了,玩家会觉得「玄学」。把碰撞盒缩到图片的 70% 左右,体验立刻正常。

2.2 玩家鱼与敌方鱼的差异,用多态而不是 if-else

玩家鱼和敌方鱼的区别只有三点:谁控制移动、谁吃谁、被吃后怎么处理。如果写成if (type == PLAYER)到处判断,代码会迅速腐烂。正确做法是让PlayerFish和EnemyFish各自重写update(),主循环完全不关心类型。

public class PlayerFish extends Fish { private int score = 0; @Override public void update() { // 位置由键盘监听器直接改,这里只做边界收敛 x = Math.max(0, Math.min(x, 800 - width)); y = Math.max(0, Math.min(y, 600 - height)); } // 玩家吃鱼:只有比对方大才判定成功 public boolean eat(EnemyFish enemy) { if (this.width > enemy.width && this.intersects(enemy)) { this.width += 2; // 每吃一条长大一点 this.height += 2; score += 10; return true; } return false; } }

逻辑说明:eat方法把「比对方大」和「碰到」两个条件收在一起,调用方只需要if (player.eat(enemy))。参数说明:width += 2是成长步长,调太大会让玩家几秒内无敌,调太小又没成就感,我一般从 2 起步,配合初始宽度 40 左右。score += 10是分值,和后续排行榜挂钩。注意这里没有用instanceof,因为主循环里玩家鱼和敌方鱼本来就分开存,多态体现在update()的调用上,而不是类型判断上。

2.3 用 ArrayList 还是 LinkedList:增删场景下的真实选择

大鱼吃小鱼每帧都可能删除被吃掉的鱼、生成新鱼,集合选型直接影响卡顿。热搜里「java排序」「java array concat」这类词说明很多人对集合操作不熟。实测结论:鱼的数量在几百条以内时,ArrayList的随机访问优势更明显,删除用倒序遍历加Iterator.remove()即可,不要用LinkedList,因为它的节点分配在频繁增删时反而更慢,而且缓存不友好。

// 倒序遍历删除,避免 ConcurrentModificationException Iterator<EnemyFish> it = enemies.iterator(); while (it.hasNext()) { EnemyFish e = it.next(); if (player.eat(e)) { it.remove(); // 唯一安全的删除方式 } }

逻辑说明:正序遍历时删除元素会导致后续元素前移、索引错位,漏掉一条鱼;用Iterator.remove()是集合框架规定的安全删除路径。参数说明:enemies建议初始容量给 64,避免反复扩容。如果鱼的数量真的上千,再考虑分帧更新,而不是换集合类型。

3. 把游戏循环跑起来:Swing 定时器、双缓冲与帧率控制

3.1 用 javax.swing.Timer 还是自己开线程

这是新手最容易纠结的点。常见做法是用javax.swing.Timer,因为它的事件回调在 EDT(事件调度线程)上执行,和 Swing 绘图天然同步,不会出现画面撕裂或并发修改异常。自己开Thread加repaint()看似灵活,但必须手动SwingUtilities.invokeLater,否则就是随机崩溃。下面是最小主循环。

public class GamePanel extends JPanel implements ActionListener { private final Timer timer; private final PlayerFish player; private final List<EnemyFish> enemies = new ArrayList<>(); public GamePanel() { setPreferredSize(new Dimension(800, 600)); setBackground(Color.CYAN); player = new PlayerFish(); // 16 毫秒约等于 60 帧,实际受系统调度影响 timer = new Timer(16, this); timer.start(); } @Override public void actionPerformed(ActionEvent e) { updateGame(); // 先更新逻辑 repaint(); // 再请求重绘 } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 必须调用,否则残影 Graphics2D g2 = (Graphics2D) g; // 开启抗锯齿,鱼边缘不会锯齿状 g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); player.draw(g2); for (EnemyFish enemy : enemies) { enemy.draw(g2); } } }

逻辑说明:actionPerformed里先updateGame()再repaint(),保证逻辑和画面一致;paintComponent第一行必须super.paintComponent(g),否则上一帧的鱼会叠在画面上,看起来像「残影」。参数说明:Timer(16, this)的 16 是毫秒,想稳 60 帧可以设 16,想省 CPU 设 33 也行;setRenderingHint开抗锯齿会略微增加绘制开销,鱼少时无感,鱼多时可以关掉换帧率。

3.2 双缓冲:为什么你的鱼会闪

Swing 的JPanel默认是双缓冲的,但如果你在paint里直接画到组件上,或者用了Canvas,就会闪。判断方法很简单:鱼移动时边缘出现白色撕裂,就是没走双缓冲。解决方式有两种,一是坚持用JPanel并重写paintComponent,二是手动BufferedImage离屏绘制。我一般用前者,代码少。

// 手动双缓冲的写法,只在必须用 Canvas 时使用 BufferedImage buffer = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB); Graphics2D bg = buffer.createGraphics(); // ... 所有绘制画到 bg 上 g.drawImage(buffer, 0, 0, null); bg.dispose(); // 必须释放,否则内存泄漏

逻辑说明:离屏图像先承接全部绘制,再一次性贴到屏幕,消除中间态。参数说明:TYPE_INT_ARGB支持透明通道,鱼图片有透明背景时必须用它;bg.dispose()不调用会累积图形上下文,跑几分钟就 OOM,这是热搜里「java.lang.OutOfMemoryError」的常见来源之一。

3.3 帧率与移动速度解耦:别让鱼速跟机器性能挂钩

很多人把速度写成「每帧移动 5 像素」,结果高刷屏上鱼飞一样,老机器上鱼爬一样。正确做法是记录两次更新的时间差,用「像素/秒」定义速度。

private long lastUpdate = System.currentTimeMillis(); private void updateGame() { long now = System.currentTimeMillis(); float delta = (now - lastUpdate) / 1000f; // 转成秒 lastUpdate = now; // 限制单帧最大步长,防止窗口拖动后鱼瞬移 delta = Math.min(delta, 0.05f); for (EnemyFish enemy : enemies) { enemy.x += enemy.speedX * delta; } }

逻辑说明:delta是距上一帧的秒数,速度乘它得到本帧位移,帧率高低不影响实际速度。参数说明:Math.min(delta, 0.05f)是后悔药,窗口被拖动或系统卡顿时delta会突然很大,不限制的话鱼会瞬移穿过玩家,碰撞直接失效。0.05 秒对应 20 帧,低于这个帧率游戏本来就该卡,限制一下反而更稳。

4. 碰撞、成长与难度曲线:参数怎么调才不劝退

4.1 碰撞检测的三种写法与取舍

矩形碰撞、圆形碰撞、像素级碰撞,精度依次提高,开销也依次提高。大鱼吃小鱼里鱼是椭圆形的,用圆形碰撞比矩形更贴合,但计算开方有开销。折中做法是比较距离的平方,省掉Math.sqrt。

// 圆形碰撞:比较圆心距离平方与半径和平方 public boolean circleHit(GameObject a, GameObject b) { int ax = a.x + a.width / 2, ay = a.y + a.height / 2; int bx = b.x + b.width / 2, by = b.y + b.height / 2; int dx = ax - bx, dy = ay - by; int r = (a.width + b.width) / 4; // 取平均半径 return dx * dx + dy * dy < r * r; }

逻辑说明:用平方比较避免开方,浮点运算变整数运算,几百条鱼时差距明显。参数说明:r取两个宽度平均值的四分之一,是因为宽度约等于直径,半径是宽度一半,两个半径和再除以二。这个系数可以微调,调大判定更宽松,调小更严格。

4.2 成长曲线:吃几条鱼才能反杀

难度曲线全靠两个参数:玩家初始大小和敌方鱼大小分布。常见翻车是玩家初始太小,前 30 秒只能逃,体验极差。我一般让玩家初始宽度 40,敌方鱼按 20、30、50、70 四档生成,比例 4:3:2:1,保证开局就有能吃的。

敌方鱼档位宽度生成权重移动速度(像素/秒)
小鱼2040%60
中鱼3030%90
大鱼5020%120
巨鱼7010%150

逻辑说明:权重决定生成概率,速度随体型增大而提高,制造「大的又快又危险」的压迫感。参数说明:速度单位是像素/秒,配合上一节的delta使用。如果玩家反馈太难,先把巨鱼权重降到 5%,再降速度,不要直接改玩家初始大小,否则整个数值体系要重调。

4.3 生成节奏与屏幕密度控制

鱼不能无限生成,否则内存和绘制都扛不住。常见做法是设一个上限,比如 40 条,低于上限时按固定间隔补充。

private static final int MAX_ENEMIES = 40; private long lastSpawn = 0; private void trySpawn(long now) { if (enemies.size() >= MAX_ENEMIES) return; if (now - lastSpawn < 500) return; // 每 500 毫秒最多生成一条 lastSpawn = now; enemies.add(EnemyFish.randomSpawn(800, 600)); }

逻辑说明:上限控制内存和绘制量,间隔控制生成节奏,两者缺一不可。参数说明:MAX_ENEMIES在 800x600 窗口下 40 条比较合适,窗口更大可以按面积比例放大;500毫秒是生成间隔,调小会让屏幕很快塞满,调大会让玩家觉得空。

5. 避坑与排查:大鱼吃小鱼最常见的 5 个翻车现场

5.1 现象:鱼吃了不消失,反而越吃越多

原因:在for-each循环里直接enemies.remove(enemy),触发ConcurrentModificationException被 catch 吞掉,或者删除的是副本。解决:统一用Iterator.remove(),并且确保删除的是同一个对象引用,不要用equals重写不当的类。

5.2 现象:窗口最小化再恢复,所有鱼瞬移重叠

原因:Timer在窗口不可见时仍在跑,delta累积成巨大值。解决:在updateGame里限制delta上限,或者在窗口windowIconified时暂停Timer,恢复时重置lastUpdate。

5.3 现象:图片加载失败,鱼变成黑块或直接 NPE

原因:用ImageIO.read(new File("fish.png"))读相对路径,打包成 jar 后路径失效。解决:改用getClass().getResourceAsStream("/images/fish.png"),资源放在src/main/resources下。这是热搜里「java项目打成tar包」「java启动失败怎么解决」的高频原因。

5.4 现象:玩几分钟后越来越卡,最后 OOM

原因:每帧new出新的Graphics2D或BufferedImage没dispose(),或者敌方鱼被移除后仍被其他集合引用。解决:图形上下文用完即dispose(),移除的鱼确保没有其他强引用,必要时用WeakReference或直接置空。

5.5 现象:键盘按了没反应,或者方向键触发按钮焦点

原因:JPanel没有setFocusable(true),或者焦点被其他组件抢走。解决:面板构造里setFocusable(true)并requestFocusInWindow(),键盘监听用KeyAdapter绑在面板上,不要绑在JFrame上。

6. 进阶技巧:把大鱼吃小鱼改成能写进简历的项目

如果你只想练手,前面五章已经够跑通一版。但如果你想让这个项目在面试里能讲,得再加一层。我一般会做三件事:第一,把游戏状态抽成GameState枚举,加暂停、结束、重开,这样能聊状态机;第二,把敌方鱼生成策略抽成接口SpawnStrategy,实现随机生成和波次生成两种,这样能聊策略模式;第三,加一个简单的分数持久化,用Properties或 JSON 存本地,这样能聊 IO 和序列化。下面是一个波次生成的骨架。

public interface SpawnStrategy { List<EnemyFish> spawn(int wave, int screenW, int screenH); } public class WaveSpawnStrategy implements SpawnStrategy { @Override public List<EnemyFish> spawn(int wave, int screenW, int screenH) { List<EnemyFish> list = new ArrayList<>(); int count = 5 + wave * 2; // 波次越高鱼越多 for (int i = 0; i < count; i++) { // 波次越高,大鱼出现概率越大 int size = Math.random() < 0.1 * wave ? 70 : 30; list.add(new EnemyFish(size, screenW, screenH)); } return list; } }

逻辑说明:接口把「生成什么鱼」和「怎么生成」解耦,主循环只依赖接口,换策略不改主循环。参数说明:5 + wave * 2是数量公式,0.1 * wave是大鱼概率,wave 超过 10 后概率封顶到 1.0,避免全是巨鱼。验证方法很简单:把WaveSpawnStrategy和随机策略都注入,跑十局看分数分布,如果波次策略的分数方差明显更小,说明难度曲线更可控。

最后说个我自己的习惯:每改一次参数,先把delta上限、MAX_ENEMIES、玩家初始宽度这三个值记在便签上,因为 90% 的手感问题都出在这三个数。别一上来就重写架构,先调参,调不动再动结构。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表