Merge branch 'develop' into fix/random-costume

This commit is contained in:
jokebookservice1 2020-05-07 16:11:08 +01:00 committed by GitHub
commit 57445a4d49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 9300 additions and 4308 deletions

531
docs/extensions.md Normal file
View file

@ -0,0 +1,531 @@
# Scratch 3.0 Extensions
This document describes technical topics related to Scratch 3.0 extension development, including the Scratch 3.0
extension specification.
For documentation regarding other aspects of Scratch 3.0 extensions see [this Extensions page on the
wiki](https://github.com/LLK/docs/wiki/Extensions).
## Types of Extensions
There are four types of extensions that can define everything from the Scratch's core library (such as the "Looks" and
"Operators" categories) to unofficial extensions that can be loaded from a remote URL.
**Scratch 3.0 does not yet support unofficial extensions.**
For more details, see [this Extensions page on the wiki](https://github.com/LLK/docs/wiki/Extensions).
| | Core | Team | Official | Unofficial |
| ------------------------------ | ---- | ---- | -------- | ---------- |
| Developed by Scratch Team | √ | √ | O | X |
| Maintained by Scratch Team | √ | √ | O | X |
| Shown in Library | X | √ | √ | X |
| Sandboxed | X | X | √ | √ |
| Can save projects to community | √ | √ | √ | X |
## JavaScript Environment
Most Scratch 3.0 is written using JavaScript features not yet commonly supported by browsers. For compatibility we
transpile the code to ES5 before publishing or deploying. Any extension included in the `scratch-vm` repository may
use ES6+ features and may use `require` to reference other code within the `scratch-vm` repository.
Unofficial extensions must be self-contained. Authors of unofficial extensions are responsible for ensuring browser
compatibility for those extensions, including transpiling if necessary.
## Translation
Scratch extensions use the [ICU message format](http://userguide.icu-project.org/formatparse/messages) to handle
translation across languages. For **core, team, and official** extensions, the function `formatMessage` is used to
wrap any ICU messages that need to be exported to the [Scratch Transifex group](https://www.transifex.com/llk/public/)
for translation.
**All extensions** may additionally define a `translation_map` object within the `getInfo` function which can provide
translations within an extension itself. The "Annotated Example" below provides a more complete illustration of how
translation within an extension can be managed. **WARNING:** the `translation_map` feature is currently in the
proposal phase and may change before implementation.
## Backwards Compatibility
Scratch is designed to be fully backwards compatible. Because of this, block definitions and opcodes should *never*
change in a way that could cause previously saved projects to fail to load or to act in unexpected / inconsistent
ways.
## Defining an Extension
Scratch extensions are defined as a single Javascript class which accepts either a reference to the Scratch
[VM](https://github.com/llk/scratch-vm) runtime or a "runtime proxy" which handles communication with the Scratch VM
across a well defined worker boundary (i.e. the sandbox).
```js
class SomeBlocks {
constructor (runtime) {
/**
* Store this for later communication with the Scratch VM runtime.
* If this extension is running in a sandbox then `runtime` is an async proxy object.
* @type {Runtime}
*/
this.runtime = runtime;
}
// ...
}
```
All extensions must define a function called `getInfo` which returns an object that contains the information needed to
render both the blocks and the extension itself.
```js
// Core, Team, and Official extensions can `require` VM code:
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
class SomeBlocks {
// ...
getInfo () {
return {
id: 'someBlocks',
name: 'Some Blocks',
blocks: [
{
opcode: 'myReporter',
blockType: BlockType.REPORTER,
text: 'letter [LETTER_NUM] of [TEXT]',
arguments: {
LETTER_NUM: {
type: ArgumentType.STRING,
defaultValue: '1'
},
TEXT: {
type: ArgumentType.STRING,
defaultValue: 'text'
}
}
}
]
};
}
// ...
}
```
Finally the extension must define a function for any "opcode" defined in the blocks. For example:
```js
class SomeBlocks {
// ...
myReporter (args) {
return args.TEXT.charAt(args.LETTER_NUM);
};
// ...
}
```
### Block Arguments
In addition to displaying text, blocks can have arguments in the form of slots to take other blocks getting plugged in, or dropdown menus to select an argument value from a list of possible values.
The possible types of block arguments are as follows:
- String - a string input, this is a type-able field which also accepts other reporter blocks to be plugged in
- Number - an input similar to the string input, but the type-able values are constrained to numbers.
- Angle - an input similar to the number input, but it has an additional UI to be able to pick an angle from a
circular dial
- Boolean - an input for a boolean (hexagonal shaped) reporter block. This field is not type-able.
- Color - an input which displays a color swatch. This field has additional UI to pick a color by choosing values for the color's hue, saturation and brightness. Optionally, the defaultValue for the color picker can also be chosen if the extension developer wishes to display the same color every time the extension is added. If the defaultValue is left out, the default behavior of picking a random color when the extension is loaded will be used.
- Matrix - an input which displays a 5 x 5 matrix of cells, where each cell can be filled in or clear.
- Note - a numeric input which can select a musical note. This field has additional UI to select a note from a
visual keyboard.
- Image - an inline image displayed on a block. This is a special argument type in that it does not represent a value and does not accept other blocks to be plugged-in in place of this block field. See the section below about "Adding an Inline Image".
#### Adding an Inline Image
In addition to specifying block arguments (an example of string arguments shown in the code snippet above),
you can also specify an inline image for the block. You must include a dataURI for the image. If left unspecified, blank space will be allocated for the image and a warning will be logged in the console.
You can optionally also specify `flipRTL`, a property indicating whether the image should be flipped horizontally when the editor has a right to left language selected as its locale. By default, the image is not flipped.
```js
return {
// ...
blocks: [
{
//...
arguments {
MY_IMAGE: {
type: ArgumentType.IMAGE,
dataURI: 'myImageData',
alt: 'This is an image',
flipRTL: true
}
}
}
]
}
```
#### Defining a Menu
To display a drop-down menu for a block argument, specify the `menu` property of that argument and a matching item in
the `menus` section of your extension's definition:
```js
return {
// ...
blocks: [
{
// ...
arguments: {
FOO: {
type: ArgumentType.NUMBER,
menu: 'fooMenu'
}
}
}
],
menus: {
fooMenu: {
items: ['a', 'b', 'c']
}
}
}
```
The items in a menu may be specified with an array or with the name of a function which returns an array. The two
simplest forms for menu definitions are:
```js
getInfo () {
return {
menus: {
staticMenu: ['static 1', 'static 2', 'static 3'],
dynamicMenu: 'getDynamicMenuItems'
}
};
}
// this member function will be called each time the menu opens
getDynamicMenuItems () {
return ['dynamic 1', 'dynamic 2', 'dynamic 3'];
}
```
The examples above are shorthand for these equivalent definitions:
```js
getInfo () {
return {
menus: {
staticMenu: {
items: ['static 1', 'static 2', 'static 3']
},
dynamicMenu: {
items: 'getDynamicMenuItems'
}
}
};
}
// this member function will be called each time the menu opens
getDynamicMenuItems () {
return ['dynamic 1', 'dynamic 2', 'dynamic 3'];
}
```
If a menu item needs a label that doesn't match its value -- for example, if the label needs to be displayed in the
user's language but the value needs to stay constant -- the menu item may be an object instead of a string. This works
for both static and dynamic menu items:
```js
menus: {
staticMenu: [
{
text: formatMessage(/* ... */),
value: 42
}
]
}
```
##### Accepting reporters ("droppable" menus)
By default it is not possible to specify the value of a dropdown menu by inserting a reporter block. While we
encourage extension authors to make their menus accept reporters when possible, doing so requires careful
consideration to avoid confusion and frustration on the part of those using the extension.
A few of these considerations include:
* The valid values for the menu should not change when the user changes the Scratch language setting.
* In particular, changing languages should never break a working project.
* The average Scratch user should be able to figure out the valid values for this input without referring to extension
documentation.
* One way to ensure this is to make an item's text match or include the item's value. For example, the official Music
extension contains menu items with names like "(1) Piano" with value 1, "(8) Cello" with value 8, and so on.
* The block should accept any value as input, even "invalid" values.
* Scratch has no concept of a runtime error!
* For a command block, sometimes the best option is to do nothing.
* For a reporter, returning zero or the empty string might make sense.
* The block should be forgiving in its interpretation of inputs.
* For example, if the block expects a string and receives a number it may make sense to interpret the number as a
string instead of treating it as invalid input.
The `acceptReporters` flag indicates that the user can drop a reporter onto the menu input:
```js
menus: {
staticMenu: {
acceptReporters: true,
items: [/*...*/]
},
dynamicMenu: {
acceptReporters: true,
items: 'getDynamicMenuItems'
}
}
```
## Annotated Example
```js
// Core, Team, and Official extensions can `require` VM code:
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const TargetType = require('../../extension-support/target-type');
// ...or VM dependencies:
const formatMessage = require('format-message');
// Core, Team, and Official extension classes should be registered statically with the Extension Manager.
// See: scratch-vm/src/extension-support/extension-manager.js
class SomeBlocks {
constructor (runtime) {
/**
* Store this for later communication with the Scratch VM runtime.
* If this extension is running in a sandbox then `runtime` is an async proxy object.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* @return {object} This extension's metadata.
*/
getInfo () {
return {
// Required: the machine-readable name of this extension.
// Will be used as the extension's namespace.
id: 'someBlocks',
// Core extensions only: override the default extension block colors.
color1: '#FF8C1A',
color2: '#DB6E00',
// Optional: the human-readable name of this extension as string.
// This and any other string to be displayed in the Scratch UI may either be
// a string or a call to `formatMessage`; a plain string will not be
// translated whereas a call to `formatMessage` will connect the string
// to the translation map (see below). The `formatMessage` call is
// similar to `formatMessage` from `react-intl` in form, but will actually
// call some extension support code to do its magic. For example, we will
// internally namespace the messages such that two extensions could have
// messages with the same ID without colliding.
// See also: https://github.com/yahoo/react-intl/wiki/API#formatmessage
name: formatMessage({
id: 'extensionName',
defaultMessage: 'Some Blocks',
description: 'The name of the "Some Blocks" extension'
}),
// Optional: URI for a block icon, to display at the edge of each block for this
// extension. Data URI OK.
// TODO: what file types are OK? All web images? Just PNG?
blockIconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DEUIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==',
// Optional: URI for an icon to be displayed in the blocks category menu.
// If not present, the menu will display the block icon, if one is present.
// Otherwise, the category menu shows its default filled circle.
// Data URI OK.
// TODO: what file types are OK? All web images? Just PNG?
menuIconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DEUIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==',
// Optional: Link to documentation content for this extension.
// If not present, offer no link.
docsURI: 'https://....',
// Required: the list of blocks implemented by this extension,
// in the order intended for display.
blocks: [
{
// Required: the machine-readable name of this operation.
// This will appear in project JSON.
opcode: 'myReporter', // becomes 'someBlocks.myReporter'
// Required: the kind of block we're defining, from a predefined list.
// Fully supported block types:
// BlockType.BOOLEAN - same as REPORTER but returns a Boolean value
// BlockType.COMMAND - a normal command block, like "move {} steps"
// BlockType.HAT - starts a stack if its value changes from falsy to truthy ("edge triggered")
// BlockType.REPORTER - returns a value, like "direction"
// Block types in development or for internal use only:
// BlockType.BUTTON - place a button in the block palette
// BlockType.CONDITIONAL - control flow, like "if {}" or "if {} else {}"
// A CONDITIONAL block may return the one-based index of a branch to
// run, or it may return zero/falsy to run no branch.
// BlockType.EVENT - starts a stack in response to an event (full spec TBD)
// BlockType.LOOP - control flow, like "repeat {} {}" or "forever {}"
// A LOOP block is like a CONDITIONAL block with two differences:
// - the block is assumed to have exactly one child branch, and
// - each time a child branch finishes, the loop block is called again.
blockType: BlockType.REPORTER,
// Required for CONDITIONAL blocks, ignored for others: the number of
// child branches this block controls. An "if" or "repeat" block would
// specify a branch count of 1; an "if-else" block would specify a
// branch count of 2.
// TODO: should we support dynamic branch count for "switch"-likes?
branchCount: 0,
// Optional, default false: whether or not this block ends a stack.
// The "forever" and "stop all" blocks would specify true here.
terminal: true,
// Optional, default false: whether or not to block all threads while
// this block is busy. This is for things like the "touching color"
// block in compatibility mode, and is only needed if the VM runs in a
// worker. We might even consider omitting it from extension docs...
blockAllThreads: false,
// Required: the human-readable text on this block, including argument
// placeholders. Argument placeholders should be in [MACRO_CASE] and
// must be [ENCLOSED_WITHIN_SQUARE_BRACKETS].
text: formatMessage({
id: 'myReporter',
defaultMessage: 'letter [LETTER_NUM] of [TEXT]',
description: 'Label on the "myReporter" block'
}),
// Required: describe each argument.
// Argument order may change during translation, so arguments are
// identified by their placeholder name. In those situations where
// arguments must be ordered or assigned an ordinal, such as interaction
// with Scratch Blocks, arguments are ordered as they are in the default
// translation (probably English).
arguments: {
// Required: the ID of the argument, which will be the name in the
// args object passed to the implementation function.
LETTER_NUM: {
// Required: type of the argument / shape of the block input
type: ArgumentType.NUMBER,
// Optional: the default value of the argument
default: 1
},
// Required: the ID of the argument, which will be the name in the
// args object passed to the implementation function.
TEXT: {
// Required: type of the argument / shape of the block input
type: ArgumentType.STRING,
// Optional: the default value of the argument
default: formatMessage({
id: 'myReporter.TEXT_default',
defaultMessage: 'text',
description: 'Default for "TEXT" argument of "someBlocks.myReporter"'
})
}
},
// Optional: the function implementing this block.
// If absent, assume `func` is the same as `opcode`.
func: 'myReporter',
// Optional: list of target types for which this block should appear.
// If absent, assume it applies to all builtin targets -- that is:
// [TargetType.SPRITE, TargetType.STAGE]
filter: [TargetType.SPRITE]
},
{
// Another block...
}
],
// Optional: define extension-specific menus here.
menus: {
// Required: an identifier for this menu, unique within this extension.
menuA: [
// Static menu: list items which should appear in the menu.
{
// Required: the value of the menu item when it is chosen.
value: 'itemId1',
// Optional: the human-readable label for this item.
// Use `value` as the text if this is absent.
text: formatMessage({
id: 'menuA_item1',
defaultMessage: 'Item One',
description: 'Label for item 1 of menu A in "Some Blocks" extension'
})
},
// The simplest form of a list item is a string which will be used as
// both value and text.
'itemId2'
],
// Dynamic menu: returns an array as above.
// Called each time the menu is opened.
menuB: 'getItemsForMenuB',
// The examples above are shorthand for setting only the `items` property in this full form:
menuC: {
// This flag makes a "droppable" menu: the menu will allow dropping a reporter in for the input.
acceptReporters: true,
// The `item` property may be an array or function name as in previous menu examples.
items: [/*...*/] || 'getItemsForMenuC'
}
},
// Optional: translations (UNSTABLE - NOT YET SUPPORTED)
translation_map: {
de: {
'extensionName': 'Einige Blöcke',
'myReporter': 'Buchstabe [LETTER_NUM] von [TEXT]',
'myReporter.TEXT_default': 'Text',
'menuA_item1': 'Artikel eins',
// Dynamic menus can be translated too
'menuB_example': 'Beispiel',
// This message contains ICU placeholders (see `myReporter()` below)
'myReporter.result': 'Buchstabe {LETTER_NUM} von {TEXT} ist {LETTER}.'
},
it: {
// ...
}
}
};
};
/**
* Implement myReporter.
* @param {object} args - the block's arguments.
* @property {string} MY_ARG - the string value of the argument.
* @returns {string} a string which includes the block argument value.
*/
myReporter (args) {
// This message contains ICU placeholders, not Scratch placeholders
const message = formatMessage({
id: 'myReporter.result',
defaultMessage: 'Letter {LETTER_NUM} of {TEXT} is {LETTER}.',
description: 'The text template for the "myReporter" block result'
});
// Note: this implementation is not Unicode-clean; it's just here as an example.
const result = args.TEXT.charAt(args.LETTER_NUM);
return message.format({
LETTER_NUM: args.LETTER_NUM,
TEXT: args.TEXT,
LETTER: result
});
};
}
```

5551
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -29,25 +29,23 @@
"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",
"canvas-toBlob": "1.0.0",
"decode-html": "2.0.0",
"diff-match-patch": "1.0.4",
"escape-html": "1.0.3",
"format-message": "6.2.1",
"htmlparser2": "3.10.0",
"immutable": "3.8.1",
"jszip": "^3.1.5",
"minilog": "3.1.0",
"nets": "3.2.0",
"scratch-parser": "4.3.4",
"scratch-sb1-converter": "0.2.6",
"scratch-translate-extension-languages": "0.0.20181205140428",
"socket.io-client": "2.0.4",
"text-encoding": "0.6.4",
"scratch-parser": "5.0.0",
"scratch-sb1-converter": "0.2.7",
"scratch-translate-extension-languages": "0.0.20191118205314",
"text-encoding": "0.7.0",
"worker-loader": "^1.1.1"
},
"devDependencies": {
@ -56,6 +54,7 @@
"adm-zip": "0.4.11",
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.4",
"callsite": "^1.0.0",
"copy-webpack-plugin": "^4.5.4",
"docdash": "^1.0.0",
"eslint": "^5.3.0",
@ -67,7 +66,7 @@
"in-publish": "^2.0.0",
"jsdoc": "^3.5.5",
"json": "^9.0.4",
"lodash.defaultsdeep": "4.6.0",
"lodash.defaultsdeep": "4.6.1",
"pngjs": "^3.3.2",
"scratch-audio": "latest",
"scratch-blocks": "latest",
@ -79,6 +78,7 @@
"stats.js": "^0.17.0",
"tap": "^12.0.1",
"tiny-worker": "^2.1.1",
"uglifyjs-webpack-plugin": "1.2.7",
"webpack": "^4.16.5",
"webpack-cli": "^3.1.0",
"webpack-dev-server": "^3.1.5"

View file

@ -0,0 +1,69 @@
const BlockType = require('../extension-support/block-type');
const ArgumentType = require('../extension-support/argument-type');
/* eslint-disable-next-line max-len */
const blockIconURI = 'data:image/svg+xml,%3Csvg id="rotate-counter-clockwise" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%233d79cc;%7D.cls-2%7Bfill:%23fff;%7D%3C/style%3E%3C/defs%3E%3Ctitle%3Erotate-counter-clockwise%3C/title%3E%3Cpath class="cls-1" d="M22.68,12.2a1.6,1.6,0,0,1-1.27.63H13.72a1.59,1.59,0,0,1-1.16-2.58l1.12-1.41a4.82,4.82,0,0,0-3.14-.77,4.31,4.31,0,0,0-2,.8,4.25,4.25,0,0,0-1.34,1.73,5.06,5.06,0,0,0,.54,4.62A5.58,5.58,0,0,0,12,17.74h0a2.26,2.26,0,0,1-.16,4.52A10.25,10.25,0,0,1,3.74,18,10.14,10.14,0,0,1,2.25,8.78,9.7,9.7,0,0,1,5.08,4.64,9.92,9.92,0,0,1,9.66,2.5a10.66,10.66,0,0,1,7.72,1.68l1.08-1.35a1.57,1.57,0,0,1,1.24-.6,1.6,1.6,0,0,1,1.54,1.21l1.7,7.37A1.57,1.57,0,0,1,22.68,12.2Z"/%3E%3Cpath class="cls-2" d="M21.38,11.83H13.77a.59.59,0,0,1-.43-1l1.75-2.19a5.9,5.9,0,0,0-4.7-1.58,5.07,5.07,0,0,0-4.11,3.17A6,6,0,0,0,7,15.77a6.51,6.51,0,0,0,5,2.92,1.31,1.31,0,0,1-.08,2.62,9.3,9.3,0,0,1-7.35-3.82A9.16,9.16,0,0,1,3.17,9.12,8.51,8.51,0,0,1,5.71,5.4,8.76,8.76,0,0,1,9.82,3.48a9.71,9.71,0,0,1,7.75,2.07l1.67-2.1a.59.59,0,0,1,1,.21L22,11.08A.59.59,0,0,1,21.38,11.83Z"/%3E%3C/svg%3E';
/**
* An example core block implemented using the extension spec.
* This is not loaded as part of the core blocks in the VM but it is provided
* and used as part of tests.
*/
class Scratch3CoreExample {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: 'coreExample',
name: 'CoreEx', // This string does not need to be translated as this extension is only used as an example.
blocks: [
{
func: 'MAKE_A_VARIABLE',
blockType: BlockType.BUTTON,
text: 'make a variable (CoreEx)'
},
{
opcode: 'exampleOpcode',
blockType: BlockType.REPORTER,
text: 'example block'
},
{
opcode: 'exampleWithInlineImage',
blockType: BlockType.COMMAND,
text: 'block with image [CLOCKWISE] inline',
arguments: {
CLOCKWISE: {
type: ArgumentType.IMAGE,
dataURI: blockIconURI
}
}
}
]
};
}
/**
* Example opcode just returns the name of the stage target.
* @returns {string} The name of the first target in the project.
*/
exampleOpcode () {
const stage = this.runtime.getTargetForStage();
return stage ? stage.getName() : 'no stage yet';
}
exampleWithInlineImage () {
return;
}
}
module.exports = Scratch3CoreExample;

View file

@ -135,7 +135,7 @@ class Scratch3DataBlocks {
deleteOfList (args, util) {
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length);
const index = Cast.toListIndex(args.INDEX, list.value.length, true);
if (index === Cast.LIST_INVALID) {
return;
} else if (index === Cast.LIST_ALL) {
@ -157,7 +157,7 @@ class Scratch3DataBlocks {
const item = args.ITEM;
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length + 1);
const index = Cast.toListIndex(args.INDEX, list.value.length + 1, false);
if (index === Cast.LIST_INVALID) {
return;
}
@ -176,18 +176,18 @@ class Scratch3DataBlocks {
const item = args.ITEM;
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length);
const index = Cast.toListIndex(args.INDEX, list.value.length, false);
if (index === Cast.LIST_INVALID) {
return;
}
list.value.splice(index - 1, 1, item);
list.value[index - 1] = item;
list._monitorUpToDate = false;
}
getItemOfList (args, util) {
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length);
const index = Cast.toListIndex(args.INDEX, list.value.length, false);
if (index === Cast.LIST_INVALID) {
return '';
}

View file

@ -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.
@ -488,27 +496,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);
}

View file

@ -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;

View file

@ -119,7 +119,7 @@ class Scratch3OperatorsBlocks {
const n = Cast.toNumber(args.NUM1);
const modulus = Cast.toNumber(args.NUM2);
let result = n % modulus;
// Scratch mod is kept positive.
// Scratch mod uses floored division instead of truncated division.
if (result / modulus < 0) result += modulus;
return result;
}

View file

@ -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);
}

View file

@ -35,6 +35,39 @@ class CentralDispatch extends SharedDispatch {
this.workers = [];
}
/**
* Synchronously call a particular method on a particular service provided locally.
* Calling this function on a remote service will fail.
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {*} - the return value of the service method.
*/
callSync (service, method, ...args) {
const {provider, isRemote} = this._getServiceProvider(service);
if (provider) {
if (isRemote) {
throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`);
}
return provider[method].apply(provider, args);
}
throw new Error(`Provider not found for service: ${service}`);
}
/**
* Synchronously set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
* @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'.
* @param {object} provider - a local object which provides this service.
*/
setServiceSync (service, provider) {
if (this.services.hasOwnProperty(service)) {
log.warn(`Central dispatch replacing existing service provider for ${service}`);
}
this.services[service] = provider;
}
/**
* Set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
@ -45,10 +78,7 @@ class CentralDispatch extends SharedDispatch {
setService (service, provider) {
/** Return a promise for consistency with {@link WorkerDispatch#setService} */
try {
if (this.services.hasOwnProperty(service)) {
log.warn(`Central dispatch replacing existing service provider for ${service}`);
}
this.services[service] = provider;
this.setServiceSync(service, provider);
return Promise.resolve();
} catch (e) {
return Promise.reject(e);

View file

@ -205,9 +205,18 @@ class BlockUtility {
* @return {Array.<Thread>} List of threads started by this function.
*/
startHats (requestedHat, optMatchFields, optTarget) {
return (
this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget)
);
// Store thread and sequencer to ensure we can return to the calling block's context.
// startHats may execute further blocks and dirty the BlockUtility's execution context
// and confuse the calling block when we return to it.
const callerThread = this.thread;
const callerSequencer = this.sequencer;
const result = this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget);
// Restore thread and sequencer to prior values before we return to the calling block.
this.thread = callerThread;
this.sequencer = callerSequencer;
return result;
}
/**

View file

@ -0,0 +1,78 @@
/**
* @fileoverview
* The BlocksRuntimeCache caches data about the top block of scripts so that
* Runtime can iterate a targeted opcode and iterate the returned set faster.
* Many top blocks need to match fields as well as opcode, since that matching
* compares strings in uppercase we can go ahead and uppercase the cached value
* so we don't need to in the future.
*/
/**
* A set of cached data about the top block of a script.
* @param {Blocks} container - Container holding the block and related data
* @param {string} blockId - Id for whose block data is cached in this instance
*/
class RuntimeScriptCache {
constructor (container, blockId) {
/**
* Container with block data for blockId.
* @type {Blocks}
*/
this.container = container;
/**
* ID for block this instance caches.
* @type {string}
*/
this.blockId = blockId;
const block = container.getBlock(blockId);
const fields = container.getFields(block);
/**
* Formatted fields or fields of input blocks ready for comparison in
* runtime.
*
* This is a clone of parts of the targeted blocks. Changes to these
* clones are limited to copies under RuntimeScriptCache and will not
* appear in the original blocks in their container. This copy is
* modified changing the case of strings to uppercase. These uppercase
* values will be compared later by the VM.
* @type {object}
*/
this.fieldsOfInputs = Object.assign({}, fields);
if (Object.keys(fields).length === 0) {
const inputs = container.getInputs(block);
for (const input in inputs) {
if (!inputs.hasOwnProperty(input)) continue;
const id = inputs[input].block;
const inputBlock = container.getBlock(id);
const inputFields = container.getFields(inputBlock);
Object.assign(this.fieldsOfInputs, inputFields);
}
}
for (const key in this.fieldsOfInputs) {
const field = this.fieldsOfInputs[key] = Object.assign({}, this.fieldsOfInputs[key]);
if (field.value.toUpperCase) {
field.value = field.value.toUpperCase();
}
}
}
}
/**
* Get an array of scripts from a block container prefiltered to match opcode.
* @param {Blocks} container - Container of blocks
* @param {string} opcode - Opcode to filter top blocks by
*/
exports.getScripts = function () {
throw new Error('blocks.js has not initialized BlocksRuntimeCache');
};
/**
* Exposed RuntimeScriptCache class used by integration in blocks.js.
* @private
*/
exports._RuntimeScriptCache = RuntimeScriptCache;
require('./blocks');

View file

@ -5,6 +5,7 @@ const MonitorRecord = require('./monitor-record');
const Clone = require('../util/clone');
const {Map} = require('immutable');
const BlocksExecuteCache = require('./blocks-execute-cache');
const BlocksRuntimeCache = require('./blocks-runtime-cache');
const log = require('../util/log');
const Variable = require('./variable');
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id');
@ -74,7 +75,13 @@ class Blocks {
* actively monitored.
* @type {Array<{blockId: string, target: Target}>}
*/
_monitored: null
_monitored: null,
/**
* A cache of hat opcodes to collection of theads to execute.
* @type {object.<string, object>}
*/
scripts: {}
};
/**
@ -509,6 +516,7 @@ class Blocks {
this._cache.procedureDefinitions = {};
this._cache._executeCached = {};
this._cache._monitored = null;
this._cache.scripts = {};
}
/**
@ -735,6 +743,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,
@ -1102,8 +1116,14 @@ class Blocks {
let mutationString = `<${mutation.tagName}`;
for (const prop in mutation) {
if (prop === 'children' || prop === 'tagName') continue;
const mutationValue = (typeof mutation[prop] === 'string') ?
let mutationValue = (typeof mutation[prop] === 'string') ?
xmlEscape(mutation[prop]) : mutation[prop];
// Handle dynamic extension blocks
if (prop === 'blockInfo') {
mutationValue = xmlEscape(JSON.stringify(mutation[prop]));
}
mutationString += ` ${prop}="${mutationValue}"`;
}
mutationString += '>';
@ -1209,4 +1229,35 @@ BlocksExecuteCache.getCached = function (blocks, blockId, CacheType) {
return cached;
};
/**
* Cache class constructor for runtime. Used to consider what threads should
* start based on hat data.
* @type {function}
*/
const RuntimeScriptCache = BlocksRuntimeCache._RuntimeScriptCache;
/**
* Get an array of scripts from a block container prefiltered to match opcode.
* @param {Blocks} blocks - Container of blocks
* @param {string} opcode - Opcode to filter top blocks by
* @returns {Array.<RuntimeScriptCache>} - Array of RuntimeScriptCache cache
* objects
*/
BlocksRuntimeCache.getScripts = function (blocks, opcode) {
let scripts = blocks._cache.scripts[opcode];
if (!scripts) {
scripts = blocks._cache.scripts[opcode] = [];
const allScripts = blocks._scripts;
for (let i = 0; i < allScripts.length; i++) {
const topBlockId = allScripts[i];
const block = blocks.getBlock(topBlockId);
if (block.opcode === opcode) {
scripts.push(new RuntimeScriptCache(blocks, topBlockId));
}
}
}
return scripts;
};
module.exports = Blocks;

View file

@ -191,6 +191,18 @@ class BlockCached {
*/
this.mutation = cached.mutation;
/**
* The profiler the block is configured with.
* @type {?Profiler}
*/
this._profiler = null;
/**
* Profiler information frame.
* @type {?ProfilerFrame}
*/
this._profilerFrame = null;
/**
* Is the opcode a hat (event responder) block.
* @type {boolean}
@ -370,6 +382,25 @@ class BlockCached {
}
}
/**
* Initialize a BlockCached instance so its command/hat
* block and reporters can be profiled during execution.
* @param {Profiler} profiler - The profiler that is currently enabled.
* @param {BlockCached} blockCached - The blockCached instance to profile.
*/
const _prepareBlockProfiling = function (profiler, blockCached) {
blockCached._profiler = profiler;
if (blockFunctionProfilerId === -1) {
blockFunctionProfilerId = profiler.idByName(blockFunctionProfilerFrame);
}
const ops = blockCached._ops;
for (let i = 0; i < ops.length; i++) {
ops[i]._profilerFrame = profiler.frame(blockFunctionProfilerId, ops[i].opcode);
}
};
/**
* Execute a block.
* @param {!Sequencer} sequencer Which sequencer is executing.
@ -466,6 +497,8 @@ const execute = function (sequencer, thread) {
currentStackFrame.reported = null;
}
const start = i;
for (; i < length; i++) {
const lastOperation = i === length - 1;
const opCached = ops[i];
@ -487,27 +520,7 @@ const execute = function (sequencer, thread) {
// Inputs are set during previous steps in the loop.
let primitiveReportedValue = null;
if (runtime.profiler === null) {
primitiveReportedValue = blockFunction(argValues, blockUtility);
} else {
const opcode = opCached.opcode;
if (blockFunctionProfilerId === -1) {
blockFunctionProfilerId = runtime.profiler.idByName(blockFunctionProfilerFrame);
}
// The method commented below has its code inlined
// underneath to reduce the bias recorded for the profiler's
// calls in this time sensitive execute function.
//
// runtime.profiler.start(blockFunctionProfilerId, opcode);
runtime.profiler.records.push(
runtime.profiler.START, blockFunctionProfilerId, opcode, 0);
primitiveReportedValue = blockFunction(argValues, blockUtility);
// runtime.profiler.stop(blockFunctionProfilerId);
runtime.profiler.records.push(runtime.profiler.STOP, 0);
}
const primitiveReportedValue = blockFunction(argValues, blockUtility);
// If it's a promise, wait until promise resolves.
if (isPromise(primitiveReportedValue)) {
@ -558,6 +571,20 @@ const execute = function (sequencer, thread) {
}
}
}
if (runtime.profiler !== null) {
if (blockCached._profiler !== runtime.profiler) {
_prepareBlockProfiling(runtime.profiler, blockCached);
}
// Determine the index that is after the last executed block. `i` is
// currently the block that was just executed. `i + 1` will be the block
// after that. `length` with the min call makes sure we don't try to
// reference an operation outside of the set of operations.
const end = Math.min(i + 1, length);
for (let p = start; p < end; p++) {
ops[p]._profilerFrame.count += 1;
}
}
};
module.exports = execute;

View file

@ -13,6 +13,12 @@ const mutatorTagToObject = function (dom) {
for (const prop in dom.attribs) {
if (prop === 'xmlns') continue;
obj[prop] = decodeHtml(dom.attribs[prop]);
// Note: the capitalization of block info in the following lines is important.
// The lowercase is read in from xml which normalizes case. The VM uses camel case everywhere else.
if (prop === 'blockinfo') {
obj.blockInfo = JSON.parse(obj.blockinfo);
delete obj.blockinfo;
}
}
for (let i = 0; i < dom.children.length; i++) {
obj.children.push(

View file

@ -106,6 +106,12 @@ class ProfilerFrame {
* @type {number}
*/
this.depth = depth;
/**
* A summarized count of the number of calls to this frame.
* @type {number}
*/
this.count = 0;
}
}
@ -126,6 +132,27 @@ class Profiler {
*/
this.records = [];
/**
* An array of frames incremented on demand instead as part of start
* and stop.
* @type {Array.<ProfilerFrame>}
*/
this.increments = [];
/**
* An array of profiler frames separated by counter argument. Generally
* for Scratch these frames are separated by block function opcode.
* This tracks each time an opcode is called.
* @type {Array.<ProfilerFrame>}
*/
this.counters = [];
/**
* A frame with no id or argument.
* @type {ProfilerFrame}
*/
this.nullFrame = new ProfilerFrame(-1);
/**
* A cache of ProfilerFrames to reuse when reporting the recorded
* frames in records.
@ -170,6 +197,41 @@ class Profiler {
this.records.push(STOP, performance.now());
}
/**
* Increment the number of times this symbol is called.
* @param {number} id The id returned by idByName for a name symbol.
*/
increment (id) {
if (!this.increments[id]) {
this.increments[id] = new ProfilerFrame(-1);
this.increments[id].id = id;
}
this.increments[id].count += 1;
}
/**
* Find or create a ProfilerFrame-like object whose counter can be
* incremented outside of the Profiler.
* @param {number} id The id returned by idByName for a name symbol.
* @param {*} arg The argument for a frame that identifies it in addition
* to the id.
* @return {{count: number}} A ProfilerFrame-like whose count should be
* incremented for each call.
*/
frame (id, arg) {
for (let i = 0; i < this.counters.length; i++) {
if (this.counters[i].id === id && this.counters[i].arg === arg) {
return this.counters[i];
}
}
const newCounter = new ProfilerFrame(-1);
newCounter.id = id;
newCounter.arg = arg;
this.counters.push(newCounter);
return newCounter;
}
/**
* Decode records and report all frames to `this.onFrame`.
*/
@ -226,6 +288,9 @@ class Profiler {
// Remove this frames totalTime from the parent's selfTime.
stack[depth - 1].selfTime -= frame.totalTime;
// This frame occured once.
frame.count = 1;
this.onFrame(frame);
i += STOP_SIZE;
@ -235,6 +300,20 @@ class Profiler {
}
}
for (let j = 0; j < this.increments.length; j++) {
if (this.increments[j] && this.increments[j].count > 0) {
this.onFrame(this.increments[j]);
this.increments[j].count = 0;
}
}
for (let k = 0; k < this.counters.length; k++) {
if (this.counters[k].count > 0) {
this.onFrame(this.counters[k]);
this.counters[k].count = 0;
}
}
this.records.length = 0;
}

View file

@ -1,12 +1,13 @@
const EventEmitter = require('events');
const {OrderedMap} = require('immutable');
const escapeHtml = require('escape-html');
const ArgumentType = require('../extension-support/argument-type');
const Blocks = require('./blocks');
const BlocksRuntimeCache = require('./blocks-runtime-cache');
const BlockType = require('../extension-support/block-type');
const Profiler = require('./profiler');
const Sequencer = require('./sequencer');
const execute = require('./execute.js');
const ScratchBlocksConstants = require('./scratch-blocks-constants');
const TargetType = require('../extension-support/target-type');
const Thread = require('./thread');
@ -14,6 +15,8 @@ const log = require('../util/log');
const maybeFormatMessage = require('../util/maybe-format-message');
const StageLayering = require('./stage-layering');
const Variable = require('./variable');
const xmlEscape = require('../util/xml-escape');
const ScratchLinkWebSocket = require('../util/scratch-link-websocket');
// Virtual I/O devices.
const Clock = require('../io/clock');
@ -39,6 +42,8 @@ const defaultBlockPackages = {
scratch3_procedures: require('../blocks/scratch3_procedures')
};
const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69'];
/**
* Information used for converting Scratch argument types into scratch-blocks data.
* @type {object.<ArgumentType, {shadowType: string, fieldType: string}>}
@ -46,30 +51,55 @@ const defaultBlockPackages = {
const ArgumentTypeMap = (() => {
const map = {};
map[ArgumentType.ANGLE] = {
shadowType: 'math_angle',
fieldType: 'NUM'
shadow: {
type: 'math_angle',
// We specify fieldNames here so that we can pick
// create and populate a field with the defaultValue
// specified in the extension.
// When the `fieldName` property is not specified,
// the <field></field> will be left out of the XML and
// the scratch-blocks defaults for that field will be
// used instead (e.g. default of 0 for number fields)
fieldName: 'NUM'
}
};
map[ArgumentType.COLOR] = {
shadowType: 'colour_picker'
shadow: {
type: 'colour_picker',
fieldName: 'COLOUR'
}
};
map[ArgumentType.NUMBER] = {
shadowType: 'math_number',
fieldType: 'NUM'
shadow: {
type: 'math_number',
fieldName: 'NUM'
}
};
map[ArgumentType.STRING] = {
shadowType: 'text',
fieldType: 'TEXT'
shadow: {
type: 'text',
fieldName: 'TEXT'
}
};
map[ArgumentType.BOOLEAN] = {
check: 'Boolean'
};
map[ArgumentType.MATRIX] = {
shadowType: 'matrix',
fieldType: 'MATRIX'
shadow: {
type: 'matrix',
fieldName: 'MATRIX'
}
};
map[ArgumentType.NOTE] = {
shadowType: 'note',
fieldType: 'NOTE'
shadow: {
type: 'note',
fieldName: 'NOTE'
}
};
map[ArgumentType.IMAGE] = {
// Inline images are weird because they're not actually "arguments".
// They are more analagous to the label on a block.
fieldType: 'field_image'
};
return map;
})();
@ -122,16 +152,6 @@ const cloudDataManager = () => {
};
};
/**
* Predefined "Converted block info" for a separator between blocks in a block category
* @type {ConvertedBlockInfo}
*/
const ConvertedSeparator = {
info: {},
json: null,
xml: '<sep gap="36"/>'
};
/**
* Numeric ID for Runtime._step in Profiler instances.
* @type {number}
@ -316,7 +336,7 @@ class Runtime extends EventEmitter {
// I/O related data.
/** @type {Object.<string, Object>} */
this.ioDevices = {
clock: new Clock(),
clock: new Clock(this),
cloud: new Cloud(this),
keyboard: new Keyboard(this),
mouse: new Mouse(this),
@ -516,6 +536,14 @@ class Runtime extends EventEmitter {
return 'PROJECT_CHANGED';
}
/**
* Event name for report that a change was made to an extension in the toolbox.
* @const {string}
*/
static get TOOLBOX_EXTENSIONS_NEED_UPDATE () {
return 'TOOLBOX_EXTENSIONS_NEED_UPDATE';
}
/**
* Event name for targets update report.
* @const {string}
@ -756,7 +784,7 @@ class Runtime extends EventEmitter {
* @private
*/
_makeExtensionMenuId (menuName, extensionId) {
return `${extensionId}_menu_${escapeHtml(menuName)}`;
return `${extensionId}_menu_${xmlEscape(menuName)}`;
}
/**
@ -783,24 +811,26 @@ class Runtime extends EventEmitter {
name: maybeFormatMessage(extensionInfo.name),
showStatusButton: extensionInfo.showStatusButton,
blockIconURI: extensionInfo.blockIconURI,
menuIconURI: extensionInfo.menuIconURI,
color1: extensionInfo.colour || '#0FBD8C',
color2: extensionInfo.colourSecondary || '#0DA57A',
color3: extensionInfo.colourTertiary || '#0B8E69',
customFieldTypes: {},
blocks: [],
menus: []
menuIconURI: extensionInfo.menuIconURI
};
if (extensionInfo.color1) {
categoryInfo.color1 = extensionInfo.color1;
categoryInfo.color2 = extensionInfo.color2;
categoryInfo.color3 = extensionInfo.color3;
} else {
categoryInfo.color1 = defaultExtensionColors[0];
categoryInfo.color2 = defaultExtensionColors[1];
categoryInfo.color3 = defaultExtensionColors[2];
}
this._blockInfo.push(categoryInfo);
this._fillExtensionCategory(categoryInfo, extensionInfo);
const fieldTypeDefinitionsForScratch = [];
for (const fieldTypeName in categoryInfo.customFieldTypes) {
if (extensionInfo.customFieldTypes.hasOwnProperty(fieldTypeName)) {
const fieldTypeInfo = categoryInfo.customFieldTypes[fieldTypeName];
fieldTypeDefinitionsForScratch.push(fieldTypeInfo.scratchBlocksDefinition);
// Emit events for custom field types from extension
this.emit(Runtime.EXTENSION_FIELD_ADDED, {
@ -810,9 +840,7 @@ class Runtime extends EventEmitter {
}
}
const allBlocks = fieldTypeDefinitionsForScratch.concat(categoryInfo.blocks).concat(categoryInfo.menus);
this.emit(Runtime.EXTENSION_ADDED, allBlocks);
this.emit(Runtime.EXTENSION_ADDED, categoryInfo);
}
/**
@ -821,33 +849,34 @@ class Runtime extends EventEmitter {
* @private
*/
_refreshExtensionPrimitives (extensionInfo) {
let extensionBlocks = [];
for (const categoryInfo of this._blockInfo) {
if (extensionInfo.id === categoryInfo.id) {
categoryInfo.name = maybeFormatMessage(extensionInfo.name);
categoryInfo.blocks = [];
categoryInfo.menus = [];
this._fillExtensionCategory(categoryInfo, extensionInfo);
extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus);
}
}
const categoryInfo = this._blockInfo.find(info => info.id === extensionInfo.id);
if (categoryInfo) {
categoryInfo.name = maybeFormatMessage(extensionInfo.name);
this._fillExtensionCategory(categoryInfo, extensionInfo);
this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks);
this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo);
}
}
/**
* Read extension information, convert menus, blocks and custom field types
* Read extension information, convert menus, blocks and custom field types
* and store the results in the provided category object.
* @param {CategoryInfo} categoryInfo - the category to be filled
* @param {ExtensionMetadata} extensionInfo - the extension metadata to read
* @private
*/
_fillExtensionCategory (categoryInfo, extensionInfo) {
categoryInfo.blocks = [];
categoryInfo.customFieldTypes = {};
categoryInfo.menus = [];
categoryInfo.menuInfo = {};
for (const menuName in extensionInfo.menus) {
if (extensionInfo.menus.hasOwnProperty(menuName)) {
const menuItems = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
const menuInfo = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuInfo, categoryInfo);
categoryInfo.menus.push(convertedMenu);
categoryInfo.menuInfo[menuName] = menuInfo;
}
}
for (const fieldTypeName in extensionInfo.customFieldTypes) {
@ -865,22 +894,20 @@ class Runtime extends EventEmitter {
}
for (const blockInfo of extensionInfo.blocks) {
if (blockInfo === '---') {
categoryInfo.blocks.push(ConvertedSeparator);
continue;
}
try {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
const opcode = convertedBlock.json.type;
categoryInfo.blocks.push(convertedBlock);
if (blockInfo.blockType !== BlockType.EVENT) {
this._primitives[opcode] = convertedBlock.info.func;
}
if (blockInfo.blockType === BlockType.EVENT || blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {
edgeActivated: blockInfo.isEdgeActivated,
restartExistingThreads: blockInfo.shouldRestartExistingThreads
};
if (convertedBlock.json) {
const opcode = convertedBlock.json.type;
if (blockInfo.blockType !== BlockType.EVENT) {
this._primitives[opcode] = convertedBlock.info.func;
}
if (blockInfo.blockType === BlockType.EVENT || blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {
edgeActivated: blockInfo.isEdgeActivated,
restartExistingThreads: blockInfo.shouldRestartExistingThreads
};
}
}
} catch (e) {
log.error('Error parsing block: ', {block: blockInfo, error: e});
@ -889,21 +916,16 @@ class Runtime extends EventEmitter {
}
/**
* Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block.
* @param {string} menuName - the name of the menu
* @param {array} menuItems - the list of items for this menu
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {object} - a JSON-esque object ready for scratch-blocks' consumption
* Convert the given extension menu items into the scratch-blocks style of list of pairs.
* If the menu is dynamic (e.g. the passed in argument is a function), return the input unmodified.
* @param {object} menuItems - an array of menu items or a function to retrieve such an array
* @returns {object} - an array of 2 element arrays or the original input function
* @private
*/
_buildMenuForScratchBlocks (menuName, menuItems, categoryInfo) {
const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id);
let options = null;
if (typeof menuItems === 'function') {
options = menuItems;
} else {
_convertMenuItems (menuItems) {
if (typeof menuItems !== 'function') {
const extensionMessageContext = this.makeMessageContextForTarget();
options = menuItems.map(item => {
return menuItems.map(item => {
const formattedItem = maybeFormatMessage(item, extensionMessageContext);
switch (typeof formattedItem) {
case 'string':
@ -915,6 +937,22 @@ class Runtime extends EventEmitter {
}
});
}
return menuItems;
}
/**
* Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block.
* @param {string} menuName - the name of the menu
* @param {object} menuInfo - a description of this menu and its items
* @property {*} items - an array of menu items or a function to retrieve such an array
* @property {boolean} [acceptReporters] - if true, allow dropping reporters onto this menu
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {object} - a JSON-esque object ready for scratch-blocks' consumption
* @private
*/
_buildMenuForScratchBlocks (menuName, menuInfo, categoryInfo) {
const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id);
const menuItems = this._convertMenuItems(menuInfo.items);
return {
json: {
message0: '%1',
@ -924,12 +962,13 @@ class Runtime extends EventEmitter {
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
outputShape: ScratchBlocksConstants.OUTPUT_SHAPE_ROUND,
outputShape: menuInfo.acceptReporters ?
ScratchBlocksConstants.OUTPUT_SHAPE_ROUND : ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE,
args0: [
{
type: 'field_dropdown',
name: menuName,
options: options
options: menuItems
}
]
}
@ -942,8 +981,10 @@ class Runtime extends EventEmitter {
fieldName: fieldName,
extendedName: extendedName,
argumentTypeInfo: {
shadowType: extendedName,
fieldType: `field_${extendedName}`
shadow: {
type: extendedName,
fieldName: `field_${extendedName}`
}
},
scratchBlocksDefinition: this._buildCustomFieldTypeForScratchBlocks(
extendedName,
@ -985,6 +1026,25 @@ class Runtime extends EventEmitter {
};
}
/**
* Convert ExtensionBlockMetadata into data ready for scratch-blocks.
* @param {ExtensionBlockMetadata} blockInfo - the block info to convert
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {ConvertedBlockInfo} - the converted & original block information
* @private
*/
_convertForScratchBlocks (blockInfo, categoryInfo) {
if (blockInfo === '---') {
return this._convertSeparatorForScratchBlocks(blockInfo);
}
if (blockInfo.blockType === BlockType.BUTTON) {
return this._convertButtonForScratchBlocks(blockInfo);
}
return this._convertBlockForScratchBlocks(blockInfo, categoryInfo);
}
/**
* Convert ExtensionBlockMetadata into scratch-blocks JSON & XML, and generate a proxy function.
* @param {ExtensionBlockMetadata} blockInfo - the block to convert
@ -992,7 +1052,7 @@ class Runtime extends EventEmitter {
* @returns {ConvertedBlockInfo} - the converted & original block information
* @private
*/
_convertForScratchBlocks (blockInfo, categoryInfo) {
_convertBlockForScratchBlocks (blockInfo, categoryInfo) {
const extendedOpcode = `${categoryInfo.id}_${blockInfo.opcode}`;
const blockJSON = {
@ -1001,8 +1061,7 @@ class Runtime extends EventEmitter {
category: categoryInfo.name,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
extensions: ['scratch_extension']
colourTertiary: categoryInfo.color3
};
const context = {
// TODO: store this somewhere so that we can map args appropriately after translation.
@ -1022,6 +1081,7 @@ class Runtime extends EventEmitter {
const iconURI = blockInfo.blockIconURI || categoryInfo.blockIconURI;
if (iconURI) {
blockJSON.extensions = ['scratch_extension'];
blockJSON.message0 = '%1 %2';
const iconJSON = {
type: 'field_image',
@ -1119,13 +1179,15 @@ class Runtime extends EventEmitter {
src: './static/blocks-media/repeat.svg', // TODO: use a constant or make this configurable?
width: 24,
height: 24,
alt: '*',
alt: '*', // TODO remove this since we don't use collapsed blocks in scratch
flip_rtl: true
}];
++outLineNum;
}
const blockXML = `<block type="${extendedOpcode}">${context.inputList.join('')}</block>`;
const mutation = blockInfo.isDynamic ? `<mutation blockInfo="${xmlEscape(JSON.stringify(blockInfo))}"/>` : '';
const inputs = context.inputList.join('');
const blockXML = `<block type="${extendedOpcode}">${mutation}${inputs}</block>`;
return {
info: context.blockInfo,
@ -1134,6 +1196,66 @@ class Runtime extends EventEmitter {
};
}
/**
* Generate a separator between blocks categories or sub-categories.
* @param {ExtensionBlockMetadata} blockInfo - the block to convert
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {ConvertedBlockInfo} - the converted & original block information
* @private
*/
_convertSeparatorForScratchBlocks (blockInfo) {
return {
info: blockInfo,
xml: '<sep gap="36"/>'
};
}
/**
* Convert a button for scratch-blocks. A button has no opcode but specifies a callback name in the `func` field.
* @param {ExtensionBlockMetadata} buttonInfo - the button to convert
* @property {string} func - the callback name
* @param {CategoryInfo} categoryInfo - the category for this button
* @returns {ConvertedBlockInfo} - the converted & original button information
* @private
*/
_convertButtonForScratchBlocks (buttonInfo) {
// for now we only support these pre-defined callbacks handled in scratch-blocks
const supportedCallbackKeys = ['MAKE_A_LIST', 'MAKE_A_PROCEDURE', 'MAKE_A_VARIABLE'];
if (supportedCallbackKeys.indexOf(buttonInfo.func) < 0) {
log.error(`Custom button callbacks not supported yet: ${buttonInfo.func}`);
}
const extensionMessageContext = this.makeMessageContextForTarget();
const buttonText = maybeFormatMessage(buttonInfo.text, extensionMessageContext);
return {
info: buttonInfo,
xml: `<button text="${buttonText}" callbackKey="${buttonInfo.func}"></button>`
};
}
/**
* Helper for _convertPlaceholdes which handles inline images which are a specialized case of block "arguments".
* @param {object} argInfo Metadata about the inline image as specified by the extension
* @return {object} JSON blob for a scratch-blocks image field.
* @private
*/
_constructInlineImageJson (argInfo) {
if (!argInfo.dataURI) {
log.warn('Missing data URI in extension block with argument type IMAGE');
}
return {
type: 'field_image',
src: argInfo.dataURI || '',
// TODO these probably shouldn't be hardcoded...?
width: 24,
height: 24,
// Whether or not the inline image should be flipped horizontally
// in RTL languages. Defaults to false, indicating that the
// image will not be flipped.
flip_rtl: argInfo.flipRTL || false
};
}
/**
* Helper for _convertForScratchBlocks which handles linearization of argument placeholders. Called as a callback
* from string#replace. In addition to the return value the JSON and XML items in the context will be filled.
@ -1147,11 +1269,7 @@ class Runtime extends EventEmitter {
// Sanitize the placeholder to ensure valid XML
placeholder = placeholder.replace(/[<"&]/, '_');
const argJSON = {
type: 'input_value',
name: placeholder
};
// Determine whether the argument type is one of the known standard field types
const argInfo = context.blockInfo.arguments[placeholder] || {};
let argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
@ -1160,41 +1278,85 @@ class Runtime extends EventEmitter {
argTypeInfo = context.categoryInfo.customFieldTypes[argInfo.type].argumentTypeInfo;
}
const defaultValue =
typeof argInfo.defaultValue === 'undefined' ? '' :
escapeHtml(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString());
// Start to construct the scratch-blocks style JSON defining how the block should be
// laid out
let argJSON;
if (argTypeInfo.check) {
argJSON.check = argTypeInfo.check;
}
// Most field types are inputs (slots on the block that can have other blocks plugged into them)
// check if this is not one of those cases. E.g. an inline image on a block.
if (argTypeInfo.fieldType === 'field_image') {
argJSON = this._constructInlineImageJson(argInfo);
} else {
// Construct input value
const shadowType = (argInfo.menu ?
this._makeExtensionMenuId(argInfo.menu, context.categoryInfo.id) :
argTypeInfo.shadowType);
const fieldType = argInfo.menu || argTypeInfo.fieldType;
// Layout a block argument (e.g. an input slot on the block)
argJSON = {
type: 'input_value',
name: placeholder
};
// <value> is the ScratchBlocks name for a block input.
context.inputList.push(`<value name="${placeholder}">`);
const defaultValue =
typeof argInfo.defaultValue === 'undefined' ? '' :
xmlEscape(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString());
// The <shadow> is a placeholder for a reporter and is visible when there's no reporter in this input.
// Boolean inputs don't need to specify a shadow in the XML.
if (shadowType) {
context.inputList.push(`<shadow type="${shadowType}">`);
// <field> is a text field that the user can type into. Some shadows, like the color picker, don't allow
// text input and therefore don't need a field element.
if (fieldType) {
context.inputList.push(`<field name="${fieldType}">${defaultValue}</field>`);
if (argTypeInfo.check) {
// Right now the only type of 'check' we have specifies that the
// input slot on the block accepts Boolean reporters, so it should be
// shaped like a hexagon
argJSON.check = argTypeInfo.check;
}
context.inputList.push('</shadow>');
}
let valueName;
let shadowType;
let fieldName;
if (argInfo.menu) {
const menuInfo = context.categoryInfo.menuInfo[argInfo.menu];
if (menuInfo.acceptReporters) {
valueName = placeholder;
shadowType = this._makeExtensionMenuId(argInfo.menu, context.categoryInfo.id);
fieldName = argInfo.menu;
} else {
argJSON.type = 'field_dropdown';
argJSON.options = this._convertMenuItems(menuInfo.items);
valueName = null;
shadowType = null;
fieldName = placeholder;
}
} else {
valueName = placeholder;
shadowType = (argTypeInfo.shadow && argTypeInfo.shadow.type) || null;
fieldName = (argTypeInfo.shadow && argTypeInfo.shadow.fieldName) || null;
}
context.inputList.push('</value>');
// <value> is the ScratchBlocks name for a block input.
if (valueName) {
context.inputList.push(`<value name="${placeholder}">`);
}
// The <shadow> is a placeholder for a reporter and is visible when there's no reporter in this input.
// Boolean inputs don't need to specify a shadow in the XML.
if (shadowType) {
context.inputList.push(`<shadow type="${shadowType}">`);
}
// A <field> displays a dynamic value: a user-editable text field, a drop-down menu, etc.
// Leave out the field if defaultValue or fieldName are not specified
if (defaultValue && fieldName) {
context.inputList.push(`<field name="${fieldName}">${defaultValue}</field>`);
}
if (shadowType) {
context.inputList.push('</shadow>');
}
if (valueName) {
context.inputList.push('</value>');
}
}
const argsName = `args${context.outLineNum}`;
const blockArgs = (context.blockJSON[argsName] = context.blockJSON[argsName] || []);
blockArgs.push(argJSON);
if (argJSON) blockArgs.push(argJSON);
const argNum = blockArgs.length;
context.argsMap[placeholder] = argNum;
@ -1202,11 +1364,12 @@ class Runtime extends EventEmitter {
}
/**
* @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in <category> elements.
* @returns {Array.<object>} scratch-blocks XML for each category of extension blocks, in category order.
* @property {string} id - the category / extension ID
* @property {string} xml - the XML text for this category, starting with `<category>` and ending with `</category>`
*/
getBlocksXML () {
const xmlParts = [];
for (const categoryInfo of this._blockInfo) {
return this._blockInfo.map(categoryInfo => {
const {name, color1, color2} = categoryInfo;
const paletteBlocks = categoryInfo.blocks.filter(block => !block.info.hideFromPalette);
const colorXML = `colour="${color1}" secondaryColour="${color2}"`;
@ -1227,12 +1390,12 @@ class Runtime extends EventEmitter {
statusButtonXML = 'showStatusButton="true"';
}
xmlParts.push(`<category name="${name}" id="${categoryInfo.id}"
${statusButtonXML} ${colorXML} ${menuIconXML}>`);
xmlParts.push.apply(xmlParts, paletteBlocks.map(block => block.xml));
xmlParts.push('</category>');
}
return xmlParts.join('\n');
return {
id: categoryInfo.id,
xml: `<category name="${name}" id="${categoryInfo.id}" ${statusButtonXML} ${colorXML} ${menuIconXML}>${
paletteBlocks.map(block => block.xml).join('')}</category>`
};
});
}
/**
@ -1243,6 +1406,34 @@ class Runtime extends EventEmitter {
(result, categoryInfo) => result.concat(categoryInfo.blocks.map(blockInfo => blockInfo.json)), []);
}
/**
* Get a scratch link socket.
* @param {string} type Either BLE or BT
* @returns {ScratchLinkSocket} The scratch link socket.
*/
getScratchLinkSocket (type) {
const factory = this._linkSocketFactory || this._defaultScratchLinkSocketFactory;
return factory(type);
}
/**
* Configure how ScratchLink sockets are created. Factory must consume a "type" parameter
* either BT or BLE.
* @param {Function} factory The new factory for creating ScratchLink sockets.
*/
configureScratchLinkSocketFactory (factory) {
this._linkSocketFactory = factory;
}
/**
* The default scratch link socket creator, using websockets to the installed device manager.
* @param {string} type Either BLE or BT
* @returns {ScratchLinkSocket} The new scratch link socket (a WebSocket object)
*/
_defaultScratchLinkSocketFactory (type) {
return new ScratchLinkWebSocket(type);
}
/**
* Register an extension that communications with a hardware peripheral by id,
* to have access to it and its peripheral functions in the future.
@ -1389,16 +1580,11 @@ class Runtime extends EventEmitter {
* @return {!Thread} The newly created thread.
*/
_pushThread (id, target, opts) {
opts = Object.assign({
stackClick: false,
updateMonitor: false
}, opts);
const thread = new Thread(id);
thread.target = target;
thread.stackClick = opts.stackClick;
thread.updateMonitor = opts.updateMonitor;
thread.blockContainer = opts.updateMonitor ?
thread.stackClick = Boolean(opts && opts.stackClick);
thread.updateMonitor = Boolean(opts && opts.updateMonitor);
thread.blockContainer = thread.updateMonitor ?
this.monitorBlocks :
target.blocks;
@ -1541,6 +1727,20 @@ class Runtime extends EventEmitter {
}
}
allScriptsByOpcodeDo (opcode, f, optTarget) {
let targets = this.executableTargets;
if (optTarget) {
targets = [optTarget];
}
for (let t = targets.length - 1; t >= 0; t--) {
const target = targets[t];
const scripts = BlocksRuntimeCache.getScripts(target.blocks, opcode);
for (let j = 0; j < scripts.length; j++) {
f(scripts[j], target);
}
}
}
/**
* Start all relevant hats.
* @param {!string} requestedHatOpcode Opcode of hats to start.
@ -1556,6 +1756,8 @@ class Runtime extends EventEmitter {
}
const instance = this;
const newThreads = [];
// Look up metadata for the relevant hat.
const hatMeta = instance._hats[requestedHatOpcode];
for (const opts in optMatchFields) {
if (!optMatchFields.hasOwnProperty(opts)) continue;
@ -1563,74 +1765,59 @@ class Runtime extends EventEmitter {
}
// Consider all scripts, looking for hats with opcode `requestedHatOpcode`.
this.allScriptsDo((topBlockId, target) => {
const blocks = target.blocks;
const block = blocks.getBlock(topBlockId);
const potentialHatOpcode = block.opcode;
if (potentialHatOpcode !== requestedHatOpcode) {
// Not the right hat.
return;
}
this.allScriptsByOpcodeDo(requestedHatOpcode, (script, target) => {
const {
blockId: topBlockId,
fieldsOfInputs: hatFields
} = script;
// Match any requested fields.
// For example: ensures that broadcasts match.
// This needs to happen before the block is evaluated
// (i.e., before the predicate can be run) because "broadcast and wait"
// needs to have a precise collection of started threads.
let hatFields = blocks.getFields(block);
// If no fields are present, check inputs (horizontal blocks)
if (Object.keys(hatFields).length === 0) {
hatFields = {}; // don't overwrite the block's actual fields list
const hatInputs = blocks.getInputs(block);
for (const input in hatInputs) {
if (!hatInputs.hasOwnProperty(input)) continue;
const id = hatInputs[input].block;
const inpBlock = blocks.getBlock(id);
const fields = blocks.getFields(inpBlock);
Object.assign(hatFields, fields);
for (const matchField in optMatchFields) {
if (hatFields[matchField].value !== optMatchFields[matchField]) {
// Field mismatch.
return;
}
}
if (optMatchFields) {
for (const matchField in optMatchFields) {
if (hatFields[matchField].value.toUpperCase() !==
optMatchFields[matchField]) {
// Field mismatch.
return;
}
}
}
// Look up metadata for the relevant hat.
const hatMeta = instance._hats[requestedHatOpcode];
if (hatMeta.restartExistingThreads) {
// If `restartExistingThreads` is true, we should stop
// any existing threads starting with the top block.
for (let i = 0; i < instance.threads.length; i++) {
if (instance.threads[i].topBlock === topBlockId &&
!instance.threads[i].stackClick && // stack click threads and hat threads can coexist
instance.threads[i].target === target) {
newThreads.push(instance._restartThread(instance.threads[i]));
for (let i = 0; i < this.threads.length; i++) {
if (this.threads[i].target === target &&
this.threads[i].topBlock === topBlockId &&
// stack click threads and hat threads can coexist
!this.threads[i].stackClick) {
newThreads.push(this._restartThread(this.threads[i]));
return;
}
}
} else {
// If `restartExistingThreads` is false, we should
// give up if any threads with the top block are running.
for (let j = 0; j < instance.threads.length; j++) {
if (instance.threads[j].topBlock === topBlockId &&
instance.threads[j].target === target &&
!instance.threads[j].stackClick && // stack click threads and hat threads can coexist
instance.threads[j].status !== Thread.STATUS_DONE) {
for (let j = 0; j < this.threads.length; j++) {
if (this.threads[j].target === target &&
this.threads[j].topBlock === topBlockId &&
// stack click threads and hat threads can coexist
!this.threads[j].stackClick &&
this.threads[j].status !== Thread.STATUS_DONE) {
// Some thread is already running.
return;
}
}
}
// Start the thread with this top block.
newThreads.push(instance._pushThread(topBlockId, target));
newThreads.push(this._pushThread(topBlockId, target));
}, optTarget);
// For compatibility with Scratch 2, edge triggered hats need to be processed before
// threads are stepped. See ScratchRuntime.as for original implementation
newThreads.forEach(thread => {
execute(this.sequencer, thread);
thread.goToNextBlock();
});
return newThreads;
}
@ -1802,8 +1989,12 @@ class Runtime extends EventEmitter {
}
}
this.targets = newTargets;
// Dispose all threads.
this.threads.forEach(thread => this._stopThread(thread));
// Dispose of the active thread.
if (this.sequencer.activeThread !== null) {
this._stopThread(this.sequencer.activeThread);
}
// Remove all remaining threads from executing in the next tick.
this.threads = [];
}
/**
@ -1906,10 +2097,15 @@ class Runtime extends EventEmitter {
* @param {!Target} editingTarget New editing target.
*/
setEditingTarget (editingTarget) {
const oldEditingTarget = this._editingTarget;
this._editingTarget = editingTarget;
// Script glows must be cleared.
this._scriptGlowsPreviousFrame = [];
this._updateGlows();
if (oldEditingTarget !== this._editingTarget) {
this.requestToolboxExtensionsUpdate();
}
}
/**
@ -2327,12 +2523,19 @@ class Runtime extends EventEmitter {
}
/**
* Emit an event that indicate that the blocks on the workspace need updating.
* Emit an event that indicates that the blocks on the workspace need updating.
*/
requestBlocksUpdate () {
this.emit(Runtime.BLOCKS_NEED_UPDATE);
}
/**
* Emit an event that indicates that the toolbox extension blocks need updating.
*/
requestToolboxExtensionsUpdate () {
this.emit(Runtime.TOOLBOX_EXTENSIONS_NEED_UPDATE);
}
/**
* Set up timers to repeatedly step in a browser.
*/

View file

@ -51,6 +51,8 @@ class Sequencer {
* @type {!Runtime}
*/
this.runtime = runtime;
this.activeThread = null;
}
/**
@ -97,8 +99,9 @@ class Sequencer {
numActiveThreads = 0;
let stoppedThread = false;
// Attempt to run each thread one time.
for (let i = 0; i < this.runtime.threads.length; i++) {
const activeThread = this.runtime.threads[i];
const threads = this.runtime.threads;
for (let i = 0; i < threads.length; i++) {
const activeThread = this.activeThread = threads[i];
// Check if the thread is done so it is not executed.
if (activeThread.stack.length === 0 ||
activeThread.status === Thread.STATUS_DONE) {
@ -118,12 +121,11 @@ class Sequencer {
if (stepThreadProfilerId === -1) {
stepThreadProfilerId = this.runtime.profiler.idByName(stepThreadProfilerFrame);
}
this.runtime.profiler.start(stepThreadProfilerId);
// Increment the number of times stepThread is called.
this.runtime.profiler.increment(stepThreadProfilerId);
}
this.stepThread(activeThread);
if (this.runtime.profiler !== null) {
this.runtime.profiler.stop();
}
activeThread.warpTimer = null;
if (activeThread.isKilled) {
i--; // if the thread is removed from the list (killed), do not increase index
@ -138,7 +140,6 @@ class Sequencer {
activeThread.status === Thread.STATUS_DONE) {
// Finished with this thread.
stoppedThread = true;
this.runtime.updateCurrentMSecs();
}
}
// We successfully ticked once. Prevents running STATUS_YIELD_TICK
@ -166,6 +167,8 @@ class Sequencer {
}
}
this.activeThread = null;
return doneThreads;
}
@ -178,6 +181,12 @@ class Sequencer {
if (!currentBlockId) {
// A "null block" - empty branch.
thread.popStack();
// Did the null follow a hat block?
if (thread.stack.length === 0) {
thread.status = Thread.STATUS_DONE;
return;
}
}
// Save the current block ID to notice if we did control flow.
while ((currentBlockId = thread.peekStack())) {
@ -193,23 +202,15 @@ class Sequencer {
if (executeProfilerId === -1) {
executeProfilerId = this.runtime.profiler.idByName(executeProfilerFrame);
}
// The method commented below has its code inlined underneath to
// reduce the bias recorded for the profiler's calls in this
// time sensitive stepThread method.
//
// this.runtime.profiler.start(executeProfilerId, null);
this.runtime.profiler.records.push(
this.runtime.profiler.START, executeProfilerId, null, 0);
// Increment the number of times execute is called.
this.runtime.profiler.increment(executeProfilerId);
}
if (thread.target === null) {
this.retireThread(thread);
} else {
execute(this, thread);
}
if (this.runtime.profiler !== null) {
// this.runtime.profiler.stop();
this.runtime.profiler.records.push(this.runtime.profiler.STOP, 0);
}
thread.blockGlowInFrame = currentBlockId;
// If the thread has yielded or is waiting, yield to other threads.
if (thread.status === Thread.STATUS_YIELD) {

View file

@ -326,7 +326,7 @@ class Target extends EventEmitter {
blocks.changeBlock({
id: id,
element: 'field',
name: 'VARIABLE',
name: variable.type === 'list' ? 'LIST' : 'VARIABLE',
value: id
}, this.runtime);
const monitorBlock = blocks.getBlock(variable.id);

View file

@ -36,7 +36,12 @@ const ArgumentType = {
/**
* MIDI note number with note picker (piano) field
*/
NOTE: 'note'
NOTE: 'note',
/**
* Inline image on block (as part of the label)
*/
IMAGE: 'image'
};
module.exports = ArgumentType;

View file

@ -8,6 +8,11 @@ const BlockType = {
*/
BOOLEAN: 'Boolean',
/**
* A button (not an actual block) for some special action, like making a variable
*/
BUTTON: 'button',
/**
* Command block
*/

View file

@ -7,32 +7,23 @@ 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');
// 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
// gdxfor: Scratch3GdxForBlocks
// This is an example that isn't loaded with the other core blocks,
// but serves as a reference for loading core blocks as extensions.
coreExample: () => require('../blocks/scratch3_core_example'),
// These are the non-core built-in extensions.
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'),
ev3: () => require('../extensions/scratch3_ev3'),
makeymakey: () => require('../extensions/scratch3_makeymakey'),
boost: () => require('../extensions/scratch3_boost'),
gdxfor: () => require('../extensions/scratch3_gdx_for')
};
/**
@ -119,6 +110,30 @@ class ExtensionManager {
return this._loadedExtensions.has(extensionID);
}
/**
* Synchronously load an internal extension (core or non-core) by ID. This call will
* fail if the provided id is not does not match an internal extension.
* @param {string} extensionId - the ID of an internal extension
*/
loadExtensionIdSync (extensionId) {
if (!builtinExtensions.hasOwnProperty(extensionId)) {
log.warn(`Could not find extension ${extensionId} in the built in extensions.`);
return;
}
/** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */
if (this.isExtensionLoaded(extensionId)) {
const message = `Rejecting attempt to load a second extension with ID ${extensionId}`;
log.warn(message);
return;
}
const extension = builtinExtensions[extensionId]();
const extensionInstance = new extension(this.runtime);
const serviceName = this._registerInternalExtension(extensionInstance);
this._loadedExtensions.set(extensionId, serviceName);
}
/**
* Load an extension by URL or internal extension ID
* @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension
@ -130,14 +145,14 @@ class ExtensionManager {
if (this.isExtensionLoaded(extensionURL)) {
const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`;
log.warn(message);
return Promise.reject(new Error(message));
return Promise.resolve();
}
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);
});
const serviceName = this._registerInternalExtension(extensionInstance);
this._loadedExtensions.set(extensionURL, serviceName);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
@ -161,7 +176,7 @@ class ExtensionManager {
dispatch.call('runtime', '_refreshExtensionPrimitives', info);
})
.catch(e => {
log.error(`Failed to refresh buildtin extension primitives: ${JSON.stringify(e)}`);
log.error(`Failed to refresh built-in extension primitives: ${JSON.stringify(e)}`);
})
);
return Promise.all(allPromises);
@ -174,6 +189,15 @@ class ExtensionManager {
return [id, workerInfo.extensionURL];
}
/**
* Synchronously collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension.
*/
registerExtensionServiceSync (serviceName) {
const info = dispatch.callSync(serviceName, 'getInfo');
this._registerExtensionInfo(serviceName, info);
}
/**
* Collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension.
@ -202,17 +226,15 @@ class ExtensionManager {
/**
* Register an internal (non-Worker) extension object
* @param {object} extensionObject - the extension object to register
* @returns {Promise} resolved once the extension is fully registered or rejected on failure
* @returns {string} The name of the registered extension service
*/
_registerInternalExtension (extensionObject) {
const extensionInfo = extensionObject.getInfo();
const fakeWorkerId = this.nextExtensionWorker++;
const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`;
return dispatch.setService(serviceName, extensionObject)
.then(() => {
dispatch.call('extensions', 'registerExtensionService', serviceName);
return serviceName;
});
dispatch.setServiceSync(serviceName, extensionObject);
dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName);
return serviceName;
}
/**
@ -272,7 +294,7 @@ class ExtensionManager {
}
return results;
}, []);
extensionInfo.menus = extensionInfo.menus || [];
extensionInfo.menus = extensionInfo.menus || {};
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus);
return extensionInfo;
}
@ -287,15 +309,24 @@ class ExtensionManager {
_prepareMenuInfo (serviceName, menus) {
const menuNames = Object.getOwnPropertyNames(menus);
for (let i = 0; i < menuNames.length; i++) {
const item = menuNames[i];
// If the value is a string, it should be the name of a function in the
// extension object to call to populate the menu whenever it is opened.
// Set up the binding for the function object here so
// we can use it later when converting the menu for Scratch Blocks.
if (typeof menus[item] === 'string') {
const menuName = menuNames[i];
let menuInfo = menus[menuName];
// If the menu description is in short form (items only) then normalize it to general form: an object with
// its items listed in an `items` property.
if (!menuInfo.items) {
menuInfo = {
items: menuInfo
};
menus[menuName] = menuInfo;
}
// If `items` is a string, it should be the name of a function in the extension object. Calling the
// function should return an array of items to populate the menu when it is opened.
if (typeof menuInfo.items === 'string') {
const menuItemFunctionName = menuInfo.items;
const serviceObject = dispatch.services[serviceName];
const menuName = menus[item];
menus[item] = this._getExtensionMenuItems.bind(this, serviceObject, menuName);
// Bind the function here so we can pass a simple item generation function to Scratch Blocks later.
menuInfo.items = this._getExtensionMenuItems.bind(this, serviceObject, menuItemFunctionName);
}
}
return menus;
@ -304,11 +335,11 @@ class ExtensionManager {
/**
* Fetch the items for a particular extension menu, providing the target ID for context.
* @param {object} extensionObject - the extension object providing the menu.
* @param {string} menuName - the name of the menu function to call.
* @param {string} menuItemFunctionName - the name of the menu function to call.
* @returns {Array} menu items ready for scratch-blocks.
* @private
*/
_getExtensionMenuItems (extensionObject, menuName) {
_getExtensionMenuItems (extensionObject, menuItemFunctionName) {
// Fetch the items appropriate for the target currently being edited. This assumes that menus only
// collect items when opened by the user while editing a particular target.
const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage();
@ -316,7 +347,7 @@ class ExtensionManager {
const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget);
// TODO: Fix this to use dispatch.call when extensions are running in workers.
const menuFunc = extensionObject[menuName];
const menuFunc = extensionObject[menuItemFunctionName];
const menuItems = menuFunc.call(extensionObject, editingTargetID).map(
item => {
item = maybeFormatMessage(item, extensionMessageContext);
@ -334,7 +365,7 @@ class ExtensionManager {
});
if (!menuItems || menuItems.length < 1) {
throw new Error(`Extension menu returned no items: ${menuName}`);
throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`);
}
return menuItems;
}
@ -353,29 +384,53 @@ class ExtensionManager {
blockAllThreads: false,
arguments: {}
}, blockInfo);
blockInfo.opcode = this._sanitizeID(blockInfo.opcode);
blockInfo.opcode = blockInfo.opcode && this._sanitizeID(blockInfo.opcode);
blockInfo.text = blockInfo.text || blockInfo.opcode;
if (blockInfo.blockType !== BlockType.EVENT) {
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
/**
* This is only here because the VM performs poorly when blocks return promises.
* @TODO make it possible for the VM to resolve a promise and continue during the same Scratch "tick"
*/
if (dispatch._isRemoteService(serviceName)) {
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
} else {
const serviceObject = dispatch.services[serviceName];
const func = serviceObject[blockInfo.func];
if (func) {
blockInfo.func = func.bind(serviceObject);
} else if (blockInfo.blockType !== BlockType.EVENT) {
throw new Error(`Could not find extension block function called ${blockInfo.func}`);
}
switch (blockInfo.blockType) {
case BlockType.EVENT:
if (blockInfo.func) {
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`);
}
} else if (blockInfo.func) {
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`);
break;
case BlockType.BUTTON:
if (blockInfo.opcode) {
log.warn(`Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}`);
}
break;
default: {
if (!blockInfo.opcode) {
throw new Error('Missing opcode for block');
}
const funcName = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
const getBlockInfo = blockInfo.isDynamic ?
args => args && args.mutation && args.mutation.blockInfo :
() => blockInfo;
const callBlockFunc = (() => {
if (dispatch._isRemoteService(serviceName)) {
return (args, util, realBlockInfo) =>
dispatch.call(serviceName, funcName, args, util, realBlockInfo);
}
// avoid promise latency if we can call direct
const serviceObject = dispatch.services[serviceName];
if (!serviceObject[funcName]) {
// The function might show up later as a dynamic property of the service object
log.warn(`Could not find extension block function called ${funcName}`);
}
return (args, util, realBlockInfo) =>
serviceObject[funcName](args, util, realBlockInfo);
})();
blockInfo.func = (args, util) => {
const realBlockInfo = getBlockInfo(args);
// TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed?
return callBlockFunc(args, util, realBlockInfo);
};
break;
}
}
return blockInfo;

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -12,7 +12,14 @@ const ScratchLinkDeviceAdapter = require('./scratch-link-device-adapter');
* @type {string}
*/
// eslint-disable-next-line max-len
const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOCAuNSkiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PHBhdGggZD0iTTEyIDM5LjVBMi41IDIuNSAwIDAgMSA5LjUgMzdjMC0uMy4yLS41LjUtLjVzLjUuMi41LjVhMS41IDEuNSAwIDEgMCAzIDB2LS4yYzAtLjQtLjItLjgtLjUtMWwtLjgtLjljLS41LS40LS43LTEtLjctMS43VjMxYzAtLjMuMi0uNS41LS41cy41LjIuNS41djIuMmMwIC40LjEuOC40IDFsLjguOWMuNS40LjggMSAuOCAxLjd2LjJjMCAxLjQtMS4xIDIuNS0yLjUgMi41eiIgZmlsbD0iI0U2RTdFOCIvPjxwYXRoIGQ9Ik0yMy43LjNBMSAxIDAgMCAwIDIzIDBIMWExIDEgMCAwIDAtLjcuM0ExIDEgMCAwIDAgMCAxdjI2YzAgLjMuMS41LjMuNy4yLjIuNC4zLjcuM2gyMmMuMyAwIC41LS4xLjctLjMuMi0uMi4zLS40LjMtLjdWMWExIDEgMCAwIDAtLjMtLjd6TTEyIDRjMiAwIDMuMyAyIDIuNiAzLjhMMTMuMyAxMWExLjQgMS40IDAgMCAxLTIuNyAwTDkuNSA3LjdsLS4yLTFDOS4yIDUuNCAxMC40IDQgMTIgNHoiIHN0cm9rZT0iIzdDODdBNSIgZmlsbD0iIzg1OTJBRiIgZmlsbC1ydWxlPSJub256ZXJvIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMiAydjI0aDIwVjJIMnptMTAgMmMyIDAgMy4zIDIgMi42IDMuOEwxMy4zIDExYTEuNCAxLjQgMCAwIDEtMi43IDBMOS41IDcuN2wtLjItMUM5LjIgNS40IDEwLjQgNCAxMiA0eiIgc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjNUNCMUQ2IiBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIHN0cm9rZT0iIzdDODdBNSIgZmlsbD0iIzg1OTJBRiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBkPSJNMjIgMjZIMnYtNmwyMC00eiIvPjxwYXRoIGQ9Ik0uMyAyNy43TDIgMjZNLjMuM0wyIDJNMjIgMkwyMy43LjNNMjMuNyAyNy43TDIyIDI2IiBzdHJva2U9IiM3Qzg3QTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxjaXJjbGUgZmlsbD0iI0ZGQkYwMCIgY3g9IjEyIiBjeT0iMTQuOCIgcj0iMS4yIi8+PHBhdGggc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjRTZFN0U4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xMCAyOGg0djRoLTR6Ii8+PHBhdGggZD0iTTE1LjUgMjJoLTdhLjUuNSAwIDAgMS0uNS0uNWMwLS4zLjItLjUuNS0uNWg3Yy4zIDAgLjUuMi41LjVzLS4yLjUtLjUuNXpNMTcuNSAyNGgtMTFhLjUuNSAwIDAgMS0uNS0uNWMwLS4zLjItLjUuNS0uNWgxMWMuMyAwIC41LjIuNS41cy0uMi41LS41LjV6IiBmaWxsPSIjRkZCRjAwIi8+PC9nPjwvc3ZnPg==';
const blockIconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAABGdBTUEAALGPC/xhBQAACCNJREFUeAHtnGtsFFUUgM+dfXbbbbcWaKHSFgrlkWgkJCb6A4kmJfiHIBYBpcFfRg1GEkmEVAvhFYw/TExMxGoICAECiZEIIUQCiiT4gh+KILRQCi2ENIV2t/ue6zl3u2Upu4XuzO4csCe587iPmXO/OWfunTszV4ABWfflQU+0p+9bTcLzEmS5gUPlvagAcVMXcMpnK1u+evW8QLYKaNkWpHKxnt6dQsqFjxo80p10Jt1vx7t30n62Ys+2IJUTUpDlqUNomgYutwsjhZFD5r6slBAOhUHX9YTe6D1GTmrIAhFeBZ2c4JFCpBiggmwlBR7pTGLUewxZYBIUWV7yqgb7g8lotuukt5ihqyELHCSEbusk931ExMxbjSkWSNxEyr3vysxZLFHWnDuT0CtFV6OKmmOBRrV4hMubZoGmMZA6lHTfgsLeHnBEIiCxUY86XRDw+sBfOgZ0m820U5lxIFYAncF+GNvVDo5QaLBu1ClyYTyF4tvd8lZltQgXFA6mW73BxoVt0ShUXG2VCp4QQdDEFqez4Bm7p7gaO0of422r3x4Ji/KrbdIexu4SE2FjgWO6OkCLx6gt6gxOiNV92tiY+ni1Ye1nu7dpQfk35ikru9EBN6unsEDIwgLJPQv8dwCfT3WPt+iFIfAUqM3vL7vpjmuz0KX1gkAfOMN33dxKkjwA9vsTDIS8uubdBZcyAWlqWtohQbRSuru/L1O2vMazAGiLxRKVFqDgDEdAaHCN0kU8Ply2vKWxABhzJZ5ipC6qHlRzfJxVz99S49GdYQEw7PYkuAmokZJ6fumlQUqiNpVSQ56i9JnyHMsCYMRdADGHk0ZyHM1b976XicH0rXtWYR57FPNSGQ7CAiCBCJQ8oXhI0FdmBiPfVnl9ZZmz5DmFDcA+HwIUOEYMcjL2+e57PbBp04HxONI4ifIEKC8TYQMwhs+7IU+hwBFOYQvB5qF8grbwJnRfQXnIhbkIG4AExF+ScE00w0X3AZLwisrDyH1JH1YAA8UlIG029FRZsu6TPfVJiIltWYIjMTLgLUlGs1izeRYmGtS383t9wnu7G2J6fH/Tln2LNUdExGLxvZSOQ1qCS/+P9CFhBZAUuj12PHgCvRJHZ7w4EnhYjya6hXGHQ2Jaxj4ilbVC2AFEUNBVXSdKb3WC29+rmISKiqFn7ARBadyEHUACFHM64VZlDTdWafVh1Yik1ZB5JEsLJGaVtosw37ld4TscWQHX4+oRWO1zWrAEWCR6oMnTCEXijmI1234MVvsPgV+WcmKndGHpwlNtZwbhkZYEkuI4CkuAXfpk0HGAPym0TXEchaUL39Br4JvQeljk+lwxOxBeCRQ3UrFHI+AMBsEV6gcnhlwIS4BU0RORV1V42EqnwnLgSyo3AsM3eA9bPOt8bAEOV6NUWGRZ9FYvHSx6R0pfYgkMmk2DCH1+Z7KwB5gKazjLGgpLgUOAuRZWALnDSncxLAOYCmskbqjhe02h5d6y0sFKF5cXgI8LrLwB9PTeGew6POwNnptlpYOVLi4nFjjuWts957rnBk8tomoZ+bjhPcqOcCcnAG34EaTqOjxmsNKxzQnAkX5wronsOry6zIn66ThljLNcg+W1a2Gi55+MCg6XcKl3NuxrbxouS87TLAcY1V0QV5+8jLyuEekeeSGTS1gOcM/lZpOrlN/DsRzOyi8CY2fLuwUum/wR1BT+ZUzrDKUv9D4LB9rXZEjNTfRjZYFS5r86ebfA3W0bcmMKFh01/5fMoorm6rSjAA2SNc2F8dvmQVWCgdy8fxg8gcEN0pWez80QUyyQFAqn/N9mhmK5PAYN7adecCPnMsUCCZ7U8ari4IGb87wJeKFDA/MlmHXBDVkgTR1CV4/gaThKzBoeKYpuSzqSrqSzEiFuJDayWxqyQJp3RUhYSKfWUSEz5iDIrhrZl8I5b37JvrTBT3wdpd43cOqT/WiJhq6ikQpkW5a8BxuS/X219uXZHoPKmdMUGdEgpWzTll3Kr95Z8VJK7N3NL7b/qHY2rnmdjd6G7oF3q/b/3RoFaPDajwIcBWiQgMHioxZoEKChfqDBc2csnmxtM2ZglMDKArFvduhBbLDv9sOD8oymA0xBCHVtl6+c7ey6Ibdt+3ox7WOoxMCmD4i68PrZkBQaEDUe1tnVqSyyfl79+vr6evz1C2jKogkYWEEc0JnViiZRqKuoqJiZtEJcn0GIsykewzhW2jJVZjzBamxsfK79ase/5MoXL106TnEDwfq36qgIF6HGjKyqFsNkDGMwUNxEDEmIHQTxyNGjH1AchvumBcC4vAuXVpiA+TDYMFDXiiZFoN+SrmMI7tixo/v3337diNtQUzNpPq1RChIra5ccAFKDUEwYLra2fnXu3PmtA0gojqbaVUNl23ft+pPiPW73U7RGYdGH5QCQYCg93C73075S34I5c+ZQa0s/B1Njou51tVVVatJAXcrED3Q4EI5plgsHgAQiSiRCoRD9ECeam9fPo32UJzFQYwJLlix9mdZ9fb1naY2iyiQ2rVtyAEi199Pi5M8/tdB62vRpzceOH3+toaHBh61w2clTp96sqq5ehUnxw0eO7KA8KKpMYtO6JZcOKTUeNRhsp0+ffmtilYI1VLf4+Qvn1784d+5ezEfW144hMR05blglpDgHSbqxt6Wl5Y8ZM6afKq8oL7LZHd54PH7H7w+cOPj9dx8uXbLk+ICynbhm4cJDr7LVMKmhoP5dphaWoFGrHMTAQrgBJCjkFdQHpPntqCUmiWCge14PBsvdFnUYlP8AMAKfKIKmYukAAAAASUVORK5CYII=';
/**
* Icon png to be displayed in the blocks category menu, encoded as a data URI.
* @type {string}
*/
// eslint-disable-next-line max-len
const menuIconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAA9dJREFUWAnNmE2IFEcUgF/9dE/v7LoaM9kkK4JBRA0EFBIPRm85hBAvEXHXwyo5eFE87GFcReMkObgJiQnkkJzEg9n8HIJixKNe1IMKihgiCbviwV11V3d0d3pmuqsqr5ppcEnb3TNVggVFVVe9eu+r97qqq4tASqp8/fsboQgmU0TMugi571K29bPy9ovPU8Sf16HbpQj3EkYFBcJcr5Am2nZfs94AIWVfqMQeHNwhICUBZ4ypUIA/X2sbIm2AW8AJK0lkEP6TJpfqwXgg4QxmF/fB7Gtvxk1G5ZKHU1CqTgPJoSUXYJYeohSUJu+qrqdVUGh2/pVX4VFffx77WaqBZkrkEFj271+qWH0sXcU3FBzyQe/Mg7B//LbKMTRTxNiDbsMHHjTJlyM7HEJIBHXs2KXFj+oTNSdoQOCYLS5jD9IwBMm5H8NplwwPb/QV4yEIcycaAza9IuA76B38fuz1OF5RXUkmHCdu6rg0BpSMgV/sAe7DdzGFrvvdi0D3mSZjQA0wt7REQsY+iWF0XbfFzyal8SLRxuteD+Du4h4Z/flbqaBHibAQtZmQtcZaAZSMwtTylaR/4vaw1ju5YhWG10pwwAqghmp2FeHO2+t11WqyM80W0m7vAOhsM1kD7CGz8L57Jsq6bitZC/GcWgLf1H6KuHT92cTDAFy/BgXMXm0OCpgV50Bo9kK3BqiBboabQMMU/WoL5im4jToeq/AIgXsiRx5KKCjcwPEsiAv/BQMu9EwyDHXd/3kqCOSzDk6t5/YglQKKeJwq+PNRmJI8kwSTaj1HZy5AhSHqnXkIvU9mMUwEw4Q5wTM57LUtkg8QPw/cdcBJ+PhvKJ0Gj80nGq6JXrg6/XFiX97GXIBpyqTieKpKViOl+WEhWXMaUavvvdIZ8Giy5+Lh3bwKm/t+Be3JazMfxc1tldY26rastiHcsQevTG9pw0znovkAcRWHzSDKnZtaOJLSfMFLB5RqtRBS4LbCurqLCy0YPkU3C0IIPEimMqR2ei7ZX2+KQdRi/WahNT/GmfOD4Vyzhx/66pcjp85dUvcmp6J8+txldXh07PPskdkS+V6EbD0vTOKlB0x9B/O6BS8ULly9PgE6x4kDPR/XX5pyYKj8xcCucsUmkNUQE0JvKKm2VioVK5HRE7UKOHbi6B94RzP+93jtpC0vWgXUF0hr3ipuw8uadwd3jXxoA9IK4Pah8t6BneV9GgjD28Svw1mlxFobgFbeFTz13cKbth93fDryp2CEq0a4hTA+aAPQ/ESJFDdvXLzzzrqNjlTqOP6uDeFf0uhvJ0ZP2QD8D6ZzU6u8YIbBAAAAAElFTkSuQmCC';
/**
* Enum for Vernier godirect protocol.
@ -53,7 +60,7 @@ const GDXFOR_SENSOR = {
/**
* The update rate, in milliseconds, for sensor data input from the peripheral.
*/
const GDXFOR_UPDATE_RATE = 100;
const GDXFOR_UPDATE_RATE = 80;
/**
* Threshold for pushing and pulling force, for the whenForcePushedOrPulled hat block.
@ -61,12 +68,6 @@ const GDXFOR_UPDATE_RATE = 100;
*/
const FORCE_THRESHOLD = 5;
/**
* Threshold for acceleration magnitude, for the "moved" gesture.
* @type {number}
*/
const MOVED_THRESHOLD = 3;
/**
* Threshold for acceleration magnitude, for the "shaken" gesture.
* @type {number}
@ -79,6 +80,12 @@ const SHAKEN_THRESHOLD = 30;
*/
const FACING_THRESHOLD = 9;
/**
* An offset for the facing threshold, used to check that we are no longer facing up.
* @type {number}
*/
const FACING_THRESHOLD_OFFSET = 5;
/**
* Threshold for acceleration magnitude, below which we are in freefall.
* @type {number}
@ -91,6 +98,12 @@ const FREEFALL_THRESHOLD = 0.5;
*/
const FREEFALL_ROTATION_FACTOR = 0.3;
/**
* Threshold in degrees for reporting that the sensor is tilted.
* @type {number}
*/
const TILT_THRESHOLD = 15;
/**
* Acceleration due to gravity, in m/s^2.
* @type {number}
@ -121,7 +134,7 @@ class GdxFor {
* @type {BLE}
* @private
*/
this._scratchLinkSocket = null;
this._ble = null;
/**
* An @vernier/godirect Device
@ -159,7 +172,7 @@ class GdxFor {
*/
this._timeoutID = null;
this.disconnect = this.disconnect.bind(this);
this.reset = this.reset.bind(this);
this._onConnect = this._onConnect.bind(this);
}
@ -168,18 +181,18 @@ class GdxFor {
* Called by the runtime when user wants to scan for a peripheral.
*/
scan () {
if (this._scratchLinkSocket) {
this._scratchLinkSocket.disconnect();
if (this._ble) {
this._ble.disconnect();
}
this._scratchLinkSocket = new BLE(this._runtime, this._extensionId, {
this._ble = new BLE(this._runtime, this._extensionId, {
filters: [
{namePrefix: 'GDX-FOR'}
],
optionalServices: [
BLEUUID.service
]
}, this._onConnect, this.disconnect);
}, this._onConnect, this.reset);
}
/**
@ -187,8 +200,8 @@ class GdxFor {
* @param {number} id - the id of the peripheral to connect to.
*/
connect (id) {
if (this._scratchLinkSocket) {
this._scratchLinkSocket.connectPeripheral(id);
if (this._ble) {
this._ble.connectPeripheral(id);
}
}
@ -197,7 +210,17 @@ class GdxFor {
* Disconnect from the GDX FOR.
*/
disconnect () {
window.clearInterval(this._timeoutID);
if (this._ble) {
this._ble.disconnect();
}
this.reset();
}
/**
* Reset all the state and timeout/interval ids.
*/
reset () {
this._sensors = {
force: 0,
accelerationX: 0,
@ -207,8 +230,10 @@ class GdxFor {
spinSpeedY: 0,
spinSpeedZ: 0
};
if (this._scratchLinkSocket) {
this._scratchLinkSocket.disconnect();
if (this._timeoutID) {
window.clearInterval(this._timeoutID);
this._timeoutID = null;
}
}
@ -218,8 +243,8 @@ class GdxFor {
*/
isConnected () {
let connected = false;
if (this._scratchLinkSocket) {
connected = this._scratchLinkSocket.isConnected();
if (this._ble) {
connected = this._ble.isConnected();
}
return connected;
}
@ -229,7 +254,7 @@ class GdxFor {
* @private
*/
_onConnect () {
const adapter = new ScratchLinkDeviceAdapter(this._scratchLinkSocket, BLEUUID);
const adapter = new ScratchLinkDeviceAdapter(this._ble, BLEUUID);
godirect.createDevice(adapter, {open: true, startMeasurements: false}).then(device => {
// Setup device
this._device = device;
@ -249,7 +274,7 @@ class GdxFor {
});
});
this._timeoutID = window.setInterval(
() => this._scratchLinkSocket.handleDisconnectError(BLEDataStoppedError),
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
});
@ -293,7 +318,7 @@ class GdxFor {
// cancel disconnect timeout and start a new one
window.clearInterval(this._timeoutID);
this._timeoutID = window.setInterval(
() => this._scratchLinkSocket.handleDisconnectError(BLEDataStoppedError),
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
}
@ -409,9 +434,10 @@ const PushPullValues = {
* @enum {string}
*/
const GestureValues = {
MOVED: 'moved',
SHAKEN: 'shaken',
STARTED_FALLING: 'started falling'
STARTED_FALLING: 'started falling',
TURNED_FACE_UP: 'turned face up',
TURNED_FACE_DOWN: 'turned face down'
};
/**
@ -423,7 +449,8 @@ const TiltAxisValues = {
FRONT: 'front',
BACK: 'back',
LEFT: 'left',
RIGHT: 'right'
RIGHT: 'right',
ANY: 'any'
};
/**
@ -437,16 +464,6 @@ const AxisValues = {
Z: 'z'
};
/**
* Enum for face menu options.
* @readonly
* @enum {string}
*/
const FaceValues = {
UP: 'up',
DOWN: 'down'
};
/**
* Scratch 3.0 blocks to interact with a GDX-FOR peripheral.
*/
@ -520,23 +537,16 @@ class Scratch3GdxForBlocks {
];
}
get FACE_MENU () {
get TILT_MENU_ANY () {
return [
...this.TILT_MENU,
{
text: formatMessage({
id: 'gdxfor.up',
default: 'up',
description: 'the sensor is facing up'
id: 'gdxfor.tiltDirectionMenu.any',
default: 'any',
description: 'label for any direction element in tilt direction picker for gdxfor extension'
}),
value: FaceValues.UP
},
{
text: formatMessage({
id: 'gdxfor.down',
default: 'down',
description: 'the sensor is facing down'
}),
value: FaceValues.DOWN
value: TiltAxisValues.ANY
}
];
}
@ -564,14 +574,6 @@ class Scratch3GdxForBlocks {
get GESTURE_MENU () {
return [
{
text: formatMessage({
id: 'gdxfor.moved',
default: 'moved',
description: 'the sensor was moved'
}),
value: GestureValues.MOVED
},
{
text: formatMessage({
id: 'gdxfor.shaken',
@ -587,6 +589,22 @@ class Scratch3GdxForBlocks {
description: 'the sensor started free falling'
}),
value: GestureValues.STARTED_FALLING
},
{
text: formatMessage({
id: 'gdxfor.turnedFaceUp',
default: 'turned face up',
description: 'the sensor was turned to face up'
}),
value: GestureValues.TURNED_FACE_UP
},
{
text: formatMessage({
id: 'gdxfor.turnedFaceDown',
default: 'turned face down',
description: 'the sensor was turned to face down'
}),
value: GestureValues.TURNED_FACE_DOWN
}
];
}
@ -614,8 +632,25 @@ class Scratch3GdxForBlocks {
id: Scratch3GdxForBlocks.EXTENSION_ID,
name: Scratch3GdxForBlocks.EXTENSION_NAME,
blockIconURI: blockIconURI,
menuIconURI: menuIconURI,
showStatusButton: true,
blocks: [
{
opcode: 'whenGesture',
text: formatMessage({
id: 'gdxfor.whenGesture',
default: 'when [GESTURE]',
description: 'when the sensor detects a gesture'
}),
blockType: BlockType.HAT,
arguments: {
GESTURE: {
type: ArgumentType.STRING,
menu: 'gestureOptions',
defaultValue: GestureValues.SHAKEN
}
}
},
{
opcode: 'whenForcePushedOrPulled',
text: formatMessage({
@ -643,18 +678,34 @@ class Scratch3GdxForBlocks {
},
'---',
{
opcode: 'whenGesture',
opcode: 'whenTilted',
text: formatMessage({
id: 'gdxfor.whenGesture',
default: 'when [GESTURE]',
description: 'when the sensor detects a gesture'
id: 'gdxfor.whenTilted',
default: 'when tilted [TILT]',
description: 'when the sensor detects tilt'
}),
blockType: BlockType.HAT,
arguments: {
GESTURE: {
TILT: {
type: ArgumentType.STRING,
menu: 'gestureOptions',
defaultValue: GestureValues.MOVED
menu: 'tiltAnyOptions',
defaultValue: TiltAxisValues.ANY
}
}
},
{
opcode: 'isTilted',
text: formatMessage({
id: 'gdxfor.isTilted',
default: 'tilted [TILT]?',
description: 'is the device tilted?'
}),
blockType: BlockType.BOOLEAN,
arguments: {
TILT: {
type: ArgumentType.STRING,
menu: 'tiltAnyOptions',
defaultValue: TiltAxisValues.ANY
}
}
},
@ -662,7 +713,7 @@ class Scratch3GdxForBlocks {
opcode: 'getTilt',
text: formatMessage({
id: 'gdxfor.getTilt',
default: 'tilt [TILT]',
default: 'tilt angle [TILT]',
description: 'gets tilt'
}),
blockType: BlockType.REPORTER,
@ -674,11 +725,21 @@ class Scratch3GdxForBlocks {
}
}
},
'---',
{
opcode: 'isFreeFalling',
text: formatMessage({
id: 'gdxfor.isFreeFalling',
default: 'falling?',
description: 'is the device in free fall?'
}),
blockType: BlockType.BOOLEAN
},
{
opcode: 'getSpinSpeed',
text: formatMessage({
id: 'gdxfor.getSpin',
default: 'spin [DIRECTION]',
default: 'spin speed [DIRECTION]',
description: 'gets spin speed'
}),
blockType: BlockType.REPORTER,
@ -705,41 +766,29 @@ class Scratch3GdxForBlocks {
defaultValue: AxisValues.X
}
}
},
'---',
{
opcode: 'isFacing',
text: formatMessage({
id: 'gdxfor.isFacing',
default: 'facing [FACING]?',
description: 'is the device facing up or down?'
}),
blockType: BlockType.BOOLEAN,
arguments: {
FACING: {
type: ArgumentType.STRING,
menu: 'faceOptions',
defaultValue: FaceValues.UP
}
}
},
{
opcode: 'isFreeFalling',
text: formatMessage({
id: 'gdxfor.isFreeFalling',
default: 'falling?',
description: 'is the device in free fall?'
}),
blockType: BlockType.BOOLEAN
}
],
menus: {
pushPullOptions: this.PUSH_PULL_MENU,
gestureOptions: this.GESTURE_MENU,
axisOptions: this.AXIS_MENU,
tiltOptions: this.TILT_MENU,
faceOptions: this.FACE_MENU
pushPullOptions: {
acceptReporters: true,
items: this.PUSH_PULL_MENU
},
gestureOptions: {
acceptReporters: true,
items: this.GESTURE_MENU
},
axisOptions: {
acceptReporters: true,
items: this.AXIS_MENU
},
tiltOptions: {
acceptReporters: true,
items: this.TILT_MENU
},
tiltAnyOptions: {
acceptReporters: true,
items: this.TILT_MENU_ANY
}
}
};
}
@ -762,36 +811,91 @@ class Scratch3GdxForBlocks {
whenGesture (args) {
switch (args.GESTURE) {
case GestureValues.MOVED:
return this.gestureMagnitude() > MOVED_THRESHOLD;
case GestureValues.SHAKEN:
return this.gestureMagnitude() > SHAKEN_THRESHOLD;
case GestureValues.STARTED_FALLING:
return this.isFreeFalling();
case GestureValues.TURNED_FACE_UP:
return this._isFacing(GestureValues.TURNED_FACE_UP);
case GestureValues.TURNED_FACE_DOWN:
return this._isFacing(GestureValues.TURNED_FACE_DOWN);
default:
log.warn(`unknown gesture value in whenGesture: ${args.GESTURE}`);
return false;
}
}
_isFacing (direction) {
if (typeof this._facingUp === 'undefined') {
this._facingUp = false;
}
if (typeof this._facingDown === 'undefined') {
this._facingDown = false;
}
// If the sensor is already facing up or down, reduce the threshold.
// This prevents small fluctations in acceleration while it is being
// turned from causing the hat block to trigger multiple times.
let threshold = FACING_THRESHOLD;
if (this._facingUp || this._facingDown) {
threshold -= FACING_THRESHOLD_OFFSET;
}
this._facingUp = this._peripheral.getAccelerationZ() > threshold;
this._facingDown = this._peripheral.getAccelerationZ() < threshold * -1;
switch (direction) {
case GestureValues.TURNED_FACE_UP:
return this._facingUp;
case GestureValues.TURNED_FACE_DOWN:
return this._facingDown;
default:
return false;
}
}
whenTilted (args) {
return this._isTilted(args.TILT);
}
isTilted (args) {
return this._isTilted(args.TILT);
}
getTilt (args) {
return this._getTiltAngle(args.TILT);
}
_isTilted (direction) {
switch (direction) {
case TiltAxisValues.ANY:
return this._getTiltAngle(TiltAxisValues.FRONT) > TILT_THRESHOLD ||
this._getTiltAngle(TiltAxisValues.BACK) > TILT_THRESHOLD ||
this._getTiltAngle(TiltAxisValues.LEFT) > TILT_THRESHOLD ||
this._getTiltAngle(TiltAxisValues.RIGHT) > TILT_THRESHOLD;
default:
return this._getTiltAngle(direction) > TILT_THRESHOLD;
}
}
_getTiltAngle (direction) {
// Tilt values are calculated using acceleration due to gravity,
// so we need to return 0 when the peripheral is not connected.
if (!this._peripheral.isConnected()) {
return 0;
}
switch (args.TILT) {
switch (direction) {
case TiltAxisValues.FRONT:
return Math.round(this._peripheral.getTiltFrontBack(false));
case TiltAxisValues.BACK:
return Math.round(this._peripheral.getTiltFrontBack(true));
case TiltAxisValues.BACK:
return Math.round(this._peripheral.getTiltFrontBack(false));
case TiltAxisValues.LEFT:
return Math.round(this._peripheral.getTiltLeftRight(false));
case TiltAxisValues.RIGHT:
return Math.round(this._peripheral.getTiltLeftRight(true));
case TiltAxisValues.RIGHT:
return Math.round(this._peripheral.getTiltLeftRight(false));
default:
log.warn(`Unknown direction in getTilt: ${args.TILT}`);
log.warn(`Unknown direction in getTilt: ${direction}`);
}
}
@ -851,17 +955,6 @@ class Scratch3GdxForBlocks {
);
}
isFacing (args) {
switch (args.FACING) {
case FaceValues.UP:
return this._peripheral.getAccelerationZ() > FACING_THRESHOLD;
case FaceValues.DOWN:
return this._peripheral.getAccelerationZ() < FACING_THRESHOLD * -1;
default:
log.warn(`Unknown direction in isFacing: ${args.FACING}`);
}
}
isFreeFalling () {
// When the peripheral is not connected, the acceleration magnitude
// is 0 instead of ~9.8, which ends up calculating as a positive

View file

@ -4,8 +4,8 @@ const Base64Util = require('../../util/base64-util');
* Adapter class
*/
class ScratchLinkDeviceAdapter {
constructor (scratchLinkSocket, {service, commandChar, responseChar}) {
this.scratchLinkSocket = scratchLinkSocket;
constructor (socket, {service, commandChar, responseChar}) {
this.socket = socket;
this._service = service;
this._commandChar = commandChar;
@ -21,13 +21,13 @@ class ScratchLinkDeviceAdapter {
writeCommand (commandBuffer) {
const data = Base64Util.uint8ArrayToBase64(commandBuffer);
return this.scratchLinkSocket
return this.socket
.write(this._service, this._commandChar, data, 'base64');
}
setup ({onResponse}) {
this._deviceOnResponse = onResponse;
return this.scratchLinkSocket
return this.socket
.startNotifications(this._service, this._responseChar, this._onResponse);
// TODO:

View file

@ -207,55 +207,61 @@ class Scratch3MakeyMakeyBlocks {
}
],
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)
KEY: {
acceptReporters: true,
items: [
{
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: {
acceptReporters: true,
items: this.buildSequenceMenu(this.DEFAULT_SEQUENCES)
}
}
};
}

View file

@ -144,7 +144,7 @@ class MicroBit {
*/
this._busyTimeoutID = null;
this.disconnect = this.disconnect.bind(this);
this.reset = this.reset.bind(this);
this._onConnect = this._onConnect.bind(this);
this._onMessage = this._onMessage.bind(this);
}
@ -222,7 +222,7 @@ class MicroBit {
filters: [
{services: [BLEUUID.service]}
]
}, this._onConnect, this.disconnect);
}, this._onConnect, this.reset);
}
/**
@ -239,10 +239,21 @@ class MicroBit {
* Disconnect from the micro:bit.
*/
disconnect () {
window.clearInterval(this._timeoutID);
if (this._ble) {
this._ble.disconnect();
}
this.reset();
}
/**
* Reset all the state and timeout/interval ids.
*/
reset () {
if (this._timeoutID) {
window.clearTimeout(this._timeoutID);
this._timeoutID = null;
}
}
/**
@ -299,7 +310,7 @@ class MicroBit {
*/
_onConnect () {
this._ble.read(BLEUUID.service, BLEUUID.rxChar, true, this._onMessage);
this._timeoutID = window.setInterval(
this._timeoutID = window.setTimeout(
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
@ -329,8 +340,8 @@ class MicroBit {
this._sensors.gestureState = data[9];
// cancel disconnect timeout and start a new one
window.clearInterval(this._timeoutID);
this._timeoutID = window.setInterval(
window.clearTimeout(this._timeoutID);
this._timeoutID = window.setTimeout(
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
@ -746,12 +757,30 @@ class Scratch3MicroBitBlocks {
}
],
menus: {
buttons: this.BUTTONS_MENU,
gestures: this.GESTURES_MENU,
pinState: this.PIN_STATE_MENU,
tiltDirection: this.TILT_DIRECTION_MENU,
tiltDirectionAny: this.TILT_DIRECTION_ANY_MENU,
touchPins: ['0', '1', '2']
buttons: {
acceptReporters: true,
items: this.BUTTONS_MENU
},
gestures: {
acceptReporters: true,
items: this.GESTURES_MENU
},
pinState: {
acceptReporters: true,
items: this.PIN_STATE_MENU
},
tiltDirection: {
acceptReporters: true,
items: this.TILT_DIRECTION_MENU
},
tiltDirectionAny: {
acceptReporters: true,
items: this.TILT_DIRECTION_ANY_MENU
},
touchPins: {
acceptReporters: true,
items: ['0', '1', '2']
}
}
};
}

View file

@ -911,8 +911,14 @@ class Scratch3MusicBlocks {
}
],
menus: {
DRUM: this._buildMenu(this.DRUM_INFO),
INSTRUMENT: this._buildMenu(this.INSTRUMENT_INFO)
DRUM: {
acceptReporters: true,
items: this._buildMenu(this.DRUM_INFO)
},
INSTRUMENT: {
acceptReporters: true,
items: this._buildMenu(this.INSTRUMENT_INFO)
}
}
};
}

View file

@ -477,7 +477,10 @@ class Scratch3PenBlocks {
}
],
menus: {
colorParam: this._initColorParam()
colorParam: {
acceptReporters: true,
items: this._initColorParam()
}
}
};
}

View file

@ -1,5 +1,6 @@
const formatMessage = require('format-message');
const nets = require('nets');
const languageNames = require('scratch-translate-extension-languages');
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
@ -65,6 +66,43 @@ 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 ARABIC_ID = 'ar';
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 HINDI_ID = 'hi';
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 +130,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 +192,154 @@ 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'
[ARABIC_ID]: {
name: 'Arabic',
locales: ['ar'],
speechSynthLocale: 'arb',
singleGender: true
},
[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'
},
[HINDI_ID]: {
name: 'Hindi',
locales: ['hi'],
speechSynthLocale: 'hi-IN',
singleGender: true
},
[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 +363,7 @@ class Scratch3Text2SpeechBlocks {
* @type {string}
*/
get DEFAULT_LANGUAGE () {
return 'en';
return ENGLISH_ID;
}
/**
@ -272,7 +413,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: [
@ -325,8 +470,14 @@ class Scratch3Text2SpeechBlocks {
}
],
menus: {
voices: this.getVoiceMenu(),
languages: this.getLanguageMenu()
voices: {
acceptReporters: true,
items: this.getVoiceMenu()
},
languages: {
acceptReporters: true,
items: this.getLanguageMenu()
}
}
};
}
@ -334,16 +485,17 @@ 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 ||
const locale = formatMessage.setup().locale ||
navigator.language || navigator.userLanguage || this.DEFAULT_LANGUAGE;
return locale.toLowerCase();
}
/**
* 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 +508,27 @@ 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);
}
// Support language names dropped onto the menu via reporter block
// such as a variable containing a language name (in any language),
// or the translate extension's language reporter.
const localeForDroppedName = languageNames.nameMap[locale.toLowerCase()];
if (localeForDroppedName && this.isSupportedLanguage(localeForDroppedName)) {
stage.textToSpeechLanguage =
this._getExtensionLocaleForSupportedLocale(localeForDroppedName);
}
// 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 +538,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);
}
/**
@ -397,14 +595,49 @@ class Scratch3Text2SpeechBlocks {
}
/**
* Get the menu of languages for the "set language" block.
* Get the localized menu of languages for the "set language" block.
* For each language:
* if there is a custom translated spoken language name, use that;
* otherwise use the translation in the languageNames menuMap;
* otherwise fall back to the untranslated name in LANGUAGE_INFO.
* @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]
}));
const editorLanguage = this.getEditorLanguage();
// Get the array of localized language names
const localizedNameMap = {};
let nameArray = languageNames.menuMap[editorLanguage];
if (nameArray) {
// Also get any localized names of spoken languages
let spokenNameArray = [];
if (languageNames.spokenLanguages) {
spokenNameArray = languageNames.spokenLanguages[editorLanguage];
nameArray = nameArray.concat(spokenNameArray);
}
// Create a map of language code to localized name
// The localized spoken language names have been concatenated onto
// the end of the name array, so the result of the forEach below is
// when there is both a written language name (e.g. 'Chinese
// (simplified)') and a spoken language name (e.g. 'Chinese
// (Mandarin)', we always use the spoken version.
nameArray.forEach(lang => {
localizedNameMap[lang.code] = lang.name;
});
}
return Object.keys(this.LANGUAGE_INFO).map(key => {
let name = this.LANGUAGE_INFO[key].name;
const localizedName = localizedNameMap[key];
if (localizedName) {
name = localizedName;
}
// Uppercase the first character of the name
name = name.charAt(0).toUpperCase() + name.slice(1);
return {
text: name,
value: key
};
});
}
/**
@ -457,16 +690,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

View file

@ -146,7 +146,10 @@ class Scratch3TranslateBlocks {
}
],
menus: {
languages: this._supportedLanguages
languages: {
acceptReporters: true,
items: this._supportedLanguages
}
}
};
}
@ -171,7 +174,15 @@ class Scratch3TranslateBlocks {
getViewerLanguage () {
this._viewerLanguageCode = this.getViewerLanguageCode();
const names = languageNames.menuMap[this._viewerLanguageCode];
const langNameObj = names.find(obj => obj.code === this._viewerLanguageCode);
let langNameObj = names.find(obj => obj.code === this._viewerLanguageCode);
// If we don't have a name entry yet, try looking it up via the Google langauge
// code instead of Scratch's (e.g. for es-419 we look up es to get espanol)
if (!langNameObj && languageNames.scratchToGoogleMap[this._viewerLanguageCode]) {
const lookupCode = languageNames.scratchToGoogleMap[this._viewerLanguageCode];
langNameObj = names.find(obj => obj.code === lookupCode);
}
let langName = this._viewerLanguageCode;
if (langNameObj) {
langName = langNameObj.name;
@ -193,12 +204,13 @@ class Scratch3TranslateBlocks {
if (acc) {
return acc;
}
if (languageKeys.indexOf(lang) > -1) {
if (languageKeys.indexOf(lang.toLowerCase()) > -1) {
return lang;
}
return acc;
}, '') || 'en';
return languageCode;
return languageCode.toLowerCase();
}
/**
@ -217,6 +229,14 @@ class Scratch3TranslateBlocks {
if (languageNames.nameMap.hasOwnProperty(languageArg)) {
return languageNames.nameMap[languageArg];
}
// There are some languages we launched in the language menu that Scratch did not
// end up launching in. In order to keep projects that may have had that menu item
// working, check for those language codes and let them through.
// Examples: 'ab', 'hi'.
if (languageNames.previouslySupported.indexOf(languageArg) !== -1) {
return languageArg;
}
// Default to English.
return 'en';
}

View file

@ -488,9 +488,18 @@ class Scratch3VideoSensingBlocks {
}
],
menus: {
ATTRIBUTE: this._buildMenu(this.ATTRIBUTE_INFO),
SUBJECT: this._buildMenu(this.SUBJECT_INFO),
VIDEO_STATE: this._buildMenu(this.VIDEO_STATE_INFO)
ATTRIBUTE: {
acceptReporters: true,
items: this._buildMenu(this.ATTRIBUTE_INFO)
},
SUBJECT: {
acceptReporters: true,
items: this._buildMenu(this.SUBJECT_INFO)
},
VIDEO_STATE: {
acceptReporters: true,
items: this._buildMenu(this.VIDEO_STATE_INFO)
}
}
};
}

View file

@ -289,6 +289,11 @@ class VideoMotion {
_curr: curr
} = this;
// The public APIs for Renderer#isTouching manage keeping the matrix and
// silhouette up-to-date, which is needed for drawable#isTouching to work (used below)
drawable.updateMatrix();
if (drawable.skin) drawable.skin.updateSilhouette();
// Restrict the region the amount and direction are built from to
// the area of the current frame overlapped by the given drawable's
// bounding box.

View file

@ -297,7 +297,6 @@ class WeDo2Motor {
/**
* Start active braking on this motor. After a short time, the motor will turn off.
* // TODO: rename this to coastAfter?
*/
startBraking () {
if (this._power === 0) return;
@ -435,7 +434,7 @@ class WeDo2 {
*/
this._batteryLevelIntervalId = null;
this.disconnect = this.disconnect.bind(this);
this.reset = this.reset.bind(this);
this._onConnect = this._onConnect.bind(this);
this._onMessage = this._onMessage.bind(this);
this._checkBatteryLevel = this._checkBatteryLevel.bind(this);
@ -594,7 +593,7 @@ class WeDo2 {
services: [BLEService.DEVICE_SERVICE]
}],
optionalServices: [BLEService.IO_SERVICE]
}, this._onConnect, this.disconnect);
}, this._onConnect, this.reset);
}
/**
@ -611,6 +610,17 @@ class WeDo2 {
* Disconnects from the current BLE socket.
*/
disconnect () {
if (this._ble) {
this._ble.disconnect();
}
this.reset();
}
/**
* Reset all the state and timeout/interval ids.
*/
reset () {
this._ports = ['none', 'none'];
this._motors = [null, null];
this._sensors = {
@ -619,10 +629,6 @@ class WeDo2 {
distance: 0
};
if (this._ble) {
this._ble.disconnect();
}
if (this._batteryLevelIntervalId) {
window.clearInterval(this._batteryLevelIntervalId);
this._batteryLevelIntervalId = null;
@ -1134,139 +1140,157 @@ class Scratch3WeDo2Blocks {
}
],
menus: {
MOTOR_ID: [
{
text: formatMessage({
id: 'wedo2.motorId.default',
default: 'motor',
description: 'label for motor element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.DEFAULT
},
{
text: formatMessage({
id: 'wedo2.motorId.a',
default: 'motor A',
description: 'label for motor A element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.A
},
{
text: formatMessage({
id: 'wedo2.motorId.b',
default: 'motor B',
description: 'label for motor B element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.B
},
{
text: formatMessage({
id: 'wedo2.motorId.all',
default: 'all motors',
description: 'label for all motors element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.ALL
}
],
MOTOR_DIRECTION: [
{
text: formatMessage({
id: 'wedo2.motorDirection.forward',
default: 'this way',
description: 'label for forward element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.FORWARD
},
{
text: formatMessage({
id: 'wedo2.motorDirection.backward',
default: 'that way',
description: 'label for backward element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.BACKWARD
},
{
text: formatMessage({
id: 'wedo2.motorDirection.reverse',
default: 'reverse',
description: 'label for reverse element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.REVERSE
}
],
TILT_DIRECTION: [
{
text: formatMessage({
id: 'wedo2.tiltDirection.up',
default: 'up',
description: 'label for up element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.UP
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.down',
default: 'down',
description: 'label for down element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.DOWN
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.left',
default: 'left',
description: 'label for left element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.LEFT
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.right',
default: 'right',
description: 'label for right element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.RIGHT
}
],
TILT_DIRECTION_ANY: [
{
text: formatMessage({
id: 'wedo2.tiltDirection.up',
default: 'up'
}),
value: WeDo2TiltDirection.UP
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.down',
default: 'down'
}),
value: WeDo2TiltDirection.DOWN
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.left',
default: 'left'
}),
value: WeDo2TiltDirection.LEFT
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.right',
default: 'right'
}),
value: WeDo2TiltDirection.RIGHT
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.any',
default: 'any',
description: 'label for any element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.ANY
}
],
OP: ['<', '>']
MOTOR_ID: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'wedo2.motorId.default',
default: 'motor',
description: 'label for motor element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.DEFAULT
},
{
text: formatMessage({
id: 'wedo2.motorId.a',
default: 'motor A',
description: 'label for motor A element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.A
},
{
text: formatMessage({
id: 'wedo2.motorId.b',
default: 'motor B',
description: 'label for motor B element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.B
},
{
text: formatMessage({
id: 'wedo2.motorId.all',
default: 'all motors',
description: 'label for all motors element in motor menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorLabel.ALL
}
]
},
MOTOR_DIRECTION: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'wedo2.motorDirection.forward',
default: 'this way',
description:
'label for forward element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.FORWARD
},
{
text: formatMessage({
id: 'wedo2.motorDirection.backward',
default: 'that way',
description:
'label for backward element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.BACKWARD
},
{
text: formatMessage({
id: 'wedo2.motorDirection.reverse',
default: 'reverse',
description:
'label for reverse element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.REVERSE
}
]
},
TILT_DIRECTION: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'wedo2.tiltDirection.up',
default: 'up',
description: 'label for up element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.UP
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.down',
default: 'down',
description: 'label for down element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.DOWN
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.left',
default: 'left',
description: 'label for left element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.LEFT
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.right',
default: 'right',
description: 'label for right element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.RIGHT
}
]
},
TILT_DIRECTION_ANY: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'wedo2.tiltDirection.up',
default: 'up'
}),
value: WeDo2TiltDirection.UP
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.down',
default: 'down'
}),
value: WeDo2TiltDirection.DOWN
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.left',
default: 'left'
}),
value: WeDo2TiltDirection.LEFT
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.right',
default: 'right'
}),
value: WeDo2TiltDirection.RIGHT
},
{
text: formatMessage({
id: 'wedo2.tiltDirection.any',
default: 'any',
description: 'label for any element in tilt direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2TiltDirection.ANY
}
]
},
OP: {
acceptReporters: true,
items: ['<', '>']
}
}
};
}

View file

@ -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,56 @@ 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}`;
};
return fetched;
})
.then(({canvas, mergeCanvas, rotationCenter}) => {
// createBitmapSkin does the right thing if costume.rotationCenter is undefined.
// That will be the case if you upload a bitmap asset or create one by taking a photo.
let center;
if (rotationCenter) {
// fetchBitmapCanvas will ensure that the costume's bitmap resolution is 2 and its rotation center is
// scaled to match, so it's okay to always divide by 2.
center = [
rotationCenter[0] / 2,
rotationCenter[1] / 2
];
}
if (!fetched.assetMatchesBase) {
updateCostumeAsset(fetched.canvas.toDataURL());
}
resolve(fetched.canvas);
}))
.then(canvas => {
// createBitmapSkin does the right thing if costume.bitmapResolution or rotationCenter are undefined...
costume.skinId = runtime.renderer.createBitmapSkin(canvas, costume.bitmapResolution, rotationCenter);
// TODO: costume.bitmapResolution will always be 2 at this point because of fetchBitmapCanvas_, so we don't
// need to pass it in here.
costume.skinId = runtime.renderer.createBitmapSkin(canvas, costume.bitmapResolution, center);
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
@ -216,7 +273,8 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) {
}
if (costume.asset.assetType.runtimeFormat === AssetType.ImageVector.runtimeFormat) {
return loadVector_(costume, runtime, rotationCenter, optVersion)
.catch(() => {
.catch(error => {
log.warn(`Error loading vector image: ${error.name}: ${error.message}`);
// Use default asset if original fails to load
costume.assetId = runtime.storage.defaultAssetId.ImageVector;
costume.asset = runtime.storage.get(costume.assetId);

View file

@ -8,10 +8,10 @@ const log = require('../util/log');
* @property {Buffer} data - sound data will be written here once loaded.
* @param {!Asset} soundAsset - the asset loaded from storage.
* @param {!Runtime} runtime - Scratch runtime, used to access the storage module.
* @param {Sprite} sprite - Scratch sprite to add sounds to.
* @param {SoundBank} soundBank - Scratch Audio SoundBank to add sounds to.
* @returns {!Promise} - a promise which will resolve to the sound when ready.
*/
const loadSoundFromAsset = function (sound, soundAsset, runtime, sprite) {
const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) {
sound.assetId = soundAsset.assetId;
if (!runtime.audioEngine) {
log.error('No audio engine present; cannot load sound asset: ', sound.md5);
@ -30,8 +30,8 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime, sprite) {
sound.rate = soundBuffer.sampleRate;
sound.sampleCount = soundBuffer.length;
if (sprite.soundBank !== null) {
sprite.soundBank.addSoundPlayer(soundPlayer);
if (soundBank !== null) {
soundBank.addSoundPlayer(soundPlayer);
}
return sound;
@ -44,10 +44,10 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime, sprite) {
* @property {string} md5 - the MD5 and extension of the sound to be loaded.
* @property {Buffer} data - sound data will be written here once loaded.
* @param {!Runtime} runtime - Scratch runtime, used to access the storage module.
* @param {Sprite} sprite - Scratch sprite to add sounds to.
* @param {SoundBank} soundBank - Scratch Audio SoundBank to add sounds to.
* @returns {!Promise} - a promise which will resolve to the sound when ready.
*/
const loadSound = function (sound, runtime, sprite) {
const loadSound = function (sound, runtime, soundBank) {
if (!runtime.storage) {
log.error('No storage module present; cannot load sound asset: ', sound.md5);
return Promise.resolve(sound);
@ -61,7 +61,7 @@ const loadSound = function (sound, runtime, sprite) {
runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
).then(soundAsset => {
sound.asset = soundAsset;
return loadSoundFromAsset(sound, soundAsset, runtime, sprite);
return loadSoundFromAsset(sound, soundAsset, runtime, soundBank);
});
};

View file

@ -1,8 +1,6 @@
const JSONRPCWebSocket = require('../util/jsonrpc-web-socket');
const ScratchLinkWebSocket = 'wss://device-manager.scratch.mit.edu:20110/scratch/ble';
// const log = require('../util/log');
const JSONRPC = require('../util/jsonrpc');
class BLE extends JSONRPCWebSocket {
class BLE extends JSONRPC {
/**
* A BLE peripheral socket object. It handles connecting, over web sockets, to
@ -11,26 +9,30 @@ class BLE extends JSONRPCWebSocket {
* @param {string} extensionId - the id of the extension using this socket.
* @param {object} peripheralOptions - the list of options for peripheral discovery.
* @param {object} connectCallback - a callback for connection.
* @param {object} disconnectCallback - a callback for disconnection.
* @param {object} resetCallback - a callback for resetting extension state.
*/
constructor (runtime, extensionId, peripheralOptions, connectCallback, disconnectCallback = null) {
const ws = new WebSocket(ScratchLinkWebSocket);
super(ws);
constructor (runtime, extensionId, peripheralOptions, connectCallback, resetCallback = null) {
super();
this._ws = ws;
this._ws.onopen = this.requestPeripheral.bind(this); // only call request peripheral after socket opens
this._ws.onerror = this._handleRequestError.bind(this, 'ws onerror');
this._ws.onclose = this.handleDisconnectError.bind(this, 'ws onclose');
this._socket = runtime.getScratchLinkSocket('BLE');
this._socket.setOnOpen(this.requestPeripheral.bind(this));
this._socket.setOnClose(this.handleDisconnectError.bind(this));
this._socket.setOnError(this._handleRequestError.bind(this));
this._socket.setHandleMessage(this._handleMessage.bind(this));
this._sendMessage = this._socket.sendMessage.bind(this._socket);
this._availablePeripherals = {};
this._connectCallback = connectCallback;
this._connected = false;
this._characteristicDidChangeCallback = null;
this._disconnectCallback = disconnectCallback;
this._resetCallback = resetCallback;
this._discoverTimeoutID = null;
this._extensionId = extensionId;
this._peripheralOptions = peripheralOptions;
this._runtime = runtime;
this._socket.open();
}
/**
@ -38,18 +40,15 @@ class BLE extends JSONRPCWebSocket {
* If the web socket is not yet open, request when the socket promise resolves.
*/
requestPeripheral () {
if (this._ws.readyState === 1) { // is this needed since it's only called on ws.onopen?
this._availablePeripherals = {};
if (this._discoverTimeoutID) {
window.clearTimeout(this._discoverTimeoutID);
}
this._discoverTimeoutID = window.setTimeout(this._handleDiscoverTimeout.bind(this), 15000);
this.sendRemoteRequest('discover', this._peripheralOptions)
.catch(e => {
this._handleRequestError(e);
});
this._availablePeripherals = {};
if (this._discoverTimeoutID) {
window.clearTimeout(this._discoverTimeoutID);
}
// TODO: else?
this._discoverTimeoutID = window.setTimeout(this._handleDiscoverTimeout.bind(this), 15000);
this.sendRemoteRequest('discover', this._peripheralOptions)
.catch(e => {
this._handleRequestError(e);
});
}
/**
@ -73,14 +72,19 @@ class BLE extends JSONRPCWebSocket {
* Close the websocket.
*/
disconnect () {
if (!this._connected) return;
if (this._connected) {
this._connected = false;
}
if (this._socket.isOpen()) {
this._socket.close();
}
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);
}
@ -149,7 +153,7 @@ class BLE extends JSONRPCWebSocket {
if (encoding) {
params.encoding = encoding;
}
if (withResponse) {
if (withResponse !== null) {
params.withResponse = withResponse;
}
return this.sendRemoteRequest('write', params)
@ -195,18 +199,17 @@ class BLE extends JSONRPCWebSocket {
* - being powered down
*
* Disconnect the socket, and if the extension using this socket has a
* disconnect callback, call it. Finally, emit an error to the runtime.
* reset callback, call it. Finally, emit an error to the runtime.
*/
handleDisconnectError (/* e */) {
// log.error(`BLE error: ${JSON.stringify(e)}`);
if (!this._connected) return;
// TODO: Fix branching by splitting up cleanup/disconnect in extension
if (this._disconnectCallback) {
this._disconnectCallback(); // must call disconnect()
} else {
this.disconnect();
this.disconnect();
if (this._resetCallback) {
this._resetCallback();
}
this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, {

View file

@ -1,8 +1,6 @@
const JSONRPCWebSocket = require('../util/jsonrpc-web-socket');
const ScratchLinkWebSocket = 'wss://device-manager.scratch.mit.edu:20110/scratch/bt';
// const log = require('../util/log');
const JSONRPC = require('../util/jsonrpc');
class BT extends JSONRPCWebSocket {
class BT extends JSONRPC {
/**
* A BT peripheral socket object. It handles connecting, over web sockets, to
@ -11,28 +9,32 @@ class BT extends JSONRPCWebSocket {
* @param {string} extensionId - the id of the extension using this socket.
* @param {object} peripheralOptions - the list of options for peripheral discovery.
* @param {object} connectCallback - a callback for connection.
* @param {object} disconnectCallback - a callback for disconnection.
* @param {object} resetCallback - a callback for resetting extension state.
* @param {object} messageCallback - a callback for message sending.
*/
constructor (runtime, extensionId, peripheralOptions, connectCallback, disconnectCallback = null, messageCallback) {
const ws = new WebSocket(ScratchLinkWebSocket);
super(ws);
constructor (runtime, extensionId, peripheralOptions, connectCallback, resetCallback = null, messageCallback) {
super();
this._ws = ws;
this._ws.onopen = this.requestPeripheral.bind(this); // only call request peripheral after socket opens
this._ws.onerror = this._handleRequestError.bind(this, 'ws onerror');
this._ws.onclose = this.handleDisconnectError.bind(this, 'ws onclose');
this._socket = runtime.getScratchLinkSocket('BT');
this._socket.setOnOpen(this.requestPeripheral.bind(this));
this._socket.setOnError(this._handleRequestError.bind(this));
this._socket.setOnClose(this.handleDisconnectError.bind(this));
this._socket.setHandleMessage(this._handleMessage.bind(this));
this._sendMessage = this._socket.sendMessage.bind(this._socket);
this._availablePeripherals = {};
this._connectCallback = connectCallback;
this._connected = false;
this._characteristicDidChangeCallback = null;
this._disconnectCallback = disconnectCallback;
this._resetCallback = resetCallback;
this._discoverTimeoutID = null;
this._extensionId = extensionId;
this._peripheralOptions = peripheralOptions;
this._messageCallback = messageCallback;
this._runtime = runtime;
this._socket.open();
}
/**
@ -40,27 +42,29 @@ class BT extends JSONRPCWebSocket {
* If the web socket is not yet open, request when the socket promise resolves.
*/
requestPeripheral () {
if (this._ws.readyState === 1) { // is this needed since it's only called on ws.onopen?
this._availablePeripherals = {};
if (this._discoverTimeoutID) {
window.clearTimeout(this._discoverTimeoutID);
}
this._discoverTimeoutID = window.setTimeout(this._handleDiscoverTimeout.bind(this), 15000);
this.sendRemoteRequest('discover', this._peripheralOptions)
.catch(
e => this._handleRequestError(e)
);
this._availablePeripherals = {};
if (this._discoverTimeoutID) {
window.clearTimeout(this._discoverTimeoutID);
}
// TODO: else?
this._discoverTimeoutID = window.setTimeout(this._handleDiscoverTimeout.bind(this), 15000);
this.sendRemoteRequest('discover', this._peripheralOptions)
.catch(
e => this._handleRequestError(e)
);
}
/**
* Try connecting to the input peripheral id, and then call the connect
* callback if connection is successful.
* @param {number} id - the id of the peripheral to connect to
* @param {string} pin - an optional pin for pairing
*/
connectPeripheral (id) {
this.sendRemoteRequest('connect', {peripheralId: id})
connectPeripheral (id, pin = null) {
const params = {peripheralId: id};
if (pin) {
params.pin = pin;
}
this.sendRemoteRequest('connect', params)
.then(() => {
this._connected = true;
this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED);
@ -75,14 +79,19 @@ class BT extends JSONRPCWebSocket {
* Close the websocket.
*/
disconnect () {
if (!this._connected) return;
if (this._connected) {
this._connected = false;
}
if (this._socket.isOpen()) {
this._socket.close();
}
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);
}
@ -136,18 +145,17 @@ class BT extends JSONRPCWebSocket {
* - being powered down
*
* Disconnect the socket, and if the extension using this socket has a
* disconnect callback, call it. Finally, emit an error to the runtime.
* reset callback, call it. Finally, emit an error to the runtime.
*/
handleDisconnectError (/* e */) {
// log.error(`BT error: ${JSON.stringify(e)}`);
if (!this._connected) return;
// TODO: Fix branching by splitting up cleanup/disconnect in extension
if (this._disconnectCallback) {
this._disconnectCallback(); // must call disconnect()
} else {
this.disconnect();
this.disconnect();
if (this._resetCallback) {
this._resetCallback();
}
this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, {

View file

@ -2,7 +2,7 @@ const Timer = require('../util/timer');
class Clock {
constructor (runtime) {
this._projectTimer = new Timer();
this._projectTimer = new Timer({now: () => runtime.currentMSecs});
this._projectTimer.start();
this._pausedTime = null;
this._paused = false;

View file

@ -9,7 +9,8 @@ const KEY_NAME = {
LEFT: 'left arrow',
UP: 'up arrow',
RIGHT: 'right arrow',
DOWN: 'down arrow'
DOWN: 'down arrow',
ENTER: 'enter'
};
/**
@ -56,6 +57,7 @@ class Keyboard {
case 'ArrowRight': return KEY_NAME.RIGHT;
case 'Down':
case 'ArrowDown': return KEY_NAME.DOWN;
case 'Enter': return KEY_NAME.ENTER;
}
// Ignore modifier keys
if (keyString.length > 1) {

View file

@ -21,12 +21,6 @@ class Video {
*/
this._skinId = -1;
/**
* The Scratch Renderer Skin object.
* @type {Skin}
*/
this._skin = null;
/**
* Id for a drawable using the video's skin that will render as a video
* preview.
@ -141,8 +135,8 @@ class Video {
}
_disablePreview () {
if (this._skin) {
this._skin.clear();
if (this._skinId !== -1) {
this.runtime.renderer.updateBitmapSkin(this._skinId, new ImageData(...Video.DIMENSIONS), 1);
this.runtime.renderer.updateDrawableProperties(this._drawable, {visible: false});
}
this._renderPreviewFrame = null;
@ -152,9 +146,8 @@ class Video {
const {renderer} = this.runtime;
if (!renderer) return;
if (this._skinId === -1 && this._skin === null && this._drawable === -1) {
this._skinId = renderer.createPenSkin();
this._skin = renderer._allSkins[this._skinId];
if (this._skinId === -1 && this._drawable === -1) {
this._skinId = renderer.createBitmapSkin(new ImageData(...Video.DIMENSIONS), 1);
this._drawable = renderer.createDrawable(StageLayering.VIDEO_LAYER);
renderer.updateDrawableProperties(this._drawable, {
skinId: this._skinId
@ -176,16 +169,17 @@ class Video {
this._renderPreviewTimeout = setTimeout(this._renderPreviewFrame, this.runtime.currentStepTime);
const canvas = this.getFrame({format: Video.FORMAT_CANVAS});
const imageData = this.getFrame({
format: Video.FORMAT_IMAGE_DATA,
cacheTimeout: this.runtime.currentStepTime
});
if (!canvas) {
this._skin.clear();
if (!imageData) {
renderer.updateBitmapSkin(this._skinId, new ImageData(...Video.DIMENSIONS), 1);
return;
}
const xOffset = Video.DIMENSIONS[0] / -2;
const yOffset = Video.DIMENSIONS[1] / 2;
this._skin.drawStamp(canvas, xOffset, yOffset);
renderer.updateBitmapSkin(this._skinId, imageData, 1);
this.runtime.requestRedraw();
};

View file

@ -7,6 +7,42 @@ if (window.performance) {
performance.mark('Scratch.EvalStart');
}
class LoadingMiddleware {
constructor () {
this.middleware = [];
this.host = null;
this.original = null;
}
install (host, original) {
this.host = host;
this.original = original;
const {middleware} = this;
return function (...args) {
let i = 0;
const next = function (_args) {
if (i >= middleware.length) {
return original.call(host, ..._args);
}
return middleware[i++](_args, next);
};
return next(args);
};
}
push (middleware) {
this.middleware.push(middleware);
}
}
const importLoadCostume = require('../import/load-costume');
const costumeMiddleware = new LoadingMiddleware();
importLoadCostume.loadCostume = costumeMiddleware.install(importLoadCostume, importLoadCostume.loadCostume);
const importLoadSound = require('../import/load-sound');
const soundMiddleware = new LoadingMiddleware();
importLoadSound.loadSound = soundMiddleware.install(importLoadSound, importLoadSound.loadSound);
const ScratchStorage = require('scratch-storage');
const VirtualMachine = require('..');
const Runtime = require('../engine/runtime');
@ -77,32 +113,92 @@ const getAssetUrl = function (asset) {
class LoadingProgress {
constructor (callback) {
this.total = 0;
this.complete = 0;
this.dataLoaded = 0;
this.contentTotal = 0;
this.contentComplete = 0;
this.hydrateTotal = 0;
this.hydrateComplete = 0;
this.memoryCurrent = 0;
this.memoryPeak = 0;
this.callback = callback;
}
sampleMemory () {
if (window.performance && window.performance.memory) {
this.memoryCurrent = window.performance.memory.usedJSHeapSize;
this.memoryPeak = Math.max(this.memoryCurrent, this.memoryPeak);
}
}
attachHydrateMiddleware (middleware) {
const _this = this;
middleware.push((args, next) => {
_this.hydrateTotal += 1;
_this.sampleMemory();
_this.callback(_this);
return Promise.resolve(next(args))
.then(value => {
_this.hydrateComplete += 1;
_this.sampleMemory();
_this.callback(_this);
return value;
});
});
}
on (storage, vm) {
const _this = this;
this.attachHydrateMiddleware(costumeMiddleware);
this.attachHydrateMiddleware(soundMiddleware);
const _load = storage.webHelper.load;
storage.webHelper.load = function (...args) {
if (_this.complete === 0 && window.performance) {
if (_this.dataLoaded === 0 && window.performance) {
// Mark in browser inspectors how long it takes to load the
// projects initial data file.
performance.mark('Scratch.LoadDataStart');
}
const result = _load.call(this, ...args);
_this.total += 1;
_this.callback(_this);
result.then(() => {
if (_this.complete === 0 && window.performance) {
// How long did loading the data file take?
performance.mark('Scratch.LoadDataEnd');
performance.measure('Scratch.LoadData', 'Scratch.LoadDataStart', 'Scratch.LoadDataEnd');
if (_this.dataLoaded) {
if (_this.contentTotal === 0 && window.performance) {
performance.mark('Scratch.DownloadStart');
}
_this.complete += 1;
_this.contentTotal += 1;
}
_this.sampleMemory();
_this.callback(_this);
result.then(() => {
if (_this.dataLoaded === 0) {
if (window.performance) {
// How long did loading the data file take?
performance.mark('Scratch.LoadDataEnd');
performance.measure('Scratch.LoadData', 'Scratch.LoadDataStart', 'Scratch.LoadDataEnd');
}
_this.dataLoaded = 1;
window.ScratchVMLoadDataEnd = Date.now();
} else {
_this.contentComplete += 1;
}
if (_this.contentComplete && _this.contentComplete === _this.contentTotal) {
if (window.performance) {
// How long did it take to download the html, js, and
// all the project assets?
performance.mark('Scratch.DownloadEnd');
performance.measure('Scratch.Download', 'Scratch.DownloadStart', 'Scratch.DownloadEnd');
}
window.ScratchVMDownloadEnd = Date.now();
}
_this.sampleMemory();
_this.callback(_this);
});
return result;
@ -112,8 +208,8 @@ class LoadingProgress {
// and not when the data has been decoded. It may be difficult to
// track that but it isn't hard to track when its all been decoded.
if (window.performance) {
// How long did it take to load the html, js, and all the
// project assets?
// How long did it take to load and hydrate the html, js, and
// all the project assets?
performance.mark('Scratch.LoadEnd');
performance.measure('Scratch.Load', 'Scratch.LoadStart', 'Scratch.LoadEnd');
}
@ -122,6 +218,7 @@ class LoadingProgress {
// With this event lets update LoadingProgress a final time so its
// displayed loading time is accurate.
_this.sampleMemory();
_this.callback(_this);
});
}
@ -163,8 +260,8 @@ class StatView {
this.totalTime = 0;
}
update (selfTime, totalTime) {
this.executions++;
update (selfTime, totalTime, count) {
this.executions += count;
this.selfTime += selfTime;
this.totalTime += totalTime;
}
@ -180,20 +277,29 @@ class StatView {
}
cell = document.createElement('td');
cell.style.textAlign = 'right';
cell.innerText = '---';
// Truncate selfTime. Value past the microsecond are floating point
// noise.
this.selfTime = Math.floor(this.selfTime * 1000) / 1000;
cell.innerText = (this.selfTime / 1000).toPrecision(3);
if (this.selfTime > 0) {
cell.innerText = (this.selfTime / 1000).toFixed(3);
}
row.appendChild(cell);
cell = document.createElement('td');
cell.style.textAlign = 'right';
cell.innerText = '---';
// Truncate totalTime. Value past the microsecond are floating point
// noise.
this.totalTime = Math.floor(this.totalTime * 1000) / 1000;
cell.innerText = (this.totalTime / 1000).toPrecision(3);
if (this.totalTime > 0) {
cell.innerText = (this.totalTime / 1000).toFixed(3);
}
row.appendChild(cell);
cell = document.createElement('td');
cell.style.textAlign = 'right';
cell.innerText = this.executions;
row.appendChild(cell);
@ -214,13 +320,13 @@ class RunningStats {
};
}
update (id, selfTime, totalTime) {
update (id, arg, selfTime, totalTime, count) {
if (id === this.stpeThreadsId) {
this.recordedTime += totalTime;
} else if (id === this.stepThreadsInnerId) {
this.executed.steps++;
this.executed.steps += count;
} else if (id === this.blockFunctionId) {
this.executed.blocks++;
this.executed.blocks += count;
}
}
}
@ -263,11 +369,12 @@ class Frames {
this.frames = [];
}
update (id, selfTime, totalTime) {
update (id, arg, selfTime, totalTime, count) {
if (id < 0) return;
if (!this.frames[id]) {
this.frames[id] = new StatView(this.profiler.nameById(id));
}
this.frames[id].update(selfTime, totalTime);
this.frames[id].update(selfTime, totalTime, count);
}
}
@ -281,13 +388,6 @@ const frameOrder = [
'Runtime._step'
];
const trackSlowFrames = [
'Sequencer.stepThreads',
'Sequencer.stepThreads#inner',
'Sequencer.stepThread',
'execute'
];
class FramesTable extends StatTable {
constructor (options) {
super(options);
@ -307,9 +407,8 @@ class FramesTable extends StatTable {
return this.frames.frames[this.profiler.idByName(key)];
}
isSlow (key, frame) {
return (trackSlowFrames.indexOf(key) > 0 &&
frame.selfTime / frame.totalTime > SLOW);
isSlow () {
return false;
}
}
@ -320,12 +419,12 @@ class Opcodes {
this.opcodes = {};
}
update (id, selfTime, totalTime, arg) {
update (id, arg, selfTime, totalTime, count) {
if (id === this.blockFunctionId) {
if (!this.opcodes[arg]) {
this.opcodes[arg] = new StatView(arg);
}
this.opcodes[arg].update(selfTime, totalTime);
this.opcodes[arg].update(selfTime, totalTime, count);
}
}
}
@ -397,13 +496,14 @@ class ProfilerRun {
});
const stepId = profiler.idByName('Runtime._step');
profiler.onFrame = ({id, selfTime, totalTime, arg}) => {
profiler.onFrame = ({id, arg, selfTime, totalTime, count}) => {
if (id === stepId) {
runningStatsView.render();
}
runningStats.update(id, selfTime, totalTime, arg);
opcodes.update(id, selfTime, totalTime, arg);
frames.update(id, selfTime, totalTime, arg);
runningStats.update(id, arg, selfTime, totalTime, count);
opcodes.update(id, arg, selfTime, totalTime, count);
frames.update(id, arg, selfTime, totalTime, count);
};
}
@ -497,12 +597,33 @@ const runBenchmark = function () {
vm.attachStorage(storage);
new LoadingProgress(progress => {
document.getElementsByClassName('loading-total')[0]
.innerText = progress.total;
document.getElementsByClassName('loading-complete')[0]
.innerText = progress.complete;
document.getElementsByClassName('loading-time')[0]
.innerText = `(${(window.ScratchVMLoadEnd || Date.now()) - window.ScratchVMLoadStart}ms)`;
const setElement = (name, value) => {
document.getElementsByClassName(name)[0].innerText = value;
};
const sinceLoadStart = key => (
`(${(window[key] || Date.now()) - window.ScratchVMLoadStart}ms)`
);
setElement('loading-total', 1);
setElement('loading-complete', progress.dataLoaded);
setElement('loading-time', sinceLoadStart('ScratchVMLoadDataEnd'));
setElement('loading-content-total', progress.contentTotal);
setElement('loading-content-complete', progress.contentComplete);
setElement('loading-content-time', sinceLoadStart('ScratchVMDownloadEnd'));
setElement('loading-hydrate-total', progress.hydrateTotal);
setElement('loading-hydrate-complete', progress.hydrateComplete);
setElement('loading-hydrate-time', sinceLoadStart('ScratchVMLoadEnd'));
if (progress.memoryPeak) {
setElement('loading-memory-current',
`${(progress.memoryCurrent / 1000000).toFixed(0)}MB`
);
setElement('loading-memory-peak',
`${(progress.memoryPeak / 1000000).toFixed(0)}MB`
);
}
}).on(storage, vm);
let warmUpTime = 4000;
@ -607,7 +728,7 @@ const renderBenchmarkData = function (json) {
setShareLink(json);
};
window.onload = function () {
const onload = function () {
if (location.hash.substring(1).startsWith('view')) {
document.body.className = 'render';
const data = location.hash.substring(6);
@ -629,3 +750,5 @@ if (window.performance) {
}
window.ScratchVMEvalEnd = Date.now();
onload();

View file

@ -40,31 +40,45 @@
<canvas id="scratch-stage"></canvas><br />
<div class="loading">
<label>Loading:</label>
<span class="loading-complete">0</span> / <span class="loading-total">0</span> <span class="loading-time">(--ms)</span>
</div>
<div class="profile-count-group">
<div class="profile-count">
<label>Percent of time worked:</label>
<span class="profile-count-value profile-count-amount-recorded">...</span>
<div class="layer">
<div class="loading">
<label>Loading Data:</label>
<span class="loading-complete">0</span> / <span class="loading-total">0</span> <span class="loading-time">(--ms)</span>
</div>
<div class="profile-count">
<label>Steps looped:</label>
<span class="profile-count-value profile-count-steps-looped">...</span>
<div class="loading">
<label>Loading Content:</label>
<span class="loading-content-complete">0</span> / <span class="loading-content-total">0</span> <span class="loading-content-time">(--ms)</span>
</div>
<div class="profile-count">
<label>Blocks executed:</label>
<span class="profile-count-value profile-count-blocks-executed">...</span>
<div class="loading">
<label>Hydrating:</label>
<span class="loading-hydrate-complete">0</span> / <span class="loading-hydrate-total">0</span> <span class="loading-hydrate-time">(--ms)</span>
</div>
<div class="loading">
<label>Memory:</label>
<span class="loading-memory-current">--</span> / <span class="loading-memory-peak">--</span>
</div>
<div class="profile-count-group">
<div class="profile-count">
<label>Percent of time worked:</label>
<span class="profile-count-value profile-count-amount-recorded">...</span>
</div>
<div class="profile-count">
<label>Steps looped:</label>
<span class="profile-count-value profile-count-steps-looped">...</span>
</div>
<div class="profile-count">
<label>Blocks executed:</label>
<span class="profile-count-value profile-count-blocks-executed">...</span>
</div>
<a class="share"><div class="profile-count">
<label>Share this report</label>
</div></a>
<a class="share" target="_parent">
<div class="profile-count">
<label>Run the full suite</label>
</div>
</a>
</div>
<a class="share"><div class="profile-count">
<label>Share this report</label>
</div></a>
<a class="share" target="_parent">
<div class="profile-count">
<label>Run the full suite</label>
</div>
</a>
</div>
<div class="profile-tables">

View file

@ -391,47 +391,34 @@ const parseMonitorObject = (object, runtime, targets, extensions) => {
};
/**
* Parse a single "Scratch object" and create all its in-memory VM objects.
* TODO: parse the "info" section, especially "savedExtensions"
* 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 {boolean} topLevel - Whether this is the top-level object (stage).
* @param {?object} zip - Optional zipped assets for local file import
* @return {!Promise.<Array.<Target>>} Promise for the loaded targets when ready, or null for unsupported objects.
* @return {?{costumePromises:Array.<Promise>,soundPromises:Array.<Promise>,soundBank:SoundBank,children:object}}
* Object of arrays of promises and child objects 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, topLevel, zip) {
const parseScratchAssets = function (object, runtime, topLevel, zip) {
if (!object.hasOwnProperty('objName')) {
if (object.hasOwnProperty('listName')) {
// Shim these objects so they can be processed as monitors
object.cmd = 'contentsOfList:';
object.param = object.listName;
object.mode = 'list';
}
// Defer parsing monitors until targets are all parsed
object.deferredMonitor = true;
return Promise.resolve(object);
// Skip parsing monitors. Or any other objects missing objName.
return 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('objName')) {
if (topLevel && object.objName !== 'Stage') {
for (const child of object.children) {
if (!child.hasOwnProperty('objName') && child.target === object.objName) {
child.target = 'Stage';
}
}
object.objName = 'Stage';
}
const assets = {
costumePromises: [],
soundPromises: [],
soundBank: runtime.audioEngine && runtime.audioEngine.createBank(),
children: []
};
sprite.name = object.objName;
}
// Costumes from JSON.
const costumePromises = [];
const costumePromises = assets.costumePromises;
if (object.hasOwnProperty('costumes')) {
for (let i = 0; i < object.costumes.length; i++) {
const costumeSource = object.costumes[i];
@ -476,7 +463,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip)
}
}
// Sounds from JSON
const soundPromises = [];
const {soundBank, soundPromises} = assets;
if (object.hasOwnProperty('sounds')) {
for (let s = 0; s < object.sounds.length; s++) {
const soundSource = object.sounds[s];
@ -505,12 +492,71 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip)
// the file name of the sound should be the soundID (provided from the project.json)
// followed by the file ext
const assetFileName = `${soundSource.soundID}.${ext}`;
soundPromises.push(deserializeSound(sound, runtime, zip, assetFileName)
.then(() => loadSound(sound, runtime, sprite))
soundPromises.push(
deserializeSound(sound, runtime, zip, assetFileName)
.then(() => loadSound(sound, runtime, soundBank))
);
}
}
// The stage will have child objects; recursively process them.
const childrenAssets = assets.children;
if (object.children) {
for (let m = 0; m < object.children.length; m++) {
childrenAssets.push(parseScratchAssets(object.children[m], runtime, false, zip));
}
}
return assets;
};
/**
* Parse a single "Scratch object" and create all its in-memory VM objects.
* TODO: parse the "info" section, especially "savedExtensions"
* @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 {boolean} topLevel - Whether this is the top-level object (stage).
* @param {?object} zip - Optional zipped assets for local file import
* @param {object} assets - Promises for assets of this scratch object grouped
* into costumes and sounds
* @return {!Promise.<Array.<Target>>} Promise for the loaded targets when ready, or null for unsupported objects.
*/
const parseScratchObject = function (object, runtime, extensions, topLevel, zip, assets) {
if (!object.hasOwnProperty('objName')) {
if (object.hasOwnProperty('listName')) {
// Shim these objects so they can be processed as monitors
object.cmd = 'contentsOfList:';
object.param = object.listName;
object.mode = 'list';
}
// Defer parsing monitors until targets are all parsed
object.deferredMonitor = true;
return Promise.resolve(object);
}
// 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('objName')) {
if (topLevel && object.objName !== 'Stage') {
for (const child of object.children) {
if (!child.hasOwnProperty('objName') && child.target === object.objName) {
child.target = 'Stage';
}
}
object.objName = 'Stage';
}
sprite.name = object.objName;
}
// Costumes from JSON.
const costumePromises = assets.costumePromises;
// Sounds from JSON
const {soundBank, soundPromises} = assets;
// Create the first clone, and load its run-state from JSON.
const target = sprite.createClone(topLevel ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER);
@ -697,13 +743,17 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip)
Promise.all(soundPromises).then(sounds => {
sprite.sounds = sounds;
// Make sure if soundBank is undefined, sprite.soundBank is then null.
sprite.soundBank = soundBank || null;
});
// The stage will have child objects; recursively process them.
const childrenPromises = [];
if (object.children) {
for (let m = 0; m < object.children.length; m++) {
childrenPromises.push(parseScratchObject(object.children[m], runtime, extensions, false, zip));
childrenPromises.push(
parseScratchObject(object.children[m], runtime, extensions, false, zip, assets.children[m])
);
}
}
@ -810,7 +860,13 @@ const sb2import = function (json, runtime, optForceSprite, zip) {
extensionIDs: new Set(),
extensionURLs: new Map()
};
return parseScratchObject(json, runtime, extensions, !optForceSprite, zip)
return Promise.resolve(parseScratchAssets(json, runtime, !optForceSprite, 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 => (
parseScratchObject(json, runtime, extensions, !optForceSprite, zip, assets)
))
.then(reorderParsedTargets)
.then(targets => ({
targets,

View file

@ -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.<Target>} Promise for the target created (stage or sprite), or null for unsupported objects.
* @return {?{costumePromises:Array.<Promise>,soundPromises:Array.<Promise>,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));
.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.<Target>} 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);
};
@ -1061,6 +1098,20 @@ const deserializeMonitor = function (monitorData, runtime, targets, extensions)
// This will be undefined for extension blocks
const monitorBlockInfo = runtime.monitorBlockInfo[monitorData.opcode];
// Due to a bug (see https://github.com/LLK/scratch-vm/pull/2322), renamed list monitors may have been serialized
// with an outdated/incorrect LIST parameter. Fix it up to use the current name of the actual corresponding list.
if (monitorData.opcode === 'data_listcontents') {
const listTarget = monitorData.targetId ?
targets.find(t => t.id === monitorData.targetId) :
targets.find(t => t.isStage);
if (
listTarget &&
Object.prototype.hasOwnProperty.call(listTarget.variables, monitorData.id)
) {
monitorData.params.LIST = listTarget.variables[monitorData.id].name;
}
}
// Convert the serialized monitorData params into the block fields structure
const fields = {};
for (const paramKey in monitorData.params) {
@ -1190,10 +1241,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.

View file

@ -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');
@ -273,9 +274,7 @@ class RenderedTarget extends Target {
this.x = position[0];
this.y = position[1];
this.renderer.updateDrawableProperties(this.drawableID, {
position: position
});
this.renderer.updateDrawablePosition(this.drawableID, position);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -322,11 +321,8 @@ class RenderedTarget extends Target {
// Keep direction between -179 and +180.
this.direction = MathUtil.wrapClamp(direction, -179, 180);
if (this.renderer) {
const renderedDirectionScale = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableProperties(this.drawableID, {
direction: renderedDirectionScale.direction,
scale: renderedDirectionScale.scale
});
const {direction: renderedDirection, scale} = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableDirectionScale(this.drawableID, renderedDirection, scale);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -372,9 +368,7 @@ class RenderedTarget extends Target {
}
this.visible = !!visible;
if (this.renderer) {
this.renderer.updateDrawableProperties(this.drawableID, {
visible: this.visible
});
this.renderer.updateDrawableVisible(this.drawableID, this.visible);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -403,11 +397,8 @@ class RenderedTarget extends Target {
(1.5 * this.runtime.constructor.STAGE_HEIGHT) / origH
);
this.size = MathUtil.clamp(size / 100, minScale, maxScale) * 100;
const renderedDirectionScale = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableProperties(this.drawableID, {
direction: renderedDirectionScale.direction,
scale: renderedDirectionScale.scale
});
const {direction, scale} = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableDirectionScale(this.drawableID, direction, scale);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -425,9 +416,7 @@ class RenderedTarget extends Target {
if (!this.effects.hasOwnProperty(effectName)) return;
this.effects[effectName] = value;
if (this.renderer) {
const props = {};
props[effectName] = this.effects[effectName];
this.renderer.updateDrawableProperties(this.drawableID, props);
this.renderer.updateDrawableEffect(this.drawableID, effectName, value);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -444,7 +433,10 @@ class RenderedTarget extends Target {
this.effects[effectName] = 0;
}
if (this.renderer) {
this.renderer.updateDrawableProperties(this.drawableID, this.effects);
for (const effectName in this.effects) {
if (!this.effects.hasOwnProperty(effectName)) continue;
this.renderer.updateDrawableEffect(this.drawableID, effectName, 0);
}
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -466,21 +458,8 @@ class RenderedTarget extends Target {
);
if (this.renderer) {
const costume = this.getCostumes()[this.currentCostume];
const drawableProperties = {
skinId: costume.skinId,
costumeResolution: costume.bitmapResolution
};
if (
typeof costume.rotationCenterX !== 'undefined' &&
typeof costume.rotationCenterY !== 'undefined'
) {
const scale = costume.bitmapResolution || 2;
drawableProperties.rotationCenter = [
costume.rotationCenterX / scale,
costume.rotationCenterY / scale
];
}
this.renderer.updateDrawableProperties(this.drawableID, drawableProperties);
this.renderer.updateDrawableSkinId(this.drawableID, costume.skinId);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -617,11 +596,8 @@ class RenderedTarget extends Target {
this.rotationStyle = RenderedTarget.ROTATION_STYLE_LEFT_RIGHT;
}
if (this.renderer) {
const renderedDirectionScale = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableProperties(this.drawableID, {
direction: renderedDirectionScale.direction,
scale: renderedDirectionScale.scale
});
const {direction, scale} = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableDirectionScale(this.drawableID, direction, scale);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -715,27 +691,19 @@ class RenderedTarget extends Target {
*/
updateAllDrawableProperties () {
if (this.renderer) {
const renderedDirectionScale = this._getRenderedDirectionAndScale();
const {direction, scale} = this._getRenderedDirectionAndScale();
this.renderer.updateDrawablePosition(this.drawableID, [this.x, this.y]);
this.renderer.updateDrawableDirectionScale(this.drawableID, direction, scale);
this.renderer.updateDrawableVisible(this.drawableID, this.visible);
const costume = this.getCostumes()[this.currentCostume];
const bitmapResolution = costume.bitmapResolution || 2;
const props = {
position: [this.x, this.y],
direction: renderedDirectionScale.direction,
draggable: this.draggable,
scale: renderedDirectionScale.scale,
visible: this.visible,
skinId: costume.skinId,
costumeResolution: bitmapResolution,
rotationCenter: [
costume.rotationCenterX / bitmapResolution,
costume.rotationCenterY / bitmapResolution
]
};
this.renderer.updateDrawableSkinId(this.drawableID, costume.skinId);
for (const effectName in this.effects) {
if (!this.effects.hasOwnProperty(effectName)) continue;
props[effectName] = this.effects[effectName];
this.renderer.updateDrawableEffect(this.drawableID, effectName, this.effects[effectName]);
}
this.renderer.updateDrawableProperties(this.drawableID, props);
if (this.visible) {
this.emit(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this);
this.runtime.requestRedraw();
@ -840,6 +808,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;

View file

@ -2,6 +2,7 @@ const RenderedTarget = require('./rendered-target');
const Blocks = require('../engine/blocks');
const {loadSoundFromAsset} = require('../import/load-sound');
const {loadCostumeFromAsset} = require('../import/load-costume');
const newBlockIds = require('../util/new-block-ids');
const StringUtil = require('../util/string-util');
const StageLayering = require('../engine/stage-layering');
@ -136,8 +137,14 @@ class Sprite {
duplicate () {
const newSprite = new Sprite(null, this.runtime);
const blocksContainer = this.blocks._blocks;
const originalBlocks = Object.keys(blocksContainer).map(key => blocksContainer[key]);
const copiedBlocks = JSON.parse(JSON.stringify(originalBlocks));
newBlockIds(copiedBlocks);
copiedBlocks.forEach(block => {
newSprite.blocks.createBlock(block);
});
newSprite.blocks = this.blocks.duplicate();
const allNames = this.runtime.targets.map(t => t.sprite.name);
newSprite.name = StringUtil.unusedName(this.name, allNames);
@ -153,7 +160,7 @@ class Sprite {
newSprite.sounds = this.sounds.map(sound => {
const newSound = Object.assign({}, sound);
const soundAsset = sound.asset;
assetPromises.push(loadSoundFromAsset(newSound, soundAsset, this.runtime, newSprite));
assetPromises.push(loadSoundFromAsset(newSound, soundAsset, this.runtime, newSprite.soundBank));
return newSound;
});

View file

@ -1,13 +1,5 @@
const Color = require('../util/color');
/**
* Store and possibly polyfill Number.isNaN. Number.isNaN can save time over
* self.isNaN by not coercing its input. We need to polyfill it to support
* Internet Explorer.
* @const
*/
const _NumberIsNaN = Number.isNaN || isNaN;
/**
* @fileoverview
* Utilities for casting and comparing Scratch data-types.
@ -33,13 +25,13 @@ class Cast {
if (typeof value === 'number') {
// Scratch treats NaN as 0, when needed as a number.
// E.g., 0 + NaN -> 0.
if (_NumberIsNaN(value)) {
if (Number.isNaN(value)) {
return 0;
}
return value;
}
const n = Number(value);
if (_NumberIsNaN(n)) {
if (Number.isNaN(n)) {
// Scratch treats NaN as 0, when needed as a number.
// E.g., 0 + NaN -> 0.
return 0;
@ -192,12 +184,13 @@ class Cast {
* LIST_INVALID: if the index was invalid in any way.
* @param {*} index Scratch arg, including 1-based numbers or special cases.
* @param {number} length Length of the list.
* @param {boolean} acceptAll Whether it should accept "all" or not.
* @return {(number|string)} 1-based index for list, LIST_ALL, or LIST_INVALID.
*/
static toListIndex (index, length) {
static toListIndex (index, length, acceptAll) {
if (typeof index !== 'number') {
if (index === 'all') {
return Cast.LIST_ALL;
return acceptAll ? Cast.LIST_ALL : Cast.LIST_INVALID;
}
if (index === 'last') {
if (length > 0) {

View file

@ -1,40 +0,0 @@
const JSONRPC = require('./jsonrpc');
// const log = require('../util/log');
class JSONRPCWebSocket extends JSONRPC {
constructor (webSocket) {
super();
this._ws = webSocket;
this._ws.onmessage = e => this._onSocketMessage(e);
this._ws.onopen = e => this._onSocketOpen(e);
this._ws.onclose = e => this._onSocketClose(e);
this._ws.onerror = e => this._onSocketError(e);
}
dispose () {
this._ws.close();
this._ws = null;
}
_onSocketOpen () {
}
_onSocketClose () {
}
_onSocketError () {
}
_onSocketMessage (e) {
const json = JSON.parse(e.data);
this._handleMessage(json);
}
_sendMessage (message) {
const messageText = JSON.stringify(message);
this._ws.send(messageText);
}
}
module.exports = JSONRPCWebSocket;

View file

@ -103,6 +103,20 @@ class MathUtil {
return randInt;
}
/**
* Scales a number from one range to another.
* @param {number} i number to be scaled
* @param {number} iMin input range minimum
* @param {number} iMax input range maximum
* @param {number} oMin output range minimum
* @param {number} oMax output range maximum
* @return {number} scaled number
*/
static scale (i, iMin, iMax, oMin, oMax) {
const p = (i - iMin) / (iMax - iMin);
return (p * (oMax - oMin)) + oMin;
}
}
module.exports = MathUtil;

View file

@ -0,0 +1,84 @@
/**
* This class provides a ScratchLinkSocket implementation using WebSockets,
* attempting to connect with the locally installed Scratch-Link.
*
* To connect with ScratchLink without WebSockets, you must implement all of the
* public methods in this class.
* - open()
* - close()
* - setOn[Open|Close|Error]
* - setHandleMessage
* - sendMessage(msgObj)
* - isOpen()
*/
class ScratchLinkWebSocket {
constructor (type) {
this._type = type;
this._onOpen = null;
this._onClose = null;
this._onError = null;
this._handleMessage = null;
this._ws = null;
}
open () {
switch (this._type) {
case 'BLE':
this._ws = new WebSocket('wss://device-manager.scratch.mit.edu:20110/scratch/ble');
break;
case 'BT':
this._ws = new WebSocket('wss://device-manager.scratch.mit.edu:20110/scratch/bt');
break;
default:
throw new Error(`Unknown ScratchLink socket Type: ${this._type}`);
}
if (this._onOpen && this._onClose && this._onError && this._handleMessage) {
this._ws.onopen = this._onOpen;
this._ws.onclose = this._onClose;
this._ws.onerror = this._onError;
} else {
throw new Error('Must set open, close, message and error handlers before calling open on the socket');
}
this._ws.onmessage = this._onMessage.bind(this);
}
close () {
this._ws.close();
this._ws = null;
}
sendMessage (message) {
const messageText = JSON.stringify(message);
this._ws.send(messageText);
}
setOnOpen (fn) {
this._onOpen = fn;
}
setOnClose (fn) {
this._onClose = fn;
}
setOnError (fn) {
this._onError = fn;
}
setHandleMessage (fn) {
this._handleMessage = fn;
}
isOpen () {
return this._ws && this._ws.readyState === this._ws.OPEN;
}
_onMessage (e) {
const json = JSON.parse(e.data);
this._handleMessage(json);
}
}
module.exports = ScratchLinkWebSocket;

View file

@ -12,18 +12,33 @@ class TaskQueue {
*
* @param {number} maxTokens - the maximum number of tokens in the bucket (burst size).
* @param {number} refillRate - the number of tokens to be added per second (sustain rate).
* @param {number} [startingTokens=maxTokens] - the number of tokens the bucket starts with.
* @param {object} options - optional settings for the new task queue instance.
* @property {number} startingTokens - the number of tokens the bucket starts with (default: `maxTokens`).
* @property {number} maxTotalCost - reject a task if total queue cost would pass this limit (default: no limit).
* @memberof TaskQueue
*/
constructor (maxTokens, refillRate, startingTokens = maxTokens) {
constructor (maxTokens, refillRate, options = {}) {
this._maxTokens = maxTokens;
this._refillRate = refillRate;
this._pendingTaskRecords = [];
this._tokenCount = startingTokens;
this._tokenCount = options.hasOwnProperty('startingTokens') ? options.startingTokens : maxTokens;
this._maxTotalCost = options.hasOwnProperty('maxTotalCost') ? options.maxTotalCost : Infinity;
this._timer = new Timer();
this._timer.start();
this._timeout = null;
this._lastUpdateTime = this._timer.timeElapsed();
this._runTasks = this._runTasks.bind(this);
}
/**
* Get the number of queued tasks which have not yet started.
*
* @readonly
* @memberof TaskQueue
*/
get length () {
return this._pendingTaskRecords.length;
}
/**
@ -35,38 +50,57 @@ class TaskQueue {
* @memberof TaskQueue
*/
do (task, cost = 1) {
const newRecord = {};
const promise = new Promise((resolve, reject) => {
newRecord.wrappedTask = () => {
const canRun = this._refillAndSpend(cost);
if (canRun) {
// Remove this task from the queue and run it
this._pendingTaskRecords.shift();
try {
resolve(task());
} catch (e) {
reject(e);
}
if (this._maxTotalCost < Infinity) {
const currentTotalCost = this._pendingTaskRecords.reduce((t, r) => t + r.cost, 0);
if (currentTotalCost + cost > this._maxTotalCost) {
return Promise.reject('Maximum total cost exceeded');
}
}
const newRecord = {
cost
};
newRecord.promise = new Promise((resolve, reject) => {
newRecord.cancel = () => {
reject(new Error('Task canceled'));
};
// Tell the next wrapper to start trying to run its task
if (this._pendingTaskRecords.length > 0) {
const nextRecord = this._pendingTaskRecords[0];
nextRecord.wrappedTask();
}
} else {
// This task can't run yet. Estimate when it will be able to, then try again.
newRecord.reject = reject;
this._waitUntilAffordable(cost).then(() => newRecord.wrappedTask());
// The caller, `_runTasks()`, is responsible for cost-checking and spending tokens.
newRecord.wrappedTask = () => {
try {
resolve(task());
} catch (e) {
reject(e);
}
};
});
this._pendingTaskRecords.push(newRecord);
// If the queue has been idle we need to prime the pump
if (this._pendingTaskRecords.length === 1) {
newRecord.wrappedTask();
this._runTasks();
}
return promise;
return newRecord.promise;
}
/**
* Cancel one pending task, rejecting its promise.
*
* @param {Promise} taskPromise - the promise returned by `do()`.
* @returns {boolean} - true if the task was found, or false otherwise.
* @memberof TaskQueue
*/
cancel (taskPromise) {
const taskIndex = this._pendingTaskRecords.findIndex(r => r.promise === taskPromise);
if (taskIndex !== -1) {
const [taskRecord] = this._pendingTaskRecords.splice(taskIndex, 1);
taskRecord.cancel();
if (taskIndex === 0 && this._pendingTaskRecords.length > 0) {
this._runTasks();
}
return true;
}
return false;
}
/**
@ -76,15 +110,16 @@ class TaskQueue {
*/
cancelAll () {
if (this._timeout !== null) {
clearTimeout(this._timeout);
this._timer.clearTimeout(this._timeout);
this._timeout = null;
}
this._pendingTaskRecords.forEach(r => r.reject());
const oldTasks = this._pendingTaskRecords;
this._pendingTaskRecords = [];
oldTasks.forEach(r => r.cancel());
}
/**
* Shorthand for calling @ _refill() then _spend(cost).
* Shorthand for calling _refill() then _spend(cost).
*
* @see {@link TaskQueue#_refill}
* @see {@link TaskQueue#_spend}
@ -129,33 +164,37 @@ class TaskQueue {
}
/**
* Create a Promise which will resolve when the bucket will be able to "afford" the given cost.
* Note that this won't refill the bucket, so make sure to refill after the promise resolves.
* Loop until the task queue is empty, running each task and spending tokens to do so.
* Any time the bucket can't afford the next task, delay asynchronously until it can.
*
* @param {number} cost - wait until the token count is at least this much.
* @returns {Promise} - to be resolved once the bucket is due for a token count greater than or equal to the cost.
* @memberof TaskQueue
*/
_waitUntilAffordable (cost) {
if (cost <= this._tokenCount) {
return Promise.resolve();
_runTasks () {
if (this._timeout) {
this._timer.clearTimeout(this._timeout);
this._timeout = null;
}
if (!(cost <= this._maxTokens)) {
return Promise.reject(new Error(`Task cost ${cost} is greater than bucket limit ${this._maxTokens}`));
for (;;) {
const nextRecord = this._pendingTaskRecords.shift();
if (!nextRecord) {
// We ran out of work. Go idle until someone adds another task to the queue.
return;
}
if (nextRecord.cost > this._maxTokens) {
throw new Error(`Task cost ${nextRecord.cost} is greater than bucket limit ${this._maxTokens}`);
}
// Refill before each task in case the time it took for the last task to run was enough to afford the next.
if (this._refillAndSpend(nextRecord.cost)) {
nextRecord.wrappedTask();
} else {
// We can't currently afford this task. Put it back and wait until we can and try again.
this._pendingTaskRecords.unshift(nextRecord);
const tokensNeeded = Math.max(nextRecord.cost - this._tokenCount, 0);
const estimatedWait = Math.ceil(1000 * tokensNeeded / this._refillRate);
this._timeout = this._timer.setTimeout(this._runTasks, estimatedWait);
return;
}
}
return new Promise(resolve => {
const tokensNeeded = Math.max(cost - this._tokenCount, 0);
const estimatedWait = Math.ceil(1000 * tokensNeeded / this._refillRate);
let timeout = null;
const onTimeout = () => {
if (this._timeout === timeout) {
this._timeout = null;
}
resolve();
};
this._timeout = timeout = setTimeout(onTimeout, estimatedWait);
});
}
}

View file

@ -90,6 +90,25 @@ class Timer {
timeElapsed () {
return this.nowObj.now() - this.startTime;
}
/**
* Call a handler function after a specified amount of time has elapsed.
* @param {function} handler - function to call after the timeout
* @param {number} timeout - number of milliseconds to delay before calling the handler
* @returns {number} - the ID of the new timeout
*/
setTimeout (handler, timeout) {
return global.setTimeout(handler, timeout);
}
/**
* Clear a timeout from the pending timeout pool.
* @param {number} timeoutId - the ID returned by `setTimeout()`
* @memberof Timer
*/
clearTimeout (timeoutId) {
global.clearTimeout(timeoutId);
}
}
module.exports = Timer;

View file

@ -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');
@ -107,18 +109,21 @@ class VirtualMachine extends EventEmitter {
this.runtime.on(Runtime.BLOCK_DRAG_END, (blocks, topBlockId) => {
this.emit(Runtime.BLOCK_DRAG_END, blocks, topBlockId);
});
this.runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => {
this.emit(Runtime.EXTENSION_ADDED, blocksInfo);
this.runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => {
this.emit(Runtime.EXTENSION_ADDED, categoryInfo);
});
this.runtime.on(Runtime.EXTENSION_FIELD_ADDED, (fieldName, fieldImplementation) => {
this.emit(Runtime.EXTENSION_FIELD_ADDED, fieldName, fieldImplementation);
});
this.runtime.on(Runtime.BLOCKSINFO_UPDATE, blocksInfo => {
this.emit(Runtime.BLOCKSINFO_UPDATE, blocksInfo);
this.runtime.on(Runtime.BLOCKSINFO_UPDATE, categoryInfo => {
this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo);
});
this.runtime.on(Runtime.BLOCKS_NEED_UPDATE, () => {
this.emitWorkspaceUpdate();
});
this.runtime.on(Runtime.TOOLBOX_EXTENSIONS_NEED_UPDATE, () => {
this.extensionManager.refreshBlocks();
});
this.runtime.on(Runtime.PERIPHERAL_LIST_UPDATE, info => {
this.emit(Runtime.PERIPHERAL_LIST_UPDATE, info);
});
@ -149,6 +154,11 @@ class VirtualMachine extends EventEmitter {
this.extensionManager = new ExtensionManager(this.runtime);
// Load core extensions
for (const id of CORE_EXTENSIONS) {
this.extensionManager.loadExtensionIdSync(id);
}
this.blockListener = this.blockListener.bind(this);
this.flyoutBlockListener = this.flyoutBlockListener.bind(this);
this.monitorBlockListener = this.monitorBlockListener.bind(this);
@ -297,6 +307,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 +316,8 @@ class VirtualMachine extends EventEmitter {
});
})
.catch(error => {
const {SB1File, ValidationError} = require('scratch-sb1-converter');
try {
const sb1 = new SB1File(input);
const json = sb1.json;
@ -372,6 +385,7 @@ class VirtualMachine extends EventEmitter {
return zip.generateAsync({
type: 'blob',
mimeType: 'application/x.scratch.sb3',
compression: 'DEFLATE',
compressionOptions: {
level: 6 // Tradeoff between best speed (1) and best compression (9)
@ -410,6 +424,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));
@ -420,6 +436,7 @@ class VirtualMachine extends EventEmitter {
return zip.generateAsync({
type: typeof optZipType === 'string' ? optZipType : 'blob',
mimeType: 'application/x.scratch.sprite3',
compression: 'DEFLATE',
compressionOptions: {
level: 6
@ -432,6 +449,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 +479,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.');
@ -483,14 +503,6 @@ class VirtualMachine extends EventEmitter {
installTargets (targets, extensions, wholeProject) {
const extensionPromises = [];
if (wholeProject) {
CORE_EXTENSIONS.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
extensionPromises.push(this.extensionManager.loadExtensionURL(extensionID));
}
});
}
extensions.extensionIDs.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID;
@ -553,6 +565,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 +605,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 +619,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));
@ -678,7 +692,7 @@ class VirtualMachine extends EventEmitter {
duplicateSound (soundIndex) {
const originalSound = this.editingTarget.getSounds()[soundIndex];
const clone = Object.assign({}, originalSound);
return loadSound(clone, this.runtime, this.editingTarget.sprite).then(() => {
return loadSound(clone, this.runtime, this.editingTarget.sprite.soundBank).then(() => {
this.editingTarget.addSound(clone, soundIndex + 1);
this.emitTargetsUpdate();
});
@ -723,7 +737,7 @@ class VirtualMachine extends EventEmitter {
const target = optTargetId ? this.runtime.getTargetById(optTargetId) :
this.editingTarget;
if (target) {
return loadSound(soundObject, this.runtime, target.sprite).then(() => {
return loadSound(soundObject, this.runtime, target.sprite.soundBank).then(() => {
target.addSound(soundObject);
this.emitTargetsUpdate();
});
@ -786,6 +800,8 @@ class VirtualMachine extends EventEmitter {
sound.assetId = sound.asset.assetId;
sound.dataFormat = storage.DataFormat.WAV;
sound.md5 = `${sound.assetId}.${sound.dataFormat}`;
sound.sampleCount = newBuffer.length;
sound.rate = newBuffer.sampleRate;
}
// If soundEncoding is null, it's because gui had a problem
// encoding the updated sound. We don't want to store anything in this
@ -850,10 +866,13 @@ class VirtualMachine extends EventEmitter {
costume.rotationCenterX = rotationCenterX;
costume.rotationCenterY = rotationCenterY;
// If the bitmap originally had a zero width or height, use that value
const bitmapWidth = bitmap.sourceWidth === 0 ? 0 : bitmap.width;
const bitmapHeight = bitmap.sourceHeight === 0 ? 0 : bitmap.height;
// @todo: updateBitmapSkin does not take ImageData
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.width = bitmapWidth;
canvas.height = bitmapHeight;
const context = canvas.getContext('2d');
context.putImageData(bitmap, 0, 0);
@ -873,7 +892,7 @@ class VirtualMachine extends EventEmitter {
const storage = this.runtime.storage;
costume.dataFormat = storage.DataFormat.PNG;
costume.bitmapResolution = bitmapResolution;
costume.size = [bitmap.width, bitmap.height];
costume.size = [bitmapWidth, bitmapHeight];
costume.asset = storage.createAsset(
storage.AssetType.ImageBitmap,
costume.dataFormat,
@ -885,7 +904,10 @@ class VirtualMachine extends EventEmitter {
costume.md5 = `${costume.assetId}.${costume.dataFormat}`;
this.emitTargetsUpdate();
});
reader.readAsArrayBuffer(blob);
// Bitmaps with a zero width or height return null for their blob
if (blob){
reader.readAsArrayBuffer(blob);
}
});
}
@ -912,7 +934,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 +1209,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);
@ -1250,7 +1274,7 @@ class VirtualMachine extends EventEmitter {
const originalSound = this.editingTarget.getSounds()[soundIndex];
const clone = Object.assign({}, originalSound);
const target = this.runtime.getTargetById(targetId);
return loadSound(clone, this.runtime, target.sprite).then(() => {
return loadSound(clone, this.runtime, target.sprite.soundBank).then(() => {
if (target) {
target.addSound(clone);
this.emitTargetsUpdate();
@ -1508,6 +1532,14 @@ class VirtualMachine extends EventEmitter {
}
return null;
}
/**
* Allow VM consumer to configure the ScratchLink socket creator.
* @param {Function} factory The custom ScratchLink socket factory.
*/
configureScratchLinkSocketFactory (factory) {
this.runtime.configureScratchLinkSocketFactory(factory);
}
}
module.exports = VirtualMachine;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -14,12 +14,25 @@ FakeRenderer.prototype.getFencedPositionOfDrawable = function (d, p) { // eslint
return [p[0], p[1]];
};
FakeRenderer.prototype.updateDrawableSkinId = function (d, skinId) { // eslint-disable-line no-unused-vars
};
FakeRenderer.prototype.updateDrawablePosition = function (d, position) { // eslint-disable-line no-unused-vars
this.x = position[0];
this.y = position[1];
};
FakeRenderer.prototype.updateDrawableDirectionScale =
function (d, direction, scale) {}; // eslint-disable-line no-unused-vars
FakeRenderer.prototype.updateDrawableVisible = function (d, visible) { // eslint-disable-line no-unused-vars
};
FakeRenderer.prototype.updateDrawableEffect = function (d, effectName, value) { // eslint-disable-line no-unused-vars
};
FakeRenderer.prototype.updateDrawableProperties = function (d, p) { // eslint-disable-line no-unused-vars
if (p.position) {
this.x = p.position[0];
this.y = p.position[1];
}
return true;
throw new Error('updateDrawableProperties is deprecated');
};
FakeRenderer.prototype.getCurrentSkinSize = function (d) { // eslint-disable-line no-unused-vars

BIN
test/fixtures/list-monitor-rename.sb3 vendored Normal file

Binary file not shown.

167
test/fixtures/mock-timer.js vendored Normal file
View file

@ -0,0 +1,167 @@
/**
* Mimic the Timer class with external control of the "time" value, allowing tests to run more quickly and
* reliably. Multiple instances of this class operate independently: they may report different time values, and
* advancing one timer will not trigger timeouts set on another.
*/
class MockTimer {
/**
* Creates an instance of MockTimer.
* @param {*} [nowObj=null] - alert the caller that this parameter, supported by Timer, is not supported here.
* @memberof MockTimer
*/
constructor (nowObj = null) {
if (nowObj) {
throw new Error('nowObj is not implemented in MockTimer');
}
/**
* The fake "current time" value, in epoch milliseconds.
* @type {number}
*/
this._mockTime = 0;
/**
* Used to store the start time of a timer action.
* Updated when calling `timer.start`.
* @type {number}
*/
this.startTime = 0;
/**
* The ID to use the next time `setTimeout` is called.
* @type {number}
*/
this._nextTimeoutId = 1;
/**
* Map of timeout ID to pending timeout callback info.
* @type {Map.<Object>}
* @property {number} time - the time at/after which this handler should run
* @property {Function} handler - the handler to call when the time comes
*/
this._timeouts = new Map();
}
/**
* Advance this MockTimer's idea of "current time", running timeout handlers if appropriate.
*
* @param {number} milliseconds - the amount of time to add to the current mock time value, in milliseconds.
* @memberof MockTimer
*/
advanceMockTime (milliseconds) {
if (milliseconds < 0) {
throw new Error('Time may not move backward');
}
this._mockTime += milliseconds;
this._runTimeouts();
}
/**
* Advance this MockTimer's idea of "current time", running timeout handlers if appropriate.
*
* @param {number} milliseconds - the amount of time to add to the current mock time value, in milliseconds.
* @returns {Promise} - promise which resolves after timeout handlers have had an opportunity to run.
* @memberof MockTimer
*/
advanceMockTimeAsync (milliseconds) {
return new Promise(resolve => {
this.advanceMockTime(milliseconds);
global.setTimeout(resolve, 0);
});
}
/**
* @returns {number} - current mock time elapsed since 1 January 1970 00:00:00 UTC.
* @memberof MockTimer
*/
time () {
return this._mockTime;
}
/**
* Returns a time accurate relative to other times produced by this function.
* @returns {number} ms-scale accurate time relative to other relative times.
* @memberof MockTimer
*/
relativeTime () {
return this._mockTime;
}
/**
* Start a timer for measuring elapsed time.
* @memberof MockTimer
*/
start () {
this.startTime = this._mockTime;
}
/**
* @returns {number} - the time elapsed since `start()` was called.
* @memberof MockTimer
*/
timeElapsed () {
return this._mockTime - this.startTime;
}
/**
* Call a handler function after a specified amount of time has elapsed.
* Guaranteed to happen in between "ticks" of JavaScript.
* @param {function} handler - function to call after the timeout
* @param {number} timeout - number of milliseconds to delay before calling the handler
* @returns {number} - the ID of the new timeout.
* @memberof MockTimer
*/
setTimeout (handler, timeout) {
const timeoutId = this._nextTimeoutId++;
this._timeouts.set(timeoutId, {
time: this._mockTime + timeout,
handler
});
this._runTimeouts();
return timeoutId;
}
/**
* Clear a particular timeout from the pending timeout pool.
* @param {number} timeoutId - the value returned from `setTimeout()`
* @memberof MockTimer
*/
clearTimeout (timeoutId) {
this._timeouts.delete(timeoutId);
}
/**
* WARNING: this method has no equivalent in `Timer`. Do not use this method outside of tests!
* @returns {boolean} - true if there are any pending timeouts, false otherwise.
* @memberof MockTimer
*/
hasTimeouts () {
return this._timeouts.size > 0;
}
/**
* Run any timeout handlers whose timeouts have expired.
* @memberof MockTimer
*/
_runTimeouts () {
const ready = [];
this._timeouts.forEach((timeoutRecord, timeoutId) => {
const isReady = timeoutRecord.time <= this._mockTime;
if (isReady) {
ready.push(timeoutRecord);
this._timeouts.delete(timeoutId);
}
});
// sort so that earlier timeouts run before later timeouts
ready.sort((a, b) => a.time < b.time);
// next tick, call everything that's ready
global.setTimeout(() => {
ready.forEach(o => o.handler());
}, 0);
}
}
module.exports = MockTimer;

View file

@ -7,7 +7,7 @@ const Thread = require('../../src/engine/thread');
const Runtime = require('../../src/engine/runtime');
const execute = require('../../src/engine/execute.js');
const projectUri = path.resolve(__dirname, '../fixtures/loudness-hat-block.sb2');
const projectUri = path.resolve(__dirname, '../fixtures/timer-greater-than-hat.sb2');
const project = readFileToBuffer(projectUri);
const checkIsHatThread = (t, vm, hatThread) => {
@ -24,8 +24,8 @@ const checkIsStackClickThread = (t, vm, stackClickThread) => {
};
/**
* loudness-hat-block.sb2 contains a single stack
* when loudness > 10
* timer-greater-than-hat.sb2 contains a single stack
* when timer > -1
* change color effect by 25
* The intention is to make sure that the hat block condition is evaluated
* on each frame.
@ -64,6 +64,52 @@ test('edge activated hat thread runs once every frame', t => {
});
});
/**
* When a hat is added it should run in the next frame. Any block related
* caching should be reset.
*/
test('edge activated hat thread runs after being added to previously executed target', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let threads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(threads.length, 1);
checkIsHatThread(t, vm, threads[0]);
t.assert(threads[0].status === Thread.STATUS_DONE);
// Add a second hat that should create a second thread
const hatBlock = threads[0].target.blocks.getBlock(threads[0].topBlock);
threads[0].target.blocks.createBlock(Object.assign(
{}, hatBlock, {id: 'hatblock2', next: null}
));
// Check that the hat thread is added again when another step is taken
vm.runtime._step();
threads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(threads.length, 2);
checkIsHatThread(t, vm, threads[0]);
checkIsHatThread(t, vm, threads[1]);
t.assert(threads[0].status === Thread.STATUS_DONE);
t.assert(threads[1].status === Thread.STATUS_DONE);
t.end();
});
});
});
/**
* If the hat doesn't finish evaluating within one frame, it shouldn't be added again
* on the next frame. (We skip execution by setting the step time to 0)
@ -111,7 +157,7 @@ test('edge activated hat thread not added twice', t => {
*/
test('edge activated hat should trigger for both sprites when sprite is duplicated', t => {
// Project that is similar to loudness-hat-block.sb2, but has code on the sprite so that
// Project that is similar to timer-greater-than-hat.sb2, but has code on the sprite so that
// the sprite can be duplicated
const projectWithSpriteUri = path.resolve(__dirname, '../fixtures/edge-triggered-hat.sb3');
const projectWithSprite = readFileToBuffer(projectWithSpriteUri);
@ -134,9 +180,6 @@ test('edge activated hat should trigger for both sprites when sprite is duplicat
t.equal(vm.runtime.threads.length, 1);
checkIsHatThread(t, vm, vm.runtime.threads[0]);
t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING);
// Run execute on the thread to populate the runtime's
// _edgeActivatedHatValues object
execute(vm.runtime.sequencer, vm.runtime.threads[0]);
let numTargetEdgeHats = vm.runtime.targets.reduce((val, target) =>
val + Object.keys(target._edgeActivatedHatValues).length, 0);
t.equal(numTargetEdgeHats, 1);
@ -145,7 +188,6 @@ test('edge activated hat should trigger for both sprites when sprite is duplicat
vm.runtime._step();
// Check that the runtime's _edgeActivatedHatValues object has two separate keys
// after execute is run on each thread
vm.runtime.threads.forEach(thread => execute(vm.runtime.sequencer, thread));
numTargetEdgeHats = vm.runtime.targets.reduce((val, target) =>
val + Object.keys(target._edgeActivatedHatValues).length, 0);
t.equal(numTargetEdgeHats, 2);

View file

@ -1,9 +1,14 @@
const test = require('tap').test;
const Worker = require('tiny-worker');
const BlockType = require('../../src/extension-support/block-type');
const dispatch = require('../../src/dispatch/central-dispatch');
const VirtualMachine = require('../../src/virtual-machine');
const Sprite = require('../../src/sprites/sprite');
const RenderedTarget = require('../../src/sprites/rendered-target');
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
@ -30,8 +35,9 @@ class TestInternalExtension {
};
}
go () {
go (args, util, blockInfo) {
this.status.goCalled = true;
return blockInfo;
}
_buildAMenu () {
@ -52,23 +58,70 @@ test('internal extension', t => {
t.ok(extension.status.constructorCalled);
t.notOk(extension.status.getInfoCalled);
return vm.extensionManager._registerInternalExtension(extension).then(() => {
t.ok(extension.status.getInfoCalled);
vm.extensionManager._registerInternalExtension(extension);
t.ok(extension.status.getInfoCalled);
const func = vm.runtime.getOpcodeFunction('testInternalExtension_go');
t.type(func, 'function');
const func = vm.runtime.getOpcodeFunction('testInternalExtension_go');
t.type(func, 'function');
t.notOk(extension.status.goCalled);
func();
t.ok(extension.status.goCalled);
t.notOk(extension.status.goCalled);
const goBlockInfo = func();
t.ok(extension.status.goCalled);
// There should be 2 menus - one is an array, one is the function to call.
t.equal(vm.runtime._blockInfo[0].menus.length, 2);
// First menu has 3 items.
t.equal(
vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3);
// Second menu is a dynamic menu and therefore should be a function.
t.type(
vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function');
});
// The 'go' block returns its own blockInfo. Make sure it matches the expected info.
// Note that the extension parser fills in missing fields so there are more fields here than in `getInfo`.
const expectedBlockInfo = {
arguments: {},
blockAllThreads: false,
blockType: BlockType.COMMAND,
func: goBlockInfo.func, // Cheat since we don't have a good way to ensure we generate the same function
opcode: 'go',
terminal: false,
text: 'go'
};
t.deepEqual(goBlockInfo, expectedBlockInfo);
// There should be 2 menus - one is an array, one is the function to call.
t.equal(vm.runtime._blockInfo[0].menus.length, 2);
// First menu has 3 items.
t.equal(
vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3);
// Second menu is a dynamic menu and therefore should be a function.
t.type(
vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function');
t.end();
});
test('load sync', t => {
const vm = new VirtualMachine();
vm.extensionManager.loadExtensionIdSync('coreExample');
t.ok(vm.extensionManager.isExtensionLoaded('coreExample'));
t.equal(vm.runtime._blockInfo.length, 1);
// blocks should be an array of two items: a button pseudo-block and a reporter block.
t.equal(vm.runtime._blockInfo[0].blocks.length, 3);
t.type(vm.runtime._blockInfo[0].blocks[0].info, 'object');
t.type(vm.runtime._blockInfo[0].blocks[0].info.func, 'MAKE_A_VARIABLE');
t.equal(vm.runtime._blockInfo[0].blocks[0].info.blockType, 'button');
t.type(vm.runtime._blockInfo[0].blocks[1].info, 'object');
t.equal(vm.runtime._blockInfo[0].blocks[1].info.opcode, 'exampleOpcode');
t.equal(vm.runtime._blockInfo[0].blocks[1].info.blockType, 'reporter');
t.type(vm.runtime._blockInfo[0].blocks[2].info, 'object');
t.equal(vm.runtime._blockInfo[0].blocks[2].info.opcode, 'exampleWithInlineImage');
t.equal(vm.runtime._blockInfo[0].blocks[2].info.blockType, 'command');
// Test the opcode function
t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'no stage yet');
const sprite = new Sprite(null, vm.runtime);
sprite.name = 'Stage';
const stage = new RenderedTarget(sprite, vm.runtime);
stage.isStage = true;
vm.runtime.targets = [stage];
t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'Stage');
t.end();
});

View file

@ -0,0 +1,52 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/list-monitor-rename.sb3');
const project = readFileToBuffer(projectUri);
test('importing sb3 project with incorrect list monitor name', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', () => {
const stage = vm.runtime.targets[0];
const cat = vm.runtime.targets[1];
for (const {target, renamedListName} of [
{target: stage, renamedListName: 'renamed global'},
{target: cat, renamedListName: 'renamed local'}
]) {
const listId = Object.keys(target.variables).find(k => target.variables[k].name === renamedListName);
const monitorRecord = vm.runtime._monitorState.get(listId);
const monitorBlock = vm.runtime.monitorBlocks.getBlock(listId);
t.equal(monitorRecord.opcode, 'data_listcontents');
// The list name should be properly renamed
t.equal(monitorRecord.params.LIST, renamedListName);
t.equal(monitorBlock.fields.LIST.value, renamedListName);
}
t.end();
process.nextTick(process.exit);
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View file

@ -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();
});

View file

@ -62,3 +62,21 @@ test('remote', t => {
.then(() => runServiceTest('RemoteDispatchTest', t), e => t.fail(e))
.then(() => dispatch._remoteCall(worker, 'dispatch', 'terminate'), e => t.fail(e));
});
test('local, sync', t => {
dispatch.setServiceSync('SyncDispatchTest', new DispatchTestService());
const a = dispatch.callSync('SyncDispatchTest', 'returnFortyTwo');
t.equal(a, 42);
const b = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 9);
t.equal(b, 18);
const c = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 123);
t.equal(c, 246);
t.throws(() => dispatch.callSync('SyncDispatchTest', 'throwException'),
new Error('This is a test exception thrown by DispatchTest'));
t.end();
});

View file

@ -25,7 +25,7 @@ test('spec', t => {
t.type(b.getNextBlock, 'function');
t.type(b.getBranch, 'function');
t.type(b.getOpcode, 'function');
t.type(b.mutationToXML, 'function');
t.end();
});
@ -239,6 +239,25 @@ test('getOpcode', t => {
t.end();
});
test('mutationToXML', t => {
const b = new Blocks(new Runtime());
const testStringRaw = '"arbitrary" & \'complicated\' test string';
const testStringEscaped = '\\&quot;arbitrary\\&quot; &amp; &apos;complicated&apos; test string';
const mutation = {
tagName: 'mutation',
children: [],
blockInfo: {
text: testStringRaw
}
};
const xml = b.mutationToXML(mutation);
t.equals(
xml,
`<mutation blockInfo="{&quot;text&quot;:&quot;${testStringEscaped}&quot;}"></mutation>`
);
t.end();
});
// Block events tests
test('create', t => {
const b = new Blocks(new Runtime());
@ -358,6 +377,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({

View file

@ -0,0 +1,26 @@
const test = require('tap').test;
const mutationAdapter = require('../../src/engine/mutation-adapter');
test('spec', t => {
t.type(mutationAdapter, 'function');
t.end();
});
test('convert DOM to Scratch object', t => {
const testStringRaw = '"arbitrary" & \'complicated\' test string';
const testStringEscaped = '\\&quot;arbitrary\\&quot; &amp; &apos;complicated&apos; test string';
const xml = `<mutation blockInfo="{&quot;text&quot;:&quot;${testStringEscaped}&quot;}"></mutation>`;
const expectedMutation = {
tagName: 'mutation',
children: [],
blockInfo: {
text: testStringRaw
}
};
// TODO: do we want to test passing a DOM node to `mutationAdapter`? Node.js doesn't have built-in DOM support...
const mutationFromString = mutationAdapter(xml);
t.deepEqual(mutationFromString, expectedMutation);
t.end();
});

View file

@ -11,20 +11,44 @@ const ScratchBlocksConstants = require('../../src/engine/scratch-blocks-constant
const testExtensionInfo = {
id: 'test',
name: 'fake test extension',
color1: '#111111',
color2: '#222222',
color3: '#333333',
blocks: [
{
func: 'MAKE_A_VARIABLE',
blockType: BlockType.BUTTON,
text: 'this is a button'
},
{
opcode: 'reporter',
blockType: BlockType.REPORTER,
text: 'simple text'
text: 'simple text',
blockIconURI: 'invalid icon URI' // trigger the 'scratch_extension' path
},
{
opcode: 'inlineImage',
blockType: BlockType.REPORTER,
text: 'text and [IMAGE]',
arguments: {
IMAGE: {
type: ArgumentType.IMAGE,
dataURI: 'invalid image URI'
}
}
},
'---', // separator between groups of blocks in an extension
{
opcode: 'command',
blockType: BlockType.COMMAND,
text: 'text with [ARG]',
text: 'text with [ARG] [ARG_WITH_DEFAULT]',
arguments: {
ARG: {
type: ArgumentType.STRING
},
ARG_WITH_DEFAULT: {
type: ArgumentType.STRING,
defaultValue: 'default text'
}
}
},
@ -58,30 +82,126 @@ const testExtensionInfo = {
]
};
const extensionInfoWithCustomFieldTypes = {
id: 'test_custom_fieldType',
name: 'fake test extension with customFieldTypes',
color1: '#111111',
color2: '#222222',
color3: '#333333',
blocks: [
{ // Block that uses custom field types
opcode: 'motorTurnFor',
blockType: BlockType.COMMAND,
text: '[PORT] run [DIRECTION] for [VALUE] [UNIT]',
arguments: {
PORT: {
defaultValue: 'A',
type: 'single-port-selector'
},
DIRECTION: {
defaultValue: 'clockwise',
type: 'custom-direction'
}
}
}
],
customFieldTypes: {
'single-port-selector': {
output: 'string',
outputShape: 2,
implementation: {
fromJson: () => null
}
},
'custom-direction': {
output: 'string',
outputShape: 3,
implementation: {
fromJson: () => null
}
}
}
};
const testCategoryInfo = function (t, block) {
t.equal(block.json.category, 'fake test extension');
t.equal(block.json.colour, '#111111');
t.equal(block.json.colourSecondary, '#222222');
t.equal(block.json.colourTertiary, '#333333');
t.equal(block.json.inputsInline, true);
};
const testButton = function (t, button) {
t.same(button.json, null); // should be null or undefined
t.equal(button.xml, '<button text="this is a button" callbackKey="MAKE_A_VARIABLE"></button>');
};
const testReporter = function (t, reporter) {
t.equal(reporter.json.type, 'test_reporter');
testCategoryInfo(t, reporter);
t.equal(reporter.json.checkboxInFlyout, true);
t.equal(reporter.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND);
t.equal(reporter.json.output, 'String');
t.notOk(reporter.json.hasOwnProperty('previousStatement'));
t.notOk(reporter.json.hasOwnProperty('nextStatement'));
t.equal(reporter.json.message0, 'simple text');
t.same(reporter.json.extensions, ['scratch_extension']);
t.equal(reporter.json.message0, '%1 %2simple text'); // "%1 %2" from the block icon
t.notOk(reporter.json.hasOwnProperty('message1'));
t.notOk(reporter.json.hasOwnProperty('args0'));
t.same(reporter.json.args0, [
// %1 in message0: the block icon
{
type: 'field_image',
src: 'invalid icon URI',
width: 40,
height: 40
},
// %2 in message0: separator between icon and text (only added when there's also an icon)
{
type: 'field_vertical_separator'
}
]);
t.notOk(reporter.json.hasOwnProperty('args1'));
t.equal(reporter.xml, '<block type="test_reporter"></block>');
};
const testInlineImage = function (t, inlineImage) {
t.equal(inlineImage.json.type, 'test_inlineImage');
testCategoryInfo(t, inlineImage);
t.equal(inlineImage.json.checkboxInFlyout, true);
t.equal(inlineImage.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND);
t.equal(inlineImage.json.output, 'String');
t.notOk(inlineImage.json.hasOwnProperty('previousStatement'));
t.notOk(inlineImage.json.hasOwnProperty('nextStatement'));
t.notOk(inlineImage.json.extensions && inlineImage.json.extensions.length); // OK if it's absent or empty
t.equal(inlineImage.json.message0, 'text and %1'); // block text followed by inline image
t.notOk(inlineImage.json.hasOwnProperty('message1'));
t.same(inlineImage.json.args0, [
// %1 in message0: the block icon
{
type: 'field_image',
src: 'invalid image URI',
width: 24,
height: 24,
flip_rtl: false // False by default
}
]);
t.notOk(inlineImage.json.hasOwnProperty('args1'));
t.equal(inlineImage.xml, '<block type="test_inlineImage"></block>');
};
const testSeparator = function (t, separator) {
t.equal(separator.json, null);
t.same(separator.json, null); // should be null or undefined
t.equal(separator.xml, '<sep gap="36"/>');
};
const testCommand = function (t, command) {
t.equal(command.json.type, 'test_command');
testCategoryInfo(t, command);
t.equal(command.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.assert(command.json.hasOwnProperty('previousStatement'));
t.assert(command.json.hasOwnProperty('nextStatement'));
t.equal(command.json.message0, 'text with %1');
t.notOk(command.json.extensions && command.json.extensions.length); // OK if it's absent or empty
t.equal(command.json.message0, 'text with %1 %2');
t.notOk(command.json.hasOwnProperty('message1'));
t.strictSame(command.json.args0[0], {
type: 'input_value',
@ -89,15 +209,18 @@ const testCommand = function (t, command) {
});
t.notOk(command.json.hasOwnProperty('args1'));
t.equal(command.xml,
'<block type="test_command"><value name="ARG"><shadow type="text"><field name="TEXT">' +
'</field></shadow></value></block>');
'<block type="test_command"><value name="ARG"><shadow type="text"></shadow></value>' +
'<value name="ARG_WITH_DEFAULT"><shadow type="text"><field name="TEXT">' +
'default text</field></shadow></value></block>');
};
const testConditional = function (t, conditional) {
t.equal(conditional.json.type, 'test_ifElse');
testCategoryInfo(t, conditional);
t.equal(conditional.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.ok(conditional.json.hasOwnProperty('previousStatement'));
t.ok(conditional.json.hasOwnProperty('nextStatement'));
t.notOk(conditional.json.extensions && conditional.json.extensions.length); // OK if it's absent or empty
t.equal(conditional.json.message0, 'test if %1 is spiffy and if so then');
t.equal(conditional.json.message1, '%1'); // placeholder for substack #1
t.equal(conditional.json.message2, 'or elsewise');
@ -123,9 +246,11 @@ const testConditional = function (t, conditional) {
const testLoop = function (t, loop) {
t.equal(loop.json.type, 'test_loop');
testCategoryInfo(t, loop);
t.equal(loop.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.ok(loop.json.hasOwnProperty('previousStatement'));
t.notOk(loop.json.hasOwnProperty('nextStatement')); // isTerminal is set on this block
t.notOk(loop.json.extensions && loop.json.extensions.length); // OK if it's absent or empty
t.equal(loop.json.message0, 'loopty %1 loops');
t.equal(loop.json.message1, '%1'); // placeholder for substack
t.equal(loop.json.message2, '%1'); // placeholder for loop arrow
@ -143,20 +268,27 @@ const testLoop = function (t, loop) {
t.equal(loop.json.args2[0].flip_rtl, true);
t.notOk(loop.json.hasOwnProperty('args3'));
t.equal(loop.xml,
'<block type="test_loop"><value name="MANY"><shadow type="math_number"><field name="NUM">' +
'</field></shadow></value></block>');
'<block type="test_loop"><value name="MANY"><shadow type="math_number"></shadow></value></block>');
};
test('registerExtensionPrimitives', t => {
const runtime = new Runtime();
runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => {
runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => {
const blocksInfo = categoryInfo.blocks;
t.equal(blocksInfo.length, testExtensionInfo.blocks.length);
// Note that this also implicitly tests that block order is preserved
const [reporter, separator, command, conditional, loop] = blocksInfo;
blocksInfo.forEach(blockInfo => {
// `true` here means "either an object or a non-empty string but definitely not null or undefined"
t.true(blockInfo.info, 'Every block and pseudo-block must have a non-empty "info" field');
});
// Note that this also implicitly tests that block order is preserved
const [button, reporter, inlineImage, separator, command, conditional, loop] = blocksInfo;
testButton(t, button);
testReporter(t, reporter);
testInlineImage(t, inlineImage);
testSeparator(t, separator);
testCommand(t, command);
testConditional(t, conditional);
@ -167,3 +299,29 @@ test('registerExtensionPrimitives', t => {
runtime._registerExtensionPrimitives(testExtensionInfo);
});
test('custom field types should be added to block and EXTENSION_FIELD_ADDED callback triggered', t => {
const runtime = new Runtime();
runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => {
const blockInfo = categoryInfo.blocks[0];
// We expect that for each argument there's a corresponding <field>-tag in the block XML
Object.values(blockInfo.info.arguments).forEach(argument => {
const regex = new RegExp(`<field name="field_${categoryInfo.id}_${argument.type}">`);
t.true(regex.test(blockInfo.xml));
});
});
let fieldAddedCallbacks = 0;
runtime.on(Runtime.EXTENSION_FIELD_ADDED, () => {
fieldAddedCallbacks++;
});
runtime._registerExtensionPrimitives(extensionInfoWithCustomFieldTypes);
// Extension includes two custom field types
t.equal(fieldAddedCallbacks, 2);
t.end();
});

View file

@ -0,0 +1,44 @@
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('if a supported language name is dropped onto the set language block, use it', t => {
ext.setLanguage({LANGUAGE: 'español'});
t.strictEqual(ext.getCurrentLanguage(), 'es');
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();
});
test('use localized spoken language name in place of localized written language name', t => {
ext.getEditorLanguage = () => 'es';
const languageMenu = ext.getLanguageMenu();
const localizedNameForChineseInSpanish = languageMenu.find(el => el.value === 'zh-cn').text;
t.strictEqual(localizedNameForChineseInSpanish, 'Chino (Mandarín)'); // i.e. should not be 'Chino (simplificado)'
t.end();
});

View file

@ -87,6 +87,8 @@ const isNearAngle = (actual, expect, optMargin = 10) => (
// A fake scratch-render drawable that will be used by VideoMotion to restrain
// the area considered for motion detection in VideoMotion.getLocalMotion
const fakeDrawable = {
updateMatrix () {}, // no-op, since isTouching always returns true
getFastBounds () {
return {
left: -120,

View file

@ -23,12 +23,15 @@ test('cycle', t => {
setTimeout(() => {
c.resetProjectTimer();
setTimeout(() => {
t.ok(c.projectTimer() > 0);
// The timer shouldn't advance until all threads have been stepped
t.ok(c.projectTimer() === 0);
c.pause();
t.ok(c.projectTimer() > 0);
t.ok(c.projectTimer() === 0);
c.resume();
t.ok(c.projectTimer() > 0);
t.ok(c.projectTimer() === 0);
t.end();
}, 100);
}, 100);
rt._step();
t.ok(c.projectTimer() > 0);
});

91
test/unit/mock-timer.js Normal file
View file

@ -0,0 +1,91 @@
const test = require('tap').test;
const MockTimer = require('../fixtures/mock-timer');
test('spec', t => {
const timer = new MockTimer();
t.type(MockTimer, 'function');
t.type(timer, 'object');
// Most members of MockTimer mimic members of Timer.
t.type(timer.startTime, 'number');
t.type(timer.time, 'function');
t.type(timer.start, 'function');
t.type(timer.timeElapsed, 'function');
t.type(timer.setTimeout, 'function');
t.type(timer.clearTimeout, 'function');
// A few members of MockTimer have no Timer equivalent and should only be used in tests.
t.type(timer.advanceMockTime, 'function');
t.type(timer.advanceMockTimeAsync, 'function');
t.type(timer.hasTimeouts, 'function');
t.end();
});
test('time', t => {
const timer = new MockTimer();
const delta = 1;
const time1 = timer.time();
const time2 = timer.time();
timer.advanceMockTime(delta);
const time3 = timer.time();
t.equal(time1, time2);
t.equal(time2 + delta, time3);
t.end();
});
test('start / timeElapsed', t => new Promise(resolve => {
const timer = new MockTimer();
const halfDelay = 1;
const fullDelay = halfDelay + halfDelay;
timer.start();
let timeoutCalled = 0;
// Wait and measure timer
timer.setTimeout(() => {
t.equal(timeoutCalled, 0);
++timeoutCalled;
const timeElapsed = timer.timeElapsed();
t.equal(timeElapsed, fullDelay);
t.end();
resolve();
}, fullDelay);
// this should not trigger the callback
timer.advanceMockTime(halfDelay);
// give the mock timer a chance to run tasks
global.setTimeout(() => {
// we've only mock-waited for half the delay so it should not have run yet
t.equal(timeoutCalled, 0);
// this should trigger the callback
timer.advanceMockTime(halfDelay);
}, 0);
}));
test('clearTimeout / hasTimeouts', t => new Promise((resolve, reject) => {
const timer = new MockTimer();
const timeoutId = timer.setTimeout(() => {
reject(new Error('Canceled task ran'));
}, 1);
timer.setTimeout(() => {
resolve('Non-canceled task ran');
t.end();
}, 2);
timer.clearTimeout(timeoutId);
while (timer.hasTimeouts()) {
timer.advanceMockTime(1);
}
}));

View file

@ -27,6 +27,24 @@ test('setxy', t => {
t.end();
});
test('blocks get new id on duplicate', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const rt = new RenderedTarget(s, r);
const block = {
id: 'id1',
topLevel: true,
fields: {}
};
rt.blocks.createBlock(block);
return rt.duplicate().then(duplicate => {
t.notOk(duplicate.blocks._blocks.hasOwnProperty(block.id));
t.end();
});
});
test('direction', t => {
const r = new Runtime();
const s = new Sprite(null, r);

View file

@ -167,32 +167,33 @@ test('toListIndex', t => {
const empty = [];
// Valid
t.strictEqual(cast.toListIndex(1, list.length), 1);
t.strictEqual(cast.toListIndex(6, list.length), 6);
t.strictEqual(cast.toListIndex(1, list.length, false), 1);
t.strictEqual(cast.toListIndex(6, list.length, false), 6);
// Invalid
t.strictEqual(cast.toListIndex(-1, list.length), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(0.1, list.length), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(0, list.length), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(7, list.length), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(-1, list.length, false), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(0.1, list.length, false), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(0, list.length, false), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(7, list.length, false), cast.LIST_INVALID);
// "all"
t.strictEqual(cast.toListIndex('all', list.length), cast.LIST_ALL);
t.strictEqual(cast.toListIndex('all', list.length, true), cast.LIST_ALL);
t.strictEqual(cast.toListIndex('all', list.length, false), cast.LIST_INVALID);
// "last"
t.strictEqual(cast.toListIndex('last', list.length), list.length);
t.strictEqual(cast.toListIndex('last', empty.length), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex('last', list.length, false), list.length);
t.strictEqual(cast.toListIndex('last', empty.length, false), cast.LIST_INVALID);
// "random"
const random = cast.toListIndex('random', list.length);
const random = cast.toListIndex('random', list.length, false);
t.ok(random <= list.length);
t.ok(random > 0);
t.strictEqual(cast.toListIndex('random', empty.length), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex('random', empty.length, false), cast.LIST_INVALID);
// "any" (alias for "random")
const any = cast.toListIndex('any', list.length);
const any = cast.toListIndex('any', list.length, false);
t.ok(any <= list.length);
t.ok(any > 0);
t.strictEqual(cast.toListIndex('any', empty.length), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex('any', empty.length, false), cast.LIST_INVALID);
t.end();
});

View file

@ -1,21 +1,92 @@
const test = require('tap').skip;
const test = require('tap').test;
const TaskQueue = require('../../src/util/task-queue');
const MockTimer = require('../fixtures/mock-timer');
const testCompare = require('../fixtures/test-compare');
test('constructor', t => {
// Max tokens = 1000, refill 1000 tokens per second (1 per millisecond), and start with 0 tokens
const bukkit = new TaskQueue(1000, 1000, 0);
// Max tokens = 1000
// Refill 1000 tokens per second (1 per millisecond)
// Token bucket starts empty
// Max total cost of queued tasks = 10000 tokens = 10 seconds
const makeTestQueue = () => {
const bukkit = new TaskQueue(1000, 1000, {
startingTokens: 0,
maxTotalCost: 10000
});
// Simulate time passing with a stubbed timer
const simulatedTimeStart = Date.now();
bukkit._timer = {timeElapsed: () => Date.now() - simulatedTimeStart};
const mockTimer = new MockTimer();
bukkit._timer = mockTimer;
mockTimer.start();
return bukkit;
};
test('spec', t => {
t.type(TaskQueue, 'function');
const bukkit = makeTestQueue();
t.type(bukkit, 'object');
t.type(bukkit.length, 'number');
t.type(bukkit.do, 'function');
t.type(bukkit.cancel, 'function');
t.type(bukkit.cancelAll, 'function');
t.end();
});
test('constructor', t => {
t.ok(new TaskQueue(1, 1));
t.ok(new TaskQueue(1, 1, {}));
t.ok(new TaskQueue(1, 1, {startingTokens: 0}));
t.ok(new TaskQueue(1, 1, {maxTotalCost: 999}));
t.ok(new TaskQueue(1, 1, {startingTokens: 0, maxTotalCost: 999}));
t.end();
});
test('run tasks', async t => {
const bukkit = makeTestQueue();
const taskResults = [];
const promises = [
bukkit.do(() => {
taskResults.push('a');
testCompare(t, bukkit._timer.timeElapsed(), '>=', 50, 'Costly task must wait');
}, 50),
bukkit.do(() => {
taskResults.push('b');
testCompare(t, bukkit._timer.timeElapsed(), '>=', 60, 'Tasks must run in serial');
}, 10),
bukkit.do(() => {
taskResults.push('c');
testCompare(t, bukkit._timer.timeElapsed(), '<=', 70, 'Cheap task should run soon');
}, 1)
];
// advance 10 simulated milliseconds per JS tick
while (bukkit.length > 0) {
await bukkit._timer.advanceMockTimeAsync(10);
}
return Promise.all(promises).then(() => {
t.deepEqual(taskResults, ['a', 'b', 'c'], 'All tasks must run in correct order');
t.end();
});
});
test('cancel', async t => {
const bukkit = makeTestQueue();
const taskResults = [];
const promises = [];
const goodCancelMessage = 'Task was canceled correctly';
bukkit.do(() => taskResults.push('nope'), 999).then(
const afterCancelMessage = 'Task was run correctly';
const cancelTaskPromise = bukkit.do(
() => {
taskResults.push('nope');
}, 999);
const cancelCheckPromise = cancelTaskPromise.then(
() => {
t.fail('Task should have been canceled');
},
@ -23,19 +94,99 @@ test('constructor', t => {
taskResults.push(goodCancelMessage);
}
);
bukkit.cancelAll();
promises.push(
bukkit.do(() => taskResults.push('a'), 50).then(() =>
testCompare(t, bukkit._timer.timeElapsed(), '>=', 50, 'Costly task must wait')
),
bukkit.do(() => taskResults.push('b'), 10).then(() =>
testCompare(t, bukkit._timer.timeElapsed(), '>=', 60, 'Tasks must run in serial')
),
bukkit.do(() => taskResults.push('c'), 1).then(() =>
testCompare(t, bukkit._timer.timeElapsed(), '<', 80, 'Cheap task should run soon')
)
);
return Promise.all(promises).then(() => {
t.deepEqual(taskResults, [goodCancelMessage, 'a', 'b', 'c'], 'All tasks must run in correct order');
const keepTaskPromise = bukkit.do(
() => {
taskResults.push(afterCancelMessage);
testCompare(t, bukkit._timer.timeElapsed(), '<', 10, 'Canceled task must not delay other tasks');
}, 5);
// give the bucket a chance to make a mistake
await bukkit._timer.advanceMockTimeAsync(1);
t.equal(bukkit.length, 2);
const taskWasCanceled = bukkit.cancel(cancelTaskPromise);
t.ok(taskWasCanceled);
t.equal(bukkit.length, 1);
while (bukkit.length > 0) {
await bukkit._timer.advanceMockTimeAsync(1);
}
return Promise.all([cancelCheckPromise, keepTaskPromise]).then(() => {
t.deepEqual(taskResults, [goodCancelMessage, afterCancelMessage]);
t.end();
});
});
test('cancelAll', async t => {
const bukkit = makeTestQueue();
const taskResults = [];
const goodCancelMessage1 = 'Task1 was canceled correctly';
const goodCancelMessage2 = 'Task2 was canceled correctly';
const promises = [
bukkit.do(() => taskResults.push('nope'), 999).then(
() => {
t.fail('Task1 should have been canceled');
},
() => {
taskResults.push(goodCancelMessage1);
}
),
bukkit.do(() => taskResults.push('nah'), 999).then(
() => {
t.fail('Task2 should have been canceled');
},
() => {
taskResults.push(goodCancelMessage2);
}
)
];
// advance time, but not enough that any task should run
await bukkit._timer.advanceMockTimeAsync(100);
bukkit.cancelAll();
// advance enough that both tasks would run if they hadn't been canceled
await bukkit._timer.advanceMockTimeAsync(10000);
return Promise.all(promises).then(() => {
t.deepEqual(taskResults, [goodCancelMessage1, goodCancelMessage2], 'Tasks should cancel in order');
t.end();
});
});
test('max total cost', async t => {
const bukkit = makeTestQueue();
let numTasks = 0;
const task = () => ++numTasks;
// Fill the queue
for (let i = 0; i < 10; ++i) {
bukkit.do(task, 1000);
}
// This one should be rejected because the queue is full
bukkit
.do(task, 1000)
.then(
() => {
t.fail('Full queue did not reject task');
},
() => {
t.pass();
}
);
while (bukkit.length > 0) {
await bukkit._timer.advanceMockTimeAsync(1000);
}
// this should be 10 if the last task is rejected or 11 if it runs
t.equal(numTasks, 10);
t.end();
});

View file

@ -1,8 +1,18 @@
const test = require('tap').test;
const Timer = require('../../src/util/timer');
// Stubbed current time
let NOW = 0;
const testNow = {
now: () => {
NOW += 100;
return NOW;
}
};
test('spec', t => {
const timer = new Timer();
const timer = new Timer(testNow);
t.type(Timer, 'function');
t.type(timer, 'object');
@ -11,32 +21,44 @@ test('spec', t => {
t.type(timer.time, 'function');
t.type(timer.start, 'function');
t.type(timer.timeElapsed, 'function');
t.type(timer.setTimeout, 'function');
t.type(timer.clearTimeout, 'function');
t.end();
});
test('time', t => {
const timer = new Timer();
const timer = new Timer(testNow);
const time = timer.time();
t.ok(Date.now() >= time);
t.ok(testNow.now() >= time);
t.end();
});
test('start / timeElapsed', t => {
const timer = new Timer();
const timer = new Timer(testNow);
const delay = 100;
const threshold = 1000 / 60; // 60 hz
// Start timer
timer.start();
// Wait and measure timer
setTimeout(() => {
const timeElapsed = timer.timeElapsed();
t.ok(timeElapsed >= 0);
t.ok(timeElapsed >= (delay - threshold) &&
timeElapsed <= (delay + threshold));
t.end();
}, delay);
// Measure timer
const timeElapsed = timer.timeElapsed();
t.ok(timeElapsed >= 0);
t.ok(timeElapsed >= (delay - threshold) &&
timeElapsed <= (delay + threshold));
t.end();
});
test('setTimeout / clearTimeout', t => new Promise((resolve, reject) => {
const timer = new Timer(testNow);
const cancelId = timer.setTimeout(() => {
reject(new Error('Canceled task ran'));
}, 1);
timer.setTimeout(() => {
resolve('Non-canceled task ran');
t.end();
}, 2);
timer.clearTimeout(cancelId);
}));

View file

@ -72,7 +72,6 @@ module.exports = [
},
externals: {
'decode-html': true,
'escape-html': true,
'format-message': true,
'htmlparser2': true,
'immutable': true,