|
| 1 | +const formatMessage = require('format-message'); |
| 2 | +const ArgumentType = require('../../extension-support/argument-type'); |
| 3 | +const BlockType = require('../../extension-support/block-type'); |
| 4 | +const Cast = require('../../util/cast'); |
| 5 | + |
| 6 | +/** |
| 7 | + * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. |
| 8 | + * @type {string} |
| 9 | + */ |
| 10 | +// eslint-disable-next-line max-len |
| 11 | +const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHN0eWxlPi5zdDJ7ZmlsbDpyZWR9LnN0M3tmaWxsOiNlMGUwZTB9LnN0NHtmaWxsOm5vbmU7c3Ryb2tlOiM2NjY7c3Ryb2tlLXdpZHRoOi41O3N0cm9rZS1taXRlcmxpbWl0OjEwfTwvc3R5bGU+PHBhdGggZD0iTTM1IDI4SDVhMSAxIDAgMCAxLTEtMVYxMmMwLS42LjQtMSAxLTFoMzBjLjUgMCAxIC40IDEgMXYxNWMwIC41LS41IDEtMSAxeiIgZmlsbD0iI2ZmZiIgaWQ9IkxheWVyXzYiLz48ZyBpZD0iTGF5ZXJfNCI+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQgMjVoMzJ2Mi43SDR6TTEzIDI0aC0yLjJhMSAxIDAgMCAxLTEtMXYtOS43YzAtLjYuNC0xIDEtMUgxM2MuNiAwIDEgLjQgMSAxVjIzYzAgLjYtLjUgMS0xIDF6Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTYuMSAxOS4zdi0yLjJjMC0uNS40LTEgMS0xaDkuN2MuNSAwIDEgLjUgMSAxdjIuMmMwIC41LS41IDEtMSAxSDcuMWExIDEgMCAwIDEtMS0xeiIvPjxjaXJjbGUgY2xhc3M9InN0MiIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIzLjQiLz48Y2lyY2xlIGNsYXNzPSJzdDIiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMy40Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQuMiAyN2gzMS45di43SDQuMnoiLz48L2c+PGcgaWQ9IkxheWVyXzUiPjxjaXJjbGUgY2xhc3M9InN0MyIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIyLjMiLz48Y2lyY2xlIGNsYXNzPSJzdDMiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMi4zIi8+PHBhdGggY2xhc3M9InN0MyIgZD0iTTEyLjUgMjIuOWgtMS4yYy0uMyAwLS41LS4yLS41LS41VjE0YzAtLjMuMi0uNS41LS41aDEuMmMuMyAwIC41LjIuNS41djguNGMwIC4zLS4yLjUtLjUuNXoiLz48cGF0aCBjbGFzcz0ic3QzIiBkPSJNNy4yIDE4Ljd2LTEuMmMwLS4zLjItLjUuNS0uNWg4LjRjLjMgMCAuNS4yLjUuNXYxLjJjMCAuMy0uMi41LS41LjVINy43Yy0uMyAwLS41LS4yLS41LS41ek00IDI2aDMydjJINHoiLz48L2c+PGcgaWQ9IkxheWVyXzMiPjxwYXRoIGNsYXNzPSJzdDQiIGQ9Ik0zNS4yIDI3LjlINC44YTEgMSAwIDAgMS0xLTFWMTIuMWMwLS42LjUtMSAxLTFoMzAuNWMuNSAwIDEgLjQgMSAxVjI3YTEgMSAwIDAgMS0xLjEuOXoiLz48cGF0aCBjbGFzcz0ic3Q0IiBkPSJNMzUuMiAyNy45SDQuOGExIDEgMCAwIDEtMS0xVjEyLjFjMC0uNi41LTEgMS0xaDMwLjVjLjUgMCAxIC40IDEgMVYyN2ExIDEgMCAwIDEtMS4xLjl6Ii8+PC9nPjwvc3ZnPg=='; |
| 12 | + |
| 13 | +/** |
| 14 | + * Length of the buffer to store key presses for the "when keys pressed in order" hat |
| 15 | + * @type {number} |
| 16 | + */ |
| 17 | +const KEY_BUFFER_LENGTH = 100; |
| 18 | + |
| 19 | +/** |
| 20 | + * Timeout in milliseconds to reset the completed flag for a sequence. |
| 21 | + * @type {number} |
| 22 | + */ |
| 23 | +const SEQUENCE_HAT_TIMEOUT = 100; |
| 24 | + |
| 25 | +/** |
| 26 | + * An id for the space key on a keyboard. |
| 27 | + */ |
| 28 | +const KEY_ID_SPACE = 'SPACE'; |
| 29 | + |
| 30 | +/** |
| 31 | + * An id for the left arrow key on a keyboard. |
| 32 | + */ |
| 33 | +const KEY_ID_LEFT = 'LEFT'; |
| 34 | + |
| 35 | +/** |
| 36 | + * An id for the right arrow key on a keyboard. |
| 37 | + */ |
| 38 | +const KEY_ID_RIGHT = 'RIGHT'; |
| 39 | + |
| 40 | +/** |
| 41 | + * An id for the up arrow key on a keyboard. |
| 42 | + */ |
| 43 | +const KEY_ID_UP = 'UP'; |
| 44 | + |
| 45 | +/** |
| 46 | + * An id for the down arrow key on a keyboard. |
| 47 | + */ |
| 48 | +const KEY_ID_DOWN = 'DOWN'; |
| 49 | + |
| 50 | +/** |
| 51 | + * Names used by keyboard io for keys used in scratch. |
| 52 | + * @enum {string} |
| 53 | + */ |
| 54 | +const SCRATCH_KEY_NAME = { |
| 55 | + [KEY_ID_SPACE]: 'space', |
| 56 | + [KEY_ID_LEFT]: 'left arrow', |
| 57 | + [KEY_ID_UP]: 'up arrow', |
| 58 | + [KEY_ID_RIGHT]: 'right arrow', |
| 59 | + [KEY_ID_DOWN]: 'down arrow' |
| 60 | +}; |
| 61 | + |
| 62 | +/** |
| 63 | + * Class for the makey makey blocks in Scratch 3.0 |
| 64 | + * @constructor |
| 65 | + */ |
| 66 | +class Scratch3MakeyMakeyBlocks { |
| 67 | + constructor (runtime) { |
| 68 | + /** |
| 69 | + * The runtime instantiating this block package. |
| 70 | + * @type {Runtime} |
| 71 | + */ |
| 72 | + this.runtime = runtime; |
| 73 | + |
| 74 | + /** |
| 75 | + * A toggle that alternates true and false each frame, so that an |
| 76 | + * edge-triggered hat can trigger on every other frame. |
| 77 | + * @type {boolean} |
| 78 | + */ |
| 79 | + this.frameToggle = false; |
| 80 | + |
| 81 | + // Set an interval that toggles the frameToggle every frame. |
| 82 | + setInterval(() => { |
| 83 | + this.frameToggle = !this.frameToggle; |
| 84 | + }, this.runtime.currentStepTime); |
| 85 | + |
| 86 | + this.keyPressed = this.keyPressed.bind(this); |
| 87 | + this.runtime.on('KEY_PRESSED', this.keyPressed); |
| 88 | + |
| 89 | + /* |
| 90 | + * An object containing a set of sequence objects. |
| 91 | + * These are the key sequences currently being detected by the "when |
| 92 | + * keys pressed in order" hat block. Each sequence is keyed by its |
| 93 | + * string representation (the sequence's value in the menu, which is a |
| 94 | + * string of KEY_IDs separated by spaces). Each sequence object |
| 95 | + * has an array property (an array of KEY_IDs) and a boolean |
| 96 | + * completed property that is true when the sequence has just been |
| 97 | + * pressed. |
| 98 | + * @type {object} |
| 99 | + */ |
| 100 | + this.sequences = {}; |
| 101 | + |
| 102 | + /* |
| 103 | + * An array of the key codes of recently pressed keys. |
| 104 | + * @type {array} |
| 105 | + */ |
| 106 | + this.keyPressBuffer = []; |
| 107 | + } |
| 108 | + |
| 109 | + /* |
| 110 | + * Localized short-form names of the space bar and arrow keys, for use in the |
| 111 | + * displayed menu items of the "when keys pressed in order" block. |
| 112 | + * @type {object} |
| 113 | + */ |
| 114 | + get KEY_TEXT_SHORT () { |
| 115 | + return { |
| 116 | + [KEY_ID_SPACE]: formatMessage({ |
| 117 | + id: 'makeymakey.spaceKey', |
| 118 | + default: 'space', |
| 119 | + description: 'The space key on a computer keyboard.' |
| 120 | + }), |
| 121 | + [KEY_ID_LEFT]: formatMessage({ |
| 122 | + id: 'makeymakey.leftArrowShort', |
| 123 | + default: 'left', |
| 124 | + description: 'Short name for the left arrow key on a computer keyboard.' |
| 125 | + }), |
| 126 | + [KEY_ID_UP]: formatMessage({ |
| 127 | + id: 'makeymakey.upArrowShort', |
| 128 | + default: 'up', |
| 129 | + description: 'Short name for the up arrow key on a computer keyboard.' |
| 130 | + }), |
| 131 | + [KEY_ID_RIGHT]: formatMessage({ |
| 132 | + id: 'makeymakey.rightArrowShort', |
| 133 | + default: 'right', |
| 134 | + description: 'Short name for the right arrow key on a computer keyboard.' |
| 135 | + }), |
| 136 | + [KEY_ID_DOWN]: formatMessage({ |
| 137 | + id: 'makeymakey.downArrowShort', |
| 138 | + default: 'down', |
| 139 | + description: 'Short name for the down arrow key on a computer keyboard.' |
| 140 | + }) |
| 141 | + }; |
| 142 | + } |
| 143 | + |
| 144 | + /* |
| 145 | + * An array of strings of KEY_IDs representing the default set of |
| 146 | + * key sequences for use by the "when keys pressed in order" block. |
| 147 | + * @type {array} |
| 148 | + */ |
| 149 | + get DEFAULT_SEQUENCES () { |
| 150 | + return [ |
| 151 | + `${KEY_ID_LEFT} ${KEY_ID_UP} ${KEY_ID_RIGHT}`, |
| 152 | + `${KEY_ID_RIGHT} ${KEY_ID_UP} ${KEY_ID_LEFT}`, |
| 153 | + `${KEY_ID_LEFT} ${KEY_ID_RIGHT}`, |
| 154 | + `${KEY_ID_RIGHT} ${KEY_ID_LEFT}`, |
| 155 | + `${KEY_ID_UP} ${KEY_ID_DOWN}`, |
| 156 | + `${KEY_ID_DOWN} ${KEY_ID_UP}`, |
| 157 | + `${KEY_ID_UP} ${KEY_ID_RIGHT} ${KEY_ID_DOWN} ${KEY_ID_LEFT}`, |
| 158 | + `${KEY_ID_SPACE} ${KEY_ID_SPACE} ${KEY_ID_SPACE}`, |
| 159 | + `${KEY_ID_UP} ${KEY_ID_UP} ${KEY_ID_DOWN} ${KEY_ID_DOWN} ` + |
| 160 | + `${KEY_ID_LEFT} ${KEY_ID_RIGHT} ${KEY_ID_LEFT} ${KEY_ID_RIGHT}` |
| 161 | + ]; |
| 162 | + } |
| 163 | + |
| 164 | + /** |
| 165 | + * @returns {object} metadata for this extension and its blocks. |
| 166 | + */ |
| 167 | + getInfo () { |
| 168 | + return { |
| 169 | + id: 'makeymakey', |
| 170 | + name: 'Makey Makey', |
| 171 | + blockIconURI: blockIconURI, |
| 172 | + blocks: [ |
| 173 | + { |
| 174 | + opcode: 'whenMakeyKeyPressed', |
| 175 | + text: 'when [KEY] key pressed', |
| 176 | + blockType: BlockType.HAT, |
| 177 | + arguments: { |
| 178 | + KEY: { |
| 179 | + type: ArgumentType.STRING, |
| 180 | + menu: 'KEY', |
| 181 | + defaultValue: KEY_ID_SPACE |
| 182 | + } |
| 183 | + } |
| 184 | + }, |
| 185 | + { |
| 186 | + opcode: 'whenCodePressed', |
| 187 | + text: 'when [SEQUENCE] pressed in order', |
| 188 | + blockType: BlockType.HAT, |
| 189 | + arguments: { |
| 190 | + SEQUENCE: { |
| 191 | + type: ArgumentType.STRING, |
| 192 | + menu: 'SEQUENCE', |
| 193 | + defaultValue: this.DEFAULT_SEQUENCES[0] |
| 194 | + } |
| 195 | + } |
| 196 | + } |
| 197 | + ], |
| 198 | + menus: { |
| 199 | + KEY: [ |
| 200 | + { |
| 201 | + text: formatMessage({ |
| 202 | + id: 'makeymakey.spaceKey', |
| 203 | + default: 'space', |
| 204 | + description: 'The space key on a computer keyboard.' |
| 205 | + }), |
| 206 | + value: KEY_ID_SPACE |
| 207 | + }, |
| 208 | + { |
| 209 | + text: formatMessage({ |
| 210 | + id: 'makeymakey.leftArrow', |
| 211 | + default: 'left arrow', |
| 212 | + description: 'The left arrow key on a computer keyboard.' |
| 213 | + }), |
| 214 | + value: KEY_ID_LEFT |
| 215 | + }, |
| 216 | + { |
| 217 | + text: formatMessage({ |
| 218 | + id: 'makeymakey.rightArrow', |
| 219 | + default: 'right arrow', |
| 220 | + description: 'The right arrow key on a computer keyboard.' |
| 221 | + }), |
| 222 | + value: KEY_ID_RIGHT |
| 223 | + }, |
| 224 | + { |
| 225 | + text: formatMessage({ |
| 226 | + id: 'makeymakey.downArrow', |
| 227 | + default: 'down arrow', |
| 228 | + description: 'The down arrow key on a computer keyboard.' |
| 229 | + }), |
| 230 | + value: KEY_ID_DOWN |
| 231 | + }, |
| 232 | + { |
| 233 | + text: formatMessage({ |
| 234 | + id: 'makeymakey.upArrow', |
| 235 | + default: 'up arrow', |
| 236 | + description: 'The up arrow key on a computer keyboard.' |
| 237 | + }), |
| 238 | + value: KEY_ID_UP |
| 239 | + }, |
| 240 | + {text: 'w', value: 'w'}, |
| 241 | + {text: 'a', value: 'a'}, |
| 242 | + {text: 's', value: 's'}, |
| 243 | + {text: 'd', value: 'd'}, |
| 244 | + {text: 'f', value: 'f'}, |
| 245 | + {text: 'g', value: 'g'} |
| 246 | + ], |
| 247 | + SEQUENCE: this.buildSequenceMenu(this.DEFAULT_SEQUENCES) |
| 248 | + } |
| 249 | + }; |
| 250 | + } |
| 251 | + |
| 252 | + /* |
| 253 | + * Build the menu of key sequences. |
| 254 | + * @param {array} sequencesArray an array of strings of KEY_IDs. |
| 255 | + * @returns {array} an array of objects with text and value properties. |
| 256 | + */ |
| 257 | + buildSequenceMenu (sequencesArray) { |
| 258 | + return sequencesArray.map( |
| 259 | + str => this.getMenuItemForSequenceString(str) |
| 260 | + ); |
| 261 | + } |
| 262 | + |
| 263 | + /* |
| 264 | + * Create a menu item for a sequence string. |
| 265 | + * @param {string} sequenceString a string of KEY_IDs. |
| 266 | + * @return {object} an object with text and value properties. |
| 267 | + */ |
| 268 | + getMenuItemForSequenceString (sequenceString) { |
| 269 | + let sequenceArray = sequenceString.split(' '); |
| 270 | + sequenceArray = sequenceArray.map(str => this.KEY_TEXT_SHORT[str]); |
| 271 | + return { |
| 272 | + text: sequenceArray.join(' '), |
| 273 | + value: sequenceString |
| 274 | + }; |
| 275 | + } |
| 276 | + |
| 277 | + /* |
| 278 | + * Check whether a keyboard key is currently pressed. |
| 279 | + * Also, toggle the results of the test on alternate frames, so that the |
| 280 | + * hat block fires repeatedly. |
| 281 | + * @param {object} args - the block arguments. |
| 282 | + * @property {number} KEY - a key code. |
| 283 | + * @param {object} util - utility object provided by the runtime. |
| 284 | + */ |
| 285 | + whenMakeyKeyPressed (args, util) { |
| 286 | + let key = args.KEY; |
| 287 | + // Convert the key arg, if it is a KEY_ID, to the key name used by |
| 288 | + // the Keyboard io module. |
| 289 | + if (SCRATCH_KEY_NAME[args.KEY]) { |
| 290 | + key = SCRATCH_KEY_NAME[args.KEY]; |
| 291 | + } |
| 292 | + const isDown = util.ioQuery('keyboard', 'getKeyIsDown', [key]); |
| 293 | + return (isDown && this.frameToggle); |
| 294 | + } |
| 295 | + |
| 296 | + /* |
| 297 | + * A function called on the KEY_PRESSED event, to update the key press |
| 298 | + * buffer and check if any of the key sequences have been completed. |
| 299 | + * @param {string} key A scratch key name. |
| 300 | + */ |
| 301 | + keyPressed (key) { |
| 302 | + // Store only the first word of the Scratch key name, so that e.g. when |
| 303 | + // "left arrow" is pressed, we store "LEFT", which matches KEY_ID_LEFT |
| 304 | + key = key.split(' ')[0]; |
| 305 | + key = key.toUpperCase(); |
| 306 | + this.keyPressBuffer.push(key); |
| 307 | + // Keep the buffer under the length limit |
| 308 | + if (this.keyPressBuffer.length > KEY_BUFFER_LENGTH) { |
| 309 | + this.keyPressBuffer.shift(); |
| 310 | + } |
| 311 | + // Check the buffer for each sequence in use |
| 312 | + for (const str in this.sequences) { |
| 313 | + const arr = this.sequences[str].array; |
| 314 | + // Bail out if we don't have enough presses for this sequence |
| 315 | + if (this.keyPressBuffer.length < arr.length) { |
| 316 | + continue; |
| 317 | + } |
| 318 | + let missFlag = false; |
| 319 | + // Slice the buffer to the length of the sequence we're checking |
| 320 | + const bufferSegment = this.keyPressBuffer.slice(-1 * arr.length); |
| 321 | + for (let i = 0; i < arr.length; i++) { |
| 322 | + if (arr[i] !== bufferSegment[i]) { |
| 323 | + missFlag = true; |
| 324 | + } |
| 325 | + } |
| 326 | + // If the miss flag is false, the sequence matched the buffer |
| 327 | + if (!missFlag) { |
| 328 | + this.sequences[str].completed = true; |
| 329 | + // Clear the completed flag after a timeout. This is necessary because |
| 330 | + // the hat is edge-triggered (not event triggered). Multiple hats |
| 331 | + // may be checking the same sequence, so this timeout gives them enough |
| 332 | + // time to all trigger before resetting the flag. |
| 333 | + setTimeout(() => { |
| 334 | + this.sequences[str].completed = false; |
| 335 | + }, SEQUENCE_HAT_TIMEOUT); |
| 336 | + } |
| 337 | + } |
| 338 | + } |
| 339 | + |
| 340 | + /* |
| 341 | + * Add a key sequence to the set currently being checked on each key press. |
| 342 | + * @param {string} sequenceString a string of space-separated KEY_IDs. |
| 343 | + * @param {array} sequenceArray an array of KEY_IDs. |
| 344 | + */ |
| 345 | + addSequence (sequenceString, sequenceArray) { |
| 346 | + // If we already have this sequence string, return. |
| 347 | + if (this.sequences.hasOwnProperty(sequenceString)) { |
| 348 | + return; |
| 349 | + } |
| 350 | + this.sequences[sequenceString] = { |
| 351 | + array: sequenceArray, |
| 352 | + completed: false |
| 353 | + }; |
| 354 | + } |
| 355 | + |
| 356 | + /* |
| 357 | + * Check whether a key sequence was recently completed. |
| 358 | + * @param {object} args The block arguments. |
| 359 | + * @property {number} SEQUENCE A string of KEY_IDs. |
| 360 | + */ |
| 361 | + whenCodePressed (args) { |
| 362 | + const sequenceString = Cast.toString(args.SEQUENCE).toUpperCase(); |
| 363 | + const sequenceArray = sequenceString.split(' '); |
| 364 | + if (sequenceArray.length < 2) { |
| 365 | + return; |
| 366 | + } |
| 367 | + this.addSequence(sequenceString, sequenceArray); |
| 368 | + |
| 369 | + return this.sequences[sequenceString].completed; |
| 370 | + } |
| 371 | +} |
| 372 | +module.exports = Scratch3MakeyMakeyBlocks; |
0 commit comments