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

Создание игр — это не только графика и геймплей, но и атмосфера, которую во многом формирует звук. Однако звуковые эффекты и музыка должны быть тесно связаны с визуальным рядом. Этот пример демонстрирует мощный подход к синхронизации скорости анимации (`timeScale`) с темпом воспроизведения аудио (`rate`), а также к визуальному отображению параметров звука, таких как громкость и заглушение. Вы научитесь создавать динамичные сцены, где анимация буквально "танцует" под музыку, и получите полный контроль над аудиопотоками через удобный интерфейс dat.GUI.

Версия 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 происходит подготовка всех необходимых ресурсов. Для анимации лошади загружается последовательность из 12 кадров. Ключевой момент — использование массива объектов для хранения данных о кадрах, что удобно для последующей передачи в создание анимации.

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

Загрузка самих изображений и аудиофайлов происходит в цикле и с помощью this.load.audio. Обратите внимание, что для аудио указаны два формата (ogg и mp3) для обеспечения кроссбраузерной совместимости.

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

Создание сцены: анимация, звук и разблокировка

В методе create мы создаем анимацию из подготовленных кадров и два спрайта для левой и правой лошади. Сразу создаются и запускаются на воспроизведение два звуковых объекта (this.soundLeft и this.soundRight).

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

if (this.sound.locked)
{
    this.sound.once('unlocked', (soundManager) =>
    {
        text.visible = false;
        this.start();
    });
}
else
{
    this.start();
}

Сердце синхронизации: метод update

Магия синхронизации происходит в методе update, который вызывается на каждом кадре. Здесь скорость анимации каждой лошади (anims.timeScale) привязывается к итоговой скорости воспроизведения её звуковой дорожки (totalRate). totalRate — это рассчитанное значение, учитывающее как глобальную скорость (this.sound.rate), так и локальную (this.soundLeft.rate).

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

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

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

Интерфейс для тонкой настройки: dat.GUI

Метод start() создает панель управления с помощью библиотеки dat.GUI. Это позволяет в реальном времени изменять ключевые параметры звука. Интерфейс разделен на три папки: для глобального менеджера звука (Sound Manager), для левого (Left) и правого (Right) аудиопотоков.

Флаг .listen() заставляет элементы GUI автоматически обновляться при изменении значения из кода, обеспечивая двустороннюю синхронизацию. Параметры rate (скорость) и detune (расстройка тона) имеют заданные диапазоны.

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

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

В конфигурации игры есть критически важная для этого примера настройка. Параметр audio: { noAudio: true } указывает Phaser создать полноценную аудиосистему, даже если на устройстве нет аудиоустройств или кодеков. Без этого в некоторых окружениях объекты this.sound и this.soundLeft/Right могли бы быть не созданы, что привело бы к ошибкам при обращении к их свойствам в update.

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

Эта настройка гарантирует, что API звука будет доступно для управления через GUI и для расчетов, даже если физическое воспроизведение невозможно.

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

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