Масштабирование игровой карты в Phaser на PC и Mobile

Хочу поделиться с вами решением в плане красивого масштабирования чего-либо (например игровой карты) в Phaser2 как на PC так и на мобильных устройствах.

В данном примере будем масштабировать карту в игре. По сути это будет Sprite, который будет отображать карту.

Особенности данного примера:

  • плавный скроллинг карты с инерцией движения;
  • плавное твиновое масштабирование карты без рывков;
  • масштабирование колесом на PC и двумя пальцами на девайсах;
  • точка под курсором не «убегает» во время масштабирования карты (как в картах гугл, например);
  • карта не уходит за определённые края, если её утащить, то она плавно возвращается к своим границам позиционирования.

За основу я взял свой шаблон Phaser 2 на TypeScript для VSCode.

В результате получилось вот так:

Прямая ссылка на пример, для открытия на девайсах.

Вся основная логика содержится в классе состояния Game:

Код файла Game.ts
module PhaserGame.Client {
  const CURSOR_DIST_CLICK = 2;

  const MAP_SCALE_MIN = 0.6;
  const MAP_SCALE_MAX = 4;
  const MAP_SCALE_DEFAULT = 1;

  // scale
  const DELTA_SCALE_MAP = 0.15;

  export class Game extends Phaser.State {
    private dummy: Phaser.Sprite;
    private dummyGui: Phaser.Sprite;
    private btnBack: Phaser.Button;
    private map: Phaser.Sprite;
    private touchLayer: Phaser.Graphics;

    private p1: Phaser.Pointer;
    private p1down = false;
    private p1MapPosDown: Phaser.Point;
    private p2: Phaser.Pointer;
    private p2down = false;
    private p2wasDown = false;
    private p2MapScaleDown = 1;

    // scaling
    private map_scale = 1;
    private map_scale_prev = 1;
    private twMapScale: Phaser.Tween;
    private twMapScaleMove: Phaser.Tween;

    private gameInputX = 0;
    private gameInputY = 0;

    private mouseSpdVector: Phaser.Point;
    private prevMousePos: Phaser.Point;


    create() {
      this.dummy = new Phaser.Sprite(this.game, 0, 0);
      this.add.existing(this.dummy);

      this.dummyGui = new Phaser.Sprite(this.game, 0, 0);
      this.add.existing(this.dummyGui);

      this.map = new Phaser.Sprite(this.game, 0, 0, 'map');
      this.dummy.addChild(this.map);

      this.updateMapScale(true, true);
      this.checkMapScrollOutOfBorders();

      // создаём слой считывания ввода пользователя мышью или тачами для манипуляций над картой
      this.touchLayer = new Phaser.Graphics(this.game, 0, 0);
      this.touchLayer.beginFill(0, 0.0);
      this.touchLayer.drawRect(0, 0, Config.GW, Config.GH);
      this.touchLayer.endFill();
      this.touchLayer.inputEnabled = true;
      this.touchLayer.input.useHandCursor = true;
      this.dummy.addChild(this.touchLayer);
      this.touchLayer.events.onInputDown.add(this.onTouchLayerInputDown, this);
      this.touchLayer.events.onInputUp.add(this.onTouchLayerInputUp, this);

      // mouse wheel event listener
      WndEvents.onGameMouseWheelSignal.add(this.onMouseWheel, this);

      // GUI
      this.btnBack = new Phaser.Button(this.game, 0, 0, 'game', this.onBackClick, this, 'Button_022', 'Button_022');
      this.btnBack.anchor.set(0.5);
      this.btnBack.scale.set(0.8);
      this.updateGuiPos();
      this.dummyGui.addChild(this.btnBack);
    }

    private onBackClick() {
      WndEvents.onGameMouseWheelSignal.remove(this.onMouseWheel, this);
      this.game.state.start(States.MAINMENU, true, false);
    }

    private setMapScale(aNewScale: number) {
      if (aNewScale <= MAP_SCALE_MIN) aNewScale = MAP_SCALE_MIN;
      if (aNewScale >= MAP_SCALE_MAX) aNewScale = MAP_SCALE_MAX;
      this.map_scale = aNewScale;
    }

    private addMapScale(dt: number) {
      // формула, чтобы карта всегда приближалась и отдалялась
      // с одинаковой процентной скоростью независимо от текущего масштаба
      var newScale = this.map_scale + dt * this.map_scale;
      this.setMapScale(newScale);
    }

    private onMouseWheel(e: WheelEvent) {
      if (e.deltaY < 0) {
        // приближение
        this.addMapScale(DELTA_SCALE_MAP);
      }
      else if (e.deltaY > 0) {
        // отдаление
        this.addMapScale(-DELTA_SCALE_MAP);
      }
    }

    private onTouchLayerInputDown(obj, p: Phaser.Pointer) {
      if (this.game.device.desktop) {
        if (p == this.game.input.mousePointer) {
          // scroll or click
          //LogMng.debug('onTouchLayerInputDown desktop: p == this.game.input.mousePointer');
          this.p1 = this.game.input.mousePointer;
          this.p1MapPosDown = this.map.position.clone();
          this.p1down = true;
          this.prevMousePos = this.p1.position.clone();
        }
      }
      else {
        // mobile
        if (p == this.game.input.pointer1) {
          //LogMng.debug('onTouchLayerInputDown mobile: p == this.game.input.pointer1');
          this.p1 = this.game.input.pointer1;
          this.p1MapPosDown = this.map.position.clone();
          this.p1down = true;
          this.prevMousePos = this.p1.position.clone();
        }
        else if (p == this.game.input.pointer2) {
          //LogMng.debug('onTouchLayerInputDown mobile: p == this.game.input.pointer2');
          this.p2 = this.game.input.pointer2;
          this.p2MapScaleDown = this.map_scale;

          // сохраняем координаты точки центра скейла карты (точка между пальцами)
          this.gameInputX = (this.p1.x + this.p2.x) / 2;
          this.gameInputY = (this.p1.y + this.p2.y) / 2;

          this.p2down = true;
          this.p2wasDown = true;
        }
      }
    }

    private onTouchLayerInputUp(obj, p: Phaser.Pointer) {
      if (this.game.device.desktop) {
        if (p == this.game.input.mousePointer) {
          // is it click?
          var dist = this.p1.positionDown.distance(this.p1.positionUp, true);
          if (dist <= CURSOR_DIST_CLICK) {
            LogMng.debug('It is map click!');
          }
          else {
            this.checkMapScrollOutOfBorders(false, 100);
          }
          this.p1down = false;
          this.p1 = null;
        }
      }
      else {
        // mobile
        if (p == this.game.input.pointer1) {
          if (!this.p2down) {
            if (!this.p2wasDown) {
              // check for tap to cell
              var dist = this.p1.positionDown.distance(this.p1.positionUp, true);
              if (dist <= CURSOR_DIST_CLICK) {
                LogMng.debug('It is map tap!');
              }
              else {
                this.checkMapScrollOutOfBorders(false, 100);
              }
            }
            this.p2wasDown = false;
          }
          else {
            this.checkMapScrollOutOfBorders(false, 100);
          }
          this.p1down = false;
          this.p1 = null;
        }
        if (p == this.game.input.pointer2) {
          this.p2down = false;
          this.p2 = null;
        }
      }
    }

    /**
     * Возвращает координаты прямоугольника для отображения карты, а так же мин и макс координаты карты
     */
    private getMapLimits(): any {
      var res = {
        x0: 0,
        y0: 0,
        x: 0,
        y: 0,
        w: 0,
        h: 0,
        x_min: 0,
        x_max: 0,
        y_min: 0,
        y_max: 0
      };
      let map_w = this.map.width;
      let map_h = this.map.height;
      let c_x = Config.GW / 2;
      let c_y = Config.GH / 2;
      let view_w = ScaleManager.gameViewW;
      let view_h = ScaleManager.gameViewH;
      let min_w_h = Math.min(view_w, map_w) / 2;
      let min_h_h = Math.min(view_h, map_h) / 2;
      res.x0 = c_x - min_w_h;
      res.y0 = c_y - min_h_h;
      res.x = c_x + min_w_h;
      res.y = c_y + min_h_h;
      res.w = res.x - res.x0;
      res.h = res.y - res.y0;
      res.x_min = res.x - map_w;
      res.x_max = res.x0;
      res.y_min = res.y - map_h;
      res.y_max = res.y0;
      return res;
    }

    private checkMapScrollOutOfBorders(aFast = false, aDur = 500) {
      let mapLimits = this.getMapLimits();
      let mapPos = this.map.position.clone();//.multiply(this.map.scale.x, this.map.scale.y);
      let needTrans = false;

      if (mapPos.x < mapLimits.x_min) {
        needTrans = true;
        mapPos.x = mapLimits.x_min;
      }
      if (mapPos.x > mapLimits.x_max) { //map_w) {
        needTrans = true;
        mapPos.x = mapLimits.x_max;
      }
      if (mapPos.y < mapLimits.y_min) {
        needTrans = true;
        mapPos.y = mapLimits.y_min;
      }
      if (mapPos.y > mapLimits.y_max) {
        needTrans = true;
        mapPos.y = mapLimits.y_max;
      }

      if (needTrans) {
        if (aFast) {
          this.map.x = mapPos.x;
          this.map.y = mapPos.y;
        }
        else {
          let tw = this.game.add.tween(this.map).to({ x: mapPos.x, y: mapPos.y }, aDur, Phaser.Easing.Sinusoidal.Out, true);
        }
      }
    }

    private updateMobileScale() {
      let p1Pos0 = this.p1.positionDown;
      let p2Pos0 = this.p2.positionDown;
      let d0 = p1Pos0.distance(p2Pos0, true);
      let p1Pos = this.p1.position;
      let p2Pos = this.p2.position;
      let d = p1Pos.distance(p2Pos, true);
      let sc = d / d0;
      this.setMapScale(this.p2MapScaleDown * sc);
    }

    private updateMapScale(aMakeDefault = false, aFast = false) {
      if (aMakeDefault) {
        this.map_scale = MAP_SCALE_DEFAULT;
      }
      else {
        var minScale = MAP_SCALE_MIN;
        if (this.map_scale < minScale) this.map_scale = minScale;
        if (this.map_scale <= minScale) {
          //this.guiMng.disableZoomMinusBtn();
        }
        else {
          //this.guiMng.enableZoomMinusBtn();
        }

        var maxScale = MAP_SCALE_MAX;
        if (this.map_scale > maxScale) this.map_scale = maxScale;
        if (this.map_scale >= maxScale) {
          //this.guiMng.disableZoomPlusBtn();
        }
        else {
          //this.guiMng.enableZoomPlusBtn();
        }
      }

      // позиция мыши на карте без учёта скейла перед скейлингом
      let p0x = this.gameInputX - this.map.x;
      let p0y = this.gameInputY - this.map.y;
      // позиция мыши на карте без учёта скейла перед скейлингом
      let px = p0x / this.map.scale.x * this.map_scale;
      let py = p0y / this.map.scale.y * this.map_scale;

      let dx = -(px - p0x);
      let dy = -(py - p0y);

      let newx = this.map.x + dx;
      let newy = this.map.y + dy;

      if (this.twMapScale) this.twMapScale.stop(false);
      if (this.twMapScaleMove) this.twMapScaleMove.stop(false);

      if (aFast) {
        this.map.x = newx;
        this.map.y = newy;
        this.map.scale.set(this.map_scale);
      }
      else {
        this.twMapScaleMove = this.game.add.tween(this.map).to({ x: newx, y: newy }, 100, Phaser.Easing.Sinusoidal.InOut);
        this.twMapScaleMove.start();
        this.twMapScale = this.game.add.tween(this.map.scale).to({ x: this.map_scale, y: this.map_scale }, 100, Phaser.Easing.Sinusoidal.InOut);
        this.twMapScale.start();
      }
    }

    private updateGuiPos() {
      this.btnBack.x = (Config.GW - ScaleManager.gameViewW) / 2 + 70;
      this.btnBack.y = (Config.GH + ScaleManager.gameViewH) / 2 - 70;
    }

    update() {
      let dt = this.game.time.elapsed * 0.001;

      // scroll and mobile scale
      let inertMinVal = 0.1;

      this.updateGuiPos();

      // для PC постоянно сохраняем позицию мышки как позицию точки скейла
      if (this.game.device.desktop) {
        this.gameInputX = this.game.input.x;
        this.gameInputY = this.game.input.y;
      }

      if (this.p1down) {
        if (this.p2down) {
          // mobile scale
          this.updateMobileScale();
        }
        else if (!this.p2wasDown) {
          // scroll
          var p0 = this.p1.positionDown.clone();
          var p = this.p1.position.clone();

          this.mouseSpdVector = p.clone().subtract(this.prevMousePos.x, this.prevMousePos.y);
          this.prevMousePos = this.p1.position.clone();

          p.subtract(p0.x, p0.y);
          this.map.position.set(this.p1MapPosDown.x + p.x, this.p1MapPosDown.y + p.y);
        }
      }
      else if (this.mouseSpdVector && (this.mouseSpdVector.x != 0 || this.mouseSpdVector.y != 0)) {
        // инерционное торможение скрола камеры
        this.mouseSpdVector.multiply(0.85, 0.85);
        let mapLimits = this.getMapLimits();
        if (this.map.x < mapLimits.x_min || this.map.x > mapLimits.x_max) this.mouseSpdVector.x = 0;
        if (this.map.y < mapLimits.y_min || this.map.y > mapLimits.y_max) this.mouseSpdVector.y = 0;
        var dx = this.mouseSpdVector.x;
        var dy = this.mouseSpdVector.y;
        this.map.position.x += dx;
        this.map.position.y += dy;
        if (Math.abs(this.mouseSpdVector.x) <= inertMinVal) this.mouseSpdVector.x = 0;
        if (Math.abs(this.mouseSpdVector.y) <= inertMinVal) this.mouseSpdVector.y = 0;
      }

      // update scale map
      if (this.map_scale != this.map_scale_prev) {
        this.updateMapScale(false, !this.game.device.desktop);
        this.map_scale_prev = this.map_scale;
        LogMng.debug('map_scale: ' + this.map_scale);
      }

    }

  }

}

Думаю, код должен быть понятен, всё разложено по функциям и прокомментировано в основных местах.

Исходники примера как всегда в репозитории проектов для этого сайта.

Если что непонятно, то пишите в комментах 😉