疯狂Java讲义课后习题解析


第 1 章 控制台五子棋 第 1 章 控制台五子棋 1.1 引言 控制台五子棋,顾名思义,就是在 Java 控制台运行的五子棋游戏,需要用户用键盘输入棋子的位 置来进行游戏。 由于是在控制台下面运行的程序,所以并没有漂亮的游戏界面,与及鼠标操作等东西,只是在一片 黑色控制台环境下进行游戏,游戏的可玩性并不高,似乎这并不是一个完整的游戏。虽然如此,但事实 上,一个程序最重要的并不是界面,而是处理各种业务逻辑与数据的方法,只要掌握了核心的方法,掌 握基础的知识,便更容易学习 awt,swing 等图形用户界面的编写,万变不离其宗,写起有操作界面的程 序也会变得更加容易,更加随心应手。而本章的主要目的让读者掌握与理解 Java 编程的基础知识,因 此,掌握本章五子棋的实现原理,对于学习以后的章节将会非常有帮助。作为本书的第一章内容,我们 在本章中将使用最简单的方式来实现一个控制台五子棋游戏。 1.1.1 五子棋介绍 五子棋是起源于中国古代的传统黑白棋种之一。现代五子棋日文称之为“连珠” ,英译为”Renju”, 英文称之为”Gobang”或”FIR”(Five in a Row 的缩写 ) ,亦有“连五子”、“五子连” 、“串珠”、“五 目”、 “五目碰”、“五格”等多种称谓。五子棋游戏是一个比较大众的棋类游戏,大多数人都会玩这个 游戏,五子棋的玩法与规则如下:  五子棋是两个人之间进行的竞技活动,由于对黑方白方规则不同,黑棋必须先行(本章节设计 的游戏,黑棋与白棋的规则一样,但一样由黑棋先下)。  五子棋专用盘为 15×15 ,五连子的方向为横、竖、斜。  在棋盘上以对局双方均不可能形成五连为和棋。  首先形成五连子的一方为赢。 五子棋必须由双方进行游戏,当某一方按照一定规则连成五个棋子的时候,该游戏方就胜利,在本 章中,我们并不需要做到对战形式的,我们可以设计一个简单的“电脑”来做我们的对手,当我们下完 棋后,这个简单的“电脑”就随便在棋盘中下一个棋,当然,如果想做更强大的“电脑”我们可以编写 程序来实现,当我们下棋的时候,这个“电脑”就对我们所下的棋子进行检测,并将棋子下到最恰当的 位置。本章主要目的是展现五子棋的实现原理,如果读者有兴趣,可以自行开发强大的“人工智能电脑” 来进行游戏。 1.1.2 输入输出约定 玩家必须以(x,y)的方式输入棋盘的坐标,其中,x 代表棋坐标,y 代表竖坐标。x 与 y 的值必须 是 1 到 N(棋盘的大小)的正数。 系统询问玩家是否继续游戏时,玩家输入 y 是代表继续,输入其它则代表退出游戏。 “●”代表黑子,“○”代表白子。当玩家以(x,y)的形式输入下棋的坐标后,游戏中就可以根据 玩家所下的坐标,再去将棋子放置到棋盘中。我们可以将棋盘看作一个二维数组,填充着棋盘式的标志 (“十”),玩家下棋后,将棋子替换原来的标志,最后再执行输入。由于本章是在控制台中进行打印, 因此只需要使用 System.out.println 来进行打印即可,如果需要实现有界面的五子棋游戏,例如使用 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·2· swing 或者 awt,可以使用相应的方法,将二维数组“画”到界面中。因此,不管是使用 swing、awt 或者其他界面技术,五子棋的实现原理几乎大同小异。 1.2 了解游戏流程描述 在开发五子棋之前,我们先了解一下游戏的整个游戏流程,了解游戏的流程,有助于我们在开发的 过程中可以清晰的掌握程序结构,对于实现功能有莫大的帮助,五子棋的具体流程如图 1.1 所示。 图 1.1 五子棋游戏流程 1.2.1 玩家输入坐标 游戏开始,系统在控制台中打印出棋盘,玩家根据这个棋盘,选定下棋的位置坐标后,在控制台中 输入相应的坐标,系统读取玩家所输入的坐标并进行相应的分析,如果玩家所下的棋使得玩家游戏胜利, 则系统询问是否继续游戏。 系统读取了玩家输入的坐标后,除了判断游戏是否胜利外,还需要判断玩家输入的坐标中是否已经 存在了相应的棋子,如果存在的话,需要再次提示玩家,重新输入。 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·3· 1.2.2 “电脑”下棋 玩家输入了坐标,系统判断玩家没有游戏胜利后,就应该轮到“电脑”下棋,在本章的开头中我们 已经讲到,本章可以实现一个简单的电脑来进行游戏,只需要随便的产生棋盘坐标,就可以让“电脑” 在相应的坐标中下棋。如果电脑随机产生的坐标中已经存在棋子,那么我们可以重新随机产生坐标,直 到产生的坐标上没有存在棋子为止。当“电脑”下完棋后,就可以使用同样的判断方式(判断是否五子 相连)来判断“电脑”所下的棋子是否已经使得游戏胜利,如果游戏胜利,同样地去提示玩家,电脑已 经胜利了。 在本章我们并不需要实现强大的人工智能“电脑”,只需要简单的随机产生坐标即可。 1.3 创建游戏的各个对象 这里设计三个类来完成游戏的功能,棋盘类(Chessboard),游戏类(GobangGame)与棋子类 (Chessman)(枚举类),类的关系如图 1.2 所示,从图中可以看出,Chessboard 依赖于 GobangGame, gobangGame 的改变,会影响到 Chessboard 状态的改变,而 Chessman 与 GobangGame 是一个聚合 关系。下面一一介绍。 图 1.2 五子棋类图 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·4· 1.3.1 Chessboard类 要进行五子棋游戏,必须有有一个棋盘,而这个类主要控制棋盘的初始化,输出与及增加新的棋子。 这个类包含以下方法:  void initBoard(),这个方法用于初始化棋盘,开始新的游戏时,应该调用此方法,初始化出一 个新的空棋盘。  void printBoard(),此方法用于在控制台输出棋盘,各方每一完一颗棋子后,由于棋盘上棋子的 状态有改变,所以必须调用此方法重新输入棋盘。  void setBoard( int posX , int posY , String chessman ),posX 与 posY 是新下棋子的 x 与 y 坐 标,,chessman 是新下棋子的类型(黑子与白子),每下完一颗棋子后,通过调用此方法把棋 子设置到棋盘上。  String[][] getBoard(),返回棋盘,返回类型是保存棋盘的二维数组。 当我们需要初始化棋盘的时候,可以直接调用 Chessboard 的 initBoard 方法,我们需要考虑该方 法需要实现的功能:初始化棋盘。由于我们将棋盘看作是一个二维数组,因此 initBoard 就需要帮我们 去创建一个二维数组,创建二维数组可以使用以下代码。 代码清单:code\gobang\src\org\crazyit\gobang\Chessboard.java Object[][] array = new Object[size][size]; for (int i = 0; i < array.length; i++) { for (int j = 0; j < array[i].length; j++) { array[i][j] = new Object(); } } 以上代码创建一个固定大小(一维与二维大小)的二维数组,再通过嵌套循环为数组中的每一个元 素进行赋值。在游戏中如果我们进行了下棋的操作,可以直接改变这个数组的某一个元素值。在创建 Chessboard 类时,我们就需要发挥面向对象的思维,在我们的程序中,所有看到的或者想的事物,我 们都可以将其抽象成具体的某个对象,并赋予一定的属性与行为。在设计对象的过程中,如果有某些事 物拿捏不准,不知如何设计属性或者行为,可以将其设计成接口或者抽象类。 Chessboard 中提供了一个 printBoard 的方法用于打印棋盘,在本章中,我们就需要将棋盘数组打 印到控制台中,因此该方法可以简单的调用 System.out.print 去打印相关的字符串。需要注意的是,由 于 printBoard 方法是没有参数的,因此我们需要为 Chessboard 提供一个二维数组变量,当调用 printBoard 方法的时候,将对象内的二维数组打印,我们可以将 Chessboard 看作一个有状态的 Java 对象,有状态的 Java 对象可以理解成一个 Java 对象保存一些与该对象相关的状态属性,如果该对象没 有保存与该对象相关的状态属性,那么我们可以将这个对象看成一个无状态的 Java 对象。 当外部调用 Chessboard 的 setBoard 方法时,就可以将某个值设置到 Chessboard 中的二维数组里, 告诉 Chessboard 玩家或者“电脑”在某个位置下了怎样的棋子。 1.3.2 Chessman类 Chessman 类是一个枚举类,此类是构造器私有的,不能直接创建,里面有 BLACK 与 WHITE 两 个静态属性,代表黑子与白子枚举类,两个表态属性都是 Chessman 类型的,要获取棋子,则通过这两 个属性调用以下的方法获取棋子:  String getChessman(),返回 String 类型的棋子实例,“●”或者“○”。 如果我们需要得到棋子的字符串(“●”或者“○”),可以使用以下的代码。 代码清单:code\gobang\src\org\crazyit\gobang\Chessman.java Chessman.BLACK.getChessman(); Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·5· 1.3.3 GobangGame类 GobangGame 类是进行游戏的类,Chessboard 依赖于此类,此类控制游戏的开始,重玩与结束, 并影响 Chessboard 类。主要包含以下构造器与方法:  GobangGame(),默认无参数构造器。  GobangGame( Chessboard chessboard ),有参数构造器,以一个 Chessboard 实例去初始化 这个类。  boolean isValid( String inputStr ),此方法验证控制台的输入字符串是否合法,如果合法,返回 true,如果不合法,则返回 false,此方法抛出 Exception 异常。  void start(),开始游戏。此方法抛出 Exception 异常。  boolean isReplay( String chessman ),是否重新开始游戏,如果是,返回 true,否则返回 false, 参数 chessman 代表黑子或白子。  int[] computerDo(),计算机随机下棋,由计算机自动设置棋盘,并返回包含新下棋子位置 x 与 y 坐标的 int[]数组类型。  boolean isWon( int posX , int posY , String ico ),判断输赢,参数 posX 与 posY 代表新下棋子 的 x 与 y 坐标,ico 代表新下的棋子类型,如果赢了,返回 true,否则返回 false。 GobangGame 是我们五子棋游戏的主体类,游戏里面所有的处理都在该类中实现。GobangGame 中的 isValid 方法用于验证控制台的输入,玩家主要在控制台输入下棋的坐标,下棋的坐标的字符串形 式为:x,y,我们需要对字符串进行处理得到x和y的值,如果玩家输入的字符串不符合系统要求,则isValid 方法返回 false,只有当该方法返回 true 的时候,才会去修改 Chessboard 的二维数组。 GobangGame 中提供了一个 start 方法,用于游戏的开始,我们需要考虑游戏开始的行为,例如需 要初始化棋盘(调用 Chessboard 的 init 方法),需要开始从控制台读取玩家的输入信息、打印棋盘,验 证控制台输入的信息等,这些功能我们将在下面的章节中加以描述。 当轮到“电脑”下棋的时候,我们需要随机生成电脑的下棋坐标,GobangGame 中的 computerDo 方法用于随机产生坐标。 判断一局游戏是否胜利,可以调用 GobangGame 的 isWon 方法,该方法判断游戏是否胜利,是五 子棋中最主要的方法,五子棋是否可以相连的所有逻辑,都会在该方法中实现。isWon 方法会在每次下 棋后(玩家下棋或者“电脑”下棋)调用。 到此,游戏中的三个对象已经设计完成,这三个对象中已经定义好了各种方法,并在前面章节中详 细描述了各个方法的作用,在下面章节中我们将开始对这三个对象所定义的方法进行实现。当然,如果 需要做到更好的程序解耦,我们可以使用一些设计模式,例如将游戏规则写成一个具体的算法,可以使 用策略模式,如果需要产生出不同的棋子(将控制台换成其他界面),可以编写棋子工厂等。但是本章 主要目的是展现一个最简单的五子棋,因此本章中并不涉及任何具体的设计模式。 1.4 棋盘类实现 在此类中,主要是用一个 String[][]类型的二维数组 board 去保存棋盘,board [i][j]代表棋盘的某个 位置(i 代表 x 坐标,j 代表 y 坐标),如果此位置没有棋子,默认值为“十”,如果有棋子,board [i][j]的 值为“●”或者“○”。用一个不可改变的常量 BOARD_SIZE 来表示棋盘的大小,所以保存这个棋盘 的是一个 BOARD_SIZE*BOARD_SIZE 的二维数组。图 1.3 描述了为什么需要使用一个二维数组来代 表一个棋盘,如果把棋盘的一列当做一个数组,那么 N 列的棋盘就是一个二维数组,用数组能很好的存 储与表现这个棋盘。 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·6· 图 1.3 棋盘与数组的关系 1.4.1 初始化棋盘 在 1.3 节介绍过,此类主要是实现棋盘初始始化、输出、与更新,在这节便用代码一步一步地实现 各个功能。首先我们需要初始化棋盘的实现,看以下代码片段。 代码清单:code\gobang\src\org\crazyit\gobang\Chessboard.java public void initBoard() { //初始化棋盘数组 board = new String[BOARD_SIZE][BOARD_SIZE]; //把每个元素赋值为“十”,用于控制台输出棋盘 for( int i = 0 ; i < BOARD_SIZE ; i++ ) { for( int j = 0 ; j < BOARD_SIZE ; j++ ) { board[i][j] = "十"; } } } 上面代码中,BOARD_SIZE 是代表棋盘的大小,用一个 String[][]类型的二维数组来代表棋盘,创 建此数组后,通过迭代为为个数组元素的值赋为“十”来初始化棋盘。创建了棋盘数组后,如果需要定 位到棋盘的某个位置,只需要得到棋盘数组的一维值与二维值即可,例如处理玩家下棋动作的时候,可 以将数组中具体的某个“十”替换成具体的棋子字符串。 1.4.2 输出棋盘 输出棋盘,只需要 Chessboard 的 board 属性(二维数组)的每一个值,打印到控制台中。如果可 以做到更好的扩展性,我们可以在二维数组中存放棋子对象,而不是简单的字符串,那么存放在二维数 组中的每一个棋子对象,都可以实现某个棋子接口或者继承棋子的抽象类,这样可以更好的做到游戏的 扩展性。当然,我们在本章为了简单起见,只在该二维数组中存放需要打印的字符串,打印时只需要得 到具体的某个二维数组的元素,将其打印即可。 代码清单:code\gobang\src\org\crazyit\gobang\Chessboard.java public void printBoard() { //打印每个数组元素 for( int i = 0 ; i < BOARD_SIZE ; i++ ) { Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·7· for( int j = 0 ; j < BOARD_SIZE ; j++ ) { //打印后不换行 System.out.print( board[i][j] ); } //每打印完一行数组元素就换行一次 System.out.print("\n"); } } 棋盘的输出与棋盘的初始化相类似,都是要遍历保存棋盘的数组,只不过是每遍历到一个元素都要 输出来,注意到这里的输出方法用的是 System.out.print()而不是常用的 System.out.println(),这里因为 System.out.println()方法是输出后自动换行的,如果使用此方法,便达不到我们需要的效果,棋盘的输 出效果如图 1.4。 图 1.4 控制台五子棋的棋盘 打印出来的效果,就好像在控制台中出现了一个棋盘。 1.4.3 获取棋盘 在 Chessboard 中提供了一个 getBoard 的方法,用于返回本对象的棋盘二维数组,该方法一般在 游戏类 GobangGame 中调用,游戏类得到棋盘的二维数组,可以用于判断棋盘中的某一个位置是否有 棋子或者计算游戏是否胜利。 getBoard 方法只需要将本对象中的 board(二维数组)返回即可,代码如下。 代码清单:code\gobang\src\org\crazyit\gobang\Chessboard.java /** * 返回棋盘 * @return 返回棋盘 */ public String[][] getBoard() { return this.board; } 到此,棋盘类的几个方法都已经实现,该类的主要功能是创建棋盘、打印棋盘等,实现的过程中涉 及了一些 Java 语言的基本操作,例如嵌套循环、创建二维数组等。在下面的小节中,我们将去实现游 戏的核心部分。 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·8· 1.5 棋子枚举类实现 在某些情况下,一个类的属性是有限而且固定的(在某些情况下),例如本章中的棋子类,它只有 两个对象,黑棋和白棋。这种实例有限而且固定的类,在 Java 里面称为枚举类,枚举类的关健字用 enum 而不是 class,此类中有两个枚举属性 BLACK 和 WHITE,代表黑子与白子,代码实现如下: 代码清单:code\gobang\src\org\crazyit\gobang\Chessman.java public enum Chessman { BLACK("●"),WHITE("○"); private String chessman; /** * 私有构造器 */ private Chessman(String chessman) { this.chessman = chessman; } /** * @return 黑棋或者白棋 */ public String getChessman() { return this.chessman; } } 在上面的代码中,可以看到,枚举类是用 enum 关键字代替了 class 关键字,看到此枚举类的构造 器的权限修饰符是 private,也是表明此类是不可以通过外部创建的,只能在此类的内部创建,这是为了 保证此对象只有黑子与白子两种类型。黑体代码是列出枚举值,实际上就是调用私用构造器创建此对象, 等同以下代码: public static final Chessman BLACK = new Chessman(“●”); public static final Chessman WHITE = new Chessman(“○”); 由于 BLACK 与 WHITE 两个属性是静态的,所以要获取黑子或者白子,可以通过以下代码来获得: Chessman.BLACK.getChessman(); Chessman.WHITE.getChessman(); 在控制台中,我们可以使用这种方式来确定棋子的字符串,如果我们需要在 swing 或者其他界面中 展示一个棋子,可能需要为具体的某个棋子保存相应的棋子图片,在本章中,由于棋子只是普通的两个 字符串,因此可以直接写成枚举对象即可。 如果你希望你的程能有更好的扩展性,笔者建议可以根据情况建立棋子接口,并提供白棋与黑棋的 实现类,我们在棋盘二维数组中存放的只是某个接口,而不是具体的类,这样,提高了程序的可扩展性, 在本小节的开头提到:在某些情况下,一个类中的属性有限并且是固定的。但是在我们开发的实际情况 中(特别是做企业应用),随着业务的不断变化,类的不可变几乎是不可能的。举个例子,如果需要将 本章中的五子棋迁移到 swing 界面中,那么该棋子枚举类就不得不更改了。 虽然本章是为了做一个较为简单的五子棋,但更多的想向大家展现面向对象的思维。 1.6 游戏类实现 本章中的游戏类是 GogangGame,在该类中,主要控制游戏的开始,重新开始与结束,验证玩家 输入的合法性,判断游戏的输与赢,调用棋盘类来初始化棋盘,打印棋盘,使用棋子类去设置棋盘等。 此类中有四个属性,两个 int 类型的 posX 与 poxY,用来存储玩家现在输入的 x 与 y 坐标(x 和 y 坐标 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·9· 是指玩家输入的数字对应棋盘数组中的一维值与二维值),一个默认值为5的int类型常量WIN_COUNT, 游戏胜利需要连接的棋子达到的连子数目,由于是五子棋,因此只需要 5 个棋子相连,游戏就胜利。还 有一个 Chessboard 类型的变量 chessboard,用来表示棋盘,游戏中就只用到一个棋盘,该对象可以使 用初始化棋盘、打印棋盘、获得棋盘(数组)等方法。 1.6.1 使用BufferedReader获取键盘输入 BufferedReader 是 Java IO 流中的一个字符包装流,它必须建立在字符流的基础之上。该对象可以 从输入流中读取文本,但标准输入:System.in 是字节流,所以程序需要使用转换流 InputStreamReader 将其包装成字符流。所以程序中用于获取键盘的输入采用以下代码创建。 //获取键盘的输入 BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String inputStr = null; //br.readLine:每当键盘输入一行内容按回车键,则输入的内容被 br 读取到 while( (inputStr = br.readLine()) != null ) { /** * 处理键盘输入 */ } BufferedReader 中有一个 readLine()方法,此方法总是读取下一行的输入字符串,如果没有下一行, 则返回 null。当得到玩家输入的字符后,我们可以进这些字符进行验证,验证完后,如果字符串符合系 统要求,可以在验证处使用 continue 跳出本次循环。 如果需要读取输入,我们就需要为这些输入作出不同的判断,例如,玩家输入了 y(继续游戏),那 么我们就需要判断玩家输入了 y 后程序所需要执行哪些操作,因此,这样会为 while 循环体中增加许多 的 if 语句,这些 if 语句会影响程序的可读性,如果需要将这些 if 语句去掉,我们可以将每个 if 中的代码 抽取出来,作为具体的一个处理类。这样做不仅减少 while 循环体中的代码,而且可以使得程序更加清 晰,程序的耦合度更低,while 循环体中只负责读取玩家输入的字符串,而具体的处理则不必由该方法 来执行。由于本章中的代码与动作相对较少,因此并不涉及如何实现以上所说的处理模式,更深入的可 以查看“仿 QQ 游戏大厅”一章。 1.6.2 验证玩家输入字符串的合法性 根据引言中提到的输入约定,玩家在控制台输入的字符串必须是以(x,y)的方式输入,还需要验证输 入的字符串是否能转换为数字,是否超越棋盘的边界(小于等于 1,大于等于棋盘数组的长度),并且需 要判断该位置是否已经存在棋子,具体判断流程如图 1.5 所示。 图 1.5 验证流程 首先,x 与 y 必须是一个数字,由以下代码验证。 代码清单:code\gobang\src\org\crazyit\gobang\GobangGame.java Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·10· //将用户输入的字符串以逗号(,)作为分隔,分隔成两个字符串 String[] posStrArr = inputStr.split(","); try { posX = Integer.parseInt( posStrArr[0] ) - 1; posY = Integer.parseInt( posStrArr[1] ) - 1; } catch (NumberFormatException e) { chessboard.printBoard(); System.out.println("请以(数字,数字)的格式输入:"); return false; } 当我们调用 Integer.parseInt 方法将字符串转换成一个 Integer 类型的时候,如果需要转换的字符串 不能转换成某一个数字,该方法将会抛出 NumberFormatException 异常,我们可以使用 catch 将该异 常捕获,提示玩家需要重新输入棋子坐标。除了判断玩家输入的字符串是否符合我们游戏所要求的格式 外,还需要判断玩家输入的坐标范围,即该范围不可小于 1 并不可大于棋盘数组的最大值,例如棋盘是 10 乘 10,但玩家输入了 11,那么将会抛出 ArrayIndexOutOfBoundsException 异常,因此,x 与 y 的 范围只能是大于 1 与小于 N(棋盘的大小),如果超出这个范围,则需要提示玩家重新输入,由以下代 码验证。 代码清单:code\gobang\src\org\crazyit\gobang\GobangGame.java //检查输入数值是否在范围之内 if( posX < 0 || posX >= Chessboard.BOARD_SIZE || posY < 0 || posY >= Chessboard.BOARD_SIZE ) { chessboard.printBoard(); System.out.println( "X 与 Y 坐标只能大于等于 1,与小于等于" + Chessboard.BOARD_SIZE + ",请重新输入:" ); return false; } 验证了输入坐标的合法性后,还需要验证玩家输入的坐标中是否已经存在棋子,我们通过 Chessboard 对象中的 getBoard 方法可以得到棋盘的二维数组,根据玩家输入的坐标得到数组中的元 素,再判断元素是否已经是一个棋子(“●”或者“○”),如果该坐标中已经有棋子(元素值为“十”), 则提示玩家重新输入,由以下代码验证。 代码清单:code\gobang\src\org\crazyit\gobang\GobangGame.java //检查输入的位置是否已经有棋子 String[][] board = chessboard.getBoard(); if( board[posX][posY] != "十" ) { chessboard.printBoard(); System.out.println( "此位置已经有棋子,请重新输入:" ); return false; } 以上代码中,如果 board[i][j]不等于“十”(“十”是棋盘每个位置的默认值),则证明此位置有棋子, 需要提示玩家重新输入。这里需要注意的是,如果没有前一个判断(判断输入的坐标是否超过棋盘范围), 那么通过棋盘数组获取某个元素时,就会抛出 ArrayIndexOutOfBoundsException 异常。 1.6.3 判断输赢 判断游戏输赢,需要在玩家输入了坐标并通过了合法性验证后(输入的坐标),再执行输赢的验证, 同样地,如果是“电脑”随机生成的坐标,我们同样的需要进行输赢验证,因此,我们已经将判断输赢 的行为,独立成一个 isWon 方法(详细请看 1.3.3 中的 GobangGame 类)。 判断输赢在本章的程序中稍微复杂,有两种方法来判断输赢: Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·11·  每次下完一颗棋子,就通过程序从横、竖、斜各个方向扫描棋盘,如果在某个方向中,有同种 颜色的棋子达到五连子,则此颜色的玩家为赢。如果没有相同颜色的棋子达到五连子,则继续 游戏。该判断方法需要遍历整个棋盘,也就是意味着每次下棋后(玩家或者“电脑”)都需要 对棋盘进行遍历,这样对程序的性能会造成一定的影响。  每次下完一颗棋子,以该棋子为中心,扫描在此棋子所在范围内的横、竖、斜方向,验证加上 此棋子有没有形成五连子,如果形成五连子,则下棋子的玩家为赢。此方法与前面的方法比较, 因为不需要扫描整个棋盘,所以更加快速,本章程序使用的是此方法,该方法的原理如图 1.6 所示。 图 1.6 五连子 在图 1.6 中可以看出,(0,0),(0,3),(0,6),(3,0),(6,0),(3,7),(7,3),(7,7)这 些坐标都是此黑棋能形成五连子的最小或者最大位置的棋子,如果各个方向有足够的空间,就延伸到第 五颗棋子,如果没有,就只延伸到边界。所以,只要能计算出任意一颗棋子的这些位置,我们就可以判 断游戏的输赢,并且是以该棋子为中心向周围进行遍历。以下是判断输赢的代码实现。 代码清单:code\gobang\src\org\crazyit\gobang\GobangGame.java //直线起点的 X 坐标 int startX = 0; //直线起点 Y 坐标 int startY = 0; //直线结束 X 坐标 int endX = Chessboard.BOARD_SIZE - 1; //直线结束 Y 坐标 int endY = endX; //同条直线上相邻棋子累积数 int sameCount = 0; int temp = 0; //计算起点的最小 X 坐标与 Y 坐标 temp = posX - WIN_COUNT + 1; startX = temp < 0 ? 0 : temp; temp = posY - WIN_COUNT + 1; startY = temp < 0 ? 0 : temp; //计算终点的最大 X 坐标与 Y 坐标 temp = posX + WIN_COUNT - 1; Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·12· endX = temp > Chessboard.BOARD_SIZE - 1 ? Chessboard.BOARD_SIZE - 1 : temp; temp = posY + WIN_COUNT - 1; endY = temp > Chessboard.BOARD_SIZE - 1 ? Chessboard.BOARD_SIZE - 1 : temp; //从左到右方向计算相同相邻棋子的数目 String[][] board = chessboard.getBoard(); for( int i = startY; i < endY; i++) { if( board[posX][i] == ico && board[posX][i+1] == ico ) { sameCount++; } else if( sameCount != WIN_COUNT - 1 ) { sameCount = 0; } } 从上面代码中可以看到,首先是计算出在这颗棋子的直线上(横、竖、斜方向)能达到五连子的最小 x、y 坐标与最大 x、y 坐标,然后从最小 x、y 坐标访问到最大 x、y 坐标,如果此颜色棋子的相连累积 数目达到五连子,则为赢。以上代码只是实现横向遍历判断,竖向遍历与斜向遍历的判断方法与横向遍 历的实现基本类似。这里需要注意的是,当遇到一个可以相边的棋子,就需要为 sameCount 值加 1。 1.6.4 计算机随机下棋 我们在前面章节中说到,使用一个简单的方式来产生一个“电脑”与我们进行对战。我们只需要使 得到随便的坐标值,并且在该坐标中进行下棋操作,就可以实现“电脑”的下棋,因此,实现这个随机 下棋的功能,最主要是产生随机的坐标。我们可以使用 Math.random 方法来产生 0.0 到 1.0 之间的 double 数组,再使用该值来乘以棋盘的大小,即可产生随机的坐标。 我们使用这个方式来产生随机坐标,因为是随机生成的位置,所以这个计算机是比较“笨”的。如 果想让计算机变“聪明”起来,可以加上人工智能“电脑”,该人工智能“电脑”需要分析玩家的所有 下棋的位置,并对这些位置的坐标进行相应的分析。以下是随机生成坐标的代码实现。 代码清单:code\gobang\src\org\crazyit\gobang\GobangGame.java //随机生成 x 坐标,即二维数组具体一维的值 int posX = (int)(Math.random() * ( Chessboard.BOARD_SIZE - 1 ) ); //随机生成 y 坐标,即二维数组具体二维的值 int posY = (int)(Math.random() * ( Chessboard.BOARD_SIZE - 1 ) ); String[][] board = chessboard.getBoard(); //当棋盘中的位置不是“十”的时候(已经有棋子),则再次生成新的坐标 while( board[posX][posY] != "十" ) { posX = (int)(Math.random() * ( Chessboard.BOARD_SIZE - 1 ) ); posY = (int)(Math.random() * ( Chessboard.BOARD_SIZE - 1 )); } 这里需要注意的是,由于我们使用了 while 循环,其中循环条件是判断棋盘数组中是否已经存在棋 秀,如果已经存在棋子,则需要重新随机生成坐标。那么就会产生这样一种情况,如果整个棋盘中都存 在棋子的话,这个 while 将永远不会跳出,即死循环,所以我们需要判断棋盘中是否所有的位置都有棋 子,如果棋盘中已经都存在棋子并且没有输赢的话,就可以提示玩家和棋了,重新开始游戏。 上面代码中,随机生成 x 与 y 坐标的过程是先用 Math.random()方法获取一个在 BOARD_SIZE(棋 盘大小)范围之内的 x 与 y 正数坐标,如果这个坐标中已经有棋子,则继续使用 Math.radom()方法获取 坐标,直到这个坐标中没有棋子。 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·13· 1.6.5 是否重新游戏 实现是否重新开始游戏的功能,这在这方法中,程序的流程是:如果玩家或者电脑赢了,则在控制 台输出询问玩家是否重新开始游戏的信息,如果玩家输入”y”字符串,则重新开始游戏,否则直接退出整 个程序,实现代码如下。 代码清单:code\gobang\src\org\crazyit\gobang\GobangGame.java /** * 是否重新开始下棋。 * @param chessman "●"为用户,"○"为计算机。 * @return 开始返回 true,反则返回 false。 */ public boolean isReplay( String chessman ) throws Exception { chessboard.printBoard(); String message = chessman.equals(Chessman.BLACK.getChessman()) ? "恭喜您,您赢了," : "很遗憾,您输了,"; System.out.println( message + "再下一局?(y/n)" ); BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); if( br.readLine().equals("y") ) { //开始新一局 return true; } return false; } 1.6.6 游戏过程实现 以下是游戏的流程说明,具体也可以看图 1.2:  (1)首先调用 Chessboard 类型的 chessboard 属性中的 initBoard()与 printBoard()方法去初始 化与打印棋盘。  (2)从控制台获取用户的输入。  (3)再调用本类的 isValid()方法去验证玩家输入的合法性,如果输入不合法,返回第 2 步继续, 否则到第 4 步。  (4)把玩家下的棋子位置赋值为"●"。  (5)调用 isWon( int posX , int posY , String chessman )判断玩家是否赢了。如果玩家赢了, 则调用 isReply()方法输出的信息询问玩家是否重新游戏,如果玩家输入 y,则返回第 1 步重新 开始。  (6)调用 computerDo()方法随机生成计算机的 x,y 坐标,并把 board[x][y] 赋值为"○"。如果 计算机赢了,则调用 isReply()方法输出的信息询问玩家是否重新游戏,如果玩家输入 y,则返 回第 1 步重新开始,否则返回第 2 步轮到用户输入。 以下的代码实现以上的流程。 代码清单:code\gobang\src\org\crazyit\gobang\GobangGame.java //true 为游戏结束 boolean isOver = false; chessboard.initBoard(); chessboard.printBoard(); Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·14· //获取键盘的输入 BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String inputStr = null; //br.readLine:每当键盘输入一行内容按回车键,则输入的内容被 br 读取到 while( (inputStr = br.readLine()) != null ) { isOver = false; if( !isValid( inputStr ) ) { //如果不合法,要求重新输入,再继续 continue; } //把对应的数组元素赋为"●" String chessman = Chessman.BLACK.getChessman(); chessboard.setBoard( posX , posY , chessman ); //判断用户是否赢了 if( isWon( posX , posY , chessman ) ) { isOver = true; } else { //计算机随机选择位置坐标 int[] computerPosArr = computerDo(); chessman = Chessman.WHITE.getChessman(); chessboard.setBoard( computerPosArr[0] , computerPosArr[1] , chessman ); //判断计算机是否赢了 if( isWon( computerPosArr[0] , computerPosArr[1] , chessman ) ) { isOver = true; } } //如果产生胜者,询问用户是否继续游戏 if( isOver ) { //如果继续,重新初始化棋盘,继续游戏 if( isReplay( chessman ) ) { chessboard.initBoard(); chessboard.printBoard(); continue; } //如果不继续,退出程序 break; } chessboard.printBoard(); System.out.println("请输入您下棋的坐标,应以 x,y 的格式输入:"); } 以上的代码中,我们使用了一个 isOver 来标识游戏是否胜利,当游戏胜利时,就询问玩家是否继 续,我们可以看到,以上的代码中我们写了多个 if 语句,其实我们可以使用一些 Java 的基础知识来解 决这些 if 问题(可以使用设计模式中的策略模式),当然大家也可能觉得这些 if 没有什么关系,但是, 由于这些 if 的存在,会使得我们程序的可读性变差,在“仿 QQ 游戏大厅”章节,同样出现了读取字符 串关作相应判断的情况,我们在该章节使用了其他方式去解决这些 if 语句,详细可以看“仿 QQ 游戏大 厅”一章。本章的目的主要介绍一个简单五子棋的实现。 Download at http://www.pin5i.com/ 第 1 章 控制台五子棋 ·15· 1.7 本章小结 本章主要是介绍开发控制台五子棋的整个过程,体现流程设计与类设计的基本方法,示范了数组的 使用,获取用户键盘的输入。使用了分支结构与循环结构的流程控制,还介绍与使用了枚举类。向读者 灌输了面向对象的一些基本知识,通过这些基础的知识设计与开发出有趣味性的小游戏,让读者加深对 这些基础知识的理解。 Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 第 2 章 仿Windows计算器 2.1 仿Windows计算器概述 Windows 计算器,是 Windows 操作系统自带计算器,,可以帮助用户完成数据的运算,它可分为“标 准型”和“科学型”,本章的仿 Windows 计算器是标准型的 Java 实现,标准型 Windows 计算器实现的 主要功能有:四则运算;求倒数;求开方;存储计算结果;读取计算结果;累积计算结果。 我们在第一章中,我们实现了一个在控制台进行的五子棋游戏,我们从本章开始将在 Swing 界面中 实现本书的项目。在本章中,我们将使用到 JFrame 和 JPanel 两个 Swing 容器,使用到 JTextField 和 JButton 两个 Swing 容器,使用 BorderLayout 和 GridLayout 做两个布局器,以及使用到事件、事件监 听器和事件适配器等。 实现一个计算器,界面中需要提供各种输入的按钮,再以这些按钮组成计算器的键盘,用户点击键 盘输入值后,就可以将其所输入的值显示到一个文本框中,运算后,再将结果显示到文本框中。计算器 的最终效果如图 2.1 所示。 图 2.1 用 Swing 制作的计算器 从图 2.1 中可以看到,我们开发界面的时候,需要提供一个文本框在窗口的最上部,文本框下面再 提供各个计算器的按钮。 2.1.1 数学与其它符号介绍 在此计算器中,主要使用的数学运算有加、减、乘、除四则运算,或者对一个正数进行开方,或者 对一个非 0 的数学求倒数,使用到的数学符号有:  加、减、乘、除,对应使用的符号是“+”、“-”、“*”、“/”。  开方与倒数,对应使用的符号是“sqrt”和“1/x”。  求结果使用的数学符号是“=”。 Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·2·  “%”号,如果使用此符号,第二个操作数就等于两数相乘再除以 100。 除了用于数学运算的符号,Windows 计算器还提供对计算结果做存储、读取、累加、清除等操作, 亦有对数字显示框中的数字做退格操作,还可以清除上次计算结果或者全部结果:  使用符号“MC”、“MR”、“MS”、“M+”代表清除存储结果、读取存储结果、保存存储 结果和累加存储结果。  使用“Backspace”符号代表退格。  使用“CE”和“C”代表清除上次计算结果和清除所有计算结果。 四则运算在程序中可以直接使用 Java 运算符实现,实现开方可以调用 Math 类的 sqrt 方法,倒数 可以使用 1 来除以原始的数字。当用户需点击“=”的时候,计算器就需要将最终的计算结果显示到文 本框中。其他的计算器功能都可以通过计算器内部的程序实现,例如使用某个字符串或者数字来保存相 应的结果,如果需要计取、存储、累加或者清除结果,可以通过改变或者读取我们所保存的值来实现。 2.1.2 界面说明 界面中使用的 Swing 组件相对简单,整个大窗口可以看作一个 JFrame 对象,在 JFrame 对象中, 存放一个 JPanel 对象,我们需要为这个 JPanel 对象进行布局,将文本框(JTextField 对象)与各个计 算器按钮(JButton 对象)添加到这个 JPanel 中。在添加计算器按钮的时候,我们可以使用 GridLayout 布局处理器来进行网格状布局,由于各个计算器按钮都是以网格状分布在界面中的,因此使用 GridLayout 非常适合。本章计算器的界面布局并不复杂,因此在这里不再详细描述。 2.2 流程描述 用户打开计算器后,在没有关闭计算器之前,可以通过鼠标点击“1”到“9”数字键和点击“+”、 “-”、“*”、“/”键去输入要运算结果的算术式,再通过点击“=”、“sqrt”、“1/x”等键去直接获取计算 结果,除外,还可以点击“MC”、“MR”、“MS”、“M+”键去清除、读取、保存、累加计算显示框中显 示的数字,还有清除上次结果、清除所有结果、退格等操作。从图 2.2 中可以看出,计算器打开之后, 就开始监听用户的鼠标动作,如果输入是关于计算结果或者“MC”、“MR”、“MS”、“M+”、“Backspace”、 “CE”、“C”等操作指令,而且没有关闭计算器,就返回计算结果并显示,如果不是,则不计算结果。 接下来再继续等待用户的输入。 本章的计算器并没有复杂的流程,只需要简单的操作,返回计算结果等。在实现计算器的过程中, 我们需要注意的是,例如已经点击了某个数字,再点击运算符,那么程序需要记录之前选点击的数字, 当用户再次点击运算符(非“=”)时,系统就需要将结果显示到文本框中。因此在开发计算器的时候, 我们需要注意用户点击的具体顺序。 Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·3· 图 2.2 计算流程 2.3 建立计算器对象 实现一个计算器,我们需要建立一系列的对象来实现,例如,计算界面我们要建立一个界面类,还 需要建立一个专门负责处理加、减、乘、除的基本计算类,还需要一个负责处理计算功能的业务类。本 小节中只讲解创建这三个基本的类,如果在开发的过程发现可以将一些行为或者属性放置到一个新的对 象中,那么可以再建立这些对象来完成需要实现的功能或者操作。 本章主要设计四个类来完成计算器的功能,界面类(CalFrame)—主要用来显示计算器界面,功 能类(CalService)—主要用于完成计算器中的逻辑功能,计算工具类(MyMath)—此类是工具类, 用于处理大型数字的加减乘除,计算器类(Cal)—用于打开计算器,计算器中各个类的关系如图 2.3 所示, 从图中可以看出,我们的界面类继承了 java.swing.JFrame 类,计算器类使用了界面类,界面类使用了 功能类,功能类使用了 MyMath 工具类,下面章节将对这些计算器的相关类作详细介绍。 Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·4· 图 2.3 计算器类图 2.3.1 MyMath工具类 使用 float,double 两种浮点基本类型来进行计算,容易损失精度,所以,我们使用一个自己定义 了加,减,乘,除方法的类,此类使用 BigDecimal 来封装基本类型,在不损失精度的同时,也可以进 行超大数字的四则运算。为了方便调用,此类的方法全部都是静态方法,可以直接用“类名.方法名”调 用,这个类包含以下方法:  static double add( double num1, double num2 ),加法,使用来计算结果的数字是封装后的 num1 和 num2,并返回 double 类型。  static double subtract ( double num1, double num2 ),减法,使用来计算结果的数字是封装后 的 num1 和 num2,并返回 double 类型。  static double multiply ( double num1, double num2 ),乘法,使用来计算结果的数字是封装后 的 num1 和 num2,并返回 double 类型。  static double divide ( double num1, double num2 ),除法,使用来计算结果的数字是封装后的 num1 和 num2,并返回 double 类型。 MyMath 类提供了基础的四则运算方法,由于该类中所有的方法都是静态的,因此外界可以直接调 用。在实现 MyMath 的过程中需要注意的是,这几个四则运算方法,参数都是 double 类型的,要进行 运算的话,需要将 double 类型转换成一个 BigDecimal 对象,我们可以使用以下代码来创建一个 BigDecimal 对象: Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·5· new BigDecimal(String.valueOf(number)); 2.3.2 CalService类 CalService 类主要是用来处理计算器的业务逻辑,用户在操作计算器时,此类将计算结果,并且返 回,并且,会记录计算器的状态(用户的上一步操作)。包含以下方法:  String callMethod( String cmd , String text ),调用方法并返回计算结果。  String cal( String text , boolean isPercent ),用来计算加、减、乘、除法,并返回封装成 String 内型的结果。参数 text 是显示框中的数字内容,boolean 类型的参数 isPercent 代表是否有"%" 运算,如果有,便加上去。  String setReciprocal( String text ),用来计算倒数,并返回封装成 String 内型的结果。  String sqrt( String text ),用来计算开方,并返回封装成 String 内型的结果。  String setOp( String cmd , String text ),设置操作符号。  String setNegative( String text ),设置正负数,当 text 是正数时,返回负数的数字字符串,反 之,则返回正数的数字字符串。  String catNum( String cmd, String text ),连接输入的数字,每次点击数字,就把把新加的数字 追加到后面,并封装成字符串返回。  String backSpace( String text ),删除最后一个字符,并返回结果。  String mCmd( String cmd, String text ),用来实现“M+”、“MC”、“MR”、“MS”与存 储有关的功能。  String clearAll(),清除所有计算结果。  String clear( String text),清除上次计算结果。 CalService 类中的各个方法都是用于处理计算的逻辑,其中 callMethod 方法可以看作中一个中转 的方法,根据参数中的 cmd 值进行分发处理,例如调用该方法时将“CE”字符串作为 cmd,那么该方 法就根据这个字符串再调用需要执行“CE”的方法。如果需要做更好的程序解耦,我们可以将这些做 成一个状态模式,将各个计算的方法都抽象成一个计算接口,该接口提供一个计算的方法,然后按照具 体的情况,为该接口提供不同的实现,例如计算开方、计算倒数等实现,然后向 callMethod 传入不同 的实现类,直接调用接口方法。 2.3.3 CalFrame类 CalFrame 类继承 javax.swing.Jframe 类,主要是用于计算器界面的实现,此类中,排版了计算器 中各个组件的位置,为组件增加事件监听器,用来监听用户的操作,并做调用相应的方法,主要包含以 下方法:  void initialize(),初始化计算器界面。  ActionListener getActionListener(),如果动作监听器为空,则创建一个,并返回,如果不为空, 直接返回。  JTextField getTextField(),这个方法初始化输入框。  JButton[] getMButton(),此方法获得计算器的存储操作键。  JButton[] getRButton(),此方法获得计算器的结果操作键。  JButton[] getNButton(),此方法获得计算器的其它操作键。 由于 CalFrame 是界面类,因此所需要进行的业务处理并不多,更多的是监听用户的操作,并进行 分发处理。这就有点像 web 应用中的 MVC 模式中的 V(视图),并不处理任务的业务逻辑,主要职责 Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·6· 是显示相应的数据。在本章中,CalFrame 包括了一些监听器,监听界面事件并调用相关的业务方法, 在实际开发中,我们可以将这些监听器作为 MVC 模式中的 C(控制器)提取到另外的类中。 2.4 MyMath工具类实现 MyMath 是一个工具类,主要用于处理加、减、乘、除四则运算,我们已经确定了实现这四个方法 的时候,都使用 BigDecimal 对象进行计算。由于我们定义 MyMath 方法的时候,所有方法的参数都是 double 类刑的,因此我们可以提供一个工具方法来将 double 转换成 BigDecimal 类型。 以下代码根据一个 double 类型转换成一个 BigDecimal。 代码清单:code\cal\src\org\crazyit\cal\MyMath.java /** * 为一个 double 类型的数字创建 BigDecimal 对象 * @param number * @return */ private static BigDecimal getBigDecimal(double number) { return new BigDecimal(number); } 提供了这个工具方法后,我们可以在其他的计算方法中使用这个工具方法,选择将 double 的参数 转换成 BigDecimal 对象,然后再进行具体的运算。 2.4.1 实现四则运算 编写了 double 转换的工具方法后,实现加、减、乘、除比较简单,由于 BigDecimal 已经为我们实 现了,因此可以直接调用该类的相应方法即可实现,以下代码分别实现四则运算。 代码清单:code\cal\src\org\crazyit\cal\MyMath.java 加法: public static double add(double num1, double num2) { //调用工具方法将 double 转换成 BigDecimal BigDecimal first = getBigDecimal(num1); BigDecimal second = getBigDecimal(num2); return first.add(second).doubleValue(); } 减法: public static double subtract(double num1, double num2) { BigDecimal first = getBigDecimal(num1); BigDecimal second = getBigDecimal(num2); return first.subtract(second).doubleValue(); } 乘法: public static double multiply(double num1, double num2) { BigDecimal first = getBigDecimal(num1); BigDecimal second = getBigDecimal(num2); return first.multiply(second).doubleValue(); } 除法: public static double divide(double num1, double num2) { Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·7· BigDecimal first = getBigDecimal(num1); BigDecimal second = getBigDecimal(num2); return first.divide(second, DEFAULT_SCALE, BigDecimal.ROUND_HALF_UP) .doubleValue(); } 四个方法都是调用了 BigDecimal 的方法来实现,Java 的 BigDecimal 类为我们提供了许多强大的 计算方法,可以让我们很方便的进行数学运算,除本章介绍的方法我,读者可以查阅 Java 的 API 来学 习该类的详细使用。 2.5 计算器主界面 这里实现计算器的界面,是用 java 的 Swing 实现的,主要用到的类有 javax.swing.JFrame(窗口), javax.swing.JButton(按钮),javax.swing.JTextField(输入框),并使用 java.awt.BorderLayout 和 java.awt.GridLayout 进行布局。在这里,我们使用“自下而下”的方法去观察此类,先看总体的排版实 现,再看各个小组件的实现。为了方便布局,我们按相近的外观把计算器分为四个部分,见图 2.4: 图 2.4 布局 2.5.1 初始化界面(initialize()方法) 此类就是一个 JFrame(继承了 javax.swing.JFrame),用来做其它窗口或者组件的父容器,初始化 计算器窗口的大概流程:  设置父窗口 JFrame 标题、布局管理器、是否可以改变等属性 。  增加输入与计算结果显示框。对应图 2.4 中的左上角那部分。  增加左边存储操作键。  增加结果操作键。  增加数字与其它运算符。 由于按外观相近的方式把组件分成了四部分,就方便程序中对相同属性的组件统一地创建与设置属 性,对于界面的布局也更加地直观与方便,观察此图,我们可以使用 BorderLayout 做总体布局,如图 2.5 所示。 Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·8· 图 2.5 布局管理器 以下代码设置父窗口 JFrame 标题和设置是否可以改变大小的属性。 //设置窗口的标题 this.setTitle("计算器"); //设置为不可改变大小 this.setResizable( false ); 增加输入与结果显示的 JTextField 输入框,这里调用本类的 getTextField()方法获取,并把它加入 panel 中的 NORTH 位置中: //增加计算输入框 JPanel panel = new JPanel(); panel.setLayout( new BorderLayout(10,1) ); panel.add( getTextField(), BorderLayout.NORTH ); panel.setPreferredSize( new Dimension( PRE_WIDTH, PRE_HEIGHT ) ); 增加左边存储操作键,本类需要通过 getMButton()方法获取一个保存 JButton 对象的数组, getMButton 方法我们将在 2.5.2 中实现。获取数组后,遍历数组,并把数组中的元素加到一个新建的 JPanel 中,最后再把这个 JPanel 加到 JFrame 的相应位置: //增加左边存储操作键 JButton[] mButton = getMButton(); //新建一个 panel,用于放置按钮 JPanel panel1 = new JPanel(); //设置布局管理器 panel1.setLayout( new GridLayout( 5, 1, 0, 5 ) ); //迭代增加按钮 for( JButton b : mButton ) panel1.add(b); 增加结果操作键,这些结果操作键包括:Back,CE,C。通过本类的 getRButton()方法获取一个保 存 JButton 对象的数组,获取数组后,遍历数组,并把数组中的元素加到一个新建的 JPanel 中,最后 再把这个 JPanel 加到 JFrame 相应的位置,具体实现的代码如下: //增加结果操作键 Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·9· JButton[] rButton = getRButton(); JPanel panel2 = new JPanel(); panel2.setLayout( new BorderLayout(1, 5) ); //新建一个 panel,用于放置按钮 JPanel panel21 = new JPanel(); //设置布局管理器 panel21.setLayout( new GridLayout( 1, 3, 3, 3 ) ); //迭代增加按钮 for( JButton b : rButton ) panel21.add(b); 接下来将其他的按键加入到界面的 JPanel 对象中,这些操作键主要包括数字键和其他的一些运算 键,我们同样的通过一个 getNButton 方法来返回这些操作键对应的 JButton 对象,最后将这些 JButton 对象加入到相应的 JPanel 中,加入到 JPanel 并设置相应布局的代码如下: //增加数字与其它运算符 JButton[] nButton = getNButton(); //新建一个 panel,用于放置按钮 JPanel panel22 = new JPanel(); //设置布局管理器 panel22.setLayout( new GridLayout( 4, 5, 3, 5 ) ); //迭代增加按钮 for( JButton b : nButton ) panel22.add(b); //把新增加的面板加到 frame … this.add(panel); 在本小节中,我们通过 getMButton、getRButton 和 getNButton 方法来返回不同的 JButton 数组, 然后再对这些数组进行遍历,将每一个 JButton 加入到界面中。这一个返回 JButton 数组的方法并没有 实现,下面将介绍如何实现这三个方法。 以上所有的代码均在 code\cal\src\org\crazyit\cal\CalFrame.java 中。 2.5.2 创建运算键 运算键主要包括数字键与基本运算键,数字键从 0 到 9,基本运算键包括开方、正负、小数点等键, 主要实现计算器界面的 getNButton 方法即可。以下是该方法的实现。 代码清单:code\cal\src\org\crazyit\cal\CalFrame.java private JButton[] getNButton() { // 这个数组保存需要设置为红色的操作符 String[] redButton = { "/", "*", "-", "+", "=" }; JButton[] result = new JButton[nOp.length]; for (int i = 0; i < this.nOp.length; i++) { // 新建按钮 JButton b = new JButton(this.nOp[i]); // 为按钮增加事件 b.addActionListener(getActionListener()); // 对 redButton 排序,才可以使用 binarySearch 方法 Arrays.sort(redButton); // 如果操作符在 redButton 出现 if (Arrays.binarySearch(redButton, nOp[i]) >= 0) { b.setForeground(Color.red); } else { b.setForeground(Color.blue); Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·10· } result[i] = b; } return result; } 以上代码需要注意的是,我们需要提供一个红色按键的字符串数组,在遍历所有的需要创建的按键 数组时,就需要作判断,如果按键数组里面存在红色按键数组的某个元素,就需要调用 JButton 的 setForeground 方法来设置该按钮的字体颜色。在代码中我们不能看到该方法帮我们创建了哪些按键, 代码中使用了一个 nOp 的字符串数组来保存需要创建的按键,该数组包含的内容如下: private String[] nOp = { "7", "8", "9", "/", "sqrt", "4", "5", "6", "*", "%", "1", "2", "3", "-", "1/x", "0", "+/-", ".", "+", "=" }; 2.5.3 创建操作按键 操作按键的创建与运算键的创建基本一致,只是所有的按键的字体都必须是红色的,创建操作按钮, 我们需要实现 getMButton 和 getRButton 方法,以下是这两个方法的具体实现。 代码清单:code\cal\src\org\crazyit\cal\CalFrame.java private JButton[] getMButton() { JButton[] result = new JButton[mOp.length + 1]; result[0] = getButton(); for (int i = 0; i < this.mOp.length; i++) { // 新建按钮 JButton b = new JButton(this.mOp[i]); // 为按钮增加事件 b.addActionListener(getActionListener()); // 设置按钮颜色 b.setForeground(Color.red); result[i + 1] = b; } return result; } private JButton[] getRButton() { JButton[] result = new JButton[rOp.length]; for (int i = 0; i < this.rOp.length; i++) { // 新建按钮 JButton b = new JButton(this.rOp[i]); // 为按钮增加事件 b.addActionListener(getActionListener()); // 设置按钮颜色 b.setForeground(Color.red); result[i] = b; } return result; } getMButton 创建的是界面左侧的操作键,getRButton 创建的是运作键上面的操作键,getMButton 和 getRButton 创建的操作键如下: //getMButton private String[] mOp = { "MC", "MR", "MS", "M+" }; //getRButton Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·11· private String[] rOp = { "Back", "CE", "C" }; 创建完界面元素后,我们可以运行计算器,具体的效果如图 2.4 所示。 2.5.4 增加事件监听器 在上一节中,我们注意到程序为 JButton 类型的组件增加了事件监听器,这个事件监听器是用来响 应用户的鼠标操作。我们使用 java.awt.event.ActionListener 接口来创建一个事件监听器,主要是实现 接口中的 actionPerformed( ActionEvent e )方法,当监听器监听到用户的操作时,会自动调用此方法, 并在此方法中处理业务逻辑,再把数据返回显示给用户。见以下代码。 代码清单:code\cal\src\org\crazyit\cal\CalFrame.java actionListener = new ActionListener(){ public void actionPerformed( ActionEvent e ) { String cmd = e.getActionCommand(); String result = null; try { //计算操作结果 result = service.callMethod( cmd, textField.getText() ); } catch( Exception e1 ) { System.out.println( e1.getMessage() ); } //处理 button 的标记 if( cmd.indexOf("MC") == 0 ) { button.setText(""); } else if( cmd.indexOf("M") == 0 && service.getStore() > 0 ) { button.setText("M"); } //设置计算结果 if( result != null ) { textField.setText( result ); } } }; 从上面代码中可以看到,这里是通过实现 java.awt.event.ActionListener 接口中的 actionPerformed( ActionEvent e )方法去创建一个 java.awt.event.ActionListener 类型的内部类,并在 actionPerformed 方法中处理业务逻辑。 首先,调用 CalService 实例中的 callMethod 方法去处理计算,并把结果返回。 result = service.callMethod( cmd, textField.getText() ); 再设置标志存储结果类型的存储标记,如果是点击“MC”按钮,就把标记设置为空,如果是点击 “MS”,“MR”,“M+”,并且存储结果大于 0,就把标记设置为“M”,这里弄不明白的读者,可以先试 着使用一下 windows 计算器的这几个按钮,再看这里就很容易理解了。 if( cmd.indexOf("MC") == 0 ) { button.setText(""); } else if( cmd.indexOf("M") == 0 && service.getStore() > 0 ) { button.setText("M"); } 最后把计算结果设置到结果文本显示框中,显示给使用者。 if( result != null ) { textField.setText( result ); } Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·12· 在监听器中,我们调用了 CalServer 的 callMethod 方法来取得操作的结果,换言之,界面中的每次 点击都会执行该方法,callMethod 我们并没有提供任何实现,在下一小节,我们将实现该方法。 2.6 计算业务处理 在 2.3 章节中,我们建立了一个类名为 CalService 的类来处理计算器的计算业务,该类处理了整个 应用中的大部分业务,其中包括数字运算,存储运算,操作结果等业务。有四个重要的属性:firstNum 代表第一个操作数,secondNum 代表第二个操作数,lastOp 代表上次用户所做的操作, isSecondNum 代表是否第二个操作数。 2.6.1 计算四则运算结果 在使用计算器计算加、减、乘、除法的过程中,正常的情况应该是用户先输入第一个操作数,再点 击加、减、乘、除计算符号,再输入第二个操作数,最后点“=”号计算出结果,所以这时用 firstNum 去保存用户输入的第一个操作数,secondNum 去保存第二个操作数,lastOp 去保存计算符号或者其它 操作,isSecondNum 用来判断用户是在输入第几个操作数。 在用户输入数字的时候(包括“0123456789.”),首先判断是第一个操作数还是第二个,如果是第 一个,就把用户新输入的数字追加到原来数字的后面,并做为结果返回;如果是第二个,直接返回结果, 并把 isSecondNum 标志为 false,用户继续输入数字的时候,就把数字追加到原来数字的后面做为结果 返回,见以下代码。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String catNum( String cmd, String text ) { String result = cmd; //如果目前的 text 不等于 0 if( !text.equals("0") ) { if( isSecondNum ) { //将 isSecondNum 标志为 false isSecondNum = false; } else { //刚返回结果为目前的 text 加上新点击的数字 result = text + cmd; } } //如果有.开头,刚在前面补 0 if( result.indexOf(".") == 0 ) { result = "0" + result; } return result; } 当用户点击“+-*/”(四则运算)的时候,就把 lastOp 设置为其中一个符号,这个变量用来记录用 户正要进行计算的类型,见以下代码。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String setOp( String cmd , String text ) { //将此操作符号设置为上次的操作 this.lastOp = cmd; //设置第一个操作数的值 this.firstNum = text; Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·13· //将第二个操作数赋值为空 this.secondNum = null; //将 isSecondNum 标志为 true this.isSecondNum = true; //返回空值 return null; } 在上面的代码中,可以看到,除了设置 lastOp 外,还把输入的数字设置给 firstNum,把 secondNum 设置为空,并把 isSecondNum 设置为 true,代表下次输入数字时,要清空输入框并重新输入。 最后用户点击“=”号时,就是程序计算出最后结果的时候,此类的 String cal( String text , boolean isPercent )方法实现了此计算,注意到这个方法的第二个参数 isPercent,这是计算器的“%”号操作, 如果有这种操作,第二个操作数就等于两数相乘再除以 100,请看以下代码。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String cal( String text , boolean isPercent ) throws Exception { //初始化第二个操作数 double secondResult = secondNum == null ? Double.valueOf( text ).doubleValue() : Double.valueOf( secondNum ).doubleValue(); //如果除数为 0,不处理 if( secondResult == 0 && this.lastOp.equals("/") ) { return "0"; } //如果有"%"操作,则第二个操作数等于两数相乘再除以 100 if( isPercent ) { secondResult = MyMath.multiply( Double.valueOf( firstNum ) , MyMath.divide( secondResult, 100 ) ); } //四则运算,返回结果赋给第一个操作数 if( this.lastOp.equals("+") ) { firstNum = String.valueOf( MyMath.add( Double.valueOf( firstNum ), secondResult ) ); } else if( this.lastOp.equals("-") ) { firstNum = String.valueOf( MyMath.subtract( Double.valueOf( firstNum ), secondResult ) ); } else if( this.lastOp.equals("*") ) { firstNum = String.valueOf( MyMath.multiply( Double.valueOf( firstNum ), secondResult ) ); } else if( this.lastOp.equals("/") ) { firstNum = String.valueOf( MyMath.divide( Double.valueOf( firstNum ), secondResult ) ); } //给第二个操作数重新赋值 secondNum = secondNum == null ? text : secondNum; //把 isSecondNum 标志为 true this.isSecondNum = true; return firstNum; } Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·14· 上面计算结果中,经历了几个步骤,首先,确定 secondNum 的值,如果 secondNum 为空, secondNum 就等于最后输入的数字,如果不为空,则等于原来的值,如果有“%”号操作,则 secondNum 再等于两数相乘除以 100 的结果;然后根据 lastOp 的值(+、-、*、/)去调用 MyMath 类中的 add、subtract、 multiply、divide 方法,并把返回的结果保存到 firstNum;最后把 secondNum 设置为空,把 firstNum 当 做结果返回。 2.6.2 存储操作 定义一个 double 类型的属性 store 来充当存储器,在用户点击“MC(清除)”、“M+(累加)”、“MR (读取)”、“MS(保存)”操作时,就调用此方法,再根据得到的字符串去进行清除、累加、读取、保 存操作,见以下代码。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String mCmd( String cmd, String text ) { if( cmd.equals( "M+" ) ) { //如果是"M+"操作,刚把计算结果累积到 store 中 store = MyMath.add( store, Double.valueOf( text ) ); } else if( cmd.equals( "MC" ) ) { //如果是"MC"操作,则清除 store store = 0; } else if( cmd.equals( "MR" ) ) { //如果是"MR"操作,则把 store 的值读出来 isSecondNum = true; return String.valueOf( store ); } else if( cmd.equals( "MS" ) ) { //如果是"MS"操作,则把计算结果保存到 store store = Double.valueOf( text ).doubleValue(); } return null; } 程序中提供了一个 store 的属性用来保存计算结果,当用户点击了“M+”时,就将结果加到 store 中,点击了“MC”时,就将 store 设置为 0,点击了“MR”,则将 store 的值读取,点击了“MS”,则 将 store 设置为当前的结果。 2.6.3 实现开方、倒数等 开方与倒数的计算实现都比较简单,开方是直接使用 Math 类的 sqrt 方法去计算接收到的数值,并 且返回结果: public String sqrt(String text) { // 将 isSecondNum 标志为 true this.isSecondNum = true; // 计算结果并返回 return String.valueOf(Math.sqrt(Double.valueOf(text))); } 倒数是调用 MyMath 的 divide 方法去计算 1 与接收到的数值相除的值。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String setReciprocal(String text) { // 如果 text 为 0,则不求倒数 if (text.equals("0")) { Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·15· return text; } else { // 将 isSecondNum 标志为 true this.isSecondNum = true; // 计算结果并返回 return String.valueOf(MyMath.divide(1, Double.valueOf(text))); } } 2.6.4 实现倒退操作 当我们的程序中得到用户在界面输入的相关数字时,如果用户进行了倒退操作,我们可以将用户输 入的数字进行截取,如果接收到的字符串是“0”或者为 null,则不作任何操作,直接返回,否则,我 们将使用 String 的 substring 方法进行处理,将输入字符串的最后一位截取。以下方法实现倒退操作。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String backSpace(String text) { return text.equals("0") || text.equals("") ? "0" : text.substring(0, text.length() - 1); } 2.6.5 清除计算结果 清除所有计算结果,把 firstNum 与 secondNum 都设置为原始值,并返回 firstNum,在 CalService 中提供了一个 clearAll 方法,用于清除所有的计算结果。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String clearAll() { // 将第一第二操作数恢复为默认值 this.firstNum = "0"; this.secondNum = null; return this.firstNum; } 2.6.6 实现中转方法(callMethod) 在前面的章节中,我们已经实现了各个方法,例如四则运算、开方、倒数、清除计算等,但是在界 面的监听器中,只会调用 CalService 的 callMethod 方法进行运算,因此我们需要对 callMethod 进行相 关的实现。 代码清单:code\cal\src\org\crazyit\cal\CalService.java public String callMethod(String cmd, String text) throws Exception { if (cmd.equals("C")) { return clearAll(); } else if (cmd.equals("CE")) { return clear(text); } else if (cmd.equals("Back")) { return backSpace(text); } else if (numString.indexOf(cmd) != -1) { return catNum(cmd, text); } else if (opString.indexOf(cmd) != -1) { Download at http://www.pin5i.com/ 第 2 章 仿 Windows 计算器 ·16· return setOp(cmd, text); } else if (cmd.equals("=")) { return cal(text, false); } else if (cmd.equals("+/-")) { return setNegative(text); } else if (cmd.equals("1/x")) { return setReciprocal(text); } else if (cmd.equals("sqrt")) { return sqrt(text); } else if (cmd.equals("%")) { return cal(text, true); } else { return mCmd(cmd, text); } } CalService 中的 callMethod 方法,只是判断输入命令,再决定调用具体的哪个方法处理计算。例 如监听器监听到用户点击了倒退了按键,那么 callMethod 方法就会根据点击的按键文本来找到 backSpace 方法。当然,使用这么多的 if…else…并不是最佳的解决方案,我们可以使用一些的设计模 式来解决。有兴趣的读者可以了解相关的设计模式,考虑如何解决这些问题。 2.7 本章小结 本章主要是通过一个仿 Windows 计算器的基本实现,向读者讲解 Java swing 编程,示范了 JFrame, JPanel,JTextField,JButton 的使用。界面布局方面,使用到了 awt 的 BorderLayout 与 GridLayourt 布局管理器去布局。并且向读者介绍了 ActionLisner 事件监听器的使用,介绍如何监听用户的动作响应 用户,并且向用户返回有用的信息。本章中实现的计算相对较为简单,有兴趣的读者可以在本文的基础 上实现更强大的计算器(科学型计算器)。另外需要注意的是,本章程序编写的过程中,使用了许多 if…else…语句,对设计模式有一定了解或者希望对此有了解的读者,可以尝试去重构本章的代码,消 除这些 if…else…。在下面的章节中,我们会在编写的过程中,展示一些设计模式的概念。 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 第 3 章 图片浏览器 3.1 图片浏览器概述 相信使用 Window 操作系统的大多数用户,都使用过 Windows 的图片浏览器,或者是功能更强大 与复杂的 ACDSee 图片浏览器(这个还支持编辑图片),图片浏览器最基本的功能是能浏览一个目录中 的所有图片,并可以点击浏览上一张图片或者下一张图片,还有对图片放大与缩小,或者翻转图片等操 作,在这里,实现了图片的浏览功能,导航功能(下一张、上一张),放大缩小功能。 本章将实现一个最简单的图片浏览器,包括了打开图片、放大与缩小图片、查看上一张和下一张图 片等功能,图片浏览器的最终效果如图 3.1 所示。 图 3.1 图片浏览器 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·2· 3.2 创建图片浏览器的相关对象 我们首先需要创建图片浏览器的相关对象。我们先创建图片浏览器的界面对象 ViewerFrame,然 后 在该类中,我们为菜单、按钮加了事件监听器,所以定义了一个继承 AbstractAction 的类 ViewerAction 来响应这些动作。在 Action 中响应动作,就到处理具体逻辑的步骤,我们把所有的逻辑处理放到 ViewerService 类中,ViewerService 中包括打开图片、上一张、下一张、放大和缩小图片等功能,为了 程序更好的解耦合,我们可以把具体的某些业务处理放置到独立的类中进行处理。 除了以上所说的几个类,由于我们这个程序有打开图片的操作,所以需要一个文件过滤器(只能选 择图片类型的文件),所以定义了一个继承 JFileChooser 的类 ViewChooser,这个类里面定义了自己的 文件过滤器。本章中所涉及的对象及它们之间的关系如图 3.2 所示。 图 3.2 图片浏览器类图 本章程序的功能较为简单,因此所涉及的对象也并不复杂,只有简单的五个对象。 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·3· 3.2.1 文件过滤器 如果要使文件对话框实现文件过滤功能,就需要结合 FileFilter 类来进行文件操作,文件过滤器是 FileFilter 的一个继承,也是文件对话框的内部类,里面重写了 FileFilter 的 accept 与 getDescription 方 法:  boolean accept( File f ),判断文件是否属于图片类型。  String getDescription(),获取过滤器的描述。 文件过滤器主要在用户打开图片时使用,当用户进行了图片选择后,就可以对用户所选择的文件进 行验证。当用户打开文件选择时,我们就可以对所有的文件进行一次过滤,文件选择器中只可以选择我 们所定义的图片文件,那么其他的文件将不会被显示。在本章中,文件过滤器是文件对话框类 (ViewerFileChooser)的一个内部类(MyFileFilter)。 3.2.2 文件对话框 Java 文件对话框的实现比较简单,只要使用 JFileChooser 类并提供一个自己的构造器即可。这里 的文件对话框对象是 JFileChooser 类的子类,目的是为了加入在 3.2.1 中定义的文件过滤器:  void addFilter(),为这个文件对话框增加过滤器。 该对象中的 addFilter 方法主要用于向文件对话框加入文件过滤器,例如我们需要只显示.bmp 的文 件,那么可以在 addFilter 方法中使用以下代码实现: this.addChoosableFileFilter(new MyFileFilter(new String[] { ".BMP" }, "BMP (*.BMP)")); 在文件对话框的 addFilter 方法加入以上的代码后,那么用户将不能看到.bmp 的文件,并且在“文 件类型”的下拉中也只能选择.bmp,效果如图 3.3 所示。在本章中,文件对话框对应的是 ViewerFileChooser 类。 图 3.3 文件过滤器的作用 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·4· 3.2.3 主界面类 我们建立一个界面类作为图片浏览器的主界面,该类包括图片显示区、菜单栏、工具栏,并为工具 栏与菜单栏加上事件监听器,如下:  void init(),初始化图片浏览器的界面。  JLabel getLabel(),获取显示图片的 JLabel。  createToolPanel(),创建放大、缩小、上一张、下一张等工具按钮。  void createMenuBar(),创建文件、工具、帮助等菜单。 在这里需要注意的是,由于打开的图片大小并不能确定,因此图片显示区必须使用 JScrollPane。 在本章中,主界面对应的是 ViewerFrame 类。 3.2.4 业务处理类ViewerService 业务处理类主要是处理图片浏览器的大部分业务逻辑,包括打开图片、关闭浏览器、放大图片、缩 小图片、浏览上一张图片、浏览下一张图片等功能,如下:  static ViewerService getInstance(),获取 ViewerService 类的一个单态实例。  void open( ViewerFrame frame ),弹出文件选择框,并读取被选择到的图片。  void zoom( ViewerFrame frame, boolean isEnlarge ),对正在浏览到的图片做放大或者缩小操 作,这里可能会丢失图片精度。  void last( ViewerFrame frame ),浏览上一张图片。  next( ViewerFrame frame ),浏览下一张图片。  void menuDo( ViewerFrame frame, String cmd ),响应菜单的动作。 在本章中,这个业务处理类并不是无状态的 Java 对象,也就是意味着本章的业务处理类将人保存 一些业务状态,这些业务状态包括:当前浏览的文件目录、文件目录的文件集合、图片放大或者缩小的 比例等属性。由于我们这个是有状态的 Java 对象,那么就意味着,如果访问的是同一个实例,那么该 对象的这些属性将会被所有的访问者共享,如果其中的一个访问者改变了其中一个或者多个属性,那么 其他的访问者将会受到影响。当然,我们本章只是一个普通的图片浏览器,不存在多个用户使用同一个 图片浏览器的情况。在本章中,业务处理类对应的是 ViewerService 类。 3.2.5 操作处理类 在本例中,由于用户可以执行的操作较少,因此,我们可以提供一个操作处理类来接收用户所有的 操作,本例中的操作处理类是 AbstractAction 的一个子类,能用 ImageIcon(图标)来创建一个 Action, 再用这个 Action 来创建按钮,点击按钮的时候,将调用此类的 actionPerformed 方法:  void actionPerformed( ActionEvent e ),重写 AbstractAction 的方法,响应事件。 由于我们只有一个操作处理类,因此在实现 actionPerformed 方法时,我们就需要进行一系列的判 断,让程序知道用户进行了何种操作,再调用业务处理类中的相应方法。 到此,图片浏览器的相关对象都已经建立,并且确定了我们需要实现哪些方法,我们在实现的过程 中,如果发现可以对程序进行重构,那么也可以在重构的过程中,创建相关的类。 3.3 创建主界面 这个图片浏览器的界面排版比较简单,只有菜单(不需要排版)、工具栏、图片显示区,我们使用 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·5· BorderLayout 进行布局,把工具栏放在 BorderLayout.NORTH ,把图片显示区放在 BorderLayout.CENTER。在本章中,由于打开图片的大小并不确定,因此我们需要使用一个 JScrollPane 来作为图片显示区域。 3.3.1 初始化界面(init()方法) 首先,设置 JFrame 窗口的标题,接下来初始化画图区域,初始化为白色,然后再获取 PENCIL_TOOL(铅笔)类型的 Tool,创建各种鼠标监听器,并在监听的执行方法中调用 Tool 的相应方法, 最后获取左边工具栏面板、下面菜单栏面板、菜单,并把这些面板与画图获取加到 JFrame 中,见以下 代码。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFrame.java public void init() { //设置标题 this.setTitle( "看图程序" ); //设置大小 this.setPreferredSize( new Dimension( width, height ) ); //创建菜单 createMenuBar(); //创建工具栏 JPanel toolBar = createToolPanel(); //把工具栏和读图区加到 JFrame 里面 this.add( toolBar, BorderLayout.NORTH ); this.add( new JScrollPane(label), BorderLayout.CENTER ); //设置为可见 this.setVisible( true ); this.pack(); } 首先是为 JFrame 设置标题,接下来设置大小,然后调用本类的 createMenuBar()方法去创建菜单 栏、调用 createToolPanel()方法去创建工具栏,最后把菜单栏和图片显示区加到 JFrame 中(图片显示 区只是一个 JLabel)。以上代码中的黑体部分,使用一个 createToolPanel 的方法来创建菜单,该方法 将在下面章节中实现。 3.3.2 创建菜单栏 菜单栏,必须有事件响应,所以,先为菜单定义一个事件监听器,见以下代码。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFrame.java //加给菜单的事件监听器 ActionListener menuListener = new ActionListener(){ public void actionPerformed(ActionEvent e) { service.menuDo( ImageFrame.this, e.getActionCommand() ); } }; 这个事件监听器实现了 ActionListener 中的 actionPerformed 方法,是响应用户操作的方法,方法 里面的 service 类就是我们的业务逻辑处理类 ImageService 的一个单态实例。有了这个事件监听器,就 可以一次性创建出所有的菜单(用数组定义好菜单文字等东西的形式),见以下方法。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFrame.java public void createMenuBar() { //创建一个 JMenuBar 放置菜单 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·6· JMenuBar menuBar = new JMenuBar(); //菜单文字数组,以下面的 menuItemArr 一一对应 String[] menuArr = { "文件(F)", "工具(T)", "帮助(H)" }; //菜单项文字数组 String[][] menuItemArr = { {"打开(O)","-", "退出(X)"}, {"放大(M)", "缩小(O)","-","上一个(X)","下一个(P)"}, { "帮助主题", "关于" } }; //遍历 menuArr 与 menuItemArr 去创建菜单 for( int i = 0 ; i < menuArr.length ; i++ ) { //新建一个 JMenu 菜单 JMenu menu = new JMenu( menuArr[i] ); for( int j = 0 ; j < menuItemArr[i].length ; j++ ) { //如果 menuItemArr[i][j]等于"-" if ( menuItemArr[i][j].equals( "-" ) ) { //设置菜单分隔 menu.addSeparator(); } else { //新建一个 JMenuItem 菜单项 JMenuItem menuItem = new JMenuItem( menuItemArr[i][j] ); menuItem.addActionListener( menuListener ); //把菜单项加到 JMenu 菜单里面 menu.add( menuItem ); } } //把菜单加到 JMenuBar 上 menuBar.add(menu); } //设置 JMenubar this.setJMenuBar( menuBar ); } 图片浏览器的菜单是这样的结构: 文件(F) 打开(O) 退出(X) 工具(T) 放大(M) 缩小(O) 上一个(X) 下一个(P) 帮助(H) 帮助主题 关于 从代码中可以看到,程序用两个数组把这两层菜单的文字保存了进去,两个数组一起遍历,每次都 创建一个菜单项(JMenuItem),并为这个菜单项增加上前面定义的事件监听器,然后把这个菜单项加 到 JMenu 中。每次遍历完 第一个数组,都把这个 JMenu 加到 JMenuBar 中。遍历完所有数组,就把 这个 JmenuBar 加到 JFrame 里面,创建菜单的过程就完成了。 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·7· 3.3.3 创建工具栏 这里的工具按钮,为了美观,想用图片的方式创建 JButton,这里就要用到 AbstractAction,也就 是我们扩展的 ViewerAction 类,首先是用 ViewerAction 的 ViewrAction(ImageIcon icon, String name, ViewerFrame frame)去创建一个 ViewrAction,参数里面的 icon 对象就是从本地路径中读了图标的图标 类,然后以这个 ViewerAction 对象为参数去创建一个 JButton。见以下代码。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFrame.java public JPanel createToolPanel() { //创建一个 JPanel JPanel panel = new JPanel(); //创建一个标题为"工具"的工具栏 JToolBar toolBar = new JToolBar( "工具" ); //设置为不可拖动 toolBar.setFloatable( false ); //设置布局方式 panel.setLayout( new FlowLayout( FlowLayout.LEFT ) ); //工具数组 String[] toolarr = { "open", "last", "next", "big", "small" }; for( int i = 0 ; i < toolarr.length ; i++ ) { ViewerAction action = new ViewerAction( new ImageIcon("img/" + toolarr[i] + ".gif") , toolarr[i], this ); //以图标创建一个新的 button JButton button = new JButton( action ); //把 button 加到工具栏中 toolBar.add(button); } panel.add( toolBar ); //返回 return panel; } 以上代码的黑体部分,我们使用了 JButton 来创建工具栏的图标,每一个 JButton 对象都使用 ViewerAction 作为构造参数,但是需要注意的是,各个 JButton 之间并不是共享一个 ViewerAction 的实 例。创建完菜单与工具栏后,可以运行查看具体的效果,主界面的效果如图 3.4 所示。 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·8· 图 3.4 图片浏览器主界面 在本例中,图片浏览器的功能相对较为简单,因此界面也是较为简洁。如果想做更强大的图片浏览 器,可以参考 ACESee 或者 Windows 图片浏览器等功能。 3.4 实现图片浏览的操作 ViewerService 类主要是处理图片浏览器的大部分业务逻辑,包括打开图片、关闭浏览器、放大图 片、缩小图片、浏览上一张图片、浏览下一张图片等功能,在这里需要再做一次说明,ViewerService 是有状态的 Java 对象。 3.4.1 实现工具栏点击 我们在 3.2.5 中创建了一个 ViewerAction 的类,主要用于处理工具栏的点击事件,当用户点击了工 具栏的某个操作时,就会执行 ViewerAction 的 actionPerformed 的方法。我们在 3.3.3 中创建工具栏时, 使用了以下代码。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFrame.java String[] toolarr = { "open", "last", "next", "big", "small" }; for( int i = 0 ; i < toolarr.length ; i++ ) { ViewerAction action = new ViewerAction( new ImageIcon("img/" + toolarr[i] + ".gif") , toolarr[i], this ); //以图标创建一个新的 button JButton button = new JButton( action ); Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·9· //把 button 加到工具栏中 toolBar.add(button); } 以上代码中使用了“open”、“last”等字符串用来标识应该使用 ViewerService 的哪个方法,那么 就意味着我们需要在 actionPerformed 方法中作出这些判断: if (this.name.equals("open")) { //打开文件对话框 } else if (this.name.equals("last")) { //上一下图片 } … 本章中只有 5 个 Action,就需要写 5 次的 if…else,对于这样的代码,我们在本书的第二章(仿 Windows 计算器)中已经出现,当前并没有提供任何的解决方案,但是如果程序中出现如些之多的 if…else,那么我们就需要想办法去解决。接下来,创建一个 Action 的接口,提供一个 execute 的方法。 代码清单:code\viewer\src\org\crazyit\viewer\action\Action.java public interface Action { /** * 具体执行的方法 * @param service 图片浏览器的业务处理类 * @param frame 主界面对象 */ void execute(ViewerService service, ViewerFrame frame); } 编写了接口 Action 后,我们定义了一个 execute 的方法,那么,我们可以为该 Action 新建实现类, 例如有一个打开文件对话框的 Action,那么我们就新建一个 OpenAction,该类实现 Action 接口。以下 代码是 OpenAction 的具体的实现。 代码清单:code\viewer\src\org\crazyit\viewer\action\ OpenAction.java public void execute(ViewerService service, ViewerFrame frame) { //打开文件对话框 } 提供了这个 OpenAction 后,我们需要修改创建工具栏的代码,换一种方式创建工具栏。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFrame.java // 工具数组 String[] toolarr = { "org.crazyit.viewer.action.OpenAction", "org.crazyit.viewer.action.LastAction", "org.crazyit.viewer.action.NextAction", "org.crazyit.viewer.action.BigAction", "org.crazyit.viewer.action.SmallAction" }; for (int i = 0; i < toolarr.length; i++) { ViewerAction action = new ViewerAction(new ImageIcon("img/" + toolarr[i] + ".gif"), toolarr[i], this); // 以图标创建一个新的 button JButton button = new JButton(action); // 把 button 加到工具栏中 toolBar.add(button); } 将原来的字符串更换为某个 Action 实现类的全限定类名,那么在构造 ViewerAction 的时候,就可 以使用这个参数去创建具体的某个实现类。为 ViewerAction 编写一个工具方法,使用反射得到 Action 接口的某个实现类。 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·10· 代码清单:code\viewer\src\org\crazyit\viewer\ViewerAction.java private Action getAction(String actionName) { try { if (this.action == null) { //创建 Action 实例 Action action = (Action)Class.forName(actionName).newInstance(); this.action = action; } return this.action; } catch (Exception e) { return null; } } 以上的黑体代码,使用了反射来创建一个实例,并且该实例在 ViewerAction 中只有一个实例,由于 该方法在 ViewerAction 中,所以我们在构造 ViewerAction 的时候,将对应的处理类传入即可。得到具 体的某个 Action 实现类后,在实现 ViewerAction 的时候,我们就可以不必使用那堆烦人的 if…else 了, 直接通过以上的工具方法(getAction)得到相关的 Action 实现类,再调用 Action 的 execute 方法即可。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerAction.java public void actionPerformed(ActionEvent e) { ViewerService service = ViewerService.getInstance(); Action action = getAction(this.actionName); //调用 Action 的 execute 方法 action.execute(service, frame); } 其实在本章中,我们并不需要如此复杂来实现,或许有些读者会觉得,编写多几个 if…else 可能比 这样做更省事,但是,如果站在程序可扩展的角度看,当需要为图片浏览器添加行为时,我们就不必再 修改 ViewerAction,我们这样做,无论添加或者减少多少个 Action,都不必去修改 ViewerAction 类, 只需要去修改使用者(主界面对象)。对于一些简单的程序,我们可以使用 if…else 来解决,但是没有 人知道程序将会有多复杂,因此笔者还是推崇使用其他方法来减少 if…else 或者尽量减低程序的耦合。 3.4.2 实现菜单的点击 我们为菜单增加了事件监听器,每次点击菜单时,都会先调用这个方法,由这个方法去决定做些什 么类型的业务处理。在方法中,是根据菜单的文字去判断下步要调用的方法。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFrame.java public void menuDo( ViewerFrame frame, String cmd ) { //打开 if( cmd.equals("打开(O)") ) { open( frame ); } //放大 if( cmd.equals("放大(M)") ) { zoom( frame, true ); } //缩小 if( cmd.equals("缩小(O)") ) { zoom( frame, false ); } //上一个 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·11· if( cmd.equals("上一个(X)") ){ last( frame ); } //下一个 if( cmd.equals("下一个(P)") ) { next( frame ); } //退出 if( cmd.equals("退出(X)") ) { System.exit( 0 ); } } 在此,我们同样可以使用 3.4.1 中的方法来消除这一堆的 if…else,在这里不再详细描述。 3.4.3 打开图片 这个图片浏览器,打开一个图片文件之后,会把这个文件所有文件夹类的所有图片类型的的文件缓 存起来,目的是为了不用每次都去搜索这个文件夹内的文件,也方面“上一张”和“下一张”的定位, 缓存的文件都保存在本类的 currentFiles 中,currentFiles 是一个 List类型。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerService.java public void open( ViewerFrame frame ) { //如果选择打开 if( fileChooser.showOpenDialog( frame ) == ViewerFileChooser.APPROVE_OPTION ) { //给目前打开的文件赋值 this.currentFile = fileChooser.getSelectedFile(); //获取文件路径 String name = this.currentFile.getPath(); //获取目前文件夹 File cd = fileChooser.getCurrentDirectory(); //如果文件夹有改变 if( cd != this.currentDirectory || this.currentDirectory == null ) { //或者 fileChooser 的所有 FileFilter FileFilter[] fileFilters = fileChooser .getChoosableFileFilters(); File files[] = cd.listFiles(); this.currentFiles = new ArrayList(); for( File file : files ) { for( FileFilter filter : fileFilters ) { //如果是图片文件 if( filter.accept( file ) ) { //把文件加到 currentFiles 中 this.currentFiles.add( file ); } } } } ImageIcon icon = new ImageIcon( name ); Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·12· frame.getLabel().setIcon( icon ); } } 首先用 ViewerFileChooser 对象的 showOpenDialog 方法弹出一个文件选择框,在用户未选择图片 之前,做其它操作的时候,这里就获取当前的文件路径与当前的文件夹。 如果 currentDirectory(当前文件夹)为空(证明是第一次打开文件)或者是 currentDirectory 不等 于现在打开的文件夹,那么证明文件夹的路径有改变,就读取这个文件夹下面的所有文件。 在读取文件的过程中,先调用 ViewerFileFilter 中的 getChoosableFileFilters()方法获取我们自定义 的文件过滤器,如果读取到的文件类型属于当前的文件过滤器中允许的类型,就把这个文件加到 currentFiles 中缓存起来。 最后,用当前选择到的文件为参数新建一个 ImageIcon 对象,并调用 ViewerFrame 对象中 JLabel 对象的 setIcon 方法,把图片设置进去,就完成了显示图片的过程。 3.4.4 放大或者缩小图片 Image 中有一个叫 getScaledInstance 的方法,能根据宽度去按比例改变图片的大小。在这个缩放 方法(zoom)中,用参数 isEnlarge 是代表放大或者缩小的。如果 isEnlarge 等于 true,就代表是放大, 反之是缩小。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerService.java public void zoom( ViewerFrame frame, boolean isEnlarge ) { //获取放大或者缩小的乘比 double enLargeRange = isEnlarge ? 1 + range : 1 - range; //获取目前的图片 ImageIcon icon = (ImageIcon)frame.getLabel().getIcon(); if( icon != null ) { int width = (int)(icon.getIconWidth() * enLargeRange); //获取改变大小后的图片 ImageIcon newIcon = new ImageIcon( icon.getImage() .getScaledInstance( width,-1,Image.SCALE_DEFAULT) ); //改变显示的图片 frame.getLabel().setIcon( newIcon ); } } 首先是通过 isEnlarge 去得到缩放的比例(放大是大于 1,缩小是 0 与 1 之间),接下来从 Jlable 中 用 getIcon 方法获的 ImageIcon 图片对象,如果这个对象不为空,就从这个对象中调用 getIconWidth 方法得到宽度,并用这个宽度和缩放比例相乘得到新的宽度。 用新的宽度为参数去调用 getScaledInstance 方法得到新的 ImageIcon 对象,最后又调用 JLabel 的 setIcon 方法把这图片设置到 JLabel 对象中去。 3.4.5 “上一张”、“下一张”图片 前面知道,ViewerService 中保存着当前打开的文件 currenFile,还有这个文件夹下面的所有图片文 件 currentFiles,那么,读取“上一张”或者“下一张”图片就变的简单了,只要得到一个图片的索引, 就能从 currentFiles 中取到图片。 这里是以读取上一张图片的方法为例子说明,读取下一张图片的实现是类似的。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerService.java public void last( ViewerFrame frame ) { //如果有打开包含图片的文件夹 Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·13· if( this.currentFiles != null && !this.currentFiles.isEmpty() ) { int index = this.currentFiles .indexOf( this.currentFile ) ; //打开上一个 if( index > 0 ) { File file = (File)this.currentFiles.get( index - 1); ImageIcon icon = new ImageIcon( file.getPath() ); frame.getLabel().setIcon( icon ); this.currentFile = file; } } } 如果 currentFile 与 currentFiles 都不为空(证明当前是有打开文件的),那就用 currentFile 从 currentFiles 中得到当前文件的索引,并把这个索引减 1,去获取上一个文件。 获取到上一个文件后,调用 File 类的 getPath()方法得到文件的路径,然后以这个为参数来创建一 个 ImageIcon,最后把它设置到 JLabel 中。 3.5 文件选择与过滤 使用 JFileChooser 创建文件对话框流程是先使用构造器创建一个 JFileChooser 对象,然后调用 JFileChooser 对象的 showXXXDialog 的方法显示文件对话框,如果需要对文件进行过滤,就需要调用 addChoosableFileFilter(FileFilter filter)方法添加文件过滤器,见以下代码。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFileChooser.java private void addFilter() { this.addChoosableFileFilter( new MyFileFilter( new String[]{".BMP"}, "BMP (*.BMP)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".JPG",".JPEG",".JPE",".JFIF"}, "JPEG (*.JPG;*.JPEG;*.JPE;*.JFIF)") ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".GIF"}, "GIF (*.GIF)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".TIF",".TIFF"}, "TIFF (*.TIF;*.TIFF)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".PNG"}, "PNG (*.PNG)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".ICO"}, "ICO (*.ICO)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".BMP",".JPG",".JPEG",".JPE",".JFIF", ".GIF",".TIF",".TIFF",".PNG",".ICO"}, "所有图形文件") ); } 这里是把 bmp,jpg,gif 等类型的文件过滤器都加到 JFileChooser 中,留意到这里是调用 MyFileFilter( String[] suffarr,String decription )这个构造器去创建一个 FileFiler,第一个参数是后缀名, 第二个参数是描述,见以下代码。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFileChooser.java public MyFileFilter( String[] suffarr,String decription ) { super(); Download at http://www.pin5i.com/ 第 3 章 图片浏览器 ·14· this.suffarr = suffarr; this.decription = decription; } MyFileFilter 继承了 FileFilter,我们这里重写它的 accept 方法,去定义过滤的规则,见以下代码。 代码清单:code\viewer\src\org\crazyit\viewer\ViewerFileChooser.java public boolean accept( File f ) { //如果文件的后缀名合法,返回 true for ( String s : suffarr ) { if ( f.getName().toUpperCase().endsWith( s ) ) { return true; } } //如果是目录,返回 true,或者返回 false return f.isDirectory(); } 到此,我们整个图片浏览器已经实现,本章所涉及的内容较少,到现在可能运行程序查看具体的效 果。 3.6 本章小结 本章通过图片浏览器的基本实现,向读者介绍了 JFileChooser 文件选择框与 FileFilter 的用法,使 用了 AbstractAction 去创建按钮,并响应按钮事件。在代码中,使用 List 缓存的技巧简单去实现了“上 一张”、“下一张”图片的功能,并让读者体会到可以怎样子去操作一张图片,例如改变图片的大小等操 作。在本章实现工具栏点击时,我们使用了 Java 的反射来创建具体具体的某个工具栏 Action 类,我们 在本章中初步使用了 Java 的反射,在以后的章节中,我们会更多的使用各种 Java 技术,目的是为了减 低程序的耦合,编写更多可扩展的程序。 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 第 4 章 桌面弹球 4.1 桌面弹球概述 桌面弹球是游戏中常见的游戏,从以前的掌上游戏机到如今的手机游戏,都是一个十分经典的游戏。 玩家控制一个可以左右移动的挡板去改变运动中小球的移动方向,目的是用小球消除游戏屏幕中的所有 障碍物到达下一关,在障碍物被消除的过程中,可能会产生一些能改变挡板或者小球状态的物品,例如: 挡板变长、变短,小球威力加强等等。本章主要介绍如何实现一个简单的弹球游戏,让读者了解“动画” 的实现原理。 在本章中,将介绍与使用 Java 的绘图功能,使用到 JPanel 的 paint(Graphics g)方法去绘图,绘图 主要是依靠这个方法中的 Graphics 类型的参数,将使用 Java 中的 Timer 去重复绘图,产生动画效果, 桌面弹球游戏的效果如图 4.1 所示。 图 4.1 桌面弹球 4.1.1 动画原理 简单地来说,动画是利用人的视觉暂留的生理特性,实现出来的一种假象,只要每隔一段时间(这 个时间少于人的视频暂留时间)就重新绘制一幅状态改变的图片,就能造成这种“动”的假象。我们在 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·2· 程序中不断的进行绘画(使用 repaint 方法),对程序来讲,只需要在短时间内进行多次的绘画,并且每 次绘画都需要改变绘画的相关值,就可以达到“动画”的效果。 4.1.2 小球反弹的方向 在本章实现的过程中,我们会设置小球于对称的方式,并出现少许偏移的方式反弹,如图 4.2 所示。 让小球反弹出现少许编移是为了让游戏增加点不确定性,增加游戏的趣味性。我们需要在编写游戏前确 定这些小细节,这样在开发的过程中,我们就可以按照这些小细节去逐步实现我们的程序。 图 4.2 小球的反弹 4.2 流程描述 玩家使用左右方向键开始游戏与控制挡板,在未消除完所有的障碍物或者挡板没有档住向下移动的 小球之前,会一直处于游戏状态,在这个状态中,小球会一直处于直线运动或者改变方向,当小球消除 掉障碍物的时候,有机率产生一些物品,产生的物品会直线向下移动,用挡板接住物品后,物品的特殊 效果会生效。如果消除了所有的障碍物,就判断玩家为赢,如果挡板没有接住向下移动的小球,就判断 玩家为输。具体的游戏流程如图 4.3 所示。 图 4.3 游戏流程 游戏中并不涉及复杂的流程,只需要处理游戏的输赢即可,因此在实现的过程中,关键是如何确定 游戏输赢的标准(挡栏没有挡住小球)。 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·3· 4.3 创建游戏对象 在这个游戏中,有挡板,小球,砖块(障碍物),道具等物品,这些物品都有共同的特性,有属于 自己的 x 与 y 坐标属性,有图片属性,有速度属性,所以,在这时在,设计一个基类 BallComponent 包含这些属性与相关的方法,让其子类继承。继承此类的子类有 Stick 类(用于定义挡板的行为于属性), Ball 类(控制小球的移动与其它动作),Brick 类(砖块类),Magic 类(道具抽像类,此类中有一个用于 使道具功能实现的抽象方法,供其子类实现)。道具类的子类有 LongMagic 与 ShortMagic,作用是使 Stick 的长度变长或者变短。在平时的开发中,如果发现多个对象之间有一些共同的特性或者行为,并 且觉得可以使用这些特性或者行为构成一个对象,那么可以建立一个新的对象作为这些对象的父类。如 果该父类中某些方法并不需要由父类实现,我们可以将父类做成抽象类,并将这些方法变成抽象的。 确定了我们游戏中的所涉及的对象后,我们还需要一个 BallFrame 类去创建一个画板,用于绘制图 片,此类还完成界面的初始化,监听用户的键盘,而与游戏相关的业务逻辑(判断输赢或者球的运动), 我们放到 BallService 类中去处理,本章类的关系如图 4.4 所示。 图 4.4 桌面弹球类图 笔者在这里提供了本章的类图,是为了让读者可以更清晰的了解本章程序的结构,但在实现开发的 过程中,我们可以根据实际情况,加入或者改变各个类的关系或者程序的结构,但最终都是为降低程序 的耦合、提高内聚、编写出优秀的代码。 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·4· 4.3.1 基类BallComponent BallComponent,做为 Brick(砖块)类、Magic(道具)类、Stick(挡板)类、Ball(小球)类的 父类,定义了这些子类共有的属性与方法,属性有:x 坐标,初始值为-1;y 坐标,初始值为-1;图片 image, 初始值为 null;速度 speed,初始值为 5。根据不同的需要,提供以下三个构造方法:  BallComponent( String path ),path 是图片的路径,用图片的路径来构造一个 BallComponent, 在此构造方法中,将根据路径去读取图片,再设置对象中 image 属性。  BallComponent( int panelWidth , int panelHeight, String path ),以 panelWidth,panelHeight, 与 path 去构造一个 Component。  BallComponent( String path , int x , int y ),以 x 坐标,y 坐标和 path 去构造一个 BallComponet。 除去这些构造方法,此类提供了这些属性的 setter 与 getter 方法,用于获取对象的坐标与图片,或 者改变对象的坐标位置与图片属性。如果我们在编码的过程中发现有一些共同的属性或者方法,我们可 以将这些放到这个基类中。 创建 BallComponent 的时候,我们可以将这个类变成抽象类,即使它没有任何的抽象方法,这样 做的目的是,在我们的桌面弹球游戏中,该类并不是具体存在的某一个对象,而是我们将一些公用的属 性或者方法存放到该类中,因此它在游戏中并不代表某个具体的对象。将该类创建为抽象类,我们就可 以提供(如果需要的话)一些抽象方法让子类去实现,并且可以在父类中调用这些抽象方法。 4.3.2 砖块类(Brick) 此类是 BallComponet 的一个子类,提供一个 Brick(String path, int type, int x, int y )构造器,其中 pah、x 与 y 参数用于调用父类的构造器,type 是代表砖块的类型:1 代表此砖块里面有 LongMagic 类 型 的道具;2 代表此砖块里面有 ShortMagic 类型的道具;其它代表此砖块里面没有道具。另外,本类 增加了 magic 与 disable 属性,magic 代表此砖块中所包含的道具,初始值为 null,disable 是用来标志 Brick 的状态,如果 diable 为 true,则表明此砖块已经不可用,不会再显示。并提供这两个属性相关的 以下方法:  void setMagic( Magic magic ),设置道具。  Magic getMagic(),获取道具。  boolean isDisable(),用来判断此类是否有效。  void setDisable( boolean disable ),停用或者启用此类,disable 的值为 true 或者 false。 确定了一个砖块由一个 Brick 对象来表示后,在界面中,我们可以提供一个 Brick 的二维数组,来 表示界面中所有的砖块,实现原理与控制台五子棋中的棋盘一样,但是在本章中,二维数组的每一个元 素并不是字符串,而是具体的某个 Brick 对象,在以后的章节中,当遇到需要在界面中绘画某些图片的 时候,我们都可以建立一个二维数组,将相应的对象放置到该数组中,当界面进行绘画的时候,就可以 将这个二维数组“画”出来。 4.3.3 道具类及其子类(Magic) Magic 类是一个道具类,在游戏中表现包含在砖块中,是 BallComponet 的一个抽象子类,此类提 供一个 Magic( String path, int x , int y )构造器去调用父类的构造器,并提供一个抽象的方法 magicDo( Stick stick ),此抽象方法是实现道具的效果功能,用于给其子类实现,现在实现的子类的 LongMagic 类和 ShortMagic 类,两个子类的 magicDo 方法中分别实现使挡板变长与变短的功能。  abstract void magicDo( Stick stick ),道具的功能,给其子类实现。 在本例中,挡板是可以变长或者变短的,而使挡板变长或者变短的方式是通过道具来实现,因此可 以将道具抽象成变长的道具或者变短的道具,而它们都需要做同一件是,就是改变挡板的展现形式。为 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·5· 了程序的可扩展性,我们在这里将一个道具变为一个抽象类(Magic),当我们需要有其他形式的道具的 时候,就可以为该类添加子类,并提供不同的实现。当然,这里只提供一个 Stick 的参数可能并不够, 如果以后游戏中出现另外一种道具,会改变球的速度(变快或者变慢),那么我们就需要为该抽象类提 供更多的参数。 4.3.4 挡板类(Stick) 同样,Stick 类也是 BallComponet 的子类,用来代表游戏中的挡板,由于挡板只有左右移动的, 所以,此类中只定义了挡板 x 方向的移动速度 SPEED ,还有定义挡板的初始长度 preWidth ,并提供 此方法的 setter 与 getter 方法,如下:  void setPreWidth( int preWidth ),设置初始长度。  int getPreWidth(),获取初始长度。 由于该类继承于 BallComponet 类,因此只需要提供一个构造器即可。在本例中,挡板是可以变长 或者变短的,并且在建立道具抽象类的时候,已经定义了一个 magicDo 的方法,该方法的参数就是一 个挡板对象,所以挡板类必须包括长度的属性,这样,在实现道具类的时候,就可以通过改变挡板类的 长度来实现本例中所需要实现的长短挡板功能。在 Stick 类中并不需要关心挡板的图片、位置与大小, 这些属性已经在 BallComponet 中体现。 4.3.5 小球类(Ball) Ball 类也是 BallComponet 的子类,由于小球在游戏面板中运动的时候除了横竖方向,还有各种角 度的斜方向,所以我们把小球的速度分解成横向速度与竖向速度(speedX 与 speedY),游戏未开始前, 小球是处于静止状态,所以用一个 started 属性来标志小球是否已经开始运动。游戏结束后,小球也是 处于静止状态,但不能再移动,同样,用一个 stop 属性来标志小球是否能再移动。除了定义这些属性, 还为这些属性提供相应的 setter 与 getter 方法,如下:  setSpeedX( int speed ),设置小球的横向速度。  setSpeedY( int speed ),设置小球的竖向速度。  boolean isStarted(),小球是否已经在运动。  void setStarted( boolean b ),把小球状态设置为运动或者静止。  int getSpeedX(),获取小球的横向速度。  int getSpeedY(),获取小球的竖向速度。 在本例中,小球对象只保存一些相关的属性,例如横向速度与纵向速度(图片、位置与大小在父类 中体现),如果需要改变小球的速度,可以调用相关的 setter 方法来进行,但是我们需要知道由哪些对 象来改变小球的相关属性,我们在前面的章节中提到,提供一个业务类进负责处理游戏的相关逻辑,因 此,业务类就需要维护一个小球的对象,来控制小球的运动或者其他行为。在这里,小球对象可以单纯 的看作一个简单的对象,并不负责处理任何的行为,这可以看作我们一般所说的贫血模式,对象并不负 责处理任何的业务逻辑。如果需要将该小球对象编写成为充血模式,可以为小球对象提供一些与之相关 的行为,例如小球会运动,我们可以为 Ball 类加入一个 run 的方法,表示球的运动,例如小球会停止运 动(在游戏结束或者开始时),我们就可以为 Ball 类添加一个 stopRun 的方法,总之,如果需要做到充 血模式,可以将所有与小球相关的方法加入到 Ball 中。 4.3.6 业务处理类(BallService) BallService 处理了这个游戏中的大部分业务功能,包括开始游戏、小球移动、道具移动、挡板移 动、测试小球与挡板是否有碰撞或者挡板和其它元素有碰撞、设置挡板的长度、判断用户是否通关、初 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·6· 始化砖块的排列与道具、画图等功能。这些功能的实现都有对应的方法,如下:  void run(),小球进行运动。  void setStickPos( KeyEvent ke ),改变挡板的坐标位置。  setBallPos(),改变小球的坐标位置。  boolean isHitBrick( Brick brick ),测试小球与砖块是否有碰撞,参数 brikc 是指砖块。  isHitStick( BallComponent image ),测试某元素与挡板是否有碰撞。  void setMagicPos(),改变道具的坐标位置。  void setStickWidth( Magic magic ),根据道具(magic)的类型去设置改变挡板的长度。  boolean isWon(),判断玩家是否已经过关。  Brick[][] createBrickArr( String path, int xSize, int ySize ),创建砖块,返回一个 Brick 类型的数 组,参数 path 是指砖块的图片,xSize 与 ySize 是数组的长度。  void draw( Graphics g ),画图,方法中是使用 Graphics 对象 g 去画图。 当游戏开始时,程序中需要不停的调用 run 方法,让小球进行运动,当然,小球进行运动的前提是 Ball 的 isStarted 方法返回 true,即游戏已经开始,run 方法的主要功能就是调小球的位置。我们需要在 游戏中通过上、下、左、右的键来控制挡板的位置,因此就需要提供一个 setStickPos 的方法来改变挡 板的位置。在本章的程序中,BallService 处理所有的相关逻辑,例如判断小球在运动的过程中是否越界、 游戏是否胜利等。在例中 BallService 处理了大部分的游戏逻辑,当然,我们也可将这些逻辑放到相关 的类中(即前面提到的充血模式),例如道具的下落、挡板的移动等。 4.3.7 主界面类(BallFrame) BallFrame 是创建一个 JFrame 主界面,设置主界面的标题、长与宽、画板等属性,并且为增加键 盘事件监听器以及创立一个Timer 每隔一小段时间去刷新画板,主要有初始化界面与或者画板两个方法, 如下:  void initialize() throws IOException,此方法抛出 IO 异常,初始化界面。  BallPanel getBallPanel(),获取一个 BallPanel 类型的 JPanel 去充当画板,BallPanel 是这个类 中的一个内部类。 我们使用了 BallService 类来处理大部分的游戏逻辑,主界面类中几乎不包括任何的逻辑处理,该 类维护一个 BallService 的对象,得到界面中相关对象的信息后,可以调用 BallService 中的方法进行处 理,并根据返回的信息来改变界面。例如小球的运动,我们可以调用 BallService 的 run 方法,再调用 BallSerivce 的 draw 方法将小球的图片“画”到界面中。 到此,本章中所有的对象都已经创建并确定了它们的行为,在建立道具类(Magic)的时候,我们 将一个道具抽象为一个 Magic 对象,该类可以有多个实现,在使用 Magic 对象的时候,我们可以利用 面向对象的多态特性,使用 Magic 的 magicDo 方法来进行“道具的使用”,在这个过程中,我们并不需 要去关心道具具体的实现。在创建游戏各个对象的过程中,我们将处理逻辑的方法放置到一个业务类中, 从一定程度上讲,减少了代码之间的耦合,并遵循了单一职责的原则。 4.4 主界面实现 在这个桌面弹球游戏中,游戏中的所有元素都是用 Graphics 对象画出来的,所以,我们的主界面 应该是一个只设置了窗口标题还有颜色等基本属性的 JFrame,在这个 JFrame 中,我们只需要提供一 个 JPanel 对象即可,因为游戏的界面并没有多复杂的布局与界面交互。当我们实现游戏的一些相关逻 辑的时候(球的运动、道具的下落等),我们可以调用 JPanel 的 repaint 方法将 JPanel 进行重绘。 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·7· 4.4.1 初始化界面(initialize()方法) 首先,设置 JFrame 窗口的标题、背景颜色与是否可以改变大小,然后获取 JPanel 对象,最后把 JPanel 画板加到 JFrame 中,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallFrame.java public void initialize() throws IOException { //设置窗口的标题 this.setTitle("弹球"); //设置为不可改变大小 this.setResizable( false ); //设置背景为黑色 this.setBackground( Color.BLACK ); //获取画板 ballPanel = getBallPanel(); //把画板加到 JFrame this.add( ballPanel ); } 看加粗的一行代码 ballPanel = getBallPanel()是调用本类中的 getBallPanel()方法去获取一个 BallPanle 对象,BallPanel 是本类的一个内部类,并且继承 JPanel,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallFrame.java //定义一个 JPanel 内部类来完成画图功能 public class BallPanel extends JPanel { /** * 重写 void paint( Graphics g )方法 * * @param g Graphics * @return void */ public void paint( Graphics g ) { //可以调用 BallService 的 draw 方法进行绘制 } } 而获取这个 BallPanel 实现是在 BallPanel getBallPanel 方法中,此类保证这个 Panel 是单态的, 每次只有一个 BallPanle 对象,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallFrame.java public BallPanel getBallPanel() { if ( ballPanel == null ) { //新建一个画板 ballPanel = new BallPanel(); //设置画板的大小 ballPanel.setPreferredSize( new Dimension( BALLPANEL_WIDTH, BALLPANEL_HEIGHT ) ); } return ballPanel; } 在这里需要注意的是,我们需要在 BallFrame 中维护一个 BallPanel 的对象,然后通过 getBallPanel 的方法来获得 BallPanel 的实例,由于 BallPanel 并不需要每次去创建,所以我们可以将 BallPane 变成 单态的。在众多的设计模式中,有一种叫做单态模式。如果遇到一些对象并不需要多次创建或者创建这 些对象将会严重消耗系统资源,那么我们可以考虑将该对象写成单态的。 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·8· 4.4.2 单态模式简介 单态模式也可以叫单例模式,该模式保证一个类有且仅有一个实例,并为外界提供一个访问,让外 界可以通过这个访问点来访问该类的唯一实例。在我们平时开发的过程中,会遇到一些不需要多次创建 的对象,例如 JDBC 的 Connection 对象,那么我们就可以利用单态模式来创建这些对象。例如单态模 式,系统可以不必多次创建该对象的实例,外界使用的时候可以使用同一个实例,因此在一定程序上减 低了系统在创建对象时的开销。 为一个类实现单态模式,需要为该类提供一个私有的构造器,再提供一个可以获取该类实现的方法 (为外界提供唯一的访问点),私有构造器是为了不让外界去使用 new 关键字来创建该类的实现,如果 外键可以使用 new 关键字来创建该类的实例,那么就意味着该类将不会是单态,有可能外界多次通过 new 关键字来创建,这就无法保证该对象的实例的唯一性。 4.4.3 运行效果 编写了 BallFrame 的初始化代码后,我们可以运行具体查看相关的游戏效果。编写创建 BallFrame 的代码: BallFrame ballFrame = new BallFrame(); ballFrame.pack(); ballFrame.setVisible(true); ballFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); 当前程序的效果如图 4.5 所示。 图 4.5 初始化游戏时的界面 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·9· 注:我们当前并没有对 BallService 中的 draw 方法作任何的实现,我们实现了 BallService 的 draw 方法后,就可以将 BallPanel 中的 paint 方法加入 BallService.draw。 4.4.4 监听器与Timer javax.swing.Timer 可以设定每隔一个时间周期就重复执行某个 task,类似于 Window 系统的计划 任务或者 Linux 系统的 crobtab,并用 start()方法去启用 Timer。在这个弹球游戏中,我们只有键盘操作, 所以只监听键盘的操作,用一个 KeyListener 去监听键盘的动作,请看以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallFrame.java //定义每 0.1 秒执行一次监听器 ActionListener task = new ActionListener(){ public void actionPerformed( ActionEvent e ) { //开始改变位置 service.run(); //刷新画板 ballPanel.repaint(); } }; //如果 timer 不为空,调用 timer 的 restart 方法 if( timer != null ) { //重新开始 timer timer.restart(); } else { //新建一个 timer timer = new Timer( 100, task ); //开始 timer timer.start(); } //增加事件监听器 KeyListener[] klarr = this.getKeyListeners(); if( klarr.length == 0 ) { //定义键盘监听适配器 KeyListener keyAdapter = new KeyAdapter(){ public void keyPressed( KeyEvent ke ) { //改变挡板的坐标 service.setStickPos( ke ); } }; this.addKeyListener( keyAdapter ); } 首先,建立一个 ActionListener 对象做为 Timer 的 task,这 个 task 主要是处理游戏中各个组件位置 的改变以及 reapint 画板,这个 task 每 100 毫秒执行一次,即每隔一百毫秒小球(或者其他组件)会执 行一次运动。如果此类的属性 timer 为空,就以 ActionListern 对象为参数去创建一个每 100 毫秒执行一 次的 Timer,并用调用 start()方法启动 Timer,如果 timer 不为空,直接调用 restart()方法启动 timer。在 这里我们需要明白的是,第一次进行游戏时,timer 为 null,就需要进行创建,当进行第二次游戏的时候, timer 非空,由于游戏停止(胜利或者失败),因此需要调用 restart 方法重新启动。由于我们是在 BallService 控制游戏的,也就意味着进行第二次游戏的时候,就需要再次调用 BallFrame 的 initialize 方法初始化游戏。 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·10· 接下来再增加事件监听器,先使用 JFrame 的 keyKeyListeners()方法获取本窗口的 KeyLister 数组, 如果这个数组的长度为空,说明本窗口并没有添加到任何 KeyListener,所以就创建一个 KeyAdapter (为 JFrame 创建一个键盘监听器)并重写 KeyAdapter 类的 void keyPressed(KeyEvent ke)方法,这 个方法用来监听键盘的按键是否有按下,如果有的话,就需要调用 BallService 的 setStickPos 方法。当 我们去实现 setStickPos 方法的时候,就需要设置小球为运动状态,启动弹球游戏就意味着小球开始进 行运动。当我们在游戏中按下左右键的时候,同时需要移动挡板,启动游戏后,我们并不需要关心小球 的移动,仅仅设置小球的运动状态,换言之,setStickPos 方法只是处理挡板的移动,小球的运动让 BallService 的 run 处理(run 方法 100 毫秒执行一次)。 4.5 挡板、小球、砖块、道具 在这个设计中,挡板、小球、砖块与砖块中所包含的道具都有一个共同的父类 BallComponet,可 以使用父类的 setX 与 setY 方法设置坐标,也可以使用 getX 与 getY 方法获取坐标,还可以使用 getImage 方法获取图片,并且父类根据不同的情况提供了几个不同的构造器, 4.5.1 挡板(Stick类) 此类提供一个以画板的宽、高和挡板的图片路径为参数的构造器,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\Stick.java public Stick( int panelWidth , int panelHeight, String path ) throws IOException { //调用父构造器 super( panelWidth, panelHeight, path ); //设置 y 坐标 this.setY( panelHeight - super.getImage().getHeight( null ) ); //设置原本的长度 this.preWidth = super.getImage().getWidth( null ); } 首先调用父类的 BallComponent(int x, int y, String path)构造器,把此对象的 x 坐标设置到画板中间 的位置,并且使用 javax.imageio.ImageIO 的 read 方法去读取磁盘中的图片文件。接下来把 y 坐标设置 到画板的底部,再根据读取出来的图片的宽度去设置 Stick 对象的初始长度属性。在从磁盘读取图片的 过程是一个 IO 操作,所以会抛出 IOException,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallComponent.java public BallComponent( int panelWidth , int panelHeight, String path ) throws IOException { super(); //读取图片 this.image = ImageIO.read( new File( path ) ); //设置 x 坐标 this.x = (int)( ( panelWidth - image.getWidth( null ) ) / 2 ); } 由于挡板的长度可能会改变,所以 Stick 类有的个 int 类型的 preWidth 属性,代表挡板的长度,并 定义一个 final int 类型的 SPEED 属性,代表挡板的移动速度,每次移动,x 坐标都会向左或者向右移动 SPEED 个坐标位置,需要为 preWidth 属性提供 setter 与 getter 方法。 实现了挡板类后,我们可以实现 BallService 的 draw 方法,先将挡板“画”到 BallPanel 中,并在 BallPanel 中调用 BallService 的 draw 方法,以下是 BallService 的 draw 方法的部分实现。 代码清单:code\ball\src\org\crazyit\ball\BallService.java // 如果赢了 if (isWon()) { Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·11· // 绘制赢的图片 g.drawImage(won.getImage(), won.getX(), won.getY(), width, height - 10, null); } else if (ball.isStop()) { // 绘制游戏结束图像 g.drawImage(gameOver.getImage(), gameOver.getX(), gameOver.getY(), width, height - 10, null); } else { // 清除原来的图像 g.clearRect(0, 0, width, height); // 绘制挡板图像 g.drawImage(stick.getImage(), stick.getX(), stick.getY(), stick .getPreWidth(), stick.getImage().getHeight(null), null); } 到此,我们可以运行程序查看创建挡板后的效果,具体的效果如图 4.6 所示。 图 4.6 创建挡板 4.5.2 小球(Ball类) 此类提供一个以画板的宽、高、挡板高度与小球的图片路径为参数的构造器,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\Ball.java public Ball( int panelWidth , int panelHeight , int offset, String path ) throws IOException { Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·12· //调用父构造器 super( panelWidth, panelHeight, path ); //设置 y 坐标 this.setY( panelHeight - super.getImage().getHeight( null ) - offset ); } 首先调用父类的 BallComponent(int x, int y, String path)构造器,把此对象的 x 坐标设置到画板中间 的位置,并且使用 javax.imageio.ImageIO 的 read 方法去读取磁盘中的图片文件。接下来把 y 坐标设置 到板位上面的位置。 在这里,小球对象有两种状态,一种是小球是否开始运动,这种状态下,如果小球没有开始运动, 代表准备开始游戏,反则代表游戏已经开始,没游戏没结束之前,小球就一直运动;一个是小球是否结 束运动,如果小球结束运动,代表游戏已经结束,小球不能再运动,挡板也不再受玩家的控制,反则代 表正在游戏中。我们在 Ball 中提供一个 started 的属性来标识这两种状态。那么当游戏开始时,就可以 直接设置 Ball 的 started 属性为 true。 我们把小球的速度方向分为横与竖两个方向,所以这里用 int 类型的 speedX 与 speedY 两个属性 去代表小球的横向方向与竖向方向,并增加相应的 setter 与 getter 方法。为 Ball 对象添加了相关的属性 后,我们可以在 BallService 的 draw 方法中,将一个小球“画”到 BallPanel 中。具体的效果如图 4.7 所示。 g.drawImage(ball.getImage(), ball.getX(), ball.getY(), null); 图 4.7 画小球图片 4.5.3 道具(Magic及其子类) Magic 类是一个抽像类,它是 BallComponet 的子类,又是 LongMagic 与 ShortMagic 的父类,此 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·13· 类只有一个抽象方法 magicDo,用来完成道具的功能,提供一个使用图片路径与 x、y 坐标为参数的构 造器供其子类继承,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\Magic.java public Magic( String path, int x , int y ) throws IOException { super( path, x, y ); } public abstract void magicDo( Stick stick ); 这个构造器只调用父类 BallComponent 的构造器,去设置道具的表现图片与初始坐标。加粗的一 行代码是用来完成道具功能的抽像方法,这里只有定义,没有实现,让其子类去实现。Magic 类有两个 子类:LongMagic 与 ShortMagic,这两个道具的功能是使游戏中的挡板变长和变短,功能都在 magicDo 的实现方法中实现,首先看 LongMagic 类实现的 magicDo 方法。 代码清单:code\ball\src\org\crazyit\ball\LongMagic.java public void magicDo( Stick stick ) { double imageWidth = stick.getImage().getWidth(null); //如果挡板没有变长过 if( stick.getPreWidth() <= imageWidth ) { //将挡板的长度改为双倍 stick.setPreWidth( (int)(stick.getPreWidth() * 2 ) ); } } 首先获取挡板图片的长度,再拿这个长度和挡板现在的长度比较,如果挡板的长度小于或者等于图 片的长度,说明挡板的长度没有增加过,所以就调用 Stick 的 setPreWidth 方法把挡板的长度设置为又 倍,下面再看 ShortMagic 实现的 magicDo 方法。 代码清单:code\ball\src\org\crazyit\ball\ShortMagic.java public void magicDo( Stick stick ) { double imageWidth = stick.getImage().getWidth(null); //如果挡板没有变短过 if( stick.getPreWidth() >= imageWidth ) { //将挡板的宽度改为一半 stick.setPreWidth( (int)(stick.getPreWidth() * 0.5 ) ); } } 这里的流程和 LongMagic 中实现的方法相似,首先获取挡板图片的长度,如果现在的长度大于或 者等于图片的长度,说明挡板的长度没有减少过,就调用 Stick 的 setPreWidth 方法把挡板的长度设置 为一半。 4.5.4 砖块(Brick类) Brick 类是 BallComponet 的一个子类,用一个 boolean 类型的属性 disalbe 去标志对象是否有效果, 还包含一个 Magic 类型的属性 magic,在构造器中初始化这个属性,见以下代码中。 代码清单:code\ball\src\org\crazyit\ball\Brick.java public Brick(String path, int type, int x, int y ) throws IOException { super(path); if( type == Brick.MAGIC_LONG_TYPE ) { this.magic = new LongMagic( "img/long.gif", x, y ); } else if( type == Brick.MAGIC_SHORT_TYPE ) { this.magic = new ShortMagic( "img/short.gif", x, y ); } if( this.magic != null ) { Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·14· this.magic.setX( x ); this.magic.setY( y ); } } 在这个构造器的参数中,除了读取图片文件的 path 参数和对象坐标的 x 与 y 参数,还有一个 int 类 型的参数 type,构造器主要是根据这个参数的值去决定此对象包含的 Magic,如是 type 等于 Brick.MAGIC_LONG_TYPE , magic 就是一个 LongMaigc 对象,如果 type 等于 Brick.MAGIC_SHORT_TYPE,magic 就是一个 ShortMagic 对象,如果 magic 不是空值 ,就设置 magic 的 x 与 y 坐标。当然,同样需要为 magic 与 disalbe 属性增加相应的 setter 和 getter 方法。 4.6 BallService类实现 BallService 被定义成一个专门处理此游戏逻辑功能的类,包含处理小球的移动、处理挡板的移动、 初始化砖块与道具、判断玩家的输赢,判断游戏中的图片元素是否有碰撞,把图片绘制到画板等功能。 由于 BallService 负责处理几乎全部的游戏逻辑,那么该类中就需要维护界面所有的组件:小球对象、 挡板对象、砖块的二维数组等。BallService 中所有的方法都是对这些对象进行处理,修改它们的相关属 性或者执行相关的行为。 4.6.1 创建与设置砖块 在本游戏的设计中,为了简单起见,没有加入游戏关卡的概念,没有去设置每一关的砖块与道具等 东西的分布,所以,游戏开始的时候,我们会用一个方法名为 createBrickArr 的方法去随机产生砖块与 道具,先看以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallService.java public Brick[][] createBrickArr( String path, int xSize, int ySize ) throws IOException { //创建一个 Brick[][] Brick[][] bricks = new Brick[xSize][ySize]; int x = 0; int y = 0; int random = 0; int imageSize = 28; boolean isDisable = false; //迭代初始化数组 for ( int i = 0 ; i 0.8 ? true : false; if( isDisable ){ random = 0; } Brick brick = new Brick( path, random, x, y ); brick.setDisable(isDisable); //设置 x 坐标 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·15· brick.setX( x ); //设置 y 坐标 brick.setY( y ); bricks[i][j] = brick; } } return bricks; } 这个方法的返回类型是 Brick[][],也就是说是一个 Brick 类型的二维数组,bricks[i][j]代表砖块在第 i 行第 j 列。有三个参数:String 类型的图片文件路径 path,还有代表返回数组大小的 xSize 与 ySize, 这两个参数是 int 类型。首先,以 xSize 与 ySize 去创建一个 Brick[][]类型的变量,接下来遍历这个数组, 在遍历的过程中,每次先创建一个砖块,然后随机设置砖块对象的 disable 属性,disable 属性为 true 的砖块将不会被显示,创建砖块的过程中,也是随机创建砖块所包含的道具,然后再设置这个砖块的 x 与 y 坐标,最后把新创建的砖块对象赋给 bricks[i][j]。遍历完这个数组后,便把 bricks 返回。这样就完 成创建游戏中所有砖块与道具的过程。 创建了砖块的二维数组后,我们就需要将这个二维数组“画”到 BallPanel 中,为 BallService 的 draw 加入相关的实现即可。 代码清单:code\ball\src\org\crazyit\ball\BallService.java // 迭代绘制砖块图像 for (int i = 0; i < bricks.length; i++) { for (int j = 0; j < bricks[i].length; j++) { BallComponent magic = bricks[i][j].getMagic(); // 如果这个砖块图像对像是有效的 if (!bricks[i][j].isDisable()) { // 里面的数字 1 为砖块图像间的间隙 g.drawImage(bricks[i][j].getImage(), bricks[i][j] .getX(), bricks[i][j].getY(), bricks[i][j] .getImage().getWidth(null) - 1, bricks[i][j] .getImage().getHeight(null) - 1, null); } else if (magic != null && magic.getY() < height) { g.drawImage(magic.getImage(), magic.getX(), magic .getY(), null); } } } 同样地,使用嵌套循环将砖块的二维数组“画”到 BallPanel 中,在绘画该二维数组的时候,要判 断砖块是否有效。需要注意的是,必须是游戏中时才进行绘画,当游戏结束(胜利、失败)或者小球停 止运动的时候,我们并不需要绘画此二维数组。绘画砖块的具体效果如图 4.8 所示。 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·16· 图 4.8 创建砖块 4.6.2 设置挡板的位置(移动挡板) 挡板的移动主要是依靠监听玩家的键盘操作,然后做出相应的反应,去改变挡板的坐标位置,所以 需要以一个 KeyEvent 对象做为这个方法的参数,在方法内可以通过这个对象的 getKeyCode()方法去获 取玩家所按下的键盘按键,先看以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallService.java public void setStickPos( KeyEvent ke ) { //把弹球的运动状态设为 true ball.setStarted( true ); //如果是左方向键 if( ke.getKeyCode() == KeyEvent.VK_LEFT ) { if( stick.getX() - stick.SPEED > 0 ) { //x坐标向左移动 stick.setX( stick.getX() - stick.SPEED ); } } //如果是右方向键 if( ke.getKeyCode() == KeyEvent.VK_RIGHT ) { if( stick.getX() + stick.SPEED < width - stick.getPreWidth() ) { //x坐标向右移动 stick.setX( stick.getX() + stick.SPEED ); Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·17· //ballFrame.getBallGame().reStart( ballFrame ); } } //如果是 F2 键 if( ke.getKeyCode() == KeyEvent.VK_F2 ) { //初始化 ballFrame try { ballFrame.initialize(); } catch ( IOException e ) { System.out.println( e.getMessage() ); } } } 如果玩家按下的是左键,也就是 ke.getKeyCode() 等于 KeyEvent.VK_LEFT,就先检查挡板是否 已经在游戏面板的最左边,如果不是,就把挡板的位置向左移动 Stick.SPEED 个位置(SPEED 代表挡 板的移动速度),否则不做任何操作。如果玩家按下的是右键,处理方式与左键类似,只不过是方向相 反。如果玩家按下的是 F2 键(这里定义 F2 键是重新开始游戏),就调用 BallFrame 对象的 initialize 方 法是重新初始化界面。实现挡板的移动较为简单,只需要设置挡板对象的坐标并判断是否越界面即可。 4.6.3 小球与砖块碰撞 图 4.9 小球与砖块碰撞 在游戏中,如果运行的小球碰到砖块,就要把砖块消掉,所以我们需要判断小球与砖块是否有碰撞, 假设小球圆心的坐标是(x1,y1),砖块中间的坐标是(x2,y2),砖块的一半边长是 n,小球的半径是 r,那么,如果(x1,y1)与(x2,y2)的距离小于 n+r,砖块与小球就处于碰撞的状态,见图 4.9 与以 下代码。 代码清单:code\ball\src\org\crazyit\ball\BallService.java public boolean isHitBrick( Brick brick ) { if ( brick.isDisable() ) { return false; } //ball 的圆心 x 坐标 double ballX = ball.getX() + ball.getImage().getWidth(null) / 2; //ball 的圆心 y 坐标 double ballY = ball.getY() + ball.getImage().getHeight(null) / 2; //brick 的中心 x 坐标 double brickX = brick.getX() Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·18· + brick.getImage().getWidth(null)/2; //brick 的中心 y 坐标 double brickY = brick.getY() + brick.getImage().getHeight(null)/2; //两个坐标点的距离 double distance = Math.sqrt( Math.pow( ballX - brickX, 2 ) + Math.pow( ballY -brickY, 2 )); //如果两个图形重叠,返回 true; if( distance < ( ball.getImage().getWidth(null) + brick.getImage().getWidth(null) )/ 2) { //使 brick 无效 brick.setDisable( true ); return true; } return false; } 粗体代码部分就是以(x1,y1),(x2,y2)两个点的距离与 n、r 的和比较,如果这个距离小于和, 就调用 Brick 对象的 setDisable 方法把 Brick 对象设置为无效,并返回 true。我们将砖块的二维数组“画” 到 BallPanel 中的时候(遍历二维数组),得到每一个砖块对象,都需判断该对象的 disable 属性,如果 该属性为 true,则表示这块砖块仍然处在原来的位置,如果该属性为 false,则表示这块砖块已经被小 球碰撞,并出“跌落”了相应的道具,在 draw 的时候,就需要将道具的图片画到界面中(BallPanel), 小球碰撞砖块的效果如图 4.10 所示。 图 4.10 小球与砖块碰撞 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·19· 如图 4.10 所示,当小球与砖块发生碰撞的时候,砖块就会变成道具,并且该道具会进行下落。道 具的移动、道具与挡板的碰撞我们将在下面的章节中描述。 4.6.4 小球、道具与挡板碰撞 我们需要实现 BallService 的 isHitStick 方法,该方法判断小球、道具与挡板是否发生了碰撞,只要 它们发生了碰撞,该方法就需要返回 true。isHitStick 方法只要判断是否发生了碰撞,至于发生碰撞后所 需要处理的事情,并不由该方法进行处理。在这里,由于挡板是长方形的,而且挡板的 y 坐标是不变的, 所以可以不使用上节判断小球与砖块碰撞的方法。假设挡板的坐标是指这个长方形的左上角,用(x1, y1)表示,挡板的长度为 n,那么,只要小球或者道具的 x 坐标处于 x1 与 x1+n 之间(也就是处于挡板 的范围内),y 坐标大于 y1,那么就可以判断它们在碰撞,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallService.java public boolean isHitStick( BallComponent bc ) { //获取图片对象 Image tempImage = bc.getImage(); //如果与挡板有碰撞 if( bc.getX() + tempImage.getWidth(null) > stick.getX() && bc.getX() < stick.getX() + stick.getPreWidth() && bc.getY() + tempImage.getHeight(null) > stick.getY() ) { return true; } return false; } 这个方法中的参数 bc 代表的是小球或者道具,加粗代码部分是判断它们是否有碰撞,bc.getX() + tempImage.getWidth(null) > stick.getX()&& bc.getX() < stick.getX() + stick.getPreWidth()是确认 bc 的 x 坐标是不是处于挡板的范围内,bc.getY() + tempImage.getHeight(null) > stick.getY()是确认 bc 的 y 坐标是否大于挡板的 y 坐标。 4.6.5 道具的移动 当小球与砖块发生碰撞后,砖块将会变成道具(如图 4.10 所示)。前面的章节中讲到这个游戏有两 个道具,LongMagic 与 ShortMagic,作用分别是使挡板就长或者变短,而道具是保存在 Brick 对象中, 所以我们需要遍历 bricks 数组中的所有 Brick 对象,如果 Brick 对象的状态是 disable 为 true(也就是说 砖块被小球消掉),而且这个 Brick 对象中有 Magic 对象不为 null,并且 Magic 对象的 y 坐标小于小于 画板的高度 height(这里意思是说这个道具还在画板的范围之内),那么,便以 Magic 对象的速度每次 增加 magic.getSpeed()个 y 坐标值,达到道具向下移动的效果,见如下代码。 代码清单:code\ball\src\org\crazyit\ball\BallService.java public void setMagicPos() { for ( int i = 0 ; i < bricks.length ; i++ ) { for ( int j = 0 ; j < bricks[i].length ; j++ ) { //获取 magic Magic magic = bricks[i][j].getMagic(); if( magic != null ) { //如果这个 brick 的状态是无效的 if( bricks[i][j].isDisable() && magic.getY() < height ) { //设置 magic 的 y 坐标向下增加 magic.setY( magic.getY() + magic.getSpeed() ); //设置挡板的宽度 Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·20· setStickWidth( magic ); } } } } } 以上的代码实现了 setMagicPos 方法,该方法每执行一次,都会改变道具的位置,因此,我们可以 在 BallService 的 run 方法调用 setMagicPos 方法(run 方法每 100 毫秒执行一次),如果砖块被消除的 话,界面中就会出现下落的道具,具体的效果如图 4.11 所示。 图 4.11 道具的下落 4.6.6 改变挡板的长度(道具的作用) 在 4.6.4 中实现了判断小球与挡板、砖块是否发生碰撞的方法,因此在这里改变挡板长度,实现起 来将会十分简单,只要判断道具与挡板是否有碰撞(调用 isHitStick 方法),如果挡板与“掉下来”的道 具发生碰撞,便调用 Magic 对象的 magicDo 方法,magicDo 方法会将挡板的长度,见以下代码。 代码清单:code\ball\src\org\crazyit\ball\BallService.java public void setStickWidth( Magic magic ) { if( isHitStick( magic ) ) { //道具的作用 magic.magicDo( stick ); } } Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·21· 在本章中只涉及两个道具:缩短挡板和加长挡板,并且我们在 Magic 的两个子类中已经对其提供了 相应的实现,因此我们在以上的代码中可以直接调用 Magic 的 magicDo 方法,这样就可以将当前的挡 板加长或者缩短。Magic 的两个子类已经在 4.5.3 中实现,接下来我们可以运行游戏查看效果,游戏的 具体效果如图 4.12 所示。 图 4.12 道具的效果 如图 4.12 所示,当游戏中的挡板接收到绿色的道具时(加长挡板),挡板的宽度就发生了改变,这 是由于我们在 Magic 的子类(LongMagic)中设置了挡板的宽度。 4.6.7 判断是否已经通关 在本游戏中,是否通关的标准是,小球是否已经将所有的砖块清除。因此我们实现该功能的时候, 就需要在 BallService 中对砖块的二维数进行遍历,如果该数组中所有的砖块对象的 disable 属性都为御 true 的话,就意味着所有的砖块都已经被“击落”,这一关游戏通过;如果数组中的某一个砖块的 disable 属性为 false 的话,游戏需要继续进行。 代码清单:code\ball\src\org\crazyit\ball\BallService.java public boolean isWon() { //如果消了全部砖块,则为赢 for ( int i = 0 ; i < bricks.length ; i++ ) { for ( int j = 0 ; j < bricks[i].length ; j++ ) { if(!bricks[i][j].isDisable() ) { return false; } } Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·22· } return true; } 实现了 isWon 方法后,我们可以启动游戏进行测试,由于我们在前面章节实现 draw 方法的时候, 就需要调用 isWon 方法来判断游戏是否胜利,如果胜利的话,就需要将游戏胜利的图片“画“到 BallPanel 中,当游戏胜利时,具体的效果如图 4.13 所示。 图 4.13 游戏胜利 到此,我们的桌面弹球游戏就已经全部实现了,可以运行游戏进行测试。我们这个桌面弹球并没有 任何复杂的功能,只是在小球运动的过程中将砖块消除并转化成相应的道具,最后判断游戏是否已经胜 利,我们可以在此基础上开发出更多有趣的功能,例如加入分数的计算、加入多种道具等。 4.7 功能改进设计 从前面的几小节可以看到,本游戏并没有加入关卡、计分等概念,而且道具的种类比较少,整个游 戏玩起来会比较枯燥,其实我们不必做太多的工作,就能把这些概念加到游戏中去。如果我们需要加入 更多的游戏道具,可以继承 Magic 对象并实现 magicDo 方法。如果需要更改砖块的排列,可以编写程 序动态创建砖块的二维数组。 4.7.1 关卡 我们可以设计每一关砖块的不同排列方式,与及里面所包括的道具,可以怎么去设置这个东西?好, Download at http://www.pin5i.com/ 第 4 章 桌面弹球 ·23· 我们可以使用最简单的一个 txt 文本去设置每关的砖块与道具,例如使用一个“*”号去代表砖块,然后 在“*”号旁边加上一个小括号去加入道具的类型,见以下文档: *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(1) *(0) *(0) *(4) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(2) *(0) *(0) *(3) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) *(0) 然后我们就可以通过 IO 操作去读取与分析这个文件,分析后便设置到 Brick 类型的数组里面。 4.7.2 计分 可以简单实现一个即时计分的功能也比较简单,可以给在 BallGame 类中增加一个静态的 int 属性 去保存分数,然后再砖块与各个道具类中增加一个分数的属性,如果有碰撞的时候,便把这些分数加到 BallGame 的计分器中。 4.7.3 道具 由于我们的道具使用了一个 Magic 抽像类作为接口,增加新的道具也比较简单,例如我现在要增加 一个一次能把所有砖块清掉的道具,那么,我可以新建一个叫 KillAllMagic 的类,继承 Magic 类,并实 现里面的 doMagic 方法,见以下代码: public void magicDo( Stick stick ) { for ( int i = 0 ; i < bricks.length ; i++ ) { for ( int j = 0 ; j < bricks[i].length ; j++ ) { bricks.[i][i].setDisable(true); } } } 把所有的砖块都设置为无效果,就清除掉所有的砖块了。 4.8 本章小结 本章主要是通过一个弹球游戏的基本实现,向读者讲解 Java 的画图方法,主要是使用 Graphics 对 像的 drawImage 方法去画图。在开发桌面弹球的过程中,我们将界面中的砖块抽象成一个二维数组, 将游戏中的相关组件(小球、挡板)都抽象成为一个对象,并为 JFrame 提供了键盘监听器,当监听器 接收到按键信息后,就会调用相关的方法去操作游戏中的各个对象,并将这些对象画到界面中。本章主 要详细描述了键盘事件监听器、在 Swing 组件中画图等相关知识点。 Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 第 6 章 仿Windows画图 6.1 画图软件概述 我们平时所使用的图形处理工具有 PhotoShop、Windows 画图工具等,其中 PhotoShop 是一款非 常强大的图形处理工具,Windows 画图工具则是一款较为简单的画图工具,功能较为简单,相信经常使 用 Windows 系统的读者都比较熟悉,是一种比较简单与具有代表性的画图工具,虽然功能不够强大, 但具有大多图片处理程序所必需的基本功能:铅笔画图、各种数学函数图形、填色、取色、橡皮擦等等 功能。 本文将使用 Java 语言去实现 Windows 的大部分功能,包括取色、各种数学函数图形、橡皮擦、喷 枪、颜色编辑等功能,除了这些绘图功能,还会实现打开图片、保存图片等文件操作功能。画图工具的 最终效果如图 6.1 所示。 图 6.1 画图 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·2· 6.2 画图工具原理 我们可以考虑一下如何使用 Java 程序去实现这些功能,如果需要进行画图,那么我们当然就需要 Graphics 类来向界面画上相应的内容,如果需要进行文件操作,我们可以使用 Java 的 IO 来实现。 6.2.1 画线 在 Graphics 中许多方法,其中有一个 drawLine 的方法,使用该方法我们可以将线画到界面中,该 方法中有四个参数,分别是线的开始点坐标(x、y)与线的结束点的坐标(x、y),因此,如果需要调 用该方法来画线的话,需要捕获用户在界面中按下鼠标的点坐标与放开鼠标时的点坐标。当调用了 drawLine 方法后,我们再对界面的组件进行一次 repaint 就可以实现画线的功能。 6.2.2 其他画图功能 画线我们可以调用 drawLine 方法,那么画椭圆的话可以调用 Graphics 类的 drawOval 方法,如果 需要画矩形的话,可以调用 drawRect 方法。如果需要实现橡皮擦的功能,可以将鼠标经过的区域画上 白色的线。实现喷涂的功能,可以在当前鼠标点击的区域中画上相应的点,Graphics 类中提供了一个 fillRect 的方法,我们可以利用该方法去填充当前的区域。除这些画图功能外,我们还需要提供一个刷子 的功能,刷子功能可以看作是一个画笔功能,只是使用刷子画出来的线比画笔更粗而已。 6.2.3 保存图片功能 我们可以在画图的界面中保存一个 BufferedImage 的对象,那么可以通过这个对象得到一个 Graphics 对象,得到该对象,就可以使用它的 drawXXX 的方法来进行画图,到最终需要进行保存的时 候,我们可以将这个 BufferedImage 对象通过 ImageIO 的 writer 方法写到文件中。 只要知道使用 Java 程序来实现画图的原理,实现程序就十分的简单,关键是如何计算各个工具的 有效范围。 6.3 创建画图工具的各个对象 使用 Windows 的画图软件,发现在编辑图片的时候,有个相似的过程,首先是用鼠标选择需要使 用的工具,然后就在画板中用鼠标进行拖动、点击等动作,画板会显示出相应工具的所产生的效果,所 以在这里设计一个命名为 Tool 的接口,这个接口是所有工具的接口,里面定义了一系列的鼠标动作。 实际上在这个画图工具中,所有的工具都必须遵守一定的规范,即使用鼠标进行拖动、点击等动作,当 需要定义某些规范的时候,我们可以将这些规范写到一个接口中,那么这个接口所有的实现类都要遵守 这个规范,这也是本章将工具作为一个接口的原因。 在本章中,画图软件的主界面使用 ImageFrame,该类继承于 JFrame,该类会初始化画图软件的 各种组件。由于我们有选择打开图片文件的操作,所以会有一个扩展 javax.swing.JFileChooser 类(为 选择文件提供一种简单的窗口选择机制)的 ImageFileChooser 类,用于处理选择文件时的过滤等操作。 因为绘图的功能已经全部由 Tool 的实现类去实现,所以除了绘图外的其它功能的逻辑实现,就全 部放到 ImageService 类中,本章中的类图如图 6.2 所示。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·3· 图 6.2 画图软件类图 6.3.1 工具接口Tool 从图 6.2 中可以看到,工具接口 Tool 定义了鼠标动作的四个方法,分别是拖动 mouseDrapped()、 移动 mouseMoved()、松 开 mouseReleased()、按 下 mousePressed()、点 击 mouseClicked()五个动作, 并用 String 类型的常量属性来定义工具的类型。这个接口只有一个实现类 AbstractTool,而每个工具类 都是去扩展 AbstractTool 类,在图中表现为 Tool1、Tool2…….ToolN。以下是此接口定义的属性与方法:  static final String ARROW_TOOL,箭头工具类型。  static final String PENCIL_TOOL,铅笔工具类型。  static final String BRUSH_TOOL ,刷子工具类型。  static final String CUT_TOOL,剪切工具类型。  static final String ERASER_TOOL,橡皮擦工具类型。  static final String LINE_TOOL ,直线工具类型。  static final String RECT_TOOL ,矩形工具类型。  static final String POLYGON_TOOL ,多边形工具类型。  static final String ROUND_TOOL,椭圆形工具类型。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·4·  static final String ROUNDRECT_TOOL ,圆角矩形工具类型。  static final String ATOMIZER_TOOL,喷墨工具类型。  static final String COLORPICKED_TOOL ,颜色选择工具类型。  void mouseDragged( MouseEvent e),当捕捉到鼠标拖动时调用的方法定义。  void mouseMoved MouseEvent e),当捕捉到鼠标移动时调用的方法定义。  void mouseReleased MouseEvent e),当捕捉到鼠标松开时调用的方法定义。  void mousePressed MouseEvent e),当捕捉到鼠标按下时调用的方法定义。  void mouseClicked MouseEvent e),当捕捉到鼠标点击时调用的方法定义。 从接口中定义的属性与方法可以看出,在接口中只定义工具的类型,还有定义工具鼠标动作的方法, 就不再做任何的事情,这些方法由它的实现类去具体实现。在某个对象中需要使用到 Tool 的实现类时, 我们可以使用一个 ToolFactor 的类来得到具体的某个 Tool 实现类,ToolFactory 返回的都是 Tool 接口, 因此使用者根本不需要关心使用的是哪一个实现类,当代码发生改变的时候,也可以减少代码的修改。 换言之,使用者只与 ToolFactory 耦合。 6.3.2 Tool的实现类AbstractTool AbstractTool 是 Tool 的实现类,也是一个抽像类,所以并不能被创建,只能被继承。此类实现 Tool 中定义的所有方法,并扩展了其它方法,让其子类继承或者重写。该类中为其他的工具类提供了大部分 的实现,那么它的子类就可以不必再做重复的实现,只关心与本类相关的逻辑,AbstractTool 所定义的 方法如下:  AbstractTool( ImageFrame frame ),让子类调用的构造器,以 ImagerFrame 为参数,用于获 取画板的属性。  AbstractTool( ImageFrame frame, String path ),让子类调用的构造器,以 ImagerFrame 为参 数,path 是工具的图标路径。  Cursor getDefaultCursor(),此方法获取默认鼠标指针的形状。  void setDefaultCursor( Cursor cursor ),设置鼠标指针指针,以 Cursor 为参数。  void setPressX(int x),设置鼠标按下的 x 坐标,int 类型的 x 为鼠标的 x 坐标。  void setPressY(int y),设置鼠标按下的 y 坐标,int 类型的 y 为鼠标的 y 坐标。  int getPressX(),返回上次鼠标按下的 x 坐标。  int getPressY(),返回上次鼠标按下的 y 坐标。  void mouseDragged( MouseEvent e),实现当捕捉到鼠标拖动时调用的方法。  void mouseMoved MouseEvent e),实现当捕捉到鼠标移动时调用的方法。  void mouseReleased MouseEvent e),实现当捕捉到鼠标松开时调用的方法。  void mousePressed MouseEvent e),实现当捕捉到鼠标按下时调用的方法。  void mouseClicked MouseEvent e),实现当捕捉到鼠标点击时调用的方法。  void createShape( MouseEvent e ,Graphics g ),画图形,通过参数 e 去获取鼠标的轨迹,并 用 Graphics 类型的对象 g 去画图形。  void draw(Graphics g, int x1, int y1, int x2, int y2),画图形,g 是用来画图形的对象,(x1,y1) 是起点坐标,(x2,y2)是终点坐标。这个方法是一个空的方法,主要是由其子类实现。  void dragBorder( MouseEvent e ),拖动边界,也就是改变画布的大小。 接口用于定义规范,那么抽象类就是用于实现部分的规范。当我们在编写程序的过程中发现,有一 类对象都必须遵守某些行为,那么我们可以将这些行为都当作规范,写到接口中;如果有些对象实现了 部分的行为,其他的行为更希望让它的子类去实现,那么我们可以将这些对象作为一个抽象类。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·5· 6.3.3 AbstractTool的子类 AbstractTool 一共有 ArrowTool(箭头)、PencilTool(铅笔)、BrushTool(刷子)、EraserTool(橡 皮擦)、LineTool(直线)、RectTool(矩形)、PolygonTool(多边形)、RoundTool(椭圆形)、RoundRectTool (圆矩形)、AtomizerTool(喷墨)、ColorPickedTool(颜色选择)11 个子类,这些子类都是根据自己的 情况重写 AbstractTool 的部分或者全部方法。由于在本设计中,想这些类在外表现为 Tool 接口,不希 望被直接实例化,所以此类的构造器私有,并提供一个静态的方法获取 Tool 类型的此类实现,如下:  static Tool getInstance( ImageFrame frame ),获取 Tool 类型的本类实例。 由于我们并不希望外界可以直接使用 new 关键字来创建这些类的实例,因此在这里使用了单态模 式,所有的子类都提供了 getInstance 的方法来返回本类的实例,并且所有的构造器都是私有的。在下 面的章节中,将会讲解如何实现这 11 个子类。 6.3.4 界面类ImageFrame 这个画图工具的界面的主要放在这个类中实现,此类有以下方法:  void init(),设置化主界面。  JPanel getDrawSpace(),获取画布。  JPanel getColorPanel(),获取颜色面板。  MyImage getBufferedImage(),获取画板中的图片。  void setBufferedImage( MyImage bufferedImage),设置画板图片,MyImage 是 BufferedImage 的一个扩展类。  void setTool( Tool tool ),设置正在使用的工具。  Tool getTool(),获取正在使用的工具。  JColorChooser getColorChooser(),获取颜色选择器。  JPanel createColorPanel(),创建一个简单的颜色选择面板。  JPanel getCurrentColorPanel(),获取颜色选择面板。  Dimension getScreenSize(),获取 Dimension 类形 screenSize,screenSize 主要用于获取画 板的高与宽等属性。  void createMenuBar(),创建文件、查看、颜色、帮助等菜单栏。  JPanel createDrawSpace(),创建画板。  JPanel createToolPanel(),创建用于画图的工具栏。 另外,此类有一个继承 JPanel 的内部类 DrawSpace,用于充当画图工具的画板,此内部类只有一 个方法,就是一个用于绘图的方法,如下:  void paint( Graphics g ),画图。 界面类类似于我们 MVC 模式中的 V(视图),该类并不负责处理任何的逻辑,主要负责从界面接收 数据,再传递给具体的业务类,让其进行相关的处理。在本章,负责处理画图功能的主要是 Tool 的实 现类。 6.3.5 业务逻辑类ImageService 除鼠标的画图功能外(画图功能由 Tool 的实现类完成),初始化画板、图片的新建打开与保存、各 种面板的显示与隐藏、颜色的编辑、整个界面的刷新、菜单等业务逻辑都放在这个类中实现,该类包含 了以下的方法:  initDrawSpace( ImageFrame frame ),初始化画板。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·6·  Dimension getScreenSize(),获取屏幕的分辨率。  repaint( Graphics g, BufferedImage bufferedImage ),刷新界面。  static Cursor createCursor( String path ),创建鼠标图形。Path 是鼠标图形的路径。  void save( boolean b, ImageFrame frame ),保存图片。  void open( ImageFrame frame ),打开图片。  void createGraphics( ImageFrame frame ),创建新图片并初始化。  void editColor( ImageFrame frame ),编辑颜色。  void exit( ImageFrame frame ),退出画图软件。  void menuDo( ImageFrame frame, String cmd ),处理菜单事件。 除了画图功能外,ImageService 负责了整个画图工具的其他功能,在本章中,该类是无状态的 Java 对象,它并没有保存一些状态属性。 6.3.6 文件选择类ImageFileChooser ImageFileChooser 类继承了 JFleChooser 类,JFleChooser 是 Java 提供的一个简单的文件选择机 制,我们这里扩展这个类,是为了增加我们自己的文件过滤器。见以下方法:  String getSuf(),获取文件的后缀名。  void addFilter(),增加文件过滤器,这里只选择图片类形的文件。 这个类中有一个继承 FileFilter 类的内部类 MyFileFilter,这个内部类主要是重写 FileFilter 的 accept 方法,判断是否是合法的文件类型,如下:  boolean accept( File f ),判断是否是合法的文件类型。 在本小节中,我们主要确定了画图工具所涉及的几个对象,并定义了他们的行为与属性,在下面章 节中,我们只要按照这些定义好的方法,逐步去实现我们的画图工具。 6.4 主界面实现 在这个软件中,主界面主要由左边的工具栏、下面的颜色选择板、占大部分区域的画图区、菜单等 几部分组成,用 BorderLayou 的排板方式,左边工具栏在 BorderLayou.WEST 位置,画图区在 BorderLayout.CENTER 位置,颜色选择面板在 BorderLayout.SOUTH 位置。先看主界面的初始化: 6.4.1 初始化界面(init()方法) 首先,设置 JFrame 窗口的标题,接下来初始化画图区域,初始化为白色,然后再获取 PENCIL_TOOL(铅笔)类型的 Tool,创建各种鼠标监听器,并在监听的执行方法中调用 Tool 的相应方法, 最后获取左边工具栏面板、下面菜单栏面板、菜单,并把这些面板与画图获取加到 JFrame 中。见以下 代码。 代码清单:code\image\src\org\crazyit\image\ImageFrame.java public void init() { //设置标题 this.setTitle( "未命名 - 画图" ); //初始化画图 service.initDrawSpace( this ); //设置标题 //获取正在使用的工具 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·7· tool = ToolFactory.getToolInstance( this, PENCIL_TOOL ); //创建鼠标运动监听器 MouseMotionListener motionListener = new MouseMotionAdapter() { //拖动鼠标 public void mouseDragged(MouseEvent e) { tool.mouseDragged( e ); } //移动鼠标 public void mouseMoved(MouseEvent e) { tool.mouseMoved( e ); } }; //创建鼠标监听器 MouseListener mouseListener = new MouseAdapter(){ //松开鼠标 public void mouseReleased( MouseEvent e ) { tool.mouseReleased( e ); } //按下鼠标 public void mousePressed(MouseEvent e) { tool.mousePressed( e ); } //点击鼠标 public void mouseClicked(MouseEvent e) { tool.mouseClicked( e ); } }; drawSpace.addMouseMotionListener( motionListener ); drawSpace.addMouseListener( mouseListener ); createMenuBar(); //以 drawSpace 为 viewport 去创建一个 JScrollPane scroll = new JScrollPane( drawSpace ); //设置 viewport ImageService.setViewport( scroll, drawSpace , bufferedImage.getWidth(), bufferedImage.getHeight() ); //将 panel 加到本 Frame 上面 this.add( scroll, BorderLayout.CENTER ); //this.add( toolPanel, BorderLayout.WEST ); //this.add( colorPanel, BorderLayout.SOUTH ); } 可以看到,这里有两种鼠标监听器,MouseMotionListener 和 MouseListener,MouseMotionListener 主要是监听鼠标的运动动作,我们实现了它的 mouseDragger(鼠标拖动)与 mouseMoved(鼠标移动) 方法,MouseListener 负责监听鼠标的其它动作,我们实现了它的 mouseReleased(松开鼠标)、 mousePressed(按下鼠标)和 mouseClicked(点击鼠标)三个方法。以上代码的黑体部分,这三行代 码分别创建菜单、画图工具栏与颜色选择面板,如何创建我们将在 6.4.3、6.4.4 和 6.4.5 中详细描述。 现在运行画图工具,可以看到效果如图 6.3 所示。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·8· 图 6.3 主界面 6.4.2 获取画板 这是一个画图工具,所以需要一个可以绘图的区域,在这里我们用继承 JPanel 的内部类 DrawSpace 去充当这个绘图区域,见以下代码。 代码清单:code\image\src\org\crazyit\image\ImageFrame.java / / 画图区域 public class DrawSpace extends JPanel { /** * 重写 void paint( Graphics g )方法 * * @param g Graphics * @return void */ public void paint( Graphics g ) { //draw service.repaint( g, bufferedImage ); } } 从上面代码可以看到,这个内部类比较简单,只是继承 JPanel,并重写 JPanel 的 paint 方法,这 里需要注意的是,要调用此方法,并不是直接调用 paint 方法,而是调用 ImageService 的 repaint 方法。 而获取这个画板就是去创建一个这个画板类的实例,由于我们的画图软件是每次都只编辑一张图片,所 以这个创建画板的方法在本类中只被调用一次。首先是 new 一个 DrawSpace 实例,再设置这个 drwaSpace 的大小,并且返回,见以下代码。 代码清单:code\image\src\org\crazyit\image\ImageFrame.java Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·9· //创建画板 public JPanel createDrawSpace() { JPanel drawSpace = new DrawSpace(); //设置 drawSpace 的大小 drawSpace.setPreferredSize( new Dimension( (int)screenSize.getWidth() , (int)screenSize.getHeight() - 150 ) ); return drawSpace; } 6.4.3 创建菜单 这个软件的菜单组织形式如下: -文件(F) -新建(N) -打开(O) -保存(S) -退出(X) -查看(V) -工具箱(T) -颜料盒(C) -颜色(C) 编辑颜色 -帮助(H) -帮助主题 -关于 由于菜单比较简单,我们把文件、查看、颜色、帮助四个菜单文字放在一个String 类型的数组menuArr 里面,并迭代这个数组去创建一个 JMenu,JMenu 就是指菜单。同样,把他们下面的各个菜单项文字 也放在一个 String 类型的二维数组里面,去迭代创建每个 JmenuItem(菜单项),每创建完一个,就为 它加上一个动作监听器,去监听这个菜单项是否被点击。请看以下代码。 代码清单:code\image\src\org\crazyit\image\ImageFrame.java //创建菜单 public void createMenuBar() { //创建一个 JMenuBar 放置菜单 JMenuBar menuBar = new JMenuBar(); //菜单文字数组,与下面的 menuItemArr 一一对应 String[] menuArr = { "文件(F)", "查看(V)", "颜色(C)", "帮助(H)" }; //菜单项文字数组 String[][] menuItemArr = { {"新建(N)", "打开(O)", "保存(S)", "-", "退出(X)"}, {"工具箱(T)", "颜料盒(C)"}, { "编辑颜色" }, { "帮助主题", "关于" } }; //遍历 menuArr 与 menuItemArr 去创建菜单 for( int i = 0 ; i < menuArr.length ; i++ ) { //新建一个 JMenu 菜单 JMenu menu = new JMenu( menuArr[i] ); Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·10· for( int j = 0 ; j < menuItemArr[i].length ; j++ ) { //如果 menuItemArr[i][j]等于"-" if ( menuItemArr[i][j].equals( "-" ) ) { //设置菜单分隔 menu.addSeparator(); } else { //新建一个 JMenuItem 菜单项 JMenuItem menuItem = new JMenuItem( menuItemArr[i][j] ); menuItem.addActionListener( menuListener ); //把菜单项加到 JMenu 菜单里面 menu.add( menuItem ); } } //把菜单加到 JMenuBar 上 menuBar.add(menu); } //设置 JMenubar this.setJMenuBar( menuBar ); } 以上代码的粗体部分,是为菜单添加相应的分隔符。在一般的下拉菜单中,可以通过分隔符将菜单 划分为多个块。加入菜单后主界面的具体效果如图 6.4 所示。 图 6.4 加入菜单后的界面 6.4.4 创建画图工具栏 画图工具栏,这里实现的工具有铅笔、刷子、拾色器、喷枪、橡皮擦、直线、多边形、矩形、椭圆 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·11· 形和圆矩形,这里,每个工具我们用一个 JButton 去代表它,使用 JButton 的 JButton(AbstractActoin action)构造器去创建 JButton,用这个构造器创建 JButton,可以在 AbstractAction 中加入按键的图标, 以图形的方式创建按钮,另外,我们会有一个 AbstractAction 的实现类。见以下代码: 代码清单:code\image\src\org\crazyit\image\ImageFrame.java public JPanel createToolPanel() { //创建一个 JPanel JPanel panel = new JPanel(); //创建一个标题为"工具"的工具栏 JToolBar toolBar = new JToolBar( "工具" ); //设置为垂直排列 toolBar.setOrientation( toolBar.VERTICAL ); //设置为可以拖动 toolBar.setFloatable( true ); //设置与边界的距离 toolBar.setMargin( new Insets( 2, 2, 2, 2) ); //设置布局方式 toolBar.setLayout( new GridLayout( 5, 2, 2, 2 ) ); //工具数组 String[] toolarr = { PENCIL_TOOL, BRUSH_TOOL, COLORPICKED_TOOL , ATOMIZER_TOOL, ERASER_TOOL, LINE_TOOL, POLYGON_TOOL , RECT_TOOL, ROUND_TOOL, ROUNDRECT_TOOL}; for( int i = 0 ; i < toolarr.length ; i++ ) { ImageAction action = new ImageAction( new ImageIcon("img/" + toolarr[i] + ".jpg") , toolarr[i], this ); //以图标创建一个新的 button JButton button = new JButton( action ); //把 button 加到工具栏中 toolBar.add(button); } panel.add( toolBar ); //返回 return panel; } 首先,我们创建一个放置这些按钮的 JToolBar,JtoolBar 是 Java Swing 提供的一个工具栏类,并 设置 JToolBar 的标题、排列方式(垂直)、是否可以拖动、与边界的距离、还有布局方式,接下来遍历 存放这些工具类形的数组,每次都以工具图标的路径、工具名去创建一个 ImageAction(AbstractAction) 的子类,并以这个 ImageAction 去创建一个 JButton,并把 JButton 加到 JToolBar 中。那么,怎么监听 这些按钮?在这里,监听的工作就放到继承 AbstractAction 的 ImageAction 去实现,见 ImageAction 中 重写的 actionPerformed 方法,这个方法就是用来执行监听到按钮被点击后的方法。 代码清单:code\image\src\org\crazyit\image\ImageAction.java public void actionPerformed( ActionEvent e ) { //设置 tool tool = name != "" ? ToolFactory.getToolInstance( frame, name ) : tool; if( tool != null ) { //设置正在使用的 tool frame.setTool( tool ); } Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·12· if( color != null ) { //设置正在使用的颜色 AbstractTool.color = color ; colorPanel.setBackground( color ); } } 代码中加粗的地方是这个方法的主要业务,首先能过工具的名字 name 去获取一个 Tool 接口的实 现类实例,如果这个实例不为空,就把它设置为现在正使用的工具。而设置正在使用的颜色这段代码, 是用在监听颜色选择时用到,如果 color 不为空,就把当前的颜色设置为被选择的颜色。加入了画图工 具栏后,具体的效果如图 6.5 所示。 图 6.5 加入画图工具栏 6.4.5 创建颜色选择面板 这是一个简单的颜色选择面板,只有最基本的几种颜色选择(BLACK、BLUE、CYAN、GRAY、 GREEN、LIGHT_GRAY、MAGENTA、ORANGE、PINK、RED、WHITE、YELLOW),创建过程与 创建工具栏的过程类似,用设置了颜色的按钮去代表这些颜色,首先创建一个 JToolBar 去放置这些颜 色按钮,并设置这个 JToolBar 的布局方式、标题、是否可以拖动等属性,最后去遍历保存这些颜色类 型的数组,每次都新创建一个 ImageAction,并以这个 ImageAction 去创建一个 JButton,同时设置这 个 JButton 的颜色,最后加到 JToolBar 中。见以下代码。 代码清单:code\image\src\org\crazyit\image\ImageFrame.java public JPanel createColorPanel() { //新建一个 JPanel JPanel panel = new JPanel(); Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·13· //设置布局方式 panel.setLayout( new FlowLayout( FlowLayout.LEFT ) ); //新建一个 JToolBar JToolBar toolBar = new JToolBar( "颜色" ); //设置为不可拖动 toolBar.setFloatable( false ); //设置与边界的距离 toolBar.setMargin( new Insets( 2, 2, 2, 2) ); //设置布局方式 toolBar.setLayout( new GridLayout( 2, 6, 2, 2 ) ); //Color 类中的已有颜色 Color[] colorArr = { BLACK, BLUE, CYAN, GRAY, GREEN, LIGHT_GRAY , MAGENTA, ORANGE, PINK, RED, WHITE, YELLOW}; JButton[] panelArr = new JButton[ colorArr.length ]; //正在使用的颜色 currentColorPanel = new JPanel(); currentColorPanel.setBackground( Color.BLACK ); currentColorPanel.setPreferredSize( new Dimension( 20, 20 ) ); //创建这些颜色的 button for( int i = 0 ; i < panelArr.length ; i++ ) { //创建 JButton panelArr[i] = new JButton( new ImageAction( colorArr[i], currentColorPanel ) ); //设置 button 的颜色 panelArr[i].setBackground( colorArr[i] ); //把 button 加到 toobar 中 toolBar.add( panelArr[i] ); } panel.add( currentColorPanel ); panel.add( toolBar); //返回 return panel; } 以上代码主要用于创建颜色选择面板,以上代码的黑体部分,使用了 JButton 来作为一种颜色的选 择按钮,这些颜色选择按键使用了 ImageAction 作为构造参数,ImageAction 在 6.4.4 中创建,该 Action 类负责处理用户点击工具栏或者选择颜色时的行为,现在可以运行画图工具,具体的效果如图 6.6 所示。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·14· 图 6.6 主界面 在本小节中,我们使用 Swing 创建了画图工具的主界面,主界面主要包括菜单、画图工具栏、颜色 选择面板与画图区域等。接下来,我们将逐步实现画图里面的功能。 6.5 工具实现 前面说到,这里的工具类设计是有一个定义了工具所有方法的接口 Tool,这个接口有一个实现类 AbstractTool,该类实现了 Tool 接口的所有方法,并定义了一些方法供子类重写,Tool 接口、AbstractTool 类与它的子类关系,如图 6.7 所示。 图 6.7 Tool 继承关系图 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·15· Tool 提供 mouseDragged、mouseMoved、mouseReleased、mousePressed、mouseClicked 五 个方法接口,由其实现类实现,接下来先了解所有工具类的父类:AbstractTool。 6.5.1 实现拖动边框改变画布大小 在画图工具中,如果用户将鼠标移到画布边缘的时候,鼠标指针需要变成可拖动的图标,当按下鼠 标左键时,就可以通过鼠标的移动来改变画布的大小。我们在创建主界面的时候,就将画布设计为一个 JScrollPane,因此对画布进行拖动,就可以设置画布的大小。在 AbstractTool 中提供一个 dragBorder 的方法。 代码清单:code\image\src\org\crazyit\image\tool\AbstractTool.java //拖动画布边框 public void dragBorder( MouseEvent e ) { getFrame().getBufferedImage().setIsSaved( false ); //获取鼠标现在的 x 与 y 坐标 int cursorType = getFrame().getDrawSpace().getCursor().getType(); int x = cursorType == Cursor.S_RESIZE_CURSOR ? AbstractTool.drawWidth : e.getX(); int y = cursorType == Cursor.W_RESIZE_CURSOR ? AbstractTool.drawHeight : e.getY(); MyImage img = null; //如果鼠标指针是拖动状态 if( ( cursorType == Cursor.NW_RESIZE_CURSOR || cursorType == Cursor.W_RESIZE_CURSOR || cursorType == Cursor.S_RESIZE_CURSOR ) && ( x > 0 && y > 0 ) ) { //改变图像大小 img = new MyImage( x, y, BufferedImage.TYPE_INT_RGB ); Graphics g = img.getGraphics(); g.setColor( Color.WHITE ); g.drawImage( getFrame().getBufferedImage(), 0, 0 , AbstractTool.drawWidth, AbstractTool.drawHeight, null ); getFrame().setBufferedImage( img ); //设置画布的大小 AbstractTool.drawWidth = x; AbstractTool.drawHeight = y; //设置 viewport ImageService.setViewport( frame.getScroll() , frame.getDrawSpace(), x, y ); } } 这个方法由 AbstractTool 的 mouseDragged 方法调用,也就是有鼠标拖动动作时,肯定会调用到 这个方法。在改变图像大小之前,需要对获鼠标的当前位置做一些判断,首先获取取鼠标当前的 x 与 y 坐标,如果鼠标的类型是属于改变大小类类型,而且鼠标的位置坐标 x 与 y 都大于 0(见代码中的加粗 部分),那就进入改变画布大小的代码段。改变画布大小的方法是先以鼠标当前的 x 与 y 坐标创建一张 新的图片,并把这张图片的底色设置为白色,再把原来的图片用 Graphics 的 drawImage 方法画到现在 的画布上。AbstractTool 中的 mouseDragged 方法是对 Tool 接口定义的 mouseDragged 的实现。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·16· 6.5.2 实现父类的画图方法 当鼠标选择了某种工具,例如直线工具,可以在通过在画板上拖动画出一条直线,当然也可以拖动 画出矩形、椭圆形等图形。在 AbstractTool 中,定义了一个 draw 方法完成这些工作,但这只是一个空 方法,具体的实现由其子类去重写,因为每种工具画出的图形都不一样,所以由其子类去重写。而这个 方法由一个 createShape 方法去调用,在 createShape 中,主要是把 draw 方法中一些共同点以同样的 方式处理,避免子类的 draw 方法的代码重复。 代码清单:code\image\src\org\crazyit\image\tool\AbstractTool.java private void createShape( MouseEvent e ,Graphics g ) { //如果位置在画布内 if( getPressX() > 0 && getPressY() > 0 && e.getX() > 0 && e.getX() < AbstractTool.drawWidth && e.getY() > 0 && e.getY()< AbstractTool.drawHeight ) { //将整张图片重画 g.drawImage( getFrame().getBufferedImage(), 0, 0 , AbstractTool.drawWidth, AbstractTool.drawHeight, null ); //设置颜色 g.setColor( AbstractTool.color ); getFrame().getBufferedImage().setIsSaved( false ); //画图形 draw( g, getPressX(), getPressY(), e.getX(), e.getY() ); } } 如果鼠标的位置是在画布的范围内,首先将图片重画,这样做的原因是在鼠标松开之前,并没有真 正把图形画出来,只是显示这个轨迹,然后再把图片的 isSaved 属性(是否已经保存)改变为 false, 最后调用 draw 方法真正画图。我们要明白何时调用 createShape 的方法,当鼠标拖动或者松开的时候, 就需要调用画图的方法,将图画到界面中,也就是在调用 mouseReleased 与 mouseDragged 方法的时 候,就需要调用这个 createShape 的工具方法。 在实现这个工具方法的过程中,我们不知不觉使用了“模板方法(TemplateMethod)”这一种设计 模式,模板方法是定义某个操作中的算法结构,而将这个结构中的某一步具体步骤延迟到子类中加载, 这个设计模式可以使子类不必去改变整个算法的结构而重新定义该算法的某个具体步骤。在以上的代码 中,我们在 createShape 中定义了具体画图的步骤,但是,以上的代码将 draw 方法留给了子类去实现。 6.5.3 鼠标移动时改变指针 当鼠标移动到画布边缘时,我们要在这种情况下改变鼠标的指针形状,为鼠标的移动加入监听器, 由于是鼠标移动执行的方法,我们需要在实现 Tool 接口的 mouseMoved 方法的时候,改变鼠标的指针, 鼠标有三种形状类型:Cursor.NW_RESIZE_CURSOR(往右下拖动)、Cursor.W_RESIZE_CURSOR (往右边拖动)、Cursor.S_RESIZE_CURSOR(往下拖动),改变鼠标形状的方法是用这三个类型中的 一个去创建一个 Cursor,并调用 drawSpace 中的 setCursor 设置现在使用的鼠标类型,以下是 AbstractTool 对该功能的实现。 代码清单:code\image\src\org\crazyit\image\tool\AbstractTool.java public void mouseMoved( MouseEvent e) { //获取鼠标现在的 x 与 y 坐标 int x = e.getX(); int y = e.getY(); //获取默认鼠标指针 Cursor cursor = getDefaultCursor(); Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·17· //如果鼠标指针在右下角 if( x > AbstractTool.drawWidth - 4 && x < AbstractTool.drawWidth + 4 && y > AbstractTool.drawHeight - 4 && y < AbstractTool.drawHeight + 4 ) { //将鼠标指针改变为右下拖动形状 cursor = new Cursor( Cursor.NW_RESIZE_CURSOR ); } //如果鼠标指针在右中 if( x > AbstractTool.drawWidth - 4 && x < AbstractTool.drawWidth + 4 && y > (int)AbstractTool.drawHeight/2 - 4 && y < (int)AbstractTool.drawHeight/2 + 4 ){ //将鼠标指针改变为右拖动形状 cursor = new Cursor( Cursor.W_RESIZE_CURSOR ); } //如果鼠标指针在下中 if( y > AbstractTool.drawHeight - 4 && y < AbstractTool.drawHeight + 4 && x > (int)AbstractTool.drawWidth/2 - 4 && x < (int)AbstractTool.drawWidth/2 + 4 ) { //将鼠标指针改变为下拖动形状 cursor = new Cursor( Cursor.S_RESIZE_CURSOR ); } //设置鼠标指针类型 getFrame().getDrawSpace().setCursor( cursor ); } 6.5.4 记录记录鼠标按下的位置 在使用工具时,不管是画直线或者矩形,总是需要记录鼠标按下时的坐标位置,加上鼠标松开时的 坐标位置,才能把一个图形准确地画出来,所以在 mousePressed 方法中,主要是记录鼠标按下时的位 置,以下是 AbstractTool 的 mousePressed 方法的实现。 代码清单:code\image\src\org\crazyit\image\tool\AbstractTool.java public void mousePressed(MouseEvent e) { //如果位置在图片范围内,设置按下的坐标 if( e.getX() > 0 && e.getX() < AbstractTool.drawWidth && e.getY() > 0 && e.getY() < AbstractTool.drawHeight ) { setPressX( e.getX() ); setPressY( e.getY() ); } } 6.5.5 重绘图片 每次松开鼠标时,可能就是一个画图动作的完成,所以 AbstractTool 在实现 Tool 的 mouseReleased 方法时需要做两步工作,一个是再次调用 createShape 方法去画图,另外一个是调用 drawSpace 的 repaint 方法去刷新,见以下代码。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·18· 代码清单:code\image\src\org\crazyit\image\tool\AbstractTool.java public void mouseReleased( MouseEvent e ) { //画图 Graphics g = getFrame().getBufferedImage().getGraphics(); createShape( e, g ); //把 pressX 与 pressY 设置为初始值 setPressX( -1 ); setPressY( -1 ); //重绘 getFrame().getDrawSpace().repaint(); } 到此,AbstractTool 都已经实现,AbstractTool 实现了 Tool 接口的全部方法,并提供了一些方法由 它的子类去实现,接下面,将实现各个工具的功能。 6.5.6 铅笔工具 铅笔工具,实现的方法主要是在拖动鼠标的时候,每次都以画直线的形式去画下铅笔的轨迹,先看 以下代码。 代码清单:code\image\src\org\crazyit\image\tool\PencilTool.java public void mouseDragged( MouseEvent e ) { super.mouseDragged(e); //获取图片的 Graphics 对象 Graphics g = getFrame().getBufferedImage().getGraphics(); if( getPressX() > 0 && getPressY() > 0 ) { g.setColor( AbstractTool.color ); g.drawLine( getPressX(), getPressY(), e.getX(), e.getY() ); setPressX( e.getX() ); setPressY( e.getY() ); getFrame().getDrawSpace().repaint(); } } 首先是用 BufferdImage 中的 getGraphics 获取图片的 Graphics 对象 g,由于我们在 ImageFrame 中保存到一个 BufferedImage 的对象(BufferImage 的子类),因此可以直接获得。如果鼠标的位置是在 画布之中(getPressX() > 0 && getPressY() > 0),便把 g 的 color 设置为当前工具的颜色,以按下时的 坐标与鼠标当前的坐标位置为参数去调用 Graphics 的 drawLine 方法画直线,并把按下时的坐标位置设 置位当前坐标位置,这样做的原因是想达到铅笔的效果,也就是说每次画直线的起点坐标都是上次的终 点坐标,最后调用 drawSpace 的 repaint 方法重绘图片。在本章中,铅笔工具对应的是 PencilTool 类, 使用铅笔工具效果如图 6.8 所示。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·19· 图 6.8 使用铅笔工具画图 6.5.7 直线工具、矩形工具、椭圆工具和圆矩形工具 这只个类分别代表直线工具、矩形工具、椭圆工具、圆矩形工具,实现的方法很简单,就是调用 Grapchis 的 drewXXXX 方法(drawLine、drawRect、drawOval、drawRoundRect),都重写父类的 draw (画图形方法)方法,draw 方法最后由 createShape 方法调用。 直线工具的实现如下。代码清单:code\image\src\org\crazyit\image\tool\LineTool.java public void draw(Graphics g, int x1, int y1, int x2, int y2) { g.drawLine(x1, y1, x2, y2); } 矩形工具的实现如下。代码清单:code\image\src\org\crazyit\image\tool\RectTool.java public void draw(Graphics g, int x1, int y1, int x2, int y2) { // 计算起点 int x = x2 > x1 ? x1 : x2; int y = y2 > y1 ? y1 : y2; // 画矩形 g.drawRect(x, y, Math.abs(x1 - x2), Math.abs(y1 - y2)); } 椭圆工具的实现如下。代码清单:code\image\src\org\crazyit\image\tool\RoundTool.java public void draw(Graphics g, int x1, int y1, int x2, int y2) { // 计算起点 int x = x2 > x1 ? x1 : x2; int y = y2 > y1 ? y1 : y2; // 画椭圆 g.drawOval(x, y, Math.abs(x1 - x2), Math.abs(y1 - y2)); Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·20· } 圆矩形工具的实现如下。代码清单:code\image\src\org\crazyit\image\tool\RoundRectTool.java public void draw(Graphics g, int x1, int y1, int x2, int y2) { // 计算起点 int x = x2 > x1 ? x1 : x2; int y = y2 > y1 ? y1 : y2; // 画圆矩形 g.drawRoundRect(x, y, Math.abs(x1 - x2), Math.abs(y1 - y2), 20, 20); } 注:除了画直线外,其他的图形都需要计算起点,取较小的坐标作为图形的开始坐标。 在本章中,直线工具对应的是 LineTool 类,矩形工具对应的是 RectTool 类,椭圆形工具对应的是, RoundTool 类,圆矩形工具对应的是 RoundRectTool 类。直线工具、矩形工具、椭圆形工具与圆矩形 工具的具体效果如图 6.9 所示。 图 6.9 直线工具、矩形工具、椭圆形工具与圆矩形工具的效果 6.5.8 多边形工具 多边形的处理与其它图形并不一样,它不是一次性画出来的,首先是拖动画第一条边,然后松开鼠 标,移动鼠标到下个位置点击松开,像画出第二条边,如此重复,最后双击鼠标闭合这个多边形。 代码中是这样实现的,每次都记录上一条直线的终点坐标,如果没有上一条线,那么终点坐标就是 鼠标当前的坐标,松开鼠标的时候(mouseReleased),就用上个终点坐标与鼠标当前的坐标画一条直线, 在双击鼠标的时候,以鼠标当前的坐标与第一个坐标画一条直线,再以鼠标当前的坐标与最后一个坐标 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·21· 画一条直线,达到闭合的目的,首先看看 mouseReleased 方法。 代码清单:code\image\src\org\crazyit\image\tool\PolygonTool.java public void mouseReleased( MouseEvent e ) { int pressX = getPressX(); int pressY = getPressY(); //调用父方法画直线 super.mouseReleased( e ); //设置第一个与最后一个坐标 if( firstX == -1 ) { firstX = pressX; firstY = pressY; } lastX = e.getX(); lastY = e.getY(); } 首先是调用父方法的 mouseReleased 去画直线,看粗体位置的代码,如果第一个坐标还没有被赋 值,那么说明这是第一次调用此方法(松开鼠标),就把这次点击松开的位置记录为第一个位置。而上 一个鼠标位置就记录为当前鼠标位置。 最后双击鼠标的时候,就以当前鼠标坐标与第一个坐标(firstX, firstY)画一条直线,再以当前鼠标 坐标与最后一个坐标画一条直线(lastX, lastY),见下以代码。 代码清单:code\image\src\org\crazyit\image\tool\PolygonTool.java public void mouseClicked(MouseEvent e) { Graphics g = getFrame().getBufferedImage().getGraphics(); if( e.getClickCount() == 2 && firstX > 0 && e.getX() > 0 && e.getX() < AbstractTool.drawWidth && e.getY() > 0 && e.getY() < AbstractTool.drawHeight ) { g.setColor( AbstractTool.color ); g.drawImage( getFrame().getBufferedImage(), 0, 0 , AbstractTool.drawWidth, AbstractTool.drawHeight, null ); draw( g, 0, 0, firstX, firstY ); draw( g, 0, 0, lastX, lastY ); setPressX( -1 ); setPressY( -1 ); firstX = -1; firstY = -1; lastX = -1; lastY = -1; getFrame().getDrawSpace().repaint(); } } 在本章中,多边形工具对应的是 PolygonTool 类,多边形工具的具体效果如图 6.10 所示。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·22· 图 6.10 多边形工具 6.5.9 刷子与橡皮擦 刷子的效果实现:在拖动鼠标的时候,总是以当前鼠标的位置画有颜色填充的矩形,矩形的大小就 是刷子的大小。而橡皮擦和刷子的实现是一样的,只不过,刷子用的是其它颜色,而橡皮擦用的是白色。 见以下代码(刷子)。 代码清单:code\image\src\org\crazyit\image\tool\BrushTool.java public void mouseDragged( MouseEvent e ) { super.mouseDragged(e); Graphics g = getFrame().getBufferedImage().getGraphics(); int x = 0; int y = 0; //画笔大小 int size = 4; if( getPressX() > 0 && getPressY() > 0 && e.getX() < AbstractTool.drawWidth && e.getY() < AbstractTool.drawHeight ) { g.setColor( AbstractTool.color ); x = e.getX() - getPressX() > 0 ? getPressX() : e.getX(); y = e.getY() - getPressY() > 0 ? getPressY() : e.getY(); g.fillRect( x - size , y - size , Math.abs( e.getX() - getPressX() ) + size , Math.abs( e.getY() - getPressY() ) + size ); setPressX( e.getX() ); setPressY( e.getY() ); getFrame().getDrawSpace().repaint(); } Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·23· } 在拖动鼠标过程中,总是会连续调用到这个方法,如果鼠标的当前位置是在画板中,那么就画矩形 (黑体代码断是画矩形)。在本章中,刷子工具对应的是 BrushTool 类,橡皮擦工具对应的是 EraserTool 类。 6.5.10 喷墨工具 喷墨效果实现:点击松开鼠标的时候,以鼠标当前的位置,在喷枪大小范围内随机画出 N 个小矩形, 见以下代码。 代码清单:code\image\src\org\crazyit\image\tool\AtomizerTool.java public void draw(MouseEvent e,Graphics g) { int x = 0; int y = 0; //喷枪大小 int size = 8; //喷枪点数 int count = 10; if( getPressX() > 0 && getPressY() > 0 && e.getX() < AbstractTool.drawWidth && e.getY() < AbstractTool.drawHeight ) { g.setColor( AbstractTool.color ); for ( int i = 0 ; i < count ; i++ ) { x = new Random().nextInt(size)+ 1; y = new Random().nextInt(size) + 1; g.fillRect( e.getX() + x , e.getY() + y , 1, 1 ); } setPressX( e.getX() ); setPressY( e.getY() ); getFrame().getDrawSpace().repaint(); } } 看加粗的代码,假如喷墨工具要每次产生 10 个小点,那么,每次都是产生两个随机数,代表与当前 坐标(x, y)的距离(x = new Random().nextInt(size)+ 1; y = new Random().nextInt(size) + 1;),最后 当前坐标加上这两个随机数,做为新的坐标去画小矩形。在本章中,喷墨工具对应的是 AtomizerTool 类,喷墨工具的效果如图 6.11 所示。 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·24· 图 6.11 喷墨工具 本小节中实现画图的基本工具,包括铅笔、刷子、橡皮擦、多边形工具等画图软件的基本工具,在 在最后一节,将会讲解如何对图形进行保存、打开、新建等相关操作。 6.6 ImageService类实现 除去鼠标的画图功能,初始化画板、图片的新建打开与保存、各种面板的显示与隐藏、颜色的编辑、 整个界面的刷新、菜单等业务逻辑都放在这个方法中实现。 6.6.1 打开图片文件 JFileChooser 用于生成打开一个文件对话框,也是用于生成“打开文件”、“保存文件”的对话框, JfileChooser 不依赖本地的 GUI 平台,它是纯 Java 实现,在不同平台具有完全的行为,并具有相同的 外观风格。使用 JfileChooser 的 showOpenDialog 方法弹出文件选择框,再使用 getSelectedFile 方法 获取选择到的文件。然后使用 ImageIO 的 read 方法,把文件读取出来,最后把当前的图片替换为新读 取到的图片与调用 drawSpace 的 repaint 方法重画、设置窗口标题,见以下代码。 代码清单:code\image\src\org\crazyit\image\ImageService.java public void open( ImageFrame frame ) { save( false, frame ); //如果打开一个文件 if( fileChooser.showOpenDialog( frame ) == ImageFileChooser.APPROVE_OPTION ) { //获取选择的文件 File file = fileChooser.getSelectedFile(); //设置当前文件夹 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·25· fileChooser.setCurrentDirectory( file ); BufferedImage image = null; try { //从文件读取图片 image = ImageIO.read( file ); } catch ( java.io.IOException e ) { e.printStackTrace(); } //宽,高 int width = image.getWidth(); int height = image.getHeight(); AbstractTool.drawWidth = width; AbstractTool.drawHeight = height; //创建一个 MyImage MyImage myImage = new MyImage( width, height , BufferedImage.TYPE_INT_RGB ); //把读取到的图片画到 myImage 上面 myImage.getGraphics().drawImage( image, 0, 0, width , height, null ); frame.setBufferedImage( myImage ); //repaint frame.getDrawSpace().repaint(); //重新设置 viewport ImageService.setViewport( frame.getScroll() , frame.getDrawSpace(), width, height ); //设置保存后的窗口标题 frame.setTitle( fileChooser.getSelectedFile().getName() + " - 画图" ); } } 6.6.2 保存图片 保存图片的操作一样是使用 JFileChooser,首先是用 JFileChooser 的 getCurrentDirectory()方法获 取当前的文件目录,以这个当前路径加上文件名做为文件保存的路径。最后,使用 ImageIO 的 write 方 法把文件写到磁盘。 在这里还有另外一个逻辑,就是看 save 方法传过来的 boolean 类型的参数 b,如果为真,直接保 存,如果为假,先询问是否保存,如果用户选择是,再以 true 去调用这个方法保存文件,见以下代码。 代码清单:code\image\src\org\crazyit\image\ImageService.java public void save( boolean b, ImageFrame frame ) { if( b ) { //如果选择保存 if(fileChooser.showSaveDialog( frame ) == ImageFileChooser.APPROVE_OPTION) { //获取当前路径 File currentDirectory = fileChooser.getCurrentDirectory(); //获取文件名 String fileName = fileChooser.getSelectedFile().getName(); //获取后缀名 Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·26· String suf = fileChooser.getSuf(); //组合保存路径 String savePath = currentDirectory + "\\" + fileName + "." + suf; try { //将图片写到保存路径 ImageIO.write( frame.getBufferedImage(), suf , new File(savePath) ); } catch ( java.io.IOException ie ) { ie.printStackTrace(); } //设置保存后的窗口标题 frame.setTitle( fileName + "." + suf + " - 画图" ); //已保存 frame.getBufferedImage().setIsSaved( true ); } } else if( !frame.getBufferedImage().isSaved() ) { //新建一个对话框 JOptionPane option = new JOptionPane(); //显示确认保存对话框 YES_NO_OPTION int checked = option.showConfirmDialog( frame, "保存改动?" , "画图", option.YES_NO_OPTION, option.WARNING_MESSAGE); //如果选择是 if( checked == option.YES_OPTION ) { //保存图片 save( true, frame ); } } } 6.6.3 新建图片 调用新建图片方法的时候,首先会先调用 save 方法,询问是否保存当前正在编辑的图片,先做用 户选择是否保存的动作。然后又重新创建一个图片对象,设置这个图片的长、宽,设置图片的颜色为白 色,把它替换到当前剪辑的图片,最后再设置窗口标题等其它东西,见以下代码: 代码清单:code\image\src\org\crazyit\image\ImageService.java public void createGraphics( ImageFrame frame ) { save( false, frame ); //宽,高 int width = (int)getScreenSize().getWidth()/2; int height = (int)getScreenSize().getHeight()/2; AbstractTool.drawWidth = width; AbstractTool.drawHeight = height; //创建一个 MyImage MyImage myImage = new MyImage( width, height , BufferedImage.TYPE_INT_RGB ); Graphics g = myImage.getGraphics(); g.setColor( Color.WHITE ); Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·27· g.fillRect( 0, 0, width, height ); frame.setBufferedImage( myImage ); //repaint frame.getDrawSpace().repaint(); //重新设置 viewport ImageService.setViewport( frame.getScroll() , frame.getDrawSpace(), width, height ); //设置保存后的窗口标题 frame.setTitle( "未命名 - 画图" ); } 6.6.4 颜色剪辑器 使用 swing 的 JColorChooser 使颜色编辑器的实现变的非常简单,只要使用它的 showDialog 方法, 就能弹出一个颜色编辑框,并且可以得到用户选择或者自己调的颜色,获取到这个颜色后,我们就把当 前使用的颜色设置为这个颜色,见以下代码。 代码清单:code\image\src\org\crazyit\image\ImageService.java public void editColor( ImageFrame frame ) { //获取颜色 Color color = JColorChooser .showDialog( frame.getColorChooser() , "编辑颜色", Color.BLACK ); color = color == null ? AbstractTool.color : color; //设置工具的颜色 AbstractTool.color = color; //设置目前显示的颜色 frame.getCurrentColorPanel().setBackground( color ); } 粗体的地方,是弹出一个标题为“编辑颜色”,默认选择了黑色的颜色编辑框,在用户未确定之前, 代码不会继续往下走,等用户选择了,就返回一个 Color 对象,然后我们使用这个对象改变当前工具的 颜色。 6.6.5 文件过滤 由于我们只会选择图片类型的文件,也只会保存为图片类型的文件,所以对 JFileChooser 选择的 文件必须做一些过滤,我们这里选择了扩展 JFileChooser 类,也就是继承它。这里定义了它的一个字 类 ImageFileChooser,在这个类里面,将对文件做一些过滤,见以下代码。 代码清单:code\image\src\org\crazyit\image\ImageFileChooser.java private void addFilter() { this.addChoosableFileFilter( new MyFileFilter( new String[]{".BMP"}, "BMP (*.BMP)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".JPG",".JPEG",".JPE",".JFIF"}, "JPEG (*.JPG;*.JPEG;*.JPE;*.JFIF)") ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".GIF"}, "GIF (*.GIF)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".TIF",".TIFF"}, "TIFF (*.TIF;*.TIFF)" ) ); Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·28· this.addChoosableFileFilter( new MyFileFilter( new String[]{".PNG"}, "PNG (*.PNG)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".ICO"}, "ICO (*.ICO)" ) ); this.addChoosableFileFilter( new MyFileFilter( new String[]{".BMP",".JPG",".JPEG",".JPE",".JFIF", ".GIF",".TIF",".TIFF",".PNG",".ICO"}, "所有图形文件") ); } 从代码中可以看到,就是调用 JFileChooser 的 addChoosableFileFilter 为文件增加各种图片类型的 过滤器,而这方法用到一个 FileFilter 类型的参数,在这里,我们也扩展了 FileFilter,叫 MyFileFilter, 是 ImageFileChooser 的一个内部类,并且重写了 FileFilter 的 accept 方法,accept 方法是用来判断文 件类型是否相符的,请看以下代码。 代码清单:code\image\src\org\crazyit\image\ImageFileChooser.java public boolean accept( File f ) { //如果文件的后缀名合法,返回 true for ( String s : suffarr ) { if ( f.getName().toUpperCase().endsWith( s ) ) { return true; } } //如果是目录,返回 true,或者返回 false return f.isDirectory(); } 如果是目录,总是返回 true,代表可以打开,如果是文件,就要判断文件的后缀名是否我们自己定 义的后缀名,如果是就返回 true,否则返回 false; 6.6.6 根据菜单的点击调用相应的方法 当用户点击打开、保存、颜色等等菜单的时候,程序是根据 ActionListener 得到的 cmd(用 ActionListener 的 getActionCommond 方法获得,这个值就是菜单的文字),选择调用不同的方法,见 以下代码。 代码清单:code\image\src\org\crazyit\image\ImageService.java public void menuDo( ImageFrame frame, String cmd ) { if( cmd.equals( "编辑颜色" ) ) { editColor( frame ); } if( cmd.equals("工具箱(T)") ) { setVisible( frame.getToolPanel() ); } if( cmd.equals("颜料盒(C)") ) { setVisible( frame.getColorPanel() ); } if( cmd.equals( "新建(N)" ) ) { createGraphics( frame ); } if ( cmd.equals( "打开(O)" ) ) { open( frame ); } Download at http://www.pin5i.com/ 第 6 章 仿 Windows 画图 ·29· if( cmd.equals( "保存(S)" ) ) { save( true , frame ); } if( cmd.equals( "退出(X)" ) ) { exit( frame ); } } 6.6.7 判断图片是否已经保存 这个功能的实现非常简单,我们的图片类 MyImage(这个类继承了 Java 的 BufferImage 类)里面 有一个 isSaved 的属性,标志图片是否已经保存。在做保存图片的动作的时候,这个类将被调用 setIsSave 方法把图片 isSaved 设置为 true。如果在编辑图片的时候,这里会再把 isSaved 设置为 false。 例如在ImageService的save()方法里面就有这样的语句:frame.getBufferedImage().setIsSaved( true ), 就是把图片设置为已保存。 6.7 本章小结 本章主要是通过画图工具的基本实现,向读者展未了使用 Graphic 类的 drawLine、drawRect 等 drawXXXX 方法,使用 MouseMotionListener、MouseListener 等监听器这些普通的类,实现了这些画 图的基本功能,加深读者对这些类与方法的理解,学会更好更有技巧地去使用这些 swing 包中的类。本 章中除了介绍一些画图的方法外,我们还使用了模板方法这个设计模式,并将工具抽象成一个接口,使 得我们编写的程序耦合性更低,向读者展现了面向对象的多态、继承与封装的特性。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 第 7 章 单机连连看 7.1 连连看游戏简介 连连看游戏,是指在一个区域内,分布着许多不同的图片,通过点击两张相同的图片并符合一定的 规则消除它们的一个游戏。我们最常见的是在 QQ 游戏大厅里的 QQ 连连看,网络上也有许多各式各样 的单机版连连看游戏。在游戏的过程中,可以考虑下如何使用自己掌握的程序去实现游戏的效果,并尝 试去开发属于自己的连连看,这是一件十分惬意的事情。在本章中,我们将详细的教大家如何使用 Java 去开发一款属于自己的单机连连看。 7.2 连连看游戏原理 实现连连看游戏并不复杂,如果先了解了程序实现这个游戏的原理,那么写出程序也就是一件简单 的事情了。首先,我们可以将玩游戏区域在 Java 程序中看作是一个二维数组对象,游戏区中可以看作 是一个容器对象,二维数组中一维的值可以看作是游戏区域中 x 坐标的值,二维数组中二维的值可以看 作是游戏区域中 y 坐标的值,容器根据这个二维数组去构造游戏区域。如下代码创建一个二维数组: Object[][] array = new Object[一维的最大值][二维的最大值]; 其次,当点击游戏区域的某个点时,我们可以找到该点在二维数组中对应的某个值。如果之前点击 的坐标点和本次点击的坐标点,它们拥有同一张图片并且符合一定的消除规则,那么,把二维数组中对 应的值设置成另外的值,再重新绘制游戏区域,就可以达到消除的效果。 最后,我们可以为游戏加入分数计算和时间计算等其他功能。下面让我们开始编写我们的连连看游 戏。 7.3 创建游戏界面与游戏区域 了解了连连看游戏的实现原理后,我们可以开始编写游戏界面,并准备游戏界面的相关类,在本章 中,进行游戏的区域是 GamePanel,该类继承于 JPanel。 7.3.1 创建游戏界面 游戏界面如图 7.1 所示。游戏区域中使用的是 JPanel。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·2· 图 7.1 游戏界面 7.3.2 游戏区域实现原理 在本章的开头提到,我们可以将游戏区看作是一个坐标。游戏区的最左上角的那一点可以看作是坐 标的(0,0)点,游戏区的最上面的边就是 x 轴,游戏区最左面的边就是 y 轴。这样,我们就可以将游 戏区域中的图片看作是一个二维数组,数组的一维值是 x 坐标的值,数组的二维值是 y 坐标的值,那么, 当游戏中在游戏区域中选择了某一点的时候,我们就可以定位到该数组中的唯一的值,再去判断这个值 并执行操作。 7.3.3 创建图片方块对象 我们首先创建一个二维数组用于表示一个游戏区域的图片,我们所需要的图片方块可以看作是一个 具体的对象,因此,我们新建一个 Piece 类,表示为一个图片方块,这个 Piece 对象需要保存一些什么 样的信息呢?这是我们所需要考虑的,当然,不一定一开始就把所有的属性都加入,我们可以想到的时 候再往里面填充所需要的属性。一个 Piece 对象即一个图片方块,那么它当然会包括一个图片对象,图 片占有一定的位置,那么我们需要记录它的开始的点的 x 坐标和 y 坐标,还有结束点的 x 坐标和 y 坐标, 什么是 Piece 对象的开始坐标和结束坐标如图 7.2 所示。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·3· 图 7.2 Piece 对象的开始点与结束点 明白了什么是开始点坐标与结束点坐标之后,我们为 Piece 对象加入这些属性,代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\commons\Piece.java public class Piece { private BufferedImage image; //保存这个方块对象的所对应的图片 private int beginX; //开始点的 x 坐标 private int beginY; //开始点的 y 坐标 private int endX; //结束点的 x 坐标 private int endY; //结束点的 y 坐标 //以下省略 getter 方法 } 我们为每个属性只提供 getter 方法,并不提供 setter 方法,我们可以通过构造器来构造这个对象, 这个对象一旦构造完成后,它的每个属性都不允许改变,这是因为在创建游戏区域时这个方块在游戏区 域中的位置已经固定,它的开始坐标与结束坐标都不再改变,更不会去改变它的图片,直到这个方块被 消除。这里需要注意的是,我们这里的构造器参数并不需要提供结束点的 x 坐标和 y 坐标,这是因为结 束点的 x 坐标和 y 坐标取决于开始点的坐标与图片的大小。 我们定义了一个方块对象,那么在定义一个二维数的时候,我们可以这样: Piece[][] pieces = new Piece[length][length]; 另外,我们还要添加两个属性,用于记录这个 Piece 对象在数组中的位置是什么,便于我们得到该 对象时,很轻易的定位到它在数组的位置: private int indexX; //该对象在数组中一维的位置 private int indexY; //该对象在数组中二维的位置 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·4· 注:以上两个属性,同样加到 Piece 对象的构造器中。 7.3.4 创建游戏处理类 新建一个 GameService 的接口,用于定义游戏逻辑的接口方法,再为这个接口新增一个实现类 GameServiceImpl,将 GameService 接口设置到 GamePanel 这个视图组件中,这们就可以达到逻辑与 视图分离了。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\GameService.java public interface GameService { //定义一个接口方法, 用于返回一个二维数组 Piece[][] getPieces(); } 以后我们需要实现某些游戏逻辑,可以在这个接口里面定义方法,并由 GameServiceImpl 去实现 了。这样可以简化视图的代码,也遵循了单一职责的原则。现在我们为 GamePanel 类加入一个 GameService 的属性,并为它的构造器中加入设置 GameService 的代码,用于设置 GameService,使 得在 GamePanel 中可以拿到 GameService 对象,代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\view\GamePanel.java private GameService gameService; public GamePanel(GameService gameService) { //省略其他设值的代码 } 现在,我们可以去修改 GamePanel 中的 paint 方法,代表游戏区域的二维数组 Piece[][]的创建与变 化,我们可以放到 GameService 的实现类中去,在 GameService 中提供一些接口方法,GamePanel 通过这些方法去获取二维数组,而如何去创建,设置这个二维组数里面的值,GamePanel 不再需要去 理会这些过程。如果 GamePanel 类的 paint 方法中可以这样得到 Piece 数组。 代码清单:code\linkgame\src\org\crazyit\linkgame\view\GamePanel.java public void paint(Graphics g) { //使用 GameService 来获取 Piece[][]二维数组 Piece[][] pieces = gameService.getPieces(); } 7.3.5 图片的读取 连连看的游戏区域中分布着图片,如果要初始化游戏区域,我们必须将这些图片读取,放到我们的 二维数组中。这一小节,将介绍怎样去随机读取图片,将图片的顺序打乱等。在读取图片之前,我们先 要明白连连看的游戏规则,点击两张相同的图片并符合一定的规则才能消除,换言之,随便点击一张图 片,必有另外一张图片等待着与它相连,也就是在游戏区域中任何一类图片的个数都是双数。如果为单 数的话,游戏将不能结束了。 首先我们必须准备好游戏中所需要的图片,本例中准备了 23 张内容不同的图片用于作为图片方块。 注:这 23 张图片的长和宽必须一样,使界面好看,也符合连连看的规则。 接下来,我们去创建用于读取图片和处理图片的 ImageUtil 工具类,在为这个工具类添加方法前, 我们必须要明确这个类的作用,读取或者处理图片的一些公共方法可以放到这里,做成静态方法给外部 使用。现在我们新建一个读取图片的方法,读取某个文件夹下面的符合后缀的图片,我们把方块的图片 独立放到一个文件夹,再使用程序读取它们,并封装成一个集合返回,实现代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\utils\ImageUtil.java //用于获取某个文件夹下面的所有图片 public static List getImages(File folder, String subfix) throws IOException { Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·5· //从目标文件夹中获取文件列表 File[] items = folder.listFiles(); //创建结果集合对象 List result = new ArrayList(); //对文件列表进行遍历 for (File file : items) { //如果该文件符合指定的文件后缀, 则加到结果集中 if (file.getName().endsWith(subfix)) result.add(ImageIO.read(file)); } return result; } 注:一般作为连连看方块的图片,每张图片的大小(长宽)都必须一致,这是传统的规则,本例也 是采用此规则,因此所有方块的图片的长宽必须一致。 接下来,我们再编写一个随机读取图片的方法,用于在一个集合中随机读取一定数量的图片,同样 地返回集合。 代码清单:code\linkgame\src\org\crazyit\linkgame\utils\ImageUtil.java //随机从 sourceImages 的集合中获取 size 张图片 public static List getRandomImages(List sourceImages, int size) { //创建一个随机数生成器 Random random = new Random(); //创建结果集合 List result = new ArrayList(); for (int i = 0; i < size; i++) { try { //随机获取一个数字,包括 0,不包括源图片集合的 size int index = random.nextInt(sourceImages.size()); //从源图片集合中获取该图片对象 BufferedImage image = sourceImages.get(index); //添加到结果集中 result.add(image); } catch (IndexOutOfBoundsException e) { //当源图片集合的 size 为 0 时,会抛出数组越界的异常,直接返回结果集 return result; } } return result; } 这样,我们就可以从文件夹中读取图片作为我们游戏的方块了,但是,前面说到,在游戏中每一种 图片在游戏区域中出现的次数必须为双数,游戏才可以结束,因此,我们需要在读取图片的时候做一些 相关处理。例如,我们的游戏区域(有图片的区域)为 10 乘 10 大小,即横 10 个方块,竖 10 个方块, 那么我们将要读取 100 张图片作为方块,由于每种图片必须为双数,因此我们将 100 除以 2,也就是需 要随机拿 50 张图片即可,再将这 50 张图片乘以 2,即是我们所要的 100 张图片,也可以保证每种图片 的数量是双数。现在,我们需要有一个方法去打乱一个集合里面的元素,就好像这样:  源: 0,1,2,3,4,5  结果: 2,1,5,0,4,3 以下为程序的实现。代码清单:code\linkgame\src\org\crazyit\linkgame\utils\ImageUtil.java //随机打乱 sourceImages public static List randomImages(List sourceImages) { //创建一个随机数生成器 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·6· Random random = new Random(); //创建一个存放数字的集合 List numbers = new ArrayList(); //获取一个集合, 里面是一些被打乱的数字 for (int i = 0; i < sourceImages.size(); i++) { //随机创建一个数字,范围是 0 到参数 sourceImage 的 size, 包括 0 不包括 size Integer temp = random.nextInt(sourceImages.size()); //为了确保数字没有重复,如果该数字已经在存放数字的集合中,重新再获取一次数字 if (!numbers.contains(temp)) { //存放数字的集合中没有该随机数,添加集合中 numbers.add(temp); } else { //该数字已经存在于集合中,i - 1 执行循环 i--; continue; } } //创建一个结果集合 List result = new ArrayList(); //对源图片集合进行遍历 for (int i = 0; i < sourceImages.size(); i++) { //从数字集合中获取已经被打乱的索引,源图片集合获取这个索引的值 result.add(sourceImages.get(numbers.get(i))); } return result; } 我们在这里先完成了一个工具类用于处理图片,那么在创建游戏区域的时候,我们可以不用在 GameService 这个逻辑类里面实现图片读取和随机打乱的功能,只需要调用这一个工具类就可以达到同 种图片为双数,并随机打乱图片的功能。 7.3.6 创建游戏区域图片数组 在画游戏区域之前,我们必须要为游戏区域数组进行赋值,将游戏区域看作一个二维数组,那么这 个数组的每个值的变化,将会影响到游戏区域的展现,因此这个二维数组对我们的游戏尤为重要。 我们现在为 GameService 接口提供一个 start 方法,用于执行游戏开始时的一些动作,例如初始化 游戏区域,重新计时,重新计分等,当玩家点击了开始时,我们就要调用这个 start 方法开始游戏,我 们可以为开始按钮创建鼠标监听器。当然,我们这里先讲创建游戏区域,GameServiceImpl 代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java public void start() { //创建一个有 100 个方块的游戏区域 this.pieces = new Piece[10][10]; //获取游戏图片,数量为 Piece 数组一维的长度乘以二维的长度 List playImages = ImageUtil.getPlayImages(new File("images/pieces"), 10 * 10); //拿第一张图片的宽,由于之前约定每张图片的大小必须一致,只拿一张即可 int imageWidth = playImages.get(0).getWidth(); //拿第一张图片的高 int imageHeight = playImages.get(0).getHeight(); //添加一个拿图片的索引 int index = 0; for (int i = 0; i < this.pieces.length; i++) { Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·7· for (int j = 0; j < this.pieces[i].length; j++) { //构造一个 Piece 对象,以一维的值乘以图片的宽作为该对象的开始 x 坐标 //以二维的值乘以图片的高作为该对象的开始 y 坐标 Piece piece = new Piece(i * imageWidth, j * imageHeight, i, j, playImages.get(index)); //将该对象放进数组中 this.pieces[i][j] = piece; index++; } } } 图 7.3 对上代码作出解析。 图 7.3 开始坐标与结束坐标的计算 如图 7.3,就清楚上面的代码为什么要这样实现了,现在,我们的游戏区域数组已经初始化好了, 剩下的就是如果将该数组“画”到 GamePanel 中了。 7.3.7 根据数组画游戏区域 在 7.3.6 中已经创建好的游戏区域数组,现在去实现 GamePanel 的 paint 方法,用于将游戏区域数 组“画”到 GamePanel 中,可以在对 Piece 数组进行循环的过程中,遇到某个非空 Piece,即“画” 到游戏区域中,具体的代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\view\GamePanel.java //调用 Graphics 类的 drawImage 方法画图,从 piece 的开始坐标开始画 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·8· g.drawImage(piece.getImage(), piece.getBeginX(),piece.getBeginY(), null); 这样,就可以进这个 GamePanel 进行绘制了,修改 GameServiceImpl 类中的 start 方法,设置第 一个方块出现的 x 坐标和 y 坐标: Piece piece = new Piece(i * imageWidth + 30, j * imageHeight + 30, i, j, playImages.get(index)); 注意上面的粗体字,代表从第一个方块开始,x 和 y 值分别加 30,即向游戏区域的右下角各移动 30。重新运行,并点击开始,效果图 7.4 所示。 图 7.4 将二维数组“画”到游戏区 7.3.8 随机初始化游戏 我们定义一个 AbstractBoard 对象,主要用于创建游戏图片,我们可以将以上创建游戏区域的代码 放置到该对象中,统一由该对象负责处理游戏图片的创建。代码如下: 代码清单:code\linkgame\src\org\crazyit\linkgame\service\AbstractBoard.java //创建结果数组 Piece[][] pieces = new Piece[config.getXSize()][config.getYSize()]; //返回非空的 Piece 集合, 该集合由子类去创建 List notNullPieces = createPieces(config, pieces); //省略以下代码 写一个 SquareBoard 类去继承 AbstractBoard,并实现它的 createPieces 方法。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\SquareBoard.java public class SquareBoard extends AbstractBoard { protected List createPieces(GameConfiguration config, Piece[][] pieces) { Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·9· List notNullPieces = new ArrayList(); for (int i = 0; i < pieces.length; i++) { for (int j = 0; j < pieces[i].length; j++) { //先构造一个 Piece 对象, 只设置数组中的位置为 i, j,其他值不设置 Piece piece = new Piece(i, j); notNullPieces.add(piece); //添加到非空 Piece 对象的集合中 } } return notNullPieces; } } 这样,如果还需要构造不同的游戏数组,我们可以去继承 AbstractBoard 类,并实现 createPieces 即可。如果现在还需要构建新的游戏数组,那么我们再写一个子类 SimpleBoard 去继承 AbstractBoard 并实现 createPieces 方法,以下是 SimpleBoard 的主体代码。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\SimpleBoard.java List notNullPieces = new ArrayList(); for (int i = 0; i < pieces.length; i++) { for (int j = 0; j < pieces[i].length; j++) { //加入判断, 符合一定条件才去构造 Piece 对象, 并加到集合中 if (i % 2 == 0) {//如果 x 能被 2 整除, 即单数列不会创建方块 //先构造一个 Piece 对象, 只设置数组中的位置为 i, j,其他值不设置 Piece piece = new Piece(i, j); //添加到非空 Piece 对象的集合中 notNullPieces.add(piece); } } } createPieces 方法中直接回返结果集合即可。现在我们编写了 SimpleBoard 与 SquareBoard 两个 对象,如果需要随机创建这两个实例,可以通过 Random 来实现。游戏每次 start 的时候,就会创建出 不同的游戏数组了,效果如图 7.5 所示。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·10· 图 7.5 随机数组 其实,上面 SquareBoard 与 SimpleBoard 继承于 AbstractBoard,使用了其中的一种设计模式:模 板方法。模板方法的概念:定义一个操作中的算法的结构,而将一些步骤延迟到子类中。模板方法使得 子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤(摘自《设计模式》)。上面的 SquareBoard 与 SimpleBoard,继 承 了 AbstractBoard,AbstractBoard 中定义了图片生成的算法,它的 两个子类就是实现父类定义的结构的某一个步骤。 7.4 实现连接程序 在 7.3 中,我们已经可以创造一个随机的数组,这一节,我们开始实现连接的程序。所谓连接,就 是将游戏区域内的两个相同图片按照一定的规则连接起来并消除,在这里,游戏区域中已经保存了多个 Piece 对象,我们在点击连接时,只需要将这些 Piece 对象消除就可以实现连接。 7.4.1 图片选择 在图片连接前,我们需要选择一张图片,并标示它已经被选择了。只需要在为鼠标的点击加入事件 监听器。当鼠标在游戏区域进行点击后,根据点击的坐标得到具体的某个 Piece 对象。新建一个 findPiece 方法,根据鼠标的 x 坐标和 y 坐标(即鼠标点击的某个点)获取点击的那个 Piece 对象。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java public Piece findPiece(int mouseX, int mouseY) { Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·11· //由于是在本类的 board 中找 Piece 对象, 如果 board 为空, 即游戏区域中没有方块 if (this.board == null) return null; //由于我们在创建 Piece 对象的时候, 将每个 Piece 的开始坐标加了 //GameConfiguration 中设置的 beginImageX/beginImageY 值, 因此这里要减去这个值 int relativeX = mouseX - this.config.getBeginImageX(); int relativeY = mouseY - this.config.getBeginImageY(); //如果鼠标点击的地方比游戏区域中第一张图片的开始 x 坐标和开始 y 坐标要小, 即没有找到方块 if (relativeX < 0 || relativeY < 0) { return null; } //获取 relativeX 坐标在游戏区域数组中的一维值, 第二个参数为每张图片的宽 int indexX = getIndex(relativeX, this.board.getCommonImageWidth()); //获取 relativeY 坐标在游戏区域数组中的二维值, 第二个参数为每张图片的高 int indexY = getIndex(relativeY, this.board.getCommonImageHeight()); //返回本对象中游戏区域数组的某个值 return this.pieces[indexX][indexY]; } 通过 findPiece 方法,我们可以通过鼠标定位到数组中的某个 Piece 对象了,接着,还要处理当选 择了某一个 Piece 对象时,需要标识它为已经被选中的状态,这时候需要告诉鼠标监听器,鼠标选择了 哪一个 Piece,再告诉 GamePanel 当前选择了哪一个 Piece,让 GamePanel 去重新绘画。这里需要注 意的是 GamePanel 只允许出现一个被选中的 Piece 对象,当选择第二个的时候,如果可以相连,那么 将设置 selectPiece 为 null,如果不可以相连,那么 selectPiece 为选择的第二个。运行游戏并对图片进 行选择效果如图 7.6 所示。 图 7.6 方块被选中的效果 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·12· 图 7.6 中可以看到,游戏区域中最左上角(0,0)的那个方块已经被选中。我们需要为 GamePanel 加入一个集合来保存已经选择的 Piece 对象,当选择了一个 Piece 对象后,将其加入到集合中。每选择 一次,都需要对集合进行判断,如果集合的大小为 0,则直接加入到集合中,如果集合大小为 1,就表 示之前已经选择了一个 Piece,就可以判断是否可以消除。 7.4.2 创建连接的相关对象 在实现连接功能前,我们还需要准备一些对象,用于封装在连接过程中会用到的对象,例如,连接 是由某些连接点构成的,这里需要一个 Point 对象,连接可以看作一个 LinkInfo 对象,即表示连接信息。 LinkInfo 里有一个集合属性,用于存放每一个 Point。具体代码如下: 代码清单:code\linkgame\src\org\crazyit\linkgame\commons\Point.java public class Point { private int x; //记录这个点对象的 x 坐标 private int y; //记录这个点对象的 y 坐标 //这里需要重写 Object 的 equals 方法, 用于判断两个点是否为同一个 public boolean equals(Object object) { //如果 object 是 Point 类型 if (object instanceof Point) { //将参数强制转成 Point 对象 Point p = (Point)object; //当两个 Point 对象的 x 坐标和 y 坐标同时相等的时候, 表示它们是同一个点 return (p.getX() == this.x && p.getY() == this.y) ? true : false; } return false; } //下面省略 setter 和 getter 方法 } 上面新建一个 Point 类,用于保存点的 x 和 y 坐标,注意,该类重写的 equals 方法,用于判断两个 Point 对象是否为同一个点,判断标准为两个点的 x 坐标与 y 坐标是否一致。 代码清单:code\linkgame\src\org\crazyit\linkgame\commons\LinkInfo.java public class LinkInfo { private List points = new ArrayList();//创建一个集合用于保存连接点 //返回连接集合 public List getLinkPoints() { return points; } } 该类只提供三个构造器和一个返回存放连接点集合的方法,构造器用于创造连接点信息对象,如果 两点可以直接连接,则调用两个参数的构造器,两个点之间需要有一个转折点才可以相连的话,需要调 用三个参数的构造器,两个点之间有两个转折点的话,就需要调用四个参数的构造器。图 7.7 解析这三 个构造器的作用。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·13· 图 7.7 LinkInfo 类的描述 如图 7.7,我下面的编码就是按照这三种方式来实现,先判断能不能直接相连,再判断一个转折点 的情况,最后为两个转折点的情况。我们还需要 GameListener 类根据 GameService 的 link 方法返回 的 LinkInfo 对象进行处理,让 GamePanel 对象进行重绘。 为 GameService 接口加新方法,让 GameListener 调用。在 GameService 接口中添加 link 方法, 让 GameServiceImpl 去实现它即可,link 方法的参数就是两个 Piece 对象,返回值就是我们上面所定义 的 LinkInfo 对象了,表示可否根据两个 Piece 对象返回一个 LinkInfo 对象,如果返回 null,则表示两个 对象不可连接。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\GameService.java //连接两个 Piece 对象, 可以连接, 返回 LinkInfo 对象 LinkInfo link(Piece p1, Piece p2); 我们需要对游戏的游戏区域进行分析,连接的情况在图 7.7 已经描述了,那么接下来就是两个 Piece 在游戏区域中的关系了,具体可分为:p1 与 p2 在同一行(indexY 值相同),p1 与 p2 在同一列(indexX 值相同),p2 在 p1 的右上角(p2 的 indexX > p1 的 indexX,p2 的 indexY < p1 的 indexY),p2 在 p1 的右下角(p2 的 indexX > p1 的 indexX,p2 的 indexY > p1 的 indexY),这里为什么不讲 p2 在 p1 的 左上角和 p2 在 p1 的左下角呢,这种情况,我们可以重新执行 link 方法,将 p1 和 p2 两个参数的位置 互换即可实现。图 7.8 讲解了这几种位置的情况。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·14· 图 7.8 两个 Piece 对象的位置关系 注:如果 p2 在 p1 的左上角和左下角,也就是 p1 在 p2 的右上角和右下角,也在上面的情况中。 现在了解了这几种情况后,下面就可以着手写一些工具方法,用于处理在上面这几个情况时会用到 的一些公用方法。 7.4.3 准备获取通道的工具方法 现在准备几个工具方法,在实现时我们需要用到的。我们需要获取一个 Piece 向上的通道,向下的 通道,向左的通道,向右的通道,什么叫通道呢?如图 7.9 所示。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·15· 图 7.9 通道的描述 弄清楚什么是通道的概念以后,我们写获取通道的程序: 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //判断 GamePanel 中的 x, y 座标中是否有 Piece 对象 private boolean hasPiece(int x, int y) { return (findPiece(x, y) == null) ? false : true } //给一个 Point 对象,返回它的左边通道 private List getLeftChanel(Point p, int min, int pieceWidth) { List result = new ArrayList(); //获取向左通道, 由一个点向左遍历, 步长为 Piece 图片的宽 for (int i = p.getX() - pieceWidth; i >= min; i = i - pieceWidth) { //遇到障碍, 表示通道已经到尽头, 直接返回 if (hasPiece(i, p.getY())) return result; result.add(new Point(i, p.getY())); } return result; } 编写完左边通道的方法后,按照同样的道理编写获取其他方向通道的方法。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·16· 7.4.4 没有转折点的横向连接 现在让我们先实现最简单的横向连接(没有转折点),可以实现 link 方法。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //实现接口的 link 方法 public LinkInfo link(Piece p1, Piece p2) { //两个 Piece 是同一个, 即在游戏区域中选择了同一个 Piece, 返回 null if (p1.equals(p2)) return null; //如果 p1 的图片与 p2 的图片不相同, 则返回 null if (!p1.isSameImage(p2)) return null; } 上面的代码,如果在游戏区域中选择了同一个 Piece,则直接返回 null,如果 p1 与 p2 的图片不相 同,表示它们不可连,直接返回。再去新增两个工具方法,用于判断一行(一列)的两个点是否有障碍。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //判断两个 y 座标相同的点对象之间是否有障碍, 以 p1 为中心向右遍历 private boolean isXBlock(Point p1, Point p2, int pieceWidth) { //如果 p2 在 p1 左边, 调换参数位置调用本方法 if (p2.getX() < p1.getX()) return isXBlock(p2, p1, pieceWidth); for (int i = p1.getX() + pieceWidth; i < p2.getX(); i = i + pieceWidth) { //如果有障碍 if (hasPiece(i, p1.getY())) return true; } return false; } //判断两个 x 座标相同的点对象之间是否有障碍, 以 p1 为中心向下遍历 private boolean isYBlock(Point p1, Point p2, int pieceHeight) { //如果 p2 在 p1 的上面, 调换参数位置重新调用本方法 if (p2.getY() < p1.getY()) return isYBlock(p2, p1, pieceHeight); for (int i = p1.getY() + pieceHeight; i < p2.getY(); i = i + pieceHeight) { if (hasPiece(p1.getX(), i)) return true; } return false; } 有了这两个方法之后,我们可以判断两个在同一行(同一列)的点之间是否可连了。下面实现同一 行(同一列)可以连的情况,这里是指没有转折点的情况,修改 link 方法。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //实现接口的 link 方法 public LinkInfo link(Piece p1, Piece p2) { //两个 Piece 是同一个, 即在游戏区域中选择了同一个 Piece, 返回 null if (p1.equals(p2)) return null; //如果 p1 的图片与 p2 的图片不相同, 则返回 null if (!p1.isSameImage(p2)) return null; //如果 p2 在 p1 的左边, 则需要重新执行本方法, 两个参数互换 if (p2.getIndexX() < p1.getIndexX()) return link(p2, p1); //获取 p1 的中心点 Point p1Point = getPieceCenter(p1); //获取 p2 的中心点 Point p2Point = getPieceCenter(p2); //获取每张图片的宽和高 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·17· int pieceWidth = this.board.getCommonImageWidth(); int pieceHeight = this.board.getCommonImageHeight(); //如果两个 Piece 在同一行 if (p1.getIndexY() == p2.getIndexY()) { //它们在同一行并之间可以连,即没有直接障碍 if (!isXBlock(p1Point, p2Point, pieceWidth)) return new LinkInfo(p1Point, p2Point); } return null; } 现在可以测试一下,效果如图 7.10 所示。 图 7.10 实现横向相连 7.4.5 没有转折点的纵向连接 下面实现纵向连接,和横向连接一样,调用工具方法 isYBlock 判断是否可以纵向连接即可,为 link 方法加入如下代码。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //如果两个 Piece 在同一列 if (p1Point.getX() == p2Point.getX()) { if (!isYBlock(p1Point, p2Point, pieceHeight)) {//它们之间没有真接障碍, 没有转折点 return new LinkInfo(p1Point, p2Point); } Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·18· } 如果两个 Piece 在同一列,并且它们可以直接纵向相连(没有转折点),就返回 LinkInfo,效果如图 7.11 所示。 图 7.11 实现纵向相连 上面实现了没有转折点的横向连接和没有转折点的纵向连接,如果在同一行或者同一列的两个 Piece 不能直接连接,即它们肯定会有两个转折点,下面将会实现。 7.4.6 一个转折点的连接 现在我们先看回图 7.8,第二种情况,表示有一个转折点,我们需要找到这个转折点,新增一个遍 历两个通道,获取它们交点的工具方法。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //遍历两个通道, 获取它们的交点 private Point getWrapPoint(List p1Chanel, List p2Chanel) { for (int i = 0; i < p1Chanel.size(); i++) { Point temp1 = p1Chanel.get(i); for (int j = 0; j < p2Chanel.size(); j++) { Point temp2 = p2Chanel.get(j); if (temp1.equals(temp2)) {//如果两个 List 中有元素有同一个, 表明这两个通道有交点 return temp1; //这个交点就是我们所需要的转折点 } Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·19· } } return null; } 这样可以获取两个通道的交点,再新增一个工具方法,用于获取两个 Point 之间的转折点(一个 Point)。代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //获取两个不在同一行或者同一列的座标点的直角连接点, 即只有一个转折点 private Point getCornerPoint(Point point1, Point point2, int pieceWidth, int pieceHeight) { //获取 p1 向右, 向上, 向下的三个通道 List point1RightChanel = getRightChanel(point1, point2.getX(), pieceWidth); List point1UpChanel = getUpChanel(point1, point2.getY(), pieceHeight); List point1DownChanel = getDownChanel(point1, point2.getY(), pieceHeight); //获取 p2 向下, 向左, 向下的三个通道 List point2DownChanel = getDownChanel(point2, point1.getY(), pieceHeight); List point2LeftChanel = getLeftChanel(point2, point1.getX(), pieceWidth); List point2UpChanel = getUpChanel(point2, point1.getY(), pieceHeight); if (isRightUp(point1, point2)) {//point2 在 point1 的右上角 //获取 p1 向右和 p2 向下的交点 Point linkPoint1 = getWrapPoint(point1RightChanel, point2DownChanel); //获取 p1 向上和 p2 向左的交点 Point linkPoint2 = getWrapPoint(point1UpChanel, point2LeftChanel); //返回其中一个交点, 如果没有交点, 则返回 null return (linkPoint1 == null) ? linkPoint2 : linkPoint1; } if (isRightDown(point1, point2)) {//point2 在 point1 的右下角 //获取 p1 向下和 p2 向左的交点 Point linkPoint1 = getWrapPoint(point1DownChanel, point2LeftChanel); //获取 p1 向右和 p2 向下的交点 Point linkPoint2 = getWrapPoint(point1RightChanel, point2UpChanel); return (linkPoint1 == null) ? linkPoint2 : linkPoint1; } return null; } 以上的代码获得两个 Piece 之间的转折点,如图 7.12 所示。 图 7.12 一个转折点,p2 在 p1 的右下角 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·20· 图 7.12 的情况,我们需要获取 p1 向右的通道,再获取 p2 向上的通道,再判断两个通道间是否有 交点,并返回该点即可。当 p2 在 p1 的右上角的情况如图 7.13 所示。 图 7.13 一个转折点,p2 在 p1 的右上角 p2 在 p1 的右上角,即获取 p1 的上右通道,p2 的向下通道,再获取它们的交点即可。在 GameServiceImpl 的 link 方法中加入判断代码,到现在 link 方法的代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //实现接口的 link 方法 public LinkInfo link(Piece p1, Piece p2) { //省略前面横向直接相连与纵向直接相连的代码 //有一个转折点的情况 //获取两个点的直角相连的点, 即只有一个转折点 Point cornerPoint = getCornerPoint(p1Point, p2Point, pieceWidth, pieceHeight); if (cornerPoint != null) return new LinkInfo(p1Point, cornerPoint, p2Point); return null; } 7.4.7 两个转折点的连接 上一小节讲了两个 Point 之间一个转折点的情况,这一节,将实现两个 Point 通过两个转折点进行 相连,两个转折点的情况比较多,可以总结为以下几种:  在同一行,不能直接相连,就必须有两个转折点,分向上与向下两种连接情况。  在同一列,不能直接相连,也必须有两个转折点,分向左与向右两种连接情况。  p2 在 p1 的右下角,这里就有六种转折情况。  p2 在 p1 的右上角,同样地也有六种转折情况。 我们一种一种来解决,先解决在同一行有两个转折点的情况。请看图 7.14,说明它们是如何相连的。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·21· 图 7.14 两个转折点,p1 与 p2 在同一行 图 7.14 可以看到,它们 p1 与 p2 相连,可以在上面连,也可以在下面连,这两种情况都代表它们 可以相连,我们先把这两种情况都加入结果中,到最后再去计算最近的距离。实现时我们可以先构建一 个 Map,Map 的 key 为第一个转折点,map 的 value 为第二个转折点,如 map 的 size 不止 1 的话,证 明这两个 Point 有多种连接途径,我们先返回第一个连接途径,最后再计算最小的连接方式。为 link 方 法添加如下代码。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //该 map 的 key 存放第一个转折点, value 存放第二个转折点, map 的 size 说明有多少个可以连的途径 Map turns = getLinkPoints(p1Point, p2Point, pieceWidth, pieceHeight); if (turns.size() != 0) { for (Object turn : turns.keySet()) { //遍历该 map, 暂时只返回 map 中第一个元素 //获取 map 中第一个元素的 key 值, 即第一个转折点 Point point1 = (Point)turn; //获取 map 中第一个元素的 value 值, 即第二个转折点 Point point2 = turns.get(point1); return new LinkInfo(p1Point, point1, point2, p2Point); } } 上面的代码有点难以理解,可以看图 7.15。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·22· 图 7.15 横向两个转折点的代码解释 图 7.15 中的几条黑线,如果这几条黑线间没有障碍,则最终会加到结果的 map 中。效果如图 7.16。 图 7.16 横向两个转折点的相连 下面再去实现第二种情况,纵向的两个转折点的情况,直接在工具方法 getLinkPoints 后面加上判 断,说明它们两个 Point 在纵向有两个转折点即可。这里直接给出代码。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·23· if (isInColumn(point1, point2)) {//在同一列 //向左遍历 //以 p1 的中心点向左遍历获取点集合 List p1LeftChanel = getLeftChanel(point1, 0, pieceWidth); //以 p2 的中心点向左遍历获取点集合 p2LeftChanel = getLeftChanel(point2, 0, pieceWidth); Map leftLinkPoints = getYLinkPoints(p1LeftChanel, p2LeftChanel, pieceWidth); //向右遍历, 不得超过游戏区域的边框 //以 p1 的中心点向右遍历获取点集合 p1RightChanel = getRightChanel(point1, widthMax, pieceWidth); //以 p2 的中心点向右遍历获取点集合 List p2RightChanel = getRightChanel(point2, widthMax, pieceWidth); Map rightLinkPoints = getYLinkPoints(p1RightChanel, p2RightChanel, pieceWidth); result.putAll(leftLinkPoints); result.putAll(rightLinkPoints); } 注意以上代码中的 2 个 Map,代表着向左与向右两种连接情况。效果如图 7.17 所示。 图 7.17 实现纵向两个转折点连接 纵向两个转折点和横向两个转折点一样,图 7.18 说明为什么需要这样实现。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·24· 图 7.18 代码说明 图 7.18 中的黑色竖线两端,就是我们结果 Map 中保存的 key 和 value,如果这 key 和 value 这两 个点之间没有障碍的话,就加到结果的 Map 中去,如果有障碍,将不会加到结果的 Map 中。下面实现 两个转折点,图 7.19 至图 7.24 讲解两个转折点的情况。 图 7.19 两个转折点 p2 在 p1 右下角的情况 1 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·25· 图 7.20 两个转折点 p2 在 p1 右下角的情况 2 图 7.21 两个转折点 p2 在 p1 右下角的情况 3 图 7.22 两个转折点 p2 在 p1 右下角的情况 4 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·26· 图 7.23 两个转折点 p2 在 p1 右下角的情况 5 图 7.24 两个转折点 p2 在 p1 右下角的情况 6 实现的代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //获取 point1 向下遍历, point2 向上遍历时横向可连接的点 Map downUpLinkPoints = getXLinkPoints(p1DownChanel, p2UpChanel, pieceWidth); //获取 point1 向右遍历, point2 向左遍历时纵向可连接的点 Map rightLeftLinkPoints = getYLinkPoints(p1RightChanel, p2LeftChanel, pieceHeight); //获取以 p1 为中心的向上通道 p1UpChanel = getUpChanel(point1, 0, pieceHeight); //获取以 p2 为中心的向上通道 p2UpChanel = getUpChanel(point2, 0, pieceHeight); //获取 point1 向上遍历, point2 向上遍历时横向可连接的点 Map upUpLinkPoints = getXLinkPoints(p1UpChanel, p2UpChanel, pieceWidth); //获取以 p1 为中心的向下通道 p1DownChanel = getDownChanel(point1, heightMax, pieceHeight); //获取以 p2 为中心的向下通道 p2DownChanel = getDownChanel(point2, heightMax, pieceHeight); //获取 point1 向下遍历, point2 向下遍历时横向可连接的点 Map downDownLinkPoints = getXLinkPoints(p1DownChanel, p2DownChanel, pieceWidth); //获取以 p1 为中心的向左通道 List p1LeftChanel = getLeftChanel(point1, 0, pieceWidth); //获取以 p2 为中心的向左通道 p2LeftChanel = getLeftChanel(point2, 0, pieceWidth); //获取 point1 向左遍历, point2 向左遍历时纵向可连接的点 Map leftLeftLinkPoints = getYLinkPoints(p1LeftChanel, p2LeftChanel, pieceHeight); //获取以 p1 为中心的向右通道 p1RightChanel = getRightChanel(point1, widthMax, pieceWidth); //获取以 p2 为中心的向右通道 List p2RightChanel = getRightChanel(point2, widthMax, pieceWidth); //获取 point1 向右遍历, point2 向右遍历时纵向可以连接的点 Map rightRightLinkPoints = getYLinkPoints(p1RightChanel, p2RightChanel, pieceHeight); result.putAll(downUpLinkPoints); Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·27· result.putAll(rightLeftLinkPoints);//将可以连接的所有点都放到结果中 result.putAll(upUpLinkPoints); result.putAll(downDownLinkPoints); result.putAll(leftLeftLinkPoints); result.putAll(rightRightLinkPoints); 注:上面黑体的几个 Map,就是代表图 7.19 到图 7.24 的 6 种情况。 效果如图 7.25 所示。 图 7.25 实现 p2 在 p1 的右下角两个转折点的连接 下面再实现 p2 在 p1 右上角的情况,这种情况和 p2 在 p1 右下角的情况类似,都是有 6 种情况, 实现过程这里不再详细描述。所有的连接方式已经全部实现了,我们可以去看游戏的效果,体验一下初 步的游戏成果。 7.4.8 找出最短距离 我们在前面实现的时候,只是返回结果 map 中的第一个元素,现在,我们需要从这些元素中找出 最短的距离返回,原来的代码如下。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //该 map 的 key 存放第一个转折点, value 存放第二个转折点, map 的 size 说明有多少个可以连的方式 Map turns = getLinkPoints(p1Point, p2Point, pieceWidth, pieceHeight); if (turns.size() != 0) { for (Object turn : turns.keySet()) { Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·28· //遍历该 map, 暂时只返回 map 中第一个元素 //获取 map 中第一个元素的 key 值, 即第一个转折点 Point point1 = (Point)turn; //获取 map 中第一个元素的 value 值, 即第二个转折点 Point point2 = turns.get(point1); return new LinkInfo(p1Point, point1, point2, p2Point); } } 现在,我们对这些代码作些修改,让它返回四个点(两个选择的点,两个转折点)之间最短的距离。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //获取 p1 和 p2 之间最短的连接信息 private LinkInfo getShortcut(Point p1, Point p2, Map turns, int shortDistance) { List infos = new ArrayList(); //遍历结果 map, 将转折点与选择的点封装成 LinkInfo 对象, 放到集合中 for (Object info : turns.keySet()) { Point point1 = (Point)info; Point point2 = turns.get(point1); infos.add(new LinkInfo(p1, point1, point2, p2)); } return getShortcut(infos, shortDistance); } //在 infos 中获取其四个点最短的那个 LinkInfo 对象 private LinkInfo getShortcut(List infos, int shortDistance) { int temp1 = 0; LinkInfo result = null; for (int i = 0; i < infos.size(); i++) { LinkInfo info = infos.get(i); //计算出几个点的总距离 int distance = countAll(info.getLinkPoints()); //将循环第一个的差距用 temp1 保存 if (i == 0) { temp1 = distance - shortDistance; result = info; } //如果下一次循环的值比 temp1 的还小, 则用当前的值作为 temp1 if (distance - shortDistance < temp1) { temp1 = distance - shortDistance; result = info; } } return result; } //计算 points 中所有点的距离总和 private int countAll(List points) { int result = 0; for (int i = 0; i < points.size(); i++) { if (i == points.size() - 1) {//循环到最后一个 break; } Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·29· Point point1 = points.get(i); Point point2 = points.get(i + 1); result += getDistance(point1, point2); } return result; } //获取两个 LinkPoint 之间的最短距离 private int getDistance(Point p1, Point p2) { int xDistance = Math.abs(p1.getX() - p2.getX()); int yDistance = Math.abs(p1.getY() - p2.getY()); return xDistance + yDistance; } 实现原理:遍历转折点 map 中的所有转折点,与原来选择的两个点构成一个 LinkInfo,再加入一个 集合中,再去遍历这个集合,选取最接近最短距离的那一个 LinkInfo 返回即可。 7.4.9 画上连接线 现在我们得到了 LinkInfo 对象,之前并没有对 LinkInfo 对象进行处理,现在可以在 GamePanel 中 为 LinkInfo 中的 Point 对象画上相应的连接线,为 LinkInfo 画上连接线。 代码清单:code\linkgame\src\org\crazyit\linkgame\view\GamePanel.java private void drawLine(LinkInfo linkInfo, Graphics g) { List points = linkInfo.getLinkPoints(); for (int i = 0; i < points.size() - 1; i++) { Point currentPoint = points.get(i); Point nextPoint = points.get(i + 1); g.drawLine(currentPoint.getX(), currentPoint.getY(), nextPoint.getX(), nextPoint.getY()); } } 在 paint 方法中调用上面的工具方法: //如果当前对象中有 linkInfo 对象, 即连接信息 if (this.linkInfo != null) { drawLine(this.linkInfo, g); //处理完后清空 linkInfo 对象 this.linkInfo = null; } 7.5 加入计分与计时功能 7.5.1 加入计分功能 玩家点击了游戏区域时,程序中需要调用 GameService 的 start 方法,我们需要为开始按钮加入鼠 标监听器,我们可以将这个监听器单独作为一个类,新建 BeginListener ,该类继承于 MouseInputAdapter,只要重写父类的 mousePressed 方法即可。 代码清单:code\linkgame\src\org\crazyit\linkgame\listener\BeginListener.java public void mousePressed(MouseEvent e) { if (this.timer != null) { Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·30· this.timer.cancel(); } this.timer = new Timer(); gamePanel.setSelectPiece(null); gamePanel.setOverImage(null); // 将分数清 0 pointLabel.setText("0"); // 将时间变成原来设置的时间(在 GameConfiguration 中设置) timeLabel.setText(String.valueOf(config.getGameTime())); // 调用 gameService 的 start 方法 gameService.start(); // 开始进行任务 task = new TimerTask(this.gamePanel, this.config.getGameTime(), this.timeLabel); timer.schedule(task, 0, 1000); // 对 gamePanel 进行重新绘制 gamePanel.repaint(); } BeginListener 中的鼠标点击方法较为简单,开始时只要设置分数、时间,并调用 GameService 的 start 方法即可。 实现普通的计分功能比较简单,可以在 GameListener(GamePanel 的鼠标监听器)中判断,当成 功连接了之后,就加上一定的分数,并设置到 GameService 中,最后把结果显示到 GamePanel 的 JLabel 中。GameService 中添加一个接口方法,让 GameServiceImpl 去实现该方法。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java private long grade = 0; //加入分数属性,初始值为 0 public long countGrade() { this.grade += this.config.getPerGrade(); return this.grade; } 效果图 7.26 所示。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·31· 图 7.26 加入计分功能 7.5.2 加入计时功能与游戏的胜利、失败 现在实现连连看的计时功能,本例的计时功能采用倒数模式,当时间到了 0 后,在游戏区内如果还 有图片方块的话,游戏就失败。 先为我们的游戏加入胜利和失败的判断,胜利和失败的提示,是在 GamePanel 中提示的,因此, 在 GamePanel 对象中添加一个 overImage 的属性,用于保存游戏胜利或者失败时图片,并以此作为游 戏胜利和失败的标准,并在 GamePanel 中加入画胜利和失败图片。 代码清单:code\linkgame\src\org\crazyit\linkgame\view\GamePanel.java //如果 overImage 不为空, 则表示游戏已经胜利或者失败 if (this.overImage != null) g.drawImage(this.overImage, 0, 0, null); 这时候,我们需要知道哪里需要时间计算的,首先肯定是游戏开始时时间开始计算,当游戏区中已 经没有方块时,即最后一点成功的连接后,时间计算停止,因此 7.5.2BeginListener 类与游戏区的监听 类 GameListener 都必须可以控制时间。 先编写一个 TimerTask 类,用于定时执行任务。 代码清单:code\linkgame\src\org\crazyit\linkgame\timer\TimerTask.java public class TimerTask extends java.util.TimerTask { private long time; //当前用掉的时候 private GamePanel gamePanel; private long gameTime; private JLabel timeLabel; Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·32· public void run() { //游戏时间已到 if (this.gameTime - this.time <= 0) { //设置游戏图片为失败 this.gamePanel.setOverImage(ImageUtil.getImage("images/lose.gif")); //取消这个任务 this.cancel(); this.gamePanel.repaint(); } //如果游戏仍然继续, 设置时间 this.timeLabel.setText(String.valueOf(this.gameTime - this.time)); this.timeLabel.repaint(); //使用的时间+1 this.time += 1; } } 该类继承 java.util.TimerTask,实现 run 方法,表示需要执行的动作。在 TimerTask 的构造器中, 需要将 gamePanle,gameTime,timeLabel 作为参数传入,首先 timeLabel 是时间显示的的一个 JLabel, 因此必须加入,gameTime 我们保存在 GameConfiguration 对象中,gamePanel 中保存了游戏胜利与 失败的标准。另外再为 TimerTask 类添加一个用掉的时间属性,用于记录游戏已经用掉的时间,并在 run 方法中加一,表示 run 每执行一次,time 的值加一,再用参数的 gameTime 减去 time 属性的值,就是 游戏所剩的时间,当游戏所剩的时间为 0 时,游戏失败,计时器停止。 在 GameListener 中如果成功连接了两个 Piece 对象后,可以判断游戏区是否还有方块的存在,即 判断该数组中象是的每一个 Piece 是否为空,如果都为空,则游戏胜利,在 GameServiceImpl 类中加 入如下 hasPieces 方法,用于判断界面中是否还存在图片。 代码清单:code\linkgame\src\org\crazyit\linkgame\service\impl\GameServiceImpl.java //实现接口的 hasPieces 方法 public boolean hasPieces(Piece[][] pieces) { for (int i = 0; i < pieces.length; i++) { for (int j = 0; j < pieces[i].length; j++) { if (pieces[i][j] != null) return true; } } return false; } 这样,当成功点击连接最后两个方块后,就停止计时器,并设置游戏为胜利状态,并在 GameListener 的 mousePressed 方法最前面加入,当游戏胜利或者,去点击游戏区,就马上返回,不再作任何判断: if (gamePanel.getOverImage() != null) return; 好了,计时功能已经实现了,现在可以去看下游戏效果如图 7.27 所示。 Download at http://www.pin5i.com/ 第 7 章 单机连连看 ·33· 图 7.27 最终的游戏效果 7.6 本章小结 本章主要讲述如何实现一个简单的单机版连连看,详细介绍了如何建立界面,如何将游戏的区域抽 象成一个二维数组,并对该二维数组进行操作,实现了随机游戏数组,实现游戏的相连,游戏的计分功 能与计时等功能。重点介绍了如何在重构代码的过程中优化代码并慢慢的改善既有设计,将游戏的部件 或者各部件的职能抽象成对象。在本章中代码还可以进行重构优化,例如将每个图形界面组件做成单独 的类,再通过工厂模式或者 Java 的反射去获取这些类,用于去创建游戏的界面。 Download at http://www.pin5i.com/ 第 8 章 简单 Java IDE 工具 第 8 章 简单Java IDE工具 8.1 IDE工具简介 IDE 是 Integrated Development Environment 的缩写,即集成开发环境,就是集成了代码编写功能、 分析功能、编译功能、debug 功能等一体化的开发套件。例如 Java 程序员经常使用的像 Eclipse、 NetBeans、JBuilder 等这些功能强大的 IDE 工具。 8.2 Java IDE的主要功能 本章我们主要编写一个简单的 Java IDE 工具,功能并不多,实现文件操作,文本操作,项目管理, Java 文件的编译和运行等基本功能即可。一个真正的 Java IDE 还包括个性化设置,代码的生成,打点 提示,debug,文件搜索等一系列附加的功能,这些功能都大大的方便了程序员的开发工作。在本章中 我们并不需要实现像 Eclipse 这样的优秀工具,只需要将去实现自己的一个拥有简单功能的 IDE,让我 们了解如何去实现这些十分常见的功能。 8.3 建立界面 在开发前,我们需要知道一个 IDE 有些什么界面,需要显示一些什么,注意我们在编写程序的过程 中需要小步快跑,如果一开始定的目标越大,那么往往做到一定程度时会有些不知所措。我们需要建立 的界面确定为图 8.1 的布局。建立好这个布局,我们还需要创建菜单,建立工作空间选择界面,项目、 文件、目录的新建界面。 Download at http://www.pin5i.com/ 第 8 章 简单 Java IDE 工具 ·2· 图 8.1 确定界面布局 图 8.1 借用了 Eclipse 的界面,Eclipse 中的的每个区域都可以自由显示与关闭,我们在本例中并不 需要做到那么复杂,只需要做到以上的布局,并可以自由的拖动每个区的大小即可,这是我们的第一个 目标。 8.3.1 建立主编辑区和信息显示区 按照图 8.1,我们需要先建立主界面的 JFrame,并往里面添加我们所需要的组件。在本例中,我们 需要做到多文件编辑,因此需要 JDesktopPane,因此,我们的主编辑中主要存放一个 Box 对象,Box 对象中放一个 JTabbedPane 作为 tab 页签,再存放一个 JDesktopPane 对象。信息显示区主要是一个 不可编辑的 JTextArea 对象。新建程序入口 Main 类,作为这个 IDE 工具的入口。 代码清单:code\e