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

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

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

Живой запуск

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

Исходный код


// Smoothed horizontal controls helper. This gives us a value between -1 and 1 depending on how long
// the player has been pressing left or right, respectively.
class SmoothedHorionztalControl {
    constructor(speed) {
        this.msSpeed = speed;
        this.value = 0;
    }

    moveLeft(delta) {
        if (this.value > 0) { this.reset(); }
        this.value -= this.msSpeed * delta;
        if (this.value < -1) { this.value = -1; }
    }

    moveRight(delta) {
        if (this.value < 0) { this.reset(); }
        this.value += this.msSpeed * delta;
        if (this.value > 1) { this.value = 1; }
    }

    reset() {
        this.value = 0;
    }
}

class Example extends Phaser.Scene
{
    playerController;
    cursors;
    text;
    cam;
    smoothedControls;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.tilemapTiledJSON('map', 'assets/tilemaps/maps/matter-platformer.json');
        this.load.image('kenney_redux_64x64', 'assets/tilemaps/tiles/kenney_redux_64x64.png');
        this.load.spritesheet('player', 'assets/sprites/dude-cropped.png', { frameWidth: 32, frameHeight: 42 });
        this.load.image('box', 'assets/sprites/box-item-boxed.png');
    }

    create ()
    {
        const map = this.make.tilemap({ key: 'map' });
        const tileset = map.addTilesetImage('kenney_redux_64x64');
        const layer = map.createLayer(0, tileset, 0, 0);

        // Set up the layer to have matter bodies. Any colliding tiles will be given a Matter body.
        map.setCollisionByProperty({ collides: true });
        this.matter.world.convertTilemapLayer(layer);

        this.matter.world.setBounds(map.widthInPixels, map.heightInPixels);
        this.matter.world.createDebugGraphic();
        this.matter.world.drawDebug = false;

        this.cursors = this.input.keyboard.createCursorKeys();
        this.smoothedControls = new SmoothedHorionztalControl(0.0005);

        // The player is a collection of bodies and sensors
        this.playerController = {
            matterSprite: this.matter.add.sprite(0, 0, 'player', 4),
            blocked: {
                left: false,
                right: false,
                bottom: false
            },
            numTouching: {
                left: 0,
                right: 0,
                bottom: 0
            },
            sensors: {
                bottom: null,
                left: null,
                right: null
            },
            time: {
                leftDown: 0,
                rightDown: 0
            },
            lastJumpedAt: 0,
            speed: {
                run: 7,
                jump: 10
            }
        };

        const M = Phaser.Physics.Matter.Matter;
        const w = this.playerController.matterSprite.width;
        const h = this.playerController.matterSprite.height;

        // The player's body is going to be a compound body:
        //  - playerBody is the solid body that will physically interact with the world. It has a
        //    chamfer (rounded edges) to avoid the problem of ghost vertices: http://www.iforce2d.net/b2dtut/ghost-vertices
        //  - Left/right/bottom sensors that will not interact physically but will allow us to check if
        //    the player is standing on solid ground or pushed up against a solid object.

        // Move the sensor to player center
        const sx = w / 2;
        const sy = h / 2;

        // The player's body is going to be a compound body.
        const playerBody = M.Bodies.rectangle(sx, sy, w * 0.75, h, { chamfer: { radius: 10 } });
        this.playerController.sensors.bottom = M.Bodies.rectangle(sx, h, sx, 5, { isSensor: true });
        this.playerController.sensors.left = M.Bodies.rectangle(sx - w * 0.45, sy, 5, h * 0.25, { isSensor: true });
        this.playerController.sensors.right = M.Bodies.rectangle(sx + w * 0.45, sy, 5, h * 0.25, { isSensor: true });
        const compoundBody = M.Body.create({
            parts: [
                playerBody, this.playerController.sensors.bottom, this.playerController.sensors.left,
                this.playerController.sensors.right
            ],
            friction: 0.01,
            restitution: 0.05 // Prevent body from sticking against a wall
        });

        this.playerController.matterSprite
            .setExistingBody(compoundBody)
            .setFixedRotation() // Sets max inertia to prevent rotation
            .setPosition(630, 1000);

        this.matter.add.image(630, 750, 'box');
        this.matter.add.image(630, 650, 'box');
        this.matter.add.image(630, 550, 'box');

        this.cam = this.cameras.main;
        this.cam.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
        this.smoothMoveCameraTowards(this.playerController.matterSprite);

        this.anims.create({
            key: 'left',
            frames: this.anims.generateFrameNumbers('player', { start: 0, end: 3 }),
            frameRate: 10,
            repeat: -1
        });
        this.anims.create({
            key: 'right',
            frames: this.anims.generateFrameNumbers('player', { start: 5, end: 8 }),
            frameRate: 10,
            repeat: -1
        });
        this.anims.create({
            key: 'idle',
            frames: this.anims.generateFrameNumbers('player', { start: 4, end: 4 }),
            frameRate: 10,
            repeat: -1
        });

        // Use matter events to detect whether the player is touching a surface to the left, right or
        // bottom.

        // Before matter's update, reset the player's count of what surfaces it is touching.
        this.matter.world.on('beforeupdate', function (event) {
            this.playerController.numTouching.left = 0;
            this.playerController.numTouching.right = 0;
            this.playerController.numTouching.bottom = 0;
        }, this);

        // Loop over the active colliding pairs and count the surfaces the player is touching.
        this.matter.world.on('collisionactive', function (event)
        {
            const playerBody = this.playerController.body;
            const left = this.playerController.sensors.left;
            const right = this.playerController.sensors.right;
            const bottom = this.playerController.sensors.bottom;

            for (let i = 0; i < event.pairs.length; i++)
            {
                const bodyA = event.pairs[i].bodyA;
                const bodyB = event.pairs[i].bodyB;

                if (bodyA === playerBody || bodyB === playerBody)
                {
                    continue;
                }
                else if (bodyA === bottom || bodyB === bottom)
                {
                    // Standing on any surface counts (e.g. jumping off of a non-static crate).
                    this.playerController.numTouching.bottom += 1;
                }
                else if ((bodyA === left && bodyB.isStatic) || (bodyB === left && bodyA.isStatic))
                {
                    // Only static objects count since we don't want to be blocked by an object that we
                    // can push around.
                    this.playerController.numTouching.left += 1;
                }
                else if ((bodyA === right && bodyB.isStatic) || (bodyB === right && bodyA.isStatic))
                {
                    this.playerController.numTouching.right += 1;
                }
            }
        }, this);

        // Update over, so now we can determine if any direction is blocked
        this.matter.world.on('afterupdate', function (event) {
            this.playerController.blocked.right = this.playerController.numTouching.right > 0 ? true : false;
            this.playerController.blocked.left = this.playerController.numTouching.left > 0 ? true : false;
            this.playerController.blocked.bottom = this.playerController.numTouching.bottom > 0 ? true : false;
        }, this);

        this.input.on('pointerdown', function () {
            this.matter.world.drawDebug = !this.matter.world.drawDebug;
            this.matter.world.debugGraphic.visible = this.matter.world.drawDebug;
        }, this);

        this.text = this.add.text(16, 16, '', {
            fontSize: '20px',
            padding: { x: 20, y: 10 },
            backgroundColor: '#ffffff',
            fill: '#000000'
        });
        this.text.setScrollFactor(0);
        this.updateText();
    }

    update (time, delta)
    {
        const matterSprite = this.playerController.matterSprite;

        // Horizontal movement
        let oldVelocityX;
        let targetVelocityX;
        let newVelocityX;

        if (this.cursors.left.isDown && !this.playerController.blocked.left)
        {
            this.smoothedControls.moveLeft(delta);
            matterSprite.anims.play('left', true);

            // Lerp the velocity towards the max run using the smoothed controls. This simulates a
            // player controlled acceleration.
            oldVelocityX = matterSprite.body.velocity.x;
            targetVelocityX = -this.playerController.speed.run;
            newVelocityX = Phaser.Math.Linear(oldVelocityX, targetVelocityX, -this.smoothedControls.value);

            matterSprite.setVelocityX(newVelocityX);
        }
        else if (this.cursors.right.isDown && !this.playerController.blocked.right)
        {
            this.smoothedControls.moveRight(delta);
            matterSprite.anims.play('right', true);

            // Lerp the velocity towards the max run using the smoothed controls. This simulates a
            // player controlled acceleration.
            oldVelocityX = matterSprite.body.velocity.x;
            targetVelocityX = this.playerController.speed.run;
            newVelocityX = Phaser.Math.Linear(oldVelocityX, targetVelocityX, this.smoothedControls.value);

            matterSprite.setVelocityX(newVelocityX);
        }
        else
        {
            this.smoothedControls.reset();
            matterSprite.anims.play('idle', true);
        }

        // Jumping & wall jumping

        // Add a slight delay between jumps since the sensors will still collide for a few frames after
        // a jump is initiated
        const canJump = (time - this.playerController.lastJumpedAt) > 250;
        if (this.cursors.up.isDown & canJump)
        {
            if (this.playerController.blocked.bottom)
            {
                matterSprite.setVelocityY(-this.playerController.speed.jump);
                this.playerController.lastJumpedAt = time;
            }
            else if (this.playerController.blocked.left)
            {
                // Jump up and away from the wall
                matterSprite.setVelocityY(-this.playerController.speed.jump);
                matterSprite.setVelocityX(this.playerController.speed.run);
                this.playerController.lastJumpedAt = time;
            }
            else if (this.playerController.blocked.right)
            {
                // Jump up and away from the wall
                matterSprite.setVelocityY(-this.playerController.speed.jump);
                matterSprite.setVelocityX(-this.playerController.speed.run);
                this.playerController.lastJumpedAt = time;
            }
        }

        this.smoothMoveCameraTowards(matterSprite, 0.9);
        this.updateText();
    }

    updateText ()
    {
        this.text.setText([
            'Arrow keys to move. Press "Up" to jump.',
            'You can wall jump!',
            'Click to toggle rendering Matter debug.'
            // 'Debug:',
            // '\tBottom blocked: ' + this.playerController.blocked.bottom,
            // '\tLeft blocked: ' + this.playerController.blocked.left,
            // '\tRight blocked: ' + this.playerController.blocked.right
        ]);
    }

    smoothMoveCameraTowards (target, smoothFactor)
    {
        if (smoothFactor === undefined) { smoothFactor = 0; }
        this.cam.scrollX = smoothFactor * this.cam.scrollX + (1 - smoothFactor) * (target.x - this.cam.width * 0.5);
        this.cam.scrollY = smoothFactor * this.cam.scrollY + (1 - smoothFactor) * (target.y - this.cam.height * 0.5);
    }
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#000000',
    parent: 'phaser-example',
    physics: {
        default: 'matter',
        matter: {
            gravity: { y: 1 },
            enableSleep: false,
            debug: true
        }
    },
    scene: Example
};

const game = new Phaser.Game(config);

Настройка мира Matter и загрузка карты

В первую очередь инициализируем физический движок Matter и загружаем tilemap-уровень. Конфигурация указывается при создании игры.

const config = {
    type: Phaser.AUTO,
    physics: {
        default: 'matter',
        matter: {
            gravity: { y: 1 },
            enableSleep: false,
            debug: true
        }
    },
    scene: Example
};

В методе create() загружается карта из JSON-файла Tiled. Важный шаг — преобразование тайлов со свойством collides: true в физические тела Matter. Это позволяет уровню быть твердым.

const map = this.make.tilemap({ key: 'map' });
map.setCollisionByProperty({ collides: true });
this.matter.world.convertTilemapLayer(layer);

Создание составного тела игрока с сенсорами

Персонаж — это не просто спрайт, а составное тело (Compound Body), собранное из нескольких частей. Основное тело (playerBody) отвечает за физическое взаимодействие. Три сенсора (bottom, left, right) не сталкиваются физически, но детектируют соприкосновение с поверхностями.

const M = Phaser.Physics.Matter.Matter;
const w = this.playerController.matterSprite.width;
const h = this.playerController.matterSprite.height;
const playerBody = M.Bodies.rectangle(sx, sy, w * 0.75, h, { chamfer: { radius: 10 } });
this.playerController.sensors.bottom = M.Bodies.rectangle(sx, h, sx, 5, { isSensor: true });
this.playerController.sensors.left = M.Bodies.rectangle(sx - w * 0.45, sy, 5, h * 0.25, { isSensor: true });
this.playerController.sensors.right = M.Bodies.rectangle(sx + w * 0.45, sy, 5, h * 0.25, { isSensor: true });
const compoundBody = M.Body.create({
    parts: [playerBody, this.playerController.sensors.bottom, this.playerController.sensors.left, this.playerController.sensors.right],
    friction: 0.01,
    restitution: 0.05
});
this.playerController.matterSprite.setExistingBody(compoundBody).setFixedRotation();

Ключевые параметры: chamfer скругляет углы для плавного скольжения, isSensor: true делает тело детектором, setFixedRotation() предотвращает вращение.

Детектирование поверхностей через события Matter

Для определения, стоит ли игрок на земле или упирается в стену, используются события движка Matter: beforeupdate, collisionactive и afterupdate. В beforeupdate счетчики касаний обнуляются.

this.matter.world.on('beforeupdate', function (event) {
    this.playerController.numTouching.left = 0;
    this.playerController.numTouching.right = 0;
    this.playerController.numTouching.bottom = 0;
}, this);

Во время активной коллизии (collisionactive) проверяется, с каким сенсором произошло столкновение, и увеличивается соответствующий счетчик. Для боковых сенсоров учитываются только статические тела, чтобы игрок мог толкать ящики.

this.matter.world.on('collisionactive', function (event) {
    for (let i = 0; i < event.pairs.length; i++) {
        const bodyA = event.pairs[i].bodyA;
        const bodyB = event.pairs[i].bodyB;
        if (bodyA === bottom || bodyB === bottom) {
            this.playerController.numTouching.bottom += 1;
        } else if ((bodyA === left && bodyB.isStatic) || (bodyB === left && bodyA.isStatic)) {
            this.playerController.numTouching.left += 1;
        }
    }
}, this);

После обновления физики (afterupdate) флаги blocked устанавливаются на основе счетчиков.

Плавное управление и реализация прыжков

Класс SmoothedHorionztalControl обеспечивает постепенное нарастание и спад значения ввода от -1 до 1. Это создает эффект плавного разгона и торможения.

class SmoothedHorionztalControl {
    moveLeft(delta) {
        if (this.value > 0) { this.reset(); }
        this.value -= this.msSpeed * delta;
        if (this.value < -1) { this.value = -1; }
    }
}

В главном цикле update() это значение используется для линейной интерполяции (Phaser.Math.Linear) скорости тела, что имитирует управляемое ускорение.

oldVelocityX = matterSprite.body.velocity.x;
targetVelocityX = -this.playerController.speed.run;
newVelocityX = Phaser.Math.Linear(oldVelocityX, targetVelocityX, -this.smoothedControls.value);
matterSprite.setVelocityX(newVelocityX);

Прыжок проверяет флаги blocked. Если игрок стоит на земле (blocked.bottom), он прыгает вверх. Если прижат к стене (blocked.left или blocked.right), он получает импульс в противоположную сторону — это и есть прыжок от стены (wall jump). Задержка в 250 мс между прыжками предотвращает спам.

if (this.playerController.blocked.bottom) {
    matterSprite.setVelocityY(-this.playerController.speed.jump);
} else if (this.playerController.blocked.left) {
    matterSprite.setVelocityY(-this.playerController.speed.jump);
    matterSprite.setVelocityX(this.playerController.speed.run);
}

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

Используя составные тела Matter.js и систему сенсоров, можно создать продвинутого физического персонажа для платформера. Ключевые преимущества: реалистичное взаимодействие со статическим и динамическим окружением, плавное управление с ускорением и возможность прыжков от стен. Для экспериментов попробуйте: изменить форму основного тела на круг, добавить двойной прыжок, реализовать скольжение по стенам или создать движущиеся платформы, прикрепляя к ним сенсоры игрока.