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

Проверка пересечений в играх часто сводится к прямоугольникам, но как быть с кривыми? В этой статье разберем пример, где спрайт сталкивается не с примитивом, а с плавной сплайновой кривой. Вы узнаете, как использовать геометрические методы Phaser для оптимизации проверок и создавать сложные, нестандартные коллайдеры для своих игр. Этот подход полезен для реализации лазерных лучей, извилистых троп, магических барьеров или любых игровых объектов, форма которых описывается кривой. Мы совместим грубую проверку по ограничивающему прямоугольнику с точным анализом отрезков, из которых состоит кривая.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    hitLine = new Phaser.Geom.Line();
    text;
    graphics;
    right = -1;
    left = -1;
    intersects = false;
    boundsColor = 0x00ff00;
    spriteBounds;
    pathBounds;
    points;
    curve;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.image('test', 'assets/sprites/32x32.png');

        // this.load.image('test', 'assets/sprites/arrow.png');
    }

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

        const image = this.add.sprite(200, 100, 'test').setAlpha(0.5).setInteractive();

        this.text = this.add.text(10, 10, '', { font: '16px Courier', fill: '#00ff00' });

        this.input.setDraggable(image);

        this.curve = new Phaser.Curves.Spline([
            50, 300,
            164, 246,
            274, 442,
            412, 157,
            522, 541,
            664, 264
        ]);

        this.points = this.curve.getDistancePoints(32);

        this.pathBounds = new Phaser.Geom.Rectangle();
        this.spriteBounds = new Phaser.Geom.Rectangle();

        this.curve.getBounds(this.pathBounds);
        image.getBounds(this.spriteBounds);

        this.input.on(Phaser.Input.Events.DRAG, event =>
        {

            event.gameObject.x = event.dragX;
            event.gameObject.y = event.dragY;

            image.getBounds(this.spriteBounds);

            this.intersects = false;

            if (Phaser.Geom.Intersects.RectangleToRectangle(this.pathBounds, this.spriteBounds))
            {
                this.boundsColor = 0xff0000;

                //  Within the curve bounds, so let's check the points

                this.left = -1;
                this.right = -1;

                for (let i = 0; i < this.points.length; i++)
                {
                    const p = this.points[i];

                    if (p.x > this.spriteBounds.x)
                    {
                        this.left = i - 1;

                        if (this.left < 0)
                        {
                            this.left = 0;
                        }

                        break;
                    }
                }

                for (let i = this.points.length - 1; i >= this.left; i--)
                {
                    const p = this.points[i];

                    if (p.x < this.spriteBounds.right)
                    {
                        this.right = i + 1;

                        if (this.right === this.points.length)
                        {
                            this.right--;
                        }

                        break;
                    }
                }

                if (this.left === -1 && this.right !== -1)
                {
                    this.left = 0;
                }
                else if (this.left !== -1 && this.right === -1)
                {
                    this.right = this.points.length - 1;
                }

                //  Rect vs. Line intersection between left and right
                const temp = new Phaser.Geom.Line();

                for (let i = this.left; i < this.right; i++)
                {
                    const p1 = this.points[i];
                    const p2 = this.points[i + 1];

                    if (!this.intersects && p1 && p2)
                    {
                        temp.setTo(p1.x, p1.y, p2.x, p2.y);

                        if (Phaser.Geom.Intersects.LineToRectangle(temp, this.spriteBounds))
                        {
                            this.intersects = true;

                            Phaser.Geom.Line.CopyFrom(temp, this.hitLine);

                            break;
                        }
                    }
                }
            }
            else
            {
                this.boundsColor = 0x00ff00;
                this.left = -1;
                this.right = -1;
            }

        });
    }

    update ()
    {
        this.text.setText([
            `left: ${this.left}`,
            `right: ${this.right}`
        ]);

        this.graphics.clear();

        //  Draw the bounds
        this.graphics.lineStyle(1, this.boundsColor, 1).strokeRectShape(this.pathBounds);

        if (this.left !== -1)
        {
            this.graphics.lineBetween(this.points[this.left].x, 0, this.points[this.left].x, 600);
            this.graphics.lineBetween(this.points[this.right].x, 0, this.points[this.right].x, 600);
        }

        this.graphics.lineStyle(1, this.boundsColor, 1).strokeRectShape(this.spriteBounds);

        this.graphics.lineStyle(1, 0xffffff, 0.5);

        this.curve.draw(this.graphics, 64);

        for (let i = 0; i < this.points.length; i++)
        {
            const p = this.points[i];

            if (i >= this.left && i <= this.right)
            {
                this.graphics.fillStyle(0xff00ff, 1);
                this.graphics.fillCircle(p.x, p.y, 3);
            }
            else
            {
                this.graphics.fillStyle(0x00ff00, 1);
                this.graphics.fillCircle(p.x, p.y, 2);
            }
        }

        if (this.intersects)
        {
            this.graphics.lineStyle(2, 0xffff00, 1);
            this.graphics.strokeLineShape(this.hitLine);
        }

    }
}

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

const game = new Phaser.Game(config);

Подготовка сцены и объектов

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

Также создаются два объекта Phaser.Geom.Rectangle: один для границ всей кривой (pathBounds), другой для границ спрайта (spriteBounds). Настраивается перетаскивание спрайта с помощью setDraggable и обработчик события DRAG.

this.curve = new Phaser.Curves.Spline([50, 300, 164, 246, 274, 442, 412, 157, 522, 541, 664, 264]);
this.points = this.curve.getDistancePoints(32);

this.pathBounds = new Phaser.Geom.Rectangle();
this.spriteBounds = new Phaser.Geom.Rectangle();

this.curve.getBounds(this.pathBounds);
image.getBounds(this.spriteBounds);

this.input.on(Phaser.Input.Events.DRAG, event => { /* Обработчик перетаскивания */ });

Оптимизация: отсечение по границам и поиск диапазона точек

Внутри обработчика события DRAG при каждом перемещении спрайта первым делом выполняется быстрая проверка пересечения двух прямоугольников: границ кривой и границ спрайта. Для этого используется Phaser.Geom.Intersects.RectangleToRectangle. Если пересечения нет — дальнейшие вычисления не требуются.

Если же прямоугольники пересекаются, начинается более точный анализ. Мы уже имеем массив точек (this.points), равномерно распределенных по кривой. Вместо того чтобы проверять пересечение спрайта с каждой парой точек, мы сначала находим диапазон точек, которые теоретически могут пересекаться со спрайтом. Для этого определяем индекс самой левой точки кривой, которая находится правее левой границы спрайта (this.left), и индекс самой правой точки, которая находится левее правой границы спрайта (this.right).

if (Phaser.Geom.Intersects.RectangleToRectangle(this.pathBounds, this.spriteBounds))
{
    this.boundsColor = 0xff0000;
    // ... Поиск индексов this.left и this.right в массиве this.points
}
else
{
    this.boundsColor = 0x00ff00;
    this.left = -1;
    this.right = -1;
}

Точная проверка пересечения отрезков с прямоугольником

После того как диапазон потенциально пересекающихся точек найден (this.left и this.right), мы проверяем пересечение спрайта не с кривой в целом, а с отдельными отрезками, которые ее составляют. В цикле от this.left до this.right берутся пары соседних точек и из них создается отрезок (Phaser.Geom.Line). Для каждого такого отрезка выполняется проверка пересечения с прямоугольником спрайта с помощью Phaser.Geom.Intersects.LineToRectangle.

Если пересечение найдено, флаг this.intersects устанавливается в true, и отрезок сохраняется в свойство this.hitLine для последующей отрисовки. Цикл прерывается, так как факт пересечения уже установлен.

const temp = new Phaser.Geom.Line();
for (let i = this.left; i < this.right; i++)
{
    const p1 = this.points[i];
    const p2 = this.points[i + 1];
    if (!this.intersects && p1 && p2)
    {
        temp.setTo(p1.x, p1.y, p2.x, p2.y);
        if (Phaser.Geom.Intersects.LineToRectangle(temp, this.spriteBounds))
        {
            this.intersects = true;
            Phaser.Geom.Line.CopyFrom(temp, this.hitLine);
            break;
        }
    }
}

Визуализация в методе Update

Метод update отвечает за отрисовку всей геометрической информации. Он очищает графику, рисует ограничивающие прямоугольники разным цветом в зависимости от наличия грубого пересечения. Если найден диапазон точек (this.left !== -1), то рисуются вертикальные линии, ограничивающие этот диапазон.

Затем отрисовывается сама кривая и все точки на ней. Точки внутри найденного диапазона (i >= this.left && i <= this.right) выделяются цветом и размером. Если было зафиксировано точное пересечение (this.intersects), то отрезок, с которым оно произошло, подсвечивается желтым цветом. Это дает полную визуальную отладку процесса.

this.graphics.clear();
this.graphics.lineStyle(1, this.boundsColor, 1).strokeRectShape(this.pathBounds);

if (this.left !== -1)
{
    this.graphics.lineBetween(this.points[this.left].x, 0, this.points[this.left].x, 600);
    this.graphics.lineBetween(this.points[this.right].x, 0, this.points[this.right].x, 600);
}

// ... Отрисовка кривой, точек и отрезка пересечения

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

Мы разобрали двухэтапный алгоритм проверки столкновения с кривой: быстрая отсечка по прямоугольникам и точная проверка по ограниченному набору отрезков. Этот метод балансирует между производительностью и точностью. Для экспериментов попробуйте: 1. Изменить шаг в getDistancePoints — как это повлияет на точность и производительность? 2. Заменить Phaser.Curves.Spline на Phaser.Curves.Ellipse или Phaser.Curves.Path. 3. Использовать эту технику для создания «невидимой стены» сложной формы или области поражения от изогнутого оружия. 4. Динамически изменять контрольные точки кривой в реальном времени и проверять пересечения.