A Cinematics Engine for a Video Game in JavaScript

The JavaScript code below implements a simple cinematics engine in JavaScript. Well, “cinematics” is probably the wrong word, because there’s not really anything very kinetic going on. Basically, two static characters at a time talk to each other with speech bubbles. I guess it’s more of a “talking head” engine.  Or, more accurately still, a “talking square” engine.

To see the code in action, copy it into an .html file. Then copy the .pngs included in this post to the same directory, and open the html file in a web browser that runs JavaScript. Or, for an online version, visit https://thiscouldbebetter.neocities.org/cinematics.html.

Rick

Jane

Spot

Suburbia

Cinematic

UPDATE 2017/09/01 – I have updated this code to add “marks” that allow the actors to stand in arbitrary positions, to allow for “beats” between lines, and to automatically advance to the next line after some number of seconds has elapsed. I also added keyboard support to the existing mouse support for speeding up the delivery of lines, and wrapped the actor and background Image instance with VisualImage.


<html>
<body>
<div id="divCinematics"></div>
<script>

// main

function main()
{
	new ImageLoader().loadImages
	(
		[
			new Image("rick.png"),
			new Image("jane.png"),
			new Image("spot.png"),
			new Image("suburbia.png"),
		],
		main2
	);
}

function main2(imageLoader)
{
	var images = imageLoader.images;

	var viewSize = new Coords(200, 150);

	var scene = new Scene
	(
		"Scene0",
		10, // fontHeight
		viewSize,
		new Background("Suburbia", new VisualImage(images["suburbia.png"])),
		// marks
		[
			new Mark("left", new Coords(viewSize.x * .25, viewSize.y / 2) ),
			new Mark("right", new Coords(viewSize.x * .75, viewSize.y / 2) ),
		],
		// actors
		[
			new Actor("Rick", new VisualImage(images["rick.png"])),
			new Actor("Jane", new VisualImage(images["jane.png"])),
			new Actor("Spot", new VisualImage(images["spot.png"])),
		],
		// lines
		[
			new Line("Rick", "[enters left]"),
			new Line("Jane", "[enters right]"),
			new Line("Rick", "Jane, have you seen Spot?"),
			new Line("Rick", "I have to take him to the vet."),
			new Line("Jane", "His NAME is Mr. Beansprouts."),
			new Line("Rick", "For the hundredth time, no it's NOT."),
			new Line("Jane", "Yes, it is.  I'll prove it."),
			new Line("Jane", "Come here, Mr. Beansprouts!"),
			new Line(null),
			new Line("Jane", "Mr. Beeeeeeansprouts!"),
			new Line(null),
			new Line("Jane", "RIGHT NOW, MR. BEANSPROUTS!"),
			new Line(null),
			new Line("Jane", "Stupid dog.  Forget it."),
			new Line("Jane", "[exits]"),
			new Line(null),
			new Line("Spot", "[enters right]"),
			new Line("Spot", "Is she gone?"),
			new Line("Spot", "I've told her a million times..."),
			new Line("Spot", "...it's DOCTOR Beansprouts."),
			new Line("Spot", "Anyway, you wanted to see me?"),
			new Line("Rick", "Shut up and get in the crate, Spot."),
			new Line("Spot", "Yes, sir."),
		]
	);

	Globals.Instance.initialize
	(
		scene
	);
}

// classes

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

function ArrayExtensions()
{}
{
	Array.prototype.addLookups = function(keyName)
	{
		for (var i = 0; i < this.length; i++)
		{
			var item = this[i];
			var key = item[keyName];
			this[key] = item;
		}
		return this;
	}
	
	Array.prototype.remove = function(element)
	{
		var elementIndex = this.indexOf(element);
		if (elementIndex != -1)
		{
			this.splice(elementIndex, 1);
		}
	}
}

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

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

function Display()
{
	// Do nothing.
}
{
	Display.prototype.drawImage = function(image, drawPos)
	{
		var size = image.size();
		this.graphics.drawImage
		(
			image.systemImage, 
			drawPos.x - size.x / 2, drawPos.y - size.y / 2
		);
	}
	
	Display.prototype.drawSpeechBubble = function(text, speechBubblePos, tailPosX, speechBubbleSize, cornerRadius)
	{
		var tailLength = cornerRadius;
		var tailWidthHalf = tailLength / 2;
			
		this.graphics.beginPath();
		this.graphics.moveTo(speechBubblePos.x + cornerRadius, speechBubblePos.y);
		this.graphics.arcTo
		(
			speechBubblePos.x + speechBubbleSize.x, speechBubblePos.y,
			speechBubblePos.x + speechBubbleSize.x, speechBubblePos.y + cornerRadius,
			cornerRadius
		);
		this.graphics.arcTo
		(
			speechBubblePos.x + speechBubbleSize.x, speechBubblePos.y + speechBubbleSize.y,
			speechBubblePos.x + speechBubbleSize.x - cornerRadius, speechBubblePos.y + speechBubbleSize.y,
			cornerRadius
		);

		this.graphics.lineTo(tailPosX + tailWidthHalf, speechBubblePos.y + speechBubbleSize.y);
		this.graphics.lineTo(tailPosX, speechBubblePos.y + speechBubbleSize.y + tailLength * 2);
		this.graphics.lineTo(tailPosX - tailWidthHalf, speechBubblePos.y + speechBubbleSize.y);

		this.graphics.arcTo
		(
			speechBubblePos.x, speechBubblePos.y + speechBubbleSize.y,
			speechBubblePos.x, speechBubblePos.y + speechBubbleSize.y - cornerRadius,
			cornerRadius
		);
		this.graphics.arcTo
		(
			speechBubblePos.x, speechBubblePos.y,
			speechBubblePos.x + cornerRadius, speechBubblePos.y,
			cornerRadius
		);
		
		this.graphics.fillStyle = "White";
		this.graphics.fill();
		
		this.drawTextAtPos
		(
			text, 
			new Coords
			(
				speechBubblePos.x + cornerRadius, 
				speechBubblePos.y + cornerRadius + this.fontHeight * .8
			)
		);
	}
	
	Display.prototype.drawTextAtPos = function(text, drawPos)
	{
		this.graphics.fillStyle = "Gray";
		this.graphics.fillText
		(
			text,
			drawPos.x, drawPos.y
		);
	}

	Display.prototype.initialize = function(fontHeight, viewSize)
	{
		this.fontHeight = fontHeight;
		this.viewSize = viewSize;

		var canvas = document.createElement("canvas");
		canvas.width = this.viewSize.x;
		canvas.height = this.viewSize.y;
		var divMain = document.getElementById("divCinematics");
		divMain.appendChild(canvas);
		this.graphics = canvas.getContext("2d");
		this.graphics.font = "" + fontHeight + "px sans-serif";

		this.fillStyle = "LightGray";
	}
	
	Display.prototype.textWidthForFontCurrent = function(text)
	{
		return this.graphics.measureText(text).width;
	}
}

function Globals()
{}
{
	Globals.Instance = new Globals();

	Globals.prototype.initialize = function(scene)
	{
		this.display = new Display();
		this.display.initialize(scene.fontHeight, scene.size);

		this.inputHelper = new InputHelper();

		this.showing = new Showing
		(
			scene, 
			2 // secondsPerLine
		);
		this.showing.initialize();

		var millisecondsPerTimerTick = 100;
		setInterval
		(
			this.handleEventTimerTick.bind(this), 
			millisecondsPerTimerTick
		);

		this.inputHelper.initialize();
	}

	// events

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

function Image(source)
{
	this.source = source;
}
{
	Image.prototype.size = function()
	{
		return new Coords(this.systemImage.width, this.systemImage.height);
	}
}

function ImageLoader()
{
}
{
	ImageLoader.prototype.loadImages = function(images, callback)
	{
		this.images = images;
		this.images.addLookups("source");
		this.callback = callback;
		this.numberOfImagesLoaded = 0;

		for (var i = 0; i < this.images.length; i++)
		{
			var image = this.images[i];
			var systemImage = document.createElement("img");
			systemImage.src = image.source;
			systemImage.onload = this.loadImages_ImageLoaded.bind(this);
			image.systemImage = systemImage;
		}
	}

	ImageLoader.prototype.loadImages_ImageLoaded = function(event)
	{
		this.numberOfImagesLoaded++;
		if (this.numberOfImagesLoaded >= this.images.length)
		{
			this.callback(this);
		}
	}
}

function InputHelper()
{
	this.keyTyped = null;
	this.isMousePressed = false;
}
{
	InputHelper.prototype.initialize = function()
	{
		document.body.onmousedown = this.handleEventMouseDown.bind(this);
		document.body.onkeyup = this.handleEventKeyUp.bind(this);
	}
	
	InputHelper.prototype.inputsActiveClear = function()
	{
		this.keyTyped = null;
		this.isMousePressed = false;
	}

	// events

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

	InputHelper.prototype.handleEventMouseDown = function(event)
	{
		this.isMousePressed = true;
	}
}

function Line(actorName, text)
{
	this.actorName = actorName;
	this.text = text;
}

function Mark(name, pos)
{
	this.name = name;
	this.pos = pos;
}

function Scene(name, fontHeight, size, background, marks, actors, lines)
{
	this.name = name;
	this.fontHeight = fontHeight;
	this.size = size;
	this.background = background;
	this.marks = marks.addLookups("name");
	this.actors = actors.addLookups("name");
	this.lines = lines;
}

function Showing(scene, secondsPerLine)
{
	this.scene = scene;
	this.secondsPerLine = secondsPerLine;
	
	this.actorNamesPresent = [];
}
{
	Showing.prototype.drawToDisplay = function(display)
	{
		var scene = this.scene;

		var background = scene.background;
		var backgroundVisual = background.visual;
		backgroundVisual.drawToDisplayForDrawableAndPos
		(
			display, background, new Coords(scene.size.x / 2, scene.size.y / 2)
		);

		var actorNames = this.actorNamesPresent;

		for (var i = 0; i < actorNames.length; i++)
		{
			var actorName = actorNames[i];
			var actor = this.scene.actors[actorName];
			var actorPos = actor.mark.pos;
			var actorVisual = actor.visual;

			actorVisual.drawToDisplayForDrawableAndPos
			(
				display, 
				actor,
				actorPos
			);
		}
		
		var line = this.lineCurrent();
		var text = line.text;
		if (line.actorName != null)
		{
			var isTextStageDirection = (text.indexOf("[") == 0);

			if (isTextStageDirection == false)
			{
				var actorName = line.actorName;
				var actor = this.scene.actors[actorName];
				var actorPos = actor.mark.pos;

				var textWidth = display.textWidthForFontCurrent(text);
				var textAsLines = [ text ];
				var numberOfTextLines = textAsLines.length;

				var speechBubbleMargin = 8;
				var textMargin = 8;

				var speechBubbleSize = new Coords
				(
					textWidth + textMargin * 2,
					numberOfTextLines * this.scene.fontHeight + textMargin * 2
				);

				var speechBubblePosX = actorPos.x - textWidth / 2 - textMargin;

				if (speechBubblePosX < 0)
				{
					speechBubblePosX = speechBubbleMargin;
				}
				else if (speechBubblePosX + speechBubbleSize.x > this.scene.size.x)
				{
					speechBubblePosX = 
						this.scene.size.x 
						- speechBubbleMargin 
						- textMargin * 2 
						- textWidth;
				}

				var speechBubblePos = new Coords(speechBubblePosX, this.scene.fontHeight);
				var tailPosX = actorPos.x;
				var cornerRadius = textMargin;

				display.drawSpeechBubble(text, speechBubblePos, tailPosX, speechBubbleSize, cornerRadius);
			}
		}
	}
	
	Showing.prototype.initialize = function()
	{
		this.timeOfLastInputInMilliseconds = new Date().getTime();
		this.isComplete = false;
		this.lineIndexCurrent = null;
		this.lineCurrentAdvance();
		this.drawToDisplay(Globals.Instance.display);
	}

	Showing.prototype.lineCurrent = function()
	{
		return (this.scene.lines[this.lineIndexCurrent]);
	}

	Showing.prototype.lineCurrentAdvance = function()
	{
		if (this.isComplete == true)
		{
			return;
		}

		if (this.lineIndexCurrent == null)
		{
			this.lineIndexCurrent = -1;
		}

		while (true)
		{
			var lineIndexNext = this.lineIndexCurrent + 1;

			if (lineIndexNext >= this.scene.lines.length)
			{
				break;
			}

			this.lineIndexCurrent = lineIndexNext;
			var lineCurrent = this.lineCurrent();
			
			var actorName = lineCurrent.actorName;
			if (actorName == null)
			{
				break;
			}
			
			var actor = this.scene.actors[actorName];

			var lineText = lineCurrent.text;

			var isLineTextStageDirection = (lineText.indexOf("[") == 0);
			if (isLineTextStageDirection == false)
			{
				break;
			}
			else
			{
				var enters = "[enters ";
				var exits = "[exits]";

				if (lineText.indexOf(enters) == 0)
				{
					var markName = lineText.substr(enters.length);
					markName = markName.substr(0, markName.length - 1);
					var mark = this.scene.marks[markName];
					actor.mark = mark;
					this.actorNamesPresent.push(actorName);
				}
				else if (lineText.indexOf(exits) == 0)
				{
					actor.mark = null;
					this.actorNamesPresent.remove(actorName);
				}
			}
		}
	}

	Showing.prototype.updateForTimerTick = function()
	{
		var inputHelper = Globals.Instance.inputHelper;

		var nowInMilliseconds = new Date().getTime();
		var millisecondsSinceLastInput = 
			nowInMilliseconds 
			- this.timeOfLastInputInMilliseconds;
		var secondsSinceLastInput = millisecondsSinceLastInput / 1000;
		var shouldAdvance = 
		(
			inputHelper.isMousePressed == true 
			|| inputHelper.keyTyped == "Enter"
			|| secondsSinceLastInput > this.secondsPerLine
		)
		
		if (shouldAdvance == true)
		{
			this.timeOfLastInputInMilliseconds = nowInMilliseconds;
			inputHelper.inputsActiveClear();

			this.lineCurrentAdvance();

			this.drawToDisplay(Globals.Instance.display);
		}
	}
}

function VisualImage(image)
{
	this.image = image;
}
{
	VisualImage.prototype.drawToDisplayForDrawableAndPos = function(display, drawable, drawPos)
	{
		display.drawImage(this.image, drawPos);
	}
}

// run

main();

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

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

One Response to A Cinematics Engine for a Video Game in JavaScript

Leave a Reply

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

WordPress.com Logo

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

Twitter picture

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

Facebook photo

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

Google+ photo

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

Connecting to %s