一、题目
数独是源自 18 世纪瑞士的一种数学游戏,是一种运用纸、笔进行演算的逻辑游戏。玩家需要根据 9×9 盘面上的已知数字,推理出所有剩余空格的数字,并满足每一行、每一列、每一个粗线宫(3*3)内的数字均含 1-9,不重复。
数独盘面是个九宫,每一宫又分为九个小格。在这八十一格中给出一定的已知数字和解题条件,利用逻辑和推理,在其他的空格上填入 1-9 的数字。使 1-9 每个数字在每一行、每一列和每一宫中都只出现一次,所以又称“九宫格”。
二、分值表
功能列表 | 打分说明 | |
扩展1 | 能够实时显示游戏的状态信息为 7 分 | 7 |
扩展2 | 可以正确将时间显示在扩展 1 的状态条中显示为 7 分 | 7 |
扩展3 | 和运行原型的界面效果有变化即可加2分,在此基础上视觉效果更赏心悦目加 1-4 分。 | 6 |
填表人 | 惠龙飞 |
三、类关系设计
这个项目的核心就是SudokuMain类,原先这个类的含义是主函数所在的类,但是我把它改成了游戏运行的主面板,当调用构造函数SudokuMain()就生成了一个JFrame的子类,也就是游戏的主面板。在这个面板上增加了菜单和进度条时间统计,菜单的功能包括:选择三种难度重新生成一个新的游戏面板、退出游戏、帮助(游戏说明)、作者二维码等。
SudokuMenu、EndJFrame、AccountJFrame、HelpJFrame也是继承自JFrame类,分别生成菜单界面、游戏胜利的面板、作者二维码面板、帮助面板。SudokuMenu是游戏运行首先出现的面板,在这里可以供用户选择难度,程序主函数只需要调用SudokuMenu的构造函数就行。因此创建了一个APP类用于验证。EndJFrame是GameBoardPanel类需要的,而AccountJFrame和HelpJFrame是SudokuMain类需要的。
GameBoardPanel类:继承自JPanel类。创建一个JPanel的子类类型,该面板中包含了9*9个Cell对象,采用了GridLayout布局管理器。需要用到cell表示每一个格子,需要用到puzzle表示数独,需要用到监听器,为每一个格子绑定。还有判断还有多少个格子不正确的方法numberOfCorrect()、美化界面用的addBoard()方法。这个类需要用到Cell和Puzzle。
Cell类继承自JPanel类,需要CellIputListener类,对Cell类没有进行大的修改。
CellIputListener类继承自ActionListener类,定义了用户敲击回车时的情况,包括:对非给定的cell进行判断并修改状态涂色;调用GameBoardPanel中的isSolved方法,用来判断游戏是否结束,如果游戏成功解决,那么就可以弹出对话框显示结果;每一次敲击空格,都要调用numberOfCorrect()重新计算剩余空格数调用updateRemainingCells()更新面板上显示的剩余空格数量。
Puzzle类需要用到SudokuGenerator类的方法,用来表达一局游戏中的puzzle的信息。
SudokuGenerator类:生成完整数独generateSudoku()方法、根据空格数生成不同难度的数独generateUncompletedSudoku()方法,以及很多辅助私有化方法,这个实现过程放在算法设计重点讲解。
GameTimer是新创建的类,通过使用计时器对象和ActionListener来实现每秒更新一次游戏进行时间的功能。它可以在游戏开始时启动计时器,并在计时器触发时更新显示时间的文本框,以实时显示游戏进行的时间。
GameProssBar是在主面板上显示剩余空格数数和时间的类。
CellStatus存放了cell的四种状态。
SudokuConstants存放了游戏面板的大小、每种难度下游戏挖去空格的大致数量、游戏需要的图片和音乐的位置 。
四、算法设计
这个项目的算法部分主要是完整数独的生成,以及空缺数独的生成。要注意的是,需要保证数独只有唯一解。我采取了回溯算法。
生成完整数独:
首先随机在左上、正中央、右下三个宫内随机填入1-9的数。这样做可以保证此时不会出现多解的情况,并且不会出现错误。
2 | 8 | 1 | ||||||
6 | 3 | 4 | ||||||
7 | 9 | 5 | ||||||
3 | 7 | 4 | ||||||
5 | 2 | 1 | ||||||
6 | 8 | 9 | ||||||
5 | 6 | 1 | ||||||
8 | 4 | 9 | ||||||
2 | 3 | 7 |
接着调用求解数独的方法:采用了回溯算法,递归调用。当数独成功填满并且满足数独的规则时,返回true,这表示已经找到了一个有效的解;当无法继续填充数独时,返回false,这种情况通常发生在回溯的过程中,当某个格子无法填入任何数字时,需要回溯到上一个选择点。
private boolean solveSudoku(int[][] board) {
for (int row = 0; row < board.length; row++) {
for (int col = 0; col < board[0].length; col++) {
if (board[row][col] == 0) { // 使用0表示空格
for (int num = 1; num <= 9; num++) {
if (isValid(board, row, col, num)) {
board[row][col] = num;
if (solveSudoku(board)) {
return true;
} else {
board[row][col] = 0; // 回溯
}
}
}
return false;
}
}
}
return true;
}
这样我们就生成了一个完整的数独。
生成有空缺的数独(即终盘):
采用“挖洞法”:基本思想就是“挖去”一个数独终盘上的一些格子,使其成为有唯一解的数独题。
为了判断解是否唯一,可以采取反证法。
挖去一个格子,调用求解器对挖去空格的数独题进行求解,一旦求解器能得到两个解,便立即终止求解器,同时宣布这个格子不能被挖去,本次“挖洞”操作是不合法的;若仅能得到一个解,即判定此次“挖洞”操作是合法的。
通过剪枝优化来降低耗费在“挖洞”尝试中的运行时间。
一旦我们发现一个格子不能被抹去(可能出现多解),就再也不管它,接着判断下面的格子,就是禁止选取曾经尝试过的格子,这样至多需要做81次“挖洞”尝试,而且任何空格将不会被重填,这一点不同于现有的"回溯"思想。
求解器代码如下:
/**
* 当仅当能求解出唯一一个数独时,返回true
* 若发现有多个数独,则返回false
*/
private static boolean ifOnlySolution(int[][] board) {
int[][] copyboard = copyBoard(board); // 创建一个副本
int solutionsCount = 0; // 记录解的数量
for (int row = 0; row < copyboard.length; row++) {
for (int col = 0; col < copyboard[0].length; col++) {
if (copyboard[row][col] == 0) {
for (int num = 1; num <= 9; num++) {
if (isValid(copyboard, row, col, num)) {
copyboard[row][col] = num;
if (ifOnlySolution(copyboard)) {
solutionsCount++; // 增加解的数量
if (solutionsCount > 1) {
return false; // 如果找到多个解,直接返回 false
}
}
copyboard[row][col] = 0; // 回溯
}
}
return solutionsCount == 1; // 返回是否找到唯一解
}
}
}
return true;
}
private static int[][] copyBoard(int[][] board) {
int[][] copy = new int[9][9];
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
copy[i][j] = board[i][j];
}
}
return copy;
}
挖空函数代码如下:
//将完整的数独挖空
public int[][] generateUncompletedSudoku(int[][] board, int numberOfSpaces) {
int[][] uncompletedSudoku = copyBoard(board);
for (int i = 0; i < numberOfSpaces; i++) {
int row = random.nextInt(9);
int col = random.nextInt(9);
int temp = uncompletedSudoku[row][col];
uncompletedSudoku[row][col] = 0;
if (!ifOnlySolution(uncompletedSudoku)) {
uncompletedSudoku[row][col] = temp;
}
}
return uncompletedSudoku;
}
五、主干代码说明
SudokuMain类:核心是构造方法和updateRemainingCells
构造方法:
public SudokuMain(int cellsToGuess) {
// 将board面板添加到内容面板的中央区域
getContentPane().setLayout(new BorderLayout());
getContentPane().add(board, BorderLayout.CENTER);
//初始化菜单
initJMenu();
// 初始化一局游戏面板
board.newGame(cellsToGuess);
//初始化状态栏
getContentPane().add(progressBar_1, BorderLayout.BEFORE_FIRST_LINE);
progressBar_1.setPreferredSize(new Dimension(0,80));
progressBar_1.setText(" 剩余单元格数: "+ board.numberOfCorrect());
getContentPane().add(progressBar_2, BorderLayout.AFTER_LINE_ENDS);
progressBar_2.setPreferredSize(new Dimension(120,0));
// 创建 GameTimer 对象,并传入 JTextField 实例
GameTimer gameTimer = new GameTimer(progressBar_2);
// 启动计时器
GameTimer.start();
// 设置页面置顶
setAlwaysOnTop(true);
//根据内容自动调整窗口的大小,以适应组件的尺寸。
pack();
//设置窗口居中显示
setLocationRelativeTo(null);
//JFrame.EXIT_ON_CLOSE表示在关闭窗口时终止程序的运行。
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
//设置窗口的标题为"Sudoku"
setTitle("Sudoku");
//将窗口设置为可见状态,以便显示在屏幕上。
setVisible(true);
}
首先是一个JFrame通常有的设置,包括设置排版,设置标题,关闭方式,页面置顶,页面居中,窗口可视;然后调用函数设置了菜单和初始化游戏面板;最后加上了两个JTextField,分别显示剩余单元格和花费时间。
初始化菜单的方法:
/**
* 初始化菜单
*/
private void initJMenu() {
JMenuBar jMenuBar = new JMenuBar();
//创建Jmenu
JMenu functionJMenu = new JMenu("function");
JMenu aboutJMenu = new JMenu("about");
JMenu difficultyMenu = new JMenu("new game");
//创建JMenuItem对象
JMenuItem exitItem = new JMenuItem("exit");
JMenuItem helpItem = new JMenuItem("help");
JMenuItem accountItem = new JMenuItem("QR-code");
JMenuItem easyItem = new JMenuItem("easy");
JMenuItem intermediateItem = new JMenuItem("intermediate");
JMenuItem difficultItem = new JMenuItem("difficult");
JMenuItem resetItem = new JMenuItem("reset game");
//添加选项
difficultyMenu.add(easyItem);
difficultyMenu.add(intermediateItem);
difficultyMenu.add(difficultItem);
functionJMenu.add(difficultyMenu);
functionJMenu.add(resetItem);
functionJMenu.add(exitItem);
aboutJMenu.add(helpItem);
aboutJMenu.add(accountItem);
jMenuBar.add(functionJMenu);
jMenuBar.add(aboutJMenu);
setJMenuBar(jMenuBar);
// 创建一个动作监听器
ActionListener buttonListener = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 获取触发动作的源对象
Object source = e.getSource();
if (source == easyItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.EASY);
dispose();
} else if (source == intermediateItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.INTER);
dispose();
} else if (source == difficultItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.DIFFICULT);
dispose();
} else if (source == exitItem) {
dispose();
} else if (source == helpItem) {
new HelpJFrame();
} else if (source == accountItem) {
new AccountJFrame();
} else if (source == resetItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.INTER);
dispose();
}
}
};
//增加动作监听
easyItem.addActionListener(buttonListener);
intermediateItem.addActionListener(buttonListener);
difficultItem.addActionListener(buttonListener);
exitItem.addActionListener(buttonListener);
helpItem.addActionListener(buttonListener);
accountItem.addActionListener(buttonListener);
resetItem.addActionListener(buttonListener);
}
创建一个JMenuBar;
创建三个JMenu用于放置JMenuItem,JMenu分别是“about”、“function”、“new game”;
创建多个JmenuItem;
将Jmenu加入各自的JMenuItem,JMenuBar加入各自的JMenu。
给JMenuItem加上动作监听,这里直接创造了匿名内部类,方便些。
更新剩余单元格数量的方法:
/**
* 这个方法用于更新剩余单元格数量,在每次敲击回车时调用
*/
public static void updateRemainingCells(int remainingCells){
progressBar_1.setText(" 剩余单元格数: "+ remainingCells);
}
SudokuMenu类:
唯一的公有方法是构造方法,用于被主函数直接调用
/**
* 构造函数,被APP直接调用
*/
public SudokuMenu() {
//初始化界面
initJFrame();
//初始化菜单
initMenu();
//将窗口设置为可见状态,以便显示在屏幕上。
setVisible(true);
}
两个私有方法如下:
/**
* initMenu()方法:用于初始化菜单
*/
private void initMenu() {
// 创建JLayeredPane作为容器
JLayeredPane layeredPane = new JLayeredPane();
layeredPane.setBounds(0, 0, getWidth(), getHeight());
// 创建一个背景标签,并将图片设置为标签的图标
ImageIcon backIcon = new ImageIcon(SudokuConstants.MENU_BACKGROUND_POSITION);
Image image = backIcon.getImage();
Image scaledImage = image.getScaledInstance(getWidth(), getHeight(), Image.SCALE_SMOOTH);
JLabel background = new JLabel(new ImageIcon(scaledImage));
background.setBounds(0, 0, getWidth(), getHeight());
// 加载前景图片,和标签
ImageIcon foregroundImageIcon = new ImageIcon(SudokuConstants.LOGO_POSITION);
Image foregroundImage = foregroundImageIcon.getImage();
Image scaledForegroundImage = foregroundImage.getScaledInstance(120, 120, Image.SCALE_SMOOTH);
JLabel foregroundLabel = new JLabel(new ImageIcon(scaledForegroundImage));
foregroundLabel.setBounds(200, 0, 200, 200);
// 设置字体和颜色
Font font = new Font("Arial", Font.BOLD, 30);
Color foreColor = new Color(255, 255, 255, 199);
Color backColor1 = new Color(210, 235, 247, 255);
Color backColor2 = new Color(113, 195, 235, 255);
Color backColor3 = new Color(24, 122, 170, 255);
// 创建按钮并设置字体和颜色
JButton jButton1 = new JButton("Easy");
jButton1.setFont(font);
jButton1.setForeground(foreColor);
jButton1.setBackground(backColor1);
jButton1.setBounds(150, 200, 300, 60);
JButton jButton2 = new JButton("Intermediate");
jButton2.setFont(font);
jButton2.setForeground(foreColor);
jButton2.setBackground(backColor2);
jButton2.setBounds(150, 300, 300, 60);
JButton jButton3 = new JButton("Difficult");
jButton3.setFont(font);
jButton3.setForeground(foreColor);
jButton3.setBackground(backColor3);
jButton3.setBounds(150, 400, 300, 60);
// 将背景标签和按钮添加到JLayeredPane中,分别放置在不同的层级
layeredPane.add(background, JLayeredPane.DEFAULT_LAYER); // 背景放置在默认层级
layeredPane.add(foregroundLabel, JLayeredPane.PALETTE_LAYER); // 前景放置在高层级
layeredPane.add(jButton1, JLayeredPane.PALETTE_LAYER); // 按钮1放置在高层级
layeredPane.add(jButton2, JLayeredPane.PALETTE_LAYER); // 按钮2放置在高层级
layeredPane.add(jButton3, JLayeredPane.PALETTE_LAYER); // 按钮3放置在高层级
// 创建一个动作监听器
ActionListener buttonListener = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 获取触发动作的源对象
Object source = e.getSource();
if (source == jButton1) {
new SudokuMain(SudokuConstants.EASY);
dispose();
} else if (source == jButton2) {
new SudokuMain(SudokuConstants.INTER);
dispose();
} else if (source == jButton3) {
new SudokuMain(SudokuConstants.DIFFICULT);
dispose();
}
}
};
jButton1.addActionListener(buttonListener);
jButton2.addActionListener(buttonListener);
jButton3.addActionListener(buttonListener);
// 将JLayeredPane设置为内容面板
setContentPane(layeredPane);
}
这个方法将Image装入JLable,存入前景和背景图片,设置宽高,再将JLable和JButton放入JLayeredPane,再为三个按钮加入动作监听,当用户按下按钮后选择不同的难度。
/**
* initJFrame()方法:用于初始化JFrame
*/
private void initJFrame() {
// 设置宽高
setSize(600,600);
// 设置页面置顶
setAlwaysOnTop(true);
// 设置居中
setLocationRelativeTo(null);
//JFrame.EXIT_ON_CLOSE表示在关闭窗口时终止程序的运行。
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
//设置窗口的标题为"Sudoku"
setTitle("Sudoku");
//取消默认的居中放置
setLayout(null);
}
initJFrame()方法比较简单,类似SudokuMain类,定义了JFrame基本信息。
SudokuMenu、EndJFrame、AccountJFrame、HelpJFrame都是继承自JFrame类,非常类似,分别生成菜单界面、游戏胜利的面板、作者二维码面板、帮助面板。这里列出实现最困难的EndJFrame和HelpJFrame。
EndJFrame类:
/**
* EndJFrame类:胜利时的结算动画.
* 可以放音乐,停止音乐,放动画
*/
class EndJFrame extends JFrame {
private Clip clip;
public EndJFrame() {
// 设置页面置顶
setAlwaysOnTop(true);
// 设置页面宽高
setSize(300, 300);
// 设置居中
setLocationRelativeTo(null);
//JFrame.EXIT_ON_CLOSE表示在关闭窗口时终止程序的运行。
setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
//设置窗口的标题
setTitle("Successful!");
// 创建一个标签
JLabel label = new JLabel();
// 加载动图
ImageIcon imageIcon = new ImageIcon(SudokuConstants.SUCCESSFUL_1_POSITION);
// 将动图设置为标签的图标
label.setIcon(imageIcon);
// 添加标签到窗口中
add(label);
pack(); // 自动调整窗口大小以适应动图的尺寸
//哈哈哈哈
playMusic();
// 添加窗口关闭事件监听器
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
stopMusic();
}
});
setVisible(true);
}
private void playMusic() {
try {
// 加载音乐文件
File musicFile = new File(SudokuConstants.MUSIC_POSITION);
AudioInputStream audioStream = AudioSystem.getAudioInputStream(musicFile);
// 创建音频剪辑
clip = AudioSystem.getClip();
clip.open(audioStream);
// 播放音乐
clip.start();
} catch (LineUnavailableException | UnsupportedAudioFileException | IOException e) {
e.printStackTrace();
}
}
private void stopMusic() {
if (clip != null && clip.isRunning()) {
clip.stop();
}
}
}
加入动图和加入图片实现方法一样,这里只介绍如何实现播放声音。
定义方法playMusic()和stopMusic(),当游戏胜利时会调用这个类构造方法打开endJFrame,在构造函数中调用playMusic()方法,当关闭页面时调用stopMusic()自动关闭声音。
playMusic 方法:这个方法用来播放音乐。首先尝试打开音频文件,然后创建一个音频输入流audioStream。接着使用 AudioSystem.getClip() 创建一个音频剪辑 clip,并打开音频输入流以供剪辑使用。最后播放音频。
stopMusic 方法:这个方法用来停止正在播放的音乐。它检查 clip 是否正在运行,如果是,就停止播放。
HelpJFrame类:
/**
* HelpJFrame类:帮助,用于给用户提供游戏说明.
*/
class HelpJFrame extends JFrame {
public HelpJFrame() {
setAlwaysOnTop(true);
setSize(600, 600);
setLocationRelativeTo(null);
setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
setTitle("Help!");
// 创建背景图片的标签
JLabel backgroundLabel = new JLabel();
ImageIcon backgroundImage = new ImageIcon(SudokuConstants.HELP_BACKGROUND_POSITION);
backgroundLabel.setIcon(backgroundImage);
// 创建文本区域
JTextPane helpTextPane = new JTextPane();
helpTextPane.setFont(new Font("Arial", Font.BOLD, 16));
helpTextPane.setForeground(Color.BLACK);
// 设置文本区域透明背景
helpTextPane.setOpaque(false);
helpTextPane.setEditable(false);
StyledDocument doc = helpTextPane.getStyledDocument();
// 创建样式1,设置第一行的样式
Style style1 = doc.addStyle("Style1", null);
StyleConstants.setForeground(style1, Color.BLACK);
// 创建样式2,设置第二行的样式
Style style2 = doc.addStyle("Style2", null);
StyleConstants.setForeground(style2, Color.BLACK);
try {
// 在StyledDocument中插入文本并应用样式
doc.insertString(doc.getLength(), " 数独是源自 18 世纪瑞士的一种数学游戏,是一种运用纸、笔进行演算的逻辑游戏。" +
"玩家需要根据 9×9 盘面上的已知数字,推理出所有剩余空格的数字,并满足每一行、每一列、每一个粗线" +
"宫(3*3)内的数字均含 1-9,不重复。\n" +
" 数独盘面是个九宫,每一宫又分为九个小格。在这八十一格中给出一定的已知数字和解题条" +
"件,利用逻辑和推理,在其他的空格上填入 1-9 的数字。使 1-9 每个数字在每一行、每一列和每一" +
"宫中都只出现一次,所以又称“九宫格”。\n\n\n", style1);
doc.insertString(doc.getLength(), "Sudoku is a mathematical game originated from Switzerland in the 18th century. It is a logic game that uses paper and pen to calculate. Players need" +
"based on the known numbers on the 9×9 disk, the numbers of all remaining Spaces are reasoned out and satisfy each row, each column, and each thick line.\n\n" +
"The numbers in the palace (3*3) contain 1-9 and do not repeat" +
"The Sudoku plate has nine houses, and each house is divided into nine small grids. Give certain known numbers and solution bars in these eighty-one grids.\n\n" +
"Use logic and reasoning to fill in the numbers 1-9 in the other Spaces. Make 1-9 each number in each row, each column, and each" +
"palace appears only once, so it is also called \"nine palaces\".", style2);
} catch (BadLocationException e) {
e.printStackTrace();
}
// 设置布局为绝对定位,使得文本区域可以放置在背景图片上的指定位置
backgroundLabel.setLayout(null);
// 设置文本区域的位置和大小
helpTextPane.setBounds(50, 50, 500, 500);
// 将文本区域添加到背景图片标签上
backgroundLabel.add(helpTextPane);
// 将背景图片标签设置为内容面板
setContentPane(backgroundLabel);
setVisible(true);
}
}
需要注意的是,如果想添加背景,需要把设置文本区域透明背景。可以设置不同大小颜色的字体,但是感觉都黑色比较美观。
Puzzle类:改变不大,仅仅调用生成数独的方法。
package sudoku;
/**
* 定义Puzzle类型,用来表达和一局游戏相关的信息。
*/
public class Puzzle {
//numbers数组存储一局游戏的数字分布
int[][] numbers = new int[SudokuConstants.GRID_SIZE][SudokuConstants.GRID_SIZE];
// isGiven数组用来存储一局游戏单元格的数字是否暴露的状态
boolean[][] isGiven = new boolean[SudokuConstants.GRID_SIZE][SudokuConstants.GRID_SIZE];
// 根据设定需要猜测的单元个数新生成一局独数
// 可以利用猜测的单元个数的多少做为游戏难度级别的设定依据
// 这个方法需要对numbers数组和isGiven数组进行更新
public void newPuzzle(int cellsToGuess) {
// hardcodedNumbers是预先设定的一局游戏的数字分布
// 生成每次都不同的完整数独
SudokuGenerator generator = new SudokuGenerator();
int[][] hardcodedNumbers = generator.generateSudoku();
//将数字分布放到numbers数组中
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
numbers[row][col] = hardcodedNumbers[row][col];
}
}
//将完整数独挖空
int[][] temp = generator.generateUncompletedSudoku(hardcodedNumbers,cellsToGuess);
boolean[][] hardcodedIsGiven = new boolean[SudokuConstants.GRID_SIZE][SudokuConstants.GRID_SIZE];
for (int i = 0; i < hardcodedIsGiven.length; i++) {
for (int j = 0; j < hardcodedIsGiven[0].length; j++) {
if(temp[i][j] == 0){
hardcodedIsGiven[i][j] = false;
}else{
hardcodedIsGiven[i][j] = true;
}
}
}
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
isGiven[row][col] = hardcodedIsGiven[row][col];
}
}
}
}
CellInputListener类:完成了todo1和2:
package sudoku;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
/**
* 创建所有单元格都可以使用的一个监听器类型
*/
public class CellInputListener implements ActionListener {
private GameBoardPanel gameboard;
public CellInputListener(GameBoardPanel gameboard){
if (gameboard == null)
throw new IllegalArgumentException("Null pointer reference.");
this.gameboard = gameboard;
}
@Override
public void actionPerformed(ActionEvent e) {
// 获得是哪个单元格出发了回车事件(获得事件源)
Cell sourceCell = (Cell)e.getSource();
// 获得输入的数字
try{
int numberIn = Integer.parseInt(sourceCell.getText());
/*
* 检查发生回车敲击事件的单元格中存储的数字和用户输入的数字是否相等
* 根据上面的判断结论更新单元格的状态为CellStatus.CORRECT_GUESS或者CellStatus.WRONG_GUESS
* 一旦单元格的属性值(状态就是它的属性值之一)被更新,那么就应该调用sourceCell.paint(),触发重新绘制单元格的外观
*/
if(sourceCell.status != CellStatus.GIVEN){
if(numberIn == sourceCell.number){
sourceCell.status = CellStatus.CORRECT_GUESS;
sourceCell.paint();
}else{
sourceCell.status = CellStatus.WRONG_GUESS;
sourceCell.paint();
}
}
}catch(NumberFormatException e1){
sourceCell.status = CellStatus.WRONG_GUESS;
sourceCell.paint();
}
/*
* 一个单元格的状态变化了,那么就应该调用GameBoardPanel中的isSolved方法,用来判断游戏是否结束
* 如果游戏成功解决,那么就可以弹出对话框显示结果。
*/
if(gameboard.isSolved()){
GameTimer.stop();
new EndJFrame();
}
/*
* 每一次敲击空格,都要调用numberOfCorrect()重新计算剩余空格数
* 调用updateRemainingCells(remainingCells)更新面板上显示的
*/
int remainingCells = gameboard.numberOfCorrect();
SudokuMain.updateRemainingCells(remainingCells);
}
}
每一次敲击空格,都需要对非给定的cell进行判断并修改状态涂色;调用GameBoardPanel中的isSolved方法,用来判断游戏是否结束,如果游戏成功解决,那么就可以弹出对话框显示结果;每一次敲击空格,都要调用numberOfCorrect()重新计算剩余空格数调用updateRemainingCells()更新面板上显示的剩余空格数量。
Cell类:仅仅改变了四种状态下格子的颜色,这里不在列出。
定义了方法addBoard为了给每个宫加上蓝色边框:
private void addBoard(int row, int col) {
cells[row][col] = new Cell(row, col);
Border centernBorder = BorderFactory.createMatteBorder(1, 1, 1, 1, new Color(25, 32, 238, 161));
Border rightAndBottomBorder = BorderFactory.createMatteBorder(1, 1, 4, 4, new Color(25, 32, 238, 161));
Border bottomBorder = BorderFactory.createMatteBorder(1, 1, 4, 1, new Color(25, 32, 238, 161));
Border rightBorder = BorderFactory.createMatteBorder(1, 1, 1, 4, new Color(25, 32, 238, 161));
cells[row][col] = new Cell(row, col);
if (row == 2 && col == 2 || row == 2 && col == 5 || row == 5 && col == 2 || row == 5 && col == 5)
cells[row][col].setBorder(rightAndBottomBorder);
else if (col == 2 || col == 5) {
cells[row][col].setBorder(rightBorder);
} else if (row == 2 || row == 5) {
cells[row][col].setBorder(bottomBorder);
} else {
cells[row][col].setBorder(centernBorder);
}
}
SudokuConstants:增加了难度控制的常量、图片和音乐的位置。
package sudoku;
/**
* 为当前应用中很多类需要使用的常量完成定义
* 将逻辑上相关联的一组类所需要的常量定义在一个类中是可以借鉴的,这样易于维护。
*/
public class SudokuConstants {
/** 定义游戏面板的大小,针对当前游戏是指每一行的单元格数 */
public static final int GRID_SIZE = 9;
/** 定义游戏挖去空格的大致数量 */
public static final int EASY = 40;
public static final int INTER = 60;
public static final int DIFFICULT = 80;
public static final int TEST = 1;
/** 定义游戏需要的图片和音乐的位置 */
public static final String LOGO_POSITION= "su\\Picture\\logo.jpg";
public static final String HELP_BACKGROUND_POSITION= "su\\Picture\\helpBackground.jpg";
public static final String MENU_BACKGROUND_POSITION= "su\\Picture\\menuBackground.jpg";
public static final String QR_CODE_POSITION= "su\\Picture\\QRCode.jpg";
public static final String SUCCESSFUL_1_POSITION= "su\\Picture\\successful.gif";
public static final String SUCCESSFUL_2_POSITION= " ";
public static final String MUSIC_POSITION= "su\\Picture\\music.wav";
public static final String TEST_POSITION= " ";
}
GameTimer类:GameTimer类通过使用计时器对象和ActionListener来实现每秒更新一次游戏进行时间的功能。它可以在游戏开始时启动计时器,并在计时器触发时更新显示时间的文本框,以实时显示游戏进行的时间。
package sudoku;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.text.DecimalFormat;
/**
* GameTimer类:用于在文本框中每一秒更新一次游戏进行的实践
*/
public class GameTimer {
private static Timer timer;
private static long startTime;
private final JTextField timerField;
private final DecimalFormat decimalFormat;
public GameTimer(JTextField timerField) {
this.timerField = timerField;
decimalFormat = new DecimalFormat("00");
// 创建计时器对象,设置计时器的间隔为 1000 毫秒(即每秒触发一次)
timer = new Timer(1000, new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 计算已经经过的时间
long elapsedTime = System.currentTimeMillis() - startTime;
// 更新显示时间的文本框
updateTimerField(elapsedTime);
}
});
}
public static void start() {
// 记录开始时间
startTime = System.currentTimeMillis();
// 启动计时器
timer.start();
}
public static void stop() {
// 停止计时器
timer.stop();
}
private void updateTimerField(long elapsedTime) {
// 将经过的时间转换为时分秒的格式
long seconds = (elapsedTime / 1000) % 60;
long minutes = (elapsedTime / (1000 * 60)) % 60;
long hours = (elapsedTime / (1000 * 60 * 60)) % 24;
// 格式化时间为字符串
String timeStr = decimalFormat.format(hours) + ":" +
decimalFormat.format(minutes) + ":" +
decimalFormat.format(seconds);
// 在文本框中显示时间
timerField.setText(timeStr);
}
}
在构造函数中,创建了一个计时器对象timer,并设置其触发间隔为1000毫秒(即每秒触发一次)。
计时器对象timer通过一个ActionListener来监听时间更新事件。每当计时器触发时,会计算已经经过的时间elapsedTime,并调用updateTimerField方法来更新显示时间的文本框。
start方法在游戏开始时被调用,记录当前时间为游戏开始时间,并启动计时器对象,使其开始触发时间更新事件。
stop方法用于停止计时器,调用timer的stop方法实现。
updateTimerField方法根据传入的经过时间elapsedTime,计算出小时、分钟、秒钟,并使用DecimalFormat对象对其进行格式化为时分秒的字符串,然后将该字符串设置为timerField的文本,实现时间的显示更新。
六、运行结果展示
这里只进行图片展示,文件夹中有程序运行的录屏演示视频文件。
游戏运行菜单界面:
游戏主界面:
简单难度:
中等难度:
困难难度:
帮助界面:
作者微信:
七、总结和收获
有很大的收获,首先是对java的GUI编程第一次实践,了解了JMenu、JMenuBar、JMenuItem、JFrame、JTextFeild等多种多样的类;也实践了AcctionListener动作监听;学习了如何插入音乐、图片等等等。
收获最大的其实是第一次做一个未完成的小项目,然后根据需求把它开发完全,也实践了类之间的关系,现在我对类的封装有了更深的体会,但这次较少使用继承和多态,下次多加使用。
感觉UML类图真的很有用,能展示类之间的关系,这次画了好几个小时。
附录:
package sudoku;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.File;
import java.io.IOException;
import javax.sound.sampled.*;
import javax.swing.*;
import javax.swing.text.BadLocationException;
import javax.swing.text.Style;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
/**
* 这个文件包含SudoluMain类、GameProgressBar类、HelpJFrame类、EndJFrame类、AccountJFrame类.
* SudokuMain类:Sudoku游戏的游戏主面板.
* GameProgressBar类:进度条,包括时间和剩余空格数.
* HelpJFrame类:帮助,用于给用户提供游戏说明.
* EndJFrame类:胜利时的结算动画.
* AccountJFrame类:提供作者的微信二维码.
*/
public class SudokuMain extends JFrame {
/**
* 当前版本号
*/
private static final long serialVersionUID = 1L;
private static final GameBoardPanel board = new GameBoardPanel();
private static final GameProgressBar progressBar_1 = new GameProgressBar();
private static final GameProgressBar progressBar_2 = new GameProgressBar();
public SudokuMain(int cellsToGuess) {
// 将board面板添加到内容面板的中央区域
getContentPane().setLayout(new BorderLayout());
getContentPane().add(board, BorderLayout.CENTER);
//初始化菜单
initJMenu();
// 初始化一局游戏面板
board.newGame(cellsToGuess);
//初始化状态栏
getContentPane().add(progressBar_1, BorderLayout.BEFORE_FIRST_LINE);
progressBar_1.setPreferredSize(new Dimension(0,80));
progressBar_1.setText(" 剩余单元格数: "+ board.numberOfCorrect());
getContentPane().add(progressBar_2, BorderLayout.AFTER_LINE_ENDS);
progressBar_2.setPreferredSize(new Dimension(120,0));
// 创建 GameTimer 对象,并传入 JTextField 实例
GameTimer gameTimer = new GameTimer(progressBar_2);
// 启动计时器
GameTimer.start();
// 设置页面置顶
setAlwaysOnTop(true);
//根据内容自动调整窗口的大小,以适应组件的尺寸。
pack();
//设置窗口居中显示
setLocationRelativeTo(null);
//JFrame.EXIT_ON_CLOSE表示在关闭窗口时终止程序的运行。
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
//设置窗口的标题为"Sudoku"
setTitle("Sudoku");
//将窗口设置为可见状态,以便显示在屏幕上。
setVisible(true);
}
/**
* 这个方法用于更新剩余单元格数量,在每次敲击回车时调用
*/
public static void updateRemainingCells(int remainingCells){
progressBar_1.setText(" 剩余单元格数: "+ remainingCells);
}
/**
* 初始化菜单
*/
private void initJMenu() {
JMenuBar jMenuBar = new JMenuBar();
//创建Jmenu
JMenu functionJMenu = new JMenu("function");
JMenu aboutJMenu = new JMenu("about");
JMenu difficultyMenu = new JMenu("new game");
//创建JMenuItem对象
JMenuItem exitItem = new JMenuItem("exit");
JMenuItem helpItem = new JMenuItem("help");
JMenuItem accountItem = new JMenuItem("QR-code");
JMenuItem easyItem = new JMenuItem("easy");
JMenuItem intermediateItem = new JMenuItem("intermediate");
JMenuItem difficultItem = new JMenuItem("difficult");
JMenuItem resetItem = new JMenuItem("reset game");
//添加选项
difficultyMenu.add(easyItem);
difficultyMenu.add(intermediateItem);
difficultyMenu.add(difficultItem);
functionJMenu.add(difficultyMenu);
functionJMenu.add(resetItem);
functionJMenu.add(exitItem);
aboutJMenu.add(helpItem);
aboutJMenu.add(accountItem);
jMenuBar.add(functionJMenu);
jMenuBar.add(aboutJMenu);
setJMenuBar(jMenuBar);
// 创建一个动作监听器
ActionListener buttonListener = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 获取触发动作的源对象
Object source = e.getSource();
if (source == easyItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.EASY);
dispose();
} else if (source == intermediateItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.INTER);
dispose();
} else if (source == difficultItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.DIFFICULT);
dispose();
} else if (source == exitItem) {
dispose();
} else if (source == helpItem) {
new HelpJFrame();
} else if (source == accountItem) {
new AccountJFrame();
} else if (source == resetItem) {
GameTimer.stop();
new SudokuMain(SudokuConstants.INTER);
dispose();
}
}
};
//增加动作监听
easyItem.addActionListener(buttonListener);
intermediateItem.addActionListener(buttonListener);
difficultItem.addActionListener(buttonListener);
exitItem.addActionListener(buttonListener);
helpItem.addActionListener(buttonListener);
accountItem.addActionListener(buttonListener);
resetItem.addActionListener(buttonListener);
}
}
/**
* HelpJFrame类:帮助,用于给用户提供游戏说明.
*/
class HelpJFrame extends JFrame {
public HelpJFrame() {
setAlwaysOnTop(true);
setSize(600, 600);
setLocationRelativeTo(null);
setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
setTitle("Help!");
// 创建背景图片的标签
JLabel backgroundLabel = new JLabel();
ImageIcon backgroundImage = new ImageIcon(SudokuConstants.HELP_BACKGROUND_POSITION);
backgroundLabel.setIcon(backgroundImage);
// 创建文本区域
JTextPane helpTextPane = new JTextPane();
helpTextPane.setFont(new Font("Arial", Font.BOLD, 16));
helpTextPane.setForeground(Color.BLACK);
// 设置文本区域透明背景
helpTextPane.setOpaque(false);
helpTextPane.setEditable(false);
StyledDocument doc = helpTextPane.getStyledDocument();
// 创建样式1,设置第一行的样式
Style style1 = doc.addStyle("Style1", null);
StyleConstants.setForeground(style1, Color.BLACK);
// 创建样式2,设置第二行的样式
Style style2 = doc.addStyle("Style2", null);
StyleConstants.setForeground(style2, Color.BLACK);
try {
// 在StyledDocument中插入文本并应用样式
doc.insertString(doc.getLength(), " 数独是源自 18 世纪瑞士的一种数学游戏,是一种运用纸、笔进行演算的逻辑游戏。" +
"玩家需要根据 9×9 盘面上的已知数字,推理出所有剩余空格的数字,并满足每一行、每一列、每一个粗线" +
"宫(3*3)内的数字均含 1-9,不重复。\n" +
" 数独盘面是个九宫,每一宫又分为九个小格。在这八十一格中给出一定的已知数字和解题条" +
"件,利用逻辑和推理,在其他的空格上填入 1-9 的数字。使 1-9 每个数字在每一行、每一列和每一" +
"宫中都只出现一次,所以又称“九宫格”。\n\n\n", style1);
doc.insertString(doc.getLength(), "Sudoku is a mathematical game originated from Switzerland in the 18th century. It is a logic game that uses paper and pen to calculate. Players need" +
"based on the known numbers on the 9×9 disk, the numbers of all remaining Spaces are reasoned out and satisfy each row, each column, and each thick line.\n\n" +
"The numbers in the palace (3*3) contain 1-9 and do not repeat" +
"The Sudoku plate has nine houses, and each house is divided into nine small grids. Give certain known numbers and solution bars in these eighty-one grids.\n\n" +
"Use logic and reasoning to fill in the numbers 1-9 in the other Spaces. Make 1-9 each number in each row, each column, and each" +
"palace appears only once, so it is also called \"nine palaces\".", style2);
} catch (BadLocationException e) {
e.printStackTrace();
}
// 设置布局为绝对定位,使得文本区域可以放置在背景图片上的指定位置
backgroundLabel.setLayout(null);
// 设置文本区域的位置和大小
helpTextPane.setBounds(50, 50, 500, 500);
// 将文本区域添加到背景图片标签上
backgroundLabel.add(helpTextPane);
// 将背景图片标签设置为内容面板
setContentPane(backgroundLabel);
setVisible(true);
}
}
/**
* AccountJFrame类:提供作者的微信二维码.
*/
class AccountJFrame extends JFrame {
public AccountJFrame() {
// 设置页面置顶
setAlwaysOnTop(true);
// 设置页面宽高
setSize(300, 300);
// 设置居中
setLocationRelativeTo(null);
//JFrame.EXIT_ON_CLOSE表示在关闭窗口时终止程序的运行。
setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
//设置窗口的标题
setTitle("account");
// 创建一个标签
JLabel label = new JLabel();
// 加载照片
ImageIcon image = new ImageIcon(SudokuConstants.QR_CODE_POSITION);
// 将照片设置为标签的图标
label.setIcon(image);
// 将标签添加到窗口中
add(label);
// 设置窗口大小自适应图片大小
pack();
//将窗口设置为可见状态,以便显示在屏幕上。
setVisible(true);
}
}
/**
* GameProgressBar类:进度条,包括时间和剩余空格数.
* 但是很多操作需要在SudokuMain中写
*/
class GameProgressBar extends JTextField {
public GameProgressBar() {
setFont(new Font("宋体", Font.PLAIN, 28));
setEditable(false);
setForeground(Color.BLUE);
}
}
/**
* EndJFrame类:胜利时的结算动画.
* 可以放音乐,停止音乐,放动画
*/
class EndJFrame extends JFrame {
private Clip clip;
public EndJFrame() {
// 设置页面置顶
setAlwaysOnTop(true);
// 设置页面宽高
setSize(300, 300);
// 设置居中
setLocationRelativeTo(null);
//JFrame.EXIT_ON_CLOSE表示在关闭窗口时终止程序的运行。
setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
//设置窗口的标题
setTitle("Successful!");
// 创建一个标签
JLabel label = new JLabel();
// 加载动图
ImageIcon imageIcon = new ImageIcon(SudokuConstants.SUCCESSFUL_1_POSITION);
// 将动图设置为标签的图标
label.setIcon(imageIcon);
// 添加标签到窗口中
add(label);
pack(); // 自动调整窗口大小以适应动图的尺寸
//哈哈哈哈
playMusic();
// 添加窗口关闭事件监听器
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
stopMusic();
}
});
setVisible(true);
}
private void playMusic() {
try {
// 加载音乐文件
File musicFile = new File(SudokuConstants.MUSIC_POSITION);
AudioInputStream audioStream = AudioSystem.getAudioInputStream(musicFile);
// 创建音频剪辑
clip = AudioSystem.getClip();
clip.open(audioStream);
// 播放音乐
clip.start();
} catch (LineUnavailableException | UnsupportedAudioFileException | IOException e) {
e.printStackTrace();
}
}
private void stopMusic() {
if (clip != null && clip.isRunning()) {
clip.stop();
}
}
}
package sudoku;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
/**
* SudokuMenu类:Sudoku游戏的菜单,可以供用户选择游戏难度.
*/
public class SudokuMenu extends JFrame {
private static final long serialVersionUID = 1L;
/**
* 构造函数,被APP直接调用
*/
public SudokuMenu() {
//初始化界面
initJFrame();
//初始化菜单
initMenu();
//将窗口设置为可见状态,以便显示在屏幕上。
setVisible(true);
}
/**
* initMenu()方法:用于初始化菜单
*/
private void initMenu() {
// 创建JLayeredPane作为容器
JLayeredPane layeredPane = new JLayeredPane();
layeredPane.setBounds(0, 0, getWidth(), getHeight());
// 创建一个背景标签,并将图片设置为标签的图标
ImageIcon backIcon = new ImageIcon(SudokuConstants.MENU_BACKGROUND_POSITION);
Image image = backIcon.getImage();
Image scaledImage = image.getScaledInstance(getWidth(), getHeight(), Image.SCALE_SMOOTH);
JLabel background = new JLabel(new ImageIcon(scaledImage));
background.setBounds(0, 0, getWidth(), getHeight());
// 加载前景图片,和标签
ImageIcon foregroundImageIcon = new ImageIcon(SudokuConstants.LOGO_POSITION);
Image foregroundImage = foregroundImageIcon.getImage();
Image scaledForegroundImage = foregroundImage.getScaledInstance(120, 120, Image.SCALE_SMOOTH);
JLabel foregroundLabel = new JLabel(new ImageIcon(scaledForegroundImage));
foregroundLabel.setBounds(200, 0, 200, 200);
// 设置字体和颜色
Font font = new Font("Arial", Font.BOLD, 30);
Color foreColor = new Color(255, 255, 255, 199);
Color backColor1 = new Color(210, 235, 247, 255);
Color backColor2 = new Color(113, 195, 235, 255);
Color backColor3 = new Color(24, 122, 170, 255);
// 创建按钮并设置字体和颜色
JButton jButton1 = new JButton("Easy");
jButton1.setFont(font);
jButton1.setForeground(foreColor);
jButton1.setBackground(backColor1);
jButton1.setBounds(150, 200, 300, 60);
JButton jButton2 = new JButton("Intermediate");
jButton2.setFont(font);
jButton2.setForeground(foreColor);
jButton2.setBackground(backColor2);
jButton2.setBounds(150, 300, 300, 60);
JButton jButton3 = new JButton("Difficult");
jButton3.setFont(font);
jButton3.setForeground(foreColor);
jButton3.setBackground(backColor3);
jButton3.setBounds(150, 400, 300, 60);
// 将背景标签和按钮添加到JLayeredPane中,分别放置在不同的层级
layeredPane.add(background, JLayeredPane.DEFAULT_LAYER); // 背景放置在默认层级
layeredPane.add(foregroundLabel, JLayeredPane.PALETTE_LAYER); // 前景放置在高层级
layeredPane.add(jButton1, JLayeredPane.PALETTE_LAYER); // 按钮1放置在高层级
layeredPane.add(jButton2, JLayeredPane.PALETTE_LAYER); // 按钮2放置在高层级
layeredPane.add(jButton3, JLayeredPane.PALETTE_LAYER); // 按钮3放置在高层级
// 创建一个动作监听器
ActionListener buttonListener = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 获取触发动作的源对象
Object source = e.getSource();
if (source == jButton1) {
new SudokuMain(SudokuConstants.EASY);
dispose();
} else if (source == jButton2) {
new SudokuMain(SudokuConstants.INTER);
dispose();
} else if (source == jButton3) {
new SudokuMain(SudokuConstants.DIFFICULT);
dispose();
}
}
};
jButton1.addActionListener(buttonListener);
jButton2.addActionListener(buttonListener);
jButton3.addActionListener(buttonListener);
// 将JLayeredPane设置为内容面板
setContentPane(layeredPane);
}
/**
* initJFrame()方法:用于初始化JFrame
*/
private void initJFrame() {
// 设置宽高
setSize(600,600);
// 设置页面置顶
setAlwaysOnTop(true);
// 设置居中
setLocationRelativeTo(null);
//JFrame.EXIT_ON_CLOSE表示在关闭窗口时终止程序的运行。
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
//设置窗口的标题为"Sudoku"
setTitle("Sudoku");
//取消默认的居中放置
setLayout(null);
}
}
package sudoku;
import java.util.Random;
/**
* generateSudoku()方法创建一个完整的数独.
* generateUncompletedSudoku(int[][] board, int numberOfSpaces)方法:根据空格数挖空.
*/
public class SudokuGenerator {
private final int[][] board;
private final Random random;
public SudokuGenerator() {
board = new int[9][9];
random = new Random();
}
public int[][] generateSudoku() {
fillDiagonal(); // 填充对角线宫
solveSudoku(board);
return board;
}
private void fillDiagonal() {
for (int i = 0; i < 9; i = i + 3) {
fillBox(i, i);
}
}
private void fillBox(int row, int col) {
int num;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
do {
num = random.nextInt(9) + 1;
} while (!isValid(board ,row, col, num));
board[row + i][col + j] = num;
}
}
}
//将完整的数独挖空
public int[][] generateUncompletedSudoku(int[][] board, int numberOfSpaces) {
int[][] uncompletedSudoku = copyBoard(board);
for (int i = 0; i < numberOfSpaces; i++) {
int row = random.nextInt(9);
int col = random.nextInt(9);
int temp = uncompletedSudoku[row][col];
uncompletedSudoku[row][col] = 0;
if (!ifOnlySolution(uncompletedSudoku)) {
uncompletedSudoku[row][col] = temp;
}
}
return uncompletedSudoku;
}
/**
* 当仅当能求解出唯一一个数独时,返回true
* 若发现有多个数独,则返回false
*/
private static boolean ifOnlySolution(int[][] board) {
int[][] copyboard = copyBoard(board); // 创建一个副本
int solutionsCount = 0; // 记录解的数量
for (int row = 0; row < copyboard.length; row++) {
for (int col = 0; col < copyboard[0].length; col++) {
if (copyboard[row][col] == 0) {
for (int num = 1; num <= 9; num++) {
if (isValid(copyboard, row, col, num)) {
copyboard[row][col] = num;
if (ifOnlySolution(copyboard)) {
solutionsCount++; // 增加解的数量
if (solutionsCount > 1) {
return false; // 如果找到多个解,直接返回 false
}
}
copyboard[row][col] = 0; // 回溯
}
}
return solutionsCount == 1; // 返回是否找到唯一解
}
}
}
return true;
}
private static int[][] copyBoard(int[][] board) {
int[][] copy = new int[9][9];
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
copy[i][j] = board[i][j];
}
}
return copy;
}
/** 求解数独的方法。
* 当数独成功填满并且满足数独的规则时,返回true。这表示我们已经找到了一个有效的解。
* 当无法继续填充数独时,返回false。这种情况通常发生在回溯的过程中,当某个格子无法填入任何数字时,需要回溯到上一个选择点。
*/
private boolean solveSudoku(int[][] board) {
for (int row = 0; row < board.length; row++) {
for (int col = 0; col < board[0].length; col++) {
if (board[row][col] == 0) { // 使用0表示空格
for (int num = 1; num <= 9; num++) {
if (isValid(board, row, col, num)) {
board[row][col] = num;
if (solveSudoku(board)) {
return true;
} else {
board[row][col] = 0; // 回溯
}
}
}
return false;
}
}
}
return true;
}
private static boolean isValid(int[][] board, int row, int col, int num) {
for (int i = 0; i < 9; i++) {
if (board[i][col] == num) {
return false; // 检查列是否有重复数字
}
if (board[row][i] == num) {
return false; // 检查行是否有重复数字
}
if (board[3 * (row / 3) + i / 3][3 * (col / 3) + i % 3] == num) {
return false; // 检查3x3宫格是否有重复数字
}
}
return true;
}
}
package sudoku;
/**
* 定义Puzzle类型,用来表达和一局游戏相关的信息。
*/
public class Puzzle {
//numbers数组存储一局游戏的数字分布
int[][] numbers = new int[SudokuConstants.GRID_SIZE][SudokuConstants.GRID_SIZE];
// isGiven数组用来存储一局游戏单元格的数字是否暴露的状态
boolean[][] isGiven = new boolean[SudokuConstants.GRID_SIZE][SudokuConstants.GRID_SIZE];
// 根据设定需要猜测的单元个数新生成一局独数
// 可以利用猜测的单元个数的多少做为游戏难度级别的设定依据
// 这个方法需要对numbers数组和isGiven数组进行更新
public void newPuzzle(int cellsToGuess) {
// hardcodedNumbers是预先设定的一局游戏的数字分布
// 生成每次都不同的完整数独
SudokuGenerator generator = new SudokuGenerator();
int[][] hardcodedNumbers = generator.generateSudoku();
//将数字分布放到numbers数组中
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
numbers[row][col] = hardcodedNumbers[row][col];
}
}
//将完整数独挖空
int[][] temp = generator.generateUncompletedSudoku(hardcodedNumbers,cellsToGuess);
boolean[][] hardcodedIsGiven = new boolean[SudokuConstants.GRID_SIZE][SudokuConstants.GRID_SIZE];
for (int i = 0; i < hardcodedIsGiven.length; i++) {
for (int j = 0; j < hardcodedIsGiven[0].length; j++) {
if(temp[i][j] == 0){
hardcodedIsGiven[i][j] = false;
}else{
hardcodedIsGiven[i][j] = true;
}
}
}
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
isGiven[row][col] = hardcodedIsGiven[row][col];
}
}
}
}
package sudoku;
/**
* 为当前应用中很多类需要使用的常量完成定义
* 将逻辑上相关联的一组类所需要的常量定义在一个类中是可以借鉴的,这样易于维护。
*/
public class SudokuConstants {
/** 定义游戏面板的大小,针对当前游戏是指每一行的单元格数 */
public static final int GRID_SIZE = 9;
/** 定义游戏挖去空格的大致数量 */
public static final int EASY = 40;
public static final int INTER = 60;
public static final int DIFFICULT = 80;
public static final int TEST = 1;
/** 定义游戏需要的图片和音乐的位置 */
public static final String LOGO_POSITION= "su\\Picture\\logo.jpg";
public static final String HELP_BACKGROUND_POSITION= "su\\Picture\\helpBackground.jpg";
public static final String MENU_BACKGROUND_POSITION= "su\\Picture\\menuBackground.jpg";
public static final String QR_CODE_POSITION= "su\\Picture\\QRCode.jpg";
public static final String SUCCESSFUL_1_POSITION= "su\\Picture\\successful.gif";
public static final String SUCCESSFUL_2_POSITION= " ";
public static final String MUSIC_POSITION= "su\\Picture\\music.wav";
public static final String TEST_POSITION= " ";
}
package sudoku;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.text.DecimalFormat;
/**
* GameTimer类:用于在文本框中每一秒更新一次游戏进行的实践
*/
public class GameTimer {
private static Timer timer;
private static long startTime;
private final JTextField timerField;
private final DecimalFormat decimalFormat;
public GameTimer(JTextField timerField) {
this.timerField = timerField;
decimalFormat = new DecimalFormat("00");
// 创建计时器对象,设置计时器的间隔为 1000 毫秒(即每秒触发一次)
timer = new Timer(1000, new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 计算已经经过的时间
long elapsedTime = System.currentTimeMillis() - startTime;
// 更新显示时间的文本框
updateTimerField(elapsedTime);
}
});
}
public static void start() {
// 记录开始时间
startTime = System.currentTimeMillis();
// 启动计时器
timer.start();
}
public static void stop() {
// 停止计时器
timer.stop();
}
private void updateTimerField(long elapsedTime) {
// 将经过的时间转换为时分秒的格式
long seconds = (elapsedTime / 1000) % 60;
long minutes = (elapsedTime / (1000 * 60)) % 60;
long hours = (elapsedTime / (1000 * 60 * 60)) % 24;
// 格式化时间为字符串
String timeStr = decimalFormat.format(hours) + ":" +
decimalFormat.format(minutes) + ":" +
decimalFormat.format(seconds);
// 在文本框中显示时间
timerField.setText(timeStr);
}
}
package sudoku;
import java.awt.*;
import javax.swing.*;
import javax.swing.border.Border;
/**
* 创建一个JPanel的子类类型,该面板中包含了9*9个Cell对象,采用了GridLayout布局管理器
* 需要用到cell,每一个格子
* 需要用到puzzle,表示数独
* 需要用到监听器,为每一个格子绑定
*/
public class GameBoardPanel extends JPanel {
private static final long serialVersionUID = 1L;
// 以像素单位为每一个Cell单元格设置外观大小
public static final int CELL_SIZE = 60;
public static final int BOARD_WIDTH = CELL_SIZE * SudokuConstants.GRID_SIZE;
public static final int BOARD_HEIGHT = CELL_SIZE * SudokuConstants.GRID_SIZE;
/** 该游戏面板是由9x9个Cell对象 (Cell是JTextFields的子类)构成 */
private Cell[][] cells = new Cell[SudokuConstants.GRID_SIZE][SudokuConstants.GRID_SIZE];
private Puzzle puzzle = new Puzzle();
public GameBoardPanel() {
super.setLayout(new GridLayout(SudokuConstants.GRID_SIZE, SudokuConstants.GRID_SIZE));
// 将Cell对象组件加入到Panle对象中
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
addBoard(row, col);
super.add(cells[row][col]);
}
}
// 为所有可编辑的单元格(即需要输入数字)绑定监听器对象
for (int row = 0; row < SudokuConstants.GRID_SIZE; row++) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; col++) {
cells[row][col].addActionListener(new CellInputListener(this));
}
}
//设置游戏面板的首选尺寸
super.setPreferredSize(new Dimension(BOARD_WIDTH, BOARD_HEIGHT));
}
/**
* 生成一局新游戏,基于生成的新游戏中的数据重新初始化gameboard中包含的每一个cell对象
*/
public void newGame(int cellsToGuess) {
puzzle.newPuzzle(cellsToGuess);
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
cells[row][col].newGame(puzzle.numbers[row][col], puzzle.isGiven[row][col]);
}
}
}
/**
* 如果当局游戏成功解决,那么返回true
* 判断依据:只要有一个单元格的状态是CellStatus.TO_GUESS或者CellStatus.WRONG_GUESS,那么游戏就没有结束。
*/
public boolean isSolved() {
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
if (cells[row][col].status == CellStatus.TO_GUESS || cells[row][col].status == CellStatus.WRONG_GUESS) {
return false;
}
}
}
return true;
}
/**
* 判断一下有多少格子还没归位
*/
public int numberOfCorrect(){
int temp = 0;
for (int row = 0; row < SudokuConstants.GRID_SIZE; ++row) {
for (int col = 0; col < SudokuConstants.GRID_SIZE; ++col) {
if (cells[row][col].status == CellStatus.TO_GUESS || cells[row][col].status == CellStatus.WRONG_GUESS) {
temp++;
}
}
}
return temp;
}
private void addBoard(int row, int col) {
cells[row][col] = new Cell(row, col);
Border centernBorder = BorderFactory.createMatteBorder(1, 1, 1, 1, new Color(25, 32, 238, 161));
Border rightAndBottomBorder = BorderFactory.createMatteBorder(1, 1, 4, 4, new Color(25, 32, 238, 161));
Border bottomBorder = BorderFactory.createMatteBorder(1, 1, 4, 1, new Color(25, 32, 238, 161));
Border rightBorder = BorderFactory.createMatteBorder(1, 1, 1, 4, new Color(25, 32, 238, 161));
cells[row][col] = new Cell(row, col);
if (row == 2 && col == 2 || row == 2 && col == 5 || row == 5 && col == 2 || row == 5 && col == 5)
cells[row][col].setBorder(rightAndBottomBorder);
else if (col == 2 || col == 5) {
cells[row][col].setBorder(rightBorder);
} else if (row == 2 || row == 5) {
cells[row][col].setBorder(bottomBorder);
} else {
cells[row][col].setBorder(centernBorder);
}
}
}
package sudoku;
/**
* 单元格在游戏中会有不同的状态。这些状态可以使用:
* 1.整型变量表示(比如1、2),但是这样表示状态可读性不高;
* 2.字符串值表示(比如“correct”等),这样表示状态则在比较时需要考虑字符串的大小写等问题。
* 在类似的场景中,建议定义枚举类型完成此类工作,枚举类型的可读性高,且是类型安全。
*/
public enum CellStatus {
GIVEN, // 具有此状态的单元格表示其包含的数字是预先给定的;
TO_GUESS, // 具有此状态的单元格是不包含任何数字,需要游戏玩家进行填充的;
CORRECT_GUESS, // 具有此状态的单元格表明游戏玩家正确填充了其应该包含的数字;
WRONG_GUESS // 具有此状态的单元格表明游戏玩家错误填充了其应该包含的数字。
// 一场游戏是否成功结束的判断条件就是没有任何一个单元格的状态是TO_GUESS或者WRONG_GUESS
}
package sudoku;
import java.awt.Color;
import java.awt.Font;
import javax.swing.JTextField;
/**
* Cell类型是用来表示游戏中的每一个单元格,它是Java API中JTextField类型的子类,之所以没有直接使用JTextField类型,就是要在其上进行扩展
* Cell需要能够输入数字(所以采用JTextField类型作为其父类类型)
* Cell还需要有其特有的业务数据(比如其在整个游戏面板中的位置,还有其状态)
*/
public class Cell extends JTextField {
private static final long serialVersionUID = 1L;
// 预先定义好在单元格不同状态下的颜色常量
public static final Color BG_GIVEN = new Color(255, 254, 254); // RGB
public static final Color FG_GIVEN = Color.BLACK;
public static final Color FG_NOT_GIVEN = Color.WHITE;
public static final Color BG_TO_GUESS = new Color(208, 205, 205);
public static final Color BG_CORRECT_GUESS = new Color(94, 213, 94);
public static final Color BG_WRONG_GUESS = new Color(205, 88, 88);
public static final Font FONT_NUMBERS = new Font("Arial", Font.PLAIN, 34);
/** 该单元格在游戏面板中的位置 */
int row, col;
/** 该单元格中应该的数字*/
int number;
/** 该单元格的当前状态 */
CellStatus status;
public Cell(int row, int col) {
this.row = row;
this.col = col;
// 调用JTextField中的方法,用来设置对齐属性以及字体
super.setHorizontalAlignment(JTextField.CENTER);
super.setFont(FONT_NUMBERS);
}
/** 当重新一局游戏时,通过这个方法设定单元格中正确的数字和初始状态 */
public void newGame(int number, boolean isGiven) {
this.number = number;
status = isGiven ? CellStatus.GIVEN : CellStatus.TO_GUESS;
paint(); // 因为数字和状态变化了,所以需要重新绘制自己的外观
}
/** 单元格的外观取决于状态 */
public void paint() {
// 以下方法的调用都使用了super,其实是没有必要的,主要是让同学们了解这些方法都是JTextField类中定义的方法
if (status == CellStatus.GIVEN) {
super.setText(number + "");
super.setEditable(false);
super.setBackground(BG_GIVEN);
super.setForeground(FG_GIVEN);
} else if (status == CellStatus.TO_GUESS) {
super.setText("");
super.setEditable(true);
super.setBackground(BG_TO_GUESS);
super.setForeground(FG_NOT_GIVEN);
} else if (status == CellStatus.CORRECT_GUESS) {
super.setBackground(BG_CORRECT_GUESS);
} else if (status == CellStatus.WRONG_GUESS) {
super.setBackground(BG_WRONG_GUESS);
}
}
}
package sudoku;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
/**
* 创建所有单元格都可以使用的一个监听器类型
*/
public class CellInputListener implements ActionListener {
private GameBoardPanel gameboard;
public CellInputListener(GameBoardPanel gameboard){
if (gameboard == null)
throw new IllegalArgumentException("Null pointer reference.");
this.gameboard = gameboard;
}
@Override
public void actionPerformed(ActionEvent e) {
// 获得是哪个单元格出发了回车事件(获得事件源)
Cell sourceCell = (Cell)e.getSource();
// 获得输入的数字
try{
int numberIn = Integer.parseInt(sourceCell.getText());
/*
* 检查发生回车敲击事件的单元格中存储的数字和用户输入的数字是否相等
* 根据上面的判断结论更新单元格的状态为CellStatus.CORRECT_GUESS或者CellStatus.WRONG_GUESS
* 一旦单元格的属性值(状态就是它的属性值之一)被更新,那么就应该调用sourceCell.paint(),触发重新绘制单元格的外观
*/
if(sourceCell.status != CellStatus.GIVEN){
if(numberIn == sourceCell.number){
sourceCell.status = CellStatus.CORRECT_GUESS;
sourceCell.paint();
}else{
sourceCell.status = CellStatus.WRONG_GUESS;
sourceCell.paint();
}
}
}catch(NumberFormatException e1){
sourceCell.status = CellStatus.WRONG_GUESS;
sourceCell.paint();
}
/*
* 一个单元格的状态变化了,那么就应该调用GameBoardPanel中的isSolved方法,用来判断游戏是否结束
* 如果游戏成功解决,那么就可以弹出对话框显示结果。
*/
if(gameboard.isSolved()){
GameTimer.stop();
new EndJFrame();
}
/*
* 每一次敲击空格,都要调用numberOfCorrect()重新计算剩余空格数
* 调用updateRemainingCells(remainingCells)更新面板上显示的
*/
int remainingCells = gameboard.numberOfCorrect();
SudokuMain.updateRemainingCells(remainingCells);
}
}
package sudoku;
import javax.swing.*;
/**
* 运行Sudoku游戏的程序
*/
public class APP {
public static void main(String[] args) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
SudokuMenu sudokuMenu = new SudokuMenu();
}
});
}
}