mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-25 07:22:33 -05:00
Merge branch 'develop' of https://github.com/LLK/scratch-vm into old-text-costumes
This commit is contained in:
commit
287222f7ed
141 changed files with 7600 additions and 1757 deletions
|
@ -2,3 +2,4 @@ coverage/*
|
|||
dist/*
|
||||
node_modules/*
|
||||
playground/*
|
||||
benchmark/*
|
||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -2,6 +2,7 @@
|
|||
.DS_Store
|
||||
|
||||
# NPM
|
||||
package-lock.json
|
||||
/node_modules
|
||||
npm-*
|
||||
|
||||
|
@ -15,3 +16,7 @@ npm-*
|
|||
# Build
|
||||
/dist
|
||||
/playground
|
||||
/benchmark
|
||||
|
||||
# Localization
|
||||
/translations
|
||||
|
|
|
@ -5,13 +5,14 @@ node_js:
|
|||
env:
|
||||
- NPM_SCRIPT="tap:unit -- --jobs=4"
|
||||
- NPM_SCRIPT="tap:integration -- --jobs=4"
|
||||
- NODE_ENV=production
|
||||
sudo: false
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
install:
|
||||
- npm install
|
||||
- npm update
|
||||
- npm --production=false install
|
||||
- npm --production=false update
|
||||
script: npm run $NPM_SCRIPT
|
||||
jobs:
|
||||
include:
|
||||
|
|
8
.tx/config
Normal file
8
.tx/config
Normal 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
|
51
package.json
51
package.json
|
@ -10,19 +10,22 @@
|
|||
"url": "git+ssh://git@github.com/LLK/scratch-vm.git"
|
||||
},
|
||||
"main": "./dist/node/scratch-vm.js",
|
||||
"browser": "./dist/web/scratch-vm.js",
|
||||
"scripts": {
|
||||
"build": "./node_modules/.bin/webpack --progress --colors --bail",
|
||||
"coverage": "./node_modules/.bin/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)\"",
|
||||
"lint": "./node_modules/.bin/eslint .",
|
||||
"build": "webpack --progress --colors --bail",
|
||||
"coverage": "tap ./test/{unit,integration}/*.js --coverage --coverage-report=lcov",
|
||||
"deploy": "touch playground/.nojekyll && gh-pages -t -d playground -m \"Build for $(git log --pretty=format:%H -n1)\"",
|
||||
"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",
|
||||
"start": "./node_modules/.bin/webpack-dev-server",
|
||||
"tap": "./node_modules/.bin/tap ./test/{unit,integration}/*.js",
|
||||
"tap:unit": "./node_modules/.bin/tap ./test/unit/*.js",
|
||||
"tap:integration": "./node_modules/.bin/tap ./test/integration/*.js",
|
||||
"start": "webpack-dev-server",
|
||||
"tap": "tap ./test/{unit,integration}/*.js",
|
||||
"tap:unit": "tap ./test/unit/*.js",
|
||||
"tap:integration": "tap ./test/integration/*.js",
|
||||
"test": "npm run lint && npm run tap",
|
||||
"watch": "./node_modules/.bin/webpack --progress --colors --watch",
|
||||
"version": "./node_modules/.bin/json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\""
|
||||
"watch": "webpack --progress --colors --watch",
|
||||
"version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"adm-zip": "0.4.7",
|
||||
|
@ -30,12 +33,15 @@
|
|||
"babel-eslint": "^7.1.1",
|
||||
"babel-loader": "^7.0.0",
|
||||
"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-config-scratch": "^4.0.0",
|
||||
"expose-loader": "0.7.3",
|
||||
"gh-pages": "^0.12.0",
|
||||
"got": "5.7.1",
|
||||
"eslint-config-scratch": "^5.0.0",
|
||||
"expose-loader": "0.7.4",
|
||||
"format-message": "5.2.1",
|
||||
"format-message-cli": "5.2.1",
|
||||
"gh-pages": "^1.1.0",
|
||||
"highlightjs": "^9.8.0",
|
||||
"htmlparser2": "3.9.2",
|
||||
"immutable": "3.8.1",
|
||||
|
@ -43,17 +49,20 @@
|
|||
"json": "^9.0.4",
|
||||
"lodash.defaultsdeep": "4.6.0",
|
||||
"minilog": "3.1.0",
|
||||
"promise": "7.1.1",
|
||||
"nets": "3.2.0",
|
||||
"promise": "8.0.1",
|
||||
"scratch-audio": "latest",
|
||||
"scratch-blocks": "latest",
|
||||
"scratch-render": "latest",
|
||||
"scratch-storage": "^0.2.0",
|
||||
"script-loader": "0.7.0",
|
||||
"socket.io-client": "1.7.3",
|
||||
"scratch-storage": "^0.3.0",
|
||||
"script-loader": "0.7.2",
|
||||
"socket.io-client": "2.0.4",
|
||||
"stats.js": "^0.17.0",
|
||||
"tap": "^10.2.0",
|
||||
"text-encoding": "0.6.4",
|
||||
"tiny-worker": "^2.1.1",
|
||||
"webpack": "^2.4.1",
|
||||
"webpack-dev-server": "^2.4.1"
|
||||
"webpack": "^3.10.0",
|
||||
"webpack-dev-server": "^2.4.1",
|
||||
"worker-loader": "1.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,30 +30,34 @@ class Scratch3DataBlocks {
|
|||
}
|
||||
|
||||
getVariable (args, util) {
|
||||
const variable = util.target.lookupOrCreateVariable(args.VARIABLE);
|
||||
const variable = util.target.lookupOrCreateVariable(
|
||||
args.VARIABLE.id, args.VARIABLE.name);
|
||||
return variable.value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 dValue = Cast.toNumber(args.VALUE);
|
||||
variable.value = castedValue + dValue;
|
||||
}
|
||||
|
||||
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.
|
||||
// If it is, report contents joined together with no separator.
|
||||
// If it's not, report contents joined together with a space.
|
||||
let allSingleLetters = true;
|
||||
for (let i = 0; i < list.contents.length; i++) {
|
||||
const listItem = list.contents[i];
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
const listItem = list.value[i];
|
||||
if (!((typeof listItem === 'string') &&
|
||||
(listItem.length === 1))) {
|
||||
allSingleLetters = false;
|
||||
|
@ -61,73 +65,80 @@ class Scratch3DataBlocks {
|
|||
}
|
||||
}
|
||||
if (allSingleLetters) {
|
||||
return list.contents.join('');
|
||||
return list.value.join('');
|
||||
}
|
||||
return list.contents.join(' ');
|
||||
return list.value.join(' ');
|
||||
|
||||
}
|
||||
|
||||
addToList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(args.LIST);
|
||||
list.contents.push(args.ITEM);
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
list.value.push(args.ITEM);
|
||||
}
|
||||
|
||||
deleteOfList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(args.LIST);
|
||||
const index = Cast.toListIndex(args.INDEX, list.contents.length);
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return;
|
||||
} else if (index === Cast.LIST_ALL) {
|
||||
list.contents = [];
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
list.contents.splice(index - 1, 1);
|
||||
list.value.splice(index - 1, 1);
|
||||
}
|
||||
|
||||
insertAtList (args, util) {
|
||||
const item = args.ITEM;
|
||||
const list = util.target.lookupOrCreateList(args.LIST);
|
||||
const index = Cast.toListIndex(args.INDEX, list.contents.length + 1);
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length + 1);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return;
|
||||
}
|
||||
list.contents.splice(index - 1, 0, item);
|
||||
list.value.splice(index - 1, 0, item);
|
||||
}
|
||||
|
||||
replaceItemOfList (args, util) {
|
||||
const item = args.ITEM;
|
||||
const list = util.target.lookupOrCreateList(args.LIST);
|
||||
const index = Cast.toListIndex(args.INDEX, list.contents.length);
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return;
|
||||
}
|
||||
list.contents.splice(index - 1, 1, item);
|
||||
list.value.splice(index - 1, 1, item);
|
||||
}
|
||||
|
||||
getItemOfList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(args.LIST);
|
||||
const index = Cast.toListIndex(args.INDEX, list.contents.length);
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
const index = Cast.toListIndex(args.INDEX, list.value.length);
|
||||
if (index === Cast.LIST_INVALID) {
|
||||
return '';
|
||||
}
|
||||
return list.contents[index - 1];
|
||||
return list.value[index - 1];
|
||||
}
|
||||
|
||||
lengthOfList (args, util) {
|
||||
const list = util.target.lookupOrCreateList(args.LIST);
|
||||
return list.contents.length;
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
return list.value.length;
|
||||
}
|
||||
|
||||
listContainsItem (args, util) {
|
||||
const item = args.ITEM;
|
||||
const list = util.target.lookupOrCreateList(args.LIST);
|
||||
if (list.contents.indexOf(item) >= 0) {
|
||||
const list = util.target.lookupOrCreateList(
|
||||
args.LIST.id, args.LIST.name);
|
||||
if (list.value.indexOf(item) >= 0) {
|
||||
return true;
|
||||
}
|
||||
// Try using Scratch comparison operator on each item.
|
||||
// (Scratch considers the string '123' equal to the number 123).
|
||||
for (let i = 0; i < list.contents.length; i++) {
|
||||
if (Cast.compare(list.contents[i], item) === 0) {
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
if (Cast.compare(list.value[i], item) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,32 +56,40 @@ class Scratch3EventBlocks {
|
|||
}
|
||||
|
||||
broadcast (args, util) {
|
||||
const broadcastOption = Cast.toString(args.BROADCAST_OPTION);
|
||||
util.startHats('event_whenbroadcastreceived', {
|
||||
BROADCAST_OPTION: broadcastOption
|
||||
});
|
||||
const broadcastVar = util.runtime.getTargetForStage().lookupBroadcastMsg(
|
||||
args.BROADCAST_OPTION.id, args.BROADCAST_OPTION.name);
|
||||
if (broadcastVar) {
|
||||
const broadcastOption = broadcastVar.name;
|
||||
util.startHats('event_whenbroadcastreceived', {
|
||||
BROADCAST_OPTION: broadcastOption
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
broadcastAndWait (args, util) {
|
||||
const broadcastOption = Cast.toString(args.BROADCAST_OPTION);
|
||||
// Have we run before, starting threads?
|
||||
if (!util.stackFrame.startedThreads) {
|
||||
// No - start hats for this broadcast.
|
||||
util.stackFrame.startedThreads = util.startHats(
|
||||
'event_whenbroadcastreceived', {
|
||||
BROADCAST_OPTION: broadcastOption
|
||||
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?
|
||||
if (!util.stackFrame.startedThreads) {
|
||||
// No - start hats for this broadcast.
|
||||
util.stackFrame.startedThreads = util.startHats(
|
||||
'event_whenbroadcastreceived', {
|
||||
BROADCAST_OPTION: broadcastOption
|
||||
}
|
||||
);
|
||||
if (util.stackFrame.startedThreads.length === 0) {
|
||||
// Nothing was started.
|
||||
return;
|
||||
}
|
||||
);
|
||||
if (util.stackFrame.startedThreads.length === 0) {
|
||||
// Nothing was started.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// We've run before; check if the wait is still going on.
|
||||
const instance = this;
|
||||
const waiting = util.stackFrame.startedThreads.some(thread => instance.runtime.isActiveThread(thread));
|
||||
if (waiting) {
|
||||
util.yield();
|
||||
// We've run before; check if the wait is still going on.
|
||||
const instance = this;
|
||||
const waiting = util.stackFrame.startedThreads.some(thread => instance.runtime.isActiveThread(thread));
|
||||
if (waiting) {
|
||||
util.yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,16 @@
|
|||
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 {
|
||||
constructor (runtime) {
|
||||
|
@ -7,6 +19,196 @@ class Scratch3LooksBlocks {
|
|||
* @type {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_sayforsecs: this.sayforsecs,
|
||||
looks_think: this.think,
|
||||
looks_thinkforsecs: this.sayforsecs,
|
||||
looks_thinkforsecs: this.thinkforsecs,
|
||||
looks_show: this.show,
|
||||
looks_hide: this.hide,
|
||||
looks_switchcostumeto: this.switchCostume,
|
||||
|
@ -31,40 +233,52 @@ class Scratch3LooksBlocks {
|
|||
looks_cleargraphiceffects: this.clearEffects,
|
||||
looks_changesizeby: this.changeSize,
|
||||
looks_setsizeto: this.setSize,
|
||||
looks_gotofront: this.goToFront,
|
||||
looks_gobacklayers: this.goBackLayers,
|
||||
looks_gotofrontback: this.goToFrontBack,
|
||||
looks_goforwardbackwardlayers: this.goForwardBackwardLayers,
|
||||
looks_size: this.getSize,
|
||||
looks_costumeorder: this.getCostumeIndex,
|
||||
looks_backdroporder: this.getBackdropIndex,
|
||||
looks_backdropname: this.getBackdropName
|
||||
looks_costumenumbername: this.getCostumeNumberName,
|
||||
looks_backdropnumbername: this.getBackdropNumberName
|
||||
};
|
||||
}
|
||||
|
||||
getMonitored () {
|
||||
return {
|
||||
looks_size: {isSpriteSpecific: true},
|
||||
looks_costumenumbername: {isSpriteSpecific: true},
|
||||
looks_backdropnumbername: {}
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
util.target.setSay('say', args.MESSAGE);
|
||||
this.say(args, util);
|
||||
const _target = util.target;
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
this._bubbleTimeout = setTimeout(() => {
|
||||
this._bubbleTimeout = null;
|
||||
// Clear say bubble and proceed.
|
||||
util.target.setSay();
|
||||
this._updateBubble(_target, 'say', '');
|
||||
resolve();
|
||||
}, 1000 * args.SECS);
|
||||
});
|
||||
}
|
||||
|
||||
think (args, util) {
|
||||
util.target.setSay('think', args.MESSAGE);
|
||||
this._updateBubble(util.target, 'think', String(args.MESSAGE));
|
||||
}
|
||||
|
||||
thinkforsecs (args, util) {
|
||||
util.target.setSay('think', args.MESSAGE);
|
||||
this.think(args, util);
|
||||
const _target = util.target;
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
this._bubbleTimeout = setTimeout(() => {
|
||||
this._bubbleTimeout = null;
|
||||
// Clear say bubble and proceed.
|
||||
util.target.setSay();
|
||||
this._updateBubble(_target, 'think', '');
|
||||
resolve();
|
||||
}, 1000 * args.SECS);
|
||||
});
|
||||
|
@ -72,10 +286,12 @@ class Scratch3LooksBlocks {
|
|||
|
||||
show (args, util) {
|
||||
util.target.setVisible(true);
|
||||
this._renderBubble(util.target);
|
||||
}
|
||||
|
||||
hide (args, util) {
|
||||
util.target.setVisible(false);
|
||||
this._renderBubble(util.target);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -191,30 +407,45 @@ class Scratch3LooksBlocks {
|
|||
util.target.setSize(size);
|
||||
}
|
||||
|
||||
goToFront (args, util) {
|
||||
util.target.goToFront();
|
||||
goToFrontBack (args, util) {
|
||||
if (!util.target.isStage) {
|
||||
if (args.FRONT_BACK === 'front') {
|
||||
util.target.goToFront();
|
||||
} else {
|
||||
util.target.goToBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goBackLayers (args, util) {
|
||||
util.target.goBackLayers(args.NUM);
|
||||
goForwardBackwardLayers (args, util) {
|
||||
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) {
|
||||
return Math.round(util.target.size);
|
||||
}
|
||||
|
||||
getBackdropIndex () {
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
return stage.currentCostume + 1;
|
||||
}
|
||||
|
||||
getBackdropName () {
|
||||
getBackdropNumberName (args) {
|
||||
const stage = this.runtime.getTargetForStage();
|
||||
if (args.NUMBER_NAME === 'number') {
|
||||
return stage.currentCostume + 1;
|
||||
}
|
||||
// Else return name
|
||||
return stage.sprite.costumes[stage.currentCostume].name;
|
||||
}
|
||||
|
||||
getCostumeIndex (args, util) {
|
||||
return util.target.currentCostume + 1;
|
||||
getCostumeNumberName (args, util) {
|
||||
if (args.NUMBER_NAME === 'number') {
|
||||
return util.target.currentCostume + 1;
|
||||
}
|
||||
// Else return name
|
||||
return util.target.sprite.costumes[util.target.currentCostume].name;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,6 +38,14 @@ class Scratch3MotionBlocks {
|
|||
};
|
||||
}
|
||||
|
||||
getMonitored () {
|
||||
return {
|
||||
motion_xposition: {isSpriteSpecific: true},
|
||||
motion_yposition: {isSpriteSpecific: true},
|
||||
motion_direction: {isSpriteSpecific: true}
|
||||
};
|
||||
}
|
||||
|
||||
moveSteps (args, util) {
|
||||
const steps = Cast.toNumber(args.STEPS);
|
||||
const radians = MathUtil.degToRad(90 - util.target.direction);
|
||||
|
@ -56,8 +64,8 @@ class Scratch3MotionBlocks {
|
|||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
if (targetName === '_mouse_') {
|
||||
targetX = util.ioQuery('mouse', 'getX');
|
||||
targetY = util.ioQuery('mouse', 'getY');
|
||||
targetX = util.ioQuery('mouse', 'getScratchX');
|
||||
targetY = util.ioQuery('mouse', 'getScratchY');
|
||||
} else if (targetName === '_random_') {
|
||||
const stageWidth = this.runtime.constructor.STAGE_WIDTH;
|
||||
const stageHeight = this.runtime.constructor.STAGE_HEIGHT;
|
||||
|
@ -98,8 +106,8 @@ class Scratch3MotionBlocks {
|
|||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
if (args.TOWARDS === '_mouse_') {
|
||||
targetX = util.ioQuery('mouse', 'getX');
|
||||
targetY = util.ioQuery('mouse', 'getY');
|
||||
targetX = util.ioQuery('mouse', 'getScratchX');
|
||||
targetY = util.ioQuery('mouse', 'getScratchY');
|
||||
} else {
|
||||
const pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS);
|
||||
if (!pointTarget) return;
|
||||
|
@ -147,7 +155,7 @@ class Scratch3MotionBlocks {
|
|||
util.yield();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
glideTo (args, util) {
|
||||
const targetXY = this.getTargetXY(args.TO, util);
|
||||
if (targetXY) {
|
||||
|
|
|
@ -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;
|
|
@ -13,32 +13,55 @@ class Scratch3ProcedureBlocks {
|
|||
*/
|
||||
getPrimitives () {
|
||||
return {
|
||||
procedures_defnoreturn: this.defNoReturn,
|
||||
procedures_callnoreturn: this.callNoReturn,
|
||||
procedures_param: this.param
|
||||
procedures_definition: this.definition,
|
||||
procedures_call: this.call,
|
||||
argument_reporter_string_number: this.argumentReporterStringNumber,
|
||||
argument_reporter_boolean: this.argumentReporterBoolean
|
||||
};
|
||||
}
|
||||
|
||||
defNoReturn () {
|
||||
definition () {
|
||||
// No-op: execute the blocks.
|
||||
}
|
||||
|
||||
callNoReturn (args, util) {
|
||||
call (args, util) {
|
||||
if (!util.stackFrame.executed) {
|
||||
const procedureCode = args.mutation.proccode;
|
||||
const paramNames = util.getProcedureParamNames(procedureCode);
|
||||
for (let i = 0; i < paramNames.length; i++) {
|
||||
if (args.hasOwnProperty(`input${i}`)) {
|
||||
util.pushParam(paramNames[i], args[`input${i}`]);
|
||||
const paramNamesAndIds = util.getProcedureParamNamesAndIds(procedureCode);
|
||||
|
||||
// If null, procedure could not be found, which can happen if custom
|
||||
// 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.startProcedure(procedureCode);
|
||||
}
|
||||
}
|
||||
|
||||
param (args, util) {
|
||||
const value = util.getParam(args.mutation.paramname);
|
||||
argumentReporterStringNumber (args, util) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,22 @@ class Scratch3SensingBlocks {
|
|||
* @type {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_mousex: this.getMouseX,
|
||||
sensing_mousey: this.getMouseY,
|
||||
sensing_setdragmode: this.setDragMode,
|
||||
sensing_mousedown: this.getMouseDown,
|
||||
sensing_keypressed: this.getKeyPressed,
|
||||
sensing_current: this.current,
|
||||
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) {
|
||||
const requestedObject = args.TOUCHINGOBJECTMENU;
|
||||
if (requestedObject === '_mouse_') {
|
||||
const mouseX = util.ioQuery('mouse', 'getX');
|
||||
const mouseY = util.ioQuery('mouse', 'getY');
|
||||
const mouseX = util.ioQuery('mouse', 'getClientX');
|
||||
const mouseY = util.ioQuery('mouse', 'getClientY');
|
||||
return util.target.isTouchingPoint(mouseX, mouseY);
|
||||
} else if (requestedObject === '_edge_') {
|
||||
return util.target.isTouchingEdge();
|
||||
|
@ -62,8 +146,8 @@ class Scratch3SensingBlocks {
|
|||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
if (args.DISTANCETOMENU === '_mouse_') {
|
||||
targetX = util.ioQuery('mouse', 'getX');
|
||||
targetY = util.ioQuery('mouse', 'getY');
|
||||
targetX = util.ioQuery('mouse', 'getScratchX');
|
||||
targetY = util.ioQuery('mouse', 'getScratchY');
|
||||
} else {
|
||||
const distTarget = this.runtime.getSpriteTargetByName(
|
||||
args.DISTANCETOMENU
|
||||
|
@ -78,6 +162,10 @@ class Scratch3SensingBlocks {
|
|||
return Math.sqrt((dx * dx) + (dy * dy));
|
||||
}
|
||||
|
||||
setDragMode (args, util) {
|
||||
util.target.setDraggable(args.DRAG_MODE === 'draggable');
|
||||
}
|
||||
|
||||
getTimer (args, util) {
|
||||
return util.ioQuery('clock', 'projectTimer');
|
||||
}
|
||||
|
@ -87,11 +175,11 @@ class Scratch3SensingBlocks {
|
|||
}
|
||||
|
||||
getMouseX (args, util) {
|
||||
return util.ioQuery('mouse', 'getX');
|
||||
return util.ioQuery('mouse', 'getScratchX');
|
||||
}
|
||||
|
||||
getMouseY (args, util) {
|
||||
return util.ioQuery('mouse', 'getY');
|
||||
return util.ioQuery('mouse', 'getScratchY');
|
||||
}
|
||||
|
||||
getMouseDown (args, util) {
|
||||
|
@ -167,8 +255,10 @@ class Scratch3SensingBlocks {
|
|||
|
||||
// Variables
|
||||
const varName = args.PROPERTY;
|
||||
if (attrTarget.variables.hasOwnProperty(varName)) {
|
||||
return attrTarget.variables[varName].value;
|
||||
for (const id in attrTarget.variables) {
|
||||
if (attrTarget.variables[id].name === varName) {
|
||||
return attrTarget.variables[id].value;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, 0
|
||||
|
|
|
@ -9,6 +9,13 @@ class Scratch3SoundBlocks {
|
|||
* @type {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 () {
|
||||
return {
|
||||
volume: 100,
|
||||
currentInstrument: 0,
|
||||
effects: {
|
||||
pitch: 0,
|
||||
pan: 0
|
||||
|
@ -91,10 +97,6 @@ class Scratch3SoundBlocks {
|
|||
sound_play: this.playSound,
|
||||
sound_playuntildone: this.playSoundAndWait,
|
||||
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_changeeffectby: this.changeEffect,
|
||||
sound_cleareffects: this.clearEffects,
|
||||
|
@ -103,10 +105,13 @@ class Scratch3SoundBlocks {
|
|||
sound_effects_menu: this.effectsMenu,
|
||||
sound_setvolumeto: this.setVolume,
|
||||
sound_changevolumeby: this.changeVolume,
|
||||
sound_volume: this.getVolume,
|
||||
sound_settempotobpm: this.setTempo,
|
||||
sound_changetempoby: this.changeTempo,
|
||||
sound_tempo: this.getTempo
|
||||
sound_volume: this.getVolume
|
||||
};
|
||||
}
|
||||
|
||||
getMonitored () {
|
||||
return {
|
||||
sound_volume: {}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -162,53 +167,17 @@ class Scratch3SoundBlocks {
|
|||
return -1;
|
||||
}
|
||||
|
||||
stopAllSounds (args, util) {
|
||||
if (util.target.audioPlayer === null) return;
|
||||
util.target.audioPlayer.stopAllSounds();
|
||||
stopAllSounds () {
|
||||
if (this.runtime.targets === null) return;
|
||||
const allTargets = this.runtime.targets;
|
||||
for (let i = 0; i < allTargets.length; i++) {
|
||||
this._stopAllSoundsForTarget(allTargets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
playNoteForBeats (args, util) {
|
||||
let note = Cast.toNumber(args.NOTE);
|
||||
note = MathUtil.clamp(note, Scratch3SoundBlocks.MIDI_NOTE_RANGE.min, Scratch3SoundBlocks.MIDI_NOTE_RANGE.max);
|
||||
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);
|
||||
_stopAllSoundsForTarget (target) {
|
||||
if (target.audioPlayer === null) return;
|
||||
target.audioPlayer.stopAllSounds();
|
||||
}
|
||||
|
||||
setEffect (args, util) {
|
||||
|
@ -240,13 +209,25 @@ class Scratch3SoundBlocks {
|
|||
}
|
||||
|
||||
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) {
|
||||
if (!soundState.effects.hasOwnProperty(effect)) continue;
|
||||
soundState.effects[effect] = 0;
|
||||
}
|
||||
if (util.target.audioPlayer === null) return;
|
||||
util.target.audioPlayer.clearEffects();
|
||||
if (target.audioPlayer === null) return;
|
||||
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) {
|
||||
|
@ -273,29 +254,6 @@ class Scratch3SoundBlocks {
|
|||
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) {
|
||||
return args.SOUND_MENU;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
* @param {object} provider - send the call through this object's `postMessage` function.
|
||||
|
@ -115,6 +125,12 @@ class SharedDispatch {
|
|||
_remoteTransferCall (provider, service, method, transfer, ...args) {
|
||||
return new Promise((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) {
|
||||
provider.postMessage({service, method, responseId, args}, transfer);
|
||||
} else {
|
||||
|
|
|
@ -75,6 +75,10 @@ const domToBlock = function (blockDOM, blocks, isTopBlock, parent) {
|
|||
id: fieldId,
|
||||
value: fieldData
|
||||
};
|
||||
const fieldVarType = xmlChild.attribs.variabletype;
|
||||
if (typeof fieldVarType === 'string') {
|
||||
block.fields[fieldName].variableType = fieldVarType;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'value':
|
||||
|
@ -156,7 +160,7 @@ const adapter = function (e) {
|
|||
if (typeof e !== '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;
|
||||
|
|
153
src/engine/block-utility.js
Normal file
153
src/engine/block-utility.js
Normal 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;
|
|
@ -2,6 +2,8 @@ const adapter = require('./adapter');
|
|||
const mutationAdapter = require('./mutation-adapter');
|
||||
const xmlEscape = require('../util/xml-escape');
|
||||
const MonitorRecord = require('./monitor-record');
|
||||
const Clone = require('../util/clone');
|
||||
const {Map} = require('immutable');
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
|
@ -24,6 +26,30 @@ class Blocks {
|
|||
* @type {Array.<String>}
|
||||
*/
|
||||
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.
|
||||
* @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) {
|
||||
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) {
|
||||
// Ignore blocks prefixed with branch prefix.
|
||||
if (input.substring(0, Blocks.BRANCH_INPUT_PREFIX.length) !==
|
||||
|
@ -116,6 +147,8 @@ class Blocks {
|
|||
inputs[input] = block.inputs[input];
|
||||
}
|
||||
}
|
||||
|
||||
this._cache.inputs[block.id] = inputs;
|
||||
return inputs;
|
||||
}
|
||||
|
||||
|
@ -148,36 +181,60 @@ class Blocks {
|
|||
* @return {?string} ID of procedure definition.
|
||||
*/
|
||||
getProcedureDefinition (name) {
|
||||
const blockID = this._cache.procedureDefinitions[name];
|
||||
if (typeof blockID !== 'undefined') {
|
||||
return blockID;
|
||||
}
|
||||
|
||||
for (const id in this._blocks) {
|
||||
if (!this._blocks.hasOwnProperty(id)) continue;
|
||||
const block = this._blocks[id];
|
||||
if ((block.opcode === 'procedures_defnoreturn' ||
|
||||
block.opcode === 'procedures_defreturn') &&
|
||||
block.mutation.proccode === name) {
|
||||
return id;
|
||||
if (block.opcode === 'procedures_definition') {
|
||||
const internal = this._getCustomBlockInternal(block);
|
||||
if (internal && internal.mutation.proccode === name) {
|
||||
this._cache.procedureDefinitions[name] = id; // The outer define block id
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._cache.procedureDefinitions[name] = 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.
|
||||
* @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) {
|
||||
if (!this._blocks.hasOwnProperty(id)) continue;
|
||||
const block = this._blocks[id];
|
||||
if ((block.opcode === 'procedures_defnoreturn' ||
|
||||
block.opcode === 'procedures_defreturn') &&
|
||||
if (block.opcode === 'procedures_prototype' &&
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
optRuntime.quietGlow(e.blockId);
|
||||
}
|
||||
this.deleteBlock({
|
||||
id: e.blockId
|
||||
});
|
||||
this.deleteBlock(e.blockId);
|
||||
break;
|
||||
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;
|
||||
case 'var_rename':
|
||||
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
|
||||
* @param {!object} block Blockly create event to be processed
|
||||
|
@ -278,6 +355,8 @@ class Blocks {
|
|||
if (block.topLevel) {
|
||||
this._addScript(block.id);
|
||||
}
|
||||
|
||||
this.resetCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -290,13 +369,13 @@ class Blocks {
|
|||
if (['field', 'mutation', 'checkbox'].indexOf(args.element) === -1) return;
|
||||
const block = this._blocks[args.id];
|
||||
if (typeof block === 'undefined') return;
|
||||
|
||||
const wasMonitored = block.isMonitored;
|
||||
switch (args.element) {
|
||||
case 'field':
|
||||
// Update block value
|
||||
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.
|
||||
const variable = optRuntime.getEditingTarget().lookupVariableById(args.value);
|
||||
if (variable) {
|
||||
|
@ -304,20 +383,43 @@ class Blocks {
|
|||
block.fields[args.name].id = args.value;
|
||||
}
|
||||
} else {
|
||||
// Changing the value in a dropdown
|
||||
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;
|
||||
case 'mutation':
|
||||
block.mutation = mutationAdapter(args.value);
|
||||
break;
|
||||
case 'checkbox':
|
||||
case 'checkbox': {
|
||||
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);
|
||||
} else if (optRuntime && !wasMonitored && block.isMonitored) {
|
||||
} else if (!wasMonitored && block.isMonitored) {
|
||||
optRuntime.requestAddMonitor(MonitorRecord({
|
||||
// @todo(vm#564) this will collide if multiple sprites use same block
|
||||
id: block.id,
|
||||
targetId: block.targetId,
|
||||
spriteName: block.targetId ? optRuntime.getTargetById(block.targetId).getName() : null,
|
||||
opcode: block.opcode,
|
||||
params: this._getBlockParams(block),
|
||||
// @todo(vm#565) for numerical values with decimals, some countries use comma
|
||||
|
@ -326,6 +428,9 @@ class Blocks {
|
|||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.resetCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -382,6 +487,7 @@ class Blocks {
|
|||
}
|
||||
this._blocks[e.id].parent = e.newParent;
|
||||
}
|
||||
this.resetCache();
|
||||
}
|
||||
|
||||
|
||||
|
@ -392,45 +498,52 @@ class Blocks {
|
|||
runAllMonitored (runtime) {
|
||||
Object.keys(this._blocks).forEach(blockId => {
|
||||
if (this.getBlock(blockId).isMonitored) {
|
||||
// @todo handle specific targets (e.g. apple x position)
|
||||
runtime.addMonitorScript(blockId);
|
||||
const targetId = this.getBlock(blockId).targetId;
|
||||
runtime.addMonitorScript(blockId, targetId ? runtime.getTargetById(targetId) : null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Block management: delete blocks and their associated scripts.
|
||||
* @param {!object} e Blockly delete event to be processed.
|
||||
* Block management: delete blocks and their associated scripts. Does nothing if a block
|
||||
* 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.
|
||||
|
||||
// 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
|
||||
if (block.next !== null) {
|
||||
this.deleteBlock({id: block.next});
|
||||
this.deleteBlock(block.next);
|
||||
}
|
||||
|
||||
// Delete inputs (including branches)
|
||||
for (const input in block.inputs) {
|
||||
// If it's null, the block in this input moved away.
|
||||
if (block.inputs[input].block !== null) {
|
||||
this.deleteBlock({id: block.inputs[input].block});
|
||||
this.deleteBlock(block.inputs[input].block);
|
||||
}
|
||||
// Delete obscured shadow blocks.
|
||||
if (block.inputs[input].shadow !== null &&
|
||||
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.
|
||||
this._deleteScript(e.id);
|
||||
this._deleteScript(blockId);
|
||||
|
||||
// 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) {
|
||||
if (!block.fields.hasOwnProperty(field)) continue;
|
||||
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;
|
||||
if (typeof value === 'string') {
|
||||
value = xmlEscape(blockField.value);
|
||||
}
|
||||
xmlString += `<field name="${blockField.name}">${value}</field>`;
|
||||
xmlString += `>${value}</field>`;
|
||||
}
|
||||
// Add blocks connected to the next connection.
|
||||
if (block.next) {
|
||||
|
@ -540,6 +662,17 @@ class Blocks {
|
|||
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`.
|
||||
* @param {?string} topBlockId ID of block that starts the script.
|
||||
|
|
|
@ -1,6 +1,26 @@
|
|||
const BlockUtility = require('./block-utility');
|
||||
const log = require('../util/log');
|
||||
const Thread = require('./thread');
|
||||
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.
|
||||
|
@ -8,7 +28,74 @@ const {Map} = require('immutable');
|
|||
* @return {boolean} True if the value appears to be a Promise.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// from regular blocks.
|
||||
// For hats: if they have an associated block function,
|
||||
|
@ -124,7 +162,7 @@ const execute = function (sequencer, thread) {
|
|||
const keys = Object.keys(fields);
|
||||
if (keys.length === 1 && Object.keys(inputs).length === 0) {
|
||||
// One field and no inputs - treat as arg.
|
||||
handleReport(fields[keys[0]].value);
|
||||
handleReport(fields[keys[0]].value, sequencer, thread, currentBlockId, opcode, isHat);
|
||||
} else {
|
||||
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.
|
||||
for (const fieldName in fields) {
|
||||
if (!fields.hasOwnProperty(fieldName)) continue;
|
||||
if (fieldName === 'VARIABLE') {
|
||||
argValues[fieldName] = fields[fieldName].id;
|
||||
if (fieldName === 'VARIABLE' || fieldName === 'LIST' ||
|
||||
fieldName === 'BROADCAST_OPTION') {
|
||||
argValues[fieldName] = {
|
||||
id: fields[fieldName].id,
|
||||
name: fields[fieldName].value
|
||||
};
|
||||
} else {
|
||||
argValues[fieldName] = fields[fieldName].value;
|
||||
}
|
||||
|
@ -148,6 +190,8 @@ const execute = function (sequencer, thread) {
|
|||
// Recursively evaluate input blocks.
|
||||
for (const inputName in inputs) {
|
||||
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 inputBlockId = input.block;
|
||||
// Is there no value for this input waiting in the stack frame?
|
||||
|
@ -168,7 +212,30 @@ const execute = function (sequencer, thread) {
|
|||
currentStackFrame.waitingReporter = null;
|
||||
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).
|
||||
|
@ -184,49 +251,25 @@ const execute = function (sequencer, thread) {
|
|||
currentStackFrame.reported = {};
|
||||
|
||||
let primitiveReportedValue = null;
|
||||
primitiveReportedValue = blockFunction(argValues, {
|
||||
stackFrame: currentStackFrame.executionContext,
|
||||
target: target,
|
||||
yield: function () {
|
||||
thread.status = Thread.STATUS_YIELD;
|
||||
},
|
||||
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);
|
||||
}
|
||||
blockUtility.sequencer = sequencer;
|
||||
blockUtility.thread = thread;
|
||||
if (runtime.profiler !== null) {
|
||||
if (blockFunctionProfilerId === -1) {
|
||||
blockFunctionProfilerId = runtime.profiler.idByName(blockFunctionProfilerFrame);
|
||||
}
|
||||
});
|
||||
// The method commented below has its code inlined underneath to reduce
|
||||
// the bias recorded for the profiler's calls in this time sensitive
|
||||
// execute function.
|
||||
//
|
||||
// runtime.profiler.start(blockFunctionProfilerId, opcode);
|
||||
runtime.profiler.records.push(
|
||||
runtime.profiler.START, blockFunctionProfilerId, opcode, 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') {
|
||||
// No value reported - potentially a command block.
|
||||
|
@ -242,7 +285,7 @@ const execute = function (sequencer, thread) {
|
|||
}
|
||||
// Promise handlers
|
||||
primitiveReportedValue.then(resolvedValue => {
|
||||
handleReport(resolvedValue);
|
||||
handleReport(resolvedValue, sequencer, thread, currentBlockId, opcode, isHat);
|
||||
if (typeof resolvedValue === 'undefined') {
|
||||
let stackFrame;
|
||||
let nextBlockId;
|
||||
|
@ -275,7 +318,7 @@ const execute = function (sequencer, thread) {
|
|||
thread.popStack();
|
||||
});
|
||||
} else if (thread.status === Thread.STATUS_RUNNING) {
|
||||
handleReport(primitiveReportedValue);
|
||||
handleReport(primitiveReportedValue, sequencer, thread, currentBlockId, opcode, isHat);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -2,6 +2,10 @@ const {Record} = require('immutable');
|
|||
|
||||
const MonitorRecord = Record({
|
||||
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,
|
||||
value: null,
|
||||
params: null
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const html = require('htmlparser2');
|
||||
const decodeHtml = require('decode-html');
|
||||
|
||||
/**
|
||||
* Convert a part of a mutation DOM to a mutation VM object, recursively.
|
||||
|
@ -11,7 +12,7 @@ const mutatorTagToObject = function (dom) {
|
|||
obj.children = [];
|
||||
for (const prop in dom.attribs) {
|
||||
if (prop === 'xmlns') continue;
|
||||
obj[prop] = dom.attribs[prop];
|
||||
obj[prop] = decodeHtml(dom.attribs[prop]);
|
||||
}
|
||||
for (let i = 0; i < dom.children.length; i++) {
|
||||
obj.children.push(
|
||||
|
|
311
src/engine/profiler.js
Normal file
311
src/engine/profiler.js
Normal 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;
|
|
@ -1,8 +1,13 @@
|
|||
const EventEmitter = require('events');
|
||||
const Sequencer = require('./sequencer');
|
||||
const Blocks = require('./blocks');
|
||||
const Thread = require('./thread');
|
||||
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.
|
||||
const Clock = require('../io/clock');
|
||||
|
@ -16,14 +21,83 @@ const defaultBlockPackages = {
|
|||
scratch3_looks: require('../blocks/scratch3_looks'),
|
||||
scratch3_motion: require('../blocks/scratch3_motion'),
|
||||
scratch3_operators: require('../blocks/scratch3_operators'),
|
||||
scratch3_pen: require('../blocks/scratch3_pen'),
|
||||
scratch3_sound: require('../blocks/scratch3_sound'),
|
||||
scratch3_sensing: require('../blocks/scratch3_sensing'),
|
||||
scratch3_data: require('../blocks/scratch3_data'),
|
||||
scratch3_procedures: require('../blocks/scratch3_procedures'),
|
||||
scratch3_wedo2: require('../blocks/scratch3_wedo2')
|
||||
scratch3_procedures: require('../blocks/scratch3_procedures')
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @constructor
|
||||
|
@ -75,6 +149,13 @@ class Runtime extends EventEmitter {
|
|||
*/
|
||||
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.
|
||||
* Keys are opcode for hat, values are metadata objects.
|
||||
|
@ -114,6 +195,13 @@ class Runtime extends EventEmitter {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
@ -172,6 +260,13 @@ class Runtime extends EventEmitter {
|
|||
keyboard: new Keyboard(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}
|
||||
*/
|
||||
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}
|
||||
*/
|
||||
static get 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.
|
||||
* @const {string}
|
||||
|
@ -262,6 +377,22 @@ class Runtime extends EventEmitter {
|
|||
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.
|
||||
*/
|
||||
|
@ -316,10 +447,327 @@ 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.
|
||||
* @param {!string} opcode The opcode to look up.
|
||||
|
@ -420,17 +868,14 @@ class Runtime extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove a thread from the list of threads.
|
||||
* @param {?Thread} thread Thread object to remove from actives
|
||||
* Stop a thread: stop running it immediately, and remove it from the thread list later.
|
||||
* @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.
|
||||
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.
|
||||
* Test project: https://scratch.mit.edu/projects/130183108/
|
||||
* @param {!Thread} thread Thread object to restart.
|
||||
* @return {Thread} The restarted thread.
|
||||
*/
|
||||
_restartThread (thread) {
|
||||
const newThread = new Thread(thread.topBlock);
|
||||
|
@ -448,9 +894,10 @@ class Runtime extends EventEmitter {
|
|||
const i = this.threads.indexOf(thread);
|
||||
if (i > -1) {
|
||||
this.threads[i] = newThread;
|
||||
} else {
|
||||
this.threads.push(thread);
|
||||
return newThread;
|
||||
}
|
||||
this.threads.push(thread);
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -459,7 +906,11 @@ class Runtime extends EventEmitter {
|
|||
* @return {boolean} True if the thread is active/running.
|
||||
*/
|
||||
isActiveThread (thread) {
|
||||
return this.threads.indexOf(thread) > -1;
|
||||
return (
|
||||
(
|
||||
thread.stack.length > 0 &&
|
||||
thread.status !== Thread.STATUS_DONE) &&
|
||||
this.threads.indexOf(thread) > -1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -481,13 +932,13 @@ class Runtime extends EventEmitter {
|
|||
if (this.threads[i].topBlock === topBlockId && this.threads[i].status !== Thread.STATUS_DONE) {
|
||||
const blockContainer = opts.target.blocks;
|
||||
const opcode = blockContainer.getOpcode(blockContainer.getBlock(topBlockId));
|
||||
|
||||
|
||||
if (this.getIsEdgeActivatedHat(opcode) && this.threads[i].stackClick !== opts.stackClick) {
|
||||
// Allow edge activated hat thread stack click to coexist with
|
||||
// edge activated hat thread that runs every frame
|
||||
continue;
|
||||
}
|
||||
this._removeThread(this.threads[i]);
|
||||
this._stopThread(this.threads[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -498,7 +949,7 @@ class Runtime extends EventEmitter {
|
|||
/**
|
||||
* 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} 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) {
|
||||
if (!optTarget) optTarget = this._editingTarget;
|
||||
|
@ -576,13 +1027,14 @@ class Runtime extends EventEmitter {
|
|||
|
||||
// If no fields are present, check inputs (horizontal blocks)
|
||||
if (Object.keys(hatFields).length === 0) {
|
||||
hatFields = {}; // don't overwrite the block's actual fields list
|
||||
const hatInputs = blocks.getInputs(block);
|
||||
for (const input in hatInputs) {
|
||||
if (!hatInputs.hasOwnProperty(input)) continue;
|
||||
const id = hatInputs[input].block;
|
||||
const inpBlock = blocks.getBlock(id);
|
||||
const fields = blocks.getFields(inpBlock);
|
||||
hatFields = Object.assign(fields, hatFields);
|
||||
Object.assign(hatFields, fields);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -605,7 +1057,7 @@ class Runtime extends EventEmitter {
|
|||
if (instance.threads[i].topBlock === topBlockId &&
|
||||
!instance.threads[i].stackClick && // stack click threads and hat threads can coexist
|
||||
instance.threads[i].target === target) {
|
||||
instance._restartThread(instance.threads[i]);
|
||||
newThreads.push(instance._restartThread(instance.threads[i]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -662,8 +1114,7 @@ class Runtime extends EventEmitter {
|
|||
continue;
|
||||
}
|
||||
if (this.threads[i].target === target) {
|
||||
this.threads[i].isKilled = true;
|
||||
this._removeThread(this.threads[i]);
|
||||
this._stopThread(this.threads[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -673,6 +1124,7 @@ class Runtime extends EventEmitter {
|
|||
*/
|
||||
greenFlag () {
|
||||
this.stopAll();
|
||||
this.emit(Runtime.PROJECT_START);
|
||||
this.ioDevices.clock.resetProjectTimer();
|
||||
this.clearEdgeActivatedValues();
|
||||
// Inform all targets of the green flag.
|
||||
|
@ -686,6 +1138,9 @@ class Runtime extends EventEmitter {
|
|||
* Stop "everything."
|
||||
*/
|
||||
stopAll () {
|
||||
// Emit stop event to allow blocks to clean up any state.
|
||||
this.emit(Runtime.PROJECT_STOP_ALL);
|
||||
|
||||
// Dispose all clones.
|
||||
const newTargets = [];
|
||||
for (let i = 0; i < this.targets.length; i++) {
|
||||
|
@ -699,11 +1154,7 @@ class Runtime extends EventEmitter {
|
|||
}
|
||||
this.targets = newTargets;
|
||||
// Dispose all threads.
|
||||
const threadsCopy = this.threads.slice();
|
||||
while (threadsCopy.length > 0) {
|
||||
const poppedThread = threadsCopy.pop();
|
||||
this._removeThread(poppedThread);
|
||||
}
|
||||
this.threads.forEach(thread => this._stopThread(thread));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -711,6 +1162,16 @@ class Runtime extends EventEmitter {
|
|||
* inactive threads after each iteration.
|
||||
*/
|
||||
_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.
|
||||
for (const hatType in this._hats) {
|
||||
if (!this._hats.hasOwnProperty(hatType)) continue;
|
||||
|
@ -721,7 +1182,16 @@ class Runtime extends EventEmitter {
|
|||
}
|
||||
this.redrawRequested = false;
|
||||
this._pushMonitors();
|
||||
if (this.profiler !== null) {
|
||||
if (stepThreadsProfilerId === -1) {
|
||||
stepThreadsProfilerId = this.profiler.idByName('Sequencer.stepThreads');
|
||||
}
|
||||
this.profiler.start(stepThreadsProfilerId);
|
||||
}
|
||||
const doneThreads = this.sequencer.stepThreads();
|
||||
if (this.profiler !== null) {
|
||||
this.profiler.stop();
|
||||
}
|
||||
this._updateGlows(doneThreads);
|
||||
// Add done threads so that even if a thread finishes within 1 frame, the green
|
||||
// flag will still indicate that a script ran.
|
||||
|
@ -730,7 +1200,16 @@ class Runtime extends EventEmitter {
|
|||
this._getMonitorThreadCount([...this.threads, ...doneThreads]));
|
||||
if (this.renderer) {
|
||||
// @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();
|
||||
if (this.profiler !== null) {
|
||||
this.profiler.stop();
|
||||
}
|
||||
}
|
||||
|
||||
if (this._refreshTargets) {
|
||||
|
@ -742,6 +1221,11 @@ class Runtime extends EventEmitter {
|
|||
this.emit(Runtime.MONITORS_UPDATE, 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
requestUpdateMonitor (monitor) {
|
||||
if (this._monitorState.has(monitor.get('id'))) {
|
||||
const id = monitor.get('id');
|
||||
if (this._monitorState.has(id)) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {string} targetId Id of target to find.
|
||||
|
@ -1008,6 +1502,15 @@ class Runtime extends EventEmitter {
|
|||
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.
|
||||
* @return {?Target} The target, if found.
|
||||
|
@ -1060,6 +1563,24 @@ class Runtime extends EventEmitter {
|
|||
this._step();
|
||||
}, 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,6 +2,42 @@ const Timer = require('../util/timer');
|
|||
const Thread = require('./thread');
|
||||
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 {
|
||||
constructor (runtime) {
|
||||
/**
|
||||
|
@ -38,7 +74,7 @@ class Sequencer {
|
|||
let numActiveThreads = Infinity;
|
||||
// Whether `stepThreads` has run through a full single tick.
|
||||
let ranFirstTick = false;
|
||||
const doneThreads = [];
|
||||
const doneThreads = this.runtime.threads.map(() => null);
|
||||
// Conditions for continuing to stepping threads:
|
||||
// 1. We must have threads in the list, and some must be active.
|
||||
// 2. Time elapsed must be less than WORK_TIME.
|
||||
|
@ -47,6 +83,13 @@ class Sequencer {
|
|||
numActiveThreads > 0 &&
|
||||
this.timer.timeElapsed() < WORK_TIME &&
|
||||
(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;
|
||||
// Attempt to run each thread one time.
|
||||
for (let i = 0; i < this.runtime.threads.length; i++) {
|
||||
|
@ -54,11 +97,13 @@ class Sequencer {
|
|||
if (activeThread.stack.length === 0 ||
|
||||
activeThread.status === Thread.STATUS_DONE) {
|
||||
// Finished with this thread.
|
||||
if (doneThreads.indexOf(activeThread) < 0) {
|
||||
doneThreads.push(activeThread);
|
||||
}
|
||||
doneThreads[i] = activeThread;
|
||||
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 &&
|
||||
!ranFirstTick) {
|
||||
// Clear single-tick yield from the last call of `stepThreads`.
|
||||
|
@ -67,7 +112,16 @@ class Sequencer {
|
|||
if (activeThread.status === Thread.STATUS_RUNNING ||
|
||||
activeThread.status === Thread.STATUS_YIELD) {
|
||||
// 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);
|
||||
if (this.runtime.profiler !== null) {
|
||||
this.runtime.profiler.stop();
|
||||
}
|
||||
activeThread.warpTimer = null;
|
||||
if (activeThread.isKilled) {
|
||||
i--; // if the thread is removed from the list (killed), do not increase index
|
||||
|
@ -80,14 +134,33 @@ class Sequencer {
|
|||
// We successfully ticked once. Prevents running STATUS_YIELD_TICK
|
||||
// threads on the next tick.
|
||||
ranFirstTick = true;
|
||||
|
||||
if (this.runtime.profiler !== null) {
|
||||
this.runtime.profiler.stop();
|
||||
}
|
||||
}
|
||||
// Filter inactive threads from `this.runtime.threads`.
|
||||
this.runtime.threads = this.runtime.threads.filter(thread => {
|
||||
if (doneThreads.indexOf(thread) > -1) {
|
||||
return false;
|
||||
numActiveThreads = 0;
|
||||
for (let i = 0; i < this.runtime.threads.length; i++) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -112,7 +185,23 @@ class Sequencer {
|
|||
// Execute the current block.
|
||||
// Save the current block ID to notice if we did control flow.
|
||||
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);
|
||||
if (this.runtime.profiler !== null) {
|
||||
// this.runtime.profiler.stop();
|
||||
this.runtime.profiler.records.push(this.runtime.profiler.STOP, performance.now());
|
||||
}
|
||||
thread.blockGlowInFrame = currentBlockId;
|
||||
// If the thread has yielded or is waiting, yield to other threads.
|
||||
if (thread.status === Thread.STATUS_YIELD) {
|
||||
|
@ -225,7 +314,17 @@ class Sequencer {
|
|||
// Look for warp-mode flag on definition, and set the thread
|
||||
// to warp-mode if needed.
|
||||
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) {
|
||||
thread.peekStackFrame().warpMode = true;
|
||||
} else if (isRecursive) {
|
||||
|
|
|
@ -2,9 +2,9 @@ const EventEmitter = require('events');
|
|||
|
||||
const Blocks = require('./blocks');
|
||||
const Variable = require('../engine/variable');
|
||||
const List = require('../engine/list');
|
||||
const uid = require('../util/uid');
|
||||
const {Map} = require('immutable');
|
||||
const log = require('../util/log');
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
|
@ -88,11 +88,57 @@ class Target extends EventEmitter {
|
|||
const variable = this.lookupVariableById(id);
|
||||
if (variable) return variable;
|
||||
// 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;
|
||||
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.
|
||||
* 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.
|
||||
* Search begins for local lists; then look for globals.
|
||||
* @param {!string} id Id 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) {
|
||||
// If we have a local copy, return it.
|
||||
if (this.lists.hasOwnProperty(name)) {
|
||||
return this.lists[name];
|
||||
}
|
||||
// If the stage has a global copy, return it.
|
||||
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;
|
||||
lookupOrCreateList (id, name) {
|
||||
const list = this.lookupVariableById(id);
|
||||
if (list) return list;
|
||||
// No variable with this name exists - create it locally.
|
||||
const newList = new Variable(id, name, Variable.LIST_TYPE, false);
|
||||
this.variables[id] = newList;
|
||||
return newList;
|
||||
}
|
||||
|
||||
|
@ -143,11 +181,11 @@ class Target extends EventEmitter {
|
|||
* dictionary of variables.
|
||||
* @param {string} id Id 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)) {
|
||||
const newVariable = new Variable(id, name, 0,
|
||||
false);
|
||||
const newVariable = new Variable(id, name, type, false);
|
||||
this.variables[id] = newVariable;
|
||||
}
|
||||
}
|
||||
|
@ -189,6 +227,7 @@ class Target extends EventEmitter {
|
|||
if (this.variables.hasOwnProperty(id)) {
|
||||
delete this.variables[id];
|
||||
if (this.runtime) {
|
||||
this.runtime.monitorBlocks.deleteBlock(id);
|
||||
this.runtime.requestRemoveMonitor(id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -164,7 +164,7 @@ class Thread {
|
|||
let blockID = this.peekStack();
|
||||
while (blockID !== null) {
|
||||
const block = this.target.blocks.getBlock(blockID);
|
||||
if (typeof block !== 'undefined' && block.opcode === 'procedures_callnoreturn') {
|
||||
if (typeof block !== 'undefined' && block.opcode === 'procedures_call') {
|
||||
break;
|
||||
}
|
||||
this.popStack();
|
||||
|
@ -271,7 +271,7 @@ class Thread {
|
|||
const sp = this.stack.length - 1;
|
||||
for (let i = sp - 1; i >= 0; i--) {
|
||||
const block = this.target.blocks.getBlock(this.stack[i]);
|
||||
if (block.opcode === 'procedures_callnoreturn' &&
|
||||
if (block.opcode === 'procedures_call' &&
|
||||
block.mutation.proccode === procedureCode) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -9,19 +9,58 @@ class Variable {
|
|||
/**
|
||||
* @param {string} id Id 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.
|
||||
* @constructor
|
||||
*/
|
||||
constructor (id, name, value, isCloud) {
|
||||
constructor (id, name, type, isCloud) {
|
||||
this.id = id || uid();
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.type = type;
|
||||
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 () {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
9
src/extension-support/argument-type.js
Normal file
9
src/extension-support/argument-type.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
const ArgumentType = {
|
||||
ANGLE: 'angle',
|
||||
BOOLEAN: 'Boolean',
|
||||
COLOR: 'color',
|
||||
NUMBER: 'number',
|
||||
STRING: 'string'
|
||||
};
|
||||
|
||||
module.exports = ArgumentType;
|
9
src/extension-support/block-type.js
Normal file
9
src/extension-support/block-type.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
const BlockType = {
|
||||
BOOLEAN: 'Boolean',
|
||||
COMMAND: 'command',
|
||||
CONDITIONAL: 'conditional',
|
||||
HAT: 'hat',
|
||||
REPORTER: 'reporter'
|
||||
};
|
||||
|
||||
module.exports = BlockType;
|
280
src/extension-support/extension-manager.js
Normal file
280
src/extension-support/extension-manager.js
Normal 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;
|
57
src/extension-support/extension-worker.js
Normal file
57
src/extension-support/extension-worker.js
Normal 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)
|
||||
};
|
BIN
src/extensions/scratch3_music/assets/drums/1-snare.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/1-snare.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/10-wood-block.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/10-wood-block.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/11-cowbell.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/11-cowbell.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/12-triangle.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/12-triangle.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/13-bongo.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/13-bongo.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/14-conga.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/14-conga.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/15-cabasa.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/15-cabasa.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/16-guiro.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/16-guiro.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/17-vibraslap.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/17-vibraslap.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/18-cuica.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/18-cuica.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/2-bass-drum.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/2-bass-drum.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/3-side-stick.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/3-side-stick.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/4-crash-cymbal.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/4-crash-cymbal.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/5-open-hi-hat.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/5-open-hi-hat.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/6-closed-hi-hat.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/6-closed-hi-hat.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/7-tambourine.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/7-tambourine.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/8-hand-clap.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/8-hand-clap.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/drums/9-claves.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/drums/9-claves.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/108.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/108.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/24.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/24.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/36.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/36.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/48.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/48.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/60.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/60.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/72.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/72.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/84.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/84.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/96.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/1-piano/96.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/11-saxophone/36.mp3
Executable file
BIN
src/extensions/scratch3_music/assets/instruments/11-saxophone/36.mp3
Executable file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/11-saxophone/60.mp3
Executable file
BIN
src/extensions/scratch3_music/assets/instruments/11-saxophone/60.mp3
Executable file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/11-saxophone/84.mp3
Executable file
BIN
src/extensions/scratch3_music/assets/instruments/11-saxophone/84.mp3
Executable file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/12-flute/60.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/12-flute/60.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/12-flute/72.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/12-flute/72.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/15-choir/48.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/15-choir/48.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/15-choir/60.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/15-choir/60.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/15-choir/72.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/15-choir/72.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/3-organ/60.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/3-organ/60.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/4-guitar/60.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/4-guitar/60.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/5-electric-guitar/60.mp3
Executable file
BIN
src/extensions/scratch3_music/assets/instruments/5-electric-guitar/60.mp3
Executable file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/6-bass/36.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/6-bass/36.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/6-bass/48.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/6-bass/48.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/8-cello/36.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/8-cello/36.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/8-cello/48.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/8-cello/48.mp3
Normal file
Binary file not shown.
BIN
src/extensions/scratch3_music/assets/instruments/8-cello/60.mp3
Normal file
BIN
src/extensions/scratch3_music/assets/instruments/8-cello/60.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
813
src/extensions/scratch3_music/index.js
Normal file
813
src/extensions/scratch3_music/index.js
Normal 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;
|
760
src/extensions/scratch3_pen/index.js
Normal file
760
src/extensions/scratch3_pen/index.js
Normal 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;
|
|
@ -1,5 +1,14 @@
|
|||
const color = require('../util/color');
|
||||
const log = require('../util/log');
|
||||
const ArgumentType = require('../../extension-support/argument-type');
|
||||
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.
|
||||
|
@ -243,7 +252,7 @@ class WeDo2 {
|
|||
* @return {number} - the latest value received from the distance sensor.
|
||||
*/
|
||||
get distance () {
|
||||
return this._sensors.distance;
|
||||
return this._sensors.distance * 10;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -371,9 +380,9 @@ const TiltDirection = {
|
|||
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';
|
||||
}
|
||||
|
||||
|
@ -395,7 +404,185 @@ class Scratch3WeDo2Blocks {
|
|||
*/
|
||||
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 finder = this._finder =
|
||||
deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_NAME, WeDo2.DEVICE_TYPE);
|
||||
deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_ID, WeDo2.DEVICE_TYPE);
|
||||
this._finder.promise.then(
|
||||
socket => {
|
||||
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.
|
||||
* @param {object} args - the block's arguments.
|
||||
|
@ -574,8 +740,10 @@ class Scratch3WeDo2Blocks {
|
|||
whenDistance (args) {
|
||||
switch (args.OP) {
|
||||
case '<':
|
||||
case '<':
|
||||
return this._device.distance < args.REFERENCE;
|
||||
case '>':
|
||||
case '>':
|
||||
return this._device.distance > args.REFERENCE;
|
||||
default:
|
||||
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.
|
||||
*/
|
||||
getDistance () {
|
||||
return this._device.distance * 10;
|
||||
return this._device.distance;
|
||||
}
|
||||
|
||||
/**
|
|
@ -1,6 +1,59 @@
|
|||
const StringUtil = require('../util/string-util');
|
||||
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.
|
||||
* 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 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 = [
|
||||
costume.rotationCenterX / costume.bitmapResolution,
|
||||
costume.rotationCenterY / costume.bitmapResolution
|
||||
];
|
||||
|
||||
let promise = runtime.storage.load(assetType, md5, ext).then(costumeAsset => {
|
||||
costume.assetId = costumeAsset.assetId;
|
||||
costume.dataFormat = ext;
|
||||
return costumeAsset;
|
||||
// @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;
|
||||
});
|
||||
|
||||
if (!runtime.renderer) {
|
||||
log.error('No rendering module present; cannot load costume asset: ', md5ext);
|
||||
return promise.then(() => costume);
|
||||
}
|
||||
|
||||
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) => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
module.exports = loadCostume;
|
||||
module.exports = {
|
||||
loadCostume,
|
||||
loadCostumeFromAsset,
|
||||
loadOldTextCostume
|
||||
};
|
||||
|
|
|
@ -3,97 +3,4 @@
|
|||
const StringUtil = require('../util/string-util');
|
||||
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;
|
||||
|
|
|
@ -1,6 +1,27 @@
|
|||
const StringUtil = require('../util/string-util');
|
||||
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.
|
||||
* @param {!object} sound - the Scratch sound object.
|
||||
|
@ -23,18 +44,12 @@ const loadSound = function (sound, runtime) {
|
|||
const ext = idParts[1].toLowerCase();
|
||||
return runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
|
||||
.then(soundAsset => {
|
||||
sound.assetId = soundAsset.assetId;
|
||||
sound.dataFormat = ext;
|
||||
return runtime.audioEngine.decodeSound(Object.assign(
|
||||
{},
|
||||
sound,
|
||||
{data: soundAsset.data}
|
||||
));
|
||||
})
|
||||
.then(soundId => {
|
||||
sound.soundId = soundId;
|
||||
return sound;
|
||||
return loadSoundFromAsset(sound, soundAsset, runtime);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = loadSound;
|
||||
module.exports = {
|
||||
loadSound,
|
||||
loadSoundFromAsset
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const got = require('got');
|
||||
const nets = require('nets');
|
||||
const io = require('socket.io-client/dist/socket.io');
|
||||
const querystring = require('querystring');
|
||||
|
||||
|
@ -312,7 +312,17 @@ class DeviceManager {
|
|||
};
|
||||
if (deviceSpec) queryObject.spec = deviceSpec;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -40,10 +40,20 @@ class Mouse {
|
|||
*/
|
||||
postData (data) {
|
||||
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) {
|
||||
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') {
|
||||
this._isDown = data.isDown;
|
||||
|
@ -54,19 +64,35 @@ class Mouse {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the X position of the mouse.
|
||||
* @return {number} Clamped X position of the mouse cursor.
|
||||
* Get the X position of the mouse in client coordinates.
|
||||
* @return {number} Non-clamped X position of the mouse cursor.
|
||||
*/
|
||||
getX () {
|
||||
return MathUtil.clamp(this._x, -240, 240);
|
||||
getClientX () {
|
||||
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.
|
||||
*/
|
||||
getY () {
|
||||
return MathUtil.clamp(-this._y, -180, 180);
|
||||
getScratchY () {
|
||||
return this._scratchY;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue