О чем этот пример
Пример 'Stacker' демонстрирует, как создать простую, но увлекательную казуальную игру на Phaser, где игрок должен точно укладывать движущиеся блоки. Этот пример — отличная отправная точка для изучения управления временными событиями, обработки ввода, работы с игровой сеткой и реализации постепенно усложняющейся логики. Мы разберем, как заставить блоки двигаться, обрабатывать столкновения и создавать динамическую сложность.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
timer;
direction = 0;
speed = 250;
block3;
block2;
block1;
gridSize = 32;
gridHeight = 15;
gridWidth = 7;
currentY = this.gridHeight;
grid;
init ()
{
const element = document.createElement('style');
document.head.appendChild(element);
element.sheet.insertRule('@font-face { font-family: "bebas"; src: url("assets/fonts/ttf/bebas.ttf") format("truetype"); }', 0);
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.script('webfont', 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js');
}
create ()
{
WebFont.load({
custom: {
families: [ 'bebas' ]
},
active: this.startGame.bind(this)
});
}
startGame ()
{
this.add.text(400, 32, 'Stacker', { fontFamily: 'bebas', fontSize: 80, color: '#ffffff' }).setShadow(2, 2, '#333333', 2, false, true);
this.add.grid(0, 0, this.gridWidth * this.gridSize, this.gridHeight * this.gridSize, this.gridSize, this.gridSize, 0x999999, 1, 0x666666).setOrigin(0);
const space = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.block1 = this.add.rectangle(this.gridSize * 2, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
this.block2 = this.add.rectangle(this.gridSize * 3, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
this.block3 = this.add.rectangle(this.gridSize * 4, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
this.grid = [];
for (let y = 0; y < this.gridHeight; y++)
{
this.grid.push([ 0, 0, 0, 0, 0, 0, 0 ]);
}
this.timer = this.time.addEvent({ delay: this.speed, callback: this.moveBlocks, callbackScope: this, loop: true });
this.input.keyboard.on('keydown_SPACE', this.drop, this);
this.input.on('pointerdown', this.drop, this);
}
gameOver ()
{
}
gameWon ()
{
}
getGridX (block)
{
return Math.ceil(block.x / this.gridSize);
}
hasBlockBelow (block)
{
return (block && this.grid[this.currentY][this.getGridX(block)]);
}
moveBlocks ()
{
if (this.direction === 0)
{
// Moving right
if (this.block1)
{
this.block1.x += this.gridSize;
if (this.getGridX(this.block1) === this.gridWidth - 1)
{
this.direction = 1;
}
}
if (this.block2)
{
this.block2.x += this.gridSize;
if (this.getGridX(this.block2) === this.gridWidth - 1)
{
this.direction = 1;
}
}
if (this.block3)
{
this.block3.x += this.gridSize;
if (this.getGridX(this.block3) === this.gridWidth - 1)
{
this.direction = 1;
}
}
}
else
{
// Moving left
if (this.block1)
{
this.block1.x -= this.gridSize;
if (this.block1 && this.getGridX(this.block1) === 0)
{
this.direction = 0;
}
}
if (this.block2)
{
this.block2.x -= this.gridSize;
if (this.block2 && this.getGridX(this.block2) === 0)
{
this.direction = 0;
}
}
if (this.block3)
{
this.block3.x -= this.gridSize;
if (this.block3 && this.getGridX(this.block3) === 0)
{
this.direction = 0;
}
}
}
}
totalBlocks ()
{
let total = 0;
if (this.block1)
{
total++;
}
if (this.block2)
{
total++;
}
if (this.block3)
{
total++;
}
return total;
}
nextRow ()
{
this.currentY--;
if (this.currentY === 10 || this.currentY === 5)
{
console.log('GETTING HARDER!', this.currentY);
this.speed -= (this.currentY === 10) ? 90 : 50;
// We also need to remove a block if they've still got the full amount
if (this.currentY === 10 && this.totalBlocks() === 3)
{
// 3 down to 2
this.block1 = null;
}
else if (this.currentY === 5 && this.totalBlocks() === 2)
{
// 2 down to 1
if (this.block1 && this.block2 || this.block1 && this.block3)
{
this.block1 = null;
}
else
{
this.block2 = null;
}
}
}
// Pick either left or right to appear from
let side = 0;
let shift = this.gridSize;
if (Math.random() >= 0.5)
{
this.direction = 1;
side = (this.gridWidth - 1) * this.gridSize;
shift = -this.gridSize;
}
else
{
this.direction = 0;
}
if (this.block1)
{
this.block1 = this.add.rectangle(side, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
side += shift;
}
if (this.block2)
{
this.block2 = this.add.rectangle(side, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
side += shift;
}
if (this.block3)
{
this.block3 = this.add.rectangle(side, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
}
this.timer = this.time.addEvent({ delay: this.speed, callback: this.moveBlocks, callbackScope: this, loop: true });
}
drop ()
{
this.timer.remove(false);
const pos1 = (this.block1) ? this.getGridX(this.block1) : -1;
const pos2 = (this.block2) ? this.getGridX(this.block2) : -1;
const pos3 = (this.block3) ? this.getGridX(this.block3) : -1;
// console.log('drop y', currentY, 'pos', pos1, pos2, pos3);
const mapY = this.currentY - 1;
if (this.currentY === this.gridHeight)
{
// Is this the first row? If so we just drop and carry on.
this.grid[mapY][pos1] = 1;
this.grid[mapY][pos2] = 1;
this.grid[mapY][pos3] = 1;
this.nextRow();
}
else if (!this.hasBlockBelow(this.block1) && !this.hasBlockBelow(this.block2) && !this.hasBlockBelow(this.block3))
{
// Can we drop? First check all 3 blocks. If none of them have anything
// below then it's game over.
this.gameOver();
}
else
{
// Drop them one by one
if (this.block1)
{
if (this.hasBlockBelow(this.block1))
{
// There's something below this block, so we're good to carry on
this.grid[mapY][pos1] = 1;
}
else
{
// There's nothing below this block, so they loose it
this.block1.visible = false;
this.block1 = null;
}
}
if (this.block2)
{
if (this.hasBlockBelow(this.block2))
{
// There's something below this block, so we're good to carry on
this.grid[mapY][pos2] = 1;
}
else
{
// There's nothing below this block, so they loose it
this.block2.visible = false;
this.block2 = null;
}
}
if (this.block3)
{
if (this.hasBlockBelow(this.block3))
{
// There's something below this block, so we're good to carry on
this.grid[mapY][pos3] = 1;
}
else
{
// There's nothing below this block, so they loose it
this.block3.visible = false;
this.block3 = null;
}
}
// console.table(grid);
if (this.block1 || this.block2 || this.block3)
{
if (this.currentY === 1)
{
this.gameWon();
}
else
{
this.nextRow();
}
}
else
{
this.gameOver();
}
}
}
}
const config = {
type: Phaser.AUTO,
parent: 'phaser-example',
width: 800,
height: 600,
scene: Example
};
const game = new Phaser.Game(config);
Инициализация сцены и игровой сетки
В начале класса Example объявляются ключевые переменные для управления состоянием игры: таймер timer, направление движения direction, скорость speed, три блока и параметры сетки (gridSize, gridHeight, gridWidth). Массив grid представляет собой двумерную матрицу, где `0— пустая ячейка, а1` — занятая блоком.
В методе create после загрузки шрифта вызывается startGame. Здесь создаётся статическая сетка для визуализации и три стартовых блока (block1, block2, block3), которые являются объектами Phaser.GameObjects.Rectangle. Блоки позиционируются на основе размера ячейки (gridSize) и отрисовываются на одну клетку выше дна (currentY - 1).
this.add.grid(0, 0, this.gridWidth * this.gridSize, this.gridHeight * this.gridSize, this.gridSize, this.gridSize, 0x999999, 1, 0x666666).setOrigin(0);
this.block1 = this.add.rectangle(this.gridSize * 2, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
Движение блоков по таймеру
Основная механика движения реализована в методе moveBlocks, который вызывается по цикличному таймеру Phaser.Time.TimerEvent. Направление движения (direction) определяет, движутся ли блоки вправо (`0) или влево (1). Каждый блок сдвигается на одну клетку (gridSize) за тик. Когда крайний блок достигает границы сетки (gridWidth - 1или0`), направление меняется на противоположное.
this.timer = this.time.addEvent({ delay: this.speed, callback: this.moveBlocks, callbackScope: this, loop: true });
if (this.direction === 0) {
this.block1.x += this.gridSize;
if (this.getGridX(this.block1) === this.gridWidth - 1) {
this.direction = 1;
}
}
Функция getGridX преобразует координату `x` блока в индекс столбца сетки, что используется для проверки границ и столкновений.
Обработка падения блоков по вводу
Игрок может сбросить движущуюся платформу, нажав пробел (SPACE) или кликнув мышью. Обработчики keydown_SPACE и pointerdown вызывают метод drop. Этот метод останавливает таймер движения и определяет, куда упадут блоки.
Сначала проверяется, первый ли это ряд. Если да, блоки просто фиксируются в сетке, и игра переходит на следующий уровень (nextRow). Если нет, для каждого блока проверяется, есть ли под ним опора, с помощью метода hasBlockBelow, который смотрит в массив grid на строку ниже. Блок без опоры уничтожается (становится null и невидимым). Если все блоки потеряны, вызывается gameOver. Если остался хотя бы один блок и достигнут верх сетки, вызывается gameWon, иначе — переход на следующий ряд.
if (this.hasBlockBelow(this.block1)) {
this.grid[mapY][pos1] = 1;
} else {
this.block1.visible = false;
this.block1 = null;
}
Переход на новый уровень и рост сложности
Метод nextRow отвечает за подготовку нового уровня. Он уменьшает currentY (движение вверх) и, при достижении определённых строк (10 или `5), увеличивает сложность: уменьшает задержку таймера (speed`), делая движение быстрее, и, если у игрока ещё все блоки, удаляет один из них.
Затем блоки, которые ещё остались у игрока, пересоздаются на новой строке. Случайным образом выбирается сторона появления (левая или правая), и блоки размещаются последовательно, в зависимости от выбранного направления direction.
if (this.currentY === 10 && this.totalBlocks() === 3) {
this.block1 = null;
this.speed -= 90;
}
let side = 0;
if (Math.random() >= 0.5) {
this.direction = 1;
side = (this.gridWidth - 1) * this.gridSize;
}
if (this.block1) {
this.block1 = this.add.rectangle(side, (this.currentY - 1) * this.gridSize, this.gridSize - 1, this.gridSize - 1, 0x6666ff).setOrigin(0);
}
Это создаёт эффект прогрессирующей сложности и нехватки ресурсов.
Что попробовать дальше
Пример 'Stacker' предлагает готовый каркас для классической аркадной игры. Он наглядно показывает работу с игровым циклом через таймеры, управление состоянием через двумерный массив и реакцию на ввод пользователя. Для экспериментов можно начать с реализации методов gameOver и gameWon — добавить анимации, звуки и экран результата. Попробуйте изменить начальное количество блоков, размер сетки или логику увеличения скорости. Интересным развитием было бы добавление системы очков за каждый успешно уложенный блок или бонусных рядов, которые возвращают потерянный блок.
