简介:本资源是一份面向Java初学者与课程设计实践者的五子棋游戏完整实现源码,聚焦图形界面开发、游戏逻辑建模与事件驱动编程等核心能力训练。压缩包共27个文件(24个Java源文件、1个Maven配置pom.xml、1个说明文档readme.txt、1个.gitignore),总大小仅40KB,轻量易读,适合快速导入IDE运行调试。其中包含Main入口类、Board棋盘管理、GameController胜负判定、Piece棋子封装等关键模块,代码结构清晰,注释规范,辅以Maven构建支持和标准版本控制配置,便于学习者理解MVC分层思想与Swing界面交互机制。已有269人下载学习,可直接用于Java GUI编程实训、算法课设或小游戏开发入门参考,是掌握二维数组状态管理、鼠标事件响应及回合制逻辑实现的典型教学范例。 直接说结论:这个项目看起来只是“一个简单的五子棋”,但它恰好覆盖了Java入门阶段最值得反复练手的三块硬骨头——二维数组的建模、状态机的流转、以及算法思路的落地。我见过太多人学完Java语法后卡在“不知道写什么项目”,五子棋就是一个刚好能让你把语法糖吐出来变成代码的题目。接下来我把整个实现思路、核心源码、以及我实测踩过的坑一条条拆开讲,代码可以直接复用,逻辑也能迁移到其他棋类游戏上。
1. 项目整体设计与思路拆解
1.1 为什么选控制台五子棋而不是Swing版
很多人一提“Java五子棋”,第一反应是做个带界面的Swing版本,画个棋盘、鼠标点击落子,看起来很炫。但我的建议是:第一版一定要做控制台版本。
原因很简单:Swing会分散你的注意力。你要处理窗口布局、鼠标监听、事件分发、重绘线程,这些和五子棋核心规则没有直接关系。等你费半天劲把界面调好,可能连胜负判断都还没写利索。控制台版本则强制你把精力集中在两件事上:数据怎么存、规则怎么判。这两个问题想透了,后面套任何界面框架都只是时间问题。
而且从代码量来看,控制台版核心逻辑大概150到200行就能写完,非常适合在一个晚上内跑通全流程。别小看这种“快速跑通”的价值,它能给你一个完整的正反馈:从输入坐标到棋盘变化,再到胜负提示,所有环节都掌握在自己手里。
1.2 棋盘的数据结构选型:固定二维数组是最优解
五子棋棋盘标准是15×15,也有19×19的变体。我这里用15×15。数据结构上,很多人第一反应是用集合或者动态数组,但其实固定大小的二维数组才是最合适的。
为什么?因为棋盘的物理结构就是固定的,15×15的格子不会在游戏过程中增加或减少。用char[][]或者int[][]既能直接映射棋盘坐标,又能通过下标访问做到O(1)级别的读写,而且打印棋盘时两层for循环就是天然的顺序遍历。
我用的是char[][]:
- 空位置用
'+'表示 - 黑子用
'●'表示 - 白子用
'○'表示
这里有个小设计:用char而不是int或String。char在输出时直接拼接,视觉上干净;在判断时也可以用字符常量的方式进行匹配,比如board[x][y] == '●'。如果用int,你还得约定0、1、2分别代表什么,增加记忆负担。
1.3 核心模块划分:把逻辑拆成三个独立层
整个程序我拆成三个部分,这也是我后来觉得最值得坚持的划分方式:
- 棋盘模块:负责棋盘的初始化、打印、坐标合法性校验、落子写入。
- 胜负判断模块:负责在每次落子后,检查以当前落子点为中心,四个方向是否存在五子连珠。
- 游戏流程模块:负责玩家轮流落子、调用棋盘模块和胜负判断模块、控制游戏结束。
这三个模块之间通过方法调用串联,不互相持有对方的内部数据。比如胜负判断模块只需要接收棋盘数组和当前落子坐标,就能返回结果,完全不关心是谁下的、下到第几手了。这样设计的好处是:以后想加AI对战,只需要替换游戏流程模块里的“玩家二”部分,棋盘和判断模块一行都不用改。
很多人写小项目喜欢把所有代码堆在main方法里,500行一个方法写完。这种方式跑起来没问题,但后面想加功能、想排查问题,会非常痛苦。分层是成本最低、收益最明显的习惯。
2. 核心细节解析与实操要点
2.1 棋盘初始化和打印:细节决定体感
棋盘初始化的代码很简单,一个双循环把二维数组填充为'+'就行。但打印这里我踩过一个坑:直接System.out.print(board[i][j])会把每个字符紧紧贴在一起,棋盘看起来像一堵密不透风的墙。
我的做法是在每个字符后面加一个空格,让每个棋子之间有间隔,同时为了便于定位坐标,我在棋盘左侧和顶部打印了行列号。
private static final int BOARD_SIZE = 15; private static final char EMPTY = '+'; private static final char BLACK = '●'; private static final char WHITE = '○'; public static void printBoard(char[][] board) { System.out.print(" "); for (int i = 0; i < BOARD_SIZE; i++) { System.out.printf("%2d ", i); } System.out.println(); for (int i = 0; i < BOARD_SIZE; i++) { System.out.printf("%2d ", i); for (int j = 0; j < BOARD_SIZE; j++) { System.out.print(board[i][j] + " "); } System.out.println(); } }这里%2d的作用是让列号对齐,个位数的坐标前补一个空格。别小看这个格式化输出,坐标对齐后,你在控制台里输入坐标时眼睛不容易看错行。棋盘打印完毕后,控制台会显示一个带坐标的网格,玩家输入“7 7”就能精确落在对应位置。
2.2 落子合法性校验:一个被无数人忽略的细节
落子合法性的判断逻辑很简单:坐标是否越界?该位置是否已经有棋子?但简单归简单,我在帮别人review代码时发现一个高频bug:很多人判断越界时只检查了下界,也就是x < 0 || y < 0,忘了检查上界x >= BOARD_SIZE || y >= BOARD_SIZE。结果玩家输入“20 20”,程序直接抛出ArrayIndexOutOfBoundsException。
另一个高频问题是把判断逻辑写成这样:
if (board[x][y] != EMPTY) { return false; } else { return true; }这种写法没错,但完全可以简化成一句return board[x][y] == EMPTY;。说实话这不是什么大问题,但如果你有面试打算,这种小而美的写法会给面试官留下更好的印象。
2.3 胜负判断:为什么只需要检查四个方向
五子棋的胜负规则是“横、竖、左斜、右斜”四个方向上有连续五个同色棋子。有些初学者会写出检查八个方向的代码,但这样会重复计算。比如从左到右和从右到左其实是同一条线。
我的做法是:以当前落子点为起点,向某个方向的正向和反向同时延伸,统计同色棋子的连续数量,然后和四个方向逐一比对。只要某个方向计数大于等于5,就判定当前玩家获胜。
这里还有一个效率优化的点:不需要每次都扫描整个棋盘。因为胜负一定和“最后一步落子”相关,所以只需要以当前落子点为中心,在四个方向分别向两端搜索。这一点在开局阶段提升不明显,但在残局阶段,棋盘上可能有上百个棋子,每次都做全盘扫描会造成不必要的计算浪费。
核心代码如下:
public static boolean checkWin(char[][] board, int row, int col) { char current = board[row][col]; int[][] directions = {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int[] dir : directions) { int count = 1; count += countDirection(board, row, col, current, dir[0], dir[1]); count += countDirection(board, row, col, current, -dir[0], -dir[1]); if (count >= 5) { return true; } } return false; } private static int countDirection(char[][] board, int row, int col, char current, int dx, int dy) { int count = 0; int x = row + dx; int y = col + dy; while (x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE && board[x][y] == current) { count++; x += dx; y += dy; } return count; }directions数组里的四个元素分别代表水平、垂直、正斜线、反斜线。每个方向只需要用一组方向向量,再配合反向取负即可。这段代码写完之后,我也顺手做了个简单测试:构造一个只有四个棋子的局部棋盘,然后强制在第五个位置落子验证胜利。测试通过后再接入主流程,这样可以避免把棋局玩到一半才发现判断逻辑有问题。
3. 实操过程与核心环节实现
3.1 完整源码:一个可直接编译运行的控制台五子棋
下面是完整源码,包含双人对战模式,代码里加了比较详细的中文注释,方便你对照理解。主类命名为GobangGame,直接复制到一个Java文件里就能编译运行。
import java.util.Scanner; public class GobangGame { private static final int BOARD_SIZE = 15; private static final char EMPTY = '+'; private static final char BLACK = '●'; private static final char WHITE = '○'; private char[][] board; private Scanner scanner; public GobangGame() { board = new char[BOARD_SIZE][BOARD_SIZE]; scanner = new Scanner(System.in); initBoard(); } private void initBoard() { for (int i = 0; i < BOARD_SIZE; i++) { for (int j = 0; j < BOARD_SIZE; j++) { board[i][j] = EMPTY; } } } public void printBoard() { System.out.print(" "); for (int i = 0; i < BOARD_SIZE; i++) { System.out.printf("%2d ", i); } System.out.println(); for (int i = 0; i < BOARD_SIZE; i++) { System.out.printf("%2d ", i); for (int j = 0; j < BOARD_SIZE; j++) { System.out.print(board[i][j] + " "); } System.out.println(); } } public boolean placePiece(int x, int y, char piece) { if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) { System.out.println("坐标越界,请输入0~14之间的数字"); return false; } if (board[x][y] != EMPTY) { System.out.println("该位置已有棋子,请重新输入"); return false; } board[x][y] = piece; return true; } public boolean checkWin(int row, int col) { char current = board[row][col]; int[][] directions = {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int[] dir : directions) { int count = 1; count += countDirection(row, col, current, dir[0], dir[1]); count += countDirection(row, col, current, -dir[0], -dir[1]); if (count >= 5) { return true; } } return false; } private int countDirection(int row, int col, char current, int dx, int dy) { int count = 0; int x = row + dx; int y = col + dy; while (x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE && board[x][y] == current) { count++; x += dx; y += dy; } return count; } public boolean isBoardFull() { for (int i = 0; i < BOARD_SIZE; i++) { for (int j = 0; j < BOARD_SIZE; j++) { if (board[i][j] == EMPTY) { return false; } } } return true; } public void startGame() { boolean isBlackTurn = true; while (true) { printBoard(); System.out.println((isBlackTurn ? "黑方" : "白方") + "落子,请输入行和列(用空格隔开):"); int x, y; try { x = scanner.nextInt(); y = scanner.nextInt(); } catch (Exception e) { System.out.println("输入格式错误,请重新输入两个整数"); scanner.nextLine(); continue; } char currentPiece = isBlackTurn ? BLACK : WHITE; if (!placePiece(x, y, currentPiece)) { continue; } if (checkWin(x, y)) { printBoard(); System.out.println((isBlackTurn ? "黑方" : "白方") + "获胜!"); break; } if (isBoardFull()) { printBoard(); System.out.println("棋盘已满,平局"); break; } isBlackTurn = !isBlackTurn; } scanner.close(); } public static void main(String[] args) { new GobangGame().startGame(); } }主流程的startGame方法是一个典型的游戏循环:打印棋盘、接收输入、落子、判胜负、切换玩家。isBlackTurn这个布尔值负责交替控制黑白方。try-catch用来拦截非数字输入,避免程序崩溃。
3.2 输入解析:如何优雅地处理玩家输入
控制台程序里输入处理是最容易翻车的地方。Scanner的nextInt()方法在遇到非数字输入时会抛出InputMismatchException,如果不加处理,整个程序直接崩溃。
我在上面代码里采用了try-catch加scanner.nextLine()清空缓冲区的做法。这里解释一下为什么需要nextLine():当nextInt()读取失败时,错误输入仍然留在缓冲区里,如果不清空,下一次循环会继续读到这个残留值,造成死循环。
另一种可行方案是全部读取字符串,再手动解析:
String input = scanner.nextLine(); String[] parts = input.split(" "); int x = Integer.parseInt(parts[0]); int y = Integer.parseInt(parts[1]);这个方案更灵活,甚至可以让玩家输入“5,7”这种带逗号的格式,只需要把split参数改成英文逗号。但缺点是代码量稍多,而且Integer.parseInt也需要处理NumberFormatException。
我最终选择nextInt()加try-catch的方案,是因为代码更简洁,对新手更友好。但你在项目里可以根据自己的偏好调整。
3.3 体验优化:加一个简单的落子提示和当前回合信息
纯控制台五子棋最大的问题是“坐在电脑前的两个人需要凑到屏幕前才能看清棋盘”。一个很实用的优化是在每次落子后,打印出上一步的落子坐标和当前是第几手。
实现方式很简单:在startGame方法里加一个moveCount计数器,每次成功落子后自增,然后在打印棋盘前输出一条信息,比如:
System.out.println("第 " + moveCount + " 手,黑方落子于 (" + x + ", " + y + ")");这个信息虽然简单,但能大幅提升复盘体验。棋局结束后,你们可以根据这个输出记录把整个对局复盘一遍,对新手理解棋局走势特别有帮助。
4. 常见问题与排查技巧实录
4.1 输入坐标越界或输入字母导致崩溃
这是我在测试中遇到最多的一个问题。解决方案在第一版代码里用的是return退出当前循环,但后面我发现单纯return会导致玩家直接失去这次落子机会,体验不好。
正确姿势是在异常捕获后继续循环,而不是终止循环。同时考虑增加一个坐标范围的显式提示:
System.out.println("请输入0~14之间的两个整数,用空格分隔");另一个细节是:Scanner在读取完int后,如果后面还有nextLine(),需要额外调用一次nextLine()吃掉换行符。我这里没有混用nextLine()所以没这个问题,但如果你在读完坐标后想加一个“是否重新开始”的确认,就要注意换行符残留。
4.2 胜负判断漏判斜线方向
有次我把directions数组写成了{{1, 0}, {0, 1}, {1, 1}, {-1, 1}},第四项的-1, 1和第三项的1, 1其实在同一条斜线上,只是方向相反。按我的实现方式,每个方向会在代码里同时向正向和反向延伸,所以只需要存一个方向向量就够了,重复添加反而会导致某条线被计两次。
这里有一个判断逻辑的自我检查方法:每次落子后,手动在棋盘上模拟摆出“横五”“竖五”“左斜五”“右斜五”四种子形状,看程序是否正确识别胜利。常见的漏判场景是斜线,因为视觉上不如横竖直观,代码里也容易把方向向量弄错。
4.3 棋盘满但没人赢的判断
棋盘满意味着两百多个格子全部被棋子占据,这种情况在真实对局里几乎没有,但作为程序健壮性的一部分还是应该处理。我在代码里加了isBoardFull()方法,每次落子后检查一次。这个检查在最坏情况下也是O(n²)的复杂度,但因为棋盘只有15×15,实际开销非常小,不需要额外优化。
4.4 设计一个简单的AI:从完全随机到基础防守
代码走到这一步,双人对战已经能玩了。但如果你想进一步提升,试着给这个程序加一个简易AI对手,也就是热搜词里提到的“五子棋地狱难度ai”的入门版。
最简单的AI是随机落子:每次从所有空格中随机挑一个位置。这个难度大概连新手都打不过。进阶版本是“堵对方”:扫描整个棋盘,如果发现对方有四个连续的棋子,就在两端落子堵住。再进一步可以做一个基于评分的AI,给每个空格打分,评估该位置在四个方向上既有的同色棋子数量和潜在连子潜力,选最高分落子。
我当时给这个项目扩展AI时,用的是评分表法。核心思路是:对每个空格,分别模拟黑方和白方在该位置落子,计算各自的“威胁值”,然后取加权和。
public int evaluate(char[][] board, int row, int col, char aiPiece, char humanPiece) { int score = 0; score += evaluateDirection(board, row, col, aiPiece); score += evaluateDirection(board, row, col, humanPiece); return score; }AI的代码量大约比基础版多一倍,但能显著提升可玩性。这个阶段你会接触到“打分”“权重”这些算法概念,对后续学习更复杂的AI算法很有帮助。
4.5 从控制台到可视化:如果你非要界面版
控制台版本跑通后,想加界面的心情我理解。我当时是把控制台版本的核心逻辑原封不动保留,单独写了一个GobangUI类,用Swing画棋盘。核心逻辑只需要把char[][]暴露出来,UI层负责渲染。
Swing版的关键点有两个:
- 用
JPanel重写paintComponent方法,绘制网格和棋子 - 用
MouseListener监听鼠标点击,换算成棋盘坐标后调用核心逻辑
鼠标坐标换算是这样一个公式:
int col = (mouseX - offsetX) / cellSize; int row = (mouseY - offsetY) / cellSize;注意处理点击位置在两个格子中间的情况,可以做四舍五入取整。这一步看似简单,但很考验对坐标系的理解,建议在控制台版跑通之后再动手。
5. 项目复盘与扩展思路
5.1 这个项目在Java学习路线里的位置
如果有人问我Java学到什么程度可以开始写五子棋,我的回答是:学完数组、循环、方法、条件判断就可以。它不需要面向对象的高级特性,甚至不需要集合框架。但恰恰是这种“基础够用”的特性,让它成为检验基础功底的试金石。
像我上面演示的代码,已经把面向对象的基本思想融进去了:用类封装游戏状态,用方法抽象行为,用私有字段保护数据。如果后续学完继承和多态,还能进一步重构:比如把棋子类型做成枚举,把玩家行为抽象成接口。
所以这个项目的价值不在于“能跑”,而在于你从“能跑”到“跑得优雅”之间能学到多少东西。
5.2 可以扩展的方向
- 悔棋功能:用一个栈存储每一步的落子坐标,悔棋时出栈并清空对应棋盘位置。
- 保存/加载棋局:把棋盘数组序列化到文件,下次启动时读取。
- 网络对战:基于Socket编程实现双人对战,一个作为服务端,一个作为客户端。
- 难度分级的AI:简单难度随机落子,中等难度只会堵四连,困难难度结合评分法。
- 房间制和录像回放功能:在保存的基础上,增加关键节点分支,方便复盘讲解。
5.3 为什么建议你自己动手写一遍
网络上确实能找到大量五子棋源码,也包括我上面分享的这份。但我的建议是:你可以参考,但一定要自己动手敲一遍。因为写五子棋的过程本质上是“把需求翻译成代码”的完整练习,你在过程中踩过的每一个坑,都会在未来写复杂项目时变成你的直觉。
比如你会发现“棋盘越界”不只是简单的if判断,还要考虑输入容错;你会发现“胜负判断”看似简单,但方向向量的正负号写错一次就会导致斜线判负失败;你会发现“游戏主循环”如果设计得不清晰,加悔棋功能时会无从下手。这些东西,光看别人代码是学不来的。
最后的经验之谈
这个项目带我走过了Java入门最枯燥的阶段。说实话,语法书里的每一个知识点单独拎出来都很无趣,但当你抱着“我要写一个五子棋”的目标去学时,一切都变得具体起来。数组不再是一堆抽象概念,而是棋盘上真实存在的一个个交叉点;循环不再是为了做题而做题,而是因为我们确实需要遍历棋盘上的每一行每一列。
如果你拿来练手,我强烈建议你完成第一版后,尝试自己加上一个“悔棋”功能。这个功能会强迫你思考:怎么记录每一步的状态?是记录坐标还是记录整个棋盘快照?栈的顺序应该怎么控制?这些问题想清楚了,你对程序状态管理的理解会上一个台阶。
先跑起来,再跑得优雅,最后跑出你自己的设计味道——这就是做编程小项目最实在的路径。
本文还有配套的精品资源,点击获取