const formatMessage = require('format-message'); const ArgumentType = require('../../extension-support/argument-type'); const BlockType = require('../../extension-support/block-type'); const Cast = require('../../util/cast'); /** * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. * @type {string} */ // eslint-disable-next-line max-len const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHN0eWxlPi5zdDJ7ZmlsbDpyZWR9LnN0M3tmaWxsOiNlMGUwZTB9LnN0NHtmaWxsOm5vbmU7c3Ryb2tlOiM2NjY7c3Ryb2tlLXdpZHRoOi41O3N0cm9rZS1taXRlcmxpbWl0OjEwfTwvc3R5bGU+PHBhdGggZD0iTTM1IDI4SDVhMSAxIDAgMCAxLTEtMVYxMmMwLS42LjQtMSAxLTFoMzBjLjUgMCAxIC40IDEgMXYxNWMwIC41LS41IDEtMSAxeiIgZmlsbD0iI2ZmZiIgaWQ9IkxheWVyXzYiLz48ZyBpZD0iTGF5ZXJfNCI+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQgMjVoMzJ2Mi43SDR6TTEzIDI0aC0yLjJhMSAxIDAgMCAxLTEtMXYtOS43YzAtLjYuNC0xIDEtMUgxM2MuNiAwIDEgLjQgMSAxVjIzYzAgLjYtLjUgMS0xIDF6Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTYuMSAxOS4zdi0yLjJjMC0uNS40LTEgMS0xaDkuN2MuNSAwIDEgLjUgMSAxdjIuMmMwIC41LS41IDEtMSAxSDcuMWExIDEgMCAwIDEtMS0xeiIvPjxjaXJjbGUgY2xhc3M9InN0MiIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIzLjQiLz48Y2lyY2xlIGNsYXNzPSJzdDIiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMy40Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQuMiAyN2gzMS45di43SDQuMnoiLz48L2c+PGcgaWQ9IkxheWVyXzUiPjxjaXJjbGUgY2xhc3M9InN0MyIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIyLjMiLz48Y2lyY2xlIGNsYXNzPSJzdDMiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMi4zIi8+PHBhdGggY2xhc3M9InN0MyIgZD0iTTEyLjUgMjIuOWgtMS4yYy0uMyAwLS41LS4yLS41LS41VjE0YzAtLjMuMi0uNS41LS41aDEuMmMuMyAwIC41LjIuNS41djguNGMwIC4zLS4yLjUtLjUuNXoiLz48cGF0aCBjbGFzcz0ic3QzIiBkPSJNNy4yIDE4Ljd2LTEuMmMwLS4zLjItLjUuNS0uNWg4LjRjLjMgMCAuNS4yLjUuNXYxLjJjMCAuMy0uMi41LS41LjVINy43Yy0uMyAwLS41LS4yLS41LS41ek00IDI2aDMydjJINHoiLz48L2c+PGcgaWQ9IkxheWVyXzMiPjxwYXRoIGNsYXNzPSJzdDQiIGQ9Ik0zNS4yIDI3LjlINC44YTEgMSAwIDAgMS0xLTFWMTIuMWMwLS42LjUtMSAxLTFoMzAuNWMuNSAwIDEgLjQgMSAxVjI3YTEgMSAwIDAgMS0xLjEuOXoiLz48cGF0aCBjbGFzcz0ic3Q0IiBkPSJNMzUuMiAyNy45SDQuOGExIDEgMCAwIDEtMS0xVjEyLjFjMC0uNi41LTEgMS0xaDMwLjVjLjUgMCAxIC40IDEgMVYyN2ExIDEgMCAwIDEtMS4xLjl6Ii8+PC9nPjwvc3ZnPg=='; /** * Length of the buffer to store key presses for the "when keys pressed in order" hat * @type {number} */ const KEY_BUFFER_LENGTH = 100; /** * Timeout in milliseconds to reset the completed flag for a sequence. * @type {number} */ const SEQUENCE_HAT_TIMEOUT = 100; /** * An id for the space key on a keyboard. */ const KEY_ID_SPACE = 'SPACE'; /** * An id for the left arrow key on a keyboard. */ const KEY_ID_LEFT = 'LEFT'; /** * An id for the right arrow key on a keyboard. */ const KEY_ID_RIGHT = 'RIGHT'; /** * An id for the up arrow key on a keyboard. */ const KEY_ID_UP = 'UP'; /** * An id for the down arrow key on a keyboard. */ const KEY_ID_DOWN = 'DOWN'; /** * Names used by keyboard io for keys used in scratch. * @enum {string} */ const SCRATCH_KEY_NAME = { [KEY_ID_SPACE]: 'space', [KEY_ID_LEFT]: 'left arrow', [KEY_ID_UP]: 'up arrow', [KEY_ID_RIGHT]: 'right arrow', [KEY_ID_DOWN]: 'down arrow' }; /** * Class for the makey makey blocks in Scratch 3.0 * @constructor */ class Scratch3MakeyMakeyBlocks { constructor (runtime) { /** * The runtime instantiating this block package. * @type {Runtime} */ this.runtime = runtime; /** * A toggle that alternates true and false each frame, so that an * edge-triggered hat can trigger on every other frame. * @type {boolean} */ this.frameToggle = false; // Set an interval that toggles the frameToggle every frame. setInterval(() => { this.frameToggle = !this.frameToggle; }, this.runtime.currentStepTime); this.keyPressed = this.keyPressed.bind(this); this.runtime.on('KEY_PRESSED', this.keyPressed); this._clearkeyPressBuffer = this._clearkeyPressBuffer.bind(this); this.runtime.on('PROJECT_STOP_ALL', this._clearkeyPressBuffer); /* * An object containing a set of sequence objects. * These are the key sequences currently being detected by the "when * keys pressed in order" hat block. Each sequence is keyed by its * string representation (the sequence's value in the menu, which is a * string of KEY_IDs separated by spaces). Each sequence object * has an array property (an array of KEY_IDs) and a boolean * completed property that is true when the sequence has just been * pressed. * @type {object} */ this.sequences = {}; /* * An array of the key codes of recently pressed keys. * @type {array} */ this.keyPressBuffer = []; } /* * Localized short-form names of the space bar and arrow keys, for use in the * displayed menu items of the "when keys pressed in order" block. * @type {object} */ get KEY_TEXT_SHORT () { return { [KEY_ID_SPACE]: formatMessage({ id: 'makeymakey.spaceKey', default: 'space', description: 'The space key on a computer keyboard.' }), [KEY_ID_LEFT]: formatMessage({ id: 'makeymakey.leftArrowShort', default: 'left', description: 'Short name for the left arrow key on a computer keyboard.' }), [KEY_ID_UP]: formatMessage({ id: 'makeymakey.upArrowShort', default: 'up', description: 'Short name for the up arrow key on a computer keyboard.' }), [KEY_ID_RIGHT]: formatMessage({ id: 'makeymakey.rightArrowShort', default: 'right', description: 'Short name for the right arrow key on a computer keyboard.' }), [KEY_ID_DOWN]: formatMessage({ id: 'makeymakey.downArrowShort', default: 'down', description: 'Short name for the down arrow key on a computer keyboard.' }) }; } /* * An array of strings of KEY_IDs representing the default set of * key sequences for use by the "when keys pressed in order" block. * @type {array} */ get DEFAULT_SEQUENCES () { return [ `${KEY_ID_LEFT} ${KEY_ID_UP} ${KEY_ID_RIGHT}`, `${KEY_ID_RIGHT} ${KEY_ID_UP} ${KEY_ID_LEFT}`, `${KEY_ID_LEFT} ${KEY_ID_RIGHT}`, `${KEY_ID_RIGHT} ${KEY_ID_LEFT}`, `${KEY_ID_UP} ${KEY_ID_DOWN}`, `${KEY_ID_DOWN} ${KEY_ID_UP}`, `${KEY_ID_UP} ${KEY_ID_RIGHT} ${KEY_ID_DOWN} ${KEY_ID_LEFT}`, `${KEY_ID_UP} ${KEY_ID_LEFT} ${KEY_ID_DOWN} ${KEY_ID_RIGHT}`, `${KEY_ID_UP} ${KEY_ID_UP} ${KEY_ID_DOWN} ${KEY_ID_DOWN} ` + `${KEY_ID_LEFT} ${KEY_ID_RIGHT} ${KEY_ID_LEFT} ${KEY_ID_RIGHT}` ]; } /** * @returns {object} metadata for this extension and its blocks. */ getInfo () { return { id: 'makeymakey', name: 'Makey Makey', blockIconURI: blockIconURI, blocks: [ { opcode: 'whenMakeyKeyPressed', text: formatMessage({ id: 'makeymakey.whenKeyPressed', default: 'when [KEY] key pressed', description: 'when a keyboard key is pressed' }), blockType: BlockType.HAT, arguments: { KEY: { type: ArgumentType.STRING, menu: 'KEY', defaultValue: KEY_ID_SPACE } } }, { opcode: 'whenCodePressed', text: formatMessage({ id: 'makeymakey.whenKeysPressedInOrder', default: 'when [SEQUENCE] pressed in order', description: 'when a sequence of keyboard keys is pressed in a specific order' }), blockType: BlockType.HAT, arguments: { SEQUENCE: { type: ArgumentType.STRING, menu: 'SEQUENCE', defaultValue: this.DEFAULT_SEQUENCES[0] } } } ], menus: { KEY: [ { text: formatMessage({ id: 'makeymakey.spaceKey', default: 'space', description: 'The space key on a computer keyboard.' }), value: KEY_ID_SPACE }, { text: formatMessage({ id: 'makeymakey.upArrow', default: 'up arrow', description: 'The up arrow key on a computer keyboard.' }), value: KEY_ID_UP }, { text: formatMessage({ id: 'makeymakey.downArrow', default: 'down arrow', description: 'The down arrow key on a computer keyboard.' }), value: KEY_ID_DOWN }, { text: formatMessage({ id: 'makeymakey.rightArrow', default: 'right arrow', description: 'The right arrow key on a computer keyboard.' }), value: KEY_ID_RIGHT }, { text: formatMessage({ id: 'makeymakey.leftArrow', default: 'left arrow', description: 'The left arrow key on a computer keyboard.' }), value: KEY_ID_LEFT }, {text: 'w', value: 'w'}, {text: 'a', value: 'a'}, {text: 's', value: 's'}, {text: 'd', value: 'd'}, {text: 'f', value: 'f'}, {text: 'g', value: 'g'} ], SEQUENCE: this.buildSequenceMenu(this.DEFAULT_SEQUENCES) } }; } /* * Build the menu of key sequences. * @param {array} sequencesArray an array of strings of KEY_IDs. * @returns {array} an array of objects with text and value properties. */ buildSequenceMenu (sequencesArray) { return sequencesArray.map( str => this.getMenuItemForSequenceString(str) ); } /* * Create a menu item for a sequence string. * @param {string} sequenceString a string of KEY_IDs. * @return {object} an object with text and value properties. */ getMenuItemForSequenceString (sequenceString) { let sequenceArray = sequenceString.split(' '); sequenceArray = sequenceArray.map(str => this.KEY_TEXT_SHORT[str]); return { text: sequenceArray.join(' '), value: sequenceString }; } /* * Check whether a keyboard key is currently pressed. * Also, toggle the results of the test on alternate frames, so that the * hat block fires repeatedly. * @param {object} args - the block arguments. * @property {number} KEY - a key code. * @param {object} util - utility object provided by the runtime. */ whenMakeyKeyPressed (args, util) { let key = args.KEY; // Convert the key arg, if it is a KEY_ID, to the key name used by // the Keyboard io module. if (SCRATCH_KEY_NAME[args.KEY]) { key = SCRATCH_KEY_NAME[args.KEY]; } const isDown = util.ioQuery('keyboard', 'getKeyIsDown', [key]); return (isDown && this.frameToggle); } /* * A function called on the KEY_PRESSED event, to update the key press * buffer and check if any of the key sequences have been completed. * @param {string} key A scratch key name. */ keyPressed (key) { // Store only the first word of the Scratch key name, so that e.g. when // "left arrow" is pressed, we store "LEFT", which matches KEY_ID_LEFT key = key.split(' ')[0]; key = key.toUpperCase(); this.keyPressBuffer.push(key); // Keep the buffer under the length limit if (this.keyPressBuffer.length > KEY_BUFFER_LENGTH) { this.keyPressBuffer.shift(); } // Check the buffer for each sequence in use for (const str in this.sequences) { const arr = this.sequences[str].array; // Bail out if we don't have enough presses for this sequence if (this.keyPressBuffer.length < arr.length) { continue; } let missFlag = false; // Slice the buffer to the length of the sequence we're checking const bufferSegment = this.keyPressBuffer.slice(-1 * arr.length); for (let i = 0; i < arr.length; i++) { if (arr[i] !== bufferSegment[i]) { missFlag = true; } } // If the miss flag is false, the sequence matched the buffer if (!missFlag) { this.sequences[str].completed = true; // Clear the completed flag after a timeout. This is necessary because // the hat is edge-triggered (not event triggered). Multiple hats // may be checking the same sequence, so this timeout gives them enough // time to all trigger before resetting the flag. setTimeout(() => { this.sequences[str].completed = false; }, SEQUENCE_HAT_TIMEOUT); } } } /** * Clear the key press buffer. */ _clearkeyPressBuffer () { this.keyPressBuffer = []; } /* * Add a key sequence to the set currently being checked on each key press. * @param {string} sequenceString a string of space-separated KEY_IDs. * @param {array} sequenceArray an array of KEY_IDs. */ addSequence (sequenceString, sequenceArray) { // If we already have this sequence string, return. if (this.sequences.hasOwnProperty(sequenceString)) { return; } this.sequences[sequenceString] = { array: sequenceArray, completed: false }; } /* * Check whether a key sequence was recently completed. * @param {object} args The block arguments. * @property {number} SEQUENCE A string of KEY_IDs. */ whenCodePressed (args) { const sequenceString = Cast.toString(args.SEQUENCE).toUpperCase(); const sequenceArray = sequenceString.split(' '); if (sequenceArray.length < 2) { return; } this.addSequence(sequenceString, sequenceArray); return this.sequences[sequenceString].completed; } } module.exports = Scratch3MakeyMakeyBlocks;