ARTICLE DETAIL

资讯详情

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

Java五子棋对战系统:从Socket到AI算法的毕设实战解析

Java五子棋对战系统:从Socket到AI算法的毕设实战解析 简介一套基于Java实现的五子棋对战系统课程设计源码面向正在完成课设的计算机专业学生以及希望入门Java游戏开发的编程学习者。整个压缩包共290个文件约18.26MB其中17个Java源文件构成游戏核心逻辑包括初始化、玩家操作处理与胜负判断265个GIF图片为棋盘、棋子及动态界面元素另有XML配置文件、WAV音效、说明文档及项目工程文件等共同支撑起一个可运行的完整项目。资源已有282人学习下载适合需要参考完整项目结构、学习GUI交互与事件处理的学生。通过阅读和调试这份源码可以理解游戏状态管理、界面绘制与资源组织方式也能直接基于其目录结构进行二次开发或改造作为课程设计答辩的实践支撑。1. Java五子棋对战系统毕设/课设的经典题里子却比想象中深很多计算机专业的同学第一次接触“Java五子棋对战系统”这个题目时第一反应是“棋盘画出来鼠标点一下落子赢了弹个窗”。真动手才发现这个题目从“能用”到“能答辩”之间隔着网络通信、并发处理、界面刷新和状态同步四座山。它既是Swing/AWT图形界面的入门练手又是Java Socket编程、多线程和简单AI算法的综合试验场——对求职面试里常被问到的“线程安全”“粘包拆包”这些点这个项目恰好能给出最直观的答案。这套系统的典型形态是C/S架构一方启动服务器端另一方通过客户端连接双方各自维护一份棋盘副本每次落子都通过Socket广播坐标。别小看这个架构它涵盖了协议设计、消息时序和异常恢复比如一方强退三大块把这些理清楚项目质量立刻从“课程设计”跳到“能写进简历”。这篇文章按我自己的实现路径来拆解先讲系统怎么分层设计再落到核心算法和网络模型最后把我在调试中踩过的坑一个个摆出来。目录按下面这个顺序走整体设计 → 对局核心算法 → 网络对战实现 → 避坑指南 → 最终验证与进阶优化。中间会给出可以直接抄的代码结构和关键代码但更重要的是理解每一步为什么这么定。2. 开局先分层这个系统到底由哪几个独立模块组成2.1 模块划分不要把画棋盘和判输赢写进同一个类很多新手一上来就在JFrame的paint方法里写胜负判断这是最糟糕的做法。五子棋系统的核心诉求是“对战”对战意味着至少两个参与者本地人机或双人同屏、跨机器网络意味着棋盘状态必须能被共享和维护。我的分层方式是经典的“三段式”界面层View、逻辑层Model、网络层Network。界面层只做两件事把二维数组画上去把鼠标点击的坐标转换成数组下标。逻辑层维护一个int[15][15]的棋盘数组提供落子、判断胜负、悔棋、重新开始这些操作。网络层负责把“我落子了”这个事件编码成消息发给对方同时把对方的落子消息解码后交给逻辑层。三层之间用接口解耦典型接口如下public interface GameListener { void onMove(int row, int col, int player); // 对方落子通知 void onWin(int player); // 胜负判定通知 void onDisconnect(String reason); // 对端断线通知 void onRestart(); // 对方请求重开 }逻辑层实现这个接口界面层也实现这个接口网络层只管收发消息不关心消息到达后是谁在处理。这样设计的好处是本地双人对战、人机对战、网络对战三种模式只需要替换一部分代码。比如人机对战你只需要在“轮到电脑”时调用AI模块的落子方法而不是走网络消息。代码结构上我通常建这么几个包view棋盘面板、主窗口、model棋盘状态、落子规则、胜负判断、ai评估函数、搜索算法、network服务器、客户端、消息协议、controller事件分发。2.2 棋盘的数据结构int数组是底线想进阶再上位运算棋盘本质是一个15×15的二维数组0表示空1表示黑子2表示白子。有些人用ArrayListChessPiece只记录已落子的坐标节省遍历时间但判断胜负时需要频繁查询某个位置是否有子、是哪个颜色数组的随机访问效率反而更高。我坚持用数组因为五子棋棋盘规模极小一次全盘遍历的时间可以忽略不计。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]; public boolean place(int row, int col, int player) { if (row 0 || row SIZE || col 0 || col SIZE) return false; if (grid[row][col] ! EMPTY) return false; grid[row][col] player; return true; } public int get(int row, int col) { return grid[row][col]; } public void clear() { for (int i 0; i SIZE; i) { Arrays.fill(grid[i], EMPTY); } } }这里有两个细节值得注意。第一place方法必须返回boolean因为落子合法性是否越界、是否已被占用是后续所有逻辑的前置条件用返回值而不是异常来处理这些“预期内失败”更清晰。第二clear()方法用Arrays.fill而不是双重for循环逐格赋值代码更短且不会漏行。如果你想再进阶一点可以用long整型配合位运算来压缩棋盘状态每个格子用2bit表示00空、01黑、10白这样一个15×15的棋盘恰好能塞进一个long需要严格计算bit数判断和存储效率都有提升但可读性很差我建议毕设阶段不要这么干面试时能说清楚“用数组”的理由反而是加分项。2.3 落子的合法性与回合管理同一份逻辑三种模式复用落子合法性不只是“格子里没子”还包含“是不是轮到你下”。在本地双人模式里这靠一个currentPlayer变量切换在联网模式里服务器端必须强制校验“这条落子消息是否来自当前轮到的一方”否则客户端可以伪造消息连续落子。我的做法是定义一个Move内部类包含行列和玩家逻辑层只暴露safePlace(Move move)方法public synchronized boolean safePlace(int row, int col, int player) { if (player ! currentPlayer) return false; // 回合错误 boolean ok place(row, col, player); if (!ok) return false; // 位置非法 if (checkWin(row, col, player)) { gameOver true; winner player; } currentPlayer (player BLACK) ? WHITE : BLACK; return true; }synchronized在这里是关键。网络对战模式下你会在一个独立的线程里接收对方消息并调用这个方法同时可能在UI线程里处理自己的落子两个线程如果同时修改棋盘数组可能处于不一致状态。加上synchronized保证同一时刻只有一个线程能改棋盘状态。这里要特别留神currentPlayer的切换顺序必须在checkWin之后因为如果这步棋已经赢了不需要再更新轮到谁。3. 胜负判断与AI落子算法选得好不好直接决定后续优化空间3.1 输赢判定八方向扫描是起点增量检测才是常态最基本的胜负判断是在每次落子后以该棋子为中心沿水平、垂直、两条对角线四个方向分别向两边延伸统计相同棋子的连续数量只要任意方向达到5颗就判定胜利。我见过很多人把四个方向拆成八个方向上下、左右、左上右下独立判断这没有必要向一个方向延伸时同时统计正反两个方向即可。public boolean checkWin(int row, int col, int player) { int[][] dirs {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int[] dir : dirs) { int count 1; count countInDir(row, col, dir[0], dir[1], player); count countInDir(row, col, -dir[0], -dir[1], player); if (count 5) return true; } return false; } private int countInDir(int row, int col, int dr, int dc, int player) { int r row dr, c col dc, cnt 0; while (r 0 r Board.SIZE c 0 c Board.SIZE board.get(r, c) player) { cnt; r dr; c dc; } return cnt; }这段代码的时间复杂度是O(1)——每次落子最多检查4个方向每个方向最多走14步总检查次数恒定。这是最简单的版本但它只判断了“5连”即胜利没有处理“长连”6颗以上的情况。按标准五子棋规则黑棋长连是禁手但如果只是学生项目判为胜利问题也不大。真正想做成产品级需要维护棋局历史每次落子在四个方向上增量更新连子信息而不是全盘重扫——当棋盘上已有200颗子时全盘扫描会卡顿一秒左右。3.2 简单AI权值打分法比暴力搜索更适合人手第一篇项目人机对战要不要做AI很多人为了答辩好看硬上α-β剪枝、蒙特卡洛树搜索最后哭都哭不出来。五子棋AI的复杂度比国际象棋低一个量级但仍然需要大量的搜索空间剪枝和评分函数调参。我建议先做权值打分法对棋盘每个空位按“某个方向已有的连子数”打一个分数取所有方向分数之和作为该位置的得分AI落子在得分最高的位置。public int[] evaluateMove() { int bestScore -1, bestRow -1, bestCol -1; int[][] dirs {{1,0},{0,1},{1,1},{1,-1}}; for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (board.get(r, c) ! EMPTY) continue; int score 0; for (int[] d : dirs) { int attack evaluateLine(r, c, d[0], d[1], AI_PLAYER); int defend evaluateLine(r, c, d[0], d[1], HUMAN_PLAYER); score attack defend * 1.1; // 防守权重略高 } if (score bestScore) { bestScore score; bestRow r; bestCol c; } } } return new int[]{bestRow, bestCol}; }这里的evaluateLine方法需要统计从当前点沿指定方向延伸、连续有多少个己方或对方棋子以及两端是否被堵死。计数逻辑可以复用countInDir但需要额外判断“活三”“冲四”这类形状——所谓“活三”是两端都没被堵的三连子对方不下堵你下一步就能变四连甚至五连。权值打分法看起来“笨”但胜在每步都在O(15×15×4)的平坦时间内完成部署简单且效果对新手来说足够惊艳——至少能打赢自己。3.3 为什么我不建议一上来就做α-β剪枝评估函数没做好剪枝是空中楼阁α-β剪枝的原理是让搜索树的节点被剪掉的可能性随深度增加而指数增长使得同样时间内可以搜索更深。但它的前提是评估函数足够准确地反映局面优劣如果评估函数本身只数连子数量剪枝出来的“最佳”路线也是短视的。常见现象是搜索深度到4层时AI会主动送吃——它看到了三步后的自己的活四却没意识到两步后对手的冲四已经挡不住。如果你真想写α-β剪枝最少需要三个前置组件一是上面说的权值打分函数且要能对任意局面不只是最后一步打分二是置换表记录已评估过的局面避免重复搜索三是杀着检测——在进入递归前先判定是否存在一步致胜的棋。把这些都理清楚代码量已经超过一千行在毕设答辩时你必须有把握讲清楚每一处优化带来的效果变化否则容易被追问到翻车。对二开项目或练手项目权值打分法加一个“二次搜索”——在前一步选出的top3位置里再模拟后续两步——就足够了。4. 网络对战Socket通信里藏着这个项目最容易翻车的三个细节4.1 C/S架构与消息协议先定消息格式再写连通信联网对战的常规做法是服务器端承担“房间管理”和“消息转发”职责。服务器监听一个端口比如8888第一个客户端连上来后等待第二个客户端连上来后服务器把两者的Socket关联起来此后任何一方发来的消息服务器原样转发给另一方。这种架构下服务器代码写在ServerSocket的accept循环里每个客户端连接各占一个线程用ExecutorService固定线程池来管理。协议字段用最简单的“|”分隔消息体就是一个字符串public class Protocol { public static final String MOVE MOVE; public static final String WIN WIN; public static final String RESTART RESTART; public static final String QUIT QUIT; public static final String HEARTBEAT HEARTBEAT; public static String encodeMove(int row, int col, int player) { return MOVE | row | col | player \n; } public static int[] decodeMove(String msg) { String[] parts msg.trim().split(\\|); return new int[]{Integer.parseInt(parts[1]), Integer.parseInt(parts[2]), Integer.parseInt(parts[3])}; } }这里强制要求每条消息以\n结尾就是为了解决后半节要说的粘包拆包。协议里一定要包含HEARTBEATTCP连接本身不感知对端是否崩溃如果一方拔网线或直接关掉进程另一方的readLine()会一直阻塞没有任何超时机制界面看起来就像“死机”了其实只是对方静默断开了。常见做法是每3秒互相发一次心跳连续3次没收到就判定对端死亡主动释放资源。4.2 粘包拆包与消息边界为什么你收到的消息总是“断手断脚”Java的BufferedReader.readLine()是基于流的这意味着你调用一次readLine()读到的内容可能包含两条消息网络延时时把多条数据合并成一次TCP段也可能一条消息被拆成两次读取对端发来的数据分段到达。解决办法是“按行读按行发”发送时在消息末尾显式加\n接收端用readLine()读到的一行就算一条完整消息。这个方案在局域网内联机时足够可靠因为消息体短、频率低。// 发送端客户端 private PrintWriter out; public void sendMove(int row, int col, int player) { out.println(Protocol.encodeMove(row, col, player)); out.flush(); // 关键一步不flush数据会滞留在缓冲区 }很多人在这一步踩坑调用了println但没调flush消息迟迟发不出去然后怀疑是网络问题。PrintWriter默认开启自动flush但创建时如果传入true参数new PrintWriter(socket.getOutputStream(), true)则每次println都会自动flush。我习惯显式调用flush因为协议不只发文本还会在特殊场景如发送调试命令或文件走原生DataOutputStream那时自动flush不可靠。接收端代码在各自的线程里public void run() { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream()))) { String line; while ((line in.readLine()) ! null) { handleMessage(line); } } catch (IOException e) { handleDisconnect(); } }handleMessage方法内部用Protocol.decodeMove之类的解析器转成结构化对象再分发给逻辑层。注意run()的循环退出条件只有一个readLine()返回null这发生在对方正常关闭连接时。如果对方进程崩溃readLine()会抛异常而不是返回null所以catch里也必须当作“对端断开”处理。4.3 服务器并发模型线程池参数不能拍脑袋服务器端每接到一个客户端就new Thread(new ClientHandler(socket)).start()是最简单的写法但经不起压力测试。用固定线程池是更稳妥的解法ExecutorService pool Executors.newFixedThreadPool(2); // 一个房间最多2人 while (true) { Socket socket serverSocket.accept(); pool.execute(new ClientHandler(socket)); }线程池大小按“同时在线房间数×2”估算每台机器撑几百个连接绰绰有余。如果你要支持多房间对战需要把accept得到的socket按两两配对再创建一个Room对象房间里维护两份PlayerHandler引用。这部分的逻辑不复杂但要注意一个细节房间配对是“先到先得”第二个玩家进来时服务器要发一条ROOM_READY消息通知双方对局开始。这样客户端就不用自己决定“我是先手还是后手”服务器直接指定第一个连接为黑棋、第二个为白棋协议里加上玩家颜色字段避免两端的currentPlayer初始化不一致。5. 界面与交互Swing绘制棋盘的性能陷阱与事件分发误区5.1 棋盘绘制别在paint方法里做任何耗时计算Swing的paintComponent(Graphics g)方法由UI线程Event Dispatch ThreadEDT调用。如果你在这个方法里做了全盘扫描、日志输出、或者Thread.sleep整个窗口会卡住超过100毫秒就必须引起警觉。我见过有人为了画一个“最后一手棋的红色标记”在paint里遍历整个ArrayList查找所有棋子坐标结果每落一子都要把全盘重画一遍。正确的做法是只在paint里做两件事循环画网格线、循环画棋子。Override protected void paintComponent(Graphics g) { super.paintComponent(g); int cellSize getWidth() / (SIZE 1); int margin cellSize; // 画网格 for (int i 0; i SIZE; i) { g.drawLine(margin i * cellSize, margin, margin i * cellSize, margin (SIZE - 1) * cellSize); g.drawLine(margin, margin i * cellSize, margin (SIZE - 1) * cellSize, margin i * cellSize); } // 画棋子 for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { int v board.get(r, c); if (v Board.EMPTY) continue; g.setColor(v Board.BLACK ? Color.BLACK : Color.WHITE); g.fillOval(margin c * cellSize - cellSize / 2 2, margin r * cellSize - cellSize / 2 2, cellSize - 4, cellSize - 4); } } }这里有个很关键的经验getWidth()在窗口初次显示时可能返回0所以必须在ComponentAdapter里监听窗口resize事件来触发重绘。另外不要在paintComponent里调用repaint()这会无限循环导致EDT被打爆。如果你需要刷新界面统一在业务逻辑完成后调用boardPanel.repaint()Swing会自动合并同一时间段的多个重绘请求。5.2 鼠标事件坐标转换没有这一行代码点一百次都是“鼠标位置偏移”鼠标监听器拿到的MouseEvent.getX()和getY()是相对组件的坐标但组件本身可能带边框Insets如果窗口设置了setBorder()坐标就得减去边框宽度。更稳妥的做法是用棋盘面板自身的坐标系——把panel全部填充棋盘不要在panel里再加其他组件。坐标转换的公式比较简单int row (e.getY() - margin cellSize / 2) / cellSize; int col (e.getX() - margin cellSize / 2) / cellSize;margin要和paint里的margin保持完全一致否则会出现“眼睛看到的位置和实际落子的位置偏了半格”的错觉。我踩过一次绘制代码里margin算的是getWidth() / (SIZE1)但鼠标事件里忘记算这个结果落点总是整体右移一格。这种问题调试起来特别耗神因为网格线看起来是均匀的只有点了棋盘的边缘你才会发现偏移。建议把margin和cellSize提升为成员变量在setSize时计算一次paint和鼠标监听都复用。5.3 双缓冲与闪烁问题Swing默认开着但重写paintComponent时容易关掉Swing组件默认是双缓冲的这在JPanel里不需要额外配置。但如果你为了贪图方便直接继承JFrame并重写paint(Graphics)方法双缓冲就失效了棋子多的时候拖动窗口能明显看到棋盘闪烁。正确做法是只重写JPanel的paintComponent然后把panel加入JFrame的ContentPane。我还习惯在构造panel时调用setDoubleBuffered(true)——虽然默认就是true但这样写等于给自己留了个明确的说明防止后来者改成false。界面层还有一件重要的事是弹窗提示胜负时不要在paintComponent或事件回调里直接JOptionPane.showMessageDialog——它会阻塞EDT导致消息接收线程也卡住如果回调在同一个线程池里。我的做法是逻辑层检测到胜利后通过SwingUtilities.invokeLater把弹窗操作投递到EDT的消息队列末尾让当前的重绘先完成。6. 避坑指南五子棋项目里最常见的五个翻车现场6.1 双方都对局但棋盘状态不同步现象A方落子后看到自己这边棋盘正常B方那边没反应或者过了一会儿才刷新甚至B方看到的棋子和A方完全不一样。原因网络消息协议里把“玩家颜色”字段放在第四位A端发MOVE|3|4|1B端解析出了player1但B端的逻辑层里currentPlayer还是2于是safePlace直接返回false相当于对方这一步被静默丢弃。解决服务器在配对成功时明确通知双方各自是什么颜色客户端收到消息后初始化本机的currentPlayer并且校验“收到的落子”是否恰好等于对方颜色而不是等于本地维护的currentPlayer——因为双方本地维护的currentPlayer会因消息顺序不同而错位。6.2 客户端连不上服务器反复“连接失败”现象双击启动客户端后填上IP和端口点“连接”提示失败但服务器端明明已经打印出“正在监听”。原因最常见的是IP填错了——本机测试填127.0.0.1没问题但两台真实机器之间填的是192.168.x.x不在同一网段比如手机热点共享时pc拿到的网关地址就变过。第二个常见原因是防火墙拦截了TCP 8888端口Windows默认会弹提示点“取消”后Socket连接抛ConnectException。解决先ping通对方IP再telnet ip 8888测试端口通不通。第三个原因比较隐蔽——服务器端监听的是localhost而不是0.0.0.0// 错误示范只监听本机回环地址外部机器连不进来 ServerSocket ss new ServerSocket(8888, 50, InetAddress.getByName(localhost)); // 正确做法不传InetAddress默认监听所有网卡 ServerSocket ss new ServerSocket(8888);6.3 人机对战时AI线程卡死了主界面现象轮到AI下棋程序卡了1~2秒甚至直接无响应鼠标点击事件失效。原因你在EDT里直接调用了ai.evaluateMove()评估函数里有一百多次的循环和二维数组遍历虽然没有死循环但阻塞了UI线程几百毫秒用户感知就是“卡顿”。解决把AI计算丢到单独线程结果通过SwingUtilities.invokeLater回投到EDT更新界面。注意AI线程和逻辑层类要加锁因为AI线程也在写棋盘数组。new Thread(() - { int[] move ai.evaluateMove(); SwingUtilities.invokeLater(() - { board.safePlace(move[0], move[1], Board.WHITE); boardPanel.repaint(); }); }).start();6.4 游戏结束时一方的弹窗导致对端消息堆积现象A方赢了弹窗弹出“你赢了”但B方的窗口没有更新为“你输了”甚至A方点掉弹窗后棋盘还能继续落子。原因胜负判定后的gameOver标志没有同步到网络消息里B端不知道对局已结束继续等到currentPlayer轮到自己。解决逻辑层检测到胜利后除了本地更新状态必须通过网络协议发WIN消息给对端对端收到后同样置gameOvertrue并弹窗。这里还有一个细节弹窗之后必须禁用棋盘点击事件否则用户可以在游戏结束状态继续落子直接破坏grid数组的连续性。我在游戏结束时设置一个isGameOver标志鼠标监听器第一行就判断它。6.5 悔棋功能实现后发现“只悔了自己这边”现象联网模式下A点击悔棋自己这边棋子取消了B那边棋盘没变然后两边棋盘完全对不上。原因悔棋只删除了本地数组里的棋子没有通过网络协议通知对方也没有校验“悔棋请求是否被对方接受”。解决悔棋必须走完整的“请求—确认”流程。一方发送RESTART或UNDO|请求对方收到后弹窗询问是否同意同意则双方同时回滚一步需要各自的落子历史栈都保存坐标不然就回滚“最后一步且只能是当前请求方的最后一步”。本地双人模式简单处理即可但联网模式绝不能直接删数组。7. 最终验证与进阶优化让这份源码值回“答辩”票价的实操清单把上面的功能都做完你手里已经有了一份能跑、能演示、能讲清楚的项目。但距离“能写出彩的毕设”还差最后两步一是系统性验证二是针对性的功能拔高。验证环节建议做这几件事第一写一个自动对弈脚本模拟机AI对机AI跑满100局统计每局时长和胜率这个数据放进答辩PPT里非常能说明你的程序“跑得稳”第二两台设备之间实测网络对战的断线恢复——关闭服务器客户端应该在5秒内弹窗“连接断开”而不是一直卡在阻塞读取第三对战过程中自由落子超过两百手记录内存占用和平均响应时间用jvisualvm看一眼是否存在内存泄漏循环创建的new Thread是重灾区。把这三个数字记录下来就是最好的“项目成果”。进阶优化按性价比从高到低排第一步给AI加一个“启发式搜索”——只在现有棋子周围两格范围内评估落点把15×15225个候选点压缩到几十个这是对权值打分法最直接的提速第二步加一个落子音效或动画Swing相对麻烦可以用javax.sound.sampled类播放短wav这类体验细节在答辩时最能留下印象第三步把网络协议从文本字符串改成JSON直接用Jackson或者手拼可读性提升很多但相应要处理更复杂的解析适合有Java基础想展示“工程能力”的同学第四步做复盘功能——把每一步按顺序存进文件格式够简单就能支持“回放”这不复杂但等于额外加了一个完整功能点。我把这些做完之后最大的收获不是“能写出一个五子棋游戏”而是第一次意识到一个看起来简单的项目把网络、并发、UI、算法串起来后任何一环出问题都会让人觉得“这程序坏了”。从线程池参数到消息边界到坐标偏移每一处都是“不会想当然就能答对”的细节。这份源码里的设计思路和踩坑记录希望能帮你少走一遍我走过的弯路。希望帮到你。本文还有配套的精品资源点击获取
返回列表