mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-26 16:02:54 -05:00
dd20e09774
This prevents generation of invalid XML due to characters like '<' or '>' in fields' default values. Unfortunately the value comes back in its escaped form, so there's still more work to be done.
1300 lines
44 KiB
JavaScript
1300 lines
44 KiB
JavaScript
const EventEmitter = require('events');
|
|
const {OrderedMap} = require('immutable');
|
|
const escapeHtml = require('escape-html');
|
|
|
|
const ArgumentType = require('../extension-support/argument-type');
|
|
const Blocks = require('./blocks');
|
|
const BlockType = require('../extension-support/block-type');
|
|
const Sequencer = require('./sequencer');
|
|
const Thread = require('./thread');
|
|
|
|
// Virtual I/O devices.
|
|
const Clock = require('../io/clock');
|
|
const DeviceManager = require('../io/deviceManager');
|
|
const Keyboard = require('../io/keyboard');
|
|
const Mouse = require('../io/mouse');
|
|
|
|
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'),
|
|
scratch3_wedo2: require('../blocks/scratch3_wedo2')
|
|
};
|
|
|
|
/**
|
|
* Information used for converting Scratch argument types into scratch-blocks data.
|
|
* @type {object.<ArgumentType, {shadowType: string, fieldType: string}>}}
|
|
*/
|
|
const ArgumentTypeMap = (() => {
|
|
const map = {};
|
|
map[ArgumentType.COLOR] = {
|
|
shadowType: 'colour_picker'
|
|
};
|
|
map[ArgumentType.NUMBER] = {
|
|
shadowType: 'math_number',
|
|
fieldType: 'NUM'
|
|
};
|
|
map[ArgumentType.STRING] = {
|
|
shadowType: 'text',
|
|
fieldType: 'TEXT'
|
|
};
|
|
// @TODO: talk to Rachel & co. to figure out what goes here. Make it OK to not have a field. Add `check` support.
|
|
map[ArgumentType.BOOLEAN] = {
|
|
shadowType: ''
|
|
};
|
|
return map;
|
|
})();
|
|
|
|
/**
|
|
* These constants are copied from scratch-blocks/core/constants.js
|
|
* @TODO find a way to require() these... maybe make a scratch-blocks/dist/constants.js or something like that?
|
|
* @readonly
|
|
* @enum {int}
|
|
*/
|
|
const ScratchBlocksConstants = {
|
|
/**
|
|
* ENUM for output shape: hexagonal (booleans/predicates).
|
|
* @const
|
|
*/
|
|
OUTPUT_SHAPE_HEXAGONAL: 1,
|
|
|
|
/**
|
|
* ENUM for output shape: rounded (numbers).
|
|
* @const
|
|
*/
|
|
OUTPUT_SHAPE_ROUND: 2,
|
|
|
|
/**
|
|
* ENUM for output shape: squared (any/all values; strings).
|
|
* @const
|
|
*/
|
|
OUTPUT_SHAPE_SQUARE: 3
|
|
};
|
|
|
|
/**
|
|
* Manages targets, scripts, and the sequencer.
|
|
* @constructor
|
|
*/
|
|
class Runtime extends EventEmitter {
|
|
constructor () {
|
|
super();
|
|
|
|
/**
|
|
* Target management and storage.
|
|
* @type {Array.<!Target>}
|
|
*/
|
|
this.targets = [];
|
|
|
|
/**
|
|
* 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();
|
|
|
|
/**
|
|
* Storage container for monitor blocks.
|
|
* These will execute on a target maybe
|
|
* @type {!Blocks}
|
|
*/
|
|
this.monitorBlocks = new Blocks();
|
|
|
|
/**
|
|
* 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 = {};
|
|
|
|
/**
|
|
* 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 = {};
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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(),
|
|
deviceManager: new DeviceManager(),
|
|
keyboard: new Keyboard(this),
|
|
mouse: new Mouse(this)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 glowing the green flag
|
|
* @const {string}
|
|
*/
|
|
static get PROJECT_RUN_START () {
|
|
return 'PROJECT_RUN_START';
|
|
}
|
|
|
|
/**
|
|
* Event name for unglowing the green flag
|
|
* @const {string}
|
|
*/
|
|
static get PROJECT_RUN_STOP () {
|
|
return 'PROJECT_RUN_STOP';
|
|
}
|
|
|
|
/**
|
|
* Event name for visual value report.
|
|
* @const {string}
|
|
*/
|
|
static get VISUAL_REPORT () {
|
|
return 'VISUAL_REPORT';
|
|
}
|
|
|
|
/**
|
|
* 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 reporting that an extension was added.
|
|
* @const {string}
|
|
*/
|
|
static get EXTENSION_ADDED () {
|
|
return 'EXTENSION_ADDED';
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the primitives provided by an extension.
|
|
* @param {ExtensionInfo} extensionInfo - information about the extension (id, blocks, etc.)
|
|
* @private
|
|
*/
|
|
_registerExtensionPrimitives (extensionInfo) {
|
|
const categoryInfo = {
|
|
id: extensionInfo.id,
|
|
name: extensionInfo.name,
|
|
color1: '#FF6680',
|
|
color2: '#FF4D6A',
|
|
color3: '#FF3355',
|
|
blocks: []
|
|
};
|
|
|
|
this._blockInfo.push(categoryInfo);
|
|
|
|
for (const blockInfo of extensionInfo.blocks) {
|
|
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
|
|
const opcode = convertedBlock.json.type;
|
|
categoryInfo.blocks.push(convertedBlock);
|
|
this._primitives[opcode] = convertedBlock.info.func;
|
|
}
|
|
|
|
this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks);
|
|
}
|
|
|
|
/**
|
|
* Convert BlockInfo into scratch-blocks JSON & XML, and generate a proxy function.
|
|
* @param {BlockInfo} blockInfo - the block to convert
|
|
* @param {CategoryInfo} categoryInfo - the category for this block
|
|
* @returns {{info: BlockInfo, json: object, xml: string}} - the converted & original block information
|
|
* @private
|
|
*/
|
|
_convertForScratchBlocks (blockInfo, categoryInfo) {
|
|
const extendedOpcode = `${categoryInfo.id}.${blockInfo.opcode}`;
|
|
const blockJSON = {
|
|
type: extendedOpcode,
|
|
inputsInline: true,
|
|
category: categoryInfo.name,
|
|
colour: categoryInfo.color1,
|
|
colourSecondary: categoryInfo.color2,
|
|
colorTertiary: categoryInfo.color3,
|
|
args0: []
|
|
};
|
|
|
|
const inputList = [];
|
|
|
|
// TODO: store this somewhere so that we can map args appropriately after translation.
|
|
// This maps an arg name to its relative position in the original (usually English) block text.
|
|
// When displaying a block in another language we'll need to run a `replace` action similar to the one below,
|
|
// but each `[ARG]` will need to be replaced with the number in this map instead of `args0.length`.
|
|
const argsMap = {};
|
|
|
|
blockJSON.message0 = blockInfo.text.replace(/\[(.+?)]/g, (match, placeholder) => {
|
|
|
|
// Sanitize the placeholder to ensure valid XML
|
|
placeholder = placeholder.replace(/[<"&]/, '_');
|
|
|
|
blockJSON.args0.push({
|
|
type: 'input_value',
|
|
name: placeholder
|
|
});
|
|
|
|
// scratch-blocks uses 1-based argument indexing
|
|
const argNum = blockJSON.args0.length;
|
|
argsMap[placeholder] = argNum;
|
|
|
|
const argInfo = blockInfo.arguments[placeholder] || {};
|
|
const argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
|
|
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ?
|
|
'' :
|
|
escapeHtml(argInfo.defaultValue.toString()));
|
|
|
|
// <value> is the ScratchBlocks name for a block input.
|
|
// The <shadow> is a placeholder for a reporter and is visible when there's no reporter in this input.
|
|
inputList.push(`<value name="${placeholder}"><shadow type="${argTypeInfo.shadowType}">`);
|
|
|
|
// <field> is a text field that the user can type into. Some shadows, like the color picker, don't allow
|
|
// text input and therefore don't need a field element.
|
|
if (argTypeInfo.fieldType) {
|
|
inputList.push(`<field name="${argTypeInfo.fieldType}">${defaultValue}</field>`);
|
|
}
|
|
|
|
inputList.push('</shadow></value>');
|
|
|
|
return `%${argNum}`;
|
|
});
|
|
|
|
switch (blockInfo.blockType) {
|
|
case BlockType.COMMAND:
|
|
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
|
|
blockJSON.previousStatement = null; // null = available connection; undefined = hat
|
|
if (!blockInfo.isTerminal) {
|
|
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
|
|
}
|
|
break;
|
|
case BlockType.REPORTER:
|
|
blockJSON.output = 'String'; // TODO: distinguish number & string here?
|
|
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_ROUND;
|
|
break;
|
|
case BlockType.BOOLEAN:
|
|
blockJSON.output = 'Boolean';
|
|
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_HEXAGONAL;
|
|
break;
|
|
case BlockType.HAT:
|
|
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
|
|
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
|
|
break;
|
|
case BlockType.CONDITIONAL:
|
|
// Statement inputs get names like 'SUBSTACK', 'SUBSTACK2', 'SUBSTACK3', ...
|
|
for (let branchNum = 1; branchNum <= blockInfo.branchCount; ++branchNum) {
|
|
blockJSON[`message${branchNum}`] = '%1';
|
|
blockJSON[`args${branchNum}`] = [{
|
|
type: 'input_statement',
|
|
name: `SUBSTACK${branchNum > 1 ? branchNum : ''}`
|
|
}];
|
|
}
|
|
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
|
|
blockJSON.previousStatement = null; // null = available connection; undefined = hat
|
|
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
|
|
break;
|
|
}
|
|
|
|
if (blockInfo.isTerminal) {
|
|
delete blockJSON.nextStatement;
|
|
}
|
|
|
|
const blockXML = `<block type="${extendedOpcode}">${inputList.join('')}</block>`;
|
|
|
|
return {
|
|
info: blockInfo,
|
|
json: blockJSON,
|
|
xml: blockXML
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in <category> elements.
|
|
*/
|
|
getBlocksXML () {
|
|
const xmlParts = [];
|
|
for (const categoryInfo of this._blockInfo) {
|
|
const {name, color1, color2} = categoryInfo;
|
|
xmlParts.push(`<category name="${name}" colour="${color1}" secondaryColour="${color2}">`);
|
|
xmlParts.push.apply(xmlParts, categoryInfo.blocks.map(blockInfo => blockInfo.xml));
|
|
xmlParts.push('</category>');
|
|
}
|
|
return xmlParts.join('\n');
|
|
}
|
|
|
|
/**
|
|
* @returns {Array.<string>} - an array containing the scratch-blocks JSON information for each dynamic block.
|
|
*/
|
|
getBlocksJSON () {
|
|
return this._blockInfo.reduce(
|
|
(result, categoryInfo) => result.concat(categoryInfo.blocks.map(blockInfo => blockInfo.json)), []);
|
|
}
|
|
|
|
/**
|
|
* Retrieve the function associated with the given opcode.
|
|
* @param {!string} opcode The opcode to look up.
|
|
* @return {Function} The function which implements the opcode.
|
|
*/
|
|
getOpcodeFunction (opcode) {
|
|
return this._primitives[opcode];
|
|
}
|
|
|
|
/**
|
|
* Return whether an opcode represents a hat block.
|
|
* @param {!string} opcode The opcode to look up.
|
|
* @return {boolean} True if the op is known to be a hat.
|
|
*/
|
|
getIsHat (opcode) {
|
|
return this._hats.hasOwnProperty(opcode);
|
|
}
|
|
|
|
/**
|
|
* Return whether an opcode represents an edge-activated hat block.
|
|
* @param {!string} opcode The opcode to look up.
|
|
* @return {boolean} True if the op is known to be a edge-activated hat.
|
|
*/
|
|
getIsEdgeActivatedHat (opcode) {
|
|
return this._hats.hasOwnProperty(opcode) &&
|
|
this._hats[opcode].edgeActivated;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Clear all edge-activaed hat values.
|
|
*/
|
|
clearEdgeActivatedValues () {
|
|
this._edgeActivatedHatValues = {};
|
|
}
|
|
|
|
/**
|
|
* Attach the audio engine
|
|
* @param {!AudioEngine} audioEngine The audio engine to attach
|
|
*/
|
|
attachAudioEngine (audioEngine) {
|
|
this.audioEngine = audioEngine;
|
|
}
|
|
|
|
/**
|
|
* Attach the renderer
|
|
* @param {!RenderWebGL} renderer The renderer to attach
|
|
*/
|
|
attachRenderer (renderer) {
|
|
this.renderer = renderer;
|
|
}
|
|
|
|
/**
|
|
* Attach the storage module
|
|
* @param {!ScratchStorage} storage The storage module to attach
|
|
*/
|
|
attachStorage (storage) {
|
|
this.storage = storage;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a thread and push it to the list of threads.
|
|
* @param {!string} id ID of block that starts the stack.
|
|
* @param {!Target} target Target to run thread on.
|
|
* @param {?object} opts optional arguments
|
|
* @param {?boolean} opts.stackClick true if the script was activated by clicking on the stack
|
|
* @param {?boolean} opts.updateMonitor true if the script should update a monitor value
|
|
* @return {!Thread} The newly created thread.
|
|
*/
|
|
_pushThread (id, target, opts) {
|
|
opts = Object.assign({
|
|
stackClick: false,
|
|
updateMonitor: false
|
|
}, opts);
|
|
|
|
const thread = new Thread(id);
|
|
thread.target = target;
|
|
thread.stackClick = opts.stackClick;
|
|
thread.updateMonitor = opts.updateMonitor;
|
|
|
|
thread.pushStack(id);
|
|
this.threads.push(thread);
|
|
return thread;
|
|
}
|
|
|
|
/**
|
|
* Remove a thread from the list of threads.
|
|
* @param {?Thread} thread Thread object to remove from actives
|
|
*/
|
|
_removeThread (thread) {
|
|
// Inform sequencer to stop executing that thread.
|
|
this.sequencer.retireThread(thread);
|
|
// Remove from the list.
|
|
const i = this.threads.indexOf(thread);
|
|
if (i > -1) {
|
|
this.threads.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restart a thread in place, maintaining its position in the list of threads.
|
|
* This is used by `startHats` to and is necessary to ensure 2.0-like execution order.
|
|
* Test project: https://scratch.mit.edu/projects/130183108/
|
|
* @param {!Thread} thread Thread object to restart.
|
|
*/
|
|
_restartThread (thread) {
|
|
const newThread = new Thread(thread.topBlock);
|
|
newThread.target = thread.target;
|
|
newThread.stackClick = thread.stackClick;
|
|
newThread.updateMonitor = thread.updateMonitor;
|
|
newThread.pushStack(thread.topBlock);
|
|
const i = this.threads.indexOf(thread);
|
|
if (i > -1) {
|
|
this.threads[i] = newThread;
|
|
} else {
|
|
this.threads.push(thread);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return whether a thread is currently active/running.
|
|
* @param {?Thread} thread Thread object to check.
|
|
* @return {boolean} True if the thread is active/running.
|
|
*/
|
|
isActiveThread (thread) {
|
|
return this.threads.indexOf(thread) > -1;
|
|
}
|
|
|
|
/**
|
|
* Toggle a script.
|
|
* @param {!string} topBlockId ID of block that starts the script.
|
|
* @param {?object} opts optional arguments to toggle script
|
|
* @param {?string} opts.target target ID for target to run script on. If not supplied, uses editing target.
|
|
* @param {?boolean} opts.stackClick true if the user activated the stack by clicking, false if not. This
|
|
* determines whether we show a visual report when turning on the script.
|
|
*/
|
|
toggleScript (topBlockId, opts) {
|
|
opts = Object.assign({
|
|
target: this._editingTarget,
|
|
stackClick: false
|
|
}, opts);
|
|
// Remove any existing thread.
|
|
for (let i = 0; i < this.threads.length; i++) {
|
|
// Toggling a script that's already running turns it off
|
|
if (this.threads[i].topBlock === topBlockId && this.threads[i].status !== Thread.STATUS_DONE) {
|
|
const blockContainer = opts.target.blocks;
|
|
const opcode = blockContainer.getOpcode(blockContainer.getBlock(topBlockId));
|
|
|
|
if (this.getIsEdgeActivatedHat(opcode) && this.threads[i].stackClick !== opts.stackClick) {
|
|
// Allow edge activated hat thread stack click to coexist with
|
|
// edge activated hat thread that runs every frame
|
|
continue;
|
|
}
|
|
this._removeThread(this.threads[i]);
|
|
return;
|
|
}
|
|
}
|
|
// Otherwise add it.
|
|
this._pushThread(topBlockId, opts.target, opts);
|
|
}
|
|
|
|
/**
|
|
* Enqueue a script that when finished will update the monitor for the block.
|
|
* @param {!string} topBlockId ID of block that starts the script.
|
|
* @param {?string} optTarget target ID for target to run script on. If not supplied, uses editing target.
|
|
*/
|
|
addMonitorScript (topBlockId, optTarget) {
|
|
if (!optTarget) optTarget = this._editingTarget;
|
|
for (let i = 0; i < this.threads.length; i++) {
|
|
// Don't re-add the script if it's already running
|
|
if (this.threads[i].topBlock === topBlockId && this.threads[i].status !== Thread.STATUS_DONE &&
|
|
this.threads[i].updateMonitor) {
|
|
return;
|
|
}
|
|
}
|
|
// Otherwise add it.
|
|
this._pushThread(topBlockId, optTarget, {updateMonitor: true});
|
|
}
|
|
|
|
/**
|
|
* Run a function `f` for all scripts in a workspace.
|
|
* `f` will be called with two parameters:
|
|
* - the top block ID of the script.
|
|
* - the target that owns the script.
|
|
* @param {!Function} f Function to call for each script.
|
|
* @param {Target=} optTarget Optionally, a target to restrict to.
|
|
*/
|
|
allScriptsDo (f, optTarget) {
|
|
let targets = this.targets;
|
|
if (optTarget) {
|
|
targets = [optTarget];
|
|
}
|
|
for (let t = targets.length - 1; t >= 0; t--) {
|
|
const target = targets[t];
|
|
const scripts = target.blocks.getScripts();
|
|
for (let j = 0; j < scripts.length; j++) {
|
|
const topBlockId = scripts[j];
|
|
f(topBlockId, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start all relevant hats.
|
|
* @param {!string} requestedHatOpcode 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 (requestedHatOpcode,
|
|
optMatchFields, optTarget) {
|
|
if (!this._hats.hasOwnProperty(requestedHatOpcode)) {
|
|
// No known hat with this opcode.
|
|
return;
|
|
}
|
|
const instance = this;
|
|
const newThreads = [];
|
|
|
|
for (const opts in optMatchFields) {
|
|
if (!optMatchFields.hasOwnProperty(opts)) continue;
|
|
optMatchFields[opts] = optMatchFields[opts].toUpperCase();
|
|
}
|
|
|
|
// Consider all scripts, looking for hats with opcode `requestedHatOpcode`.
|
|
this.allScriptsDo((topBlockId, target) => {
|
|
const blocks = target.blocks;
|
|
const block = blocks.getBlock(topBlockId);
|
|
const potentialHatOpcode = block.opcode;
|
|
if (potentialHatOpcode !== requestedHatOpcode) {
|
|
// Not the right hat.
|
|
return;
|
|
}
|
|
|
|
// Match any requested fields.
|
|
// For example: ensures that broadcasts match.
|
|
// This needs to happen before the block is evaluated
|
|
// (i.e., before the predicate can be run) because "broadcast and wait"
|
|
// needs to have a precise collection of started threads.
|
|
let hatFields = blocks.getFields(block);
|
|
|
|
// If no fields are present, check inputs (horizontal blocks)
|
|
if (Object.keys(hatFields).length === 0) {
|
|
const hatInputs = blocks.getInputs(block);
|
|
for (const input in hatInputs) {
|
|
if (!hatInputs.hasOwnProperty(input)) continue;
|
|
const id = hatInputs[input].block;
|
|
const inpBlock = blocks.getBlock(id);
|
|
const fields = blocks.getFields(inpBlock);
|
|
hatFields = Object.assign(fields, hatFields);
|
|
}
|
|
}
|
|
|
|
if (optMatchFields) {
|
|
for (const matchField in optMatchFields) {
|
|
if (hatFields[matchField].value.toUpperCase() !==
|
|
optMatchFields[matchField]) {
|
|
// Field mismatch.
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look up metadata for the relevant hat.
|
|
const hatMeta = instance._hats[requestedHatOpcode];
|
|
if (hatMeta.restartExistingThreads) {
|
|
// If `restartExistingThreads` is true, we should stop
|
|
// any existing threads starting with the top block.
|
|
for (let i = 0; i < instance.threads.length; i++) {
|
|
if (instance.threads[i].topBlock === topBlockId &&
|
|
!instance.threads[i].stackClick && // stack click threads and hat threads can coexist
|
|
instance.threads[i].target === target) {
|
|
instance._restartThread(instance.threads[i]);
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
// If `restartExistingThreads` is false, we should
|
|
// give up if any threads with the top block are running.
|
|
for (let j = 0; j < instance.threads.length; j++) {
|
|
if (instance.threads[j].topBlock === topBlockId &&
|
|
instance.threads[j].target === target &&
|
|
!instance.threads[j].stackClick && // stack click threads and hat threads can coexist
|
|
instance.threads[j].status !== Thread.STATUS_DONE) {
|
|
// Some thread is already running.
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// Start the thread with this top block.
|
|
newThreads.push(instance._pushThread(topBlockId, target));
|
|
}, optTarget);
|
|
return newThreads;
|
|
}
|
|
|
|
/**
|
|
* Dispose all targets. Return to clean state.
|
|
*/
|
|
dispose () {
|
|
this.stopAll();
|
|
this.targets.map(this.disposeTarget, this);
|
|
}
|
|
|
|
/**
|
|
* Dispose of a target.
|
|
* @param {!Target} disposingTarget Target to dispose of.
|
|
*/
|
|
disposeTarget (disposingTarget) {
|
|
this.targets = this.targets.filter(target => {
|
|
if (disposingTarget !== target) return true;
|
|
// Allow target to do dispose actions.
|
|
target.dispose();
|
|
// Remove from list of targets.
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop any threads acting on the target.
|
|
* @param {!Target} target Target to stop threads for.
|
|
* @param {Thread=} optThreadException Optional thread to skip.
|
|
*/
|
|
stopForTarget (target, optThreadException) {
|
|
// Stop any threads on the target.
|
|
for (let i = 0; i < this.threads.length; i++) {
|
|
if (this.threads[i] === optThreadException) {
|
|
continue;
|
|
}
|
|
if (this.threads[i].target === target) {
|
|
this.threads[i].isKilled = true;
|
|
this._removeThread(this.threads[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start all threads that start with the green flag.
|
|
*/
|
|
greenFlag () {
|
|
this.stopAll();
|
|
this.ioDevices.clock.resetProjectTimer();
|
|
this.clearEdgeActivatedValues();
|
|
// Inform all targets of the green flag.
|
|
for (let i = 0; i < this.targets.length; i++) {
|
|
this.targets[i].onGreenFlag();
|
|
}
|
|
this.startHats('event_whenflagclicked');
|
|
}
|
|
|
|
/**
|
|
* Stop "everything."
|
|
*/
|
|
stopAll () {
|
|
// Dispose all clones.
|
|
const newTargets = [];
|
|
for (let i = 0; i < this.targets.length; i++) {
|
|
this.targets[i].onStopAll();
|
|
if (this.targets[i].hasOwnProperty('isOriginal') &&
|
|
!this.targets[i].isOriginal) {
|
|
this.targets[i].dispose();
|
|
} else {
|
|
newTargets.push(this.targets[i]);
|
|
}
|
|
}
|
|
this.targets = newTargets;
|
|
// Dispose all threads.
|
|
const threadsCopy = this.threads.slice();
|
|
while (threadsCopy.length > 0) {
|
|
const poppedThread = threadsCopy.pop();
|
|
this._removeThread(poppedThread);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Repeatedly run `sequencer.stepThreads` and filter out
|
|
* inactive threads after each iteration.
|
|
*/
|
|
_step () {
|
|
// Find all edge-activated hats, and add them to threads to be evaluated.
|
|
for (const hatType in this._hats) {
|
|
if (!this._hats.hasOwnProperty(hatType)) continue;
|
|
const hat = this._hats[hatType];
|
|
if (hat.edgeActivated) {
|
|
this.startHats(hatType);
|
|
}
|
|
}
|
|
this.redrawRequested = false;
|
|
this._pushMonitors();
|
|
const doneThreads = this.sequencer.stepThreads();
|
|
this._updateGlows(doneThreads);
|
|
// Add done threads so that even if a thread finishes within 1 frame, the green
|
|
// flag will still indicate that a script ran.
|
|
this._emitProjectRunStatus(
|
|
this.threads.length + doneThreads.length -
|
|
this._getMonitorThreadCount([...this.threads, ...doneThreads]));
|
|
if (this.renderer) {
|
|
// @todo: Only render when this.redrawRequested or clones rendered.
|
|
this.renderer.draw();
|
|
}
|
|
|
|
if (this._refreshTargets) {
|
|
this.emit(Runtime.TARGETS_UPDATE);
|
|
this._refreshTargets = false;
|
|
}
|
|
|
|
if (!this._prevMonitorState.equals(this._monitorState)) {
|
|
this.emit(Runtime.MONITORS_UPDATE, this._monitorState);
|
|
this._prevMonitorState = this._monitorState;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the number of threads in the given array that are monitor threads (threads
|
|
* that update monitor values, and don't count as running a script).
|
|
* @param {!Array.<Thread>} threads The set of threads to look through.
|
|
* @return {number} The number of monitor threads in threads.
|
|
*/
|
|
_getMonitorThreadCount (threads) {
|
|
let count = 0;
|
|
threads.forEach(thread => {
|
|
if (thread.updateMonitor) count++;
|
|
});
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Queue monitor blocks to sequencer to be run.
|
|
*/
|
|
_pushMonitors () {
|
|
this.monitorBlocks.runAllMonitored(this);
|
|
}
|
|
|
|
/**
|
|
* Set the current editing target known by the runtime.
|
|
* @param {!Target} editingTarget New editing target.
|
|
*/
|
|
setEditingTarget (editingTarget) {
|
|
this._editingTarget = editingTarget;
|
|
// Script glows must be cleared.
|
|
this._scriptGlowsPreviousFrame = [];
|
|
this._updateGlows();
|
|
this.requestTargetsUpdate(editingTarget);
|
|
}
|
|
|
|
/**
|
|
* Set whether we are in 30 TPS compatibility mode.
|
|
* @param {boolean} compatibilityModeOn True iff in compatibility mode.
|
|
*/
|
|
setCompatibilityMode (compatibilityModeOn) {
|
|
this.compatibilityMode = compatibilityModeOn;
|
|
if (this._steppingInterval) {
|
|
clearInterval(this._steppingInterval);
|
|
this.start();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit glows/glow clears for scripts after a single tick.
|
|
* Looks at `this.threads` and notices which have turned on/off new glows.
|
|
* @param {Array.<Thread>=} optExtraThreads Optional list of inactive threads.
|
|
*/
|
|
_updateGlows (optExtraThreads) {
|
|
const searchThreads = [];
|
|
searchThreads.push.apply(searchThreads, this.threads);
|
|
if (optExtraThreads) {
|
|
searchThreads.push.apply(searchThreads, optExtraThreads);
|
|
}
|
|
// Set of scripts that request a glow this frame.
|
|
const requestedGlowsThisFrame = [];
|
|
// Final set of scripts glowing during this frame.
|
|
const finalScriptGlows = [];
|
|
// Find all scripts that should be glowing.
|
|
for (let i = 0; i < searchThreads.length; i++) {
|
|
const thread = searchThreads[i];
|
|
const target = thread.target;
|
|
if (target === this._editingTarget) {
|
|
const blockForThread = thread.blockGlowInFrame;
|
|
if (thread.requestScriptGlowInFrame) {
|
|
let script = target.blocks.getTopLevelScript(blockForThread);
|
|
if (!script) {
|
|
// Attempt to find in flyout blocks.
|
|
script = this.flyoutBlocks.getTopLevelScript(
|
|
blockForThread
|
|
);
|
|
}
|
|
if (script) {
|
|
requestedGlowsThisFrame.push(script);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Compare to previous frame.
|
|
for (let j = 0; j < this._scriptGlowsPreviousFrame.length; j++) {
|
|
const previousFrameGlow = this._scriptGlowsPreviousFrame[j];
|
|
if (requestedGlowsThisFrame.indexOf(previousFrameGlow) < 0) {
|
|
// Glow turned off.
|
|
this.glowScript(previousFrameGlow, false);
|
|
} else {
|
|
// Still glowing.
|
|
finalScriptGlows.push(previousFrameGlow);
|
|
}
|
|
}
|
|
for (let k = 0; k < requestedGlowsThisFrame.length; k++) {
|
|
const currentFrameGlow = requestedGlowsThisFrame[k];
|
|
if (this._scriptGlowsPreviousFrame.indexOf(currentFrameGlow) < 0) {
|
|
// Glow turned on.
|
|
this.glowScript(currentFrameGlow, true);
|
|
finalScriptGlows.push(currentFrameGlow);
|
|
}
|
|
}
|
|
this._scriptGlowsPreviousFrame = finalScriptGlows;
|
|
}
|
|
|
|
/**
|
|
* Emit run start/stop after each tick. Emits when `this.threads.length` goes
|
|
* between non-zero and zero
|
|
*
|
|
* @param {number} nonMonitorThreadCount The new nonMonitorThreadCount
|
|
*/
|
|
_emitProjectRunStatus (nonMonitorThreadCount) {
|
|
if (this._nonMonitorThreadCount === 0 && nonMonitorThreadCount > 0) {
|
|
this.emit(Runtime.PROJECT_RUN_START);
|
|
}
|
|
if (this._nonMonitorThreadCount > 0 && nonMonitorThreadCount === 0) {
|
|
this.emit(Runtime.PROJECT_RUN_STOP);
|
|
}
|
|
this._nonMonitorThreadCount = nonMonitorThreadCount;
|
|
}
|
|
|
|
/**
|
|
* "Quiet" a script's glow: stop the VM from generating glow/unglow events
|
|
* about that script. Use when a script has just been deleted, but we may
|
|
* still be tracking glow data about it.
|
|
* @param {!string} scriptBlockId Id of top-level block in script to quiet.
|
|
*/
|
|
quietGlow (scriptBlockId) {
|
|
const index = this._scriptGlowsPreviousFrame.indexOf(scriptBlockId);
|
|
if (index > -1) {
|
|
this._scriptGlowsPreviousFrame.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit feedback for block glowing (used in the sequencer).
|
|
* @param {?string} blockId ID for the block to update glow
|
|
* @param {boolean} isGlowing True to turn on glow; false to turn off.
|
|
*/
|
|
glowBlock (blockId, isGlowing) {
|
|
if (isGlowing) {
|
|
this.emit(Runtime.BLOCK_GLOW_ON, {id: blockId});
|
|
} else {
|
|
this.emit(Runtime.BLOCK_GLOW_OFF, {id: blockId});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit feedback for script glowing.
|
|
* @param {?string} topBlockId ID for the top block to update glow
|
|
* @param {boolean} isGlowing True to turn on glow; false to turn off.
|
|
*/
|
|
glowScript (topBlockId, isGlowing) {
|
|
if (isGlowing) {
|
|
this.emit(Runtime.SCRIPT_GLOW_ON, {id: topBlockId});
|
|
} else {
|
|
this.emit(Runtime.SCRIPT_GLOW_OFF, {id: topBlockId});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit value for reporter to show in the blocks.
|
|
* @param {string} blockId ID for the block.
|
|
* @param {string} value Value to show associated with the block.
|
|
*/
|
|
visualReport (blockId, value) {
|
|
this.emit(Runtime.VISUAL_REPORT, {id: blockId, value: String(value)});
|
|
}
|
|
|
|
/**
|
|
* Add a monitor to the state. If the monitor already exists in the state,
|
|
* overwrites it.
|
|
* @param {!MonitorRecord} monitor Monitor to add.
|
|
*/
|
|
requestAddMonitor (monitor) {
|
|
this._monitorState = this._monitorState.set(monitor.id, monitor);
|
|
}
|
|
|
|
/**
|
|
* Update a monitor in the state. Does nothing if the monitor does not already
|
|
* exist in the state.
|
|
* @param {!Map} monitor Monitor values to update. Values on the monitor with overwrite
|
|
* values on the old monitor with the same ID. If a value isn't defined on the new monitor,
|
|
* the old monitor will keep its old value.
|
|
*/
|
|
requestUpdateMonitor (monitor) {
|
|
if (this._monitorState.has(monitor.get('id'))) {
|
|
this._monitorState =
|
|
this._monitorState.set(monitor.get('id'), this._monitorState.get(monitor.get('id')).merge(monitor));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a monitor from the state. Does nothing if the monitor already does
|
|
* not exist in the state.
|
|
* @param {!string} monitorId ID of the monitor to remove.
|
|
*/
|
|
requestRemoveMonitor (monitorId) {
|
|
this._monitorState = this._monitorState.delete(monitorId);
|
|
}
|
|
|
|
/**
|
|
* Get a target by its id.
|
|
* @param {string} targetId Id of target to find.
|
|
* @return {?Target} The target, if found.
|
|
*/
|
|
getTargetById (targetId) {
|
|
for (let i = 0; i < this.targets.length; i++) {
|
|
const target = this.targets[i];
|
|
if (target.id === targetId) {
|
|
return target;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the first original (non-clone-block-created) sprite given a name.
|
|
* @param {string} spriteName Name of sprite to look for.
|
|
* @return {?Target} Target representing a sprite of the given name.
|
|
*/
|
|
getSpriteTargetByName (spriteName) {
|
|
for (let i = 0; i < this.targets.length; i++) {
|
|
const target = this.targets[i];
|
|
if (target.sprite && target.sprite.name === spriteName) {
|
|
return target;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a target by its drawable id.
|
|
* @param {number} drawableID drawable id of target to find
|
|
* @return {?Target} The target, if found
|
|
*/
|
|
getTargetByDrawableId (drawableID) {
|
|
for (let i = 0; i < this.targets.length; i++) {
|
|
const target = this.targets[i];
|
|
if (target.drawableID === drawableID) return target;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the clone counter to track how many clones are created.
|
|
* @param {number} changeAmount How many clones have been created/destroyed.
|
|
*/
|
|
changeCloneCounter (changeAmount) {
|
|
this._cloneCounter += changeAmount;
|
|
}
|
|
|
|
/**
|
|
* Return whether there are clones available.
|
|
* @return {boolean} True until the number of clones hits Runtime.MAX_CLONES.
|
|
*/
|
|
clonesAvailable () {
|
|
return this._cloneCounter < Runtime.MAX_CLONES;
|
|
}
|
|
|
|
/**
|
|
* Report that a new target has been created, possibly by cloning an existing target.
|
|
* @param {Target} newTarget - the newly created target.
|
|
* @param {Target} [sourceTarget] - the target used as a source for the new clone, if any.
|
|
* @fires Runtime#targetWasCreated
|
|
*/
|
|
fireTargetWasCreated (newTarget, sourceTarget) {
|
|
this.emit('targetWasCreated', newTarget, sourceTarget);
|
|
}
|
|
|
|
/**
|
|
* Get a target representing the Scratch stage, if one exists.
|
|
* @return {?Target} The target, if found.
|
|
*/
|
|
getTargetForStage () {
|
|
for (let i = 0; i < this.targets.length; i++) {
|
|
const target = this.targets[i];
|
|
if (target.isStage) {
|
|
return target;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the editing target.
|
|
* @return {?Target} The editing target.
|
|
*/
|
|
getEditingTarget () {
|
|
return this._editingTarget;
|
|
}
|
|
|
|
/**
|
|
* Tell the runtime to request a redraw.
|
|
* Use after a clone/sprite has completed some visible operation on the stage.
|
|
*/
|
|
requestRedraw () {
|
|
this.redrawRequested = true;
|
|
}
|
|
|
|
/**
|
|
* Emit a targets update at the end of the step if the provided target is
|
|
* the original sprite
|
|
* @param {!Target} target Target requesting the targets update
|
|
*/
|
|
requestTargetsUpdate (target) {
|
|
if (!target.isOriginal) return;
|
|
this._refreshTargets = true;
|
|
}
|
|
|
|
/**
|
|
* Set up timers to repeatedly step in a browser.
|
|
*/
|
|
start () {
|
|
let interval = Runtime.THREAD_STEP_INTERVAL;
|
|
if (this.compatibilityMode) {
|
|
interval = Runtime.THREAD_STEP_INTERVAL_COMPATIBILITY;
|
|
}
|
|
this.currentStepTime = interval;
|
|
this._steppingInterval = setInterval(() => {
|
|
this._step();
|
|
}, interval);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Event fired after a new target has been created, possibly by cloning an existing target.
|
|
*
|
|
* @event Runtime#targetWasCreated
|
|
* @param {Target} newTarget - the newly created target.
|
|
* @param {Target} [sourceTarget] - the target used as a source for the new clone, if any.
|
|
*/
|
|
|
|
module.exports = Runtime;
|