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

В музыкальных играх и интерактивных инсталляциях часто требуется запускать несколько аудиодорожек не одновременно, а с точной задержкой, создавая эффект постепенного наложения звуковых слоев (стеймов). Пример из официальной галереи Phaser демонстрирует, как использовать маркеры, события и параметр `delay` для синхронизированного воспроизведения композиции, где каждый новый инструмент вступает строго после завершения первого цикла предыдущего. Этот подход полезен для создания динамичных саундтреков, обучающих последовательностей или сложных аудио-визуальных эффектов, где timing — это всё.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    bass;
    gui;
    bottomSpeaker;
    middleSpeaker;
    topRightSpeaker;
    topLeftSpeaker;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.bitmapFont('atari-classic', 'assets/fonts/bitmap/atari-classic.png', 'assets/fonts/bitmap/atari-classic.xml');

        this.load.image('streets', 'assets/sprites/cyberpunk-street.png');

        this.load.atlas('speakers', 'assets/sprites/speakers/speakers.png', 'assets/sprites/speakers/speakers.json');

        this.load.audio('bass', [ 'assets/audio/tech/bass.ogg', 'assets/audio/tech/bass.mp3' ]);
        this.load.audio('drums', [ 'assets/audio/tech/drums.ogg', 'assets/audio/tech/drums.mp3' ]);
        this.load.audio('percussion', [ 'assets/audio/tech/percussion.ogg', 'assets/audio/tech/percussion.mp3' ]);
        this.load.audio('synth1', [ 'assets/audio/tech/synth1.ogg', 'assets/audio/tech/synth1.mp3' ]);
        this.load.audio('synth2', [ 'assets/audio/tech/synth2.ogg', 'assets/audio/tech/synth2.mp3' ]);
        this.load.audio('top1', [ 'assets/audio/tech/top1.ogg', 'assets/audio/tech/top1.mp3' ]);
        this.load.audio('top2', [ 'assets/audio/tech/top2.ogg', 'assets/audio/tech/top2.mp3' ]);
    }

    create ()
    {
        const streets = this.add.image(0, 0, 'streets');
        streets.setScale(600 / 192);
        streets.setOrigin(0);

        this.topLeftSpeaker = this.add.image(445, 332, 'speakers', 'top-left');
        this.topLeftSpeaker.setOrigin(1, 0.46);
        this.topRightSpeaker = this.add.image(445, 332, 'speakers', 'top-right');
        this.topRightSpeaker.setOrigin(0, 0.975);
        this.middleSpeaker = this.add.image(443, 417, 'speakers', 'middle');
        this.middleSpeaker.setOrigin(0.5, 1);
        this.bottomSpeaker = this.add.image(443, 504, 'speakers', 'bottom');
        this.bottomSpeaker.setOrigin(0.5, 1);

        this.bass = this.sound.add('bass');
        const drums = this.sound.add('drums');
        const percussion = this.sound.add('percussion');
        const synth1 = this.sound.add('synth1');
        const synth2 = this.sound.add('synth2');
        const top1 = this.sound.add('top1');
        const top2 = this.sound.add('top2');

        this.gui = new dat.GUI();
        const sm = this.gui.addFolder('Sound Manager');
        sm.add(this.sound, 'rate', 0.5, 2).listen();
        sm.add(this.sound, 'detune', -1200, 1200).step(50).listen();

        const loopMarker = {
            name: 'loop',
            start: 0,
            duration: 7.68,
            config: {
                loop: true
            }
        };

        if (this.sound.locked)
        {
            const text = this.add.bitmapText(400, 70, 'atari-classic', 'Tap to start', 40);
            text.x -= Math.round(text.width / 2);
            text.y -= Math.round(text.height / 2);

            this.sound.once('unlocked', function (soundManager)
            {
                text.visible = false;

                this.startStem.call(this, this.bass, 'Bass', this.bottomSpeaker);

            }, this);
        }
        else
        {
            this.startStem.call(this, this.bass, 'Bass', this.bottomSpeaker);
        }

        this.bass.addMarker(loopMarker);

        // Delay option can only be passed in config
        this.bass.play('loop', {
            delay: 0
        });

        // Below won't work

        // sound.delay = delay;
        // sound.play('loop');

        this.bass.once('looped', function (sound)
        {
            this.startStem.call(this, drums, 'Drums', this.middleSpeaker);
        }, this);

        drums.addMarker(loopMarker);
        drums.play('loop', {
            delay: loopMarker.duration
        });
        drums.once('looped', function (sound)
        {
            this.startStem.call(this, percussion, 'Percussion', this.middleSpeaker);
        }, this);

        percussion.addMarker(loopMarker);
        percussion.play('loop', {
            delay: loopMarker.duration * 2
        });
        percussion.once('looped', function (sound)
        {
            this.startStem.call(this, synth1, 'Synth 1', this.topRightSpeaker);
        }, this);

        synth1.addMarker(loopMarker);
        synth1.play('loop', {
            delay: loopMarker.duration * 3
        });
        synth1.once('looped', function (sound)
        {
            this.startStem.call(this, synth2, 'Synth 2', this.topRightSpeaker);
        }, this);

        synth2.addMarker(loopMarker);
        synth2.play('loop', {
            delay: loopMarker.duration * 4
        });
        synth2.once('looped', function (sound)
        {
            this.startStem.call(this, top1, 'Top 1', this.topLeftSpeaker);
        }, this);

        top1.addMarker(loopMarker);
        top1.play('loop', {
            delay: loopMarker.duration * 5
        });
        top1.once('looped', function (sound)
        {
            this.startStem.call(this, top2, 'Top 2', this.topLeftSpeaker);
            sm.open();
        }, this);

        top2.addMarker(loopMarker);
        top2.play('loop', {
            delay: loopMarker.duration * 6
        });
    }

    update ()
    {
        this.middleSpeaker.y = this.bottomSpeaker.y - this.bottomSpeaker.height * this.bottomSpeaker.scaleY;
        this.topLeftSpeaker.y =
            this.topRightSpeaker.y =
                this.middleSpeaker.y - this.middleSpeaker.height * this.middleSpeaker.scaleY;

        this.tweens.setGlobalTimeScale(this.bass.totalRate);
    }

    startStem (stem, text, speaker)
    {
        const s = this.gui.addFolder(text);
        s.add(stem, 'seek', 0, stem.duration).step(0.01).listen();
        s.add(stem, 'mute').listen();
        s.open();

        this.tweens.add({
            targets: speaker,
            scaleX: 1.3,
            scaleY: 1.1,

            duration: 241,

            ease: 'Sine.easeInOut',
            repeat: -1,
            yoyo: true
        });
    }
}

/**
 * @author    Pavle Goloskokovic <pgoloskokovic@gmail.com> (http://prunegames.com)
 *
 * Cyberpunk Street Environment by Luis Zuno (https://www.patreon.com/posts/8303915) / CC-BY-3.0
 */

const config = {
    type: Phaser.AUTO,
    parent: 'phaser-example',
    width: 800,
    height: 600,
    backgroundColor: '#838282',
    scene: Example,
    pixelArt: true
};

const game = new Phaser.Game(config);

Подготовка сцены и загрузка ресурсов

В методе preload() загружаются все необходимые ресурсы: шрифт для интерфейса, фоновое изображение и спрайты колонок, представленные в виде атласа. Ключевой момент — загрузка семи отдельных аудиофайлов, каждый из которых представляет собой один инструмент или партию (стейм) общей музыкальной композиции. Phaser автоматически выберет подходящий формат (OGG или MP3) в зависимости от браузера.

this.load.audio('bass', [ 'assets/audio/tech/bass.ogg', 'assets/audio/tech/bass.mp3' ]);
this.load.audio('drums', [ 'assets/audio/tech/drums.ogg', 'assets/audio/tech/drums.mp3' ]);
// ... остальные дорожки

Создание аудиообъектов и интерфейса

В create() сначала размещается фон и спрайты колонок, каждый из которых будет визуально реагировать на свой звуковой стейм. Затем для каждого загруженного аудиоключа создается объект звука через this.sound.add(). Это дает независимый контроль над каждой дорожкой.

Для отладки и интерактивности подключается библиотека dat.GUI. В нее выносятся глобальные параметры менеджера звука: общая скорость воспроизведения (rate) и детюн (detune).

this.bass = this.sound.add('bass');
const drums = this.sound.add('drums');
// ...
this.gui = new dat.GUI();
const sm = this.gui.addFolder('Sound Manager');
sm.add(this.sound, 'rate', 0.5, 2).listen();

Работа с аудиомаркерами и задержкой (delay)

Сердце примера — использование маркеров (markers). Маркер определяет именованный сегмент внутри звукового файла. Здесь создается маркер loop, который указывает на начало (0) и длину (7.68 секунд) сегмента, а также задает его зацикленность.

**Критически важный нюанс:** параметр задержки delay можно передать ТОЛЬКО в конфигурационном объекте метода play(). Попытка установить свойство sound.delay отдельно не сработает, о чем явно сказано в комментарии кода.

const loopMarker = {
    name: 'loop',
    start: 0,
    duration: 7.68,
    config: { loop: true }
};
this.bass.addMarker(loopMarker);
// Задержка задается здесь, в config
this.bass.play('loop', { delay: 0 });
// А так НЕ сработает: sound.delay = delay; sound.play('loop');

Каскадный запуск стеймов по событию 'looped'

Чтобы каждый следующий инструмент вступал ровно после первого полного цикла предыдущего, используется событие looped. Оно срабатывает, когда воспроизведение маркера с конфигом loop: true завершает один круг.

В обработчике этого события для текущего звука запускается следующий стейм. Формула для расчета задержки проста: delay: loopMarker.duration * N, где N — порядковый номер инструмента в цепочке. Это гарантирует безупречную синхронизацию.

this.bass.once('looped', function (sound) {
    this.startStem.call(this, drums, 'Drums', this.middleSpeaker);
}, this);
// Следующий звук начнется с задержкой в длину одного маркера
drums.play('loop', { delay: loopMarker.duration });

Визуальная обратная связь и анимация

Функция startStem() отвечает за визуализацию. Она добавляет в GUI панель управления для конкретного стейма (ползунок перемотки seek и переключатель mute) и запускает твин на спрайте колонки, заставляя ее пульсировать в такт музыке.

В методе update() происходит непрерывное выравнивание позиций колонок друг относительно друга, создавая целостную визуальную конструкцию. Также здесь используется мощная связка: глобальная скорость всех твинов (this.tweens.setGlobalTimeScale) привязывается к общей скорости воспроизведения басовой дорожки (this.bass.totalRate). Это значит, что при изменении темпа музыки через GUI анимация также ускорится или замедлится, сохраняя полную синхронизацию звука и картинки.

this.tweens.setGlobalTimeScale(this.bass.totalRate);

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

Пример наглядно показывает, как в Phaser можно создавать сложно синхронизированные аудио-визуальные композиции, используя встроенные возможности Sound API: маркеры, параметр delay и событийную модель. Для экспериментов попробуйте изменить длину loopMarker.duration, добавить больше звуковых слоев или привязать параметры твинов (например, интенсивность пульсации) не к глобальному таймскейлу, а к анализу громкости (volume) конкретного стейма в реальном времени.