Java五子棋游戏开发实战:从棋盘建模到AI对战全解析
简介面向Java开发者的五子棋游戏学习工程包覆盖面向对象设计、Swing图形界面构建、AI博弈算法和完整项目开发流程并给出可直接运行的程序示例。包内已实现棋盘类、棋子类、玩家类以及基于极小极大算法与Alpha-Beta剪枝的电脑AI代码包含合法落子判断、五子连珠检测、人机对战、悔棋、保存与加载进度等功能并附带一份论文式教程文档可引导读者从零搭建项目理解递归搜索、评估函数与剪枝优化等核心机制。资源共65个文件以14个头文件、12个C源文件、11个目标文件为主体同时包含可执行程序、音效、位图资源、图标、配置文件和Visual Studio工程文件目录结构清晰方便查看源码、编译调试或直接运行体验。整个RAR压缩包仅2.73MB已有205人学习下载适合用作课程设计、毕业设计或Java进阶练手项目。1. 从能玩到能赢Java 五子棋游戏到底要拆成几块java 五子棋游戏大概是课程设计里出镜率最高的小项目但多数人第一次写完只做到「能落子」悔棋和电脑对手都做得稀碎。我拆的这套资源是一份完整可运行的五子棋工程15×15 棋盘、双人对战、人机对战、悔棋、复盘、存档都有配套教程和课程设计文档把每个类和判定函数拆开讲适合交课程设计的学生也适合想拿小项目练 Swing 和二维数组的开发者。它解决的是「看着简单、动手全是坑」棋盘建模、鼠标坐标换算、五连判定、AI 怎么写、为什么窗口会花屏。这些问题过一遍Java 基础、面向对象和 Swing 事件机制就都练到了。2. 把棋盘建模做扎实二维数组、坐标映射与五子判定2.1 棋盘与棋子为什么用二维数组而不是一组按钮很多网上的例子会把棋盘做成JButton[][] buttons new JButton[15][15]每个按钮挂一个ActionListener。这种写法第一眼很直观点哪个按钮就在哪个位置落子但用不了几步就会卡住棋盘背景要画线按钮数量一多布局就很丑AI 要从按钮数组里反查位置代码绕一圈又回到二维下标。更麻烦的是人机对战、悔棋、复盘都要频繁读写盘面让按钮同时承担「显示」和「数据」两个职责最后界面层一改业务逻辑跟着崩。我一般会把棋盘数据和界面彻底分开。先写一个Board类内部只存一个int[][]用 0、1、2 分别表示空、黑棋、白棋。这样界面层只负责把数组画出来AI 层只负责读数组、算分数各自都不需要知道对方怎么实现。public class Board { public static final int SIZE 15; public static final int EMPTY 0; public static final int BLACK 1; public static final int WHITE 2; private final int[][] grid new int[SIZE][SIZE]; private int moveCount; public boolean put(int x, int y, int player) { if (x 0 || x SIZE || y 0 || y SIZE) { return false; } if (grid[x][y] ! EMPTY) { return false; } grid[x][y] player; moveCount; return true; } public void clear(int x, int y) { if (grid[x][y] ! EMPTY) { grid[x][y] EMPTY; moveCount--; } } public int get(int x, int y) { return grid[x][y]; } public boolean isFull() { return moveCount SIZE * SIZE; } }这段代码的逻辑很直白put先做边界检查再做占位检查都通过了才写数组并让moveCount加一。clear是为悔棋和 AI 的模拟落子准备的它同步减掉moveCount否则棋盘满了判断会失效。两个参数值得说清楚player传的是 1 或 2不是Color对象颜色映射完全留给界面层SIZE是static final如果作业要求改成 13 路或 19 路棋盘只需要改这一处其他代码基本不用动。2.2 落点换算与合法性校验Math.round 解决半格偏移棋盘画在JPanel上之后鼠标点击拿到的是像素坐标比如(px, py)而棋盘逻辑用的是格子下标。两者之间必须有换算。最常见的翻车点是直接用整数除法(px - MARGIN) / CELL。整数除法是向下取整点落在格子线右侧半个格子内也会被判成前一格整个棋盘像被平移了半格。正确的做法是把除法转成double再用Math.round取最近的格子线。如果棋盘左边距是 20 像素、格距是 36 像素那screenToGrid应该这样写public int[] screenToGrid(int px, int py) { int x (int) Math.round((px - MARGIN) / (double) CELL); int y (int) Math.round((py - MARGIN) / (double) CELL); if (x 0 || x Board.SIZE || y 0 || y Board.SIZE) { return null; } return new int[]{x, y}; }MARGIN和CELL一定要与画棋盘时用的常量完全一致我见过有人画的时候用 20 和 36换算的时候用 18 和 35结果整个棋盘点哪都错位。方法返回null表示点击点在棋盘外调用方直接忽略就好。还有一个细节这里的MARGIN是棋盘第一条线到面板边缘的距离不是棋盘中线的偏移。如果窗口大小会被用户调整最好固定setPreferredSize不要一边拖动窗口一边动态改CELL否则换算和绘制很难同步。2.3 五子判定四个方向累计而不是八方向五子连珠的判定看起来只要数「横竖斜」五个方向但很多新手写成八方向上下左右、四个对角每个方向单独写一遍写完已经绕晕。其实一个方向上的正向和反向是同一根线只需要检查四个主方向每个方向里往两头数。判定时有一个前提只需要检查当前落子的那一个点而不需要全盘扫描。因为只有刚落的这颗子可能形成五连其他区域没有新增棋子结果不会变。private static final int[][] DIRECTIONS { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; public boolean checkWin(int x, int y, int player) { for (int[] d : DIRECTIONS) { int dx d[0], dy d[1]; int count 1; for (int i 1; i 5; i) { int nx x dx * i; int ny y dy * i; if (nx 0 || nx SIZE || ny 0 || ny SIZE || grid[nx][ny] ! player) { break; } count; } for (int i 1; i 5; i) { int nx x - dx * i; int ny y - dy * i; if (nx 0 || nx SIZE || ny 0 || ny SIZE || grid[nx][ny] ! player) { break; } count; } if (count 5) { return true; } } return false; }这个写法的关键是「遇到边界、空格或对方棋子就 break」不能跳过对方棋子继续数。很多抄来的代码会把count数过对方棋子导致隔子也算赢。四个方向里{1,-1}是从左上到右下的斜线扫描如果你用{-1,1}也可以但方向数组里不要同时放互为反向的两项否则每条线会被统计两遍。count 5而不是count 5是因为存在 5 连以上连成的长连。标准无禁手规则下长连也算赢如果你们作业规定长连无效就要在判定后额外检查两端课程设计里一般不需要这么细。3. 把界面画对JPanel 重绘、鼠标落子与悔棋栈3.1 界面选型JPanel paintComponent 而不是 JFrame 里画有人会把绘制代码直接写在JFrame的paint()方法里窗口一拖动、一最小化就花屏。原因是JFrame本身有 content pane直接覆写paint会绕过 Swing 的重绘机制画出来的内容经常被其他层级盖住。正确做法是单独写一个BoardPanel extends JPanel把绘制逻辑放在paintComponent(Graphics g)里。这个方法会在需要重绘时被 Swing 自动调用我们只负责画不负责决定什么时候画。public class BoardPanel extends JPanel { private static final int MARGIN 20; private static final int CELL 36; private final Board board; public BoardPanel(Board board) { this.board board; setPreferredSize(new Dimension( MARGIN * 2 (Board.SIZE - 1) * CELL, MARGIN * 2 (Board.SIZE - 1) * CELL)); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (int i 0; i Board.SIZE; i) { g2.drawLine(MARGIN, MARGIN i * CELL, MARGIN (Board.SIZE - 1) * CELL, MARGIN i * CELL); g2.drawLine(MARGIN i * CELL, MARGIN, MARGIN i * CELL, MARGIN (Board.SIZE - 1) * CELL); } for (int x 0; x Board.SIZE; x) { for (int y 0; y Board.SIZE; y) { int player board.get(x, y); if (player Board.EMPTY) { continue; } g2.setColor(player Board.BLACK ? Color.BLACK : Color.WHITE); g2.fillOval(MARGIN x * CELL - 14, MARGIN y * CELL - 14, 28, 28); } } } }绘制分两层先画 15 条横线和 15 条竖线形成棋盘再遍历整个Board数组把有棋子的格子画成圆。fillOval的第三个参数 28 是棋子直径这里取CELL - 8让棋子之间留一点空隙观感更接近真实棋盘。代码里第一行super.paintComponent(g)不能省它负责清掉上一次绘制留下的内容否则窗口重绘时可能出现残影。setPreferredSize配合JFrame.pack()使用窗口会自动按棋盘大小调整不用手动 setSize。3.2 鼠标落子与回合控制状态字段挡住重复点击鼠标交互挂在BoardPanel上用MouseAdapter就够了。要注意监听器应该加在棋盘面板上而不是加到JFrame上否则点击事件会被子面板「吃掉」控制台一点反应都没有。boardPanel.addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { if (gameState ! GameState.PLAYING) { return; } int[] pos boardPanel.screenToGrid(e.getX(), e.getY()); if (pos null) { return; } int x pos[0]; int y pos[1]; if (!board.put(x, y, currentPlayer)) { return; } moveStack.push(new int[]{x, y, currentPlayer}); boardPanel.repaint(); if (board.checkWin(x, y, currentPlayer)) { gameState GameState.GAME_OVER; JOptionPane.showMessageDialog(boardPanel, currentPlayer Board.BLACK ? 黑棋胜 : 白棋胜); } else if (board.isFull()) { gameState GameState.GAME_OVER; JOptionPane.showMessageDialog(boardPanel, 平局); } else { currentPlayer 3 - currentPlayer; maybeRobotMove(); } } });事件处理的顺序很重要先判断状态再换算坐标再调用put落子。put已经把越界和占位都验过了所以这里拿到false可以直接 return界面不用弹错误框体验反而是自然的。currentPlayer 3 - currentPlayer是一个取巧写法因为黑棋常量是 1、白棋常量是 2加起来正好是 33 减去当前值会得到另一个玩家。这个写法在 AI 代码里也常用但是可读性略差介意的话写 if-else 也行。moveStack在成功落子后才 push顺序千万不能反否则错的点也会被记录。3.3 悔棋、复盘与回放用栈把每一步都留住悔棋的本质是「撤销最近一次落子」用Stackint[]天然合适。每步棋存{x, y, player}悔棋时从栈顶弹出一个点把棋盘上对应位置清空。看起来很简单但人机对战模式下有个大坑如果悔棋只弹玩家这一步AI 的下一步还留在棋盘上棋局等于没悔。我一般会这样处理玩家悔棋时先把玩家的那步弹掉再判断当前模式是否人机对战。如果是且栈顶还有一步 AI 的棋就一起弹掉再把currentPlayer设回玩家颜色。public void undo() { if (moveStack.isEmpty()) { return; } int[] last moveStack.pop(); board.clear(last[0], last[1]); if (isHumanVsAi last[2] AI_PLAYER !moveStack.isEmpty()) { int[] aiMove moveStack.pop(); board.clear(aiMove[0], aiMove[1]); } currentPlayer HUMAN_COLOR; boardPanel.repaint(); }这个方法的边界在于如果玩家刚落子、AI 还没来得及响应栈顶是玩家的棋last[2] ! AI_PLAYER就只弹一步。但课程设计里 AI 通常在同一线程内同步计算玩家落子后 AI 立刻落子不会出现这种中间状态。复盘回放就更简单了既然每步棋都记录在moveHistory里清空棋盘后用一个Timer每隔几百毫秒放一颗子就能做出最简单的回放效果。真正的工程量不在回放而在「棋盘清空后悔棋栈和历史记录也要同步重置」很多项目做完这一步才发现旧记录还在一按悔棋就把复盘时的子给清了。4. 人机对战评分表权值 AI 加一步杀棋检查4.1 为什么选权值法而不是一上来就 Alpha-Beta课程设计里最常见的要求是「电脑会下棋」但没说一定要下得多聪明。很多人一上来就抄最小最大搜索15×15 的棋盘分支数大得离谱深度调到 4 就可能卡死最后交上去的演示现场翻车。对这个小项目我推荐用权值法给每个空点打分认为对己方连子越有利的点分数越高然后选分数最高的点落子。它的优点是实现量小、运行快、每一步棋都能解释而且调整难度只需要改打分规则。Alpha-Beta 搜索听着高级但搜索树、棋盘恢复、评估函数三件事同时出错时你根本不知道该调哪里。4.2 评估函数四个方向的连子数乘上开放端权值权值法核心是评估函数。我的做法是假设某个空点已经放了玩家棋子然后沿四个方向数连子再统计这条线的两端是否为空。两端都空叫「活」只有一端空叫「眠」被对方堵死的那条线直接不记分。一张表先把权值定下来落子后棋形两端情况权值5 连及以上无所谓1000004 连两端都通500004 连一端通100003 连两端都通40003 连一端通5002 连两端都通5002 连一端通50这些数值不是标准答案是我调过几轮之后的经验值。关键是 5 连的优先级必须远远高于其他棋形否则电脑可能为了做活三而错过直接赢的机会。private int scoreDirection(int x, int y, int player, int dx, int dy) { int count 1; int openEnds 0; int nx x dx; int ny y dy; while (nx 0 nx Board.SIZE ny 0 ny Board.SIZE board.get(nx, ny) player) { count; nx dx; ny dy; } if (nx 0 nx Board.SIZE ny 0 ny Board.SIZE board.get(nx, ny) Board.EMPTY) { openEnds; } nx x - dx; ny y - dy; while (nx 0 nx Board.SIZE ny 0 ny Board.SIZE board.get(nx, ny) player) { count; nx - dx; ny - dy; } if (nx 0 nx Board.SIZE ny 0 ny Board.SIZE board.get(nx, ny) Board.EMPTY) { openEnds; } if (count 5) return 100000; if (count 4) { return openEnds 2 ? 50000 : openEnds 1 ? 10000 : 0; } if (count 3) { return openEnds 2 ? 4000 : openEnds 1 ? 500 : 0; } if (count 2) { return openEnds 2 ? 500 : openEnds 1 ? 50 : 0; } return 0; }注意count从 1 开始因为这代表「当前空点放上棋子后的连子数」而这里并没有真正把棋子写进棋盘所以不需要put和clear。这也是这个实现不容易把棋盘改乱的原因。单方向分数算完之后把四个方向加起来就是这个点对某一方的价值private int scorePoint(int x, int y, int player) { return scoreDirection(x, y, player, 1, 0) scoreDirection(x, y, player, 0, 1) scoreDirection(x, y, player, 1, 1) scoreDirection(x, y, player, 1, -1); }落子时把「对自己 的价值」和「对对手的价值 × 2」相加防守权重略大于进攻权重。权重太小电脑会只顾进攻被对手连环堵死权重太大电脑又过于保守棋盘上全是防守子。我一般用 2.0 这个系数起步。public int[] bestMove(int player) { int bestScore 0; int[] best null; for (int x 0; x Board.SIZE; x) { for (int y 0; y Board.SIZE; y) { if (board.get(x, y) ! Board.EMPTY) { continue; } if (!nearStone(x, y)) { continue; } int score scorePoint(x, y, player) scorePoint(x, y, 3 - player) * 2; if (score bestScore) { bestScore score; best new int[]{x, y}; } } } if (best null) { return new int[]{Board.SIZE / 2, Board.SIZE / 2}; } return best; }nearStone是扫描当前空点周围两格内有没有已落棋子。棋盘空的时候没有任何候选点AI 直接走天元棋局中后期这个过滤能大幅减少要打分的点AI 的计算量从 225 个点降到几十个点人机对战完全感觉不到卡顿。private boolean nearStone(int x, int y) { for (int i -2; i 2; i) { for (int j -2; j 2; j) { int nx x i; int ny y j; if (nx 0 nx Board.SIZE ny 0 ny Board.SIZE board.get(nx, ny) ! Board.EMPTY) { return true; } } } return false; }4.3 一步杀棋检查与难度分级给普通权值法再加两道保险纯权值法有个明显弱点它只看当前局面评分不看「下一步能不能赢」。更弱智的写法甚至不会堵对手的「四三」对手快赢了还自顾自做活二。补救办法是在bestMove之前加一个一步杀棋检查。public boolean isWinningMove(int x, int y, int player) { board.put(x, y, player); boolean win board.checkWin(x, y, player); board.clear(x, y); return win; }这个方法的本质是「先落子试一下赢了就恢复棋盘」。用完之后必须clear还原否则棋盘状态被污染后面所有评分都会错。我见过有人把这个函数写在循环里忘了clearAI 下着下着棋盘上凭空多出好几颗子。综合起来的决策顺序是这样public int[] decideMove(int player) { for (int[] p : candidatePoints()) { if (isWinningMove(p[0], p[1], player)) { return p; } } int opponent 3 - player; for (int[] p : candidatePoints()) { if (isWinningMove(p[0], p[1], opponent)) { return p; } } return bestMove(player); }先看自己能不能一步成五能就直接赢再看对手下一步会不会成五会就必须堵两件事都没有就按权值选点。这个顺序让 AI 有最基础的攻防意识做一个课程设计的人机对战已经够看了。难度分级也在这里做普通权值法直接选最高分简单难度可以在分数最高的五个候选里随机挑一个困难难度可以在权值法基础上再模拟一轮对手的回应相当于一层极浅的搜索。课程设计通常做到「简单 / 普通」两档就足够答辩了。5. 五子棋项目避坑从空指针到界面卡死的五个现场5.1 现象点击棋盘没有反应控制台也不报错很多人把MouseListener加到了JFrame上却把棋盘画在BoardPanel里。Swing 的鼠标事件会优先派发给最上层的子组件BoardPanel挡住了事件JFrame上的监听器根本收不到所以点哪里都没反应。原因就是监听器挂错了对象。解决方法是把监听器加到BoardPanel上或者在JFrame上设置一个透明玻璃面板拦截事件。课程设计里直接用第一种简单不容易出错。另外确认screenToGrid返回的不是null如果点击偏到棋盘外事件会静默忽略看起来也像没反应。5.2 现象窗口最小化再还原棋盘出现残影和错位这是把绘制代码写在错误组件里的典型症状。直接在JFrame.paint()里画棋盘、不调用super.paint()窗口每次重绘都不会先清理旧内容上一帧的线条和棋子会残留在新帧下面。解决方法是把全部绘制逻辑放进BoardPanel.paintComponent并且第一行调用super.paintComponent(g)。之后每次模型变化就调boardPanel.repaint()让 Swing 决定什么时候重绘不要手动去碰getGraphics和paint方法。5.3 现象悔棋白悔或者越悔棋盘越空人机对战模式下玩家点悔棋后电脑马上又下一子看起来就像没悔过。原因是悔棋只弹掉了玩家的最后一步AI 那步还在栈里、还在棋盘上。如果连续悔棋还可能把栈弹空导致currentPlayer变成null或者下一次落子时空指针。解决的思路是「一次悔棋 撤销一整个回合」。人机模式弹两步双人模式弹一步弹完之后把当前玩家强制设回玩家颜色再repaint。写undo()时一定要先判断栈是否为空不要在弹空之后继续访问栈顶。5.4 现象存档读档后棋子都在但轮到谁走全乱了只把Board里的二维数组存进文件重启后读出来棋盘没问题但currentPlayer还是初始化时的黑棋悔棋栈也是空的。按悔棋直接空指针轮到自己电脑下时又多用错颜色。这就是「只存了数据没存状态」。解决方法是做一个GameSnapshot把grid、currentPlayer、moveHistory、模式、步数一起写进文件。读档时先恢复棋盘再恢复这些流程变量。如果用了对象序列化记得transient字段不要做存档用否则读出来全是默认值。5.5 现象轮到电脑下棋时窗口卡成白板人机模式里AI 想棋和界面重绘都挤在鼠标事件线程上。一旦评估函数里放了大量Thread.sleep模拟思考、或者写了递归打印日志重绘就排队等着窗口表现成未响应。解决方法是把 AI 计算放到SwingWorker里计算结束后在done()回调里更新棋盘和界面不要在mouseClicked里同步等 AI。这样点完一个子窗口立刻刷新AI 算多久都不会卡住界面。课程设计如果只是 15×15 权值扫描其实很快但养成这个习惯后面加搜索深度时才不会被坑。6. 给代码留条后路状态机管住对局流程再加一个复盘入口很多五子棋项目越改越乱是因为流程控制散落在各个事件监听器里。落子监听器里判断游戏是否结束按钮监听器里又判断能不能悔棋AI 线程里又重复判断一遍。加一个暂停功能就能改出三四个 bug。我从这套资源里学到最值钱的做法是给整个对局定义一个状态机。先列一张状态表再写代码流程就会很清楚状态允许的操作下一状态WAITING开始新对局PLAYINGPLAYING落子、悔棋、存档、复盘GAME_OVER / WAITINGGAME_OVER复盘、重开、退出WAITING代码里就是一个枚举public enum GameState { WAITING, PLAYING, GAME_OVER }所有入口事件统一先判断状态符合条件才往前走。落子、悔棋、AI 响应、按钮点击都走这一个入口状态迁移只有两处落子后发现赢棋或棋盘满进入GAME_OVER用户点「再来一局」回到WAITING。这样不会再出现「游戏结束还能继续落子」这种玄学 bug。复盘入口也是这个状态机带来的顺手功能。棋盘数据、悔棋栈、当前玩家都在同一个控制器里被管理清空棋盘后只需要把moveHistory按顺序回放。如果一开始就把流程写成三五个 if 散落在不同类里想加复盘就得把每个事件都搜一遍。从那以后我每次写 Swing 小游戏都强制先手写一张状态迁移表贴在类注释里写完状态机再碰界面代码。这套工程的源码、教程和课程设计文档都在资源包里拿下来对照第 2 章到第 4 章的顺序重写一遍比只看代码记得牢。五子棋这样的项目真正的收获不在能跑而在改得动、讲得清希望帮到你。本文还有配套的精品资源点击获取