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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    tests = [

        function (fn)
        {
            this.first.once('play', function (sound)
            {
                this.text.setText('Playing');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.play();
        },

        function (fn)
        {
            this.first.once('pause', function (sound)
            {
                this.text.setText('Paused');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.pause();
        },

        function (fn)
        {
            this.first.once('resume', function (sound)
            {
                this.text.setText('Resuming');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.resume();
        },

        function (fn)
        {
            this.first.once('stop', function (sound)
            {
                this.text.setText('Stopped');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.stop();
        },

        function (fn)
        {
            this.first.once('play', function (sound)
            {
                this.text.setText('Play from start');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.play();
        },

        function (fn)
        {
            this.first.once('rate', function (sound, value)
            {
                this.text.setText('Speed up rate');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.rate = 1.5;
        },

        function (fn)
        {
            this.first.once('detune', function (sound, value)
            {
                this.text.setText('Speed up detune');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.detune = 600;
        },

        function (fn)
        {
            this.first.once('rate', function (sound, value)
            {
                this.text.setText('Slow down rate');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.rate = 1;
        },

        function (fn)
        {
            this.first.once('detune', function (sound, value)
            {
                this.text.setText('Slow down detune');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.detune = 0;
        },

        function (fn)
        {
            this.tweens.add({

                onStart: function ()
                {
                    this.text.setText('Fade out');
                },

                targets: this.first,
                volume: 0,

                ease: 'Linear',
                duration: 2000,

                onComplete: fn
            });
        },

        function (fn)
        {
            this.tweens.add({

                onStart: function ()
                {
                    this.text.setText('Fade in');
                },

                targets: this.first,
                volume: 1,

                ease: 'Linear',
                duration: 2000,

                onComplete: fn
            });
        },

        function (fn)
        {
            this.first.once('mute', function ()
            {
                this.text.setText('Mute');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.mute = true;
        },

        function (fn)
        {
            this.first.once('mute', function ()
            {
                this.text.setText('Unmute');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.mute = false;
        },

        function (fn)
        {
            this.first.once('volume', function ()
            {
                this.text.setText('Half volume');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.volume = 0.5;
        },

        function (fn)
        {
            this.first.once('volume', function ()
            {
                this.text.setText('Full volume');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.volume = 1;
        },

        function (fn)
        {
            this.first.once('seek', function ()
            {
                this.text.setText('Seek to start');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.first.seek = 0;
        },

        function (fn)
        {
            this.second.once('play', function ()
            {
                this.text.setText('Play 2nd');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.second.play();
        },

        function (fn)
        {
            this.sound.once('mute', function (soundManager, value)
            {
                this.text.setText('Mute global');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.sound.mute = true;
        },

        function (fn)
        {
            this.sound.once('mute', function (soundManager, value)
            {
                this.text.setText('Unmute global');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.sound.mute = false;
        },

        function (fn)
        {
            this.sound.once('volume', function (soundManager, value)
            {
                this.text.setText('Half volume global');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.sound.volume = 0.5;
        },

        function (fn)
        {
            this.tweens.add({

                onStart: function ()
                {
                    this.text.setText('Fade out global');
                },

                targets: this.sound,
                volume: 0,

                ease: 'Linear',
                duration: 2000,

                onComplete: fn
            });
        },

        function (fn)
        {
            this.tweens.add({

                onStart: function ()
                {
                    this.text.setText('Fade in global');
                },

                targets: this.sound,
                volume: 1,

                ease: 'Linear',
                duration: 2000,

                onComplete: fn
            });
        },

        function (fn)
        {
            this.sound.once('pauseall', function (soundManager)
            {
                this.text.setText('Pause all');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.sound.pauseAll();
        },

        function (fn)
        {
            this.sound.once('resumeall', function (soundManager)
            {
                this.text.setText('Resume all');
                this.time.addEvent({
                    delay: 2000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.sound.resumeAll();
        },

        function (fn)
        {
            this.sound.once('stopall', function (soundManager)
            {
                this.text.setText('Stop all');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.sound.stopAll();
        },

        function (fn)
        {
            this.audioSprite.once('play', function (sound)
            {
                this.text.setText('Play sprite');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.audioSprite.play('07');
        },

        function (fn)
        {
            this.audioSprite.once('pause', function (sound)
            {
                this.text.setText('Pause sprite');
                this.time.addEvent({
                    delay: 1000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.audioSprite.pause();
        },

        function (fn)
        {
            this.audioSprite.once('resume', function (sound)
            {
                this.text.setText('Resume sprite');
                this.time.addEvent({
                    delay: 1500,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.audioSprite.resume();
        },

        function (fn)
        {
            this.audioSprite.once('play', function (sound)
            {
                this.text.setText('Multiple sprites');
                this.time.addEvent({
                    delay: 10000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            const sounds = [ '01', '02', '03', '03', '05' ];

            for (let i = 0; i < sounds.length; i++)
            {
                this.time.addEvent({
                    delay: i * 2000,
                    callback: this.audioSprite.play.bind(this.audioSprite, sounds[i]),
                    callbackScope: this.audioSprite
                });
            }
        },

        function (fn)
        {
            this.audioSprite.once('play', function (sound)
            {
                this.text.setText('Loop sprite');
                this.time.addEvent({
                    delay: 4000,
                    callback: fn,
                    callbackScope: this
                });
            }, this);

            this.audioSprite.play('06', {
                loop: true
            });
        },

        function (fn)
        {
            this.tweens.add({

                onStart: function ()
                {
                    this.text.setText('Fade out sprite');
                },

                targets: this.audioSprite,
                volume: 0,

                ease: 'Linear',
                duration: 4000,

                onComplete: function ()
                {
                    this.audioSprite.volume = 1;
                    this.audioSprite.stop();

                    fn();
                }
            });
        }
    ];

    audioSprite;
    second;
    first;
    text;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        const head = document.getElementsByTagName('head')[0];
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = 'https://fonts.googleapis.com/css?family=Sorts+Mill+Goudy';
        head.appendChild(link);

        this.load.image('prometheus', 'assets/pics/Prometheus Brings Fire To Mankind.jpg');

        this.load.audio('overture', [
            'assets/audio/Ludwig van Beethoven - The Creatures of Prometheus, Op. 43/Overture.ogg',
            'assets/audio/Ludwig van Beethoven - The Creatures of Prometheus, Op. 43/Overture.mp3'
        ]);

        this.load.audioSprite('creatures', 'assets/audio/Ludwig van Beethoven - The Creatures of Prometheus, Op. 43/sprites.json', [
            'assets/audio/Ludwig van Beethoven - The Creatures of Prometheus, Op. 43/sprites.ogg',
            'assets/audio/Ludwig van Beethoven - The Creatures of Prometheus, Op. 43/sprites.mp3'
        ]);
    }

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

        const prometheus = this.add.image(400, 300, 'prometheus');
        prometheus.setScale(600 / prometheus.height);

        this.text = this.add.text(400, 300, 'Loading...', {
            fontFamily: '\'Sorts Mill Goudy\', serif',
            fontSize: 80,
            color: '#fff',
            align: 'center'
        });
        this.text.setOrigin(0.5);
        this.text.setShadow(0, 1, '#888', 2);

        this.first = this.sound.add('overture', { loop: true });
        this.second = this.sound.add('overture', { loop: true });
        this.audioSprite = this.sound.addAudioSprite('creatures');

        this.enableInput.call(this);
    }

    chain (i)
    {
        return () =>
        {
            if (this.tests[i])
            {
                this.tests[i].call(this, this.chain.call(this, ++i));
            }
            else
            {
                this.text.setText('Complete!');

                this.time.addEvent({
                    delay: 5000,
                    callback: this.enableInput,
                    callbackScope: this
                });
            }
        };
    }

    enableInput ()
    {
        this.text.setText('Click to start');

        this.input.once('pointerdown', function (pointer)
        {
            this.tests[0].call(this, this.chain.call(this, 1));
        }, this);
    }
}

/**
 * @author    Pavle Goloskokovic <pgoloskokovic@gmail.com> (http://prunegames.com)
 *
 * Prometheus Brings Fire To Mankind - Painting by Heinrich Füger, 1817, Public Domain
 * The Creatures of Prometheus, Op. 43, Overture - Music by Ludwig van Beethoven, 1801, Public Domain
 */

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

const game = new Phaser.Game(config);

Структура демонстрации: цепочка тестов

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

Массив tests содержит функции-действия. Каждая функция принимает колбэк fn, который должен быть вызван для перехода к следующему тесту. Внутри тестов используется this.time.addEvent для создания задержки перед вызовом этого колбэка.

Метод chain организует последовательный вызов этих тестов, формируя замыкание для каждого индекса в массиве.

// Пример одного теста из массива
function (fn)
{
    this.first.once('play', function (sound)
    {
        this.text.setText('Playing');
        this.time.addEvent({
            delay: 2000,
            callback: fn,
            callbackScope: this
        });
    }, this);
    this.first.play();
}
// Функция chain, которая запускает тесты по порядку
chain (i)
{
    return () =>
    {
        if (this.tests[i])
        {
            this.tests[i].call(this, this.chain.call(this, ++i));
        }
        else
        {
            this.text.setText('Complete!');
            this.time.addEvent({
                delay: 5000,
                callback: this.enableInput,
                callbackScope: this
            });
        }
    };
}

Базовые методы управления звуком и их события

Класс Phaser.Sound.WebAudioSound (экземпляры this.first, this.second) предоставляет основные методы управления воспроизведением: play(), pause(), resume(), stop(). Каждое изменение состояния генерирует одноимённое событие.

Ключевой момент — использование метода .once() для подписки на событие. Это гарантирует, что обработчик сработает только один раз, что идеально подходит для последовательной демонстрации.

// Подписка на событие 'pause' и последующая пауза
this.first.once('pause', function (sound)
{
    this.text.setText('Paused');
    // ... задержка и вызов fn
}, this);
this.first.pause();

Свойства rate (скорость) и detune (детун в центах) также генерируют события при изменении. Это позволяет синхронизировать игровые процессы (например, визуальные эффекты) с изменением тональности или темпа звука.

// Установка скорости воспроизведения в 1.5 раза выше
this.first.once('rate', function (sound, value)
{
    this.text.setText('Speed up rate');
    // ...
}, this);
this.first.rate = 1.5;

Управление громкостью: от свойств до твинов

Громкостью можно управлять как напрямую, через свойства volume и mute, так и плавно, с помощью системы твинов Phaser.

Прямое изменение громкости:

this.first.volume = 0.5; // Установка громкости на 50%
this.first.mute = true;  // Отключение звука (генерирует событие 'mute')

Для создания плавных переходов (fade-in / fade-out) звук (или весь менеджер звуков) добавляется в качестве цели твина. Свойство volume анимируется от текущего значения к целевому.

this.tweens.add({
    onStart: function ()
    {
        this.text.setText('Fade out');
    },
    targets: this.first, // Цель анимации — объект звука
    volume: 0,           // Конечное значение громкости
    ease: 'Linear',
    duration: 2000,
    onComplete: fn       // Колбэк для перехода к следующему тесту
});

Важно: глобальной громкостью и mute-статусом для всех звуков управляет менеджер this.sound. Изменение его свойств влияет на все звуки в сцене и также генерирует события ('volume', 'mute').

Глобальное управление: менеджер звуков

Объект this.sound (экземпляр Phaser.Sound.WebAudioSoundManager) предоставляет методы для массового управления всеми звуками в сцене: pauseAll(), resumeAll(), stopAll(). Эти методы также генерируют соответствующие события.

// Пауза для всех звуков
this.sound.once('pauseall', function (soundManager)
{
    this.text.setText('Pause all');
    // ...
}, this);
this.sound.pauseAll();

Настройка this.sound.pauseOnBlur = false в методе create() отключает стандартное поведение Phaser, которое ставит звук на паузу при потере фокуса браузерной вкладкой. Это полезно для демонстраций и некоторых типов игр.

Глобальную громкость также можно анимировать твинами, воздействуя на менеджер звуков:

targets: this.sound, // Цель — менеджер звуков, а не отдельный звук
volume: 0,

Мощь аудиоспрайтов

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

Загрузка спрайта:

this.load.audioSprite('creatures', 'path/to/sprites.json', [
    'path/to/sprites.ogg',
    'path/to/sprites.mp3'
]);

Создание и использование объекта спрайта:

this.audioSprite = this.sound.addAudioSprite('creatures');
// Воспроизведение конкретного маркера (например, '07')
this.audioSprite.play('07');

Метод play() для аудиоспрайта принимает имя маркера и опциональный объект конфигурации. В примере показано воспроизведение с зацикливанием (loop: true) и организация последовательного воспроизведения нескольких эффектов с задержкой через this.time.addEvent.

// Последовательное воспроизведение нескольких эффектов из спрайта
const sounds = [ '01', '02', '03', '03', '05' ];
for (let i = 0; i < sounds.length; i++)
{
    this.time.addEvent({
        delay: i * 2000,
        callback: this.audioSprite.play.bind(this.audioSprite, sounds[i]),
        callbackScope: this.audioSprite
    });
}

Объект аудиоспрайта генерирует те же события (play, pause, и т.д.), что и обычный звук, что обеспечивает единый API для управления.

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

Пример охватывает практически весь API работы со звуком в Phaser 3. Вы узнали, как управлять воспроизведением, реагировать на события, создавать плавные аудиоэффекты и использовать аудиоспрайты для оптимизации. Для экспериментов попробуйте: создать систему случайного выбора и воспроизведения эффектов из спрайта; привязать изменение rate/detune к игровым событиям (например, замедлению времени); реализовать сложные аудиомиксы, управляя громкостью нескольких звуковых слоёв через твины с разными функциями плавности (ease).