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

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

Версия 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,
    audio: {
        noAudio: true
    }
};

const game = new Phaser.Game(config);

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

В методе preload происходит загрузка всех необходимых ресурсов: фонового изображения, спрайтов колонок в виде атласа и набора аудиофайлов в двух форматах (OGG и MP3) для обеспечения кросс-браузерной совместимости.

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' ]);

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

Работа с маркерами и параметром delay

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

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

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

// Правильно: задержка передается в config
this.bass.play('loop', {
    delay: 0
});

// Неправильно: так задержка не применится
// sound.delay = delay;
// sound.play('loop');

Последовательное добавление звуковых слоев

Логика построения композиции реализована через цепочку событий looped. Каждый звуковой объект, после того как его маркер loop завершит первое полное воспроизведение (первую итерацию цикла), генерирует событие looped.

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

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

drums.play('loop', {
    delay: loopMarker.duration // Запуск через 7.68 сек после старта bass
});

Функция startStem не только запускает твин-анимацию для соответствующей колонки, но и добавляет панель управления громкостью и позицией воспроизведения (seek) для данного звукового слоя в интерфейс dat.GUI.

Синхронизация анимации и обработка автозапуска

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

this.middleSpeaker.y = this.bottomSpeaker.y - this.bottomSpeaker.height * this.bottomSpeaker.scaleY;

Также здесь синхронизируется глобальная шкала времени всех твинов (this.tweens.setGlobalTimeScale) со скоростью воспроизведения основного баса (this.bass.totalRate). Если пользователь меняет скорость (rate) через GUI, все анимации будут замедляться или ускоряться соответственно.

Пример учитывает политику автозапуска аудио в браузерах. Если звуковая система заблокирована (this.sound.locked), на экране появляется надпись "Tap to start", и цепочка запуска звуков начинается только после события unlocked.

if (this.sound.locked) {
    this.sound.once('unlocked', function (soundManager) {
        text.visible = false;
        this.startStem.call(this, this.bass, 'Bass', this.bottomSpeaker);
    }, this);
}

Особенности конфигурации проекта

В конфигурации игры (config) важно обратить внимание на два момента. Во-первых, включен режим pixelArt: true, который отключает сглаживание при масштабировании спрайтов, сохраняя четкость пиксельной графики.

const config = {
    // ... другие настройки ...
    pixelArt: true,
    audio: {
        noAudio: true
    }
};

Во-вторых, в секции audio установлен флаг noAudio: true. Этот флаг указывает Phaser создать заглушку для звуковой системы, если на устройстве нет реального аудио API (например, в некоторых тестовых средах). Это предотвращает ошибки при инициализации, но в реальном проекте, ориентированном на аудио, этот флаг обычно не используется.

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

Пример наглядно показывает мощь и гибкость звуковой системы Phaser для создания синхронизированных аудио-последовательностей. Используя маркеры, события и параметр delay, вы можете проектировать сложные музыкальные конструкции, идеально подходящие для ритм-игр, интерактивных саундтреков или поэтапного раскрытия игровой атмосферы. Для экспериментов попробуйте изменить длительность маркера, добавить больше звуковых слоев, реализовать нелинейную логику запуска (например, по клику на определенную колонку) или привязать параметры rate и detune из GUI к игровым событиям.