ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Swing 汉诺塔图形课设:六类拆分与递归自动演示

Java Swing 汉诺塔图形课设:六类拆分与递归自动演示 简介这份资源是面向Java初学者与高校课程设计学生的汉诺塔Hannoi塔游戏课程设计报告围绕递归算法与Swing图形界面开发展开适合正在完成Java程序设计课程设计、需要参考完整项目实现思路与文档写作规范的读者。包内共1个doc文档约606KB内容涵盖课程设计目的、系统需求分析、系统概要设计与详细设计、开发环境J2SDK与Eclipse等章节。文档以HannoiWindow、Tower、TowerPoint、Disc、HandleMouse、AutoMoveDisc六个类的组合关系为主线讲解盘子拖动、大小规则校验、盘子数量与颜色调整、背景音乐控制、自动移动与重新开始等功能的实现细节并附有UML类图与类间关系说明同时梳理了递归边界条件、GUI布局事件处理、非法操作提示等常见难点的解决思路。已有111人学习下载可供读者对照参考快速理清面向对象设计与递归求解的完整开发流程。1. 从一份拆成 6 个类的 Hannoi 塔课设说起汉诺塔的递归三行代码谁都会背难的是把它做成能拖、能校验、能自动演示的图形程序。这份课设的思路值得借鉴它没有把所有逻辑塞进一个 main 方法而是切成 HannoiWindow、Tower、TowerPoint、Disc、HandleMouse、AutoMoveDisc 六个类各管一段——窗口管菜单和按钮Tower 管三个座和坐标TowerPoint 管每个可落位置Disc 干脆继承 JButton 当盘子用鼠标逻辑和自动求解各自独立成类。对刚学完 Java 面向对象、准备做课设的在校生来说这是一份能直接跑起来、也能顺手改造的模板对已经写了几年业务代码的人回头看这套「用坐标和状态位模拟物理规则」的朴素做法反而能看清 GUI 事件驱动模型的骨架。2. HannoiWindow 窗口装配与三档难度的状态复位主窗口是整个程序唯一的入口它不做游戏逻辑只负责组装面板、发号施令。新手最容易在这里犯的错是把盘子的创建、坐标计算、鼠标监听全写进构造函数结果窗口一改尺寸整个程序就崩。合理的边界是窗口只持有 Tower 的引用通过几个 set 方法把参数递进去。2.1 窗口骨架与布局参数public class HannoiWindow extends JFrame implements ActionListener { Tower tower null; int amountOfDisc 3; char[] towerName {A, B, C}; JMenuBar bar; JMenu menuGrade; JMenuItem oneGradeItem, twoGradeItem, threeGradeItem; JButton renew null, autoButton null; HannoiWindow() { tower new Tower(towerName); // 先设参数再放置盘子顺序不能反 tower.setAmountOfDisc(amountOfDisc); tower.setMaxDiscWidth(120); tower.setMinDiscWidth(50); tower.setDiscHeight(16); tower.putDiscOnTower(); add(tower, BorderLayout.CENTER); // Tower 是 JPanel 子类放中间 bar new JMenuBar(); menuGrade new JMenu(选择级别); oneGradeItem new JMenuItem(初级); twoGradeItem new JMenuItem(中级); threeGradeItem new JMenuItem(高级); menuGrade.add(oneGradeItem); menuGrade.add(twoGradeItem); menuGrade.add(threeGradeItem); bar.add(menuGrade); setJMenuBar(bar); // 菜单项与按钮共用同一个监听器靠 getSource() 区分 oneGradeItem.addActionListener(this); twoGradeItem.addActionListener(this); threeGradeItem.addActionListener(this); renew new JButton(重新开始); renew.addActionListener(this); autoButton new JButton(自动演示); autoButton.addActionListener(this); JPanel north new JPanel(); north.add(renew); north.add(autoButton); add(north, BorderLayout.NORTH); setResizable(false); setBounds(60, 60, 460, 410); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setVisible(true); validate(); } }这段代码里真正需要留意的是四个几何参数它们互相咬合改一个就得核对另外三个。参数初值作用改动后的连带影响amountOfDisc3盘子个数决定 Disc 数组和三个塔点数组的长度最少 2^3-17 步maxDiscWidth120最大盘宽度同时决定三个座的水平基准塔点 x 分别是它的 1、2、3 倍minDiscWidth50最小盘宽度与 max 一起决定相邻盘的宽度差discHeight16盘子高度决定塔点的纵向间距、竖线起止点和底部横条位置setBounds(60, 60, 460, 410)的宽必须留够 3 倍 maxDiscWidth 加左右余量否则 C 座会被裁掉一截鼠标永远点不到最右边的盘子。2.2 三档难度切换的复位路径菜单项的响应逻辑短但顺序有讲究。public void actionPerformed(ActionEvent e) { if (e.getSource() oneGradeItem) { amountOfDisc 3; tower.setAmountOfDisc(amountOfDisc); tower.putDiscOnTower(); // 内部先 removeDisk 再重建 } else if (e.getSource() twoGradeItem) { amountOfDisc 4; tower.setAmountOfDisc(amountOfDisc); tower.putDiscOnTower(); } else if (e.getSource() threeGradeItem) { amountOfDisc 5; tower.setAmountOfDisc(amountOfDisc); tower.putDiscOnTower(); } else if (e.getSource() renew) { tower.setAmountOfDisc(amountOfDisc); // 沿用当前档位不清零 amountOfDisc tower.putDiscOnTower(); } else if (e.getSource() autoButton) { tower.setAmountOfDisc(amountOfDisc); tower.putDiscOnTower(); int x getBounds().x getBounds().width; int y getBounds().y; tower.getAutoMoveDisc().setLocation(x, y); tower.getAutoMoveDisc().setSize(280, getBounds().height); tower.getAutoMoveDisc().setVisible(true); } validate(); }「重新开始」这里有个细节它不能重置amountOfDisc否则用户在高级档玩到一半点重开会被打回三盘。盘子数量是窗口级别的状态Tower 只是执行者这个职责划分要守住。2.3 自动演示对话框的定位自动演示按钮做三件事复位棋盘、把 AutoMoveDisc 对话框贴到主窗口右侧、设成可见。getBounds().x getBounds().width让对话框紧贴主窗口右边沿不会盖住棋盘高度直接取主窗口高度这样步数列表和四个按钮都能排下。如果窗口被拖到屏幕最右侧对话框会跑到屏幕外常见做法是判断x 280是否超出屏幕宽度再回退到主窗口左侧。3. Tower 塔点模型与盘子几何参数的计算Tower 是整个项目的重心它同时承担了两件事维护三根柱子上的盘子分布以及把这份分布翻译成像素坐标。把这两件事混在一起写是课设里最常见的结构问题但在这个规模下用「塔点数组」做中间层已经足够清晰。3.1 TowerPoint 与坐标系的对应关系TowerPoint 只存四个东西坐标 x、y占位标志 haveDisc以及压在上面的 Disc 引用。public class TowerPoint { int x, y; boolean haveDisc; Disc disc null; public boolean isHaveDisc() { return haveDisc; } public void setHaveDisc(boolean boo) { haveDisc boo; } // 把盘子压到该点容器的添加顺序决定叠放顺序 public void putDisc(Component com, Container con) { int w con.getWidth(); int h con.getHeight(); disc (Disc) com; setHaveDisc(true); disc.setPoint(this); // 盘子中心对齐塔点而不是左上角对齐 con.add(disc); disc.setLocation(x - disc.getWidth() / 2, y - disc.getHeight() / 2); con.setComponentZOrder(disc, 0); } public Disc getDiscOnPoint() { return disc; } public void removeDisc(Component com, Container con) { if (disc ! null con ! null) con.remove(disc); disc null; setHaveDisc(false); } }关键在putDisc里的居中塔点的 (x, y) 是盘子中心不是左上角。少了x - disc.getWidth()/2这一步所有盘子会向右偏移半个身位越往下越明显看起来像是斜着堆的。3.2 塔点生成与盘子宽度递推public void putDiscOnTower() { removeDisk(); // 重建前必须清场 int n (maxDiscWidth - minDiscWidth) / amountOfDisc; disc new Disc[amountOfDisc]; for (int i 0; i disc.length; i) { disc[i] new Disc(); disc[i].setNumber(i); // 编号越小盘子越窄 int diskWidth minDiscWidth i * n; disc[i].setSize(diskWidth, discHeight); disc[i].addMouseListener(handleMouse); disc[i].addMouseMotionListener(handleMouse); } pointA new TowerPoint[amountOfDisc]; pointB new TowerPoint[amountOfDisc]; pointC new TowerPoint[amountOfDisc]; int verticalDistance discHeight; for (int i 0; i pointA.length; i) { pointA[i] new TowerPoint(maxDiscWidth, 100 verticalDistance); verticalDistance discHeight; } // pointB、pointC 同理x 分别取 2*maxDiscWidth 和 3*maxDiscWidth for (int i 0; i pointA.length; i) { pointA[i].putDisc(disc[i], this); // 初始全部堆在 A 座 } handleMouse.setPointA(pointA); handleMouse.setPointB(pointB); handleMouse.setPointC(pointC); autoMoveDisc new AutoMoveDisc(this); autoMoveDisc.setTowerName(towerName); autoMoveDisc.setAmountOfDisc(amountOfDisc); autoMoveDisc.setPointA(pointA); autoMoveDisc.setPointB(pointB); autoMoveDisc.setPointC(pointC); validate(); repaint(); }宽度公式minDiscWidth i * n有个取整偏差当 amountOfDisc5 时n (120-50)/5 14最大盘只有 504×14 106用不满 120。想让它铺满把除法改成先乘后除或者把 n 定义为(maxDiscWidth - minDiscWidth) / (amountOfDisc - 1)并单独处理只有一盘的情况。另外注意塔点数组下标 0 在顶部下标 amountOfDisc-1 在底部。初始化时pointA[i].putDisc(disc[i], this)把编号大的宽盘放在了大下标处视觉上自然形成上小下大。这个「下标越大越靠下」的约定会被绘制、拖拽、自动求解三处复用不能中途改。3.3 绘制顺序与竖线锚点paintComponent负责画三根柱子的竖线、底座横条、每个塔点的小圆点和座标文字。它的调用时机是repaint()所以每次盘子移动后都要触发一次。绘制的顺序也有讲究绘制内容坐标来源说明竖线point[i][0] 到 point[i][n-1]y 各向外扩 discHeight/2把盘子包住底座横条pointA[n-1] 到 pointC[n-1]蓝色 fillRect高度固定 6塔点圆点每个塔点中心直径 5画在盘子之后会被遮住座名文字point[i][n-1].getY()50A座B座C座注意事项paintComponent里直接访问pointA[0]如果 Tower 还未执行过putDiscOnTower()就被重绘会抛 NullPointerException。稳妥写法是在方法开头加一句if (pointA null) return;或者在构造 Tower 时就完成一次初始化。4. HandleMouse 拖拽判定与 AutoMoveDisc 递归演示鼠标拖拽和自动求解是两条互不干扰的通道共用同一套塔点数据。前者解决「人怎么操作」后者解决「机器怎么演示」放在两个类里是对的。4.1 鼠标三阶段的事件分工方法触发时机职责mousePressed按下左键记录盘子原占塔点记录鼠标在盘内的相对偏移mouseDragged按住拖动让盘子按偏移量跟着鼠标走实时 setLocationmouseReleased松开左键求最近合法落点能放就落不能放就弹回原塔点public void mousePressed(MouseEvent e) { Disc disc (Disc) e.getSource(); startPoint disc.getPoint(); // 记住原位置非法时回退用 x e.getX(); // 相对偏移避免盘子跳到鼠标左上角 y e.getY(); } public void mouseDragged(MouseEvent e) { Disc disc (Disc) e.getSource(); disc.setLocation(disc.getX() e.getX() - x, disc.getY() e.getY() - y); } public void mouseReleased(MouseEvent e) { Disc disc (Disc) e.getSource(); endPoint findNearestPoint(disc); // 在三个座里找距离最近的塔点 if (endPoint ! null canPut(disc, endPoint)) { startPoint.removeDisc(disc, con); // 先摘下来再压到新位置 endPoint.putDisc(disc, con); } else { startPoint.putDisc(disc, con); // 非法则原位放回 } con.repaint(); }x、y这两个偏移量是必须的。如果拖拽时直接disc.setLocation(e.getX(), e.getY())鼠标点在盘子哪个角落盘子就跳到哪手感会非常跳。4.2 合法落点的两条判定规则只有一条不能把大盘放在小盘上面。落到代码里要拆成两个条件——落点必须是该座当前第一个空位且该空位下面那张盘的编号要比本盘小编号小盘小应在上方。private boolean canPut(Disc disc, TowerPoint target) { if (target.isHaveDisc()) return false; // 位置已被占 // 校验它下方的塔点如果下方有盘本盘必须更小 for (TowerPoint[] tower : new TowerPoint[][]{pointA, pointB, pointC}) { int idx indexOf(tower, target); if (idx 0) { if (idx 1 tower.length tower[idx 1].isHaveDisc()) { Disc below tower[idx 1].getDiscOnPoint(); return disc.getNumber() below.getNumber(); } return true; // 到底了随便放 } } return false; }反过来写判断也行below.getNumber() disc.getNumber()说明下面那张更宽摆在它上面合法。这里最容易错的是把编号方向记反了建议在 Disc 上加一句注释「number 越小越窄」省得三天后自己都看不懂。4.3 递归步骤预生成与定时播放自动演示不能真的在新线程里递归调用移动方法那样界面会被瞬间刷完看不到过程。常见做法是先用递归把每一步压进列表再用javax.swing.Timer按固定间隔逐条回放。// 预生成移动序列把 n 个盘从 a 经 c 移到 b private void buildSteps(int n, char a, char b, char c) { if (n 1) { steps.add(a - b); return; } buildSteps(n - 1, a, c, b); // n-1 个先挪到辅助座 steps.add(a - b); // 最大的那个直接到位 buildSteps(n - 1, c, b, a); // 再把 n-1 个挪过去 } // Timer 每触发一次执行一步 private void playNext() { if (moveStep steps.size()) { time.stop(); return; } String s steps.get(moveStep); char from s.charAt(0), to s.charAt(3); TowerPoint src topPointOf(from); // 取出该座最上面的盘 TowerPoint dst emptyPointOf(to); // 该座第一个空位 Disc d src.getDiscOnPoint(); src.removeDisc(d, tower); dst.putDisc(d, tower); tower.repaint(); showStep.append(s \n); // 步数同步滚进文本区 }步数总量是 2^n - 1这个数字决定了演示时长也决定了文本区需要多大。盘子数最少步数500ms 间隔下的时长37约 3.5 秒415约 7.5 秒531约 15.5 秒Timer 的间隔建议放在 300ms 到 800ms 之间。低于 200ms 人眼跟不上高于 1000ms 又显得卡。对话框上的暂停按钮对应time.stop()继续按钮对应time.start()但要注意暂停后moveStep必须保留否则会从头开始。5. 编译链路、坐标越界与演示稳定性的排查技巧这个项目的编译命令很朴素但在中文环境里有几个坑值得提前说清楚。六个源文件放在同一目录下用javac *.java批量编译再运行主类# 指定编码避免注释里的中文把编译搞崩 javac -encoding UTF-8 HannoiWindow.java Tower.java Disc.java TowerPoint.java HandleMouse.java AutoMoveDisc.java java HannoiWindow-encoding不能省。用 GBK 保存的源码在 UTF-8 环境下编译会报「编码 GBK 的不可映射字符」反过来也一样。Eclipse 里对应的是 Window → Preferences → General → Workspace 的 Text file encoding设成和文件实际编码一致这个项目里六个文件要保持统一。几个高频故障和对应处理现象根因处理启动即抛 NullPointerException堆栈指向 paintComponentpointA 还是 null 就触发重绘在 paintComponent 开头判空或先 putDiscOnTower 再 setVisible盘子能拖但松手后消失removeDisc 里 con.remove 了盘但没重新 add落点为空时把盘子放回 startPoint别只调 remove高难度下 C 座点不到窗口宽度小于 3×maxDiscWidth调大 setBounds 的宽或调小 maxDiscWidth点一次「自动演示」后速度越来越快每次点击都 new 一个 Timer多个计时器叠加在按钮响应里先判断 time ! null 再 stop然后重建盘子被底座横条盖住组件添加顺序晚于绘制用 setComponentZOrder 把 Disc 提到最上层再补一个验证递归正确性的土办法把buildSteps产出的序列打印出来检查三个座上的盘子数是否始终满足「上小下大」以及最终是否全部落到目标座。三盘应该正好 7 步四盘 15 步五盘 31 步数量对不上就说明递归的边界条件写错了——通常是n 1那条分支被写成了n 0导致每层多算一步。如果要继续往外扩比较值得做的是把手动操作的每一步也记进一个操作栈提供「撤销」和「回放」再进一步可以在每次落子后判断当前局面是否仍处在最短解路径上给出提示。这两个改动都只需在 HandleMouse 的落点分支里加几行记录代码塔点模型本身不用动。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进