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

Синхронизация звука и графики — ключ к созданию погружающих игр. В этом примере наглядно показано, как параметры аудио напрямую влияют на проигрывание анимации: скорость звука управляет скоростью анимации, а громкость — её прозрачностью. Вы научитесь работать с глобальными настройками звука (`SoundManager`) и отдельными звуковыми объектами, а также поймёте, как обрабатывать ограничения браузеров на автовоспроизведение.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    soundRight;
    soundLeft;
    horseRight;
    horseLeft;
    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: {
        noAudio: true
    }
};

const game = new Phaser.Game(config);

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

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

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

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

this.load.image(this.horseFrames[i].key, `assets/animations/horse/frame_${(`0${i}`).slice(-2)}_delay-0.05s.png`);

Аудио загружается с помощью this.load.audio, где указаны альтернативные форматы файлов (OGG и MP3) для кросс-браузерной совместимости.

Создание сцены и обработка аудио-блокировки

В методе create создаётся анимация horse из подготовленных кадров. Два спрайта лошадей (horseLeft и horseRight) добавляются на сцену и масштабируются.

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

Затем создаются и запускаются на повтор (loop: true) два звуковых объекта: soundLeft и soundRight.

Важный момент — проверка свойства this.sound.locked. Оно указывает, заблокировано ли автовоспроизведение звука политикой браузера. Если да, то выводится текст с инструкцией, и воспроизведение стартует только после события unlocked. Иначе сцена сразу запускает метод start.

if (this.sound.locked)
{
    // Создание текста
    this.sound.once('unlocked', (soundManager) =>
    {
        text.visible = false;
        this.start();
    });
}
else
{
    this.start();
}

Связывание аудио и визуала в реальном времени

Сердце примера — метод update. Здесь в каждом кадре параметры звука напрямую влияют на анимацию.

Скорость анимации (anims.timeScale) привязывается к итоговой скорости воспроизведения звука (totalRate). totalRate — это рассчитанное значение, учитывающее и rate, и detune.

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

Альфа-канал (прозрачность) спрайта вычисляется как произведение глобальной громкости (this.sound.volume) на громкость конкретного звука (this.soundLeft.volume). Это демонстрирует, как настройки разного уровня комбинируются.

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

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

this.horseLeft.visible = !this.sound.mute && !this.soundLeft.mute;

Интерактивная панель управления с dat.GUI

Метод start, вызываемый после разблокировки звука, запускает анимации и создаёт панель управления с помощью библиотеки dat.GUI. Она позволяет в реальном времени изменять параметры и сразу видеть их влияние на анимацию.

Создаются три папки: для глобального Sound Manager и для каждого из звуковых объектов (Left, Right). В каждой папке добавляются контролы для свойств mute, volume, rate и detune. Флаг .listen() заставляет интерфейс автоматически обновляться при программном изменении значений.

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

Изменение rate ускоряет или замедляет звук и анимацию. detune изменяет высоту тона в центах (100 центов = 1 полутон), что также влияет на итоговую скорость (totalRate).

Важная настройка конфигурации: `audio.noAudio`

Ключевой момент для работы примера в браузерах с жёсткой политикой автовоспроизведения — настройка audio.noAudio в конфиге игры. Установка этого параметра в true заставляет Phaser создать заглушки для аудио API, что позволяет симулировать работу звуковой системы до её разблокировки пользователем. Без этой настройки пример может не работать корректно, так как попытка создать звук через this.sound.add() до события unlocked может завершиться ошибкой.

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

Это позволяет безопасно создавать звуковые объекты и управлять их свойствами (через GUI) ещё до того, как пользователь взаимодействовал со страницей, обеспечивая плавный старт.

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

Этот пример — отличная основа для создания реактивной визуальной обратной связи на игровые события. Вы можете экспериментировать: связать скорость игрового процесса с темпом музыки, сделать пульсацию объектов в ритм или визуализировать звуковую сцену, где разные частоты управляют разными слоями анимации. Попробуйте привязать totalRate не только к анимации, но и к скорости движения частиц или вращения объектов.