Java Swing实现2D台球游戏:从物理碰撞到MVC架构的完整实践 📅 2026/8/27 4:31:09 简介在Java图形界面编程中Swing和AWT是构建桌面应用的基础工具集它们提供了丰富的组件和绘图能力。其核心原理是通过事件驱动模型和双缓冲技术来实现用户交互与图形渲染。掌握这些技术对于开发交互式应用具有重要价值尤其在游戏和教育软件领域。本文以2D台球游戏为例深入探讨了如何运用MVC架构进行解耦设计并详细实现了包括碰撞检测、物理模拟在内的关键模块。通过模拟球体运动与碰撞响应结合空间划分优化该项目生动展示了Java在图形编程和物理引擎构建方面的应用潜力。1. 项目概述从零到一构建一个Java台球游戏最近在整理自己的代码仓库翻出来一个大学时期做的Java台球小游戏项目。当时为了完成课程设计也为了深入理解Java的图形界面和物理模拟花了不少心思。现在回头看这个项目虽然不大但麻雀虽小五脏俱全涵盖了从界面绘制、事件处理、物理碰撞到游戏逻辑的完整链条。对于想用Java做点有趣东西的初学者或者想巩固面向对象和图形编程的朋友来说是一个非常不错的练手项目。它不依赖复杂的游戏引擎纯粹用Java标准库主要是Swing/AWT实现能让你清晰地看到每一行代码是如何驱动屏幕上那个小球运动的。今天我就把这个项目的设计思路、核心源码以及我踩过的那些坑系统地梳理一遍希望能给你带来一些启发。这个游戏的核心目标很简单模拟一个基础的2D台球游戏包含母球击打、目标球入袋、物理碰撞、分数计算等基本功能。它适合有一定Java基础熟悉类、接口、事件监听等概念的开发者学习。通过复现这个项目你不仅能学会如何用Swing绘制动态界面更能深入理解如何在代码中模拟现实世界的物理规律这是从“写业务代码”到“创造交互体验”的关键一步。2. 整体架构与核心类设计一个清晰的架构是项目成功的基石。在设计之初我就决定采用经典的MVC模型-视图-控制器模式进行解耦这样代码结构清晰也便于后续扩展。虽然对于小项目来说有点“杀鸡用牛刀”的感觉但养成好的设计习惯至关重要。2.1 模型层设计数据与规则的核心模型层是游戏的大脑它封装了所有的游戏状态数据和核心规则。我主要设计了以下几个核心类Ball类这是最重要的模型类代表台球桌上的每一个球。它的属性远不止一个位置坐标那么简单。public class Ball { private double x, y; // 球心坐标 private double velocityX, velocityY; // 速度向量 private double radius; // 半径 private Color color; // 颜色 private int number; // 球号如1-15号球 private boolean inPocket; // 是否已入袋 // ... 构造函数、Getter/Setter // 关键方法移动、检测碰撞、绘制自身 public void move(double friction) { // 应用摩擦力使速度逐渐衰减 velocityX * friction; velocityY * friction; // 更新位置 x velocityX; y velocityY; // 速度过小时视为停止 if (Math.abs(velocityX) 0.01 Math.abs(velocityY) 0.01) { velocityX velocityY 0; } } }这里的关键在于move方法中的摩擦力模拟。friction是一个略小于1的常数如0.99每一帧都乘以速度从而实现一个非常自然的减速效果而不是瞬间停止。Table类代表整个台球桌。它持有一个Ball对象的列表并管理球桌的边界、球袋位置以及球与球、球与边界的碰撞检测逻辑。这是物理引擎的核心。public class Table { private ListBall balls; private ListPocket pockets; // 球袋 private double width, height; private double friction; // 桌面摩擦系数 public void update() { // 1. 更新所有球的位置 for (Ball ball : balls) { if (!ball.isInPocket()) { ball.move(friction); } } // 2. 检测球与边界的碰撞 checkBoundaryCollision(); // 3. 检测球与球之间的碰撞这是一个优化难点 checkBallCollision(); // 4. 检测球入袋 checkPocket(); } }checkBallCollision方法是性能关键。最朴素的实现是双重循环遍历所有球进行两两检测当球数多时效率很低。我采用了简单的空间划分优化将桌子划分为若干网格只检测相邻网格中的球大幅提升了性能。GameModel类这是总的游戏状态管理器。它聚合了Table实例并管理游戏状态如等待击球、球运动、回合切换、当前玩家、分数等全局信息。它向控制器暴露接口如hitBall(double force, double angle)击球isAllBallsStopped()判断所有球是否静止等。2.2 视图层设计Swing绘制的艺术视图层负责将模型层的数据渲染到屏幕上。我使用一个继承自JPanel的GameView类作为主画布并重写其paintComponent(Graphics g)方法。public class GameView extends JPanel { private GameModel model; Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; // 1. 开启抗锯齿让线条和圆更平滑 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 2. 绘制桌布绿色矩形 g2d.setColor(new Color(0, 100, 0)); g2d.fillRect(tableX, tableY, tableWidth, tableHeight); // 3. 绘制桌边和球袋 drawTableBorderAndPockets(g2d); // 4. 绘制所有未入袋的球 for (Ball ball : model.getBalls()) { if (!ball.isInPocket()) { ball.draw(g2d); // Ball类自身的绘制方法 } } // 5. 绘制瞄准线当鼠标拖动时 if (isAiming) { drawAimingLine(g2d); } // 6. 绘制分数和状态信息 drawHUD(g2d); } }注意在Swing中所有绘图操作必须在paintComponent方法内完成并且第一行必须是super.paintComponent(g)用于清空上一帧的画面。使用Graphics2D可以获得更强大的绘图能力如设置笔画粗细、抗锯齿等。为了让动画流畅需要启动一个游戏循环线程Game Loop。我没有使用while(true)循环而是采用了Swing的Timer因为它能确保重绘操作在事件分发线程EDT上安全执行。Timer gameTimer new Timer(16, e - { // 约60FPS (1000ms/60 ≈ 16ms) model.update(); // 更新模型物理计算 repaint(); // 请求重绘画布 }); gameTimer.start();2.3 控制器层设计连接用户与游戏控制器层是用户输入和游戏逻辑之间的桥梁。我主要通过鼠标事件监听器来实现。鼠标按下记录击球起始点进入瞄准状态。鼠标拖动根据当前鼠标位置和起始点计算击球方向和力量并实时绘制瞄准线。鼠标释放根据拖动的距离和方向计算出力的大小和角度调用GameModel的hitBall方法将力施加给母球。public class GameController implements MouseListener, MouseMotionListener { private GameModel model; private GameView view; private Point dragStart; Override public void mousePressed(MouseEvent e) { if (model.canHit()) { // 只有在所有球静止时才能击球 dragStart e.getPoint(); view.setAiming(true); } } Override public void mouseReleased(MouseEvent e) { if (dragStart ! null model.canHit()) { Point dragEnd e.getPoint(); double dx dragStart.x - dragEnd.x; double dy dragStart.y - dragEnd.y; // 计算力量限制最大值和角度 double force Math.min(Math.sqrt(dx*dx dy*dy) / 10.0, 50.0); double angle Math.atan2(dy, dx); model.hitBall(force, angle); view.setAiming(false); dragStart null; } } // ... mouseDragged 用于绘制瞄准线 }这里的一个技巧是力量的计算。我使用拖动距离的平方根来模拟非线性的力量增长短距离拖动精度高长距离拖动力量大并设置一个上限防止力量过大。角度通过Math.atan2(dy, dx)计算这个函数能正确处理所有象限的角度。3. 核心物理与碰撞检测实现这是游戏中最有趣也最具挑战性的部分。如何让球的运动看起来真实关键在于物理模拟的精度。3.1 运动与摩擦力球的运动遵循最基本的物理学每一帧根据当前速度更新位置。我在Ball.move()方法中已经展示了基础的移动和摩擦力。更真实的模拟还可以加入角速度球的旋转但这会大大增加复杂度。对于入门项目线性摩擦力已经能提供不错的效果。摩擦系数friction需要反复调试0.98到0.995之间通常能产生比较真实的感觉。3.2 球与边界库边的碰撞当球碰到桌子边缘时需要反弹。这不仅仅是简单的速度取反还需要考虑碰撞点法线。private void checkBoundaryCollision(Ball ball) { double nextX ball.getX() ball.getVelocityX(); double nextY ball.getY() ball.getVelocityY(); double radius ball.getRadius(); // 检测左右边界 if (nextX - radius LEFT_BOUNDARY || nextX radius RIGHT_BOUNDARY) { ball.setVelocityX(-ball.getVelocityX() * BOUNDARY_DAMPING); // 反向并加入阻尼损失 // 防止球卡在边界将其位置修正到刚好接触边界 ball.setX(nextX - radius LEFT_BOUNDARY ? LEFT_BOUNDARY radius : RIGHT_BOUNDARY - radius); } // 检测上下边界同理 }BOUNDARY_DAMPING是边界阻尼系数比如0.95表示每次碰撞会损失5%的能量这样球不会永远弹下去。3.3 球与球的碰撞检测与响应这是最复杂的部分。检测两个球是否碰撞很简单计算球心距离是否小于两球半径之和。难点在于碰撞后的响应——速度如何变化我使用了基于动量守恒和能量守恒近似的公式计算两个刚性球在非弹性碰撞后的新速度。假设球质量相等标准台球公式可以简化public void resolveCollision(Ball ball1, Ball ball2) { // 计算碰撞法线向量 (从ball1指向ball2) double dx ball2.getX() - ball1.getX(); double dy ball2.getY() - ball1.getY(); double distance Math.sqrt(dx*dx dy*dy); // 单位法线向量 double nx dx / distance; double ny dy / distance; // 单位切线向量 double tx -ny; double ty nx; // 将速度分解为法线和切线方向 // 球1的速度点积 double v1n nx * ball1.getVelocityX() ny * ball1.getVelocityY(); double v1t tx * ball1.getVelocityX() ty * ball1.getVelocityY(); // 球2的速度点积 double v2n nx * ball2.getVelocityX() ny * ball2.getVelocityY(); double v2t tx * ball2.getVelocityX() ty * ball2.getVelocityY(); // 碰撞后法线方向的速度交换质量相等时 double v1nAfter v2n; double v2nAfter v1n; // 切线方向速度不变 // 将法向和切向速度转换回x, y方向 ball1.setVelocityX(v1nAfter * nx v1t * tx); ball1.setVelocityY(v1nAfter * ny v1t * ty); ball2.setVelocityX(v2nAfter * nx v2t * tx); ball2.setVelocityY(v2nAfter * ny v2t * ty); // 非常重要的一步分离球体防止它们因浮点误差“粘”在一起 double overlap ball1.getRadius() ball2.getRadius() - distance; if (overlap 0) { ball1.setX(ball1.getX() - nx * overlap / 2); ball1.setY(ball1.getY() - ny * overlap / 2); ball2.setX(ball2.getX() nx * overlap / 2); ball2.setY(ball2.getY() ny * overlap / 2); } }实操心得在实现这个算法时我最开始忘了最后“分离球体”那一步结果球撞上后经常因为计算误差导致距离略小于两半径之和下一帧检测不到分离就粘在了一起出现“鬼畜”抖动。加上这个位置修正后碰撞就非常稳定了。3.4 球入袋检测球袋通常被建模为桌子四个角及长边中点的圆形区域。检测入袋就变成了检测球心是否进入某个圆内。public class Pocket { private double centerX, centerY; private double radius; // 袋口半径比球半径稍大 public boolean isBallIn(Ball ball) { double dx ball.getX() - centerX; double dy ball.getY() - centerY; double distance Math.sqrt(dx*dx dy*dy); // 球心进入袋口半径即算入袋 return distance ball.getRadius() this.radius; } }在Table.update()的checkPocket方法中遍历所有未入袋的球和所有球袋进行检测。一旦球入袋就将其inPocket状态设为true并从物理模拟和绘制列表中“移除”实际上只是不再更新和绘制。4. 游戏逻辑与状态管理物理引擎让球动起来游戏逻辑则决定了怎么玩、怎么算赢。4.1 回合制与击球规则我模拟了简化版的8球规则。游戏有两个玩家或一个玩家对电脑。初始时所有球1-15号随机摆放在三角框内母球白色放在开球区。玩家轮流击球。合法击球玩家必须首先用母球击中自己花色全色或花色的球且此后必须有任意球包括对手球入袋或母球碰到库边。犯规母球落袋洗袋、未首先击中本方球、击球后无任何球碰库边等。犯规后对手获得自由球可将母球置于桌面任意位置击打。胜负一名玩家将自己所有花色的球击入袋后再合法将8号球击入指定袋口即获胜。若在过程中误将8号球击入或犯规情况下将8号球击入则判负。在GameModel中我用一个枚举来管理游戏状态public enum GameState { PLAYER1_TURN, // 玩家1回合 PLAYER2_TURN, // 玩家2回合 AIMING, // 瞄准中 BALLS_MOVING, // 球运动中 GAME_OVER // 游戏结束 }每次击球后游戏进入BALLS_MOVING状态并启动一个Timer或线程持续检查isAllBallsStopped()。当所有球静止后根据最后入袋的球、是否犯规等规则判断是否换手、得分并切换回PLAYER1_TURN或PLAYER2_TURN状态。4.2 分数计算与胜负判定分数计算相对简单。我设计了Player类记录玩家击入的球号列表。在GameModel中根据当前击球结果更新玩家对象。 胜负判定是游戏逻辑的集大成者需要在每次击球后、所有球静止时进行一系列检查检查8号球是否入袋如果是立即判断游戏是否结束。检查犯规根据犯规规则设置foul标志。检查目标球是否全部入袋检查当前玩家的目标球是否已清空。根据以上信息决定下一回合的击球方、是否自由球以及是否宣布获胜方。这部分代码逻辑分支较多需要仔细处理。我的经验是画一个状态流程图明确每一种击球结果如“合法击入本方球”、“合法击入对方球”、“犯规且8号入袋”等对应的状态迁移路径这样写代码时才不容易混乱。5. 性能优化与代码打磨当球的数量增多或者物理计算更复杂时性能可能成为瓶颈。除了前面提到的空间划分优化碰撞检测还有以下几点可以提升体验1. 双缓冲技术 直接在JPanel上绘制当画面复杂时可能会出现闪烁。启用双缓冲可以创建离屏图像先在内存中完成所有绘制再一次性显示到屏幕上。// 在自定义JPanel的构造函数中设置 setDoubleBuffered(true); // Swing默认可能已启用但显式设置更保险2. 避免在paintComponent中创建对象paintComponent方法会被频繁调用在这里创建Color、Font、Stroke等对象会产生大量垃圾影响性能。正确的做法是在类初始化时创建这些对象并复用。public class GameView extends JPanel { private final Color tableColor new Color(0, 100, 0); private final BasicStroke borderStroke new BasicStroke(5.0f); // ... 在paintComponent中直接使用这些预定义对象 }3. 使用Delta Time时间增量 我们的游戏循环固定为16ms一帧但在不同性能的电脑上实际循环时间可能波动。更专业的做法是计算两帧之间的时间差deltaTime并用它来缩放物体的移动距离和速度这样能保证在不同帧率下物理运动是一致的。不过对于这个入门项目固定时间步长已经足够。4. 逻辑与渲染分离 这是一个高级思想。我们的Timer同时驱动了model.update()和view.repaint()。在更复杂的游戏中物理更新的频率如60Hz和画面渲染的频率如屏幕刷新率60Hz或更高可能希望解耦。这可以通过独立的逻辑线程和渲染线程来实现但会引入线程同步的复杂度。当前项目暂不需要。6. 项目扩展与进阶思路完成基础版本后你可以尝试添加更多功能让游戏变得更专业、更有趣1. 加入球体旋转与杆法 真实的台球击打母球不同部位会产生不同的旋转高杆、低杆、左塞、右塞影响母球碰撞后的走位。你可以在Ball类中加入spinX,spinY属性并在碰撞计算和与桌面摩擦力的计算中考虑旋转的影响。这是一个巨大的挑战但能极大提升模拟的真实性。2. 实现简单的AI对手 让电脑玩家能够决策击打哪个球、用什么角度和力度。这可以是一个简单的规则AI如寻找最容易进袋的球也可以引入路径搜索算法如计算母球撞击目标球后目标球的运动路径是否指向袋口。实现一个哪怕很笨的AI也能让你对游戏规则的理解更深一层。3. 美化界面与音效界面用更精美的图片替代纯色绘制。为球桌加载木质纹理图片用真实的台球图片替代彩色圆绘制更逼真的球杆和手部瞄准动画。音效使用javax.sound.sampled库在碰撞、入袋时播放对应的音效文件.wav格式。音效能极大提升游戏的沉浸感。4. 网络对战功能 这是最具挑战性的扩展。你需要设计网络协议来同步两个客户端的游戏状态球的位置、速度、游戏回合等。可以使用Java Socket进行TCP通信将GameModel的状态序列化后发送。关键难点在于网络延迟的处理和状态的权威性哪个客户端是主机。你可以先从简单的“热座”模式两人用同一台电脑轮流玩开始再挑战网络功能。7. 开发中遇到的典型问题与解决方案在开发过程中我踩了不少坑这里记录几个最有代表性的问题1球运动时出现“抖动”或“穿越”现象。表现球在高速运动时可能会在边界或另一个球上“抖动”甚至直接穿过去。原因这是经典的“隧道效应”。因为我们的碰撞检测是基于当前帧的位置如果球的速度太快一帧移动的距离超过了它的直径那么它就可能从障碍物的一侧“瞬移”到另一侧中间没有产生重叠从而检测不到碰撞。解决方案增加物理更新频率将游戏循环的Timer间隔调小如从16ms调到8ms这样每帧移动距离变短。使用连续碰撞检测不是检测两个时刻的静态位置而是计算球在本帧时间内的运动轨迹线段检测该轨迹是否与边界或其他球的轨迹相交。这更复杂但效果最好。限制最大速度给球的速度设置一个合理的上限确保单帧位移不会过大。这是最简单有效的临时方案。我在Ball.move()方法中加入了对速度的钳制。问题2多个球堆在一起时碰撞响应异常导致球“炸飞”。表现当三个或以上球紧贴在一起时一次碰撞检测可能会产生连锁反应导致某个球获得不合理的巨大速度。原因在同一个物理更新周期内一个球可能先后与多个球发生碰撞。如果处理顺序不当或者碰撞后没有及时更新位置就会导致物理状态计算错误。解决方案分离检测与响应先找出本帧所有发生的碰撞对存储在一个列表中。迭代求解对碰撞列表中的每一对球应用碰撞响应并更新它们的速度和位置。然后可能需要再次检测这些球是否因位置变化又产生了新的碰撞对于高密度情况可能需要迭代多次直到没有新碰撞。我的项目因为球密度不高采用一次遍历位置修正基本可以接受。问题3游戏在不同分辨率的屏幕上显示比例失调。表现在开发机上运行正常换到高分辨率显示器上球桌可能只占屏幕一小部分或者UI元素错位。解决方案不要使用绝对像素坐标。定义逻辑坐标系统。例如规定球桌的逻辑大小是1000*500个单位。在GameView的paintComponent中根据面板的实际大小计算一个缩放比例将所有逻辑坐标乘以这个比例再绘制。double scaleX (double)getWidth() / LOGICAL_TABLE_WIDTH; double scaleY (double)getHeight() / LOGICAL_TABLE_HEIGHT; double scale Math.min(scaleX, scaleY); // 取最小比例保持等比例缩放 // 绘制时所有坐标和尺寸都乘以 scale g2d.scale(scale, scale);问题4内存泄漏与线程安全。表现游戏长时间运行后越来越卡。原因Swing的Timer会持有其ActionListener的引用。如果游戏窗口关闭后没有正确停止Timer它和相关的模型、视图对象可能无法被垃圾回收。另外游戏循环线程如果直接修改Swing组件状态可能引发线程安全问题。解决方案在游戏窗口JFrame的关闭事件中显式地停止TimergameTimer.stop();。所有对Swing组件的更新如修改JLabel的文本都通过SwingUtilities.invokeLater()包装确保在事件分发线程上执行。SwingUtilities.invokeLater(() - { scoreLabel.setText(得分: currentScore); });把这个项目从一行行代码变成屏幕上流畅运动的台球整个过程充满了调试和解决问题的乐趣。它让我深刻体会到编程不仅仅是实现功能更是对现实世界的一种抽象和模拟。当你看到自己写的物理公式让小球按照预想的方式滚动、碰撞时那种成就感是无与伦比的。如果你正在学习Java厌倦了控制台打印和简单的CRUD强烈建议你动手实现一个这样的小游戏它会让你对编程有全新的认识。本文还有配套的精品资源点击获取