Хочу поделиться с вами решением в плане красивого масштабирования чего-либо (например игровой карты) в 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);
}
}
}
}
Думаю, код должен быть понятен, всё разложено по функциям и прокомментировано в основных местах.
Исходники примера как всегда в репозитории проектов для этого сайта.
Если что непонятно, то пишите в комментах 😉