A Simple Sokoban Game in JavaScript

The code below implements a simple “Sokoban” game in JavaScript. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript. Or, for an online version, visit https://thiscouldbebetter.neocities.org/sokoban.html

The player is represented by the cyan square, and the gray squares represent walls. The player can be moved around with the W, A, S, and D keys. The goal of the game is to push the sliders (brown squares) onto the targets (green squares). The sliders can only be pushed, not pulled, and they can only move if there’s not a wall or another slider in the way. If a slider gets pushed against the outside wall, it’s stuck there, since the player can’t get behind it to push it off. For more information about Sokoban, see its Wikipedia article.

Sokoban

<html>
<body>

<div id="divMain"></div>

<script type="text/javascript">

// main

function SlidingPuzzleGame()
{
	this.main = function()
	{
		var mapTerrains = 
		[
			// name, symbol, color, isPassable, isMovable
			new MapTerrain("Goal", 		"+", "LightGreen", 	true, false),
			new MapTerrain("Floor", 	".", "Black",		true, false),
			new MapTerrain("Player",	"p", "Cyan", 		false, true),
			new MapTerrain("Slider", 	"o", "Brown", 		false, true),
			new MapTerrain("Wall", 		"x", "LightGray", 	false, false),
		];

		var level0 = new Level
		(
			"Level 0",
			new Map
			(
				mapTerrains,
				[
					"xxxxxxxxxxxx",
					"x..........x",
					"x.p........x",
					"x.....x....x",
					"x.....x..+.x",
					"x.....x....x",
					"x..o..x..+.x",
					"x.....x....x",
					"x..o..x....x",
					"x..........x",
					"x..........x",
					"xxxxxxxxxxxx",
				]
			)
		);

		Globals.Instance.initialize 
		(
			new Coords(120, 120), // viewSizeInPixels
			level0
		);
	}
}

// extensions

function ArrayExtensions()
{
	// do nothing
}
{
	Array.prototype.addLookups = function(keyName)
	{
		for (var i = 0; i < this.length; i++)
		{
			var item = this[i];
			var key = item[keyName];
			this[key] = item;
		}
	}
}

// classes

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	Coords.prototype.add = function(other)
	{
		this.x += other.x;
		this.y += other.y;
		return this;
	}

	Coords.prototype.clone = function()
	{
		return new Coords(this.x, this.y);
	}

	Coords.prototype.divide = function(other)
	{
		this.x /= other.x;
		this.y /= other.y;
		return this;
	}

	Coords.prototype.equals = function(other)
	{
		var returnValue = 
		(
			this.x == other.x 
			&& this.y == other.y
		);
		return returnValue;
	}

	Coords.prototype.multiply = function(other)
	{
		this.x *= other.x;
		this.y *= other.y;
		return this;
	}

	Coords.prototype.overwriteWith = function(other)
	{
		this.x = other.x;
		this.y = other.y;
		return this;
	}
}

function DisplayHelper()
{
	// do nothing
}
{
	DisplayHelper.prototype.clear = function()
	{
		this.graphics.fillStyle = "White";
		this.graphics.fillRect
		(
			0, 
			0,
			this.viewSizeInPixels.x,
			this.viewSizeInPixels.y
		);

		this.graphics.strokeStyle = "Gray";
		this.graphics.strokeRect
		(
			0, 
			0,
			this.viewSizeInPixels.x,
			this.viewSizeInPixels.y
		);
	}

	DisplayHelper.prototype.drawLevel = function(level)
	{
		this.clear();
		this.drawMap(level.map);
	}

	DisplayHelper.prototype.drawMap = function(map)
	{
		var mapSizeInCells = map.sizeInCells;
		var mapCellSizeInPixels = this.viewSizeInPixels.clone().divide
		(
			mapSizeInCells
		);
		var cellPos = new Coords(0, 0);
		var drawPos = new Coords(0, 0);

		for (var y = 0; y < mapSizeInCells.y; y++)
		{
			cellPos.y = y;

			for (var x = 0; x < mapSizeInCells.x; x++)
			{
				cellPos.x = x;

				var cellToDraw = map.cellAtPos(cellPos);

				if (cellToDraw.color != null)
				{
					drawPos.overwriteWith
					(
						cellPos
					).multiply
					(
						mapCellSizeInPixels
					);
	
					this.graphics.fillStyle = cellToDraw.color;
		
					this.graphics.fillRect
					(
						drawPos.x, drawPos.y,
						mapCellSizeInPixels.x,
						mapCellSizeInPixels.y
					);
				}
			}
		}

		var colorSlider = map.terrains["Slider"].color;

		for (var i = 0; i < map.sliders.length; i++)
		{
			var slider = map.sliders[i];

			drawPos.overwriteWith
			(
				slider.pos
			).multiply
			(
				mapCellSizeInPixels
			);
			
			this.graphics.fillStyle = colorSlider;
		
			this.graphics.fillRect
			(
				drawPos.x, drawPos.y,
				mapCellSizeInPixels.x,
				mapCellSizeInPixels.y
			);
		}

		var colorPlayer = map.terrains["Player"].color;

		drawPos.overwriteWith
		(
			map.player.pos
		).multiply
		(
			mapCellSizeInPixels
		);

		this.graphics.fillStyle = colorPlayer;
		
		this.graphics.fillRect
		(
			drawPos.x, drawPos.y,
			mapCellSizeInPixels.x,
			mapCellSizeInPixels.y
		);
	}

	DisplayHelper.prototype.initialize = function(viewSizeInPixels)
	{
		this.viewSizeInPixels = viewSizeInPixels;
		
		var canvas = document.createElement("canvas");
		canvas.width = viewSizeInPixels.x;
		canvas.height = viewSizeInPixels.y;
		
		this.graphics = canvas.getContext("2d");

		var divMain = document.getElementById("divMain");
		divMain.appendChild(canvas);
	}
}

function Globals()
{
	// do nothing
}
{
	Globals.Instance = new Globals();

	Globals.prototype.initialize = function(viewSizeInPixels, level)
	{
		this.level = level;

		this.displayHelper = new DisplayHelper();
		this.displayHelper.initialize(viewSizeInPixels);

		this.timer = setInterval
		(
			this.handleEventTimerTick.bind(this),
			150 // millisecondsPerTimerTick
		);

		this.inputHelper = new InputHelper();
		this.inputHelper.initialize();
	}

	// events

	Globals.prototype.handleEventTimerTick = function()
	{
		this.level.updateForTimerTick();
	}
}

function InputHelper()
{
	// do nothing
}
{
	InputHelper.prototype.finalize = function()
	{
		this.keyCodePressed = null;
		document.body.onkeydown = null;
		document.body.onkeyup = null;
	}
	
	InputHelper.prototype.initialize = function()
	{
		document.body.onkeydown = this.handleEventKeyDown.bind(this);
		document.body.onkeyup = this.handleEventKeyUp.bind(this);
	}

	// events

	InputHelper.prototype.handleEventKeyDown = function(event)
	{
		this.keyCodePressed = event.keyCode;
	}

	InputHelper.prototype.handleEventKeyUp = function(event)
	{
		this.keyCodePressed = null;
	}
}

function Level(name, map)
{
	this.name = name;
	this.map = map;
}
{
	Level.prototype.updateForTimerTick = function()
	{
		Globals.Instance.displayHelper.drawLevel(this);

		var inputHelper = Globals.Instance.inputHelper;

		if (inputHelper.keyCodePressed == 65) // a
		{
			this.updateForTimerTick_PlayerMove
			(
				new Coords(-1, 0)
			);
		}
		else if (inputHelper.keyCodePressed == 68) // d
		{
			this.updateForTimerTick_PlayerMove
			(
				new Coords(1, 0)
			);
		}
		else if (inputHelper.keyCodePressed == 83) // s
		{
			this.updateForTimerTick_PlayerMove
			(
				new Coords(0, 1)
			);
		}
		else if (inputHelper.keyCodePressed == 87) // w
		{
			this.updateForTimerTick_PlayerMove
			(
				new Coords(0, -1)
			);
		}
	}

	Level.prototype.updateForTimerTick_PlayerMove = function(directionToMove)
	{
		var playerToMove = this.map.player;
		var playerPosNext = playerToMove.pos.clone().add
		(
			directionToMove
		);
		var map = this.map;
		var cellAtPlayerPosNext = map.cellAtPos(playerPosNext);

		if (cellAtPlayerPosNext.isPassable == true)
		{
			var sliderAtPlayerPosNext = map.sliderAtPos(playerPosNext);

			if (sliderAtPlayerPosNext == null)
			{
				playerToMove.pos.add(directionToMove);
			}
			else
			{
				var canSliderSlide = true;

				var sliderPosNext = playerPosNext.clone().add
				(
					directionToMove
				);
				var cellAtSliderPosNext = map.cellAtPos(sliderPosNext);
				if (cellAtSliderPosNext.isPassable == false)
				{
					canSliderSlide = false;
				}
				else
				{
					var sliderOtherAtSliderPosNext = this.map.sliderAtPos
					(
						sliderPosNext
					);
					if (sliderOtherAtSliderPosNext != null)
					{
						canSliderSlide = false;
					}
				}

				if (canSliderSlide == true)
				{
					playerToMove.pos.add(directionToMove);
					sliderAtPlayerPosNext.pos.add(directionToMove);

					if (cellAtSliderPosNext.name == "Goal")
					{
						this.victoryCheck();
					}
				}
			}
		}
	}

	Level.prototype.victoryCheck = function()
	{
		var areAllGoalCellsOccupiedBySliders = true;

		var map = this.map;
		var terrainGoal = map.terrains["Goal"];
		var cellPos = new Coords(0, 0);

		for (var y = 0; y < map.sizeInCells.y; y++)
		{
			cellPos.y = y;

			for (var x = 0; x < map.sizeInCells.x; x++)
			{
				cellPos.x = x;

				var terrainAtPos = map.cellAtPos(cellPos);
				if (terrainAtPos == terrainGoal)
				{
					var sliderAtPos = map.sliderAtPos(cellPos);
					if (sliderAtPos == null)
					{
						areAllGoalCellsOccupiedBySliders = false;
						y = map.sizeInCells.y;
						break;
					}
				}
			}
		}

		if (areAllGoalCellsOccupiedBySliders == true)
		{
			Globals.Instance.inputHelper.finalize();
			Globals.Instance.displayHelper.drawLevel(this);

			var divWinMessage = document.createElement("div");
			divWinMessage.innerHTML = "You win!";
			document.body.appendChild(divWinMessage);
		}
	}
}

function Map(terrains, cellsAsStrings)
{
	this.terrains = terrains;
	this.terrains.addLookups("name");
	this.terrains.addLookups("symbol");
	this.cellsAsStrings = cellsAsStrings;

	this.sizeInCells = new Coords
	(
		this.cellsAsStrings[0].length,
		this.cellsAsStrings.length
	);

	this.movablesInitialize();
}
{
	Map.prototype.cellAtPos = function(cellPos)
	{
		var terrainSymbol = this.cellsAsStrings[cellPos.y].charAt(cellPos.x);
		var terrain = this.terrains[terrainSymbol];
		return terrain;
	}

	Map.prototype.movablesInitialize = function()
	{
		this.sliders = [];

		var terrainSymbolForFloor = this.terrains["Floor"].symbol;
		var cellPos = new Coords(0, 0);

		for (var y = 0; y < this.sizeInCells.y; y++)
		{
			cellPos.y = y;
			var cellRowAsString = this.cellsAsStrings[y];

			for (var x = 0; x < this.sizeInCells.x; x++)
			{
				cellPos.x = x;
				var terrainSymbol = cellRowAsString.charAt(x);
				var terrain = this.terrains[terrainSymbol];
				if (terrain.isMovable == true)
				{
					cellRowAsString = 
						cellRowAsString.substr(0, x)
						+ terrainSymbolForFloor
						+ cellRowAsString.substr(x + 1)
				}

				if (terrain.name == "Player")
				{
					this.player = new Player(cellPos.clone());
				}
				else if (terrain.name == "Slider")
				{
					var slider = new Slider(cellPos.clone());
					this.sliders.push(slider);
				}
			}

			this.cellsAsStrings[y] = cellRowAsString;
		} 
	}

	Map.prototype.sliderAtPos = function(cellPos)
	{
		var returnValue = null;

		for (var i = 0; i < this.sliders.length; i++)
		{
			var slider = this.sliders[i];
			if (slider.pos.equals(cellPos) == true)
			{
				returnValue = slider;
				break;
			}
		}

		return returnValue;
	}
}

function MapTerrain(name, symbol, color, isPassable, isMovable)
{
	this.name = name;
	this.symbol = symbol;
	this.color = color;
	this.isPassable = isPassable;
	this.isMovable = isMovable;
}

function Player(pos)
{
	this.pos = pos;
}

function Slider(pos)
{
	this.pos = pos;
}

// run

new SlidingPuzzleGame().main();

</script>
</body>
</html>

Advertisement
This entry was posted in Uncategorized and tagged , , , , . Bookmark the permalink.

1 Response to A Simple Sokoban Game in JavaScript

  1. Christophe says:

    Hello, I also coded a sokoban and try to explain how to do this in my blog. My code use text files for the leves and a engine I created to move the sokokban. I hope it will interest you.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s