简介: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加减1,col减加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. 锦上添花的功能:悔棋、新开局与状态显示
五子棋基础版本跑通后,接下来要加三个用户体验功能:悔棋、新开一局、显示当前轮到谁。这三个功能放在窗口的侧边栏或者底部工具栏里。
先看悔棋。悔棋的本质是“撤回最后一次落子”,需要把上次落子的位置记录在状态里。这里就需要额外的数据结构来保存落子历史。最简单的方案是维护一个List<int[]>,每次落子时把{row, col}放进去,悔棋时取出最后一个并清空对应棋盘位置。
可以新建一个MoveHistory类,或者直接在Member变量里维护:
private List<int[]> 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五子棋的完整实践能给你带来一个有趣、能跑、可扩展的入门项目。做完了,拿去和同学下一局,赢了再回来告诉我。
本文还有配套的精品资源,点击获取