简介这是一套基于JavaSwing实现的大鱼吃小鱼小游戏项目面向Java入门者、课程设计或桌面小游戏爱好者。资源从创建窗口UI、绘制背景、添加键盘监听开始逐步实现小鱼随方向键移动、多鱼群的集合管理与绘制并完成吃鱼与被吃的碰撞判定同时集成用户登录与注册功能完整覆盖一个可玩小游戏的主要开发环节。压缩包共43个文件包含14个Java源文件、15个class可运行文件、8张PNG鱼类素材与1张JPG背景图另有项目配置和用户数据文本整体仅1.97MB。src目录按功能模块组织源码与图片素材分离方便对照学习和二次修改。目前已有273人学习下载适合课程作业、兴趣练手或在此基础上做功能扩展。1. 每年到课程设计季总有人对着「基于 JavaSwing 实现大鱼吃小鱼小游戏含用户登陆、注册功能」这种题目犯愁界面要不要用现成框架登录注册到底存哪游戏逻辑怎么和 Swing 的窗口世界共存把这个题目完整做一遍你会发现它几乎把 Java 基础里最容易在面试中被问到的点全过了一遍事件监听、集合遍历、线程与 EDT、文件读写、面向对象的继承与多态。对刚学完 Java 基础、想拿第一个完整项目练手的人这个选题性价比很高。后面的代码我都按「能直接跑起来、能讲解清楚」的标准写你也可以直接拿去做课设答辩的底稿。2. 动手前先分层界面、游戏逻辑、数据存储怎么拆最省事2.1 三层结构怎么分为什么不能把游戏代码全塞进 JPanel先说结论这个项目再小也要分成界面层ui、游戏逻辑层game、数据层dao三个包。我第一次做类似项目时图省事把鱼的移动、碰撞、分数全写在 JPanel 里结果两千行代码全挤在一个类里后面想加注册功能时几乎没法改。Swing 的事件模型决定了界面代码天生是「被动响应」的——按钮点了才执行鼠标动了才触发而游戏逻辑是「每帧都在跑」的。这两者节奏完全不同混在一起会让一个类同时背负事件响应和游戏循环两种职责。具体分工我一般这样定ui 包里的 LoginFrame、RegisterFrame、GameFrame、GamePanel 只管布局、控件把用户输入交给下层game 包里的 Fish 实体类和游戏规则不依赖任何 Swing 控件顶多用 java.awt.Imagedao 包只负责用户数据的读写与校验游戏完全不碰。这样以后想把文件存储换成 MySQL只改 dao 一个包界面和游戏逻辑一行都不用动。2.2 项目文件清单与包名规划建议包名用 com.你的名字.fishgame避免和别人课设代码混淆。下面是完整目录结构fishgame/ ├── src/com/fishgame/ │ ├── Main.java // 程序入口只负责拉起登录窗口 │ ├── ui/ │ │ ├── LoginFrame.java // 登录窗口 │ │ ├── RegisterFrame.java // 注册窗口 │ │ └── GameFrame.java // 游戏外层窗口内嵌 GamePanel │ ├── game/ │ │ ├── Fish.java // 鱼实体位置、尺寸、速度、图片 │ │ └── GamePanel.java // 游戏画布主循环、碰撞、生成 │ └── dao/ │ ├── UserDao.java // 用户数据文件读写 │ └── PasswordUtil.java // 密码加盐哈希工具 └── resources/ └── images/ // 鱼和背景图片打包进 classpathMain.java 是整个程序的入口别在 Main 里直接 new LoginFrame 就完事要包一层 SwingUtilities.invokeLater。原因是 Swing 的组件只能在事件分发线程EDT上创建和操作main 方法跑在普通线程上直接创建窗口属于踩线操作。代码很短public class Main { public static void main(String[] args) { SwingUtilities.invokeLater(() - new LoginFrame().setVisible(true)); } }这段代码做了三件事把创建登录窗口的任务丢进 EDT、保证窗口的初始化和绘制不会和系统其他事件竞争、避免了 main 线程退出时窗口被意外回收。课设里很多人不写这层包装也能跑但一旦在低配机器上或连续开关窗口时出事第一个被怀疑的就是这里。注意resources 目录要在 IDE 里标记成 Resources Root否则编译后图片不会进 classpath运行时会拿不到图片。2.3 Swing 组件选型这几个类足够撑起整个界面Swing 组件很多但这个项目用到的其实就固定几个。我把常用组件和用途列成一张表照着这张表写界面基本不会出现「该用哪个组件」的犹豫组件用途关键点JFrame每个窗口的容器setDefaultCloseOperation 决定关闭行为JPanel承载布局和游戏画布游戏画布必须重写 paintComponentJLabel显示文字、图标可以配合 ImageIcon 显示缩略图JTextField用户名输入getText() 拿 StringJPasswordField密码输入getPassword() 返回 char[]别用 getText()JButton登录、注册、开始addActionListener 绑定事件GridBagLayout登录/注册表单布局比手动 setBounds 靠谱得多javax.swing.Timer游戏主循环回调在 EDT 上执行天然避坑这里特别提醒一下 JPasswordField很多人习惯写 passwordField.getText()结果拿到的不是密码——新版 JDK 已经废弃了它返回字符串的行为。正确做法是 new String(passwordField.getPassword())这个细节第五章还会再展开一次。布局上登录和注册表单我推荐 GridBagLayout 或 GridLayout 嵌套别用绝对定位。绝对定位在窗口缩放后一定会乱答辩时老师拖一下窗口角你就翻车了。3. 注册登录模块Swing 表单校验、密码加盐哈希与窗口切换怎么落地3.1 注册界面文本框、密码框和按钮的监听事件注册界面和登录界面长得很像区别是注册多一个「确认密码」框。下面这段是 RegisterFrame 的核心逻辑布局代码省略重点看数据怎么从控件流向数据层public class RegisterFrame extends JFrame { private JTextField usernameField new JTextField(15); private JPasswordField passwordField new JPasswordField(15); private JPasswordField confirmField new JPasswordField(15); public RegisterFrame() { setTitle(大鱼吃小鱼 - 用户注册); setSize(360, 280); setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); setLocationRelativeTo(null); // 窗口居中 // GridBagLayout 布局代码省略排 3 行表单 1 行按钮 JButton registerBtn new JButton(注册); registerBtn.addActionListener(e - doRegister()); } private void doRegister() { String username usernameField.getText().trim(); String password new String(passwordField.getPassword()); String confirm new String(confirmField.getPassword()); if (username.isEmpty() || password.isEmpty()) { JOptionPane.showMessageDialog(this, 用户名和密码不能为空); return; } if (password.length() 6) { JOptionPane.showMessageDialog(this, 密码至少需要 6 位); return; } if (!password.equals(confirm)) { JOptionPane.showMessageDialog(this, 两次输入的密码不一致); return; } try { UserDao dao new UserDao(); if (dao.usernameExists(username)) { JOptionPane.showMessageDialog(this, 用户名已被注册换一个试试); return; } String salt PasswordUtil.randomSalt(); dao.saveUser(username, salt, PasswordUtil.sha256(password, salt)); JOptionPane.showMessageDialog(this, 注册成功请返回登录); dispose(); // 只关注册窗口不影响登录窗口 } catch (IOException ex) { JOptionPane.showMessageDialog(this, 保存用户数据失败 ex.getMessage()); } } }逻辑说明所有校验都在 actionPerformed 的 lambda 里做因为按钮监听器在 EDT 上执行访问和修改 Swing 控件是线程安全的。校验顺序有讲究——先判空、再查长度、再比对两次密码最后才碰数据层文件避免无意义的文件 IO。dispose() 只关闭当前窗口登录窗口不受影响因为注册窗口本来就是从登录窗口 new 出来的子窗口。参数说明JPasswordField 的构造参数 15 是列宽不是最大长度。如果要做输入长度限制得靠 DocumentFilter课设里一般不强制但至少要在校验时判空。JOptionPane.showMessageDialog 的第一个参数传 this弹窗会以父窗口为中心弹出否则飘到屏幕左上角观感很业余。3.2 文件存储的 UserDao读、写、查重怎么设计用户数据量很小课设用 MySQL 反而累赘——要装服务、配驱动老师机器上还不一定有环境。最常见、最稳妥的做法是落一个本地文件 users.dat每行一条用户记录。下面是 UserDao 实现只暴露界面层真正需要的几个方法public class UserDao { private static final Path USER_FILE Paths.get(users.dat); private static final String SEP \\|; public boolean usernameExists(String username) throws IOException { return findUser(username) ! null; } public void saveUser(String username, String salt, String passwordHash) throws IOException { String line username | salt | passwordHash System.lineSeparator(); Files.write(USER_FILE, line.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); } public String getSalt(String username) throws IOException { String[] u findUser(username); return u null ? null : u[1]; } public String getPasswordHash(String username) throws IOException { String[] u findUser(username); return u null ? null : u[2]; } private String[] findUser(String username) throws IOException { if (!Files.exists(USER_FILE)) { return null; } for (String line : Files.readAllLines(USER_FILE, StandardCharsets.UTF_8)) { String[] parts line.split(SEP); if (parts.length 3 parts[0].equals(username)) { return parts; } } return null; } }几个写法上的说明。Files.write 的 StandardOpenOption.APPEND 保证新用户追加到文件末尾不会覆盖已有记录CREATE 表示文件不存在时自动创建。line.split 里我用的是转义后的\\|——因为 | 在正则表达式里是或操作符直接 split(|) 会把一行切得七零八落这是非常典型的低级翻车点。readAllLines 按 UTF-8 解码整个文件如果你在中文 Windows 上跑读写编码必须统一否则用户名里混入 GBK 乱码查重和登录会永远失败。想把项目做得更有工程味可以在 dao 包里定义一个 User 实体类id、username、salt、hash让 UserDao 返回 Optional 。课设答辩时这样提一句老师会觉得你不是只会堆代码。至于为什么不用 Properties 文件——Properties 的键值对不保证顺序以后想记录「注册时间、最后登录时间」还得另做序列化纯文本行格式最简单也最好讲清楚。3.3 密码不能明文存加盐哈希的三行核心代码课设里经常看到 users.dat 里直接躺着一堆明文密码答辩时老师问一句「文件泄露了怎么办」就卡住了。避免这个尴尬只需要一个工具类public class PasswordUtil { public static String randomSalt() { SecureRandom random new SecureRandom(); byte[] bytes new byte[16]; random.nextBytes(bytes); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); // 转成 32 位十六进制 } return sb.toString(); } public static String sha256(String input, String salt) { try { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] digest md.digest((input salt).getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : digest) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(当前 JDK 不支持 SHA-256, e); } } }逻辑说明salt 是每个用户独立的 16 字节随机数转成 32 位十六进制字符串sha256 把「明文密码 salt」拼起来做 SHA-256 摘要文件里存的是摘要而不是原文。登录时先按用户名查出 salt再把用户输入的密码和这个 salt 做同样的哈希比对两个摘要是否一致。这样文件泄露后「123456」不会直接暴露而且因为 salt 不同两个密码相同的用户摘要也不一样。参数说明16 字节 salt 加 SHA-256对课设完全够用。想让老师眼前一亮可以说「salt 用 SecureRandom 而不是 Random」「再加一层固定的应用密钥参与哈希」——这两句话一出来基本能证明你懂密码学基础而不是只会抄代码。从这一步起你就从「跑通了」升到「懂原理」了。3.4 登录窗口的校验与窗口切换登录按钮和注册大同小异区别只在验证成功后要做窗口切换。登录逻辑的关键代码loginBtn.addActionListener(e - { String username usernameField.getText().trim(); String password new String(passwordField.getPassword()); if (username.isEmpty() || password.isEmpty()) { JOptionPane.showMessageDialog(this, 用户名和密码不能为空); return; } try { UserDao dao new UserDao(); String salt dao.getSalt(username); if (salt null) { JOptionPane.showMessageDialog(this, 用户不存在); return; } String inputHash PasswordUtil.sha256(password, salt); if (inputHash.equals(dao.getPasswordHash(username))) { dispose(); // 登录窗口退场 new GameFrame().setVisible(true); // 游戏窗口登场 } else { JOptionPane.showMessageDialog(this, 密码错误); } } catch (IOException ex) { JOptionPane.showMessageDialog(this, 读取用户数据失败 ex.getMessage()); } });窗口切换是这个模块最常见的坑如果 LoginFrame 用 EXIT_ON_CLOSE登录成功后虽然 new 了 GameFrame但登录窗口只是隐藏JVM 进程还在——切到游戏界面后关掉游戏整个程序其实没退出。所以登录窗口要用 DISPOSE_ON_CLOSE登录成功时 dispose() 掉GameFrame 再用 EXIT_ON_CLOSE。这组搭配是 Swing 多窗口程序的标配记下来能少踩一半的窗口坑。4. 核心玩法落地玩家鱼移动、敌人鱼生成与碰撞判定怎么写4.1 Timer 驱动的游戏主循环30ms 一帧做了什么游戏循环是 Swing 游戏的心脏。很多新手一上来就 new Thread(() - { while(true) { ... Thread.sleep(30); } })然后接二连三遇到界面冻结和并发异常。Swing 的界面绘制必须发生在 EDT 上自己起的线程改了鱼的位置再 repaint()EDT 可能在任意时刻读取数据两边不同步就会出问题。最稳妥的选择是 javax.swing.Timer名字带 swing 就说明它是为 EDT 设计的。下面这段是 GamePanel 的初始化与主循环骨架public class GamePanel extends JPanel { private static final int PANEL_WIDTH 800; private static final int PANEL_HEIGHT 600; private static final int FRAME_INTERVAL 30; // 毫秒约 33 帧/秒 private Fish player; private ListFish fishList new ArrayList(); private javax.swing.Timer timer; private int targetX PANEL_WIDTH / 2; private int targetY PANEL_HEIGHT / 2; private int score; public GamePanel() { setPreferredSize(new Dimension(PANEL_WIDTH, PANEL_HEIGHT)); setBackground(new Color(0x1B4F72)); // 深海蓝 setDoubleBuffered(true); player new Fish(loadImage(images/player.png), targetX, targetY, 60, 6, true); addMouseMotionListener(new MouseMotionAdapter() { Override public void mouseMoved(MouseEvent e) { targetX e.getX(); // 只记录目标点不直接改鱼位置 targetY e.getY(); } }); timer new javax.swing.Timer(FRAME_INTERVAL, evt - gameStep()); timer.start(); } private void gameStep() { movePlayer(); spawnIfNeeded(); moveEnemies(); checkEat(); if (player.isDead()) { timer.stop(); JOptionPane.showMessageDialog(this, 游戏结束得分 score); } repaint(); } private void movePlayer() { int dx Integer.signum(targetX - player.getCenterX()); int dy Integer.signum(targetY - player.getCenterY()); player.move(dx * player.getSpeed(), dy * player.getSpeed()); } }逻辑说明mouseMoved 只更新目标点真正的移动发生在 gameStep 里。这个「事件只记状态、循环里消费状态」的模式是 Swing 游戏的正统写法——事件线程只做轻量标记每帧统一推进避免「事件里改坐标、绘制时读到一半」的脏读。Integer.signum 把方向差归一成 -1、0、1再乘速度鱼就不是瞬移而是每帧朝目标点逼近。参数说明FRAME_INTERVAL 取 30 毫秒帧率约 33fps课设够用想更顺滑可以降到 20但 CPU 占用会上升别为丝滑把风扇拉满。movePlayer 每帧让鱼走 speed 像素玩家鱼我给 46太小手感肉太大容易冲过头。注意这里没做窗口边界裁剪鱼可以半身出屏要不要限制边界看你想要开放水域还是封闭鱼缸。4.2 Fish 实体类位置、尺寸、图片与面向对象设计Fish 类是这个项目里最能体现 Java 面向对象基础的部分。我用一个类加 isPlayer 标志区分玩家鱼和敌人鱼比搞两个子类省事以后玩家鱼要加技能再拆 PlayerFish extends Fish 也不迟。最小实现如下public class Fish { private int centerX, centerY; // 用圆心坐标碰撞时省一次换算 private int size; // 直径决定吃与被吃 private int speed; // 每帧移动像素数 private final Image image; private final boolean isPlayer; private boolean dead; public Fish(Image image, int x, int y, int size, int speed, boolean isPlayer) { this.image image; this.centerX x; this.centerY y; this.size size; this.speed speed; this.isPlayer isPlayer; } public void move(int dx, int dy) { centerX dx; centerY dy; } public void grow(int delta) { this.size delta; } public Rectangle getBounds() { return new Rectangle(centerX - size / 2, centerY - size / 2, size, size); } public double getRadius() { return size / 2.0; } public boolean canEat(Fish other) { return this.size other.size; } }这里有个设计细节值得在答辩时说我用 centerX/centerY 存圆心而不是左上角。碰撞检测本质上比的是两鱼圆心的距离用左上角存的话每次都要先算圆心多一步还容易算错直接存圆心getBounds() 只在绘制时调用职责更清楚。这就是「数据按逻辑建模、表现层做换算」的思想Java 面试题聊到面向对象时这是个很好的例子。grow() 方法体现了封装外部调用者只告诉它长大 5 还是 10size 怎么变是 Fish 自己的事。canEat 把「谁吃谁」的规则收敛到 Fish 内部碰撞代码里不用到处写 if 比较。如果你想展示 java 基础里的集合排序可以把鱼群按 size 排个序比如让最大的鱼排在列表头部顺带还能聊一句 Comparator 的用法。4.3 碰撞检测与「吃下去」的判定距离比较加体型比较碰撞检测听起来高大上真正落地的核心就一个公式两圆距离小于两圆半径之和则碰撞。更讲究一点玩家鱼作为主动方判定要分两步——先算距离满足后再比体型避免小鱼嘴还没张开就被大鱼的判定撞死。代码如下private void checkEat() { IteratorFish it fishList.iterator(); while (it.hasNext()) { Fish enemy it.next(); double distance Math.hypot( enemy.getCenterX() - player.getCenterX(), enemy.getCenterY() - player.getCenterY()); if (distance player.getRadius() enemy.getRadius()) { if (player.canEat(enemy)) { player.grow(5); score enemy.getSize() * 2; it.remove(); // 迭代器删除安全 } else { player.die(); return; } } } }逻辑说明Math.hypot 内部对溢出做了处理比先平方再开方更稳。判定顺序必须是先几何碰撞、再体型比较——如果先比体型再算距离会出现「隔着半个屏幕大鱼把小鱼吓死」的乌龙。玩家被吃后立即 return避免死亡状态下继续吃鱼导致重复扣分或重复弹窗。参数说明grow(5) 是每吃一条鱼直径 5。这个值不是随便定的——玩家鱼初始 60如果 5吃三四条小鱼就长一圈节奏偏快想慢热改成 3。score enemy.getSize() * 2 让分数和鱼的大小挂钩吃大鱼得分更高比固定 10 更能引导玩家去冒险追大鱼策略性瞬间上来了。注意这里用 it.remove() 而不是 fishList.remove(enemy)否则必踩第五章的并发异常坑。4.4 敌人鱼生成与难度递增数量上限加随机边缘出生敌人鱼不能无限生成否则玩家迟早被鱼海淹没也不能固定难度玩 30 秒和玩 5 分钟一个体验就没有「越玩越紧张」的感觉。我用两个参数控制难度鱼群数量上限以及生成鱼的体型范围。难度递增的做法是随时间缩小最小体型、提高大鱼出现概率。生成代码如下private void spawnIfNeeded() { if (fishList.size() 30) { return; // 数量上限防止鱼海 } int spawnCount random.nextInt(2) 1; // 每帧生 1~2 条 for (int i 0; i spawnCount; i) { int edge random.nextInt(4); // 0 上 1 右 2 下 3 左 int x random.nextInt(PANEL_WIDTH); int y random.nextInt(PANEL_HEIGHT); if (edge 0) y -30; // 从屏幕外出生 else if (edge 1) x PANEL_WIDTH 30; else if (edge 2) y PANEL_HEIGHT 30; else x -30; int size random.nextInt(50) 12; // 12 ~ 61 int speed random.nextInt(4) 1; // 1 ~ 4 fishList.add(new Fish(randomFishImage(), x, y, size, speed, false)); } }逻辑说明从四边外 30 像素处出生鱼会「游进」画面而不是凭空冒出来视觉自然很多。size 范围 1261初始玩家鱼 60偶尔出现的 60 大鱼就是天然的「心跳时刻」。speed 14 保证不同鱼游速有差异想更符合直觉可以加一层「越大越慢」的规则。参数说明数量上限 30 是经验值——再挤画面会很乱每帧遍历 30 条加碰撞检测对 Swing 毫无压力。难度递增建议单独抽一个 adjustDifficulty()在 gameStep 里每 10 秒调一次把 size 下限从 12 逐渐抬到 25把生成概率从 1~2 条/帧抬到 2~3 条/帧。代码就两三行但答辩时能清楚讲出「难度曲线是线性递增的通过控制体型下限和生成速率实现」这一句话的信息量胜过十页截图。5. 避坑排查Swing 小游戏最容易翻车的 5 个现场这一章把历届课设小组的血泪经验集中列一遍。每个问题都是「现象 → 原因 → 解决」三段式我踩过的和帮人排过的坑基本都在这了。5.1 现象画面疯狂闪烁、拖窗口时残影原因重写了 public void paint(Graphics g) 而不是 paintComponent(Graphics g)。Swing 的双缓冲机制默认发生在 JComponent.paint 内部直接覆盖 paint 等于把这层双缓冲剥掉了还得手动处理缓冲区创建和刷新绝大多数人在这里翻车。解决只重写 paintComponent并且第一行调用 super.paintComponent(g)保持 JPanel 默认开启的双缓冲。如果还闪检查是不是在 EDT 之外频繁调用 repaint()比如自己在 Thread 里每 10ms 刷一次。repaint 线程安全但过度调用会积压重绘请求正确做法是让 Timer 每 30ms 触发一次重绘事件驱动代替手动刷屏。5.2 现象运行中突然抛 ConcurrentModificationException原因游戏循环里遍历 fishList 做碰撞检测同一段逻辑里又调用 fishList.remove() 删鱼。foreach 循环隐藏了迭代器删除时集合结构被修改迭代器直接罢工。解决用显式 Iterator 并在迭代过程中用 it.remove()实现见 4.3 节的 checkEat。另外如果游戏逻辑跑在独立线程而 UI 在 EDT还要考虑线程同步——最省心方案是回到 Timer 单线程模型让 fishList 只被 EDT 访问从根上消除竞争。5.3 现象图片加载出来是 null双击 jar 后鱼全不见了原因用了 new ImageIcon(images/player.png) 这种文件系统相对路径。IDE 里运行还能找到因为工作目录恰好是项目根打成 jar 后图片在压缩包里文件系统路径根本访问不到于是全变 null。解决把图片放 resources 目录用类路径加载private Image loadImage(String path) { ImageIcon icon new ImageIcon(getClass().getResource(/ path)); return icon.getImage(); }注意getResource 参数以 / 开头表示从 classpath 根开始找/images/player.png 对应 resources/images/player.png。打 jar 时确认 resources 被标记为 Resources Root。我调试图片路径的小技巧先判断 getResource(...) null为 null 就弹个调试框把 classpath 打出来比瞎猜路径快得多。5.4 现象登录成功后界面卡死或关掉游戏窗口进程还在原因两个方向都有人踩。一个是登录窗口用了 EXIT_ON_CLOSE登录成功只是 setVisible(false)旧窗口还挂在进程里关掉游戏后 JVM 不退出另一个是游戏窗口用了 DISPOSE_ON_CLOSE关掉后整个进程直接结束连保存分数都来不及。解决登录窗口和注册窗口用 DISPOSE_ON_CLOSE登录成功时登录窗口执行 dispose() 再进入 GameFrame游戏窗口用 EXIT_ON_CLOSE。这样用户每关一个窗口就少一个最终关闭游戏窗口时进程干净退出。想加「确认退出」弹窗用 WindowAdapter 的 windowClosing 事件但别在事件里同时调用 dispose 和 EXIT语义会叠加。5.5 现象注册一个用户后登录永远提示密码错误原因十有八九是 JPasswordField 的取值问题。getText() 在新 JDK 里已废弃更隐蔽的是有人写 passwordField.getPassword().toString()以为拿到密码其实拿到的是 char[] 对象的默认地址串长这样 [C1b6d3586拿它去和哈希比永远对不上。解决一律用 new String(passwordField.getPassword()) 转成 String 再做哈希。比对时注意用户名要 trim() 去首尾空格密码不要 trim——密码里的空格是合法字符。我见过最哭笑不得的 bug注册时不 trim、登录时 trim 密码同一个密码两边摘出不同的哈希永远登录失败。注册和登录对密码的处理必须完全一致要么都不 trim要么都在输入框层面统一禁止首尾空格。6. 收尾技巧从「能玩」到「手感舒服」的三个进阶改动游戏做到能玩是一回事做到顺手是另一回事。课设答辩时老师不只问功能更看重细节打磨。分享三个我每次做 Swing 小游戏都会顺手加上的改动代码量都不大但对体验的提升立竿见影。第一个改动是给玩家鱼加「插值跟随」。4.1 节里鼠标移动直接改目标点鱼每帧走固定步长最大的问题是鱼贴到鼠标后不会停会围绕目标点来回抖动。改进方式是让鱼每帧朝目标移动剩余距离的一半越靠近速度越慢停得很自然也就是常说的缓动int remainX targetX - player.getCenterX(); int remainY targetY - player.getCenterY(); player.move(remainX / 4, remainY / 4);第二个改动是吃鱼反馈。在 checkEat 里把得分记到一个「飘字」列表吃到鱼时在吃鱼坐标塞入一个 {text, x, y, remainFrames} 对象每帧让它上浮 1 像素、剩余帧数减 1paintComponent 里遍历绘制帧数耗尽就移除。这个粒子系统的雏形很值得做它让你理解 GUI 游戏里所有「临时画面元素」都可以用生命周期对象管理而不是写死在状态变量里。第三个改动是打包。把项目打成可双击运行的 jar比在 IDE 里现场点运行稳得多——现场演示最怕启动失败。IDE 的 Artifacts 配置里选 JAR → From modules with dependenciesMain Class 填 com.fishgame.Main构建后在 jar 同目录放一个 start.batecho off start javaw -jar fishgame.jarjavaw 不带控制台窗口双击不会闪黑框双击没反应多半是 java 环境变量没配好或 JDK 版本过低。把这些细节做好演示环节就不会再出岔子。最后说一点我自己的习惯每次改完代码我都会从头走一遍「注册 → 登录 → 玩一局 → 关窗口」全流程重点盯三件事——文件里有没有多出重复用户、重开一局时鱼群数量是否正常、关闭所有窗口后进程是否真的退出。做课设那阵子我按这套流程过了三遍把上面五个坑全部提前踩平了。希望这份笔记能帮你在同样的路上少踩几个坑也祝你答辩顺利。本文还有配套的精品资源点击获取