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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    bass;
    gui;
    bitmapFont;
    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 ()
    {
        /*
         * These values are used to compensate for lags, caused by
         * time difference between calling play method on an audio
         * tag element and it actually staring to play audio and
         * changing audio tag playback position, in order to achieve
         * gapless looping and precise delayed playback.
         *
         * You might need to tweak this value to get the desired results
         * since lags vary depending on the browser/platform.
         */
        this.sound.audioPlayDelay = 0.1;
        this.sound.loopEndOffset = 0.05;

        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: {
        disableWebAudio: true
    }
};

const game = new Phaser.Game(config);

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

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

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

this.sound.audioPlayDelay = 0.1;
this.sound.loopEndOffset = 0.05;

Затем на сцену добавляются фоновое изображение и спрайты динамиков, которые позже будут анимированы.

Создание звуков и управление через GUI

Для каждого загруженного аудиофайла создаётся объект звука с помощью this.sound.add(). Эти объекты дают детальный контроль над каждым аудио stem (отдельной дорожкой).

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

С помощью библиотеки dat.GUI создаётся панель управления. На ней можно в реальном времени регулировать глобальные параметры: скорость воспроизведения (rate) и детюн (detune). Это влияет на все звуки, так как свойства меняются у самого SoundManager (this.sound).

Маркеры лупов и точное планирование

Сердце примера — использование маркеров. Маркер определяет фрагмент аудио для зацикливания: его начало, длину и параметры (в данном случае loop: true).

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

Маркер добавляется к каждому звуковому объекту методом addMarker(). Ключевой момент — использование параметра delay в конфиге метода play(). Этот параметр позволяет отложить старт воспроизведения маркера на заданное количество секунд. Именно так достигается последовательное, пошаговое вступление дорожек.

this.bass.play('loop', {
    delay: 0 // Басс стартует сразу
});

drums.play('loop', {
    delay: loopMarker.duration // Ударные через одну длину лупа
});

Важно: параметр delay работает только при передаче в конфигурационном объекте метода play(). Установка свойства sound.delay отдельно не даст эффекта.

Синхронизация и визуальная обратная связь

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

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

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

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

Важная конфигурация: отключение Web Audio

В конфигурации игры есть критически важный параметр, который делает этот пример возможным. По умолчанию Phaser использует Web Audio API, но для данного метода точного контроля времени через delay и маркеры необходимо использовать HTML5 Audio.

const config = {
    // ... другие настройки
    audio: {
        disableWebAudio: true // Принудительно используем HTML5 Audio
    }
};

Именно HTML5 Audio API предоставляет нужное поведение для свойств audioPlayDelay и loopEndOffset, а также для работы маркеров с задержкой. Без этой настройки код будет вести себя иначе.

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

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