RacingGame学习记录1——辅助类

来源:互联网 发布:淘宝十字绣回收骗局 编辑:程序博客网 时间:2024/05/11 15:05

 

第一节:辅助类

我想,在开始分析飞车的代码前,有必要先解释下辅助类这个概念。

顾名思义,辅助类的作用就是提供服务。在面向对象设计的表述中他们也常被称为服务者。与服务者相对应的是掌握控制权的控制者。他们两者之间的关系可以理解为士兵和军官之间的关系:控制者控制着程序中的全部,或部分逻辑;而服务者往往只封装了某一些方面的功能,自身并没有控制的能力,仅仅只是听候控制者的调遣。这种划分系统结构的方式在实际应用中经常被使用。其最大好处是结构清晰,同时也把在软件生存期间可能需要反复修改的控制流程集中到不处理低层问题的控制类中。相当于避免让一个战略家被繁杂事物困扰,使之能够专心思考战略全局上的问题。

现在可以看看我们可爱的飞车游戏代码中的一些辅助类的组织结构了。值得注意的一点是飞车中类的组织结构中,虽然能明显看到一个Helpers文件夹,让人知道这里面是辅助类,但并不是所有的提供服务的类都在这儿。如Graphics中的很多提供基本绘制的类,按上文所说的服务和控制的分法来说,也应该属于辅助类。在飞车的组织结构中,同样可以看到一个名为GameLogic的文件夹,从名字上很明显可以判断这属于控制者。但同样,并非代码中所有的控制者都在GameLogic中,在GameScreens中的那些控制着游戏界面样式的类同样属于控制类。所以说辅助类和控制类是更多意义上是一种抽象的理解。在Benjamin Nitschke设计每一个类的时候并没有刻意去注意哪些类属于服务者,哪些类又是控制者。但我们在阅读他的代码中,思路中若能把握这样一条线索,对整个游戏的结构便能看的更加明晰。

这一节的主要内容是分析飞车结构中的Helpers目录中的几个文件。如前所述,严格意义上这并不是代码中全部的辅助类。但为了全编的条理性,我将其他涉及具体功能的辅助类放到相应节中叙述。

 

文件一:ColorHelper.cs

从文件名中就能够直观地看出该文件是一个颜色方面的辅助类。像这样以文件的主要内容为开头,以Helper、Manager、Controler、Keeper这样表示身份的词作为结尾的命名方式同样也是面向对象设计中常使用的方法。比如负责碰撞检测的类称为CollisionManager,专门用来存储场景信息的类称为SceneDataKeeper等,在飞车的源代码中也能发现不少像这样的名称。

让我们开始分析这个源文件。源代码中对ColorHelper类的注释是这样说的:“颜色辅助类,只是转换颜色到不同的格式,同时提供在Color类(指XNAFramework中的Color类)中缺少的辅助方法。”

然后我们可以注意下对ColorHelper类的声明。ColorHelper类被声明为public static class。对于public,我本并不想在此过多的说明,任何一本类C语言的入门书都会对这个关键字做全面的解释。但仍然容易见到很多写代码的人对public、internal、private这三个关键字的理解有限。很多人不明白internal的作用,该用internal的地方都用public代替了。实际上internal的功能是强大的。在我当前的一个游戏项目SmartTank中,利用了

[assembly: InternalsVisibleTo( "assemblyName" )]

这个程序集属性将一个基础程序集的部分函数的调用权限制在若干高层程序集中。而在另一些未用该属性声明为内部可见的程序集中,这些函数是不可见的。个人认为这是管理组件之间的可见域的一种很简便的方法。免去了另一种在程序集之间使用接口和动态注入的办法的繁琐。

而static这个关键字却是辅助类的一个很典型的标志。通过static关键字来将一个类声明为静态类。这样,对ColorHelpr的所有引用都只会是ColorHelper.StaitcMethod()或者ColorHelper.StaticVariable这两种方式。符合辅助类不管理状态变量的特点。在飞车的代码中我们将会看到很多提供低层服务的内都被声明为静态类。

然后我们来看看ColorHelper类中的具体内容。首先是声明了两个Color类型的常量,Empty与HalfAlpha。声明中之所以用readonly关键字而不用const是因为const不能修饰包括了结构类型的type类型。(更详细的解释请参考MSDN。)Benjamin认为这两个常量会经常被使用,所以就在这里声明为静态只读对象了。XNAFramework中的很多类中也包含这样的常量,例如Vetctor2.Zero。

接下来的公有函数中,提供了两种混合颜色的方法,和两种改变颜色深度/透明度的方法。

MultiplyColors(Color col1, Color col2):将两个颜色各通道的值相乘后除以255,作为新颜色的相应通道值。

InterpolateColor(Color col1, Color col2, float percent):将两个颜色各通道的值线性混合后作为新颜色的相应通道值。

经过对两个方法的比较测试,我们可以发现,前一个方法会凸现在两个颜色中值都较高的通道,而削弱两个颜色中值都不高的通道;而后一种方法由于是线性混合,新的颜色是输入颜色的某个中间色,而参数percent(0~1f)表示第二个输入颜色在输出颜色中所占的百分比。

ApplyAlphaToColor(Color col, float newAlpha ):将传入颜色的Alpha值(透明度)修改为newAlpha*225,作为输出颜色。

MixAlphaToColor ( Color col, float newAlpha ):将传入颜色的Alpha值修改为newAlpha*225并将其他通道的值乘以newAlpha作为输出颜色相应通道的值。

这两个函数在制作2d贴图的淡入淡出效果时非常有用。有了这个函数,我们只需要在游戏每一次Update中改变(递增或递减)一个储存着Alpha值的变量,然后将此变量传递给这两个函数其中之一来生成实际的绘制颜色,就能制作出淡入或淡出效果。如果使用前者,则单纯改变颜色的透明度,如果使用后者,将会发现在改变透明度的同时还改变了颜色的深浅。

虽然这个类看起来很短小,但作用倒是不小。所以我在SmartTank的项目就直接将该文件引用了。辅助类的另一个特点就是跟具体程序松耦合,能够很容易地转移到其他项目中。

 

文件二:Directories.cs

个人很疑惑为什么Benjamin没有把这个类声明为静态类,而是写了一个空的私有构造函数来防止在类的外部创建这个类的实例。实际上声明为静态类完全不成问题,编译得很顺利。(当然还得去掉那个私有构造函数。)难道Benjamin这样做的目的是想向我们展示他达到同一目的的不同途径?恐怕这个问题只能去问他本人了。顺带着说明一下为什么要将构造函数声明为私有的:一个类,即使你不写构造函数,编译器默认都会为你生成一个不带参数的空的公共构造函数。这样只要在类的可见域中,任何部分的代码都可以创建一个类的实例。但如果我们写了一个私有的不带参数的构造函数来覆盖这个默认的构造函数,那么这个类便没有公共构造函数了。自然,在这个类的外部就无法创建这个类的实例了。这种方法常使用在单实例类这种设计模式中——这种类只拥有一个唯一的实例,往往在这个类中作为静态成员被创建。

言归正传,让我们来分析下这个Directories类的功能和他为整个程序带来的怎样的好处。

Directory意为目录。Directories便是管理程序中所有文件目录常量的类了。Directories类中的一个名为Directories的区间(region)。此区间中包含了若干程序其他部分常用到的目录的路径。任何需要此路径的地方,都必须来询问Directories类。就好比Directories类是一个管理仓库的老爷爷。其他人(类)要到仓库里去找东西,每一次来都要问老爷爷螺丝放哪里了啊?扳手又放哪里了啊?老爷爷就告诉他们具体的位置。如果出于某种原因螺丝要换地方存放了,(游戏的编写过程中很有可能会变化目录结构)只需要通知老爷爷就可以了,以后别人来要螺丝,老爷爷又会告诉他螺丝的新位置。这种方式比让每个人评自己的印象去找螺丝要好的多,如果螺丝换位子了而这个信息又没有通知到每一个需要螺丝的人。那么没有获知的人去找螺丝就会出异常了。其实早在C语言时期,就有一条代码约定:不要在代码中直接包含常量,先将常量用标识符表示出来。(通过#define或者常量声明)然后在代码中每个需要这个常量的地方,都代以该标识符。作用也是一样的。

Directory类中使用的比较多的一个函数是Path.Combine( string, string )。这个函数在拼接目录或文件路径时非常有用。不需要你关心传入的字符串的开头或结尾是否包括那两个“/”。Path类也就是System.IO名称空间中的一个辅助类了,还包含有很多与路径相关的辅助静态函数。

最后要说一下类中对GameBaseDirectory的初始化语句:

GameBaseDirectory = StorageContainer.TitleLocation;

StorageContainer类是XNAFramework中定义的一个类。而TitleLocation是用来获得游戏执行目录的绝对路径的。如果你查询TitleLocation的定义的话,就可以发现这么一行注释:The title's install location based on the current platform.意思就是说返回值视平台的不同而不同。也就是说,这个类是为了方便统一处理在windows平台和xbox平台上的储存文件的。无论这个程序在windows上运行,还是xbox平台上运行,StorageContainer.TitleLocation都可以返回相应的运行目录。免除了XNA程序员的一个麻烦。

 

文件三:FileHelper.cs

让我们接下来将视线转移到程序中处理文件IO的一个辅助类,FileHelper。

这个类的辅助函数包括了对文件读写的基本操作;包括从文件中提取包含文件中每一行的字符串的数组,(GetLines函数);也包括了对一些特定数据结构的读写的辅助函数。

对文件的读写操作涉及平台特性。XNA中的文件读写操作在本书之前的章节已经提及,本处就不在重复说明了。

让我们把视线转入Getlines函数中。其中有这样一个循环:

List<string> lines = new List<string>();

do

{

    lines.Add( reader.ReadLine() );

} while (reader.Peek() > -1);

MSDN中对获取文件中行的示例代码并没有用do while句型。而是:while(reader.Peek() >= 0)

{

       lines.Add( reader.ReadLine() );

}

读者可能会担心Benjamin这样的写法遇到所读文件为空的时候会不会抛出异常。经过一个简单的测试,发现在此使用do while这种句型确实有值得商榷的地方:当所读文件为空时,reader.ReadLine()的返回值为null。而这个没有被初始化的string对象就这样被添加到lines中,又返回到调用GetLines函数的地方。带来一些潜在的危险。个人认为还是采用MSDN中的写法比较妥当。

另外我们来看看FileHelper当中的几个序列化和反序列化的辅助函数。分别将三元向量,四员向量,4*4矩阵进行读写。对序列化这个名词可以略微解释一下:因为文件的读写操作都是以流的形式进行的。(涉及硬件的工作形式。)所以如果要将内存中的数据保存到磁盘上或者通过网络传播出去,抑或是要从磁盘或网络上将信息装换成内存中那样的结构形式,就必须经过序列化或反序列化的过程。BinaryWriter与BinaryReader实际上已经绑定到某个流上,执行writer.Write()或者reader.Read*()时实际上是在一个流上进行读写操作。看看这几个序列化的函数就可以发现序列化无非只是将某个数据结构按一定的顺序写到流中,而反序列化只是按同样的顺序从流中读取数据,并用来初始化这个数据结构而已了。

一旦自己的游戏中需要保存和读取自定义文件,(比如从地图编辑器中保存一个游戏场景,然后在游戏中读入这个场景文件。)就必然会面临文件的序列化和反序列化的问题。整个文件的序列化,实际上也是由FileHelper中这样的基本数据结构的序列化组成的。这种情况下,除了自己设计序列化的方式之外,还可以遵循.NetFramework提供的一个序列化的管理方法。关于这个,可以从MSDN中关于ISerializable、SerializableAttribute、BinaryFormatter的页面中获知。

 

文件四:Log.cs

相信大家对Log这个词一定不会陌生。这个记录日志的东西已经是中大型应用程序中不可缺少的组成部分。他的目的就是用来Debug的。或许一些人会忽视这玩意的作用,认为自己的程序出点什么问题,设置几个断点,跟踪一下就都能够解决。(我最初也抱有这样的顽固念头。)但设想一下,在像我们游戏更新的Update函数那样的反复循环中,出现了一个足够偶然,(大概几千次循环会无规律地出现那么一两次。)但又足以让程序无法正常运行的Bug。又加上这个bug涉及的因素可能不止一个,或者你根本不知道这个bug从何而来。在这种情况下,你想重现这个bug都几乎不可能。更别说修正它了。但在这样情况下,如果我们有一个像这样的Log类,能对关键部件的运行状态进行记录。那么一旦发现程序运行不正常了,只需看看Log写下的记录,对bug的定位就会容易很多了。然后可以在出问题的组件中进行调试,或者按测试的那套方法进行修改。目的性明确了不少。

来看Log类的结构。只有两个函数,一是initialize(),用来创建日志文件的,可以看到他调用了FileHelper的CreateGameContentFile方法,传入createNew参数的是false,意思就是如果文件存在,打开这个文件并返回就可以了。可见Log类每次运行都是接着同一个日志文件的末尾来添加新日志。也可以在代码中看到每次程序运行都会在日志文件中添加一个记录运行时间的说明头,以便能够区分不同时间的运行日志。

第二个函数就是Log的辅助函数了:Write( string message )。程序中任何的位置可以调用这个函数来记录下出错的信息。而这个函数的作用就是往日志文件中写下这个消息了。

值得注意的是这么一个预处理指令:

#if DEBUG

    // In debug mode write that message to the console as well!

     System.Console.WriteLine(s);

#endif

这几行的意思是,如果当前运行的是Debug版本的生成文件,那么也在控制台上输出这条信息。为避免一些读者在此处产生疑惑,我就简单的介绍下System.Console.WriteLine(s)在这里的作用。如果你在C#的IDE(集成编辑环境,在这里也就指C# Express或者Visual Studio)中打开了RacingGame的解决方案。右键点击“解决方案资源管理器”中RacingGame项目名,可以在右键菜单中的最下方找到“属性”。单击后出现属性编辑界面。在属性界面中定位到“应用程序”选项卡,可以看到“程序集名称”,“默认命名空间”,接下来是一个名为“输出类型”的选框,当前它的值是Windows应用程序。打开下拉列表,会发现还有两种其他的类型,分别是“控制台应用程序”和“类库”。这里,如果选择“控制台应用程序”的话,编译再运行的时候会首先出现一个控制台,然后再出现游戏的窗口。这种情况下Console.WriteLine函数的输出就会显示在控制台中。但如果项目输出类型是Windows应用程序的话,控制台的窗口将不在显示,Console.WriteLine函数的输出会记录在输出视图中。而输出视图可以通过IDE菜单中“视图”下的“输出”切换是否显示。这样,当游戏还在Debug阶段是,直接通过输出视图便可以查看本次运行的日志。方便了程序的调试。

 

文件五:RandomHelper.cs

让游戏中出现一些随机的因素能够大大加强游戏的可玩性。在暴雪公司的著名作品《DiabloII》中,每新建一个玩家账号,产生的游戏地图都会不一样。因为游戏中地图的具体形状是通过一个地图种子按一定的算法生成的。地图种子的值不同,得到的地图形状就会不同。.NetFramework中提供一个产生随机数的类——Random。其内部也是使用一个种子,通过一种随机算法产生随机数序列。也就是说,如果种子相同,产生的随机序列就会一摸一样。这样的序列一般称之为伪随机数序列。但如果把种子与程序的运行时间相关联,由于程序每次运行时间不可能一样,产生的随机数序列就不可能重复出现了。

RandomHelper类就是对Random类进行了封装。在GenerateNewRandomGenerator函数中可以明显看出是怎样将运行时间设置为Random用于产生序列的种子的:

globalRandomGenerator = new Random( (int)DateTime.Now.Ticks );

接下来,在region“Get random float and byte methods”中定义了一些获得某一种类型的随机值的辅助函数。其中NormalVector3的含义是标准三元向量,即长度为1的三元向量。

这同样是一个很实用的辅助类,所以我的游戏中也直接包含了这个文件。

 

文件六:Vector3Helper.cs

这是Helpers目录中的最后一个文件了。文件的内容也是最简单的——补充一些三元向量的实用函数,分别有:计算两向量之间的夹角;计算点到直线的距离;计算点到面的距离。好在中国人的数学普遍比美国人学得好,这本书的之前的章节对游戏中的数学问题也做过介绍了,所以这里对数学的计算公式也就不再多说。我们在自己的项目中,如果有必要的话,完全可以写一个更好的数学组件。包括这里的一些向量运算。

 

小节:

本节通过对Helpers文件夹中六个辅助类的分析,了解了辅助类的作用。了解了面向对象设计中控制者和服务者之间的分工合作。同时也介绍了文件IO、日志记录、随机数生成等相关知识。

辅助类的一大优点是跟具体项目基本不耦合,可以随着项目经验不断积累,不断改良。同时也容易相互交流,分享。所以祝大家都能积累出自己的一些小小的财富吧! 

原创粉丝点击