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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    soundRight;
    soundLeft;
    horseRight;
    horseLeft;
    i = 0;
    horseFrames = [];

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

        for (let i = 0; i < 12; i++)
        {
            this.horseFrames.push({
                key: `horse${(`0${i}`).slice(-2)}`,
                frame: '__BASE'
            });
        }

        // Loading horse animation
        for (let i = 0; i < this.horseFrames.length; i++)
        {
            this.load.image(this.horseFrames[i].key, `assets/animations/horse/frame_${(`0${i}`).slice(-2)}_delay-0.05s.png`);
        }

        // Loading music
        this.load.audio('left', [
            'assets/audio/Rossini - William Tell Overture (8 Bits Version)/left.ogg',
            'assets/audio/Rossini - William Tell Overture (8 Bits Version)/left.mp3'
        ]);
        this.load.audio('right', [
            'assets/audio/Rossini - William Tell Overture (8 Bits Version)/right.ogg',
            'assets/audio/Rossini - William Tell Overture (8 Bits Version)/right.mp3'
        ]);
    }

    create ()
    {
        this.anims.create({
            key: 'horse',
            frames: this.horseFrames,
            frameRate: 20,
            repeat: -1
        });

        this.horseLeft = this.add.sprite(200, 300, 'horse09');
        this.horseLeft.setScale(400 / 480);

        this.horseRight = this.add.sprite(600, 300, 'horse10');
        this.horseRight.setScale(400 / 480);

        this.soundLeft = this.sound.add('left');
        this.soundLeft.play({
            loop: true
        });

        this.soundRight = this.sound.add('right');
        this.soundRight.play({
            loop: true
        });

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

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

                this.start();
            });
        }
        else
        {
            this.start();
        }
    }

    update ()
    {
        this.horseLeft.anims.timeScale = this.soundLeft.totalRate;
        this.horseRight.anims.timeScale = this.soundRight.totalRate;

        this.horseLeft.setAlpha(this.sound.volume * this.soundLeft.volume);
        this.horseRight.setAlpha(this.sound.volume * this.soundRight.volume);

        this.horseLeft.visible = !this.sound.mute && !this.soundLeft.mute;
        this.horseRight.visible = !this.sound.mute && !this.soundRight.mute;
    }

    start ()
    {
        this.horseLeft.play('horse');
        this.horseRight.play('horse');

        const gui = new dat.GUI();

        const sm = gui.addFolder('Sound Manager');
        sm.add(this.sound, 'mute').listen();
        sm.add(this.sound, 'volume', 0, 1).listen();
        sm.add(this.sound, 'rate', 0.5, 2).listen();
        sm.add(this.sound, 'detune', -1200, 1200).step(50).listen();
        sm.open();

        const sl = gui.addFolder('Left');
        sl.add(this.soundLeft, 'mute').listen();
        sl.add(this.soundLeft, 'volume', 0, 1).listen();
        sl.add(this.soundLeft, 'rate', 0.5, 2).listen();
        sl.add(this.soundLeft, 'detune', -1200, 1200).step(50).listen();
        sl.open();

        const sr = gui.addFolder('Right');
        sr.add(this.soundRight, 'mute').listen();
        sr.add(this.soundRight, 'volume', 0, 1).listen();
        sr.add(this.soundRight, 'rate', 0.5, 2).listen();
        sr.add(this.soundRight, 'detune', -1200, 1200).step(50).listen();
        sr.open();
    }
}

/**
 * @author    Pavle Goloskokovic <pgoloskokovic@gmail.com> (http://prunegames.com)
 *
 * Images by Walter Newton (http://walternewton.tumblr.com/post/58684376490/like-a-train-in-the-night)
 * Music by Classical 8 Bit (http://classical8bit.tumblr.com/) / CC BY
 */

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

for (; this.i < 12; this.i++)
{
    this.horseFrames.push({
        key: `horse${(`0${this.i}`).slice(-2)}`,
        frame: '__BASE'
    });
}

const game = new Phaser.Game(config);

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

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

for (let i = 0; i < 12; i++)
{
    this.horseFrames.push({
        key: `horse${(`0${i}`).slice(-2)}`,
        frame: '__BASE'
    });
}

Затем, в create(), создаётся анимация horse из подготовленных кадров. Важный момент: в конфигурации игры (config) указан параметр pixelArt: true, что обеспечивает чёткое отображение пиксельной графики, и audio: { disableWebAudio: true }, который заставляет Phaser использовать HTML5 Audio вместо Web Audio API. Это может быть необходимо для совместимости или специфического поведения.

Создание звуков и обработка блокировки

После загрузки создаются два звуковых экземпляра (soundLeft и soundRight) с помощью this.sound.add() и сразу запускаются в режиме зацикливания. В современных браузерах воспроизведение аудио может быть заблокировано до первого взаимодействия пользователя. Код проверяет свойство this.sound.locked.

if (this.sound.locked)
{
    const text = this.add.bitmapText(400, 50, 'atari-classic', 'Tap to start', 40);
    // ... позиционирование текста
    this.sound.once('unlocked', (soundManager) =>
    {
        text.visible = false;
        this.start();
    });
}
else
{
    this.start();
}

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

Динамическая связь звука и анимации в update()

Вся магия синхронизации происходит в методе update(), который вызывается каждый кадр. Скорость анимации (anims.timeScale) каждого спрайта привязывается к итоговой скорости воспроизведения соответствующего звука. Итоговая скорость (totalRate) — это произведение глобальной rate и локальной rate звукового экземпляра.

this.horseLeft.anims.timeScale = this.soundLeft.totalRate;
this.horseRight.anims.timeScale = this.soundRight.totalRate;

Прозрачность (alpha) спрайтов вычисляется на основе произведения глобальной громкости и громкости каждого экземпляра. Видимость (visible) спрайтов отключается, если включён глобальный или локальный mute.

this.horseLeft.setAlpha(this.sound.volume * this.soundLeft.volume);
this.horseRight.visible = !this.sound.mute && !this.soundRight.mute;

Таким образом, любое изменение параметров звука через UI мгновенно отражается на анимации.

Интерактивное управление параметрами через dat.GUI

Метод start() создаёт интерактивную панель управления с помощью библиотеки dat.GUI. Она разделена на три папки: для глобального менеджера звуков (SoundManager) и для каждого из звуковых экземпляров.

const sm = gui.addFolder('Sound Manager');
sm.add(this.sound, 'mute').listen();
sm.add(this.sound, 'volume', 0, 1).listen();
sm.add(this.sound, 'rate', 0.5, 2).listen();
sm.add(this.sound, 'detune', -1200, 1200).step(50).listen();

Метод .listen() заставляет элементы GUI автоматически обновляться при программном изменении значений. Параметры: - mute: включение/выключение звука. - volume: громкость от 0 до 1. - rate: скорость воспроизведения (0.5 — в два раза медленнее, 2 — в два раза быстрее). - detune: изменение тона в центах (полутон = 100 центов).

Это позволяет в реальном времени экспериментировать с настройками и сразу видеть и слышать результат.

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

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