О чем этот пример
Разработка игр часто начинается с простых механик. В этой статье мы разберем реализацию классической игры «Змейка» на Phaser 3. Вы научитесь создавать игровые объекты с помощью классов, управлять движением по таймеру, обрабатывать ввод с клавиатуры и реализовывать базовую логику столкновений. Это отличный фундамент для понимания игрового цикла и организации кода в 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 x = Phaser.Math.Between(0, 39);
var y = Phaser.Math.Between(0, 29);
this.setPosition(x * 16, y * 16);
}
});
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;
}
}
});
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
snake.collideWithFood(food);
}
}
Структура проекта и загрузка ресурсов
Игра начинается с базовой конфигурации сцены и загрузки изображений. Обратите внимание, что координаты в игре представлены в клетках (grid), а затем умножаются на 16 для отрисовки на экране.
var config = {
type: Phaser.WEBGL,
width: 640,
height: 480,
backgroundColor: '#bfcc00',
parent: 'phaser-example',
scene: {
preload: preload,
create: create,
update: update
}
};
В функции preload мы загружаем два спрайта: сегмент тела змейки и еду. Изображения загружаются с удаленного URL.
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. Его главная задача — отображаться в случайной позиции на игровом поле после того, как его съели.
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 x = Phaser.Math.Between(0, 39);
var y = Phaser.Math.Between(0, 29);
this.setPosition(x * 16, y * 16);
}
});
Класс Snake не наследуется от стандартного игрового объекта, а является пользовательской структурой. Он управляет группой сегментов тела (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;
}
// ... другие методы
});
Логика движения и управления
Движение змейки реализовано не в каждом кадре, а по таймеру. Это создает характерный пошаговый эффект. Метод update класса Snake проверяет, настало ли время для следующего хода.
update: function (time) {
if (time >= this.moveTime) {
return this.move(time);
}
}
Сам метод move сдвигает голову в зависимости от текущего направления (this.heading). Функция Phaser.Math.Wrap обеспечивает телепортацию змейки при выходе за границы игрового поля (40x30 клеток). Затем 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
}
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;
}
Управление осуществляется через методы faceLeft, faceRight, faceUp, faceDown. Они проверяют, что змейка не может развернуться на 180 градусов мгновенно (например, при движении вправо нельзя сразу повернуть налево).
faceLeft: function () {
if (this.direction === UP || this.direction === DOWN) {
this.heading = LEFT;
}
}
Взаимодействие с едой и игровой цикл
Столкновение с едой проверяется в основном игровом цикле (update сцены) после успешного движения змейки. Метод collideWithFood сравнивает координаты головы змейки и еды.
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;
}
return false;
}
Метод grow добавляет новый сегмент в хвост, используя сохраненные ранее координаты this.tail.
grow: function () {
var newPart = this.body.create(this.tail.x, this.tail.y, 'body');
newPart.setOrigin(0);
}
Основной цикл update сцены обрабатывает ввод с клавиатуры через объект cursors и запускает обновление состояния змейки.
function update (time, delta)
{
if (!snake.alive) return;
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)) {
snake.collideWithFood(food);
}
}
Что попробовать дальше
Мы разобрали ключевые компоненты игры «Змейка»: управление по таймеру, движение группы объектов, обработку ввода и простые столкновения. Для экспериментов попробуйте добавить препятствия на поле, реализовать подсчет очков или изменить механику роста (например, змейка укорачивается со временем). Также можно заменить графику на spritesheet для анимации сегментов тела.
