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

Изучение примера Controller.js из официальных примеров Phaser открывает мощный паттерн для создания приложений с интерфейсом, напоминающим рабочий стол. В этой сцене реализована система, где пользователь может кликать по иконкам и открывать различные интерактивные демо-сцены в отдельных перетаскиваемых окнах. Это полезный подход для создания редакторов уровней, панелей управления игрой или просто для организации множества мини-игр в одном проекте, где важна модульность и управление состоянием.

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

Живой запуск

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

Исходный код


class Controller extends Phaser.Scene {

    constructor ()
    {
        super();

        this.count = 0;

        this.workbench;
        this.workbenchTitle;
        this.workbenchIcons;
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('disk', 'assets/phaser3/disk.png');

        this.load.image('workbenchTitle', 'assets/phaser3/workbench-title.png');
        this.load.image('workbenchIcons', 'assets/phaser3/workbench-icons.png');
        this.load.image('demosWindow', 'assets/phaser3/demos-window.png');
        this.load.image('eyesIcon', 'assets/phaser3/eyes-icon.png');
        this.load.image('starsIcon', 'assets/phaser3/stars-icon.png');
        this.load.image('jugglerIcon', 'assets/phaser3/juggler-icon.png');
        this.load.image('twistIcon', 'assets/phaser3/twist-icon.png');
        this.load.image('invadersIcon', 'assets/phaser3/invaders-icon.png');
        this.load.image('clockIcon', 'assets/phaser3/clock-icon.png');
        this.load.image('boingIcon', 'assets/phaser3/boing-icon.png');

        this.load.image('starsWindow', 'assets/phaser3/stars-window.png');
        this.load.image('sineWindow', 'assets/phaser3/sinewave-window.png');
        this.load.image('eyesWindow', 'assets/phaser3/eyes-window.png');
        this.load.image('jugglerWindow', 'assets/phaser3/juggler-window.png');
        this.load.image('invadersWindow', 'assets/phaser3/invaders-window.png');
        this.load.image('clockWindow', 'assets/phaser3/clock-window.png');

        this.load.atlas('boing', 'assets/phaser3/boing.png', 'assets/phaser3/boing.json');

        this.load.spritesheet('juggler', 'assets/phaser3/juggler.png', { frameWidth: 128, frameHeight: 184 });
        this.load.image('star', 'assets/phaser3/star2.png');
        this.load.image('eye', 'assets/phaser3/eye.png');

        this.load.image('invaders.boom', 'assets/games/multi/boom.png');
        this.load.spritesheet('invaders.bullet', 'assets/games/multi/bullet.png', { frameWidth: 12, frameHeight: 14 });
        this.load.image('invaders.bullet2', 'assets/games/multi/bullet2.png');
        this.load.image('invaders.explode', 'assets/games/multi/explode.png');
        this.load.spritesheet('invaders.invader1', 'assets/games/multi/invader1.png', { frameWidth: 16, frameHeight: 16 });
        this.load.spritesheet('invaders.invader2', 'assets/games/multi/invader2.png', { frameWidth: 22, frameHeight: 16 });
        this.load.spritesheet('invaders.invader3', 'assets/games/multi/invader3.png', { frameWidth: 24, frameHeight: 16 });
        this.load.image('invaders.mothership', 'assets/games/multi/mothership.png');
        this.load.image('invaders.ship', 'assets/games/multi/ship.png');
    }

    create ()
    {
        //  Create animations

        this.anims.create({
            key: 'juggler',
            frames: this.anims.generateFrameNumbers('juggler'),
            frameRate: 28,
            repeat: -1
        });

        this.anims.create({
            key: 'boing',
            frames: this.anims.generateFrameNames('boing', { prefix: 'boing', start: 1, end: 14 }),
            frameRate: 28,
            repeat: -1
        });

        this.anims.create({
            key: 'bullet',
            frames: this.anims.generateFrameNumbers('invaders.bullet'),
            frameRate: 8,
            repeat: -1
        });

        this.anims.create({
            key: 'invader1',
            frames: this.anims.generateFrameNumbers('invaders.invader1'),
            frameRate: 2,
            repeat: -1
        });

        this.anims.create({
            key: 'invader2',
            frames: this.anims.generateFrameNumbers('invaders.invader2'),
            frameRate: 2,
            repeat: -1
        });

        this.anims.create({
            key: 'invader3',
            frames: this.anims.generateFrameNumbers('invaders.invader3'),
            frameRate: 2,
            repeat: -1
        });

        this.workbench = this.add.graphics({ x: 16, y: 21 });

        this.workbench.fillStyle(0xffffff);
        this.workbench.fillRect(0, 0, this.sys.game.config.width - 105, 20);

        this.workbenchTitle = this.add.image(16, 21, 'workbenchTitle').setOrigin(0);
        this.workbenchIcons = this.add.image(this.sys.game.config.width - 87, 21, 'workbenchIcons').setOrigin(0);

        const disk = this.add.image(16, 64, 'disk').setOrigin(0).setInteractive();

        const demosWindow = this.add.image(0, 0, 'demosWindow').setOrigin(0);
        const eyesIcon = this.add.image(32, 34, 'eyesIcon', 0).setOrigin(0).setInteractive();
        const jugglerIcon = this.add.image(48, 110, 'jugglerIcon', 0).setOrigin(0).setInteractive();
        const starsIcon = this.add.image(230, 40, 'starsIcon', 0).setOrigin(0).setInteractive();
        const invadersIcon = this.add.image(120, 34, 'invadersIcon', 0).setOrigin(0).setInteractive();
        const clockIcon = this.add.image(240, 120, 'clockIcon', 0).setOrigin(0).setInteractive();
        const boingIcon = this.add.image(146, 128, 'boingIcon', 0).setOrigin(0).setInteractive();

        const demosContainer = this.add.container(32, 70, [ demosWindow, eyesIcon, jugglerIcon, starsIcon, invadersIcon, clockIcon, boingIcon ]);

        demosContainer.setVisible(false);

        demosContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, demosWindow.width, demosWindow.height), Phaser.Geom.Rectangle.Contains);

        this.input.setDraggable(demosContainer);

        demosContainer.on('drag', function (pointer, dragX, dragY) {

            this.x = dragX;
            this.y = dragY;

        });

        disk.once('pointerup', function () {

            demosContainer.setVisible(true);

        });

        eyesIcon.on('pointerup', function () {

            this.createWindow(Eyes);

        }, this);

        jugglerIcon.on('pointerup', function () {

            this.createWindow(Juggler);

        }, this);

        starsIcon.on('pointerup', function () {

            this.createWindow(Stars);

        }, this);

        invadersIcon.on('pointerup', function () {

            this.createWindow(Invaders);

        }, this);

        clockIcon.on('pointerup', function () {

            this.createWindow(Clock);

        }, this);

        boingIcon.on('pointerup', function () {

            this.createWindow(Boing);

        }, this);


        this.events.on('resize', this.resize, this);
    }

    createWindow (func)
    {
        const x = Phaser.Math.Between(400, 600);
        const y = Phaser.Math.Between(64, 128);

        const handle = 'window' + this.count++;

        const win = this.add.zone(x, y, func.WIDTH, func.HEIGHT).setInteractive().setOrigin(0);

        const demo = new func(handle, win);

        this.input.setDraggable(win);

        win.on('drag', function (pointer, dragX, dragY) {

            this.x = dragX;
            this.y = dragY;

            demo.refresh()

        });

        this.scene.add(handle, demo, true);
    }

    resize (width, height)
    {
        if (width === undefined) { width = this.game.config.width; }
        if (height === undefined) { height = this.game.config.height; }

        this.cameras.resize(width, height);

        this.workbench.clear();
        this.workbench.fillStyle(0xffffff);
        this.workbench.fillRect(0, 0, width - 105, 20);

        this.workbenchIcons.x = (width - 87);
    }

}

Загрузка ресурсов: подготовка рабочего пространства

Класс Controller наследуется от Phaser.Scene и выступает в роли главной сцены-контейнера. В методе preload происходит масштабная загрузка всех необходимых для демонстраций ресурсов: фоновых изображений, иконок, спрайтов и анимаций.

Обратите внимание на организацию загрузки: для разных демо (например, invaders) используется префикс в имени ключа, что помогает избежать конфликтов. Анимации загружаются как из атласа (boing), так и из спрайтшитов (juggler, invaders.bullet).

this.load.atlas('boing', 'assets/phaser3/boing.png', 'assets/phaser3/boing.json');
this.load.spritesheet('invaders.bullet', 'assets/games/multi/bullet.png', { frameWidth: 12, frameHeight: 14 });

Создание интерфейса: панель и контейнер с окнами

В методе create сначала создаются все анимации через this.anims.create. Это необходимо сделать глобально, чтобы они были доступны в других, динамически создаваемых сценах.

Затем формируется интерфейс. this.workbench — это объект Graphics, который рисует белую панель в верхней части экрана. Ее размер вычисляется на основе конфигурации игры, что закладывает основу для отзывчивого интерфейса.

Ключевой элемент — demosContainer. Это контейнер (Phaser.GameObjects.Container), который группирует окно со списком демо (demosWindow) и все иконки. Изначально он скрыт (setVisible(false)). Контейнеру назначается интерактивная зона и возможность перетаскивания.

const demosContainer = this.add.container(32, 70, [ demosWindow, eyesIcon, jugglerIcon, starsIcon, invadersIcon, clockIcon, boingIcon ]);
demosContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, demosWindow.width, demosWindow.height), Phaser.Geom.Rectangle.Contains);
this.input.setDraggable(demosContainer);

Обработка событий: клики по иконкам и логика окон

Иконка диска (disk) при клике показывает контейнер с демо. Каждая иконка внутри контейнера (eyesIcon, jugglerIcon и др.) при клике вызывает метод createWindow, передавая в него конструктор соответствующей демо-сцены (например, Eyes, Juggler).

eyesIcon.on('pointerup', function () {
    this.createWindow(Eyes);
}, this);

Метод createWindow — сердце архитектуры. Он генерирует уникальное имя для новой сцены, создает интерактивную зону (Phaser.GameObjects.Zone), которая будет служить областью перетаскивания для окна, и запускает переданную демо-сцену как дочернюю.

const handle = 'window' + this.count++;
const win = this.add.zone(x, y, func.WIDTH, func.HEIGHT).setInteractive().setOrigin(0);
const demo = new func(handle, win);
this.scene.add(handle, demo, true);

При перетаскивании зоны (win) обновляется ее позиция, и вызывается метод refresh() у запущенной демо-сцены, чтобы та могла перерисовать свое содержимое относительно новых координат.

Реакция на изменение размера: отзывчивый интерфейс

Сцена Controller подписана на событие resize. При изменении размера игрового окна (например, в веб-версии при изменении размера браузера) вызывается метод resize. Он перенастраивает камеру и критически важный элемент интерфейса — белую панель (workbench). Панель перерисовывается с новой шириной, а иконки на ней (workbenchIcons) сдвигаются, чтобы оставаться у правого края.

this.workbench.clear();
this.workbench.fillStyle(0xffffff);
this.workbench.fillRect(0, 0, width - 105, 20);
this.workbenchIcons.x = (width - 87);

Это обеспечивает корректное отображение интерфейса при любом разрешении.

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

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

  1. Добавьте кнопку закрытия окон, удаляющую сцену и ее зону
  2. Реализуйте сохранение позиций окон между сессиями
  3. Используйте этот паттерн для создания панели инструментов в редакторе карт, где каждое окно — это палитра объектов или настройки слоев