├── .gitignore ├── src ├── main.ts ├── lib │ ├── version.ts │ ├── index.ts │ ├── classes │ │ ├── terminal-game-io-node.spec.ts │ │ ├── terminal-game-io-browser.spec.ts │ │ ├── abstract-terminal-game-io.spec.ts │ │ ├── terminal-game-io-node.ts │ │ ├── terminal-game-io-browser.ts │ │ └── abstract-terminal-game-io.ts │ ├── models │ │ ├── abstract-terminal-game-io.interface.ts │ │ ├── terminal-game-io.interface.ts │ │ └── key-name.interface.ts │ ├── terminal-game-io.factory.ts │ └── utilities │ │ ├── environment.ts │ │ ├── browser-keyboard-event.ts │ │ └── terminal.ts ├── templates │ ├── demo-node.js │ └── demo-browser.html └── dev.ts ├── .travis.yml ├── .editorconfig ├── tsconfig.json ├── tslint.json ├── LICENCE ├── package.json ├── webpack.config.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | export * from './lib'; 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: '8' 3 | cache: yarn 4 | script: 5 | - npm run build 6 | after_success: 7 | - npm run report-coverage 8 | -------------------------------------------------------------------------------- /src/lib/version.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | export const version = '3.1.0'; 4 | export const author = 'Robert Rypuła'; 5 | export const githubUrl = 'https://github.com/robertrypula'; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 120 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | export * from './models/terminal-game-io.interface'; 4 | export * from './terminal-game-io.factory'; 5 | export * from './utilities/environment'; 6 | export * from './version'; 7 | export { Key, KeyName } from './models/key-name.interface'; 8 | -------------------------------------------------------------------------------- /src/lib/classes/terminal-game-io-node.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { TerminalGameIoNode } from './terminal-game-io-node'; 4 | 5 | describe('AbstractTerminalGameIo', () => { 6 | it('should create proper instance', () => { 7 | expect(TerminalGameIoNode).toBeTruthy(); // dummy test 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/classes/terminal-game-io-browser.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { TerminalGameIoBrowser } from './terminal-game-io-browser'; 4 | 5 | describe('AbstractTerminalGameIo', () => { 6 | it('should create proper instance', () => { 7 | expect(TerminalGameIoBrowser).toBeTruthy(); // dummy test 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/classes/abstract-terminal-game-io.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { AbstractTerminalGameIo } from './abstract-terminal-game-io'; 4 | 5 | describe('AbstractTerminalGameIo', () => { 6 | it('should create proper instance', () => { 7 | expect(AbstractTerminalGameIo).toBeTruthy(); // dummy test 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "./dist", 5 | "experimentalDecorators": true, 6 | "jsx": "react", 7 | "module": "commonjs", 8 | "noImplicitAny": true, 9 | "outDir": "./dist", 10 | "sourceMap": true, 11 | "target": "es5" 12 | }, 13 | "exclude": [], 14 | "include": [ 15 | "src/**/*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "indent": [true, "spaces", 2], 9 | "trailing-comma": [ 10 | true, 11 | { 12 | "multiline": "never", 13 | "singleline": "never" 14 | } 15 | ], 16 | "quotemark": [true, "single"], 17 | "prefer-for-of": false 18 | }, 19 | "rulesDirectory": [] 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/models/abstract-terminal-game-io.interface.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { KeyName } from './key-name.interface'; 4 | 5 | export interface IAbstractTerminalGameIo { 6 | drawFrame(data: string, width: number, height: number): void; 7 | exit(): void; 8 | getTime(): number; 9 | triggerKeypress(keyName: KeyName): void; 10 | } 11 | 12 | export interface IAbstractTerminalGameIoOptions { 13 | fps: number; 14 | frameHandler: AbstractFrameHandler; 15 | keypressHandler: AbstractKeypressHandler; 16 | } 17 | 18 | export type AbstractFrameHandler = (instance: IAbstractTerminalGameIo) => void; 19 | export type AbstractKeypressHandler = (instance: IAbstractTerminalGameIo, keyName: KeyName) => void; 20 | -------------------------------------------------------------------------------- /src/lib/terminal-game-io.factory.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { isBrowser, isNode } from '.'; 4 | import { TerminalGameIoBrowser } from './classes/terminal-game-io-browser'; 5 | import { TerminalGameIoNode } from './classes/terminal-game-io-node'; 6 | import { 7 | ITerminalGameIoOptions, 8 | ITerminalGameIoStatic, 9 | TerminalGameIoFactory 10 | } from './models/terminal-game-io.interface'; 11 | 12 | export const createTerminalGameIo: TerminalGameIoFactory = ( 13 | options: ITerminalGameIoOptions 14 | ) => { 15 | let factoryClass: ITerminalGameIoStatic; 16 | 17 | if (isNode) { 18 | factoryClass = TerminalGameIoNode; 19 | } else if (isBrowser) { 20 | factoryClass = TerminalGameIoBrowser; 21 | } else { 22 | throw new Error('Unable to create TerminalGameIo object due to environment detection problem.'); 23 | } 24 | 25 | return new factoryClass(options); 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/models/terminal-game-io.interface.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { IAbstractTerminalGameIo, IAbstractTerminalGameIoOptions } from './abstract-terminal-game-io.interface'; 4 | import { KeyName } from './key-name.interface'; 5 | 6 | /*tslint:disable-next-line:no-empty-interface*/ 7 | export interface ITerminalGameIo extends IAbstractTerminalGameIo { } 8 | 9 | export interface ITerminalGameIoOptions extends IAbstractTerminalGameIoOptions { 10 | domElementId?: string; 11 | frameHandler: FrameHandler; 12 | keypressHandler: KeypressHandler; 13 | } 14 | 15 | export interface ITerminalGameIoStatic { 16 | new( 17 | terminalGameIoOptions: ITerminalGameIoOptions 18 | ): ITerminalGameIo; 19 | } 20 | 21 | export type TerminalGameIoFactory = (options: ITerminalGameIoOptions) => ITerminalGameIo; 22 | 23 | export type FrameHandler = (instance: ITerminalGameIo) => void; 24 | export type KeypressHandler = (instance: ITerminalGameIo, keyName: KeyName) => void; 25 | -------------------------------------------------------------------------------- /src/lib/utilities/environment.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | declare var document: Document; 4 | declare var global: NodeJS.Global; 5 | declare var navigator: Navigator; 6 | declare var window: Window; 7 | 8 | /* 9 | isNode && isBrowser based on: 10 | - https://stackoverflow.com/a/33697246 11 | - https://github.com/foo123/asynchronous.js/blob/0.5.1/asynchronous.js#L40 12 | - https://stackoverflow.com/a/48536881 13 | */ 14 | export const isNode = 15 | typeof global !== 'undefined' && 16 | toString.call(global) === '[object global]'; 17 | 18 | export const isBrowser = 19 | !isNode && 20 | typeof navigator !== 'undefined' && 21 | typeof document !== 'undefined' && 22 | typeof window !== 'undefined'; 23 | 24 | export const getElementById = (id: string): HTMLElement => { 25 | return isBrowser 26 | ? document.getElementById(id) 27 | : null; 28 | }; 29 | 30 | export const argv: string[] = isNode ? global.process.argv : []; 31 | 32 | export const process: NodeJS.Process = isNode ? global.process : null; 33 | -------------------------------------------------------------------------------- /src/lib/models/key-name.interface.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | export type KeyName = Key | string; 4 | 5 | export enum Key { 6 | Unknown = '', 7 | 8 | // other Ascii 9 | Backspace = 'Backspace', 10 | Tab = 'Tab', 11 | Enter = 'Enter', 12 | Escape = 'Escape', 13 | Space = 'Space', 14 | Delete = 'Delete', 15 | 16 | // arrows 17 | ArrowUp = 'ArrowUp', 18 | ArrowDown = 'ArrowDown', 19 | ArrowRight = 'ArrowRight', 20 | ArrowLeft = 'ArrowLeft', 21 | 22 | // cursor position 23 | Home = 'Home', 24 | Insert = 'Insert', 25 | End = 'End', 26 | PageUp = 'PageUp', 27 | PageDown = 'PageDown', 28 | 29 | // functional 30 | F1 = 'F1', 31 | F2 = 'F2', 32 | F3 = 'F3', 33 | F4 = 'F4', 34 | F5 = 'F5', 35 | F6 = 'F6', 36 | F7 = 'F7', 37 | F8 = 'F8', 38 | F9 = 'F9', 39 | F10 = 'F10', 40 | F11 = 'F11', 41 | F12 = 'F12' 42 | } 43 | 44 | export interface IKeyNameMapBrowser { 45 | keyNameIn: string[]; 46 | keyNameOut: KeyName; 47 | } 48 | 49 | export interface IKeyNameMapNode { 50 | data: number[][]; 51 | keyName: KeyName; 52 | } 53 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula/terminal-game-io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/lib/classes/terminal-game-io-node.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { ITerminalGameIo, ITerminalGameIoOptions, KeyName, process } from '..'; 4 | import { cursorPosition, getKeyName } from '../utilities/terminal'; 5 | import { AbstractTerminalGameIo } from './abstract-terminal-game-io'; 6 | 7 | export class TerminalGameIoNode extends AbstractTerminalGameIo implements ITerminalGameIo { 8 | public constructor(options: ITerminalGameIoOptions) { 9 | super(options); 10 | } 11 | 12 | protected finalCleanup(): void { 13 | process.stdin.removeAllListeners(); 14 | process.exit(); 15 | } 16 | 17 | protected clear(): void { 18 | this.write(cursorPosition(0, 0)); 19 | } 20 | 21 | protected initializeEvents(): void { 22 | process.stdin.setRawMode(true); 23 | process.stdin.on('data', (buffer: Buffer) => { 24 | const keyName: KeyName = getKeyName(buffer.toJSON().data); 25 | 26 | // TODO on ssh connections more than one key might be present in the data array - fix it 27 | 28 | this.keypressHandler(this, keyName); 29 | }); 30 | } 31 | 32 | protected write(value: string): void { 33 | process.stdout.write(value); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/classes/terminal-game-io-browser.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { getElementById, ITerminalGameIo, ITerminalGameIoOptions, KeyName } from '..'; 4 | import { getNormalizedKeyName } from '../utilities/browser-keyboard-event'; 5 | import { AbstractTerminalGameIo } from './abstract-terminal-game-io'; 6 | 7 | export class TerminalGameIoBrowser extends AbstractTerminalGameIo implements ITerminalGameIo { 8 | protected domElementId: string; 9 | protected keydownEventListener: (e: KeyboardEvent) => void; 10 | 11 | public constructor(options: ITerminalGameIoOptions) { 12 | super(options); 13 | this.domElementId = options.domElementId ? options.domElementId : 'root'; 14 | } 15 | 16 | protected finalCleanup(): void { 17 | document.removeEventListener('keydown', this.keydownEventListener); 18 | } 19 | 20 | protected clear(): void { 21 | const domElement: HTMLElement = getElementById(this.domElementId); 22 | 23 | if (domElement) { 24 | domElement.innerHTML = ''; 25 | } 26 | } 27 | 28 | protected initializeEvents(): void { 29 | this.keydownEventListener = (e: KeyboardEvent): void => { 30 | const keyName: KeyName = getNormalizedKeyName(e); 31 | 32 | this.keypressHandler(this, keyName); 33 | // e.preventDefault(); // TODO think about it 34 | }; 35 | 36 | document.addEventListener('keydown', this.keydownEventListener); 37 | } 38 | 39 | protected write(value: string): void { 40 | const domElement: HTMLElement = getElementById(this.domElementId); 41 | 42 | if (domElement) { 43 | domElement.innerHTML = domElement.innerHTML + value; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/templates/demo-node.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | const TerminalGameIo = 4 | require('./terminal-game-io-v3.1.0.js'); // in your application replace it to: require('terminal-game-io'); 5 | 6 | const Key = TerminalGameIo.Key; 7 | 8 | const FPS = 5; 9 | const BOARD_WIDTH = 40; 10 | const BOARD_HEIGHT = 12; 11 | 12 | let terminalGameIo; 13 | let lastKeyName = ''; 14 | let posX = Math.round(BOARD_WIDTH / 2); 15 | let posY = Math.round(BOARD_HEIGHT / 2); 16 | let frameNumber = 0; 17 | 18 | const frameHandler = (instance) => { 19 | const lines = []; 20 | let frameData = ''; 21 | 22 | for (let y = 0; y < BOARD_HEIGHT; y++) { 23 | for (let x = 0; x < BOARD_WIDTH; x++) { 24 | frameData += (posX === x && posY === y) ? '@' : '.'; 25 | } 26 | } 27 | 28 | lines.push( 29 | 'Frame: ' + (frameNumber++), 30 | 'Time: ' + instance.getTime().toFixed(3) + 's', 31 | 'Last key name: ' + lastKeyName, 32 | '', 33 | 'Use arrows to move.', 34 | 'Press Escape to exit...' 35 | ); 36 | for (let i = 0; i < lines.length; i++) { 37 | frameData = addLine(frameData, lines[i], BOARD_WIDTH); 38 | } 39 | 40 | instance.drawFrame(frameData, BOARD_WIDTH, BOARD_HEIGHT + lines.length); 41 | }; 42 | 43 | const addLine = (frameData, line, lineWidth) => { 44 | return line.length > lineWidth 45 | ? frameData + line.substr(0, lineWidth) 46 | : frameData + line + (new Array(lineWidth - line.length + 1).join(' ')); 47 | }; 48 | 49 | const keypressHandler = (instance, keyName) => { 50 | lastKeyName = keyName; 51 | 52 | switch (keyName) { 53 | case Key.ArrowDown: 54 | posY = (posY + 1) % BOARD_HEIGHT; 55 | break; 56 | case Key.ArrowUp: 57 | posY = posY === 0 ? BOARD_HEIGHT - 1 : posY - 1; 58 | break; 59 | case Key.ArrowLeft: 60 | posX = posX === 0 ? BOARD_WIDTH - 1 : posX - 1; 61 | break; 62 | case Key.ArrowRight: 63 | posX = (posX + 1) % BOARD_WIDTH; 64 | break; 65 | case Key.Escape: 66 | instance.exit(); 67 | break; 68 | } 69 | 70 | frameHandler(instance); 71 | }; 72 | 73 | terminalGameIo = TerminalGameIo.createTerminalGameIo({ 74 | fps: FPS, 75 | frameHandler, 76 | keypressHandler 77 | }); 78 | -------------------------------------------------------------------------------- /src/dev.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { 4 | createTerminalGameIo, 5 | FrameHandler, 6 | ITerminalGameIo, 7 | Key, 8 | KeyName, 9 | KeypressHandler 10 | } from './lib'; // in your application replace it to: } from 'terminal-game-io'; 11 | 12 | const FPS = 5; 13 | const BOARD_WIDTH = 40; 14 | const BOARD_HEIGHT = 12; 15 | 16 | let terminalGameIo: ITerminalGameIo; 17 | let lastKeyName = ''; 18 | let posX = Math.round(BOARD_WIDTH / 2); 19 | let posY = Math.round(BOARD_HEIGHT / 2); 20 | let frameNumber = 0; 21 | 22 | const frameHandler: FrameHandler = (instance: ITerminalGameIo) => { 23 | const lines: string[] = []; 24 | let frameData = ''; 25 | 26 | for (let y = 0; y < BOARD_HEIGHT; y++) { 27 | for (let x = 0; x < BOARD_WIDTH; x++) { 28 | frameData += (posX === x && posY === y) ? '@' : '.'; 29 | } 30 | } 31 | 32 | lines.push( 33 | 'Frame: ' + (frameNumber++), 34 | 'Time: ' + instance.getTime().toFixed(3) + 's', 35 | 'Last key name: ' + lastKeyName, 36 | '', 37 | 'Use arrows to move.', 38 | 'Press Escape to exit...' 39 | ); 40 | for (let i = 0; i < lines.length; i++) { 41 | frameData = addLine(frameData, lines[i], BOARD_WIDTH); 42 | } 43 | 44 | instance.drawFrame(frameData, BOARD_WIDTH, BOARD_HEIGHT + lines.length); 45 | }; 46 | 47 | const addLine = (frameData: string, line: string, lineWidth: number): string => { 48 | return line.length > lineWidth 49 | ? frameData + line.substr(0, lineWidth) 50 | : frameData + line + (new Array(lineWidth - line.length + 1).join(' ')); 51 | }; 52 | 53 | const keypressHandler: KeypressHandler = (instance: ITerminalGameIo, keyName: KeyName) => { 54 | lastKeyName = keyName; 55 | 56 | switch (keyName) { 57 | case Key.ArrowDown: 58 | posY = (posY + 1) % BOARD_HEIGHT; 59 | break; 60 | case Key.ArrowUp: 61 | posY = posY === 0 ? BOARD_HEIGHT - 1 : posY - 1; 62 | break; 63 | case Key.ArrowLeft: 64 | posX = posX === 0 ? BOARD_WIDTH - 1 : posX - 1; 65 | break; 66 | case Key.ArrowRight: 67 | posX = (posX + 1) % BOARD_WIDTH; 68 | break; 69 | case Key.Escape: 70 | instance.exit(); 71 | break; 72 | } 73 | 74 | frameHandler(instance); 75 | }; 76 | 77 | terminalGameIo = createTerminalGameIo({ 78 | fps: FPS, 79 | frameHandler, 80 | keypressHandler 81 | }); 82 | -------------------------------------------------------------------------------- /src/lib/classes/abstract-terminal-game-io.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { KeyName } from '..'; 4 | import { 5 | AbstractFrameHandler, 6 | AbstractKeypressHandler, 7 | IAbstractTerminalGameIo, 8 | IAbstractTerminalGameIoOptions 9 | } from '../models/abstract-terminal-game-io.interface'; 10 | 11 | export abstract class AbstractTerminalGameIo implements IAbstractTerminalGameIo { 12 | protected active: boolean; 13 | protected frameDuration: number; 14 | protected frameHandler: AbstractFrameHandler; 15 | protected intervalId: any; 16 | protected keypressHandler: AbstractKeypressHandler; 17 | protected currentFrameData: string; 18 | protected startTime: number; 19 | 20 | protected constructor(options: IAbstractTerminalGameIoOptions) { 21 | this.frameDuration = Math.round(1000 / options.fps); 22 | this.frameHandler = options.frameHandler; 23 | this.keypressHandler = options.keypressHandler; 24 | this.startTime = new Date().getTime(); 25 | 26 | this.initialize(); 27 | setTimeout(() => this.frameHandler(this), 0); 28 | } 29 | 30 | public drawFrame(data: string, width: number, height: number): void { 31 | let fullFrame = ''; 32 | let index = 0; 33 | let line: string; 34 | 35 | if (!this.active) { 36 | return; 37 | } 38 | 39 | if (data.length !== width * height) { 40 | throw new Error('Frame data is not matching drawFrame dimensions'); 41 | } 42 | 43 | if (this.currentFrameData === data) { 44 | return; 45 | } 46 | 47 | this.clear(); 48 | for (let y = 0; y < height; y++) { 49 | line = ''; 50 | for (let x = 0; x < width; x++) { 51 | line += data[index++]; 52 | } 53 | fullFrame += line + '\n'; 54 | } 55 | this.write(fullFrame); 56 | this.currentFrameData = data; 57 | } 58 | 59 | public exit(): void { 60 | this.active = false; 61 | clearInterval(this.intervalId); 62 | this.finalCleanup(); 63 | } 64 | 65 | public getTime(): number { 66 | const now = new Date().getTime(); 67 | const difference = now - this.startTime; 68 | 69 | return difference / 1000; 70 | } 71 | 72 | public triggerKeypress(keyName: KeyName): void { 73 | if (!this.active) { 74 | return; 75 | } 76 | 77 | this.keypressHandler(this, keyName); 78 | } 79 | 80 | protected abstract clear(): void; 81 | 82 | protected abstract finalCleanup(): void; 83 | 84 | protected initialize(): void { 85 | this.initializeEvents(); 86 | this.intervalId = setInterval(() => this.frameHandler(this), this.frameDuration); 87 | this.active = true; 88 | } 89 | 90 | protected abstract initializeEvents(): void; 91 | 92 | protected abstract write(value: string): void; 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/utilities/browser-keyboard-event.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { IKeyNameMapBrowser, Key, KeyName } from '../models/key-name.interface'; 4 | 5 | const isPrintableAscii = (keyName: string): boolean => { 6 | // skip tilde as this is not the key available via single press 7 | return keyName.length === 1 && 8 | '!'.charCodeAt(0) <= keyName.charCodeAt(0) && 9 | keyName.charCodeAt(0) <= '}'.charCodeAt(0); 10 | }; 11 | 12 | const keyNameMap: IKeyNameMapBrowser[] = [ 13 | // other ASCII 14 | { keyNameIn: ['Backspace'], keyNameOut: Key.Backspace }, 15 | { keyNameIn: ['Tab'], keyNameOut: Key.Tab }, 16 | { keyNameIn: ['Enter'], keyNameOut: Key.Enter }, 17 | { keyNameIn: ['Escape', 'Esc'], keyNameOut: Key.Escape }, 18 | { keyNameIn: ['Space', 'Spacebar', ' '], keyNameOut: Key.Space }, 19 | { keyNameIn: ['Delete', 'Del'], keyNameOut: Key.Delete }, 20 | // arrows 21 | { keyNameIn: ['ArrowUp', 'Up'], keyNameOut: Key.ArrowUp }, 22 | { keyNameIn: ['ArrowDown', 'Down'], keyNameOut: Key.ArrowDown }, 23 | { keyNameIn: ['ArrowRight', 'Right'], keyNameOut: Key.ArrowRight }, 24 | { keyNameIn: ['ArrowLeft', 'Left'], keyNameOut: Key.ArrowLeft }, 25 | // cursor position 26 | { keyNameIn: ['Home'], keyNameOut: Key.Home }, 27 | { keyNameIn: ['Insert'], keyNameOut: Key.Insert }, 28 | { keyNameIn: ['End'], keyNameOut: Key.End }, 29 | { keyNameIn: ['PageUp'], keyNameOut: Key.PageUp }, 30 | { keyNameIn: ['PageDown'], keyNameOut: Key.PageDown }, 31 | // functional 32 | { keyNameIn: ['F1'], keyNameOut: Key.F1 }, 33 | { keyNameIn: ['F2'], keyNameOut: Key.F2 }, 34 | { keyNameIn: ['F3'], keyNameOut: Key.F3 }, 35 | { keyNameIn: ['F4'], keyNameOut: Key.F4 }, 36 | { keyNameIn: ['F5'], keyNameOut: Key.F5 }, 37 | { keyNameIn: ['F6'], keyNameOut: Key.F6 }, 38 | { keyNameIn: ['F7'], keyNameOut: Key.F7 }, 39 | { keyNameIn: ['F8'], keyNameOut: Key.F8 }, 40 | { keyNameIn: ['F9'], keyNameOut: Key.F9 }, 41 | { keyNameIn: ['F10'], keyNameOut: Key.F10 }, 42 | { keyNameIn: ['F11'], keyNameOut: Key.F11 }, 43 | { keyNameIn: ['F12'], keyNameOut: Key.F12 }, 44 | // purely IE mapping 45 | { keyNameIn: ['Add'], keyNameOut: '+' }, 46 | { keyNameIn: ['Decimal'], keyNameOut: '.' }, 47 | { keyNameIn: ['Divide'], keyNameOut: '/' }, 48 | { keyNameIn: ['Multiply'], keyNameOut: '*' }, 49 | { keyNameIn: ['Subtract'], keyNameOut: '-' } 50 | ]; 51 | 52 | export const getNormalizedKeyName = (e: KeyboardEvent): KeyName => { 53 | let match: IKeyNameMapBrowser[]; 54 | 55 | if (isPrintableAscii(e.key)) { 56 | return e.key; 57 | } 58 | 59 | match = keyNameMap.filter( 60 | (entry) => entry.keyNameIn.indexOf(e.key) >= 0 61 | ); 62 | 63 | if (match.length === 1) { 64 | return match[0].keyNameOut; 65 | } 66 | 67 | return Key.Unknown; 68 | }; 69 | -------------------------------------------------------------------------------- /src/lib/utilities/terminal.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Robert Rypuła - https://github.com/robertrypula 2 | 3 | import { IKeyNameMapNode, Key, KeyName } from '../models/key-name.interface'; 4 | 5 | const CSI = String.fromCharCode(27) + '['; 6 | 7 | export const cursorPosition = (x: number, y: number) => `${CSI}${y + 1};${y + 1}H`; 8 | 9 | const isSingleBytePrintableAscii = (data: number[]): boolean => { 10 | // skip tilde as this is not the key available via single press 11 | return data.length === 1 && 12 | '!'.charCodeAt(0) <= data[0] && 13 | data[0] <= '}'.charCodeAt(0); 14 | }; 15 | 16 | const keyMap: IKeyNameMapNode[] = [ 17 | // other ASCII 18 | { data: [[8], [127]], keyName: Key.Backspace }, // ssh connection via putty generates 127 for Backspace - weird... 19 | { data: [[9]], keyName: Key.Tab }, 20 | { data: [[13]], keyName: Key.Enter }, 21 | { data: [[27]], keyName: Key.Escape }, 22 | { data: [[32]], keyName: Key.Space }, 23 | { data: [[27, 91, 51, 126]], keyName: Key.Delete }, 24 | // arrows 25 | { data: [[27, 91, 65]], keyName: Key.ArrowUp }, 26 | { data: [[27, 91, 66]], keyName: Key.ArrowDown }, 27 | { data: [[27, 91, 67]], keyName: Key.ArrowRight }, 28 | { data: [[27, 91, 68]], keyName: Key.ArrowLeft }, 29 | // cursor position 30 | { data: [[27, 91, 49, 126]], keyName: Key.Home }, 31 | { data: [[27, 91, 50, 126]], keyName: Key.Insert }, 32 | { data: [[27, 91, 52, 126]], keyName: Key.End }, 33 | { data: [[27, 91, 53, 126]], keyName: Key.PageUp }, 34 | { data: [[27, 91, 54, 126]], keyName: Key.PageDown }, 35 | // functional 36 | { data: [[27, 91, 91, 65], [27, 91, 49, 49, 126]], keyName: Key.F1 }, 37 | { data: [[27, 91, 91, 66], [27, 91, 49, 50, 126]], keyName: Key.F2 }, 38 | { data: [[27, 91, 91, 67], [27, 91, 49, 51, 126]], keyName: Key.F3 }, 39 | { data: [[27, 91, 91, 68], [27, 91, 49, 52, 126]], keyName: Key.F4 }, 40 | { data: [[27, 91, 91, 69], [27, 91, 49, 53, 126]], keyName: Key.F5 }, 41 | { data: [[27, 91, 49, 55, 126]], keyName: Key.F6 }, 42 | { data: [[27, 91, 49, 56, 126]], keyName: Key.F7 }, 43 | { data: [[27, 91, 49, 57, 126]], keyName: Key.F8 }, 44 | { data: [[27, 91, 50, 48, 126]], keyName: Key.F9 }, 45 | { data: [[27, 91, 50, 49, 126]], keyName: Key.F10 }, 46 | { data: [[27, 91, 50, 51, 126]], keyName: Key.F11 }, 47 | { data: [[27, 91, 50, 52, 126]], keyName: Key.F12 } 48 | ]; 49 | 50 | export const getKeyName = (data: number[]): KeyName => { 51 | let match: IKeyNameMapNode[]; 52 | 53 | if (isSingleBytePrintableAscii(data)) { 54 | return String.fromCharCode(data[0]); 55 | } 56 | 57 | match = keyMap.filter((entry) => { 58 | const innerResult = entry.data.filter((subEntry) => subEntry.join(',') === data.join(',')); 59 | 60 | return innerResult.length > 0; 61 | }); 62 | 63 | if (match.length === 1) { 64 | return match[0].keyName; 65 | } 66 | 67 | return Key.Unknown; 68 | }; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terminal-game-io", 3 | "version": "3.1.0", 4 | "description": "It has never been easier to start writing ASCII games in NodeJs or browser. This package handles for you basic input (keyboard events) and output (ASCII 'frame').", 5 | "keywords": [ 6 | "angular", 7 | "angular6", 8 | "ascii game", 9 | "escape codes", 10 | "node escape codes", 11 | "node game", 12 | "nodejs game", 13 | "terminal game", 14 | "terminal keypress", 15 | "terminal render frame", 16 | "typescript" 17 | ], 18 | "author": "Robert Rypuła", 19 | "license": "MIT", 20 | "scripts": { 21 | "build": "npm run clean && npm run test && npm run lint && npm run lint:tsfmt:verify && webpack --env.PRODUCTION && rimraf dist/dev.d.ts", 22 | "dev-node": "npm run clean && webpack --env.DEVELOPMENT && node dist/dev.js", 23 | "dev-browser": "npm run clean && webpack-dev-server --env.DEVELOPMENT --open", 24 | "test": "jest --coverage", 25 | "test:watch": "jest --watch", 26 | "lint": "tslint -c tslint.json 'src/**/*.ts'", 27 | "lint:tsfmt:verify": "tsfmt --verify --no-tsfmt --no-tslint --no-tsconfig --no-vscode", 28 | "lint:tsfmt:replace": "tsfmt --replace --no-tsfmt --no-tslint --no-tsconfig --no-vscode", 29 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 30 | "clean": "rimraf dist && rimraf coverage", 31 | "npm-check": "npm-check --skip-unused", 32 | "npm-check:u": "npm-check --skip-unused -u" 33 | }, 34 | "main": "dist/terminal-game-io-v3.1.0.js", 35 | "types": "dist/main.d.ts", 36 | "files": [ 37 | "dist/" 38 | ], 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/robertrypula/terminal-game-io.git" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/robertrypula/terminal-game-io/issues" 45 | }, 46 | "jest": { 47 | "transform": { 48 | "^.+\\.tsx?$": "ts-jest" 49 | }, 50 | "testRegex": "(/__tests__/.*|(\\.|/)(spec))\\.(jsx?|tsx?)$", 51 | "moduleFileExtensions": [ 52 | "ts", 53 | "tsx", 54 | "js", 55 | "jsx", 56 | "json", 57 | "node" 58 | ], 59 | "testEnvironment": "node" 60 | }, 61 | "devDependencies": { 62 | "@types/jest": "^23.3.9", 63 | "@types/node": "^10.12.2", 64 | "copy-webpack-plugin": "^4.6.0", 65 | "coveralls": "^3.0.1", 66 | "html-webpack-exclude-assets-plugin": "0.0.7", 67 | "html-webpack-plugin": "^3.2.0", 68 | "jest": "^23.6.0", 69 | "npm-check": "^5.9.0", 70 | "rimraf": "^2.6.2", 71 | "ts-jest": "^23.10.4", 72 | "ts-loader": "^5.3.0", 73 | "tslint": "^5.10.0", 74 | "typescript": "^3.1.6", 75 | "typescript-formatter": "^7.2.0", 76 | "uglifyjs-webpack-plugin": "^2.0.1", 77 | "webpack": "^4.24.0", 78 | "webpack-cli": "^3.1.2", 79 | "webpack-dev-server": "^3.1.10" 80 | }, 81 | "dependencies": {} 82 | } 83 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const HtmlWebpackExcludeAssetsPlugin = require('html-webpack-exclude-assets-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const { readFileSync } = require('fs'); 7 | const packageJson = require('./package.json'); 8 | const packageName = packageJson.name; 9 | const libraryName = getLibraryName(packageName); 10 | const versionFileContent = readFileSync(path.resolve(__dirname) + '/src/lib/version.ts', 'utf8'); 11 | const version = getVersion(versionFileContent); 12 | 13 | function getLibraryName(packageName) { 14 | return packageName 15 | .toLowerCase() 16 | .split('-') 17 | .map(chunk => chunk.charAt(0).toUpperCase() + chunk.slice(1)) 18 | .join(''); 19 | } 20 | 21 | function getVersion(versionFileContent) { 22 | const patternStart = '= \''; 23 | 24 | return versionFileContent.substring( 25 | versionFileContent.indexOf(patternStart) + patternStart.length, 26 | versionFileContent.indexOf('\';') 27 | ); 28 | } 29 | 30 | function getConfig(env) { 31 | return { 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.tsx?$/, 36 | use: 'ts-loader', 37 | exclude: /node_modules/ 38 | } 39 | ] 40 | }, 41 | resolve: { 42 | extensions: ['.ts', '.js'] 43 | }, 44 | target: 'web', 45 | output: { 46 | filename: '[name].js', 47 | library: libraryName, 48 | libraryTarget: 'umd', 49 | path: path.resolve(__dirname, 'dist'), 50 | globalObject: 'this' 51 | }, 52 | plugins: [ 53 | new HtmlWebpackPlugin({ 54 | filename: 'demo-browser.html', 55 | hash: true, 56 | minify: false, 57 | template: './src/templates/demo-browser.html', 58 | excludeAssets: [/^dev.*.js/] 59 | }), 60 | new HtmlWebpackExcludeAssetsPlugin(), // https://stackoverflow.com/a/50830422 61 | new webpack.DefinePlugin({ 62 | DEVELOPMENT: JSON.stringify(env.DEVELOPMENT === true), 63 | PRODUCTION: JSON.stringify(env.PRODUCTION === true) 64 | }) 65 | ] 66 | }; 67 | } 68 | 69 | function fillDev(config) { 70 | config.mode = 'development'; 71 | config.entry = { 72 | [`${packageName}-v${version}`]: './src/main.ts', 73 | [`dev`]: './src/dev.ts' 74 | }; 75 | 76 | config.devtool = 'inline-source-map'; 77 | 78 | config.devServer = { 79 | contentBase: path.resolve(__dirname), 80 | publicPath: '/dist/', 81 | compress: true, 82 | port: 8000, 83 | hot: false, 84 | openPage: 'dist/demo-browser.html', 85 | overlay: { 86 | warnings: true, 87 | errors: true 88 | } 89 | }; 90 | } 91 | 92 | function fillProd(config) { 93 | config.mode = 'production'; 94 | config.entry = { 95 | [`${packageName}-v${version}`]: './src/main.ts' 96 | }; 97 | 98 | config.devtool = 'source-map'; 99 | 100 | config.plugins.push( 101 | new CopyWebpackPlugin( 102 | [ 103 | { 104 | from: path.resolve(__dirname) + '/src/templates/demo-node.js', 105 | to: path.resolve(__dirname) + '/dist/demo-node.js', 106 | toType: 'file' 107 | } 108 | ] 109 | ) 110 | ); 111 | } 112 | 113 | module.exports = (env) => { 114 | const config = getConfig(env); 115 | 116 | if (env.DEVELOPMENT === true) { 117 | fillDev(config); 118 | } else if (env.PRODUCTION === true) { 119 | fillProd(config); 120 | } else { 121 | throw 'Please set the environment!'; 122 | } 123 | 124 | return config; 125 | }; 126 | -------------------------------------------------------------------------------- /src/templates/demo-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |