1、三天教你做俄罗斯方块小花朵 2010-07-12序言大学学 C#的时候做了一个俄罗斯方块,发现挺多新手都想牛刀小试一把,我就重写了一遍,并写了这份文档教程,如果你理解快的话,三天就能做出来你的俄罗斯方块了。先看一下我的俄罗斯方块吧,游戏规则估计不用多说了,我的俄罗斯方块的特色是有美女脱衣表演哦,每升一级,美女就脱一件衣服哦!另外,你还可以自己设定各个参数,包括游戏窗口的大小,按键,背景音乐,甚至自定义砖块样式。第一部分:基础知识1. 了解认识 GDI+GDI+的技术是建立在 GDI 上的。GDI+提供了一个抽象层,隐藏了不同视频卡之间的区别,这样就可以调用 windows AIP 函数完成指定
2、的任务了GDI+由.NET 基类集组成,这些基类可用于在屏幕上完成定制绘图,能把合适的指令发送到图形设备的驱动程序上,确保在监视器屏幕上显示正确的输出,这里的输出包括打印到硬拷贝中。表 1-1 列出了 GDI+基类的主要命名空间表 1-1命名空间 说明System.Drawing 包含与一类绘图功能有关的大多数累、结构、枚举、委托System.Drawing.Drawing2D 为大多数高级 2D 和矢量绘图操作提供了支持,包括消除锯齿、几何转换和图形路径System.Drawing.Imaging 帮助处理图像(位图、Gif 文件等)的各种类System.Drawing.Printing 把
3、打印机或打印预览窗口作为输出设备时使用的类System.Drawing.Design 一些预定义的对话框、属性表和其他用户界面元素,与在设计期间扩展用户界面相关System.Drawing.Text 与字体和字体系列执行高级操作的类在 GDI+中,设备环境(DC)包装在.NET 基类 System.Drawing.Graphics 中。大多数绘图工作都是调用Graphics 的实例来完成的。实际上,因为 Graphics 类负责处理大多数绘图操作,所以 GDI+中很少有操作不涉及到 Graphics 实例。理解如何处理这个对象是理解如何使用 GDI+在现实设备上绘图的关键。2. 绘制图形下面用
4、一个小示例来说明如何在应用程序的窗口中绘图(文章所有的示例都在 Visual Studio 2005 中建立为 C#的 Windows 应用程序) 。启动 VS2005,创建一个 windows 应用程序的项目,语言是 C#,名字为 Tetris(俄罗斯方块),然后切换到代码视图,在构造函数的最下面追加如下代码:运行程序,我们期待的结果是在窗体上出现一个蓝色的矩形和一个红色的椭圆,但是实际运行结果呢?什么都没有显示,这是什么原因呢?原因就是在构造函数里执行画图代码的时候,窗口还没有显示出来,也就是说,还没有可以提供绘图的地方,所以,我们要看到期待中的蓝色矩形和红色椭圆,就必须在窗口显示出来以后
5、再执行才能看到效果。知道了原因我们也就知道解决方案,回到设计视图,添加 Form_Shown 事件,通过下面的提示我们知道这个事件发生在窗口第一次显示的时候。然后我们把代码移动到 Form_Shown 事件中,再次运行一下程序,接下了就是见证奇迹的时刻了。期待中的蓝色矩形和红色椭圆如期出现了,矩形的坐标(0,0),大小(50,50)。椭圆的坐标(0,50),大小(80,50)。需要提醒一下的是如果是椭圆,则是外接矩形的坐标。这里坐标(x,y)表示从窗口的客户区域左上角开始向右的 x 个像素,向下 y 个像素这些是现实出来的图形的左上角的坐标。外面注意到椭圆的顶部和矩形的下边有轻度的重叠,这与代
6、码中给出的坐标有点不同,这是因为windows 在重叠的区域放置了巨星和椭圆的线条。在默认情况下,windows 视图把图形边框所在的线条放到中心位置但这并不是总能做到的,因为线条是以像素为单位来绘制的,但每个图形的边框理论上位于两个像素之间。结果 1 个像素宽的线条就会正好位于图形顶边和左边的立面,而在右边和底边的外面。这样,从严格意义上讲,相邻的边框就会有 1 个像素的重叠。由于我们制定的线条宽度比较大,因此重叠区域也就比较大了。一般来说,可以设定 Pen.Alignment 属性来改变默认的操作方式,但这里使用默认的操作就足够了。接下了我们会发现一个问题,如果把这个窗口的绘图部分用别的窗
7、体遮住,或者移动到屏幕的外边或者最小化,再恢复它,会发现绘制好的图形就不见了或者部分不见了。这是怎么回事?这个就要从 windows 处理屏幕数据的方式来说起了。如果窗口的一部分被隐藏了,windows 通常会立刻删除与其中显示的内容相关的所有信息。这是必须的,否则存储屏幕数据的内存量就会是个天文数字。按照 1024x768 像素,24 位彩色模式,屏幕上的每个点(像素)就会占据 3 个字节(byte),整个屏幕需要 2.25MB 的显存来存储这些数据。下面考虑一种最糟糕的情况:屏幕上有 20 个窗口,都是最大化状态,windows 就需要 45MB 的显存来存储,如果是 32 位的彩色模式,
8、或者分辨率更大点,则消耗更多的显存,很显然,windows 不能这样管理用户界面。在窗口的某一部分消失时,那些像素也就丢失了。因为 windows 释放了保存这些像素的显存。但要注意,窗口的一部分被隐藏了,当它检测到窗口不再被隐藏时,就请求拥有该窗口的应用程序重新绘制该部分的内容。这个规则有一些例外窗口的一部分被挡住的时间比较短(如,菜单的拉出,临时挡住了下面的窗口) 。但一般情况下,如果窗口的一部分被挡住,应用程序就需要在以后重新绘制那部分。换句话说,windows 只需要花费一个屏幕的显存就可以处理 N 多个窗口同时打开的情况,这是我的理解。这就可以解释我们的程序为什么出问题了。我们的代码
9、只是在第一次显示的时候才执行,并且只执行一次,不能在以后需要的时候自动重新绘制图形。备注:windows 的标准控件非常专业,能够在 windows 需要的时候自动重新绘制他们自己。所以这就是在使用时不需要担心实际绘图过程的原因之一。3. 使用 OnPaint()绘制图形上面的解释可以让你觉得挺复杂的,但实际上并非如此,要让应用程序在需要的时候绘制自身是非常简单的。windows 会利用 Paint 事件通知应用程序完成一些重新绘制的请求。有趣的是,Form 类已经执行了这个事件的处理,因此不需要再添加处理代码了。我们添加 OnPaint()事件,根据注释,我们知道这个事件会在控件需要重新绘制
10、的时候发生。然后我们把代码再移动到这个事件里。注意,我们修改了获取 Graphics 的方式,PaintEventArgs 里包含一个 Graphics 实例,所以我们就不需要再调用 CreateGraphics()来创建了。运行一下看看结果,发现 bug 消失了,我们已经成功的实现是在窗口上绘制我们需要的图形了,有了这些基础,我们就可以正式开工了。备注: 这一部分主要摘抄自C#高级编程(第四版) ,第 25 章第一节第二部分:详细设计1. 实现原理了解了基础知识以后我们就可以思考游戏的实现了,其实这个游戏实现的原理非常简单,就是不断的在窗口上画砖块,清砖块。注意这里的清砖块其实就是用背景颜色
11、把某个区域给填充而已,本质还是绘制。具体的设计思路如下: 通过 timer 定时执行某个操作来改变活动砖块的坐标,并更新窗口的绘图; 通过键盘事件改变活动砖块的坐标以及形状,并更新窗口的绘图; 每次更新都要检测是否有填满的行,或者是否游戏结束等等; 针对消除的行数更新游戏得分,等级等信息2. 抽象建模了解了基础知识以后就可以动手设计我们的俄罗斯方块的现实了,根据面向对象的设计思想,我们第一时间想到的就是抽象建模,提取的最明显的类,模块。既然如此我们就开始吧。我们知道游戏的规则是把一个一个不规则的砖块尽可能的填满到槽里,我们就从这些不规则的砖块入手吧。砖块的样式很多,但是根据我们对游戏的了解,最
12、长(宽)的砖块也不找过 5 个,根据这个限制,我们定义一个 5x5 大小的一个容器来存储各个砖块的样式。标准的俄罗斯方块的砖块包括下面 7 中样式。当然,将来如果我们游戏设计的好,可以自己定义很多样式。 到这里,我们就已经提取出了一个砖块的类了,我们新建一个类,类名为 Brick。接下来,由于如何才能控制产生这些砖块呢?首先要知道总共有多少种砖块信息(样式,颜色) ,基于这个需要,我们再抽象出一个砖块模板类,用于描述一个砖块的样式,颜色等信息,这类就叫做BrickTemplate 吧。然后为了能够统一管理这些 BrickTemplate 信息,我们需要设计一个类,用来维护这些砖块模板,以方便我
13、们将来随时增加或者获取某个砖块模板,我们就定义一个 TemplateArray 类吧。然后呢,还差一个用来产生砖块的工厂,所以再定义一个 BrickFactory 类,用于生产砖块。到这里,我们对砖块的处理流程已基本清晰了,规整一下如表 2-1 下:表 2-1类 说明BrickTemplate 砖块的信息描述类,相当于一个模具,用来制作 BrickTemplateArray 管理砖块信息的管理类,用于维护 BrickTemplateBrickFactory 用于根据 BrickTemplate 产生一个实际的 BrickBrick 砖块实体类,用于描述一个实际砖块最后,我们还需要设计一个游戏画
14、布类,负责实现游戏的展示以及逻辑处理。这个类是整个游戏的核心,就起名叫做 GamePalette。至此,整个类级别的设计就基本结束,还有需要的等到时候用时再实现,方便期间,我们把这些逻辑模块放到一个文件夹中,解决方案的物理结构如下3. 逻辑实现接下了我们逐步讲解各个类的具体实现,先从 Brick 类入手,上面一节我们说过了,这个类是一个具体的砖块类,这个类应具有以下属性和方法。这里详细介绍一下我们的设计思路,我们以一个小砖块元素为单位,每个砖块有 5x5=25 个坐标,其中只有一部分是有数据的,也就是有效的,这些有效的坐标也就形成了砖块的样式。比如,下图这个砖块样式的坐标数组就应该为(1,2)
15、,(1,3),(2,2),(2,3)。但是这个砖块有多大呢,由于我们是以一个小砖块元素为单位,所以,每个小砖块元素的坐标点对应为画布上一个矩形区域,也就是说每个坐标点的是一个 RectPix 大小的块。再考虑到砖块是活动的,所以砖块的实际坐标得加上在画布上的偏移,也就是说某个点的坐标(x, y)在画布上的实际坐标为(X+x, Y+y)。但是这样计算起来比较麻烦,所以我们采用坐标系平移的方法,把坐标中心放置在砖块的中心,即 x 轴向上,y 轴向左偏移 2 个单位,这样就变成了上图所示,这样做的目的是为了方便计算。接下来,我们开始具体讲解每个函数的实现: 构造函数唯一需要说明一下的是初始偏移我们设
16、定为(2,2),这样就可以完整显示整个砖块了 顺时针旋转结合下图可以更好的理解转换规则,同样适用于逆时针旋转: 画砖块到画布这个函数很好理解,把每个点放大为一个矩形区域,然后以砖块颜色填充这个区域。这个函数需要注意的地方是我们在使用 Graphics 对象的时候要先 lock,这样防止同一时间其他地方也在使用这个对象,从而引发异常。 擦除画布上的砖块可见,其实擦除就是用背景色把砖块颜色覆盖而已,本质上和上面画砖块是一样的。 点放大为区域这个函数的功能前面介绍过了,就不多说了,这里的 width 和 height 要-2 是为了留出中间的间隔缝隙为画网格保留的。至此,砖块类的基本功能已经实现了,
17、我们现在可以测试一下这个砖块类是否能够正确运行,我们回到 Form1 的设计视图,修改如下:注意,这里我们不在 Form 上画,而是在 PictureBox 上画,其实只要有 Paint 功能的标准控件都可以来画,我们就选择 picturebox 控件,使用这个控件的好处还有很多,因为这个控件本事就是为显示图形图像设计的,所以将来如果需要可以提供更多的操作,比如我们将来想把背景换成一个图片之类的,用 picturebox 效率比较高,不会闪烁。实现代码如下所示:这里需要注意一下,因为我们修改砖块的属性以后,必须通知 picturebox 再次重绘砖块才能看到效果,这也是我们为什么调用 Refr
18、esh 的原因,这个方法会导致 picturebox 的重绘。运行一下,会发现我们期待中的砖块已经显示出来了,同样可以测试其他功能是否能够正常工作好了,至此,我们对砖块的初步设计已成型,可以说砖块已经具有了灵魂,可以动了,接下来我们把其他配套的类来实现一下。砖块样式信息模板类是生产砖块的模具,很容易想到,它应该记录要生产砖块的样式,以及颜色。但是如何记录砖块的样式呢?这个可以用任何你觉得可以描述的方法,我们前面说过用一个 5x5 大小的二维数组可以存储,然后 5x5 的二维数组可以演变为一个 25 的一维数组,比如我们可以用“0000000110001000010000000”来描述一个砖块样
19、式,这时你可能迷惑了,这咋看出来是个砖块的样式?不要急,我们说过这个一维数组是从二维数组演变过来的,我再把它还原看看(每 5 个换行) ,就变成了下面的格式,如果我们忽略 0,只看 1,那么这个形状不就是我们刚才测试的那个砖块的形状么?明白了这个,我们就来实现这个砖块样式信息的模板类吧,我们用 String 类型存储这个 0101 的样式编码,由于这个模板类很简单,也很容易理解,就不多讲了,直接看代码就可以看懂了。砖块的模具也实现了,根据前面的介绍,标准的游戏有 7 种砖块样式,也就是说游戏运行过程中,需要有 7 个这样的模具来随机调用生产砖块。那接下了就看看如何管理这些模具,也就是 Temp
20、lateArray类的实现,这个也很简单,为了使程序更简单,我们只提供了添加(add)和清空(clear)功能,至于编辑和删除如果有兴趣可以自己添加。代码如下:然后就差生产砖块的机器了,这个机器的工作原理就是随机的从 TemplateArray 中获取一个砖块模具 BrickTemplate,然后按照这个模具生产出相应的砖块 Brick。前面我们把这个类定义为BrickFactory,那就看看这个转窑怎么实现的吧。这个类的功能很简单,就一个函数,而且也不复杂,代码注释也很详细,就不再详细解释了,自己体会一下吧。接下来我们测试一下这些配套设施能否正确的产生砖块,我们回到 Form1 的设计界面,
21、添加一个button,用于随机更换砖块,并修改代码如下:代码中添加了一个 BrickFactory 对象,用于随机生成砖块,构造函数里清楚的介绍了如何初始化相应的数据,至于那些 0101 编码,估计你现在已经知道他们的含义了吧?对,就是对应那 7 中标准的砖块编码,至于颜色,根据自己的喜好,设定你喜欢的颜色就行了。测试结果如下4. 游戏规则的实现如果你能够成功实现前面所介绍的功能,你已经完成 50%了,而且这 50%含金量很高,足够你开发一些其他的游戏了,比如简单点的贪吃蛇,或者经典的吃豆子等等小游戏了。不过我们还是要先完成我们俄罗斯方块的游戏再说,不能一知半解的就结束。我们的砖块虽然已经有了
22、灵魂,但是还是不收束缚的灵魂,所以我们要做的就是控制它,不能让它无限制的移动,该落下的就落下,该停止的就停止。先看看这个游戏画布类应该具有哪些属性:常量区COLORS:用于在消除满行砖块时闪烁该行,增加游戏可视化效果TIME_SPANS:用于设定不同等级下的砖块的下落速度SCORE_SPANS:用于消除满行砖块时计算得分变量区m_BrickFactory:很容易理解,用于在游戏过程中生产砖块m_Width,m_Height:游戏画布水平和垂直格子的数目m_CoorArray:用于记录游戏画布上各个格子的颜色m_BgColor,m_GridColor:画布背景色和网格颜色m_Size:单元格的大
23、小m_Level,m_Score,m_GameOver,m_ShowGrid,m_Pause,m_Ready 这些变量容易理解,不再多说。m_MainPalette,m_NextPalette:这两个分别对应游戏中主画面和 next 的画面m_TimerBrick:用于实时更新游戏状态,比如砖块下落,检查是否得分等等timer 的用法不知道你熟悉不熟悉,timer 对象可以定时触发一个事件,而且不影响这段时间内其他部分的代码执行(timer 的用法待会再说。如果你了解多线程) ,这个地方也可以换成多线程的方式。接下来看看构造函数,很简单,就不多说了。再看看属性访问器,也很简单,不说了接下来就是
24、有含金量的函数了,这些函数共同形成了游戏规则以及游戏控制,先看看公有函数包含哪些这些函数是对外可见的,也就是通过这些接口我们来控制是玩游戏。我们着重介绍几个函数的作用 MoveDown这个函数最重要的一点就是中间的 for 循环判断是否能够向下移动,这一点是很重要的,也是游戏的核心规则。同样的方法处理 MoveLeft,MoveRight,就不多说了。 DropDown这个函数很简单,也很容易理解,就是快速的调用 MoveDown 而已。 DeasilRotate这个函数的关键地方也是要判断能不能满足旋转的条件,由于代码不复杂,结合前面介绍过砖块旋转的算法,所以,就不多说了,同样的方法验证 C
25、ontraRotate。 PaintPalette从注释上可以看出这个函数是用于绘制主画布上的游戏图形,至于其中调用的函数(PaintGridLine,PaintBricks) ,我们待会再介绍,暂时只需要先在代码里放一个空函数就行了。 PaintNext这个函数用于绘制下一个砖块到 Next 画布上,就先叫它“小地图”吧,代码很简单不再多解释了。 Start这函数用于开始游戏,开始的时候需要生成两个砖块,一个放在主画布上作为活动砖块,一个显示在“小地图”上,作为下一个。为了方便调试,我们没有启动计时器,等将来调试差不多的时候再取消注释即可,注意别忘了啊!或者不注释也可以调试。这里稍微介绍一下
26、 timer 的使用,timer 总共分 3 中,一种在 System.Timers 命名空间下,一种在 System.Threading 命名空间下,一种在 System.Windows.Form 命名空间下,它们各有各的特点,我们这里用的是 System.Timers 命名空间下的 Timer,timer 初始化的时候可以使用一个整形数字初始化触发间隔。我们这里就是用当前等级对应的速度来初始化,然后设定触发事件,也就是 Elapsed事件,ElapsedEventHanlder 的参数是一个委托,或者说是一个函数指针,用来绑定处理函数,我们这里设定的是 OnBrickTimedEvent。
27、至于 AutoReset 属性很容易理解,就是重置计时器。最后调用Start 方法开始计时或者 Stop 方法停止计时。 Close这个函数用于析构不需要的资源,尤其是最后两句,否则重新开始游戏的时候,会有 bug。剩下的暂停 Pause 和继续 Resume 函数就不说了,代码就一句,就是设定 m_Pause 变量的值而已。接下来我们来测试一下这些基本功能是否能够正确运行。重命名 Form1 为 FormMain,并回到设计视图,修改设计视图如下,两个 picturebox 对应主画布和“小地图” (注意设定 picturebox 的边框样式为FixedSingle,另外大小按照图上所示,后
28、面会介绍为什么设定大小) 。4 个 button 对应四个方向按键。然后回到代码视图,添加代码如下构造函数中初始化了 m_GamePalette 对象,并启动游戏。通过实参,我们可以计算出,游戏水平的宽度=水平格子数 x 格子大小 = 15 * 20 = 300,这也就是前面为什么设定游戏画布 pbMainPalette 的宽度为 300 的原因了,同样高度,以及小地图的大小也是这个道理。现在我们就可以测试是否砖块可以受控制了。运行一下,看看效果:同过点击四个按钮,看看主画布上的砖块能不能按照我们是要求旋转或者移动。至此这个砖块已经收到我们的控制了,剩下的就是处理落下的砖块了。接下来我们开始研
29、究 GamePalette 类的其他私有函数,看看他们是怎么实现的。 OnBrickTimedEvent这个函数我们前面提到过了,这个是 m_TimerBrick 触发时的处理函数,这个函数在正常情况下会首先调用 MoveDown 函数,使得活动砖块自动下落一格,如果不能下移则执行 CheckAndOverBrick 函数,检查游戏状态,比如游戏结束,或者消除满行砖块,游戏得分等等. PaintGridLine这个函数很好理解,画 m_Width-2 条竖线和 m_Height-2 条横线,实现网格效果。之所以每个方向都少画 2 条,是因为我们设定了 Picturebox 的边框样式了,也就是
30、说边框不用画了,就少了两条,当然多画两条也无所谓。 PaintBricks这个函数用来绘制已经存在的砖块,其实原理和画砖块是一样的,应该没有什么看不懂的吧,如果看不懂,可以写个小程序试试加深理解。 CheckAndOverBrick这个函数用于检查,更新游戏状态,这个函数执行的前提是不能下移(MoveDown 返回 false)的时候,所以此时首先需要更新画布已有砖块的信息,就是把当前这个砖块各个位置的颜色设定到画布的m_CoorArray 里,然后检查 m_CoorArray 并消除满行,具体方法见 CheckAndDelFullRow 函数。接着原先的下一个砖块变成了当前活动砖块,再检查这
31、个砖块能否放在画布上,也就是检查游戏结束,检查的原理注释上有。如果游戏没有结束,那么生成下一个砖块显示在“小地图”上。 CheckAndDelFullRow这个函数有点长,不过逻辑倒是不太复杂,通过注释可以看出来,就是判断当前砖块所在的那些行是不是存在满行的条件以及行的数目,然后删除这些行,并将上面的砖块下移,根据删除行的数目追加相应的得分以及修改对应的级别和速度。中间闪烁效果的那个 for 循环是可选的,可以不要。 InitRandomBrick这是一个可选函数,写不写都行,函数的作用是根据初始等级随机先在画布上放置一些零碎的砖块,用于增加游戏可玩性。 PaintGameOver这个函数用于
32、打印一个“GAME OVER“的结束信息,也是可选函数,不多讲了。至此整个游戏的核心类 GamePalette 类已经全部实现了,我们的游戏也已经完成 80%了,接下来就是前台 UI 的设计了。在前面的测试中,我们已经大体实现了前台的布局了,剩下要改的东西不多,你可以根据自己的喜好,设计出多种多样的游戏界面,下面介绍一下我的设计吧,虽然丑了点,但还是中规中矩的。主界面就先这样设计吧,没有复杂的控件,在前面的测试窗体基础上改动一下就差不多了。这里需要提醒一下,窗体的 KeyPreview 属性必须设定 true,这样窗体才能接受键盘事件。接着添加键盘按下事件。并在事件里调用对应的功能即可,这个窗体代码如下:当按下 F2 时,如果当前游戏存在,那么需要先关闭游戏,至于为什么要关闭,你可以测试一下不关闭的后果。然后定义 7 中标准的砖块样式模板用来构造砖块,然后初始化一个新的游戏。调用开始函数,就可以玩了。当按下其他键时,必须首先判断游戏存在才行,因为他们是控制游戏的,所以又先决条件。具体的按键功能很清楚,F3 暂停或者继续当前游戏,上下左右控制方向和选择,空格丢下。此时我们再测试一下,看看我们的俄罗斯方块是不是可以玩了呢?