const Cast = require('../util/cast'); /** * Names used internally for keys used in scratch, also known as "scratch keys". * @enum {string} */ const KEY_NAME = { SPACE: 'space', LEFT: 'left arrow', UP: 'up arrow', RIGHT: 'right arrow', DOWN: 'down arrow', ENTER: 'enter', // tw: extra keys BACKSPACE: 'backspace', DELETE: 'delete', SHIFT: 'shift', CAPS_LOCK: 'caps lock', SCROLL_LOCK: 'scroll lock', CONTROL: 'control', ESCAPE: 'escape', INSERT: 'insert', HOME: 'home', END: 'end', PAGE_UP: 'page up', PAGE_DOWN: 'page down' }; /** * An set of the names of scratch keys. * @type {Set} */ const KEY_NAME_SET = new Set(Object.values(KEY_NAME)); class Keyboard { constructor (runtime) { /** * List of currently pressed scratch keys. * A scratch key is: * A key you can press on a keyboard, excluding modifier keys. * An uppercase string of length one; * except for special key names for arrow keys and space (e.g. 'left arrow'). * Can be a non-english unicode letter like: æ ø ש נ 手 廿. * @type{Array.} */ this._keysPressed = []; // pm: keep track of hit keys this._keysHit = []; this._keysHitOnStep = {}; // key: the key pressed, value: the step they were pressed on // pm: keep track of how long keys have been pressed for this._keyTimestamps = {}; /** * Reference to the owning Runtime. * Can be used, for example, to activate hats. * @type{!Runtime} */ this.runtime = runtime; // tw: track last pressed key this.lastKeyPressed = ''; this._numeralKeyCodesToStringKey = new Map(); // after processing all blocks, we can check if this step is after any keys we pressed this.runtime.on("RUNTIME_STEP_END", () => { const newHitKeys = []; for (const key of this._keysHit) { const stepKeyPressedOn = this._keysHitOnStep[key] || -1; if (this.runtime.frameLoop._stepCounter <= stepKeyPressedOn) { newHitKeys.push(key); } } // replace with the keys that are now pressed this._keysHit = newHitKeys; }); } /** * Convert from a keyboard event key name to a Scratch key name. * @param {string} keyString the input key string. * @return {string} the corresponding Scratch key, or an empty string. */ _keyStringToScratchKey (keyString) { keyString = Cast.toString(keyString); // Convert space and arrow keys to their Scratch key names. switch (keyString) { case ' ': return KEY_NAME.SPACE; case 'ArrowLeft': case 'Left': return KEY_NAME.LEFT; case 'ArrowUp': case 'Up': return KEY_NAME.UP; case 'Right': case 'ArrowRight': return KEY_NAME.RIGHT; case 'Down': case 'ArrowDown': return KEY_NAME.DOWN; case 'Enter': return KEY_NAME.ENTER; // tw: extra keys case 'Backspace': return KEY_NAME.BACKSPACE; case 'Delete': return KEY_NAME.DELETE; case 'Shift': return KEY_NAME.SHIFT; case 'CapsLock': return KEY_NAME.CAPS_LOCK; case 'ScrollLock': return KEY_NAME.SCROLL_LOCK; case 'Control': return KEY_NAME.CONTROL; case 'Escape': return KEY_NAME.ESCAPE; case 'Insert': return KEY_NAME.INSERT; case 'Home': return KEY_NAME.HOME; case 'End': return KEY_NAME.END; case 'PageUp': return KEY_NAME.PAGE_UP; case 'PageDown': return KEY_NAME.PAGE_DOWN; } // Ignore modifier keys if (keyString.length > 1) { return ''; } // tw: toUpperCase() happens later. We need to track key case. return keyString; } /** * Convert from a block argument to a Scratch key name. * @param {string} keyArg the input arg. * @return {string} the corresponding Scratch key. */ _keyArgToScratchKey (keyArg) { // If a number was dropped in, try to convert from ASCII to Scratch key. if (typeof keyArg === 'number') { // Check for the ASCII range containing numbers, some punctuation, // and uppercase letters. if (keyArg >= 48 && keyArg <= 90) { return String.fromCharCode(keyArg); } switch (keyArg) { case 32: return KEY_NAME.SPACE; case 37: return KEY_NAME.LEFT; case 38: return KEY_NAME.UP; case 39: return KEY_NAME.RIGHT; case 40: return KEY_NAME.DOWN; } } keyArg = Cast.toString(keyArg); // If the arg matches a special key name, return it. // No special keys have a name that is only 1 character long, so we can avoid the lookup // entirely in the most common case. if (keyArg.length > 1 && KEY_NAME_SET.has(keyArg)) { return keyArg; } // Use only the first character. if (keyArg.length > 1) { keyArg = keyArg[0]; } // Check for the space character. if (keyArg === ' ') { return KEY_NAME.SPACE; } // tw: support Scratch 2 hacked blocks // There are more hacked blocks but most of them get mangled by Scratch 2 -> Scratch 3 conversion if (keyArg === '\r') { // this probably belongs upstream return KEY_NAME.ENTER; } if (keyArg === '\u001b') { return KEY_NAME.ESCAPE; } return keyArg.toUpperCase(); } /** * Keyboard DOM event handler. * @param {object} data Data from DOM event. */ postData (data) { if (!data.key) return; // tw: convert single letter keys to uppercase because of changes in _keyStringToScratchKey const scratchKeyCased = this._keyStringToScratchKey(data.key); const scratchKey = scratchKeyCased.length === 1 ? scratchKeyCased.toUpperCase() : scratchKeyCased; if (scratchKey === '') return; const index = this._keysPressed.indexOf(scratchKey); if (data.isDown) { // tw: track last pressed key this.lastKeyPressed = scratchKeyCased; this.runtime.emit('KEY_PRESSED', scratchKey); // If not already present, add to the list. if (index < 0) { // pm: key isnt present? we hit it for the first time this.runtime.emit('KEY_HIT', scratchKey); this._keysPressed.push(scratchKey); this._keyTimestamps[scratchKey] = Date.now(); // pm: keep track of hit keys this._keysHit.push(scratchKey); this._keysHitOnStep[scratchKey] = this.runtime.frameLoop._stepCounter; } } else if (index > -1) { // If already present, remove from the list. this._keysPressed.splice(index, 1); if (scratchKey in this._keyTimestamps) { delete this._keyTimestamps[scratchKey]; } } // Fix for https://github.com/LLK/scratch-vm/issues/2271 if (data.hasOwnProperty('keyCode')) { const keyCode = data.keyCode; if (this._numeralKeyCodesToStringKey.has(keyCode)) { const lastKeyOfSameCode = this._numeralKeyCodesToStringKey.get(keyCode); if (lastKeyOfSameCode !== scratchKey) { const indexToUnpress = this._keysPressed.indexOf(lastKeyOfSameCode); if (indexToUnpress !== -1) { this._keysPressed.splice(indexToUnpress, 1); if (scratchKey in this._keyTimestamps) { delete this._keyTimestamps[lastKeyOfSameCode]; } } } } this._numeralKeyCodesToStringKey.set(keyCode, scratchKey); } } /** * Get key down state for a specified key. * @param {Any} keyArg key argument. * @return {boolean} Is the specified key down? */ getKeyIsDown (keyArg) { if (keyArg === 'any') { return this._keysPressed.length > 0; } const scratchKey = this._keyArgToScratchKey(keyArg); return this._keysPressed.indexOf(scratchKey) > -1; } /** * pm: Get if key was hit this tick for a specified key. * @param {Any} keyArg key argument. * @return {boolean} Is the specified key hit? */ getKeyIsHit (keyArg) { if (keyArg === 'any') { return this._keysHit.length > 0; } const scratchKey = this._keyArgToScratchKey(keyArg); return this._keysHit.indexOf(scratchKey) > -1; } // tw: expose last pressed key getLastKeyPressed () { return this.lastKeyPressed; } // pm: why dont we expose all keys? getAllKeysPressed () { return this._keysPressed; } getKeyTimestamp (keyArg) { if (keyArg === 'any') { // loop through all keys and see which one we have held the longest let oldestTimestamp = Infinity; let found = false; for (const keyName in this._keyTimestamps) { const timestamp = this._keyTimestamps[keyName]; if (timestamp < oldestTimestamp) { oldestTimestamp = timestamp; found = true; } } if (!found) return 0; return oldestTimestamp; } // everything else const scratchKey = this._keyArgToScratchKey(keyArg); if (!(scratchKey in this._keyTimestamps)) { return 0; } return this._keyTimestamps[scratchKey]; } getKeyTimestamps () { return this._keyTimestamps; } } module.exports = Keyboard;