О чем этот пример
Создание платформера с разрушаемым окружением — это классический приём, который добавляет игре глубины и интерактивности. В этом примере мы разберём, как в Phaser с физическим движком Matter.js реализовать механику, при которой тайлы карты исчезают после контакта с игроком. Вы научитесь работать с `Tilemap` слоями, назначать им физические тела, обрабатывать сложные столкновения через события Matter и анимировать разрушение тайлов, создавая живой и отзывчивый игровой мир.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
map;
cam;
smoothedControls;
cursors;
playerController;
mapScale = 2.5;
constructor ()
{
super({key: "main"});
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.tilemapTiledJSON('this.map', 'assets/tilemaps/maps/matter-destroy-tile-bodies.json');
this.load.image('platformer_tiles', 'assets/tilemaps/tiles/platformer_tiles.png');
this.load.spritesheet('player', 'assets/sprites/dude-cropped.png', { frameWidth: 32, frameHeight: 42 });
}
create ()
{
this.map = this.make.tilemap({ key: 'this.map' });
const tileset = this.map.addTilesetImage('platformer_tiles');
const bgLayer = this.map.createLayer('Background Layer', tileset, 0, 0)
.setScale(this.mapScale);
const groundLayer = this.map.createLayer('Ground Layer', tileset, 0, 0)
.setScale(this.mapScale);
const fgLayer = this.map.createLayer('Foreground Layer', tileset, 0, 0)
.setScale(this.mapScale)
.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);
// Change the label of the Matter body on platform tiles that should fall when the player steps
// on them. This makes it easier to check Matter collisions.
groundLayer.forEachTile((tile) => {
// In Tiled, the platform tiles have been given a "fallOnContact" property
if (tile.properties.fallOnContact)
{
tile.physics.matterBody.body.label = 'disappearingPlatform';
}
});
// The player is a collection of bodies and sensors. See "matter platformer with wall jumping"
// example for more explanation.
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: 12
}
};
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 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
});
this.playerController.matterSprite
.setExistingBody(compoundBody)
.setFixedRotation() // Sets max inertia to prevent rotation
.setPosition(32, 500);
this.cam = this.cameras.main;
this.cam.setBounds(0, 0, this.map.widthInPixels * this.mapScale, this.map.heightInPixels * this.mapScale);
this.smoothMoveCameraTowards(this.playerController.matterSprite);
this.matter.world.setBounds(this.map.widthInPixels * this.mapScale, this.map.heightInPixels * this.mapScale);
this.matter.world.drawDebug = false;
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
});
// Loop over the active colliding pairs and count the surfaces the player is touching.
this.matter.world.on('collisionstart', (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.label === 'disappearingPlatform') ||
(bodyB === playerBody && bodyA.label === 'disappearingPlatform'))
{
const tileBody = bodyA.label === 'disappearingPlatform' ? bodyA : bodyB;
// Matter Body instances have a reference to their associated game object. Here,
// that's the Phaser.Physics.Matter.TileBody, which has a reference to the
// Phaser.GameObjects.Tile.
const tileWrapper = tileBody.gameObject;
const tile = tileWrapper.tile;
// Only destroy a tile once
if (tile.properties.isBeingDestroyed)
{
continue;
}
tile.properties.isBeingDestroyed = true;
// Since we are using ES5 here, the local tile variable isn't scoped to this block -
// bind to the rescue.
this.tweens.add({
targets: tile,
alpha: { value: 0, duration: 500, ease: 'Power1' },
onComplete: this.destroyTile.bind(this, tile)
});
}
// Note: the tile bodies in this level are all simple rectangle bodies, so checking the
// label is easy. See matter detect collision with tile for how to handle when the tile
// bodies are compound shapes or concave polygons.
}
});
// 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', (event) =>
{
this.playerController.numTouching.left = 0;
this.playerController.numTouching.right = 0;
this.playerController.numTouching.bottom = 0;
});
// Loop over the active colliding pairs and count the surfaces the player is touching.
this.matter.world.on('collisionactive', (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;
}
}
});
// Update over, so now we can determine if any direction is blocked
this.matter.world.on('afterupdate', (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.input.on('pointerdown', () => {
this.matter.world.drawDebug = !this.matter.world.drawDebug;
this.matter.world.debugGraphic.visible = this.matter.world.drawDebug;
});
this.cursors = this.input.keyboard.createCursorKeys();
this.smoothedControls = new SmoothedHorionztalControl(0.001);
const lines = [
'Arrow keys to move.',
'Press "Up" to jump.',
'Don\'t look back :)',
'Click to toggle rendering Matter debug.'
];
const text = this.add.text(16, 16, lines, {
fontSize: '20px',
padding: { x: 20, y: 10 },
backgroundColor: '#000000',
fill: '#ffffff'
});
text.setScrollFactor(0);
}
update (time, delta)
{
const matterSprite = this.playerController.matterSprite;
if (!matterSprite) { return; }
// Player death
if (matterSprite.y > this.map.heightInPixels * this.mapScale)
{
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, this.playerController);
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;
console.log(this.playerController.blocked.bottom)
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);
}
destroyTile (tile)
{
const layer = tile.tilemapLayer;
layer.removeTileAt(tile.x, tile.y);
tile.physics.matterBody.destroy();
}
restart ()
{
this.cam.fade(500, 0, 0, 0);
this.cam.shake(250, 0.01);
this.time.addEvent({
delay: 600,
callback: () => {
this.cam.resetFX();
this.scene.stop();
this.scene.start('main');
}
});
}
}
// 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, playerController)
{
if (this.value > 0) { this.reset(); }
this.value -= this.msSpeed * delta;
if (this.value < -1) { this.value = -1; }
playerController.time.rightDown += delta;
}
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',
pixelArt: true,
physics: {
default: 'matter',
matter: {
gravity: { y: 1 },
enableSleep: false,
debug: true
}
},
scene: Example
};
const game = new Phaser.Game(config);
Подготовка карты и создание физических тел для тайлов
Основой уровня служит тайловая карта, загруженная из JSON-файла Tiled. Ключевой шаг — превратить статичные тайлы в динамические физические объекты.
Сначала мы создаём слои карты и масштабируем их. Затем для слоя 'Ground Layer' включаем коллизии по свойству collides, заданному в Tiled. Метод this.matter.world.convertTilemapLayer(groundLayer) автоматически создаёт Matter-тела для всех тайлов на этом слое, у которых включена коллизия.
Далее мы проходим по всем тайлам слоя и изменяем label (метку) тела у тех тайлов, которые должны разрушаться. В Tiled таким тайлам присвоено пользовательское свойство fallOnContact. Изменение метки позволяет позже легко идентифицировать эти тела при обработке столкновений.
groundLayer.setCollisionByProperty({ collides: true });
this.matter.world.convertTilemapLayer(groundLayer);
groundLayer.forEachTile((tile) => {
if (tile.properties.fallOnContact)
{
tile.physics.matterBody.body.label = 'disappearingPlatform';
}
});
Создание составного тела игрока с сенсорами
Для точного определения, стоит ли игрок на земле или касается стены, используется составное тело (compound body), состоящее из основного прямоугольника и трёх сенсорных областей (датчиков). Сенсоры — это Matter-тела с флагом isSensor: true, которые генерируют события столкновения, но не оказывают физического сопротивления.
Основное тело игрока создаётся с помощью M.Bodies.rectangle. Сенсоры для низа, левой и правой сторон создаются как тонкие прямоугольники, расположенные по краям спрайта. Все эти части объединяются в одно составное тело с помощью M.Body.create. Это тело затем назначается спрайту игрока методом setExistingBody().
const M = Phaser.Physics.Matter.Matter;
const w = this.playerController.matterSprite.width;
const h = this.playerController.matterSprite.height;
const sx = w / 2;
const sy = h / 2;
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
});
this.playerController.matterSprite
.setExistingBody(compoundBody)
.setFixedRotation()
.setPosition(32, 500);
Обработка столкновений: обнаружение и разрушение платформ
Механика разрушения тайлов активируется при событии collisionstart мира Matter. Мы перебираем все пары столкнувшихся тел (event.pairs) и ищем те, где одно из тел — основное тело игрока (playerBody), а второе имеет метку 'disappearingPlatform'.
Если такая пара найдена, мы получаем ссылку на объект тайла через tileBody.gameObject.tile. Чтобы избежать множественного срабатывания, проверяем пользовательское свойство тайла isBeingDestroyed. Если тайл ещё не разрушается, мы устанавливаем это свойство в true и запускаем твин (анимацию) на постепенное исчезновение тайла (alpha: 0). По завершении анимации вызывается функция destroyTile.
this.matter.world.on('collisionstart', (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.label === 'disappearingPlatform') ||
(bodyB === playerBody && bodyA.label === 'disappearingPlatform')) {
const tileBody = bodyA.label === 'disappearingPlatform' ? bodyA : bodyB;
const tileWrapper = tileBody.gameObject;
const tile = tileWrapper.tile;
if (tile.properties.isBeingDestroyed) { continue; }
tile.properties.isBeingDestroyed = true;
this.tweens.add({
targets: tile,
alpha: { value: 0, duration: 500, ease: 'Power1' },
onComplete: this.destroyTile.bind(this, tile)
});
}
}
});
Отслеживание состояния игрока через сенсоры
Для плавного управления необходимо знать, касается ли игрок земли или стен. Это реализовано через три события Matter: beforeupdate, collisionactive и afterupdate.
В beforeupdate счётчики касаний сбрасываются. В collisionactive мы анализируем активные столкновения сенсоров игрока с другими телами. Если сенсор bottom соприкасается с любым телом, увеличивается счётчик касаний земли. Сенсоры left и right увеличивают свои счётчики только при контакте со статичными телами (isStatic), чтобы игрок мог толкать динамические объекты, но останавливался о стены. В afterupdate на основе значений счётчиков обновляются флаги blocked в контроллере игрока, которые используются в логике движения и прыжка.
this.matter.world.on('beforeupdate', (event) => {
this.playerController.numTouching.left = 0;
this.playerController.numTouching.right = 0;
this.playerController.numTouching.bottom = 0;
});
this.matter.world.on('collisionactive', (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) {
this.playerController.numTouching.bottom += 1;
} else if ((bodyA === left && bodyB.isStatic) || (bodyB === left && bodyA.isStatic)) {
this.playerController.numTouching.left += 1;
} else if ((bodyA === right && bodyB.isStatic) || (bodyB === right && bodyA.isStatic)) {
this.playerController.numTouching.right += 1;
}
}
});
this.matter.world.on('afterupdate', (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;
});
Управление движением и анимация разрушения
Движение игрока обрабатывается в update. Горизонтальная скорость плавно интерполируется к целевой с помощью Phaser.Math.Linear, что создаёт эффект ускорения и замедления. Коэффициент сглаживания берётся из вспомогательного класса SmoothedHorionztalControl. Прыжок возможен только если игрок стоит на земле (blocked.bottom) и прошло более 250 мс с последнего прыжка.
Функция destroyTile вызывается после завершения анимации исчезновения тайла. Она удаляет тайл с карты с помощью layer.removeTileAt и уничтожает его физическое тело через tile.physics.matterBody.destroy(), окончательно убирая его из симуляции Matter.
// Фрагмент управления скоростью
oldVelocityX = matterSprite.body.velocity.x;
targetVelocityX = this.playerController.speed.run;
newVelocityX = Phaser.Math.Linear(oldVelocityX, targetVelocityX, this.smoothedControls.value);
matterSprite.setVelocityX(newVelocityX);
// Функция окончательного удаления тайла
destroyTile (tile) {
const layer = tile.tilemapLayer;
layer.removeTileAt(tile.x, tile.y);
tile.physics.matterBody.destroy();
}
Что попробовать дальше
Этот пример демонстрирует мощную связку Tilemap и физики Matter.js для создания интерактивной среды. Вы можете экспериментировать: изменить задержку перед падением тайлов, добавить эффекты частиц при разрушении, сделать тайлы восстанавливающимися через время или реализовать разрушение от определённого типа атаки. Попробуйте также использовать составные или вогнутые полигональные тела для тайлов — для их обработки потребуется более сложная логика проверки столкновений, как указано в комментариях кода.
