LibGDX重建Flappy Bird——(5) 添加Box2D物理仿真和游戏逻辑

原文链接:https://my.oschina.net/u/2432369/blog/610408

  本章源码链接:http://pan.baidu.com/s/1o6Tt6VS密码:dvsc

  在本章我们将为Flappy Bird添加BOX2D物理仿真,BOX2D物理仿真可以模拟现实世界物体的属性,如重力、速度、摩擦等等。在BOX2D中存在三种物理对象,分别是StaticBody、KinematicBody和DynamicBody,其中静态物体StaticBody只能模拟那些固定不动的对象且不能与StaticBody和KinematicBody类型物理对象发生碰撞。KinematicBody和StaticBody类似,但是他可以模拟速度,让对象按照一定的速度运动,但他也不能与StaticBody和KinematicBody类型物理对象放生碰撞。最后一种DynamicBody对象既可以模拟所有物理属性,也可以与上述三种类型物体的任意一种发生碰撞。

  首先,我们为三个对象添加一个共同的方法,既然是共同的方法,那么我们就应该添加到三者公用的父类中,然后再子类中重写该方法。

  修改AbstractGameObject:

package com.art.zok.flappybird.game.object;

import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.physics.box2d.Body;
import com.badlogic.gdx.physics.box2d.World;

public abstract class AbstractGameObject {
	
	public Vector2 position;
	public Vector2 dimension;
	public Vector2 origin;
	public Vector2 scale;
	public float rotation;
	public Body body;
	
	public AbstractGameObject() {
		position = new Vector2();
		dimension = new Vector2(1, 1);
		origin = new Vector2();
		scale = new Vector2(1, 1);
		rotation = 0;
	}
	
	public void beginToSimulate(World world) {
		
	}
	
	public void update(float deltaTime) {
		if(body != null) {
			position.set(body.getPosition());
			rotation = body.getAngle() * MathUtils.radiansToDegrees;
		}
	}
	
	public abstract void render(SpriteBatch batch);
}

   上面我们为AbstractGameObject类添加了一个公有的beginToSimulate()方法。我们在每个子类重写该方法,并在其中添加创建BOX2D物理世界的Body对象代码。

为对象创建Body
  首先,我们为Bird对象重写beginToSimulate()方法:

@Override
	public void beginToSimulate(World world) {
		BodyDef bodyDef = new BodyDef();
		bodyDef.type = BodyType.DynamicBody; // 必须是动态对象
		bodyDef.fixedRotation = true; 	     // 固定角度
		bodyDef.position.set(position);

		body = world.createBody(bodyDef);
		body.setUserData(this);

		PolygonShape shape = new PolygonShape();
		shape.setAsBox(dimension.x / 2, dimension.y / 2);

		shape.setRadius(-0.4f);

		FixtureDef fixtureDef = new FixtureDef();
		fixtureDef.density = 0.1f;
		fixtureDef.friction = 0f;
		fixtureDef.shape = shape;
		body.createFixture(fixtureDef);
	}

  这里我们首先介绍一下World参数,World表示BOX2D中模拟世界,所有在该世界创建的Body对象都会作为模拟对象,因此,只要我们要使用BOX2D那么绝离不开World。接下来是BodyDef,BodyDef是创建Body对象所要使用的参数,该参数定义了Body的类型、位置以及各种属性参数。这里我们将Bird对象定义为DynamicBody类型,fixedRotiation=ture表示我们不使用BOX2D模拟Bird对象的旋转过程,Bird对象的旋转角度一直为0。接下来我们初始化了Body的位置。下面我们使用World.createBody()创建了body对象,并将其付给成员变量body。PolygonSape描述的是BOX2D中多边形物体的形状,BOX2D规定PolygonSape必须是凸多边形,并且最多是八条边,如果是凹多边形或者大于八条边的多边形就需要使用网格化技术等等各种技术,还要我们这里并不牵扯这么复杂的问题,因为我们只需要将Bird当作四条边的矩形即可。所以我们使用了setAsBox()方法将该多边形设置为一个宽高分别为dimension.x和dimension.y,注意这里的两个参数分别是半宽和半高。setAsBox()还有一个重载方法setAsBox(hx, hy, center, angle)其中center表示矩形的中心点,该点坐标值是以position为原点的坐标系为标准的,angle表示旋转角度。所以setAsBox(hx, hy)默认以(0, 0)为中心点,旋转角度为0。setRadius(-0.4f)是为避免发生隧道效应而设置的,该值可以理解为碰撞时的最大碰撞深度,我们设定为负值只是为了让游戏更加逼真而已,也降低了一定的难度。FixtureDef定义了Body的物理属性参数,如密度、摩擦力和形状等等。接下来我们使用该参数为Body创建Fixture对象。还有Body.setUserData()可以为Body对象设定一个用户自定义数据,我们这里传递了this,这样我们就可以循环访问了。   为Land创建重写beginToSimulate():
@Override
	public void beginToSimulate(World world) {
		BodyDef bodyDef = new BodyDef();
		bodyDef.type = BodyType.KinematicBody;			// 运动物体
		bodyDef.position.set(position);					// 初始位置
		body = world.createBody(bodyDef);
		body.setUserData(this);
		
		PolygonShape shape = new PolygonShape();
		shape.setAsBox(dimension.x * 1.5f, dimension.y / 2, 
				new Vector2(dimension.x * 1.5f, dimension.y / 2), 0);	// 矩形边界
		shape.setRadius(-0.1f);								
		
		FixtureDef fixtureDef = new FixtureDef();
		fixtureDef.shape = shape;
		body.createFixture(fixtureDef);
		body.setLinearVelocity(LAND_VELOCITY, 0);
	}
  Land对象的Body和Bird对象的Body存在两点区别,第一Land对象的Body属于KinematicBody物体,所以在fixtureDef我们没有定义它的密度和摩擦力等等;第二我们使用setLinearVelocity为Body设定了一个水平向左的速度。
  为Pipe类重写beginToSimulate()方法:
@Override
		public void beginToSimulate(World world) {
			// down
			BodyDef bodyDef = new BodyDef();
			bodyDef.type = BodyType.KinematicBody;
			bodyDef.position.set(position);
			
			Body b = world.createBody(bodyDef);
			
			PolygonShape shape = new PolygonShape();
			shape.setAsBox(dimension.x / 2, dnPipeHeight / 2,
					new Vector2(dimension.x / 2, dnPipeHeight / 2), 0);
			shape.setRadius(-0.1f);
			
			FixtureDef fixtureDefDown = new FixtureDef();
			fixtureDefDown.shape = shape;
			
			b.createFixture(fixtureDefDown);
			b.setLinearVelocity(PIPE_VELOCITY, 0);
			body = b;
			
			// up
			bodyDef.position.set(position.x, position.y + dnPipeHeight + CHANNEL_HEIGHT);
			b = world.createBody(bodyDef);
			shape.setAsBox(dimension.x / 2, (dimension.y - dnPipeHeight - CHANNEL_HEIGHT) / 2, 
					      new Vector2(dimension.x / 2, (dimension.y - dnPipeHeight - CHANNEL_HEIGHT) / 2), 0);
			shape.setRadius(-0.1f);
			FixtureDef fixtureDefUp = new FixtureDef();
			fixtureDefUp.shape = shape;
			b.setLinearVelocity(PIPE_VELOCITY, 0);
			b.createFixture(fixtureDefUp);
		}
  同样Pipe类也是KinematicBody类型物体,不同的是,我们这里创建了两个body,这是因为我们在同一Pipe对象中绑定了上下两个游戏中的管子或者说是柱子。然而,成员变量body中保存的是下面(down)那个管子的Body对象,这样做是因为我们的Pipe对象的位置和该Body对象的位置相同,所以我们总是以下面(down)这个Body对象为标准。   既然Pipe对象在游戏未开始的时候不会有任何动作,而且一开始我们并不会进行BOX2D 模拟,所以只要一开始我们不在update()中调用World.step(),那么Pipe的Body对象并不会得到更新,因此我们为何不在Pipe的初始化方法直接调用beginToSimulate()方法完成Body的创建,但是由于beginToSimulate()需要World对象作为参数,所以我们必须还要为Pipe或者Pipes维护一个World类型成员变量。为了简单期间我们在Pipes外部类维护一个World成员变量world即可,当在Pipe的初始化方法init()调用beginToSimulate()时,我们只需将Pipes.this.world作为该方法的参数即可。   根据上述要求,我们修改Pipes类和Pipe内部类的构造函数:
...
public class Pipes extends AbstractGameObject {
        ....
	private World word;
	
	public Pipes(World world) {
                ...
		init(world);
	}
	
	public void init(World world) {
		this.word = world;
                ...
	}
	...
	public class Pipe extends AbstractGameObject {
		 ...
		public void init() {
			...
			beginToSimulate(Pipes.this.word);
		}
                ...
	}
}
  上面我们为Pipes维护了一个World类型成员变量world,我们发现,我们是在Pipes.init()方法中初始化world变量而不是在构造方法中,这样做是因为每次调用init()方法重新开始游戏时我们的WorldController类都会重建World对象。
使用BOX2D仿真
  开始使用BOX2D仿真之前我们还需要准备一下,首先为WorldController维护一个world对象并添加一个isStart成员变量:
...
public class WorldController extends InputAdapter implements Disposable {  
    private static final String TAG =   
            WorldController.class.getName();  
    
    World world;
    boolean isStart;
    ...
    private void init() {
    	isStart = false;
    	initWorld();
        ...
        Gdx.input.setInputProcessor(this);
    }  
    
    private void initWorld() {
    	if(world != null) world.dispose();
    	world = new World(new Vector2(0, -9.8f), false);
    }
    ...
    public void update(float deltaTime) {
    	
    	// 如果开始游戏则开始模拟
    	if(isStart) {
    		world.step(deltaTime, 8, 3);
    	}
    	
    	bird.update(deltaTime);
    	...
    }  

    @Override
    public boolean touchDown(int screenX, int screenY, int pointer, int button) {
    	if(!isStart) {
    		isStart = true;
    		bird.beginToSimulate(world);
    		land.beginToSimulate(world);
    		//pipes.beginToSimulate(world);
    	}
    	return super.touchDown(screenX, screenY, pointer, button);
    }
    
    @Override
    public void dispose() {
    	world.dispose();
    }
}

  首先我们为WorldController添加了一个world成员和一个isStart成员变量,分别表示Box2D的世界和游戏是否开始。接下来我们在初始化方法init()中将isStart初始化为false并调用了initWorld()方法初始化world,在initWorld()中如果world成员变量不为空则销毁他并重新创建新的World对象,World类的构造函数需要两个参数,第一个表示x和y两个方向上的重力加速度,因为在Libgdx中y轴正方向是竖直向上的,所以这里的重力设置为-9.8m/s²。

  在update()方法中,我们首先判断isStart是否等于true,如果为true则表示游戏已经开始,我们必须开始进行BOX2D模拟。BOX2D要进行物理仿真则必须在每个更新循环调用World.step()方法,该方法第一个参数是步长,也就是上一帧开始渲染到该帧开始渲染的间隔时间,也称为增量时间,所以直接将deltaTime传入即可,接下来两个整型参数分别表示速度迭代次数和位置迭代次数,理论上是越高越精确,但是设置太高会严重影响物理仿真的性能,所以需要酌情设置。

 我们还为WorldController实现了Disposable接口并重写dispose()方法,并在该方法执行了world.dispose()释放内存。既然WorldController实现了Disposable接口,就说明WorldController在不需要的时候需要释放相应的内存资源,事实也正是这样,所以我们也必须在拥有WorldController实例的FlappyBirdMain类的dispose()方法中调用其dispose()方法。

@Override 
    public void dispose() { 
    	worldRenderer.dispose();
    	worldController.dispose();
    }
 还有,我们让WorldController继承了InputAdapter类,该类是LIBGDX的输入事件适配器。只要我们想响应键盘和鼠标的输入或者触摸屏的触摸,我们只需要实现并创建一个InputAdapter实例,然后将其作为参数调用Gdx.input.setInputProcessor()方法,这样就相当告诉LibGDX,我们想让当前所有输入事件由该类实例进行响应,然后我们只需要重写相应的方法便可响应输入事件。这里我们重写了touchDown方法,该方法既可以测试鼠标的点击事件也可以测试触摸屏的触摸事件,前两个参数表示点击或者触摸事件的屏幕位置(单位是像素)并且 坐标系是以左上角为坐标原点的;如果是屏幕触摸则第三个参数表示是触摸的第几个点,这里也表明LibGDX是支持多点触控的;第四个参数等于Buttons类的常量之一,如果是触摸事件第四个参数始终等于Buttons.LEFT;该方法的返回值表示是否结束touchDown事件,如果返回true则LibGDX认为已经完成该事件的处理,则不会在继续调用下一个输入适配器的同一个方法。在该方法中,如果isStart等于false,我们首先将为isStart赋值为true表明开始物理仿真,即游戏开始。接下来我们分别为Bird对象和Land对象调用beginToSimulate()方法创建相应的Body。

  经过上述分析,我们添加的代码一开始处于未开始状态,当鼠标点击窗口或者手指触摸屏幕则开始游戏。下面测试并应用:

LibGDX重建Flappy Bird——(5) 添加Box2D物理仿真和游戏逻辑   我们发现没开始之前一切正常,一旦点击鼠标或者触摸屏幕,稍等几秒钟就出现上图的错误。我们发现,Land对象并不能正确的衔接,我们猜测是Land.wrapLand()方法出现了错误。通过排查,的确是wrapLand()出现了错误,因为我们只考虑了没有开始模拟时Land的转换过程,而创建Body之后,body!=null的情况,也是就是现在已经开始模拟的情况我们并没处理或者说没有考虑到。现在打开Land类并将wrapLand()方法修改如下:
private void wrapLand() {
		if (leftMoveDist >= viewWidth) {
			position.x += viewWidth;
			leftMoveDist = 0;
			if(body != null) {
				body.setTransform(position, 0);
			}
		}
	}
  我们引入一个新方法B ody.setTransform()。BOX2D允许 kinematicBody类型物体使用该方法进行2D空间变换,该方法的第一个参数是新位置的坐标,第二个参数是旋转的角度,这里我们分别设置为position和0。
  接下来我们可以重新测试:
LibGDX重建Flappy Bird——(5) 添加Box2D物理仿真和游戏逻辑

  现在可以观察到各个对象和背景都能正常工作了,但是如果当你切换到其他窗口等待几秒钟之后在返回到该应用窗口,就会发现另有问题:

LibGDX重建Flappy Bird——(5) 添加Box2D物理仿真和游戏逻辑

  上面这个问题是因为当系统切换到其他窗口后,本应用将进入暂停状态,当重新回到应用后又恢复执行,但是恢复后执行第一帧渲染时的增量时间deltaTime由于重新加载资源导致事件变得特别长(大概是0.19秒左右)造成的。解决该问题我们只需要稍微修改一下FlappyBirdMain的render方法即可:

@Override 
    public void render() { 
    	if(!paused) {
	    	// 增量时间不能大于0.018秒
    		float deltaTime = Math.min(Gdx.graphics.getDeltaTime(), 0.018f);
	    	worldController.update(deltaTime);
    	}
        ...
    }
BOX2D的调试渲染   BOX2D提供了一个调试渲染器,我们可以使用该渲染器观察Body的运行状态是否符合预期。使用该功能首先在WorldRenderer类中添加一个Box2DDebugRenderer类型成员变量debugRenderer,然后在init()方法中初始化并在在render()方法中调用debugRenderer.render()方法:
...
public class WorldRenderer implements Disposable {  
      
    ... 
    private Box2DDebugRenderer debugRenderer;
    ...
    private void init() {
    	batch = new SpriteBatch();
    	debugRenderer = new Box2DDebugRenderer();
        ...
    }  
      
    public void render() { 
    	//renderWorld(batch);
    	debugRenderer.render(worldController.world, camera.projection);
    }  
    ...
}

 debugRenderer需要两个参数,第一个是需要渲染的BOX2D世界对象world,第二个是视口的投影矩阵。由于BOX2D的调试渲染只绘制了Body对象的轮廓,所以为了避免游戏对象渲染的纹理干扰调试渲染,所以我们屏蔽renderWorld(batch)方法的调用。为了更清晰可见,我们应该将清屏颜色设置为黑色(0,0,0,1)。完成上述修改后重新运行:

LibGDX重建Flappy Bird——(5) 添加Box2D物理仿真和游戏逻辑

  观察截图可以发现Bird对象的位置和Land、Pipe对象均有相交,这就是创建Body时调用shape.setRadius()的结果。调试完成需要将上述添加代码注释掉,如果觉得麻烦你也可以创建一个boolean类型的常量进行条件判断是否调试渲染,这样修改起来则方便许多。

创建Bird对象跳跃逻辑

  首先Bird跳跃有两种运动过程,第一是上下快速平移;第二是绕着中心点旋转。因为我们使用的是BOX2D模拟Bird的运动过程,因此第一种运动过程很容易实现:我们只需要在每次点击时为Bird附加一个竖直向上的速度,这时,因为有重力加速度的作用,Bird对象会开始经历一个先减速上升而后加速下降的过程,这正符合我们的预期,至于仿真度的高度那就参数大小问题了。我们仔细观察原游戏的Bird运动过程,我们就可以发现Bird的旋转过程是跟速度有关系的,当速度小于某个值时Bird以较慢的速度顺时针旋转,速度大于某个值时Bird以较快的速度逆时针旋转,并且Bird的旋转角度具有确定的范围,所以我们可以设定一个速度阀值,然后个根据Bird当前的速度分成成两种情况对Bird对象的rotation变量进行持续更新,必须强调一点,更新Bird对象的旋转角度时,我们也必须更新Body对象,否则两者的边界矩形将不能重合,这样碰撞模拟将显得非常不真实。

  根据上述分析,我们现在来改造Bird对象,首先我们实现第一种运动状态,为Bird对象添加一个新方法setJumping():

// 触发跳跃
	public void setJumping() {
		if(body != null) {
			body.setLinearVelocity(0, 35f);
		}
	}

  在该方法中我们调用了setLinearVelocity()方法。BOX2D允许我们使用该方法为动态物体设置一个平移速度,第一个参数表示水平速度,第二个参数表示竖直方向速度,速度大于零表示向正方向平移,小于零表示向反方向平移。在LibGDX中坐标原点始终位于左下角,水平向右为x轴正方向,竖直向上为y轴正方向。   接下来实现第二个运动状态,首先为Bird添加下面几个静态常量:
public class Bird extends AbstractGameObject {
	protected static final float BIRD_MAX_FLAP_ANGLE = 20; // 逆时针旋转最大角度
	protected static final float BIRD_MAX_DROP_ANGLE = -90; // 顺时针旋转最大角度
	protected static final float FLAP_ANGLE_DRAG = 9.0f; // 逆时针旋转速度
	protected static final float BIRD_FLAP_ANGLE_POWER = 6.0f; // 顺时针旋转速度
        protected static final float SPEED_THRESHOLD = -20f;// 速度阀值
        ...
}
上面每个厂里都给出了解释,下面修改Bird.update()方法:
@Override
	public void update(float deltaTime) {
		if (body == null) {
			if (waveState == WAVE_STATE.WAVE_FALLING)
				position.y -= 0.05f;
			else if (waveState == WAVE_STATE.WAVE_RISE) {
				position.y += 0.05f;
			}
			if (position.y < min_wave_height) {
				waveState = WAVE_STATE.WAVE_RISE;
			} else if (position.y > max_wave_height) {
				waveState = WAVE_STATE.WAVE_FALLING;
			}
		} else {
			super.update(deltaTime);
			// 根据速度计算最新旋转角度
			if (body.getLinearVelocity().y < SPEED_THRESHOLD) {
				rotation -= BIRD_FLAP_ANGLE_POWER;
			} else {
				rotation += FLAP_ANGLE_DRAG;
			}
			// 限制旋轉角度在20到-90度之間
			rotation = MathUtils.clamp(rotation, BIRD_MAX_DROP_ANGLE, BIRD_MAX_FLAP_ANGLE);
			body.setTransform(position, rotation * MathUtils.degreesToRadians);
		}
	}
  在update()方法中,当body等于null则属于前面我们介绍的游戏未开始时状态,所以不用多说。当body不等null则表明游戏已经开始,我们首先调用父类update()方法,然后通过body.getLinearVelocity()方法获得body的速度矢量,接着通过竖直方向的速度和速度阈值作比较我们分成了两种情况处理,即顺时针和逆时针。在LibGDX中,逆时针旋转为正,顺时针旋转为负,所以在顺时针的情况我们为rotation每帧减去BIRD_FLAP_ANGLE_POWER度,逆时针我们为rotation每帧增加FLAP_ANGLE_DRAG度。接着我们调用MathUtils.clamp()方法将rotation现在[-90,20]度之间。最后,我们变换body对象的选择角度,因为这里我们只是变换角度,所以位置position并没有发生改变,还有,在BOX2D中角度都是以弧度表示的,所以我们需要将rotation转换为弧度,LibGDX提供了一个方便的常量MathUtils.degreesToRadians可以将角度转换为弧度。   现在Bird的行为逻辑已经实现,但是我们还需要确定在哪里调用setJumping()方法触发跳跃,前面我们介绍了WorldController重载的touchDown()方法,我们知道该方法既能处理鼠标按下事件也能处理屏幕触摸事件,所以这里非常适合调用setJumping()方法触发跳跃,下面我们就修改该方法:
@Override
    public boolean touchDown(int screenX, int screenY, int pointer, int button) {
    	if (button == Buttons.LEFT) {
			if(!isStart) {
				isStart = true;
				bird.beginToSimulate(world);
				land.beginToSimulate(world);
			}
			bird.setJumping();
		}
		return true;
    }
  到现在为止,Bird已经可以完成上面要求的行为逻辑了。我们可以启动测试一下: LibGDX重建Flappy Bird——(5) 添加Box2D物理仿真和游戏逻辑
  是不是很激动呀,哈哈哈!到现在游戏已经基本成型。上面紫红色的线框太难看了,其实这是TexturePacker类为我们提供的调试功能,我们可以打开desktop项目的main类将drawDebugOutline变量改为false,并将rebuildAtlas改为true重新生成纹理集资源再次运行应用: LibGDX重建Flappy Bird——(5) 添加Box2D物理仿真和游戏逻辑
  现在看起来是不是舒服很多!到现在本章内容就结束了,下一章我们将会完成剩下的游戏逻辑和其他一些内容。


















转载于:https://my.oschina.net/u/2432369/blog/610408

上一篇:LibGDX重建Flappy Bird——(1) 项目创建与导入


下一篇:LibGDX重建Flappy Bird——(7) 添加GUI信息