简介:这份《java大鱼吃小鱼.zip》是一套基于Java Swing开发的经典小游戏完整源码与学习项目,主要面向Java初学者、在校学生以及刚接触GUI编程的开发者,可用于理解游戏窗口搭建、事件监听、图像渲染与动画刷新的实际落地方式。压缩包共1171个文件,整体约61MB,包含大量png图片素材、gif动效、java源文件、class编译文件以及project、xml等工程配置,其中图片资源主要用于鱼类角色、海洋背景与UI界面,java与class文件则对应完整的游戏逻辑实现和可直接运行的程序结构,目录层级较多,适合边查阅边对照学习。目前已有1454人学习下载,说明该示例在此类入门游戏开发内容中具备一定参考价值。通过这个项目,学习者可以掌握JFrame与JPanel构建游戏窗口、MouseListener与MouseMotionListener处理鼠标操控、Timer与repaint实现动画刷新的核心思路,也能从清晰注释中了解图像资源管理和面向对象扩展方法,便于后续在此基础上增加关卡、鱼种或障碍物做二次开发。
1. 为什么一个Java课设级别的“大鱼吃小鱼”包值得你认真跑一遍
如果你搜过“java大鱼吃小鱼.zip”,大概率是以下几种情况之一:期末课程设计要交一个Java桌面游戏、想找个现成项目学Swing事件处理和碰撞检测、或者准备面试时往简历里塞一个“完整可运行项目”。这个zip通常不大,里面是几百行Java源码加一个资源文件夹,但恰恰是这种“看起来简单”的项目,最容易让新手在解压后的前十分钟里反复翻车——不是编译报错,就是运行后窗口黑屏、鱼不动、按方向键没反应、甚至计分直接负数。这篇笔记不是帮你按图索骥地抄代码,而是按我平时拿到一个陌生Java小游戏源码包时的完整梳理路径:先看类结构,再找游戏循环,然后确认碰撞判定和绘制时机,最后把所有“复制粘贴完却跑不对”的坑提前踩一遍。适合J2SE基础刚过关、想从“看代码”跨到“改代码”的读者;如果你已经能独立写完一个Swing小应用,这文章能帮你把这个demo改造成能拿得出手的作品。先记住一点:这类项目成败的关键,从来不是图片素材好不好看,而是游戏循环的刷新逻辑和碰撞判定是不是写在正确的组件回调里。
2. 从zip到能跑起来的第一个窗口:先读懂这套项目的骨架
2.1 解压后先别双击运行,先花五分钟认清三个核心类
我拿到任何一个“xx游戏.zip”,第一件事不是运行,而是解压后直接看src目录下的类文件列表。java大鱼吃小鱼这个项目的源码结构大同小异,通常由三部分组成:启动入口类、游戏场景面板类、鱼对象类(有时还有独立的食物类,但更常见的是把大鱼、小鱼统一抽象成一个Fish对象,只用大小和速度区分)。很多同学一解压就找jar包或者README去双击,结果要么提示找不到主类,要么弹个黑窗口瞬间闪退——原因几乎都是没搞清入口类。
如果你手上这份源码的包名是类似com.game.fish这种,启动类一般长这样:
public class GameMain { public static void main(String[] args) { JFrame frame = new JFrame("大鱼吃小鱼"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); GamePanel panel = new GamePanel(); frame.add(panel); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); } }逻辑说明:这段代码做的事情只有四件——创建窗口、指定关闭行为、把游戏面板塞进窗口、显示出来。pack()是根据面板的preferredSize自动调整窗口大小,所以如果你改了面板尺寸却没重新setPreferredSize,窗口会按旧尺寸显示。注意这里没有setSize,这是Swing项目的常见约定。
参数说明:setResizable(false)是这类游戏的标准做法,防止用户拖拽窗口导致游戏区域尺寸变化、碰撞判定错位;setLocationRelativeTo(null)让窗口居中。如果你在别的机器上运行报“找不到主类”,先在IDE里手动配置Main Class为这个GameMain,而不是依赖IDE自动识别。
2.2 游戏窗口“动起来”的真相:Timer驱动下的线程模型
接下来是最容易让新手误解的地方——鱼为什么会自己游?方向键为什么能控制大鱼?答案不在main方法里,而在GamePanel这个继承JPanel的类中。这个类同时干了三件事:加载图片、启动游戏循环、响应键盘事件。很多改造失败的案例,都是想当然地开了一个Thread跑while(true)循环刷新,结果画面疯狂闪烁或者直接卡死UI线程——因为Swing的单线程模型规定,所有UI刷新必须发生在事件分发线程(EDT)上。
这类项目最稳妥的实现,是使用javax.swing.Timer来做游戏循环,而不是Thread:
public class GamePanel extends JPanel implements ActionListener, KeyListener { private Timer timer; private Fish playerFish; private List<Fish> fishList; private int score; public GamePanel() { setPreferredSize(new Dimension(800, 600)); setFocusable(true); addKeyListener(this); // 每隔50毫秒刷新一次,即每秒20帧 timer = new Timer(50, this); timer.start(); initGameData(); } @Override public void actionPerformed(ActionEvent e) { // 每一帧做的事:更新所有鱼的位置、检查碰撞、刷新画面 updateFishPosition(); checkCollision(); repaint(); } }逻辑说明:Timer的构造参数里,第一个数字是间隔毫秒数,第二个是监听器。它每次触发都会回调actionPerformed方法,这个方法里更新完所有游戏数据后调用repaint()——注意是请求重绘,而不是直接调用paintComponent,Swing会在合适的时机自动重绘。这样写的好处是天然线程安全,不需要处理同步锁,也完全避免了Thread方案里那个经典的“repaint在非EDT线程调用导致偶发卡死”的坑。
参数说明:50毫秒是这类休闲游戏最常见的刷新间隔。如果你想改成30帧,就写成33毫秒;想降低CPU占用改成15帧,就写成66毫秒。但低于30帧时鱼的运动会明显一卡一卡,高于30帧对这个游戏没有任何视觉收益,反而占用CPU。我个人习惯用33到50之间,具体看目标机器性能。
2.3 鱼游动的本质:坐标累加与边界反弹
这个项目里所有鱼(无论玩家控制的大鱼还是游来游去的小鱼),运动模型都是同一个:每帧在x和y方向上加上一个速度向量。区别只在于大鱼的速度由键盘改变,小鱼的速度是随机给的。理解了这个模型,你就能解释很多诡异现象——为什么鱼会越游越快?为什么鱼会卡在窗口边缘抖动?为什么某条鱼突然斜着飞走了?
public void move(int panelWidth, int panelHeight) { x += speedX; y += speedY; // 碰到左右边界反弹 if (x < 0) { x = 0; speedX = Math.abs(speedX); } else if (x > panelWidth - width) { x = panelWidth - width; speedX = -Math.abs(speedX); } // 碰到上下边界反弹,逻辑同上 if (y < 0) { y = 0; speedY = Math.abs(speedY); } else if (y > panelHeight - height) { y = panelHeight - height; speedY = -Math.abs(speedY); } }逻辑说明:先移动坐标,再检查是否越界。越界后不是简单地把坐标拉回边界值就完事,还必须反转速度方向——否则下一帧又会继续越界,鱼就卡在边界上抖动。这段代码里Math.abs的作用是确保反弹方向正确,这是最容易漏掉的一行。
参数说明:panelWidth - width减的是鱼的图片宽度,不是鱼的“质量”或“半径”。这样鱼的右边缘刚好停在窗口右边缘。如果你发现鱼半个身体穿出窗口,先检查是不是忘了减width,或者拿碰撞用的圆形半径当了矩形宽高去减。这在图片边缘透明时特别容易看走眼。
3. 核心玩法“吃鱼”的落地:图片加载、碰撞判定与成长机制
3.1 图片加载的两种姿势:getResource和ImageIO,别用File路径
java大鱼吃小鱼这个项目里,图片素材一般放在src同级的res目录或者源码包下。很多新手解压后直接把源码拷到自己的工程里,结果图片加载全部报空指针——根本原因是用了new File("fish.png")这种相对路径,而IDE的执行目录和你以为的目录不一样。这类游戏项目里,图片加载的可靠写法只有一种主流组合。
private Image loadImage(String path) { try { URL url = getClass().getResource(path); if (url == null) { throw new RuntimeException("图片资源不存在: " + path); } return new ImageIcon(url).getImage(); } catch (Exception e) { e.printStackTrace(); return null; } }逻辑说明:用getClass().getResource()加载资源,而不是File路径,核心优势在于——不管你从IDE启动还是打成jar包运行,资源都能被正确找到,因为它是从类路径去定位的。url == null的检查必须写,否则ImageIcon构造出一个空图,后续绘制时直接静默失败,画面里就少一条鱼。
参数说明:path的写法有讲究:如果图片放在源码包com/game/fish/res目录下,路径要写成"/com/game/fish/res/fish_big.png";如果放在src根目录下的res文件夹,就写"/res/fish_big.png"。最常见的坑是用了不带开头的相对路径"fish.png",这时getResource默认从当前类所在包目录找,找不到就返回null。我见过至少五个项目翻车在这一行上。
3.2 碰撞判定的工程写法:矩形判定太粗糙,圆心判定刚刚好
“吃鱼”的判定逻辑很简单:大鱼碰到小鱼就吃掉、分数增加、大鱼变大一点;碰到更大的鱼则自己死亡(或者游戏结束)。判定方式直接影响手感——用getBounds().intersects()做矩形碰撞的话,鱼图片往往有透明边角,两条鱼还没真正碰头就被判成碰撞了,玩家会觉得游戏不公平。这个项目的成熟做法是基于圆形的距离判定。
private boolean checkHit(Fish f1, Fish f2) { // 圆心距离小于两个半径之和,视为碰撞 int centerX1 = f1.getX() + f1.getWidth() / 2; int centerY1 = f1.getY() + f1.getHeight() / 2; int centerX2 = f2.getX() + f2.getWidth() / 2; int centerY2 = f2.getY() + f2.getHeight() / 2; // 用平方距离比较,避免开根号的开销 int radiusSum = f1.getWidth() / 2 + f2.getWidth() / 2; int dx = centerX1 - centerX2; int dy = centerY1 - centerY2; return (dx * dx + dy * dy) <= (radiusSum * radiusSum); }逻辑说明:圆心坐标用图片宽高的一半近似。用平方距离和半径平方和比较,省掉了Math.sqrt,虽然这个项目里每帧碰撞检测的对象数量可能不到50个,这点性能差异可以忽略,但写习惯了总没坏处。注意半径不是“图片宽度的一半”而是“视觉上鱼的半径”,如果你的大鱼图片是长方形,用宽的一半会导致上下方向判定过松、左右方向判定过紧。更优的做法是图片里鱼占的区域单独抠一个圆的半径常量,但那种精调一般课设用不上。
参数说明:碰撞判定最好放在鱼的列表更新之后、repaint之前。每帧检查两次:玩家鱼吃列表里的小鱼,和玩家鱼被大鱼吃。如果你发现“明明图片重叠了却不判定碰撞”,先检查是不是拿错了参数——比如把f1.getWidth()/2写成了getX()/2。
3.3 成长的参数设计:size增加曲线决定了游戏节奏
吃了鱼之后的大鱼变大,如果直接每次size += 5,画面会出现一个几乎肉眼可见的“跳变”,观感很差。这类项目里,更合理的做法是按鱼的“质量”累计,再映射到尺寸。
public void eat(Fish smallFish) { // 按比例增长:吃得越多,需要的量越大 weight += smallFish.getWeight(); int newWidth = (int) Math.sqrt(weight) * 10; if (newWidth > maxWidth) { newWidth = maxWidth; } setSize(newWidth, newWidth * height / width); }逻辑说明:用sqrt(weight)再乘系数,本质是让“面积”随重量线性增长。大鱼吃小鱼后,吃同等重量的小鱼,尺寸增加会越来越不明显——这正好符合大鱼吃小鱼这个游戏里“前期快速成长、后期稳定尺寸”的手感。如果图省事直接加宽度,你会发现玩家玩到后期大鱼大得离谱,碰撞半径几乎覆盖半个屏幕,游戏失去挑战性。
参数说明:maxWidth是硬性上限,防止尺寸无限制增长导致显示越界或碰撞逻辑异常。height / width是等比例缩放,保持图片不变形。这里有个细节:很多源码包里的Fish类没用weight这个概念,而是直接size++,导致“吃一条小鱼变大1像素”,玩家毫无体感。如果你拿到的是这种版本,可以把size改成按当前size的一定比例增长,比如size = (int)(size * 1.02f) + 1,手感会好很多。
4. 大鱼吃小鱼常见问题排查:四大经典翻车现场与修复方案
4.1 画面一卡一卡或者闪烁,甚至黑屏只剩背景色
现象:运行后窗口出来了,背景色也有,但鱼看不到或者整个画面剧烈闪烁、残影严重。原因有两种:一是你没有重写paintComponent而是写了paint方法,导致Swing的绘制管线被破坏;二是你在用Thread手动循环里频繁调用new ImageIcon重新加载图片。解决方法是:确认方法签名是protected void paintComponent(Graphics g),并且在方法第一行调用super.paintComponent(g)先清空画布;图片实例提到字段里加载一次,不要放到绘制方法里。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 绘制背景 g.drawImage(bgImage, 0, 0, null); // 绘制所有鱼 if (playerFish != null) { playerFish.draw(g); } for (Fish fish : fishList) { fish.draw(g); } // 绘制分数是可选,但建议用drawString而不是覆盖文本组件 g.setColor(Color.WHITE); g.setFont(new Font("微软雅黑", Font.BOLD, 20)); g.drawString("分数: " + score, 10, 30); }逻辑说明:super.paintComponent(g)是必须的一行——它负责用背景色清空之前绘制的内容。没有它,你这次画的鱼会残留到下一帧,形成拖影。绘制顺序决定图层:先背景、再鱼、最后UI文本。顺序反了的话,背景会盖住鱼。这是不少改光源代码的常见误会。
参数说明:null作为最后参数是告诉ImageObserver不需要异步通知。绘制分数用drawString而不是在面板上add一个JLabel,因为每帧位置变化频繁,组件重绘开销远大于drawString。
4.2 按方向键没反应,或者鱼只朝一个方向死走
现象:窗口能打开、鱼也在游,但键盘怎么按大鱼都不动,或者按了之后松手鱼依然继续朝那个方向走。第一个情况九成是忘了加setFocusable(true)和addKeyListener(this)——JPanel默认不抢焦点,按键事件根本没有传给面板。第二个情况是很多初学者的经典设计错误:把按键处理写成keyPressed里设speedX,但没在keyReleased里把速度清零,导致松手后速度保持,鱼自然不刹车。
@Override public void keyPressed(KeyEvent e) { int key = e.getKeyCode(); // 用boolean标记状态,而不是直接修改speedX if (key == KeyEvent.VK_LEFT) { upPressed = true; } else if (key == KeyEvent.VK_RIGHT) { downPressed = true; } // 上下方向同理 } @Override public void keyReleased(KeyEvent e) { int key = e.getKeyCode(); if (key == KeyEvent.VK_LEFT) { upPressed = false; } else if (key == KeyEvent.VK_RIGHT) { downPressed = false; } // 上下方向同理 }逻辑说明:用键位布尔标记法而不是直接改速度,好处有两点:一是支持多键同时按下不冲突,二是松手响应自然。速度更新在每帧开始时统一计算,比如speedX = (rightPressed ? 5 : 0) - (leftPressed ? 5 : 0)。如果直接用keyPressed里改speedX,在多键同时按下时会互相覆盖,而且释放其中一个键时无法判断另一个是否还按着。
参数说明:如果你拿到的源码里只有keyPressed没有keyReleased,那按方向键就会越走越快——因为每按一次都叠加一个速度增量。修复方法是改成我上面这种状态标记,或者把speedX的赋值改成if (e.getKeyCode() == KeyEvent.VK_LEFT) { speedX = -5; },同时keyReleased里把speedX置0。推荐第一种,扩展性更好。
4.3 吃到小鱼后分数变负数或者不涨
现象:明明大鱼碰到小鱼了,画面也判断吃掉了,但分数突然变成负数,或者连续吃好几条分都不变。这个坑的本质是计分逻辑里用了同一份“分数”数据,但鱼列表中每条鱼带着可变的“分值”。常见错误写法是在Fish类里定义一个score字段,Field会随着鱼被吃掉后的状态改变而改变;更隐蔽的是:你在碰撞去除列表中鱼的循环里下标管理错误,导致同一条鱼被判定两次,每次扣分。建议的做法是把分值独立成单独常量映射。
private int getScoreForFish(Fish fish) { // 按鱼的重量区间给分,体现“风险越大收益越大” int weight = fish.getWeight(); if (weight < 10) { return 5; } else if (weight < 20) { return 10; } else if (weight < 50) { return 20; } return 50; }逻辑说明:吃小鱼得5分,吃中鱼得10分,吃接近自己尺寸的鱼得50分——这样游戏才有策略:是贪心吃大的一步登天,还是稳妥吃小的积少成多。计分时用局部变量处理,而不是改动Fish内部的字段,避免碰撞判定里重复吃同一只鱼导致分数异常。
参数说明:分值的阈值要与玩家鱼的成长曲线匹配。如果玩家鱼成长很快,那么weight<10的区间很快就会吃光,只吃得到中鱼,那么整个游戏的计分区间分布就不合理。我一般会在GamePanel里用常量数组定义,方便调整平衡。
4.4 游戏一开始就“游戏结束”,或者大鱼直接出现在小鱼群里
现象:点开始游戏,还没看清楚画面就弹Game Over,或者出生点周围全是能吃掉玩家的大鱼。这类问题是典型的随机初始化范围没控制好。出生点如果完全随机,玩家鱼有可能刷在一条大鱼的碰撞半径内,第一帧碰撞检测直接判死亡。另一个常见问题是,初始化生成小鱼时,数量太多或大小范围没约束,导致满屏都是比自己大的鱼,根本没有操作空间。
private void initGameData() { playerFish = new Fish(400, 300, 60); fishList = new ArrayList<>(); Random rand = new Random(); for (int i = 0; i < 8; i++) { int x = rand.nextInt(700) + 50; // 避开窗口边缘 int y = rand.nextInt(500) + 50; int size = 15 + rand.nextInt(30); // 15~45,保证大多数小于玩家 fishList.add(new Fish(x, y, size)); } }逻辑说明:玩家鱼固定出生在屏幕中央偏左位置,周围50像素内不生成其他鱼。生成的NPC鱼大小范围是15到45,玩家初始大小是60,这样大多数NPC都是可吃的,偶尔出现一两条45的,勉强能吃但要追一会儿。避免“出生即死亡”的关键是把size = 15 + rand.nextInt(30)的范围上限设成小于玩家初始大小。
参数说明:rand.nextInt(700)+50是让x落在50到750之间,避免贴边。如果你想调低难度让新手入门舒服点,把NPC大小改成10到40就行;想挑战就改成10到60,你会碰到一条45的鱼追你半屏的体验。别忽视这个初始化的平衡——它决定了这个游戏第一印象是流畅还是劝退。
5. 让这个项目从60分变90分的三个进阶改造:从验收变成作品
这个zip里的项目如果只是“能跑、能吃、能得分”,在我的标准里只能算60分。要让它在课程答辩或作品集里真正站得住,我建议你按下面三个方向各做一个小时的改造,投入产出比极高,而且每一条都能清晰地向别人解释你的设计取舍。
第一,给鱼加朝向翻转。大多数这个项目的源码是贴一张固定的鱼图片,鱼向左游时图片是朝右的,看起来像倒车。改法是在Fish类的draw方法里判断speedX的正负,然后决定是否对Graphics做水平镜像。具体实现通过g.drawImage(image, x + width, y, -width, height, null)即可实现翻转,但注意这种绘制方式如果x和width计算不当容易导致鱼的位置错位。更稳妥的写法是用AffineTransform,我推荐后者,虽然代码多几行,但坐标映射心智负担小得多。
第二,把分数改成“高名次记录”持久化。用Properties写入本地配置文件,每次游戏结束时读取并比较,如果超过最高分就写回。这里有个踩坑点:如果你把配置文件放在项目根目录下相对路径,打成jar包后会写不进去,读者不一定会看,但约定俗称的解决办法是把配置文件放在用户目录的.fishgame隐藏文件夹下。文件操作用Properties.store()和load()实现,几十行就搞定,但能让你这个demo从“每次重新开始都零分”变成“有完整单局循环”。
第三,加音效建议用Clip而不是AudioClip——AudioClip在循环播放时经常报LineUnavailableException,而Clip可以通过getSystemClipboard一个循环打底层为背景音乐预加载,效果稳定得多。唯一的坑是音频文件格式:必须转成wav,且不要用超过64kbps的码率,否则Java运行时加载会出现爆音或延时。校验方法可以启动后监听控制台是否有UnsupportedAudioFileException输出。
如果你做完这三个改造,这个项目本质上就不再是一个可以用“简单课设”四个字盖棺定论的东西了。游戏循环的线程模型你能讲清楚、碰撞判定能说出为什么不用矩形、图片资源加载能解释classpath机制。答辩时再配合演示,这些点才是真正拉开差距的地方。我当年第一次改造这个项目时,在翻转那条鱼图片的方向上卡了整整四十分钟,最后发现是AffineTransform里少乘了一个坐标系位移,属于那种“想明白了简单、没想到的时候看哪都是对的”的玄学bug。所以我今天的建议就是:一个看似摸透的小项目,里面一行坐标计算都可能让你重新认识Swing,值得花一个下午慢慢折腾。希望这些笔记能帮到你,少走我当年走的弯路。
本文还有配套的精品资源,点击获取