diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index de686d4f43b..856271cb155 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -19,4 +19,3 @@ jobs: - run: npm ci - run: npm run lint - run: npm run build - - run: npm run test diff --git a/package-lock.json b/package-lock.json index 8884150aba3..df78418005c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "format-message": "6.2.1", "htmlparser2": "3.10.0", "immutable": "3.8.2", - "scratch-parser": "github:TurboWarp/scratch-parser#master", + "json5": "^2.2.3", + "scratch-parser": "github:Unsandboxed/scratch-parser#master", "scratch-sb1-converter": "0.2.7", "scratch-translate-extension-languages": "0.0.20191118205314", "text-encoding": "0.7.0", @@ -37,6 +38,7 @@ "babel-loader": "8.2.2", "callsite": "1.0.0", "copy-webpack-plugin": "4.5.4", + "depended": "github:Unsandboxed/depended#main", "docdash": "1.2.0", "eslint": "8.55.0", "eslint-config-scratch": "9.0.3", @@ -2187,10 +2189,9 @@ "integrity": "sha512-texcM9oxfEsADVlVDR5UhLkYclPKsV9mytJh+9pHHonNcUrxRVGF6FkJTzWO/Hl5NafU1crSdw737nqKE3atSA==" }, "node_modules/@turbowarp/sb3fix": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@turbowarp/sb3fix/-/sb3fix-0.3.0.tgz", - "integrity": "sha512-tZjpPb37UUnr6mxeZ12FLqHFhQ66rIUEIfFMWoLLzmt8VFE6JjM74chyphsB3AwON5ehs678zzXkhRSOZgPK3A==", - "license": "MPL-2.0", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@turbowarp/sb3fix/-/sb3fix-0.3.2.tgz", + "integrity": "sha512-vrTT0tf3ax/wIodE/Hzcn5p9O4DWaTCaenS/HHhSvxgjgrOiP6wuqs0aTQqhaxYrEKA5ypjVq59+kbII0EOVVw==", "dependencies": { "@turbowarp/jszip": "^3.11.0" } @@ -4717,6 +4718,15 @@ "node": ">=0.10" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -5109,6 +5119,32 @@ "node": ">= 0.8" } }, + "node_modules/depended": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/Unsandboxed/depended.git#817f2ba4662bc25155ea2a9870be89d59d47a2b9", + "dev": true, + "dependencies": { + "node-fetch": "^3.3.2" + } + }, + "node_modules/depended/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -6973,6 +7009,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -7923,6 +7982,18 @@ "integrity": "sha512-72j+ATEN13NFJ1hYaPcDVJEE37BD1P29plLIdCqEMwezVa1c7VSPgRB1eZnkoWxm4YKFgS770pJlE1ZczACqgQ==", "dev": true }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -10339,7 +10410,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -11451,6 +11521,25 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -13568,8 +13657,7 @@ }, "node_modules/scratch-parser": { "version": "0.0.0-development", - "resolved": "git+ssh://git@github.com/TurboWarp/scratch-parser.git#e00db024414831fb61a1117f8585f91fc177bf4c", - "license": "MPL-2.0", + "resolved": "git+ssh://git@github.com/Unsandboxed/scratch-parser.git#58a37c6091ff2a442fa360058c448129179def4a", "dependencies": { "@turbowarp/json": "^0.1.1", "@turbowarp/jszip": "^3.11.0", @@ -18916,6 +19004,15 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 2c841260adb..902bd23c49f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "tap:integration": "tap ./test/integration/*.js", "test": "npm run lint && npm run tap", "watch": "webpack --progress --colors --watch", - "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"" + "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"", + "update": "node scripts/updatedeps.mjs" }, "tap": { "branches": 60, @@ -47,7 +48,8 @@ "format-message": "6.2.1", "htmlparser2": "3.10.0", "immutable": "3.8.2", - "scratch-parser": "github:TurboWarp/scratch-parser#master", + "json5": "^2.2.3", + "scratch-parser": "github:Unsandboxed/scratch-parser#master", "scratch-sb1-converter": "0.2.7", "scratch-translate-extension-languages": "0.0.20191118205314", "text-encoding": "0.7.0", @@ -65,6 +67,7 @@ "babel-loader": "8.2.2", "callsite": "1.0.0", "copy-webpack-plugin": "4.5.4", + "depended": "github:Unsandboxed/depended#main", "docdash": "1.2.0", "eslint": "8.55.0", "eslint-config-scratch": "9.0.3", diff --git a/scripts/updatedeps.mjs b/scripts/updatedeps.mjs new file mode 100644 index 00000000000..5b5534ba039 --- /dev/null +++ b/scripts/updatedeps.mjs @@ -0,0 +1,4 @@ +import { v } from 'depended'; +v.updateDeps([ + ['scratch-parser', 'develop'] +]); diff --git a/src/blocks/scratch3_camera.js b/src/blocks/scratch3_camera.js new file mode 100644 index 00000000000..399ff987528 --- /dev/null +++ b/src/blocks/scratch3_camera.js @@ -0,0 +1,85 @@ +const Cast = require('../util/cast'); + +class Scratch3CameraBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } + + getMonitored () { + return { + camera_xposition: { + getId: () => 'xposition' + }, + camera_yposition: { + getId: () => 'yposition' + } + }; + } + + /** + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. + */ + getPrimitives () { + return { + camera_movetoxy: this.moveToXY, + camera_changebyxy: this.changeByXY, + camera_setx: this.setX, + camera_changex: this.changeX, + camera_sety: this.setY, + camera_changey: this.changeY, + camera_xposition: this.getCameraX, + camera_yposition: this.getCameraY + }; + } + + moveToXY (args) { + const x = Cast.toNumber(args.X); + const y = Cast.toNumber(args.Y); + this.runtime.camera.setXY(x, y); + } + + changeByXY (args) { + const x = Cast.toNumber(args.X); + const y = Cast.toNumber(args.Y); + const newX = x + this.runtime.camera.x; + const newY = y + this.runtime.camera.y; + this.runtime.camera.setXY(newX, newY); + } + + setX (args) { + const x = Cast.toNumber(args.X); + this.runtime.camera.setXY(x, this.runtime.camera.y); + } + + changeX (args) { + const x = Cast.toNumber(args.X); + const newX = x + this.runtime.camera.x; + this.runtime.camera.setXY(newX, this.runtime.camera.y); + } + + setY (args) { + const y = Cast.toNumber(args.Y); + this.runtime.camera.setXY(this.runtime.camera.x, y); + } + + changeY (args) { + const y = Cast.toNumber(args.Y); + const newY = y + this.runtime.camera.y; + this.runtime.camera.setXY(this.runtime.camera.x, newY); + } + + getCameraX () { + return this.runtime.camera.x; + } + + getCameraY () { + return this.runtime.camera.y; + } +} + +module.exports = Scratch3CameraBlocks; diff --git a/src/blocks/scratch3_control.js b/src/blocks/scratch3_control.js index ebf1951514e..4c0d20820f2 100644 --- a/src/blocks/scratch3_control.js +++ b/src/blocks/scratch3_control.js @@ -33,6 +33,8 @@ class Scratch3ControlBlocks { control_if: this.if, control_if_else: this.ifElse, control_stop: this.stop, + control_break: this.break, + control_continue: this.continue, control_create_clone_of: this.createClone, control_delete_this_clone: this.deleteClone, control_get_counter: this.getCounter, @@ -149,6 +151,14 @@ class Scratch3ControlBlocks { } } + break (_, util) { + util.thread.breakCurrentLoop(); + } + + continue (_, util) { + util.thread.continueCurrentLoop(); + } + createClone (args, util) { this._createClone(Cast.toString(args.CLONE_OPTION), util.target); } @@ -193,13 +203,13 @@ class Scratch3ControlBlocks { } 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.) + // In Scratch 3.0 and TurboWarp, this would simply + // run the contained substack. In Unsandboxed, + // we've reimplemented the intended functionality + // of running the stack all in one frame. + util.thread.peekStackFrame().warpMode = false; util.startBranch(1, false); + util.thread.peekStackFrame().warpMode = true; } } diff --git a/src/blocks/scratch3_data.js b/src/blocks/scratch3_data.js index 1a2e5b86eea..d3568d72800 100644 --- a/src/blocks/scratch3_data.js +++ b/src/blocks/scratch3_data.js @@ -1,4 +1,5 @@ const Cast = require('../util/cast'); +const Clone = require('../util/clone'); class Scratch3DataBlocks { constructor (runtime) { @@ -21,11 +22,13 @@ class Scratch3DataBlocks { data_hidevariable: this.hideVariable, data_showvariable: this.showVariable, data_listcontents: this.getListContents, + data_listarraycontents: this.getListArrayContents, data_addtolist: this.addToList, data_deleteoflist: this.deleteOfList, data_deletealloflist: this.deleteAllOfList, data_insertatlist: this.insertAtList, data_replaceitemoflist: this.replaceItemOfList, + data_setlist: this.setList, data_itemoflist: this.getItemOfList, data_itemnumoflist: this.getItemNumOfList, data_lengthoflist: this.lengthOfList, @@ -90,6 +93,23 @@ class Scratch3DataBlocks { this.changeMonitorVisibility(args.LIST.id, false); } + getListArrayContents (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(); + } + + return Clone.structured(list.value); + } + getListContents (args, util) { const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); @@ -126,6 +146,8 @@ class Scratch3DataBlocks { addToList (args, util) { const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); + if (list.locked) return; + list.value.push(args.ITEM); list._monitorUpToDate = false; } @@ -133,6 +155,7 @@ class Scratch3DataBlocks { deleteOfList (args, util) { const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); + if (list.locked) return; const index = Cast.toListIndex(args.INDEX, list.value.length, true); if (index === Cast.LIST_INVALID) { return; @@ -147,6 +170,7 @@ class Scratch3DataBlocks { deleteAllOfList (args, util) { const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); + if (list.locked) return; list.value = []; return; } @@ -155,6 +179,7 @@ class Scratch3DataBlocks { const item = args.ITEM; const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); + if (list.locked) return; const index = Cast.toListIndex(args.INDEX, list.value.length + 1, false); if (index === Cast.LIST_INVALID) { return; @@ -167,6 +192,7 @@ class Scratch3DataBlocks { const item = args.ITEM; const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); + if (list.locked) return; const index = Cast.toListIndex(args.INDEX, list.value.length, false); if (index === Cast.LIST_INVALID) { return; @@ -175,6 +201,15 @@ class Scratch3DataBlocks { list._monitorUpToDate = false; } + setList (args, util) { + const array = Cast.toArray(args.ARRAY); + const list = util.target.lookupOrCreateList( + args.LIST.id, args.LIST.name); + if (list.locked) return; + list.value = array; + list._monitorUpToDate = false; + } + getItemOfList (args, util) { const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); diff --git a/src/blocks/scratch3_event.js b/src/blocks/scratch3_event.js index 10ceb10cc3d..5bfad8217f7 100644 --- a/src/blocks/scratch3_event.js +++ b/src/blocks/scratch3_event.js @@ -24,6 +24,7 @@ class Scratch3EventBlocks { */ getPrimitives () { return { + event_when: this.when, event_whentouchingobject: this.touchingObject, event_broadcast: this.broadcast, event_broadcastandwait: this.broadcastAndWait, @@ -36,6 +37,10 @@ class Scratch3EventBlocks { event_whenflagclicked: { restartExistingThreads: true }, + event_when: { + restartExistingThreads: false, + edgeActivated: true + }, event_whenkeypressed: { restartExistingThreads: false }, @@ -62,6 +67,11 @@ class Scratch3EventBlocks { }; } + when (args) { + const condition = Cast.toBoolean(args.CONDITION); + return condition; + } + touchingObject (args, util) { return util.target.isTouchingObject(args.TOUCHINGOBJECTMENU); } diff --git a/src/blocks/scratch3_looks.js b/src/blocks/scratch3_looks.js index e84e0f83893..9c509e2b72a 100644 --- a/src/blocks/scratch3_looks.js +++ b/src/blocks/scratch3_looks.js @@ -171,7 +171,8 @@ class Scratch3LooksBlocks { bottom: target.y }; } - const stageSize = this.runtime.renderer.getNativeSize(); + // usb: remove bounds to support camera + const stageSize = [Infinity, Infinity]; const stageBounds = { left: -stageSize[0] / 2, right: stageSize[0] / 2, @@ -256,7 +257,7 @@ class Scratch3LooksBlocks { } // Limit the length of the string. - text = String(text).substr(0, Scratch3LooksBlocks.SAY_BUBBLE_LIMIT); + text = Cast.toString(text).substr(0, Scratch3LooksBlocks.SAY_BUBBLE_LIMIT); return text; } @@ -298,6 +299,7 @@ class Scratch3LooksBlocks { looks_changeeffectby: this.changeEffect, looks_seteffectto: this.setEffect, looks_cleargraphiceffects: this.clearEffects, + looks_effect: this.getEffect, looks_changesizeby: this.changeSize, looks_setsizeto: this.setSize, looks_changestretchby: () => {}, // legacy no-op blocks @@ -312,6 +314,10 @@ class Scratch3LooksBlocks { getMonitored () { return { + looks_effect: { + isSpriteSpecific: true, + getId: (targetId, fields) => getMonitorIdForBlockWithArgs(`${targetId}_effect`, fields) + }, looks_size: { isSpriteSpecific: true, getId: targetId => `${targetId}_size` @@ -395,7 +401,7 @@ class Scratch3LooksBlocks { target.setCostume(optZeroIndex ? requestedCostume : requestedCostume - 1); } else { // Strings should be treated as costume names, where possible - const costumeIndex = target.getCostumeIndexByName(requestedCostume.toString()); + const costumeIndex = target.getCostumeIndexByName(Cast.toString(requestedCostume)); if (costumeIndex !== -1) { target.setCostume(costumeIndex); @@ -429,7 +435,7 @@ class Scratch3LooksBlocks { stage.setCostume(optZeroIndex ? requestedBackdrop : requestedBackdrop - 1); } else { // Strings should be treated as backdrop names where possible - const costumeIndex = stage.getCostumeIndexByName(requestedBackdrop.toString()); + const costumeIndex = stage.getCostumeIndexByName(Cast.toString(requestedBackdrop)); if (costumeIndex !== -1) { stage.setCostume(costumeIndex); @@ -560,6 +566,11 @@ class Scratch3LooksBlocks { util.target.clearEffects(); } + getEffect (args, util) { + const effect = Cast.toString(args.EFFECT).toLowerCase(); + return util.target.getEffect(effect); + } + changeSize (args, util) { const change = Cast.toNumber(args.CHANGE); util.target.setSize(util.target.size + change); diff --git a/src/blocks/scratch3_motion.js b/src/blocks/scratch3_motion.js index 057d73db124..3aa489b9588 100644 --- a/src/blocks/scratch3_motion.js +++ b/src/blocks/scratch3_motion.js @@ -20,10 +20,12 @@ class Scratch3MotionBlocks { motion_movesteps: this.moveSteps, motion_gotoxy: this.goToXY, motion_goto: this.goTo, + motion_changebyxy: this.changeByXY, motion_turnright: this.turnRight, motion_turnleft: this.turnLeft, motion_pointindirection: this.pointInDirection, motion_pointtowards: this.pointTowards, + motion_pointtowardsxy: this.pointTowardsXY, motion_glidesecstoxy: this.glide, motion_glideto: this.glideTo, motion_ifonedgebounce: this.ifOnEdgeBounce, @@ -35,6 +37,7 @@ class Scratch3MotionBlocks { motion_xposition: this.getX, motion_yposition: this.getY, motion_direction: this.getDirection, + motion_rotationstyle: this.getRotationStyle, // Legacy no-op blocks: motion_scroll_right: () => {}, motion_scroll_up: () => {}, @@ -57,6 +60,10 @@ class Scratch3MotionBlocks { motion_direction: { isSpriteSpecific: true, getId: targetId => `${targetId}_direction` + }, + motion_rotationstyle: { + isSpriteSpecific: true, + getId: targetId => `${targetId}_rotationstyle` } }; } @@ -78,17 +85,34 @@ class Scratch3MotionBlocks { util.target.setXY(x, y); } + changeByXY (args, util) { // used by compiler + const dx = Cast.toNumber(args.X); + const dy = Cast.toNumber(args.Y); + util.target.setXY(util.target.x + dx, util.target.y + dy); + } + getTargetXY (targetName, util) { let targetX = 0; let targetY = 0; if (targetName === '_mouse_') { targetX = util.ioQuery('mouse', 'getScratchX'); targetY = util.ioQuery('mouse', 'getScratchY'); + } else if (targetName === '_camera_') { + targetX = this.runtime.camera.x; + targetY = this.runtime.camera.y; } else if (targetName === '_random_') { const stageWidth = this.runtime.stageWidth; const stageHeight = this.runtime.stageHeight; targetX = Math.round(stageWidth * (Math.random() - 0.5)); targetY = Math.round(stageHeight * (Math.random() - 0.5)); + + // usb: transform based on camera + targetX = this.runtime.renderer.translateX( + targetX, false, 1, true, targetY, 1 + ); + targetY = this.runtime.renderer.translateY( + targetY, false, 1, true, targetX, 1 + ); } else { targetName = Cast.toString(targetName); const goToTarget = this.runtime.getSpriteTargetByName(targetName); @@ -127,6 +151,9 @@ class Scratch3MotionBlocks { if (args.TOWARDS === '_mouse_') { targetX = util.ioQuery('mouse', 'getScratchX'); targetY = util.ioQuery('mouse', 'getScratchY'); + } else if (args.TOWARDS === '_camera_') { + targetX = this.runtime.camera.x; + targetY = this.runtime.camera.y; } else if (args.TOWARDS === '_random_') { util.target.setDirection(Math.round(Math.random() * 360) - 180); return; @@ -144,6 +171,13 @@ class Scratch3MotionBlocks { util.target.setDirection(direction); } + pointTowardsXY (args, util) { // used by compiler + const dx = Cast.toNumber(args.X) - util.target.x; + const dy = Cast.toNumber(args.Y) - util.target.y; + const direction = 90 - MathUtil.radToDeg(Math.atan2(dy, dx)); + util.target.setDirection(direction); + } + glide (args, util) { if (util.stackFrame.timer) { const timeElapsed = util.stackFrame.timer.timeElapsed(); @@ -281,6 +315,10 @@ class Scratch3MotionBlocks { return util.target.direction; } + getRotationStyle (args, util) { + return util.target.rotationStyle; + } + // This corresponds to snapToInteger in Scratch 2 limitPrecision (coordinate) { const rounded = Math.round(coordinate); diff --git a/src/blocks/scratch3_operators.js b/src/blocks/scratch3_operators.js index a2a5ab4bd2b..3c0bbf1199d 100644 --- a/src/blocks/scratch3_operators.js +++ b/src/blocks/scratch3_operators.js @@ -20,18 +20,21 @@ class Scratch3OperatorsBlocks { operator_subtract: this.subtract, operator_multiply: this.multiply, operator_divide: this.divide, + operator_exponent: this.exponent, + operator_clamp: this.clamp, operator_lt: this.lt, + operator_lt_equals: this.ltEquals, operator_equals: this.equals, operator_gt: this.gt, + operator_gt_equals: this.gtEquals, operator_and: this.and, operator_or: this.or, + operator_xor: this.xor, operator_not: this.not, operator_random: this.random, - operator_join: this.join, - operator_letter_of: this.letterOf, - operator_length: this.length, - operator_contains: this.contains, operator_mod: this.mod, + operator_min: this.min, + operator_max: this.max, operator_round: this.round, operator_mathop: this.mathop }; @@ -53,10 +56,18 @@ class Scratch3OperatorsBlocks { return Cast.toNumber(args.NUM1) / Cast.toNumber(args.NUM2); } + exponent (args) { + return Cast.toNumber(args.NUM1) ** Cast.toNumber(args.NUM2); + } + lt (args) { return Cast.compare(args.OPERAND1, args.OPERAND2) < 0; } + ltEquals (args) { + return Cast.compare(args.OPERAND1, args.OPERAND2) <= 0; + } + equals (args) { return Cast.compare(args.OPERAND1, args.OPERAND2) === 0; } @@ -65,6 +76,10 @@ class Scratch3OperatorsBlocks { return Cast.compare(args.OPERAND1, args.OPERAND2) > 0; } + gtEquals (args) { + return Cast.compare(args.OPERAND1, args.OPERAND2) >= 0; + } + and (args) { return Cast.toBoolean(args.OPERAND1) && Cast.toBoolean(args.OPERAND2); } @@ -73,10 +88,37 @@ class Scratch3OperatorsBlocks { return Cast.toBoolean(args.OPERAND1) || Cast.toBoolean(args.OPERAND2); } + xor (args) { + return Cast.toBoolean(args.OPERAND1) !== Cast.toBoolean(args.OPERAND2); + } + not (args) { return !Cast.toBoolean(args.OPERAND); } + min (args) { + const n1 = Cast.toNumber(args.NUM1); + const n2 = Cast.toNumber(args.NUM2); + return Math.min(n1, n2); + } + + max (args) { + const n1 = Cast.toNumber(args.NUM1); + const n2 = Cast.toNumber(args.NUM2); + return Math.max(n1, n2); + } + + clamp (args) { + const n = Cast.toNumber(args.NUM); + const from = Cast.toNumber(args.FROM); + const to = Cast.toNumber(args.TO); + + if (from > to) { + return Math.min(Math.max(n, to), from); + } + return Math.min(Math.max(n, from), to); + } + random (args) { return this._random(args.FROM, args.TO); } @@ -93,31 +135,6 @@ class Scratch3OperatorsBlocks { return (Math.random() * (high - low)) + low; } - join (args) { - return Cast.toString(args.STRING1) + Cast.toString(args.STRING2); - } - - letterOf (args) { - const index = Cast.toNumber(args.LETTER) - 1; - const str = Cast.toString(args.STRING); - // Out of bounds? - if (index < 0 || index >= str.length) { - return ''; - } - return str.charAt(index); - } - - length (args) { - return Cast.toString(args.STRING).length; - } - - contains (args) { - const format = function (string) { - return Cast.toString(string).toLowerCase(); - }; - return format(args.STRING1).includes(format(args.STRING2)); - } - mod (args) { const n = Cast.toNumber(args.NUM1); const modulus = Cast.toNumber(args.NUM2); diff --git a/src/blocks/scratch3_procedures.js b/src/blocks/scratch3_procedures.js index 679fdf128e8..e8205b569d6 100644 --- a/src/blocks/scratch3_procedures.js +++ b/src/blocks/scratch3_procedures.js @@ -90,6 +90,7 @@ class Scratch3ProcedureBlocks { } util.startProcedure(procedureCode); + util.thread.tryCompile(); } return (args, util) { @@ -122,7 +123,7 @@ class Scratch3ProcedureBlocks { if (util.target.runtime.compilerOptions.enabled && lowercaseValue === 'is compiled?') { return true; } - if (lowercaseValue === 'is turbowarp?') { + if (lowercaseValue === 'is unsandboxed?') { return true; } // When the parameter is not found in the most recent procedure diff --git a/src/blocks/scratch3_sensing.js b/src/blocks/scratch3_sensing.js index 15c071cb7f5..7152c62d406 100644 --- a/src/blocks/scratch3_sensing.js +++ b/src/blocks/scratch3_sensing.js @@ -203,6 +203,9 @@ class Scratch3SensingBlocks { if (args.DISTANCETOMENU === '_mouse_') { targetX = util.ioQuery('mouse', 'getScratchX'); targetY = util.ioQuery('mouse', 'getScratchY'); + } else if (args.DISTANCETOMENU === '_camera_') { + targetX = this.runtime.camera.x; + targetY = this.runtime.camera.y; } else { args.DISTANCETOMENU = Cast.toString(args.DISTANCETOMENU); const distTarget = this.runtime.getSpriteTargetByName( @@ -253,6 +256,7 @@ class Scratch3SensingBlocks { case 'hour': return date.getHours(); case 'minute': return date.getMinutes(); case 'second': return date.getSeconds(); + case 'millisecond': return date.getMilliseconds(); } return 0; } diff --git a/src/blocks/scratch3_string.js b/src/blocks/scratch3_string.js new file mode 100644 index 00000000000..6538074b412 --- /dev/null +++ b/src/blocks/scratch3_string.js @@ -0,0 +1,194 @@ +const Cast = require('../util/cast.js'); + +class Scratch3StringBlocks { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } + + /** + * Retrieve the block primitives implemented by this package. + * @return {object.} Mapping of opcode to Function. + */ + getPrimitives () { + return { + operator_join: this.join, + operator_letter_of: this.letterOf, + operator_letters_of: this.lettersOf, + operator_length: this.length, + operator_contains: this.contains, + string_reverse: this.reverse, + string_repeat: this.repeat, + string_replace: this.replace, + string_item_split: this.itemSplit, + string_ternary: this.ternary, + string_convert: this.convertTo, + string_index_of: this.indexOf, + string_exactly: this.exactly, + string_is: this.stringIs + }; + } + + length (args) { + return Cast.toString(args.STRING).length; + } + + join (args) { + return Cast.toString(args.STRING1) + Cast.toString(args.STRING2); + } + + reverse (args) { // usb + const str = Cast.toString(args.STRING); + + return str.split('').reverse() + .join(''); + } + + repeat (args) { // usb + const str = Cast.toString(args.STRING); + const times = Cast.toNumber(args.NUMBER); + + return str.repeat(times); + } + + replace (args) { // usb + const old = Cast.toString(args.REPLACE); + const replacer = Cast.toString(args.WITH); + const str = Cast.toString(args.STRING); + + return str.replace(new RegExp(old, 'gi'), replacer); + } + + letterOf (args) { + const str = Cast.toString(args.STRING); + return this._getLetterOf(str, args.LETTER); + } + + _getLetterOf (string, index) { // usb // used by compiler + // usb: we support some weird dropdowns now + if (index === 'last') { + index = string.length - 1; + } else if (index === 'random') { + index = Math.floor(Math.random() * string.length); + } else { + index = Cast.toNumber(index) - 1; + } + + // Out of bounds? + if (index < 0 || index >= string.length) { + return ''; + } + + return string.charAt(index); + } + + lettersOf (args) { // usb + const index1 = Cast.toNumber(args.LETTER1); + const index2 = Cast.toNumber(args.LETTER2); + const str = Cast.toString(args.STRING); + + return str.slice(Math.max(index1, 1) - 1, Math.min(str.length, index2)); + } + + itemSplit (args) { // usb + const str = Cast.toString(args.STRING).toLowerCase(); + const split = Cast.toString(args.SPLIT); + + return this._getItemFromSplit(str, split, args.INDEX); + } + + _getItemFromSplit (string, split, index) { // used by compiler + const splitString = string.split(split); + + if (index === 'last') { + index = splitString.length - 1; + } else if (index === 'random') { + index = Math.floor(Math.random() * splitString.length); + } else { + index = Cast.toNumber(index) - 1; + } + + return splitString[index] ?? ''; + } + + ternary (args) { // usb + const condition = Cast.toBoolean(args.CONDITION); + const str1 = Cast.toString(args.STRING1); + const str2 = Cast.toString(args.STRING2); + + return condition ? str1 : str2; + } + + convertTo (args) { // usb + const str = Cast.toString(args.STRING); + const convert = Cast.toString(args.CONVERT).toLowerCase(); + + return this._convertString(str, convert); + } + + _convertString (string, textCase) { // used by compiler + if (textCase === 'lowercase') { + return string.toLowerCase(); + } + + return string.toUpperCase(); + } + + indexOf (args) { // usb + const find = Cast.toString(args.STRING1).toLowerCase(); + const str = Cast.toString(args.STRING2).toLowerCase(); + + return this._getNumberIndex(find, str, args.INDEX); + } + + _getNumberIndex (find, string, index) { // used by compiler + const length = find.length; + if (length > string.length) return 0; + + const occurences = []; + for (let i = 0; i < string.length; i++) { + if (string.substring(i, i + length) === find) { + occurences.push(i); + } + } + + if (index === 'last') { + index = occurences.length - 1; + } else if (index === 'random') { + index = Math.floor(Math.random() * occurences.length); + } else { + index = Cast.toNumber(index) - 1; + } + + return occurences[index] ?? 0; + } + + contains (args) { + const format = function (string) { + return Cast.toString(string).toLowerCase(); + }; + return format(args.STRING1).includes(format(args.STRING2)); + } + + exactly (args) { // usb + const str1 = args.STRING1; + const str2 = args.STRING2; + return str1 === str2; + } + + stringIs (args) { // usb + const str = Cast.toString(args.STRING); + const check = Cast.toString(args.CONVERT).toLowerCase(); + + if (check === 'lowercase') { + return str.toLowerCase() === str; + } + return str.toUpperCase() === str; + } + +} + +module.exports = Scratch3StringBlocks; diff --git a/src/compiler/compat-block-utility.js b/src/compiler/compat-block-utility.js index 6fedba1d9d2..6b9033a2f46 100644 --- a/src/compiler/compat-block-utility.js +++ b/src/compiler/compat-block-utility.js @@ -10,7 +10,8 @@ class CompatibilityLayerBlockUtility extends BlockUtility { return this.thread.compatibilityStackFrame; } - startBranch (branchNumber, isLoop) { + startBranch (branchNumber, isLoop, onEnd) { + if (this._branchInfo && onEnd) this._branchInfo.onEnd.push(onEnd); this._startedBranch = [branchNumber, isLoop]; } @@ -29,10 +30,11 @@ class CompatibilityLayerBlockUtility extends BlockUtility { throw new Error('getParam is not supported by this BlockUtility'); } - init (thread, fakeBlockId, stackFrame) { + init (thread, fakeBlockId, stackFrame, branchInfo) { this.thread = thread; this.sequencer = thread.target.runtime.sequencer; this._startedBranch = null; + this._branchInfo = branchInfo; thread.stack[0] = fakeBlockId; thread.compatibilityStackFrame = stackFrame; } diff --git a/src/compiler/compat-blocks.js b/src/compiler/compat-blocks.js index 1c9d8f5ae9a..fe6825e04f0 100644 --- a/src/compiler/compat-blocks.js +++ b/src/compiler/compat-blocks.js @@ -6,6 +6,14 @@ // Please keep these lists alphabetical. const stacked = [ + // usb to do: compile these when working + 'camera_movetoxy', + 'camera_changebyxy', + 'camera_setx', + 'camera_changex', + 'camera_sety', + 'camera_changey', + 'looks_changestretchby', 'looks_hideallsprites', 'looks_say', @@ -18,7 +26,9 @@ const stacked = [ 'motion_glidesecstoxy', 'motion_glideto', 'motion_goto', + 'motion_changebyxy', 'motion_pointtowards', + 'motion_pointtowardsxy', 'motion_scroll_right', 'motion_scroll_up', 'sensing_askandwait', @@ -34,12 +44,19 @@ const stacked = [ ]; const inputs = [ + 'looks_effect', 'motion_xscroll', 'motion_yscroll', 'sensing_loud', 'sensing_loudness', 'sensing_userid', - 'sound_volume' + 'sound_volume', + + 'operator_letter_of', + 'string_item_split', + 'string_convert', + 'string_index_of', + 'string_ternary' ]; module.exports = { diff --git a/src/compiler/irgen.js b/src/compiler/irgen.js index 0ef1a696e88..ef6e54e08d1 100644 --- a/src/compiler/irgen.js +++ b/src/compiler/irgen.js @@ -189,11 +189,10 @@ class ScriptTreeGenerator { kind: 'tw.lastKeyPressed' }; } - } - if (index === -1) { + return { - kind: 'constant', - value: 0 + kind: 'args.parameter', + name: name }; } return { @@ -206,7 +205,7 @@ class ScriptTreeGenerator { const name = block.fields.VALUE.value; const index = this.script.arguments.lastIndexOf(name); if (index === -1) { - if (name.toLowerCase() === 'is compiled?' || name.toLowerCase() === 'is turbowarp?') { + if (name.toLowerCase() === 'is compiled?' || name.toLowerCase() === 'is unsandboxed?') { return { kind: 'constant', value: true @@ -223,6 +222,15 @@ class ScriptTreeGenerator { }; } + case 'camera_xposition': + return { + kind: 'camera.x' + }; + case 'camera_yposition': + return { + kind: 'camera.y' + }; + case 'control_get_counter': return { kind: 'counter.get' @@ -261,6 +269,11 @@ class ScriptTreeGenerator { kind: 'list.contents', list: this.descendVariable(block, 'LIST', LIST_TYPE) }; + case 'data_listarraycontents': + return { + kind: 'list.arraycontents', + list: this.descendVariable(block, 'LIST', LIST_TYPE) + }; case 'event_broadcast_menu': { const broadcastOption = block.fields.BROADCAST_OPTION; @@ -296,6 +309,10 @@ class ScriptTreeGenerator { kind: 'looks.size' }; + case 'motion_rotationstyle': + return { + kind: 'motion.rotationStyle' + }; case 'motion_direction': return { kind: 'motion.direction' @@ -321,6 +338,13 @@ class ScriptTreeGenerator { left: this.descendInputOfBlock(block, 'OPERAND1'), right: this.descendInputOfBlock(block, 'OPERAND2') }; + case 'operator_clamp': + return { + kind: 'op.clamp', + num: this.descendInputOfBlock(block, 'NUM'), + left: this.descendInputOfBlock(block, 'FROM'), + right: this.descendInputOfBlock(block, 'TO') + }; case 'operator_contains': return { kind: 'op.contains', @@ -333,6 +357,12 @@ class ScriptTreeGenerator { left: this.descendInputOfBlock(block, 'NUM1'), right: this.descendInputOfBlock(block, 'NUM2') }; + case 'operator_exponent': + return { + kind: 'op.exponent', + left: this.descendInputOfBlock(block, 'NUM1'), + right: this.descendInputOfBlock(block, 'NUM2') + }; case 'operator_equals': return { kind: 'op.equals', @@ -345,6 +375,12 @@ class ScriptTreeGenerator { left: this.descendInputOfBlock(block, 'OPERAND1'), right: this.descendInputOfBlock(block, 'OPERAND2') }; + case 'operator_gt_equals': + return { + kind: 'op.greaterEqual', + left: this.descendInputOfBlock(block, 'OPERAND1'), + right: this.descendInputOfBlock(block, 'OPERAND2') + }; case 'operator_join': return { kind: 'op.join', @@ -356,10 +392,17 @@ class ScriptTreeGenerator { kind: 'op.length', string: this.descendInputOfBlock(block, 'STRING') }; - case 'operator_letter_of': - return { - kind: 'op.letterOf', - letter: this.descendInputOfBlock(block, 'LETTER'), + // case 'operator_letter_of': + // return { + // kind: 'op.letterOf', + // letter: this.descendInputOfBlock(block, 'LETTER'), + // string: this.descendInputOfBlock(block, 'STRING') + // }; + case 'operator_letters_of': + return { + kind: 'op.lettersOf', + left: this.descendInputOfBlock(block, 'LETTER1'), + right: this.descendInputOfBlock(block, 'LETTER2'), string: this.descendInputOfBlock(block, 'STRING') }; case 'operator_lt': @@ -368,6 +411,12 @@ class ScriptTreeGenerator { left: this.descendInputOfBlock(block, 'OPERAND1'), right: this.descendInputOfBlock(block, 'OPERAND2') }; + case 'operator_lt_equals': + return { + kind: 'op.lessEqual', + left: this.descendInputOfBlock(block, 'OPERAND1'), + right: this.descendInputOfBlock(block, 'OPERAND2') + }; case 'operator_mathop': { const value = this.descendInputOfBlock(block, 'NUM'); const operator = block.fields.OPERATOR.value.toLowerCase(); @@ -440,6 +489,18 @@ class ScriptTreeGenerator { left: this.descendInputOfBlock(block, 'NUM1'), right: this.descendInputOfBlock(block, 'NUM2') }; + case 'operator_min': + return { + kind: 'op.min', + left: this.descendInputOfBlock(block, 'NUM1'), + right: this.descendInputOfBlock(block, 'NUM2') + }; + case 'operator_max': + return { + kind: 'op.max', + left: this.descendInputOfBlock(block, 'NUM1'), + right: this.descendInputOfBlock(block, 'NUM2') + }; case 'operator_multiply': return { kind: 'op.multiply', @@ -535,6 +596,12 @@ class ScriptTreeGenerator { left: this.descendInputOfBlock(block, 'NUM1'), right: this.descendInputOfBlock(block, 'NUM2') }; + case 'operator_xor': + return { + kind: 'op.xor', + left: this.descendInputOfBlock(block, 'OPERAND1'), + right: this.descendInputOfBlock(block, 'OPERAND2') + }; case 'procedures_call': return this.descendProcedure(block); @@ -636,6 +703,37 @@ class ScriptTreeGenerator { kind: 'sensing.username' }; + case 'string_exactly': + return { + kind: 'str.exactly', + left: this.descendInputOfBlock(block, 'STRING1'), + right: this.descendInputOfBlock(block, 'STRING2') + }; + case 'string_is': + return { + kind: 'str.is', + left: this.descendInputOfBlock(block, 'STRING'), + right: block.fields.CONVERT.value + }; + case 'string_repeat': + return { + kind: 'str.repeat', + str: this.descendInputOfBlock(block, 'STRING'), + num: this.descendInputOfBlock(block, 'NUMBER') + }; + case 'string_replace': + return { + kind: 'str.replace', + left: this.descendInputOfBlock(block, 'REPLACE'), + right: this.descendInputOfBlock(block, 'WITH'), + str: this.descendInputOfBlock(block, 'STRING') + }; + case 'string_reverse': + return { + kind: 'str.reverse', + str: this.descendInputOfBlock(block, 'STRING') + }; + case 'sound_sounds_menu': // This menu is special compared to other menus -- it actually has an opcode function. return { @@ -659,7 +757,11 @@ class ScriptTreeGenerator { const blockInfo = this.getBlockInfo(block.opcode); if (blockInfo) { const type = blockInfo.info.blockType; - if (type === BlockType.REPORTER || type === BlockType.BOOLEAN) { + if ( + type === BlockType.ARRAY || type === BlockType.OBJECT || + type === BlockType.REPORTER || type === BlockType.BOOLEAN || + type === BlockType.INLINE + ) { return this.descendCompatLayer(block); } } @@ -690,15 +792,14 @@ class ScriptTreeGenerator { descendStackedBlock (block) { switch (block.opcode) { case 'control_all_at_once': - // In Scratch 3, this block behaves like "if 1 = 1" + // In Unsandboxed, attempts to run the script in 1 frame. return { - kind: 'control.if', + kind: 'control.allAtOnce', condition: { kind: 'constant', value: true }, - whenTrue: this.descendSubstack(block, 'SUBSTACK'), - whenFalse: [] + code: this.descendSubstack(block, 'SUBSTACK') }; case 'control_clear_counter': return { @@ -797,6 +898,16 @@ class ScriptTreeGenerator { kind: 'noop' }; } + case 'control_break': { + return { + kind: 'control.break' + }; + } + case 'control_continue': { + return { + kind: 'control.continue' + }; + } case 'control_wait': this.script.yields = true; return { @@ -889,6 +1000,12 @@ class ScriptTreeGenerator { variable: this.descendVariable(block, 'VARIABLE', SCALAR_TYPE), value: this.descendInputOfBlock(block, 'VALUE') }; + case 'data_setlist': + return { + kind: 'list.set', + list: this.descendVariable(block, 'LIST', LIST_TYPE), + array: this.descendInputOfBlock(block, 'ARRAY') + }; case 'data_showlist': return { kind: 'list.show', @@ -1445,7 +1562,7 @@ class ScriptTreeGenerator { const blockInfo = this.getBlockInfo(block.opcode); const blockType = (blockInfo && blockInfo.info && blockInfo.info.blockType) || BlockType.COMMAND; const substacks = {}; - if (blockType === BlockType.CONDITIONAL || blockType === BlockType.LOOP) { + if (blockType === BlockType.CONDITIONAL || blockType === BlockType.LOOP || blockType === BlockType.INLINE) { for (const inputName in block.inputs) { if (!inputName.startsWith('SUBSTACK')) continue; const branchNum = inputName === 'SUBSTACK' ? 1 : +inputName.substring('SUBSTACK'.length); @@ -1462,7 +1579,9 @@ class ScriptTreeGenerator { blockType, inputs, fields, - substacks + substacks, + breakable: block.isBreakable ?? false, + iterable: block.isIterable ?? false }; } diff --git a/src/compiler/jsexecute.js b/src/compiler/jsexecute.js index b7bff8956ad..f101ff3fa97 100644 --- a/src/compiler/jsexecute.js +++ b/src/compiler/jsexecute.js @@ -10,6 +10,7 @@ const globalState = { Timer: require('../util/timer'), Cast: require('../util/cast'), + Clone: require('../util/clone'), log: require('../util/log'), blockUtility: require('./compat-block-utility'), thread: null @@ -76,7 +77,7 @@ runtimeFunctions.waitThreads = `const waitThreads = function*(threads) { } } if (allWaiting) { - thread.status = 3; // STATUS_YIELD_TICK + thread.setStatus(3); // STATUS_YIELD_TICK } yield; @@ -112,16 +113,16 @@ const waitPromise = function*(promise) { // enter STATUS_PROMISE_WAIT and yield // this will stop script execution until the promise handlers reset the thread status // because promise handlers might execute immediately, configure thread.status here - thread.status = 1; // STATUS_PROMISE_WAIT + thread.setStatus(1); // STATUS_PROMISE_WAIT promise .then(value => { returnValue = value; - thread.status = 0; // STATUS_RUNNING + thread.setStatus(0); // STATUS_RUNNING }, error => { globalState.log.warn('Promise rejected in compiled script:', error); returnValue = '' + error; - thread.status = 0; // STATUS_RUNNING + thread.setStatus(0); // STATUS_RUNNING }); yield; @@ -136,6 +137,7 @@ const isPromise = value => ( ); const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, useFlags, blockId, branchInfo) { const thread = globalState.thread; + while(thread.status === 5 /* STATUS_PAUSED */) yield; const blockUtility = globalState.blockUtility; const stackFrame = branchInfo ? branchInfo.stackFrame : {}; @@ -152,7 +154,7 @@ const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, use }; const executeBlock = () => { - blockUtility.init(thread, blockId, stackFrame); + blockUtility.init(thread, blockId, stackFrame, branchInfo); return blockFunction(inputs, blockUtility); }; @@ -170,10 +172,13 @@ const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, use return ''; } - while (thread.status === 2 /* STATUS_YIELD */ || thread.status === 3 /* STATUS_YIELD_TICK */) { + while ( + thread.status === 2 /* STATUS_YIELD */ || + thread.status === 3 /* STATUS_YIELD_TICK */ + ) { // Yielded threads will run next iteration. if (thread.status === 2 /* STATUS_YIELD */) { - thread.status = 0; // STATUS_RUNNING + thread.setStatus(0); // STATUS_RUNNING // Yield back to the event loop when stuck or not in warp mode. if (!isWarp || isStuck()) { yield; @@ -187,6 +192,7 @@ const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, use if (isPromise(returnValue)) { returnValue = finish(yield* waitPromise(returnValue)); if (useFlags) hasResumedFromPromise = true; + while(thread.status === 5 /* STATUS_PAUSED */) yield; return returnValue; } @@ -196,6 +202,8 @@ const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, use } } + while(thread.status === 5 /* STATUS_PAUSED */) yield; + return finish(returnValue); }`; @@ -207,7 +215,8 @@ runtimeFunctions.createBranchInfo = `const createBranchInfo = (isLoop) => ({ defaultIsLoop: isLoop, isLoop: false, branch: 0, - stackFrame: {} + stackFrame: {}, + onEnd: [] });`; /** @@ -224,7 +233,7 @@ runtimeFunctions.retire = `const retire = () => { * @param {*} value The value to cast * @returns {boolean} The value cast to a boolean */ -runtimeFunctions.toBoolean = `const toBoolean = value => { +runtimeFunctions.asBoolean = `const asBoolean = value => { if (typeof value === 'boolean') { return value; } @@ -237,6 +246,23 @@ runtimeFunctions.toBoolean = `const toBoolean = value => { return !!value; }`; +/** + * Scratch cast to string + * Similar to Cast.toString() + * @param {*} value The value to cast + * @returns {string} THe value cast to a string + */ +runtimeFunctions.asString = `const asString = value => { + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return '{}'; + } + } + return "" + value; +}`; + /** * If a number is very close to a whole number, round to that whole number. * @param {number} value Value to round @@ -376,6 +402,9 @@ runtimeFunctions.distance = `const distance = menu => { if (menu === '_mouse_') { targetX = thread.target.runtime.ioDevices.mouse.getScratchX(); targetY = thread.target.runtime.ioDevices.mouse.getScratchY(); + } else if (menu === '_camera_') { + targetX = thread.target.runtime.camera.x; + targetY = thread.target.runtime.camera.y; } else { const distTarget = thread.target.runtime.getSpriteTargetByName(menu); if (!distTarget) return 10000; @@ -448,6 +477,16 @@ runtimeFunctions.listReplace = `const listReplace = (list, idx, value) => { list._monitorUpToDate = false; }`; +/** + * Set the contents in a list. + * @param {import('../engine/variable')} list The list + * @param {*} array The new contents. + */ +runtimeFunctions.listSet = `const listSet = (list, array) => { + list.value = globalState.Cast.toArray(array); + list._monitorUpToDate = false; +}`; + /** * Insert a value in a list. * @param {import('../engine/variable')} list The list. @@ -532,6 +571,15 @@ runtimeFunctions.listContents = `const listContents = list => { return list.value.join(''); }`; +/** + * Get the raw array form of a list. + * @param {import('../engine/variable')} list The list. + * @returns {string} Stringified form of the list. + */ +runtimeFunctions.listArrayContents = `const listArrayContents = list => { + return globalState.Clone.structured(list.value); +}`; + /** * Convert a color to an RGB list * @param {*} color The color value to convert @@ -590,7 +638,9 @@ runtimeFunctions.yieldThenCallGenerator = `const yieldThenCallGenerator = functi */ const execute = thread => { globalState.thread = thread; - thread.generator.next(); + if (thread.status !== 5 /* STATUS_PAUSED */) { + thread.generator.next(); + } }; const threadStack = []; diff --git a/src/compiler/jsgen.js b/src/compiler/jsgen.js index 10387a797a7..d75aebbe6c0 100644 --- a/src/compiler/jsgen.js +++ b/src/compiler/jsgen.js @@ -19,6 +19,9 @@ const {IntermediateScript, IntermediateRepresentation} = require('./intermediate const sanitize = string => { if (typeof string !== 'string') { log.warn(`sanitize got unexpected type: ${typeof string}`); + if (typeof string === 'object') { + return JSON.stringify(string); + } string = '' + string; } return JSON.stringify(string).slice(1, -1); @@ -63,6 +66,8 @@ const generatorNameVariablePool = new VariablePool('gen'); * @property {() => boolean} isNeverNumber */ +let CAST_LOGGING = false; + /** * @implements {Input} */ @@ -75,35 +80,42 @@ class TypedInput { } asNumber () { + if (CAST_LOGGING) console.log('TypedInput@asNumber', this); if (this.type === TYPE_NUMBER) return this.source; if (this.type === TYPE_NUMBER_NAN) return `(${this.source} || 0)`; return `(+${this.source} || 0)`; } asNumberOrNaN () { + if (CAST_LOGGING) console.log('TypedInput@asNumberOrNaN', this); if (this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN) return this.source; return `(+${this.source})`; } asString () { + if (CAST_LOGGING) console.log('TypedInput@asString', this); if (this.type === TYPE_STRING) return this.source; - return `("" + ${this.source})`; + return `asString(${this.source})`; } asBoolean () { + if (CAST_LOGGING) console.log('TypedInput@asBoolean', this); if (this.type === TYPE_BOOLEAN) return this.source; - return `toBoolean(${this.source})`; + return `asBoolean(${this.source})`; } asColor () { + if (CAST_LOGGING) console.log('TypedInput@asColor', this); return this.asUnknown(); } asUnknown () { + if (CAST_LOGGING) console.log('TypedInput@asUnknown', this); return this.source; } asSafe () { + if (CAST_LOGGING) console.log('TypedInput@asSafe', this); return this.asUnknown(); } @@ -130,6 +142,7 @@ class ConstantInput { } asNumber () { + if (CAST_LOGGING) console.log('ConstantInput@asNumber', this); // Compute at compilation time const numberValue = +this.constantValue; if (numberValue) { @@ -145,19 +158,23 @@ class ConstantInput { } asNumberOrNaN () { + if (CAST_LOGGING) console.log('ConstantInput@asNumberOrNaN', this); return this.asNumber(); } asString () { - return `"${sanitize('' + this.constantValue)}"`; + if (CAST_LOGGING) console.log('ConstantInput@asString', this); + return `("${sanitize('' + this.constantValue)}")`; } asBoolean () { + if (CAST_LOGGING) console.log('ConstantInput@asBoolean', this); // Compute at compilation time return Cast.toBoolean(this.constantValue).toString(); } asColor () { + if (CAST_LOGGING) console.log('ConstantInput@asColor', this); // Attempt to parse hex code at compilation time if (/^#[0-9a-f]{6,8}$/i.test(this.constantValue)) { const hex = this.constantValue.substr(1); @@ -167,11 +184,17 @@ class ConstantInput { } asUnknown () { + if (CAST_LOGGING) console.log('ConstantInput@asUnknown', this); // Attempt to convert strings to numbers if it is unlikely to break things if (typeof this.constantValue === 'number') { // todo: handle NaN? return this.constantValue; } + // We mustn't convert raw objects to strings. Blocks can handle that. + if (typeof this.constantValue === 'object') { + // todo: handle NaN? + return this.constantValue; + } const numberValue = +this.constantValue; if (numberValue.toString() === this.constantValue) { return this.constantValue; @@ -180,6 +203,7 @@ class ConstantInput { } asSafe () { + if (CAST_LOGGING) console.log('ConstantInput@asSafe', this); if (this.safe) { return this.asUnknown(); } @@ -246,35 +270,42 @@ class VariableInput { } asNumber () { + if (CAST_LOGGING) console.log('VariableInput@asNumber', this); if (this.type === TYPE_NUMBER) return this.source; if (this.type === TYPE_NUMBER_NAN) return `(${this.source} || 0)`; return `(+${this.source} || 0)`; } asNumberOrNaN () { + if (CAST_LOGGING) console.log('VariableInput@asNumberOrNaN', this); if (this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN) return this.source; return `(+${this.source})`; } asString () { + if (CAST_LOGGING) console.log('VariableInput@asString', this); if (this.type === TYPE_STRING) return this.source; - return `("" + ${this.source})`; + return `asString(${this.source})`; } asBoolean () { + if (CAST_LOGGING) console.log('VariableInput@asBoolean', this); if (this.type === TYPE_BOOLEAN) return this.source; - return `toBoolean(${this.source})`; + return `asBoolean(${this.source})`; } asColor () { + if (CAST_LOGGING) console.log('VariableInput@asColor', this); return this.asUnknown(); } asUnknown () { + if (CAST_LOGGING) console.log('VariableInput@asUnknown', this); return this.source; } asSafe () { + if (CAST_LOGGING) console.log('VariableInput@asSafe', this); return this.asUnknown(); } @@ -330,7 +361,7 @@ const isSafeConstantForEqualsOptimization = input => { * A frame contains some information about the current substack being compiled. */ class Frame { - constructor (isLoop) { + constructor (isLoop, isBreakable) { /** * Whether the current stack runs in a loop (while, for) * @type {boolean} @@ -338,11 +369,24 @@ class Frame { */ this.isLoop = isLoop; + /** + * For compatibility with StackFrame + * @type {boolean} + */ + this.isIterable = this.isLoop; + /** * Whether the current block is the last block in the stack. * @type {boolean} */ this.isLastBlock = false; + + /** + * Whether or not the current stack can be broken by continue or break + * @type {boolean} + * @readonly + */ + this.isBreakable = isLoop ? true : (isBreakable ?? false); } } @@ -433,7 +477,33 @@ class JSGenerator { case 'addons.call': return new TypedInput(`(${this.descendAddonCall(node)})`, TYPE_UNKNOWN); + case 'args.parameter': + return new TypedInput(`(thread.getParam("${node.name}") ?? 0)`, TYPE_UNKNOWN); + case 'compat': + if (node.blockType === BlockType.INLINE) { + const branchVariable = this.localVariables.next(); + const returnVariable = this.localVariables.next(); + let source = '(yield* (function*() {\n'; + source += `let ${returnVariable} = undefined;\n`; + source += `const ${branchVariable} = createBranchInfo(false);\n`; + source += `${returnVariable} = (${this.generateCompatibilityLayerCall(node, false, branchVariable)});\n`; + source += `${branchVariable}.branch = globalState.blockUtility._startedBranch[0];\n`; + source += `switch (${branchVariable}.branch) {\n`; + for (const index in node.substacks) { + source += `case ${+index}: {\n`; + const _frame = new Frame(false, node.breakable); + _frame.isIterable = node.iterable; + source += this.descendStackForSource(node.substacks[index], _frame); + source += `break;\n`; + source += `}\n`; // close case + } + source += '}\n'; // close switch + source += `if (${branchVariable}.onEnd[0]) yield ${branchVariable}.onEnd.shift()(${branchVariable});\n`; + source += `return ${returnVariable};\n`; + source += '})())'; // close function and yield + return new TypedInput(source, TYPE_UNKNOWN); + } // Compatibility layer inputs never use flags. return new TypedInput(`(${this.generateCompatibilityLayerCall(node, false)})`, TYPE_UNKNOWN); @@ -450,6 +520,8 @@ class JSGenerator { return new TypedInput(`listContains(${this.referenceVariable(node.list)}, ${this.descendInput(node.item).asUnknown()})`, TYPE_BOOLEAN); case 'list.contents': return new TypedInput(`listContents(${this.referenceVariable(node.list)})`, TYPE_STRING); + case 'list.arraycontents': + return new TypedInput(`listArrayContents(${this.referenceVariable(node.list)})`, TYPE_STRING); case 'list.get': { const index = this.descendInput(node.index); if (environment.supportsNullishCoalescing) { @@ -480,6 +552,8 @@ class JSGenerator { case 'motion.direction': return new TypedInput('target.direction', TYPE_NUMBER); + case 'motion.rotationStyle': + return new TypedInput('target.rotationStyle', TYPE_NUMBER); case 'motion.x': return new TypedInput('limitPrecision(target.x)', TYPE_NUMBER); case 'motion.y': @@ -512,6 +586,8 @@ class JSGenerator { return new TypedInput(`((Math.atan(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER); case 'op.ceiling': return new TypedInput(`Math.ceil(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); + case 'op.clamp': + return new TypedInput(`Math.min(Math.max(${this.descendInput(node.num).asNumber()}, ${this.descendInput(node.left).asNumber()}), ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER); case 'op.contains': return new TypedInput(`(${this.descendInput(node.string).asString()}.toLowerCase().indexOf(${this.descendInput(node.contains).asString()}.toLowerCase()) !== -1)`, TYPE_BOOLEAN); case 'op.cos': @@ -542,6 +618,8 @@ class JSGenerator { // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareEqual(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } + case 'op.exponent': + return new TypedInput(`(${this.descendInput(node.left).asNumber()} ** ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER); case 'op.e^': return new TypedInput(`Math.exp(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.floor': @@ -564,6 +642,11 @@ class JSGenerator { // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareGreaterThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } + case 'op.greaterEqual': { + const left = this.descendInput(node.left); + const right = this.descendInput(node.right); + return new TypedInput(`(${left.asUnknown()} >= ${right.asUnknown()})`, TYPE_BOOLEAN); + } case 'op.join': return new TypedInput(`(${this.descendInput(node.left).asString()} + ${this.descendInput(node.right).asString()})`, TYPE_STRING); case 'op.length': @@ -586,8 +669,15 @@ class JSGenerator { // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareLessThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } - case 'op.letterOf': - return new TypedInput(`((${this.descendInput(node.string).asString()})[(${this.descendInput(node.letter).asNumber()} | 0) - 1] || "")`, TYPE_STRING); + case 'op.lessEqual': { + const left = this.descendInput(node.left); + const right = this.descendInput(node.right); + return new TypedInput(`(${left.asUnknown()} <= ${right.asUnknown()})`, TYPE_BOOLEAN); + } + // case 'op.letterOf': + // return new TypedInput(`((${this.descendInput(node.string).asString()})[(${this.descendInput(node.letter).asNumber()} | 0) - 1] || "")`, TYPE_STRING); + case 'op.lettersOf': + return new TypedInput(`((${this.descendInput(node.string).asString()}).substring(${this.descendInput(node.left).asNumber() - 1}, ${this.descendInput(node.right).asNumber()}) || "")`, TYPE_STRING); case 'op.ln': // Needs to be marked as NaN because Math.log(-1) == NaN return new TypedInput(`Math.log(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN); @@ -598,6 +688,10 @@ class JSGenerator { this.descendedIntoModulo = true; // Needs to be marked as NaN because mod(0, 0) (and others) == NaN return new TypedInput(`mod(${this.descendInput(node.left).asNumber()}, ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); + case 'op.min': + return new TypedInput(`Math.min(${this.descendInput(node.left).asNumber()}, ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER); + case 'op.max': + return new TypedInput(`Math.max(${this.descendInput(node.left).asNumber()}, ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER); case 'op.multiply': // Needs to be marked as NaN because Infinity * 0 === NaN return new TypedInput(`(${this.descendInput(node.left).asNumber()} * ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); @@ -605,6 +699,8 @@ class JSGenerator { return new TypedInput(`!${this.descendInput(node.operand).asBoolean()}`, TYPE_BOOLEAN); case 'op.or': return new TypedInput(`(${this.descendInput(node.left).asBoolean()} || ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN); + case 'op.xor': + return new TypedInput(`(${this.descendInput(node.left).asBoolean()} !== ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN); case 'op.random': if (node.useInts) { // Both inputs are ints, so we know neither are NaN @@ -674,7 +770,7 @@ class JSGenerator { case 'sensing.daysSince2000': return new TypedInput('daysSince2000()', TYPE_NUMBER); case 'sensing.distance': - // TODO: on stages, this can be computed at compile time + // TODO: on stages and invalid values, this can be computed at compile time return new TypedInput(`distance(${this.descendInput(node.target).asString()})`, TYPE_NUMBER); case 'sensing.hour': return new TypedInput(`(new Date().getHours())`, TYPE_NUMBER); @@ -733,6 +829,27 @@ class JSGenerator { case 'sensing.year': return new TypedInput(`(new Date().getFullYear())`, TYPE_NUMBER); + case 'str.exactly': + return new TypedInput(`(${this.descendInput(node.left).asUnknown()} === ${this.descendInput(node.right).asUnknown()})`, TYPE_UNKNOWN); + case 'str.is': { + const str = this.descendInput(node.left).asString(); + if (node.right.toLowerCase() === 'uppercase') { + return new TypedInput(`${str.toUpperCase() === str}`, TYPE_BOOLEAN); + } + return new TypedInput(`${str.toLowerCase() === str}`, TYPE_BOOLEAN); + } + case 'str.repeat': + return new TypedInput(`(${this.descendInput(node.str).asString()}.repeat(${this.descendInput(node.num).asNumber()}))`, TYPE_STRING); + case 'str.replace': + return new TypedInput(`${this.descendInput(node.str).asString()}.replace(new RegExp(${this.descendInput(node.left).asString()}, "gi"), ${this.descendInput(node.right).asString()})`, TYPE_STRING); + case 'str.reverse': + return new TypedInput(`${this.descendInput(node.str).asString()}.split("").reverse().join("");`, TYPE_STRING); + + case 'camera.x': + return new TypedInput('runtime.camera.x', TYPE_NUMBER); + case 'camera.y': + return new TypedInput('runtime.camera.y', TYPE_NUMBER); + case 'timer.get': return new TypedInput('runtime.ioDevices.clock.projectTimer()', TYPE_NUMBER); @@ -772,11 +889,14 @@ class JSGenerator { this.source += `switch (${branchVariable}.branch) {\n`; for (const index in node.substacks) { this.source += `case ${+index}: {\n`; - this.descendStack(node.substacks[index], new Frame(false)); + const _frame = new Frame(false, node.breakable); + _frame.isIterable = node.iterable; + this.descendStack(node.substacks[index], _frame); this.source += `break;\n`; this.source += `}\n`; // close case } this.source += '}\n'; // close switch + this.source += `if (${branchVariable}.onEnd[0]) yield ${branchVariable}.onEnd.shift()(${branchVariable});\n`; this.source += `if (!${branchVariable}.isLoop) break;\n`; this.yieldLoop(); this.source += '}\n'; // close while @@ -841,6 +961,16 @@ class JSGenerator { case 'control.stopScript': this.stopScript(); break; + case 'control.break': + if (this.frames.find(frame => + frame.isLoop || + frame.isBreakable || + frame.isIterable + )) this.source += 'break;\n'; + break; + case 'control.continue': + if (this.frames.find(frame => frame.isLoop || frame.isIterable)) this.source += 'continue;\n'; + break; case 'control.wait': { const duration = this.localVariables.next(); this.source += `thread.timer = timer();\n`; @@ -873,6 +1003,15 @@ class JSGenerator { this.source += `}\n`; break; + case 'control.allAtOnce': { + // eslint-disable-next-line no-case-declarations + const previousWarp = this.isWarp; + this.isWarp = true; + this.descendStack(node.code, new Frame(false)); + this.isWarp = previousWarp; + break; + } + case 'counter.clear': this.source += 'runtime.ext_scratch3_control._counter = 0;\n'; break; @@ -915,9 +1054,12 @@ class JSGenerator { this.yielded(); break; + // TODO: Lists that are locked don't need their monitors updated. + // In fact, they don't need to be executed at all, since right + // now, locked is a static value that cannot be changed. case 'list.add': { const list = this.referenceVariable(node.list); - this.source += `${list}.value.push(${this.descendInput(node.item).asSafe()});\n`; + this.source += `if (!${list}.locked) ${list}.value.push(${this.descendInput(node.item).asSafe()});\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } @@ -926,12 +1068,12 @@ class JSGenerator { const index = this.descendInput(node.index); if (index instanceof ConstantInput) { if (index.constantValue === 'last') { - this.source += `${list}.value.pop();\n`; + this.source += `if (!${list}.locked) ${list}.value.pop();\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } if (+index.constantValue === 1) { - this.source += `${list}.value.shift();\n`; + this.source += `if (!${list}.locked) ${list}.value.shift();\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } @@ -940,9 +1082,11 @@ class JSGenerator { this.source += `listDelete(${list}, ${index.asUnknown()});\n`; break; } - case 'list.deleteAll': - this.source += `${this.referenceVariable(node.list)}.value = [];\n`; + case 'list.deleteAll': { + const list = this.referenceVariable(node.list); + this.source += `if (!${list}.locked) ${list}.value = [];\n`; break; + } case 'list.hide': this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.list.id)}", element: "checkbox", value: false }, runtime);\n`; break; @@ -951,16 +1095,23 @@ class JSGenerator { const index = this.descendInput(node.index); const item = this.descendInput(node.item); if (index instanceof ConstantInput && +index.constantValue === 1) { - this.source += `${list}.value.unshift(${item.asSafe()});\n`; + this.source += `if (!${list}.locked) ${list}.value.unshift(${item.asSafe()});\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } - this.source += `listInsert(${list}, ${index.asUnknown()}, ${item.asSafe()});\n`; + this.source += `if (!${list}.locked) listInsert(${list}, ${index.asUnknown()}, ${item.asSafe()});\n`; + break; + } + case 'list.replace': { + const list = this.referenceVariable(node.list); + this.source += `if (!${list}.locked) listReplace(${list}, ${this.descendInput(node.index).asUnknown()}, ${this.descendInput(node.item).asSafe()});\n`; break; } - case 'list.replace': - this.source += `listReplace(${this.referenceVariable(node.list)}, ${this.descendInput(node.index).asUnknown()}, ${this.descendInput(node.item).asSafe()});\n`; + case 'list.set': { + const list = this.referenceVariable(node.list); + this.source += `if (!${list}.locked) listSet(${list}, ${this.descendInput(node.array).asUnknown()});\n`; break; + } case 'list.show': this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.list.id)}", element: "checkbox", value: true }, runtime);\n`; break; @@ -1206,6 +1357,16 @@ class JSGenerator { this.popFrame(); } + descendStackForSource (nodes, frame) { + // Wrapper for descendStack to get the source + const oldSource = this.source; + this.source = ''; + this.descendStack(nodes, frame); + const stackSource = this.source; + this.source = oldSource; + return stackSource; + } + descendVariable (variable) { if (Object.prototype.hasOwnProperty.call(this.variableInputs, variable.id)) { return this.variableInputs[variable.id]; @@ -1447,7 +1608,10 @@ JSGenerator.unstable_exports = { ConstantInput, VariableInput, Frame, - sanitize + sanitize, + set CAST_LOGGING (value) { + CAST_LOGGING = Boolean(value); + } }; // Test hook used by automated snapshot testing. diff --git a/src/engine/block-utility.js b/src/engine/block-utility.js index 52384c5eca2..595ec7fe86c 100644 --- a/src/engine/block-utility.js +++ b/src/engine/block-utility.js @@ -105,14 +105,14 @@ class BlockUtility { * Set the thread to yield. */ yield () { - this.thread.status = Thread.STATUS_YIELD; + this.thread.setStatus(Thread.STATUS_YIELD); } /** * Set the thread to yield until the next tick of the runtime. */ yieldTick () { - this.thread.status = Thread.STATUS_YIELD_TICK; + this.thread.setStatus(Thread.STATUS_YIELD_TICK); } /** @@ -202,15 +202,16 @@ class BlockUtility { * @param {!string} requestedHat Opcode of hats to start. * @param {object=} optMatchFields Optionally, fields to match on the hat. * @param {Target=} optTarget Optionally, a target to restrict to. + * @param {Target=} optParams Optionally, parameters to push onto the hat. * @return {Array.} List of threads started by this function. */ - startHats (requestedHat, optMatchFields, optTarget) { + startHats (requestedHat, optMatchFields, optTarget, optParams) { // Store thread and sequencer to ensure we can return to the calling block's context. // startHats may execute further blocks and dirty the BlockUtility's execution context // and confuse the calling block when we return to it. const callerThread = this.thread; const callerSequencer = this.sequencer; - const result = this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget); + const result = this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget, optParams); // Restore thread and sequencer to prior values before we return to the calling block. this.thread = callerThread; diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 71ace3a205f..2c017fff9f5 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -477,6 +477,12 @@ class Blocks { if (e.isLocal && editingTarget && !editingTarget.isStage && !e.isCloud) { if (!editingTarget.lookupVariableById(e.varId)) { editingTarget.createVariable(e.varId, e.varName, e.varType); + + this.runtime.addPendingMonitor(e.varId); + + // TODO this should probably be batched + // (esp. if we receive multiple new var_creates in a row). + this.runtime.requestToolboxExtensionsUpdate(); this.emitProjectChanged(); } } else { @@ -492,6 +498,12 @@ class Blocks { } } stage.createVariable(e.varId, e.varName, e.varType, e.isCloud); + + this.runtime.addPendingMonitor(e.varId); + + // TODO same as above, this should probably be batched + // (esp. if we receive multiple new var_creates in a row). + this.runtime.requestToolboxExtensionsUpdate(); this.emitProjectChanged(); } break; @@ -655,6 +667,20 @@ class Blocks { this._addScript(block.id); } + // A block was just created, see if it had an associated pending monitor, + // if so, keep track of the monitor state change by mimicing the checkbox + // event from the flyout. Clear record of this block from + // the pending monitors list. + if (this === this.runtime.monitorBlocks && + this.runtime.getPendingMonitor(block.id)) { + this.changeBlock({ + id: block.id, // Monitor blocks for variables are the variable ID. + element: 'checkbox', // Mimic checkbox event from flyout. + value: true + }, this.runtime); + this.runtime.removePendingMonitor(block.id); + } + this.resetCache(); // A new block was actually added to the block container, @@ -685,15 +711,7 @@ class Blocks { // Update block value if (!block.fields[args.name]) return; - if (args.name === 'VARIABLE' || args.name === 'LIST' || - args.name === 'BROADCAST_OPTION') { - // Get variable name using the id in args.value. - const variable = this.runtime.getEditingTarget().lookupVariableById(args.value); - if (variable) { - block.fields[args.name].value = variable.name; - block.fields[args.name].id = args.value; - } - } else { + if (typeof block.fields[args.name].variableType === 'undefined') { // Changing the value in a dropdown block.fields[args.name].value = args.value; @@ -716,6 +734,13 @@ class Blocks { params: this._getBlockParams(flyoutBlock) })); } + } else { + // Get variable name using the id in args.value. + const variable = this.runtime.getEditingTarget().lookupVariableById(args.value); + if (variable) { + block.fields[args.name].value = variable.name; + block.fields[args.name].id = args.value; + } } break; case 'mutation': @@ -726,7 +751,8 @@ class Blocks { // block but in the case of monitored reporters that have arguments, // map the old id to a new id, creating a new monitor block if necessary if (block.fields && Object.keys(block.fields).length > 0 && - block.opcode !== 'data_variable' && block.opcode !== 'data_listcontents') { + block.opcode !== 'data_variable' && block.opcode !== 'data_listcontents' && + block.opcode !== 'data_listarraycontents') { // This block has an argument which needs to get separated out into // multiple monitor blocks with ids based on the selected argument @@ -754,6 +780,8 @@ class Blocks { isSpriteLocalVariable = !(this.runtime.getTargetForStage().variables[block.fields.VARIABLE.id]); } else if (block.opcode === 'data_listcontents') { isSpriteLocalVariable = !(this.runtime.getTargetForStage().variables[block.fields.LIST.id]); + } else if (block.opcode === 'data_listarraycontents') { + isSpriteLocalVariable = !(this.runtime.getTargetForStage().variables[block.fields.LIST.id]); } const isSpriteSpecific = isSpriteLocalVariable || @@ -781,7 +809,11 @@ class Blocks { params: this._getBlockParams(block), // @todo(vm#565) for numerical values with decimals, some countries use comma value: '', - mode: block.opcode === 'data_listcontents' ? 'list' : 'default' + // @todo let extensions use list monitors! + mode: ( + block.opcode === 'data_listcontents' || + block.opcode === 'data_listarraycontents' + ) ? 'list' : 'default' })); } } @@ -938,6 +970,41 @@ class Blocks { this.emitProjectChanged(); } + getAllReferencesForVariable (variable) { + let fieldName; + let truncatedOpcode; + if (variable.type === Variable.SCALAR_TYPE) { + fieldName = 'VARIABLE'; + truncatedOpcode = 'variable'; + } else if (variable.type === Variable.LIST_TYPE) { + fieldName = 'LIST'; + truncatedOpcode = 'listarraycontents'; + } else { + // TODO handle broadcast messages later + return []; + } + + const variableBlocks = []; + for (const blockId in this._blocks) { + if (!Object.prototype.hasOwnProperty.call(this._blocks, blockId)) continue; + const block = this._blocks[blockId]; + // Check for blocks with fields referencing variable/list, otherwise variable/list reporters + if (block.fields[fieldName] && + block.fields[fieldName].value === variable.name) { + // It's a block containing a variable field whose currently selected value + // matches the given variable name + variableBlocks.push(block); + } else if (block.mutation && + block.mutation.blockInfo && + block.mutation.blockInfo.opcode === truncatedOpcode && + block.mutation.blockInfo.text === variable.name) { + // It's a variable reporter whose name matches the given variable + variableBlocks.push(block); + } + } + return variableBlocks; + } + /** * Delete all blocks and their associated scripts. */ @@ -1223,25 +1290,33 @@ class Blocks { xmlString += ''; } } - // Add any fields on this block. - for (const field in block.fields) { - if (!Object.prototype.hasOwnProperty.call(block.fields, field)) continue; - const blockField = block.fields[field]; - xmlString += `${value}`; } - xmlString += `>${value}`; } + // Add blocks connected to the next connection. if (block.next) { xmlString += `${this.blockToXML(block.next, comments)}`; diff --git a/src/engine/camera.js b/src/engine/camera.js new file mode 100644 index 00000000000..bd4d22f18ff --- /dev/null +++ b/src/engine/camera.js @@ -0,0 +1,140 @@ +const EventEmitter = require('events'); + +const Cast = require('../util/cast'); +const MathUtil = require('../util/math-util'); + +/** + * @fileoverview + * The camera is an arbitrary object used to + * describe properties of the renderer projection. + */ + +/** + * Camera: instance of a camera object on the stage. + */ +class Camera extends EventEmitter { + constructor (runtime) { + super(); + + this.runtime = runtime; + + /** + * Scratch X coordinate. Currently should range from -240 to 240. + * @type {Number} + */ + this.x = 0; + + /** + * Scratch Y coordinate. Currently should range from -180 to 180. + * @type {number} + */ + this.y = 0; + + /** + * Scratch direction. Currently should range from -179 to 180. + * @type {number} + */ + this.direction = 90; + + /** + * Zoom of camera as a percentage. Similar to size. + * @type {number} + */ + this.zoom = 100; + + /** + * Determines whether the camera values will affect the projection. + * @type {boolean} + */ + this.enabled = true; + + /** + * Interpolation data used by tw-interpolate. + */ + this.interpolationData = null; + } + + /** + * Event name for the camera updating. + * @const {string} + */ + static get CAMERA_UPDATE () { + return 'CAMERA_UPDATE'; + } + + /** + * Set the X and Y values of the camera. + * @param x The x coordinate. + * @param y The y coordinate. + */ + setXY (x, y) { + this.x = Cast.toNumber(x); + this.y = Cast.toNumber(y); + + this.emitCameraUpdate(); + } + + /** + * Set the zoom of the camera. + * @param zoom The new zoom value. + */ + setZoom (zoom) { + this.zoom = Cast.toNumber(zoom); + if (this.runtime.runtimeOptions.miscLimits) { + this.zoom = MathUtil.clamp(this.zoom, 10, 300); + } + + this.emitCameraUpdate(); + } + + /** + * Point the camera towards a given direction. + * @param direction Direction to point the camera. + */ + setDirection (direction) { + if (!isFinite(direction)) return; + + this.direction = MathUtil.wrapClamp(direction, -179, 180); + + this.emitCameraUpdate(); + } + + /** + * Set whether the camera will affect the projection. + * @param enabled The new enabled state. + */ + setEnabled (enabled) { + this.enabled = enabled; + } + + /** + * Tell the renderer to update the rendered camera state. + */ + emitCameraUpdate () { + if (!this.runtime.renderer) return; + + this.runtime.renderer._updateCamera( + this.x, + this.y, + this.direction, + this.zoom + ); + + this.runtime.emit('CAMERA_UPDATE', this); + + this.runtime.requestRedraw(); + } + + /** + * Reset all camera properties. + */ + reset () { + this.x = 0; + this.y = 0; + this.direction = 90; + this.zoom = 100; + this.enabled = true; + } +} + +module.exports = Camera; diff --git a/src/engine/execute.js b/src/engine/execute.js index 8cb192f0938..8750d7a3c58 100644 --- a/src/engine/execute.js +++ b/src/engine/execute.js @@ -61,7 +61,7 @@ const handleReport = function (resolvedValue, sequencer, thread, blockCached, la if (isHat) { // Hat predicate was evaluated. if (thread.stackClick) { - thread.status = Thread.STATUS_RUNNING; + thread.setStatus(Thread.STATUS_RUNNING); } else if (sequencer.runtime.getIsEdgeActivatedHat(opcode)) { // If this is an edge-activated hat, only proceed if the value is // true and used to be false, or the stack was activated explicitly @@ -74,13 +74,13 @@ const handleReport = function (resolvedValue, sequencer, thread, blockCached, la const edgeWasActivated = hasOldEdgeValue ? (!oldEdgeValue && resolvedValue) : resolvedValue; if (edgeWasActivated) { - thread.status = Thread.STATUS_RUNNING; + thread.setStatus(Thread.STATUS_RUNNING); } else { sequencer.retireThread(thread); } } else if (resolvedValue) { // Predicate returned true: allow the script to run. - thread.status = Thread.STATUS_RUNNING; + thread.setStatus(Thread.STATUS_RUNNING); } else { // Predicate returned false: do not allow script to run sequencer.retireThread(thread); @@ -108,7 +108,7 @@ const handleReport = function (resolvedValue, sequencer, thread, blockCached, la } } // Finished any yields. - thread.status = Thread.STATUS_RUNNING; + thread.setStatus(Thread.STATUS_RUNNING); } }; @@ -144,7 +144,7 @@ const handlePromiseResolution = (resolvedValue, sequencer, thread, blockCached, const handlePromise = (primitiveReportedValue, sequencer, thread, blockCached, lastOperation) => { if (thread.status === Thread.STATUS_RUNNING) { // Primitive returned a promise; automatically yield thread. - thread.status = Thread.STATUS_PROMISE_WAIT; + thread.setStatus(Thread.STATUS_PROMISE_WAIT); } // Promise handlers primitiveReportedValue.then(resolvedValue => { @@ -313,17 +313,13 @@ class BlockCached { // Store the static fields onto _argValues. for (const fieldName in fields) { - if ( - fieldName === 'VARIABLE' || - fieldName === 'LIST' || - fieldName === 'BROADCAST_OPTION' - ) { + if (typeof fields[fieldName].variableType === 'undefined') { + this._argValues[fieldName] = fields[fieldName].value; + } else { this._argValues[fieldName] = { id: fields[fieldName].id, name: fields[fieldName].value }; - } else { - this._argValues[fieldName] = fields[fieldName].value; } } diff --git a/src/engine/monitor-record.js b/src/engine/monitor-record.js index 1259d525290..a117f32bb1d 100644 --- a/src/engine/monitor-record.js +++ b/src/engine/monitor-record.js @@ -17,6 +17,7 @@ const MonitorRecord = Record({ y: null, width: 0, height: 0, + locked: false, visible: true }); diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 0f0c74a86f3..dfbce08283e 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -24,6 +24,8 @@ const FontManager = require('./tw-font-manager'); const fetchWithTimeout = require('../util/fetch-with-timeout'); const platform = require('./tw-platform.js'); +const CORE_BLOCKS = []; + // Virtual I/O devices. const Clock = require('../io/clock'); const Cloud = require('../io/cloud'); @@ -42,14 +44,18 @@ const defaultBlockPackages = { scratch3_looks: require('../blocks/scratch3_looks'), scratch3_motion: require('../blocks/scratch3_motion'), scratch3_operators: require('../blocks/scratch3_operators'), + scratch3_string: require('../blocks/scratch3_string'), scratch3_sound: require('../blocks/scratch3_sound'), scratch3_sensing: require('../blocks/scratch3_sensing'), + scratch3_camera: require('../blocks/scratch3_camera'), scratch3_data: require('../blocks/scratch3_data'), scratch3_procedures: require('../blocks/scratch3_procedures') }; const interpolate = require('./tw-interpolate'); const FrameLoop = require('./tw-frame-loop'); +const Camera = require('./camera'); +const Cast = require('../util/cast.js'); const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69']; @@ -95,6 +101,12 @@ const ArgumentTypeMap = (() => { map[ArgumentType.BOOLEAN] = { check: 'Boolean' }; + map[ArgumentType.ARRAY] = { + check: 'Array' + }; + map[ArgumentType.OBJECT] = { + check: 'Object' + }; map[ArgumentType.MATRIX] = { shadow: { type: 'matrix', @@ -124,6 +136,37 @@ const ArgumentTypeMap = (() => { fieldName: 'SOUND_MENU' } }; + map[ArgumentType.VARIABLE] = { + fieldType: 'field_variable', + fieldName: 'VARIABLE' + }; + map[ArgumentType.LABEL] = { + fieldType: 'field_label_serializable', + fieldName: 'LABEL' + }; + map[ArgumentType.PARAMETER] = { + shadow: { + type: 'argument_reporter_string_number', + fieldName: 'VALUE' + } + }; + return map; +})(); + +const FieldTypeMap = (() => { + const map = {}; + map[ArgumentType.ANGLE] = { + fieldName: 'field_angle' + }; + map[ArgumentType.NUMBER] = { + fieldName: 'field_number' + }; + map[ArgumentType.STRING] = { + fieldName: 'field_input' + }; + map[ArgumentType.NOTE] = { + fieldName: 'field_note' + }; return map; })(); @@ -328,6 +371,12 @@ class Runtime extends EventEmitter { */ this._prevMonitorState = OrderedMap({}); + /** + * Track any monitors to be added that don't have corresponding monitor blocks + * created yet. + */ + this._pendingMonitors = new Set(); + /** * Whether the project is in "turbo mode." * @type {Boolean} @@ -456,10 +505,12 @@ class Runtime extends EventEmitter { this.stageWidth = Runtime.STAGE_WIDTH; this.stageHeight = Runtime.STAGE_HEIGHT; + this.camera = new Camera(this); + this.runtimeOptions = { maxClones: Runtime.MAX_CLONES, - miscLimits: true, - fencing: true + miscLimits: false, + fencing: false }; this.compilerOptions = { @@ -534,6 +585,45 @@ class Runtime extends EventEmitter { * Total number of finished or errored scratch-storage load() requests since the runtime was created or cleared. */ this.finishedAssetRequests = 0; + + /** + * Whether or not the project is currently paused + */ + this.paused = false; + + /** + * The audio settings (runtime) + */ + this.audioSettings = { + muted: false, + volume: 1 + }; + + /** + * A temporary storage area that gets cleared when the project starts or stops + */ + this.temporaryStorage = {}; + this.on(Runtime.PROJECT_START, () => { + this.temporaryStorage = {}; + }); + this.on(Runtime.PROJECT_STOP_ALL, () => { + this.temporaryStorage = {}; + }); + + /** + * Export some internal values for extensions. + */ + this.exports = { + ExtendedJSON, + i_will_not_ask_for_help_when_these_break: () => { + console.warn('You are using unsupported APIs. WHEN your code breaks, do not expect help.'); + return ({ + ScratchBlocksConstants, + ArgumentTypeMap, + FieldTypeMap + }); + } + }; } /** @@ -690,6 +780,14 @@ class Runtime extends EventEmitter { return 'PROJECT_START'; } + /** + * Event name when the project is paused + * @const {string} + */ + static get PROJECT_PAUSE () { + return 'PROJECT_PAUSE'; + } + /** * Event name when threads start running. * Used by the UI to indicate running status. @@ -717,6 +815,14 @@ class Runtime extends EventEmitter { return 'PROJECT_STOP_ALL'; } + /** + * Event name for when the volume is changed + * @const {string} + */ + static get VOLUME_CHANGE () { + return 'VOLUME_CHANGE'; + } + /** * Event name for target being stopped by a stop for target call. * Used by blocks that need to stop individual targets. @@ -758,6 +864,14 @@ class Runtime extends EventEmitter { return 'TOOLBOX_EXTENSIONS_NEED_UPDATE'; } + /** + * Event name for camera update report. + * @const {string} + */ + static get CAMERA_UPDATE () { + return 'CAMERA_UPDATE'; + } + /** * Event name for targets update report. * @const {string} @@ -799,13 +913,21 @@ class Runtime extends EventEmitter { } /** - * Event name for reporting that an extension as asked for a custom field to be added + * Event name for reporting that an extension has asked for a custom field to be added * @const {string} */ static get EXTENSION_FIELD_ADDED () { return 'EXTENSION_FIELD_ADDED'; } + /** + * Event name for reporting that an extension has asked for a custom shape type to be added + * @const {string} + */ + static get EXTENSION_SHAPE_ADDED () { + return 'EXTENSION_SHAPE_TYPE_ADDED'; + } + /** * Event name for updating the available set of peripheral devices. * This causes the peripheral connection modal to update a list of @@ -886,6 +1008,10 @@ class Runtime extends EventEmitter { return 'BLOCKSINFO_UPDATE'; } + static get BLOCK_UPDATE () { + return 'BLOCK_UPDATE'; + } + /** * Event name when the runtime tick loop has been started. * @const {string} @@ -1087,6 +1213,17 @@ class Runtime extends EventEmitter { } } + for (const blockShapeName in categoryInfo.customShapes) { + if (Object.prototype.hasOwnProperty.call(extensionInfo.customShapes, blockShapeName)) { + const blockShapeInfo = categoryInfo.customShapes[blockShapeName]; + + // Emit events for custom shape types from extension + this.emit(Runtime.EXTENSION_SHAPE_ADDED, { + implementation: blockShapeInfo + }); + } + } + this.emit(Runtime.EXTENSION_ADDED, categoryInfo); } @@ -1115,8 +1252,10 @@ class Runtime extends EventEmitter { _fillExtensionCategory (categoryInfo, extensionInfo) { categoryInfo.blocks = []; categoryInfo.customFieldTypes = {}; + categoryInfo.customShapes = {}; categoryInfo.menus = []; categoryInfo.menuInfo = {}; + categoryInfo.convertedMenuInfo = {}; for (const menuName in extensionInfo.menus) { if (Object.prototype.hasOwnProperty.call(extensionInfo.menus, menuName)) { @@ -1124,6 +1263,13 @@ class Runtime extends EventEmitter { const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuInfo, categoryInfo); categoryInfo.menus.push(convertedMenu); categoryInfo.menuInfo[menuName] = menuInfo; + // Use the convertedMenu and `menuInfo` to consolidate + // the information needed to layout the menu correctly + // on a dynamic extension block. + categoryInfo.convertedMenuInfo[menuName] = { + items: Array.isArray(menuInfo.items) ? convertedMenu.json.args0[0].options : menuInfo.items, + acceptReporters: menuInfo.acceptReporters || false + }; } } for (const fieldTypeName in extensionInfo.customFieldTypes) { @@ -1139,6 +1285,12 @@ class Runtime extends EventEmitter { categoryInfo.customFieldTypes[fieldTypeName] = fieldTypeInfo; } } + for (const blockShapeName in extensionInfo.customShapes) { + if (Object.prototype.hasOwnProperty.call(extensionInfo.customShapes, blockShapeName)) { + const shapeType = extensionInfo.customShapes[blockShapeName]; + categoryInfo.customShapes[blockShapeName] = shapeType; + } + } if (extensionInfo.docsURI) { const xml = '