adventure-engine
Version:
Simple text based adventure game library.
569 lines (498 loc) • 18.2 kB
JavaScript
//=========================================================
// terminal.js: Primary module for adventure-engine terminal
//=========================================================
// ______ _ ______
// (____ \ | | (_____ \
// ____) )| | _____) )
// | __ ( | | | ____/
// | |__) )| |_____ _| |
// |______(_)_______|_)_|
//
// Description:
// "Dumb" terminal supports "print" and "clear" socket commands.
// Sends a "newLine" command on enter with any text recieved.
//
// Author: Brian Piltin
//
// Copyright: (C) 2019 Brian Piltin.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
//
// Version: 0.1.8
// History:
// 0.1.0: Initial version
// 0.1.1:
// - Add support for control codes for pausing, delay, and color
// 0.1.2:
// - Add support for multiple screens and screen configurations
// 0.1.3:
// - Add support for animation by pausing frames
// 0.1.4:
// - Refactor screenBuffer initialziation
// 0.1.5:
// - Remove support for multiple colors by removing color buffers.
// See playground/terminal-old-1.js for original code.
// 0.1.6:
// - Double buffer the screens and allow for z ordering.
// 0.1.7:
// - Add support for adding blank lines to files
// - Add support for changing logo on html page
// - Fix misc. bugs
// 0.1.8:
// - Fix bug where %n0 should print 10 blank lines
//
// TODO:
// - Add support for sound
// - Add support for advanced color graphics
// - Fix text wrapping on typing characters and backspace
// - Allow for configuring player channels through config function
//=========================================================
;
var terminal = (function () {
const DEBUG = false;
let instance;
function init(canvasId) {
const MAX_FRAME_RATE = 10;
const CANVAS_HEIGHT = 480;
const MAX_ROWS = 40;
const MAX_COLS = 80;
const
CTRL_RESET = 0, CTRL_PANIC = 1, CTRL_DELAY_CHAR = 2, CTRL_DELAY_LINE = 4;
const HOME = 1, RIGHT = 2, LEFT = 3, UP = 4, DOWN = 5;
//---------------------------------------------------------
// Main
//---------------------------------------------------------
//---------------------------------------------------------
// Connection
//---------------------------------------------------------
let socket = io();
//---------------------------------------------------------
// Display
//---------------------------------------------------------
// Calculate character dimensions
const CHAR_FONT = "10pt Courier New";
const CHAR_Y_SPACING = 2;
const CHAR_HEIGHT = Math.round(CANVAS_HEIGHT / (MAX_ROWS + CHAR_Y_SPACING));
const CHAR_HEIGHT_OFFSET = -2;
let terminal = document.getElementById(canvasId);
let ctx = terminal.getContext("2d");
ctx.font = CHAR_FONT;
let line = repeatStr(" ", MAX_COLS);
const CHAR_WIDTH = Math.round(ctx.measureText(line).width / MAX_COLS);
let
screens = {
default: new Screen({
hasFocus: true
})
},
focusScreen = screens.default,
bgColor = "#000000",
fgColor = "#00FF00",
screenBuffer = [];
//---------------------------------------------------------
// Audio
//---------------------------------------------------------
let player;
//---------------------------------------------------------
// Socket event handlers
//---------------------------------------------------------
socket.on('configure', function (config) {
DEBUG && console.log("Configuring screens: " + config);
configure(config);
});
socket.on('print', function (text, screenName) {
DEBUG && console.log("Printing: " + text.substr(0, 10) + " to: " + screenName);
screens[screenName || "default"].print(text + "");
});
socket.on('play', function (music, channelNum) {
DEBUG && console.log("Playing: " + music + " on " + channelNum);
player && player.play(music, channelNum);
});
socket.on('stop', function (channelNum) {
DEBUG && console.log("Stopping playing on channel: " + channelNum);
player && player.stop(channelNum);
});
socket.on('mute', function (channelNum) {
player && player.mute(channelNum);
});
socket.on('unmute', function (channelNum) {
player && player.unMute(channelNum);
});
socket.on('setlogo', function (text) {
let logo = document.getElementById("logo");
if (logo) { logo.innerHTML = text; }
});
//---------------------------------------------------------
// Keyboard event handlers
//---------------------------------------------------------
document.addEventListener('keypress', function (e) {
if (!focusScreen.isWaiting()) {
if (e.charCode >= 32 && e.charCode <= 126) {
focusScreen.type(e.key);
}
}
});
document.addEventListener('keydown', function (e) {
if (!focusScreen.isWaiting()) {
if (e.keyCode === 8) {
focusScreen.backspace();
} else if (e.keyCode === 13) {
// Carriage Return
socket.emit('newline', focusScreen.flushReadBuffer());
focusScreen.type("\n");
}
}
player = player || loFiPlayer.getInstance();
});
document.addEventListener('keypress', function (e) {
if (focusScreen.isWaiting()) {
focusScreen.continue();
}
});
start();
//---------------------------------------------------------
// Screen object
//---------------------------------------------------------
function Screen(config = {}) {
let
row = config.row || 0, col = config.col || 0,
rows = config.rows || MAX_ROWS, cols = config.cols || MAX_COLS,
updating = false,
printBuffer = [], screenBuffer = [], readBuffer = "",
cursor = { row: 0, col: 0 },
hasFocus = config.hasFocus || false,
tabSpaces = repeatStr(" ", config.tabSpaces) || repeatStr(" ", 4),
ctrl = config.ctrl || CTRL_RESET,
charDelay = config.charDelay || 25,
lineDelay = config.lineDelay || 500,
endWait;
if (config.delayChars) { ctrl |= CTRL_DELAY_CHAR; }
if (config.delayLines) { ctrl |= CTRL_DELAY_LINE; }
function start() {
stop();
ctrl &= ~CTRL_PANIC;
clear();
}
function stop() {
ctrl |= CTRL_PANIC;
endWait && endWait();
}
//---------------------------------------------------------
// Clear the local screenbuffer within the range of this screen.
//---------------------------------------------------------
function clear() {
for (let r = 0; r < rows; r++) {
screenBuffer[r] = [];
for (let c = 0; c < cols; c++) {
screenBuffer[r][c] = " ";
};
}
cursor.row = cursor.col = 0;
}
function type(char) {
readBuffer += char === "\n" ? "" : char;
printBuffer.push(char);
};
//---------------------------------------------------------
// Move the cursor one square in any direction.
//---------------------------------------------------------
function moveCursor(dir) {
if (dir === RIGHT) { cursor.col++; }
else if (dir === DOWN) { cursor.row++; cursor.col = 0; }
else if (dir === HOME) { cursor.row = cursor.col = 0; return; }
else if (dir === LEFT) { cursor.col--; }
else if (dir === UP) { cursor.row--; }
if (cursor.col >= cols) {
cursor.col = 0;
cursor.row++;
} else if (cursor.col < 0) {
cursor.col = cols - 1;
cursor.row--;
}
if (cursor.row >= rows) {
// Scroll the screenBuffer
screenBuffer.shift();
screenBuffer.push([]);
cursor.row = rows - 1;
} else if (cursor.row < 0) {
cursor.row = 0;
}
}
async function update() {
if (printBuffer.length && !updating) {
updating = true;
let
str = printBuffer.shift(),
lastLength = printBuffer.length,
repeats = 0;
for (let i = 0; i < str.length; i++) {
// Respond to panic signal to stop rendering
if (ctrl & CTRL_PANIC) {
ctrl &= ~CTRL_PANIC;
break;
} else if (str[i] === "%") {
// Handle CTRL characters
i++;
if (str[i] === "x") { // Reset control
ctrl = CTRL_RESET;
} else if (str[i] === "c") { // Clear screen
clear();
} else if (str[i] === "d") { // Delay chars
ctrl |= CTRL_DELAY_CHAR;
} else if (str[i] === "l") { // Delay lines
ctrl |= CTRL_DELAY_LINE;
} else if (str[i] === "w") { // Wait for input
await input();
} else if (str[i] === "n") { // Add lines + numLines
let numLines = parseInt(str[++i]) || 10;
for (let n = 0; n < numLines; n++) { moveCursor(DOWN); }
} else if (str[i] === "p") { // Pause + pauseTime
let pauseTime = (parseInt(str[++i]) * 100) || 1000;
await pause(pauseTime);
} else if (str[i] === "r") { // Repeat + numRepeats
let numRepeats = parseInt(str[++i]);
if (printBuffer.length === lastLength) {
if (!numRepeats || repeats < numRepeats) {
i = -1;
moveCursor(HOME);
repeats++;
}
}
} else if (str[i] === "q") { // Force quit updating
break;
}
} else if (str[i] === "\n") { // Unix linefeed
if (ctrl & CTRL_DELAY_LINE) { await delayLine(); }
moveCursor(DOWN);
} else {
// Handle printable characters
if (ctrl & CTRL_DELAY_CHAR) { await delayChar(); }
let r = cursor.row, c = cursor.col, s = str[i + 1];
screenBuffer[r][c] = str[i];
if (!hasFocus) {
if (isPrintableChar(s)) { moveCursor(RIGHT); }
} else if (!s || s == "%" || isPrintableChar(s)) {
// New line will move the cursor next iteration
moveCursor(RIGHT);
}
}
}
updating = false;
}
}
function isPrintableChar(char) {
return char && (char !== "%" && char !== "\n");
}
function delayLine() {
return new Promise(function (resolve) {
setTimeout(function () {
resolve();
}, lineDelay);
});
}
function delayChar() {
return new Promise(function (resolve) {
setTimeout(function () {
resolve();
}, charDelay);
});
}
function input() {
return new Promise(function (resolve) {
endWait = function () {
endWait = undefined;
resolve();
}
});
}
function pause(timeMs) {
return new Promise(function (resolve) {
setTimeout(function () {
resolve();
}, timeMs);
});
}
function backspace() {
if (readBuffer && readBuffer.length > 0) {
moveCursor(LEFT);
readBuffer = readBuffer.slice(0, -1);
screenBuffer[cursor.row] = screenBuffer[cursor.row].slice(0, cursor.col);
}
}
function flushReadBuffer() {
let temp = readBuffer;
readBuffer = "";
return temp;
}
function print(text) {
printBuffer.push(text.replace(/\t/g, tabSpaces).replace(/\r\n/g, "\n"));
}
//---------------------------------------------------------
// Screen API
//---------------------------------------------------------
return {
start,
stop,
print,
type,
backspace,
flushReadBuffer,
update,
row,
rows,
col,
cols,
screenBuffer,
cursor,
hasFocus,
isWaiting: function () {
return endWait;
},
continue: function () {
endWait && endWait();
}
}
}
//---------------------------------------------------------
// Drawing
//---------------------------------------------------------
function initScreenBuffer() {
screenBuffer = [];
for (let row = 0; row < MAX_ROWS; row++) {
screenBuffer[row] = [];
for (let col = 0; col < MAX_COLS; col++) {
screenBuffer[row][col] = " ";
};
}
}
//---------------------------------------------------------
// The primary "blitting" routine for characters.
// TODO: Speed this up!
//---------------------------------------------------------
function redraw() {
let cursorRow, cursorCol;
for (const key in screens) {
let scr = screens[key];
// Copy local buffer to global buffer
for (let r = 0; r < scr.rows; r++) {
for (let c = 0; c < scr.cols; c++) {
screenBuffer[scr.row + r][scr.col + c] = scr.screenBuffer[r][c];
}
}
if (scr.hasFocus) {
cursorRow = scr.row + scr.cursor.row;
cursorCol = scr.col + scr.cursor.col;
}
}
drawBackground();
for (let r = 0; r < MAX_ROWS; r++) {
// TODO: optimize this by converting rows from arrays to strings
drawString(screenBuffer[r].join(""), r, 0, fgColor);
}
drawCursor(cursorRow, cursorCol);
}
function drawRect(x, y, width, height, color, angle) {
if (angle) ctx.rotate(-angle * Math.PI / 180);
ctx.fillStyle = color || "#000000";
ctx.fillRect(x, y, width, height);
}
function drawBackground() {
let
width = MAX_COLS * CHAR_WIDTH, // Fix last col underdraw
height = MAX_ROWS * (CHAR_HEIGHT + CHAR_Y_SPACING); // Fix last row underdraw
drawRect(0, 0, width, height, bgColor);
}
function drawText(txt, x, y, color, bgColor) {
if (bgColor) {
drawRect(x, y, CHAR_WIDTH * txt.length, CHAR_HEIGHT, bgColor);
}
ctx.fillStyle = color || "#00FF00";
ctx.fillText(txt, x, y + CHAR_HEIGHT + CHAR_HEIGHT_OFFSET);
}
function drawString(str, r, c, color, bgColor) {
let
x = c * CHAR_WIDTH,
y = r * (CHAR_HEIGHT + CHAR_Y_SPACING);
drawText(str, x, y, color, bgColor);
}
function drawCursor(row, col) {
let
x = col * CHAR_WIDTH,
y = row * (CHAR_HEIGHT + CHAR_Y_SPACING) + 1,
width = CHAR_WIDTH,
height = CHAR_HEIGHT - 1;
drawRect(x, y, width, height, fgColor);
}
//---------------------------------------------------------
// Utils
//---------------------------------------------------------
function repeatStr(str, num) {
return Array.from(str.repeat(num)).join("");
}
//---------------------------------------------------------
// Terminal API
//---------------------------------------------------------
function configure(config) {
DEBUG && console.log(config);
stop();
for (const screenName in screens) {
var screen = screens[screenName];
delete screens[screenName];
}
focusScreen = undefined;
for (const screenName in config) {
var screen = new Screen(config[screenName]);
screens[screenName] = screen;
if (config[screenName].hasFocus) {
focusScreen = screen;
};
}
focusScreen = focusScreen || screen;
start();
}
function start() {
stop();
initScreenBuffer();
for (const key in screens) { screens[key].start(); }
setInterval(update, 1000 / MAX_FRAME_RATE);
setInterval(redraw, 1000 / MAX_FRAME_RATE);
}
function stop() {
clearInterval(redraw);
clearInterval(update);
for (const key in screens) { screens[key].stop(); }
}
function update() {
for (const key in screens) {
screens[key].update();
}
}
return {
print: function (text, toScreen) {
toScreen = screens[toScreen] || "default";
if (screens[toScreen]) {
screens[toScreen].print(text);
}
},
configure
}
}
return {
getInstance: function (canvasId = "terminal") {
if (!instance) {
instance = init(canvasId);
}
return instance;
}
}
})();