ARTICLE DETAIL

资讯详情

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

Java入门必做:从零手写一个五子棋游戏(Swing实战)

Java入门必做:从零手写一个五子棋游戏(Swing实战) 简介Java基础语法学完后如何通过一个完整项目串联核心技能是很多初学者关心的问题。图形界面编程背后依赖事件驱动机制和二维数组数据结构理解鼠标点击与坐标映射是构建交互应用的关键。而棋类游戏则天然包含状态管理、边界处理和胜负判定算法这些都是工程开发中频繁复用的思维模型。掌握这些基础后无论是开发小工具还是复杂业务系统都能更从容地应对。本文以五子棋为例从Swing界面绘制讲起逐步实现落子、胜负判断、悔棋及简单AI完整呈现一个Java小项目的诞生过程帮助初学者真正把语法变成可运行的作品。 作为一个教了多年Java入门的老程序员我经常被问到同一个问题“学完了基础语法接下来该做什么项目练手”有人去做图书管理系统结果被增删改查淹没有人去啃SSM框架结果连Maven都没配明白还有人跟着视频敲了个俄罗斯方块但敲完根本不知道每一行代码为什么要这么写。我的建议一直是认真写一个五子棋游戏。这个项目有意思的地方在于——它看起来很小但五脏俱全。你要处理用户输入、绘制界面、设计数据结构、写核心算法、处理边界条件几乎是Java日常开发的全流程体验。而且做完能拿去给同学玩成就感是实打实的。1. 为什么我推荐初学者第一个项目选五子棋很多初学者选项目有个误区以为越复杂越好。实际上第一个项目的价值不在于“大”而在于“完整”。五子棋恰好是“麻雀虽小五脏俱全”的典型代表。一个能让两个人轮流下的五子棋需要覆盖这些Java核心技术点Swing界面设计、事件监听机制、二维数组的操作、图形绘制、字符串输出、常量定义、类的职责划分。如果考虑到人机对战还会用到贪心算法、简单估价函数甚至牵涉到极小化极大搜索。从学习曲线来看五子棋是平滑的。即使你只用最基本的知识也能写出来JFrame、JPanel、MouseListener再加一个二维数组——足够了。等写完第一版你可以逐步增加悔棋、计时、禁手规则、人机对战每一版难度都只是阶梯式增加不会突然让你崩盘。从调试难度来看五子棋非常友好。游戏逻辑出错时你可以通过落子、观察棋盘状态直观定位问题。相比之下网络编程里的BUG往往要抓包、看日志初学者很容易受挫。最关键的一点五子棋的胜负判定算法是经典的“遍历方向检测”问题。这个算法思想以后会在很多场景复用——比如连连看的连通检测、扫雷的扩散算法、甚至数组游戏的消除判定。把这个逻辑想透你的编程内功会扎实很多。2. 动手前先想清楚的事棋盘坐标、数据结构与类职责写代码之前最忌讳直接打开IDE开始敲。我先说我们最终要达成什么样的程序形态。运行程序后会弹出一个窗口里面有15×15的棋盘网格。两名玩家轮流用鼠标在交叉点上落子黑子先落、白子随后。每落一子要判定一方是否连成五子形成后弹出胜利提示游戏结束。此外要支持新开一局、悔棋以及落子位置的显示比如“第12行第8列”。核心功能就这些但在动手之前有几件事必须提前定下来。为什么选15×15而不是19×19因为这是中国国内五子棋最常见的棋盘规格15条线、15个交叉点棋子落在交叉点上而不是格子中央。这个规则必须明确它决定了你的坐标映射公式。棋盘的绘制区我用一个600像素宽、600像素高的画布。15条等分线每条线的间距就是40像素。棋盘四周各留出20像素的边距确切说是半个格子距离使得最外面一圈的交叉点不至于贴边。于是从第i条线0到14索引到画布左边缘的距离是20 i * 40。鼠标落点时我们需要做一次坐标取整格子坐标col (clickX - 20 20) / 40加20的目的是让鼠标点在格子中点偏左偏右都能正确归到最近的交叉点。数据结构我选择了int[][] board new int[15][15]用0表示空1表示黑子2表示白子。为什么不直接用二维数组存“棋子的颜色对象”因为int最直观、最省内存、判断也最快。真实项目中当然可以用枚举但那是进阶优化初学者第一步用int完全没问题。类划分上我会拆成三个核心类GameFrame——主窗口类继承JFrame负责搭建整体界面布局。BoardPanel——棋盘面板类继承JPanel覆写paintComponent画棋盘和棋子同时挂上鼠标监听处理落子。GameLogic——游戏逻辑类负责判定胜负、判断落子是否合法、悔棋时回退状态。为什么要拆类因为“画界面”和“管逻辑”是两件不同的事。初学者容易犯的毛病是全部代码堆在一个类里一个类写800行。拆分的好处是逻辑清晰你以后往游戏里加AI时GameLogic这个类就是天然的扩展点。3. 真正开始写代码从骨架到棋盘绘制如果你的IDE里还没有项目先建一个普通的Java项目不需要Maven也不需要引入第三方库。Swing是JDK自带的我们要用的所有东西都在标准库里这一点对初学者实在太友好了。主类Main可以很简洁public class Main { public static void main(String[] args) { GameFrame frame new GameFrame(); frame.setVisible(true); } }GameFrame的构造器里我们要做的事情是设置窗口标题、设置窗口尺寸、指定关闭按钮的行为、把BoardPanel加进来。public class GameFrame extends JFrame { public GameFrame() { setTitle(Java五子棋 - 玩家对战); setSize(700, 680); // 宽多留一些后续可以放功能按钮和信息区 setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); // 窗口居中 setResizable(false); BoardPanel boardPanel new BoardPanel(); add(boardPanel); pack(); // 让窗口按面板的preferredSize自动调整 } }这里有个细节我用了pack()而不是直接setSize。pack会按照BoardPanel里设定的优先尺寸来调整窗口大小这样我们只要在BoardPanel的构造器或getPreferredSize方法里返回一个合适的尺寸就不用反复调窗口大小参数了。BoarPanel的骨架是这样的public class BoardPanel extends JPanel { private static final int ROWS 15; private static final int COLS 15; private static final int CELL_SIZE 40; private static final int OFFSET 20; private int[][] board; private int currentPlayer; // 1黑2白 private int moveCount; private int lastRow -1; private int lastCol -1; private GameLogic logic; public BoardPanel() { this.board new int[ROWS][COLS]; this.currentPlayer 1; this.moveCount 0; this.logic new GameLogic(); setPreferredSize(new Dimension(COLS * CELL_SIZE OFFSET * 2, ROWS * CELL_SIZE OFFSET * 2)); addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { handleClick(e.getX(), e.getY()); } }); } }核心的绘制逻辑在paintComponent里。请注意老手通常会用paintComponent而不是paint因为paint会覆盖掉很多内部细节直接覆写paintComponent是Swing推荐的正确姿势。Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 画背景 g2d.setColor(new Color(210, 180, 140)); g2d.fillRect(0, 0, getWidth(), getHeight()); // 画网格线 g2d.setColor(Color.BLACK); for (int i 0; i ROWS; i) { int startX OFFSET; int startY OFFSET i * CELL_SIZE; int endX OFFSET (COLS - 1) * CELL_SIZE; int endY startY; g2d.drawLine(startX, startY, endX, endY); startX OFFSET i * CELL_SIZE; startY OFFSET; endX startX; endY OFFSET (ROWS - 1) * CELL_SIZE; g2d.drawLine(startX, startY, endX, endY); } // 画星位天元和星位点就像真正的棋盘 g2d.fillOval(OFFSET 7 * CELL_SIZE - 4, OFFSET 7 * CELL_SIZE - 4, 8, 8); g2d.fillOval(OFFSET 3 * CELL_SIZE - 4, OFFSET 3 * CELL_SIZE - 4, 8, 8); g2d.fillOval(OFFSET 11 * CELL_SIZE - 4, OFFSET 3 * CELL_SIZE - 4, 8, 8); g2d.fillOval(OFFSET 3 * CELL_SIZE - 4, OFFSET 11 * CELL_SIZE - 4, 8, 8); g2d.fillOval(OFFSET 11 * CELL_SIZE - 4, OFFSET 11 * CELL_SIZE - 4, 8, 8); // 画棋子 for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { if (board[row][col] ! 0) { drawPiece(g2d, row, col, board[row][col]); } } } // 画最后一手标记 if (lastRow 0 lastCol 0) { g2d.setColor(Color.RED); int cx OFFSET lastCol * CELL_SIZE; int cy OFFSET lastRow * CELL_SIZE; g2d.drawRect(cx - 5, cy - 5, 10, 10); } }等一下你可能注意到了我在棋盘外围做了旋转绘制的小改造上面代码里我只画了一遍横向和纵向线不对我重新看一下。实际上如果按纯逻辑改的话最好是把横向线和纵向线分开两个循环来画避免嵌套循环在每次迭代里重复计算。但初版完全可以写得直白一点// 画横线 for (int i 0; i ROWS; i) { int y OFFSET i * CELL_SIZE; g2d.drawLine(OFFSET, y, OFFSET (COLS - 1) * CELL_SIZE, y); } // 画纵线 for (int j 0; j COLS; j) { int x OFFSET j * CELL_SIZE; g2d.drawLine(x, OFFSET, x, OFFSET (ROWS - 1) * CELL_SIZE); }drawPiece方法我们需要单独实现private void drawPiece(Graphics2D g2d, int row, int col, int player) { int cx OFFSET col * CELL_SIZE; int cy OFFSET row * CELL_SIZE; int radius CELL_SIZE / 2 - 4; // 立体感的棋子先画边缘再填充渐变色 if (player 1) { g2d.setColor(Color.BLACK); g2d.fillOval(cx - radius, cy - radius, radius * 2, radius * 2); // 加一点高光 g2d.setColor(Color.DARK_GRAY); g2d.fillOval(cx - radius / 2, cy - radius / 2, radius, radius); } else { g2d.setColor(Color.WHITE); g2d.fillOval(cx - radius, cy - radius, radius * 2, radius * 2); g2d.setColor(Color.LIGHT_GRAY); g2d.fillOval(cx - radius / 2, cy - radius / 2, radius, radius); } g2d.setColor(Color.BLACK); g2d.drawOval(cx - radius, cy - radius, radius * 2, radius * 2); }高光那一笔其实很关键。同样是圆形加了内圈高光后棋子会有立体感整个棋盘的视觉效果会提升一个档次代码成本却极低。这种细节打磨就是“代码写得好”和“代码只是能跑”的区别。画完棋盘我们跑一下程序应该能看到一个木色背景、十五条线、五个星位的空棋盘。窗口弹出正常说明Swing环境没有配置问题——这一步如果卡住了十有八九是JDK没装好或者环境变量没配上。4. 点下去就落子鼠标事件与坐标转换棋盘是静态的了现在要让用户点击时能落子。核心逻辑在handleClick方法里。鼠标点击后我们拿到的是像素坐标e.getX()和e.getY()需要先转成格子坐标。这一步是很多初学者第一个懵的地方。我画个思维模型棋盘就是一个二维数组每个元素对应一个交叉点交叉点的屏幕坐标是(OFFSET col * CELL_SIZE, OFFSET row * CELL_SIZE)。反过来给一个屏幕坐标我们要找到离它最近的交叉点也就是做四舍五入private void handleClick(int x, int y) { int col (x - OFFSET CELL_SIZE / 2) / CELL_SIZE; int row (y - OFFSET CELL_SIZE / 2) / CELL_SIZE; if (row 0 || row ROWS || col 0 || col COLS) { return; // 点在棋盘外忽略 } if (board[row][col] ! 0) { System.out.println(这个位置已经有棋子了); return; } // 落子 board[row][col] currentPlayer; moveCount; lastRow row; lastCol col; // 判定胜负 if (logic.checkWin(board, row, col, currentPlayer)) { repaint(); String winner (currentPlayer 1) ? 黑方 : 白方; int option JOptionPane.showConfirmDialog(this, winner 获胜是否再来一局, 游戏结束, JOptionPane.YES_NO_OPTION); if (option JOptionPane.YES_OPTION) { resetGame(); } else { System.exit(0); } return; } // 平局判定棋盘下满还没有人赢 if (moveCount ROWS * COLS) { repaint(); JOptionPane.showMessageDialog(this, 平局棋盘已满, 游戏结束, JOptionPane.INFORMATION_MESSAGE); resetGame(); return; } // 切换玩家 currentPlayer (currentPlayer 1) ? 2 : 1; repaint(); }这段代码里有几个值得注意的细节。首先是坐标转换的公式。(x - OFFSET CELL_SIZE / 2) / CELL_SIZE加法那一步是实现四舍五入的。比如格子大小为40鼠标点在离左边线10像素的位置加20变成30除以40得0归到第0列鼠标点在离左边线25像素的位置加20变成45除以40得1归到第1列。这个处理相当于“谁离得近就归谁”和整数除法向下取整配合得很自然。其次是边界检查。如果用户点到了棋盘边缘之外col或row可能是负数也可能大于14。不检查的话数组越界异常会让程序直接崩溃。虽然这是个小细节但“永远不要相信外部输入”是编程的基本素养——鼠标的位置本质就是一种外部输入。再次是落子后的顺序处理先落子、再判胜、再切换玩家。这个顺序不能乱。很多初学者会先把currentPlayer切换了再判胜结果判胜时传进去的棋子颜色是错的。我把胜负判定逻辑单独抽到GameLogic里了。当判定赢了之后用JOptionPane弹出对话框提示然后询问是否再来一局。这里可能有人会觉得直接在循环里判断不就行了其实不需要游戏是事件驱动的每次落子后判断一次就好。还有一个不容易想到的点平局判定。棋盘是15×15共225个交叉点两个人下满后理论上一定有一方连成五子。但实际编程时如果不加这个判断极端情况下棋盘被填满了程序会继续运行点击空白区域没反应玩家会被“卡死”。加上平局判定游戏状态才是完备的。这就是测边界条件的重要性——写程序时只考虑“正常情况”是不够的还要考虑“极端情况”下程序好歹有个合理的出口。5. 胜负判定五子棋的核心算法现在到了全项目最有含金量的部分判定一方是否形成了五连子。先明确需求。落下一颗棋子后系统要判断以这颗棋子为中心在水平、垂直、左斜、右斜四个方向上是否存在连续五颗同色棋子。每种情况都不能漏。我见过很多初学者的第一反应是每次落子后遍历整个棋盘找有没有五连。这个思路没问题但很低效而且代码写起来容易出错。更简洁的做法是“以当前落子为中心向四个方向延伸计数”。理由是这样的五连若是形成最后一手落下的棋子必然包含在这个五连里。所以新落下的这枚棋子一定是“关键节点”。我们只需以它为原点向四个方向各数一数连续同色棋子的数量。以水平方向为例。从当前棋子出发先向左遍历只要相邻格子的棋子颜色和当前棋子相同计数加1继续往左直到遇到不同的颜色或者越界。然后从当前棋子向右同样遍历。最终水平方向的连续棋子数量 左边连续数量 右边连续数量 1当前这枚。如果这个数量大于等于5就判断获胜。这里有个小坑如果数量等于5就判定赢那如果出现六连呢传统五子棋规则下五连或以上都算赢。所以判断条件是count 5而不是count 5。这个细节看起来不大但如果忘掉等号在出现长连时程序会漏判。垂直、左斜、右斜方向逻辑完全相同只是坐标增量不同。四个方向的坐标增量分别是水平row不变col加减1垂直col不变row加减1左斜row和col同时加减1右斜row加减1col减加1写代码时可以用一个二维数组统一表示方向向量减少重复代码public class GameLogic { // 四个方向水平、垂直、左斜\、右斜/ private static final int[][] DIRECTIONS { {0, 1}, // 水平行不变列增加 {1, 0}, // 垂直行增加列不变 {1, 1}, // 左斜行和列同时增加 {1, -1} // 右斜行增加列减少 }; public boolean checkWin(int[][] board, int row, int col, int player) { for (int[] dir : DIRECTIONS) { int count 1; // 当前棋子 count countDirection(board, row, col, dir[0], dir[1], player); count countDirection(board, row, col, -dir[0], -dir[1], player); if (count 5) { return true; } } return false; } private int countDirection(int[][] board, int row, int col, int rowStep, int colStep, int player) { int count 0; int r row rowStep; int c col colStep; while (r 0 r board.length c 0 c board[0].length board[r][c] player) { count; r rowStep; c colStep; } return count; } }countDirection方法是单向计数的。传进来方向向量(0,1)时它从当前棋子向右数传(-0,-1)时向左数。两个方向的计数加上当前棋子本身就是该方向上的总连子数。这段代码的精髓在于用方向向量统一了四个方向。如果你把这四个方向分别写成四个方法代码会长得几乎一样然后又难以维护。用一个数组统一之后逻辑密度高、清晰、好扩展。真实项目的算法核心往往就是这种“用数据结构消解重复代码”的思维。初学者经常在边界检查上出错。比如数组越界。所以while循环里那个r 0 r board.length必须放最前面。Java中是短路运算符如果左边已经是false右边就不会执行因此不会因为board[r][c]访问到越界下标而崩溃。但是如果把顺序写反写成board[r][c] player r 0程序会直接抛异常——这个经典的一字之差的坑大家至少值得踩一次以加深印象。我再给一个更取巧但非常高效的等价实现把棋盘定义成19×19中间15×15为有效区域——不这个方案会带来坐标映射混乱的问题。初版先不搞这种优化能用对就行。我实测过checkWin方法的开销极低每次落子最多检查4方向×两个方向的连续有限步平均不到20次数组访问。哪怕你把这个方法放在GameLoop里每秒钟跑一万次性能也毫无压力。所以不考虑优化也完全没问题。6. 锦上添花的功能悔棋、新开局与状态显示五子棋基础版本跑通后接下来要加三个用户体验功能悔棋、新开一局、显示当前轮到谁。这三个功能放在窗口的侧边栏或者底部工具栏里。先看悔棋。悔棋的本质是“撤回最后一次落子”需要把上次落子的位置记录在状态里。这里就需要额外的数据结构来保存落子历史。最简单的方案是维护一个Listint[]每次落子时把{row, col}放进去悔棋时取出最后一个并清空对应棋盘位置。可以新建一个MoveHistory类或者直接在Member变量里维护private Listint[] history new ArrayList();落子时添加历史history.add(new int[]{row, col});悔棋按钮的事件处理private void undoMove() { if (history.isEmpty()) { JOptionPane.showMessageDialog(this, 还没有落子无法悔棋); return; } int[] last history.remove(history.size() - 1); board[last[0]][last[1]] 0; lastRow -1; lastCol -1; // 如果还不止一步把lastRow/lastCol回退给更早一步这样红框标记更准确 if (!history.isEmpty()) { int[] prev history.get(history.size() - 1); lastRow prev[0]; lastCol prev[1]; } // 切换回上一位玩家 currentPlayer (currentPlayer 1) ? 2 : 1; moveCount--; repaint(); }这个实现有个逻辑细节悔棋不只是清掉最后一颗棋子还要把currentPlayer切回上一位否则黑方下的子悔掉之后还是黑方继续就变成白方会连下两手的局面。这是初学者最容易忽略的。新开一局就简单多了把所有状态清空private void resetGame() { for (int i 0; i ROWS; i) { Arrays.fill(board[i], 0); } history.clear(); currentPlayer 1; moveCount 0; lastRow -1; lastCol -1; repaint(); }注意用Arrays.fill来快速清零这比双循环手工赋值简洁不少。当前轮到谁可以用一个JLabel来显示。在GameFrame里放一个状态面板用一个label实时显示“黑方走棋”或“白方走棋”。怎么把棋盘面板里的信息同步给label这里有两个方案一是让BoardPanel在状态变化时调用一个回调接口二是直接把label引用传给BoardPanel让它更新。对初学者来说第二个方案更直观但如果想学习解耦设计可以试试第一个方案。我做了一个非常简单的监听器接口public interface GameStateListener { void onPlayerChanged(int player); void onGameOver(String message); }BoardPanel里持有这个listener在状态变化时调用。GameFrame负责实现监听器并更新界面。这是观察者模式的雏形虽然只是个简化版本但它能帮你建立起“界面和逻辑解耦”的意识——这个意识对以后写Android、写Web后端都非常有用。侧边栏的按钮布局很容易用JPanel GridLayout或FlowLayout即可放在窗口的East区域。GameFrame里这样设置JPanel sidePanel new JPanel(); sidePanel.setLayout(new BoxLayout(sidePanel, BoxLayout.Y_AXIS)); JLabel statusLabel new JLabel(黑方走棋); JButton undoBtn new JButton(悔棋); JButton newBtn new JButton(新开局); undoBtn.addActionListener(e - boardPanel.undoMove()); newBtn.addActionListener(e - boardPanel.resetGame()); sidePanel.add(statusLabel); sidePanel.add(undoBtn); sidePanel.add(newBtn); add(sidePanel, BorderLayout.EAST);BoardPanel的构造器里接受statusLabel引用每次切换玩家时调用statusLabel.setText(白方走棋)即可。为了在Swing里避免线程安全问题我们目前所有的更新都发生在事件分发线程里所以不需要额外的线程处理——这一点初学者暂时不用深究但等以后涉及定时器或AI计算时就要注意了。再补充一个体验细节状态栏里可以顺带显示最后一手的坐标形如“(8, 12)”。这个信息在调试时很有用尤其当你发现棋子画到了奇怪的位置时查坐标能立刻定位问题。我在handleClick里顺手把坐标赋给一个字符串再更新到statusLabel上一行代码的事留着无妨。7. 调试五大坑和两个优化小技巧正文写得再顺初学者通常还是会在这几个地方卡住。我按踩坑概率从高到低列出来每一条都附上排查思路。第一坑鼠标点击位置和落子位置不对应。比如说你点在第7列棋子却画在第6列或第8列。原因基本是坐标转换公式里的取整写错了。排查方法很简单在handleClick里先把row和col输出到控制台然后手动点击某个交叉点看一下输出是否正确。如果偏移很规律比如总是偏左边大概率是少加了CELL_SIZE / 2或者加多了。这个公式要拿纸笔推导一遍别看代码“差不多”就放过去。第二坑棋盘背景是默认的灰色画了网格线还是怪的。这是因为JPanel默认的background颜色不够贴合棋盘质感。可以setBackground(new Color(210, 180, 140))或者在paintComponent里先fillRect覆盖整个画布区域。我上面代码用了后者这样即使未来面板尺寸变化颜色也不会出现残留。第三坑棋子画完后边缘有锯齿。Swing默认不开启抗锯齿画出来的圆边缘会显得粗糙。在paintComponent第一行加RenderingHints.KEY_ANTIALIASING一行代码解决问题。这也是绝大多数游戏画面的常规操作。第四坑数组越界异常。多数是坐标转换后col或row是负数或等于15。上面代码已经有边界检查但如果你自己改动时漏了就会崩。排查时看异常堆栈里的行号再对照board[row][col]那行是不是没有加边界判断。第五坑点击棋盘后程序没反应。这个我需要重点说一下。如果你在mouseClicked里处理了逻辑但忘了调用repaint()你会发现棋盘上什么都没变。repaint()的作用是告诉Swing“这块区域需要重画”然后系统才会调用paintComponent。忘记repaint是Swing开发最常见的错误之一而且不会报错只是画面不更新隐蔽性极强。然后是优化技巧。技巧一棋子半径和网格间距的关系。我把棋子直径设置成32半径16而格子间距是40。这样棋子之间留了8像素的空隙棋盘整体看起来透气。如果棋子直径接近40会显得拥挤。这个视觉细节初学者一般不会注意但不妨做出来看看。技巧二画棋子时加上渐变或高光。我们之前的drawPiece里加了个半圆高光这一招是模仿真实棋子在光线下的反光。如果你的目标只是能跑可以不做但如果想让作品在同学习作里显得赏心悦目这种“一分代码、三分颜值”的优化值得花两分钟加上。还有一个隐藏的细节我用JOptionPane来提示胜利。在某些Linux窗口管理器下JOptionPane弹出时机可能在repaint之前导致赢得瞬间界面还没显示出最后一颗棋子。这种情况极少见但最稳的写法是在repaint之后再调用JOptionPane。你把repaint()放在JOptionPane之前就行Swing会先把重绘任务排队然后弹出对话框时界面已经刷新完毕。我上面的代码就是这么写的。8. 一个更进阶版本的思考加入简单AI如果双人版你写着已经毫不费力下一步可以考虑加入AI让玩家跟电脑对战。我给一个不涉及深奥算法、但能跑且有一定战斗力的AI方案基于打分的贪心算法。这个AI的逻辑是遍历棋盘上每个空点给这个点打分分数越高越值得落子。怎么打分看这个点落在哪个方向能形成多少连子、是否能形成活三、眠三、活二等。比如如果你在这个点落子后能形成活四那得分极高必然要下如果电脑在这个点落子能挡住玩家的活三得分也很高。最简单的实现思路是“攻守一体”public int[] getBestMove(int[][] board, int aiPlayer) { int bestScore -1; int[] bestMove new int[]{-1, -1}; for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { if (board[row][col] ! 0) continue; int score evaluatePoint(board, row, col, aiPlayer); if (score bestScore) { bestScore score; bestMove[0] row; bestMove[1] col; } } } return bestMove; }evaluatePoint实现时对每个方向统计当前点放上AI棋子后有几连两端是否能继续延伸活还是眠。具体评分规则可以用查表法五连100000分活四50000分眠四10000分活三5000分眠三1000分活二500分如果是一个真正的对弈AI这些数值会有更精细的调法。但作为初学者的第一个AI能走成“知道堵你的活三、知道自己连活三”的程度已经相当有成就感了。关于AI落子的线程问题等你真要写时就要注意了AI计算不能放在事件分发线程里否则计算期间窗口会卡死。简单的解决办法是用javax.swing.Timer安排延时或者用SwingWorker异步计算。这个知识点对初学者有些超前但遇到的时候查一下你能学到很多。9. 从“能跑”到“像样”整理代码与工程化习惯项目最后我建议你做一次代码整理。这不是为了交作业好看而是为了养成以后写真实项目的习惯。先看一个初学常见问题把所有常量散落在各个类里。等到要改棋盘规格时你得去两三个类里拼命找数字。我在前面代码里用static final int ROWS、COLS、CELL_SIZE、OFFSET把关键参数集中定义在前面后面所有地方都引用常量而不是魔法数字。以后你想把棋盘改成19×19只需要改这几个常量的值游戏会自动适配。同样的道理棋子颜色用常量表示public static final int EMPTY 0; public static final int BLACK 1; public static final int WHITE 2;如果这些值分散在代码里到处是“1”“2”以后想增加一个“红棋”选项你会改到怀疑人生。然后是注释。给初学者的代码确实要详细注释但也有个度。注释应该解释“为什么”和“边界情况”而不是逐行翻译“这行代码把数组第i位设为1”。我在每段核心逻辑的开头写了注释说明思路具体行没有每行都注释这样代码本身更清爽。这个注释风格是真实工程里推荐的。最后是版本管理。哪怕只是个人练习我也会建议建立Git仓库每完成一个小功能提交一次。比如“已完成棋盘绘制”、“已实现落子”、“已实现胜负判定”。这样你随时能回退到某一次能跑的状态并且能直观看到整个项目从0到1演化的过程。这个习惯的价值等你以后做课程设计或真实项目时会体会得特别深。尾声拿这个项目接着做点什么五子棋这个项目写到双人对战、悔棋、新开局、简单AI之后你已经掌握了一个完整Java小项目的全流程。接下来可以做的事情很多把AI升级成极小化极大搜索加上禁手规则改成五子棋专用棋盘坐标用“A、B、C…”标注列号或者导出棋谱记录。对我来说每一次带新手做这个项目最兴奋的时刻不是最终运行起来而是学员在验收时发现自己居然能理解某个“有难度的逻辑”的那个瞬间。比如第一次把checkWin写对、第一次调通AI的堵棋、第一次自己注意到“悔棋要撤销历史记录”。这些瞬间标志着你不只是在抄代码而是真的在“编程”了。如果你卡在某一步不要急着把整个项目关掉而是在关键位置打上打印语句把变量值和状态变化输出到控制台。我调试这个项目时有一半的时间是靠System.out.println找到问题的。调试不是丢人的事它是程序员日常工作的一部分。希望这篇Java五子棋的完整实践能给你带来一个有趣、能跑、可扩展的入门项目。做完了拿去和同学下一局赢了再回来告诉我。本文还有配套的精品资源点击获取
返回列表