A Networked Multiplayer Game in JavaScript with Node.js

The JavaScript code below implements a very simple multiplayer “Spacewar” game in Javascript, using Node.js and the Socket.IO library. To see it in action, follow the steps below.

It’s not much of a game at this point. There’s no planet in the middle of the screen and you can’t do hyperspace jumps as in most versions of Spacewar. But you can start a server, connect multiple web browsers to it, fly around with the W, A, S and D keys, and shoot with the F key.

NetworkedMultiplayerSpacewar

UPDATE 2017/08/03 – I have updated this post to add a planet with gravity, to change squares to circles, to improve collisions, and to add a “hyperspace jump” that can be activated with the “J” key to jump to a random location every so often. I also replaced the existing serializer, added “terse” serialization to decrease the amount of network traffic being sent over the sockets, and cleaned up some unused code.

UPDATE 2017/08/04 – Further updates have been made to the program (not included here) and it has been posted to “https://github.com/thiscouldbebetter/NetworkedSpacewar”.

1. Make sure that Node.js is installed, following the steps in a previous post.

2. In any convenient location, create a new directory named “MultiplayerGame”.

3. Open a command prompt, navigate to the newly created “MultiplayerGame” directory, and run “npm install socket.io” to install the Socket.IO library for Node.js. Note that because packages are installed to the local directory by default, I had to do this even though I had already installed Socket.IO somewhere else on the filesystem. No doubt there’s some way to avoid the need to install it again.

4. In the newly created “MultiplayerGame” directory, create new text files named “Local.html”, “Server.js”, “Server-Run.bat”, “Client.html”, and “Common.js”. Copy the text given in the code listings below these steps into each respective file.

5. Run “Server-Run.bat”. A console window will appear, and the game server will start running in it.

6. Open “Client.html” in a web browser that runs JavaScript. The game field will appear, and a colored square representing the player will appear in it.

7. Open a second copy of “Client.html” in another web browser window. The game field containing two players will appear in both web browsers.

8. A self-hosted version of the game can be run by opening the “Local.html” file in a web browser. That version is going to be even less fun, though, since running locally means there will only ever be one active player in the game at a time.

Local.html:

<html>
<body>

<script type="text/javascript" src="Common.js"></script>

<script type="text/javascript">

function Local()
{
	// do nothing
}
{
	Local.prototype.initialize = function()
	{
		this.clientID = "_" + IDHelper.IDNext();

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

		this.display = new Display(this.world.size);
		this.display.initialize(document);

		this.updatesIncoming = [];

		var bodyDefnForUser = BodyDefn.player(this.world, this.clientID);
		var update = new Update_BodyDefnRegister(bodyDefnForUser);
		this.world.updatesOutgoing.push(update);

		var bodyForUser = new Body
		(
			this.clientID,
			this.clientID,
			bodyDefnForUser.name,
			new Coords(10, 10),
			new Coords(1, 0)
		);
		
		update = new Update_BodyCreate(bodyForUser);
		this.world.updatesOutgoing.push(update);

		setInterval
		(
			this.updateForTick.bind(this),
			this.world.millisecondsPerTick()
		);	
	}

	Local.prototype.start = function()
	{
		this.clientID = IDHelper.IDNext();
		this.document = document;

		this.world = World.demo();

		this.initialize();
	}

	Local.prototype.updateForTick = function()
	{
		var world = this.world;

		world.updateForTick_UpdatesApply(this.updatesIncoming);

		world.updateForTick_Remove();

		world.updateForTick_Spawn();

		this.updateForTick_Client();

		this.updateForTick_Server();

		this.updateForTick_UpdatesOutgoingSend();
	}

	Local.prototype.updateForTick_Client = function()
	{
		var world = this.world;

		var bodyForUser = world.bodies[this.clientID];

		if (bodyForUser != null)
		{
			var activity = bodyForUser.activity;
			if (activity != null)
			{
				activity.perform(world, this.inputHelper, bodyForUser, activity);
			}
		}

		this.display.drawWorld(world);
	}

	Local.prototype.updateForTick_Server = function()
	{
		var world = this.world;
		var bodies = world.bodies;

		for (var i = 0; i < bodies.length; i++)
		{
			var body = bodies[i];
			body.updateForTick_Integrity(world);
			body.updateForTick_Actions(world);
			body.updateForTick_Physics(world);
		}

		for (var i = 0; i < bodies.length; i++)
		{
			var body = bodies[i];
			body.updateForTick_Collisions(world, i);
		}
	}

	Local.prototype.updateForTick_UpdatesOutgoingSend = function()
	{
		var world = this.world;

		for (var i = 0; i < world.updatesOutgoing.length; i++)
		{
			var update = world.updatesOutgoing[i];
			this.updatesIncoming.push(update);
		}
		world.updatesOutgoing.length = 0;
	}
}

new Local().start();

</script>

</body>
</html>

Server.js:

var fs = require("fs");
eval(fs.readFileSync("./Common.js").toString());

function Server(portToListenOn)
{
	this.portToListenOn = portToListenOn;
	this.socketsToClients = [];
}
{
	Server.prototype.initialize = function()
	{
		Log.IsEnabled = true;

		this.serializer = new Serializer;

		this.clientID = IDHelper.IDNext();

		this.updatesIncoming = [];

		var socketIO = require("socket.io");
		var io = socketIO.listen
		(
			this.portToListenOn,
			{ log: false }
		);

		io.sockets.on
		(
			"connection", 
			this.handleEvent_ClientConnected.bind(this)
		);

		console.log("Server started at " + new Date().toLocaleTimeString());
		console.log("Listening on port " + this.portToListenOn + "...");

		setInterval
		(
			this.updateForTick.bind(this),
			this.world.millisecondsPerTick()
		);	
	}

	Server.prototype.start = function()
	{
		this.world = World.demo();

		this.initialize();
	}

	Server.prototype.updateForTick = function()
	{
		var world = this.world;

		world.updateForTick_UpdatesApply(this.updatesIncoming);

		world.updateForTick_Spawn();

		this.updateForTick_Server();

		world.updateForTick_UpdatesApply(world.updatesImmediate);

		world.updateForTick_Remove();

		this.updateForTick_UpdatesOutgoingSend();
	}

	Server.prototype.updateForTick_Server = function()
	{
		var world = this.world;
		var bodies = world.bodies;

		for (var i = 0; i < bodies.length; i++)
		{
			var body = bodies[i];
			body.updateForTick_Integrity(world);
			body.updateForTick_Actions(world);
			body.updateForTick_Physics(world);
		}

		for (var i = 0; i < bodies.length; i++)
		{
			var body = bodies[i];
			body.updateForTick_Collisions(world, i);
		}
	}

	Server.prototype.updateForTick_UpdatesOutgoingSend = function()
	{
		var world = this.world;

		for (var i = 0; i < world.updatesOutgoing.length; i++)
		{
			var update = world.updatesOutgoing[i];
			var serializer = (update.serialize == null ? this.serializer : update);
			var updateSerialized = serializer.serialize(update);

			for (var c = 0; c < this.socketsToClients.length; c++)
			{
				var socketToClient = this.socketsToClients[c];
				socketToClient.emit("update", updateSerialized);
			}
		}
		world.updatesOutgoing.length = 0;
	}

	// events

	Server.prototype.handleEvent_ClientConnected = function(socketToClient)
	{	
		var clientIndex = this.socketsToClients.length;
		var clientID = "C_" + clientIndex;
		
		this.socketsToClients.push(socketToClient);
		
		var bodyDefnForClient = BodyDefn.player(this.world, clientID);

		var updateBodyDefnRegister = new Update_BodyDefnRegister
		(
			bodyDefnForClient
		);
		updateBodyDefnRegister.updateWorld(this.world);
		this.world.updatesOutgoing.push(updateBodyDefnRegister);

		var bodyForClient = new Body
		(
			clientID, // id
			clientID, // name
			bodyDefnForClient.name,
			new Coords().randomize().multiply(this.world.size),
			new Coords().randomize().normalize()
		);
		var updateBodyCreate = new Update_BodyCreate(bodyForClient);
		this.world.updatesOutgoing.push(updateBodyCreate);
		updateBodyCreate.updateWorld(this.world);
		
		var updateClientJoin = new Update_ClientJoin(clientID, this.world);
		socketToClient.emit("update", this.serializer.serialize(updateClientJoin));
		
		socketToClient.on
		(
			"update",
			this.handleEvent_UpdateReceived.bind(this, clientID)
		);
		
		socketToClient.on
		(
			"disconnect",
			this.handleEvent_ClientDisconnected.bind(this, clientID)
		);
				
		console.log(clientID + " joined the server.");
	}
	
	Server.prototype.handleEvent_ClientDisconnected = function(clientID)
	{
		console.log(clientID + " disconnected.");
		
		var bodies = this.world.bodies;
		var bodyToDestroy = bodies[clientID];
		if (bodyToDestroy != null)
		{
			bodyToDestroy.integrity = 0;
		}
	}
	
	Server.prototype.handleEvent_UpdateReceived = function(clientID, updateSerialized)
	{
		var serializer;
		var firstChar = updateSerialized[0];
		
		if (firstChar == "{") // JSON
		{
			serializer = this.serializer;
		}
		else // terse
		{
			var updateCode = firstChar;
			if (updateCode == Update_Actions.UpdateCode)
			{
				serializer = new Update_Actions();
			}
		}
		
		var update = serializer.deserialize
		(
			updateSerialized
		);
		
		update.bodyID = clientID; // Because it's not encoded with terse serialization.
		
		update.updateWorld(this.world, this);
	}
	
}

new Server(8089).start();

Server-Run.bat:

node Server.js
pause

Client.html:

<html>
<body>

<script type="text/javascript" src="Common.js"></script>

<script type="text/javascript" src="http://localhost:8089/socket.io/socket.io.js"></script>

<script type="text/javascript">

function Client(serviceURL)
{
	this.serviceURL = serviceURL;
}
{
	Client.prototype.initialize = function()
	{
		this.serializer = new Serializer();

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

		this.display = new Display(this.world.size);
		this.display.initialize(document);

		this.updatesIncoming = [];

		this.socketToServer = io.connect(this.serviceURL);
		this.socketToServer.on
		(
			"update", 
			this.handleEvent_UpdateReceived.bind(this)
		);

		this.timer = setInterval
		(
			this.updateForTick.bind(this),
			this.world.millisecondsPerTick()
		);
	}

	Client.prototype.start = function()
	{
		this.clientID = IDHelper.IDNext();

		this.world = World.demo();
		this.world.bodiesToSpawn.length = 0; // hack

		this.initialize();
	}

	Client.prototype.updateForTick = function()
	{
		var world = this.world;

		world.updateForTick_UpdatesApply(this.updatesIncoming);

		world.updateForTick_Remove();

		world.updateForTick_Spawn();

		this.updateForTick_Client();

		this.updateForTick_UpdatesOutgoingSend();
	}

	Client.prototype.updateForTick_Client = function()
	{
		var world = this.world;

		var bodyForUser = world.bodies[world.idOfBodyControlledByUser];

		if (bodyForUser != null)
		{
			var activity = bodyForUser.activity;
			
			activity.perform
			(
				world, this.inputHelper, bodyForUser, activity
			);
		}

		this.display.drawWorld(world);
	}

	Client.prototype.updateForTick_UpdatesOutgoingSend = function()
	{
		var world = this.world;

		for (var i = 0; i < world.updatesOutgoing.length; i++)
		{
			var update = world.updatesOutgoing[i];
			var serializer = (update.serialize == null ? this.serializer : update);
			var updateSerialized = serializer.serialize(update);
			this.socketToServer.emit("update", updateSerialized);
		}
		world.updatesOutgoing.length = 0;
	}

	// events

	Client.prototype.handleEvent_UpdateReceived = function(updateSerialized)
	{
		var serializer;
		var firstChar = updateSerialized[0];
		
		if (firstChar == "{") // JSON
		{
			serializer = this.serializer;
		}
		else // terse
		{
			var updateCode = firstChar;
			if (updateCode == Update_Physics.UpdateCode)
			{
				serializer = new Update_Physics();
			}
		}
	
		var update = serializer.deserialize(updateSerialized);
		
		this.updatesIncoming.push(update);
	}
}

new Client("http://localhost:8089").start();

</script>

</body>
</html>

Common.js:


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

		return this;
	}

	Array.prototype.append = function(other)
	{
		for (var i = 0; i < other.length; i++)
		{
			this.push(other[i]);
		}

		return this;
	}

	Array.prototype.clone = function()
	{
		var returnValues = [];

		for (var i = 0; i < this.length; i++)
		{
			var item = this[i];
			var itemCloned = item.clone();
			returnValues.push(itemCloned);
		}

		return returnValues;	
	}

	Array.prototype.contains = function(itemToFind)
	{
		return (this.indexOf(itemToFind) >= 0);
	}

	Array.prototype.members = function(memberName)
	{
		var returnValues = [];

		for (var i = 0; i < this.length; i++)
		{
			var item = this[i];
			var member = item[memberName];
			returnValues.push(member);
		}

		return returnValues;
	}

	Array.prototype.removeAt = function(indexToRemoveAt)
	{
		this.splice(indexToRemoveAt, 1);
	}

	Array.prototype.remove = function(itemToRemove)
	{
		if (this.contains(itemToRemove) == true)
		{
			this.removeAt(this.indexOf(itemToRemove));
		}
	}
}

// classes

function Action(name, inputName, perform)
{
	this.name = name;
	this.inputName = inputName;
	this.perform = perform;
}

function Activity(name, perform)
{
	this.name = name;
	this.perform = perform;

	this.actionNames = [];
}
{
	Activity.prototype.clone = function()
	{
		return new Activity(this.name, this.perform);
	}
}

function Body(id, name, defnName, pos, orientation)
{
	this.id = id;
	this.name = name;
	this.defnName = defnName;
	this.pos = pos;
	this.orientation = orientation;

	this.vel = new Coords(0, 0);
	this.accel = new Coords(0, 0);
	this.right = this.orientation.clone().right();
}
{
	// instance methods

	Body.prototype.collideWith = function(world, other)
	{
		var bodyDefnThis = this.defn(world);
		var bodyDefnOther = other.defn(world);

		if (bodyDefnThis.collide != null)
		{
			bodyDefnThis.collide(world, this, other);
		}
		if (bodyDefnOther.collide != null)
		{
			bodyDefnOther.collide(world, other, this);
		}
	}

	Body.prototype.defn = function(world)
	{
		var returnValue = world.bodyDefns[this.defnName];
		return returnValue;
	}

	Body.prototype.initializeForWorld = function(world)
	{
		var bodyDefn = this.defn(world);
		this.integrity = bodyDefn.integrityMax;
		this.ticksToLive = bodyDefn.ticksToLive;
		this.energy = 0;
		this.ticksSinceActionPerformed = 0;
		this.devices = bodyDefn.devices.clone().addLookups("name");
		this.activity = bodyDefn.activity;
	}

	Body.prototype.overwriteWith = function(other)
	{
		this.defnName = other.defnName;
		this.pos.overwriteWith(other.pos);
		this.orientation.overwriteWith(other.orientation);
		this.vel.overwriteWith(other.vel);
		this.accel.overwriteWith(other.accel);
		this.right.overwriteWith(other.right);
	}

	Body.prototype.updateForTick_Actions = function(world)
	{
		if (this.activity != null)
		{
			var bodyDefn = this.defn(world);

			this.activity.perform(world, null, this, this.activity);

			var actionNames = this.activity.actionNames;

			for (var a = 0; a < actionNames.length; a++)
			{
				var actionName = actionNames[a];
				var action = world.actions[actionName];
				action.perform(world, this);
			}
			
			actionNames.length = 0;

			for (var d = 0; d < this.devices.length; d++)
			{
				var device = this.devices[d];
				device.updateForTick(world, this);
			}

			this.energy += bodyDefn.energyPerTick;
			if (this.energy > bodyDefn.energyMax)
			{
				this.energy = bodyDefn.energyMax;
			}
		}
	}

	Body.prototype.updateForTick_Collisions = function(world, i)
	{
		var bodies = world.bodies;
		
		for (var j = i + 1; j < bodies.length; j++)
		{
			var bodyOther = bodies[j];
	
			var distanceFromThisToOther = bodyOther.pos.clone().subtract
			(
				this.pos
			).magnitude();
			
			var sumOfRadii = 
				this.defn(world).radius 
				+ bodyOther.defn(world).radius;
	
			if (distanceFromThisToOther < sumOfRadii)
			{
				this.collideWith(world, bodyOther);
			}
		}
	}

	Body.prototype.updateForTick_Integrity = function(world)
	{
		if (this.ticksToLive != null)
		{
			this.ticksToLive--;
			if (this.ticksToLive <= 0)
			{
				this.integrity = 0;
			}
		}

		if (this.integrity <= 0)
		{
			if (this.defn(world).categoryNames.contains("Player") == true)
			{
				Log.write(this.name + " was destroyed.")
			}
			var update = new Update_BodyRemove
			(
				this.id
			);
			world.updatesImmediate.push(update);
			world.updatesOutgoing.push(update);
		}
	}

	Body.prototype.updateForTick_Physics = function(world)
	{
		var bodyDefn = this.defn(world);

		this.vel.add
		(
			this.accel
		).trimToMagnitude
		(
			bodyDefn.speedMax
		);

		this.pos.add
		(
			this.vel
		).wrapToRange
		(
			world.size
		);

		this.accel.clear();
		
		if (bodyDefn.speedMax != 0 || bodyDefn.turnRate != 0)
		{
			var update = new Update_Physics
			(
				this.id, this.pos, this.orientation
			);

			world.updatesOutgoing.push(update);
		}
	}
}

function BodyDefn
(
	name, 
	categoryNames,
	color, 
	integrityMax,
	ticksToLive,
	massInKg,
	speedMax, 
	accelerationPerTick, 
	turnRate, 
	energyMax,
	energyPerTick,
	radius, 
	activity,
	actionNames,
	devices, 
	collide
)
{
	this.name = name;
	this.categoryNames = categoryNames;
	this.color = color;
	this.integrityMax = integrityMax;
	this.ticksToLive = ticksToLive;
	this.massInKg = massInKg;
	this.speedMax = speedMax;
	this.accelerationPerTick = accelerationPerTick;
	this.turnRate = turnRate;
	this.energyMax = energyMax;
	this.energyPerTick = energyPerTick;
	this.radius = radius;
	this.activity = activity;
	this.actionNames = actionNames;
	this.devices = devices;
	this.collide = collide;
}
{
	BodyDefn.planet = function()
	{
		var color = ColorHelper.random();
		
		var activityGravitate = new Activity
		(
			"Gravitate",
			// perform
			function(world, inputHelper, actor, activity)
			{
				var planet = actor;
				var planetDefn = planet.defn(world);
				var bodiesOther = world.bodies;
				for (var i = 0; i < bodiesOther.length; i++)
				{
					var bodyOther = bodiesOther[i];
					if (bodyOther != planet)
					{
						if (bodyOther.massInKg != 0)
						{
							var displacement = bodyOther.pos.clone().subtract
							(
								planet.pos
							);
							var distance = displacement.magnitude();
							
							if (distance > 0)
							{
								var bodyOtherDefn = bodyOther.defn(world);
								
								var direction = displacement.divideScalar
								(
									distance
								);
								
								var gravityConstantInPixels2OverKg2 = 2 * Math.pow(10, -24);
								
								var accelDueToGravity = direction.multiplyScalar
								(
									gravityConstantInPixels2OverKg2 * planetDefn.massInKg
								).divideScalar
								(
									distance * distance
								);
								
								bodyOther.accel.subtract(accelDueToGravity);
							}
						}
					}
				}
			}
		);

		var returnValue = new BodyDefn
		(
			"Planet",
			[ "Planet" ], // categoryNames
			color,
			Number.POSITIVE_INFINITY, // integrityMax
			null, // ticksToLive
			6 * Math.pow(10, 24), // massInKg (Earth actual)
			0, // speedMax 
			0, // accelerationPerTick
			0, // turnRate
			0, // energyMax
			0, // energyPerTick
			10, // radius
			activityGravitate, 
			[], // actionNames
			[], // devices
			function collide(world, collider, other)
			{
				var planet = collider;
				var displacement = other.pos.clone().subtract
				(
					planet.pos
				);
				
				var distance = displacement.magnitude();
			
				var direction = displacement.divideScalar(distance);
				
				other.pos.overwriteWith
				(
					planet.pos
				).add
				(
					direction.clone().multiplyScalar
					(
						planet.defn(world).radius 
						+ other.defn(world).radius
					)
				);
				
				var speedAlongRadius = other.vel.dotProduct(direction);
	
				var accelOfReflection = direction.multiplyScalar(speedAlongRadius * 2);
	
				other.accel.subtract(accelOfReflection);
			}
		);

		return returnValue;

	}

	BodyDefn.player = function(world, name)
	{
		var color = ColorHelper.random();

		var activityUserInputAccept = new Activity
		(
			"UserInputAccept",
			// perform
			function(world, inputHelper, actor, activity)
			{
				if (inputHelper == null)
				{
					return;
				}
				
				activity.actionNames.length = 0;
		
				var bodyDefn = actor.defn(world);

				var inputNamesActive = inputHelper.inputNamesActive;
				for (var i = 0; i < inputNamesActive.length; i++)
				{
					var inputNameActive = inputNamesActive[i];
					var action = world.actions[inputNameActive];
					if (action != null)
					{
						var actionName = action.name;
						if (bodyDefn.actionNames.contains(actionName) == true)
						{
							activity.actionNames.push(actionName);
						}
					}
				}

				if (activity.actionNames.length > 0)
				{
					var update = new Update_Actions
					(
						actor.id,
						activity.actionNames
					);

					world.updatesOutgoing.push(update);
				}
			}
		);

		var returnValue = new BodyDefn
		(
			name,
			[ "Player" ], // categoryNames
			color,
			1, // integrityMax
			null, // ticksToLive
			10000, // massInKg (10 tonnes)
			2, // speedMax 
			.15, // accelerationPerTick
			.15, // turnRate
			1, // energyMax
			.1, // energyPerTick
			3, // radius
			activityUserInputAccept, // activity
			world.actions.members("name"), // actionNames
			// devices
			[
				Device.gun(),
				Device.jump(),
			],
			null // collide
		);

		return returnValue;
	}

	BodyDefn.projectile = function()
	{
		return new BodyDefn
		(
			"Projectile", // name
			[], // categoryNames
			"Brown", // color 
			1, // integrityMax
			10, // ticksToLive
			0, // massInKg
			8, // speedMax
			0, // accelerationPerTick 
			0, // turnRate
			0, // energyMax
			0, // energyPerTick
			1, // radius
			null, // activity
			[], // actionNames
			[], // devices
			// collide
			function(world, collider, other)
			{
				this.integrity = 0;
				other.integrity--;
			}
		);
	}
}

function ColorHelper()
{
	// static class
}
{
	ColorHelper.random = function()
	{
		var hueMax = 360;
		var hue = Math.floor(Math.random() * hueMax);

		var returnValue = 
			"hsl(" + hue + ", 100%, 50%)";

		return returnValue;
	}
}

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	// constants

	Coords.NumberOfDimensions = 2;

	// instance methods

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

	Coords.prototype.clear = function()
	{
		this.x = 0;
		this.y = 0;
		return this;
	}

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

	Coords.prototype.dimension = function(dimensionIndex)
	{
		var returnValue;

		if (dimensionIndex == 0)
		{
			returnValue = this.x;
		}
		else 
		{
			returnValue = this.y;
		}

		return returnValue;
	}

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

	Coords.prototype.divideScalar = function(scalar)
	{
		this.x /= scalar;
		this.y /= scalar;
		return this;
	}
	
	Coords.prototype.dotProduct = function(other)
	{
		return this.x * other.x  + this.y * other.y;
	}

	Coords.prototype.magnitude = function()
	{
		return Math.sqrt(this.x * this.x + this.y * this.y);
	}

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

	Coords.prototype.multiplyScalar = function(scalar)
	{
		this.x *= scalar;
		this.y *= scalar;
		return this;
	}

	Coords.prototype.normalize = function()
	{
		return this.divideScalar(this.magnitude());
	}

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

	Coords.prototype.randomize = function()
	{
		this.x = Math.random();
		this.y = Math.random();
		return this;
	}

	Coords.prototype.right = function()
	{
		var temp = this.y;
		this.y = this.x;
		this.x = 0 - temp;
		return this;
	}

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

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

	Coords.prototype.trimToMagnitude = function(magnitudeMax)
	{
		var magnitude = this.magnitude();

		if (magnitude > magnitudeMax)
		{
			this.divideScalar
			(
				magnitude
			).multiplyScalar
			(
				magnitudeMax
			);
		}

		return this;
	}

	Coords.prototype.wrapToRange = function(max)
	{
		while (this.x < 0)
		{
			this.x += max.x;
		}
		while (this.x >= max.x)
		{
			this.x -= max.x;
		}
		while (this.y < 0)
		{
			this.y += max.y;
		}
		while (this.y >= max.y)
		{
			this.y -= max.y;
		}
		return this;
	}
}

function Device(name, ticksToCharge, energyToUse, use)
{
	this.name = name;
	this.ticksToCharge = ticksToCharge;
	this.energyToUse = energyToUse;
	this.use = use;	

	this.ticksSinceUsed = 0;
}
{
	// static methods

	Device.gun = function()
	{
		var returnValue = new Device
		(
			"Gun",
			5, // ticksToCharge
			.3, // energyToUse
			// use
			function(world, body, device)
			{
				if (device.ticksSinceUsed < device.ticksToCharge)
				{
					return;
				}

				if (body.energy < device.energyToUse)
				{
					return;
				}

				device.ticksSinceUsed = 0;

				body.energy -= device.energyToUse;
				var bodyDefn = body.defn(world);

				var projectileDefn = world.bodyDefns["Projectile"];

				var projectileID = "P" + IDHelper.IDNext();

				var projectile = new Body
				(
					projectileID,
					"", // no name
					projectileDefn.name,
					body.pos.clone().add
					(
						body.orientation.clone().multiplyScalar
						(
							bodyDefn.radius * 1.1
						)
					).add
					(
						body.vel
					),
					body.orientation.clone()
				);
	
				projectile.vel.overwriteWith
				(
					body.orientation
				).multiplyScalar
				(
					projectileDefn.speedMax
				);

				var update = new Update_BodyCreate(projectile);
				world.updatesImmediate.push(update);
				world.updatesOutgoing.push(update);
			}
		);

		return returnValue;
	}

	Device.jump = function()
	{
		var returnValue = new Device
		(
			"Jump",
			50, // ticksToCharge
			1, // energyToUse
			// use
			function(world, body, device)
			{
				if (device.ticksSinceUsed < device.ticksToCharge)
				{
					return;
				}

				if (body.energy < device.energyToUse)
				{
					return;
				}

				device.ticksSinceUsed = 0;

				body.energy -= device.energyToUse;

				body.pos.randomize().multiply
				(
					world.size
				);
			}
		);

		return returnValue;
	}

	// instance methods

	Device.prototype.clone = function()
	{
		return new Device(this.name, this.ticksToCharge, this.energyToUse, this.use);
	}	

	Device.prototype.updateForTick = function(world, body)
	{
		this.ticksSinceUsed++;
	}
}


function Log()
{}
{
	Log.IsEnabled = false;

	Log.write = function(message)
	{
		if (Log.IsEnabled == true)
		{
			console.log(message);
		}
	}
}

function Display(size)
{
	this.size = size;

	this.colorBack = "White";
	this.colorFore = "Gray";
}
{
	Display.prototype.clear = function()
	{
		this.graphics.fillStyle = this.colorBack;
		this.graphics.fillRect(0, 0, this.size.x, this.size.y);

		this.graphics.strokeStyle = this.colorFore;
		this.graphics.strokeRect(0, 0, this.size.x, this.size.y);
	}

	Display.prototype.drawTextAtPos = function(text, drawPos)
	{
		this.graphics.fillStyle = this.colorFore;
		this.graphics.fillText
		(
			text, 
			drawPos.x, drawPos.y
		);
	}

	Display.prototype.drawWorld = function(world)
	{
		this.clear();

		var drawPos = new Coords();
		var drawPos2 = new Coords();

		for (var i = 0; i < world.bodies.length; i++)
		{
			var body = world.bodies[i];
			var bodyPos = body.pos;
			var bodyDefn = body.defn(world);
			var bodySize = bodyDefn.radius;

			this.graphics.strokeStyle = bodyDefn.color;

			drawPos.overwriteWith
			(
				bodyPos
			);

			this.drawTextAtPos(body.name, drawPos);

			this.graphics.beginPath();
			this.graphics.arc
			(
				drawPos.x, drawPos.y, // center
				bodySize, // radius
				0, Math.PI * 2 // start, stop angles
			);
			this.graphics.stroke();

			if (bodyDefn.speedMax != 0) // hack
			{
				this.graphics.beginPath();

				this.graphics.moveTo(drawPos.x, drawPos.y);

				drawPos2.overwriteWith
				(
					body.orientation
				).multiplyScalar
				(
					bodySize * 2
				).add
				(
					drawPos
				);

				this.graphics.lineTo(drawPos2.x, drawPos2.y);
	
				this.graphics.stroke();
			}
		}		
	}

	Display.prototype.initialize = function(document)
	{
		var canvas = document.createElement("canvas");
		canvas.width = this.size.x;
		canvas.height = this.size.y;

		this.graphics = canvas.getContext("2d");

		document.body.appendChild(canvas);

		this.domElement = canvas;
	}
}

function IDHelper()
{
	// static class
}
{
	IDHelper._idNext = 0;

	IDHelper.IDNext = function()
	{
		return "_" + IDHelper._idNext++;
	}
}

function InputHelper()
{
	// do nothing
}
{
	InputHelper.prototype.initialize = function(document)
	{
		this.inputNamesActive = [];
		document.body.onkeydown = this.handleEventKeyDown.bind(this);
		document.body.onkeyup = this.handleEventKeyUp.bind(this);
	}

	// events

	InputHelper.prototype.handleEventKeyDown = function(event)
	{
		var inputName = event.key;
		if (this.inputNamesActive[inputName] == null)
		{
			this.inputNamesActive.push(inputName);
			this.inputNamesActive[inputName] = inputName;
		}
	}

	InputHelper.prototype.handleEventKeyUp = function(event)
	{
		var inputName = event.key;
		this.inputNamesActive.remove(inputName);
		delete this.inputNamesActive[inputName];
	}
}

function Serializer()
{
	// do nothing
}
{
	Serializer.prototype.deserialize = function(stringToDeserialize)
	{
		var nodeRoot = JSON.parse(stringToDeserialize);
		nodeRoot.__proto__ = SerializerNode.prototype;
		nodeRoot.prototypesAssign();
		var returnValue = nodeRoot.unwrap([]);

		return returnValue;
	}

	Serializer.prototype.serialize = function(objectToSerialize)
	{
		var nodeRoot = new SerializerNode(objectToSerialize);

		nodeRoot.wrap([], []);

		var nodeRootSerialized = JSON.stringify
		(
			nodeRoot, 
			null, // ? 
			4 // pretty-print indent size
		);

		return nodeRootSerialized;
	}
}

function SerializerNode(objectWrapped)
{
	this.objectWrappedTypeName = null;
	this.id = null;
	this.isReference = null;

	this.objectWrapped = objectWrapped;
}
{
	SerializerNode.prototype.wrap = function
	(
		objectsAlreadyWrapped, objectIndexToNodeLookup
	)
	{
		if (this.objectWrapped != null)
		{			
			var typeName = this.objectWrapped.constructor.name;

			var objectIndexExisting = 
				objectsAlreadyWrapped.indexOf(this.objectWrapped);
				
			if (objectIndexExisting >= 0)
			{
				var nodeForObjectExisting = objectIndexToNodeLookup[objectIndexExisting];
				this.id = nodeForObjectExisting.id;
				this.isReference = true;
				this.objectWrapped = null;
			}
			else
			{
				this.isReference = false;
				var objectIndex = objectsAlreadyWrapped.length;
				this.id = objectIndex;
				objectsAlreadyWrapped.push(this.objectWrapped);
				objectIndexToNodeLookup[objectIndex] = this;

				this.objectWrappedTypeName = typeName;
	
				if (typeName == "Function")
				{
					this.objectWrapped = this.objectWrapped.toString();
				}
				else
				{
					this.children = {};
	
					for (var propertyName in this.objectWrapped)
					{
						if (this.objectWrapped.__proto__[propertyName] == null)
						{
							var propertyValue = this.objectWrapped[propertyName];

							if (propertyValue == null)
							{
								child = null;
							}
							else 
							{			
								var propertyValueTypeName = propertyValue.constructor.name;

								if 
								(
									propertyValueTypeName == "Boolean"
									|| propertyValueTypeName == "Number"
									|| propertyValueTypeName == "String"
								)
								{
									child = propertyValue;
								}
								else
								{
									child = new SerializerNode
									(
										propertyValue
									);
								}

							}

							this.children[propertyName] = child;
						}
					}

					delete this.objectWrapped;
	
					for (var childName in this.children)
					{
						var child = this.children[childName];
						if (child != null)
						{
							var childTypeName = child.constructor.name;
							if (childTypeName == "SerializerNode")
							{
								child.wrap
								(
									objectsAlreadyWrapped,
									objectIndexToNodeLookup
								);
							}
						}
					}
				}
			}

		} // end if objectWrapped != null

		return this;		

	} // end method

	SerializerNode.prototype.prototypesAssign = function()
	{
		if (this.children != null)
		{
			for (var childName in this.children)
			{
				var child = this.children[childName];
				if (child != null)
				{
					var childTypeName = child.constructor.name;
					if (childTypeName == "Object")
					{
						child.__proto__ = SerializerNode.prototype;
						child.prototypesAssign();
					}
				}
			}
		}
	}

	SerializerNode.prototype.unwrap = function(nodesAlreadyProcessed)
	{
		if (this.isReference == true)
		{
			var nodeExisting = nodesAlreadyProcessed[this.id];
			this.objectWrapped = nodeExisting.objectWrapped;
		}
		else
		{
			nodesAlreadyProcessed[this.id] = this;
			var typeName = this.objectWrappedTypeName;
			if (typeName == null)
			{
				// Value is null.  Do nothing.
			}
			else if (typeName == "Array")
			{
				this.objectWrapped = [];
			}
			else if (typeName == "Function")
			{
				this.objectWrapped = eval("(" + this.objectWrapped + ")");
			}
			else if 
			(
				typeName == "Boolean" 
				|| typeName == "Number" 
				|| typeName == "String"
			)
			{
				// Primitive types. Do nothing.
			}
			else
			{
				this.objectWrapped = {};
				var objectWrappedType = eval("(" + typeName + ")");
				this.objectWrapped.__proto__ = objectWrappedType.prototype;
			}

	
			if (this.children != null)
			{
				for (var childName in this.children)
				{
					var child = this.children[childName];
			
					if (child != null)
					{
						if (child.constructor.name == "SerializerNode")
						{
							child = child.unwrap
							(
								nodesAlreadyProcessed
							);
						}
					}

					this.objectWrapped[childName] = child;
				}
			}

		}

		return this.objectWrapped;
	}
}

function Update_Actions(bodyID, actionNames)
{
	this.bodyID = bodyID;
	this.actionNames = actionNames;
}
{
	// methods
	
	Update_Actions.prototype.updateWorld = function(world)
	{
		var body = world.bodies[this.bodyID];

		if (body != null)
		{
			body.ticksSinceActionPerformed = 0;
			body.activity.actionNames.length = 0;
			body.activity.actionNames.append(this.actionNames);
		}
	}
	
	// serialization
	
	Update_Actions.UpdateCode = "A";
	
	Update_Actions.prototype.deserialize = function(updateSerialized)
	{
		var parts = updateSerialized.split(";");
		
		var returnValue = new Update_Actions
		(
			null, // bodyID - Is set elsewhere.
			parts.slice(1) // actionNames
		);
		
		return returnValue
	}
	
	Update_Actions.prototype.serialize = function()
	{
		var returnValue = 
			Update_Actions.UpdateCode + ";"
			+ this.actionNames.join(";");
			
		return returnValue;
	}
}

function Update_BodyCreate(body)
{
	this.body = body;
}
{
	Update_BodyCreate.prototype.updateWorld = function(world)
	{
		var bodyExisting = world.bodies[this.body.id];
		if (bodyExisting == null)
		{
			world.bodiesToSpawn.push(this.body);
		}
	}
}

function Update_BodyDefnRegister(bodyDefn)
{
	this.bodyDefn = bodyDefn;
}
{
	Update_BodyDefnRegister.prototype.updateWorld = function(world)
	{
		world.bodyDefns.push(this.bodyDefn);
		world.bodyDefns[this.bodyDefn.name] = this.bodyDefn;
	}
}

function Update_BodyRemove(bodyID)
{
	this.bodyID = bodyID;
}
{
	Update_BodyRemove.prototype.updateWorld = function(world)
	{
		world.bodyIDsToRemove.push(this.bodyID);
	}
}

function Update_ClientJoin(clientID, world)
{
	this.clientID = clientID;
	this.world = world;
}
{
	Update_ClientJoin.prototype.updateWorld = function(world)
	{
		world.overwriteWith(this.world);
		world.idOfBodyControlledByUser = this.clientID;
	}
}

function Update_Physics(bodyID, pos, orientation)
{
	this.bodyID = bodyID;
	this.pos = pos;
	this.orientation = orientation;
}
{	
	// instance methods

	Update_Physics.prototype.updateWorld = function(world)
	{
		var body = world.bodies[this.bodyID];
		if (body != null)
		{
			body.pos.overwriteWith(this.pos);
			body.orientation.overwriteWith(this.orientation);
			body.right.overwriteWith(this.orientation).right();
		}
	}
	
	// serialization

	Update_Physics.UpdateCode = "P";
	
	Update_Physics.prototype.deserialize = function(updateSerialized)
	{
		var parts = updateSerialized.split(";");
		
		var returnValue = new Update_Physics
		(
			parts[1],
			new Coords(parseFloat(parts[2]), parseFloat(parts[3])), // pos
			new Coords(parseFloat(parts[4]), parseFloat(parts[5])) // orientation
		);
		
		return returnValue;
	}
	
	Update_Physics.prototype.serialize = function()
	{
		var returnValue = 
			Update_Physics.UpdateCode + ";"
			+ this.bodyID + ";"
			+ this.pos.x + ";" + this.pos.y + ";"
			+ this.orientation.x + ";" + this.orientation.y;
			
		return returnValue;
	}
}

function World(name, ticksPerSecond, size, actions, bodyDefns, bodiesInitial)
{
	this.name = name;
	this.ticksPerSecond = ticksPerSecond;
	this.size = size;
	this.actions = actions.addLookups("name").addLookups("inputName");
	this.bodyDefns = bodyDefns.addLookups("name");

	this.bodies = [];

	this.bodiesToSpawn = bodiesInitial.slice();
	this.bodyIDsToRemove = [];

	this.updatesImmediate = [];
	this.updatesOutgoing = [];
}
{
	// static methods

	World.demo = function()
	{
		var actions = 
		[
			new Action
			(
				"Accelerate",
				"w", // inputName
				// perform
				function(world, body)
				{
					var bodyDefn = body.defn(world);
					var acceleration = bodyDefn.accelerationPerTick;

					body.accel.add
					(
						body.orientation.clone().multiplyScalar
						(
							acceleration
						)
					);
				}
			),
			
			new Action
			(
				"Fire",
				"f", // inputName
				// peform
				function(world, body)
				{
					var device = body.devices["Gun"];
					device.use(world, body, device);
				}
			),
			
			new Action
			(
				"Jump",
				"j", // inputName
				function(world, body)
				{
					var device = body.devices["Jump"];
					device.use(world, body, device);
				}
			),

			new Action
			(
				"Quit",
				"Escape", // inputName
				function(world, body)
				{
					body.integrity = 0;
				}
			),

			new Action
			(
				"TurnLeft",
				"a", // inputName
				function(world, body)
				{
					var bodyDefn = body.defn(world);
					var turnRate = bodyDefn.turnRate;

					body.orientation.subtract
					(
						body.right.clone().multiplyScalar
						(
							turnRate
						)
					).normalize();

					body.right.overwriteWith
					(
						body.orientation
					).right();
				}
			),

			new Action
			(
				"TurnRight",
				"d", // inputName
				function(world, body)
				{
					var bodyDefn = body.defn(world);
					var turnRate = bodyDefn.turnRate;

					body.orientation.add
					(
						body.right.clone().multiplyScalar(turnRate)
					).normalize();

					body.right.overwriteWith
					(
						body.orientation
					).right();
				}
			),
		];

		var actionNames = actions.members("name");

		var bodyDefnPlanet = BodyDefn.planet();

		var worldSize = new Coords(128, 128);

		var bodyPlanet = new Body
		(
			"Planet", // id
			"", // name
			bodyDefnPlanet.name,
			worldSize.clone().divideScalar(2), // pos
			new Coords(1, 0) // orientation
		);

		var returnValue = new World
		(
			"World0",
			20, // ticksPerSecond
			worldSize,
			actions,
			// bodyDefns
			[
				bodyDefnPlanet,
				BodyDefn.projectile(),
			],
			// bodies
			[
				bodyPlanet
			]
		);

		return returnValue;
	}

	// instance methods

	World.prototype.bodyRemove = function(body)
	{
		if (body != null)
		{
			this.bodies.remove(body);
			this.bodies[body.id] = null;
			delete this.bodies[body.id];
		}
	}

	World.prototype.bodySpawn = function(body)
	{
		this.bodies.push(body);
		this.bodies[body.id] = body;
		body.initializeForWorld(this);
	}

	World.prototype.millisecondsPerTick = function()
	{
		return Math.floor(1000 / this.ticksPerSecond);
	}
	
	World.prototype.overwriteWith = function(other)
	{
		this.name = other.name;
		this.ticksPerSecond = other.ticksPerSecond;
		this.size = other.size;
		this.actions = other.actions;
		this.bodyDefns = other.bodyDefns;
		this.bodies = other.bodies;
	}
		
	World.prototype.updateForTick_Remove = function()
	{
		// hack
		// If a client is paused, the updates build up,
		// and once processing resumes, the body may not be created
		// by the time this attempts to remove it,
		// so it can't remove it, but the list is cleared anyway,
		// so it forgets it needs to remove it,
		// so once it actually gets created it lasts forever. 
		var bodyIDsThatCannotYetBeRemoved = [];
		
		for(var i = 0; i < this.bodyIDsToRemove.length; i++)
		{
			var bodyID = this.bodyIDsToRemove[i];
			var body = this.bodies[bodyID];
			if (body == null)
			{
				bodyIDsThatCannotYetBeRemoved.push(bodyID);
			}
			else
			{
				this.bodyRemove(body);
			}
		}
		this.bodyIDsToRemove = bodyIDsThatCannotYetBeRemoved;
	}

	World.prototype.updateForTick_Spawn = function()
	{
		for (var i = 0; i < this.bodiesToSpawn.length; i++)
		{
			var body = this.bodiesToSpawn[i];
			this.bodySpawn(body);
		}
		this.bodiesToSpawn.length = 0;
	}
	
	World.prototype.updateForTick_UpdatesApply = function(updatesToApply)
	{
		for (var i = 0; i < updatesToApply.length; i++)
		{
			var update = updatesToApply[i];
			update.updateWorld(this);
		}
		updatesToApply.length = 0;
	}
}

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

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 )

Google+ photo

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

Connecting to %s