О чем этот пример
В этой части серии по созданию игры «Змейка» на Phaser мы реализуем ключевую механику: управление змейкой, её плавное движение по игровому полю и взаимодействие с едой. Вы научитесь использовать пользовательские классы, управлять состоянием объектов через векторные позиции и эффективно работать с группами спрайтов (`Phaser.Group`). Этот подход демонстрирует, как структурировать игровую логику в объектно-ориентированном стиле, что критически важно для разработки более сложных проектов.
Версия 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);
// 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 загружаются два спрайта: сегмент тела змейки и еда. Обратите внимание на использование setBaseURL для указания корневого пути к ресурсам.
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');
}
Создание классов Food и Snake
Внутри create определяются два основных класса. Food наследуется от Phaser.GameObjects.Image и представляет собой единицу еды с методом eat, увеличивающим счётчик. Snake — это основной класс, управляющий змейкой. Он хранит позицию головы в объекте Phaser.Math.Vector2, группу сегментов тела в this.body, а также состояние (направление, скорость, живо ли существо). Инициализация змейки создаёт голову как первый сегмент в группе.
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;
},
// ... остальные методы
});
Управление направлением и движение
Управление осуществляется через методы faceLeft, faceRight, faceUp, faceDown. Они меняют свойство heading только если новое направление не противоположно текущему direction, что предотвращает разворот на 180 градусов. Основная логика движения находится в методе move. Он вызывается из update по таймеру (this.moveTime). Позиция головы обновляется в зависимости от heading с применением Phaser.Math.Wrap для телепортации через границы экрана. Затем Phaser.Actions.ShiftPosition сдвигает все сегменты тела, передавая новую позицию головы и сохраняя старую позицию последнего сегмента в this.tail.
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.Actions.ShiftPosition(this.body.getChildren(), this.headPosition.x * 16, this.headPosition.y * 16, 1, this.tail);
this.moveTime = time + this.speed;
return true;
}
Взаимодействие с едой и рост змейки
Метод collideWithFood проверяет коллизию по координатам головы и еды. При совпадении вызывается grow, который создаёт новый сегмент в группе this.body на позиции this.tail. Метод eat у еды увеличивает счётчик. Каждые 5 съеденных единиц еды скорость змейки немного увеличивается (уменьшается задержка this.speed).
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;
}
}
Перемещение еды на свободную клетку
Функция repositionFood вызывается после съедания еды. Она создаёт логическую сетку 40x30, где изначально все клетки помечены как свободные (true). Затем метод snake.updateGrid проходит по всем сегментам тела и помечает занятые клетки как false. Из оставшихся свободных клеток случайным образом выбирается новая позиция для еды с помощью Phaser.Math.RND.pick. Если свободных клеток не осталось — игра считается выигранной.
snake.updateGrid(testGrid);
// ...
var pos = Phaser.Math.RND.pick(validLocations);
food.setPosition(pos.x * 16, pos.y * 16);
Что попробовать дальше
Вы реализовали основную игровую петлю «Змейки»: управляемое движение, рост при поедании еды и её переспавн. Код демонстрирует эффективное использование классов, групп и векторной математики Phaser. Для экспериментов попробуйте: изменить начальную скорость и правило её увеличения, добавить визуальные эффекты при съедании еды, реализовать систему очков или усложнить игровое поле с препятствиями, которые тоже нужно учитывать в repositionFood.
