在pixi.js实现设备自适应和强制竖屏

在pixi.js实现设备自适应和强制竖屏

在我的BLOG上观看获得最佳体验。


最近在用`pixi.js`写一个2D音游,出现了关于强制竖屏、缩放和动态定位这三个适配的问题,给大家分享一下心得。

问题

最近在用pixi.js写一个2D音游,出现了关于适配的三个问题。

其一,由于游戏是竖屏模式,而某APP在IPAD下是强制横屏的,所以需要一个方式去适配。

其二,游戏本身需要保持一定的纵横比,而且在不同分辨率的屏幕下都要保证内容显示正常,即需要一个适当的缩放。

其三,并非游戏内的所有元素都是有绝对位置定位的,有些元素的位置需要根据当前实际的游戏屏幕中的可视范围来确定,比如用户控制按钮必须在屏幕可视区域内置低,计分板必须置顶等,这需要在渲染时动态计算。

分析和实现

对于这三个问题,让我们先分析一下,其本质是什么,以及有哪些方式可以运用。

强制竖屏

竖屏适配的方式有很多种,一种普遍的方法是在PAD的横屏下向两侧添加背景,将游戏画布居中,如下图。这种方式虽然可行,但却不是那么完美。


而另一种方法则是利用对容器的旋转等操作,让游戏画布始终保持一个真正意义上的竖屏,如图所示。这种方法比较完美,能最大化地利用设备的可视区域。


那么这种模式如何实现呢?关注过我之前的文章的朋友,想必知道之前我用egret.js写过一个Galgame,它实现强制竖屏的方式是当设备横屏时,直接利用transformrotate属性对canvas容器进行变换,而后通过调整lefttop属性来重置容器位置。进行调整后,我们获得了一个旋转过的、仍然铺满屏幕的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进行缩放,并始终保持renderersize为游戏画布本身的绝对大小也可以。
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;
  }
}

编辑于 2017-12-02

文章被以下专栏收录

    无论一开始加入前端这个行业的目的如何,你都应当明白前端这个领域的本质。交互和视觉技术是我们的命门,也是我们为世界能展现的最美好的东西。我乐于分享,也希望从业的大家都乐于分享,show出你们酷炫的作品,让我们一起改善这个行业。