对于每个诸如玩家、武器、敌人(NPC)等的单元(unit)类型,你都需要在GameLogic命名空间创建一个类。一个游戏单元需要存储它的属性(例如:速度,生命值,伤害等)和逻辑(状态和行为)。除了游戏单元的逻辑,你还要在GameScreen 类中构建主程序的逻辑,这个逻辑定义了游戏控制和单元如何更新和绘制。你将在本章最后创建GameScreen类。
在你开始创建游戏逻辑类之前,让我们回顾一下前面所说的游戏功能:
- 开始游戏时玩家装备了机枪和弹药,可以跑、跳和攻击(瞄准和射击)。
- 每个怪兽在地图上随机行走直到它们看见玩家或被攻击,当这种情况发生时,怪兽会追踪玩家,如果接近就会攻击玩家。如果它的生命值为0则死亡,如果玩家的生命值为0则游戏结束。
从上面的描述中可以看出,玩家和敌人共享某些属性和行为,诸如生命值、在地形上移动、可以导致和受到伤害,是一个动画模型等。正因为如此,所以你将创建一个包含这些共有属性的基类,而玩家类和敌人类从这个基类继承。
本节你将创建一个游戏单元的基类,这些游戏单元都是动画模型,可以在地形上移动,可以导致和受到伤害。在GameLogic命名空间创建一个新类并将它命名为TerrainUnit。首先声明一些单元共享的变量——生命值和速度:
// Basic attributes (Life and Speed) int life; int maxLife; float speed;
你使用AnimatedModel类将TerrainUnit作为一个动画模型绘制。所以,声明一个AnimatedModel 类型的变量存储TerrainUnit动画模型。接下来,声明一个int类型的变量存储当前单元的动画,这个变量会在以后用来控制和改变单元的动画。
每个单元还需要一个包围盒和包围球用来进行碰撞检测,这可以使用XNA的BoundingBox和BoundingSphere类。单元的碰撞体是它的动画模型的碰撞体,而动画模型的碰撞体是由它的内容处理器创建的。因为当单元在地图上移动时动画模型的碰撞体会进行变换,所以在TerrainUnit类中需要一个碰撞体的拷贝。要判定碰撞体何时进行更新,需要创建 needUpdateCollision标识:
// Animated model AnimatedModel animatedModel; int currentAnimationId; // Collision volumes BoundingBox boundingBox; BoundingSphere boundingSphere; bool needUpdateCollision;
注意:在第11章中创建的动画模型处理器并没有创建碰撞体,但在下面的“单元碰撞”一节中你会扩展这个处理器,使它可以生成碰撞体。
每个单元有两个速度-线速度和角速度-其中线速度用来更新单元的位置(或平移)而角速度用来更新单元的朝向(或旋转)。线速度和角速度都由一个3D向量表示,角速度的每个分量表示绕世界空间中X,Y和Z轴的角速度。还有一个速度对应重力,重力的方向定义为Y轴(0, 1, 0)。这个速度可以是负值(当单元下落时)和正值(但单元跳起时)。
// Velocities and gravity Vector3 linearVelocity; Vector3 angularVelocity; float gravityVelocity;
你使用三个向量存储单元的朝向:headingVec,strafeVec和upVec,这些向量对应单元的前方,右方和上方。当你根据坐标轴移动单元时就会使用到这些向量,例如,当你向让单元后退,你可以将这个单元的线速度设置为headingVec的负方向:
// Unit coordinate system Vector3 headingVec; Vector3 strafeVec; Vector3 upVec;
要判断单元是否在地形上,是否还活着,是否需要调整跳跃,需要创建一些标识:
// Some flags bool isOnTerrain; bool isDead; bool adjustJumpChanges;
创建和加载单元
TerrainUnit类从DrawableGameComponent类继承,而DrawableGameComponent类需要一个Game实例。所以,TerrainUnit构造函数以一个Game作为参数并把这个Game也用在了基类(DrawableGameComponent)的构造函数中。变量是在TerrainUnit 类的构造函数中被初始化的,下面是代码:
public TerrainUnit(Game game) : base(game) { gravityVelocity = 0.0f; isOnTerrain = false; isDead = false; adjustJumpChanges = false; needUpdateCollision = true; }
要加载单元的模型动画需要创建Load方法,Load方法以一个动画模型文件名为参数,加载模型,将模型放置在地形上,更新它的朝向。下面是Load方法的代码:
protected void Load(string unitModelFileName) { animatedModel = new AnimatedModel(Game); animatedModel.Initialize(); animatedModel.Load(unitModelFileName); // Put the player above the terrain UpdateHeight(0); isOnTerrain = true; NormalizeBaseVectors(); }
让单元跳跃
单元有一个行为是跳跃,可以让单元向上离开地面然后下落,使单元下落的重力加速度,在游戏中重力加速度与重力轴方向(向上世界坐标系的Y轴0, 1, 0)相反。所以要让单元向上跳起,你可以将重力加速度值变为正值,这样可以让单元向上运动。当单元在空中时,你慢慢地减小重力加速度直到它再次变成负值,这样单元会向下移动。要让跳跃变得平滑,你需要定义一个重力加速度的最大值和最小值,这样当单元下落时它的速度会减少到最小值。
当单元跳跃时要比行走时移动得快,这样的话,相机的移动速度就会偏慢。要解决这个问题,当单元跳跃时需要增加相机的移动速度,当单元落回到地面时,再把相机的移动速度恢复为原值。你也可以在跳跃时增加单元的速度,允许它跳更大的距离。下面是Jump方法的代码:
public void Jump(float jumpHeight) { if (isOnTerrain) { // Update camera chase speed and unit speed ThirdPersonCamera camera = cameraManager.ActiveCamera as ThirdPersonCamera; camera.ChaseSpeed *= 4.0f; speed *= 1.5f; adjustJumpChanges = true; // Set the gravity velocity gravityVelocity = (float)GRAVITY_ACCELERATION * jumpHeight * 0.1f; isOnTerrain = false; } }
在使单元跳跃前应先检查它是否在地面上,防止单元在空中继续跳跃。Jump方法的参数是你想让单元能够跳到的高度。注意在改变了相机移动速度和单元的速度后要将adjustJumpChanges设置为true,这表示这些改变需要在后面恢复到原值。
更新单元的高度
基于TerrainUnit 创建的单元能在地形上移动,这些单元需要在更新它们位置同时更新它们的高度,以确保总能保持在地面上。当单元移动到一个新位置时,地形高度可以等于,大于或小于单元的初始高度,如图12-4所示。
图12-4 在地形上移动单元
如果当前地形高度等于或大于单元的高度说明单元在地面上,在这中情况中,你要将单元的高度设置为地形的高度。否则,单元在空中,你需要减少施加在单元上的重力加速度。要根据单元在地形上的位置更新单元高度,你需要创建UpdateHeight方法。
注意:要让单元保持在地面上,你需要确保重力加速度不是正值。如果重力加速度为正,单元会向上移动,你就无法让它站在地面上了。下面是UpdateHeight方法的代码:
// Transformation property public virtual Transformation Transformation { get { return animatedModel.Transformation; } set { animatedModel.Transformation = value; } } private void UpdateHeight(float elapsedTimeSeconds) { // Get terrain height float terrainHeight = terrain.GetHeight(Transformation.Translate); Vector3 newPosition = Transformation.Translate; // Unit is on terrain if (Transformation.Translate.Y <= terrainHeight && gravityVelocity <= 0) { // Put the unit over the terrain isOnTerrain = true; gravityVelocity = 0.0f; newPosition.Y = terrainHeight; // Restore the changes made when the unit jumped if (adjustJumpChanges) { ThirdPersonCamera camera = cameraManager.ActiveCamera as ThirdPersonCamera; camera.ChaseSpeed /= 4.0f; speed /= 1.5f; adjustJumpChanges = false; } } // Unit is in the air else { // Decrement the gravity velocity if (gravityVelocity > MIN_GRAVITY) gravityVelocity -= GRAVITY_ACCELERATION *elapsedTimeSeconds; // Apply the gravity velocity newPosition.Y = Math.Max(terrainHeight, Transformation.Translate.Y+gravityVelocity); } // Update the unit position Transformation.Translate = heightTranslate; }
只要单元在地面上,你就要通过adjustJumpChanges变量检查是否需要修正Jump方法导致的改变。否则,如果gravityVelocity大于重力加速度的最小值,就需要减少gravityVelocity并移动玩家。施加在单元上的所有变换是由Transformation属性生成的,这个属性也改变动画模型的变换。通过这种方式,当你绘制动画模型时,所有单元的变换已经存储在其中了。
更新单元
当更新单元时,你需要更新它的位置和朝向(变换)和动画模型。要更新单元的动画模型,你只需调用AnimatedModel.类的Update方法。要更新单元的位置,你需要根据速度和经过的时间计算出位移,并将这个位移添加到当前位置上。更新朝向也是同样的方法,角速度用来计算单元旋转程度。下面是Update和NormalizeBaseVectors方法的代码:
public override void Update(GameTime time) { // Update the animated model float elapsedTimeSeconds =(float)time.ElapsedGameTime.TotalSeconds; animatedModel.Update(time, Matrix.Identity); // Update the height and collision volumes if the unit moves if (linearVelocity != Vector3.Zero || gravityVelocity != 0.0f) { Transformation.Translate += linearVelocity *elapsedTimeSeconds * speed; UpdateHeight(elapsedTimeSeconds); needUpdateCollision = true; } // Update coordinate system when the unit rotates if (angularVelocity != Vector3.Zero) { Transformation.Rotate += angularVelocity *elapsedTimeSeconds * speed; NormalizeBaseVectors(); } base.Update(time); } private void NormalizeBaseVectors() { // Get the vectors from the animated model matrix headingVec = Transformation.Matrix.Forward; strafeVec = Transformation.Matrix.Right; upVec = Transformation.Matrix.Up; }
在Update方法中,你首先更新单元的动画模型,需要将时间和用来变换动画模型的父矩阵作为参数传入。因为无需变换动画模型,你可以传入一个单位矩阵。之后要更新单元的线速度和角速度。
如果单元的linearVelocity或gravityVelocity不为0,则说明单元正在移动,你需要调用UpdateHeight方法让单元处在地形之上。你还需要将needUpdateCollision设置为true,用来更新单元的碰撞体的位置。
最后,如果单元的angularVelocity不为0,你要调用NormalizeBaseVectors方法更新它的朝向向量(heading, strafe和up向量)。你可以从动画模型的变换矩阵中提取这些矢量。
单元的碰撞体
你可以用不同的方法对场景中的物体进行碰撞检测。精确的方法是使用mesh(由很多三角形构成)检查两个物体的相交。这种方法是最精确的,但也是效率最低的。例如,要检查两个由2000个三角形构成的mesh之间的碰撞,你需要进行2000 * 2000次检测。所以你可以使用碰撞体检测而不是mesh检测碰撞体提供了一个快速的,但不是很精确的检查两个物体相交情况的方法。在这个游戏中,你会使用两个不同的碰撞体-碰撞盒和碰撞球。如果碰撞体是一个盒子,叫做包围盒,如果是一个球,叫做包围球。
你可以构建一个盒子用来检测与坐标轴对齐的碰撞。这种情况中的盒子叫做axis-aligned bounding box (AABB,或翻译成轴对齐矩形包围盒)。使用AABB的优点是它很简单。但是,因为它要对齐世界坐标轴,所以无法旋转。如果使用的盒子朝向单元的坐标轴,这叫做object oriented bounding box (OOBB,翻译成方向包围盒)。使用OOBB进行碰撞检测要比AABB慢,但OOBB提供了一个始终朝向单元的包围盒。图12-5展示了一个AABB和两个不同朝向的OOBB。
图12-5 为模型创建一个AABB和一个OOBB。(Left) 如果模型的朝向与世界相同则AABB和 OOBB是一样的(左图),新的朝向的AABB(中图),新的朝向的OOBB(右图)
因为XNA已经包含了一个类可以处理AABB,你可以将这个类作为单元的包围盒。这样每个单元都有一个AABB和包围球,用XNA的BoundingBox和BoundingSphere类表示。
内容管道的默认模型处理器会为模型中的每个mesh生成一个包围球。这样你就有了每个mesh的包围球。你可以创建针对整个模型的包围球以避免进行每个mesh的碰撞检测。因为默认模型处理器不会生成AABB,所以你需要自己生成。
你可以通过改变模型处理器创建单元的包围盒和包围球,这个处理器就是在第11章中创建的AnimatedModelProcessor。首先打开AnimatedModelProcessor类,这个类在AnimatedModelProcessorWin项目中。然后创建一个叫做GetModelVertices的方法提取模型中mesh的所有顶点。你将使用这些顶点通过XNA 中的BoundingBox和BoundingSphere类中的CreateFromPoints方法创建模型的碰撞体。CreateFromPoints方法会从模型的顶点中创建一个包围体。下面是GetModelVertices方法的代码:
private void GetModelVertices(NodeContent node,List<Vector3> vertexList) { MeshContent meshContent = node as MeshContent; if (meshContent != null) { for (int i= 0; i< meshContent.Geometry.Count; i++) { GeometryContent geometryContent = meshContent.Geometry[i]; for (int j = 0; j<geometryContent.Vertices.Positions.Count; j++) vertexList.Add(geometryContent.Vertices.Positions[j]); } } foreach (NodeContent child in node.Children) GetModelVertices(child, vertexList); }
在GetModelVertices方法中你遍历了模型的所有节点,从跟节点开始,搜索MeshContent节点。MeshContent节点包含模型的mesh数据,从这些数据的Geometry属性中你可以提取mesh的顶点信息。在处理了节点后,你需要对其子节点也调用GetModelVertices方法,知道处理完所有的节点。注意所有的顶点都被存储在类型为List<Vector3> 的vertexList变量中。
在AnimatedModelProcessor 类的Process方法的最后,在那里你处理了模型并提前了骨骼动画数据,还需要调用GetModelVertices方法生成模型的碰撞体。在生成了碰撞体之后,将它们存储到模型的Tag属性中。你可以将碰撞体添加到包含模型动画数据的字典中。下面是生成碰撞体的代码:
// Extract all model's vertices List<Vector3> vertexList = new List<Vector3>(); GetModelVertices(input, vertexList); // Generate the collision volumes BoundingBox modelBoundBox = BoundingBox.CreateFromPoints(vertexList); BoundingSphere modelBoundSphere = BoundingSphere.CreateFromPoints(vertexList); // Store everything in a dictionary Dictionary<string, object> tagDictionary =new Dictionary<string, object>(); tagDictionary.Add("AnimatedModelData", animatedModelData); tagDictionary.Add("ModelBoudingBox", modelBoundBox); tagDictionary.Add("ModelBoudingSphere", modelBoundSphere); // Set the dictionary as the model tag property model.Tag = tagDictionary; return model;
单元碰撞检测
每个单元都有一个包围盒和一个包围球,它们已经在前面被动画模型内容处理器创建了,现在你可以进行一些碰撞检测了。为了让事情变得简单点,我只进行两个检测。第一个是射线与单元的碰撞检测,用来检查子弹是否击中单元。第二个判断单元是否在相机的视锥体中,用来避免绘制不在视野中的单元。
要检查射线与单元的碰撞,要使用AABB的包围盒。这种情况中,使用AABB的精度比包围球高。注意你需要对AABB设置与单元一样的变换(平移和旋转)。并且你还要确保模型应与世界坐标系对齐才能使用AABB。
要实现上面的检测,你可以变换射线而不是模型的AABB,这样就可以保证AABB是与世界坐标轴对齐的。下面是TerrainUnit 类中的BoxIntersects方法的代码,用来进行射线与单元的AABB的碰撞检测:
public float? BoxIntersects(Ray ray) { Matrix inverseTransform = Matrix.Invert(Transformation.Matrix); ray.Position = Vector3.Transform(ray.Position,inverseTransform); ray.Direction = Vector3.TransformNormal(ray.Direction,inverseTransform); return animatedModel.BoundingBox.Intersects(ray); }
在BoxIntersects方法中你首先计算变换矩阵的逆矩阵,然后使用这个逆矩阵变换射线的位置和方向。你需要使用XNA中Vector3的Transform方法变换射线的初始位置,TransformNormal方法变换射线的方向。之后你就可以进行碰撞检测了。
现在,使用单元的包围球检测单元是否在相机的视锥体中。这种情况中,使用包围球更加简单而且精度也不重要。你只需使用XNA的BoundingSphere类的Intersects方法进行这种检测:
boundingSphere.Intersects(activeCamera.Frustum);
最后,只要单元在移动就要更新包围球。要更新单元的包围球你只需平移它就可以了,因为球没有朝向。下面是UpdateCollision 方法的代码:
private void UpdateCollision() { // Update bounding sphere boundingSphere = animatedModel.BoundingSphere; boundingSphere.Center += Transformation.Translate; needUpdateCollision = false; }
受到伤害
要让单元可以受到伤害,你需要创建一个ReceiveDamage方法,这个方法将伤害值作为参数。下面是代码:
public virtual void ReceiveDamage(int damageValue) { life = Math.Max(0, life - damageValue); if (life == 0) isDead = true; }
当单元的生命值降到0,isDead变量为true。这种情况下,你就不要更新这个单元了。ReceiveDamage方法是虚拟方法,可以让从TerrainUnit类继承的单元重写这个方法,例如,添加一个死亡时的动画。
改变动画
每次当单元改变当前动作(或状态)时你都要改变动画。例如,模型空闲时的动画与奔跑时的动画就不一样。单元的动画模型(AnimatedModel类)包含一个数组存储所有的动画。你可以手动改变动画,但要做到这点,你需要遍历所有动画找到所需的动画。这一步是必须的,因为你不知道单元包含哪些动画以及动画以何种顺序存储。
要让动画的切换变得简单点,你可以为单元动画创建一个枚举,每个枚举以动画模型存储的顺序保存动画列表。例如,Player类有一个叫做PlayerAnimations的枚举,Enemy类有一个叫做EnemyAnimations的枚举,如下面的代码所示:
public enum PlayerAnimations { Idle = 0, Run, Aim, Shoot } public enum EnemyAnimations { Idle = 0, Run, Bite, TakeDamage, Die }
你使用这个枚举改变模型的当前动画。你需要在TerrainUnit类中创建SetAnimation方法改变单元的动画。在SetAnimation方法中,你使用一个整数值设置模型动画,这个整数值是AnimatedModel 类中的动画数组的索引。但是,因为你不知道动画的索引,所以这个方法是protected,只有从TerrainUnit 类继承的类(Player和Enemy)可以使用它。这样,你就可以在 Player和Enemy类中使用PlayerAnimations 和EnemyAnimations枚举改变动画模型。
下面是SetAnimation方法的代码:
protected void SetAnimation(int animationId,bool reset, bool enableLoop, bool waitFinish) { if (reset || currentAnimationId != animationId) { if (waitFinish && !AnimatedModel.IsAnimationFinished) return; AnimatedModel.ActiveAnimation = AnimatedModel.Animations[animationId]; AnimatedModel.EnableAnimationLoop = enableLoop; currentAnimationId = animationId; } }
SetAnimation 方法的其他参数可以让动画复位,循环或在动画放完前不切换到其他动画。无论何时设置一个动画,它的identifier都存储在currentAnimationId变量中用来阻止当前动画被复位,除非你设置其他参数为true强制复位。下面是Player 类的SetAnimation方法的代码:
// Player class public class Player : TerrainUnit { ... public void SetAnimation(PlayerAnimations animation,bool reset, bool enableLoop, bool waitFinish) { SetAnimation((int)animation, reset, enableLoop, waitFinish); } }
下面是Enemy类的SetAnimation方法的代码:
// Enemy class public class Enemy : TerrainUnit { ... public void SetAnimation(EnemyAnimations animation,bool reset, bool enableLoop, bool waitFinish) { SetAnimation((int)animation, reset, enableLoop, waitFinish); } }
SetAnimation方法可以很容易地切换单元的动画并保证设置可用的动画。下面的代码展示了如何改变动画:
player.SetAnimation(PlayerAnimations.Idle, false, true, false); enemy.SetAnimation(EnemyAnimations.Run, false, true, false);
绘制单元
要绘制单元你需要调用单元动画模型的Draw方法。因为所有的单元变换都是直接存储在动画模型中的,所以你无需再设置其他东西了。下面是TerrainUnit 类的Draw方法代码:
public override void Draw(GameTime time) { animatedModel.Draw(time); }