diff --git a/package-lock.json b/package-lock.json index 905020e41..bcb85600a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1089,9 +1089,9 @@ } }, "@vernier/godirect": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@vernier/godirect/-/godirect-1.4.1.tgz", - "integrity": "sha512-5RaBF0UOLJR85UWlPTUv4c7aE3OW3wtaWd6MFEEhVrHTfUuEJPq7444U8tC4hKZnrNyppV/bCN9JtSmN0OlHkg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vernier/godirect/-/godirect-1.5.0.tgz", + "integrity": "sha512-vMS0fQubI3WSSLg1Ry3aey/qWCl9XoCsFzgwOWYkeJs45YxjPel+42pLh5pO7uP6oF47sjZUKx8kGOoTiiiirA==" }, "@webassemblyjs/ast": { "version": "1.5.13", @@ -1673,14 +1673,6 @@ "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", "dev": true }, - "async": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", - "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", - "requires": { - "lodash": "^4.14.0" - } - }, "async-each": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", @@ -3228,7 +3220,8 @@ "crc32": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/crc32/-/crc32-0.2.2.tgz", - "integrity": "sha1-etIg1v/c0Rn5/BJ6d3LKzqOQpLo=" + "integrity": "sha1-etIg1v/c0Rn5/BJ6d3LKzqOQpLo=", + "dev": true }, "create-ecdh": { "version": "4.0.3", @@ -3440,11 +3433,6 @@ } } }, - "deflate-js": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/deflate-js/-/deflate-js-0.2.3.tgz", - "integrity": "sha1-+Fq7WOvFFRowYUdHPVfD5PfkQms=" - }, "del": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", @@ -6271,15 +6259,6 @@ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, - "gzip-js": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/gzip-js/-/gzip-js-0.3.2.tgz", - "integrity": "sha1-IxF+/usozzhSSN7/Df+tiUg22Ws=", - "requires": { - "crc32": ">= 0.2.2", - "deflate-js": ">= 0.2.2" - } - }, "handle-thing": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-1.2.5.tgz", @@ -7460,7 +7439,8 @@ "lodash": { "version": "4.17.4", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", + "dev": true }, "lodash._baseassign": { "version": "3.2.0", @@ -12149,9 +12129,9 @@ } }, "scratch-blocks": { - "version": "0.1.0-prerelease.1551865183", - "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1551865183.tgz", - "integrity": "sha512-jb4J40y60HyRCryvFSwW9zXsQwaM2DzP5i9KQkJem9RP8gXOZrr56rXHF9VOQ9jQ06fulG65NMTkCL+BU5ulnw==", + "version": "0.1.0-prerelease.1552662801", + "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1552662801.tgz", + "integrity": "sha512-OPTvPvzsV0ryhOGNfV9F0dopsuohIyu0SglzQw7jmEf6eeQ3yW0hOGqUeU2HR3c9F/uS3Uy7PispXhYCAZo/tg==", "dev": true, "requires": { "exports-loader": "0.6.3", @@ -12171,14 +12151,13 @@ } }, "scratch-parser": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/scratch-parser/-/scratch-parser-4.3.5.tgz", - "integrity": "sha512-jOHrR9evVnRxnIc7W+1m7S2E5yDyUCbh8xvPueT10mo7AfLprE9lRKAtc6yF3Gxj0Rm/jhyiGBn8crAPc/F4Vg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/scratch-parser/-/scratch-parser-5.0.0.tgz", + "integrity": "sha512-7kjxoxivLgYYvmAJVLOOWnca4CigwuCpgjy9+6UuxOMgSZKO1xqIjxIADupabmh1ZLZZDVe45DBM/CQTdtVDkw==", "requires": { "ajv": "6.3.0", - "async": "2.6.0", - "gzip-js": "0.3.2", - "jszip": "3.1.5" + "jszip": "3.1.5", + "pify": "4.0.1" }, "dependencies": { "ajv": { @@ -12190,6 +12169,11 @@ "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.3.0" } + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" } } }, @@ -12248,13 +12232,6 @@ "js-md5": "0.7.3", "minilog": "3.1.0", "text-encoding": "^0.7.0" - }, - "dependencies": { - "text-encoding": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", - "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==" - } } }, "scratch-storage": { @@ -12276,19 +12253,13 @@ "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.7.3.tgz", "integrity": "sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==", "dev": true - }, - "text-encoding": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", - "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==", - "dev": true } } }, "scratch-svg-renderer": { - "version": "0.2.0-prerelease.20190125192231", - "resolved": "https://registry.npmjs.org/scratch-svg-renderer/-/scratch-svg-renderer-0.2.0-prerelease.20190125192231.tgz", - "integrity": "sha512-8cOLJsN2zDT2FLcB3wLxew3tzO6fkI25uiaW0c6juQl5zJseANIvP4tc31gaeUG4xSQa1zfk/PpXKPQDGa66Tw==", + "version": "0.2.0-prerelease.20190304180800", + "resolved": "https://registry.npmjs.org/scratch-svg-renderer/-/scratch-svg-renderer-0.2.0-prerelease.20190304180800.tgz", + "integrity": "sha512-fFePDGb65g1YaN/fkBl6hfgvjxr3hE9TMWuAYU1MeK5vVeZA+HPslUyMIvsEvLV5nCqs+0Jv7ievnP39ihhHLQ==", "dev": true, "requires": { "base64-js": "1.2.1", @@ -13518,9 +13489,9 @@ "dev": true }, "text-encoding": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", - "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=" + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", + "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==" }, "text-table": { "version": "0.2.0", diff --git a/package.json b/package.json index 25084053b..7e67f95b0 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"" }, "dependencies": { - "@vernier/godirect": "1.4.1", + "@vernier/godirect": "1.5.0", "arraybuffer-loader": "^1.0.6", "atob": "2.1.2", "btoa": "1.2.1", @@ -43,11 +43,11 @@ "jszip": "^3.1.5", "minilog": "3.1.0", "nets": "3.2.0", - "scratch-parser": "4.3.5", + "scratch-parser": "5.0.0", "scratch-sb1-converter": "0.2.7", "scratch-translate-extension-languages": "0.0.20181205140428", "socket.io-client": "2.0.4", - "text-encoding": "0.6.4", + "text-encoding": "0.7.0", "worker-loader": "^1.1.1" }, "devDependencies": { diff --git a/src/blocks/scratch3_looks.js b/src/blocks/scratch3_looks.js index 94d70ea5b..a6da97f35 100644 --- a/src/blocks/scratch3_looks.js +++ b/src/blocks/scratch3_looks.js @@ -76,6 +76,14 @@ class Scratch3LooksBlocks { return {min: 0, max: 100}; } + /** + * Limit for brightness effect + * @const {object} + */ + static get EFFECT_BRIGHTNESS_LIMIT (){ + return {min: -100, max: 100}; + } + /** * @param {Target} target - collect bubble state for this target. Probably, but not necessarily, a RenderedTarget. * @returns {BubbleState} the mutable bubble state associated with that target. This will be created if necessary. @@ -484,27 +492,36 @@ class Scratch3LooksBlocks { ); } + clampEffect (effect, value) { + let clampedValue = value; + switch (effect) { + case 'ghost': + clampedValue = MathUtil.clamp(value, + Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.min, + Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.max); + break; + case 'brightness': + clampedValue = MathUtil.clamp(value, + Scratch3LooksBlocks.EFFECT_BRIGHTNESS_LIMIT.min, + Scratch3LooksBlocks.EFFECT_BRIGHTNESS_LIMIT.max); + break; + } + return clampedValue; + } + changeEffect (args, util) { const effect = Cast.toString(args.EFFECT).toLowerCase(); const change = Cast.toNumber(args.CHANGE); if (!util.target.effects.hasOwnProperty(effect)) return; let newValue = change + util.target.effects[effect]; - if (effect === 'ghost') { - newValue = MathUtil.clamp(newValue, - Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.min, - Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.max); - } + newValue = this.clampEffect(effect, newValue); util.target.setEffect(effect, newValue); } setEffect (args, util) { const effect = Cast.toString(args.EFFECT).toLowerCase(); let value = Cast.toNumber(args.VALUE); - if (effect === 'ghost') { - value = MathUtil.clamp(value, - Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.min, - Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.max); - } + value = this.clampEffect(effect, value); util.target.setEffect(effect, value); } diff --git a/src/blocks/scratch3_motion.js b/src/blocks/scratch3_motion.js index 9571e5ef1..d42dce463 100644 --- a/src/blocks/scratch3_motion.js +++ b/src/blocks/scratch3_motion.js @@ -87,6 +87,7 @@ class Scratch3MotionBlocks { targetX = Math.round(stageWidth * (Math.random() - 0.5)); targetY = Math.round(stageHeight * (Math.random() - 0.5)); } else { + targetName = Cast.toString(targetName); const goToTarget = this.runtime.getSpriteTargetByName(targetName); if (!goToTarget) return; targetX = goToTarget.x; @@ -127,6 +128,7 @@ class Scratch3MotionBlocks { util.target.setDirection(Math.round(Math.random() * 360) - 180); return; } else { + args.TOWARDS = Cast.toString(args.TOWARDS); const pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS); if (!pointTarget) return; targetX = pointTarget.x; diff --git a/src/blocks/scratch3_sensing.js b/src/blocks/scratch3_sensing.js index bd5956a60..8dbd95d6c 100644 --- a/src/blocks/scratch3_sensing.js +++ b/src/blocks/scratch3_sensing.js @@ -191,6 +191,7 @@ class Scratch3SensingBlocks { targetX = util.ioQuery('mouse', 'getScratchX'); targetY = util.ioQuery('mouse', 'getScratchY'); } else { + args.DISTANCETOMENU = Cast.toString(args.DISTANCETOMENU); const distTarget = this.runtime.getSpriteTargetByName( args.DISTANCETOMENU ); @@ -282,6 +283,7 @@ class Scratch3SensingBlocks { if (args.OBJECT === '_stage_') { attrTarget = this.runtime.getTargetForStage(); } else { + args.OBJECT = Cast.toString(args.OBJECT); attrTarget = this.runtime.getSpriteTargetByName(args.OBJECT); } diff --git a/src/engine/blocks.js b/src/engine/blocks.js index e22122edd..db0d5a5bc 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -735,6 +735,12 @@ class Blocks { if (this._blocks[e.newParent].inputs.hasOwnProperty(e.newInput)) { oldShadow = this._blocks[e.newParent].inputs[e.newInput].shadow; } + + // If the block being attached is itself a shadow, make sure to set + // both block and shadow to that blocks ID. This happens when adding + // inputs to a custom procedure. + if (this._blocks[e.id].shadow) oldShadow = e.id; + this._blocks[e.newParent].inputs[e.newInput] = { name: e.newInput, block: e.id, diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index 8305e65b2..00684a622 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -7,34 +7,22 @@ const BlockType = require('./block-type'); // These extensions are currently built into the VM repository but should not be loaded at startup. // TODO: move these out into a separate repository? // TODO: change extension spec so that library info, including extension ID, can be collected through static methods -const Scratch3PenBlocks = require('../extensions/scratch3_pen'); -const Scratch3WeDo2Blocks = require('../extensions/scratch3_wedo2'); -const Scratch3MusicBlocks = require('../extensions/scratch3_music'); -const Scratch3MicroBitBlocks = require('../extensions/scratch3_microbit'); -const Scratch3Text2SpeechBlocks = require('../extensions/scratch3_text2speech'); -const Scratch3TranslateBlocks = require('../extensions/scratch3_translate'); -const Scratch3VideoSensingBlocks = require('../extensions/scratch3_video_sensing'); -const Scratch3Speech2TextBlocks = require('../extensions/scratch3_speech2text'); -const Scratch3Ev3Blocks = require('../extensions/scratch3_ev3'); -const Scratch3MakeyMakeyBlocks = require('../extensions/scratch3_makeymakey'); -const Scratch3BoostBlocks = require('../extensions/scratch3_boost'); -// todo: only load this extension once we have a compatible way to load its -// Vernier module dependency. -// const Scratch3GdxForBlocks = require('../extensions/scratch3_gdx_for'); const builtinExtensions = { - pen: Scratch3PenBlocks, - wedo2: Scratch3WeDo2Blocks, - music: Scratch3MusicBlocks, - microbit: Scratch3MicroBitBlocks, - text2speech: Scratch3Text2SpeechBlocks, - translate: Scratch3TranslateBlocks, - videoSensing: Scratch3VideoSensingBlocks, - speech2text: Scratch3Speech2TextBlocks, - ev3: Scratch3Ev3Blocks, - makeymakey: Scratch3MakeyMakeyBlocks, - boost: Scratch3BoostBlocks - // gdxfor: Scratch3GdxForBlocks + pen: () => require('../extensions/scratch3_pen'), + wedo2: () => require('../extensions/scratch3_wedo2'), + music: () => require('../extensions/scratch3_music'), + microbit: () => require('../extensions/scratch3_microbit'), + text2speech: () => require('../extensions/scratch3_text2speech'), + translate: () => require('../extensions/scratch3_translate'), + videoSensing: () => require('../extensions/scratch3_video_sensing'), + speech2text: () => require('../extensions/scratch3_speech2text'), + ev3: () => require('../extensions/scratch3_ev3'), + makeymakey: () => require('../extensions/scratch3_makeymakey'), + boost: () => require('../extensions/scratch3_boost') + // todo: only load this extension once we have a compatible way to load its + // Vernier module dependency. + // gdxfor: () => require('../extensions/scratch3_gdx_for') }; /** @@ -135,7 +123,7 @@ class ExtensionManager { return Promise.reject(new Error(message)); } - const extension = builtinExtensions[extensionURL]; + const extension = builtinExtensions[extensionURL](); const extensionInstance = new extension(this.runtime); return this._registerInternalExtension(extensionInstance).then(serviceName => { this._loadedExtensions.set(extensionURL, serviceName); diff --git a/src/extensions/scratch3_text2speech/index.js b/src/extensions/scratch3_text2speech/index.js index 9a80e9468..d1f0b0d38 100644 --- a/src/extensions/scratch3_text2speech/index.js +++ b/src/extensions/scratch3_text2speech/index.js @@ -65,6 +65,41 @@ const GIANT_ID = 'GIANT'; */ const KITTEN_ID = 'KITTEN'; +/** + * Playback rate for the tenor voice, for cases where we have only a female gender voice. + */ +const FEMALE_TENOR_RATE = 0.89; // -2 semitones + +/** + * Playback rate for the giant voice, for cases where we have only a female gender voice. + */ +const FEMALE_GIANT_RATE = 0.79; // -4 semitones + +/** + * Language ids. The value for each language id is a valid Scratch locale. + */ +const CHINESE_ID = 'zh-cn'; +const DANISH_ID = 'da'; +const DUTCH_ID = 'nl'; +const ENGLISH_ID = 'en'; +const FRENCH_ID = 'fr'; +const GERMAN_ID = 'de'; +const ICELANDIC_ID = 'is'; +const ITALIAN_ID = 'it'; +const JAPANESE_ID = 'ja'; +const KOREAN_ID = 'ko'; +const NORWEGIAN_ID = 'nb'; +const POLISH_ID = 'pl'; +const PORTUGUESE_BR_ID = 'pt-br'; +const PORTUGUESE_ID = 'pt'; +const ROMANIAN_ID = 'ro'; +const RUSSIAN_ID = 'ru'; +const SPANISH_ID = 'es'; +const SPANISH_419_ID = 'es-419'; +const SWEDISH_ID = 'sv'; +const TURKISH_ID = 'tr'; +const WELSH_ID = 'cy'; + /** * Class for the text2speech blocks. * @constructor @@ -92,6 +127,12 @@ class Scratch3Text2SpeechBlocks { if (this.runtime) { runtime.on('targetWasCreated', this._onTargetCreated); } + + /** + * A list of all Scratch locales that are supported by the extension. + * @type {Array} + */ + this._supportedLocales = this._getSupportedLocales(); } /** @@ -148,57 +189,142 @@ class Scratch3Text2SpeechBlocks { } /** - * An object with language names mapped to their language codes. + * An object with information for each language. + * + * A note on the different sets of locales referred to in this extension: + * + * SCRATCH LOCALE + * Set by the editor, and used to store the language state in the project. + * Listed in l10n: https://github.com/LLK/scratch-l10n/blob/master/src/supported-locales.js + * SUPPORTED LOCALE + * A Scratch locale that has a corresponding extension locale. + * EXTENSION LOCALE + * A locale corresponding to one of the available spoken languages + * in the extension. There can be multiple supported locales for a single + * extension locale. For example, for both written versions of chinese, + * zh-cn and zh-tw, we use a single spoken language (Mandarin). So there + * are two supported locales, with a single extension locale. + * SPEECH SYNTH LOCALE + * A different locale code system, used by our speech synthesis service. + * Each extension locale has a speech synth locale. */ get LANGUAGE_INFO () { return { - 'Danish': 'da', - 'Dutch': 'nl', - 'English': 'en', - 'French': 'fr', - 'German': 'de', - 'Icelandic': 'is', - 'Italian': 'it', - 'Japanese': 'ja', - 'Polish': 'pl', - 'Portuguese (Brazilian)': 'pt-br', - 'Portuguese (European)': 'pt', - 'Russian': 'ru', - 'Spanish (European)': 'es', - 'Spanish (Latin American)': 'es-419' + [CHINESE_ID]: { + name: 'Chinese (Mandarin)', + locales: ['zh-cn', 'zh-tw'], + speechSynthLocale: 'cmn-CN', + singleGender: true + }, + [DANISH_ID]: { + name: 'Danish', + locales: ['da'], + speechSynthLocale: 'da-DK' + }, + [DUTCH_ID]: { + name: 'Dutch', + locales: ['nl'], + speechSynthLocale: 'nl-NL' + }, + [ENGLISH_ID]: { + name: 'English', + locales: ['en'], + speechSynthLocale: 'en-US' + }, + [FRENCH_ID]: { + name: 'French', + locales: ['fr'], + speechSynthLocale: 'fr-FR' + }, + [GERMAN_ID]: { + name: 'German', + locales: ['de'], + speechSynthLocale: 'de-DE' + }, + [ICELANDIC_ID]: { + name: 'Icelandic', + locales: ['is'], + speechSynthLocale: 'is-IS' + }, + [ITALIAN_ID]: { + name: 'Italian', + locales: ['it'], + speechSynthLocale: 'it-IT' + }, + [JAPANESE_ID]: { + name: 'Japanese', + locales: ['ja', 'ja-Hira'], + speechSynthLocale: 'ja-JP' + }, + [KOREAN_ID]: { + name: 'Korean', + locales: ['ko'], + speechSynthLocale: 'ko-KR', + singleGender: true + }, + [NORWEGIAN_ID]: { + name: 'Norwegian', + locales: ['nb', 'nn'], + speechSynthLocale: 'nb-NO', + singleGender: true + }, + [POLISH_ID]: { + name: 'Polish', + locales: ['pl'], + speechSynthLocale: 'pl-PL' + }, + [PORTUGUESE_BR_ID]: { + name: 'Portuguese (Brazilian)', + locales: ['pt-br'], + speechSynthLocale: 'pt-BR' + }, + [PORTUGUESE_ID]: { + name: 'Portuguese (European)', + locales: ['pt'], + speechSynthLocale: 'pt-PT' + }, + [ROMANIAN_ID]: { + name: 'Romanian', + locales: ['ro'], + speechSynthLocale: 'ro-RO', + singleGender: true + }, + [RUSSIAN_ID]: { + name: 'Russian', + locales: ['ru'], + speechSynthLocale: 'ru-RU' + }, + [SPANISH_ID]: { + name: 'Spanish (European)', + locales: ['es'], + speechSynthLocale: 'es-ES' + }, + [SPANISH_419_ID]: { + name: 'Spanish (Latin American)', + locales: ['es-419'], + speechSynthLocale: 'es-US' + }, + [SWEDISH_ID]: { + name: 'Swedish', + locales: ['sv'], + speechSynthLocale: 'sv-SE', + singleGender: true + }, + [TURKISH_ID]: { + name: 'Turkish', + locales: ['tr'], + speechSynthLocale: 'tr-TR', + singleGender: true + }, + [WELSH_ID]: { + name: 'Welsh', + locales: ['cy'], + speechSynthLocale: 'cy-GB', + singleGender: true + } }; } - /** - * This is a temporary adapter to convert Scratch locale codes to Amazon polly's locale codes. - * @todo remove this once the speech synthesis server can perform this conversion - * @param {string} locale the Scratch locale to convert. - * @return {string} the Amazon polly locale. - */ - localeToPolly (locale) { - const pollyLocales = { - 'da': 'da-DK', // Danish - 'nl': 'nl-NL', // Dutch - 'en': 'en-US', // English - 'fr': 'fr-FR', // French - 'de': 'de-DE', // German - 'is': 'is-IS', // Icelandic - 'it': 'it-IT', // Italian - 'ja': 'ja-JP', // Japanese - 'pl': 'pl-PL', // Polish - 'pt-br': 'pt-BR', // Portuguese (Brazilian) - 'pt': 'pt-PT', // Portuguese (European) - 'ru': 'ru-RU', // Russian - 'es': 'es-ES', // Spanish (European) - 'es-419': 'es-US' // Spanish (Latin American) - }; - let converted = 'en-US'; - if (pollyLocales[locale]) { - converted = pollyLocales[locale]; - } - return converted; - } - /** * The key to load & store a target's text2speech state. * @return {string} The key. @@ -222,7 +348,7 @@ class Scratch3Text2SpeechBlocks { * @type {string} */ get DEFAULT_LANGUAGE () { - return 'en'; + return ENGLISH_ID; } /** @@ -272,7 +398,11 @@ class Scratch3Text2SpeechBlocks { return { id: 'text2speech', - name: 'Text to Speech', + name: formatMessage({ + id: 'text2speech.categoryName', + default: 'Text to Speech', + description: 'Name of the Text to Speech extension.' + }), blockIconURI: blockIconURI, menuIconURI: menuIconURI, blocks: [ @@ -334,7 +464,7 @@ class Scratch3Text2SpeechBlocks { /** * Get the language code currently set in the editor, or fall back to the * browser locale. - * @return {string} the language code. + * @return {string} a Scratch locale code. */ getEditorLanguage () { return formatMessage.setup().locale || @@ -342,8 +472,8 @@ class Scratch3Text2SpeechBlocks { } /** - * Get the language for speech synthesis. - * @returns {string} the language code. + * Get the language code currently set for the extension. + * @returns {string} a Scratch locale code. */ getCurrentLanguage () { const stage = this.runtime.getTargetForStage(); @@ -356,17 +486,18 @@ class Scratch3Text2SpeechBlocks { } /** - * Set the language for speech synthesis. + * Set the language code for the extension. * It is stored in the stage so it can be saved and loaded with the project. - * @param {string} languageCode a locale code to set. + * @param {string} locale a locale code. */ - setCurrentLanguage (languageCode) { + setCurrentLanguage (locale) { const stage = this.runtime.getTargetForStage(); if (!stage) return; - // Only set the language if it is in the list. - if (this.isSupportedLanguage(languageCode)) { - stage.textToSpeechLanguage = languageCode; + + if (this.isSupportedLanguage(locale)) { + stage.textToSpeechLanguage = this._getExtensionLocaleForSupportedLocale(locale); } + // If the language is null, set it to the default language. // This can occur e.g. if the extension was loaded with the editor // set to a language that is not in the list. @@ -376,13 +507,49 @@ class Scratch3Text2SpeechBlocks { } /** - * Check if a language code is in the list of supported languages for the + * Get the extension locale for a supported locale, or null. + * @param {string} locale a locale code. + * @returns {?string} a locale supported by the extension. + */ + _getExtensionLocaleForSupportedLocale (locale) { + for (const lang in this.LANGUAGE_INFO) { + if (this.LANGUAGE_INFO[lang].locales.includes(locale)) { + return lang; + } + } + log.error(`cannot find extension locale for locale ${locale}`); + } + + /** + * Get the locale code used by the speech synthesis server corresponding to + * the current language code set for the extension. + * @returns {string} a speech synthesis locale. + */ + _getSpeechSynthLocale () { + let speechSynthLocale = this.LANGUAGE_INFO[this.DEFAULT_LANGUAGE].speechSynthLocale; + if (this.LANGUAGE_INFO[this.getCurrentLanguage()]) { + speechSynthLocale = this.LANGUAGE_INFO[this.getCurrentLanguage()].speechSynthLocale; + } + return speechSynthLocale; + } + + /** + * Get an array of the locales supported by this extension. + * @returns {Array} An array of locale strings. + */ + _getSupportedLocales () { + return Object.keys(this.LANGUAGE_INFO).reduce((acc, lang) => + acc.concat(this.LANGUAGE_INFO[lang].locales), []); + } + + /** + * Check if a Scratch language code is in the list of supported languages for the * speech synthesis service. * @param {string} languageCode the language code to check. * @returns {boolean} true if the language code is supported. */ isSupportedLanguage (languageCode) { - return Object.values(this.LANGUAGE_INFO).includes(languageCode); + return this._supportedLocales.includes(languageCode); } /** @@ -401,9 +568,9 @@ class Scratch3Text2SpeechBlocks { * @return {array} the text and value for each menu item. */ getLanguageMenu () { - return Object.keys(this.LANGUAGE_INFO).map(languageName => ({ - text: languageName, - value: this.LANGUAGE_INFO[languageName] + return Object.keys(this.LANGUAGE_INFO).map(key => ({ + text: this.LANGUAGE_INFO[key].name, + value: key })); } @@ -457,16 +624,29 @@ class Scratch3Text2SpeechBlocks { speakAndWait (args, util) { // Cast input to string let words = Cast.toString(args.WORDS); - let locale = this.localeToPolly(this.getCurrentLanguage()); + let locale = this._getSpeechSynthLocale(); const state = this._getState(util.target); - const gender = this.VOICE_INFO[state.voiceId].gender; - const playbackRate = this.VOICE_INFO[state.voiceId].playbackRate; + let gender = this.VOICE_INFO[state.voiceId].gender; + let playbackRate = this.VOICE_INFO[state.voiceId].playbackRate; + + // Special case for voices where the synthesis service only provides a + // single gender voice. In that case, always request the female voice, + // and set special playback rates for the tenor and giant voices. + if (this.LANGUAGE_INFO[this.getCurrentLanguage()].singleGender) { + gender = 'female'; + if (state.voiceId === TENOR_ID) { + playbackRate = FEMALE_TENOR_RATE; + } + if (state.voiceId === GIANT_ID) { + playbackRate = FEMALE_GIANT_RATE; + } + } if (state.voiceId === KITTEN_ID) { words = words.replace(/\S+/g, 'meow'); - locale = 'en-US'; + locale = this.LANGUAGE_INFO[this.DEFAULT_LANGUAGE].speechSynthLocale; } // Build up URL diff --git a/src/import/load-costume.js b/src/import/load-costume.js index e2ce43345..a8da72a48 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -32,6 +32,53 @@ const loadVector_ = function (costume, runtime, rotationCenter, optVersion) { }); }; +const canvasPool = (function () { + /** + * A pool of canvas objects that can be reused to reduce memory + * allocations. And time spent in those allocations and the later garbage + * collection. + */ + class CanvasPool { + constructor () { + this.pool = []; + this.clearSoon = null; + } + + /** + * After a short wait period clear the pool to let the VM collect + * garbage. + */ + clear () { + if (!this.clearSoon) { + this.clearSoon = new Promise(resolve => setTimeout(resolve, 1000)) + .then(() => { + this.pool.length = 0; + this.clearSoon = null; + }); + } + } + + /** + * Return a canvas. Create the canvas if the pool is empty. + * @returns {HTMLCanvasElement} A canvas element. + */ + create () { + return this.pool.pop() || document.createElement('canvas'); + } + + /** + * Release the canvas to be reused. + * @param {HTMLCanvasElement} canvas A canvas element. + */ + release (canvas) { + this.clear(); + this.pool.push(canvas); + } + } + + return new CanvasPool(); +}()); + /** * Return a promise to fetch a bitmap from storage and return it as a canvas * If the costume has bitmapResolution 1, it will be converted to bitmapResolution 2 here (the standard for Scratch 3) @@ -54,86 +101,76 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { return Promise.reject('No V2 Bitmap adapter present.'); } - return new Promise((resolve, reject) => { - const baseImageElement = new Image(); - let textImageElement; + return Promise.all([costume.asset, costume.textLayerAsset].map(asset => { + if (!asset) { + return null; + } - // We need to wait for 2 images total to load. loadedOne will be true when one - // is done, and we are just waiting for one more. - let loadedOne = false; + if (typeof createImageBitmap !== 'undefined') { + return createImageBitmap( + new Blob([asset.data], {type: asset.assetType.contentType}) + ); + } - const onError = function () { - // eslint-disable-next-line no-use-before-define - removeEventListeners(); - reject('Costume load failed. Asset could not be read.'); - }; - const onLoad = function () { - if (loadedOne) { - // eslint-disable-next-line no-use-before-define - removeEventListeners(); - resolve([baseImageElement, textImageElement]); - } else { - loadedOne = true; - } - }; + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = function () { + resolve(image); + image.onload = null; + image.onerror = null; + }; + image.onerror = function () { + reject('Costume load failed. Asset could not be read.'); + image.onload = null; + image.onerror = null; + }; + image.src = asset.encodeDataURI(); + }); + })) + .then(([baseImageElement, textImageElement]) => { + const mergeCanvas = canvasPool.create(); - const removeEventListeners = function () { - baseImageElement.removeEventListener('error', onError); - baseImageElement.removeEventListener('load', onLoad); + const scale = costume.bitmapResolution === 1 ? 2 : 1; + mergeCanvas.width = baseImageElement.width; + mergeCanvas.height = baseImageElement.height; + + const ctx = mergeCanvas.getContext('2d'); + ctx.drawImage(baseImageElement, 0, 0); if (textImageElement) { - textImageElement.removeEventListener('error', onError); - textImageElement.removeEventListener('load', onLoad); + ctx.drawImage(textImageElement, 0, 0); + } + // Track the canvas we merged the bitmaps onto separately from the + // canvas that we receive from resize if scale is not 1. We know + // resize treats mergeCanvas as read only data. We don't know when + // resize may use or modify the canvas. So we'll only release the + // mergeCanvas back into the canvas pool. Reusing the canvas from + // resize may cause errors. + let canvas = mergeCanvas; + if (scale !== 1) { + canvas = runtime.v2BitmapAdapter.resize(mergeCanvas, canvas.width * scale, canvas.height * scale); } - }; - baseImageElement.addEventListener('load', onLoad); - baseImageElement.addEventListener('error', onError); - if (costume.textLayerAsset) { - textImageElement = new Image(); - textImageElement.addEventListener('load', onLoad); - textImageElement.addEventListener('error', onError); - textImageElement.src = costume.textLayerAsset.encodeDataURI(); - } else { - loadedOne = true; - } - baseImageElement.src = costume.asset.encodeDataURI(); - }).then(imageElements => { - const [baseImageElement, textImageElement] = imageElements; + // By scaling, we've converted it to bitmap resolution 2 + if (rotationCenter) { + rotationCenter[0] = rotationCenter[0] * scale; + rotationCenter[1] = rotationCenter[1] * scale; + costume.rotationCenterX = rotationCenter[0]; + costume.rotationCenterY = rotationCenter[1]; + } + costume.bitmapResolution = 2; - let canvas = document.createElement('canvas'); - const scale = costume.bitmapResolution === 1 ? 2 : 1; - canvas.width = baseImageElement.width; - canvas.height = baseImageElement.height; + // Clean up the costume object + delete costume.textLayerMD5; + delete costume.textLayerAsset; - const ctx = canvas.getContext('2d'); - ctx.drawImage(baseImageElement, 0, 0); - if (textImageElement) { - ctx.drawImage(textImageElement, 0, 0); - } - if (scale !== 1) { - canvas = runtime.v2BitmapAdapter.resize(canvas, canvas.width * scale, canvas.height * scale); - } - - // By scaling, we've converted it to bitmap resolution 2 - if (rotationCenter) { - rotationCenter[0] = rotationCenter[0] * scale; - rotationCenter[1] = rotationCenter[1] * scale; - costume.rotationCenterX = rotationCenter[0]; - costume.rotationCenterY = rotationCenter[1]; - } - costume.bitmapResolution = 2; - - // Clean up the costume object - delete costume.textLayerMD5; - delete costume.textLayerAsset; - - return { - canvas: canvas, - rotationCenter: rotationCenter, - // True if the asset matches the base layer; false if it required adjustment - assetMatchesBase: scale === 1 && !textImageElement - }; - }) + return { + canvas, + mergeCanvas, + rotationCenter, + // True if the asset matches the base layer; false if it required adjustment + assetMatchesBase: scale === 1 && !textImageElement + }; + }) .catch(() => { // Clean up the text layer properties if it fails to load delete costume.textLayerMD5; @@ -141,36 +178,43 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { }); }; -const loadBitmap_ = function (costume, runtime, rotationCenter) { - return fetchBitmapCanvas_(costume, runtime, rotationCenter).then(fetched => new Promise(resolve => { - rotationCenter = fetched.rotationCenter; +const loadBitmap_ = function (costume, runtime, _rotationCenter) { + return fetchBitmapCanvas_(costume, runtime, _rotationCenter) + .then(fetched => { + const updateCostumeAsset = function (dataURI) { + if (!runtime.v2BitmapAdapter) { + // TODO: This might be a bad practice since the returned + // promise isn't acted on. If this is something we should be + // creating a rejected promise for we should also catch it + // somewhere and act on that error (like logging). + // + // Return a rejection to stop executing updateCostumeAsset. + return Promise.reject('No V2 Bitmap adapter present.'); + } - const updateCostumeAsset = function (dataURI) { - if (!runtime.v2BitmapAdapter) { - return Promise.reject('No V2 Bitmap adapter present.'); + const storage = runtime.storage; + costume.asset = storage.createAsset( + storage.AssetType.ImageBitmap, + storage.DataFormat.PNG, + runtime.v2BitmapAdapter.convertDataURIToBinary(dataURI), + null, + true // generate md5 + ); + costume.dataFormat = storage.DataFormat.PNG; + costume.assetId = costume.asset.assetId; + costume.md5 = `${costume.assetId}.${costume.dataFormat}`; + }; + + if (!fetched.assetMatchesBase) { + updateCostumeAsset(fetched.canvas.toDataURL()); } - const storage = runtime.storage; - costume.asset = storage.createAsset( - storage.AssetType.ImageBitmap, - storage.DataFormat.PNG, - runtime.v2BitmapAdapter.convertDataURIToBinary(dataURI), - null, - true // generate md5 - ); - costume.dataFormat = storage.DataFormat.PNG; - costume.assetId = costume.asset.assetId; - costume.md5 = `${costume.assetId}.${costume.dataFormat}`; - }; - - if (!fetched.assetMatchesBase) { - updateCostumeAsset(fetched.canvas.toDataURL()); - } - resolve(fetched.canvas); - })) - .then(canvas => { + return fetched; + }) + .then(({canvas, mergeCanvas, rotationCenter}) => { // createBitmapSkin does the right thing if costume.bitmapResolution or rotationCenter are undefined... costume.skinId = runtime.renderer.createBitmapSkin(canvas, costume.bitmapResolution, rotationCenter); + canvasPool.release(mergeCanvas); const renderSize = runtime.renderer.getSkinSize(costume.skinId); costume.size = [renderSize[0] * 2, renderSize[1] * 2]; // Actual size, since all bitmaps are resolution 2 diff --git a/src/io/ble.js b/src/io/ble.js index 8fa21de2e..0b97f9ae6 100644 --- a/src/io/ble.js +++ b/src/io/ble.js @@ -73,14 +73,19 @@ class BLE extends JSONRPCWebSocket { * Close the websocket. */ disconnect () { - if (!this._connected) return; + if (this._ws.readyState === this._ws.OPEN) { + this._ws.close(); + } - this._ws.close(); - this._connected = false; + if (this._connected) { + this._connected = false; + } + if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } + // Sets connection status icon to orange this._runtime.emit(this._runtime.constructor.PERIPHERAL_DISCONNECTED); } diff --git a/src/io/bt.js b/src/io/bt.js index 455684a2a..a2056209c 100644 --- a/src/io/bt.js +++ b/src/io/bt.js @@ -75,14 +75,19 @@ class BT extends JSONRPCWebSocket { * Close the websocket. */ disconnect () { - if (!this._connected) return; + if (this._ws.readyState === this._ws.OPEN) { + this._ws.close(); + } + + if (this._connected) { + this._connected = false; + } - this._ws.close(); - this._connected = false; if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } + // Sets connection status icon to orange this._runtime.emit(this._runtime.constructor.PERIPHERAL_DISCONNECTED); } diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 2cafa5920..412a758eb 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -823,46 +823,32 @@ const deserializeBlocks = function (blocks) { /** - * Parse a single "Scratch object" and create all its in-memory VM objects. + * Parse the assets of a single "Scratch object" and load them. This + * preprocesses objects to support loading the data for those assets over a + * network while the objects are further processed into Blocks, Sprites, and a + * list of needed Extensions. * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. * @param {!Runtime} runtime Runtime object to load all structures into. - * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. * @param {JSZip} zip Sb3 file describing this project (to load assets from) - * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. + * @return {?{costumePromises:Array.,soundPromises:Array.,soundBank:SoundBank}} + * Object of arrays of promises for asset objects used in Sprites. As well as a + * SoundBank for the sound assets. null for unsupported objects. */ -const parseScratchObject = function (object, runtime, extensions, zip) { +const parseScratchAssets = function (object, runtime, zip) { if (!object.hasOwnProperty('name')) { // Watcher/monitor - skip this object until those are implemented in VM. // @todo return Promise.resolve(null); } - // Blocks container for this object. - const blocks = new Blocks(runtime); - // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. - const sprite = new Sprite(blocks, runtime); + const assets = { + costumePromises: null, + soundPromises: null, + soundBank: runtime.audioEngine && runtime.audioEngine.createBank() + }; - // Sprite/stage name from JSON. - if (object.hasOwnProperty('name')) { - sprite.name = object.name; - } - if (object.hasOwnProperty('blocks')) { - deserializeBlocks(object.blocks); - // Take a second pass to create objects and add extensions - for (const blockId in object.blocks) { - if (!object.blocks.hasOwnProperty(blockId)) continue; - const blockJSON = object.blocks[blockId]; - blocks.createBlock(blockJSON); - - // If the block is from an extension, record it. - const extensionID = getExtensionIdForOpcode(blockJSON.opcode); - if (extensionID) { - extensions.extensionIDs.add(extensionID); - } - } - } // Costumes from JSON. - const costumePromises = (object.costumes || []).map(costumeSource => { + assets.costumePromises = (object.costumes || []).map(costumeSource => { // @todo: Make sure all the relevant metadata is being pulled out. const costume = { // costumeSource only has an asset if an image is being uploaded as @@ -894,7 +880,7 @@ const parseScratchObject = function (object, runtime, extensions, zip) { // process has been completed }); // Sounds from JSON - const soundPromises = (object.sounds || []).map(soundSource => { + assets.soundPromises = (object.sounds || []).map(soundSource => { const sound = { assetId: soundSource.assetId, format: soundSource.format, @@ -914,10 +900,59 @@ const parseScratchObject = function (object, runtime, extensions, zip) { // any translation that needs to happen will happen in the process // of building up the costume object into an sb3 format return deserializeSound(sound, runtime, zip) - .then(() => loadSound(sound, runtime, sprite.soundBank)); + .then(() => loadSound(sound, runtime, assets.soundBank)); // Only attempt to load the sound after the deserialization // process has been completed. }); + + return assets; +}; + +/** + * Parse a single "Scratch object" and create all its in-memory VM objects. + * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. + * @param {!Runtime} runtime Runtime object to load all structures into. + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @param {JSZip} zip Sb3 file describing this project (to load assets from) + * @param {object} assets - Promises for assets of this scratch object grouped + * into costumes and sounds + * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. + */ +const parseScratchObject = function (object, runtime, extensions, zip, assets) { + if (!object.hasOwnProperty('name')) { + // Watcher/monitor - skip this object until those are implemented in VM. + // @todo + return Promise.resolve(null); + } + // Blocks container for this object. + const blocks = new Blocks(runtime); + + // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. + const sprite = new Sprite(blocks, runtime); + + // Sprite/stage name from JSON. + if (object.hasOwnProperty('name')) { + sprite.name = object.name; + } + if (object.hasOwnProperty('blocks')) { + deserializeBlocks(object.blocks); + // Take a second pass to create objects and add extensions + for (const blockId in object.blocks) { + if (!object.blocks.hasOwnProperty(blockId)) continue; + const blockJSON = object.blocks[blockId]; + blocks.createBlock(blockJSON); + + // If the block is from an extension, record it. + const extensionID = getExtensionIdForOpcode(blockJSON.opcode); + if (extensionID) { + extensions.extensionIDs.add(extensionID); + } + } + } + // Costumes from JSON. + const {costumePromises} = assets; + // Sounds from JSON + const {soundBank, soundPromises} = assets; // Create the first clone, and load its run-state from JSON. const target = sprite.createClone(object.isStage ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER); // Load target properties from JSON. @@ -1039,6 +1074,8 @@ const parseScratchObject = function (object, runtime, extensions, zip) { }); Promise.all(soundPromises).then(sounds => { sprite.sounds = sounds; + // Make sure if soundBank is undefined, sprite.soundBank is then null. + sprite.soundBank = soundBank || null; }); return Promise.all(costumePromises.concat(soundPromises)).then(() => target); }; @@ -1190,10 +1227,16 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { const monitorObjects = json.monitors || []; - return Promise.all( + return Promise.resolve( targetObjects.map(target => - parseScratchObject(target, runtime, extensions, zip)) + parseScratchAssets(target, runtime, zip)) ) + // Force this promise to wait for the next loop in the js tick. Let + // storage have some time to send off asset requests. + .then(assets => Promise.resolve(assets)) + .then(assets => Promise.all(targetObjects + .map((target, index) => + parseScratchObject(target, runtime, extensions, zip, assets[index])))) .then(targets => targets // Re-sort targets back into original sprite-pane ordering .map((t, i) => { // Add layer order property to deserialized targets. diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index 78243a269..12a51f2ab 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -1,6 +1,7 @@ const log = require('../util/log'); const MathUtil = require('../util/math-util'); const StringUtil = require('../util/string-util'); +const Cast = require('../util/cast'); const Clone = require('../util/clone'); const Target = require('../engine/target'); const StageLayering = require('../engine/stage-layering'); @@ -840,6 +841,7 @@ class RenderedTarget extends Target { * @return {boolean} True iff touching a clone of the sprite. */ isTouchingSprite (spriteName) { + spriteName = Cast.toString(spriteName); const firstClone = this.runtime.getSpriteTargetByName(spriteName); if (!firstClone || !this.renderer) { return false; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index b610d193e..daf9df544 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -1,4 +1,10 @@ -const TextEncoder = require('text-encoding').TextEncoder; +let _TextEncoder; +if (typeof TextEncoder === 'undefined') { + _TextEncoder = require('text-encoding').TextEncoder; +} else { + /* global TextEncoder */ + _TextEncoder = TextEncoder; +} const EventEmitter = require('events'); const JSZip = require('jszip'); @@ -8,12 +14,8 @@ const ExtensionManager = require('./extension-support/extension-manager'); const log = require('./util/log'); const MathUtil = require('./util/math-util'); const Runtime = require('./engine/runtime'); -const {SB1File, ValidationError} = require('scratch-sb1-converter'); -const sb2 = require('./serialization/sb2'); -const sb3 = require('./serialization/sb3'); const StringUtil = require('./util/string-util'); const formatMessage = require('format-message'); -const validate = require('scratch-parser'); const Variable = require('./engine/variable'); const newBlockIds = require('./util/new-block-ids'); @@ -297,6 +299,7 @@ class VirtualMachine extends EventEmitter { } const validationPromise = new Promise((resolve, reject) => { + const validate = require('scratch-parser'); // The second argument of false below indicates to the validator that the // input should be parsed/validated as an entire project (and not a single sprite) validate(input, false, (error, res) => { @@ -305,6 +308,8 @@ class VirtualMachine extends EventEmitter { }); }) .catch(error => { + const {SB1File, ValidationError} = require('scratch-sb1-converter'); + try { const sb1 = new SB1File(input); const json = sb1.json; @@ -410,6 +415,8 @@ class VirtualMachine extends EventEmitter { * specified by optZipType or blob by default. */ exportSprite (targetId, optZipType) { + const sb3 = require('./serialization/sb3'); + const soundDescs = serializeSounds(this.runtime, targetId); const costumeDescs = serializeCostumes(this.runtime, targetId); const spriteJson = StringUtil.stringify(sb3.serialize(this.runtime, targetId)); @@ -432,6 +439,7 @@ class VirtualMachine extends EventEmitter { * @return {string} Serialized state of the runtime. */ toJSON () { + const sb3 = require('./serialization/sb3'); return StringUtil.stringify(sb3.serialize(this.runtime)); } @@ -461,9 +469,11 @@ class VirtualMachine extends EventEmitter { const deserializePromise = function () { const projectVersion = projectJSON.projectVersion; if (projectVersion === 2) { + const sb2 = require('./serialization/sb2'); return sb2.deserialize(projectJSON, runtime, false, zip); } if (projectVersion === 3) { + const sb3 = require('./serialization/sb3'); return sb3.deserialize(projectJSON, runtime, zip); } return Promise.reject('Unable to verify Scratch Project version.'); @@ -553,6 +563,7 @@ class VirtualMachine extends EventEmitter { } const validationPromise = new Promise((resolve, reject) => { + const validate = require('scratch-parser'); // The second argument of true below indicates to the parser/validator // that the given input should be treated as a single sprite and not // an entire project @@ -592,6 +603,7 @@ class VirtualMachine extends EventEmitter { _addSprite2 (sprite, zip) { // Validate & parse + const sb2 = require('./serialization/sb2'); return sb2.deserialize(sprite, this.runtime, true, zip) .then(({targets, extensions}) => this.installTargets(targets, extensions, false)); @@ -605,7 +617,7 @@ class VirtualMachine extends EventEmitter { */ _addSprite3 (sprite, zip) { // Validate & parse - + const sb3 = require('./serialization/sb3'); return sb3 .deserialize(sprite, this.runtime, zip, true) .then(({targets, extensions}) => this.installTargets(targets, extensions, false)); @@ -912,7 +924,7 @@ class VirtualMachine extends EventEmitter { costume.asset = storage.createAsset( storage.AssetType.ImageVector, costume.dataFormat, - (new TextEncoder()).encode(svg), + (new _TextEncoder()).encode(svg), null, true // generate md5 ); @@ -1187,6 +1199,8 @@ class VirtualMachine extends EventEmitter { * @return {!Promise} Promise that resolves when the extensions and blocks have been added. */ shareBlocksToTarget (blocks, targetId, optFromTargetId) { + const sb3 = require('./serialization/sb3'); + const copiedBlocks = JSON.parse(JSON.stringify(blocks)); newBlockIds(copiedBlocks); const target = this.runtime.getTargetById(targetId); diff --git a/test/fixtures/execute/sprite-number-name.sb2 b/test/fixtures/execute/sprite-number-name.sb2 new file mode 100644 index 000000000..ec72d2e02 Binary files /dev/null and b/test/fixtures/execute/sprite-number-name.sb2 differ diff --git a/test/unit/blocks_looks.js b/test/unit/blocks_looks.js index 61b6ddd58..599e7e49a 100644 --- a/test/unit/blocks_looks.js +++ b/test/unit/blocks_looks.js @@ -211,3 +211,48 @@ test('numbers should be rounded to two decimals in say', t => { looks.say(args, util); }); + +test('clamp graphic effects', t => { + const rt = new Runtime(); + const looks = new Looks(rt); + const expectedValues = { + brightness: {high: 100, low: -100}, + ghost: {high: 100, low: 0}, + color: {high: 500, low: -500}, + fisheye: {high: 500, low: -500}, + whirl: {high: 500, low: -500}, + pixelate: {high: 500, low: -500}, + mosaic: {high: 500, low: -500} + }; + const args = [ + {EFFECT: 'brightness', VALUE: 500, CLAMP: 'high'}, + {EFFECT: 'brightness', VALUE: -500, CLAMP: 'low'}, + {EFFECT: 'ghost', VALUE: 500, CLAMP: 'high'}, + {EFFECT: 'ghost', VALUE: -500, CLAMP: 'low'}, + {EFFECT: 'color', VALUE: 500, CLAMP: 'high'}, + {EFFECT: 'color', VALUE: -500, CLAMP: 'low'}, + {EFFECT: 'fisheye', VALUE: 500, CLAMP: 'high'}, + {EFFECT: 'fisheye', VALUE: -500, CLAMP: 'low'}, + {EFFECT: 'whirl', VALUE: 500, CLAMP: 'high'}, + {EFFECT: 'whirl', VALUE: -500, CLAMP: 'low'}, + {EFFECT: 'pixelate', VALUE: 500, CLAMP: 'high'}, + {EFFECT: 'pixelate', VALUE: -500, CLAMP: 'low'}, + {EFFECT: 'mosaic', VALUE: 500, CLAMP: 'high'}, + {EFFECT: 'mosaic', VALUE: -500, CLAMP: 'low'} + ]; + + util.target.setEffect = function (effectName, actualValue) { + const clamp = actualValue > 0 ? 'high' : 'low'; + rt.emit(effectName + clamp, effectName, actualValue); + }; + + for (const arg of args) { + rt.addListener(arg.EFFECT + arg.CLAMP, (effectName, actualValue) => { + const expected = expectedValues[arg.EFFECT][arg.CLAMP]; + t.strictEqual(actualValue, expected); + }); + + looks.setEffect(arg, util); + } + t.end(); +}); diff --git a/test/unit/engine_blocks.js b/test/unit/engine_blocks.js index c25d5b42b..8ab483e29 100644 --- a/test/unit/engine_blocks.js +++ b/test/unit/engine_blocks.js @@ -358,6 +358,43 @@ test('move no obscure shadow', t => { t.end(); }); +test('move - attaching new shadow', t => { + const b = new Blocks(new Runtime()); + // Block/shadow are null to mimic state right after a procedure_call block + // is mutated by adding an input. The "move" will attach the new shadow. + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: { + fooInput: { + name: 'fooInput', + block: null, + shadow: null + } + }, + topLevel: true + }); + b.createBlock({ + id: 'bar', + opcode: 'TEST_BLOCK', + shadow: true, + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + b.moveBlock({ + id: 'bar', + newInput: 'fooInput', + newParent: 'foo' + }); + t.equal(b._blocks.foo.inputs.fooInput.block, 'bar'); + t.equal(b._blocks.foo.inputs.fooInput.shadow, 'bar'); + t.end(); +}); + test('change', t => { const b = new Blocks(new Runtime()); b.createBlock({ diff --git a/test/unit/extension_text_to_speech.js b/test/unit/extension_text_to_speech.js new file mode 100644 index 000000000..8c96ab802 --- /dev/null +++ b/test/unit/extension_text_to_speech.js @@ -0,0 +1,30 @@ +const test = require('tap').test; +const TextToSpeech = require('../../src/extensions/scratch3_text2speech/index.js'); + +const fakeStage = { + textToSpeechLanguage: null +}; + +const fakeRuntime = { + getTargetForStage: () => fakeStage, + on: () => {} // Stub out listener methods used in constructor. +}; + +const ext = new TextToSpeech(fakeRuntime); + +test('if no language is saved in the project, use default', t => { + t.strictEqual(ext.getCurrentLanguage(), 'en'); + t.end(); +}); + +test('if an unsupported language is dropped onto the set language block, use default', t => { + ext.setLanguage({LANGUAGE: 'nope'}); + t.strictEqual(ext.getCurrentLanguage(), 'en'); + t.end(); +}); + +test('get the extension locale for a supported locale that differs', t => { + ext.setLanguage({LANGUAGE: 'ja-Hira'}); + t.strictEqual(ext.getCurrentLanguage(), 'ja'); + t.end(); +});