Merge branch 'develop' of https://github.com/LLK/scratch-vm into old-text-costumes

This commit is contained in:
Florrie 2018-01-23 11:35:46 -04:00
commit 287222f7ed
No known key found for this signature in database
GPG key ID: E2877D259442CB08
141 changed files with 7600 additions and 1757 deletions

View file

@ -2,3 +2,4 @@ coverage/*
dist/* dist/*
node_modules/* node_modules/*
playground/* playground/*
benchmark/*

5
.gitignore vendored
View file

@ -2,6 +2,7 @@
.DS_Store .DS_Store
# NPM # NPM
package-lock.json
/node_modules /node_modules
npm-* npm-*
@ -15,3 +16,7 @@ npm-*
# Build # Build
/dist /dist
/playground /playground
/benchmark
# Localization
/translations

View file

@ -5,13 +5,14 @@ node_js:
env: env:
- NPM_SCRIPT="tap:unit -- --jobs=4" - NPM_SCRIPT="tap:unit -- --jobs=4"
- NPM_SCRIPT="tap:integration -- --jobs=4" - NPM_SCRIPT="tap:integration -- --jobs=4"
- NODE_ENV=production
sudo: false sudo: false
cache: cache:
directories: directories:
- node_modules - node_modules
install: install:
- npm install - npm --production=false install
- npm update - npm --production=false update
script: npm run $NPM_SCRIPT script: npm run $NPM_SCRIPT
jobs: jobs:
include: include:

8
.tx/config Normal file
View file

@ -0,0 +1,8 @@
[main]
host = https://www.transifex.com
[experimental-scratch.pen]
file_filter = translations/pen/<lang>.json
source_file = translations/pen/en.json
source_lang = en
type = CHROME

View file

@ -10,19 +10,22 @@
"url": "git+ssh://git@github.com/LLK/scratch-vm.git" "url": "git+ssh://git@github.com/LLK/scratch-vm.git"
}, },
"main": "./dist/node/scratch-vm.js", "main": "./dist/node/scratch-vm.js",
"browser": "./dist/web/scratch-vm.js",
"scripts": { "scripts": {
"build": "./node_modules/.bin/webpack --progress --colors --bail", "build": "webpack --progress --colors --bail",
"coverage": "./node_modules/.bin/tap ./test/{unit,integration}/*.js --coverage --coverage-report=lcov", "coverage": "tap ./test/{unit,integration}/*.js --coverage --coverage-report=lcov",
"deploy": "touch playground/.nojekyll && ./node_modules/.bin/gh-pages -t -d playground -m \"Build for $(git log --pretty=format:%H -n1)\"", "deploy": "touch playground/.nojekyll && gh-pages -t -d playground -m \"Build for $(git log --pretty=format:%H -n1)\"",
"lint": "./node_modules/.bin/eslint .", "extract:pen": "mkdirp translations/pen && format-message extract --out-file translations/pen/en.json src/extensions/scratch3_pen/index.js",
"i18n:src": "npm run extract:pen",
"lint": "eslint . && format-message lint src/**/*.js",
"prepublish": "in-publish && npm run build || not-in-publish", "prepublish": "in-publish && npm run build || not-in-publish",
"start": "./node_modules/.bin/webpack-dev-server", "start": "webpack-dev-server",
"tap": "./node_modules/.bin/tap ./test/{unit,integration}/*.js", "tap": "tap ./test/{unit,integration}/*.js",
"tap:unit": "./node_modules/.bin/tap ./test/unit/*.js", "tap:unit": "tap ./test/unit/*.js",
"tap:integration": "./node_modules/.bin/tap ./test/integration/*.js", "tap:integration": "tap ./test/integration/*.js",
"test": "npm run lint && npm run tap", "test": "npm run lint && npm run tap",
"watch": "./node_modules/.bin/webpack --progress --colors --watch", "watch": "webpack --progress --colors --watch",
"version": "./node_modules/.bin/json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"" "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\""
}, },
"devDependencies": { "devDependencies": {
"adm-zip": "0.4.7", "adm-zip": "0.4.7",
@ -30,12 +33,15 @@
"babel-eslint": "^7.1.1", "babel-eslint": "^7.1.1",
"babel-loader": "^7.0.0", "babel-loader": "^7.0.0",
"babel-preset-es2015": "^6.24.1", "babel-preset-es2015": "^6.24.1",
"copy-webpack-plugin": "4.0.1", "copy-webpack-plugin": "4.2.1",
"decode-html": "2.0.0",
"escape-html": "1.0.3",
"eslint": "^4.5.0", "eslint": "^4.5.0",
"eslint-config-scratch": "^4.0.0", "eslint-config-scratch": "^5.0.0",
"expose-loader": "0.7.3", "expose-loader": "0.7.4",
"gh-pages": "^0.12.0", "format-message": "5.2.1",
"got": "5.7.1", "format-message-cli": "5.2.1",
"gh-pages": "^1.1.0",
"highlightjs": "^9.8.0", "highlightjs": "^9.8.0",
"htmlparser2": "3.9.2", "htmlparser2": "3.9.2",
"immutable": "3.8.1", "immutable": "3.8.1",
@ -43,17 +49,20 @@
"json": "^9.0.4", "json": "^9.0.4",
"lodash.defaultsdeep": "4.6.0", "lodash.defaultsdeep": "4.6.0",
"minilog": "3.1.0", "minilog": "3.1.0",
"promise": "7.1.1", "nets": "3.2.0",
"promise": "8.0.1",
"scratch-audio": "latest", "scratch-audio": "latest",
"scratch-blocks": "latest", "scratch-blocks": "latest",
"scratch-render": "latest", "scratch-render": "latest",
"scratch-storage": "^0.2.0", "scratch-storage": "^0.3.0",
"script-loader": "0.7.0", "script-loader": "0.7.2",
"socket.io-client": "1.7.3", "socket.io-client": "2.0.4",
"stats.js": "^0.17.0", "stats.js": "^0.17.0",
"tap": "^10.2.0", "tap": "^10.2.0",
"text-encoding": "0.6.4",
"tiny-worker": "^2.1.1", "tiny-worker": "^2.1.1",
"webpack": "^2.4.1", "webpack": "^3.10.0",
"webpack-dev-server": "^2.4.1" "webpack-dev-server": "^2.4.1",
"worker-loader": "1.1.0"
} }
} }

View file

@ -30,30 +30,34 @@ class Scratch3DataBlocks {
} }
getVariable (args, util) { getVariable (args, util) {
const variable = util.target.lookupOrCreateVariable(args.VARIABLE); const variable = util.target.lookupOrCreateVariable(
args.VARIABLE.id, args.VARIABLE.name);
return variable.value; return variable.value;
} }
setVariableTo (args, util) { setVariableTo (args, util) {
const variable = util.target.lookupOrCreateVariable(args.VARIABLE); const variable = util.target.lookupOrCreateVariable(
args.VARIABLE.id, args.VARIABLE.name);
variable.value = args.VALUE; variable.value = args.VALUE;
} }
changeVariableBy (args, util) { changeVariableBy (args, util) {
const variable = util.target.lookupOrCreateVariable(args.VARIABLE); const variable = util.target.lookupOrCreateVariable(
args.VARIABLE.id, args.VARIABLE.name);
const castedValue = Cast.toNumber(variable.value); const castedValue = Cast.toNumber(variable.value);
const dValue = Cast.toNumber(args.VALUE); const dValue = Cast.toNumber(args.VALUE);
variable.value = castedValue + dValue; variable.value = castedValue + dValue;
} }
getListContents (args, util) { getListContents (args, util) {
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
// Determine if the list is all single letters. // Determine if the list is all single letters.
// If it is, report contents joined together with no separator. // If it is, report contents joined together with no separator.
// If it's not, report contents joined together with a space. // If it's not, report contents joined together with a space.
let allSingleLetters = true; let allSingleLetters = true;
for (let i = 0; i < list.contents.length; i++) { for (let i = 0; i < list.value.length; i++) {
const listItem = list.contents[i]; const listItem = list.value[i];
if (!((typeof listItem === 'string') && if (!((typeof listItem === 'string') &&
(listItem.length === 1))) { (listItem.length === 1))) {
allSingleLetters = false; allSingleLetters = false;
@ -61,73 +65,80 @@ class Scratch3DataBlocks {
} }
} }
if (allSingleLetters) { if (allSingleLetters) {
return list.contents.join(''); return list.value.join('');
} }
return list.contents.join(' '); return list.value.join(' ');
} }
addToList (args, util) { addToList (args, util) {
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
list.contents.push(args.ITEM); args.LIST.id, args.LIST.name);
list.value.push(args.ITEM);
} }
deleteOfList (args, util) { deleteOfList (args, util) {
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
const index = Cast.toListIndex(args.INDEX, list.contents.length); args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length);
if (index === Cast.LIST_INVALID) { if (index === Cast.LIST_INVALID) {
return; return;
} else if (index === Cast.LIST_ALL) { } else if (index === Cast.LIST_ALL) {
list.contents = []; list.value = [];
return; return;
} }
list.contents.splice(index - 1, 1); list.value.splice(index - 1, 1);
} }
insertAtList (args, util) { insertAtList (args, util) {
const item = args.ITEM; const item = args.ITEM;
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
const index = Cast.toListIndex(args.INDEX, list.contents.length + 1); args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length + 1);
if (index === Cast.LIST_INVALID) { if (index === Cast.LIST_INVALID) {
return; return;
} }
list.contents.splice(index - 1, 0, item); list.value.splice(index - 1, 0, item);
} }
replaceItemOfList (args, util) { replaceItemOfList (args, util) {
const item = args.ITEM; const item = args.ITEM;
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
const index = Cast.toListIndex(args.INDEX, list.contents.length); args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length);
if (index === Cast.LIST_INVALID) { if (index === Cast.LIST_INVALID) {
return; return;
} }
list.contents.splice(index - 1, 1, item); list.value.splice(index - 1, 1, item);
} }
getItemOfList (args, util) { getItemOfList (args, util) {
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
const index = Cast.toListIndex(args.INDEX, list.contents.length); args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length);
if (index === Cast.LIST_INVALID) { if (index === Cast.LIST_INVALID) {
return ''; return '';
} }
return list.contents[index - 1]; return list.value[index - 1];
} }
lengthOfList (args, util) { lengthOfList (args, util) {
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
return list.contents.length; args.LIST.id, args.LIST.name);
return list.value.length;
} }
listContainsItem (args, util) { listContainsItem (args, util) {
const item = args.ITEM; const item = args.ITEM;
const list = util.target.lookupOrCreateList(args.LIST); const list = util.target.lookupOrCreateList(
if (list.contents.indexOf(item) >= 0) { args.LIST.id, args.LIST.name);
if (list.value.indexOf(item) >= 0) {
return true; return true;
} }
// Try using Scratch comparison operator on each item. // Try using Scratch comparison operator on each item.
// (Scratch considers the string '123' equal to the number 123). // (Scratch considers the string '123' equal to the number 123).
for (let i = 0; i < list.contents.length; i++) { for (let i = 0; i < list.value.length; i++) {
if (Cast.compare(list.contents[i], item) === 0) { if (Cast.compare(list.value[i], item) === 0) {
return true; return true;
} }
} }

View file

@ -56,14 +56,21 @@ class Scratch3EventBlocks {
} }
broadcast (args, util) { broadcast (args, util) {
const broadcastOption = Cast.toString(args.BROADCAST_OPTION); const broadcastVar = util.runtime.getTargetForStage().lookupBroadcastMsg(
args.BROADCAST_OPTION.id, args.BROADCAST_OPTION.name);
if (broadcastVar) {
const broadcastOption = broadcastVar.name;
util.startHats('event_whenbroadcastreceived', { util.startHats('event_whenbroadcastreceived', {
BROADCAST_OPTION: broadcastOption BROADCAST_OPTION: broadcastOption
}); });
} }
}
broadcastAndWait (args, util) { broadcastAndWait (args, util) {
const broadcastOption = Cast.toString(args.BROADCAST_OPTION); const broadcastVar = util.runtime.getTargetForStage().lookupBroadcastMsg(
args.BROADCAST_OPTION.id, args.BROADCAST_OPTION.name);
if (broadcastVar) {
const broadcastOption = broadcastVar.name;
// Have we run before, starting threads? // Have we run before, starting threads?
if (!util.stackFrame.startedThreads) { if (!util.stackFrame.startedThreads) {
// No - start hats for this broadcast. // No - start hats for this broadcast.
@ -85,5 +92,6 @@ class Scratch3EventBlocks {
} }
} }
} }
}
module.exports = Scratch3EventBlocks; module.exports = Scratch3EventBlocks;

View file

@ -1,4 +1,16 @@
const Cast = require('../util/cast'); const Cast = require('../util/cast');
const Clone = require('../util/clone');
const RenderedTarget = require('../sprites/rendered-target');
/**
* @typedef {object} BubbleState - the bubble state associated with a particular target.
* @property {Boolean} onSpriteRight - tracks whether the bubble is right or left of the sprite.
* @property {?int} drawableId - the ID of the associated bubble Drawable, null if none.
* @property {Boolean} drawableVisible - false if drawable has been hidden by blank text.
* See _renderBubble for explanation of this optimization.
* @property {string} text - the text of the bubble.
* @property {string} type - the type of the bubble, "say" or "think"
*/
class Scratch3LooksBlocks { class Scratch3LooksBlocks {
constructor (runtime) { constructor (runtime) {
@ -7,6 +19,196 @@ class Scratch3LooksBlocks {
* @type {Runtime} * @type {Runtime}
*/ */
this.runtime = runtime; this.runtime = runtime;
this._onTargetMoved = this._onTargetMoved.bind(this);
this._onResetBubbles = this._onResetBubbles.bind(this);
this._onTargetWillExit = this._onTargetWillExit.bind(this);
this._updateBubble = this._updateBubble.bind(this);
// Reset all bubbles on start/stop
this.runtime.on('PROJECT_STOP_ALL', this._onResetBubbles);
this.runtime.on('targetWasRemoved', this._onTargetWillExit);
// Enable other blocks to use bubbles like ask/answer
this.runtime.on('SAY', this._updateBubble);
}
/**
* The default bubble state, to be used when a target has no existing bubble state.
* @type {BubbleState}
*/
static get DEFAULT_BUBBLE_STATE () {
return {
drawableId: null,
drawableVisible: true,
onSpriteRight: true,
skinId: null,
text: '',
type: 'say'
};
}
/**
* The key to load & store a target's bubble-related state.
* @type {string}
*/
static get STATE_KEY () {
return 'Scratch.looks';
}
/**
* @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.
* @private
*/
_getBubbleState (target) {
let bubbleState = target.getCustomState(Scratch3LooksBlocks.STATE_KEY);
if (!bubbleState) {
bubbleState = Clone.simple(Scratch3LooksBlocks.DEFAULT_BUBBLE_STATE);
target.setCustomState(Scratch3LooksBlocks.STATE_KEY, bubbleState);
}
return bubbleState;
}
/**
* Handle a target which has moved.
* @param {RenderedTarget} target - the target which has moved.
* @private
*/
_onTargetMoved (target) {
const bubbleState = this._getBubbleState(target);
if (bubbleState.drawableId) {
this._positionBubble(target);
}
}
/**
* Handle a target which is exiting.
* @param {RenderedTarget} target - the target.
* @private
*/
_onTargetWillExit (target) {
const bubbleState = this._getBubbleState(target);
if (bubbleState.drawableId && bubbleState.skinId) {
this.runtime.renderer.destroyDrawable(bubbleState.drawableId);
this.runtime.renderer.destroySkin(bubbleState.skinId);
bubbleState.drawableId = null;
bubbleState.skinId = null;
bubbleState.drawableVisible = true; // Reset back to default value
this.runtime.requestRedraw();
}
target.removeListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
}
/**
* Handle project start/stop by clearing all visible bubbles.
* @private
*/
_onResetBubbles () {
for (let n = 0; n < this.runtime.targets.length; n++) {
this._onTargetWillExit(this.runtime.targets[n]);
}
clearTimeout(this._bubbleTimeout);
}
/**
* Position the bubble of a target. If it doesn't fit on the specified side, flip and rerender.
* @param {!Target} target Target whose bubble needs positioning.
* @private
*/
_positionBubble (target) {
const bubbleState = this._getBubbleState(target);
const [bubbleWidth, bubbleHeight] = this.runtime.renderer.getSkinSize(bubbleState.drawableId);
const targetBounds = target.getBounds();
const stageBounds = this.runtime.getTargetForStage().getBounds();
if (bubbleState.onSpriteRight && bubbleWidth + targetBounds.right > stageBounds.right &&
(targetBounds.left - bubbleWidth > stageBounds.left)) { // Only flip if it would fit
bubbleState.onSpriteRight = false;
this._renderBubble(target);
} else if (!bubbleState.onSpriteRight && targetBounds.left - bubbleWidth < stageBounds.left &&
(bubbleWidth + targetBounds.right < stageBounds.right)) { // Only flip if it would fit
bubbleState.onSpriteRight = true;
this._renderBubble(target);
} else {
this.runtime.renderer.updateDrawableProperties(bubbleState.drawableId, {
position: [
bubbleState.onSpriteRight ? (
Math.min(stageBounds.right - bubbleWidth, targetBounds.right)
) : (
Math.max(stageBounds.left, targetBounds.left - bubbleWidth)
),
Math.min(stageBounds.top, targetBounds.top + bubbleHeight)
]
});
this.runtime.requestRedraw();
}
}
/**
* Create a visible bubble for a target. If a bubble exists for the target,
* just set it to visible and update the type/text. Otherwise create a new
* bubble and update the relevant custom state.
* @param {!Target} target Target who needs a bubble.
* @return {undefined} Early return if text is empty string.
* @private
*/
_renderBubble (target) {
const bubbleState = this._getBubbleState(target);
const {drawableVisible, type, text, onSpriteRight} = bubbleState;
// Remove the bubble if target is not visible, or text is being set to blank
// without being initialized. See comment below about blank text optimization.
if (!target.visible || (text === '' && !bubbleState.skinId)) {
return this._onTargetWillExit(target);
}
if (bubbleState.skinId) {
// Optimization: if text is set to blank, hide the drawable instead of
// getting rid of it. This prevents flickering in "typewriter" projects
if ((text === '' && drawableVisible) || (text !== '' && !drawableVisible)) {
bubbleState.drawableVisible = text !== '';
this.runtime.renderer.updateDrawableProperties(bubbleState.drawableId, {
visible: bubbleState.drawableVisible
});
}
if (bubbleState.drawableVisible) {
this.runtime.renderer.updateTextSkin(bubbleState.skinId, type, text, onSpriteRight, [0, 0]);
}
} else {
target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
// TODO is there a way to figure out before rendering whether to default left or right?
const targetBounds = target.getBounds();
const stageBounds = this.runtime.getTargetForStage().getBounds();
if (targetBounds.right + 170 > stageBounds.right) {
bubbleState.onSpriteRight = false;
}
bubbleState.drawableId = this.runtime.renderer.createDrawable();
bubbleState.skinId = this.runtime.renderer.createTextSkin(type, text, bubbleState.onSpriteRight, [0, 0]);
this.runtime.renderer.setDrawableOrder(bubbleState.drawableId, Infinity);
this.runtime.renderer.updateDrawableProperties(bubbleState.drawableId, {
skinId: bubbleState.skinId
});
}
this._positionBubble(target);
}
/**
* The entry point for say/think blocks. Clears existing bubble if the text is empty.
* Set the bubble custom state and then call _renderBubble.
* @param {!Target} target Target that say/think blocks are being called on.
* @param {!string} type Either "say" or "think"
* @param {!string} text The text for the bubble, empty string clears the bubble.
* @private
*/
_updateBubble (target, type, text) {
const bubbleState = this._getBubbleState(target);
bubbleState.type = type;
bubbleState.text = text;
this._renderBubble(target);
} }
/** /**
@ -18,7 +220,7 @@ class Scratch3LooksBlocks {
looks_say: this.say, looks_say: this.say,
looks_sayforsecs: this.sayforsecs, looks_sayforsecs: this.sayforsecs,
looks_think: this.think, looks_think: this.think,
looks_thinkforsecs: this.sayforsecs, looks_thinkforsecs: this.thinkforsecs,
looks_show: this.show, looks_show: this.show,
looks_hide: this.hide, looks_hide: this.hide,
looks_switchcostumeto: this.switchCostume, looks_switchcostumeto: this.switchCostume,
@ -31,40 +233,52 @@ class Scratch3LooksBlocks {
looks_cleargraphiceffects: this.clearEffects, looks_cleargraphiceffects: this.clearEffects,
looks_changesizeby: this.changeSize, looks_changesizeby: this.changeSize,
looks_setsizeto: this.setSize, looks_setsizeto: this.setSize,
looks_gotofront: this.goToFront, looks_gotofrontback: this.goToFrontBack,
looks_gobacklayers: this.goBackLayers, looks_goforwardbackwardlayers: this.goForwardBackwardLayers,
looks_size: this.getSize, looks_size: this.getSize,
looks_costumeorder: this.getCostumeIndex, looks_costumenumbername: this.getCostumeNumberName,
looks_backdroporder: this.getBackdropIndex, looks_backdropnumbername: this.getBackdropNumberName
looks_backdropname: this.getBackdropName };
}
getMonitored () {
return {
looks_size: {isSpriteSpecific: true},
looks_costumenumbername: {isSpriteSpecific: true},
looks_backdropnumbername: {}
}; };
} }
say (args, util) { say (args, util) {
util.target.setSay('say', args.MESSAGE); // @TODO in 2.0 calling say/think resets the right/left bias of the bubble
this._updateBubble(util.target, 'say', String(args.MESSAGE));
} }
sayforsecs (args, util) { sayforsecs (args, util) {
util.target.setSay('say', args.MESSAGE); this.say(args, util);
const _target = util.target;
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(() => { this._bubbleTimeout = setTimeout(() => {
this._bubbleTimeout = null;
// Clear say bubble and proceed. // Clear say bubble and proceed.
util.target.setSay(); this._updateBubble(_target, 'say', '');
resolve(); resolve();
}, 1000 * args.SECS); }, 1000 * args.SECS);
}); });
} }
think (args, util) { think (args, util) {
util.target.setSay('think', args.MESSAGE); this._updateBubble(util.target, 'think', String(args.MESSAGE));
} }
thinkforsecs (args, util) { thinkforsecs (args, util) {
util.target.setSay('think', args.MESSAGE); this.think(args, util);
const _target = util.target;
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(() => { this._bubbleTimeout = setTimeout(() => {
this._bubbleTimeout = null;
// Clear say bubble and proceed. // Clear say bubble and proceed.
util.target.setSay(); this._updateBubble(_target, 'think', '');
resolve(); resolve();
}, 1000 * args.SECS); }, 1000 * args.SECS);
}); });
@ -72,10 +286,12 @@ class Scratch3LooksBlocks {
show (args, util) { show (args, util) {
util.target.setVisible(true); util.target.setVisible(true);
this._renderBubble(util.target);
} }
hide (args, util) { hide (args, util) {
util.target.setVisible(false); util.target.setVisible(false);
this._renderBubble(util.target);
} }
/** /**
@ -191,31 +407,46 @@ class Scratch3LooksBlocks {
util.target.setSize(size); util.target.setSize(size);
} }
goToFront (args, util) { goToFrontBack (args, util) {
if (!util.target.isStage) {
if (args.FRONT_BACK === 'front') {
util.target.goToFront(); util.target.goToFront();
} else {
util.target.goToBack();
}
}
} }
goBackLayers (args, util) { goForwardBackwardLayers (args, util) {
util.target.goBackLayers(args.NUM); if (!util.target.isStage) {
if (args.FORWARD_BACKWARD === 'forward') {
util.target.goForwardLayers(Cast.toNumber(args.NUM));
} else {
util.target.goBackwardLayers(Cast.toNumber(args.NUM));
}
}
} }
getSize (args, util) { getSize (args, util) {
return Math.round(util.target.size); return Math.round(util.target.size);
} }
getBackdropIndex () { getBackdropNumberName (args) {
const stage = this.runtime.getTargetForStage(); const stage = this.runtime.getTargetForStage();
if (args.NUMBER_NAME === 'number') {
return stage.currentCostume + 1; return stage.currentCostume + 1;
} }
// Else return name
getBackdropName () {
const stage = this.runtime.getTargetForStage();
return stage.sprite.costumes[stage.currentCostume].name; return stage.sprite.costumes[stage.currentCostume].name;
} }
getCostumeIndex (args, util) { getCostumeNumberName (args, util) {
if (args.NUMBER_NAME === 'number') {
return util.target.currentCostume + 1; return util.target.currentCostume + 1;
} }
// Else return name
return util.target.sprite.costumes[util.target.currentCostume].name;
}
} }
module.exports = Scratch3LooksBlocks; module.exports = Scratch3LooksBlocks;

View file

@ -38,6 +38,14 @@ class Scratch3MotionBlocks {
}; };
} }
getMonitored () {
return {
motion_xposition: {isSpriteSpecific: true},
motion_yposition: {isSpriteSpecific: true},
motion_direction: {isSpriteSpecific: true}
};
}
moveSteps (args, util) { moveSteps (args, util) {
const steps = Cast.toNumber(args.STEPS); const steps = Cast.toNumber(args.STEPS);
const radians = MathUtil.degToRad(90 - util.target.direction); const radians = MathUtil.degToRad(90 - util.target.direction);
@ -56,8 +64,8 @@ class Scratch3MotionBlocks {
let targetX = 0; let targetX = 0;
let targetY = 0; let targetY = 0;
if (targetName === '_mouse_') { if (targetName === '_mouse_') {
targetX = util.ioQuery('mouse', 'getX'); targetX = util.ioQuery('mouse', 'getScratchX');
targetY = util.ioQuery('mouse', 'getY'); targetY = util.ioQuery('mouse', 'getScratchY');
} else if (targetName === '_random_') { } else if (targetName === '_random_') {
const stageWidth = this.runtime.constructor.STAGE_WIDTH; const stageWidth = this.runtime.constructor.STAGE_WIDTH;
const stageHeight = this.runtime.constructor.STAGE_HEIGHT; const stageHeight = this.runtime.constructor.STAGE_HEIGHT;
@ -98,8 +106,8 @@ class Scratch3MotionBlocks {
let targetX = 0; let targetX = 0;
let targetY = 0; let targetY = 0;
if (args.TOWARDS === '_mouse_') { if (args.TOWARDS === '_mouse_') {
targetX = util.ioQuery('mouse', 'getX'); targetX = util.ioQuery('mouse', 'getScratchX');
targetY = util.ioQuery('mouse', 'getY'); targetY = util.ioQuery('mouse', 'getScratchY');
} else { } else {
const pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS); const pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS);
if (!pointTarget) return; if (!pointTarget) return;

View file

@ -1,376 +0,0 @@
const Cast = require('../util/cast');
const Clone = require('../util/clone');
const Color = require('../util/color');
const MathUtil = require('../util/math-util');
const RenderedTarget = require('../sprites/rendered-target');
/**
* @typedef {object} PenState - the pen state associated with a particular target.
* @property {Boolean} penDown - tracks whether the pen should draw for this target.
* @property {number} hue - the current hue of the pen.
* @property {number} shade - the current shade of the pen.
* @property {PenAttributes} penAttributes - cached pen attributes for the renderer. This is the authoritative value for
* diameter but not for pen color.
*/
/**
* Host for the Pen-related blocks in Scratch 3.0
* @param {Runtime} runtime - the runtime instantiating this block package.
* @constructor
*/
class Scratch3PenBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* The ID of the renderer Drawable corresponding to the pen layer.
* @type {int}
* @private
*/
this._penDrawableId = -1;
/**
* The ID of the renderer Skin corresponding to the pen layer.
* @type {int}
* @private
*/
this._penSkinId = -1;
this._onTargetCreated = this._onTargetCreated.bind(this);
this._onTargetMoved = this._onTargetMoved.bind(this);
runtime.on('targetWasCreated', this._onTargetCreated);
}
/**
* The default pen state, to be used when a target has no existing pen state.
* @type {PenState}
*/
static get DEFAULT_PEN_STATE () {
return {
penDown: false,
hue: 120,
shade: 50,
penAttributes: {
color4f: [0, 0, 1, 1],
diameter: 1
}
};
}
/**
* Place the pen layer in front of the backdrop but behind everything else.
* We should probably handle this somewhere else... somewhere central that knows about pen, backdrop, video, etc.
* Maybe it should be in the GUI?
* @type {int}
*/
static get PEN_ORDER () {
return 1;
}
/**
* The minimum and maximum allowed pen size.
* @type {{min: number, max: number}}
*/
static get PEN_SIZE_RANGE () {
return {min: 1, max: 255};
}
/**
* The key to load & store a target's pen-related state.
* @type {string}
*/
static get STATE_KEY () {
return 'Scratch.pen';
}
/**
* Clamp a pen size value to the range allowed by the pen.
* @param {number} requestedSize - the requested pen size.
* @returns {number} the clamped size.
* @private
*/
_clampPenSize (requestedSize) {
return MathUtil.clamp(
requestedSize,
Scratch3PenBlocks.PEN_SIZE_RANGE.min,
Scratch3PenBlocks.PEN_SIZE_RANGE.max
);
}
/**
* Retrieve the ID of the renderer "Skin" corresponding to the pen layer. If
* the pen Skin doesn't yet exist, create it.
* @returns {int} the Skin ID of the pen layer, or -1 on failure.
* @private
*/
_getPenLayerID () {
if (this._penSkinId < 0 && this.runtime.renderer) {
this._penSkinId = this.runtime.renderer.createPenSkin();
this._penDrawableId = this.runtime.renderer.createDrawable();
this.runtime.renderer.setDrawableOrder(this._penDrawableId, Scratch3PenBlocks.PEN_ORDER);
this.runtime.renderer.updateDrawableProperties(this._penDrawableId, {skinId: this._penSkinId});
}
return this._penSkinId;
}
/**
* @param {Target} target - collect pen state for this target. Probably, but not necessarily, a RenderedTarget.
* @returns {PenState} the mutable pen state associated with that target. This will be created if necessary.
* @private
*/
_getPenState (target) {
let penState = target.getCustomState(Scratch3PenBlocks.STATE_KEY);
if (!penState) {
penState = Clone.simple(Scratch3PenBlocks.DEFAULT_PEN_STATE);
target.setCustomState(Scratch3PenBlocks.STATE_KEY, penState);
}
return penState;
}
/**
* When a pen-using Target is cloned, clone the pen state.
* @param {Target} newTarget - the newly created target.
* @param {Target} [sourceTarget] - the target used as a source for the new clone, if any.
* @listens Runtime#event:targetWasCreated
* @private
*/
_onTargetCreated (newTarget, sourceTarget) {
if (sourceTarget) {
const penState = sourceTarget.getCustomState(Scratch3PenBlocks.STATE_KEY);
if (penState) {
newTarget.setCustomState(Scratch3PenBlocks.STATE_KEY, Clone.simple(penState));
if (penState.penDown) {
newTarget.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
}
}
}
}
/**
* Handle a target which has moved. This only fires when the pen is down.
* @param {RenderedTarget} target - the target which has moved.
* @param {number} oldX - the previous X position.
* @param {number} oldY - the previous Y position.
* @private
*/
_onTargetMoved (target, oldX, oldY) {
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
const penState = this._getPenState(target);
this.runtime.renderer.penLine(penSkinId, penState.penAttributes, oldX, oldY, target.x, target.y);
this.runtime.requestRedraw();
}
}
/**
* Update the cached RGB color from the hue & shade values in the provided PenState object.
* @param {PenState} penState - the pen state to update.
* @private
*/
_updatePenColor (penState) {
let rgb = Color.hsvToRgb({h: penState.hue * 180 / 100, s: 1, v: 1});
const shade = (penState.shade > 100) ? 200 - penState.shade : penState.shade;
if (shade < 50) {
rgb = Color.mixRgb(Color.RGB_BLACK, rgb, (10 + shade) / 60);
} else {
rgb = Color.mixRgb(rgb, Color.RGB_WHITE, (shade - 50) / 60);
}
penState.penAttributes.color4f[0] = rgb.r / 255.0;
penState.penAttributes.color4f[1] = rgb.g / 255.0;
penState.penAttributes.color4f[2] = rgb.b / 255.0;
penState.penAttributes.color4f[3] = 1;
}
/**
* Wrap a pen hue or shade values to the range [0,200).
* @param {number} value - the pen hue or shade value to the proper range.
* @returns {number} the wrapped value.
* @private
*/
_wrapHueOrShade (value) {
value = value % 200;
if (value < 0) value += 200;
return value;
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
pen_clear: this.clear,
pen_stamp: this.stamp,
pen_pendown: this.penDown,
pen_penup: this.penUp,
pen_setpencolortocolor: this.setPenColorToColor,
pen_changepencolorby: this.changePenHueBy,
pen_setpencolortonum: this.setPenHueToNumber,
pen_changepenshadeby: this.changePenShadeBy,
pen_setpenshadeto: this.setPenShadeToNumber,
pen_changepensizeby: this.changePenSizeBy,
pen_setpensizeto: this.setPenSizeTo
};
}
/**
* The pen "clear" block clears the pen layer's contents.
*/
clear () {
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
this.runtime.renderer.penClear(penSkinId);
this.runtime.requestRedraw();
}
}
/**
* The pen "stamp" block stamps the current drawable's image onto the pen layer.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
*/
stamp (args, util) {
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
const target = util.target;
this.runtime.renderer.penStamp(penSkinId, target.drawableID);
this.runtime.requestRedraw();
}
}
/**
* The pen "pen down" block causes the target to leave pen trails on future motion.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
*/
penDown (args, util) {
const target = util.target;
const penState = this._getPenState(target);
if (!penState.penDown) {
penState.penDown = true;
target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
}
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
this.runtime.renderer.penPoint(penSkinId, penState.penAttributes, target.x, target.y);
this.runtime.requestRedraw();
}
}
/**
* The pen "pen up" block stops the target from leaving pen trails.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
*/
penUp (args, util) {
const target = util.target;
const penState = this._getPenState(target);
if (penState.penDown) {
penState.penDown = false;
target.removeListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
}
}
/**
* The pen "set pen color to {color}" block sets the pen to a particular RGB color.
* @param {object} args - the block arguments.
* @property {int} COLOR - the color to set, expressed as a 24-bit RGB value (0xRRGGBB).
* @param {object} util - utility object provided by the runtime.
*/
setPenColorToColor (args, util) {
const penState = this._getPenState(util.target);
const rgb = Cast.toRgbColorObject(args.COLOR);
const hsv = Color.rgbToHsv(rgb);
penState.hue = 200 * hsv.h / 360;
penState.shade = 50 * hsv.v;
penState.penAttributes.color4f[0] = rgb.r / 255.0;
penState.penAttributes.color4f[1] = rgb.g / 255.0;
penState.penAttributes.color4f[2] = rgb.b / 255.0;
if (rgb.hasOwnProperty('a')) { // Will there always be an 'a'?
penState.penAttributes.color4f[3] = rgb.a / 255.0;
} else {
penState.penAttributes.color4f[3] = 1;
}
}
/**
* The pen "change pen color by {number}" block rotates the hue of the pen by the given amount.
* @param {object} args - the block arguments.
* @property {number} COLOR - the amount of desired hue rotation.
* @param {object} util - utility object provided by the runtime.
*/
changePenHueBy (args, util) {
const penState = this._getPenState(util.target);
penState.hue = this._wrapHueOrShade(penState.hue + Cast.toNumber(args.COLOR));
this._updatePenColor(penState);
}
/**
* The pen "set pen color to {number}" block sets the hue of the pen.
* @param {object} args - the block arguments.
* @property {number} COLOR - the desired hue.
* @param {object} util - utility object provided by the runtime.
*/
setPenHueToNumber (args, util) {
const penState = this._getPenState(util.target);
penState.hue = this._wrapHueOrShade(Cast.toNumber(args.COLOR));
this._updatePenColor(penState);
}
/**
* The pen "change pen shade by {number}" block changes the "shade" of the pen, related to the HSV value.
* @param {object} args - the block arguments.
* @property {number} SHADE - the amount of desired shade change.
* @param {object} util - utility object provided by the runtime.
*/
changePenShadeBy (args, util) {
const penState = this._getPenState(util.target);
penState.shade = this._wrapHueOrShade(penState.shade + Cast.toNumber(args.SHADE));
this._updatePenColor(penState);
}
/**
* The pen "set pen shade to {number}" block sets the "shade" of the pen, related to the HSV value.
* @param {object} args - the block arguments.
* @property {number} SHADE - the amount of desired shade change.
* @param {object} util - utility object provided by the runtime.
*/
setPenShadeToNumber (args, util) {
const penState = this._getPenState(util.target);
penState.shade = this._wrapHueOrShade(Cast.toNumber(args.SHADE));
this._updatePenColor(penState);
}
/**
* The pen "change pen size by {number}" block changes the pen size by the given amount.
* @param {object} args - the block arguments.
* @property {number} SIZE - the amount of desired size change.
* @param {object} util - utility object provided by the runtime.
*/
changePenSizeBy (args, util) {
const penAttributes = this._getPenState(util.target).penAttributes;
penAttributes.diameter = this._clampPenSize(penAttributes.diameter + Cast.toNumber(args.SIZE));
}
/**
* The pen "set pen size to {number}" block sets the pen size to the given amount.
* @param {object} args - the block arguments.
* @property {number} SIZE - the amount of desired size change.
* @param {object} util - utility object provided by the runtime.
*/
setPenSizeTo (args, util) {
const penAttributes = this._getPenState(util.target).penAttributes;
penAttributes.diameter = this._clampPenSize(Cast.toNumber(args.SIZE));
}
}
module.exports = Scratch3PenBlocks;

View file

@ -13,32 +13,55 @@ class Scratch3ProcedureBlocks {
*/ */
getPrimitives () { getPrimitives () {
return { return {
procedures_defnoreturn: this.defNoReturn, procedures_definition: this.definition,
procedures_callnoreturn: this.callNoReturn, procedures_call: this.call,
procedures_param: this.param argument_reporter_string_number: this.argumentReporterStringNumber,
argument_reporter_boolean: this.argumentReporterBoolean
}; };
} }
defNoReturn () { definition () {
// No-op: execute the blocks. // No-op: execute the blocks.
} }
callNoReturn (args, util) { call (args, util) {
if (!util.stackFrame.executed) { if (!util.stackFrame.executed) {
const procedureCode = args.mutation.proccode; const procedureCode = args.mutation.proccode;
const paramNames = util.getProcedureParamNames(procedureCode); const paramNamesAndIds = util.getProcedureParamNamesAndIds(procedureCode);
for (let i = 0; i < paramNames.length; i++) {
if (args.hasOwnProperty(`input${i}`)) { // If null, procedure could not be found, which can happen if custom
util.pushParam(paramNames[i], args[`input${i}`]); // block is dragged between sprites without the definition.
// Match Scratch 2.0 behavior and noop.
if (paramNamesAndIds === null) {
return;
}
const [paramNames, paramIds] = paramNamesAndIds;
for (let i = 0; i < paramIds.length; i++) {
if (args.hasOwnProperty(paramIds[i])) {
util.pushParam(paramNames[i], args[paramIds[i]]);
} }
} }
util.stackFrame.executed = true; util.stackFrame.executed = true;
util.startProcedure(procedureCode); util.startProcedure(procedureCode);
} }
} }
param (args, util) { argumentReporterStringNumber (args, util) {
const value = util.getParam(args.mutation.paramname); const value = util.getParam(args.VALUE);
if (value === null) {
return '';
}
return value;
}
argumentReporterBoolean (args, util) {
const value = util.getParam(args.VALUE);
if (value === null) {
return false;
}
return value; return value;
} }
} }

View file

@ -7,6 +7,22 @@ class Scratch3SensingBlocks {
* @type {Runtime} * @type {Runtime}
*/ */
this.runtime = runtime; this.runtime = runtime;
/**
* The "answer" block value.
* @type {string}
*/
this._answer = '';
/**
* The list of queued questions and respective `resolve` callbacks.
* @type {!Array}
*/
this._questionList = [];
this.runtime.on('ANSWER', this._onAnswer.bind(this));
this.runtime.on('PROJECT_START', this._resetAnswer.bind(this));
this.runtime.on('PROJECT_STOP_ALL', this._clearAllQuestions.bind(this));
} }
/** /**
@ -24,19 +40,87 @@ class Scratch3SensingBlocks {
sensing_of: this.getAttributeOf, sensing_of: this.getAttributeOf,
sensing_mousex: this.getMouseX, sensing_mousex: this.getMouseX,
sensing_mousey: this.getMouseY, sensing_mousey: this.getMouseY,
sensing_setdragmode: this.setDragMode,
sensing_mousedown: this.getMouseDown, sensing_mousedown: this.getMouseDown,
sensing_keypressed: this.getKeyPressed, sensing_keypressed: this.getKeyPressed,
sensing_current: this.current, sensing_current: this.current,
sensing_dayssince2000: this.daysSince2000, sensing_dayssince2000: this.daysSince2000,
sensing_loudness: this.getLoudness sensing_loudness: this.getLoudness,
sensing_askandwait: this.askAndWait,
sensing_answer: this.getAnswer
}; };
} }
getMonitored () {
return {
sensing_answer: {},
sensing_loudness: {},
sensing_timer: {},
sensing_current: {}
};
}
_onAnswer (answer) {
this._answer = answer;
const questionObj = this._questionList.shift();
if (questionObj) {
const [_question, resolve, target, wasVisible, wasStage] = questionObj;
// If the target was visible when asked, hide the say bubble unless the target was the stage.
if (wasVisible && !wasStage) {
this.runtime.emit('SAY', target, 'say', '');
}
resolve();
this._askNextQuestion();
}
}
_resetAnswer () {
this._answer = '';
}
_enqueueAsk (question, resolve, target, wasVisible, wasStage) {
this._questionList.push([question, resolve, target, wasVisible, wasStage]);
}
_askNextQuestion () {
if (this._questionList.length > 0) {
const [question, _resolve, target, wasVisible, wasStage] = this._questionList[0];
// If the target is visible, emit a blank question and use the
// say event to trigger a bubble unless the target was the stage.
if (wasVisible && !wasStage) {
this.runtime.emit('SAY', target, 'say', question);
this.runtime.emit('QUESTION', '');
} else {
this.runtime.emit('QUESTION', question);
}
}
}
_clearAllQuestions () {
this._questionList = [];
this.runtime.emit('QUESTION', null);
}
askAndWait (args, util) {
const _target = util.target;
return new Promise(resolve => {
const isQuestionAsked = this._questionList.length > 0;
this._enqueueAsk(String(args.QUESTION), resolve, _target, _target.visible, _target.isStage);
if (!isQuestionAsked) {
this._askNextQuestion();
}
});
}
getAnswer () {
return this._answer;
}
touchingObject (args, util) { touchingObject (args, util) {
const requestedObject = args.TOUCHINGOBJECTMENU; const requestedObject = args.TOUCHINGOBJECTMENU;
if (requestedObject === '_mouse_') { if (requestedObject === '_mouse_') {
const mouseX = util.ioQuery('mouse', 'getX'); const mouseX = util.ioQuery('mouse', 'getClientX');
const mouseY = util.ioQuery('mouse', 'getY'); const mouseY = util.ioQuery('mouse', 'getClientY');
return util.target.isTouchingPoint(mouseX, mouseY); return util.target.isTouchingPoint(mouseX, mouseY);
} else if (requestedObject === '_edge_') { } else if (requestedObject === '_edge_') {
return util.target.isTouchingEdge(); return util.target.isTouchingEdge();
@ -62,8 +146,8 @@ class Scratch3SensingBlocks {
let targetX = 0; let targetX = 0;
let targetY = 0; let targetY = 0;
if (args.DISTANCETOMENU === '_mouse_') { if (args.DISTANCETOMENU === '_mouse_') {
targetX = util.ioQuery('mouse', 'getX'); targetX = util.ioQuery('mouse', 'getScratchX');
targetY = util.ioQuery('mouse', 'getY'); targetY = util.ioQuery('mouse', 'getScratchY');
} else { } else {
const distTarget = this.runtime.getSpriteTargetByName( const distTarget = this.runtime.getSpriteTargetByName(
args.DISTANCETOMENU args.DISTANCETOMENU
@ -78,6 +162,10 @@ class Scratch3SensingBlocks {
return Math.sqrt((dx * dx) + (dy * dy)); return Math.sqrt((dx * dx) + (dy * dy));
} }
setDragMode (args, util) {
util.target.setDraggable(args.DRAG_MODE === 'draggable');
}
getTimer (args, util) { getTimer (args, util) {
return util.ioQuery('clock', 'projectTimer'); return util.ioQuery('clock', 'projectTimer');
} }
@ -87,11 +175,11 @@ class Scratch3SensingBlocks {
} }
getMouseX (args, util) { getMouseX (args, util) {
return util.ioQuery('mouse', 'getX'); return util.ioQuery('mouse', 'getScratchX');
} }
getMouseY (args, util) { getMouseY (args, util) {
return util.ioQuery('mouse', 'getY'); return util.ioQuery('mouse', 'getScratchY');
} }
getMouseDown (args, util) { getMouseDown (args, util) {
@ -167,8 +255,10 @@ class Scratch3SensingBlocks {
// Variables // Variables
const varName = args.PROPERTY; const varName = args.PROPERTY;
if (attrTarget.variables.hasOwnProperty(varName)) { for (const id in attrTarget.variables) {
return attrTarget.variables[varName].value; if (attrTarget.variables[id].name === varName) {
return attrTarget.variables[id].value;
}
} }
// Otherwise, 0 // Otherwise, 0

View file

@ -9,6 +9,13 @@ class Scratch3SoundBlocks {
* @type {Runtime} * @type {Runtime}
*/ */
this.runtime = runtime; this.runtime = runtime;
// Clear sound effects on green flag and stop button events.
this._clearEffectsForAllTargets = this._clearEffectsForAllTargets.bind(this);
if (this.runtime) {
this.runtime.on('PROJECT_STOP_ALL', this._clearEffectsForAllTargets);
this.runtime.on('PROJECT_START', this._clearEffectsForAllTargets);
}
} }
/** /**
@ -26,7 +33,6 @@ class Scratch3SoundBlocks {
static get DEFAULT_SOUND_STATE () { static get DEFAULT_SOUND_STATE () {
return { return {
volume: 100, volume: 100,
currentInstrument: 0,
effects: { effects: {
pitch: 0, pitch: 0,
pan: 0 pan: 0
@ -91,10 +97,6 @@ class Scratch3SoundBlocks {
sound_play: this.playSound, sound_play: this.playSound,
sound_playuntildone: this.playSoundAndWait, sound_playuntildone: this.playSoundAndWait,
sound_stopallsounds: this.stopAllSounds, sound_stopallsounds: this.stopAllSounds,
sound_playnoteforbeats: this.playNoteForBeats,
sound_playdrumforbeats: this.playDrumForBeats,
sound_restforbeats: this.restForBeats,
sound_setinstrumentto: this.setInstrument,
sound_seteffectto: this.setEffect, sound_seteffectto: this.setEffect,
sound_changeeffectby: this.changeEffect, sound_changeeffectby: this.changeEffect,
sound_cleareffects: this.clearEffects, sound_cleareffects: this.clearEffects,
@ -103,10 +105,13 @@ class Scratch3SoundBlocks {
sound_effects_menu: this.effectsMenu, sound_effects_menu: this.effectsMenu,
sound_setvolumeto: this.setVolume, sound_setvolumeto: this.setVolume,
sound_changevolumeby: this.changeVolume, sound_changevolumeby: this.changeVolume,
sound_volume: this.getVolume, sound_volume: this.getVolume
sound_settempotobpm: this.setTempo, };
sound_changetempoby: this.changeTempo, }
sound_tempo: this.getTempo
getMonitored () {
return {
sound_volume: {}
}; };
} }
@ -162,53 +167,17 @@ class Scratch3SoundBlocks {
return -1; return -1;
} }
stopAllSounds (args, util) { stopAllSounds () {
if (util.target.audioPlayer === null) return; if (this.runtime.targets === null) return;
util.target.audioPlayer.stopAllSounds(); const allTargets = this.runtime.targets;
for (let i = 0; i < allTargets.length; i++) {
this._stopAllSoundsForTarget(allTargets[i]);
}
} }
playNoteForBeats (args, util) { _stopAllSoundsForTarget (target) {
let note = Cast.toNumber(args.NOTE); if (target.audioPlayer === null) return;
note = MathUtil.clamp(note, Scratch3SoundBlocks.MIDI_NOTE_RANGE.min, Scratch3SoundBlocks.MIDI_NOTE_RANGE.max); target.audioPlayer.stopAllSounds();
let beats = Cast.toNumber(args.BEATS);
beats = this._clampBeats(beats);
const soundState = this._getSoundState(util.target);
const inst = soundState.currentInstrument;
const vol = soundState.volume;
if (typeof this.runtime.audioEngine === 'undefined') return;
return this.runtime.audioEngine.playNoteForBeatsWithInstAndVol(note, beats, inst, vol);
}
playDrumForBeats (args, util) {
let drum = Cast.toNumber(args.DRUM);
drum -= 1; // drums are one-indexed
if (typeof this.runtime.audioEngine === 'undefined') return;
drum = MathUtil.wrapClamp(drum, 0, this.runtime.audioEngine.numDrums - 1);
let beats = Cast.toNumber(args.BEATS);
beats = this._clampBeats(beats);
if (util.target.audioPlayer === null) return;
return util.target.audioPlayer.playDrumForBeats(drum, beats);
}
restForBeats (args) {
let beats = Cast.toNumber(args.BEATS);
beats = this._clampBeats(beats);
if (typeof this.runtime.audioEngine === 'undefined') return;
return this.runtime.audioEngine.waitForBeats(beats);
}
_clampBeats (beats) {
return MathUtil.clamp(beats, Scratch3SoundBlocks.BEAT_RANGE.min, Scratch3SoundBlocks.BEAT_RANGE.max);
}
setInstrument (args, util) {
const soundState = this._getSoundState(util.target);
let instNum = Cast.toNumber(args.INSTRUMENT);
instNum -= 1; // instruments are one-indexed
if (typeof this.runtime.audioEngine === 'undefined') return;
instNum = MathUtil.wrapClamp(instNum, 0, this.runtime.audioEngine.numInstruments - 1);
soundState.currentInstrument = instNum;
return this.runtime.audioEngine.instrumentPlayer.loadInstrument(soundState.currentInstrument);
} }
setEffect (args, util) { setEffect (args, util) {
@ -240,13 +209,25 @@ class Scratch3SoundBlocks {
} }
clearEffects (args, util) { clearEffects (args, util) {
const soundState = this._getSoundState(util.target); this._clearEffectsForTarget(util.target);
}
_clearEffectsForTarget (target) {
const soundState = this._getSoundState(target);
for (const effect in soundState.effects) { for (const effect in soundState.effects) {
if (!soundState.effects.hasOwnProperty(effect)) continue; if (!soundState.effects.hasOwnProperty(effect)) continue;
soundState.effects[effect] = 0; soundState.effects[effect] = 0;
} }
if (util.target.audioPlayer === null) return; if (target.audioPlayer === null) return;
util.target.audioPlayer.clearEffects(); target.audioPlayer.clearEffects();
}
_clearEffectsForAllTargets () {
if (this.runtime.targets === null) return;
const allTargets = this.runtime.targets;
for (let i = 0; i < allTargets.length; i++) {
this._clearEffectsForTarget(allTargets[i]);
}
} }
setVolume (args, util) { setVolume (args, util) {
@ -273,29 +254,6 @@ class Scratch3SoundBlocks {
return soundState.volume; return soundState.volume;
} }
setTempo (args) {
const tempo = Cast.toNumber(args.TEMPO);
this._updateTempo(tempo);
}
changeTempo (args) {
const change = Cast.toNumber(args.TEMPO);
if (typeof this.runtime.audioEngine === 'undefined') return;
const tempo = change + this.runtime.audioEngine.currentTempo;
this._updateTempo(tempo);
}
_updateTempo (tempo) {
tempo = MathUtil.clamp(tempo, Scratch3SoundBlocks.TEMPO_RANGE.min, Scratch3SoundBlocks.TEMPO_RANGE.max);
if (typeof this.runtime.audioEngine === 'undefined') return;
this.runtime.audioEngine.setTempo(tempo);
}
getTempo () {
if (typeof this.runtime.audioEngine === 'undefined') return;
return this.runtime.audioEngine.currentTempo;
}
soundsMenu (args) { soundsMenu (args) {
return args.SOUND_MENU; return args.SOUND_MENU;
} }

View file

@ -91,6 +91,16 @@ class SharedDispatch {
} }
} }
/**
* Check if a particular service lives on another worker.
* @param {string} service - the service to check.
* @returns {boolean} - true if the service is remote (calls must cross a Worker boundary), false otherwise.
* @private
*/
_isRemoteService (service) {
return this._getServiceProvider(service).isRemote;
}
/** /**
* Like {@link call}, but force the call to be posted through a particular communication channel. * Like {@link call}, but force the call to be posted through a particular communication channel.
* @param {object} provider - send the call through this object's `postMessage` function. * @param {object} provider - send the call through this object's `postMessage` function.
@ -115,6 +125,12 @@ class SharedDispatch {
_remoteTransferCall (provider, service, method, transfer, ...args) { _remoteTransferCall (provider, service, method, transfer, ...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const responseId = this._storeCallbacks(resolve, reject); const responseId = this._storeCallbacks(resolve, reject);
/** @TODO: remove this hack! this is just here so we don't try to send `util` to a worker */
if ((args.length > 0) && (typeof args[args.length - 1].yield === 'function')) {
args.pop();
}
if (transfer) { if (transfer) {
provider.postMessage({service, method, responseId, args}, transfer); provider.postMessage({service, method, responseId, args}, transfer);
} else { } else {

View file

@ -75,6 +75,10 @@ const domToBlock = function (blockDOM, blocks, isTopBlock, parent) {
id: fieldId, id: fieldId,
value: fieldData value: fieldData
}; };
const fieldVarType = xmlChild.attribs.variabletype;
if (typeof fieldVarType === 'string') {
block.fields[fieldName].variableType = fieldVarType;
}
break; break;
} }
case 'value': case 'value':
@ -156,7 +160,7 @@ const adapter = function (e) {
if (typeof e !== 'object') return; if (typeof e !== 'object') return;
if (typeof e.xml !== 'object') return; if (typeof e.xml !== 'object') return;
return domToBlocks(html.parseDOM(e.xml.outerHTML)); return domToBlocks(html.parseDOM(e.xml.outerHTML, {decodeEntities: true}));
}; };
module.exports = adapter; module.exports = adapter;

153
src/engine/block-utility.js Normal file
View file

@ -0,0 +1,153 @@
const Thread = require('./thread');
/**
* @fileoverview
* Interface provided to block primitive functions for interacting with the
* runtime, thread, target, and convenient methods.
*/
class BlockUtility {
constructor (sequencer = null, thread = null) {
/**
* A sequencer block primitives use to branch or start procedures with
* @type {?Sequencer}
*/
this.sequencer = sequencer;
/**
* The block primitives thread with the block's target, stackFrame and
* modifiable status.
* @type {?Thread}
*/
this.thread = thread;
}
/**
* The target the primitive is working on.
* @type {Target}
*/
get target () {
return this.thread.target;
}
/**
* The runtime the block primitive is running in.
* @type {Runtime}
*/
get runtime () {
return this.sequencer.runtime;
}
/**
* The stack frame used by loop and other blocks to track internal state.
* @type {object}
*/
get stackFrame () {
return this.thread.peekStackFrame().executionContext;
}
/**
* Set the thread to yield.
*/
yield () {
this.thread.status = Thread.STATUS_YIELD;
}
/**
* Start a branch in the current block.
* @param {number} branchNum Which branch to step to (i.e., 1, 2).
* @param {boolean} isLoop Whether this block is a loop.
*/
startBranch (branchNum, isLoop) {
this.sequencer.stepToBranch(this.thread, branchNum, isLoop);
}
/**
* Stop all threads.
*/
stopAll () {
this.sequencer.runtime.stopAll();
}
/**
* Stop threads other on this target other than the thread holding the
* executed block.
*/
stopOtherTargetThreads () {
this.sequencer.runtime.stopForTarget(this.thread.target, this.thread);
}
/**
* Stop this thread.
*/
stopThisScript () {
this.thread.stopThisScript();
}
/**
* Start a specified procedure on this thread.
* @param {string} procedureCode Procedure code for procedure to start.
*/
startProcedure (procedureCode) {
this.sequencer.stepToProcedure(this.thread, procedureCode);
}
/**
* Get names for parameters for the given procedure.
* @param {string} procedureCode Procedure code for procedure to query.
* @return {Array.<string>} List of param names for a procedure.
*/
getProcedureParamNamesAndIds (procedureCode) {
return this.thread.target.blocks.getProcedureParamNamesAndIds(procedureCode);
}
/**
* Store a procedure parameter value by its name.
* @param {string} paramName The procedure's parameter name.
* @param {*} paramValue The procedure's parameter value.
*/
pushParam (paramName, paramValue) {
this.thread.pushParam(paramName, paramValue);
}
/**
* Retrieve the stored parameter value for a given parameter name.
* @param {string} paramName The procedure's parameter name.
* @return {*} The parameter's current stored value.
*/
getParam (paramName) {
return this.thread.getParam(paramName);
}
/**
* Start all relevant hats.
* @param {!string} requestedHat Opcode of hats to start.
* @param {object=} optMatchFields Optionally, fields to match on the hat.
* @param {Target=} optTarget Optionally, a target to restrict to.
* @return {Array.<Thread>} List of threads started by this function.
*/
startHats (requestedHat, optMatchFields, optTarget) {
return (
this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget)
);
}
/**
* Query a named IO device.
* @param {string} device The name of like the device, like keyboard.
* @param {string} func The name of the device's function to query.
* @param {Array.<*>} args Arguments to pass to the device's function.
* @return {*} The expected output for the device's function.
*/
ioQuery (device, func, args) {
// Find the I/O device and execute the query/function call.
if (
this.sequencer.runtime.ioDevices[device] &&
this.sequencer.runtime.ioDevices[device][func]) {
const devObject = this.sequencer.runtime.ioDevices[device];
return devObject[func].apply(devObject, args);
}
}
}
module.exports = BlockUtility;

View file

@ -2,6 +2,8 @@ const adapter = require('./adapter');
const mutationAdapter = require('./mutation-adapter'); const mutationAdapter = require('./mutation-adapter');
const xmlEscape = require('../util/xml-escape'); const xmlEscape = require('../util/xml-escape');
const MonitorRecord = require('./monitor-record'); const MonitorRecord = require('./monitor-record');
const Clone = require('../util/clone');
const {Map} = require('immutable');
/** /**
* @fileoverview * @fileoverview
@ -24,6 +26,30 @@ class Blocks {
* @type {Array.<String>} * @type {Array.<String>}
*/ */
this._scripts = []; this._scripts = [];
/**
* Runtime Cache
* @type {{inputs: {}, procedureParamNames: {}, procedureDefinitions: {}}}
* @private
*/
this._cache = {
/**
* Cache block inputs by block id
* @type {object.<string, !Array.<object>>}
*/
inputs: {},
/**
* Cache procedure Param Names by block id
* @type {object.<string, ?Array.<string>>}
*/
procedureParamNames: {},
/**
* Cache procedure definitions by block id
* @type {object.<string, ?string>}
*/
procedureDefinitions: {}
};
} }
/** /**
@ -104,11 +130,16 @@ class Blocks {
/** /**
* Get all non-branch inputs for a block. * Get all non-branch inputs for a block.
* @param {?object} block the block to query. * @param {?object} block the block to query.
* @return {!object} All non-branch inputs and their associated blocks. * @return {?Array.<object>} All non-branch inputs and their associated blocks.
*/ */
getInputs (block) { getInputs (block) {
if (typeof block === 'undefined') return null; if (typeof block === 'undefined') return null;
const inputs = {}; let inputs = this._cache.inputs[block.id];
if (typeof inputs !== 'undefined') {
return inputs;
}
inputs = {};
for (const input in block.inputs) { for (const input in block.inputs) {
// Ignore blocks prefixed with branch prefix. // Ignore blocks prefixed with branch prefix.
if (input.substring(0, Blocks.BRANCH_INPUT_PREFIX.length) !== if (input.substring(0, Blocks.BRANCH_INPUT_PREFIX.length) !==
@ -116,6 +147,8 @@ class Blocks {
inputs[input] = block.inputs[input]; inputs[input] = block.inputs[input];
} }
} }
this._cache.inputs[block.id] = inputs;
return inputs; return inputs;
} }
@ -148,36 +181,60 @@ class Blocks {
* @return {?string} ID of procedure definition. * @return {?string} ID of procedure definition.
*/ */
getProcedureDefinition (name) { getProcedureDefinition (name) {
const blockID = this._cache.procedureDefinitions[name];
if (typeof blockID !== 'undefined') {
return blockID;
}
for (const id in this._blocks) { for (const id in this._blocks) {
if (!this._blocks.hasOwnProperty(id)) continue; if (!this._blocks.hasOwnProperty(id)) continue;
const block = this._blocks[id]; const block = this._blocks[id];
if ((block.opcode === 'procedures_defnoreturn' || if (block.opcode === 'procedures_definition') {
block.opcode === 'procedures_defreturn') && const internal = this._getCustomBlockInternal(block);
block.mutation.proccode === name) { if (internal && internal.mutation.proccode === name) {
this._cache.procedureDefinitions[name] = id; // The outer define block id
return id; return id;
} }
} }
}
this._cache.procedureDefinitions[name] = null;
return null; return null;
} }
/** /**
* Get the procedure definition for a given name. * Get names of parameters for the given procedure.
* @param {?string} name Name of procedure to query. * @param {?string} name Name of procedure to query.
* @return {?string} ID of procedure definition. * @return {?Array.<string>} List of param names for a procedure.
*/ */
getProcedureParamNames (name) { getProcedureParamNamesAndIds (name) {
const cachedNames = this._cache.procedureParamNames[name];
if (typeof cachedNames !== 'undefined') {
return cachedNames;
}
for (const id in this._blocks) { for (const id in this._blocks) {
if (!this._blocks.hasOwnProperty(id)) continue; if (!this._blocks.hasOwnProperty(id)) continue;
const block = this._blocks[id]; const block = this._blocks[id];
if ((block.opcode === 'procedures_defnoreturn' || if (block.opcode === 'procedures_prototype' &&
block.opcode === 'procedures_defreturn') &&
block.mutation.proccode === name) { block.mutation.proccode === name) {
return JSON.parse(block.mutation.argumentnames); const names = JSON.parse(block.mutation.argumentnames);
const ids = JSON.parse(block.mutation.argumentids);
this._cache.procedureParamNames[name] = [names, ids];
return this._cache.procedureParamNames[name];
} }
} }
this._cache.procedureParamNames[name] = null;
return null; return null;
} }
duplicate () {
const newBlocks = new Blocks();
newBlocks._blocks = Clone.simple(this._blocks);
newBlocks._scripts = Clone.simple(this._scripts);
return newBlocks;
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/** /**
@ -242,12 +299,23 @@ class Blocks {
if (optRuntime && this._blocks[e.blockId].topLevel) { if (optRuntime && this._blocks[e.blockId].topLevel) {
optRuntime.quietGlow(e.blockId); optRuntime.quietGlow(e.blockId);
} }
this.deleteBlock({ this.deleteBlock(e.blockId);
id: e.blockId
});
break; break;
case 'var_create': case 'var_create':
stage.createVariable(e.varId, e.varName); // New variables being created by the user are all global.
// Check if this variable exists on the current target or stage.
// If not, create it on the stage.
// TODO create global and local variables when UI provides a way.
if (optRuntime.getEditingTarget()) {
if (!optRuntime.getEditingTarget().lookupVariableById(e.varId)) {
stage.createVariable(e.varId, e.varName, e.varType);
}
} else if (!stage.lookupVariableById(e.varId)) {
// Since getEditingTarget returned null, we now need to
// explicitly check if the stage has the variable, and
// create one if not.
stage.createVariable(e.varId, e.varName, e.varType);
}
break; break;
case 'var_rename': case 'var_rename':
stage.renameVariable(e.varId, e.newName); stage.renameVariable(e.varId, e.newName);
@ -260,6 +328,15 @@ class Blocks {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/**
* Reset all runtime caches.
*/
resetCache () {
this._cache.inputs = {};
this._cache.procedureParamNames = {};
this._cache.procedureDefinitions = {};
}
/** /**
* Block management: create blocks and scripts from a `create` event * Block management: create blocks and scripts from a `create` event
* @param {!object} block Blockly create event to be processed * @param {!object} block Blockly create event to be processed
@ -278,6 +355,8 @@ class Blocks {
if (block.topLevel) { if (block.topLevel) {
this._addScript(block.id); this._addScript(block.id);
} }
this.resetCache();
} }
/** /**
@ -290,13 +369,13 @@ class Blocks {
if (['field', 'mutation', 'checkbox'].indexOf(args.element) === -1) return; if (['field', 'mutation', 'checkbox'].indexOf(args.element) === -1) return;
const block = this._blocks[args.id]; const block = this._blocks[args.id];
if (typeof block === 'undefined') return; if (typeof block === 'undefined') return;
const wasMonitored = block.isMonitored; const wasMonitored = block.isMonitored;
switch (args.element) { switch (args.element) {
case 'field': case 'field':
// Update block value // Update block value
if (!block.fields[args.name]) return; if (!block.fields[args.name]) return;
if (args.name === 'VARIABLE') { if (args.name === 'VARIABLE' || args.name === 'LIST' ||
args.name === 'BROADCAST_OPTION') {
// Get variable name using the id in args.value. // Get variable name using the id in args.value.
const variable = optRuntime.getEditingTarget().lookupVariableById(args.value); const variable = optRuntime.getEditingTarget().lookupVariableById(args.value);
if (variable) { if (variable) {
@ -304,20 +383,43 @@ class Blocks {
block.fields[args.name].id = args.value; block.fields[args.name].id = args.value;
} }
} else { } else {
// Changing the value in a dropdown
block.fields[args.name].value = args.value; block.fields[args.name].value = args.value;
if (!optRuntime){
break;
}
const flyoutBlock = block.shadow && block.parent ? this._blocks[block.parent] : block;
if (flyoutBlock.isMonitored) {
optRuntime.requestUpdateMonitor(Map({
id: flyoutBlock.id,
params: this._getBlockParams(flyoutBlock)
}));
}
} }
break; break;
case 'mutation': case 'mutation':
block.mutation = mutationAdapter(args.value); block.mutation = mutationAdapter(args.value);
break; break;
case 'checkbox': case 'checkbox': {
block.isMonitored = args.value; block.isMonitored = args.value;
if (optRuntime && wasMonitored && !block.isMonitored) { if (!optRuntime) {
break;
}
const isSpriteSpecific = optRuntime.monitorBlockInfo.hasOwnProperty(block.opcode) &&
optRuntime.monitorBlockInfo[block.opcode].isSpriteSpecific;
block.targetId = isSpriteSpecific ? optRuntime.getEditingTarget().id : null;
if (wasMonitored && !block.isMonitored) {
optRuntime.requestRemoveMonitor(block.id); optRuntime.requestRemoveMonitor(block.id);
} else if (optRuntime && !wasMonitored && block.isMonitored) { } else if (!wasMonitored && block.isMonitored) {
optRuntime.requestAddMonitor(MonitorRecord({ optRuntime.requestAddMonitor(MonitorRecord({
// @todo(vm#564) this will collide if multiple sprites use same block // @todo(vm#564) this will collide if multiple sprites use same block
id: block.id, id: block.id,
targetId: block.targetId,
spriteName: block.targetId ? optRuntime.getTargetById(block.targetId).getName() : null,
opcode: block.opcode, opcode: block.opcode,
params: this._getBlockParams(block), params: this._getBlockParams(block),
// @todo(vm#565) for numerical values with decimals, some countries use comma // @todo(vm#565) for numerical values with decimals, some countries use comma
@ -328,6 +430,9 @@ class Blocks {
} }
} }
this.resetCache();
}
/** /**
* Block management: move blocks from parent to parent * Block management: move blocks from parent to parent
* @param {!object} e Blockly move event to be processed * @param {!object} e Blockly move event to be processed
@ -382,6 +487,7 @@ class Blocks {
} }
this._blocks[e.id].parent = e.newParent; this._blocks[e.id].parent = e.newParent;
} }
this.resetCache();
} }
@ -392,45 +498,52 @@ class Blocks {
runAllMonitored (runtime) { runAllMonitored (runtime) {
Object.keys(this._blocks).forEach(blockId => { Object.keys(this._blocks).forEach(blockId => {
if (this.getBlock(blockId).isMonitored) { if (this.getBlock(blockId).isMonitored) {
// @todo handle specific targets (e.g. apple x position) const targetId = this.getBlock(blockId).targetId;
runtime.addMonitorScript(blockId); runtime.addMonitorScript(blockId, targetId ? runtime.getTargetById(targetId) : null);
} }
}); });
} }
/** /**
* Block management: delete blocks and their associated scripts. * Block management: delete blocks and their associated scripts. Does nothing if a block
* @param {!object} e Blockly delete event to be processed. * with the given ID does not exist.
* @param {!string} blockId Id of block to delete
*/ */
deleteBlock (e) { deleteBlock (blockId) {
// @todo In runtime, stop threads running on this script. // @todo In runtime, stop threads running on this script.
// Get block // Get block
const block = this._blocks[e.id]; const block = this._blocks[blockId];
if (!block) {
// No block with the given ID exists
return;
}
// Delete children // Delete children
if (block.next !== null) { if (block.next !== null) {
this.deleteBlock({id: block.next}); this.deleteBlock(block.next);
} }
// Delete inputs (including branches) // Delete inputs (including branches)
for (const input in block.inputs) { for (const input in block.inputs) {
// If it's null, the block in this input moved away. // If it's null, the block in this input moved away.
if (block.inputs[input].block !== null) { if (block.inputs[input].block !== null) {
this.deleteBlock({id: block.inputs[input].block}); this.deleteBlock(block.inputs[input].block);
} }
// Delete obscured shadow blocks. // Delete obscured shadow blocks.
if (block.inputs[input].shadow !== null && if (block.inputs[input].shadow !== null &&
block.inputs[input].shadow !== block.inputs[input].block) { block.inputs[input].shadow !== block.inputs[input].block) {
this.deleteBlock({id: block.inputs[input].shadow}); this.deleteBlock(block.inputs[input].shadow);
} }
} }
// Delete any script starting with this block. // Delete any script starting with this block.
this._deleteScript(e.id); this._deleteScript(blockId);
// Delete block itself. // Delete block itself.
delete this._blocks[e.id]; delete this._blocks[blockId];
this.resetCache();
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -485,11 +598,20 @@ class Blocks {
for (const field in block.fields) { for (const field in block.fields) {
if (!block.fields.hasOwnProperty(field)) continue; if (!block.fields.hasOwnProperty(field)) continue;
const blockField = block.fields[field]; const blockField = block.fields[field];
xmlString += `<field name="${blockField.name}"`;
const fieldId = blockField.id;
if (fieldId) {
xmlString += ` id="${fieldId}"`;
}
const varType = blockField.variableType;
if (typeof varType === 'string') {
xmlString += ` variabletype="${varType}"`;
}
let value = blockField.value; let value = blockField.value;
if (typeof value === 'string') { if (typeof value === 'string') {
value = xmlEscape(blockField.value); value = xmlEscape(blockField.value);
} }
xmlString += `<field name="${blockField.name}">${value}</field>`; xmlString += `>${value}</field>`;
} }
// Add blocks connected to the next connection. // Add blocks connected to the next connection.
if (block.next) { if (block.next) {
@ -540,6 +662,17 @@ class Blocks {
return params; return params;
} }
/**
* Helper to get the corresponding internal procedure definition block
* @param {!object} defineBlock Outer define block.
* @return {!object} internal definition block which has the mutation.
*/
_getCustomBlockInternal (defineBlock) {
if (defineBlock.inputs && defineBlock.inputs.custom_block) {
return this._blocks[defineBlock.inputs.custom_block.block];
}
}
/** /**
* Helper to add a stack to `this._scripts`. * Helper to add a stack to `this._scripts`.
* @param {?string} topBlockId ID of block that starts the script. * @param {?string} topBlockId ID of block that starts the script.

View file

@ -1,6 +1,26 @@
const BlockUtility = require('./block-utility');
const log = require('../util/log'); const log = require('../util/log');
const Thread = require('./thread'); const Thread = require('./thread');
const {Map} = require('immutable'); const {Map} = require('immutable');
const cast = require('../util/cast');
/**
* Single BlockUtility instance reused by execute for every pritimive ran.
* @const
*/
const blockUtility = new BlockUtility();
/**
* Profiler frame name for block functions.
* @const {string}
*/
const blockFunctionProfilerFrame = 'blockFunction';
/**
* Profiler frame ID for 'blockFunction'.
* @type {number}
*/
let blockFunctionProfilerId = -1;
/** /**
* Utility function to determine if a value is a Promise. * Utility function to determine if a value is a Promise.
@ -8,7 +28,74 @@ const {Map} = require('immutable');
* @return {boolean} True if the value appears to be a Promise. * @return {boolean} True if the value appears to be a Promise.
*/ */
const isPromise = function (value) { const isPromise = function (value) {
return value && value.then && typeof value.then === 'function'; return (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
);
};
/**
* Handle any reported value from the primitive, either directly returned
* or after a promise resolves.
* @param {*} resolvedValue Value eventually returned from the primitive.
* @param {!Sequencer} sequencer Sequencer stepping the thread for the ran
* primitive.
* @param {!Thread} thread Thread containing the primitive.
* @param {!string} currentBlockId Id of the block in its thread for value from
* the primitive.
* @param {!string} opcode opcode used to identify a block function primitive.
* @param {!boolean} isHat Is the current block a hat?
*/
// @todo move this to callback attached to the thread when we have performance
// metrics (dd)
const handleReport = function (
resolvedValue, sequencer, thread, currentBlockId, opcode, isHat) {
thread.pushReportedValue(resolvedValue);
if (isHat) {
// Hat predicate was evaluated.
if (sequencer.runtime.getIsEdgeActivatedHat(opcode)) {
// If this is an edge-activated hat, only proceed if the value is
// true and used to be false, or the stack was activated explicitly
// via stack click
if (!thread.stackClick) {
const oldEdgeValue = sequencer.runtime.updateEdgeActivatedValue(
currentBlockId,
resolvedValue
);
const edgeWasActivated = !oldEdgeValue && resolvedValue;
if (!edgeWasActivated) {
sequencer.retireThread(thread);
}
}
} else if (!resolvedValue) {
// Not an edge-activated hat: retire the thread
// if predicate was false.
sequencer.retireThread(thread);
}
} else {
// In a non-hat, report the value visually if necessary if
// at the top of the thread stack.
if (typeof resolvedValue !== 'undefined' && thread.atStackTop()) {
if (thread.stackClick) {
sequencer.runtime.visualReport(currentBlockId, resolvedValue);
}
if (thread.updateMonitor) {
const targetId = sequencer.runtime.monitorBlocks.getBlock(currentBlockId).targetId;
if (targetId && !sequencer.runtime.getTargetById(targetId)) {
// Target no longer exists
return;
}
sequencer.runtime.requestUpdateMonitor(Map({
id: currentBlockId,
spriteName: targetId ? sequencer.runtime.getTargetById(targetId).getName() : null,
value: String(resolvedValue)
}));
}
}
// Finished any yields.
thread.status = Thread.STATUS_RUNNING;
}
}; };
/** /**
@ -61,55 +148,6 @@ const execute = function (sequencer, thread) {
return; return;
} }
/**
* Handle any reported value from the primitive, either directly returned
* or after a promise resolves.
* @param {*} resolvedValue Value eventually returned from the primitive.
*/
// @todo move this to callback attached to the thread when we have performance
// metrics (dd)
const handleReport = function (resolvedValue) {
thread.pushReportedValue(resolvedValue);
if (isHat) {
// Hat predicate was evaluated.
if (runtime.getIsEdgeActivatedHat(opcode)) {
// If this is an edge-activated hat, only proceed if
// the value is true and used to be false, or the stack was activated
// explicitly via stack click
if (!thread.stackClick) {
const oldEdgeValue = runtime.updateEdgeActivatedValue(
currentBlockId,
resolvedValue
);
const edgeWasActivated = !oldEdgeValue && resolvedValue;
if (!edgeWasActivated) {
sequencer.retireThread(thread);
}
}
} else if (!resolvedValue) {
// Not an edge-activated hat: retire the thread
// if predicate was false.
sequencer.retireThread(thread);
}
} else {
// In a non-hat, report the value visually if necessary if
// at the top of the thread stack.
if (typeof resolvedValue !== 'undefined' && thread.atStackTop()) {
if (thread.stackClick) {
runtime.visualReport(currentBlockId, resolvedValue);
}
if (thread.updateMonitor) {
runtime.requestUpdateMonitor(Map({
id: currentBlockId,
value: String(resolvedValue)
}));
}
}
// Finished any yields.
thread.status = Thread.STATUS_RUNNING;
}
};
// Hats and single-field shadows are implemented slightly differently // Hats and single-field shadows are implemented slightly differently
// from regular blocks. // from regular blocks.
// For hats: if they have an associated block function, // For hats: if they have an associated block function,
@ -124,7 +162,7 @@ const execute = function (sequencer, thread) {
const keys = Object.keys(fields); const keys = Object.keys(fields);
if (keys.length === 1 && Object.keys(inputs).length === 0) { if (keys.length === 1 && Object.keys(inputs).length === 0) {
// One field and no inputs - treat as arg. // One field and no inputs - treat as arg.
handleReport(fields[keys[0]].value); handleReport(fields[keys[0]].value, sequencer, thread, currentBlockId, opcode, isHat);
} else { } else {
log.warn(`Could not get implementation for opcode: ${opcode}`); log.warn(`Could not get implementation for opcode: ${opcode}`);
} }
@ -138,8 +176,12 @@ const execute = function (sequencer, thread) {
// Add all fields on this block to the argValues. // Add all fields on this block to the argValues.
for (const fieldName in fields) { for (const fieldName in fields) {
if (!fields.hasOwnProperty(fieldName)) continue; if (!fields.hasOwnProperty(fieldName)) continue;
if (fieldName === 'VARIABLE') { if (fieldName === 'VARIABLE' || fieldName === 'LIST' ||
argValues[fieldName] = fields[fieldName].id; fieldName === 'BROADCAST_OPTION') {
argValues[fieldName] = {
id: fields[fieldName].id,
name: fields[fieldName].value
};
} else { } else {
argValues[fieldName] = fields[fieldName].value; argValues[fieldName] = fields[fieldName].value;
} }
@ -148,6 +190,8 @@ const execute = function (sequencer, thread) {
// Recursively evaluate input blocks. // Recursively evaluate input blocks.
for (const inputName in inputs) { for (const inputName in inputs) {
if (!inputs.hasOwnProperty(inputName)) continue; if (!inputs.hasOwnProperty(inputName)) continue;
// Do not evaluate the internal custom command block within definition
if (inputName === 'custom_block') continue;
const input = inputs[inputName]; const input = inputs[inputName];
const inputBlockId = input.block; const inputBlockId = input.block;
// Is there no value for this input waiting in the stack frame? // Is there no value for this input waiting in the stack frame?
@ -168,7 +212,30 @@ const execute = function (sequencer, thread) {
currentStackFrame.waitingReporter = null; currentStackFrame.waitingReporter = null;
thread.popStack(); thread.popStack();
} }
argValues[inputName] = currentStackFrame.reported[inputName]; const inputValue = currentStackFrame.reported[inputName];
if (inputName === 'BROADCAST_INPUT') {
const broadcastInput = inputs[inputName];
// Check if something is plugged into the broadcast block, or
// if the shadow dropdown menu is being used.
if (broadcastInput.block === broadcastInput.shadow) {
// Shadow dropdown menu is being used.
// Get the appropriate information out of it.
const shadow = blockContainer.getBlock(broadcastInput.shadow);
const broadcastField = shadow.fields.BROADCAST_OPTION;
argValues.BROADCAST_OPTION = {
id: broadcastField.id,
name: broadcastField.value
};
} else {
// Something is plugged into the broadcast input.
// Cast it to a string. We don't need an id here.
argValues.BROADCAST_OPTION = {
name: cast.toString(inputValue)
};
}
} else {
argValues[inputName] = inputValue;
}
} }
// Add any mutation to args (e.g., for procedures). // Add any mutation to args (e.g., for procedures).
@ -184,49 +251,25 @@ const execute = function (sequencer, thread) {
currentStackFrame.reported = {}; currentStackFrame.reported = {};
let primitiveReportedValue = null; let primitiveReportedValue = null;
primitiveReportedValue = blockFunction(argValues, { blockUtility.sequencer = sequencer;
stackFrame: currentStackFrame.executionContext, blockUtility.thread = thread;
target: target, if (runtime.profiler !== null) {
yield: function () { if (blockFunctionProfilerId === -1) {
thread.status = Thread.STATUS_YIELD; blockFunctionProfilerId = runtime.profiler.idByName(blockFunctionProfilerFrame);
},
startBranch: function (branchNum, isLoop) {
sequencer.stepToBranch(thread, branchNum, isLoop);
},
stopAll: function () {
runtime.stopAll();
},
stopOtherTargetThreads: function () {
runtime.stopForTarget(target, thread);
},
stopThisScript: function () {
thread.stopThisScript();
},
startProcedure: function (procedureCode) {
sequencer.stepToProcedure(thread, procedureCode);
},
getProcedureParamNames: function (procedureCode) {
return blockContainer.getProcedureParamNames(procedureCode);
},
pushParam: function (paramName, paramValue) {
thread.pushParam(paramName, paramValue);
},
getParam: function (paramName) {
return thread.getParam(paramName);
},
startHats: function (requestedHat, optMatchFields, optTarget) {
return (
runtime.startHats(requestedHat, optMatchFields, optTarget)
);
},
ioQuery: function (device, func, args) {
// Find the I/O device and execute the query/function call.
if (runtime.ioDevices[device] && runtime.ioDevices[device][func]) {
const devObject = runtime.ioDevices[device];
return devObject[func].apply(devObject, args);
} }
// 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, performance.now());
}
primitiveReportedValue = blockFunction(argValues, blockUtility);
if (runtime.profiler !== null) {
// runtime.profiler.stop(blockFunctionProfilerId);
runtime.profiler.records.push(runtime.profiler.STOP, performance.now());
} }
});
if (typeof primitiveReportedValue === 'undefined') { if (typeof primitiveReportedValue === 'undefined') {
// No value reported - potentially a command block. // No value reported - potentially a command block.
@ -242,7 +285,7 @@ const execute = function (sequencer, thread) {
} }
// Promise handlers // Promise handlers
primitiveReportedValue.then(resolvedValue => { primitiveReportedValue.then(resolvedValue => {
handleReport(resolvedValue); handleReport(resolvedValue, sequencer, thread, currentBlockId, opcode, isHat);
if (typeof resolvedValue === 'undefined') { if (typeof resolvedValue === 'undefined') {
let stackFrame; let stackFrame;
let nextBlockId; let nextBlockId;
@ -275,7 +318,7 @@ const execute = function (sequencer, thread) {
thread.popStack(); thread.popStack();
}); });
} else if (thread.status === Thread.STATUS_RUNNING) { } else if (thread.status === Thread.STATUS_RUNNING) {
handleReport(primitiveReportedValue); handleReport(primitiveReportedValue, sequencer, thread, currentBlockId, opcode, isHat);
} }
}; };

View file

@ -1,18 +0,0 @@
/**
* @fileoverview
* Object representing a Scratch list.
*/
/**
* @param {!string} name Name of the list.
* @param {Array} contents Contents of the list, as an array.
* @constructor
*/
class List {
constructor (name, contents) {
this.name = name;
this.contents = contents;
}
}
module.exports = List;

View file

@ -2,6 +2,10 @@ const {Record} = require('immutable');
const MonitorRecord = Record({ const MonitorRecord = Record({
id: null, id: null,
/** Present only if the monitor is sprite-specific, such as x position */
spriteName: null,
/** Present only if the monitor is sprite-specific, such as x position */
targetId: null,
opcode: null, opcode: null,
value: null, value: null,
params: null params: null

View file

@ -1,4 +1,5 @@
const html = require('htmlparser2'); const html = require('htmlparser2');
const decodeHtml = require('decode-html');
/** /**
* Convert a part of a mutation DOM to a mutation VM object, recursively. * Convert a part of a mutation DOM to a mutation VM object, recursively.
@ -11,7 +12,7 @@ const mutatorTagToObject = function (dom) {
obj.children = []; obj.children = [];
for (const prop in dom.attribs) { for (const prop in dom.attribs) {
if (prop === 'xmlns') continue; if (prop === 'xmlns') continue;
obj[prop] = dom.attribs[prop]; obj[prop] = decodeHtml(dom.attribs[prop]);
} }
for (let i = 0; i < dom.children.length; i++) { for (let i = 0; i < dom.children.length; i++) {
obj.children.push( obj.children.push(

311
src/engine/profiler.js Normal file
View file

@ -0,0 +1,311 @@
/**
* @fileoverview
* A way to profile Scratch internal performance. Like what blocks run during a
* step? How much time do they take? How much time is spent inbetween blocks?
*
* Profiler aims for to spend as little time inside its functions while
* recording. For this it has a simple internal record structure that records a
* series of values for each START and STOP event in a single array. This lets
* all the values be pushed in one call for the array. This simplicity allows
* the contents of the start() and stop() calls to be inlined in areas that are
* called frequently enough to want even greater performance from Profiler so
* what is recorded better reflects on the profiled code and not Profiler
* itself.
*/
/**
* The next id returned for a new profile'd function.
* @type {number}
*/
let nextId = 0;
/**
* The mapping of names to ids.
* @const {Object.<string, number>}
*/
const profilerNames = {};
/**
* The START event identifier in Profiler records.
* @const {number}
*/
const START = 0;
/**
* The STOP event identifier in Profiler records.
* @const {number}
*/
const STOP = 1;
/**
* The number of cells used in the records array by a START event.
* @const {number}
*/
const START_SIZE = 4;
/**
* The number of cells used in the records array by a STOP event.
* @const {number}
*/
const STOP_SIZE = 2;
/**
* Stored reference to Performance instance provided by the Browser.
* @const {Performance}
*/
const performance = typeof window === 'object' && window.performance;
/**
* Callback handle called by Profiler for each frame it decodes from its
* records.
* @callback FrameCallback
* @param {ProfilerFrame} frame
*/
/**
* A set of information about a frame of execution that was recorded.
*/
class ProfilerFrame {
/**
* @param {number} depth Depth of the frame in the recorded stack.
*/
constructor (depth) {
/**
* The numeric id of a record symbol like Runtime._step or
* blockFunction.
* @type {number}
*/
this.id = -1;
/**
* The amount of time spent inside the recorded frame and any deeper
* frames.
* @type {number}
*/
this.totalTime = 0;
/**
* The amount of time spent only inside this record frame. Not
* including time in any deeper frames.
* @type {number}
*/
this.selfTime = 0;
/**
* An arbitrary argument for the recorded frame. For example a block
* function might record its opcode as an argument.
* @type {*}
*/
this.arg = null;
/**
* The depth of the recorded frame. This can help compare recursive
* funtions that are recorded. Each level of recursion with have a
* different depth value.
* @type {number}
*/
this.depth = depth;
}
}
class Profiler {
/**
* @param {FrameCallback} onFrame a handle called for each recorded frame.
* The passed frame value may not be stored as it'll be updated with later
* frame information. Any information that is further stored by the handler
* should make copies or reduce the information.
*/
constructor (onFrame = function () {}) {
/**
* A series of START and STOP values followed by arguments. After
* recording is complete the full set of records is reported back by
* stepping through the series to connect the relative START and STOP
* information.
* @type {Array.<*>}
*/
this.records = [];
/**
* A cache of ProfilerFrames to reuse when reporting the recorded
* frames in records.
* @type {Array.<ProfilerFrame>}
*/
this._stack = [new ProfilerFrame(0)];
/**
* A callback handle called with each decoded frame when reporting back
* all the recorded times.
* @type {FrameCallback}
*/
this.onFrame = onFrame;
/**
* A reference to the START record id constant.
* @const {number}
*/
this.START = START;
/**
* A reference to the STOP record id constant.
* @const {number}
*/
this.STOP = STOP;
}
/**
* Start recording a frame of time for an id and optional argument.
* @param {number} id The id returned by idByName for a name symbol like
* Runtime._step.
* @param {?*} arg An arbitrary argument value to store with the frame.
*/
start (id, arg) {
this.records.push(START, id, arg, performance.now());
}
/**
* Stop the current frame.
*/
stop () {
this.records.push(STOP, performance.now());
}
/**
* Decode records and report all frames to `this.onFrame`.
*/
reportFrames () {
const stack = this._stack;
let depth = 1;
// Step through the records and initialize Frame instances from the
// START and STOP events. START and STOP events are separated by events
// for deeper frames run by higher frames. Frames are stored on a stack
// and reinitialized for each START event. When a stop event is reach
// the Frame for the current depth has its final values stored and its
// passed to the current onFrame callback. This way Frames are "pushed"
// for each START event and "popped" for each STOP and handed to an
// outside handle to any desired reduction of the collected data.
for (let i = 0; i < this.records.length;) {
if (this.records[i] === START) {
if (depth >= stack.length) {
stack.push(new ProfilerFrame(depth));
}
// Store id, arg, totalTime, and initialize selfTime.
const frame = stack[depth++];
frame.id = this.records[i + 1];
frame.arg = this.records[i + 2];
// totalTime is first set as the time recorded by this START
// event. Once the STOP event is reached the stored start time
// is subtracted from the recorded stop time. The resulting
// difference is the actual totalTime, and replaces the start
// time in frame.totalTime.
//
// totalTime is used this way as a convenient member to store a
// value between the two events without needing additional
// members on the Frame or in a shadow map.
frame.totalTime = this.records[i + 3];
// selfTime is decremented until we reach the STOP event for
// this frame. totalTime will be added to it then to get the
// time difference.
frame.selfTime = 0;
i += START_SIZE;
} else if (this.records[i] === STOP) {
const now = this.records[i + 1];
const frame = stack[--depth];
// totalTime is the difference between the start event time
// stored in totalTime and the stop event time pulled from this
// record.
frame.totalTime = now - frame.totalTime;
// selfTime is the difference of this frame's totalTime and the
// sum of totalTime of deeper frames.
frame.selfTime += frame.totalTime;
// Remove this frames totalTime from the parent's selfTime.
stack[depth - 1].selfTime -= frame.totalTime;
this.onFrame(frame);
i += STOP_SIZE;
} else {
this.records.length = 0;
throw new Error('Unable to decode Profiler records.');
}
}
this.records.length = 0;
}
/**
* Lookup or create an id for a frame name.
* @param {string} name The name to return an id for.
* @return {number} The id for the passed name.
*/
idByName (name) {
return Profiler.idByName(name);
}
/**
* Reverse lookup the name from a given frame id.
* @param {number} id The id to search for.
* @return {string} The name for the given id.
*/
nameById (id) {
return Profiler.nameById(id);
}
/**
* Lookup or create an id for a frame name.
* @static
* @param {string} name The name to return an id for.
* @return {number} The id for the passed name.
*/
static idByName (name) {
if (typeof profilerNames[name] !== 'number') {
profilerNames[name] = nextId++;
}
return profilerNames[name];
}
/**
* Reverse lookup the name from a given frame id.
* @static
* @param {number} id The id to search for.
* @return {string} The name for the given id.
*/
static nameById (id) {
for (const name in profilerNames) {
if (profilerNames[name] === id) {
return name;
}
}
return null;
}
/**
* Profiler is only available on platforms with the Performance API.
* @return {boolean} Can the Profiler run in this browser?
*/
static available () {
return (
typeof window === 'object' &&
typeof window.performance !== 'undefined');
}
}
/**
* A reference to the START record id constant.
* @const {number}
*/
Profiler.START = START;
/**
* A reference to the STOP record id constant.
* @const {number}
*/
Profiler.STOP = STOP;
module.exports = Profiler;

View file

@ -1,8 +1,13 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const Sequencer = require('./sequencer');
const Blocks = require('./blocks');
const Thread = require('./thread');
const {OrderedMap} = require('immutable'); const {OrderedMap} = require('immutable');
const escapeHtml = require('escape-html');
const ArgumentType = require('../extension-support/argument-type');
const Blocks = require('./blocks');
const BlockType = require('../extension-support/block-type');
const Sequencer = require('./sequencer');
const Thread = require('./thread');
const Profiler = require('./profiler');
// Virtual I/O devices. // Virtual I/O devices.
const Clock = require('../io/clock'); const Clock = require('../io/clock');
@ -16,14 +21,83 @@ const defaultBlockPackages = {
scratch3_looks: require('../blocks/scratch3_looks'), scratch3_looks: require('../blocks/scratch3_looks'),
scratch3_motion: require('../blocks/scratch3_motion'), scratch3_motion: require('../blocks/scratch3_motion'),
scratch3_operators: require('../blocks/scratch3_operators'), scratch3_operators: require('../blocks/scratch3_operators'),
scratch3_pen: require('../blocks/scratch3_pen'),
scratch3_sound: require('../blocks/scratch3_sound'), scratch3_sound: require('../blocks/scratch3_sound'),
scratch3_sensing: require('../blocks/scratch3_sensing'), scratch3_sensing: require('../blocks/scratch3_sensing'),
scratch3_data: require('../blocks/scratch3_data'), scratch3_data: require('../blocks/scratch3_data'),
scratch3_procedures: require('../blocks/scratch3_procedures'), scratch3_procedures: require('../blocks/scratch3_procedures')
scratch3_wedo2: require('../blocks/scratch3_wedo2')
}; };
/**
* Information used for converting Scratch argument types into scratch-blocks data.
* @type {object.<ArgumentType, {shadowType: string, fieldType: string}>}}
*/
const ArgumentTypeMap = (() => {
const map = {};
map[ArgumentType.ANGLE] = {
shadowType: 'math_angle',
fieldType: 'NUM'
};
map[ArgumentType.COLOR] = {
shadowType: 'colour_picker'
};
map[ArgumentType.NUMBER] = {
shadowType: 'math_number',
fieldType: 'NUM'
};
map[ArgumentType.STRING] = {
shadowType: 'text',
fieldType: 'TEXT'
};
map[ArgumentType.BOOLEAN] = {
check: 'Boolean'
};
return map;
})();
/**
* These constants are copied from scratch-blocks/core/constants.js
* @TODO find a way to require() these... maybe make a scratch-blocks/dist/constants.js or something like that?
* @readonly
* @enum {int}
*/
const ScratchBlocksConstants = {
/**
* ENUM for output shape: hexagonal (booleans/predicates).
* @const
*/
OUTPUT_SHAPE_HEXAGONAL: 1,
/**
* ENUM for output shape: rounded (numbers).
* @const
*/
OUTPUT_SHAPE_ROUND: 2,
/**
* ENUM for output shape: squared (any/all values; strings).
* @const
*/
OUTPUT_SHAPE_SQUARE: 3
};
/**
* Numeric ID for Runtime._step in Profiler instances.
* @type {number}
*/
let stepProfilerId = -1;
/**
* Numeric ID for Sequencer.stepThreads in Profiler instances.
* @type {number}
*/
let stepThreadsProfilerId = -1;
/**
* Numeric ID for RenderWebGL.draw in Profiler instances.
* @type {number}
*/
let rendererDrawProfilerId = -1;
/** /**
* Manages targets, scripts, and the sequencer. * Manages targets, scripts, and the sequencer.
* @constructor * @constructor
@ -75,6 +149,13 @@ class Runtime extends EventEmitter {
*/ */
this._primitives = {}; this._primitives = {};
/**
* Map to look up all block information by extended opcode.
* @type {Array.<CategoryInfo>}
* @private
*/
this._blockInfo = [];
/** /**
* Map to look up hat blocks' metadata. * Map to look up hat blocks' metadata.
* Keys are opcode for hat, values are metadata objects. * Keys are opcode for hat, values are metadata objects.
@ -114,6 +195,13 @@ class Runtime extends EventEmitter {
*/ */
this._refreshTargets = false; this._refreshTargets = false;
/**
* Map to look up all monitor block information by opcode.
* @type {object}
* @private
*/
this.monitorBlockInfo = {};
/** /**
* Ordered map of all monitors, which are MonitorReporter objects. * Ordered map of all monitors, which are MonitorReporter objects.
*/ */
@ -172,6 +260,13 @@ class Runtime extends EventEmitter {
keyboard: new Keyboard(this), keyboard: new Keyboard(this),
mouse: new Mouse(this) mouse: new Mouse(this)
}; };
/**
* A runtime profiler that records timed events for later playback to
* diagnose Scratch performance.
* @type {Profiler}
*/
this.profiler = null;
} }
/** /**
@ -223,7 +318,17 @@ class Runtime extends EventEmitter {
} }
/** /**
* Event name for glowing the green flag * Event name when the project is started (threads may not necessarily be
* running).
* @const {string}
*/
static get PROJECT_START () {
return 'PROJECT_START';
}
/**
* Event name when threads start running.
* Used by the UI to indicate running status.
* @const {string} * @const {string}
*/ */
static get PROJECT_RUN_START () { static get PROJECT_RUN_START () {
@ -231,13 +336,23 @@ class Runtime extends EventEmitter {
} }
/** /**
* Event name for unglowing the green flag * Event name when threads stop running
* Used by the UI to indicate not-running status.
* @const {string} * @const {string}
*/ */
static get PROJECT_RUN_STOP () { static get PROJECT_RUN_STOP () {
return 'PROJECT_RUN_STOP'; return 'PROJECT_RUN_STOP';
} }
/**
* Event name for project being stopped or restarted by the user.
* Used by blocks that need to reset state.
* @const {string}
*/
static get PROJECT_STOP_ALL () {
return 'PROJECT_STOP_ALL';
}
/** /**
* Event name for visual value report. * Event name for visual value report.
* @const {string} * @const {string}
@ -262,6 +377,22 @@ class Runtime extends EventEmitter {
return 'MONITORS_UPDATE'; return 'MONITORS_UPDATE';
} }
/**
* Event name for reporting that an extension was added.
* @const {string}
*/
static get EXTENSION_ADDED () {
return 'EXTENSION_ADDED';
}
/**
* Event name for reporting that blocksInfo was updated.
* @const {string}
*/
static get BLOCKSINFO_UPDATE () {
return 'BLOCKSINFO_UPDATE';
}
/** /**
* How rapidly we try to step threads by default, in ms. * How rapidly we try to step threads by default, in ms.
*/ */
@ -316,9 +447,326 @@ class Runtime extends EventEmitter {
} }
} }
} }
// Collect monitored from package.
if (packageObject.getMonitored) {
this.monitorBlockInfo = Object.assign({}, this.monitorBlockInfo, packageObject.getMonitored());
} }
} }
} }
}
/**
* Generate an extension-specific menu ID.
* @param {string} menuName - the name of the menu.
* @param {string} extensionId - the ID of the extension hosting the menu.
* @returns {string} - the constructed ID.
* @private
*/
_makeExtensionMenuId (menuName, extensionId) {
return `${extensionId}.menu.${escapeHtml(menuName)}`;
}
/**
* Register the primitives provided by an extension.
* @param {ExtensionInfo} extensionInfo - information about the extension (id, blocks, etc.)
* @private
*/
_registerExtensionPrimitives (extensionInfo) {
const categoryInfo = {
id: extensionInfo.id,
name: extensionInfo.name,
blockIconURI: extensionInfo.blockIconURI,
menuIconURI: extensionInfo.menuIconURI,
color1: '#FF6680',
color2: '#FF4D6A',
color3: '#FF3355',
blocks: [],
menus: []
};
this._blockInfo.push(categoryInfo);
for (const menuName in extensionInfo.menus) {
if (extensionInfo.menus.hasOwnProperty(menuName)) {
const menuItems = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
categoryInfo.menus.push(convertedMenu);
}
}
for (const blockInfo of extensionInfo.blocks) {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
const opcode = convertedBlock.json.type;
categoryInfo.blocks.push(convertedBlock);
this._primitives[opcode] = convertedBlock.info.func;
if (blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */
}
}
this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks.concat(categoryInfo.menus));
}
/**
* Reregister the primitives for an extension
* @param {ExtensionInfo} extensionInfo - new info (results of running getInfo)
* for an extension
* @private
*/
_refreshExtensionPrimitives (extensionInfo) {
let extensionBlocks = [];
for (const categoryInfo of this._blockInfo) {
if (extensionInfo.id === categoryInfo.id) {
categoryInfo.blocks = [];
categoryInfo.menus = [];
for (const menuName in extensionInfo.menus) {
if (extensionInfo.menus.hasOwnProperty(menuName)) {
const menuItems = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
categoryInfo.menus.push(convertedMenu);
}
}
for (const blockInfo of extensionInfo.blocks) {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
const opcode = convertedBlock.json.type;
categoryInfo.blocks.push(convertedBlock);
this._primitives[opcode] = convertedBlock.info.func;
if (blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */
}
}
extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus);
}
}
this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks);
}
/**
* 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
* @private
*/
_buildMenuForScratchBlocks (menuName, menuItems, categoryInfo) {
const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id);
/** @TODO: support dynamic menus when 'menuItems' is a method name string (see extension spec) */
if (typeof menuItems === 'string') {
throw new Error(`Dynamic extension menus are not yet supported. Menu name: ${menuName}`);
}
const options = menuItems.map(item => {
switch (typeof item) {
case 'string':
return [item, item];
case 'object':
return [item.text, item.value];
default:
throw new Error(`Can't interpret menu item: ${item}`);
}
});
return {
json: {
message0: '%1',
type: menuId,
inputsInline: true,
output: 'String',
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
outputShape: ScratchBlocksConstants.OUTPUT_SHAPE_ROUND,
args0: [
{
type: 'field_dropdown',
name: menuName,
options: options
}
]
}
};
}
/**
* Convert BlockInfo into scratch-blocks JSON & XML, and generate a proxy function.
* @param {BlockInfo} blockInfo - the block to convert
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {{info: BlockInfo, json: object, xml: string}} - the converted & original block information
* @private
*/
_convertForScratchBlocks (blockInfo, categoryInfo) {
const extendedOpcode = `${categoryInfo.id}.${blockInfo.opcode}`;
const blockJSON = {
type: extendedOpcode,
inputsInline: true,
category: categoryInfo.name,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
args0: [],
extensions: ['scratch_extension']
};
const inputList = [];
// TODO: store this somewhere so that we can map args appropriately after translation.
// This maps an arg name to its relative position in the original (usually English) block text.
// When displaying a block in another language we'll need to run a `replace` action similar to the one below,
// but each `[ARG]` will need to be replaced with the number in this map instead of `args0.length`.
const argsMap = {};
blockJSON.message0 = '';
// If an icon for the extension exists, prepend it to each block, with a vertical separator.
if (categoryInfo.blockIconURI) {
blockJSON.message0 = '%1 %2';
const iconJSON = {
type: 'field_image',
src: categoryInfo.blockIconURI,
width: 40,
height: 40
};
const separatorJSON = {
type: 'field_vertical_separator'
};
blockJSON.args0.push(iconJSON);
blockJSON.args0.push(separatorJSON);
}
blockJSON.message0 += blockInfo.text.replace(/\[(.+?)]/g, (match, placeholder) => {
// Sanitize the placeholder to ensure valid XML
placeholder = placeholder.replace(/[<"&]/, '_');
const argJSON = {
type: 'input_value',
name: placeholder
};
const argInfo = blockInfo.arguments[placeholder] || {};
const argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ?
'' :
escapeHtml(argInfo.defaultValue.toString()));
if (argTypeInfo.check) {
argJSON.check = argTypeInfo.check;
}
const shadowType = (argInfo.menu ?
this._makeExtensionMenuId(argInfo.menu, categoryInfo.id) :
argTypeInfo.shadowType);
const fieldType = argInfo.menu || argTypeInfo.fieldType;
// <value> is the ScratchBlocks name for a block input.
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) {
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) {
inputList.push(`<field name="${fieldType}">${defaultValue}</field>`);
}
inputList.push('</shadow>');
}
inputList.push('</value>');
// scratch-blocks uses 1-based argument indexing
blockJSON.args0.push(argJSON);
const argNum = blockJSON.args0.length;
argsMap[placeholder] = argNum;
return `%${argNum}`;
});
switch (blockInfo.blockType) {
case BlockType.COMMAND:
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.previousStatement = null; // null = available connection; undefined = hat
if (!blockInfo.isTerminal) {
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
}
break;
case BlockType.REPORTER:
blockJSON.output = 'String'; // TODO: distinguish number & string here?
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_ROUND;
break;
case BlockType.BOOLEAN:
blockJSON.output = 'Boolean';
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_HEXAGONAL;
break;
case BlockType.HAT:
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
break;
case BlockType.CONDITIONAL:
// Statement inputs get names like 'SUBSTACK', 'SUBSTACK2', 'SUBSTACK3', ...
for (let branchNum = 1; branchNum <= blockInfo.branchCount; ++branchNum) {
blockJSON[`message${branchNum}`] = '%1';
blockJSON[`args${branchNum}`] = [{
type: 'input_statement',
name: `SUBSTACK${branchNum > 1 ? branchNum : ''}`
}];
}
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.previousStatement = null; // null = available connection; undefined = hat
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
break;
}
if (blockInfo.isTerminal) {
delete blockJSON.nextStatement;
}
const blockXML = `<block type="${extendedOpcode}">${inputList.join('')}</block>`;
return {
info: blockInfo,
json: blockJSON,
xml: blockXML
};
}
/**
* @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in <category> elements.
*/
getBlocksXML () {
const xmlParts = [];
for (const categoryInfo of this._blockInfo) {
const {name, color1, color2} = categoryInfo;
const paletteBlocks = categoryInfo.blocks.filter(block => !block.info.hideFromPalette);
const colorXML = `colour="${color1}" secondaryColour="${color2}"`;
// Use a menu icon if there is one. Otherwise, use the block icon. If there's no icon,
// the category menu will show its default colored circle.
let menuIconURI = '';
if (categoryInfo.menuIconURI) {
menuIconURI = categoryInfo.menuIconURI;
} else if (categoryInfo.blockIconURI) {
menuIconURI = categoryInfo.blockIconURI;
}
const menuIconXML = menuIconURI ?
`iconURI="${menuIconURI}"` : '';
xmlParts.push(`<category name="${name}" ${colorXML} ${menuIconXML}>`);
xmlParts.push.apply(xmlParts, paletteBlocks.map(block => block.xml));
xmlParts.push('</category>');
}
return xmlParts.join('\n');
}
/**
* @returns {Array.<string>} - an array containing the scratch-blocks JSON information for each dynamic block.
*/
getBlocksJSON () {
return this._blockInfo.reduce(
(result, categoryInfo) => result.concat(categoryInfo.blocks.map(blockInfo => blockInfo.json)), []);
}
/** /**
* Retrieve the function associated with the given opcode. * Retrieve the function associated with the given opcode.
@ -420,17 +868,14 @@ class Runtime extends EventEmitter {
} }
/** /**
* Remove a thread from the list of threads. * Stop a thread: stop running it immediately, and remove it from the thread list later.
* @param {?Thread} thread Thread object to remove from actives * @param {!Thread} thread Thread object to remove from actives
*/ */
_removeThread (thread) { _stopThread (thread) {
// Mark the thread for later removal
thread.isKilled = true;
// Inform sequencer to stop executing that thread. // Inform sequencer to stop executing that thread.
this.sequencer.retireThread(thread); this.sequencer.retireThread(thread);
// Remove from the list.
const i = this.threads.indexOf(thread);
if (i > -1) {
this.threads.splice(i, 1);
}
} }
/** /**
@ -438,6 +883,7 @@ class Runtime extends EventEmitter {
* This is used by `startHats` to and is necessary to ensure 2.0-like execution order. * This is used by `startHats` to and is necessary to ensure 2.0-like execution order.
* Test project: https://scratch.mit.edu/projects/130183108/ * Test project: https://scratch.mit.edu/projects/130183108/
* @param {!Thread} thread Thread object to restart. * @param {!Thread} thread Thread object to restart.
* @return {Thread} The restarted thread.
*/ */
_restartThread (thread) { _restartThread (thread) {
const newThread = new Thread(thread.topBlock); const newThread = new Thread(thread.topBlock);
@ -448,9 +894,10 @@ class Runtime extends EventEmitter {
const i = this.threads.indexOf(thread); const i = this.threads.indexOf(thread);
if (i > -1) { if (i > -1) {
this.threads[i] = newThread; this.threads[i] = newThread;
} else { return newThread;
this.threads.push(thread);
} }
this.threads.push(thread);
return thread;
} }
/** /**
@ -459,7 +906,11 @@ class Runtime extends EventEmitter {
* @return {boolean} True if the thread is active/running. * @return {boolean} True if the thread is active/running.
*/ */
isActiveThread (thread) { isActiveThread (thread) {
return this.threads.indexOf(thread) > -1; return (
(
thread.stack.length > 0 &&
thread.status !== Thread.STATUS_DONE) &&
this.threads.indexOf(thread) > -1);
} }
/** /**
@ -487,7 +938,7 @@ class Runtime extends EventEmitter {
// edge activated hat thread that runs every frame // edge activated hat thread that runs every frame
continue; continue;
} }
this._removeThread(this.threads[i]); this._stopThread(this.threads[i]);
return; return;
} }
} }
@ -498,7 +949,7 @@ class Runtime extends EventEmitter {
/** /**
* Enqueue a script that when finished will update the monitor for the block. * Enqueue a script that when finished will update the monitor for the block.
* @param {!string} topBlockId ID of block that starts the script. * @param {!string} topBlockId ID of block that starts the script.
* @param {?string} optTarget target ID for target to run script on. If not supplied, uses editing target. * @param {?Target} optTarget target Target to run script on. If not supplied, uses editing target.
*/ */
addMonitorScript (topBlockId, optTarget) { addMonitorScript (topBlockId, optTarget) {
if (!optTarget) optTarget = this._editingTarget; if (!optTarget) optTarget = this._editingTarget;
@ -576,13 +1027,14 @@ class Runtime extends EventEmitter {
// If no fields are present, check inputs (horizontal blocks) // If no fields are present, check inputs (horizontal blocks)
if (Object.keys(hatFields).length === 0) { if (Object.keys(hatFields).length === 0) {
hatFields = {}; // don't overwrite the block's actual fields list
const hatInputs = blocks.getInputs(block); const hatInputs = blocks.getInputs(block);
for (const input in hatInputs) { for (const input in hatInputs) {
if (!hatInputs.hasOwnProperty(input)) continue; if (!hatInputs.hasOwnProperty(input)) continue;
const id = hatInputs[input].block; const id = hatInputs[input].block;
const inpBlock = blocks.getBlock(id); const inpBlock = blocks.getBlock(id);
const fields = blocks.getFields(inpBlock); const fields = blocks.getFields(inpBlock);
hatFields = Object.assign(fields, hatFields); Object.assign(hatFields, fields);
} }
} }
@ -605,7 +1057,7 @@ class Runtime extends EventEmitter {
if (instance.threads[i].topBlock === topBlockId && if (instance.threads[i].topBlock === topBlockId &&
!instance.threads[i].stackClick && // stack click threads and hat threads can coexist !instance.threads[i].stackClick && // stack click threads and hat threads can coexist
instance.threads[i].target === target) { instance.threads[i].target === target) {
instance._restartThread(instance.threads[i]); newThreads.push(instance._restartThread(instance.threads[i]));
return; return;
} }
} }
@ -662,8 +1114,7 @@ class Runtime extends EventEmitter {
continue; continue;
} }
if (this.threads[i].target === target) { if (this.threads[i].target === target) {
this.threads[i].isKilled = true; this._stopThread(this.threads[i]);
this._removeThread(this.threads[i]);
} }
} }
} }
@ -673,6 +1124,7 @@ class Runtime extends EventEmitter {
*/ */
greenFlag () { greenFlag () {
this.stopAll(); this.stopAll();
this.emit(Runtime.PROJECT_START);
this.ioDevices.clock.resetProjectTimer(); this.ioDevices.clock.resetProjectTimer();
this.clearEdgeActivatedValues(); this.clearEdgeActivatedValues();
// Inform all targets of the green flag. // Inform all targets of the green flag.
@ -686,6 +1138,9 @@ class Runtime extends EventEmitter {
* Stop "everything." * Stop "everything."
*/ */
stopAll () { stopAll () {
// Emit stop event to allow blocks to clean up any state.
this.emit(Runtime.PROJECT_STOP_ALL);
// Dispose all clones. // Dispose all clones.
const newTargets = []; const newTargets = [];
for (let i = 0; i < this.targets.length; i++) { for (let i = 0; i < this.targets.length; i++) {
@ -699,11 +1154,7 @@ class Runtime extends EventEmitter {
} }
this.targets = newTargets; this.targets = newTargets;
// Dispose all threads. // Dispose all threads.
const threadsCopy = this.threads.slice(); this.threads.forEach(thread => this._stopThread(thread));
while (threadsCopy.length > 0) {
const poppedThread = threadsCopy.pop();
this._removeThread(poppedThread);
}
} }
/** /**
@ -711,6 +1162,16 @@ class Runtime extends EventEmitter {
* inactive threads after each iteration. * inactive threads after each iteration.
*/ */
_step () { _step () {
if (this.profiler !== null) {
if (stepProfilerId === -1) {
stepProfilerId = this.profiler.idByName('Runtime._step');
}
this.profiler.start(stepProfilerId);
}
// Clean up threads that were told to stop during or since the last step
this.threads = this.threads.filter(thread => !thread.isKilled);
// Find all edge-activated hats, and add them to threads to be evaluated. // Find all edge-activated hats, and add them to threads to be evaluated.
for (const hatType in this._hats) { for (const hatType in this._hats) {
if (!this._hats.hasOwnProperty(hatType)) continue; if (!this._hats.hasOwnProperty(hatType)) continue;
@ -721,7 +1182,16 @@ class Runtime extends EventEmitter {
} }
this.redrawRequested = false; this.redrawRequested = false;
this._pushMonitors(); this._pushMonitors();
if (this.profiler !== null) {
if (stepThreadsProfilerId === -1) {
stepThreadsProfilerId = this.profiler.idByName('Sequencer.stepThreads');
}
this.profiler.start(stepThreadsProfilerId);
}
const doneThreads = this.sequencer.stepThreads(); const doneThreads = this.sequencer.stepThreads();
if (this.profiler !== null) {
this.profiler.stop();
}
this._updateGlows(doneThreads); this._updateGlows(doneThreads);
// Add done threads so that even if a thread finishes within 1 frame, the green // Add done threads so that even if a thread finishes within 1 frame, the green
// flag will still indicate that a script ran. // flag will still indicate that a script ran.
@ -730,7 +1200,16 @@ class Runtime extends EventEmitter {
this._getMonitorThreadCount([...this.threads, ...doneThreads])); this._getMonitorThreadCount([...this.threads, ...doneThreads]));
if (this.renderer) { if (this.renderer) {
// @todo: Only render when this.redrawRequested or clones rendered. // @todo: Only render when this.redrawRequested or clones rendered.
if (this.profiler !== null) {
if (rendererDrawProfilerId === -1) {
rendererDrawProfilerId = this.profiler.idByName('RenderWebGL.draw');
}
this.profiler.start(rendererDrawProfilerId);
}
this.renderer.draw(); this.renderer.draw();
if (this.profiler !== null) {
this.profiler.stop();
}
} }
if (this._refreshTargets) { if (this._refreshTargets) {
@ -742,6 +1221,11 @@ class Runtime extends EventEmitter {
this.emit(Runtime.MONITORS_UPDATE, this._monitorState); this.emit(Runtime.MONITORS_UPDATE, this._monitorState);
this._prevMonitorState = this._monitorState; this._prevMonitorState = this._monitorState;
} }
if (this.profiler !== null) {
this.profiler.stop();
this.profiler.reportFrames();
}
} }
/** /**
@ -916,7 +1400,7 @@ class Runtime extends EventEmitter {
* @param {!MonitorRecord} monitor Monitor to add. * @param {!MonitorRecord} monitor Monitor to add.
*/ */
requestAddMonitor (monitor) { requestAddMonitor (monitor) {
this._monitorState = this._monitorState.set(monitor.id, monitor); this._monitorState = this._monitorState.set(monitor.get('id'), monitor);
} }
/** /**
@ -927,9 +1411,10 @@ class Runtime extends EventEmitter {
* the old monitor will keep its old value. * the old monitor will keep its old value.
*/ */
requestUpdateMonitor (monitor) { requestUpdateMonitor (monitor) {
if (this._monitorState.has(monitor.get('id'))) { const id = monitor.get('id');
if (this._monitorState.has(id)) {
this._monitorState = this._monitorState =
this._monitorState.set(monitor.get('id'), this._monitorState.get(monitor.get('id')).merge(monitor)); this._monitorState.set(id, this._monitorState.get(id).merge(monitor));
} }
} }
@ -942,6 +1427,15 @@ class Runtime extends EventEmitter {
this._monitorState = this._monitorState.delete(monitorId); this._monitorState = this._monitorState.delete(monitorId);
} }
/**
* Removes all monitors with the given target ID from the state. Does nothing if
* the monitor already does not exist in the state.
* @param {!string} targetId Remove all monitors with given target ID.
*/
requestRemoveMonitorByTargetId (targetId) {
this._monitorState = this._monitorState.filterNot(value => value.targetId === targetId);
}
/** /**
* Get a target by its id. * Get a target by its id.
* @param {string} targetId Id of target to find. * @param {string} targetId Id of target to find.
@ -1008,6 +1502,15 @@ class Runtime extends EventEmitter {
this.emit('targetWasCreated', newTarget, sourceTarget); this.emit('targetWasCreated', newTarget, sourceTarget);
} }
/**
* Report that a clone target is being removed.
* @param {Target} target - the target being removed
* @fires Runtime#targetWasRemoved
*/
fireTargetWasRemoved (target) {
this.emit('targetWasRemoved', target);
}
/** /**
* Get a target representing the Scratch stage, if one exists. * Get a target representing the Scratch stage, if one exists.
* @return {?Target} The target, if found. * @return {?Target} The target, if found.
@ -1060,6 +1563,24 @@ class Runtime extends EventEmitter {
this._step(); this._step();
}, interval); }, interval);
} }
/**
* Turn on profiling.
* @param {Profiler/FrameCallback} onFrame A callback handle passed a
* profiling frame when the profiler reports its collected data.
*/
enableProfiling (onFrame) {
if (Profiler.available()) {
this.profiler = new Profiler(onFrame);
}
}
/**
* Turn off profiling.
*/
disableProfiling () {
this.profiler = null;
}
} }
/** /**

View file

@ -2,6 +2,42 @@ const Timer = require('../util/timer');
const Thread = require('./thread'); const Thread = require('./thread');
const execute = require('./execute.js'); const execute = require('./execute.js');
/**
* Profiler frame name for stepping a single thread.
* @const {string}
*/
const stepThreadProfilerFrame = 'Sequencer.stepThread';
/**
* Profiler frame name for the inner loop of stepThreads.
* @const {string}
*/
const stepThreadsInnerProfilerFrame = 'Sequencer.stepThreads#inner';
/**
* Profiler frame name for execute.
* @const {string}
*/
const executeProfilerFrame = 'execute';
/**
* Profiler frame ID for stepThreadProfilerFrame.
* @type {number}
*/
let stepThreadProfilerId = -1;
/**
* Profiler frame ID for stepThreadsInnerProfilerFrame.
* @type {number}
*/
let stepThreadsInnerProfilerId = -1;
/**
* Profiler frame ID for executeProfilerFrame.
* @type {number}
*/
let executeProfilerId = -1;
class Sequencer { class Sequencer {
constructor (runtime) { constructor (runtime) {
/** /**
@ -38,7 +74,7 @@ class Sequencer {
let numActiveThreads = Infinity; let numActiveThreads = Infinity;
// Whether `stepThreads` has run through a full single tick. // Whether `stepThreads` has run through a full single tick.
let ranFirstTick = false; let ranFirstTick = false;
const doneThreads = []; const doneThreads = this.runtime.threads.map(() => null);
// Conditions for continuing to stepping threads: // Conditions for continuing to stepping threads:
// 1. We must have threads in the list, and some must be active. // 1. We must have threads in the list, and some must be active.
// 2. Time elapsed must be less than WORK_TIME. // 2. Time elapsed must be less than WORK_TIME.
@ -47,6 +83,13 @@ class Sequencer {
numActiveThreads > 0 && numActiveThreads > 0 &&
this.timer.timeElapsed() < WORK_TIME && this.timer.timeElapsed() < WORK_TIME &&
(this.runtime.turboMode || !this.runtime.redrawRequested)) { (this.runtime.turboMode || !this.runtime.redrawRequested)) {
if (this.runtime.profiler !== null) {
if (stepThreadsInnerProfilerId === -1) {
stepThreadsInnerProfilerId = this.runtime.profiler.idByName(stepThreadsInnerProfilerFrame);
}
this.runtime.profiler.start(stepThreadsInnerProfilerId);
}
numActiveThreads = 0; numActiveThreads = 0;
// Attempt to run each thread one time. // Attempt to run each thread one time.
for (let i = 0; i < this.runtime.threads.length; i++) { for (let i = 0; i < this.runtime.threads.length; i++) {
@ -54,11 +97,13 @@ class Sequencer {
if (activeThread.stack.length === 0 || if (activeThread.stack.length === 0 ||
activeThread.status === Thread.STATUS_DONE) { activeThread.status === Thread.STATUS_DONE) {
// Finished with this thread. // Finished with this thread.
if (doneThreads.indexOf(activeThread) < 0) { doneThreads[i] = activeThread;
doneThreads.push(activeThread);
}
continue; continue;
} }
// A thread was removed, added or this thread was restarted.
if (doneThreads[i] !== null) {
doneThreads[i] = null;
}
if (activeThread.status === Thread.STATUS_YIELD_TICK && if (activeThread.status === Thread.STATUS_YIELD_TICK &&
!ranFirstTick) { !ranFirstTick) {
// Clear single-tick yield from the last call of `stepThreads`. // Clear single-tick yield from the last call of `stepThreads`.
@ -67,7 +112,16 @@ class Sequencer {
if (activeThread.status === Thread.STATUS_RUNNING || if (activeThread.status === Thread.STATUS_RUNNING ||
activeThread.status === Thread.STATUS_YIELD) { activeThread.status === Thread.STATUS_YIELD) {
// Normal-mode thread: step. // Normal-mode thread: step.
if (this.runtime.profiler !== null) {
if (stepThreadProfilerId === -1) {
stepThreadProfilerId = this.runtime.profiler.idByName(stepThreadProfilerFrame);
}
this.runtime.profiler.start(stepThreadProfilerId);
}
this.stepThread(activeThread); this.stepThread(activeThread);
if (this.runtime.profiler !== null) {
this.runtime.profiler.stop();
}
activeThread.warpTimer = null; activeThread.warpTimer = null;
if (activeThread.isKilled) { if (activeThread.isKilled) {
i--; // if the thread is removed from the list (killed), do not increase index i--; // if the thread is removed from the list (killed), do not increase index
@ -80,14 +134,33 @@ class Sequencer {
// We successfully ticked once. Prevents running STATUS_YIELD_TICK // We successfully ticked once. Prevents running STATUS_YIELD_TICK
// threads on the next tick. // threads on the next tick.
ranFirstTick = true; ranFirstTick = true;
if (this.runtime.profiler !== null) {
this.runtime.profiler.stop();
}
} }
// Filter inactive threads from `this.runtime.threads`. // Filter inactive threads from `this.runtime.threads`.
this.runtime.threads = this.runtime.threads.filter(thread => { numActiveThreads = 0;
if (doneThreads.indexOf(thread) > -1) { for (let i = 0; i < this.runtime.threads.length; i++) {
return false; const thread = this.runtime.threads[i];
if (doneThreads[i] === null) {
this.runtime.threads[numActiveThreads] = thread;
numActiveThreads++;
} }
return true; }
}); this.runtime.threads.length = numActiveThreads;
// Filter undefined and null values from `doneThreads`.
let numDoneThreads = 0;
for (let i = 0; i < doneThreads.length; i++) {
const maybeThread = doneThreads[i];
if (maybeThread !== null) {
doneThreads[numDoneThreads] = maybeThread;
numDoneThreads++;
}
}
doneThreads.length = numDoneThreads;
return doneThreads; return doneThreads;
} }
@ -112,7 +185,23 @@ class Sequencer {
// Execute the current block. // Execute the current block.
// Save the current block ID to notice if we did control flow. // Save the current block ID to notice if we did control flow.
currentBlockId = thread.peekStack(); currentBlockId = thread.peekStack();
if (this.runtime.profiler !== null) {
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, performance.now());
}
execute(this, thread); execute(this, thread);
if (this.runtime.profiler !== null) {
// this.runtime.profiler.stop();
this.runtime.profiler.records.push(this.runtime.profiler.STOP, performance.now());
}
thread.blockGlowInFrame = currentBlockId; thread.blockGlowInFrame = currentBlockId;
// If the thread has yielded or is waiting, yield to other threads. // If the thread has yielded or is waiting, yield to other threads.
if (thread.status === Thread.STATUS_YIELD) { if (thread.status === Thread.STATUS_YIELD) {
@ -225,7 +314,17 @@ class Sequencer {
// Look for warp-mode flag on definition, and set the thread // Look for warp-mode flag on definition, and set the thread
// to warp-mode if needed. // to warp-mode if needed.
const definitionBlock = thread.target.blocks.getBlock(definition); const definitionBlock = thread.target.blocks.getBlock(definition);
const doWarp = definitionBlock.mutation.warp; const innerBlock = thread.target.blocks.getBlock(
definitionBlock.inputs.custom_block.block);
let doWarp = false;
if (innerBlock && innerBlock.mutation) {
const warp = innerBlock.mutation.warp;
if (typeof warp === 'boolean') {
doWarp = warp;
} else if (typeof warp === 'string') {
doWarp = JSON.parse(warp);
}
}
if (doWarp) { if (doWarp) {
thread.peekStackFrame().warpMode = true; thread.peekStackFrame().warpMode = true;
} else if (isRecursive) { } else if (isRecursive) {

View file

@ -2,9 +2,9 @@ const EventEmitter = require('events');
const Blocks = require('./blocks'); const Blocks = require('./blocks');
const Variable = require('../engine/variable'); const Variable = require('../engine/variable');
const List = require('../engine/list');
const uid = require('../util/uid'); const uid = require('../util/uid');
const {Map} = require('immutable'); const {Map} = require('immutable');
const log = require('../util/log');
/** /**
* @fileoverview * @fileoverview
@ -88,11 +88,57 @@ class Target extends EventEmitter {
const variable = this.lookupVariableById(id); const variable = this.lookupVariableById(id);
if (variable) return variable; if (variable) return variable;
// No variable with this name exists - create it locally. // No variable with this name exists - create it locally.
const newVariable = new Variable(id, name, 0, false); const newVariable = new Variable(id, name, Variable.SCALAR_TYPE, false);
this.variables[id] = newVariable; this.variables[id] = newVariable;
return newVariable; return newVariable;
} }
/**
* Look up a broadcast message object with the given id and return it
* if it exists.
* @param {string} id Id of the variable.
* @param {string} name Name of the variable.
* @return {?Variable} Variable object.
*/
lookupBroadcastMsg (id, name) {
let broadcastMsg;
if (id) {
broadcastMsg = this.lookupVariableById(id);
} else if (name) {
broadcastMsg = this.lookupBroadcastByInputValue(name);
} else {
log.error('Cannot find broadcast message if neither id nor name are provided.');
}
if (broadcastMsg) {
if (name && (broadcastMsg.name.toLowerCase() !== name.toLowerCase())) {
log.error(`Found broadcast message with id: ${id}, but` +
`its name, ${broadcastMsg.name} did not match expected name ${name}.`);
}
if (broadcastMsg.type !== Variable.BROADCAST_MESSAGE_TYPE) {
log.error(`Found variable with id: ${id}, but its type ${broadcastMsg.type}` +
`did not match expected type ${Variable.BROADCAST_MESSAGE_TYPE}`);
}
return broadcastMsg;
}
}
/**
* Look up a broadcast message with the given name and return the variable
* if it exists. Does not create a new broadcast message variable if
* it doesn't exist.
* @param {string} name Name of the variable.
* @return {?Variable} Variable object.
*/
lookupBroadcastByInputValue (name) {
const vars = this.variables;
for (const propName in vars) {
if ((vars[propName].type === Variable.BROADCAST_MESSAGE_TYPE) &&
(vars[propName].name.toLowerCase() === name.toLowerCase())) {
return vars[propName];
}
}
}
/** /**
* Look up a variable object. * Look up a variable object.
* Search begins for local variables; then look for globals. * Search begins for local variables; then look for globals.
@ -117,24 +163,16 @@ class Target extends EventEmitter {
/** /**
* Look up a list object for this target, and create it if one doesn't exist. * Look up a list object for this target, and create it if one doesn't exist.
* Search begins for local lists; then look for globals. * Search begins for local lists; then look for globals.
* @param {!string} id Id of the list.
* @param {!string} name Name of the list. * @param {!string} name Name of the list.
* @return {!List} List object. * @return {!Varible} Variable object representing the found/created list.
*/ */
lookupOrCreateList (name) { lookupOrCreateList (id, name) {
// If we have a local copy, return it. const list = this.lookupVariableById(id);
if (this.lists.hasOwnProperty(name)) { if (list) return list;
return this.lists[name]; // No variable with this name exists - create it locally.
} const newList = new Variable(id, name, Variable.LIST_TYPE, false);
// If the stage has a global copy, return it. this.variables[id] = newList;
if (this.runtime && !this.isStage) {
const stage = this.runtime.getTargetForStage();
if (stage.lists.hasOwnProperty(name)) {
return stage.lists[name];
}
}
// No list with this name exists - create it locally.
const newList = new List(name, []);
this.lists[name] = newList;
return newList; return newList;
} }
@ -143,11 +181,11 @@ class Target extends EventEmitter {
* dictionary of variables. * dictionary of variables.
* @param {string} id Id of variable * @param {string} id Id of variable
* @param {string} name Name of variable. * @param {string} name Name of variable.
* @param {string} type Type of variable, '', 'broadcast_msg', or 'list'
*/ */
createVariable (id, name) { createVariable (id, name, type) {
if (!this.variables.hasOwnProperty(id)) { if (!this.variables.hasOwnProperty(id)) {
const newVariable = new Variable(id, name, 0, const newVariable = new Variable(id, name, type, false);
false);
this.variables[id] = newVariable; this.variables[id] = newVariable;
} }
} }
@ -189,6 +227,7 @@ class Target extends EventEmitter {
if (this.variables.hasOwnProperty(id)) { if (this.variables.hasOwnProperty(id)) {
delete this.variables[id]; delete this.variables[id];
if (this.runtime) { if (this.runtime) {
this.runtime.monitorBlocks.deleteBlock(id);
this.runtime.requestRemoveMonitor(id); this.runtime.requestRemoveMonitor(id);
} }
} }

View file

@ -164,7 +164,7 @@ class Thread {
let blockID = this.peekStack(); let blockID = this.peekStack();
while (blockID !== null) { while (blockID !== null) {
const block = this.target.blocks.getBlock(blockID); const block = this.target.blocks.getBlock(blockID);
if (typeof block !== 'undefined' && block.opcode === 'procedures_callnoreturn') { if (typeof block !== 'undefined' && block.opcode === 'procedures_call') {
break; break;
} }
this.popStack(); this.popStack();
@ -271,7 +271,7 @@ class Thread {
const sp = this.stack.length - 1; const sp = this.stack.length - 1;
for (let i = sp - 1; i >= 0; i--) { for (let i = sp - 1; i >= 0; i--) {
const block = this.target.blocks.getBlock(this.stack[i]); const block = this.target.blocks.getBlock(this.stack[i]);
if (block.opcode === 'procedures_callnoreturn' && if (block.opcode === 'procedures_call' &&
block.mutation.proccode === procedureCode) { block.mutation.proccode === procedureCode) {
return true; return true;
} }

View file

@ -9,19 +9,58 @@ class Variable {
/** /**
* @param {string} id Id of the variable. * @param {string} id Id of the variable.
* @param {string} name Name of the variable. * @param {string} name Name of the variable.
* @param {(string|number)} value Value of the variable. * @param {string} type Type of the variable, one of '' or 'list'
* @param {boolean} isCloud Whether the variable is stored in the cloud. * @param {boolean} isCloud Whether the variable is stored in the cloud.
* @constructor * @constructor
*/ */
constructor (id, name, value, isCloud) { constructor (id, name, type, isCloud) {
this.id = id || uid(); this.id = id || uid();
this.name = name; this.name = name;
this.value = value; this.type = type;
this.isCloud = isCloud; this.isCloud = isCloud;
switch (this.type) {
case Variable.SCALAR_TYPE:
this.value = 0;
break;
case Variable.LIST_TYPE:
this.value = [];
break;
case Variable.BROADCAST_MESSAGE_TYPE:
this.value = this.name;
break;
default:
throw new Error(`Invalid variable type: ${this.type}`);
}
} }
toXML () { toXML () {
return `<variable type="" id="${this.id}">${this.name}</variable>`; return `<variable type="${this.type}" id="${this.id}">${this.name}</variable>`;
}
/**
* Type representation for scalar variables.
* This is currently represented as ''
* for compatibility with blockly.
* @const {string}
*/
static get SCALAR_TYPE () {
return '';
}
/**
* Type representation for list variables.
* @const {string}
*/
static get LIST_TYPE () {
return 'list';
}
/**
* Type representation for list variables.
* @const {string}
*/
static get BROADCAST_MESSAGE_TYPE () {
return 'broadcast_msg';
} }
} }

View file

@ -0,0 +1,9 @@
const ArgumentType = {
ANGLE: 'angle',
BOOLEAN: 'Boolean',
COLOR: 'color',
NUMBER: 'number',
STRING: 'string'
};
module.exports = ArgumentType;

View file

@ -0,0 +1,9 @@
const BlockType = {
BOOLEAN: 'Boolean',
COMMAND: 'command',
CONDITIONAL: 'conditional',
HAT: 'hat',
REPORTER: 'reporter'
};
module.exports = BlockType;

View file

@ -0,0 +1,280 @@
const dispatch = require('../dispatch/central-dispatch');
const log = require('../util/log');
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 builtinExtensions = {
pen: Scratch3PenBlocks,
wedo2: Scratch3WeDo2Blocks,
music: Scratch3MusicBlocks
};
/**
* @typedef {object} ArgumentInfo - Information about an extension block argument
* @property {ArgumentType} type - the type of value this argument can take
* @property {*|undefined} default - the default value of this argument (default: blank)
*/
/**
* @typedef {object} BlockInfo - Information about an extension block
* @property {string} opcode - the block opcode
* @property {string|object} text - the human-readable text on this block
* @property {BlockType|undefined} blockType - the type of block (default: BlockType.COMMAND)
* @property {int|undefined} branchCount - the number of branches this block controls, if conditional (default: 0)
* @property {Boolean|undefined} isTerminal - true if this block ends a stack (default: false)
* @property {Boolean|undefined} blockAllThreads - true if all threads must wait for this block to run (default: false)
* @property {object.<string,ArgumentInfo>|undefined} arguments - information about this block's arguments, if any
* @property {string|Function|undefined} func - the method for this block on the extension service (default: opcode)
* @property {Array.<string>|undefined} filter - the list of targets for which this block should appear (default: all)
* @property {Boolean|undefined} hideFromPalette - true if should not be appear in the palette. (default false)
*/
/**
* @typedef {object} CategoryInfo - Information about a block category
* @property {string} id - the unique ID of this category
* @property {string} color1 - the primary color for this category, in '#rrggbb' format
* @property {string} color2 - the secondary color for this category, in '#rrggbb' format
* @property {string} color3 - the tertiary color for this category, in '#rrggbb' format
* @property {Array.<BlockInfo>} block - the blocks in this category
*/
/**
* @typedef {object} PendingExtensionWorker - Information about an extension worker still initializing
* @property {string} extensionURL - the URL of the extension to be loaded by this worker
* @property {Function} resolve - function to call on successful worker startup
* @property {Function} reject - function to call on failed worker startup
*/
class ExtensionManager {
constructor (runtime) {
/**
* The ID number to provide to the next extension worker.
* @type {int}
*/
this.nextExtensionWorker = 0;
/**
* FIFO queue of extensions which have been requested but not yet loaded in a worker,
* along with promise resolution functions to call once the worker is ready or failed.
*
* @type {Array.<PendingExtensionWorker>}
*/
this.pendingExtensions = [];
/**
* Map of worker ID to workers which have been allocated but have not yet finished initialization.
* @type {Array.<PendingExtensionWorker>}
*/
this.pendingWorkers = [];
/**
* Set of loaded extension URLs/IDs (equivalent for built-in extensions).
* @type {Set.<string>}
* @private
*/
this._loadedExtensions = new Map();
/**
* Keep a reference to the runtime so we can construct internal extension objects.
* TODO: remove this in favor of extensions accessing the runtime as a service.
* @type {Runtime}
*/
this.runtime = runtime;
dispatch.setService('extensions', this).catch(e => {
log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`);
});
}
/**
* Check whether an extension is registered or is in the process of loading. This is intended to control loading or
* adding extensions so it may return `true` before the extension is ready to be used. Use the promise returned by
* `loadExtensionURL` if you need to wait until the extension is truly ready.
* @param {string} extensionID - the ID of the extension.
* @returns {boolean} - true if loaded, false otherwise.
*/
isExtensionLoaded (extensionID) {
return this._loadedExtensions.has(extensionID);
}
/**
* 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
* @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure
*/
loadExtensionURL (extensionURL) {
if (builtinExtensions.hasOwnProperty(extensionURL)) {
/** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */
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));
}
const extension = builtinExtensions[extensionURL];
const extensionInstance = new extension(this.runtime);
return this._registerInternalExtension(extensionInstance).then(serviceName => {
this._loadedExtensions.set(extensionURL, serviceName);
});
}
return new Promise((resolve, reject) => {
// If we `require` this at the global level it breaks non-webpack targets, including tests
const ExtensionWorker = require('worker-loader?name=extension-worker.js!./extension-worker');
this.pendingExtensions.push({extensionURL, resolve, reject});
dispatch.addWorker(new ExtensionWorker());
});
}
/**
* regenerate blockinfo for any loaded extensions
*/
refreshBlocks () {
this._loadedExtensions.forEach(serviceName => {
dispatch.call(serviceName, 'getInfo')
.then(info => {
dispatch.call('runtime', '_refreshExtensionPrimitives', info);
})
.catch(e => {
log.error(`Failed to refresh buildtin extension primitives: ${JSON.stringify(e)}`);
});
});
}
allocateWorker () {
const id = this.nextExtensionWorker++;
const workerInfo = this.pendingExtensions.shift();
this.pendingWorkers[id] = workerInfo;
return [id, workerInfo.extensionURL];
}
/**
* Collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension.
*/
registerExtensionService (serviceName) {
dispatch.call(serviceName, 'getInfo').then(info => {
this._registerExtensionInfo(serviceName, info);
});
}
/**
* Called by an extension worker to indicate that the worker has finished initialization.
* @param {int} id - the worker ID.
* @param {*?} e - the error encountered during initialization, if any.
*/
onWorkerInit (id, e) {
const workerInfo = this.pendingWorkers[id];
delete this.pendingWorkers[id];
if (e) {
workerInfo.reject(e);
} else {
workerInfo.resolve(id);
}
}
/**
* 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
*/
_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;
});
}
/**
* Sanitize extension info then register its primitives with the VM.
* @param {string} serviceName - the name of the service hosting the extension
* @param {ExtensionInfo} extensionInfo - the extension's metadata
* @private
*/
_registerExtensionInfo (serviceName, extensionInfo) {
extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo);
dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => {
log.error(`Failed to register primitives for extension on service ${serviceName}: ${JSON.stringify(e)}`);
});
}
/**
* Modify the provided text as necessary to ensure that it may be used as an attribute value in valid XML.
* @param {string} text - the text to be sanitized
* @returns {string} - the sanitized text
* @private
*/
_sanitizeID (text) {
return text.toString().replace(/[<"&]/, '_');
}
/**
* Apply minor cleanup and defaults for optional extension fields.
* TODO: make the ID unique in cases where two copies of the same extension are loaded.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {ExtensionInfo} extensionInfo - the extension info to be sanitized
* @returns {ExtensionInfo} - a new extension info object with cleaned-up values
* @private
*/
_prepareExtensionInfo (serviceName, extensionInfo) {
extensionInfo = Object.assign({}, extensionInfo);
extensionInfo.id = this._sanitizeID(extensionInfo.id);
extensionInfo.name = extensionInfo.name || extensionInfo.id;
extensionInfo.blocks = extensionInfo.blocks || [];
extensionInfo.targetTypes = extensionInfo.targetTypes || [];
extensionInfo.blocks = extensionInfo.blocks.reduce((result, blockInfo) => {
try {
result.push(this._prepareBlockInfo(serviceName, blockInfo));
} catch (e) {
// TODO: more meaningful error reporting
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`);
}
return result;
}, []);
return extensionInfo;
}
/**
* Apply defaults for optional block fields.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {BlockInfo} blockInfo - the block info from the extension
* @returns {BlockInfo} - a new block info object which has values for all relevant optional fields.
* @private
*/
_prepareBlockInfo (serviceName, blockInfo) {
blockInfo = Object.assign({}, {
blockType: BlockType.COMMAND,
terminal: false,
blockAllThreads: false,
arguments: {}
}, blockInfo);
blockInfo.opcode = this._sanitizeID(blockInfo.opcode);
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
blockInfo.text = blockInfo.text || 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 frame.
*/
if (dispatch._isRemoteService(serviceName)) {
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
} else {
const serviceObject = dispatch.services[serviceName];
blockInfo.func = serviceObject[blockInfo.func].bind(serviceObject);
}
return blockInfo;
}
}
module.exports = ExtensionManager;

View file

@ -0,0 +1,57 @@
/* eslint-env worker */
const ArgumentType = require('../extension-support/argument-type');
const BlockType = require('../extension-support/block-type');
const dispatch = require('../dispatch/worker-dispatch');
class ExtensionWorker {
constructor () {
this.nextExtensionId = 0;
this.initialRegistrations = [];
dispatch.waitForConnection.then(() => {
dispatch.call('extensions', 'allocateWorker').then(x => {
const [id, extension] = x;
this.workerId = id;
try {
importScripts(extension);
const initialRegistrations = this.initialRegistrations;
this.initialRegistrations = null;
Promise.all(initialRegistrations).then(() => dispatch.call('extensions', 'onWorkerInit', id));
} catch (e) {
dispatch.call('extensions', 'onWorkerInit', id, e);
}
});
});
this.extensions = [];
}
register (extensionObject) {
const extensionId = this.nextExtensionId++;
this.extensions.push(extensionObject);
const serviceName = `extension.${this.workerId}.${extensionId}`;
const promise = dispatch.setService(serviceName, extensionObject)
.then(() => dispatch.call('extensions', 'registerExtensionService', serviceName));
if (this.initialRegistrations) {
this.initialRegistrations.push(promise);
}
return promise;
}
}
global.Scratch = global.Scratch || {};
global.Scratch.ArgumentType = ArgumentType;
global.Scratch.BlockType = BlockType;
/**
* Expose only specific parts of the worker to extensions.
*/
const extensionWorker = new ExtensionWorker();
global.Scratch.extensions = {
register: extensionWorker.register.bind(extensionWorker)
};

Binary file not shown.

View file

@ -0,0 +1,813 @@
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const Clone = require('../../util/clone');
const Cast = require('../../util/cast');
const MathUtil = require('../../util/math-util');
const Timer = require('../../util/timer');
/**
* Icon svg to be displayed at the left edge of each extension block, encoded as a data URI.
* @type {string}
*/
// eslint-disable-next-line max-len
const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPm11c2ljLWJsb2NrLWljb248L3RpdGxlPjxkZWZzPjxwYXRoIGQ9Ik0zMi4xOCAyNS44NzRDMzIuNjM2IDI4LjE1NyAzMC41MTIgMzAgMjcuNDMzIDMwYy0zLjA3IDAtNS45MjMtMS44NDMtNi4zNzItNC4xMjYtLjQ1OC0yLjI4NSAxLjY2NS00LjEzNiA0Ljc0My00LjEzNi42NDcgMCAxLjI4My4wODQgMS44OS4yMzQuMzM4LjA4Ni42MzcuMTguOTM4LjMwMi44Ny0uMDItLjEwNC0yLjI5NC0xLjgzNS0xMi4yMy0yLjEzNC0xMi4zMDIgMy4wNi0xLjg3IDguNzY4LTIuNzUyIDUuNzA4LS44ODUuMDc2IDQuODItMy42NSAzLjg0NC0zLjcyNC0uOTg3LTQuNjUtNy4xNTMuMjYzIDE0LjczOHptLTE2Ljk5OCA1Ljk5QzE1LjYzIDM0LjE0OCAxMy41MDcgMzYgMTAuNDQgMzZjLTMuMDcgMC01LjkyMi0xLjg1Mi02LjM4LTQuMTM2LS40NDgtMi4yODQgMS42NzQtNC4xMzUgNC43NS00LjEzNSAxLjAwMyAwIDEuOTc1LjE5NiAyLjg1NS41NDMuODIyLS4wNTUtLjE1LTIuMzc3LTEuODYyLTEyLjIyOC0yLjEzMy0xMi4zMDMgMy4wNi0xLjg3IDguNzY0LTIuNzUzIDUuNzA2LS44OTQuMDc2IDQuODItMy42NDggMy44MzQtMy43MjQtLjk4Ny00LjY1LTcuMTUyLjI2MiAxNC43Mzh6IiBpZD0iYSIvPjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjx1c2UgZmlsbD0iI0ZGRiIgeGxpbms6aHJlZj0iI2EiLz48cGF0aCBzdHJva2Utb3BhY2l0eT0iLjEiIHN0cm9rZT0iIzAwMCIgZD0iTTI4LjQ1NiAyMS42NzVjLS4wMS0uMzEyLS4wODctLjgyNS0uMjU2LTEuNzAyLS4wOTYtLjQ5NS0uNjEyLTMuMDIyLS43NTMtMy43My0uMzk1LTEuOTgtLjc2LTMuOTItMS4xNDItNi4xMTMtLjczMi00LjIyMy0uNjkzLTYuMDUuMzQ0LTYuNTI3LjUtLjIzIDEuMDYtLjA4IDEuODQuMzUuNDE0LjIyNyAyLjE4MiAxLjM2NSAyLjA3IDEuMjk2IDEuOTk0IDEuMjQyIDMuNDY0IDEuNzc0IDQuOTMgMS41NDggMS41MjYtLjIzNyAyLjUwNC0uMDYgMi44NzYuNjE4LjM0OC42MzUuMDE1IDEuNDE2LS43MyAyLjE4LTEuNDcyIDEuNTE2LTMuOTc1IDIuNTE0LTUuODQ4IDIuMDIzLS44MjItLjIyLTEuMjM4LS40NjUtMi4zOC0xLjI2N2wtLjA5NS0uMDY2Yy4wNDcuNTkzLjI2NCAxLjc0LjcxNyAzLjgwMy4yOTQgMS4zMzYgMi4wOCA5LjE4NyAyLjYzNyAxMS42NzRsLjAwMi4wMTJjLjUyOCAyLjYzNy0xLjg3MyA0LjcyNC01LjIzNiA0LjcyNC0zLjI5IDAtNi4zNjMtMS45ODgtNi44NjItNC41MjgtLjUzLTIuNjQgMS44NzMtNC43MzQgNS4yMzMtNC43MzQuNjcyIDAgMS4zNDcuMDg1IDIuMDE0LjI1LjIyNy4wNTcuNDM2LjExOC42MzYuMTg3em0tMTYuOTk2IDUuOTljLS4wMS0uMzE4LS4wOS0uODM4LS4yNjYtMS43MzctLjA5LS40Ni0uNTk1LTIuOTM3LS43NTMtMy43MjctLjM5LTEuOTYtLjc1LTMuODktMS4xMy02LjA3LS43MzItNC4yMjMtLjY5Mi02LjA1LjM0NC02LjUyNi41MDItLjIzIDEuMDYtLjA4MiAxLjg0LjM1LjQxNS4yMjcgMi4xODIgMS4zNjQgMi4wNyAxLjI5NSAxLjk5MyAxLjI0MiAzLjQ2MiAxLjc3NCA0LjkyNiAxLjU0OCAxLjUyNS0uMjQgMi41MDQtLjA2NCAyLjg3Ni42MTQuMzQ4LjYzNS4wMTUgMS40MTUtLjcyOCAyLjE4LTEuNDc0IDEuNTE3LTMuOTc3IDIuNTEzLTUuODQ3IDIuMDE3LS44Mi0uMjItMS4yMzYtLjQ2NC0yLjM3OC0xLjI2N2wtLjA5NS0uMDY1Yy4wNDcuNTkzLjI2NCAxLjc0LjcxNyAzLjgwMi4yOTQgMS4zMzcgMi4wNzggOS4xOSAyLjYzNiAxMS42NzVsLjAwMy4wMTNjLjUxNyAyLjYzOC0xLjg4NCA0LjczMi01LjIzNCA0LjczMi0zLjI4NyAwLTYuMzYtMS45OTMtNi44Ny00LjU0LS41Mi0yLjY0IDEuODg0LTQuNzMgNS4yNC00LjczLjkwNSAwIDEuODAzLjE1IDIuNjUuNDM2eiIvPjwvZz48L3N2Zz4=';
/**
* Icon svg to be displayed in the category menu, encoded as a data URI.
* @type {string}
*/
// eslint-disable-next-line max-len
const menuIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2LjA5IDEyLjkzN2MuMjI4IDEuMTQxLS44MzMgMi4wNjMtMi4zNzMgMi4wNjMtMS41MzUgMC0yLjk2Mi0uOTIyLTMuMTg2LTIuMDYzLS4yMy0xLjE0Mi44MzMtMi4wNjggMi4zNzItMi4wNjguMzIzIDAgLjY0MS4wNDIuOTQ1LjExN2EzLjUgMy41IDAgMCAxIC40NjguMTUxYy40MzUtLjAxLS4wNTItMS4xNDctLjkxNy02LjExNC0xLjA2Ny02LjE1MiAxLjUzLS45MzUgNC4zODQtMS4zNzcgMi44NTQtLjQ0Mi4wMzggMi40MS0xLjgyNSAxLjkyMi0xLjg2Mi0uNDkzLTIuMzI1LTMuNTc3LjEzMiA3LjM3ek03LjQ2IDguNTYzYy0xLjg2Mi0uNDkzLTIuMzI1LTMuNTc2LjEzIDcuMzdDNy44MTYgMTcuMDczIDYuNzU0IDE4IDUuMjIgMThjLTEuNTM1IDAtMi45NjEtLjkyNi0zLjE5LTIuMDY4LS4yMjQtMS4xNDIuODM3LTIuMDY3IDIuMzc1LTIuMDY3LjUwMSAwIC45ODcuMDk4IDEuNDI3LjI3Mi40MTItLjAyOC0uMDc0LTEuMTg5LS45My02LjExNEMzLjgzNCAxLjg3IDYuNDMgNy4wODcgOS4yODIgNi42NDZjMi44NTQtLjQ0Ny4wMzggMi40MS0xLjgyMyAxLjkxN3oiIGZpbGw9IiM1NzVFNzUiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==';
/**
* Class for the music-related blocks in Scratch 3.0
* @param {Runtime} runtime - the runtime instantiating this block package.
* @constructor
*/
class Scratch3MusicBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* The current tempo in beats per minute. The tempo is a global property of the project,
* not a property of each sprite, so it is not stored in the MusicState object.
* @type {number}
*/
this.tempo = 60;
/**
* The number of drum and instrument sounds currently being played simultaneously.
* @type {number}
* @private
*/
this._concurrencyCounter = 0;
/**
* An array of audio buffers, one for each drum sound.
* @type {Array}
* @private
*/
this._drumBuffers = [];
/**
* An array of arrays of audio buffers. Each instrument has one or more audio buffers.
* @type {Array[]}
* @private
*/
this._instrumentBufferArrays = [];
this._loadAllSounds();
}
/**
* Download and decode the full set of drum and instrument sounds, and
* store the audio buffers in arrays.
*/
_loadAllSounds () {
const loadingPromises = [];
this.DRUM_INFO.forEach((drumInfo, index) => {
const fileName = `drums/${drumInfo.fileName}`;
const promise = this._loadSound(fileName, index, this._drumBuffers);
loadingPromises.push(promise);
});
this.INSTRUMENT_INFO.forEach((instrumentInfo, instrumentIndex) => {
this._instrumentBufferArrays[instrumentIndex] = [];
instrumentInfo.samples.forEach((sample, noteIndex) => {
const fileName = `instruments/${instrumentInfo.dirName}/${sample}`;
const promise = this._loadSound(fileName, noteIndex, this._instrumentBufferArrays[instrumentIndex]);
loadingPromises.push(promise);
});
});
Promise.all(loadingPromises).then(() => {
// @TODO: Update the extension status indicator.
});
}
/**
* Download and decode a sound, and store the buffer in an array.
* @param {string} fileName - the audio file name.
* @param {number} index - the index at which to store the audio buffer.
* @param {array} bufferArray - the array of buffers in which to store it.
* @return {Promise} - a promise which will resolve once the sound has loaded.
*/
_loadSound (fileName, index, bufferArray) {
if (!this.runtime.storage) return;
if (!this.runtime.audioEngine) return;
if (!this.runtime.audioEngine.audioContext) return;
return this.runtime.storage.load(this.runtime.storage.AssetType.Sound, fileName, 'mp3')
.then(soundAsset => {
const context = this.runtime.audioEngine.audioContext;
// Check for newer promise-based API
if (context.decodeAudioData.length === 1) {
return context.decodeAudioData(soundAsset.data.buffer);
} else { // eslint-disable-line no-else-return
// Fall back to callback API
return new Promise((resolve, reject) =>
context.decodeAudioData(soundAsset.data.buffer,
buffer => resolve(buffer),
error => reject(error)
)
);
}
})
.then(buffer => {
bufferArray[index] = buffer;
});
}
/**
* Create data for a menu in scratch-blocks format, consisting of an array of objects with text and
* value properties. The text is a translated string, and the value is one-indexed.
* @param {object[]} info - An array of info objects each having a name property.
* @return {array} - An array of objects with text and value properties.
* @private
*/
_buildMenu (info) {
return info.map((entry, index) => {
const obj = {};
obj.text = entry.name;
obj.value = String(index + 1);
return obj;
});
}
/**
* An array of info about each drum.
* @type {object[]} an array of objects.
* @param {string} name - the translatable name to display in the drums menu.
* @param {string} fileName - the name of the audio file containing the drum sound.
*/
get DRUM_INFO () {
return [
{
name: '(1) Snare Drum',
fileName: '1-snare'
},
{
name: '(2) Bass Drum',
fileName: '2-bass-drum'
},
{
name: '(3) Side Stick',
fileName: '3-side-stick'
},
{
name: '(4) Crash Cymbal',
fileName: '4-crash-cymbal'
},
{
name: '(5) Open Hi-Hat',
fileName: '5-open-hi-hat'
},
{
name: '(6) Closed Hi-Hat',
fileName: '6-closed-hi-hat'
},
{
name: '(7) Tambourine',
fileName: '7-tambourine'
},
{
name: '(8) Hand Clap',
fileName: '8-hand-clap'
},
{
name: '(9) Claves',
fileName: '9-claves'
},
{
name: '(10) Wood Block',
fileName: '10-wood-block'
},
{
name: '(11) Cowbell',
fileName: '11-cowbell'
},
{
name: '(12) Triangle',
fileName: '12-triangle'
},
{
name: '(13) Bongo',
fileName: '13-bongo'
},
{
name: '(14) Conga',
fileName: '14-conga'
},
{
name: '(15) Cabasa',
fileName: '15-cabasa'
},
{
name: '(16) Guiro',
fileName: '16-guiro'
},
{
name: '(17) Vibraslap',
fileName: '17-vibraslap'
},
{
name: '(18) Cuica',
fileName: '18-cuica'
}
];
}
/**
* An array of info about each instrument.
* @type {object[]} an array of objects.
* @param {string} name - the translatable name to display in the instruments menu.
* @param {string} dirName - the name of the directory containing audio samples for this instrument.
* @param {number} [releaseTime] - an optional duration for the release portion of each note.
* @param {number[]} samples - an array of numbers representing the MIDI note number for each
* sampled sound used to play this instrument.
*/
get INSTRUMENT_INFO () {
return [
{
name: '(1) Piano',
dirName: '1-piano',
releaseTime: 0.5,
samples: [24, 36, 48, 60, 72, 84, 96, 108]
},
{
name: '(2) Electric Piano',
dirName: '2-electric-piano',
releaseTime: 0.5,
samples: [60]
},
{
name: '(3) Organ',
dirName: '3-organ',
releaseTime: 0.5,
samples: [60]
},
{
name: '(4) Guitar',
dirName: '4-guitar',
releaseTime: 0.5,
samples: [60]
},
{
name: '(5) Electric Guitar',
dirName: '5-electric-guitar',
releaseTime: 0.5,
samples: [60]
},
{
name: '(6) Bass',
dirName: '6-bass',
releaseTime: 0.25,
samples: [36, 48]
},
{
name: '(7) Pizzicato',
dirName: '7-pizzicato',
releaseTime: 0.25,
samples: [60]
},
{
name: '(8) Cello',
dirName: '8-cello',
releaseTime: 0.1,
samples: [36, 48, 60]
},
{
name: '(9) Trombone',
dirName: '9-trombone',
samples: [36, 48, 60]
},
{
name: '(10) Clarinet',
dirName: '10-clarinet',
samples: [48, 60]
},
{
name: '(11) Saxophone',
dirName: '11-saxophone',
samples: [36, 60, 84]
},
{
name: '(12) Flute',
dirName: '12-flute',
samples: [60, 72]
},
{
name: '(13) Wooden Flute',
dirName: '13-wooden-flute',
samples: [60, 72]
},
{
name: '(14) Bassoon',
dirName: '14-bassoon',
samples: [36, 48, 60]
},
{
name: '(15) Choir',
dirName: '15-choir',
releaseTime: 0.25,
samples: [48, 60, 72]
},
{
name: '(16) Vibraphone',
dirName: '16-vibraphone',
releaseTime: 0.5,
samples: [60, 72]
},
{
name: '(17) Music Box',
dirName: '17-music-box',
releaseTime: 0.25,
samples: [60]
},
{
name: '(18) Steel Drum',
dirName: '18-steel-drum',
releaseTime: 0.5,
samples: [60]
},
{
name: '(19) Marimba',
dirName: '19-marimba',
samples: [60]
},
{
name: '(20) Synth Lead',
dirName: '20-synth-lead',
releaseTime: 0.1,
samples: [60]
},
{
name: '(21) Synth Pad',
dirName: '21-synth-pad',
releaseTime: 0.25,
samples: [60]
}
];
}
/**
* The key to load & store a target's music-related state.
* @type {string}
*/
static get STATE_KEY () {
return 'Scratch.music';
}
/**
* The default music-related state, to be used when a target has no existing music state.
* @type {MusicState}
*/
static get DEFAULT_MUSIC_STATE () {
return {
currentInstrument: 0
};
}
/**
* The minimum and maximum MIDI note numbers, for clamping the input to play note.
* @type {{min: number, max: number}}
*/
static get MIDI_NOTE_RANGE () {
return {min: 0, max: 130};
}
/**
* The minimum and maximum beat values, for clamping the duration of play note, play drum and rest.
* 100 beats at the default tempo of 60bpm is 100 seconds.
* @type {{min: number, max: number}}
*/
static get BEAT_RANGE () {
return {min: 0, max: 100};
}
/** The minimum and maximum tempo values, in bpm.
* @type {{min: number, max: number}}
*/
static get TEMPO_RANGE () {
return {min: 20, max: 500};
}
/**
* The maximum number of sounds to allow to play simultaneously.
* @type {number}
*/
static get CONCURRENCY_LIMIT () {
return 30;
}
/**
* @param {Target} target - collect music state for this target.
* @returns {MusicState} the mutable music state associated with that target. This will be created if necessary.
* @private
*/
_getMusicState (target) {
let musicState = target.getCustomState(Scratch3MusicBlocks.STATE_KEY);
if (!musicState) {
musicState = Clone.simple(Scratch3MusicBlocks.DEFAULT_MUSIC_STATE);
target.setCustomState(Scratch3MusicBlocks.STATE_KEY, musicState);
}
return musicState;
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: 'music',
name: 'Music',
menuIconURI: menuIconURI,
blockIconURI: blockIconURI,
blocks: [
{
opcode: 'playDrumForBeats',
blockType: BlockType.COMMAND,
text: 'play drum [DRUM] for [BEATS] beats',
arguments: {
DRUM: {
type: ArgumentType.NUMBER,
menu: 'drums',
defaultValue: 1
},
BEATS: {
type: ArgumentType.NUMBER,
defaultValue: 0.25
}
}
},
{
opcode: 'restForBeats',
blockType: BlockType.COMMAND,
text: 'rest for [BEATS] beats',
arguments: {
BEATS: {
type: ArgumentType.NUMBER,
defaultValue: 0.25
}
}
},
{
opcode: 'playNoteForBeats',
blockType: BlockType.COMMAND,
text: 'play note [NOTE] for [BEATS] beats',
arguments: {
NOTE: {
type: ArgumentType.NUMBER,
defaultValue: 60
},
BEATS: {
type: ArgumentType.NUMBER,
defaultValue: 0.25
}
}
},
{
opcode: 'setInstrument',
blockType: BlockType.COMMAND,
text: 'set instrument to [INSTRUMENT]',
arguments: {
INSTRUMENT: {
type: ArgumentType.NUMBER,
menu: 'instruments',
defaultValue: 1
}
}
},
{
opcode: 'setTempo',
blockType: BlockType.COMMAND,
text: 'set tempo to [TEMPO]',
arguments: {
TEMPO: {
type: ArgumentType.NUMBER,
defaultValue: 60
}
}
},
{
opcode: 'changeTempo',
blockType: BlockType.COMMAND,
text: 'change tempo by [TEMPO]',
arguments: {
TEMPO: {
type: ArgumentType.NUMBER,
defaultValue: 20
}
}
},
{
opcode: 'getTempo',
text: 'tempo',
blockType: BlockType.REPORTER
}
],
menus: {
drums: this._buildMenu(this.DRUM_INFO),
instruments: this._buildMenu(this.INSTRUMENT_INFO)
}
};
}
/**
* Play a drum sound for some number of beats.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
* @property {int} DRUM - the number of the drum to play.
* @property {number} BEATS - the duration in beats of the drum sound.
*/
playDrumForBeats (args, util) {
if (this._stackTimerNeedsInit(util)) {
let drum = Cast.toNumber(args.DRUM);
drum = Math.round(drum);
drum -= 1; // drums are one-indexed
drum = MathUtil.wrapClamp(drum, 0, this.DRUM_INFO.length - 1);
let beats = Cast.toNumber(args.BEATS);
beats = this._clampBeats(beats);
this._playDrumNum(util, drum);
this._startStackTimer(util, this._beatsToSec(beats));
} else {
this._checkStackTimer(util);
}
}
/**
* Play a drum sound using its 0-indexed number.
* @param {object} util - utility object provided by the runtime.
* @param {number} drumNum - the number of the drum to play.
* @private
*/
_playDrumNum (util, drumNum) {
if (util.runtime.audioEngine === null) return;
if (util.target.audioPlayer === null) return;
// If we're playing too many sounds, do not play the drum sound.
if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) {
return;
}
const outputNode = util.target.audioPlayer.getInputNode();
const context = util.runtime.audioEngine.audioContext;
const bufferSource = context.createBufferSource();
bufferSource.buffer = this._drumBuffers[drumNum];
bufferSource.connect(outputNode);
bufferSource.start();
this._concurrencyCounter++;
bufferSource.onended = () => {
this._concurrencyCounter--;
};
}
/**
* Rest for some number of beats.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
* @property {number} BEATS - the duration in beats of the rest.
*/
restForBeats (args, util) {
if (this._stackTimerNeedsInit(util)) {
let beats = Cast.toNumber(args.BEATS);
beats = this._clampBeats(beats);
this._startStackTimer(util, this._beatsToSec(beats));
} else {
this._checkStackTimer(util);
}
}
/**
* Play a note using the current musical instrument for some number of beats.
* This function processes the arguments, and handles the timing of the block's execution.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
* @property {number} NOTE - the pitch of the note to play, interpreted as a MIDI note number.
* @property {number} BEATS - the duration in beats of the note.
*/
playNoteForBeats (args, util) {
if (this._stackTimerNeedsInit(util)) {
let note = Cast.toNumber(args.NOTE);
note = MathUtil.clamp(note,
Scratch3MusicBlocks.MIDI_NOTE_RANGE.min, Scratch3MusicBlocks.MIDI_NOTE_RANGE.max);
let beats = Cast.toNumber(args.BEATS);
beats = this._clampBeats(beats);
// If the duration is 0, do not play the note. In Scratch 2.0, "play drum for 0 beats" plays the drum,
// but "play note for 0 beats" is silent.
if (beats === 0) return;
const durationSec = this._beatsToSec(beats);
this._playNote(util, note, durationSec);
this._startStackTimer(util, durationSec);
} else {
this._checkStackTimer(util);
}
}
/**
* Play a note using the current instrument for a duration in seconds.
* This function actually plays the sound, and handles the timing of the sound, including the
* "release" portion of the sound, which continues briefly after the block execution has finished.
* @param {object} util - utility object provided by the runtime.
* @param {number} note - the pitch of the note to play, interpreted as a MIDI note number.
* @param {number} durationSec - the duration in seconds to play the note.
* @private
*/
_playNote (util, note, durationSec) {
if (util.runtime.audioEngine === null) return;
if (util.target.audioPlayer === null) return;
// If we're playing too many sounds, do not play the note.
if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) {
return;
}
// Determine which of the audio samples for this instrument to play
const musicState = this._getMusicState(util.target);
const inst = musicState.currentInstrument;
const instrumentInfo = this.INSTRUMENT_INFO[inst];
const sampleArray = instrumentInfo.samples;
const sampleIndex = this._selectSampleIndexForNote(note, sampleArray);
// If the audio sample has not loaded yet, bail out
if (typeof this._instrumentBufferArrays[inst] === 'undefined') return;
if (typeof this._instrumentBufferArrays[inst][sampleIndex] === 'undefined') return;
// Create the audio buffer to play the note, and set its pitch
const context = util.runtime.audioEngine.audioContext;
const bufferSource = context.createBufferSource();
bufferSource.buffer = this._instrumentBufferArrays[inst][sampleIndex];
const sampleNote = sampleArray[sampleIndex];
bufferSource.playbackRate.value = this._ratioForPitchInterval(note - sampleNote);
// Create a gain node for this note, and connect it to the sprite's audioPlayer.
const gainNode = context.createGain();
bufferSource.connect(gainNode);
const outputNode = util.target.audioPlayer.getInputNode();
gainNode.connect(outputNode);
// Start playing the note
bufferSource.start();
// Schedule the release of the note, ramping its gain down to zero,
// and then stopping the sound.
let releaseDuration = this.INSTRUMENT_INFO[inst].releaseTime;
if (typeof releaseDuration === 'undefined') {
releaseDuration = 0.01;
}
const releaseStart = context.currentTime + durationSec;
const releaseEnd = releaseStart + releaseDuration;
gainNode.gain.setValueAtTime(1, releaseStart);
gainNode.gain.linearRampToValueAtTime(0.0001, releaseEnd);
bufferSource.stop(releaseEnd);
// Update the concurrency counter
this._concurrencyCounter++;
bufferSource.onended = () => {
this._concurrencyCounter--;
};
}
/**
* The samples array for each instrument is the set of pitches of the available audio samples.
* This function selects the best one to use to play a given input note, and returns its index
* in the samples array.
* @param {number} note - the input note to select a sample for.
* @param {number[]} samples - an array of the pitches of the available samples.
* @return {index} the index of the selected sample in the samples array.
* @private
*/
_selectSampleIndexForNote (note, samples) {
// Step backwards through the array of samples, i.e. in descending pitch, in order to find
// the sample that is the closest one below (or matching) the pitch of the input note.
for (let i = samples.length - 1; i >= 0; i--) {
if (note >= samples[i]) {
return i;
}
}
return 0;
}
/**
* Calcuate the frequency ratio for a given musical interval.
* @param {number} interval - the pitch interval to convert.
* @return {number} a ratio corresponding to the input interval.
* @private
*/
_ratioForPitchInterval (interval) {
return Math.pow(2, (interval / 12));
}
/**
* Clamp a duration in beats to the allowed min and max duration.
* @param {number} beats - a duration in beats.
* @return {number} - the clamped duration.
* @private
*/
_clampBeats (beats) {
return MathUtil.clamp(beats, Scratch3MusicBlocks.BEAT_RANGE.min, Scratch3MusicBlocks.BEAT_RANGE.max);
}
/**
* Convert a number of beats to a number of seconds, using the current tempo.
* @param {number} beats - number of beats to convert to secs.
* @return {number} seconds - number of seconds `beats` will last.
* @private
*/
_beatsToSec (beats) {
return (60 / this.tempo) * beats;
}
/**
* Check if the stack timer needs initialization.
* @param {object} util - utility object provided by the runtime.
* @return {boolean} - true if the stack timer needs to be initialized.
* @private
*/
_stackTimerNeedsInit (util) {
return !util.stackFrame.timer;
}
/**
* Start the stack timer and the yield the thread if necessary.
* @param {object} util - utility object provided by the runtime.
* @param {number} duration - a duration in seconds to set the timer for.
* @private
*/
_startStackTimer (util, duration) {
util.stackFrame.timer = new Timer();
util.stackFrame.timer.start();
util.stackFrame.duration = duration;
util.yield();
}
/**
* Check the stack timer, and if its time is not up yet, yield the thread.
* @param {object} util - utility object provided by the runtime.
* @private
*/
_checkStackTimer (util) {
const timeElapsed = util.stackFrame.timer.timeElapsed();
if (timeElapsed < util.stackFrame.duration * 1000) {
util.yield();
}
}
/**
* Select an instrument for playing notes.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
* @property {int} INSTRUMENT - the number of the instrument to select.
*/
setInstrument (args, util) {
const musicState = this._getMusicState(util.target);
let instNum = Cast.toNumber(args.INSTRUMENT);
instNum = Math.round(instNum);
instNum -= 1; // instruments are one-indexed
instNum = MathUtil.wrapClamp(instNum, 0, this.INSTRUMENT_INFO.length - 1);
musicState.currentInstrument = instNum;
}
/**
* Set the current tempo to a new value.
* @param {object} args - the block arguments.
* @property {number} TEMPO - the tempo, in beats per minute.
*/
setTempo (args) {
const tempo = Cast.toNumber(args.TEMPO);
this._updateTempo(tempo);
}
/**
* Change the current tempo by some amount.
* @param {object} args - the block arguments.
* @property {number} TEMPO - the amount to change the tempo, in beats per minute.
*/
changeTempo (args) {
const change = Cast.toNumber(args.TEMPO);
const tempo = change + this.tempo;
this._updateTempo(tempo);
}
/**
* Update the current tempo, clamping it to the min and max allowable range.
* @param {number} tempo - the tempo to set, in beats per minute.
* @private
*/
_updateTempo (tempo) {
tempo = MathUtil.clamp(tempo, Scratch3MusicBlocks.TEMPO_RANGE.min, Scratch3MusicBlocks.TEMPO_RANGE.max);
this.tempo = tempo;
}
/**
* Get the current tempo.
* @return {number} - the current tempo, in beats per minute.
*/
getTempo () {
return this.tempo;
}
}
module.exports = Scratch3MusicBlocks;

View file

@ -0,0 +1,760 @@
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const Cast = require('../../util/cast');
const Clone = require('../../util/clone');
const Color = require('../../util/color');
const formatMessage = require('format-message');
const MathUtil = require('../../util/math-util');
const RenderedTarget = require('../../sprites/rendered-target');
const log = require('../../util/log');
/**
* Icon svg to be displayed at the left edge of each extension block, encoded as a data URI.
* @type {string}
*/
// eslint-disable-next-line max-len
const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+cGVuLWljb248L3RpdGxlPjxnIHN0cm9rZT0iIzU3NUU3NSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik04Ljc1MyAzNC42MDJsLTQuMjUgMS43OCAxLjc4My00LjIzN2MxLjIxOC0yLjg5MiAyLjkwNy01LjQyMyA1LjAzLTcuNTM4TDMxLjA2NiA0LjkzYy44NDYtLjg0MiAyLjY1LS40MSA0LjAzMi45NjcgMS4zOCAxLjM3NSAxLjgxNiAzLjE3My45NyA0LjAxNUwxNi4zMTggMjkuNTljLTIuMTIzIDIuMTE2LTQuNjY0IDMuOC03LjU2NSA1LjAxMiIgZmlsbD0iI0ZGRiIvPjxwYXRoIGQ9Ik0yOS40MSA2LjExcy00LjQ1LTIuMzc4LTguMjAyIDUuNzcyYy0xLjczNCAzLjc2Ni00LjM1IDEuNTQ2LTQuMzUgMS41NDYiLz48cGF0aCBkPSJNMzYuNDIgOC44MjVjMCAuNDYzLS4xNC44NzMtLjQzMiAxLjE2NGwtOS4zMzUgOS4zYy4yODItLjI5LjQxLS42NjguNDEtMS4xMiAwLS44NzQtLjUwNy0xLjk2My0xLjQwNi0yLjg2OC0xLjM2Mi0xLjM1OC0zLjE0Ny0xLjgtNC4wMDItLjk5TDMwLjk5IDUuMDFjLjg0NC0uODQgMi42NS0uNDEgNC4wMzUuOTYuODk4LjkwNCAxLjM5NiAxLjk4MiAxLjM5NiAyLjg1NU0xMC41MTUgMzMuNzc0Yy0uNTczLjMwMi0xLjE1Ny41Ny0xLjc2NC44M0w0LjUgMzYuMzgybDEuNzg2LTQuMjM1Yy4yNTgtLjYwNC41My0xLjE4Ni44MzMtMS43NTcuNjkuMTgzIDEuNDQ4LjYyNSAyLjEwOCAxLjI4Mi42Ni42NTggMS4xMDIgMS40MTIgMS4yODcgMi4xMDIiIGZpbGw9IiM0Qzk3RkYiLz48cGF0aCBkPSJNMzYuNDk4IDguNzQ4YzAgLjQ2NC0uMTQuODc0LS40MzMgMS4xNjVsLTE5Ljc0MiAxOS42OGMtMi4xMyAyLjExLTQuNjczIDMuNzkzLTcuNTcyIDUuMDFMNC41IDM2LjM4bC45NzQtMi4zMTYgMS45MjUtLjgwOGMyLjg5OC0xLjIxOCA1LjQ0LTIuOSA3LjU3LTUuMDFsMTkuNzQzLTE5LjY4Yy4yOTItLjI5Mi40MzItLjcwMi40MzItMS4xNjUgMC0uNjQ2LS4yNy0xLjQtLjc4LTIuMTIyLjI1LjE3Mi41LjM3Ny43MzcuNjE0Ljg5OC45MDUgMS4zOTYgMS45ODMgMS4zOTYgMi44NTYiIGZpbGw9IiM1NzVFNzUiIG9wYWNpdHk9Ii4xNSIvPjxwYXRoIGQ9Ik0xOC40NSAxMi44M2MwIC41LS40MDQuOTA1LS45MDQuOTA1cy0uOTA1LS40MDUtLjkwNS0uOTA0YzAtLjUuNDA3LS45MDMuOTA2LS45MDMuNSAwIC45MDQuNDA0LjkwNC45MDR6IiBmaWxsPSIjNTc1RTc1Ii8+PC9nPjwvc3ZnPg==';
/**
* Enum for pen color parameter values.
* @readonly
* @enum {string}
*/
const ColorParam = {
COLOR: 'color',
SATURATION: 'saturation',
BRIGHTNESS: 'brightness',
TRANSPARENCY: 'transparency'
};
/**
* @typedef {object} PenState - the pen state associated with a particular target.
* @property {Boolean} penDown - tracks whether the pen should draw for this target.
* @property {number} color - the current color (hue) of the pen.
* @property {PenAttributes} penAttributes - cached pen attributes for the renderer. This is the authoritative value for
* diameter but not for pen color.
*/
/**
* Host for the Pen-related blocks in Scratch 3.0
* @param {Runtime} runtime - the runtime instantiating this block package.
* @constructor
*/
class Scratch3PenBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* The ID of the renderer Drawable corresponding to the pen layer.
* @type {int}
* @private
*/
this._penDrawableId = -1;
/**
* The ID of the renderer Skin corresponding to the pen layer.
* @type {int}
* @private
*/
this._penSkinId = -1;
this._onTargetCreated = this._onTargetCreated.bind(this);
this._onTargetMoved = this._onTargetMoved.bind(this);
runtime.on('targetWasCreated', this._onTargetCreated);
}
/**
* The default pen state, to be used when a target has no existing pen state.
* @type {PenState}
*/
static get DEFAULT_PEN_STATE () {
return {
penDown: false,
color: 66.66,
saturation: 100,
brightness: 100,
transparency: 0,
_shade: 50, // Used only for legacy `change shade by` blocks
penAttributes: {
color4f: [0, 0, 1, 1],
diameter: 1
}
};
}
/**
* Place the pen layer in front of the backdrop but behind everything else.
* We should probably handle this somewhere else... somewhere central that knows about pen, backdrop, video, etc.
* Maybe it should be in the GUI?
* @type {int}
*/
static get PEN_ORDER () {
return 1;
}
/**
* The minimum and maximum allowed pen size.
* @type {{min: number, max: number}}
*/
static get PEN_SIZE_RANGE () {
return {min: 1, max: 255};
}
/**
* The key to load & store a target's pen-related state.
* @type {string}
*/
static get STATE_KEY () {
return 'Scratch.pen';
}
/**
* Clamp a pen size value to the range allowed by the pen.
* @param {number} requestedSize - the requested pen size.
* @returns {number} the clamped size.
* @private
*/
_clampPenSize (requestedSize) {
return MathUtil.clamp(
requestedSize,
Scratch3PenBlocks.PEN_SIZE_RANGE.min,
Scratch3PenBlocks.PEN_SIZE_RANGE.max
);
}
/**
* Retrieve the ID of the renderer "Skin" corresponding to the pen layer. If
* the pen Skin doesn't yet exist, create it.
* @returns {int} the Skin ID of the pen layer, or -1 on failure.
* @private
*/
_getPenLayerID () {
if (this._penSkinId < 0 && this.runtime.renderer) {
this._penSkinId = this.runtime.renderer.createPenSkin();
this._penDrawableId = this.runtime.renderer.createDrawable();
this.runtime.renderer.setDrawableOrder(this._penDrawableId, Scratch3PenBlocks.PEN_ORDER);
this.runtime.renderer.updateDrawableProperties(this._penDrawableId, {skinId: this._penSkinId});
}
return this._penSkinId;
}
/**
* @param {Target} target - collect pen state for this target. Probably, but not necessarily, a RenderedTarget.
* @returns {PenState} the mutable pen state associated with that target. This will be created if necessary.
* @private
*/
_getPenState (target) {
let penState = target.getCustomState(Scratch3PenBlocks.STATE_KEY);
if (!penState) {
penState = Clone.simple(Scratch3PenBlocks.DEFAULT_PEN_STATE);
target.setCustomState(Scratch3PenBlocks.STATE_KEY, penState);
}
return penState;
}
/**
* When a pen-using Target is cloned, clone the pen state.
* @param {Target} newTarget - the newly created target.
* @param {Target} [sourceTarget] - the target used as a source for the new clone, if any.
* @listens Runtime#event:targetWasCreated
* @private
*/
_onTargetCreated (newTarget, sourceTarget) {
if (sourceTarget) {
const penState = sourceTarget.getCustomState(Scratch3PenBlocks.STATE_KEY);
if (penState) {
newTarget.setCustomState(Scratch3PenBlocks.STATE_KEY, Clone.simple(penState));
if (penState.penDown) {
newTarget.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
}
}
}
}
/**
* Handle a target which has moved. This only fires when the pen is down.
* @param {RenderedTarget} target - the target which has moved.
* @param {number} oldX - the previous X position.
* @param {number} oldY - the previous Y position.
* @param {boolean} isForce - whether the movement was forced.
* @private
*/
_onTargetMoved (target, oldX, oldY, isForce) {
// Only move the pen if the movement isn't forced (ie. dragged).
if (!isForce) {
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
const penState = this._getPenState(target);
this.runtime.renderer.penLine(penSkinId, penState.penAttributes, oldX, oldY, target.x, target.y);
this.runtime.requestRedraw();
}
}
}
/**
* Wrap a color input into the range (0,100).
* @param {number} value - the value to be wrapped.
* @returns {number} the wrapped value.
* @private
*/
_wrapColor (value) {
return MathUtil.wrapClamp(value, 0, 100);
}
/**
* Initialize color parameters menu with localized strings
* @returns {array} of the localized text and values for each menu element
* @private
*/
_initColorParam () {
return [
{
text: formatMessage({
id: 'pen.colorMenu.color',
default: 'color',
description: 'label for color element in color picker for pen extension'
}),
value: ColorParam.COLOR
},
{
text: formatMessage({
id: 'pen.colorMenu.saturation',
default: 'saturation',
description: 'label for saturation element in color picker for pen extension'
}),
value: ColorParam.SATURATION
},
{
text: formatMessage({
id: 'pen.colorMenu.brightness',
default: 'brightness',
description: 'label for brightness element in color picker for pen extension'
}),
value: ColorParam.BRIGHTNESS
},
{
text: formatMessage({
id: 'pen.colorMenu.transparency',
default: 'transparency',
description: 'label for transparency element in color picker for pen extension'
}),
value: ColorParam.TRANSPARENCY
}
];
}
/**
* Clamp a pen color parameter to the range (0,100).
* @param {number} value - the value to be clamped.
* @returns {number} the clamped value.
* @private
*/
_clampColorParam (value) {
return MathUtil.clamp(value, 0, 100);
}
/**
* Convert an alpha value to a pen transparency value.
* Alpha ranges from 0 to 1, where 0 is transparent and 1 is opaque.
* Transparency ranges from 0 to 100, where 0 is opaque and 100 is transparent.
* @param {number} alpha - the input alpha value.
* @returns {number} the transparency value.
* @private
*/
_alphaToTransparency (alpha) {
return (1.0 - alpha) * 100.0;
}
/**
* Convert a pen transparency value to an alpha value.
* Alpha ranges from 0 to 1, where 0 is transparent and 1 is opaque.
* Transparency ranges from 0 to 100, where 0 is opaque and 100 is transparent.
* @param {number} transparency - the input transparency value.
* @returns {number} the alpha value.
* @private
*/
_transparencyToAlpha (transparency) {
return 1.0 - (transparency / 100.0);
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: 'pen',
name: 'Pen',
blockIconURI: blockIconURI,
blocks: [
{
opcode: 'clear',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.clear',
default: 'clear',
description: 'erase all pen trails and stamps'
})
},
{
opcode: 'stamp',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.stamp',
default: 'stamp',
description: 'render current costume on the background'
})
},
{
opcode: 'penDown',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.penDown',
default: 'pen down',
description: 'start leaving a trail when the sprite moves'
})
},
{
opcode: 'penUp',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.penUp',
default: 'pen up',
description: 'stop leaving a trail behind the sprite'
})
},
{
opcode: 'setPenColorToColor',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.setColor',
default: 'set pen color to [COLOR]',
description: 'set the pen color to a particular (RGB) value'
}),
arguments: {
COLOR: {
type: ArgumentType.COLOR
}
}
},
{
opcode: 'changePenColorParamBy',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.changeColorParam',
default: 'change pen [COLOR_PARAM] by [VALUE]',
description: 'change the state of a pen color parameter'
}),
arguments: {
COLOR_PARAM: {
type: ArgumentType.STRING,
menu: 'colorParam',
defaultValue: ColorParam.COLOR
},
VALUE: {
type: ArgumentType.NUMBER,
defaultValue: 10
}
}
},
{
opcode: 'setPenColorParamTo',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.setColorParam',
default: 'set pen [COLOR_PARAM] to [VALUE]',
description: 'set the state for a pen color parameter e.g. saturation'
}),
arguments: {
COLOR_PARAM: {
type: ArgumentType.STRING,
menu: 'colorParam',
defaultValue: ColorParam.COLOR
},
VALUE: {
type: ArgumentType.NUMBER,
defaultValue: 50
}
}
},
{
opcode: 'changePenSizeBy',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.changeSize',
default: 'change pen size by [SIZE]',
description: 'change the diameter of the trail left by a sprite'
}),
arguments: {
SIZE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'setPenSizeTo',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.setSize',
default: 'set pen size to [SIZE]',
description: 'set the diameter of a trail left by a sprite'
}),
arguments: {
SIZE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
},
/* Legacy blocks, should not be shown in flyout */
{
opcode: 'setPenShadeToNumber',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.setShade',
default: 'set pen shade to [SHADE]',
description: 'legacy pen blocks - set pen shade'
}),
arguments: {
SHADE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
},
hideFromPalette: true
},
{
opcode: 'changePenShadeBy',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.changeShade',
default: 'change pen shade by [SHADE]',
description: 'legacy pen blocks - change pen shade'
}),
arguments: {
SHADE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
},
hideFromPalette: true
},
{
opcode: 'setPenHueToNumber',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.setHue',
default: 'set pen color to [HUE]',
description: 'legacy pen blocks - set pen color to number'
}),
arguments: {
HUE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
},
hideFromPalette: true
},
{
opcode: 'changePenHueBy',
blockType: BlockType.COMMAND,
text: formatMessage({
id: 'pen.changeHue',
default: 'change pen color by [HUE]',
description: 'legacy pen blocks - change pen color'
}),
arguments: {
HUE: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
},
hideFromPalette: true
}
],
menus: {
colorParam: this._initColorParam()
}
};
}
/**
* The pen "clear" block clears the pen layer's contents.
*/
clear () {
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
this.runtime.renderer.penClear(penSkinId);
this.runtime.requestRedraw();
}
}
/**
* The pen "stamp" block stamps the current drawable's image onto the pen layer.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
*/
stamp (args, util) {
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
const target = util.target;
this.runtime.renderer.penStamp(penSkinId, target.drawableID);
this.runtime.requestRedraw();
}
}
/**
* The pen "pen down" block causes the target to leave pen trails on future motion.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
*/
penDown (args, util) {
const target = util.target;
const penState = this._getPenState(target);
if (!penState.penDown) {
penState.penDown = true;
target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
}
const penSkinId = this._getPenLayerID();
if (penSkinId >= 0) {
this.runtime.renderer.penPoint(penSkinId, penState.penAttributes, target.x, target.y);
this.runtime.requestRedraw();
}
}
/**
* The pen "pen up" block stops the target from leaving pen trails.
* @param {object} args - the block arguments.
* @param {object} util - utility object provided by the runtime.
*/
penUp (args, util) {
const target = util.target;
const penState = this._getPenState(target);
if (penState.penDown) {
penState.penDown = false;
target.removeListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved);
}
}
/**
* The pen "set pen color to {color}" block sets the pen to a particular RGB color.
* The transparency is reset to 0.
* @param {object} args - the block arguments.
* @property {int} COLOR - the color to set, expressed as a 24-bit RGB value (0xRRGGBB).
* @param {object} util - utility object provided by the runtime.
*/
setPenColorToColor (args, util) {
const penState = this._getPenState(util.target);
const rgb = Cast.toRgbColorObject(args.COLOR);
const hsv = Color.rgbToHsv(rgb);
penState.color = (hsv.h / 360) * 100;
penState.saturation = hsv.s * 100;
penState.brightness = hsv.v * 100;
if (rgb.hasOwnProperty('a')) {
penState.transparency = 100 * (1 - (rgb.a / 255.0));
} else {
penState.transparency = 0;
}
// Set the legacy "shade" value the same way scratch 2 did.
penState._shade = penState.brightness / 2;
this._updatePenColor(penState);
}
/**
* Update the cached color from the color, saturation, brightness and transparency values
* in the provided PenState object.
* @param {PenState} penState - the pen state to update.
* @private
*/
_updatePenColor (penState) {
const rgb = Color.hsvToRgb({
h: penState.color * 360 / 100,
s: penState.saturation / 100,
v: penState.brightness / 100
});
penState.penAttributes.color4f[0] = rgb.r / 255.0;
penState.penAttributes.color4f[1] = rgb.g / 255.0;
penState.penAttributes.color4f[2] = rgb.b / 255.0;
penState.penAttributes.color4f[3] = this._transparencyToAlpha(penState.transparency);
}
/**
* Set or change a single color parameter on the pen state, and update the pen color.
* @param {ColorParam} param - the name of the color parameter to set or change.
* @param {number} value - the value to set or change the param by.
* @param {PenState} penState - the pen state to update.
* @param {boolean} change - if true change param by value, if false set param to value.
* @private
*/
_setOrChangeColorParam (param, value, penState, change) {
switch (param) {
case ColorParam.COLOR:
penState.color = this._wrapColor(value + (change ? penState.color : 0));
break;
case ColorParam.SATURATION:
penState.saturation = this._clampColorParam(value + (change ? penState.saturation : 0));
break;
case ColorParam.BRIGHTNESS:
penState.brightness = this._clampColorParam(value + (change ? penState.brightness : 0));
break;
case ColorParam.TRANSPARENCY:
penState.transparency = this._clampColorParam(value + (change ? penState.transparency : 0));
break;
default:
log.warn(`Tried to set or change unknown color parameter: ${param}`);
}
this._updatePenColor(penState);
}
/**
* The "change pen {ColorParam} by {number}" block changes one of the pen's color parameters
* by a given amound.
* @param {object} args - the block arguments.
* @property {ColorParam} COLOR_PARAM - the name of the selected color parameter.
* @property {number} VALUE - the amount to change the selected parameter by.
* @param {object} util - utility object provided by the runtime.
*/
changePenColorParamBy (args, util) {
const penState = this._getPenState(util.target);
this._setOrChangeColorParam(args.COLOR_PARAM, Cast.toNumber(args.VALUE), penState, true);
}
/**
* The "set pen {ColorParam} to {number}" block sets one of the pen's color parameters
* to a given amound.
* @param {object} args - the block arguments.
* @property {ColorParam} COLOR_PARAM - the name of the selected color parameter.
* @property {number} VALUE - the amount to set the selected parameter to.
* @param {object} util - utility object provided by the runtime.
*/
setPenColorParamTo (args, util) {
const penState = this._getPenState(util.target);
this._setOrChangeColorParam(args.COLOR_PARAM, Cast.toNumber(args.VALUE), penState, false);
}
/**
* The pen "change pen size by {number}" block changes the pen size by the given amount.
* @param {object} args - the block arguments.
* @property {number} SIZE - the amount of desired size change.
* @param {object} util - utility object provided by the runtime.
*/
changePenSizeBy (args, util) {
const penAttributes = this._getPenState(util.target).penAttributes;
penAttributes.diameter = this._clampPenSize(penAttributes.diameter + Cast.toNumber(args.SIZE));
}
/**
* The pen "set pen size to {number}" block sets the pen size to the given amount.
* @param {object} args - the block arguments.
* @property {number} SIZE - the amount of desired size change.
* @param {object} util - utility object provided by the runtime.
*/
setPenSizeTo (args, util) {
const penAttributes = this._getPenState(util.target).penAttributes;
penAttributes.diameter = this._clampPenSize(Cast.toNumber(args.SIZE));
}
/* LEGACY OPCODES */
/**
* Scratch 2 "hue" param is equivelant to twice the new "color" param.
* @param {object} args - the block arguments.
* @property {number} HUE - the amount to set the hue to.
* @param {object} util - utility object provided by the runtime.
*/
setPenHueToNumber (args, util) {
const penState = this._getPenState(util.target);
const hueValue = Cast.toNumber(args.HUE);
const colorValue = hueValue / 2;
this._setOrChangeColorParam(ColorParam.COLOR, colorValue, penState, false);
this._legacyUpdatePenColor(penState);
}
/**
* Scratch 2 "hue" param is equivelant to twice the new "color" param.
* @param {object} args - the block arguments.
* @property {number} HUE - the amount of desired hue change.
* @param {object} util - utility object provided by the runtime.
*/
changePenHueBy (args, util) {
const penState = this._getPenState(util.target);
const hueChange = Cast.toNumber(args.HUE);
const colorChange = hueChange / 2;
this._setOrChangeColorParam(ColorParam.COLOR, colorChange, penState, true);
this._legacyUpdatePenColor(penState);
}
/**
* Use legacy "set shade" code to calculate RGB value for shade,
* then convert back to HSV and store those components.
* It is important to also track the given shade in penState._shade
* because it cannot be accurately backed out of the new HSV later.
* @param {object} args - the block arguments.
* @property {number} SHADE - the amount to set the shade to.
* @param {object} util - utility object provided by the runtime.
*/
setPenShadeToNumber (args, util) {
const penState = this._getPenState(util.target);
let newShade = Cast.toNumber(args.SHADE);
// Wrap clamp the new shade value the way scratch 2 did.
newShade = newShade % 200;
if (newShade < 0) newShade += 200;
// And store the shade that was used to compute this new color for later use.
penState._shade = newShade;
this._legacyUpdatePenColor(penState);
}
/**
* Because "shade" cannot be backed out of hsv consistently, use the previously
* stored penState._shade to make the shade change.
* @param {object} args - the block arguments.
* @property {number} SHADE - the amount of desired shade change.
* @param {object} util - utility object provided by the runtime.
*/
changePenShadeBy (args, util) {
const penState = this._getPenState(util.target);
const shadeChange = Cast.toNumber(args.SHADE);
this.setPenShadeToNumber({SHADE: penState._shade + shadeChange}, util);
}
/**
* Update the pen state's color from its hue & shade values, Scratch 2.0 style.
* @param {object} penState - update the HSV & RGB values in this pen state from its hue & shade values.
* @private
*/
_legacyUpdatePenColor (penState) {
// Create the new color in RGB using the scratch 2 "shade" model
let rgb = Color.hsvToRgb({h: penState.color * 360 / 100, s: 1, v: 1});
const shade = (penState._shade > 100) ? 200 - penState._shade : penState._shade;
if (shade < 50) {
rgb = Color.mixRgb(Color.RGB_BLACK, rgb, (10 + shade) / 60);
} else {
rgb = Color.mixRgb(rgb, Color.RGB_WHITE, (shade - 50) / 60);
}
// Update the pen state according to new color
const hsv = Color.rgbToHsv(rgb);
penState.color = 100 * hsv.h / 360;
penState.saturation = 100 * hsv.s;
penState.brightness = 100 * hsv.v;
this._updatePenColor(penState);
}
}
module.exports = Scratch3PenBlocks;

View file

@ -1,5 +1,14 @@
const color = require('../util/color'); const ArgumentType = require('../../extension-support/argument-type');
const log = require('../util/log'); const BlockType = require('../../extension-support/block-type');
const color = require('../../util/color');
const log = require('../../util/log');
/**
* Icon svg to be displayed at the left edge of each extension block, encoded as a data URI.
* @type {string}
*/
// eslint-disable-next-line max-len
const iconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+d2VkbzItYmxvY2staWNvbjwvdGl0bGU+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMzUuMzEzIDEwLjQ2N0gzMi4wOVY4Ljg2NWMwLS4yMjMuMTgtLjQwNC40MDUtLjQwNGgyLjQxMmMuMjI0IDAgLjQwNi4xODIuNDA2LjQwNXYxLjYwMnpNMzAuNDc3IDEwLjQ2N2gtMy4yMjRWOC44NjVjMC0uMjIzLjE4My0uNDA0LjQwNy0uNDA0aDIuNDFjLjIyNiAwIC40MDcuMTgyLjQwNy40MDV2MS42MDJ6TTI1LjY0IDEwLjQ2N0gyMi40MlY4Ljg2NWMwLS4yMjMuMTgyLS40MDQuNDA2LS40MDRoMi40MWMuMjI2IDAgLjQwNy4xODIuNDA3LjQwNXYxLjYwMnpNMjAuODA2IDEwLjQ2N2gtMy4yMjRWOC44NjVjMC0uMjIzLjE4Mi0uNDA0LjQwNi0uNDA0SDIwLjRjLjIyNCAwIC40MDYuMTgyLjQwNi40MDV2MS42MDJ6TTE1Ljk3IDEwLjQ2N2gtMy4yMjRWOC44NjVjMC0uMjIzLjE4Mi0uNDA0LjQwNy0uNDA0aDIuNDFjLjIyNiAwIC40MDcuMTgyLjQwNy40MDV2MS42MDJ6TTExLjEzNSAxMC40NjdINy45MVY4Ljg2NWMwLS4yMjMuMTgzLS40MDQuNDA3LS40MDRoMi40MTJjLjIyMyAwIC40MDUuMTgyLjQwNS40MDV2MS42MDJ6IiBzdHJva2U9IiM2Rjc4OTMiIGZpbGw9IiNGRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0zNy43MyAxMC40NjdINi4zYy0yLjY3IDAtNC44MzYgMi4xNTMtNC44MzYgNC44MDh2My4yMDVoMzcuMDczdi03LjIxYzAtLjQ0NC0uMzYyLS44MDMtLjgwNy0uODAzeiIgc3Ryb2tlPSIjNkY3ODkzIiBmaWxsPSIjRkZGIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMzguMTM0IDMwLjk4SDEuODY3Yy0uMjI0IDAtLjQwMy0uMTgtLjQwMy0uNFYxNi4yMzZoMzIuNzFjLjczIDAgMS40My4yODcgMS45NDUuOC41MTUuNTE0IDEuMjE1LjgwMiAxLjk0NC44MDJoLjQ3M3YxMi43NGMwIC4yMi0uMTguNC0uNDAzLjR6IiBzdHJva2U9IiM2Rjc4OTMiIGZpbGw9IiNFNkU3RTgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIHN0cm9rZT0iIzZGNzg5MyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBkPSJNMzQuODMgMTYuMjM3bC40ODMtMi41NjVoMy4yMjMiLz48cGF0aCBkPSJNMzguNTM2IDExLjI2OFYzMC41OGMwIC4yMi0uMTguNC0uNDAzLjRIMS44NjZjLS4yMiAwLS40MDMtLjE4LS40MDMtLjR2LTEuMjAzaDM0LjI4MmMuNjUgMCAxLjE4LS41MjQgMS4xOC0xLjE3M1YxMC40NjdoLjgwNWMuNDQ2IDAgLjgwNi4zNi44MDYuOHoiIHN0cm9rZT0iIzZGNzg5MyIgZmlsbD0iIzZGNzg5MyIgb3BhY2l0eT0iLjE1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMTEuNTM4IDE2LjI4aDIwLjE0OGMuMjIyIDAgLjQwMy4xOC40MDMuNHY2LjUyN2MwIC4yMjItLjE4Mi40LS40MDQuNEgxMS41MzhjLS4yMjMgMC0uNDA0LS4xNzgtLjQwNC0uNFYxNi42OGMwLS4yMi4xOC0uNC40MDQtLjQiIGZpbGw9IiNFNkU3RTgiLz48cGF0aCBkPSJNMTEuNTM4IDE2LjI4aDIwLjE0OGMuMjIyIDAgLjQwMy4xOC40MDMuNHY2LjUyN2MwIC4yMjItLjE4Mi40LS40MDQuNEgxMS41MzhjLS4yMjMgMC0uNDA0LS4xNzgtLjQwNC0uNFYxNi42OGMwLS4yMi4xOC0uNC40MDQtLjR6IiBzdHJva2U9IiM2Rjc4OTMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0zMi4wOSAxNi4yOHY2LjkyN2MwIC4yMjItLjE4LjQtLjQwNC40aC0yMC4xNWMtLjIyIDAtLjQtLjE4LS40LS40di0xLjJoMTguMTZjLjY1MyAwIDEuMTgtLjUyNiAxLjE4LTEuMTc0VjE2LjI4aDEuNjEzeiIgc3Ryb2tlPSIjNkY3ODkzIiBmaWxsPSIjNkU3NzkyIiBvcGFjaXR5PSIuMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0zMC40NzcgMTYuMjhoLTMuMjI0di0xLjYwNGMwLS4yMjMuMTgzLS40MDQuNDA3LS40MDRoMi40MWMuMjI2IDAgLjQwNy4xOC40MDcuNDA0djEuNjAzek0xNS45NyAxNi4yOGgtMy4yMjR2LTEuNjA0YzAtLjIyMy4xODItLjQwNC40MDctLjQwNGgyLjQxYy4yMjYgMCAuNDA3LjE4LjQwNy40MDR2MS42MDN6TTI1LjY0IDE2LjI4SDIyLjQydi0xLjYwNGMwLS4yMjMuMTgyLS40MDQuNDA2LS40MDRoMi40MWMuMjI2IDAgLjQwNy4xOC40MDcuNDA0djEuNjAzek0yMC44MDYgMTYuMjhoLTMuMjI0di0xLjYwNGMwLS4yMjMuMTgyLS40MDQuNDA2LS40MDRIMjAuNGMuMjI0IDAgLjQwNi4xOC40MDYuNDA0djEuNjAzeiIgc3Ryb2tlPSIjNkY3ODkzIiBmaWxsPSIjRTZFN0U4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMTguNTU3IDE5LjkxYzAgMS4wMjUtLjgzNyAxLjg1Ny0xLjg3IDEuODU3LTEuMDMgMC0xLjg2Ny0uODMyLTEuODY3LTEuODU4IDAtMS4wMjcuODM3LTEuODU4IDEuODY4LTEuODU4IDEuMDMyIDAgMS44Ny44MyAxLjg3IDEuODU3ek0yMy40OCAxOS45MWMwIDEuMDI1LS44MzYgMS44NTctMS44NjggMS44NTdzLTEuODctLjgzMi0xLjg3LTEuODU4YzAtMS4wMjcuODM4LTEuODU4IDEuODctMS44NThzMS44NjguODMgMS44NjggMS44NTd6TTI4LjQwNCAxOS45MWMwIDEuMDI1LS44MzcgMS44NTctMS44NjggMS44NTctMS4wMzIgMC0xLjg3LS44MzItMS44Ny0xLjg1OCAwLTEuMDI3LjgzOC0xLjg1OCAxLjg3LTEuODU4IDEuMDMgMCAxLjg2OC44MyAxLjg2OCAxLjg1N3oiIHN0cm9rZT0iIzZGNzg5MyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE4LjU1NyAxOS45MjJjMCAxLjAyNi0uODM3IDEuODU4LTEuODcgMS44NTgtMS4wMyAwLTEuODY3LS44MzItMS44NjctMS44NTggMC0xLjAyNS44MzctMS44NTcgMS44NjgtMS44NTcgMS4wMzIgMCAxLjg3LjgzMiAxLjg3IDEuODU3TTIzLjQ4IDE5LjkyMmMwIDEuMDI2LS44MzYgMS44NTgtMS44NjggMS44NThzLTEuODctLjgzMi0xLjg3LTEuODU4YzAtMS4wMjUuODM4LTEuODU3IDEuODctMS44NTdzMS44NjguODMyIDEuODY4IDEuODU3TTI4LjQwNCAxOS45MjJjMCAxLjAyNi0uODM3IDEuODU4LTEuODY4IDEuODU4LTEuMDMyIDAtMS44Ny0uODMyLTEuODctMS44NTggMC0xLjAyNS44MzgtMS44NTcgMS44Ny0xLjg1NyAxLjAzIDAgMS44NjguODMyIDEuODY4IDEuODU3IiBmaWxsPSIjNkY3ODkzIiBvcGFjaXR5PSIuNSIvPjwvZz48L3N2Zz4=';
/** /**
* Manage power, direction, and timers for one WeDo 2.0 motor. * Manage power, direction, and timers for one WeDo 2.0 motor.
@ -243,7 +252,7 @@ class WeDo2 {
* @return {number} - the latest value received from the distance sensor. * @return {number} - the latest value received from the distance sensor.
*/ */
get distance () { get distance () {
return this._sensors.distance; return this._sensors.distance * 10;
} }
/** /**
@ -371,9 +380,9 @@ const TiltDirection = {
class Scratch3WeDo2Blocks { class Scratch3WeDo2Blocks {
/** /**
* @return {string} - the name of this extension. * @return {string} - the ID of this extension.
*/ */
static get EXTENSION_NAME () { static get EXTENSION_ID () {
return 'wedo2'; return 'wedo2';
} }
@ -395,7 +404,185 @@ class Scratch3WeDo2Blocks {
*/ */
this.runtime = runtime; this.runtime = runtime;
this.runtime.HACK_WeDo2Blocks = this; this.connect();
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: Scratch3WeDo2Blocks.EXTENSION_ID,
name: 'WeDo 2.0',
iconURI: iconURI,
blocks: [
{
opcode: 'motorOnFor',
text: 'turn [MOTOR_ID] on for [DURATION] seconds',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
},
DURATION: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'motorOn',
text: 'turn [MOTOR_ID] on',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
}
}
},
{
opcode: 'motorOff',
text: 'turn [MOTOR_ID] off',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
}
}
},
{
opcode: 'startMotorPower',
text: 'set [MOTOR_ID] power to [POWER]',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
},
POWER: {
type: ArgumentType.NUMBER,
defaultValue: 100
}
}
},
{
opcode: 'setMotorDirection',
text: 'set [MOTOR_ID] direction to [DIRECTION]',
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'motorID',
defaultValue: MotorID.DEFAULT
},
DIRECTION: {
type: ArgumentType.STRING,
menu: 'motorDirection',
defaultValue: MotorDirection.FORWARD
}
}
},
{
opcode: 'setLightHue',
text: 'set light color to [HUE]',
blockType: BlockType.COMMAND,
arguments: {
HUE: {
type: ArgumentType.NUMBER,
defaultValue: 50
}
}
},
{
opcode: 'playNoteFor',
text: 'play note [NOTE] for [DURATION] seconds',
blockType: BlockType.COMMAND,
arguments: {
NOTE: {
type: ArgumentType.NUMBER, // TODO: ArgumentType.MIDI_NOTE?
defaultValue: 60
},
DURATION: {
type: ArgumentType.NUMBER,
defaultValue: 0.5
}
}
},
{
opcode: 'whenDistance',
text: 'when distance [OP] [REFERENCE]',
blockType: BlockType.HAT,
arguments: {
OP: {
type: ArgumentType.STRING,
menu: 'lessMore',
defaultValue: '<'
},
REFERENCE: {
type: ArgumentType.NUMBER,
defaultValue: 50
}
}
},
{
opcode: 'whenTilted',
text: 'when tilted [DIRECTION]',
func: 'isTilted',
blockType: BlockType.HAT,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirectionAny',
defaultValue: TiltDirection.ANY
}
}
},
{
opcode: 'getDistance',
text: 'distance',
blockType: BlockType.REPORTER
},
{
opcode: 'isTilted',
text: 'tilted [DIRECTION]?',
blockType: BlockType.BOOLEAN,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirectionAny',
defaultValue: TiltDirection.ANY
}
}
},
{
opcode: 'getTiltAngle',
text: 'tilt angle [DIRECTION]',
blockType: BlockType.REPORTER,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirection',
defaultValue: TiltDirection.UP
}
}
}
],
menus: {
motorID: [MotorID.DEFAULT, MotorID.A, MotorID.B, MotorID.ALL],
motorDirection: [MotorDirection.FORWARD, MotorDirection.BACKWARD, MotorDirection.REVERSE],
tiltDirection: [TiltDirection.UP, TiltDirection.DOWN, TiltDirection.LEFT, TiltDirection.RIGHT],
tiltDirectionAny:
[TiltDirection.UP, TiltDirection.DOWN, TiltDirection.LEFT, TiltDirection.RIGHT, TiltDirection.ANY],
lessMore: ['<', '>']
}
};
} }
/** /**
@ -407,7 +594,7 @@ class Scratch3WeDo2Blocks {
} }
const deviceManager = this.runtime.ioDevices.deviceManager; const deviceManager = this.runtime.ioDevices.deviceManager;
const finder = this._finder = const finder = this._finder =
deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_NAME, WeDo2.DEVICE_TYPE); deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_ID, WeDo2.DEVICE_TYPE);
this._finder.promise.then( this._finder.promise.then(
socket => { socket => {
if (this._finder === finder) { if (this._finder === finder) {
@ -427,27 +614,6 @@ class Scratch3WeDo2Blocks {
}); });
} }
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
wedo2_motorOnFor: this.motorOnFor,
wedo2_motorOn: this.motorOn,
wedo2_motorOff: this.motorOff,
wedo2_startMotorPower: this.startMotorPower,
wedo2_setMotorDirection: this.setMotorDirection,
wedo2_setLightHue: this.setLightHue,
wedo2_playNoteFor: this.playNoteFor,
wedo2_whenDistance: this.whenDistance,
wedo2_whenTilted: this.whenTilted,
wedo2_getDistance: this.getDistance,
wedo2_isTilted: this.isTilted,
wedo2_getTiltAngle: this.getTiltAngle
};
}
/** /**
* Turn specified motor(s) on for a specified duration. * Turn specified motor(s) on for a specified duration.
* @param {object} args - the block's arguments. * @param {object} args - the block's arguments.
@ -574,8 +740,10 @@ class Scratch3WeDo2Blocks {
whenDistance (args) { whenDistance (args) {
switch (args.OP) { switch (args.OP) {
case '<': case '<':
case '&lt;':
return this._device.distance < args.REFERENCE; return this._device.distance < args.REFERENCE;
case '>': case '>':
case '&gt;':
return this._device.distance > args.REFERENCE; return this._device.distance > args.REFERENCE;
default: default:
log.warn(`Unknown comparison operator in whenDistance: ${args.OP}`); log.warn(`Unknown comparison operator in whenDistance: ${args.OP}`);
@ -597,7 +765,7 @@ class Scratch3WeDo2Blocks {
* @return {number} - the distance sensor's value, scaled to the [0,100] range. * @return {number} - the distance sensor's value, scaled to the [0,100] range.
*/ */
getDistance () { getDistance () {
return this._device.distance * 10; return this._device.distance;
} }
/** /**

View file

@ -1,6 +1,59 @@
const StringUtil = require('../util/string-util'); const StringUtil = require('../util/string-util');
const log = require('../util/log'); const log = require('../util/log');
/**
* Initialize a costume from an asset asynchronously.
* Do not call this unless there is a renderer attached.
* @param {!object} costume - the Scratch costume object.
* @property {int} skinId - the ID of the costume's render skin, once installed.
* @property {number} rotationCenterX - the X component of the costume's origin.
* @property {number} rotationCenterY - the Y component of the costume's origin.
* @property {number} [bitmapResolution] - the resolution scale for a bitmap costume.
* @param {!Asset} costumeAsset - the asset of the costume loaded from storage.
* @param {!Runtime} runtime - Scratch runtime, used to access the storage module.
* @returns {?Promise} - a promise which will resolve after skinId is set, or null on error.
*/
const loadCostumeFromAsset = function (costume, costumeAsset, runtime) {
costume.assetId = costumeAsset.assetId;
if (!runtime.renderer) {
log.error('No rendering module present; cannot load costume: ', costume.name);
return costume;
}
const AssetType = runtime.storage.AssetType;
const rotationCenter = [
costume.rotationCenterX / costume.bitmapResolution,
costume.rotationCenterY / costume.bitmapResolution
];
if (costumeAsset.assetType === AssetType.ImageVector) {
costume.skinId = runtime.renderer.createSVGSkin(costumeAsset.decodeText(), rotationCenter);
return costume;
}
return new Promise((resolve, reject) => {
const imageElement = new Image();
const onError = function () {
// eslint-disable-next-line no-use-before-define
removeEventListeners();
reject();
};
const onLoad = function () {
// eslint-disable-next-line no-use-before-define
removeEventListeners();
resolve(imageElement);
};
const removeEventListeners = function () {
imageElement.removeEventListener('error', onError);
imageElement.removeEventListener('load', onLoad);
};
imageElement.addEventListener('error', onError);
imageElement.addEventListener('load', onLoad);
imageElement.src = costumeAsset.encodeDataURI();
}).then(imageElement => {
costume.skinId = runtime.renderer.createBitmapSkin(imageElement, costume.bitmapResolution, rotationCenter);
return costume;
});
};
/** /**
* Load a costume's asset into memory asynchronously. * Load a costume's asset into memory asynchronously.
* Do not call this unless there is a renderer attached. * Do not call this unless there is a renderer attached.
@ -25,55 +78,107 @@ const loadCostume = function (md5ext, costume, runtime) {
const ext = idParts[1].toLowerCase(); const ext = idParts[1].toLowerCase();
const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap; const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap;
return runtime.storage.load(assetType, md5, ext).then(costumeAsset => {
costume.dataFormat = ext;
return loadCostumeFromAsset(costume, costumeAsset, runtime);
});
};
/**
* Load an "old text" costume's asset into memory asynchronously.
* "Old text" costumes are ones who have a text part from Scratch 1.4.
* See the issue LLK/scratch-vm#672 for more information.
* Do not call this unless there is a renderer attached.
* @param {string} baseMD5ext - the MD5 and extension of the base layer of the costume to be loaded.
* @param {string} textMD5ext - the MD5 and extension of the text layer of the costume to be loaded.
* @param {!object} costume - the Scratch costume object.
* @property {int} skinId - the ID of the costume's render skin, once installed.
* @property {number} rotationCenterX - the X component of the costume's origin.
* @property {number} rotationCenterY - the Y component of the costume's origin.
* @property {number} [bitmapResolution] - the resolution scale for a bitmap costume.
* @param {!Runtime} runtime - Scratch runtime, used to access the storage module.
* @returns {?Promise} - a promsie which will resolve after skinId is set, or null on error.
*/
const loadOldTextCostume = function(baseMD5ext, textMD5ext, costume, runtime) {
// @todo should [bitmapResolution] (in the documentation comment) not be optional? After all, the resulting image is always a bitmap.
if (!runtime.storage) {
log.error('No storage module present; cannot load costume asset: ', baseMD5ext, textMD5ext);
return Promise.resolve(costume);
}
const [baseMD5, baseExt] = StringUtil.splitFirst(baseMD5ext, '.');
const [textMD5, textExt] = StringUtil.splitFirst(textMD5ext, '.');
if (baseExt === 'svg' || textExt === 'svg') {
log.error('Old text costumes should never be SVGs');
return Promise.resolve(costume);
}
const assetType = runtime.storage.AssetType.ImageBitmap;
// @todo should this be in a separate function, which could also be used by loadCostume?
const rotationCenter = [ const rotationCenter = [
costume.rotationCenterX / costume.bitmapResolution, costume.rotationCenterX / costume.bitmapResolution,
costume.rotationCenterY / costume.bitmapResolution costume.rotationCenterY / costume.bitmapResolution
]; ];
let promise = runtime.storage.load(assetType, md5, ext).then(costumeAsset => { // @todo what should the assetId be? Probably unset, since we'll be doing image processing (which will produce a completely new image)?
costume.assetId = costumeAsset.assetId; // @todo what about the dataFormat? This depends on how the image processing is implemented.
costume.dataFormat = ext;
return costumeAsset;
});
if (!runtime.renderer) { return Promise.all([
log.error('No rendering module present; cannot load costume asset: ', md5ext); runtime.storage.load(assetType, baseMD5, baseExt),
return promise.then(() => costume); runtime.storage.load(assetType, textMD5, textExt)
} ]).then(costumeAssets => (
if (assetType === AssetType.ImageVector) {
promise = promise.then(costumeAsset => {
costume.skinId = runtime.renderer.createSVGSkin(costumeAsset.decodeText(), rotationCenter);
return costume;
});
} else {
promise = promise.then(costumeAsset => (
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const imageElement = new Image(); const baseImageElement = new Image();
const textImageElement = new Image();
let loadedOne = false;
const onError = function () { const onError = function () {
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-use-before-define
removeEventListeners(); removeEventListeners();
reject(); reject();
}; };
const onLoad = function () { const onLoad = function () {
// eslint-disable-next-line no-use-before-define if (loadedOne) {
removeEventListeners(); removeEventListeners();
resolve(imageElement); resolve([baseImageElement, textImageElement]);
} else {
loadedOne = true;
}
}; };
const removeEventListeners = function () { const removeEventListeners = function () {
imageElement.removeEventListener('error', onError); baseImageElement.removeEventListener('error', onError);
imageElement.removeEventListener('load', onLoad); textImageElement.removeEventListener('error', onError);
}; baseImageElement.removeEventListener('load', onLoad);
imageElement.addEventListener('error', onError); textImageElement.removeEventListener('load', onLoad);
imageElement.addEventListener('load', onLoad);
imageElement.src = costumeAsset.encodeDataURI();
})
)).then(imageElement => {
costume.skinId = runtime.renderer.createBitmapSkin(imageElement, costume.bitmapResolution, rotationCenter);
return costume;
});
}
return promise;
}; };
module.exports = loadCostume; baseImageElement.addEventListener('error', onError);
textImageElement.addEventListener('error', onError);
baseImageElement.addEventListener('load', onLoad);
textImageElement.addEventListener('load', onLoad);
const [baseAsset, textAsset] = costumeAssets;
baseImageElement.src = baseAsset.encodeDataURI();
textImageElement.src = textAsset.encodeDataURI();
})
)).then(imageElements => {
const [baseImageElement, textImageElement] = imageElements;
// @todo flatten the base and text images. The renderer should probably do the image processing that'll be needed here.
// The text part is currently displayed only for debugging.
costume.skinId = runtime.renderer.createBitmapSkin(textImageElement, costume.bitmapResolution, rotationCenter);
return costume;
});
};
module.exports = {
loadCostume,
loadCostumeFromAsset,
loadOldTextCostume
};

View file

@ -3,97 +3,4 @@
const StringUtil = require('../util/string-util'); const StringUtil = require('../util/string-util');
const log = require('../util/log'); const log = require('../util/log');
// @todo should [bitmapResolution] (in the documentation comment) not be optional? After all, the resulting image is always a bitmap.
/**
* Load an "old text" costume's asset into memory asynchronously.
* "Old text" costumes are ones who have a text part from Scratch 1.4.
* See the issue LLK/scratch-vm#672 for more information.
* Do not call this unless there is a renderer attached.
* @param {string} baseMD5ext - the MD5 and extension of the base layer of the costume to be loaded.
* @param {string} textMD5ext - the MD5 and extension of the text layer of the costume to be loaded.
* @param {!object} costume - the Scratch costume object.
* @property {int} skinId - the ID of the costume's render skin, once installed.
* @property {number} rotationCenterX - the X component of the costume's origin.
* @property {number} rotationCenterY - the Y component of the costume's origin.
* @property {number} [bitmapResolution] - the resolution scale for a bitmap costume.
* @param {!Runtime} runtime - Scratch runtime, used to access the storage module.
* @returns {?Promise} - a promsie which will resolve after skinId is set, or null on error.
*/
const loadOldTextCostume = function(baseMD5ext, textMD5ext, costume, runtime) {
if (!runtime.storage) {
log.error('No storage module present; cannot load costume asset: ', baseMD5ext, textMD5ext);
return Promise.resolve(costume);
}
const [baseMD5, baseExt] = StringUtil.splitFirst(baseMD5ext, '.');
const [textMD5, textExt] = StringUtil.splitFirst(textMD5ext, '.');
if (baseExt === 'svg' || textExt === 'svg') {
log.error('Old text costumes should never be SVGs');
return Promise.resolve(costume);
}
const assetType = runtime.storage.AssetType.ImageBitmap;
// @todo should this be in a separate function, which could also be used by loadCostume?
const rotationCenter = [
costume.rotationCenterX / costume.bitmapResolution,
costume.rotationCenterY / costume.bitmapResolution
];
// @todo what should the assetId be? Probably unset, since we'll be doing image processing (which will produce a completely new image)?
// @todo what about the dataFormat? This depends on how the image processing is implemented.
return Promise.all([
runtime.storage.load(assetType, baseMD5, baseExt),
runtime.storage.load(assetType, textMD5, textExt)
]).then(costumeAssets => (
new Promise((resolve, reject) => {
const baseImageElement = new Image();
const textImageElement = new Image();
let loadedOne = false;
const onError = function () {
// eslint-disable-next-line no-use-before-define
removeEventListeners();
reject();
};
const onLoad = function () {
if (loadedOne) {
removeEventListeners();
resolve([baseImageElement, textImageElement]);
} else {
loadedOne = true;
}
};
const removeEventListeners = function () {
baseImageElement.removeEventListener('error', onError);
textImageElement.removeEventListener('error', onError);
baseImageElement.removeEventListener('load', onLoad);
textImageElement.removeEventListener('load', onLoad);
};
baseImageElement.addEventListener('error', onError);
textImageElement.addEventListener('error', onError);
baseImageElement.addEventListener('load', onLoad);
textImageElement.addEventListener('load', onLoad);
const [baseAsset, textAsset] = costumeAssets;
baseImageElement.src = baseAsset.encodeDataURI();
textImageElement.src = textAsset.encodeDataURI();
})
)).then(imageElements => {
const [baseImageElement, textImageElement] = imageElements;
// @todo flatten the base and text images. The renderer should probably do the image processing that'll be needed here.
// The text part is currently displayed only for debugging.
costume.skinId = runtime.renderer.createBitmapSkin(textImageElement, costume.bitmapResolution, rotationCenter);
return costume;
});
};
module.exports = loadOldTextCostume; module.exports = loadOldTextCostume;

View file

@ -1,6 +1,27 @@
const StringUtil = require('../util/string-util'); const StringUtil = require('../util/string-util');
const log = require('../util/log'); const log = require('../util/log');
/**
* Initialize a sound from an asset asynchronously.
* @param {!object} sound - the Scratch sound object.
* @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 {!Asset} soundAsset - the asset loaded from storage.
* @param {!Runtime} runtime - Scratch runtime, used to access the storage module.
* @returns {!Promise} - a promise which will resolve to the sound when ready.
*/
const loadSoundFromAsset = function (sound, soundAsset, runtime) {
sound.assetId = soundAsset.assetId;
return runtime.audioEngine.decodeSound(Object.assign(
{},
sound,
{data: soundAsset.data}
)).then(soundId => {
sound.soundId = soundId;
return sound;
});
};
/** /**
* Load a sound's asset into memory asynchronously. * Load a sound's asset into memory asynchronously.
* @param {!object} sound - the Scratch sound object. * @param {!object} sound - the Scratch sound object.
@ -23,18 +44,12 @@ const loadSound = function (sound, runtime) {
const ext = idParts[1].toLowerCase(); const ext = idParts[1].toLowerCase();
return runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext) return runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
.then(soundAsset => { .then(soundAsset => {
sound.assetId = soundAsset.assetId;
sound.dataFormat = ext; sound.dataFormat = ext;
return runtime.audioEngine.decodeSound(Object.assign( return loadSoundFromAsset(sound, soundAsset, runtime);
{},
sound,
{data: soundAsset.data}
));
})
.then(soundId => {
sound.soundId = soundId;
return sound;
}); });
}; };
module.exports = loadSound; module.exports = {
loadSound,
loadSoundFromAsset
};

View file

@ -1,4 +1,4 @@
const got = require('got'); const nets = require('nets');
const io = require('socket.io-client/dist/socket.io'); const io = require('socket.io-client/dist/socket.io');
const querystring = require('querystring'); const querystring = require('querystring');
@ -312,7 +312,17 @@ class DeviceManager {
}; };
if (deviceSpec) queryObject.spec = deviceSpec; if (deviceSpec) queryObject.spec = deviceSpec;
const url = `${this._serverURL}/${encodeURIComponent(deviceType)}/list?${querystring.stringify(queryObject)}`; const url = `${this._serverURL}/${encodeURIComponent(deviceType)}/list?${querystring.stringify(queryObject)}`;
return got(url).then(response => JSON.parse(response.body)); return new Promise((resolve, reject) => {
nets({
method: 'GET',
url: url,
json: {}
}, (err, res, body) => {
if (err) return reject(err);
if (res.statusCode !== 200) return reject(body);
resolve(body);
});
});
} }
/** /**

View file

@ -40,10 +40,20 @@ class Mouse {
*/ */
postData (data) { postData (data) {
if (data.x) { if (data.x) {
this._x = data.x - (data.canvasWidth / 2); this._clientX = data.x;
this._scratchX = MathUtil.clamp(
480 * ((data.x / data.canvasWidth) - 0.5),
-240,
240
);
} }
if (data.y) { if (data.y) {
this._y = data.y - (data.canvasHeight / 2); this._clientY = data.y;
this._scratchY = MathUtil.clamp(
-360 * ((data.y / data.canvasHeight) - 0.5),
-180,
180
);
} }
if (typeof data.isDown !== 'undefined') { if (typeof data.isDown !== 'undefined') {
this._isDown = data.isDown; this._isDown = data.isDown;
@ -54,19 +64,35 @@ class Mouse {
} }
/** /**
* Get the X position of the mouse. * Get the X position of the mouse in client coordinates.
* @return {number} Clamped X position of the mouse cursor. * @return {number} Non-clamped X position of the mouse cursor.
*/ */
getX () { getClientX () {
return MathUtil.clamp(this._x, -240, 240); return this._clientX;
} }
/** /**
* Get the Y position of the mouse. * Get the Y position of the mouse in client coordinates.
* @return {number} Non-clamped Y position of the mouse cursor.
*/
getClientY () {
return this._clientY;
}
/**
* Get the X position of the mouse in scratch coordinates.
* @return {number} Clamped X position of the mouse cursor.
*/
getScratchX () {
return this._scratchX;
}
/**
* Get the Y position of the mouse in scratch coordinates.
* @return {number} Clamped Y position of the mouse cursor. * @return {number} Clamped Y position of the mouse cursor.
*/ */
getY () { getScratchY () {
return MathUtil.clamp(-this._y, -180, 180); return this._scratchY;
} }
/** /**

Some files were not shown because too many files have changed in this diff Show more