Статьи

Написание движка плиток в JavaFX

С появлением встроенных версий JavaFX наша платформа стала более интересной для разработки игр, поскольку теперь мы можем ориентироваться на небольшие потребительские устройства, такие как планшеты и смартфоны. Поэтому я решил немного поэкспериментировать с JavaFX для написания игр. На этот раз я хотел использовать Canvas для большего контроля над рендерингом, чтобы иметь возможность оптимизировать производительность на небольших устройствах. Это мой опыт написания Tile Engine.

Что такое плиточный двигатель?

В первые годы игровые приставки и компьютеры имели очень ограниченные ресурсы. Таким образом, чтобы иметь игры с тысячами больших экранов, разработчикам необходимо было придумать способ хранения экранов в формате, отличном от растрового изображения на экране. Так были изобретены Tile Engines, которые могут генерировать большие экраны из ограниченного набора повторно используемой более мелкой графики (Tiles). Это экономит оперативную память и улучшает производительность рендеринга.

TileMaps

Инструкции по созданию экрана хранятся в TileMaps. Эти карты обычно организованы как двумерная матрица идентификаторов тайлов. Обычно плитки организованы в слои, чтобы обеспечить простое Z-упорядочение и большую гибкость в сочетании графики с различными фонами. Обычно TileMaps также поддерживают хранение метаданных, например, если определенные плитки заблокированы, или точки появления для врагов.

TileMap с несколькими слоями, созданными с

Tilesets

Плитки, на которые есть ссылки в карте, обычно хранятся в TileSets, которые состоят из одного растрового изображения и мета-информации о том, как разделить его на плитки. Вот пример такого изображения с opengameart.com, сайта, на котором размещены игровые ресурсы с лицензиями Open Source. В моих примерах я использую некоторые из этих графиков.

Типичное изображение TileSet размером 1024 x 1024 (^ 2 = хорошо для видеокарт)

ObjectGroups

Еще одна особенность формата TMX — это Object Layers. Эти специальные слои можно использовать для определения форм и полилиний произвольной формы и присвоения им свойств. Основная идея заключается в том, что мы можем использовать их для определения областей, в которых создаются спрайты (точки появления), выходы, порталы и непрямоугольные формы столкновений. Создатель TileEngine или разработчик, создающий игры с его помощью, должен определить, как обращаться с объектными группами. Я планирую широко их использовать, и они являются очень хорошим расширением для декларативного определения игрового процесса. Например, вы можете использовать их для определения анимации, диалогов сценариев и т. Д.

Точки появления, определенные в

Рабочий процесс, инструменты и форматы

Идея мозаичных карт также обеспечивает хороший рабочий процесс. Графические дизайнеры могут создавать ресурсы, а игровые дизайнеры могут импортировать их в редактор уровней, например «Tiled», и создавать уровни с помощью перетаскивания. Карты хранятся в машиночитаемом формате TileMap. Например, Tiled использует формат карты TMX для хранения TileMap. Это очень простой формат XML, который затем может быть загружен TileEngine. Для своей реализации я решил использовать формат TMX, поэтому я могу использовать « Tiled » для проектирования уровней.

Реализация в JavaFX

Для реализации я решил использовать рендеринг в непосредственном режиме JavaFX Canvas вместо рендеринга в режиме удержания при использовании отдельных узлов. Это дает мне больше возможностей для оптимизации производительности на небольших устройствах, таких как Raspberry Pi.

Чтение файлов TMX / TSX

Первое, что нам нужно, это способ чтения файлов TileMap (TMX) и TileSet (TSX) . С JAXB довольно просто создать TileMapReader, который может создавать POJO из файла. Поэтому, если вы используете Engine, вы просто звоните:

1
TileMap map = TileMapReader.readMap(“path/to/my/map.tmx”);

Камера

Так как в большинстве игр TileMaps будет больше экрана, отображается только часть карты. Обычно карта сосредоточена на герое. Вы можете сделать это, просто отслеживая положение карты в верхнем левом углу экрана. Мы называем это нашей позицией камеры. Затем позиция обновляется с позиции героя как раз перед тем, как TileMap отображается так:

1
2
3
4
5
6
7
8
9
// the center of the screen is the preferred location of our hero
 
double centerX = screenWidth / 2;
 
double centerY = screenHeight / 2;
 
cameraX = hero.getX() - centerX;
 
cameraY = hero.getY() - centerY;

Нам просто нужно убедиться, что камера не покидает плитку:

01
02
03
04
05
06
07
08
09
10
11
12
13
// if we get too close to the borders
 
if (cameraX >= cameraMaxX) {
 
cameraX = cameraMaxX;
 
}
 
if (cameraY >= cameraMaxY) {
 
cameraY = cameraMaxY;
 
}

Рендеринг TileMap с использованием Canvas

Тогда действительно легко рендерить плитки. мы просто перебираем слои и просим мозаичную карту отобразить правильное изображение в текущей позиции. Сначала нам нужно выяснить, какие плитки в данный момент видны, и смещение, поскольку наш герой перемещается пиксель за пикселем, а не плиткой за плиткой:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// x,y index of first tile to be shown
 
int startX = (int) (cameraX / tileWidth);
 
int startY = (int) (cameraY / tileHeight);
 
// the offset in pixels
 
int offX = (int) (cameraX % tileWidth);
 
int offY = (int) (cameraY % tileHeight);
 
Then we loop through the visible layers and draw the tile:
 
for (int y = 0; y < screenHeightInTiles; y++) {
 
for (int x = 0; x < screenWidthInTiles; x++) {
 
// get the tile id of the tile at this position
 
int gid = layer.getGid((x + startX) + ((y + startY) * tileMap.getWidth()));
 
graphicsContext2D.save();
 
// position the graphicscontext for drawing
 
graphicsContext2D.translate((x * tileWidth) - offX, (y * tileHeight) - offY);
 
// ask the tilemap to draw the tile
 
tileMap.drawTile(graphicsContext2D, gid);
 
// restore the old state
 
graphicsContext2D.restore();
 
}
 
}

Затем TileMap выяснит, к какому Tileset принадлежит данный элемент, и попросит TileSet отрисовать его в контексте. Само рисование так же просто, как поиск правильных координат в изображении TileSets:

01
02
03
04
05
06
07
08
09
10
11
public void drawTile(GraphicsContext graphicsContext2D, int tileIndex) {
 
int x = tileIndex % cols;
 
int y = tileIndex / cols;
 
// TODO support for margin and spacing
 
graphicsContext2D.drawImage(tileImage, x * tilewidth, y* tileheight, tilewidth, tileheight, 0, 0, tilewidth, tileheight);
 
}

Игровой цикл. Таким образом, мы можем упростить это до:

Игровой цикл снова очень прост. Я использую временную шкалу и ключевой кадр, чтобы запустить импульс для игры с определенной частотой кадров (FPS):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
final Duration oneFrameAmt = Duration.millis(1000 / FPS);
 
final KeyFrame oneFrame = new KeyFrame(oneFrameAmt,
 
new EventHandler() {
 
@Override
 
public void handle(Event t) {
 
update();
 
render();
 
}
 
});
 
TimelineBuilder.create()
 
.cycleCount(Animation.INDEFINITE)
 
.keyFrames(oneFrame)
 
.build()
 
.play();

Спрайты

Каждый вызов для обновления в TileMapCanvas проходит через все спрайты и обновляет их. Базовые спрайты в настоящее время содержат один TileSet с циклом ходьбы, подобным этому:

Поскольку спрайты, как правило, имеют много прозрачного пространства вокруг них, чтобы иметь дополнительное пространство для анимированного поведения, такого как раскачивание меча, я решил разрешить добавить MoveBox и CollisionBox для удобства. CollisionBox может использоваться для определения области, где наш герой может быть ранен. MoveBox следует размещать вокруг ног, чтобы он мог проходить перед запрещенными плитками, когда верхняя часть тела перекрывает плитку. Голубоватая область вокруг нашего «героя» — это граница спрайтов:

https://www.youtube.com/watch?v=08H6LZkcqXw

Спрайты также могут иметь временное поведение. При каждом обновлении Sprite просматривает свое поведение и проверяет, пора ли срабатывать. Если это так, то вызывается метод «веди себя». Если у нас есть враг, например скелет в примере приложения, мы можем добавить его ИИ здесь. У нашего Скелета, например, очень простое поведение, чтобы заставить его следовать за нашим героем. Он также проверяет наличие столкновений и наносит урон нашему герою следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
monsterSprite.addBehaviour(new Sprite.Behavior() {
 
@Override
 
public void behave(Sprite sprite, TileMapCanvas playingField) {
 
if (sprite.getCollisionBox().intersects(hero.getCollisionBox())) {
 
hero.hurt(1);
 
}
 
}
 
});

Интервал по умолчанию составляет секунду. Если вам нужны другие интервалы, вы можете установить их. Поведения можно использовать повторно, разные спрайты могут использовать один и тот же экземпляр поведения. Поведения похожи на ключевые кадры, и в настоящее время я также использую их для определения времени анимации (увеличение индекса плитки для следующего вызова рендеринга).

ObjectGroupHandler

Как уже упоминалось в начале, ObjectGroups — это удобные точки расширения. В моем примере игры я использую их для определения точек появления нашего героя и монстров. В настоящее время вы просто добавляете ObjectGroupHandler, который, в свою очередь, использует информацию в ObjectGroup для создания спрайтов Героев и Монстров и добавления к ним Поведения:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class MonsterHandler implements ObjectGroupHandler {
 
Sprite hero;
 
@Override
 
public void handle(ObjectGroup group, final TileMapCanvas field) {
 
if (group.getName().equals('sprites')) {
 
for (TObject tObject : group.getObjectLIst()) {
 
if (tObject.getName().equals('MonsterSpawner')) {
 
try {
 
double x = tObject.getX();
 
double y = tObject.getY();
 
TileSet monster = TileMapReader.readSet('/de/eppleton/tileengine/resources/maps/BODY_skeleton.tsx');
 
Sprite monsterSprite = new Sprite(monster, 9, x, y, 'monster');
 
monsterSprite.setMoveBox(new Rectangle2D(18, 42, 28, 20));
 
field.addSprite(monsterSprite);
 
monsterSprite.addBehaviour(new Sprite.Behavior() {
 
@Override
 
public void behave(Sprite sprite, TileMapCanvas playingField) {
 
if (sprite.getCollisionBox().intersects(hero.getCollisionBox())) {
 
hero.hurt(1);
 
}
 
}
 
});
 
}

Собираем все вместе

Для создания примера игры все, что вам нужно сделать, это создать TileMaps, TileSets, один или несколько ObjectGroupHandler (ов), чтобы создать спрайты и добавить поведение, и вы готовы играть:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// create the world
 
TileMap tileMap = TileMapReader.readMap('/de/eppleton/tileengine/resources/maps/sample.tmx');
 
// initialize the TileMapCanvas
 
TileMapCanvas playingField = new TileMapCanvas(tileMap, 0, 0, 500, 500);
 
// add Handlers, can also be done declaratively.
 
playingField.addObjectGroupHandler(new MonsterHandler());
 
// display the TileMapCanvas
 
StackPane root = new StackPane();
 
root.getChildren().add(playingField);
 
Scene scene = new Scene(root, 500, 500);
 
playingField.requestFocus();
 
primaryStage.setTitle('Tile Engine Sample');
 
primaryStage.setScene(scene);
 
primaryStage.show();

Это было отправной точкой моего двигателя плитки. В то же время он превратился в более общий 2D-движок общего назначения, поэтому поддерживаются также спрайты, которые не используют TileSets и слои, которые свободно отображаются. Но это работает довольно хорошо до сих пор.

Ссылка: Написание Tile Engine в JavaFX от нашего партнера JCG Тони Эппла в блоге Eppleton .