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

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

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

Живой запуск

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

Исходный код


class BootLoader extends Phaser.Scene
{
    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('loader', 'assets/demoscene/birdy-nam-nam-loader.png');
        this.load.image('click', 'assets/demoscene/birdy-nam-nam-click.png');
    }

    create ()
    {
        this.scene.start('demo');
    }
}

class Example extends Phaser.Scene
{
    loadImage;
    track;
    bird;
    egg = 0;
    chick1;
    chick2;
    chick3;

    preload ()
    {
        // this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.loadImage = this.add.image(0, 0, 'loader').setOrigin(0);

        this.load.audio('jungle', [ 'assets/audio/jungle.ogg', 'assets/audio/jungle.mp3' ]);
        this.load.animation('birdyAnims', 'assets/demoscene/birdy.json');
        this.load.image('bg1', 'assets/demoscene/birdy-nam-nam-bg1.png');
        this.load.image('bg2', 'assets/demoscene/birdy-nam-nam-bg2.png');
        this.load.atlas('birdy', 'assets/demoscene/budbrain.png', 'assets/demoscene/budbrain.json');
    }

    create ()
    {
        this.sound.pauseOnBlur = false;

        this.track = this.sound.add('jungle');

        this.anims.create({
            key: 'lay',
            frames: this.anims.generateFrameNames('birdy', { prefix: 'lay', start: 0, end: 19 }),
            frameRate: 28

            // delay: 1
        });

        if (this.sound.locked)
        {
            this.loadImage.setTexture('click');

            this.sound.once('unlocked', () =>
            {
                this.startDemo();
            });
        }
        else
        {
            this.startDemo();
        }
    }

    startDemo ()
    {
        this.loadImage.setVisible(false);

        this.add.image(0, 0, 'bg1').setOrigin(0);

        this.bird = this.add.sprite(328, 152, 'birdy', 'lay0').setOrigin(0).setDepth(10);

        this.bird.on('animationcomplete', this.dropEgg);

        this.bird.anims.playAfterDelay('lay', 2250);

        // track.once('play', function ()
        // {
        //     bird.anims.playAfterDelay('lay', 2250);
        // });

        this.track.play();
    }

    dropEgg ()
    {
        console.log('dropEgg');

        const smallEgg = this.add.image(this.bird.x + 116, 228, 'birdy', 'egg-small').setOrigin(0);

        this.tweens.add({
            targets: smallEgg,
            y: 288,
            ease: 'Linear',
            delay: 800,
            duration: 200,
            completeDelay: 800,
            onComplete: this.moveBird,
            callbackScope: this
        });

        this.egg++;
    }

    moveBird ()
    {
        console.log('moveBird', this.egg, this.bird.x);

        if (this.egg < 3)
        {
            this.bird.x -= 124;

            this.bird.setFrame('lay0');

            this.bird.anims.play({ key: 'lay', delay: 0, frameRate: 28 });
        }
        else
        {
            //  Ready for scene 2
            // this.time.addEvent({ delay: 800, callback: changeScene, callbackScope: this });
        }
    }

    changeScene ()
    {
        this.children.removeAll();

        this.add.image(0, 0, 'bg2').setOrigin(0);

        this.chick1 = this.add.sprite(100, 72, 'birdy', 'hatch1').setOrigin(0);
        this.chick2 = this.add.sprite(260, 72, 'birdy', 'hatch1').setOrigin(0);
        this.chick3 = this.add.sprite(420, 72, 'birdy', 'hatch1').setOrigin(0);

        this.chick1.anims.playAfterDelay('hatch', 1000 - 200);
        this.chick2.anims.playAfterDelay('hatch', 2000 - 200);
        this.chick3.anims.playAfterDelay('hatch', 3000 - 200);

        this.time.addEvent({ delay: 4500, callback: () => this.checkDisOut()});
    }

    checkDisOut ()
    {
        this.chick1.anims.play('lookRight');
        this.chick2.anims.play('checkDisOut');
        this.chick3.anims.play('lookLeft');
    }
}

const loaderSceneConfig = {
    key: 'loader',
    active: true,
    scene: BootLoader
};

const demoSceneConfig = {
    key: 'demo',
    active: false,
    visible: false,
    scene: Example
};

const config = {
    type: Phaser.AUTO,
    parent: 'phaser-example',
    width: 640,
    height: 338,
    scene: [ loaderSceneConfig, demoSceneConfig ]
};

const game = new Phaser.Game(config);

Архитектура сцен: разделение загрузки и логики

Проект использует две сцены: BootLoader и Example. Первая отвечает только за начальную загрузку минимального набора ресурсов (изображений для индикации загрузки), вторая — за основную логику демо. Это хорошая практика: показывать пользователю индикатор прогресса, пока на фоне грузятся тяжелые ассеты (анимации, звуки, атласы).

class BootLoader extends Phaser.Scene
{
    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('loader', 'assets/demoscene/birdy-nam-nam-loader.png');
        this.load.image('click', 'assets/demoscene/birdy-nam-nam-click.png');
    }

    create ()
    {
        this.scene.start('demo');
    }
}

После создания BootLoader сразу запускает основную сцену demo. Основная сцена Example уже имеет свой метод preload, где загружаются все остальные ресурсы. Обратите внимание, что setBaseURL здесь закомментирован, так как он уже был установлен в загрузочной сцене — это глобальная настройка загрузчика.

Управление аудио и обработка пользовательского взаимодействия

В Phaser 3 воспроизведение аудио в браузерах часто требует жеста пользователя (например, клика). Код в методе create основной сцены корректно обрабатывает эту особенность.

create ()
{
    this.sound.pauseOnBlur = false;
    this.track = this.sound.add('jungle');

    if (this.sound.locked)
    {
        this.loadImage.setTexture('click');
        this.sound.once('unlocked', () =>
        {
            this.startDemo();
        });
    }
    else
    {
        this.startDemo();
    }
}

Свойство this.sound.locked проверяет, заблокирован ли аудиоконтекст браузера. Если да, то изображение-заглушка меняется на картинку с призывом кликнуть (click). Как только пользователь совершит взаимодействие, сработает событие unlocked, и демо запустится. Флаг pauseOnBlur = false предотвращает паузу аудио, когда пользователь переключается на другую вкладку браузера.

Создание анимации из атласа и запуск по таймеру

Анимации загружаются из JSON-файла, но также могут быть созданы вручную через this.anims.create. В этом примере анимация lay создаётся из кадров атласа birdy.

this.anims.create({
    key: 'lay',
    frames: this.anims.generateFrameNames('birdy', { prefix: 'lay', start: 0, end: 19 }),
    frameRate: 28
});

Функция generateFrameNames генерирует массив кадров, ища в атласе спрайты с именами lay0, lay1, ..., lay19. После создания анимации её можно проиграть на спрайте с задержкой:

this.bird.anims.playAfterDelay('lay', 2250);

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

Цепочка событий: от анимации к твину и обратно

Логика демо построена как цепочка колбэков. Когда анимация кладки яйца завершается, генерируется событие animationcomplete, которое вызывает метод dropEgg.

this.bird.on('animationcomplete', this.dropEgg);

Внутри dropEgg создаётся спрайт яйца, который затем анимируется по вертикали с помощью твина. Обратите внимание на параметры твина:

this.tweens.add({
    targets: smallEgg,
    y: 288,
    ease: 'Linear',
    delay: 800,
    duration: 200,
    completeDelay: 800,
    onComplete: this.moveBird,
    callbackScope: this
});

После задержки (delay) в 800 мс яйцо двигается вниз за 200 мс, затем следует ещё одна пауза (completeDelay), и только после этого срабатывает onComplete, вызывая moveBird. Параметр callbackScope: this критически важен — он гарантирует, что внутри moveBird контекст this будет ссылаться на текущую сцену, а не на объект твина.

Управление состоянием сцены и переход между этапами

Метод moveBird использует переменную egg как счётчик, чтобы определить, сколько раз птица уже снесла яйцо. Это простой пример управления состоянием внутри сцены.

moveBird ()
{
    if (this.egg < 3)
    {
        this.bird.x -= 124;
        this.bird.setFrame('lay0');
        this.bird.anims.play({ key: 'lay', delay: 0, frameRate: 28 });
    }
    else
    {
        //  Ready for scene 2
    }
}

Если яиц меньше трёх, птица смещается влево, сбрасывается на первый кадр анимации и процесс повторяется. После третьего яйца код закомментирован, но предполагается переход ко второй части демо через changeScene. В этой части показывается новый фон и анимируются три птенца с разными задержками, используя playAfterDelay и time.addEvent для создания последовательности действий.

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

Пример демонстрирует ключевые техники для создания скриптованных последовательностей в Phaser: разделение сцен, обработку аудио-блокировки, управление анимациями через события и построение цепочек действий с помощью твинов и таймеров. Для экспериментов попробуйте

  1. раскомментировать вызов changeScene и настроить плавный переход
  2. добавить больше интерактивных элементов (например, клик по яйцу для ускорения вылупления)
  3. реализовать загрузку прогресса для всех ресурсов в BootLoader