Talking about Sokoban game, Game development, HTML5, Javascript, Phaser and TypeScript.
One of the principles of coding is to build scripts as much reusable as you can. This is why I already built a pure JavaScript Sokoban class with no dependencies to handle Sokoban games.
This time I am showing you an even simpler TypeScript class to handle Sokoban games. It’s so simple you will be able to build a Sokoban game in less than 10 lines of code, and you’ll only need to manage user input and visual output.
Look at the game:
Move the character with ARROW keys.
If you want to solve the level, here is the walkthrough:
RDDLRUULDLDDLDDRURRUUULLDDLDRUUURRDLULDDLDDRUUURRDDLRUULLDLDDRU
Now, let’s have a look at the source code, made of one HTML file and 5 TypeScript files:
index.html
The webpage which hosts the game
<!DOCTYPE html>
<html>
<head>
<style type = "text/css">
* {
padding: 0;
margin: 0;
}
body{
background: #000;
}
canvas {
touch-action: none;
-ms-touch-action: none;
}
</style>
<script src = "main.js"></script>
</head>
<body>
<div id = "thegame"></div>
</body>
</html>
gameOptions.ts
All configurable game options are stored in this file. This time we only need one options, but you’ll never know
// CONFIGURABLE GAME OPTIONS
export const GameOptions = {
// size of each tile, in pixels
tileSize : 40
}
main.ts
This is where the game is instanced, with all Phaser related options
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame} from './playGame';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : 320,
height : 320
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0x262626,
scale : scaleObject,
scene : [PreloadAssets, PlayGame],
pixelArt : true
}
// the game itself
new Phaser.Game(configObject);
preloadAssets.ts
Here we preload all assets to be used in the game. Only one image this time, but again, you’ll never know.
// CLASS TO PRELOAD ASSETS
import { GameOptions } from "./gameOptions";
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be execute during class preloading
preload(): void {
this.load.spritesheet("tiles", "assets/tiles.png", {
frameWidth : GameOptions.tileSize,
frameHeight : GameOptions.tileSize
});
}
// method to be called once the instance has been created
create(): void {
// call PlayGame class
this.scene.start('PlayGame');
}
}
playGame.ts
This is the mail game file, when I handle user input and visual output.
I highlighted the Sokoban related lines, so you can see how it’s simple to build your own game starting from my TypeScript class:
// THE GAME ITSELF
// modules to import
import { GameOptions } from './gameOptions';
import { Sokoban } from './sokoban';
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
sokobanGame : Sokoban;
arrowKeys : Phaser.Types.Input.Keyboard.CursorKeys;
isPlayerMoving : boolean;
// constructor
constructor() {
super({
key: 'PlayGame'
});
}
// method to be called once the class has been created
create() : void {
// player is not moving
this.isPlayerMoving = false;
// Sokoban level in standard text notation
const levelString : string = '########\n#####@.#\n####.$$#\n#### $ #\n### .# #\n### #\n### ###\n########';
// create a new Sokoban instance
this.sokobanGame = new Sokoban();
// build the Sokoban level
this.sokobanGame.buildLevelFromString(levelString);
// iterate through all level actors
this.sokobanGame.actors.map((actor) => {
// add the sprite
let sprite : Phaser.GameObjects.Sprite = this.add.sprite(GameOptions.tileSize * actor.column, GameOptions.tileSize * actor.row, 'tiles', actor.type);
// set sprite registration point
sprite.setOrigin(0);
actor.data = sprite;
});
// initialize arrow keys
this.arrowKeys = this.input.keyboard.createCursorKeys();
}
update() : void {
// is the player moving?
if (this.isPlayerMoving) {
// are all arrow keys unpressed?
if (!this.arrowKeys.up.isDown && !this.arrowKeys.right.isDown && !this.arrowKeys.down.isDown && !this.arrowKeys.left.isDown) {
// player is no longer moving
this.isPlayerMoving = false;
}
}
// player is not moving
else {
// we store player move in this variable
let playerMove : (number | null) = null;
// is "up" arrow key pressed?
if (this.arrowKeys.up.isDown) {
// playerMove is up
playerMove = this.sokobanGame.up;
}
// same concept for right direction
if (this.arrowKeys.right.isDown) {
playerMove = this.sokobanGame.right;
}
// same concept for down direction
if (this.arrowKeys.down.isDown) {
playerMove = this.sokobanGame.down;
}
// same concept for left direction
if (this.arrowKeys.left.isDown) {
playerMove = this.sokobanGame.left;
}
// does player move have a value?
if (playerMove != null) {
// player is moving
this.isPlayerMoving = true;
// loop through all movements
this.sokobanGame.move(playerMove).map((move) => {
// set new position of the actor
move.actor.data.setPosition(GameOptions.tileSize * move.to.column, GameOptions.tileSize * move.to.row);
// set new frame of the actor
move.actor.data.setFrame(this.sokobanGame.getValueAt(move.to.row, move.to.column));
if (this.sokobanGame.solved) {
this.cameras.main.shake(500);
}
});
}
}
}
}
sokoban.ts
And finally the TypeScript class you can use in your projects, no matter the framework you are about to use.
It’s fully commented so I am sure you will find it quite clear:
// tile types: 0: floor, 1: wall, 2: goal, 3: crate, 4: player, 5 (3+2): crate on goal, 6 (4+2): player on goal
enum tileType {
FLOOR,
WALL,
GOAL,
CRATE,
PLAYER
}
// player direction: 0: up, 1: right, 2: down, 3: left
enum playerDirection {
UP,
RIGHT,
DOWN,
LEFT
}
// SOKOBAN CLASS
export class Sokoban {
// movement information mapping for up, right, down and left diretion
private movementInfo : SokobanCoordinate[] = [
new SokobanCoordinate(-1, 0),
new SokobanCoordinate(0, 1),
new SokobanCoordinate(1, 0),
new SokobanCoordinate(0, -1)
];
// possible string items according to sokoban level notation standard
private stringItems : string = ' #.$@*+';
// the player
private player : SokobanActor;
// the crates
private crates : SokobanActor [];
// the tiles
private tiles : SokobanActor[];
// the level
private level : number [][];
// constructor
constructor() {
// initialize all arrays
this.crates = [];
this.level = [];
this.crates = [];
this.tiles = [];
}
// method to build a level form a string
// argument: the string, which we assume to be correct
buildLevelFromString(levelString: string) : void {
// split the string in rows
let rows : string[] = levelString.split("\n");
// iterate through all rows
for (let i : number = 0; i < rows.length; i ++) {
// set level i-th row
this.level[i] = [];
// iterate through all columns (string's characters)
for (let j : number = 0; j < rows[i].length; j ++) {
// get tile value according to its position in stringItems string
let value = this.stringItems.indexOf(rows[i].charAt(j));
// set level value
this.level[i][j] = value;
// create the actors to be placed in this tile
this.createActors(i, j, value);
}
}
}
// method to create actors
// arguments: level row, level column and level value
private createActors(row : number, column : number, value : tileType) : void {
// a simple switch to handle different values
// it could be optimized but I prefer to show you how to do it case by case
switch (value) {
// floor, goal and wall are simple elements, as there is only one actor: the floor, the wall or the goal
case tileType.FLOOR :
case tileType.WALL :
case tileType.GOAL :
// add the actor to tiles array
this.tiles.push(new SokobanActor(row, column, value));
break;
// anything with a crate enters this block of code, now we have to split crate and floor type
case tileType.FLOOR + tileType.CRATE :
case tileType.GOAL + tileType.CRATE :
// add the actor below the crate in tiles array
this.tiles.push(new SokobanActor(row, column, value - tileType.CRATE));
// add the crate actor in crates array
this.crates.push(new SokobanActor(row, column, tileType.CRATE));
break;
// same concept is applied to the player
case tileType.FLOOR + tileType.PLAYER :
case tileType.GOAL + tileType.PLAYER :
this.tiles.push(new SokobanActor(row, column, value - tileType.PLAYER));
this.player = new SokobanActor(row, column, tileType.PLAYER);
break;
}
}
// getter to get "up" direction value
get up() : number {
return playerDirection.UP;
}
// getter to get "down" direction value
get down() : number {
return playerDirection.DOWN;
}
// getter to get "left" direction value
get left() : number {
return playerDirection.LEFT;
}
// getter to get "right" direction value
get right() : number {
return playerDirection.RIGHT;
}
// getter to get all Sokoban actors
get actors() : SokobanActor[] {
// for a matter of z-indexing, first I return all floor tiles, then all crates
let actorsArray : SokobanActor[] = this.tiles.concat(this.crates);
// finally, the player is added
actorsArray.push(this.player);
return actorsArray;
}
// method to get a tile value
// arguments: the row and the column
getValueAt(row : number, column: number) : number {
return this.level[row][column];
}
// method to check if the level is solved
get solved() : boolean {
// we don't want to find crates
return this.level.findIndex(row => row.includes(tileType.CRATE)) == -1;
}
// method to move the player, if possible, and return an array of movements
// argument: the direction
move(direction: playerDirection) : SokobanMovement[] {
// array to store movements
let movements : SokobanMovement[] = [];
// check if it's a legal move
if (this.canMove(direction)) {
// determine player destination
let playerDestination : SokobanCoordinate = new SokobanCoordinate(this.player.row + this.movementInfo[direction].row, this.player.column + this.movementInfo[direction].column);
// loop through all crates
this.crates.forEach ((crate : SokobanActor) => {
// if there is a crate on destination tile...
if (crate.row == playerDestination.row && crate.column == playerDestination.column) {
// determine crate destination
let crateDestination : SokobanCoordinate = new SokobanCoordinate(this.player.row + 2 * this.movementInfo[direction].row, this.player.column + 2* this.movementInfo[direction].column);
// move the crate
movements.push(this.moveActor(crate, new SokobanCoordinate(crate.row, crate.column), crateDestination));
}
});
// move the player
movements.push(this.moveActor(this.player, new SokobanCoordinate(this.player.row, this.player.column), playerDestination));
}
// return movements array
return movements;
}
// method to check if a tile is walkable
// arguments: tile row and column
private isWalkableAt(row : number, column : number) : boolean {
// tile is walkable if it's a floor or a goal
return this.getValueAt(row, column) == tileType.FLOOR || this.getValueAt(row, column) == tileType.GOAL;
}
// method to check if there is a crate on a tile
// arguments: tile row and column
private isCrateAt(row : number, column : number) : boolean {
// there's a crate if the tile is a crate or the tile is a crate over the goal
return this.getValueAt(row, column) == tileType.CRATE || this.getValueAt(row, column) == tileType.CRATE + tileType.GOAL;
}
// method to check if there is a pushable crate on a tile
// arguments: tile row and column, and movement direction
private isPushableCrateAt(row : number, column: number, direction : playerDirection) : boolean {
// there's a pushable crate if there is a crate and the destination tile is walkable
return this.isCrateAt(row, column) && this.isWalkableAt(row + this.movementInfo[direction].row, column + this.movementInfo[direction].column);
}
// method to check if the player can move in a given direction
// argument: the direction
private canMove(direction : playerDirection) : boolean {
// determine destination row and column
let destinationRow : number = this.player.row + this.movementInfo[direction].row;
let destinationColumn : number = this.player.column + this.movementInfo[direction].column;
// player can move if destination tile is walkable or is a pushable crate
return this.isWalkableAt(destinationRow, destinationColumn) || this.isPushableCrateAt(destinationRow, destinationColumn, direction);
}
// method to move an actor
// arguments: the actor, the starting tile and the destination tile
private moveActor(actor : SokobanActor, from : SokobanCoordinate, to : SokobanCoordinate) : SokobanMovement {
// move the actor
actor.moveTo(to.row, to.column);
// adjust level values
this.level[from.row][from.column] -= actor.type;
this.level[to.row][to.column] += actor.type;
// return movement information
return new SokobanMovement(actor, from, to);
}
}
// SOKOBAN ACTOR CLASS
class SokobanActor {
// actor customizable data
data : any;
// actor position
private position : SokobanCoordinate;
// actor tile type
private _type : tileType;
// constructor
// arguments: row, column and tile type
constructor(row : number, column : number, type: tileType) {
this.position = new SokobanCoordinate(row, column);
this._type = type;
}
// get type of the actor
get type() : tileType {
return this._type;
}
// is the actor a crate?
get isCrate() : boolean {
return this._type == tileType.CRATE;
}
// is the actor the player?
get isPlayer() : boolean {
return this._type == tileType.PLAYER;
}
// get actor column
get column() : number {
return this.position.column;
}
// get actor row
get row() : number {
return this.position.row;
}
// method to move the actor
// arguments: row and column
moveTo(row : number, column : number) : void {
this.position.setCoordinate(row, column);
}
}
// SOKOBAN MOVEMENT CLASS
class SokobanMovement {
// actor to move
actor : SokobanActor;
// current coordinate
from : SokobanCoordinate;
// destination coordinate
to : SokobanCoordinate;
// constructor
// arguments: the actor, current coordinate, destination coordinate
constructor(actor : SokobanActor, from : SokobanCoordinate, to : SokobanCoordinate) {
this.actor = actor;
this.from = new SokobanCoordinate(from.row, from.column);
this.to = new SokobanCoordinate(to.row, to.column);
}
}
// SOKOBAN COORDINATE CLASS
class SokobanCoordinate {
// row and column, just two values to use as x,y coordinates
private _row : number;
private _column : number;
constructor(row : number, column: number) {
this._row = row;
this._column = column;
}
// get row
get row() : number {
return this._row
}
// get column
get column() : number {
return this._column;
}
// method to set coordinate
// arguments: row and column
setCoordinate(row : number, column : number) : void {
this._row = row;
this._column = column;
}
}
Now, I will build a complete Sokoban game with levels starting from this class. Follow me on Twitter to stay up to date, and download the source code to start creating your own Sokoban game.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.