
简介这是一套面向Java初学者的Swing图形界面练手项目基于JavaSwing实现大鱼吃小鱼小游戏并带有用户登录与注册功能适合课程设计、期末项目或GUI入门实践。项目代码围绕完整开发流程展开包含窗口对象创建、背景与鱼类素材绘制、键盘监听控制己方鱼、多条鱼的管理与移动、吃鱼与被吃判定等关键环节能直观理解Java GUI游戏的事件处理与碰撞检测思路。压缩包共43个文件仅约1.97MB主要包含14个Java源码、15个编译后的class文件及多张png/jpg图片素材另附工程配置文件与资源目录便于直接导入开发环境查看运行效果。已有273人学习浏览借助源码可快速梳理登录注册的数据保存方式、画布刷新与方向控制的配合逻辑动手修改参数或图片即可变成自己的作品对课程报告也有直接参考价值。1. 从课设到大鱼吃小鱼为什么这个Java Swing游戏值得你亲手做一遍如果你是计算机相关专业的学生或者刚转行Java的初学者大概率见过或做过这个经典课设用Java Swing实现一个大鱼吃小鱼小游戏带用户登录和注册功能。很多人在网上找源码、下载、跑起来、交作业但等面试官一问“你这个碰撞检测怎么写的”“登录信息存哪了”就露馅了。与其这样不如自己把这条链路完整走一遍——游戏循环、键盘/鼠标事件、碰撞检测、用户信息持久化刚好覆盖Java基础、集合、IO、Swing组件和事件模型是一道性价比极高的综合练习题。这篇笔记我把自己的实现思路、核心代码和踩过的坑都整理出来按这套方案写你不仅能跑通课设还能把它讲清楚。2. 拆解架构与选型Swing做游戏到底行不行怎么搭才不翻车2.1 为什么选Swing而不是JavaFX或游戏引擎常见做法是选Swing因为它是JDK自带的GUI库不需要额外引入依赖环境配置好就能跑。对比一下JavaFX虽然界面更现代但需要单独下载SDK和配置模块对课设来说反而多一道门槛LibGDX等游戏引擎功能强大但是学习曲线陡而且做2D小游戏的体量有点杀鸡用牛刀。Swing的优势在于事件分发机制EDT、双缓冲绘制和丰富的布局管理器做“大鱼吃小鱼”这种2D平面游戏完全够用。另一个现实因素是绝大多数学校的Java课程只教到Swing你用它做课设答辩时老师问什么你都能答上不会因为用了没教过的技术被追问到崩溃。2.2 整体模块划分逻辑、绘制、存储各管各的我一般把项目拆成四个包entity放鱼、玩家、食物等数据类service放游戏逻辑和用户管理ui放窗口和面板util放工具类如音频播放和图片加载。这样拆的好处是登录失败不会影响游戏逻辑游戏出bug也不用去翻注册相关的代码。核心的数据流动是游戏循环每帧更新鱼的位置和大小 → 调用repaint()触发重绘 →paintComponent里根据鱼集合绘制画面。如果你把绘制代码和业务逻辑全塞在一个类里后期加个鱼群数量、调整个难度都得小心翼翼翻车概率极高。2.3 主窗口结构用CardLayout切换登录和游戏界面Swing里做多界面切换最省事的方案就是CardLayout。主窗口JFrame持有一个JPanel容器里面放LoginPanel、RegisterPanel和GamePanel用CardLayout.show(container, game)就能在登录成功后切到游戏界面。这样写比setVisible(true/false)切窗口安全得多因为游戏窗口不会被误开两个导致焦点混乱。主窗口记得setLocationRelativeTo(null)居中显示setResizable(false)固定大小避免玩家拖拽窗口导致游戏面板尺寸被拉伸变形。下面是最小可运行的主窗口代码public class MainFrame extends JFrame { private CardLayout cardLayout new CardLayout(); private JPanel container new JPanel(cardLayout); private UserService userService new UserService(); public MainFrame() { setTitle(大鱼吃小鱼); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(900, 600); setLocationRelativeTo(null); setResizable(false); container.add(new LoginPanel(this, userService), login); container.add(new RegisterPanel(this, userService), register); container.add(new GamePanel(), game); setContentPane(container); cardLayout.show(container, login); } public void showPanel(String name) { cardLayout.show(container, name); } }LoginPanel和RegisterPanel的构造函数接收MainFrame实例是为了在按钮点击时能调用showPanel切换界面。UserService则是共享的用户管理服务登录和注册面板都能访问。这里要注意setSize(900, 600)和pack()的区别——如果你用pack()Swing会根据组件首选大小撑开窗口但游戏界面需要固定画布尺寸用setSize更可控。实际运行时如果鱼跑出画面检查是不是窗口尺寸和游戏面板的setPreferredSize不一致导致的。2.4 游戏主循环与Swing Timer的选择游戏循环有几种写法while(true)Thread.sleep、Swing Timer、java.util.Timer。这里强烈建议用Swing Timer因为它的回调事件是在EDT事件分发线程上执行的可以直接操作Swing组件不会因为多线程访问UI组件导致偶发崩溃。while(true)循环里调用repaint()虽然也行但你必须自己在UI线程和逻辑线程之间做同步课设阶段没必要给自己加这个复杂度。Swing Timer的用法很简单Timer timer new Timer(16, e - { gamePanel.update(); // 更新游戏逻辑 gamePanel.repaint(); // 触发重绘 }); timer.start();16毫秒对应约60FPS是人眼看流畅的分界线。如果你的机器性能不够或者鱼的数量太多可以调到20毫秒即50FPS视觉差别很小但CPU占用明显下降。这里有个血泪经验不要在paintComponent里更新游戏逻辑只做绘制。因为paintComponent的调用时机不由你控窗口被遮挡再显示时会自动重绘如果你把“鱼移动”的逻辑放在绘制方法里会出现时间间隔不均匀、鱼突然瞬移的玄学问题。正确做法是update()里统一更新状态paintComponent只负责把当前状态画出来。3. 核心玩法逻辑鱼的大小、移动与碰撞检测的实现细节3.1 鱼的实体设计尺寸、速度和吞噬关系鱼的数据结构是整条游戏逻辑的地基。我设计了一个Fish类包含坐标x、y速度speed半径radius用来表示大小和类型type。半径比直接存宽高更省心因为碰撞检测是按圆形来算的用radius可以避免在圆形和矩形之间来回换算。鱼的等级可以用半径区间来表示比如小鱼半径15像素中鱼半径30像素大鱼半径60像素。在update()方法里按半径决定这条鱼算哪个等级影响它能吃什么鱼。初始权重是在FishFactory里配置的方便调整难度曲线而不是在代码里硬编码出现20次半径数字。public class Fish { private int x, y; private int speed; private int radius; private String type; // player, small, medium, large public Fish(int x, int y, int speed, int radius, String type) { this.x x; this.y y; this.speed speed; this.radius radius; this.type type; } public boolean intersects(Fish other) { int dx this.x - other.x; int dy this.y - other.y; int distSq dx * dx dy * dy; int minDist this.radius other.radius; return distSq minDist * minDist; } public boolean canEat(Fish other) { return this.radius other.radius * 1.1; } // getter/setter 省略 }intersects方法用距离平方来比较避免了Math.sqrt的开销。虽然现代CPU算个平方根不心疼但每帧要跟几十条鱼做碰撞检测积少成多这个习惯值得保留。canEat里的1.1是个阈值——只有当自己半径比对方大10%以上才能吞掉避免两条大小差不多的鱼互相吃掉造成死锁。3.2 玩家控制方式鼠标与键盘两种方案对比游戏提供给玩家的操控方式我做过鼠标跟随和键盘方向键两种。鼠标跟随的体验更接近“大鱼吃小鱼”这类手游的操控鱼头朝鼠标方向游动按住鼠标左键加速。键盘方向键适合答辩演示因为操作直观老师看得懂。我最终的方案是同时支持代码实现不复杂。鼠标跟随的写法是public void updatePlayerWithMouse(Point mousePos) { int dx mousePos.x - player.getX(); int dy mousePos.y - player.getY(); double dist Math.sqrt(dx * dx dy * dy); if (dist 5) return; // 距离太近不用转向 double angle Math.atan2(dy, dx); player.setX((int)(player.getX() Math.cos(angle) * player.getSpeed())); player.setY((int)(player.getY() Math.sin(angle) * player.getSpeed())); }Math.atan2(dy, dx)算出鼠标相对鱼的方向角然后用cos/sin分解到x、y轴。dist 5的短路判断很重要不然鱼会在这附近来回抖动看起来像抽风。还要注意鱼的移动速度应该跟大小挂钩——大鱼的移动速度要比小鱼慢这是一个游戏平衡参数我一般设置speed max(1, baseSpeed - radius / 10)。3.3 AI鱼群的移动与重生机制AI鱼的移动不能太“完美”否则玩家感到挫败也不能太傻否则没有挑战性。我采用随机游走策略每条鱼每帧有一定概率改变方向方向变化范围限制在±30度以内这样鱼的移动轨迹显得自然而不唐突。速度也分两档比玩家小的鱼跑得快一点求生比玩家大的鱼慢一点猎食这符合真实的生物逻辑玩家追逐小鱼和躲避大鱼时的节奏就会自然分层。代码里我实现了一个简单的updateAI(Fish fish)方法随机改变角度再按速度和方向移动。AI鱼被玩家或大鱼吃掉后在场景外随机位置重生保证水域里鱼的数量始终动态平衡。3.4 碰撞检测的优雅实现两对判定碰撞检测是整个游戏最容易“看起来不对”的地方因为有一对概念很容易搞混碰触touch和吞噬eat。碰触是鱼身有交集就算吞噬则必须是“嘴够大能含住对方”。如果你直接把“有交集”当成“能吃掉”就会出现两个半径差不多的鱼互相吞掉或者大鱼碰到小鱼但没吞掉却把对方挤走的怪现象。我的做法是记两条规则先判定intersects这是物理层面的接触再判定canEat这是逻辑层面的吞噬能力只有两者都满足才触发吃掉。一是防止两鱼重叠后持续扣分二是避免玩家鱼跟AI鱼在边界来回纠缠。具体实现如下public void handleEating() { for (int i 0; i fishes.size(); i) { Fish other fishes.get(i); if (other player) continue; if (player.intersects(other)) { if (player.canEat(other)) { player.setRadius(player.getRadius() other.getRadius() / 3); fishes.remove(i); spawnNewFish(); score other.getRadius() * 10; } else if (other.canEat(player)) { gameOver(); return; } } } }吃掉后玩家的半径只增加对方半径的1/3而不是全额吸收否则玩家滚雪球变大速度太快5分钟后就无敌了游戏失去挑战性。spawnNewFish()在场景边界外随机生成新鱼让AI鱼群数目保持稳定。3.5 边界处理别让鱼游出画面外再掉进死角鱼的坐标必须在(0, 0)到(panelWidth, panelHeight)之间的有效区域内。这组逻辑不处理好玩家鱼会钻出边界消失或AI鱼卡在墙角抖动。我加了一段边界修正逻辑每帧更新完位置后检查坐标public void clampToBounds(Fish fish, int boundaryWidth, int boundaryHeight) { int maxX boundaryWidth - fish.getRadius(); int maxY boundaryHeight - fish.getRadius(); if (fish.getX() fish.getRadius()) fish.setX(fish.getRadius()); if (fish.getX() maxX) fish.setX(maxX); if (fish.getY() fish.getRadius()) fish.setY(fish.getRadius()); if (fish.getY() maxY) fish.setY(maxY); }注意边界要用radius而不是0这样鱼游到边缘时还留着半个身位在水里视觉上不会感觉鱼像被“卡边”。AI鱼撞墙后的行为我选择了“反弹”而不是“转向”代码更简单真实感反而更好。4. 用户登录与注册从文件存储到数据校验的完整实现4.1 用户数据存储方案文件还是数据库课设阶段的用户数据存储常见做法是文件存储或者SQLite/MySQL。我推荐首选文件存储原因有三JFrame项目不想引入额外的数据库依赖作业要求主要是演示登录注册流程和界面交互万一脸老师拷到别的机器上运行没有数据库环境的尴尬谁也想不到。文件存储我使用的是.properties格式或者简单的txt格式每行存一个用户记录用户名密码。如果你用的是纯文本容易被篡改那就在注册的时候给密码做一次MD5哈希存进去的是哈希值而不是明文。这个细节会在答辩时成为亮点。用Java的Properties类读写非常简单public class UserService { private Properties users new Properties(); private File dataFile new File(users.properties); public UserService() { if (dataFile.exists()) { try (FileInputStream in new FileInputStream(dataFile)) { users.load(in); } catch (IOException e) { e.printStackTrace(); } } } public boolean register(String username, String password) { if (users.containsKey(username)) return false; users.setProperty(username, md5(password)); saveToFile(); return true; } public boolean login(String username, String password) { String stored users.getProperty(username); return stored ! null stored.equals(md5(password)); } private void saveToFile() { try (FileOutputStream out new FileOutputStream(dataFile)) { users.store(out, User Data); } catch (IOException e) { e.printStackTrace(); } } private String md5(String input) { try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(input.getBytes()); StringBuilder sb new StringBuilder(); for (byte b : bytes) sb.append(String.format(%02x, b)); return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(MD5 algorithm not found, e); } } }Properties默认编码是ISO-8859-1如果你的用户名是中文会出现乱码。两种解决办法一是存的时候对中文做URL编码二是干脆换用FileWriter写文本格式。我后来改用了文本存储每行一条记录读写逻辑控制在20行以内而且能自定义UTF-8编码避免中文用户名乱掉的尴尬。另外MessageDigest每次调用都要重新getInstance不要用static缓存因为有些JDK版本的MessageDigest不是线程安全的。4.2 登录界面的设计与验证逻辑登录面板用GridBagLayout做居中布局用户名输入框JTextField、密码输入框JPasswordField、登录按钮和跳转注册按钮。这里有个细节JPasswordField.getText()已经废弃了应该用new String(passwordField.getPassword())这是很多老代码的坑。按钮绑定ActionListener回调里先做非空验证再调UserService.login。如果登录失败用JOptionPane.showMessageDialog弹窗提示这个组件比JLabel显示错误信息更醒目用户体验拆分得很自然。loginBtn.addActionListener(e - { String username usernameField.getText().trim(); String password new String(passwordField.getPassword()); if (username.isEmpty() || password.isEmpty()) { JOptionPane.showMessageDialog(this, 用户名和密码不能为空); return; } if (userService.login(username, password)) { JOptionPane.showMessageDialog(this, 登录成功); mainFrame.showPanel(game); } else { JOptionPane.showMessageDialog(this, 用户名或密码错误); } });trim()处理用户手滑输入空格的情况。这里缺少一个“密码可见/不可见”的切换功能Swing自带的JCheckBox可以绑定passwordField.setEchoChar(\0)来显示明文代码很简单但能提升演示效果。4.3 注册界面的密码确认与格式校验注册页有一个容易踩坑的地方让用户输入两次密码做一致性校验这个逻辑人人都会写但校验不通过时要保留用户已经输入的用户名免得从头再来。我用GridBagLayout在注册面板上放了用户名输入框、密码输入框、确认密码输入框和两个按钮提交注册和返回登录。校验规则除了“密码二次一致”我还会检查用户名长度在3到16个字符之间密码必须同时包含字母和数字。这个规则在注册逻辑里写private boolean validateRegister(String username, String password, String confirm) { if (username.length() 3 || username.length() 16) { JOptionPane.showMessageDialog(this, 用户名长度需为3-16个字符); return false; } if (!password.matches(.*[a-zA-Z].*) || !password.matches(.*\\d.*)) { JOptionPane.showMessageDialog(this, 密码需包含字母和数字); return false; } if (!password.equals(confirm)) { JOptionPane.showMessageDialog(this, 两次密码输入不一致); return false; } return true; }matches(.*[a-zA-Z].*)这种正则写法是判断字符串里“存在一个字母”的惯用做法.*表示任意前缀后缀。这里要注意如果你在MD5之前把密码提前做了trim()登录和注册时都要做同样处理不然注册时密码带空格登录时空格被去掉就永远匹配不上。4.4 会话状态与“记住我”的简单实现登录成功后MainFrame保存当前用户名GamePanel在初始化时可以把它显示在“当前玩家”标签上这就是最简单的会话管理。如果你还想做“记住我”功能选中复选框时用Properties存当前用户名下次启动时LoginPanel自动填入。虽然只存用户名不涉及密码安全性没问题但注意文件不要暴露。实际开发中我一般把用户名保存到本地配置文件密码不做自动填充这样可以防止他人直接看到密码哈希和实际密码之间的对应关系。5. 避坑指南Swing游戏开发中的5个常见翻车现场5.1 中文乱码Swing默认字体和不支持中文的配置文件现象是游戏画面里的“得分”“暂停”显示成方块users.properties文件打开后用户名变成乱码。原因是旧版JDK中Swing的默认字体是Dialog它映射到系统字体但某些Linux/Windows环境下不支持中文渲染配置文件方面Properties#store用ISO-8859-1编码写入中文必然乱码。解决方法是给JFrame或顶层面板统一设置字体UIManager.put(Label.font, new Font(微软雅黑, Font.PLAIN, 14))或者至少做到panel.setFont(new Font(微软雅黑, Font.PLAIN, 14))。配置文件换成BufferedWriter手动以UTF-8格式写入读取时用BufferedReader指定UTF-8别再依赖Properties的默认编码。5.2 画面闪烁没有用双缓冲绘制现象是窗口拖动或鱼多的时候画面出现明显的闪烁和残影。原因是你直接在paintComponent里画背景和鱼操作系统来不及把整帧推上去画面就在新旧帧之间来回切换。Swing顶级组件默认是双缓冲的但JPanel子类自绘时需要在构造函数里设置public GamePanel() { setDoubleBuffered(true); setBackground(new Color(0, 153, 204)); }这一行经常被漏掉。如果你画面仍然闪检查是否重写了update()方法且没有调用super.update(g)正确处理方式是不重写update()只重写paintComponent(Graphics g)。5.3 登录成功后游戏界面空白现象是登录弹窗成功cardLayout.show(container, game)执行了但界面白茫茫一片什么都没有。原因是GamePanel做了耗时初始化比如加载图片或音频阻塞了EDT线程界面无法完成首次重绘。解决方法是把图片和音频的加载放在主线程提前完成构造阶段异步加载或者先用空背景初始化GamePanel等数据准备好再repaint()。我通常把图片资源放到项目resources/images目录用ImageIcon预加载避免在EDT上做磁盘IO。另一个原因是你忘记给GamePanel设置setPreferredSize导致面板大小为0验证方法是在GamePanel构造里加一句System.out.println(getSize())控制台打印一下真实尺寸。5.4 游戏帧率不稳定Timer被GC回收或EDT被阻塞现象是高配机器上游戏也卡顿帧率像过山车。原因之一是Swing Timer对象没有保存为成员变量被垃圾回收后定时事件不再触发更常见的是在paintComponent里做了图片旋转或缩放这种Java2D重计算EDT忙不过来。我的排查流程是先检查Timer是否存活再注释掉绘制代码逐一测试耗时操作。图片缩放可以用BufferedImage.getScaledInstance提前做或者直接准备多套尺寸的图片不要在每帧里现场缩放。5.5 文件写入失败导致注册崩溃现象是点击注册按钮报NullPointerException或FileNotFoundException原因是users.properties的路径是相对路径而你可能把它放在了src/main/resources下运行时当前工作目录是项目的根目录路径对不上。最稳健的做法是用绝对路径System.getProperty(user.home)下面创建一个.bigfish目录存数据。这样无论从哪里启动Java程序都能找到文件也方便用户备份数据。我现在的做法是File homeDir new File(System.getProperty(user.home), .bigfish); if (!homeDir.exists()) homeDir.mkdirs(); dataFile new File(homeDir, users.db);存储方案升级为JSON格式后用Jackson或者Gson序列化成ListUser可读性和扩展性都更好。6. 进阶优化游戏难度曲线、玩家成长与打包发布课设如果只停留在“能跑”答辩时老师几句追问就会露怯。这章我把长期迭代中积累的优化思路打包给你照着做在别提什么“简单课设”了。首先是难度曲线。如果AI鱼永远只有那几种大小玩家5分钟之后就腻了。我按时间动态调整AI鱼群的组成比例开局前1分钟主要生成比自己小的鱼游戏进行到3分钟开始出现体型稍大的中等鱼5分钟以后大鱼比例上升玩家的可活动空间被压缩。这个比例控制我封装了一个DifficultyManager核心逻辑是维护一个LevelConfig列表每个档位指定某大小区间的鱼出现的权重。你的套餐里的具体配置数字可以自己调但没有难度压迫感的游戏就算不上“大鱼吃小鱼”。其次是玩家成长。除了体型变大还可以设计“速度衰减”与“得分倍率”绑定每次吃掉一条鱼加固定分数但连吃3条有1.5倍连击加分。这个机制能激励玩家主动游到鱼群密集的地方冒风险。因为玩家体型的增大本身就带来了速度惩罚 速度衰减就已经给了风险再叠加连击加分会更有拉扯感。计算得分配方用一行代码score other.getRadius() * 10 * (comboCount 3 ? 1.5 : 1.0);最后是打包与发布。Swing项目的可执行jar包打包java -jar如果清单文件缺失Main-Class属性就会报“无法找到主类”。用Maven直接在pom.xml里配maven-jar-plugin完成主类设置或者集成maven-shade-plugin打一个fat jar把依赖打包进去。我的经验是如果你的项目只用到了JDK自带的Swing那么用jar cvfe这一行命令就够了jar cvfe bigfish.jar ui.MainFrame -C out .不用额外引入任何第三方依赖的情况下fat jar毫无必要还能让编译产物体积控制在几百KB。如果你用了外部图片资源和音频记得打包时把它们一并压缩进jar运行时用ClassLoader.getResource(/images/fish.png)加载写完这句我就不再靠相对路径赌运气读取图片了。从实用的角度来说上面这些优化不一定要全部落地但“难度曲线”和“jar打包”是最能体现你理解深度的两块。建议至少把难度曲线用上这个改动只有几十行代码却能把游戏体验感拉升一个档次也是答辩时最容易让老师“眼前一亮”的细节。我最早做这个项目的时候曾经死磕过图片透明度和碰撞手感调了两天最后发现罪魁祸首只是循环里忘加break导致鱼被重复计算吞了两次。后来养成了习惯每加一个设置项就重启一次游戏测3分钟。这个习惯虽然蠢但它有效。这些积累今天都写在里面了希望帮到你。本文还有配套的精品资源点击获取