Как масштабировать HTML5 игру

В этой статье я подробно расскажу как масштабировать вашу HTML5 игру, чтобы она всегда вписывалась в максимальный размер web страницы.

На самом деле весь этот функционал уже реализован в моём классе ScaleManager, который вы могли давно наблюдать в шаблоне проекта для Phaser на TypeScript. Поэтому я просто рассмотрю его подробно, чтобы всё было понятно.

Код класса ScaleManager
class ScaleManager {
  private static game: Phaser.Game;
  private static dom_id: string = '';
  private static dom: HTMLElement;
  private static isDesktop = false;
  // game maximum size
  private static game_w: number;
  private static game_h: number;
  // game save area size
  private static game_sw: number;
  private static game_sh: number;

  // delta values container position from left and top of the page
  public static dtx: number = 0;
  public static dty: number = 0;

  // true current game view size
  public static gameViewW: number;
  public static gameViewH: number;

  // orientation
  public static isPortrait: boolean;
  public static onOrientationChange: Phaser.Signal = new Phaser.Signal(); // orientation change event

  public static init(aGame: Phaser.Game, aDomId: string, GW: number, GH: number, GSW: number, GSH: number) {
    this.game = aGame;
    this.dom_id = aDomId;
    this.dom = document.getElementById(this.dom_id);

    this.game_w = GW;
    this.game_h = GH;
    this.game_sw = GSW;
    this.game_sh = GSH;

    aGame.scale.scaleMode = Phaser.ScaleManager.USER_SCALE;

    this.isDesktop = this.game.device.desktop;
    // TEST
    //this.isDesktop = false;

    ScaleManager.SizeCalculation();

    window.onresize = () => {
      ScaleManager.SizeCalculation();
    };
  }

  private static doEventOriChange() {
    this.onOrientationChange.dispatch(this.isPortrait);
  }

  public static SizeCalculation() {
    var wnd = {
      w: window.innerWidth,
      h: window.innerHeight
    };

    // orientation
    var oldOri = this.isPortrait;
    this.isPortrait = wnd.h > wnd.w;

    // determine game size
    var g = {
      w: ScaleManager.game_w,
      h: ScaleManager.game_h,
      sw: ScaleManager.game_sw,
      sh: ScaleManager.game_sh
    }

    var gw: number;
    var gh: number;

    if (g.h / g.w > wnd.h / wnd.w) {
      // game compressed by HEIGHT, black borders on the LEFT and the RIGHT
      if (g.sh / g.w > wnd.h / wnd.w) {
        // A
        gh = wnd.h * g.h / g.sh;
        gw = gh * g.w / g.h;
      } else {
        // B
        gw = wnd.w;
        gh = gw * g.h / g.w;
      }
    } else {
      // game compressed by WIDTH, black borders on the TOP and the BOT
      if (g.h / g.sw > wnd.h / wnd.w) {
        // C
        gh = wnd.h;
        gw = gh * g.w / g.h;
      } else {
        // D
        gw = wnd.w * g.w / g.sw;
        gh = gw * g.h / g.w;
      }
    }

    // game scale
    var scale_x = gw / g.w;
    var scale_y = gh / g.h;
    var newScale = Math.min(scale_x, scale_y);
    ScaleManager.game.scale.setUserScale(newScale, newScale, 0, 0);

    // game dt xy
    this.dtx = (wnd.w - gw) / 2;
    this.dty = (wnd.h - gh) / 2;

    this.gameViewW = this.game_w + 2 * this.dtx / newScale;
    if (this.gameViewW > this.game_w) this.gameViewW = this.game_w;

    this.gameViewH = this.game_h + 2 * this.dty / newScale;
    if (this.gameViewH > this.game_h) this.gameViewH = this.game_h;

    // game div position
    this.dom.style.marginLeft = Math.round(this.dtx).toString() + 'px';

    if (!this.isDesktop && this.isPortrait) {
      this.dom.style.marginTop = '0px';
    }
    else {
      this.dom.style.marginTop = Math.round(this.dty).toString() + 'px';
    }

    this.dom.style.maxWidth = String(gw) + 'px';
    this.dom.style.maxHeight = String(gh) + 'px';

    ScaleManager.game.scale.refresh();

    // ROTATION ICON UPDATE
    this.updateRotationIcon();

    if (this.isPortrait != oldOri) {
      this.doEventOriChange();
    }
  }

  private static updateRotationIcon() {
    var MAX_PERC = 24;

    if (!this.isDesktop) {
      if (this.isPortrait) {
        this.showRotateIcon();
      }
      else {
        this.hideRotateIcon();
        return;
      }

      var wnd = {
        w: window.innerWidth,
        h: window.innerHeight
      };

      var rp_div = document.getElementById("rp-div");
      var rp_img = document.getElementById("rp-img");
      var com_h = this.dom.clientHeight + rp_div.clientHeight;

      var perc = MAX_PERC;
      if (rp_img.style.height != null && rp_img.style.height != undefined && rp_img.style.height != '') {
        if (rp_img.style.height.indexOf('%') > 0)
          perc = Number(rp_img.style.height.split('%')[0]);
        //LogMng.debug('perc = ' + perc);
      }

      if (com_h > wnd.h) {
        while (com_h > wnd.h) {
          perc--;
          rp_img.style.width = rp_img.style.height = String(perc) + '%';
          com_h = this.dom.clientHeight + rp_div.clientHeight;
        }
      }
      else {
        while (perc < MAX_PERC && com_h < wnd.h - 10) {
          perc++;
          rp_img.style.width = rp_img.style.height = String(perc) + '%';
          com_h = this.dom.clientHeight + rp_div.clientHeight;
        }
      }

      var bot_h = wnd.h - this.dom.clientHeight;
      rp_div.style.paddingTop = String((bot_h - rp_img.clientHeight) / 2) + 'px';

    }
  }

  private static showRotateIcon() {
    document.getElementById("rp-div").style.display = "block";
    //ScaleManager.game.world.isPaused = true;
  }

  private static hideRotateIcon() {
    document.getElementById("rp-div").style.display = "none";
    //ScaleManager.game.world.isPaused = false;
  }

}

Суть этого класса в том, что он располагает и масштабирует вашу игру так, чтобы она всегда была по центру страницы и отображалась желаемая рабочая область (SafeArea) игры.

Работает класс автономно, т.к. после инициализации сам подписывается на событие window.onresize. По этому событию выполняется функция ScaleManager.SizeCalculation() в которой и происходит расчёт всех размеров и событий относительно текущих размеров страницы.

Для использования класса его нужно просто инициализировать и лучше это сделать как можно раньше, перед любой значимой сценой, чтобы любая видимая часть игры уже правильно отмасштабировалась, в моём примере это делается в самой первой сцене Boot:

Код класса Boot
module PhaserGame.Client {

  export class Boot extends Phaser.State {
    preload() {
      this.load.atlasJSONArray('loading', './assets/atlases/loading.png', './assets/atlases/loading.json');
    }

    create() {
      this.stage.setBackgroundColor(0x6600CC);
      this.input.maxPointers = 1;
      // если true то игра старается не останавливаться теряя фокус
      this.stage.disableVisibilityChange = true;

      // SCALE MNG
      ScaleManager.init(this.game, Config.DOM_PARENT_ID, Config.GW, Config.GH, Config.GSW, Config.GSH);

      // LogMng settings
      if (isRelease()) LogMng.setMode(LogMng.MODE_RELEASE);
      LogMng.system('current log mode: ' + LogMng.getMode());

      // FILL PARAMS
      Params.isTapToStartBtn = //true || // uncomment for testing
        this.game.device.iOS ||
        this.game.device.iPhone ||
        this.game.device.iPhone4 ||
        this.game.device.iPad ||
        this.game.device.mobileSafari;

      // little pause
      this.time.events.add(100, this.onWaitComplete, this);
    }

    onWaitComplete() {
      this.game.state.start(States.PRELOADER, true, false);
    }

  }
}

В функцию ScaleManager.init(…) нужно передать следующие параметры:

  • ссылку на текущий класс игры
  • id DOM элемента, в котором расположена игра, в данном случае это div-элемент HTML страницы с игрой, который имеет id = «game»
  • максимальное разрешение игры GW и GH и разрешение минимальной рабочей области игры (GSW и GSH), так называемая SafeArea область.

SafeArea — это минимальная рабочая область игры, которая не должна обрезаться (выходить за края экрана), область которую игрок должен всегда видеть на любых разрешениях экрана.

Для наглядности рассмотрим пример: наша игровая сцена должна корректно отображаться и на экранах iPad-ов и на экранах длинных телефонов. Максимальное соотношение сторон iPad-а = 4:3, максимальное сплюснутое разрешения телефонов бывает 16:9, учитывая что сферху место будет занимать полоска браузера (которая отображает адрес страницы и т.п.), то будет почти 2:1. Допустим мы определились с разрешением текстур для игры и делаем их под разрешение 1024:768 — это будет нашим максимальным отображаемым разрешением, а минимальную необходимую область определили разрешением в 800:400.

Я специально подготовил прототип игровой сцены с разметкой этих областей:

Т.е. игра на разных устройствах должна выглядить следующим образом:

Я думаю смысл SafeArea понятен — эта область должна всегда быть на экране и если экран позволяет, то показывается MaximumArea.

Теперь рассмотрим случаи, которые описаны в коде под буквами A, B, C, D.

 

 

 

 

А вот живая демонстрация данного скейлинга, откройте и изменяйте размеры окна браузера чтобы понаблюдать динамку масштабирования, а так же можно открыть на любом устройстве. Вот видео данной демонстрации:

Исходники примера можно как всегда посмотреть и скачать в репозитории.

PS:

Так же у меня здесь (в шаблоне) частично прикручена функция отображения иконки «разверните устройство», если на мобильных устройствах повернулись в портретный режим. Это можно видеть на видео и на мобильных устройствах. Но она пока что работает только для горизонтальных ориентаций. И это уже другая тема, которую я раскрою в последующей статье, когда допишу опциональный функционал для обоих ориентаций.