65.9K
CodeProject 正在变化。 阅读更多。
Home

BounceBall - XNA Farseer Magic

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (72投票s)

2011年3月27日

CPOL

62分钟阅读

viewsIcon

210298

downloadIcon

19085

在本文中,我们将使用Farseer物理引擎和XNA为Windows Phone 7开发一款游戏。本文为您的游戏提供基础,使游戏开发变得轻松快捷。

从小我就想制作游戏,如今技术变得如此易于使用,我们可以非常轻松地创建简单的游戏。本文是尝试构建一个基于可重用组件的游戏框架,以减少基于物理和XNA的游戏所需的时间。

Figure1.png
下载源代码

目录

谁、什么、哪里以及为什么?

如果你非常渴望看到实际操作,可以跳过本节。

本文面向谁? 本文旨在帮助新开发移动游戏的开发者,以及那些使用XNA构建过移动应用的开发者。为了充分利用本文,建议您至少对C#和.NET框架有很好的理解,并对Microsoft XNA Game Studio有所了解。如果您是Farseer物理引擎的新手,请不要害怕,我是和这个游戏一起学习Farseer的。

我到底在读什么? Windows Phone游戏有一套独特的、需要解决的要求和挑战。通过本文的学习,您将了解如何构建您的应用程序,以提供一致可靠的用户体验。对于开发者而言,本文提供了许多可重用但非常必要的游戏组件。

为什么写这篇文章? 我对Windows Phone 7有很多游戏创意,并希望实现它们,但当我开始使用XNA和Farseer物理引擎时,我无法找到一种最佳且高效的方式来快速轻松地构建游戏。例如,没有清晰的框架或可重用代码可供一站式使用。因此,我开始收集/构建任何游戏的基本构建块。将所有东西组装到一个项目中后,使用XNA和Farseer物理引擎变得非常容易。所以本文将使您能够在几天甚至几小时内构建游戏。

你需要什么?(先决条件) 我们使用的所有工具都是免费的。
适用于Windows Phone的Microsoft Visual Studio 2010 Express
GIMP,GNU图像处理程序
Farseer物理引擎

这意味着什么?(约定)
//记住// 不要忘记这一点。
//注意// 文章之外的一般信息。

源代码在哪里? 以下是下载链接,包括主游戏源代码、3个演示、FarseerPhysicsXNA dll和代码片段。

游戏概述

我认为我们应该清楚要构建什么,然后我们将构建实际的代码库并讨论选择一个选项而不是另一个选项的问题。因此,我们将构建一个非常简单但相当令人上瘾的游戏,其中玩家只需控制主体在每次触地时跳跃时的左右移动。对于向前移动,我们将使用恒定滚动方法,玩家无法控制游戏的移动或速度。这种移动方式增加了游戏玩法的额外难度,毕竟游戏应该吸引各个级别的玩家(新手、普通和经验丰富),因此我们将构建具有不同难度设置的不同级别。关于游戏的目标,它是生存,不仅是生存,玩家需要通过冒收集奖励点(我们需要考虑如何实现它)的风险来获得更高的分数。所以就是这样,这就是我们的想法。从上面的想法中,你可以想到需要存在的基本要素,比如一些级别选择和游戏区域(因此是屏幕系统),物理(Farseer物理对象),用于获取玩家左右移动输入的按钮,计分系统(毕竟它是一个游戏,它们需要一些目标,而我们游戏的目标是玩家尽可能多地得分)。

Farseer物理引擎 3.2 和 XNA 4

什么是物理引擎以及为什么我们需要它?

物理引擎使物体以可信的方式运动,并使它们遵守现实世界般的物理规则。从游戏的角度来看,物理引擎只是一个程序动画系统。

我们可以自己构建一个,毕竟在我们的游戏中,我们不需要对对象的物理属性进行太多控制,但我们旨在快速可靠地构建游戏,因此我们将使用现有的、可用的和经过测试的代码作为物理引擎,而不是自己构建。因此,我们选择Farseer物理引擎(v3.2)。

Farseer物理引擎的背景

Farseer物理引擎是Box2d的C#移植。Box2D是一个用于游戏的2D刚体模拟库。Farseer物理引擎是一个具有真实物理响应的碰撞检测系统。让我们先了解它的基础知识。
要使用Farseer物理引擎,您需要了解以下4件事:

1. 世界(World): 这是一个包含一切的对象;你可以把它比作真实世界。它有重力、刚体、接触、夹具等。它的Step方法移动对象并确保一切都一致和稳定。

//Creates a new World with Gravity of 10 in downward direction.
World MyWorld = new World(-Vector2.UnitY * 10);

2. 刚体(Body): 就像真实世界一样,力作用于刚体。但与真实世界不同的是,它们默认没有结构或形状。刚体跟踪在世界中的位置,并受到碰撞或重力产生的冲量的影响。以下是刚体类型的详细信息。
静态刚体(StaticBody): 这种刚体在模拟中不会移动,它们不会与其他静态或运动刚体碰撞。如果用户(开发者)想要移动它们,那是可能的。
运动刚体(KinematicBody): 它在模拟时会根据施加给它的速度移动。运动刚体不对力做出响应。用户可以手动移动它们,但通常通过设置其速度来移动。它们不会与其他静态或运动刚体碰撞。
动态刚体(DynamicBody): 动态刚体是完全模拟的刚体。它始终具有非零的有限质量。它可以与任何类型的刚体碰撞。

/* Creates a new Static Body and add to Current World object so that it can be updated by engine. We will see a better and easy approach of doing the same using Facotries. */
Body MyBody = new Body(World);
MyBody.IsStatic = true;

//REMEMBER//
If we pass world as parameter to body creation then body will be added to world automatically and we no need to add it manually.

3. 形状(Shape): 它是刚体的2D结构,例如对象在世界中占据多少空间,例如:多边形、正方形、圆形等。它需要计算惯性、质量、质心及相关内容。形状可以独立使用而无需刚体。简而言之,刚体具有位置和速度,并且可以承受力、冲量和扭矩。

/* Creates a new Circle Shape with Radius 10 and Density 25. We will see a better and easy approach of doing the same using Facotries. */
CircleShape MyCircle = new CircleShape(10, 25);

4. 夹具(Fixture): 它是刚体和形状之间的连接。夹具将形状绑定到刚体,还提供摩擦、恢复系数(反弹能力,我猜)、密度等属性。这种绑定方式使得形状的质心成为刚体的位置。正是夹具提供了与碰撞相关的功能,例如当形状碰撞时,它将力传递给刚体,从而使形状一起移动。

/* Creates a new Fixture which binds provided Body and Shape. We will see a better and easy approach of doing the same using Facotries. */
Fixture MyFixture = new Fixture(MyBody, MyShape);

除了Farseer物理引擎的上述4个主要部分之外,我们还有关节、工厂和多边形工具来帮助我们轻松完成任务,如下所述。

关节(Joints): 这是一种将两个或更多刚体连接在一起的约束。游戏中的典型示例包括布娃娃、跷跷板和滑轮。关节可以通过多种不同方式组合以产生有趣的运动。关节有多种类型,如角度关节、距离关节、摩擦关节、旋转关节、齿轮关节、线形关节、棱柱关节、滑轮关节、旋转关节、滑块关节和焊接关节。

/* Creates a new Fixture which binds provided Body and Shape. We will see a better and easy approach of doing the same using Facotries. */
AngleJoint MyJoint = new AngleJoint(BodyA, BodyB);

工厂(Factories): 工厂通过提供创建常见类型对象的方式使我们的生活变得轻松。有针对刚体、形状、夹具、关节和路径的工厂。每种对象类型都有自己的静态工厂,具有许多重载,可在创建它们时提供完全的灵活性。我们在游戏中主要使用了BodyFactory和FixtureFactory。以下是工厂的一些示例。

//Create Body which gets places at Origin.
Body MyBody = BodyFactory.CreateBody(MyWorld, Vector2.Zero);

//Create Circle Fixture with 10 Radius and 25 Density.
FixtureFactory.CreateCircle(MyWorld, 10, 25);

多边形工具(PolygonTools): 此类帮助我们创建各种多边形。使用此类我们可以创建矩形、圆角矩形、边缘、圆形、椭圆形、胶囊和齿轮。用法示例如下

//Generate Vertices for Rectangle of 10x5 dimensions. We can use the same approach for more complex objects.
Vertices vert = PolygonTools.CreateRectangle(10, 5);

//Create Fixture for Vertices collected in last step and binds the shape generated from Vertices to some Body.
FixtureFactory.CreatePolygon(vert, 10, MyBody);

到目前为止,我们已经创建了世界,向其中添加了刚体,通过不同方式创建了形状,通过关节连接了刚体,并向刚体添加了夹具。这都是Farseer物理引擎的核心;毫无疑问,我们需要比这更多的理解来构建复杂的对象和大型游戏,但这足以开始玩转它。

在XNA中渲染Farseer物理对象(RenderXNA)

在本节中,我们将实际看到一些工作代码以及对象在世界中定位的基本原理。首先,我们需要了解Farseer物理引擎中对象测量是如何考虑的。

理解Farseer物理引擎的测量单位
Farseer是Box2D引擎的移植,在Box2D中,如果我们想创建一个矩形,我们会告诉系统形状的半宽、半高和中心点。相同的测量系统也移植到了Farseer物理引擎。在Silverlight或其他.net应用程序中,我们通过指定其左上角位置,然后提供高度和宽度信息来创建矩形。但Farseer物理引擎并非如此,因此请务必记住,物理对象的位置始终表示中心点而不是左上角,但高度和宽度应完整提供,而不是像Box2D那样提供一半,这是一个例外。
现在,如果你想创建一个10x10单位的矩形并在Farseer物理对象中指定相同的值,那么你最终会得到一个非常大的对象,因为Farseer物理使用米作为其单位。所以我们总是需要使用非常小的尺寸,或者需要一个转换器,将我们的像素值转换为米。我喜欢用转换器,因为它允许我在整个应用程序中只考虑像素,从而消除了记住哪些是像素,哪些需要是米的麻烦。所以我们需要选择一个转换比例,比如1米=15像素或类似的东西。这个比例将在整个应用程序中用于将显示单位(像素)转换为模拟单位(米)。
最后,Farseer物理引擎中的旋转采用弧度单位,因此在将旋转量(度)分配给物理对象之前,请务必将其转换为弧度。

Farseer物理引擎使用示例
到目前为止,我们有了物理对象,我们知道需要遵循哪些测量标准,但没有UI相关的东西,因为Farseer是物理引擎而不是UI生成器。因此,如果我们继续在世界中创建一个刚体,即使它在那里,我们也无法在屏幕上看到任何东西。为了使Farseer物理对象显示在屏幕上,我们需要使用XNA绘制它们。我们可以使用SpriteBatch类的Draw和DrawText方法在屏幕上渲染纹理和文本。下面是一个示例,用于绘制一个10x10大小的矩形(正方形)并用MyTexture纹理填充它。

SpriteBatch.Draw(MyTexture, new Rectangle(0, 0, 10, 10), Color.White);

那么,让我们构建一个示例,让一个矩形因为重力而落到地面上。
启动Visual Studio 2010 Express for Windows Phone,创建新项目,从模板中选择XNA Game Studio 4.0,从项目模板中选择Windows Phone Game (4.0) 并命名为FarseerXNADemo1。它将为您提供2个项目,第一个是XNA项目,另一个是用于管理资源(如图像、声音、字体等)的内容项目。
- 首先我们需要引用Windows Phone 7的Farseer物理引擎dll(可在http://farseerphysics.codeplex.com的下载标签页找到)。现在我们需要获取一些纹理(这里纹理指的是图像),这些纹理将被渲染,但我们也接受简单的颜色,所以我将一个空白的png图像(blank.png)添加到内容项目。完成以上步骤后,我们的解决方案资源管理器将如下所示。
Figure2.png
- 现在转到Game1.cs文件并使用以下代码设置屏幕的高度和宽度,

public Game1()
{
	Window.Title = "DEMO 1";

	_graphics = new GraphicsDeviceManager(this);
	_graphics.PreferredBackBufferWidth = 480;
	_graphics.PreferredBackBufferHeight = 800;
	_graphics.IsFullScreen = true;

	Content.RootDirectory = "Content";
}
- 我们首先设置窗口标题,然后使用当前Game类的实例创建一个新的GraphicsDeviceManager对象。现在我们将宽度设置为480,高度设置为800,因为我们希望手机处于纵向模式。此外,我们正在开发游戏,因此我们不想在手机上半部分显示电池或连接相关内容,因此全屏标志已打开。(请注意,有时显示电池详细信息是很好的做法,否则玩家可能会在玩游戏时耗尽全部电池,手机突然关机,这可能会很烦人)。
- 在同一文件中,声明一个名为MyTexture的Texture2D对象,一个名为MyWorld的World对象,以及两个名为BoxBody和FloorBody的Body对象。
- 在Initialize方法中,通过为之前声明的所有4个对象分配物理相关属性来初始化它们。例如:
Texture2D MyTexture;
World MyWorld;
Body BoxBody, FloorBody;

protected override void LoadContent()
{
	// Create a new SpriteBatch, which can be used to draw textures.
	spriteBatch = new SpriteBatch(GraphicsDevice);
	
	base.LoadContent();
	
	//Load Blank Texture so that we can render colors over it.
	MyTexture = Content.Load<Texture2D>("blank");
	
	//Create New World with gravity of 10 units, downward.
	MyWorld = new World(Vector2.UnitY * 10);
	
	//Create Floor
	Fixture floorFixture = FixtureFactory.CreateRectangle(MyWorld, ConvertUnits.ToSimUnits(480), ConvertUnits.ToSimUnits(10), 10);
	floorFixture.Restitution = 0.5f;        //Bounceability
	floorFixture.Friction = 0.5f;           //Friction
	FloorBody = floorFixture.Body;          //Get Body from Fixture
	FloorBody.IsStatic = true;              //Floor must be stationary object
	
	//Create Box, (Note:Different way from above code, just to show it otherwise there is no difference)
	BoxBody = BodyFactory.CreateBody(MyWorld);
	FixtureFactory.CreateRectangle(ConvertUnits.ToSimUnits(50), ConvertUnits.ToSimUnits(50), 10, Vector2.Zero, BoxBody);
	foreach (Fixture fixture in BoxBody.FixtureList)
	{
	    fixture.Restitution = 0.5f;
	    fixture.Friction = 0.5f;
	}
	BoxBody.BodyType = BodyType.Dynamic;
	
	//Place floor object to bottom of the screen.
	FloorBody.Position = ConvertUnits.ToSimUnits(new Vector2(240, 700));
	
	//Place Box on screen, somewhere
	BoxBody.Position = ConvertUnits.ToSimUnits(new Vector2(240, 25));
}
- 让我们一行一行地解读(有关ConvertUnits类的更多信息,请参阅“ConvertUnits”部分)。Initialize方法的第一行加载纹理并将其保存在对象中以备后用。然后我们创建一个新的World,其中重力向下为10个单位。之后,我们通过使用FixtureFactory.CreateRectangle方法创建其形状和夹具来创建地板Body。它创建了一个视口宽度和20个单位高度的矩形。它还为其创建了正确的形状和Body。然后我们设置地板的弹性和摩擦属性,最后保存Body对象以备后用。
- 我们可以使用与创建地板相同的方法来创建物理对象,但我想向您展示其他方式,所以我们将使用稍微不同的代码创建盒子。我们首先创建一个刚体,如果我们在构造函数中传入world对象,那么我们同时将该刚体添加到world中。然后我们创建夹具,这样它将创建形状并将其绑定到刚体(请注意,这次我们在CreateRectangle方法中将BoxBody作为参数传递)。然后我们为刚体的夹具提供弹性系数和摩擦值。盒子创建的最后一行是设置正确的刚体类型。我们希望盒子在重力作用下移动,所以我们将此对象设置为动态类型。
- 到目前为止,我们已经创建了地板和盒子,现在我们需要将它们定位在屏幕上,地板需要放在屏幕底部,而盒子可以放在顶部某个位置。
- ConvertUnits: 此类用于将单位(测量值)从Farseer物理单位(米)转换为显示特定单位(像素)。我们使用了16个单位的比例,因此对我们来说1米=16像素。对于800x480像素的屏幕来说,这个比例足够好。该类中有3个静态方法和许多重载,方法如下所述:
ToSimUnits – 此方法将提供的值转换为模拟单位,即显示单位(像素)转换为模拟单位(米)。
ToDisplayUnits – 此方法将提供的值转换为显示单位,即模拟单位(米)转换为显示单位(像素)。
SetDisplayUnitToSimUnitRatio – 此方法允许应用程序设置显示单位到模拟单位的任何转换比例。请明智地使用它,因为设置比例可能会导致物理对象的意外行为,例如质量非常大的对象尺寸相对较小,或类似的不规则性。
//REMEMBER//
In Farseer Physics measurements are in meters not in pixels. And position signifies centre point of object not the top-left corner.
- 完成物理相关工作后,我们需要使用XNA绘制这些对象。为此,我们在Draw方法中添加以下代码。
protected override void Draw(GameTime gameTime)
{            
	GraphicsDevice.Clear(Color.CornflowerBlue);
	
	spriteBatch.Begin();
	
	//Draw Box, its Height is 50 and Width is 50.
	spriteBatch.Draw(MyTexture, new Rectangle((int)(ConvertUnits.ToDisplayUnits(BoxBody.Position.X) - 25), (int)(ConvertUnits.ToDisplayUnits(BoxBody.Position.Y) - 25), 50, 50), Color.Green);
	
	//Draw Floor, its Height is 10 and Width is 480.
	spriteBatch.Draw(MyTexture, new Rectangle((int)ConvertUnits.ToDisplayUnits(FloorBody.Position).X - 240, (int)ConvertUnits.ToDisplayUnits(FloorBody.Position).Y - 5, 480, 10), null, Color.Gray, FloorBody.Rotation, new Vector2(0, 0), SpriteEffects.None, 0);
	
	spriteBatch.End();
	
	base.Draw(gameTime);
}
- 我们使用SpriteBatch类的Draw方法。绘制盒子时,我们提供纹理和我们想要渲染盒子的目标矩形(简而言之就是盒子的尺寸)。矩形的前两个参数是左上角坐标(请记住Farseer将中心点存储为位置,因此我们需要从位置中减去一半宽度和一半高度才能获得左上角)。矩形的后两个参数分别是宽度和高度。地板对象也使用相同的方法。
- 如果你现在运行项目,你会看到盒子和地板,但是盒子并没有在重力作用下下落。这是因为我们没有更新World,这意味着我们没有将World移动到下一步。为此,请在Update方法中添加代码,它将如下所示:
protected override void Update(GameTime gameTime)
{
	// Allows the game to exit
	if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
	this.Exit();
	
	// variable time step but never less then 30 Hz
	MyWorld.Step(Math.Min((float)gameTime.ElapsedGameTime.TotalMilliseconds * 0.001f, (1f / 30f)));
	
	base.Update(gameTime);
}
- 在上面的代码中,请注意Step方法,它将以每秒30次或更多的更新速度向前移动世界。现在,如果您运行项目,您将看到盒子在重力作用下坠落。
Figure3.png
- 现在你可能会想,将物理对象与XNA集成起来是如此容易,我当时也这么想,但如果你尝试旋转、变换并将更多形状组合到一个刚体中,你就必须弄清楚那个刚体应该如何在屏幕上精确地绘制出来,而且相信我,这并不像看起来那么容易,而且需要花费大量时间才能准确地找出如何以通用方式绘制对象。那么我们该去哪里呢?答案是DebugDraw。

DebugDraw (RenderXNA)

- DebugDraw是Farseer物理引擎提供的功能,用于在开发阶段在屏幕上绘制对象数据,这将帮助您可视化刚体、关节、夹具等。我想为什么我们不能在实际应用程序中使用它呢?并且找不到任何足够好的理由阻止我这样做。因此我们有了一个名为“RenderXNA”的项目。
- 这个项目包含三个类:RenderMaterial、Materials和RenderXNAHelper。RenderMaterial类是我们渲染对象时需要的数据结构。Materials类是一个帮助类,用于管理已加载的纹理,以便不再重复加载纹理。最后是RenderXNAHelper类,这是从FarseerPhysics.DebugView类和IDisposable接口派生的主类。我们使用这个类来渲染对象,而不是编写自己的代码来绘制对象。如果你查看代码,你会发现许多方法,如DrawJoint、DrawShape、DrawPolygon、DrawCircle、DrawTexturedLine等,这些方法实际上绘制了我们的对象。RenderXNAHelper使用World.BodyList获取所有可用于渲染的Body。
- RenderXNAHelper源自Farseer物理引擎DebugView源代码中的DebugViewXNA.cs文件。我修改了它,使其在项目外部使用材质。足够简单,可以在比构建我们自己的代码更少的时间内使其工作。
- 好的,现在是RenderXNA项目的使用示例时间。您可以在提供的源代码中找到RenderXNA.dll;将其引用添加到我们的第一个演示项目FarseerXNADemo1中。
- 我们还需要Camera2D类,我是在一个示例中找到的。它的工作方式就像真实的相机一样,我们不需要查看代码就可以使用它(即使我对此知之甚少)。Camera2D类中有用的方法是ConvertScreenToWorld和ConvertWorldToScreen方法,这些方法分别将屏幕点(实际点)转换为世界点(系统点),反之亦然。我们还有Projection和View作为Matrix类型的属性;它们分别用于表示当前相机投影和相机的当前视锥体。这个类还包含Update方法,需要在XNA Game类的Update方法中调用。这就是我们需要记住的关于Camera2D类的所有内容。
- 在有了Camera2D类之后,我们可以进一步进行示例实现。继续添加一个名为Game2的新类,并使其派生自Microsoft.Xna.Framework.Game类。它的构造函数将与我们项目中已有的Game1类相同。同时,在Game2类中声明与Game1中相同的类级别变量。除此之外,我们还需要Camera2D类和RenderXNAHelper类的2个额外变量。我们需要LoadContent、Update和Draw这三个方法,所以先重写它们。在LoadContent方法中初始化Camera2D和RenderXNAHelper对象,如下所示。首先从Game1类复制整个LoadContent方法,并在创建World对象之后,在LoadContent方法中添加以下代码。
//Create DebugView and switch on the Flags to render shapes.
RenderHelper = new RenderXNAHelper(MyWorld);
RenderHelper.AppendFlags(DebugViewFlags.TexturedShape);
RenderHelper.RemoveFlags(DebugViewFlags.Shape);

RenderHelper.DefaultShapeColor = Color.White;
RenderHelper.SleepingShapeColor = Color.LightGray;
RenderHelper.LoadContent(GraphicsDevice, Content);

Camera = new Camera2D(GraphicsDevice);
- 我们还需要将纹理细节添加到物理对象本身,因为现在它们将由RenderXNAHelper渲染。幸运的是,FixtureFactory的CreateRectangle方法(几乎所有工厂方法都有此规定,参数名为UserData)有一个对象类型的参数,可以像我们在大多数.net控件中拥有的TAG属性一样使用。因此,我们可以在其中分配纹理、颜色等详细信息,为此我们使用RenderMaterial的对象。其示例如下:
new RenderMaterial(MyTexture, "Blank") { Color = Color.White }
- 上述代码将以白色渲染MyTexture的图像(白色表示纹理以其原始图像颜色渲染,非白色表示图像将覆盖相同颜色的蒙版)。
- 因此,将新的RenderMaterial对象作为最后一个参数添加到Floor和Box Fixtures的FixtureFactory.CreateRectangle方法中。
- 添加上述代码后,我们需要向Update和Draw方法中添加代码,它们将如下所示:
protected override void Update(GameTime gameTime)
{
    // Allows the game to exit
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
        this.Exit();

    if (MyWorld != null)
    {
        // Update the camera
        Camera.Update();

        // variable time step but never less then 30 Hz
        MyWorld.Step(Math.Min((float)gameTime.ElapsedGameTime.TotalMilliseconds * 0.001f, (1f / 30f)));
        RenderHelper.Update(gameTime);
    }

    base.Update(gameTime);
}

protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.CornflowerBlue);

    if (MyWorld != null)
    {
    //Render the Data
        RenderHelper.RenderDebugData(ref Camera2D.Projection, ref Camera2D.View);
    }

    base.Draw(gameTime);
}
- 要运行这个新的游戏类,我们需要在项目属性中将其设置为启动类型。
Figure4.png
- 此外,我们还需要为默认渲染添加一种字体和线条材质。所以现在我们的内容项目将如下所示:
Figure5.png
- 现在运行项目,您将看到与Game1相同的屏幕。这就是我们获得与手动绘制对象相同体验所需的一切。请注意,我们没有在Draw方法中渲染任何对象,而是将该责任转移给了RenderXNAHelper类。这将消除我们在屏幕上渲染对象时遇到的绝大部分问题。

本节的结束语是,我们有了物理对象;我们可以绘制它们并提供纹理、颜色和其他细节。FarseerXNADemo1的完整示例代码。

让我们挖掘并奠定基础(Windows Phone 7 XNA游戏基础)

到目前为止,我们只关注了物理,但游戏需要的不仅仅是物理引擎,它需要面板(普通和可滚动)、按钮、标签和图像控件,这些控件可以放置在面板内以实现滚动,背景控制(如视差背景),边框控制,输入处理程序,字体和纹理管理器,帧率计数器,以及最重要的一个基础屏幕模型,我们可以在其上以最少的代码尽可能轻松地创建新的游戏屏幕。我们将从帧率计数器开始逐一创建这些东西。(请注意,我采用了不同的方式来创建和使用这些可重用组件,只是为了向您展示我们在创建可重用控件时可以采用哪些方式。)
对于本节,我们参考FarseerXNABase项目。

屏幕系统

屏幕系统,你可能会在脑海中产生一个问题:为什么是屏幕系统?我们有类,每个类都会绘制或执行其相关工作,例如说明类会在显示器上绘制说明。这很好,但使用屏幕系统我们可以更好地控制何时、如何以及哪个屏幕(类)获得显示器的控制权。所以我们的第一个任务是有一种添加、移除屏幕的方法,但在那之前我们需要有一个屏幕的类。

游戏屏幕 (GameScreen)
屏幕是一个具有更新和绘制逻辑的单层,可以与其他层结合以构建复杂的菜单系统。例如,主菜单、选项菜单、“你确定要退出吗”消息框以及主游戏本身,都作为屏幕实现。我们在FarseerXNABase/Components/ScreenSystem文件夹中有一个名为GameScreen的基类。它是一个抽象类,将作为每个屏幕的基类使用。让我们来探讨它的属性和方法。
Figure6.png
ControllingPlayer:获取当前控制此屏幕的玩家索引,如果它接受来自任何玩家的输入,则为null。这用于将游戏锁定到特定的玩家资料。主菜单响应来自任何连接的游戏手柄的输入,但无论哪个玩家从此菜单中进行选择,都将获得对所有后续屏幕的控制权,因此其他游戏手柄在控制玩家返回主菜单之前处于非活动状态。
EnabledGestures:获取屏幕感兴趣的手势。屏幕应对手势尽可能具体,以提高手势引擎的准确性。例如,大多数菜单只需要Tap或VerticalDrag即可操作。这些手势由ScreenManager在屏幕更改时处理,并且所有手势都放置在传递给HandleInput方法的InputState中。
IsActive:此属性显示当前屏幕是否是顶部屏幕。如果当前屏幕位于顶部,则为true,并且所有输入仅由该屏幕处理。
IsExiting:指示当前屏幕是否正在退出。屏幕可能正在过渡离开的原因有两种:它可能是暂时离开为另一个在其顶部的屏幕腾出空间,或者它可能是永久离开:此属性指示屏幕是否真的在退出:如果设置,屏幕将在过渡完成时自动删除自己。
IsPopup:通常,当一个屏幕覆盖在另一个屏幕之上时,第一个屏幕会过渡离开为新屏幕腾出空间。此属性指示屏幕是否只是一个小弹出窗口,在这种情况下,它下面的屏幕无需费心过渡离开。
ScreenManager:稍后将在其自己的部分中讨论。它将返回附加到当前屏幕的ScreenManager。
ScreenState:屏幕可以处于四种状态之一:TransitionOn、Active、TransitionOff和Hidden。此属性根据屏幕的当前条件返回其中之一。
TransitionAlpha:它存储屏幕的不透明度值。范围从零到一。
TransitionOffTime:当前屏幕完全过渡关闭所需的时间跨度。
TransitionOnTime:当前屏幕完全过渡打开所需的时间跨度。
TransitionPosition:指示当前屏幕的位置,范围从零(完全激活,无过渡)到一(完全过渡关闭,消失)。

Draw:当屏幕需要绘制自身时调用。
ExitScreen:使屏幕退出并开始过渡。这不会立即杀死屏幕,而是会等待过渡关闭时间段完成。
Update:允许屏幕运行逻辑,例如更新过渡位置。无论屏幕处于活动状态、隐藏状态还是正在过渡中,此方法都会被调用。
HandleGamePadInput、HandleInput和HandleKeyboardInput:允许屏幕处理用户输入。与Update不同,此方法仅在屏幕处于活动状态时调用,而不是在其他屏幕获得焦点时调用。
LoadContent、UnloadContent:加载和卸载屏幕的图形内容。
UpdateTransition:这是一个帮助方法,用于更新屏幕过渡位置。

屏幕管理器 (ScreenManager)
此类别管理添加、移除和所有屏幕相关任务,例如渲染屏幕、更新屏幕等。简而言之,它管理一个或多个GameScreen类的实例,在适当的时间调用update、draw方法,并将输入路由到最顶层的屏幕。让我们看看它的属性和方法。
Figure7.png
SpriteBatch:所有屏幕共享的默认精灵批处理。这节省了每个屏幕创建自己的本地实例的麻烦。

AddScreen:它将新屏幕添加到屏幕管理器实例。
Draw:通知每个屏幕绘制自身。
Update:允许每个屏幕运行逻辑。
FadeBackBufferToBlack:帮助绘制半透明的黑色全屏精灵,用于屏幕的淡入淡出,以及使弹出窗口背景变暗。
GetScreens:公开一个包含所有屏幕的数组。我们返回一个副本而不是真实的 master list,因为屏幕只能使用AddScreen或RemoveScreen方法添加或删除。
LoadContent、UnloadContent:加载和卸载图形内容。
RemoveScreen:从屏幕管理器中移除屏幕。您应该使用GameScreen.ExitScreen而不是直接调用此方法,以便屏幕可以逐渐过渡离开而不是立即移除。
TraceScreen:用于调试,它打印所有屏幕的列表。

Camera2D
我们已经在前面的部分讨论了Camera2D类的用法。现在让我们更详细地探讨它的属性和方法。
Figure8.png
CurrentSize:表示摄像机视图当前大小的矢量。表示为 Size * (1 / Zoom)。
MaxPosition 和 MinPosition:摄像机能到达的最远左边和右边。如果这两个值相同,则不会应用裁剪,除非您已覆盖该方法。
MaxRotation 和 MinRotation:它表示摄像机可以旋转的最大和最小角度。
MaxZoom 和 MinZoom:属性表示摄像机缩放的限制。
MoveRate:表示跟踪摄像机移动的速度。
Position:当前摄像机位置。
Rotation:摄像机的当前旋转。
RotationRate:表示摄像机在一次更新中可以旋转多少。
ScreenCenter:表示屏幕中心。
ScreenHeight 和 ScreenWidth:表示当前屏幕的高度和宽度。
TargetPosition、TargetRotation 和 TargetZoom:表示摄像机要达到的目标值。这些值会与当前值进行检查,如果需要,每次前进都会进行必要的更新。
TrackingBody:表示需要跟踪的FarseerPhysics刚体。此刚体将始终位于屏幕中央。
Zoom 和 ZoomRate:Zoom表示当前缩放级别,ZoomRate表示在向前一步中可以实现的缩放量。

ConvertScreenToWorld:它将屏幕位置转换为摄像机(世界)位置。
ConvertWorldToScreen:它将世界位置转换为屏幕位置。
CreateProjection:它将为摄像机创建投影矩阵;每当屏幕分辨率或更具体地说长宽比发生变化时,我们都需要调用此方法。
MoveCamera:按指定量移动摄像机。
ResetCamera:将摄像机重置为其默认值。
Resize:将更新后的旋转、位置和缩放值应用于摄像机。
SmoothResetCamera:将摄像机位置重置为默认值,从当前位置、旋转和缩放值逐渐淡入。
Update:将摄像机向前移动一个时间步长。
ZoomIn 和 ZoomOut:分别使摄像机放大和缩小。

InputHelper
此类的目的是帮助捕获用户的输入。InputHelper类中提供了以下属性和方法。此类别也可以与Xbox一起使用。
Figure9.png
(您可能在想为什么这个类中有GamePad、Keyboard和Mouse相关的函数,而我们只使用手机。答案是,我们将在调试时使用鼠标,并且同一个类也可以用于Xbox游戏。)
CurrentGamePadState:获取当前玩家的游戏手柄状态。
CurrentKeyboardState:获取当前玩家的键盘状态。
LastGamePadState 和 LastKeyboardState:获取当前玩家上次的游戏手柄或键盘状态。
MousePosition:给出鼠标的当前位置。
MouseScollWheelPosition 和 MouseScrollWheelVelocity:表示当前鼠标滚轮的滚动位置及其速度。
MouseVelocity:给出鼠标移动速度,可以表示为当前鼠标位置 – 上次鼠标位置。

IsKeyDown 和 IsKeyUp:检查控制玩家是否按下或释放了指定的按键。
IsNewButtonPress 和 IsNewKeyPress:用于检查此更新期间是否新按下了按钮的帮助程序。控制玩家参数指示从哪个玩家读取输入。如果为null,则它将接受来自任何玩家的输入。当检测到按钮按下时,输出玩家索引表示哪个玩家按下了它。
IsOldButtonPress:一个布尔值,指示所选鼠标按钮在当前状态下未被按下,但在旧状态下被按下。
IsPauseGame:检查“暂停游戏”输入操作。控制玩家参数指定要读取哪个玩家的输入。如果为null,它将接受来自任何玩家的输入。
Update:读取键盘和游戏手柄的最新状态。

PhysicsGameScreen
到目前为止,我们已经有了作为所有屏幕基类的GameScreen,但我们还需要为物理相关屏幕开发一个基类。这个基类将为我们封装大部分任务,例如创建边框、设置和初始化RenderXNAHelper、鼠标到物理对象的处理程序等。以下是可用方法,让我们来探讨它们。
Figure10.png
CreateBorder:在调试时,我们大多数时候需要限制物理对象不掉出屏幕,这就是我们有CreatBorder方法的原因,它可以在屏幕边缘创建边框。有四个重载可以进一步简化工作并允许我们根据需要进行自定义。
Draw:此方法将使用RenderXNAHelper和世界对象绘制物理对象。
Update:此方法仅调用世界的step方法和RenderXNAHelper对象的update。
EnableOrDisableFlag:它将启用或禁用RenderXNAHelper对象中的调试标志。
HandleInput:通常,在调试时,即使我们在Windows Phone开发中,我们也需要使用鼠标移动对象,此方法将帮助我们完成此操作。同样,此方法也可用于在Xbox或Windows游戏时启用或禁用任何标志。
LoadContent:加载并初始化World、RenderXNAHelper等对象。
Mouse、MouseDown和MouseUp:鼠标相关方法,帮助使用鼠标操纵物理对象。
UpdateScreen:当摄像机投影改变时,此方法会被调用。我们需要编写所有需要在摄像机移动或改变位置时更新的代码。例如,根据摄像机位置移动边框。

SpriteFont
我们使用这个类来处理字体。我们将游戏中所有需要的字体加载到这个类中,并在整个应用程序中使用它。我们使用这个类来捕获所有字体,并在需要时使用它们。

帧率计数器

这是开发游戏时最需要的功能,因为如果FPS(每秒帧数)在特定操作或长时间玩游戏后下降,那么我们可以说一定有一些处理密集型工作正在进行,或者我们在屏幕上绘制了太多数据。这为我们调试性能问题提供了起点。因此,在不过多赘述的情况下,我们将查看代码细节。
FrameRateCounter类有两个方法:Update和Draw,它派生自DrawableGameComponent。
Update方法检查经过的时间,如果时间跨度大于或等于一秒,则更新要显示的FPS并将计数器重置为零,否则不执行任何操作。
Draw方法总是递增帧率计数器并渲染当前存储的帧率到屏幕上。

视差背景

该组件可帮助您为任何2D游戏滚动背景。视差滚动意味着以不同的速度将不同层的图像彼此滚动,从而产生深度和距离的效果。通过想象您正在一辆行驶中的汽车中望向山脉,可以很容易地理解这一点,因此近处的物体(如树木)移动速度比远处的山脉快。有关视差滚动的更多信息,请参阅维基百科
现在是看代码的时候了,然后我们将构建一个演示来展示它的用法。跳到FarseerXNABase/Components/ParallaxBackground.cs类文件。
一打开文件,你就会看到许多属性,让我们先看看它们的用法。
SpeedXSpeedY:当我们移动背景图像时,这些属性决定了背景在两个方向上移动的速度。默认值都为1,根据开发人员设置的滚动方向,设置为0或1。
HeightWidth:获取或设置我们想要显示滚动图像的视口的高度和宽度。
ScrollingTextures:它是一个Texture2D类型的列表。我们可以提供多张图像依次滚动,处于相同的深度级别。如果所有图像都滚动完毕,它将再次从第一张图像开始滚动,并重复此过程。
Position:给出移动背景的当前位置。起初这个属性看似无用,但请相信我,你需要它。想象一下你在一个有移动背景的游戏中,突然电话打进来,你的游戏被墓碑化了,这时你需要知道背景在哪个位置,以便将其存储在内存中,以便在我们从墓碑状态恢复时检索。

//REMEMBER//
ParallaxBackground component won’t work perfectly with the images of Height/Width less than the ViewPort’s Height/Width respectively. Say want to scroll image in horizontal direction in landscape phone mode, with full screen occupancy then you must have images greater than or equal to the width of the phone’s width (i.e. 800) otherwise it might fail in moving background perfectly.
我们使用名为TextureDetails的结构体来存储我们想要滚动的纹理信息。对于滚动背景,我们需要知道任何图像/纹理将占用多少空间,以及是否需要一次渲染多个纹理,所有这些问题都需要诸如高度、宽度、图像/纹理在带状(我们指的是带有多个图像及其按顺序位置的整个背景)中的位置等信息。
唯一需要解释的方法是Draw方法。以下是该方法的代码片段,
public void Draw(SpriteBatch spriteBatch)
{
	//Iterate through all texture
	//If Current position fall into current texture then render it and
	//Increment the current location by width (min(texturewidth and windowWidth)
	
	float positionX = _currentPosition.X;
	float positionY = _currentPosition.Y;
	for (int i = 0; i < _textures.Count; i++)
	{
		if (this._direction == ParallaxDirection.Horizontal)
			if (positionX >= _textures[i].BeltPositionX && positionX <= (_textures[i].BeltPositionX + _textures[i].Width))
			{
				positionX += Math.Min(_textures[i].Width, this.Width);
				spriteBatch.Draw(_textures[i].Texture, new Vector2(_textures[i].BeltPositionX - _currentPosition.X, 0), Color.White);
			}
		
		if (this._direction == ParallaxDirection.Vertical)
			if (positionY >= _textures[i].BeltPositionY && positionY <= (_textures[i].BeltPositionY + _textures[i].Height))
			{
				positionY += Math.Min(_textures[i].Height, this.Height);
				spriteBatch.Draw(_textures[i].Texture, new Vector2(0, _textures[i].BeltPositionY - _currentPosition.Y), Color.White);
			}
	}
	
	//Means last texture reached and we can't reset Position to zero unless untill we completely render last texture,
	//So render first texture just by the last texture
	// |-------------|--------------------------|
	// | Last        | First                    |
	// | Texture     | Texture                  |
	// |-------------|--------------------------|
	if (this._direction == ParallaxDirection.Horizontal)
		if (_currentPosition.X >= (_textures[_textures.Count - 1].BeltPositionX + _textures[_textures.Count - 1].Width - this.Width))
		{
		spriteBatch.Draw(_textures[0].Texture, new Vector2((_textures[_textures.Count - 1].BeltPositionX + _textures[_textures.Count - 1].Width - _currentPosition.X), 0), Color.White);
		}
	
	if (this._direction == ParallaxDirection.Vertical)
		if (_currentPosition.Y >= (_textures[_textures.Count - 1].BeltPositionY + _textures[_textures.Count - 1].Height - this.Height))
		{
		spriteBatch.Draw(_textures[0].Texture, new Vector2(0, (_textures[_textures.Count - 1].BeltPositionY + _textures[_textures.Count - 1].Height - _currentPosition.Y)), Color.White);
		}
}
此方法是组件的核心。为了理解此方法,假设我们使用三张宽度为1000像素的不同纹理,并希望它们水平滚动。那么首先会发生什么?组件应该从当前位置(0,0)到(800,480)(宽度和高度)位置绘制第一张纹理,其余纹理应该被裁剪。然后会有一个Move方法调用,它将通过速度更新当前位置,这将导致裁剪区域以速度移动(假设SpeedX=1且SpeedY=0),因此我们当前位置为(1,0),我们仍然有高度和宽度分别为480和800,所以我们从(1,0)到(801,480)绘制第一个纹理区域。这种情况一直持续到我们到达无法完全被第一个纹理填充整个屏幕的位置,这将是(201,0)位置,当当前位置的X坐标达到201时,我们必须从(799,0)到(800,480)绘制第二个纹理。第一个和第二个纹理的渲染由for循环和其中的代码处理。
现在假设你正在渲染最后一张纹理,并且你知道它会在某个时刻移动,以至于无法填充整个屏幕,这时我们需要在最后一张纹理的最后一点之后立即开始渲染第一张纹理。你可以在方法的第二部分看到这种情况发生。最后四个if语句帮助我们实现这一点。
让我们通过一个例子来看看视差,我们将使用维基百科中的图像,并使用我们的游戏组件使它们滚动。所以首先创建一个名为FarseerXNADemo2的新项目,并将图像从文件夹Demos\FarseerXNADemo2\FarseerXNADemo2\FarseerXNADemo2Content添加到内容中。每层将有4张图像。从侧面看,我们的层将大致如下所示。
Figure11.png
现在将对FarseerXNABase库的引用添加到此项目中。所以现在我们的解决方案看起来像下面这样,
Figure12.png
打开Game1.cs文件并删除不必要的方法,如Initialize、Unload,只保留LoadContent、Update和Draw方法。
现在定义四个ParallaxBackground对象,即layer0、layer1、layer2和layer3。我们需要用它们需要滚动的图像、滚动方向、视口的高度和宽度以及我们希望它们滚动的速度来初始化这些对象。下面是Game1.cs类中LoadContent方法的代码片段。
List<Texture2D> lst = new List<Texture2D>();
lst.Add(Content.Load<Texture2D>("layer-0"));
layer0 = new ParallaxBackground(lst, ParallaxDirection.Horizontal)
{
	Height = graphics.GraphicsDevice.Viewport.Height,
	Width = graphics.GraphicsDevice.Viewport.Width,
	SpeedX = 0
};

以同样的方式,我们初始化了其余3层,并逐渐增加它们的速度。现在剩下的代码是在Update方法中调用ParallaxBackground的Move方法,并在Game1.cs的Draw方法中调用Draw方法。运行项目并切换到横屏模式,你将看到不同的背景以不同的速度移动,在屏幕上呈现出相当逼真的视图。

按钮、文本块、滚动面板、面板控制、边框和图像控制

几乎在每个游戏中,您都需要有按钮、菜单、图像、文本块等。因此,我开始在网上寻找一些初创公司,幸运地在App Hub上找到了一些非常好的控件和结构非常好的代码。我喜欢这个项目,因为它为我提供了一些入门基础代码,例如Control类,它可以扩展以做更多事情并构建游戏UI几乎所有必需的组件。使用这个项目,我创建并扩展了游戏所需的控件。所以让我们来探索FarseerXNABase项目中包含App Hub代码和扩展控件的Controls文件夹。

Control 类
Control 是一个简单UI控件框架的基类。控件按层次结构分组;每个控件都有一个父级和可选的子级列表。它们也会自行绘制。
在层次结构的根控件上,每次更新调用一次HandleInput();然后它会调用其子控件的HandleInput。控件可以覆盖HandleInput以检查触摸事件等。如果您覆盖此方法,则需要调用base.HandleInput以使您的子控件能够看到输入。

//REMEMBER//
Controls do not have any special system for setting TouchPanel.EnabledGestures; if you’re using gesture-sensitive controls, you need to set EnabledGestures as appropriate in each GameScreen.

重写Control.Draw方法以渲染您的控件。Control.Draw接受一个“DrawContext”结构,其中包含GraphicsDevice和其他对绘图有用的对象。它还包含一个SpriteBatch,在调用Control.Draw()之前会调用Begin()。这允许跨多个控件批量处理精灵以提高渲染速度。
控件具有Position和Size,它们定义了一个矩形。默认情况下,Size由内部调用ComputeSize方法自动计算,每个子控件可以根据需要实现该方法。例如,TextControl使用渲染文本的大小。
没有动态布局系统。相反,容器控件(特别是PanelControl)包含用于将其子控件定位成行、列或其他排列的方法。客户端代码应创建屏幕所需的控件,然后调用一个或多个这些布局函数来定位它们。

DrawContext 类
它是要传递到Control.Draw()方法的一组渲染数据。通过将这些数据传递到每个绘图调用中,我们的控件可以在需要时访问必要的数据,而无需引入对ScreenManager等顶级对象的依赖。
它有以下字段,
Device:XNA GraphicsDevice对象。
GameTime:传递给Game.Draw()方法的GameTime。
SpriteBatch:所有屏幕共享的SpriteBatch。在绘制控件之前会调用此批处理的Begin方法,在绘制控件之后会调用End方法,这样多个控件的渲染可以批量处理。
DrawOffset (Vector2):绘制的位置偏移。请注意,这是一个简单的位置偏移,而不是填充变换,因此此API不易支持完整的层次变换。在调用Control.Draw时,控件的位置将已添加到此向量中。
BlankTexture (Texture2D):一个单像素白色纹理,可用于在SpriteBatch中绘制盒子和轮廓。

TextControl 类
此类别用于在屏幕上渲染单行文本。它派生自Control基类,并重写了Draw和ComputeSize两个方法。此类别可以轻松扩展,以允许不同的文本操作功能,如文本换行、对齐、字体大小等。

ImageControl 类
ImageControl是一个显示单个精灵的控件。默认情况下,它显示整个纹理。如果给定一个null纹理,此控件将使用DrawContext.BlankTexture。这允许它用于绘制实心矩形。它的一些属性如下所述:
Origin:源纹理中的位置,以像素为单位。默认为(0,0)表示左上角。
SourceSize:源矩形的尺寸,以像素为单位。如果为null(默认值),则尺寸将与控件的尺寸相同。只有当您希望像素以1比1以外的尺寸缩放时才需要设置此属性;通常您只需使用Size属性设置源和目标矩形的尺寸即可。
Color:用于调制纹理的颜色。默认是白色,它显示原始未修改的纹理。
在Draw重写方法中,它将计算源和目标矩形,并用指定的颜色绘制提供的纹理。

PanelControl 类
此类以行或列的方式排列Control类型的对象,并在组件之间留出给定的间距。它只有LayoutColumn和LayoutRow两个方法,带有三个参数:Margin(X和Y)和float类型的Spacing。它主要用于包含许多子控件,可能带或不带UI。

ScrollTracker 类
ScrollTracker 监视触摸板上的拖动和滑动手势,并计算视口在较大画布中的适当位置和比例,以模拟Silverlight滚动控件的行为。此类别仅处理视图矩形的计算;该矩形如何用于渲染由客户端代码决定。
这个类监视HorizontalDrag、DragComplete和Flick手势。但是,它不能直接设置TouchPanel.EnabledGestures,因为那很可能会干扰应用程序中其他地方所需的手势。所以它只是公开一个const 'HandledGestures'字段,并依赖客户端代码适当地设置TouchPanel.EnabledGestures。它的一些属性和方法如下所述:
CanvasRect:一个矩形(由客户端代码设置),给出我们希望在其中滚动的画布区域。通常比视口更高或更宽。
ViewRect:描述当前可见视图区域的矩形。通常,调用者会设置一次以设置视口大小和初始位置,然后让ScrollTracker移动它;但是,您可以随时设置它以更改视口的位置或大小。
FullCanvasRect:FullCanvasRect与CanvasRect相同,只是它被扩展为至少与ViewRect一样大。这是我们在其中滚动的真实画布区域。
Update 方法将检查我们是否需要使用X和Y方向的速度来移动视口(我们是否正在跟踪拖动手势)。它还将处理软钳位,这意味着在滚动结束时它将逐渐停止而不是突然猛拉,并用于在滚动器边缘获得部分过度拖动效果。
HandleInput 方法接受一个InputHelper类型的参数,并为我们处理手势。我们使用此方法将手势通知Update方法;一旦发现任何触摸,我们就会设置一个标志。我们不能为此使用手势,因为在初始触摸时没有返回手势数据。我们必须小心地只选择“按下”位置,因为TouchState可以在我们看到GestureType.Flick或GestureType.DragComplete之后的一帧返回其他事件。

ScrollingPanelControl 类
此类是PanelControl的扩展,并添加了ScrollTracker。它重写了Update、Draw和HandleInput三个方法。在Update方法中,我们计算ScrollTracker的可滚动区域。HandleInput将允许ScrollTracker控制输入并根据需要对其做出响应。最后是Draw方法,它将在使用ScrollTracker的ViewRect数据渲染子控件之前调整偏移量。

Button 类
此类用作按钮,它派生自Control类,因此可以拥有子控件并可以添加到PanelControl和ScrollablePanelControl中。我们主要在菜单等上下文中使用此类。现在让我们深入研究一些代码,首先是属性。
Font:绘制按钮显示文本时使用的字体精灵。如果TextVisible为true则需要,否则控件将抛出错误。
TextVisible:布尔属性,用于通知绘制方法是否在按钮上绘制文本。
DisplayText:字符串属性,存储要在按钮表面渲染的文本。
TextRotation:渲染显示文本时应添加的旋转量。
TextSize:要渲染的文本大小。我们有三种大小:小、中和大。实际上,如果为小,我们将渲染文本缩放为其原始大小的75%;如果为中(默认),则为100%;如果为大,则为125%。
TextAlignment:对齐按钮视图区域中的文本。它可以是左对齐、右对齐或居中对齐(默认)。
ClickAreaSpecific:我们可以使用不同的显示区域和不同大小的可点击区域。如果此属性为true,则我们只在可点击区域中检查Tap手势。
ClickArea:定义按钮可点击区域的矩形,如果未指定,则与显示区域大小相同。
NormalButtonTextureClickedButtonTexture:分别存储普通按钮状态和点击按钮状态的纹理。NormalTexture是必需的,否则控件将抛出错误。但您可以轻松地将其更改为您的使用方式。
State:它是按钮的当前状态。它可以是Normal或Clicked,具体取决于我们拥有的Tap手势位置。
HeightWidth:指定显示区域的高度和宽度。它将用于渲染纹理以及在非ClickAreaSpecific模式下查找点击。Height和Width默认值为50像素。
ForegroundClickedForeground:字体在普通状态和点击状态下的颜色。两者默认均为黑色。
TextWidthTextHeight:分别获取显示文本的宽度和高度。
TapPosition:给出上次点击位置。
Tag:它用于存储按钮控件的额外信息,例如在动态创建的按钮中,我们需要知道哪个按钮触发了点击事件,在这种情况下,我们可以存储一些有意义的信息,可以唯一标识每个按钮。它的类型是对象,因此可以存储任何内容。

我们重写了四个方法:ComputeSize、Update、Draw和HandleInput。
ComputeSize将返回宽度和高度值。
HandleInput:在此方法中,我们检查Tap手势,如果发现,则需要处理该Tap手势。在处理过程中,如果Tap位置在当前按钮内部,则我们在处理后移除该手势,否则我们忽略它并且不执行任何操作。在处理过程中,我们记录Tap的位置并存储以供在Update方法中使用。然后我们检查ClickAreaSpecific标志,如果找到,则将当前Tap位置与可点击区域进行检查,否则我们将显示区域作为可点击区域并检查位置。如果Tap在可点击区域之外,则我们退出该方法并返回false,以便手势保留在手势列表中,其他按钮可以处理它。如果Tap在可点击区域内部,则我们将按钮状态更改为Clicked,其余的事情(例如根据按钮状态渲染正确的纹理和字体)将由Update和Draw方法处理。
Update:如果我们设置了TapPosition,那么我们需要记录当前的ElapsedTime,这将在稍后用于在显示点击纹理后触发点击事件。如果我们不这样做,按钮看起来就不会像按钮,因为当用户点击按钮时,点击事件将被触发,并且点击纹理不会显示足够长的时间以可见。
Draw:此方法存储按钮的上次绘制位置,该位置将用于检查点击是否在按钮内部。它还会根据按钮的状态实际渲染正确的纹理和前景。

好了,现在够了,是时候进行演示了。我们将有一个演示,展示我们刚刚探讨过的屏幕系统和控件的用法。我们将创建3个屏幕;其中一个将是弹出式,这意味着它将允许其下方的屏幕进行渲染。我们还将使用按钮、文本块、可滚动面板和普通面板。所以继续探索FarseerXNADemo3文件夹中的代码。以下是该项目的屏幕截图。您可以使用手机的返回导航按钮来关闭屏幕。

Figure13.png

让我们开发一个游戏。准备好了吗!!!

我们已经拥有了创建游戏所需的大部分部件,所以让我们先思考一个想法。最简单的游戏概念可以是当主体触地时自行跳跃,玩家只需控制主体的左右移动。我们可以为主体设置障碍和奖励。游戏的目标是以最高分数完成一个关卡。我们还将以恒定速度滚动屏幕,这增加了游戏玩法的挑战。关于障碍,我们将有两种类型的障碍,一种是可跳过的,另一种是触之即死的。可跳过的障碍允许我们使主体以特定方式移动,这些障碍类似于地面,使主体跳跃。而触之即死的障碍具有相同目的,但玩家需要避开它们,因为当主体触碰到它们时,关卡就会结束。关于奖励,我们可以放置一些物体,当跳跃的主体收集到它们时,分数会大幅增加,从而促使玩家收集它们,我们还会为这些奖励设置更多危险,所以玩家必须选择是收集还是仅仅生存。此外,我们还可以设置空心地面,如果主体掉进去,游戏关卡就会结束。嗯……好的,这就是我们所需要的一切。

现在概念已经准备好了,所以让我们启动程序员的大脑,我们需要思考如何实现上述概念。显然,我们需要一些屏幕,比如启动屏幕,我们可以从中收集游戏信息,如如何玩、高分以及导航链接到关卡选择屏幕。接下来,我们需要一个关卡选择屏幕,玩家可以通过它选择不同的关卡。然后是实际的游戏屏幕,它根据提供给它的数据渲染关卡。除了屏幕,我们还需要一些辅助类,比如Assets类,它将保存所有图形、声音和物理相关的常量值。此外,目前假设我们有游戏数据,其中包括关卡名称、描述以及关卡中障碍物和其他物体的位置。这些游戏数据是XML形式的(我们将在关卡编辑器部分讨论如何创建关卡),所以第一件事是反序列化这些数据并将其保存在内存中,为此我们使用相同的Assets类(你可以在类底部、高分功能上方找到“关卡数据”部分)。除了Assets类,我们还有一个Serialization辅助类,它为我们反序列化XML数据,如果需要也可以读取文本文件。我们还有其他简单的类,如Entity、EntityData、Level和GameObjectData,它们主要用作数据结构,用于存储实际游戏屏幕的关卡数据。还有一些枚举,如EntityType和GameObjectType,可以帮助我们克服字符串输入错误。
让我们逐一探索游戏屏幕。

启动屏幕

这将是游戏运行时出现的第一个屏幕。所以它应该是导航的根,因此我们需要三个菜单项:“开始游戏”将导航到关卡选择屏幕,“说明”将显示说明屏幕弹出窗口,“高分”将导航到高分屏幕。为了增加趣味性,我们将放置一些移动的背景和一些游戏本身的图形。所以我们的屏幕看起来像下面这样:
Figure1.png
此屏幕上使用的组件是按钮(用于创建菜单)、PanelControl(用于容纳菜单项)和ParallaxBackground(显然用于移动背景)。我们还有一个名为ExitGame的事件,用于通知主游戏类(FarseerXNAWP7Game.cs)我们需要关闭游戏。

说明屏幕

我们需要提供控件的详细信息以及如何玩游戏,所以我们有说明屏幕。它不是全屏显示,而是一个弹出屏幕。以下是说明屏幕在启动屏幕上方放置/打开时的外观:
Figure14.png
在此屏幕中,我们只使用了按钮和PanelControl来容纳它。其他所有内容都只是使用正常的XNA Draw和DrawString方法进行渲染。

//NOTE//
Any popup in your game or in any Windows Phone 7 application should be closed when user hit the back hardware button, otherwise your application won’t pass the review process for marketplace.

关卡选择屏幕

我们想创建许多不同的关卡,因此我们需要允许用户随时玩其中任何一个关卡,所以我们需要在一个屏幕中显示所有关卡,因此有了LevelSelectorScreen。我们的第一个任务是获取Assets类中已有的关卡,并在按钮上显示关卡的名称和描述。所以我们的屏幕看起来像下面这样:
Figure15.png
这里我们只使用了背景图片、按钮和ScrollablePanelControl(因为这个列表可能会变得非常大,用户可能需要滚动)。

//NOTE//
Before using scrollable panel control make sure you have enabled all the gestures required for the ScrollTracker (Flick, VerticalDrag  and DragComplete) while buttons required Tap gesture.

游戏关卡,物理屏幕

到目前为止,我们还没有在任何屏幕中使用Farseer;现在是时候了。这个屏幕将是实际的游乐场,我们的主题将在这里反弹,摄像机将以稳定的速度向右移动。在移动主题时,玩家必须面对障碍物,并尽可能高分地找到出路。
为了简化渲染和设计,我们将把屏幕划分为网格或方形方块,每个方块大小为32像素,使800x480的屏幕成为25x15个方块。
还有一件事我们没有过多谈论,那就是“关卡是如何设计的”?我曾想过使用xml数据来存储关卡信息,比如带有x和y位置的障碍物节点。但那样设计整个关卡将花费大量时间。所以我决定构建一个关卡编辑器,专门为我的游戏(这次不是通用的)。通过这个关卡编辑器,我们可以设计自己的关卡,并且这个关卡编辑器会提供在游戏中渲染关卡所需的XML(示例在FarseerXNAWP7/Levels/Game.xml)。现在我们假设已经设计了一些关卡并通过关卡选择屏幕进入了GameLevelPhysicsScreen。这个屏幕的任务是读取数据并以正确的物理效果正确渲染它们。
//NOTE//
If you are using physics in a game then don’t be too realistic with the numbers, because most of the games are 50% non-real physical conditions, like you may need to set the gravity to more than 9.8 to make it more real. In short, do trial and errors with the numbers until you get the simulation to work similar to what is in your mind and don’t bother about the real world value.

从现在开始,我们将使用以下术语:关卡数据 = 实体集合;实体 = 将在屏幕上渲染并与主题相关的每个事物;主题 = 玩家可控制的移动实体;障碍物 = 当击中顶部时使主题跳跃的实体;尖刺 = 放置在不同位置且始终静止的致命障碍物;尖刺带 = 为了理解为什么需要这个物体,首先考虑一个场景,假设我们以稳定的速度向右移动摄像机,如果玩家不移动主题,那么它很快就会超出屏幕,所以我们需要在屏幕的整个左墙上放置一条尖刺带,它会随着摄像机一起滚动;前墙 = 玩家可以比摄像机速度更快地移动主题,因此玩家有可能通过右墙将主题移出边界,为了防止这种情况,我们需要有一堵前墙,它不可见但确实存在;实心地板 = 与障碍物相同,但自动生成在屏幕底部,当主题击中时会使其跳跃;钻石 = 当玩家击中它们时,钻石会消失,玩家会获得大量分数提升;空心地板 = 在物理上它实际上什么都不是,它是地板上的一个洞,如果主题掉进去,游戏结束。
所以现在我们假设LevelSelectorScreen已经在构造函数中传递了关卡数据。让我们探索FarseerXNAWP7/Screens/GameLevelPhysicsScreen.cs中的代码。
我们将从LoadContent方法开始,它的第一行代码就重置了World(记住Farseer物理引擎的World类),并设置了大小为65的向下重力(回忆一下上一个NOTE部分,游戏有50%不真实)。LoadContent中一个非常重要的方法调用是CreateEntities,这个方法负责创建背景、记分板、关卡实体、前墙和尖刺带。我们最感兴趣的是关卡实体的创建,所以让我们看看PlotLevelData方法。好吧,我承认它乍一看有点难以理解,但相信我,它非常简单。在PlotLevelData方法中,我们遍历所有实体,并根据它们的类型(障碍物、尖刺、空心地板等)创建相关的物理对象,很简单,对吧!所以如果你仔细看这个方法,我们正在使用我们在FarseeXNADemo1中解释过的相同的FixtureFactory和BodyFactory创建物理对象。但是为了使游戏可玩,我们需要做一些小的调整。下面是每个实体创建的解释。
障碍物(1到6):障碍物是1个单元格大小(32x32像素)的实心方块,只允许物体从其顶部表面弹跳。为了让物体跳跃,当它与特定类型的物体(如障碍物的前侧)发生碰撞时,我们会对其施加冲量。但是无法识别我们是撞到了前墙、侧墙还是底墙,所以我们创建了2个物理物体,一个将是带有正确纹理的实际障碍物,另一个将是放置在障碍物顶部(第一个物体)的细线,当它与物体碰撞时,会使物体跳跃。第二个物体的宽度会比障碍物宽度略小,因为我们将在创建墙壁时使用相同的障碍物,如果我们将第二个物体保持相同宽度,那么墙壁也可能使物体跳跃,这是我们不想发生的,所以宽度略小,下图显示了这个问题。
Figure16.png
尖刺(上、下、左、右):尖刺是使用顶点创建的,它们最终都是三角形,适合32x32像素的正方形。
地板(左、中、右):地板指空心地板实体。空心地板纹理的左侧和右侧与中间不同,中间地板纹理可以重复以方便创建。因此,我们需要为每种地板实体创建不同的对象。实际上它是空心地板,所以它不应该有任何东西,但游戏如何判断物体是否掉入其中呢?所以我们使用与障碍物相同的技术,我们创建两个对象,一个带有完整纹理且大小为地板对象(宽度32,高度64像素),第二个对象是一条细线,放置在地板底部(屏幕底部)。我们的第一个对象不与物体碰撞,而第二个对象会碰撞,因此我们会看到物体掉入地板洞的漂亮效果,当它撞到洞的地面时,我们会收到通知。
主题与钻石:我们可以精确地使用顶点来制作钻石的几何形状,但我们的游戏不需要那么高的精度,所以我们可以很容易地使用矩形/圆形来制作这些物体。而且我认为现在我们不需要知道如何创建矩形和圆形,对吧!!!
实心地板:你可能已经注意到,我们使用二维数组来跟踪对象在网格中的位置。当我们在列x行位置有任何对象时,我们在矩阵中放入1。这将在计算我们需要放置地板的位置时使用,我们只需比较最后一行的值即可,简单吧……
如果你注意到我们正在传递渲染信息和GameObjectType,现在你会问为什么?让我讲个故事,很久以前有个国王,当发生碰撞时,我们无法通过查看碰撞体的夹具来判断它是地板、障碍物、尖刺还是钻石,所以我们为每个物体保存了更多信息,这些信息可以在碰撞时轻松检索。所以让我们看看碰撞方法。
OnCollision:好的,那么思考一下有多少对象可以碰撞,以及当发生这种情况时我们应该做什么?这个问题的答案就是我们的方法体。只有主题可以与其他对象碰撞,所以碰撞可能发生在主题和障碍物、尖刺、空心地板、实心地板、钻石和尖刺带之间。我们甚至不关心前墙,因为在碰撞时我们不需要做任何事情,物理引擎会处理它。所以让我们探讨当主题与上述对象碰撞时我们应该做什么。与以下对象碰撞:
- 障碍物和实心地板:对主题施加冲量,使其看起来像跳跃。
- 尖刺、尖刺带和空心地板:游戏结束了,伙计。开始显示游戏结束屏幕(LevelOverScreen)的过程。
- 钻石:现在这就有问题了。如果你等待OnCollision事件触发,那么你的主体就会从与钻石的碰撞中弹开,而我们绝对不希望发生这种情况。我们希望主体穿过钻石,并且仍然希望从屏幕中移除该钻石,但是怎么做呢???在碰撞之前,我们可以检查主体是否会与钻石碰撞,如果会,我们就从世界中移除钻石,因此没有碰撞和弹跳,但我们知道会有碰撞,所以增加分数,酷吧。
还剩一件事,那就是游戏如何知道关卡完成了?我们再次使用隐形完成墙和碰撞检测来确定它。我们将在关卡数据的末尾创建一个隐形墙,并检查主题与它的碰撞,如果发现碰撞,我们就可以说玩家已经完成了关卡,需要显示关卡完成屏幕。
关于这个屏幕的最后一件事是,我们把纹理存储在字典里,你可能会问为什么,为什么我们不能像其他屏幕一样使用变量?我的答案是“缓存”。假设玩家碰到了尖刺,游戏结束了,那么我们需要允许玩家重试关卡,为此我们不想再次加载所有纹理,它们已经在那儿了,已经加载过了。所以我们把它们存储在内存中以备后用。

关卡结束屏幕

这个屏幕类似于说明屏幕;事实上,它们都共享相同的背景图片。这个屏幕用于显示玩家完成游戏关卡或游戏结束时的总分数。玩家可以从同一个屏幕选择进入关卡选择菜单或重试关卡/下一关卡,具体取决于屏幕类型(可以是关卡完成屏幕或游戏结束屏幕)。下面是当玩家碰到尖刺且游戏结束时该屏幕的截图。
Figure17.png

高分屏幕

我们需要的最后一个屏幕是高分屏幕。我们将使用隔离存储来存储玩家的分数。我们可以有多个关卡,因此需要存储与关卡相关的高分。此外,我们只对每个关卡的前5名高分感兴趣。下面是高分屏幕的截图。
Figure18.png
这里我们使用了可滚动面板控件,因为这个列表可能会超出屏幕;背景是相同的视差背景。

墓碑化(TOMBSTONING)

在手机应用程序中,我们需要考虑的一点是可能随时发生的各种中断。中断指的是接到电话或用户按下硬件按钮等情况。由于WP7不是多任务手机(至少目前不是),我们不能同时运行多个应用程序。所以,假设你的应用程序正在运行,用户按下了硬件搜索按钮,那么你的应用程序就会被停用,搜索屏幕就会被激活。但如果你正在使用Visual Studio并已附加调试,你会发现调试并未停止,这意味着你的应用程序并未完全被杀死,而只是不在前台。现在,如果你按下返回按钮,你的应用程序就会恢复。你可能会认为WP7会为你存储状态,并从你离开的地方恢复,但事实并非如此,恢复意味着WP7知道它已被重新激活,并且没有启动新的实例。因此,我们可以利用这些知识在应用程序被停用和激活时保存和加载状态。
在我们的游戏中,我们只关心主游戏屏幕(GameLevelPhysicsScreen)。如果这个屏幕是活动的,那么我们会将对象的当前状态保存在内存中,以便以后检索。好的,那么我们需要保存哪些信息才能再次加载屏幕呢?答案是:主题的位置、摄像机位置、背景位置、正在进行的当前关卡索引、收集到的钻石和行进的距离。我们使用一个名为ResumeState的类来存储这些数据,然后将该类的对象放入手机的内存状态中。
现在我们需要知道何时将数据保存到内存中以及何时从内存中加载,为此我们可以分别监听当前PhoneApplicationService的Deactivated和Activated事件。当发生Deactivated事件时,我们从StartScreen获取ResumeState对象,它会向LevelSelector Screen请求相同的对象,而LevelSelector Screen又会从实际的GameLevelPhysicsScreen(如果它是活动屏幕)获取数据。这个ResumeState对象将被保存到手机的内存状态中。当发生Activated事件时,我们检查手机内存状态中是否存在Resume state键,如果找到,我们就获取ResumeState对象并将其传递给StartScreen,现在StartScreen检查本地ResumeState对象,如果已设置,它将加载LevelSelectorScreen,LevelSelectorScreen执行相同的过程并使用ResumeState对象加载GameLevelPhysicsScreen。在ContentLoad方法中,如果ResumeState对象已设置,我们将使用ResumeState对象中的值初始化相机、主题、背景和分数。这就是我们使应用程序从Tombstate恢复所需做的一切。
关于墓碑化的最后一件事是“准备就绪”屏幕,想象一下你正在游戏进行中,突然一个电话打进来,游戏进入墓碑状态,通话结束后你点击返回按钮,游戏从中间恢复,但是玩家回来时可能还没有准备好按下正确的控制键,这时我们需要向他显示一个“准备就绪”屏幕。我们使用5秒倒计时来告诉玩家我们将在5秒后准备就绪,所以你最好也准备好。

XNA的关卡编辑器和序列化

为了我们的游戏,我们需要一些具有不同难度级别的关卡。所以与其用纸笔来构建关卡,我决定采用关卡编辑器应用程序。这个应用程序将简化创建关卡的过程,并提供关卡的实际运行时感受。通过这个应用程序,我们可以创建新的关卡集,编辑它们,在上面放置不同类型的实体,并通过拖放来设计关卡。所以让我们快速探索一下这些表单。

关卡编辑器主窗体

此表单是应用程序的主表单,您可以创建关卡、编辑关卡、将其记录在显示列表中、保存关卡以供游戏使用,或者加载现有关卡进行编辑。下面是相同内容的截图:
Figure19.png

新建/编辑关卡窗体

此表单用于编辑关卡的属性,如名称、描述、总宽度(稍后可在关卡构建器表单中编辑)和视差背景图像。
Figure20.png

实际关卡构建器

下面的表单是关卡的主要设计画布。我们可以使用简单的拖放功能从这个表单构建任何复杂的关卡。要构建关卡,请从侧边栏中选择任何实体,然后左键单击画布将其放置在上面。右键单击任何实体以将其从表面移除。这里需要注意的是,编辑器并不是那么智能,如果你尝试在网格的最后一排放置一个2个单元格大小的实体,那是可能的。所以你必须聪明地行动才能构建可玩的关卡。拜托,我也有社交生活。
Figure21.png
当我们点击保存并关闭时,实体数据(位置、实体类型、图像、高度和宽度)会存储在内存中。当我们从主窗口保存时,我们会得到XML格式,XNA游戏可以使用XML序列化和反序列化来读取它,从而使扩展变得容易。

你的收获

- 游戏框架
- 用于快速创建屏幕的代码片段
- 图形
- 可重用组件(按钮、图像、文本、面板和滚动条)
- 屏幕系统
- Farseer物理引擎基础知识和渲染辅助

结束语

最后,我只是想寻求改进建议,或者如果你在代码中发现任何错误,请告诉我。此外,我还没有在真实手机上测试过。建立游戏并将游戏开发任务变得像LOB应用程序一样简单一直都是我的梦想。如果哪怕只有一个开发者受到这篇文章的启发并开始开发游戏,那么我的任务就完成了。

最后一件事,如果你喜欢这些东西,请投票;如果有任何错误,请告诉我。

更新历史

2011年3月28日 - 原文发表。
2011年4月11日 - 结束语 minor change。
2011年4月12日 - 添加FarseerPhysicsXNA.dll作为下载,因为新版本源文件无法编译。

© . All rights reserved.