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

Игра Stacker — классический аркадный автомат, где нужно выстраивать башню из блоков. Этот пример отлично демонстрирует, как в Phaser организовать игровую логику с использованием нескольких сцен, управлять состоянием игрового поля и реализовать постепенное усложнение. В статье мы разберем ключевые техники: работу с 2D-сеткой, создание движущихся объектов, обработку столкновений и плавные переходы между состояниями игры. Эти знания пригодятся для создания широкого спектра головоломок и аркад.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


class Boot extends Phaser.Scene {

    constructor ()
    {
        super('boot');
    }

    init ()
    {
        let 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.image('bg', 'assets/skies/gradient26.png');
        this.load.image('grid', 'assets/skies/grid.png');
        this.load.script('webfont', 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js');

        this.load.audio('place', [
            'assets/audio/stacker/place.ogg',
            'assets/audio/stacker/place.m4a'
        ]);

        this.load.audio('miss', [
            'assets/audio/stacker/miss.ogg',
            'assets/audio/stacker/miss.m4a'
        ]);

        this.load.audio('gamelost', [
            'assets/audio/stacker/gamelost.ogg',
            'assets/audio/stacker/gamelost.m4a'
        ]);

        this.load.audio('gamewon', [
            'assets/audio/stacker/gamewon.ogg',
            'assets/audio/stacker/gamewon.m4a'
        ]);
    }

    create ()
    {
        let scene = this.scene;

        WebFont.load({
            custom: {
                families: [ 'bebas' ]
            },
            active: function ()
            {
                scene.start('instructions');
            }
        });
    }

}

class Instructions extends Phaser.Scene {

    constructor ()
    {
        super('instructions');
    }

    create ()
    {
        this.add.image(400, 300, 'bg');
        this.add.image(400, 430, 'grid').setDisplaySize(800, 376);

        this.add.text(720, 0, 'S\n t\na\n c\nk\n e\nr', { fontFamily: 'bebas', fontSize: 74, color: '#ffffff', lineSpacing: -10 }).setShadow(2, 2, "#333333", 2, false, true);

        this.add.text(20, 40, 'Instructions', { fontFamily: 'bebas', fontSize: 70, color: '#ffffff' }).setShadow(2, 2, "#333333", 2, false, true);

        var help = [
            'Build a tower all the way to the top of the screen',
            'to win big "prizes"! Place rows of blocks on top',
            'of each other, but be careful: it gets faster each',
            'time, you lose blocks if you don\'t land perfectly,',
            'and you automatically shrink after rows 5 and 10!'
        ];

        this.add.text(20, 180, help, { fontFamily: 'bebas', fontSize: 30, color: '#ffffff', lineSpacing: 6 }).setShadow(2, 2, "#333333", 2, false, true);

        this.add.text(20, 450, 'Space Bar or Click to Place a Row', { fontFamily: 'bebas', fontSize: 40, color: '#ffffff' }).setShadow(2, 2, "#333333", 2, false, true);

        this.input.keyboard.once('keydown_SPACE', this.start, this);
        this.input.once('pointerdown', this.start, this);
    }

    start ()
    {
        this.scene.start('game');
    }

}

class StackerGame extends Phaser.Scene {

    constructor ()
    {
        super('game');

        this.grid;
        this.gridWidth = 7;
        this.gridHeight = 15;
        this.gridSize = 32;

        this.block1;
        this.block2;
        this.block3;

        this.speed = 250;

        this.direction = 0;
        this.currentY = 0;
        this.timer;

        this.offset = { x: this.gridSize * 6, y: this.gridSize * 2 };
    }

    init ()
    {
        this.grid = [];

        this.speed = 250;

        this.direction = 0;
        this.currentY = this.gridHeight;
    }

    create ()
    {
        let ox = this.offset.x;
        let oy = this.offset.y;

        let gw = this.gridWidth;
        let gh = this.gridHeight;

        let size = this.gridSize;

        let rows = [ 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 ];
        let prizes = [ 'Major Prize!', '', '' ,'' ,'', 'Minor Prize', '', '' ,'' ,'', 'Bonus' ];

        this.add.image(400, 300, 'bg');
        this.add.image(400, 430, 'grid').setDisplaySize(800, 376);

        this.add.text(720, 0, 'S\n t\na\n c\nk\n e\nr', { fontFamily: 'bebas', fontSize: 74, color: '#ffffff', lineSpacing: -10 }).setShadow(2, 2, "#333333", 2, false, true);

        this.add.text(ox - 32, oy, rows, { fontFamily: 'bebas', fontSize: 26, color: '#ffffff', align: 'right' }).setShadow(2, 2, "#333333", 2, false, true);
        this.add.text(ox + (gw * size) + size/2, oy, prizes, { fontFamily: 'bebas', fontSize: 26, color: '#ffffff' }).setShadow(2, 2, "#333333", 2, false, true);

        this.add.grid(ox, oy, gw * size, gh * size, size, size, 0x999999, 1, 0x666666).setOrigin(0);

        this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);

        this.block1 = this.add.rectangle(ox + size * 2, oy + (this.currentY - 1) * size, size - 1, size - 1, 0x99ffff).setOrigin(0);
        this.block2 = this.add.rectangle(ox + size * 3, oy + (this.currentY - 1) * size, size - 1, size - 1, 0x99ffff).setOrigin(0);
        this.block3 = this.add.rectangle(ox + size * 4, oy + (this.currentY - 1) * size, size - 1, size - 1, 0x99ffff).setOrigin(0);

        for (var y = 0; y < gh; 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);
    }

    getGridX (block)
    {
        return (block.x - this.offset.x) / this.gridSize;
    }

    hasBlockBelow (block)
    {
        return (block && this.grid[this.currentY][this.getGridX(block)]);
    }

    totalBlocks ()
    {
        let total = 0;

        if (this.block1)
        {
            total++;
        }

        if (this.block2)
        {
            total++;
        }

        if (this.block3)
        {
            total++;
        }

        return total;
    }

    moveBlocks ()
    {
        let block1 = this.block1;
        let block2 = this.block2;
        let block3 = this.block3;

        let size = this.gridSize;
        let gw = this.gridWidth;

        if (this.direction === 0)
        {
            //  Moving right
            if (block1)
            {
                block1.x += size;

                if (this.getGridX(block1) === gw - 1)
                {
                    this.direction = 1;
                }
            }

            if (block2)
            {
                block2.x += size;

                if (this.getGridX(block2) === gw - 1)
                {
                    this.direction = 1;
                }
            }

            if (block3)
            {
                block3.x += size;

                if (this.getGridX(block3) === gw - 1)
                {
                    this.direction = 1;
                }
            }
        }
        else
        {
            //  Moving left
            if (block1)
            {
                block1.x -= size;

                if (block1 && this.getGridX(block1) === 0)
                {
                    this.direction = 0;
                }
            }

            if (block2)
            {
                block2.x -= size;

                if (block2 && this.getGridX(block2) === 0)
                {
                    this.direction = 0;
                }
            }

            if (block3)
            {
                block3.x -= size;

                if (block3 && this.getGridX(block3) === 0)
                {
                    this.direction = 0;
                }
            }
        }
    }

    drop ()
    {
        this.timer.remove(false);

        let pos1 = (this.block1) ? this.getGridX(this.block1) : -1;
        let pos2 = (this.block2) ? this.getGridX(this.block2) : -1;
        let pos3 = (this.block3) ? this.getGridX(this.block3) : -1;

        let 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.sound.play('place');

            this.nextRow();
        }
        else
        {
            //  Can we drop? First check all 3 blocks. If none of them have anything
            //  below then it's game over.

            var droppedOne = false;

            if (!this.hasBlockBelow(this.block1) && !this.hasBlockBelow(this.block2) && !this.hasBlockBelow(this.block3))
            {
                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 lose it
                        this.block1.visible = false;
                        this.block1 = null;
                        droppedOne = true;
                    }
                }

                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 lose it
                        this.block2.visible = false;
                        this.block2 = null;
                        droppedOne = true;
                    }
                }

                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 lose it
                        this.block3.visible = false;
                        this.block3 = null;
                        droppedOne = true;
                    }
                }

                if (this.block1 || this.block2 || this.block3)
                {
                    if (this.currentY === 1)
                    {
                        this.currentY--;

                        this.gameOver();
                    }
                    else
                    {
                        if (droppedOne)
                        {
                            this.sound.play('miss');
                        }
                        else
                        {
                            this.sound.play('place');
                        }

                        this.nextRow();
                    }
                }
                else
                {
                    this.gameOver();
                }
            }
        }
    }

    nextRow ()
    {
        this.currentY--;

        if (this.currentY === 10 || this.currentY === 5)
        {
            this.speed -= (this.currentY === 10) ? 100 : 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 size = this.gridSize;
        let shift = size;

        let ox = this.offset.x;
        let oy = this.offset.y;

        if (Math.random() >= 0.5)
        {
            this.direction = 1;
            side = (this.gridWidth - 1) * size;
            shift = -size;
        }
        else
        {
            this.direction = 0;
        }

        if (this.block1)
        {
            this.block1 = this.add.rectangle(ox + side, oy + (this.currentY - 1) * size, size - 1, size - 1, 0x99ffff).setOrigin(0);
            side += shift;
        }

        if (this.block2)
        {
            this.block2 = this.add.rectangle(ox + side, oy + (this.currentY - 1) * size, size - 1, size - 1, 0x99ffff).setOrigin(0);
            side += shift;
        }

        if (this.block3)
        {
            this.block3 = this.add.rectangle(ox + side, oy + (this.currentY - 1) * size, size - 1, size - 1, 0x99ffff).setOrigin(0);
        }

        this.timer = this.time.addEvent({ delay: this.speed, callback: this.moveBlocks, callbackScope: this, loop: true });
    }

    gameOver ()
    {
        this.timer.remove(false);

        this.input.keyboard.off('keydown_SPACE', this.drop);
        this.input.off('pointerdown', this.drop);

        this.registry.set('score', this.gridHeight - this.currentY);

        this.scene.pause();
        this.scene.run('gameOver');
    }
}

class GameOver extends Phaser.Scene {

    constructor ()
    {
        super('gameOver');
    }

    create ()
    {
        this.add.rectangle(400, 300, 640, 500, 0x000000, 0.7);

        var list = [
            'Tiny Bonus:',
            '',
            'Minor Prize:',
            '',
            'Major Prize:'
        ];

        var prizes1 = [ 'A Paperclip', 'Half-eaten Sandwich', 'A Boiled Egg', 'Used Gum', 'A Goldfish', 'A Book about Flash' ];
        var prizes2 = [ 'Mario Stickers', 'SNES Joypad', 'Superman Cape', 'Contra Poster', 'Bubble Machine', 'X-Ray Specs', 'Skateboard' ];
        var prizes3 = [ 'Playstation 4', 'A Tardis', 'An X-Wing', 'Super Nintendo', 'Arcade Machine', 'Dragon Egg', 'Personal Cyborg' ];

        var score = this.registry.get('score');

        console.log(score);

        var prizelist = [
            'Nothing (Complete 5 rows)',
            '',
            'Nothing (Complete 10 rows)',
            '',
            'Nothing (Complete 15 rows)'
        ];

        var title = 'GAME OVER!';

        if (score >= 5)
        {
            prizelist[0] = Phaser.Utils.Array.GetRandom(prizes1);
        }

        if (score >= 10)
        {
            prizelist[2] = Phaser.Utils.Array.GetRandom(prizes2);
        }

        if (score === 15)
        {
            prizelist[4] = Phaser.Utils.Array.GetRandom(prizes3);
            title = 'GAME WON!';
        }

        if (score < 5)
        {
            this.sound.play('gamelost');
        }
        else
        {
            this.sound.play('gamewon');
        }

        this.add.text(400, 120, title, { fontFamily: 'bebas', fontSize: 80, color: '#ffffff' }).setShadow(2, 2, "#333333", 2, false, true).setOrigin(0.5);
        this.add.text(400, 200, 'Let\'s see what you have won:', { fontFamily: 'bebas', fontSize: 26, color: '#ffffff' }).setShadow(2, 2, "#333333", 2, false, true).setOrigin(0.5);

        this.add.text(100, 270, list, { fontFamily: 'bebas', fontSize: 26, color: '#ffffff', align: 'right' }).setShadow(2, 2, "#333333", 2, false, true);
        this.add.text(260, 270, prizelist, { fontFamily: 'bebas', fontSize: 26, color: '#ffff00' }).setShadow(2, 2, "#333333", 2, false, true);

        this.add.text(400, 500, 'Space or Click to try again', { fontFamily: 'bebas', fontSize: 26, color: '#ffffff' }).setShadow(2, 2, "#333333", 2, false, true).setOrigin(0.5);

        this.input.keyboard.once('keydown_SPACE', this.restart, this);
        this.input.once('pointerdown', this.restart, this);
    }

    restart ()
    {
        this.scene.start('game');
    }

}

var config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    parent: 'phaser-example',
    scene: [ Boot, Instructions, StackerGame, GameOver ]
};

var game = new Phaser.Game(config);

Структура сцен: от загрузки до игры

Проект разделен на четыре сцены, каждая отвечает за свой этап. Это классический подход для организации кода в Phaser.

Сцена Boot загружает ресурсы и шрифты. Обратите внимание на нестандартную загрузку аудио с двумя форматами для кросс-браузерной совместимости. Шрифт подключается динамически через CSS.

this.load.audio('place', [
    'assets/audio/stacker/place.ogg',
    'assets/audio/stacker/place.m4a'
]);

Сцена Instructions показывает правила. После взаимодействия пользователя она запускает основную игровую сцену.

this.input.keyboard.once('keydown_SPACE', this.start, this);
this.input.once('pointerdown', this.start, this);

Основная механика сосредоточена в StackerGame, а GameOver обрабатывает завершение раунда.

Игровая сетка и движущиеся блоки

Сердце игры — представление поля в виде двумерного массива this.grid. Каждая ячейка может содержать 0 (пусто) или 1 (занято блоком). Блоки визуализируются как прямоугольники (this.add.rectangle), но их состояние хранится в сетке.

Три текущих блока (block1, block2, block3) движутся горизонтально по таймеру this.timer. Метод moveBlocks проверяет границы сетки и меняет направление.

moveBlocks ()
{
    if (this.direction === 0)
    {
        //  Движение вправо
        if (block1)
        {
            block1.x += size;
            if (this.getGridX(block1) === gw - 1)
            {
                this.direction = 1;
            }
        }
        ...
    }
}

Ключевой метод getGridX переводит пиксельные координаты блока в индекс колонки сетки, что упрощает логику проверок.

Механика установки блоков и проверка столкновений

При нажатии клавиши или клике срабатывает метод drop. Это самая сложная часть логики. Сначала игра проверяет, есть ли под любым из трех блоков опора (занятая ячейка сетки на ряд ниже).

Метод hasBlockBelow для этого использует данные сетки.

hasBlockBelow (block)
{
    return (block && this.grid[this.currentY][this.getGridX(block)]);
}

Если опоры нет ни под одним блоком — игра завершается. Если есть, то блоки устанавливаются (this.grid[mapY][pos] = 1), а те, что зависли в воздухе, уничтожаются (становятся null). После успешной установки вызывается nextRow для перехода на новый уровень.

Прогрессия сложности: скорость и количество блоков

Игра становится сложнее с высотой. После 5-го и 10-го ряда скорость движения увеличивается, а количество блоков в движущейся группе уменьшается. Это реализовано в методе nextRow.

if (this.currentY === 10 || this.currentY === 5)
{
    this.speed -= (this.currentY === 10) ? 100 : 50;
    //  Убираем блок, если их полный комплект
    if (this.currentY === 10 && this.totalBlocks() === 3)
    {
        this.block1 = null;
    }
}

Также здесь определяется, с какой стороны (side) и в каком направлении (this.direction) появятся новые блоки на следующем ряду, создавая элемент неожиданности.

Завершение игры и передача данных между сценами

При проигрыше или победе сцена StackerGame ставится на паузу и запускается GameOver. Ключевой результат — количество построенных рядов — передается через реестр (this.registry).

gameOver ()
{
    this.registry.set('score', this.gridHeight - this.currentY);
    this.scene.pause();
    this.scene.run('gameOver');
}

Сцена GameOver получает этот счет, выбирает соответствующий "приз" из массивов с помощью Phaser.Utils.Array.GetRandom и отображает итог. Это простой и эффективный способ обмена данными между независимыми сценами.

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

Пример Stacker — это отличная основа для понимания геймдизайна и реализации игровой логики в Phaser. Вы можете экспериментировать: изменить размер сетки, добавить новые типы блоков с особыми свойствами, ввести бонусные ряды, которые дают дополнительный блок, или реализовать систему очков за точность. Попробуйте заменить прямоугольники на спрайты с анимациями для более яркой визуализации.