Sokoban Series: Part 3, Handling Input and Game Logic

In this episode we will handle keyboard and touch input to move our player through the world. We will add the game logic that checks for valid moves and that verifies whether the win condition was met.

Input

We want to add two kinds of input: keyboard input for desktop users, and touch input for mobile users.

We will start with keyboard input:

Keyboard Input

There are two ways to control the player character: either you move him around using the arrow keys, or alternatively you move around using WASD. French keyboards have an azerty layout, and would move around using ZQSD. As far as I know, there is no way to know the keyboard layout of the player. Luckily, as we only need these four keys, we can just let the player move forward using either Z or W, and move left using either Q or A. For other games this approach may not be viable.

We create a function getPlayerDirection() that returns either the direction our player’s character must move to, or null when no key was touched. 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
export type PlayerDirection = 'playerLeft' | 'playerRight' | 'playerUp' | 'playerDown';

class LevelScene extends Phaser.Scene {
private leftKeys: Phaser.Input.Keyboard.Key[];
private rightKeys: Phaser.Input.Keyboard.Key[];
private upKeys: Phaser.Input.Keyboard.Key[];
private downKeys: Phaser.Input.Keyboard.Key[];

private createInputHandler() {
this.leftKeys = [Phaser.Input.Keyboard.KeyCodes.LEFT, Phaser.Input.Keyboard.KeyCodes.A, Phaser.Input.Keyboard.KeyCodes.Q].map((key) => {
return this.input.keyboard.addKey(key);
});
this.rightKeys = [Phaser.Input.Keyboard.KeyCodes.RIGHT, Phaser.Input.Keyboard.KeyCodes.D].map((key) => {
return this.input.keyboard.addKey(key);
});
this.upKeys = [Phaser.Input.Keyboard.KeyCodes.UP, Phaser.Input.Keyboard.KeyCodes.W, Phaser.Input.Keyboard.KeyCodes.Z].map((key) => {
return this.input.keyboard.addKey(key);
});
this.downKeys = [Phaser.Input.Keyboard.KeyCodes.DOWN, Phaser.Input.Keyboard.KeyCodes.S].map((key) => {
return this.input.keyboard.addKey(key);
});
}

private getPlayerDirection(): PlayerDirection | null {
if (this.leftKeys.some((key) => key.isDown)) {
return 'playerLeft';
}
if (this.rightKeys.some((key) => key.isDown)) {
return 'playerRight';
}

if (this.upKeys.some((key) => key.isDown)) {
return 'playerUp';
}
if (this.downKeys.some((key) => key.isDown)) {
return 'playerDown';
}
return null;
}
}

The handling of keyboard input is well documented in Phaser, and you can find plenty of examples on their example page. Mobile input is trickier, and will be discussed next.

Touch Input

For mobile we want the user to move the ingame character by tapping the screen. We have two options.

  • The first option is to move the character by tapping the sides of the scree. So when the user taps the top of the screen the player moves up, left to move left etc.
  • The second option was to have movement relative to the position of the player. So when the user taps to the left of the player’s character, he moves left.

The first version of our game implemented the first option, but users preferred relative movement as it felt more natural. One of the parts that was difficult to get right with the first option was to find a cutoff that felt natural. What happens when a user presses the center of the screen? What happens when you press the topleft corner of the screen? In our opinion there is no correct answer, and it just shows that this option for movement is not the correct on. It was also hard to get the boundaries right for different screen sizes.

This is the code for the first option:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class LevelScene extends Phaser.Scene {
private getDirectionFromInput(currentTime: number): PlayerDirection {
const bounds = this.physics.world.bounds;
const pointer = this.input.pointer1;
const isHorizontalCentered = pointer.x > bounds.right / 4 && pointer.x < bounds.right * 3 / 4;
const isLeft = pointer.x < bounds.left + WIDTH / 3;
const isRight = pointer.x > bounds.right - WIDTH / 4;
const isTop = pointer.y < bounds.top + HEIGHT / 2;
const isBottom = pointer.y > bounds.bottom - HEIGHT / 2;
if (isLeft && isVerticalCentered) {
return 'playerLeft';
}
if (isRight && isVerticalCentered) {
return 'playerRight';
}
if (isTop && isHorizontalCentered) {
return 'playerUp';
}
if (isBottom && isHorizontalCentered) {
return 'playerDown';
}
return null;
}
}

The better way is to have movement relative to the player position. We just need to compute the angle between the location of the player and the location of the pointer. You can use Phaser.Math.Angle to compute the angle between two points. We then move the player right when the angle is between +45 degrees and -45 degrees, up when it is between +45 and +135 degrees etc. To ensure we get angles between 0 and Math.PI * 2 you can use Phaser.Math.Angle.Normalize.

There is still one tricky part. Remember that we centered the playing field in our canvas. Thus, when we ask the coordinates of our player, we get the in-game coordinate, but not the one that is rendered on screen. The touch input on the other hand returns on-screen coordinates. Luckily, you can use
camera.getWorldPoint(x,y) to convert screen coordinates to world coordinates. A second mistake we made is that in classic mathematics the y-axis goes upwards, while the in-game y-axis goes down. A simple mistake that did cost us a head scratch :)

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
class LevelScene extends Phaser.Scene {
private getDirectionFromInput(currentTime: number): PlayerDirection {
const pointer = this.input.pointer1;

if (!pointer.isDown) {
return null;
}

// we want users to press for every movement, and not keep their finger down
if (currentTime - pointer.downTime > 400) {
return null;
}

const playerCenter = this.player.getCenter();
const pointerWorld = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
const angle = Phaser.Math.Angle.Normalize(Phaser.Math.Angle.Between(playerCenter.x, playerCenter.y, pointerWorld.x, pointerWorld.y, ));
const quarter = Math.PI / 4;
if (angle < quarter || angle >= (Math.PI + quarter * 3)) {
return 'playerRight';
}
if (angle >= quarter && angle < quarter * 3) {
return 'playerDown';
}
if (angle >= quarter * 3 && angle < quarter * 5) {
return 'playerLeft';
}
if (angle >= quarter * 5 && angle < Math.PI + quarter * 3) {
return 'playerUp';
}
return null;
}
}

Game Logic

Now that we have input we need to define the game logic. In theory we could do this using the physics system of Phaser: we can define collisions between the player and a crate, and let the player move the crates in this manner. The downside of this approach is that players need pixel perfect precision to place their crates, or they will become stuck behind walls.

Given that the gameplay of Sokoban is so simple we can just implement everything ourselves without using the physics system.

What are the things we need to do?

  • We need to check whether a square is free. A square is free when it is within bounds, does not contain a wall and does not contain a crate.
    This is needed to check whether our player can move to that location, or whether a crate can be moved to that location.
  • We need to check whether a crate can be moved to the next tile, and what the next tile is.
  • Do the actual moving of the player and the crate.

So first let’s write a helper function getCrateAt(tileX, tileY) that returns the crate at the given position, or null when no crate is on that location. Keep in mind that our crates are represented using coordinates, while a tilemap refers to tile numbers. Luckily, you can simply use tileToWorldXY() to convert coordinates to a tile.

1
2
3
4
5
6
7
8
9
10
11
12
13
class LevelScene extends Phaser.Scene {
private getCrateAt(tileX: number, tileY: number): Crate | null {
const allCrates = this.crates.getChildren() as Crate[];
const { x, y } = this.tileMap.tileToWorldXY(tileX, tileY);
const crateAtLocation = allCrates.filter((crate) => {
return crate.x === x && crate.y === y;
});
if (crateAtLocation.length === 0) {
return null;
}
return crateAtLocation[0];
}
}

Next, let’s write checkIfLocationIsFree(tileX, tileY):

1
2
3
4
5
6
7
8
9
10
11
12
13
class LevelScene extends Phaser.Scene {
private checkIfLocationIsFree(tileX: number, tileY: number): boolean {
if (tileX < 0 || tileX >= this.tileMap.width || tileY < 0 || tileY >= this.tileMap.height) {
return false;
}
const hasWall = !!this.wallLayer.getTileAt(tileX, tileY);
if (hasWall) {
return false;
}
const crate = this.getCrateAt(tileX, tileY);
return !crate;
}
}

We need to check ‘the next tile’ at several loations:

  • Whenever a player moves we need to know what his target tile will be,
  • Whenever a crate is moved we need to to see whether the next location is free.

To facilitate this we create getNextTile(startTile, direction, layer) that gets the next tile of startTile based on direction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class LevelScene extends Phaser.Scene {
private getNextTile(
start: Phaser.Tilemaps.Tile,
direction: PlayerDirection,
layer: Phaser.Tilemaps.DynamicTilemapLayer | Phaser.Tilemaps.StaticTilemapLayer,
): Phaser.Tilemaps.Tile {
const { x, y } = start;
switch (direction) {
case 'playerDown':
return layer.getTileAt(x, y + 1);
case 'playerUp':
return layer.getTileAt(x, y - 1);
case 'playerLeft':
return layer.getTileAt(x - 1, y);
case 'playerRight':
return layer.getTileAt(x + 1, y);
default:
throw new Error('Unexpected direction');
}
}
}

Let’s add everything together, and create our movement logic:

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
class LevelScene extends Phaser.Scene {
private updatePlayer(direction: PlayerDirection | null) {
if (!direction) {
return;
}
this.troToMovePlayer(direction);
}

private troToMovePlayer(direction: PlayerDirection) {
const { x, y } = this.player;
const currentTile = this.tileMap.getTileAtWorldXY(x, y, true);
const wall = this.getNextTile(currentTile, direction, this.wallLayer, true);
if (!wall) { // out of bounds
return;
}
if (wall.index > -1) {
return;
}
const floor = this.getNextTile(currentTile, direction, this.floorLayer);
const crate = this.getCrateAt(wall.x, wall.y);

if (crate) {
const oneFurther = this.getNextTile(wall, direction, this.floorLayer);
if (!oneFurther.index < 0) { // out of bounds
return false;
}
if (!this.checkIfLocationIsFree(oneFurther.x, oneFurther.y)) {
return false;
}
}
this.movePlayer(wall.x, wall.y);
}

private checkIfLocationIsFree(tileX: number, tileY: number): boolean {
if (tileX < 0 || tileX >= this.tileMap.width || tileY < 0 || tileY >= this.tileMap.height) {
return false;
}
const hasWall = !!this.wallLayer.getTileAt(tileX, tileY);
if (hasWall) {
return false;
}
const crate = this.getCrateAt(tileX, tileY);
return !crate;
}

private getCrateAt(tileX: number, tileY: number): Crate | null {
const allCrates = this.crates.getChildren() as Crate[];
const { x, y } = this.tileMap.tileToWorldXY(tileX, tileY);
const crateAtNewLocation = allCrates.filter((crate) => {
return crate.x === x && crate.y === y;
});
if (crateAtNewLocation.length === 0) {
return null;
}
return crateAtNewLocation[0];
}

private getNextTile(
start: Phaser.Tilemaps.Tile,
direction: PlayerDirection,
layer: Phaser.Tilemaps.DynamicTilemapLayer | Phaser.Tilemaps.StaticTilemapLayer,
): Phaser.Tilemaps.Tile {
const { x, y } = start;
switch (direction) {
case 'playerDown':
return layer.getTileAt(x, y + 1);
case 'playerUp':
return layer.getTileAt(x, y - 1);
case 'playerLeft':
return layer.getTileAt(x - 1, y);
case 'playerRight':
return layer.getTileAt(x + 1, y);
default:
throw new Error('Unexpected direction');
}
}

private movePlayer(tileX: number, tileY: number) {
const { x, y } = this.tileMap.tileToWorldXY(tileX, tileY);
const crate = this.getCrateAt(tileX, tileY);
if (crate) {
// not the prettiest way, we simply move the crate along the same direction
// knowing that the next spot is available
const crateX = crate.x + x - this.player.x;
const crateY = crate.y + y - this.player.y;
const newTile = this.floorLayer.getTileAtWorldXY(crateX, crateY);
crate.moveTo(crateX, crateY, newTile);
}
this.player.moveTo(x, y);
}
}

Next, we implement our Player moveTo(). We use animations to let our character move, and use a tween to move our player naturally. By using the onComplete handler we keep track of whether our playing is still moving or not.

The code to move a crate is done in a similar fashion.

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
class Player extends Phaser.GameObjects.Sprite {
moveTo(newX: number, newY: number) {
const direction = this.getDirection(newX, newY);
if (!direction) {
return;
}
this.state = 'moving';
this.scene.tweens.add({
targets: this,
x: newX,
y: newY,
duration: GAME_SPEED,
onStart: this.onMoveStart,
onStartParams: [direction],
onComplete: this.onMoveComplete,
});
}

private onMoveStart = (tween: Phaser.Tweens.Tween, object: Phaser.GameObjects.GameObject, direction: PlayerDirection) => {
this.state = 'moving';
this.anims.play(direction);

if (this.idleTimer) {
this.idleTimer.destroy();
}
}

private onMoveComplete = () => {
this.state = 'standing';
this.anims.stop();

// in the final version we have undos and restarts that destroys our player
// this check ensures that the callback doesnt crash
if (!this.scene) {
return;
}

// reset player to start frame after movement is complete
this.idleTimer = this.scene.time.delayedCall(500, () => {
this.setFrame(START_FRAME);
this.idleTimer = null;
}, [], this);
}

private getDirection(newX: number, newY: number): PlayerDirection | null {
if (newX > this.x) {
return 'playerRight';
}
if (newX < this.x) {
return 'playerLeft';
}
if (newY > this.y) {
return 'playerDown';
}
if (newY < this.y) {
return 'playerUp';
}
return null;
}

Game Finished Logic

The final functionality we need to implement is the check whether the game is complete. We check that every goal has a crate. However, we implemented this in an illogical fashion: instead of looping over all the goals we loop over all the crates, and ensure that the number of crates in the right position matches the number of goals. Looking back, looping over the goals is a lot more logical, but we never got around to rewrite it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class LevelScene extends Phaser.Scene {
private hasPlayerWon(): boolean {
return this.allCratesInCorrectLocation();
}

private allCratesInCorrectLocation(): boolean {
const crates = this.crates.getChildren() as Crate[];
const inCorrectLocation = crates.filter((crate) => {
const goal = this.goalLayer.getTileAtWorldXY(crate.x, crate.y);
if (!goal) {
return false;
}
return crate.onCorrectLocation(goal);
});
const goalTiles = this.getTiles((tile) => {
return tile.index > 1;
}, this.goalLayer);
return inCorrectLocation.length === goalTiles.length;
}
}

The crate.onCorrectLocation(goal) function simply checks whether the coordinates of the goal and the crate match.

Conclusion

We took our game with layers and sprites and added input handling. This enables us to move our player and crates in the game. We also added the logic of the game: we check whether crates can be moved, and whether the level is completed or not. Creating the game is now simply a matter of calling the right functions in your update loop, which we will leave up to you. In the next episode we will create a game menu using React on top of our game.