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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    acc = 0;
    dist = 0;
    rows = [];
    ship;

    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('asteroid1', 'assets/games/asteroids/asteroid1.png');
        this.load.image('asteroid2', 'assets/games/asteroids/asteroid2.png');
        this.load.image('asteroid3', 'assets/games/asteroids/asteroid3.png');
        this.load.image('ship', 'assets/games/asteroids/ship.png');
    }

    create()
    {
        this.ship = this.add.image(640, 500, 'ship').setRotation(-Math.PI / 2).setDepth(100);

        for (let y = 0; y < this.scale.height + 128; y += 8)
        {
            this.update(0, 16.7);
        }
    }

    update (time, delta)
    {
        const { width, height } = this.scale;
        const d = delta / 2;
        this.acc += d;
        this.dist += d;

        this.ship.setPosition(
            640 + 64 * Math.cos(this.dist / 500),
            500 + 64 * Math.sin(this.dist / 487)
        );

        // Advance landscape.
        const color = new Phaser.Display.Color();
        for (const row of this.rows)
        {
            if (row[0].y > height + 128)
            {
                for (const asteroid of row)
                {
                    asteroid.destroy();
                }
                this.rows = this.rows.filter(r => r !== row);
            }
            for (const asteroid of row)
            {
                asteroid.y += d;
                color.gray((asteroid.landscapeTint + 0.75 - 0.35 * asteroid.depth * 8 / height) * 255);
                asteroid.setTint(color.color);
            }
        }

        const configA = {
            noiseCells: [ 4, 3, 4, 4 ],
            noiseWrap: [ 4, 9, 4, 4 ],
            noiseIterations: 3
        };

        if (this.acc > 8)
        {
            this.acc -= 8;

            for (const row of this.rows)
            {
                for (const asteroid of row)
                {
                    asteroid.depth++;
                }
            }

            const row = [];
            this.rows.push(row);
            for (let x = 0; x <= width; x += 32)
            {
                const rotation = Phaser.Math.Hash(x + this.dist - 1) * Math.PI * 2;
                const dy = Phaser.Math.HashCell([x / width, this.dist / height, 0, 0], configA);
                const asteroidNumber = Math.ceil(Phaser.Math.Hash(x + this.dist - 2) * 3);
                color.gray(255 * (0.5 + dy * 0.5));

                const asteroid = this.add.image(
                    x,
                    dy * -128 - 64,
                    `asteroid${asteroidNumber}`
                )
                .setScale(2)
                .setRotation(rotation)
                .setTintMode(Phaser.TintModes.HARD_LIGHT);
                asteroid.landscapeTint = dy * 0.25;
                row.push(asteroid);
            }
        }
    }
}

const config = {
    type: Phaser.WEBGL,
    parent: 'phaser-example',
    width: 1280,
    height: 720,
    scene: Example
};

const game = new Phaser.Game(config);

Принцип работы: Лента конвейера

В основе лежит принцип "ленты конвейера". Мы создаем ряды (rows) астероидов, которые непрерывно движутся вниз по экрану. Когда ряд полностью скрывается за нижней границей, он уничтожается, а вверху создается новый. Это создает иллюзию бесконечного движения сквозь поле астероидов.

Ключевая переменная this.dist накапливает время и является основным "семенем" (seed) для всей процедурной генерации. От нее зависит положение корабля и параметры каждого нового астероида.

Генерация астероидов: Хеши и шум

Для того чтобы каждый запуск игры и каждый ряд астероидов выглядел детерминировано (предсказуемо), но при этом естественно и разнообразно, используются две функции из Phaser.Math.

Phaser.Math.Hash() генерирует псевдослучайное число от 0 до 1 на основе входного значения (например, координаты `xи времениthis.dist`). Это число используется для выбора текстуры астероида и его начального угла поворота.

Более сложный паттерн задается с помощью Phaser.Math.HashCell(). Эта функция генерирует ячеистый шум, который идеально подходит для создания кластеров и плавных переходов. Конфигурация configA управляет размером и поведением этих ячеек.

const rotation = Phaser.Math.Hash(x + this.dist - 1) * Math.PI * 2;
const dy = Phaser.Math.HashCell([x / width, this.dist / height, 0, 0], configA);
const asteroidNumber = Math.ceil(Phaser.Math.Hash(x + this.dist - 2) * 3);

Управление глубиной и цветом

Для создания ощущения объема и движения вглубь экрана используется свойство depth спрайтов и динамический расчет цвета (тинта). Каждый кадр все астероиды в активных рядах смещаются вниз (asteroid.y += d). Каждые 8 условных единиц времени (this.acc > 8) всем существующим астероидам увеличивается depth, что визуально "отдаляет" их, а вверху создается новый ряд.

Цвет тинта вычисляется на основе изначального оттенка ландшафта (asteroid.landscapeTint), который был задан при создании, и текущей глубины объекта. Чем дальше астероид (больше depth), тем он темнее. Это имитирует рассеяние света в пространстве.

color.gray((asteroid.landscapeTint + 0.75 - 0.35 * asteroid.depth * 8 / height) * 255);
asteroid.setTint(color.color);

Режим наложения цвета Phaser.TintModes.HARD_LIGHT применяется при создании астероида, чтобы тинт взаимодействовал с текстурой, создавая более сложные визуальные эффекты.

Анимация и управление памятью

Движение корабля по простой круговой орбите добавляет динамики сцене и наглядно демонстрирует независимость генерации ландшафта от других игровых процессов.

Важный аспект — управление памятью. Объекты, вышедшие за пределы видимой области, необходимо уничтожать, чтобы избежать утечек памяти. В методе update() происходит проверка, ушел ли целый ряд за нижнюю границу экрана плюс запас в 128 пикселей. Если да, то для каждого астероида в ряду вызывается asteroid.destroy(), а сам ряд удаляется из массива this.rows.

if (row[0].y > height + 128)
{
    for (const asteroid of row)
    {
        asteroid.destroy();
    }
    this.rows = this.rows.filter(r => r !== row);
}

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

Этот пример демонстрирует мощь процедурной генерации на основе хешей для создания эффективных и визуально интересных бесконечных миров. Хеш-функции обеспечивают детерминизм и высокую производительность. Для экспериментов попробуйте изменить конфигурацию configA, чтобы получить другой тип ячеистого шума, или замените движение корабля на управляемое игроком. Можно также добавить несколько слоев ландшафта с разной скоростью движения для эффекта параллакса, используя тот же принцип с разными множителями для depth и скорости.