ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java五子棋对战系统设计与实现:从棋盘模型到Socket联机

Java五子棋对战系统设计与实现:从棋盘模型到Socket联机 简介这是一份基于Java实现的五子棋对战系统课程设计源码适合Java初学者、高校学生及需要完成课设的开发者参考用于理解交互式游戏从需求分析到编码实现的全过程。包体共290个文件压缩后约18.26MB其中17个Java源文件构成核心游戏逻辑覆盖初始化、落子处理与胜负判断265个GIF图像用于界面组件与动画表现3个XML配置文件管理界面布局和参数另有说明文档、项目文件、音效等辅助资源整体结构清晰。目前已有281人学习下载。通过对源码的研读读者可以掌握Java Swing等GUI设计方法、游戏状态管理与事件响应思路还能借助说明文档与项目模块文件快速搭建运行环境适合作为课设模板或入门游戏开发的实践案例。1. 为什么Java五子棋对战系统值得自己写一遍很多Java课程设计和面试项目都会选五子棋但大部分同学拿到的源码要么是几百行堆在主类里的玩具要么是只实现了人机却完全屏蔽了网络对战的半成品。这个标题里的“对战系统”四个字已经把需求从“画一个棋盘”提升到了“可交互、可扩展、可联机”的层面。一个能拿得出手的设计实现至少要拆出棋盘模型、落子规则、胜负判定、对局状态机和网络通信这几个模块并且让每个模块都可以单独测试。我会按照从单机到联机的顺序把一个基于Java五子棋对战系统的设计思路和关键源码展开讲覆盖从数据模型到AI评估、再到Socket联机的落地路径。这对正在做课程设计、准备Java面试、想在Swing或JavaFX里练手的人来说是一条可以直接复用的路线。2. 把Java五子棋对战系统的棋盘模型写成可扩展的类2.1 为什么用二维数组而不是一维数组或集合五子棋棋盘常规是15×15也支持19×19。常见做法是直接用int[][]或者byte[][]0表示空1表示黑棋2表示白棋。很多代码喜欢用char[][]存B和W但后续做AI评估和网络传输时byte和int的序列化成本更低而且不用每次都做字符转换。我一般会定义一个Board类把棋盘数组封装起来同时维护一个moveCount来记录当前步数方便实现悔棋和复盘。这个设计能让胜负判定、AI落子、网络同步都只依赖Board的公开接口而不是到处操作底层数组。此外二维数组的坐标和row、col的对应关系最直观调试时你只需要把数组打印出来就能看到整个棋盘。2.2 核心类与字段设计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 0; // 已落子数 private int lastRow -1; // 最后一子行号 private int lastCol -1; // 最后一子列号 public boolean inBounds(int row, int col) { return row 0 row SIZE col 0 col SIZE; } public boolean canPlace(int row, int col) { return inBounds(row, col) grid[row][col] EMPTY; } /** * 落子合法则写入棋盘并更新状态 */ public void place(int row, int col, int player) { if (!canPlace(row, col)) throw new IllegalArgumentException(非法落子); grid[row][col] player; moveCount; lastRow row; lastCol col; } public int getPiece(int row, int col) { return inBounds(row, col) ? grid[row][col] : EMPTY; } public int getMoveCount() { return moveCount; } public int getLastRow() { return lastRow; } public int getLastCol() { return lastCol; } }这段代码里SIZE是棋盘边长EMPTY/BLACK/WHITE三个常量让调用方不用记住魔法数字。place方法是整个系统里最核心的入口所有落子都必须经过它这样后面加悔棋和回放就只需要在place里做快照。lastRow和lastCol记录最近一步的位置胜负判断要依赖它们。这里的参数row和col都从0开始计数与二维数组下标一致UI层如果从1开始计数要在传入前减1。2.3 对局状态机把先后手、输赢和回合串起来光有棋盘还不够一个对战系统还需要状态管理。我见过不少源码把boolean isBlackTurn直接放在界面类里结果做联机时只能到处传flag。更好的做法是把对局状态收敛到一个GameState枚举或者状态机里。常见设计是WAITING、PLACING、WIN、DRAW四个状态玩家落子前必须处于PLACING落子后检查胜负赢了就切到WIN棋盘满了没分出胜负就切到DRAW。状态可执行动作转移目标WAITING开始PLACINGPLACING落子PLACING或WIN或DRAWWIN重新开始PLACINGDRAW重新开始PLACING这张表可以直接当作enum的注释用也可以用来写单元测试的用例。状态机的价值在联机时体现得最明显服务器收到客户端落子请求后先检查状态是不是PLACING不是就直接拒绝这样可以挡住重复落子、越权悔棋这一类问题。比如客户端连续发送两个相同坐标第二次请求时状态已经因为第一次落子切换到了等待对方或者胜利服务器自然不会再执行第二次place。在单机版里同样可以防止用户在胜负已分时继续落子。3. 胜负判定Java五子棋判胜算法的正确打开方式3.1 从当前落子点向四个方向扫描胜负判定的朴素做法是每次落子后遍历整个棋盘检查所有横向、纵向、对角线的连子数。但五子棋只需要判断刚落下的一颗棋子是否构成五连所以更高效的做法是从lastRow和lastCol出发沿水平、垂直、主对角线、副对角线四个方向分别向两边扫描。每个方向统计包含当前落子的最长连续同色棋子数如果达到5就宣布胜利。这里的好处是时间复杂度只跟棋盘尺寸有关而不是跟已落子数量有关。在实际工程中我习惯把这个逻辑放进单独的Referee类而不是塞进Board这样AI和网络对战都能复用同一套规则。3.2 方向数组与边界条件/** 判断刚刚落在(row,col)的棋子是否获胜 */ public boolean checkWin(Board board, int row, int col) { int player board.getPiece(row, col); if (player Board.EMPTY) return false; // 四个方向水平、垂直、主对角线、副对角线 int[][] directions {{1,0},{0,1},{1,1},{1,-1}}; for (int[] d : directions) { int count 1; // 当前棋子本身 count countDirection(board, row, col, d[0], d[1], player); count countDirection(board, row, col, -d[0], -d[1], player); if (count 5) return true; } return false; } /** 沿单一方向连续统计同色棋子数 */ private int countDirection(Board board, int row, int col, int dr, int dc, int player) { int count 0; int r row dr, c col dc; while (board.inBounds(r, c) board.getPiece(r, c) player) { count; r dr; c dc; } return count; }这里directions数组只需要存四个正向方向反向靠加负号得到避免了重复代码。countDirection方法里dr和dc分别代表行和列的步进值越界判断放在while条件里保证了即使棋子落在棋盘边缘也不会数组越界。如果你改成19×19棋盘SIZE变了但这段判断完全不用动。另一个容易被忽略的点是count初始值设为1因为这颗刚落下的棋子本身就是一个连续点。3.3 为什么优先用当前落子点而不是全盘扫描很多网上源码在place后调用一个isBoardWin遍历全盘这在调试阶段问题不大但一旦接上AI计算AI每评估一个候选点就要模拟一次落子如果每次都遍历全盘评估速度会随着棋盘上棋子增多而线性退化。从当前点判定只有四个方向上的有限步检查最坏情况也不过是4个方向各扫描14格是常数时间。另外这个判定方法天然支持“禁手”扩展——如果你要写无禁手规则的版本当前点判定只需要再加一个对三三和四四的检测而全盘扫描会非常难做。下表给出了四种方向向量的含义对照方便在动手调试时快速理解。行步长(dr)列步长(dc)描述10竖直向下01水平向右11主对角线右下1-1副对角线左下4. 给Java五子棋对战系统加一个能用的AI对手4.1 评估函数进攻分与防守分很多人机对战源码用的是MinimaxAlphaBeta但那个深度最多到4层在15×15棋盘上响应时间已经超过5秒。五子棋棋盘大、落子空间多更适合用基于评估函数的贪心搜索。基本思路是遍历所有空位对该位置分别计算“我落这里能形成多少威胁”的进攻分和“对手落这里会形成多少威胁”的防守分两者加权求和后选最大值。4.2 一个可落地的AI落子方法/** 找当前AI的最佳落子坐标 */ public int[] findBestMove(Board board, int aiPlayer) { int opponent (aiPlayer Board.BLACK) ? Board.WHITE : Board.BLACK; int bestScore -1; int[] bestMove new int[]{-1, -1}; for (int r 0; r Board.SIZE; r) { for (int c 0; c Board.SIZE; c) { if (!board.canPlace(r, c)) continue; int attackScore evaluatePoint(board, r, c, aiPlayer); // 进攻分 int defenseScore evaluatePoint(board, r, c, opponent); // 防守分 int score attackScore * 2 defenseScore; if (score bestScore) { bestScore score; bestMove new int[]{r, c}; } } } return bestMove; } /** 评估在某个空位放指定棋子的得分 */ private int evaluatePoint(Board board, int row, int col, int player) { int score 0; int[][] directions {{1,0},{0,1},{1,1},{1,-1}}; for (int[] d : directions) { int len 1; // 模拟落子后的连续长度 len countContinuous(board, row, col, d[0], d[1], player); len countContinuous(board, row, col, -d[0], -d[1], player); if (len 5) score 10000; // 直接获胜 else if (len 4) score 1000; // 活四或冲四 else if (len 3) score 100; // 活三 else if (len 2) score 10; // 活二 } return score; }attackScore和defenseScore分别站在AI和对手的角度评估同一个空位。防御分直接用对手棋形打分不乘以系数权重放在进攻分上这样AI会优先保证自己连成五子其次才去堵对手。evaluatePoint里的len统计包含当前点的连续棋子数注意当前点是空位所以countContinuous在计数时不会把当前点本身算进去初始值len1代表待落子形成的连子。这个评估函数没有考虑跳子例如XOOOX的威胁但对课程设计和一般对战已经完全够用。4.3 调参与边界为什么要让防守权重低于进攻把进攻分设为防守分的2倍是我测试下来比较稳的配比。如果防守权重大于进攻AI会变成纯粹的防守机器开局后手很难赢如果进攻权重大太高AI又容易无视对手的四连。这里的本质是一个攻防平衡问题。你可以把score attackScore * 2 defenseScore改成attackScore defenseScore * 2做对照会明显看到AI风格变化。另外evaluatePoint里len4时直接给了1000分这意味着只要有一个方向的活四或者冲四AI就会优先抢占这是五子棋AI能赢棋的最低要求。至于更复杂的跳活三、眠三等棋形需要在evaluatePoint里单独解析棋型序列这是个不小的工程量但可以放到后面迭代。为了更直观这里给出我常用的权重表你可以直接复制到代码里做对照实验。连子长度数值含义510000必胜41000冲四或活四3100活三210活二10无威胁5. 用Socket把Java五子棋改成双人联机对战5.1 同步方式回合制坐标同步网络对战有两种常见方案一种是服务器计算和裁决客户端只做显示另一种是客户端互传消息服务器只做转发。五子棋这种回合制游戏用第二种更简单因为落子合法性可以在本地判断服务器只需要验证收到的坐标在棋盘范围内。实际开发中我一般会让服务器持有Board对象并在每次收到落子后本地也落一次这样服务器能直接判断胜负并把结果广播给两个客户端避免客户端作弊。这里的核心是把“落子”这个动作建模成一条协议消息而不是让客户端直接操作远端棋盘。5.2 服务器端核心代码public class GomokuServer { private ServerSocket serverSocket; private Socket player1, player2; private Board board new Board(); private int currentPlayer Board.BLACK; // 当前轮到谁 public void start(int port) throws IOException { serverSocket new ServerSocket(port); player1 serverSocket.accept(); player2 serverSocket.accept(); System.out.println(两个玩家都已连接); while (true) { Socket current (currentPlayer Board.BLACK) ? player1 : player2; DataInputStream in new DataInputStream(current.getInputStream()); int x in.readInt(); int y in.readInt(); // 校验合法性非法则跳过本次循环 if (!board.canPlace(x, y)) { continue; } board.place(x, y, currentPlayer); boolean win checkWin(board, board.getLastRow(), board.getLastCol()); // 把落子结果发给双方 sendMove(player1, x, y, currentPlayer, win); sendMove(player2, x, y, currentPlayer, win); if (win) break; currentPlayer (currentPlayer Board.BLACK) ? Board.WHITE : Board.BLACK; } } private void sendMove(Socket socket, int x, int y, int player, boolean win) throws IOException { DataOutputStream out new DataOutputStream(socket.getOutputStream()); out.writeInt(x); out.writeInt(y); out.writeInt(player); out.writeBoolean(win); out.flush(); } }这段代码的start方法首先创建ServerSocket然后依次等待两个玩家接入。currentPlayer用来标记当前应该接收谁的落子这样即使两个客户端同时发数据服务器也只会读取当前玩家所在socket的输入流。sendMove每次把坐标、颜色和是否胜利发给双方保证两个客户端看到的棋盘完全一致。注意服务器没有维护单独的线程来同时读写因为回合制天然是串行的这种同步方式在棋盘状态上不会产生竞态。如果客户端断开这里的readInt会抛出EOFException实际项目中需要捕获后通知另一个玩家获胜或者退出。5.3 客户端消息处理的坑客户端需要单独开一个线程持续监听服务器的数据。最常见的坑是把readInt直接放在UI线程里导致界面假死。正确的做法是让DataInputStream的读操作在new Thread里跑拿到数据后通过SwingUtilities.invokeLater切回UI线程更新棋盘。另一个坑是TCP粘包和半包但这里每次发送固定两个int加一个booleanJava的DataInputStream通过readInt和readBoolean已经处理了缓冲所以不需要自己处理字节边界。如果你用BufferedReader直接按行读反而会因为每个int占的字节数不同而出问题。为了明确协议我把一条落子消息的格式列成表照着定义客户端的读写顺序就不会乱。字节顺序类型说明1-4int行坐标x5-8int列坐标y9-12int棋子颜色1黑2白13boolean是否胜利6. 用Zobrist哈希给AI评估提速顺便理清源码结构6.1 为什么需要哈希缓存上面的人机AI每评估一个候选点都要对四个方向重新扫描。当棋盘从15×15扩大到19×19AI的响应时间会成倍上涨。一个成熟的优化是缓存棋局状态对应的评估值用Zobrist哈希为每个状态生成一个64位随机数。每次落子后把哈希值更新而不是重新计算。这个技巧在象棋和围棋引擎里很常用五子棋虽然状态空间小但用了它之后AI可以在相同时间内多算好几倍的空位。6.2 Zobrist哈希的Java实现片段public class ZobristHasher { private final long[][][] table new long[Board.SIZE][Board.SIZE][3]; private final Random random new Random(42); // 固定种子方便调试 public ZobristHasher() { for (int r 0; r Board.SIZE; r) { for (int c 0; c Board.SIZE; c) { for (int p 0; p 3; p) { table[r][c][p] random.nextLong(); } } } } public long hashOf(Board board) { long hash 0; for (int r 0; r Board.SIZE; r) { for (int c 0; c Board.SIZE; c) { int piece board.getPiece(r, c); if (piece ! Board.EMPTY) hash ^ table[r][c][piece]; } } return hash; } /** 落子时增量更新哈希 */ public long updateHash(long oldHash, int row, int col, int player) { return oldHash ^ table[row][col][player]; } }这里的table是一个三维数组第一个维度是行第二个是列第三个是棋子类型。hashOf遍历整块棋盘做异或虽然第一次计算仍然要遍历全盘但后续每次落子只需要把新位置的随机数异或进旧哈希就能得到新状态哈希。random new Random(42)固定了种子保证同一盘棋在每次运行时的哈希序列一致这对调试AI的缓存命中非常有价值。实际使用中你可以在Board.place方法里增加一个updateHash调用把旧的哈希和当前坐标的随机数异或然后存成Board的成员变量。6.3 源码结构建议一个能称作“系统”的Java五子棋项目至少应该分成view、controller、model、network四个包。model里放Board、GameState、Playercontroller里放游戏逻辑和AInetwork里放服务器和客户端view里放Swing或JavaFX的界面。我在接手类似课程设计时最常看到的问题是所有类都在一个src根包下界面代码和业务代码互相引用到最后加网络功能时根本找不到修改点。按包拆开后单机版和联机版可以共用同一个modelview只需要调用Board和GameState的接口这样在本地测试和网络部署时都只需要替换一个入口类。本文还有配套的精品资源点击获取
返回列表