基于Java的五子棋对战系统设计与实现:从Swing界面到Socket通信

发布时间:2026/9/23 20:15:00
基于Java的五子棋对战系统设计与实现:从Swing界面到Socket通信 简介基于Java实现的五子棋对战系统课程设计源码适合Java初学者、在校生及对游戏开发感兴趣的开发者用于学习项目整体架构与图形界面交互。资源压缩包18.26MB共290个文件主体为265个GIF图像、17个Java源文件、3个XML配置文件另附说明文档、音效、图片及项目配置GIF覆盖棋盘、棋子与按钮状态等界面素材Java源码负责初始化、落子处理、胜负判断与回合轮换XML用于布局或参数配置。目前已有281人浏览学习可据此还原一个可运行的五子棋对战程序。项目源码结构完整便于按模块逐步阅读与调试。通过研读源码能够掌握Swing或AWT界面搭建、事件监听、二维数组棋盘建模及基础判胜算法同时理解IDEA工程结构、版本管理忽略规则和资源分类方式为后续扩展人机对战、联机对战等功能打下基础。1. 基于Java五子棋对战系统难的不是规则而是“对战”两个字一张15×15的棋盘、黑白两色棋子、五子连珠即胜利这是五子棋的完整规则。麻烦的是“对战系统”四个字意味着最少两个客户端要维持同一局棋的同步状态还要在一方落子后立刻看到结果。很多Java课程设计做到一半就卡住不是不会画棋盘而是棋局数据模型、网络消息协议、界面事件这三块各写各的最后互相不匹配。基于Java五子棋对战系统的设计实现源码正好用一个小体量项目把Java基础、多线程、Socket、Swing这几个高频考点串成一条线。适合人群是刚能把Java语法背顺、准备拿一个完整项目练手的大二学生以及想搞清楚“单人程序和多人程序到底差在哪”的面试准备者。下面按“数据模型→规则判断→Socket通信→界面与人机→验收排错”的顺序推进每一部分都给出直接能编译的最小实现。按照这份思路写出来的源码既能在课程设计答辩时讲清楚规则细节也可以作为后续 Spring Boot 项目前面的“纯 Java 核心模块”单独保留。2. 先把五子棋对战系统的棋局描述好棋盘类、坐标系统与胜负判断代码对战系统所有功能最终都要落到“棋局当前是什么状态”这个问题上。界面坐标、网络坐标、规则坐标如果不统一后面网络同步会出现大量对不齐的bug。因此第一步先从Java类的角度把棋局固定成一张二维数组一切操作都围绕数组完成。2.1 用int二维数组承载棋盘把界面坐标和棋盘坐标彻底分开Swing绘制一个棋子需要绝对像素位置而落子和判赢需要的是第几行第几列。若直接用像素坐标去做规则判断代码里的判断条件会非常混乱。常见做法是维护一个15×15的int数组0表示空位1表示黑方2表示白方。数组下标的行、列都从0开始棋盘中心的坐标是(7,7)。下面这段代码演示了棋盘类的核心结构public class Board { public static final int SIZE 15; private static final int EMPTY 0; private final int[][] grid new int[SIZE][SIZE]; public boolean put(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]; } }put方法的返回值表示在规则上是否允许这颗棋子落下。这里故意不把“是否允许落子”和“是否重绘界面”混在一起人机、局域网对战里的落子请求都会先经过同一个put方法。参数row、col分别表示行、列player用1、2区分双方这样日志和调试都非常直观。2.2 对战状态机等待落子、对局中、棋局结束对战系统不是“两个客户端随便点”每一手棋都必须处于一个明确的状态里。用一个枚举或者int常量记录当前状态即可不需要引入复杂框架。经验法则是把状态字段设为volatile防止跨线程读取时读到旧值。public enum GameState { WAITING_PLAYER, // 等待双方连接 PLAYING, // 对局进行中 FINISHED // 已分出胜负或和棋 }在Controller里落子流程是这样的只有state为PLAYING时才允许走棋每次落子后检查该点是否形成五连判定胜出后state变成FINISHED同时冻结棋盘。这个状态机虽然简单但能拦住大多数重复落子、胜负后的多余操作问题。下面这张表总结了三种状态下允许的动作状态允许动作触发新状态的方法WAITING_PLAYER显示“等待对方”不可落子双方连接成功后进入PLAYINGPLAYING轮到的一方落子落子后判胜负若胜则FINISHEDFINISHED只能请求重新开局重置棋盘后回到WAITING_PLAYER值得注意的细节是悔棋和重开并不需要单独设计一套复杂命令它们本质上都是“把棋盘清空恢复状态”的reset操作。状态机的引入让后续网络消息处理只需要关心当前状态是否合法而不必在按钮回调里做大量if判断。2.3 胜负判断以最后落子点为中心四个方向双向扫描五子棋判断胜负最快的方法不是遍历整个棋盘而是顺着刚落下那颗棋子向四个方向数一遍。这四个方向分别是水平、垂直、右上到左下、左上到右下。因为斜线有正反两侧每个方向都需要向两个端点扩展统计。public class WinChecker { // 四个方向右、下、右下、右上 private static final int[][] DIRS { {0, 1}, {1, 0}, {1, 1}, {1, -1} }; public boolean isWin(int[][] grid, int row, int col, int player) { for (int[] d : DIRS) { int count 1; int dr d[0], dc d[1]; for (int step 1; step 5; step) { int r row dr * step, c col dc * step; if (r 0 || r 15 || c 0 || c 15) break; if (grid[r][c] ! player) break; count; } for (int step 1; step 5; step) { int r row - dr * step, c col - dc * step; if (r 0 || r 15 || c 0 || c 15) break; if (grid[r][c] ! player) break; count; } if (count 5) return true; } return false; } }整个判断过程的复杂度接近常数因为棋盘边界最大也只是15。第二个循环之所以从1而不是从0开始是为了避免把最后一次落子重复计数。这里的关键是连续超过五颗也算胜利现实中六连的出现只能说明对方此前没有拦截因此用count 5而不是count 5。2.4 为什么建议不要在UI线程里写这套判断Swing界面的paint和鼠标回调都在EDT线程执行。若在回调里直接调用WinChecker算法本身很快没太大问题但一旦加入AI评估或网络通知可能会让界面卡顿。更合理的写法是让落子动作先进入一个队列由后台线程执行规则判断后再通过SwingUtilities.invokeLater通知刷新。这种设计与后面网络消息处理的方式完全一致所以从第一版就把“逻辑”和“界面”分成不同包后面维护成本会低很多。课程设计里的系统需求分析也可以在ProcessOn里画一张行动序列图把客户端点击、消息发送、服务端判定这条链路标记出来直接导出图片就能放进需求文档。3. 给五子棋对战系统装上Socket网络协议、线程池与消息排队“对战”比“单人”复杂的根源在于接收方永远没办法确定网络消息会在什么时候到达。程序必须假设对方会在任何时候断开也会在任何时候发来一手棋。Java里做局域网对战最直接的方式就是Socket。下面从服务端、协议、客户端三个角度把网络层做一个可运行的实现。3.1 先定义一套手写消息协议不要上来就传对象对战系统传输核心信息就几类落子坐标、重新开局、认输、心跳。新手最容易犯的错误是把棋盘对象用Java序列化后整体发送。这样做一方面会让消息变大另一方面两边的类文件一旦不一致就直接报错。更稳的方案是使用简单的文本协议一行一条消息字段之间用逗号分隔。消息参数示例方向含义MOVE7,13客户端→服务端→客户端在(7,13)落一颗当前颜色的棋子SYNC0客户端→服务端请求全量棋盘数据WIN1服务端→客户端1号玩家获胜DRAW0客户端→服务端提议和棋AGAIN0任意→服务端请求重新开始PING1双向心跳检查文本协议的好处是只用BufferedReader.readLine()就能解析调试的时候打开控制台就能看出逻辑问题不需要额外的IDE插件。MOVE中的坐标是数组下标与上一章的棋盘坐标保持一致。3.2 服务端一个ServerSocket接收两个玩家交给线程池处理服务端需要时刻等待两个客户端接入。常规写法是在主线程里循环accept()每接入一个客户端就创建一个ClientHandler。如果直接把每个handler放在new Thread()里也能跑但项目同时还有AI模式和重开功能连接数一旦增多线程容易失控。更推荐做法是引入线程池public class GameServer { private final ExecutorService pool new ThreadPoolExecutor( 2, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(16)); private final ListPrintWriter clients new CopyOnWriteArrayList(); public void start(int port) throws IOException { ServerSocket serverSocket new ServerSocket(port); System.out.println(server on port port); while (true) { Socket socket serverSocket.accept(); pool.execute(() - { try { handleClient(socket); } catch (IOException e) { System.out.println(client handler failed: e.getMessage()); } }); } } private void handleClient(Socket socket) throws IOException { BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); clients.add(out); String line; while ((line in.readLine()) ! null) { if (line.startsWith(MOVE)) { for (PrintWriter writer : clients) { writer.println(line); } } } } }线程池参数含义是核心线程数2最大线程数8空闲线程60秒回收阻塞队列容量16。五子棋对战最多4个连接所以核心线程数2其实够用。用CopyOnWriteArrayList保存客户端Writer是为了在遍历断开连接的客户端时不触发并发修改异常。3.3 客户端读消息的线程和画棋盘的线程不能互相阻塞客户端同样要起一条读线程一直readLine()但绝不能在那里直接修改棋盘否则与EDT线程并发操作集合会造成不可预知的界面问题。正确姿势是把网络消息投递到一个队列让UI所在的线程统一消费。public class GameClient { private final BlockingQueueString incoming new LinkedBlockingQueue(); private final Socket socket; public GameClient(Socket socket) { this.socket socket; } public void readLoop() { new Thread(() - { try (BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { incoming.offer(line); // 只是入队不直接操作棋盘 } } catch (IOException e) { System.out.println(disconnected: e.getMessage()); } }).start(); } public String pollMessage(long timeout, TimeUnit unit) throws InterruptedException { return incoming.poll(timeout, unit); // 供界面线程调用 } }用LinkedBlockingQueue做缓冲本质上是把网络的不确定性挡在了棋局逻辑外面。界面刷新时可以先用poll把所有消息一次性取出再逐条apply到棋盘。参数timeout建议设为100毫秒这样既不会让界面事件积压也不会把CPU空转到空转。3.4 断线识别与重连参数不要等到读写失败才处理Socket读写发生IOException通常意味着连接断开但对方机器断电、网络抖动时可能要等很久。常见做法是双方每5秒发送一次PING对端如果连续3次没有收到回复就判定对局结束。这个参数在局域网课程设计里表现稳定改成每秒一次会让日志刷得太多改成30秒一次又会延迟发现断线。断线后服务端要广播给对方玩家界面状态回到WAITING_PLAYER同时清理clients列表中对应的Writer避免下一次广播写到失效连接上。4. 在Swing里把五子棋对战系统的棋盘画出来并让AI模式复用棋局引擎对战系统的界面层不需要花哨重点是保持逻辑和绘制的边界。把棋盘绘制、鼠标事件、AI落子放在一个类里写会让后期扩展非常痛苦。下面这部分以Swing为例展示如何用最小的代码完成棋盘绘制、坐标换算和AI模式接入。4.1 画一张棋盘paintComponent里只需要数据驱动UI先把常量定义好边距MARGIN30格子间距CELL40棋盘大小CELL*(SIZE-1)MARGIN*2。这里的SIZE是规则里的15不是界面像素数。绘制时先画15条横线、15条竖线和9个星位再根据棋盘的数组状态画黑白棋子。public class BoardPanel extends JPanel { private final int margin 30; private final int cell 40; private final Board board; public BoardPanel(Board board) { this.board board; } Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); int size board.SIZE; for (int i 0; i size; i) { g2.drawLine(margin, margin i * cell, margin (size - 1) * cell, margin i * cell); g2.drawLine(margin i * cell, margin, margin i * cell, margin (size - 1) * cell); } for (int r 0; r size; r) { for (int c 0; c size; c) { int v board.get(r, c); if (v 0) continue; int x margin c * cell; int y margin r * cell; g2.setColor(v 1 ? Color.BLACK : Color.WHITE); g2.fillOval(x - 12, y - 12, 24, 24); g2.setColor(Color.DARK_GRAY); g2.drawOval(x - 12, y - 12, 24, 24); } } } }这段代码要掌握两个点drawOval参数是左上角坐标和宽高圆心是交叉点填充半径固定为12可根据cell动态算出建议设成cell/2 - 6这样棋子不会互相覆盖。绘制时先填充再描边黑色棋子会有浅色边缘白棋也会有深色轮廓棋盘在深色背景上也能看清。4.2 鼠标点击坐标与棋盘下标之间的换算点击事件拿到的x、y是像素值需要先减去边距再除以格子间距然后四舍五入取到最近的交叉点。这里用Math.round而不是强转int因为强转只会向下取整点击两线之间时会选错格子。board.addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { int col Math.round((e.getX() - margin) / (float) cell); int row Math.round((e.getY() - margin) / (float) cell); if (row 0 || row 15 || col 0 || col 15) return; controller.onUserMove(row, col); } });注意事件顺序鼠标点击有时会在拖动后触发事件里不做任何规则判断全部交给controller。棋盘数据每次改动后调用repaint()即可这个调用非常轻量不需要额外做节流。这里的margin和cell需要在BoardPanel里提供getter避免在事件匿名内部类里重复定义魔法数字。4.3 AI模式如何复用引擎先堵对方可能赢的点再找自己可以连的那颗编程作业里经常要求“人机对战”这里要建立一条共识人机也只是另一个“玩家”同样走MOVE消息只是消息来源从网络变成了AI选择器。最简单可用的AI是两层启发式第一层扫描所有空白点按“地点分数”排序第二层优先检查某个空点是否能让对方形成活四或冲四若有则必须堵。public int[] findBestMove(Board board, int aiPlayer) { int bestScore -1; int[] best null; for (int row 0; row 15; row) { for (int col 0; col 15; col) { if (board.get(row, col) ! 0) continue; int myScore evaluate(board, row, col, aiPlayer); int opponentScore evaluate(board, row, col, 3 - aiPlayer); int score Math.max(myScore, opponentScore * 2); if (score bestScore) { bestScore score; best new int[]{row, col}; } } } return best; }evaluate里数四个方向连子数量遇到活三记1200分冲三记800分活二记300分。乘2是鼓励AI优先拦截对方实际测试时你会发现对方已经形成活三但你不去堵AI会连输好几盘这时把对方分数再调成myScore * 2即可。AI计算会循环225个交叉点每点四方向统计整体耗时不足1毫秒不需要额外做深度搜索。4.4 源码该按什么分包才不会在答辩时让老师翻半天分包不决定功能但决定代码能被别人快速读懂。常见做法是三包一入口engine放棋盘、规则、胜负判定和AInet放Server、Client协议解析ui放BoardPanel、GameFrame、Controller。入口类Main只负责读取命令行参数决定启动服务端还是客户端。包名典型类职责engineBoard, WinChecker, AiPlayer棋局规则与AInetGameServer, GameClientSocket与协议解析uiBoardPanel, GameFrame事件采集与绘制默认Main组装并启动把System.out的日志集中在net包把业务日志集中在engine包答辩时你能立刻说清“这一条是服务器打印的连接日志”。模块之间不要互相引用UI类型比如从GameClient的readLoop拿到MOVE 7,13,1之后由controller组装这个消息给engine而不是直接把整个GameFrame传进客户端类。5. 五子棋对战系统源码跑起来后的五个验证细节5.1 本机上的网络对战先开后连服务端先启动两个客户端连上同一个端口。没有图形界面时可以用命令行直接看端口监听netstat -ano | findstr 8088这里的8088是你在GameServer里传入的端口号。若启动时提示Address already in use说明上一次关闭不彻底需要改动端口参数或者等待系统的连接进入TIME_WAIT状态过期。5.2 第二局开始时的棋盘重置重开游戏最容易漏掉的是“客户端本地棋盘”和“服务端转发消息”状态不同步。触发重开时应该先广播AGAIN消息双方收到后再把棋盘数组整体重置为0而不是重新new一个Board因为new对象会导致之前持有的引用失效。重置后要立即把状态从FINISHED改回PLAYING并决定哪一方先手。5.3 连续六颗以上是否判赢如果胜负逻辑写成了count 5六连会被误判为没有获胜。正确逻辑是count 5。测试时故意在一条直线上摆六颗棋子代码应返回赢家。这个细节往往能防住简易算法的边界缺陷。5.4 日志里看到乱码多半是编码问题Socket流在读写时统一指定UTF-8不要依赖系统默认编码。如果在Windows下运行控制台日志中文乱码可以把IDE的全局编码设成UTF-8并在javac编译时带上-encoding UTF-8参数。5.5 参数化改动验证把AI评估的权重、心跳超时和队列长度提取成开局常量验收前修改这三个参数各跑一局对比可以直观感受到系统按预期变化。如果老师现场要求把棋盘改成19路只要将SIZE从15改成19并同步调整BoardPanel里的格子间距计算即可其余代码保持不动。最后再强调一个调用细节棋盘数组的遍历顺序是row在外层、col在内层返回值统一是先row后col。网络协议里的坐标格式也按这个顺序传递前端解析时保持一致这样对接双方的代码不会出现x、y颠倒。本文还有配套的精品资源点击获取