[Update] How to manage big isometric maps with Phaser 3.5

Jerome did a great job introducing the chunking script and then explaining how to implement it in his article. Since then, Phaser’s API has evolved significantly enough to warrant an update.

All the code for this update can be found on github.

 

In this article I’ll cover what is needed to implement the chunking approach with the current version of Phaser (3.55.2) and how make it work with the isometric map, which Phaser supports starting from version 3.50.0.

Making it work with Phaser 3.55.2 (and above)

To make it work with the current stable version of Phaser (3.55.2) we need to add just one line in the removeChunk method

Game.scene.cache.tilemap.remove(`chunk${chunkID}`);
So the final version looks like this
Game.maps[chunkID].destroy();
Game.scene.cache.tilemap.remove(`chunk${chunkID}`);
var idx = Game.displayedChunks.indexOf(chunkID);
if(idx > -1) Game.displayedChunks.splice(idx,1);
Phaser API changed slightly over the releases and to avoid seeing warnings in the console I recommend changing the createStaticLayer to createLayer in the displayChunk method.

Additionally I checked if this updated version will work with the future version of the Phaser (v3.60.0 Beta 3 at the time of writing) and it will. So there won’t be needed any more changes to make it working in the nearest future.

If you’re working with big maps and you’re having performance issues with the splitter script – check this out.

Making it work with isometric maps

Creating an isometric map with Tiled

For this example I created an isometric map with the Tiled map editor. The map has a width of 80 tiles and a height of 80 tiles. The tiles are of size 64px*32px. The map has one layer named “Ground”. I filled the “Ground” using the tileset used in one of the Phaser’s example.

Next I exported it as a json file from the Tiled editor (both files, Tiled *.tmx and exported *.json, are in the demo repo) and I used that json file to generate the map chunks using the splitter script (chunks json files are in the demo repo too, in chunks directory).

Also to make it working with Phaser we need to add the tileset file in the preload method

this.load.image("tiles", "assets/tilesheet_iso.png");

So now we have the map splitted into chunks and we can implement the way to (un)loading them dynamically.

Using isometric map chunks with Phaser

Phaser started supporting isometric tilemaps from version 3.50.0 and it handles isometric maps a little bit differently than the regular two dimensional maps. The main difference is that, with the regular two dimensional map the origin of the coordinate system is at the top-left corner starting from the x=0,y=0 coordinates and having positive numbers only:

2d map

With isometric map tiles are rotated squares (rhombuses) and the origin of the coordinate system is at the top-middle point starting from x=0, y=0, allowing only positive y values but both negative and positive x values:

isometric map

Depending on the dimensions of your tiles the shape of the map may differ.

Let’s go step by step and implement loading map chunks in Phaser with isometric map.

Updating the environment

So we start with the create() method. We get the data from the master.json and we calculate useful constants.

const {
  chunkHeight,
  chunkWidth,
  mapHeight,
  mapWidth,
  nbChunksX,
  nbChunksY,
  tileHeight,
  tileWidth,
} = this.cache.json.get("master");

this.chunkHeight = chunkHeight;
this.chunkWidth = chunkWidth;
this.mapHeight = mapHeight;
this.mapWidth = mapWidth;
this.nbChunksX = nbChunksX;
this.nbChunksY = nbChunksY;
this.tileHeight = tileHeight;
this.tileWidth = tileWidth;

this.lastChunkID = this.nbChunksX * this.nbChunksY - 1;
this.chunkHalfWidth = (this.chunkWidth / 2) * this.tileWidth;
this.chunkHalfHeight = (this.chunkHeight / 2) * this.tileHeight;

Next we call this.updateEnvironment(); method for the first time. In this tutorial we will call updateEnvironment after every movement of the player.
In the beginning of the updateEnvironment method we get the player’s current chunkID:

const chunkID = this.computeChunkID(
  ...this.getChunkXYFromPlayerXY(this.player.x, this.player.y)
);

So we pass player’s x,y to the getChunkXYFromPlayerXY method. There we compute the tile of the player using the getTileFromXY method and we use it to compute the chunk XY (eg. chunk0 is X=0, Y=0 and chunk5 is X=1, Y=1 for our map which is 4 chunks width and 4 chunks height) which we pass to the final computeChunkID method.

The reasoning about the chunks IDs stays the same as Jerome explained it his article.

When we have player’s current chunkID we can get the array of chunks that the player should see

const chunks = getSurroundingTiles({
  positionTile: IDToXY({
    ID: chunkID,
    nbChunksX: this.nbChunksX,
    nbChunksY: this.nbChunksY,
  }),
  includeMainObject: true,
  endX: this.nbChunksX - 1,
  endY: this.nbChunksY - 1,
}).map(({ x, y }) => this.computeChunkID(x, y));

Details of the getSurroundingTiles I’m covering later.
If you want you can use Jerome’s listAdjacentChunks – the result will be the same.

Next steps are similar to the ones from Jerome’s original post.
We get newChunks and oldChunks:

const newChunks = findDiffArrayElements(chunks, this.displayedChunks); // Lists the surrounding chunks that are not displayed yet (and have to be)
const oldChunks = findDiffArrayElements(this.displayedChunks, chunks); // Lists the surrounding chunks that are still displayed (and shouldn't anymore)

and we iterate over the new (needing to be loaded) and old (needing to be removed) ones:

newChunks.forEach((chunk) => {
  console.log(`loading chunk${chunk}`);
  this.load.tilemapTiledJSON(
    `chunk${chunk}`,
    `assets/map/chunks/chunk${chunk}.json`
  );
});

if (newChunks.length > 0) {
  this.load.start(); // Needed to trigger loads from outside of preload()
}

oldChunks.forEach((chunk) => {
  console.log(`destroying chunk${chunk}`);
  this.removeChunk(chunk);
});

Displaying chunks

displayChunk method is called from the Phaser’s preload method whenever we load new chunks:

preload() {
  // We will be loading files on the fly, so we need to listen to events triggered when
  // a file (a tilemap, more specifically) is added to the cache
  this.cache.tilemap.events.on("add", (cache, key) => {
    this.displayChunk(key);
  });
  // ...
}

We are passing a key parameter (which is just a string, eg. chunk0) to the displayChunk method so we can make tilemap and add tileset images to it:

const map = this.make.tilemap({ key });
// The first parameter is the name of the tileset in Tiled and the second parameter is the key
// of the tileset image used when loading the file in preload.
const tiles = map.addTilesetImage("tilesheet_iso", "tiles");

Next we exctract chunkID from the passed key so we can calculate useful constants:

// We need to compute the position of the chunk in the world
const chunkID = parseInt(key.match(/\d+/)[0], 10); // Extracts the chunk number from file name
const chunkX = Math.floor(chunkID / this.nbChunksX);
const chunkY = chunkID % this.nbChunksX;
const isCenterChunk = (chunkID - chunkX) % this.nbChunksX;

Having all this informations we can determine where a chunk should be render on the screen.

For two dimensional maps in Phaser you can just do what Jerome did in his tutorial:

for(var i = 0; i < map.layers.length; i++) {
  // You can load a layer from the map using the layer name from Tiled, or by using the layer
  // index
  var layer = map.createStaticLayer(i, tiles, chunkX*32, chunkY*32);
  // Trick to automatically give different depths to each layer while avoid having a layer at depth 1 (because depth 1 is for our player character)
  layer.setDepth(2*i);
}

But for isometric map it’s a little bit more tricky. The algorithm that I came up with looks like this:

let offset;

if (isCenterChunk === 0) {
  offset = {
    x: 0,
    y: chunkX * this.chunkHalfWidth,
  };
} else if (chunkID < this.nbChunksX * chunkX + chunkX) {
  offset = {
    x:
      -(chunkX * this.chunkHalfWidth) +
      (chunkID % this.nbChunksX) * this.chunkHalfWidth,
    y:
      chunkX * this.chunkHalfHeight +
      (chunkID % this.nbChunksX) * this.chunkHalfHeight,
  };
} else {
  offset = {
    x: ((chunkY - chunkX) % this.nbChunksX) * this.chunkHalfWidth,
    y: (chunkX + chunkY) * this.chunkHalfHeight,
  };
}

Let’s break it down to pieces. Having in mind that our isometric map is a rotated square and that the chunks are indexed from 0, the layout looks like this:

Phaser needs to know where to render the chunk on the screen and the starting point of the chunk is its top corner.
Looking at the picture is easy to see that the chunks 0, 5, 10 and 15 have theirs starting points precisely at the X axis (marked with red dots).

So that’s the first part of our algorithm.

if (isCenterChunk === 0) {
  offset = {
    x: 0,
    y: chunkX * this.chunkHalfWidth,
  };
}

Next we check if the chunk is on the left side of the X axis

else if (chunkID < this.nbChunksX * chunkX + chunkX) {
  offset = {
    x:
      -(chunkX * this.chunkHalfWidth) +
      (chunkID % this.nbChunksX) * this.chunkHalfWidth,
    y:
      chunkX * this.chunkHalfHeight +
      (chunkID % this.nbChunksX) * this.chunkHalfHeight,
  };
}

if it’s not the center chunk or left chunk it must be the chunk on the right side

else {
  offset = {
    x: ((chunkY - chunkX) % this.nbChunksX) * this.chunkHalfWidth,
    y: (chunkX + chunkY) * this.chunkHalfHeight,
  };
}

At the end of the displayChunk method we create a layer named Ground passing the calculated offsets

map.createLayer("Ground", tiles, offset.x, offset.y);

this.maps[chunkID] = map;
this.displayedChunks.push(chunkID);

Summary

Working with big maps in Phaser may be frustrating but Jerome’s chunking script fixes the performance issues. If you want to use it with the isometric map you just need to do a few adjustments and you’re free to have fun working on your game.

Extras

Get surrounding tiles

Jerome by creating the listAdjacentChunks method achieved exactly what is needed – the method returns the array of the chunks surrounding given chunk. For my game I wanted to have a generic method that would give me a list of surrounding chunks (or tiles) given a certain configuration, e.g. give me the tiles around the object with different sizes (not occupying one tile but maybe many tiles) and give me not only the closest tiles but a range of them (sizeToIncrease).
If we want to include the main object we need to use the helper function getObjectTiles

const getObjectTiles = ({ positionTile, size = { x: 1, y: 1 } }) => {
  if (size.x <= 0 || size.y <= 0) {
    return [];
  }

  const { x, y } = positionTile;

  const objectTiles = [];

  for (let xi = x; xi < x + size.x; xi += 1) {
    for (let yj = y; yj < y + size.y; yj += 1) {
      objectTiles.push({ x: xi, y: yj });
    }
  }

  return objectTiles;
};

Going back to the getSurroundingTiles function, startX, startY, endX and endY are describing the size of our two dimensional board, eg. our map is 4x4 so our defaults are exactly for our map chunks.

const getSurroundingTiles = ({
  positionTile,
  size = { x: 1, y: 1 },
  sizeToIncrease = { x: 1, y: 1 },
  startX = 0,
  startY = 0,
  endX = 4,
  endY = 4,
  includeMainObject = false,
}) => {
  const { x, y } = positionTile;

  if (x < 0 || y < 0) {
    return [];
  }

  let objectTiles = [];

  if (!includeMainObject) {
    objectTiles = getObjectTiles({ positionTile, size });

    if (objectTiles.length === 0) {
      return [];
    }
  }

  const tiles = [];

  for (
    let xi = x - sizeToIncrease.x;
    xi < x + size.x + sizeToIncrease.x;
    xi += 1
  ) {
    for (
      let yj = y - sizeToIncrease.y;
      yj = startX &&
        xi = startY &&
        yj  xi === objectTile.x && yj === objectTile.y
        )
      ) {
        tiles.push({ x: xi, y: yj });
      }
    }
  }

  return tiles;
};

Each of our chunk is build from small tiles of size 64px x 32px and each chunk is 20 tiles width and 20 tiles height. So another purpose of this function may be calculating range of tiles around the building. Let say we have building of size 2x3 tiles and we want to get tiles surrounding that building.

getSurroundingTiles({
  positionTile: { x: 6, y: 4 }, // ID: 70
  size: { x: 2, y: 3 },
  sizeToIncrease: { x: 2, y: 2 },
  endX: 15,
  endY: 15,
});


Try it yourself here.

Splitter script performance issues with big maps

tl;dr
Try using updated version of the script.

When I started using the splitter script on a fairly small map (80×80) it was working perfectly fine. But when I tried it with the bigger map (800×800) – node process was hanging for a few minutes with no information what is going on until I ended it. I’ve added the console.log, I tried again and I saw chunking being created slowly
splitter script being slow
It took more than 11 minutes to create 1600 chunks on my machine. I decided to find out if I can speed it up a little bit. After profiling I could see that the cloning the map is the bottleneck. So I got rid of the clone library, did the cloning the simplest way I came up with and I did run the script again
splitter script working faster
Now creating 1600 chunks on my machine takes around 20 seconds and I’m fine with that.

 

About the author:
I’m Jakub, I live in Poland, I am front-end developer, you can find me at github.com/neu5