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

Создание сложных музыкальных композиций в играх часто требует точной синхронизации нескольких аудиодорожек. Пример из официальной коллекции Phaser демонстрирует, как использовать Web Audio API для последовательного запуска звуковых 'степов' (инструментов) с заданной задержкой, создавая эффект постепенного наложения слоёв музыки. Этот подход полезен для построения динамичных саундтреков, где музыка развивается по мере игры, или для синхронизации звуковых эффектов с визуальными событиями.

Версия 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() загружаются все необходимые ресурсы: фоновое изображение, спрайты колонок в виде атласа и набор аудиофайлов в форматах 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().

this.bass = this.sound.add('bass');
const drums = this.sound.add('drums');
// ... создание объектов для других дорожек

Маркеры и конфигурация циклического воспроизведения

Ключевой элемент для синхронизации — использование маркеров (markers). Маркер позволяет определить именованный сегмент внутри звукового файла и параметры его воспроизведения. В примере создаётся один маркер с именем 'loop', который определяет сегмент длиной 7.68 секунд, настроенный на зацикливание.

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

Этот маркер добавляется к каждому звуковому объекту с помощью метода addMarker.

this.bass.addMarker(loopMarker);

Запуск дорожек с задержкой и цепочка событий

Чтобы дорожки запускались не одновременно, а последовательно, используется параметр delay в конфигурационном объекте метода play(). Важно: параметр задержки можно передать только здесь, установка свойства sound.delay после создания не сработает.

// Правильно: задержка передаётся в config
this.bass.play('loop', {
    delay: 0
});
// Для следующей дорожки задержка равна длине маркера
drums.play('loop', {
    delay: loopMarker.duration
});

Синхронизация последующих слоёв строится на событии looped, которое генерируется после первого полного проигрывания цикла. Обработчик этого события запускает следующую дорожку, создавая каскадный эффект.

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

Визуальная обратная связь и управление

Для визуализации активности каждого аудиослоя используется библиотека dat.GUI, создающая панель управления, и твины для анимации спрайтов колонок.

Метод startStem() выполняет две задачи: 1. Добавляет для дорожки папку в GUI с ползунком перемотки (seek) и переключателем заглушения (mute). 2. Запускает повторяющуюся анимацию пульсации (tween) для соответствующего спрайта колонки.

this.tweens.add({
    targets: speaker,
    scaleX: 1.3,
    scaleY: 1.1,
    duration: 241,
    ease: 'Sine.easeInOut',
    repeat: -1,
    yoyo: true
});

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

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

Пример наглядно показывает, как в Phaser можно создавать сложные, синхронизированные аудиокомпозиции, используя маркеры, задержку воспроизведения и события звуковой системы. Для экспериментов попробуйте изменить длину маркера duration, чтобы создать другой ритмический рисунок, или используйте разные значения задержки для каждого слоя, чтобы получить более сложные музыкальные паттерны. Также можно привязать запуск новых дорожек не к событию looped, а, например, к кликам игрока или достижению определённых очков, сделав музыку интерактивной.