scratch-vm
Version:
Virtual Machine for Scratch 3.0
1,409 lines (1,259 loc) • 97.3 kB
JavaScript
const EventEmitter = require('events');
const {OrderedMap} = require('immutable');
const uuid = require('uuid');
const ArgumentType = require('../extension-support/argument-type');
const Blocks = require('./blocks');
const BlocksRuntimeCache = require('./blocks-runtime-cache');
const BlockType = require('../extension-support/block-type');
const Profiler = require('./profiler');
const Sequencer = require('./sequencer');
const execute = require('./execute.js');
const ScratchBlocksConstants = require('./scratch-blocks-constants');
const TargetType = require('../extension-support/target-type');
const Thread = require('./thread');
const log = require('../util/log');
const maybeFormatMessage = require('../util/maybe-format-message');
const StageLayering = require('./stage-layering');
const Variable = require('./variable');
const xmlEscape = require('../util/xml-escape');
const ScratchLinkWebSocket = require('../util/scratch-link-websocket');
const fetchWithTimeout = require('../util/fetch-with-timeout');
// Virtual I/O devices.
const Clock = require('../io/clock');
const Cloud = require('../io/cloud');
const Keyboard = require('../io/keyboard');
const Mouse = require('../io/mouse');
const MouseWheel = require('../io/mouseWheel');
const UserData = require('../io/userData');
const Video = require('../io/video');
const StringUtil = require('../util/string-util');
const uid = require('../util/uid');
const defaultBlockPackages = {
scratch3_control: require('../blocks/scratch3_control'),
scratch3_event: require('../blocks/scratch3_event'),
scratch3_looks: require('../blocks/scratch3_looks'),
scratch3_motion: require('../blocks/scratch3_motion'),
scratch3_operators: require('../blocks/scratch3_operators'),
scratch3_sound: require('../blocks/scratch3_sound'),
scratch3_sensing: require('../blocks/scratch3_sensing'),
scratch3_data: require('../blocks/scratch3_data'),
scratch3_procedures: require('../blocks/scratch3_procedures')
};
const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69'];
/**
* Information used for converting Scratch argument types into scratch-blocks data.
* @type {object.<ArgumentType, {shadowType: string, fieldType: string}>}
*/
const ArgumentTypeMap = (() => {
const map = {};
map[ArgumentType.ANGLE] = {
shadow: {
type: 'math_angle',
// We specify fieldNames here so that we can pick
// create and populate a field with the defaultValue
// specified in the extension.
// When the `fieldName` property is not specified,
// the <field></field> will be left out of the XML and
// the scratch-blocks defaults for that field will be
// used instead (e.g. default of 0 for number fields)
fieldName: 'NUM'
}
};
map[ArgumentType.COLOR] = {
shadow: {
type: 'colour_picker',
fieldName: 'COLOUR'
}
};
map[ArgumentType.NUMBER] = {
shadow: {
type: 'math_number',
fieldName: 'NUM'
}
};
map[ArgumentType.STRING] = {
shadow: {
type: 'text',
fieldName: 'TEXT'
}
};
map[ArgumentType.BOOLEAN] = {
check: 'Boolean'
};
map[ArgumentType.MATRIX] = {
shadow: {
type: 'matrix',
fieldName: 'MATRIX'
}
};
map[ArgumentType.NOTE] = {
shadow: {
type: 'note',
fieldName: 'NOTE'
}
};
map[ArgumentType.IMAGE] = {
// Inline images are weird because they're not actually "arguments".
// They are more analagous to the label on a block.
fieldType: 'field_image'
};
return map;
})();
/**
* A pair of functions used to manage the cloud variable limit,
* to be used when adding (or attempting to add) or removing a cloud variable.
* @typedef {object} CloudDataManager
* @property {function} canAddCloudVariable A function to call to check that
* a cloud variable can be added.
* @property {function} addCloudVariable A function to call to track a new
* cloud variable on the runtime.
* @property {function} removeCloudVariable A function to call when
* removing an existing cloud variable.
* @property {function} hasCloudVariables A function to call to check that
* the runtime has any cloud variables.
*/
/**
* Creates and manages cloud variable limit in a project,
* and returns two functions to be used to add a new
* cloud variable (while checking that it can be added)
* and remove an existing cloud variable.
* These are to be called whenever attempting to create or delete
* a cloud variable.
* @return {CloudDataManager} The functions to be used when adding or removing a
* cloud variable.
*/
const cloudDataManager = () => {
const limit = 10;
let count = 0;
const canAddCloudVariable = () => count < limit;
const addCloudVariable = () => {
count++;
};
const removeCloudVariable = () => {
count--;
};
const hasCloudVariables = () => count > 0;
return {
canAddCloudVariable,
addCloudVariable,
removeCloudVariable,
hasCloudVariables
};
};
/**
* Numeric ID for Runtime._step in Profiler instances.
* @type {number}
*/
let stepProfilerId = -1;
/**
* Numeric ID for Sequencer.stepThreads in Profiler instances.
* @type {number}
*/
let stepThreadsProfilerId = -1;
/**
* Numeric ID for RenderWebGL.draw in Profiler instances.
* @type {number}
*/
let rendererDrawProfilerId = -1;
/**
* Manages targets, scripts, and the sequencer.
* @constructor
*/
class Runtime extends EventEmitter {
constructor () {
super();
/**
* Target management and storage.
* @type {Array.<!Target>}
*/
this.targets = [];
/**
* Targets in reverse order of execution. Shares its order with drawables.
* @type {Array.<!Target>}
*/
this.executableTargets = [];
/**
* A list of threads that are currently running in the VM.
* Threads are added when execution starts and pruned when execution ends.
* @type {Array.<Thread>}
*/
this.threads = [];
/** @type {!Sequencer} */
this.sequencer = new Sequencer(this);
/**
* Storage container for flyout blocks.
* These will execute on `_editingTarget.`
* @type {!Blocks}
*/
this.flyoutBlocks = new Blocks(this, true /* force no glow */);
/**
* Storage container for monitor blocks.
* These will execute on a target maybe
* @type {!Blocks}
*/
this.monitorBlocks = new Blocks(this, true /* force no glow */);
/**
* Currently known editing target for the VM.
* @type {?Target}
*/
this._editingTarget = null;
/**
* Map to look up a block primitive's implementation function by its opcode.
* This is a two-step lookup: package name first, then primitive name.
* @type {Object.<string, Function>}
*/
this._primitives = {};
/**
* Map to look up all block information by extended opcode.
* @type {Array.<CategoryInfo>}
* @private
*/
this._blockInfo = [];
/**
* Map to look up hat blocks' metadata.
* Keys are opcode for hat, values are metadata objects.
* @type {Object.<string, Object>}
*/
this._hats = {};
/**
* A list of script block IDs that were glowing during the previous frame.
* @type {!Array.<!string>}
*/
this._scriptGlowsPreviousFrame = [];
/**
* Number of non-monitor threads running during the previous frame.
* @type {number}
*/
this._nonMonitorThreadCount = 0;
/**
* All threads that finished running and were removed from this.threads
* by behaviour in Sequencer.stepThreads.
* @type {Array<Thread>}
*/
this._lastStepDoneThreads = null;
/**
* Currently known number of clones, used to enforce clone limit.
* @type {number}
*/
this._cloneCounter = 0;
/**
* Flag to emit a targets update at the end of a step. When target data
* changes, this flag is set to true.
* @type {boolean}
*/
this._refreshTargets = false;
/**
* Map to look up all monitor block information by opcode.
* @type {object}
* @private
*/
this.monitorBlockInfo = {};
/**
* Ordered map of all monitors, which are MonitorReporter objects.
*/
this._monitorState = OrderedMap({});
/**
* Monitor state from last tick
*/
this._prevMonitorState = OrderedMap({});
/**
* Whether the project is in "turbo mode."
* @type {Boolean}
*/
this.turboMode = false;
/**
* Whether the project is in "compatibility mode" (30 TPS).
* @type {Boolean}
*/
this.compatibilityMode = false;
/**
* A reference to the current runtime stepping interval, set
* by a `setInterval`.
* @type {!number}
*/
this._steppingInterval = null;
/**
* Current length of a step.
* Changes as mode switches, and used by the sequencer to calculate
* WORK_TIME.
* @type {!number}
*/
this.currentStepTime = null;
// Set an intial value for this.currentMSecs
this.updateCurrentMSecs();
/**
* Whether any primitive has requested a redraw.
* Affects whether `Sequencer.stepThreads` will yield
* after stepping each thread.
* Reset on every frame.
* @type {boolean}
*/
this.redrawRequested = false;
// Register all given block packages.
this._registerBlockPackages();
// Register and initialize "IO devices", containers for processing
// I/O related data.
/** @type {Object.<string, Object>} */
this.ioDevices = {
clock: new Clock(this),
cloud: new Cloud(this),
keyboard: new Keyboard(this),
mouse: new Mouse(this),
mouseWheel: new MouseWheel(this),
userData: new UserData(),
video: new Video(this)
};
/**
* A list of extensions, used to manage hardware connection.
*/
this.peripheralExtensions = {};
/**
* A runtime profiler that records timed events for later playback to
* diagnose Scratch performance.
* @type {Profiler}
*/
this.profiler = null;
const newCloudDataManager = cloudDataManager();
/**
* Check wether the runtime has any cloud data.
* @type {function}
* @return {boolean} Whether or not the runtime currently has any
* cloud variables.
*/
this.hasCloudData = newCloudDataManager.hasCloudVariables;
/**
* A function which checks whether a new cloud variable can be added
* to the runtime.
* @type {function}
* @return {boolean} Whether or not a new cloud variable can be added
* to the runtime.
*/
this.canAddCloudVariable = newCloudDataManager.canAddCloudVariable;
/**
* A function that tracks a new cloud variable in the runtime,
* updating the cloud variable limit. Calling this function will
* emit a cloud data update event if this is the first cloud variable
* being added.
* @type {function}
*/
this.addCloudVariable = this._initializeAddCloudVariable(newCloudDataManager);
/**
* A function which updates the runtime's cloud variable limit
* when removing a cloud variable and emits a cloud update event
* if the last of the cloud variables is being removed.
* @type {function}
*/
this.removeCloudVariable = this._initializeRemoveCloudVariable(newCloudDataManager);
/**
* A string representing the origin of the current project from outside of the
* Scratch community, such as CSFirst.
* @type {?string}
*/
this.origin = null;
this._initScratchLink();
this.resetRunId();
}
/**
* Width of the stage, in pixels.
* @const {number}
*/
static get STAGE_WIDTH () {
return 480;
}
/**
* Height of the stage, in pixels.
* @const {number}
*/
static get STAGE_HEIGHT () {
return 360;
}
/**
* Event name for glowing a script.
* @const {string}
*/
static get SCRIPT_GLOW_ON () {
return 'SCRIPT_GLOW_ON';
}
/**
* Event name for unglowing a script.
* @const {string}
*/
static get SCRIPT_GLOW_OFF () {
return 'SCRIPT_GLOW_OFF';
}
/**
* Event name for glowing a block.
* @const {string}
*/
static get BLOCK_GLOW_ON () {
return 'BLOCK_GLOW_ON';
}
/**
* Event name for unglowing a block.
* @const {string}
*/
static get BLOCK_GLOW_OFF () {
return 'BLOCK_GLOW_OFF';
}
/**
* Event name for a cloud data update
* to this project.
* @const {string}
*/
static get HAS_CLOUD_DATA_UPDATE () {
return 'HAS_CLOUD_DATA_UPDATE';
}
/**
* Event name for turning on turbo mode.
* @const {string}
*/
static get TURBO_MODE_ON () {
return 'TURBO_MODE_ON';
}
/**
* Event name for turning off turbo mode.
* @const {string}
*/
static get TURBO_MODE_OFF () {
return 'TURBO_MODE_OFF';
}
/**
* Event name when the project is started (threads may not necessarily be
* running).
* @const {string}
*/
static get PROJECT_START () {
return 'PROJECT_START';
}
/**
* Event name when threads start running.
* Used by the UI to indicate running status.
* @const {string}
*/
static get PROJECT_RUN_START () {
return 'PROJECT_RUN_START';
}
/**
* Event name when threads stop running
* Used by the UI to indicate not-running status.
* @const {string}
*/
static get PROJECT_RUN_STOP () {
return 'PROJECT_RUN_STOP';
}
/**
* Event name for project being stopped or restarted by the user.
* Used by blocks that need to reset state.
* @const {string}
*/
static get PROJECT_STOP_ALL () {
return 'PROJECT_STOP_ALL';
}
/**
* Event name for target being stopped by a stop for target call.
* Used by blocks that need to stop individual targets.
* @const {string}
*/
static get STOP_FOR_TARGET () {
return 'STOP_FOR_TARGET';
}
/**
* Event name for visual value report.
* @const {string}
*/
static get VISUAL_REPORT () {
return 'VISUAL_REPORT';
}
/**
* Event name for project loaded report.
* @const {string}
*/
static get PROJECT_LOADED () {
return 'PROJECT_LOADED';
}
/**
* Event name for report that a change was made that can be saved
* @const {string}
*/
static get PROJECT_CHANGED () {
return 'PROJECT_CHANGED';
}
/**
* Event name for report that a change was made to an extension in the toolbox.
* @const {string}
*/
static get TOOLBOX_EXTENSIONS_NEED_UPDATE () {
return 'TOOLBOX_EXTENSIONS_NEED_UPDATE';
}
/**
* Event name for targets update report.
* @const {string}
*/
static get TARGETS_UPDATE () {
return 'TARGETS_UPDATE';
}
/**
* Event name for monitors update.
* @const {string}
*/
static get MONITORS_UPDATE () {
return 'MONITORS_UPDATE';
}
/**
* Event name for block drag update.
* @const {string}
*/
static get BLOCK_DRAG_UPDATE () {
return 'BLOCK_DRAG_UPDATE';
}
/**
* Event name for block drag end.
* @const {string}
*/
static get BLOCK_DRAG_END () {
return 'BLOCK_DRAG_END';
}
/**
* Event name for reporting that an extension was added.
* @const {string}
*/
static get EXTENSION_ADDED () {
return 'EXTENSION_ADDED';
}
/**
* Event name for reporting that an extension as asked for a custom field to be added
* @const {string}
*/
static get EXTENSION_FIELD_ADDED () {
return 'EXTENSION_FIELD_ADDED';
}
/**
* Event name for updating the available set of peripheral devices.
* This causes the peripheral connection modal to update a list of
* available peripherals.
* @const {string}
*/
static get PERIPHERAL_LIST_UPDATE () {
return 'PERIPHERAL_LIST_UPDATE';
}
/**
* Event name for when the user picks a bluetooth device to connect to
* via Companion Device Manager (CDM)
* @const {string}
*/
static get USER_PICKED_PERIPHERAL () {
return 'USER_PICKED_PERIPHERAL';
}
/**
* Event name for reporting that a peripheral has connected.
* This causes the status button in the blocks menu to indicate 'connected'.
* @const {string}
*/
static get PERIPHERAL_CONNECTED () {
return 'PERIPHERAL_CONNECTED';
}
/**
* Event name for reporting that a peripheral has been intentionally disconnected.
* This causes the status button in the blocks menu to indicate 'disconnected'.
* @const {string}
*/
static get PERIPHERAL_DISCONNECTED () {
return 'PERIPHERAL_DISCONNECTED';
}
/**
* Event name for reporting that a peripheral has encountered a request error.
* This causes the peripheral connection modal to switch to an error state.
* @const {string}
*/
static get PERIPHERAL_REQUEST_ERROR () {
return 'PERIPHERAL_REQUEST_ERROR';
}
/**
* Event name for reporting that a peripheral connection has been lost.
* This causes a 'peripheral connection lost' error alert to display.
* @const {string}
*/
static get PERIPHERAL_CONNECTION_LOST_ERROR () {
return 'PERIPHERAL_CONNECTION_LOST_ERROR';
}
/**
* Event name for reporting that a peripheral has not been discovered.
* This causes the peripheral connection modal to show a timeout state.
* @const {string}
*/
static get PERIPHERAL_SCAN_TIMEOUT () {
return 'PERIPHERAL_SCAN_TIMEOUT';
}
/**
* Event name to indicate that the microphone is being used to stream audio.
* @const {string}
*/
static get MIC_LISTENING () {
return 'MIC_LISTENING';
}
/**
* Event name for reporting that blocksInfo was updated.
* @const {string}
*/
static get BLOCKSINFO_UPDATE () {
return 'BLOCKSINFO_UPDATE';
}
/**
* Event name when the runtime tick loop has been started.
* @const {string}
*/
static get RUNTIME_STARTED () {
return 'RUNTIME_STARTED';
}
/**
* Event name when the runtime dispose has been called.
* @const {string}
*/
static get RUNTIME_DISPOSED () {
return 'RUNTIME_DISPOSED';
}
/**
* Event name for reporting that a block was updated and needs to be rerendered.
* @const {string}
*/
static get BLOCKS_NEED_UPDATE () {
return 'BLOCKS_NEED_UPDATE';
}
/**
* How rapidly we try to step threads by default, in ms.
*/
static get THREAD_STEP_INTERVAL () {
return 1000 / 60;
}
/**
* In compatibility mode, how rapidly we try to step threads, in ms.
*/
static get THREAD_STEP_INTERVAL_COMPATIBILITY () {
return 1000 / 30;
}
/**
* How many clones can be created at a time.
* @const {number}
*/
static get MAX_CLONES () {
return 300;
}
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Helper function for initializing the addCloudVariable function
_initializeAddCloudVariable (newCloudDataManager) {
// The addCloudVariable function
return (() => {
const hadCloudVarsBefore = this.hasCloudData();
newCloudDataManager.addCloudVariable();
if (!hadCloudVarsBefore && this.hasCloudData()) {
this.emit(Runtime.HAS_CLOUD_DATA_UPDATE, true);
}
});
}
// Helper function for initializing the removeCloudVariable function
_initializeRemoveCloudVariable (newCloudDataManager) {
return (() => {
const hadCloudVarsBefore = this.hasCloudData();
newCloudDataManager.removeCloudVariable();
if (hadCloudVarsBefore && !this.hasCloudData()) {
this.emit(Runtime.HAS_CLOUD_DATA_UPDATE, false);
}
});
}
/**
* Register default block packages with this runtime.
* @todo Prefix opcodes with package name.
* @private
*/
_registerBlockPackages () {
for (const packageName in defaultBlockPackages) {
if (defaultBlockPackages.hasOwnProperty(packageName)) {
// @todo pass a different runtime depending on package privilege?
const packageObject = new (defaultBlockPackages[packageName])(this);
// Collect primitives from package.
if (packageObject.getPrimitives) {
const packagePrimitives = packageObject.getPrimitives();
for (const op in packagePrimitives) {
if (packagePrimitives.hasOwnProperty(op)) {
this._primitives[op] =
packagePrimitives[op].bind(packageObject);
}
}
}
// Collect hat metadata from package.
if (packageObject.getHats) {
const packageHats = packageObject.getHats();
for (const hatName in packageHats) {
if (packageHats.hasOwnProperty(hatName)) {
this._hats[hatName] = packageHats[hatName];
}
}
}
// Collect monitored from package.
if (packageObject.getMonitored) {
this.monitorBlockInfo = Object.assign({}, this.monitorBlockInfo, packageObject.getMonitored());
}
}
}
}
getMonitorState () {
return this._monitorState;
}
/**
* Generate an extension-specific menu ID.
* @param {string} menuName - the name of the menu.
* @param {string} extensionId - the ID of the extension hosting the menu.
* @returns {string} - the constructed ID.
* @private
*/
_makeExtensionMenuId (menuName, extensionId) {
return `${extensionId}_menu_${xmlEscape(menuName)}`;
}
/**
* Create a context ("args") object for use with `formatMessage` on messages which might be target-specific.
* @param {Target} [target] - the target to use as context. If a target is not provided, default to the current
* editing target or the stage.
*/
makeMessageContextForTarget (target) {
const context = {};
target = target || this.getEditingTarget() || this.getTargetForStage();
if (target) {
context.targetType = (target.isStage ? TargetType.STAGE : TargetType.SPRITE);
}
}
/**
* Register the primitives provided by an extension.
* @param {ExtensionMetadata} extensionInfo - information about the extension (id, blocks, etc.)
* @private
*/
_registerExtensionPrimitives (extensionInfo) {
const categoryInfo = {
id: extensionInfo.id,
name: maybeFormatMessage(extensionInfo.name),
showStatusButton: extensionInfo.showStatusButton,
blockIconURI: extensionInfo.blockIconURI,
menuIconURI: extensionInfo.menuIconURI
};
if (extensionInfo.color1) {
categoryInfo.color1 = extensionInfo.color1;
categoryInfo.color2 = extensionInfo.color2;
categoryInfo.color3 = extensionInfo.color3;
} else {
categoryInfo.color1 = defaultExtensionColors[0];
categoryInfo.color2 = defaultExtensionColors[1];
categoryInfo.color3 = defaultExtensionColors[2];
}
this._blockInfo.push(categoryInfo);
this._fillExtensionCategory(categoryInfo, extensionInfo);
for (const fieldTypeName in categoryInfo.customFieldTypes) {
if (extensionInfo.customFieldTypes.hasOwnProperty(fieldTypeName)) {
const fieldTypeInfo = categoryInfo.customFieldTypes[fieldTypeName];
// Emit events for custom field types from extension
this.emit(Runtime.EXTENSION_FIELD_ADDED, {
name: `field_${fieldTypeInfo.extendedName}`,
implementation: fieldTypeInfo.fieldImplementation
});
}
}
this.emit(Runtime.EXTENSION_ADDED, categoryInfo);
}
/**
* Reregister the primitives for an extension
* @param {ExtensionMetadata} extensionInfo - new info (results of running getInfo) for an extension
* @private
*/
_refreshExtensionPrimitives (extensionInfo) {
const categoryInfo = this._blockInfo.find(info => info.id === extensionInfo.id);
if (categoryInfo) {
categoryInfo.name = maybeFormatMessage(extensionInfo.name);
this._fillExtensionCategory(categoryInfo, extensionInfo);
this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo);
}
}
/**
* Read extension information, convert menus, blocks and custom field types
* and store the results in the provided category object.
* @param {CategoryInfo} categoryInfo - the category to be filled
* @param {ExtensionMetadata} extensionInfo - the extension metadata to read
* @private
*/
_fillExtensionCategory (categoryInfo, extensionInfo) {
categoryInfo.blocks = [];
categoryInfo.customFieldTypes = {};
categoryInfo.menus = [];
categoryInfo.menuInfo = {};
for (const menuName in extensionInfo.menus) {
if (extensionInfo.menus.hasOwnProperty(menuName)) {
const menuInfo = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuInfo, categoryInfo);
categoryInfo.menus.push(convertedMenu);
categoryInfo.menuInfo[menuName] = menuInfo;
}
}
for (const fieldTypeName in extensionInfo.customFieldTypes) {
if (extensionInfo.customFieldTypes.hasOwnProperty(fieldTypeName)) {
const fieldType = extensionInfo.customFieldTypes[fieldTypeName];
const fieldTypeInfo = this._buildCustomFieldInfo(
fieldTypeName,
fieldType,
extensionInfo.id,
categoryInfo
);
categoryInfo.customFieldTypes[fieldTypeName] = fieldTypeInfo;
}
}
for (const blockInfo of extensionInfo.blocks) {
try {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
categoryInfo.blocks.push(convertedBlock);
if (convertedBlock.json) {
const opcode = convertedBlock.json.type;
if (blockInfo.blockType !== BlockType.EVENT) {
this._primitives[opcode] = convertedBlock.info.func;
}
if (blockInfo.blockType === BlockType.EVENT || blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {
edgeActivated: blockInfo.isEdgeActivated,
restartExistingThreads: blockInfo.shouldRestartExistingThreads
};
}
}
} catch (e) {
log.error('Error parsing block: ', {block: blockInfo, error: e});
}
}
}
/**
* Convert the given extension menu items into the scratch-blocks style of list of pairs.
* If the menu is dynamic (e.g. the passed in argument is a function), return the input unmodified.
* @param {object} menuItems - an array of menu items or a function to retrieve such an array
* @returns {object} - an array of 2 element arrays or the original input function
* @private
*/
_convertMenuItems (menuItems) {
if (typeof menuItems !== 'function') {
const extensionMessageContext = this.makeMessageContextForTarget();
return menuItems.map(item => {
const formattedItem = maybeFormatMessage(item, extensionMessageContext);
switch (typeof formattedItem) {
case 'string':
return [formattedItem, formattedItem];
case 'object':
return [maybeFormatMessage(item.text, extensionMessageContext), item.value];
default:
throw new Error(`Can't interpret menu item: ${JSON.stringify(item)}`);
}
});
}
return menuItems;
}
/**
* Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block.
* @param {string} menuName - the name of the menu
* @param {object} menuInfo - a description of this menu and its items
* @property {*} items - an array of menu items or a function to retrieve such an array
* @property {boolean} [acceptReporters] - if true, allow dropping reporters onto this menu
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {object} - a JSON-esque object ready for scratch-blocks' consumption
* @private
*/
_buildMenuForScratchBlocks (menuName, menuInfo, categoryInfo) {
const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id);
const menuItems = this._convertMenuItems(menuInfo.items);
return {
json: {
message0: '%1',
type: menuId,
inputsInline: true,
output: 'String',
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
outputShape: menuInfo.acceptReporters ?
ScratchBlocksConstants.OUTPUT_SHAPE_ROUND : ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE,
args0: [
{
type: 'field_dropdown',
name: menuName,
options: menuItems
}
]
}
};
}
_buildCustomFieldInfo (fieldName, fieldInfo, extensionId, categoryInfo) {
const extendedName = `${extensionId}_${fieldName}`;
return {
fieldName: fieldName,
extendedName: extendedName,
argumentTypeInfo: {
shadow: {
type: extendedName,
fieldName: `field_${extendedName}`
}
},
scratchBlocksDefinition: this._buildCustomFieldTypeForScratchBlocks(
extendedName,
fieldInfo.output,
fieldInfo.outputShape,
categoryInfo
),
fieldImplementation: fieldInfo.implementation
};
}
/**
* Build the scratch-blocks JSON needed for a fieldType.
* Custom field types need to be namespaced to the extension so that extensions can't interfere with each other
* @param {string} fieldName - The name of the field
* @param {string} output - The output of the field
* @param {number} outputShape - Shape of the field (from ScratchBlocksConstants)
* @param {object} categoryInfo - The category the field belongs to (Used to set its colors)
* @returns {object} - Object to be inserted into scratch-blocks
*/
_buildCustomFieldTypeForScratchBlocks (fieldName, output, outputShape, categoryInfo) {
return {
json: {
type: fieldName,
message0: '%1',
inputsInline: true,
output: output,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
outputShape: outputShape,
args0: [
{
name: `field_${fieldName}`,
type: `field_${fieldName}`
}
]
}
};
}
/**
* Convert ExtensionBlockMetadata into data ready for scratch-blocks.
* @param {ExtensionBlockMetadata} blockInfo - the block info to convert
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {ConvertedBlockInfo} - the converted & original block information
* @private
*/
_convertForScratchBlocks (blockInfo, categoryInfo) {
if (blockInfo === '---') {
return this._convertSeparatorForScratchBlocks(blockInfo);
}
if (blockInfo.blockType === BlockType.BUTTON) {
return this._convertButtonForScratchBlocks(blockInfo);
}
return this._convertBlockForScratchBlocks(blockInfo, categoryInfo);
}
/**
* Convert ExtensionBlockMetadata into scratch-blocks JSON & XML, and generate a proxy function.
* @param {ExtensionBlockMetadata} blockInfo - the block to convert
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {ConvertedBlockInfo} - the converted & original block information
* @private
*/
_convertBlockForScratchBlocks (blockInfo, categoryInfo) {
const extendedOpcode = `${categoryInfo.id}_${blockInfo.opcode}`;
const blockJSON = {
type: extendedOpcode,
inputsInline: true,
category: categoryInfo.name,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3
};
const context = {
// TODO: store this somewhere so that we can map args appropriately after translation.
// This maps an arg name to its relative position in the original (usually English) block text.
// When displaying a block in another language we'll need to run a `replace` action similar to the one
// below, but each `[ARG]` will need to be replaced with the number in this map.
argsMap: {},
blockJSON,
categoryInfo,
blockInfo,
inputList: []
};
// If an icon for the extension exists, prepend it to each block, with a vertical separator.
// We can overspecify an icon for each block, but if no icon exists on a block, fall back to
// the category block icon.
const iconURI = blockInfo.blockIconURI || categoryInfo.blockIconURI;
if (iconURI) {
blockJSON.extensions = ['scratch_extension'];
blockJSON.message0 = '%1 %2';
const iconJSON = {
type: 'field_image',
src: iconURI,
width: 40,
height: 40
};
const separatorJSON = {
type: 'field_vertical_separator'
};
blockJSON.args0 = [
iconJSON,
separatorJSON
];
}
switch (blockInfo.blockType) {
case BlockType.COMMAND:
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.previousStatement = null; // null = available connection; undefined = hat
if (!blockInfo.isTerminal) {
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
}
break;
case BlockType.REPORTER:
blockJSON.output = 'String'; // TODO: distinguish number & string here?
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_ROUND;
break;
case BlockType.BOOLEAN:
blockJSON.output = 'Boolean';
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_HEXAGONAL;
break;
case BlockType.HAT:
case BlockType.EVENT:
if (!blockInfo.hasOwnProperty('isEdgeActivated')) {
// if absent, this property defaults to true
blockInfo.isEdgeActivated = true;
}
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
break;
case BlockType.CONDITIONAL:
case BlockType.LOOP:
blockInfo.branchCount = blockInfo.branchCount || 1;
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.previousStatement = null; // null = available connection; undefined = hat
if (!blockInfo.isTerminal) {
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
}
break;
}
const blockText = Array.isArray(blockInfo.text) ? blockInfo.text : [blockInfo.text];
let inTextNum = 0; // text for the next block "arm" is blockText[inTextNum]
let inBranchNum = 0; // how many branches have we placed into the JSON so far?
let outLineNum = 0; // used for scratch-blocks `message${outLineNum}` and `args${outLineNum}`
const convertPlaceholders = this._convertPlaceholders.bind(this, context);
const extensionMessageContext = this.makeMessageContextForTarget();
// alternate between a block "arm" with text on it and an open slot for a substack
while (inTextNum < blockText.length || inBranchNum < blockInfo.branchCount) {
if (inTextNum < blockText.length) {
context.outLineNum = outLineNum;
const lineText = maybeFormatMessage(blockText[inTextNum], extensionMessageContext);
const convertedText = lineText.replace(/\[(.+?)]/g, convertPlaceholders);
if (blockJSON[`message${outLineNum}`]) {
blockJSON[`message${outLineNum}`] += convertedText;
} else {
blockJSON[`message${outLineNum}`] = convertedText;
}
++inTextNum;
++outLineNum;
}
if (inBranchNum < blockInfo.branchCount) {
blockJSON[`message${outLineNum}`] = '%1';
blockJSON[`args${outLineNum}`] = [{
type: 'input_statement',
name: `SUBSTACK${inBranchNum > 0 ? inBranchNum + 1 : ''}`
}];
++inBranchNum;
++outLineNum;
}
}
if (blockInfo.blockType === BlockType.REPORTER) {
if (!blockInfo.disableMonitor && context.inputList.length === 0) {
blockJSON.checkboxInFlyout = true;
}
} else if (blockInfo.blockType === BlockType.LOOP) {
// Add icon to the bottom right of a loop block
blockJSON[`lastDummyAlign${outLineNum}`] = 'RIGHT';
blockJSON[`message${outLineNum}`] = '%1';
blockJSON[`args${outLineNum}`] = [{
type: 'field_image',
src: './static/blocks-media/repeat.svg', // TODO: use a constant or make this configurable?
width: 24,
height: 24,
alt: '*', // TODO remove this since we don't use collapsed blocks in scratch
flip_rtl: true
}];
++outLineNum;
}
const mutation = blockInfo.isDynamic ? `<mutation blockInfo="${xmlEscape(JSON.stringify(blockInfo))}"/>` : '';
const inputs = context.inputList.join('');
const blockXML = `<block type="${extendedOpcode}">${mutation}${inputs}</block>`;
return {
info: context.blockInfo,
json: context.blockJSON,
xml: blockXML
};
}
/**
* Generate a separator between blocks categories or sub-categories.
* @param {ExtensionBlockMetadata} blockInfo - the block to convert
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {ConvertedBlockInfo} - the converted & original block information
* @private
*/
_convertSeparatorForScratchBlocks (blockInfo) {
return {
info: blockInfo,
xml: '<sep gap="36"/>'
};
}
/**
* Convert a button for scratch-blocks. A button has no opcode but specifies a callback name in the `func` field.
* @param {ExtensionBlockMetadata} buttonInfo - the button to convert
* @property {string} func - the callback name
* @param {CategoryInfo} categoryInfo - the category for this button
* @returns {ConvertedBlockInfo} - the converted & original button information
* @private
*/
_convertButtonForScratchBlocks (buttonInfo) {
// for now we only support these pre-defined callbacks handled in scratch-blocks
const supportedCallbackKeys = ['MAKE_A_LIST', 'MAKE_A_PROCEDURE', 'MAKE_A_VARIABLE'];
if (supportedCallbackKeys.indexOf(buttonInfo.func) < 0) {
log.error(`Custom button callbacks not supported yet: ${buttonInfo.func}`);
}
const extensionMessageContext = this.makeMessageContextForTarget();
const buttonText = maybeFormatMessage(buttonInfo.text, extensionMessageContext);
return {
info: buttonInfo,
xml: `<button text="${buttonText}" callbackKey="${buttonInfo.func}"></button>`
};
}
/**
* Helper for _convertPlaceholdes which handles inline images which are a specialized case of block "arguments".
* @param {object} argInfo Metadata about the inline image as specified by the extension
* @return {object} JSON blob for a scratch-blocks image field.
* @private
*/
_constructInlineImageJson (argInfo) {
if (!argInfo.dataURI) {
log.warn('Missing data URI in extension block with argument type IMAGE');
}
return {
type: 'field_image',
src: argInfo.dataURI || '',
// TODO these probably shouldn't be hardcoded...?
width: 24,
height: 24,
// Whether or not the inline image should be flipped horizontally
// in RTL languages. Defaults to false, indicating that the
// image will not be flipped.
flip_rtl: argInfo.flipRTL || false
};
}
/**
* Helper for _convertForScratchBlocks which handles linearization of argument placeholders. Called as a callback
* from string#replace. In addition to the return value the JSON and XML items in the context will be filled.
* @param {object} context - information shared with _convertForScratchBlocks about the block, etc.
* @param {string} match - the overall string matched by the placeholder regex, including brackets: '[FOO]'.
* @param {string} placeholder - the name of the placeholder being matched: 'FOO'.
* @return {string} scratch-blocks placeholder for the argument: '%1'.
* @private
*/
_convertPlaceholders (context, match, placeholder) {
// Sanitize the placeholder to ensure valid XML
placeholder = placeholder.replace(/[<"&]/, '_');
// Determine whether the argument type is one of the known standard field types
const argInfo = context.blockInfo.arguments[placeholder] || {};
let argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
// Field type not a standard field type, see if extension has registered custom field type
if (!ArgumentTypeMap[argInfo.type] && context.categoryInfo.customFieldTypes[argInfo.type]) {
argTypeInfo = context.categoryInfo.customFieldTypes[argInfo.type].argumentTypeInfo;
}
// Start to construct the scratch-blocks style JSON defining how the block should be
// laid out
let argJSON;
// Most field types are inputs (slots on the block that can have other blocks plugged into them)
// check if this is not one of those cases. E.g. an inline image on a block.
if (argTypeInfo.fieldType === 'field_image') {
argJSON = this._constructInlineImageJson(argInfo);
} else {
// Construct input value
// Layout a block argument (e.g. an input slot on the block)
argJSON = {
type: 'input_value',
name: placeholder
};
const defaultValue =
typeof argInfo.defaultValue === 'undefined' ? '' :
xmlEscape(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString());
if (argTypeInfo.check) {
// Right now the only type of 'check' we have specifies that the
// input slot on the block accepts Boolean reporters, so it should be
// shaped like a hexagon
argJSON.check = argTypeInfo.check;
}
let valueName;
let shadowType;
let fieldName;
if (argInfo.menu) {
const menuInfo = context.categoryInfo.menuInfo[argInfo.menu];
if (menuInfo.acceptReporters) {
valueName = placeholder;
shadowType = this._makeExtensionMenuId(argInfo.menu, context.categoryInfo.id);
fieldName = argInfo.menu;
} else {
argJSON.type = 'field_dropdown';
argJSON.options = this._convertMenuItems(menuInfo.items);
valueName = null;
shadowType = null;
fieldName = placeholder;
}
} else {
valueName = placeholder;
shadowType = (argTypeInfo.shadow && argTypeInfo.shadow.type) || null;
fieldName = (argTypeInfo.shadow && argTypeInfo.shadow.fieldName) || null;
}
// <value> is the ScratchBlocks name for a block input.
if (valueName) {
context.inputList.push(`<value name="${placeholder}">`);
}
// The <shadow> is a placeholder for a reporter and is visible when there's no reporter in this input.
// Boolean inputs don't need to specify a shadow in the XML.
if (shadowType) {
context.inputList.push(`<shadow type="${shadowType}">`);
}
// A <field> displays a dynamic value: a user-editable text field, a drop-down menu, etc.
// Leave out the field if defaultValue or fieldName are not specified
if (defaultValue && fieldName) {
context.inputList.push(`<field name="${fieldName}">${defaultValue}</field>`);
}
if (shadowType) {
context.inputList.push('</shadow>');
}
if (valueName) {
context.inputList.push('</value>');
}
}
const argsName = `args${context.outLineNum}`;
const blockArgs = (context.blockJSON[argsName] = context.blockJSON[argsName] || []);
if (argJSON) blockArgs.push(argJSON);
const argNum = blockArgs.length;
context.argsMap[placeholder] = argNum;
return `%${argNum}`;
}
/**
* @returns {Array.<object>} scratch-blocks XML for each category of extension blocks, in category order.
* @param {?Target} [target] - the active editing target (optional)
* @property {string} id - the category / extension ID
* @property {string} xml - the XML text for this category, starting with `<category>` and ending with `</category>`
*/
getBlocksXML (target) {
return this._blockInfo.map(categoryInfo => {
const {name, color1, color2} = categoryInfo;
// Filter out blocks that aren't supposed to be shown on this target, as determined by the block info's
// `hideFromPalette` and `filter` properties.
const paletteBlocks = categoryInfo.blocks.filter(block => {
let blockFilterIncludesTarget = true;
// If an editing target is not passed, include all blocks
// If the block info doesn't include a `filter` property, always include it
if (target && block.info.filter) {
blockFilterIncludesTarget = block.info.filter.includes(
target.isStage ? TargetType.STAGE : TargetType.SPRITE
);
}
// If the block info's `hideFromPalette` is true, then filter out this block
return blockFilterIncludesTarget && !