Initial commit of 001code-html Scratch frontend project.

Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
module.exports = {
root: true,
extends: ['scratch', 'scratch/es6'],
env: {
browser: true
},
rules: {
'valid-jsdoc': 'off'
}
};

View File

@@ -0,0 +1,206 @@
const Cast = require('../util/cast');
class Scratch3ControlBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* The "counter" block value. For compatibility with 2.0.
* @type {number}
*/
this._counter = 0; // used by compiler
this.runtime.on('RUNTIME_DISPOSED', this.clearCounter.bind(this));
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
control_repeat: this.repeat,
control_repeat_until: this.repeatUntil,
control_while: this.repeatWhile,
control_for_each: this.forEach,
control_forever: this.forever,
control_wait: this.wait,
control_wait_until: this.waitUntil,
control_if: this.if,
control_if_else: this.ifElse,
control_stop: this.stop,
control_create_clone_of: this.createClone,
control_delete_this_clone: this.deleteClone,
control_get_counter: this.getCounter,
control_incr_counter: this.incrCounter,
control_clear_counter: this.clearCounter,
control_all_at_once: this.allAtOnce
};
}
getHats () {
return {
control_start_as_clone: {
restartExistingThreads: false
}
};
}
repeat (args, util) {
const times = Math.round(Cast.toNumber(args.TIMES));
// Initialize loop
if (typeof util.stackFrame.loopCounter === 'undefined') {
util.stackFrame.loopCounter = times;
}
// Only execute once per frame.
// When the branch finishes, `repeat` will be executed again and
// the second branch will be taken, yielding for the rest of the frame.
// Decrease counter
util.stackFrame.loopCounter--;
// If we still have some left, start the branch.
if (util.stackFrame.loopCounter >= 0) {
util.startBranch(1, true);
}
}
repeatUntil (args, util) {
const condition = Cast.toBoolean(args.CONDITION);
// If the condition is false (repeat UNTIL), start the branch.
if (!condition) {
util.startBranch(1, true);
}
}
repeatWhile (args, util) {
const condition = Cast.toBoolean(args.CONDITION);
// If the condition is true (repeat WHILE), start the branch.
if (condition) {
util.startBranch(1, true);
}
}
forEach (args, util) {
const variable = util.target.lookupOrCreateVariable(
args.VARIABLE.id, args.VARIABLE.name);
if (typeof util.stackFrame.index === 'undefined') {
util.stackFrame.index = 0;
}
if (util.stackFrame.index < Number(args.VALUE)) {
util.stackFrame.index++;
variable.value = util.stackFrame.index;
util.startBranch(1, true);
}
}
waitUntil (args, util) {
const condition = Cast.toBoolean(args.CONDITION);
if (!condition) {
util.yield();
}
}
forever (args, util) {
util.startBranch(1, true);
}
wait (args, util) {
if (util.stackTimerNeedsInit()) {
const duration = Math.max(0, 1000 * Cast.toNumber(args.DURATION));
util.startStackTimer(duration);
this.runtime.requestRedraw();
util.yield();
} else if (!util.stackTimerFinished()) {
util.yield();
}
}
if (args, util) {
const condition = Cast.toBoolean(args.CONDITION);
if (condition) {
util.startBranch(1, false);
}
}
ifElse (args, util) {
const condition = Cast.toBoolean(args.CONDITION);
if (condition) {
util.startBranch(1, false);
} else {
util.startBranch(2, false);
}
}
stop (args, util) {
const option = args.STOP_OPTION;
if (option === 'all') {
util.stopAll();
} else if (option === 'other scripts in sprite' ||
option === 'other scripts in stage') {
util.stopOtherTargetThreads();
} else if (option === 'this script') {
util.stopThisScript();
}
}
createClone (args, util) {
this._createClone(Cast.toString(args.CLONE_OPTION), util.target);
}
_createClone (cloneOption, target) { // used by compiler
// Set clone target
let cloneTarget;
if (cloneOption === '_myself_') {
cloneTarget = target;
} else {
cloneTarget = this.runtime.getSpriteTargetByName(cloneOption);
}
// If clone target is not found, return
if (!cloneTarget) return;
// Create clone
const newClone = cloneTarget.makeClone();
if (newClone) {
this.runtime.addTarget(newClone);
// Place behind the original target.
newClone.goBehindOther(cloneTarget);
}
}
deleteClone (args, util) {
if (util.target.isOriginal) return;
this.runtime.disposeTarget(util.target);
this.runtime.stopForTarget(util.target);
}
getCounter () {
return this._counter;
}
clearCounter () {
this._counter = 0;
}
incrCounter () {
this._counter++;
}
allAtOnce (args, util) {
// Since the "all at once" block is implemented for compatiblity with
// Scratch 2.0 projects, it behaves the same way it did in 2.0, which
// is to simply run the contained script (like "if 1 = 1").
// (In early versions of Scratch 2.0, it would work the same way as
// "run without screen refresh" custom blocks do now, but this was
// removed before the release of 2.0.)
util.startBranch(1, false);
}
}
module.exports = Scratch3ControlBlocks;

View File

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

View File

@@ -0,0 +1,240 @@
const Cast = require('../util/cast');
class Scratch3DataBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
data_variable: this.getVariable,
data_setvariableto: this.setVariableTo,
data_changevariableby: this.changeVariableBy,
data_hidevariable: this.hideVariable,
data_showvariable: this.showVariable,
data_listcontents: this.getListContents,
data_addtolist: this.addToList,
data_deleteoflist: this.deleteOfList,
data_deletealloflist: this.deleteAllOfList,
data_insertatlist: this.insertAtList,
data_replaceitemoflist: this.replaceItemOfList,
data_itemoflist: this.getItemOfList,
data_itemnumoflist: this.getItemNumOfList,
data_lengthoflist: this.lengthOfList,
data_listcontainsitem: this.listContainsItem,
data_hidelist: this.hideList,
data_showlist: this.showList
};
}
getVariable (args, util) {
const variable = util.target.lookupOrCreateVariable(
args.VARIABLE.id, args.VARIABLE.name);
return variable.value;
}
setVariableTo (args, util) {
const variable = util.target.lookupOrCreateVariable(
args.VARIABLE.id, args.VARIABLE.name);
variable.value = args.VALUE;
if (variable.isCloud) {
util.ioQuery('cloud', 'requestUpdateVariable', [variable.name, args.VALUE]);
}
}
changeVariableBy (args, util) {
const variable = util.target.lookupOrCreateVariable(
args.VARIABLE.id, args.VARIABLE.name);
const castedValue = Cast.toNumber(variable.value);
const dValue = Cast.toNumber(args.VALUE);
const newValue = castedValue + dValue;
variable.value = newValue;
if (variable.isCloud) {
util.ioQuery('cloud', 'requestUpdateVariable', [variable.name, newValue]);
}
}
changeMonitorVisibility (id, visible) {
// Send the monitor blocks an event like the flyout checkbox event.
// This both updates the monitor state and changes the isMonitored block flag.
this.runtime.monitorBlocks.changeBlock({
id: id, // Monitor blocks for variables are the variable ID.
element: 'checkbox', // Mimic checkbox event from flyout.
value: visible
}, this.runtime);
}
showVariable (args) {
this.changeMonitorVisibility(args.VARIABLE.id, true);
}
hideVariable (args) {
this.changeMonitorVisibility(args.VARIABLE.id, false);
}
showList (args) {
this.changeMonitorVisibility(args.LIST.id, true);
}
hideList (args) {
this.changeMonitorVisibility(args.LIST.id, false);
}
getListContents (args, util) {
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
// If block is running for monitors, return copy of list as an array if changed.
if (util.thread.updateMonitor) {
// Return original list value if up-to-date, which doesn't trigger monitor update.
if (list._monitorUpToDate) return list.value;
// If value changed, reset the flag and return a copy to trigger monitor update.
// Because monitors use Immutable data structures, only new objects trigger updates.
list._monitorUpToDate = true;
return list.value.slice();
}
// 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.value.length; i++) {
const listItem = list.value[i];
if (!((typeof listItem === 'string') &&
(listItem.length === 1))) {
allSingleLetters = false;
break;
}
}
if (allSingleLetters) {
return list.value.join('');
}
return list.value.join(' ');
}
addToList (args, util) {
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
list.value.push(args.ITEM);
list._monitorUpToDate = false;
}
deleteOfList (args, util) {
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length, true);
if (index === Cast.LIST_INVALID) {
return;
} else if (index === Cast.LIST_ALL) {
list.value = [];
return;
}
list.value.splice(index - 1, 1);
list._monitorUpToDate = false;
}
deleteAllOfList (args, util) {
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
list.value = [];
return;
}
insertAtList (args, util) {
const item = args.ITEM;
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length + 1, false);
if (index === Cast.LIST_INVALID) {
return;
}
list.value.splice(index - 1, 0, item);
list._monitorUpToDate = false;
}
replaceItemOfList (args, util) {
const item = args.ITEM;
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length, false);
if (index === Cast.LIST_INVALID) {
return;
}
list.value[index - 1] = item;
list._monitorUpToDate = false;
}
getItemOfList (args, util) {
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
const index = Cast.toListIndex(args.INDEX, list.value.length, false);
if (index === Cast.LIST_INVALID) {
return '';
}
return list.value[index - 1];
}
getItemNumOfList (args, util) {
const item = args.ITEM;
const list = util.target.lookupOrCreateList(
args.LIST.id, args.LIST.name);
// Go through the list items one-by-one using Cast.compare. This is for
// cases like checking if 123 is contained in a list [4, 7, '123'] --
// Scratch considers 123 and '123' to be equal.
for (let i = 0; i < list.value.length; i++) {
if (Cast.compare(list.value[i], item) === 0) {
return i + 1;
}
}
// We don't bother using .indexOf() at all, because it would end up with
// edge cases such as the index of '123' in [4, 7, 123, '123', 9].
// If we use indexOf(), this block would return 4 instead of 3, because
// indexOf() sees the first occurence of the string 123 as the fourth
// item in the list. With Scratch, this would be confusing -- after all,
// '123' and 123 look the same, so one would expect the block to say
// that the first occurrence of '123' (or 123) to be the third item.
// Default to 0 if there's no match. Since Scratch lists are 1-indexed,
// we don't have to worry about this conflicting with the "this item is
// the first value" number (in JS that is 0, but in Scratch it's 1).
return 0;
}
lengthOfList (args, util) {
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.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.value.length; i++) {
if (Cast.compare(list.value[i], item) === 0) {
return true;
}
}
return false;
}
}
module.exports = Scratch3DataBlocks;

View File

@@ -0,0 +1,137 @@
const Cast = require('../util/cast');
class Scratch3EventBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
this.runtime.on('KEY_PRESSED', key => {
this.runtime.startHats('event_whenkeypressed', {
KEY_OPTION: key
});
this.runtime.startHats('event_whenkeypressed', {
KEY_OPTION: 'any'
});
});
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
event_whentouchingobject: this.touchingObject,
event_broadcast: this.broadcast,
event_broadcastandwait: this.broadcastAndWait,
event_whengreaterthan: this.hatGreaterThanPredicate
};
}
getHats () {
return {
event_whenflagclicked: {
restartExistingThreads: true
},
event_whenkeypressed: {
restartExistingThreads: false
},
event_whenthisspriteclicked: {
restartExistingThreads: true
},
event_whentouchingobject: {
restartExistingThreads: false,
edgeActivated: true
},
event_whenstageclicked: {
restartExistingThreads: true
},
event_whenbackdropswitchesto: {
restartExistingThreads: true
},
event_whengreaterthan: {
restartExistingThreads: false,
edgeActivated: true
},
event_whenbroadcastreceived: {
restartExistingThreads: true
}
};
}
touchingObject (args, util) {
return util.target.isTouchingObject(args.TOUCHINGOBJECTMENU);
}
hatGreaterThanPredicate (args, util) {
const option = Cast.toString(args.WHENGREATERTHANMENU).toLowerCase();
const value = Cast.toNumber(args.VALUE);
switch (option) {
case 'timer':
return util.ioQuery('clock', 'projectTimer') > value;
case 'loudness':
return this.runtime.audioEngine && this.runtime.audioEngine.getLoudness() > value;
}
return false;
}
broadcast (args, util) {
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) {
if (!util.stackFrame.broadcastVar) {
util.stackFrame.broadcastVar = util.runtime.getTargetForStage().lookupBroadcastMsg(
args.BROADCAST_OPTION.id, args.BROADCAST_OPTION.name);
}
if (util.stackFrame.broadcastVar) {
const broadcastOption = util.stackFrame.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;
}
}
// We've run before; check if the wait is still going on.
const instance = this;
// Scratch 2 considers threads to be waiting if they are still in
// runtime.threads. Threads that have run all their blocks, or are
// marked done but still in runtime.threads are still considered to
// be waiting.
const waiting = util.stackFrame.startedThreads
.some(thread => instance.runtime.threads.indexOf(thread) !== -1);
if (waiting) {
// If all threads are waiting for the next tick or later yield
// for a tick as well. Otherwise yield until the next loop of
// the threads.
if (
util.stackFrame.startedThreads
.every(thread => instance.runtime.isWaitingThread(thread))
) {
util.yieldTick();
} else {
util.yield();
}
}
}
}
}
module.exports = Scratch3EventBlocks;

View File

@@ -0,0 +1,615 @@
const Cast = require('../util/cast');
const Clone = require('../util/clone');
const uid = require('../util/uid');
const StageLayering = require('../engine/stage-layering');
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id');
const MathUtil = require('../util/math-util');
/**
* @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 {string} text - the text of the bubble.
* @property {string} type - the type of the bubble, "say" or "think"
* @property {?string} usageId - ID indicating the most recent usage of the say/think bubble.
* Used for comparison when determining whether to clear a say/think bubble.
*/
class Scratch3LooksBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
this._onTargetChanged = this._onTargetChanged.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(Scratch3LooksBlocks.SAY_OR_THINK, 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,
onSpriteRight: true,
skinId: null,
text: '',
type: 'say',
usageId: null
};
}
/**
* The key to load & store a target's bubble-related state.
* @type {string}
*/
static get STATE_KEY () {
return 'Scratch.looks';
}
/**
* Event name for a text bubble being created or updated.
* @const {string}
*/
static get SAY_OR_THINK () {
// There are currently many places in the codebase which explicitly refer to this event by the string 'SAY',
// so keep this as the string 'SAY' for now rather than changing it to 'SAY_OR_THINK' and breaking things.
return 'SAY';
}
/**
* Limit for say bubble string.
* @const {string}
*/
static get SAY_BUBBLE_LIMIT () {
return 330;
}
/**
* Limit for ghost effect
* @const {object}
*/
static get EFFECT_GHOST_LIMIT (){
return {min: 0, max: 100};
}
/**
* Limit for brightness effect
* @const {object}
*/
static get EFFECT_BRIGHTNESS_LIMIT (){
return {min: -100, max: 100};
}
/**
* @param {Target} target - collect bubble state for this target. Probably, but not necessarily, a RenderedTarget.
* @returns {BubbleState} the mutable bubble state associated with that target. This will be created if necessary.
* @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
*/
_onTargetChanged (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, StageLayering.SPRITE_LAYER);
this.runtime.renderer.destroySkin(bubbleState.skinId);
bubbleState.drawableId = null;
bubbleState.skinId = null;
this.runtime.requestRedraw();
}
target.onTargetVisualChange = null;
}
/**
* Handle project start/stop by clearing all visible bubbles.
* @private
*/
_onResetBubbles () {
for (let n = 0; n < this.runtime.targets.length; n++) {
const bubbleState = this._getBubbleState(this.runtime.targets[n]);
bubbleState.text = '';
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) {
if (!target.visible) return;
const bubbleState = this._getBubbleState(target);
const [bubbleWidth, bubbleHeight] = this.runtime.renderer.getCurrentSkinSize(bubbleState.drawableId);
let targetBounds;
try {
targetBounds = target.getBoundsForBubble();
} catch (error_) {
// Bounds calculation could fail (e.g. on empty costumes), in that case
// use the x/y position of the target.
targetBounds = {
left: target.x,
right: target.x,
top: target.y,
bottom: target.y
};
}
const stageSize = this.runtime.renderer.getNativeSize();
const stageBounds = {
left: -stageSize[0] / 2,
right: stageSize[0] / 2,
top: stageSize[1] / 2,
bottom: -stageSize[1] / 2
};
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.updateDrawablePosition(bubbleState.drawableId, [
bubbleState.onSpriteRight ? (
Math.max(
stageBounds.left, // Bubble should not extend past left edge of stage
Math.min(stageBounds.right - bubbleWidth, targetBounds.right)
)
) : (
Math.min(
stageBounds.right - bubbleWidth, // Bubble should not extend past right edge of stage
Math.max(stageBounds.left, targetBounds.left - bubbleWidth)
)
),
// Bubble should not extend past the top of the stage
Math.min(stageBounds.top, targetBounds.bottom + 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) { // used by compiler
if (!this.runtime.renderer) return;
const bubbleState = this._getBubbleState(target);
const {type, text, onSpriteRight} = bubbleState;
// Remove the bubble if target is not visible, or text is being set to blank.
if (!target.visible || text === '') {
this._onTargetWillExit(target);
return;
}
if (bubbleState.skinId) {
this.runtime.renderer.updateTextSkin(bubbleState.skinId, type, text, onSpriteRight, [0, 0]);
} else {
target.onTargetVisualChange = this._onTargetChanged;
bubbleState.drawableId = this.runtime.renderer.createDrawable(StageLayering.SPRITE_LAYER);
bubbleState.skinId = this.runtime.renderer.createTextSkin(type, text, bubbleState.onSpriteRight, [0, 0]);
this.runtime.renderer.updateDrawableSkinId(bubbleState.drawableId, bubbleState.skinId);
}
this._positionBubble(target);
}
/**
* Properly format text for a text bubble.
* @param {string} text The text to be formatted
* @return {string} The formatted text
* @private
*/
_formatBubbleText (text) {
if (text === '') return text;
// Non-integers should be rounded to 2 decimal places (no more, no less), unless they're small enough that
// rounding would display them as 0.00. This matches 2.0's behavior:
// https://github.com/scratchfoundation/scratch-flash/blob/2e4a402ceb205a042887f54b26eebe1c2e6da6c0/src/scratch/ScratchSprite.as#L579-L585
if (typeof text === 'number' &&
Math.abs(text) >= 0.01 && text % 1 !== 0) {
text = text.toFixed(2);
}
// Limit the length of the string.
text = String(text).substr(0, Scratch3LooksBlocks.SAY_BUBBLE_LIMIT);
return text;
}
/**
* 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 = this._formatBubbleText(text);
bubbleState.usageId = uid();
this._renderBubble(target);
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
looks_say: this.say,
looks_sayforsecs: this.sayforsecs,
looks_think: this.think,
looks_thinkforsecs: this.thinkforsecs,
looks_show: this.show,
looks_hide: this.hide,
looks_hideallsprites: () => {}, // legacy no-op block
looks_switchcostumeto: this.switchCostume,
looks_switchbackdropto: this.switchBackdrop,
looks_switchbackdroptoandwait: this.switchBackdropAndWait,
looks_nextcostume: this.nextCostume,
looks_nextbackdrop: this.nextBackdrop,
looks_changeeffectby: this.changeEffect,
looks_seteffectto: this.setEffect,
looks_cleargraphiceffects: this.clearEffects,
looks_changesizeby: this.changeSize,
looks_setsizeto: this.setSize,
looks_changestretchby: () => {}, // legacy no-op blocks
looks_setstretchto: () => {},
looks_gotofrontback: this.goToFrontBack,
looks_goforwardbackwardlayers: this.goForwardBackwardLayers,
looks_size: this.getSize,
looks_costumenumbername: this.getCostumeNumberName,
looks_backdropnumbername: this.getBackdropNumberName
};
}
getMonitored () {
return {
looks_size: {
isSpriteSpecific: true,
getId: targetId => `${targetId}_size`
},
looks_costumenumbername: {
isSpriteSpecific: true,
getId: (targetId, fields) => getMonitorIdForBlockWithArgs(`${targetId}_costumenumbername`, fields)
},
looks_backdropnumbername: {
getId: (_, fields) => getMonitorIdForBlockWithArgs('backdropnumbername', fields)
}
};
}
say (args, util) {
// @TODO in 2.0 calling say/think resets the right/left bias of the bubble
const message = args.MESSAGE;
this._say(message, util.target);
}
_say (message, target) { // used by compiler
this.runtime.emit(Scratch3LooksBlocks.SAY_OR_THINK, target, 'say', message);
}
sayforsecs (args, util) {
this.say(args, util);
const target = util.target;
const usageId = this._getBubbleState(target).usageId;
return new Promise(resolve => {
this._bubbleTimeout = setTimeout(() => {
this._bubbleTimeout = null;
// Clear say bubble if it hasn't been changed and proceed.
if (this._getBubbleState(target).usageId === usageId) {
this._updateBubble(target, 'say', '');
}
resolve();
}, 1000 * args.SECS);
});
}
think (args, util) {
this.runtime.emit(Scratch3LooksBlocks.SAY_OR_THINK, util.target, 'think', args.MESSAGE);
}
thinkforsecs (args, util) {
this.think(args, util);
const target = util.target;
const usageId = this._getBubbleState(target).usageId;
return new Promise(resolve => {
this._bubbleTimeout = setTimeout(() => {
this._bubbleTimeout = null;
// Clear think bubble if it hasn't been changed and proceed.
if (this._getBubbleState(target).usageId === usageId) {
this._updateBubble(target, 'think', '');
}
resolve();
}, 1000 * args.SECS);
});
}
show (args, util) {
util.target.setVisible(true);
this._renderBubble(util.target);
}
hide (args, util) {
util.target.setVisible(false);
this._renderBubble(util.target);
}
/**
* Utility function to set the costume of a target.
* Matches the behavior of Scratch 2.0 for different types of arguments.
* @param {!Target} target Target to set costume to.
* @param {Any} requestedCostume Costume requested, e.g., 0, 'name', etc.
* @param {boolean=} optZeroIndex Set to zero-index the requestedCostume.
* @return {Array.<!Thread>} Any threads started by this switch.
*/
_setCostume (target, requestedCostume, optZeroIndex) { // used by compiler
if (typeof requestedCostume === 'number') {
// Numbers should be treated as costume indices, always
target.setCostume(optZeroIndex ? requestedCostume : requestedCostume - 1);
} else {
// Strings should be treated as costume names, where possible
const costumeIndex = target.getCostumeIndexByName(requestedCostume.toString());
if (costumeIndex !== -1) {
target.setCostume(costumeIndex);
} else if (requestedCostume === 'next costume') {
target.setCostume(target.currentCostume + 1);
} else if (requestedCostume === 'previous costume') {
target.setCostume(target.currentCostume - 1);
// Try to cast the string to a number (and treat it as a costume index)
// Pure whitespace should not be treated as a number
// Note: isNaN will cast the string to a number before checking if it's NaN
} else if (!(isNaN(requestedCostume) || Cast.isWhiteSpace(requestedCostume))) {
target.setCostume(optZeroIndex ? Number(requestedCostume) : Number(requestedCostume) - 1);
}
}
// Per 2.0, 'switch costume' can't start threads even in the Stage.
return [];
}
/**
* Utility function to set the backdrop of a target.
* Matches the behavior of Scratch 2.0 for different types of arguments.
* @param {!Target} stage Target to set backdrop to.
* @param {Any} requestedBackdrop Backdrop requested, e.g., 0, 'name', etc.
* @param {boolean=} optZeroIndex Set to zero-index the requestedBackdrop.
* @return {Array.<!Thread>} Any threads started by this switch.
*/
_setBackdrop (stage, requestedBackdrop, optZeroIndex) { // used by compiler
if (typeof requestedBackdrop === 'number') {
// Numbers should be treated as backdrop indices, always
stage.setCostume(optZeroIndex ? requestedBackdrop : requestedBackdrop - 1);
} else {
// Strings should be treated as backdrop names where possible
const costumeIndex = stage.getCostumeIndexByName(requestedBackdrop.toString());
if (costumeIndex !== -1) {
stage.setCostume(costumeIndex);
} else if (requestedBackdrop === 'next backdrop') {
stage.setCostume(stage.currentCostume + 1);
} else if (requestedBackdrop === 'previous backdrop') {
stage.setCostume(stage.currentCostume - 1);
} else if (requestedBackdrop === 'random backdrop') {
const numCostumes = stage.getCostumes().length;
if (numCostumes > 1) {
// Don't pick the current backdrop, so that the block
// will always have an observable effect.
const lowerBound = 0;
const upperBound = numCostumes - 1;
const costumeToExclude = stage.currentCostume;
const nextCostume = MathUtil.inclusiveRandIntWithout(lowerBound, upperBound, costumeToExclude);
stage.setCostume(nextCostume);
}
// Try to cast the string to a number (and treat it as a costume index)
// Pure whitespace should not be treated as a number
// Note: isNaN will cast the string to a number before checking if it's NaN
} else if (!(isNaN(requestedBackdrop) || Cast.isWhiteSpace(requestedBackdrop))) {
stage.setCostume(optZeroIndex ? Number(requestedBackdrop) : Number(requestedBackdrop) - 1);
}
}
const newName = stage.getCostumes()[stage.currentCostume].name;
return this.runtime.startHats('event_whenbackdropswitchesto', {
BACKDROP: newName
});
}
switchCostume (args, util) {
this._setCostume(util.target, args.COSTUME); // used by compiler
}
nextCostume (args, util) {
this._setCostume(
util.target, util.target.currentCostume + 1, true
);
}
switchBackdrop (args) {
this._setBackdrop(this.runtime.getTargetForStage(), args.BACKDROP);
}
switchBackdropAndWait (args, util) {
// Have we run before, starting threads?
if (!util.stackFrame.startedThreads) {
// No - switch the backdrop.
util.stackFrame.startedThreads = (
this._setBackdrop(
this.runtime.getTargetForStage(),
args.BACKDROP
)
);
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;
// Scratch 2 considers threads to be waiting if they are still in
// runtime.threads. Threads that have run all their blocks, or are
// marked done but still in runtime.threads are still considered to
// be waiting.
const waiting = util.stackFrame.startedThreads
.some(thread => instance.runtime.threads.indexOf(thread) !== -1);
if (waiting) {
// If all threads are waiting for the next tick or later yield
// for a tick as well. Otherwise yield until the next loop of
// the threads.
if (
util.stackFrame.startedThreads
.every(thread => instance.runtime.isWaitingThread(thread))
) {
util.yieldTick();
} else {
util.yield();
}
}
}
nextBackdrop () {
const stage = this.runtime.getTargetForStage();
this._setBackdrop(
stage, stage.currentCostume + 1, true
);
}
clampEffect (effect, value) { // used by compiler
let clampedValue = value;
switch (effect) {
case 'ghost':
clampedValue = MathUtil.clamp(value,
Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.min,
Scratch3LooksBlocks.EFFECT_GHOST_LIMIT.max);
break;
case 'brightness':
clampedValue = MathUtil.clamp(value,
Scratch3LooksBlocks.EFFECT_BRIGHTNESS_LIMIT.min,
Scratch3LooksBlocks.EFFECT_BRIGHTNESS_LIMIT.max);
break;
}
return clampedValue;
}
changeEffect (args, util) {
const effect = Cast.toString(args.EFFECT).toLowerCase();
const change = Cast.toNumber(args.CHANGE);
if (!Object.prototype.hasOwnProperty.call(util.target.effects, effect)) return;
let newValue = change + util.target.effects[effect];
newValue = this.clampEffect(effect, newValue);
util.target.setEffect(effect, newValue);
}
setEffect (args, util) {
const effect = Cast.toString(args.EFFECT).toLowerCase();
let value = Cast.toNumber(args.VALUE);
value = this.clampEffect(effect, value);
util.target.setEffect(effect, value);
}
clearEffects (args, util) {
util.target.clearEffects();
}
changeSize (args, util) {
const change = Cast.toNumber(args.CHANGE);
util.target.setSize(util.target.size + change);
}
setSize (args, util) {
const size = Cast.toNumber(args.SIZE);
util.target.setSize(size);
}
goToFrontBack (args, util) {
if (!util.target.isStage) {
if (args.FRONT_BACK === 'front') {
util.target.goToFront();
} else {
util.target.goToBack();
}
}
}
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);
}
getBackdropNumberName (args) {
const stage = this.runtime.getTargetForStage();
if (args.NUMBER_NAME === 'number') {
return stage.currentCostume + 1;
}
// Else return name
return stage.getCostumes()[stage.currentCostume].name;
}
getCostumeNumberName (args, util) {
if (args.NUMBER_NAME === 'number') {
return util.target.currentCostume + 1;
}
// Else return name
return util.target.getCostumes()[util.target.currentCostume].name;
}
}
module.exports = Scratch3LooksBlocks;

View File

@@ -0,0 +1,328 @@
const Cast = require('../util/cast');
const { debug } = require('../util/log');
const MathUtil = require('../util/math-util');
const Timer = require('../util/timer');
class Scratch3MotionBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
console.log("Primitives being registered");
return {
motion_movesteps: this.moveSteps,
motion_testblock: this.testblock,
motion_move_f: this.MoveFSteps,
motion_move_b: this.MoveBSteps,
motion_cmd_print: this.CMDprint,
motion_gotoxy: this.goToXY,
motion_goto: this.goTo,
motion_turnright: this.turnRight,
motion_turnleft: this.turnLeft,
motion_jump_move: this.motion_jump_move,
motion_pointindirection: this.pointInDirection,
motion_pointtowards: this.pointTowards,
motion_glidesecstoxy: this.glide,
motion_glideto: this.glideTo,
motion_ifonedgebounce: this.ifOnEdgeBounce,
motion_setrotationstyle: this.setRotationStyle,
motion_changexby: this.changeX,
motion_setx: this.setX,
motion_changeyby: this.changeY,
motion_sety: this.setY,
motion_xposition: this.getX,
motion_yposition: this.getY,
motion_direction: this.getDirection,
// Legacy no-op blocks:
motion_scroll_right: () => {},
motion_scroll_up: () => {},
motion_align_scene: () => {},
motion_xscroll: () => {},
motion_yscroll: () => {}
};
}
getMonitored () {
return {
motion_xposition: {
isSpriteSpecific: true,
getId: targetId => `${targetId}_xposition`
},
motion_yposition: {
isSpriteSpecific: true,
getId: targetId => `${targetId}_yposition`
},
motion_direction: {
isSpriteSpecific: true,
getId: targetId => `${targetId}_direction`
}
};
}
moveSteps (args, util) {
const steps = Cast.toNumber(args.STEPS);
this._moveSteps(steps, util.target);
}
MoveBSteps (args, util) {
// const steps = Cast.toNumber(args.STEPS);
// this._moveSteps(steps, util.target);
}
MoveFSteps (args, util) {
// const steps = Cast.toNumber(args.STEPS);
// this._moveSteps(steps, util.target);
}
CMDprint (args, util) {
// const text = Cast.toString(args.TEXT);
// console.log(text);
}
_moveSteps (steps, target) { // used by compiler
const radians = MathUtil.degToRad(90 - target.direction);
const dx = steps * Math.cos(radians);
const dy = steps * Math.sin(radians);
target.setXY(target.x + dx, target.y + dy);
}
goToXY (args, util) {
const x = Cast.toNumber(args.X);
const y = Cast.toNumber(args.Y);
util.target.setXY(x, y);
}
getTargetXY (targetName, util) {
let targetX = 0;
let targetY = 0;
if (targetName === '_mouse_') {
targetX = util.ioQuery('mouse', 'getScratchX');
targetY = util.ioQuery('mouse', 'getScratchY');
} else if (targetName === '_random_') {
const stageWidth = this.runtime.stageWidth;
const stageHeight = this.runtime.stageHeight;
targetX = Math.round(stageWidth * (Math.random() - 0.5));
targetY = Math.round(stageHeight * (Math.random() - 0.5));
} else {
targetName = Cast.toString(targetName);
const goToTarget = this.runtime.getSpriteTargetByName(targetName);
if (!goToTarget) return;
targetX = goToTarget.x;
targetY = goToTarget.y;
}
return [targetX, targetY];
}
goTo (args, util) {
const targetXY = this.getTargetXY(args.TO, util);
if (targetXY) {
util.target.setXY(targetXY[0], targetXY[1]);
}
}
testblock (args, util) {
const steps = Cast.toNumber(args.STEPS);
this._moveSteps(steps, util.target);
}
turnRight (args, util) {
// const degrees = Cast.toNumber(args.DEGREES);
// util.target.setDirection(util.target.direction + degrees);
}
turnLeft (args, util) {
// const degrees = Cast.toNumber(args.DEGREES);
// util.target.setDirection(util.target.direction - degrees);
}
motion_jump_move (args, util) {
}
pointInDirection (args, util) {
const direction = Cast.toNumber(args.DIRECTION);
util.target.setDirection(direction);
}
pointTowards (args, util) {
let targetX = 0;
let targetY = 0;
if (args.TOWARDS === '_mouse_') {
targetX = util.ioQuery('mouse', 'getScratchX');
targetY = util.ioQuery('mouse', 'getScratchY');
} else if (args.TOWARDS === '_random_') {
util.target.setDirection(Math.round(Math.random() * 360) - 180);
return;
} else {
args.TOWARDS = Cast.toString(args.TOWARDS);
const pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS);
if (!pointTarget) return;
targetX = pointTarget.x;
targetY = pointTarget.y;
}
const dx = targetX - util.target.x;
const dy = targetY - util.target.y;
const direction = 90 - MathUtil.radToDeg(Math.atan2(dy, dx));
util.target.setDirection(direction);
}
glide (args, util) {
if (util.stackFrame.timer) {
const timeElapsed = util.stackFrame.timer.timeElapsed();
if (timeElapsed < util.stackFrame.duration * 1000) {
// In progress: move to intermediate position.
const frac = timeElapsed / (util.stackFrame.duration * 1000);
const dx = frac * (util.stackFrame.endX - util.stackFrame.startX);
const dy = frac * (util.stackFrame.endY - util.stackFrame.startY);
util.target.setXY(
util.stackFrame.startX + dx,
util.stackFrame.startY + dy
);
util.yield();
} else {
// Finished: move to final position.
util.target.setXY(util.stackFrame.endX, util.stackFrame.endY);
}
} else {
// First time: save data for future use.
util.stackFrame.timer = new Timer();
util.stackFrame.timer.start();
util.stackFrame.duration = Cast.toNumber(args.SECS);
util.stackFrame.startX = util.target.x;
util.stackFrame.startY = util.target.y;
util.stackFrame.endX = Cast.toNumber(args.X);
util.stackFrame.endY = Cast.toNumber(args.Y);
if (util.stackFrame.duration <= 0) {
// Duration too short to glide.
util.target.setXY(util.stackFrame.endX, util.stackFrame.endY);
return;
}
util.yield();
}
}
glideTo (args, util) {
const targetXY = this.getTargetXY(args.TO, util);
if (targetXY) {
this.glide({SECS: args.SECS, X: targetXY[0], Y: targetXY[1]}, util);
}
}
ifOnEdgeBounce (args, util) {
this._ifOnEdgeBounce(util.target);
}
_ifOnEdgeBounce (target) { // used by compiler
const bounds = target.getBounds();
if (!bounds) {
return;
}
// Measure distance to edges.
// Values are positive when the sprite is far away,
// and clamped to zero when the sprite is beyond.
const stageWidth = this.runtime.stageWidth;
const stageHeight = this.runtime.stageHeight;
const distLeft = Math.max(0, (stageWidth / 2) + bounds.left);
const distTop = Math.max(0, (stageHeight / 2) - bounds.top);
const distRight = Math.max(0, (stageWidth / 2) - bounds.right);
const distBottom = Math.max(0, (stageHeight / 2) + bounds.bottom);
// Find the nearest edge.
let nearestEdge = '';
let minDist = Infinity;
if (distLeft < minDist) {
minDist = distLeft;
nearestEdge = 'left';
}
if (distTop < minDist) {
minDist = distTop;
nearestEdge = 'top';
}
if (distRight < minDist) {
minDist = distRight;
nearestEdge = 'right';
}
if (distBottom < minDist) {
minDist = distBottom;
nearestEdge = 'bottom';
}
if (minDist > 0) {
return; // Not touching any edge.
}
// Point away from the nearest edge.
const radians = MathUtil.degToRad(90 - target.direction);
let dx = Math.cos(radians);
let dy = -Math.sin(radians);
if (nearestEdge === 'left') {
dx = Math.max(0.2, Math.abs(dx));
} else if (nearestEdge === 'top') {
dy = Math.max(0.2, Math.abs(dy));
} else if (nearestEdge === 'right') {
dx = 0 - Math.max(0.2, Math.abs(dx));
} else if (nearestEdge === 'bottom') {
dy = 0 - Math.max(0.2, Math.abs(dy));
}
const newDirection = MathUtil.radToDeg(Math.atan2(dy, dx)) + 90;
target.setDirection(newDirection);
// Keep within the stage.
const fencedPosition = target.keepInFence(target.x, target.y);
target.setXY(fencedPosition[0], fencedPosition[1]);
}
setRotationStyle (args, util) {
util.target.setRotationStyle(args.STYLE);
}
changeX (args, util) {
const dx = Cast.toNumber(args.DX);
util.target.setXY(util.target.x + dx, util.target.y);
}
setX (args, util) {
const x = Cast.toNumber(args.X);
util.target.setXY(x, util.target.y);
}
changeY (args, util) {
const dy = Cast.toNumber(args.DY);
util.target.setXY(util.target.x, util.target.y + dy);
}
setY (args, util) {
debugger
const y = Cast.toNumber(args.Y);
util.target.setXY(util.target.x, y);
}
getX (args, util) {
// return this.limitPrecision(util.target.x);
}
getY (args, util) {
// return this.limitPrecision(util.target.y);
}
getDirection (args, util) {
// return util.target.direction;
}
// This corresponds to snapToInteger in Scratch 2
limitPrecision (coordinate) {
const rounded = Math.round(coordinate);
const delta = coordinate - rounded;
const limitedCoord = (Math.abs(delta) < 1e-9) ? rounded : coordinate;
return limitedCoord;
}
}
module.exports = Scratch3MotionBlocks;

View File

@@ -0,0 +1,157 @@
const Cast = require('../util/cast.js');
const MathUtil = require('../util/math-util.js');
class Scratch3OperatorsBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
operator_add: this.add,
operator_subtract: this.subtract,
operator_multiply: this.multiply,
operator_divide: this.divide,
operator_lt: this.lt,
operator_equals: this.equals,
operator_gt: this.gt,
operator_and: this.and,
operator_or: this.or,
operator_not: this.not,
operator_random: this.random,
operator_join: this.join,
operator_letter_of: this.letterOf,
operator_length: this.length,
operator_contains: this.contains,
operator_mod: this.mod,
operator_round: this.round,
operator_mathop: this.mathop
};
}
add (args) {
return Cast.toNumber(args.NUM1) + Cast.toNumber(args.NUM2);
}
subtract (args) {
return Cast.toNumber(args.NUM1) - Cast.toNumber(args.NUM2);
}
multiply (args) {
return Cast.toNumber(args.NUM1) * Cast.toNumber(args.NUM2);
}
divide (args) {
return Cast.toNumber(args.NUM1) / Cast.toNumber(args.NUM2);
}
lt (args) {
return Cast.compare(args.OPERAND1, args.OPERAND2) < 0;
}
equals (args) {
return Cast.compare(args.OPERAND1, args.OPERAND2) === 0;
}
gt (args) {
return Cast.compare(args.OPERAND1, args.OPERAND2) > 0;
}
and (args) {
return Cast.toBoolean(args.OPERAND1) && Cast.toBoolean(args.OPERAND2);
}
or (args) {
return Cast.toBoolean(args.OPERAND1) || Cast.toBoolean(args.OPERAND2);
}
not (args) {
return !Cast.toBoolean(args.OPERAND);
}
random (args) {
return this._random(args.FROM, args.TO);
}
_random (from, to) { // used by compiler
const nFrom = Cast.toNumber(from);
const nTo = Cast.toNumber(to);
const low = nFrom <= nTo ? nFrom : nTo;
const high = nFrom <= nTo ? nTo : nFrom;
if (low === high) return low;
// If both arguments are ints, truncate the result to an int.
if (Cast.isInt(from) && Cast.isInt(to)) {
return low + Math.floor(Math.random() * ((high + 1) - low));
}
return (Math.random() * (high - low)) + low;
}
join (args) {
return Cast.toString(args.STRING1) + Cast.toString(args.STRING2);
}
letterOf (args) {
const index = Cast.toNumber(args.LETTER) - 1;
const str = Cast.toString(args.STRING);
// Out of bounds?
if (index < 0 || index >= str.length) {
return '';
}
return str.charAt(index);
}
length (args) {
return Cast.toString(args.STRING).length;
}
contains (args) {
const format = function (string) {
return Cast.toString(string).toLowerCase();
};
return format(args.STRING1).includes(format(args.STRING2));
}
mod (args) {
const n = Cast.toNumber(args.NUM1);
const modulus = Cast.toNumber(args.NUM2);
let result = n % modulus;
// Scratch mod uses floored division instead of truncated division.
if (result / modulus < 0) result += modulus;
return result;
}
round (args) {
return Math.round(Cast.toNumber(args.NUM));
}
mathop (args) {
const operator = Cast.toString(args.OPERATOR).toLowerCase();
const n = Cast.toNumber(args.NUM);
switch (operator) {
case 'abs': return Math.abs(n);
case 'floor': return Math.floor(n);
case 'ceiling': return Math.ceil(n);
case 'sqrt': return Math.sqrt(n);
case 'sin': return Math.round(Math.sin((Math.PI * n) / 180) * 1e10) / 1e10;
case 'cos': return Math.round(Math.cos((Math.PI * n) / 180) * 1e10) / 1e10;
case 'tan': return MathUtil.tan(n);
case 'asin': return (Math.asin(n) * 180) / Math.PI;
case 'acos': return (Math.acos(n) * 180) / Math.PI;
case 'atan': return (Math.atan(n) * 180) / Math.PI;
case 'ln': return Math.log(n);
case 'log': return Math.log(n) / Math.LN10;
case 'e ^': return Math.exp(n);
case '10 ^': return Math.pow(10, n);
}
return 0;
}
}
module.exports = Scratch3OperatorsBlocks;

View File

@@ -0,0 +1,136 @@
class Scratch3ProcedureBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
procedures_definition: this.definition,
procedures_call: this.call,
procedures_return: this.return,
argument_reporter_string_number: this.argumentReporterStringNumber,
argument_reporter_boolean: this.argumentReporterBoolean
};
}
definition () {
// No-op: execute the blocks.
}
call (args, util) {
const stackFrame = util.stackFrame;
const isReporter = !!args.mutation.return;
if (stackFrame.executed) {
if (isReporter) {
const returnValue = stackFrame.returnValue;
// This stackframe will be reused for other reporters in this block, so clean it up for them.
// Can't use reset() because that will reset too much.
const threadStackFrame = util.thread.peekStackFrame();
threadStackFrame.params = null;
delete stackFrame.returnValue;
delete stackFrame.executed;
return returnValue;
}
return;
}
const procedureCode = args.mutation.proccode;
const paramNamesIdsAndDefaults = util.getProcedureParamNamesIdsAndDefaults(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 (paramNamesIdsAndDefaults === null) {
if (isReporter) {
return '';
}
return;
}
const [paramNames, paramIds, paramDefaults] = paramNamesIdsAndDefaults;
// Initialize params for the current stackFrame to {}, even if the procedure does
// not take any arguments. This is so that `getParam` down the line does not look
// at earlier stack frames for the values of a given parameter (#1729)
util.initParams();
for (let i = 0; i < paramIds.length; i++) {
if (Object.prototype.hasOwnProperty.call(args, paramIds[i])) {
util.pushParam(paramNames[i], args[paramIds[i]]);
} else {
util.pushParam(paramNames[i], paramDefaults[i]);
}
}
const addonBlock = util.runtime.getAddonBlock(procedureCode);
if (addonBlock) {
const result = addonBlock.callback(util.thread.getAllparams(), util);
if (util.thread.status === 1 /* STATUS_PROMISE_WAIT */) {
// If the addon block is using STATUS_PROMISE_WAIT to force us to sleep,
// make sure to not re-run this block when we resume.
stackFrame.executed = true;
}
return result;
}
stackFrame.executed = true;
if (isReporter) {
util.thread.peekStackFrame().waitingReporter = true;
// Default return value
stackFrame.returnValue = '';
}
util.startProcedure(procedureCode);
}
return (args, util) {
util.stopThisScript();
// If used outside of a custom block, there may be no stackframe.
if (util.thread.peekStackFrame()) {
util.stackFrame.returnValue = args.VALUE;
}
}
argumentReporterStringNumber (args, util) {
const value = util.getParam(args.VALUE);
if (value === null) {
// tw: support legacy block
if (String(args.VALUE).toLowerCase() === 'last key pressed') {
return util.ioQuery('keyboard', 'getLastKeyPressed');
}
// When the parameter is not found in the most recent procedure
// call, the default is always 0.
return 0;
}
return value;
}
argumentReporterBoolean (args, util) {
const value = util.getParam(args.VALUE);
if (value === null) {
// tw: implement is compiled? and is turbowarp?
const lowercaseValue = String(args.VALUE).toLowerCase();
if (util.target.runtime.compilerOptions.enabled && lowercaseValue === 'is compiled?') {
return true;
}
if (lowercaseValue === 'is turbowarp?') {
return true;
}
// When the parameter is not found in the most recent procedure
// call, the default is always 0.
return 0;
}
return value;
}
}
module.exports = Scratch3ProcedureBlocks;

View File

@@ -0,0 +1,348 @@
const Cast = require('../util/cast');
const Timer = require('../util/timer');
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id');
class Scratch3SensingBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* The "answer" block value.
* @type {string}
*/
this._answer = ''; // used by compiler
/**
* The timer utility.
* @type {Timer}
*/
this._timer = new Timer();
/**
* The stored microphone loudness measurement.
* @type {number}
*/
this._cachedLoudness = -1;
/**
* The time of the most recent microphone loudness measurement.
* @type {number}
*/
this._cachedLoudnessTimestamp = 0;
/**
* 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));
this.runtime.on('STOP_FOR_TARGET', this._clearTargetQuestions.bind(this));
this.runtime.on('RUNTIME_DISPOSED', this._resetAnswer.bind(this));
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
sensing_touchingobject: this.touchingObject,
sensing_touchingcolor: this.touchingColor,
sensing_coloristouchingcolor: this.colorTouchingColor,
sensing_distanceto: this.distanceTo,
sensing_timer: this.getTimer,
sensing_resettimer: this.resetTimer,
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_loud: this.isLoud,
sensing_askandwait: this.askAndWait,
sensing_answer: this.getAnswer,
sensing_username: this.getUsername,
sensing_userid: () => {} // legacy no-op block
};
}
getMonitored () {
return {
sensing_answer: {
getId: () => 'answer'
},
sensing_mousedown: {
getId: () => 'mousedown'
},
sensing_mousex: {
getId: () => 'mousex'
},
sensing_mousey: {
getId: () => 'mousey'
},
sensing_loudness: {
getId: () => 'loudness'
},
sensing_timer: {
getId: () => 'timer'
},
sensing_dayssince2000: {
getId: () => 'dayssince2000'
},
sensing_current: {
// This is different from the default toolbox xml id in order to support
// importing multiple monitors from the same opcode from sb2 files,
// something that is not currently supported in scratch 3.
getId: (_, fields) => getMonitorIdForBlockWithArgs('current', fields) // _${param}`
}
};
}
_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);
}
_clearTargetQuestions (stopTarget) {
const currentlyAsking = this._questionList.length > 0 && this._questionList[0][2] === stopTarget;
this._questionList = this._questionList.filter(question => (
question[2] !== stopTarget
));
if (currentlyAsking) {
this.runtime.emit('SAY', stopTarget, 'say', '');
if (this._questionList.length > 0) {
this._askNextQuestion();
} else {
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) {
return util.target.isTouchingObject(args.TOUCHINGOBJECTMENU);
}
touchingColor (args, util) {
const color = Cast.toRgbColorList(args.COLOR);
return util.target.isTouchingColor(color);
}
colorTouchingColor (args, util) {
const maskColor = Cast.toRgbColorList(args.COLOR);
const targetColor = Cast.toRgbColorList(args.COLOR2);
return util.target.colorIsTouchingColor(targetColor, maskColor);
}
distanceTo (args, util) {
if (util.target.isStage) return 10000;
let targetX = 0;
let targetY = 0;
if (args.DISTANCETOMENU === '_mouse_') {
targetX = util.ioQuery('mouse', 'getScratchX');
targetY = util.ioQuery('mouse', 'getScratchY');
} else {
args.DISTANCETOMENU = Cast.toString(args.DISTANCETOMENU);
const distTarget = this.runtime.getSpriteTargetByName(
args.DISTANCETOMENU
);
if (!distTarget) return 10000;
targetX = distTarget.x;
targetY = distTarget.y;
}
const dx = util.target.x - targetX;
const dy = util.target.y - targetY;
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');
}
resetTimer (args, util) {
util.ioQuery('clock', 'resetProjectTimer');
}
getMouseX (args, util) {
return util.ioQuery('mouse', 'getScratchX');
}
getMouseY (args, util) {
return util.ioQuery('mouse', 'getScratchY');
}
getMouseDown (args, util) {
return util.ioQuery('mouse', 'getIsDown');
}
current (args) {
const menuOption = Cast.toString(args.CURRENTMENU).toLowerCase();
const date = new Date();
switch (menuOption) {
case 'year': return date.getFullYear();
case 'month': return date.getMonth() + 1; // getMonth is zero-based
case 'date': return date.getDate();
case 'dayofweek': return date.getDay() + 1; // getDay is zero-based, Sun=0
case 'hour': return date.getHours();
case 'minute': return date.getMinutes();
case 'second': return date.getSeconds();
}
return 0;
}
getKeyPressed (args, util) {
return util.ioQuery('keyboard', 'getKeyIsDown', [args.KEY_OPTION]);
}
daysSince2000 () {
const msPerDay = 24 * 60 * 60 * 1000;
const start = new Date(2000, 0, 1); // Months are 0-indexed.
const today = new Date();
const dstAdjust = today.getTimezoneOffset() - start.getTimezoneOffset();
let mSecsSinceStart = today.valueOf() - start.valueOf();
mSecsSinceStart += ((today.getTimezoneOffset() - dstAdjust) * 60 * 1000);
return mSecsSinceStart / msPerDay;
}
getLoudness () {
if (typeof this.runtime.audioEngine === 'undefined') return -1;
if (this.runtime.currentStepTime === null) return -1;
// Only measure loudness once per step
const timeSinceLoudness = this._timer.time() - this._cachedLoudnessTimestamp;
if (timeSinceLoudness < this.runtime.currentStepTime) {
return this._cachedLoudness;
}
this._cachedLoudnessTimestamp = this._timer.time();
this._cachedLoudness = this.runtime.audioEngine.getLoudness();
return this._cachedLoudness;
}
isLoud () {
return this.getLoudness() > 10;
}
getAttributeOf (args) {
let attrTarget;
if (args.OBJECT === '_stage_') {
attrTarget = this.runtime.getTargetForStage();
} else {
args.OBJECT = Cast.toString(args.OBJECT);
attrTarget = this.runtime.getSpriteTargetByName(args.OBJECT);
}
// attrTarget can be undefined if the target does not exist
// (e.g. single sprite uploaded from larger project referencing
// another sprite that wasn't uploaded)
if (!attrTarget) return 0;
// Generic attributes
if (attrTarget.isStage) {
switch (args.PROPERTY) {
// Scratch 1.4 support
case 'background #': return attrTarget.currentCostume + 1;
case 'backdrop #': return attrTarget.currentCostume + 1;
case 'backdrop name':
return attrTarget.getCostumes()[attrTarget.currentCostume].name;
case 'volume': return attrTarget.volume;
}
} else {
switch (args.PROPERTY) {
case 'x position': return attrTarget.x;
case 'y position': return attrTarget.y;
case 'direction': return attrTarget.direction;
case 'costume #': return attrTarget.currentCostume + 1;
case 'costume name':
return attrTarget.getCostumes()[attrTarget.currentCostume].name;
case 'size': return attrTarget.size;
case 'volume': return attrTarget.volume;
}
}
// Target variables.
const varName = args.PROPERTY;
const variable = attrTarget.lookupVariableByNameAndType(varName, '', true);
if (variable) {
return variable.value;
}
// Otherwise, 0
return 0;
}
getUsername (args, util) {
return util.ioQuery('userData', 'getUsername');
}
}
module.exports = Scratch3SensingBlocks;

View File

@@ -0,0 +1,373 @@
const MathUtil = require('../util/math-util');
const Cast = require('../util/cast');
const Clone = require('../util/clone');
/**
* Occluded boolean value to make its use more understandable.
* @const {boolean}
*/
const STORE_WAITING = true;
class Scratch3SoundBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
this.waitingSounds = {};
// Clear sound effects on green flag and stop button events.
this.stopAllSounds = this.stopAllSounds.bind(this);
this._stopWaitingSoundsForTarget = this._stopWaitingSoundsForTarget.bind(this);
this._clearEffectsForAllTargets = this._clearEffectsForAllTargets.bind(this);
if (this.runtime) {
this.runtime.on('PROJECT_STOP_ALL', this.stopAllSounds);
this.runtime.on('PROJECT_STOP_ALL', this._clearEffectsForAllTargets);
this.runtime.on('STOP_FOR_TARGET', this._stopWaitingSoundsForTarget);
this.runtime.on('PROJECT_START', this._clearEffectsForAllTargets);
}
this._onTargetCreated = this._onTargetCreated.bind(this);
if (this.runtime) {
runtime.on('targetWasCreated', this._onTargetCreated);
}
}
/**
* The key to load & store a target's sound-related state.
* @type {string}
*/
static get STATE_KEY () {
return 'Scratch.sound';
}
/**
* The default sound-related state, to be used when a target has no existing sound state.
* @type {SoundState}
*/
static get DEFAULT_SOUND_STATE () {
return {
effects: {
pitch: 0,
pan: 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: 36, max: 96}; // C2 to C7
}
/**
* 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 minimum and maximum values for each sound effect.
* @type {{effect:{min: number, max: number}}}
*/
static get EFFECT_RANGE () {
return {
pitch: {min: -360, max: 360}, // -3 to 3 octaves
pan: {min: -100, max: 100} // 100% left to 100% right
};
}
/** The minimum and maximum values for sound effects when miscellaneous limits are removed. */
static get LARGER_EFFECT_RANGE () {
return {
// scratch-audio throws if pitch is too big because some math results in Infinity
pitch: {min: -1000, max: 1000},
// No reason for these to go beyond 100
pan: {min: -100, max: 100}
};
}
/**
* @param {Target} target - collect sound state for this target.
* @returns {SoundState} the mutable sound state associated with that target. This will be created if necessary.
* @private
*/
_getSoundState (target) {
let soundState = target.getCustomState(Scratch3SoundBlocks.STATE_KEY);
if (!soundState) {
soundState = Clone.simple(Scratch3SoundBlocks.DEFAULT_SOUND_STATE);
target.setCustomState(Scratch3SoundBlocks.STATE_KEY, soundState);
target.soundEffects = soundState.effects;
}
return soundState;
}
/**
* When a Target is cloned, clone the sound 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 soundState = sourceTarget.getCustomState(Scratch3SoundBlocks.STATE_KEY);
if (soundState && newTarget) {
newTarget.setCustomState(Scratch3SoundBlocks.STATE_KEY, Clone.simple(soundState));
this._syncEffectsForTarget(newTarget);
}
}
}
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
sound_play: this.playSound,
sound_playuntildone: this.playSoundAndWait,
sound_stopallsounds: this.stopAllSounds,
sound_seteffectto: this.setEffect,
sound_changeeffectby: this.changeEffect,
sound_cleareffects: this.clearEffects,
sound_sounds_menu: this.soundsMenu,
sound_beats_menu: this.beatsMenu,
sound_effects_menu: this.effectsMenu,
sound_setvolumeto: this.setVolume,
sound_changevolumeby: this.changeVolume,
sound_volume: this.getVolume
};
}
getMonitored () {
return {
sound_volume: {
isSpriteSpecific: true,
getId: targetId => `${targetId}_volume`
}
};
}
playSound (args, util) {
// Don't return the promise, it's the only difference for AndWait
this._playSound(args, util);
}
playSoundAndWait (args, util) {
return this._playSound(args, util, STORE_WAITING);
}
_playSound (args, util, storeWaiting) {
const index = this._getSoundIndex(args.SOUND_MENU, util);
if (index >= 0) {
const {target} = util;
const {sprite} = target;
const {soundId} = sprite.sounds[index];
if (sprite.soundBank) {
if (storeWaiting === STORE_WAITING) {
this._addWaitingSound(target.id, soundId);
} else {
this._removeWaitingSound(target.id, soundId);
}
return sprite.soundBank.playSound(target, soundId);
}
}
}
_addWaitingSound (targetId, soundId) {
if (!this.waitingSounds[targetId]) {
this.waitingSounds[targetId] = new Set();
}
this.waitingSounds[targetId].add(soundId);
}
_removeWaitingSound (targetId, soundId) {
if (!this.waitingSounds[targetId]) {
return;
}
this.waitingSounds[targetId].delete(soundId);
}
_getSoundIndex (soundName, util) {
// if the sprite has no sounds, return -1
const len = util.target.sprite.sounds.length;
if (len === 0) {
return -1;
}
// look up by name first
const index = this.getSoundIndexByName(soundName, util);
if (index !== -1) {
return index;
}
// then try using the sound name as a 1-indexed index
const oneIndexedIndex = parseInt(soundName, 10);
if (!isNaN(oneIndexedIndex)) {
return MathUtil.wrapClamp(oneIndexedIndex - 1, 0, len - 1);
}
// could not be found as a name or converted to index, return -1
return -1;
}
getSoundIndexByName (soundName, util) {
const sounds = util.target.sprite.sounds;
for (let i = 0; i < sounds.length; i++) {
if (sounds[i].name === soundName) {
return i;
}
}
// if there is no sound by that name, return -1
return -1;
}
stopAllSounds () {
if (this.runtime.targets === null) return;
const allTargets = this.runtime.targets;
for (let i = 0; i < allTargets.length; i++) {
this._stopAllSoundsForTarget(allTargets[i]);
}
}
_stopAllSoundsForTarget (target) {
if (target.sprite.soundBank) {
target.sprite.soundBank.stopAllSounds(target);
if (this.waitingSounds[target.id]) {
this.waitingSounds[target.id].clear();
}
}
}
_stopWaitingSoundsForTarget (target) {
if (target.sprite.soundBank) {
if (this.waitingSounds[target.id]) {
for (const soundId of this.waitingSounds[target.id].values()) {
target.sprite.soundBank.stop(target, soundId);
}
this.waitingSounds[target.id].clear();
}
}
}
setEffect (args, util) {
return this._updateEffect(args, util, false);
}
changeEffect (args, util) {
return this._updateEffect(args, util, true);
}
_updateEffect (args, util, change) {
const effect = Cast.toString(args.EFFECT).toLowerCase();
const value = Cast.toNumber(args.VALUE);
const soundState = this._getSoundState(util.target);
if (!Object.prototype.hasOwnProperty.call(soundState.effects, effect)) return;
if (change) {
soundState.effects[effect] += value;
} else {
soundState.effects[effect] = value;
}
const miscLimits = this.runtime.runtimeOptions.miscLimits;
const {min, max} = miscLimits ?
Scratch3SoundBlocks.EFFECT_RANGE[effect] :
Scratch3SoundBlocks.LARGER_EFFECT_RANGE[effect];
soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max);
this._syncEffectsForTarget(util.target);
if (miscLimits) {
// Yield until the next tick.
return Promise.resolve();
}
// Requesting a redraw makes sure that "forever: change pitch by 1" still work but without
// yielding unnecessarily in other cases
this.runtime.requestRedraw();
}
_syncEffectsForTarget (target) {
if (!target || !target.sprite.soundBank) return;
target.soundEffects = this._getSoundState(target).effects;
target.sprite.soundBank.setEffects(target);
}
clearEffects (args, util) {
this._clearEffectsForTarget(util.target);
}
_clearEffectsForTarget (target) {
const soundState = this._getSoundState(target);
for (const effect in soundState.effects) {
if (!Object.prototype.hasOwnProperty.call(soundState.effects, effect)) continue;
soundState.effects[effect] = 0;
}
this._syncEffectsForTarget(target);
}
_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) {
const volume = Cast.toNumber(args.VOLUME);
return this._updateVolume(volume, util);
}
changeVolume (args, util) {
const volume = Cast.toNumber(args.VOLUME) + util.target.volume;
return this._updateVolume(volume, util);
}
_updateVolume (volume, util) {
volume = MathUtil.clamp(volume, 0, 100);
util.target.volume = volume;
this._syncEffectsForTarget(util.target);
if (this.runtime.runtimeOptions.miscLimits) {
// Yield until the next tick.
return Promise.resolve();
}
this.runtime.requestRedraw();
}
getVolume (args, util) {
return util.target.volume;
}
soundsMenu (args) {
return args.SOUND_MENU;
}
beatsMenu (args) {
return args.BEATS;
}
effectsMenu (args) {
return args.EFFECT;
}
}
module.exports = Scratch3SoundBlocks;

View File

@@ -0,0 +1,41 @@
const fs = require('fs');
const VirtualMachine = require('../index');
/* eslint-env node */
/* eslint-disable no-console */
const file = process.argv[2];
if (!file) {
throw new Error('Invalid file');
}
const runProject = async buffer => {
const vm = new VirtualMachine();
vm.runtime.on('SAY', (target, type, text) => {
console.log(text);
});
vm.setCompatibilityMode(true);
vm.clear();
await vm.loadProject(buffer);
vm.start();
vm.greenFlag();
await new Promise(resolve => {
const interval = setInterval(() => {
let active = 0;
const threads = vm.runtime.threads;
for (let i = 0; i < threads.length; i++) {
if (!threads[i].updateMonitor) {
active += 1;
}
}
if (active === 0) {
clearInterval(interval);
resolve();
}
}, 50);
});
vm.stopAll();
vm.quit();
};
runProject(fs.readFileSync(file));

View File

@@ -0,0 +1,42 @@
const BlockUtility = require('../engine/block-utility');
class CompatibilityLayerBlockUtility extends BlockUtility {
constructor () {
super();
this._startedBranch = null;
}
get stackFrame () {
return this.thread.compatibilityStackFrame;
}
startBranch (branchNumber, isLoop) {
this._startedBranch = [branchNumber, isLoop];
}
startProcedure () {
throw new Error('startProcedure is not supported by this BlockUtility');
}
// Parameters are not used by compiled scripts.
initParams () {
throw new Error('initParams is not supported by this BlockUtility');
}
pushParam () {
throw new Error('pushParam is not supported by this BlockUtility');
}
getParam () {
throw new Error('getParam is not supported by this BlockUtility');
}
init (thread, fakeBlockId, stackFrame) {
this.thread = thread;
this.sequencer = thread.target.runtime.sequencer;
this._startedBranch = null;
thread.stack[0] = fakeBlockId;
thread.compatibilityStackFrame = stackFrame;
}
}
// Export a single instance to be reused.
module.exports = new CompatibilityLayerBlockUtility();

View File

@@ -0,0 +1,48 @@
/**
* @fileoverview List of blocks to be supported in the compiler compatibility layer.
* This is only for native blocks. Extensions should not be listed here.
*/
// Please keep these lists alphabetical.
const stacked = [
'looks_changestretchby',
'looks_hideallsprites',
'looks_say',
'looks_sayforsecs',
'looks_setstretchto',
'looks_switchbackdroptoandwait',
'looks_think',
'looks_thinkforsecs',
'motion_align_scene',
'motion_glidesecstoxy',
'motion_glideto',
'motion_goto',
'motion_pointtowards',
'motion_scroll_right',
'motion_scroll_up',
'sensing_askandwait',
'sensing_setdragmode',
'sound_changeeffectby',
'sound_changevolumeby',
'sound_cleareffects',
'sound_play',
'sound_playuntildone',
'sound_seteffectto',
'sound_setvolumeto',
'sound_stopallsounds'
];
const inputs = [
'motion_xscroll',
'motion_yscroll',
'sensing_loud',
'sensing_loudness',
'sensing_userid',
'sound_volume'
];
module.exports = {
stacked,
inputs
};

View File

@@ -0,0 +1,37 @@
const {IRGenerator} = require('./irgen');
const JSGenerator = require('./jsgen');
const compile = thread => {
const irGenerator = new IRGenerator(thread);
const ir = irGenerator.generate();
const procedures = {};
const target = thread.target;
const compileScript = script => {
if (script.cachedCompileResult) {
return script.cachedCompileResult;
}
const compiler = new JSGenerator(script, ir, target);
const result = compiler.compile();
script.cachedCompileResult = result;
return result;
};
const entry = compileScript(ir.entry);
for (const procedureVariant of Object.keys(ir.procedures)) {
const procedureData = ir.procedures[procedureVariant];
const procedureTree = compileScript(procedureData);
procedures[procedureVariant] = procedureTree;
}
return {
startingFunction: entry,
procedures,
executableHat: ir.entry.executableHat
};
};
module.exports = compile;

View File

@@ -0,0 +1,20 @@
/* eslint-disable no-eval */
/**
* @returns {boolean} true if the nullish coalescing operator (x ?? y) is supported.
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing_operator
*/
const supportsNullishCoalescing = () => {
try {
// eslint-disable-next-line no-unused-vars
const fn = new Function('undefined ?? 3');
// if function construction succeeds, the browser understood the syntax.
return true;
} catch (e) {
return false;
}
};
module.exports = {
supportsNullishCoalescing: supportsNullishCoalescing()
};

View File

@@ -0,0 +1,109 @@
/**
* @fileoverview Common intermediates shared amongst parts of the compiler.
*/
/**
* An IntermediateScript describes a single script.
* Scripts do not necessarily have hats.
*/
class IntermediateScript {
constructor () {
/**
* The ID of the top block of this script.
* @type {string}
*/
this.topBlockId = null;
/**
* List of nodes that make up this script.
* @type {Array|null}
*/
this.stack = null;
/**
* Whether this script is a procedure.
* @type {boolean}
*/
this.isProcedure = false;
/**
* This procedure's variant, if any.
* @type {string}
*/
this.procedureVariant = '';
/**
* This procedure's code, if any.
* @type {string}
*/
this.procedureCode = '';
/**
* List of names of arguments accepted by this function, if it is a procedure.
* @type {string[]}
*/
this.arguments = [];
/**
* Whether this script should be run in warp mode.
* @type {boolean}
*/
this.isWarp = false;
/**
* Whether this script can `yield`
* If false, this script will be compiled as a regular JavaScript function (function)
* If true, this script will be compiled as a generator function (function*)
* @type {boolean}
*/
this.yields = true;
/**
* Whether this script should use the "warp timer"
* @type {boolean}
*/
this.warpTimer = false;
/**
* List of procedure IDs that this script needs.
* @readonly
*/
this.dependedProcedures = [];
/**
* Cached result of compiling this script.
* @type {Function|null}
*/
this.cachedCompileResult = null;
/**
* Whether the top block of this script is an executable hat.
* @type {boolean}
*/
this.executableHat = false;
}
}
/**
* An IntermediateRepresentation contains scripts.
*/
class IntermediateRepresentation {
constructor () {
/**
* The entry point of this IR.
* @type {IntermediateScript}
*/
this.entry = null;
/**
* Maps procedure variants to their intermediate script.
* @type {Object.<string, IntermediateScript>}
*/
this.procedures = {};
}
}
module.exports = {
IntermediateScript,
IntermediateRepresentation
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,635 @@
/**
* @fileoverview Runtime for scripts generated by jsgen
*/
/* eslint-disable no-unused-vars */
/* eslint-disable prefer-template */
/* eslint-disable valid-jsdoc */
/* eslint-disable max-len */
const globalState = {
Timer: require('../util/timer'),
Cast: require('../util/cast'),
log: require('../util/log'),
blockUtility: require('./compat-block-utility'),
thread: null
};
let baseRuntime = '';
const runtimeFunctions = {};
/**
* Determine whether the current tick is likely stuck.
* This implements similar functionality to the warp timer found in Scratch.
* @returns {boolean} true if the current tick is likely stuck.
*/
baseRuntime += `let stuckCounter = 0;
const isStuck = () => {
// The real time is not checked on every call for performance.
stuckCounter++;
if (stuckCounter === 100) {
stuckCounter = 0;
return globalState.thread.target.runtime.sequencer.timer.timeElapsed() > 500;
}
return false;
};`;
/**
* Start hats by opcode.
* @param {string} requestedHat The opcode of the hat to start.
* @param {*} optMatchFields Fields to match.
* @returns {Array} A list of threads that were started.
*/
runtimeFunctions.startHats = `const startHats = (requestedHat, optMatchFields) => {
const thread = globalState.thread;
const threads = thread.target.runtime.startHats(requestedHat, optMatchFields);
return threads;
}`;
/**
* Implements "thread waiting", where scripts are halted until all the scripts have finished executing.
* @param {Array} threads The list of threads.
*/
runtimeFunctions.waitThreads = `const waitThreads = function*(threads) {
const thread = globalState.thread;
const runtime = thread.target.runtime;
while (true) {
// determine whether any threads are running
let anyRunning = false;
for (let i = 0; i < threads.length; i++) {
if (runtime.threads.indexOf(threads[i]) !== -1) {
anyRunning = true;
break;
}
}
if (!anyRunning) {
// all threads are finished, can resume
return;
}
let allWaiting = true;
for (let i = 0; i < threads.length; i++) {
if (!runtime.isWaitingThread(threads[i])) {
allWaiting = false;
break;
}
}
if (allWaiting) {
thread.status = 3; // STATUS_YIELD_TICK
}
yield;
}
}`;
/**
* waitPromise: Wait until a Promise resolves or rejects before continuing.
* @param {Promise} promise The promise to wait for.
* @returns {*} the value that the promise resolves to, otherwise undefined if the promise rejects
*/
/**
* isPromise: Determine if a value is Promise-like
* @param {unknown} promise The value to check
* @returns {promise is PromiseLike} True if the value is Promise-like (has a .then())
*/
/**
* executeInCompatibilityLayer: Execute a scratch-vm primitive.
* @param {*} inputs The inputs to pass to the block.
* @param {function} blockFunction The primitive's function.
* @param {boolean} useFlags Whether to set flags (hasResumedFromPromise)
* @param {string} blockId Block ID to set on the emulated block utility.
* @param {*|null} branchInfo Extra information object for CONDITIONAL and LOOP blocks. See createBranchInfo().
* @returns {*} the value returned by the block, if any.
*/
runtimeFunctions.executeInCompatibilityLayer = `let hasResumedFromPromise = false;
const waitPromise = function*(promise) {
const thread = globalState.thread;
let returnValue;
// enter STATUS_PROMISE_WAIT and yield
// this will stop script execution until the promise handlers reset the thread status
// because promise handlers might execute immediately, configure thread.status here
thread.status = 1; // STATUS_PROMISE_WAIT
promise
.then(value => {
returnValue = value;
thread.status = 0; // STATUS_RUNNING
}, error => {
globalState.log.warn('Promise rejected in compiled script:', error);
returnValue = '' + error;
thread.status = 0; // STATUS_RUNNING
});
yield;
return returnValue;
};
const isPromise = value => (
// see engine/execute.js
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
);
const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, useFlags, blockId, branchInfo) {
const thread = globalState.thread;
const blockUtility = globalState.blockUtility;
const stackFrame = branchInfo ? branchInfo.stackFrame : {};
const finish = (returnValue) => {
if (branchInfo) {
if (typeof returnValue === 'undefined' && blockUtility._startedBranch) {
branchInfo.isLoop = blockUtility._startedBranch[1];
return blockUtility._startedBranch[0];
}
branchInfo.isLoop = branchInfo.defaultIsLoop;
return returnValue;
}
return returnValue;
};
const executeBlock = () => {
blockUtility.init(thread, blockId, stackFrame);
return blockFunction(inputs, blockUtility);
};
let returnValue = executeBlock();
if (isPromise(returnValue)) {
returnValue = finish(yield* waitPromise(returnValue));
if (useFlags) hasResumedFromPromise = true;
return returnValue;
}
if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) {
// Something external is forcing us to stop
yield;
// Make up a return value because whatever is forcing us to stop can't specify one
return '';
}
while (thread.status === 2 /* STATUS_YIELD */ || thread.status === 3 /* STATUS_YIELD_TICK */) {
// Yielded threads will run next iteration.
if (thread.status === 2 /* STATUS_YIELD */) {
thread.status = 0; // STATUS_RUNNING
// Yield back to the event loop when stuck or not in warp mode.
if (!isWarp || isStuck()) {
yield;
}
} else {
// status is STATUS_YIELD_TICK, always yield to the event loop
yield;
}
returnValue = executeBlock();
if (isPromise(returnValue)) {
returnValue = finish(yield* waitPromise(returnValue));
if (useFlags) hasResumedFromPromise = true;
return returnValue;
}
if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) {
yield;
return finish('');
}
}
return finish(returnValue);
}`;
/**
* @param {boolean} isLoop True if the block is a LOOP by default (can be overridden by startBranch() call)
* @returns {unknown} Branch info object for compatibility layer.
*/
runtimeFunctions.createBranchInfo = `const createBranchInfo = (isLoop) => ({
defaultIsLoop: isLoop,
isLoop: false,
branch: 0,
stackFrame: {}
});`;
/**
* End the current script.
*/
runtimeFunctions.retire = `const retire = () => {
const thread = globalState.thread;
thread.target.runtime.sequencer.retireThread(thread);
}`;
/**
* Scratch cast to boolean.
* Similar to Cast.toBoolean()
* @param {*} value The value to cast
* @returns {boolean} The value cast to a boolean
*/
runtimeFunctions.toBoolean = `const toBoolean = value => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
if (value === '' || value === '0' || value.toLowerCase() === 'false') {
return false;
}
return true;
}
return !!value;
}`;
/**
* If a number is very close to a whole number, round to that whole number.
* @param {number} value Value to round
* @returns {number} Rounded number or original number
*/
runtimeFunctions.limitPrecision = `const limitPrecision = value => {
const rounded = Math.round(value);
const delta = value - rounded;
return (Math.abs(delta) < 1e-9) ? rounded : value;
}`;
/**
* Used internally by the compare family of function.
* See similar method in cast.js.
* @param {*} val A value that evaluates to 0 in JS string-to-number conversation such as empty string, 0, or tab.
* @returns {boolean} True if the value should not be treated as the number zero.
*/
baseRuntime += `const isNotActuallyZero = val => {
if (typeof val !== 'string') return false;
for (let i = 0; i < val.length; i++) {
const code = val.charCodeAt(i);
if (code === 48 || code === 9) {
return false;
}
}
return true;
};`;
/**
* Determine if two values are equal.
* @param {*} v1 First value
* @param {*} v2 Second value
* @returns {boolean} true if v1 is equal to v2
*/
baseRuntime += `const compareEqualSlow = (v1, v2) => {
const n1 = +v1;
if (isNaN(n1) || (n1 === 0 && isNotActuallyZero(v1))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
const n2 = +v2;
if (isNaN(n2) || (n2 === 0 && isNotActuallyZero(v2))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
return n1 === n2;
};
const compareEqual = (v1, v2) => (typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) && !isNaN(v2) || v1 === v2) ? v1 === v2 : compareEqualSlow(v1, v2);`;
/**
* Determine if one value is greater than another.
* @param {*} v1 First value
* @param {*} v2 Second value
* @returns {boolean} true if v1 is greater than v2
*/
runtimeFunctions.compareGreaterThan = `const compareGreaterThanSlow = (v1, v2) => {
let n1 = +v1;
let n2 = +v2;
if (n1 === 0 && isNotActuallyZero(v1)) {
n1 = NaN;
} else if (n2 === 0 && isNotActuallyZero(v2)) {
n2 = NaN;
}
if (isNaN(n1) || isNaN(n2)) {
const s1 = ('' + v1).toLowerCase();
const s2 = ('' + v2).toLowerCase();
return s1 > s2;
}
return n1 > n2;
};
const compareGreaterThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) ? v1 > v2 : compareGreaterThanSlow(v1, v2)`;
/**
* Determine if one value is less than another.
* @param {*} v1 First value
* @param {*} v2 Second value
* @returns {boolean} true if v1 is less than v2
*/
runtimeFunctions.compareLessThan = `const compareLessThanSlow = (v1, v2) => {
let n1 = +v1;
let n2 = +v2;
if (n1 === 0 && isNotActuallyZero(v1)) {
n1 = NaN;
} else if (n2 === 0 && isNotActuallyZero(v2)) {
n2 = NaN;
}
if (isNaN(n1) || isNaN(n2)) {
const s1 = ('' + v1).toLowerCase();
const s2 = ('' + v2).toLowerCase();
return s1 < s2;
}
return n1 < n2;
};
const compareLessThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v2) ? v1 < v2 : compareLessThanSlow(v1, v2)`;
/**
* Generate a random integer.
* @param {number} low Lower bound
* @param {number} high Upper bound
* @returns {number} A random integer between low and high, inclusive.
*/
runtimeFunctions.randomInt = `const randomInt = (low, high) => low + Math.floor(Math.random() * ((high + 1) - low))`;
/**
* Generate a random float.
* @param {number} low Lower bound
* @param {number} high Upper bound
* @returns {number} A random floating point number between low and high.
*/
runtimeFunctions.randomFloat = `const randomFloat = (low, high) => (Math.random() * (high - low)) + low`;
/**
* Create and start a timer.
* @returns {Timer} A started timer
*/
runtimeFunctions.timer = `const timer = () => {
const t = new globalState.Timer({
now: () => globalState.thread.target.runtime.currentMSecs
});
t.start();
return t;
}`;
/**
* Returns the amount of days since January 1st, 2000.
* @returns {number} Days since 2000.
*/
// Date.UTC(2000, 0, 1) === 946684800000
// Hardcoding it is marginally faster
runtimeFunctions.daysSince2000 = `const daysSince2000 = () => (Date.now() - 946684800000) / (24 * 60 * 60 * 1000)`;
/**
* Determine distance to a sprite or point.
* @param {string} menu The name of the sprite or location to find.
* @returns {number} Distance to the point, or 10000 if it cannot be calculated.
*/
runtimeFunctions.distance = `const distance = menu => {
const thread = globalState.thread;
if (thread.target.isStage) return 10000;
let targetX = 0;
let targetY = 0;
if (menu === '_mouse_') {
targetX = thread.target.runtime.ioDevices.mouse.getScratchX();
targetY = thread.target.runtime.ioDevices.mouse.getScratchY();
} else {
const distTarget = thread.target.runtime.getSpriteTargetByName(menu);
if (!distTarget) return 10000;
targetX = distTarget.x;
targetY = distTarget.y;
}
const dx = thread.target.x - targetX;
const dy = thread.target.y - targetY;
return Math.sqrt((dx * dx) + (dy * dy));
}`;
/**
* Convert a Scratch list index to a JavaScript list index.
* "all" is not considered as a list index.
* Similar to Cast.toListIndex()
* @param {number} index Scratch list index.
* @param {number} length Length of the list.
* @returns {number} 0 based list index, or -1 if invalid.
*/
baseRuntime += `const listIndexSlow = (index, length) => {
if (index === 'last') {
return length - 1;
} else if (index === 'random' || index === 'any') {
if (length > 0) {
return (Math.random() * length) | 0;
}
return -1;
}
index = (+index || 0) | 0;
if (index < 1 || index > length) {
return -1;
}
return index - 1;
};
const listIndex = (index, length) => {
if (typeof index !== 'number') {
return listIndexSlow(index, length);
}
index = index | 0;
return index < 1 || index > length ? -1 : index - 1;
};`;
/**
* Get a value from a list.
* @param {Array} list The list
* @param {*} idx The 1-indexed index in the list.
* @returns {*} The list item, otherwise empty string if it does not exist.
*/
runtimeFunctions.listGet = `const listGet = (list, idx) => {
const index = listIndex(idx, list.length);
if (index === -1) {
return '';
}
return list[index];
}`;
/**
* Replace a value in a list.
* @param {import('../engine/variable')} list The list
* @param {*} idx List index, Scratch style.
* @param {*} value The new value.
*/
runtimeFunctions.listReplace = `const listReplace = (list, idx, value) => {
const index = listIndex(idx, list.value.length);
if (index === -1) {
return;
}
list.value[index] = value;
list._monitorUpToDate = false;
}`;
/**
* Insert a value in a list.
* @param {import('../engine/variable')} list The list.
* @param {*} idx The Scratch index in the list.
* @param {*} value The value to insert.
*/
runtimeFunctions.listInsert = `const listInsert = (list, idx, value) => {
const index = listIndex(idx, list.value.length + 1);
if (index === -1) {
return;
}
list.value.splice(index, 0, value);
list._monitorUpToDate = false;
}`;
/**
* Delete a value from a list.
* @param {import('../engine/variable')} list The list.
* @param {*} idx The Scratch index in the list.
*/
runtimeFunctions.listDelete = `const listDelete = (list, idx) => {
if (idx === 'all') {
list.value = [];
return;
}
const index = listIndex(idx, list.value.length);
if (index === -1) {
return;
}
list.value.splice(index, 1);
list._monitorUpToDate = false;
}`;
/**
* Return whether a list contains a value.
* @param {import('../engine/variable')} list The list.
* @param {*} item The value to search for.
* @returns {boolean} True if the list contains the item
*/
runtimeFunctions.listContains = `const listContains = (list, item) => {
// TODO: evaluate whether indexOf is worthwhile here
if (list.value.indexOf(item) !== -1) {
return true;
}
for (let i = 0; i < list.value.length; i++) {
if (compareEqual(list.value[i], item)) {
return true;
}
}
return false;
}`;
/**
* Find the 1-indexed index of an item in a list.
* @param {import('../engine/variable')} list The list.
* @param {*} item The item to search for
* @returns {number} The 1-indexed index of the item in the list, otherwise 0
*/
runtimeFunctions.listIndexOf = `const listIndexOf = (list, item) => {
for (let i = 0; i < list.value.length; i++) {
if (compareEqual(list.value[i], item)) {
return i + 1;
}
}
return 0;
}`;
/**
* Get the stringified form of a list.
* @param {import('../engine/variable')} list The list.
* @returns {string} Stringified form of the list.
*/
runtimeFunctions.listContents = `const listContents = list => {
for (let i = 0; i < list.value.length; i++) {
const listItem = list.value[i];
// this is an intentional break from what scratch 3 does to address our automatic string -> number conversions
// it fixes more than it breaks
if ((listItem + '').length !== 1) {
return list.value.join(' ');
}
}
return list.value.join('');
}`;
/**
* Convert a color to an RGB list
* @param {*} color The color value to convert
* @return {Array.<number>} [r,g,b], values between 0-255.
*/
runtimeFunctions.colorToList = `const colorToList = color => globalState.Cast.toRgbColorList(color)`;
/**
* Implements Scratch modulo (floored division instead of truncated division)
* @param {number} n Number
* @param {number} modulus Base
* @returns {number} n % modulus (floored division)
*/
runtimeFunctions.mod = `const mod = (n, modulus) => {
let result = n % modulus;
if (result / modulus < 0) result += modulus;
return result;
}`;
/**
* Implements Scratch tangent.
* @param {number} angle Angle in degrees.
* @returns {number} value of tangent or Infinity or -Infinity
*/
runtimeFunctions.tan = `const tan = (angle) => {
switch (angle % 360) {
case -270: case 90: return Infinity;
case -90: case 270: return -Infinity;
}
return Math.round(Math.tan((Math.PI * angle) / 180) * 1e10) / 1e10;
}`;
/**
* @param {function} callback The function to run
* @param {...unknown} args The arguments to pass to the function
* @returns {unknown} A generator that will yield once then call the function and return its value.
*/
runtimeFunctions.yieldThenCall = `const yieldThenCall = function* (callback, ...args) {
yield;
return callback(...args);
}`;
/**
* @param {function} callback The generator function to run
* @param {...unknown} args The arguments to pass to the generator function
* @returns {unknown} A generator that will yield once then delegate to the generator function and return its value.
*/
runtimeFunctions.yieldThenCallGenerator = `const yieldThenCallGenerator = function* (callback, ...args) {
yield;
return yield* callback(...args);
}`;
/**
* Step a compiled thread.
* @param {Thread} thread The thread to step.
*/
const execute = thread => {
globalState.thread = thread;
thread.generator.next();
};
const threadStack = [];
const saveGlobalState = () => {
threadStack.push(globalState.thread);
};
const restoreGlobalState = () => {
globalState.thread = threadStack.pop();
};
const insertRuntime = source => {
let result = baseRuntime;
for (const functionName of Object.keys(runtimeFunctions)) {
if (source.includes(functionName)) {
result += `${runtimeFunctions[functionName]};`;
}
}
result += `return ${source}`;
return result;
};
/**
* Evaluate arbitrary JS in the context of the runtime.
* @param {string} source The string to evaluate.
* @returns {*} The result of evaluating the string.
*/
const scopedEval = source => {
const withRuntime = insertRuntime(source);
try {
return new Function('globalState', withRuntime)(globalState);
} catch (e) {
globalState.log.error('was unable to compile script', withRuntime);
throw e;
}
};
execute.scopedEval = scopedEval;
execute.runtimeFunctions = runtimeFunctions;
execute.saveGlobalState = saveGlobalState;
execute.restoreGlobalState = restoreGlobalState;
module.exports = execute;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
class VariablePool {
/**
* @param {string} prefix The prefix at the start of the variable name.
*/
constructor (prefix) {
if (prefix.trim().length === 0) {
throw new Error('prefix cannot be empty');
}
this.prefix = prefix;
/**
* @private
*/
this.count = 0;
}
next () {
return `${this.prefix}${this.count++}`;
}
}
module.exports = VariablePool;

View File

@@ -0,0 +1,143 @@
const SharedDispatch = require('./shared-dispatch');
const log = require('../util/log');
/**
* This class serves as the central broker for message dispatch. It expects to operate on the main thread / Window and
* it must be informed of any Worker threads which will participate in the messaging system. From any context in the
* messaging system, the dispatcher's "call" method can call any method on any "service" provided in any participating
* context. The dispatch system will forward function arguments and return values across worker boundaries as needed.
* @see {WorkerDispatch}
*/
class CentralDispatch extends SharedDispatch {
constructor () {
super();
/**
* Map of channel name to worker or local service provider.
* If the entry is a Worker, the service is provided by an object on that worker.
* Otherwise, the service is provided locally and methods on the service will be called directly.
* @see {setService}
* @type {object.<Worker|object>}
*/
this.services = {};
/**
* The constructor we will use to recognize workers.
* @type {Function}
*/
this.workerClass = (typeof Worker === 'undefined' ? null : Worker);
/**
* List of workers attached to this dispatcher.
* @type {Array}
*/
this.workers = [];
}
/**
* Synchronously call a particular method on a particular service provided locally.
* Calling this function on a remote service will fail.
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {*} - the return value of the service method.
*/
callSync (service, method, ...args) {
const {provider, isRemote} = this._getServiceProvider(service);
if (provider) {
if (isRemote) {
throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`);
}
// TODO: verify correct `this` after switching from apply to spread
// eslint-disable-next-line prefer-spread
return provider[method].apply(provider, args);
}
throw new Error(`Provider not found for service: ${service}`);
}
/**
* Synchronously set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
* @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'.
* @param {object} provider - a local object which provides this service.
*/
setServiceSync (service, provider) {
if (Object.prototype.hasOwnProperty.call(this.services, service)) {
log.warn(`Central dispatch replacing existing service provider for ${service}`);
}
this.services[service] = provider;
}
/**
* Set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
* @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'.
* @param {object} provider - a local object which provides this service.
* @returns {Promise} - a promise which will resolve once the service is registered.
*/
setService (service, provider) {
/** Return a promise for consistency with {@link WorkerDispatch#setService} */
try {
this.setServiceSync(service, provider);
return Promise.resolve();
} catch (e) {
return Promise.reject(e);
}
}
/**
* Add a worker to the message dispatch system. The worker must implement a compatible message dispatch framework.
* The dispatcher will immediately attempt to "handshake" with the worker.
* @param {Worker} worker - the worker to add into the dispatch system.
*/
addWorker (worker) {
if (this.workers.indexOf(worker) === -1) {
this.workers.push(worker);
worker.onmessage = this._onMessage.bind(this, worker);
this._remoteCall(worker, 'dispatch', 'handshake').catch(e => {
log.error(`Could not handshake with worker: ${e}`);
});
} else {
log.warn('Central dispatch ignoring attempt to add duplicate worker');
}
}
/**
* Fetch the service provider object for a particular service name.
* @override
* @param {string} service - the name of the service to look up
* @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found
* @protected
*/
_getServiceProvider (service) {
const provider = this.services[service];
return provider && {
provider,
isRemote: Boolean((this.workerClass && provider instanceof this.workerClass) || provider.isRemote)
};
}
/**
* Handle a call message sent to the dispatch service itself
* @override
* @param {Worker} worker - the worker which sent the message.
* @param {DispatchCallMessage} message - the message to be handled.
* @returns {Promise|undefined} - a promise for the results of this operation, if appropriate
* @protected
*/
_onDispatchMessage (worker, message) {
let promise;
switch (message.method) {
case 'setService':
promise = this.setService(message.args[0], worker);
break;
default:
log.error(`Central dispatch received message for unknown method: ${message.method}`);
}
return promise;
}
}
module.exports = new CentralDispatch();

View File

@@ -0,0 +1,239 @@
const log = require('../util/log');
/**
* @typedef {object} DispatchCallMessage - a message to the dispatch system representing a service method call
* @property {*} responseId - send a response message with this response ID. See {@link DispatchResponseMessage}
* @property {string} service - the name of the service to be called
* @property {string} method - the name of the method to be called
* @property {Array|undefined} args - the arguments to be passed to the method
*/
/**
* @typedef {object} DispatchResponseMessage - a message to the dispatch system representing the results of a call
* @property {*} responseId - a copy of the response ID from the call which generated this response
* @property {*|undefined} error - if this is truthy, then it contains results from a failed call (such as an exception)
* @property {*|undefined} result - if error is not truthy, then this contains the return value of the call (if any)
*/
/**
* @typedef {DispatchCallMessage|DispatchResponseMessage} DispatchMessage
* Any message to the dispatch system.
*/
/**
* The SharedDispatch class is responsible for dispatch features shared by
* {@link CentralDispatch} and {@link WorkerDispatch}.
*/
class SharedDispatch {
constructor () {
/**
* List of callback registrations for promises waiting for a response from a call to a service on another
* worker. A callback registration is an array of [resolve,reject] Promise functions.
* Calls to local services don't enter this list.
* @type {Array.<Function[]>}
*/
this.callbacks = [];
/**
* The next response ID to be used.
* @type {int}
*/
this.nextResponseId = 0;
}
/**
* Call a particular method on a particular service, regardless of whether that service is provided locally or on
* a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone
* algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be
* transferred to the worker, and they should not be used after this call.
* @example
* dispatcher.call('vm', 'setData', 'cat', 42);
* // this finds the worker for the 'vm' service, then on that worker calls:
* vm.setData('cat', 42);
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {Promise} - a promise for the return value of the service method.
*/
call (service, method, ...args) {
return this.transferCall(service, method, null, ...args);
}
/**
* Call a particular method on a particular service, regardless of whether that service is provided locally or on
* a worker. If the service is provided by a worker, the `args` will be copied using the Structured Clone
* algorithm, except for any items which are also in the `transfer` list. Ownership of those items will be
* transferred to the worker, and they should not be used after this call.
* @example
* dispatcher.transferCall('vm', 'setData', [myArrayBuffer], 'cat', myArrayBuffer);
* // this finds the worker for the 'vm' service, transfers `myArrayBuffer` to it, then on that worker calls:
* vm.setData('cat', myArrayBuffer);
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {Array} [transfer] - objects to be transferred instead of copied. Must be present in `args` to be useful.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {Promise} - a promise for the return value of the service method.
*/
transferCall (service, method, transfer, ...args) {
try {
const {provider, isRemote} = this._getServiceProvider(service);
if (provider) {
if (isRemote) {
return this._remoteTransferCall(provider, service, method, transfer, ...args);
}
// TODO: verify correct `this` after switching from apply to spread
// eslint-disable-next-line prefer-spread
const result = provider[method].apply(provider, args);
return Promise.resolve(result);
}
return Promise.reject(new Error(`Service not found: ${service}`));
} catch (e) {
return Promise.reject(e);
}
}
/**
* 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.
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {Promise} - a promise for the return value of the service method.
*/
_remoteCall (provider, service, method, ...args) {
return this._remoteTransferCall(provider, service, method, null, ...args);
}
/**
* Like {@link transferCall}, but force the call to be posted through a particular communication channel.
* @param {object} provider - send the call through this object's `postMessage` function.
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {Array} [transfer] - objects to be transferred instead of copied. Must be present in `args` to be useful.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {Promise} - a promise for the return value of the service method.
*/
_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 */
// tw: upstream's logic is broken
// Args is actually a 3 length list of [args, util, real block info]
// We only want to send args. The others will throw errors when they try to be cloned
if ((args.length > 0) && (typeof args[args.length - 1].func === 'function')) {
args.pop();
args.pop();
}
if (transfer) {
provider.postMessage({service, method, responseId, args}, transfer);
} else {
provider.postMessage({service, method, responseId, args});
}
});
}
/**
* Store callback functions pending a response message.
* @param {Function} resolve - function to call if the service method returns.
* @param {Function} reject - function to call if the service method throws.
* @returns {*} - a unique response ID for this set of callbacks. See {@link _deliverResponse}.
* @protected
*/
_storeCallbacks (resolve, reject) {
const responseId = this.nextResponseId++;
this.callbacks[responseId] = [resolve, reject];
return responseId;
}
/**
* Deliver call response from a worker. This should only be called as the result of a message from a worker.
* @param {int} responseId - the response ID of the callback set to call.
* @param {DispatchResponseMessage} message - the message containing the response value(s).
* @protected
*/
_deliverResponse (responseId, message) {
try {
const [resolve, reject] = this.callbacks[responseId];
delete this.callbacks[responseId];
if (message.error) {
reject(message.error);
} else {
resolve(message.result);
}
} catch (e) {
log.error(`Dispatch callback failed: ${e}`);
}
}
/**
* Handle a message event received from a connected worker.
* @param {Worker} worker - the worker which sent the message, or the global object if running in a worker.
* @param {MessageEvent} event - the message event to be handled.
* @protected
*/
_onMessage (worker, event) {
/** @type {DispatchMessage} */
const message = event.data;
message.args = message.args || [];
let promise;
if (message.service) {
if (message.service === 'dispatch') {
promise = this._onDispatchMessage(worker, message);
} else {
promise = this.call(message.service, message.method, ...message.args);
}
} else if (typeof message.responseId === 'undefined') {
log.error(`Dispatch caught malformed message from a worker: ${JSON.stringify(event)}`);
} else {
this._deliverResponse(message.responseId, message);
}
if (promise) {
if (typeof message.responseId === 'undefined') {
log.error(`Dispatch message missing required response ID: ${JSON.stringify(event)}`);
} else {
promise.then(
result => worker.postMessage({responseId: message.responseId, result}),
error => worker.postMessage({responseId: message.responseId, error: `${error}`})
);
}
}
}
/**
* Fetch the service provider object for a particular service name.
* @abstract
* @param {string} service - the name of the service to look up
* @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found
* @protected
*/
_getServiceProvider (service) {
throw new Error(`Could not get provider for ${service}: _getServiceProvider not implemented`);
}
/**
* Handle a call message sent to the dispatch service itself
* @abstract
* @param {Worker} worker - the worker which sent the message.
* @param {DispatchCallMessage} message - the message to be handled.
* @returns {Promise|undefined} - a promise for the results of this operation, if appropriate
* @private
*/
_onDispatchMessage (worker, message) {
throw new Error(`Unimplemented dispatch message handler cannot handle ${message.method} method`);
}
}
module.exports = SharedDispatch;

View File

@@ -0,0 +1,113 @@
const SharedDispatch = require('./shared-dispatch');
const log = require('../util/log');
const {centralDispatchService} = require('../extension-support/tw-extension-worker-context');
/**
* This class provides a Worker with the means to participate in the message dispatch system managed by CentralDispatch.
* From any context in the messaging system, the dispatcher's "call" method can call any method on any "service"
* provided in any participating context. The dispatch system will forward function arguments and return values across
* worker boundaries as needed.
* @see {CentralDispatch}
*/
class WorkerDispatch extends SharedDispatch {
constructor () {
super();
/**
* This promise will be resolved when we have successfully connected to central dispatch.
* @type {Promise}
* @see {waitForConnection}
* @private
*/
this._connectionPromise = new Promise(resolve => {
this._onConnect = resolve;
});
/**
* Map of service name to local service provider.
* If a service is not listed here, it is assumed to be provided by another context (another Worker or the main
* thread).
* @see {setService}
* @type {object}
*/
this.services = {};
this._onMessage = this._onMessage.bind(this, centralDispatchService);
if (typeof self !== 'undefined') {
self.onmessage = this._onMessage;
}
}
/**
* @returns {Promise} a promise which will resolve upon connection to central dispatch. If you need to make a call
* immediately on "startup" you can attach a 'then' to this promise.
* @example
* dispatch.waitForConnection.then(() => {
* dispatch.call('myService', 'hello');
* })
*/
get waitForConnection () {
return this._connectionPromise;
}
/**
* Set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
* @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'.
* @param {object} provider - a local object which provides this service.
* @returns {Promise} - a promise which will resolve once the service is registered.
*/
setService (service, provider) {
if (Object.prototype.hasOwnProperty.call(this.services, service)) {
log.warn(`Worker dispatch replacing existing service provider for ${service}`);
}
this.services[service] = provider;
return this.waitForConnection.then(() => (
this._remoteCall(centralDispatchService, 'dispatch', 'setService', service)
));
}
/**
* Fetch the service provider object for a particular service name.
* @override
* @param {string} service - the name of the service to look up
* @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found
* @protected
*/
_getServiceProvider (service) {
// if we don't have a local service by this name, contact central dispatch by calling `postMessage` on self
const provider = this.services[service];
return {
provider: provider || centralDispatchService,
isRemote: !provider
};
}
/**
* Handle a call message sent to the dispatch service itself
* @override
* @param {Worker} worker - the worker which sent the message.
* @param {DispatchCallMessage} message - the message to be handled.
* @returns {Promise|undefined} - a promise for the results of this operation, if appropriate
* @protected
*/
_onDispatchMessage (worker, message) {
let promise;
switch (message.method) {
case 'handshake':
promise = this._onConnect();
break;
case 'terminate':
// Don't close until next tick, after sending confirmation back
setTimeout(() => self.close(), 0);
promise = Promise.resolve();
break;
default:
log.error(`Worker dispatch received message for unknown method: ${message.method}`);
}
return promise;
}
}
module.exports = new WorkerDispatch();

View File

@@ -0,0 +1,176 @@
const mutationAdapter = require('./mutation-adapter');
const html = require('htmlparser2');
const uid = require('../util/uid');
/**
* Convert and an individual block DOM to the representation tree.
* Based on Blockly's `domToBlockHeadless_`.
* @param {Element} blockDOM DOM tree for an individual block.
* @param {object} blocks Collection of blocks to add to.
* @param {boolean} isTopBlock Whether blocks at this level are "top blocks."
* @param {?string} parent Parent block ID.
* @return {undefined}
*/
const domToBlock = function (blockDOM, blocks, isTopBlock, parent) {
if (!blockDOM.attribs.id) {
blockDOM.attribs.id = uid();
}
// Block skeleton.
const block = {
id: blockDOM.attribs.id, // Block ID
opcode: blockDOM.attribs.type, // For execution, "event_whengreenflag".
inputs: {}, // Inputs to this block and the blocks they point to.
fields: {}, // Fields on this block and their values.
next: null, // Next block in the stack, if one exists.
topLevel: isTopBlock, // If this block starts a stack.
parent: parent, // Parent block ID, if available.
shadow: blockDOM.name === 'shadow', // If this represents a shadow/slot.
x: blockDOM.attribs.x, // X position of script, if top-level.
y: blockDOM.attribs.y // Y position of script, if top-level.
};
// Add the block to the representation tree.
blocks[block.id] = block;
// Process XML children and find enclosed blocks, fields, etc.
for (let i = 0; i < blockDOM.children.length; i++) {
const xmlChild = blockDOM.children[i];
// Enclosed blocks and shadows
let childBlockNode = null;
let childShadowNode = null;
for (let j = 0; j < xmlChild.children.length; j++) {
const grandChildNode = xmlChild.children[j];
if (!grandChildNode.name) {
// Non-XML tag node.
continue;
}
const grandChildNodeName = grandChildNode.name.toLowerCase();
if (grandChildNodeName === 'block') {
childBlockNode = grandChildNode;
} else if (grandChildNodeName === 'shadow') {
childShadowNode = grandChildNode;
}
}
// Use shadow block only if there's no real block node.
if (!childBlockNode && childShadowNode) {
childBlockNode = childShadowNode;
}
// Not all Blockly-type blocks are handled here,
// as we won't be using all of them for Scratch.
switch (xmlChild.name.toLowerCase()) {
case 'field':
{
// Add the field to this block.
const fieldName = xmlChild.attribs.name;
// Add id in case it is a variable field
const fieldId = xmlChild.attribs.id;
let fieldData = '';
if (xmlChild.children.length > 0 && xmlChild.children[0].data) {
fieldData = xmlChild.children[0].data;
} else {
// If the child of the field with a data property
// doesn't exist, set the data to an empty string.
fieldData = '';
}
block.fields[fieldName] = {
name: fieldName,
id: fieldId,
value: fieldData
};
const fieldVarType = xmlChild.attribs.variabletype;
if (typeof fieldVarType === 'string') {
block.fields[fieldName].variableType = fieldVarType;
}
break;
}
case 'comment':
{
block.comment = xmlChild.attribs.id;
break;
}
case 'value':
case 'statement':
{
// Recursively generate block structure for input block.
domToBlock(childBlockNode, blocks, false, block.id);
if (childShadowNode && childBlockNode !== childShadowNode) {
// Also generate the shadow block.
domToBlock(childShadowNode, blocks, false, block.id);
}
// Link this block's input to the child block.
const inputName = xmlChild.attribs.name;
block.inputs[inputName] = {
name: inputName,
block: childBlockNode.attribs.id,
shadow: childShadowNode ? childShadowNode.attribs.id : null
};
break;
}
case 'next':
{
if (!childBlockNode || !childBlockNode.attribs) {
// Invalid child block.
continue;
}
// Recursively generate block structure for next block.
domToBlock(childBlockNode, blocks, false, block.id);
// Link next block to this block.
block.next = childBlockNode.attribs.id;
break;
}
case 'mutation':
{
block.mutation = mutationAdapter(xmlChild);
break;
}
}
}
};
/**
* Convert outer blocks DOM from a Blockly CREATE event
* to a usable form for the Scratch runtime.
* This structure is based on Blockly xml.js:`domToWorkspace` and `domToBlock`.
* @param {Element} blocksDOM DOM tree for this event.
* @return {Array.<object>} Usable list of blocks from this CREATE event.
*/
const domToBlocks = function (blocksDOM) {
// At this level, there could be multiple blocks adjacent in the DOM tree.
const blocks = {};
for (let i = 0; i < blocksDOM.length; i++) {
const block = blocksDOM[i];
if (!block.name || !block.attribs) {
continue;
}
const tagName = block.name.toLowerCase();
if (tagName === 'block' || tagName === 'shadow') {
domToBlock(block, blocks, true, null);
}
}
// Flatten blocks object into a list.
const blocksList = [];
for (const b in blocks) {
if (!Object.prototype.hasOwnProperty.call(blocks, b)) continue;
blocksList.push(blocks[b]);
}
return blocksList;
};
/**
* Adapter between block creation events and block representation which can be
* used by the Scratch runtime.
* @param {object} e `Blockly.events.create` or `Blockly.events.endDrag`
* @return {Array.<object>} List of blocks from this CREATE event.
*/
const adapter = function (e) {
// Validate input
if (typeof e !== 'object') return;
if (typeof e.xml !== 'object') return;
return domToBlocks(html.parseDOM(e.xml.outerHTML, {decodeEntities: true}));
};
module.exports = adapter;

View File

@@ -0,0 +1,242 @@
const Thread = require('./thread');
const Timer = require('../util/timer');
/**
* @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;
this._nowObj = {
now: () => this.sequencer.runtime.currentMSecs
};
}
/**
* 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;
}
/**
* Use the runtime's currentMSecs value as a timestamp value for now
* This is useful in some cases where we need compatibility with Scratch 2
* @type {function}
*/
get nowObj () {
if (this.runtime) {
return this._nowObj;
}
return null;
}
/**
* The stack frame used by loop and other blocks to track internal state.
* @type {object}
*/
get stackFrame () {
const frame = this.thread.peekStackFrame();
if (frame.executionContext === null) {
frame.executionContext = {};
}
return frame.executionContext;
}
/**
* Check the stack timer and return a boolean based on whether it has finished or not.
* @return {boolean} - true if the stack timer has finished.
*/
stackTimerFinished () {
const timeElapsed = this.stackFrame.timer.timeElapsed();
if (timeElapsed < this.stackFrame.duration) {
return false;
}
return true;
}
/**
* Check if the stack timer needs initialization.
* @return {boolean} - true if the stack timer needs to be initialized.
*/
stackTimerNeedsInit () {
return !this.stackFrame.timer;
}
/**
* Create and start a stack timer
* @param {number} duration - a duration in milliseconds to set the timer for.
*/
startStackTimer (duration) {
if (this.nowObj) {
this.stackFrame.timer = new Timer(this.nowObj);
} else {
this.stackFrame.timer = new Timer();
}
this.stackFrame.timer.start();
this.stackFrame.duration = duration;
}
/**
* Set the thread to yield.
*/
yield () {
this.thread.status = Thread.STATUS_YIELD;
}
/**
* Set the thread to yield until the next tick of the runtime.
*/
yieldTick () {
this.thread.status = Thread.STATUS_YIELD_TICK;
}
/**
* 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 and ids of 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);
}
/**
* Get names, ids, and defaults of parameters for the given procedure.
* @param {string} procedureCode Procedure code for procedure to query.
* @return {Array.<string>} List of param names for a procedure.
*/
getProcedureParamNamesIdsAndDefaults (procedureCode) {
return this.thread.target.blocks.getProcedureParamNamesIdsAndDefaults(procedureCode);
}
/**
* Initialize procedure parameters in the thread before pushing parameters.
*/
initParams () {
this.thread.initParams();
}
/**
* 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) {
// Store thread and sequencer to ensure we can return to the calling block's context.
// startHats may execute further blocks and dirty the BlockUtility's execution context
// and confuse the calling block when we return to it.
const callerThread = this.thread;
const callerSequencer = this.sequencer;
const result = this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget);
// Restore thread and sequencer to prior values before we return to the calling block.
this.thread = callerThread;
this.sequencer = callerSequencer;
return result;
}
/**
* 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];
// TODO: verify correct `this` after switching from apply to spread
// eslint-disable-next-line prefer-spread
return devObject[func].apply(devObject, args);
}
}
}
module.exports = BlockUtility;

View File

@@ -0,0 +1,19 @@
/**
* @fileoverview
* Access point for private method shared between blocks.js and execute.js for
* caching execute information.
*/
/**
* A private method shared with execute to build an object containing the block
* information execute needs and that is reset when other cached Blocks info is
* reset.
* @param {Blocks} blocks Blocks containing the expected blockId
* @param {string} blockId blockId for the desired execute cache
*/
exports.getCached = function () {
throw new Error('blocks.js has not initialized BlocksExecuteCache');
};
// Call after the default throwing getCached is assigned for Blocks to replace.
require('./blocks');

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
/**
* @fileoverview
* Object representing a Scratch Comment (block or workspace).
*/
const uid = require('../util/uid');
const xmlEscape = require('../util/xml-escape');
class Comment {
/**
* @param {string} id Id of the comment.
* @param {string} text Text content of the comment.
* @param {number} x X position of the comment on the workspace.
* @param {number} y Y position of the comment on the workspace.
* @param {number} width The width of the comment when it is full size.
* @param {number} height The height of the comment when it is full size.
* @param {boolean} minimized Whether the comment is minimized.
* @constructor
*/
constructor (id, text, x, y, width, height, minimized) {
this.id = id || uid();
this.text = text;
this.x = x;
this.y = y;
this.width = Math.max(Number(width), Comment.MIN_WIDTH);
this.height = Math.max(Number(height), Comment.MIN_HEIGHT);
this.minimized = minimized || false;
this.blockId = null;
}
toXML () {
return `<comment id="${this.id}" x="${this.x}" y="${
this.y}" w="${this.width}" h="${this.height}" pinned="${
this.blockId !== null}" minimized="${this.minimized}">${xmlEscape(this.text)}</comment>`;
}
// TODO choose min and defaults for width and height
static get MIN_WIDTH () {
return 20;
}
static get MIN_HEIGHT () {
return 20;
}
static get DEFAULT_WIDTH () {
return 100;
}
static get DEFAULT_HEIGHT () {
return 100;
}
}
module.exports = Comment;

View File

@@ -0,0 +1,603 @@
const BlockUtility = require('./block-utility');
const BlocksExecuteCache = require('./blocks-execute-cache');
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.
* @param {*} value Value to check for a Promise.
* @return {boolean} True if the value appears to be a Promise.
*/
const isPromise = function (value) {
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, blockCached, lastOperation) {
const currentBlockId = blockCached.id;
const opcode = blockCached.opcode;
const isHat = blockCached._isHat;
const isConditional = blockCached._isConditional;
const isLoop = blockCached._isLoop;
thread.pushReportedValue(resolvedValue);
if (isHat) {
// Hat predicate was evaluated.
if (thread.stackClick) {
thread.status = Thread.STATUS_RUNNING;
} else 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
const hasOldEdgeValue = thread.target.hasEdgeActivatedValue(currentBlockId);
const oldEdgeValue = thread.target.updateEdgeActivatedValue(
currentBlockId,
resolvedValue
);
const edgeWasActivated = hasOldEdgeValue ? (!oldEdgeValue && resolvedValue) : resolvedValue;
if (edgeWasActivated) {
thread.status = Thread.STATUS_RUNNING;
} else {
sequencer.retireThread(thread);
}
} else if (resolvedValue) {
// Predicate returned true: allow the script to run.
thread.status = Thread.STATUS_RUNNING;
} else {
// Predicate returned false: do not allow script to run
sequencer.retireThread(thread);
}
} else if ((isConditional || isLoop) && typeof resolvedValue !== 'undefined') {
sequencer.stepToBranch(thread, cast.toNumber(resolvedValue), isLoop);
} else {
// In a non-hat, report the value visually if necessary if
// at the top of the thread stack.
if (lastOperation && 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: resolvedValue
}));
}
}
// Finished any yields.
thread.status = Thread.STATUS_RUNNING;
}
};
const handlePromiseResolution = (resolvedValue, sequencer, thread, blockCached, lastOperation) => {
handleReport(resolvedValue, sequencer, thread, blockCached, lastOperation);
// If it's a command block or a top level reporter in a stackClick.
// TW: Don't mangle the stack when we just finished executing a hat block.
// Hat block is always the top and first block of the script. There are no loops to find.
if (lastOperation && (!blockCached._isHat || thread.stackClick)) {
let stackFrame;
let nextBlockId;
do {
// In the case that the promise is the last block in the current thread stack
// We need to pop out repeatedly until we find the next block.
const popped = thread.popStack();
if (popped === null) {
return;
}
nextBlockId = thread.target.blocks.getNextBlock(popped);
if (nextBlockId !== null) {
// A next block exists so break out this loop
break;
}
// Investigate the next block and if not in a loop,
// then repeat and pop the next item off the stack frame
stackFrame = thread.peekStackFrame();
} while (stackFrame !== null && !stackFrame.isLoop);
thread.pushStack(nextBlockId);
}
};
const handlePromise = (primitiveReportedValue, sequencer, thread, blockCached, lastOperation) => {
if (thread.status === Thread.STATUS_RUNNING) {
// Primitive returned a promise; automatically yield thread.
thread.status = Thread.STATUS_PROMISE_WAIT;
}
// Promise handlers
primitiveReportedValue.then(resolvedValue => {
handlePromiseResolution(resolvedValue, sequencer, thread, blockCached, lastOperation);
}, rejectionReason => {
// Promise rejected: the primitive had some error.
log.warn('Primitive rejected promise: ', rejectionReason);
handlePromiseResolution(`${rejectionReason}`, sequencer, thread, blockCached, lastOperation);
});
};
/**
* A execute.js internal representation of a block to reduce the time spent in
* execute as the same blocks are called the most.
*
* With the help of the Blocks class create a mutable copy of block
* information. The members of BlockCached derived values of block information
* that does not need to be reevaluated until a change in Blocks. Since Blocks
* handles where the cache instance is stored, it drops all cache versions of a
* block when any change happens to it. This way we can quickly execute blocks
* and keep perform the right action according to the current block information
* in the editor.
*
* @param {Blocks} blockContainer the related Blocks instance
* @param {object} cached default set of cached values
*/
class BlockCached {
constructor (blockContainer, cached) {
/**
* Block id in its parent set of blocks.
* @type {string}
*/
this.id = cached.id;
/**
* Block operation code for this block.
* @type {string}
*/
this.opcode = cached.opcode;
/**
* Original block object containing argument values for static fields.
* @type {object}
*/
this.fields = cached.fields;
/**
* Original block object containing argument values for executable inputs.
* @type {object}
*/
this.inputs = cached.inputs;
/**
* Procedure mutation.
* @type {?object}
*/
this.mutation = cached.mutation;
/**
* The profiler the block is configured with.
* @type {?Profiler}
*/
this._profiler = null;
/**
* Profiler information frame.
* @type {?ProfilerFrame}
*/
this._profilerFrame = null;
/**
* Is the opcode a hat (event responder) block.
* @type {boolean}
*/
this._isHat = false;
/**
* The block opcode's implementation function.
* @type {?function}
*/
this._blockFunction = null;
/**
* Is the block function defined for this opcode?
* @type {boolean}
*/
this._definedBlockFunction = false;
/**
* Is this block a block with no function but a static value to return.
* @type {boolean}
*/
this._isShadowBlock = false;
/**
* The static value of this block if it is a shadow block.
* @type {?any}
*/
this._shadowValue = null;
/**
* A copy of the block's fields that may be modified.
* @type {object}
*/
this._fields = Object.assign({}, this.fields);
/**
* A copy of the block's inputs that may be modified.
* @type {object}
*/
this._inputs = Object.assign({}, this.inputs);
/**
* An arguments object for block implementations. All executions of this
* specific block will use this objecct.
* @type {object}
*/
this._argValues = {
mutation: this.mutation
};
/**
* The inputs key the parent refers to this BlockCached by.
* @type {string}
*/
this._parentKey = null;
/**
* The target object where the parent wants the resulting value stored
* with _parentKey as the key.
* @type {object}
*/
this._parentValues = null;
/**
* A sequence of non-shadow operations that can must be performed. This
* list recreates the order this block and its children are executed.
* Since the order is always the same we can safely store that order
* and iterate over the operations instead of dynamically walking the
* tree every time.
* @type {Array<BlockCached>}
*/
this._ops = [];
const {runtime} = blockUtility.sequencer;
const {opcode, fields, inputs} = this;
// Assign opcode isHat and blockFunction data to avoid dynamic lookups.
this._isHat = runtime.getIsHat(opcode);
this._blockFunction = runtime.getOpcodeFunction(opcode);
this._definedBlockFunction = typeof this._blockFunction !== 'undefined';
const flowing = runtime._flowing[opcode];
this._isConditional = !!(flowing && flowing.conditional);
this._isLoop = !!(flowing && flowing.loop);
// Store the current shadow value if there is a shadow value.
const fieldKeys = Object.keys(fields);
this._isShadowBlock = (
!this._definedBlockFunction &&
fieldKeys.length === 1 &&
Object.keys(inputs).length === 0
);
this._shadowValue = this._isShadowBlock && fields[fieldKeys[0]].value;
// Store the static fields onto _argValues.
for (const fieldName in fields) {
if (
fieldName === 'VARIABLE' ||
fieldName === 'LIST' ||
fieldName === 'BROADCAST_OPTION'
) {
this._argValues[fieldName] = {
id: fields[fieldName].id,
name: fields[fieldName].value
};
} else {
this._argValues[fieldName] = fields[fieldName].value;
}
}
// Remove custom_block. It is not part of block execution.
delete this._inputs.custom_block;
if ('BROADCAST_INPUT' in this._inputs) {
// BROADCAST_INPUT is called BROADCAST_OPTION in the args and is an
// object with an unchanging shape.
this._argValues.BROADCAST_OPTION = {
id: null,
name: null
};
// We can go ahead and compute BROADCAST_INPUT if it is a shadow
// value.
const broadcastInput = this._inputs.BROADCAST_INPUT;
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;
this._argValues.BROADCAST_OPTION.id = broadcastField.id;
this._argValues.BROADCAST_OPTION.name = broadcastField.value;
// Evaluating BROADCAST_INPUT here we do not need to do so
// later.
delete this._inputs.BROADCAST_INPUT;
}
}
// Cache all input children blocks in the operation lists. The
// operations can later be run in the order they appear in correctly
// executing the operations quickly in a flat loop instead of needing to
// recursivly iterate them.
for (const inputName in this._inputs) {
const input = this._inputs[inputName];
if (input.block) {
const inputCached = BlocksExecuteCache.getCached(blockContainer, input.block, BlockCached);
if (inputCached._isHat) {
continue;
}
this._ops.push(...inputCached._ops);
inputCached._parentKey = inputName;
inputCached._parentValues = this._argValues;
// Shadow values are static and do not change, go ahead and
// store their value on args.
if (inputCached._isShadowBlock) {
this._argValues[inputName] = inputCached._shadowValue;
}
}
}
// The final operation is this block itself. At the top most block is a
// command block or a block that is being run as a monitor.
if (this._definedBlockFunction) {
this._ops.push(this);
}
}
}
/**
* Initialize a BlockCached instance so its command/hat
* block and reporters can be profiled during execution.
* @param {Profiler} profiler - The profiler that is currently enabled.
* @param {BlockCached} blockCached - The blockCached instance to profile.
*/
const _prepareBlockProfiling = function (profiler, blockCached) {
blockCached._profiler = profiler;
if (blockFunctionProfilerId === -1) {
blockFunctionProfilerId = profiler.idByName(blockFunctionProfilerFrame);
}
const ops = blockCached._ops;
for (let i = 0; i < ops.length; i++) {
ops[i]._profilerFrame = profiler.frame(blockFunctionProfilerId, ops[i].opcode);
}
};
/**
* Execute a block.
* @param {!Sequencer} sequencer Which sequencer is executing.
* @param {!Thread} thread Thread which to read and execute.
*/
const execute = function (sequencer, thread) {
const runtime = sequencer.runtime;
// store sequencer and thread so block functions can access them through
// convenience methods.
blockUtility.sequencer = sequencer;
blockUtility.thread = thread;
// Current block to execute is the one on the top of the stack.
const currentBlockId = thread.peekStack();
const currentStackFrame = thread.peekStackFrame();
let blockContainer = thread.blockContainer;
let blockCached = BlocksExecuteCache.getCached(blockContainer, currentBlockId, BlockCached);
if (blockCached === null) {
blockContainer = runtime.flyoutBlocks;
blockCached = BlocksExecuteCache.getCached(blockContainer, currentBlockId, BlockCached);
// Stop if block or target no longer exists.
if (blockCached === null) {
// No block found: stop the thread; script no longer exists.
sequencer.retireThread(thread);
return;
}
}
const ops = blockCached._ops;
const length = ops.length;
let i = 0;
if (currentStackFrame.reported !== null) {
const reported = currentStackFrame.reported;
// Reinstate all the previous values.
for (; i < reported.length; i++) {
const {opCached: oldOpCached, inputValue} = reported[i];
const opCached = ops.find(op => op.id === oldOpCached);
if (opCached) {
const inputName = opCached._parentKey;
const argValues = opCached._parentValues;
if (inputName === 'BROADCAST_INPUT') {
// Something is plugged into the broadcast input.
// Cast it to a string. We don't need an id here.
argValues.BROADCAST_OPTION.id = null;
argValues.BROADCAST_OPTION.name = cast.toString(inputValue);
} else {
argValues[inputName] = inputValue;
}
}
}
// Find the last reported block that is still in the set of operations.
// This way if the last operation was removed, we'll find the next
// candidate. If an earlier block that was performed was removed then
// we'll find the index where the last operation is now.
if (reported.length > 0) {
const lastExisting = reported.reverse().find(report => ops.find(op => op.id === report.opCached));
if (lastExisting) {
i = ops.findIndex(opCached => opCached.id === lastExisting.opCached) + 1;
} else {
i = 0;
}
}
// The reporting block must exist and must be the next one in the sequence of operations.
if (thread.justReported !== null && ops[i] && ops[i].id === currentStackFrame.reporting) {
const opCached = ops[i];
const inputValue = thread.justReported;
thread.justReported = null;
const inputName = opCached._parentKey;
const argValues = opCached._parentValues;
if (inputName === 'BROADCAST_INPUT') {
// Something is plugged into the broadcast input.
// Cast it to a string. We don't need an id here.
argValues.BROADCAST_OPTION.id = null;
argValues.BROADCAST_OPTION.name = cast.toString(inputValue);
} else {
argValues[inputName] = inputValue;
}
i += 1;
}
currentStackFrame.reporting = null;
currentStackFrame.reported = null;
currentStackFrame.waitingReporter = false;
}
const start = i;
for (; i < length; i++) {
const lastOperation = i === length - 1;
const opCached = ops[i];
currentStackFrame.op = opCached;
const blockFunction = opCached._blockFunction;
// Update values for arguments (inputs).
const argValues = opCached._argValues;
// Fields are set during opCached initialization.
// Blocks should glow when a script is starting,
// not after it has finished (see #1404).
// Only blocks in blockContainers that don't forceNoGlow
// should request a glow.
if (!blockContainer.forceNoGlow) {
thread.requestScriptGlowInFrame = true;
}
// Inputs are set during previous steps in the loop.
const primitiveReportedValue = blockFunction(argValues, blockUtility);
const primitiveIsPromise = isPromise(primitiveReportedValue);
if (primitiveIsPromise || currentStackFrame.waitingReporter) {
if (primitiveIsPromise) {
handlePromise(primitiveReportedValue, sequencer, thread, opCached, lastOperation);
}
// Store the already reported values. They will be thawed into the
// future versions of the same operations by block id. The reporting
// operation if it is promise waiting will set its parent value at
// that time.
thread.justReported = null;
currentStackFrame.reporting = ops[i].id;
currentStackFrame.reported = ops.slice(0, i).map(reportedCached => {
const inputName = reportedCached._parentKey;
const reportedValues = reportedCached._parentValues;
if (inputName === 'BROADCAST_INPUT') {
return {
opCached: reportedCached.id,
inputValue: reportedValues[inputName].BROADCAST_OPTION.name
};
}
return {
opCached: reportedCached.id,
inputValue: reportedValues[inputName]
};
});
// We are waiting to be resumed later. Stop running this set of operations
// and continue them later after thawing the reported values.
break;
} else if (thread.status === Thread.STATUS_RUNNING) {
if (lastOperation) {
handleReport(primitiveReportedValue, sequencer, thread, opCached, lastOperation);
} else {
// By definition a block that is not last in the list has a
// parent.
const inputName = opCached._parentKey;
const parentValues = opCached._parentValues;
if (inputName === 'BROADCAST_INPUT') {
// Something is plugged into the broadcast input.
// Cast it to a string. We don't need an id here.
parentValues.BROADCAST_OPTION.id = null;
parentValues.BROADCAST_OPTION.name = cast.toString(primitiveReportedValue);
} else {
parentValues[inputName] = primitiveReportedValue;
}
}
} else if (thread.status === Thread.STATUS_DONE) {
// Nothing else to execute.
break;
}
}
if (runtime.profiler !== null) {
if (blockCached._profiler !== runtime.profiler) {
_prepareBlockProfiling(runtime.profiler, blockCached);
}
// Determine the index that is after the last executed block. `i` is
// currently the block that was just executed. `i + 1` will be the block
// after that. `length` with the min call makes sure we don't try to
// reference an operation outside of the set of operations.
const end = Math.min(i + 1, length);
for (let p = start; p < end; p++) {
ops[p]._profilerFrame.count += 1;
}
}
};
module.exports = execute;

View File

@@ -0,0 +1,23 @@
const {Record} = require('immutable');
const MonitorRecord = Record({
id: null, // Block Id
/** 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,
mode: 'default',
sliderMin: 0,
sliderMax: 100,
isDiscrete: true,
x: null, // (x: null, y: null) Indicates that the monitor should be auto-positioned
y: null,
width: 0,
height: 0,
visible: true
});
module.exports = MonitorRecord;

View File

@@ -0,0 +1,48 @@
const html = require('htmlparser2');
const decodeHtml = require('decode-html');
/**
* Convert a part of a mutation DOM to a mutation VM object, recursively.
* @param {object} dom DOM object for mutation tag.
* @return {object} Object representing useful parts of this mutation.
*/
const mutatorTagToObject = function (dom) {
const obj = Object.create(null);
obj.tagName = dom.name;
obj.children = [];
for (const prop in dom.attribs) {
if (prop === 'xmlns') continue;
obj[prop] = decodeHtml(dom.attribs[prop]);
// Note: the capitalization of block info in the following lines is important.
// The lowercase is read in from xml which normalizes case. The VM uses camel case everywhere else.
if (prop === 'blockinfo') {
obj.blockInfo = JSON.parse(obj.blockinfo);
delete obj.blockinfo;
}
}
for (let i = 0; i < dom.children.length; i++) {
obj.children.push(
mutatorTagToObject(dom.children[i])
);
}
return obj;
};
/**
* Adapter between mutator XML or DOM and block representation which can be
* used by the Scratch runtime.
* @param {(object|string)} mutation Mutation XML string or DOM.
* @return {object} Object representing the mutation.
*/
const mutationAdpater = function (mutation) {
let mutationParsed;
// Check if the mutation is already parsed; if not, parse it.
if (typeof mutation === 'object') {
mutationParsed = mutation;
} else {
mutationParsed = html.parseDOM(mutation)[0];
}
return mutatorTagToObject(mutationParsed);
};
module.exports = mutationAdpater;

View File

@@ -0,0 +1,390 @@
/**
* @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;
/**
* A summarized count of the number of calls to this frame.
* @type {number}
*/
this.count = 0;
}
}
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 = [];
/**
* An array of frames incremented on demand instead as part of start
* and stop.
* @type {Array.<ProfilerFrame>}
*/
this.increments = [];
/**
* An array of profiler frames separated by counter argument. Generally
* for Scratch these frames are separated by block function opcode.
* This tracks each time an opcode is called.
* @type {Array.<ProfilerFrame>}
*/
this.counters = [];
/**
* A frame with no id or argument.
* @type {ProfilerFrame}
*/
this.nullFrame = new ProfilerFrame(-1);
/**
* A cache of ProfilerFrames to reuse when reporting the recorded
* frames in records.
* @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());
}
/**
* Increment the number of times this symbol is called.
* @param {number} id The id returned by idByName for a name symbol.
*/
increment (id) {
if (!this.increments[id]) {
this.increments[id] = new ProfilerFrame(-1);
this.increments[id].id = id;
}
this.increments[id].count += 1;
}
/**
* Find or create a ProfilerFrame-like object whose counter can be
* incremented outside of the Profiler.
* @param {number} id The id returned by idByName for a name symbol.
* @param {*} arg The argument for a frame that identifies it in addition
* to the id.
* @return {{count: number}} A ProfilerFrame-like whose count should be
* incremented for each call.
*/
frame (id, arg) {
for (let i = 0; i < this.counters.length; i++) {
if (this.counters[i].id === id && this.counters[i].arg === arg) {
return this.counters[i];
}
}
const newCounter = new ProfilerFrame(-1);
newCounter.id = id;
newCounter.arg = arg;
this.counters.push(newCounter);
return newCounter;
}
/**
* Decode records and report all frames to `this.onFrame`.
*/
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 frame occured once.
frame.count = 1;
this.onFrame(frame);
i += STOP_SIZE;
} else {
this.records.length = 0;
throw new Error('Unable to decode Profiler records.');
}
}
for (let j = 0; j < this.increments.length; j++) {
if (this.increments[j] && this.increments[j].count > 0) {
this.onFrame(this.increments[j]);
this.increments[j].count = 0;
}
}
for (let k = 0; k < this.counters.length; k++) {
if (this.counters[k].count > 0) {
this.onFrame(this.counters[k]);
this.counters[k].count = 0;
}
}
this.records.length = 0;
}
/**
* 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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
/**
* These constants are copied from scratch-blocks/core/constants.js
* @TODO find a way to require() these straight from scratch-blocks... maybe make a scratch-blocks/dist/constants.js?
* @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
};
module.exports = ScratchBlocksConstants;

View File

@@ -0,0 +1,372 @@
const Timer = require('../util/timer');
const Thread = require('./thread');
const execute = require('./execute.js');
const compilerExecute = require('../compiler/jsexecute');
/**
* 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) {
/**
* A utility timer for timing thread sequencing.
* @type {!Timer}
*/
this.timer = new Timer();
/**
* Reference to the runtime owning this sequencer.
* @type {!Runtime}
*/
this.runtime = runtime;
this.activeThread = null;
}
/**
* Time to run a warp-mode thread, in ms.
* @type {number}
*/
static get WARP_TIME () {
return 500;
}
/**
* Step through all threads in `this.runtime.threads`, running them in order.
* @return {Array.<!Thread>} List of inactive threads after stepping.
*/
stepThreads () {
// Work time is 75% of the thread stepping interval.
const WORK_TIME = 0.75 * this.runtime.currentStepTime;
// For compatibility with Scatch 2, update the millisecond clock
// on the Runtime once per step (see Interpreter.as in Scratch 2
// for original use of `currentMSecs`)
this.runtime.updateCurrentMSecs();
// Start counting toward WORK_TIME.
this.timer.start();
// Count of active threads.
let numActiveThreads = Infinity;
// Whether `stepThreads` has run through a full single tick.
let ranFirstTick = false;
const doneThreads = [];
// 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.
// 3. Either turbo mode, or no redraw has been requested by a primitive.
while (this.runtime.threads.length > 0 &&
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;
let stoppedThread = false;
// Attempt to run each thread one time.
const threads = this.runtime.threads;
for (let i = 0; i < threads.length; i++) {
const activeThread = this.activeThread = threads[i];
// Check if the thread is done so it is not executed.
if (activeThread.stack.length === 0 ||
activeThread.status === Thread.STATUS_DONE) {
// Finished with this thread.
stoppedThread = true;
continue;
}
if (activeThread.status === Thread.STATUS_YIELD_TICK &&
!ranFirstTick) {
// Clear single-tick yield from the last call of `stepThreads`.
activeThread.status = Thread.STATUS_RUNNING;
}
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);
}
// Increment the number of times stepThread is called.
this.runtime.profiler.increment(stepThreadProfilerId);
}
this.stepThread(activeThread);
activeThread.warpTimer = null;
}
if (activeThread.status === Thread.STATUS_RUNNING) {
numActiveThreads++;
}
// Check if the thread completed while it just stepped to make
// sure we remove it before the next iteration of all threads.
if (activeThread.stack.length === 0 ||
activeThread.status === Thread.STATUS_DONE) {
// Finished with this thread.
stoppedThread = true;
}
}
// 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`.
if (stoppedThread) {
let nextActiveThread = 0;
for (let i = 0; i < this.runtime.threads.length; i++) {
const thread = this.runtime.threads[i];
if (thread.stack.length !== 0 &&
thread.status !== Thread.STATUS_DONE) {
this.runtime.threads[nextActiveThread] = thread;
nextActiveThread++;
} else {
this.runtime.threadMap.delete(thread.getId());
doneThreads.push(thread);
}
}
this.runtime.threads.length = nextActiveThread;
}
}
this.activeThread = null;
return doneThreads;
}
/**
* Step the requested thread for as long as necessary.
* @param {!Thread} thread Thread object to step.
*/
stepThread (thread) {
if (thread.isCompiled) {
compilerExecute(thread);
return;
}
let currentBlockId = thread.peekStack();
if (!currentBlockId) {
// A "null block" - empty branch.
thread.popStack();
// Did the null follow a hat block?
if (thread.stack.length === 0) {
thread.status = Thread.STATUS_DONE;
return;
}
}
// Save the current block ID to notice if we did control flow.
while ((currentBlockId = thread.peekStack())) {
let isWarpMode = thread.peekStackFrame().warpMode;
if (isWarpMode && !thread.warpTimer) {
// Initialize warp-mode timer if it hasn't been already.
// This will start counting the thread toward `Sequencer.WARP_TIME`.
thread.warpTimer = new Timer();
thread.warpTimer.start();
}
// Execute the current block.
if (this.runtime.profiler !== null) {
if (executeProfilerId === -1) {
executeProfilerId = this.runtime.profiler.idByName(executeProfilerFrame);
}
// Increment the number of times execute is called.
this.runtime.profiler.increment(executeProfilerId);
}
if (thread.target === null) {
this.retireThread(thread);
} else {
execute(this, thread);
}
thread.blockGlowInFrame = currentBlockId;
// If the thread has yielded or is waiting, yield to other threads.
if (thread.status === Thread.STATUS_YIELD) {
// Mark as running for next iteration.
thread.status = Thread.STATUS_RUNNING;
// In warp mode, yielded blocks are re-executed immediately.
if (isWarpMode &&
thread.warpTimer.timeElapsed() <= Sequencer.WARP_TIME) {
continue;
}
return;
} else if (thread.status === Thread.STATUS_PROMISE_WAIT) {
// A promise was returned by the primitive. Yield the thread
// until the promise resolves. Promise resolution should reset
// thread.status to Thread.STATUS_RUNNING.
return;
} else if (thread.status === Thread.STATUS_YIELD_TICK) {
// stepThreads will reset the thread to Thread.STATUS_RUNNING
return;
} else if (thread.status === Thread.STATUS_DONE) {
// Nothing more to execute.
return;
}
// If no control flow has happened, switch to next block.
if (thread.peekStack() === currentBlockId && !thread.peekStackFrame().waitingReporter) {
thread.goToNextBlock();
}
// If no next block has been found at this point, look on the stack.
while (!thread.peekStack()) {
thread.popStack();
if (thread.stack.length === 0) {
// No more stack to run!
thread.status = Thread.STATUS_DONE;
return;
}
const stackFrame = thread.peekStackFrame();
isWarpMode = stackFrame.warpMode;
if (stackFrame.isLoop) {
// The current level of the stack is marked as a loop.
// Return to yield for the frame/tick in general.
// Unless we're in warp mode - then only return if the
// warp timer is up.
if (!isWarpMode ||
thread.warpTimer.timeElapsed() > Sequencer.WARP_TIME) {
// Don't do anything to the stack, since loops need
// to be re-executed.
return;
}
// Don't go to the next block for this level of the stack,
// since loops need to be re-executed.
continue;
} else if (stackFrame.waitingReporter) {
// This level of the stack was waiting for a value.
// This means a reporter has just returned - so don't go
// to the next block for this level of the stack.
continue;
}
// Get next block of existing block on the stack.
thread.goToNextBlock();
}
}
}
/**
* Step a thread into a block's branch.
* @param {!Thread} thread Thread object to step to branch.
* @param {number} branchNum Which branch to step to (i.e., 1, 2).
* @param {boolean} isLoop Whether this block is a loop.
*/
stepToBranch (thread, branchNum, isLoop) {
if (!branchNum) {
branchNum = 1;
}
const currentBlockId = thread.peekStack();
const branchId = thread.target.blocks.getBranch(
currentBlockId,
branchNum
);
thread.peekStackFrame().isLoop = isLoop;
if (branchId) {
// Push branch ID to the thread's stack.
thread.pushStack(branchId);
} else {
thread.pushStack(null);
}
}
/**
* Step a procedure.
* @param {!Thread} thread Thread object to step to procedure.
* @param {!string} procedureCode Procedure code of procedure to step to.
*/
stepToProcedure (thread, procedureCode) {
const definition = thread.target.blocks.getProcedureDefinition(procedureCode);
if (!definition) {
return;
}
// Check if the call is recursive.
// If so, set the thread to yield after pushing.
const isRecursive = thread.isRecursiveCall(procedureCode);
// To step to a procedure, we put its definition on the stack.
// Execution for the thread will proceed through the definition hat
// and on to the main definition of the procedure.
// When that set of blocks finishes executing, it will be popped
// from the stack by the sequencer, returning control to the caller.
thread.pushStack(definition);
// In known warp-mode threads, only yield when time is up.
if (thread.peekStackFrame().warpMode &&
thread.warpTimer.timeElapsed() > Sequencer.WARP_TIME) {
thread.status = Thread.STATUS_YIELD;
} else {
// Look for warp-mode flag on definition, and set the thread
// to warp-mode if needed.
const definitionBlock = thread.target.blocks.getBlock(definition);
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) {
// In normal-mode threads, yield any time we have a recursive call.
thread.status = Thread.STATUS_YIELD;
}
}
}
/**
* Retire a thread in the middle, without considering further blocks.
* @param {!Thread} thread Thread object to retire.
*/
retireThread (thread) {
thread.stack = [];
thread.stackFrame = [];
thread.requestScriptGlowInFrame = false;
thread.status = Thread.STATUS_DONE;
if (thread.isCompiled) {
thread.procedures = null;
thread.generator = null;
}
}
}
module.exports = Sequencer;

View File

@@ -0,0 +1,29 @@
class StageLayering {
static get BACKGROUND_LAYER () {
return 'background';
}
static get VIDEO_LAYER () {
return 'video';
}
static get PEN_LAYER () {
return 'pen';
}
static get SPRITE_LAYER () {
return 'sprite';
}
// Order of layer groups relative to each other,
static get LAYER_GROUPS () {
return [
StageLayering.BACKGROUND_LAYER,
StageLayering.VIDEO_LAYER,
StageLayering.PEN_LAYER,
StageLayering.SPRITE_LAYER
];
}
}
module.exports = StageLayering;

View File

@@ -0,0 +1,805 @@
const EventEmitter = require('events');
const Blocks = require('./blocks');
const Variable = require('../engine/variable');
const Comment = require('../engine/comment');
const uid = require('../util/uid');
const {Map} = require('immutable');
const log = require('../util/log');
const StringUtil = require('../util/string-util');
const VariableUtil = require('../util/variable-util');
/**
* @fileoverview
* A Target is an abstract "code-running" object for the Scratch VM.
* Examples include sprites/clones or potentially physical-world devices.
*/
class Target extends EventEmitter {
/**
* @param {Runtime} runtime Reference to the runtime.
* @param {?Blocks} blocks Blocks instance for the blocks owned by this target.
* @constructor
*/
constructor (runtime, blocks) {
super();
if (!blocks) {
blocks = new Blocks(runtime);
}
/**
* Reference to the runtime.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* A unique ID for this target.
* @type {string}
*/
this.id = uid();
/**
* Blocks run as code for this target.
* @type {!Blocks}
*/
this.blocks = blocks;
/**
* Dictionary of variables and their values for this target.
* Key is the variable id.
* @type {Object.<string,*>}
*/
this.variables = {};
/**
* Dictionary of comments for this target.
* Key is the comment id.
* @type {Object.<string,*>}
*/
this.comments = {};
/**
* Dictionary of custom state for this target.
* This can be used to store target-specific custom state for blocks which need it.
* TODO: do we want to persist this in SB3 files?
* @type {Object.<string,*>}
*/
this._customState = {};
/**
* Currently known values for edge-activated hats.
* Keys are block ID for the hat; values are the currently known values.
* @type {Object.<string, *>}
*/
this._edgeActivatedHatValues = {};
/**
* Maps extension ID to a JSON-serializable value.
* @type {Object.<string, object>}
*/
this.extensionStorage = {};
}
/**
* Called when the project receives a "green flag."
* @abstract
*/
onGreenFlag () {}
/**
* Return a human-readable name for this target.
* Target implementations should override this.
* @abstract
* @returns {string} Human-readable name for the target.
*/
getName () {
return this.id;
}
/**
* Update an edge-activated hat block value.
* @param {!string} blockId ID of hat to store value for.
* @param {*} newValue Value to store for edge-activated hat.
* @return {*} The old value for the edge-activated hat.
*/
updateEdgeActivatedValue (blockId, newValue) {
const oldValue = this._edgeActivatedHatValues[blockId];
this._edgeActivatedHatValues[blockId] = newValue;
return oldValue;
}
hasEdgeActivatedValue (blockId) {
return Object.prototype.hasOwnProperty.call(this._edgeActivatedHatValues, blockId);
}
/**
* Clear all edge-activaed hat values.
*/
clearEdgeActivatedValues () {
this._edgeActivatedHatValues = {};
}
/**
* Look up a variable object, first by id, and then by name if the id is not found.
* Create a new variable if both lookups fail.
* @param {string} id Id of the variable.
* @param {string} name Name of the variable.
* @return {!Variable} Variable object.
*/
lookupOrCreateVariable (id, name) {
let variable = this.lookupVariableById(id);
if (variable) return variable;
variable = this.lookupVariableByNameAndType(name, Variable.SCALAR_TYPE);
if (variable) return variable;
// No variable with this name exists - create it locally.
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.
* @param {string} id Id of the variable.
* @param {string} name Name of the variable.
* @return {!Variable} Variable object.
*/
lookupVariableById (id) {
// If we have a local copy, return it.
if (Object.prototype.hasOwnProperty.call(this.variables, id)) {
return this.variables[id];
}
// If the stage has a global copy, return it.
if (this.runtime && !this.isStage) {
const stage = this.runtime.getTargetForStage();
if (stage && Object.prototype.hasOwnProperty.call(stage.variables, id)) {
return stage.variables[id];
}
}
}
/**
* Look up a variable object by its name and variable type.
* Search begins with local variables; then global variables if a local one
* was not found.
* @param {string} name Name of the variable.
* @param {string} type Type of the variable. Defaults to Variable.SCALAR_TYPE.
* @param {?bool} skipStage Optional flag to skip checking the stage
* @return {?Variable} Variable object if found, or null if not.
*/
lookupVariableByNameAndType (name, type, skipStage) {
if (typeof name !== 'string') return;
if (typeof type !== 'string') type = Variable.SCALAR_TYPE;
skipStage = skipStage || false;
for (const varId in this.variables) {
const currVar = this.variables[varId];
if (currVar.name === name && currVar.type === type) {
return currVar;
}
}
if (!skipStage && this.runtime && !this.isStage) {
const stage = this.runtime.getTargetForStage();
if (stage) {
for (const varId in stage.variables) {
const currVar = stage.variables[varId];
if (currVar.name === name && currVar.type === type) {
return currVar;
}
}
}
}
return null;
}
/**
* 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 {!Varible} Variable object representing the found/created list.
*/
lookupOrCreateList (id, name) {
let list = this.lookupVariableById(id);
if (list) return list;
list = this.lookupVariableByNameAndType(name, Variable.LIST_TYPE);
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;
}
/**
* Creates a variable with the given id and name and adds it to the
* 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'
* @param {boolean} isCloud Whether the variable to create has the isCloud flag set.
* Additional checks are made that the variable can be created as a cloud variable.
*/
createVariable (id, name, type, isCloud) {
if (!Object.prototype.hasOwnProperty.call(this.variables, id)) {
const newVariable = new Variable(id, name, type, false);
if (isCloud && this.isStage && this.runtime.canAddCloudVariable()) {
newVariable.isCloud = true;
this.runtime.addCloudVariable();
this.runtime.ioDevices.cloud.requestCreateVariable(newVariable);
}
this.variables[id] = newVariable;
}
}
/**
* Creates a comment with the given properties.
* @param {string} id Id of the comment.
* @param {string} blockId Optional id of the block the comment is attached
* to if it is a block comment.
* @param {string} text The text the comment contains.
* @param {number} x The x coordinate of the comment on the workspace.
* @param {number} y The y coordinate of the comment on the workspace.
* @param {number} width The width of the comment when it is full size
* @param {number} height The height of the comment when it is full size
* @param {boolean} minimized Whether the comment is minimized.
*/
createComment (id, blockId, text, x, y, width, height, minimized) {
if (!Object.prototype.hasOwnProperty.call(this.comments, id)) {
const newComment = new Comment(id, text, x, y,
width, height, minimized);
if (blockId) {
newComment.blockId = blockId;
const blockWithComment = this.blocks.getBlock(blockId);
if (blockWithComment) {
blockWithComment.comment = id;
} else {
log.warn(`Could not find block with id ${blockId
} associated with commentId: ${id}`);
}
}
this.comments[id] = newComment;
}
}
/**
* Renames the variable with the given id to newName.
* @param {string} id Id of variable to rename.
* @param {string} newName New name for the variable.
*/
renameVariable (id, newName) {
if (Object.prototype.hasOwnProperty.call(this.variables, id)) {
const variable = this.variables[id];
if (variable.id === id) {
const oldName = variable.name;
variable.name = newName;
if (this.runtime) {
if (variable.isCloud && this.isStage) {
this.runtime.ioDevices.cloud.requestRenameVariable(oldName, newName);
}
if (variable.type === Variable.SCALAR_TYPE) {
// sensing__of may be referencing to this variable.
// Change the reference.
let blockUpdated = false;
this.runtime.targets.forEach(t => {
blockUpdated = t.blocks.updateSensingOfReference(
oldName,
newName,
this.isStage ? '_stage_' : this.getName()
) || blockUpdated;
});
// Request workspace change only if sensing_of blocks were actually updated.
if (blockUpdated) this.runtime.requestBlocksUpdate();
}
const blocks = this.runtime.monitorBlocks;
blocks.changeBlock({
id: id,
element: 'field',
name: variable.type === Variable.LIST_TYPE ? 'LIST' : 'VARIABLE',
value: id
}, this.runtime);
const monitorBlock = blocks.getBlock(variable.id);
if (monitorBlock) {
this.runtime.requestUpdateMonitor(Map({
id: id,
params: blocks._getBlockParams(monitorBlock)
}));
}
}
}
}
}
/**
* Removes the variable with the given id from the dictionary of variables.
* @param {string} id Id of variable to delete.
*/
deleteVariable (id) {
if (Object.prototype.hasOwnProperty.call(this.variables, id)) {
// Get info about the variable before deleting it
const deletedVariableName = this.variables[id].name;
const deletedVariableWasCloud = this.variables[id].isCloud;
delete this.variables[id];
if (this.runtime) {
if (deletedVariableWasCloud && this.isStage) {
this.runtime.ioDevices.cloud.requestDeleteVariable(deletedVariableName);
this.runtime.removeCloudVariable();
}
this.runtime.monitorBlocks.deleteBlock(id);
this.runtime.requestRemoveMonitor(id);
}
}
}
/**
* Remove this target's monitors from the runtime state and remove the
* target-specific monitored blocks (e.g. local variables, global variables for the stage, x-position).
* NOTE: This does not delete any of the stage monitors like backdrop name.
*/
deleteMonitors () {
this.runtime.requestRemoveMonitorByTargetId(this.id);
let targetSpecificMonitorBlockIds;
if (this.isStage) {
// This only deletes global variables and not other stage monitors like backdrop number.
targetSpecificMonitorBlockIds = Object.keys(this.variables);
} else {
targetSpecificMonitorBlockIds = Object.keys(this.runtime.monitorBlocks._blocks)
.filter(key => this.runtime.monitorBlocks._blocks[key].targetId === this.id);
}
for (const blockId of targetSpecificMonitorBlockIds) {
this.runtime.monitorBlocks.deleteBlock(blockId);
}
}
/**
* Create a clone of the variable with the given id from the dictionary of
* this target's variables.
* @param {string} id Id of variable to duplicate.
* @param {boolean=} optKeepOriginalId Optional flag to keep the original variable ID
* for the duplicate variable. This is necessary when cloning a sprite, for example.
* @return {?Variable} The duplicated variable, or null if
* the original variable was not found.
*/
duplicateVariable (id, optKeepOriginalId) {
if (Object.prototype.hasOwnProperty.call(this.variables, id)) {
const originalVariable = this.variables[id];
const newVariable = new Variable(
optKeepOriginalId ? id : null, // conditionally keep original id or generate a new one
originalVariable.name,
originalVariable.type,
originalVariable.isCloud
);
if (newVariable.type === Variable.LIST_TYPE) {
newVariable.value = originalVariable.value.slice(0);
} else {
newVariable.value = originalVariable.value;
}
return newVariable;
}
return null;
}
/**
* Duplicate the dictionary of this target's variables as part of duplicating.
* this target or making a clone.
* @param {object=} optBlocks Optional block container for the target being duplicated.
* If provided, new variables will be generated with new UIDs and any variable references
* in this blocks container will be updated to refer to the corresponding new IDs.
* @return {object} The duplicated dictionary of variables
*/
duplicateVariables (optBlocks) {
let allVarRefs;
if (optBlocks) {
allVarRefs = optBlocks.getAllVariableAndListReferences();
}
return Object.keys(this.variables).reduce((accum, varId) => {
const newVariable = this.duplicateVariable(varId, !optBlocks);
accum[newVariable.id] = newVariable;
if (optBlocks && allVarRefs) {
const currVarRefs = allVarRefs[varId];
if (currVarRefs) {
this.mergeVariables(varId, newVariable.id, currVarRefs);
}
}
return accum;
}, {});
}
/**
* Post/edit sprite info.
* @param {object} data An object with sprite info data to set.
* @abstract
*/
postSpriteInfo () {}
/**
* Retrieve custom state associated with this target and the provided state ID.
* @param {string} stateId - specify which piece of state to retrieve.
* @returns {*} the associated state, if any was found.
*/
getCustomState (stateId) {
return this._customState[stateId];
}
/**
* Store custom state associated with this target and the provided state ID.
* @param {string} stateId - specify which piece of state to store on this target.
* @param {*} newValue - the state value to store.
*/
setCustomState (stateId, newValue) {
this._customState[stateId] = newValue;
}
/**
* Call to destroy a target.
* @abstract
*/
dispose () {
this._customState = {};
if (this.runtime) {
this.runtime.removeExecutable(this);
}
}
// Variable Conflict Resolution Helpers
/**
* Get the names of all the variables of the given type that are in scope for this target.
* For targets that are not the stage, this includes any target-specific
* variables as well as any stage variables unless the skipStage flag is true.
* For the stage, this is all stage variables.
* @param {string} type The variable type to search for; defaults to Variable.SCALAR_TYPE
* @param {?bool} skipStage Optional flag to skip the stage.
* @return {Array<string>} A list of variable names
*/
getAllVariableNamesInScopeByType (type, skipStage) {
if (typeof type !== 'string') type = Variable.SCALAR_TYPE;
skipStage = skipStage || false;
const targetVariables = Object.values(this.variables)
.filter(v => v.type === type)
.map(variable => variable.name);
if (skipStage || this.isStage || !this.runtime) {
return targetVariables;
}
const stage = this.runtime.getTargetForStage();
const stageVariables = stage.getAllVariableNamesInScopeByType(type);
return targetVariables.concat(stageVariables);
}
/**
* Merge variable references with another variable.
* @param {string} idToBeMerged ID of the variable whose references need to be updated
* @param {string} idToMergeWith ID of the variable that the old references should be replaced with
* @param {?Array<Object>} optReferencesToUpdate Optional context of the change.
* Defaults to all the blocks in this target.
* @param {?string} optNewName New variable name to merge with. The old
* variable name in the references being updated should be replaced with this new name.
* If this parameter is not provided or is '', no name change occurs.
*/
mergeVariables (idToBeMerged, idToMergeWith, optReferencesToUpdate, optNewName) {
const referencesToChange = optReferencesToUpdate ||
// TODO should there be a separate helper function that traverses the blocks
// for all references for a given ID instead of doing the below..?
this.blocks.getAllVariableAndListReferences()[idToBeMerged];
VariableUtil.updateVariableIdentifiers(referencesToChange, idToMergeWith, optNewName);
}
/**
* Share a local variable (and given references for that variable) to the stage.
* @param {string} varId The ID of the variable to share.
* @param {Array<object>} varRefs The list of variable references being shared,
* that reference the given variable ID. The names and IDs of these variable
* references will be updated to refer to the new (or pre-existing) global variable.
*/
shareLocalVariableToStage (varId, varRefs) {
if (!this.runtime) return;
const variable = this.variables[varId];
if (!variable) {
log.warn(`Cannot share a local variable to the stage if it's not local.`);
return;
}
const stage = this.runtime.getTargetForStage();
// If a local var is being shared with the stage,
// sharing will make the variable global, resulting in a conflict
// with the existing local variable. Preemptively Resolve this conflict
// by renaming the new global variable.
// First check if we've already done the local to global transition for this
// variable. If we have, merge it with the global variable we've already created.
const varIdForStage = `StageVarFromLocal_${varId}`;
let stageVar = stage.lookupVariableById(varIdForStage);
// If a global var doesn't already exist, create a new one with a fresh name.
// Use the ID we created above so that we can lookup this new variable in the
// future if we decide to share this same variable again.
if (!stageVar) {
const varName = variable.name;
const varType = variable.type;
const newStageName = `Stage: ${varName}`;
stageVar = this.runtime.createNewGlobalVariable(newStageName, varIdForStage, varType);
}
// Update all variable references to use the new name and ID
this.mergeVariables(varId, stageVar.id, varRefs, stageVar.name);
}
/**
* Share a local variable with a sprite, merging with one of the same name and
* type if it already exists on the sprite, or create a new one.
* @param {string} varId Id of the variable to share
* @param {Target} sprite The sprite to share the variable with
* @param {Array<object>} varRefs A list of all the variable references currently being shared.
*/
shareLocalVariableToSprite (varId, sprite, varRefs) {
if (!this.runtime) return;
if (this.isStage) return;
const variable = this.variables[varId];
if (!variable) {
log.warn(`Tried to call 'shareLocalVariableToSprite' with a non-local variable.`);
return;
}
const varName = variable.name;
const varType = variable.type;
// Check if the receiving sprite already has a variable of the same name and type
// and use the existing variable, otherwise create a new one.
const existingLocalVar = sprite.lookupVariableByNameAndType(varName, varType);
let newVarId;
if (existingLocalVar) {
newVarId = existingLocalVar.id;
} else {
const newVar = new Variable(null, varName, varType);
newVarId = newVar.id;
sprite.variables[newVarId] = newVar;
}
// Merge with the local variable on the new sprite.
this.mergeVariables(varId, newVarId, varRefs);
}
/**
* Given a list of variable referencing fields, shares those variables with
* the target with the provided id, resolving any variable conflicts that arise
* using the following rules:
*
* If this target is the stage, exit. There are no conflicts that arise
* from sharing variables from the stage to another sprite. The variables
* already exist globally, so no further action is needed.
*
* If a variable being referenced is a global variable, do nothing. The
* global variable already exists so no further action is needed.
*
* If a variable being referenced is local, and
* 1) The receiving target is a sprite:
* create a new local variable or merge with an existing local variable
* of the same name and type. Update all the referencing fields
* for the original variable to reference the new variable.
* 2) The receiving target is the stage:
* Create a new global variable with a fresh name and update all the referencing
* fields to reference the new variable.
*
* @param {Array<object>} blocks The blocks containing
* potential conflicting references to variables.
* @param {Target} receivingTarget The target receiving the variables
*/
resolveVariableSharingConflictsWithTarget (blocks, receivingTarget) {
if (this.isStage) return;
// Get all the variable references in the given list of blocks
const allVarListRefs = this.blocks.getAllVariableAndListReferences(blocks);
// For all the variables being referenced, check for which ones are local
// to this target, and resolve conflicts based on whether the receiving target
// is a sprite (with a conflicting local variable) or whether it is
// the stage (which cannot have local variables)
for (const varId in allVarListRefs) {
const currVar = this.variables[varId];
if (!currVar) continue; // The current variable is global, there shouldn't be any conflicts here, skip it.
// Get the list of references for the current variable id
const currVarListRefs = allVarListRefs[varId];
if (receivingTarget.isStage) {
this.shareLocalVariableToStage(varId, currVarListRefs);
} else {
this.shareLocalVariableToSprite(varId, receivingTarget, currVarListRefs);
}
}
}
/**
* Fixes up variable references in this target avoiding conflicts with
* pre-existing variables in the same scope.
* This is used when uploading this target as a new sprite into an existing
* project, where the new sprite may contain references
* to variable names that already exist as global variables in the project
* (and thus are in scope for variable references in the given sprite).
*
* If this target has a block that references an existing global variable and that
* variable *does not* exist in this target (e.g. it was a global variable in the
* project the sprite was originally exported from), merge the variables. This entails
* fixing the variable references in this sprite to reference the id of the pre-existing global variable.
*
* If this target has a block that references an existing global variable and that
* variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded),
* then the local variable is renamed to distinguish itself from the pre-existing variable.
* All blocks that reference the local variable will be updated to use the new name.
*/
// TODO (#1360) This function is too long, add some helpers for the different chunks and cases...
fixUpVariableReferences () {
if (!this.runtime) return; // There's no runtime context to conflict with
if (this.isStage) return; // Stage can't have variable conflicts with itself (and also can't be uploaded)
const stage = this.runtime.getTargetForStage();
if (!stage || !stage.variables) return;
const renameConflictingLocalVar = (id, name, type) => {
const conflict = stage.lookupVariableByNameAndType(name, type);
if (conflict) {
const newName = StringUtil.unusedName(
`${this.getName()}: ${name}`,
this.getAllVariableNamesInScopeByType(type));
this.renameVariable(id, newName);
return newName;
}
return null;
};
const allReferences = this.blocks.getAllVariableAndListReferences();
const unreferencedLocalVarIds = [];
if (Object.keys(this.variables).length > 0) {
for (const localVarId in this.variables) {
if (!Object.prototype.hasOwnProperty.call(this.variables, localVarId)) continue;
if (!allReferences[localVarId]) unreferencedLocalVarIds.push(localVarId);
}
}
const conflictIdsToReplace = Object.create(null);
const conflictNamesToReplace = Object.create(null);
// Cache the list of all variable names by type so that we don't need to
// re-calculate this in every iteration of the following loop.
const varNamesByType = {};
const allVarNames = type => {
const namesOfType = varNamesByType[type];
if (namesOfType) return namesOfType;
varNamesByType[type] = this.runtime.getAllVarNamesOfType(type);
return varNamesByType[type];
};
for (const varId in allReferences) {
// We don't care about which var ref we get, they should all have the same var info
const varRef = allReferences[varId][0];
const varName = varRef.referencingField.value;
const varType = varRef.type;
if (this.lookupVariableById(varId)) {
// Found a variable with the id in either the target or the stage,
// figure out which one.
if (Object.prototype.hasOwnProperty.call(this.variables, varId)) {
// If the target has the variable, then check whether the stage
// has one with the same name and type. If it does, then rename
// this target specific variable so that there is a distinction.
const newVarName = renameConflictingLocalVar(varId, varName, varType);
if (newVarName) {
// We are not calling this.blocks.updateBlocksAfterVarRename
// here because it will search through all the blocks. We already
// have access to all the references for this var id.
allReferences[varId].map(ref => {
ref.referencingField.value = newVarName;
return ref;
});
}
}
} else {
// We didn't find the referenced variable id anywhere,
// Treat it as a reference to a global variable (from the original
// project this sprite was exported from).
// Check for whether a global variable of the same name and type exists,
// and if so, track it to merge with the existing global in a second pass of the blocks.
const existingVar = stage.lookupVariableByNameAndType(varName, varType);
if (existingVar) {
if (!conflictIdsToReplace[varId]) {
conflictIdsToReplace[varId] = existingVar.id;
}
} else {
// A global variable with the same name did not already exist,
// create a new one such that it does not conflict with any
// names of local variables of the same type.
const allNames = allVarNames(varType);
const freshName = StringUtil.unusedName(varName, allNames);
stage.createVariable(varId, freshName, varType);
if (!conflictNamesToReplace[varId]) {
conflictNamesToReplace[varId] = freshName;
}
}
}
}
// Rename any local variables that were missed above because they aren't
// referenced by any blocks
for (const id in unreferencedLocalVarIds) {
const varId = unreferencedLocalVarIds[id];
const name = this.variables[varId].name;
const type = this.variables[varId].type;
renameConflictingLocalVar(varId, name, type);
}
// Handle global var conflicts with existing global vars (e.g. a sprite is uploaded, and has
// blocks referencing some variable that the sprite does not own, and this
// variable conflicts with a global var)
// In this case, we want to merge the new variable referenes with the
// existing global variable
for (const conflictId in conflictIdsToReplace) {
const existingId = conflictIdsToReplace[conflictId];
const referencesToUpdate = allReferences[conflictId];
this.mergeVariables(conflictId, existingId, referencesToUpdate);
}
// Handle global var conflicts existing local vars (e.g a sprite is uploaded,
// and has blocks referencing some variable that the sprite does not own, and this
// variable conflcits with another sprite's local var).
// In this case, we want to go through the variable references and update
// the name of the variable in that reference.
for (const conflictId in conflictNamesToReplace) {
const newName = conflictNamesToReplace[conflictId];
const referencesToUpdate = allReferences[conflictId];
referencesToUpdate.map(ref => {
ref.referencingField.value = newName;
return ref;
});
}
}
}
module.exports = Target;

View File

@@ -0,0 +1,530 @@
const log = require('../util/log');
/**
* Recycle bin for empty stackFrame objects
* @type Array<_StackFrame>
*/
const _stackFrameFreeList = [];
/**
* A frame used for each level of the stack. A general purpose
* place to store a bunch of execution context and parameters
* @param {boolean} warpMode Whether this level of the stack is warping
* @constructor
* @private
*/
class _StackFrame {
constructor (warpMode) {
/**
* Whether this level of the stack is a loop.
* @type {boolean}
*/
this.isLoop = false;
/**
* Whether this level is in warp mode. Is set by some legacy blocks and
* "turbo mode"
* @type {boolean}
*/
this.warpMode = warpMode;
/**
* Reported value from just executed block.
* @type {Any}
*/
this.justReported = null;
/**
* The active block that is waiting on a promise.
* @type {string}
*/
this.reporting = '';
/**
* Persists reported inputs during async block.
* @type {Object}
*/
this.reported = null;
/**
* Name of waiting reporter.
* @type {string}
*/
this.waitingReporter = null;
/**
* Procedure parameters.
* @type {Object}
*/
this.params = null;
/**
* A context passed to block implementations.
* @type {Object}
*/
this.executionContext = null;
/**
* Internal block object being executed. This is *not* the same as the object found
* in target.blocks.
* @type {object}
*/
this.op = null;
}
/**
* Reset all properties of the frame to pristine null and false states.
* Used to recycle.
* @return {_StackFrame} this
*/
reset () {
this.isLoop = false;
this.warpMode = false;
this.justReported = null;
this.reported = null;
this.waitingReporter = null;
this.params = null;
this.executionContext = null;
this.op = null;
return this;
}
/**
* Reuse an active stack frame in the stack.
* @param {?boolean} warpMode defaults to current warpMode
* @returns {_StackFrame} this
*/
reuse (warpMode = this.warpMode) {
this.reset();
this.warpMode = Boolean(warpMode);
return this;
}
/**
* Create or recycle a stack frame object.
* @param {boolean} warpMode Enable warpMode on this frame.
* @returns {_StackFrame} The clean stack frame with correct warpMode setting.
*/
static create (warpMode) {
const stackFrame = _stackFrameFreeList.pop();
if (typeof stackFrame !== 'undefined') {
stackFrame.warpMode = Boolean(warpMode);
return stackFrame;
}
return new _StackFrame(warpMode);
}
/**
* Put a stack frame object into the recycle bin for reuse.
* @param {_StackFrame} stackFrame The frame to reset and recycle.
*/
static release (stackFrame) {
if (typeof stackFrame !== 'undefined') {
_stackFrameFreeList.push(stackFrame.reset());
}
}
}
/**
* A thread is a running stack context and all the metadata needed.
* @param {?string} firstBlock First block to execute in the thread.
* @constructor
*/
class Thread {
constructor (firstBlock) {
/**
* ID of top block of the thread
* @type {!string}
*/
this.topBlock = firstBlock;
/**
* Stack for the thread. When the sequencer enters a control structure,
* the block is pushed onto the stack so we know where to exit.
* @type {Array.<string>}
*/
this.stack = [];
/**
* Stack frames for the thread. Store metadata for the executing blocks.
* @type {Array.<_StackFrame>}
*/
this.stackFrames = [];
/**
* Status of the thread, one of three states (below)
* @type {number}
*/
this.status = 0; /* Thread.STATUS_RUNNING */
/**
* Whether the thread is killed in the middle of execution.
* @type {boolean}
*/
this.isKilled = false;
/**
* Target of this thread.
* @type {?Target}
*/
this.target = null;
/**
* The Blocks this thread will execute.
* @type {Blocks}
*/
this.blockContainer = null;
/**
* Whether the thread requests its script to glow during this frame.
* @type {boolean}
*/
this.requestScriptGlowInFrame = false;
/**
* Which block ID should glow during this frame, if any.
* @type {?string}
*/
this.blockGlowInFrame = null;
/**
* A timer for when the thread enters warp mode.
* Substitutes the sequencer's count toward WORK_TIME on a per-thread basis.
* @type {?Timer}
*/
this.warpTimer = null;
this.justReported = null;
this.triedToCompile = false;
this.isCompiled = false;
// compiler data
// these values only make sense if isCompiled == true
this.timer = null;
/**
* The thread's generator.
* @type {Generator}
*/
this.generator = null;
/**
* @type {Object.<string, import('../compiler/compile').CompiledScript>}
*/
this.procedures = null;
this.executableHat = false;
}
/**
* Thread status for initialized or running thread.
* This is the default state for a thread - execution should run normally,
* stepping from block to block.
* @const
*/
static get STATUS_RUNNING () {
return 0; // used by compiler
}
/**
* Threads are in this state when a primitive is waiting on a promise;
* execution is paused until the promise changes thread status.
* @const
*/
static get STATUS_PROMISE_WAIT () {
return 1; // used by compiler
}
/**
* Thread status for yield.
* @const
*/
static get STATUS_YIELD () {
return 2; // used by compiler
}
/**
* Thread status for a single-tick yield. This will be cleared when the
* thread is resumed.
* @const
*/
static get STATUS_YIELD_TICK () {
return 3; // used by compiler
}
/**
* Thread status for a finished/done thread.
* Thread is in this state when there are no more blocks to execute.
* @const
*/
static get STATUS_DONE () {
return 4; // used by compiler
}
/**
* @param {Target} target The target running the thread.
* @param {string} topBlock ID of the thread's top block.
* @returns {string} A unique ID for this target and thread.
*/
static getIdFromTargetAndBlock (target, topBlock) {
// & should never appear in any IDs, so we can use it as a separator
return `${target.id}&${topBlock}`;
}
getId () {
return Thread.getIdFromTargetAndBlock(this.target, this.topBlock);
}
/**
* Push stack and update stack frames appropriately.
* @param {string} blockId Block ID to push to stack.
*/
pushStack (blockId) {
this.stack.push(blockId);
// Push an empty stack frame, if we need one.
// Might not, if we just popped the stack.
if (this.stack.length > this.stackFrames.length) {
const parent = this.stackFrames[this.stackFrames.length - 1];
this.stackFrames.push(_StackFrame.create(typeof parent !== 'undefined' && parent.warpMode));
}
}
/**
* Reset the stack frame for use by the next block.
* (avoids popping and re-pushing a new stack frame - keeps the warpmode the same
* @param {string} blockId Block ID to push to stack.
*/
reuseStackForNextBlock (blockId) {
this.stack[this.stack.length - 1] = blockId;
this.stackFrames[this.stackFrames.length - 1].reuse();
}
/**
* Pop last block on the stack and its stack frame.
* @return {string} Block ID popped from the stack.
*/
popStack () {
_StackFrame.release(this.stackFrames.pop());
return this.stack.pop();
}
/**
* Pop back down the stack frame until we hit a procedure call or the stack frame is emptied
*/
stopThisScript () {
let blockID = this.peekStack();
while (blockID !== null) {
const block = this.target.blocks.getBlock(blockID);
// Reporter form of procedures_call
if (this.peekStackFrame().waitingReporter) {
break;
}
// Command form of procedures_call
if (typeof block !== 'undefined' && block.opcode === 'procedures_call') {
// By definition, if we get here, the procedure is done, so skip ahead so
// the arguments won't be re-evaluated and then discarded as frozen state
// about which arguments have been evaluated is lost.
// This fixes https://github.com/TurboWarp/scratch-vm/issues/201
this.goToNextBlock();
break;
}
this.popStack();
blockID = this.peekStack();
}
if (this.stack.length === 0) {
// Clean up!
this.requestScriptGlowInFrame = false;
this.status = Thread.STATUS_DONE;
}
}
/**
* Get top stack item.
* @return {?string} Block ID on top of stack.
*/
peekStack () {
return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
}
/**
* Get top stack frame.
* @return {?object} Last stack frame stored on this thread.
*/
peekStackFrame () {
return this.stackFrames.length > 0 ? this.stackFrames[this.stackFrames.length - 1] : null;
}
/**
* Get stack frame above the current top.
* @return {?object} Second to last stack frame stored on this thread.
*/
peekParentStackFrame () {
return this.stackFrames.length > 1 ? this.stackFrames[this.stackFrames.length - 2] : null;
}
/**
* Push a reported value to the parent of the current stack frame.
* @param {*} value Reported value to push.
*/
pushReportedValue (value) {
this.justReported = typeof value === 'undefined' ? null : value;
}
/**
* Initialize procedure parameters on this stack frame.
*/
initParams () {
const stackFrame = this.peekStackFrame();
if (stackFrame.params === null) {
stackFrame.params = {};
}
}
/**
* Add a parameter to the stack frame.
* Use when calling a procedure with parameter values.
* @param {!string} paramName Name of parameter.
* @param {*} value Value to set for parameter.
*/
pushParam (paramName, value) {
const stackFrame = this.peekStackFrame();
stackFrame.params[paramName] = value;
}
/**
* Get a parameter at the lowest possible level of the stack.
* @param {!string} paramName Name of parameter.
* @return {*} value Value for parameter.
*/
getParam (paramName) {
for (let i = this.stackFrames.length - 1; i >= 0; i--) {
const frame = this.stackFrames[i];
if (frame.params === null) {
continue;
}
if (Object.prototype.hasOwnProperty.call(frame.params, paramName)) {
return frame.params[paramName];
}
return null;
}
return null;
}
getAllparams () {
const stackFrame = this.peekStackFrame();
return stackFrame.params;
}
/**
* Whether the current execution of a thread is at the top of the stack.
* @return {boolean} True if execution is at top of the stack.
*/
atStackTop () {
return this.peekStack() === this.topBlock;
}
/**
* Switch the thread to the next block at the current level of the stack.
* For example, this is used in a standard sequence of blocks,
* where execution proceeds from one block to the next.
*/
goToNextBlock () {
const nextBlockId = this.target.blocks.getNextBlock(this.peekStack());
this.reuseStackForNextBlock(nextBlockId);
}
/**
* Attempt to determine whether a procedure call is recursive,
* by examining the stack.
* @param {!string} procedureCode Procedure code of procedure being called.
* @return {boolean} True if the call appears recursive.
*/
isRecursiveCall (procedureCode) {
let callCount = 5; // Max number of enclosing procedure calls to examine.
const sp = this.stackFrames.length - 1;
for (let i = sp - 1; i >= 0; i--) {
const block = this.target.blocks.getBlock(this.stackFrames[i].op.id);
if (block.opcode === 'procedures_call' &&
block.mutation.proccode === procedureCode) {
return true;
}
if (--callCount < 0) return false;
}
return false;
}
/**
* Attempt to compile this thread.
*/
tryCompile () {
if (!this.blockContainer) {
return;
}
// importing the compiler here avoids circular dependency issues
const compile = require('../compiler/compile');
this.triedToCompile = true;
// stackClick === true disables hat block generation
// It would be great to cache these separately, but for now it's easiest to just disable them to avoid
// cached versions of scripts breaking projects.
const canCache = !this.stackClick;
const topBlock = this.topBlock;
// Flyout blocks are stored in a special block container.
const blocks = this.blockContainer.getBlock(topBlock) ? this.blockContainer : this.target.runtime.flyoutBlocks;
const cachedResult = canCache && blocks.getCachedCompileResult(topBlock);
// If there is a cached error, do not attempt to recompile.
if (cachedResult && !cachedResult.success) {
return;
}
let result;
if (cachedResult) {
result = cachedResult.value;
} else {
try {
result = compile(this);
if (canCache) {
blocks.cacheCompileResult(topBlock, result);
}
} catch (error) {
log.error('cannot compile script', this.target.getName(), error);
if (canCache) {
blocks.cacheCompileError(topBlock, error);
}
this.target.runtime.emitCompileError(this.target, error);
return;
}
}
this.procedures = {};
for (const procedureCode of Object.keys(result.procedures)) {
this.procedures[procedureCode] = result.procedures[procedureCode](this);
}
this.generator = result.startingFunction(this)();
this.executableHat = result.executableHat;
if (!this.blockContainer.forceNoGlow) {
this.blockGlowInFrame = this.topBlock;
this.requestScriptGlowInFrame = true;
}
this.isCompiled = true;
}
}
// for extensions
Thread._StackFrame = _StackFrame;
module.exports = Thread;

View File

@@ -0,0 +1,230 @@
const EventEmitter = require('events');
const AssetUtil = require('../util/tw-asset-util');
const StringUtil = require('../util/string-util');
const log = require('../util/log');
/**
* @typedef InternalFont
* @property {boolean} system True if the font is built in to the system
* @property {string} family The font's name
* @property {string} fallback Fallback font family list
* @property {Asset} [asset] scratch-storage asset if system: false
*/
class FontManager extends EventEmitter {
/**
* @param {Runtime} runtime
*/
constructor (runtime) {
super();
this.runtime = runtime;
/** @type {Array<InternalFont>} */
this.fonts = [];
}
/**
* @param {string} family An unknown font family
* @returns {boolean} true if the family is valid
*/
isValidFamily (family) {
return /^[-\w ]+$/.test(family);
}
/**
* @param {string} family
* @returns {boolean}
*/
hasFont (family) {
return !!this.fonts.find(i => i.family === family);
}
/**
* @param {string} family
* @returns {boolean}
*/
getSafeName (family) {
family = family.replace(/[^-\w ]/g, '');
return StringUtil.unusedName(family, this.fonts.map(i => i.family));
}
changed () {
this.emit('change');
}
/**
* @param {string} family
* @param {string} fallback
*/
addSystemFont (family, fallback) {
if (!this.isValidFamily(family)) {
throw new Error('Invalid family');
}
this.fonts.push({
system: true,
family,
fallback
});
this.changed();
}
/**
* @param {string} family
* @param {string} fallback
* @param {Asset} asset scratch-storage asset
*/
addCustomFont (family, fallback, asset) {
if (!this.isValidFamily(family)) {
throw new Error('Invalid family');
}
this.fonts.push({
system: false,
family,
fallback,
asset
});
this.updateRenderer();
this.changed();
}
/**
* @returns {Array<{system: boolean; name: string; family: string; data: Uint8Array | null; format: string | null}>}
*/
getFonts () {
return this.fonts.map(font => ({
system: font.system,
name: font.family,
family: `"${font.family}", ${font.fallback}`,
data: font.asset ? font.asset.data : null,
format: font.asset ? font.asset.dataFormat : null
}));
}
/**
* @param {number} index Corresponds to index from getFonts()
*/
deleteFont (index) {
const [removed] = this.fonts.splice(index, 1);
if (!removed.system) {
this.updateRenderer();
}
this.changed();
}
clear () {
const hadNonSystemFont = this.fonts.some(i => !i.system);
this.fonts = [];
if (hadNonSystemFont) {
this.updateRenderer();
}
this.changed();
}
updateRenderer () {
if (!this.runtime.renderer || !this.runtime.renderer.setCustomFonts) {
return;
}
const fontfaces = {};
for (const font of this.fonts) {
if (!font.system) {
const uri = font.asset.encodeDataURI();
const fontface = `@font-face { font-family: "${font.family}"; src: url("${uri}"); }`;
const family = `"${font.family}", ${font.fallback}`;
fontfaces[family] = fontface;
}
}
this.runtime.renderer.setCustomFonts(fontfaces);
}
/**
* Get data to save in project.json and sb3 files.
*/
serializeJSON () {
if (this.fonts.length === 0) {
return null;
}
return this.fonts.map(font => {
const serialized = {
system: font.system,
family: font.family,
fallback: font.fallback
};
if (!font.system) {
const asset = font.asset;
serialized.md5ext = `${asset.assetId}.${asset.dataFormat}`;
}
return serialized;
});
}
/**
* @returns {Asset[]} list of scratch-storage assets
*/
serializeAssets () {
return this.fonts
.filter(i => !i.system)
.map(i => i.asset);
}
/**
* @param {unknown} json
* @param {JSZip} [zip]
* @param {boolean} [keepExisting]
* @returns {Promise<void>}
*/
async deserialize (json, zip, keepExisting) {
if (!keepExisting) {
this.clear();
}
if (!Array.isArray(json)) {
return;
}
for (const font of json) {
if (!font || typeof font !== 'object') {
continue;
}
try {
const system = font.system;
const family = font.family;
const fallback = font.fallback;
if (
typeof system !== 'boolean' ||
typeof family !== 'string' ||
typeof fallback !== 'string' ||
this.hasFont(family)
) {
continue;
}
if (system) {
this.addSystemFont(family, fallback);
} else {
const md5ext = font.md5ext;
if (typeof md5ext !== 'string') {
continue;
}
const asset = await AssetUtil.getByMd5ext(
this.runtime,
zip,
this.runtime.storage.AssetType.Font,
md5ext
);
this.addCustomFont(family, fallback, asset);
}
} catch (e) {
log.error('could not add font', e);
}
}
}
}
module.exports = FontManager;

View File

@@ -0,0 +1,94 @@
// Due to the existence of features such as interpolation and "0 FPS" being treated as "screen refresh rate",
// The VM loop logic has become much more complex
// Use setTimeout to polyfill requestAnimationFrame in Node.js environments
const _requestAnimationFrame = typeof requestAnimationFrame === 'function' ?
requestAnimationFrame :
(f => setTimeout(f, 1000 / 60));
const _cancelAnimationFrame = typeof requestAnimationFrame === 'function' ?
cancelAnimationFrame :
clearTimeout;
const animationFrameWrapper = callback => {
let id;
const handle = () => {
id = _requestAnimationFrame(handle);
callback();
};
const cancel = () => _cancelAnimationFrame(id);
id = _requestAnimationFrame(handle);
return {
cancel
};
};
class FrameLoop {
constructor (runtime) {
this.runtime = runtime;
this.running = false;
this.setFramerate(30);
this.setInterpolation(false);
this.stepCallback = this.stepCallback.bind(this);
this.interpolationCallback = this.interpolationCallback.bind(this);
this._stepInterval = null;
this._interpolationAnimation = null;
this._stepAnimation = null;
}
setFramerate (fps) {
this.framerate = fps;
this._restart();
}
setInterpolation (interpolation) {
this.interpolation = interpolation;
this._restart();
}
stepCallback () {
this.runtime._step();
}
interpolationCallback () {
this.runtime._renderInterpolatedPositions();
}
_restart () {
if (this.running) {
this.stop();
this.start();
}
}
start () {
this.running = true;
if (this.framerate === 0) {
this._stepAnimation = animationFrameWrapper(this.stepCallback);
this.runtime.currentStepTime = 1000 / 60;
} else {
// Interpolation should never be enabled when framerate === 0 as that's just redundant
if (this.interpolation) {
this._interpolationAnimation = animationFrameWrapper(this.interpolationCallback);
}
this._stepInterval = setInterval(this.stepCallback, 1000 / this.framerate);
this.runtime.currentStepTime = 1000 / this.framerate;
}
}
stop () {
this.running = false;
clearInterval(this._stepInterval);
if (this._interpolationAnimation) {
this._interpolationAnimation.cancel();
}
if (this._stepAnimation) {
this._stepAnimation.cancel();
}
this._interpolationAnimation = null;
this._stepAnimation = null;
}
}
module.exports = FrameLoop;

View File

@@ -0,0 +1,140 @@
/**
* Prepare the targets of a runtime for interpolation.
* @param {Runtime} runtime The Runtime with targets to prepare for interpolation.
*/
const setupInitialState = runtime => {
const renderer = runtime.renderer;
for (const target of runtime.targets) {
const directionAndScale = target._getRenderedDirectionAndScale();
// If sprite may have been interpolated in the previous frame, reset its renderer state.
if (renderer && target.interpolationData) {
const drawableID = target.drawableID;
renderer.updateDrawablePosition(drawableID, [target.x, target.y]);
renderer.updateDrawableDirectionScale(drawableID, directionAndScale.direction, directionAndScale.scale);
renderer.updateDrawableEffect(drawableID, 'ghost', target.effects.ghost);
}
if (target.visible && !target.isStage) {
target.interpolationData = {
x: target.x,
y: target.y,
direction: directionAndScale.direction,
scale: directionAndScale.scale,
costume: target.currentCostume,
ghost: target.effects.ghost
};
} else {
target.interpolationData = null;
}
}
};
/**
* Interpolate the position of targets.
* @param {Runtime} runtime The Runtime with targets to interpolate.
* @param {number} time Relative time in the frame in [0-1].
*/
const interpolate = (runtime, time) => {
const renderer = runtime.renderer;
if (!renderer) {
return;
}
for (const target of runtime.targets) {
// interpolationData is the initial state at the start of the frame (time 0)
// the state on the target itself is the state at the end of the frame (time 1)
const interpolationData = target.interpolationData;
if (!interpolationData) {
continue;
}
// Don't waste time interpolating sprites that are hidden.
if (!target.visible) {
continue;
}
const drawableID = target.drawableID;
// Position interpolation.
const xDistance = target.x - interpolationData.x;
const yDistance = target.y - interpolationData.y;
const absoluteXDistance = Math.abs(xDistance);
const absoluteYDistance = Math.abs(yDistance);
if (absoluteXDistance > 0.1 || absoluteYDistance > 0.1) {
const drawable = renderer._allDrawables[drawableID];
// Large movements are likely intended to be instantaneous.
// getAABB is less accurate than getBounds, but it's much faster
const bounds = drawable.getAABB();
const tolerance = Math.min(240, Math.max(50, 1.5 * (bounds.width + bounds.height)));
const distance = Math.sqrt((absoluteXDistance ** 2) + (absoluteYDistance ** 2));
if (distance < tolerance) {
const newX = interpolationData.x + (xDistance * time);
const newY = interpolationData.y + (yDistance * time);
renderer.updateDrawablePosition(drawableID, [newX, newY]);
}
}
// Effect interpolation.
const ghostChange = target.effects.ghost - interpolationData.ghost;
const absoluteGhostChange = Math.abs(ghostChange);
// Large changes are likely intended to be instantaneous.
if (absoluteGhostChange > 0 && absoluteGhostChange < 25) {
const newGhost = target.effects.ghost + (ghostChange * time);
renderer.updateDrawableEffect(drawableID, 'ghost', newGhost);
}
// Interpolate scale and direction.
const costumeUnchanged = interpolationData.costume === target.currentCostume;
if (costumeUnchanged) {
let {direction, scale} = target._getRenderedDirectionAndScale();
let updateDrawableDirectionScale = false;
// Interpolate direction.
if (direction !== interpolationData.direction) {
// Perfect 90 degree angles should not be interpolated.
// eg. the foreground tile clones in https://scratch.mit.edu/projects/60917032/
if (direction % 90 !== 0 || interpolationData.direction % 90 !== 0) {
const currentRadians = direction * Math.PI / 180;
const startingRadians = interpolationData.direction * Math.PI / 180;
direction = Math.atan2(
(Math.sin(currentRadians) * time) + (Math.sin(startingRadians) * (1 - time)),
(Math.cos(currentRadians) * time) + (Math.cos(startingRadians) * (1 - time))
) * 180 / Math.PI;
updateDrawableDirectionScale = true;
}
}
// Interpolate scale.
const startingScale = interpolationData.scale;
if (scale[0] !== startingScale[0] || scale[1] !== startingScale[1]) {
// Do not interpolate size when the sign of either scale differs.
if (
Math.sign(scale[0]) === Math.sign(startingScale[0]) &&
Math.sign(scale[1]) === Math.sign(startingScale[1])
) {
const changeX = scale[0] - startingScale[0];
const changeY = scale[1] - startingScale[1];
const absoluteChangeX = Math.abs(changeX);
const absoluteChangeY = Math.abs(changeY);
// Large changes are likely intended to be instantaneous.
if (absoluteChangeX < 100 && absoluteChangeY < 100) {
scale[0] = startingScale[0] + (changeX * time);
scale[1] = startingScale[1] + (changeY * time);
updateDrawableDirectionScale = true;
}
}
}
if (updateDrawableDirectionScale) {
renderer.updateDrawableDirectionScale(drawableID, direction, scale);
}
}
}
};
module.exports = {
setupInitialState,
interpolate
};

View File

@@ -0,0 +1,7 @@
// Forks should change this.
// This can be accessed externally on `vm.runtime.platform`
module.exports = {
name: 'TurboWarp',
url: 'https://turbowarp.org/'
};

View File

@@ -0,0 +1,70 @@
/**
* @fileoverview
* Object representing a Scratch variable.
*/
const uid = require('../util/uid');
const xmlEscape = require('../util/xml-escape');
class Variable {
/**
* @param {string} id Id of the variable.
* @param {string} name Name 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, type, isCloud) {
this.id = id || uid();
this.name = name;
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 (isLocal) {
isLocal = (isLocal === true);
return `<variable type="${this.type}" id="${this.id}" islocal="${isLocal
}" iscloud="${this.isCloud}">${xmlEscape(this.name)}</variable>`;
}
/**
* Type representation for scalar variables.
* This is currently represented as ''
* for compatibility with blockly.
* @const {string}
*/
static get SCALAR_TYPE () {
return ''; // used by compiler
}
/**
* Type representation for list variables.
* @const {string}
*/
static get LIST_TYPE () {
return 'list'; // used by compiler
}
/**
* Type representation for list variables.
* @const {string}
*/
static get BROADCAST_MESSAGE_TYPE () {
return 'broadcast_msg';
}
}
module.exports = Variable;

View File

@@ -0,0 +1,57 @@
/**
* Block argument types
* @enum {string}
*/
const ArgumentType = {
/**
* Numeric value with angle picker
*/
ANGLE: 'angle',
/**
* Boolean value with hexagonal placeholder
*/
BOOLEAN: 'Boolean',
/**
* Numeric value with color picker
*/
COLOR: 'color',
/**
* Numeric value with text field
*/
NUMBER: 'number',
/**
* String value with text field
*/
STRING: 'string',
/**
* String value with matrix field
*/
MATRIX: 'matrix',
/**
* MIDI note number with note picker (piano) field
*/
NOTE: 'note',
/**
* Inline image on block (as part of the label)
*/
IMAGE: 'image',
/**
* Name of costume in the current target
*/
COSTUME: 'costume',
/**
* Name of sound in the current target
*/
SOUND: 'sound'
};
module.exports = ArgumentType;

View File

@@ -0,0 +1,60 @@
/**
* Types of block
* @enum {string}
*/
const BlockType = {
/**
* Boolean reporter with hexagonal shape
*/
BOOLEAN: 'Boolean',
/**
* A button (not an actual block) for some special action, like making a variable
*/
BUTTON: 'button',
/**
* A text label (not an actual block) for adding comments or labling blocks
*/
LABEL: 'label',
/**
* Command block
*/
COMMAND: 'command',
/**
* Specialized command block which may or may not run a child branch
* The thread continues with the next block whether or not a child branch ran.
*/
CONDITIONAL: 'conditional',
/**
* Specialized hat block with no implementation function
* This stack only runs if the corresponding event is emitted by other code.
*/
EVENT: 'event',
/**
* Hat block which conditionally starts a block stack
*/
HAT: 'hat',
/**
* Specialized command block which may or may not run a child branch
* If a child branch runs, the thread evaluates the loop block again.
*/
LOOP: 'loop',
/**
* General reporter with numeric or string value
*/
REPORTER: 'reporter',
/**
* Arbitrary scratch-blocks XML.
*/
XML: 'xml'
};
module.exports = BlockType;

View File

@@ -0,0 +1,18 @@
/**
* @typedef {object} MessageDescriptor
* @property {string} id - the translator-friendly unique ID of this message.
* @property {string} default - the message text in the default language (English).
* @property {string} [description] - a description of this message to help translators understand the context.
*/
/**
* This is a hook for extracting messages from extension source files.
* This function simply returns the message descriptor map object that's passed in.
* @param {object.<MessageDescriptor>} messages - the messages to be defined
* @return {object.<MessageDescriptor>} - the input, unprocessed
*/
const defineMessages = function (messages) {
return messages;
};
module.exports = defineMessages;

View File

@@ -0,0 +1,609 @@
const dispatch = require('../dispatch/central-dispatch');
const log = require('../util/log');
const maybeFormatMessage = require('../util/maybe-format-message');
const BlockType = require('./block-type');
const SecurityManager = require('./tw-security-manager');
// 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 defaultBuiltinExtensions = {
// This is an example that isn't loaded with the other core blocks,
// but serves as a reference for loading core blocks as extensions.
coreExample: () => require('../blocks/scratch3_core_example'),
// These are the non-core built-in extensions.
pen: () => require('../extensions/scratch3_pen'),
wedo2: () => require('../extensions/scratch3_wedo2'),
music: () => require('../extensions/scratch3_music'),
microbit: () => require('../extensions/scratch3_microbit'),
text2speech: () => require('../extensions/scratch3_text2speech'),
translate: () => require('../extensions/scratch3_translate'),
videoSensing: () => require('../extensions/scratch3_video_sensing'),
ev3: () => require('../extensions/scratch3_ev3'),
makeymakey: () => require('../extensions/scratch3_makeymakey'),
boost: () => require('../extensions/scratch3_boost'),
gdxfor: () => require('../extensions/scratch3_gdx_for'),
// tw: core extension
tw: () => require('../extensions/tw')
};
/**
* @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} ConvertedBlockInfo - Raw extension block data paired with processed data ready for scratch-blocks
* @property {ExtensionBlockMetadata} info - the raw block info
* @property {object} json - the scratch-blocks JSON definition for this block
* @property {string} xml - the scratch-blocks XML definition for this block
*/
/**
* @typedef {object} CategoryInfo - Information about a block category
* @property {string} id - the unique ID of this category
* @property {string} name - the human-readable name of this category
* @property {string|undefined} blockIconURI - optional URI for the block icon image
* @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.<ConvertedBlockInfo>} blocks - the blocks, separators, etc. in this category
* @property {Array.<object>} menus - the menus provided by 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
*/
const createExtensionService = extensionManager => {
const service = {};
service.registerExtensionServiceSync = extensionManager.registerExtensionServiceSync.bind(extensionManager);
service.allocateWorker = extensionManager.allocateWorker.bind(extensionManager);
service.onWorkerInit = extensionManager.onWorkerInit.bind(extensionManager);
service.registerExtensionService = extensionManager.registerExtensionService.bind(extensionManager);
return service;
};
class ExtensionManager {
constructor (vm) {
/**
* 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 = [];
/**
* Map of worker ID to the URL where it was loaded from.
* @type {Array<string>}
*/
this.workerURLs = [];
/**
* Map of loaded extension URLs/IDs (equivalent for built-in extensions) to service name.
* @type {Map.<string,string>}
* @private
*/
this._loadedExtensions = new Map();
/**
* Responsible for determining security policies related to custom extensions.
*/
this.securityManager = new SecurityManager();
/**
* @type {VirtualMachine}
*/
this.vm = vm;
/**
* 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 = vm.runtime;
this.loadingAsyncExtensions = 0;
this.asyncExtensionsLoadedCallbacks = [];
this.builtinExtensions = Object.assign({}, defaultBuiltinExtensions);
dispatch.setService('extensions', createExtensionService(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);
}
/**
* Determine whether an extension with a given ID is built in to the VM, such as pen.
* Note that "core extensions" like motion will return false here.
* @param {string} extensionId
* @returns {boolean}
*/
isBuiltinExtension (extensionId) {
return Object.prototype.hasOwnProperty.call(this.builtinExtensions, extensionId);
}
/**
* Synchronously load an internal extension (core or non-core) by ID. This call will
* fail if the provided id is not does not match an internal extension.
* @param {string} extensionId - the ID of an internal extension
*/
loadExtensionIdSync (extensionId) {
if (!this.isBuiltinExtension(extensionId)) {
log.warn(`Could not find extension ${extensionId} in the built in extensions.`);
return;
}
/** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */
if (this.isExtensionLoaded(extensionId)) {
const message = `Rejecting attempt to load a second extension with ID ${extensionId}`;
log.warn(message);
return;
}
const extension = this.builtinExtensions[extensionId]();
const extensionInstance = new extension(this.runtime);
const serviceName = this._registerInternalExtension(extensionInstance);
this._loadedExtensions.set(extensionId, serviceName);
this.runtime.compilerRegisterExtension(extensionId, extensionInstance);
}
addBuiltinExtension (extensionId, extensionClass) {
this.builtinExtensions[extensionId] = () => extensionClass;
}
_isValidExtensionURL (extensionURL) {
try {
const parsedURL = new URL(extensionURL);
return (
parsedURL.protocol === 'https:' ||
parsedURL.protocol === 'http:' ||
parsedURL.protocol === 'data:' ||
parsedURL.protocol === 'file:'
);
} catch (e) {
return false;
}
}
/**
* 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
*/
async loadExtensionURL (extensionURL) {
if (this.isBuiltinExtension(extensionURL)) {
this.loadExtensionIdSync(extensionURL);
return;
}
if (this.isExtensionURLLoaded(extensionURL)) {
// Extension is already loaded.
return;
}
if (!this._isValidExtensionURL(extensionURL)) {
throw new Error(`Invalid extension URL: ${extensionURL}`);
}
this.runtime.setExternalCommunicationMethod('customExtensions', true);
this.loadingAsyncExtensions++;
const sandboxMode = await this.securityManager.getSandboxMode(extensionURL);
const rewritten = await this.securityManager.rewriteExtensionURL(extensionURL);
if (sandboxMode === 'unsandboxed') {
const {load} = require('./tw-unsandboxed-extension-runner');
const extensionObjects = await load(rewritten, this.vm)
.catch(error => this._failedLoadingExtensionScript(error));
const fakeWorkerId = this.nextExtensionWorker++;
this.workerURLs[fakeWorkerId] = extensionURL;
for (const extensionObject of extensionObjects) {
const extensionInfo = extensionObject.getInfo();
const serviceName = `unsandboxed.${fakeWorkerId}.${extensionInfo.id}`;
dispatch.setServiceSync(serviceName, extensionObject);
dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName);
this._loadedExtensions.set(extensionInfo.id, serviceName);
}
this._finishedLoadingExtensionScript();
return;
}
/* eslint-disable max-len */
let ExtensionWorker;
if (sandboxMode === 'worker') {
ExtensionWorker = require('worker-loader?name=js/extension-worker/extension-worker.[hash].js!./extension-worker');
} else if (sandboxMode === 'iframe') {
ExtensionWorker = (await import(/* webpackChunkName: "iframe-extension-worker" */ './tw-iframe-extension-worker')).default;
} else {
throw new Error(`Invalid sandbox mode: ${sandboxMode}`);
}
/* eslint-enable max-len */
return new Promise((resolve, reject) => {
this.pendingExtensions.push({extensionURL: rewritten, resolve, reject});
dispatch.addWorker(new ExtensionWorker());
}).catch(error => this._failedLoadingExtensionScript(error));
}
/**
* Wait until all async extensions have loaded
* @returns {Promise} resolved when all async extensions have loaded
*/
allAsyncExtensionsLoaded () {
if (this.loadingAsyncExtensions === 0) {
return;
}
return new Promise((resolve, reject) => {
this.asyncExtensionsLoadedCallbacks.push({
resolve,
reject
});
});
}
/**
* Regenerate blockinfo for any loaded extensions
* @returns {Promise} resolved once all the extensions have been reinitialized
*/
refreshBlocks () {
const allPromises = Array.from(this._loadedExtensions.values()).map(serviceName =>
dispatch.call(serviceName, 'getInfo')
.then(info => {
info = this._prepareExtensionInfo(serviceName, info);
dispatch.call('runtime', '_refreshExtensionPrimitives', info);
})
.catch(e => {
log.error('Failed to refresh built-in extension primitives', e);
})
);
return Promise.all(allPromises);
}
allocateWorker () {
const id = this.nextExtensionWorker++;
const workerInfo = this.pendingExtensions.shift();
this.pendingWorkers[id] = workerInfo;
this.workerURLs[id] = workerInfo.extensionURL;
return [id, workerInfo.extensionURL];
}
/**
* Synchronously collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension.
*/
registerExtensionServiceSync (serviceName) {
const info = dispatch.callSync(serviceName, 'getInfo');
this._registerExtensionInfo(serviceName, info);
}
/**
* Collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension.
*/
registerExtensionService (serviceName) {
dispatch.call(serviceName, 'getInfo').then(info => {
this._loadedExtensions.set(info.id, serviceName);
this._registerExtensionInfo(serviceName, info);
this._finishedLoadingExtensionScript();
});
}
_finishedLoadingExtensionScript () {
this.loadingAsyncExtensions--;
if (this.loadingAsyncExtensions === 0) {
this.asyncExtensionsLoadedCallbacks.forEach(i => i.resolve());
this.asyncExtensionsLoadedCallbacks = [];
}
}
_failedLoadingExtensionScript (error) {
// Don't set the current extension counter to 0, otherwise it will go negative if another
// extension finishes or fails to load.
this.loadingAsyncExtensions--;
this.asyncExtensionsLoadedCallbacks.forEach(i => i.reject(error));
this.asyncExtensionsLoadedCallbacks = [];
// Re-throw error so the promise still rejects.
throw error;
}
/**
* 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();
}
}
/**
* Register an internal (non-Worker) extension object
* @param {object} extensionObject - the extension object to register
* @returns {string} The name of the registered extension service
*/
_registerInternalExtension (extensionObject) {
const extensionInfo = extensionObject.getInfo();
const fakeWorkerId = this.nextExtensionWorker++;
const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`;
dispatch.setServiceSync(serviceName, extensionObject);
dispatch.callSync('extensions', 'registerExtensionServiceSync', 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}:`, e);
});
}
/**
* 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);
if (!/^[a-z0-9]+$/i.test(extensionInfo.id)) {
throw new Error('Invalid extension id');
}
extensionInfo.name = extensionInfo.name || extensionInfo.id;
extensionInfo.blocks = extensionInfo.blocks || [];
extensionInfo.targetTypes = extensionInfo.targetTypes || [];
extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => {
try {
let result;
switch (blockInfo) {
case '---': // separator
result = '---';
break;
default: // an ExtensionBlockMetadata object
result = this._prepareBlockInfo(serviceName, blockInfo);
break;
}
results.push(result);
} catch (e) {
// TODO: more meaningful error reporting
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`);
}
return results;
}, []);
extensionInfo.menus = extensionInfo.menus || {};
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus);
return extensionInfo;
}
/**
* Prepare extension menus. e.g. setup binding for dynamic menu functions.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {Array.<MenuInfo>} menus - the menu defined by the extension.
* @returns {Array.<MenuInfo>} - a menuInfo object with all preprocessing done.
* @private
*/
_prepareMenuInfo (serviceName, menus) {
const menuNames = Object.getOwnPropertyNames(menus);
for (let i = 0; i < menuNames.length; i++) {
const menuName = menuNames[i];
let menuInfo = menus[menuName];
// If the menu description is in short form (items only) then normalize it to general form: an object with
// its items listed in an `items` property.
if (!menuInfo.items) {
menuInfo = {
items: menuInfo
};
menus[menuName] = menuInfo;
}
// If `items` is a string, it should be the name of a function in the extension object. Calling the
// function should return an array of items to populate the menu when it is opened.
if (typeof menuInfo.items === 'string') {
const menuItemFunctionName = menuInfo.items;
const serviceObject = dispatch.services[serviceName];
// Bind the function here so we can pass a simple item generation function to Scratch Blocks later.
menuInfo.items = this._getExtensionMenuItems.bind(this, serviceObject, menuItemFunctionName);
}
}
return menus;
}
/**
* Fetch the items for a particular extension menu, providing the target ID for context.
* @param {object} extensionObject - the extension object providing the menu.
* @param {string} menuItemFunctionName - the name of the menu function to call.
* @returns {Array} menu items ready for scratch-blocks.
* @private
*/
_getExtensionMenuItems (extensionObject, menuItemFunctionName) {
// Fetch the items appropriate for the target currently being edited. This assumes that menus only
// collect items when opened by the user while editing a particular target.
const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage();
const editingTargetID = editingTarget ? editingTarget.id : null;
const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget);
// TODO: Fix this to use dispatch.call when extensions are running in workers.
const menuFunc = extensionObject[menuItemFunctionName];
const menuItems = menuFunc.call(extensionObject, editingTargetID).map(
item => {
item = maybeFormatMessage(item, extensionMessageContext);
switch (typeof item) {
case 'object':
return [
maybeFormatMessage(item.text, extensionMessageContext),
item.value
];
case 'string':
return [item, item];
default:
return item;
}
});
if (!menuItems || menuItems.length < 1) {
throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`);
}
return menuItems;
}
/**
* Apply defaults for optional block fields.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {ExtensionBlockMetadata} blockInfo - the block info from the extension
* @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields.
* @private
*/
_prepareBlockInfo (serviceName, blockInfo) {
if (blockInfo.blockType === BlockType.XML) {
blockInfo = Object.assign({}, blockInfo);
blockInfo.xml = String(blockInfo.xml) || '';
return blockInfo;
}
blockInfo = Object.assign({}, {
blockType: BlockType.COMMAND,
terminal: false,
blockAllThreads: false,
arguments: {}
}, blockInfo);
blockInfo.text = blockInfo.text || blockInfo.opcode;
switch (blockInfo.blockType) {
case BlockType.EVENT:
if (blockInfo.func) {
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`);
}
break;
case BlockType.BUTTON:
if (blockInfo.opcode) {
log.warn(`Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}`);
}
blockInfo.callFunc = () => {
dispatch.call(serviceName, blockInfo.func);
};
break;
case BlockType.LABEL:
if (blockInfo.opcode) {
log.warn(`Ignoring opcode "${blockInfo.opcode}" for label: ${blockInfo.text}`);
}
break;
default: {
if (!blockInfo.opcode) {
throw new Error('Missing opcode for block');
}
const funcName = blockInfo.func || blockInfo.opcode;
const getBlockInfo = blockInfo.isDynamic ?
args => args && args.mutation && args.mutation.blockInfo :
() => blockInfo;
const callBlockFunc = (() => {
if (dispatch._isRemoteService(serviceName)) {
return (args, util, realBlockInfo) =>
dispatch.call(serviceName, funcName, args, util, realBlockInfo)
.then(result => {
// Scratch is only designed to handle these types.
// If any other value comes in such as undefined, null, an object, etc.
// we'll convert it to a string to avoid undefined behavior.
if (
typeof result === 'number' ||
typeof result === 'string' ||
typeof result === 'boolean'
) {
return result;
}
return `${result}`;
});
}
// avoid promise latency if we can call direct
const serviceObject = dispatch.services[serviceName];
if (!serviceObject[funcName]) {
// The function might show up later as a dynamic property of the service object
log.warn(`Could not find extension block function called ${funcName}`);
}
return (args, util, realBlockInfo) =>
serviceObject[funcName](args, util, realBlockInfo);
})();
blockInfo.func = (args, util) => {
const realBlockInfo = getBlockInfo(args);
// TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed?
return callBlockFunc(args, util, realBlockInfo);
};
break;
}
}
return blockInfo;
}
getExtensionURLs () {
const extensionURLs = {};
for (const [extensionId, serviceName] of this._loadedExtensions.entries()) {
if (Object.prototype.hasOwnProperty.call(this.builtinExtensions, extensionId)) {
continue;
}
// Service names for extension workers are in the format "extension.WORKER_ID.EXTENSION_ID"
const workerId = +serviceName.split('.')[1];
const extensionURL = this.workerURLs[workerId];
if (typeof extensionURL === 'string') {
extensionURLs[extensionId] = extensionURL;
}
}
return extensionURLs;
}
isExtensionURLLoaded (url) {
return Object.values(this.workerURLs).includes(url);
}
}
module.exports = ExtensionManager;

View File

@@ -0,0 +1,64 @@
/**
* @typedef {object} ExtensionMetadata
* All the metadata needed to register an extension.
* @property {string} id - a unique alphanumeric identifier for this extension. No special characters allowed.
* @property {string} [name] - the human-readable name of this extension.
* @property {string} [blockIconURI] - URI for an image to be placed on each block in this extension. Data URI ok.
* @property {string} [menuIconURI] - URI for an image to be placed on this extension's category menu item. Data URI ok.
* @property {string} [docsURI] - link to documentation content for this extension.
* @property {Array.<ExtensionBlockMetadata|string>} blocks - the blocks provided by this extension, plus separators.
* @property {Object.<ExtensionMenuMetadata>} [menus] - map of menu name to metadata for each of this extension's menus.
*/
/**
* @typedef {object} ExtensionBlockMetadata
* All the metadata needed to register an extension block.
* @property {string} opcode - a unique alphanumeric identifier for this block. No special characters allowed.
* @property {string} [func] - the name of the function implementing this block. Can be shared by other blocks/opcodes.
* @property {BlockType} blockType - the type of block (command, reporter, etc.) being described.
* @property {string} text - the text on the block, with [PLACEHOLDERS] for arguments.
* @property {Boolean} [hideFromPalette] - true if this block should not appear in the block palette.
* @property {Boolean} [isTerminal] - true if the block ends a stack - no blocks can be connected after it.
* @property {Boolean} [disableMonitor] - true if this block is a reporter but should not allow a monitor.
* @property {ReporterScope} [reporterScope] - if this block is a reporter, this is the scope/context for its value.
* @property {Boolean} [isEdgeActivated] - sets whether a hat block is edge-activated.
* @property {Boolean} [shouldRestartExistingThreads] - sets whether a hat/event block should restart existing threads.
* @property {int} [branchCount] - for flow control blocks, the number of branches/substacks for this block.
* @property {Object.<ExtensionArgumentMetadata>} [arguments] - map of argument placeholder to metadata about each arg.
*/
/**
* @typedef {object} ExtensionArgumentMetadata
* All the metadata needed to register an argument for an extension block.
* @property {ArgumentType} type - the type of the argument (number, string, etc.)
* @property {*} [defaultValue] - the default value of this argument.
* @property {string} [menu] - the name of the menu to use for this argument, if any.
*/
/**
* @typedef {ExtensionDynamicMenu|ExtensionMenuItems} ExtensionMenuMetadata
* All the metadata needed to register an extension drop-down menu.
*/
/**
* @typedef {string} ExtensionDynamicMenu
* The string name of a function which returns menu items.
* @see {ExtensionMenuItems} - the type of data expected to be returned by the specified function.
*/
/**
* @typedef {Array.<ExtensionMenuItemSimple|ExtensionMenuItemComplex>} ExtensionMenuItems
* Items in an extension menu.
*/
/**
* @typedef {string} ExtensionMenuItemSimple
* A menu item for which the label and value are identical strings.
*/
/**
* @typedef {object} ExtensionMenuItemComplex
* A menu item for which the label and value can differ.
* @property {*} value - the value of the block argument when this menu item is selected.
* @property {string} text - the human-readable label of this menu item in the menu.
*/

View File

@@ -0,0 +1,100 @@
/* eslint-env worker */
const ScratchCommon = require('./tw-extension-api-common');
const createScratchX = require('./tw-scratchx-compatibility-layer');
const dispatch = require('../dispatch/worker-dispatch');
const log = require('../util/log');
const {isWorker} = require('./tw-extension-worker-context');
const createTranslate = require('./tw-l10n');
const translate = createTranslate(null);
const loadScripts = url => {
if (isWorker) {
importScripts(url);
} else {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.onload = () => resolve();
script.onerror = () => {
reject(new Error(`Error in sandboxed script: ${url}. Check the console for more information.`));
};
script.src = url;
document.body.appendChild(script);
});
}
};
class ExtensionWorker {
constructor () {
this.nextExtensionId = 0;
this.initialRegistrations = [];
this.firstRegistrationPromise = new Promise(resolve => {
this.firstRegistrationCallback = resolve;
});
dispatch.waitForConnection.then(() => {
dispatch.call('extensions', 'allocateWorker').then(async x => {
const [id, extension] = x;
this.workerId = id;
try {
await loadScripts(extension);
await this.firstRegistrationPromise;
const initialRegistrations = this.initialRegistrations;
this.initialRegistrations = null;
Promise.all(initialRegistrations).then(() => dispatch.call('extensions', 'onWorkerInit', id));
} catch (e) {
log.error(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.firstRegistrationCallback();
this.initialRegistrations.push(promise);
}
return promise;
}
}
global.Scratch = global.Scratch || {};
Object.assign(global.Scratch, ScratchCommon, {
canFetch: () => Promise.resolve(true),
fetch: (url, options) => fetch(url, options),
canOpenWindow: () => Promise.resolve(false),
openWindow: () => Promise.reject(new Error('Scratch.openWindow not supported in sandboxed extensions')),
canRedirect: () => Promise.resolve(false),
redirect: () => Promise.reject(new Error('Scratch.redirect not supported in sandboxed extensions')),
canRecordAudio: () => Promise.resolve(false),
canRecordVideo: () => Promise.resolve(false),
canReadClipboard: () => Promise.resolve(false),
canNotify: () => Promise.resolve(false),
canGeolocate: () => Promise.resolve(false),
canEmbed: () => Promise.resolve(false),
translate
});
/**
* Expose only specific parts of the worker to extensions.
*/
const extensionWorker = new ExtensionWorker();
global.Scratch.extensions = {
register: extensionWorker.register.bind(extensionWorker)
};
global.ScratchExtensions = createScratchX(global.Scratch);

View File

@@ -0,0 +1,18 @@
/**
* Indicate the scope for a reporter's value.
* @enum {string}
*/
const ReporterScope = {
/**
* This reporter's value is global and does not depend on context.
*/
GLOBAL: 'global',
/**
* This reporter's value is specific to a particular target/sprite.
* Another target may have a different value or may not even have a value.
*/
TARGET: 'target'
};
module.exports = ReporterScope;

View File

@@ -0,0 +1,17 @@
/**
* Default types of Target supported by the VM
* @enum {string}
*/
const TargetType = {
/**
* Rendered target which can move, change costumes, etc.
*/
SPRITE: 'sprite',
/**
* Rendered target which cannot move but can change backdrops
*/
STAGE: 'stage'
};
module.exports = TargetType;

View File

@@ -0,0 +1,15 @@
// If a project uses an extension but does not specify a URL, it will default to
// the URLs given here, if it exists. This is useful for compatibility with other mods.
const defaults = new Map();
// Box2D (`griffpatch`) is not listed here because our extension is not actually
// compatible with the original version due to fields vs inputs.
// Scratch Lab Animated Text - https://lab.scratch.mit.edu/text/
defaults.set('text', 'https://extensions.turbowarp.org/lab/text.js');
// Turboloader's AudioStream
defaults.set('audiostr', 'https://extensions.turbowarp.org/turboloader/audiostream.js');
module.exports = defaults;

View File

@@ -0,0 +1,13 @@
const ArgumentType = require('./argument-type');
const BlockType = require('./block-type');
const TargetType = require('./target-type');
const Cast = require('../util/cast');
const Scratch = {
ArgumentType,
BlockType,
TargetType,
Cast
};
module.exports = Scratch;

View File

@@ -0,0 +1,5 @@
module.exports = {
isWorker: true,
// centralDispatchService is the object to call postMessage() on to send a message to parent.
centralDispatchService: self
};

View File

@@ -0,0 +1,29 @@
const context = require('./tw-extension-worker-context');
const jQuery = require('./tw-jquery-shim');
global.$ = jQuery;
global.jQuery = jQuery;
const id = window.__WRAPPED_IFRAME_ID__;
context.isWorker = false;
context.centralDispatchService = {
postMessage (message, transfer) {
const data = {
vmIframeId: id,
message
};
if (transfer) {
window.parent.postMessage(data, '*', transfer);
} else {
window.parent.postMessage(data, '*');
}
}
};
require('./extension-worker');
window.parent.postMessage({
vmIframeId: id,
ready: true
}, '*');

View File

@@ -0,0 +1,96 @@
const uid = require('../util/uid');
const frameSource = require('./tw-load-script-as-plain-text!./tw-iframe-extension-worker-entry');
const none = "'none'";
const featurePolicy = {
'accelerometer': none,
'ambient-light-sensor': none,
'battery': none,
'camera': none,
'display-capture': none,
'document-domain': none,
'encrypted-media': none,
'fullscreen': none,
'geolocation': none,
'gyroscope': none,
'magnetometer': none,
'microphone': none,
'midi': none,
'payment': none,
'picture-in-picture': none,
'publickey-credentials-get': none,
'speaker-selection': none,
'usb': none,
'vibrate': none,
'vr': none,
'screen-wake-lock': none,
'web-share': none,
'interest-cohort': none
};
const generateAllow = () => Object.entries(featurePolicy)
.map(([name, permission]) => `${name} ${permission}`)
.join('; ');
class IframeExtensionWorker {
constructor () {
this.id = uid();
this.isRemote = true;
this.ready = false;
this.queuedMessages = [];
this.iframe = document.createElement('iframe');
this.iframe.className = 'tw-custom-extension-frame';
this.iframe.dataset.id = this.id;
this.iframe.style.display = 'none';
this.iframe.setAttribute('aria-hidden', 'true');
this.iframe.sandbox = 'allow-scripts';
this.iframe.allow = generateAllow();
document.body.appendChild(this.iframe);
window.addEventListener('message', this._onWindowMessage.bind(this));
const blob = new Blob([
// eslint-disable-next-line max-len
`<!DOCTYPE html><body><script>window.__WRAPPED_IFRAME_ID__=${JSON.stringify(this.id)};${frameSource}</script></body>`
], {
type: 'text/html; charset=utf-8'
});
this.iframe.src = URL.createObjectURL(blob);
}
_onWindowMessage (e) {
if (!e.data || e.data.vmIframeId !== this.id) {
return;
}
if (e.data.ready) {
this.ready = true;
for (const {data, transfer} of this.queuedMessages) {
this.postMessage(data, transfer);
}
this.queuedMessages.length = 0;
}
if (e.data.message) {
this.onmessage({
data: e.data.message
});
}
}
onmessage () {
// Should be overridden
}
postMessage (data, transfer) {
if (this.ready) {
if (transfer) {
this.iframe.contentWindow.postMessage(data, '*', transfer);
} else {
this.iframe.contentWindow.postMessage(data, '*');
}
} else {
this.queuedMessages.push({data, transfer});
}
}
}
module.exports = IframeExtensionWorker;

View File

@@ -0,0 +1,112 @@
/**
* @fileoverview
* Many ScratchX extensions require jQuery to do things like loading scripts and making requests.
* The real jQuery is pretty large and we'd rather not bring in everything, so this file reimplements
* small stubs of a few jQuery methods.
* It's just supposed to be enough to make existing ScratchX extensions work, nothing more.
*/
const log = require('../util/log');
const jQuery = () => {
throw new Error('Not implemented');
};
jQuery.getScript = (src, callback) => {
const script = document.createElement('script');
script.src = src;
if (callback) {
// We don't implement callback arguments.
script.onload = () => callback();
}
document.body.appendChild(script);
};
/**
* @param {Record<string, any>|undefined} obj
* @returns {URLSearchParams}
*/
const objectToQueryString = obj => {
const params = new URLSearchParams();
if (obj) {
for (const key of Object.keys(obj)) {
params.set(key, obj[key]);
}
}
return params;
};
let jsonpCallback = 0;
jQuery.ajax = async (arg1, arg2) => {
let options = {};
if (arg1 && arg2) {
options = arg2;
options.url = arg1;
} else if (arg1) {
options = arg1;
}
const urlParameters = objectToQueryString(options.data);
const getFinalURL = () => {
const query = urlParameters.toString();
let url = options.url;
if (query) {
url += `?${query}`;
}
// Forcibly upgrade all HTTP requests to HTTPS so that they don't error on HTTPS sites
// All the extensions we care about work fine with this
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://');
}
return url;
};
const successCallback = result => {
if (options.success) {
options.success(result);
}
};
const errorCallback = error => {
log.error(error);
if (options.error) {
// The error object we provide here might not match what jQuery provides but it's enough to
// prevent extensions from throwing errors trying to access properties.
options.error(error);
}
};
try {
if (options.dataType === 'jsonp') {
const callbackName = `_jsonp_callback${jsonpCallback++}`;
global[callbackName] = data => {
delete global[callbackName];
successCallback(data);
};
const callbackParameterName = options.jsonp || 'callback';
urlParameters.set(callbackParameterName, callbackName);
jQuery.getScript(getFinalURL());
return;
}
if (options.dataType === 'script') {
jQuery.getScript(getFinalURL(), successCallback);
return;
}
const res = await fetch(getFinalURL(), {
headers: options.headers
});
// dataType defaults to "Intelligent Guess (xml, json, script, or html)"
// It happens that all the ScratchX extensions we care about either set dataType to "json" or
// leave it blank and implicitly request JSON, so this works good enough for now.
successCallback(await res.json());
} catch (e) {
errorCallback(e);
}
};
module.exports = jQuery;

View File

@@ -0,0 +1,61 @@
const formatMessage = require('format-message');
/**
* @param {VM|null} vm
* @returns {object}
*/
const createTranslate = vm => {
const namespace = formatMessage.namespace();
const translate = (message, args) => {
if (message && typeof message === 'object') {
// already in the expected format
} else if (typeof message === 'string') {
message = {
default: message
};
} else {
throw new Error('unsupported data type in translate()');
}
return namespace(message, args);
};
const generateId = defaultMessage => `_${defaultMessage}`;
const getLocale = () => {
if (vm) return vm.getLocale();
if (typeof navigator !== 'undefined') return navigator.language;
return 'en';
};
let storedTranslations = {};
translate.setup = newTranslations => {
if (newTranslations) {
storedTranslations = newTranslations;
}
namespace.setup({
locale: getLocale(),
missingTranslation: 'ignore',
generateId,
translations: storedTranslations
});
};
Object.defineProperty(translate, 'language', {
configurable: true,
enumerable: true,
get: () => getLocale()
});
translate.setup({});
if (vm) {
vm.on('LOCALE_CHANGED', () => {
translate.setup(null);
});
}
return translate;
};
module.exports = createTranslate;

View File

@@ -0,0 +1,20 @@
// Based on https://github.com/webpack-contrib/worker-loader/tree/v2.0.0
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
module.exports.pitch = function (request) {
// Technically this loader does work in other environments, but our use case does not want that.
if (this.target !== 'web') {
return 'throw new Error("Not supported in non-web environment");';
}
this.cacheable(false);
const callback = this.async();
const compiler = this._compilation.createChildCompiler('extension worker', {});
new SingleEntryPlugin(this.context, `!!${request}`, 'extension worker').apply(compiler);
compiler.runAsChild((err, entries, compilation) => {
if (err) return callback(err);
const file = entries[0].files[0];
const source = `module.exports = ${JSON.stringify(compilation.assets[file].source())};`;
return callback(null, source);
});
};

View File

@@ -0,0 +1,227 @@
// ScratchX API Documentation: https://github.com/LLK/scratchx/wiki/
const ArgumentType = require('./argument-type');
const BlockType = require('./block-type');
const {
argumentIndexToId,
generateExtensionId
} = require('./tw-scratchx-utilities');
/**
* @typedef ScratchXDescriptor
* @property {unknown[][]} blocks
* @property {Record<string, unknown[]>} [menus]
* @property {string} [url]
* @property {string} [displayName]
*/
/**
* @typedef ScratchXStatus
* @property {0|1|2} status 0 is red/error, 1 is yellow/not ready, 2 is green/ready
* @property {string} msg
*/
const parseScratchXBlockType = type => {
if (type === '' || type === ' ' || type === 'w') {
return {
type: BlockType.COMMAND,
async: type === 'w'
};
}
if (type === 'r' || type === 'R') {
return {
type: BlockType.REPORTER,
async: type === 'R'
};
}
if (type === 'b') {
return {
type: BlockType.BOOLEAN,
// ScratchX docs don't seem to mention boolean reporters that wait
async: false
};
}
if (type === 'h') {
return {
type: BlockType.HAT,
async: false
};
}
throw new Error(`Unknown ScratchX block type: ${type}`);
};
const isScratchCompatibleValue = v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
/**
* @param {string} argument ScratchX argument with leading % removed.
* @param {unknown} defaultValue Default value, if any
*/
const parseScratchXArgument = (argument, defaultValue) => {
const result = {};
const hasDefaultValue = isScratchCompatibleValue(defaultValue);
// defaultValue is ignored for booleans in Scratch 3
if (hasDefaultValue && argument !== 'b') {
result.defaultValue = defaultValue;
}
if (argument === 's') {
result.type = ArgumentType.STRING;
if (!hasDefaultValue) {
result.defaultValue = '';
}
} else if (argument === 'n') {
result.type = ArgumentType.NUMBER;
if (!hasDefaultValue) {
result.defaultValue = 0;
}
} else if (argument[0] === 'm') {
result.type = ArgumentType.STRING;
const split = argument.split(/\.|:/);
const menuName = split[1];
result.menu = menuName;
} else if (argument === 'b') {
result.type = ArgumentType.BOOLEAN;
} else {
throw new Error(`Unknown ScratchX argument type: ${argument}`);
}
return result;
};
const wrapScratchXFunction = (originalFunction, argumentCount, async) => args => {
// Convert Scratch 3's argument object to an argument list expected by ScratchX
const argumentList = [];
for (let i = 0; i < argumentCount; i++) {
argumentList.push(args[argumentIndexToId(i)]);
}
if (async) {
return new Promise(resolve => {
originalFunction(...argumentList, resolve);
});
}
return originalFunction(...argumentList);
};
/**
* @param {string} name
* @param {ScratchXDescriptor} descriptor
* @param {Record<string, () => unknown>} functions
*/
const convert = (name, descriptor, functions) => {
const extensionId = generateExtensionId(name);
const info = {
id: extensionId,
name: descriptor.displayName || name,
blocks: [],
color1: '#4a4a5e',
color2: '#31323f',
color3: '#191a21'
};
const scratch3Extension = {
getInfo: () => info,
_getStatus: functions._getStatus
};
if (descriptor.url) {
info.docsURI = descriptor.url;
}
for (const blockDescriptor of descriptor.blocks) {
if (blockDescriptor.length === 1) {
// Separator
info.blocks.push('---');
continue;
}
const scratchXBlockType = blockDescriptor[0];
const blockText = blockDescriptor[1];
const functionName = blockDescriptor[2];
const defaultArgumentValues = blockDescriptor.slice(3);
let scratchText = '';
const argumentInfo = [];
const blockTextParts = blockText.split(/%([\w.:]+)/g);
for (let i = 0; i < blockTextParts.length; i++) {
const part = blockTextParts[i];
const isArgument = i % 2 === 1;
if (isArgument) {
parseScratchXArgument(part);
const argumentIndex = Math.floor(i / 2).toString();
const argumentDefaultValue = defaultArgumentValues[argumentIndex];
const argumentId = argumentIndexToId(argumentIndex);
argumentInfo[argumentId] = parseScratchXArgument(part, argumentDefaultValue);
scratchText += `[${argumentId}]`;
} else {
scratchText += part;
}
}
const scratch3BlockType = parseScratchXBlockType(scratchXBlockType);
const blockInfo = {
opcode: functionName,
blockType: scratch3BlockType.type,
text: scratchText,
arguments: argumentInfo
};
info.blocks.push(blockInfo);
const originalFunction = functions[functionName];
const argumentCount = argumentInfo.length;
scratch3Extension[functionName] = wrapScratchXFunction(
originalFunction,
argumentCount,
scratch3BlockType.async
);
}
const menus = descriptor.menus;
if (menus) {
const scratch3Menus = {};
for (const menuName of Object.keys(menus) || {}) {
const menuItems = menus[menuName];
const menuInfo = {
items: menuItems
};
scratch3Menus[menuName] = menuInfo;
}
info.menus = scratch3Menus;
}
return scratch3Extension;
};
const extensionNameToExtension = new Map();
/**
* @param {*} Scratch Scratch 3.0 extension API object
* @returns {*} ScratchX-compatible API object
*/
const createScratchX = Scratch => {
const register = (name, descriptor, functions) => {
const scratch3Extension = convert(name, descriptor, functions);
extensionNameToExtension.set(name, scratch3Extension);
Scratch.extensions.register(scratch3Extension);
};
/**
* @param {string} extensionName
* @returns {ScratchXStatus}
*/
const getStatus = extensionName => {
const extension = extensionNameToExtension.get(extensionName);
if (extension) {
return extension._getStatus();
}
return {
status: 0,
msg: 'does not exist'
};
};
return {
register,
getStatus
};
};
module.exports = createScratchX;

View File

@@ -0,0 +1,25 @@
/**
* @fileoverview
* General ScratchX-related utilities used in multiple places.
* Changing these functions may break projects.
*/
/**
* @param {string} scratchXName
* @returns {string}
*/
const generateExtensionId = scratchXName => {
const sanitizedName = scratchXName.replace(/[^a-z0-9]/gi, '').toLowerCase();
return `sbx${sanitizedName}`;
};
/**
* @param {number} i 0-indexed index of argument in list
* @returns {string} Scratch 3 argument name
*/
const argumentIndexToId = i => i.toString();
module.exports = {
generateExtensionId,
argumentIndexToId
};

View File

@@ -0,0 +1,157 @@
/* eslint-disable no-unused-vars */
/**
* Responsible for determining various policies related to custom extension security.
* The default implementation prevents automatic extension loading, but grants any
* loaded extensions the maximum possible capabilities so as to retain compatibility
* with a vanilla scratch-vm. You may override properties of an instance of this class
* to customize the security policies as you see fit, for example:
* ```js
* vm.securityManager.getSandboxMode = (url) => {
* if (url.startsWith("https://example.com/")) {
* return "unsandboxed";
* }
* return "iframe";
* };
* vm.securityManager.canAutomaticallyLoadExtension = (url) => {
* return confirm("Automatically load extension: " + url);
* };
* vm.securityManager.canFetch = (url) => {
* return url.startsWith('https://turbowarp.org/');
* };
* vm.securityManager.canOpenWindow = (url) => {
* return url.startsWith('https://turbowarp.org/');
* };
* vm.securityManager.canRedirect = (url) => {
* return url.startsWith('https://turbowarp.org/');
* };
* ```
*/
class SecurityManager {
/**
* Determine the typeof sandbox to use for a certain custom extension.
* @param {string} extensionURL The URL of the custom extension.
* @returns {'worker'|'iframe'|'unsandboxed'|Promise<'worker'|'iframe'|'unsandboxed'>}
*/
getSandboxMode (extensionURL) {
// Default to worker for Scratch compatibility
return Promise.resolve('worker');
}
/**
* Determine whether a custom extension that was stored inside a project may be
* loaded. You could, for example, ask the user to confirm loading an extension
* before resolving.
* @param {string} extensionURL The URL of the custom extension.
* @returns {Promise<boolean>|boolean}
*/
canLoadExtensionFromProject (extensionURL) {
// Default to false for security
return Promise.resolve(false);
}
/**
* Allows last-minute changing the real URL of the extension that gets loaded.
* @param {*} extensionURL The URL requested to be loaded.
* @returns {Promise<string>|string} The URL to actually load.
*/
rewriteExtensionURL (extensionURL) {
return Promise.resolve(extensionURL);
}
/**
* Determine whether an extension is allowed to fetch a remote resource URL.
* This only applies to unsandboxed extensions that use the appropriate Scratch.* APIs.
* Sandboxed extensions ignore this entirely as there is no way to force them to use our APIs.
* data: and blob: URLs are always allowed (this method is never called).
* @param {string} resourceURL
* @returns {Promise<boolean>|boolean}
*/
canFetch (resourceURL) {
// By default, allow any requests.
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to open a new window or tab to a given URL.
* This only applies to unsandboxed extensions. Sandboxed extensions are unable to open windows.
* javascript: URLs are always rejected (this method is never called).
* @param {string} websiteURL
* @returns {Promise<boolean>|boolean}
*/
canOpenWindow (websiteURL) {
// By default, allow all.
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to redirect the current tab to a given URL.
* This only applies to unsandboxed extensions. Sandboxed extensions are unable to redirect the parent
* window, but are free to redirect their own sandboxed window.
* javascript: URLs are always rejected (this method is never called).
* @param {string} websiteURL
* @returns {Promise<boolean>|boolean}
*/
canRedirect (websiteURL) {
// By default, allow all.
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to record audio from the user's microphone.
* This could include raw audio data or a transcriptions.
* Note that, even if this returns true, success is not guaranteed.
* @returns {Promise<boolean>|boolean}
*/
canRecordAudio () {
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to record video from the user's camera.
* Note that, even if this returns true, success is not guaranteed.
* @returns {Promise<boolean>|boolean}
*/
canRecordVideo () {
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to read values from the user's clipboard
* without user interaction.
* Note that, even if this returns true, success is not guaranteed.
* @returns {Promise<boolean>|boolean}
*/
canReadClipboard () {
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to show notifications.
* Note that, even if this returns true, success is not guaranteed.
* @returns {Promise<boolean>|boolean}
*/
canNotify () {
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to find the user's precise location using GPS
* and other techniques. Note that, even if this returns true, success is not guaranteed.
* @returns {Promise<boolean>|boolean}
*/
canGeolocate () {
return Promise.resolve(true);
}
/**
* Determine whether an extension is allowed to embed content from a given URL.
* @param {string} documentURL The URL of the embed.
* @returns {Promise<boolean>|boolean}
*/
canEmbed (documentURL) {
return Promise.resolve(true);
}
}
module.exports = SecurityManager;

View File

@@ -0,0 +1,177 @@
const ScratchCommon = require('./tw-extension-api-common');
const createScratchX = require('./tw-scratchx-compatibility-layer');
const AsyncLimiter = require('../util/async-limiter');
const createTranslate = require('./tw-l10n');
const staticFetch = require('../util/tw-static-fetch');
/* eslint-disable require-await */
/**
* Parse a URL object or return null.
* @param {string} url
* @returns {URL|null}
*/
const parseURL = url => {
try {
return new URL(url, location.href);
} catch (e) {
return null;
}
};
/**
* Sets up the global.Scratch API for an unsandboxed extension.
* @param {VirtualMachine} vm
* @returns {Promise<object[]>} Resolves with a list of extension objects when Scratch.extensions.register is called.
*/
const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => {
const extensionObjects = [];
const register = extensionObject => {
extensionObjects.push(extensionObject);
resolve(extensionObjects);
};
// Create a new copy of global.Scratch for each extension
const Scratch = Object.assign({}, global.Scratch || {}, ScratchCommon);
Scratch.extensions = {
unsandboxed: true,
register
};
Scratch.vm = vm;
Scratch.renderer = vm.runtime.renderer;
Scratch.canFetch = async url => {
const parsed = parseURL(url);
if (!parsed) {
return false;
}
// Always allow protocols that don't involve a remote request.
if (parsed.protocol === 'blob:' || parsed.protocol === 'data:') {
return true;
}
return vm.securityManager.canFetch(parsed.href);
};
Scratch.canOpenWindow = async url => {
const parsed = parseURL(url);
if (!parsed) {
return false;
}
// Always reject protocols that would allow code execution.
// eslint-disable-next-line no-script-url
if (parsed.protocol === 'javascript:') {
return false;
}
return vm.securityManager.canOpenWindow(parsed.href);
};
Scratch.canRedirect = async url => {
const parsed = parseURL(url);
if (!parsed) {
return false;
}
// Always reject protocols that would allow code execution.
// eslint-disable-next-line no-script-url
if (parsed.protocol === 'javascript:') {
return false;
}
return vm.securityManager.canRedirect(parsed.href);
};
Scratch.canRecordAudio = async () => vm.securityManager.canRecordAudio();
Scratch.canRecordVideo = async () => vm.securityManager.canRecordVideo();
Scratch.canReadClipboard = async () => vm.securityManager.canReadClipboard();
Scratch.canNotify = async () => vm.securityManager.canNotify();
Scratch.canGeolocate = async () => vm.securityManager.canGeolocate();
Scratch.canEmbed = async url => {
const parsed = parseURL(url);
if (!parsed) {
return false;
}
return vm.securityManager.canEmbed(parsed.href);
};
Scratch.fetch = async (url, options) => {
const actualURL = url instanceof Request ? url.url : url;
const staticFetchResult = staticFetch(url);
if (staticFetchResult) {
return staticFetchResult;
}
if (!await Scratch.canFetch(actualURL)) {
throw new Error(`Permission to fetch ${actualURL} rejected.`);
}
return fetch(url, options);
};
Scratch.openWindow = async (url, features) => {
if (!await Scratch.canOpenWindow(url)) {
throw new Error(`Permission to open tab ${url} rejected.`);
}
// Use noreferrer to prevent new tab from accessing `window.opener`
const baseFeatures = 'noreferrer';
features = features ? `${baseFeatures},${features}` : baseFeatures;
return window.open(url, '_blank', features);
};
Scratch.redirect = async url => {
if (!await Scratch.canRedirect(url)) {
throw new Error(`Permission to redirect to ${url} rejected.`);
}
location.href = url;
};
Scratch.translate = createTranslate(vm);
global.Scratch = Scratch;
global.ScratchExtensions = createScratchX(Scratch);
vm.emit('CREATE_UNSANDBOXED_EXTENSION_API', Scratch);
});
/**
* Disable the existing global.Scratch unsandboxed extension APIs.
* This helps debug poorly designed extensions.
*/
const teardownUnsandboxedExtensionAPI = () => {
// We can assume global.Scratch already exists.
global.Scratch.extensions.register = () => {
throw new Error('Too late to register new extensions.');
};
};
/**
* Load an unsandboxed extension from an arbitrary URL. This is dangerous.
* @param {string} extensionURL
* @param {Virtualmachine} vm
* @returns {Promise<object[]>} Resolves with a list of extension objects if the extension was loaded successfully.
*/
const loadUnsandboxedExtension = (extensionURL, vm) => new Promise((resolve, reject) => {
setupUnsandboxedExtensionAPI(vm).then(resolve);
const script = document.createElement('script');
script.onerror = () => {
reject(new Error(`Error in unsandboxed script ${extensionURL}. Check the console for more information.`));
};
script.src = extensionURL;
document.body.appendChild(script);
}).then(objects => {
teardownUnsandboxedExtensionAPI();
return objects;
});
// Because loading unsandboxed extensions requires messing with global state (global.Scratch),
// only let one extension load at a time.
const limiter = new AsyncLimiter(loadUnsandboxedExtension, 1);
const load = (extensionURL, vm) => limiter.do(extensionURL, vm);
module.exports = {
setupUnsandboxedExtensionAPI,
load
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,981 @@
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const log = require('../../util/log');
const formatMessage = require('format-message');
const MathUtil = require('../../util/math-util');
const BLE = require('../../io/ble');
const godirect = require('@vernier/godirect/dist/godirect.min.umd.js');
const ScratchLinkDeviceAdapter = require('./scratch-link-device-adapter');
/**
* Icon png 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/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAABGdBTUEAALGPC/xhBQAACCNJREFUeAHtnGtsFFUUgM+dfXbbbbcWaKHSFgrlkWgkJCb6A4kmJfiHIBYBpcFfRg1GEkmEVAvhFYw/TExMxGoICAECiZEIIUQCiiT4gh+KILRQCi2ENIV2t/ue6zl3u2Upu4XuzO4csCe587iPmXO/OWfunTszV4ABWfflQU+0p+9bTcLzEmS5gUPlvagAcVMXcMpnK1u+evW8QLYKaNkWpHKxnt6dQsqFjxo80p10Jt1vx7t30n62Ys+2IJUTUpDlqUNomgYutwsjhZFD5r6slBAOhUHX9YTe6D1GTmrIAhFeBZ2c4JFCpBiggmwlBR7pTGLUewxZYBIUWV7yqgb7g8lotuukt5ihqyELHCSEbusk931ExMxbjSkWSNxEyr3vysxZLFHWnDuT0CtFV6OKmmOBRrV4hMubZoGmMZA6lHTfgsLeHnBEIiCxUY86XRDw+sBfOgZ0m820U5lxIFYAncF+GNvVDo5QaLBu1ClyYTyF4tvd8lZltQgXFA6mW73BxoVt0ShUXG2VCp4QQdDEFqez4Bm7p7gaO0of422r3x4Ji/KrbdIexu4SE2FjgWO6OkCLx6gt6gxOiNV92tiY+ni1Ye1nu7dpQfk35ikru9EBN6unsEDIwgLJPQv8dwCfT3WPt+iFIfAUqM3vL7vpjmuz0KX1gkAfOMN33dxKkjwA9vsTDIS8uubdBZcyAWlqWtohQbRSuru/L1O2vMazAGiLxRKVFqDgDEdAaHCN0kU8Ply2vKWxABhzJZ5ipC6qHlRzfJxVz99S49GdYQEw7PYkuAmokZJ6fumlQUqiNpVSQ56i9JnyHMsCYMRdADGHk0ZyHM1b976XicH0rXtWYR57FPNSGQ7CAiCBCJQ8oXhI0FdmBiPfVnl9ZZmz5DmFDcA+HwIUOEYMcjL2+e57PbBp04HxONI4ifIEKC8TYQMwhs+7IU+hwBFOYQvB5qF8grbwJnRfQXnIhbkIG4AExF+ScE00w0X3AZLwisrDyH1JH1YAA8UlIG029FRZsu6TPfVJiIltWYIjMTLgLUlGs1izeRYmGtS383t9wnu7G2J6fH/Tln2LNUdExGLxvZSOQ1qCS/+P9CFhBZAUuj12PHgCvRJHZ7w4EnhYjya6hXGHQ2Jaxj4ilbVC2AFEUNBVXSdKb3WC29+rmISKiqFn7ARBadyEHUACFHM64VZlDTdWafVh1Yik1ZB5JEsLJGaVtosw37ld4TscWQHX4+oRWO1zWrAEWCR6oMnTCEXijmI1234MVvsPgV+WcmKndGHpwlNtZwbhkZYEkuI4CkuAXfpk0HGAPym0TXEchaUL39Br4JvQeljk+lwxOxBeCRQ3UrFHI+AMBsEV6gcnhlwIS4BU0RORV1V42EqnwnLgSyo3AsM3eA9bPOt8bAEOV6NUWGRZ9FYvHSx6R0pfYgkMmk2DCH1+Z7KwB5gKazjLGgpLgUOAuRZWALnDSncxLAOYCmskbqjhe02h5d6y0sFKF5cXgI8LrLwB9PTeGew6POwNnptlpYOVLi4nFjjuWts957rnBk8tomoZ+bjhPcqOcCcnAG34EaTqOjxmsNKxzQnAkX5wronsOry6zIn66ThljLNcg+W1a2Gi55+MCg6XcKl3NuxrbxouS87TLAcY1V0QV5+8jLyuEekeeSGTS1gOcM/lZpOrlN/DsRzOyi8CY2fLuwUum/wR1BT+ZUzrDKUv9D4LB9rXZEjNTfRjZYFS5r86ebfA3W0bcmMKFh01/5fMoorm6rSjAA2SNc2F8dvmQVWCgdy8fxg8gcEN0pWez80QUyyQFAqn/N9mhmK5PAYN7adecCPnMsUCCZ7U8ari4IGb87wJeKFDA/MlmHXBDVkgTR1CV4/gaThKzBoeKYpuSzqSrqSzEiFuJDayWxqyQJp3RUhYSKfWUSEz5iDIrhrZl8I5b37JvrTBT3wdpd43cOqT/WiJhq6ikQpkW5a8BxuS/X219uXZHoPKmdMUGdEgpWzTll3Kr95Z8VJK7N3NL7b/qHY2rnmdjd6G7oF3q/b/3RoFaPDajwIcBWiQgMHioxZoEKChfqDBc2csnmxtM2ZglMDKArFvduhBbLDv9sOD8oymA0xBCHVtl6+c7ey6Ibdt+3ox7WOoxMCmD4i68PrZkBQaEDUe1tnVqSyyfl79+vr6evz1C2jKogkYWEEc0JnViiZRqKuoqJiZtEJcn0GIsykewzhW2jJVZjzBamxsfK79ase/5MoXL106TnEDwfq36qgIF6HGjKyqFsNkDGMwUNxEDEmIHQTxyNGjH1AchvumBcC4vAuXVpiA+TDYMFDXiiZFoN+SrmMI7tixo/v3337diNtQUzNpPq1RChIra5ccAFKDUEwYLra2fnXu3PmtA0gojqbaVUNl23ft+pPiPW73U7RGYdGH5QCQYCg93C73075S34I5c+ZQa0s/B1Njou51tVVVatJAXcrED3Q4EI5plgsHgAQiSiRCoRD9ECeam9fPo32UJzFQYwJLlix9mdZ9fb1naY2iyiQ2rVtyAEi199Pi5M8/tdB62vRpzceOH3+toaHBh61w2clTp96sqq5ehUnxw0eO7KA8KKpMYtO6JZcOKTUeNRhsp0+ffmtilYI1VLf4+Qvn1784d+5ezEfW144hMR05blglpDgHSbqxt6Wl5Y8ZM6afKq8oL7LZHd54PH7H7w+cOPj9dx8uXbLk+ICynbhm4cJDr7LVMKmhoP5dphaWoFGrHMTAQrgBJCjkFdQHpPntqCUmiWCge14PBsvdFnUYlP8AMAKfKIKmYukAAAAASUVORK5CYII=';
/**
* Icon png to be displayed in the blocks category menu, encoded as a data URI.
* @type {string}
*/
// eslint-disable-next-line max-len
const menuIconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAA9dJREFUWAnNmE2IFEcUgF/9dE/v7LoaM9kkK4JBRA0EFBIPRm85hBAvEXHXwyo5eFE87GFcReMkObgJiQnkkJzEg9n8HIJixKNe1IMKihgiCbviwV11V3d0d3pmuqsqr5ppcEnb3TNVggVFVVe9eu+r97qqq4tASqp8/fsboQgmU0TMugi571K29bPy9ovPU8Sf16HbpQj3EkYFBcJcr5Am2nZfs94AIWVfqMQeHNwhICUBZ4ypUIA/X2sbIm2AW8AJK0lkEP6TJpfqwXgg4QxmF/fB7Gtvxk1G5ZKHU1CqTgPJoSUXYJYeohSUJu+qrqdVUGh2/pVX4VFffx77WaqBZkrkEFj271+qWH0sXcU3FBzyQe/Mg7B//LbKMTRTxNiDbsMHHjTJlyM7HEJIBHXs2KXFj+oTNSdoQOCYLS5jD9IwBMm5H8NplwwPb/QV4yEIcycaAza9IuA76B38fuz1OF5RXUkmHCdu6rg0BpSMgV/sAe7DdzGFrvvdi0D3mSZjQA0wt7REQsY+iWF0XbfFzyal8SLRxuteD+Du4h4Z/flbqaBHibAQtZmQtcZaAZSMwtTylaR/4vaw1ju5YhWG10pwwAqghmp2FeHO2+t11WqyM80W0m7vAOhsM1kD7CGz8L57Jsq6bitZC/GcWgLf1H6KuHT92cTDAFy/BgXMXm0OCpgV50Bo9kK3BqiBboabQMMU/WoL5im4jToeq/AIgXsiRx5KKCjcwPEsiAv/BQMu9EwyDHXd/3kqCOSzDk6t5/YglQKKeJwq+PNRmJI8kwSTaj1HZy5AhSHqnXkIvU9mMUwEw4Q5wTM57LUtkg8QPw/cdcBJ+PhvKJ0Gj80nGq6JXrg6/XFiX97GXIBpyqTieKpKViOl+WEhWXMaUavvvdIZ8Giy5+Lh3bwKm/t+Be3JazMfxc1tldY26rastiHcsQevTG9pw0znovkAcRWHzSDKnZtaOJLSfMFLB5RqtRBS4LbCurqLCy0YPkU3C0IIPEimMqR2ei7ZX2+KQdRi/WahNT/GmfOD4Vyzhx/66pcjp85dUvcmp6J8+txldXh07PPskdkS+V6EbD0vTOKlB0x9B/O6BS8ULly9PgE6x4kDPR/XX5pyYKj8xcCucsUmkNUQE0JvKKm2VioVK5HRE7UKOHbi6B94RzP+93jtpC0vWgXUF0hr3ipuw8uadwd3jXxoA9IK4Pah8t6BneV9GgjD28Svw1mlxFobgFbeFTz13cKbth93fDryp2CEq0a4hTA+aAPQ/ESJFDdvXLzzzrqNjlTqOP6uDeFf0uhvJ0ZP2QD8D6ZzU6u8YIbBAAAAAElFTkSuQmCC';
/**
* Enum for Vernier godirect protocol.
* @readonly
* @enum {string}
*/
const BLEUUID = {
service: 'd91714ef-28b9-4f91-ba16-f0d9a604f112',
commandChar: 'f4bf14a6-c7d5-4b6d-8aa8-df1a7c83adcb',
responseChar: 'b41e6675-a329-40e0-aa01-44d2f444babe'
};
/**
* A time interval to wait (in milliseconds) before reporting to the BLE socket
* that data has stopped coming from the peripheral.
*/
const BLETimeout = 4500;
/**
* A string to report to the BLE socket when the GdxFor has stopped receiving data.
* @type {string}
*/
const BLEDataStoppedError = 'Force and Acceleration extension stopped receiving data';
/**
* Sensor ID numbers for the GDX-FOR.
*/
const GDXFOR_SENSOR = {
FORCE: 1,
ACCELERATION_X: 2,
ACCELERATION_Y: 3,
ACCELERATION_Z: 4,
SPIN_SPEED_X: 5,
SPIN_SPEED_Y: 6,
SPIN_SPEED_Z: 7
};
/**
* The update rate, in milliseconds, for sensor data input from the peripheral.
*/
const GDXFOR_UPDATE_RATE = 80;
/**
* Threshold for pushing and pulling force, for the whenForcePushedOrPulled hat block.
* @type {number}
*/
const FORCE_THRESHOLD = 5;
/**
* Threshold for acceleration magnitude, for the "shaken" gesture.
* @type {number}
*/
const SHAKEN_THRESHOLD = 30;
/**
* Threshold for acceleration magnitude, to check if we are facing up.
* @type {number}
*/
const FACING_THRESHOLD = 9;
/**
* An offset for the facing threshold, used to check that we are no longer facing up.
* @type {number}
*/
const FACING_THRESHOLD_OFFSET = 5;
/**
* Threshold for acceleration magnitude, below which we are in freefall.
* @type {number}
*/
const FREEFALL_THRESHOLD = 0.5;
/**
* Factor used to account for influence of rotation during freefall.
* @type {number}
*/
const FREEFALL_ROTATION_FACTOR = 0.3;
/**
* Threshold in degrees for reporting that the sensor is tilted.
* @type {number}
*/
const TILT_THRESHOLD = 15;
/**
* Acceleration due to gravity, in m/s^2.
* @type {number}
*/
const GRAVITY = 9.8;
/**
* Manage communication with a GDX-FOR peripheral over a Scratch Link client socket.
*/
class GdxFor {
/**
* Construct a GDX-FOR communication object.
* @param {Runtime} runtime - the Scratch 3.0 runtime
* @param {string} extensionId - the id of the extension
*/
constructor (runtime, extensionId) {
/**
* The Scratch 3.0 runtime used to trigger the green flag button.
* @type {Runtime}
* @private
*/
this._runtime = runtime;
/**
* The BluetoothLowEnergy connection socket for reading/writing peripheral data.
* @type {BLE}
* @private
*/
this._ble = null;
/**
* An @vernier/godirect Device
* @type {Device}
* @private
*/
this._device = null;
this._runtime.registerPeripheralExtension(extensionId, this);
/**
* The id of the extension this peripheral belongs to.
*/
this._extensionId = extensionId;
/**
* The most recently received value for each sensor.
* @type {Object.<string, number>}
* @private
*/
this._sensors = {
force: 0,
accelerationX: 0,
accelerationY: 0,
accelerationZ: 0,
spinSpeedX: 0,
spinSpeedY: 0,
spinSpeedZ: 0
};
/**
* Interval ID for data reading timeout.
* @type {number}
* @private
*/
this._timeoutID = null;
this.reset = this.reset.bind(this);
this._onConnect = this._onConnect.bind(this);
}
/**
* Called by the runtime when user wants to scan for a peripheral.
*/
scan () {
if (this._ble) {
this._ble.disconnect();
}
this._ble = new BLE(this._runtime, this._extensionId, {
filters: [
{namePrefix: 'GDX-FOR'}
],
optionalServices: [
BLEUUID.service
]
}, this._onConnect, this.reset);
}
/**
* Called by the runtime when user wants to connect to a certain peripheral.
* @param {number} id - the id of the peripheral to connect to.
*/
connect (id) {
if (this._ble) {
this._ble.connectPeripheral(id);
}
}
/**
* Called by the runtime when a user exits the connection popup.
* Disconnect from the GDX FOR.
*/
disconnect () {
if (this._ble) {
this._ble.disconnect();
}
this.reset();
}
/**
* Reset all the state and timeout/interval ids.
*/
reset () {
this._sensors = {
force: 0,
accelerationX: 0,
accelerationY: 0,
accelerationZ: 0,
spinSpeedX: 0,
spinSpeedY: 0,
spinSpeedZ: 0
};
if (this._timeoutID) {
window.clearInterval(this._timeoutID);
this._timeoutID = null;
}
}
/**
* Return true if connected to the goforce device.
* @return {boolean} - whether the goforce is connected.
*/
isConnected () {
let connected = false;
if (this._ble) {
connected = this._ble.isConnected();
}
return connected;
}
/**
* Starts reading data from peripheral after BLE has connected to it.
* @private
*/
_onConnect () {
const adapter = new ScratchLinkDeviceAdapter(this._ble, BLEUUID);
godirect.createDevice(adapter, {open: true, startMeasurements: false}).then(device => {
// Setup device
this._device = device;
this._device.keepValues = false; // todo: possibly remove after updating Vernier godirect module
// Enable sensors
this._device.sensors.forEach(sensor => {
sensor.setEnabled(true);
});
// Set sensor value-update behavior
this._device.on('measurements-started', () => {
const enabledSensors = this._device.sensors.filter(s => s.enabled);
enabledSensors.forEach(sensor => {
sensor.on('value-changed', s => {
this._onSensorValueChanged(s);
});
});
this._timeoutID = window.setInterval(
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
});
// Start device
this._device.start(GDXFOR_UPDATE_RATE);
});
}
/**
* Handler for sensor value changes from the goforce device.
* @param {object} sensor - goforce device sensor whose value has changed
* @private
*/
_onSensorValueChanged (sensor) {
switch (sensor.number) {
case GDXFOR_SENSOR.FORCE:
// Normalize the force, which can be measured between -50 and 50 N,
// to be a value between -100 and 100.
this._sensors.force = MathUtil.clamp(sensor.value * 2, -100, 100);
break;
case GDXFOR_SENSOR.ACCELERATION_X:
this._sensors.accelerationX = sensor.value;
break;
case GDXFOR_SENSOR.ACCELERATION_Y:
this._sensors.accelerationY = sensor.value;
break;
case GDXFOR_SENSOR.ACCELERATION_Z:
this._sensors.accelerationZ = sensor.value;
break;
case GDXFOR_SENSOR.SPIN_SPEED_X:
this._sensors.spinSpeedX = this._spinSpeedFromGyro(sensor.value);
break;
case GDXFOR_SENSOR.SPIN_SPEED_Y:
this._sensors.spinSpeedY = this._spinSpeedFromGyro(sensor.value);
break;
case GDXFOR_SENSOR.SPIN_SPEED_Z:
this._sensors.spinSpeedZ = this._spinSpeedFromGyro(sensor.value);
break;
}
// cancel disconnect timeout and start a new one
window.clearInterval(this._timeoutID);
this._timeoutID = window.setInterval(
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
}
_spinSpeedFromGyro (val) {
const framesPerSec = 1000 / this._runtime.currentStepTime;
val = MathUtil.radToDeg(val);
val = val / framesPerSec; // convert to from degrees per sec to degrees per frame
val = val * -1;
return val;
}
getForce () {
return this._sensors.force;
}
getTiltFrontBack (back = false) {
const x = this.getAccelerationX();
const y = this.getAccelerationY();
const z = this.getAccelerationZ();
// Compute the yz unit vector
const y2 = y * y;
const z2 = z * z;
let value = y2 + z2;
value = Math.sqrt(value);
// For sufficiently small zy vector values we are essentially at 90 degrees.
// The following snaps to 90 and avoids divide-by-zero errors.
// The snap factor was derived through observation -- just enough to
// still allow single degree steps up to 90 (..., 87, 88, 89, 90).
if (value < 0.35) {
value = (x < 0) ? 90 : -90;
} else {
value = x / value;
value = Math.atan(value);
value = MathUtil.radToDeg(value) * -1;
}
// Back is the inverse of front
if (back) value *= -1;
return value;
}
getTiltLeftRight (right = false) {
const x = this.getAccelerationX();
const y = this.getAccelerationY();
const z = this.getAccelerationZ();
// Compute the yz unit vector
const x2 = x * x;
const z2 = z * z;
let value = x2 + z2;
value = Math.sqrt(value);
// For sufficiently small zy vector values we are essentially at 90 degrees.
// The following snaps to 90 and avoids divide-by-zero errors.
// The snap factor was derived through observation -- just enough to
// still allow single degree steps up to 90 (..., 87, 88, 89, 90).
if (value < 0.35) {
value = (y < 0) ? 90 : -90;
} else {
value = y / value;
value = Math.atan(value);
value = MathUtil.radToDeg(value) * -1;
}
// Right is the inverse of left
if (right) value *= -1;
return value;
}
getAccelerationX () {
return this._sensors.accelerationX;
}
getAccelerationY () {
return this._sensors.accelerationY;
}
getAccelerationZ () {
return this._sensors.accelerationZ;
}
getSpinSpeedX () {
return this._sensors.spinSpeedX;
}
getSpinSpeedY () {
return this._sensors.spinSpeedY;
}
getSpinSpeedZ () {
return this._sensors.spinSpeedZ;
}
}
/**
* Enum for pushed and pulled menu options.
* @readonly
* @enum {string}
*/
const PushPullValues = {
PUSHED: 'pushed',
PULLED: 'pulled'
};
/**
* Enum for motion gesture menu options.
* @readonly
* @enum {string}
*/
const GestureValues = {
SHAKEN: 'shaken',
STARTED_FALLING: 'started falling',
TURNED_FACE_UP: 'turned face up',
TURNED_FACE_DOWN: 'turned face down'
};
/**
* Enum for tilt axis menu options.
* @readonly
* @enum {string}
*/
const TiltAxisValues = {
FRONT: 'front',
BACK: 'back',
LEFT: 'left',
RIGHT: 'right',
ANY: 'any'
};
/**
* Enum for axis menu options.
* @readonly
* @enum {string}
*/
const AxisValues = {
X: 'x',
Y: 'y',
Z: 'z'
};
/**
* Scratch 3.0 blocks to interact with a GDX-FOR peripheral.
*/
class Scratch3GdxForBlocks {
/**
* @return {string} - the name of this extension.
*/
static get EXTENSION_NAME () {
return 'Force and Acceleration';
}
/**
* @return {string} - the ID of this extension.
*/
static get EXTENSION_ID () {
return 'gdxfor';
}
get AXIS_MENU () {
return [
{
text: 'x',
value: AxisValues.X
},
{
text: 'y',
value: AxisValues.Y
},
{
text: 'z',
value: AxisValues.Z
}
];
}
get TILT_MENU () {
return [
{
text: formatMessage({
id: 'gdxfor.tiltDirectionMenu.front',
default: 'front',
description: 'label for front element in tilt direction picker for gdxfor extension'
}),
value: TiltAxisValues.FRONT
},
{
text: formatMessage({
id: 'gdxfor.tiltDirectionMenu.back',
default: 'back',
description: 'label for back element in tilt direction picker for gdxfor extension'
}),
value: TiltAxisValues.BACK
},
{
text: formatMessage({
id: 'gdxfor.tiltDirectionMenu.left',
default: 'left',
description: 'label for left element in tilt direction picker for gdxfor extension'
}),
value: TiltAxisValues.LEFT
},
{
text: formatMessage({
id: 'gdxfor.tiltDirectionMenu.right',
default: 'right',
description: 'label for right element in tilt direction picker for gdxfor extension'
}),
value: TiltAxisValues.RIGHT
}
];
}
get TILT_MENU_ANY () {
return [
...this.TILT_MENU,
{
text: formatMessage({
id: 'gdxfor.tiltDirectionMenu.any',
default: 'any',
description: 'label for any direction element in tilt direction picker for gdxfor extension'
}),
value: TiltAxisValues.ANY
}
];
}
get PUSH_PULL_MENU () {
return [
{
text: formatMessage({
id: 'gdxfor.pushed',
default: 'pushed',
description: 'the force sensor was pushed inward'
}),
value: PushPullValues.PUSHED
},
{
text: formatMessage({
id: 'gdxfor.pulled',
default: 'pulled',
description: 'the force sensor was pulled outward'
}),
value: PushPullValues.PULLED
}
];
}
get GESTURE_MENU () {
return [
{
text: formatMessage({
id: 'gdxfor.shaken',
default: 'shaken',
description: 'the sensor was shaken'
}),
value: GestureValues.SHAKEN
},
{
text: formatMessage({
id: 'gdxfor.startedFalling',
default: 'started falling',
description: 'the sensor started free falling'
}),
value: GestureValues.STARTED_FALLING
},
{
text: formatMessage({
id: 'gdxfor.turnedFaceUp',
default: 'turned face up',
description: 'the sensor was turned to face up'
}),
value: GestureValues.TURNED_FACE_UP
},
{
text: formatMessage({
id: 'gdxfor.turnedFaceDown',
default: 'turned face down',
description: 'the sensor was turned to face down'
}),
value: GestureValues.TURNED_FACE_DOWN
}
];
}
/**
* Construct a set of GDX-FOR blocks.
* @param {Runtime} runtime - the Scratch 3.0 runtime.
*/
constructor (runtime) {
/**
* The Scratch 3.0 runtime.
* @type {Runtime}
*/
this.runtime = runtime;
// Create a new GdxFor peripheral instance
this._peripheral = new GdxFor(this.runtime, Scratch3GdxForBlocks.EXTENSION_ID);
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: Scratch3GdxForBlocks.EXTENSION_ID,
name: Scratch3GdxForBlocks.EXTENSION_NAME,
blockIconURI: blockIconURI,
menuIconURI: menuIconURI,
showStatusButton: true,
blocks: [
{
opcode: 'whenGesture',
text: formatMessage({
id: 'gdxfor.whenGesture',
default: 'when [GESTURE]',
description: 'when the sensor detects a gesture'
}),
blockType: BlockType.HAT,
arguments: {
GESTURE: {
type: ArgumentType.STRING,
menu: 'gestureOptions',
defaultValue: GestureValues.SHAKEN
}
}
},
{
opcode: 'whenForcePushedOrPulled',
text: formatMessage({
id: 'gdxfor.whenForcePushedOrPulled',
default: 'when force sensor [PUSH_PULL]',
description: 'when the force sensor is pushed or pulled'
}),
blockType: BlockType.HAT,
arguments: {
PUSH_PULL: {
type: ArgumentType.STRING,
menu: 'pushPullOptions',
defaultValue: PushPullValues.PUSHED
}
}
},
{
opcode: 'getForce',
text: formatMessage({
id: 'gdxfor.getForce',
default: 'force',
description: 'gets force'
}),
blockType: BlockType.REPORTER
},
'---',
{
opcode: 'whenTilted',
text: formatMessage({
id: 'gdxfor.whenTilted',
default: 'when tilted [TILT]',
description: 'when the sensor detects tilt'
}),
blockType: BlockType.HAT,
arguments: {
TILT: {
type: ArgumentType.STRING,
menu: 'tiltAnyOptions',
defaultValue: TiltAxisValues.ANY
}
}
},
{
opcode: 'isTilted',
text: formatMessage({
id: 'gdxfor.isTilted',
default: 'tilted [TILT]?',
description: 'is the device tilted?'
}),
blockType: BlockType.BOOLEAN,
arguments: {
TILT: {
type: ArgumentType.STRING,
menu: 'tiltAnyOptions',
defaultValue: TiltAxisValues.ANY
}
}
},
{
opcode: 'getTilt',
text: formatMessage({
id: 'gdxfor.getTilt',
default: 'tilt angle [TILT]',
description: 'gets tilt'
}),
blockType: BlockType.REPORTER,
arguments: {
TILT: {
type: ArgumentType.STRING,
menu: 'tiltOptions',
defaultValue: TiltAxisValues.FRONT
}
}
},
'---',
{
opcode: 'isFreeFalling',
text: formatMessage({
id: 'gdxfor.isFreeFalling',
default: 'falling?',
description: 'is the device in free fall?'
}),
blockType: BlockType.BOOLEAN
},
{
opcode: 'getSpinSpeed',
text: formatMessage({
id: 'gdxfor.getSpin',
default: 'spin speed [DIRECTION]',
description: 'gets spin speed'
}),
blockType: BlockType.REPORTER,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'axisOptions',
defaultValue: AxisValues.Z
}
}
},
{
opcode: 'getAcceleration',
text: formatMessage({
id: 'gdxfor.getAcceleration',
default: 'acceleration [DIRECTION]',
description: 'gets acceleration'
}),
blockType: BlockType.REPORTER,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'axisOptions',
defaultValue: AxisValues.X
}
}
}
],
menus: {
pushPullOptions: {
acceptReporters: true,
items: this.PUSH_PULL_MENU
},
gestureOptions: {
acceptReporters: true,
items: this.GESTURE_MENU
},
axisOptions: {
acceptReporters: true,
items: this.AXIS_MENU
},
tiltOptions: {
acceptReporters: true,
items: this.TILT_MENU
},
tiltAnyOptions: {
acceptReporters: true,
items: this.TILT_MENU_ANY
}
}
};
}
whenForcePushedOrPulled (args) {
switch (args.PUSH_PULL) {
case PushPullValues.PUSHED:
return this._peripheral.getForce() < FORCE_THRESHOLD * -1;
case PushPullValues.PULLED:
return this._peripheral.getForce() > FORCE_THRESHOLD;
default:
log.warn(`unknown push/pull value in whenForcePushedOrPulled: ${args.PUSH_PULL}`);
return false;
}
}
getForce () {
return Math.round(this._peripheral.getForce());
}
whenGesture (args) {
switch (args.GESTURE) {
case GestureValues.SHAKEN:
return this.gestureMagnitude() > SHAKEN_THRESHOLD;
case GestureValues.STARTED_FALLING:
return this.isFreeFalling();
case GestureValues.TURNED_FACE_UP:
return this._isFacing(GestureValues.TURNED_FACE_UP);
case GestureValues.TURNED_FACE_DOWN:
return this._isFacing(GestureValues.TURNED_FACE_DOWN);
default:
log.warn(`unknown gesture value in whenGesture: ${args.GESTURE}`);
return false;
}
}
_isFacing (direction) {
if (typeof this._facingUp === 'undefined') {
this._facingUp = false;
}
if (typeof this._facingDown === 'undefined') {
this._facingDown = false;
}
// If the sensor is already facing up or down, reduce the threshold.
// This prevents small fluctations in acceleration while it is being
// turned from causing the hat block to trigger multiple times.
let threshold = FACING_THRESHOLD;
if (this._facingUp || this._facingDown) {
threshold -= FACING_THRESHOLD_OFFSET;
}
this._facingUp = this._peripheral.getAccelerationZ() > threshold;
this._facingDown = this._peripheral.getAccelerationZ() < threshold * -1;
switch (direction) {
case GestureValues.TURNED_FACE_UP:
return this._facingUp;
case GestureValues.TURNED_FACE_DOWN:
return this._facingDown;
default:
return false;
}
}
whenTilted (args) {
return this._isTilted(args.TILT);
}
isTilted (args) {
return this._isTilted(args.TILT);
}
getTilt (args) {
return this._getTiltAngle(args.TILT);
}
_isTilted (direction) {
switch (direction) {
case TiltAxisValues.ANY:
return this._getTiltAngle(TiltAxisValues.FRONT) > TILT_THRESHOLD ||
this._getTiltAngle(TiltAxisValues.BACK) > TILT_THRESHOLD ||
this._getTiltAngle(TiltAxisValues.LEFT) > TILT_THRESHOLD ||
this._getTiltAngle(TiltAxisValues.RIGHT) > TILT_THRESHOLD;
default:
return this._getTiltAngle(direction) > TILT_THRESHOLD;
}
}
_getTiltAngle (direction) {
// Tilt values are calculated using acceleration due to gravity,
// so we need to return 0 when the peripheral is not connected.
if (!this._peripheral.isConnected()) {
return 0;
}
switch (direction) {
case TiltAxisValues.FRONT:
return Math.round(this._peripheral.getTiltFrontBack(true));
case TiltAxisValues.BACK:
return Math.round(this._peripheral.getTiltFrontBack(false));
case TiltAxisValues.LEFT:
return Math.round(this._peripheral.getTiltLeftRight(true));
case TiltAxisValues.RIGHT:
return Math.round(this._peripheral.getTiltLeftRight(false));
default:
log.warn(`Unknown direction in getTilt: ${direction}`);
}
}
getSpinSpeed (args) {
switch (args.DIRECTION) {
case AxisValues.X:
return Math.round(this._peripheral.getSpinSpeedX());
case AxisValues.Y:
return Math.round(this._peripheral.getSpinSpeedY());
case AxisValues.Z:
return Math.round(this._peripheral.getSpinSpeedZ());
default:
log.warn(`Unknown direction in getSpinSpeed: ${args.DIRECTION}`);
}
}
getAcceleration (args) {
switch (args.DIRECTION) {
case AxisValues.X:
return Math.round(this._peripheral.getAccelerationX());
case AxisValues.Y:
return Math.round(this._peripheral.getAccelerationY());
case AxisValues.Z:
return Math.round(this._peripheral.getAccelerationZ());
default:
log.warn(`Unknown direction in getAcceleration: ${args.DIRECTION}`);
}
}
/**
* @param {number} x - x axis vector
* @param {number} y - y axis vector
* @param {number} z - z axis vector
* @return {number} - the magnitude of a three dimension vector.
*/
magnitude (x, y, z) {
return Math.sqrt((x * x) + (y * y) + (z * z));
}
accelMagnitude () {
return this.magnitude(
this._peripheral.getAccelerationX(),
this._peripheral.getAccelerationY(),
this._peripheral.getAccelerationZ()
);
}
gestureMagnitude () {
return this.accelMagnitude() - GRAVITY;
}
spinMagnitude () {
return this.magnitude(
this._peripheral.getSpinSpeedX(),
this._peripheral.getSpinSpeedY(),
this._peripheral.getSpinSpeedZ()
);
}
isFreeFalling () {
// When the peripheral is not connected, the acceleration magnitude
// is 0 instead of ~9.8, which ends up calculating as a positive
// free fall; so we need to return 'false' here to prevent returning 'true'.
if (!this._peripheral.isConnected()) {
return false;
}
const accelMag = this.accelMagnitude();
const spinMag = this.spinMagnitude();
// We want to account for rotation during freefall,
// so we tack on a an estimated "rotational effect"
// The FREEFALL_ROTATION_FACTOR const is used to both scale the
// gyro measurements and convert them to radians/second.
// So, we compare our accel magnitude against:
// FREEFALL_THRESHOLD + (some_scaled_magnitude_of_rotation).
const ffThresh = FREEFALL_THRESHOLD + (FREEFALL_ROTATION_FACTOR * spinMag);
return accelMag < ffThresh;
}
}
module.exports = Scratch3GdxForBlocks;

View File

@@ -0,0 +1,44 @@
const Base64Util = require('../../util/base64-util');
/**
* Adapter class
*/
class ScratchLinkDeviceAdapter {
constructor (socket, {service, commandChar, responseChar}) {
this.socket = socket;
this._service = service;
this._commandChar = commandChar;
this._responseChar = responseChar;
this._onResponse = this._onResponse.bind(this);
this._deviceOnResponse = null;
}
get godirectAdapter () {
return true;
}
writeCommand (commandBuffer) {
const data = Base64Util.uint8ArrayToBase64(commandBuffer);
return this.socket
.write(this._service, this._commandChar, data, 'base64');
}
setup ({onResponse}) {
this._deviceOnResponse = onResponse;
return this.socket
.startNotifications(this._service, this._responseChar, this._onResponse);
// TODO:
// How do we find out from scratch link if communication closes?
}
_onResponse (base64) {
const array = Base64Util.base64ToUint8Array(base64);
const response = new DataView(array.buffer);
return this._deviceOnResponse(response);
}
}
module.exports = ScratchLinkDeviceAdapter;

View File

@@ -0,0 +1,396 @@
const formatMessage = require('format-message');
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const Cast = require('../../util/cast');
/**
* 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,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHN0eWxlPi5zdDJ7ZmlsbDpyZWR9LnN0M3tmaWxsOiNlMGUwZTB9LnN0NHtmaWxsOm5vbmU7c3Ryb2tlOiM2NjY7c3Ryb2tlLXdpZHRoOi41O3N0cm9rZS1taXRlcmxpbWl0OjEwfTwvc3R5bGU+PHBhdGggZD0iTTM1IDI4SDVhMSAxIDAgMCAxLTEtMVYxMmMwLS42LjQtMSAxLTFoMzBjLjUgMCAxIC40IDEgMXYxNWMwIC41LS41IDEtMSAxeiIgZmlsbD0iI2ZmZiIgaWQ9IkxheWVyXzYiLz48ZyBpZD0iTGF5ZXJfNCI+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQgMjVoMzJ2Mi43SDR6TTEzIDI0aC0yLjJhMSAxIDAgMCAxLTEtMXYtOS43YzAtLjYuNC0xIDEtMUgxM2MuNiAwIDEgLjQgMSAxVjIzYzAgLjYtLjUgMS0xIDF6Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTYuMSAxOS4zdi0yLjJjMC0uNS40LTEgMS0xaDkuN2MuNSAwIDEgLjUgMSAxdjIuMmMwIC41LS41IDEtMSAxSDcuMWExIDEgMCAwIDEtMS0xeiIvPjxjaXJjbGUgY2xhc3M9InN0MiIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIzLjQiLz48Y2lyY2xlIGNsYXNzPSJzdDIiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMy40Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTQuMiAyN2gzMS45di43SDQuMnoiLz48L2c+PGcgaWQ9IkxheWVyXzUiPjxjaXJjbGUgY2xhc3M9InN0MyIgY3g9IjIyLjgiIGN5PSIxOC4yIiByPSIyLjMiLz48Y2lyY2xlIGNsYXNzPSJzdDMiIGN4PSIzMC42IiBjeT0iMTguMiIgcj0iMi4zIi8+PHBhdGggY2xhc3M9InN0MyIgZD0iTTEyLjUgMjIuOWgtMS4yYy0uMyAwLS41LS4yLS41LS41VjE0YzAtLjMuMi0uNS41LS41aDEuMmMuMyAwIC41LjIuNS41djguNGMwIC4zLS4yLjUtLjUuNXoiLz48cGF0aCBjbGFzcz0ic3QzIiBkPSJNNy4yIDE4Ljd2LTEuMmMwLS4zLjItLjUuNS0uNWg4LjRjLjMgMCAuNS4yLjUuNXYxLjJjMCAuMy0uMi41LS41LjVINy43Yy0uMyAwLS41LS4yLS41LS41ek00IDI2aDMydjJINHoiLz48L2c+PGcgaWQ9IkxheWVyXzMiPjxwYXRoIGNsYXNzPSJzdDQiIGQ9Ik0zNS4yIDI3LjlINC44YTEgMSAwIDAgMS0xLTFWMTIuMWMwLS42LjUtMSAxLTFoMzAuNWMuNSAwIDEgLjQgMSAxVjI3YTEgMSAwIDAgMS0xLjEuOXoiLz48cGF0aCBjbGFzcz0ic3Q0IiBkPSJNMzUuMiAyNy45SDQuOGExIDEgMCAwIDEtMS0xVjEyLjFjMC0uNi41LTEgMS0xaDMwLjVjLjUgMCAxIC40IDEgMVYyN2ExIDEgMCAwIDEtMS4xLjl6Ii8+PC9nPjwvc3ZnPg==';
/**
* Length of the buffer to store key presses for the "when keys pressed in order" hat
* @type {number}
*/
const KEY_BUFFER_LENGTH = 100;
/**
* Timeout in milliseconds to reset the completed flag for a sequence.
* @type {number}
*/
const SEQUENCE_HAT_TIMEOUT = 100;
/**
* An id for the space key on a keyboard.
*/
const KEY_ID_SPACE = 'SPACE';
/**
* An id for the left arrow key on a keyboard.
*/
const KEY_ID_LEFT = 'LEFT';
/**
* An id for the right arrow key on a keyboard.
*/
const KEY_ID_RIGHT = 'RIGHT';
/**
* An id for the up arrow key on a keyboard.
*/
const KEY_ID_UP = 'UP';
/**
* An id for the down arrow key on a keyboard.
*/
const KEY_ID_DOWN = 'DOWN';
/**
* Names used by keyboard io for keys used in scratch.
* @enum {string}
*/
const SCRATCH_KEY_NAME = {
[KEY_ID_SPACE]: 'space',
[KEY_ID_LEFT]: 'left arrow',
[KEY_ID_UP]: 'up arrow',
[KEY_ID_RIGHT]: 'right arrow',
[KEY_ID_DOWN]: 'down arrow'
};
/**
* Class for the makey makey blocks in Scratch 3.0
* @constructor
*/
class Scratch3MakeyMakeyBlocks {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
/**
* A toggle that alternates true and false each frame, so that an
* edge-triggered hat can trigger on every other frame.
* @type {boolean}
*/
this.frameToggle = false;
// Set an interval that toggles the frameToggle every frame.
setInterval(() => {
this.frameToggle = !this.frameToggle;
}, this.runtime.currentStepTime);
this.keyPressed = this.keyPressed.bind(this);
this.runtime.on('KEY_PRESSED', this.keyPressed);
this._clearkeyPressBuffer = this._clearkeyPressBuffer.bind(this);
this.runtime.on('PROJECT_STOP_ALL', this._clearkeyPressBuffer);
/*
* An object containing a set of sequence objects.
* These are the key sequences currently being detected by the "when
* keys pressed in order" hat block. Each sequence is keyed by its
* string representation (the sequence's value in the menu, which is a
* string of KEY_IDs separated by spaces). Each sequence object
* has an array property (an array of KEY_IDs) and a boolean
* completed property that is true when the sequence has just been
* pressed.
* @type {object}
*/
this.sequences = {};
/*
* An array of the key codes of recently pressed keys.
* @type {array}
*/
this.keyPressBuffer = [];
}
/*
* Localized short-form names of the space bar and arrow keys, for use in the
* displayed menu items of the "when keys pressed in order" block.
* @type {object}
*/
get KEY_TEXT_SHORT () {
return {
[KEY_ID_SPACE]: formatMessage({
id: 'makeymakey.spaceKey',
default: 'space',
description: 'The space key on a computer keyboard.'
}),
[KEY_ID_LEFT]: formatMessage({
id: 'makeymakey.leftArrowShort',
default: 'left',
description: 'Short name for the left arrow key on a computer keyboard.'
}),
[KEY_ID_UP]: formatMessage({
id: 'makeymakey.upArrowShort',
default: 'up',
description: 'Short name for the up arrow key on a computer keyboard.'
}),
[KEY_ID_RIGHT]: formatMessage({
id: 'makeymakey.rightArrowShort',
default: 'right',
description: 'Short name for the right arrow key on a computer keyboard.'
}),
[KEY_ID_DOWN]: formatMessage({
id: 'makeymakey.downArrowShort',
default: 'down',
description: 'Short name for the down arrow key on a computer keyboard.'
})
};
}
/*
* An array of strings of KEY_IDs representing the default set of
* key sequences for use by the "when keys pressed in order" block.
* @type {array}
*/
get DEFAULT_SEQUENCES () {
return [
`${KEY_ID_LEFT} ${KEY_ID_UP} ${KEY_ID_RIGHT}`,
`${KEY_ID_RIGHT} ${KEY_ID_UP} ${KEY_ID_LEFT}`,
`${KEY_ID_LEFT} ${KEY_ID_RIGHT}`,
`${KEY_ID_RIGHT} ${KEY_ID_LEFT}`,
`${KEY_ID_UP} ${KEY_ID_DOWN}`,
`${KEY_ID_DOWN} ${KEY_ID_UP}`,
`${KEY_ID_UP} ${KEY_ID_RIGHT} ${KEY_ID_DOWN} ${KEY_ID_LEFT}`,
`${KEY_ID_UP} ${KEY_ID_LEFT} ${KEY_ID_DOWN} ${KEY_ID_RIGHT}`,
`${KEY_ID_UP} ${KEY_ID_UP} ${KEY_ID_DOWN} ${KEY_ID_DOWN} ` +
`${KEY_ID_LEFT} ${KEY_ID_RIGHT} ${KEY_ID_LEFT} ${KEY_ID_RIGHT}`
];
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: 'makeymakey',
name: 'Makey Makey',
blockIconURI: blockIconURI,
blocks: [
{
opcode: 'whenMakeyKeyPressed',
text: formatMessage({
id: 'makeymakey.whenKeyPressed',
default: 'when [KEY] key pressed',
description: 'when a keyboard key is pressed'
}),
blockType: BlockType.HAT,
arguments: {
KEY: {
type: ArgumentType.STRING,
menu: 'KEY',
defaultValue: KEY_ID_SPACE
}
}
},
{
opcode: 'whenCodePressed',
text: formatMessage({
id: 'makeymakey.whenKeysPressedInOrder',
default: 'when [SEQUENCE] pressed in order',
description: 'when a sequence of keyboard keys is pressed in a specific order'
}),
blockType: BlockType.HAT,
arguments: {
SEQUENCE: {
type: ArgumentType.STRING,
menu: 'SEQUENCE',
defaultValue: this.DEFAULT_SEQUENCES[0]
}
}
}
],
menus: {
KEY: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'makeymakey.spaceKey',
default: 'space',
description: 'The space key on a computer keyboard.'
}),
value: KEY_ID_SPACE
},
{
text: formatMessage({
id: 'makeymakey.upArrow',
default: 'up arrow',
description: 'The up arrow key on a computer keyboard.'
}),
value: KEY_ID_UP
},
{
text: formatMessage({
id: 'makeymakey.downArrow',
default: 'down arrow',
description: 'The down arrow key on a computer keyboard.'
}),
value: KEY_ID_DOWN
},
{
text: formatMessage({
id: 'makeymakey.rightArrow',
default: 'right arrow',
description: 'The right arrow key on a computer keyboard.'
}),
value: KEY_ID_RIGHT
},
{
text: formatMessage({
id: 'makeymakey.leftArrow',
default: 'left arrow',
description: 'The left arrow key on a computer keyboard.'
}),
value: KEY_ID_LEFT
},
{text: 'w', value: 'w'},
{text: 'a', value: 'a'},
{text: 's', value: 's'},
{text: 'd', value: 'd'},
{text: 'f', value: 'f'},
{text: 'g', value: 'g'}
]
},
SEQUENCE: {
acceptReporters: true,
items: this.buildSequenceMenu(this.DEFAULT_SEQUENCES)
}
}
};
}
/*
* Build the menu of key sequences.
* @param {array} sequencesArray an array of strings of KEY_IDs.
* @returns {array} an array of objects with text and value properties.
*/
buildSequenceMenu (sequencesArray) {
return sequencesArray.map(
str => this.getMenuItemForSequenceString(str)
);
}
/*
* Create a menu item for a sequence string.
* @param {string} sequenceString a string of KEY_IDs.
* @return {object} an object with text and value properties.
*/
getMenuItemForSequenceString (sequenceString) {
let sequenceArray = sequenceString.split(' ');
sequenceArray = sequenceArray.map(str => this.KEY_TEXT_SHORT[str]);
return {
text: sequenceArray.join(' '),
value: sequenceString
};
}
/*
* Check whether a keyboard key is currently pressed.
* Also, toggle the results of the test on alternate frames, so that the
* hat block fires repeatedly.
* @param {object} args - the block arguments.
* @property {number} KEY - a key code.
* @param {object} util - utility object provided by the runtime.
*/
whenMakeyKeyPressed (args, util) {
let key = args.KEY;
// Convert the key arg, if it is a KEY_ID, to the key name used by
// the Keyboard io module.
if (SCRATCH_KEY_NAME[args.KEY]) {
key = SCRATCH_KEY_NAME[args.KEY];
}
const isDown = util.ioQuery('keyboard', 'getKeyIsDown', [key]);
return (isDown && this.frameToggle);
}
/*
* A function called on the KEY_PRESSED event, to update the key press
* buffer and check if any of the key sequences have been completed.
* @param {string} key A scratch key name.
*/
keyPressed (key) {
// Store only the first word of the Scratch key name, so that e.g. when
// "left arrow" is pressed, we store "LEFT", which matches KEY_ID_LEFT
key = key.split(' ')[0];
key = key.toUpperCase();
this.keyPressBuffer.push(key);
// Keep the buffer under the length limit
if (this.keyPressBuffer.length > KEY_BUFFER_LENGTH) {
this.keyPressBuffer.shift();
}
// Check the buffer for each sequence in use
for (const str in this.sequences) {
const arr = this.sequences[str].array;
// Bail out if we don't have enough presses for this sequence
if (this.keyPressBuffer.length < arr.length) {
continue;
}
let missFlag = false;
// Slice the buffer to the length of the sequence we're checking
const bufferSegment = this.keyPressBuffer.slice(-1 * arr.length);
for (let i = 0; i < arr.length; i++) {
if (arr[i] !== bufferSegment[i]) {
missFlag = true;
}
}
// If the miss flag is false, the sequence matched the buffer
if (!missFlag) {
this.sequences[str].completed = true;
// Clear the completed flag after a timeout. This is necessary because
// the hat is edge-triggered (not event triggered). Multiple hats
// may be checking the same sequence, so this timeout gives them enough
// time to all trigger before resetting the flag.
setTimeout(() => {
this.sequences[str].completed = false;
}, SEQUENCE_HAT_TIMEOUT);
}
}
}
/**
* Clear the key press buffer.
*/
_clearkeyPressBuffer () {
this.keyPressBuffer = [];
}
/*
* Add a key sequence to the set currently being checked on each key press.
* @param {string} sequenceString a string of space-separated KEY_IDs.
* @param {array} sequenceArray an array of KEY_IDs.
*/
addSequence (sequenceString, sequenceArray) {
// If we already have this sequence string, return.
if (Object.prototype.hasOwnProperty.call(this.sequences, sequenceString)) {
return;
}
this.sequences[sequenceString] = {
array: sequenceArray,
completed: false
};
}
/*
* Check whether a key sequence was recently completed.
* @param {object} args The block arguments.
* @property {number} SEQUENCE A string of KEY_IDs.
*/
whenCodePressed (args) {
const sequenceString = Cast.toString(args.SEQUENCE).toUpperCase();
const sequenceArray = sequenceString.split(' ');
if (sequenceArray.length < 2) {
return;
}
this.addSequence(sequenceString, sequenceArray);
return this.sequences[sequenceString].completed;
}
}
module.exports = Scratch3MakeyMakeyBlocks;

View File

@@ -0,0 +1,984 @@
const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const log = require('../../util/log');
const cast = require('../../util/cast');
const formatMessage = require('format-message');
const BLE = require('../../io/ble');
const Base64Util = require('../../util/base64-util');
/**
* Icon png 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/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAACXBIWXMAABYlAAAWJQFJUiTwAAAKcElEQVR42u2cfXAU9RnHv7u3L3d7l9yR5PIGXO7MkQKaYiCUWqJhFGvRMk4JZXSc8aXVaSmiYlthVHQEW99FxiIdrVY6teiMdoa+ICqhIqgQAsjwMgYDOQKXl7uY17u9293b3f5x5JKYe8+FJGSfvzbP/n77e/azz+95nt9v90KoqgpN0hdSQ6AB1ABqADWAmmgANYAaQA2gJhpADeBEE2q8GPLaWzu/CslyiY4k9dOn5uijtXGd7+jWkaReVpT3Hrhv6d0awEFC07rgD+ZeYYnXprhwigUAvjj0zbjxQCLebozT7iDzK1ZUWCru2K7L//6MVC8ue45Blz8n6rlQ815QtuohOlXiEdy/AUqPa6y59Mkh6Q1345GNja6m7pHEQKNl3t0704EXat4L6fSOmOeEI1vHKzwAyNJR9MPFpRUPOu0ONm2A0xatWaTLm5WfDrzvAppA8AbiG03fC8CQNkDKZK2YrPAuRrhpifJERsuYywveJc7CqcIDMAyeLm82dEXzw39I/qjXkpr3QuW9lxfAdOABGAKPslWDnbsy7Jl8BxTeM3SqmO0gaA5U6c3jymup0YSn9JyLee67wpTfBQAQjmyF3HFqiJcRtDECjy5dAmbmcgQPvjjxl3Lx4IVjnD/5cE1zkWtyP34VBGcdKLJnLgc9cznk1kMXFdzEn8KJ4KUqqsSHvcxWDf7j1UM8UPr6/YgHhhX8xAaYaXgAIB7fBnbuSrBzV8aNgarEQ/z6/YkLcDTg9V9XlXjQtuqoU1TpcUHlvZDOfDiuyh5qPMCLrJ1bDw3EuUtx81N/BH3pjQBJQ2HMF5V6iKfeRchVm9kkMtrwxmSdobeA9daBde8GwVlBcFYofS1Jw0vaAy9HeJHQwBUPzIBvGxDc92Rmp/BowJs10wkAONfsBs8HAAAltqngOAO8HZ3o6OiMqcvLy4E1Lwc8H8C5ZndMXdLJa/qNacNLCDBw/O8nFUNWxp/64+tWAwBefe1tHKg7CgC4/9d3ori4EHv3HcDrb26PqVt2602ovvaHaGlpw+8ffSamLqXYmya8jG8mpFy6iGLkWLh4HAwG4+r6j4VBfaPpLgU8IMGO9MLqW2pYQ9aQokuR5dgXIwCC1CUcNMj3hpdvLAdSF54EYpCHooRA0Swomo2pC0kCQpIAkqTA6LmYupgxL0X7m78+aG10NXVkpIwxsAwWXncDCESHLkohfPbpbiT6ZFPPZQ9fC0e58Wi6wTDj6UbT/rQAyiERS2pW4Kc3LQDLRO8miCEAKj7d83FcTxyLJJJJ+9MCqKoq9HomMrgkSThxsgEcZ8AMpwMkSYJlKDA0DVUFiHGWRDJp/4jXwqIo4uFHnkZXdw8AYGbZFXhs3WqQJDkhkkim7E8KoMlkxKbnn8DBunrwUli3e8/+yOAA0HjmHDq7upGXm5PUoDUr7hmWRB5Zt3FYwoime+vtd/H6G9uGJIxouniSyP6H7v8FystnY80jGzIA0MihsMAKu20aTp3JzFb6WCWRuDUvHwByw8cOhw2FBVaYjNzIAba1e3Hfb9aiq7MTNStuBwAsvr4KO3d9GnmKztIS5EyxTJiVSDT7p04tipx/9MnnYc7ORlu7NzMxsK3di5AkDHgGw2DTC+uHBeGJshJJZL/fxyMQEDKbRAiCQDAoQhBDYBkKNE2j4uqrhpUBoiSBIMZfEhkN+1NeiWSqEB2rlUg69md0JRIQRHy86z8jXsqNVRLJlP0jqgNJXXgAgjbCcONmCHUvQ+44NWG2s/rtH5Mt/ciToo0wLH4JBGO6LLazRiJk2vBYy4gHHw/bWSN+LZBKEhkMjzn/CaSiKgQOvJDyFB7L7axUJWNJZDA8IhQA1boPin7KZbMSGfUYyFx9b3hXg/cCsoBA2Z0AoYOaxlcC4+mdyCUDKBzanLFBJ3USyaRMuiSSKZmUSSSTMimTCABUlblRU9kAZ0E39p+eii21c+EL0jHbOwu6sfaWgyjND//U4oP6MmzZnfi79XT7mfQSNi7bh0JzOLG19XBY/89r49pYVebGqhuOosDsh1+gsWV3BXYdd2Q+BlaVuXFv9bHgkSbzk+vfcVRyjHhi47J9cftsXLYf7T36Ix8cLHlo6ydlv6qpPI2qssRZcuOy/Wjp4k5s+2zG+offKqtcUt6kJtNv7S0H0RtkvEufXTB/6bML5je2Wy7UVDbEbF9o9mPDsv2oP5v75vbPS26rP5u3fdXiozDppcwDrKlswOlWy9E//DX09Mt/azh8zzNM1RybF86C7pheVGD240CDeX3NWtfml94Rt+0+Mf3Lm8qbEnpfgdmPs+3G9+564vTT//pM/GrHYduWRP0AYOEMN/5S61xT92Vtfd2XtfWb/vu91fHALyxzw9tnkB/cTD5w+2Ou9375HHtfa7exM5mxRpKFaafdQQKgAcDERs98/foLHrXdaXfoABi8vczhWO2/28/TRR5z2h00gKymNl1ton79oigq6bQ7dE67Q+ew9mb1h4FYYwVESgLAXLSRa+3mWpIdK+UYuPiq89f8+XfT/+ftZQ4vLm9ZmUyfdcsv1M2fWfRaUCK8i8vdK1u6ktuAWPWTsztm24o/cnnYHUsrWzd1+fVJ9XtqxbG3XzFdNcPTawjcueibpxK1t+X26f/9R8a953jub4typOvm2b1XnvUmv8JKWMZcaZffX3XDERRP8cGaFRjWxtPLoZvXY4oxgPBNEsgxBhCUKEzL6Ru+JydS8Ak0giKFgESDJFQoKmCgQzAwIfQEWETzmoBIwd2VNaStu8uEHGO4Buz06zHHFv0dRkefAZ1+PQx0KNK2eIoPLCUj2zDc275qzgcBFWv+cf3IyxgTK2KOzQufEM5kfpGF12eGPSf8DXN+No/87HDWiwYYALw+M6ym8AscAxO++X7xCTRM7EDQzht0Da8v/NWo1dQDAxNCocUXs+303IGHdaptOmYXnh/SLlZbV+fwnwJm6UXEm/ojqgM/PFmJQ81OPHfrtqT7bN23BE8seTflYLvz5DwYGQHLKz5Puo/XZ8aLtT+D1dSDuxbsGQIymmz48DbwIguOESJOcce8XaO3oVpZ8k3Em5KVVAAMFnuOB9as1MbimCBunn04vBmR40ls29Wfgxf1KMn1gBdY+MXUCvK4ANvPndpLzrLzALjBN2VPwrDBksgLYkn1jBMp90nVY2++8vAw3RlPeLNYVZSPAEgjKWP6ZCn4lF+gMdnE08spQb73RQB9aXtgo6tJcNodf8rWz3L//Br340UW3sExEkXrFFKSSUVHqkRfkJZ8QSZk5gS6hw9H+GyDQAclSs41BVmSUIn+toAKIUTJskKoQUknCxKlkISKb/sM0NMyyVAhXW+AlYosfgOgQlUJVadTSUWBKoQoudvPioPbenq5oIUTaRUqenhWKi3oyVIUqKpKREoLggDhF6hQb4CV9LRM9rctMPN6glChp2SdTqeSskwoAECSKnG61fzFR/XsGu+FhmONriYl7TImsjoYKJyZSeB8CoBQo6spqU8TCO1fgE7gDVUNoCYaQA2gBlADqAHURAOoAdQAagA10QCOgfwfNp/hXbfBMCAAAAAASUVORK5CYII=';
/**
* Enum for micro:bit BLE command protocol.
* https://github.com/scratchfoundation/scratch-microbit-firmware/blob/master/protocol.md
* @readonly
* @enum {number}
*/
const BLECommand = {
CMD_PIN_CONFIG: 0x80,
CMD_DISPLAY_TEXT: 0x81,
CMD_DISPLAY_LED: 0x82
};
/**
* A time interval to wait (in milliseconds) before reporting to the BLE socket
* that data has stopped coming from the peripheral.
*/
const BLETimeout = 4500;
/**
* A time interval to wait (in milliseconds) while a block that sends a BLE message is running.
* @type {number}
*/
const BLESendInterval = 100;
/**
* A string to report to the BLE socket when the micro:bit has stopped receiving data.
* @type {string}
*/
const BLEDataStoppedError = 'micro:bit extension stopped receiving data';
/**
* Enum for micro:bit protocol.
* https://github.com/scratchfoundation/scratch-microbit-firmware/blob/master/protocol.md
* @readonly
* @enum {string}
*/
const BLEUUID = {
service: 0xf005,
rxChar: '5261da01-fa7e-42ab-850b-7c80220097cc',
txChar: '5261da02-fa7e-42ab-850b-7c80220097cc'
};
/**
* Manage communication with a MicroBit peripheral over a Scrath Link client socket.
*/
class MicroBit {
/**
* Construct a MicroBit communication object.
* @param {Runtime} runtime - the Scratch 3.0 runtime
* @param {string} extensionId - the id of the extension
*/
constructor (runtime, extensionId) {
/**
* The Scratch 3.0 runtime used to trigger the green flag button.
* @type {Runtime}
* @private
*/
this._runtime = runtime;
/**
* The BluetoothLowEnergy connection socket for reading/writing peripheral data.
* @type {BLE}
* @private
*/
this._ble = null;
this._runtime.registerPeripheralExtension(extensionId, this);
/**
* The id of the extension this peripheral belongs to.
*/
this._extensionId = extensionId;
/**
* The most recently received value for each sensor.
* @type {Object.<string, number>}
* @private
*/
this._sensors = {
tiltX: 0,
tiltY: 0,
buttonA: 0,
buttonB: 0,
touchPins: [0, 0, 0],
gestureState: 0,
ledMatrixState: new Uint8Array(5)
};
/**
* The most recently received value for each gesture.
* @type {Object.<string, Object>}
* @private
*/
this._gestures = {
moving: false,
move: {
active: false,
timeout: false
},
shake: {
active: false,
timeout: false
},
jump: {
active: false,
timeout: false
}
};
/**
* Interval ID for data reading timeout.
* @type {number}
* @private
*/
this._timeoutID = null;
/**
* A flag that is true while we are busy sending data to the BLE socket.
* @type {boolean}
* @private
*/
this._busy = false;
/**
* ID for a timeout which is used to clear the busy flag if it has been
* true for a long time.
*/
this._busyTimeoutID = null;
this.reset = this.reset.bind(this);
this._onConnect = this._onConnect.bind(this);
this._onMessage = this._onMessage.bind(this);
}
/**
* @param {string} text - the text to display.
* @return {Promise} - a Promise that resolves when writing to peripheral.
*/
displayText (text) {
const output = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) {
output[i] = text.charCodeAt(i);
}
return this.send(BLECommand.CMD_DISPLAY_TEXT, output);
}
/**
* @param {Uint8Array} matrix - the matrix to display.
* @return {Promise} - a Promise that resolves when writing to peripheral.
*/
displayMatrix (matrix) {
return this.send(BLECommand.CMD_DISPLAY_LED, matrix);
}
/**
* @return {number} - the latest value received for the tilt sensor's tilt about the X axis.
*/
get tiltX () {
return this._sensors.tiltX;
}
/**
* @return {number} - the latest value received for the tilt sensor's tilt about the Y axis.
*/
get tiltY () {
return this._sensors.tiltY;
}
/**
* @return {boolean} - the latest value received for the A button.
*/
get buttonA () {
return this._sensors.buttonA;
}
/**
* @return {boolean} - the latest value received for the B button.
*/
get buttonB () {
return this._sensors.buttonB;
}
/**
* @return {number} - the latest value received for the motion gesture states.
*/
get gestureState () {
return this._sensors.gestureState;
}
/**
* @return {Uint8Array} - the current state of the 5x5 LED matrix.
*/
get ledMatrixState () {
return this._sensors.ledMatrixState;
}
/**
* Called by the runtime when user wants to scan for a peripheral.
*/
scan () {
if (this._ble) {
this._ble.disconnect();
}
this._ble = new BLE(this._runtime, this._extensionId, {
filters: [
{services: [BLEUUID.service]}
]
}, this._onConnect, this.reset);
}
/**
* Called by the runtime when user wants to connect to a certain peripheral.
* @param {number} id - the id of the peripheral to connect to.
*/
connect (id) {
if (this._ble) {
this._ble.connectPeripheral(id);
}
}
/**
* Disconnect from the micro:bit.
*/
disconnect () {
if (this._ble) {
this._ble.disconnect();
}
this.reset();
}
/**
* Reset all the state and timeout/interval ids.
*/
reset () {
if (this._timeoutID) {
window.clearTimeout(this._timeoutID);
this._timeoutID = null;
}
}
/**
* Return true if connected to the micro:bit.
* @return {boolean} - whether the micro:bit is connected.
*/
isConnected () {
let connected = false;
if (this._ble) {
connected = this._ble.isConnected();
}
return connected;
}
/**
* Send a message to the peripheral BLE socket.
* @param {number} command - the BLE command hex.
* @param {Uint8Array} message - the message to write
*/
send (command, message) {
if (!this.isConnected()) return;
if (this._busy) return;
// Set a busy flag so that while we are sending a message and waiting for
// the response, additional messages are ignored.
this._busy = true;
// Set a timeout after which to reset the busy flag. This is used in case
// a BLE message was sent for which we never received a response, because
// e.g. the peripheral was turned off after the message was sent. We reset
// the busy flag after a while so that it is possible to try again later.
this._busyTimeoutID = window.setTimeout(() => {
this._busy = false;
}, 5000);
const output = new Uint8Array(message.length + 1);
output[0] = command; // attach command to beginning of message
for (let i = 0; i < message.length; i++) {
output[i + 1] = message[i];
}
const data = Base64Util.uint8ArrayToBase64(output);
this._ble.write(BLEUUID.service, BLEUUID.txChar, data, 'base64', true).then(
() => {
this._busy = false;
window.clearTimeout(this._busyTimeoutID);
}
);
}
/**
* Starts reading data from peripheral after BLE has connected to it.
* @private
*/
_onConnect () {
this._ble.read(BLEUUID.service, BLEUUID.rxChar, true, this._onMessage);
this._timeoutID = window.setTimeout(
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
}
/**
* Process the sensor data from the incoming BLE characteristic.
* @param {object} base64 - the incoming BLE data.
* @private
*/
_onMessage (base64) {
// parse data
const data = Base64Util.base64ToUint8Array(base64);
this._sensors.tiltX = data[1] | (data[0] << 8);
if (this._sensors.tiltX > (1 << 15)) this._sensors.tiltX -= (1 << 16);
this._sensors.tiltY = data[3] | (data[2] << 8);
if (this._sensors.tiltY > (1 << 15)) this._sensors.tiltY -= (1 << 16);
this._sensors.buttonA = data[4];
this._sensors.buttonB = data[5];
this._sensors.touchPins[0] = data[6];
this._sensors.touchPins[1] = data[7];
this._sensors.touchPins[2] = data[8];
this._sensors.gestureState = data[9];
// cancel disconnect timeout and start a new one
window.clearTimeout(this._timeoutID);
this._timeoutID = window.setTimeout(
() => this._ble.handleDisconnectError(BLEDataStoppedError),
BLETimeout
);
}
/**
* @param {number} pin - the pin to check touch state.
* @return {number} - the latest value received for the touch pin states.
* @private
*/
_checkPinState (pin) {
return this._sensors.touchPins[pin];
}
}
/**
* Enum for tilt sensor direction.
* @readonly
* @enum {string}
*/
const MicroBitTiltDirection = {
FRONT: 'front',
BACK: 'back',
LEFT: 'left',
RIGHT: 'right',
ANY: 'any'
};
/**
* Enum for micro:bit gestures.
* @readonly
* @enum {string}
*/
const MicroBitGestures = {
MOVED: 'moved',
SHAKEN: 'shaken',
JUMPED: 'jumped'
};
/**
* Enum for micro:bit buttons.
* @readonly
* @enum {string}
*/
const MicroBitButtons = {
A: 'A',
B: 'B',
ANY: 'any'
};
/**
* Enum for micro:bit pin states.
* @readonly
* @enum {string}
*/
const MicroBitPinState = {
ON: 'on',
OFF: 'off'
};
/**
* Scratch 3.0 blocks to interact with a MicroBit peripheral.
*/
class Scratch3MicroBitBlocks {
/**
* @return {string} - the name of this extension.
*/
static get EXTENSION_NAME () {
return 'micro:bit';
}
/**
* @return {string} - the ID of this extension.
*/
static get EXTENSION_ID () {
return 'microbit';
}
/**
* @return {number} - the tilt sensor counts as "tilted" if its tilt angle meets or exceeds this threshold.
*/
static get TILT_THRESHOLD () {
return 15;
}
/**
* @return {array} - text and values for each buttons menu element
*/
get BUTTONS_MENU () {
return [
{
text: 'A',
value: MicroBitButtons.A
},
{
text: 'B',
value: MicroBitButtons.B
},
{
text: formatMessage({
id: 'microbit.buttonsMenu.any',
default: 'any',
description: 'label for "any" element in button picker for micro:bit extension'
}),
value: MicroBitButtons.ANY
}
];
}
/**
* @return {array} - text and values for each gestures menu element
*/
get GESTURES_MENU () {
return [
{
text: formatMessage({
id: 'microbit.gesturesMenu.moved',
default: 'moved',
description: 'label for moved gesture in gesture picker for micro:bit extension'
}),
value: MicroBitGestures.MOVED
},
{
text: formatMessage({
id: 'microbit.gesturesMenu.shaken',
default: 'shaken',
description: 'label for shaken gesture in gesture picker for micro:bit extension'
}),
value: MicroBitGestures.SHAKEN
},
{
text: formatMessage({
id: 'microbit.gesturesMenu.jumped',
default: 'jumped',
description: 'label for jumped gesture in gesture picker for micro:bit extension'
}),
value: MicroBitGestures.JUMPED
}
];
}
/**
* @return {array} - text and values for each pin state menu element
*/
get PIN_STATE_MENU () {
return [
{
text: formatMessage({
id: 'microbit.pinStateMenu.on',
default: 'on',
description: 'label for on element in pin state picker for micro:bit extension'
}),
value: MicroBitPinState.ON
},
{
text: formatMessage({
id: 'microbit.pinStateMenu.off',
default: 'off',
description: 'label for off element in pin state picker for micro:bit extension'
}),
value: MicroBitPinState.OFF
}
];
}
/**
* @return {array} - text and values for each tilt direction menu element
*/
get TILT_DIRECTION_MENU () {
return [
{
text: formatMessage({
id: 'microbit.tiltDirectionMenu.front',
default: 'front',
description: 'label for front element in tilt direction picker for micro:bit extension'
}),
value: MicroBitTiltDirection.FRONT
},
{
text: formatMessage({
id: 'microbit.tiltDirectionMenu.back',
default: 'back',
description: 'label for back element in tilt direction picker for micro:bit extension'
}),
value: MicroBitTiltDirection.BACK
},
{
text: formatMessage({
id: 'microbit.tiltDirectionMenu.left',
default: 'left',
description: 'label for left element in tilt direction picker for micro:bit extension'
}),
value: MicroBitTiltDirection.LEFT
},
{
text: formatMessage({
id: 'microbit.tiltDirectionMenu.right',
default: 'right',
description: 'label for right element in tilt direction picker for micro:bit extension'
}),
value: MicroBitTiltDirection.RIGHT
}
];
}
/**
* @return {array} - text and values for each tilt direction (plus "any") menu element
*/
get TILT_DIRECTION_ANY_MENU () {
return [
...this.TILT_DIRECTION_MENU,
{
text: formatMessage({
id: 'microbit.tiltDirectionMenu.any',
default: 'any',
description: 'label for any direction element in tilt direction picker for micro:bit extension'
}),
value: MicroBitTiltDirection.ANY
}
];
}
/**
* Construct a set of MicroBit blocks.
* @param {Runtime} runtime - the Scratch 3.0 runtime.
*/
constructor (runtime) {
/**
* The Scratch 3.0 runtime.
* @type {Runtime}
*/
this.runtime = runtime;
// Create a new MicroBit peripheral instance
this._peripheral = new MicroBit(this.runtime, Scratch3MicroBitBlocks.EXTENSION_ID);
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: Scratch3MicroBitBlocks.EXTENSION_ID,
name: Scratch3MicroBitBlocks.EXTENSION_NAME,
blockIconURI: blockIconURI,
showStatusButton: true,
blocks: [
{
opcode: 'whenButtonPressed',
text: formatMessage({
id: 'microbit.whenButtonPressed',
default: 'when [BTN] button pressed',
description: 'when the selected button on the micro:bit is pressed'
}),
blockType: BlockType.HAT,
arguments: {
BTN: {
type: ArgumentType.STRING,
menu: 'buttons',
defaultValue: MicroBitButtons.A
}
}
},
{
opcode: 'isButtonPressed',
text: formatMessage({
id: 'microbit.isButtonPressed',
default: '[BTN] button pressed?',
description: 'is the selected button on the micro:bit pressed?'
}),
blockType: BlockType.BOOLEAN,
arguments: {
BTN: {
type: ArgumentType.STRING,
menu: 'buttons',
defaultValue: MicroBitButtons.A
}
}
},
'---',
{
opcode: 'whenGesture',
text: formatMessage({
id: 'microbit.whenGesture',
default: 'when [GESTURE]',
description: 'when the selected gesture is detected by the micro:bit'
}),
blockType: BlockType.HAT,
arguments: {
GESTURE: {
type: ArgumentType.STRING,
menu: 'gestures',
defaultValue: MicroBitGestures.MOVED
}
}
},
'---',
{
opcode: 'displaySymbol',
text: formatMessage({
id: 'microbit.displaySymbol',
default: 'display [MATRIX]',
description: 'display a pattern on the micro:bit display'
}),
blockType: BlockType.COMMAND,
arguments: {
MATRIX: {
type: ArgumentType.MATRIX,
defaultValue: '0101010101100010101000100'
}
}
},
{
opcode: 'displayText',
text: formatMessage({
id: 'microbit.displayText',
default: 'display text [TEXT]',
description: 'display text on the micro:bit display'
}),
blockType: BlockType.COMMAND,
arguments: {
TEXT: {
type: ArgumentType.STRING,
defaultValue: formatMessage({
id: 'microbit.defaultTextToDisplay',
default: 'Hello!',
description: `default text to display.
IMPORTANT - the micro:bit only supports letters a-z, A-Z.
Please substitute a default word in your language
that can be written with those characters,
substitute non-accented characters or leave it as "Hello!".
Check the micro:bit site documentation for details`
})
}
}
},
{
opcode: 'displayClear',
text: formatMessage({
id: 'microbit.clearDisplay',
default: 'clear display',
description: 'display nothing on the micro:bit display'
}),
blockType: BlockType.COMMAND
},
'---',
{
opcode: 'whenTilted',
text: formatMessage({
id: 'microbit.whenTilted',
default: 'when tilted [DIRECTION]',
description: 'when the micro:bit is tilted in a direction'
}),
blockType: BlockType.HAT,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirectionAny',
defaultValue: MicroBitTiltDirection.ANY
}
}
},
{
opcode: 'isTilted',
text: formatMessage({
id: 'microbit.isTilted',
default: 'tilted [DIRECTION]?',
description: 'is the micro:bit is tilted in a direction?'
}),
blockType: BlockType.BOOLEAN,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirectionAny',
defaultValue: MicroBitTiltDirection.ANY
}
}
},
{
opcode: 'getTiltAngle',
text: formatMessage({
id: 'microbit.tiltAngle',
default: 'tilt angle [DIRECTION]',
description: 'how much the micro:bit is tilted in a direction'
}),
blockType: BlockType.REPORTER,
arguments: {
DIRECTION: {
type: ArgumentType.STRING,
menu: 'tiltDirection',
defaultValue: MicroBitTiltDirection.FRONT
}
}
},
'---',
{
opcode: 'whenPinConnected',
text: formatMessage({
id: 'microbit.whenPinConnected',
default: 'when pin [PIN] connected',
description: 'when the pin detects a connection to Earth/Ground'
}),
blockType: BlockType.HAT,
arguments: {
PIN: {
type: ArgumentType.STRING,
menu: 'touchPins',
defaultValue: '0'
}
}
}
],
menus: {
buttons: {
acceptReporters: true,
items: this.BUTTONS_MENU
},
gestures: {
acceptReporters: true,
items: this.GESTURES_MENU
},
pinState: {
acceptReporters: true,
items: this.PIN_STATE_MENU
},
tiltDirection: {
acceptReporters: true,
items: this.TILT_DIRECTION_MENU
},
tiltDirectionAny: {
acceptReporters: true,
items: this.TILT_DIRECTION_ANY_MENU
},
touchPins: {
acceptReporters: true,
items: ['0', '1', '2']
}
}
};
}
/**
* Test whether the A or B button is pressed
* @param {object} args - the block's arguments.
* @return {boolean} - true if the button is pressed.
*/
whenButtonPressed (args) {
if (args.BTN === 'any') {
return this._peripheral.buttonA | this._peripheral.buttonB;
} else if (args.BTN === 'A') {
return this._peripheral.buttonA;
} else if (args.BTN === 'B') {
return this._peripheral.buttonB;
}
return false;
}
/**
* Test whether the A or B button is pressed
* @param {object} args - the block's arguments.
* @return {boolean} - true if the button is pressed.
*/
isButtonPressed (args) {
if (args.BTN === 'any') {
return (this._peripheral.buttonA | this._peripheral.buttonB) !== 0;
} else if (args.BTN === 'A') {
return this._peripheral.buttonA !== 0;
} else if (args.BTN === 'B') {
return this._peripheral.buttonB !== 0;
}
return false;
}
/**
* Test whether the micro:bit is moving
* @param {object} args - the block's arguments.
* @return {boolean} - true if the micro:bit is moving.
*/
whenGesture (args) {
const gesture = cast.toString(args.GESTURE);
if (gesture === 'moved') {
return (this._peripheral.gestureState >> 2) & 1;
} else if (gesture === 'shaken') {
return this._peripheral.gestureState & 1;
} else if (gesture === 'jumped') {
return (this._peripheral.gestureState >> 1) & 1;
}
return false;
}
/**
* Display a predefined symbol on the 5x5 LED matrix.
* @param {object} args - the block's arguments.
* @return {Promise} - a Promise that resolves after a tick.
*/
displaySymbol (args) {
const symbol = cast.toString(args.MATRIX).replace(/\s/g, '');
const reducer = (accumulator, c, index) => {
const value = (c === '0') ? accumulator : accumulator + Math.pow(2, index);
return value;
};
const hex = symbol.split('').reduce(reducer, 0);
if (hex !== null) {
this._peripheral.ledMatrixState[0] = hex & 0x1F;
this._peripheral.ledMatrixState[1] = (hex >> 5) & 0x1F;
this._peripheral.ledMatrixState[2] = (hex >> 10) & 0x1F;
this._peripheral.ledMatrixState[3] = (hex >> 15) & 0x1F;
this._peripheral.ledMatrixState[4] = (hex >> 20) & 0x1F;
this._peripheral.displayMatrix(this._peripheral.ledMatrixState);
}
return new Promise(resolve => {
setTimeout(() => {
resolve();
}, BLESendInterval);
});
}
/**
* Display text on the 5x5 LED matrix.
* @param {object} args - the block's arguments.
* @return {Promise} - a Promise that resolves after the text is done printing.
* Note the limit is 19 characters
* The print time is calculated by multiplying the number of horizontal pixels
* by the default scroll delay of 120ms.
* The number of horizontal pixels = 6px for each character in the string,
* 1px before the string, and 5px after the string.
*/
displayText (args) {
const text = String(args.TEXT).substring(0, 19);
if (text.length > 0) this._peripheral.displayText(text);
const yieldDelay = 120 * ((6 * text.length) + 6);
return new Promise(resolve => {
setTimeout(() => {
resolve();
}, yieldDelay);
});
}
/**
* Turn all 5x5 matrix LEDs off.
* @return {Promise} - a Promise that resolves after a tick.
*/
displayClear () {
for (let i = 0; i < 5; i++) {
this._peripheral.ledMatrixState[i] = 0;
}
this._peripheral.displayMatrix(this._peripheral.ledMatrixState);
return new Promise(resolve => {
setTimeout(() => {
resolve();
}, BLESendInterval);
});
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {object} args - the block's arguments.
* @property {TiltDirection} DIRECTION - the tilt direction to test (front, back, left, right, or any).
* @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction.
*/
whenTilted (args) {
return this._isTilted(args.DIRECTION);
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {object} args - the block's arguments.
* @property {TiltDirection} DIRECTION - the tilt direction to test (front, back, left, right, or any).
* @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction.
*/
isTilted (args) {
return this._isTilted(args.DIRECTION);
}
/**
* @param {object} args - the block's arguments.
* @property {TiltDirection} DIRECTION - the direction (front, back, left, right) to check.
* @return {number} - the tilt sensor's angle in the specified direction.
* Note that getTiltAngle(front) = -getTiltAngle(back) and getTiltAngle(left) = -getTiltAngle(right).
*/
getTiltAngle (args) {
return this._getTiltAngle(args.DIRECTION);
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {TiltDirection} direction - the tilt direction to test (front, back, left, right, or any).
* @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction.
* @private
*/
_isTilted (direction) {
switch (direction) {
case MicroBitTiltDirection.ANY:
return (Math.abs(this._peripheral.tiltX / 10) >= Scratch3MicroBitBlocks.TILT_THRESHOLD) ||
(Math.abs(this._peripheral.tiltY / 10) >= Scratch3MicroBitBlocks.TILT_THRESHOLD);
default:
return this._getTiltAngle(direction) >= Scratch3MicroBitBlocks.TILT_THRESHOLD;
}
}
/**
* @param {TiltDirection} direction - the direction (front, back, left, right) to check.
* @return {number} - the tilt sensor's angle in the specified direction.
* Note that getTiltAngle(front) = -getTiltAngle(back) and getTiltAngle(left) = -getTiltAngle(right).
* @private
*/
_getTiltAngle (direction) {
switch (direction) {
case MicroBitTiltDirection.FRONT:
return Math.round(this._peripheral.tiltY / -10);
case MicroBitTiltDirection.BACK:
return Math.round(this._peripheral.tiltY / 10);
case MicroBitTiltDirection.LEFT:
return Math.round(this._peripheral.tiltX / -10);
case MicroBitTiltDirection.RIGHT:
return Math.round(this._peripheral.tiltX / 10);
default:
log.warn(`Unknown tilt direction in _getTiltAngle: ${direction}`);
}
}
/**
* @param {object} args - the block's arguments.
* @return {boolean} - the touch pin state.
* @private
*/
whenPinConnected (args) {
const pin = parseInt(args.PIN, 10);
if (isNaN(pin)) return;
if (pin < 0 || pin > 2) return false;
return this._peripheral._checkPinState(pin);
}
}
module.exports = Scratch3MicroBitBlocks;

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