ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java实现大鱼吃小鱼游戏:从MVC架构到碰撞检测的完整开发指南

Java实现大鱼吃小鱼游戏:从MVC架构到碰撞检测的完整开发指南 1. 项目缘起与核心玩法拆解最近在整理一些经典的编程练手项目发现《大鱼吃小鱼》这个游戏虽然规则简单但用来理解面向对象设计、游戏循环和碰撞检测等核心概念效果出奇的好。它不像大型游戏引擎项目那样复杂但又涵盖了从状态管理到用户交互的完整闭环非常适合Java初学者进阶或者有经验的开发者用来回顾基础。这个游戏的魅力在于其直观的“成长”机制你控制一条小鱼通过不断吃掉比自己小的鱼来变大同时要躲避比自己大的鱼。这种简单的规则背后隐藏着游戏设计里最经典的“风险与收益”平衡。很多人一听到“游戏开发”就觉得头大认为必须掌握复杂的图形库或游戏引擎。其实不然用Java的Swing或JavaFX这类标准GUI库完全可以从零开始构建一个可玩性不错的《大鱼吃小鱼》。整个过程不仅能让你巩固Java SE的核心知识比如多线程用于游戏循环、集合框架管理多个游戏对象、事件处理键盘控制还能让你对游戏开发的基本流程有一个清晰的认识。接下来我会结合一个完整的实现源码带你一步步拆解这个项目的构建思路、关键技术和那些容易踩坑的细节。2. 游戏架构设计与核心类规划在动手写代码之前先花点时间规划一下整体架构这能避免后期陷入混乱的代码泥潭。对于《大鱼吃小鱼》这类2D小游戏一个清晰的分层模型非常有效。我的设计通常分为模型层、视图层和控制层也就是常说的MVC模式的一个简化变体。2.1 模型层游戏世界的“数据与规则”模型层是游戏的核心它定义了游戏里有什么东西以及这些东西遵循什么规则。这里我们需要几个核心的类。首先是Fish类代表所有的鱼包括玩家控制的鱼和电脑控制的鱼。这个类需要哪些属性呢位置x, y坐标、大小用一个半径或宽度/高度表示、速度包含x方向和y方向的分量、颜色以及一个布尔值标识它是否被吃掉或者是否存活。这里有一个关键设计点如何表示“大小”和“吃与被吃”的关系我通常用一个整型变量size来表示规则很简单只有当一条鱼的size大于另一条鱼时才能吃掉对方。这个比较逻辑就封装在Fish类的某个方法里比如canEat(Fish other)。public class Fish { private int x, y; // 坐标 private int size; // 大小决定谁能吃谁 private int speedX, speedY; // 速度分量 private Color color; private boolean alive; public boolean canEat(Fish other) { // 只有当自己比对方大且对方还活着时才能吃 return this.alive other.alive this.size other.size; } public void move() { // 根据速度更新位置 x speedX; y speedY; // 这里还应包含边界检测逻辑碰到边界后速度取反或执行其他行为 } // 其他getter/setter和draw方法绘制逻辑可以放在这里但更推荐放在视图层 }其次是GameWorld类它是游戏的“上帝视角”管理着游戏中的所有实体和全局状态。它应该持有一个ListFish来存放所有的鱼包括玩家鱼和AI鱼。这个类还负责游戏循环的更新逻辑在每一个时间步tick里调用每条鱼的move()方法更新它们的位置。碰撞检测检查每两条鱼之间是否发生了碰撞并根据canEat的规则决定吃鱼行为更新鱼的状态如将被吃的鱼标记为alivefalse或者增大吃鱼者的size。游戏规则管理比如玩家鱼被吃则游戏结束或者玩家鱼成长到一定大小通关。实体管理负责生成新的AI鱼移除被吃掉的鱼可以从列表中移除或者只是标记为不渲染不更新。将游戏逻辑集中到GameWorld中能使控制层如键盘事件处理和视图层渲染的职责更清晰它们只需要向GameWorld查询状态或发送指令即可。2.2 视图层将数据“画”出来视图层负责渲染。在Java中我们可以继承JPanel并重写其paintComponent(Graphics g)方法。在这个方法里我们遍历GameWorld中所有的Fish调用每个鱼的绘制方法或者直接在这里根据鱼的数据画圆、画图片。这里的一个技巧是双缓冲。直接在paintComponent中绘制当画面更新频繁时容易出现闪烁。标准的做法是使用双缓冲技术先在内存中的一个图像BufferedImage上绘制整个场景然后再将这个图像一次性绘制到面板上。public class GamePanel extends JPanel { private GameWorld world; private BufferedImage bufferImage; public GamePanel(GameWorld world) { this.world world; } Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 双缓冲 if (bufferImage null || bufferImage.getWidth() ! getWidth() || bufferImage.getHeight() ! getHeight()) { bufferImage new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB); } Graphics2D g2dBuffer bufferImage.createGraphics(); // 清空缓冲区 g2dBuffer.setColor(Color.BLUE); // 背景色例如蓝色代表水 g2dBuffer.fillRect(0, 0, getWidth(), getHeight()); // 绘制所有鱼 for (Fish fish : world.getAllFish()) { if (fish.isAlive()) { g2dBuffer.setColor(fish.getColor()); // 假设鱼用圆形表示大小为size int diameter fish.getSize() * 2; // 将size映射为直径 g2dBuffer.fillOval(fish.getX() - fish.getSize(), fish.getY() - fish.getSize(), diameter, diameter); } } // 将缓冲区图像绘制到面板上 g.drawImage(bufferImage, 0, 0, null); g2dBuffer.dispose(); } }2.3 控制层连接用户与游戏的“桥梁”控制层主要负责两件事用户输入和游戏主循环驱动。用户输入很简单就是为游戏窗口添加键盘监听器。例如监听方向键当按键按下时设置玩家鱼的移动速度当按键释放时将速度归零。游戏主循环是游戏的心脏它决定了游戏更新的频率帧率。这里绝对不能用while(true)循环然后在里面调用repaint()这会完全阻塞Swing的事件分发线程导致界面卡死无响应。正确的方法是使用SwingTimer或者单独的游戏线程。我更喜欢使用SwingTimer因为它是在Swing的事件线程中执行回调天然适合更新游戏状态和请求重绘无需担心线程安全问题。public class GameController { private GameWorld world; private GamePanel panel; private Timer gameTimer; public GameController(GameWorld world, GamePanel panel) { this.world world; this.panel panel; // 设置一个每16毫秒触发一次的计时器约等于60FPS this.gameTimer new Timer(16, e - { world.update(); // 更新游戏世界状态 panel.repaint(); // 请求重绘面板 checkGameOver(); // 检查游戏结束条件 }); } public void startGame() { gameTimer.start(); } // ... 键盘监听器绑定等方法 }3. 核心算法实现碰撞、AI与成长系统架构搭好了现在我们深入几个最核心的算法实现细节这些地方是游戏逻辑是否流畅、是否有趣的关键。3.1 高效的碰撞检测最简单的碰撞检测是判断两个圆鱼的圆心距离是否小于半径之和。在GameWorld.update()中我们需要对所有鱼进行两两检测。注意这是一个O(n²)复杂度的操作。当鱼的数量n很多时比如超过100性能会急剧下降。对于这个小游戏几十条鱼的数量级完全够用但了解优化思路很重要。常见的优化策略有空间分割如四叉树将屏幕划分为多个区域只检测同一区域或相邻区域内的物体。在基础实现中我们使用简单循环。但要注意避免重复检测A和B检测一次就够了和自检测鱼和自己碰撞。public void checkCollisions() { ListFish fishes getAllFish(); // 获取所有鱼的副本或直接操作 for (int i 0; i fishes.size(); i) { Fish a fishes.get(i); if (!a.isAlive()) continue; for (int j i 1; j fishes.size(); j) { // j从i1开始避免重复 Fish b fishes.get(j); if (!b.isAlive()) continue; // 计算距离 double dx a.getX() - b.getX(); double dy a.getY() - b.getY(); double distance Math.sqrt(dx * dx dy * dy); int minDistance a.getSize() b.getSize(); if (distance minDistance) { // 发生碰撞判断谁吃谁 if (a.canEat(b)) { eatFish(a, b); // a吃掉b } else if (b.canEat(a)) { eatFish(b, a); // b吃掉a } // 如果大小相等则可能弹开或无事发生根据规则设计 } } } } private void eatFish(Fish predator, Fish prey) { prey.setAlive(false); // 捕食者成长可以简单增加size也可以按猎物size的比例增加 predator.setSize(predator.getSize() prey.getSize() / 5); // 可以在这里添加音效或分数增加的事件 }3.2 AI鱼的“智能”行为让AI鱼完全随机游动会显得很傻。我们可以赋予它们一些简单的行为模式比如“漫游并偶尔转向”、“遇到边界反弹”、“感知玩家并逃跑/追击”更高级。一个简单而有效的实现是状态机。每条AI鱼可以有几个状态WANDER漫游、ESCAPE逃跑、CHASE追击。在Fish.move()或GameWorld.update()中根据当前状态决定速度向量。对于漫游可以每隔一段时间比如2-5秒随机生成一个新的目标方向速度向量然后平滑地转向那个方向这比每一帧完全随机更自然。// 在Fish类中为AI鱼添加 private int aiTimer 0; private static final int AI_CHANGE_INTERVAL 100; // 100帧改变一次方向 public void updateAI() { if (!isPlayerControlled) { aiTimer; if (aiTimer AI_CHANGE_INTERVAL) { aiTimer 0; // 随机一个角度并转换为速度向量 double angle Math.random() * 2 * Math.PI; int speed 2; // 基础速度 speedX (int) (Math.cos(angle) * speed); speedY (int) (Math.sin(angle) * speed); } // 边界检测碰到边界就反弹简单取反速度 if (x 0 || x worldWidth) speedX -speedX; if (y 0 || y worldHeight) speedY -speedY; } }如果要实现逃跑逻辑就需要在GameWorld中为每条AI鱼计算它到玩家鱼的距离和方向。如果距离小于某个“警戒范围”且玩家鱼比它大则AI鱼的速度方向应设置为远离玩家鱼的方向。3.3 成长与难度曲线如果玩家鱼只是简单地变大游戏会很快变得无聊无敌或沮丧被大鱼包围。一个好的成长系统需要搭配动态的难度曲线。非线性成长吃掉一条鱼增长的量可以不是固定的。例如吃掉比自己小很多的鱼增长很少吃掉和自己差不多大的鱼增长较多。这鼓励玩家冒险去吃接近自己体型的鱼。AI鱼生成策略不要一次性生成所有鱼而是定时生成。随着游戏时间推移或玩家体型增大可以提高生成频率。生成鱼的平均体型变大。生成鱼的速度变快。甚至生成少量具有特殊行为比如一直追击玩家的“精英”鱼。玩家视野/缩放一个常见的增强体验的效果是随着玩家鱼变大游戏镜头可以缓慢拉远或者背景、其他鱼相对缩小让玩家始终能看到大部分活动区域避免因体型过大而看不清周围环境。这在Swing中可以通过调整绘制坐标的缩放比例来实现。4. 性能调优、常见坑点与项目扩展即使是一个小游戏如果不注意也会遇到性能问题和奇怪的Bug。下面分享几个我实际开发中遇到的坑和解决方案。4.1 性能瓶颈与优化实战问题游戏卡顿尤其在鱼多的时候。诊断最可能的原因是碰撞检测的O(n²)循环或者是在paintComponent中进行了复杂的计算或创建了大量临时对象如 new Color, new Font。优化碰撞检测如前所述当鱼数量超过50时可以考虑引入简单的网格空间划分。将游戏面板划分为多个单元格每条鱼根据其坐标属于某个单元格。碰撞检测时只需检测同一单元格及相邻单元格内的鱼。这能极大减少检测对数。优化绘制确保使用双缓冲这能消除闪烁并提升绘制效率。避免在paintComponent内创建对象。所有画笔Graphics2D、颜色、字体等都应该在初始化时创建好并复用。只绘制可见区域Swing的paintComponent会传入一个clipRect裁剪矩形代表需要重绘的区域。高级优化可以只绘制与这个区域相交的鱼但对于这个规模的项目通常不需要。使用轻量级图形绘制实心圆fillOval比绘制带纹理的图片快。如果要用图片确保图片已经加载好ImageIO.read并缓存而不是每次绘制都从磁盘读取。问题游戏速度不稳定在不同性能的电脑上快慢不一。原因SwingTimer的回调间隔是固定的但回调执行的时间点并不绝对精确且如果单次更新渲染耗时超过间隔时间就会导致实际帧率下降。解决方案基于时间的运动。这是游戏开发中的一个重要概念。不要让物体的移动距离distance speed per frame而应该是distance speed per second * deltaTime。deltaTime是上一帧到这一帧实际经过的时间。// 在GameController或GameWorld中 private long lastUpdateTime; public void update() { long currentTime System.nanoTime(); float deltaTime (currentTime - lastUpdateTime) / 1_000_000_000.0f; // 转换为秒 lastUpdateTime currentTime; for (Fish fish : fishes) { // 移动距离 速度(像素/秒) * 时间差(秒) fish.setX(fish.getX() (int)(fish.getSpeedX() * deltaTime)); fish.setY(fish.getY() (int)(fish.getSpeedY() * deltaTime)); } }这样无论帧率是30还是60鱼每秒移动的像素距离都是恒定的游戏体验保持一致。4.2 那些容易踩的“坑”线程安全陷阱如果你错误地使用了Thread.sleep或自己启了一个游戏线程来更新状态然后在这个线程中直接调用Swing组件的方法如repaint() 修改JLabel的文本可能会引发不可预知的界面错误。记住一条黄金法则所有对Swing组件的创建、访问和修改都必须在事件分发线程上进行。使用SwingUtilities.invokeLater()来将你的更新任务包装后提交给事件线程。这也是为什么我推荐使用SwingTimer因为它自动在事件线程中执行。对象状态同步在游戏循环中更新鱼的位置同时在paintComponent中读取鱼的位置进行绘制。如果更新和绘制发生在同一时刻尽管在单线程的SwingTimer中顺序执行但也要注意理论上没问题。但如果你未来改为多线程模型比如用独立的线程进行物理更新就必须考虑使用锁或并发集合来保护共享数据ListFish。内存泄漏被吃掉的鱼alivefalse如果只是标记而不从ListFish中移除这个列表会越来越大。需要定期比如每100帧清理掉已经不存活的鱼对象。同样在游戏结束或重新开始时要确保清空旧列表防止旧对象残留。4.3 项目扩展与创意发挥基础版本实现后你可以尝试添加更多功能让它变成一个真正的“作品”图形与音效用真正的鱼精灵图替换简单的圆形。加载一系列图片实现简单的游泳动画。添加背景音乐和吃鱼、被吃的音效。可以使用javax.sound.sampled.Clip来播放WAV格式音频。游戏功能丰富化技能系统玩家鱼可以积累能量释放“加速冲刺”、“短暂无敌”、“全屏吸引小鱼”等技能。关卡与目标不止是生存可以设计关卡比如“在60秒内成长到size50”、“吃掉10条红色的鱼”。多种鱼类型除了大小增加类型属性。例如有毒的鱼碰到会缩小、发光的鱼吃掉得分加倍、怀孕的鱼吃掉后爆出一堆小鱼。网络化高级尝试使用Socket编程让两个玩家在不同的电脑上控制各自的鱼在同一个海洋里竞争。这涉及到网络同步、延迟补偿等更复杂的课题。这个项目麻雀虽小五脏俱全。从构思到实现每一步都涉及到扎实的编程思想和决策。我最开始做的时候也被碰撞检测的效率问题和游戏线程的卡顿折磨过但正是解决了这些问题才对Java GUI编程和游戏循环有了更深的理解。希望这份详细的思路和实现要点能帮你少走弯路更顺畅地做出属于自己的《大鱼吃小鱼》。
返回列表