О чем этот пример
Воспроизведение нескольких аудиодорожек в идеальной синхронизации — важная задача для ритм-игр, интерактивной музыки или сложных звуковых сцен. Phaser предоставляет гибкую систему работы со звуком, но управление временем запуска нескольких треков может быть нетривиальным. В этой статье мы разберем пример, демонстрирующий, как с помощью маркеров и параметра `delay` организовать последовательный запуск аудиопетель (stems) с точной временной задержкой, создавая нарастающую музыкальную композицию.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
bass;
gui;
bottomSpeaker;
middleSpeaker;
topRightSpeaker;
topLeftSpeaker;
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('streets', 'assets/sprites/cyberpunk-street.png');
this.load.atlas('speakers', 'assets/sprites/speakers/speakers.png', 'assets/sprites/speakers/speakers.json');
this.load.audio('bass', [ 'assets/audio/tech/bass.ogg', 'assets/audio/tech/bass.mp3' ]);
this.load.audio('drums', [ 'assets/audio/tech/drums.ogg', 'assets/audio/tech/drums.mp3' ]);
this.load.audio('percussion', [ 'assets/audio/tech/percussion.ogg', 'assets/audio/tech/percussion.mp3' ]);
this.load.audio('synth1', [ 'assets/audio/tech/synth1.ogg', 'assets/audio/tech/synth1.mp3' ]);
this.load.audio('synth2', [ 'assets/audio/tech/synth2.ogg', 'assets/audio/tech/synth2.mp3' ]);
this.load.audio('top1', [ 'assets/audio/tech/top1.ogg', 'assets/audio/tech/top1.mp3' ]);
this.load.audio('top2', [ 'assets/audio/tech/top2.ogg', 'assets/audio/tech/top2.mp3' ]);
}
create ()
{
const streets = this.add.image(0, 0, 'streets');
streets.setScale(600 / 192);
streets.setOrigin(0);
this.topLeftSpeaker = this.add.image(445, 332, 'speakers', 'top-left');
this.topLeftSpeaker.setOrigin(1, 0.46);
this.topRightSpeaker = this.add.image(445, 332, 'speakers', 'top-right');
this.topRightSpeaker.setOrigin(0, 0.975);
this.middleSpeaker = this.add.image(443, 417, 'speakers', 'middle');
this.middleSpeaker.setOrigin(0.5, 1);
this.bottomSpeaker = this.add.image(443, 504, 'speakers', 'bottom');
this.bottomSpeaker.setOrigin(0.5, 1);
this.bass = this.sound.add('bass');
const drums = this.sound.add('drums');
const percussion = this.sound.add('percussion');
const synth1 = this.sound.add('synth1');
const synth2 = this.sound.add('synth2');
const top1 = this.sound.add('top1');
const top2 = this.sound.add('top2');
this.gui = new dat.GUI();
const sm = this.gui.addFolder('Sound Manager');
sm.add(this.sound, 'rate', 0.5, 2).listen();
sm.add(this.sound, 'detune', -1200, 1200).step(50).listen();
const loopMarker = {
name: 'loop',
start: 0,
duration: 7.68,
config: {
loop: true
}
};
if (this.sound.locked)
{
const text = this.add.bitmapText(400, 70, '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.startStem.call(this, this.bass, 'Bass', this.bottomSpeaker);
}, this);
}
else
{
this.startStem.call(this, this.bass, 'Bass', this.bottomSpeaker);
}
this.bass.addMarker(loopMarker);
// Delay option can only be passed in config
this.bass.play('loop', {
delay: 0
});
// Below won't work
// sound.delay = delay;
// sound.play('loop');
this.bass.once('looped', function (sound)
{
this.startStem.call(this, drums, 'Drums', this.middleSpeaker);
}, this);
drums.addMarker(loopMarker);
drums.play('loop', {
delay: loopMarker.duration
});
drums.once('looped', function (sound)
{
this.startStem.call(this, percussion, 'Percussion', this.middleSpeaker);
}, this);
percussion.addMarker(loopMarker);
percussion.play('loop', {
delay: loopMarker.duration * 2
});
percussion.once('looped', function (sound)
{
this.startStem.call(this, synth1, 'Synth 1', this.topRightSpeaker);
}, this);
synth1.addMarker(loopMarker);
synth1.play('loop', {
delay: loopMarker.duration * 3
});
synth1.once('looped', function (sound)
{
this.startStem.call(this, synth2, 'Synth 2', this.topRightSpeaker);
}, this);
synth2.addMarker(loopMarker);
synth2.play('loop', {
delay: loopMarker.duration * 4
});
synth2.once('looped', function (sound)
{
this.startStem.call(this, top1, 'Top 1', this.topLeftSpeaker);
}, this);
top1.addMarker(loopMarker);
top1.play('loop', {
delay: loopMarker.duration * 5
});
top1.once('looped', function (sound)
{
this.startStem.call(this, top2, 'Top 2', this.topLeftSpeaker);
sm.open();
}, this);
top2.addMarker(loopMarker);
top2.play('loop', {
delay: loopMarker.duration * 6
});
}
update ()
{
this.middleSpeaker.y = this.bottomSpeaker.y - this.bottomSpeaker.height * this.bottomSpeaker.scaleY;
this.topLeftSpeaker.y =
this.topRightSpeaker.y =
this.middleSpeaker.y - this.middleSpeaker.height * this.middleSpeaker.scaleY;
this.tweens.setGlobalTimeScale(this.bass.totalRate);
}
startStem (stem, text, speaker)
{
const s = this.gui.addFolder(text);
s.add(stem, 'seek', 0, stem.duration).step(0.01).listen();
s.add(stem, 'mute').listen();
s.open();
this.tweens.add({
targets: speaker,
scaleX: 1.3,
scaleY: 1.1,
duration: 241,
ease: 'Sine.easeInOut',
repeat: -1,
yoyo: true
});
}
}
/**
* @author Pavle Goloskokovic <pgoloskokovic@gmail.com> (http://prunegames.com)
*
* Cyberpunk Street Environment by Luis Zuno (https://www.patreon.com/posts/8303915) / CC-BY-3.0
*/
const config = {
type: Phaser.AUTO,
parent: 'phaser-example',
width: 800,
height: 600,
backgroundColor: '#838282',
scene: Example,
pixelArt: true,
audio: {
noAudio: true
}
};
const game = new Phaser.Game(config);
Загрузка ресурсов и подготовка сцены
В методе preload загружаются все необходимые ресурсы: фоновое изображение, спрайты колонок в виде атласа и набор аудиофайлов. Каждый аудиофайл загружается в двух форматах (OGG и MP3) для кросс-браузерной совместимости.
В методе create происходит начальная настройка сцены. Сначала добавляется и масштабируется фоновое изображение. Затем создаются спрайты четырех динамиков из атласа. Их позиции и точки привязки (origin) тщательно подобраны для визуального выравнивания.
this.topLeftSpeaker = this.add.image(445, 332, 'speakers', 'top-left');
this.topLeftSpeaker.setOrigin(1, 0.46);
После этого для каждого загруженного аудиоключа создается объект звука через this.sound.add(). Это дает отдельный контроль над каждым музыкальным слоем (stem).
Работа с аудиомаркерами и задержкой
Ключевой элемент синхронизации — использование аудиомаркеров. Маркер определяет именованный сегмент внутри звукового файла.
const loopMarker = {
name: 'loop',
start: 0,
duration: 7.68,
config: {
loop: true
}
};
Этот маркер с именем 'loop' начинается с начала файла, длится 7.68 секунды и будет зациклен благодаря параметру config. Маркер добавляется к каждому звуковому объекту с помощью метода addMarker.
Для запуска звука с задержкой используется параметр delay в конфигурационном объекте метода play. Важно: параметр delay можно передать только при вызове play, его нельзя установить отдельно через свойство звукового объекта.
// Правильно: задержка передается в config
this.bass.play('loop', {
delay: 0
});
Каскадный запуск дорожек по событию
Чтобы дорожки запускались не одновременно, а последовательно после каждого завершения цикла предыдущей, используется событие 'looped'. Это событие генерируется каждый раз, когда зацикленный звук завершает проигрывание маркера и начинает его заново.
this.bass.once('looped', function (sound) {
this.startStem.call(this, drums, 'Drums', this.middleSpeaker);
}, this);
Обработчик once гарантирует, что код внутри функции выполнится только после первого цикла. Внутри этого обработчика запускается следующая дорожка. Ее задержка (delay) рассчитывается относительно длительности маркера, что обеспечивает точность. Например, вторая дорожка (drums) начнет играть через одну длительность маркера после старта первой.
drums.play('loop', {
delay: loopMarker.duration // Задержка = 7.68 сек
});
Таким образом, каждая следующая дорожка добавляется к композиции с интервалом в 7.68 секунды, создавая эффект постепенного наложения слоев.
Визуальная обратная связь и управление
Для наглядности каждый запущенный звуковой слой визуализируется анимацией соответствующего динамика и панелью управления в интерфейсе. Метод startStem отвечает за эту логику.
this.tweens.add({
targets: speaker,
scaleX: 1.3,
scaleY: 1.1,
duration: 241,
ease: 'Sine.easeInOut',
repeat: -1,
yoyo: true
});
Для каждого слоя создается папка в GUI (с помощью библиотеки dat.GUI), где можно управлять позицией воспроизведения (seek) и отключать звук (mute). Также в GUI вынесены глобальные параметры rate (скорость) и detune (расстройка) для менеджера звуков.
В методе update происходит синхронизация анимации со скоростью воспроизведения главного бас-трека. Позиции динамиков обновляются относительно друг друга, а глобальная шкала времени для всех твинов привязывается к общей скорости баса.
this.tweens.setGlobalTimeScale(this.bass.totalRate);
Конфигурация игры и обработка аудиоблокировки
Конфигурация игры содержит важный параметр audio: { noAudio: true }. Эта настройка указывает Phaser создать звуковую систему, но не начинать автоматическое декодирование аудио. Это полезно для контроля над моментом начала воспроизведения, особенно на мобильных устройствах, где требуется взаимодействие с пользователем.
Код проверяет свойство this.sound.locked. Если звук заблокирован (обычно в мобильных браузерах), на экране появляется надпись «Tap to start». Воспроизведение начнется только после события 'unlocked', которое срабатывает при первом пользовательском взаимодействии.
if (this.sound.locked) {
// Показать текст "Tap to start"
this.sound.once('unlocked', function (soundManager) {
// Начать воспроизведение
}, this);
}
Что попробовать дальше
Использование маркеров и параметра delay в методе play — мощный и точный способ синхронизации нескольких аудиопетель в Phaser. Этот подход позволяет создавать сложные, динамически развивающиеся звуковые ландшафты, где каждый слой появляется в строго рассчитанный момент.
**Идеи для экспериментов:**
1. Измените длительность маркера или рассчитайте задержки по другой формуле (например, геометрической прогрессии).
2. Запускайте дорожки не последовательно, а в ответ на действия игрока, используя тот же механизм с delay.
3. Используйте событие 'looped' для изменения игрового процесса или визуальных эффектов на каждом новом цикле музыки.
4. Попробуйте динамически изменять глобальные rate или detune в зависимости от состояния игры.
