Table of Contents
JavaScript Terminal
- Also see: Version 2 which is a more advanced version of this codebase.
This section will outline the basic JavaScript terminal code. This code was not used much when I taught JavaScript to Roger and Neo, but it serves the same basic purpose as the PyGame Terminal.
The level of this code is the beginning of Season 2, for many reasons. If we took out the resize code, it might be late season 1 material, but with the resize code in I would say it is good for Season 2. Maybe a stripped down version could be produced later; but the idea here is that it will display on a cellphone or a tablet as well as on a desktop.
You can view a technical demo of this program at: JavaScript Terminal Technical Demo 1.
The Font
The font used in this technical demo is PxPlus_IBM_VGA_8x16.ttf which is available from the The Ultimate Oldschool PC Font Pack at int10h.org, bless their kindness. We used version 2.2.
index.html
You can of course also use an index.php.
<html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Basic Javascript Terminal</title> <style> body, html { margin: 0; overflow: hidden; } canvas { display: block; border: none; } </style> </head> <body> <canvas id="fsc"></canvas> <script src="js/Color.js"></script> <script src="js/Character.js"></script> <script src="js/Terminal.js"></script> <script src="js/main.js"></script> </body> </html>
There is not much to see here. The most interesting line is probably the meta tag. We must set initial_scale to 1 or the text will look too small on higher resolution screens.
Otherwise our main goal is to load and run the JavaScript files. There are four main parts: Color.js, which is a color map, Character.js which discusses what needs to be known to represent a character, Terminal, which holds the information about characters on the screen and how to draw them, and main.js which sets up everything and starts the game loop.
Actually, you could probably refactor some things in main into a class (such as a Game.js class) and then just instantiate and run the class, like we do in Python for PyGame. Perhaps this will be done in a part two sequel article to this one. For now, it's left as an exercise to the reader.
Color.js
class Color { static black = '#000000'; static blue = '#0000AA'; static green = '#00AA00'; static cyan = '#00AAAA'; static red = '#AA0000'; static magenta = '#AA00AA'; static brown = '#AA5500'; static lightgray = '#AAAAAA'; static darkgray = '#555555'; static lightblue = '#5555FF'; static lightgreen = '#55FF55'; static lightcyan = '#55FFFF'; static lightred = '#FF5555'; static lightmagenta = '#FF55FF'; static yellow = '#FFFF55'; static white = '#FFFFFF'; static colorMap = [this.black, this.blue, this.green, this.cyan, this.red, this.magenta, this.brown, this.lightgray, this.darkgray, this.lightblue, this.lightgreen, this.lightcyan, this.lightred, this.lightmagenta, this.yellow, this.white] static getColor(index) { if (index >= 0 && index <= 15) { return this.colorMap[index]; } else { return null; // Return null for invalid index } } static getColorByName(colorName) { const lowerCaseColorName = colorName.toLowerCase(); const index = Object.keys(Color).findIndex(key => Color[key].toLowerCase() === lowerCaseColorName); if (index !== -1) { return Color.getColor(index); } else { return null; // Return null for unsupported colors } } }
Info
This code contains four important parts; the static references, the colormap, and two getters (by color number and by name).
Static References
These can be accessed by Color.lightgray, Color.black, etc. once the file is loaded. This is also how we define the colormap.
The ColorMap
These are an enumerated list of colors, which allow us to reference the color by their original color number. Although VGA had access to 256 colors, we are most concerned with the first sixteen here; they are listed in order from 0 to 15. This will allow us to simulate the look and feel of “color ASCII nethack” on an IBM PC.
getColor() and getColorByName()
You can do Color.getColor(number), and 0 will return black.
You can also do Color.getColorByName('black') and this will return Color.black. This is probably useful if you need to input a color by the user, or you don't wish to remember the digits or use the static references. The digits are probably the most space-efficient way of storing the color data – although bit packing would be more efficient – the digits (0 to 15) are a decent trade-off.
Character.js
class Character { constructor(ch = ' ', color = 'LightGray', background = 'black') { this.ch = ch; this.color = color; this.background = background; } }
Simple! We will store the character (or it's ASCII code for printing), it's color, and it's background. This is all the data we need to represent a character on the screen.
Terminal.js
class Terminal { constructor(cols, rows) { this.charWidth = 16; this.charHeight = 32; this.cols = cols; this.rows = rows; this.buf = new Array(rows); for (let y = 0; y < rows; y++) { this.buf[y] = new Array(cols); for (let x = 0; x < cols; x++) { this.buf[y][x] = new Character(); } } this.calcFontMetrics() } calcFontMetrics() { // Measure the width of a single character (assuming monospaced font) const measureTextResult = ctx.measureText('@'); //console.log(measureTextResult); // Calculate the baseline offset // If there is a problem, adjust by hand. // PxPlus_IBM_VGA_9x16 seems to work well at 24... this.font_yadj = Math.round(32 - measureTextResult.fontBoundingBoxAscent) + 1; this.font_yadj = 24 //console.log("Calculating font Y adjust: " + this.font_yadj); } setch(x, y, ch, color = 'gray', background = 'black') { this.buf[y][x].ch = ch; this.buf[y][x].color = color; this.buf[y][x].background = background; } drawCharacter(ctx, x, y, ch, color = 'light gray', background = 'black') { // Calculate the actual position on the canvas based on character width and height const xPos = x * this.charWidth; const yPos = (y * this.charHeight); // Set the background ctx.fillStyle = background; ctx.fillRect(xPos, yPos, this.charWidth, this.charHeight); // Draw the character on the canvas ctx.fillStyle = color; ctx.fillText(ch, xPos, yPos + this.font_yadj); } draw(ctx) { for (let y = 0; y < this.rows; y++) { for (let x = 0; x < this.cols; x++) { // Get the character and color from the buf array const ch = this.buf[y][x].ch; const fg = this.buf[y][x].color; const bg = this.buf[y][x].background; this.drawCharacter(ctx, x, y, ch, fg, bg); } } } copyContentTo(newTerminal) { // Determine the number of rows and columns to copy const rowsToCopy = Math.min(this.rows, newTerminal.rows); const colsToCopy = Math.min(this.cols, newTerminal.cols); // Copy content from the old terminal to the new terminal for (let y = 0; y < rowsToCopy; y++) { for (let x = 0; x < colsToCopy; x++) { const ch = this.buf[y][x].ch; const color = this.buf[y][x].color; const background = this.buf[y][x].background; newTerminal.setch(x, y, ch, color, background); } } if (this.timerId) { clearInterval(this.timerId) } } }
Analysis
This is the workhorse and it's a bit large, so review it carefully.
Constructor
The constructor sets up various metrics. First, we have hardcoded the font metrics we will be using. We could discover these, but, for now we will design it around a particular font as much as we can.
Next are rows and columns, and finally we set up the main data structure, which is a basic two-dimensional array of class Character.
calculateFontMetrics()
This function is somewhat difficult as it is font-dependant. If you have problems with it you should adjust it by hand. Luckily int10h has a preview feature and if you blow it up to 300% or 400% you can count the pixels visually. For a 32 point PxPlus Codepage 437 VGA 9×16 font I have found 24 to be a workable yadj.
setch()
setch() is a simple function that sets the character data of an x,y location. It is the standard way such data should be set.
drawCharacter()
This is an important one. We first blank out the space by drawing the background color. Next, we draw the character. Note the y_adj.
draw()
This just draws every character. It could theoretically be improved by only drawing characters which have changed, but this would require a change to the game loop which removed the clear-screen. It can also lead to subtle changes if some characters are drawn outside their box. Such a feature might be included in a part 2 (or part 3) in this series.
copyTo
A simple function which copies important data to a new terminal, used mainly in resizing (see main.js).
main.js
// Set up Canvas let canvas = document.getElementById('fsc'); let ctx = canvas.getContext('2d'); // Set up Terminal maxtrows = 10 maxtcols = 10 terminal = new Terminal(10, 10) // load font const font = new FontFace('myvga', 'url(PxPlus_IBM_VGA_8x16.ttf)'); font.load().then(() => { document.fonts.add(font); ctx.font = '32px myvga'; }); function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function getRandomLetter() { const s = "abcdefghijklmnopqrstuvwxyz"; const i = Math.floor(Math.random() * s.length); return s.charAt(i); } // Function to resize and recreate the canvas function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; maxTcols = Math.floor(canvas.width / terminal.charWidth) maxTrows = Math.floor(canvas.height / terminal.charHeight); t = new Terminal(maxTcols, maxTrows); terminal.copyContentTo(t); terminal = t; ctx.font = '32px myvga'; // This seems to be needed here. } // Call the resizeCanvas function when the window is resized window.addEventListener('resize', resizeCanvas); // Animation logic, timed events, etc. // I don't plan for this to be used much in a roguelike. // but it is useful for (ex.) a static demo of the display engine. function update() { n = getRandomInt(1, 100); if (n === 1) { x = getRandomInt(0, terminal.cols - 1) y = getRandomInt(0, terminal.rows - 1) ch = getRandomLetter() color = Color.getColor(getRandomInt(0, 15)) background = Color.black terminal.setch(x, y, ch, color, background) //console.log("[" + ch + "], " + x + "," + y + " " + color); } } // All we really need to do is draw the terminal. // What is on the terminal is done using (ex.) Terminal.setch() function render() { // clear screen ctx.fillStyle = 'black'; ctx.fillRect(0, 0, canvas.width, canvas.height); // draw characters on terminal terminal.draw(ctx); } function gameLoop() { // Update game logic here update() // Render the game state render(); // Request the next frame requestAnimationFrame(gameLoop); } // Initial canvas setup resizeCanvas(); // Quick test -- see update() for the rest of the 'demo' test code. terminal.setch(3, 3, '@', Color.lightgray); terminal.setch(5, 3, 'a', Color.yellow); terminal.setch(6, 4, 'b', Color.green); terminal.setch(7, 5, 'c', Color.red); terminal.setch(8, 6, 'd', Color.magenta); // Start the game loop gameLoop();
Analysis
Most of this should already be familiar since all of it would have been covered in JavaScript Season 1.
However, one interesting quirk which arose is that the ctx.font has to be set upon terminal resize. I am not sure where the best place is to set it, but it seems to work great setting it on a resize. It would also work if you set it every time you drew a character, but if we assume that we are only using one font and are essentially the main application (a one-canvas app) then setting it on every character draw is probably unnecessary.
Also See
- Also see: Version 2 which is a more advanced version of this codebase.