一、说在前面的话
最近在做项目时,使用了久违多年的技术LibGDX。想想当前对做游戏的憧憬,感觉如果自己能做一款游戏那就酷毙了😁。但是游戏对于美工硬性要求特别高,所以就只能通过反编译swf小游戏移植到Android上小打小闹的玩玩。最近公司想通过LibGDX Spine技术实现一些炫酷的动效交互,所以让我们又有了重逢的机会😌。
在Android上开发界面时,让我们不得不提到的问题:多分辨率适配的问题。好在LibGDX提供了一个方便的处理屏幕适配问题,便是视口(Viewport)。
视口(Viewport) 负责管理游戏相机并处理世界(代码/逻辑中所认为的内容显示边界)坐标与实际屏幕坐标之间的映射。视口字面上意思为眼睛视觉上能够看到的口(一般来说就是指手机屏幕的显示),在程序代码中指的是显示内容的逻辑世界边界(宽高),是给不同尺寸手机屏幕统一定义的一个固定的虚拟屏幕尺寸。视口通常结合舞台一起使用,例如给舞台设置一个宽高为 720 * 1080 的伸展视口,则在代码/逻辑中舞台/屏幕宽高就可以当做是固定不变的 720 * 1080,即舞台/屏幕左下角坐标为 (0, 0),右上角坐标为 (720, 1080),最终绘制到不同尺寸的真正手机屏幕上时对 720 * 1080的绘制结果通过竖直和水平方向的适当缩放使之刚好显示到屏幕上,这样就达到了同样的一套程序逻辑在不同尺寸的屏幕上尽可能地保证了一致的显示效果。
好吧,上面我们已经了解了基本概念,那我们就正式开始吧
二、进入主题
友情提醒:以下的概念和代码都是基于gdx:1.9.10版本,如果在验证过程中有所不同,请更新到这个版本
1、视口(Viewport)的类型
视图的继承关系:
LibGDX中主要有以下3中类型视口,它们统一继承自抽象视口类Viewport:
(1)ExtendViewport(延伸视口)
视口保持和原屏幕相同的宽高比先适配其中一个方向(宽或高),然后另一个方向进行延伸。视口有可能延伸到屏幕外面导致部分内容不能显示。
它的效果和FitViewport效果很相识,不过它会根据屏幕做单方向延伸。其延伸的代码逻辑如下:
//Scaling.fit会在下文中把具体代码逻辑贴出来
Vector2 scaled = Scaling.fit.apply(worldWidth, worldHeight, screenWidth, screenHeight);
// Extend in the short direction.
int viewportWidth = Math.round(scaled.x);
int viewportHeight = Math.round(scaled.y);
if (viewportWidth < screenWidth) {
float toViewportSpace = viewportHeight / worldHeight;
float toWorldSpace = worldHeight / viewportHeight;
float lengthen = (screenWidth - viewportWidth) * toWorldSpace;
if (maxWorldWidth > 0) lengthen = Math.min(lengthen, maxWorldWidth - minWorldWidth);
worldWidth += lengthen;
viewportWidth += Math.round(lengthen * toViewportSpace);
} else if (viewportHeight < screenHeight) {
float toViewportSpace = viewportWidth / worldWidth;
float toWorldSpace = worldWidth / viewportWidth;
float lengthen = (screenHeight - viewportHeight) * toWorldSpace;
if (maxWorldHeight > 0) lengthen = Math.min(lengthen, maxWorldHeight - minWorldHeight);
worldHeight += lengthen;
viewportHeight += Math.round(lengthen * toViewportSpace);
}
setWorldSize(worldWidth, worldHeight);
所以通过以上代码可以看出,ExtendViewport(延伸视口) 是以先适配宽度延伸高度为主,其次才是相反。
(2)ScreenViewport(屏幕视口)
屏幕视口的世界尺寸基于原屏幕尺寸(宽高比相同)。默认 1 个世界单位 == 1 个屏幕像素,但这个比例关系可以通过 ScreenViewport 类中的方法(setUnitsPerPixel)进行修改。
其代码逻辑为:
setWorldSize(screenWidth * unitsPerPixel, screenHeight * unitsPerPixel);
是不是看起来特别简单,所以这个视图窗口一般不建议使用,除非你通过屏幕动态计算每个演员的大小,以便适配当前屏幕。
(3)ScalingViewport(缩放视口)
缩放视口顾名思义就是将世界尺寸的水平或竖直方向进行相应的缩放(缩放比可能不同)以适配屏幕的宽高。创建缩放视口实例时需要指定缩放方式(枚举类型 Scaling)。
ScalingViewport 主要有以下几种缩放方式:
- Scaling.fit: 保持宽高比例不变将,世界整个缩放到实际屏幕中(相当于最大限度使之内嵌在屏幕中),如果有剩余没有填满屏幕的空间将出现黑边(世界宽高比和实际屏幕宽高比不一致时出现)。
- Scaling.fill: 保持宽高比例不变,将世界进行延伸使之能够填充满整个屏幕,有可能世界的其中一部分在屏幕外面(世界宽高比和实际屏幕宽高比不一致时出现)。
- Scaling.stretch: 不保持宽高比例(水平和竖直方向的缩放比例可以不同),将整个世界恰好缩放到屏幕中。
- Scaling.none: 保持宽高比例不变,使用固定的世界尺寸,并且不进行任何缩放(世界可能没有填充满屏幕,也可能有一部分在屏幕外面)。
对于 Scaling.fit,Scaling.fill,Scaling.stretch 的 ScalingViewport 缩放类型,LibGDX 中给出了便捷的 ScalingViewport 的子类进行实现,分别对应 FitViewport,FillViewport,StretchViewport。
其代码实现方式如下:
Vector2 scaled = scaling.apply(getWorldWidth(), getWorldHeight(), screenWidth, screenHeight);
int viewportWidth = Math.round(scaled.x);
int viewportHeight = Math.round(scaled.y);
其中的Scaling模式的代码逻辑如下:
/** Returns the size of the source scaled to the target. Note the same Vector2 instance is always returned and should never be
* cached. */
public Vector2 apply (float sourceWidth, float sourceHeight, float targetWidth, float targetHeight) {
switch (this) {
case fit: {
float targetRatio = targetHeight / targetWidth;
float sourceRatio = sourceHeight / sourceWidth;
float scale = targetRatio > sourceRatio ? targetWidth / sourceWidth : targetHeight / sourceHeight;
temp.x = sourceWidth * scale;
temp.y = sourceHeight * scale;
break;
}
case fill: {
float targetRatio = targetHeight / targetWidth;
float sourceRatio = sourceHeight / sourceWidth;
float scale = targetRatio < sourceRatio ? targetWidth / sourceWidth : targetHeight / sourceHeight;
temp.x = sourceWidth * scale;
temp.y = sourceHeight * scale;
break;
}
case fillX: {
float scale = targetWidth / sourceWidth;
temp.x = sourceWidth * scale;
temp.y = sourceHeight * scale;
break;
}
case fillY: {
float scale = targetHeight / sourceHeight;
temp.x = sourceWidth * scale;
temp.y = sourceHeight * scale;
break;
}
case stretch:
temp.x = targetWidth;
temp.y = targetHeight;
break;
case stretchX:
temp.x = targetWidth;
temp.y = sourceHeight;
break;
case stretchY:
temp.x = sourceWidth;
temp.y = targetHeight;
break;
case none:
temp.x = sourceWidth;
temp.y = sourceHeight;
break;
}
return temp;
}
这个类型也是我们用的最多的模式了。
自此视口(Viewport) 的基本概念都已经讲述完成了,下面就让我们愉快的从项目中使用吧
2、在项目中实际使用
我们就以下图来进行布局,来看一下我们具体怎么实现的。
不过在实现这个布局时,首先有几个硬性要求 :
- 保证下半部的12宫格在任何屏幕比例上不能做变形(保证都是正方形展示),且上半部的功能按钮不能做变形。
- 唯一可以做延伸的就是顶部的地图,可以在不同屏幕比例时做适当的衍生,
- 必须保证整个屏幕正常填充,且不得变形。
那就让我们开始吧
(1)创建承载游戏窗口的界面
如果我们是使用Activity去承载整个界面窗口,那是完全没有问题的,直接这样写就可以:
public class MainActivity extends AndroidApplication {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AndroidApplicationConfiguration config = new AndroidApplicationConfiguration();
config.numSamples = 2;
initialize(new MyApplicationListener());
}
}
其中 MyApplicationListener需要继承ApplicationListener或者ApplicationAdapter,亦或者Game即可。
但是如果我们是使用Fragment去承载就有点麻烦了,这个麻烦主要出现在LibGDX中使用的Fragment(AndroidFragmentApplication)是support.v4的,而且最新的版本:gdx:1.9.10也同样是support.v4,对于当前项目引入的AndroidX的库,就很麻烦了。不过没关系,我们只需要把AndroidFragmentApplication拷贝一份,创建一个包名为:com.badlogic.gdx.backends.android,名字为:AndroidFragmentXApplication类,把其中类中的Fragment的引入路径改为androidx.fragment.app.Fragment,即可
(2)应用演员(Actor)到场景(Screen)和舞台(Stage)
在这里就需要使用到了上面描述到的视口(Viewport),是不是很惊喜,是不是很意外。😁
我们需要先创建Screen,代码如下:
public class GzkGameScreen extends ScreenAdapter {
//操作功能区舞台
private GzkGameStage mGameStage;
//地图特效区舞台
private GzkSpecialEffectsStage mSpecialEffectsStage;
...
private void init() {
//初始化操作功能区并设置使用的视图窗口为StretchViewport
mGameStage = new GzkGameStage(mMainGame,
new StretchViewport(mMainGame.getWorldWidth(), mMainGame.getWorldHeight()));
//初始化地图特效区并设置使用的视图窗口为ScalingViewport
mSpecialEffectsStage = new GzkSpecialEffectsStage(mMainGame,
new ScalingViewport(Scaling.fillX, mMainGame.getWorldWidth(), mGameStage.getTopGroupHeight()));
//这里是为了让地图特效区和操作功能区的顶部对其
ScalingViewport effectsStageViewport = (ScalingViewport) mSpecialEffectsStage.getViewport();
effectsStageViewport.setScreenY(mGameStage.getViewport().getScreenHeight() - effectsStageViewport.getScreenHeight());
}
...
@Override
public void render(float delta) {
super.render(delta);
// 使用背景颜色清屏
Gdx.gl.glClearColor(BarracksRes.BG_RGBA.r, BarracksRes.BG_RGBA.g,
BarracksRes.BG_RGBA.b, BarracksRes.BG_RGBA.a);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT |
(Gdx.graphics.getBufferFormat().coverageSampling ? GL20.GL_COVERAGE_BUFFER_BIT_NV : 0));
//应用当前视图窗口到相机中,这里传入了一个false默认,是为了让它们顶部对齐
mSpecialEffectsStage.getViewport().apply(false);
mSpecialEffectsStage.act();
mSpecialEffectsStage.draw();
mGameStage.getViewport().apply();
mGameStage.act();
mGameStage.draw();
}
...
}
从上述代码中,可以看出创建了两个Stage,而且它们使用的视图也不同。GzkGameStage使用了StretchViewport,在设置它的宽和高时,我们是有一个小技巧的。首先我们是以720 * 1080为基准做的,然后在实际设置视图大小时,是通过动态计算的。代码如下:
worldWidth = GzkRes.FIX_WORLD_WIDTH;
worldHeight = Gdx.graphics.getHeight() * worldWidth / Gdx.graphics.getWidth();
也就是说我们宽度是固定的,然后高度通过实际的屏幕分辨率去计算。这样在设置到StretchViewport中时,这样就能防止拉伸或者变形。
而GzkSpecialEffectsStage使用的是ScalingViewport(Scaling.fillX),这是因为它的宽度也是确定的,而高度是通过mGameStage.getTopGroupHeight()获取的,它也是通过计算得到的,故而不会变形,具体怎么计算。请继续往下看。
我们先看一下GzkGameStage中的代码逻辑:
public class GzkGameStage extends BaseStage implements OnStartDragListener,
ItemTouchHelper.Callback, TopGroup.ITopGroupListener {
...
if (DEBUG) {
Log.d(TAG, "init() called");
}
mItemTouchHelper = new ItemTouchHelper(this, this);
//以下布局都是从下往上布局的
//初始化底部区域
BottomGroup bottomGroup = new BottomGroup(getMainGame());
addActor(bottomGroup);
//初始化中间区域
mMiddleGroup = new MiddleGroup(getMainGame(), this);
mMiddleGroup.setY(bottomGroup.getY() + bottomGroup.getHeight() + 10);
addActor(mMiddleGroup);
//初始化顶部区域
float y = mMiddleGroup.getY() + mMiddleGroup.getHeight();
mTopGroup = new TopGroup(getMainGame(), getMainGame().getWorldHeight() - y);
mTopGroup.setTopGroupListener(this);
mTopGroup.setY(y);
addActor(mTopGroup);
//初始化用于移动英雄的演员
mMoveCardHeroActor = new CardHeroActor(getMainGame());
mMoveCardHeroActor.setBgImageVisible(false);
mMoveCardHeroActor.setVisible(false);
addActor(mMoveCardHeroActor);
}
...
从代码中,我们可以看出,是从下往上开始布局的,LibGDX坐标是从左下角开始的,而Android是从左上角开始的,这一点在开发过程中一定要切记。我们优先保证底部区域和中部区域的高度不被拉伸,剩余的所有空间都给顶部区域,这样就能保证,在不同分辨率下很好的适配屏幕了。
至于BottomGroup和MiddleGroup距离的内部距离,我是以720 *1280为基础设置坐标和大小,在这里需要提醒的一点:
建议大家在布局时,还是需要依据一个基准设置大小的,这样在后期替换图片或者高清资源时,不会导致错乱。下面我就贴出一部分样咧代码:
public class CardHeroActor extends BaseCollisionGroup {
private static final boolean DEBUG = BuildConfig.DEBUG;
private static final String TAG = DEBUG ? "CardHeroActor" : "";
private static final int DISPLAY_REWARD_COIN_INTERVAL = 4000;
private static final int CARD_GROUP_SIZE = 144;
private static final int CARD_HERO_HEIGHT = 140;
private Image mHeroBgImage;
private Image mHeroMidBgImage;
private Image mHeroFrameTopImage;
private Image mFrameEmptyImage;
private Image mHeroImage;
private HeroGradeDisplay mHeroGradeDisplay;
private GameData.HeroData mHeroData;
//设置英雄的卡片位置
private int mCardHeroPosition;
private OnStartDragListener mStartDragListener;
//创建英雄的时间
private long mPreShowRewardCoinTime;
public CardHeroActor(BarracksMainGame mainGame) {
this(mainGame, null);
}
CardHeroActor(BarracksMainGame mainGame, OnStartDragListener listener) {
super(mainGame);
mStartDragListener = listener;
init();
initListener();
}
private void init() {
if (DEBUG) {
Log.d(TAG, "init() called");
}
float viewSize = CARD_GROUP_SIZE;
setSize(viewSize, viewSize);
TextureAtlas textureAtlas = getMainGame().getTextureAtlas();
mFrameEmptyImage = new Image(textureAtlas.findRegion(BarracksRes.AtlasNames.CAMP_FRAME_EMPTY_IMG));
mHeroFrameTopImage = new Image(textureAtlas.findRegion(BarracksRes.AtlasNames.CAMP_FRAME_TOP_IMG));
mHeroMidBgImage = new Image(textureAtlas.findRegion(BarracksRes.AtlasNames.CAMP_FRAME_MID_IMG));
mHeroBgImage = new Image(textureAtlas.findRegion(BarracksRes.AtlasNames.CAMP_FRAME_BOTTOM_IMG));
mHeroBgImage.setSize(viewSize, viewSize);
mFrameEmptyImage.setSize(viewSize, viewSize);
mHeroMidBgImage.setSize(viewSize, CARD_HERO_HEIGHT);
mHeroFrameTopImage.setSize(mHeroMidBgImage.getWidth(), mHeroMidBgImage.getHeight());
mHeroMidBgImage.setY(getHeight() - mHeroMidBgImage.getHeight());
mHeroFrameTopImage.setY(mHeroMidBgImage.getY());
addActor(mFrameEmptyImage);
//初始化英雄等级的布局展示
mHeroGradeDisplay = new HeroGradeDisplay(getMainGame());
checkHeroState();
}
至于顶部区域的布局,X的坐标还是按照基准去做,Y坐标就需要动态去计算了,根据当前Group高度去动态计算了。
至此大致的流程已经讲解完毕,下面附上一个成品的效果图:
三、总结
LibGDX确实为我们在屏幕适配上提供了很多的便利,我们在做项目时,可根据需求选相应的视口(Viewport),不过我们用的比较多的还是ScalingViewport(缩放视口),但是实际项目开发中还是需要根据实际的需求,至此**视口(Viewport)**介绍完毕