在pixi.js实现设备自适应和强制竖屏
世界Skill
问题
最近在用pixi.js
写一个2D音游,出现了关于适配的三个问题。
其一,由于游戏是竖屏模式,而某APP在IPAD下是强制横屏的,所以需要一个方式去适配。
其二,游戏本身需要保持一定的纵横比,而且在不同分辨率的屏幕下都要保证内容显示正常,即需要一个适当的缩放。
其三,并非游戏内的所有元素都是有绝对位置定位的,有些元素的位置需要根据当前实际的游戏屏幕中的可视范围来确定,比如用户控制按钮必须在屏幕可视区域内置低,计分板必须置顶等,这需要在渲染时动态计算。
分析和实现
对于这三个问题,让我们先分析一下,其本质是什么,以及有哪些方式可以运用。
强制竖屏
竖屏适配的方式有很多种,一种普遍的方法是在PAD的横屏下向两侧添加背景,将游戏画布居中,如下图。这种方式虽然可行,但却不是那么完美。
而另一种方法则是利用对容器的旋转等操作,让游戏画布始终保持一个真正意义上的竖屏,如图所示。这种方法比较完美,能最大化地利用设备的可视区域。
那么这种模式如何实现呢?关注过我之前的文章的朋友,想必知道之前我用egret.js
写过一个Galgame,它实现强制竖屏的方式是当设备横屏时,直接利用transform
的rotate
属性对canvas
容器进行变换,而后通过调整left
和top
属性来重置容器位置。进行调整后,我们获得了一个旋转过的、仍然铺满屏幕的canvas
容器,由于只是修改了容器的属性,所以容器内部绘制的场景仍然是相对不变的:
#container {
transform: rotate(90deg);
top: ${device-height};
left: 0;
}
这样做画面确实完成了适配,一切看起来很完美,但进一步操作后你会发现这个在pixi.js
中是行不通的——touch事件的位置判定出现了问题,也就是说旋转后,你给DOC(DisplayaObjectContainer)的绑定的事件无法在触碰它时被正确触发。经过查看egret
的源代码并进行分析后,我确认是egret
在旋转容器后重置了全局的touch事件判定,应该是在transform
变换后引擎对触碰位置与画面内DOC的映射出现了问题,所以需要重置,但在pixi
中,我并没有找到这个方法(也可能看漏了,有找到的请务必告诉我),所以只能用另一个方法来绕过——既然根级容器无法完成需求,我就利用引擎自身提供的Container
方法自己造一个根级容器,由于对这个容器的所有变换都是引擎内部管理的,所以并不会出现touch事件的映射错误,也就完美绕过了之前的问题。具体实现看以下代码:
const root = new PIXI.Container();
game.stage.addChild(root);
const screenRect = container.getBoundingClientRect();
renderer.resize(screenRect.width, screenRect.height);
if (window.orientation === 90 || window.orientation === -90) {
root.rotation = -Math.PI / 2;
root.y = newWidth;
} else {
root.rotation = -Math.PI / 2;
root.y = screenRect.height;
}
这个本质上,是始终保持canvas
容器全屏,然后重置renderer
的尺寸让根级的stage
容器全屏。之后构建一个root
容器作为实际的根级容器,并将其添加到stage
中作为唯一的children
。之后判断window.orientation
属性,这个属性用于判断当前设备的方向,当其为±90
时即为横屏,此时只需要将root
容器旋转90度,而后将其transform.y
进行调整。
缩放
为了强制竖屏,我们已然有了root
容器,如此一来,缩放也就变得十分简单了——只需要设置root
容器的缩放即可:
当然,如果仅仅是需要缩放,直接对
canvas
进行缩放,并始终保持renderer
的size
为游戏画布本身的绝对大小也可以。
const {width, height} = options;
const screenRect = this.container.getBoundingClientRect();
renderer.resize(screenRect.width, screenRect.height);
let offsetWidth = 0;
let offsetHeight = 0;
if (window.orientation === 90 || window.orientation === -90) {
offsetWidth = screenRect.height;
offsetHeight = screenRect.width;
} else {
offsetWidth = screenRect.width;
offsetHeight = screenRect.height;
}
const newWidth = offsetWidth;
const scale = newWidth / width;
const newHeight = height * scale;
root.scale.x = newWidth / width;
root.scale.y = newHeight / height;
这里直接对root
容器进行了缩放,这样一来,之后所有子级元素的定位布局仍然是按照游戏画布的绝对大小来的。也就是说,这一层的修改相对于后面的编程是无感知的,做到了自适应逻辑的无痛植入。我在这里实现的是fixedWidth
这种自适应模式,它表现为宽度适应屏幕,高度将会跟随宽度进行缩放。这种模式是最为常见的一种模式,但它往往也会带来一个问题——游戏缩放后的实际画布仍然有可能会超出设备可视区域,这就引出了一个概念——可视范围。
可视范围
解决了游戏画布的自适应,还有可视元素自适应的问题需要解决。如一开始分析,在游戏中有些元素是不能越出可视区域的,所以就需要在编写布局代码时拿到游戏可视区域的定义,以此为基准来动态确定元素的位置。为此,我定义了一个realScreen
的公有属性,里面存储着游戏实际的可视范围:
何为越过可视区域?比如在一个16:9的设备上,我使用了以上的强制竖屏和
fixedWidth
模式。此时倘若我设计了一个作为玩家控制器的元素,其定位为游戏画布置底,那么当设备的可视区域比例小于16:9时,这个控制器的一部分便会越出可视区域,这显然并不是我们预期的,我们需要的,应该是无论设备如何改变,控制器都始终在设备可视区域的底部。
type TScreen = {
// 可视区域的上边界,对标游戏画布尺寸
top: number;
// 可视区域的下边界,对标游戏画布尺寸
bottom: number;
// 可视区域的左边界,对标游戏画布尺寸
left: number;
// 可视区域的右边界,对标游戏画布尺寸
right: number;
// 可视区域的宽度,对标游戏画布尺寸
width: number;
// 可视区域的高度,对标游戏画布尺寸
height: number;
// 可视区域的宽度,对标设备尺寸
realWidth: number,
// 可视区域的高度,对标设备尺寸
realHeight: number,
// 设备纵横比
aspectRatio: number;
};
如此一来,后续元素便可以动态确定它们的位置了。
完整代码
我将以上三种特性集成到了一个resize
函数中,并将其放入了一个Game
类中,以下便是封装好的Game
类,使用时只需要从类的实例中获取realScreen
属性便可。
export default class Game extends PIXI.Application {
private container: HTMLElement;
private options: PIXI.ApplicationOptions;
public root: PIXI.Container;
public realScreen: TScreen;
constructor(element: string, options?: PIXI.ApplicationOptions) {
super(options);
this.options = options;
this.container = document.getElementById(element);
this.container.appendChild(this.view);
......
this.root = new PIXI.Container();
this.addChild(this.root);
}
public resize = () => {
const {width, height} = this.options;
const screenRect = this.container.getBoundingClientRect();
this.renderer.resize(screenRect.width, screenRect.height);
let offsetWidth = 0;
let offsetHeight = 0;
if (window.orientation === 90 || window.orientation === -90) {
offsetWidth = screenRect.height;
offsetHeight = screenRect.width;
} else {
offsetWidth = screenRect.width;
offsetHeight = screenRect.height;
}
const aspectRatio = offsetHeight / offsetWidth;
// fixWidth, orientation="portrait"
const newWidth = offsetWidth;
const scale = newWidth / width;
const newHeight = height * scale;
const {root} = this.layers;
root.scale.x = newWidth / width;
root.scale.y = newHeight / height;
if (window.orientation === 90 || window.orientation === -90) {
root.rotation = -Math.PI / 2;
root.y = newWidth;
} else {
root.rotation = 0;
root.y = 0;
}
this.realScreen = {
realWidth: offsetWidth,
realHeight: offsetHeight,
width,
height,
top: 0,
bottom: offsetHeight / scale,
left: 0,
right: width,
aspectRatio
};
this.realScreen.width = this.realScreen.right - this.realScreen.left;
this.realScreen.height = this.realScreen.bottom - this.realScreen.top;
}
}