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

Flood Fill (заливка) — это не только классический алгоритм поиска в ширину или глубину, но и основа для популярных головоломок. В этой статье мы разберём готовый пример игры на Phaser, где игрок, выбирая цвета, должен закрасить всё игровое поле. Вы узнаете, как реализовать рекурсивный алгоритм заливки, управлять состоянием игровых объектов, создавать плавные анимации и визуальные эффекты с помощью системы частиц Phaser. Этот пример отлично демонстрирует, как соединить игровую логику с приятной визуальной составляющей.

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

Живой запуск

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

Исходный код


var Flood = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

    function Flood ()
    {
        Phaser.Scene.call(this, { key: 'flood' });

        this.allowClick = true;

        this.arrow;
        this.cursor;
        this.cursorTween;
        this.monsterTween;

        this.icon1 = { shadow: null, monster: null };
        this.icon2 = { shadow: null, monster: null };
        this.icon3 = { shadow: null, monster: null };
        this.icon4 = { shadow: null, monster: null };
        this.icon5 = { shadow: null, monster: null };
        this.icon6 = { shadow: null, monster: null };

        this.gridBG;

        this.instructions;
        this.text1;
        this.text2;
        this.text3;

        this.currentColor = '';

        this.emitters = {};

        this.grid = [];
        this.matched = [];

        this.moves = 25;

        this.frames = [ 'blue', 'green', 'grey', 'purple', 'red', 'yellow' ];
    },

    preload: function ()
    {
        this.load.bitmapFont('atari', 'assets/fonts/bitmap/atari-smooth.png', 'assets/fonts/bitmap/atari-smooth.xml');
        this.load.atlas('flood', 'assets/games/flood/blobs.png', 'assets/games/flood/blobs.json');
    },

    create: function ()
    {
        this.add.image(400, 300, 'flood', 'background');
        this.gridBG = this.add.image(400, 600 + 300, 'flood', 'grid');

        this.createIcon(this.icon1, 'grey', 16, 156);
        this.createIcon(this.icon2, 'red', 16, 312);
        this.createIcon(this.icon3, 'green', 16, 458);
        this.createIcon(this.icon4, 'yellow', 688, 156);
        this.createIcon(this.icon5, 'blue', 688, 312);
        this.createIcon(this.icon6, 'purple', 688, 458);

        this.cursor = this.add.image(16, 156, 'flood', 'cursor-over').setOrigin(0).setVisible(false);

        //  The game is played in a 14x14 grid with 6 different colors

        this.grid = [];

        for (var x = 0; x < 14; x++)
        {
            this.grid[x] = [];

            for (var y = 0; y < 14; y++)
            {
                var sx = 166 + (x * 36);
                var sy = 66 + (y * 36);
                var color = Phaser.Math.Between(0, 5);

                var block = this.add.image(sx, -600 + sy, 'flood', this.frames[color]);

                block.setData('oldColor', color);
                block.setData('color', color);
                block.setData('x', sx);
                block.setData('y', sy);

                this.grid[x][y] = block;
            }
        }

        //  Do a few floods just to make it a little easier starting off
        this.helpFlood();

        for (var i = 0; i < this.matched.length; i++)
        {
            var block = this.matched[i];

            block.setFrame(this.frames[block.getData('color')]);
        }

        this.currentColor = this.grid[0][0].getData('color');

        for (var i = 0; i < this.frames.length; i++)
        {
            this.createEmitter(this.frames[i]);
        }

        this.createArrow();

        this.text1 = this.add.bitmapText(684, 30, 'atari', 'Moves', 20).setAlpha(0);
        this.text2 = this.add.bitmapText(694, 60, 'atari', '00', 40).setAlpha(0);
        this.text3 = this.add.bitmapText(180, 200, 'atari', 'So close!\n\nClick to\ntry again', 48).setAlpha(0);

        this.instructions = this.add.image(400, 300, 'flood', 'instructions').setAlpha(0);

        this.revealGrid();
    },

    helpFlood: function ()
    {
        for (var i = 0; i < 8; i++)
        {
            var x = Phaser.Math.Between(0, 13);
            var y = Phaser.Math.Between(0, 13);

            var oldColor = this.grid[x][y].getData('color');
            var newColor = oldColor + 1;

            if (newColor === 6)
            {
                newColor = 0;
            }

            this.floodFill(oldColor, newColor, x, y)
        }
    },

    createArrow: function ()
    {
        this.arrow = this.add.image(109 - 24, 48, 'flood', 'arrow-white').setOrigin(0).setAlpha(0);

        this.tweens.add({

            targets: this.arrow,
            x: '+=24',
            ease: 'Sine.easeInOut',
            duration: 900,
            yoyo: true,
            repeat: -1

        });
    },

    createIcon: function (icon, color, x, y)
    {
        var sx = (x < 400) ? -200 : 1000;

        icon.monster = this.add.image(sx, y, 'flood', 'icon-' + color).setOrigin(0);

        var shadow = this.add.image(sx, y, 'flood', 'shadow');

        shadow.setData('color', this.frames.indexOf(color));

        shadow.setData('x', x);

        shadow.setData('monster', icon.monster);

        shadow.setOrigin(0);

        shadow.setInteractive();

        icon.shadow = shadow;
    },

    revealGrid: function ()
    {
        this.tweens.add({
            targets: this.gridBG,
            y: 300,
            ease: 'Power3'
        });

        var i = 800;

        for (var y = 13; y >= 0; y--)
        {
            for (var x = 0; x < 14; x++)
            {
                var block = this.grid[x][y];

                this.tweens.add({

                    targets: block,

                    y: block.getData('y'),

                    ease: 'Power3',
                    duration: 800,
                    delay: i

                });

                i += 20;
            }
        }

        i -= 1000;

        //  Icons
        this.tweens.add({
            targets: [ this.icon1.shadow, this.icon1.monster ],
            x: this.icon1.shadow.getData('x'),
            ease: 'Power3',
            delay: i
        });

        this.tweens.add({
            targets: [ this.icon4.shadow, this.icon4.monster ],
            x: this.icon4.shadow.getData('x'),
            ease: 'Power3',
            delay: i
        });

        i += 200;

        this.tweens.add({
            targets: [ this.icon2.shadow, this.icon2.monster ],
            x: this.icon2.shadow.getData('x'),
            ease: 'Power3',
            delay: i
        });

        this.tweens.add({
            targets: [ this.icon5.shadow, this.icon5.monster ],
            x: this.icon5.shadow.getData('x'),
            ease: 'Power3',
            delay: i
        });

        i += 200;

        this.tweens.add({
            targets: [ this.icon3.shadow, this.icon3.monster ],
            x: this.icon3.shadow.getData('x'),
            ease: 'Power3',
            delay: i
        });

        this.tweens.add({
            targets: [ this.icon6.shadow, this.icon6.monster ],
            x: this.icon6.shadow.getData('x'),
            ease: 'Power3',
            delay: i
        });

        //  Text

        this.tweens.add({
            targets: [ this.text1, this.text2 ],
            alpha: 1,
            ease: 'Power3',
            delay: i
        });

        i += 500;

        this.tweens.addCounter({
            from: 0,
            to: 25,
            ease: 'Power1',
            onUpdate: tween =>
            {
                this.text2.setText(Phaser.Utils.String.Pad(tween.getValue().toFixed(), 2, '0', 1));
            },
            delay: i
        });

        i += 500;

        this.tweens.add({
            targets: [ this.instructions, this.arrow ],
            alpha: 1,
            ease: 'Power3',
            delay: i
        });

        this.time.delayedCall(i, this.startInputEvents, [], this);
    },

    startInputEvents: function ()
    {
        this.input.on('gameobjectover', this.onIconOver, this);
        this.input.on('gameobjectout', this.onIconOut, this);
        this.input.on('gameobjectdown', this.onIconDown, this);

        //  Cheat mode :)

        this.input.keyboard.on('keydown-M', function () {

            this.moves++;
            this.text2.setText(Phaser.Utils.String.Pad(this.moves, 2, '0', 1));

        }, this);

        this.input.keyboard.on('keydown-X', function () {

            this.moves--;
            this.text2.setText(Phaser.Utils.String.Pad(this.moves, 2, '0', 1));

        }, this);
    },

    stopInputEvents: function ()
    {
        this.input.off('gameobjectover', this.onIconOver);
        this.input.off('gameobjectout', this.onIconOut);
        this.input.off('gameobjectdown', this.onIconDown);
    },

    onIconOver: function (pointer, gameObject)
    {
        var icon = gameObject;

        var newColor = icon.getData('color');

        //  Valid color?
        if (newColor !== this.currentColor)
        {
            this.cursor.setFrame('cursor-over');
        }
        else
        {
            this.cursor.setFrame('cursor-invalid');
        }

        this.cursor.setPosition(icon.x + 48, icon.y + 48);

        if (this.cursorTween)
        {
            this.cursorTween.stop();
        }

        this.cursor.setAlpha(1);
        this.cursor.setVisible(true);

        //  Change arrow color
        this.arrow.setFrame('arrow-' + this.frames[newColor]);

        //  Jiggle the monster :)
        var monster = icon.getData('monster');

        this.children.bringToTop(monster);

        this.monsterTween = this.tweens.add({
            targets: monster,
            y: '-=24',
            yoyo: true,
            repeat: -1,
            duration: 300,
            ease: 'Power2'
        });
    },

    onIconOut: function (pointer, gameObject)
    {
        this.monsterTween.stop(0);

		gameObject.getData('monster').setY(gameObject.y);

        this.cursorTween = this.tweens.add({
            targets: this.cursor,
            alpha: 0,
            duration: 300,
            persists: true
        });

        this.arrow.setFrame('arrow-white');
    },

    onIconDown: function (pointer, gameObject)
    {
        if (!this.allowClick)
        {
            return;
        }

        var icon = gameObject;

        var newColor = icon.getData('color');

        //  Valid color?
        if (newColor === this.currentColor)
        {
            return;
        }

        var oldColor = this.grid[0][0].getData('color');

        // console.log('starting flood from', oldColor, this.frames[oldColor], 'to', newColor, this.frames[newColor]);

        if (oldColor !== newColor)
        {
            this.currentColor = newColor;

            this.matched = [];

            if (this.monsterTween)
            {
                this.monsterTween.seek(0);
                this.monsterTween.stop();
            }

            this.cursor.setVisible(false);
            this.instructions.setVisible(false);

            this.moves--;

            this.text2.setText(Phaser.Utils.String.Pad(this.moves, 2, '0', 1));

            this.floodFill(oldColor, newColor, 0, 0);

            if (this.matched.length > 0)
            {
                this.startFlow();
            }
        }
    },

    createEmitter: function (color)
    {
        this.emitters[color] = this.add.particles(0, 0, 'flood', {
            frame: color,
            lifespan: 1000,
            speed: { min: 300, max: 400 },
            alpha: { start: 1, end: 0 },
            scale: { start: 0.5, end: 0 },
            rotate: { start: 0, end: 360, ease: 'Power2' },
            blendMode: 'ADD',
            emitting: false
        });
    },

    startFlow: function ()
    {
        this.matched.sort(function (a, b) {

            var aDistance = Phaser.Math.Distance.Between(a.x, a.y, 166, 66);
            var bDistance = Phaser.Math.Distance.Between(b.x, b.y, 166, 66);

            return aDistance - bDistance;

        });

        //  Swap the sprites

        var t = 0;
        var inc = (this.matched.length > 98) ? 6 : 12;

        this.allowClick = false;

        for (var i = 0; i < this.matched.length; i++)
        {
            var block = this.matched[i];

            var blockColor = this.frames[block.getData('color')];
            var oldBlockColor = this.frames[block.getData('oldColor')];

            var emitter = this.emitters[oldBlockColor];

            this.time.delayedCall(t, function (block, blockColor) {

                block.setFrame(blockColor);

                emitter.explode(6, block.x, block.y);

            }, [ block, blockColor, emitter ]);

            t += inc;
        }

        this.time.delayedCall(t, function () {

            this.allowClick = true;

            if (this.checkWon())
            {
                this.gameWon();
            }
            else if (this.moves === 0)
            {
                this.gameLost();
            }

        }, [], this);
    },

    checkWon: function ()
    {
        var topLeft = this.grid[0][0].getData('color');

        for (var x = 0; x < 14; x++)
        {
            for (var y = 0; y < 14; y++)
            {
                if (this.grid[x][y].getData('color') !== topLeft)
                {
                    return false;
                }
            }
        }

        return true;
    },

    clearGrid: function ()
    {
        //  Hide everything :)

        this.tweens.add({
            targets: [
                this.icon1.monster, this.icon1.shadow,
                this.icon2.monster, this.icon2.shadow,
                this.icon3.monster, this.icon3.shadow,
                this.icon4.monster, this.icon4.shadow,
                this.icon5.monster, this.icon5.shadow,
                this.icon6.monster, this.icon6.shadow,
                this.arrow,
                this.cursor
            ],
            alpha: 0,
            duration: 500,
            delay: 500
        });

        var i = 500;

        for (var y = 13; y >= 0; y--)
        {
            for (var x = 0; x < 14; x++)
            {
                var block = this.grid[x][y];

                this.tweens.add({

                    targets: block,

                    scaleX: 0,
                    scaleY: 0,

                    ease: 'Power3',
                    duration: 800,
                    delay: i

                });

                i += 10;
            }
        }

        return i;
    },

    gameLost: function ()
    {
        this.stopInputEvents();

        this.text1.setText("Lost!");
        this.text2.setText(':(');

        var i = this.clearGrid();

        this.text3.setAlpha(0);
        this.text3.setVisible(true);

        this.tweens.add({
            targets: this.text3,
            alpha: 1,
            duration: 1000,
            delay: i
        });

        this.input.once('pointerdown', this.resetGame, this);
    },

    resetGame: function ()
    {
        this.text1.setText("Moves");
        this.text2.setText("00");
        this.text3.setVisible(false);

        //  Show everything :)

        this.arrow.setFrame('arrow-white');

        this.tweens.add({
            targets: [
                this.icon1.monster, this.icon1.shadow,
                this.icon2.monster, this.icon2.shadow,
                this.icon3.monster, this.icon3.shadow,
                this.icon4.monster, this.icon4.shadow,
                this.icon5.monster, this.icon5.shadow,
                this.icon6.monster, this.icon6.shadow,
                this.arrow,
                this.cursor
            ],
            alpha: 1,
            duration: 500,
            delay: 500
        });

        var i = 500;

        for (var y = 13; y >= 0; y--)
        {
            for (var x = 0; x < 14; x++)
            {
                var block = this.grid[x][y];

                //  Set a new color
                var color = Phaser.Math.Between(0, 5);

                block.setFrame(this.frames[color]);

                block.setData('oldColor', color);
                block.setData('color', color);

                this.tweens.add({

                    targets: block,

                    scaleX: 1,
                    scaleY: 1,

                    ease: 'Power3',
                    duration: 800,
                    delay: i

                });

                i += 10;
            }
        }

        //  Do a few floods just to make it a little easier starting off
        this.helpFlood();

        for (var i = 0; i < this.matched.length; i++)
        {
            var block = this.matched[i];

            block.setFrame(this.frames[block.getData('color')]);
        }

        this.currentColor = this.grid[0][0].getData('color');

        this.tweens.addCounter({
            from: 0,
            to: 25,
            ease: 'Power1',
            onUpdate: tween =>
            {
                this.text2.setText(Phaser.Utils.String.Pad(tween.getValue().toFixed(), 2, '0', 1));
            },
            delay: i
        });

        this.moves = 25;

        this.time.delayedCall(i, this.startInputEvents, [], this);
    },

    gameWon: function ()
    {
        this.stopInputEvents();

        this.text1.setText("Won!!");
        this.text2.setText(':)');

        var i = this.clearGrid();

        //  Put the winning monster in the middle

        var monster = this.add.image(400, 300, 'flood', 'icon-' + this.frames[this.currentColor]);

        monster.setScale(0);

        this.tweens.add({
            targets: monster,
            scaleX: 4,
            scaleY: 4,
            angle: 360 * 4,
            duration: 1000,
            delay: i
        });

        this.time.delayedCall(2000, this.boom, [], this);
    },

    boom: function ()
    {
        var color = Phaser.Math.RND.pick(this.frames);

        this.emitters[color].explode(8, Phaser.Math.Between(128, 672), Phaser.Math.Between(28, 572))

        color = Phaser.Math.RND.pick(this.frames);

        this.emitters[color].explode(8, Phaser.Math.Between(128, 672), Phaser.Math.Between(28, 572))

        this.time.delayedCall(100, this.boom, [], this);
    },

    floodFill: function (oldColor, newColor, x, y)
    {
        if (oldColor === newColor || this.grid[x][y].getData('color') !== oldColor)
        {
            return;
        }

        this.grid[x][y].setData('oldColor', oldColor);
        this.grid[x][y].setData('color', newColor);

        if (this.matched.indexOf(this.grid[x][y]) === -1)
        {
            this.matched.push(this.grid[x][y]);
        }

        if (x > 0)
        {
            this.floodFill(oldColor, newColor, x - 1, y);
        }

        if (x < 13)
        {
            this.floodFill(oldColor, newColor, x + 1, y);
        }

        if (y > 0)
        {
            this.floodFill(oldColor, newColor, x, y - 1);
        }

        if (y < 13)
        {
            this.floodFill(oldColor, newColor, x, y + 1);
        }
    }

});

var config = {
    type: Phaser.WEBGL,
    width: 800,
    height: 600,
    pixelArt: true,
    parent: 'phaser-example',
    scene: [ Flood ]
};

var game = new Phaser.Game(config);

Структура сцены и инициализация

Игра построена как одна сцена Phaser, реализованная через класс Phaser.Class. В методе initialize (конструкторе) задаётся ключ сцены и инициализируются основные свойства.

Основные свойства: - grid: двумерный массив 14x14 для хранения блоков (объектов Image). - matched: массив для отслеживания блоков, которые нужно перекрасить в текущем ходе. - currentColor: текущий выбранный цвет. - moves: количество оставшихся ходов (25). - frames: массив строковых ключей кадров (цветов) из атласа.

В preload загружаются bitmap-шрифт и атлас спрайтов.

this.load.bitmapFont('atari', 'assets/fonts/bitmap/atari-smooth.png', 'assets/fonts/bitmap/atari-smooth.xml');
this.load.atlas('flood', 'assets/games/flood/blobs.png', 'assets/games/flood/blobs.json');

Создание игрового поля и интерфейса

В методе create происходит основная настройка сцены. Сначала добавляется фон и сетка (gridBG), которая изначально позиционируется за пределами экрана.

Затем создаются шесть иконок-монстров по краям экрана, представляющих доступные цвета. Каждая иконка состоит из двух слоёв: тени (shadow) и самого монстра (monster). Тень является интерактивным объектом (setInteractive).

this.createIcon(this.icon1, 'grey', 16, 156);

Игровое поле (grid) заполняется 196 блоками (14x14). Каждому блоку присваивается случайный цвет (от 0 до 5). Координаты каждого блока и его цвет сохраняются в данных объекта через setData.

var block = this.add.image(sx, -600 + sy, 'flood', this.frames[color]);
block.setData('oldColor', color);
block.setData('color', color);
block.setData('x', sx);
block.setData('y', sy);
this.grid[x][y] = block;

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

После настройки всех элементов вызывается revealGrid(), которая запускает сложную последовательность анимаций появления поля, иконок и текста с помощью tweens.

Алгоритм Flood Fill и игровая логика

Сердце игры — рекурсивная функция floodFill. Она изменяет цвет всех смежных блоков, имеющих старый цвет (oldColor), на новый (newColor), начиная с заданной координаты (обычно [0,0]).

floodFill: function (oldColor, newColor, x, y)
{
    if (oldColor === newColor || this.grid[x][y].getData('color') !== oldColor)
    {
        return;
    }
    this.grid[x][y].setData('oldColor', oldColor);
    this.grid[x][y].setData('color', newColor);
    if (this.matched.indexOf(this.grid[x][y]) === -1)
    {
        this.matched.push(this.grid[x][y]);
    }
    // Рекурсивные вызовы к соседям (вверх, вниз, влево, вправо)
    // ...
}

Когда игрок кликает на иконку цвета, в onIconDown проверяется, отличается ли новый цвет от текущего. Если да, то уменьшается счётчик ходов, вызывается floodFill, а затем startFlow().

Функция startFlow() отвечает за визуальное обновление поля. Она сортирует массив matched по расстоянию от начального блока, чтобы анимация заливки распространялась волной. Затем для каждого блока с задержкой (time.delayedCall) меняется кадр спрайта и запускается эффект частиц (emitter.explode).

Проверка победы (checkWon) происходит после завершения анимации и сравнивает цвет всех блоков с цветом в левом верхнем углу.

Обработка ввода и визуальный фидбэк

Управление осуществляется через события мыши на иконках-тенях. При наведении (onIconOver) появляется курсор, меняется цвет стрелки-подсказки, а монстр начинает "дрыгаться" (анимация через tween).

this.monsterTween = this.tweens.add({
    targets: monster,
    y: '-=24',
    yoyo: true,
    repeat: -1,
    duration: 300,
    ease: 'Power2'
});

При выборе недопустимого (текущего) цвета курсор меняется на "недействительный". Это даёт игроку понятный фидбэк.

В примере также реализован "чит-режим" для отладки: нажатия клавиш M и X увеличивают или уменьшают счётчик ходов.

this.input.keyboard.on('keydown-M', function () {
    this.moves++;
    this.text2.setText(Phaser.Utils.String.Pad(this.moves, 2, '0', 1));
}, this);

Анимации, эффекты и состояния игры

Phaser Tween Manager активно используется для плавных переходов. Анимация появления (revealGrid) — это каскад tweens для каждого ряда блоков и иконок с увеличивающейся задержкой, что создаёт эффект последовательного выезда.

Система частиц (ParticleEmitter) добавляет взрывы конфетти при смене цвета блока. Для каждого цвета предварительно создаётся свой эмиттер в createEmitter.

this.emitters[color] = this.add.particles(0, 0, 'flood', {
    frame: color,
    lifespan: 1000,
    speed: { min: 300, max: 400 },
    alpha: { start: 1, end: 0 },
    // ... другие настройки
});

Игра имеет три основных состояния: процесс, победа (gameWon) и поражение (gameLost). При победе поле очищается (clearGrid), а в центре с анимацией масштабирования и вращения появляется иконка-монстр победного цвета, после чего начинается фейерверк из частиц (boom). При поражении показывается текст и предлагается рестарт по клику (resetGame).

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

Этот пример игры Flood Fill на Phaser — отличная демонстрация соединения фундаментального алгоритма с качественной игровой подачей. Вы можете экспериментировать, изменяя размер поля (не забыв поправить границы в floodFill), количество цветов или правила (например, давать больше ходов). Попробуйте заменить алгоритм заливки на не рекурсивную очередь, чтобы избежать потенциальных переполнений стека на очень больших полях. Также интересно будет добавить систему очков, которая учитывает скорость или размер залитой области за ход.