A Key Sequence Recorder in JavaScript

The JavaScript code below implements a key sequence recorder in JavaScript. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript.

When the Record button is clicked, the program will begin recording the times which certain keys (up to five distinct keys) on the keyboard are pressed and released. The recording continues until the Stop button is pressed. This information is displayed graphically as it is recorded or played back, and can also be exported and imported to text.

This code is intended as a first step towards using a keyboard as the user interface for a musical instrument. It might also conceivably be useful for recording sequences of key presses for testing of real-time software, though it would probably need to be adapted somewhat for that purpose.

KeySequenceRecorder.png


<html>
<body>
<div id="divUI">

	<b>Sequence Recorder</b>
	<div>
		<button onclick="buttonRecord_Clicked();">Record</button>
		<button onclick="buttonStop_Clicked();">Stop</button>
		<button onclick="buttonPlay_Clicked();">Play</button></div>
	<div id="divDisplay"></div>
	<div>
		<label>Sequence Serialized:</label>
		<button onclick="buttonExport_Clicked();">Export</button>
		<button onclick="buttonImport_Clicked();">Import</button>

		<textarea id="textareaSequenceSerialized" cols="60" rows="10"></textarea>
	</div>
</div>
<script type="text/javascript">

// event handlers

function buttonImport_Clicked()
{
	var textareaSequenceSerialized = 
		document.getElementById("textareaSequenceSerialized");
	var sequenceSerialized = textareaSequenceSerialized.value;
	var sequence = Sequence.deserialize(sequenceSerialized);
	Session.Instance.sequenceRecorded = sequence;
	Session.Instance.draw();
}

function buttonExport_Clicked()
{
	var sequence = Session.Instance.sequenceRecorded;
	var sequenceSerialized = sequence.serialize();
	var textareaSequenceSerialized = 
		document.getElementById("textareaSequenceSerialized");
	textareaSequenceSerialized.value = sequenceSerialized;
}

function buttonPlay_Clicked()
{
	Session.Instance.play();
}

function buttonRecord_Clicked()
{
	Session.Instance.record();
}

function buttonStop_Clicked()
{
	Session.Instance.stop();
}

// extensions

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

	Array.prototype.remove = function(element)
	{
		var elementIndex = this.indexOf(element);
		if (elementIndex != -1)
		{
			this.splice(elementIndex, 1);
		}
		return this;
	}
}

// classes

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

function Display(size)
{
	this.size = size;
}
{
	Display.prototype.initialize = function()
	{
		this.canvas = document.createElement("canvas");
		this.canvas.width = this.size.x;
		this.canvas.height = this.size.y;
		this.graphics = this.canvas.getContext("2d");
		var divDisplay = document.getElementById("divDisplay");
		divDisplay.appendChild(this.canvas);
	}
	
	// drawing
	
	Display.prototype.clear = function()
	{
		this.graphics.fillStyle = "White";
		this.graphics.fillRect
		(
			0, 0, this.size.x, this.size.y
		);
		
		this.graphics.strokeStyle = "Gray";
		this.graphics.strokeRect
		(
			0, 0, this.size.x, this.size.y
		);		
	}
	
	Display.prototype.drawCircle = function(pos, radius, colorFill, colorBorder)
	{
		this.graphics.beginPath();
		this.graphics.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
	
		if (colorFill != null)
		{
			this.graphics.fillStyle = colorFill;
			this.graphics.fill();
		}
		
		if (colorBorder != null)
		{
			this.graphics.strokeStyle = colorBorder;
			this.graphics.stroke();
		}
	}
	
	Display.prototype.drawLine = function(fromPos, toPos, color)
	{
		this.graphics.beginPath();
		this.graphics.moveTo(fromPos.x, fromPos.y);
		this.graphics.lineTo(toPos.x, toPos.y);
		this.graphics.strokeStyle = color;		
		this.graphics.stroke();
	}
	
	Display.prototype.drawRectangle = function(pos, size, colorFill, colorBorder)
	{
		if (colorFill != null)
		{
			this.graphics.fillStyle = colorFill;
			this.graphics.fillRect
			(
				pos.x, pos.y, size.x, size.y
			);
		}
		
		if (colorBorder != null)
		{
			this.graphics.strokeStyle = colorBorder;
			this.graphics.strokeRect
			(
				pos.x, pos.y, size.x, size.y
			);
		}
	}
	
	Display.prototype.drawText = function(text, pos, color)
	{
		this.graphics.fillStyle = color;
		this.graphics.fillText(text, pos.x, pos.y);
	}
}

function Event(offsetInSeconds, isPressNotRelease)
{
	this.offsetInSeconds = offsetInSeconds;
	this.isPressNotRelease = isPressNotRelease;
}
{	
	Event.prototype.offsetInMilliseconds = function()
	{
		return Math.round(this.offsetInSeconds * 1000);
	}

	// drawable

	Event.MarkerRadius = 3;
	Event.MarkerSize = new Coords(5, 5);
	
	Event.prototype.draw = function(display, pixelsPerSecond, viewOffsetInSecondsMin, yPos)
	{
		var xPos = this.draw_XPos(pixelsPerSecond, viewOffsetInSecondsMin);
		var drawPos = new Coords(xPos, yPos);
	
		if (this.isPressNotRelease == true)
		{
			display.drawCircle(drawPos, Event.MarkerRadius, null, "Gray");
		}
		else
		{
			var markerSize = Event.MarkerSize;
			drawPos.x -= markerSize.x / 2;
			drawPos.y -= markerSize.x / 2;			
			display.drawRectangle(drawPos, markerSize, null, "Gray");
		}
	}
	
	Event.prototype.draw_XPos = function(pixelsPerSecond, viewOffsetInSecondsMin)
	{
		var returnValue = 
			(this.offsetInSeconds - viewOffsetInSecondsMin) 
			* pixelsPerSecond;
			
		return returnValue;
	}
}

function InputHelper()
{
	this.inputsPressed = [];
	this.inputsActive = [];
	this.inputsReleased = [];
}
{
	InputHelper.prototype.initialize = function()
	{
		document.body.onkeydown = this.handleEventKeyDown.bind(this);
		document.body.onkeyup = this.handleEventKeyUp.bind(this);
	}
		
	// events
	
	InputHelper.prototype.handleEventKeyDown = function(event)
	{
		var input = event.key;
		
		if (this.inputsPressed[input] == null)
		{
			this.inputsPressed.push(input);		
			this.inputsPressed[input] = input;
			
			this.inputsActive.push(input);
			this.inputsActive[input] = input;
		}
	}
	
	InputHelper.prototype.handleEventKeyUp = function(event)
	{
		var input = event.key;
		if (this.inputsPressed[input] != null)
		{
			this.inputsPressed.remove(input);
			delete this.inputsPressed[input];

			this.inputsActive.remove(input);
			delete this.inputsActive[input];
			
			this.inputsReleased.push(input);
			this.inputsReleased[input] = input;
		}
	}	
}

function Sequence(tracks)
{
	this.tracks = tracks.addLookups("input");
}
{
	Sequence.prototype.draw = function(display, pixelsPerSecond, cursorOffsetInSeconds)
	{
		var secondsPerDisplay = display.size.x / pixelsPerSecond;	
		var secondsPerDisplayHalf = secondsPerDisplay / 2;
		var viewOffsetMinInSeconds = cursorOffsetInSeconds - secondsPerDisplayHalf;
		var viewOffsetMaxInSeconds = viewOffsetMinInSeconds + secondsPerDisplay;

		display.clear();
		
		for (var t = 0; t < this.tracks.length; t++)
		{
			var track = this.tracks[t];
			var trackPosY = 
				display.size.y / (this.tracks.length + 1) * (t + 1);
			track.draw
			(
				display, 
				pixelsPerSecond, 
				cursorOffsetInSeconds,
				viewOffsetMinInSeconds, 
				viewOffsetMaxInSeconds,
				trackPosY
			);
		}
		
		if (viewOffsetMinInSeconds <= 0)
		{
			var eventDummy = new Event(0, null);
			var zeroPosX = eventDummy.draw_XPos
			(
				pixelsPerSecond, viewOffsetMinInSeconds
			);
		
			display.drawLine
			(
				new Coords(zeroPosX, 0),
				new Coords(zeroPosX, display.size.y),
				"Gray"
			)
		}
		
		var cursorPosX = display.size.x / 2;
		display.drawLine
		(
			new Coords(cursorPosX, 0), 
			new Coords(cursorPosX, display.size.y),
			"Gray"
		);
		
		var cursorOffsetInMilliseconds = Math.round
		(
			cursorOffsetInSeconds * 1000
		);
		
		display.drawText
		(
			"" + cursorOffsetInMilliseconds, 
			new Coords(cursorPosX, display.size.y), 
			"Gray"
		);
	}
	
	// serialization
	
	Sequence.deserialize = function(sequenceSerialized)
	{
		var tracksSerialized = sequenceSerialized.split("\n");
		var tracks = [];
		for (var i = 0; i < tracksSerialized.length; i++)
		{
			var trackSerialized = tracksSerialized[i];
			var track = Track.deserialize(trackSerialized);
			tracks.push(track);
		}
		
		var returnValue = new Sequence(tracks);
		return returnValue;
	}
	
	Sequence.prototype.serialize = function()
	{
		var tracksSerialized = [];
		for (var i = 0; i < this.tracks.length; i++)
		{
			var track = this.tracks[i];
			var trackSerialized = track.serialize();
			tracksSerialized.push(trackSerialized);
		}
		
		var returnValue = tracksSerialized.join("\n");
		return returnValue;
	}
}

function Session(displaySize, pixelsPerSecond)
{
	this.pixelsPerSecond = pixelsPerSecond;

	this.display = new Display(displaySize);	
	this.display.initialize();
	this.display.clear();
	
	this.inputHelper = new InputHelper();
	this.inputHelper.initialize();
	
	this.status = SessionStatus.Stopped;
	
	this.millisecondsPerTimerTick = 50;
	this.numberOfTracksMax = 5;
	this.cursorOffsetInSeconds = 0;
}
{
	Session.Instance = new Session
	(
		new Coords(500, 100), // displaySize
		100 // pixelsPerSecond
	);
	
	Session.prototype.play = function()
	{
		this.status = SessionStatus.Playing;
		this.timeStarted = new Date();
		this.timer = setInterval
		(
			this.play_Output.bind(this),
			this.millisecondsPerTimerTick
		);
	}	
	
	Session.prototype.play_Output = function(event)
	{
		var now = new Date();
		var cursorOffsetInMilliseconds = now - this.timeStarted;
		this.cursorOffsetInSeconds = cursorOffsetInMilliseconds / 1000;
		this.draw();
	}
	
	Session.prototype.record = function()
	{
		this.status = SessionStatus.Recording;
		this.timeStarted = new Date();
		this.sequenceRecorded = new Sequence([]);

		this.timer = setInterval
		(
			this.record_Input.bind(this),
			this.millisecondsPerTimerTick
		);
	}
	
	Session.prototype.record_Input = function()
	{
		var now = new Date();
		var offsetInMilliseconds = now - this.timeStarted;
		this.cursorOffsetInSeconds = offsetInMilliseconds / 1000;
	
		var tracks = this.sequenceRecorded.tracks;

		var inputsActive = this.inputHelper.inputsActive;
		for (var i = 0; i < inputsActive.length; i++)
		{
			var input = inputsActive[i];
			
			var trackForInput = tracks[input];
			if (trackForInput == null)
			{
				if (tracks.length < this.numberOfTracksMax)
				{
					trackForInput = new Track(input, []);
					tracks.push(trackForInput);
					tracks[input] = trackForInput;
				}
				else
				{
					continue;
				}
			}
			
			var event = new Event(this.cursorOffsetInSeconds, true);
			trackForInput.events.push(event);			
			
			delete inputsActive[input];
			inputsActive.remove(input);
		}
		
		var inputsReleased = this.inputHelper.inputsReleased;
		for (var i = 0; i < inputsReleased.length; i++)
		{
			var input = inputsReleased[i];
			var trackForInput = tracks[input];
			
			if (trackForInput != null)
			{
				var event = new Event(this.cursorOffsetInSeconds, false);
				trackForInput.events.push(event);
			}
				
			inputsReleased.remove(input);
			delete inputsReleased[input];
		}
		
		this.draw();
	}
	
	Session.prototype.stop = function()
	{
		this.status = SessionStatus.Stopped;
		if (this.timer != null)
		{
			clearInterval(this.timer);
		}
	}
	
	// drawing
	
	Session.prototype.draw = function()
	{
		this.sequenceRecorded.draw
		(
			this.display, this.pixelsPerSecond, this.cursorOffsetInSeconds
		);	
	}
}

function SessionStatus(name)
{
	this.name = name;
}
{
	SessionStatus.Playing = new SessionStatus("Playing");
	SessionStatus.Recording = new SessionStatus("Recording");
	SessionStatus.Stopped = new SessionStatus("Stopped");	
}

function Span(eventStart, eventStop)
{
	this.eventStart = eventStart;
	this.eventStop = eventStop;
}
{
	Span.prototype.draw = function(display, pixelsPerSecond, viewOffsetMinInSeconds, trackPosY)
	{
		this.eventStart.draw(display, pixelsPerSecond, viewOffsetMinInSeconds, trackPosY);

		var xPosStart = this.eventStart.draw_XPos(pixelsPerSecond, viewOffsetMinInSeconds);
		var xPosStop = this.eventStop.draw_XPos(pixelsPerSecond, viewOffsetMinInSeconds);
		
		display.drawLine
		(
			new Coords(xPosStart, trackPosY),
			new Coords(xPosStop, trackPosY),
			"Gray"
		);
		
		this.eventStop.draw(display, pixelsPerSecond, viewOffsetMinInSeconds, trackPosY);
	}
	
	// serialization
	
	Span.deserialize = function(spanSerialized)
	{
		var parts = spanSerialized.split("_");
		var eventStart = new Event((parseFloat(parts[0]) / 1000), true);
		var eventStop = new Event((parseFloat(parts[1]) / 1000), false);
		var returnValue = new Span(eventStart, eventStop);
		return returnValue;
	}
	
	Span.prototype.serialize = function()
	{
		var returnValue = 
			this.eventStart.offsetInMilliseconds() + "_" 
			+ this.eventStop.offsetInMilliseconds();
		return returnValue;
	}
}

function Track(input, events)
{
	this.input = input;
	this.events = events;
}
{
	// drawing

	Track.prototype.draw = function
	(
		display, 
		pixelsPerSecond, 
		cursorOffsetInSeconds, 
		viewOffsetMinInSeconds, 
		viewOffsetMaxInSeconds, 
		yPos
	)
	{	
		display.drawLine
		(
			new Coords(0, yPos),
			new Coords(display.size.x, yPos),			
			"LightGray"
		);
	
		display.drawText
		(
			this.input, new Coords(0, yPos), "Gray"
		);
				
		var eventPrev = null;
	
		for (var i = 0; i < this.events.length; i++) 		
		{
 			var event = this.events[i];
 			if (event.offsetInSeconds >= viewOffsetMinInSeconds)
			{
				if (event.offsetInSeconds >= viewOffsetMaxInSeconds)
				{
					break;
				}
				else
				{					
					var span = null;
					if (event.isPressNotRelease == true)
					{
						var eventNext = this.events[i + 1];
						if (eventNext == null)
						{
							var eventDummy = new Event
							(
								cursorOffsetInSeconds, false
							);
							span = new Span(event, eventDummy)
						}
						else
						{
							span = new Span(event, eventNext);
						}
					}
					else if (eventPrev != null)
					{
						span = new Span(eventPrev, event);	
					}
					
					if (span != null)
					{
						span.draw(display, pixelsPerSecond, viewOffsetMinInSeconds, yPos);
					}
				}
			}
			
			eventPrev = event;
			
		} // end for each event
	}
	
	// serialization
	
	Track.deserialize = function(trackSerialized)
	{
		var parts = trackSerialized.split("=");
		var input = parts[0];
		var spansSerialized = parts[1].split(",");
		var events = [];
		for (var i = 0; i < spansSerialized.length; i++)
		{
			var spanSerialized = spansSerialized[i];
			var span = Span.deserialize(spanSerialized);
			events.push(span.eventStart);
			events.push(span.eventStop);
		}
		
		var returnValue = new Track(input, events);
		return returnValue;
	}
	
	Track.prototype.serialize = function()
	{
		var spansSerialized = [];
		for (var e = 0; e < this.events.length; e += 2)
		{
			var eventStart = this.events[e];
			var eventStop = this.events[e + 1];
			var span = new Span(eventStart, eventStop);
			var spanSerialized = span.serialize();
			spansSerialized.push(spanSerialized);
		}
		
		var returnValue = 
			this.input + "=" + spansSerialized.join(",");
		return returnValue;
	}
}

</script>

</body>
</html>

Advertisements
Posted in Uncategorized | Tagged , | Leave a comment

A Scrolling Map Tile Engine in JavaScript

The JavaScript below implements a simple engine for allowing an animated character to move around a map made of tiles. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript.

This code was originally intended to be the first steps in implementing the exploration engine for a role-playing game.

ScrollableMapWithAnimatedCharacter.png


<html>
<body>

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

<script type="text/javascript">

// main

function main()
{
	var display = new Display(new Coords(200, 200));
	var mapCellSizeInPixels = new Coords(16, 16);
	var world = World.demo(display.sizeInPixels, mapCellSizeInPixels);
	var universe = new Universe
	(
		10, // timerTicksPerSecond
		display, 
		world
	);
	universe.start();
}

// extensions

function ArrayExtensions()
{
	// Extension class.
}
{
	Array.prototype.addLookups = function(keyName)
	{
		for (var i = 0; i < this.length; i++)
		{
			var element = this[i];
			var key = element[keyName];
			this[key] = element;
		}
		return this;
	}
	
	Array.prototype.clone = function()
	{
		var returnValues = [];
		for (var i = 0; i < this.length; i++)
		{
			var element = this[i];
			var elementCloned = element.clone();
			returnValues.push(elementCloned);
		}
		return returnValues;
	}
	
	Array.prototype.remove = function(element)
	{
		var elementIndex = this.indexOf(element);
		if (elementIndex >= 0)
		{
			this.splice(elementIndex, 1);
		}
		return this;
	}
}

// classes

function Activity(defnName, target)
{
	this.defnName = defnName;
	this.target = target;
}
{
	Activity.prototype.defn = function(world)
	{
		return world.activityDefns[this.defnName];
	}
	
	Activity.prototype.perform = function(universe, world, venue, actor)
	{
		this.defn(world).perform(universe, world, venue, actor, this);
	}
}

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

function Camera(viewSize, pos)
{
	this.viewSize = viewSize;
	this.pos = pos;
	
	this.viewSizeHalf = this.viewSize.clone().half();
}

function Color(name, code, componentsRGBA)
{
	this.name = name;
	this.code = code;
	this.componentsRGBA = componentsRGBA;
	
	this.systemColor = 
		"rgba(" 
		+ Math.floor(this.componentsRGBA[0] * Color.ComponentMax) + ","
		+ Math.floor(this.componentsRGBA[1] * Color.ComponentMax) + ","
		+ Math.floor(this.componentsRGBA[2] * Color.ComponentMax) + ","
		+ this.componentsRGBA[3] // ?
		+ ")";	
}
{
	Color.ComponentMax = 255;

	Color.Instances = function()
	{
		if (Color._instances == null)
		{
			Color._instances = new Color_Instances();
		}
		return Color._instances;
	}
	
	function Color_Instances()
	{
		this._Transparent = new Color("Transparent", ".", [0, 0, 0, 0]);
		this.Black 	= new Color("Black", "k", [0, 0, 0, 1]);
		this.Blue 	= new Color("Blue", "b", [0, 0, 1, 1]);
		this.Cyan 	= new Color("Cyan", "c", [0, 1, 1, 1]);	
		this.Gray 	= new Color("Gray", "a", [.5, .5, .5, 1]);
		this.GrayDark = new Color("GrayDark", "A", [.25, .25, .25, 1]);
		this.GrayLight = new Color("GrayLight", "-", [.75, .75, .75, 1]);		
		this.Green 	= new Color("Green", "g", [0, 1, 0, 1]);
		this.Orange = new Color("Orange", "o", [1, .5, 0, 1]);
		this.Red 	= new Color("Red", "r", [1, 0, 0, 1]);
		this.Violet = new Color("Violet", "v", [1, 0, 1, 1]);
		this.White 	= new Color("White", "w", [1, 1, 1, 1]);
		this.Yellow = new Color("Yellow", "y", [1, 1, 0, 1]);		

		this._All = 
		[
			this._Transparent,
			this.Black,
			this.Blue,
			this.Cyan,
			this.Gray,
			this.GrayDark,
			this.GrayLight,
			this.Green,
			this.Orange,
			this.Red,
			this.Violet,
			this.White,
			this.Yellow,
		].addLookups("code");
	}
}

function Constraint_Follow(target)
{
	this.target = target;
}
{
	Constraint_Follow.prototype.apply = function(constrainable)
	{
		constrainable.pos.overwriteWith(this.target.pos);
	}
}

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.addXY = function(x, y)
	{
		this.x += x;
		this.y += y;
		return this;
	}
	
	Coords.prototype.ceiling = function()
	{
		this.x = Math.ceil(this.x);
		this.y = Math.ceil(this.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.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.floor = function()
	{
		this.x = Math.floor(this.x);
		this.y = Math.floor(this.y);
		return this;
	}
		
	Coords.prototype.half = function()
	{
		return this.divideScalar(2);
	}
	
	Coords.prototype.isInRangeMax = function(max)
	{
		var returnValue = 
		(
			this.x >= 0
			&& this.x <= max.x
			&& this.y >= 0
			&& this.y <= max.y
		);
		
		return returnValue;
	}
		
	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.overwriteWith = function(other)
	{
		this.x = other.x;
		this.y = other.y;
		return this;
	}
	
	Coords.prototype.overwriteWithXY = function(x, y)
	{
		this.x = x;
		this.y = y;
		return this;
	}
	
	Coords.prototype.round = function()
	{
		this.x = Math.round(this.x);
		this.y = Math.round(this.y);
		return this;
	}
			
	Coords.prototype.subtract = function(other)
	{
		this.x -= other.x;
		this.y -= other.y;
		return this;
	}
	
	Coords.prototype.trimToRangeMax = function(max)
	{
		if (this.x < 0)
		{
			this.x = 0;
		}
		else if (this.x > max.x)
		{
			this.x = max.x;
		}
		
		if (this.y < 0)
		{
			this.y = 0;
		}
		else if (this.y > max.y)
		{
			this.y = max.y;
		}
		
		return this;
	}
}

function Display(sizeInPixels)
{
	this.sizeInPixels = sizeInPixels;
	this.fontSizeInPixels = Math.floor(this.sizeInPixels.y / 32);
	
	this.sizeInPixelsHalf = this.sizeInPixels.clone().half();
	
	this.drawPos = new Coords();
}
{
	Display.prototype.initialize = function()
	{
		this.canvas = document.createElement("canvas");
		this.canvas.width = this.sizeInPixels.x;
		this.canvas.height = this.sizeInPixels.y;
		this.graphics = this.canvas.getContext("2d");
		this.graphics.font = this.fontSizeInPixels + "px sans-serif";
		return this;
	}
	
	Display.prototype.toImage = function(name)
	{
		var dataURL = this.canvas.toDataURL();
		var systemImage = document.createElement("img");
		systemImage.src = dataURL;
		var returnValue = new Image(name, this.sizeInPixels, systemImage);
		return returnValue;
	}
	
	// drawing
	
	Display.prototype.clear = function()
	{
		this.graphics.fillStyle = "White";
		this.graphics.fillRect(0, 0, this.sizeInPixels.x, this.sizeInPixels.y);
		this.graphics.strokeStyle = "Gray";
		this.graphics.strokeRect(0, 0, this.sizeInPixels.x, this.sizeInPixels.y);

		return this;		
	}
		
	Display.prototype.clearRectangle = function(pos, size)
	{
		this.graphics.clearRect(pos.x, pos.y, size.x, size.y);
		return this; 
	}
	
	Display.prototype.drawCircle = function(center, radius, colorFill, colorBorder)
	{
		this.graphics.beginPath();		
		this.graphics.arc(center.x, center.y, radius, 0, Polar.RadiansPerTurn);

		if (colorFill != null)
		{
			this.graphics.fillStyle = colorFill;
			this.graphics.fill();	
		}
		
		if (colorBorder != null)
		{
			this.graphics.strokeStyle = colorBorder;
			this.graphics.stroke();	
		}
		
		return this;
	}
	
	Display.prototype.drawImage = function(image, pos)
	{
		this.graphics.drawImage(image.systemImage, pos.x, pos.y);
		
		return this;
	}
	
	Display.prototype.drawImageRegion = function(image, sourcePos, sourceSize, targetPos)
	{
		var targetSize = sourceSize;
	
		this.graphics.drawImage
		(
			image.systemImage, 
			sourcePos.x, sourcePos.y,
			sourceSize.x, sourceSize.y,
			targetPos.x, targetPos.y,
			targetSize.x, targetSize.y
		);
		
		return this;
	}
		
	Display.prototype.drawPolygon = function(vertices, colorFill, colorBorder)
	{
		this.graphics.beginPath();
		var vertex = vertices[0];
		this.graphics.moveTo(vertex.x, vertex.y);
		for (var i = 1; i < vertices.length; i++)
		{
			vertex = vertices[i];
			this.graphics.lineTo(vertex.x, vertex.y);
		}
		this.graphics.closePath();

		if (colorFill != null)
		{
			this.graphics.fillStyle = colorFill;
			this.graphics.fill();	
		}
		
		if (colorBorder != null)
		{
			this.graphics.strokeStyle = colorBorder;
			this.graphics.stroke();	
		}
		
		return this;
	}
		
	Display.prototype.drawRectangle = function(pos, size, colorFill, colorBorder)
	{
		if (colorFill != null)
		{
			this.graphics.fillStyle = colorFill;
			this.graphics.fillRect(pos.x, pos.y, size.x, size.y);
		}
		
		if (colorBorder != null)
		{
			this.graphics.strokeStyle = colorBorder;
			this.graphics.strokeRect(pos.x, pos.y, size.x, size.y);
		}
		
		return this;
	}
	
	Display.prototype.drawText = function(text, pos, colorFill, colorBorder)
	{
		if (colorBorder != null)
		{
			this.graphics.strokeStyle = color;
			this.graphics.strokeString(text, pos.x, pos.y);
		}
	
		if (colorFill != null)
		{
			this.graphics.fillStyle = color;
			this.graphics.fillString(text, pos.x, pos.y);
		}
		
		return this;
	}
}

function Image(name, size, systemImage)
{
	this.name = name;
	this.size = size;
	this.systemImage = systemImage;
	
	this.sizeHalf = this.size.clone().half();
}
{
	Image.fromStrings = function(name, colors, pixelsAsStrings)
	{
		var size = new Coords
		(
			pixelsAsStrings[0].length, pixelsAsStrings.length
		);
		var canvas = document.createElement("canvas");
		canvas.width = size.x;
		canvas.height = size.y;
		var graphics = canvas.getContext("2d");
		
		for (var y = 0; y < size.y; y++)
		{
			var pixelRowAsString = pixelsAsStrings[y];
			
			for (var x = 0; x < size.x; x++)
			{
				var pixelColorCode = pixelRowAsString[x];
				var pixelColor = colors[pixelColorCode];
				graphics.fillStyle = pixelColor.systemColor;
				graphics.fillRect(x, y, 1, 1);
			}
		}
		
		var systemImage = document.createElement("img");
		systemImage.src = canvas.toDataURL();
		
		var returnValue = new Image
		(
			name, size, systemImage
		);
		
		return returnValue;
	}
	
	Image.prototype.toDisplay = function()
	{
		return new Display(this.size).initialize().drawImage(this, new Coords(0, 0));
	}
}

function InputHelper()
{
	this.inputsPressed = [];
	this.inputsActive = [];
}
{
	InputHelper.prototype.initialize = function()
	{
		document.body.onkeydown = this.handleEventKeyDown.bind(this);
		document.body.onkeyup = this.handleEventKeyUp.bind(this);
	}
	
	InputHelper.prototype.inputActivate = function(input)
	{
		if (this.inputsActive[input] == null)
		{
			this.inputsActive[input] = input;
			this.inputsActive.push(input);
		}	
	}	
	
	InputHelper.prototype.inputAdd = function(input)
	{
		if (this.inputsPressed[input] == null)
		{
			this.inputsPressed[input] = input;
			this.inputsPressed.push(input);
			this.inputActivate(input);
		}	
	}
	
	InputHelper.prototype.inputInactivate = function(input)
	{
		if (this.inputsActive[input] != null)
		{
			delete this.inputsActive[input];
			this.inputsActive.remove(input);
		}
	}
	
	InputHelper.prototype.inputRemove = function(input)
	{
		this.inputInactivate(input);
		if (this.inputsPressed[input] != null)
		{
			delete this.inputsPressed[input];
			this.inputsPressed.remove(input);
		}
	}
	
	// events
	
	InputHelper.prototype.handleEventKeyDown = function(event)
	{
		this.inputAdd(event.key);
	}
	
	InputHelper.prototype.handleEventKeyUp = function(event)
	{
		this.inputRemove(event.key);
	}	
}

function Map(cellSizeInPixels, terrains, cellsAsStrings)
{
	this.cellSizeInPixels = cellSizeInPixels;
	this.terrains = terrains.addLookups("code");
	this.cellsAsStrings = cellsAsStrings;
	
	this.sizeInCells = new Coords
	(
		cellsAsStrings[0].length, cellsAsStrings.length
	);
	
	this.sizeInCellsMinusOnes = this.sizeInCells.clone().addXY
	(
		-1, -1
	);
	
	this.cellPos = new Coords();
	this.drawPos = new Coords();
}
{
	Map.prototype.terrainAtPosInCells = function(posInCells)
	{
		var terrainCode = this.cellsAsStrings[posInCells.y][posInCells.x];
		var terrain = this.terrains[terrainCode];
		return terrain;
	}

	// drawable

	Map.prototype.draw = function(universe, world, display, visualCamera)
	{
		var cellPos = this.cellPos;
		var drawPos = this.drawPos;
		var cell = {};
		cell.pos = drawPos;
		cell.velInCellsPerTick = new Coords(0, 0); // hack
		var halves = new Coords(.5, .5);
		var ones = new Coords(1, 1);
		
		var camera = visualCamera.camera;
		var cameraPos = camera.pos;
		var cameraViewSizeHalf = camera.viewSizeHalf;
		var cellPosMin = cameraPos.clone().subtract
		(
			cameraViewSizeHalf
		).divide
		(
			this.cellSizeInPixels
		).floor().trimToRangeMax
		(
			this.sizeInCellsMinusOnes
		);
		var cellPosMax = cameraPos.clone().add
		(
			cameraViewSizeHalf
		).divide
		(
			this.cellSizeInPixels
		).ceiling().trimToRangeMax
		(
			this.sizeInCellsMinusOnes
		);
						
		for (var y = cellPosMin.y; y <= cellPosMax.y; y++)
		{
			cellPos.y = y;
			
			for (var x = cellPosMin.x; x <= cellPosMax.x; x++)
			{
				cellPos.x = x;
				
				drawPos.overwriteWith
				(
					cellPos
				).add(halves).multiply
				(
					this.cellSizeInPixels
				);
				
				var terrain = this.terrainAtPosInCells(cellPos);
				var terrainVisual = terrain.visual;
				visualCamera.child = terrainVisual;				
				visualCamera.draw
				(
					universe, world, display, cell
				);
			}
		}

		var sizeDiminished = this.sizeInCellsMinusOnes.clone().addXY(-1, -1);
		
		var cornerPosMin = cellPosMin.clone().addXY(-1, -1).trimToRangeMax(this.sizeInCells);
		var cornerPosMax = cellPosMax.trimToRangeMax(sizeDiminished);
		var cornerPos = new Coords();
		var neighborOffsets = 
		[
			new Coords(0, 0),		
			new Coords(1, 0),
			new Coords(0, 1),			
			new Coords(1, 1),
		];
		var neighborPos = new Coords();
		var neighborTerrains = [];
				
		for (var y = cornerPosMin.y; y <= cornerPosMax.y; y++)
		{
			cornerPos.y = y;
			
			for (var x = cornerPosMin.x; x <= cornerPosMax.x; x++)
			{
				cornerPos.x = x;
				
				var neighborOffset = neighborOffsets[0];
				neighborPos.overwriteWith(cornerPos).add(neighborOffset);
				var terrainHighestSoFar = this.terrainAtPosInCells(neighborPos);
				
				for (var n = 1; n < neighborOffsets.length; n++)
				{
					var neighborOffset = neighborOffsets[n];
					neighborPos.overwriteWith(cornerPos).add(neighborOffset);
					var neighborTerrain = this.terrainAtPosInCells(neighborPos);
					var zLevelDifference = 
						neighborTerrain.zLevelForOverlays 
						- terrainHighestSoFar.zLevelForOverlays;
					if (zLevelDifference > 0)
					{
						terrainHighestSoFar = neighborTerrain;
					}
				}
				
				var terrainHighest = terrainHighestSoFar;
				var visualChildIndexSoFar = 0;
		
				for (var n = 0; n < neighborOffsets.length; n++)
				{
					var neighborOffset = neighborOffsets[n];
					neighborPos.overwriteWith(cornerPos).add(neighborOffset);
					var neighborTerrain = this.terrainAtPosInCells(neighborPos);
					if (neighborTerrain != terrainHighest)
					{
						visualChildIndexSoFar |= (1 << n);
					}
				}
				
				if (visualChildIndexSoFar > 0)
				{
					drawPos.overwriteWith
					(
						cornerPos
					).add(ones).multiply
					(
						this.cellSizeInPixels
					);
					
					var terrainVisual = terrainHighest.visual.children[visualChildIndexSoFar];
					if (terrainVisual != null) // hack
					{
						visualCamera.child = terrainVisual;				
						visualCamera.draw
						(
							universe, world, display, cell
						);
					}
				}
			}
		}
	}
}

function MapTerrain(name, code, blocksMovement, zLevelForOverlays, visual)
{
	this.name = name;
	this.code = code;
	this.blocksMovement = blocksMovement;
	this.zLevelForOverlays = zLevelForOverlays;
	this.visual = visual;
}

function MapTerrainVisual(children)
{
	this.children = children;
	
	var childNames = MapTerrainVisual.ChildNames;
		
	for (var i = 0; i < childNames.length; i++)
	{
		var childName = childNames[i];
		var child = this.children[i];
		this.children[childName] = child;
	}
}
{
	MapTerrainVisual.TestInstance = function()
	{
		// Helpful for debugging.	
		var radius = 3;
		var size = new Coords(5, 5);
		return new MapTerrainVisual
		(
			[
				new VisualRectangle(size, null, "Black"), // 0000 - center
				new VisualCircle(radius, "Red", "Black"), // 0001 - inside se
				new VisualCircle(radius, "Orange", "Black"), // 0010 - inside sw
				new VisualCircle(radius, "Yellow","Black"), // 0011 - edge n
				new VisualCircle(radius, "Green", "Black"), // 0100 - inside ne
				new VisualCircle(radius, "Blue", "Black"),  // 0101 - edge w
				new VisualCircle(radius, "Violet", "Black"), // 0110 - diagonal
				new VisualCircle(radius, "Gray", "Black"),  // 0111 - outside se
				new VisualRectangle(size, "Red", "Black"), // 1000 - inside nw
				new VisualRectangle(size, "Orange", "Black"), // 1001 - diagonal?
				new VisualRectangle(size, "Yellow", "Black"), // 1010 - edge e
				new VisualRectangle(size, "Green", "Black"), // 1011 - outside sw
				new VisualRectangle(size, "Blue", "Black"), // 1100 - edge s
				new VisualRectangle(size, "Violet", "Black"), // 1101 - outside ne
				new VisualRectangle(size, "Gray", "Black"), // 1110 - outside nw
				new VisualRectangle(size, null, "Red"), // 1111 // Never
			]
		);
	}
	
	MapTerrainVisual.ChildNames = 
	[
		"Center",
		"InsideSE",
		"InsideSW",	
		"EdgeN",		
		"InsideNE",
		"EdgeW",
		"DiagonalSlash",
		"OutsideSE",
		"InsideNW",
		"DiagonalBackslash",
		"EdgeE",
		"OutsideSW",
		"EdgeS",
		"OutsideNE",
		"OutsideNW"
	]


	MapTerrainVisual.prototype.draw = function(universe, world, display, drawable)
	{
		this.children["Center"].draw(universe, world, display, drawable);
	}
}

function Mover(name, visual, activity, posInCells)
{
	this.name = name;
	this.visual = visual;
	this.activity = activity;
	this.posInCells = posInCells;

	this.pos = new Coords();
	this.posInCellsNext = new Coords();
	this.posInCellsNextFloor = new Coords();	
	this.velInCellsPerTick = new Coords(0, 0);
}
{
	Mover.prototype.updateForTimerTick = function(universe, world, venue)
	{		
		this.posInCellsNext.overwriteWith
		(
			this.posInCells
		).add
		(
			this.velInCellsPerTick
		);
		
		var map = venue.map;		
		this.posInCellsNextFloor.overwriteWith(this.posInCellsNext).floor();		
		var mapTerrain = map.terrainAtPosInCells(this.posInCellsNextFloor);
		if (mapTerrain.blocksMovement == false)
		{
			this.posInCells.overwriteWith(this.posInCellsNext);
		}
		
		this.pos.overwriteWith
		(
			this.posInCells
		).multiply
		(
			map.cellSizeInPixels
		);
		
		this.activity.perform(universe, world, venue, this);		
	}
	
	// drawable
	
	Mover.prototype.draw = function(universe, world, visualCamera)
	{
		visualCamera.child = this.visual;
		visualCamera.draw(universe, world, universe.display, this);
	}	
}

function Polar(azimuthInTurns, radius)
{
	this.azimuthInTurns = azimuthInTurns;
	this.radius = radius;
}
{
	Polar.RadiansPerTurn = Math.PI * 2.0;

	Polar.prototype.fromCoords = function(coords)
	{
		var azimuthInRadians = Math.atan2(coords.y, coords.x);
		var azimuthInTurns = azimuthInRadians / Polar.RadiansPerTurn;
		if (azimuthInTurns < 0)
		{
			azimuthInTurns += 1;
		}
		this.azimuthInTurns = azimuthInTurns;
		this.radius = coords.magnitude();
		return this;
	}
}

function Portal(defnName, posInCells, destinationVenueName, destinationPosInCells)
{
	this.defnName = defnName;
	this.posInCells = posInCells.addXY(.5, .5);
	this.destinationVenueName = destinationVenueName;
	this.destinationPosInCells = destinationPosInCells;
	
	this.pos = new Coords();
}
{
	Portal.prototype.defn = function(world)
	{
		return world.portalDefns[this.defnName];
	}

	Portal.prototype.activate = function(universe, world, venue, actor)
	{
		var mover = actor;
		venue.moversToRemove.push(mover);
		var venueNext = world.venues[this.destinationVenueName];
		venueNext.movers.splice(0, 0, mover);
		mover.posInCells.overwriteWith(this.destinationPosInCells);
		world.venueNext = venueNext;
	}
	
	Portal.prototype.updateForTimerTick = function(universe, world, venue)
	{
		this.pos.overwriteWith(this.posInCells).multiply(venue.map.cellSizeInPixels);	
	}
	
	// drawable
		
	Portal.prototype.draw = function(universe, world, visualCamera)
	{
		var defn = this.defn(world);
		var visual = defn.visual;
		visualCamera.child = visual;
		visualCamera.draw(universe, world, universe.display, this);
	}
}

function PortalDefn(name, visual)
{
	this.name = name;
	this.visual = visual;
}

function Universe(timerTicksPerSecond, display, world)
{
	this.timerTicksPerSecond = timerTicksPerSecond;
	this.display = display;
	this.world = world;
	
	this.secondsPerTimerTick = 1 / this.timerTicksPerSecond;
}
{
	Universe.prototype.start = function()
	{
		var divMain = document.getElementById("divMain");
		divMain.appendChild(this.display.initialize().canvas);
				
		var timerTicksPerSecond = 10;
		var msPerSecond = 1000;
		var msPerTimerTick = Math.floor(msPerSecond / timerTicksPerSecond);
		this.timer = setInterval
		(
			this.updateForTimerTick.bind(this),
			msPerTimerTick
		);

		this.inputHelper = new InputHelper();
		this.inputHelper.initialize();
		
		this.world.initialize(this);		
	}
	
	Universe.prototype.updateForTimerTick = function()
	{
		this.world.draw(this);		
		this.world.updateForTimerTick(this);
	}
}

function VisualAnimation(framesPerSecond, frames)
{
	this.framesPerSecond = framesPerSecond;
	this.frames = frames;
	
	this.durationInSeconds = this.frames.length / this.framesPerSecond;
}
{
	VisualAnimation.prototype.draw = function(universe, world, display, drawable)
	{
		if (drawable.secondsSinceAnimationStarted == null)
		{
			drawable.secondsSinceAnimationStarted = 0;
		}
		
		var frameIndexCurrent = Math.floor
		(
			drawable.secondsSinceAnimationStarted * this.framesPerSecond
		);
		
		var frameCurrent = this.frames[frameIndexCurrent];
		frameCurrent.draw(universe, world, display, drawable);
		
		drawable.secondsSinceAnimationStarted += universe.secondsPerTimerTick;
		if (drawable.secondsSinceAnimationStarted >= this.durationInSeconds)
		{
			drawable.secondsSinceAnimationStarted -= this.durationInSeconds;
		}
	}
}

function VisualCamera(camera, child)
{
	this.camera = camera;
	this.child = child;
	
	this.drawablePosOriginal = new Coords();
}
{
	VisualCamera.prototype.draw = function(universe, world, display, drawable)
	{
		this.drawablePosOriginal.overwriteWith(drawable.pos);
		drawable.pos.subtract
		(
			this.camera.pos
		).add
		(
			display.sizeInPixelsHalf
		);
		this.child.draw(universe, world, display, drawable);
		drawable.pos.overwriteWith(this.drawablePosOriginal);
	}
}

function VisualCircle(radius, colorFill, colorBorder)
{
	this.radius = radius;
	this.colorFill = colorFill;
	this.colorBorder = colorBorder;
}
{
	VisualCircle.prototype.draw = function(universe, world, display, drawable)
	{
		display.drawCircle(drawable.pos, this.radius, this.colorFill, this.colorBorder);
	}
}

function VisualDirectional(visualAtRest, visualsForDirections)
{
	this.visualAtRest = visualAtRest;
	this.visualsForDirections = visualsForDirections;
	
	this.polar = new Polar();
}
{
	VisualDirectional.prototype.draw = function(universe, world, display, drawable)
	{
		var visualToDraw = null;
		var vel = drawable.velInCellsPerTick;
		if (vel.magnitude() == 0)
		{
			visualToDraw = this.visualAtRest;
		}
		else
		{
			this.polar.fromCoords(vel);
			var azimuthInTurns = this.polar.azimuthInTurns;
			var directionIndex = Math.floor(azimuthInTurns * this.visualsForDirections.length);
			visualToDraw = this.visualsForDirections[directionIndex];
		}
		visualToDraw.draw(universe, world, display, drawable);
	}
}

function VisualGroup(children)
{
	this.children = children;
}
{
	VisualGroup.prototype.draw = function(universe, world, display, drawable)
	{
		for (var i = 0; i < this.children.length; i++)
		{
			var child = this.children[i];
			child.draw(universe, world, display, drawable);
		}
	}
}

function VisualImage(image, size)
{
	this.image = image;
	this.size = (size == null ? this.image.size : size);
	
	this.sizeHalf = this.size.clone().half();
	
	this.drawPos = new Coords();
}
{
	VisualImage.manyFromImages = function(images)
	{
		var returnValues = [];
		for (var i = 0; i < images.length; i++)
		{
			var image = images[i];
			var visual = (image == null ? null : new VisualImage(image));
			returnValues.push(visual);
		}
		return returnValues;
	}

	VisualImage.prototype.draw = function(universe, world, display, drawable)
	{		
		var drawPos = this.drawPos.overwriteWith
		(
			drawable.pos
		).subtract
		(
			this.sizeHalf
		);
	
		display.drawImage(this.image, drawPos);
	}
}

function VisualImageRegion(image, offset, size)
{
	this.image = image;
	this.offset = offset;
	this.size = size;
	
	this.sizeHalf = this.size.clone().half();
	
	this.drawPos = new Coords();
}
{
	VisualImageRegion.prototype.draw = function(universe, world, display, drawable)
	{		
		var drawPos = this.drawPos.overwriteWith
		(
			drawable.pos
		).subtract
		(
			this.sizeHalf
		);
	
		display.drawImageRegion(this.image, this.offset, this.size, drawPos);
	}
}

function VisualOffset(offset, child)
{
	this.offset = offset;
	this.child = child;
	
	this.drawablePosOriginal = new Coords();
}
{
	VisualOffset.prototype.draw = function(universe, world, display, drawable)
	{
		this.drawablePosOriginal.overwriteWith(drawable.pos);
		drawable.pos.add(this.offset);
		this.child.draw(universe, world, display, drawable);
		drawable.pos.overwriteWith(this.drawablePosOriginal);
	}
}

function VisualPolygon(vertices, colorFill, colorBorder)
{
	this.vertices = vertices;
	this.colorFill = colorFill;
	this.colorBorder = colorBorder;
	
	this.verticesTransformed = this.vertices.clone();
}
{
	VisualPolygon.prototype.draw = function(universe, world, display, drawable)
	{
		for (var i = 0; i < this.vertices.length; i++)
		{
			var vertex = this.vertices[i];
			var vertexTransformed = this.verticesTransformed[i];
			
			vertexTransformed.overwriteWith
			(
				vertex
			).add
			(
				drawable.pos
			);
		}
		
		display.drawPolygon(this.verticesTransformed, this.colorFill, this.colorBorder);
	}
}

function VisualRectangle(size, colorFill, colorBorder)
{
	this.size = size;
	this.colorFill = colorFill;
	this.colorBorder = colorBorder;	
	
	this.sizeHalf = this.size.clone().half();
	
	this.drawPos = new Coords();
}
{
	VisualRectangle.prototype.draw = function(universe, world, display, drawable)
	{
		var drawPos = this.drawPos.overwriteWith
		(
			drawable.pos
		).subtract
		(
			this.sizeHalf
		);
		
		display.drawRectangle(drawPos, this.size, this.colorFill, this.colorBorder);
	}
}

function VisualText(text)
{
	this.text = text;
}
{
	VisualText.prototype.draw = function(universe, world, display, drawable)
	{
		display.drawText(this.text, drawable.pos);
	}
}

function Venue(name, camera, map, portals, movers)
{
	this.name = name;
	this.camera = camera;
	this.map = map;
	this.portals = portals;
	this.movers = movers;
	
	this.moversToRemove = [];	
}
{
	Venue.prototype.initialize = function(universe, world)
	{
		this.constraintCameraFollowPlayer = new Constraint_Follow
		(
			this.movers[0]
		);

		this.updateForTimerTick(universe, world);
	}

	Venue.prototype.updateForTimerTick = function(universe, world)
	{			
		for (var i = 0; i < this.portals.length; i++)
		{
			var portal = this.portals[i];
			portal.updateForTimerTick(universe, world, this);
		}
	
		for (var i = 0; i < this.movers.length; i++)
		{
			var mover = this.movers[i];
			mover.updateForTimerTick(universe, world, this);	
		}
		
		for (var i = 0; i < this.moversToRemove.length; i++)
		{
			var mover = this.moversToRemove[i];
			if (mover == this.constraintCameraFollowPlayer.target)
			{
				this.constraintCameraFollowPlayer.target = this.camera;
			}
			this.movers.remove(mover);
		}		
		this.moversToRemove.length = 0;
	}
	
	// drawable
	
	Venue.prototype.draw = function(universe, world)
	{
		this.constraintCameraFollowPlayer.apply(this.camera);
	
		universe.display.clear();
	
		var visualCamera = new VisualCamera(this.camera);
		
		this.map.draw(universe, this, universe.display, visualCamera);
				
		for (var i = 0; i < this.portals.length; i++)
		{
			var portal = this.portals[i];
			portal.draw(universe, world, visualCamera);
		}
		
		for (var i = 0; i < this.movers.length; i++)
		{
			var mover = this.movers[i];
			mover.draw(universe, world, visualCamera);
		}
	}

}

function World(name, portalDefns, activityDefns, venues)
{
	this.name = name;
	this.portalDefns = portalDefns.addLookups("name");
	this.activityDefns = activityDefns.addLookups("name");
	this.venues = venues.addLookups("name");
	
	this.venueNext = this.venues[0];
}
{
	World.demo = function(displaySizeInPixels, cellSizeInPixels)
	{				
		var portalSize = cellSizeInPixels.clone().multiplyScalar(.75);;
		var portalDefns = 
		[
			new PortalDefn
			(
				"PortalTown",
				new VisualPolygon
				(
					[
						new Coords(-.5, 0).multiply(portalSize),
						new Coords(.5, 0).multiply(portalSize),
						new Coords(.5, -.5).multiply(portalSize),
						new Coords(0, -1).multiply(portalSize),
						new Coords(-.5, -.5).multiply(portalSize),
					],
					"LightGreen", "Green"
				)
			),
			new PortalDefn
			(
				"PortalExit",
				new VisualPolygon
				(
					[
						new Coords(-.5, 0).multiply(portalSize),
						new Coords(0, -.5).multiply(portalSize),
						new Coords(0, -.25).multiply(portalSize),
						new Coords(.5, -.25).multiply(portalSize),
						new Coords(.5, .25).multiply(portalSize),
						new Coords(0, .25).multiply(portalSize),
						new Coords(0, .5).multiply(portalSize),
					],
					"LightGreen", "Green"
				)
			)
			
		];
		
		var activityDefns = 
		[
			new ActivityDefn
			(
				"DoNothing", 
				function perform(universe, world, venue, actor, activity) 
				{
					// Do nothing.
				} 
			),
			
			new ActivityDefn
			(
				"MoveRandomly",
				function perform(universe, world, venue, actor, activity) 
				{
					while (activity.target == null)
					{
						actor.posInCells.round();
						var directionToMove = new Coords();
						var heading = Math.floor(4 * Math.random());
						if (heading == 0)
						{
							directionToMove.overwriteWithXY(0, 1);
						}
						else if (heading == 1)
						{
							directionToMove.overwriteWithXY(-1, 0);
						}
						else if (heading == 2)
						{
							directionToMove.overwriteWithXY(1, 0);
						}
						else if (heading == 3)
						{
							directionToMove.overwriteWithXY(0, -1);
						}
						
						var target = actor.posInCells.clone().add
						(
							directionToMove
						);
						
						if (target.isInRangeMax(venue.map.sizeInCells) == true)
						{
							var terrainAtTarget = venue.map.terrainAtPosInCells(target);
							if (terrainAtTarget.blocksMovement == false)
							{
								activity.target = target;
							}
						}
					}
					
					var target = activity.target;
					
					var displacementToTarget = target.clone().subtract
					(
						actor.posInCells
					);
					
					var distanceToTarget = displacementToTarget.magnitude();

					var speedInCellsPerTick = 0.1;
					
					if (distanceToTarget <= speedInCellsPerTick)
					{
						actor.posInCells.overwriteWith(target);
						activity.target = null;
					}
					else
					{
						var directionToTarget = displacementToTarget.divideScalar
						(
							distanceToTarget
						);
						actor.velInCellsPerTick.overwriteWith
						(
							directionToTarget
						).multiplyScalar
						(
							speedInCellsPerTick
						);
					}
				}
			),
			
			new ActivityDefn
			(
				"UserInputAccept", 
				function perform(universe, world, venue, actor, activity) 
				{
					var actorVel = actor.velInCellsPerTick;
					actorVel.clear();
					var inputHelper = universe.inputHelper;
					var inputsActive = inputHelper.inputsActive;
					for (var i = 0; i < inputsActive.length; i++)
					{
						var input = inputsActive[i];
						if (input == null)
						{
							// do nothing
						}
						else if (input.startsWith("Arrow") == true)
						{
							if (input == "ArrowDown")
							{
								actorVel.overwriteWithXY(0, 1);
							}
							else if (input == "ArrowLeft")
							{
								actorVel.overwriteWithXY(-1, 0);
							}
							else if (input == "ArrowRight")
							{
								actorVel.overwriteWithXY(1, 0);
							}
							else if (input == "ArrowUp")
							{
								actorVel.overwriteWithXY(0, -1);
							}
							
							var speedInCellsPerTick = 0.1;
							actorVel.multiplyScalar(speedInCellsPerTick);							
						}
						else if (input == "Enter")
						{
							inputHelper.inputInactivate(input);
							var displacement = new Coords();
							var portals = venue.portals;
							for (var i = 0; i < portals.length; i++)
							{
								var portal = portals[i];
								var distance = displacement.overwriteWith
								(
									portal.pos
								).subtract
								(
									actor.pos
								).magnitude();
								var distanceMax = venue.map.cellSizeInPixels.x;
								if (distance <= distanceMax)
								{
									portal.activate(universe, world, venue, actor);
								}
							}
						}
					}
				} 
			),
			
		];
		
		var colors = Color.Instances()._All;

		var mapTerrainVisualDesert = new MapTerrainVisual(VisualImage.manyFromImages
		([
			Image.fromStrings
			(
				"DesertCenter", 
				colors, 
				[ 
					"yywwyywwyywwyyww",
					"ywwyywwyywwyywwy",
					"wwyywwyywwyywwyy",
					"wyywwyywwyywwyyw",
					"yywwyywwyywwyyww",
					"ywwyywwyywwyywwy",
					"wwyywwyywwyywwyy",
					"wyywwyywwyywwyyw",
					"yywwyywwyywwyyww",
					"ywwyywwyywwyywwy",
					"wwyywwyywwyywwyy",
					"wyywwyywwyywwyyw",
					"yywwyywwyywwyyww",
					"ywwyywwyywwyywwy",
					"wwyywwyywwyywwyy",
					"wyywwyywwyywwyyw",
				]
			),
			Image.fromStrings
			(
				"DesertInsideSE", 
				colors, 
				[ 
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"aaaawyyw........",
					"yywwyyww........",
					"ywwyywwy........",
					"wwyywwyy........",
					"wyywwyyw........",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
				]
			),			
			Image.fromStrings
			(
				"DesertInsideSW", 
				colors, 
				[ 
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywaaaa",
					"........yywwyyww",
					"........ywwyywwy",
					"........wwyywwyy",
					"........wyywwyyw",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
				]
			),			
			Image.fromStrings
			(
				"DesertEdgeN", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"aaaaaaaaaaaaaaaa",
					"yywwyywwyywwyyww",
					"ywwyywwyywwyywwy",
					"wwyywwyywwyywwyy",
					"wyywwyywwyywwyyw",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
				]
			),
			Image.fromStrings
			(
				"DesertInsideNE", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"yywwyyww........",
					"ywwyywwy........",
					"wwyywwyy........",
					"wyywwyyw........",
					"aaaayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
				]
			),
			Image.fromStrings
			(
				"DesertEdgeW", 
				colors, 
				[ 
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
				]
			),			
			Image.fromStrings
			(
				"DesertDiagonalBackslash", 
				colors, 
				[ 
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywaaaa",
					"........yywwyyww",
					"........ywwyywwy",
					"........wwyywwyy",
					"........wyywwyyw",
					"yywwyyww........",
					"ywwyywwy........",
					"wwyywwyy........",
					"wyywwyyw........",
					"aaaayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
				]
			),
			Image.fromStrings
			(
				"DesertOutsideNW", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"...aaaaaaaaaaaaa",
					"...ayywwyywwyyww",
					"...aywwyywwyywwy",
					"...awwyywwyywwyy",
					"...awyywwyywwyyw",
					"...ayywwy.......",
					"...aywwyy.......",
					"...awwyyw.......",
					"...awyyww.......",
					"...ayywwy.......",
					"...aywwyy.......",
					"...awwyyw.......",
					"...awyyww.......",
				]
			),
			Image.fromStrings
			(
				"DesertInsideNW", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"........yywwyyww",
					"........ywwyywwy",
					"........wwyywwyy",
					"........wyywwyyw",
					"........yywwaaaa",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
				]
			),
			Image.fromStrings
			(
				"DesertDiagonalSlash", 
				colors, 
				[ 
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"aaaawyyw........",
					"yywwyyww........",
					"ywwyywwy........",
					"wwyywwyy........",
					"wyywwyyw........",
					"........yywwyyww",
					"........ywwyywwy",
					"........wwyywwyy",
					"........wyywwyyw",
					"........yywwaaaa",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
				]
			),
			Image.fromStrings
			(
				"DesertEdgeE", 
				colors, 
				[ 
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
				]
			),			
			Image.fromStrings
			(
				"DesertOutsideNE", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"aaaaaaaaaaaaa...",
					"yywwyywwyywwa...",
					"ywwyywwyywwya...",
					"wwyywwyywwyya...",
					"wyywwyywwyywa...",
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
				]
			),
			Image.fromStrings
			(
				"DesertEdgeS", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"yywwyywwyywwyyww",
					"ywwyywwyywwyywwy",
					"wwyywwyywwyywwyy",
					"wyywwyywwyywwyyw",
					"aaaaaaaaaaaaaaaa",
					"................",
					"................",
					"................",
				]
			),
			Image.fromStrings
			(
				"DesertOutsideSW", 
				colors, 
				[ 
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
					"...ayyww........",
					"...aywwy........",
					"...awwyy........",
					"...awyyw........",
					"...ayywwyywwyyww",
					"...aywwyywwyywwy",
					"...awwyywwyywwyy",
					"...awyywwyywwyyw",
					"...aaaaaaaaaaaaa",
					"................",
					"................",
					"................",
				]
			),
			Image.fromStrings
			(
				"DesertOutsideSE", 
				colors, 
				[ 
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
					"........yywwa...",
					"........ywwya...",
					"........wwyya...",
					"........wyywa...",
					"yywwyywwyywwa...",
					"ywwyywwyywwya...",
					"wwyywwyywwyya...",
					"wyywwyywwyywa...",
					"aaaaaaaaaaaaa...",
					"................",
					"................",
					"................",
				]
			),
		]));
		
		var mapTerrainVisualRock = new MapTerrainVisual(VisualImage.manyFromImages
		([
			Image.fromStrings
			(
				"RockCenter", 
				colors, 
				[ 
					"--AA--AA--AA--AA",
					"-AA--AA--AA--AA-",
					"AA--AA--AA--AA--",
					"A--AA--AA--AA--A",
					"--AA--AA--AA--AA",
					"-AA--AA--AA--AA-",
					"AA--AA--AA--AA--",
					"A--AA--AA--AA--A",
					"--AA--AA--AA--AA",
					"-AA--AA--AA--AA-",
					"AA--AA--AA--AA--",
					"A--AA--AA--AA--A",
					"--AA--AA--AA--AA",
					"-AA--AA--AA--AA-",
					"AA--AA--AA--AA--",
					"A--AA--AA--AA--A",
				]
			),
			Image.fromStrings
			(
				"RockInsideSE", 
				colors, 
				[ 
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"aaaaA--A........",
					"--AA--AA........",
					"-AA--AA-........",
					"AA--AA--........",
					"A--AA--A........",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
				]
			),			
			Image.fromStrings
			(
				"RockInsideSW", 
				colors, 
				[ 
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aaaaa",
					"........--AA--AA",
					"........-AA--AA-",
					"........AA--AA--",
					"........A--AA--A",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
				]
			),			
			Image.fromStrings
			(
				"RockEdgeN", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"aaaaaaaaaaaaaaaa",
					"--AA--AA--AA--AA",
					"-AA--AA--AA--AA-",
					"AA--AA--AA--AA--",
					"A--AA--AA--AA--A",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
				]
			),
			Image.fromStrings
			(
				"RockInsideNE", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"--AA--AA........",
					"-AA--AA-........",
					"AA--AA--........",
					"A--AA--A........",
					"aaaa--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
				]
			),
			Image.fromStrings
			(
				"RockEdgeW", 
				colors, 
				[ 
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
				]
			),			
			Image.fromStrings
			(
				"RockDiagonalBackslash", 
				colors, 
				[ 
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aaaaa",
					"........--AA--AA",
					"........-AA--AA-",
					"........AA--AA--",
					"........A--AA--A",
					"--AA--AA........",
					"-AA--AA-........",
					"AA--AA--........",
					"A--AA--A........",
					"aaaa--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
				]
			),
			Image.fromStrings
			(
				"RockOutsideNW", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"...aaaaaaaaaaaaa",
					"...a--AA--AA--AA",
					"...a-AA--AA--AA-",
					"...aAA--AA--AA--",
					"...aA--AA--AA--A",
					"...a--AA-.......",
					"...a-AA--.......",
					"...aAA--A.......",
					"...aA--AA.......",
					"...a--AA-.......",
					"...a-AA--.......",
					"...aAA--A.......",
					"...aA--AA.......",
				]
			),
			Image.fromStrings
			(
				"RockInsideNW", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"........--AA--AA",
					"........-AA--AA-",
					"........AA--AA--",
					"........A--AA--A",
					"........--AAaaaa",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
				]
			),
			Image.fromStrings
			(
				"RockDiagonalSlash", 
				colors, 
				[ 
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"aaaaA--A........",
					"--AA--AA........",
					"-AA--AA-........",
					"AA--AA--........",
					"A--AA--A........",
					"........--AA--AA",
					"........-AA--AA-",
					"........AA--AA--",
					"........A--AA--A",
					"........--AAaaaa",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
				]
			),
			Image.fromStrings
			(
				"RockEdgeE", 
				colors, 
				[ 
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
				]
			),			
			Image.fromStrings
			(
				"RockOutsideNE", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"aaaaaaaaaaaaa...",
					"--AA--AA--AAa...",
					"-AA--AA--AA-a...",
					"AA--AA--AA--a...",
					"A--AA--AA--Aa...",
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
				]
			),
			Image.fromStrings
			(
				"RockEdgeS", 
				colors, 
				[ 
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"................",
					"--AA--AA--AA--AA",
					"-AA--AA--AA--AA-",
					"AA--AA--AA--AA--",
					"A--AA--AA--AA--A",
					"aaaaaaaaaaaaaaaa",
					"................",
					"................",
					"................",
				]
			),
			Image.fromStrings
			(
				"RockOutsideSW", 
				colors, 
				[ 
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
					"...a--AA........",
					"...a-AA-........",
					"...aAA--........",
					"...aA--A........",
					"...a--AA--AA--AA",
					"...a-AA--AA--AA-",
					"...aAA--AA--AA--",
					"...aA--AA--AA--A",
					"...aaaaaaaaaaaaa",
					"................",
					"................",
					"................",
				]
			),
			Image.fromStrings
			(
				"RockOutsideSE", 
				colors, 
				[ 
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
					"........--AAa...",
					"........-AA-a...",
					"........AA--a...",
					"........A--Aa...",
					"--AA--AA--AAa...",
					"-AA--AA--AA-a...",
					"AA--AA--AA--a...",
					"A--AA--AA--Aa...",
					"aaaaaaaaaaaaa...",
					"................",
					"................",
					"................",
				]
			),
		]));
		
		//mapTerrainVisualDesert = MapTerrainVisual.TestInstance();
				
		var mapTerrains = 
		[
			new MapTerrain
			(
				"Desert", 
				".", 
				false, // blocksMovement
				1, // zLevelForOverlays
				mapTerrainVisualDesert
			),
			new MapTerrain
			(
				"Rocks", 
				"x", 
				true, // blocksMovement 
				2, // zLevelForOverlays
				mapTerrainVisualRock
			),
			
			new MapTerrain
			(
				"Water", 
				"~", 
				true, // blocksMovement 
				0, // zLevelForOverlays
				new MapTerrainVisual([new VisualImage
				(
					Image.fromStrings
					(
						"Water", 
						colors, 
						[ 
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",
							"cccccccccccccccc",								
						]
					)
				)])
			)
		];
					
		var moverVisual = new VisualDirectional
		(
			// visualAtRest
			new VisualGroup
			([
				new VisualPolygon
				(
					[
						new Coords(0, -1).multiply(cellSizeInPixels),
						new Coords(-.5, 0).multiply(cellSizeInPixels),
						new Coords(.5, 0).multiply(cellSizeInPixels),
					],
					"Gray", null
				),
				new VisualOffset
				(
					new Coords(0, -cellSizeInPixels.y / 2),
					new VisualCircle(cellSizeInPixels.x / 4, "Tan", null)
				)
			]),
			// visualsForDirections
			[
				// east
				new VisualAnimation
				(
					4, // framesPerSecond
					[
						new VisualPolygon
						(
							[
								new Coords(.4, -1).multiply(cellSizeInPixels),
								new Coords(-.5, 0).multiply(cellSizeInPixels),
								new Coords(.5, 0).multiply(cellSizeInPixels),
							],
							"Gray", null
						),
						new VisualPolygon
						(
							[
								new Coords(.5, -.9).multiply(cellSizeInPixels),
								new Coords(-.5, 0).multiply(cellSizeInPixels),
								new Coords(.5, 0).multiply(cellSizeInPixels),
							],
							"Gray", null
						),						
					]
				),
				
				// south
				new VisualAnimation
				(
					4, // framesPerSecond
					[
						new VisualGroup
						([
							new VisualPolygon
							(
								[
									new Coords(0, -1).multiply(cellSizeInPixels),
									new Coords(-.5, 0).multiply(cellSizeInPixels),
									new Coords(.5, 0).multiply(cellSizeInPixels),
								],
								"Gray", null
							),
							new VisualOffset
							(
								new Coords(0, -cellSizeInPixels.y * .5),
								new VisualCircle(cellSizeInPixels.x / 4, "Tan", null)
							)
						]),

						new VisualGroup
						([
							new VisualPolygon
							(
								[
									new Coords(0, -.9).multiply(cellSizeInPixels),
									new Coords(-.5, 0).multiply(cellSizeInPixels),
									new Coords(.5, 0).multiply(cellSizeInPixels),
								],
								"Gray", null
							),
							new VisualOffset
							(
								new Coords(0, -cellSizeInPixels.y * .4),							
								new VisualCircle(cellSizeInPixels.x / 4, "Tan", null)
							)
						]),							
					]
				),

				// west
				new VisualAnimation
				(
					4, // framesPerSecond
					[
						new VisualPolygon
						(
							[
								new Coords(-.4, -1).multiply(cellSizeInPixels),
								new Coords(-.5, 0).multiply(cellSizeInPixels),
								new Coords(.5, 0).multiply(cellSizeInPixels),
							],
							"Gray", null
						),
						new VisualPolygon
						(
							[
								new Coords(-.5, -.9).multiply(cellSizeInPixels),
								new Coords(-.5, 0).multiply(cellSizeInPixels),
								new Coords(.5, 0).multiply(cellSizeInPixels),
							],
							"Gray", null
						),						
					]
				),

				// north
				new VisualAnimation
				(
					4, // framesPerSecond
					[
						new VisualPolygon
						(
							[
								new Coords(0, -1).multiply(cellSizeInPixels),
								new Coords(-.5, 0).multiply(cellSizeInPixels),
								new Coords(.5, 0).multiply(cellSizeInPixels),
							],
							"Gray", null
						),
						
						new VisualPolygon
						(
							[
								new Coords(0, -.9).multiply(cellSizeInPixels),
								new Coords(-.5, 0).multiply(cellSizeInPixels),
								new Coords(.5, 0).multiply(cellSizeInPixels),
							],
							"Gray", null
						),							
					]
				),
			]
		);
						
		var venues = 
		[ 
			new Venue
			(
				"Overworld",
				new Camera(displaySizeInPixels, new Coords(0, 0)),
				new Map
				(
					cellSizeInPixels,
					mapTerrains,
					[
						"~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~",
						"~....x.........................~",
						"~..........~~..................~",
						"~.........~~~~.................~",
						"~.........~~~~...xx............~",
						"~..........~~....x...xx........~",
						"~..............xxxx.x.x........~",
						"~................x.............~",
						"~.........~.~.......x..........~",
						"~..........~...................~",
						"~..............................~",
						"~..............................~",
						"~..............................~",
						"~..............................~",
						"~..............................~",
						"~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~",
					]
				),
				[
					new Portal
					(
						"PortalTown", 
						new Coords(17, 9),
						"Lonelytown", // destinationVenueName
						new Coords(1, 4) // destinationPosInCells
					)
				],
				[
					new Mover
					(
						"Player",
						moverVisual,
						new Activity("UserInputAccept", null), 
						new Coords(16, 8) // posInCells
					),
				]
			),
			
			new Venue
			(
				"Lonelytown",
				new Camera(displaySizeInPixels, new Coords(0, 0)),
				new Map
				(
					cellSizeInPixels,
					mapTerrains,
					[
						"xxxxxxxxxxxxxxxx",
						"x..............x",
						"x..............x",
						"x..............x",
						"x..............x",
						"x..............x",
						"x..............x",
						"xxxxxxxxxxxxxxxx",
					]
				),
				[
					new Portal
					(
						"PortalExit", 
						new Coords(1, 4), // posInCells
						"Overworld", // destinationVenueName
						new Coords(17, 9) // destinationPosInCells
					)
				],
				[
					new Mover
					(
						"Stranger",
						moverVisual,
						new Activity("MoveRandomly", null), 
						new Coords(4, 4) // posInCells
					),
				]
			),
			
		];
	
		var returnValue = new World
		(
			"WorldDemo",
			portalDefns,
			activityDefns,
			venues
		);
		
		return returnValue;
	}
	
	// instance methods
	
	World.prototype.initialize = function(universe)
	{
		this.updateForTimerTick(universe);
	}
		
	World.prototype.updateForTimerTick = function(universe)
	{
		if (this.venueNext != null)
		{
			this.venueCurrent = this.venueNext;
			this.venueCurrent.initialize(universe, this);
			this.venueNext = null;
		}
		this.venueCurrent.updateForTimerTick(universe, this);
	}
	
	World.prototype.draw = function(universe)
	{
		this.venueCurrent.draw(universe, this, universe.display);
	}
		
}

// run

main();

</script>

</body>
</html>

Posted in Uncategorized | Tagged , , , , | Leave a comment

A Brick-Breaking Game in JavaScript

Below is a simple brick-breaking game implemented in JavaScript. Its gameplay is similar to the classic games Breakout and Arkanoid. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript.

BrickBreakingGame.png


<html>
<body>
<script type="text/javascript">

// main

function main()
{
	var displaySize = new Coords(100, 200);

	var display = new Display(displaySize);

	var world = World.random(displaySize);

	Globals.Instance.initialize
	(
		10, // ticksPerSecond
		display,
		world
	);
}

// extensions

function ArrayExtensions()
{
	// extension class
}
{
	Array.prototype.remove = function(element)
	{
		var elementIndex = this.indexOf(element);
		if (elementIndex >= 0)
		{
			this.splice(elementIndex, 1);
		}
		return this;
	}
}

// classes

function Activity(perform)
{
	this.perform = perform;
}
{
	Activity.Instances = new Activity_Instances()
	
	function Activity_Instances()
	{
		this.DoNothing = new Activity(function perform() {});
		this.UserInputAccept = new Activity
		(
			function perform(world, actor)
			{
				var inputHelper = Globals.Instance.inputHelper;
				var inputsActive = inputHelper.keysPressed;

				for (var i = 0; i < inputsActive.length; i++)
				{
					var inputActive = inputsActive[i];
					if (inputActive == "ArrowLeft")
					{
						actor.vel.x -= actor.accelPerTick;
					}
					else if (inputActive == "ArrowRight")
					{
						actor.vel.x += actor.accelPerTick;
					}
				}
			}
		);
	}
}

function Actor(pos, activity)
{
	this.pos = pos;
	this.activity = activity;

	this.color = "Gray";
	this.radius = 8;

	this.vel = new Coords(0, 0);

	this.accelPerTick = .005;
	this.speedMax = .25;

	this.projectileRadius = this.radius / 4;
	this.projectileSpeed = .3;

	// Helper variables.

	this.coordsTemp = new Coords();
	this.vertices = 
	[
		new Coords(), new Coords(), new Coords()
	];
}
{
	Actor.prototype.updateForTimerTick = function(world)
	{
		this.activity.perform(world, this);

		var speed = this.vel.magnitude();
		if (speed >= this.speedMax)
		{
			this.vel.normalize().multiplyScalar(this.speedMax);
		}

		this.pos.add(this.vel);
		this.pos.trimToRangeMinMax
		(
			world.actorPosMin,
			world.actorPosMax
		);

		this.vel.multiplyScalar(.98); // friction
	}

	// drawable

	Actor.prototype.drawToDisplay = function(display)
	{
		display.drawCircle
		(
			this.pos, this.radius, "Gray"
		);
	}
}

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	Coords.Instances = new Coords_Instances();

	function Coords_Instances()
	{
		this.Zeroes = new Coords(0, 0);
	}

	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;
	}

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

	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.random = function()
	{
		this.x = Math.random();
		this.y = Math.random();
		return this;
	}

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

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

	Coords.prototype.trimToRangeMinMax = function(min, max)
	{
		if (this.x < min.x)
		{
			this.x = min.x;
		}
		else if (this.x > max.x)
		{
			this.x = max.x;
		}

		while (this.y < min.y)
		{
			this.y = min.y;
		}
		while (this.y > max.y)
		{
			this.y = max.y;
		}

		return this;
	}
}

function CollisionHelper()
{
	this.displacement = new Coords();
	this.edgeForward = new Coords();
	this.edgeRight = new Coords();
}
{
	CollisionHelper.Instance = new CollisionHelper();

	CollisionHelper.prototype.doCirclesCollide = function
	(
		circle0Center, circle0Radius, circle1Center, circle1Radius
	)
	{
		var distanceBetweenCenters = this.displacement.overwriteWith
		(
			circle1Center
		).subtract
		(
			circle0Center
		).magnitude();

		var sumOfRadii = circle0Radius + circle1Radius;

		var returnValue = (distanceBetweenCenters < sumOfRadii);

		return returnValue;
	}
}

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

	this.colorBack = "White";
	this.colorFore = "Gray";
}
{
	Display.prototype.initialize = function()
	{
		var canvas = document.createElement("canvas");
		canvas.width = this.size.x;
		canvas.height = this.size.y;
		this.graphics = canvas.getContext("2d");

		document.body.appendChild(canvas);
	}

	// drawing

	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.drawCircle = function(center, radius, colorBorder)
	{
		this.graphics.strokeStyle = colorBorder;

		this.graphics.beginPath();
		this.graphics.arc(center.x, center.y, radius, 0, Polar.RadiansPerTurn);
		this.graphics.stroke();
	}

	Display.prototype.drawLine = function(fromPos, toPos, color)
	{
		this.graphics.strokeStyle = color;
		this.graphics.beginPath();
		this.graphics.moveTo(fromPos.x, fromPos.y);
		this.graphics.lineTo(toPos.x, toPos.y);
		this.graphics.stroke();
	}

	Display.prototype.drawText = function(text, height, pos, color)
	{
		this.graphics.strokeStyle = this.colorBack;
		this.graphics.strokeText(text, pos.x, pos.y + height);

		this.graphics.fillStyle = color;
		this.graphics.fillText(text, pos.x, pos.y + height);
	}
}

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

	Globals.prototype.initialize = function(timerTicksPerSecond, display, world)
	{
		this.display = display;
		this.display.initialize();

		this.world = world;

		this.inputHelper = new InputHelper();
		
		var millisecondsPerTimerTick = Math.floor(1000 / this.timerTicksPerSecond);
		this.timer = setInterval
		(
			this.handleEventTimerTick.bind(this), 
			millisecondsPerTimerTick
		);

		this.inputHelper.initialize();
	}

	// events

	Globals.prototype.handleEventTimerTick = function()
	{
		this.world.drawToDisplay(this.display);
		this.world.updateForTimerTick();
	}
}

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

	InputHelper.prototype.removeKey = function(key)
	{
		if (this.keysPressed[key] != null)
		{
			this.keysPressed.splice(this.keysPressed.indexOf(key), 1);
			delete this.keysPressed[key];
		}
	}

	// events 

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

	InputHelper.prototype.handleEventKeyUp = function(event)
	{
		this.removeKey(event.key);
	}

}

function Polar(azimuthInTurns, radius)
{
	this.azimuthInTurns = azimuthInTurns;
	this.radius = radius;
}
{
	Polar.RadiansPerTurn = Math.PI * 2;

	Polar.prototype.toCoords = function(coords)
	{
		var azimuthInRadians = this.azimuthInTurns * Polar.RadiansPerTurn; 
		coords.x = Math.cos(azimuthInRadians) * this.radius;
		coords.y = Math.sin(azimuthInRadians) * this.radius;
		return coords;
	}

	Polar.prototype.trimAzimuthToRangeMinMax = function(min, max)
	{
		if (this.azimuthInTurns < min)
		{
			this.azimuthInTurns = min;
		}
		else if (this.azimuthInTurns > max)
		{
			this.azimuthInTurns = max;
		}
		return this;
	}
}

function Obstacle(radius, pos)
{
	this.radius = radius;
	this.pos = pos;
}
{
	// drawable

	Obstacle.prototype.drawToDisplay = function(display)
	{
		display.drawCircle(this.pos, this.radius, "Gray");
	}
}

function Projectile(pos, vel)
{
	this.pos = pos;
	this.vel = vel;

	this.radius = 2;
	this.color = "Gray";
}
{
	Projectile.prototype.updateForTimerTick = function(world)
	{
		this.pos.add(this.vel);

		if (this.pos.x < 0 || this.pos.x > world.size.x)
		{
			this.vel.x *= -1;
		}

		if (this.pos.y < 0)
		{
			this.vel.y *= -1;
		} 
		else if (this.pos.y > world.size.y)
		{
			world.projectiles.length = 0;
		}

		this.updateForTimerTick_Collisions(world);
	}

	Projectile.prototype.updateForTimerTick_Collisions = function(world)
	{
		var collisionHelper = CollisionHelper.Instance;

		var obstacles = world.obstacles;

		for (var i = 0; i < obstacles.length; i++)
		{
			var obstacle = obstacles[i];
			var doProjectileAndObstacleCollide = collisionHelper.doCirclesCollide
			(
				this.pos, this.radius,
				obstacle.pos, obstacle.radius
			);

			if (doProjectileAndObstacleCollide == true)
			{
				this.collideWithOther(obstacle);
				obstacles.remove(obstacle);
				i--;
				break;
			}
		}
	
		var actors = world.actors;

		for (var i = 0; i < actors.length; i++)
		{
			var actor = actors[i];
			var doProjectileAndActorCollide = collisionHelper.doCirclesCollide
			(
				this.pos, this.radius,
				actor.pos, actor.radius
			);
			if (doProjectileAndActorCollide == true)
			{
				this.collideWithOther(actor);
			}
		}
	}

	// drawable

	Projectile.prototype.drawToDisplay = function(display)
	{
		display.drawLine
		(
			this.pos, 
			this.pos.clone().subtract(this.vel), 
			this.color
		);

		display.drawCircle
		(
			this.pos, this.radius, this.color
		);
	}

	// collidable

	Projectile.prototype.collideWithOther = function(other)
	{
		var posAfterCollision = new Coords();
		var velAfterCollision = new Coords();
		var displacementBetweenCenters = new Coords();
		var velocityRelative = new Coords();
  
		var sumOfBodyRadii = this.radius + other.radius; 
 
		displacementBetweenCenters.overwriteWith
		(
			other.pos
		).subtract
		(
			this.pos
		);
 
		var distanceBetweenCenters = displacementBetweenCenters.magnitude();
 
		var normalAtCollision = displacementBetweenCenters.divideScalar
		(
			distanceBetweenCenters
		);
 
		var velocityAlongNormal = normalAtCollision.multiplyScalar
		(
			this.vel.dotProduct
			(
				normalAtCollision
			)
		);

		this.vel.add
		(
			velocityAlongNormal.multiplyScalar(-2)
		)
	}
}

function World(size, actor, obstacles, projectiles)
{
	this.size = size;
	this.actors = [ actor ];
	this.obstacles = obstacles;
	this.projectiles = projectiles;

	this.actorPosMin = new Coords(actor.radius, actor.pos.y);
	this.actorPosMax = new Coords(this.size.x - actor.radius, actor.pos.y);
}
{
	World.random = function(size)
	{
		var obstacleGridSizeInCells = new Coords(8, 8);
		var spaceBetweenObstacles = new Coords(1, 1).multiplyScalar
		(
			size.x / (obstacleGridSizeInCells.x + 1)
		);

		var actorPos = new Coords
		(
			size.x / 2,
			size.y - spaceBetweenObstacles.y
		);

		var actor = new Actor
		(
			actorPos,
			Activity.Instances.UserInputAccept
		);

		var obstacleRadius = Math.floor(spaceBetweenObstacles.x / 2);

		var obstacles = [];
		for (var y = 1; y <= obstacleGridSizeInCells.y; y++)
		for (var x = 1; x <= obstacleGridSizeInCells.x; x++)
		{
			var pos = new Coords(x, y).multiply(spaceBetweenObstacles);

			var obstacle = new Obstacle
			(
				obstacleRadius, pos
			);

			obstacles.push(obstacle);
		}

		var projectile = new Projectile
		(
			actor.pos.clone().subtract
			(
				new Coords(0, actor.radius + actor.projectileRadius)
			),
			new Coords(1, -1).normalize().multiplyScalar
			(
				actor.projectileSpeed
			)
		);
		
		var projectiles = [ projectile ];

		var returnValue = new World
		(
			size,
			actor,
			obstacles,
			projectiles
		);

		return returnValue;
	}

	// instance methods

	World.prototype.updateForTimerTick = function()
	{		
		for (var i = 0; i < this.actors.length; i++)
		{
			var actor = this.actors[i];
			actor.updateForTimerTick(this);
		}

		for (var i = 0; i < this.projectiles.length; i++)
		{
			var projectile = this.projectiles[i];
			projectile.updateForTimerTick(this);
		}
	}

	// drawable

	World.prototype.drawToDisplay = function(display)
	{
		display.clear();

		for (var i = 0; i < this.obstacles.length; i++)
		{
			var obstacle = this.obstacles[i];
			obstacle.drawToDisplay(display);
		}

		for (var i = 0; i < this.actors.length; i++)
		{
			var actor = this.actors[i];
			actor.drawToDisplay(display);
		}

		for (var i = 0; i < this.projectiles.length; i++)
		{
			var projectile = this.projectiles[i];
			projectile.drawToDisplay(display);
		}
	}
}

// run

main();

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

Posted in Uncategorized | Tagged , , | Leave a comment

An Asteroids Clone in JavaScript

Below is a simple clone of the classic video game Asteroids implemented in JavaScript. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript.

Asteroids.png


<html>
<body>
<script type="text/javascript">

// main

function main()
{
	var displaySize = new Coords(200, 200);

	var display = new Display(displaySize);

	var world = World.random(displaySize);

	Globals.Instance.initialize
	(
		10, // ticksPerSecond
		display,
		world
	);
}

// extensions

function ArrayExtensions()
{
	// extension class
}
{
	Array.prototype.remove = function(element)
	{
		var elementIndex = this.indexOf(element);
		if (elementIndex >= 0)
		{
			this.splice(elementIndex, 1);
		}
		return this;
	}
}

// classes

function Activity(perform)
{
	this.perform = perform;
}
{
	Activity.Instances = new Activity_Instances()
	
	function Activity_Instances()
	{
		this.DoNothing = new Activity(function perform() {});
		this.UserInputAccept = new Activity
		(
			function perform(world, actor)
			{
				var inputHelper = Globals.Instance.inputHelper;
				var inputsActive = inputHelper.keysPressed;

				for (var i = 0; i < inputsActive.length; i++)
				{
					var inputActive = inputsActive[i];
					if (inputActive == "ArrowLeft")
					{
						actor.forward.subtract
						(
							actor.right.clone().multiplyScalar
							(
								actor.turnsPerTick
							)
						).normalize();
						actor.right.overwriteWith(actor.forward).right();
					}
					else if (inputActive == "ArrowRight")
					{
						actor.forward.add
						(
							actor.right.clone().multiplyScalar
							(
								actor.turnsPerTick
							)
						).normalize();
						actor.right.overwriteWith(actor.forward).right();
					}
					else if (inputActive == "ArrowUp")
					{
						actor.vel.add
						(
							actor.forward.clone().multiplyScalar
							(
								actor.accelPerTick
							)
						);
					}
					else if (inputActive == "Enter")
					{
						if (world.projectiles.length > 0)
						{
							return;
						}

						var projectilePos = actor.forward.clone().multiplyScalar
						(
							actor.lengthHalf
						).add
						(
							actor.pos
						);

						var projectileVel = actor.forward.clone().multiplyScalar
						(
							actor.projectileSpeed
						);

						var projectile = new Projectile
						(
							projectilePos, projectileVel
						);
						world.projectiles.push(projectile);

						inputHelper.removeKey(inputActive)
					}
				}
			}
		);
	}
}

function Actor(pos, activity)
{
	this.pos = pos;
	this.activity = activity;

	this.color = "Gray";
	this.widthHalf = 3;
	this.lengthHalf = 4;

	this.forward = new Coords(1, 0);
	this.right = this.forward.clone().right();

	this.vel = new Coords(0, 0);

	this.accelPerTick = .0025;
	this.turnsPerTick = .02;
	this.speedMax = .25;
	this.projectileSpeed = 1; 

	// Helper variables.

	this.coordsTemp = new Coords();
	this.vertices = 
	[
		new Coords(), new Coords(), new Coords()
	];
}
{
	Actor.prototype.updateForTimerTick = function(world)
	{
		this.activity.perform(world, this);

		var speed = this.vel.magnitude();
		if (speed >= this.speedMax)
		{
			this.vel.normalize().multiplyScalar(this.speedMax);
		}

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

		var collisionHelper = CollisionHelper.Instance;

		var obstacles = world.obstacles;
		for (var i = 0; i < obstacles.length; i++)
		{
			var obstacle = obstacles[i];
			var doActorAndObstacleCollide = collisionHelper.doCirclesCollide
			(
				this.pos, this.widthHalf, // hack
				obstacle.pos, obstacle.radius
			);
			if (doActorAndObstacleCollide == true)
			{
				world.actors.remove(this);
			}
		}
	}

	// drawable

	Actor.prototype.drawToDisplay = function(display)
	{
		this.vertices[0].overwriteWith
		(
			this.forward
		).multiplyScalar
		(
			this.lengthHalf
		).add
		(
			this.pos
		);

		var back = this.coordsTemp.overwriteWith
		(
			this.forward	
		).multiplyScalar
		(
			0 - this.lengthHalf
		).add
		(
			this.pos
		);
		
		this.vertices[1].overwriteWith
		(
			this.right
		).multiplyScalar
		(
			this.widthHalf
		).add
		(
			back
		);

		this.vertices[2].overwriteWith
		(
			this.right
		).multiplyScalar
		(
			0 - this.widthHalf
		).add
		(
			back
		);

		display.drawPolygon(this.vertices, this.color);
	}
}

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	Coords.Instances = new Coords_Instances();

	function Coords_Instances()
	{
		this.Zeroes = new Coords(0, 0);
	}

	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;
	}

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

	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.random = function()
	{
		this.x = Math.random();
		this.y = Math.random();
		return this;
	}

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

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

	Coords.prototype.wrapToRangeMax = 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 CollisionHelper()
{
	this.displacement = new Coords();
	this.edgeForward = new Coords();
	this.edgeRight = new Coords();
}
{
	CollisionHelper.Instance = new CollisionHelper();

	CollisionHelper.prototype.doCirclesCollide = function
	(
		circle0Center, circle0Radius, circle1Center, circle1Radius
	)
	{
		var distanceBetweenCenters = this.displacement.overwriteWith
		(
			circle1Center
		).subtract
		(
			circle0Center
		).magnitude();

		var sumOfRadii = circle0Radius + circle1Radius;

		var returnValue = (distanceBetweenCenters < sumOfRadii);

		return returnValue;
	}
}

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

	this.colorBack = "White";
	this.colorFore = "Gray";
}
{
	Display.prototype.initialize = function()
	{
		var canvas = document.createElement("canvas");
		canvas.width = this.size.x;
		canvas.height = this.size.y;
		this.graphics = canvas.getContext("2d");

		document.body.appendChild(canvas);
	}

	// drawing

	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.drawCircle = function(center, radius, colorBorder)
	{
		this.graphics.strokeStyle = colorBorder;

		this.graphics.beginPath();
		this.graphics.arc(center.x, center.y, radius, 0, Polar.RadiansPerTurn);
		this.graphics.stroke();
	}

	Display.prototype.drawLine = function(fromPos, toPos, color)
	{
		this.graphics.strokeStyle = color;
		this.graphics.beginPath();
		this.graphics.moveTo(fromPos.x, fromPos.y);
		this.graphics.lineTo(toPos.x, toPos.y);
		this.graphics.stroke();
	}

	Display.prototype.drawPolygon = function(vertices, colorBorder)
	{
		this.graphics.strokeStyle = colorBorder;
		this.graphics.beginPath();
		var vertex = vertices[0];
		this.graphics.moveTo(vertex.x, vertex.y);
		for (var i = 1; i < vertices.length; i++)
		{
			vertex = vertices[i];
			this.graphics.lineTo(vertex.x, vertex.y);
		}
		this.graphics.closePath();
		this.graphics.stroke();
	}


	Display.prototype.drawRectangle = function(pos, size, colorBorder)
	{
		this.graphics.strokeStyle = colorBorder;
		this.graphics.strokeRect
		(
			pos.x, pos.y, size.x, size.y
		);
	}

	Display.prototype.drawText = function(text, height, pos, color)
	{
		this.graphics.strokeStyle = this.colorBack;
		this.graphics.strokeText(text, pos.x, pos.y + height);

		this.graphics.fillStyle = color;
		this.graphics.fillText(text, pos.x, pos.y + height);
	}
}

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

	Globals.prototype.initialize = function(timerTicksPerSecond, display, world)
	{
		this.display = display;
		this.display.initialize();

		this.world = world;

		this.inputHelper = new InputHelper();
		
		var millisecondsPerTimerTick = Math.floor(1000 / this.timerTicksPerSecond);
		this.timer = setInterval
		(
			this.handleEventTimerTick.bind(this), 
			millisecondsPerTimerTick
		);

		this.inputHelper.initialize();
	}

	// events

	Globals.prototype.handleEventTimerTick = function()
	{
		this.world.drawToDisplay(this.display);
		this.world.updateForTimerTick();
	}
}

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

	InputHelper.prototype.removeKey = function(key)
	{
		if (this.keysPressed[key] != null)
		{
			this.keysPressed.splice(this.keysPressed.indexOf(key), 1);
			delete this.keysPressed[key];
		}
	}

	// events 

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

	InputHelper.prototype.handleEventKeyUp = function(event)
	{
		this.removeKey(event.key);
	}

}

function Polar(azimuthInTurns, radius)
{
	this.azimuthInTurns = azimuthInTurns;
	this.radius = radius;
}
{
	Polar.RadiansPerTurn = Math.PI * 2;

	Polar.prototype.toCoords = function(coords)
	{
		var azimuthInRadians = this.azimuthInTurns * Polar.RadiansPerTurn; 
		coords.x = Math.cos(azimuthInRadians) * this.radius;
		coords.y = Math.sin(azimuthInRadians) * this.radius;
		return coords;
	}

	Polar.prototype.trimAzimuthToRangeMinMax = function(min, max)
	{
		if (this.azimuthInTurns < min)
		{
			this.azimuthInTurns = min;
		}
		else if (this.azimuthInTurns > max)
		{
			this.azimuthInTurns = max;
		}
		return this;
	}
}

function Obstacle(radius, pos, vel)
{
	this.radius = radius;
	this.pos = pos;
	this.vel = vel;
}
{
	Obstacle.prototype.updateForTimerTick = function(world)
	{
		this.pos.add(this.vel).wrapToRangeMax(world.size);

		var collisionHelper = CollisionHelper.Instance;
		var obstacles = world.obstacles;
		for (var i = 0; i < obstacles.length; i++)
		{
			var other = obstacles[i];
			if (other != this)
			{
				var doThisAndOtherCollide = collisionHelper.doCirclesCollide
				(
					this.pos, this.radius,
					other.pos, other.radius
				);

				if (doThisAndOtherCollide == true)
				{
					this.collideWithOther(other);
				}
			}
		}
	}

	// drawable

	Obstacle.prototype.drawToDisplay = function(display)
	{
		display.drawCircle(this.pos, this.radius, "Gray");
	}

	// collidable

	Obstacle.prototype.collideWithOther = function(other)
	{
		var bodyPositionsAfterCollision = [new Coords(), new Coords()];
		var bodyVelsAfterCollision = [new Coords(), new Coords()];
		var displacement = new Coords();
		var velocityRelative = new Coords();
 
		var bodies = [ this, other ];
		var body0 = bodies[0];
		var body1 = bodies[1];
 
		var sumOfBodyRadii = 
			body0.radius + body1.radius; 
 
		velocityRelative.overwriteWith
		(
			body0.vel
		).subtract
		(
			body1.vel
		);
  
		displacement.overwriteWith
		(
			body0.pos
		).subtract
		(
			body1.pos
		);
 
		var distanceBetweenBodyCenters = displacement.magnitude();
		var overlap = sumOfBodyRadii - distanceBetweenBodyCenters;
		var overlapHalf = overlap / 2;
 
		var normalAtCollision = displacement.divideScalar
		(
			distanceBetweenBodyCenters
		);
 
		var velocityAlongNormal = normalAtCollision.multiplyScalar
		(
			velocityRelative.dotProduct
			(
				normalAtCollision
			)
		);
 
		velocityRelative.subtract
		(
			velocityAlongNormal
		).multiplyScalar
		(
			-1
		);
 
		for (var i = 0; i < bodies.length; i++)
		{
			var bodyThis = bodies[i];
			var bodyOther = bodies[1 - i];
		 
			var bodyPosAfterCollision = bodyPositionsAfterCollision[i];
			var bodyVelAfterCollision = bodyVelsAfterCollision[i];
 
			var multiplier = (i == 0 ? -1 : 1);
 
			bodyPosAfterCollision.overwriteWith
			(
				normalAtCollision
			).multiplyScalar
			(
				multiplier * overlapHalf
			).add
			(
				bodyThis.pos
			);
  
			bodyVelAfterCollision.overwriteWith
			(
				velocityRelative
			).multiplyScalar
			(
				multiplier
			).add
			(
				bodyOther.vel
			);
		}
 
		for (var i = 0; i < bodies.length; i++)
		{
			var bodyThis = bodies[i];
			var bodyPosAfterCollision = bodyPositionsAfterCollision[i];
			var bodyVelAfterCollision = bodyVelsAfterCollision[i];
 
			bodyThis.pos.overwriteWith
			(
				bodyPosAfterCollision
			);
			bodyThis.vel.overwriteWith
			(
				bodyVelAfterCollision
			);
		}
	}

}

function Projectile(pos, vel)
{
	this.pos = pos;
	this.vel = vel;

	this.radiusInFlight = 2;
	this.colorInFlight = "Gray";

	this.ticksSinceSpawned = 0;
	this.ticksToLive = 100;

	this.ticksSinceExplosion = null;
	this.ticksToExplode = 30;
	this.radiusExplodingMax = 20;
	this.colorExploding = "Gray";
}
{
	Projectile.prototype.drawToDisplay = function(display)
	{
		if (this.ticksSinceExplosion == null)
		{
			display.drawCircle
			(
				this.pos, this.radiusInFlight, this.colorInFlight
			);
			display.drawLine
			(
				this.pos, 
				this.pos.clone().subtract(this.vel), 
				this.colorInFlight
			);
		}
		else
		{
			var radiusCurrent = 
				this.radiusExplodingMax 
				* this.ticksSinceExplosion 
				/ this.ticksToExplode;
			display.drawCircle(this.pos, radiusCurrent, this.colorExploding);
		}
	}

	Projectile.prototype.updateForTimerTick = function(world)
	{
		if (this.ticksSinceSpawned >= this.ticksToLive)
		{
			world.projectiles.remove(this);
		}
		else if (this.ticksSinceExplosion == null)
		{
			this.pos.add(this.vel).wrapToRangeMax(world.size);
			
			this.updateForTimerTick_Obstacles(world);
		}
		else if (this.ticksSinceExplosion < this.ticksToExplode)
		{
			this.ticksSinceExplosion++;
		}
		else
		{
			// todo
		}

		this.ticksSinceSpawned++;
	}

	Projectile.prototype.updateForTimerTick_Obstacles = function(world)
	{
		var collisionHelper = CollisionHelper.Instance;

		var obstacles = world.obstacles;

		for (var i = 0; i < obstacles.length; i++)
		{
			var obstacle = obstacles[i];
			var doProjectileAndObstacleCollide = collisionHelper.doCirclesCollide
			(
				this.pos, this.radiusInFlight,
				obstacle.pos, obstacle.radius
			);

			if (doProjectileAndObstacleCollide == true)
			{
				world.projectiles.remove(this);
				obstacles.remove(obstacle);
				i--;

				this.updateForTimerTick_Obstacles_Children
				(
					world, obstacle
				);

				break;
			}
		}
	}

	Projectile.prototype.updateForTimerTick_Obstacles_Children = function(world, obstacle)
	{
		var obstacleChildRadius = obstacle.radius / 2;
		if (obstacleChildRadius >= 2)
		{
			for (var c = 0; c < 2; c++)
			{
				var lateral = obstacle.vel.clone().right().normalize().multiplyScalar
				(
				 	(c == 0 ? -1 : 1)
				)

				var displacement = lateral.clone().multiplyScalar
				(
					obstacleChildRadius
				);
				var accel = lateral.clone().multiplyScalar(.1);

				var obstacleChildVel = obstacle.vel.clone().add(accel);

				var obstacleChild = new Obstacle
				(
					obstacleChildRadius,
					obstacle.pos.clone().add(displacement),
					obstacleChildVel
				);
				world.obstacles.push(obstacleChild);
			}
		}
	}
}

function World(size, actor, obstacles)
{
	this.size = size;
	this.actors = [ actor ];
	this.obstacles = obstacles;

	this.projectiles = [];
}
{
	World.random = function(size)
	{
		var actorPos = size.clone().multiplyScalar(.5);

		var actor = new Actor
		(
			actorPos,
			Activity.Instances.UserInputAccept
		);

		var numberOfObstacles = 2;
		var obstacleRadius = 16;

		var obstacles = [];
		var obstacleSpeedMax = .2;
		for (var i = 0; i < numberOfObstacles; i++)
		{
			var pos = new Coords().random().multiply(size);
			var vel = new Polar
			(
				Math.random(), // azimuthInTurns
				Math.random() * obstacleSpeedMax // radius
			).toCoords(new Coords());

			var obstacle = new Obstacle
			(
				obstacleRadius, pos, vel
			);

			obstacles.push(obstacle);
		}

		var returnValue = new World
		(
			size,
			actor,
			obstacles
		);

		return returnValue;
	}

	// instance methods

	World.prototype.updateForTimerTick = function()
	{		
		for (var i = 0; i < this.actors.length; i++)
		{
			var actor = this.actors[i];
			actor.updateForTimerTick(this);
		}

		for (var i = 0; i < this.obstacles.length; i++)
		{
			var obstacle = this.obstacles[i];
			obstacle.updateForTimerTick(this);
		}

		for (var i = 0; i < this.projectiles.length; i++)
		{
			var projectile = this.projectiles[i];
			projectile.updateForTimerTick(this);
		}
	}

	// drawable

	World.prototype.drawToDisplay = function(display)
	{
		display.clear();

		for (var i = 0; i < this.obstacles.length; i++)
		{
			var obstacle = this.obstacles[i];
			obstacle.drawToDisplay(display);
		}

		for (var i = 0; i < this.actors.length; i++)
		{
			var actor = this.actors[i];
			actor.drawToDisplay(display);
		}

		for (var i = 0; i < this.projectiles.length; i++)
		{
			var projectile = this.projectiles[i];
			projectile.drawToDisplay(display);
		}
	}
}

// run

main();

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

Posted in Uncategorized | Tagged , , | Leave a comment

An Artillery Game in JavaScript

Below is a simple artillery game implemented in JavaScript. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript.

UPDATE 2017/11/22 – I have updated this code to account for explosions being blocked by the landscape, though it may not be immediately clear that this is happening based on the visuals.

I also plan to make a live version of this game available at the URL “https://thiscouldbebetter.neocities.org/artillerygame.html“, and to post a Git repository of the code at https://github.com/thiscouldbebetter/ArtilleryGame“.

ArtilleryGame.png


<html>
<body>
<script type="text/javascript">
 
// main

function main()
{
	var displaySize = new Coords(200, 200);
 
	var display = new Display(displaySize);
 
	var world = World.random(new Coords(0, .05), displaySize);
 
	Globals.Instance.initialize
	(
		10, // ticksPerSecond
		display,
		world
	);
}
 
// extensions

function ArrayExtensions()
{
	// extension class
}
{
	Array.prototype.clone = function()
	{
		var returnValues = [];

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

		return returnValues;
	}
}
 
// classes
 
function Activity(perform)
{
	this.perform = perform;
}
{
	Activity.Instances = new Activity_Instances()
	 
	function Activity_Instances()
	{
		this.DoNothing = new Activity(function perform() {});
		this.UserInputAccept = new Activity
		(
			function perform(world, actor)
			{
				var inputHelper = Globals.Instance.inputHelper;
				var inputActive = inputHelper.keyPressed;
				var powerFactor = 1000;
 
				if (inputActive == "ArrowDown")
				{
					actor.powerCurrent -= actor.powerPerTick;
					actor.powerCurrent = 
						Math.round(actor.powerCurrent * powerFactor) / powerFactor;
					if (actor.powerCurrent < actor.powerMin)
					{
						actor.powerCurrent = actor.powerMin;
					}
				}   
				else if (inputActive == "ArrowLeft")
				{
					actor.firePolar.azimuthInTurns -= actor.turnsPerTick;
					actor.firePolar.trimAzimuthToRangeMinMax
					(
						actor.azimuthInTurnsMin, 
						actor.azimuthInTurnsMax
					);
				}
				else if (inputActive == "ArrowRight")
				{
					actor.firePolar.azimuthInTurns += actor.turnsPerTick;
					actor.firePolar.trimAzimuthToRangeMinMax
					(
						actor.azimuthInTurnsMin, 
						actor.azimuthInTurnsMax
					);
				}
				else if (inputActive == "ArrowUp")
				{
					actor.powerCurrent += actor.powerPerTick;
					actor.powerCurrent = 
						Math.round(actor.powerCurrent * powerFactor) / powerFactor;
					if (actor.powerCurrent > actor.powerMax)
					{
						actor.powerCurrent = actor.powerMax;
					}
				}
				else if (inputActive == "Enter")
				{
					var projectile = new Projectile
					(
						actor.color,
						actor.muzzlePos.clone(),
						// vel
						actor.firePolar.toCoords
						(
							new Coords()
						).normalize().multiplyScalar
						(
							actor.powerCurrent
						)
					);
 
					world.projectiles = [ projectile ];
 
					world.actorIndexCurrent = 1 - world.actorIndexCurrent;
				}   
				inputHelper.keyPressed = false;
			}
		);
	}
}
 
function Actor(color, pos, activity)
{
	this.color = color;
	this.pos = pos;
	this.activity = activity;
 
	this.wins = 0;
	this.ticksSinceKilled = null;
	this.ticksToDie = 30;
 
	this.collider = new Circle(this.pos, 8);
 
	this.powerMin = 1;
	this.powerMax = 4;
	this.powerPerTick = .1;
 
	this.azimuthInTurnsMin = .5;
	this.azimuthInTurnsMax = 1;
	this.turnsPerTick = 1.0 / Polar.DegreesPerTurn;
	this.firePolar = new Polar
	(
		(this.azimuthInTurnsMin + this.azimuthInTurnsMax) / 2, 
		this.collider.radius * 2
	);
	this.muzzlePos = this.pos.clone().add
	(
		this.firePolar.toCoords( new Coords() )
	);
 
	this.vel = new Coords();
 
	this.reset();
}
{
	Actor.prototype.reset = function()
	{
		this.firePolar.azimuthInTurns = 
			(this.azimuthInTurnsMin + this.azimuthInTurnsMax) / 2, 
		this.powerCurrent = (this.powerMin + this.powerMax) / 2;
 
		this.ticksSinceKilled = null;
		this.pos.y = 0;
		this.vel.clear();
	}
 
	Actor.prototype.updateForTimerTick = function(world)
	{
		if (this.ticksSinceKilled == null)
		{
			if (this == world.actorCurrent() && world.projectiles.length == 0)
			{
				this.activity.perform(world, this);
			}
 
			this.firePolar.toCoords(this.muzzlePos);
			this.muzzlePos.add(this.pos);
 
			var surfaceAltitude = world.landscape.altitudeAtX
			(
				this.pos.x
			);
			var isBelowGround = (this.pos.y >= surfaceAltitude);
			if (isBelowGround == false)
			{
				this.vel.add(world.gravityPerTick);
				this.pos.add(this.vel);
			}
			else
			{
				this.vel.clear();
				this.pos.y = surfaceAltitude;
			}
 
		}
		else if (this.ticksSinceKilled < this.ticksToDie)
		{
			 this.ticksSinceKilled++;
		}
		else
		{
			world.reset();
		}
	}
 
	// drawable
 
	Actor.prototype.drawToDisplay = function(display)
	{
		display.drawCircle(this.pos, this.collider.radius, this.color);
		display.drawLine
		(
			this.pos,
			this.muzzlePos
		);
		 
		if (this == Globals.Instance.world.actorCurrent())
		{
			var fireAzimuthInTurnsRecentered = Math.abs
			( 
				0.75 - this.firePolar.azimuthInTurns
			);
			var fireAzimuthInDegrees = Math.round
			(
				fireAzimuthInTurnsRecentered 
				* Polar.DegreesPerTurn
			);
			var text = "Angle:" + fireAzimuthInDegrees + " Power:" + this.powerCurrent;
			display.drawText
			(
				text,
				this.collider.radius,
				Coords.Instances.Zeroes,
				this.color
			);
		}
	}
}

function Circle(center, radius)
{
	this.center = center;
	this.radius = radius;
}

function CollisionHelper()
{
	this.displacement = new Coords();
}
{
	CollisionHelper.Instance = new CollisionHelper();
 
	CollisionHelper.prototype.doCirclesCollide = function(circle0, circle1)
	{
		var distanceBetweenCenters = this.displacement.overwriteWith
		(
			circle1.center
		).subtract
		(
			circle0.center
		).magnitude();
 
		var sumOfRadii = circle0.radius + circle1.radius;
 
		var returnValue = (distanceBetweenCenters < sumOfRadii);
 
		return returnValue;
	}

	CollisionHelper.prototype.doEdgesCollide = function(edge0, edge1)
	{
		var returnValue = null;
 
		if (this.edgeProjected == null)
		{
			this.edgeProjected = new Edge([new Coords(), new Coords()]);
		}
		var edgeProjected = this.edgeProjected;
		edgeProjected.overwriteWith(edge1).projectOnto(edge0);
		var edgeProjectedStart = edgeProjected.vertices[0];
		var edgeProjectedDirection = edgeProjected.direction;	   
 
		var distanceAlongEdgeProjectedToXAxis = 
			0 - edgeProjectedStart.y
			/ edgeProjectedDirection.y
 
		if 
		(
			distanceAlongEdgeProjectedToXAxis > 0 
			&& distanceAlongEdgeProjectedToXAxis < edgeProjected.length
		)
		{
			var distanceAlongEdge0ToIntersection =
				edgeProjectedStart.x 
				+ (edgeProjectedDirection.x * distanceAlongEdgeProjectedToXAxis);
 
			if 
			(
				distanceAlongEdge0ToIntersection > 0
				&& distanceAlongEdge0ToIntersection < edge0.length
			)
			{
				returnValue = true;
			}
		}
 
		return returnValue;
	}
}
 
function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	Coords.Instances = new Coords_Instances();
 
	function Coords_Instances()
	{
		this.Zeroes = new Coords(0, 0);
	}
 
	Coords.prototype.add = function(other)
	{
		this.x += other.x;
		this.y += other.y;
		return this;
	}

	Coords.prototype.addXY = function(x, y)
	{
		this.x += x;
		this.y += y;
		return this;
	}
 
	Coords.prototype.clear = function()
	{
		this.x = 0;
		this.y = 0;
	}
 
	Coords.prototype.clone = function()
	{
		return new Coords(this.x, this.y);
	}
 
	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.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.overwriteWithXY = function(x, y)
	{
		this.x = x;
		this.y = y;
		return this;
	}

	Coords.prototype.right = function()
	{
		var temp = this.x;
		this.x = 0 - this.y;
		this.y = temp;
		return this;
	}
	
	Coords.prototype.subtract = function(other)
	{
		this.x -= other.x;
		this.y -= other.y;
		return this;
	}
}
 
function Display(size)
{
	this.size = size;
 
	this.colorBack = "White";
	this.colorFore = "Gray";
}
{
	Display.prototype.initialize = function()
	{
		var canvas = document.createElement("canvas");
		canvas.width = this.size.x;
		canvas.height = this.size.y;
		this.graphics = canvas.getContext("2d");
 
		document.body.appendChild(canvas);
	}
 
	// drawing
 
	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.drawCircle = function(center, radius, colorBorder)
	{
		this.graphics.strokeStyle = colorBorder;
 
		this.graphics.beginPath();
		this.graphics.arc(center.x, center.y, radius, 0, Polar.RadiansPerTurn);
		this.graphics.stroke();
	}
 
	Display.prototype.drawLine = function(fromPos, toPos, color)
	{
		this.graphics.strokeStyle = color;
		this.graphics.beginPath();
		this.graphics.moveTo(fromPos.x, fromPos.y);
		this.graphics.lineTo(toPos.x, toPos.y);
		this.graphics.stroke();
	}
 
	Display.prototype.drawRectangle = function(pos, size, colorBorder)
	{
		this.graphics.strokeStyle = colorBorder;
		this.graphics.strokeRect
		(
			pos.x, pos.y, size.x, size.y
		);
	}
 
	Display.prototype.drawText = function(text, height, pos, color)
	{
		this.graphics.strokeStyle = this.colorBack;
		this.graphics.strokeText(text, pos.x, pos.y + height);
 
		this.graphics.fillStyle = color;
		this.graphics.fillText(text, pos.x, pos.y + height);
	}
}

function Edge(vertices)
{
	this.vertices = vertices;

	this.displacement = new Coords();
	this.direction = new Coords();
	this.right = new Coords();
	this.recalculateDerivedValues();
}
{
	Edge.prototype.clone = function()
	{
		return new Edge(this.vertices.clone());
	}

	Edge.prototype.overwriteWith = function(other)
	{
		this.vertices[0].overwriteWith(other.vertices[0]);
		this.vertices[1].overwriteWith(other.vertices[1]);
		this.recalculateDerivedValues();
		return this;
	}

	Edge.prototype.projectOnto = function(other)
	{
		for (var i = 0; i < this.vertices.length; i++)
		{
			var vertexThis = this.vertices[i];
 
			vertexThis.subtract
			(
				other.vertices[0]
			).overwriteWithXY
			(
				vertexThis.dotProduct(other.direction),
				vertexThis.dotProduct(other.right)
			);
		}
 
		this.recalculateDerivedValues();
 
		return this;
	}

	Edge.prototype.recalculateDerivedValues = function()
	{
		this.displacement.overwriteWith
		(
			this.vertices[1]
		).subtract(this.vertices[0]);
		this.length = this.displacement.magnitude();
		this.direction.overwriteWith(this.displacement).divideScalar(this.length);
		this.right.overwriteWith(this.direction).right();
	}
}
 
function Globals()
{
	// Do nothing.
}
{
	Globals.Instance = new Globals();
 
	Globals.prototype.initialize = function(timerTicksPerSecond, display, world)
	{
		this.display = display;
		this.display.initialize();
 
		this.world = world;
 
		this.inputHelper = new InputHelper();
		 
		var millisecondsPerTimerTick = Math.floor(1000 / this.timerTicksPerSecond);
		this.timer = setInterval
		(
			this.handleEventTimerTick.bind(this), 
			millisecondsPerTimerTick
		);
 
		this.inputHelper.initialize();
	}
 
	// events
 
	Globals.prototype.handleEventTimerTick = function()
	{
		this.world.drawToDisplay(this.display);
		this.world.updateForTimerTick();
	}
}
 
function InputHelper()
{
	this.keyPressed = null;
}
{
	InputHelper.prototype.initialize = function()
	{
		document.body.onkeydown = this.handleEventKeyDown.bind(this);
	}
 
	// events 
 
	InputHelper.prototype.handleEventKeyDown = function(event)
	{
		this.keyPressed = event.key;
	}
}
 
function Landscape(size, horizonPoints)
{
	this.size = size;
	this.color = "Green";   

	this.edges = [];
	var horizonPointPrev = horizonPoints[0];
	for (var i = 1; i < horizonPoints.length; i++)
	{
		var horizonPoint = horizonPoints[i];
		var edge = new Edge([horizonPointPrev, horizonPoint]);
		this.edges.push(edge);
		horizonPointPrev = horizonPoint;
	}
}
{
	Landscape.random = function(size, numberOfPoints)
	{
		var points = [];
		for (var i = 0; i < numberOfPoints + 1; i++)
		{
			var point = new Coords(i * size.x / numberOfPoints, 0);
			points.push(point);
		}
 
		var returnValue = new Landscape(size, points).randomize();
 
		return returnValue;
	}
 
	// instance methods
 
	Landscape.prototype.altitudeAtX = function(xToCheck)
	{
		var returnValue;
 
		for (var i = 0; i < this.edges.length; i++)
		{
			var edge = this.edges[i];
			var horizonPointPrev = edge.vertices[0];
			var horizonPoint = edge.vertices[1];

			if (horizonPoint.x > xToCheck)
			{
				var horizonChange = horizonPoint.clone().subtract
				(
					horizonPointPrev
				);
	 
				var t = 
					(xToCheck - horizonPointPrev.x)
					/ (horizonChange.x);
 
				var altitude = 
					horizonPointPrev.y 
					+ (t * horizonChange.y);
 
				returnValue = altitude;
				break;
			}
 
			horizonPointPrev = horizonPoint;
		}
 
		return returnValue;		 
	}

	Landscape.prototype.collidesWithEdge = function(edgeOther)
	{
		var returnValue = false;
		var collisionHelper = CollisionHelper.Instance;
		for (var i = 0; i < this.edges.length; i++)
		{
			var edgeThis = this.edges[i];
			var doEdgesCollide = collisionHelper.doEdgesCollide
			(
				edgeThis, edgeOther
			);
			if (doEdgesCollide == true)
			{
				returnValue = true;
				break;
			}
		}
		return returnValue
	}
 
	Landscape.prototype.randomize = function()
	{
		var altitudeMid = this.size.y / 2;
		var altitudeRange = this.size.y / 2;
		var altitudeRangeHalf = altitudeRange / 2;
		var altitudeMin = altitudeMid - altitudeRangeHalf;
		var altitudeMax = altitudeMin + altitudeRange;
 
		this.edges[0].vertices[0].y = 
			altitudeMin + Math.random() * altitudeRange;
		for (var i = 0; i < this.edges.length; i++)
		{
			var edge = this.edges[i];
			var point = edge.vertices[1];
			point.y = altitudeMin + Math.random() * altitudeRange;
			edge.recalculateDerivedValues();
		}
 
		return this;		
	}
 
	// drawable
	 
	Landscape.prototype.drawToDisplay = function(display)
	{
		for (var i = 0; i < this.edges.length; i++)
		{
			var edge = this.edges[i];
			display.drawLine(edge.vertices[0], edge.vertices[1], this.color);
		}
	}
}
 
function Polar(azimuthInTurns, radius)
{
	this.azimuthInTurns = azimuthInTurns;
	this.radius = radius;
}
{
	Polar.RadiansPerTurn = Math.PI * 2;
	Polar.DegreesPerTurn = 360;
 
	Polar.prototype.toCoords = function(coords)
	{
		var azimuthInRadians = this.azimuthInTurns * Polar.RadiansPerTurn; 
		coords.x = Math.cos(azimuthInRadians) * this.radius;
		coords.y = Math.sin(azimuthInRadians) * this.radius;
		return coords;
	}
 
	Polar.prototype.trimAzimuthToRangeMinMax = function(min, max)
	{
		if (this.azimuthInTurns < min)
		{
			this.azimuthInTurns = min;
		}
		else if (this.azimuthInTurns > max)
		{
			this.azimuthInTurns = max;
		}
		return this;
	}
}
 
function Projectile(color, pos, vel)
{
	this.color = color;
	this.pos = pos;
	this.vel = vel;
 
	this.collider = new Circle(this.pos, 2);
 
	this.ticksSinceExplosion = null;
	this.ticksToExplode = 30;
	this.radiusExplodingMax = 20;
}
{
	Projectile.prototype.radiusCurrent = function()
	{
		var radiusCurrent = 
			this.radiusExplodingMax 
			* this.ticksSinceExplosion 
			/ this.ticksToExplode;

		return radiusCurrent;
	}
 
	Projectile.prototype.updateForTimerTick = function(world)
	{
		if (this.ticksSinceExplosion == null)
		{
			this.vel.add(world.gravityPerTick);
			this.pos.add(this.vel);
			if (this.pos.y > world.size.y)
			{
				world.projectiles.length = 0;
			}
			else
			{
				var surfaceAltitude = world.landscape.altitudeAtX(this.pos.x);
				var isBeneathHorizon = (this.pos.y >= surfaceAltitude);
				if (isBeneathHorizon == true)
				{
					this.ticksSinceExplosion = 0;
					this.pos.y = surfaceAltitude;
				}
			}
		}
		else if (this.ticksSinceExplosion < this.ticksToExplode)
		{
			this.ticksSinceExplosion++;
		}
		else
		{  
			var collisionHelper = CollisionHelper.Instance;
			var actors = world.actors;
			for (var i = 0; i < actors.length; i++)
			{
				var actor = actors[i];

				this.collider.radius = this.radiusCurrent();
				var isActorWithinExplosionRadius = collisionHelper.doCirclesCollide
				(
					this.collider, actor.collider
				);

				if (isActorWithinExplosionRadius == true)
				{
					var edgeFromExplosionToActor = new Edge
					([
						this.pos.clone().addXY(0, -1), // hack 
						actor.pos.clone().addXY(0, -1)
					]);
					var isExplosionBlockedByGround = world.landscape.collidesWithEdge
					(
						edgeFromExplosionToActor
					);

					if (isExplosionBlockedByGround == false)
					{
						var actorOther = actors[1 - i];
						actorOther.ticksSinceKilled = 0;
						actorOther.wins++;
					}
				}
			}
			world.projectiles.length = 0;
		}
	}

	// drawable

	Projectile.prototype.drawToDisplay = function(display)
	{
		if (this.ticksSinceExplosion == null)
		{
			display.drawCircle
			(
				this.pos, this.collider.radius, this.color
			);
			display.drawLine
			(
				this.pos, 
				this.pos.clone().subtract(this.vel), 
				this.color
			);
		}
		else
		{
			display.drawCircle(this.pos, this.radiusCurrent(), this.color);
		}
	}
}
 
function World(gravityPerTick, size, landscape, actors)
{
	this.gravityPerTick = gravityPerTick;
	this.size = size;
	this.landscape = landscape;
	this.actors = actors;
 
	this.actorIndexCurrent = 0;
	this.projectiles = [];
}
{
	World.random = function(gravityPerTick, size)
	{
		var landscape = Landscape.random(size, 10);
 
		var actors = 
		[
			new Actor
			(
				"Blue", 
				new Coords(size.x / 6, 0),
				Activity.Instances.UserInputAccept
			), 
			new Actor
			(
				"Red", 
				new Coords(5 * size.x / 6, 0),
				Activity.Instances.UserInputAccept
			), 
		];
 
		var returnValue = new World
		(
			gravityPerTick,
			size,
			landscape,
			actors
		);
 
		return returnValue;
	}
 
	// instance methods
 
	World.prototype.actorCurrent = function()
	{
		return this.actors[this.actorIndexCurrent];
	}
 
	World.prototype.actorCurrentAdvance = function()
	{
		this.actorIndexCurrent = this.actors.length - 1 - this.actorIndexCurrent;
	}
 
	World.prototype.reset = function()
	{
		this.landscape.randomize();
		for (var i = 0; i < this.actors.length; i++)
		{
			var actor = this.actors[i];
			actor.reset();
		}   
	}
 
	World.prototype.updateForTimerTick = function()
	{
		for (var i = 0; i < this.projectiles.length; i++)
		{
			var projectile = this.projectiles[i];
			projectile.updateForTimerTick(this);
		}
 
		for (var i = 0; i < this.actors.length; i++)
		{
			var actor = this.actors[i];
			actor.updateForTimerTick(this);
		}
	}
 
	// drawable
 
	World.prototype.drawToDisplay = function(display)
	{
		display.clear();
		this.landscape.drawToDisplay(display);
 
		for (var i = 0; i < this.actors.length; i++)
		{
			var actor = this.actors[i];
			actor.drawToDisplay(display);
			display.drawText("" + actor.wins, actor.radius, actor.pos, actor.color);
		}
 
		for (var i = 0; i < this.projectiles.length; i++)
		{
			var projectile = this.projectiles[i];
			projectile.drawToDisplay(display);
		}
	}
}
 
// run
 
main();
 
</script>
</body>
</html>

Posted in Uncategorized | Tagged , , | Leave a comment

A Retirement Savings Calculator in JavaScript

The JavaScript program below, when run, allows a user to calculate how many years it will take to save enough to replace their salary based on the specified initial savings, savings per year, and rates of return before and after retirement. To see the code 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/retirementsavingscalculator.html.

RetirementSavingsCalculator.png


 <div id="divUI">

	<p><b>Retirement Savings Calculator</b></p>

	<label>Initial Savings:</label><br />
	<input id="inputSavingsInitial" type="number" value="0"></input><br />
	
	<label>Return on Investment per Period Before Retirement:</label><br />
	<input id="inputReturnOnInvestmentPerPeriodBeforeRetirement" type="number" value=".1"></input><br />

	<label>New Savings per Period:</label><br />
	<input id="inputSavingsNewPerPeriod" type="number" value=".5"></input><br />

	<label>Return on Investment per Period After Retirement:</label><br />
	<input id="inputReturnOnInvestmentPerPeriodAfterRetirement" type="number" value=".05"></input><br />

	<label>Retirement Income Desired per Period:</label><br />
	<input id="inputRetirementIncomePerPeriod" value=".5"></input><br />

	<button onclick="buttonCalculate_Clicked();">Calculate</button><br />

	<label>Savings at Retirement:</label><br />
	<input id="inputSavingsAtRetirement" disabled="true"></input><br />

	<label>Periods Required:</label><br />
	<input id="inputPeriodsRequired" disabled="true"></input><br />

</div>

<script type="text/javascript">

/*

Derivation of the "Savings Plan Equation":

n = number of periods (usually years)
s_n = savings after n periods
d = new savings per period
r = 1 + return on investment per period

Start with the first two periods:

s1 = d + s0r
s2 = d + s1r
   = d + (d + s0r)r
   = d + dr + s0r^2
   = d(1 + r) + s0r^2

Extrapolate from n = 2 to find the pattern for any n:

s_n = d(1 + r + r^2 + ... + r^(n-1)) + s0r^n

Multiply each term in the sequence ( 1 + ... r^(n-1) ) 
by (r - 1)/(r - 1).  
All but the first and last terms 
in the resulting sequence cancel:

s_n = d ( (r^n - 1) / (r - 1) ) + s0r^n

Now solve for n to find 
the number of periods required to get from s0 to s_n:

s_n = d ( (r^n - 1) / (r-1) ) + s0r^n
s_n = d ( (r^n - 1) / (r-1) ) + ( s0r^n(r-1) / (r-1) )
s_n = d ( (r^n - 1) / (r-1) ) + ( s0r^(n+1) -s0r^n ) / (r-1)
s_n = (dr^n - d) / (r-1) + ( s0r^(n+1) -s0r^n ) / (r-1)
s_n = ( dr^n - d + s0r^(n+1) -s0r^n ) / (r-1)
s_n * (r - 1) = dr^n - d + s0r^(n+1) - s0r^n
s_n * (r - 1) + d = dr^n + s0r^(n+1) - s0r^n
s_n * (r - 1) + d = r^n(d + s0r - s0)
(s_n * (r - 1) + d) / (d + s0r - s0) = r^n
log_r ( (s_n * (r - 1) + d) / (d + s0r - s0) ) = log_r r^n
log_r ( (s_n * (r - 1) + d) / (d + s0r - s0) ) = n
n = log_r ( (s_n * (r - 1) + d) / (d + s0r - s0) )

Change the logarithm's base from r to 10:
log_a x = log_b x / log_b a

n = log ( (s_n * (r - 1) + d) / (d + s0r - s0) ) / log r
n = log ( (s_n * (r - 1) + d) / (d + s0(r - 1)) ) / log r

*/

// ui event handlers

function buttonCalculate_Clicked()
{
	var inputSavingsInitial
		= document.getElementById("inputSavingsInitial");
	var inputReturnOnInvestmentPerPeriodBeforeRetirement
		= document.getElementById("inputReturnOnInvestmentPerPeriodBeforeRetirement");
	var inputSavingsNewPerPeriod
		= document.getElementById("inputSavingsNewPerPeriod");
	var inputReturnOnInvestmentPerPeriodAfterRetirement
		= document.getElementById("inputReturnOnInvestmentPerPeriodAfterRetirement");
	var inputRetirementIncomePerPeriod
		= document.getElementById("inputRetirementIncomePerPeriod");
	var inputPeriodsRequired 
		= document.getElementById("inputPeriodsRequired");

	var savingsInitial = 
		parseFloat(inputSavingsInitial.value);
	var returnOnInvestmentPerPeriodBeforeRetirement = 
		parseFloat(inputReturnOnInvestmentPerPeriodBeforeRetirement.value);
	var savingsNewPerPeriod = 
		parseFloat(inputSavingsNewPerPeriod.value);
	var returnOnInvestmentPerPeriodAfterRetirement = 
		parseFloat(inputReturnOnInvestmentPerPeriodAfterRetirement.value);
	var retirementIncomePerPeriod = 
		parseFloat(inputRetirementIncomePerPeriod.value);
	
	var savingsAtRetirement = 
		retirementIncomePerPeriod 
		/ returnOnInvestmentPerPeriodAfterRetirement;
	inputSavingsAtRetirement.value = savingsAtRetirement;

	var periodsRequired = Math.log
	(
		( 
			savingsAtRetirement 
			* returnOnInvestmentPerPeriodBeforeRetirement
			+ savingsNewPerPeriod 
		)
		/ 
		(
			savingsNewPerPeriod
			+ savingsInitial
			* returnOnInvestmentPerPeriodBeforeRetirement
		) 		
	)
	/ Math.log(1 + returnOnInvestmentPerPeriodBeforeRetirement);

	inputPeriodsRequired.value = periodsRequired;
}

</script>

Posted in Uncategorized | Tagged , , , , , | Leave a comment

An Adding Machine in JavaScript

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

AddingMachine.png


<html>
<body>

<div id="divUI">

	<label>Addends:</label><br />	
	<textarea id="textareaAddends" cols="80" rows="25">
1
2
fizz
4
buzz

42 (The answer to everything.)
<=(1,337)=>
0.999999999
-40 (Celsius or Fahrenheit?)
...
How many licks does it take? 3, that's how many.
	</textarea><br />
	<button onclick="buttonAdd_Clicked();">Add</button><br />
	<label>Sum:</label><br />
	<input id="inputSum"></input><br />
	
</div>

<script type="text/javascript">

// ui event handlers

function buttonAdd_Clicked()
{
	var textareaAddends = document.getElementById("textareaAddends");

	var addendsAsString = textareaAddends.value;
	var addendsAsLines = addendsAsString.split("\n");

	var charsToKeep = "0123456789.-";

	var sumSoFar = 0;
	for (var i = 0; i < addendsAsLines.length; i++)
	{
		var addendAsLine = addendsAsLines[i];
		var addendTrimmed = "";
		for (var j = 0; j < addendAsLine.length; j++)
		{
			var addendChar = addendAsLine[j];
			if (charsToKeep.indexOf(addendChar) >= 0)
			{
				addendTrimmed += addendChar;
			}
		}
		var addendAsNumber = parseFloat(addendTrimmed);
		if (isNaN(addendAsNumber) == false)
		{
			sumSoFar += addendAsNumber;
		}
	}
	
	var inputSum = document.getElementById("inputSum");
	inputSum.value = sumSoFar;	
}

</script>

</body>
</html>

Posted in Uncategorized | Tagged , , , , , , | Leave a comment