简介:这是一套面向Java初学者的Swing图形界面练手项目,基于Java+Swing实现大鱼吃小鱼小游戏,并带有用户登录与注册功能,适合课程设计、期末项目或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序列化成List<User>,可读性和扩展性都更好。
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分钟。这个习惯虽然蠢,但它有效。这些积累今天都写在里面了,希望帮到你。
本文还有配套的精品资源,点击获取