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

В этой статье разберем ключевые игровые механики классической «Змейки», реализованные на Phaser. Вы научитесь создавать управляемых персонажей с плавным движением по сетке, обрабатывать столкновения и генерировать объекты в случайных, но валидных позициях. Этот паттерн полезен для множества аркадных и пошаговых игр. Мы подробно рассмотрим код, где змейка управляется стрелками, растет при поедании еды и замедляется по мере роста сложности. Понимание этих принципов поможет вам создавать собственные игровые механики с использованием групп объектов (`Group`), действий (`Actions`) и векторной математики Phaser.

Версия 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;

//  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;

            scene.children.add(this);
        },

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

    });

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

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

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

Структура сцены и создание классов

Игра начинается с конфигурации и стандартных методов сцены Phaser: preload, create и update. В create мы определяем два основных класса с помощью Phaser.Class: Food (еда) и Snake (змея). Это позволяет инкапсулировать логику каждого объекта.

Класс Food расширяет Phaser.GameObjects.Image. Его конструктор устанавливает текстуру, позицию (с учетом размера клетки 16x16) и счетчик съеденных единиц. Важно, что объект добавляется в сцену через scene.children.add(this).

Класс Snake не расширяет стандартный класс, а является кастомным. В его конструкторе инициализируются ключевые свойства: позиция головы в координатах сетки (headPosition), группа для сегментов тела (body), скорость движения и направление.

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;
        scene.children.add(this);
    },
    eat: function () { this.total++; }
});
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;
    },
    // ... остальные методы
});

Управление и обновление движения

Управление обрабатывается в функции update сцены. Состояние клавиш-стрелок проверяется через объект cursors. Методы faceLeft, faceRight, faceUp и faceDown класса Snake изменяют направление движения (heading), но только если это не противоположно текущему направлению (direction). Это предотвращает разворот на 180 градусов, который привел бы к мгновенной смерти.

Основная логика движения находится в методе snake.update(time), который вызывается каждый кадр. Если текущее время (time) превышает запланированное время следующего хода (moveTime), вызывается внутренний метод move().

// В update сцены:
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();
}
// Метод move класса Snake:
move: function (time) {
    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;
    // ... дальше сдвиг тела и проверка столкновений
}

Функция Phaser.Math.Wrap обеспечивает телепортацию змейки при выходе за границы сетки 40x30.

Движение тела и обнаружение столкновений

Самый изящный момент — обновление положения тела. Вместо ручного перебора всех сегментов используется Phaser.Actions.ShiftPosition. Эта функция сдвигает все элементы группы (this.body) на новую позицию головы. Последний элемент группы (хвост) удаляется, а его координаты сохраняются в свойство this.tail. Эти координаты позже используются для роста змейки.

После сдвига проверяется столкновение головы с телом. Phaser.Actions.GetFirst ищет в группе тела первый элемент, координаты которого совпадают с координатами головы (this.head.x, this.head.y), начиная со второго элемента (параметр `1игнорирует саму голову). Если такой элемент найден, змейка умирает (this.alive = false`).

//  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
var hitBody = Phaser.Actions.GetFirst(this.body.getChildren(), { x: this.head.x, y: this.head.y }, 1);
if (hitBody) {
    this.alive = false;
    return false;
} else {
    this.moveTime = time + this.speed;
    return true;
}

Механика поедания еды и роста

Столкновение с едой проверяется в методе collideWithFood. Он сравнивает пиксельные координаты головы змейки (this.head.x, this.head.y) и объекта еды (food.x, food.y). При совпадении вызываются два ключевых метода: this.grow() и food.eat().

Метод grow создает новый сегмент тела в координатах, сохраненных в this.tail (это позиция последнего сегмента перед сдвигом). Таким образом, змейка растет с хвоста.

Каждая пятая съеденная единица пищи увеличивает скорость змейки, уменьшая задержку между ходами (this.speed -= 5). Это классический способ наращивания сложности.

collideWithFood: function (food) {
    if (this.head.x === food.x && this.head.y === food.y) {
        this.grow();
        food.eat();
        if (this.speed > 20 && food.total % 5 === 0) {
            this.speed -= 5;
        }
        return true;
    } else {
        return false;
    }
},

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

Интеллектуальное размещение еды на поле

После съедания еды вызывается функция repositionFood. Её задача — разместить новый объект еды в случайной, но свободной от тела змейки клетке.

Алгоритм работает в три этапа: 1. Создается двумерный массив testGrid (40x30), где изначально все клетки помечены как валидные (true). 2. Метод snake.updateGrid(testGrid) проходит по всем сегментам тела и помечает занимаемые ими клетки как невалидные (false). 3. Из массива testGrid собирается список validLocations со свободными координатами. Если свободные клетки есть, одна из них выбирается случайно с помощью Phaser.Math.RND.pick.

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

function repositionFood () {
    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);
    // ... сбор validLocations и случайный выбор позиции
}

// Метод updateGrid в классе Snake:
updateGrid: function (grid) {
    this.body.children.each(function (segment) {
        var bx = segment.x / 16;
        var by = segment.y / 16;
        grid[by][bx] = false;
    });
    return grid;
}

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

Мы разобрали полный цикл игровой механики «Змейки»: от создания классов и обработки ввода до движения, столкновений и динамического размещения объектов. Ключевые инструменты Phaser, которые стоит запомнить: Phaser.Class для создания сущностей, Group для управления коллекциями объектов и Actions для эффективных операций над ними. Для экспериментов попробуйте: 1. Добавить разные типы еды с особыми эффектами (замедление, ускорение, временная неуязвимость). 2. Реализовать препятствия на поле, которые также нужно учитывать в repositionFood. 3. Изменить логику роста, чтобы змейка увеличивалась не на один, а на несколько сегментов за особую еду. 4. Визуально оживить игру, добавив анимации поедания и движения.