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

В играх с боковым скроллингом часто возникает задача создания бесконечного и разнообразного игрового пространства. Генерация ландшафта «на лету» экономит ресурсы и позволяет создавать уникальные уровни для каждого прохождения. В этой статье мы разберем пример из официальной документации Phaser, который демонстрирует, как с помощью классов `Curves.Path` и простой математики создать иллюзию бесконечного коридора, по которому летит самолет. Вы научитесь управлять кривыми, генерировать новые сегменты пути и проверять столкновения на основе положения спрайта относительно этого пути.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    constructor ()
    {
        super();
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('plane', 'assets/sprites/plane.png');
        this.load.image('sky', 'assets/skies/bigsky.png');
    }

    create ()
    {
        this.add.image(400, 300, 'sky').setScrollFactor(0);

        this.graphics = this.add.graphics();

        this.topPath = new Phaser.Curves.Path(0, Phaser.Math.Between(200, 100));
        this.bottomPath = new Phaser.Curves.Path(0, Phaser.Math.Between(400, 500));

        this.plane = this.add.image(100, 300, 'plane');

        this.input.on('pointermove', pointer => {

            this.plane.x = pointer.worldX;
            this.plane.y = pointer.worldY;

        });

        //  Create a random land which is 1000px long (800 for our screen size + 200 buffer)

        let ty = Phaser.Math.Between(200, 100);
        let by = Phaser.Math.Between(400, 500);

        for (let x = 200; x <= 1000; x += 200)
        {
            this.topPath.lineTo(x, ty);
            this.bottomPath.lineTo(x, by);

            ty = Phaser.Math.Between(200, 100);
            by = Phaser.Math.Between(400, 500);
        }

        this.offset = 0;
    }

    update ()
    {
        //  Scroll the camera at a fixed speed
        const speed = 4;

        this.cameras.main.scrollX += speed;
        this.plane.x += speed;
        this.offset += speed;

        //  Every 200 pixels we'll generate a new chunk of land
        if (this.offset >= 200)
        {
            //  We need to generate a new section of the land as we've run out
            let ty = Phaser.Math.Between(200, 100);
            let by = Phaser.Math.Between(400, 500);

            const topEnd = this.topPath.getEndPoint();
            const bottomEnd = this.bottomPath.getEndPoint();

            this.topPath.lineTo(topEnd.x + 200, ty);
            this.bottomPath.lineTo(bottomEnd.x + 200, by);

            this.offset = 0;
        }

        //  Get the position of the plane on the path
        const x = this.plane.x / (1000 + this.cameras.main.scrollX - this.offset);

        //  These vec2s contain the x/y of the plane on the path
        //  By checking the plane.y value against the top.y and bottom.y we know if it's hit the wall or not
        const top = this.topPath.getPoint(x);
        const bottom = this.bottomPath.getPoint(x);

        //  Draw it
        this.graphics.clear();

        //  This will give a debug draw style with just lines:

        // this.graphics.lineStyle(1, 0x000000, 1);
        // this.topPath.draw(this.graphics);
        // this.bottomPath.draw(this.graphics);

        //  And this will give a filled Graphics landscape:
        this.drawLand(this.topPath, 0);
        this.drawLand(this.bottomPath, 600);

        //  Draw the markers to show where on the path we are
        this.graphics.fillStyle(0x00ff00);
        this.graphics.fillRect(top.x - 2, top.y - 2, 5, 5);
        this.graphics.fillRect(bottom.x - 2, bottom.y - 2, 5, 5);
    }

    drawLand (path, offsetY)
    {
        const points = [ { x: 0, y: offsetY }];

        let lastX;

        for (let i = 0; i < path.curves.length; i++)
        {
            const curve = path.curves[i];

            points.push(curve.p0, curve.p1);

            lastX = curve.p1.x;
        }

        points.push({ x: lastX, y: offsetY });

        this.graphics.fillStyle(0x7b3a05);
        this.graphics.fillPoints(points, true, true);
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#efefef',
    parent: 'phaser-example',
    scene: Example
};

const game = new Phaser.Game(config);

Инициализация сцены и загрузка ресурсов

В методе preload мы задаем базовый URL для загрузки и загружаем два изображения: спрайт самолета (plane) и фон неба (sky). Фон будет статичным, в то время как ландшафт и самолет будут двигаться.

preload ()
{
    this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.image('plane', 'assets/sprites/plane.png');
    this.load.image('sky', 'assets/skies/bigsky.png');
}

В методе create мы добавляем фоновое изображение и устанавливаем его scrollFactor в 0. Это значит, что фон не будет двигаться при скроллинге камеры, создавая эффект далекого неба. Также создается объект Graphics для отрисовки ландшафта.

create ()
{
    this.add.image(400, 300, 'sky').setScrollFactor(0);
    this.graphics = this.add.graphics();
}

Создание и управление кривыми Path

Ключевой элемент примера — использование Phaser.Curves.Path. Это объект, представляющий собой последовательность соединенных кривых (в данном случае, прямых линий — lineTo). Мы создаем два пути: один для «потолка» ландшафта, другой для «пола». Их начальные координаты задаются случайным образом в пределах заданной высоты.

this.topPath = new Phaser.Curves.Path(0, Phaser.Math.Between(200, 100));
this.bottomPath = new Phaser.Curves.Path(0, Phaser.Math.Between(400, 500));

Затем в цикле генерируется начальный участок ландшафта длиной 1000 пикселей. Каждые 200 пикселей мы добавляем новую точку в оба пути, используя случайные значения Y. Это создает неровный, псевдослучайный коридор.

for (let x = 200; x <= 1000; x += 200)
{
    this.topPath.lineTo(x, ty);
    this.bottomPath.lineTo(x, by);
    ty = Phaser.Math.Between(200, 100);
    by = Phaser.Math.Between(400, 500);
}

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

this.input.on('pointermove', pointer => {
    this.plane.x = pointer.worldX;
    this.plane.y = pointer.worldY;
});

Бесконечная генерация и скроллинг в update

В методе update происходит движение камеры и самолета с постоянной скоростью. Переменная this.offset отслеживает, на сколько пикселей мы сдвинулись с момента последней генерации.

const speed = 4;
this.cameras.main.scrollX += speed;
this.plane.x += speed;
this.offset += speed;

Каждые 200 пикселей (if (this.offset >= 200)) генерируется новый сегмент пути. Мы получаем последнюю точку текущего пути с помощью getEndPoint() и добавляем к ней новую линию (lineTo) со случайной координатой Y. Таким образом, путь удлиняется, создавая иллюзию бесконечного коридора.

const topEnd = this.topPath.getEndPoint();
this.topPath.lineTo(topEnd.x + 200, ty);

Самая важная часть — определение положения самолета относительно границ коридора. Мы вычисляем нормализованную позицию самолета на пути (значение от 0 до 1) на основе его X-координаты и текущего смещения камеры.

const x = this.plane.x / (1000 + this.cameras.main.scrollX - this.offset);
const top = this.topPath.getPoint(x);
const bottom = this.bottomPath.getPoint(x);

Метод getPoint(x) возвращает точку на пути для заданной нормализованной позиции. Координаты Y этих точек (top.y и bottom.y) представляют собой верхнюю и нижнюю границы коридора в данной позиции по X. В данном примере столкновение не обрабатывается, но его легко реализовать, сравнив this.plane.y с top.y и bottom.y.

Отрисовка ландшафта и отладка

Каждый кадр мы очищаем графику (this.graphics.clear()) и заново рисуем ландшафт. Вместо простой отрисовки линий пути, используется метод drawLand, который создает замкнутую залитую фигуру.

this.drawLand(this.topPath, 0);
this.drawLand(this.bottomPath, 600);

Метод drawLand принимает путь и смещение по Y. Он создает массив точек, включая начальную точку, все контрольные точки кривых пути и замыкает фигуру, опускаясь до линии смещения. Затем используется fillPoints для заливки фигуры цветом.

drawLand (path, offsetY)
{
    const points = [ { x: 0, y: offsetY }];
    // ... сбор точек из path.curves ...
    points.push({ x: lastX, y: offsetY });
    this.graphics.fillStyle(0x7b3a05);
    this.graphics.fillPoints(points, true, true);
}

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

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

Пример наглядно демонстрирует мощь Phaser.Curves.Path для создания динамических и бесконечных игровых пространств. Используя случайную генерацию и простую логику добавления сегментов, можно создавать разнообразные ландшафты для раннеров, леталок или автосимуляторов. Для экспериментов попробуйте: изменить алгоритм генерации Y-координат (например, сделать холмы плавнее), реализовать настоящую проверку столкновений самолета с границами, добавить сбор монет или препятствий, также привязанных к пути, или использовать более сложные кривые (например, spline) для создания извилистых туннелей.