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

Пространственное аудио — мощный инструмент для создания погружения в играх. Оно позволяет звукам "привязываться" к объектам в мире и меняться в зависимости от позиции игрока. В этой статье мы разберем, как в Phaser реализовать систему, где монстры издают пугающие звуки, а игрок слышит их громче или тише, приближаясь или удаляясь. Вы научитесь работать с Web Audio API через Phaser, настраивать параметры пространственного звучания и синхронизировать позицию "ушей" игрока с его спрайтом. Это знание поможет вам создавать более живые и атмосферные миры.

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

Живой запуск

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

Исходный код


class Monster extends Phaser.GameObjects.Sprite
{
    constructor (scene, x, y, texture, frame, soundKey)
    {
        super(scene, x, y, texture, frame);

        this.fx = scene.sound.add(soundKey);

        this.fx.play({
            loop: true,
            source: {
                x,
                y,
                orientationX: 0,
                orientationY: 0,
                orientationZ: -1,
                // distanceModel: 'inverse',
                refDistance: 6,
                rolloffFactor: 1,
                coneInnerAngle: 180,
                coneOuterAngle: 280,
                coneOuterGain: 0.3
            }
        });

        scene.add.existing(this);
    }
}

class Example extends Phaser.Scene
{
    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('dude', 'assets/sprites/phaser-dude.png');
        this.load.image('bg', 'assets/textures/cave-map3.jpg');
        this.load.atlas('monsters', 'assets/atlas/monsters.png', 'assets/atlas/monsters.json');

        this.load.setPath('assets/audio/monsters/');

        this.load.audio('growl1', [ 'growl1.ogg', 'growl1.mp3' ]);
        this.load.audio('growl2', [ 'growl2.ogg', 'growl2.mp3' ]);
        this.load.audio('growl3', [ 'growl3.ogg', 'growl3.mp3' ]);
        this.load.audio('growl4', [ 'growl4.ogg', 'growl4.mp3' ]);
        this.load.audio('growl5', [ 'growl5.ogg', 'growl5.mp3' ]);
        this.load.audio('growl6', [ 'growl6.ogg', 'growl6.mp3' ]);
        this.load.audio('growl7', [ 'growl7.ogg', 'growl7.mp3' ]);
        this.load.audio('growl8', [ 'growl8.ogg', 'growl8.mp3' ]);
        this.load.audio('growl9', [ 'growl9.ogg', 'growl9.mp3' ]);
        this.load.audio('growl10', [ 'growl10.ogg', 'growl10.mp3' ]);
        this.load.audio('growl11', [ 'growl11.ogg', 'growl11.mp3' ]);
        this.load.audio('growl12', [ 'growl12.ogg', 'growl12.mp3' ]);
        this.load.audio('growl13', [ 'growl13.ogg', 'growl13.mp3' ]);
        this.load.audio('growl14', [ 'growl14.ogg', 'growl14.mp3' ]);
        this.load.audio('growl15', [ 'growl15.ogg', 'growl15.mp3' ]);
    }

    create ()
    {
        this.add.image(0, 0, 'bg').setOrigin(0, 0);

        if (this.sound.locked)
        {
            const text = this.add.text(10, 10, 'Click to Start', { font: '32px Courier', fill: '#00ff00' });

            this.sound.once('unlocked', () =>
            {
                text.destroy();

                this.createMonsters()
                this.createPlayer();
            });
        }
        else
        {
            this.createMonsters();
            this.createPlayer();
        }
    }

    createMonsters ()
    {
        const frames = [
            'assassin',
            'cultist',
            'darkreaper',
            'drow',
            'frogman',
            'ghost',
            'giantspider',
            'gnoll',
            'goblin',
            'guard',
            'hobgoblin',
            'icegolem',
            'imp',
            'lizardman',
            'magmagolem',
            'ogre',
            'ruffian',
            'scout',
            'skeletalwarrior',
            'slime',
            'stonegolem',
            'swashbuckler',
            'troll',
            'wererat',
            'zombie',
        ];

        for (let i = 0; i < 32; i++)
        {
            const x = Phaser.Math.Between(500, 2500);
            const y = Phaser.Math.Between(500, 2500);
            const sound = Phaser.Math.Between(1, 15);
            const frame = Phaser.Utils.Array.GetRandom(frames);

            const monster = new Monster(this, x, y, 'monsters', frame, `growl${sound}`);
        }
    }

    createPlayer ()
    {
        this.player = this.physics.add.sprite(400, 300, 'dude');

        this.cameras.main.startFollow(this.player);

        this.cursors = this.input.keyboard.createCursorKeys();

        this.sound.setListenerPosition(400, 300);
    }

    update ()
    {
        if (!this.player)
        {
            return;
        }

        this.player.setVelocity(0);

        if (this.cursors.left.isDown)
        {
            this.player.setVelocityX(-300);
        }
        else if (this.cursors.right.isDown)
        {
            this.player.setVelocityX(300);
        }

        if (this.cursors.up.isDown)
        {
            this.player.setVelocityY(-300);
        }
        else if (this.cursors.down.isDown)
        {
            this.player.setVelocityY(300);
        }

        this.sound.listenerPosition.set(this.player.x, this.player.y);
    }
}

const config = {
    type: Phaser.AUTO,
    parent: 'phaser-example',
    width: 800,
    height: 600,
    physics: {
        default: 'arcade',
        arcade: {
            debug: false
        }
    },
    scene: Example
};

const game = new Phaser.Game(config);

Загрузка ресурсов и инициализация

В методе preload загружаются все необходимые ресурсы: изображение игрока (dude), фон (bg), атлас спрайтов монстров и коллекция аудиофайлов с рычанием. Обратите внимание на использование this.load.setPath для упрощения загрузки нескольких аудиофайлов из одной директории.

В методе create добавляется фон и обрабатывается ограничение автозапуска аудио в браузерах. Если звук заблокирован (this.sound.locked), на экране появляется надпись, и игра ждет события unlocked, после которого инициализируются монстры и игрок. Если звук изначально доступен, инициализация происходит сразу.

if (this.sound.locked)
{
    const text = this.add.text(10, 10, 'Click to Start', { font: '32px Courier', fill: '#00ff00' });

    this.sound.once('unlocked', () =>
    {
        text.destroy();
        this.createMonsters()
        this.createPlayer();
    });
}
else
{
    this.createMonsters();
    this.createPlayer();
}

Класс Monster: привязка звука к объекту

Ключевая логика пространственного звука инкапсулирована в классе Monster. В его конструкторе создается и сразу воспроизводится аудиообъект с настройками пространственного звучания. Параметр source в конфиге play определяет исходную позицию звука в мире и его характеристики. - `x,y` — координаты монстра, откуда исходит звук. - refDistance и rolloffFactor — управляют затуханием громкости с расстоянием. - coneInnerAngle, coneOuterAngle, coneOuterGain — задают направленность звукового конуса (здесь не используется активно, так как orientationX/Z нулевые). Звук проигрывается с параметром loop: true, создавая постоянный фоновый гул.

this.fx = scene.sound.add(soundKey);
this.fx.play({
    loop: true,
    source: {
        x,
        y,
        orientationX: 0,
        orientationY: 0,
        orientationZ: -1,
        refDistance: 6,
        rolloffFactor: 1,
        coneInnerAngle: 180,
        coneOuterAngle: 280,
        coneOuterGain: 0.3
    }
});

Создание мира: монстры и игрок

Метод createMonsters случайным образом расставляет 32 монстра на карте, используя случайный кадр из атласа и случайный звук рычания. Это создает разнообразную и динамичную звуковую среду. Метод createPlayer создает физический спрайт игрока, который становится объектом слежения для камеры. Самое важное здесь — установка начальной позиции "слушателя" (игрока) в аудиосистеме с помощью this.sound.setListenerPosition. Это точка, относительно которой рассчитывается громкость всех пространственных звуков.

// В createMonsters
const x = Phaser.Math.Between(500, 2500);
const y = Phaser.Math.Between(500, 2500);
const sound = Phaser.Math.Between(1, 15);
const frame = Phaser.Utils.Array.GetRandom(frames);
const monster = new Monster(this, x, y, 'monsters', frame, `growl${sound}`);
// В createPlayer
this.player = this.physics.add.sprite(400, 300, 'dude');
this.cameras.main.startFollow(this.player);
this.sound.setListenerPosition(400, 300);

Обновление: движение игрока и слушателя

В методе update обрабатывается управление игроком с клавиатуры. При движении в любом направлении спрайту задается соответствующая скорость.

Критически важная строка — this.sound.listenerPosition.set(this.player.x, this.player.y). Она непрерывно обновляет позицию слушателя в аудиосистеме, привязывая ее к текущим координатам спрайта игрока. Благодаря этому, когда игрок приближается к монстру, его рычание становится громче, а при удалении — тише, создавая реалистичный эффект присутствия.

this.player.setVelocity(0);
if (this.cursors.left.isDown) this.player.setVelocityX(-300);
else if (this.cursors.right.isDown) this.player.setVelocityX(300);
if (this.cursors.up.isDown) this.player.setVelocityY(-300);
else if (this.cursors.down.isDown) this.player.setVelocityY(300);
this.sound.listenerPosition.set(this.player.x, this.player.y);

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

Вы реализовали систему пространственного звука в Phaser, где аудиоисточники привязаны к объектам мира, а позиция слушателя синхронизирована с игроком. Это основа для создания глубокого погружения. Для экспериментов попробуйте: 1. Изменить параметры refDistance и rolloffFactor у монстров, чтобы настроить дальность слышимости. 2. Добавить изменение параметров orientationX/Z у звука монстра, чтобы он был направленным (например, монстр "смотрит" в определенную сторону). 3. Динамически менять тип звука монстра (например, с рычания на вой) при определенных условиях. 4. Реализовать подобную систему для звуков окружения (ветер, вода), также привязанных к координатам.