用 JAVA 开发游戏连连看
(注:这篇文章是在2004.12完成的,当时是为了向《电脑爱好者》投稿,这是原稿,由于此杂志面向的读者原因,因此文章中有些地方显得过“白”,在此后,稿子经过两次修改,虽然最终得以发表,但已改得基本上没有太多的技术性了,而且两次改稿下来,一共写了近6万字,累~~~,现在将其略作修改放在主页上,希望对大家有所帮助)
提起 JAVA ,相信大家也不会陌生了吧, JAVA 是一门相当优秀的语言。目前 JAVA 领域 J2EE 、 JS 用 JAVA 开发游戏连连看 (注:这篇文章是在2004.12完成的,当时是为了向《电脑爱好者》投稿,这是原稿,由于此杂志面向的读者原因,因此文章中有些地方显得过“白”,在此后,稿子经过两次修改,虽然最终得以发表,但已改得基本上没有太多的技术性了,而且两次改稿下来,一共写了近6万字,累~~~,现在将其略作修改放在主页上,希望对大家有所帮助)
很少有人用 JAVA 来开发桌面游戏,是因为 JAVA 在网络方面的强大优势使人们忽略了 JAVA 在桌面平台上的开发,特别是游戏方面,而并不是因为 JAVA 做不到,而我,却希望通过我的尝试来告诉大家:原来 JAVA 也能做出漂亮的桌面游戏的(我可不是在夸我的程序:)) 1. 由于 JAVA 生来就是为网络服务的,因此,一旦有需要,很容易将单机版的游戏改为网络版的(我现在就在做:)) 2. 由于现在有很多手机都支持 J2ME ,而 J2ME 和 J2SE 是一脉相承的,因此,用 JAVA 编写的游戏可以很容易移植到 J2ME 平台上,想想当你的游戏在手机上运行是一样多么愉快的事情啊。 开发的周期并不是很长,可是开发过程中我也遇到不少困难,也有不少收获,我希望将我的开发过程写下来,与大家共同进步:) 在我的开发过程中,你可以发现我是这么做的: 1. 使用数组实现算法的核心 2. 使用布局和按钮来实现操作 3. 使用布局实现用户界面 4. 使用线程在同一时间做更多的事情 5. 使用静态变量来减少内存的开销,并且保持信息的一致性 6. 使用文件来保存用户的配置信息 7. 使用声音让程序更有生气 (之一)动手前的准备 看看别人现成的游戏 在盖房子之前,我们都会先打好地基,然后搭起框架,最后再就是一点一点添砖加瓦,做软件也是一样的道理,都是从大体的框加向细节部分设计实现,现在,我们开始吧。 其实不管是做软件也好,做游戏也好,只要是写程序,在动手之前是一定会存在需求和分析的,如果不经过一定的分析就开始动手写程序,那么,这个程序一定会很难写下去的,最后的结果可能会导致放弃。 那么,在我们动手之前,让我们先简单的分析一下吧。由于“连连看”并不是一个我们凭空开发的游戏,并且网上也已经有很多别人已经开发好的版本,因此,对于我们来说,我们已经拥有了一个很好的原型(比如说 QQ 游戏中的“连连看”),分析起来也应该是轻松得多。由于 QQ 中的“连连看”是网络版,为了开发上的简便,我们先放弃网络功能,做一个简单的单机版就行了。现在,让我们现在来看一看 QQ 中的连连看吧。 “连连看”的游戏规则其实并不复杂,首先,游戏开始的时候,地图上会有由数张不同的图片随机分散在地图上(并且每张图片会出现偶数次,通常是 4 次),只需要在地图上找出两张相同的图片(点),并且这两个点之前可以用不超过 3 条的直线连接起来就可以消除这两点,如此下去,直到地图上的点全部消除完就算游戏结束,怎么样,规则很简单吧?:)我们的开发就完全按照些规则来吧。 分析游戏规则找出算法 通过上面的分析,我们已经知道了游戏规则,可是,我们怎么样去实现呢? 其实所谓的实现也就是算法,那我们怎么样找出算法呢?别急,让我们来看一看上图,或者自己动手玩一玩别人做好的。 通过对上图的观察,我们发现,可以将游戏中的地图看作是一个二维数组,其中的所有图片(以下称“点”)可以看作是数组中的一个具体的元素。那么,游戏中相同的图片可以看作是数组中不同位置两个值相同的元素。至于直线,让我们给组数中的每一个元素赋一个特殊的值如 0 ,以表示地图上空白的位置。并且同时规定:当连续的具有该特殊值的点的横向索引或纵向索引相同时,可以认为这是一条直线,比如下图: 当数组中两点的值相同并且两点间只需要不超过 3 根直线能连接起来的时候,就让这两点的值变为 0 ,如果数组中全是 0 值的点,就认为游戏已经结束:) 怎么样,算法够简单了吧:) 用伪代码来描述程序的结构 现在,我们用伪代码来描述一下游戏,假设用户开始了游戏:
看看有没有什么问题?如果没有问题,我们进入下一步吧:) 确定程序需要的模块 当伪代码完成后,并且在我们的大脑里转了几圈发现没有问题后,现在就可以开始进行模块的划分工作了。 我们还是再看一看 QQ 中的“连连看”,整个程序只需要通过鼠标操作就可以了,按照 MVC 的结构来进行程序设计,那么我们需要一个 Model ,用来完成整个程序的核心算法;一个 View ,用来显示用户界面,当然还需要一个 Control ,用来处理用户鼠标的操作,这样一来,只需要三个模块就可以完成了。 1. 算法模块 2. 控制模块 3. 显示模块 现在我们再细想一下,这样真的就可以了吗? Model 是一定需要的,这是整个程序的灵魂。然而对于 Control (控制)来说,控制会分为用户游戏中的操作和游戏提供的功能性操作,如果所有的操作包括游戏中的游戏控制、游戏界面上的帮助、设置等都要通过一个 Control 来完成,那么这个 Control 一定会比较大,并且会比较复杂,而过于复杂的模块通常都是比较容易引起错误,或者导致编码困难的,因此,我们就有必要将具有类似功能的操作分开,以减少各个模块的复杂程度,同时,也可以使模块的功能更单纯(这也是 OO 中所倡导的)。 现在我们将菜单操作和游戏操作分开,分开后的模块如下:
以上是程序的最主要的模块,除此之外,由于开发过程中的需要,对于每个模块,我们可能还需要一些辅助的模块来使程序更加完善,由于这些模块并不会对程序有太大的影响,因此,我们可以在需要的时候再来添加。 (之二)实现游戏的算法 将游戏地图转换为数组来描述 算法总是很枯燥的,没有直接设计界面来得舒服,然而,算法却是整个程序的核心,所以,仅管枯燥,我们还是得耐心地完成这一步。 在进行程序算法的设计时,我们首先要尽可能抛开一些无关紧要的部分,这样可以使算法看起来直接明了,但同时也要考虑弹性,以便将来扩充。 在前面已经说过了,整个游戏的核心算法也就是以二维数组为主体的算法,那么,定义一个二维数组是必不可少的了。 二维数组究竟应该有多大呢? 10X10 是不是小了, 20*20 呢,大了?究竟多大比较合适?为了考虑到程序以后改动的需要,我们还是定义成变量吧,这样以后要改动的时候,只需要改动一下变量的值就行了,因此,我们现在为程序增加一个类,使之专门用来保存与程序有关的一些数据。
至于为什么要定义成 public static final ,这个,自己想想就知道了:)还不知道?晕,看看书吧:( 现在,我们将这个类起名为 Map ,同时,我们规定,为了描述地图中空白的区域,我们使用 0 来表示。
初始化游戏地图 在地图初始化的时候,我们需要用一些“随机”的数字来填充这张地图,之所有将“随机”用引号括起来,是因为这些数字并不是真正意义上的随机:首先,数组中具有相同值的元素只能出现 4 次(具有 0 值的元素除外),其次,这些数字是被散乱的分布在数组中的。 要使元素出现 4 次,那么数组中所有不重复的元素个数最大为数组的大小 /4 ,为了简单起先,我们使这些元素的值用 1 、 2 、 3 ……进行编号。 要想将这些分配好的元素再分配到二维数组中,我们需要一个一维数组来辅助完成这项工作。 首先,我们按照二维数组的大小来建立一个大小相同的一维数组,并且,我们规定数组中出现的不重复的元素的个数(元素个数的多少代表了地图的填充率,填充率越高,表示游戏难度越高),同时,我们也要保证数组的长度能被 4 整除(目前是这样,其实不是必需的),因为相同的元素会出现 4 次。因此,我们定义一个变量,用来表示地图上可能出现元素种类的最大个数,同时也定义一个变量,表示目前地图上出现的元素的个数。
在,我们将这些元素放置在一维数组中:
这时,一维数组初始化完成了,可惜数组中的元素是规规矩矩按顺序出现的,如果不打乱就填充到地图中,这游戏似乎也太简单了(因为相邻的点一定可以消除啊),现在,我们得想个办法打乱这个数组。 怎么打乱这个数组呢?好办,我们来看看,假设数组的原始排列是这样的: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9][10] [11] [12] [13] [14] [15] 从最后一个元素 [15] 起,依次与此元素之前的某一个元素将值互换,完成后再从 [14] 起,与在 [14] 之前的某一个元素将值互换,直到 [1] 与 [0] 的值互换后,如此一来,数组就被完全打乱了,如果还不明白,我们来看一看下图: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9][10] [11] [12] [13] [14] [15] 在 [15] 之前有 15 个元素,产生一个 15 以内的随机数,比如说 8 ,再将 [15] 和 [8] 的值互换,变成了如下: [0] [1] [2] [3] [4] [5] [6] [7] [15] [9][10] [11] [12] [13] [14] [8] 再从 [14] 号元素开始,产生一个 14 以内的随机数,比如说 10 ,互换 [14] 和 [10] 的值: 改变前: [0] [1] [2] [3] [4] [5] [6] [7] [15] [9][10] [11] [12] [13] [14] [8] 改变后: [0] [1] [2] [3] [4] [5] [6] [7] [15] [9] [14] [11][12] [13] [10] [8] 怎么样,略施小技就搞定了,简单吧?算法如下:
现在,一维数组中的元素已经被打乱了,现在我们只需要按顺序依次填充回二维数组中就行了,这样,二维数组中的值就一定是乱的。
( 打乱后的数组,感觉如何,虽然难看了点,但很有用 ) 对数组中两个元素是否可以消除的判断 地图的初始化已经完成了,现在的问题是,我们怎么样才能知道数组中的两个元素是否可以消除呢? 根据游戏规则,如果两个点之间可以用不超过 3 条直线连接起来,这两点就可以消除,现在我们来分析一下所有可能的情况: 两点之间只需要一条直线连接: (图略了……) 由上图可以看出,如果两点间只需要一条直线能够连接起来,则 A 、 B 两点的横坐标或纵坐标必定相同,有了这个条件,我们判断 A 、 B 两点是否只需要一条直接连接就简单了许多。 这段代码比较简单,所以就不写出来了,大家可以看看源程序,只不过需要注意的是,我们将横线连接和竖线连接分开来处理,这样做是为了后面工作的简单。
( 注意:为了简单省事,我们用 java.awt 包中的 Poin(x, y)t 来描述二维数组中元素的坐标,但是有一点要特别小心, x 和 y 与二维数组中元素的下标值 恰好相反 ,如左上图中 A 的下标为 array[1][0] , Point 的描述却是为 Point(0, 1) ,如果不注意这一点,程序会出错的。 ) 两点之间需要两条直线连接: 如上图, A 、 B 两点如果需要两条直线连接起来,有可能有两种方式,于是,我们可以巧妙的构建一个 C 点和一个 D 点,并且规定 C 点的横坐标为 A 点的横坐标, C 点的纵坐标为 B 点的纵坐标, D 点的横坐标为 B 点的横坐标, D 点的纵坐标为 A 点的纵坐标(这一点很重要,因为 C 、 D 决定了 AC 、 BC 、 AD 、 BD 的连线方式),如下图: 如果此时 C 点(或 D 点)能同时满足 AC ( AD )、 BC ( BD )只需要一条直线相连,就表示 A 、 B 之前能够使用两条直线连接起来,并且 C 点( D 点)为拐点(以后会用上的)
( 注意:由于 C 点和 D 点的构建方式确定了 AC 、 BD 永远是竖连线、 BC 、 AD 永远是横连线 ) 两点之间需要三条直线连接: 这种方式是最复杂的了,我们还是先分析一下出现三条直线的所有可能性吧。 ( 图 A) ( 图 B :这种方式比较容易忽略掉 ) 以上图说明了两点间三条直线的所有可能性,和二条直线的情况相比,拐点是两个,麻烦了一点,但也不难处理。 下面我们来分析一下该怎么处理二个拐点的情况(三条直线)。由上面的图可以看出, A 、 B 如果要通过三条直线相连,则必须有 C 、 D 两个拐点,如果能确定下 C 、 D ,问题就好解决多了。 怎么样来确定 C 、 D 两点呢?我们以图 A 中的左图为例,在此之前,我们规定 C 点与 A 点在同一竖线上, D 点与 A 点在同一直线上。同时,从图中我们也可以看出, A 、 B 两点间如果只能通过三条直线连接起来,则必定有一条直线处于 A 、 B 的横向夹线纵向夹线中(如画圈的线)。 我们假设相等的线为在 A 、 B 两点的横坐标相等、纵坐标为 0~Setting.ROW 构成的区域上 ( 如图 ) 。 我们先扫描出所有的线,并且我们发现,如果在 A 、 B 构成的区域中存在两个点能构成直线,那么,这条直线就 有可能 是我们需要的直线,我们称此线为符合线,如果符合线的两端( C 、 D 两点)与 A 、 B 两点分别能 AC 、 CD 、 DB 能构成直线的原则,则 AB 间一定可以通过三条直线连接起来。(这个可能我描述得不太清楚,但相信你应该不难明白的) 我们把所有找到的符合线保存起来,并且要记录下符合线是横向上的还是纵向上的,然后通过这些找到的符合线,依次和 A 、 B 两点进行判断,一旦找到这样的 C 、 D 两点,能满足 AC 、 CD 、 DB 这三条线上都没有障碍,那么, A 、 B 就可以消除了。还是用算法来描述一下吧。 首先我们构建一个保存 C 、 D 点的类 Line ,并且要指明 C 、 D 的方向是横向还是纵向。
同时,由于在扫描的过程中,会找到多根符合线,因此,我们可以用 Vector 来保存这些找到的符合线(为了提高效率,也可以使用 LinkedList 来保存)。
扫描两点构成的矩形内有没有完整的空白线段
现在,我们对所有找到的符合线进行判断,看看 AC 、 DB 是否同样也可以消除
消除该两个元素时,只需要将两个元素的值置为 0 即可。 更多的功能:自动寻找匹配的点 现在,算法基本上是实现了,但是,为了使游戏更丰富,我们还需要实现更多的功能,现在,我们添加一个自动寻找匹配的点的功能。 该功能需要分两步走: 第一步,从左上向右下搜索二维数组中第一个值不为 0 的元素 A ,找到该点后,然后再从该点向后找到一个值与该点值相等的元素 B ,然后对这两个元素进行是否可消除的判断,如果可以消除,则说明该两点匹配,如果不能消除,则继续寻找与 A 点值相等的 B 点,如果找不到 B 点,则寻找下一个 A 点,依次下去,直到找不到这个 A 点,这就表时地图上已经不存在可消除的点了,我们用伪算法描述如下:
更多的功能:刷新地图 刷新地图的功能其实非常简单,只是需要将二维数组中现有的元素打乱后然后放回这个二维数组中就行了,我们还是只简单的用伪算法描述一下吧:)
完成代码并且测试 现在,算法部分的代码大体上算是完成了,我们可以进行一下测试,测试应该很简单,限于篇幅的原因,我就不在这里写出测试用的代码了,但可以说明一下如何进行测试: 我们可以构建一些特殊的地图,然后用 Match(Point a, Point b) 方法来判断我们指定的两点是否可以消除,或者使用自动寻找的功能,找到相同的两点后,消除这两个点,当地图上没有可消除的点时,就刷新地图,直到点全部消除完成。同时,我们还可以在 horzionMatch(Point a, Point b) 、 verticalMatch(Point a, Point b) 等加上输出语句,来看看匹配时程序执行到哪了,换几个不同的点多测试几次,如果没有问题,那就应该没有问题了:) (之三)将算法与界面结合起来 用布局和按钮来实现算法的界面 上面已经说完了算法,相信大家也迫不及待的想进入界面的设计了吧,好了,多的不说,我们开始吧。 既然我们的算法是基于二维数组的,那么我们也应该在界面使用一个能反映二维数组的控件。这里有两种方式,一种使用表格来实现,第二种是使用布局来实现。 相对而言,用表格来实现二维数组要简单一些, JAVA 提供的布局方式可能是令大家都不习惯的一种界面设计方式,不过,在这里,我还是选用了布局的方式来实现界面,因为,当你设计完界面后,你会发现 JAVA 的布局也是有它的优点的。 JAVA 提供了 BorderLyout 、 GridLayout 、 FlowLayout 等布局,而在这些布局中, GridLayout 布局是最接近于表格方式的一种布局,同时,我们使用 Jbutton 控件来作为数组中的元素。
当然了,上面的代码太简单了,简单得连一些基本的事情也没有做,比如说在按钮上加上文字,并且,要保证每个按钮上的文字与算法中二维数组中相对位置的元素的值相对应。现在我们运行一下看看,怎么样,像不像? 界面出来了,那我们怎么知道用户点击的是哪个按钮呢。其实这也不难的,我们在放置按钮的时候,对每个按钮的 CommandName 属性赋一个值,通过这个值,我们就可以知道该按钮对应二维数组中元素的具体位置了,现在,我们按放置按钮的代码改动一下:
在按钮的事情处理中,再把这个值取出来,并且还原到坐标上,
怎么样,是不是如此简单?已经知道了用户是按的哪个按钮,并且知道了该按钮对应的二维数组中的值,剩下的事情就不用我说了吧:) 现在,我们需要用 JButton 控件来将算法中二维数组中每个元素的值区别出来,最简单的就是用不同的数字来显示,有了这个功能后,我们就可以很方便的实现界面与算法的同步了。
设计用户界面 好了,到现在为止,不管怎么说,我们已经大体上实现了与算法有关的界面,虽然还很难看,但总算是有了个界面,也算是对自己的一点安慰吧。 现在,我们要设计用户的界面了,用户的界面也无需让我们多考虑了,既然已经有现成的,我们就抄吧(其实是我天生缺乏美术天份,与其自己做得难看,还不如抄抄别人现成的)。这部分没有太多要说的,我就用图来说明一下吧。 同样,整个界面也是使用了 JAVA 提供的布局方式,这里使用的是 BorderLayout 布局,我们需要注意的就是先算计好每个区域所需要尺寸的大小,并且使用 JPanel 的 setPreferredSize 方法来固定好大小,同时,根据自己的需要,添加命令按钮如开始、退出等。 为用户界面添加功能 用户界面的设计已经出来了,但是相应的功能我们还没有实现,现在,让我们来一起实现这些功能吧。 同样,我们还是参照一下别人游戏中有哪些功能,并且根据自己的需要来进行选择,在这里,我们只需要提供开始游戏、刷新地图、提示、炸弹等功能就好了(其它的暂时可以不管,以后需要的时候可以再添加)。 开始游戏: 当用户开始游戏的时候,我们需要将地图初始化并且显示出游戏界面。 刷新地图: 此功能我们在算法部分中已经实现了,现在要做的就是使显示部分能够同步进行。 提示功能: 此功能在算法中也已经实现,我们需要做的就是怎么样将找到的这两个并且向别人指出。 炸弹功能: 此功能其实就是在提示功能的基础上,加上自动消除两个点。 好了,相信这些对我们并非难事,我们还是继续下一步吧。 (之四)添加更多的功能 计分功能 大体上我们的程序已经可以跑了起来,可惜,就这么玩玩也太没有意思了,总得有个计分的吧。虽然我们不知道别人是怎么计分的,可是,程序是我们自己动手写的,我的地盘我做主,看看我是怎么计分的吧(实现可以放在下一步)。 1. 规定用户游戏时间,游戏时间为地图中方块数 * 4 ,比如说有 120 个方块,那么游戏时间就是 480 秒,如果用户在 480 秒内没有完成游戏,则没有时间奖励分 2. 时间奖励分为剩余时间 * 1 ,即在规定时间内完成游戏的,每提前一秒钟奖励 1 分 3. 当已经选择第二个点后,如果选择的第二个点与第一个点之间无解,扣 1 分,如果有解,则增加 10 分 4. 刷新功能会降低游戏难度,因此,当用户刷新地图时,扣 8 分 5. 提示也要扣分,理由同上,使用一次扣 10 分 6. 炸弹功能,这个更变态啊,使用一次扣 12 分 好了,计分规则定下来了,合理不合理先放在一边,不过,为了方便以后修改计分规则,我们还是将这些定义为常量吧。
现在,我们来一一实现计分功能。 首先,我们定义一个变量来保存用户的总分,另外,消除功能、刷新功能、提示功能和炸弹功能由于都有现成的方法,因此,我们只需要在这些方法中添加上计分功能就行了,唯一麻烦一点的就是计算时间分。 为了满足计时的功能,我们需要在游戏开始的时候记录下当前的时间,同时,在游戏完成时也要记录下完成时的时间,两者相减后就是用户游戏的时间了(如何知道游戏在什么时候结束,这个大家想想,不难办到:))。记录时间可以使用 System.currentTimeMillis() 方法,不过需要注意的是,这是以毫秒来计时的,要记得除以 1000 。 高手排行榜 分数是记下来了,自己每次游戏完成都能知道自己的分数,感觉是不错,不过,光和自己比不够劲啊,要是能和别人比就更好了,嗯,继续满足你的“愿望”:) (注:以下功能需要一个支持动态页面的空间) 最简单的办法,就是我们在互联网上建立一个保存用户分数的数据库,当用户每次完成后,都让用户可以选择将分数发送到这个数据库中,同时,我们需要做一个后台程序,该程序接收到用户提交来的姓名和分数后,就保存到数据库中,并且,根据当前的分数,告诉用户当前的 Top10 排行榜。后台程序可以使用网页来实现,至于怎么做,这个与程序关系不大,我们只看一看程序这一块怎么做吧。 首先,做法有两种,一种是自己使用 Socket 类来完成 HTTP 的 Get/Post 操作,另一种就是使用 URL 类,前者适用性前(如 J2ME 就不支持 URL 类)后者用起来方便一些,在此,我们就用后者来实现。 游戏完成后,当用户需要提交分数到互联网上时,先弹出一个对话框让用户输入姓名,同时将用户的姓名和分数,还有自己已经做好的页面“凑”成一个合法的 URL 地址,然后,将这个 URL 地址简单的 new 一下 (new 的同时就已经将这些数据发送到了互联网上了 ) ,比方说我做的排行榜的页面为 http://www.xhai.com/kyodai/top10.asp ,用户的姓名为“ ZhangJian ”,分数为 2000 分,那么,根据这些信息,我拼凑成的网址就是应该是类似于http://www.xhai.com/kyodai/top10.asp?name=ZhangJian&score=2000 这样样的地址,然后, newURL(“http://www.xhai.com/kyodai/top10.asp?name=ZhangJian&score= 2000”) 就行了:)是不是够简单的?当然,如果有需要,还可以对这些数据进行一下加密,以防用户“作弊”。 (现在已经废弃) 为了在程序中显示排行榜的页面,我们需要程序能够处理页面的功能,显示,如果自己去实现这个功能,虽然并不复杂,但是 JAVA 提供的 JEditorPane 控件却更适合完成这项工作。 我不想用完整源程序来说明,毕竟那太费篇幅,只是截取一段代码来描述一下吧。
使用配置文件来保存用户信息 假如用户每次要发送成绩到互联网上时都要输入姓名,那有多麻烦啊,为什么我们不将用户的姓名保存起来呢? 其实,不光是用户的姓名需要保存,以后我们添加的其它选项也需要保存起来,因此,我们必须实现一个保存 / 读取用户信息的类,就如果 Windows 的注册表或者 ini 文件一样。在这里,我不想多费口舌,只是告诉一下大家怎么做就行了。 JAVA 提供了一个 java.util.Properties() 类,这个类就是用来保存 / 读取配置文件的,它的 setProperty() 和 getProperty() 方法就是分别用来保存 / 读取配置文件信息的,就如同使用 ini 文件一样,具体的用法查一查 API 就清楚了。 (之五)完善用户界面 让界面更动起来 整个程序的界面总算是出来了,可惜不太漂亮,这种界面,别说别人,就连自己也不愿意多看几眼,因此,做一些适当的美化工作还是非常有必要的。 想要让界面变得漂亮,最好的办法就是大量使用帖图,可惜,图片太多不仅会影响到程序的执行效率,同时,由于美工不是我们的长项,因此,我们还是走走捷径算了。 首先,我们将各个用户控件设置好背景色,这是最简单的方法了,只要颜色搭配得当,也是最有效的办法了。 其次,为了使界面看上去不那么单薄,因此,我们可以想办法使界面更有立体感。好在 JAVA 为我们提供了许多种 Border 控件,通过 Border 控件来组合其它控件的使用,将会使界面变得有立体感。 第三,使用图片。以上的方法,只会让控件变得漂亮,但控件仍然有控件的影子。而大多数人一看到控件,第一反应就会想起应用程序,而不是游戏。既然我们做的是游戏,那么,我们就可以自己做一些简单的图片来“掩蔽”控件的本来面目。好在这个游戏按钮不多,做几个也不太难。 经过以上的几步操作,界面变得漂亮多了,不是吗? 改变鼠标光标 很少看见过有人改变程序中光标的样子,是不是 JAVA 做不到?其实 JAVA 已经考虑到了这一点,只不过很少有人想去这么做这已。createCustomCursor 就是为我们准备的,其具体用法是: createCustomCursor(Image cursor, PointhotSpot, String name) cursor 是我们要设置为光标的图片, hotSpot 是图片显示在实际光标位置的位移, name 就是光标的名字拉! 好了,现在我们找一张合适的图片来作为程序的光标吧,看看效果如何? 如果,你还不满意,或者,你要说:我们的光标不能动啊,人家 QQ 上的光标可是会动的呢。 这确实有点麻烦,因为 JAVA 提供的方法只能显示静态的光标,但是,通过一些简单的方法,我们还是可以实现的。 由于 JAVA 的光标只能是静态图片,因此,要显示动态的光标,我们只能是定时更改光标的图片,首先,我们准备好一系列图片,然后,我们需要使用 javax.swing.Timer(int,java.awt.event.ActionListener) 方法来设置一个定时器,当定时器的事件触发后,我们就改变光标显示的图片。在本程序中,由于考虑到效率问题,我们就没有使用动态光标了,不过,如果你有兴趣,可以试试的:) 将时间 / 分数的显示作为动画来显示 为了让程序更有活力,我们可以适当的将游戏中一些显示信息的地方做成小动画,比如说时间和分数。 在动画的处理过程中,我们要保证动画只是起到作为游戏的点缀,而不能影响到游戏的正常进行(比如说不能在动画进行的过程中中断游戏),同时,动画也不能太喧宾夺主,这样也会分散别人在游戏中的注意力的。 为了保证动画过程和游戏过程的平行运行,因此,我们非常有必要将动画分离成一个独立的控件,并且要保证动画有自己单独的线程来运行。好了,现在我们先来看看我们怎么把时间作为动画分离出来的吧。
怎么样,时间的显示是不是可以动了?为了使文字在使用大字体的情况下显示得更漂亮一些,我们可以适当的使用抗锯齿效果, JAVA 提供了现成的方法,很简单的,现在我们将 paint(Graphics g) 改动一下:
Graphics2D 是 JAVA 提供的增强型图形处理包,可以实现许多以前 Graphics 实现不了的功能。在由于系统记录的时间是以毫秒为单位记录的,因此,在上面我们需要写一个 getTime() 方法来将时间的显示格式化成类似于 123.4 这种形式。 时间的动画完成了,现在我们开始制作分数变化的动画。其实分数动画的基本设计方法与时间动画相同,但是有一点不同的是,时间动画在用户游戏的整个过程中是一直运行的,而分数的动画是要根据用户当前得分的情况进行变化的,也就是说,分数的动画是被用户干预的。 现在,我们将动画运行的主过程改动一下。为了简单起见,我们只考虑分数从低向高的变化,不考虑分数从高向低的变化。
当每次用户的分数发生变化时,我们可以使用 setScore(int l, int c) 方法同步分数显示的动画效果。 实现消除图片时的动画效果 在完成了上面两个动画,现在我们来设计消除图片时的动画,动画的效果是当我们选中两个可消除的图片后,找到两个图片之间的连接直线,并且从第一个选中的图片向第二个选中的图片之间做点的拖尾效果的动画(如果不是很明白,看看程序运行的效果就知道了)。 这个动画比前两个动画处理起来要麻烦得多,因为,它牵扯到的部分比较多:首先,需要记录下两点之间的直线以及这些直线出现的先后次序,其次,还需要知道每条直线的方向,是横向还是纵向,第三,还需要在这些直线上依次进行动画效果的处理。这个动画效果不仅牵扯到了界面,还牵扯到了算法。现在,我们还是一起看看怎么实现吧。 首先,在算法中,当每次消除两个点的时候,我们就需要记录下这两个点之间的连线情况,是横着的还是竖着的,是 1 条直线还是 2 条或者 3 条。因此,对于 Map.java 中的 verticalMatch(Point a, Point b) 方法就必须改动一下
recorder 说明了当前是否要求记录下路径,比如说在使用提示功能的时候,虽然我们是会调用该方法来进行试控两点是否可以消除,但实际上,这两个点并不是真的需要消除,所以,在这种情况下,就不应该记录下路径。 AnimateDelete 是我们新创建的一个类,其作用就是处理消除时的动画效果的,该类有几个构造函数,依次如下:
上面的 public AnimateDelete(JButton[]dots) 构造函数看起来似乎没用,实际上,这个是非常有用的,我后面会提到的。 好了,现在可以在 Map 算法中的 horizonMatch 、 verticalMatch 、 oneCorner 、 twoCorner 等方法中添加消除动画的构造函数了。 在 AnimateDelete 的几个构造函数中,还要记得将每种方式中涉及到的直线的路径记录下来,最后,将路径上的这些元素依次保存在一个一维数组中,同时,我们也需要记录下路径的长度,以便动画时的操作。现在我们来看看动画部分如何处理。
由于消除动画可以几个消除动画同时进行,因此,对于此效果,我们就要在每次使用该效果时实例化一个该对象了。 为程序添加声音 现在,我们来为我们的游戏添加声音。虽然 JAVA 自从出道之日起就能够处理声音,但是那只限于在 APPLET 中进行处理,即便是这样,声音的格式也只能是少见的 AU 格式。幸好, SUN 意识到了这个问题,在 JDK1.3 之后, JAVA 就提供了专门的声音处理包来满足声音处理的需求。 javax.sound.midi 和 javax.sound.sampled 就是分别是用来处理 MIDI 和波形文件的,虽然 JAVA 提供的这两个包还不支持如 MP3 、 RM 等这类格式的文件,但是对于我们的这个游戏来说,能处理 MIDI 和 WAV 文件也已经够用了。 MIDI 格式的文件其优点在于文件小,但缺点是只能保存乐曲而无法包含声音信息, WAV 格式虽然能包含声音信息,可惜文件太大。因此,我们选用 MIDI 来作为游戏的背景音乐,而 WAV 来作为音效。 我们先来看看如何处理 MIDI 格式的文件吧。
代码很短,但是已经能很好的完成我们需要的功能了,当然,如果你还嫌不满的话, JAVA 也提供了多种方法让你对 MIDI 格式的文件进行音调、频率的改变,由于这方面要牵扯到比较专业的知道,而我也不太了解,因此我就不说了:) 对 WAV 格式文件的操作和 MIDI 的操作基本上很类似,只不过使用的 API 包不同罢了,具体的我就不多说了,大家看看源代码就知道了。 让用户了解游戏规则 并非所有的人都玩过这个游戏,也并非所有的人都了解游戏的规则,因此,做一个帮助系统对刚接触的用户来说,还是非常有必要的。制作帮助系统非常简单,无非就是用一个对话框来显示游戏规则,显示的方式有多种,可以使用 JLabel 控件来显示,也可以使用 JTextArea 控件来显示,当然,在这里,为了使帮助系统更完美,使用 HTML 来制作帮助系统将来是最佳选择,由于在上面已经提到过如何使用 JEditorPane 控件显示 HTML 页面,因此,这个就留给大家自己完成吧。 并且使用自己的偏好来进行游戏 虽然我们为游戏提供了许多功能,可是并非所有的用户都能完全接受这些功能的,因此,我们需要提供一些设置使用户能够使用自己的偏好来进行游戏。 在此,我们提供了用户选择打开 / 关闭背景音乐、打开 / 关闭游戏音效、设置游戏的难度、设置消除动画的速度这 4 项功能,为了使用户能够自己设置,我们不仅需要使用上面提到过的配置文件来保存信息,还需要在程序中提供设置界面,在此,我们再添加一个 SetupDialog 类,这个类并不难实现,大家看看源程序就可以了。 (之六)优化:让程序运行更稳定、更高效 改善游戏的合理性 到目前为止,我们的游戏基本上算是完成了,为了使程序更合理,我们还需要将整个程序从头再理一遍,看看有没有改进的地方。 首先,在变量的使用上,由于很多地方会使用相同的数据,因此,将变量定义为 public static 可以减少不必要的重复定义,如颜色、使用的图片等。 其次,由于 JAVA 的特性,我们可以对程序进行简单的修改(如使类从 JApplet 继承而不是从 JFrame 继承等),将其改变为 Applet 程序,让游戏在网页中也可以运行。同时,也考虑到程序的发布,我们可以将最终结果生成 JAR 包。 第三、为了确保在游戏运行时所有需要的资源都已经加载,因此,我们需要使用 MediaTracker 的 addImage(Image image, int id) 和waitForID(int id) 方法来保证资源加载。 第四、为了避免每次使用资源时都要从硬盘中读取,因此,我们尽可能对所有的资源都要进行缓冲处理,这是以空间换时间的做法。 第五、由于在最后发布程序的时候,我们需要将编译结果打包,因此,对于程序中的图片、声音等也需要放在包中,这样,对文件的访问就不能使用 File 类了,必须使用java.lang.ClassLoader.findResource(java.lang.String) 来访问包中的资源。 节约内存的使用 虽然 JAVA 提供的垃圾回收机制使我们可以不必考虑内存分配 / 回收的问题,然而,考虑到运行游戏的客户端多样性,因此,我们还是有必要减少内存开销的。还记得在前面我们提到过 AnimateDelete 类中的 public AnimateDelete(JButton[]dots) 构造函数吗?这个看似无用的方法其实是非常有用的。 在前面,我们提及过,由于多个消除动画可以同步进行,因此,如果在每次实例化类的时候都将界面上的 JButton 控件复制进来,这是一笔不小的内存开支,因此,我们在 AnimateDelete 类中将接收到的 JButton 控件定义为 static ,这样,所有的实例都可以共享同一个 JButton 控件,表面上看起来定义 static 变量使得这部分内存的开销无法除,但考虑到 AnimateDelete 类调用的频率,因此,这样反而是节约了内存开支的。 同样,如果大家看看我的源程序,会发现我有一些地方都将变量定义为了 static 。 (之七)扩展我们的程序 到此为止,我们的开发算是告一段落了,虽然在开发过程中有不少的苦与乐,但是,我们终于完成了这件作品,对自己也算是一点安慰了。 尽管我的文笔可能不流畅,也可能表达不清楚,但最重要的是你能跟着完成这个游戏。 J2ME 版本 想想,如果这个游戏能跑在手机上,这将是多么愉快的一件事情啊。 手机上的开发和 PC 上开发的区别在于: 1、由于受限制于设备屏幕的大小,因此,在设计时需要充分考虑好界面的大小 2、在手机上,由于处理器远远没有 PC 处理器的性能强劲,因此,动画将会被省略掉 3、各种型号的手机对声音处理的包可能不完全相同,因此,如果强调通用性,声音也会被省略掉 4、手机上没有 GridLayout 布局,没有 Button 控件,因此,所有的布局必须自己使用 Canvas 来绘制 5、 …… 尽管有着如此多的缺点,能完成手机上的版本依然值得期待,你愿意试试吗? 联机对战版本 想要让你的程序能够支持联机大战吗,看看我这么设计是否合理。 1、使用 Socket 来进行通讯 2、使用一个独立的服务器进程来完成各个客户端之间的通讯 3、定义好每次通讯时传递信息的描述方式 4、保证客户端的同步 5、 …… P 、 STRUTS 等技术不知有多么的热门,他们的主要用途是用来进行企业开发, J2ME 也由于能够被大量的移动设备所支持,因此,也有不少的程序,特别是游戏是在 J2ME 平台上开发的,反而是 J2SE ,似乎只是被人们用来做一做程序界面的,就连 APPLET 也很少有人使用了(有了 FLASH ,谁还用 APPLET 啊)。用 JAVA 来开发桌面平台的游戏,似乎很少有人这么做,也可能大家一想到做游戏都会想到 C 、 C++ 、汇编等。 前段日子我迷上的 QQ 游戏中的“连连看”,游戏之余,突发奇想,也想自己用做一个试试,经过十来天的奋战,终于完成了。 我选择了 JAVA 来开发这个游戏,之所以选择 JAVA ,是因为: |
|
很少有人用 JAVA 来开发桌面游戏,是因为 JAVA 在网络方面的强大优势使人们忽略了 JAVA 在桌面平台上的开发,特别是游戏方面,而并不是因为 JAVA 做不到,而我,却希望通过我的尝试来告诉大家:原来 JAVA 也能做出漂亮的桌面游戏的(我可不是在夸我的程序:))
1. 由于 JAVA 生来就是为网络服务的,因此,一旦有需要,很容易将单机版的游戏改为网络版的(我现在就在做:))
2. 由于现在有很多手机都支持 J2ME ,而 J2ME 和 J2SE 是一脉相承的,因此,用 JAVA 编写的游戏可以很容易移植到 J2ME 平台上,想想当你的游戏在手机上运行是一样多么愉快的事情啊。
开发的周期并不是很长,可是开发过程中我也遇到不少困难,也有不少收获,我希望将我的开发过程写下来,与大家共同进步:)
在我的开发过程中,你可以发现我是这么做的:
1. 使用数组实现算法的核心
2. 使用布局和按钮来实现操作
3. 使用布局实现用户界面
4. 使用线程在同一时间做更多的事情
5. 使用静态变量来减少内存的开销,并且保持信息的一致性
6. 使用文件来保存用户的配置信息
7. 使用声音让程序更有生气
(之一)动手前的准备
看看别人现成的游戏
在盖房子之前,我们都会先打好地基,然后搭起框架,最后再就是一点一点添砖加瓦,做软件也是一样的道理,都是从大体的框加向细节部分设计实现,现在,我们开始吧。
其实不管是做软件也好,做游戏也好,只要是写程序,在动手之前是一定会存在需求和分析的,如果不经过一定的分析就开始动手写程序,那么,这个程序一定会很难写下去的,最后的结果可能会导致放弃。
那么,在我们动手之前,让我们先简单的分析一下吧。由于“连连看”并不是一个我们凭空开发的游戏,并且网上也已经有很多别人已经开发好的版本,因此,对于我们来说,我们已经拥有了一个很好的原型(比如说 QQ 游戏中的“连连看”),分析起来也应该是轻松得多。由于 QQ 中的“连连看”是网络版,为了开发上的简便,我们先放弃网络功能,做一个简单的单机版就行了。现在,让我们现在来看一看 QQ 中的连连看吧。
“连连看”的游戏规则其实并不复杂,首先,游戏开始的时候,地图上会有由数张不同的图片随机分散在地图上(并且每张图片会出现偶数次,通常是 4 次),只需要在地图上找出两张相同的图片(点),并且这两个点之前可以用不超过 3 条的直线连接起来就可以消除这两点,如此下去,直到地图上的点全部消除完就算游戏结束,怎么样,规则很简单吧?:)我们的开发就完全按照些规则来吧。
分析游戏规则找出算法
通过上面的分析,我们已经知道了游戏规则,可是,我们怎么样去实现呢?
其实所谓的实现也就是算法,那我们怎么样找出算法呢?别急,让我们来看一看上图,或者自己动手玩一玩别人做好的。
通过对上图的观察,我们发现,可以将游戏中的地图看作是一个二维数组,其中的所有图片(以下称“点”)可以看作是数组中的一个具体的元素。那么,游戏中相同的图片可以看作是数组中不同位置两个值相同的元素。至于直线,让我们给组数中的每一个元素赋一个特殊的值如 0 ,以表示地图上空白的位置。并且同时规定:当连续的具有该特殊值的点的横向索引或纵向索引相同时,可以认为这是一条直线,比如下图:
当数组中两点的值相同并且两点间只需要不超过 3 根直线能连接起来的时候,就让这两点的值变为 0 ,如果数组中全是 0 值的点,就认为游戏已经结束:)
怎么样,算法够简单了吧:)
用伪代码来描述程序的结构
现在,我们用伪代码来描述一下游戏,假设用户开始了游戏:
准备地图 while ( 等待用户选择点 ) { 当前点 = 用户选择的点 if ( 存在上次选择的点 ) { if ( 当前点与上次选择的点可消除 ) { 消除两点; 上次选择的点 = null ; if ( 地图上已没有可消除的点 ) { 游戏结束; } } else { 上次选择的点 = 当前点; } } else { 上次选择的点 = 当前点; } } 游戏结束; |
看看有没有什么问题?如果没有问题,我们进入下一步吧:)
确定程序需要的模块
当伪代码完成后,并且在我们的大脑里转了几圈发现没有问题后,现在就可以开始进行模块的划分工作了。
我们还是再看一看 QQ 中的“连连看”,整个程序只需要通过鼠标操作就可以了,按照 MVC 的结构来进行程序设计,那么我们需要一个 Model ,用来完成整个程序的核心算法;一个 View ,用来显示用户界面,当然还需要一个 Control ,用来处理用户鼠标的操作,这样一来,只需要三个模块就可以完成了。
1. 算法模块
2. 控制模块
3. 显示模块
现在我们再细想一下,这样真的就可以了吗? Model 是一定需要的,这是整个程序的灵魂。然而对于 Control (控制)来说,控制会分为用户游戏中的操作和游戏提供的功能性操作,如果所有的操作包括游戏中的游戏控制、游戏界面上的帮助、设置等都要通过一个 Control 来完成,那么这个 Control 一定会比较大,并且会比较复杂,而过于复杂的模块通常都是比较容易引起错误,或者导致编码困难的,因此,我们就有必要将具有类似功能的操作分开,以减少各个模块的复杂程度,同时,也可以使模块的功能更单纯(这也是 OO 中所倡导的)。
现在我们将菜单操作和游戏操作分开,分开后的模块如下:
- 菜单显示
- 菜单控制
- 游戏显示
- 游戏控制
- 游戏核心算法
以上是程序的最主要的模块,除此之外,由于开发过程中的需要,对于每个模块,我们可能还需要一些辅助的模块来使程序更加完善,由于这些模块并不会对程序有太大的影响,因此,我们可以在需要的时候再来添加。
(之二)实现游戏的算法
将游戏地图转换为数组来描述
算法总是很枯燥的,没有直接设计界面来得舒服,然而,算法却是整个程序的核心,所以,仅管枯燥,我们还是得耐心地完成这一步。
在进行程序算法的设计时,我们首先要尽可能抛开一些无关紧要的部分,这样可以使算法看起来直接明了,但同时也要考虑弹性,以便将来扩充。
在前面已经说过了,整个游戏的核心算法也就是以二维数组为主体的算法,那么,定义一个二维数组是必不可少的了。
二维数组究竟应该有多大呢? 10X10 是不是小了, 20*20 呢,大了?究竟多大比较合适?为了考虑到程序以后改动的需要,我们还是定义成变量吧,这样以后要改动的时候,只需要改动一下变量的值就行了,因此,我们现在为程序增加一个类,使之专门用来保存与程序有关的一些数据。
//Setting.java public static final int ROW = 8; // 假设地图有 8 行 public static final int COLUMN = 8; // 假设地图有 8 列 |
至于为什么要定义成 public static final ,这个,自己想想就知道了:)还不知道?晕,看看书吧:(
现在,我们将这个类起名为 Map ,同时,我们规定,为了描述地图中空白的区域,我们使用 0 来表示。
//Map.java private int[][] map = new int[Setting.ROW][Setting.COLUMN]; |
初始化游戏地图
在地图初始化的时候,我们需要用一些“随机”的数字来填充这张地图,之所有将“随机”用引号括起来,是因为这些数字并不是真正意义上的随机:首先,数组中具有相同值的元素只能出现 4 次(具有 0 值的元素除外),其次,这些数字是被散乱的分布在数组中的。
要使元素出现 4 次,那么数组中所有不重复的元素个数最大为数组的大小 /4 ,为了简单起先,我们使这些元素的值用 1 、 2 、 3 ……进行编号。
要想将这些分配好的元素再分配到二维数组中,我们需要一个一维数组来辅助完成这项工作。
首先,我们按照二维数组的大小来建立一个大小相同的一维数组,并且,我们规定数组中出现的不重复的元素的个数(元素个数的多少代表了地图的填充率,填充率越高,表示游戏难度越高),同时,我们也要保证数组的长度能被 4 整除(目前是这样,其实不是必需的),因为相同的元素会出现 4 次。因此,我们定义一个变量,用来表示地图上可能出现元素种类的最大个数,同时也定义一个变量,表示目前地图上出现的元素的个数。
//Map.java int[] array = new int[Setting.ROW * Setting.COLUMN]; // 辅助的一维数组 int maxElement = 16; //maxElement 的值不能超过 map 总元素 /4 int elements = 16; // 先假设 maxElement 和 elements 相等 |
在,我们将这些元素放置在一维数组中:
for (int i = 0; i < max; i++) { array[i * 4] = i + 1; array[i * 4 + 1] = i + 1; array[i * 4 + 2] = i + 1; array[i * 4 + 3] = i + 1; } |
这时,一维数组初始化完成了,可惜数组中的元素是规规矩矩按顺序出现的,如果不打乱就填充到地图中,这游戏似乎也太简单了(因为相邻的点一定可以消除啊),现在,我们得想个办法打乱这个数组。
怎么打乱这个数组呢?好办,我们来看看,假设数组的原始排列是这样的:
[0] [1] [2] [3] [4] [5] [6] [7] [8] [9][10] [11] [12] [13] [14] [15]
从最后一个元素 [15] 起,依次与此元素之前的某一个元素将值互换,完成后再从 [14] 起,与在 [14] 之前的某一个元素将值互换,直到 [1] 与 [0] 的值互换后,如此一来,数组就被完全打乱了,如果还不明白,我们来看一看下图:
[0] [1] [2] [3] [4] [5] [6] [7] [8] [9][10] [11] [12] [13] [14] [15]
在 [15] 之前有 15 个元素,产生一个 15 以内的随机数,比如说 8 ,再将 [15] 和 [8] 的值互换,变成了如下:
[0] [1] [2] [3] [4] [5] [6] [7] [15] [9][10] [11] [12] [13] [14] [8]
再从 [14] 号元素开始,产生一个 14 以内的随机数,比如说 10 ,互换 [14] 和 [10] 的值:
改变前:
[0] [1] [2] [3] [4] [5] [6] [7] [15] [9][10] [11] [12] [13] [14] [8]
改变后:
[0] [1] [2] [3] [4] [5] [6] [7] [15] [9] [14] [11][12] [13] [10] [8]
怎么样,略施小技就搞定了,简单吧?算法如下:
int[] random(int[] array) { java.util.Random random = new java.util.Random(); for (int i = array.length; i > 0; i--) { int j = random.nextInt(i); int temp = array[j]; array[j] = array[i - 1]; array[i - 1] = temp; } return array; // 其实也可以不返回,因为数组的操作总是改变引用的 } |
现在,一维数组中的元素已经被打乱了,现在我们只需要按顺序依次填充回二维数组中就行了,这样,二维数组中的值就一定是乱的。
for (int i = 0; i < ROW; i++) { for (int j = 0; j < COLUMN; j++) { map[i][j] = array[i * COLUMN + j]; } } |
( 打乱后的数组,感觉如何,虽然难看了点,但很有用 )
对数组中两个元素是否可以消除的判断
地图的初始化已经完成了,现在的问题是,我们怎么样才能知道数组中的两个元素是否可以消除呢?
根据游戏规则,如果两个点之间可以用不超过 3 条直线连接起来,这两点就可以消除,现在我们来分析一下所有可能的情况:
两点之间只需要一条直线连接:
(图略了……)
由上图可以看出,如果两点间只需要一条直线能够连接起来,则 A 、 B 两点的横坐标或纵坐标必定相同,有了这个条件,我们判断 A 、 B 两点是否只需要一条直接连接就简单了许多。
这段代码比较简单,所以就不写出来了,大家可以看看源程序,只不过需要注意的是,我们将横线连接和竖线连接分开来处理,这样做是为了后面工作的简单。
boolean verticalMatch(Point a, Point b) // 竖线上的判断 boolean horizonMatch(Point a, Point b) // 横线上的判断 |
( 注意:为了简单省事,我们用 java.awt 包中的 Poin(x, y)t 来描述二维数组中元素的坐标,但是有一点要特别小心, x 和 y 与二维数组中元素的下标值 恰好相反 ,如左上图中 A 的下标为 array[1][0] , Point 的描述却是为 Point(0, 1) ,如果不注意这一点,程序会出错的。 )
两点之间需要两条直线连接:
如上图, A 、 B 两点如果需要两条直线连接起来,有可能有两种方式,于是,我们可以巧妙的构建一个 C 点和一个 D 点,并且规定 C 点的横坐标为 A 点的横坐标, C 点的纵坐标为 B 点的纵坐标, D 点的横坐标为 B 点的横坐标, D 点的纵坐标为 A 点的纵坐标(这一点很重要,因为 C 、 D 决定了 AC 、 BC 、 AD 、 BD 的连线方式),如下图:
如果此时 C 点(或 D 点)能同时满足 AC ( AD )、 BC ( BD )只需要一条直线相连,就表示 A 、 B 之前能够使用两条直线连接起来,并且 C 点( D 点)为拐点(以后会用上的)
//A 、 B 之间有一个拐点 boolean oneCorner(Point a, Point b) { Point c, d; boolean isMatch; c = new Point(a.x, b.y); d = new Point(b.x, a.y); if (map[c.x][c.y] == 0) { //C 点上必须没有障碍 isMatch = horizonMatch(a, c) && verticalMatch (b, c); if (isMatch) { return isMatch; } } if (map[d.x][d.y] == 0) { //D 点上必须没有障碍 isMatch = verticalMatch (a, d) && horizonMatch (b, d); return isMatch; } return false; } |
( 注意:由于 C 点和 D 点的构建方式确定了 AC 、 BD 永远是竖连线、 BC 、 AD 永远是横连线 )
两点之间需要三条直线连接:
这种方式是最复杂的了,我们还是先分析一下出现三条直线的所有可能性吧。
( 图 A)
( 图 B :这种方式比较容易忽略掉 )
以上图说明了两点间三条直线的所有可能性,和二条直线的情况相比,拐点是两个,麻烦了一点,但也不难处理。
下面我们来分析一下该怎么处理二个拐点的情况(三条直线)。由上面的图可以看出, A 、 B 如果要通过三条直线相连,则必须有 C 、 D 两个拐点,如果能确定下 C 、 D ,问题就好解决多了。
怎么样来确定 C 、 D 两点呢?我们以图 A 中的左图为例,在此之前,我们规定 C 点与 A 点在同一竖线上, D 点与 A 点在同一直线上。同时,从图中我们也可以看出, A 、 B 两点间如果只能通过三条直线连接起来,则必定有一条直线处于 A 、 B 的横向夹线纵向夹线中(如画圈的线)。
我们假设相等的线为在 A 、 B 两点的横坐标相等、纵坐标为 0~Setting.ROW 构成的区域上 ( 如图 ) 。
我们先扫描出所有的线,并且我们发现,如果在 A 、 B 构成的区域中存在两个点能构成直线,那么,这条直线就 有可能 是我们需要的直线,我们称此线为符合线,如果符合线的两端( C 、 D 两点)与 A 、 B 两点分别能 AC 、 CD 、 DB 能构成直线的原则,则 AB 间一定可以通过三条直线连接起来。(这个可能我描述得不太清楚,但相信你应该不难明白的)
我们把所有找到的符合线保存起来,并且要记录下符合线是横向上的还是纵向上的,然后通过这些找到的符合线,依次和 A 、 B 两点进行判断,一旦找到这样的 C 、 D 两点,能满足 AC 、 CD 、 DB 这三条线上都没有障碍,那么, A 、 B 就可以消除了。还是用算法来描述一下吧。
首先我们构建一个保存 C 、 D 点的类 Line ,并且要指明 C 、 D 的方向是横向还是纵向。
//Line.java public class Line { public Point a, b; public int direct; //1 表示横线, 0 表示竖线 public Line() { } public Line(int direct, Point a, Point b) { this.direct = direct; this.a = a; this.b = b; } } |
同时,由于在扫描的过程中,会找到多根符合线,因此,我们可以用 Vector 来保存这些找到的符合线(为了提高效率,也可以使用 LinkedList 来保存)。
Vector vector = new Vector(); // 保存求解后的线 |
扫描两点构成的矩形内有没有完整的空白线段
Vector scan(Point a, Point b) { Vector v = new Vector(); // 从 a, c 连线向 b 扫描,扫描竖线 // 扫描 A 点左边的所有线 for (int y = a.y; y >= 0; y--) { if (map[a.x][y] == 0 && map[b.x][y] == 0 && verticalMatch(new Point(a.x, y), new Point(b.x, y))) { // 存在完整路线 v.add(new Line(0, new Point(a.x, y), new Point(b.x, y))); } } // 扫描 A 点右边的所有线 for (int y = a.y; y < COLUMN; y++) { if (map[a.x][y] == 0 && map[b.x][y] == 0 && verticalMatch(new Point(a.x, y), new Point(b.x, y))) { // 存在完整路线 v.add(new Line(0, new Point(a.x, y), new Point(b.x, y))); } } // 从 a, d 连线向 b 扫描,扫描横线 // 扫描 A 点上面的所有线 for (int x = a.x; x >= 0; x--) { if (map[x][a.y] == 0 && map[x][b.y] == 0 && horizonMatch(new Point(x, a.y), new Point(x, b.y))) { v.add(new Line(1, new Point(x, a.y), new Point(x, b.y))); } } // 扫描 A 点下面的所有线 for (int x = a.x; x < ROW; x++) { if (map[x][a.y] == 0 && map[x][b.y] == 0 && horizonMatch(new Point(x, a.y), new Point(x, b.y))) { v.add(new Line(1, new Point(x, a.y), new Point(x, b.y))); } } return v; } |
现在,我们对所有找到的符合线进行判断,看看 AC 、 DB 是否同样也可以消除
boolean twoCorner(Point a, Point b) { vector = scan(a, b); if (vector.isEmpty()) { // 没有完整的空白线段,无解 return false; } for (int index = 0; index < vector.size(); index++) { Line line = (Line) vector.elementAt(index); if (line.direct == 1) { // 横线上的扫描段,找到了竖线 if (verticalMatch(a, line.a) && verticalMatch(b, line.b)) { // 找到了解,返回 return true; } } else { // 竖线上的扫描段,找到了横线 if (horizonMatch(a, line.a) && horizonMatch(b, line.b)) { return true; } } } return false; } |
消除该两个元素时,只需要将两个元素的值置为 0 即可。
更多的功能:自动寻找匹配的点
现在,算法基本上是实现了,但是,为了使游戏更丰富,我们还需要实现更多的功能,现在,我们添加一个自动寻找匹配的点的功能。
该功能需要分两步走:
第一步,从左上向右下搜索二维数组中第一个值不为 0 的元素 A ,找到该点后,然后再从该点向后找到一个值与该点值相等的元素 B ,然后对这两个元素进行是否可消除的判断,如果可以消除,则说明该两点匹配,如果不能消除,则继续寻找与 A 点值相等的 B 点,如果找不到 B 点,则寻找下一个 A 点,依次下去,直到找不到这个 A 点,这就表时地图上已经不存在可消除的点了,我们用伪算法描述如下:
找到第一个 A 点 while (A 点存在时 ) { while ( 能找到与 A 点值相等的 B 点 ) { if (Match(A, b)) { 返回找到的 AB 点 ; } } 寻找下一个 A 点 ; } 找不到点 ; |
更多的功能:刷新地图
刷新地图的功能其实非常简单,只是需要将二维数组中现有的元素打乱后然后放回这个二维数组中就行了,我们还是只简单的用伪算法描述一下吧:)
找到地图中所有的值不为 0 的点并且保存到一维数组中 打乱一维数组 重新分配回二维数组中 |
完成代码并且测试
现在,算法部分的代码大体上算是完成了,我们可以进行一下测试,测试应该很简单,限于篇幅的原因,我就不在这里写出测试用的代码了,但可以说明一下如何进行测试:
我们可以构建一些特殊的地图,然后用 Match(Point a, Point b) 方法来判断我们指定的两点是否可以消除,或者使用自动寻找的功能,找到相同的两点后,消除这两个点,当地图上没有可消除的点时,就刷新地图,直到点全部消除完成。同时,我们还可以在 horzionMatch(Point a, Point b) 、 verticalMatch(Point a, Point b) 等加上输出语句,来看看匹配时程序执行到哪了,换几个不同的点多测试几次,如果没有问题,那就应该没有问题了:)
(之三)将算法与界面结合起来
用布局和按钮来实现算法的界面
上面已经说完了算法,相信大家也迫不及待的想进入界面的设计了吧,好了,多的不说,我们开始吧。
既然我们的算法是基于二维数组的,那么我们也应该在界面使用一个能反映二维数组的控件。这里有两种方式,一种使用表格来实现,第二种是使用布局来实现。
相对而言,用表格来实现二维数组要简单一些, JAVA 提供的布局方式可能是令大家都不习惯的一种界面设计方式,不过,在这里,我还是选用了布局的方式来实现界面,因为,当你设计完界面后,你会发现 JAVA 的布局也是有它的优点的。
JAVA 提供了 BorderLyout 、 GridLayout 、 FlowLayout 等布局,而在这些布局中, GridLayout 布局是最接近于表格方式的一种布局,同时,我们使用 Jbutton 控件来作为数组中的元素。
//MapUI.java public class MapUI extends JPanel implements ActionListener { JButton[] dots = new JButton[Setting.ROW * Setting.COLUMN]; public MapUI() { // 设计布局 GridLayout gridLayout = new GridLayout(); gridLayout.setRows(Setting.ROW); gridLayout.setColumns(Setting.COLUMN); gridLayout.setHgap(2); // 设置纵向间距 gridLayout.setVgap(2); // 设置横向间距 this.setLayout(gridLayout); // 放置按钮 for (int row = 0; row < Setting.ROW; row++) { for (int col = 0; col < Setting.COLUMN; col++) { int index = row * Setting.COLUMN + col; dots[index].addActionListener(this); // 添加事件处理 this.add(dots[index]); } } } } |
当然了,上面的代码太简单了,简单得连一些基本的事情也没有做,比如说在按钮上加上文字,并且,要保证每个按钮上的文字与算法中二维数组中相对位置的元素的值相对应。现在我们运行一下看看,怎么样,像不像?
界面出来了,那我们怎么知道用户点击的是哪个按钮呢。其实这也不难的,我们在放置按钮的时候,对每个按钮的 CommandName 属性赋一个值,通过这个值,我们就可以知道该按钮对应二维数组中元素的具体位置了,现在,我们按放置按钮的代码改动一下:
// 放置按钮 for (int row = 0; row < Setting.ROW; row++) { for (int col = 0; col < Setting.COLUMN; col++) { int index = row * Setting.COLUMN + col; dots[i].setActionCommand("" + i); } } |
在按钮的事情处理中,再把这个值取出来,并且还原到坐标上,
public void actionPerformed(ActionEvent e) { JButton button = (JButton) e.getSource(); int offset = Integer.parseInt(button.getActionCommand()); int row, col; row = Math.round(offset / Setting.COLUMN); col = offset - row * Setting.COLUMN; } |
怎么样,是不是如此简单?已经知道了用户是按的哪个按钮,并且知道了该按钮对应的二维数组中的值,剩下的事情就不用我说了吧:)
现在,我们需要用 JButton 控件来将算法中二维数组中每个元素的值区别出来,最简单的就是用不同的数字来显示,有了这个功能后,我们就可以很方便的实现界面与算法的同步了。
// 根据数组来绘置画面 private void paint() { for (int row = 0; row < Setting.ROW; row++) { for (int col = 0; col < Setting.COLUMN; col++) { int index = row * Setting.COLUMN + col; if (map.getMap()[row][col] > 0) { dots[index].setIcon(Kyodai.BlocksIcon[map.getMap()[row][col] - 1]); dots[index].setEnabled(true); } else { dots[index].setIcon(null); dots[index].setText(“” + index); } } } } |
设计用户界面
好了,到现在为止,不管怎么说,我们已经大体上实现了与算法有关的界面,虽然还很难看,但总算是有了个界面,也算是对自己的一点安慰吧。
现在,我们要设计用户的界面了,用户的界面也无需让我们多考虑了,既然已经有现成的,我们就抄吧(其实是我天生缺乏美术天份,与其自己做得难看,还不如抄抄别人现成的)。这部分没有太多要说的,我就用图来说明一下吧。
同样,整个界面也是使用了 JAVA 提供的布局方式,这里使用的是 BorderLayout 布局,我们需要注意的就是先算计好每个区域所需要尺寸的大小,并且使用 JPanel 的 setPreferredSize 方法来固定好大小,同时,根据自己的需要,添加命令按钮如开始、退出等。
为用户界面添加功能
用户界面的设计已经出来了,但是相应的功能我们还没有实现,现在,让我们来一起实现这些功能吧。
同样,我们还是参照一下别人游戏中有哪些功能,并且根据自己的需要来进行选择,在这里,我们只需要提供开始游戏、刷新地图、提示、炸弹等功能就好了(其它的暂时可以不管,以后需要的时候可以再添加)。
开始游戏: 当用户开始游戏的时候,我们需要将地图初始化并且显示出游戏界面。
刷新地图: 此功能我们在算法部分中已经实现了,现在要做的就是使显示部分能够同步进行。
提示功能: 此功能在算法中也已经实现,我们需要做的就是怎么样将找到的这两个并且向别人指出。
炸弹功能: 此功能其实就是在提示功能的基础上,加上自动消除两个点。
好了,相信这些对我们并非难事,我们还是继续下一步吧。
(之四)添加更多的功能
计分功能
大体上我们的程序已经可以跑了起来,可惜,就这么玩玩也太没有意思了,总得有个计分的吧。虽然我们不知道别人是怎么计分的,可是,程序是我们自己动手写的,我的地盘我做主,看看我是怎么计分的吧(实现可以放在下一步)。
1. 规定用户游戏时间,游戏时间为地图中方块数 * 4 ,比如说有 120 个方块,那么游戏时间就是 480 秒,如果用户在 480 秒内没有完成游戏,则没有时间奖励分
2. 时间奖励分为剩余时间 * 1 ,即在规定时间内完成游戏的,每提前一秒钟奖励 1 分
3. 当已经选择第二个点后,如果选择的第二个点与第一个点之间无解,扣 1 分,如果有解,则增加 10 分
4. 刷新功能会降低游戏难度,因此,当用户刷新地图时,扣 8 分
5. 提示也要扣分,理由同上,使用一次扣 10 分
6. 炸弹功能,这个更变态啊,使用一次扣 12 分
好了,计分规则定下来了,合理不合理先放在一边,不过,为了方便以后修改计分规则,我们还是将这些定义为常量吧。
//Setting.java public final static int limitScore = 4; // 每个方块限定的时间 public final static int timeScore = 2; // 时间奖励的分数 public final static int wrongScore = 1; // 选择失败扣分 public final static int freshScore = 8; // 刷新功能扣分 public final static int hintScore = 10; // 提示功能扣分 public final static int bombScore = 12; // 炸弹功能扣分 public final static int correctScore = 10; // 成功消除后加分 |
现在,我们来一一实现计分功能。
首先,我们定义一个变量来保存用户的总分,另外,消除功能、刷新功能、提示功能和炸弹功能由于都有现成的方法,因此,我们只需要在这些方法中添加上计分功能就行了,唯一麻烦一点的就是计算时间分。
为了满足计时的功能,我们需要在游戏开始的时候记录下当前的时间,同时,在游戏完成时也要记录下完成时的时间,两者相减后就是用户游戏的时间了(如何知道游戏在什么时候结束,这个大家想想,不难办到:))。记录时间可以使用 System.currentTimeMillis() 方法,不过需要注意的是,这是以毫秒来计时的,要记得除以 1000 。
高手排行榜
分数是记下来了,自己每次游戏完成都能知道自己的分数,感觉是不错,不过,光和自己比不够劲啊,要是能和别人比就更好了,嗯,继续满足你的“愿望”:)
(注:以下功能需要一个支持动态页面的空间)
最简单的办法,就是我们在互联网上建立一个保存用户分数的数据库,当用户每次完成后,都让用户可以选择将分数发送到这个数据库中,同时,我们需要做一个后台程序,该程序接收到用户提交来的姓名和分数后,就保存到数据库中,并且,根据当前的分数,告诉用户当前的 Top10 排行榜。后台程序可以使用网页来实现,至于怎么做,这个与程序关系不大,我们只看一看程序这一块怎么做吧。
首先,做法有两种,一种是自己使用 Socket 类来完成 HTTP 的 Get/Post 操作,另一种就是使用 URL 类,前者适用性前(如 J2ME 就不支持 URL 类)后者用起来方便一些,在此,我们就用后者来实现。
游戏完成后,当用户需要提交分数到互联网上时,先弹出一个对话框让用户输入姓名,同时将用户的姓名和分数,还有自己已经做好的页面“凑”成一个合法的 URL 地址,然后,将这个 URL 地址简单的 new 一下 (new 的同时就已经将这些数据发送到了互联网上了 ) ,比方说我做的排行榜的页面为 http://www.xhai.com/kyodai/top10.asp ,用户的姓名为“ ZhangJian ”,分数为 2000 分,那么,根据这些信息,我拼凑成的网址就是应该是类似于http://www.xhai.com/kyodai/top10.asp?name=ZhangJian&score=2000 这样样的地址,然后, newURL(“http://www.xhai.com/kyodai/top10.asp?name=ZhangJian&score= 2000”) 就行了:)是不是够简单的?当然,如果有需要,还可以对这些数据进行一下加密,以防用户“作弊”。 (现在已经废弃)
为了在程序中显示排行榜的页面,我们需要程序能够处理页面的功能,显示,如果自己去实现这个功能,虽然并不复杂,但是 JAVA 提供的 JEditorPane 控件却更适合完成这项工作。
我不想用完整源程序来说明,毕竟那太费篇幅,只是截取一段代码来描述一下吧。
JEditorPane HelpPane = new JEditorPane(); HelpPane.setEditable(false); // 禁止用户编辑 HelpPane.setPage(new URL("http://www.xhai.com/kyodai/top10.asp")); // 设置页面 HelpPane.addHyperlinkListener(this); // 添加用户点击链接时的事件处理 public void hyperlinkUpdate(HyperlinkEvent e) { if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED) { JEditorPane pane = (JEditorPane) e.getSource(); if (e instanceof HTMLFrameHyperlinkEvent) { HTMLFrameHyperlinkEvent evt = (HTMLFrameHyperlinkEvent) e; HTMLDocument doc = (HTMLDocument) pane.getDocument(); doc.processHTMLFrameHyperlinkEvent(evt); } else { try { pane.setPage(e.getURL()); } catch (Throwable t) { t.printStackTrace(); } } } } |
使用配置文件来保存用户信息
假如用户每次要发送成绩到互联网上时都要输入姓名,那有多麻烦啊,为什么我们不将用户的姓名保存起来呢?
其实,不光是用户的姓名需要保存,以后我们添加的其它选项也需要保存起来,因此,我们必须实现一个保存 / 读取用户信息的类,就如果 Windows 的注册表或者 ini 文件一样。在这里,我不想多费口舌,只是告诉一下大家怎么做就行了。
JAVA 提供了一个 java.util.Properties() 类,这个类就是用来保存 / 读取配置文件的,它的 setProperty() 和 getProperty() 方法就是分别用来保存 / 读取配置文件信息的,就如同使用 ini 文件一样,具体的用法查一查 API 就清楚了。
(之五)完善用户界面
让界面更动起来
整个程序的界面总算是出来了,可惜不太漂亮,这种界面,别说别人,就连自己也不愿意多看几眼,因此,做一些适当的美化工作还是非常有必要的。
想要让界面变得漂亮,最好的办法就是大量使用帖图,可惜,图片太多不仅会影响到程序的执行效率,同时,由于美工不是我们的长项,因此,我们还是走走捷径算了。
首先,我们将各个用户控件设置好背景色,这是最简单的方法了,只要颜色搭配得当,也是最有效的办法了。
其次,为了使界面看上去不那么单薄,因此,我们可以想办法使界面更有立体感。好在 JAVA 为我们提供了许多种 Border 控件,通过 Border 控件来组合其它控件的使用,将会使界面变得有立体感。
第三,使用图片。以上的方法,只会让控件变得漂亮,但控件仍然有控件的影子。而大多数人一看到控件,第一反应就会想起应用程序,而不是游戏。既然我们做的是游戏,那么,我们就可以自己做一些简单的图片来“掩蔽”控件的本来面目。好在这个游戏按钮不多,做几个也不太难。
经过以上的几步操作,界面变得漂亮多了,不是吗?
改变鼠标光标
很少看见过有人改变程序中光标的样子,是不是 JAVA 做不到?其实 JAVA 已经考虑到了这一点,只不过很少有人想去这么做这已。createCustomCursor 就是为我们准备的,其具体用法是:
createCustomCursor(Image cursor, PointhotSpot, String name)
cursor 是我们要设置为光标的图片, hotSpot 是图片显示在实际光标位置的位移, name 就是光标的名字拉!
好了,现在我们找一张合适的图片来作为程序的光标吧,看看效果如何?
如果,你还不满意,或者,你要说:我们的光标不能动啊,人家 QQ 上的光标可是会动的呢。
这确实有点麻烦,因为 JAVA 提供的方法只能显示静态的光标,但是,通过一些简单的方法,我们还是可以实现的。
由于 JAVA 的光标只能是静态图片,因此,要显示动态的光标,我们只能是定时更改光标的图片,首先,我们准备好一系列图片,然后,我们需要使用 javax.swing.Timer(int,java.awt.event.ActionListener) 方法来设置一个定时器,当定时器的事件触发后,我们就改变光标显示的图片。在本程序中,由于考虑到效率问题,我们就没有使用动态光标了,不过,如果你有兴趣,可以试试的:)
将时间 / 分数的显示作为动画来显示
为了让程序更有活力,我们可以适当的将游戏中一些显示信息的地方做成小动画,比如说时间和分数。
在动画的处理过程中,我们要保证动画只是起到作为游戏的点缀,而不能影响到游戏的正常进行(比如说不能在动画进行的过程中中断游戏),同时,动画也不能太喧宾夺主,这样也会分散别人在游戏中的注意力的。
为了保证动画过程和游戏过程的平行运行,因此,我们非常有必要将动画分离成一个独立的控件,并且要保证动画有自己单独的线程来运行。好了,现在我们先来看看我们怎么把时间作为动画分离出来的吧。
//ClockAnimate.java public class ClockAnimate extends JPanel // 将时间的显示作为 Panel 控件 implements Runnable { // 使用线程保证动画的独立性 public ClockAnimate() { this.setPreferredSize(new Dimension(156, 48)); // 设置好控件的大小 } 现在,我们就做一个的数字变化的效果,这种效果最简单的方式就是让数字每隔一段时间就变化一次。 public void start() { startTime = System.currentTimeMillis(); // 当线程起动时,记录下当前的时间 thread = new Thread(this); thread.start(); // 线程开始运行 } public void run() { // 线程运行的主过程 Thread currentThread = Thread.currentThread(); while (thread == currentThread) { long time = System.currentTimeMillis(); usedTime = time - startTime; try { repaint(); // 重画数字 thread.sleep( 100l); // 延时 100 毫秒,即 0.1 秒 } catch (InterruptedException ex) { } } } public void paint(Graphics g) { // 重画时间 g.drawString("Time:" + usedTime, 16, 40); } |
怎么样,时间的显示是不是可以动了?为了使文字在使用大字体的情况下显示得更漂亮一些,我们可以适当的使用抗锯齿效果, JAVA 提供了现成的方法,很简单的,现在我们将 paint(Graphics g) 改动一下:
public void paint(Graphics g) { Graphics2D g2 = (Graphics2D) g; Dimension d = getSize(); g2.setBackground(new Color(111, 146, 212)); g2.clearRect(0, 0, d.width, d.height); // 使用背景色清除当前的显示区域 g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 打开抗锯齿效果 g2.setColor(new Color(212, 255, 200)); g2.setFont(new Font("serif", Font.PLAIN, 28)); g2.drawString("Time:" + getTime(), 16, 40); } |
Graphics2D 是 JAVA 提供的增强型图形处理包,可以实现许多以前 Graphics 实现不了的功能。在由于系统记录的时间是以毫秒为单位记录的,因此,在上面我们需要写一个 getTime() 方法来将时间的显示格式化成类似于 123.4 这种形式。
时间的动画完成了,现在我们开始制作分数变化的动画。其实分数动画的基本设计方法与时间动画相同,但是有一点不同的是,时间动画在用户游戏的整个过程中是一直运行的,而分数的动画是要根据用户当前得分的情况进行变化的,也就是说,分数的动画是被用户干预的。
现在,我们将动画运行的主过程改动一下。为了简单起见,我们只考虑分数从低向高的变化,不考虑分数从高向低的变化。
public void run() { Thread currentThread = Thread.currentThread(); while (thread == currentThread && lastScore < currentScore) { try { lastScore++; repaint(); thread.sleep( 50l); } catch (InterruptedException ex) { } } } public void setScore(int l, int c) { // 根据用户得分前后的数值进行动画处理 this.lastScore = l; this.currentScore = c; start(); } |
当每次用户的分数发生变化时,我们可以使用 setScore(int l, int c) 方法同步分数显示的动画效果。
实现消除图片时的动画效果
在完成了上面两个动画,现在我们来设计消除图片时的动画,动画的效果是当我们选中两个可消除的图片后,找到两个图片之间的连接直线,并且从第一个选中的图片向第二个选中的图片之间做点的拖尾效果的动画(如果不是很明白,看看程序运行的效果就知道了)。
这个动画比前两个动画处理起来要麻烦得多,因为,它牵扯到的部分比较多:首先,需要记录下两点之间的直线以及这些直线出现的先后次序,其次,还需要知道每条直线的方向,是横向还是纵向,第三,还需要在这些直线上依次进行动画效果的处理。这个动画效果不仅牵扯到了界面,还牵扯到了算法。现在,我们还是一起看看怎么实现吧。
首先,在算法中,当每次消除两个点的时候,我们就需要记录下这两个点之间的连线情况,是横着的还是竖着的,是 1 条直线还是 2 条或者 3 条。因此,对于 Map.java 中的 verticalMatch(Point a, Point b) 方法就必须改动一下
private boolean verticalMatch(Point a, Point b, boolean recorder) { ………… if (!test && recorder) { // 如果当前不是测试并且要求记录路径 animate = new AnimateDelete(0, a, b); } return true; } |
recorder 说明了当前是否要求记录下路径,比如说在使用提示功能的时候,虽然我们是会调用该方法来进行试控两点是否可以消除,但实际上,这两个点并不是真的需要消除,所以,在这种情况下,就不应该记录下路径。
AnimateDelete 是我们新创建的一个类,其作用就是处理消除时的动画效果的,该类有几个构造函数,依次如下:
public class AnimateDelete implements Runnable { // 获得界面上的 JButton 控件 public AnimateDelete(JButton[] dots) { this.dots = dots; } // 一条直线的情况 //direct 方向, 1 表示 a, b 在同一直线上, 0 表示 a, b 在同一竖线上 public AnimateDelete(int direct, Point a, Point b) { …… } // 两条直线的情况 //direct 方向, 1 表示 a, b 在同一直线上, b, c 在同一竖线上; //0 表示 a, b 在同一竖线上, b, c 在同一直线上 public AnimateDelete(int direct, Point a, Point b, Point c) { …… } // 三条直线的情况 //direct 1 表示 a, b 为横线, b, c 为竖线 , c, d 为横线 //0 表示 a, b 为竖线, b, c 为横线, c, d 为竖线 public AnimateDelete(int direct, Point a, Point b, Point c, Point d) { …… } |
上面的 public AnimateDelete(JButton[]dots) 构造函数看起来似乎没用,实际上,这个是非常有用的,我后面会提到的。
好了,现在可以在 Map 算法中的 horizonMatch 、 verticalMatch 、 oneCorner 、 twoCorner 等方法中添加消除动画的构造函数了。
在 AnimateDelete 的几个构造函数中,还要记得将每种方式中涉及到的直线的路径记录下来,最后,将路径上的这些元素依次保存在一个一维数组中,同时,我们也需要记录下路径的长度,以便动画时的操作。现在我们来看看动画部分如何处理。
public void run() { if (count < 2) { //count 是路径的长度,当 count<2 的时候,不进行动画 return; } Thread currentThread = Thread.currentThread(); boolean animate = true; while (thread == currentThread && animate) { // 先用图片来填充经过的路径 for (int i = 1; i < count - 1; i++) { dots[array[i]].setEnabled(true); dots[array[i]].setIcon(Kyodai.GuideIcon); try { thread.sleep( 20l); } catch (InterruptedException ex) { } } // 然后恢复经过的路径 for (int i = 1; i < count - 1; i++) { dots[array[i]].setIcon(null); dots[array[i]].setEnabled(false); try { thread.sleep( 20l); } catch (InterruptedException ex) { } } // 消除两点 dots[array[0]].setIcon(null); dots[array[0]].setEnabled(false); dots[array[count - 1]].setIcon(null); dots[array[count - 1]].setEnabled(false); animate = false; } stop(); // 停止动画 } |
由于消除动画可以几个消除动画同时进行,因此,对于此效果,我们就要在每次使用该效果时实例化一个该对象了。
为程序添加声音
现在,我们来为我们的游戏添加声音。虽然 JAVA 自从出道之日起就能够处理声音,但是那只限于在 APPLET 中进行处理,即便是这样,声音的格式也只能是少见的 AU 格式。幸好, SUN 意识到了这个问题,在 JDK1.3 之后, JAVA 就提供了专门的声音处理包来满足声音处理的需求。 javax.sound.midi 和 javax.sound.sampled 就是分别是用来处理 MIDI 和波形文件的,虽然 JAVA 提供的这两个包还不支持如 MP3 、 RM 等这类格式的文件,但是对于我们的这个游戏来说,能处理 MIDI 和 WAV 文件也已经够用了。
MIDI 格式的文件其优点在于文件小,但缺点是只能保存乐曲而无法包含声音信息, WAV 格式虽然能包含声音信息,可惜文件太大。因此,我们选用 MIDI 来作为游戏的背景音乐,而 WAV 来作为音效。
我们先来看看如何处理 MIDI 格式的文件吧。
// 读取 midi 文件 public void loadMidi(String filename) throws IOException, InvalidMidiDataException { URLClassLoader urlLoader = (URLClassLoader)this.getClass().getClassLoader(); URL url = urlLoader.findResource(filename); sequence = MidiSystem.getSequence(url); //sequence 保存着 MIDI 的音序结构 } // 播放 sequence public void play() { if (isPlaying) { // 如果已经在播放,返回 return; } try { sequencer = MidiSystem.getSequencer(); sequencer.open(); sequencer.setSequence(sequence); // 加载 sequence sequencer.addMetaEventListener(this); // 添加事件处理 } catch (InvalidMidiDataException ex) { } catch (MidiUnavailableException e) { } // Start playing thread = new Thread(this); thread.start(); } public void run() { Thread currentThread = Thread.currentThread(); while (currentThread == thread && !isPlaying) { // 当 MIDI 没有播放的时候,播放 MIDI 音乐 sequencer.start(); isPlaying = true; try { thread.sleep( 1000l); } catch (InterruptedException ex) { } } } |
代码很短,但是已经能很好的完成我们需要的功能了,当然,如果你还嫌不满的话, JAVA 也提供了多种方法让你对 MIDI 格式的文件进行音调、频率的改变,由于这方面要牵扯到比较专业的知道,而我也不太了解,因此我就不说了:)
对 WAV 格式文件的操作和 MIDI 的操作基本上很类似,只不过使用的 API 包不同罢了,具体的我就不多说了,大家看看源代码就知道了。
让用户了解游戏规则
并非所有的人都玩过这个游戏,也并非所有的人都了解游戏的规则,因此,做一个帮助系统对刚接触的用户来说,还是非常有必要的。制作帮助系统非常简单,无非就是用一个对话框来显示游戏规则,显示的方式有多种,可以使用 JLabel 控件来显示,也可以使用 JTextArea 控件来显示,当然,在这里,为了使帮助系统更完美,使用 HTML 来制作帮助系统将来是最佳选择,由于在上面已经提到过如何使用 JEditorPane 控件显示 HTML 页面,因此,这个就留给大家自己完成吧。
并且使用自己的偏好来进行游戏
虽然我们为游戏提供了许多功能,可是并非所有的用户都能完全接受这些功能的,因此,我们需要提供一些设置使用户能够使用自己的偏好来进行游戏。
在此,我们提供了用户选择打开 / 关闭背景音乐、打开 / 关闭游戏音效、设置游戏的难度、设置消除动画的速度这 4 项功能,为了使用户能够自己设置,我们不仅需要使用上面提到过的配置文件来保存信息,还需要在程序中提供设置界面,在此,我们再添加一个 SetupDialog 类,这个类并不难实现,大家看看源程序就可以了。
(之六)优化:让程序运行更稳定、更高效
改善游戏的合理性
到目前为止,我们的游戏基本上算是完成了,为了使程序更合理,我们还需要将整个程序从头再理一遍,看看有没有改进的地方。
首先,在变量的使用上,由于很多地方会使用相同的数据,因此,将变量定义为 public static 可以减少不必要的重复定义,如颜色、使用的图片等。
其次,由于 JAVA 的特性,我们可以对程序进行简单的修改(如使类从 JApplet 继承而不是从 JFrame 继承等),将其改变为 Applet 程序,让游戏在网页中也可以运行。同时,也考虑到程序的发布,我们可以将最终结果生成 JAR 包。
第三、为了确保在游戏运行时所有需要的资源都已经加载,因此,我们需要使用 MediaTracker 的 addImage(Image image, int id) 和waitForID(int id) 方法来保证资源加载。
第四、为了避免每次使用资源时都要从硬盘中读取,因此,我们尽可能对所有的资源都要进行缓冲处理,这是以空间换时间的做法。
第五、由于在最后发布程序的时候,我们需要将编译结果打包,因此,对于程序中的图片、声音等也需要放在包中,这样,对文件的访问就不能使用 File 类了,必须使用java.lang.ClassLoader.findResource(java.lang.String) 来访问包中的资源。
节约内存的使用
虽然 JAVA 提供的垃圾回收机制使我们可以不必考虑内存分配 / 回收的问题,然而,考虑到运行游戏的客户端多样性,因此,我们还是有必要减少内存开销的。还记得在前面我们提到过 AnimateDelete 类中的 public AnimateDelete(JButton[]dots) 构造函数吗?这个看似无用的方法其实是非常有用的。
在前面,我们提及过,由于多个消除动画可以同步进行,因此,如果在每次实例化类的时候都将界面上的 JButton 控件复制进来,这是一笔不小的内存开支,因此,我们在 AnimateDelete 类中将接收到的 JButton 控件定义为 static ,这样,所有的实例都可以共享同一个 JButton 控件,表面上看起来定义 static 变量使得这部分内存的开销无法除,但考虑到 AnimateDelete 类调用的频率,因此,这样反而是节约了内存开支的。
同样,如果大家看看我的源程序,会发现我有一些地方都将变量定义为了 static 。
(之七)扩展我们的程序
到此为止,我们的开发算是告一段落了,虽然在开发过程中有不少的苦与乐,但是,我们终于完成了这件作品,对自己也算是一点安慰了。
尽管我的文笔可能不流畅,也可能表达不清楚,但最重要的是你能跟着完成这个游戏。
J2ME 版本
想想,如果这个游戏能跑在手机上,这将是多么愉快的一件事情啊。
手机上的开发和 PC 上开发的区别在于:
1、由于受限制于设备屏幕的大小,因此,在设计时需要充分考虑好界面的大小
2、在手机上,由于处理器远远没有 PC 处理器的性能强劲,因此,动画将会被省略掉
3、各种型号的手机对声音处理的包可能不完全相同,因此,如果强调通用性,声音也会被省略掉
4、手机上没有 GridLayout 布局,没有 Button 控件,因此,所有的布局必须自己使用 Canvas 来绘制
5、 ……
尽管有着如此多的缺点,能完成手机上的版本依然值得期待,你愿意试试吗?
联机对战版本
想要让你的程序能够支持联机大战吗,看看我这么设计是否合理。
1、使用 Socket 来进行通讯
2、使用一个独立的服务器进程来完成各个客户端之间的通讯
3、定义好每次通讯时传递信息的描述方式
4、保证客户端的同步
5、 ……