第六章 使用Managed DirectX编写游戏
翻译:clayman
仅供个人学习之用,勿用于任何商业用途,转载请注明作者^_^
选择游戏
虽然很多关于3D游戏编程的高级主题还没有讨论,但我们已经有足够的背景知识来写一个简单游戏了。这一章,我们将使用至今学过的知识,再加上一点点新的东西来创建游戏。
真正开始写游戏之前,最好先拟一份计划。我们需要确定写什么类型的游戏,它将有哪些最基本的特性,等等。考虑到目前的技术限制,自然不能写太复杂的游戏。这将是一个简单的游戏。在MS-DOS环境下,曾经有一个叫做“Donkey”的游戏,玩家控制着车不能撞到路上的donkey。听起来足够简单吧,我们将创建一个三维版本,并且用普通的障碍物来代替donkey。这个游戏叫做“躲避者(Dodger)”。
开始编码之前,需要花一点时间来策划和设计游戏。我们需要怎样的游戏,玩的时候来控制。Well,显然,要有一个Car类来控制交通工具。接下来,使用另一个类来控制障碍物将会很不错。除此之外,主要的游戏引擎类必须完成所有的渲染操作并把所有对象组织起来。
如果尝试商业游戏,那么大部分时间将会花在游戏创意上。游戏创意将会写成详细的文档,包括了游戏主题和特性的各种细节。本书的着重于讨论游戏的实际开发工作,而不是游戏发行和创意,所以我们将略过这一步。
通常开之发写还必须写完整的技术文档(technical specification)(简称为spec)。它包以适当的细节列出了所以类,以及需要实现的各种方法、属性。通常还包括表示对象之间关系的UML图。这份文档的目的是让你在编码前坐下来认真考虑程序的设计。由于本书聚焦于代码的编写,我们同样略过这一步。需要说明的是,强烈建议你在写任何代码前花点时间撰写技术文档。
编写游戏
现在可以打开VS创建项目了。创建一个名为Dodger的windows应用程序。使用DodgerGame代替代码中所有出现Form1的地方。添加对DirectX程序集的引用。创建私有的device成员,如下修改构造函数:
public DodgerGame()
{
this.Size = new Size(800,600);
this.Text = “Dodger Game”;
this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.Opaque,true);
}
这将会把窗口设置为800×600(注:实际代码中我将会创建一个全屏的游戏,另外如果现在运行程序,会发现我们创建了一个透明的窗口),设置窗口标题和样式(style),这样渲染代码才会正常工作。接下来修改程序的入口点:
static void Main() {详见源码}
这个应该很熟悉了吧,基本上就是之前每一章用来启动程序的代码。创建窗体、初始化图形引擎,运行窗体。在initializeGraphics内做如下改动:
private void InitializeGraphics() {详见源码};
创建了presentation parameters结构之后,确保有它有深度缓冲。这里有什么新内容呢?首先,保存了默认的适配器的序数号,接下来保存了creation flags,并把它的默认值设为software vertex processing。但是,现代图形卡都在硬件层实现了vertex processing。何必把宝贵的CPU资源用在显卡可以完成的任务上呢?答案是不需要这样做,但你不知道是否真的支持这种特性,于是有了接下来的代码。在真正创建device之前,需要先保存显卡的功能(capabilities,简称Caps),这样可以用来决定使用那一种flags创建device。因为你只是创建一个硬件设备,所以只储存这几个Caps就可以了。关于检查适配器所有Caps的内容回忆一下第二章吧。
还记得使用顶点缓冲时需要在重置设备之后重建缓冲吗?我们为device订阅了created事件。当device重置之后,设定device的所有默认状态,添加如下代码:
private void OnDeviceReset(object sender,EventArgs e) {详见源码};
(注意:类似于这里的代码,你可能会使用一个层(layer)来检查支持的灯光。这种情况下,先检查是否支持一盏灯,如果可以,则创建它。然后再用类似的方法检测是否支持第二盏灯。这样即使最差的情况你也能获得一盏灯光)
这里和前面学过的代码也很类似,通过projection fransform和view transform来设置摄像机。对于这个游戏来说,我们的摄像机不需要移动,所以只需要在重置设备之后设置一次就可以了(与设备相关的状态都会在重值之后丢失)。
环境光不是最好的选择,我们已经知道他不能产生真实的光影效果,所以方向光将是不错的选择。但并不能确定设备是否支持这种光源。创建了设备之后,就不需要再使用先前的Caps结构了,device会为你保留着这些信息。如果device支持方向光,而且支持一盏以上的灯光,你应该使用它;否则,使用默认的环境光。它虽然不真实,但总比黑色的场景要好吧。最后,重载OnPaint方法,:
protected override void OnPaint(PaintEventArgs e){详见源码};
这里没有什么新内容,当然你可以把背景改为任何你喜欢的颜色。现在已经为加载模型做好了准备。创建变量来储存.X文件中的赛道模型吧。
private Mesh roadMesh = null;
private Material[] roadMaterials = null;
private Texture[] roadTextures = null;
接下来修改一下前一章里的load mesh方法。最大的改变是将把它改为静态方法,因为不止一个类会调用它,同样把所有的材质和纹理作为参数来传递,而不是作为类成员来访问。添加如下代码:
public static Mesh LoadMesh(Device device,string file,ref Material[] meshMaterials,ref Texture[] meshTextures){详见源码};
这个方法前面已经深入讨论过了。使用这个方法来加载赛道模型,还需要在重置设备的事件里添加它,在OnDeviceReset最后加上如下代码:
roadMesh = LoadMesh(device,@"..\..\road.x",ref roadMaterials,ref roadTextures);
确定你已经把赛道模型和纹理文件复制到了源文件的目录下。这段代码将会加载模型以及纹理,并储存纹理、裁制以及模型。每一帧道路mesh都需要渲染很多次,因该创建一个方法来完成渲染工作。添加如下代码:
private void DrawRoad(float x, float y ,float z) {详见源码};
你应该还记得这个方法吧,它和我们之前使用的方法如此类似。把mesh变换为正确的位置然后渲染每一个子集。我们需要每次渲染两段赛道mesh:一段是赛车现在行驶的赛道,一段是即将行驶到的赛道。实际上我们的赛车并没与移动,而是赛道在移动。这样做的原因有两个:如果每一帧都移动赛车,那么还必须同时移动摄像机来跟上它。这些而外的计算实际上是不必要的。还有一个更重要的原因:如果赛车向前移动,而且玩家很厉害,那么赛车的位置可能会超出浮点值的范围,甚至导致溢出。因为我们的游戏世界并没有边界(游戏不会有终点),所以让赛车停留在原地,移动赛道。
自然,需要一些变量来控制赛道。添加如下代码:
public const float RoadLocationLeft = 2.5f;
public const float RoadLocationRight = -2.5f;
private const float RoadSize = 100.0f;
private const float MaxRoadSpeed = 250.0f;
private const float RoadSpeedIncrement = 0.5f;
private float RoadDepth0 = 0.0f;
private float RoadDepth1 = -100.0f;
private float RoadSpeed = 30.0f;
作为mesh的赛道模型是已知的,长宽各为100个单位。RoadSize常量就是赛道的长度,两个location常量标记了赛道两边的中点。最后两个常量用来控制游戏操作。最大速度让游戏每秒移动250个单位,每次加速多移动0.5个单位。
最后,设置两段赛道的深度。把地一段赛道设置为0,第二段紧跟着上一段赛道。添加绘制赛道的代码,使用这几个变量来绘制赛道。在BeginScene方法之后添加如下代码:
DrawRoad(0.0f,0.0f,RoadDepth0);
DrawRoad(0.0f,0.0f,RoadDepth1);
现在运行程序,可以看到已经正确的绘制了赛道,但是这条沥青的赛道看起来极度可怕。这种结果是由Direct3渲染计算像素的方式引起的。当一个texel要覆盖屏幕中的多个像素时,这些像素需要通过一个放大过滤器来补偿(magnify filter to compensate)。当几个texel需要被绘制为一个像素时,他们会通过一个缩小过滤器。两种情况下的默认过滤器是一个名为Point的过滤器,它将会使用texel最接近的颜色作为像素的颜色,因此导致了这种情况。
有很多种方法来过滤纹理,但是,device不一定支持。我们只需要一个可以在texel之间插值计算,让赛道纹理看起来比较平滑的过滤器就可以了。在OnDeviceReset方法里添加如下代码:
详见private void OnDeviceReset(object sender,EventArgs e)中的代码
如你所见,先检查设备在放大(magnification)和缩小(minification)上是否支持各向异性(anisotropic)的过滤器。如果可以,就使用它。不行的话,再检测是否支持线性(linear)过滤器。如果两者都不可用,那么只能什么都不作,保留这种粗糙的效果。假设你的图形卡能支持其中一种过滤器,那么现在可以看到效果要好多了。
赛道以及处在了屏幕的中间,但还没有移动。还需要一个方法来更新游戏状态,完成移动赛道,进行碰撞检测。应该再OnPaint方法一开始就调用这个方法(再clear方法前):
OnFrameUpdate();
以下则是这个方法的代码:
private void OnFrameUpdate(){详见源码}
整个游戏编写完之后会有很庞大的代码,但现在,我们所需的只是让路动起来而已。先忽略elapsedTime,这段代码所作的只是移动路面而已。最后还需要添加一个变量:
private float elapsedTime = 0.0f;
特别提示:
为什么需要使用时间呢?为了方便讨论,假设我们每一帧都把赛道移动相同的距离。也许在你的电脑上它运行的很完美,但在其他的系统上呢?找一台比你的系统配置低的系统运行看看吧,赛道看起来会运行的相当缓慢。同样换到配置较高的系统上,赛道又会移动的快很多。原因在于你的计算是基于帧速率(frame rate)。假设在你的系统上,每秒可以跑60帧,那么所有的计算过程都是依赖于这个静态的帧速率而来的。因此,在每秒可以跑40帧或80帧的系统中,自然会得到不同的计算结果。让你的程序在每一个系统下运行都得到同样的结果是我们的基本目标之一,因此无论如何都应该避免基于帧速率的计算。
解决这个问题一个比较好的方法就是根据时间来计算位移。比如,赛道的最大速度定义为每秒250个单位。首先,我们需要获得自上一次“更新”过后过去的时间间隔。.net运行时内建的一个属性(tick count)可以用来获得系统的tick count。但它它并不完美:这个计时器的精度太低。它的值大约每15毫秒才更新一次,因此,在一个高帧速率的系统中(每秒60帧以上),赛道的移动将是不连续的,因为所用的时间不是平滑的。
如果你的系统支持的话,在DirectX SDK包含了一个高精度(通常精度为1毫秒)的计时器类DirectXTimer。但如何你的系统不支持,那么则只能使用tick count了。本书都将使用这个计时器来计算时间。(注:这里的DirectXTimer实际上是作者通过P/Invoke自己实现的一个计时器,代码在Utility.cs文件中,书上没有具体讲解实现方法,但大家应该都能看明白吧^_^)
为场景添加一辆可移动的赛车吧
好了,现在已经有了渲染好的、并且可以沿着场景移动的赛道了,接下来应该添加实际与玩家交互的对象了:一辆赛车。可以简单的再添加赛道的那个主要的类里加上一些关于赛车的变量和常量就可以了,但这样的代码将不是模块化的。你应该把关于赛车的代码分离出来,成为一个独立的类。为工程添加一个名为“Car”的新类吧。
Car类应该完成些什么任务呢?因为当其他物体移动的使用它仍然是静止不动的,不需要向前,也不需要向后。但为了让赛车能躲避路上的障碍物,它应该能够左右移动,同样,它还需要能渲染自身。好了,有了这些信息,就可以为类添加成员了:
详见源码
这些变量已经足够用于控制赛车了。Height和depth都为静态的常量。赛车向两旁的移动速度的增量也是常量。使用最后一个常量的原因是赛车模型的大小刚好比赛道大,所以需要把它缩小一点点。
其他的成员基本上一看名字就知道它的用途了。有赛车当前的位置数据,默认情况下赛车位于赛道的左边。赛车的直径(Diameter),稍后会使用它来进行碰撞检测。有赛车的侧滑速度。当然,还有用来检测赛车在向哪个方向移动的两个布尔变量。最后,是有关mesh的变量。
Car类的构造函数需要完成两个任务:创建mesh对象(包括与它相关的结构)以及计算赛车的直径。添加一下构造函数:
public Car(){详见源码}
创建car mesh的方法和创建road mesh的方法基本上一样。接下来计算直径的新的代码则是比较有趣的。这里实际上是在计算赛车的边界球体(bounding sphere,mesh的所有的顶点都包含在这个球体内)。Geometry类包含了这个方法,只要把需要计算边界的顶点作为参数传给这个方法就可以了。
这里所需的就是从mesh获得顶点。你已经知道顶点保存在顶点缓冲内的,因此直接使用这块顶点缓冲。为了读取顶点缓冲中的数据,必须调用lock方法。在下一章中,会学到更多来自于VertexBuffer类的lock方法重载。现在,只需要知道这个方法会使用一个流返回所有顶点数据。还可以使用ComputeBoundingSphere方法获得这个mesh的“中心”以及边界球体的半径。因为我们并不需要关心mesh的中心,所以只需要把半径乘2获得直径就可以了。但是,模型经过了缩放,所以直径也需要缩放同样的比例。最后(在必不可少的finally块中),确定解锁并且释放了顶点缓冲。
接下来,添加绘制赛车的方法。Car类已经保存了赛车的位置,只需要获得device对象就可以绘图了。这个方法几乎和DrawRoad方法一样,区别在于变量不同以及在变换前需要缩放mesh,添加如下代码:
public void DrawCar(Device device) {详见源码}
在使用Car类之前,还需要让外部可以访问类的私有成员,添加如下公共属性:
{详见源码}
现在,应该在主要的游戏引擎类里添加成员来使用Car类了。在DogerGame类里添加如下代码:
private Car car = null;
由于car类的构造函数需要device作为变量才能初始化,所以只有在创建了device之后才能调用它。在OnDeviceReset方法里创建car是个不错的主意,在创建了road mesh之后添加如下代码:
car = new Car(device);
创建了赛车之后,就可以更新渲染部分的代码了。在OnPaint中两个DrawRoad方法之后添加以下代码:
car.Draw(device);
可以看到,已经在路上正确的渲染了赛车。可是,如何才能控制赛车左右移动呢?先忽略鼠标的存在,假设玩家拥有键盘,并且将使用键盘来控制游戏。使用键盘上的4个方法键来控制游戏是不错的选择。重载OnKeyDown方法:
protected override void OnKyeDown(KeyEventArgs e) {详见源码}
这里没有什么特别的内容。如果按下了ESC则游戏结束同时关闭窗口。按下左键或者右键,则把相应的moving变量设置为true,另一个则设为false。现在运行程序,按下按键可以正确更新赛车的两个moving变量。但赛车本身并不会移动,还需要为赛车添加一个函数更新它的位置:
public void Update(float elapsedTime)
这个方法接受逝去的时间值作为参数,所以无论在任何系统上,都会得到相同的结果。这个方法本身很简单,哪一个moving变量的值为true,则向那个方向移动移动相应的距离(根据所经过的时间长短)接下利检查是否已经移动到了边界,如果是的话则完成移动。但是,这个方法是不会自己调用自己的,还需要更新OnFrameUpdate方法,加入以下代码:
car.Update(elapsedTime);
(注:如果你是按着教程一步一步来,没有偷看最后源码的话,会发现此时赛车根本不会移动,郁闷吧,呵呵,原因是根本没有启动计时器。在初始化图形设备的InitializeGraphics()方法中加上如下代码吧 Utility.Timer(DirectXTimer.Start); )
添加障碍物
恭喜,这就是你创建的第一个3D互动程序了。已经完成了模拟赛车的移动。虽然实际上是赛道在移动,但显出的效果确实是赛车在移动。至此,游戏已经完成大半。接下来是添加障碍物的时候了。与添加Car类一样,添加一个名为Obstacle的类。
我们将使用不同颜色形状的mesh作为障碍物。通过mesh类创建stock对象可以改变mesh的类型,同时,使用材质来改变障碍物的颜色。添加如下的变量和常量:
{详见源码}
第一个常量表示将会有5种不同类型的mesh(球体、立方体、圆环、圆柱以及茶壶)。其中大多数的物体都有一个长度或半径的参数。我们希望所有障碍物都有同样的尺寸,所以应该把这些参数都设置为常量。很多种mesh类型都有一个而外的参数可以控制mesh中的三角形数量(stacks,slices,rings等等)。最后一个常量就是用来控制这些参数的。可以增大或减小这个参数来控制mesh的细节。
接下来的color数组用来控制mesh的颜色。我只是随即的选择了一些颜色而已,也可以把它们改为任何你喜欢的颜色。应该注意到这个类里既没有任何的材质数组,也没有纹理数组。你应该知道默认的mesh类型只包含了一个没有材质和纹理的子集,因此,额外的信息是不需要的。
由于障碍物需要放置在路面之上,并且实际上是路在移动,所以必须保证它们是和路面同时移动的。需要position属性来保证在路面移动时障碍物会同时更新。最后由于在创建茶壶时不能控制它的大小,需要检查创建的是否为茶壶,平且对它进行相应的缩放。为Obstacle类添加如下构造函数:
注意到这里我们使用了来自utility的Rnd属性。它的具体实现非常简单位于utitity.cs文件中,只是用来返回一个随即的时间而已。Obstacle默认的构造函数保存了障碍物的默认位置,而且默认的为一个“非茶壶的”mesh。接下来选择创建某个类型的mesh。最后,选择一个随机的颜色作为材质颜色。
在把障碍物添加到游戏引擎之前,还有一些额外的工作需要完成。首先,添加一个方法来和赛道同步更新障碍物的位置。,添加如下代码:
public void Update(float elapsedTime,float speed)
再一次使用elapsed time作为参数来保证程序在任何系统都能正常工作。同时,把当前赛道的速度也作为参数,这样物体就好像是“放置”在赛道上一样。接下来,还需要一个方法渲染障碍物:
public void Draw(Device device) {详见源码}
因为茶壶没有经过正确的缩放,因此如果渲染的是茶壶,那么应该先对他进行缩放,再移动到正确的位置。之后,设置材质颜色,把纹理设置为null,绘制mesh。
显然,同一时间赛道上需要多个障碍物。你需要一个方法来简单的在游戏引擎里添加或者移除障碍物。使用数组是一个可行的方法,却不是最好的:数组不能重置大小。集合是一个不错的选择,为obstacles添加一个集合来储存障碍物:
public class Obstacles : Ienumerable {详见源码}
当然,别忘了对System.Collections名称空间的引用。这个类包含了可以直接访问集合成员的索引器,可以让foreach方法正确工作的迭代器,以及三个值得注意的方法:add,remove以及clear。obstacle文件有了这些基本的方法之后,可以为游戏引擎添加障碍物了。
首先,需要一个变量来储存当前场景里的障碍物。为DodgerGame类添加如下变量:
private Obstacles obstacles;
接下来,需要一个方法用新的障碍物填充即将出现的一段赛道,添加如下代码:
private void AddObstacles(float minDepth) {详见源码}
这个方法是把障碍物添加到游戏的起点。首先,计算需要添加到这段赛道的障碍物数量。同时,还必须保证在障碍物之间有足够的距离让赛车躲避,否则,对玩家而言很不公平。接下来,就把障碍物随机的添加到路上。同时,把它添加到当前的obstacles集合中。注意到在创建障碍物时使用了一个名为ObstaclesHeight的常量,以下是它的声明:
在障碍物出现在场景之前,还有3件事要做:you need to add a call into our obstacle addition method somewhere,你需要取保为场景中的每一个障碍物都调用了update方法,你最后还需要渲染障碍物。因为在开始游戏前,需要把所有成员变量都重置为默认状态。是添加一个新方法的时候了,使用这个方法来初始化AddObstacles。添加如下代码:
private void LoadDefaultFameOptions()
{
RoadDepth0 = 0.0f;
RoadDepth1 = -100.0f;
RoadSpeed = 30.0f;
car.Location = RoadLocationLeft;
car.Speed = 10.0f;
car.IsMovingLeft = false;
car.IsMovingRight = false;
foreach(Obstacle o in obstacles)
{
o.Dispose();
}
obstacles.Clear();
AddObstaxles(RoadDepth1);
Utility.Timer(DirectXTimer.Start);
}
这个方法重置了大量我们关心的成员变量。同时依次对集合中的对象进行dispose操作,并且在重新填充集合之前删除所有元素。最后,启动计时器。应该在InitializeGraphics方法中创建了device之后的地方调用这个方法。千万不要把这个方法添加到OnDeviceReset方法中;只需要在每次游戏开始的时候调用一次就足够了。
LoadDefaultGameOptions();
现在需要在OnFrameUpdate方法中添加一个方法来更新障碍物。因为每一帧都需要更新所有障碍物,所以应该迭代他们。在OnFrameUpdate方法中,car.Update(elapsedTime)之前,添加如下代码:
foreach(Obstacle o in obstacles)
{
o.Update(elapsedTime,roadSpeed);
}
把障碍物添加到游戏引擎的一步就是渲染他们了。在OnPaint方法中,紧跟在绘制赛车的代码之后,添加我们熟悉的方法来渲染障碍物:
foreach(Obstacle o in obstacles)
{
o.Draw(device);
}
尝试着运行程序吧!(注:哈哈,程序抛出异常了吧,记住,还要在OnDeviceReset的最后添加 obstacles = new Obstacles();)。当你沿赛道行驶了一段距离,避开了几个障碍物之后,发生了什么?看起来在避开了最初的几个障碍物之后,就不在有新的障碍物出现了。回想一下先前的代码,你只是在第一段赛道添加了障碍物,之后,不停的移动这段赛道。但对于新的“赛道段”,你并没有调用任何方法填充障碍物。在添加新赛道段的地方添加如下代码吧:
if(roadDepth0 > 75.0f)
{
roadDepth0 = roadDepth1 - 100.0f;
AddObstacles(roadDepth0);
}
if(roadDepth1 > 75.0f)
{
roadDepth1 = roadDepth0 - 100.0f;
AddObstacles(roadDepth1);
}
不错,现在看起来好多了。你获得了一辆在赛道上缓缓行驶并且可以穿越(至少现在可以)障碍物的赛车。障碍物看起来有些呆板。应该让他动起来,让障碍物在赛车经过的时候旋转起来。首先,需要添加一些新的成员变量让obstacle类能控制旋转:
private float rotation = 0;
private float rotationspeed = 0.0f;
private Vector3 rotationVector;
障碍物的旋转速度和转轴都应该是随机的,这样他们才会看起来与众不同。在obstacle类构造函数的最后添加以下两行代码就可以实现这个功能:
rotationspeed = (float)Utility.Rnd.NextDouble() * (float)Math.PI;
rotationVector = new Vector3((float)Utility.Rnd.NextDouble(),(float)Utility.Rnd.NextDouble(),(float)Utility.Rnd.NextDouble());
为了使障碍物旋转起来,还有两个需要修改的地方。首先,需要把实现旋转的代码添加到Update函数中:
Rotation += (rotationspeed * elapsedTime);
这里没什么特别的,只是根据时间和随机的旋转速度来增加旋转角度而已。最后,真正改变通过world transform来实现旋转,这样,渲染出来的物体才是旋转的。更新以下代码:
if(isTeapot)
{
device.Transform.World = Matrix.RotationAxis(rotationVector,rotation)
*Matrix.Scaling(ObjectRadius,ObjectRadius,ObjectRadius)*Matrix.Translation(position);
}
else
{
device.Transform.World = Matrix.RotationAxis(rotationVector,rotation) * Matrix.Translation(position);
}
再次运行程序,可以看到当赛车经过的时候,障碍物已经在随机旋转了。下一步该干什么了呢?Well,让赛车真正的能和障碍物碰撞将会是很酷的,同时可以记录下总分来,看看你到底开了多远。当添加计分系统的同时,你也应该实时的记录游戏状态。在游戏引擎的DodgerGame类中,添加如下变量:
private bool isGameOver = true;
private int fameOverTick = 0;
private bool hasGameStarted = false;
private int score = 0;
所有关于游戏的信息都储存在这里。你可以知道游戏是否结束了,是否是第一次开始游戏,上一次和当前的游戏得分。这些都是不错的特性,不过怎么才能实现他呢?首先从计分系统开始吧,毕竟这才是玩家最关心的内容。玩家每经过了一个障碍物,就获得一定的分数。当然,你也希望游戏更具挑战性:加快赛道的速度,这样障碍物也会来到更快。另外,很重要的一点是:在LoadDefaultGameOptions方法中添加一行代码来重置总分,这样在新游戏开始的时候玩家才不会获得额外的分数。
sorce = 0;
接下来,在OnFrameUpdate方法中,在移动障碍物之前,添加如下代码:
Obstacles removeObstacles = new Obstacles();
foreach(Obstacle o in obstacles)
{
if(o.Depth > car.Diameter - (Car.Depth * 2))
{
removeObstacles.Add(o);
roadSpeed += RoadSpeedIncrement;
if(roadSpeed >= MaxRoadSpeed)
{
roadSpeed = MaxRoadSpeed;
}
car.IncrementSpeed();
score += (int)(roadSpeed * (roadSpeed / car.Speed));
}
}
foreach(Obstacle o in removeObstacles)
{
obstacles.Remove(o);
o.Dispose();
}
removeObstacles.Clear();
使用这段代码获得了玩家已经经过的障碍物的列表(这个“表”同一时间只应该包含一个元素)。列表中每次增加一个障碍物,就相应的增加总分,增加赛道的速度提升难度,增加赛车的速度(虽然赛车的速度没有赛道增加的快)。完成了这些操作之后,把障碍物从列表中移除。注意观察,我们还使用了一个公式来根据赛道的速度计算得分,因此,开的越远,得分就越多。同时你可能已经注意到我们使用了一个还没有实现的方法来增加赛车的速度。不用多说,添加代码:
public void IncrementSpeed()
{
carSpeed += SpeedIncrement;
}
现在,需要添加一个新的方法来判断赛车是否撞到了障碍物。在obstacle类中,添加如下代码:
public bool IsHittingCar(float carLocation,float carDiameter)
{
if(position.Z > (Car.Depth - (carDiameter / 2.0f)))
{
if((carLocation < 0) && (position.X < 0))
return true;
if((carLocation > 0) && (position.X > 0))
return true;
}
return false;
}
这里都是些很简单的东西;首先检查赛车和障碍物的深度(depth)是否相同,能否发生碰撞,如果相同,并且位于路的同一边,那么返回ture,否则赛车和障碍物不会碰撞,,返回false。有了这些代码,就可以在游戏引擎中实现碰撞检测了。更新OnFrameUpdate方法:
foreach(Obstacle o in obstacles)
{
o.Update(elapsedTime,roadSpeed);
if(o.IsHittingCar(car.Location,car.Diameter))
{
isGameOver = true;
gameOverTick = System.Environment.TickCount;
Utility.Timer(DirectXTimer.Stop);
}
}
每次更新了障碍物之后,检查是否发生了碰撞。如果发生了,那么游戏结束。设置游戏状态,并且停止计时器。
最后一步
至今为止,我们还没有使用过那些状态变量。你应该先完成游戏的逻辑设计。你将要求玩家通过按下任意键来启动游戏。游戏结束之后,将会有一瞬间停顿(大约一秒左右),接下来再次按下任意键就可以重新启动游戏。你首先要确定的是一旦游戏结束了,其它状态就不应该再更新了。因此,OnFrameUpdate方法的第一行应该是这样的:
if((isGameOver) || (!hasGameStarted))
return;
接下来解决通过按下任意键启动游戏,在OnKeyDown方法重载的最后一行,添加如下的逻辑部分:
if(isGameOver)
{
LoadDefaultGameOptions();
}
isGameOver = false;
haGameStarted = true;
好了,这就是我们所要求的行为。当游戏结束了,玩家按下任意键,一个恢复为默认设置的新游戏就开始了。如果你愿意,可以从InitializeGraphics方法中删除LoadDefaultGameOptions的调用了,因为在每次按下任意键启动游戏的时候就会调用它。但是,我们还没有添加碰撞后让画面短暂停留一瞬间的代码。同样也在OnKeyDown方法中来实现;而且因该在检查是否按下了ESC键之后的添加代码:
这将会在游戏结束后的一秒之内忽略所有击键(除了可以按下ESC退出游戏)。现在可以尝试着玩玩我们的游戏了!虽然它们并不完整。我们记录了得分,却并没有显式的把这个得分告诉玩家。现在来完善这一步吧。Direct3D名称空间下有一个称为Font的类可以用来绘制文本。注意,在System.Drawing名称空间里也有一个Font类,而且如果没有前缀修饰的情况下使用“Font”,那个这两个类会发生冲突。幸运的是,可以使用using语句来做如下申明:
Using Driect3D = Microsoft.DirectX.Direct3D;
你所创建的每个字可以是不同的颜色,但最好使用相同的大小以及字体。对这个游戏来说,你需要两种不同的文本类型,自然,也需要2种不同的字体。为DodgerGame类添加如下变量:
private Direct3D.Font scoreFont = null;
private Direct3D.Font gameFont = null;
你只有在创建了device之后才能初始化这些变量。在创建了device之后的地方添加代码。它们并不需要在OnDeviceReset事件中初始化,应为这些类会自动处理重置设备时的事件。在InitializeGraphics的最后一行添加如下代码:
scoreFont = new Microsoft.DirectX.Direct3D.Font(device, new System.Drawing.Font("Arial",12.0f,FontStyle.Bold));
gameFont = new Microsoft.DirectX.Direct3D.Font(device, new System.Drawing.Font("Arial",36.0f,FontStyle.Bold | FontStyle.Italic));
这里创建了两种不同大小的Arial字体。之后,更新进行渲染的方法来绘制字体。字体是最后才需要绘制的内容,因此在绘制赛车的代码之后添加如下代码:
if(hasGameStarted)
{
scoreFont.DrawText(null,string.Format("Current score:{0}",score),
new Rectangle(5,5,0,0), DrawTextFormat.NoClip,Color.Yellow);
}
if(isGameOver)
{
if(hasGameStarted)
{
gameFont.DrawText(null,"You crashed. The game is over.",
new Rectangle(25,45,0,0),DrawTextFormat.NoClip,Color.Pink);
}
if((System.Environment.TickCount - gameOverTick) >= 1000)
{
gameFont.DrawText(null,"Press any key to begin.", new Rectangle(25,100,0,0),
DrawTextFormat.NoClip, Color.WhiteSmoke);
}
}
我们将在后面的章节里详细讨论DrawText方法。现在只需要知道它能完成他的名字所表示的功能。好了,现在可以看到当游戏已开始,就可以看到当前的分数了。除此而外,当游戏结束之后,你告诉玩家他撞车了。最后,游戏结束了一秒钟之后,提醒玩家按任意键可以可以重新开始游戏。
Wow,至今为止,你已经完成了一个完整的游戏了。试玩一下吧。还有什么遗漏的吗?把最高分保存起来将是不错的尝试^_^。
(注:以下部分作者演示了如何把最高分和玩家的姓名作为一个结构保存到注册表中,由于大部分是代码,且这部分内容基本与图形无关,就不翻译了)
First off, we will need a place to store the information for the high scores. We will only really care about the name of the player as well as the score they achieved, so we can create a simple structure for this. Add this into your main games namespace:
public struct HighScore
{
private int realScore;
private string playerName;
public int Score { get { return realScore; } set { realScore = value; } }
public string Name { get { return playerName; } set {
playerName = value; } }
}
Now we will also need to maintain the list of high scores in our game engine. We will only maintain the top three scores, so we can use an array to store these. Add the following declarations to our game engine:
Private HighScore[] highScores = new HighScore[3];
private string defaultHighScoreName = string.Empty;
All we need now is three separate functions. The first will check the current score to see if it qualifies for inclusion into the high score list. The next one will save the high score information into the registry, while the last one will load it back from the registry. Add these methods to the game engine:
private void CheckHighScore()
{
int index = -1;
for (int i = highScores.Length - 1; i >= 0; i--)
{
if (score >= highScores[i].Score) // We beat this score
{
index = i;
}
}
// We beat the score if index is greater than 0
if (index >= 0)
{
for (int i = highScores.Length - 1; i > index ; i--)
{
// Move each existing score down one
highScores[i] = highScores[i-1];
}
highScores[index].Score = score;
highScores[index].Name = Input.InputBox("You got a highscore!!","Please enter your name.", defaultHighScoreName);
}
}
private void LoadHighScores()
{
Microsoft.Win32.RegistryKey key =Microsoft.Win32.Registry.LocalMachine.CreateSubKey("Software\\MDXBoox\\Dodger");
try
{
for(int i = 0; i < highScores.Length; i++)
{
highScores[i].Name = (string)key.GetValue(string.Format("Player{0}", i), string.Empty);
highScores[i].Score = (int)key.GetValue(string.Format("Score{0}", i), 0);
}
defaultHighScoreName = (string)key.GetValue("PlayerName", System.Environment.UserName);
}
finally
{
if (key != null)
{
key.Close(); // Make sure to close the key
}
}
/// <summary>
/// Save all the high score information to the registry
/// </summary>
public void SaveHighScores()
{
Microsoft.Win32.RegistryKey key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey("Software\\MDXBoox\\Dodger");
try
{
for(int i = 0; i < highScores.Length; i++)
{
key.SetValue(string.Format("Player{0}", i),highScores[i].Name);
key.SetValue(string.Format("Score{0}", i),highScores[i].Score);
}
key.SetValue("PlayerName", defaultHighScoreName);
}
finally
{
if (key != null)
key.Close(); // Make sure to close the key
}
}
I won't delve too much into these functions since they deal mainly with built-in .NET classes and have really nothing to do with the Managed DirectX code. However, it is important to show where these methods get called from our game engine.
The check for the high scores should happen as soon as the game is over. Replace the code in OnFrameUpdate that checks if the car hits an obstacle with the following:
if (o.IsHittingCar(car.Location, car.Diameter))
{
isGameOver = true;
gameOverTick = System.Environment.TickCount;
Utility.Timer(DirectXTimer.Stop);
CheckHighScore();
}
You can load the high scores at the end of the constructor for the main game engine. You might notice that the save method is public (while the others were private). This is because we will call this method in our main method. Replace the main method with the following code:
using (DodgerGame frm = new DodgerGame())
{
frm.Show();
frm.InitializeGraphics();
Application.Run(frm);
frm.SaveHighScores();
}
The last thing we need to do is actually show the player the list of high scores. We will add this into our rendering method. Right before we call the end method on our game font, add this section of code to render our high scores:
gameFont.DrawText(null, "High Scores: ", new Rectangle(25,155,0,0),
DrawTextFormat.NoClip, Color.CornflowerBlue);
for (int i = 0; i < highScores.Length; i++)
{
gameFont.DrawText(null, string.Format("Player: {0} : {1}",highScores[i].Name, highScores[i].Score), new Rectangle(25,210 + (i * 55),0,0), DrawTextFormat.NoClip,
Color.CornflowerBlue);
}
再花一点时间来复习一下所完成的工作吧,这可是你的第一个游戏
可以到这个地方去下载源码http://bbs.gameres.com/showthread.asp?threadid=32844