
简介这是一份基于Java开发的台球小游戏设计源码面向Java初学者、游戏开发入门者以及需要参考完整项目结构的高校学生。资源以可运行项目形式呈现涵盖游戏界面绘制、用户交互、球体运动物理模拟等模块解决初学者缺乏完整项目经验、不知如何组织游戏代码的问题。压缩包共168个文件大小46.53MB文件类型丰富偏好设置文件保存用户的按键/画质等个性化配置XML文件用于运行时参数与跨环境部署调整PNG图像提供球桌、台球与背景等视觉素材属性文件约定编译所需的依赖与路径索引与快照文件则辅助项目检索和版本回溯其余如生成文件、描述符等服务于自动化构建。目前已有299人学习下载。通过阅读这份源码可以深入理解Java 2D绘图与动画刷新、碰撞检测与摩擦力/反弹计算、键盘鼠标事件处理等游戏开发关键技巧项目内通过多个package划分界面、逻辑、数据模块也展示了面向对象设计中高内聚低耦合的实践方法。对于正在完成课程设计、准备毕业项目或自学游戏开发的人来说这是一份可直接运行、边读边改的实用参考。1. 一个Java台球小游戏为什么值得你自己动手写一遍很多人以为台球小游戏的难点在绘图其实恰好相反绘图只占三成工作量剩下的七成都在跟物理模拟和帧循环较劲。这就像一个黑匣子你花钱买的源码包能跑但一改参数就翻车——球的碰撞位置不对、大力击球会穿球、球停不下来。用Java从零搭一套台球小游戏设计源码不是在重复造轮子而是把“游戏循环、碰撞检测、状态管理”这三块基本功一次补齐。适合正在做Java课程设计、想找项目练手、或者准备面试时拿出一个能讲清原理的作品的人。2. 台球小游戏的骨架游戏循环与球对象的建模2.1 先定数据结构球的属性与状态机写台球之前先别急着画界面把球对象设计好。一个球在桌面上需要记录的不只是坐标和半径它每一帧都在“移动—碰撞—减速”这个循环里。我一般会用一个Ball类把位置、速度、半径、颜色、是否进袋都放在一起方便后续物理模块统一操作。public class Ball { public double x, y; // 球心坐标 public double vx, vy; // 速度分量单位像素/秒 public double radius; // 半径通常取 12~15 public Color color; public boolean inPocket; public Ball(double x, double y, double r, Color c) { this.x x; this.y y; this.radius r; this.color c; this.vx 0; this.vy 0; this.inPocket false; } public void move(double dt) { x vx * dt; y vy * dt; } }这里有个容易忽略的点move方法里的dt是单帧时长单位必须统一。如果用毫秒速度分量也要换算成像素/毫秒如果用秒速度就是像素/秒。混用单位是新手最常见的翻车原因之一表现就是球一会儿快一会儿慢。我的习惯是用秒做时间单位dt固定为 0.016约 60 FPS。球的状态机也建议在Ball里加一个枚举静止、运动、进袋。这个状态不是“装饰”它是后续击球判定的依据——只有母球静止时才能拖动瞄准否则点击鼠标无效。2.2 主循环采用固定时间步长绘图用双缓冲Swing 的repaint()并不是游戏主循环它只是请求系统在某个时机重绘。真正驱动一局台球运转的是一个固定时间步长的循环。我用Swing Timer来承担这个任务而不是Thread.sleep因为 Timer 的回调天然跑在 EDT事件分发线程上更新和绘制都安全。Timer timer new Timer(16, e - { update(0.016); // 固定步长推进物理 repaint(); // 触发重绘 }); timer.start();update方法里做三件事移动所有未进袋的球、处理球与库边的碰撞、处理球与球之间的碰撞。每一步都讲究顺序顺序错了会出连锁 bug这个先按下不表后面避坑章节细说。绘图侧直接继承JPanel并重写paintComponent是最常见的做法。但注意一个细节桌面背景、球杆、辅助线这些东西每帧画一次直接用g.fillOval也能跑只是快速拖动时会有轻微闪烁。要彻底消掉闪烁就用双缓冲——先画到一张BufferedImage上再一次drawImage上屏。Override protected void paintComponent(Graphics g) { super.paintComponent(g); if (offscreen null) { offscreen new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_RGB); } Graphics2D g2d (Graphics2D) offscreen.getGraphics(); drawTable(g2d); // 绘制台面、库边、袋口 drawBalls(g2d); // 绘制所有球 drawAimLine(g2d); // 绘制瞄准辅助线 g.drawImage(offscreen, 0, 0, null); }BufferedImage.TYPE_INT_RGB是 24 位 RGB绘制速度最快但注意它没有 alpha 通道。drawTable、drawBalls、drawAimLine这三个方法按层绘制先画底层内容再画上层内容顺序别颠倒否则球会被桌边遮住或辅助线盖住球体。2.3 坐标系的约定物理坐标与像素坐标解耦一个实战中很有用的习惯不要把物理计算直接写在像素坐标上。桌面尺寸、球半径、速度上限这些都应该抽成常量并且跟实际渲染尺寸分开。比如物理世界定义“桌面宽 2000、高 1000”渲染时通过缩放把它映射到窗口的像素区域。public final class TableSpec { public static final double WIDTH 2000; // 物理宽度 public static final double HEIGHT 1000; // 物理高度 public static final double CUSHION 0.95; // 库边反弹系数 public static final double FRICTION 0.985; // 每帧速度衰减 public static final double MAX_POWER 3000; // 最大击球初速度 }这样的好处是调物理参数时不需要关心窗口大小改完一个数字就能整体感知变化。很多开源小游戏的源码里坐标直接写死成窗口像素换个分辨率物理表现就全变了这属于典型的不该抄的代码。3. 球与球碰撞的正确解法从球心距到最早碰撞时刻3.1 为什么不能只判断“两球心距离小于半径之和”最容易想到的碰撞检测是每帧检查两球心距离是否小于2 * radius小于就交换速度。这个思路在慢速球时勉强能用一旦大力击球问题立刻暴露一帧内球可能移动了 30 像素而两个半径 12 的球的碰撞距离阈值只有 24 像素。上一帧还差 5 像素没碰到下一帧已经交错而过动画上就是“穿球”。这就是典型的隧道效应tunneling。做台球小游戏不能只在离散帧上做判断应该计算“两球在运动过程中最早发生碰撞的时刻”再按这个时刻推进位置。3.2 解运动方程求最早碰撞时刻假设球 A 和球 B 在当前帧开始时位置分别为(x1, y1)和(x2, y2)速度分别为(vx1, vy1)和(vx2, vy2)。在时间t后两球心的距离平方可以写成一个二次函数public double findCollisionTime(Ball a, Ball b) { double dx a.x - b.x; double dy a.y - b.y; double dvx a.vx - b.vx; double dvy a.vy - b.vy; // 相对位移的长度平方: (dx dvx*t)^2 (dy dvy*t)^2 double A dvx * dvx dvy * dvy; double B 2 * (dx * dvx dy * dvy); double C dx * dx dy * dy - (a.radius b.radius) * (a.radius b.radius); if (A 0) return -1; // 相对速度为0不会碰撞 double discriminant B * B - 4 * A * C; if (discriminant 0) return -1; // 无实根不会碰撞 double sqrtD Math.sqrt(discriminant); double t1 (-B - sqrtD) / (2 * A); double t2 (-B sqrtD) / (2 * A); // 最早碰撞时刻应在当前帧内 if (t1 0 t1 1) return t1; if (t2 0 t2 1) return t2; return -1; }这里的t被归一化到 0 到 1 之间0 表示当前帧开始1 表示当前帧结束。t1是两球刚接触的时刻t2是它们分离的时刻。如果t1落在 0 到 1 之间说明这一帧内会发生碰撞就应该把球推进到t1那一瞬间再处理速度变化。这个算法的本质是把“离散检测”换成“连续检测”它不依赖帧率也不会因为速度大而漏碰撞。代价是每帧要对所有球两两计算一次不过台球桌上的球最多 16 个两两组合也就 120 次计算性能完全不是问题。3.3 碰撞后的速度修正一维弹性碰撞公式找到碰撞时刻后先把两个球的位置推进到碰撞瞬间再计算碰撞后的速度。两球质量相同台球比赛中球的规格一致一维弹性碰撞的速度交换公式可以简化碰撞后球 A 沿连心线方向的速度分量变成球 B 原来的分量反过来也一样。public void resolveCollision(Ball a, Ball b) { // 先退回到碰撞发生的时刻 double t findCollisionTime(a, b); if (t 0) return; a.move(-t * (1 - 0.0001)); // 回退到碰撞前 b.move(-t * (1 - 0.0001)); // 计算连心线方向的单位向量 double dx b.x - a.x; double dy b.y - a.y; double dist Math.sqrt(dx * dx dy * dy); if (dist 0) return; double nx dx / dist; double ny dy / dist; // 把速度分解到法线方向和切线方向 double v1n a.vx * nx a.vy * ny; double v2n b.vx * nx b.vy * ny; double v1t a.vx * ny - a.vy * nx; double v2t b.vx * ny - b.vy * nx; // 等质量弹性碰撞法线方向速度交换 double newV1n v2n; double newV2n v1n; a.vx newV1n * nx v1t * ny; a.vy newV1n * ny - v1t * nx; b.vx newV2n * nx v2t * ny; b.vy newV2n * ny - v2t * nx; }这里用到了“法线方向”和“切线方向”的分解(nx, ny)是两球心连线方向切线方向是(ny, -nx)。等质量弹性碰撞的特点是法线方向速度交换切线方向速度不变。这个分解是台球物理的核心必须理解透。如果你直接交换vx、vy效果会非常奇怪——球会像撞墙一样按照屏幕直角坐标系反弹而不是沿着球心连线方向反弹。位置回退时用1 - 0.0001而不是1是为了避免更新后两球依然重叠导致下一帧重复检测到碰撞。这个“微小提前量”是我调了很久才发现的细节很多人卡在“球一直抖”的问题上多半就是这里没处理干净。3.4 库边反弹与摩擦衰减参数需要一起调球的运动不能只靠碰撞桌面对球的摩擦和库边的反弹同样重要。摩擦我采用“每帧速度乘以衰减系数”的方式简单有效public void applyFriction(Ball ball) { ball.vx * TableSpec.FRICTION; ball.vy * TableSpec.FRICTION; if (Math.abs(ball.vx) 0.5 Math.abs(ball.vy) 0.5) { ball.vx 0; ball.vy 0; } }FRICTION 0.985表示每帧速度保留 98.5%也就是每分钟衰减约 0.985 的 60 次方大约掉到原来的 40% 左右。这个值决定了球能滚多远——太小球停得飞快影响击球手感太大球会满桌乱窜很难停球。常见做法是先设为 0.98 到 0.99 之间再根据实际手感微调。注意阈值 0.5当速度降到 0.5 像素/秒以下时直接归零否则小球会在桌面上“爬行”很久观感很差。库边反弹的处理就一行球碰到左/右库边时反转 vx碰到上/下库边时反转 vy再乘一个反弹恢复系数CUSHION。反弹系数不要设为 1现实中台球碰库边会损失一部分能量设成 0.95 左右比较真实。这里有个容易犯的错使用Math.abs判断碰边时要把球半径考虑进去。比如球心 x 坐标小于radius时才算碰左库边而不是 x 小于 0否则球会半边嵌到库边里。4. 用鼠标瞄准与击球把交互接进游戏循环4.1 拖拽方向与击球力度的映射台球小游戏的操作核心是“反向拖拽瞄准”从球心向外拖拖的方向是球被击打后运动方向的反方向。这个交互方式符合大多数人对台球游戏的直觉——杆在后面往后拉是预备击球。mousePressed: if (cueBall null || cueBall.inPocket) return; if (Math.abs(cueBall.vx) 0.1 || Math.abs(cueBall.vy) 0.1) return; dragStartX e.getX(); dragStartY e.getY(); mouseDragged: currentX e.getX(); currentY e.getY(); // 拖拽方向:从球心指向鼠标位置的向量,取反向 double dx currentX - tableToScreen(cueBall.x); double dy currentY - tableToScreen(cueBall.y); double dist Math.sqrt(dx * dx dy * dy); if (dist 10) return; // 避免鼠标点在球心上时方向混乱 aimDirX -dx / dist; aimDirY -dy / dist; power Math.min(dist / 5, 1.0); // 归一化到0~1 mouseReleased: if (power 0.2) { cueBall.vx aimDirX * power * TableSpec.MAX_POWER; cueBall.vy aimDirY * power * TableSpec.MAX_POWER; }这段逻辑里有几个参数值得说明power Math.min(dist / 5, 1.0)拖拽距离除以 5 映射到 0~1拖 100 像素就是满力。除以多少决定“多大的拖拽算满力”这个值跟窗口尺寸有关。如果你在物理坐标系里设计改成dist / TableSpec.WIDTH * 2更通用。power 0.2才触发击球避免鼠标轻轻一点就产生一个微弱但持续的滚动干扰下一次瞄准。这个阈值可以按手感调0.15~0.25 都算合理。击球必须限定母球静止球还在滚动时不能进入瞄准状态否则会出现“球还没停就被打出去”的荒谬场面。这是状态机存在的意义不只是摆设。4.2 辅助线绘制方向与力度的可视反馈玩家需要清晰的视觉反馈才能瞄准。我绘制了两条辅助线一条从球心出发指向击球方向即鼠标位置的相反方向长度随力量变化另一条是垂直于球杆的力度条根据power的值改变颜色。private void drawAimLine(Graphics2D g2d) { if (cueBall null || cueBall.inPocket) return; if (Math.abs(cueBall.vx) 0.1) return; double length 80 power * 150; // 基础长度80满力230 g2d.setColor(new Color(255, 255, 255, 120)); g2d.setStroke(new BasicStroke(2.0f)); // 从球心沿反方向画辅助线 g2d.drawLine( screenX(cueBall.x), screenY(cueBall.y), screenX(cueBall.x) - (int)(aimDirX * length), screenY(cueBall.y) - (int)(aimDirY * length) ); }辅助线的价格很低但对游戏体验提升非常明显。没有它玩家只能靠“瞎试”来找角度。如果你追求更好的手感还可以沿击球方向画一条虚拟轨迹预测母球前进 2~3 次碰撞后的位置不过那需要额外跑一次模拟适合放在“高级玩法”里做基础版先把瞄准线做好就够。4.3 进球判定与回合状态管理球进袋的判定用“球心到袋口中心距离小于袋口半径”来判断。注意袋口的半径要比球半径大 8~10 像素否则会经常出现球“卡在洞口又不进”的情况非常打击玩家信心。public void checkPocket(Ball ball) { double[][] pockets { {0, 0}, {TableSpec.WIDTH, 0}, {0, TableSpec.HEIGHT}, {TableSpec.WIDTH, TableSpec.HEIGHT}, {TableSpec.WIDTH / 2, 0}, {TableSpec.WIDTH / 2, TableSpec.HEIGHT} }; for (double[] p : pockets) { double dx ball.x - p[0]; double dy ball.y - p[1]; if (dx * dx dy * dy POCKET_RADIUS * POCKET_RADIUS) { ball.inPocket true; ball.vx 0; ball.vy 0; // 隐藏球体或者移动到界面外 ball.x -1000; ball.y -1000; break; } } }中袋的位置放在WIDTH / 2的上边缘和下边缘这是中式台球标准桌的六袋布局。进袋后把球移到界面外比直接在绘制时跳过它更稳妥因为碰撞检测还要遍历所有球如果球还在原位置其他球会跟“幽灵球”发生碰撞。回合状态的流转建议用一个枚举AIMING等待瞄准、ROLLING球在滚动、GAME_OVER所有球进袋或击球违例。主循环里每次更新后都检查状态如果cueBall速度为零且还有别的球在动就继续等待如果场上所有球都静止了再从ROLLING切回AIMING。这个切换逻辑写不好会带来“球还没停稳就进入瞄准”这种小问题但影响不小。5. 常见翻车现场台球物理的五个坑与排查方法5.1 球在高速运动时穿球而过现象大力击球后母球直接穿过目标球好像中间没东西。原因只做了离散帧的球心距检测一帧内位移量超过了球直径。解决改用“最早碰撞时刻”算法先算碰撞时间t把球推进到t时刻再交换速度。核心是解二次方程代码在前文findCollisionTime里。这是台球物理最值得投资的一个算法没有替代方案。5.2 两球接触后持续抖动或相互弹开现象两个球碰在一起后朝向对方的抖动持续很长时间甚至越推越远。原因碰撞速度交换后下一帧检测到两球仍处于重叠状态于是再次触发碰撞形成反复反弹。这是“碰撞后位置没有完全分离”导致的连锁反应。解决碰撞解决完成后手动把两球沿连心线方向推开epsilon距离比如dist 1像素。也可以在更新时给速度乘一个很小的0.9999让抖动能量快速衰减。我从维护经验里学到的做法是碰撞回退到 t 时刻后用Math.max(0, dist - (a.radius b.radius))确保至少不重叠。5.3 球滚动距离肉眼可见地异常现象球要么滚三秒才停要么手一碰就定在原地感觉不像台球。原因摩擦系数和阈值设置不匹配。衰减系数接近 1如 0.995时球会在停球阈值0.5 像素/秒附近徘徊很久衰减系数太小如 0.98球又缺乏滑行感。解决把FRICTION和停球阈值拆开调。先固定停球阈值 0.5用 0.98 起步测试理想效果是“大力击球能滚 3~4 秒轻推 1 秒内停住”。调完再回头微调阈值到 0.3~0.8 之间。这个参数非常依赖手感没有标准答案但“低衰减 高阈值”和“高衰减 低阈值”会有完全不同的体验二选一行。5.4 母球进袋后游戏还在更新但瞄准完全失灵现象母球进袋后点击鼠标无效或者球从袋口又“复活”了。原因没有处理cueBall.inPocket状态。进袋后球坐标被移到界面外但状态没有让游戏进入“重新摆球”分支或者inPocket判定后在下一帧又被碰撞算法碰回桌面。解决母球进袋后重置母球到开球点(WIDTH / 4, HEIGHT / 2)设置速度为 0然后进入AIMING状态。开球点坐标和进袋判定里的POCKET_RADIUS做成常量方便后续调整。注意在设置母球位置时清空所有速度分量否则母球会“带速重生”。5.5 库边反弹角度看起来不真实现象球碰库边后反弹角度明显不对像是被“磁铁吸住”再弹走。原因碰边判定用了球心坐标而不是球体边缘。比如左库边ball.x 0才反弹但实际上球碰到库边发生在ball.x radius时。等ball.x变成负数时球已经嵌入边里了反弹自然看起来滞后。解决碰边判定用ball.x - ball.radius 0反弹后把球心 x 直接设成radius同样处理右库边ball.x ball.radius WIDTH时设为WIDTH - radius。这个“夹紧”步骤很重要不然球嵌在边里时会产生错误的重叠导致后续碰撞判定错乱。6. 验证物理正确性的两个技巧从“能玩”到“手感对”6.1 写一个单球模拟法用于验证摩擦参数物理参数调起来最怕“黑匣子”——看不出是摩擦问题还是碰撞问题。我习惯先把球数减到 1只有一个母球和一条水平速度让它滚出去看它在屏幕上滚多远、多久停住。这样可以直接核对摩擦算得对不对。具体做法是在update里临时加一段测试代码强制把母球速度设为(800, 0)然后打印每帧的位置和速度。理论上有了FRICTION 0.985第 60 帧速度应接近800 * 0.985^60 ≈ 370第 120 帧约 170。如果实测偏差超过 2%说明摩擦的实现里可能有额外的速度损失比如在碰撞判断里误乘了衰减沿着这个方向查代码会快得多。6.2 用“能量守恒”检查碰撞算法台球碰撞算法的正确性可以用一个简单的数值实验验证两个质量相同的球A 沿 x 轴方向以速度v撞向静止的 B。碰撞后A 停止B 以速度v继续前进。这时系统总动能(v^2)前后不变动量也守恒。这个可以写成一个单元测试方法在游戏启动时自动跑一遍Test public void testElasticCollision() { Ball a new Ball(100, 100, 12, Color.WHITE); Ball b new Ball(130, 100, 12, Color.RED); a.vx 100; a.vy 0; resolveCollision(a, b); assertTrue(Math.abs(a.vx) 0.001); assertTrue(Math.abs(b.vx - 100) 0.001); }如果这个测试通过说明碰撞算法在“理想场景”下是对的。再往前一步可以验证斜碰A 从 45 度方向撞击 B碰撞后两球速度方向应该沿连心线对称展开。这类测试的价值在于当你改了摩擦参数或者加了新功能后能快速发现碰撞核心逻辑是否被动过。我个人习惯是在每次大改后跑一遍这个测试它帮我省了很多次在黑屏状态下盲猜问题的痛苦。台球小游戏做到“能玩”不难做到“手感对”需要耐心。参数调了一晚上最后只改了一个FRICTION从 0.982 到 0.985 这种事很常见。把物理验证的辅助代码留好以后改任何东西都敢动手这就是我眼里的后悔药。希望帮到你。本文还有配套的精品资源点击获取