基于Java的五子棋对战系统设计与实现:从Swing界面到Socket通信
2026/9/23 20:14:53 网站建设 项目流程

简介:基于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显示“等待对方”,不可落子双方连接成功后,进入PLAYING
PLAYING轮到的一方落子落子后判胜负,若胜则FINISHED
FINISHED只能请求重新开局重置棋盘后回到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 List<PrintWriter> 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 BlockingQueue<String> 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

先把常量定义好:边距MARGIN=30,格子间距CELL=40,棋盘大小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放棋盘、规则、胜负判定和AI;net放Server、Client协议解析;ui放BoardPanel、GameFrame、Controller。入口类Main只负责读取命令行参数决定启动服务端还是客户端。

包名典型类职责
engineBoard, WinChecker, AiPlayer棋局规则与AI
netGameServer, 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颠倒。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询