Sokoban Series: Part 2, Creating an Interactive Level

In this episode we will transform our tilemap from the previous episode into a map with different layers and sprites. This gives us a foundation to implement our game logic and to easily create new maps. Next to that, we will play around with the camera to render our map in the center of the canvas, so that maps with different sizes get rendered nicely.

Object Classes

I want to take a static definition of our game world, a tilemap, and convert it into something
dynamic with sprites that we can play with. In sokoban we have the following objects:

  • Walls
  • Floor
  • Goals
  • Crates
  • Our hero

The first three objects are static and will never change when playing, while the last two are dynamic. As the first three are static and don’t contain that much logic we can simply represent them using tiles and tilemaps. The dynamics ones can be represented using sprites. We discuss the latter two.

Player Sprite

Let’s open our editor and create a Player sprite. Most Phaser tutorials or documents that I have seen just use the base sprite class and modify its state externally. I find it nicer to have specialized classes for my objects that extends the sprite class.

Create a sprites directory and player.ts inside that directory. For now our player won’t do much except be drawn on the screen. We’ll add animations later.

1
2
3
4
5
6
7
8
9
10
11
import * as Phaser from 'phaser';

const START_FRAME = 'Player/player_05'; // depends on your tilesheet, check out the JSON

class Player extends Phaser.GameObjects.Sprite {
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, 'assets', START_FRAME);
}
}

export default Player;

Our Player class doesn’t do much, besides calling the Sprite constructor with START_FRAME. This is the sprite that will be used to draw our player initially. Open assets.json to find the correct name.

Crate Sprite

Similar to our player we will create a crate sprite class as well:

1
2
3
4
5
6
7
8
9
const START_FRAME = 'Crates/crate_12';

class Crate extends Phaser.GameObjects.Sprite {
constructor(scene: Phaser.Scene, x: number, y: number, private crateType: number) {
super(scene, x, y, 'assets', crateTypeToAsset(crateType));
}
}

export default Crate;

Creating our Level in Tiled

In sokoban every square can only contain a single item: a square is either a floor, a goal, a wall, a crate or contains the player. In theory we could represent the whole map as a single layer. I prefer to have multiple layers, where each layer contains a single object type.

Multiple layers also enable us to diversify the look of our level. With a single layer we would not be able to specify the looks of the floor under a crate. It also makes it a bit easier to get all items of a certain type, as they are all in a single layer.

We create a floor, crates, goal, spawn and walls layer. The spawn layer contains a single sprite that indicates the starting position of the player. By making it a separate layer we can simply not render that layer in our game, but still encode the information in our map.

tiled-layer

Creating our Level in Phaser

So, let’s import our level into Phaser. Instead of using our MainScene from earlier we’ll create a dedicated LevelScene.

This is (part of) the code:

1
2
3
4
5
6
7
8
9
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import * as Phaser from 'phaser';
import Crate from '../sprites/crate';
import Player from '../sprites/player';

class LevelScene extends Phaser.Scene {

private player: Player;
private crates: Phaser.GameObjects.Group;

// tilemap
private tileSet: Phaser.Tilemaps.Tileset;
private tileMap: Phaser.Tilemaps.Tilemap;

// layers
private floorLayer: Phaser.Tilemaps.DynamicTilemapLayer;
private wallLayer: Phaser.Tilemaps.StaticTilemapLayer;
private spawnLayer: Phaser.Tilemaps.StaticTilemapLayer;
private goalLayer: Phaser.Tilemaps.StaticTilemapLayer;
private crateLayer: Phaser.Tilemaps.StaticTilemapLayer;

constructor() {
super({
key: 'LevelScene',
});
}

preload() {
this.load.tilemapTiledJSON('level01', `./assets/levels/level01.json`);
}

create() {
this.tileMap = this.make.tilemap({ key: 'level01' });
this.tileSet = this.tileMap.addTilesetImage('assets');
this.createLevel();
this.createPlayer();
}

private createLevel() {
this.createLayers();
this.createCrates();
}

private createLayers() {
const x = 0;
const y = 0;
this.spawnLayer = this.tileMap.createStaticLayer('Spawns', this.tileSet, x, y);
this.spawnLayer.setVisible(false);
this.crateLayer = this.tileMap.createStaticLayer('Crates', this.tileSet, x, y);
this.crateLayer.setVisible(false);
this.floorLayer = this.tileMap.createStaticLayer('Floors', this.tileSet, x, y);
this.wallLayer = this.tileMap.createStaticLayer('Walls', this.tileSet, x, y);
this.goalLayer = this.tileMap.createStaticLayer('Goals', this.tileSet, x, y);
}

private createCrates() {
const crateTiles = this.getTiles((tile) => {
return tile.index > -1;
}, this.crateLayer);
const crateSprites = crateTiles.map((tile) => {
const { x, y } = this.tileMap.tileToWorldXY(tile.x, tile.y);
const { type } = tile.properties as { type: number };
const crate = new Crate(this, x, y, type);
this.add.existing(crate);
return crate;
});
this.crates = this.add.group(crateSprites);
}

private createPlayer() {
const playerSpawn = this.getSpawn();
const { x, y } = this.tileMap.tileToWorldXY(playerSpawn.x, playerSpawn.y);
this.player = new Player(this, x, y);
this.add.existing(this.player);
}
}

export default LevelScene;

That’s a lot. Let’s break it down. In our preload function we load our tiled map data, and in our create funcion we’ll create the tile map. This is similar to our previous example. We turn our spawn and crate layer invisible. We don’t want to render our spawn, we just need it to know where our player starts. Our crates won’t be represented by tiles. Instead, we will use the Crate sprite we created earlier.

The function createCrates will create all the crates. It uses a helper function getTiles to filter all the tiles of certain layer. We’ll find all the tiles of the crateLayer that have a positive index: Phaser either represents a non-existing tile as null, or can provide a stub tile with a negative index (but that still has an x and y coordinate etc.). We take all the tiles that have a crate, and create an instance of a Crate sprite. These are added to the Crate group. Note that crates use world coordinates, while tiles use tile map coordinates. World coordinates are the actual coordinates that are used to draw on the screen. Tile map coordinates are better suited for tile maps: the topleft tile has coordinate (0,0), the tile next to that (1,0) etc.

We create our player in a similar way.

So, how does getTiles and getSpawn work? Here is the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class LevelScene {
...
private getSpawn() {
const spawns = this.getTiles((tile) => {
return tile.index > - 1
}, this.spawnLayer);
if (spawns.length !== 1) {
throw new Error(`[LevelScene] Expected single spawn`);
}
return spawns[0];
}

private getTiles(
test: (tile: Phaser.Tilemaps.Tile) => boolean,
layer?: Phaser.Tilemaps.StaticTilemapLayer
): Phaser.Tilemaps.Tile[] {
this.tileMap.setLayer(layer || this.floorLayer);
return this.tileMap.filterTiles((tile: Phaser.Tilemaps.Tile) => {
return test(tile);
}, this, 0, 0, this.tileMap.width, this.tileMap.height);
}
}

getTiles uses filterTiles to filter out all the tiles of a tilemap. A tilemap has a current layer against which all operations run. So, we first set the active layer to the given layer (or the floor layer). filterTiles allows you to define a rectangle on which the filter operates. We always want to full tile map, so we start in the topleft corner (0,0), and have a rectangle with width tileMap.width and height tileMap.height.

Next I want to make the distinction between tiles clearer, so it is easier for our players to navigate the gridlike map. We draw a horizontal and vertical grid with dotted lines. We create a generic function addGridLine that can draw horizontal and vertical dotted lines, and use this function to draw our grid.

Here is the code:

1
2
3
4
5
6
7
8
9
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Level {
...
private createGridLines() {
const lineLength = 6;
const skipLength = 2;
this.addVerticalLines(lineLength, skipLength);
this.addHorizontalLines(lineLength, skipLength);
}

private addVerticalLines(lineLength: number, skipLength: number) {
let currentX = 0;
const stopX = this.tileMap.widthInPixels;
const stopY = this.tileMap.heightInPixels;
const next = (x: number, y: number) => ({ x: x, y: y + lineLength });
const skip = (x: number, y: number) => ({ x: x, y: y + skipLength });
const stop = (x: number, y: number) => y >= stopY;
while (currentX <= stopX) {
this.addGridLine(currentX, 0, next, skip, stop);
currentX += this.tileMap.tileWidth;
}
}

private addHorizontalLines(lineLength: number, skipLength: number) {
let currentY = 0;
const stopX = this.tileMap.widthInPixels;
const stopY = this.tileMap.heightInPixels;
const next = (x: number, y: number) => ({ x: x + lineLength, y: y });
const skip = (x: number, y: number) => ({ x: x + skipLength, y: y });
const stop = (x: number, y: number) => x >= stopX;
while (currentY <= stopY) {
this.addGridLine(0, currentY, next, skip, stop);
currentY += this.tileMap.tileHeight;
}
}

private addGridLine(
startX: number,
startY: number,
next: (x: number, y: number) => { x: number, y: number },
skip: (x: number, y: number) => { x: number, y: number },
stop: (x: number, y: number) => boolean,
) {
let currentX = startX;
let currentY = startY;
const line = this.add.graphics({
x: 0, y: 0,
lineStyle: { width: 1, alpha: 0.5, color: 0x000000 },
fillStyle: { color: 0x000000, alpha: 1 },
});
line.beginPath();
line.moveTo(startX, startY);
while (!stop(currentX, currentY)) {
const { x: nextX, y: nextY } = next(currentX, currentY);
line.lineTo(nextX, nextY);
const { x: skipX, y: skipY } = skip(nextX, nextY);
line.moveTo(skipX, skipY);
currentX = skipX;
currentY = skipY;
}
line.closePath();
line.strokePath();
line.fillPath();
}
}

Centering our Map

We currently draw our map in the top left corner of our canvas. It would be nicer if our map would be drawn in the center, regardless of its size. We can do this using the Phaser 3 camera system, which got a nice revamp. I’ll be honest that it took me a while to get this working, and I noticed that on older versions of chrome this doesn’t work properly.

So, this code renders our game in the center of the canvas:

1
2
3
4
5
6
7
8
9
10
11
class LevelScene {
private centerCamera() {
const bounds = this.physics.world.bounds;
const x = bounds.width / 2 - this.tileMap.widthInPixels / 2;
const y = bounds.height / 2 - this.tileMap.heightInPixels / 2;
this.cameras.remove(this.cameras.main);
const camera = this.cameras.add(x, y, this.tileMap.widthInPixels, this.tileMap.heightInPixels, true);
camera.setOrigin(0, 0);
camera.setScroll(0, 0);
}
}

It works by taking the bounds of our world, in other terms the size of our canvas. We compute the center point, and then offset that point with the half of our tilemap. x and y point to the top left corner of where we want to draw our tile map.

Next, we remove the main camera. Don’t ask me why. If I simply updated the main camera the code didn’t work. It’s probably a mistake on my part, but I just moved on. We can’t simply remove the main camera without adding a new main camera, so we do that. We create a camera that projects onto x and y with the width and height of our map, but which ‘looks’ at the topleft corner. So, we can just keep on using (0,0) as the topleft corner of our game, while the camera renders everything in the center.

In the final version I added some text (“Level Complete”) that grows when you complete a level. The text was cut of on the left and right side as the growing effect fell outside the viewport of our camera. To solve that we simply let the camera render from -100 with a width of widthInPixels + 200, and set the scroll to (-100, 0).

Conclusion

In this episode we converted our plain tilemap into different layers and sprites, and centered our game into the middle of the camera. Our sprites don’t do much… yet. That is for the next episode.