UNPKG

adventure-engine

Version:

Simple text based adventure game library.

569 lines (498 loc) 18.2 kB
//========================================================= // 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 //========================================================= "use strict"; 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; } } })();