scratch-vm
Version:
Virtual Machine for Scratch 3.0
1,314 lines (1,242 loc) • 3.19 MB
JavaScript
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define([], factory);
else if(typeof exports === 'object')
exports["VirtualMachine"] = factory();
else
root["VirtualMachine"] = factory();
})(global, () => {
return /******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ({
/***/ "./node_modules/arraybuffer-loader/lib/to-array-buffer.js":
/*!****************************************************************!*\
!*** ./node_modules/arraybuffer-loader/lib/to-array-buffer.js ***!
\****************************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
"use strict";
/* provided dependency */ var Buffer = __webpack_require__(/*! buffer */ "buffer")["Buffer"];
module.exports = function (base64Data) {
var isBrowser = typeof window !== 'undefined' && typeof window.atob === 'function'
var binary = isBrowser ? window.atob(base64Data) : Buffer.from(base64Data, 'base64').toString('binary')
var bytes = new Uint8Array(binary.length)
for (var i = 0; i < binary.length; ++i) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
/***/ }),
/***/ "./package.json":
/*!**********************!*\
!*** ./package.json ***!
\**********************/
/***/ ((module) => {
"use strict";
module.exports = /*#__PURE__*/JSON.parse('{"name":"scratch-vm","version":"5.0.299","description":"Virtual Machine for Scratch 3.0","author":"Massachusetts Institute of Technology","license":"AGPL-3.0-only","homepage":"https://github.com/scratchfoundation/scratch-vm#readme","repository":{"type":"git","url":"https://github.com/scratchfoundation/scratch-vm.git","sha":"af179d8114073ee02ee3315ac5ebebdb58151b47"},"main":"./dist/node/scratch-vm.js","browser":"./dist/web/scratch-vm.js","exports":{"webpack":"./src/index.js","browser":"./dist/web/scratch-vm.js","node":"./dist/node/scratch-vm.js","default":"./src/index.js"},"scripts":{"build":"npm run docs && webpack --progress","coverage":"tap ./test/{unit,integration}/*.js --coverage --coverage-report=lcov","docs":"jsdoc -c .jsdoc.json","i18n:src":"mkdirp translations/core && format-message extract --out-file translations/core/en.json src/extensions/**/index.js","i18n:push":"tx-push-src scratch-editor extensions translations/core/en.json","lint":"eslint . && format-message lint src/**/*.js","prepare":"husky install","prepublish":"in-publish && npm run build || not-in-publish","start":"webpack serve","tap":"tap ./test/{unit,integration}/*.js","tap:unit":"tap ./test/unit/*.js","tap:integration":"tap ./test/integration/*.js","test":"npm run lint && npm run tap","watch":"webpack --progress --watch","version":"json -f package.json -I -e \\"this.repository.sha = \'$(git log -n1 --pretty=format:%H)\'\\""},"config":{"commitizen":{"path":"cz-conventional-changelog"}},"browserslist":["Chrome >= 63","Edge >= 15","Firefox >= 57","Safari >= 11"],"tap":{"branches":60,"functions":70,"lines":70,"statements":70},"dependencies":{"@vernier/godirect":"^1.5.0","arraybuffer-loader":"^1.0.6","atob":"^2.1.2","btoa":"^1.2.1","buffer":"^6.0.3","canvas-toBlob":"^1.0.0","decode-html":"^2.0.0","diff-match-patch":"^1.0.4","format-message":"^6.2.1","htmlparser2":"^3.10.0","immutable":"^3.8.1","jszip":"^3.1.5","minilog":"^3.1.0","scratch-audio":"^2.0.0","scratch-parser":"^6.0.0","scratch-render":"^2.0.0","scratch-sb1-converter":"^2.0.0","scratch-storage":"^4.0.0","scratch-svg-renderer":"3.0.114","scratch-translate-extension-languages":"^1.0.0","text-encoding":"^0.7.0","uuid":"^8.3.2","web-worker":"^1.3.0"},"devDependencies":{"@babel/core":"7.27.1","@babel/eslint-parser":"7.27.1","@babel/preset-env":"7.27.1","@commitlint/cli":"17.8.1","@commitlint/config-conventional":"17.8.1","adm-zip":"0.4.11","babel-loader":"9.2.1","callsite":"1.0.0","copy-webpack-plugin":"4.6.0","docdash":"1.2.0","eslint":"8.57.1","eslint-config-scratch":"9.0.9","expose-loader":"1.0.3","file-loader":"6.2.0","format-message-cli":"6.2.4","husky":"8.0.3","in-publish":"2.0.1","js-md5":"0.7.3","jsdoc":"3.6.11","json":"^9.0.4","pngjs":"3.4.0","scratch-blocks":"1.1.210","scratch-l10n":"5.0.230","scratch-render-fonts":"1.0.190","scratch-semantic-release-config":"3.0.0","scratch-webpack-configuration":"3.0.0","script-loader":"0.7.2","semantic-release":"19.0.5","stats.js":"0.17.0","tap":"16.3.10","webpack":"5.99.7","webpack-cli":"4.10.0","webpack-dev-server":"3.11.3"}}');
/***/ }),
/***/ "./src/blocks/scratch3_control.js":
/*!****************************************!*\
!*** ./src/blocks/scratch3_control.js ***!
\****************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
const Cast = __webpack_require__(/*! ../util/cast */ "./src/util/cast.js");
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;
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) {
// Cast argument to string
args.CLONE_OPTION = Cast.toString(args.CLONE_OPTION);
// Set clone target
let cloneTarget;
if (args.CLONE_OPTION === '_myself_') {
cloneTarget = util.target;
} else {
cloneTarget = this.runtime.getSpriteTargetByName(args.CLONE_OPTION);
}
// 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;
/***/ }),
/***/ "./src/blocks/scratch3_core_example.js":
/*!*********************************************!*\
!*** ./src/blocks/scratch3_core_example.js ***!
\*********************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
const BlockType = __webpack_require__(/*! ../extension-support/block-type */ "./src/extension-support/block-type.js");
const ArgumentType = __webpack_require__(/*! ../extension-support/argument-type */ "./src/extension-support/argument-type.js");
/* 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;
/***/ }),
/***/ "./src/blocks/scratch3_data.js":
/*!*************************************!*\
!*** ./src/blocks/scratch3_data.js ***!
\*************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
const Cast = __webpack_require__(/*! ../util/cast */ "./src/util/cast.js");
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);
if (list.value.length < Scratch3DataBlocks.LIST_ITEM_LIMIT) {
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;
}
const listLimit = Scratch3DataBlocks.LIST_ITEM_LIMIT;
if (index > listLimit) return;
list.value.splice(index - 1, 0, item);
if (list.value.length > listLimit) {
// If inserting caused the list to grow larger than the limit,
// remove the last element in the list
list.value.pop();
}
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;
}
/**
* Type representation for list variables.
* @const {number}
*/
static get LIST_ITEM_LIMIT() {
return 200000;
}
}
module.exports = Scratch3DataBlocks;
/***/ }),
/***/ "./src/blocks/scratch3_event.js":
/*!**************************************!*\
!*** ./src/blocks/scratch3_event.js ***!
\**************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
const Cast = __webpack_require__(/*! ../util/cast */ "./src/util/cast.js");
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;
/***/ }),
/***/ "./src/blocks/scratch3_looks.js":
/*!**************************************!*\
!*** ./src/blocks/scratch3_looks.js ***!
\**************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
const Cast = __webpack_require__(/*! ../util/cast */ "./src/util/cast.js");
const Clone = __webpack_require__(/*! ../util/clone */ "./src/util/clone.js");
const RenderedTarget = __webpack_require__(/*! ../sprites/rendered-target */ "./src/sprites/rendered-target.js");
const uid = __webpack_require__(/*! ../util/uid */ "./src/util/uid.js");
const StageLayering = __webpack_require__(/*! ../engine/stage-layering */ "./src/engine/stage-layering.js");
const getMonitorIdForBlockWithArgs = __webpack_require__(/*! ../util/get-monitor-id */ "./src/util/get-monitor-id.js");
const MathUtil = __webpack_require__(/*! ../util/math-util */ "./src/util/math-util.js");
/**
* @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.removeListener(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this._onTargetChanged);
}
/**
* 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) {
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.addListener(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, 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 => "".concat(targetId, "_size")
},
looks_costumenumbername: {
isSpriteSpecific: true,
getId: (targetId, fields) => getMonitorIdForBlockWithArgs("".concat(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
this.runtime.emit(Scratch3LooksBlocks.SAY_OR_THINK, util.target, 'say', args.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) {
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) {
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);
}
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) {
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;
/***/ }),
/***/ "./src/blocks/scratch3_motion.js":
/*!***************************************!*\
!*** ./src/blocks/scratch3_motion.js ***!
\***************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
const Cast = __webpack_require__(/*! ../util/cast */ "./src/util/cast.js");
const MathUtil = __webpack_require__(/*! ../util/math-util */ "./src/util/math-util.js");
const Timer = __webpack_require__(/*! ../util/timer */ "./src/util/timer.js");
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() {
return {
motion_movesteps: this.moveSteps,
motion_gotoxy: this.goToXY,
motion_goto: this.goTo,
motion_turnright: this.turnRight,
motion_turnleft: this.turnLeft,
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 => "".concat(targetId, "_xposition")
},
motion_yposition: {
isSpriteSpecific: true,
getId: targetId => "".concat(targetId, "_yposition")
},
motion_direction: {
isSpriteSpecific: true,
getId: targetId => "".concat(targetId, "_direction")
}
};
}
moveSteps(args, util) {
const steps = Cast.toNumber(args.STEPS);
const radians = MathUtil.degToRad(90 - util.target