О чем этот пример
Классическая головоломка «Сапёр» — отличный проект для изучения архитектуры игр на Phaser. В этой статье мы разберём реализацию игры из официальных примеров Phaser. Вы узнаете, как эффективно организовать игровую сетку, обрабатывать взаимодействие игрока и реализовать ключевые механики, такие как открытие клеток, установка флажков и рекурсивный алгоритм заливки пустых областей. Этот пример демонстрирует чистый подход к разделению данных (модель клетки) и отображения (спрайты в Phaser).
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Cell
{
constructor (grid, index, x, y)
{
this.grid = grid;
this.index = index;
this.x = x;
this.y = y;
this.open = false;
this.bomb = false;
this.flagged = false;
this.query = false;
this.exploded = false;
// 0 = empty, 1,2,3,4,5,6,7,8 = number of adjacent bombs
this.value = 0;
this.tile = grid.scene.make.image({
key: 'tiles',
frame: 0,
x: grid.offset.x + (x * 16),
y: grid.offset.y + (y * 16),
origin: 0
});
grid.board.add(this.tile);
this.tile.setInteractive();
this.tile.on('pointerdown', this.onPointerDown, this);
this.tile.on('pointerup', this.onPointerUp, this);
}
reset ()
{
this.open = false;
this.bomb = false;
this.flagged = false;
this.query = false;
this.exploded = false;
this.value = 0;
this.tile.setFrame(0);
}
onPointerDown (pointer)
{
if (!this.grid.populated)
{
this.grid.generate(this.index);
}
if (this.open || !this.grid.playing)
{
return;
}
if (pointer.rightButtonDown())
{
if (this.query)
{
this.query = false;
this.tile.setFrame(0);
}
else if (this.flagged)
{
this.query = true;
this.flagged = false;
this.grid.updateBombs(-1);
this.tile.setFrame(3);
}
else if (!this.flagged)
{
this.flagged = true;
this.tile.setFrame(2);
this.grid.updateBombs(1);
this.grid.checkWinState();
}
}
else if (!this.flagged && !this.query)
{
this.onClick();
}
}
onClick ()
{
if (this.bomb)
{
this.exploded = true;
this.grid.gameOver();
}
else
{
if (this.value === 0)
{
this.grid.floodFill(this.x, this.y);
}
else
{
this.show();
}
this.grid.button.setFrame(2);
this.grid.checkWinState();
}
}
onPointerUp ()
{
if (this.grid.button.frame.name === 2)
{
this.grid.button.setFrame(0);
}
}
reveal ()
{
if (this.exploded)
{
this.tile.setFrame(6);
}
else if (!this.bomb && (this.flagged || this.query))
{
this.tile.setFrame(7);
}
else if (this.bomb)
{
this.tile.setFrame(5);
}
else
{
this.show();
}
}
show ()
{
const values = [ 1, 8, 9, 10, 11, 12, 13, 14, 15 ];
this.tile.setFrame(values[this.value]);
this.open = true;
}
debug ()
{
const values = [ '⬜️', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣' ];
if (this.bomb)
{
return '💣';
}
else
{
return values[this.value];
}
}
}
class Grid
{
constructor (scene, width, height, bombs)
{
this.scene = scene;
this.width = width;
this.height = height;
this.size = width * height;
this.offset = new Phaser.Math.Vector2(12, 55);
this.timeCounter = 0;
this.bombQty = bombs;
this.bombsCounter = bombs;
this.playing = false;
this.populated = false;
this.timer = scene.time.addEvent();
// 0 = waiting to create the grid
// 1 = playing
// 2 = game won
// 3 = game lost
this.state = 0;
this.data = [];
const x = Math.floor((scene.scale.width / 2) - (20 + (width * 16)) / 2);
const y = Math.floor((scene.scale.height / 2) - (63 + (height * 16)) / 2);
this.board = scene.add.container(x, y);
this.digit1;
this.digit2;
this.digit3;
this.time1;
this.time2;
this.time3;
this.button;
this.createBackground();
this.createCells();
this.updateDigits();
this.button.setInteractive();
this.button.on('pointerdown', this.onButtonDown, this);
this.button.on('pointerup', this.onButtonUp, this);
}
createCells ()
{
let i = 0;
for (let x = 0; x < this.width; x++)
{
this.data[x] = [];
for (let y = 0; y < this.height; y++)
{
this.data[x][y] = new Cell(this, i, x, y);
i++;
}
}
}
createBackground ()
{
const board = this.board;
const factory = this.scene.add;
// 55 added to the top, 8 added to the bottom (63)
// 12 added to the left, 8 added to the right (20)
// cells start at 12 x 55
const width = this.width * 16;
const height = this.height * 16;
// Top
board.add(factory.image(0, 0, 'topLeft').setOrigin(0));
const topBgWidth = (width + 20) - 60 - 56;
board.add(factory.tileSprite(60, 0, topBgWidth, 55, 'topBg').setOrigin(0));
board.add(factory.image(width + 20, 0, 'topRight').setOrigin(1, 0));
// Sides
const sideHeight = (height + 63) - 55 - 8;
board.add(factory.tileSprite(0, 55, 12, sideHeight, 'left').setOrigin(0));
board.add(factory.tileSprite(width + 20, 55, 8, sideHeight, 'right').setOrigin(1, 0));
// Bottom
board.add(factory.image(0, height + 63, 'botLeft').setOrigin(0, 1));
const botBgWidth = (width + 20) - 12 - 8;
board.add(factory.tileSprite(12, height + 63, botBgWidth, 8, 'botBg').setOrigin(0, 1));
board.add(factory.image(width + 20, height + 63, 'botRight').setOrigin(1, 1));
// Bombs Digits
this.digit1 = factory.image(17, 16, 'digits', 0).setOrigin(0);
this.digit2 = factory.image(17 + 13, 16, 'digits', 0).setOrigin(0);
this.digit3 = factory.image(17 + 26, 16, 'digits', 0).setOrigin(0);
board.add([ this.digit1, this.digit2, this.digit3 ]);
// Timer Digits
const x = (width + 20) - 54;
this.time1 = factory.image(x, 16, 'digits', 0).setOrigin(0);
this.time2 = factory.image(x + 13, 16, 'digits', 0).setOrigin(0);
this.time3 = factory.image(x + 26, 16, 'digits', 0).setOrigin(0);
board.add([ this.time1, this.time2, this.time3 ]);
// Button
const buttonX = Math.floor(((width + 20) / 2) - 13);
this.button = factory.image(buttonX, 15, 'buttons', 0).setOrigin(0);
board.add(this.button);
}
updateBombs (diff)
{
this.bombsCounter -= diff;
this.updateDigits();
}
updateDigits ()
{
const count = Phaser.Utils.String.Pad(this.bombsCounter.toString(), 3, '0', 1);
this.digit1.setFrame(parseInt(count[0]));
this.digit2.setFrame(parseInt(count[1]));
this.digit3.setFrame(parseInt(count[2]));
}
onButtonDown ()
{
this.button.setFrame(1);
}
onButtonUp ()
{
if (this.state > 0)
{
this.button.setFrame(0);
this.restart();
}
}
restart ()
{
this.populated = false;
this.playing = false;
this.bombsCounter = this.bombQty;
this.state = 0;
this.timeCounter = -1;
this.timer.paused = true;
let location = 0;
do {
this.getCell(location).reset();
location++;
} while (location < this.size);
this.updateDigits();
this.time1.setFrame(0);
this.time2.setFrame(0);
this.time3.setFrame(0);
}
gameOver ()
{
this.playing = false;
this.state = 3;
this.timer.paused = true;
this.button.setFrame(4);
let location = 0;
do {
this.getCell(location).reveal();
location++;
} while (location < this.size);
}
gameWon ()
{
this.playing = false;
this.state = 2;
this.timer.paused = true;
this.button.setFrame(3);
}
checkWinState ()
{
let correct = 0;
let location = 0;
let open = 0;
do {
const cell = this.getCell(location);
if (cell.open)
{
open++;
}
if (cell.bomb && cell.flagged)
{
open++;
correct++;
}
location++;
} while (location < this.size);
// console.log('Check', correct, 'out of', this.bombQty, 'open', open, 'of', this.size);
if (correct === this.bombQty && open === this.size)
{
this.gameWon();
}
}
generate (startIndex)
{
let qty = this.bombQty;
const bombs = [];
do {
const location = Phaser.Math.Between(0, this.size - 1);
const cell = this.getCell(location);
if (!cell.bomb && cell.index !== startIndex)
{
cell.bomb = true;
qty--;
bombs.push(cell);
}
} while (qty > 0);
bombs.forEach(cell => {
// Update the 8 cells around this bomb cell
const adjacent = this.getAdjacentCells(cell);
adjacent.forEach(adjacentCell => {
if (adjacentCell)
{
adjacentCell.value++;
}
});
});
this.playing = true;
this.populated = true;
this.state = 1;
this.timer.reset({ delay: 1000, callback: this.onTimer, callbackScope: this, loop: true });
this.debug();
}
onTimer ()
{
this.timeCounter++;
if (this.timeCounter < 1000)
{
const count = Phaser.Utils.String.Pad(this.timeCounter.toString(), 3, '0', 1);
this.time1.setFrame(parseInt(count[0]));
this.time2.setFrame(parseInt(count[1]));
this.time3.setFrame(parseInt(count[2]));
}
}
getCell (index)
{
const pos = Phaser.Math.ToXY(index, this.width, this.height);
return this.data[pos.x][pos.y];
}
getCellXY (x, y)
{
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
{
return null;
}
return this.data[x][y];
}
getAdjacentCells (cell)
{
return [
// Top-Left, Top-Middle, Top-Right
this.getCellXY(cell.x - 1, cell.y - 1),
this.getCellXY(cell.x, cell.y - 1),
this.getCellXY(cell.x + 1, cell.y - 1),
// Left, Right
this.getCellXY(cell.x - 1, cell.y),
this.getCellXY(cell.x + 1, cell.y),
// Bottom-Left, Bottom-Middle, Bottom-Right
this.getCellXY(cell.x - 1, cell.y + 1),
this.getCellXY(cell.x, cell.y + 1),
this.getCellXY(cell.x + 1, cell.y + 1)
];
}
floodFill (x, y)
{
const cell = this.getCellXY(x, y);
if (cell && !cell.open && !cell.bomb)
{
cell.show();
if (cell.value === 0)
{
this.floodFill(x, y - 1);
this.floodFill(x, y + 1);
this.floodFill(x - 1, y);
this.floodFill(x + 1, y);
}
}
}
debug ()
{
for (let y = 0; y < this.height; y++)
{
let row = '';
for (let x = 0; x < this.width; x++)
{
let cell = this.data[x][y];
if (x === 0)
{
row = row.concat(`|`);
}
row = row.concat(`${cell.debug()}|`);
}
console.log(row);
}
console.log('');
}
}
class Intro extends Phaser.Scene
{
constructor ()
{
super();
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.setPath('assets/games/minesweeper/');
this.load.spritesheet('tiles', 'tiles.png', { frameWidth: 16 });
this.load.spritesheet('digits', 'digits.png', { frameWidth: 13, frameHeight: 23, endFrame: 9 });
this.load.spritesheet('buttons', 'digits.png', { frameWidth: 26, frameHeight: 26, startFrame: 5 });
this.load.image('topLeft', 'top-left.png');
this.load.image('topRight', 'top-right.png');
this.load.image('topBg', 'top-bg.png');
this.load.image('botLeft', 'bot-left.png');
this.load.image('botRight', 'bot-right.png');
this.load.image('botBg', 'bot-bg.png');
this.load.image('left', 'left.png');
this.load.image('right', 'right.png');
this.load.image('intro', 'intro.png');
this.load.image('win95', 'win95.png');
}
create ()
{
this.input.mouse.disableContextMenu();
this.highlight = this.add.rectangle(0, 334, 800, 70, 0x0182fb).setOrigin(0).setAlpha(0.75);
this.intro = this.add.image(0, 0, 'intro').setOrigin(0);
const zone1 = this.add.zone(0, 334, 800, 70).setOrigin(0);
const zone2 = this.add.zone(0, 411, 800, 70).setOrigin(0);
const zone3 = this.add.zone(0, 488, 800, 70).setOrigin(0);
zone1.setInteractive();
zone2.setInteractive();
zone3.setInteractive();
zone1.on('pointerover', () => {
this.highlight.y = zone1.y;
});
zone2.on('pointerover', () => {
this.highlight.y = zone2.y;
});
zone3.on('pointerover', () => {
this.highlight.y = zone3.y;
});
zone1.once('pointerdown', () =>
{
this.startGame(9, 9, 10);
});
zone2.once('pointerdown', () =>
{
this.startGame(16, 16, 40);
});
zone3.once('pointerdown', () =>
{
this.startGame(16, 30, 99);
});
}
startGame (width, height, bombs)
{
this.scene.start('MineSweeper', { width, height, bombs });
}
}
class MineSweeper extends Phaser.Scene
{
constructor ()
{
super('MineSweeper');
}
init (data)
{
this.width = data.width;
this.height = data.height;
this.bombs = data.bombs;
}
create ()
{
this.add.image(0, 0, 'win95').setOrigin(0);
this.grid = new Grid(this, this.width, this.height, this.bombs);
}
}
const config = {
type: Phaser.AUTO,
width: 800,
height: 600,
backgroundColor: 0x2d2d2d,
parent: 'phaser-example',
scene: [ Intro, MineSweeper ]
};
const game = new Phaser.Game(config);
Модель клетки: состояние и взаимодействие
Класс Cell — это сердце игры. Он хранит состояние одной клетки и её визуальное представление.
Каждая клетка знает, открыта ли она (open), является ли бомбой (bomb), помечена ли флажком (flagged) или знаком вопроса (query). Числовое значение value указывает количество соседних бомб (от 0 до 8).
Объект this.tile — это спрайт Phaser, отображающий текущий кадр из атласа tiles. Метод setFrame меняет его внешний вид.
Обработка ввода привязана непосредственно к спрайту клетки. Обратите внимание на проверку pointer.rightButtonDown() для реализации контекстного меню (правый клик).
this.tile.on('pointerdown', this.onPointerDown, this);
this.tile.on('pointerup', this.onPointerUp, this);
Логика правого клика реализует циклическое переключение состояний: обычная клетка -> флажок -> знак вопроса -> обычная клетка. При изменении состояния флажка вызывается метод this.grid.updateBombs() для обновления счётчика.
if (pointer.rightButtonDown())
{
if (this.query)
{
this.query = false;
this.tile.setFrame(0);
}
else if (this.flagged)
{
this.query = true;
this.flagged = false;
this.grid.updateBombs(-1);
this.tile.setFrame(3);
}
else if (!this.flagged)
{
this.flagged = true;
this.tile.setFrame(2);
this.grid.updateBombs(1);
this.grid.checkWinState();
}
}
Игровая сетка: управление данными и интерфейсом
Класс Grid управляет всей игровой доской. Он создаёт и хранит массив клеток this.data, а также отвечает за отрисовку игрового интерфейса — счётчиков бомб и времени, кнопки сброса и декоративных панелей.
Метод createCells заполняет двумерный массив объектами Cell, передавая им координаты и индекс.
createCells ()
{
let i = 0;
for (let x = 0; x < this.width; x++)
{
this.data[x] = [];
for (let y = 0; y < this.height; y++)
{
this.data[x][y] = new Cell(this, i, x, y);
i++;
}
}
}
Особенность этой реализации — «ленивая» генерация мин. Игровое поле заполняется бомбами только после первого клика игрока. Это стандартное поведение, гарантирующее, что первый ход никогда не будет проигрышным. Генерация запускается в Cell.onPointerDown.
if (!this.grid.populated)
{
this.grid.generate(this.index);
}
Метод generate размещает заданное количество бомб в случайных клетках, исключая клетку первого клика. Затем для каждой бомбы увеличивает значение value у всех соседних клеток через getAdjacentCells. После этого запускается игровой таймер.
Управление счётчиками реализовано через спрайты цифр. Метод updateDigits форматирует число и устанавливает соответствующие кадры спрайтов.
const count = Phaser.Utils.String.Pad(this.bombsCounter.toString(), 3, '0', 1);
this.digit1.setFrame(parseInt(count[0]));
Ключевые игровые алгоритмы: заливка и проверка победы
Две самые интересные алгоритмические части — это рекурсивное открытие пустых областей (flood fill) и проверка условия победы.
Когда игрок кликает на клетку с value === 0, вызывается метод floodFill. Он рекурсивно открывает все соседние пустые клетки (значение 0) и останавливается на границах, где у клеток есть число (значение от 1 до 8). Это создаёт эффект мгновенного открытия больших пустых областей.
floodFill (x, y)
{
const cell = this.getCellXY(x, y);
if (cell && !cell.open && !cell.bomb)
{
cell.show();
if (cell.value === 0)
{
this.floodFill(x, y - 1);
this.floodFill(x, y + 1);
this.floodFill(x - 1, y);
this.floodFill(x + 1, y);
}
}
}
Условие победы проверяется в checkWinState. Игрок выигрывает, если выполнены два условия одновременно: все бомбы отмечены флажками (correct === this.bombQty) и все остальные клетки открыты (open === this.size). Алгоритм проходит по всем клеткам, подсчитывая открытые и правильно помеченные бомбы.
if (cell.bomb && cell.flagged)
{
open++;
correct++;
}
Метод reveal вызывается при завершении игры и отвечает за показ всех мин и ошибок игрока (например, неверно поставленных флажков).
Сцены Phaser: меню и переход к игре
Проект использует две сцены: Intro (меню выбора сложности) и MineSweeper (основная игровая сцена).
Сцена Intro загружает все ресурсы. В create она создаёт интерактивные зоны для выбора уровня сложности. При наведении на зону двигается полупрозрачный прямоугольник highlight, создавая эффект подсветки.
zone1.on('pointerover', () => {
this.highlight.y = zone1.y;
});
Важный момент: чтобы отключить стандартное контекстное меню браузера по правому клику на холсте, используется строка:
this.input.mouse.disableContextMenu();
При выборе сложности сцена запускает основную игровую сцену MineSweeper, передавая параметры (ширину, высоту, количество мин) через объект data.
startGame (width, height, bombs)
{
this.scene.start('MineSweeper', { width, height, bombs });
}
В сцене MineSweeper эти параметры считываются в методе init, а в create создаётся экземпляр класса Grid, который и начинает игру.
Что попробовать дальше
Разобранный пример «Сапёра» демонстрирует мощь объектно-ориентированного подхода в Phaser. Чёткое разделение на классы Cell и Grid делает код поддерживаемым и расширяемым. Для экспериментов попробуйте: изменить алгоритм генерации поля на детерминированный, добавить новые типы клеток (например, «защитный щит»), реализовать систему рекордов или создать эффект анимации при открытии области. Также интересно будет заменить спрайты на графику высокого разрешения, сохранив при этом всю игровую логику.
