12.5.1 TerrainUnit类

对于每个诸如玩家、武器、敌人(NPC)等的单元(unit)类型,你都需要在GameLogic命名空间创建一个类。一个游戏单元需要存储它的属性(例如:速度,生命值,伤害等)和逻辑(状态和行为)。除了游戏单元的逻辑,你还要在GameScreen 类中构建主程序的逻辑,这个逻辑定义了游戏控制和单元如何更新和绘制。你将在本章最后创建GameScreen类。

在你开始创建游戏逻辑类之前,让我们回顾一下前面所说的游戏功能:

从上面的描述中可以看出,玩家和敌人共享某些属性和行为,诸如生命值、在地形上移动、可以导致和受到伤害,是一个动画模型等。正因为如此,所以你将创建一个包含这些共有属性的基类,而玩家类和敌人类从这个基类继承。

 

本节你将创建一个游戏单元的基类,这些游戏单元都是动画模型,可以在地形上移动,可以导致和受到伤害。在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

图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

图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); 
}

发布时间:2009/6/25 8:15:36  阅读次数:6237

2006 - 2024,推荐分辨率1024*768以上,推荐浏览器Chrome、Edge等现代浏览器,截止2021年12月5日的访问次数:1872万9823 站长邮箱

沪ICP备18037240号-1

沪公网安备 31011002002865号