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

Добавление звукового сопровождения — важный шаг в создании атмосферы игры. В этой статье мы разберем, как интегрировать динамически генерируемые звуковые эффекты в классическую «Змейку» на Phaser. Вы научитесь использовать `Phaser.Sound.Dynamic.FX` для создания звуков прямо в коде, без загрузки аудиофайлов. Это особенно полезно для прототипирования и создания простых, но отзывчивых звуковых откликов на события игры, таких как поедание еды и гибель змейки.

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

Живой запуск

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

Исходный код


var config = {
    type: Phaser.WEBGL,
    width: 640,
    height: 480,
    backgroundColor: '#bfcc00',
    parent: 'phaser-example',
    scene: {
        preload: preload,
        create: create,
        update: update
    }
};

var snake;
var food;
var cursors;

var ctx = new AudioContext();

//  Direction consts
var UP = 0;
var DOWN = 1;
var LEFT = 2;
var RIGHT = 3;

var game = new Phaser.Game(config);

function preload ()
{
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.image('food', 'assets/games/snake/food.png');
    this.load.image('body', 'assets/games/snake/body.png');
}

function create ()
{
    var Food = new Phaser.Class({

        Extends: Phaser.GameObjects.Image,

        initialize:

        function Food (scene, x, y)
        {
            Phaser.GameObjects.Image.call(this, scene)

            this.setTexture('food');
            this.setPosition(x * 16, y * 16);
            this.setOrigin(0);

            this.total = 0;

            this.eatEffect = {
                frequency: 523.25,
                attack: 0.05,
                decay: 0.2,
                type: 'sine',
                volume: 3,
                pan: 0.8,
                pitchBend: 600,
                reverse: true,
                random: 100
            };

            scene.children.add(this);
        },

        eat: function ()
        {
            this.total++;

            new Phaser.Sound.Dynamic.FX(ctx, this.eatEffect);
        }

    });

    var Snake = new Phaser.Class({

        initialize:

        function Snake (scene, x, y)
        {
            this.headPosition = new Phaser.Math.Vector2(x, y);

            this.body = scene.add.group();

            this.head = this.body.create(x * 16, y * 16, 'body');
            this.head.setOrigin(0);

            this.alive = true;

            this.speed = 100;

            this.moveTime = 0;

            this.tail = new Phaser.Math.Vector2(x, y);

            this.heading = RIGHT;
            this.direction = RIGHT;

            this.deathEffect = {
                frequency: 16,
                decay: 1,
                type: 'sawtooth',
                dissonance: 50
            };
        },

        update: function (time)
        {
            if (time >= this.moveTime)
            {
                return this.move(time);
            }
        },

        faceLeft: function ()
        {
            if (this.direction === UP || this.direction === DOWN)
            {
                this.heading = LEFT;
            }
        },

        faceRight: function ()
        {
            if (this.direction === UP || this.direction === DOWN)
            {
                this.heading = RIGHT;
            }
        },

        faceUp: function ()
        {
            if (this.direction === LEFT || this.direction === RIGHT)
            {
                this.heading = UP;
            }
        },

        faceDown: function ()
        {
            if (this.direction === LEFT || this.direction === RIGHT)
            {
                this.heading = DOWN;
            }
        },

        move: function (time)
        {
            /**
            * Based on the heading property (which is the direction the pgroup pressed)
            * we update the headPosition value accordingly.
            * 
            * The Math.wrap call allow the snake to wrap around the screen, so when
            * it goes off any of the sides it re-appears on the other.
            */
            switch (this.heading)
            {
                case LEFT:
                    this.headPosition.x = Phaser.Math.Wrap(this.headPosition.x - 1, 0, 40);
                    break;

                case RIGHT:
                    this.headPosition.x = Phaser.Math.Wrap(this.headPosition.x + 1, 0, 40);
                    break;

                case UP:
                    this.headPosition.y = Phaser.Math.Wrap(this.headPosition.y - 1, 0, 30);
                    break;

                case DOWN:
                    this.headPosition.y = Phaser.Math.Wrap(this.headPosition.y + 1, 0, 30);
                    break;
            }

            this.direction = this.heading;

            //  Update the body segments and place the last coordinate into this.tail
            Phaser.Actions.ShiftPosition(this.body.getChildren(), this.headPosition.x * 16, this.headPosition.y * 16, 1, this.tail);

            //  Check to see if any of the body pieces have the same x/y as the head
            //  If they do, the head ran into the body

            var hitBody = Phaser.Actions.GetFirst(this.body.getChildren(), { x: this.head.x, y: this.head.y }, 1);

            if (hitBody)
            {
                console.log('dead');

                //  Game Over
                new Phaser.Sound.Dynamic.FX(ctx, this.deathEffect);

                this.alive = false;

                return false;
            }
            else
            {
                //  Update the timer ready for the next movement
                this.moveTime = time + this.speed;

                return true;
            }
        },

        grow: function ()
        {
            var newPart = this.body.create(this.tail.x, this.tail.y, 'body');

            newPart.setOrigin(0);
        },

        collideWithFood: function (food)
        {
            if (this.head.x === food.x && this.head.y === food.y)
            {
                this.grow();

                food.eat();

                //  For every 5 items of food eaten we'll increase the snake speed a little
                if (this.speed > 20 && food.total % 5 === 0)
                {
                    this.speed -= 5;
                }

                return true;
            }
            else
            {
                return false;
            }
        },

        updateGrid: function (grid)
        {
            //  Remove all body pieces from valid positions list
            this.body.children.each(function (segment) {

                var bx = segment.x / 16;
                var by = segment.y / 16;

                grid[by][bx] = false;

            });

            return grid;
        }

    });

    food = new Food(this, 3, 4);

    snake = new Snake(this, 8, 8);

    //  Create our keyboard controls
    cursors = this.input.keyboard.createCursorKeys();
}

function update (time, delta)
{
    if (!snake.alive)
    {
        return;
    }

    /**
    * Check which key is pressed, and then change the direction the snake
    * is heading based on that. The checks ensure you don't double-back
    * on yourself, for example if you're moving to the right and you press
    * the LEFT cursor, it ignores it, because the only valid directions you
    * can move in at that time is up and down.
    */
    if (cursors.left.isDown)
    {
        snake.faceLeft();
    }
    else if (cursors.right.isDown)
    {
        snake.faceRight();
    }
    else if (cursors.up.isDown)
    {
        snake.faceUp();
    }
    else if (cursors.down.isDown)
    {
        snake.faceDown();
    }

    if (snake.update(time))
    {
        //  If the snake updated, we need to check for collision against food

        if (snake.collideWithFood(food))
        {
            repositionFood();
        }
    }
}

/**
* We can place the food anywhere in our 40x30 grid
* *except* on-top of the snake, so we need
* to filter those out of the possible food locations.
* If there aren't any locations left, they've won!
*
* @method repositionFood
* @return {boolean} true if the food was placed, otherwise false
*/
function repositionFood ()
{
    //  First create an array that assumes all positions
    //  are valid for the new piece of food

    //  A Grid we'll use to reposition the food each time it's eaten
    var testGrid = [];

    for (var y = 0; y < 30; y++)
    {
        testGrid[y] = [];

        for (var x = 0; x < 40; x++)
        {
            testGrid[y][x] = true;
        }
    }

    snake.updateGrid(testGrid);

    //  Purge out false positions
    var validLocations = [];

    for (var y = 0; y < 30; y++)
    {
        for (var x = 0; x < 40; x++)
        {
            if (testGrid[y][x] === true)
            {
                //  Is this position valid for food? If so, add it here ...
                validLocations.push({ x: x, y: y });
            }
        }
    }

    if (validLocations.length > 0)
    {
        //  Use the RNG to pick a random food position
        var pos = Phaser.Math.RND.pick(validLocations);

        //  And place it
        food.setPosition(pos.x * 16, pos.y * 16);

        return true;
    }
    else
    {
        return false;
    }
}

Инициализация аудиоконтекста

Перед созданием звуков необходимо инициализировать аудиоконтекст браузера. Это глобальный объект, который управляет всеми аудиооперациями. В нашем примере он создается до инициализации игры.

var ctx = new AudioContext();

Переменная ctx передается в конструктор Phaser.Sound.Dynamic.FX для каждого звукового эффекта, обеспечивая их корректную работу в рамках одного аудиографа.

Создание класса Food со звуком

Класс Food наследуется от Phaser.GameObjects.Image и представляет собой еду на игровом поле. Его ключевая особенность — метод eat, который воспроизводит звуковой эффект при поедании.

this.eatEffect = {
    frequency: 523.25,
    attack: 0.05,
    decay: 0.2,
    type: 'sine',
    volume: 3,
    pan: 0.8,
    pitchBend: 600,
    reverse: true,
    random: 100
};

Этот объект конфигурации описывает параметры звука: основную частоту, огибающую (атака и затухание), тип волны, громкость, панораму и эффекты модуляции. При вызове eat() создается новый экземпляр динамического звука.

eat: function () {
    this.total++;
    new Phaser.Sound.Dynamic.FX(ctx, this.eatEffect);
}

Звук генерируется «на лету» на основе переданных параметров, что делает эффект уникальным и не требует внешних файлов.

Звук смерти в классе Snake

Класс Snake управляет змейкой и содержит эффект для воспроизведения при столкновении с собственным хвостом.

this.deathEffect = {
    frequency: 16,
    decay: 1,
    type: 'sawtooth',
    dissonance: 50
};

Здесь используется очень низкая частота (16 Гц) и тип волны «пила», что создает резкий, диссонирующий звук, ассоциирующийся с неудачей. Эффект запускается в методе move, когда обнаруживается столкновение головы с телом.

if (hitBody) {
    new Phaser.Sound.Dynamic.FX(ctx, this.deathEffect);
    this.alive = false;
    return false;
}

Создание звука непосредственно в момент события обеспечивает мгновенную аудиообратную связь игроку.

Логика перемещения и роста змейки

Движение змейки реализовано с помощью таймера на основе игрового времени. Метод update в классе Snake проверяет, настало ли время для следующего шага.

update: function (time) {
    if (time >= this.moveTime) {
        return this.move(time);
    }
}

В методе move обновляется позиция головы с использованием Phaser.Math.Wrap для телепортации за пределы экрана. Затем с помощью Phaser.Actions.ShiftPosition все сегменты тела сдвигаются, а последняя позиция сохраняется в this.tail для возможного роста.

Phaser.Actions.ShiftPosition(this.body.getChildren(), this.headPosition.x * 16, this.headPosition.y * 16, 1, this.tail);

Метод grow создает новый сегмент тела в сохраненной позиции tail, когда змейка ест пищу.

grow: function () {
    var newPart = this.body.create(this.tail.x, this.tail.y, 'body');
    newPart.setOrigin(0);
}

Управление скоростью и репозиционирование еды

Игра динамически меняет сложность. При поедании каждой пятой единицы пищи скорость змейки увеличивается (значение this.speed уменьшается).

if (this.speed > 20 && food.total % 5 === 0) {
    this.speed -= 5;
}

После того как еда съедена, функция repositionFood находит новую валидную позицию на сетке 40x30, исключая клетки, занятые телом змейки. Для этого используется временная сетка testGrid, которую метод snake.updateGrid обновляет, помечая занятые клетки как false.

snake.updateGrid(testGrid);

Затем из всех true-клеток случайным образом выбирается новая позиция с помощью Phaser.Math.RND.pick.

var pos = Phaser.Math.RND.pick(validLocations);
food.setPosition(pos.x * 16, pos.y * 16);

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

Интеграция динамически генерируемых звуков с помощью Phaser.Sound.Dynamic.FX — мощный инструмент для быстрого добавления аудиообратной связи в ваши прототипы. Вы можете экспериментировать с параметрами звуковых эффектов (частотой, типом волны, огибающей), чтобы создать совершенно разное звучание для разных событий. Попробуйте добавить фоновый звуковой луп или изменить звук роста змейки в зависимости от ее длины. Также интересно будет реализовать систему частиц или визуальные эффекты, синхронизированные со звуками, для большей иммерсивности.