О чем этот пример
Создание игрового мира, который реагирует на действия игрока, — ключевой элемент геймдизайна. В этом примере мы разберем, как в платформере на Phaser с физикой Matter.js можно в реальном времени модифицировать тайловую карту: активировать кнопку, которая меняет текстуру тайла и создает разрушаемый мост. Этот подход позволяет создавать интерактивные уровни с изменяемым окружением, не перезагружая сцену.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
playerController;
cursors;
cam;
smoothedControls;
map;
constructor () {
super({ key: "main" });
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.tilemapTiledJSON('map', 'assets/tilemaps/maps/matter-platformer-dynamic-example.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 ()
{
this.map = this.make.tilemap({ key: 'map' });
const tileset = this.map.addTilesetImage('kenney_redux_64x64');
const bgLayer = this.map.createLayer('Background Layer', tileset, 0, 0);
const groundLayer = this.map.createLayer('Ground Layer', tileset, 0, 0);
const fgLayer = this.map.createLayer('Foreground Layer', tileset, 0, 0).setDepth(1);
// Set up the layer to have matter bodies. Any colliding tiles will be given a Matter body.
groundLayer.setCollisionByProperty({ collides: true });
this.matter.world.convertTilemapLayer(groundLayer);
this.matter.world.setBounds(this.map.widthInPixels, this.map.heightInPixels);
this.matter.world.drawDebug = false;
this.cursors = this.input.keyboard.createCursorKeys();
this.smoothedControls = new SmoothedHorionztalControl(0.001);
// 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: 5,
jump: 7
}
};
const M = Phaser.Physics.Matter.Matter;
const w = this.playerController.matterSprite.width;
const h = this.playerController.matterSprite.height;
// 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 height_fix = 0;
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
],
restitution: 0.05 // Prevent body from sticking against a wall
});
// There is a "Button Press Sensor" polygon in the "Sensors" layer in Tiled. We can use this to
// map out the "pressable" hitbox for the button.
const sensor = this.map.findObject('Sensors', function (obj)
{
return obj.name === 'Button Press Sensor';
});
const center = M.Vertices.centre(sensor.polygon); // Matter places shapes by center of mass
const sensorBody = this.matter.add.fromVertices(
sensor.x + center.x, sensor.y + center.y,
sensor.polygon,
{ isStatic: true, isSensor: true }
);
this.playerController.matterSprite
.setExistingBody(compoundBody)
.setFixedRotation() // Sets max inertia to prevent rotation
.setPosition(32, 1000);
this.cam = this.cameras.main;
this.cam.setBounds(0, 0, this.map.widthInPixels, this.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.
// Loop over the active colliding pairs and count the surfaces the player is touching.
this.matter.world.on('collisionstart', 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 === playerBody && bodyB === sensorBody) ||
(bodyA === sensorBody && bodyB === playerBody))
{
this.matter.world.remove(sensorBody);
const buttonTile = groundLayer.getTileAt(4, 18);
// Change the tile to the new index (a "pressed" button tile) and tell the existing
// matter body to update itself from the Tiled collision data.
buttonTile.index = 93;
buttonTile.physics.matterBody.setFromTileCollision();
// Animate a bridge of new tiles opening up over the lava.
for (let j = 5; j <= 14; j++)
{
this.time.addEvent({
delay: (j - 5) * 50,
callback: function (x)
{
const bridgeTile = groundLayer.putTileAt(12, x, 19);
// When creating a new tile that didn't already have a tile body, you
// can use the tileBody factory method. See
// Phaser.Physics.Matter.TileBody for options. This will default to
// adding a body with the Tiled collision data here.
this.matter.add.tileBody(bridgeTile);
}.bind(this, j)
});
}
}
}
}, this);
// 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);
const lines = [
'Arrow keys to move. Press "Up" to jump.',
'Press the button!',
'Click to toggle rendering Matter debug.'
];
const text = this.add.text(16, 16, lines, {
fontSize: '20px',
padding: { x: 20, y: 10 },
backgroundColor: '#ffffff',
fill: '#000000'
});
text.setScrollFactor(0);
}
update (time, delta)
{
const matterSprite = this.playerController.matterSprite;
if (!matterSprite) { return; }
// Player death
if (matterSprite.y > this.map.heightInPixels)
{
matterSprite.destroy();
this.playerController.matterSprite = null;
this.restart();
return;
}
// 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
// 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 && this.playerController.blocked.bottom)
{
matterSprite.setVelocityY(-this.playerController.speed.jump);
this.playerController.lastJumpedAt = time;
}
this.smoothMoveCameraTowards(matterSprite, 0.9);
}
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);
}
restart ()
{
this.cam.fade(500, 0, 0, 0);
this.cam.shake(250, 0.01);
this.time.addEvent({
delay: 500,
callback: function ()
{
this.cam.resetFX();
this.scene.stop();
this.scene.start('main');
},
callbackScope: this
});
}
}
// 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;
}
}
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);
Загрузка тайловой карты и создание физических тел
В методе preload загружаются ресурсы: тайловая карта в формате JSON, tileset и спрайты. В create создается тайлмап и три слоя: фоновый, основной (земля) и передний.
Важный шаг — настройка физики для слоя земли. С помощью setCollisionByProperty мы указываем, что только тайлы со свойством collides: true (заданным в Tiled) будут сталкиваться. Метод convertTilemapLayer автоматически создает физические тела Matter.js для этих тайлов.
groundLayer.setCollisionByProperty({ collides: true });
this.matter.world.convertTilemapLayer(groundLayer);
Также здесь задаются границы мира Matter и отключается дебаг-отрисовка по умолчанию.
Создание составного тела игрока с сенсорами
Для точного определения столкновений (стоит ли игрок на земле, упирается ли в стену) используется составное тело (Compound Body). Оно состоит из основного прямоугольника и трех сенсоров: снизу, слева и справа. Сенсоры — это тела с флагом isSensor: true, которые регистрируют пересечения, но не оказывают физического сопротивления.
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 });
const compoundBody = M.Body.create({
parts: [playerBody, this.playerController.sensors.bottom, ...],
restitution: 0.05
});
Основной спрайт игрока привязывается к этому составному телу методом setExistingBody. setFixedRotation() предотвращает нежелательное вращение персонажа.
Обработка столкновений и логика движения
Логика управления и анимации находится в update. Движение по горизонтали реализовано с плавным разгоном (линейной интерполяцией Phaser.Math.Linear) на основе вспомогательного класса SmoothedHorionztalControl. Это создает эффект инерции.
newVelocityX = Phaser.Math.Linear(oldVelocityX, targetVelocityX, this.smoothedControls.value);
matterSprite.setVelocityX(newVelocityX);
Прыжок возможен только если сенсор снизу (bottom) регистрирует столкновение (this.playerController.blocked.bottom). Для исключения "двойных прыжков" вводятся задержка между прыжками по времени. Состояние сенсоров обновляется в обработчиках событий Matter beforeupdate, collisionactive и afterupdate.
Взаимодействие с кнопкой и изменение карты
В слое 'Sensors' тайлмапа размещен полигональный объект 'Button Press Sensor'. При старте игры из его вертексов создается статическое сенсорное тело Matter.
const sensorBody = this.matter.add.fromVertices(sensor.x + center.x, sensor.y + center.y, sensor.polygon, { isStatic: true, isSensor: true });
В обработчике события collisionstart проверяется столкновение тела игрока (playerBody) с этим сенсором. При контакте:
1. Сенсор удаляется (this.matter.world.remove).
2. Тайл кнопки (координаты 4, 18) меняет свой индекс на 93 (текстура "нажатой кнопки").
3. Физическое тело этого тайла обновляется в соответствии с новой геометрией из Tiled через setFromTileCollision().
buttonTile.index = 93;
buttonTile.physics.matterBody.setFromTileCollision();
Затем в цикле с задержкой создаются тайлы моста. Для новых тайлов, у которых изначально не было физического тела, используется фабричный метод this.matter.add.tileBody(bridgeTile).
Плавная камера и перезапуск сцены
Камера следует за игроком с плавным сглаживанием. Метод smoothMoveCameraTowards использует линейную интерполяцию между текущей позицией камеры и целевой (центрированной на игроке).
this.cam.scrollX = smoothFactor * this.cam.scrollX + (1 - smoothFactor) * (target.x - this.cam.width * 0.5);
При падении игрока за пределы карты (смерть) вызывается метод restart. Он запускает эффекты затемнения (fade) и встряски (shake) камеры, а через 500 мс перезапускает текущую сцену, полностью сбрасывая состояние игры.
this.time.addEvent({
delay: 500,
callback: function () {
this.scene.stop();
this.scene.start('main');
}
});
Что попробовать дальше
Пример демонстрирует мощную связку тайловых карт Phaser и физики Matter.js для создания динамического игрового мира. Вы можете экспериментировать: сделать мост разрушаемым при повторном нажатии кнопки, добавлять другие интерактивные элементы (рычаги, исчезающие платформы) или генерировать части уровня процедурно в ответ на действия игрока. Важно помнить, что метод tileBody следует использовать для новых тайлов, а setFromTileCollision — для обновления уже существующих физических тел.
