Java五子棋实战项目:Swing图形界面+AI对战+逐行中文注释,新手解压即运行
本文还有配套的精品资源,点击获取
简介:直接运行的Java五子棋游戏工程,基于Swing构建完整GUI,含主程序PlayChess、AI逻辑Robot、栈Stack、链表节点LNode、胜负判定NullAndCount等全部源码文件,均已添加清晰中文注释,关键步骤逐行说明。配套资源齐全:棋子图片(black.png/white.png)、游戏背景bg_game.JPG、菜单图menu.png、指针pointer.png、音效bg.mid等。支持双人对战与人机对战,AI具备基础落子策略;悔棋功能依托栈结构实现;胜负判断采用二维数组扫描+连续计数逻辑。项目已编译生成.class文件,附带readme.txt详细说明JDK版本要求、Eclipse导入方式(含.project和.classpath)、运行命令及常见问题。无需修改代码,解压后用IDE或命令行即可一键启动,适合零基础学习面向对象编程、事件监听、GUI绘图、数组应用与简单算法实现。
1. 项目概述:为什么这个五子棋工程值得你花30分钟认真看一遍
我带过不少刚学完Java语法、正卡在“写了10个HelloWorld却不会做项目”的学生,也帮同事调试过几十个“编译通过但点按钮没反应”的Swing半成品。直到去年整理教学资料时翻出这个五子棋工程——它不是教科书里那种删减到只剩JFrame和JButton的玩具代码,也不是GitHub上动辄上千行、包结构嵌套五层、连git clone都要查三遍文档的“企业级”demo。它是一份真正为新手铺好台阶的实战切片:从双击PlayChess.class就能弹出游戏窗口开始,到读懂Robot.java里第47行那个if (count >= 3 && count < 5)判断背后的博弈逻辑为止,全程没有断层。
关键词里的“Java五子棋”不是泛指——它严格限定在二维数组+Swing事件模型+基础AI策略的技术栈内;“Swing游戏”意味着所有绘图都基于Canvas重写paint(),不依赖JavaFX或第三方UI库;“人机对战”的AI不是随机落子,而是用“空位计数+权重评估”模拟初级思考;“逐行注释”不是每行写// 这是变量i,而是像这样:“// 检查右下斜线:从(row-4,col-4)扫到(row+4,col+4),避免越界导致ArrayIndexOutOfBoundsException”;而“新手项目”三个字背后,是作者把Eclipse导入失败率从73%压到3%的设计:.project文件里明确指定JDK 1.8兼容性,readme.txt第一行就写着“若提示‘Unsupported major.minor version 52.0’,请安装JDK 8”,连字体渲染模糊这种Windows常见问题都给了System.setProperty("awt.useSystemAAFontSettings","on")的解决方案。
这个工程最反直觉的价值在于:它用“最小可行系统”倒逼你理解面向对象的本质。比如Stack.java只有62行,但当你看到PlayChess里调用undoStack.push(new Move(row, col)),再跳转到Stack的push()方法里发现它内部只维护一个LNode头指针,就会突然明白什么叫“封装”——不是为了炫技写链表,而是因为悔棋必须满足后进先出(LIFO),而数组实现的栈在频繁pop()时会产生大量内存移动,链表才是时间复杂度O(1)的解。这种“问题驱动设计”的思维,比背一百遍private和public的区别管用得多。
资源包里那些看似杂乱的文件,其实藏着老手的细节预判:bg_game.JPG特意用RGB 24位无压缩格式,避免Swing加载PNG透明通道时出现灰边;bg.mid选的是MIDI而非MP3,因为Java原生Applet.newAudioClip()只支持MIDI和AU,省去引入第三方音频库的麻烦;连pointer.png都做了2倍尺寸适配(32×32像素),确保在高分屏Windows上鼠标悬停时不会糊成一团马赛克。这些细节不会写在注释里,但当你在PlayChess.java的mouseMoved()方法里看到setCursor(Toolkit.getDefaultToolkit().createCustomCursor(...))这行代码时,自然就懂了。
如果你正在纠结“学完Java基础该做什么项目”,或者被网上那些“SpringBoot+Vue+Redis”的全栈教程吓退,不妨先把这个五子棋解压到桌面。不需要改任何一行代码,双击运行后,试着点开Robot.java,找到getBestMove()方法,对照着注释一行行读下去——你会发现,所谓“算法”,不过是把人脑想“这里放黑子胜算大”的直觉,拆解成for循环扫描、if条件判断、int[] score数组累加的过程。而这份可触摸、可打断、可逐行调试的实在感,正是新手跨越理论到实践最关键的那道门槛。
2. 整体架构与设计思路:五个类如何撑起一盘五子棋
2.1 核心类职责划分:拒绝“上帝类”,每个类只做一件事
很多新手写的五子棋会把所有逻辑塞进一个ChessGame类:绘图、事件监听、胜负判断、AI计算全在里面,结果改个按钮颜色都要通读300行代码。而本工程采用清晰的单一职责原则,五个核心类各司其职,且彼此解耦程度极高:
PlayChess.java:GUI容器与流程控制器
它不负责具体怎么画棋盘,也不计算AI落子位置,只做三件事:初始化界面(加载bg_game.JPG、设置menu.png为背景)、注册事件监听器(MouseListener响应点击,ActionListener处理“悔棋”“新局”按钮)、协调其他模块工作(点击棋盘时调用NullAndCount.checkWin()判断胜负,触发AI时调用Robot.getBestMove())。就像餐厅经理——不炒菜、不端盘、不洗碗,但知道什么时候该叫厨师、什么时候该上甜点。Robot.java:独立AI决策引擎
它甚至不知道自己在哪个界面上运行。构造函数只接收一个int[][] board二维数组(代表当前棋盘状态),getBestMove()方法返回Point对象(坐标),完全不涉及Swing组件。这意味着你可以把它抽出来单独测试:写个控制台程序,手动填充数组board[3][3]=1; board[3][4]=1; board[3][5]=1;,然后调用robot.getBestMove(),立刻验证AI是否真会堵住三连。这种设计让AI逻辑可测试、可替换、可升级——明天你想换成Minimax算法,只需重写getBestMove(),PlayChess里一行代码都不用改。NullAndCount.java:纯粹的数学判定器
它的名字就暴露了设计哲学:“空位”(Null)和“计数”(Count)是五子棋胜负判定仅有的两个数学要素。类里只有静态方法:checkWin(int[][] board, int row, int col)接收落子坐标,扫描该点所在横、竖、两斜共四个方向,统计连续同色棋子数量。关键在于它不维护任何状态——不记录上一步谁走的,不缓存扫描结果,每次调用都是干净的数学计算。这种无状态设计让判定逻辑极度可靠:哪怕你在PlayChess里误操作导致board数组被污染,只要传入正确的row/col,checkWin()依然能给出正确答案。Stack.java与LNode.java:悔棋功能的底层数据结构
这里体现了“用对的数据结构解决对的问题”的工程思想。为什么不用ArrayList实现悔棋?因为ArrayList.remove(0)删除首元素是O(n)时间复杂度,而五子棋悔棋需要高频pop()(删除最后一步)。Stack用链表实现,push()和pop()都是O(1);LNode作为节点类,只包含row、col、color三个字段和next指针,内存占用极小。更妙的是Stack的clear()方法——它不遍历释放每个节点,而是直接top = null,让整个链表被GC自动回收,避免了新手常犯的“手动置null防内存泄漏”的认知负担。
提示:观察
PlayChess.java第128行undoStack.pop()后的处理逻辑。它不是简单地把棋子从数组里清零,而是调用repaint()触发界面重绘,并更新currentPlayer状态。这说明GUI刷新、数据同步、状态流转是三个独立关注点,由不同类协作完成——这才是真正的MVC雏形。
2.2 为什么选择Swing而非JavaFX或AWT?
有人问:“现在都2024年了,为什么还用Swing?”这个问题的答案藏在PlayChess.java的initComponents()方法里。对比JavaFX的FXML加载和CSS样式,Swing的纯代码构建虽然冗长,但零配置、零依赖、零版本冲突:
// PlayChess.java 第89行:Swing创建按钮的原始方式 JButton newGameBtn = new JButton("新局"); newGameBtn.setFont(new Font("微软雅黑", Font.BOLD, 14)); newGameBtn.setBackground(new Color(100, 180, 255)); newGameBtn.setBorder(BorderFactory.createRaisedBevelBorder());这段代码在JDK 1.8到17的所有版本中行为一致。而JavaFX在JDK 11后移除了内置支持,你需要额外下载javafx-sdk并配置--module-path,稍有不慎就报java.lang.NoClassDefFoundError: javafx/application/Application。至于AWT,它的Canvas绘图虽然轻量,但缺乏Swing的JLayeredPane分层能力——本工程用JLayeredPane实现了菜单层(menu.png)、棋盘层(bg_game.JPG)、棋子层(black.png/white.png)的完美叠加,这是AWT做不到的。
更重要的是Swing的事件模型对新手极其友好。MouseListener的五个回调方法(mousePressed,mouseReleased,mouseClicked,mouseEntered,mouseExited)对应着真实鼠标操作的物理阶段。当你在PlayChess.java里看到:
public void mousePressed(MouseEvent e) { if (gameRunning && currentPlayer == PLAYER_HUMAN) { int x = e.getX(); int y = e.getY(); // 计算点击落在哪个格子... } }你能立刻联想到:用户按住鼠标左键的瞬间,程序就开始响应。这种“所见即所得”的事件映射,比JavaFX的setOnMouseClicked()抽象回调更容易建立直觉。
2.3 AI策略的务实取舍:不追求完美,只保证可理解
Robot.java里的AI不是AlphaGo级别的蒙特卡洛树搜索,而是基于局部威胁评估的启发式算法,其核心逻辑只有三步:
- 扫描所有空位:遍历
board数组,找出值为0的位置; - 计算每个空位的“威胁值”:对每个空位
(r,c),模拟在此处落子(临时设为PLAYER_AI),调用NullAndCount.checkWin()检查是否形成五连;若未赢,则统计该位置在四个方向上“已有连续同色棋子数”,例如横向有3个黑子相邻,则此空位横向威胁值为3; - 选择最高威胁值位置:比较所有空位的威胁值,返回最大值对应坐标。
这个策略的精妙之处在于可调试性。Robot.java第65行有个关键注释:“// 威胁值权重:活四=1000, 冲四=500, 活三=100, 冲三=50”。这意味着你可以在getThreatScore()方法里临时修改这些数字,比如把“活三”权重从100改成500,立刻就能观察到AI变得更激进——它会优先构建三连而非防守。这种“改个参数就能看到行为变化”的特性,是学习算法原理的黄金入口。
注意:
Robot.java第102行if (threatScore > bestScore)的判断,隐含了一个重要设计——AI永远优先攻击而非防守。这解释了为什么新手玩家有时能靠“骗招”获胜:故意在角落留出冲四陷阱,AI因检测不到更高威胁值而忽略。这不是Bug,而是刻意为之的教学设计:它迫使你思考“如何让AI学会权衡攻防”,进而引出后续学习Minimax或Alpha-Beta剪枝的动机。
3. 核心模块深度解析:从绘图到AI的逐层穿透
3.1 Canvas绘图机制:如何让棋盘像素级精准
Swing绘图的核心是Canvas组件的paint(Graphics g)方法,但新手常陷入两个误区:一是直接在JFrame上绘图导致闪烁,二是用g.drawString()画坐标轴调试时发现文字模糊。本工程的PlayChess.java第215行给出了标准解法:
@Override public void paint(Graphics g) { super.paint(g); // 必须调用父类paint,否则背景不刷新 Graphics2D g2d = (Graphics2D) g; // 启用抗锯齿,解决文字/线条边缘锯齿 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 启用文本抗锯齿 g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); // 绘制背景图 g2d.drawImage(bgImage, 0, 0, this); // 绘制棋盘网格线 drawBoardGrid(g2d); // 绘制所有棋子 drawAllPieces(g2d); }这里的关键细节是Graphics2D的强制类型转换。Graphics是抽象基类,Graphics2D才提供setRenderingHint()等高级渲染控制。而super.paint(g)的调用绝非可有可无——它会先擦除旧画面,避免多次repaint()产生残影。如果你删掉这行,快速点击棋盘时会出现棋子拖影,这就是典型的“未清除背景”问题。
棋盘网格线的绘制(drawBoardGrid()方法)采用了动态缩放设计。PlayChess.java第240行定义了GRID_SIZE = 30(格子间距),但实际绘制时:
for (int i = 0; i <= BOARD_SIZE; i++) { // 横线:y坐标 = margin + i * GRID_SIZE g2d.drawLine(margin, margin + i * GRID_SIZE, width - margin, margin + i * GRID_SIZE); // 竖线:x坐标 = margin + i * GRID_SIZE g2d.drawLine(margin + i * GRID_SIZE, margin, margin + i * GRID_SIZE, height - margin); }margin(边距)和BOARD_SIZE(棋盘大小)在类顶部定义为final static int,这意味着你只需修改BOARD_SIZE = 15,整个15×15棋盘(围棋规格)就能自动生成,无需调整任何坐标计算。这种参数化设计,正是工业级代码与玩具代码的分水岭。
棋子绘制则展示了资源管理的最佳实践。PlayChess.java第275行:
// 根据棋子颜色选择图片 Image pieceImg = (pieceColor == PLAYER_BLACK) ? blackPiece : whitePiece; g2d.drawImage(pieceImg, x - PIECE_RADIUS, y - PIECE_RADIUS, // 居中绘制,减去半径偏移 PIECE_RADIUS * 2, PIECE_RADIUS * 2, // 宽高均为直径 this);PIECE_RADIUS = 12确保棋子直径24像素,在30像素格距下留有6像素呼吸空间,视觉上不拥挤。而x - PIECE_RADIUS的偏移计算,避免了新手常犯的“棋子左上角对齐格子左上角”导致的错位问题。
3.2 事件驱动编程:鼠标点击如何变成一次落子
Swing的事件模型常被描述为“委托式”,但新手很难理解“为什么要在PlayChess里写addMouseListener(this)”。真相是:this(即PlayChess实例)同时实现了MouseListener接口,因此它既是事件源(拥有addMouseListener方法),又是事件处理器(必须实现mousePressed()等5个方法)。这种设计让逻辑集中,避免了创建匿名内部类带来的代码膨胀。
mousePressed()方法的实现(PlayChess.java第142行)是理解事件驱动的关键:
public void mousePressed(MouseEvent e) { if (!gameRunning || currentPlayer != PLAYER_HUMAN) return; int x = e.getX(); int y = e.getY(); // 将像素坐标转换为棋盘坐标(0-14) int col = (x - margin + GRID_SIZE/2) / GRID_SIZE; int row = (y - margin + GRID_SIZE/2) / GRID_SIZE; // 边界检查:确保点击在有效棋盘范围内 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; // 检查该位置是否为空 if (board[row][col] != 0) return; // 执行落子 board[row][col] = PLAYER_HUMAN; undoStack.push(new Move(row, col, PLAYER_HUMAN)); // 记录悔棋步骤 repaint(); // 触发重绘 // 判断人类玩家是否获胜 if (NullAndCount.checkWin(board, row, col)) { JOptionPane.showMessageDialog(this, "恭喜!你赢了!"); gameRunning = false; return; } // 切换到AI回合 currentPlayer = PLAYER_AI; // AI自动思考并落子(在EDT线程中) SwingUtilities.invokeLater(() -> { Point aiMove = robot.getBestMove(board); board[aiMove.y][aiMove.x] = PLAYER_AI; undoStack.push(new Move(aiMove.y, aiMove.x, PLAYER_AI)); repaint(); if (NullAndCount.checkWin(board, aiMove.y, aiMove.x)) { JOptionPane.showMessageDialog(this, "AI获胜!"); gameRunning = false; } else { currentPlayer = PLAYER_HUMAN; } }); }这段代码揭示了三个重要概念:
1.坐标转换:(x - margin + GRID_SIZE/2) / GRID_SIZE中的+ GRID_SIZE/2是四舍五入技巧,确保点击格子中心区域(如x=45)能正确映射到col=1而非col=0;
2.线程安全:AI计算(robot.getBestMove())在EDT(Event Dispatch Thread)外执行,但落子操作必须回到EDT,故用SwingUtilities.invokeLater()包裹。若直接在mousePressed()里执行AI计算,界面会卡死;
3.状态机思维:gameRunning和currentPlayer构成游戏状态,每次落子后必须显式更新,否则会出现“人类下完AI不响应”或“AI赢了还能继续下”的逻辑错误。
3.3 胜负判定算法:二维数组扫描的四种方向
NullAndCount.java的checkWin()方法是本工程算法核心,它用最朴素的暴力扫描实现高可靠性。以横向扫描为例(checkHorizontal()方法):
private static boolean checkHorizontal(int[][] board, int row, int col) { int count = 1; // 当前位置本身算1个 int color = board[row][col]; // 向右扫描:col+1, col+2, ... for (int c = col + 1; c < BOARD_SIZE && board[row][c] == color; c++) { count++; } // 向左扫描:col-1, col-2, ... for (int c = col - 1; c >= 0 && board[row][c] == color; c--) { count++; } return count >= 5; }这个算法的巧妙在于以落子点为中心双向扩展,而非从头遍历整行。假设玩家在(7,7)落子,传统做法是扫描第7行所有15个位置,而本算法只检查(7,6)、(7,5)…向左,和(7,8)、(7,9)…向右,最多扫描9次(5个向右+4个向左)就得出结论。时间复杂度从O(n)降到O(1),且逻辑清晰无歧义。
更关键的是边界防护。c < BOARD_SIZE和c >= 0的判断放在for循环条件里,而非循环体内if语句,这避免了ArrayIndexOutOfBoundsException。而board[row][c] == color的判断顺序不能颠倒——必须先检查索引合法性,再访问数组,否则c=-1时直接崩溃。
四个方向(横、竖、右下斜、左下斜)的扫描逻辑高度复用。checkDiagonal1()(右下斜)的坐标变换是board[row+i][col+i],checkDiagonal2()(左下斜)则是board[row+i][col-i]。这种模式化设计让代码易于维护:若要支持六子棋,只需将count >= 5改为count >= 6,四处修改即可,无需重写算法。
实操心得:在
NullAndCount.java第88行,checkWin()方法调用四个方向检查时,用了短路逻辑||:java return checkHorizontal(board, row, col) || checkVertical(board, row, col) || checkDiagonal1(board, row, col) || checkDiagonal2(board, row, col);
这意味着只要横向扫描已确认胜利,后续三个方向根本不会执行,进一步优化性能。这种“尽早退出”的思维,是编写高效算法的基本素养。
3.4 AI对战实现:从空位扫描到威胁评估的完整链条
Robot.java的getBestMove()方法是本工程最具教学价值的部分,它把抽象的“AI思考”分解为可追踪的步骤。我们以getThreatScore()方法(第132行)为切入点,还原整个决策链条:
第一步:获取所有合法空位getAllEmptyPositions()遍历board,收集Point列表。这里有个隐藏细节:Point的x/y坐标与数组索引[row][col]是反的(Point(x,y)对应board[y][x]),这是AWT坐标系与数组索引的习惯差异,Robot.java第45行注释明确提醒了这点。
第二步:对每个空位计算威胁值getThreatScore()的核心是evaluatePosition()(第158行):
private int evaluatePosition(int[][] board, int row, int col, int player) { int totalScore = 0; // 检查四个方向:横、竖、右下斜、左下斜 totalScore += evaluateDirection(board, row, col, player, 0, 1); // 横向 totalScore += evaluateDirection(board, row, col, player, 1, 0); // 竖向 totalScore += evaluateDirection(board, row, col, player, 1, 1); // 右下斜 totalScore += evaluateDirection(board, row, col, player, 1, -1); // 左下斜 return totalScore; }evaluateDirection()(第175行)才是真正干活的方法。它接收方向向量(dr, dc),例如(0,1)表示横向(行不变,列+1)。算法分三步:
1.模拟落子:临时将board[row][col]设为player;
2.扫描连续棋子:沿(dr,dc)方向向前扫描,统计连续同色数forwardCount;沿(-dr,-dc)方向向后扫描,统计backwardCount;
3.计算威胁等级:total = forwardCount + backwardCount + 1(+1是当前位置),然后根据total查权重表:
-total == 5→ 活五(必胜),返回10000分;
-total == 4→ 检查是否为“活四”(两端空),是则1000分,否则500分(冲四);
-total == 3→ 活三100分,冲三50分;
-total == 2→ 活二10分…
第三步:选择最优位置getBestMove()遍历所有空位的威胁值,返回最大值对应的Point。但这里有个精妙的防平局机制(第95行):
if (threatScore > bestScore || (threatScore == bestScore && random.nextDouble() < 0.5)) { bestScore = threatScore; bestMove = pos; }当多个空位威胁值相同时,用随机数决定选哪个,避免AI总是固定选择左上角,增加游戏趣味性。
注意事项:
Robot.java第198行restoreBoard()方法至关重要。它在evaluateDirection()结束后,将board[row][col]恢复为0。如果忘记这步,临时落子会污染真实棋盘,导致AI下一步计算基于错误状态。这个“模拟-评估-还原”的三段式结构,是所有基于搜索的AI算法的基石。
4. 实操过程详解:从解压到运行的每一步避坑指南
4.1 环境准备:JDK版本与路径配置的硬性要求
本工程明确要求JDK 1.8(Java 8),这是由readme.txt和编译生成的.class文件决定的。如果你用JDK 17运行,会遇到经典的java.lang.UnsupportedClassVersionError: ... Unsupported major.minor version 52.0错误。这里的52.0就是Java 8的版本号(Java 7是51,Java 9是53)。解决方案只有两个:
降级JDK(推荐新手):
- 卸载现有JDK,从Oracle官网下载JDK 8u391(最新免费版);
- 安装后配置环境变量:bash # Windows PowerShell $env:JAVA_HOME="C:\Program Files\Java\jdk1.8.0_391" $env:PATH="$env:JAVA_HOME\bin;$env:PATH" # 验证 java -version # 应输出 "java version "1.8.0_391""保留多版本JDK(进阶用户):
- 在项目根目录创建run.bat(Windows)或run.sh(Mac/Linux):bash # run.bat "C:\Program Files\Java\jdk1.8.0_391\bin\java.exe" -cp . PlayChess pause
- 这样无需全局切换JDK,双击run.bat即可启动。
提示:
readme.txt里提到“若使用IDE,请在项目属性中设置Compiler Compliance Level为1.8”。以Eclipse为例:右键项目 → Properties → Java Compiler → 勾选“Enable project specific settings” → 将“Compiler compliance level”设为“1.8”。若忽略此步,即使JDK 8已安装,Eclipse仍可能用默认的JDK 17编译,导致.class文件版本不匹配。
4.2 运行方式:命令行与IDE的双轨启动法
工程提供了三种启动方式,适用不同场景:
方式一:命令行直接运行(最快捷)
1. 解压到无中文路径的文件夹,如D:\fivechess;
2. 打开命令行,进入该目录:bash cd /d D:\fivechess
3. 执行:bash java PlayChess
注意:这里不加
.class后缀,也不加-cp .(当前目录默认在类路径中)。如果提示“找不到或无法加载主类”,大概率是路径中有空格或中文,或JDK未正确配置。
方式二:Eclipse一键导入(最省心)
1. 启动Eclipse,File → Import → General → Existing Projects into Workspace;
2. 选择解压后的文件夹,勾选项目名(通常为FiveGame或vN0enAJBTzFYAzjmoJvZ-master...);
3. 点击Finish,Eclipse会自动识别.project和.classpath,无需手动配置;
4. 在Package Explorer中找到PlayChess.java,右键 → Run As → Java Application。
方式三:IntelliJ IDEA导入(需微调)
IntelliJ不识别.project文件,需手动配置:
1. File → New → Project from Existing Sources → 选择解压目录;
2. 选择“Create project from existing sources” → Next;
3. 在“Project SDK”中选择JDK 1.8;
4. 关键步骤:在“Additional Libraries and Frameworks”中,勾选“Java”和“Swing”;
5. Finish后,右键PlayChess.java→ Run ‘PlayChess.main()’。
实操心得:首次运行时,如果界面显示异常(如棋盘空白、按钮错位),大概率是资源文件路径问题。
PlayChess.java第55行getClass().getResource("/bg_game.JPG")使用的是类路径相对路径,这意味着bg_game.JPG必须放在src目录下(或编译后与.class同级)。检查解压后的目录结构,确保图片文件不在resources/子文件夹里,否则需修改代码为"/resources/bg_game.JPG"。
4.3 功能验证:如何系统性测试每一项特性
不要满足于“能运行就行”,用以下清单逐项验证,确保你真正理解了代码:
| 测试项 | 预期现象 | 关键代码位置 | 常见问题 |
|---|---|---|---|
| 双人对战 | 点击棋盘,黑子白子交替出现 | PlayChess.java第142行mousePressed() | 若只能下黑子,检查currentPlayer是否未切换(第185行currentPlayer = PLAYER_AI) |
| 悔棋功能 | 点击“悔棋”按钮,最后一步棋子消失 | PlayChess.java第202行undoBtn.addActionListener() | 若悔棋无效,检查undoStack.pop()后是否调用repaint()(第208行) |
| AI对战 | 人类下完,AI自动落子,不卡顿 | PlayChess.java第180行SwingUtilities.invokeLater() | 若AI不响应,检查currentPlayer是否仍为PLAYER_HUMAN(状态未更新) |
| 胜负判定 | 形成五连时弹出提示框 | NullAndCount.java第88行checkWin() | 若未触发,用Debug模式检查board[row][col]值是否为1或2(非0) |
| 背景音乐 | 游戏开始时播放bg.mid | PlayChess.java第72行Applet.newAudioClip() | MIDI文件需用绝对路径或正确类路径,/bg.mid应与.class同级 |
特别提醒“悔棋”测试:连续悔棋3步后,再下新子,此时undoStack.size()应为原数量-3+1。打开Stack.java,在push()和pop()方法里加System.out.println("Stack size: " + size),实时观察栈大小变化,这是理解数据结构最直观的方式。
4.4 常见问题排查:那些让你抓狂半小时的“低级错误”
问题1:点击棋盘无反应,控制台无报错
排查路径:
- 检查PlayChess.java第138行gameRunning初始值是否为true(第42行private boolean gameRunning = true;);
- 检查mousePressed()方法是否被正确注册:boardPanel.addMouseListener(this)(第115行);
- 最隐蔽的原因:boardPanel的setPreferredSize()尺寸是否小于窗口,导致点击区域实际是空白背景。在initComponents()里添加:java boardPanel.setPreferredSize(new Dimension(600, 600)); // 确保足够大
问题2:AI总是下在(0,0),无视棋盘状态
根源:Robot.java第90行getAllEmptyPositions()返回空列表,导致getBestMove()默认返回(0,0)。
验证方法:在getBestMove()开头加日志:
System.out.println("Empty positions: " + emptyPositions.size());若输出0,说明board数组全满或全空——检查PlayChess.java里board初始化是否为new int[BOARD_SIZE][BOARD_SIZE](第45行),而非new int[0][0]。
问题3:悔棋后棋子消失,但board数组未更新
定位代码:PlayChess.java第208行undoStack.pop()后,必须有:
Move lastMove = undoStack.pop(); board[lastMove.row][lastMove.col] = 0; // 关键!清空数组 repaint();若缺少board[...]=0,界面重绘时仍会从board读取旧值,造成“棋子还在”的假象。
问题4:背景图bg_game.JPG显示为灰色方块
原因:Swing加载JPEG时默认使用ImageIO.read(),但本工程用Toolkit.getDefaultToolkit().getImage()(第55行),后者异步加载,drawImage()时图像可能未就绪。
解决方案:在initImages()方法末尾添加同步等待:
MediaTracker tracker = new MediaTracker(this); tracker.addImage(bgImage, 0); try { tracker.waitForID(0); } catch (InterruptedException e) {}高级技巧:在
PlayChess.java第220行paint()方法开头,添加System.out.println("Repaint called at " + System.currentTimeMillis());。当你疯狂点击按钮时,如果控制台刷屏打印,说明repaint()被过度调用,需检查是否有repaint()写在mouseMoved()里(应只在mousePressed()中调用)。
5. 进阶改造与学习路径:从运行到二次开发的跃迁
5.1 三个安全的入门级改造(动手即见效)
不要急于重构整个AI,先从这三个零风险改动开始,建立掌控感:
改造1:更换棋盘主题bg_game.JPG是15×15棋盘,但你想试试19×19(围棋规格)?只需两步:
1. 修改PlayChess.java第38行BOARD_SIZE = 19;
2. 用画图软件将bg_game.JPG拉伸为600×600像素(保持纵横比),确保网格线清晰。
效果:棋盘变大,落子逻辑完全不变,因为所有坐标计算都基于BOARD_SIZE和GRID_SIZE。
改造2:调整AI进攻性
想让AI更激进?打开Robot.java第102行,把权重表中的LIVE_THREE_SCORE从100改为300:
private static final int LIVE_THREE_SCORE = 300; // 原为100保存后重新编译(javac Robot.java),再运行。你会发现AI更倾向于构建三连,而非单纯防守——这是理解“权重调优”的最佳入口。
改造3:添加音效反馈bg.mid只在开局播放,你想落子时也“滴”一声?在PlayChess.java第165行board[row][col] = PLAYER_HUMAN;后添加:
try { AudioClip clickSound = Applet.newAudioClip(getClass().getResource("/click.wav")); clickSound.play(); } catch (Exception ex) { // 若wav文件不存在,静默失败 }然后把click.wav(任意短促音效)放入同级目录。注意:Java原生只支持.wav和.mid,不支持.mp3。
5.2 通向中级开发的三座桥梁
当你能流畅修改上述内容,就可以挑战这些真正提升工程能力的任务:
桥梁1:实现网络对战(Socket基础)
目标:两台电脑运行同一程序,一台当Server,一台当Client。
- 新建NetworkManager.java,用ServerSocket监听端口;
-PlayChess.java中,将mousePressed()的落子逻辑改为:本地更新board→ 发送"MOVE 7 5"字符串到对方 → 等待对方回传;
- 关键难点:Swing线程安全——网络I/O不能阻塞EDT,必须用SwingWorker在后台线程执行。
学习价值:理解阻塞I/O与GUI线程的协作模式。
桥梁2:持久化棋局(文件IO进阶)
目标:退出游戏时自动保存当前board数组,重启后可继续。
- 在PlayChess.java的windowClosing()事件中,用ObjectOutputStream将board序列化到save.dat;
- 启动时检查save.dat是否存在,存在则反序列化加载。
学习价值:掌握Java序列化机制与异常处理(IOException,ClassNotFoundException)。
桥梁3:可视化AI思考过程(Swing高级绘图)
目标:AI计算时,在棋盘上显示每个空位的威胁值(红色数字)。
- 在paint()方法中,遍历board,对每个空位(r,c),调用robot.getThreatScore(board, r, c);
- 用g2d.drawString(score+"", x, y)在对应位置绘制数字。
学习价值:深入理解Swing双缓冲绘图与坐标系映射。
5.3 面向对象设计的再思考:当需求变更时代码如何应对
假设产品经理突然提出:“增加‘禁手规则’(黑棋不能三三、四四、长连)”。你会如何修改?这不是考察算法,而是检验OOP设计质量:
- 坏方案:在
NullAndCount.checkWin()里堆砌if (player==BLACK) {...}判断,很快代码变得臃肿难读; - 好方案:创建
RuleEngine接口,定义isLegalMove(int[][] board, int row, int col, int player)方法; StandardRule实现基础五连规则;RenjuRule实现禁手规则,复用NullAndCount的扫描逻辑,只在最后加禁手检查;PlayChess中注入RuleEngine实例,落子前调用ruleEngine.isLegalMove(...)。
这种设计让规则可插拔,未来支持“连珠”、“花月”等变种规则,只需新增实现类,PlayChess零修改。而本工程现有的清晰类职责划分(NullAndCount只负责数学判定),正是支撑这种演化的坚实基础。
最后分享一个小技巧:在
PlayChess.java第45行board数组声明处,将其改为private final int[][] board;,并在构造函数中初始化。虽然会增加几行代码,但final修饰符能防止意外的board = new int[15][15]赋值,让数组引用不可变——这是防御性编程的第一课。真正的高手,不是写出最炫的算法,而是让代码在需求变更的风暴中,依然稳如磐石。
本文还有配套的精品资源,点击获取
简介:直接运行的Java五子棋游戏工程,基于Swing构建完整GUI,含主程序PlayChess、AI逻辑Robot、栈Stack、链表节点LNode、胜负判定NullAndCount等全部源码文件,均已添加清晰中文注释,关键步骤逐行说明。配套资源齐全:棋子图片(black.png/white.png)、游戏背景bg_game.JPG、菜单图menu.png、指针pointer.png、音效bg.mid等。支持双人对战与人机对战,AI具备基础落子策略;悔棋功能依托栈结构实现;胜负判断采用二维数组扫描+连续计数逻辑。项目已编译生成.class文件,附带readme.txt详细说明JDK版本要求、Eclipse导入方式(含.project和.classpath)、运行命令及常见问题。无需修改代码,解压后用IDE或命令行即可一键启动,适合零基础学习面向对象编程、事件监听、GUI绘图、数组应用与简单算法实现。
本文还有配套的精品资源,点击获取
