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

В игровых сценах часто требуется синхронизировать действие на экране с определённым моментом звуковой дорожки. Например, для перемотки кат-сцены или создания интерактивного аудиоплеера внутри игры. Phaser 3 предоставляет для этого простой и мощный инструмент — свойство `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.setTimeScale = 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: 6, 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
};

const game = new Phaser.Game(config);

Начинаем воспроизведение с нужной позиции

Ключевой метод для работы — this.sound.add(key), который создаёт объект звука (Sound). У этого объекта есть свойство seek, определяющее текущую позицию воспроизведения в секундах.

Самый эффективный способ начать проигрывать звук с определённого момента — передать параметр seek прямо в метод play(). Это делается за один вызов API.

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

Альтернативный, двухэтапный способ — сначала запустить звук, а затем изменить его позицию. Однако первый метод предпочтительнее, так как он выполняет ту же операцию более оптимально.

// Альтернативный, менее эффективный способ
catAstroPhi.play();
catAstroPhi.seek = 2.550;

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

В методе update() происходит синхронизация игрового процесса со звуком. Позиция спрайта кота на экране рассчитывается на основе текущей позиции (seek) и общей длительности (duration) аудиотрека.

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

На основе этой же позиции обновляется маска (Graphics), которая постепенно открывает текстуру радуги, создавая эффект прогресс-бара.

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

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

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.setTimeScale = this.catAstroPhi.totalRate;

Интерактивное управление через GUI и drag-and-drop

В примере используется библиотека dat.GUI для создания панели управления звуком. Это удобно для отладки и демонстрации. В панель добавлены контролы для основных свойств объекта Sound.

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();
// ... добавлены кнопки play, pause, resume, stop

Более игровой способ управления реализован через перетаскивание (drag) спрайта кота. При перемещении кота по горизонтали пересчитывается и устанавливается позиция в аудиотреке.

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

Эта формула преобразует координату `X` спрайта в диапазон от 0 до длительности звука, обеспечивая прямую и обратную связь между визуальным объектом и аудиопотоком.

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

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

Если звук заблокирован, на экран выводится сообщение. Как только пользователь кликнет (событие unlocked), сообщение скрывается и инициализируется основная логика игры через метод setup.

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

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

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

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