О чем этот пример

Создание игр для множества устройств — это всегда компромисс между сохранением задуманного вида и адаптацией под любые размеры экрана. Пример с двумя сценами демонстрирует мощный паттерн для разделения логики масштабирования и игрового контента. Вы научитесь фиксировать соотношение сторон у основного игрового мира, в то время как фон и UI могут растягиваться на весь браузер, создавая бесшовный и профессиональный вид на любом разрешении.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


//  This Scene has no aspect ratio lock, it will scale to fit the browser window, but will zoom to match the Game
class BackgroundScene extends Phaser.Scene
{
    gameScene;
    layer;

    constructor ()
    {
        super('BackgroundScene');
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('logo', 'assets/sprites/phaser3-logo-x2.png');
        this.load.image('fakegame', 'assets/pics/ninja-masters2.png');
    }

    create ()
    {
        this.layer = this.add.container();

        const bg = this.add.image(0, 0, 'fakegame');

        this.layer.add(bg);

        //  We launch this Scene first because we can't use `getZoom` otherwise
        this.scene.launch('GameScene');

        this.gameScene = this.scene.get('GameScene');
    }

    updateCamera ()
    {
        const width = this.scale.gameSize.width;
        const height = this.scale.gameSize.height;

        const camera = this.cameras.main;

        camera.setViewport(0, 0, width, height);

        this.layer.setPosition(width / 2, height / 2);
        this.layer.setScale(this.gameScene.getZoom());
    }

    resize ()
    {
        this.updateCamera();
    }
}

//  This Scene is aspect ratio locked at 640 x 960 (and scaled and centered accordingly)
class GameScene extends Phaser.Scene
{
    backgroundScene;
    parent;
    sizer;

    constructor ()
    {
        super('GameScene');
    }

    create ()
    {
        const width = this.scale.gameSize.width;
        const height = this.scale.gameSize.height;

        this.parent = new Phaser.Structs.Size(width, height);
        this.sizer = new Phaser.Structs.Size(640, 960, Phaser.Structs.Size.HEIGHT_CONTROLS_WIDTH, this.parent);

        this.parent.setSize(width, height);
        this.sizer.setSize(width, height);

        this.backgroundScene = this.scene.get('BackgroundScene');

        this.updateCamera();

        this.scale.on('resize', this.resize, this);

        //  Normal game stuff from here on down
        this.add.image(640 / 2, 960 / 2, 'fakegame');
    }

    updateCamera ()
    {
        const camera = this.cameras.main;

        const x = Math.ceil((this.parent.width - this.sizer.width) * 0.5);
        const y = 0;
        const scaleX = this.sizer.width / 640;
        const scaleY = this.sizer.height / 960;

        camera.setViewport(x, y, this.sizer.width, this.sizer.height);
        camera.setZoom(Math.max(scaleX, scaleY));
        camera.centerOn(320, 480);

        this.backgroundScene.updateCamera();
    }

    getZoom ()
    {
        return this.cameras.main.zoom;
    }

    resize (gameSize, baseSize, displaySize, resolution)
    {
        const width = gameSize.width;
        const height = gameSize.height;

        this.parent.setSize(width, height);
        this.sizer.setSize(width, height);

        this.updateCamera();
    }
}

const config = {
    type: Phaser.AUTO,
    backgroundColor: '#000000',
    scale: {
        mode: Phaser.Scale.RESIZE,
        parent: 'phaser-example',
        width: 640,
        height: 960,
        min: {
            width: 320,
            height: 480
        },
        max: {
            width: 1920,
            height: 1400
        }
    },
    scene: [ BackgroundScene, GameScene ]
};

const game = new Phaser.Game(config);

Разделение ответственности: Зачем две сцены?

Ключевая идея примера — разделение. BackgroundScene отвечает за фон, который должен заполнять всё окно браузера без учёта соотношения сторон. GameScene содержит основной игровой мир с фиксированным соотношением сторон (640x960).

Это позволяет имитировать популярный в мобильных играх эффект, когда игровая область сохраняет пропорции и центрируется, а фон и интерфейс вокруг неё плавно заполняют оставшееся пространство. Обе сцены запускаются одновременно и синхронизируют свои камеры через вызовы методов.

// Запуск GameScene из BackgroundScene после создания
this.scene.launch('GameScene');
this.gameScene = this.scene.get('GameScene');
// Получение ссылки на BackgroundScene из GameScene
this.backgroundScene = this.scene.get('BackgroundScene');

Ядро масштабирования: Использование Phaser.Structs.Size

В GameScene для расчётов используется утилита Phaser.Structs.Size. Она автоматически вычисляет размеры области отображения, сохраняя заданное соотношение сторон и вписываясь в доступное пространство.

Мы создаём два объекта: parent (размер окна браузера) и sizer (размер нашей игровой области, 640x960). Флаг Phaser.Structs.Size.HEIGHT_CONTROLS_WIDTH указывает, что высота является ведущим измерением для расчётов — область будет масштабироваться, сохраняя пропорции, по высоте окна.

this.parent = new Phaser.Structs.Size(width, height);
this.sizer = new Phaser.Structs.Size(640, 960, Phaser.Structs.Size.HEIGHT_CONTROLS_WIDTH, this.parent);

При каждом ресайзе мы обновляем размеры обоих объектов, и sizer автоматически пересчитывает свои width и height, оставаясь вписанным в новый parent.

this.parent.setSize(width, height);
this.sizer.setSize(width, height);

Синхронизация камер: updateCamera в GameScene

Метод updateCamera() в GameScene — сердце логики отображения. Он использует рассчитанные sizer значения, чтобы позиционировать и масштабировать основную камеру.

1. **Вьюпорт**: Камере задаётся область отрисовки (setViewport), которая равна рассчитанным размерам sizer и смещена для центрирования по горизонтали. 2. **Зум**: Масштаб камеры (zoom) вычисляется как максимальное соотношение между целевым и реальным размером. Это гарантирует, что игровая область 640x960 всегда будет полностью помещаться в выделенный для неё sizer, возможны чёрные полосы (letterbox). 3. **Центрирование**: Камера центрируется на середине игрового мира.

const x = Math.ceil((this.parent.width - this.sizer.width) * 0.5);
const y = 0;
const scaleX = this.sizer.width / 640;
const scaleY = this.sizer.height / 960;

camera.setViewport(x, y, this.sizer.width, this.sizer.height);
camera.setZoom(Math.max(scaleX, scaleY));
camera.centerOn(320, 480);

После настройки своей камеры, GameScene вызывает updateCamera() у BackgroundScene, передавая эстафету.

Реакция на ресайз: Обработчик события scale.resize

Чтобы игра реагировала на изменение размера окна, в GameScene подписываемся на событие resize от менеджера масштабирования this.scale. В конфигурации игры используется Phaser.Scale.RESIZE, которое делает canvas резиновым.

Обработчик resize обновляет размеры parent и sizer, а затем вызывает updateCamera(), которая пересчитывает вьюпорты и зум для обеих сцен.

this.scale.on('resize', this.resize, this);

// ...

resize (gameSize, baseSize, displaySize, resolution) {
    const width = gameSize.width;
    const height = gameSize.height;

    this.parent.setSize(width, height);
    this.sizer.setSize(width, height);

    this.updateCamera();
}

Важно: BackgroundScene также имеет метод resize, который вызывается автоматически системой Phaser для каждой сцены при срабатывании глобального события. Он просто делегирует логику своему updateCamera().

Фон на весь экран: updateCamera в BackgroundScene

Логика фона проще. Его камера всегда занимает весь экран (setViewport(0, 0, width, height)). Контейнер this.layer, содержащий фоновое изображение, позиционируется в центр экрана.

Ключевой момент — его масштаб. Он запрашивает текущий зум у GameScene через метод getZoom() и применяет его к себе. Это заставляет фон масштабироваться в точности так же, как и основная игровая камера, создавая иллюзию единого целого, даже если игровая область меньше окна.

updateCamera () {
    const width = this.scale.gameSize.width;
    const height = this.scale.gameSize.height;
    const camera = this.cameras.main;

    camera.setViewport(0, 0, width, height);
    this.layer.setPosition(width / 2, height / 2);
    this.layer.setScale(this.gameScene.getZoom()); // Синхронизация зума!
}

Что попробовать дальше

Этот паттерн с двумя сценами — отличная основа для сложной системы отзывчивого интерфейса. Вы можете расширить его: добавить третью сцену для HUD-элементов (здоровье, очки), которые будут привязаны к краям экрана, а не к игровому миру. Поэкспериментируйте с другими флагами Phaser.Structs.Size, например WIDTH_CONTROLS_HEIGHT, чтобы игровая область масштабировалась по ширине. Также попробуйте менять метод расчёта зума в GameScene на Math.min(scaleX, scaleY) — это включит режим "обрезки" (crop), когда игровая область будет заполнять весь sizer, но часть контента может уйти за края.