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

В играх часто требуется запустить фоновую музыку или звуковой эффект не с начала, а с определенного момента. Например, при перезапуске уровня или для синхронизации звука с анимацией. Phaser позволяет это делать легко с помощью свойства `seek`. Эта статья на практическом примере показывает, как управлять позицией воспроизведения аудио, создавать визуальную обратную связь и синхронизировать звук со спрайтом.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    rainbowMask;
    catAstroPhi;
    cat;

    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('bg', 'assets/animations/nyan/bg.png');

        this.load.image('rainbow', 'assets/animations/nyan/rainbow.png');

        this.load.spritesheet('cat', 'assets/animations/nyan/cat.png', { frameWidth: 97, frameHeight: 59 });

        this.load.audio('CatAstroPhi', [
            'assets/audio/CatAstroPhi_shmup_normal.ogg',
            'assets/audio/CatAstroPhi_shmup_normal.mp3'
        ]);
    }

    create ()
    {

        this.catAstroPhi = this.sound.add('CatAstroPhi');

        this.catAstroPhi.play({
            seek: 2.550
        });

        // play() method call above has the same effect as the
        // two lines below but it is done in only one command
        // and it is a bit more efficient

        // catAstroPhi.play();
        // catAstroPhi.seek = 2.550;

        this.add.image(400, 300, 'bg');

        if (this.sound.locked)
        {
            const text = this.add.bitmapText(400, 300, '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.setup.call(this);

            }, this);
        }
        else
        {
            this.setup.call(this);
        }
    }

    update ()
    {
        if (this.cat)
        {
            this.cat.x = this.cat.width / 2 + (this.catAstroPhi.seek / this.catAstroPhi.duration) * (800 - this.cat.width);

            this.rainbowMask.clear();
            this.rainbowMask.fillStyle(0xffffff, 1);
            this.rainbowMask.fillRect(0, 0, this.cat.x - 15, 600);

            if (!this.catAstroPhi.isPlaying && this.cat.anims.isPlaying)
            {
                this.cat.anims.pause();
            }
            else if (this.catAstroPhi.isPlaying && !this.cat.anims.isPlaying)
            {
                this.cat.anims.resume();
            }

            this.cat.anims.timeScale = this.catAstroPhi.totalRate;
        }
    }

    setup ()
    {
        const gui = new dat.GUI();

        const sm = gui.addFolder('CatAstroPhi Sound');
        sm.add(this.catAstroPhi, 'seek', 0, this.catAstroPhi.duration).step(0.01).listen();
        sm.add(this.catAstroPhi, 'rate', 0.5, 2).listen();
        sm.add(this.catAstroPhi, 'detune', -1200, 1200).step(50).listen();
        sm.add(this.catAstroPhi, 'loop').listen();
        sm.add(this.catAstroPhi, 'play');
        sm.add(this.catAstroPhi, 'pause');
        sm.add(this.catAstroPhi, 'resume');
        sm.add(this.catAstroPhi, 'stop');
        sm.open();

        this.rainbowMask = this.make.graphics();

        const rainbow = this.add.image(400, 300, 'rainbow');
        rainbow.enableFilters().filters.external.addMask(this.rainbowMask);

        this.anims.create({
            key: 'cat',
            frames: this.anims.generateFrameNumbers('cat', { start: 0, end: 5, first: 0 }),
            frameRate: 15,
            repeat: -1
        });

        this.cat = this.add.sprite(0, 300, 'cat').setInteractive();
        this.cat.play('cat');

        this.input.setDraggable(this.cat);

        this.input.on('drag', (pointer, cat, dragX, dragY) =>
        {

            cat.x = Math.min(Math.max(cat.width / 2, dragX), 800 - cat.width / 2);

            this.catAstroPhi.seek = (cat.x - cat.width / 2) / (800 - cat.width) * this.catAstroPhi.duration;
        });
    }
}

/**
 * @author    Pavle Goloskokovic <pgoloskokovic@gmail.com> (http://prunegames.com)
 */

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 загружаются все необходимые ресурсы: шрифт, фоновое изображение, радуга, спрайтшит для анимации кота и аудиофайл. Обратите внимание, что для аудио указаны два формата (OGG и MP3) для лучшей кросс-браузерной поддержки.

Ключевой момент происходит в create. Сначала создается объект звука с помощью this.sound.add('CatAstroPhi'). Затем он воспроизводится методом play(), но с конфигурационным объектом, где указано свойство seek.

this.catAstroPhi = this.sound.add('CatAstroPhi');
this.catAstroPhi.play({
    seek: 2.550
});

Этот код запускает трек не с нулевой отметки, а с позиции 2.55 секунды. Как отмечено в комментарии, аналогичного результата можно добиться, вызвав play() без параметров, а затем установив свойство this.catAstroPhi.seek = 2.550. Однако первый способ — короче и эффективнее.

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

Современные браузеры блокируют воспроизведение аудио до первого взаимодействия пользователя со страницей. Phaser предоставляет удобный способ обработки этого сценария через свойство this.sound.locked.

if (this.sound.locked)
{
    const text = this.add.bitmapText(400, 300, 'atari-classic', 'Tap to start', 40);
    text.setOrigin(0.5);
    this.sound.once('unlocked', this.setup, this);
}
else
{
    this.setup.call(this);
}

Если звук заблокирован, на экране появляется текст с просьбой нажать на экран. Как только пользователь совершит действие, сработает событие unlocked, текст скроется и вызовется основной метод инициализации setup. Если блокировки нет, setup вызывается сразу.

Создание UI для управления звуком

В методе setup создается панель управления с помощью библиотеки dat.GUI. Это позволяет в реальном времени тестировать параметры звука.

const sm = gui.addFolder('CatAstroPhi Sound');
sm.add(this.catAstroPhi, 'seek', 0, this.catAstroPhi.duration).step(0.01).listen();
sm.add(this.catAstroPhi, 'rate', 0.5, 2).listen();
sm.add(this.catAstroPhi, 'detune', -1200, 1200).step(50).listen();
sm.add(this.catAstroPhi, 'loop').listen();
sm.add(this.catAstroPhi, 'play');
sm.add(this.catAstroPhi, 'pause');
sm.add(this.catAstroPhi, 'resume');
sm.add(this.catAstroPhi, 'stop');

Особенно важно здесь контролируемое свойство seek. Слайдер позволяет перематывать аудио в любой момент его длительности (duration). Метод .listen() заставляет UI автоматически обновляться при изменении значения из кода.

Визуализация и синхронизация

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

if (this.cat)
{
    this.cat.x = this.cat.width / 2 + (this.catAstroPhi.seek / this.catAstroPhi.duration) * (800 - this.cat.width);
}

В каждом кадре (update) вычисляется позиция кота на основе прогресса воспроизведения. Кроме того, создается маска для радуги, которая растет вслед за котом, создавая эффект "разворачивания".

this.rainbowMask.clear();
this.rainbowMask.fillStyle(0xffffff, 1);
this.rainbowMask.fillRect(0, 0, this.cat.x - 15, 600);

Анимация кота также синхронизирована со звуком: она приостанавливается, когда звук останавливается, и ее скорость (timeScale) привязывается к общей скорости воспроизведения (totalRate), которая учитывает и rate, и detune.

Интерактивное перетаскивание для перемотки

Помимо UI-панели, реализован интерактивный способ перемотки — перетаскивание кота мышью.

this.input.setDraggable(this.cat);
this.input.on('drag', (pointer, cat, dragX, dragY) => {
    cat.x = Math.min(Math.max(cat.width / 2, dragX), 800 - cat.width / 2);
    this.catAstroPhi.seek = (cat.x - cat.width / 2) / (800 - cat.width) * this.catAstroPhi.duration;
});

При событии drag вычисляется новая позиция `xдля спрайта кота (с учетом границ экрана). Затем на основе этой позиции пересчитывается и устанавливается новое значение дляthis.catAstroPhi.seek. Это демонстрирует двустороннюю связь: UI (положение кота) меняет состояние звука, и наоборот — вupdate` звук управляет положением кота.

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

Свойство seek в Phaser — мощный инструмент для точного контроля над воспроизведением аудио. Оно открывает возможности для создания продвинутых аудиоплееров внутри игр, синхронизации кат-сцен и реализации нелинейного аудио. Для экспериментов попробуйте

  1. Связать seek с прогресс-баром уровня
  2. Создать систему "звуковых закладок" для быстрого перехода к ключевым моментам трека
  3. Реализовать запись и воспроизведение игровых реплеев с синхронным звуком, сохраняя массив временных меток seek