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.