上次写完基础双人版之后,评论区就成了一个小型 Bug 收集现场。有人反映"在最上面一行下棋,只要凑够五连,程序直接崩",也有人问"鼠标点下去棋子为什么总落在格子里面,而不是交点上",还有人想要悔棋、想跟电脑对战。这些问题有一个共同点:都属于"功能没问题,但用起来不舒服"的范畴。所以这一篇我决定换一个顺序,先聊 Bug 修正,再聊功能添加,顺带把迭代过程中踩到的坑一起记录下来,省得大家再把同样的路走一遍。
先说结论:标题里的"(二)"不是摆样子,这一版我确实把代码推倒重写了一部分。胜负判定、坐标换算、棋盘绘制这三块都动了手术,悔棋和人机对战是新加入的功能,禁手规则做成了可配置项。下面每一节我都会给出可以直接复制的完整代码,并解释当时为什么这么改。
1. 先别急着加功能,几个反馈最多的 Bug 值得单独处理
我习惯在接到反馈后先做一件事:把问题按"影响程度"和"出现频率"排个序。影响程度决定优先级,出现频率决定要不要重写而不是打补丁。这一版的反馈里,真正影响核心玩法的有三类,我把它们整理成了表格:
| 问题 | 触发场景 | 影响 |
|---|---|---|
| 棋盘边缘落子后,连成五子时程序异常退出 | 第 0 行、第 0 列、第 14 行、第 14 列附近 | 致命,游戏无法继续 |
| 鼠标点击和棋子实际位置有偏差,越靠近中线越明显 | 所有区域,尤其棋盘中部 | 体验差,误落子 |
| 棋子多了以后窗体刷新变慢,拖动窗口时画面闪烁 | 30 手以上,多窗口切换时 | 视觉疲劳,影响对局感受 |
这三个问题刚好分布在数据层、交互层和绘制层。数据层是胜负判定逻辑;交互层是鼠标坐标到棋盘坐标的换算;绘制层是 Swing 重绘机制使用不当。逐一修完以后,我才敢去考虑悔棋和 AI,因为功能就像搭积木,地基不稳,往上加多少都会塌。
还有一个隐藏问题值得一提,就是"最后一手没有标记"。对局双方经常因为找不到刚才下在哪里,导致下一步摆在毫不相关的位置。这个问题不大,但体验提升明显,我也放到绘制层那一节里一起处理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 边缘落子崩溃:一个越界异常引发的胜负判定重构
2.1 崩溃现场:日志和现象
这个 Bug 的复现路径非常清晰:在棋盘最上方一行落子,然后围绕这一子继续下,只要这一子所在的连线上凑够五个子,程序就抛异常退出。
我第一时间打印了调用栈,问题出在 checkWin 方法里。最初的实现很朴素,为了让读者看得清楚,我先把错误的写法贴出来:
java复制// 错误写法:固定扫描以落子为中心、半径为4的正方形区域
public boolean checkWin(int row, int col, int player) {
for (int i = row - 4; i <= row + 4; i++) {
for (int j = col - 4; j <= col + 4; j++) {
if (i < 0 || i >= BOARD_SIZE || j < 0 || j >= BOARD_SIZE) {
continue;
}
// 统计横向、纵向、斜向连续棋子数量
}
}
return false;
}
表面看,continue 已经处理了越界,问题不会出在访问数组上。但真正致命的是内部统计连子数量的那段逻辑:当 row 等于 0 时,row - 4 是负数,如果内部没有对 i + 1、i + 2 这类访问做边界判断,或者在统计某个方向时直接使用 board[i + 1][j],数组越界就不可避免。
我在测试时之所以没发现,是因为平时落子基本都集中在棋盘中央,很少有人去边缘试。可游戏一旦交付,玩家的操作习惯五花八门,这种"认为玩家只会在中间下棋"的假设,本身就是 Bug。
2.2 用方向增量扫描替代区间遍历
重构时我没有在原来的四重循环上补边界判断,而是换了一种完全不同的思路:从落子点出发,沿着四个方向分别向两侧延伸,遇到不是当前玩家的棋子或者越界就停止。这样每个方向只做两次线性扫描,代码更短,边界问题也天然规避了。
修正后的完整代码如下:
java复制private static final int BOARD_SIZE = 15;
public boolean checkWin(int row, int col, int player) {
int[][] directions = {
{0, 1}, // 水平方向
{1, 0}, // 垂直方向
{1, 1}, // 主对角线方向
{1, -1} // 副对角线方向
};
for (int[] dir : directions) {
int count = 1;
int dr = dir[0];
int dc = dir[1];
// 从落子点向正方向延伸
for (int i = 1; i < 5; i++) {
int nr = row + dr * i;
int nc = col + dc * i;
if (isValid(nr, nc) && board[nr][nc] == player) {
count++;
} else {
break;
}
}
// 从落子点向反方向延伸
for (int i = 1; i < 5; i++) {
int nr = row - dr * i;
int nc = col - dc * i;
if (isValid(nr, nc) && board[nr][nc] == player) {
count++;
} else {
break;
}
}
if (count >= 5) {
return true;
}
}
return false;
}
private boolean isValid(int row, int col) {
return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}
这段代码的思路其实很简单:我把"四个方向"抽象成四个方向向量,每个方向只需要做"正向延伸"和"反向延伸"两次统计,最后把两边连子数加上落子那一颗,超过 5 就赢。isValid 方法把所有越界判断收敛到一处,后面再加别的逻辑时,也不用担心漏掉边界。
这个重构保留了原来的语义,但把复杂度从嵌套区间遍历降到了线性扫描,运行效率还更高。对于 15x15 的棋盘,这种改动的收益感知不强,但代码的可读性和安全性提升是非常明显的。
3. 棋子偏移半格:交叉点坐标换算的正确姿势
3.1 坐标换算的两种常见错误
棋子在交叉点上,但鼠标点击的判定总要"差半格",这是 Swing 五子棋新手最容易踩的坑。
问题出在鼠标事件处理器里,把屏幕坐标换算成棋盘坐标时,大多数人的第一版代码长这样:
java复制// 错误写法一:直接整除
int col = (e.getX() - OFFSET) / CELL_SIZE;
int row = (e.getY() - OFFSET) / CELL_SIZE;
棋盘左侧留了 OFFSET 这个边距,第一个交叉点的屏幕坐标是 (OFFSET, OFFSET),第二个交叉点是 (OFFSET + CELL_SIZE, OFFSET + CELL_SIZE),以此类推。直接整除的问题在于:屏幕坐标落在 OFFSET + CELL_SIZE * 3 到 OFFSET + CELL_SIZE * 4 - 1 这一段时,会被归到第 3 列,但视觉上更靠近第 4 个交叉点的区域,同样被归到了第 3 列。准确地说,是点了交叉点右侧半个格子时,棋子会落在左边的交叉点上,看起来整体偏左。
还有一种常见写法是 Math.round:
java复制int col = Math.round((e.getX() - OFFSET) / (float) CELL_SIZE);
这个写法比直接整除好一些,但在格子大小为偶数时,Math.round 的边界行为和"加一半再整除"有细微差别。为了统一逻辑,我建议直接用后者。
3.2 修正后的鼠标监听器完整代码
正确做法是"先加上半个格子宽度,再整除",这样整数除法就自动实现了四舍五入:
java复制int col = (e.getX() - OFFSET + CELL_SIZE / 2) / CELL_SIZE;
int row = (e.getY() - OFFSET + CELL_SIZE / 2) / CELL_SIZE;
这行代码背后的原理值得展开讲一下。假设格子宽度是 30,OFFSET 是 30,第一个交叉点屏幕 x 坐标是 30,第二个是 60,第三个是 90。当鼠标点击 x = 74 时,减去偏移后是 44,再加 15 等于 59,除以 30 是 1,判定为第 1 列,也就是屏幕坐标 60 对应的那个交叉点。如果不加 15,44 除以 30 也是 1,没有区别。但当鼠标点击 x = 76 时,减偏移后是 46,加 15 等于 61,除以 30 是 2,判定为第 2 列,即屏幕坐标 90 对应的交叉点;不加 15 的话,46 除以 30 还是 1,就会落到左边那个交叉点。这个例子正好说明:加 CELL_SIZE / 2 是为了让区间 [60, 90) 的中线 75 成为分界点。
完整的鼠标事件处理,我自己是这么写的:
java复制addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (gameOver || thinking) {
return;
}
int col = (e.getX() - OFFSET + CELL_SIZE / 2) / CELL_SIZE;
int row = (e.getY() - OFFSET + CELL_SIZE / 2) / CELL_SIZE;
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
return;
}
if (board[row][col] != 0) {
return;
}
placePiece(row, col, currentPlayer);
}
});
这里我额外做了两层防呆:范围判断和重复落子判断。它们看起来不起眼,却能避免很多后续问题。比如玩家故意点到棋盘外部,或者误点已经有棋子的交叉点,程序都要能安静地忽略,而不是弹异常或者覆盖已有棋子。
4. 重绘闪屏与最后一手高亮:界面体验的两个隐形更新
4.1 用棋盘位图缓存避免重复绘制
Swing 的 JPanel 默认开启双缓冲,理论上不该闪屏,但我在棋子数量较多时明显感觉到刷新慢,拖动窗口边缘时也会闪。原因其实不在双缓冲,而在 paintComponent 里做的重复计算太多。
最初的绘制逻辑是这样的:每次重绘都把棋盘背景颜色刷一遍,然后重新画 15 条横线、15 条竖线、5 个星位,再遍历整个二维数组把棋子全部画一遍。这个流程在棋子少时没感觉,但下到四五十手后,每一帧都要重复绘制整张棋盘,Swing 的重绘线程被拖慢了,闪屏自然出现。
优化方案是"静态内容缓存,动态内容重绘":
java复制private BufferedImage boardImage;
private int lastRow = -1;
private int lastCol = -1;
private void ensureBoardImage() {
if (boardImage != null) {
return;
}
boardImage = new BufferedImage(PANEL_SIZE, PANEL_SIZE, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = boardImage.createGraphics();
// 画棋盘背景色
g2d.setColor(new Color(210, 180, 140));
g2d.fillRect(0, 0, PANEL_SIZE, PANEL_SIZE);
// 画网格线
g2d.setColor(Color.BLACK);
for (int i = 0; i < BOARD_SIZE; i++) {
g2d.drawLine(OFFSET, OFFSET + i * CELL_SIZE,
OFFSET + (BOARD_SIZE - 1) * CELL_SIZE, OFFSET + i * CELL_SIZE);
g2d.drawLine(OFFSET + i * CELL_SIZE, OFFSET,
OFFSET + i * CELL_SIZE, OFFSET + (BOARD_SIZE - 1) * CELL_SIZE);
}
// 画星位(天元和星)
int[] stars = {3, 7, 11};
for (int r : stars) {
for (int c : stars) {
g2d.fillOval(OFFSET + c * CELL_SIZE - 3,
OFFSET + r * CELL_SIZE - 3, 6, 6);
}
}
g2d.dispose();
}
然后在 paintComponent 里,先贴缓存图,再只画动态部分:
java复制@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
ensureBoardImage();
g.drawImage(boardImage, 0, 0, null);
// 画棋子
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
if (board[r][c] == 0) {
continue;
}
int x = OFFSET + c * CELL_SIZE;
int y = OFFSET + r * CELL_SIZE;
g.setColor(board[r][c] == 1 ? Color.BLACK : Color.WHITE);
g.fillOval(x - CELL_SIZE / 2 + 1, y - CELL_SIZE / 2 + 1,
CELL_SIZE - 2, CELL_SIZE - 2);
g.setColor(Color.DARK_GRAY);
g.drawOval(x - CELL_SIZE / 2 + 1, y - CELL_SIZE / 2 + 1,
CELL_SIZE - 2, CELL_SIZE - 2);
}
}
// 高亮最后一手
if (lastRow >= 0) {
int x = OFFSET + lastCol * CELL_SIZE;
int y = OFFSET + lastRow * CELL_SIZE;
g.setColor(Color.RED);
g.drawRect(x - 5, y - 5, 10, 10);
}
}
这样每次重绘只需要遍历棋盘数组画棋子,棋盘网格线、背景、星位只画一次。实测在 60 手以后,帧率依然稳定,拖动窗口也不再闪烁。
4.2 最后一手提示怎么加
最后一手标记是个很小的功能,但在实际对局里非常实用。实现方式就是两个成员变量:lastRow 和 lastCol。每次成功落子后更新它们,然后调用 repaint()。绘制时在对应交叉点画一个红色小方框即可。
需要注意的一点是:销毁棋子、悔棋、重新开始时,也要同步更新这两个变量,否则会出现"红框指着空气"的情况。比如悔棋后,lastRow 和 lastCol 应该指向被撤回的那一手之前的位置;如果没有上一手了,就重置为 -1。我在后面讲悔棋的时候会一并处理。
5. 悔棋功能:一个栈搞定,但人机对战不是
5.1 普通对战模式下的悔棋
悔棋的数据结构无非是一个栈。但很多人会直接把落子记录放在数组或 ArrayList 里,悔棋时弹出一个元素。这样能用,不过我想更规范一点,自定义一个 Move 类保存行、列、玩家,这样不仅悔棋方便,以后做回放、做复盘都能复用:
java复制public class Move {
int row;
int col;
int player;
public Move(int row, int col, int player) {
this.row = row;
this.col = col;
this.player = player;
}
}
双人对战模式下的悔棋逻辑很简单:从栈顶弹出一个 Move,把棋盘对应位置清空,把当前玩家恢复为这一步的棋手,然后刷新界面。
java复制private void undoForDoubleMode() {
if (history.isEmpty()) {
return;
}
Move move = history.pop();
board[move.row][move.col] = 0;
currentPlayer = move.player;
lastRow = move.row;
lastCol = move.col;
repaint();
}
这里的坑在于:很多人只清掉了棋盘,忘了恢复 currentPlayer。结果就是悔棋后,本来该轮到白棋下,程序却仍让黑棋继续下,连走两步。恢复 currentPlayer 时要从弹出的 Move 里取 player,而不是简单地取反。
5.2 人机对战模式下,悔棋要连撤两步
人机对战模式下,情况复杂得多。玩家每走一步,AI 会立即回一手。所以历史栈里的记录是"玩家、AI、玩家、AI"交替出现的。当玩家点击悔棋时,他真实的意图是"撤回我刚刚下的那一步,也撤回 AI 的回应",否则撤回一步之后,当前局面变成 AI 刚下完,玩家没有机会落子。
java复制private void undoForAIMode() {
if (history.isEmpty()) {
return;
}
// 撤销 AI 的一步
Move aiMove = history.pop();
board[aiMove.row][aiMove.col] = 0;
// 如果还有记录,撤销玩家的一步
if (!history.isEmpty()) {
Move humanMove = history.pop();
board[humanMove.row][humanMove.col] = 0;
currentPlayer = humanMove.player;
lastRow = humanMove.row;
lastCol = humanMove.col;
} else {
// 只有一个记录说明玩家还没下,AI 不可能落子,这里其实是本手棋的情况
currentPlayer = 1;
lastRow = -1;
lastCol = -1;
}
repaint();
}
这个逻辑有一个边界情况:如果历史栈为空,说明还没有任何落子,不用处理。如果栈里只有一条记录,理论上不会发生,因为玩家下第一步后 AI 会立即回应,历史会变成两条。但如果 AI 的回应因为某种原因被阻塞了,栈里可能只有一条玩家记录,这时悔棋只撤销玩家那一步,且把 currentPlayer 重置为初始玩家(默认黑棋)。
5.3 关于"AI 正在思考时按钮禁用"
人机对战的悔棋还有一个很容易忽略的问题:AI 的思考过程在 Swing 单线程模型下是阻塞式的。如果 AI 正在计算最佳落点,玩家点击悔棋,可能造成逻辑冲突,比如 AI 计算完发现坐标已经被悔棋清空了,落子时索引异常。
我的处理方式很粗暴但有效:用一个 boolean thinking 标志位,AI 计算前设为 true,计算完成落子后设为 false。在鼠标监听器和悔棋按钮的处理逻辑里,第一行就判断 thinking,为真就直接返回。这个标志位看起来简单,但能避免大量偶发性的并发问题。
6. 人机对战 AI:不写搜索树也能下得有模有样
6.1 权值评分算法的核心思想
很多人一听 AI 就觉得要上极大极小搜索、Alpha-Beta 剪枝,甚至神经网络。但对五子棋这种小棋盘游戏,还有一个性价比很高的方案:权值评分。
核心思想非常直观:遍历棋盘上每一个空位,给每个空位打分,分数最高的就是 AI 的落子点。打分思路是把该空位分别假设为黑棋和白棋,然后沿四个方向统计"如果在这里落子,能形成什么棋型",再根据棋型的重要性累加分数。
关键点是 AI 不能只考虑进攻,还得考虑防守。我一般把两个方向的分数分别算出来,防守分乘一个系数再加到总分里:
java复制public int[] findBestMove() {
int bestRow = -1;
int bestCol = -1;
int bestScore = -1;
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
if (board[r][c] != 0) {
continue;
}
int attackScore = evaluateFor(r, c, computerColor);
int defenseScore = evaluateFor(r, c, humanColor);
int totalScore = attackScore + defenseScore * 2;
if (totalScore > bestScore) {
bestScore = totalScore;
bestRow = r;
bestCol = c;
}
}
}
return new int[]{bestRow, bestCol};
}
为什么防守分要乘 2?这是我试出来一个相对合理的经验值。乘 1 的时候 AI 过于激进,经常不管对手的活三;乘 3 又过于保守,该进攻时跑去堵一个无关紧要的斜线。乘 2 至少在我的测试棋局里,攻守平衡做得还不错。这个系数不是固定的,各位可以根据实际效果调整。
6.2 方向棋型扫描与计分实现
evaluateFor 方法内部,对一个空位,分别沿着水平、垂直、两条对角线四个方向统计棋型:
java复制private int evaluateFor(int row, int col, int player) {
int score = 0;
int[][] dirs = {
{1, 0},
{0, 1},
{1, 1},
{1, -1}
};
for (int[] dir : dirs) {
score += evaluateDirection(row, col, dir[0], dir[1], player);
}
return score;
}
private int evaluateDirection(int row, int col, int dr, int dc, int player) {
int count = 1;
int openEnds = 0;
// 正方向延伸
int nr = row + dr;
int nc = col + dc;
while (isValid(nr, nc) && board[nr][nc] == player) {
count++;
nr += dr;
nc += dc;
}
if (isValid(nr, nc) && board[nr][nc] == 0) {
openEnds++;
}
// 反方向延伸
nr = row - dr;
nc = col - dc;
while (isValid(nr, nc) && board[nr][nc] == player) {
count++;
nr -= dr;
nc -= dc;
}
if (isValid(nr, nc) && board[nr][nc] == 0) {
openEnds++;
}
if (count >= 5) {
return 1000000;
}
switch (count) {
case 4:
if (openEnds == 2) return 200000;
if (openEnds == 1) return 50000;
return 0;
case 3:
if (openEnds == 2) return 10000;
if (openEnds == 1) return 2000;
return 0;
case 2:
if (openEnds == 2) return 500;
if (openEnds == 1) return 100;
return 0;
case 1:
if (openEnds == 2) return 20;
if (openEnds == 1) return 5;
return 0;
}
return 0;
}
openEnds 表示这条棋线的两端有多少个空位。为什么这个值很重要?因为"活四"(两端都空)和"冲四"(一端被堵)虽然都是四子,但威胁程度完全不同。活四无论对手怎么堵,下一步都能成五;冲四则被堵死就没有后续。同理,活三和眠三的差距也很大。如果不区分 openEnds,AI 很容易犯"守着死棋不放、放着活棋不堵"的毛病。
计分表可以自己调整,但要注意几个档位必须拉开差距。比如活三的分数一定要高于两个眠三之和,否则 AI 会为了堵多个眠三而放弃自己形成活三的机会。
6.3 为什么不用递归搜索:简单场景够用,性能可控
有朋友会问,权值评分能糊弄新手,但碰到会玩的呢?这得看应用场景。如果只是做一个人机对战的小游戏,权值评分完全够用;它的计算量小,单次评分只需要遍历 15x15 个空位,每个空位做四次方向扫描,几百毫秒内能跑完,不需要引入复杂的搜索树和评估函数。
权值评分的缺点也很明显:它只能看到一步,看不到"如果我在这里挡,对手下一步会在那里形成双三"这类连续变化。但要在 Swing 上实现一个带剪枝的搜索树,代码量至少翻一倍,逻辑复杂度也会上升。所以我在这一版里选择了权值评分,把 AI 做成一个"看起来会思考,实际上靠经验公式下棋"的对手。对于入门项目来说,这个平衡我觉得是合适的。
如果你的目标是做一个战无不胜的五子棋 AI,权值评分只是第一版,后续可以在它的基础上加入搜索深度,比如计算两步以后的局面分数。这个方向展开又是一大篇,这里先埋个伏笔。
7. 禁手规则要不要加:我选择了可配置
7.1 禁手规则为什么让人又爱又恨
五子棋的民间玩法里,黑棋先手优势很大,所以正规比赛会限制黑棋,不允许黑棋走出"双三""双四"和"长连",这些统称禁手。但禁手规则对普通玩家来说非常不友好,很多人根本不知道还有这种限制,兴致勃勃下了个三三,结果被判负,体验直接归零。
我在这版里做了一个开关:默认关闭禁手,让普通玩家玩得爽;在菜单里或者代码常量里提供配置项,开启后黑棋落子前会检查禁手。为什么要做成可配置而不是直接砍掉?因为五子棋教学和学习场景中,禁手是绕不开的知识点,不少人专门找带禁手的程序来练习。
7.2 长连禁手检测的实现思路
长连禁手是最好实现的,落子后沿四个方向统计连续同色棋子数量,超过 5 就是长连。
java复制private boolean isOverLine(int row, int col, int player) {
int[][] dirs = {
{0, 1},
{1, 0},
{1, 1},
{1, -1}
};
for (int[] dir : dirs) {
int count = 1;
int nr = row + dir[0];
int nc = col + dir[1];
while (isValid(nr, nc) && board[nr][nc] == player) {
count++;
nr += dir[0];
nc += dir[1];
}
nr = row - dir[0];
nc = col - dir[1];
while (isValid(nr, nc) && board[nr][nc] == player) {
count++;
nr -= dir[0];
nc -= dir[1];
}
if (count >= 6) {
return true;
}
}
return false;
}
判断双三、双四禁手就麻烦很多。我的建议是借助前面已经写好的棋型评估方法:把当前落子临时放进棋盘,对四个方向做棋型分析,统计活三和冲四的数量,如果活三数量大于等于 2 或冲四数量大于等于 2,就判定为禁手。检测完成后记得把临时落子从棋盘上拿掉,避免影响后续逻辑。
但这里有个细节:禁手检查必须在"模拟落子"状态下进行,而不是在真实棋盘上进行。所以完整流程是:玩家落子前,先临时把棋子放进棋盘,检查是否禁手;如果禁手,提示并禁止落子;如果合法,才真正写入棋盘。这个顺序不能颠倒。
我最终没有把双三、双四的完整检测代码贴上来,一方面是因为代码量很大,另一方面是因为它依赖你前面棋型评估函数的具体实现,硬贴一份反而容易和各位自己的代码冲突。思路上面已经说清楚了,照着做完全可以实现。
回到最开始的问题:为什么这一版要先修 Bug、后加功能?因为 Edge Case 暴露的是代码结构里的隐患,坐标换算代表的是交互细节,重绘优化决定的是画面流畅度,这三件事不做,悔棋和 AI 加得再漂亮,玩家也会因为基础体验问题而放弃。修完这些再回头看看,其实每个 Bug 背后都是一个"假设玩家不会这样操作"的思维盲区。我自己踩过这些坑之后,最大的心得就是:写游戏和写工具不一样,你的用户不是按你想好的路径操作,他们总会用各种奇怪的方式尝试,所以代码里多留边界判断,比后期查日志省力得多。
