О чем этот пример
Создание правдоподобной изометрической игры требует не только красивого визуала, но и корректного отображения объектов друг относительно друга. Ключевой элемент этого — правильная сортировка по глубине (depth sorting). В этой статье мы разберем на примере из официальной галереи Phaser, как реализовать динамическую систему глубины для персонажей и объектов на изометрической карте. Вы научитесь управлять порядком отрисовки, чтобы персонажи, уходя за деревья или здания, выглядели естественно.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
var directions = {
west: { offset: 0, x: -2, y: 0, opposite: 'east' },
northWest: { offset: 32, x: -2, y: -1, opposite: 'southEast' },
north: { offset: 64, x: 0, y: -2, opposite: 'south' },
northEast: { offset: 96, x: 2, y: -1, opposite: 'southWest' },
east: { offset: 128, x: 2, y: 0, opposite: 'west' },
southEast: { offset: 160, x: 2, y: 1, opposite: 'northWest' },
south: { offset: 192, x: 0, y: 2, opposite: 'north' },
southWest: { offset: 224, x: -2, y: 1, opposite: 'northEast' }
};
var anims = {
idle: {
startFrame: 0,
endFrame: 4,
speed: 0.2
},
walk: {
startFrame: 4,
endFrame: 12,
speed: 0.15
},
attack: {
startFrame: 12,
endFrame: 20,
speed: 0.11
},
die: {
startFrame: 20,
endFrame: 28,
speed: 0.2
},
shoot: {
startFrame: 28,
endFrame: 32,
speed: 0.1
}
};
var skeletons = [];
var tileWidthHalf;
var tileHeightHalf;
var d = 0;
var scene;
// GameObject Skeleton
class Skeleton extends Phaser.GameObjects.Image {
constructor(scene, x, y, motion, direction, distance) {
super(scene, x, y, 'skeleton', direction.offset);
this.startX = x;
this.startY = y;
this.distance = distance;
this.motion = motion;
this.anim = anims[motion];
this.direction = directions[direction];
this.speed = 0.15;
this.f = this.anim.startFrame;
this.depth = y + 64;
scene.time.delayedCall(this.anim.speed * 1000, this.changeFrame, [], this);
}
changeFrame ()
{
this.f++;
var delay = this.anim.speed;
if (this.f === this.anim.endFrame)
{
switch (this.motion)
{
case 'walk':
this.f = this.anim.startFrame;
this.frame = this.texture.get(this.direction.offset + this.f);
scene.time.delayedCall(delay * 1000, this.changeFrame, [], this);
break;
case 'attack':
delay = Math.random() * 2;
scene.time.delayedCall(delay * 1000, this.resetAnimation, [], this);
break;
case 'idle':
delay = 0.5 + Math.random();
scene.time.delayedCall(delay * 1000, this.resetAnimation, [], this);
break;
case 'die':
delay = 6 + Math.random() * 6;
scene.time.delayedCall(delay * 1000, this.resetAnimation, [], this);
break;
}
}
else
{
this.frame = this.texture.get(this.direction.offset + this.f);
scene.time.delayedCall(delay * 1000, this.changeFrame, [], this);
}
}
resetAnimation ()
{
this.f = this.anim.startFrame;
this.frame = this.texture.get(this.direction.offset + this.f);
scene.time.delayedCall(this.anim.speed * 1000, this.changeFrame, [], this);
}
update ()
{
if (this.motion === 'walk')
{
this.x += this.direction.x * this.speed;
if (this.direction.y !== 0)
{
this.y += this.direction.y * this.speed;
this.depth = this.y + 64;
}
// Walked far enough?
if (Phaser.Math.Distance.Between(this.startX, this.startY, this.x, this.y) >= this.distance)
{
this.direction = directions[this.direction.opposite];
this.f = this.anim.startFrame;
this.frame = this.texture.get(this.direction.offset + this.f);
this.startX = this.x;
this.startY = this.y;
}
}
}
}
class Example extends Phaser.Scene
{
constructor ()
{
super();
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.json('map', 'assets/tests/iso/isometric-grass-and-water.json');
this.load.spritesheet('tiles', 'assets/tests/iso/isometric-grass-and-water.png', { frameWidth: 64, frameHeight: 64 });
this.load.spritesheet('skeleton', 'assets/tests/iso/skeleton8.png', { frameWidth: 128, frameHeight: 128 });
this.load.image('house', 'assets/tests/iso/rem_0002.png');
}
create ()
{
scene = this;
this.buildMap();
this.placeHouses();
skeletons.push(this.add.existing(new Skeleton(this, 240, 290, 'walk', 'southEast', 100)));
skeletons.push(this.add.existing(new Skeleton(this, 100, 380, 'walk', 'southEast', 230)));
skeletons.push(this.add.existing(new Skeleton(this, 620, 140, 'walk', 'south', 380)));
skeletons.push(this.add.existing(new Skeleton(this, 460, 180, 'idle', 'south', 0)));
skeletons.push(this.add.existing(new Skeleton(this, 760, 100, 'attack', 'southEast', 0)));
skeletons.push(this.add.existing(new Skeleton(this, 800, 140, 'attack', 'northWest', 0)));
skeletons.push(this.add.existing(new Skeleton(this, 750, 480, 'walk', 'east', 200)));
skeletons.push(this.add.existing(new Skeleton(this, 1030, 300, 'die', 'west', 0)));
skeletons.push(this.add.existing(new Skeleton(this, 1180, 340, 'attack', 'northEast', 0)));
skeletons.push(this.add.existing(new Skeleton(this, 1180, 180, 'walk', 'southEast', 160)));
skeletons.push(this.add.existing(new Skeleton(this, 1450, 320, 'walk', 'southWest', 320)));
skeletons.push(this.add.existing(new Skeleton(this, 1500, 340, 'walk', 'southWest', 340)));
skeletons.push(this.add.existing(new Skeleton(this, 1550, 360, 'walk', 'southWest', 330)));
this.cameras.main.setSize(1600, 600);
window.fx = this.cameras.main.postFX.addTiltShift(0.5, 0.1, 0.8, 0.5, 1, 1);
// this.cameras.main.scrollX = 800;
}
update ()
{
skeletons.forEach(function (skeleton) {
skeleton.update();
});
// return;
if (d)
{
this.cameras.main.scrollX -= 0.5;
if (this.cameras.main.scrollX <= 0)
{
d = 0;
}
}
else
{
this.cameras.main.scrollX += 0.5;
if (this.cameras.main.scrollX >= 800)
{
d = 1;
}
}
}
buildMap ()
{
// Parse the data out of the map
const data = scene.cache.json.get('map');
const tilewidth = data.tilewidth;
const tileheight = data.tileheight;
tileWidthHalf = tilewidth / 2;
tileHeightHalf = tileheight / 2;
const layer = data.layers[0].data;
const mapwidth = data.layers[0].width;
const mapheight = data.layers[0].height;
const centerX = mapwidth * tileWidthHalf;
const centerY = 16;
let i = 0;
for (let y = 0; y < mapheight; y++)
{
for (let x = 0; x < mapwidth; x++)
{
const id = layer[i] - 1;
const tx = (x - y) * tileWidthHalf;
const ty = (x + y) * tileHeightHalf;
const tile = scene.add.image(centerX + tx, centerY + ty, 'tiles', id);
tile.depth = centerY + ty;
i++;
}
}
}
placeHouses ()
{
const house_1 = scene.add.image(240, 370, 'house');
house_1.depth = house_1.y + 86;
const house_2 = scene.add.image(1300, 290, 'house');
house_2.depth = house_2.y + 86;
}
}
const config = {
type: Phaser.WEBGL,
width: 800,
height: 600,
backgroundColor: '#000000',
parent: 'phaser-example',
scene: Example
};
const game = new Phaser.Game(config);
Суть проблемы глубины в изометрии
В классической 2D-графике объекты отрисовываются в порядке их добавления на сцену. В изометрической проекции это приводит к ошибкам: персонаж, стоящий "за" деревом, может отображаться поверх него, разрушая иллюзию трехмерности.
Решение — использовать свойство .depth у игровых объектов. В Phaser объекты с большим значением depth отрисовываются поверх объектов с меньшим значением. Для изометрии логично привязывать глубину к координате Y на экране: чем ниже (больше Y) находится объект, тем "ближе" он к камере, и тем выше он должен быть в порядке отрисовки.
В нашем примере это базовый принцип: this.depth = y + 64 для скелета и tile.depth = centerY + ty для тайлов карты.
Анализ класса Skeleton: анимация и движение
Персонажи (скелеты) реализованы как отдельный класс, расширяющий Phaser.GameObjects.Image. Это позволяет инкапсулировать логику анимации, движения и обновления глубины.
Ключевые моменты конструктора:
- Кадры анимации выбираются из спрайтшита с помощью direction.offset + this.f.
- Глубина (depth) устанавливается сразу при создании на основе позиции Y.
- Запускается цикл анимации через scene.time.delayedCall.
constructor(scene, x, y, motion, direction, distance) {
super(scene, x, y, 'skeleton', direction.offset);
this.depth = y + 64;
scene.time.delayedCall(this.anim.speed * 1000, this.changeFrame, [], this);
}
Метод update() отвечает за движение (walk). При изменении координаты Y глубина пересчитывается, что критически важно для объектов в движении.
if (this.direction.y !== 0) {
this.y += this.direction.y * this.speed;
this.depth = this.y + 64;
}
Построение изометрической карты
Карта загружается из JSON-файла (Tiled format). Алгоритм buildMap() преобразует сеточные координаты тайлов в изометрические на экране. Именно здесь закладывается основа для правильной глубины статичного мира.
Формулы преобразования координат:
- tx = (x - y) * tileWidthHalf
- ty = (x + y) * tileHeightHalf
Глубина каждого тайла устанавливается равной его конечной координате ty (с небольшим смещением centerY). Такой подход гарантирует, что тайлы, которые должны быть "выше" на экране (меньший Y), будут отрисованы первыми, а "нижние" тайлы (больший Y) перекроют их.
const tile = scene.add.image(centerX + tx, centerY + ty, 'tiles', id);
tile.depth = centerY + ty;
Статичные объекты, такие как дома, добавляются отдельно, и их глубина также рассчитывается от позиции Y с поправкой на высоту спрайта (+86).
Централизованное обновление и управление камерой
Все объекты-скелеты хранятся в глобальном массиве skeletons. В основном цикле игры, в методе update() сцены, для каждого скелета вызывается его собственный метод update(). Это обеспечивает обновление позиции и, как следствие, глубины для всех движущихся объектов каждый кадр.
update ()
{
skeletons.forEach(function (skeleton) {
skeleton.update();
});
// ... код движения камеры
}
В примере также реализовано автоматическое покачивание камеры (скроллинг по оси X между 0 и 800). Это создает эффект панорамного обзора и демонстрирует, что система глубины продолжает корректно работать даже при движении камеры.
if (d) {
this.cameras.main.scrollX -= 0.5;
} else {
this.cameras.main.scrollX += 0.5;
}
Что попробовать дальше
Динамическая сортировка по глубине на основе координаты Y — это фундаментальный прием для создания убедительной изометрической проекции в Phaser. Пример показывает, как грамотно комбинировать статичный мир и движущиеся объекты. Для экспериментов попробуйте: добавить больше типов анимаций, реализовать взаимодействие скелетов с объектами карты (например, скрываться за зданиями), или изменить логику глубины для объектов сложной формы.
